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")yPCAesperan matrices numéricas. En cuanto una columna es de tipo texto (categoría, texto libre, fecha sin parsear), el paso falla con unValueErrorexplí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_selectorselecciona 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
memorydelPipelinecachea en disco cada transformer ya ajustado; en unGridSearchCV, 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_gridusan__para bajar por la jerarquía:preprocessor__num__imputer__strategyllega hasta elSimpleImputerdentro del sub-pipeline numérico, dentro delColumnTransformer. - Fallo silencioso a vigilar:
make_column_selector(dtype_include=object)solo selecciona columnas de tipoobject. Columnas con dtypestring,categoryu otros tipos no numéricos no encajan ni en ese selector ni endtype_include=np.number, yColumnTransformerlas descarta en silencio salvo que se configureremainderexplícitamente. Revisadf.dtypesantes de confiar en la selección automática, sobre todo si el DataFrame viene de un origen que ya normaliza tipos (Parquet, un ORM,pandascondtype_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
| Columna | Cardinalidad | Transformer recomendado | Cuándo evitarlo |
|---|---|---|---|
| Numérica continua | - | StandardScaler (o RobustScaler si hay outliers fuertes) | Modelos basados en árboles: el escalado no aporta nada |
| Categórica | Baja (hasta ~15 categorías) | OneHotEncoder(handle_unknown="ignore") | Cardinalidad alta: el número de columnas crece sin control |
| Categórica | Alta (decenas o cientos de valores) | TargetEncoder | Datasets 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 ColumnTransformer | Vocabulario 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.