Ilustración técnica para: Tu Primer Pipeline con Scikit-learn: Encadenando Preprocesamiento y Modelos de ML

Pipelines de scikit-learn con columnas mixtas: por qué fallan y cómo arreglarlos


Un Pipeline que encadena StandardScaler y LogisticRegression sigue siendo el ejemplo que abre casi cualquier tutorial de scikit-learn, y sigue funcionando exactamente igual que siempre: la API no ha cambiado. Lo que cambia es que ningún dataset real tiene solo columnas numéricas limpias, así que ese pipeline de dos pasos no es el que vas a escribir tú. Es el que se rompe en cuanto aparece una columna de texto, o peor: el que sigue corriendo sin lanzar ningún error mientras filtra información del conjunto de test hacia el de entrenamiento sin que nadie lo note, hasta que el modelo rinde peor en producción de lo que prometía en validación cruzada.

Qué es (y qué no es) un Pipeline de scikit-learn

Un Pipeline de scikit-learn es una lista ordenada de pasos (nombre, objeto) en la que cada paso intermedio implementa fit y transform (un transformer: StandardScaler, SimpleImputer, OneHotEncoder...) y el último paso implementa al menos fit (un estimator: un clasificador o regresor). Al llamar .fit() sobre el pipeline completo, cada transformer se ajusta únicamente con los datos que le llegan después de pasar por los pasos anteriores, y esa misma cadena, con los parámetros ya aprendidos y no reaprendidos, se reaplica intacta en .predict() sobre datos nuevos.

Eso es lo que lo distingue de encadenar transformaciones a mano: el pipeline garantiza que el fit de cada paso solo ve el conjunto de entrenamiento de esa iteración de validación cruzada, nunca el de test. Es la forma que recomienda la propia documentación de scikit-learn para evitar data leakage, que menciona explícitamente a StandardScaler, SimpleImputer y PCA como transformers donde este riesgo aparece si se ajustan antes de dividir los datos (documentación oficial de scikit-learn sobre fugas de datos).

La señal: el pipeline del tutorial funciona, el tuyo no

El pipeline mínimo de dos pasos funciona perfecto mientras todas las columnas sean numéricas:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

pipe = Pipeline([("scaler", StandardScaler()), ("clf", LogisticRegression())])
pipe.fit(X_train, y_train)  # X_train: todo numérico, sin nulos

El primer dataset con una columna de texto lo rompe, sin ambigüedad:

import pandas as pd
from sklearn.preprocessing import StandardScaler

df = pd.DataFrame({"edad": [34, 41], "ciudad": ["Madrid", "Valencia"]})
StandardScaler().fit_transform(df)
# ValueError: could not convert string to float: 'Madrid'

Ese error, al menos, es ruidoso y se detecta en el primer fit(). El problema real es el que no lanza ninguna excepción: si en vez de meter una selección de features o un escalador dentro del pipeline lo aplicas antes, sobre todo el dataset completo, cross_val_score te devuelve un número que parece bueno y es mentira.

La causa técnica: dos fallos que se confunden con uno

Lo que rompe el pipeline del tutorial en un dataset real casi siempre es una de estas dos cosas, y conviene no tratarlas como el mismo problema:

  • Incompatibilidad de tipo de columna: StandardScaler, SimpleImputer(strategy="mean") y PCA esperan matrices numéricas. En cuanto una columna es de tipo texto (categoría, texto libre, fecha sin parsear), el paso falla con un ValueError explícito como el de arriba. Es molesto, pero se detecta solo.
  • Fuga de datos silenciosa: ocurre cuando cualquier paso que "aprende" de los datos (selección de features, escalado, oversampling, incluso descartar columnas por su correlación con el target) se calcula fuera del bucle de validación cruzada, sobre el dataset completo. No lanza ningún error. Solo infla el score de validación por encima de lo que el modelo hará con datos nuevos.

La solución al primer fallo es ColumnTransformer: aplica un sub-pipeline distinto a cada subconjunto de columnas (numéricas, categóricas, texto) y reúne las salidas en una sola matriz, dentro de un único Pipeline que sigue siendo compatible con GridSearchCV (documentación oficial de ColumnTransformer). La solución al segundo es disciplina: todo lo que "aprenda" de X o de y va dentro del pipeline, nunca antes del train_test_split ni antes de cada fold de la validación cruzada.

Cómo confirmarlo antes de que llegue a producción

