Capítulo 11 de 29 12 secciones 19 min

Compartir

El pipeline

ColumnTransformer, imputar, codificar, y diez puntos por encima del listón que casi no vienen del modelo.

El pipeline fue lo que a mí me quitó los errores tontos de encima. Junta la preparación y el modelo en un solo objeto, así que las mismas transformaciones se aplican igual al entrenamiento, al examen y a producción. Y lo importante no es la comodidad: es que el pipeline, y no el modelo, es lo que se guarda y lo que viaja 📦

Llevamos cuatro capítulos preparando datos y todavía no hemos entrenado un modelo de verdad. Hoy sí, y además con el número que va a valer para todo el resto del libro 🎯

Antes, una pregunta: ¿cuántas veces has tenido que rehacer un análisis porque no te acordabas del orden exacto en que tocaste los datos? El pipeline existe para que esa pregunta deje de tener sentido 📦

Pero antes hay que resolver un problema práctico: todo lo del capítulo 9 hay que hacerlo igual en los datos de entrenamiento, en los de examen y en producción. Y si lo haces a mano tres veces, en la tercera te equivocas. Siempre.

Para eso está el Pipeline.

Los cuatro pasos dentro de un pipeline, imputar, codificar, escalar y modelo, con la conclusión resaltada de que todo eso junto es lo que se guarda y lo que viaja a producción.
Lo que se guarda no es el modelo. Es todo lo que hubo que hacerle a los datos para llegar hasta él, en el mismo orden y con los mismos números aprendidos.

El punto de partida

import pandas as pd

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

def carga_limpia(url):
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y',
                                  errors='coerce')
        v[col] = f
    return v

def prepara(v):
    v = v.sort_values(['cliente_id', 'fecha']).copy()
    v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)
    v['sin_descuento'] = v['descuento'].isna().astype(int)
    v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)
    v['precio_unitario'] = v['monto'] / v['unidades']
    anterior = v.groupby('cliente_id')['fecha'].shift(1)
    v['dias_reales'] = (v['fecha'] - anterior).dt.days
    v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
    return v

datos = prepara(carga_limpia(URL))
print(datos.shape)
(3000, 20)

Los dos errores que te va a dar scikit-learn

Antes del pipeline, vamos a chocarnos con las dos paredes, porque el pipeline existe justo para no chocarse con ellas.

from sklearn.linear_model import LogisticRegression

LogisticRegression().fit(datos[['unidades', 'satisfaccion']], datos['compro'])
ValueError: Input X contains NaN.
LogisticRegression does not accept missing values encoded as NaN natively. For supervised learning, you might want to consider sklearn.ensemble.HistGradientBoostingClassifier and Regressor which accept missing values encoded as NaNs natively. Alternatively, it is possible to preprocess the data, for instance by using an imputer transformer in a pipeline or drop samples with missing values. See https://scikit-learn.org/stable/modules/impute.html You can find a list of all estimators that handle NaN values at the following page: https://scikit-learn.org/stable/modules/impute.html#estimators-that-handle-nan-values

Input X contains NaN. Pared número uno: los modelos no aceptan huecos. Hay que rellenarlos, y eso se llama imputar.

from sklearn.preprocessing import OneHotEncoder

oh = OneHotEncoder(handle_unknown='error')
oh.fit(datos[['ciudad']].head(100))
oh.transform(pd.DataFrame({'ciudad': ['huancayo']}))
ValueError: Found unknown categories ['huancayo'] in column 0 during transform

Found unknown categories ['huancayo']. Pared número dos: si en producción aparece una ciudad que no estaba al entrenar, revienta.

Ese error es bueno mientras desarrollas y malísimo a las tres de la mañana en producción. Por eso se pone handle_unknown='ignore': la categoría nueva se codifica como todo ceros y el modelo sigue funcionando 🌙

Las columnas no se tratan todas igual

