ℭ𝔥𝔞𝔯𝔱 𝔅𝔞𝔶

ChartPython · Manual de uso

Manual de ChartPython

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

Parte 1

Usar ChartPython

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

Primeros pasos#

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

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#

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 = 2
precio * cantidad
Salida
6900

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.

subtotal = precio * cantidad
print("Subtotal:", subtotal)
print("Con IVA:", subtotal * 1.21)
subtotal > 5000
Salida
Subtotal: 6900
Con IVA: 8349.0
True
display(precio, cantidad)
precio * cantidad;
Salida
3450
2

Los dos modos

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

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

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

Celdas de texto

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

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

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

Archivos: subir, leer y descargar#

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()
Salida
'/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())
Salida
['notas.txt']
En el panel diceQué significa
subidoLo subiste vos. Queda guardado en el navegador con ese notebook y se vuelve a poner en /mnt/data cada vez que Python se reinicia. Quitar lo saca.
creado por PythonLo escribió una celda. Vive sólo mientras Python está prendido: al interrumpir o reiniciar se pierde, así que descargalo antes.

Guardar, abrir y exportar notebooks#

  • 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 import y los archivos creados por Python.
  • Se conservan las celdas, sus salidas y los archivos que subiste, que vuelven a /mnt/data antes de la próxima ejecución.

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

# No lo ejecutes: este bucle no termina nunca y hay que interrumpirlo
while True:
    pass

Qué trae instalado (y qué no)#

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

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

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__)
Salida
Python 3.12.1
pandas 2.2.0
NumPy 1.26.4
SciPy 1.12.0
statsmodels 0.14.2

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

No estáQué deja de andarQué usar
matplotlib, seaborn, plotlyGráficos, df.plot()Gráficos sin matplotlib
openpyxl, xlrdpd.read_excel(), df.to_excel()Trabajar con CSV
pyarrowParquet y FeatherCSV o JSON
Jinja2df.style (colores y formatos de tabla)round() y pd.set_option("display.float_format", …)
tabulatedf.to_markdown()print(df.to_string())
scikit-learnModelos de machine learningstatsmodels para regresión y clasificación con logit
requestsDescargar datos de internetBajar el archivo y subirlo al panel
Este ejemplo da error a propósito
import matplotlib.pyplot as plt
Error
ModuleNotFoundError: 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:

Este ejemplo da error a propósito
from IPython.display import display
Error
ModuleNotFoundError: No module named 'IPython'

No se pueden pedir datos con input()

Este ejemplo da error a propósito
nombre = input("¿Cómo te llamás? ")
Error
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()
Salida
-294967296
np.array([2_000_000_000, 2_000_000_000], dtype="int64").sum()
Salida
4000000000

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#

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)
Salida
<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))
Salida
9 5 14
3.5
3
1
49
3.14 5 8 1

Texto

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")
Salida
ana PÉREZ
Ana Pérez
ANA PÉREZ
['yerba', 'café', 'azúcar']
3.450
7 True

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.

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", "."))
Salida
Total: 1,234,567.89
Descuento: 15%
1.234.567,89

Para 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(",", ".")))
Salida
43 7.0 10 unidades
3.5
Este ejemplo da error a propósito
float("3,5")
Error
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))
Salida
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)
Salida
3450
sin marca
nombre → Yerba 1kg
precio → 3450
stock → 10

Dos 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))
Salida
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")
Salida
Quedan pocas unidades

Para 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)
Salida
CABA
CÓRDOBA
MENDOZA
1 3450
2 1100
Yerba: $ 3450
Azúcar: $ 1100
Mes 1
Mes 2
Mes 3

Una 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)
Salida
[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))
Salida
1210.0
1089.0

try / 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"))
Salida
3450.5 None

Los 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]))
Salida
4.0 2.0
6 5

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

help(round)
Salida
Help on built-in function round in module builtins:

round(number, ndigits=None)
    Round a number to a given precision in decimal digits.

    The return value is an integer if ndigits is omitted or None.  Otherwise
    the return value has the same type as the number.  ndigits may be negative.

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")
Salida
Listo: ventas.csv quedó en /mnt/data

