Capítulo 16 de 25 9 secciones 15 min

Validación cruzada, o por qué 0,7214 no era un resultado

Diez semillas dan cinco puntos y medio de diferencia, y ajustar hiperparámetros movió once diezmilésimas.

Un solo train_test_split no es un resultado: con diez semillas distintas este modelo da entre 0,6870 y 0,7419 de AUC. La validación cruzada de cinco da 0,7056 ± 0,0133, y esa desviación es la que dice que una diferencia de menos de un punto entre dos modelos no significa nada. Y ajustar hiperparámetros movió once diezmilésimas, mucho menos que limpiar datos, construir columnas o elegir el umbral.

Llevamos nueve capítulos diciendo "el AUC es 0,7214" 📐

Ese número salió de partir los datos una vez, con random_state=42. Hoy vamos a ver qué habría pasado con otra semilla, y la respuesta incomoda un poquito.

El punto de partida

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

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']

def arma(clasificador=None):
    pre = ColumnTransformer([
        ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                          ('e', StandardScaler())]), NUMERICAS),
        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                          ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
    ])
    if clasificador is None:
        clasificador = LogisticRegression(max_iter=1000, random_state=42)
    return Pipeline([('pre', pre), ('mod', clasificador)])

datos = prepara(carga_limpia(URL))
X = datos[NUMERICAS + CATEGORICAS]
y = datos['compro']
print(X.shape)
(3000, 13)

El mismo modelo, diez veces

aucs = []
for semilla in range(10):
    X_a, X_b, y_a, y_b = train_test_split(X, y, test_size=0.25,
                                          random_state=semilla, stratify=y)
    m = arma().fit(X_a, y_a)
    aucs.append(roc_auc_score(y_b, m.predict_proba(X_b)[:, 1]))

print(np.round(aucs, 4))
print('mínimo', round(min(aucs), 4), '| máximo', round(max(aucs), 4),
      '| media', round(np.mean(aucs), 4))
[0.7105 0.7091 0.687  0.7046 0.7419 0.7017 0.6976 0.7124 0.707  0.7152]
mínimo 0.687 | máximo 0.7419 | media 0.7087

De 0,6870 a 0,7419. Cinco puntos y medio de diferencia, y lo único que cambió fue qué filas cayeron en cada lado 😳

Y aquí va la parte incómoda: nuestro 0,7214 de siempre está por encima de la media (0,7087). O sea que el 42 nos tocó bueno.

Esto es lo que pasa cuando alguien prueba cinco semillas y reporta la mejor. No hace falta mala intención: basta con parar de probar cuando sale un número bonito 🎲

Validación cruzada de cinco bloques: cinco filas y en cada una el bloque de validación se corre una posición, de modo que los cinco bloques sirven una vez de validación y cuatro de entrenamiento.
Cinco mediciones en vez de una, y lo que más informa no es la media sino cuánto se separan entre sí. Si un bloque da 0,80 y otro 0,62, no tienes un modelo con 0,71: tienes un modelo del que todavía no sabes nada.

Validación cruzada, que es la respuesta

err^=1Kk=1Kerrk

entrenas K veces dejando fuera un trozo distinto cada vez y promedias, para que la nota no dependa de qué filas cayeron en el test

En vez de partir una vez, se parte cinco: cada trozo hace de examen una vez y de entrenamiento cuatro. Así todas las filas se usan para las dos cosas y el resultado no depende de la suerte.

from sklearn.model_selection import StratifiedKFold, cross_val_score

particion = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
notas = cross_val_score(arma(), X, y, cv=particion, scoring='roc_auc')

print(np.round(notas, 4))
print('media:', round(notas.mean(), 4), '± desviación:', round(notas.std(), 4))
[0.6957 0.7009 0.692  0.7104 0.7291]
media: 0.7056 ± desviación: 0.0133

0,7056 ± 0,0133. Ese es el número que hay que reportar, y con la desviación al lado.

