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#
- 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]:.
2 + 24Debajo 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#
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.
precio = 3450
cantidad = 2precio * cantidad6900Debajo 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.
subtotal = precio * cantidad
print("Subtotal:", subtotal)
print("Con IVA:", subtotal * 1.21)
subtotal > 5000Subtotal: 6900
Con IVA: 8349.0
Truedisplay(precio, cantidad)
precio * cantidad;3450
2Los 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#
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.
import os
os.getcwd()'/mnt/data'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.
with open("notas.txt", "w", encoding="utf-8") as archivo:
archivo.write("Primera prueba con ChartPython\n")
sorted(os.listdir())['notas.txt']| 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#
- 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#
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.
# No lo ejecutes: este bucle no termina nunca y hay que interrumpirlo
while True:
passQué trae instalado (y qué no)#
| 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.
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__)Python 3.12.1
pandas 2.2.0
NumPy 1.26.4
SciPy 1.12.0
statsmodels 0.14.2No 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 |
import matplotlib.pyplot as pltModuleNotFoundError: No module named 'matplotlib'Diferencias con Jupyter#
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:
import time
inicio = time.perf_counter()
suma = sum(range(1_000_000))
print(f"Tardó {time.perf_counter() - inicio:.3f} segundos")display() ya viene incluido. Si copiás código que hace from IPython.display import display, borrá esa línea:
from IPython.display import displayModuleNotFoundError: No module named 'IPython'No se pueden pedir datos con input()
nombre = input("¿Cómo te llamás? ")RuntimeError: input() no está disponible en ChartPython. Asigná el valor en una celda o cargá un archivo.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.
import numpy as np
np.array([2_000_000_000, 2_000_000_000]).sum()-294967296np.array([2_000_000_000, 2_000_000_000], dtype="int64").sum()4000000000Lí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#
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.
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)<class 'float'>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))9 5 14
3.5
3
1
49
3.14 5 8 1Texto
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")ana PÉREZ
Ana Pérez
ANA PÉREZ
['yerba', 'café', 'azúcar']
3.450
7 TrueLas 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.
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", "."))Total: 1,234,567.89
Descuento: 15%
1.234.567,89Para pasar de un tipo a otro están int(), float() y str(). Python sólo entiende el punto decimal:
print(int("42") + 1, float("3.5") * 2, str(10) + " unidades")
print(float("3,5".replace(",", ".")))43 7.0 10 unidades
3.5float("3,5")ValueError: could not convert string to float: '3,5'Listas y diccionarios#
Una lista guarda valores en orden. Las posiciones se cuentan desde 0 y los números negativos cuentan desde el final.
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))3450
2890
[5200.5, 1100]
5 13630.5 5200.5
[990, 1100, 2890, 3450, 5200.5]Un diccionario guarda pares clave → valor. Sirve, por ejemplo, para tablas de equivalencias.
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)3450
sin marca
nombre → Yerba 1kg
precio → 3450
stock → 10Dos cosas más que aparecen seguido: asignar varios valores a la vez y quedarse con los valores sin repetir (set).
minimo, maximo = min(precios), max(precios)
print(minimo, maximo)
provincias = ["CABA", "Córdoba", "CABA", "Mendoza"]
sorted(set(provincias))990 5200.5
['CABA', 'Córdoba', 'Mendoza']Condiciones y bucles#
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 :.
stock = 3
if stock == 0:
print("Sin stock")
elif stock < 5:
print("Quedan pocas unidades")
else:
print("Hay stock")Quedan pocas unidadesPara comparar: == (igual), != (distinto), <, <=, >, >=. Para combinar condiciones: and, or y not; in pregunta si un valor está en una lista o en un texto.
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)CABA
CÓRDOBA
MENDOZA
1 3450
2 1100
Yerba: $ 3450
Azúcar: $ 1100
Mes 1
Mes 2
Mes 3Una comprensión de lista arma una lista nueva en una línea, transformando o filtrando otra:
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)[4174.5, 6292.6, 1331.0, 3496.9]
[3450, 5200.5]Funciones, módulos y errores#
Una función guarda pasos para reusarlos. Los parámetros con = tienen un valor por defecto, y return devuelve el resultado.
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))1210.0
1089.0try / except atrapa un error esperado para decidir qué hacer en lugar de que se corte todo:
def a_numero(texto):
try:
return float(texto.replace(".", "").replace(",", "."))
except ValueError:
return None
print(a_numero("3.450,50"), a_numero("s/d"))3450.5 NoneLos módulos se cargan con import. Los alias pd y np son una convención que vas a ver en todos lados.
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]))4.0 2.0
6 5help() muestra la documentación de cualquier función, en inglés. Con help(pd.read_csv) ves todos sus parámetros.
help(round)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.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#
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.
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")Listo: ventas.csv quedó en /mnt/dataSi preferís trabajar con tu propio archivo, subilo desde el panel y cambiá el nombre en los ejemplos.
Leer un CSV#
pd.read_csv() lee el archivo y devuelve un DataFrame. shape dice cuántas filas y columnas quedaron.
import pandas as pd
ventas = pd.read_csv("ventas.csv")
ventas.shape(12, 1)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:
ventas = pd.read_csv("ventas.csv", sep=";")
ventas.shape(12, 9)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.
ventas["Sucursal"].head(3)0 1.0
1 1.0
2 2.0
Name: Sucursal, dtype: float64ventas = pd.read_csv("ventas.csv", sep=";", dtype={"Sucursal": str})
ventas["Sucursal"].head(3)0 001
1 001
2 002
Name: Sucursal, dtype: objectLos 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" |
pd.read_csv("ventas.csv", sep=";", usecols=["Fecha", "Producto", "Cantidad"], nrows=3) 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 NaNNaN 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:
pd.read_csv("ventas.csv", sep=";", na_values=["s/d"])["Cantidad"].tolist()[2.0, 1.0, nan, nan, 3.0, nan, nan, 250.0, 4.0, 1.0, 6.0, 2.0]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:
import io
texto = "producto;precio\nYerba;3.450,50\nCafé;5.200,00\n"
pd.read_csv(io.StringIO(texto), sep=";", decimal=",", thousands=".") producto precio
0 Yerba 3450.5
1 Café 5200.0Acentos 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:
with open("clientes_windows.csv", "w", encoding="latin-1") as archivo:
archivo.write("nombre;ciudad\nJosé;Córdoba\n")El síntoma es un UnicodeDecodeError:
pd.read_csv("clientes_windows.csv", sep=";")UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 17: invalid continuation bytepd.read_csv("clientes_windows.csv", sep=";", encoding="latin-1") nombre ciudad
0 José CórdobaAl 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#
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. |
display(ventas.head(3), ventas.tail(2))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).
ventas.columns.tolist()['Sucursal', 'Fecha', 'Cliente ', 'Provincia', 'Producto', 'Cantidad', 'Precio Unitario', 'Descuento', 'Pagado']ventas.info()<class 'pandas.core.frame.DataFrame'>
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+ bytesventas["Provincia"].value_counts(dropna=False)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: int64print(ventas["Pagado"].unique())
print("Filas repetidas:", ventas.duplicated().sum())['Sí' 'si' 'No' 'SI' nan 'no']
Filas repetidas: 1Si una tabla tiene muchas columnas o filas, pandas muestra … en el medio. Estas opciones cambian cuánto se ve:
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 finalParte 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:
df = ventas.copy()Nombres de columnas#
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:
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()['sucursal', 'fecha', 'cliente', 'provincia', 'producto', 'cantidad', 'precio_unitario', 'descuento', 'pagado']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#
df = df.dropna(how="all") # borra las filas que están completamente vacías
df[df.duplicated(keep=False)][["fecha", "cliente", "producto"]] fecha cliente producto
2 04/01/2026 María López Yerba 1kg
3 04/01/2026 María López Yerba 1kgduplicated() marca las filas que repiten una anterior; con keep=False marca todas las copias, para verlas juntas. drop_duplicates() deja una sola:
df = df.drop_duplicates()
df = df.reset_index(drop=True) # vuelve a numerar las filas 0, 1, 2…
len(df)10Si 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#
Con .str se aplica un método de texto a toda la columna. Los faltantes quedan como faltantes, sin error.
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()['Ana Pérez', 'Juan Gómez', 'María López', 'Pedro Ruiz', 'Lucía Fernández', 'Sofía Díaz']| 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:
df["producto"].str.extract(r"(\d+)\s*(kg|g|ml)").drop_duplicates() 0 1
0 1 kg
1 500 g
4 900 mlUnificar 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:
clave = (
df["provincia"].str.strip().str.lower()
.str.normalize("NFKD").str.encode("ascii", "ignore").str.decode("ascii")
.str.replace(".", "", regex=False)
)
clave.unique().tolist()['buenos aires', 'bs as', 'cordoba', 'caba', 'santa fe', 'capital federal', 'mendoza']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)provincia
Córdoba 3
CABA 3
Buenos Aires 2
Santa Fe 1
Mendoza 1
Name: count, dtype: int64Números con formato argentino#
$ 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.
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)0 3450.0
1 5200.5
2 3450.0
Name: precio_unitario, dtype: float64Los porcentajes se tratan igual, dividiendo por 100 al final:
df["descuento"] = pd.to_numeric(df["descuento"].str.replace("%", "", regex=False).str.strip(), errors="coerce") / 100
df["descuento"].tolist()[0.1, 0.0, 0.05, nan, 0.0, 0.15, 0.0, 0.0, 0.05, 0.0]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.
convertida = pd.to_numeric(df["cantidad"], errors="coerce")
df.loc[convertida.isna() & df["cantidad"].notna(), ["cliente", "cantidad"]] cliente cantidad
4 Lucía Fernández s/ddf["cantidad"] = convertida
df["cantidad"].tolist()[2.0, 1.0, nan, 3.0, nan, 250.0, 4.0, 1.0, 6.0, 2.0]Sí y no
Para columnas de dos valores, un diccionario con todas las variantes y map:
df["pagado"] = df["pagado"].str.strip().str.lower().map({"sí": True, "si": True, "no": False})
df["pagado"].value_counts(dropna=False)pagado
True 6
False 4
Name: count, dtype: int64Fechas#
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).
df["fecha"] = pd.to_datetime(df["fecha"], format="%d/%m/%Y", errors="coerce")
df.loc[df["fecha"].isna(), ["cliente", "fecha"]] cliente fecha
6 Juan Gómez NaT| 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):
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) 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.0Otras 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#
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:
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"]] fecha cliente producto cantidad
5 2026-01-07 Ana Pérez Café 500g 250.0250 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.
import numpy as np
df.loc[fuera, "cantidad"] = np.nanOtra 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#
df.isna().sum()sucursal 0
fecha 1
cliente 0
provincia 0
producto 0
cantidad 3
precio_unitario 0
descuento 1
pagado 0
dtype: int64No 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.
df["descuento"] = df["descuento"].fillna(0)
df["cantidad"] = df["cantidad"].fillna(df["cantidad"].median())
df["cantidad"].tolist()[2.0, 1.0, 2.0, 3.0, 2.0, 2.0, 4.0, 1.0, 6.0, 2.0]| 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#
Con los faltantes resueltos, cada columna puede tener su tipo definitivo. astype convierte:
df["cantidad"] = df["cantidad"].astype("int64")
df["pagado"] = df["pagado"].astype(bool)
df.dtypessucursal object
fecha datetime64[ns]
cliente object
provincia object
producto object
cantidad int64
precio_unitario float64
descuento float64
pagado bool
dtype: object| 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. |
montos = pd.Series([3_000_000_000.0, 150_000.0])
print(montos.astype(int).tolist())
print(montos.astype("int64").tolist())[-2147483648, 150000]
[3000000000, 150000]Un error de tipo muy común: una columna de números que en realidad es texto. No falla, pero hace otra cosa:
pd.Series(["10", "20", "30"]).sum()'102030'Validar antes de guardar#
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.
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")Validación OK: 10 filasassert df["fecha"].notna().all(), "Hay fechas vacías o imposibles"AssertionError: Hay fechas vacías o imposiblesUn resumen de calidad por columna también ayuda a ver todo de un vistazo:
pd.DataFrame({
"tipo": df.dtypes.astype(str),
"faltantes": df.isna().sum(),
"distintos": df.nunique(),
}) 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 2Guardar el CSV limpio#
Antes de guardar, una columna calculada: el total de cada pedido. Las operaciones entre columnas se hacen fila a fila, sin bucles.
df["total"] = (df["cantidad"] * df["precio_unitario"] * (1 - df["descuento"])).round(2)
df.to_csv("ventas_limpio.csv", index=False)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):
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]))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,5Otros 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#
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.
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"]] 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.00limpio.equals(df)TrueLa 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.
import numpy as np
import pandas as pd
ventas = pd.read_csv("ventas_limpio.csv", parse_dates=["fecha"], dtype={"sucursal": str})
ventas.dtypessucursal object
fecha datetime64[ns]
cliente object
provincia object
producto object
cantidad int64
precio_unitario float64
descuento float64
pagado bool
total float64
dtype: objectSeleccionar y filtrar#
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.
ventas["cliente"].head(3)0 Ana Pérez
1 Juan Gómez
2 María López
Name: cliente, dtype: objectventas[["cliente", "producto", "total"]].head(3) 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.0Para 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.
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"]]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.0Filtrar 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:
de_caba = ventas["provincia"] == "CABA"
ventas.loc[de_caba, ["cliente", "producto", "total"]] 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.0Para combinar condiciones se usan & (y), | (o) y ~ (no), con cada condición entre paréntesis:
grandes_de_caba = (ventas["provincia"] == "CABA") & (ventas["total"] > 5000)
ventas.loc[grandes_de_caba, ["cliente", "total"]] cliente total
6 Juan Gómez 13800.0
8 Pedro Ruiz 6270.0ventas[ventas["provincia"] == "CABA" and ventas["total"] > 5000]ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().| 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 @:
minimo = 5000
ventas.query("provincia in ['CABA', 'Córdoba'] and total > @minimo")[["cliente", "provincia", "total"]] 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.0Modificar 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.
caba = ventas[ventas["provincia"] == "CABA"]
caba["total_con_iva"] = caba["total"] * 1.21Si 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.
caba = ventas[ventas["provincia"] == "CABA"].copy()
caba["total_con_iva"] = caba["total"] * 1.21Crear, renombrar y ordenar 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):
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) 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 chicopd.cut arma tramos con límites que elegís vos; pd.qcut, tramos con la misma cantidad de filas cada uno (cuartiles, quintiles…):
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) total tramo quintil
0 6210.0 medio 3
1 5200.5 medio 2
2 6555.0 medio 4
3 3300.0 bajo 1Para 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:
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() 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 botellaOrdenar, renombrar y borrar. ascending=False ordena de mayor a menor, y con una lista se ordena por varias columnas:
ventas = ventas.drop(columns=["tamano", "envase"])
ventas.sort_values(["provincia", "total"], ascending=[True, False])[["provincia", "cliente", "total"]].head(5) 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| 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:
(
ventas
.assign(total_miles=lambda d: (d["total"] / 1000).round(1))
.sort_values("total_miles", ascending=False)
.head(3)
[["cliente", "producto", "total_miles"]]
) 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.8Estadística 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.
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() 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.0salario = 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))Media: 1470643
Mediana: 1351000.0
Desvío estándar: 641413
Mínimo y máximo: 373000.0 4577000.0
Percentil 90: 2341500.0encuesta.describe().round(1) 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| 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"]) |
encuesta["region"].value_counts(normalize=True).round(3)region
Cuyo 0.183
Centro 0.173
NEA 0.170
Patagonia 0.167
NOA 0.153
AMBA 0.153
Name: proportion, dtype: float64encuesta[["edad", "educacion", "experiencia", "horas", "salario"]].corr().round(2) 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.00Por 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):
pd.set_option("display.float_format", "{:,.2f}".format)
encuesta[["horas", "salario"]].agg(["mean", "median"]) horas salario
mean 40.00 1,470,643.33
median 40.00 1,351,000.00pd.reset_option("display.float_format") # vuelve al formato normalAgrupar y resumir#
groupby separa la tabla en grupos, calcula algo en cada uno y junta los resultados. Es el equivalente a una tabla dinámica.
ventas.groupby("provincia")["total"].sum()provincia
Buenos Aires 11410.50
CABA 23370.00
Córdoba 22736.00
Mendoza 2890.00
Santa Fe 8840.85
Name: total, dtype: float64Para varios resultados con nombre propio, agg recibe nombre=("columna", "función"):
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) 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.00Funciones 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:
ventas.groupby(["provincia", "producto"], as_index=False)["cantidad"].sum() 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 2transform calcula por grupo pero devuelve un valor por fila, alineado con la tabla original. Sirve para participaciones y comparaciones contra el promedio del grupo:
ventas["participacion"] = ventas["total"] / ventas.groupby("provincia")["total"].transform("sum")
ventas[["provincia", "cliente", "total", "participacion"]].round(3).head(4) 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.141Tablas dinámicas y tablas de contingencia
pd.pivot_table(
ventas,
index="provincia",
columns="rubro",
values="total",
aggfunc="sum",
fill_value=0,
margins=True,
margins_name="Total",
)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.35pd.crosstab cuenta combinaciones de dos variables. Con normalize="index" da la proporción dentro de cada fila:
pd.crosstab(encuesta["region"], encuesta["sexo"], normalize="index").round(2)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.42ventas.groupby("tramo", observed=True)["total"].agg(["count", "sum"]) count sum
tramo
bajo 2 6190.00
medio 5 30015.50
alto 3 33041.85Combinar tablas#
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.
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() 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.00how= | 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".
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) 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_onlypd.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:
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()archivo
ventas_parte1.csv 5
ventas_parte2.csv 5
Name: count, dtype: int64Formato ancho y largo#
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.)
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) 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.3largo.pivot(index="mes", columns="region", values="variacion")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.1pivot 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#
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.
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) ventas ipc
2024-01-01 1000.0 100.0
2024-02-01 1080.0 103.0
2024-03-01 1052.0 106.1serie[["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] 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| 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.
serie["ventas"].resample("QE").sum().head(4)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: float64serie["ventas"].resample("Q").sum().head(1)Las ventas del CSV limpio, por semana. La fila sin fecha queda afuera sola:
ventas.set_index("fecha")["total"].resample("W").sum()fecha
2026-01-04 17965.50
2026-01-11 37481.85
Name: total, dtype: float64Deflactar
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:
serie["ventas_reales"] = (serie["ventas"] / serie["ipc"] * serie["ipc"].iloc[-1]).round()
serie[["ventas", "ipc", "ventas_reales"]].iloc[[0, 12, 23]] 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.0Estacionariedad 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.
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))ADF = 2.98, p-valor = 1.000
Autocorrelación: [1. 0.87 0.74 0.62]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:
from statsmodels.tsa.arima.model import ARIMA
ajuste = ARIMA(serie["ventas"], order=(1, 1, 0)).fit()
ajuste.forecast(steps=3).round()2026-01-01 2446.0
2026-02-01 2450.0
2026-03-01 2452.0
Freq: MS, Name: predicted_mean, dtype: float64NumPy esencial#
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.
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 False2.5 2.0 1
[1.22 1.41 nan 2. ]
['bajo' 'alto' 'bajo' 'alto']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))4.605170185988092 2.718281828459045 3.0
[0. 0.25 0.5 0.75] [0. 0.25 0.5 0.75 1. ]
46.0Números al azar para simular o para tomar muestras. Con la misma semilla salen siempre los mismos:
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[ 0.305 -1.04 0.75 ]
[1 5 2 1 4]
['Patagonia' 'NOA' 'NOA']
[1 4 2 3 0]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)660000.0La media ponderada es la que se usa con encuestas que traen un ponderador, como la EPH del INDEC.
Pruebas estadísticas#
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.
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}")t = -4.06, p-valor = 0.0001| 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) |
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}")chi2 = 0.66, gl = 5, p-valor = 0.985
r = 0.51, p-valor = 3.1e-21
ANOVA: F = 1.47, p-valor = 0.199Un intervalo de confianza del 95 % para una media, con la distribución t:
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})")Horas semanales: 40.0 (IC 95 %: 39.1 a 40.9)Regresión con statsmodels#
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.
import statsmodels.formula.api as smf
modelo = smf.ols("salario ~ educacion + experiencia", data=encuesta).fit()
print(modelo.summary())El resumen trae todo, pero cada parte también se puede pedir por separado, por ejemplo para armar una tabla propia:
pd.DataFrame({
"coeficiente": modelo.params,
"error_estandar": modelo.bse,
"t": modelo.tvalues,
"p_valor": modelo.pvalues,
}).round(4) 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.0000print(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 %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| 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"):
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)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: float64retorno = mincer.params["educacion"] * 100
print(f"Cada año más de educación se asocia con un salario {retorno:.1f} % más alto")Cada año más de educación se asocia con un salario 7.1 % más altoCon el modelo estimado, predict calcula valores para casos nuevos:
nuevos = pd.DataFrame({"educacion": [12, 17], "experiencia": [10, 10]})
modelo.predict(nuevos).round()0 1232882.0
1 1765158.0
dtype: float64Variable 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.
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)Intercept 0.002
C(sexo)[T.varón] 1.455
educacion 1.450
experiencia 1.059
dtype: float64Diagnósticos
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())Breusch-Pagan (heterocedasticidad): p-valor = 0.003
VIF (multicolinealidad): {'educacion': 1.07, 'experiencia': 1.07, 'horas': 1.0}Gráficos sin matplotlib#
ChartPython no trae matplotlib, así que df.plot() no funciona. Para mirar una distribución rápido alcanzan unas barras de texto:
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}")Yerba 1kg ██████████████████████████████ 26,565
Café 500g ████████████████████████████ 24,442
Azúcar 1kg ███████████ 9,570
Aceite 900ml ██████████ 8,670Para 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:
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 = [
'<rect width="100%" height="100%" fill="#fff"/>',
f'<text x="0" y="20" font-size="15" font-weight="600">{escape(str(titulo))}</text>',
]
for i, (etiqueta, valor) in enumerate(serie.items()):
y = arriba + i * fila
largo = max(valor, 0) / maximo * (ancho - izquierda - 90)
partes.append(
f'<text x="{izquierda - 8}" y="{y + 16}" font-size="12" text-anchor="end">{escape(str(etiqueta))}</text>'
f'<rect x="{izquierda}" y="{y + 4}" width="{largo:.1f}" height="{fila - 10}" fill="{color}"/>'
f'<text x="{izquierda + largo + 6:.1f}" y="{y + 16}" font-size="12">{valor:,.0f}</text>'
)
alto = arriba + fila * len(serie) + 8
return SVG(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{ancho}" height="{alto}" '
f'font-family="sans-serif" fill="#101725">{"".join(partes)}</svg>'
)
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'<svg xmlns="http://www.w3.org/2000/svg" width="{ancho}" height="{alto}" '
f'font-family="sans-serif" fill="#101725">'
'<rect width="100%" height="100%" fill="#fff"/>'
f'<text x="0" y="20" font-size="15" font-weight="600">{escape(str(titulo))}</text>'
f'<line x1="{izquierda}" y1="{alto - abajo}" x2="{ancho - derecha}" y2="{alto - abajo}" stroke="#c9d0da"/>'
f'<text x="{izquierda - 8}" y="{y(maximo) + 4:.1f}" font-size="11" text-anchor="end">{maximo:,.0f}</text>'
f'<text x="{izquierda - 8}" y="{y(minimo) + 4:.1f}" font-size="11" text-anchor="end">{minimo:,.0f}</text>'
f'<polyline points="{puntos}" fill="none" stroke="{color}" stroke-width="2"/>'
f'<text x="{izquierda}" y="{alto - 14}" font-size="11">{desde}</text>'
f'<text x="{ancho - derecha}" y="{alto - 14}" font-size="11" text-anchor="end">{hasta}</text>'
"</svg>"
)grafico_barras(por_producto, "Facturación por producto")grafico_lineas(serie["ventas_reales"], "Ventas a precios del último mes")Un histograma es un gráfico de barras de los conteos por tramo. value_counts(bins=8) arma los tramos:
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")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:
grafico = grafico_lineas(serie["ventas"], "Ventas mensuales")
with open("ventas_mensuales.svg", "w", encoding="utf-8") as archivo:
archivo.write(grafico.codigo)Parte 6
Referencia
Errores frecuentes#
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:
pd.read_csv("Ventas.csv") # el archivo se llama ventas.csv, en minúsculaFileNotFoundError: [Errno 44] No such file or directory: 'Ventas.csv'crudo = pd.read_csv("ventas.csv", sep=";")
crudo["Cliente"] # la columna es "Cliente " con un espacioKeyError: 'Cliente'import io
texto = "producto,precio\nYerba,3450\nCafé,5200,50\n" # coma decimal en un CSV con comas
pd.read_csv(io.StringIO(texto))ParserError: Error tokenizing data. C error: Expected 2 fields in line 3, saw 3Chuleta de comandos#
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.