Capítulo 5: Ingeniería de features, pipelines y validación — el oficio del ML profesional¶
"Applied machine learning is basically feature engineering." — Andrew Ng
"Better data beats fancier algorithms." — máxima repetida en todos los equipos de ML serios del planeta.
Hasta ahora has aprendido los algoritmos: regresión, clasificación, clustering. Ese es el conocimiento. Este capítulo enseña el oficio: lo que separa un notebook de juguete que "saca 95 %" de un sistema profesional que funciona en producción seis meses después de desplegarlo.
El oficio tiene tres pilares, y los tres se entrelazan en este capítulo:
- Ingeniería de features: transformar datos crudos en señales que el modelo pueda explotar.
- Pipelines: encapsular todo el preprocesamiento y el modelo en un único artefacto reproducible y a prueba de fugas.
- Validación honesta: medir el rendimiento de forma que el número que reportas sea el número que verás en producción.
Si dominas estos tres pilares, ya eres más útil para una empresa que el 90 % de la gente que solo sabe llamar a fit() y predict().
Índice¶
- La verdad del oficio: el 80 % está en los datos
- Ingeniería de features
- 2.1 Features numéricas
- 2.2 Features categóricas
- 2.3 Features de fechas
- 2.4 Texto básico: de strings a números
- 2.5 Valores faltantes como información
- Data leakage: el enemigo silencioso
- Pipelines de sklearn: el corazón del ML profesional
- Validación cruzada a fondo
- Búsqueda de hiperparámetros
- Ejemplo integrador: solicitudes de préstamo de principio a fin
- Caso empresarial: el modelo del 95 % que rendía 70 %
- Buenas prácticas
- Malas prácticas
- Errores comunes
- FAQ — Preguntas frecuentes
- Resumen del capítulo
- Bibliografía y recursos
1. La verdad del oficio: el 80 % está en los datos¶
Existe un mito persistente entre quienes empiezan en ML: la idea de que el trabajo consiste en elegir el algoritmo perfecto. Que si el modelo no funciona, la solución es probar uno "más potente". Que la diferencia entre un junior y un senior es que el senior conoce algoritmos más exóticos.
La realidad de la industria es exactamente la opuesta:
| Actividad | % del impacto en el rendimiento (típico) | % del tiempo que le dedica un principiante |
|---|---|---|
| Entender el problema y los datos | ~30 % | 5 % |
| Ingeniería de features | ~40 % | 10 % |
| Validación correcta (sin leakage) | ~15 % (evita desastres) | 5 % |
| Elección del algoritmo | ~10 % | 50 % |
| Ajuste fino de hiperparámetros | ~5 % | 30 % |
Los porcentajes exactos varían por dominio, pero la forma de la tabla es universal: los datos y las features dominan; el algoritmo es secundario. Una regresión logística con features excelentes casi siempre gana a una red neuronal con features mediocres sobre datos tabulares.
Caso empresarial
En 2006 Netflix lanzó el famoso Netflix Prize: un millón de dólares para quien mejorara su recomendador un 10 %. El equipo ganador (BellKor's Pragmatic Chaos) lo consiguió con un ensemble de 107 modelos... que Netflix nunca puso en producción completo. ¿Por qué? Porque la mayor parte de la ganancia venía de dos o tres ideas sobre cómo representar los datos (efectos temporales: la gente puntúa distinto según el día; sesgos por usuario y por película), y el coste de ingeniería de mantener 107 modelos no compensaba las décimas adicionales. Las features temporales valían oro; la complejidad algorítmica extra, calderilla.
Caso empresarial
Un banco europeo mediano (caso documentado en conferencias del sector, anonimizado) pasó ocho meses probando XGBoost, LightGBM y redes neuronales para scoring crediticio, mejorando el AUC de 0.74 a 0.76. Un analista nuevo añadió una sola feature — el ratio deuda/ingresos en lugar de deuda e ingresos por separado — y el AUC saltó a 0.81 con el modelo lineal que ya tenían. Ocho meses de algoritmos < una tarde de pensar en el dominio.
Consejo profesional
Antes de escribir una línea de código de modelado, dedica al menos una sesión completa a hacerte estas preguntas: ¿qué sabría un experto humano del dominio que el modelo no puede ver en las columnas crudas? ¿Qué ratios, diferencias, agregados o transformaciones capturarían ese conocimiento? Esa lista de hipótesis es tu plan de ingeniería de features.
Nota
Nada de esto significa que el algoritmo no importe. Significa que el orden correcto de esfuerzo es: datos → features → validación → algoritmo → hiperparámetros. Los capítulos 6 (ensembles) y siguientes te darán algoritmos más potentes; este capítulo te da la base sin la cual esos algoritmos solo amplifican tus errores.
2. Ingeniería de features¶
La ingeniería de features (feature engineering) es el arte de transformar los datos crudos en representaciones que faciliten al modelo encontrar patrones. Vamos técnica por técnica: qué hace, código línea por línea, cuándo usarla, cuándo no, y qué riesgos tiene.
Trabajaremos con este pequeño DataFrame de ejemplo a lo largo de la sección:
import numpy as np # cálculo numérico
import pandas as pd # manipulación de datos tabulares
rng = np.random.default_rng(42) # generador aleatorio reproducible
df = pd.DataFrame({
"ingresos": rng.lognormal(mean=10, sigma=0.6, size=8).round(0), # cola larga a la derecha
"deuda": rng.lognormal(mean=8.5, sigma=0.8, size=8).round(0), # también asimétrica
"edad": rng.integers(22, 70, size=8), # uniforme-ish
"ciudad": ["Madrid", "Bogotá", "CDMX", "Madrid", "Lima", "CDMX", "Bogotá", "Madrid"],
"educacion": ["secundaria", "grado", "master", "grado", "doctorado", "secundaria", "grado", "master"],
})
print(df)
2.1 Features numéricas¶
2.1.1 Escalado: StandardScaler vs MinMaxScaler vs RobustScaler¶
Muchos algoritmos son sensibles a la escala de las features: KNN y K-Means (distancias), SVM, regresión logística/lineal con regularización, redes neuronales y PCA. Si ingresos va de 5 000 a 500 000 y edad de 22 a 70, la distancia euclídea estará dominada por los ingresos y la edad será casi invisible.
Los tres escaladores principales de sklearn:
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
X = df[["ingresos", "deuda", "edad"]] # solo columnas numéricas
# --- StandardScaler: (x - media) / desviación_típica ---
scaler_std = StandardScaler() # instanciamos el transformador
X_std = scaler_std.fit_transform(X) # fit: aprende media y std; transform: aplica
print("Medias aprendidas:", scaler_std.mean_) # los parámetros aprendidos se guardan con sufijo _
print("Stds aprendidas:", scaler_std.scale_) # esto es lo que se aplicará a datos futuros
# --- MinMaxScaler: (x - min) / (max - min) → rango [0, 1] ---
scaler_mm = MinMaxScaler() # por defecto lleva a [0, 1]
X_mm = scaler_mm.fit_transform(X) # aprende min y max de CADA columna
# --- RobustScaler: (x - mediana) / IQR ---
scaler_rob = RobustScaler() # usa mediana y rango intercuartílico
X_rob = scaler_rob.fit_transform(X) # inmune a valores extremos
Observa el patrón fit / transform: fit aprende parámetros de los datos (media, min, mediana...) y transform los aplica. Este patrón es la clave de todo el capítulo: los parámetros deben aprenderse solo del conjunto de entrenamiento y aplicarse tal cual al de test. Guárdate esta idea; en la sección de leakage volverá con fuerza.
Tabla de decisión de escalado:
| Escalador | Fórmula | Usa cuando... | Evita cuando... | Sensible a outliers |
|---|---|---|---|---|
StandardScaler |
(x − μ) / σ | Distribución ~simétrica; modelos lineales, SVM, PCA, redes | Hay outliers extremos que distorsionan μ y σ | Sí (moderado) |
MinMaxScaler |
(x − min) / (max − min) | Necesitas rango acotado exacto [0,1] (redes con sigmoides, imágenes) | Hay outliers: un solo valor extremo comprime todo lo demás | Mucho |
RobustScaler |
(x − mediana) / IQR | Datos con outliers reales que no quieres eliminar (ingresos, precios) | Casi nunca hace daño; es la opción defensiva | No |
| (ninguno) | — | Árboles de decisión, Random Forest, Gradient Boosting | — | — |
Advertencia
Los modelos basados en árboles (Decision Tree, Random Forest, XGBoost) no necesitan escalado: dividen por umbrales (ingresos > 30000) y el umbral es invariante a transformaciones monótonas. Escalar para un Random Forest no rompe nada, pero es trabajo inútil y añade ruido cognitivo al pipeline.
2.1.2 Transformación logarítmica para colas largas¶
Variables como ingresos, precios de vivienda, número de visitas web o importes de transacciones tienen colas largas a la derecha: la mayoría de valores son moderados y unos pocos son enormes. Los modelos lineales sufren con esto porque unos pocos puntos dominan el ajuste.
import matplotlib.pyplot as plt # para visualizar el efecto
ingresos = rng.lognormal(mean=10, sigma=0.8, size=2000) # simulamos 2000 ingresos con cola larga
fig, axes = plt.subplots(1, 2, figsize=(10, 3)) # dos gráficos lado a lado
axes[0].hist(ingresos, bins=50) # histograma crudo: cola enorme a la derecha
axes[0].set_title("Ingresos crudos (asimétricos)")
axes[1].hist(np.log1p(ingresos), bins=50) # log1p = log(1 + x): seguro si hay ceros
axes[1].set_title("log1p(ingresos) (≈ campana)")
plt.show()
np.log1p(x)calculalog(1 + x): se prefiere anp.log(x)porque no explota con x = 0 (log(0) = −∞).- La transformación inversa es
np.expm1. Si transformas el target (por ejemplo, predecirlog(precio)), recuerda des-transformar las predicciones connp.expm1(pred)antes de reportar errores en euros.
Cuándo usar: variable estrictamente positiva, asimetría fuerte a la derecha, modelo sensible a escala/outliers (lineales, KNN, redes).
Cuándo no: variables con valores negativos (el log no existe), variables ya simétricas, modelos de árboles (les da igual).
Riesgo: olvidarte de invertir la transformación al interpretar resultados; aplicar log a algo con ceros usando np.log en vez de np.log1p.
Nota
sklearn ofrece PowerTransformer (Box-Cox, Yeo-Johnson) que aprende automáticamente la mejor transformación de potencia, y QuantileTransformer que fuerza cualquier distribución a una normal o uniforme. Son las versiones "industriales" del log manual y, al ser transformadores, encajan en pipelines.
2.1.3 Binning (discretización)¶
Binning convierte una variable continua en categorías: edad → [18-30, 30-45, 45-60, 60+].
from sklearn.preprocessing import KBinsDiscretizer
# 4 bins con estrategia 'quantile': cada bin tiene ~el mismo número de muestras
binner = KBinsDiscretizer(
n_bins=4, # número de intervalos
encode="ordinal", # salida como enteros 0,1,2,3 (también existe 'onehot')
strategy="quantile", # cortes en los cuartiles; 'uniform' corta a igual anchura
)
edad_binned = binner.fit_transform(df[["edad"]]) # fit aprende los cortes; transform asigna bin
print("Cortes aprendidos:", binner.bin_edges_) # los umbrales entre bins
Cuándo usar: cuando la relación con el target es claramente no monótona (el riesgo de impago puede ser alto en muy jóvenes y muy mayores, bajo en medio) y usas un modelo lineal que no puede capturar eso; cuando necesitas interpretabilidad regulatoria ("tramo de edad"). Cuándo no: con modelos de árboles (ellos ya hacen binning implícito y mejor, porque eligen los cortes mirando el target); cuando la relación es monótona (pierdes información sin ganar nada). Riesgo: perder resolución — dos personas de 30 y 44 años caen en el mismo bin y se vuelven indistinguibles.
2.1.4 Interacciones y ratios: donde vive el conocimiento del dominio¶
Aquí es donde el conocimiento del negocio se convierte en rendimiento. Un modelo lineal ve deuda e ingresos por separado, pero lo que predice el impago es su relación:
# El ratio deuda/ingresos: la feature estrella del scoring crediticio
df["ratio_deuda_ingresos"] = df["deuda"] / (df["ingresos"] + 1) # +1 evita división por cero
# Otras interacciones típicas según dominio:
# e-commerce: gasto_total / num_pedidos → ticket medio
# salud: peso / altura**2 → IMC
# SaaS: logins_ultimo_mes / logins_mes_previo → tendencia de engagement
# inmobiliaria: precio / metros_cuadrados → precio por m²
¿Por qué funciona? Un modelo lineal solo puede aprender a·deuda + b·ingresos, que no puede expresar una división. Al darle el ratio ya calculado, le regalas en una columna lo que jamás podría construir por sí mismo. Los árboles pueden aproximar interacciones con suficientes datos y profundidad, pero dárselas explícitas les ahorra muchísimo trabajo y mejora la generalización con datasets pequeños.
Consejo profesional
La pregunta mágica para inventar ratios: "¿qué compararía un experto humano?". Un analista de crédito no mira la deuda absoluta; la compara con los ingresos. Un médico no mira el peso; lo compara con la altura. Cada comparación experta es un ratio candidato.
2.1.5 Features polinómicas¶
PolynomialFeatures genera automáticamente potencias y productos cruzados:
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(
degree=2, # hasta grado 2: x², y², x·y
include_bias=False, # sin columna constante de unos (el modelo ya tiene intercept)
interaction_only=False, # True generaría solo productos cruzados, sin cuadrados
)
X_poly = poly.fit_transform(df[["ingresos", "edad"]]) # de 2 columnas pasamos a 5
print(poly.get_feature_names_out()) # ['ingresos','edad','ingresos^2','ingresos edad','edad^2']
Cuándo usar: modelos lineales con pocas features y sospecha de relaciones curvas o interacciones; es la manera estándar de darle "curvatura" a una regresión lineal. Cuándo no: con muchas features — el número de columnas explota combinatoriamente (20 features a grado 3 → 1 771 columnas) y con ello el sobreajuste. Riesgo: overfitting severo. Combínalo casi siempre con regularización (Ridge/Lasso) y escalado previo.
Ejercicio rápido 1: Tienes una columna precio_vivienda con asimetría fuerte a la derecha y algunos valores a 0 (viviendas de protección cedidas gratis). Quieres usarla como feature en una regresión Ridge. ¿Qué transformación aplicas y por qué no np.log?
Ver solución
`np.log1p(precio_vivienda)`. La asimetría fuerte a la derecha pide una transformación logarítmica para que el modelo lineal no quede dominado por los valores extremos. No puede usarse `np.log` directamente porque `log(0) = -inf` y esos `-inf` romperían el entrenamiento; `log1p(x) = log(1+x)` vale 0 cuando x=0 y es prácticamente idéntica a `log(x)` para valores grandes. Después del log, un `StandardScaler` dentro del pipeline dejaría la feature lista para Ridge.2.2 Features categóricas¶
Los modelos de sklearn solo comen números. Toda columna de texto categórico (ciudad, educacion) debe codificarse. La elección del codificador importa mucho.
2.2.1 OneHotEncoder: una columna binaria por categoría¶
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(
handle_unknown="ignore", # ← CRÍTICO: categoría nueva en producción → todo ceros, no crash
sparse_output=False, # devuelve array denso (más cómodo para inspeccionar; sparse ahorra memoria)
)
ciudad_ohe = ohe.fit_transform(df[["ciudad"]]) # fit aprende las categorías del train
print(ohe.categories_) # [array(['Bogotá','CDMX','Lima','Madrid'])]
print(ohe.get_feature_names_out()) # ['ciudad_Bogotá','ciudad_CDMX','ciudad_Lima','ciudad_Madrid']
print(ciudad_ohe[:3]) # cada fila: un 1 en su ciudad, 0 en el resto
¿Por qué handle_unknown="ignore" es innegociable? Porque en producción aparecerán categorías que no estaban en el entrenamiento. Entrenaste con Madrid, Bogotá, CDMX y Lima; el martes llega una solicitud desde Valparaíso. Con el comportamiento por defecto (error), tu servicio lanza una excepción y el sistema de préstamos se cae a las 3 de la mañana. Con ignore, la fila se codifica como todo ceros (ninguna ciudad conocida) y el modelo hace una predicción razonable con el resto de features.
Cuándo usar: pocas categorías (< ~15), sin orden natural, cualquier modelo. Cuándo no: alta cardinalidad (miles de códigos postales → miles de columnas casi todas cero: memoria, ruido, overfitting).
2.2.2 OrdinalEncoder: cuando el orden importa¶
from sklearn.preprocessing import OrdinalEncoder
ord_enc = OrdinalEncoder(
categories=[["secundaria", "grado", "master", "doctorado"]], # ← orden EXPLÍCITO de menor a mayor
handle_unknown="use_encoded_value", # categoría desconocida →
unknown_value=-1, # ...se codifica como -1
)
edu_ord = ord_enc.fit_transform(df[["educacion"]]) # secundaria→0, grado→1, master→2, doctorado→3
print(edu_ord.ravel())
- Pasamos
categoriesexplícitamente: si dejas que sklearn infiera, ordena alfabéticamente (doctorado=0,grado=1,master=2,secundaria=3) — ¡un orden absurdo que miente al modelo! - Cuándo usar: categorías con orden real (nivel educativo, talla S/M/L/XL, rating crediticio AAA/AA/A/BBB). También es aceptable para árboles con alta cardinalidad aunque no haya orden (los árboles pueden aislar cualquier valor con cortes sucesivos).
- Cuándo no: categorías sin orden (
ciudad) en un modelo lineal: le estarías diciendo que CDMX (1) está "entre" Bogotá (0) y Lima (2), una relación numérica inventada.
2.2.3 Target encoding: poder y peligro¶
Para categóricas de alta cardinalidad (código postal, ID de comercio, modelo de coche) existe una técnica potentísima: sustituir cada categoría por la media del target en esa categoría. Código postal 28001 → tasa de impago histórica del 28001.
from sklearn.preprocessing import TargetEncoder # disponible desde sklearn 1.3
te = TargetEncoder(
smooth="auto", # suavizado: mezcla la media de la categoría con la media global,
# ponderando por cuántas muestras tiene la categoría (categorías
# raras → tira hacia la media global, evita memorizar ruido)
cv=5, # ← LA CLAVE: codificación con validación cruzada interna.
) # Cada fila se codifica usando medias calculadas en OTROS folds,
# nunca con su propio target. Esto corta el leakage de raíz.
y = np.array([0, 1, 0, 0, 1, 1, 0, 0]) # target de ejemplo (impago sí/no)
ciudad_te = te.fit_transform(df[["ciudad"]], y) # nota: necesita y, a diferencia de OHE
print(ciudad_te.ravel().round(3))
Advertencia
El target encoding ingenuo (agrupar por categoría, calcular la media del target y mapear) es una de las fuentes de leakage más devastadoras del ML tabular. Cada fila participa en el cálculo de su propia codificación: la feature contiene una versión filtrada del target. En categorías con pocas muestras, la feature es casi literalmente el target. Resultado: validación espectacular, producción desastrosa. Las dos defensas son el suavizado (smoothing) y la codificación out-of-fold con CV — exactamente lo que hace TargetEncoder de sklearn cuando llamas a fit_transform (usa el parámetro cv); fíjate en que transform sobre datos nuevos sí usa las medias completas del train, que es lo correcto.
Tabla comparativa de codificadores categóricos:
| Codificador | Salida | Cardinalidad ideal | ¿Necesita y? | Riesgo principal | Modelo típico |
|---|---|---|---|---|---|
OneHotEncoder |
k columnas binarias | Baja (< 15) | No | Explosión de columnas | Cualquiera |
OrdinalEncoder |
1 columna entera | Cualquiera | No | Inventa orden falso (en lineales) | Árboles; ordinales reales |
TargetEncoder |
1 columna real | Alta (> 15) | Sí | Leakage si se hace mal | Cualquiera, sobre todo con alta cardinalidad |
| Frequency encoding | 1 columna (conteo) | Alta | No | Colisiones (dos categorías con igual frecuencia) | Árboles |
2.3 Features de fechas¶
Una columna datetime cruda es inútil para sklearn. Pero dentro de una fecha viven decenas de señales:
fechas = pd.to_datetime(pd.Series([
"2026-01-15 09:30", "2026-03-08 22:10", "2026-07-01 14:00",
"2026-12-24 18:45", "2026-05-30 03:20", "2026-08-15 11:00",
]))
df_fechas = pd.DataFrame({"fecha": fechas})
df_fechas["anio"] = df_fechas["fecha"].dt.year # tendencia a largo plazo
df_fechas["mes"] = df_fechas["fecha"].dt.month # estacionalidad anual
df_fechas["dia_semana"] = df_fechas["fecha"].dt.dayofweek # 0=lunes ... 6=domingo
df_fechas["hora"] = df_fechas["fecha"].dt.hour # patrones intradía
df_fechas["es_finde"] = (df_fechas["dia_semana"] >= 5).astype(int) # sábado o domingo
df_fechas["dia_mes"] = df_fechas["fecha"].dt.day # efectos de "principio de mes" (nóminas)
# Festivos: la librería 'holidays' cubre decenas de países
import holidays # pip install holidays
festivos_es = holidays.Spain(years=[2026]) # diccionario fecha → nombre del festivo
df_fechas["es_festivo"] = df_fechas["fecha"].dt.date.map(
lambda d: int(d in festivos_es) # 1 si la fecha es festivo en España
)
print(df_fechas)
Codificación cíclica: el problema de las 23:59 y las 00:00¶
Si codificas la hora como número (0–23), el modelo cree que las 23 h y las 0 h están máximamente lejos (distancia 23), cuando en realidad están a un minuto. Lo mismo con diciembre (12) y enero (1). El tiempo es circular, y la solución es proyectarlo sobre un círculo con seno y coseno:
# Cada instante se convierte en un punto sobre el círculo unidad.
# El ángulo es proporcional a la posición dentro del ciclo (24 h, 12 meses...).
df_fechas["hora_sin"] = np.sin(2 * np.pi * df_fechas["hora"] / 24) # componente Y del círculo
df_fechas["hora_cos"] = np.cos(2 * np.pi * df_fechas["hora"] / 24) # componente X del círculo
df_fechas["mes_sin"] = np.sin(2 * np.pi * (df_fechas["mes"] - 1) / 12) # -1 para que enero sea ángulo 0
df_fechas["mes_cos"] = np.cos(2 * np.pi * (df_fechas["mes"] - 1) / 12)
¿Por qué dos columnas (sin y cos) y no solo el seno? Porque el seno solo no es inyectivo: sin de las 6 h y de las 18 h... no, mejor visto así: hay pares de horas distintas con el mismo seno (las 9 h y las 15 h comparten seno). Con las dos coordenadas (sin, cos) cada hora tiene un punto único en el círculo, y la distancia euclídea entre dos puntos refleja fielmente la cercanía temporal: las 23 h y las 0 h quedan pegadas, como debe ser.
Cuándo usar la codificación cíclica: modelos basados en distancias o lineales (KNN, SVM, redes, regresiones) con variables temporales cíclicas.
Cuándo no es necesaria: árboles y boosting — pueden cortar hora >= 22 y hora <= 1 en ramas distintas y unirlas; con suficientes datos, la versión ordinal les basta (aunque la cíclica rara vez les perjudica).
2.4 Texto básico: de strings a números¶
El módulo 06 cubrirá embeddings y transformers. Pero muchísimo valor se extrae del texto con técnicas simples que caben en un pipeline de sklearn:
comentarios = pd.Series([
"producto excelente, envío rápido",
"MUY MALO!!! no funciona, quiero devolución",
"correcto, sin más",
"increíble calidad precio, repetiré seguro",
])
# --- Features "artesanales": baratas y sorprendentemente útiles ---
feats = pd.DataFrame()
feats["longitud"] = comentarios.str.len() # textos largos ≈ opiniones intensas
feats["num_palabras"] = comentarios.str.split().str.len() # riqueza del comentario
feats["num_exclam"] = comentarios.str.count("!") # intensidad emocional
feats["pct_mayusculas"] = comentarios.str.count(r"[A-ZÁÉÍÓÚÑ]") / feats["longitud"] # GRITOS
print(feats)
# --- TF-IDF: la representación clásica de texto ---
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer(
max_features=1000, # nos quedamos con los 1000 términos más frecuentes
ngram_range=(1, 2), # unigramas y bigramas: "muy" y "muy malo" (¡el bigrama cambia todo!)
min_df=1, # ignora términos que aparecen en menos de min_df documentos
)
X_texto = tfidf.fit_transform(comentarios) # matriz dispersa (docs × términos)
print(X_texto.shape) # cada celda: peso TF-IDF del término en el doc
TF-IDF pondera cada palabra por su frecuencia en el documento (TF) dividida por su frecuencia en el corpus (IDF): palabras comunes en todos los documentos ("el", "de") pesan poco; palabras distintivas ("devolución") pesan mucho. Es una bolsa de palabras: pierde el orden, pero para clasificación de temas o sentimiento básico sigue siendo una línea base durísima de batir... hasta que llegan los embeddings del módulo 06, que capturan significado ("pésimo" ≈ "horrible" aunque no compartan letras).
Y como TfidfVectorizer es un transformador de sklearn, encaja directamente en un pipeline con un clasificador detrás — texto crudo entra, predicción sale:
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
# El clasificador de texto clásico en 4 líneas: TF-IDF + logística.
pipe_texto = Pipeline([
("tfidf", TfidfVectorizer(max_features=5000, ngram_range=(1, 2))), # texto → matriz dispersa
("clf", LogisticRegression(max_iter=1000)), # logística sobre esa matriz
])
# pipe_texto.fit(comentarios_train, etiquetas_train) # ajusta vocabulario + modelo, solo con train
# pipe_texto.predict(["envío lento pero producto ok"]) # producción: recibe el string tal cual
Nota
Observa que el vocabulario del TF-IDF también se aprende de los datos (fit): si vectorizas todo el corpus antes de dividir, las palabras que solo existen en el test se cuelan en el vocabulario del train. Otra fuga sutil que el pipeline elimina de raíz.
2.5 Valores faltantes como información¶
Los NaN no son solo un estorbo técnico: el hecho de que falte un dato es a menudo información. Que alguien no declare sus ingresos en una solicitud de crédito no es aleatorio.
from sklearn.impute import SimpleImputer
df_nan = pd.DataFrame({
"ingresos": [30000, np.nan, 52000, np.nan, 41000, 38000],
"edad": [34, 45, np.nan, 29, 51, 62],
})
# --- Paso 1: preservar la señal "faltaba" ANTES de imputar ---
imputer = SimpleImputer(
strategy="median", # 'mean', 'median', 'most_frequent' o 'constant'
add_indicator=True, # ← añade columnas binarias extra: 1 donde había NaN
)
X_imp = imputer.fit_transform(df_nan) # fit aprende las medianas del train
print(imputer.statistics_) # las medianas aprendidas
print(X_imp) # [ingresos, edad, ingresos_faltaba, edad_faltaba]
Estrategias de imputación y cuándo usarlas:
| Estrategia | Cómo funciona | Úsala cuando... | Cuidado con... |
|---|---|---|---|
mean |
Media de la columna | Distribución simétrica sin outliers | Outliers arrastran la media |
median |
Mediana de la columna | Colas largas, outliers (la opción segura por defecto) | Poca cosa; es robusta |
most_frequent |
Moda | Categóricas | Empates arbitrarios |
constant (+ fill_value) |
Valor fijo (p.ej. "desconocido", −1) | Categóricas; cuando el NaN es una categoría en sí | −1 en numéricas confunde a modelos lineales |
KNNImputer |
Media de los k vecinos más parecidos | Correlaciones fuertes entre features | Coste computacional; necesita escalado previo |
IterativeImputer |
Modela cada feature en función de las demás | Datasets pequeños donde cada dato cuenta | Lento; experimental en sklearn |
Advertencia
La imputación debe hacerse después de dividir en train y test — o, mejor dicho, los estadísticos de imputación (la mediana, la media) deben calcularse solo con el train y aplicarse al test. Si calculas la mediana con todo el dataset, información del test se filtra al entrenamiento. ¿Suena paranoico? Es exactamente el tipo de fuga sutil que analizamos en la siguiente sección. Y adelantamos la buena noticia: cuando el imputador vive dentro de un Pipeline, este error se vuelve imposible de cometer.
Ejercicio rápido 2: Tienes la columna codigo_postal con 8 000 valores distintos y quieres predecir impago con una regresión logística. Descarta razonadamente: (a) OneHotEncoder, (b) OrdinalEncoder, (c) TargetEncoder con cv, y elige la mejor.
Ver solución
(a) OneHotEncoder crearía 8 000 columnas, casi todas cero: memoria disparada, varianza altísima, y la logística sobreajustaría los códigos postales con pocas muestras. Descartado. (b) OrdinalEncoder asignaría enteros arbitrarios 0–7999 y la regresión logística interpretaría ese número como magnitud (el CP 7999 "vale más" que el 3): orden inventado sin sentido para un modelo lineal. Descartado. (c) **TargetEncoder con `cv` y `smooth`** es la respuesta: comprime los 8 000 códigos en una sola columna con significado directo (tasa de impago suavizada de la zona), el suavizado protege las categorías raras y la codificación out-of-fold evita el leakage. Es el caso de uso canónico del target encoding.3. Data leakage: el enemigo silencioso¶
Data leakage (fuga de datos): cualquier situación en la que información que no estará disponible en el momento de la predicción real se cuela en el entrenamiento o en la evaluación del modelo. El resultado es siempre el mismo: métricas de validación infladas y un batacazo en producción.
Es, con diferencia, el error más caro del ML aplicado. No falla ruidosamente como un bug de sintaxis: falla en silencio, dándote números mejores de lo debido. El modelo con leakage aprueba todos tus tests y suspende en el mundo real.
3.1 Los tres tipos de leakage¶
flowchart TD
A["DATA LEAKAGE"] --> B["Target leakage<br/>(fuga del objetivo)"]
A --> C["Contaminación train-test"]
A --> D["Leakage temporal"]
B --> B1["Una feature contiene información<br/>derivada del target o posterior a él.<br/>Ej: 'antibiótico_recetado' para<br/>predecir 'tiene_infección'"]
C --> C1["Estadísticos calculados con TODO el<br/>dataset antes de dividir: escalado,<br/>imputación, selección de features.<br/>El test 'opina' sobre el train"]
D --> D1["Entrenar con el futuro y evaluar<br/>en el pasado. Split aleatorio en<br/>series temporales, features con<br/>agregados que miran adelante"]
3.2 Cinco historias reales de leakage sutil¶
Estos cinco patrones aparecen una y otra vez en empresas reales. Léelos como cuentos de terror profesional.
Historia 1 — La feature que venía del futuro. Un equipo de un hospital construye un modelo para predecir neumonía a partir de historiales. AUC de 0.99. Celebración. Revisión posterior: entre las features estaba medicacion_actual, que incluía... antibióticos para la neumonía. La medicación se prescribe después del diagnóstico que intentaban predecir. El modelo no predecía neumonía; leía la respuesta escrita por el médico. En producción, con pacientes aún sin diagnosticar, esa columna estaba vacía y el modelo era inútil. Moraleja: por cada feature pregunta "¿este valor existe ANTES del momento en que necesito predecir?".
Historia 2 — Normalizar antes de dividir. Un data scientist hace StandardScaler().fit_transform(X) sobre TODO el dataset y luego train_test_split. Parece inocente: solo son medias y desviaciones. Pero la media del dataset completo contiene información del test: si el test tiene valores atípicamente altos, la media global sube, y el train queda escalado "sabiendo" algo del test. Con datasets grandes el efecto es pequeño; con datasets pequeños o con selección de features basada en correlaciones globales, el efecto puede inflar la métrica varios puntos. Es el leakage más común del mundo porque parece que no importa.
Historia 3 — Imputar con estadísticas globales. Variante de la anterior y aún más frecuente: df.fillna(df.mean()) sobre el dataset entero, y luego dividir. La media usada para rellenar los NaN del train incluye valores del test. Peor todavía si la imputación es por grupo (groupby('ciudad').transform('mean')) con grupos pequeños: filas del test acaban determinando directamente valores del train.
Historia 4 — Duplicados repartidos entre train y test. Una empresa de clasificación de imágenes de productos tenía fotos casi duplicadas (misma prenda, dos ángulos, o la misma imagen subida dos veces por vendedores distintos). El split aleatorio puso una copia en train y otra en test. El modelo "acertaba" el test memorizando el train. Accuracy reportada: 94 %. Con deduplicación previa al split: 81 %. El mismo patrón ocurre en tabular: el mismo cliente con dos solicitudes casi idénticas, una en cada lado del split.
Historia 5 — El ID que sabía demasiado. Un modelo de detección de fraude incluyó transaction_id como feature numérica ("total, los árboles ignoran lo irrelevante"). Resultó que los IDs eran secuenciales y el equipo de fraude había cargado los casos confirmados de fraude en un lote posterior: los fraudes tenían IDs sistemáticamente más altos. El árbol aprendió id > 8_450_000 → fraude. En validación (mismo dataset, misma artimaña) funcionaba de maravilla; con transacciones nuevas, el umbral no significaba nada. Variantes del mismo mal: número de fila, timestamp de inserción en la base de datos, nombre del fichero de origen.
3.3 Cómo detectar el leakage¶
El leakage rara vez se ve en el código; se ve en los síntomas:
| Síntoma | Qué sospechar |
|---|---|
| Métrica "demasiado buena" (AUC > 0.95 en un problema humano difícil) | Target leakage en alguna feature |
| Una sola feature domina abrumadoramente la importancia | Esa feature deriva del target |
| Gran diferencia entre CV y test final, o entre test y producción | Contaminación train-test o temporal |
| El rendimiento cae en cuanto quitas una feature "administrativa" (id, timestamp) | ID correlacionado con el target |
| El modelo rinde mejor en test que en train | Duplicados o algo muy raro en el split |
Consejo profesional
La prueba del olfato definitiva: si tu modelo saca 0.98 de AUC prediciendo algo que a un experto humano le cuesta, no lo celebres: audítalo. En ML aplicado, las noticias demasiado buenas casi siempre son bugs.
3.4 Checklist anti-leakage¶
Antes de reportar cualquier métrica, repasa:
- [ ] ¿Cada feature existe en el momento de la predicción? Simula mentalmente el instante de producción: ¿qué columnas están ya rellenas?
- [ ] ¿Dividiste ANTES de cualquier
fit? Escalado, imputación, selección de features, target encoding: todo se ajusta solo con train. - [ ] ¿Hay duplicados o casi-duplicados? Deduplica antes del split; si hay entidades repetidas (mismo cliente en varias filas), usa
GroupKFold. - [ ] ¿Los datos son temporales? Entonces el split debe respetar el tiempo (
TimeSeriesSplit), nunca aleatorio. - [ ] ¿Eliminaste IDs, timestamps de sistema y columnas administrativas?
- [ ] ¿El target encoding usa out-of-fold?
- [ ] ¿La métrica es sospechosamente alta? Ejecuta un análisis de importancia de features y explica la top-1 ante un colega.
- [ ] ¿Todo el preprocesamiento vive dentro de un Pipeline? ← Esta única casilla previene automáticamente la mitad de las anteriores. Vamos a ello.
4. Pipelines de sklearn: el corazón del ML profesional¶
4.1 Por qué existen los pipelines¶
Un Pipeline encadena transformadores y un modelo final en un solo objeto que se comporta como un estimador más. Sus tres superpoderes:
- Anti-leakage automático. Dentro de una validación cruzada, el pipeline re-ajusta todo el preprocesamiento solo con el fold de entrenamiento, en cada fold. Es estructuralmente imposible que la media del test contamine el train. El error de las Historias 2 y 3 desaparece por diseño.
- Reproducibilidad. Todo el flujo — imputación, escalado, encoding, modelo — está en un objeto con parámetros explícitos. Nada de "ah, y antes ejecuté esta celda del notebook que ya borré".
- Despliegue. El pipeline serializado es el artefacto que va a producción: recibe datos crudos (con NaN, con strings, con fechas) y devuelve predicciones. El código de producción no reimplementa el preprocesamiento (fuente clásica de bugs de training-serving skew); ejecuta exactamente el mismo objeto.
flowchart LR
subgraph PIPELINE["Pipeline (un único objeto)"]
direction LR
A["SimpleImputer<br/>(mediana del TRAIN)"] --> B["StandardScaler<br/>(μ, σ del TRAIN)"] --> C["LogisticRegression"]
end
D["Datos crudos<br/>(NaN, escalas locas)"] --> PIPELINE --> E["Predicciones"]
style PIPELINE fill:#e8f4e8,stroke:#2e7d32
4.2 Pipeline paso a paso¶
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
# Un pipeline es una lista de tuplas (nombre, transformador/estimador).
# Todos los pasos menos el último deben tener fit y transform;
# el último solo necesita fit (y predict si es un modelo).
pipe = Pipeline(steps=[
("imputador", SimpleImputer(strategy="median")), # paso 1: rellenar NaN con la mediana del train
("escalador", StandardScaler()), # paso 2: estandarizar con μ y σ del train
("modelo", LogisticRegression(max_iter=1000)), # paso final: el clasificador
])
# Al llamar pipe.fit(X_train, y_train) ocurre en cadena:
# 1. imputador.fit(X_train) → aprende medianas → X1 = imputador.transform(X_train)
# 2. escalador.fit(X1) → aprende μ, σ → X2 = escalador.transform(X1)
# 3. modelo.fit(X2, y_train) → entrena la logística
#
# Al llamar pipe.predict(X_test) ocurre:
# 1. X1 = imputador.transform(X_test) ← usa las medianas DEL TRAIN (no re-ajusta)
# 2. X2 = escalador.transform(X1) ← usa μ, σ DEL TRAIN
# 3. return modelo.predict(X2)
#
# Fíjate: en predict jamás se llama a fit. El test nunca influye en los parámetros. Anti-leakage gratis.
4.3 ColumnTransformer: numéricas y categóricas a la vez¶
Los datasets reales mezclan tipos de columna, y cada tipo necesita su tratamiento. ColumnTransformer aplica pipelines distintos a subconjuntos de columnas y concatena los resultados:
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
# --- 1. Declaramos qué columnas son de cada tipo ---
cols_numericas = ["ingresos", "deuda", "edad", "ratio_deuda_ingresos"]
cols_categoricas = ["ciudad", "educacion"]
# --- 2. Mini-pipeline para las numéricas ---
pipe_num = Pipeline(steps=[
("imputador", SimpleImputer(strategy="median", add_indicator=True)), # NaN→mediana + flag de missing
("escalador", StandardScaler()), # estandarización
])
# --- 3. Mini-pipeline para las categóricas ---
pipe_cat = Pipeline(steps=[
("imputador", SimpleImputer(strategy="most_frequent")), # NaN→categoría más frecuente
("onehot", OneHotEncoder(handle_unknown="ignore")), # a binarias, robusto a categorías nuevas
])
# --- 4. El ColumnTransformer une ambos mundos ---
preprocesador = ColumnTransformer(
transformers=[
# (nombre, transformador, columnas a las que se aplica)
("numericas", pipe_num, cols_numericas),
("categoricas", pipe_cat, cols_categoricas),
],
remainder="drop", # columnas no mencionadas → se descartan ('passthrough' las dejaría pasar tal cual)
verbose_feature_names_out=True, # los nombres de salida llevan prefijo: 'numericas__ingresos'
)
# --- 5. Pipeline completo: preprocesador + modelo ---
pipe_completo = Pipeline(steps=[
("preproceso", preprocesador), # todo el feature engineering
("modelo", LogisticRegression(max_iter=1000)), # el clasificador al final
])
Nota
remainder="drop" es la opción defensiva: si mañana alguien añade una columna transaction_id al DataFrame, el pipeline la ignora en lugar de colártela como feature (¿recuerdas la Historia 5?). Usa passthrough solo cuando controles exactamente qué columnas llegan.
4.4 Acceder a los pasos y a los nombres de las features¶
# Los pasos son accesibles por nombre, como un diccionario:
pipe_completo.named_steps["modelo"] # la LogisticRegression
pipe_completo["preproceso"] # sintaxis abreviada equivalente
# Tras el fit, puedes inspeccionar los parámetros aprendidos:
# pipe_completo["preproceso"].named_transformers_["numericas"]["imputador"].statistics_
# get_feature_names_out: los nombres de las columnas DESPUÉS del preprocesado.
# Imprescindible para interpretar coeficientes o importancias:
# nombres = pipe_completo["preproceso"].get_feature_names_out()
# → ['numericas__ingresos', ..., 'categoricas__ciudad_Bogotá', 'categoricas__ciudad_CDMX', ...]
# Ejemplo de uso: emparejar coeficientes de la logística con su feature
# coefs = pd.Series(
# pipe_completo["modelo"].coef_[0], # coeficientes del modelo entrenado
# index=pipe_completo["preproceso"].get_feature_names_out(), # nombres post-transformación
# ).sort_values() # ordenados: los más negativos y positivos
4.5 Serialización con joblib: el artefacto de producción¶
import joblib # serializador optimizado para objetos con arrays
# Guardar el pipeline ENTERO (preprocesado + modelo) en un solo fichero:
joblib.dump(pipe_completo, "modelo_prestamos_v1.joblib")
# ...meses después, en el servidor de producción:
pipeline_produccion = joblib.load("modelo_prestamos_v1.joblib")
# predicciones = pipeline_produccion.predict(solicitudes_nuevas_crudas)
# ^^^^^^^^^^^^^^^^^^^^^^^^
# datos CRUDOS: con NaN, con strings. El pipeline se encarga de todo.
Esto es lo que se despliega: un solo fichero que contiene medianas de imputación, medias de escalado, categorías del one-hot y pesos del modelo. En el capítulo 7 (despliegue) envolveremos este .joblib en una API. Si en lugar del pipeline guardaras solo el modelo, el código de producción tendría que replicar el preprocesamiento a mano — y cualquier discrepancia (una mediana recalculada, un orden de columnas distinto) produce predicciones corruptas sin ningún error visible.
Advertencia
Un fichero joblib solo es seguro de cargar si confías en su origen (deserializar ejecuta código), y debe cargarse con la misma versión de sklearn con la que se guardó (fija las versiones en requirements.txt). Guarda junto al artefacto un fichero de metadatos: fecha, commit, métricas y versiones.
4.6 Transformadores personalizados: FunctionTransformer¶
¿Y si tu feature engineering no existe como clase de sklearn? Por ejemplo, el log1p de la sección 2.1.2 o un ratio de dominio. FunctionTransformer convierte cualquier función en un paso de pipeline:
from sklearn.preprocessing import FunctionTransformer
# Envuelve np.log1p como transformador (con su inversa, por si haces inverse_transform):
log_transformer = FunctionTransformer(
func=np.log1p, # la transformación hacia delante
inverse_func=np.expm1, # la inversa (opcional pero recomendable)
feature_names_out="one-to-one", # los nombres de columna se conservan tal cual
)
# Un transformador que CREA una feature nueva a partir del DataFrame:
def agregar_ratio(X):
X = X.copy() # nunca mutar el DataFrame original
X["ratio_deuda_ingresos"] = X["deuda"] / (X["ingresos"] + 1) # la feature de dominio
return X # devuelve el DataFrame ampliado
creador_ratio = FunctionTransformer(agregar_ratio) # listo para encajar en un Pipeline
# pipe = Pipeline([
# ("ratio", creador_ratio), # 1º crea la feature de dominio
# ("preproceso", preprocesador), # 2º imputa/escala/codifica
# ("modelo", LogisticRegression(max_iter=1000)), # 3º predice
# ])
Al meter también las transformaciones deterministas dentro del pipeline, el artefacto joblib se vuelve completamente autocontenido: producción le pasa las columnas crudas de la base de datos y el pipeline construye hasta las features de dominio. Menos código duplicado entre entrenamiento y producción, menos oportunidades de skew.
Consejo profesional
Para lógica con estado (por ejemplo, un capping de outliers que aprende los percentiles 1 y 99 del train), escribe una clase que herede de BaseEstimator y TransformerMixin con métodos fit y transform. Es el patrón estándar para transformadores a medida y se integra con GridSearchCV como cualquier otro paso.
Ejercicio rápido — orden de los pasos: ¿Es correcto este pipeline para KNN: Pipeline([("escalador", StandardScaler()), ("imputador", SimpleImputer(strategy="mean")), ("knn", KNeighborsClassifier())])? Razona el orden.
Ver solución
Funciona sin error (StandardScaler tolera NaN propagándolos y SimpleImputer los rellena después), pero el orden es discutible y frágil: lo canónico es **imputar primero y escalar después**. Razones: (1) muchos transformadores fallan o se comportan mal con NaN, así que eliminarlos cuanto antes hace el pipeline robusto a cambios de pasos; (2) si imputas con la media *después* de estandarizar, insertas ceros (la media estandarizada), lo cual es equivalente en este caso concreto, pero en cuanto cambies a `strategy="median"` o añadas `add_indicator=True` el orden empieza a importar de verdad. Orden recomendado: `SimpleImputer → StandardScaler → KNeighborsClassifier`. Ejercicio rápido 3: Un compañero escribe: X_scaled = StandardScaler().fit_transform(X); scores = cross_val_score(LogisticRegression(), X_scaled, y, cv=5). ¿Qué está mal y cómo lo corriges en dos líneas?
Ver solución
Está escalando con TODO el dataset antes de la validación cruzada: en cada fold, el conjunto de validación ya participó en el cálculo de μ y σ (contaminación train-test, Historia 2). La corrección es meter el escalador en el pipeline para que se re-ajuste dentro de cada fold solo con la parte de entrenamiento:5. Validación cruzada a fondo¶
5.1 Por qué un solo split engaña¶
Con train_test_split evalúas el modelo sobre una muestra concreta de test. Si el dataset es pequeño o mediano, la métrica depende fuertemente de qué filas cayeron en el test: cambia la semilla y el accuracy baila 3, 5, hasta 10 puntos. Reportar un único número de un único split es reportar ruido con cara de precisión.
La validación cruzada (CV) resuelve esto evaluando el modelo k veces sobre k particiones distintas y reportando media y desviación: obtienes una estimación del rendimiento y de su incertidumbre.
5.2 K-Fold explicado¶
Con k=5, el dataset se parte en 5 trozos ("folds"). Se entrena 5 veces, cada vez dejando un fold distinto fuera como validación:
K-FOLD CON k=5 (■ = validación, · = entrenamiento)
Dataset dividido en 5 folds
┌──────┬──────┬──────┬──────┬──────┐
Iter 1 │ ■■■■ │ ···· │ ···· │ ···· │ ···· │ → métrica_1
├──────┼──────┼──────┼──────┼──────┤
Iter 2 │ ···· │ ■■■■ │ ···· │ ···· │ ···· │ → métrica_2
├──────┼──────┼──────┼──────┼──────┤
Iter 3 │ ···· │ ···· │ ■■■■ │ ···· │ ···· │ → métrica_3
├──────┼──────┼──────┼──────┼──────┤
Iter 4 │ ···· │ ···· │ ···· │ ■■■■ │ ···· │ → métrica_4
├──────┼──────┼──────┼──────┼──────┤
Iter 5 │ ···· │ ···· │ ···· │ ···· │ ■■■■ │ → métrica_5
└──────┴──────┴──────┴──────┴──────┘
Resultado final = media(métrica_1..5) ± desviación(métrica_1..5)
Cada fila del dataset se usa para validar EXACTAMENTE una vez.
5.3 StratifiedKFold, cross_val_score y cross_validate¶
from sklearn.model_selection import StratifiedKFold, cross_val_score, cross_validate
# StratifiedKFold: cada fold conserva la proporción de clases del dataset.
# Imprescindible en clasificación, crítico con clases desbalanceadas
# (con 5 % de positivos, un fold aleatorio podría quedarse casi sin positivos).
cv = StratifiedKFold(
n_splits=5, # k = 5 folds
shuffle=True, # baraja antes de partir (recomendado salvo datos temporales)
random_state=42, # reproducibilidad del barajado
)
# cross_val_score: la vía rápida — una métrica, k números
# scores = cross_val_score(
# pipe_completo, # ← pasamos el PIPELINE entero: preprocesado incluido en cada fold
# X, y, # datos CRUDOS, sin preprocesar (el pipeline lo hace por fold)
# cv=cv, # la estrategia de partición
# scoring="roc_auc", # la métrica (ver sklearn.metrics.get_scorer_names())
# n_jobs=-1, # todos los núcleos de CPU en paralelo
# )
# print(f"AUC: {scores.mean():.3f} ± {scores.std():.3f}")
# cross_validate: la vía completa — varias métricas + tiempos + scores de train
# resultados = cross_validate(
# pipe_completo, X, y, cv=cv,
# scoring=["roc_auc", "accuracy", "f1"], # varias métricas a la vez
# return_train_score=True, # scores en train: para diagnosticar overfitting
# )
# Si train_roc_auc ≈ 1.0 y test_roc_auc ≈ 0.75 → el modelo memoriza: regulariza o simplifica.
Cómo leer los resultados como un profesional:
# Supongamos estos 5 scores de validación de una CV:
scores = np.array([0.87, 0.84, 0.90, 0.85, 0.88])
print(f"Media: {scores.mean():.3f}") # 0.868 → la estimación puntual del rendimiento
print(f"Desviación: {scores.std():.3f}") # 0.021 → la incertidumbre de esa estimación
print(f"Rango: [{scores.min():.3f}, {scores.max():.3f}]") # el peor y el mejor fold
# Lecturas prácticas:
# - Desviación pequeña (≈0.01-0.03): el modelo es estable; la media es fiable.
# - Desviación grande (>0.05): el rendimiento depende mucho de qué datos toquen;
# sospecha dataset pequeño, outliers influyentes o grupos/tiempo mal manejados.
# - Comparando dos modelos: si sus intervalos media±std se solapan ampliamente,
# la diferencia NO es concluyente. No declares ganador por 0.003 de AUC.
Nota
Con datasets pequeños, una sola CV de 5 folds sigue teniendo varianza. RepeatedStratifiedKFold(n_splits=5, n_repeats=10) repite la CV con 10 barajados distintos (50 evaluaciones) y estabiliza mucho la estimación a cambio de 10× el coste — barato si el modelo entrena en segundos.
5.4 TimeSeriesSplit: el tiempo no se baraja¶
Advertencia
Con datos temporales (ventas diarias, precios, logs de sensores, churn mensual), el k-fold aleatorio es leakage temporal puro: entrenas con datos de marzo para "predecir" febrero. El modelo aprende del futuro y su métrica es ciencia ficción. Regla absoluta: jamás k-fold aleatorio con series temporales.
TIMESERIESSPLIT (■ = validación, · = entrenamiento, tiempo →)
pasado ──────────────────────────────► futuro
┌──────┬──────┬──────┬──────┬──────┐
Iter 1 │ ···· │ ■■■■ │ │ │ │ entrena en t1, valida en t2
├──────┼──────┼──────┼──────┼──────┤
Iter 2 │ ···· │ ···· │ ■■■■ │ │ │ entrena en t1-t2, valida en t3
├──────┼──────┼──────┼──────┼──────┤
Iter 3 │ ···· │ ···· │ ···· │ ■■■■ │ │ entrena en t1-t3, valida en t4
├──────┼──────┼──────┼──────┼──────┤
Iter 4 │ ···· │ ···· │ ···· │ ···· │ ■■■■ │ entrena en t1-t4, valida en t5
└──────┴──────┴──────┴──────┴──────┘
SIEMPRE se entrena en el pasado y se valida en el futuro.
El conjunto de entrenamiento crece en cada iteración (ventana expansiva).
from sklearn.model_selection import TimeSeriesSplit
cv_temporal = TimeSeriesSplit(
n_splits=4, # 4 iteraciones como en el diagrama
# gap=7, # opcional: deja 7 muestras de hueco entre train y validación
) # (útil si las features usan ventanas móviles que "rozan" el futuro)
# Requisito: los datos deben estar ORDENADOS cronológicamente antes de usarlo.
5.5 GroupKFold: cuando las filas no son independientes¶
Si tienes varias filas por entidad (varias compras por cliente, varias visitas por paciente, varios frames por vídeo), un k-fold normal puede poner filas del mismo cliente en train y en validación. El modelo "reconoce" al cliente en lugar de generalizar a clientes nuevos: métricas infladas, otra forma de fuga.
from sklearn.model_selection import GroupKFold
cv_grupos = GroupKFold(n_splits=5) # k folds SIN romper grupos
# scores = cross_val_score(
# pipe_completo, X, y,
# cv=cv_grupos,
# groups=df["cliente_id"], # ← todas las filas de un cliente caen en el MISMO fold
# )
# La pregunta que responde: "¿cómo rendirá el modelo con CLIENTES que nunca ha visto?"
5.6 ¿Qué CV usar? Tabla de decisión¶
| Situación | Estrategia de CV | Por qué |
|---|---|---|
| Clasificación estándar, filas independientes | StratifiedKFold(shuffle=True) |
Mantiene proporción de clases en cada fold |
| Regresión estándar, filas independientes | KFold(shuffle=True) |
No hay clases que estratificar |
| Datos temporales (cualquier serie) | TimeSeriesSplit |
Jamás validar el pasado con el futuro |
| Varias filas por entidad (cliente, paciente...) | GroupKFold / StratifiedGroupKFold |
Evita reconocer entidades entre train y validación |
| Dataset muy pequeño (< 200 filas) | RepeatedStratifiedKFold o LOO |
Reduce la varianza de la estimación repitiendo |
| Clases muy desbalanceadas | StratifiedKFold + métrica adecuada (AUC-PR) |
Garantiza positivos en cada fold |
| Temporal y con grupos | Split temporal por bloques + grupos manuales | Ninguna clase de sklearn lo da hecho; hay que pensarlo |
Ejercicio rápido 4: Tienes 10 000 registros de consumo eléctrico horario de 50 edificios durante un año, y quieres predecir el consumo del próximo mes. Un compañero propone StratifiedKFold(shuffle=True). Da DOS razones por las que es una mala idea y propón la alternativa.
Ver solución
Razón 1 — **leakage temporal**: con barajado aleatorio entrenarías con horas de noviembre para validar horas de marzo; en producción solo tendrás el pasado, así que la métrica estaría inflada. Razón 2 — **grupos**: hay 50 edificios con miles de filas cada uno; el split aleatorio pone horas del mismo edificio en train y validación y el modelo "memoriza" el perfil de cada edificio en lugar de generalizar. (Bonus: StratifiedKFold ni siquiera aplica — es regresión, no hay clases.) Alternativa: `TimeSeriesSplit` sobre los datos ordenados cronológicamente (validar siempre en meses posteriores al train). Si además quisieras estimar el rendimiento en *edificios nuevos*, combinarías el corte temporal con separación de edificios entre train y validación.6. Búsqueda de hiperparámetros¶
Los hiperparámetros (la C de la logística, el max_depth del árbol, el número de vecinos de KNN) no se aprenden con fit: se eligen probando. La forma de probar sin engañarse es todo un protocolo.
6.1 GridSearchCV: exhaustivo y caro¶
GridSearchCV prueba todas las combinaciones de una rejilla, cada una evaluada con validación cruzada:
from sklearn.model_selection import GridSearchCV
# Rejilla: 3 valores de C × 2 penalizaciones = 6 combinaciones × 5 folds = 30 entrenamientos
param_grid = {
"modelo__C": [0.01, 0.1, 1.0], # ← 'modelo__C': paso 'modelo' del pipeline, parámetro 'C'
"modelo__penalty": ["l1", "l2"], # la doble barra baja __ navega dentro del pipeline
}
# grid = GridSearchCV(
# estimator=pipe_completo, # se busca sobre el PIPELINE entero
# param_grid=param_grid, # la rejilla
# cv=5, # validación cruzada interna para puntuar cada combinación
# scoring="roc_auc", # métrica a optimizar
# n_jobs=-1, # paraleliza
# refit=True, # al acabar, re-entrena la mejor combinación con TODO el train
# )
# grid.fit(X_train, y_train) # SOLO con el train. El test ni se toca.
# print(grid.best_params_) # la mejor combinación
# print(grid.best_score_) # su AUC medio en CV
# grid.best_estimator_ # el pipeline ya re-entrenado, listo para usar
El coste crece multiplicativamente: 4 hiperparámetros con 5 valores cada uno = 625 combinaciones × 5 folds = 3 125 entrenamientos. La rejilla exhaustiva escala fatal.
6.2 RandomizedSearchCV: por qué el azar suele ganar¶
RandomizedSearchCV muestrea N combinaciones al azar de distribuciones que tú defines. Bergstra y Bengio demostraron en 2012 (Random Search for Hyper-Parameter Optimization) algo contraintuitivo: con el mismo presupuesto, la búsqueda aleatoria suele encontrar mejores configuraciones que la rejilla.
La intuición: normalmente solo 1 o 2 hiperparámetros importan de verdad. Una rejilla de 5×5 solo prueba 5 valores distintos del parámetro importante (cada uno repetido 5 veces con distintos valores del parámetro irrelevante). Una búsqueda aleatoria de 25 puntos prueba 25 valores distintos del parámetro importante. Mismo coste, cinco veces más exploración de lo que importa.
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import loguniform, randint
param_dist = {
"modelo__C": loguniform(1e-3, 1e2), # distribución log-uniforme: explora órdenes de magnitud
"modelo__penalty": ["l1", "l2"], # las listas se muestrean uniformemente
}
# random_search = RandomizedSearchCV(
# pipe_completo,
# param_distributions=param_dist,
# n_iter=30, # presupuesto: 30 combinaciones muestreadas (tú decides el coste)
# cv=5, scoring="roc_auc", n_jobs=-1, random_state=42,
# )
Nota
HalvingGridSearchCV y HalvingRandomSearchCV (búsqueda por successive halving) empiezan evaluando muchas combinaciones con pocos recursos (pocas muestras) y van eliminando las peores, dedicando el presupuesto a las prometedoras. Para búsquedas grandes son mucho más eficientes. Y el estándar moderno para búsqueda seria es Optuna: optimización bayesiana que aprende de los resultados anteriores para decidir qué probar después, con pruning de ensayos malos a mitad de entrenamiento. Cuando pases de "ajustar una logística" a "ajustar un LightGBM con 10 hiperparámetros", Optuna es la herramienta.
| Método | Estrategia | Coste | Úsalo cuando |
|---|---|---|---|
GridSearchCV |
Exhaustivo sobre rejilla | Explota combinatoriamente | ≤ 2-3 hiperparámetros con pocos valores |
RandomizedSearchCV |
Muestreo aleatorio, presupuesto fijo | Tú lo fijas (n_iter) |
Caso general; espacios medianos/grandes |
HalvingGridSearchCV |
Eliminación progresiva | Mucho menor que grid | Rejillas grandes, datos abundantes |
| Optuna | Bayesiana + pruning | El más eficiente por ensayo | Modelos caros, muchos hiperparámetros |
6.3 El protocolo completo: la anidación correcta¶
El error conceptual más común: elegir hiperparámetros mirando el test y luego reportar la métrica en ese mismo test. Al hacerlo, el test ha influido en el modelo — ya no es una estimación honesta. El protocolo correcto:
PROTOCOLO COMPLETO DE EVALUACIÓN HONESTA
┌─────────────────────────────── DATASET COMPLETO ───────────────────────────────┐
│ │
│ ┌───────────────────── TRAIN (80 %) ─────────────────────┐ ┌─ TEST (20 %) ─┐ │
│ │ │ │ │ │
│ │ GridSearch/RandomSearch CON validación cruzada: │ │ CAJA │ │
│ │ ┌──────┬──────┬──────┬──────┬──────┐ │ │ FUERTE │ │
│ │ │ ■■■■ │ ···· │ ···· │ ···· │ ···· │ cada combinación │ │ │ │
│ │ │ ···· │ ■■■■ │ ···· │ ···· │ ···· │ de hiperparámetros│ │ No se abre │ │
│ │ │ ···· │ ···· │ ■■■■ │ ···· │ ···· │ se puntúa con CV │ │ hasta el │ │
│ │ │ ···· │ ···· │ ···· │ ■■■■ │ ···· │ DENTRO del train │ │ final. │ │
│ │ │ ···· │ ···· │ ···· │ ···· │ ■■■■ │ │ │ │ │
│ │ └──────┴──────┴──────┴──────┴──────┘ │ │ Se usa UNA │ │
│ │ │ │ │ sola vez. │ │
│ │ ▼ │ │ │ │
│ │ mejores hiperparámetros → re-entrenar con TODO train │ │ │ │
│ └──────────────────────────────────────────┬───────────────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ modelo final ──── evaluar UNA vez ── métrica │
│ HONESTA │
└─────────────────────────────────────────────────────────────────────────────────┘
flowchart TD
A["Dataset completo"] --> B["train_test_split<br/>(estratificado)"]
B --> C["TRAIN 80 %"]
B --> D["TEST 20 % <br/>intocable"]
C --> E["Búsqueda de hiperparámetros<br/>con CV interna (k=5)<br/>sobre el pipeline COMPLETO"]
E --> F["Mejor configuración<br/>re-entrenada con todo TRAIN<br/>(refit=True lo hace solo)"]
F --> G["Evaluación final en TEST<br/>UNA sola vez"]
G --> H["Métrica honesta<br/>→ la que reportas<br/>→ la que esperas en producción"]
F --> I["joblib.dump →<br/>artefacto de producción"]
style D fill:#ffe0e0,stroke:#c62828
style H fill:#e8f4e8,stroke:#2e7d32
Consejo profesional
Si iteras muchas veces "pruebo algo → miro el test → pruebo otra cosa", estás haciendo overfitting manual al test aunque nunca entrenes con él. El test se gasta con cada mirada. Disciplina: todo el desarrollo con CV sobre train; el test, una vez, al final, para el informe.
6.4 El superpoder: buscar hiperparámetros del preprocesado Y del modelo a la vez¶
Como el pipeline expone todos sus parámetros internos con la sintaxis paso__subpaso__parametro, la búsqueda puede optimizar decisiones de preprocesamiento junto a las del modelo. ¿Imputar con media o mediana? ¿Escalar estándar o robusto? Que lo decidan los datos:
from sklearn.model_selection import GridSearchCV
from sklearn.preprocessing import StandardScaler, RobustScaler
param_grid_total = {
# --- hiperparámetros del PREPROCESADO ---
"preproceso__numericas__imputador__strategy": ["mean", "median"], # ¿qué imputación va mejor?
"preproceso__numericas__escalador": [StandardScaler(), RobustScaler()], # ¡se puede cambiar el objeto entero!
# --- hiperparámetros del MODELO ---
"modelo__C": [0.01, 0.1, 1.0, 10.0], # regularización de la logística
}
# Lee la clave larga de izquierda a derecha:
# preproceso → paso 'preproceso' del pipeline (el ColumnTransformer)
# __numericas → transformador 'numericas' dentro del ColumnTransformer
# __imputador → paso 'imputador' de ese mini-pipeline
# __strategy → parámetro 'strategy' del SimpleImputer
#
# grid_total = GridSearchCV(pipe_completo, param_grid_total, cv=5, scoring="roc_auc", n_jobs=-1)
# grid_total.fit(X_train, y_train)
# → 2 × 2 × 4 = 16 combinaciones que cubren preprocesado + modelo, todas evaluadas
# con CV sin ninguna fuga. Esto, sin pipelines, sería una pesadilla de bucles anidados y bugs.
Esto es imposible de hacer limpiamente sin pipelines. Es el argumento definitivo para usarlos siempre.
Ejercicio rápido 5: Tu compañera ejecuta un GridSearchCV(cv=5) sobre el train, obtiene best_score_ = 0.91, evalúa en test y obtiene 0.89. Decide entonces ampliar la rejilla, relanzar la búsqueda, volver a mirar el test (0.90), ampliar otra vez, mirar otra vez el test (0.905)... y reporta 0.905 como métrica final. ¿Qué ha hecho mal exactamente y qué número debería reportar?
Ver solución
Ha usado el test como conjunto de validación: cada vez que amplía la rejilla *porque el test no le convence* está tomando decisiones de modelado guiadas por el test. Tras varias iteraciones, la configuración final ha sido seleccionada (indirectamente) por su rendimiento en test, así que 0.905 ya no es una estimación imparcial — es un máximo sobre varias miradas, sesgado al alza. Lo correcto: todas las iteraciones de rejilla se comparan por su `best_score_` de CV (0.91 vs lo que salga), se elige la mejor por CV, y el test se consulta **una sola vez al final**. El número honesto era el 0.89 de la primera y única mirada legítima; para "recuperar" un test limpio ahora necesitaría datos frescos que nunca hayan sido consultados.7. Ejemplo integrador: solicitudes de préstamo de principio a fin¶
Todo el capítulo en un solo caso realista: predecir el impago (default) de solicitudes de préstamo con numéricas, categóricas, fechas y nulos. Código completo y ejecutable, línea por línea.
7.1 Generar el dataset sintético¶
import numpy as np
import pandas as pd
rng = np.random.default_rng(7) # semilla fija: resultados reproducibles
n = 600 # ~600 solicitudes
# --- Variables base ---
ingresos = rng.lognormal(mean=10.2, sigma=0.5, size=n).round(0) # ingresos anuales, cola larga
deuda = (ingresos * rng.uniform(0.05, 0.9, size=n)).round(0) # deuda correlacionada con ingresos
edad = rng.integers(21, 70, size=n) # edad del solicitante
antiguedad_empleo = np.clip(rng.exponential(6, size=n), 0, 40).round(1) # años en el empleo actual
ciudad = rng.choice(["Madrid", "Bogotá", "CDMX", "Lima", "Buenos Aires"],
size=n, p=[0.3, 0.2, 0.25, 0.15, 0.1]) # categórica nominal
educacion = rng.choice(["secundaria", "grado", "master", "doctorado"],
size=n, p=[0.35, 0.4, 0.2, 0.05]) # categórica ORDINAL
# Fecha de solicitud a lo largo de 2 años
fecha_solicitud = pd.to_datetime("2024-01-01") + pd.to_timedelta(
rng.integers(0, 730, size=n), unit="D" # día aleatorio en 730 días
)
# --- Target con lógica de negocio real: el ratio deuda/ingresos manda ---
ratio = deuda / ingresos # la señal principal
logit = (
3.0 * ratio # más apalancamiento → más riesgo
- 0.04 * antiguedad_empleo # estabilidad laboral protege
- 0.015 * (edad - 21) # la edad reduce riesgo (suavemente)
+ rng.normal(0, 0.6, size=n) # ruido: el mundo no es determinista
- 0.8 # intercepto: calibra la tasa base
)
default = (1 / (1 + np.exp(-logit)) > 0.5).astype(int) # sigmoide + umbral → 0/1
df = pd.DataFrame({
"ingresos": ingresos, "deuda": deuda, "edad": edad,
"antiguedad_empleo": antiguedad_empleo, "ciudad": ciudad,
"educacion": educacion, "fecha_solicitud": fecha_solicitud,
"default": default,
})
# --- Inyectar valores faltantes realistas (no aleatorios del todo) ---
mask_ingresos = rng.random(n) < 0.12 # 12 % no declara ingresos
df.loc[mask_ingresos, "ingresos"] = np.nan
mask_antig = rng.random(n) < 0.08 # 8 % sin antigüedad registrada
df.loc[mask_antig, "antiguedad_empleo"] = np.nan
mask_edu = rng.random(n) < 0.05 # 5 % sin nivel educativo
df.loc[mask_edu, "educacion"] = np.nan
print(df.head())
print(f"\nTasa de impago: {df['default'].mean():.1%}") # ~30-40 %: desbalance moderado
print(df.isna().sum()) # recuento de nulos por columna
7.2 Feature engineering de fechas (fuera del pipeline, porque es determinista)¶
# Las transformaciones de fecha NO aprenden nada de los datos (no tienen 'fit'):
# son funciones deterministas fila a fila, así que pueden aplicarse antes del split sin riesgo de fuga.
df["mes"] = df["fecha_solicitud"].dt.month # estacionalidad
df["dia_semana"] = df["fecha_solicitud"].dt.dayofweek # patrón semanal
df["mes_sin"] = np.sin(2 * np.pi * (df["mes"] - 1) / 12) # codificación cíclica...
df["mes_cos"] = np.cos(2 * np.pi * (df["mes"] - 1) / 12) # ...del mes (círculo anual)
# El ratio deuda/ingresos: también determinista fila a fila → seguro pre-split.
# OJO: usamos las columnas con NaN; donde falte el ingreso, el ratio será NaN y el
# imputador del pipeline se encargará (y el indicador de missing conservará la señal).
df["ratio_deuda_ingresos"] = df["deuda"] / df["ingresos"]
df = df.drop(columns=["fecha_solicitud", "mes"]) # ya extraído lo útil; fuera la fecha cruda
Nota
La regla para decidir qué va dentro y qué fuera del pipeline: si la transformación aprende parámetros de los datos (medias, categorías, medianas...), va DENTRO. Si es una función determinista fila a fila (extraer el mes, calcular un ratio), puede ir fuera — aunque meterla dentro con FunctionTransformer también es válido y hace el artefacto más autocontenido.
7.3 Split honesto¶
from sklearn.model_selection import train_test_split
X = df.drop(columns=["default"]) # todas las features
y = df["default"] # el target
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2, # 20 % a la caja fuerte
stratify=y, # conserva la tasa de impago en ambos lados
random_state=42, # reproducible
)
print(f"Train: {X_train.shape[0]} filas | Test: {X_test.shape[0]} filas ( no se toca hasta el final)")
7.4 El pipeline completo con ColumnTransformer¶
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder, OrdinalEncoder
# --- Clasificación de columnas por tratamiento ---
cols_num = ["ingresos", "deuda", "edad", "antiguedad_empleo",
"ratio_deuda_ingresos", "mes_sin", "mes_cos", "dia_semana"]
cols_cat_nominal = ["ciudad"] # sin orden → one-hot
cols_cat_ordinal = ["educacion"] # con orden → ordinal explícito
# --- Rama numérica: imputar (guardando el indicador) + escalar ---
pipe_num = Pipeline([
("imputador", SimpleImputer(strategy="median", add_indicator=True)), # mediana train + flags de NaN
("escalador", StandardScaler()), # μ y σ del train
])
# --- Rama nominal: imputar moda + one-hot robusto ---
pipe_nom = Pipeline([
("imputador", SimpleImputer(strategy="constant", fill_value="desconocida")), # NaN → categoría propia
("onehot", OneHotEncoder(handle_unknown="ignore")), # nuevas ciudades no rompen nada
])
# --- Rama ordinal: imputar + codificar respetando el ORDEN real ---
pipe_ord = Pipeline([
("imputador", SimpleImputer(strategy="most_frequent")), # NaN → nivel más común
("ordinal", OrdinalEncoder(
categories=[["secundaria", "grado", "master", "doctorado"]], # orden explícito de menor a mayor
handle_unknown="use_encoded_value", unknown_value=-1, # robustez ante valores raros
)),
])
# --- Unión de las tres ramas ---
preprocesador = ColumnTransformer([
("num", pipe_num, cols_num), # rama numérica sobre sus 8 columnas
("nom", pipe_nom, cols_cat_nominal),# rama nominal sobre 'ciudad'
("ord", pipe_ord, cols_cat_ordinal),# rama ordinal sobre 'educacion'
], remainder="drop") # cualquier columna inesperada: fuera
7.5 Comparar 3 modelos con validación cruzada¶
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
modelos = {
"logistica": LogisticRegression(max_iter=2000), # línea base lineal
"random_forest": RandomForestClassifier(n_estimators=300, random_state=42), # no lineal robusto
"knn": KNeighborsClassifier(n_neighbors=15), # basado en distancias
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 5 folds estratificados
for nombre, modelo in modelos.items():
pipe = Pipeline([("preproceso", preprocesador), ("modelo", modelo)]) # pipeline por candidato
scores = cross_val_score(pipe, X_train, y_train, # ¡SOLO train!
cv=cv, scoring="roc_auc", n_jobs=-1) # AUC en cada fold
print(f"{nombre:15s} AUC = {scores.mean():.3f} ± {scores.std():.3f}") # media ± desviación
# Salida típica (variará ligeramente):
# logistica AUC = 0.878 ± 0.021
# random_forest AUC = 0.866 ± 0.028
# knn AUC = 0.842 ± 0.033
# La logística compite de tú a tú: el target ES logístico en el ratio → las features buenas
# hacen brillar al modelo simple. Better features beat fancier algorithms, en directo.
7.6 Búsqueda de hiperparámetros sobre el pipeline ganador¶
from sklearn.model_selection import GridSearchCV
pipe_final = Pipeline([
("preproceso", preprocesador),
("modelo", LogisticRegression(max_iter=2000, solver="liblinear")), # liblinear soporta l1 y l2
])
param_grid = {
"preproceso__num__imputador__strategy": ["mean", "median"], # decisión de preprocesado, a examen
"modelo__C": [0.01, 0.1, 1.0, 10.0], # fuerza de regularización (inversa)
"modelo__penalty": ["l1", "l2"], # tipo de penalización
}
# Total: 2 × 4 × 2 = 16 combinaciones × 5 folds = 80 entrenamientos. Asumible.
grid = GridSearchCV(
pipe_final, param_grid,
cv=cv, # la misma CV estratificada
scoring="roc_auc", # optimizamos AUC
n_jobs=-1, # en paralelo
refit=True, # re-entrena la mejor config con todo el train al terminar
)
grid.fit(X_train, y_train) # búsqueda completa DENTRO del train
print("Mejores parámetros:", grid.best_params_)
print(f"Mejor AUC (CV): {grid.best_score_:.3f}")
7.7 Evaluación final honesta y serialización¶
from sklearn.metrics import roc_auc_score, classification_report
# El momento de la verdad: abrimos la caja fuerte UNA vez.
mejor_pipeline = grid.best_estimator_ # pipeline completo ya re-entrenado
proba_test = mejor_pipeline.predict_proba(X_test)[:, 1] # probabilidad de impago por solicitud
pred_test = mejor_pipeline.predict(X_test) # clase predicha (umbral 0.5)
print(f"AUC en test: {roc_auc_score(y_test, proba_test):.3f}") # ¿coincide con el AUC de CV?
print(classification_report(y_test, pred_test, digits=3)) # precision, recall, f1 por clase
# Chequeo de cordura profesional: AUC(test) ≈ AUC(CV) ± su desviación → todo coherente.
# Si el test saliera MUY por encima de la CV → sospecha duplicados/leakage.
# Si saliera muy por debajo → posible cambio de distribución o mala suerte del split (investiga).
# --- El artefacto que va a producción ---
import joblib
joblib.dump(mejor_pipeline, "modelo_prestamos_v1.joblib") # TODO en un fichero
metadatos = {
"fecha_entrenamiento": "2026-07-03",
"auc_cv": round(grid.best_score_, 4),
"auc_test": round(roc_auc_score(y_test, proba_test), 4),
"mejores_params": grid.best_params_,
"n_train": len(X_train),
}
print(metadatos) # en un proyecto real: guárdalo como JSON junto al .joblib (o usa MLflow)
Fíjate en la coherencia del protocolo completo: fechas y ratios (deterministas) antes del split; split estratificado con test bajo llave; todo lo que aprende de los datos dentro del pipeline; comparación de modelos con CV solo en train; búsqueda de hiperparámetros (incluidos los del preprocesado) con CV solo en train; una única mirada al test; el pipeline entero serializado. Ese es el flujo profesional. Memorízalo: es el mismo esqueleto para el 90 % de los problemas tabulares que verás.
8. Caso empresarial: el modelo del 95 % que rendía 70 %¶
Caso empresarial
FinanCredit (nombre ficticio, patrón 100 % real) es una fintech de préstamos al consumo. Su equipo de datos —dos data scientists recién contratados, con prisa por demostrar valor— construyó un modelo de scoring en notebooks. En la demo interna: 95 % de accuracy y AUC 0.97. Dirección aprobó el despliegue inmediato; el modelo pasó a decidir aprobaciones automáticas de hasta 5 000 €.
Semana 6 tras el despliegue. El equipo de riesgos da la voz de alarma: la tasa de impago de los préstamos aprobados por el modelo duplica la histórica. Se audita el sistema y se recalcula el rendimiento real con las solicitudes de producción etiquetadas: el modelo acierta poco más del 70 %, apenas mejor que las reglas manuales que sustituyó. Pérdida directa estimada del trimestre: 340 000 € en impagos extra. Se convoca un post-mortem.
El post-mortem encontró cuatro fugas apiladas:
- Target leakage de manual. La feature estrella (importancia #1 con diferencia) era
num_llamadas_cobranza: el número de llamadas del departamento de cobros al cliente. Las llamadas de cobranza ocurren después del impago — la feature era el target con disfraz. En producción, una solicitud nueva tiene siemprenum_llamadas_cobranza = 0, así que la columna que sostenía el modelo estaba muerta. Nadie había preguntado "¿este dato existe en el momento de decidir?". - Normalización pre-split. El notebook hacía
scaler.fit_transform(X)sobre el dataset completo y después eltrain_test_split. Contaminación clásica (Historia 2), pequeña pero real. - Duplicados entre train y test. Los clientes que re-solicitaban tenían varias filas casi idénticas; el split aleatorio repartió copias entre train y test. Parte del "acierto" era memoria, no predicción.
- Evaluación temporal invertida. El split aleatorio mezclaba solicitudes de 2024 y 2025: el modelo se evaluó "prediciendo" el pasado con información del futuro, justo cuando el perfil de cliente estaba cambiando por una campaña de marketing.
El re-análisis con protocolo limpio — deduplicación por cliente, split temporal (entrenar con 2024, evaluar con 2025), eliminación de toda feature posterior a la decisión y preprocesamiento dentro de un pipeline — arrojó un AUC honesto de 0.79. Ese era el modelo real que siempre tuvieron. La cifra de 0.97 fue una alucinación metodológica.
Cómo el pipeline lo habría evitado (y qué no puede evitar): las fugas 2 y 3 (parcialmente) desaparecen mecánicamente con Pipeline + CV: el escalador se ajusta por fold, y una CV con GroupKFold por cliente habría delatado los duplicados con una caída visible de la métrica. La fuga 4 se evita con la disciplina de la tabla de la sección 5.6: datos con tiempo → TimeSeriesSplit. La fuga 1 — la más letal — no la arregla ninguna clase de sklearn: la arregla la pregunta de oficio ("¿existe este valor en el momento de predecir?") y el checklist anti-leakage de la sección 3.4. Herramientas + disciplina; ninguna de las dos basta sola.
Epílogo. FinanCredit instauró tres reglas que hoy son estándar en la industria: (1) ningún modelo llega a producción sin pipeline serializado de extremo a extremo; (2) toda métrica reportada debe indicar su protocolo de validación (qué CV, qué split temporal); (3) toda feature nueva pasa una revisión de disponibilidad temporal firmada por alguien de negocio. El data scientist que lideró el post-mortem es hoy el head of ML. El oficio se aprende — a veces por las malas.
9. Buenas prácticas¶
- Divide primero, transforma después. El
train_test_splites la primera línea de código de modelado, antes de cualquierfitde cualquier transformador. - Todo lo que aprende de los datos, dentro del pipeline. Imputadores, escaladores, encoders, selección de features. Sin excepciones.
handle_unknown="ignore"en OneHotEncoder, siempre. Producción traerá categorías que no viste.- Empieza con una línea base simple (logística o árbol pequeño con features crudas) y mide cada mejora contra ella. Sin línea base no sabes si tu ingeniería aporta.
- Invierte en features de dominio (ratios, diferencias, agregados por entidad) antes que en modelos exóticos.
- Reporta media ± desviación de la CV, no un número suelto de un split.
- Elige la CV según la estructura de los datos (tabla 5.6): temporal →
TimeSeriesSplit; grupos →GroupKFold; clasificación → estratificada. - Guarda el pipeline completo con joblib más un fichero de metadatos (fecha, métricas, versiones, commit).
- Desconfía de las métricas espectaculares. Audita la importancia de features de todo modelo que rinda "demasiado bien".
- Fija semillas (
random_state) en todo: splits, CV, modelos. La reproducibilidad no es opcional en un equipo.
10. Malas prácticas¶
fit_transformsobre el dataset completo y luego dividir. La madre de todas las fugas silenciosas.- Target encoding "a mano" con groupby + mean sin out-of-fold ni suavizado.
- K-fold aleatorio con datos temporales. Estás validando con la bola de cristal.
- Usar IDs, timestamps de sistema o columnas administrativas como features "porque el modelo ya sabrá ignorarlas". No sabrá.
- Mirar el test repetidamente durante el desarrollo para decidir el siguiente paso. Cada mirada lo gasta.
- Reimplementar el preprocesamiento en el código de producción en lugar de desplegar el pipeline. Training-serving skew garantizado.
- Escalar para un Random Forest y otras transformaciones-cargo-cult que no aplican al modelo elegido.
- OrdinalEncoder con orden alfabético implícito en categorías ordinales reales.
- Eliminar todas las filas con NaN sin analizar si el missing es informativo (a menudo destruyes justo la señal).
- Optimizar 6 hiperparámetros con GridSearchCV exhaustivo cuando RandomizedSearch u Optuna darían más con menos.
11. Errores comunes¶
| Error | Síntoma | Causa | Solución |
|---|---|---|---|
| Escalar antes del split | CV ligeramente optimista; test < CV | fit con datos de test incluidos |
Escalador dentro del Pipeline |
| Target encoding ingenuo | AUC de validación altísimo, producción mediocre | Cada fila codificada con su propio target | TargetEncoder(cv=...) con suavizado |
| Categoría nueva rompe producción | Excepción Found unknown categories a las 3 AM |
handle_unknown="error" (defecto antiguo) |
handle_unknown="ignore" |
| K-fold en series temporales | Métrica offline buena, online mala | Entrenar con el futuro | TimeSeriesSplit |
| Filas del mismo cliente en train y test | Test inflado; clientes nuevos van mal | Split aleatorio ignora los grupos | GroupKFold(groups=cliente_id) |
| Imputar con estadísticas globales | Fuga sutil, difícil de notar | df.fillna(df.mean()) pre-split |
SimpleImputer dentro del pipeline |
| Hiperparámetros elegidos mirando el test | Test ya no es honesto; producción decepciona | Búsqueda fuera del protocolo anidado | GridSearch con CV en train; test una sola vez |
np.log con ceros |
-inf y NaN en cascada, modelo roto |
log(0) no existe | np.log1p |
| Coeficientes ininterpretables post-pipeline | "¿Qué columna es la 37?" | No mapear nombres tras transformar | get_feature_names_out() |
| Cargar joblib con otra versión de sklearn | Warnings o comportamientos distintos | Entornos sin fijar | Fijar versiones; guardar metadatos del entorno |
12. FAQ — Preguntas frecuentes¶
1. ¿Escalo el target también en regresión?
Escalarlo no es necesario para la mayoría de modelos, pero transformarlo sí puede ayudar: si el target tiene cola larga (precios, ingresos), entrenar sobre log1p(y) suele mejorar los modelos lineales. Recuerda invertir con expm1 antes de calcular errores en unidades reales, o usa TransformedTargetRegressor de sklearn, que automatiza la ida y la vuelta.
2. ¿El one-hot encoding no crea multicolinealidad perfecta (la trampa de las dummies)?
Sí: las k columnas suman siempre 1. Para modelos con regularización (lo habitual en sklearn) es irrelevante en la práctica. Si haces inferencia estadística estricta (statsmodels, p-valores), usa drop="first" en OneHotEncoder.
3. ¿Cuántos folds uso: 5 o 10?
5 es el estándar de facto: buen equilibrio sesgo-varianza-coste. 10 da estimaciones algo más estables a doble coste; con datasets grandes, la diferencia rara vez justifica el tiempo. Con datasets diminutos, considera RepeatedStratifiedKFold.
4. ¿Puedo usar la media de la CV como la métrica final que reporto? Como estimación de desarrollo, sí. Pero la cifra "de contrato" debe salir del test intocado: la CV se usó para tomar decisiones (elegir modelo, hiperparámetros) y por tanto está ligeramente sesgada al alza. Para máxima pureza estadística existe la nested CV, pero en la práctica industrial el esquema "CV en train + test final" es el estándar.
5. ¿Qué hago con una categórica de cardinalidad media (30-100 valores)?
Zona gris. Opciones por orden de preferencia: agrupar categorías raras en "otras" (OneHotEncoder(min_frequency=...) lo hace solo), target encoding con CV, o frequency encoding para árboles. Prueba con CV cuál gana en tus datos.
6. ¿Los pipelines funcionan con XGBoost y LightGBM?
Sí: ambos ofrecen wrappers compatibles con sklearn (XGBClassifier, LGBMClassifier) que encajan como último paso del pipeline y funcionan con GridSearchCV, cross_val_score, etc. Lo verás en el capítulo 6.
7. ¿fit_transform y fit + transform son lo mismo?
En resultado, sí (fit_transform puede estar optimizado internamente). Lo importante es sobre qué datos llamas cada cosa: fit_transform sobre train, y solo transform sobre test/producción. Si escribes fit_transform(X_test), acabas de re-aprender parámetros del test: fuga.
8. ¿Por qué mi AUC de test salió mejor que el de CV? ¿Es leakage seguro? No necesariamente: con tests pequeños hay varianza de muestreo y puede salir por encima por azar (compara con la desviación de la CV: si está dentro de ±1σ, normal). Sospecha si la diferencia es grande y sistemática: entonces busca duplicados entre train y test o alguna fuga.
9. ¿Cuándo doy el salto de GridSearch/RandomSearch a Optuna? Cuando el espacio supere ~4-5 hiperparámetros, cuando cada entrenamiento sea caro (boosting con datasets grandes, redes), o cuando quieras pruning de ensayos malos. Para una logística con 2 hiperparámetros, GridSearch es perfecto; para un LightGBM con 8, Optuna amortiza en la primera hora.
10. ¿El feature engineering sigue importando con deep learning y LLMs? En datos tabulares — la mayoría del ML empresarial — sí, rotundamente: gradient boosting + buenas features sigue ganando benchmarks tabulares en 2026. En imágenes, audio y texto, las redes aprenden las representaciones (por eso el módulo 06 va de embeddings), pero incluso ahí el "feature engineering" se transforma (prompts, agregados de metadatos) en lugar de desaparecer.
13. Resumen del capítulo¶
- El rendimiento vive en los datos y las features, no en el algoritmo. El orden de esfuerzo profesional: entender datos → features → validación → modelo → hiperparámetros.
- Feature engineering es traducir conocimiento de dominio a columnas: escalado según el modelo (tabla 2.1.1), log para colas largas, ratios que un experto compararía, encoders según cardinalidad y orden (tabla 2.2.3), codificación cíclica para tiempo, indicadores de missing porque la ausencia informa.
- Data leakage es el error más caro del oficio: target leakage, contaminación train-test y fuga temporal. Se detecta por resultados "demasiado buenos" y se previene con el checklist 3.4 y con pipelines.
Pipeline+ColumnTransformerencapsulan preprocesamiento y modelo en un objeto: anti-leakage estructural en CV, reproducibilidad y un único artefacto desplegable con joblib.- Validación cruzada: k-fold estratificado como defecto,
TimeSeriesSplitcon tiempo,GroupKFoldcon entidades repetidas (tabla 5.6). Reporta media ± desviación. - Búsqueda de hiperparámetros: grid para espacios pequeños, random para medianos (Bergstra), Optuna para serios. Protocolo sagrado: búsqueda con CV dentro de train, test intocado hasta el final, una sola mirada.
- El superpoder de los pipelines: optimizar hiperparámetros del preprocesado y del modelo a la vez con la sintaxis
paso__subpaso__parametro. - El flujo del ejemplo integrador (sección 7) es la plantilla del 90 % de tus futuros proyectos tabulares.
En el capítulo 6 subimos la potencia de fuego del último paso del pipeline: ensembles, bagging, Random Forest a fondo y gradient boosting (XGBoost/LightGBM) — que encajarán, cómo no, como un paso más de los pipelines que ya dominas.
14. Bibliografía y recursos¶
- Documentación oficial de scikit-learn — guías de usuario imprescindibles:
- Pipelines y estimadores compuestos: https://scikit-learn.org/stable/modules/compose.html
- Preprocesamiento: https://scikit-learn.org/stable/modules/preprocessing.html
- Validación cruzada: https://scikit-learn.org/stable/modules/cross_validation.html
- Búsqueda de hiperparámetros: https://scikit-learn.org/stable/modules/grid_search.html
- Fugas de datos (guía oficial): https://scikit-learn.org/stable/common_pitfalls.html#data-leakage
- Bergstra, J. & Bengio, Y. (2012). Random Search for Hyper-Parameter Optimization. JMLR 13. https://www.jmlr.org/papers/v13/bergstra12a.html
- Kaufman, S. et al. (2012). Leakage in Data Mining: Formulation, Detection, and Avoidance. ACM TKDD. https://dl.acm.org/doi/10.1145/2382577.2382579
- Zheng, A. & Casari, A. (2018). Feature Engineering for Machine Learning. O'Reilly. https://www.oreilly.com/library/view/feature-engineering-for/9781491953235/
- Kuhn, M. & Johnson, K. (2019). Feature Engineering and Selection: A Practical Approach for Predictive Models. http://www.feat.engineering/
- Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras & TensorFlow (3.ª ed.). O'Reilly. https://www.oreilly.com/library/view/hands-on-machine-learning/9781098125967/
- Optuna — framework de optimización de hiperparámetros: https://optuna.org/
- Librería
holidayspara festivos por país: https://pypi.org/project/holidays/ - Akande, O. — Target Encoding done the right way (discusión de leakage en target encoding): https://maxhalford.github.io/blog/target-encoding/
| Anterior | Módulo | Siguiente |
|---|---|---|
| Capítulo 4: Aprendizaje no supervisado | 02-MACHINE-LEARNING | Capítulo 6: Ensembles y boosting |