Una columna numérica se rellena con la mediana y se escala. Una de texto se rellena con la más frecuente y se convierte en ceros y unos. Son dos recetas distintas para el mismo DataFrame, y eso lo resuelve ColumnTransformer.

from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
             'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion',
             'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']

preparacion = ColumnTransformer([
    ('num', Pipeline([
        ('rellenar', SimpleImputer(strategy='median')),
        ('escalar', StandardScaler()),
    ]), NUMERICAS),
    ('cat', Pipeline([
        ('rellenar', SimpleImputer(strategy='most_frequent')),
        ('codificar', OneHotEncoder(handle_unknown='ignore')),
    ]), CATEGORICAS),
])
print(len(NUMERICAS), 'numéricas y', len(CATEGORICAS), 'categóricas')
9 numéricas y 4 categóricas

Y fíjate en lo que no está en esas listas: monto_final_facturado y dias_desde_ultima. Las dos miran al futuro, y por eso las decisiones de los capítulos 2 y 9 terminan siendo dos listas de nombres 📋

El pipeline completo

from sklearn.model_selection import train_test_split

X = datos[NUMERICAS + CATEGORICAS]
y = datos['compro']

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
                                          random_state=42, stratify=y)

modelo = Pipeline([
    ('preparacion', preparacion),
    ('clasificador', LogisticRegression(max_iter=1000, random_state=42)),
])

modelo.fit(X_tr, y_tr)
print('exactitud:', round(modelo.score(X_te, y_te), 4))
exactitud: 0.6733

0,6733 🎉

Los porqués de estas líneas

Este bloque tiene cuatro decisiones y las cuatro se defienden solas en cuanto sabes qué pasa si las quitas:

  • ✂️ Se parte ANTES de preparar nada. Es el orden y no un detalle. Si preparas primero, la media y las categorías con las que preparas llevan dentro las filas del examen, y el examen deja de serlo.
  • ⚖️ stratify=y. El trozo escondido mantiene el mismo porcentaje de ventas cerradas que el original. Sin esto, el azar puede darte un examen que no se parece al problema.
  • 📦 Un solo objeto Pipeline. La preparación y el modelo viajan juntos, así que es imposible aplicar una transformación al entrenamiento y olvidarla en producción. Ese olvido es de los errores más caros y no da ningún error.
  • 🎲 random_state=42 en los dos sitios. En el reparto y en el modelo. Sin él, cada corrida devuelve un número distinto y no puedes comparar nada con nada.

Y ahora el número que le da sentido:

from sklearn.dummy import DummyClassifier

tonto = DummyClassifier(strategy='most_frequent').fit(X_tr, y_tr)
print('el listón:', round(tonto.score(X_te, y_te), 4))
el listón: 0.5773

0,5773 el listón, 0,6733 el modelo. Casi diez puntos de ganancia, contra el punto escaso del capítulo 1.

Y esos diez puntos no vinieron del modelo: la regresión logística es de lo más simple que hay. Vinieron de los capítulos 4 y 9, de limpiar y de construir columnas 🧹

Qué hace por dentro, para que no sea magia

print(modelo.named_steps['preparacion'].transform(X_tr).shape)
(2250, 28)

2.250 filas y 28 columnas, cuando le dimos 13. Las nueve numéricas siguen siendo nueve, y las cuatro de texto se convirtieron en diecinueve: seis ciudades, cuatro segmentos, cuatro canales y cinco categorías.

Eso es lo que hace OneHotEncoder: una columna de sí/no por cada valor posible. Por eso hay que tener cuidado con las columnas que tienen muchísimos valores distintos, como un cliente_id con 617: te crea 617 columnas y el modelo se pone a memorizar clientes 🫠

Por qué el pipeline y no hacerlo a mano

Voy a ser honesta con algo, porque en muchos sitios te lo cuentan exagerado.

