Capítulo 17 de 25 14 secciones 24 min

XGBoost, LightGBM y Optuna: cuándo ganan y cuándo no

Los tres boosting contra la regresión logística sobre las mismas 3.000 ventas, y sesenta pruebas de Optuna para ver si alcanzan.

Un boosting entrena árboles en fila y cada uno corrige el error del anterior. En este archivo de 3.000 ventas los tres pierden de fábrica contra una regresión logística: 0,6556 XGBoost, 0,6683 LightGBM y 0,6799 HistGradientBoosting contra 0,7214. Afinar con Optuna sube el LightGBM de 0,6613 a 0,7012 en validación cruzada, y lo que elige la búsqueda es apagar el modelo: hojas de 200 filas mínimo y nueve hojas por árbol. La brecha se cierra sola con datos, de 0,1887 con 300 filas a 0,0091 con 1.800, así que lo que falta no es modelo, son filas.

XGBoost, LightGBM y CatBoost. Los tres nombres que aparecen en todas las ofertas de trabajo y en todos los hilos de LinkedIn 🌟

Este capítulo los pone a competir contra la regresión logística del capítulo 13 sobre las mismas 3.000 ventas. Y te adelanto el final porque es el motivo por el que vale la pena leerlo: los tres pierden.

Tranqui, que no es un capítulo para decirte que no los uses. Es para que sepas cuándo ganan, por qué aquí no, y cómo afinarlos cuando toque, que es la parte que casi nunca se cuenta 💜

Qué hace un boosting por dentro

Un bosque aleatorio entrena cien árboles a la vez, cada uno con un trozo distinto de los datos, y después vota. Un boosting hace lo contrario: entrena los árboles en fila, y cada uno se dedica a arreglar lo que el anterior hizo mal.

Fm(x)=Fm1(x)+νhm(x)

el modelo de esta ronda es el de la ronda anterior más un árbol nuevo, y la letra nu es el freno que decide qué porción de ese árbol se suma

Esa letra griega, la nu, es la learning_rate. Es un freno: con 0,1 solo se suma la décima parte de lo que ese árbol propone.

Y lo que aprende cada árbol nuevo no es la respuesta, es el error que queda:

hmLFm1(xi)=yipi(con log-loss)

cada árbol nuevo no aprende la respuesta sino el error que queda, y con log-loss ese error es la resta entre lo que pasó y lo que el modelo creía

Ahí está la idea entera. El primer árbol dice "los mayoristas compran". El segundo mira dónde se equivocó el primero y dice "sí, pero los mayoristas de provincia que compran poco, no". El tercero afina el resto. Y así trescientas veces 🎯

Por eso un boosting es tan potente y por eso se pasa de rosca tan fácil: si lo dejas seguir, acaba memorizando las filas raras del entrenamiento una por una.

El punto de partida

Misma preparación que en todo el libro, para que la comparación sea justa.

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold, cross_val_score, 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(modelo):
    """El mismo preprocesado de siempre, con el modelo que le pases dentro."""
    pre = ColumnTransformer([
        ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                          ('e', StandardScaler())]), NUMERICAS),
        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                          ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
    ])
    return Pipeline([('pre', pre), ('mod', modelo)])

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)
print('train', X_tr.shape, 'test', X_te.shape)
print('tasa de compra train', round(y_tr.mean(), 4), 'test', round(y_te.mean(), 4))
train (2250, 13) test (750, 13)
tasa de compra train 0.5778 test 0.5773

Los tres, de fábrica, contra la logística

Sin tocar ni un parámetro. Tal como salen de la caja.

import xgboost as xgb
import lightgbm as lgb

def auc(modelo, nombre):
    m = arma(modelo).fit(X_tr, y_tr)
    a = roc_auc_score(y_te, m.predict_proba(X_te)[:, 1])
    print(f'{nombre:24s} AUC test {a:.4f}')

auc(LogisticRegression(max_iter=1000, random_state=42), 'logistica')
auc(RandomForestClassifier(n_estimators=300, random_state=42), 'bosque')
auc(HistGradientBoostingClassifier(random_state=42), 'HistGradientBoosting')
auc(xgb.XGBClassifier(random_state=42, eval_metric='logloss'), 'xgboost')
auc(lgb.LGBMClassifier(random_state=42, verbose=-1), 'lightgbm')
logistica                AUC test 0.7214
bosque                   AUC test 0.6770
HistGradientBoosting     AUC test 0.6799
xgboost                  AUC test 0.6556
lightgbm                 AUC test 0.6683

Ahí lo tienes 😅

La regresión logística, que es de 1958 y cabe en una fórmula de una línea, le saca puntos a las tres librerías que ganan competencias.