Y la desviación no es decoración: dice que una diferencia de menos de un punto entre dos modelos no significa nada. Si un modelo saca 0,706 y otro 0,712, están empatados 📐

El Stratified importa: reparte manteniendo la proporción de la clase positiva en cada trozo. Sin él, con clases desbalanceadas, algún trozo se puede quedar casi sin positivos.

Dos curvas de AUC según cuántas ventas se usan para entrenar: la de entrenamiento y la de validación, que se acercan y se aplanan al llegar al final en lugar de seguir subiendo.
Cuando las dos curvas se juntan y se aplanan, más datos ya no van a arreglar nada: el techo lo pone la información que tienen las columnas, no la cantidad de filas.
Curva de aprendizaje: el error de entrenamiento se mantiene bajo y plano mientras el de validación baja al aumentar el tamaño del conjunto, sin llegar a juntarse. La distancia entre las dos líneas está marcada como brecha de varianza.
Mientras la línea de validación siga bajando, más datos te siguen sirviendo. Cuando se aplana y la brecha se mantiene, ya no: ahí el dinero va en variables nuevas o en otro modelo, no en juntar más filas de lo mismo.

La curva de aprendizaje

Antes de tocar hiperparámetros, hay una pregunta que sale más barata: ¿me faltan datos o me falta modelo?

from sklearn.model_selection import learning_curve

tam, train, validacion = learning_curve(
    arma(), X, y, train_sizes=[0.1, 0.3, 0.5, 0.75, 1.0],
    cv=particion, scoring='roc_auc', n_jobs=-1)

for n, a, b in zip(tam, train.mean(axis=1), validacion.mean(axis=1)):
    print(f'{n:5d} filas   train {a:.4f}   validación {b:.4f}   hueco {a - b:.4f}')
  240 filas   train 0.8049   validación 0.6672   hueco 0.1378
  720 filas   train 0.7369   validación 0.6956   hueco 0.0413
 1200 filas   train 0.7191   validación 0.7015   hueco 0.0176
 1800 filas   train 0.7196   validación 0.7041   hueco 0.0155
 2400 filas   train 0.7223   validación 0.7056   hueco 0.0167

Esa tabla se lee así, y es una de las herramientas más infravaloradas que existen:

  • 📉 El hueco se cierra, de 0,1378 con 240 filas a 0,0167 con 2.400. O sea que el sobreajuste desapareció al tener datos.
  • 📈 La validación sigue subiendo, aunque despacito: de 0,6672 a 0,7056. Todavía no se aplanó del todo.

La conclusión práctica: más datos ayudarían un poquito, y cambiar de modelo casi nada. Si la curva de validación se hubiera aplanado, más datos no servirían de nada y habría que buscar columnas nuevas.

Es la diferencia entre pedir presupuesto para "más datos" y pedirlo para "más tiempo de análisis", y contestarla con una tabla en vez de con una opinión 💼

Buscar hiperparámetros sin engañarte

Un hiperparámetro es una decisión tuya, no algo que el modelo aprenda. El max_depth del árbol, el k de los vecinos, el C de la logística.

Probarlos a mano es lento y peligroso: si pruebas veinte y te quedas con el mejor en el examen, el examen dejó de ser examen.

from sklearn.model_selection import GridSearchCV

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

rejilla = GridSearchCV(arma(), {'mod__C': [0.01, 0.1, 1, 10, 100]},
                       cv=particion, scoring='roc_auc', n_jobs=-1)
rejilla.fit(X_tr, y_tr)

print('mejor:', rejilla.best_params_)
print('CV   :', round(rejilla.best_score_, 4))
print('test :', round(roc_auc_score(y_te, rejilla.predict_proba(X_te)[:, 1]), 4))
mejor: {'mod__C': 0.1}
CV   : 0.7
test : 0.721

Fíjate en el mod__C con doble guion bajo: es la forma de decirle "el parámetro C del paso que se llama mod". Con esa sintaxis puedes ajustar también la preparación, por ejemplo la estrategia del imputador.