Si rellenas los huecos con la mediana de todo el archivo antes de partir en train y test, técnicamente estás filtrando información del examen al entrenamiento. En este caso concreto el resultado sale exactamente igual, porque la mediana de 3.000 filas y la de 2.250 se parecen muchísimo.

Así que el argumento de verdad para usar pipeline no es ese susto. Son estos tres:

  • 🔁 No repetirte. Las mismas transformaciones se aplican solas a train, a test y a la fila que llegue mañana. Sin copiar y pegar, que es donde se cuelan los bugs.
  • Validación cruzada honesta. En el capítulo 16 vamos a partir los datos cinco veces, y la preparación tiene que rehacerse dentro de cada partición. Sin pipeline eso es imposible de escribir bien.
  • 📦 Un solo objeto. El modelo entrenado incluye la limpieza. Se guarda en un archivo y quien lo use no tiene que saber qué hiciste.

Ese último punto es el que más vale en la empresa: lo que se despliega no es "el modelo", es el pipeline entero. Si mandas solo el clasificador, quien lo reciba tiene que reproducir tus trece transformaciones de memoria, y no va a poder 📦

La columna que se cae sin que nadie avise

Este es el fallo del ColumnTransformer y no da error, no da aviso, y el modelo entrena tan contento 😖

Imagina que armas la preparación con prisa y en las numéricas pones solo las cinco primeras, olvidándote de las otras cuatro:

olvidadiza = ColumnTransformer([
    ('num', StandardScaler(), NUMERICAS[:5]),
    ('cat', OneHotEncoder(handle_unknown='ignore'), CATEGORICAS),
])

print('columnas que le doy   :', len(X_tr.columns))
print('columnas que declaré  :', len(NUMERICAS[:5] + CATEGORICAS))
print('columnas que salen    :', olvidadiza.fit_transform(X_tr.fillna(0)).shape[1])
columnas que le doy   : 13
columnas que declaré  : 9
columnas que salen    : 24

Le di trece columnas, declaré nueve, y salieron 24 en vez de 28 🕳️

Las cuatro que no nombré no se quedaron sin transformar: se tiraron. Ese es el valor por defecto de remainder, que es 'drop'. Lo que no aparece en ninguna lista, desaparece.

Y ahí está lo peligroso: el modelo entrena, da un número, y ese número es peor de lo que podría ser sin que nada te diga por qué. Si la columna que se cayó era la buena, acabas de tirar tu mejor señal.

conserva = ColumnTransformer([
    ('num', StandardScaler(), NUMERICAS[:5]),
    ('cat', OneHotEncoder(handle_unknown='ignore'), CATEGORICAS),
], remainder='passthrough')

print('con remainder passthrough:', conserva.fit_transform(X_tr.fillna(0)).shape[1])
con remainder passthrough: 28

28, las de siempre. Con passthrough las que no nombraste pasan tal cual, sin escalar ni nada 🚪

¿Y cuál conviene? Yo dejo el drop por defecto a propósito, pero comprobando. El drop es más seguro a largo plazo: el día que alguien añada una columna nueva al CSV, con passthrough se te cuela en el modelo sin que la mires, y con drop se queda fuera hasta que decidas.

Lo que no puede faltar es la comprobación, y cabe en una línea:

declaradas = set(NUMERICAS) | set(CATEGORICAS)
sobran = set(X_tr.columns) - declaradas

print('columnas sin declarar:', sorted(sobran) or 'ninguna')
columnas sin declarar: ninguna

Esa línea al lado del ColumnTransformer convierte un fallo silencioso en uno que se ve 👀

Cómo se llaman las columnas al salir

El pipeline te entrega una matriz de números sin nombres, y eso es un problema en cuanto quieras explicar algo. Menos mal que se pueden pedir:

nombres = modelo.named_steps['preparacion'].get_feature_names_out()

