# Escalar, imputar y codificar

URL canónica: https://missyera.com/guias/machine-learning-desde-cero/decisiones-de-preprocessing/
Última actualización: 2026-11-14
Autora: Gera Flores (Miss Yera)

> **Respuesta corta**
>
> Me pasé años eligiendo escalador como si importara. Aquí seis escaladores dan de 0,6994 a 0,7007 y cuatro imputadores se mueven ocho diezmilésimas, cuando la desviación de la validación cruzada es 0,0165. O sea que la diferencia entre el mejor y el peor cabe entera dentro del ruido, y elegir por el cuarto decimal es elegir por azar 🎲

Capítulo 10 de 29 del libro [Machine learning desde cero](https://missyera.com/guias/machine-learning-desde-cero.md).

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 🔧

Y te hago una pregunta incómoda de entrada: **¿cuántas de las decisiones técnicas que defendiste el año pasado podrías justificar hoy con un número?** Este capítulo va de eso 🤔

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 orden del preprocesamiento: imputar, codificar, escalar y modelo, con una nota de que imputar sí decide y otra resaltada de que seis escaladores distintos dan entre 0,6994 y 0,7007.](https://missyera.com/static/diagramas/webp/ml-10-preprocesamiento.webp)

*La caja resaltada es la sorpresa del capítulo. Seis escaladores caben en trece diezmilésimas, y la desviación de la validación cruzada es doce veces más grande que esa distancia.*

## El banco de pruebas

Los porqués de este banco de pruebas

Comparar preprocesamientos parece trivial y tiene tres decisiones dentro, y si alguna se cae la comparación deja de medir lo que crees:

- 🔁 **Validación cruzada y no un solo reparto.** Con una sola partición, la diferencia entre dos escaladores se mezcla con la suerte de esa partición. Es exactamente lo que hace falta separar aquí.
- 🔒 **Todo dentro del pipeline.** Si el escalador se ajusta fuera, se ajusta con las filas del examen dentro, y entonces estarías comparando seis maneras distintas de hacer trampa.
- 🎲 **La misma semilla para los seis.** Si cada uno se evalúa con un reparto distinto, la tabla mide repartos y no escaladores. Es el error más fácil de cometer y el más difícil de ver después.

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))
```

```text
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}')
```

```text
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}')
```

```text
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}')
```

```text
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}')
```

```text
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}')
```

```text
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 27 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 27 🚦

## 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}')
```

```text
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}')
```

```text
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')
```

```text
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}')
```

```text
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))
```

```text
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')
```

```text
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}')
```

```text
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}')
```

```text
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

## El ganador que sale del ruido

La trampa

Comparas seis escaladores como manda el manual, ordenas por AUC y te quedas con el mejor. El código es correcto y la tabla es real.

```
for nombre, esc in escaladores.items():
    auc[nombre] = evalúa(esc)

mejor = max(auc, key=auc.get)
# 'RobustScaler'  (0.7007)
```

**Qué está mal**

El mejor saca 0,7007 y el peor 0,6994. Trece diezmilésimas de diferencia, sobre una validación cruzada cuya desviación es **0,0165**, o sea doce veces más grande que la distancia entre el primero y el último 📉

Ese ranking no mide escaladores: mide el azar del reparto. Si cambias la semilla, el ganador cambia. Y el problema no es perder trece diezmilésimas, es que ahora tienes una decisión escrita en el informe, defendida en una reunión y copiada al proyecto siguiente, sostenida por nada.

La regla que uso: **si la diferencia no supera la desviación de la validación cruzada, no hay ganador**. Se elige por otra cosa, que aquí es cuál se explica mejor y cuál aguanta un valor raro en producción.

## 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.

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

Si la diferencia cabe dentro del ruido, no hay ganador. Hay una moneda.

La desviación de la validación cruzada, y por qué una diferencia dentro de ella no significa nada, viene de la estadística: está en el [libro de estadística desde cero](https://missyera.com/guias/estadistica-desde-cero/) 📐

## 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.

## Seguir leyendo

- Anterior: [Construir columnas](https://missyera.com/guias/machine-learning-desde-cero/construir-columnas.md)
- Siguiente: [El pipeline](https://missyera.com/guias/machine-learning-desde-cero/el-pipeline.md)

## Contacto

- Diagnóstico de 30 minutos con Miss Yera: https://calendly.com/soymissyera/reunion-con-yera
- Correo: hola@missyera.com
- Esta página en HTML: https://missyera.com/guias/machine-learning-desde-cero/decisiones-de-preprocessing/
- Índice de todas las páginas en texto: https://missyera.com/llms-index.txt