Esto no es un accidente ni una casualidad de esta partición. Es lo que pasa casi siempre en un archivo de este tamaño, y tiene una explicación que no es "los datos son malos".

Por qué pierde el boosting aquí

Dos razones, y las dos se pueden medir.

La primera es el tamaño. Son 2.250 filas de entrenamiento. Un boosting con trescientos árboles tiene sitio de sobra para aprenderse esas 2.250 filas de memoria, y memorizar no es aprender.

La segunda es la forma de la señal. En el libro de estadística salió que lo que separa a quien compra de quien no es sobre todo el segmento y el canal, y esos empujan en línea recta: ser Mayorista sube la probabilidad y ya está. Un boosting está hecho para encontrar cosas del tipo "Mayorista sí, pero solo en la sierra, en el canal digital y con más de tres unidades". Si esa clase de reglas no está en los datos, el boosting no tiene nada que buscar y lo único que le queda es memorizar 🔍

La regresión logística no puede memorizar aunque quiera. Su límite es su defensa.

Afinar: qué es Optuna y por qué no es una rejilla

Antes de rendirse hay que afinar, que es lo justo. Un LightGBM de fábrica es un LightGBM sin configurar.

La forma antigua era GridSearchCV: le das cinco valores de cada parámetro y prueba todas las combinaciones. Con siete parámetros y cinco valores cada uno, eso son 78.125 entrenamientos, y encima probando valores que ya se sabía que iban mal.

Optuna hace otra cosa. Prueba una combinación, mira el resultado, y la siguiente la elige en función de lo que ya aprendió. Si las tasas de aprendizaje bajas van bien, insiste por ahí y deja de gastar pruebas en las altas.

import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

def objetivo(t):
    modelo = lgb.LGBMClassifier(
        random_state=42, verbose=-1,
        n_estimators=t.suggest_int('n_estimators', 50, 600),
        learning_rate=t.suggest_float('learning_rate', 0.01, 0.3, log=True),
        num_leaves=t.suggest_int('num_leaves', 4, 64),
        min_child_samples=t.suggest_int('min_child_samples', 5, 200),
        subsample=t.suggest_float('subsample', 0.5, 1.0),
        subsample_freq=1,
        colsample_bytree=t.suggest_float('colsample_bytree', 0.5, 1.0),
        reg_lambda=t.suggest_float('reg_lambda', 1e-3, 30.0, log=True))
    return cross_val_score(arma(modelo), X_tr, y_tr, cv=cv, scoring='roc_auc').mean()

estudio = optuna.create_study(direction='maximize',
                              sampler=optuna.samplers.TPESampler(seed=42))
estudio.optimize(objetivo, n_trials=60)

print('mejor CV', round(estudio.best_value, 4))
for k, v in estudio.best_params.items():
    print(' ', k, round(v, 4) if isinstance(v, float) else v)
mejor CV 0.7012
  n_estimators 439
  learning_rate 0.0134
  num_leaves 9
  min_child_samples 200
  subsample 0.6737
  colsample_bytree 0.5664
  reg_lambda 0.0123

Fíjate en dos cosas de ese código, porque las dos son la diferencia entre afinar bien y engañarse.

Uno: la función objetivo devuelve validación cruzada sobre train. No toca el test ni una vez. Si el objetivo fuera el AUC de test, sesenta pruebas buscando el máximo lo encontrarían, y ese número ya no mediría nada.

Dos: arma(modelo) mete el preprocesado dentro. Es lo del capítulo 11, y aquí importa el doble: cada una de las sesenta pruebas hace cinco particiones, o sea 300 entrenamientos, y en los 300 el imputador aprende su mediana solo del trozo que le toca.

Cuánto sube, y qué eligió

base = cross_val_score(arma(LogisticRegression(max_iter=1000, random_state=42)),
                       X_tr, y_tr, cv=cv, scoring='roc_auc')
bruto = cross_val_score(arma(lgb.LGBMClassifier(random_state=42, verbose=-1)),
                        X_tr, y_tr, cv=cv, scoring='roc_auc')
print(f'logistica        CV {base.mean():.4f} +/- {base.std():.4f}')
print(f'lightgbm defecto CV {bruto.mean():.4f} +/- {bruto.std():.4f}')
print(f'lightgbm afinado CV {estudio.best_value:.4f}')

afinado = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, subsample_freq=1,
                                  **estudio.best_params)).fit(X_tr, y_tr)
print('lightgbm afinado AUC test',
      round(roc_auc_score(y_te, afinado.predict_proba(X_te)[:, 1]), 4))