Si 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
Salida
(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
Salida
(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)
Salida
0    1.0
1    1.0
2    2.0
Name: Sucursal, dtype: float64
ventas = pd.read_csv("ventas.csv", sep=";", dtype={"Sucursal": str})
ventas["Sucursal"].head(3)
Salida
0    001
1    001
2    002
Name: Sucursal, dtype: object

Los parámetros que más se usan

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

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:

pd.read_csv("ventas.csv", sep=";", na_values=["s/d"])["Cantidad"].tolist()
Salida
[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=".")
Salida
  producto  precio
0    Yerba  3450.5
1     Café  5200.0

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:

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:

Este ejemplo da error a propósito
pd.read_csv("clientes_windows.csv", sep=";")
Error
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 17: invalid continuation byte
pd.read_csv("clientes_windows.csv", sep=";", encoding="latin-1")
Salida
  nombre   ciudad
0   José  Córdoba

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#

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

ComandoMuestra
df.head(), df.head(10)Las primeras filas (5 si no decís cuántas).
df.tail(3)Las últimas filas.
df.sample(5, random_state=1)Filas al azar; random_state hace que salgan siempre las mismas.
df.shape(filas, columnas).
df.columns.tolist()Los nombres exactos de las columnas.
df.dtypesEl tipo de cada columna.
df.info()Tipos, valores no vacíos y memoria, todo junto.
df.describe()Resumen estadístico de las columnas numéricas; con include="all", de todas.
df.isna().sum()Cantidad de faltantes por columna.
df["col"].value_counts()Cuántas veces aparece cada valor.
df["col"].unique(), df["col"].nunique()Los valores distintos y cuántos son.
df.duplicated().sum()Cuántas filas repiten a una anterior.
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()
Salida
['Sucursal', 'Fecha', 'Cliente ', 'Provincia', 'Producto', 'Cantidad', 'Precio Unitario', 'Descuento', 'Pagado']
ventas.info()
Salida
<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+ bytes
ventas["Provincia"].value_counts(dropna=False)
Salida
Provincia
Córdoba            3
Buenos Aires       1
bs. as.            1
CABA               1
NaN                1
cordoba            1
Santa Fe           1
Capital Federal    1
Mendoza            1
caba               1
Name: count, dtype: int64
print(ventas["Pagado"].unique())
print("Filas repetidas:", ventas.duplicated().sum())
Salida
['Sí' 'si' 'No' 'SI' nan 'no']
Filas repetidas: 1

Si 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 final

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:

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()
Salida
['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"]]
Salida
        fecha      cliente   producto
2  04/01/2026  María López  Yerba 1kg
3  04/01/2026  María López  Yerba 1kg

duplicated() 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)
Salida
10

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#

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()
Salida
['Ana Pérez', 'Juan Gómez', 'María López', 'Pedro Ruiz', 'Lucía Fernández', 'Sofía Díaz']
MétodoHace
.str.strip(), .str.lower(), .str.upper(), .str.title()Espacios y mayúsculas.
.str.replace("viejo", "nuevo", regex=False)Reemplaza texto literal. Con regex=True, un patrón.
.str.contains("yerba", case=False, na=False)True donde aparece el texto. Sirve para filtrar.
.str.startswith("0"), .str.endswith("kg")Empieza o termina con…
.str.len()Largo de cada texto.
.str.split(" ", expand=True)Parte el texto en varias columnas.
.str.extract(r"(\d+)")Saca la parte que coincide con un patrón.
.str.zfill(5)Completa con ceros a la izquierda.
.str[:3]Los primeros tres caracteres.

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

df["producto"].str.extract(r"(\d+)\s*(kg|g|ml)").drop_duplicates()
Salida
     0   1
0    1  kg
1  500   g
4  900  ml

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:

clave = (
    df["provincia"].str.strip().str.lower()
    .str.normalize("NFKD").str.encode("ascii", "ignore").str.decode("ascii")
    .str.replace(".", "", regex=False)
)
clave.unique().tolist()
Salida
['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)
Salida
provincia
Córdoba         3
CABA            3
Buenos Aires    2
Santa Fe        1
Mendoza         1
Name: count, dtype: int64

Nú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)
Salida
0    3450.0
1    5200.5
2    3450.0
Name: precio_unitario, dtype: float64

Los 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()
Salida
[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"]]
Salida
           cliente cantidad
4  Lucía Fernández      s/d
df["cantidad"] = convertida
df["cantidad"].tolist()
Salida
[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)
Salida
pagado
True     6
False    4
Name: count, dtype: int64

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).

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

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

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)
Salida
       fecha      mes      dia  hace_dias
