Ya tenemos las columnas. Falta decidir cómo entra cada una: qué se escala, con qué se rellenan los huecos y cómo se convierte el texto en números 🔧
Estas decisiones se toman a ojo casi siempre, copiando lo que había en el proyecto anterior. Aquí las vamos a medir todas, y el resultado va a ser incómodo: casi ninguna mueve la aguja.
Lo cual no significa que den igual. Significa que no se eligen por el AUC 💜
El banco de pruebas
Una función que arma el pipeline con las piezas que le pases y devuelve la validación cruzada. A partir de ahí, todo el capítulo es cambiar una pieza.
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import KNNImputer, SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import (KBinsDiscretizer, MinMaxScaler, OneHotEncoder,
OrdinalEncoder, PowerTransformer,
QuantileTransformer, RobustScaler, StandardScaler)
from sklearn.svm import SVC
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']
v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
return v
NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']
datos = prepara(carga_limpia(URL))
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)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
ONEHOT = Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))])
def prueba(paso_numerico, paso_categorico=ONEHOT, modelo=None):
pre = ColumnTransformer([('num', paso_numerico, NUMERICAS),
('cat', paso_categorico, CATEGORICAS)])
tuberia = Pipeline([('pre', pre),
('mod', modelo or LogisticRegression(max_iter=2000,
random_state=42))])
s = cross_val_score(tuberia, X_tr, y_tr, cv=cv, scoring='roc_auc')
return s.mean(), s.std()
print('banco listo. filas de entrenamiento:', len(X_tr))
banco listo. filas de entrenamiento: 2250
Seis escaladores, un solo resultado
for nombre, escalador in [
('sin escalar', 'passthrough'),
('StandardScaler', StandardScaler()),
('MinMaxScaler', MinMaxScaler()),
('RobustScaler', RobustScaler()),
('Quantile a normal', QuantileTransformer(output_distribution='normal',
random_state=42)),
('Yeo-Johnson', PowerTransformer(method='yeo-johnson'))]:
pasos = [('r', SimpleImputer(strategy='median'))]
if escalador != 'passthrough':
pasos.append(('e', escalador))
media, desviacion = prueba(Pipeline(pasos))
print(f'{nombre:20s} AUC {media:.4f} +/- {desviacion:.4f}')
sin escalar AUC 0.6998 +/- 0.0183 StandardScaler AUC 0.6997 +/- 0.0165 MinMaxScaler AUC 0.6998 +/- 0.0178 RobustScaler AUC 0.6996 +/- 0.0162 Quantile a normal AUC 0.6994 +/- 0.0158 Yeo-Johnson AUC 0.7007 +/- 0.0170
De 0,6994 a 0,7007. Trece diezmilésimas entre el mejor y el peor, cuando la desviación de la propia validación cruzada es 0,0165 😐
O sea que la diferencia entre no escalar nada y usar la transformación más sofisticada de scikit-learn es doce veces más chica que el ruido de la medición. Elegir por ese número es elegir el ruido.
Y ojo con precio_unitario, que en el capítulo
6 tenía asimetría 6,734. Ni siquiera Yeo-Johnson, que está
hecho justo para eso, consigue sacarle una décima 🤷♀️
Con un boosting, ni eso
for nombre, escalador in [('sin escalar', 'passthrough'),
('StandardScaler', StandardScaler())]:
pasos = [('r', SimpleImputer(strategy='median'))]
if escalador != 'passthrough':
pasos.append(('e', escalador))
media, desviacion = prueba(Pipeline(pasos),
modelo=HistGradientBoostingClassifier(random_state=42))
print(f'{nombre:20s} AUC {media:.4f} +/- {desviacion:.4f}')
sin escalar AUC 0.6653 +/- 0.0180 StandardScaler AUC 0.6653 +/- 0.0180
El mismo número, hasta la cuarta cifra, y no es casualidad 🌳
Un árbol pregunta "¿esta columna es mayor que X?", y esa pregunta no cambia si multiplicas la columna por mil. Escalar antes de un árbol es trabajo que no hace nada, ni bueno ni malo.
Y ahora dónde sí decide
Si con esos dos modelos da igual, la conclusión fácil es "el escalado es un mito". Vamos a comprobarlo con dos modelos que miden distancias.
for nombre, modelo in [('k vecinos', KNeighborsClassifier(n_neighbors=25)),
('SVM rbf', SVC(probability=True, random_state=42))]:
for etiqueta, escalador in [('sin escalar', 'passthrough'),
('escalado', StandardScaler())]:
pasos = [('r', SimpleImputer(strategy='median'))]
if escalador != 'passthrough':
pasos.append(('e', escalador))
media, desviacion = prueba(Pipeline(pasos), modelo=modelo)
print(f'{nombre:12s} {etiqueta:12s} AUC {media:.4f} +/- {desviacion:.4f}')
k vecinos sin escalar AUC 0.5863 +/- 0.0197 k vecinos escalado AUC 0.6611 +/- 0.0277 SVM rbf sin escalar AUC 0.6214 +/- 0.0355 SVM rbf escalado AUC 0.6732 +/- 0.0226
Ahí está 💥
El k vecinos pasa de 0,5863 a 0,6611. Siete centésimas y media, que es más de lo que da cualquier cambio de modelo en todo el libro. Y la SVM, cinco centésimas.
La razón es de sentido común: esos dos modelos miden distancias entre filas.
Sin escalar, monto va en miles y satisfaccion de 1 a
5, así que la distancia entre dos clientes es casi enteramente la
diferencia de monto y las otras ocho columnas no se enteran.
Conclusión honesta y con matices: el escalado no es un mito ni un trámite. Depende del modelo, y como en un proyecto se prueban varios (capítulo 13), se escala siempre y se acabó 📏
Cuatro formas de rellenar un hueco
for nombre, imputador in [
('mediana', SimpleImputer(strategy='median')),
('media', SimpleImputer(strategy='mean')),
('constante -1', SimpleImputer(strategy='constant', fill_value=-1)),
('KNN con 5 vecinos', KNNImputer(n_neighbors=5))]:
media, desviacion = prueba(Pipeline([('r', imputador), ('e', StandardScaler())]))
print(f'{nombre:20s} AUC {media:.4f} +/- {desviacion:.4f}')
mediana AUC 0.6997 +/- 0.0165 media AUC 0.6997 +/- 0.0165 constante -1 AUC 0.6998 +/- 0.0175 KNN con 5 vecinos AUC 0.6990 +/- 0.0171
Otra vez lo mismo: de 0,6990 a 0,6998, ocho diezmilésimas 😑
Y fíjate en que el KNN, que es el caro (mira los cinco vecinos más parecidos de cada fila con hueco), sale el último. No por mucho, pero el último.
La explicación está en el capítulo 9: la información
del hueco ya no está en el hueco, está en las banderas
sin_descuento y sin_satisfaccion. Con la bandera
puesta, da igual qué número pongas debajo: el modelo ya sabe que ahí no había
nada 🕳️
Por eso yo uso la mediana. No porque gane, sino porque es la que menos sorpresas da: no se mueve con un valor raro, no inventa correlaciones y se explica en una frase en la reunión.
El texto, convertido en números
NUMERICO = Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())])
ORDINAL = Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OrdinalEncoder(handle_unknown='use_encoded_value',
unknown_value=-1))])
for nombre, codificador in [('one-hot', ONEHOT),
('ordinal (numero arbitrario)', ORDINAL)]:
for etiqueta, modelo in [('logistica', LogisticRegression(max_iter=2000,
random_state=42)),
('boosting', HistGradientBoostingClassifier(random_state=42))]:
media, desviacion = prueba(NUMERICO, codificador, modelo)
print(f'{nombre:28s} {etiqueta:10s} AUC {media:.4f} +/- {desviacion:.4f}')
one-hot logistica AUC 0.6997 +/- 0.0165 one-hot boosting AUC 0.6653 +/- 0.0180 ordinal (numero arbitrario) logistica AUC 0.6906 +/- 0.0203 ordinal (numero arbitrario) boosting AUC 0.6573 +/- 0.0210
Aquí sí hay diferencia, aunque sea chica: el ordinal cuesta 0,0091 con la logística y 0,0080 con el boosting.
Y el motivo es que el ordinal se inventa un orden. Le pone 0 a Abarrotes, 1 a Bebidas, 2 a Cuidado personal, y el modelo lineal lee eso como que Cuidado personal es el doble que Bebidas. Que es una tontería, pero el modelo no lo sabe 🔢
Con cuatro o cinco niveles como aquí, el one-hot añade pocas columnas y
gana. Con los 617 niveles de cliente_id del capítulo
5, el one-hot es imposible y hay que buscar otra cosa.
El error que te espera en producción
Fíjate en que arriba puse handle_unknown en los dos
codificadores. Sin eso:
codificador = OrdinalEncoder().fit(X_tr[['ciudad']]) codificador.transform([['ciudad-nueva']])
ValueError: Found unknown categories ['ciudad-nueva'] in column 0 during transform
El día que el equipo comercial abra en Cusco, el modelo revienta 💥
Y aquí hay una decisión de verdad, no una convención. Las dos opciones son malas de formas distintas:
- 💣 Reventar es ruidoso, pero avisa. Alguien lo ve el mismo día.
- 🤫
handle_unknown='ignore'sigue funcionando y pone ceros. En el capítulo 23 se ve lo que eso significa: 300 ventas de una ciudad nueva predichas sin que nadie se entere.
Mi respuesta es las dos cosas: ignore en el codificador para no
tumbar el servicio, y una comprobación antes de predecir que avise por su
cuenta. Eso es la función revisa del capítulo
23 🚦
Partir en tramos, que aquí no sale a cuenta
La idea es buena sobre el papel: si una columna no empuja en línea recta, partirla en tramos deja que un modelo lineal vea escalones.
for nombre, transformador in [
('sin tramos', 'passthrough'),
('4 tramos por cuantil', KBinsDiscretizer(n_bins=4, encode='onehot-dense',
strategy='quantile')),
('10 tramos por cuantil', KBinsDiscretizer(n_bins=10, encode='onehot-dense',
strategy='quantile'))]:
pasos = [('r', SimpleImputer(strategy='median'))]
pasos.append(('e', StandardScaler()) if transformador == 'passthrough'
else ('t', transformador))
media, desviacion = prueba(Pipeline(pasos))
print(f'{nombre:24s} AUC {media:.4f} +/- {desviacion:.4f}')
sin tramos AUC 0.6997 +/- 0.0165 4 tramos por cuantil AUC 0.6734 +/- 0.0178 10 tramos por cuantil AUC 0.6670 +/- 0.0173
Pierde, y cuanto más se parte, más pierde: 0,6997 sin tramos, 0,6734 con cuatro y 0,6670 con diez 📉
Esto va en contra de lo que yo esperaba al escribir el capítulo, así que merece explicación. Y la explicación está en el capítulo 8: la relación entre el monto y la compra no existe una vez descuentas el segmento.
Partir en tramos sirve para que un modelo lineal capture una relación curva. Si no hay relación, lo único que haces es cambiar una columna con información por cuatro columnas con menos, y encima con las fronteras puestas donde caen los cuantiles y no donde el negocio tiene sentido.
La regla que me llevo: los tramos se justifican con un hallazgo, no con una intuición 🪜
La tabla de decisiones
Todo el capítulo cabe en esto, y esto es lo que se pega en el informe.
| Decisión | Cuánto mueve el AUC | Entonces se elige por |
|---|---|---|
| Qué escalador | 0,0013 | Que no se rompa con un valor raro |
| Escalar o no, con árboles | 0,0000 | Da igual, se deja por costumbre |
| Escalar o no, con distancias | 0,0748 | Se escala, sin discusión |
| Qué imputador | 0,0008 | Que se explique en una frase |
| One-hot u ordinal | 0,0091 | One-hot si caben las columnas |
| Partir en tramos | −0,0263 | Solo con un hallazgo detrás |
Dos de las seis importan, y una de las dos importa en contra 😅
Si esto te parece decepcionante, mira el otro lado: significa que puedes dejar de discutir cuatro de estas seis decisiones y meter ese tiempo en las que sí mueven, que son construir columnas (capítulo 9) y elegir el umbral (capítulo 15).
Ejercicios
1. Cada columna con su tratamiento
Hasta aquí todas las numéricas recibieron el mismo trato. Dale a cada una lo que su ficha del capítulo 6 pedía.
SESGADAS = ['monto', 'precio_unitario']
NORMALES = ['unidades', 'descuento', 'satisfaccion', 'visita_numero']
BANDERAS = ['sin_compra_previa', 'sin_descuento', 'sin_satisfaccion']
a_medida = ColumnTransformer([
('sesgadas', Pipeline([('r', SimpleImputer(strategy='median')),
('e', PowerTransformer(method='yeo-johnson'))]), SESGADAS),
('normales', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NORMALES),
('banderas', 'passthrough', BANDERAS),
('cat', ONEHOT, CATEGORICAS),
])
a_medida = Pipeline([('pre', a_medida),
('mod', LogisticRegression(max_iter=2000, random_state=42))])
s = cross_val_score(a_medida, X_tr, y_tr, cv=cv, scoring='roc_auc')
print(f'a medida AUC {s.mean():.4f} +/- {s.std():.4f}')
media, desviacion = prueba(Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]))
print(f'todo por igual AUC {media:.4f} +/- {desviacion:.4f}')
a medida AUC 0.7010 +/- 0.0170 todo por igual AUC 0.6997 +/- 0.0165
0,7010 contra 0,6997. Trece diezmilésimas otra vez, o sea la misma nota, y cuatro veces más código para conseguirla 😂
Y aun así lo hago en proyectos de verdad, por una razón que no es el AUC: las
banderas pasan por passthrough en vez de escalarse, así que sus
coeficientes se leen directamente como odds sin deshacer ninguna
transformación. Se gana en explicación, no en acierto.
2. Cuánto tarda cada decisión
Si el AUC no decide, que decida algo. Mide el tamaño del objeto que hay que guardar y cargar en cada arranque.
import os
import tempfile
import joblib
carpeta = tempfile.mkdtemp()
for nombre, paso in [('mediana', SimpleImputer(strategy='median')),
('KNN con 5 vecinos', KNNImputer(n_neighbors=5))]:
pre = ColumnTransformer([('num', Pipeline([('r', paso), ('e', StandardScaler())]),
NUMERICAS),
('cat', ONEHOT, CATEGORICAS)])
modelo = Pipeline([('pre', pre),
('mod', LogisticRegression(max_iter=2000, random_state=42))])
modelo.fit(X_tr, y_tr)
ruta = os.path.join(carpeta, nombre.replace(' ', '_') + '.joblib')
joblib.dump(modelo, ruta)
print(f'{nombre:20s} {os.path.getsize(ruta):>9,d} bytes')
mediana 5,642 bytes KNN con 5 vecinos 188,106 bytes
El KNN se lleva el conjunto de entrenamiento entero dentro, porque para imputar una fila nueva necesita compararla con las que vio 🎒
Ese es el tipo de argumento que decide cuando el AUC no lo hace. Un objeto más pesado tarda más en cargar en cada arranque del servicio y hay que versionarlo igual.
3. El escalador contra un valor absurdo
Mañana llega una venta con un cero de más por un error de tecleo. Mide qué le hace eso a cada escalador.
rara = X_tr.copy()
rara.iloc[0, rara.columns.get_loc('monto')] = 4_000_000
for nombre, escalador in [('StandardScaler', StandardScaler()),
('MinMaxScaler', MinMaxScaler()),
('RobustScaler', RobustScaler())]:
antes = escalador.fit_transform(X_tr[['monto']])
despues = escalador.fit_transform(rara[['monto']])
print(f'{nombre:16s} desviacion de las 2.250 filas: '
f'antes {antes.std():.4f} despues {despues.std():.4f}')
StandardScaler desviacion de las 2.250 filas: antes 1.0000 despues 1.0000 MinMaxScaler desviacion de las 2.250 filas: antes 0.1134 despues 0.0211 RobustScaler desviacion de las 2.250 filas: antes 0.9415 despues 103.6063
Las tres reaccionan distinto y ninguna se queda igual 😱
El MinMax aplasta a todo el mundo. Su rango pasa a ser de 0 a cuatro millones, así que las 2.250 filas de verdad se apelotonan contra el cero: la desviación cae de 0,1134 a 0,0211, una quinta parte. La información sigue ahí pero comprimida hasta casi desaparecer.
El Standard sale en 1,0000 las dos veces, y eso no es que aguante: es que por construcción siempre devuelve desviación 1. Ese número no mide nada, y lo dejo justamente para que se vea que un indicador puede parecer tranquilizador sin decir nada.
Y el Robust se dispara a 103,6. Como divide por el rango intercuartílico, que no se mueve, la fila mal tecleada se convierte en un número enorme y arrastra la desviación del conjunto.
Suena mal y es justo lo que quieres: las 2.250 filas buenas se quedan donde estaban y el dato absurdo se queda absurdo, en vez de disimularse. Se ve en cualquier gráfico y se ve en cualquier alarma 🚩
Ahí tienes el criterio de verdad para elegir escalador, que no era el AUC: cuál deja el dato raro a la vista en vez de esconderlo entre los demás.
4. Imputar mirando el examen, que es trampa
La mediana del imputador tiene que salir solo del entrenamiento. Mide cuánto se infla si sale de todo.
from sklearn.metrics import roc_auc_score
# Lo correcto: el pipeline aprende la mediana dentro de cada partición
correcto = Pipeline([('pre', ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NUMERICAS),
('cat', ONEHOT, CATEGORICAS)])),
('mod', LogisticRegression(max_iter=2000, random_state=42))])
correcto.fit(X_tr, y_tr)
print('correcto AUC test', round(roc_auc_score(y_te, correcto.predict_proba(X_te)[:, 1]), 4))
# La trampa: la mediana sale de las 3.000 filas, examen incluido
trampa = X.copy()
trampa[NUMERICAS] = trampa[NUMERICAS].fillna(trampa[NUMERICAS].median())
Xt_tr, Xt_te = trampa.loc[X_tr.index], trampa.loc[X_te.index]
sucio = Pipeline([('pre', ColumnTransformer([
('num', StandardScaler(), NUMERICAS), ('cat', ONEHOT, CATEGORICAS)])),
('mod', LogisticRegression(max_iter=2000, random_state=42))])
sucio.fit(Xt_tr, y_tr)
print('con trampa AUC test', round(roc_auc_score(y_te, sucio.predict_proba(Xt_te)[:, 1]), 4))
correcto AUC test 0.7214 con trampa AUC test 0.7215
La diferencia es minúscula, y lo digo aunque no ayude al argumento 💛
Con 3.000 filas y una mediana, filtrar el examen apenas cambia nada. El problema es que no siempre es minúscula: con un imputador que aprende relaciones entre columnas, o con selección de variables mirando todo el archivo, la misma trampa vale varios puntos.
Por eso la regla del capítulo 11 no admite excepciones, aunque en este caso concreto no habría pasado nada.
5. Cuántas columnas salen del otro lado
Antes de discutir codificaciones, mira cuántas columnas crea cada una. Es lo que decide si el one-hot es viable.
pre = ColumnTransformer([('num', StandardScaler(), NUMERICAS),
('cat', ONEHOT, CATEGORICAS)])
pre.fit(X_tr)
print('con one-hot :', len(pre.get_feature_names_out()), 'columnas')
pre_ord = ColumnTransformer([('num', StandardScaler(), NUMERICAS),
('cat', ORDINAL, CATEGORICAS)])
pre_ord.fit(X_tr)
print('con ordinal :', len(pre_ord.get_feature_names_out()), 'columnas')
print()
for c in CATEGORICAS:
print(f' {c:12s} {X_tr[c].nunique():3d} niveles')
print()
print('si cliente_id entrara con one-hot:', datos['cliente_id'].nunique(), 'columnas mas')
con one-hot : 28 columnas con ordinal : 13 columnas ciudad 6 niveles segmento 4 niveles canal 4 niveles categoria 5 niveles si cliente_id entrara con one-hot: 617 columnas mas
Veintiocho columnas contra trece. Con estos niveles el one-hot es gratis 🧩
Y ahí abajo está el límite: cliente_id añadiría 617 columnas a
2.250 filas de entrenamiento. Eso ya no es codificar, es regalarle al modelo una
columna por cliente para que se lo aprenda de memoria.
6. El paso que se come columnas sin decir nada
Un ColumnTransformer tira todo lo que no le
nombres. Compruébalo olvidándote de tres columnas a propósito.
olvidadizo = ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NUMERICAS[:4]),
('cat', ONEHOT, CATEGORICAS)])
modelo = Pipeline([('pre', olvidadizo),
('mod', LogisticRegression(max_iter=2000, random_state=42))])
s = cross_val_score(modelo, X_tr, y_tr, cv=cv, scoring='roc_auc')
print('columnas que le pasé :', len(NUMERICAS + CATEGORICAS))
print('columnas que usó :', len(olvidadizo.fit(X_tr).get_feature_names_out()))
print(f'AUC {s.mean():.4f} +/- {s.std():.4f}')
print()
print('lo mismo con las nueve numericas:')
media, desviacion = prueba(Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]))
print(f'AUC {media:.4f} +/- {desviacion:.4f}')
columnas que le pasé : 13 columnas que usó : 23 AUC 0.6793 +/- 0.0184 lo mismo con las nueve numericas: AUC 0.6997 +/- 0.0165
Le pasé trece columnas y solo nombré cuatro de las nueve numéricas. Las cinco que faltan se van sin una sola advertencia, y el AUC se cae de 0,6997 a 0,6793 🕳️
Las 23 de la segunda línea son las de después del one-hot: cuatro numéricas más diecinueve columnas nuevas de las categóricas. Por eso el número de salida no sirve para detectar la pérdida, y hay que mirar los nombres.
Es el fallo silencioso más común de todo scikit-learn y no aparece en ningún
sitio: remainder='drop' es el valor por defecto. Con
remainder='passthrough' pasa lo contrario, que se cuelan columnas
que no querías, incluidos identificadores.
Por eso la comprobación de arriba, get_feature_names_out(), va
siempre después de armar el pipeline. Una línea 📏
7. El resumen, en una función que decide sola
Cierra el capítulo con la función que arma el preprocesamiento a partir de la auditoría de tipos, sin decidir nada a mano.
def arma_preprocesado(v, columnas, umbral_asimetria=2.0):
from scipy import stats
sesgadas, normales, banderas, categoricas = [], [], [], []
for c in columnas:
if not pd.api.types.is_numeric_dtype(v[c]):
categoricas.append(c)
elif v[c].dropna().nunique() <= 2:
banderas.append(c)
elif abs(stats.skew(v[c].dropna())) > umbral_asimetria:
sesgadas.append(c)
else:
normales.append(c)
partes = []
if sesgadas:
partes.append(('sesgadas', Pipeline([
('r', SimpleImputer(strategy='median')),
('e', PowerTransformer(method='yeo-johnson'))]), sesgadas))
if normales:
partes.append(('normales', Pipeline([
('r', SimpleImputer(strategy='median')),
('e', RobustScaler())]), normales))
if banderas:
partes.append(('banderas', 'passthrough', banderas))
if categoricas:
partes.append(('cat', ONEHOT, categoricas))
return ColumnTransformer(partes), {'sesgadas': sesgadas, 'normales': normales,
'banderas': banderas, 'categoricas': categoricas}
pre, reparto = arma_preprocesado(X_tr, NUMERICAS + CATEGORICAS)
for grupo, cols in reparto.items():
print(f'{grupo:12s} {cols}')
print()
modelo = Pipeline([('pre', pre),
('mod', LogisticRegression(max_iter=2000, random_state=42))])
s = cross_val_score(modelo, X_tr, y_tr, cv=cv, scoring='roc_auc')
print(f'AUC {s.mean():.4f} +/- {s.std():.4f}')
sesgadas ['precio_unitario'] normales ['unidades', 'monto', 'descuento', 'satisfaccion', 'visita_numero'] banderas ['sin_compra_previa', 'sin_descuento', 'sin_satisfaccion'] categoricas ['ciudad', 'segmento', 'canal', 'categoria'] AUC 0.7012 +/- 0.0175
Mismo resultado que a mano, y con el reparto escrito para que se pueda revisar 📋
Fíjate en que devuelve dos cosas: el transformador y el reparto. Lo segundo es lo que hace que esto sea auditable en vez de mágico. Cuando alguien pregunte por qué el descuento se escaló con RobustScaler, la respuesta está impresa y no hay que leer el código.
Y ese umbral_asimetria de 2,0 es una decisión mía que puse en un
parámetro a propósito. Las decisiones discutibles se ponen donde se vean, no
enterradas en un if 💛
Un detalle del código que cuesta un rato la primera vez: la comprobación de
si una columna es texto va con pd.api.types.is_numeric_dtype y no
con dtype == object. Desde pandas 3, una columna de texto ya no
tiene dtype object, así que la comparación de toda la vida devuelve
False y las categóricas se cuelan por la rama de los números 🐍
Comprueba que lo tienes
Pruebas seis escaladores distintos y los seis dan el mismo AUC, con las diferencias por debajo de la desviación de la validación cruzada. ¿Qué haces?
- Elijo el que menos se rompa el día que llegue un dato raro
- Me quedo con el que salió más alto, aunque sea por poco
- Pruebo más escaladores hasta que alguno destaque
- Quito el escalado, que total da igual
Lo que te llevas
- 😐 Seis escaladores dan de 0,6994 a 0,7007. Trece diezmilésimas, cuando la desviación de la propia validación cruzada es 0,0165.
- 🌳 Con un boosting, escalar da exactamente el mismo número hasta la cuarta cifra. Un árbol pregunta si una columna es mayor que un valor, y eso no cambia al multiplicarla por mil.
- 💥 Pero con k vecinos el escalado vale 0,0748 de AUC y con SVM 0,0518. Esos miden distancias, y sin escalar la distancia entre dos clientes es casi enteramente la diferencia de monto.
- 🕳️ Cuatro imputadores dan ocho diezmilésimas de diferencia, y el KNN, que es el caro, sale el último. La información del hueco ya está en la bandera.
- 🔢 El ordinal cuesta 0,0091 contra el one-hot, porque se inventa un orden: le pone 0 a Abarrotes y 2 a Cuidado personal y el modelo lee que uno es el doble.
- 💣 Sin
handle_unknown, el día que abras en Cusco el modelo revienta. Conignoreno revienta y predice mal en silencio, así que hacen falta las dos cosas: ignore y una comprobación aparte. - 📉 Partir el monto en tramos PIERDE: 0,6997 sin tramos, 0,6734 con cuatro y 0,6670 con diez. Los tramos se justifican con un hallazgo, no con una intuición.
- 🧯 El MinMaxScaler con un monto mal tecleado aplasta las 2.250 filas restantes contra el cero. El RobustScaler ni se entera, y ese es el criterio de verdad para elegirlo.
- 🚨
ColumnTransformertira sin avisar toda columna que no le nombres, porqueremainder='drop'es el valor por defecto. Se comprueba conget_feature_names_out(), que es una línea. - ⚖️ Dos de seis decisiones importan, y una importa en contra. Eso libera tiempo para las que sí mueven: construir columnas y elegir el umbral.
Qué viene ahora
Ya sabemos qué columnas entran y cómo. Falta que todo eso se ejecute igual en entrenamiento, en examen y en producción, sin copiar y pegar 📦
El capítulo 11 junta las piezas de este capítulo en un solo objeto que se entrena de una vez, se guarda de una vez y no deja que la preparación se te escape a los datos de examen.
Lo que verás allí:
- 🔗 El
ColumnTransformery elPipelinemontados sobre las decisiones que acabamos de tomar aquí. - 🧪 Por qué la preparación tiene que rehacerse dentro de cada partición de la validación cruzada, y qué pasa si no.
- 📦 Un solo objeto que se guarda con
joblib, se carga en producción y trae el preprocesamiento dentro. - 🏷️ Y la lista de nombres que salen del otro lado, que es la que vas a necesitar para explicar el modelo.
Y ahí sale el número que va a valer para todo el resto del libro: casi diez puntos de AUC por encima del listón, y ninguno de ellos vino del algoritmo.