print('cuántas salen:', len(nombres))
print('las primeras :', list(nombres[:4]))
print('las últimas  :', list(nombres[-4:]))
cuántas salen: 28
las primeras : ['num__unidades', 'num__monto', 'num__descuento', 'num__satisfaccion']
las últimas  : ['cat__categoria_Bebidas', 'cat__categoria_Cuidado personal', 'cat__categoria_Limpieza', 'cat__categoria_Snacks']

Ahí se ve de dónde salen las 28 columnas 🏷️

El prefijo dice de qué bloque viene cada una: num__ de las numéricas, cat__ de las categóricas. Y fíjate en las últimas: el OneHotEncoder convirtió categoria en una columna por valor, con el valor pegado al nombre.

Trece columnas entraron y salieron 28 porque las cuatro categóricas se abrieron en una columna por categoría. Ese es todo el misterio de por qué el número cambia.

Sin esta lista, el capítulo 20 sería imposible: los coeficientes salen en el mismo orden que estos nombres, y sin ellos tendrías 28 números sin saber a qué corresponde cada uno 🔢

El pipeline es el artefacto, no el modelo

Aquí está la razón de fondo por la que todo esto va junto y no separado 📦

import joblib
import os
import tempfile

ruta = os.path.join(tempfile.mkdtemp(), 'modelo.joblib')
joblib.dump(modelo, ruta)

print('pesa', round(os.path.getsize(ruta) / 1024, 1), 'KB')

otro = joblib.load(ruta)

print('mismas predicciones:', (otro.predict(X_te) == modelo.predict(X_te)).all())
print('misma exactitud    :', round(otro.score(X_te, y_te), 4))
pesa 5.5 KB
mismas predicciones: True
misma exactitud    : 0.6733

Cinco kilobytes y medio, y al cargarlo predice exactamente igual 💾

Y lo que hay dentro de ese archivo no es solo el clasificador. Están las medianas con las que rellenar los huecos, las medias y desviaciones con las que escalar, y la lista de categorías que vio cada columna. Todo lo que aprendió durante el entrenamiento y que hace falta para tratar una fila nueva igual que a las de entrenamiento.

Si hubieras guardado solo el modelo, mañana tendrías que acordarte de con qué mediana rellenaste, en qué orden iban las categorías y si escalaste antes o después. Y no te vas a acordar 🙃

Por eso el archivo que se manda a producción es el pipeline entero. Lo que recibe es una fila igual a la del CSV original, y lo que devuelve es una predicción. Nada en medio que alguien tenga que recordar.

Ejercicios

Siete. Intenta antes de abrir 💛

1. Los nombres de las columnas que salen

Qué nombres tienen las 28 columnas que produce la preparación.

nombres = modelo.named_steps['preparacion'].get_feature_names_out()
print(len(nombres))
print(nombres[9:19])
28
['cat__ciudad_arequipa' 'cat__ciudad_chiclayo' 'cat__ciudad_cusco'
 'cat__ciudad_lima' 'cat__ciudad_piura' 'cat__ciudad_trujillo'
 'cat__segmento_Bodega' 'cat__segmento_Horeca' 'cat__segmento_Mayorista'
 'cat__segmento_Minimarket']

Ahí están las seis ciudades convertidas en columnas. Esa lista de nombres es la que vas a necesitar en el capítulo 20 para explicar el modelo: sin ella, los coeficientes son 28 números sin etiqueta 🏷️

2. Sin las columnas de texto

Cuánto se pierde si tiras las cuatro categóricas.

from sklearn.metrics import roc_auc_score

solo_num = Pipeline([
    ('rellenar', SimpleImputer(strategy='median')),
    ('escalar', StandardScaler()),
    ('clasificador', LogisticRegression(max_iter=1000, random_state=42)),
])
solo_num.fit(X_tr[NUMERICAS], y_tr)

def mide(m, X):
    return (round(m.score(X, y_te), 4),
            round(roc_auc_score(y_te, m.predict_proba(X)[:, 1]), 4))