valores = [t.value for t in estudio.trials if t.value is not None]
print('peor prueba', round(min(valores), 4), 'mejor', round(max(valores), 4))
logistica        CV 0.6997 +/- 0.0165
lightgbm defecto CV 0.6613 +/- 0.0193
lightgbm afinado CV 0.7012
lightgbm afinado AUC test 0.7085
peor prueba 0.6494 mejor 0.7012

Afinar sirvió, y mucho: el LightGBM pasa de 0,66 a 0,70 en validación cruzada. Casi cuatro centésimas de AUC, que es muchísimo más de lo que sube cambiar de modelo.

Pero mira lo que eligió la búsqueda, que es la parte interesante:

  • 🍃 num_leaves en 9, con 64 disponibles. Árboles diminutos.
  • 🧱 min_child_samples en 200, que era el tope. O sea: ninguna hoja con menos de 200 filas.
  • 🐌 learning_rate en 0,0134, casi el mínimo del rango.
  • 🎲 colsample_bytree en 0,57. Cada árbol ve poco más de la mitad de las columnas.

Traducido: la búsqueda pasó sesenta pruebas descubriendo que la mejor forma de usar LightGBM en este archivo es apagarlo casi del todo 😂

Eso no es un fracaso de Optuna. Es Optuna diciéndote algo del problema, y hay que saber escucharlo: cuando la búsqueda te pide simplificar sin parar, lo que te está diciendo es que no tienes datos suficientes para lo que estás pidiendo.

El parámetro que aterriza en el borde

min_child_samples salió 200 y 200 era el tope que yo puse. Eso es una bandera roja de manual: la búsqueda quería seguir subiendo y se chocó contra una pared que puse yo, no el problema.

Así que se amplía el rango y se vuelve a buscar. Siempre.

def objetivo_ancho(t):
    modelo = lgb.LGBMClassifier(
        random_state=42, verbose=-1,
        n_estimators=t.suggest_int('n_estimators', 50, 600),
        learning_rate=t.suggest_float('learning_rate', 0.01, 0.3, log=True),
        num_leaves=t.suggest_int('num_leaves', 4, 64),
        min_child_samples=t.suggest_int('min_child_samples', 5, 600),
        subsample=t.suggest_float('subsample', 0.5, 1.0),
        subsample_freq=1,
        colsample_bytree=t.suggest_float('colsample_bytree', 0.5, 1.0),
        reg_lambda=t.suggest_float('reg_lambda', 1e-3, 30.0, log=True))
    return cross_val_score(arma(modelo), X_tr, y_tr, cv=cv, scoring='roc_auc').mean()

ancho = optuna.create_study(direction='maximize',
                            sampler=optuna.samplers.TPESampler(seed=42))
ancho.optimize(objetivo_ancho, n_trials=60)
print('mejor CV', round(ancho.best_value, 4))
print('min_child_samples', ancho.best_params['min_child_samples'])
print('num_leaves', ancho.best_params['num_leaves'])
mejor CV 0.7013
min_child_samples 276
num_leaves 33

Con sitio hasta 600, la búsqueda elige 276 y el CV se queda igual: 0,7013 contra 0,7012. La pared existía pero no apretaba, porque de 200 para arriba la cosa está plana.

Y esto también hay que decirlo tal cual: comprobar el borde costó treinta segundos y no cambió nada. Se comprueba igual, porque la vez que sí cambie algo no lleva un cartel avisando 🚩

El freno y los árboles van juntos

learning_rate y n_estimators no son dos parámetros independientes: son el mismo parámetro visto de dos formas.

M·νconstante

la tasa de aprendizaje y el número de árboles se compensan, así que bajar el freno a la mitad obliga a poner el doble de árboles para llegar al mismo sitio

Si bajas el freno a la mitad, necesitas el doble de árboles para llegar al mismo sitio. Por eso afinar los dos por separado es perder pruebas.

Lo que se hace en la práctica: fijar un learning_rate bajo, poner muchísimos árboles, y dejar que el modelo pare solo cuando deje de mejorar en un trozo de validación. Se llama early stopping.

X_aj, X_val, y_aj, y_val = train_test_split(X_tr, y_tr, test_size=0.2,
                                            random_state=42, stratify=y_tr)

pre = arma(LogisticRegression()).named_steps['pre']
A = pre.fit_transform(X_aj)
V = pre.transform(X_val)
T = pre.transform(X_te)

parada = lgb.LGBMClassifier(random_state=42, verbose=-1,
                            n_estimators=2000, learning_rate=0.05)
parada.fit(A, y_aj, eval_X=V, eval_y=y_val, eval_metric='auc',
           callbacks=[lgb.early_stopping(50, verbose=False)])