0 2026-01-03  2026-01   sábado       28.0
1 2026-01-03  2026-01   sábado       28.0
2 2026-01-04  2026-01  domingo       27.0
3 2026-01-05  2026-01    lunes       26.0

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#

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"]]
Salida
       fecha    cliente   producto  cantidad
5 2026-01-07  Ana Pérez  Café 500g     250.0

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.
import numpy as np

df.loc[fuera, "cantidad"] = np.nan

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#

df.isna().sum()
Salida
sucursal           0
fecha              1
cliente            0
provincia          0
producto           0
cantidad           3
precio_unitario    0
descuento          1
pagado             0
dtype: int64

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.

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

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

Tipos de datos#

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.dtypes
Salida
sucursal                   object
fecha              datetime64[ns]
cliente                    object
provincia                  object
producto                   object
cantidad                    int64
precio_unitario           float64
descuento                 float64
pagado                       bool
dtype: object
TipoPara
"int64"Enteros. No admite faltantes.
"Int64" (con mayúscula)Enteros con faltantes, que se ven como <NA>.
"float64" o floatDecimales; admite NaN.
str (queda como object)Texto, códigos, DNI, CUIT.
boolVerdadero o falso.
"category"Texto con pocos valores que se repiten mucho (provincia, sexo): ocupa menos memoria.
datetime64[ns]Fechas, con pd.to_datetime.
montos = pd.Series([3_000_000_000.0, 150_000.0])
print(montos.astype(int).tolist())
print(montos.astype("int64").tolist())
Salida
[-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()
Salida
'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")
Salida
Validación OK: 10 filas
Este ejemplo da error a propósito
assert df["fecha"].notna().all(), "Hay fechas vacías o imposibles"
Error
AssertionError: Hay fechas vacías o imposibles

Un 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(),
})
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

Guardar 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]))
Salida
sucursal;fecha;cliente;provincia;producto;cantidad;precio_unitario;descuento;pagado;total
001;03/01/2026;Ana Pérez;Buenos Aires;Yerba 1kg;2;3450,0;0,1;True;6210,0
001;03/01/2026;Juan Gómez;Buenos Aires;Café 500g;1;5200,5;0,0;True;5200,5

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#

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"]]
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
limpio.equals(df)
Salida
True

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.

import numpy as np
import pandas as pd

ventas = pd.read_csv("ventas_limpio.csv", parse_dates=["fecha"], dtype={"sucursal": str})
ventas.dtypes
Salida
sucursal                   object
fecha              datetime64[ns]
cliente                    object
provincia                  object
producto                   object
cantidad                    int64
precio_unitario           float64
descuento                 float64
pagado                       bool
total                     float64
dtype: object

Seleccionar 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)
Salida
0      Ana Pérez
1     Juan Gómez
2    María López
Name: cliente, dtype: object
ventas[["cliente", "producto", "total"]].head(3)
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

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

  • loc usa etiquetas: el valor del índice y el nombre de la columna. Los rangos incluyen el final: loc[0:2] son tres filas.
  • iloc usa posiciones, como una lista: iloc[0:2] son dos filas.
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"]]
Salida
María López
Ana Pérez
       cliente   total
0    Ana Pérez  6210.0
1   Juan Gómez  5200.5
2  María López  6555.0

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:

de_caba = ventas["provincia"] == "CABA"
ventas.loc[de_caba, ["cliente", "producto", "total"]]
Salida
      cliente    producto    total
3  Pedro Ruiz  Azúcar 1kg   3300.0
6  Juan Gómez   Yerba 1kg  13800.0
8  Pedro Ruiz  Azúcar 1kg   6270.0

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

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

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

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.

caba = ventas[ventas["provincia"] == "CABA"]
caba["total_con_iva"] = caba["total"] * 1.21

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.

caba = ventas[ventas["provincia"] == "CABA"].copy()
caba["total_con_iva"] = caba["total"] * 1.21

Crear, 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)
Salida
      provincia    region   total   tamano
