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.
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:
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_leavesen 9, con 64 disponibles. Árboles diminutos. - 🧱
min_child_samplesen 200, que era el tope. O sea: ninguna hoja con menos de 200 filas. - 🐌
learning_rateen 0,0134, casi el mínimo del rango. - 🎲
colsample_bytreeen 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.
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.
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_samplesaterrizó 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.