print('solo numéricas:', mide(solo_num, X_te[NUMERICAS]))
print('con todo      :', mide(modelo, X_te))
solo numéricas: (0.66, 0.693)
con todo      : (0.6733, 0.7214)

Punto y medio de exactitud, y casi tres puntos de AUC.

Metí ahí el AUC a propósito, porque la exactitud es un instrumento grueso: solo cuenta aciertos, así que dos modelos bastante distintos pueden dar el mismo número. El AUC mira si el modelo ordena bien y es mucho más sensible. Lo vemos entero en el capítulo 14, y desde aquí lo voy a ir poniendo al lado 📐

3. Sin las banderas de hueco

Y cuánto valen las tres columnas del capítulo 9.

sin_banderas = [c for c in NUMERICAS if not c.startswith('sin_')]
prep2 = ColumnTransformer([
    ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                      ('e', StandardScaler())]), sin_banderas),
    ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                      ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
])
m2 = Pipeline([('p', prep2),
               ('c', LogisticRegression(max_iter=1000, random_state=42))]).fit(X_tr, y_tr)

print('sin banderas:', mide(m2, X_te))
print('con banderas:', mide(modelo, X_te))
sin banderas: (0.66, 0.7101)
con banderas: (0.6733, 0.7214)

Y aquí está por qué hay que mirar las dos métricas: la exactitud da exactamente 0,66 en los dos casos, el de quitar las categóricas y el de quitar las banderas, como si costaran lo mismo. El AUC dice otra cosa: 0,693 sin las categóricas y 0,7101 sin las banderas.

O sea que las columnas de texto valen casi el doble que las banderas, y con la exactitud sola no lo habrías visto 📐

Aun así, esas tres banderas siguen siendo la mejor relación esfuerzo/resultado del libro: tres líneas del capítulo 9 por un punto de AUC 🚩

4. La ciudad nueva, ahora sin reventar

Comprueba que el pipeline aguanta una ciudad que nunca vio.

fila = X_te.head(1).copy()
fila['ciudad'] = 'huancayo'
print(modelo.predict_proba(fila).round(4))
[[0.3432 0.6568]]

Contesta sin quejarse. Con handle_unknown='ignore' la ciudad desconocida se codifica como seis ceros, o sea "ninguna de las que conozco", y el modelo decide con el resto de las columnas.

Eso es exactamente lo que quieres en producción: degradar, no caerse 🌙

5. Guardar el pipeline entero

Guarda el modelo con su limpieza y vuélvelo a cargar.

import joblib
import tempfile
from pathlib import Path

ruta = Path(tempfile.mkdtemp()) / 'modelo.joblib'
joblib.dump(modelo, ruta)

recuperado = joblib.load(ruta)
print('igual:', recuperado.score(X_te, y_te) == modelo.score(X_te, y_te))
igual: True

Un archivo, un objeto, con la limpieza dentro. Eso es lo que se manda a quien lo va a poner en producción.

Y un aviso: ese archivo depende de la versión de scikit-learn con la que lo guardaste. Junto al modelo va siempre un requirements.txt con las versiones exactas, o el día de la actualización deja de cargar 📦

6. El error de olvidarse una columna

Pásale al modelo un DataFrame al que le falta una columna.

modelo.predict(X_te.drop(columns=['canal']).head(1))
ValueError: columns are missing: {'canal'}

Te dice exactamente cuál falta. Y esto es una ventaja del pipeline, no un problema: si trabajaras con arrays de numpy sueltos, la columna que falta correría todo hacia la izquierda y el modelo predeciría con los datos cambiados de sitio, sin error ninguno 😳

Trabajar con nombres de columna y no con posiciones es de las decisiones que más disgustos evitan.

7. Una fila nueva, de principio a fin

Simula una oportunidad que llega hoy y pídele al modelo su probabilidad.