Y ahora mira los cinco resultados:

for p, s in zip(rejilla.cv_results_['params'], rejilla.cv_results_['mean_test_score']):
    print(p, round(s, 4))
{'mod__C': 0.01} 0.6989
{'mod__C': 0.1} 0.7
{'mod__C': 1} 0.6997
{'mod__C': 10} 0.6995
{'mod__C': 100} 0.6995

De 0,6989 a 0,7000. Once diezmilésimas entre el mejor y el peor, cuando la desviación de la validación cruzada era 0,0133.

Traducido: ajustar la logística en este problema no sirvió de nada. Y eso también es un resultado, y de los que ahorran semanas 😅

Es lo contrario de lo que se suele contar. El orden de rentabilidad casi siempre es: primero limpiar (capítulo 4), después construir columnas (capítulo 9), después el umbral (capítulo 15), y solo al final los hiperparámetros.

Curvas de nivel de la pérdida alrededor de su óptimo, con dos regiones de restricción encima: el rombo de L1 y el círculo punteado de L2. La solución de L1 cae justo en un vértice del rombo, sobre uno de los ejes.
Aquí se ve por qué Lasso anula coeficientes y Ridge no, que es la pregunta que siempre queda colgando. El rombo tiene esquinas y las esquinas están sobre los ejes, así que la solución tiende a caer en una: y estar sobre el eje significa que ese coeficiente es cero. El círculo no tiene esquinas.

Qué es ese C, que es Ridge y que es Lasso

Ltotal=L+λjwj2(L2),Ltotal=L+λj|wj|(L1)

le sumas al error un castigo por tener pesos grandes, y la versión con valor absoluto además los empuja hasta cero, o sea que selecciona columnas

Llevamos dos capítulos ajustando C sin decir qué es. Es el freno de la regresión logística, y merece su propia sección porque es el hiperparámetro que más te vas a encontrar.

Al entrenar, el modelo busca los pesos que mejor separan. Si lo dejas suelto, los estira todo lo que haga falta para acertar en entrenamiento, que es justamente memorizar. La regularización le añade a la cuenta un castigo por tener pesos grandes, y ahí hay dos formas de castigar:

  • 📏 Ridge, o penalización L2, castiga la suma de los pesos al cuadrado. Los encoge a todos y no anula ninguno. Es la que trae LogisticRegression puesta de fábrica.
  • ✂️ Lasso, o penalización L1, castiga la suma de los valores absolutos. Esta sí pone pesos en cero, o sea que elige columnas mientras entrena.

Y C es el freno al revés: C chico castiga más. Es de las convenciones más incómodas de scikit-learn y no hay que darle vueltas, solo recordarla.

from sklearn.linear_model import LogisticRegression

print(f'{"C":<8}{"Ridge":<9}{"Lasso":<9}pesos en cero')
for C in [0.001, 0.01, 0.1, 1.0, 10.0]:
    ridge = arma(LogisticRegression(max_iter=1000, C=C, random_state=42)).fit(X_tr, y_tr)
    lasso = arma(LogisticRegression(max_iter=1000, C=C, penalty='l1',
                                    solver='liblinear', random_state=42)).fit(X_tr, y_tr)
    ceros = (lasso.named_steps['mod'].coef_[0] == 0).sum()
    print(f'{C:<8}'
          f'{roc_auc_score(y_te, ridge.predict_proba(X_te)[:, 1]):<9.4f}'
          f'{roc_auc_score(y_te, lasso.predict_proba(X_te)[:, 1]):<9.4f}'
          f'{ceros} de 28')
C       Ridge    Lasso    pesos en cero
0.001   0.7145   0.5000   28 de 28
0.01    0.7175   0.6813   24 de 28
0.1     0.7210   0.7229   14 de 28
1.0     0.7214   0.7224   6 de 28
10.0    0.7217   0.7217   4 de 28