print('pedidos 2000, usados', parada.best_iteration_)
print('con parada  AUC test', round(roc_auc_score(y_te, parada.predict_proba(T)[:, 1]), 4))

entero = lgb.LGBMClassifier(random_state=42, verbose=-1,
                            n_estimators=2000, learning_rate=0.05).fit(A, y_aj)
print('los 2000    AUC test', round(roc_auc_score(y_te, entero.predict_proba(T)[:, 1]), 4))
pedidos 2000, usados 68
con parada  AUC test 0.6856
los 2000    AUC test 0.632

De dos mil árboles pedidos, usa 68. Y los otros 1.932 no es que sobren: es que hacen daño, cinco centésimas y media de AUC.

Ahí ves el boosting memorizando en directo. Cada árbol a partir del 68 está aprendiéndose filas concretas del entrenamiento que no se repiten en ningún otro sitio 📉

Ojo con el preprocesado de este bloque, que va fuera del pipeline a propósito: eval_X necesita la matriz ya transformada. Es la única vez en todo el libro que preparo los datos por fuera, y por eso el imputador aprende solo de X_aj y nunca ve X_val.

Si en tu versión de LightGBM eso da error, el nombre antiguo del parámetro es eval_set=[(V, y_val)], con las dos matrices en una tupla.

Dos curvas de AUC según cuántas ventas se usen para entrenar. La regresión logística arranca alta y sube poco. El LightGBM afinado arranca en 0,50, que es tirar una moneda, y sube deprisa hasta casi alcanzarla en 1.800 filas. El área rosa entre las dos se estrecha.
La brecha entre el boosting y la regresión logística no la cierra afinar el modelo: la cierran las filas. Con 300 ventas el LightGBM no distingue nada y con 1.800 ya casi toca a la logística.

Cuánta gente hace falta para que el boosting alcance

Si la hipótesis es que faltan datos, se mide. Se entrena con trozos cada vez más grandes y se mira si la brecha se cierra.

MEJOR = dict(n_estimators=439, learning_rate=0.0134, num_leaves=9,
             min_child_samples=200, subsample=0.6737, subsample_freq=1,
             colsample_bytree=0.5664, reg_lambda=0.0123)

print(f"{'filas':>6} {'logistica':>11} {'lightgbm':>10} {'brecha':>9}")
for n in (300, 600, 900, 1200, 1800, 2250):
    la, lb = [], []
    for semilla in range(6):
        if n < len(X_tr):
            Xs, _, ys, _ = train_test_split(X_tr, y_tr, train_size=n,
                                            random_state=semilla, stratify=y_tr)
        else:
            Xs, ys = X_tr, y_tr
        a = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(Xs, ys)
        b = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR)).fit(Xs, ys)
        la.append(roc_auc_score(y_te, a.predict_proba(X_te)[:, 1]))
        lb.append(roc_auc_score(y_te, b.predict_proba(X_te)[:, 1]))
        if n >= len(X_tr):
            break
    print(f'{n:6d} {np.mean(la):11.4f} {np.mean(lb):10.4f} {np.mean(la)-np.mean(lb):+9.4f}')
 filas   logistica   lightgbm    brecha
   300      0.6887     0.5000   +0.1887
   600      0.7038     0.6194   +0.0844
   900      0.7126     0.6611   +0.0515
  1200      0.7131     0.6848   +0.0283
  1800      0.7201     0.7110   +0.0091
  2250      0.7214     0.7084   +0.0130

Esta tabla es el capítulo entero en seis líneas 📊

Con 300 filas el LightGBM da 0,5000 clavado, que es lo mismo que tirar una moneda. Y tiene sentido: le pedimos hojas de mínimo 200 filas, así que con 300 apenas puede partir una vez. Los parámetros están afinados para 2.250.

A partir de ahí la brecha se cierra sola y sin excepciones: 0,1887 → 0,0844 → 0,0515 → 0,0283 → 0,0091. En 1.800 filas ya casi se tocan.

La última fila sube un poco, a 0,0130, y no hay que taparlo: las cinco primeras son el promedio de seis submuestras distintas y la última es una sola medición, porque con 2.250 no hay nada que submuestrear. Se está comparando un promedio con un dato suelto.

La tendencia es lo que importa, y la tendencia dice algo muy concreto: a este archivo no le falta modelo, le faltan filas. Con 10.000 ventas esta conversación sería otra.

Un error que vas a ver el primer día

xgb.XGBClassifier(random_state=42, eval_metric='logloss').fit(
    X_tr[['unidades', 'ciudad']], y_tr)
