{# Manual de ChartPython. Cada ejemplo va en un bloque "call py()" y es la única copia de ese código: de acá salen la página, el notebook de práctica (/chartpython/manual.ipynb, ver web/chartpython_manual.py) y la prueba que ejecuta todos los bloques en orden con el mismo Pyodide que usa ChartPython (quantbay/tests/chartpython/manual.test.ts). Si un ejemplo deja de andar o su salida cambia, esa prueba falla. Reglas de los bloques: - El código va pegado al margen izquierdo, sin la sangría del HTML. - Los bloques se ejecutan de arriba hacia abajo en un mismo intérprete: un ejemplo puede usar variables de los anteriores. - Un "call salida()" justo después de un bloque es su salida esperada: lo que imprime y el valor de la última línea, en texto. - py(error="KeyError") marca un ejemplo que falla a propósito; su salida va en "call salida_error()". py(aviso="FutureWarning") espera ese aviso. - py(ejecutar=false) no se ejecuta ni va al notebook (un bucle infinito). - Nada de llaves dobles ni llave con porcentaje dentro del código: Jinja las interpretaría. #} {# forceescape: el texto de un bloque "call" llega como Markup y escape no lo tocaría. La salida conserva los espacios del principio de su primera línea (el encabezado de una tabla de pandas empieza con espacios). #} {% macro py(error=none, aviso=none, ejecutar=true) -%}
{%- if error %}
Este ejemplo da error a propósito
{% endif -%}
{{ caller().strip()|forceescape }}
{%- endmacro %} {% macro salida() -%}
Salida
{{ caller().lstrip("\n").rstrip()|forceescape }}
{%- endmacro %} {% macro salida_error() -%}
Error
{{ caller().lstrip("\n").rstrip()|forceescape }}
{%- endmacro %} {% macro ancla(id) %}#{% endmacro %} ChartBay · Manual de ChartPython {% set active = 'chartpython' %} {% include '_topbar_v2.html' with context %}

ChartPython · Manual de uso

Manual de ChartPython

Cómo usar el notebook y los comandos de Python que más se usan para limpiar un CSV y analizar datos. Cada ejemplo de esta página se ejecuta en las pruebas de ChartBay con el mismo Python que corre en tu navegador, así que lo que ves acá funciona allá.

Parte 1

Usar ChartPython

ChartPython es un notebook parecido a Jupyter que corre entero en tu navegador. Escribís código en celdas, lo ejecutás y el resultado aparece debajo. Python y sus paquetes viven en la página: no hay que instalar nada y los archivos que subís no llegan a ningún servidor.

Primeros pasos{{ ancla('primeros-pasos') }}

  1. Entrá a ChartPython. Arranca con un notebook que tiene una celda vacía.
  2. Hacé clic en la celda, escribí 2 + 2 y apretá Shift+Enter.
  3. La primera vez tarda: el navegador descarga Python y sus paquetes, unos 115 MB. Quedan en la caché, así que las próximas veces arranca en pocos segundos.
  4. El resultado aparece debajo de la celda, marcado con Out[1]:.
{% call py() %} 2 + 2 {% endcall %} {% call salida() %} 4 {% endcall %}

Debajo del notebook, la barra de estado cuenta qué está haciendo Python: Python apagado (todavía no arrancó), Preparando Python…, Python listo, Ejecutando… con los segundos que lleva, y Python se detuvo si algo lo cortó. También muestra la memoria que usa y la hora del último guardado.

Celdas y atajos de teclado{{ ancla('celdas') }}

Hay dos tipos de celda: de código, que ejecuta Python, y de texto, para escribir notas con formato. Se agregan con los botones + Código y + Texto.

Las celdas comparten las variables: lo que definís en una se puede usar en las demás. Lo que importa es el orden en que las ejecutaste, no dónde están en la página. El número de In [3]: dice cuándo corrió cada una, y In [*]: que está esperando o corriendo.

{% call py() %} precio = 3450 cantidad = 2 {% endcall %} {% call py() %} precio * cantidad {% endcall %} {% call salida() %} 6900 {% endcall %}

Debajo de la celda se ve el valor de la última línea. Para ver algo del medio usá print(), y para mostrar varias tablas en una misma celda, display(), que ya está disponible sin importar nada. Un punto y coma al final de la última línea oculta su valor.

{% call py() %} subtotal = precio * cantidad print("Subtotal:", subtotal) print("Con IVA:", subtotal * 1.21) subtotal > 5000 {% endcall %} {% call salida() %} Subtotal: 6900 Con IVA: 8349.0 True {% endcall %} {% call py() %} display(precio, cantidad) precio * cantidad; {% endcall %} {% call salida() %} 3450 2 {% endcall %}

Los dos modos

Como en Jupyter, hay un modo edición (estás escribiendo dentro de una celda) y un modo comando (la celda está seleccionada pero no escribís). Esc pasa a modo comando y Enter vuelve a editar.

TeclasModoQué hace
Shift+Enterlos dosEjecuta la celda y pasa a la siguiente. Si es la última, crea una nueva.
Ctrl+Enter (Cmd en Mac)los dosEjecuta y se queda en la celda.
Alt+Enterlos dosEjecuta e inserta una celda de código debajo.
Ctrl+Slos dosGuarda el notebook ya (igual se guarda solo).
Tab / Shift+TabediciónAgrega o saca un nivel de sangría (4 espacios).
Ctrl+/ediciónComenta o descomenta las líneas elegidas.
EscediciónPasa a modo comando.
EntercomandoEdita la celda seleccionada.
A / BcomandoInserta una celda arriba o abajo.
D DcomandoBorra la celda (D dos veces).
ZcomandoRecupera la última celda borrada.
M / YcomandoConvierte la celda en texto o en código.
↑ ↓ o K JcomandoSelecciona la celda anterior o la siguiente.
Shift+↑ / Shift+↓comandoMueve la celda hacia arriba o hacia abajo.
I IcomandoInterrumpe lo que se está ejecutando (reinicia Python).
0 0comandoReinicia Python.

Al escribir, Enter mantiene la sangría de la línea y agrega un nivel después de :, que es lo que pide Python en un if, un for o una función. La barra de arriba de las celdas tiene lo mismo en botones: ▶ Ejecutar, ⏭ Ejecutar todo, ■ Interrumpir, ↻ Reiniciar, + Código, + Texto y Borrar salidas.

Celdas de texto

Usan Markdown. Con doble clic (o Enter) se editan y con Shift+Enter se ven con formato. Se puede usar:

  • # Título, ## Subtítulo y así hasta seis niveles.
  • **negrita**, *cursiva* y `código`.
  • Listas con - o con 1., citas con > y líneas horizontales con ---.
  • Tablas con | y una fila de guiones debajo del encabezado.
  • Bloques de código entre tres comillas invertidas y enlaces [texto](https://…).

No se muestran imágenes ni fórmulas de LaTeX.

Archivos: subir, leer y descargar{{ ancla('archivos') }}

Debajo de las celdas está el panel Archivos del notebook. Con Subir archivo elegís un CSV (o un .tsv, .txt o .json) y queda en /mnt/data, la carpeta donde arranca Python. Por eso alcanza con el nombre para abrirlo: pd.read_csv("ventas.csv"), sin ruta.

{% call py() %} import os os.getcwd() {% endcall %} {% call salida() %} '/mnt/data' {% endcall %}

Lo que escribe Python también queda ahí y aparece en el panel como creado por Python, con un botón Descargar. os.listdir() muestra los nombres exactos de todo lo que hay.

{% call py() %} with open("notas.txt", "w", encoding="utf-8") as archivo: archivo.write("Primera prueba con ChartPython\n") sorted(os.listdir()) {% endcall %} {% call salida() %} ['notas.txt'] {% endcall %}
En el panel diceQué significa
subidoLo subiste vos. Queda guardado en el navegador con ese notebook y se vuelve a poner en /mnt/data cada vez que Python se reinicia. Quitar lo saca.
creado por PythonLo escribió una celda. Vive sólo mientras Python está prendido: al interrumpir o reiniciar se pierde, así que descargalo antes.

Guardar, abrir y exportar notebooks{{ ancla('notebooks') }}

  • Se guarda solo mientras escribís; la barra de estado muestra la hora del último guardado. Ctrl+S lo guarda en el momento.
  • El selector de arriba lista los notebooks de este navegador. Nuevo, Renombrar y Borrar hacen lo que dicen; borrar pide confirmación.
  • Cada notebook tiene su propio Python. Cambiar de notebook reinicia Python: las variables y los archivos creados por el anterior no pasan al nuevo.
  • Descargar .ipynb baja el notebook en el formato de Jupyter, que también abren VS Code y Google Colab.
  • Abrir .ipynb importa un notebook. Muestra las salidas que traía sin ejecutar nada; para regenerarlas, ejecutá las celdas.
  • El notebook que exporta el laboratorio de Econometría también se abre acá. Subí el CSV con el nombre que indica el notebook en Dataset esperado y ejecutalo.

Si el navegador no deja guardar (pasa en algunas ventanas privadas), ChartPython lo avisa y hay que descargar el .ipynb para no perder el trabajo.

Interrumpir y reiniciar Python{{ ancla('reiniciar') }}

En un navegador la única forma de frenar un código que no termina es apagar Python y volver a prenderlo. Por eso ■ Interrumpir (I I) y ↻ Reiniciar (0 0) tienen el mismo efecto:

  • Se pierden las variables, los import y los archivos creados por Python.
  • Se conservan las celdas, sus salidas y los archivos que subiste, que vuelven a /mnt/data antes de la próxima ejecución.

Después de reiniciar, ⏭ Ejecutar todo corre las celdas de código en orden y recupera todo. Si la barra dice Python se detuvo (por ejemplo, porque se quedó sin memoria), es lo mismo: volvé a ejecutar.

{% call py(ejecutar=false) %} # No lo ejecutes: este bucle no termina nunca y hay que interrumpirlo while True: pass {% endcall %}

Qué trae instalado (y qué no){{ ancla('instalado') }}

PaqueteVersiónPara qué
Python3.12El lenguaje, con casi toda su biblioteca estándar.
pandas2.2.0Tablas: leer CSV, limpiar, agrupar, combinar.
NumPy1.26.4Cálculo numérico y números al azar.
SciPy1.12.0Pruebas estadísticas y distribuciones.
statsmodels0.14.2Regresiones, pruebas de diagnóstico y series de tiempo.
patsy0.5.6Las fórmulas del estilo y ~ x1 + x2.
python-dateutil, pytzFechas y zonas horarias (los usa pandas).

De la biblioteca estándar están, entre otros, csv, json, re, math, statistics, datetime, collections, itertools, unicodedata, pathlib, glob, io, zipfile, random y decimal.

{% call py() %} import sys import numpy as np import pandas as pd import scipy import statsmodels print("Python", sys.version.split()[0]) print("pandas", pd.__version__) print("NumPy", np.__version__) print("SciPy", scipy.__version__) print("statsmodels", statsmodels.__version__) {% endcall %} {% call salida() %} Python 3.12.1 pandas 2.2.0 NumPy 1.26.4 SciPy 1.12.0 statsmodels 0.14.2 {% endcall %}

No hay internet ni pip: no se pueden instalar paquetes ni leer archivos desde una URL. Estos, que son comunes en otros tutoriales, no están:

No estáQué deja de andarQué usar
matplotlib, seaborn, plotlyGráficos, df.plot()Gráficos sin matplotlib
openpyxl, xlrdpd.read_excel(), df.to_excel()Trabajar con CSV
pyarrowParquet y FeatherCSV o JSON
Jinja2df.style (colores y formatos de tabla)round() y pd.set_option("display.float_format", …)
tabulatedf.to_markdown()print(df.to_string())
scikit-learnModelos de machine learningstatsmodels para regresión y clasificación con logit
requestsDescargar datos de internetBajar el archivo y subirlo al panel
{% call py(error="ModuleNotFoundError") %} import matplotlib.pyplot as plt {% endcall %} {% call salida_error() %} ModuleNotFoundError: No module named 'matplotlib' {% endcall %}

Diferencias con Jupyter{{ ancla('diferencias') }}

Si venís de Jupyter o de Colab casi todo funciona igual. Estas son las diferencias que se notan:

No hay comandos mágicos

%timeit, %matplotlib inline, !pip install y pd.read_csv? dan SyntaxError: no son Python, son de IPython. Para la ayuda de una función usá help(), y para medir cuánto tarda algo, time:

{% call py() %} import time inicio = time.perf_counter() suma = sum(range(1_000_000)) print(f"Tardó {time.perf_counter() - inicio:.3f} segundos") {% endcall %}

display() ya viene incluido. Si copiás código que hace from IPython.display import display, borrá esa línea:

{% call py(error="ModuleNotFoundError") %} from IPython.display import display {% endcall %} {% call salida_error() %} ModuleNotFoundError: No module named 'IPython' {% endcall %}

No se pueden pedir datos con input()

{% call py(error="RuntimeError") %} nombre = input("¿Cómo te llamás? ") {% endcall %} {% call salida_error() %} RuntimeError: input() no está disponible en ChartPython. Asigná el valor en una celda o cargá un archivo. {% endcall %}

Los enteros de NumPy son de 32 bits

Python corre en WebAssembly de 32 bits, y ahí el entero por defecto de NumPy es int32: llega hasta 2.147.483.647. Pasado ese número la cuenta da mal sin avisar, algo fácil de cruzar con montos en pesos.

{% call py() %} import numpy as np np.array([2_000_000_000, 2_000_000_000]).sum() {% endcall %} {% call salida() %} -294967296 {% endcall %} {% call py() %} np.array([2_000_000_000, 2_000_000_000], dtype="int64").sum() {% endcall %} {% call salida() %} 4000000000 {% endcall %}

Límites de una celda

  • Una celda muestra hasta 1 MiB de salida o unos 1000 print. Lo que pase de ahí se corta con un aviso, pero el código sigue corriendo hasta el final.
  • El código de una celda puede ocupar hasta 100 KiB.
  • Para mirar datos grandes, preferí head(), sample() o un resumen antes que imprimir fila por fila.

Parte 2

Python básico para datos

Lo mínimo de Python que aparece todo el tiempo al trabajar con tablas. Si ya lo conocés, saltá a Leer y explorar.

Variables, números y texto{{ ancla('variables') }}

Una variable es un nombre para un valor. Se crea con = y no hace falta declarar el tipo. Lo que va después de # es un comentario: Python lo ignora.

{% call py() %} edad = 34 # entero (int) altura = 1.72 # decimal (float): siempre con punto, nunca con coma nombre = "Lucía" # texto (str), entre comillas simples o dobles socio = True # verdadero o falso (bool) sin_dato = None # "no hay valor" type(altura) {% endcall %} {% call salida() %} {% endcall %} {% call py() %} print(7 + 2, 7 - 2, 7 * 2) print(7 / 2) # división print(7 // 2) # división entera print(7 % 2) # resto print(7 ** 2) # potencia print(round(3.14159, 2), abs(-5), max(3, 8, 1), min(3, 8, 1)) {% endcall %} {% call salida() %} 9 5 14 3.5 3 1 49 3.14 5 8 1 {% endcall %}

Texto

{% call py() %} cliente = " ana PÉREZ " print(cliente.strip()) # sin espacios en los bordes print(cliente.strip().title()) # Mayúscula Inicial print(cliente.strip().upper()) # TODO EN MAYÚSCULAS print("yerba;café;azúcar".split(";")) # cortar en una lista print("$ 3.450".replace("$", "").strip()) print(len("Córdoba"), "Cór" in "Córdoba") {% endcall %} {% call salida() %} ana PÉREZ Ana Pérez ANA PÉREZ ['yerba', 'café', 'azúcar'] 3.450 7 True {% endcall %}

Las f-strings arman texto con valores adentro. Después de : va el formato: ,.2f es separador de miles y dos decimales, .0% es porcentaje sin decimales.

{% call py() %} total = 1234567.891 descuento = 0.15 print(f"Total: {total:,.2f}") print(f"Descuento: {descuento:.0%}") # Con punto de miles y coma decimal, como se escribe en Argentina: print(f"{total:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")) {% endcall %} {% call salida() %} Total: 1,234,567.89 Descuento: 15% 1.234.567,89 {% endcall %}

Para pasar de un tipo a otro están int(), float() y str(). Python sólo entiende el punto decimal:

{% call py() %} print(int("42") + 1, float("3.5") * 2, str(10) + " unidades") print(float("3,5".replace(",", "."))) {% endcall %} {% call salida() %} 43 7.0 10 unidades 3.5 {% endcall %} {% call py(error="ValueError") %} float("3,5") {% endcall %} {% call salida_error() %} ValueError: could not convert string to float: '3,5' {% endcall %}

Listas y diccionarios{{ ancla('colecciones') }}

Una lista guarda valores en orden. Las posiciones se cuentan desde 0 y los números negativos cuentan desde el final.

{% call py() %} precios = [3450, 5200.5, 1100, 2890] print(precios[0]) # el primero print(precios[-1]) # el último print(precios[1:3]) # posiciones 1 y 2: el 3 no entra precios.append(990) # agregar al final print(len(precios), sum(precios), max(precios)) print(sorted(precios)) {% endcall %} {% call salida() %} 3450 2890 [5200.5, 1100] 5 13630.5 5200.5 [990, 1100, 2890, 3450, 5200.5] {% endcall %}

Un diccionario guarda pares clave → valor. Sirve, por ejemplo, para tablas de equivalencias.

{% call py() %} producto = {"nombre": "Yerba 1kg", "precio": 3450, "stock": 12} print(producto["precio"]) print(producto.get("marca", "sin marca")) # valor por defecto si la clave no está producto["stock"] = 10 # cambiar o agregar un valor for clave, valor in producto.items(): print(clave, "→", valor) {% endcall %} {% call salida() %} 3450 sin marca nombre → Yerba 1kg precio → 3450 stock → 10 {% endcall %}

Dos cosas más que aparecen seguido: asignar varios valores a la vez y quedarse con los valores sin repetir (set).

{% call py() %} minimo, maximo = min(precios), max(precios) print(minimo, maximo) provincias = ["CABA", "Córdoba", "CABA", "Mendoza"] sorted(set(provincias)) {% endcall %} {% call salida() %} 990 5200.5 ['CABA', 'Córdoba', 'Mendoza'] {% endcall %}

Condiciones y bucles{{ ancla('control') }}

Python usa la sangría para saber qué líneas van dentro de un if o de un for: cuatro espacios después de la línea que termina en :.

{% call py() %} stock = 3 if stock == 0: print("Sin stock") elif stock < 5: print("Quedan pocas unidades") else: print("Hay stock") {% endcall %} {% call salida() %} Quedan pocas unidades {% endcall %}

Para comparar: == (igual), != (distinto), <, <=, >, >=. Para combinar condiciones: and, or y not; in pregunta si un valor está en una lista o en un texto.

{% call py() %} for provincia in ["CABA", "Córdoba", "Mendoza"]: print(provincia.upper()) for numero, precio in enumerate([3450, 1100], start=1): print(numero, precio) for producto, precio in zip(["Yerba", "Azúcar"], [3450, 1100]): print(f"{producto}: $ {precio}") for mes in range(1, 4): # 1, 2 y 3: el 4 no entra print("Mes", mes) {% endcall %} {% call salida() %} CABA CÓRDOBA MENDOZA 1 3450 2 1100 Yerba: $ 3450 Azúcar: $ 1100 Mes 1 Mes 2 Mes 3 {% endcall %}

Una comprensión de lista arma una lista nueva en una línea, transformando o filtrando otra:

{% call py() %} precios = [3450, 5200.5, 1100, 2890] con_iva = [round(p * 1.21, 2) for p in precios] caros = [p for p in precios if p > 3000] print(con_iva) print(caros) {% endcall %} {% call salida() %} [4174.5, 6292.6, 1331.0, 3496.9] [3450, 5200.5] {% endcall %}

Funciones, módulos y errores{{ ancla('funciones') }}

Una función guarda pasos para reusarlos. Los parámetros con = tienen un valor por defecto, y return devuelve el resultado.

{% call py() %} def precio_final(precio, descuento=0.0, iva=0.21): """Aplica el descuento y después el IVA.""" con_descuento = precio * (1 - descuento) return round(con_descuento * (1 + iva), 2) print(precio_final(1000)) print(precio_final(1000, descuento=0.1)) {% endcall %} {% call salida() %} 1210.0 1089.0 {% endcall %}

try / except atrapa un error esperado para decidir qué hacer en lugar de que se corte todo:

{% call py() %} def a_numero(texto): try: return float(texto.replace(".", "").replace(",", ".")) except ValueError: return None print(a_numero("3.450,50"), a_numero("s/d")) {% endcall %} {% call salida() %} 3450.5 None {% endcall %}

Los módulos se cargan con import. Los alias pd y np son una convención que vas a ver en todos lados.

{% call py() %} import math from statistics import mean, median import numpy as np import pandas as pd print(math.sqrt(16), math.log(100, 10)) print(mean([3, 5, 10]), median([3, 5, 10])) {% endcall %} {% call salida() %} 4.0 2.0 6 5 {% endcall %}

help() muestra la documentación de cualquier función, en inglés. Con help(pd.read_csv) ves todos sus parámetros.

{% call py() %} help(round) {% endcall %} {% call salida() %} Help on built-in function round in module builtins: round(number, ndigits=None) Round a number to a given precision in decimal digits. The return value is an integer if ndigits is omitted or None. Otherwise the return value has the same type as the number. ndigits may be negative. {% endcall %}

Cuando algo falla, Python muestra el traceback: la última línea dice el tipo de error y por qué, y las de arriba, en qué línea de la celda pasó. En Errores frecuentes están los más comunes con su solución.

Parte 3

Leer y explorar un CSV

pandas guarda una tabla en un DataFrame: filas, columnas con nombre y un índice que identifica cada fila (al leer un CSV son los números 0, 1, 2…). Cada columna, por separado, es una Series.

El CSV de práctica{{ ancla('csv-practica') }}

Para practicar sin depender de un archivo tuyo, esta celda crea ventas.csv en /mnt/data. Tiene los problemas típicos de un CSV exportado de una planilla o de un sistema de facturación: punto y coma como separador, precios con $ y coma decimal, espacios de más, provincias escritas de varias formas, un s/d, una fila vacía, un pedido repetido, una fecha imposible y una cantidad que no tiene sentido.

{% call py() %} texto = """Sucursal;Fecha;Cliente ;Provincia;Producto;Cantidad;Precio Unitario;Descuento;Pagado 001;03/01/2026; Ana Pérez ;Buenos Aires;Yerba 1kg;2;$ 3.450,00;10%;Sí 001;03/01/2026;JUAN GÓMEZ;bs. as.;Café 500g;1;$ 5.200,50;0%;si 002;04/01/2026;María López;Córdoba;Yerba 1kg;;$ 3.450,00;5%;No 002;04/01/2026;María López;Córdoba;Yerba 1kg;;$ 3.450,00;5%;No 001;05/01/2026;Pedro Ruiz;CABA;Azúcar 1kg;3;$ 1.100,00;;SI ;;;;;;;; 002;06/01/2026;lucía fernández;cordoba;Aceite 900ml;s/d;$ 2.890,00;0%;no 003;07/01/2026;Ana Pérez;Santa Fe ;Café 500g;250;$ 5.200,50;15%;Sí 001;31/02/2026;Juan Gómez;Capital Federal;Yerba 1kg;4;$ 3.450,00;0%;Sí 003;08/01/2026;Sofía Díaz;Mendoza;Aceite 900ml;1;$ 2.890,00;0%;No 001;09/01/2026;Pedro Ruiz;caba;Azúcar 1kg;6;$ 1.100,00;5%;Sí 002;10/01/2026;Lucía Fernández;Córdoba;Café 500g;2;$ 5.200,50;0%;No """ with open("ventas.csv", "w", encoding="utf-8") as archivo: archivo.write(texto) print("Listo: ventas.csv quedó en /mnt/data") {% endcall %} {% call salida() %} Listo: ventas.csv quedó en /mnt/data {% endcall %}

Si preferís trabajar con tu propio archivo, subilo desde el panel y cambiá el nombre en los ejemplos.

Leer un CSV{{ ancla('leer-csv') }}

pd.read_csv() lee el archivo y devuelve un DataFrame. shape dice cuántas filas y columnas quedaron.

{% call py() %} import pandas as pd ventas = pd.read_csv("ventas.csv") ventas.shape {% endcall %} {% call salida() %} (12, 1) {% endcall %}

Una sola columna: pandas espera comas y este archivo separa con punto y coma, así que cada fila entera quedó como un único texto. Es lo primero que hay que revisar cuando algo se ve raro. El parámetro sep lo arregla:

{% call py() %} ventas = pd.read_csv("ventas.csv", sep=";") ventas.shape {% endcall %} {% call salida() %} (12, 9) {% endcall %}

Otra trampa clásica: los códigos con ceros a la izquierda. pandas ve números, los convierte y el 001 pasa a ser 1.0. Con dtype se le indica que esa columna es texto.

{% call py() %} ventas["Sucursal"].head(3) {% endcall %} {% call salida() %} 0 1.0 1 1.0 2 2.0 Name: Sucursal, dtype: float64 {% endcall %} {% call py() %} ventas = pd.read_csv("ventas.csv", sep=";", dtype={"Sucursal": str}) ventas["Sucursal"].head(3) {% endcall %} {% call salida() %} 0 001 1 001 2 002 Name: Sucursal, dtype: object {% endcall %}

Los parámetros que más se usan

ParámetroCuándoEjemplo
sepSeparador distinto de la coma. Con sep=None pandas intenta adivinarlo.sep=";", sep="\t", sep=None, engine="python"
decimal, thousandsNúmeros escritos como 1.234,56.decimal=",", thousands="."
encodingAcentos rotos o UnicodeDecodeError.encoding="latin-1", encoding="utf-8-sig"
dtypeColumnas que tienen que quedar como texto: códigos, DNI, CUIT, códigos postales.dtype={"cuit": str}
na_valuesTextos que en tu archivo significan “sin dato”.na_values=["s/d", "-", "N/A"]
usecolsLeer sólo algunas columnas.usecols=["Fecha", "Cantidad"]
nrows, skiprowsLeer una parte, o saltear renglones de título antes del encabezado.nrows=1000, skiprows=3
header, namesEl archivo no tiene fila de encabezado.header=None, names=["fecha", "valor"]
parse_datesLeer columnas como fecha (conviene cuando ya vienen en formato 2026-01-31).parse_dates=["fecha"]
on_bad_linesDescartar filas mal formadas (con cuidado: se pierden datos).on_bad_lines="skip"
{% call py() %} pd.read_csv("ventas.csv", sep=";", usecols=["Fecha", "Producto", "Cantidad"], nrows=3) {% endcall %} {% call salida() %} Fecha Producto Cantidad 0 03/01/2026 Yerba 1kg 2.0 1 03/01/2026 Café 500g 1.0 2 04/01/2026 Yerba 1kg NaN {% endcall %}

NaN es como pandas marca un valor faltante. Con na_values, el s/d también pasa a ser faltante y la columna queda numérica:

{% call py() %} pd.read_csv("ventas.csv", sep=";", na_values=["s/d"])["Cantidad"].tolist() {% endcall %} {% call salida() %} [2.0, 1.0, nan, nan, 3.0, nan, nan, 250.0, 4.0, 1.0, 6.0, 2.0] {% endcall %}

Números con coma decimal

io.StringIO deja leer un texto como si fuera un archivo, útil para probar un parámetro sin crear nada:

{% call py() %} import io texto = "producto;precio\nYerba;3.450,50\nCafé;5.200,00\n" pd.read_csv(io.StringIO(texto), sep=";", decimal=",", thousands=".") {% endcall %} {% call salida() %} producto precio 0 Yerba 3450.5 1 Café 5200.0 {% endcall %}

Acentos y codificación

Excel en Windows suele guardar los CSV en latin-1 (también llamado cp1252) y pandas espera UTF-8. Esta celda crea un archivo así, para ver qué pasa:

{% call py() %} with open("clientes_windows.csv", "w", encoding="latin-1") as archivo: archivo.write("nombre;ciudad\nJosé;Córdoba\n") {% endcall %}

El síntoma es un UnicodeDecodeError:

{% call py(error="UnicodeDecodeError") %} pd.read_csv("clientes_windows.csv", sep=";") {% endcall %} {% call salida_error() %} UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 17: invalid continuation byte {% endcall %} {% call py() %} pd.read_csv("clientes_windows.csv", sep=";", encoding="latin-1") {% endcall %} {% call salida() %} nombre ciudad 0 José Córdoba {% endcall %}

Al revés: si ves Córdoba en lugar de Córdoba, el archivo era UTF-8 y se leyó como latin-1. Y si el primer nombre de columna empieza con , usá encoding="utf-8-sig".

Explorar un DataFrame{{ ancla('explorar') }}

Antes de limpiar hay que mirar. Estos comandos responden las preguntas de siempre: qué columnas hay, de qué tipo, cuántos faltantes y qué valores aparecen.

ComandoMuestra
df.head(), df.head(10)Las primeras filas (5 si no decís cuántas).
df.tail(3)Las últimas filas.
df.sample(5, random_state=1)Filas al azar; random_state hace que salgan siempre las mismas.
df.shape(filas, columnas).
df.columns.tolist()Los nombres exactos de las columnas.
df.dtypesEl tipo de cada columna.
df.info()Tipos, valores no vacíos y memoria, todo junto.
df.describe()Resumen estadístico de las columnas numéricas; con include="all", de todas.
df.isna().sum()Cantidad de faltantes por columna.
df["col"].value_counts()Cuántas veces aparece cada valor.
df["col"].unique(), df["col"].nunique()Los valores distintos y cuántos son.
df.duplicated().sum()Cuántas filas repiten a una anterior.
{% call py() %} display(ventas.head(3), ventas.tail(2)) {% endcall %}

En ChartPython los DataFrames se ven como tablas. De acá en adelante, las salidas del manual los muestran en texto, que es lo mismo que imprime print(df).

{% call py() %} ventas.columns.tolist() {% endcall %} {% call salida() %} ['Sucursal', 'Fecha', 'Cliente ', 'Provincia', 'Producto', 'Cantidad', 'Precio Unitario', 'Descuento', 'Pagado'] {% endcall %} {% call py() %} ventas.info() {% endcall %} {% call salida() %} RangeIndex: 12 entries, 0 to 11 Data columns (total 9 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Sucursal 11 non-null object 1 Fecha 11 non-null object 2 Cliente 11 non-null object 3 Provincia 11 non-null object 4 Producto 11 non-null object 5 Cantidad 9 non-null object 6 Precio Unitario 11 non-null object 7 Descuento 10 non-null object 8 Pagado 11 non-null object dtypes: object(9) memory usage: 504.0+ bytes {% endcall %} {% call py() %} ventas["Provincia"].value_counts(dropna=False) {% endcall %} {% call salida() %} Provincia Córdoba 3 Buenos Aires 1 bs. as. 1 CABA 1 NaN 1 cordoba 1 Santa Fe 1 Capital Federal 1 Mendoza 1 caba 1 Name: count, dtype: int64 {% endcall %} {% call py() %} print(ventas["Pagado"].unique()) print("Filas repetidas:", ventas.duplicated().sum()) {% endcall %} {% call salida() %} ['Sí' 'si' 'No' 'SI' nan 'no'] Filas repetidas: 1 {% endcall %}

Si una tabla tiene muchas columnas o filas, pandas muestra … en el medio. Estas opciones cambian cuánto se ve:

{% call py() %} pd.set_option("display.max_columns", 50) # columnas antes de cortar pd.set_option("display.max_rows", 100) # filas antes de mostrar sólo el principio y el final {% endcall %}

Parte 4

Limpiar un CSV

Paso a paso, sobre el CSV de práctica. Al final hay una receta completa que junta todo en una función para reusar con el próximo archivo.

Se trabaja sobre una copia, así ventas queda como vino del archivo y se puede comparar:

{% call py() %} df = ventas.copy() {% endcall %}

Nombres de columnas{{ ancla('nombres') }}

Nombres en minúscula, sin acentos, sin espacios y sin símbolos evitan errores de tipeo y dejan escribir df["precio_unitario"] sin dudar. Los métodos de texto de pandas (.str) funcionan también sobre los nombres de las columnas:

{% call py() %} df.columns = ( df.columns.str.strip() # espacios en los bordes .str.lower() # minúsculas .str.normalize("NFKD") # separa cada letra de su acento... .str.encode("ascii", "ignore") # ...descarta los acentos... .str.decode("ascii") # ...y vuelve a texto .str.replace(r"\W+", "_", regex=True) # espacios y símbolos → _ ) df.columns.tolist() {% endcall %} {% call salida() %} ['sucursal', 'fecha', 'cliente', 'provincia', 'producto', 'cantidad', 'precio_unitario', 'descuento', 'pagado'] {% endcall %}

Para cambiar sólo algunos nombres, rename con un diccionario de viejo → nuevo: df = df.rename(columns={"precio_unitario": "precio"}).

Filas vacías y duplicadas{{ ancla('vacias-duplicadas') }}

{% call py() %} df = df.dropna(how="all") # borra las filas que están completamente vacías df[df.duplicated(keep=False)][["fecha", "cliente", "producto"]] {% endcall %} {% call salida() %} fecha cliente producto 2 04/01/2026 María López Yerba 1kg 3 04/01/2026 María López Yerba 1kg {% endcall %}

duplicated() marca las filas que repiten una anterior; con keep=False marca todas las copias, para verlas juntas. drop_duplicates() deja una sola:

{% call py() %} df = df.drop_duplicates() df = df.reset_index(drop=True) # vuelve a numerar las filas 0, 1, 2… len(df) {% endcall %} {% call salida() %} 10 {% endcall %}

Si dos filas son el mismo registro aunque difieran en alguna columna (por ejemplo, una carga corregida), compará sólo las columnas que lo identifican y elegí cuál conservar: df.drop_duplicates(subset=["fecha", "cliente", "producto"], keep="last").

Texto y categorías{{ ancla('texto') }}

Con .str se aplica un método de texto a toda la columna. Los faltantes quedan como faltantes, sin error.

{% call py() %} df["cliente"] = ( df["cliente"].str.strip() # espacios en los bordes .str.replace(r"\s+", " ", regex=True) # espacios repetidos en el medio .str.title() # Mayúscula Inicial ) df["cliente"].unique().tolist() {% endcall %} {% call salida() %} ['Ana Pérez', 'Juan Gómez', 'María López', 'Pedro Ruiz', 'Lucía Fernández', 'Sofía Díaz'] {% endcall %}
MétodoHace
.str.strip(), .str.lower(), .str.upper(), .str.title()Espacios y mayúsculas.
.str.replace("viejo", "nuevo", regex=False)Reemplaza texto literal. Con regex=True, un patrón.
.str.contains("yerba", case=False, na=False)True donde aparece el texto. Sirve para filtrar.
.str.startswith("0"), .str.endswith("kg")Empieza o termina con…
.str.len()Largo de cada texto.
.str.split(" ", expand=True)Parte el texto en varias columnas.
.str.extract(r"(\d+)")Saca la parte que coincide con un patrón.
.str.zfill(5)Completa con ceros a la izquierda.
.str[:3]Los primeros tres caracteres.

str.extract usa expresiones regulares: \d+ es “uno o más dígitos” y cada paréntesis es una columna del resultado. Por ejemplo, para separar el tamaño y la unidad del nombre del producto:

{% call py() %} df["producto"].str.extract(r"(\d+)\s*(kg|g|ml)").drop_duplicates() {% endcall %} {% call salida() %} 0 1 0 1 kg 1 500 g 4 900 ml {% endcall %}

Unificar categorías

La provincia está escrita de muchas formas. Primero se arma una clave normalizada (minúsculas, sin acentos, sin puntos) para que las variantes se parezcan, y después un diccionario dice a qué valor corresponde cada una:

{% call py() %} clave = ( df["provincia"].str.strip().str.lower() .str.normalize("NFKD").str.encode("ascii", "ignore").str.decode("ascii") .str.replace(".", "", regex=False) ) clave.unique().tolist() {% endcall %} {% call salida() %} ['buenos aires', 'bs as', 'cordoba', 'caba', 'santa fe', 'capital federal', 'mendoza'] {% endcall %} {% call py() %} equivalencias = { "buenos aires": "Buenos Aires", "bs as": "Buenos Aires", "caba": "CABA", "capital federal": "CABA", "cordoba": "Córdoba", "santa fe": "Santa Fe", "mendoza": "Mendoza", } df["provincia"] = clave.map(equivalencias) df["provincia"].value_counts(dropna=False) {% endcall %} {% call salida() %} provincia Córdoba 3 CABA 3 Buenos Aires 2 Santa Fe 1 Mendoza 1 Name: count, dtype: int64 {% endcall %}

Números con formato argentino{{ ancla('numeros') }}

$ 3.450,00 es texto para Python. Hay que sacar el signo y el punto de miles, cambiar la coma decimal por punto y recién ahí convertir con pd.to_numeric. Con errors="coerce", lo que no se puede convertir queda NaN en lugar de cortar con un error.

{% call py() %} df["precio_unitario"] = pd.to_numeric( df["precio_unitario"] .str.replace("$", "", regex=False) # el signo .str.replace(".", "", regex=False) # el punto de miles .str.replace(",", ".", regex=False) # la coma decimal .str.strip(), errors="coerce", ) df["precio_unitario"].head(3) {% endcall %} {% call salida() %} 0 3450.0 1 5200.5 2 3450.0 Name: precio_unitario, dtype: float64 {% endcall %}

Los porcentajes se tratan igual, dividiendo por 100 al final:

{% call py() %} df["descuento"] = pd.to_numeric(df["descuento"].str.replace("%", "", regex=False).str.strip(), errors="coerce") / 100 df["descuento"].tolist() {% endcall %} {% call salida() %} [0.1, 0.0, 0.05, nan, 0.0, 0.15, 0.0, 0.0, 0.05, 0.0] {% endcall %}

Antes de convertir una columna conviene ver qué valores no se van a poder leer, para no perder datos en silencio. La idea: convertir, y mostrar lo que era un valor pero quedó NaN.

{% call py() %} convertida = pd.to_numeric(df["cantidad"], errors="coerce") df.loc[convertida.isna() & df["cantidad"].notna(), ["cliente", "cantidad"]] {% endcall %} {% call salida() %} cliente cantidad 4 Lucía Fernández s/d {% endcall %} {% call py() %} df["cantidad"] = convertida df["cantidad"].tolist() {% endcall %} {% call salida() %} [2.0, 1.0, nan, 3.0, nan, 250.0, 4.0, 1.0, 6.0, 2.0] {% endcall %}

Sí y no

Para columnas de dos valores, un diccionario con todas las variantes y map:

{% call py() %} df["pagado"] = df["pagado"].str.strip().str.lower().map({"sí": True, "si": True, "no": False}) df["pagado"].value_counts(dropna=False) {% endcall %} {% call salida() %} pagado True 6 False 4 Name: count, dtype: int64 {% endcall %}

Fechas{{ ancla('fechas') }}

pd.to_datetime convierte texto en fechas. Pasale siempre el formato: sin él, pandas puede leer 03/01/2026 como 1 de marzo. Con errors="coerce", las fechas imposibles quedan NaT (el faltante de las fechas).

{% call py() %} df["fecha"] = pd.to_datetime(df["fecha"], format="%d/%m/%Y", errors="coerce") df.loc[df["fecha"].isna(), ["cliente", "fecha"]] {% endcall %} {% call salida() %} cliente fecha 6 Juan Gómez NaT {% endcall %}
En el archivoFormato
31/12/2026format="%d/%m/%Y"
31-12-26format="%d-%m-%y"
2026-12-31format="%Y-%m-%d"
31/12/2026 18:45format="%d/%m/%Y %H:%M"
Mezcladosformat="mixed", dayfirst=True (revisá el resultado)
46022 (número de serie de Excel)pd.to_datetime(col, unit="D", origin="1899-12-30")

Una vez convertida, .dt da acceso a las partes de la fecha. Los nombres de los días vienen en inglés; para tenerlos en castellano se usa el número del día (lunes es 0):

{% call py() %} dias = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"] pd.DataFrame({ "fecha": df["fecha"], "mes": df["fecha"].dt.to_period("M"), "dia": df["fecha"].dt.dayofweek.map(dict(enumerate(dias))), "hace_dias": (pd.Timestamp("2026-01-31") - df["fecha"]).dt.days, }).head(4) {% endcall %} {% call salida() %} fecha mes dia hace_dias 0 2026-01-03 2026-01 sábado 28.0 1 2026-01-03 2026-01 sábado 28.0 2 2026-01-04 2026-01 domingo 27.0 3 2026-01-05 2026-01 lunes 26.0 {% endcall %}

Otras que se usan: .dt.year, .dt.month, .dt.day, .dt.quarter y .dt.strftime("%d/%m/%Y") para volver a texto. Para filtrar por fecha se compara con un texto en formato año-mes-día: df[df["fecha"] >= "2026-01-05"].

Valores atípicos{{ ancla('atipicos') }}

Un valor atípico no siempre es un error, pero siempre hay que mirarlo. La regla del rango intercuartílico (IQR) marca lo que se aleja más de 1,5 veces la distancia entre el cuartil 1 y el 3:

{% call py() %} q1 = df["cantidad"].quantile(0.25) q3 = df["cantidad"].quantile(0.75) iqr = q3 - q1 fuera = (df["cantidad"] < q1 - 1.5 * iqr) | (df["cantidad"] > q3 + 1.5 * iqr) df.loc[fuera, ["fecha", "cliente", "producto", "cantidad"]] {% endcall %} {% call salida() %} fecha cliente producto cantidad 5 2026-01-07 Ana Pérez Café 500g 250.0 {% endcall %}

250 paquetes de café en un pedido de almacén parece un error de carga (¿eran 2?). Qué hacer depende del caso; estas son las opciones habituales:

  • Tratarlo como faltante y completarlo después: df.loc[fuera, "cantidad"] = np.nan.
  • Borrar la fila: df = df[~fuera] (el ~ invierte la condición).
  • Recortarlo a un máximo razonable: df["cantidad"].clip(upper=20).
  • Dejarlo y anotarlo, si es un dato real.
{% call py() %} import numpy as np df.loc[fuera, "cantidad"] = np.nan {% endcall %}

Otra forma de medir qué tan lejos está un valor es el puntaje z: cuántos desvíos estándar se aleja de la media. (col - col.mean()) / col.std(); suele marcarse lo que supera 3 en valor absoluto. Con pocos datos, como acá, el IQR es más confiable.

Valores faltantes{{ ancla('faltantes') }}

{% call py() %} df.isna().sum() {% endcall %} {% call salida() %} sucursal 0 fecha 1 cliente 0 provincia 0 producto 0 cantidad 3 precio_unitario 0 descuento 1 pagado 0 dtype: int64 {% endcall %}

No hay una única respuesta: depende de qué significa el faltante. Acá, un descuento vacío es “sin descuento”, y para la cantidad usamos la mediana, que no se deja arrastrar por valores extremos.

{% call py() %} df["descuento"] = df["descuento"].fillna(0) df["cantidad"] = df["cantidad"].fillna(df["cantidad"].median()) df["cantidad"].tolist() {% endcall %} {% call salida() %} [2.0, 1.0, 2.0, 3.0, 2.0, 2.0, 4.0, 1.0, 6.0, 2.0] {% endcall %}
ParaComando
Porcentaje de faltantes por columnadf.isna().mean().round(3)
Ver las filas con algún faltantedf[df.isna().any(axis=1)]
Borrar filas sin fechadf = df.dropna(subset=["fecha"])
Completar con un valor fijodf["col"] = df["col"].fillna(0)
Completar textodf["col"] = df["col"].fillna("Sin dato")
Media o medianadf["col"].fillna(df["col"].median())
Mediana de su grupodf["col"].fillna(df.groupby("grupo")["col"].transform("median"))
El valor anterior (series de tiempo)df["col"].ffill()
Interpolar entre vecinosdf["col"].interpolate()

La fecha imposible la dejamos como NaT: el pedido existió y sus montos sirven, aunque no se sepa el día. Si vas a analizar por fecha, esas filas quedan afuera solas.

Tipos de datos{{ ancla('tipos') }}

Con los faltantes resueltos, cada columna puede tener su tipo definitivo. astype convierte:

{% call py() %} df["cantidad"] = df["cantidad"].astype("int64") df["pagado"] = df["pagado"].astype(bool) df.dtypes {% endcall %} {% call salida() %} sucursal object fecha datetime64[ns] cliente object provincia object producto object cantidad int64 precio_unitario float64 descuento float64 pagado bool dtype: object {% endcall %}
TipoPara
"int64"Enteros. No admite faltantes.
"Int64" (con mayúscula)Enteros con faltantes, que se ven como <NA>.
"float64" o floatDecimales; admite NaN.
str (queda como object)Texto, códigos, DNI, CUIT.
boolVerdadero o falso.
"category"Texto con pocos valores que se repiten mucho (provincia, sexo): ocupa menos memoria.
datetime64[ns]Fechas, con pd.to_datetime.
{% call py() %} montos = pd.Series([3_000_000_000.0, 150_000.0]) print(montos.astype(int).tolist()) print(montos.astype("int64").tolist()) {% endcall %} {% call salida() %} [-2147483648, 150000] [3000000000, 150000] {% endcall %}

Un error de tipo muy común: una columna de números que en realidad es texto. No falla, pero hace otra cosa:

{% call py() %} pd.Series(["10", "20", "30"]).sum() {% endcall %} {% call salida() %} '102030' {% endcall %}

Validar antes de guardar{{ ancla('validar') }}

assert corta con un error si una condición no se cumple. Escribir las reglas del negocio así evita guardar un archivo roto sin darse cuenta, sobre todo cuando la limpieza se repite con cada archivo nuevo.

{% call py() %} assert df["precio_unitario"].notna().all(), "Hay precios que no se pudieron leer" assert df["cantidad"].between(1, 100).all(), "Hay cantidades fuera de rango" assert df["provincia"].notna().all(), "Hay provincias sin equivalencia" assert not df.duplicated().any(), "Quedaron filas repetidas" print("Validación OK:", len(df), "filas") {% endcall %} {% call salida() %} Validación OK: 10 filas {% endcall %} {% call py(error="AssertionError") %} assert df["fecha"].notna().all(), "Hay fechas vacías o imposibles" {% endcall %} {% call salida_error() %} AssertionError: Hay fechas vacías o imposibles {% endcall %}

Un resumen de calidad por columna también ayuda a ver todo de un vistazo:

{% call py() %} pd.DataFrame({ "tipo": df.dtypes.astype(str), "faltantes": df.isna().sum(), "distintos": df.nunique(), }) {% endcall %} {% call salida() %} tipo faltantes distintos sucursal object 0 3 fecha datetime64[ns] 1 8 cliente object 0 6 provincia object 0 5 producto object 0 4 cantidad int64 0 5 precio_unitario float64 0 4 descuento float64 0 4 pagado bool 0 2 {% endcall %}

Guardar el CSV limpio{{ ancla('guardar') }}

Antes de guardar, una columna calculada: el total de cada pedido. Las operaciones entre columnas se hacen fila a fila, sin bucles.

{% call py() %} df["total"] = (df["cantidad"] * df["precio_unitario"] * (1 - df["descuento"])).round(2) df.to_csv("ventas_limpio.csv", index=False) {% endcall %}

index=False evita que el índice se guarde como una columna sin nombre. El archivo aparece en el panel como creado por Python: descargalo antes de reiniciar.

Si el archivo lo va a abrir alguien con Excel en castellano, conviene punto y coma, coma decimal y utf-8-sig (sin esa marca, Excel muestra mal los acentos):

{% call py() %} df.to_csv( "ventas_para_excel.csv", index=False, sep=";", decimal=",", encoding="utf-8-sig", date_format="%d/%m/%Y", ) with open("ventas_para_excel.csv", encoding="utf-8-sig") as archivo: print("".join(archivo.readlines()[:3])) {% endcall %} {% call salida() %} sucursal;fecha;cliente;provincia;producto;cantidad;precio_unitario;descuento;pagado;total 001;03/01/2026;Ana Pérez;Buenos Aires;Yerba 1kg;2;3450,0;0,1;True;6210,0 001;03/01/2026;Juan Gómez;Buenos Aires;Café 500g;1;5200,5;0,0;True;5200,5 {% endcall %}

Otros formatos: df.to_json("ventas.json", orient="records", force_ascii=False, date_format="iso") para JSON, y sep="\t" en to_csv para un .tsv. No se puede guardar .xlsx porque falta openpyxl; el CSV para Excel de arriba se abre con doble clic.

Receta completa{{ ancla('receta') }}

Los pasos anteriores, en una función. Cuando llegue el archivo del mes que viene, se sube y se ejecuta la misma celda. Las funciones chicas de arriba se pueden copiar a cualquier notebook.

{% call py() %} import numpy as np import pandas as pd EQUIVALENCIAS = { "buenos aires": "Buenos Aires", "bs as": "Buenos Aires", "caba": "CABA", "capital federal": "CABA", "cordoba": "Córdoba", "santa fe": "Santa Fe", "mendoza": "Mendoza", } def sin_acentos(textos): """Sirve para una columna (Series) o para los nombres de columnas.""" return textos.str.normalize("NFKD").str.encode("ascii", "ignore").str.decode("ascii") def a_numero(textos): """'$ 3.450,50' → 3450.5. Lo que no se puede leer queda NaN.""" limpio = ( textos.str.replace("$", "", regex=False) .str.replace("%", "", regex=False) .str.replace(".", "", regex=False) .str.replace(",", ".", regex=False) .str.strip() ) return pd.to_numeric(limpio, errors="coerce") def limpiar_ventas(ruta): df = pd.read_csv(ruta, sep=";", dtype={"Sucursal": str}) # Nombres de columnas y filas vacías o repetidas df.columns = sin_acentos(df.columns.str.strip().str.lower()).str.replace(r"\W+", "_", regex=True) df = df.dropna(how="all").drop_duplicates().reset_index(drop=True) # Texto y categorías df["cliente"] = df["cliente"].str.strip().str.replace(r"\s+", " ", regex=True).str.title() clave = sin_acentos(df["provincia"].str.strip().str.lower()).str.replace(".", "", regex=False) df["provincia"] = clave.map(EQUIVALENCIAS) df["pagado"] = df["pagado"].str.strip().str.lower().map({"sí": True, "si": True, "no": False}) # Números y fechas df["precio_unitario"] = a_numero(df["precio_unitario"]) df["descuento"] = a_numero(df["descuento"]).fillna(0) / 100 df["cantidad"] = pd.to_numeric(df["cantidad"], errors="coerce") df["fecha"] = pd.to_datetime(df["fecha"], format="%d/%m/%Y", errors="coerce") # Atípicos: fuera de 1,5 IQR se toman como error de carga q1, q3 = df["cantidad"].quantile([0.25, 0.75]) fuera = (df["cantidad"] < q1 - 1.5 * (q3 - q1)) | (df["cantidad"] > q3 + 1.5 * (q3 - q1)) df.loc[fuera, "cantidad"] = np.nan # Faltantes, tipos y columnas calculadas df["cantidad"] = df["cantidad"].fillna(df["cantidad"].median()).astype("int64") df["pagado"] = df["pagado"].astype(bool) df["total"] = (df["cantidad"] * df["precio_unitario"] * (1 - df["descuento"])).round(2) # Validaciones assert df["precio_unitario"].notna().all(), "Hay precios que no se pudieron leer" assert df["provincia"].notna().all(), "Hay provincias sin equivalencia" return df limpio = limpiar_ventas("ventas.csv") limpio.to_csv("ventas_limpio.csv", index=False) limpio[["fecha", "cliente", "provincia", "producto", "cantidad", "total"]] {% endcall %} {% call salida() %} fecha cliente provincia producto cantidad total 0 2026-01-03 Ana Pérez Buenos Aires Yerba 1kg 2 6210.00 1 2026-01-03 Juan Gómez Buenos Aires Café 500g 1 5200.50 2 2026-01-04 María López Córdoba Yerba 1kg 2 6555.00 3 2026-01-05 Pedro Ruiz CABA Azúcar 1kg 3 3300.00 4 2026-01-06 Lucía Fernández Córdoba Aceite 900ml 2 5780.00 5 2026-01-07 Ana Pérez Santa Fe Café 500g 2 8840.85 6 NaT Juan Gómez CABA Yerba 1kg 4 13800.00 7 2026-01-08 Sofía Díaz Mendoza Aceite 900ml 1 2890.00 8 2026-01-09 Pedro Ruiz CABA Azúcar 1kg 6 6270.00 9 2026-01-10 Lucía Fernández Córdoba Café 500g 2 10401.00 {% endcall %} {% call py() %} limpio.equals(df) {% endcall %} {% call salida() %} True {% endcall %}

La función da exactamente lo mismo que los pasos uno por uno. Desde acá, ventas_limpio.csv es el punto de partida del análisis.

Parte 5

Transformar y analizar

Se parte del archivo limpio. Al volver a leerlo, parse_dates recupera las fechas y dtype los ceros de la sucursal; el resto de los tipos pandas los reconoce solo.

{% call py() %} import numpy as np import pandas as pd ventas = pd.read_csv("ventas_limpio.csv", parse_dates=["fecha"], dtype={"sucursal": str}) ventas.dtypes {% endcall %} {% call salida() %} sucursal object fecha datetime64[ns] cliente object provincia object producto object cantidad int64 precio_unitario float64 descuento float64 pagado bool total float64 dtype: object {% endcall %}

Seleccionar y filtrar{{ ancla('seleccionar') }}

Una columna se pide con su nombre entre corchetes y devuelve una Series. Varias columnas van en una lista, por eso los corchetes dobles, y devuelven un DataFrame.

{% call py() %} ventas["cliente"].head(3) {% endcall %} {% call salida() %} 0 Ana Pérez 1 Juan Gómez 2 María López Name: cliente, dtype: object {% endcall %} {% call py() %} ventas[["cliente", "producto", "total"]].head(3) {% endcall %} {% call salida() %} cliente producto total 0 Ana Pérez Yerba 1kg 6210.0 1 Juan Gómez Café 500g 5200.5 2 María López Yerba 1kg 6555.0 {% endcall %}

Para elegir filas y columnas a la vez hay dos herramientas:

  • loc usa etiquetas: el valor del índice y el nombre de la columna. Los rangos incluyen el final: loc[0:2] son tres filas.
  • iloc usa posiciones, como una lista: iloc[0:2] son dos filas.
{% call py() %} print(ventas.loc[2, "cliente"]) # fila con índice 2, columna "cliente" print(ventas.iloc[0, 2]) # primera fila, tercera columna ventas.loc[0:2, ["cliente", "total"]] {% endcall %} {% call salida() %} María López Ana Pérez cliente total 0 Ana Pérez 6210.0 1 Juan Gómez 5200.5 2 María López 6555.0 {% endcall %}

Filtrar filas

Un filtro es una Series de True y False: se quedan las filas con True. La forma más clara es darle nombre a la condición y usarla en loc:

{% call py() %} de_caba = ventas["provincia"] == "CABA" ventas.loc[de_caba, ["cliente", "producto", "total"]] {% endcall %} {% call salida() %} cliente producto total 3 Pedro Ruiz Azúcar 1kg 3300.0 6 Juan Gómez Yerba 1kg 13800.0 8 Pedro Ruiz Azúcar 1kg 6270.0 {% endcall %}

Para combinar condiciones se usan & (y), | (o) y ~ (no), con cada condición entre paréntesis:

{% call py() %} grandes_de_caba = (ventas["provincia"] == "CABA") & (ventas["total"] > 5000) ventas.loc[grandes_de_caba, ["cliente", "total"]] {% endcall %} {% call salida() %} cliente total 6 Juan Gómez 13800.0 8 Pedro Ruiz 6270.0 {% endcall %} {% call py(error="ValueError") %} ventas[ventas["provincia"] == "CABA" and ventas["total"] > 5000] {% endcall %} {% call salida_error() %} ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all(). {% endcall %}
CondiciónSe escribe
Igual, distinto, mayor…df["provincia"] == "CABA", !=, >, >=
Y, o, no(cond1) & (cond2), (cond1) | (cond2), ~cond
Está en una listadf["provincia"].isin(["CABA", "Córdoba"])
Entre dos valores (incluidos)df["total"].between(3000, 7000)
Contiene un textodf["producto"].str.contains("café", case=False, na=False)
Es o no es faltantedf["fecha"].isna(), df["fecha"].notna()
Desde una fechadf["fecha"] >= "2026-01-05"
Los N más grandes o más chicosdf.nlargest(3, "total"), df.nsmallest(3, "total")

query escribe el filtro como texto, que se lee más fácil cuando hay muchas condiciones. Las variables de Python se usan con @:

{% call py() %} minimo = 5000 ventas.query("provincia in ['CABA', 'Córdoba'] and total > @minimo")[["cliente", "provincia", "total"]] {% endcall %} {% call salida() %} cliente provincia total 2 María López Córdoba 6555.0 4 Lucía Fernández Córdoba 5780.0 6 Juan Gómez CABA 13800.0 8 Pedro Ruiz CABA 6270.0 9 Lucía Fernández Córdoba 10401.0 {% endcall %}

Modificar lo filtrado

Si guardás un filtro en una variable y después le cambiás algo, pandas avisa con un SettingWithCopyWarning: no sabe si querés cambiar la tabla original o sólo el pedazo.

{% call py(aviso="SettingWithCopyWarning") %} caba = ventas[ventas["provincia"] == "CABA"] caba["total_con_iva"] = caba["total"] * 1.21 {% endcall %}

Si querés una tabla aparte, pedí una copia con .copy(). Si querés cambiar la original, hacelo en un solo paso con loc: ventas.loc[condicion, "columna"] = valor.

{% call py() %} caba = ventas[ventas["provincia"] == "CABA"].copy() caba["total_con_iva"] = caba["total"] * 1.21 {% endcall %}

Crear, renombrar y ordenar columnas{{ ancla('columnas') }}

Una columna nueva se crea asignándole un valor. Las cuentas entre columnas se hacen fila por fila. Para asignar según una condición, np.where (dos opciones) o np.select (varias):

{% call py() %} ventas["region"] = np.where(ventas["provincia"].isin(["CABA", "Buenos Aires"]), "AMBA", "Interior") condiciones = [ventas["total"] < 4000, ventas["total"] < 8000] ventas["tamano"] = np.select(condiciones, ["chico", "mediano"], default="grande") ventas[["provincia", "region", "total", "tamano"]].head(4) {% endcall %} {% call salida() %} provincia region total tamano 0 Buenos Aires AMBA 6210.0 mediano 1 Buenos Aires AMBA 5200.5 mediano 2 Córdoba Interior 6555.0 mediano 3 CABA AMBA 3300.0 chico {% endcall %}

pd.cut arma tramos con límites que elegís vos; pd.qcut, tramos con la misma cantidad de filas cada uno (cuartiles, quintiles…):

{% call py() %} ventas["tramo"] = pd.cut(ventas["total"], bins=[0, 4000, 8000, np.inf], labels=["bajo", "medio", "alto"]) ventas["quintil"] = pd.qcut(ventas["total"], q=5, labels=[1, 2, 3, 4, 5]) ventas[["total", "tramo", "quintil"]].head(4) {% endcall %} {% call salida() %} total tramo quintil 0 6210.0 medio 3 1 5200.5 medio 2 2 6555.0 medio 4 3 3300.0 bajo 1 {% endcall %}

Para una transformación que no existe como método, se escribe una función y se aplica a cada valor con apply. Para traducir valores con un diccionario, map:

{% call py() %} def rubro(producto): return "Infusiones" if producto.startswith(("Yerba", "Café")) else "Almacén" ventas["rubro"] = ventas["producto"].apply(rubro) ventas["envase"] = ventas["producto"].map({ "Yerba 1kg": "paquete", "Café 500g": "paquete", "Azúcar 1kg": "paquete", "Aceite 900ml": "botella", }) ventas[["producto", "rubro", "envase"]].drop_duplicates() {% endcall %} {% call salida() %} producto rubro envase 0 Yerba 1kg Infusiones paquete 1 Café 500g Infusiones paquete 3 Azúcar 1kg Almacén paquete 4 Aceite 900ml Almacén botella {% endcall %}

Ordenar, renombrar y borrar. ascending=False ordena de mayor a menor, y con una lista se ordena por varias columnas:

{% call py() %} ventas = ventas.drop(columns=["tamano", "envase"]) ventas.sort_values(["provincia", "total"], ascending=[True, False])[["provincia", "cliente", "total"]].head(5) {% endcall %} {% call salida() %} provincia cliente total 0 Buenos Aires Ana Pérez 6210.0 1 Buenos Aires Juan Gómez 5200.5 6 CABA Juan Gómez 13800.0 8 CABA Pedro Ruiz 6270.0 3 CABA Pedro Ruiz 3300.0 {% endcall %}
ParaComando
Renombrar columnasdf = df.rename(columns={"precio_unitario": "precio"})
Borrar columnasdf = df.drop(columns=["a", "b"])
Borrar filas por índicedf = df.drop(index=[0, 5])
Elegir y reordenar columnasdf = df[["fecha", "cliente", "total"]]
Insertar una columna en una posicióndf.insert(0, "id", range(1, len(df) + 1))
Ordenar por el índicedf.sort_index()
Usar una columna como índice, y volver atrásdf.set_index("fecha"), df.reset_index()
Redondeardf["total"].round(2), df.round(1)

Los métodos se pueden encadenar entre paréntesis, un paso por línea. assign crea columnas dentro de la cadena; el lambda d: recibe la tabla tal como viene en ese paso:

{% call py() %} ( ventas .assign(total_miles=lambda d: (d["total"] / 1000).round(1)) .sort_values("total_miles", ascending=False) .head(3) [["cliente", "producto", "total_miles"]] ) {% endcall %} {% call salida() %} cliente producto total_miles 6 Juan Gómez Yerba 1kg 13.8 9 Lucía Fernández Café 500g 10.4 5 Ana Pérez Café 500g 8.8 {% endcall %}

Estadística descriptiva{{ ancla('descriptiva') }}

Diez ventas alcanzan para aprender a limpiar, pero no para hacer estadística. Esta celda arma una encuesta simulada de 300 personas; la semilla hace que los números sean siempre los mismos. Con datos tuyos, reemplazala por un pd.read_csv.

{% call py() %} rng = np.random.default_rng(2026) n = 300 encuesta = pd.DataFrame({ "edad": rng.integers(18, 66, n), "sexo": rng.choice(["mujer", "varón"], n), "region": rng.choice(["AMBA", "Centro", "Cuyo", "NEA", "NOA", "Patagonia"], n), "educacion": rng.integers(7, 19, n), # años de estudio }) encuesta["experiencia"] = (encuesta["edad"] - encuesta["educacion"] - 6).clip(lower=0) encuesta["horas"] = rng.normal(40, 8, n).round().clip(10, 70) log_salario = ( 13 + 0.07 * encuesta["educacion"] + 0.03 * encuesta["experiencia"] - 0.0005 * encuesta["experiencia"] ** 2 - 0.12 * (encuesta["sexo"] == "mujer") + rng.normal(0, 0.35, n) ) encuesta["salario"] = np.exp(log_salario).round(-3) encuesta.to_csv("encuesta.csv", index=False) encuesta.head() {% endcall %} {% call salida() %} edad sexo region educacion experiencia horas salario 0 58 mujer Patagonia 11 41 38.0 892000.0 1 26 varón NOA 18 2 40.0 1770000.0 2 19 mujer Cuyo 10 3 41.0 1071000.0 3 48 mujer Cuyo 8 34 38.0 835000.0 4 35 mujer Patagonia 17 12 46.0 1588000.0 {% endcall %} {% call py() %} salario = encuesta["salario"] print("Media:", round(salario.mean())) print("Mediana:", salario.median()) print("Desvío estándar:", round(salario.std())) print("Mínimo y máximo:", salario.min(), salario.max()) print("Percentil 90:", salario.quantile(0.9)) {% endcall %} {% call salida() %} Media: 1470643 Mediana: 1351000.0 Desvío estándar: 641413 Mínimo y máximo: 373000.0 4577000.0 Percentil 90: 2341500.0 {% endcall %} {% call py() %} encuesta.describe().round(1) {% endcall %} {% call salida() %} edad educacion experiencia horas salario count 300.0 300.0 300.0 300.0 300.0 mean 41.5 12.5 23.2 40.0 1470643.3 std 13.6 3.5 13.9 7.9 641413.2 min 18.0 7.0 0.0 17.0 373000.0 25% 31.0 9.0 11.0 35.0 964500.0 50% 41.0 12.0 24.0 40.0 1351000.0 75% 53.0 16.0 34.0 46.0 1826250.0 max 65.0 18.0 52.0 59.0 4577000.0 {% endcall %}
MedidaMétodo
Cantidad de valores no vacíos.count()
Suma, media, mediana.sum(), .mean(), .median()
Moda.mode() (puede haber más de una)
Desvío estándar y varianza.std(), .var() (muestrales)
Mínimo, máximo, rango.min(), .max(), .max() - .min()
Percentiles y cuartiles.quantile(0.9), .quantile([0.25, 0.5, 0.75])
Asimetría y curtosis.skew(), .kurt()
Varias a la vez.agg(["count", "mean", "median", "std"])
Frecuencias y proporciones.value_counts(), .value_counts(normalize=True)
Correlación y covarianzadf.corr(), df.corr(method="spearman"), df.cov()
Media ponderadanp.average(df["salario"], weights=df["ponderador"])
{% call py() %} encuesta["region"].value_counts(normalize=True).round(3) {% endcall %} {% call salida() %} region Cuyo 0.183 Centro 0.173 NEA 0.170 Patagonia 0.167 NOA 0.153 AMBA 0.153 Name: proportion, dtype: float64 {% endcall %} {% call py() %} encuesta[["edad", "educacion", "experiencia", "horas", "salario"]].corr().round(2) {% endcall %} {% call salida() %} edad educacion experiencia horas salario edad 1.00 -0.01 0.97 0.05 0.30 educacion -0.01 1.00 -0.25 0.04 0.51 experiencia 0.97 -0.25 1.00 0.04 0.16 horas 0.05 0.04 0.04 1.00 0.00 salario 0.30 0.51 0.16 0.00 1.00 {% endcall %}

Por defecto pandas muestra los decimales que haya. Una opción cambia el formato de todos los números que se muestren (no cambia los datos):

{% call py() %} pd.set_option("display.float_format", "{:,.2f}".format) encuesta[["horas", "salario"]].agg(["mean", "median"]) {% endcall %} {% call salida() %} horas salario mean 40.00 1,470,643.33 median 40.00 1,351,000.00 {% endcall %} {% call py() %} pd.reset_option("display.float_format") # vuelve al formato normal {% endcall %}

Agrupar y resumir{{ ancla('agrupar') }}

groupby separa la tabla en grupos, calcula algo en cada uno y junta los resultados. Es el equivalente a una tabla dinámica.

{% call py() %} ventas.groupby("provincia")["total"].sum() {% endcall %} {% call salida() %} provincia Buenos Aires 11410.50 CABA 23370.00 Córdoba 22736.00 Mendoza 2890.00 Santa Fe 8840.85 Name: total, dtype: float64 {% endcall %}

Para varios resultados con nombre propio, agg recibe nombre=("columna", "función"):

{% call py() %} resumen = ventas.groupby("provincia").agg( pedidos=("total", "size"), unidades=("cantidad", "sum"), facturacion=("total", "sum"), ticket_promedio=("total", "mean"), ) resumen.round(2).sort_values("facturacion", ascending=False) {% endcall %} {% call salida() %} pedidos unidades facturacion ticket_promedio provincia CABA 3 13 23370.00 7790.00 Córdoba 3 6 22736.00 7578.67 Buenos Aires 2 3 11410.50 5705.25 Santa Fe 1 2 8840.85 8840.85 Mendoza 1 1 2890.00 2890.00 {% endcall %}

Funciones que acepta: "size" (filas), "count" (no vacíos), "sum", "mean", "median", "min", "max", "std", "nunique" (distintos), "first" y "last".

Con dos columnas se agrupa por combinación. as_index=False deja los grupos como columnas comunes, cómodo para seguir trabajando o guardar:

{% call py() %} ventas.groupby(["provincia", "producto"], as_index=False)["cantidad"].sum() {% endcall %} {% call salida() %} provincia producto cantidad 0 Buenos Aires Café 500g 1 1 Buenos Aires Yerba 1kg 2 2 CABA Azúcar 1kg 9 3 CABA Yerba 1kg 4 4 Córdoba Aceite 900ml 2 5 Córdoba Café 500g 2 6 Córdoba Yerba 1kg 2 7 Mendoza Aceite 900ml 1 8 Santa Fe Café 500g 2 {% endcall %}

transform calcula por grupo pero devuelve un valor por fila, alineado con la tabla original. Sirve para participaciones y comparaciones contra el promedio del grupo:

{% call py() %} ventas["participacion"] = ventas["total"] / ventas.groupby("provincia")["total"].transform("sum") ventas[["provincia", "cliente", "total", "participacion"]].round(3).head(4) {% endcall %} {% call salida() %} provincia cliente total participacion 0 Buenos Aires Ana Pérez 6210.0 0.544 1 Buenos Aires Juan Gómez 5200.5 0.456 2 Córdoba María López 6555.0 0.288 3 CABA Pedro Ruiz 3300.0 0.141 {% endcall %}

Tablas dinámicas y tablas de contingencia

{% call py() %} pd.pivot_table( ventas, index="provincia", columns="rubro", values="total", aggfunc="sum", fill_value=0, margins=True, margins_name="Total", ) {% endcall %} {% call salida() %} rubro Almacén Infusiones Total provincia Buenos Aires 0.0 11410.50 11410.50 CABA 9570.0 13800.00 23370.00 Córdoba 5780.0 16956.00 22736.00 Mendoza 2890.0 0.00 2890.00 Santa Fe 0.0 8840.85 8840.85 Total 18240.0 51007.35 69247.35 {% endcall %}

pd.crosstab cuenta combinaciones de dos variables. Con normalize="index" da la proporción dentro de cada fila:

{% call py() %} pd.crosstab(encuesta["region"], encuesta["sexo"], normalize="index").round(2) {% endcall %} {% call salida() %} sexo mujer varón region AMBA 0.52 0.48 Centro 0.52 0.48 Cuyo 0.51 0.49 NEA 0.53 0.47 NOA 0.54 0.46 Patagonia 0.58 0.42 {% endcall %} {% call py() %} ventas.groupby("tramo", observed=True)["total"].agg(["count", "sum"]) {% endcall %} {% call salida() %} count sum tramo bajo 2 6190.00 medio 5 30015.50 alto 3 33041.85 {% endcall %}

Combinar tablas{{ ancla('combinar') }}

merge junta dos tablas por una columna en común, como un BUSCARV. validate="m:1" verifica que del lado derecho cada producto aparezca una sola vez; si no, corta con un error en lugar de duplicar filas sin avisar.

{% call py() %} productos = pd.DataFrame({ "producto": ["Yerba 1kg", "Café 500g", "Azúcar 1kg", "Aceite 900ml"], "costo": [2300.0, 3600.0, 700.0, 2100.0], }) con_costo = ventas.merge(productos, on="producto", how="left", validate="m:1") con_costo["margen"] = con_costo["total"] - con_costo["cantidad"] * con_costo["costo"] con_costo.groupby("producto")[["total", "margen"]].sum() {% endcall %} {% call salida() %} total margen producto Aceite 900ml 8670.00 2370.00 Azúcar 1kg 9570.00 3270.00 Café 500g 24442.35 6442.35 Yerba 1kg 26565.00 8165.00 {% endcall %}
how=Se queda con
"left"Todas las filas de la tabla de la izquierda; lo que no encuentra queda NaN.
"inner" (por defecto)Sólo las filas que están en las dos.
"right"Todas las de la derecha.
"outer"Todas las de las dos.

Para saber qué no se cruzó, indicator=True agrega la columna _merge. Si la clave se llama distinto en cada tabla: left_on="cliente", right_on="nombre".

{% call py() %} clientes = pd.DataFrame({ "cliente": ["Ana Pérez", "Juan Gómez", "Marta Ríos"], "segmento": ["mayorista", "minorista", "minorista"], }) ventas[["cliente"]].drop_duplicates().merge(clientes, on="cliente", how="outer", indicator=True) {% endcall %} {% call salida() %} cliente segmento _merge 0 Ana Pérez mayorista both 1 Juan Gómez minorista both 2 Lucía Fernández NaN left_only 3 Marta Ríos minorista right_only 4 María López NaN left_only 5 Pedro Ruiz NaN left_only 6 Sofía Díaz NaN left_only {% endcall %}

pd.concat apila tablas con las mismas columnas, por ejemplo un CSV por mes. glob busca los archivos por patrón y assign anota de cuál vino cada fila:

{% call py() %} import glob ventas.iloc[:5].to_csv("ventas_parte1.csv", index=False) ventas.iloc[5:].to_csv("ventas_parte2.csv", index=False) archivos = sorted(glob.glob("ventas_parte*.csv")) juntas = pd.concat( [pd.read_csv(nombre).assign(archivo=nombre) for nombre in archivos], ignore_index=True, ) juntas["archivo"].value_counts() {% endcall %} {% call salida() %} archivo ventas_parte1.csv 5 ventas_parte2.csv 5 Name: count, dtype: int64 {% endcall %}

Formato ancho y largo{{ ancla('reestructurar') }}

Muchas planillas vienen en formato ancho: una columna por mes o por año. Para agrupar, filtrar o graficar conviene el formato largo: una fila por observación. melt pasa de ancho a largo y pivot, de largo a ancho. (Los valores son inventados.)

{% call py() %} ipc = pd.DataFrame({ "region": ["GBA", "Noreste", "Patagonia"], "2026-01": [2.1, 2.4, 1.9], "2026-02": [2.3, 2.2, 2.0], "2026-03": [1.8, 2.0, 2.1], }) largo = ipc.melt(id_vars="region", var_name="mes", value_name="variacion") largo.head(4) {% endcall %} {% call salida() %} region mes variacion 0 GBA 2026-01 2.1 1 Noreste 2026-01 2.4 2 Patagonia 2026-01 1.9 3 GBA 2026-02 2.3 {% endcall %} {% call py() %} largo.pivot(index="mes", columns="region", values="variacion") {% endcall %} {% call salida() %} region GBA Noreste Patagonia mes 2026-01 2.1 2.4 1.9 2026-02 2.3 2.2 2.0 2026-03 1.8 2.0 2.1 {% endcall %}

pivot falla si una combinación de índice y columna aparece dos veces. En ese caso usá pivot_table, que resume los repetidos con aggfunc.

Series de tiempo{{ ancla('series') }}

Con las fechas como índice, pandas entiende el tiempo: puede sumar por mes, comparar contra el período anterior o calcular promedios móviles. pd.date_range genera fechas; "MS" es el primer día de cada mes.

{% call py() %} meses = pd.date_range("2024-01-01", periods=24, freq="MS") serie = pd.DataFrame( { "ventas": np.round(1000 * 1.04 ** np.arange(24) + np.tile([0.0, 40.0, -30.0], 8)), "ipc": np.round(100 * 1.03 ** np.arange(24), 1), }, index=meses, ) serie.head(3) {% endcall %} {% call salida() %} ventas ipc 2024-01-01 1000.0 100.0 2024-02-01 1080.0 103.0 2024-03-01 1052.0 106.1 {% endcall %} {% call py() %} serie[["ventas"]].assign( anterior=serie["ventas"].shift(1), variacion=serie["ventas"].pct_change().round(3), interanual=serie["ventas"].pct_change(12).round(3), media_movil=serie["ventas"].rolling(3).mean().round(1), acumulado=serie["ventas"].cumsum(), ).iloc[12:15] {% endcall %} {% call salida() %} ventas anterior variacion interanual media_movil acumulado 2025-01-01 1601.0 1509.0 0.061 0.601 1543.3 16667.0 2025-02-01 1705.0 1601.0 0.065 0.579 1605.0 18372.0 2025-03-01 1702.0 1705.0 -0.002 0.618 1669.3 20074.0 {% endcall %}
ParaComando
Valor del período anterior.shift(1)
Diferencia con el anterior.diff()
Variación porcentual (mensual, interanual).pct_change(), .pct_change(12)
Promedio móvil de 3 períodos.rolling(3).mean()
Acumulado.cumsum()
Un año o un rango de fechasserie.loc["2025"], serie.loc["2025-03":"2025-06"]
Cambiar la frecuencia.resample("QE").sum(), .resample("YE").mean()

resample agrupa por períodos. Los códigos más usados: "D" día, "W" semana, "MS" y "ME" inicio y fin de mes, "QS" y "QE" de trimestre, "YS" y "YE" de año.

{% call py() %} serie["ventas"].resample("QE").sum().head(4) {% endcall %} {% call salida() %} 2024-03-31 3132.0 2024-06-30 3522.0 2024-09-30 3960.0 2024-12-31 4452.0 Freq: QE-DEC, Name: ventas, dtype: float64 {% endcall %} {% call py(aviso="FutureWarning") %} serie["ventas"].resample("Q").sum().head(1) {% endcall %}

Las ventas del CSV limpio, por semana. La fila sin fecha queda afuera sola:

{% call py() %} ventas.set_index("fecha")["total"].resample("W").sum() {% endcall %} {% call salida() %} fecha 2026-01-04 17965.50 2026-01-11 37481.85 Name: total, dtype: float64 {% endcall %}

Deflactar

Para comparar montos de distintos meses con inflación, se dividen por un índice de precios. Multiplicar por el último valor del índice los expresa a pesos del último mes:

{% call py() %} serie["ventas_reales"] = (serie["ventas"] / serie["ipc"] * serie["ipc"].iloc[-1]).round() serie[["ventas", "ipc", "ventas_reales"]].iloc[[0, 12, 23]] {% endcall %} {% call salida() %} ventas ipc ventas_reales 2024-01-01 1000.0 100.0 1974.0 2025-01-01 1601.0 142.6 2216.0 2025-12-01 2435.0 197.4 2435.0 {% endcall %}

Estacionariedad y pronóstico

statsmodels trae las herramientas clásicas: la prueba de Dickey-Fuller aumentada, la autocorrelación y los modelos ARIMA. En la Documentación de ChartBay está explicado qué mide cada una.

{% call py() %} from statsmodels.tsa.stattools import acf, adfuller estadistico, p_valor, *_ = adfuller(serie["ventas"]) print(f"ADF = {estadistico:.2f}, p-valor = {p_valor:.3f}") print("Autocorrelación:", acf(serie["ventas"], nlags=3).round(2)) {% endcall %} {% call salida() %} ADF = 2.98, p-valor = 1.000 Autocorrelación: [1. 0.87 0.74 0.62] {% endcall %}

Un p-valor alto no permite rechazar que la serie tenga raíz unitaria: es lo esperable en una serie que crece mes a mes. Lo habitual es trabajar con sus variaciones (pct_change() o diff()) o con un modelo que la diferencie, como el ARIMA con d=1:

{% call py() %} from statsmodels.tsa.arima.model import ARIMA ajuste = ARIMA(serie["ventas"], order=(1, 1, 0)).fit() ajuste.forecast(steps=3).round() {% endcall %} {% call salida() %} 2026-01-01 2446.0 2026-02-01 2450.0 2026-03-01 2452.0 Freq: MS, Name: predicted_mean, dtype: float64 {% endcall %}

NumPy esencial{{ ancla('numpy') }}

pandas está construido sobre NumPy, y muchas cuentas se escriben directo con sus funciones. np.nan es el valor faltante; las funciones que empiezan con nan lo ignoran.

{% call py() %} a = np.array([1.5, 2.0, np.nan, 4.0]) print(np.nanmean(a), np.nanmedian(a), np.isnan(a).sum()) print(np.round(np.sqrt(a), 2)) print(np.where(a > 1.8, "alto", "bajo")) # ojo: NaN > 1.8 es False {% endcall %} {% call salida() %} 2.5 2.0 1 [1.22 1.41 nan 2. ] ['bajo' 'alto' 'bajo' 'alto'] {% endcall %} {% call py() %} print(np.log(100), np.exp(1), np.log10(1000)) print(np.arange(0, 1, 0.25), np.linspace(0, 1, 5)) print(np.percentile([10, 20, 30, 40, 50], 90)) {% endcall %} {% call salida() %} 4.605170185988092 2.718281828459045 3.0 [0. 0.25 0.5 0.75] [0. 0.25 0.5 0.75 1. ] 46.0 {% endcall %}

Números al azar para simular o para tomar muestras. Con la misma semilla salen siempre los mismos:

{% call py() %} rng = np.random.default_rng(42) print(rng.normal(loc=0, scale=1, size=3).round(3)) print(rng.integers(1, 7, size=5)) # como tirar un dado: del 1 al 6 print(rng.choice(["AMBA", "Centro", "Cuyo", "NEA", "NOA", "Patagonia"], size=3)) print(rng.permutation(5)) # los números del 0 al 4, mezclados {% endcall %} {% call salida() %} [ 0.305 -1.04 0.75 ] [1 5 2 1 4] ['Patagonia' 'NOA' 'NOA'] [1 4 2 3 0] {% endcall %} {% call py() %} salarios = np.array([500_000.0, 800_000.0, 1_200_000.0]) ponderadores = np.array([120, 60, 20]) # cuántas personas representa cada caso np.average(salarios, weights=ponderadores) {% endcall %} {% call salida() %} 660000.0 {% endcall %}

La media ponderada es la que se usa con encuestas que traen un ponderador, como la EPH del INDEC.

Pruebas estadísticas{{ ancla('pruebas') }}

scipy.stats tiene las pruebas de hipótesis habituales. Todas devuelven el estadístico y el p-valor: si es menor que el nivel elegido (por ejemplo 0,05), se rechaza la hipótesis nula. El p-valor no mide el tamaño del efecto ni prueba causalidad.

{% call py() %} from scipy import stats mujeres = encuesta.loc[encuesta["sexo"] == "mujer", "salario"] varones = encuesta.loc[encuesta["sexo"] == "varón", "salario"] prueba = stats.ttest_ind(mujeres, varones, equal_var=False) # t de Welch print(f"t = {prueba.statistic:.2f}, p-valor = {prueba.pvalue:.4f}") {% endcall %} {% call salida() %} t = -4.06, p-valor = 0.0001 {% endcall %}
PreguntaPruebaCódigo
¿Dos grupos tienen distinta media?t de Welchstats.ttest_ind(a, b, equal_var=False)
Lo mismo, sin suponer normalidadMann-Whitneystats.mannwhitneyu(a, b)
¿Cambió en los mismos casos (antes y después)?t pareadastats.ttest_rel(antes, despues)
¿La media es distinta de un valor?t de una muestrastats.ttest_1samp(x, 40)
¿Tres o más grupos tienen distinta media?ANOVAstats.f_oneway(g1, g2, g3)
¿Dos variables categóricas están asociadas?Chi-cuadradostats.chi2_contingency(pd.crosstab(a, b))
¿Dos variables numéricas se mueven juntas?Pearson, Spearmanstats.pearsonr(x, y), stats.spearmanr(x, y)
¿Los datos son normales?Shapiro-Wilkstats.shapiro(x)
Valores críticosNormal, tstats.norm.ppf(0.975), stats.t.ppf(0.975, df=29)
{% call py() %} tabla = pd.crosstab(encuesta["region"], encuesta["sexo"]) chi2, p_valor, grados, esperados = stats.chi2_contingency(tabla) print(f"chi2 = {chi2:.2f}, gl = {grados}, p-valor = {p_valor:.3f}") r, p_valor = stats.pearsonr(encuesta["educacion"], encuesta["salario"]) print(f"r = {r:.2f}, p-valor = {p_valor:.2g}") grupos = [grupo["salario"] for _, grupo in encuesta.groupby("region")] anova = stats.f_oneway(*grupos) print(f"ANOVA: F = {anova.statistic:.2f}, p-valor = {anova.pvalue:.3f}") {% endcall %} {% call salida() %} chi2 = 0.66, gl = 5, p-valor = 0.985 r = 0.51, p-valor = 3.1e-21 ANOVA: F = 1.47, p-valor = 0.199 {% endcall %}

Un intervalo de confianza del 95 % para una media, con la distribución t:

{% call py() %} horas = encuesta["horas"] bajo, alto = stats.t.interval(0.95, df=len(horas) - 1, loc=horas.mean(), scale=stats.sem(horas)) print(f"Horas semanales: {horas.mean():.1f} (IC 95 %: {bajo:.1f} a {alto:.1f})") {% endcall %} {% call salida() %} Horas semanales: 40.0 (IC 95 %: 39.1 a 40.9) {% endcall %}

Regresión con statsmodels{{ ancla('regresion') }}

La forma más cómoda es con fórmulas: se escribe el modelo como texto y statsmodels arma las variables, agrega la constante y convierte las categóricas.

{% call py() %} import statsmodels.formula.api as smf modelo = smf.ols("salario ~ educacion + experiencia", data=encuesta).fit() print(modelo.summary()) {% endcall %}

El resumen trae todo, pero cada parte también se puede pedir por separado, por ejemplo para armar una tabla propia:

{% call py() %} pd.DataFrame({ "coeficiente": modelo.params, "error_estandar": modelo.bse, "t": modelo.tvalues, "p_valor": modelo.pvalues, }).round(4) {% endcall %} {% call salida() %} coeficiente error_estandar t p_valor Intercept -185988.5478 135932.6489 -1.3682 0.1723 educacion 106455.1145 8786.0972 12.1163 0.0000 experiencia 14140.9223 2237.0151 6.3213 0.0000 {% endcall %} {% call py() %} print(f"R² = {modelo.rsquared:.3f}, R² ajustado = {modelo.rsquared_adj:.3f}, n = {int(modelo.nobs)}") modelo.conf_int(alpha=0.05).round() # intervalos de confianza del 95 % {% endcall %} {% call salida() %} R² = 0.348, R² ajustado = 0.343, n = 300 0 1 Intercept -453502.0 81525.0 educacion 89164.0 123746.0 experiencia 9739.0 18543.0 {% endcall %}
En la fórmulaSignifica
y ~ x1 + x2y explicada por x1 y x2, con constante.
np.log(y)Logaritmo (hace falta haber hecho import numpy as np).
I(x ** 2)x al cuadrado: I() hace que la cuenta se tome literal.
C(region)Variable categórica: una dummy por categoría, menos la base.
C(region, Treatment("NOA"))Elegir la categoría base.
x1:x2Sólo la interacción.
x1 * x2x1 + x2 + x1:x2.
- 1Sin constante.

Una ecuación de Mincer: logaritmo del salario, experiencia al cuadrado, dummies de sexo y región y errores estándar robustos a heterocedasticidad (cov_type="HC1"):

{% call py() %} mincer = smf.ols( "np.log(salario) ~ educacion + experiencia + I(experiencia ** 2) + C(sexo) + C(region)", data=encuesta, ).fit(cov_type="HC1") mincer.params.round(4) {% endcall %} {% call salida() %} Intercept 12.7601 C(sexo)[T.varón] 0.1774 C(region)[T.Centro] 0.0115 C(region)[T.Cuyo] -0.1256 C(region)[T.NEA] -0.0174 C(region)[T.NOA] 0.0076 C(region)[T.Patagonia] 0.0488 educacion 0.0708 experiencia 0.0337 I(experiencia ** 2) -0.0005 dtype: float64 {% endcall %} {% call py() %} retorno = mincer.params["educacion"] * 100 print(f"Cada año más de educación se asocia con un salario {retorno:.1f} % más alto") {% endcall %} {% call salida() %} Cada año más de educación se asocia con un salario 7.1 % más alto {% endcall %}

Con el modelo estimado, predict calcula valores para casos nuevos:

{% call py() %} nuevos = pd.DataFrame({"educacion": [12, 17], "experiencia": [10, 10]}) modelo.predict(nuevos).round() {% endcall %} {% call salida() %} 0 1232882.0 1 1765158.0 dtype: float64 {% endcall %}

Variable dependiente binaria

Para un sí/no (0 o 1), un logit. disp=0 evita que imprima las iteraciones; exponenciar los coeficientes da los odds ratios.

{% call py() %} encuesta["salario_alto"] = (encuesta["salario"] > encuesta["salario"].median()).astype("int64") logit = smf.logit("salario_alto ~ educacion + experiencia + C(sexo)", data=encuesta).fit(disp=0) np.exp(logit.params).round(3) {% endcall %} {% call salida() %} Intercept 0.002 C(sexo)[T.varón] 1.455 educacion 1.450 experiencia 1.059 dtype: float64 {% endcall %}

Diagnósticos

{% call py() %} import statsmodels.api as sm from statsmodels.stats.diagnostic import het_breuschpagan from statsmodels.stats.outliers_influence import variance_inflation_factor _, p_bp, _, _ = het_breuschpagan(modelo.resid, modelo.model.exog) print(f"Breusch-Pagan (heterocedasticidad): p-valor = {p_bp:.3f}") X = sm.add_constant(encuesta[["educacion", "experiencia", "horas"]]) vif = pd.Series([variance_inflation_factor(X.values, i) for i in range(1, X.shape[1])], index=X.columns[1:]) print("VIF (multicolinealidad):", vif.round(2).to_dict()) {% endcall %} {% call salida() %} Breusch-Pagan (heterocedasticidad): p-valor = 0.003 VIF (multicolinealidad): {'educacion': 1.07, 'experiencia': 1.07, 'horas': 1.0} {% endcall %}

Gráficos sin matplotlib{{ ancla('graficos') }}

ChartPython no trae matplotlib, así que df.plot() no funciona. Para mirar una distribución rápido alcanzan unas barras de texto:

{% call py() %} por_producto = ventas.groupby("producto")["total"].sum().sort_values(ascending=False) largos = (por_producto / por_producto.max() * 30).round().astype("int64") for producto, largo in largos.items(): print(f"{producto:<14}{'█' * largo} {por_producto[producto]:,.0f}") {% endcall %} {% call salida() %} Yerba 1kg ██████████████████████████████ 26,565 Café 500g ████████████████████████████ 24,442 Azúcar 1kg ███████████ 9,570 Aceite 900ml ██████████ 8,670 {% endcall %}

Para un gráfico de verdad, ChartPython muestra cualquier objeto que sepa dibujarse como SVG (el método _repr_svg_, igual que en Jupyter). Estas dos funciones arman barras y líneas; copialas una vez en tu notebook y usalas con cualquier Series:

{% call py() %} from html import escape class SVG: """Muestra un dibujo SVG como salida de la celda.""" def __init__(self, codigo): self.codigo = codigo def _repr_svg_(self): return self.codigo def grafico_barras(serie, titulo="", ancho=640, color="#1e5fa8"): """Barras horizontales: una por cada valor de la serie.""" serie = serie.dropna() fila, arriba, izquierda = 28, 36, 150 maximo = max(serie.max(), 0) or 1 partes = [ '', f'{escape(str(titulo))}', ] for i, (etiqueta, valor) in enumerate(serie.items()): y = arriba + i * fila largo = max(valor, 0) / maximo * (ancho - izquierda - 90) partes.append( f'{escape(str(etiqueta))}' f'' f'{valor:,.0f}' ) alto = arriba + fila * len(serie) + 8 return SVG( f'{"".join(partes)}' ) def grafico_lineas(serie, titulo="", ancho=640, alto=280, color="#1e5fa8"): """Una línea para una serie ordenada, por ejemplo con fechas en el índice.""" serie = serie.dropna() izquierda, derecha, arriba, abajo = 70, 20, 36, 40 minimo, maximo = serie.min(), serie.max() rango = (maximo - minimo) or 1 paso = (ancho - izquierda - derecha) / max(len(serie) - 1, 1) def x(i): return izquierda + i * paso def y(valor): return arriba + (maximo - valor) / rango * (alto - arriba - abajo) puntos = " ".join(f"{x(i):.1f},{y(v):.1f}" for i, v in enumerate(serie)) desde, hasta = (escape(str(v)[:10]) for v in (serie.index[0], serie.index[-1])) return SVG( f'' '' f'{escape(str(titulo))}' f'' f'{maximo:,.0f}' f'{minimo:,.0f}' f'' f'{desde}' f'{hasta}' "" ) {% endcall %} {% call py() %} grafico_barras(por_producto, "Facturación por producto") {% endcall %} {% call py() %} grafico_lineas(serie["ventas_reales"], "Ventas a precios del último mes") {% endcall %}

Un histograma es un gráfico de barras de los conteos por tramo. value_counts(bins=8) arma los tramos:

{% call py() %} conteo = encuesta["salario"].value_counts(bins=8, sort=False) conteo.index = [f"{tramo.left / 1e6:.1f} a {tramo.right / 1e6:.1f} M" for tramo in conteo.index] grafico_barras(conteo, "Personas por tramo de salario") {% endcall %}

Para llevarte el gráfico, hacé clic derecho sobre la imagen y guardala, o escribí el SVG en un archivo y descargalo desde el panel:

{% call py() %} grafico = grafico_lineas(serie["ventas"], "Ventas mensuales") with open("ventas_mensuales.svg", "w", encoding="utf-8") as archivo: archivo.write(grafico.codigo) {% endcall %}

Parte 6

Referencia

Errores frecuentes{{ ancla('errores') }}

Buscá la última línea del error en esta tabla. Casi siempre es uno de estos.

El mensaje diceQué pasóCómo se arregla
Al leer archivos
FileNotFoundError: No such file or directoryEl nombre no coincide exacto (mayúsculas, extensión) o el archivo lo había creado Python antes de reiniciarse.Mirá los nombres con os.listdir(). Subilo de nuevo o volvé a ejecutar la celda que lo crea.
UnicodeDecodeError: 'utf-8' codec can't decodeEl CSV no está en UTF-8 (típico de Excel en Windows).encoding="latin-1". Ver.
Todo queda en una sola columnaEl separador no es la coma.sep=";" o sep=None, engine="python".
ParserError: Error tokenizing dataUna fila tiene más campos que el encabezado: separador equivocado o comas decimales sin comillas.Revisá sep y decimal. Como último recurso, on_bad_lines="skip".
ImportError: Missing optional dependency 'openpyxl'Intentaste leer o escribir Excel.Guardalo como CSV. Ver.
Cada archivo puede ocupar hasta 10 MiBEl archivo supera el límite del panel.Sacale columnas o filas antes, por ejemplo con el Limpiador, o partilo en varios.
Columnas y tipos
KeyError: 'Cliente'La columna no se llama exactamente así: espacios, mayúsculas o acentos.df.columns.tolist() y limpiar los nombres.
ValueError: could not convert string to floatTexto con formato de número argentino, o valores como s/d.Convertir con pd.to_numeric(..., errors="coerce").
TypeError: can't multiply sequence by non-int, unsupported operand type(s)Una columna que parece numérica es texto.pd.to_numeric antes de operar; revisá df.dtypes.
Números negativos enormes o sumas imposiblesEnteros de 32 bits que se desbordaron.astype("int64"), nunca astype(int). Ver.
OverflowError: Python int too large to convert to C longLo mismo, cuando pandas se da cuenta.astype("int64").
Filtros y avisos de pandas
ValueError: The truth value of a Series is ambiguousUsaste and, or o not con columnas.&, | y ~, con cada condición entre paréntesis.
SettingWithCopyWarningModificaste una tabla que salió de un filtro..copy() o asignar con loc. Ver.
FutureWarning: 'M' is deprecatedCódigo de frecuencia viejo."ME", "QE", "YE".
FutureWarning: The default of observed=False is deprecatedAgrupaste por una columna categórica.groupby(..., observed=True).
FutureWarning: Downcasting behavior in `replace` is deprecatedreplace para convertir texto en True/False o números.Usá map con un diccionario.
FutureWarning: A value is trying to be set on a copy ... inplacedf["col"].fillna(0, inplace=True).df["col"] = df["col"].fillna(0).
Propios de ChartPython
NameError: name 'df' is not definedLa celda que lo crea no se ejecutó, o Python se reinició.⏭ Ejecutar todo.
ModuleNotFoundError: No module named 'matplotlib'Ese paquete no viene en ChartPython.Qué trae instalado.
ImportError: Missing optional dependency 'Jinja2'Usaste df.style.round() o display.float_format.
SyntaxError en una línea con %, ! o ?Comandos mágicos de Jupyter.Diferencias con Jupyter.
RuntimeError: input() no está disponibleChartPython no puede pedir datos mientras corre.Asigná el valor en una variable.
[Salida truncada…]La celda imprimió más de 1 MiB.Mostrá menos: head(), sample(), un resumen.
Python se detuvoSe quedó sin memoria o se cortó el proceso.Volvé a ejecutar; con datos grandes, leé sólo las columnas que usás.

Tres de estos, para reconocerlos cuando aparezcan:

{% call py(error="FileNotFoundError") %} pd.read_csv("Ventas.csv") # el archivo se llama ventas.csv, en minúscula {% endcall %} {% call salida_error() %} FileNotFoundError: [Errno 44] No such file or directory: 'Ventas.csv' {% endcall %} {% call py(error="KeyError") %} crudo = pd.read_csv("ventas.csv", sep=";") crudo["Cliente"] # la columna es "Cliente " con un espacio {% endcall %} {% call salida_error() %} KeyError: 'Cliente' {% endcall %} {% call py(error="ParserError") %} import io texto = "producto,precio\nYerba,3450\nCafé,5200,50\n" # coma decimal en un CSV con comas pd.read_csv(io.StringIO(texto)) {% endcall %} {% call salida_error() %} ParserError: Error tokenizing data. C error: Expected 2 fields in line 3, saw 3 {% endcall %}

Chuleta de comandos{{ ancla('chuleta') }}

Lo más usado en una sola tabla. df es cualquier DataFrame y "col", cualquier columna.

Quiero…Comando
Leer y guardar
Leer un CSV separado por punto y comadf = pd.read_csv("archivo.csv", sep=";")
Leer con coma decimal y punto de milespd.read_csv("archivo.csv", sep=";", decimal=",", thousands=".")
Leer un CSV de Excel en Windowspd.read_csv("archivo.csv", sep=";", encoding="latin-1")
Conservar ceros a la izquierdapd.read_csv("archivo.csv", dtype={"codigo": str})
Ver qué archivos hayimport os; os.listdir()
Guardar un CSVdf.to_csv("salida.csv", index=False)
Guardar un CSV para Excel en castellanodf.to_csv("salida.csv", index=False, sep=";", decimal=",", encoding="utf-8-sig")
Explorar
Primeras y últimas filasdf.head(10), df.tail(5)
Filas y columnasdf.shape
Nombres y tipos de columnasdf.columns.tolist(), df.dtypes, df.info()
Resumen estadísticodf.describe(), df.describe(include="all")
Faltantes por columnadf.isna().sum()
Frecuencia de cada valordf["col"].value_counts(dropna=False)
Valores distintosdf["col"].unique(), df["col"].nunique()
Seleccionar y filtrar
Una o varias columnasdf["col"], df[["col1", "col2"]]
Por etiqueta o por posicióndf.loc[fila, "col"], df.iloc[0, 2]
Filas que cumplen una condicióndf[df["col"] > 100]
Dos condicionesdf[(df["a"] > 1) & (df["b"] == "x")]
Valores de una listadf[df["col"].isin(["a", "b"])]
Texto que contiene algodf[df["col"].str.contains("algo", case=False, na=False)]
Ordenardf.sort_values("col", ascending=False)
Limpiar
Nombres de columnas prolijosdf.columns = df.columns.str.strip().str.lower().str.replace(r"\W+", "_", regex=True)
Renombrardf = df.rename(columns={"viejo": "nuevo"})
Borrar filas vacíasdf = df.dropna(how="all")
Borrar duplicadosdf = df.drop_duplicates()
Espacios y mayúsculasdf["col"] = df["col"].str.strip().str.title()
Reemplazar textodf["col"].str.replace("viejo", "nuevo", regex=False)
Unificar categoríasdf["col"].map({"bs as": "Buenos Aires", "caba": "CABA"})
Texto a númeropd.to_numeric(df["col"], errors="coerce")
“$ 1.234,56” a númeropd.to_numeric(df["col"].str.replace("$", "", regex=False).str.replace(".", "", regex=False).str.replace(",", ".", regex=False), errors="coerce")
Texto a fechapd.to_datetime(df["col"], format="%d/%m/%Y", errors="coerce")
Completar faltantesdf["col"] = df["col"].fillna(0)
Borrar filas con faltantes en una columnadf = df.dropna(subset=["col"])
Cambiar el tipodf["col"].astype("int64"), .astype(str), .astype("category")
Transformar
Columna calculadadf["total"] = df["cantidad"] * df["precio"]
Según una condiciónnp.where(df["col"] > 100, "alto", "bajo")
Tramospd.cut(df["col"], bins=[0, 10, 100, np.inf]), pd.qcut(df["col"], q=4)
Aplicar una funcióndf["col"].apply(funcion)
Partes de una fechadf["fecha"].dt.year, .dt.month, .dt.to_period("M")
Borrar columnasdf = df.drop(columns=["col"])
Resumir y combinar
Total por grupodf.groupby("grupo")["valor"].sum()
Varias medidas por grupodf.groupby("grupo").agg(n=("valor", "size"), media=("valor", "mean"))
Tabla dinámicapd.pivot_table(df, index="fila", columns="columna", values="valor", aggfunc="sum")
Tabla de contingenciapd.crosstab(df["a"], df["b"], normalize="index")
Participación dentro del grupodf["valor"] / df.groupby("grupo")["valor"].transform("sum")
Unir dos tablasdf.merge(otra, on="clave", how="left")
Apilar tablaspd.concat([df1, df2], ignore_index=True)
De ancho a largo y al revésdf.melt(id_vars="id"), df.pivot(index="id", columns="var", values="valor")
Tiempo y estadística
Sumar por mesdf.set_index("fecha")["valor"].resample("ME").sum()
Variación y promedio móvils.pct_change(), s.rolling(3).mean()
Correlacionesdf.corr()
Diferencia de mediasstats.ttest_ind(a, b, equal_var=False)
Regresión linealsmf.ols("y ~ x1 + x2", data=df).fit()
Con errores robustossmf.ols("y ~ x1 + C(grupo)", data=df).fit(cov_type="HC1")
Logitsmf.logit("y ~ x1 + x2", data=df).fit(disp=0)

Escrito para Pyodide 0.26.4 con pandas 2.2.0, NumPy 1.26.4, SciPy 1.12.0 y statsmodels 0.14.2, las versiones que trae ChartPython. En otra instalación de Python casi todo funciona igual; las diferencias están en Diferencias con Jupyter. Material educativo: ningún modelo garantiza resultados.