Capítulo 10 de 25 12 secciones 19 min

Escalar, imputar y codificar: qué decide de verdad

Seis escaladores que dan el mismo AUC, uno que vale siete centésimas, y la tabla de decisiones que sale de medirlo todo.

Casi ninguna decisión de preprocesamiento mueve el AUC: seis escaladores dan de 0,6994 a 0,7007 y cuatro imputadores ocho diezmilésimas, cuando la desviación de la validación cruzada es 0,0165. Con árboles, escalar da exactamente el mismo número. Donde sí decide es en los modelos que miden distancias: el k vecinos gana 0,0748 de AUC al escalar. Partir el monto en tramos pierde 0,0263. Y las dos cosas que de verdad importan no son el AUC: handle_unknown, porque sin él el modelo revienta con una ciudad nueva, y que ColumnTransformer tira sin avisar toda columna que no le nombres.

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ónCuánto mueve el AUCEntonces se elige por
Qué escalador0,0013Que no se rompa con un valor raro
Escalar o no, con árboles0,0000Da igual, se deja por costumbre
Escalar o no, con distancias0,0748Se escala, sin discusión
Qué imputador0,0008Que se explique en una frase
One-hot u ordinal0,0091One-hot si caben las columnas
Partir en tramos−0,0263Solo 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. Con ignore no 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.
  • 🚨 ColumnTransformer tira sin avisar toda columna que no le nombres, porque remainder='drop' es el valor por defecto. Se comprueba con get_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 ColumnTransformer y el Pipeline montados 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.

¿Tienes alguna duda o consulta?