ValueError: DataFrame.dtypes for data must be int, float, bool or category. When categorical type is supplied, the experimental DMatrix parameter`enable_categorical` must be set to `True`.  Invalid columns:ciudad: str

XGBoost no sabe qué hacer con la palabra "lima". Necesita números, y por eso todo el capítulo pasa por arma(), que trae el OneHotEncoder dentro.

Es el error más común de quien viene de scikit-learn y prueba XGBoost por primera vez, y la solución no es convertir la columna a mano: es meter el modelo en el pipeline que ya tienes.

Entonces, ¿cuándo sí?

El boosting gana, y gana claro, cuando se cumplen estas cosas:

  • 📈 Hay filas. Decenas de miles para arriba. La tabla de antes lo enseña: la brecha se cierra con datos, no con parámetros.
  • 🔀 La señal tiene esquinas. Interacciones de verdad, del tipo "esto solo pasa cuando se juntan estas tres cosas".
  • 🧮 Hay muchas columnas. Cuando pasas de cincuenta o cien, el boosting elige solo cuáles mirar y la logística se ahoga.
  • ⏱️ Los datos son tabulares. Si son imágenes, audio o texto, esto no es la herramienta; eso es el libro de deep learning.

Y mi regla práctica, la que uso de verdad en proyectos: la logística es el baseline y el boosting tiene que ganárselo. Si no le gana por un margen que aguante la validación cruzada, se va la logística a producción, que se explica sola, se entrena en un segundo y no tiene siete parámetros que alguien tendrá que mantener 😌

Ejercicios

1. XGBoost afinado, a ver si mejora al LightGBM

Repite la búsqueda de Optuna con XGBoost en vez de LightGBM y compara. Los parámetros no se llaman igual.

def objetivo_xgb(t):
    modelo = xgb.XGBClassifier(
        random_state=42, eval_metric='logloss',
        n_estimators=t.suggest_int('n_estimators', 50, 600),
        learning_rate=t.suggest_float('learning_rate', 0.01, 0.3, log=True),
        max_depth=t.suggest_int('max_depth', 2, 8),
        min_child_weight=t.suggest_float('min_child_weight', 1, 50, log=True),
        subsample=t.suggest_float('subsample', 0.5, 1.0),
        colsample_bytree=t.suggest_float('colsample_bytree', 0.5, 1.0),
        reg_lambda=t.suggest_float('reg_lambda', 1e-3, 30.0, log=True))
    return cross_val_score(arma(modelo), X_tr, y_tr, cv=cv, scoring='roc_auc').mean()

ex = optuna.create_study(direction='maximize',
                         sampler=optuna.samplers.TPESampler(seed=42))
ex.optimize(objetivo_xgb, n_trials=60)
print('xgboost afinado CV', round(ex.best_value, 4))
print('max_depth', ex.best_params['max_depth'])
mx = arma(xgb.XGBClassifier(random_state=42, eval_metric='logloss',
                            **ex.best_params)).fit(X_tr, y_tr)
print('xgboost afinado AUC test',
      round(roc_auc_score(y_te, mx.predict_proba(X_te)[:, 1]), 4))
xgboost afinado CV 0.7023
max_depth 2
xgboost afinado AUC test 0.7061

Misma película con otro actor. max_depth sale 2, o sea árboles de un solo corte: la búsqueda vuelve a pedir simplificar.

Y aquí hay un matiz que merece la pena mirar despacio. En validación cruzada el XGBoost afinado saca 0,7023, que está por encima del 0,7012 del LightGBM y también del 0,6997 de la logística. En test saca 0,7061 y vuelve a quedarse debajo del 0,7214.

O sea que ganar en CV por 0,0026 no garantizó ganar en test. Esa distancia es más pequeña que la desviación de la propia CV, que era 0,0165: se está celebrando ruido 🎲

Que las dos librerías, buscando por su cuenta y con parámetros que ni se llaman igual, terminen las dos pidiendo árboles diminutos es la mejor prueba de que el techo no es del algoritmo 🧱

2. Cuántas pruebas hacen falta de verdad

Sesenta pruebas fue un número que puse yo. Mira en cuál apareció el mejor resultado y cuánto aportaron las siguientes.

mejor = -1
historia = []
for i, t in enumerate(estudio.trials, 1):
    if t.value is not None and t.value > mejor:
        mejor = t.value
        historia.append((i, round(mejor, 4)))
print('cada vez que se batio el record:')
for i, v in historia:
    print(f'  prueba {i:2d}  CV {v}')
print('records totales:', len(historia))
cada vez que se batio el record:
  prueba  1  CV 0.6696
  prueba  3  CV 0.6974
  prueba 12  CV 0.6984
  prueba 16  CV 0.7003
  prueba 17  CV 0.7006
  prueba 28  CV 0.7012
