Capitulo 03
NumPy y Pandas: el motor de la manipulacion de datos
Pensar vectorialmente, dejar de escribir loops
NumPy y Pandas son las dos librerias que hacen todo el resto posible. Aca aprendes el modelo mental vectorial, broadcasting, indexacion, groupby y los 3 errores que rompen tu codigo en produccion.
3.1 Por que NumPy cambia tu forma de pensar
NumPy no es “Python con numeros”. Es un cambio de paradigma: de operar elemento por elemento (imperativo) a operar sobre el array completo (declarativo/vectorial). La diferencia no es solo velocidad (10-100x). Es que la operacion vectorizada expresa la intencion de forma mas clara.
import numpy as np
import time
# Dataset grande
np.random.seed(42)
N = 1_000_000
alturas = np.random.normal(170, 10, N) # cm
pesos = np.random.normal(70, 15, N) # kg
# MANERA 1: loop ingenuo (Python puro, ~10x mas lento)
start = time.perf_counter()
imcs = []
for i in range(N):
h_m = alturas[i] / 100
imcs.append(pesos[i] / (h_m ** 2))
print(f"Loop Python: {time.perf_counter() - start:.3f}s")
# MANERA 2: list comprehension (mejor, pero todavia lenta)
start = time.perf_counter()
imcs = [pesos[i] / ((alturas[i] / 100) ** 2) for i in range(N)]
print(f"List comp: {time.perf_counter() - start:.3f}s")
# MANERA 3: vectorizacion NumPy (30-100x mas rapido)
start = time.perf_counter()
imcs_np = pesos / ((alturas / 100) ** 2)
print(f"NumPy vectorial: {time.perf_counter() - start:.3f}s")
# Las 3 dan el MISMO resultado. NumPy es ~50x mas rapido. NumPy ejecuta operaciones en C continuo (no en Python interpretado). Los arrays son bloques de memoria contiguos de tipos homogeneos (todos float64 o todos int32), lo que permite usar instrucciones SIMD del procesador (Single Instruction Multiple Data). Python, en cambio, tiene cada numero como un objeto PyObject con punteros, type checking, garbage collection: mucha sobrecarga.
3.2 Broadcasting: la operacion mas importante de NumPy
El broadcasting es la regla que permite operar arrays de formas distintas sin copiar datos. Por ejemplo, restar la media (vector de 784,) a una imagen de (28, 28) sin que falle.
Broadcasting (reglas)
M3Cuando dos arrays tienen formas distintas, NumPy 'estira' el array de rango menor para que coincida con el mayor, SI: (1) cada dimension es igual, o (2) una de ellas es 1. NO estira si las dimensiones son distintas y ninguna es 1: eso es un error.
Ej: (3, 4) + (4,) → (3, 4) porque el segundo se 'estira' a (1, 4) y luego a (3, 4). (3, 4) + (3,) → ERROR porque no se puede hacer 3 = 4.
import numpy as np
# Normalizacion Z-score columna por columna (operacion clave de ML)
X = np.random.randn(100, 5) # 100 muestras, 5 features
mean = X.mean(axis=0) # shape (5,)
std = X.std(axis=0) # shape (5,)
X_norm = (X - mean) / std # broadcasting: (100, 5) - (5,) → (100, 5)
print("X.shape: ", X.shape)
print("mean.shape: ", mean.shape)
print("X_norm.mean(axis=0):", X_norm.mean(axis=0).round(10)) # ~[0, 0, 0, 0, 0]
print("X_norm.std(axis=0): ", X_norm.std(axis=0).round(10)) # ~[1, 1, 1, 1, 1]
# Ejemplo que FALLA: shapes incompatibles
A = np.ones((3, 4))
B = np.ones((3,)) # solo 1 dimension
try:
C = A + B
except ValueError as e:
print(f"\nError esperado: {e}")
# Solucion: reshape B a (1, 3) y luego broadcast a (?, 3) — pero
# eso no matchearia las 4 columnas. Mejor: usar (3, 1) si queremos
# restar por fila. “Right-align las shapes y compara columna por columna”. Ej: (100, 5) vs (5,) → alineamos a la derecha, queda (100, 5) vs (_, 5). Las dimensiones son iguales (5 = 5), entonces OK. (100, 5) vs (100,) → (100, 5) vs (100, _). La segunda columna es 5 vs nada → ERROR.
3.3 Pandas: Series, DataFrame e indexacion
Pandas es la abstraccion de datos tabulares sobre NumPy. Tres conceptos: Series (1D), DataFrame (2D), Index (etiquetas de fila/columna). El Index es lo que hace a Pandas diferente de NumPy: permite joins, time series, alineacion automatica.
| Concepto | Descripcion |
|---|---|
| Series | Array 1D con etiquetas. Piensa en una columna de Excel con nombre. dtype homogeneo. |
| DataFrame | Tabla 2D: columnas de Series que comparten el mismo Index. El objeto central de Pandas. |
| Index | Etiquetas de fila. Por defecto 0, 1, 2, ... pero puede ser fechas, strings, categorias. Permite joins. |
Indexacion: .loc vs .iloc vs .at vs .iat
| Concepto | Descripcion |
|---|---|
| df.loc[fila, col] | Por ETIQUETA. Inclusivo en ambos extremos. El que mas usas. |
| df.iloc[0:5, 1:3] | Por POSICION ENTERA. Exclusivo en el extremo derecho (como Python slices). |
| df.at[idx, "col"] | Acceso ESCALAR por etiqueta. Mas rapido que .loc para un solo valor. |
| df.iat[i, j] | Acceso ESCALAR por posicion. Mas rapido que .iloc para un solo valor. |
| df.query("col > 5") | Filtros con sintaxis SQL-like. Util para filtros complejos legibles. |
| df[mask & mask2] | Componer condiciones con & (AND), | (OR), ~ (NOT). Parentesis obligatorios. |
import pandas as pd
import numpy as np
df = pd.DataFrame({
'region': ['Norte', 'Sur', 'Centro', 'Norte', 'Sur'],
'producto': ['A', 'B', 'A', 'B', 'A'],
'monto': [100, 200, 150, 300, 250],
'fecha': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05'])
}, index=['t1', 't2', 't3', 't4', 't5'])
# Por etiqueta
print(df.loc['t2', 'monto']) # 200
# Por posicion
print(df.iloc[1, 2]) # 200
# Query: legible para filtros complejos
print(df.query("monto > 150 and region == 'Sur'"))
# Asignacion: USAR .loc, NUNCA encadenar []
df.loc[df['monto'] > 150, 'monto_alto'] = True
print(df) 3.4 Tidy Data y melt/pivot
El formato Tidy Data (Hadley Wickham) es la convencion canonica: cada variable es una columna, cada observacion es una fila, cada tipo de unidad forma una tabla. Casi todas las herramientas de DS asumen tidy data.
import pandas as pd
# Wide format: 1 fila por anio, 1 columna por trimestre
wide = pd.DataFrame({
'anio': [2023, 2024],
'Q1': [100, 120],
'Q2': [110, 130],
'Q3': [105, 125],
'Q4': [115, 135],
})
print("Wide:")
print(wide)
# Long format: 1 fila por (anio, trimestre, ventas)
long = pd.melt(wide, id_vars=['anio'], var_name='trimestre', value_name='ventas')
print("\nLong (tidy):")
print(long)
# Para volver a wide: pivot
back_to_wide = long.pivot(index='anio', columns='trimestre', values='ventas')
print("\nDe vuelta a wide:")
print(back_to_wide) La mayoria de las visualizaciones (Seaborn, ggplot) y modelos asumen long format. Si tus datos estan en wide, pd.melt() primero. Si los necesitas wide para reporting, pivot() despues.
3.5 Groupby: split-apply-combine
GroupBy es el patron mas importante de Pandas. Implementa el paradigma split-apply-combine: dividir el DataFrame en grupos segun una o mas claves, aplicar una funcion a cada grupo, y combinar los resultados.
import pandas as pd
import numpy as np
df = pd.DataFrame({
'region': ['Norte', 'Norte', 'Sur', 'Sur', 'Centro', 'Centro'],
'producto':['A', 'B', 'A', 'B', 'A', 'B'],
'monto': [100, 200, 150, 300, 120, 180],
'unidades':[1, 2, 1, 3, 1, 2]
})
# 1) AGG: reduce a 1 fila por grupo. La salida es mas chica que la entrada.
print("=== agg ===")
print(df.groupby('region').agg(
ventas_totales=('monto', 'sum'),
venta_promedio=('monto', 'mean'),
unidades_totales=('unidades', 'sum'),
))
# 2) TRANSFORM: conserva la forma original. Util para features.
print("\n=== transform ===")
df['pct_del_total'] = df.groupby('region')['monto'].transform(
lambda x: x / x.sum()
)
print(df[['region', 'monto', 'pct_del_total']])
# 3) APPLY: maxima flexibilidad, minima performance.
# Solo usar cuando agg/transform no alcanzan.
print("\n=== apply ===")
print(df.groupby('region').apply(lambda g: g.nlargest(1, 'monto'))) - agg: cuando quieres UN numero por grupo (reporte, KPI). Salida: 1 fila por grupo.
- transform: cuando quieres agregar una feature al DataFrame original (ej: “ventas/ventas_de_su_region”). Salida: misma forma que la entrada.
- apply: cuando necesitas logica custom que no es aggregacion ni transformacion simple. Mas lento. Evitar si es posible.
3.6 Errores comunes en NumPy/Pandas
3.7 EDA en 5 minutos (el flujo profesional)
import pandas as pd
import numpy as np
def eda_rapido(df: pd.DataFrame, target: str = None) -> None:
"""EDA de 5 minutos: 6 chequeos que SIEMPRE hay que hacer."""
print(f"=== EDA rapido: {df.shape[0]} filas x {df.shape[1]} columnas ===\n")
# 1. Tipos
print("1. Tipos de dato:")
print(df.dtypes.value_counts())
# 2. Nulos
print("\n2. Valores nulos:")
nulos = df.isna().sum()
if nulos.sum() == 0:
print(" Sin nulos")
else:
print(nulos[nulos > 0].sort_values(ascending=False))
# 3. Duplicados
n_dups = df.duplicated().sum()
print(f"\n3. Duplicados: {n_dups} ({n_dups/len(df):.1%})")
# 4. Numericas: distribucion
print("\n4. Estadisticas de columnas numericas:")
print(df.describe().T.round(2))
# 5. Categoricas: cardinalidad
cat_cols = df.select_dtypes(include=['object', 'category']).columns
if len(cat_cols) > 0:
print("\n5. Cardinalidad de categoricas:")
for c in cat_cols:
n_unique = df[c].nunique()
print(f" {c}: {n_unique} valores unicos {'⚠️ ALTA' if n_unique > 50 else ''}")
# 6. Target (si existe)
if target and target in df.columns:
print(f"\n6. Distribucion del target '{target}':")
vc = df[target].value_counts(normalize=True).round(3)
print(vc)
if len(vc) > 2 and vc.min() < 0.1:
print(" ⚠️ CLASES DESBALANCEADAS: considera stratified split y metricas como F1/PR-AUC.")
# Uso:
# eda_rapido(df, target='churn') - Descargá un dataset publico (Titanic, House Prices o el de tu preferencia, al menos 5 columnas y 1000+ filas).
- Aplica la funcion
eda_rapido()de la seccion 3.7. Reporta todos los hallazgos. - Para cada columna numerica con outliers (IQR), investigá si son errores o valores legitimos (un outlier de $5M en precios de casas es real, un outlier de edad=200 probablemente no).
- Converti a tidy data con
pd.melt()si esta en wide. Hacé una visualizacion con Seaborn (que cubriremos en M5). - Para columnas categoricas de alta cardinalidad (> 50), considera agrupar las menos frecuentes en “OTROS”.
- Identifica el % de nulos por columna. Propon una estrategia: ¿imputar, eliminar fila, eliminar columna?