Lesson 7 of 56· Auditar los datos antes de transformarlos
Un equipo de riesgo de AndinaCredit entrena un modelo para predecir mora a 90 días. En validación obtiene un ROC-AUC de 0.91. Lo despliegan. A las tres semanas, el AUC real sobre solicitudes nuevas es 0.74.
No cambiaron de algoritmo. No cambiaron de features. El culpable fueron dos líneas de código en el orden equivocado:
# El error más caro de todo el curso
X = StandardScaler().fit_transform(X) # 1) escala usando TODO el dataset
X_train, X_test, y_train, y_test = train_test_split(X, y) # 2) y DESPUÉS parteEso se llama fuga de datos (data leakage): información que en el momento de la predicción real no existiría se coló en el entrenamiento o en la evaluación. El síntoma clásico es exactamente el de arriba — una métrica de validación bonita que no sobrevive al contacto con datos nuevos.
Al terminar esta lección vas a poder:
.fit() y por qué esos parámetros solo pueden venir del conjunto de entrenamiento;train_test_split, GroupKFold, TimeSeriesSplit) según la estructura de tus datos;Esta es la lección más importante del curso. Todo lo que viene después (escalar, codificar, imputar, derivar) es inútil si lo haces del lado equivocado de esta frontera.
.fit()La clave está en entender que un transformador de scikit-learn no es una función pura: tiene memoria. fit() mira los datos y guarda parámetros; transform() aplica esos parámetros guardados sin volver a mirar nada.
| Transformador | Qué guarda en .fit() | Atributo aprendido |
|---|---|---|
StandardScaler | media y desviación estándar de cada columna | mean_, scale_ |
MinMaxScaler | mínimo y máximo de cada columna | data_min_, data_max_ |
RobustScaler | mediana y rango intercuartílico | center_, scale_ |
SimpleImputer | la media / mediana / moda de cada columna | statistics_ |
OneHotEncoder | el conjunto de categorías vistas | categories_ |
TargetEncoder | la media del objetivo por categoría | encodings_ |
PCA | las direcciones principales de los datos | components_ |
SelectKBest | qué columnas son las mejores | scores_ |
Cada uno de esos parámetros es conocimiento sobre la distribución de los datos. Si lo calculas usando también las filas de test, tu conjunto de test dejó de ser desconocido: participó, aunque sea un poquito, en la construcción del modelo. Y la métrica que midas sobre él ya no estima lo que pasará con solicitudes futuras.
Dicho en una frase que conviene memorizar:
fitsolo sobre entrenamiento.transformsobre todo lo demás.
La estandarización deja esto explícito en la fórmula: lo que aplicas a cualquier fila —train, test o una solicitud que llega en producción a las 3 a.m.— es
y son constantes del modelo, igual que los coeficientes de una regresión. En producción no tienes el test set ni el futuro: solo tienes esas constantes, congeladas.
A la izquierda, el flujo que produce métricas mentirosas. A la derecha, el único orden correcto.
Fíjate en el nodo de abajo a la derecha: los parámetros aprendidos son parte del artefacto que despliegas. Esa es la prueba definitiva de si un cálculo es legítimo. Pregúntate:
Cuando llegue una sola solicitud nueva, ¿podría calcular esta transformación con la información que tengo en ese instante?
Si la respuesta es no —porque necesitarías la media de todo el dataset, o la etiqueta, o filas del futuro— es fuga.
Nada de esto es abstracto. Tomemos una sola columna: ingreso_mensual (en millones de COP) de ocho solicitudes de entrenamiento y dos de prueba.
import numpy as np
from sklearn.preprocessing import StandardScaler
X_train = np.array([[2.0], [4.0], [4.0], [4.0], [5.0], [5.0], [7.0], [9.0]])
X_test = np.array([[3.0], [11.0]])
# --- FLUJO CORRECTO: fit solo con train -------------------------------
sc = StandardScaler().fit(X_train)
print(sc.mean_, sc.scale_) # [5.] [2.]
print(sc.transform(X_test).ravel()) # [-1. 3.]
# --- FLUJO CON FUGA: fit con train + test -----------------------------
sc_fuga = StandardScaler().fit(np.vstack([X_train, X_test]))
print(sc_fuga.mean_, sc_fuga.scale_) # [5.4] [2.65329983]
print(sc_fuga.transform(X_test).ravel()) # [-0.90453403 2.11057941]Los parámetros aprendidos cambian: la media pasa de 5.00 a 5.40 y la desviación de 2.00 a 2.65, porque la solicitud de 11 millones —que el modelo no debería conocer— arrastró las estadísticas hacia arriba.
El efecto sobre la fila de test es directo: el solicitante de ingreso alto pasa de ser un caso a 3.0 desviaciones de la media a parecer uno de solo 2.11. El flujo con fuga lo hace parecer menos excepcional de lo que es, usando información que salió de él mismo.
Con una sola columna y dos filas de test el daño es pequeño y hasta parece inofensivo. El problema es que la fuga escala con la ambición del preprocesamiento. Un StandardScaler filtra dos números por columna; un TargetEncoder filtra la media del objetivo por categoría; un SelectKBest filtra qué columnas se parecen a la etiqueta en todo el dataset. Cuanto más aprende el transformador, más grande es el agujero.
El caso extremo es clásico —está descrito en The Elements of Statistical Learning (sección 7.10.2)— y puedes reproducirlo en un notebook:
import numpy as np
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_score
rng = np.random.default_rng(0)
X = rng.normal(size=(200, 3000)) # 3000 features de RUIDO PURO
y = rng.integers(0, 2, size=200) # etiqueta totalmente aleatoria
# MAL: selecciono las 20 "mejores" mirando todo el dataset, y luego valido
X_sel = SelectKBest(f_classif, k=20).fit_transform(X, y)
print(cross_val_score(LogisticRegression(max_iter=1000), X_sel, y, cv=5).mean())
# -> claramente por encima de 0.5
# BIEN: la selección vive DENTRO del pipeline y se reajusta en cada fold
pipe = make_pipeline(SelectKBest(f_classif, k=20), LogisticRegression(max_iter=1000))
print(cross_val_score(pipe, X, y, cv=5).mean())
# -> alrededor de 0.5, que es la verdad: no hay señal que aprenderNo hay ninguna señal en esos datos: X e y son independientes por construcción, así que el acierto real solo puede ser 50 %. Aun así, el primer bloque reporta una precisión muy superior, porque la selección ya había "visto" qué columnas se parecían a y en las filas que luego usó para validar. La métrica no mide el modelo: mide la fuga.
Antes de seguir con los tipos de fuga, fija el reflejo mecánico: calcular los parámetros con train y aplicarlos a test. En la celda siguiente lo haces a mano con NumPy, para que veas que no hay magia en StandardScaler — solo dos números guardados.
Completa los dos TODO para reproducir a mano lo que hace StandardScaler: aprende la media y la desviación estándar únicamente con X_train, y luego estandariza X_test con esos mismos parámetros. Usa la desviación estándar poblacional (el ddof=0 por defecto de NumPy, que es también el que usa scikit-learn). La salida debe ser exactamente dos líneas con el formato ya escrito.
import numpy as np
# Ingreso mensual en millones de COP
X_train = np.array([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])
X_test = np.array([3.0, 11.0])
# TODO 1: calcula la media y la desviación estándar SOLO con X_train
mu = None
sd = None
# TODO 2: estandariza X_test usando ESOS parámetros (no los de X_test)
X_test_esc = None
print(f"media_train={mu:.2f} desv_train={sd:.2f}")
print(f"test_escalado={np.round(X_test_esc, 4).tolist()}")La fuga de preprocesamiento es la más común, pero la más difícil de detectar es la temporal: entrenar con información posterior al momento en que harías la predicción.
En nuestro dataset de AndinaCredit cada fila tiene fecha_solicitud, y el objetivo mora_90d se conoce 90 días después de desembolsar. Un train_test_split(shuffle=True) mezcla solicitudes de septiembre en el entrenamiento y de febrero en el test: el modelo aprende del futuro para "predecir" el pasado. En producción eso jamás ocurre — solo tienes historia.
Las formas típicas de fuga temporal:
La regla: toda feature de una fila debe poder calcularse con datos estrictamente anteriores a la marca de tiempo de esa fila.
En código, la partición temporal es tan simple como esto (y TimeSeriesSplit hace lo mismo con varios cortes sucesivos para validación cruzada):
corte = "2024-07-01"
train = solicitudes[solicitudes["fecha_solicitud"] < corte]
test = solicitudes[solicitudes["fecha_solicitud"] >= corte]Además de honesto, este esquema te regala información gratis: si tu métrica se cae al validar sobre el periodo siguiente, acabas de descubrir que tus datos tienen drift — y es mucho mejor enterarte ahora que después de desplegar.
El tercer tipo aparece cuando las filas no son independientes. En AndinaCredit un mismo cliente_id puede tener varias solicitudes, y el dataset complementario de pagos tiene decenas de filas por cliente.
Si la solicitud de febrero de CLI-0001 queda en train y la de agosto en test, el modelo no está generalizando a clientes nuevos: está recordando a ese cliente. Peor aún cuando construyes agregaciones —"promedio histórico de dias_atraso del cliente"— usando todas sus filas: ese promedio mezcla el comportamiento observado en las filas de test y lo inyecta en las de train. Es una fuga doble, por agregación y por grupo.
Dos reglas para agregaciones seguras:
| Situación | Qué usar | Por qué |
|---|---|---|
| Filas independientes, clasificación balanceada o no | train_test_split(..., stratify=y) / StratifiedKFold | Mantiene la proporción de la clase minoritaria en cada partición |
| Varias filas por entidad (cliente, dispositivo, paciente) | GroupKFold, GroupShuffleSplit | Ninguna entidad aparece en train y test a la vez |
| Ambas cosas: grupos + clases desbalanceadas | StratifiedGroupKFold | Respeta grupos y proporción de clases |
| Datos con orden temporal | TimeSeriesSplit o corte por fecha | El entrenamiento siempre precede a la validación |
from sklearn.model_selection import GroupKFold, cross_val_score
cv = GroupKFold(n_splits=5)
scores = cross_val_score(modelo, X, y, cv=cv,
groups=solicitudes["cliente_id"], # <- la clave
scoring="roc_auc")Un síntoma que delata la fuga por grupo: tu KFold normal da un AUC notablemente mejor que el mismo modelo con GroupKFold. Esa diferencia es, casi siempre, memoria de clientes repetidos.
| Fuga | Cómo se ve en el código | Señal de alarma |
|---|---|---|
| De preprocesamiento | scaler.fit_transform(X) antes del split | Métrica de test sospechosamente parecida a la de train |
| De objetivo (target leakage) | Una feature que solo existe después de conocer la etiqueta: dias_mora_actual, motivo_castigo, fecha_cancelacion | Una sola feature domina la importancia con AUC > 0.98 |
| Temporal | train_test_split(shuffle=True) sobre datos con fecha | El modelo se degrada rápido al pasar de mes |
| Por grupo | Filas del mismo cliente en train y test | KFold mucho mejor que GroupKFold |
| Por agregación | groupby("cliente_id").mean() calculado sobre todo el dataset | Features "históricas" que incluyen el presente |
| Por duplicados | Filas idénticas repetidas antes de particionar | Un 1-NN acierta casi todo |
| De selección de features | SelectKBest(...).fit(X, y) fuera del pipeline | Muchas features, pocas filas, métrica excelente |
| De umbral / decisiones | Elegir el umbral de decisión o el k de KNN mirando el test | La métrica final coincide justo con el mejor valor probado |
Dos casos que suelen colarse aun sabiendo la teoría:
ingreso_mensual por percentil 99 calculado sobre todo el dataset usa el test para decidir qué es normal. Calcula el percentil en train y aplica el mismo corte a test.Puedes evitar la fuga a mano, con disciplina. Pero la disciplina falla, sobre todo cuando el preprocesamiento crece a diez pasos y validación cruzada con cinco folds. La solución de ingeniería es encapsular todo el preprocesamiento en un Pipeline, que es exactamente lo que construiremos en detalle más adelante en el curso.
Un Pipeline es un estimador: tiene su propio fit y su propio transform/predict. Cuando lo pasas a cross_val_score o a GridSearchCV, scikit-learn ejecuta el fit de cada paso solo con el fold de entrenamiento, y aplica transform al fold de validación. La fuga se vuelve estructuralmente imposible.
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
num = ["edad", "ingreso_mensual", "monto_solicitado", "tasa_ofrecida"]
cat = ["tipo_empleo", "canal", "nivel_educativo"]
pre = ColumnTransformer([
("num", Pipeline([("imp", SimpleImputer(strategy="median")),
("esc", StandardScaler())]), num),
("cat", Pipeline([("imp", SimpleImputer(strategy="most_frequent")),
("oh", OneHotEncoder(handle_unknown="ignore"))]), cat),
])
modelo = Pipeline([("pre", pre), ("clf", LogisticRegression(max_iter=1000))])
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42)
# El fit de medias, medianas y categorías ocurre SOLO aquí dentro:
modelo.fit(X_train, y_train)
# Y en cada fold, por separado, aquí:
print(cross_val_score(modelo, X_train, y_train, cv=5, scoring="roc_auc").mean())Fíjate en dos detalles que ya son buenas prácticas de producción:
handle_unknown="ignore" en el OneHotEncoder: si en test aparece una ciudad que no estaba en train, el pipeline no explota. Ese es el comportamiento honesto — en producción habrá categorías nuevas.modelo entrenado contiene medianas, medias y categorías. Guardarlo con joblib guarda también esos parámetros: es el mismo artefacto que servirá predicciones. No hay forma de que la transformación de producción se desincronice de la de entrenamiento.fit aprende parámetros; esos parámetros solo pueden nacer del conjunto de entrenamiento.GroupKFold, tiempo → corte por fecha.Pipeline convierte la regla en una garantía del código, no en una nota mental.Un compañero escribió esta imputación de edad para el modelo de AndinaCredit:
todo_junto = np.concatenate([edad_train, edad_test])
mediana = np.nanmedian(todo_junto) # <- aquí se fugó el test
test_imputado = np.where(np.isnan(edad_test), mediana, edad_test)Calculó la mediana con train + test, así que el valor con el que rellena los faltantes lleva dentro información de las filas que luego va a usar para evaluar. Con estos datos la diferencia es concreta: la mediana honesta es 35.0, la contaminada 42.5 — un solicitante con edad desconocida pasaría de parecer joven a parecer maduro solo por culpa del orden de dos líneas.
Corrígelo en la celda siguiente: aprende la mediana solo con edad_train (ignorando NaN) y úsala para imputar edad_test.
Corrige la fuga: calcula la mediana de edad usando únicamente edad_train (ignorando los NaN) y con ella imputa los valores faltantes de edad_test, dejando intactos los valores que sí existen. La salida debe ser exactamente las dos líneas del formato ya escrito.
import numpy as np
edad_train = np.array([25.0, 30.0, np.nan, 40.0, 45.0])
edad_test = np.array([np.nan, 60.0, 70.0])
# FRAGMENTO CON FUGA (no lo uses, está aquí como referencia):
# todo_junto = np.concatenate([edad_train, edad_test])
# mediana = np.nanmedian(todo_junto)
# test_imputado = np.where(np.isnan(edad_test), mediana, edad_test)
# TODO 1: aprende la mediana SOLO con edad_train, ignorando los NaN
mediana = None
# TODO 2: imputa los NaN de edad_test con esa mediana (deja intactos los demás valores)
test_imputado = None
print(f"mediana_train={mediana:.1f}")
print(f"test_imputado={np.round(test_imputado, 1).tolist()}")$39.00 USD