Aquí hay tres cosas y las tres valen 👀

Ridge apenas se mueve, de 0,7145 a 0,7217 en un rango de C que va de mil a una diezmilésima. Con trece columnas hay poco que frenar.

Lasso con C=0,001 pone los 28 pesos en cero y saca 0,5000 exacto, que es tirar una moneda. Es la demostración más limpia que conozco de qué hace el castigo: apretado del todo, el modelo se queda sin nada que decir.

Y con C=0,1, Lasso anula 14 de 28 pesos y saca 0,7229, que es mejor que el modelo completo. La mitad de las columnas sobraba, y esto lo descubrió solo mientras entrenaba 🎉

Cuando C sube, los ceros bajan: 24, luego 14, luego 6, luego 4. El freno se suelta y el modelo se permite usar más columnas.

Miremos cuáles sobreviven cuando apretamos fuerte:

fuerte = arma(LogisticRegression(max_iter=1000, C=0.05, penalty='l1',
                                 solver='liblinear', random_state=42)).fit(X_tr, y_tr)
nombres = fuerte.named_steps['pre'].get_feature_names_out()
pesos = fuerte.named_steps['mod'].coef_[0]

for i in np.argsort(-np.abs(pesos))[:6]:
    print(f'{nombres[i]:32} {pesos[i]:+.4f}')
cat__segmento_Bodega             -0.5157
num__satisfaccion                +0.3564
num__sin_compra_previa           -0.3158
num__monto                       +0.2366
cat__canal_Marketplace           -0.2007
cat__canal_WhatsApp              +0.1191

Segmento, satisfacción, sin_compra_previa y canal están las cuatro ahí, que son exactamente las cuatro que la importancia por permutación va a encontrar en el capítulo 19, por un camino completamente distinto.

Cuando dos métodos que no se parecen en nada te señalan las mismas columnas, eso ya no es casualidad 💪

Se cuela el monto en cuarto lugar, que allá salía quinto y flojito. Y tiene explicación: Lasso mira el tamaño del peso y la permutación mide cuánto AUC se pierde al revolver la columna. No son lo mismo, y el monto es el caso donde se separan.

Y para elegir entre las dos: Ridge por defecto, porque casi siempre va igual o mejor y no te quita nada. Lasso cuando tienes muchas columnas y quieres que el modelo elija, que es su gracia de verdad.

El bosque, con su rejilla

from sklearn.ensemble import RandomForestClassifier

rejilla_bosque = GridSearchCV(
    arma(RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)),
    {'mod__max_depth': [3, 5, 8, None], 'mod__min_samples_leaf': [1, 10, 30]},
    cv=particion, scoring='roc_auc', n_jobs=-1)
rejilla_bosque.fit(X_tr, y_tr)

print('mejor:', rejilla_bosque.best_params_)
print('CV   :', round(rejilla_bosque.best_score_, 4))
print('test :', round(roc_auc_score(y_te, rejilla_bosque.predict_proba(X_te)[:, 1]), 4))
mejor: {'mod__max_depth': 8, 'mod__min_samples_leaf': 10}
CV   : 0.693
test : 0.711

Doce combinaciones, y el mejor bosque saca 0,693 en validación cruzada contra el 0,7056 de la logística sin ajustar.

El modelo complicado, ajustado, sigue perdiendo contra el simple sin ajustar. Es el capítulo 13 confirmado con el método correcto esta vez 🥇

Ejercicios

Siete. Intenta antes de abrir 💛

1. Validación cruzada por cliente

Del capítulo 12: repite la validación cruzada sin que un cliente caiga en los dos lados.

from sklearn.model_selection import GroupKFold

por_cliente = cross_val_score(arma(), X, y, groups=datos['cliente_id'],
                              cv=GroupKFold(n_splits=5), scoring='roc_auc')
print('por cliente:', np.round(por_cliente, 4), 'media', round(por_cliente.mean(), 4))
print('normal    :', round(notas.mean(), 4))
por cliente: [0.7319 0.6732 0.7241 0.7283 0.6919] media 0.7099
normal    : 0.7056