0  Buenos Aires      AMBA  6210.0  mediano
1  Buenos Aires      AMBA  5200.5  mediano
2       Córdoba  Interior  6555.0  mediano
3          CABA      AMBA  3300.0    chico

pd.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)
Salida
    total  tramo quintil
0  6210.0  medio       3
1  5200.5  medio       2
2  6555.0  medio       4
3  3300.0   bajo       1

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:

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()
Salida
       producto       rubro   envase
0     Yerba 1kg  Infusiones  paquete
1     Café 500g  Infusiones  paquete
3    Azúcar 1kg     Almacén  paquete
4  Aceite 900ml     Almacén  botella

Ordenar, 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)
Salida
      provincia     cliente    total
0  Buenos Aires   Ana Pérez   6210.0
1  Buenos Aires  Juan Gómez   5200.5
6          CABA  Juan Gómez  13800.0
8          CABA  Pedro Ruiz   6270.0
3          CABA  Pedro Ruiz   3300.0
ParaComando
Renombrar columnasdf = df.rename(columns={"precio_unitario": "precio"})
Borrar columnasdf = df.drop(columns=["a", "b"])
Borrar filas por índicedf = df.drop(index=[0, 5])
Elegir y reordenar columnasdf = df[["fecha", "cliente", "total"]]
Insertar una columna en una posicióndf.insert(0, "id", range(1, len(df) + 1))
Ordenar por el índicedf.sort_index()
Usar una columna como índice, y volver atrásdf.set_index("fecha"), df.reset_index()
Redondeardf["total"].round(2), df.round(1)

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

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

Estadí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()
Salida
   edad   sexo     region  educacion  experiencia  horas    salario
0    58  mujer  Patagonia         11           41   38.0   892000.0
1    26  varón        NOA         18            2   40.0  1770000.0
2    19  mujer       Cuyo         10            3   41.0  1071000.0
3    48  mujer       Cuyo          8           34   38.0   835000.0
4    35  mujer  Patagonia         17           12   46.0  1588000.0
salario = encuesta["salario"]
print("Media:", round(salario.mean()))
print("Mediana:", salario.median())
print("Desvío estándar:", round(salario.std()))
print("Mínimo y máximo:", salario.min(), salario.max())
print("Percentil 90:", salario.quantile(0.9))
Salida
Media: 1470643
Mediana: 1351000.0
Desvío estándar: 641413
Mínimo y máximo: 373000.0 4577000.0
Percentil 90: 2341500.0
encuesta.describe().round(1)
Salida
        edad  educacion  experiencia  horas    salario
count  300.0      300.0        300.0  300.0      300.0
mean    41.5       12.5         23.2   40.0  1470643.3
std     13.6        3.5         13.9    7.9   641413.2
min     18.0        7.0          0.0   17.0   373000.0
25%     31.0        9.0         11.0   35.0   964500.0
50%     41.0       12.0         24.0   40.0  1351000.0
75%     53.0       16.0         34.0   46.0  1826250.0
max     65.0       18.0         52.0   59.0  4577000.0
MedidaMétodo
Cantidad de valores no vacíos.count()
Suma, media, mediana.sum(), .mean(), .median()
Moda.mode() (puede haber más de una)
Desvío estándar y varianza.std(), .var() (muestrales)
Mínimo, máximo, rango.min(), .max(), .max() - .min()
Percentiles y cuartiles.quantile(0.9), .quantile([0.25, 0.5, 0.75])
Asimetría y curtosis.skew(), .kurt()
Varias a la vez.agg(["count", "mean", "median", "std"])
Frecuencias y proporciones.value_counts(), .value_counts(normalize=True)
Correlación y covarianzadf.corr(), df.corr(method="spearman"), df.cov()
Media ponderadanp.average(df["salario"], weights=df["ponderador"])
encuesta["region"].value_counts(normalize=True).round(3)
Salida
region
Cuyo         0.183
Centro       0.173
NEA          0.170
Patagonia    0.167
NOA          0.153
AMBA         0.153
Name: proportion, dtype: float64
encuesta[["edad", "educacion", "experiencia", "horas", "salario"]].corr().round(2)
Salida
             edad  educacion  experiencia  horas  salario
