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') }}
- Entrá a ChartPython. Arranca con un notebook que tiene una celda vacía.
- Hacé clic en la celda, escribí
2 + 2y apretá Shift+Enter. - 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.
- El resultado aparece debajo de la celda, marcado con
Out[1]:.
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.
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.
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.
| Teclas | Modo | Qué hace |
|---|---|---|
| Shift+Enter | los dos | Ejecuta la celda y pasa a la siguiente. Si es la última, crea una nueva. |
| Ctrl+Enter (Cmd en Mac) | los dos | Ejecuta y se queda en la celda. |
| Alt+Enter | los dos | Ejecuta e inserta una celda de código debajo. |
| Ctrl+S | los dos | Guarda el notebook ya (igual se guarda solo). |
| Tab / Shift+Tab | edición | Agrega o saca un nivel de sangría (4 espacios). |
| Ctrl+/ | edición | Comenta o descomenta las líneas elegidas. |
| Esc | edición | Pasa a modo comando. |
| Enter | comando | Edita la celda seleccionada. |
| A / B | comando | Inserta una celda arriba o abajo. |
| D D | comando | Borra la celda (D dos veces). |
| Z | comando | Recupera la última celda borrada. |
| M / Y | comando | Convierte la celda en texto o en código. |
| ↑ ↓ o K J | comando | Selecciona la celda anterior o la siguiente. |
| Shift+↑ / Shift+↓ | comando | Mueve la celda hacia arriba o hacia abajo. |
| I I | comando | Interrumpe lo que se está ejecutando (reinicia Python). |
| 0 0 | comando | Reinicia 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ítuloy así hasta seis niveles.**negrita**,*cursiva*y`código`.- Listas con
-o con1., 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.
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.
| En el panel dice | Qué significa |
|---|---|
| subido | Lo 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 Python | Lo 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
importy los archivos creados por Python. - Se conservan las celdas, sus salidas y los archivos que subiste, que vuelven a
/mnt/dataantes 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') }}
| Paquete | Versión | Para qué |
|---|---|---|
| Python | 3.12 | El lenguaje, con casi toda su biblioteca estándar. |
| pandas | 2.2.0 | Tablas: leer CSV, limpiar, agrupar, combinar. |
| NumPy | 1.26.4 | Cálculo numérico y números al azar. |
| SciPy | 1.12.0 | Pruebas estadísticas y distribuciones. |
| statsmodels | 0.14.2 | Regresiones, pruebas de diagnóstico y series de tiempo. |
| patsy | 0.5.6 | Las fórmulas del estilo y ~ x1 + x2. |
| python-dateutil, pytz | Fechas 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.
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 andar | Qué usar |
|---|---|---|
| matplotlib, seaborn, plotly | Gráficos, df.plot() | Gráficos sin matplotlib |
| openpyxl, xlrd | pd.read_excel(), df.to_excel() | Trabajar con CSV |
| pyarrow | Parquet y Feather | CSV o JSON |
| Jinja2 | df.style (colores y formatos de tabla) | round() y pd.set_option("display.float_format", …) |
| tabulate | df.to_markdown() | print(df.to_string()) |
| scikit-learn | Modelos de machine learning | statsmodels para regresión y clasificación con logit |
| requests | Descargar datos de internet | Bajar el archivo y subirlo al panel |
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:
display() ya viene incluido. Si copiás código que hace from IPython.display import display, borrá esa línea:
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.
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.
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.
Para pasar de un tipo a otro están int(), float() y str(). Python sólo entiende el punto decimal:
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).
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 :.
Para comparar: == (igual), != (distinto), <, <=, >, >=. Para combinar condiciones: and, or y not; in pregunta si un valor está en una lista o en un texto.
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.
try / except atrapa un error esperado para decidir qué hacer en lugar de que se corte todo:
Los módulos se cargan con import. Los alias pd y np son una convención que vas a ver en todos lados.
help() muestra la documentación de cualquier función, en inglés. Con help(pd.read_csv) ves todos sus parámetros.
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.
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.
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:
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.
Los parámetros que más se usan
| Parámetro | Cuándo | Ejemplo |
|---|---|---|
sep | Separador distinto de la coma. Con sep=None pandas intenta adivinarlo. | sep=";", sep="\t", sep=None, engine="python" |
decimal, thousands | Números escritos como 1.234,56. | decimal=",", thousands="." |
encoding | Acentos rotos o UnicodeDecodeError. | encoding="latin-1", encoding="utf-8-sig" |
dtype | Columnas que tienen que quedar como texto: códigos, DNI, CUIT, códigos postales. | dtype={"cuit": str} |
na_values | Textos que en tu archivo significan “sin dato”. | na_values=["s/d", "-", "N/A"] |
usecols | Leer sólo algunas columnas. | usecols=["Fecha", "Cantidad"] |
nrows, skiprows | Leer una parte, o saltear renglones de título antes del encabezado. | nrows=1000, skiprows=3 |
header, names | El archivo no tiene fila de encabezado. | header=None, names=["fecha", "valor"] |
parse_dates | Leer columnas como fecha (conviene cuando ya vienen en formato 2026-01-31). | parse_dates=["fecha"] |
on_bad_lines | Descartar filas mal formadas (con cuidado: se pierden datos). | on_bad_lines="skip" |
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:
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:
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:
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.
| Comando | Muestra |
|---|---|
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.dtypes | El 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. |
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).
Si una tabla tiene muchas columnas o filas, pandas muestra … en el medio. Estas opciones cambian cuánto se ve:
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:
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:
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:
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.
| Método | Hace |
|---|---|
.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:
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.
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.
Sí y no
Para columnas de dos valores, un diccionario con todas las variantes y map:
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).
| En el archivo | Formato |
|---|---|
31/12/2026 | format="%d/%m/%Y" |
31-12-26 | format="%d-%m-%y" |
2026-12-31 | format="%Y-%m-%d" |
31/12/2026 18:45 | format="%d/%m/%Y %H:%M" |
| Mezclados | format="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):
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.
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 %}| Para | Comando |
|---|---|
| Porcentaje de faltantes por columna | df.isna().mean().round(3) |
| Ver las filas con algún faltante | df[df.isna().any(axis=1)] |
| Borrar filas sin fecha | df = df.dropna(subset=["fecha"]) |
| Completar con un valor fijo | df["col"] = df["col"].fillna(0) |
| Completar texto | df["col"] = df["col"].fillna("Sin dato") |
| Media o mediana | df["col"].fillna(df["col"].median()) |
| Mediana de su grupo | df["col"].fillna(df.groupby("grupo")["col"].transform("median")) |
| El valor anterior (series de tiempo) | df["col"].ffill() |
| Interpolar entre vecinos | df["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:
| Tipo | Para |
|---|---|
"int64" | Enteros. No admite faltantes. |
"Int64" (con mayúscula) | Enteros con faltantes, que se ven como <NA>. |
"float64" o float | Decimales; admite NaN. |
str (queda como object) | Texto, códigos, DNI, CUIT. |
bool | Verdadero o falso. |
"category" | Texto con pocos valores que se repiten mucho (provincia, sexo): ocupa menos memoria. |
datetime64[ns] | Fechas, con pd.to_datetime. |
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.
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):
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.
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:
locusa etiquetas: el valor del índice y el nombre de la columna. Los rangos incluyen el final:loc[0:2]son tres filas.ilocusa posiciones, como una lista:iloc[0:2]son dos filas.
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:
Para combinar condiciones se usan & (y), | (o) y ~ (no), con cada condición entre paréntesis:
| Condición | Se escribe |
|---|---|
| Igual, distinto, mayor… | df["provincia"] == "CABA", !=, >, >= |
| Y, o, no | (cond1) & (cond2), (cond1) | (cond2), ~cond |
| Está en una lista | df["provincia"].isin(["CABA", "Córdoba"]) |
| Entre dos valores (incluidos) | df["total"].between(3000, 7000) |
| Contiene un texto | df["producto"].str.contains("café", case=False, na=False) |
| Es o no es faltante | df["fecha"].isna(), df["fecha"].notna() |
| Desde una fecha | df["fecha"] >= "2026-01-05" |
| Los N más grandes o más chicos | df.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 @:
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.
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.
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):
pd.cut arma tramos con límites que elegís vos; pd.qcut, tramos con la misma cantidad de filas cada uno (cuartiles, quintiles…):
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:
Ordenar, renombrar y borrar. ascending=False ordena de mayor a menor, y con una lista se ordena por varias columnas:
| Para | Comando |
|---|---|
| Renombrar columnas | df = df.rename(columns={"precio_unitario": "precio"}) |
| Borrar columnas | df = df.drop(columns=["a", "b"]) |
| Borrar filas por índice | df = df.drop(index=[0, 5]) |
| Elegir y reordenar columnas | df = df[["fecha", "cliente", "total"]] |
| Insertar una columna en una posición | df.insert(0, "id", range(1, len(df) + 1)) |
| Ordenar por el índice | df.sort_index() |
| Usar una columna como índice, y volver atrás | df.set_index("fecha"), df.reset_index() |
| Redondear | df["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:
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.
| Medida | Mé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 covarianza | df.corr(), df.corr(method="spearman"), df.cov() |
| Media ponderada | np.average(df["salario"], weights=df["ponderador"]) |
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.
Para varios resultados con nombre propio, agg recibe nombre=("columna", "función"):
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:
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:
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:
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.
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".
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:
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.)
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.
| Para | Comando |
|---|---|
| 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 fechas | serie.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.
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:
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.
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.
| Pregunta | Prueba | Código |
|---|---|---|
| ¿Dos grupos tienen distinta media? | t de Welch | stats.ttest_ind(a, b, equal_var=False) |
| Lo mismo, sin suponer normalidad | Mann-Whitney | stats.mannwhitneyu(a, b) |
| ¿Cambió en los mismos casos (antes y después)? | t pareada | stats.ttest_rel(antes, despues) |
| ¿La media es distinta de un valor? | t de una muestra | stats.ttest_1samp(x, 40) |
| ¿Tres o más grupos tienen distinta media? | ANOVA | stats.f_oneway(g1, g2, g3) |
| ¿Dos variables categóricas están asociadas? | Chi-cuadrado | stats.chi2_contingency(pd.crosstab(a, b)) |
| ¿Dos variables numéricas se mueven juntas? | Pearson, Spearman | stats.pearsonr(x, y), stats.spearmanr(x, y) |
| ¿Los datos son normales? | Shapiro-Wilk | stats.shapiro(x) |
| Valores críticos | Normal, t | stats.norm.ppf(0.975), stats.t.ppf(0.975, df=29) |
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órmula | Significa |
|---|---|
y ~ x1 + x2 | y 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:x2 | Sólo la interacción. |
x1 * x2 | x1 + x2 + x1:x2. |
- 1 | Sin 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"):
Con el modelo estimado, predict calcula valores para casos nuevos:
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.
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:
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:
Un histograma es un gráfico de barras de los conteos por tramo. value_counts(bins=8) arma los tramos:
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 dice | Qué pasó | Cómo se arregla |
|---|---|---|
| Al leer archivos | ||
FileNotFoundError: No such file or directory | El 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 decode | El CSV no está en UTF-8 (típico de Excel en Windows). | encoding="latin-1". Ver. |
| Todo queda en una sola columna | El separador no es la coma. | sep=";" o sep=None, engine="python". |
ParserError: Error tokenizing data | Una 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 MiB | El 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 float | Texto 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 imposibles | Enteros de 32 bits que se desbordaron. | astype("int64"), nunca astype(int). Ver. |
OverflowError: Python int too large to convert to C long | Lo mismo, cuando pandas se da cuenta. | astype("int64"). |
| Filtros y avisos de pandas | ||
ValueError: The truth value of a Series is ambiguous | Usaste and, or o not con columnas. | &, | y ~, con cada condición entre paréntesis. |
SettingWithCopyWarning | Modificaste una tabla que salió de un filtro. | .copy() o asignar con loc. Ver. |
FutureWarning: 'M' is deprecated | Código de frecuencia viejo. | "ME", "QE", "YE". |
FutureWarning: The default of observed=False is deprecated | Agrupaste por una columna categórica. | groupby(..., observed=True). |
FutureWarning: Downcasting behavior in `replace` is deprecated | replace 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 ... inplace | df["col"].fillna(0, inplace=True). | df["col"] = df["col"].fillna(0). |
| Propios de ChartPython | ||
NameError: name 'df' is not defined | La 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á disponible | ChartPython 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 detuvo | Se 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 coma | df = pd.read_csv("archivo.csv", sep=";") |
| Leer con coma decimal y punto de miles | pd.read_csv("archivo.csv", sep=";", decimal=",", thousands=".") |
| Leer un CSV de Excel en Windows | pd.read_csv("archivo.csv", sep=";", encoding="latin-1") |
| Conservar ceros a la izquierda | pd.read_csv("archivo.csv", dtype={"codigo": str}) |
| Ver qué archivos hay | import os; os.listdir() |
| Guardar un CSV | df.to_csv("salida.csv", index=False) |
| Guardar un CSV para Excel en castellano | df.to_csv("salida.csv", index=False, sep=";", decimal=",", encoding="utf-8-sig") |
| Explorar | |
| Primeras y últimas filas | df.head(10), df.tail(5) |
| Filas y columnas | df.shape |
| Nombres y tipos de columnas | df.columns.tolist(), df.dtypes, df.info() |
| Resumen estadístico | df.describe(), df.describe(include="all") |
| Faltantes por columna | df.isna().sum() |
| Frecuencia de cada valor | df["col"].value_counts(dropna=False) |
| Valores distintos | df["col"].unique(), df["col"].nunique() |
| Seleccionar y filtrar | |
| Una o varias columnas | df["col"], df[["col1", "col2"]] |
| Por etiqueta o por posición | df.loc[fila, "col"], df.iloc[0, 2] |
| Filas que cumplen una condición | df[df["col"] > 100] |
| Dos condiciones | df[(df["a"] > 1) & (df["b"] == "x")] |
| Valores de una lista | df[df["col"].isin(["a", "b"])] |
| Texto que contiene algo | df[df["col"].str.contains("algo", case=False, na=False)] |
| Ordenar | df.sort_values("col", ascending=False) |
| Limpiar | |
| Nombres de columnas prolijos | df.columns = df.columns.str.strip().str.lower().str.replace(r"\W+", "_", regex=True) |
| Renombrar | df = df.rename(columns={"viejo": "nuevo"}) |
| Borrar filas vacías | df = df.dropna(how="all") |
| Borrar duplicados | df = df.drop_duplicates() |
| Espacios y mayúsculas | df["col"] = df["col"].str.strip().str.title() |
| Reemplazar texto | df["col"].str.replace("viejo", "nuevo", regex=False) |
| Unificar categorías | df["col"].map({"bs as": "Buenos Aires", "caba": "CABA"}) |
| Texto a número | pd.to_numeric(df["col"], errors="coerce") |
| “$ 1.234,56” a número | pd.to_numeric(df["col"].str.replace("$", "", regex=False).str.replace(".", "", regex=False).str.replace(",", ".", regex=False), errors="coerce") |
| Texto a fecha | pd.to_datetime(df["col"], format="%d/%m/%Y", errors="coerce") |
| Completar faltantes | df["col"] = df["col"].fillna(0) |
| Borrar filas con faltantes en una columna | df = df.dropna(subset=["col"]) |
| Cambiar el tipo | df["col"].astype("int64"), .astype(str), .astype("category") |
| Transformar | |
| Columna calculada | df["total"] = df["cantidad"] * df["precio"] |
| Según una condición | np.where(df["col"] > 100, "alto", "bajo") |
| Tramos | pd.cut(df["col"], bins=[0, 10, 100, np.inf]), pd.qcut(df["col"], q=4) |
| Aplicar una función | df["col"].apply(funcion) |
| Partes de una fecha | df["fecha"].dt.year, .dt.month, .dt.to_period("M") |
| Borrar columnas | df = df.drop(columns=["col"]) |
| Resumir y combinar | |
| Total por grupo | df.groupby("grupo")["valor"].sum() |
| Varias medidas por grupo | df.groupby("grupo").agg(n=("valor", "size"), media=("valor", "mean")) |
| Tabla dinámica | pd.pivot_table(df, index="fila", columns="columna", values="valor", aggfunc="sum") |
| Tabla de contingencia | pd.crosstab(df["a"], df["b"], normalize="index") |
| Participación dentro del grupo | df["valor"] / df.groupby("grupo")["valor"].transform("sum") |
| Unir dos tablas | df.merge(otra, on="clave", how="left") |
| Apilar tablas | pd.concat([df1, df2], ignore_index=True) |
| De ancho a largo y al revés | df.melt(id_vars="id"), df.pivot(index="id", columns="var", values="valor") |
| Tiempo y estadística | |
| Sumar por mes | df.set_index("fecha")["valor"].resample("ME").sum() |
| Variación y promedio móvil | s.pct_change(), s.rolling(3).mean() |
| Correlaciones | df.corr() |
| Diferencia de medias | stats.ttest_ind(a, b, equal_var=False) |
| Regresión lineal | smf.ols("y ~ x1 + x2", data=df).fit() |
| Con errores robustos | smf.ols("y ~ x1 + C(grupo)", data=df).fit(cov_type="HC1") |
| Logit | smf.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.