records totales: 6

Seis récords en sesenta pruebas, y el último en la 28. Las 32 siguientes no mejoraron nada: la mitad del presupuesto sobró.

Y fíjate en la 3, que ya llegó a 0,6974. Las 57 pruebas restantes sirvieron para arañar 0,0038 más. Casi todo lo que da una búsqueda lo da al principio 📉

Con Optuna esto se automatiza con un pruner, que corta una prueba a medias cuando ya se ve que va peor que las anteriores. En archivos como este no hace falta, pero cuando cada entrenamiento tarda diez minutos, cambia el día ⏳

3. Afinar mirando el test, para ver cuánto engaña

Haz justo lo que no hay que hacer: que la función objetivo devuelva el AUC de test. Después mide cuánto se infló.

def objetivo_trampa(t):
    modelo = lgb.LGBMClassifier(
        random_state=42, verbose=-1,
        n_estimators=t.suggest_int('n_estimators', 50, 600),
        learning_rate=t.suggest_float('learning_rate', 0.01, 0.3, log=True),
        num_leaves=t.suggest_int('num_leaves', 4, 64),
        min_child_samples=t.suggest_int('min_child_samples', 5, 200),
        subsample=t.suggest_float('subsample', 0.5, 1.0),
        subsample_freq=1,
        colsample_bytree=t.suggest_float('colsample_bytree', 0.5, 1.0),
        reg_lambda=t.suggest_float('reg_lambda', 1e-3, 30.0, log=True))
    m = arma(modelo).fit(X_tr, y_tr)
    return roc_auc_score(y_te, m.predict_proba(X_te)[:, 1])

trampa = optuna.create_study(direction='maximize',
                             sampler=optuna.samplers.TPESampler(seed=42))
trampa.optimize(objetivo_trampa, n_trials=60)
ganador = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, subsample_freq=1,
                                  **trampa.best_params))
print('AUC test del ganador', round(trampa.best_value, 4))
print('su CV honesta       ',
      round(cross_val_score(ganador, X_tr, y_tr, cv=cv, scoring='roc_auc').mean(), 4))

X_elige, X_final, y_elige, y_final = train_test_split(
    X_te, y_te, test_size=0.5, random_state=7, stratify=y_te)
ganador.fit(X_tr, y_tr)
print('en la mitad que use para elegir',
      round(roc_auc_score(y_elige, ganador.predict_proba(X_elige)[:, 1]), 4))
print('en la mitad que nunca vio     ',
      round(roc_auc_score(y_final, ganador.predict_proba(X_final)[:, 1]), 4))
AUC test del ganador 0.7136
su CV honesta        0.6991
en la mitad que use para elegir 0.7142
en la mitad que nunca vio      0.7115

Y aquí toca contar el resultado tal como salió, aunque no sea el que esperaba: la trampa apenas infló nada. 0,7142 en la mitad que usé para elegir contra 0,7115 en la que nunca vio. Veintisiete diezmilésimas.

La lección no es "entonces se puede afinar contra el test". Es esta: con sesenta pruebas el sobreajuste fue pequeño, con seiscientas sería grande, y desde dentro no hay forma de saber en cuál de los dos casos estás. La única defensa es no mirar 🙈

Fíjate además en la CV honesta del ganador: 0,6991, por debajo del 0,7012 que salió afinando bien. Optimizar el test le costó puntos en todo lo demás.

4. Los mismos parámetros, otras cinco particiones

El capítulo 16 dijo que una sola partición es una sola tirada de dados. Comprueba si la ventaja de la logística aguanta cambiando el sorteo.

filas = []
for semilla in (0, 1, 7, 42, 2024):
    Xa, Xb, ya, yb = train_test_split(X, y, test_size=0.25,
                                      random_state=semilla, stratify=y)
    log = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(Xa, ya)
    gbm = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR)).fit(Xa, ya)
    a = roc_auc_score(yb, log.predict_proba(Xb)[:, 1])
    b = roc_auc_score(yb, gbm.predict_proba(Xb)[:, 1])
    filas.append({'semilla': semilla, 'logistica': round(a, 4),
                  'lightgbm': round(b, 4), 'gana': 'log' if a > b else 'gbm'})
tabla = pd.DataFrame(filas)
print(tabla.to_string(index=False))
print('\ngana la logistica en', (tabla['gana'] == 'log').sum(), 'de 5')
 semilla  logistica  lightgbm gana
       0     0.7105    0.7060  log
       1     0.7091    0.7127  gbm
       7     0.7124    0.7003  log
      42     0.7214    0.7084  log
    2024     0.6900    0.6899  log

