El Pipeline Que Todos Escribimos

¡Hola Devs! Si llevas un rato usando scikit-learn, seguramente ya escribiste este pipeline: las columnas numéricas se imputan y se escalan, el resto se imputa y se hace one-hot encoding, y las columnas se enrutan por dtype. Funciona en casi cualquier tabla sin que la mires.

Es corto, reutilizable y fácil de confiar. Ese es el problema.

Quise averiguar qué es lo que este pipeline hace mal en silencio, así que intenté romperlo a propósito. El resultado: un modelo con 0.979 en validación cruzada 5-fold y 0.564 en clientes nuevos. Nada crasheó. No salió ningún warning. Todos los números parecían de un modelo fuerte.

Esta es la historia de cuatro trampas que un pipeline basado en dtype no ve — y de la automatización auto-verificable que construí para atraparlas. Para una discusión más amplia sobre por qué los guardrails de runtime no alcanzan para sistemas de IA, checa nuestro análisis sobre la capa de confianza que le falta a los agentes de IA.

Data scientist reviewing cross-validation vs holdout AUC gap on a dashboard, illustrating silent ML pipeline leakage Coding Session Visual

Cuatro Trampas, Una Falla Silenciosa

Generé datos sintéticos de churn (8,000 filas por dataset, 5 seeds) y planté cuatro trampas:

  1. Región como número. region_code guarda enteros de 1 a 12, pero cada región afecta el churn de forma distinta y fuera de orden. El pipeline la escala como si fuera una medida.
  2. Valores faltantes informativos. support_calls falta en ~45% de los churners y 8% de los no-churners. La imputación por mediana borra esa señal.
  3. Una fecha guardada como texto. signup_date es un string YYYY-MM-DD, así que se convierte en una columna one-hot por fecha única (1,931 valores únicos en 6,000 filas).
  4. Un ID que filtra el label. En entrenamiento, customer_id se asignó más o menos en orden del resultado. En el holdout limpio, los IDs no tienen relación con el churn.

El Score Que Mintió

VarianteAUC CVAUC Holdout
Pipeline automático ingenuo0.9790.564
+ región como categoría0.9820.624
+ indicador de faltante0.9840.670
+ parse de la fecha0.9800.585
+ quitar columna ID0.6400.632
Las cuatro correcciones0.8330.826

Dentro de la regresión logística de la seed 0, la feature más fuerte entre 1,940 era customer_id (|coeficiente| 5.05). Las siguientes eran columnas one-hot de fechas específicas. El modelo leyó el label del ID y memorizó ruido por fecha.

Enseñándole al Pipeline a Auto-Chequearse

Si construyes automatización para gente que no programa, "acuérdate de revisar fugas" no es un plan. Cada trampa de arriba deja una huella estadística. Aquí va un scanner que corre antes del modelado:

import pandas as pd
import numpy as np

def scan_for_traps(df, target_col, id_like=('_id', 'id_'), date_like=('date', '_at', 'time')):
    """Imprime advertencias en lenguaje simple para trampas comunes de fuga."""
    warnings = []
    y = df[target_col]

    for col in df.columns:
        if col == target_col:
            continue
        s = df[col]

        # Trampa 4: columna tipo ID correlacionada con el target
        if any(tok in col.lower() for tok in id_like) and s.dtype.kind in 'iu':
            corr = np.corrcoef(s.fillna(-1), y)[0, 1]
            if abs(corr) > 0.3:
                warnings.append(f"'{col}' parece un ID pero correlaciona con el target (r={corr:.2f}). Posible fuga.")

        # Trampa 3: columna de texto con demasiados valores únicos (probablemente fecha)
        if s.dtype == object and s.nunique() > 0.1 * len(s):
            warnings.append(f"'{col}' es texto con {s.nunique()} valores únicos. Hazle parse antes del one-hot.")

        # Trampa 2: ausencia informativa
        if s.isna().any():
            miss_rate = y[s.isna()].mean()
            present_rate = y[s.notna()].mean()
            if abs(miss_rate - present_rate) > 0.1:
                warnings.append(f"La ausencia en '{col}' es informativa (tasa del target {miss_rate:.2f} vs {present_rate:.2f}). Agrega un indicador de faltante.")

        # Trampa 1: columna numérica con baja cardinalidad (probablemente categoría)
        if s.dtype.kind in 'iu' and s.nunique() < 20 and col.lower().endswith(('code', 'id', 'zip')):
            warnings.append(f"'{col}' es numérica pero parece categórica ({s.nunique()} valores). Trátala como categoría.")

    for w in warnings:
        print('⚠️', w)
    return warnings