nueva = pd.DataFrame([{
    'unidades': 12, 'monto': 1450.0, 'descuento': 0.18, 'satisfaccion': 4.0,
    'precio_unitario': 1450.0 / 12,
    'sin_compra_previa': 0, 'sin_descuento': 0, 'sin_satisfaccion': 0,
    'visita_numero': 3,
    'ciudad': 'lima', 'segmento': 'Mayorista', 'canal': 'WhatsApp',
    'categoria': 'Abarrotes',
}])

print('probabilidad de que cierre:', round(modelo.predict_proba(nueva)[0, 1], 4))
probabilidad de que cierre: 0.8499

0,85. Mayorista, por WhatsApp, con descuento y monto alto: todo lo que el modelo aprendió que cierra.

Fíjate en que hubo que construir precio_unitario a mano. En un sistema de verdad eso también va dentro del pipeline, y se hace con un transformador propio. Aquí lo dejo fuera para que se vea que existe ese hueco: la preparación que no está en el pipeline es preparación que alguien tiene que recordar 🔧

8. Una fila nueva, de principio a fin

Pásale al pipeline una sola fila y mira qué devuelve.

una = X_te.iloc[[0]]

print('probabilidad de compra:', round(modelo.predict_proba(una)[0][1], 4))
print('predicción            :', modelo.predict(una)[0])
probabilidad de compra: 0.6747
predicción            : 1

67% de probabilidad, y como pasa del 50% la predicción es 1 🎯

Fíjate en el doble corchete de iloc[[0]]. Con uno solo te devuelve una Series y el pipeline espera una tabla, aunque sea de una fila. Es el error más tonto de esta parte y lo cometemos todas.

Y ojo a la diferencia entre las dos líneas: predict_proba te da el número con el que puedes decidir, y predict ya decidió por ti usando el 50%. Que ese 50% casi nunca es el umbral correcto es de lo que va el capítulo 15.

9. Una ciudad que el modelo nunca vio

Cámbiale la ciudad a una que no está en los datos de entrenamiento.

inventada = X_te.iloc[[0]].copy()
inventada['ciudad'] = 'huancayo'

print('con la ciudad inventada:', round(modelo.predict_proba(inventada)[0][1], 4))
print('con la de verdad       :', round(modelo.predict_proba(X_te.iloc[[0]])[0][1], 4))
con la ciudad inventada: 0.6568
con la de verdad       : 0.6747

No revienta, y la probabilidad se mueve poco: de 0,6747 a 0,6568 ✅

Eso pasa gracias al handle_unknown='ignore' del OneHotEncoder. Ante una ciudad desconocida pone ceros en todas las columnas de ciudad, o sea trata la fila como si no supiéramos de dónde es, y sigue con las otras doce columnas.

Con handle_unknown='error', que es lo que viste al principio del capítulo, esa misma fila habría tirado el proceso entero. Y el día que abras una tienda en una ciudad nueva, va a pasar 🏙️

10. ¿Y si falta una columna?

Quítale una columna a la fila y mira el mensaje.

try:
    modelo.predict(X_te.drop(columns=['ciudad']).iloc[[0]])
except Exception as e:
    print(type(e).__name__ + ':', str(e).splitlines()[0])
ValueError: columns are missing: {'ciudad'}

Y aquí el pipeline sí para, y me parece perfecto 🛑

Una ciudad desconocida es un dato nuevo, y con eso se puede trabajar. Una columna que falta es otro formato de archivo, y ahí lo correcto es parar: si siguiera adelante rellenando lo que falta, estaría prediciendo sobre algo que no es lo que le enseñaste.

El mensaje además dice exactamente cuál falta, así que se arregla en un minuto en vez de en una tarde.

11. Lo que el pipeline se guardó para siempre

Pásale una fila con tres huecos y busca con qué los rellenó.

import numpy as np

con_hueco = X_te.iloc[[0]].copy()
con_hueco[['monto', 'satisfaccion', 'descuento']] = np.nan