0,7099 contra 0,7056: prácticamente lo mismo, e incluso un poquito más alto.

Y esto matiza el capítulo 12, donde con una sola partición la diferencia salía de casi cuatro puntos. Con cinco particiones, esa diferencia se disuelve: era ruido de una partición concreta, no un efecto.

Me parece importante dejarlo escrito así, porque es exactamente el error que este capítulo enseña a no cometer 🎲

2. Cuántos trozos

Compara 3, 5 y 10 particiones.

for k in (3, 5, 10):
    n = cross_val_score(arma(), X, y, scoring='roc_auc',
                        cv=StratifiedKFold(k, shuffle=True, random_state=42))
    print(f'{k:2d} trozos  media {n.mean():.4f}  desviación {n.std():.4f}')
 3 trozos  media 0.7056  desviación 0.0085
 5 trozos  media 0.7056  desviación 0.0133
10 trozos  media 0.7081  desviación 0.0188

Las medias se parecen y la desviación sube con más trozos, porque cada examen tiene menos filas y es más variable.

Cinco es el estándar y está bien. Diez cuando tienes pocos datos y quieres entrenar con más en cada vuelta; tres cuando el modelo tarda mucho ⏱️

3. Ajustar también la preparación

Deja que la búsqueda elija cómo rellenar los huecos.

rej3 = GridSearchCV(
    arma(),
    {'pre__num__r__strategy': ['median', 'mean', 'most_frequent'],
     'mod__C': [0.1, 1]},
    cv=particion, scoring='roc_auc', n_jobs=-1)
rej3.fit(X_tr, y_tr)
print('mejor:', rej3.best_params_)
print('CV   :', round(rej3.best_score_, 4))
mejor: {'mod__C': 0.1, 'pre__num__r__strategy': 'most_frequent'}
CV   : 0.7001

Ese pre__num__r__strategy se lee de fuera hacia dentro: el paso pre, dentro el bloque num, dentro el paso r, y su parámetro strategy.

Esto es lo que el pipeline del capítulo 11 hace posible. Sin él, probar tres formas de imputar dentro de una validación cruzada honesta es imposible de escribir 🔧

4. Búsqueda al azar cuando la rejilla es grande

Con muchos parámetros, probarlos todos no cabe. Prueba 10 combinaciones al azar.

from sklearn.model_selection import RandomizedSearchCV

rej4 = RandomizedSearchCV(
    arma(RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)),
    {'mod__max_depth': [3, 5, 8, 12, None],
     'mod__min_samples_leaf': [1, 5, 10, 20, 30],
     'mod__max_features': ['sqrt', 'log2', 0.5]},
    n_iter=10, cv=particion, scoring='roc_auc', random_state=42, n_jobs=-1)
rej4.fit(X_tr, y_tr)
print('probó 10 de', 5 * 5 * 3, 'combinaciones')
print('mejor CV:', round(rej4.best_score_, 4))
probó 10 de 75 combinaciones
mejor CV: 0.692

Diez de setenta y cinco, y llega casi al mismo sitio que la rejilla completa. Con muchos parámetros la búsqueda al azar es mejor idea que la exhaustiva, porque casi siempre solo unos pocos parámetros importan y al azar los explora más 🎲

5. El error de buscar y medir en el mismo sitio

Compara el mejor resultado de la búsqueda con el resultado en el examen de verdad.

print('lo que dice la búsqueda:', round(rejilla_bosque.best_score_, 4))
print('lo que da en el examen :', round(roc_auc_score(y_te,
      rejilla_bosque.predict_proba(X_te)[:, 1]), 4))
lo que dice la búsqueda: 0.693
lo que da en el examen : 0.711

Aquí el examen sale mejor que la búsqueda (0,711 contra 0,693), y eso es normal: la búsqueda entrena con cuatro quintos de los datos y el modelo final con todos.

