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 🎯
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.
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))</pre> <pre class="salida">exactitud: 0.6733</pre> <p>0
- 6733 🎉
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 📦
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 19 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 🔧
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 que te llevas
- 🚫 Los modelos no comen nulos ni texto. Imputar y codificar no es opcional.
- 🧩
ColumnTransformeraplica 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.
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! 🌸