La fuga de datos a veces sí se ve leyendo el código: el fit_transform(X, y) antes de entrar al bucle de validación cruzada, más abajo, ya la delata. Pero un score de validación alto por sí solo no diagnostica la causa: hace falta medir qué pasa al mover ese mismo paso dentro del pipeline. Este experimento, con un montaje deliberadamente extremo (200 filas, 10.000 variables aleatorias, selección de las 25 mejores correlaciones) y datos sintéticos donde el target es puro ruido, muestra cómo cambia el score entre seleccionar features fuera del pipeline y dentro de él:

import numpy as np
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.pipeline import Pipeline

rng = np.random.RandomState(42)
X = rng.standard_normal((200, 10_000))
y = rng.choice(2, 200)  # target puramente aleatorio: el score real debería rondar 0.5
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# Selección de features FUERA del pipeline (fuga de datos)
X_leaky = SelectKBest(f_classif, k=25).fit_transform(X, y)
print(cross_val_score(LogisticRegression(max_iter=1000), X_leaky, y, cv=cv).mean())
# 0.88 -- parece un modelo excelente. Es una ilusión: y es ruido.

# Selección DENTRO del pipeline (se recalcula en cada fold)
safe_pipe = Pipeline([("select", SelectKBest(f_classif, k=25)), ("clf", LogisticRegression(max_iter=1000))])
print(cross_val_score(safe_pipe, X, y, cv=cv).mean())
# 0.495 -- esto sí refleja la realidad: no hay señal, el target es ruido.

Los números 0,88 y 0,495 son el resultado de esta ejecución concreta (random_state=42) con un montaje deliberadamente extremo: 200 filas, 10.000 variables aleatorias y selección de las 25 mejores correlaciones. No representan la magnitud que produce la fuga de datos en cualquier caso: con menos variables, más filas o otra semilla, la caída puede ser de unas pocas décimas, no de casi la mitad. Es una demostración exagerada a propósito para que el efecto no se pueda pasar por alto. Lo generalizable es el patrón, verificado ejecutando el código anterior con scikit-learn 1.9.0, la versión estable actual, y descrito en la recomendación oficial de scikit-learn para evitar fuga de datos: si el score cae al mover un paso de fuera a dentro del Pipeline, ese paso estaba viendo datos que no debía ver.

La solución: ColumnTransformer + Pipeline con caché

Un pipeline completo para un dataset con columnas numéricas (con nulos) y categóricas, listo para GridSearchCV:

import numpy as np
import pandas as pd
from sklearn import set_config
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

set_config(transform_output="pandas")  # cada paso devuelve un DataFrame, no un ndarray

numeric_pipe = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler", StandardScaler()),
])

categorical_pipe = Pipeline([
    # sparse_output=False es obligatorio junto con transform_output="pandas":
    # un DataFrame no admite una matriz sparse por columna.
    ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
])

preprocessor = ColumnTransformer([
    ("num", numeric_pipe, make_column_selector(dtype_include=np.number)),
    ("cat", categorical_pipe, make_column_selector(dtype_include=object)),
])

full_pipeline = Pipeline(
    [("preprocessor", preprocessor), ("classifier", LogisticRegression(max_iter=1000))],
    memory="./sklearn_cache",  # cachea cada transformer ajustado; el último paso nunca se cachea
)

param_grid = {
    "classifier__C": [0.1, 1.0, 10.0],
    "preprocessor__num__imputer__strategy": ["mean", "median"],
}

# Dataset mínimo y reproducible, para que el bloque se pueda ejecutar tal cual:
# 500 filas, dos columnas numéricas (una con nulos) y una categórica.
rng = np.random.RandomState(0)
X = pd.DataFrame({
    "edad": rng.normal(40, 12, size=500),
    "ingresos": np.where(rng.random(500) < 0.05, np.nan, rng.normal(30_000, 8_000, size=500)),
    "ciudad": rng.choice(["Madrid", "Valencia", "Sevilla", "Bilbao"], size=500),
})
y = (X["edad"] + rng.normal(0, 10, size=500) > 45).astype(int)

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
search = GridSearchCV(full_pipeline, param_grid, cv=5, scoring="roc_auc")
search.fit(X_train, y_train)
print(search.best_params_, search.score(X_test, y_test))