edad         1.00      -0.01         0.97   0.05     0.30
educacion   -0.01       1.00        -0.25   0.04     0.51
experiencia  0.97      -0.25         1.00   0.04     0.16
horas        0.05       0.04         0.04   1.00     0.00
salario      0.30       0.51         0.16   0.00     1.00

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):

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

Agrupar 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()
Salida
provincia
Buenos Aires    11410.50
CABA            23370.00
Córdoba         22736.00
Mendoza          2890.00
Santa Fe         8840.85
Name: total, dtype: float64

Para 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)
Salida
              pedidos  unidades  facturacion  ticket_promedio
provincia
CABA                3        13     23370.00          7790.00
Córdoba             3         6     22736.00          7578.67
Buenos Aires        2         3     11410.50          5705.25
Santa Fe            1         2      8840.85          8840.85
Mendoza             1         1      2890.00          2890.00

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:

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

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:

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

Tablas 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",
)
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

pd.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)
Salida
sexo       mujer  varón
region
AMBA        0.52   0.48
Centro      0.52   0.48
Cuyo        0.51   0.49
NEA         0.53   0.47
NOA         0.54   0.46
Patagonia   0.58   0.42
ventas.groupby("tramo", observed=True)["total"].agg(["count", "sum"])
Salida
       count       sum
tramo
bajo       2   6190.00
medio      5  30015.50
alto       3  33041.85

Combinar 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()
Salida
                 total   margen
producto
Aceite 900ml   8670.00  2370.00
Azúcar 1kg     9570.00  3270.00
Café 500g     24442.35  6442.35
Yerba 1kg     26565.00  8165.00
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".

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)
Salida
           cliente   segmento      _merge
0        Ana Pérez  mayorista        both
1       Juan Gómez  minorista        both
2  Lucía Fernández        NaN   left_only
3       Marta Ríos  minorista  right_only
4      María López        NaN   left_only
5       Pedro Ruiz        NaN   left_only
6       Sofía Díaz        NaN   left_only

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:

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()
Salida
archivo
ventas_parte1.csv    5
ventas_parte2.csv    5
Name: count, dtype: int64

Formato 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)
Salida
      region      mes  variacion
0        GBA  2026-01        2.1
1    Noreste  2026-01        2.4
2  Patagonia  2026-01        1.9
3        GBA  2026-02        2.3
largo.pivot(index="mes", columns="region", values="variacion")
Salida
region   GBA  Noreste  Patagonia
mes
2026-01  2.1      2.4        1.9
2026-02  2.3      2.2        2.0
2026-03  1.8      2.0        2.1

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#

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)
Salida
            ventas    ipc
2024-01-01  1000.0  100.0
2024-02-01  1080.0  103.0
2024-03-01  1052.0  106.1
serie[["ventas"]].assign(
    anterior=serie["ventas"].shift(1),
    variacion=serie["ventas"].pct_change().round(3),
    interanual=serie["ventas"].pct_change(12).round(3),
    media_movil=serie["ventas"].rolling(3).mean().round(1),
    acumulado=serie["ventas"].cumsum(),
).iloc[12:15]
Salida
            ventas  anterior  variacion  interanual  media_movil  acumulado
2025-01-01  1601.0    1509.0      0.061       0.601       1543.3    16667.0
2025-02-01  1705.0    1601.0      0.065       0.579       1605.0    18372.0
2025-03-01  1702.0    1705.0     -0.002       0.618       1669.3    20074.0
ParaComando
Valor del período anterior.shift(1)
Diferencia con el anterior.diff()
Variación porcentual (mensual, interanual).pct_change(), .pct_change(12)
Promedio móvil de 3 períodos.rolling(3).mean()
Acumulado.cumsum()
Un año o un rango de fechasserie.loc["2025"], serie.loc["2025-03":"2025-06"]
Cambiar la frecuencia.resample("QE").sum(), .resample("YE").mean()

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

serie["ventas"].resample("QE").sum().head(4)
Salida
2024-03-31    3132.0
2024-06-30    3522.0
2024-09-30    3960.0
2024-12-31    4452.0
Freq: QE-DEC, Name: ventas, dtype: float64
serie["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()
Salida
fecha
2026-01-04    17965.50
2026-01-11    37481.85
Name: total, dtype: float64

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:

serie["ventas_reales"] = (serie["ventas"] / serie["ipc"] * serie["ipc"].iloc[-1]).round()
serie[["ventas", "ipc", "ventas_reales"]].iloc[[0, 12, 23]]
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

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.

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))
Salida
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()
Salida
2026-01-01    2446.0
2026-02-01    2450.0
2026-03-01    2452.0
Freq: MS, Name: predicted_mean, dtype: float64