print('predice igual con tres huecos:',
      round(modelo.predict_proba(con_hueco)[0][1], 4))
print('y las medianas que tiene dentro:')
print(np.round(modelo.named_steps['preparacion']
               .named_transformers_['num']
               .named_steps['rellenar'].statistics_, 2))
predice igual con tres huecos: 0.687
y las medianas que tiene dentro:
[1.2000e+01 5.2462e+02 1.2000e-01 3.0000e+00 5.1230e+01 0.0000e+00
 0.0000e+00 0.0000e+00 3.0000e+00]

Ahí están, guardadas: mediana 12 en unidades, 524,62 en monto, 0,12 en descuento, 3 en satisfacción 🗄️

Y son las medianas del conjunto de entrenamiento, no las de la fila que acabas de pasar ni las del archivo entero. Se calcularon una vez, en el fit, y viajan dentro del pipeline para siempre.

Ese detalle es la mitad del capítulo 12. Si el rellenado usara la mediana de todos los datos, incluidos los de prueba, el modelo estaría viendo información que en producción no va a tener, y el número que te da saldría mejor de lo que es.

Comprueba que lo tienes

¿Por qué el escalado tiene que ir dentro del Pipeline y no antes?

  • Porque si se calcula sobre todos los datos, el test se cuela en el entrenamiento
  • Porque queda más ordenado
  • Porque el Pipeline es más rápido
  • Porque scikit-learn lo exige

Lo razonable que sale caro

La trampa

Quieres mirar cómo quedan las columnas escaladas antes de meterlas al pipeline, que es lo razonable. Y ya que está hecho, se aprovecha.

esc = StandardScaler().fit(X)
X_esc = esc.transform(X)

X_tr, X_te, y_tr, y_te = train_test_split(
    X_esc, y, test_size=0.25)
Qué está mal

El fit está hecho sobre X entera, así que la media y la desviación con las que se escala llevan dentro las filas del examen. Cuando el modelo se examine, esas filas ya habrán participado en decidir cómo se miden 🫥

Aquí infla poco, porque escalar es una transformación suave y las columnas no son raras. Y eso es justo lo que lo hace peligroso: pasa la revisión, no cambia el número lo suficiente como para levantar sospechas, y el mismo patrón con un imputador o con una codificación por media sí que te destroza el resultado.

Por eso el escalador va dentro del pipeline y el pipeline va dentro de la validación cruzada. No por elegancia: porque así es imposible cometer este error aunque quieras.

Lo que te llevas

  • 🚫 Los modelos no comen nulos ni texto. Imputar y codificar no es opcional.
  • 🧩 ColumnTransformer aplica una receta a las numéricas y otra a las de texto, en el mismo DataFrame.
  • 🌙 handle_unknown='ignore' para que una categoría nueva no tumbe producción.
  • 📈 Primer modelo de verdad: 0,6733 contra un listón de 0,5773. Diez puntos, y casi todos vienen de limpiar y construir columnas, no del modelo.
  • 🔢 Las 13 columnas se convierten en 28 al codificar. Ojo con las categóricas de muchos valores.
  • 🔁 El pipeline no es solo por la fuga: es por no repetirte, por poder validar bien y por poder guardar un solo objeto.
  • 📦 Lo que se despliega es el pipeline entero, nunca el clasificador solo.

Y si de todo el capítulo te llevas una sola frase, que sea esta:

Lo que se guarda no es el modelo. Es todo lo que hubo que hacerle a los datos para llegar hasta él.

Si las clases y los objetos de scikit-learn te resultan raros, el libro de Python desde cero los explica antes de que hagan falta 🐍

En el capítulo 14 nos ponemos serias con el listón: por qué el modelo tonto es obligatorio, cuál elegir, y el caso donde el 95% de exactitud es una vergüenza.

Que tengas lindo día! 🌸

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?