gana la logistica en 4 de 5

Un resultado que solo aparece con random_state=42 no es un resultado. Este aguanta cuatro de cinco, y por eso me atrevo a escribirlo en un libro 💪

Pero mira las dos que casi se caen. Con la semilla 1 gana el LightGBM, y con la 2024 la logística gana por 0,0001, que es empatar. Así que la frase correcta no es "la logística gana", es "la logística no pierde, y cuesta la centésima parte".

Esa diferencia de matiz es la que separa un informe que aguanta preguntas de uno que no 🎯

5. Qué mira cada uno

La logística y el boosting sacan notas parecidas. Mira si además miran lo mismo.

from sklearn.inspection import permutation_importance

log = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(X_tr, y_tr)
gbm = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR)).fit(X_tr, y_tr)

def top(modelo, nombre):
    r = permutation_importance(modelo, X_te, y_te, n_repeats=10,
                               random_state=42, scoring='roc_auc')
    s = pd.Series(r.importances_mean, index=X_te.columns).sort_values(ascending=False)
    print(f'--- {nombre} ---')
    print(s.head(5).round(4).to_string())

top(log, 'logistica')
top(gbm, 'lightgbm afinado')
--- logistica ---
segmento             0.0909
satisfaccion         0.0464
sin_compra_previa    0.0361
canal                0.0334
monto                0.0067
--- lightgbm afinado ---
monto                0.0394
satisfaccion         0.0360
sin_compra_previa    0.0322
canal                0.0234
segmento             0.0232

Aquí sale algo que yo no esperaba, y es de lo mejor del capítulo 👀

Las dos notas se parecen (0,7214 contra 0,7084) pero las dos listas no. Para la logística lo primero es el segmento, con 0,0909, que es el doble que lo siguiente. Para el LightGBM lo primero es monto, con 0,0394, y el segmento se le cae hasta el último puesto de los cinco.

Y monto, que para el boosting es la columna número uno, para la logística vale 0,0067. Prácticamente nada.

La explicación es la misma que salió en el capítulo 5 con la columna de ruido. monto es continua, así que un árbol puede trocearla donde quiera y sacarle escalones. La logística solo puede multiplicarla por un número, y en línea recta esa columna casi no dice nada 📐

Mira además la forma de las dos listas. La logística tiene una columna que manda y las demás detrás; el LightGBM las tiene todas apretadas entre 0,0394 y 0,0232, repartiendo la apuesta. Eso es exactamente lo que hace un modelo al que le falta señal clara: se agarra a todo un poquito.

Dos modelos con notas parecidas apoyados en columnas distintas es un dato operativo, no una curiosidad: el día que monto llegue mal de origen, uno de los dos se cae y el otro ni se entera 🔧

6. Los dos juntos, a ver si suman

Si miran cosas distintas, promediar sus probabilidades debería mejorar a los dos. Compruébalo.

p_log = log.predict_proba(X_te)[:, 1]
p_gbm = gbm.predict_proba(X_te)[:, 1]

print('correlacion entre las dos probabilidades',
      round(np.corrcoef(p_log, p_gbm)[0, 1], 4))
print('logistica ', round(roc_auc_score(y_te, p_log), 4))
print('lightgbm  ', round(roc_auc_score(y_te, p_gbm), 4))
for peso in (0.25, 0.5, 0.75):
    mezcla = peso * p_log + (1 - peso) * p_gbm
    print(f'mezcla {peso:.2f} log', round(roc_auc_score(y_te, mezcla), 4))
correlacion entre las dos probabilidades 0.9383
logistica  0.7214
lightgbm   0.7084
mezcla 0.25 log 0.7147
mezcla 0.50 log 0.7188
mezcla 0.75 log 0.7207

Esto es un ensemble, y es lo que gana las competiciones de Kaggle: juntar modelos que se equivocan en sitios distintos.

Aquí no funciona, y el motivo está en la primera línea: las dos probabilidades van correlacionadas al 0,9383. Miran columnas distintas, como acabamos de ver, pero acaban ordenando a la gente casi igual 🤝

Ninguna mezcla llega al 0,7214 de la logística sola. La mejor es la que le da el 75% del peso a la logística y saca 0,7207, o sea que cuanto menos LightGBM lleva la mezcla, mejor va. Traducido: aquí el ensemble es una forma cara de diluir el mejor modelo.

Y esa es la comprobación que hay que hacer siempre antes de montar uno. Correlación por debajo de 0,8 y la mezcla suele aportar; por encima de 0,9, casi nunca 📏

7. Lo que pesa cada modelo