NumPy 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 False
Salida
2.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))
Salida
4.605170185988092 2.718281828459045 3.0
[0.   0.25 0.5  0.75] [0.   0.25 0.5  0.75 1.  ]
46.0

Nú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
Salida
[ 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)
Salida
660000.0

La 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}")
Salida
t = -4.06, p-valor = 0.0001
PreguntaPruebaCódigo
¿Dos grupos tienen distinta media?t de Welchstats.ttest_ind(a, b, equal_var=False)
Lo mismo, sin suponer normalidadMann-Whitneystats.mannwhitneyu(a, b)
¿Cambió en los mismos casos (antes y después)?t pareadastats.ttest_rel(antes, despues)
¿La media es distinta de un valor?t de una muestrastats.ttest_1samp(x, 40)
¿Tres o más grupos tienen distinta media?ANOVAstats.f_oneway(g1, g2, g3)
¿Dos variables categóricas están asociadas?Chi-cuadradostats.chi2_contingency(pd.crosstab(a, b))
¿Dos variables numéricas se mueven juntas?Pearson, Spearmanstats.pearsonr(x, y), stats.spearmanr(x, y)
¿Los datos son normales?Shapiro-Wilkstats.shapiro(x)
Valores críticosNormal, tstats.norm.ppf(0.975), stats.t.ppf(0.975, df=29)
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}")
Salida
chi2 = 0.66, gl = 5, p-valor = 0.985
r = 0.51, p-valor = 3.1e-21
ANOVA: F = 1.47, p-valor = 0.199

Un 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})")
Salida
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)
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
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 %
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
En la fórmulaSignifica
y ~ x1 + x2y explicada por x1 y x2, con constante.
np.log(y)Logaritmo (hace falta haber hecho import numpy as np).
I(x ** 2)x al cuadrado: I() hace que la cuenta se tome literal.
C(region)Variable categórica: una dummy por categoría, menos la base.
C(region, Treatment("NOA"))Elegir la categoría base.
x1:x2Sólo la interacción.
x1 * x2x1 + x2 + x1:x2.
- 1Sin constante.

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

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)
Salida
Intercept                 12.7601
C(sexo)[T.varón]           0.1774
C(region)[T.Centro]        0.0115
C(region)[T.Cuyo]         -0.1256
C(region)[T.NEA]          -0.0174
C(region)[T.NOA]           0.0076
C(region)[T.Patagonia]     0.0488
educacion                  0.0708
experiencia                0.0337
I(experiencia ** 2)       -0.0005
dtype: float64
retorno = mincer.params["educacion"] * 100
print(f"Cada año más de educación se asocia con un salario {retorno:.1f} % más alto")
Salida
Cada año más de educación se asocia con un salario 7.1 % más alto

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

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

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.

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)
Salida
Intercept           0.002
C(sexo)[T.varón]    1.455
educacion           1.450
experiencia         1.059
dtype: float64

Diagnó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())
Salida
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}")
Salida
Yerba 1kg     ██████████████████████████████ 26,565
Café 500g     ████████████████████████████ 24,442
Azúcar 1kg    ███████████ 9,570
Aceite 900ml  ██████████ 8,670

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:

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

Tres de estos, para reconocerlos cuando aparezcan:

Este ejemplo da error a propósito
pd.read_csv("Ventas.csv")   # el archivo se llama ventas.csv, en minúscula
Error
FileNotFoundError: [Errno 44] No such file or directory: 'Ventas.csv'
Este ejemplo da error a propósito
crudo = pd.read_csv("ventas.csv", sep=";")
crudo["Cliente"]   # la columna es "Cliente " con un espacio
Error
KeyError: 'Cliente'
Este ejemplo da error a propósito
import io

texto = "producto,precio\nYerba,3450\nCafé,5200,50\n"   # coma decimal en un CSV con comas
pd.read_csv(io.StringIO(texto))
Error
ParserError: Error tokenizing data. C error: Expected 2 fields in line 3, saw 3

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

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