def gap_warning(cv_auc, holdout_auc, threshold=0.1):
    """Marca gaps sospechosos entre CV y holdout."""
    gap = cv_auc - holdout_auc
    if gap > threshold:
        print(f"⚠️ La validación cruzada ({cv_auc:.2f}) está muy por encima del holdout ({holdout_auc:.2f}). Sospecha fuga o memorización.")
    return gap

En la seed 0, esto marcó exactamente las cuatro columnas plantadas (customer_id, region_code, support_calls, signup_date) y dejó tranquilas a tenure_months, monthly_spend y plan_type.

Python code snippet on laptop showing scikit-learn ColumnTransformer with dtype-based routing and missing indicator logic System Abstract Visual

Dónde Se Queda Corto Este Enfoque

  • Una sola fuente de datos sintéticos. Todos los resultados vienen de un generador que yo escribí. La dirección de los efectos debería mantenerse, pero los tamaños van a diferir en datos reales. Fix: corre las mismas variantes en una tabla real que conozcas bien.
  • La fuga del ID está plantada y exagerada. Las fugas reales suelen ser más débiles. Fix: revisa cada columna tipo ID o timestamp por su relación con el target antes de modelar.
  • Solo dos familias de modelos. Otros modelos pueden reaccionar distinto. Fix: repite con tu propio modelo, usando un holdout construido como van a llegar tus datos futuros.
  • Dispersión en algunos resultados. Los números de gradient boosting para indicador de faltante y región son ruidosos. Fix: usa más seeds o CV repetida antes de sacar conclusiones sobre un cambio aislado.
  • El checker se probó en los mismos datos sintéticos. Los datos reales van a disparar advertencias que necesitan decisión humana. Fix: trata las advertencias como invitaciones a mirar, no como correcciones automáticas.

Cinco Preguntas Antes de Confiar en la Automatización

  1. ¿Hay un holdout que se parezca a los datos futuros? La validación cruzada sobre el train no atrapa una fuga que ya existe en el train.
  2. ¿En qué columna se apoya más el modelo? Si un ID, número de fila o timestamp está arriba, para.
  3. ¿Una columna numérica es realmente categoría? Códigos, CPs e IDs de región son números que no son cantidades.
  4. ¿Los valores faltantes son aleatorios? Si la tasa del target difiere donde falta la columna, agrega un indicador.
  5. ¿Hay columnas de texto con miles de valores únicos? Fechas, nombres y texto libre necesitan su propio manejo, no one-hot.

Server room representing production holdout data that reveals model failure after passing cross-validation Programming Illustration

Automatización No Es Verificación

Un pipeline de 8 líneas es un buen punto de partida y un pésimo punto de parada. En este experimento produjo un 0.98 que cayó a 0.56 en datos nuevos, y hicieron falta cuatro correcciones separadas — no una — para llegar a un 0.83 honesto.

La automatización ahorró tiempo, pero no hizo la verificación, así que le agregué la verificación a la automatización. Si construyes herramientas para gente que no puede detectar estos problemas por sí sola, esa es la parte que hay que construir.

Siguientes pasos: envuelve el scanner de arriba en un paso de preprocesamiento de tu Pipeline, loguea las advertencias en tu experiment tracker y bloquea la promoción de modelos con base en un check de gap CV/holdout. Para un patrón relacionado en sistemas de producción, checa cómo CSS @function trae funciones custom nativas sin Sass — un recordatorio de que la conveniencia sin verificación es deuda técnica.

Fuente: Towards Data Science — My Machine Learning Pipeline Cheated, and Cross-Validation Never Noticed

Este contenido fue redactado con la asistencia de herramientas de IA, basándose en fuentes confiables, y fue revisado por nuestro equipo editorial antes de su publicación. No reemplaza el asesoramiento de un profesional especializado.