Todo el capítulo habla de puntos de AUC. Mide también lo que hay que cargar en el servidor cada vez que arranca.

import os
import tempfile
import joblib

carpeta = tempfile.mkdtemp()

def pesa(modelo, nombre):
    m = arma(modelo).fit(X_tr, y_tr)
    ruta = os.path.join(carpeta, nombre.replace(' ', '_') + '.joblib')
    joblib.dump(m, ruta)
    tam = os.path.getsize(ruta)
    print(f'{nombre:20s} {tam:>8,d} bytes')
    return tam

chico = pesa(LogisticRegression(max_iter=1000, random_state=42), 'logistica')
grande = pesa(lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR), 'lightgbm afinado')
print(f'\nel boosting pesa {grande / chico:.0f} veces mas')
print('arboles que lleva dentro:', MEJOR['n_estimators'])
print('y la busqueda fueron 60 pruebas x 5 particiones =', 60 * 5, 'entrenamientos')
logistica               5,642 bytes
lightgbm afinado      349,119 bytes

el boosting pesa 62 veces mas
arboles que lleva dentro: 439
y la busqueda fueron 60 pruebas x 5 particiones = 300 entrenamientos

439 árboles ocupan 349 KB. La regresión logística son unos cuantos números y un intercepto, y ocupa 5 KB.

Sesenta y dos veces más peso para quedarse por debajo en AUC. Y esto no es una curiosidad de informática: ese archivo hay que versionarlo, guardarlo, cargarlo en cada arranque del servidor y volver a entrenarlo cada vez que los datos se muevan 💸

Medí el tamaño y no el tiempo a propósito. Los segundos dependen de tu máquina y del resto de cosas que tengas abiertas, así que no se pueden publicar en un libro donde cada salida se comprueba; los bytes salen iguales siempre 📏

Comprueba que lo tienes

Afinaste LightGBM con Optuna y el mejor valor de min_child_samples salió 200, que era justo el tope del rango que le diste. ¿Qué haces?

  • Ampliar el rango y volver a buscar, porque el tope lo pusiste tú
  • Nada: 200 es el mejor valor, la búsqueda ya lo encontró
  • Bajarlo a 100 para que el modelo tenga más libertad
  • Cambiar de modelo, porque ese parámetro no debería importar tanto

Lo que te llevas

  • 🧱 Un boosting entrena árboles en fila y cada uno arregla el error del anterior. Un bosque los entrena a la vez y vota. No es el mismo animal.
  • 🥊 De fábrica, los tres boosting pierden contra la regresión logística en este archivo: 0,6556, 0,6683 y 0,6799 contra 0,7214.
  • 🔧 Afinar con Optuna sube el LightGBM de 0,6613 a 0,7012 en validación cruzada. Casi cuatro centésimas, mucho más de lo que da cambiar de modelo.
  • 🔇 Y lo que eligió la búsqueda fue apagar el modelo: hojas de 200 filas mínimo, nueve hojas por árbol y la tasa de aprendizaje casi en el mínimo.
  • 🚩 min_child_samples aterrizó en el tope del rango, así que se amplió a 600. Eligió 276 y el CV no se movió: la pared no apretaba, pero eso no se sabe hasta comprobarlo.
  • 🛑 Early stopping usó 68 árboles de los 2.000 pedidos, y los 1.932 restantes no sobraban: costaban cinco centésimas y media de AUC.
  • 📉 La brecha se cierra sola con filas: 0,1887 con 300, 0,0091 con 1.800. A este archivo no le falta modelo, le faltan datos.
  • ⚖️ La logística es el baseline y el boosting tiene que ganárselo. Si no le gana por un margen que aguante la validación cruzada, va la logística.

Qué viene ahora

Ya tenemos varios modelos y ya sabemos cuál va a producción. Falta la pregunta que va a hacer la primera persona que reciba la lista: ¿por qué este cliente y no el otro? 🙋‍♀️

El capítulo 19 abre la caja con lo que trae scikit-learn: coeficientes, odds e importancia por permutación. Y ahí aparece una columna que medimos en doce puntos en el capítulo 4 y que al modelo no le aporta nada, lo cual tiene una explicación bonita.

Lo que verás allí:

  • 📖 Leer los coeficientes de una logística y decirlos en voz alta con exp().
  • 🔀 Importancia por permutación, que funciona con cualquier modelo, también con los tres de este capítulo.
  • 🧩 Explicar una predicción concreta sumando aportes.
  • 👯 Y por qué dos columnas que dicen lo mismo salen las dos sin importancia, que es la trampa que hace que se descarten columnas buenas.
¿Tienes alguna duda o consulta?