Tres detalles que no aparecen en el ejemplo de dos pasos de cualquier tutorial y que sí importan en producción:

  • make_column_selector selecciona columnas por tipo de dato (o por patrón de nombre), así el pipeline no depende de una lista de nombres de columna hardcodeada que se rompe en cuanto cambia el esquema de entrada.
  • El parámetro memory del Pipeline cachea en disco cada transformer ya ajustado; en un GridSearchCV, si varias combinaciones de hiperparámetros comparten el mismo preprocesamiento, ese preprocesamiento se calcula una sola vez y no una vez por combinación. El último paso nunca se cachea, aunque sea un transformer (referencia de la API de Pipeline).
  • Los nombres de hiperparámetros en param_grid usan __ para bajar por la jerarquía: preprocessor__num__imputer__strategy llega hasta el SimpleImputer dentro del sub-pipeline numérico, dentro del ColumnTransformer.
  • Fallo silencioso a vigilar: make_column_selector(dtype_include=object) solo selecciona columnas de tipo object. Columnas con dtype string, category u otros tipos no numéricos no encajan ni en ese selector ni en dtype_include=np.number, y ColumnTransformer las descarta en silencio salvo que se configure remainder explícitamente. Revisa df.dtypes antes de confiar en la selección automática, sobre todo si el DataFrame viene de un origen que ya normaliza tipos (Parquet, un ORM, pandas con dtype_backend="pyarrow").

Para columnas categóricas de alta cardinalidad (códigos postales, IDs de producto, cientos de ciudades), OneHotEncoder deja de tener sentido porque hace explotar el número de columnas. Desde la versión 1.3, scikit-learn incluye TargetEncoder, que codifica cada categoría con la media del target condicionada a esa categoría, usando cross-fitting interno para no filtrar información del propio target hacia su propia codificación (documentación oficial de TargetEncoder, introducida en scikit-learn 1.3.0). Se usa igual que cualquier otro transformer, dentro de su propia rama del ColumnTransformer.

Qué transformador usar según el tipo de columna

ColumnaCardinalidadTransformer recomendadoCuándo evitarlo
Numérica continua-StandardScaler (o RobustScaler si hay outliers fuertes)Modelos basados en árboles: el escalado no aporta nada
CategóricaBaja (hasta ~15 categorías)OneHotEncoder(handle_unknown="ignore")Cardinalidad alta: el número de columnas crece sin control
CategóricaAlta (decenas o cientos de valores)TargetEncoderDatasets muy pequeños (unos pocos cientos de filas): el cross-fitting interno se vuelve ruidoso
Fecha/hora-Extraer año, mes y día de la semana antes del ColumnTransformer; tratarlas después como numéricas o categóricas-
Texto libre-TfidfVectorizer en su propia rama del ColumnTransformerVocabulario muy reducido: probablemente es más una categoría que texto

La regla que resume la tabla: si un paso aprende algo de tus datos (una media, una varianza, qué categorías existen, qué features se quedan), ese paso va dentro del Pipeline, nunca antes del train_test_split. Qué transformer concreto elegir por columna es una decisión de datos; la arquitectura que hace falta para combinarlos sin fuga la resuelve ColumnTransformer una sola vez, no columna por columna.

Serialización para producción: guardar el pipeline entrenado

Un pipeline entrenado no vive para siempre en el mismo proceso que lo ajustó: hay que persistirlo y cargarlo en el servicio que hace las predicciones. La documentación oficial de persistencia de scikit-learn recomienda joblib sobre pickle para esto, porque es más eficiente con los arrays de NumPy grandes que suele contener un pipeline ya ajustado:

import joblib

joblib.dump(search.best_estimator_, "pipeline.joblib")

# En el servicio de inferencia, en otro proceso:
loaded_pipeline = joblib.load("pipeline.joblib")
predictions = loaded_pipeline.predict(X_new)

Advertencia de seguridad antes de usar esto en producción: joblib.load (igual que pickle.load) puede ejecutar código arbitrario al deserializar. La documentación oficial de persistencia de scikit-learn lo dice sin matices: nunca se debe cargar un archivo pickle o joblib que venga de una fuente que no sea de confianza y verificada, de la misma forma que nunca se ejecutaría código de una fuente no confiable. Trata pipeline.joblib como código ejecutable, no como datos: cárgalo solo si tú mismo lo generaste o confías plenamente en quien lo generó.

El otro aviso que importa antes de depender de esto en producción: la propia documentación advierte que un pipeline serializado con una versión de scikit-learn no está garantizado que cargue bien con otra versión distinta. Fija la versión de scikit-learn en el entorno que sirve el modelo a la misma que usaste para entrenarlo, y si necesitas actualizarla, reentrena y vuelve a serializar en vez de asumir compatibilidad hacia atrás.

Compartir X LinkedIn