Lo que hay que vigilar es el caso contrario. Si el examen sale bastante peor que la búsqueda, probaste demasiadas combinaciones y una de ellas acertó por casualidad. Con doce como aquí no pasa; con quinientas, sí 🎯

6. El error del doble guion bajo

Pásale a la rejilla un parámetro escrito sin el prefijo del paso.

GridSearchCV(arma(), {'C': [0.1, 1]}, cv=3).fit(X_tr, y_tr)
ValueError: Invalid parameter 'C' for estimator Pipeline(steps=[('pre',
                 ColumnTransformer(transformers=[('num',
                                                  Pipeline(steps=[('r',
                                                                   SimpleImputer(strategy='median')),
                                                                  ('e',
                                                                   StandardScaler())]),
                                                  ['unidades', 'monto',
                                                   'descuento', 'satisfaccion',
                                                   'precio_unitario',
                                                   'sin_compra_previa',
                                                   'sin_descuento',
                                                   'sin_satisfaccion',
                                                   'visita_numero']),
                                                 ('cat',
                                                  Pipeline(steps=[('r',
                                                                   SimpleImputer(strategy='most_frequent')),
                                                                  ('c',
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  ['ciudad', 'segmento',
                                                   'canal', 'categoria'])])),
                ('mod', LogisticRegression(max_iter=1000, random_state=42))]). Valid parameters are: ['memory', 'steps', 'transform_input', 'verbose'].

Y el mensaje es de los buenos: te dice que C no es un parámetro del pipeline y encima te lista los que sí valen.

Cuando no te acuerdes de cómo se llama algo, arma().get_params() te devuelve la lista completa 🔑

7. El resultado final, escrito como se debe

Junta todo lo del capítulo en el párrafo que iría en el informe.

final = cross_val_score(arma(), X, y, cv=particion, scoring='roc_auc')
print(f'AUC {final.mean():.4f} ± {final.std():.4f} '
      f'(validación cruzada de 5, {len(X)} filas)')
print(f'rango de las 5: {final.min():.4f} a {final.max():.4f}')
print(f'una sola partición habría dado entre {min(aucs):.4f} y {max(aucs):.4f}')
AUC 0.7056 ± 0.0133 (validación cruzada de 5, 3000 filas)
rango de las 5: 0.6920 a 0.7291
una sola partición habría dado entre 0.6870 y 0.7419

Esas tres líneas son lo que separa un número de un resultado.

Cualquiera que lea eso sabe cuánto puede confiar, y sobre todo sabe que una mejora de dos décimas no es una mejora. Es la frase que más discusiones ahorra en una reunión de datos 💛

Comprueba que lo tienes

El mismo modelo da AUC de 0,66 a 0,74 según cómo partas los datos. ¿Cuál reportas?

  • El promedio de varias particiones, con su variación al lado
  • El mejor, porque demuestra de lo que es capaz
  • El peor, para ser conservador
  • El de la partición con random_state=42

Lo que te llevas

  • 🎲 Diez semillas dan de 0,6870 a 0,7419. Un solo train_test_split no es un resultado.
  • 📐 La validación cruzada da 0,7056 ± 0,0133, y la desviación se reporta siempre.
  • 📊 Una diferencia menor que la desviación no es una diferencia.
  • 📈 La curva de aprendizaje contesta si faltan datos o falta modelo. Aquí el hueco se cierra de 0,1377 a 0,0167 y la validación todavía sube.
  • 🔧 Ajustar la logística movió once diezmilésimas. Limpiar, construir columnas y elegir el umbral rinden mucho más.
  • 🥇 El bosque ajustado (0,693) sigue perdiendo contra la logística sin ajustar (0,7056).
  • 🔍 Con cinco particiones, la diferencia entre partir por fila y por cliente se disuelve: era ruido de una partición.

En el capítulo 18 vamos a la clase rara: qué hacer cuando lo que te importa pasa el 5% de las veces.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?