Lección 5.5.3 · 70 min
Gradient boosting: XGBoost, LightGBM y CatBoost
Las tres librerías de gradient boosting son los caballos de batalla de los datos tabulares. Esta lección las pone a resolver el mismo problema, con la misma configuración y resultados reproducibles, sobre el panel sintético del nivel: elige el número de árboles en una validación purgada, prueba el sector como variable categórica y las compara con la baseline Ridge, que en un problema casi lineal no deberían batir por mucho.
- Módulo
- 5.5 · Modelos clásicos
- Duración
- 70 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
Boosting: árboles pequeños que corrigen a los anteriores
Un bosque (lección 5.5.2) promedia árboles independientes para bajar su varianza. El gradient boosting hace otra cosa: construye los árboles uno detrás de otro y cada uno corrige lo que los anteriores hacen mal. Con la pérdida y un modelo inicial (la media de la etiqueta), en cada paso :
- se calcula el gradiente negativo de la pérdida en cada fila, (con error cuadrático, el residuo );
- se ajusta un árbol pequeño a esos ;
- se suma con un peso pequeño, la tasa de aprendizaje : , y se repite hasta árboles.
Fuente Mermaid
flowchart TB F0["F0 = media"] -->|residuos| T1[Árbol 1] T1 --> F1["F1 = F0 + η·T1"] F1 -->|residuos| T2[Árbol 2] T2 --> F2["F2 = F1 + η·T2"]
La formulación es de Friedman (2001). XGBoost (Chen y Guestrin, 2016) añadió una aproximación de segundo orden de la pérdida con regularización L2 en los valores de las hojas y un algoritmo escalable; LightGBM (Ke et al., 2017), el crecimiento por hojas y el muestreo de los gradientes; CatBoost (Prokhorenkova et al., 2018), árboles simétricos y un tratamiento de las categóricas pensado para evitar una fuga del objetivo. Las tres usan histogramas: discretizan cada feature en unos cientos de cubos y buscan cortes solo entre cubos, lo que hace el ajuste rápido con cientos de miles de filas.
En datos tabulares de muchos dominios, los árboles con boosting siguen ganando a las redes neuronales en los bancos de pruebas de tamaño medio (Grinsztajn, Oyallon y Varoquaux, 2022). En finanzas la pregunta relevante es otra: si ganan a un modelo lineal con buenas features. En datos reales de acciones de EE. UU., Gu, Kelly y Xiu (2020) encuentran que árboles y redes superan a las regresiones penalizadas y atribuyen la ventaja a interacciones no lineales entre predictores. Si la señal es casi lineal, como en el panel de este nivel, no hay nada que ganar y sí varianza que pagar. Esta lección lo mide.
El mismo problema para los tres
Los datos son el panel sintético del nivel (400 acciones ficticias, 21 features semanales, verdad publicada en
public/curso/datos/nivel-5/README.md; nada de lo que sale aquí dice nada de mercados reales), con features y
etiqueta en rangos normales de cada semana como en la lección 5.5.2. Hacen falta tres particiones con purga de una
semana entre ellas, porque la etiqueta de la última semana de cada tramo acaba en el siguiente (lección 5.4.2):
- Entrenamiento 2012-2016 (259 semanas): para ajustar con distintos números de árboles.
- Validación 2017-2018 (103 semanas): para elegir el número de árboles. Nunca se usa para medir el resultado final.
- Prueba 2019-2025 (365 semanas): una sola vez, con el modelo reentrenado en 2012-2018.
import numpy as np
import pandas as pd
from scipy.stats import norm
from sklearn.linear_model import Ridge
f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet")
d = (f.merge(e[["fecha", "ticker", "ret_sig_semana"]],
on=["fecha", "ticker"])
.dropna(subset=["ret_sig_semana"])
.reset_index(drop=True))
FE = [c for c in f.columns[3:] if c != "revision_analistas"]
def g(s):
"""Rango normal de cada semana (lección 5.2.4); sin dato, 0."""
por = s.groupby(d["fecha"])
u = (por.rank() - 0.5) / por.transform("count")
return pd.Series(norm.ppf(u), index=s.index).fillna(0.0)
def ic(score, mask):
"""IC de rangos de cada semana entre score y la etiqueta."""
t = pd.DataFrame({"s": score, "f": sem[mask],
"y": d.loc[mask, "ret_sig_semana"].to_numpy()})
t[["s", "y"]] = t.groupby("f")[["s", "y"]].rank()
return t.groupby("f")[["s", "y"]].corr().xs("s", level=1)["y"]
X = pd.DataFrame({k: g(d[k]) for k in FE}).astype("float32")
X["sector"] = d["sector"].astype("category")
yr = g(d["ret_sig_semana"]).to_numpy()
sem = d["fecha"].to_numpy()
fe = lambda t: np.datetime64(t)
ent = sem < fe("2016-12-26") # 2012-2016
val = (sem >= fe("2017-01-06")) & (sem < fe("2018-12-24"))
tr_todo = sem < fe("2018-12-24") # 2012-2018
te = sem >= fe("2019-01-01") # 2019-2025
print({k: len(np.unique(sem[m])) for k, m in
[("ent", ent), ("val", val), ("prueba", te)]})
ridge = Ridge(alpha=10.0)
ic_val_r = ic(ridge.fit(X.loc[ent, FE], yr[ent])
.predict(X.loc[val, FE]), val).mean()
ic_r = ic(ridge.fit(X.loc[tr_todo, FE], yr[tr_todo])
.predict(X.loc[te, FE]), te)
print(f"Ridge: IC validación {ic_val_r:.4f} prueba {ic_r.mean():.4f} "
f"± {ic_r.std() / np.sqrt(len(ic_r)):.4f}"){'ent': 259, 'val': 103, 'prueba': 365}
Ridge: IC validación 0.0155 prueba 0.0180 ± 0.0045La baseline: Ridge con α = 10 (con 145 000 filas es, en la práctica, MCO; lección 5.5.1) da un IC de 0,0155 en la
validación de 2017-2018 entrenando con 2012-2016, y de 0,0180 ± 0,0045 en la prueba de 2019-2025 entrenando con
2012-2018. El sector se guarda como category de pandas: así lo reconocen XGBoost y LightGBM como categórica.
Una configuración, tres dialectos
Cada librería llama de una forma distinta a los mismos controles, y algunos no tienen equivalente exacto. Esta es la traducción de la configuración de la lección:
| Control | XGBoost 3.4 | LightGBM 4.7 | CatBoost 1.2 | Aquí |
|---|---|---|---|---|
| Número de árboles | n_estimators | n_estimators | iterations | hasta 800 |
| Tasa de aprendizaje | learning_rate | learning_rate | learning_rate | 0,02 |
| Tamaño del árbol | max_depth | num_leaves y max_depth | depth | 2 (4 hojas) |
| Filas mínimas por hoja | min_child_weight (suma de hessianos: filas, con error cuadrático) | min_child_samples | min_data_in_leaf (no con árboles simétricos) | 3 000 |
| Submuestreo de filas | subsample | subsample y subsample_freq | subsample con bootstrap_type="Bernoulli" | 0,5 |
| Features candidatas | colsample_bytree | colsample_bytree | rsm | 0,8 |
| L2 de las hojas | reg_lambda | reg_lambda | l2_leaf_reg | 10 |
| Crecimiento | por niveles (depthwise) | por hojas (la de mayor ganancia) | árboles simétricos | |
| Categóricas | enable_categorical=True y dtype category | dtype category o categorical_feature | cat_features | sector |
| Determinismo | random_state, n_jobs | random_state, n_jobs, deterministic, force_row_wise | random_seed, thread_count | 0 y 4 hilos |
Tres diferencias de fondo. Crecimiento: XGBoost parte todos los nodos de un nivel antes de pasar al siguiente;
LightGBM parte la hoja con mayor reducción de pérdida, esté donde esté, y por eso su tamaño se controla con
num_leaves (con max_depth como freno); CatBoost usa el mismo corte en todos los nodos de un nivel (árboles
simétricos u oblivious), muy regularizados y rápidos de evaluar. Con profundidad 2 los tres árboles tienen como
mucho cuatro hojas, pero no son iguales: el de CatBoost usa una sola pregunta por nivel. Hoja mínima: CatBoost no
la admite con árboles simétricos; su regularización viene de la simetría y de l2_leaf_reg. Categóricas: cada
una las trata a su manera (sección «El sector como variable categórica»).
¿Por qué profundidad 2, hojas de 3 000 filas y tasa 0,02? Por lo que enseñó la lección 5.5.2: en este panel, los árboles útiles son diminutos y promedian muchas filas. Un booster con los valores por defecto (profundidad 6 en XGBoost y CatBoost, 31 hojas en LightGBM, tasa 0,1 a 0,3) sobreajusta en pocas iteraciones.
import catboost as cb
import lightgbm as lgb
import xgboost as xgb
N, LR = 800, 0.02
def modelos(semilla=0):
return {
"xgboost": xgb.XGBRegressor(
n_estimators=N, learning_rate=LR, max_depth=2,
min_child_weight=3000, subsample=0.5, colsample_bytree=0.8,
reg_lambda=10, tree_method="hist", enable_categorical=True,
random_state=semilla, n_jobs=4),
"lightgbm": lgb.LGBMRegressor(
n_estimators=N, learning_rate=LR, max_depth=2, num_leaves=4,
min_child_samples=3000, subsample=0.5, subsample_freq=1,
colsample_bytree=0.8, reg_lambda=10, random_state=semilla,
n_jobs=4, deterministic=True, force_row_wise=True,
verbose=-1),
"catboost": cb.CatBoostRegressor(
iterations=N, learning_rate=LR, depth=2, l2_leaf_reg=10,
subsample=0.5, bootstrap_type="Bernoulli", rsm=0.8,
random_seed=semilla, thread_count=4, verbose=0,
allow_writing_files=False),
}
def predice(nombre, m, Xm, k):
"""Predicción con los k primeros árboles."""
if nombre == "xgboost":
return m.predict(Xm, iteration_range=(0, k))
if nombre == "lightgbm":
return m.predict(Xm, num_iteration=k)
return m.predict(Xm, ntree_end=k)
K = [25, 50, 100, 200, 400, 800]
ajustados, curva = {}, {}
for nombre, m in modelos().items():
ajustados[nombre] = m.fit(X.loc[ent, FE], yr[ent])
curva[nombre] = [ic(predice(nombre, m, X.loc[val, FE], k),
val).mean() for k in K]
pd.DataFrame(curva, index=K).round(4) xgboost lightgbm catboost
25 0.0098 0.0077 0.0058
50 0.0089 0.0073 0.0049
100 0.0083 0.0075 0.0069
200 0.0077 0.0070 0.0088
400 0.0055 0.0063 0.0095
800 0.0035 0.0041 0.0086verbose=-1 en LightGBM, verbose=0 y allow_writing_files=False en CatBoost (si no, crea una carpeta
catboost_info/ con los registros del entrenamiento) mantienen la salida limpia. Cada librería tiene su forma de
predecir con los primeros árboles de un modelo ya entrenado (iteration_range, num_iteration, ntree_end):
así se obtiene la curva entera con un solo ajuste.
La figura dibuja la tabla. XGBoost y LightGBM dan su mejor IC de validación con 25 árboles, el borde inferior de la rejilla (el óptimo podría estar aún más abajo), y empeoran con cada árbol añadido: de 0,0098 a 0,0035 y de 0,0077 a 0,0041. CatBoost, con árboles simétricos más rígidos, sube hasta 400 árboles (0,0095) y baja poco después. Los tres quedan por debajo de Ridge (0,0155) en todo el recorrido. A diferencia del bosque de la lección 5.5.2, el boosting sí sobreajusta al añadir árboles: cada uno se ajusta a los residuos de los anteriores, y con esta etiqueta los residuos son casi todo ruido.
La parada temprana mira la pérdida, no el IC
Las tres librerías tienen parada temprana: se les da un conjunto de validación, evalúan la pérdida tras cada árbol y paran cuando lleva iteraciones sin mejorar. Es cómodo, y hay que saber qué mide.
lg = lgb.LGBMRegressor(**modelos()["lightgbm"].get_params())
lg.fit(X.loc[ent, FE], yr[ent], eval_X=(X.loc[val, FE],),
eval_y=(yr[val],),
callbacks=[lgb.early_stopping(100, verbose=False)])
print("mejor iteración según el error cuadrático:", lg.best_iteration_)mejor iteración según el error cuadrático: 14Con el error cuadrático de validación, LightGBM se queda en 14 árboles. No es lo mismo que el máximo del IC: el error
cuadrático castiga la escala de las predicciones, y con una señal tan débil cualquier predicción con varianza lo
empeora aunque ordene algo mejor; una cartera por deciles solo usa el orden. Por eso la celda anterior calcula el IC
de validación, semana a semana, para cada número de árboles. Se podría pasar una métrica propia a la parada temprana
(un eval_metric que agrupe por semanas), pero la curva explícita enseña además lo plana y ruidosa que es.
Dos detalles de versión. En LightGBM 4.7, eval_set=[(X, y)] emite un aviso de obsolescencia: los conjuntos de
validación van en eval_X y eval_y. Y la validación tiene que estar purgada: si las etiquetas fueran de 21
sesiones, una validación pegada al entrenamiento compartiría cuatro semanas de etiqueta con él y la parada temprana
elegiría un modelo que memoriza (lecciones 5.4.1 y 5.4.2).
El número de árboles elegido y la prueba
Con el de mayor IC de validación, cada modelo se reentrena con todo 2012-2018 y se evalúa una sola vez en 2019-2025. La diferencia con Ridge se mide emparejada, semana a semana.
filas = []
for nombre in ajustados:
k = K[int(np.argmax(curva[nombre]))]
m = modelos()[nombre]
m.set_params(**{"iterations" if nombre == "catboost"
else "n_estimators": k})
m.fit(X.loc[tr_todo, FE], yr[tr_todo])
s = ic(m.predict(X.loc[te, FE]), te)
dif = s - ic_r
filas.append({"modelo": nombre, "k": k,
"ic_val": max(curva[nombre]), "ic_prueba": s.mean(),
"se": s.std() / np.sqrt(len(s)),
"menos_ridge": dif.mean(),
"se_dif": dif.std() / np.sqrt(len(dif))})
pd.DataFrame(filas).set_index("modelo").round(4) k ic_val ic_prueba se menos_ridge se_dif
modelo
xgboost 25 0.0098 0.0136 0.0046 -0.0044 0.0022
lightgbm 25 0.0077 0.0139 0.0047 -0.0041 0.0022
catboost 400 0.0095 0.0160 0.0046 -0.0020 0.0019La figura de la cabecera resume el resultado. XGBoost da 0,0136, LightGBM 0,0139 y CatBoost 0,0160, frente a 0,0180 de Ridge y 0,0256 del oráculo en el mismo periodo (lección 5.5.1). Las diferencias emparejadas con Ridge son negativas en los tres: −0,0044 ± 0,0022 y −0,0041 ± 0,0022 (t ≈ −2) en XGBoost y LightGBM, −0,0020 ± 0,0019 en CatBoost. Es el resultado esperable en este panel: la verdad es casi lineal en los rangos, la única interacción plantada es pequeña (lección 5.5.2) y los boosters la pagan con varianza. Con los valores por defecto de las librerías el resultado sería peor.
El sector como variable categórica
El sector es la típica variable categórica de un panel de acciones. En el panel no tiene efecto plantado: el alfa verdadero no depende de él (los sectores tienen betas distintas, pero eso no se puede predecir sin saber hacia dónde irá el mercado). Lo correcto sería que no ayudara. Las tres librerías lo tratan de forma distinta:
- LightGBM ordena las categorías de cada nodo por su gradiente medio (
sum_gradient / sum_hessian, según su documentación) y busca la mejor partición en dos grupos sobre ese orden. Con diez sectores es un corte muy flexible. - XGBoost con
enable_categorical=Truehace algo parecido: con menos categorías quemax_cat_to_onehot(4 por defecto) prueba una contra el resto; con más, reparte las categorías entre los dos hijos del nodo. - CatBoost sustituye cada categoría por estadísticas del objetivo calculadas en orden: recorre las filas en una permutación y codifica cada una solo con las etiquetas de las anteriores. Así la codificación de una fila no incluye su propia etiqueta, una fuga del objetivo que sus autores identifican y corrigen.
COL = FE + ["sector"]
k = 200
cat = {
"xgboost": xgb.XGBRegressor(**{**modelos()["xgboost"]
.get_params(), "n_estimators": k}),
"lightgbm": lgb.LGBMRegressor(**{**modelos()["lightgbm"]
.get_params(), "n_estimators": k}),
"catboost": cb.CatBoostRegressor(
**{**modelos()["catboost"].get_params(), "iterations": k},
cat_features=["sector"]),
}
filas = []
for nombre, m in cat.items():
m.fit(X.loc[ent, COL], yr[ent])
sin = ic(predice(nombre, ajustados[nombre], X.loc[val, FE], k),
val).mean()
con = ic(m.predict(X.loc[val, COL]), val).mean()
filas.append({"modelo": nombre, "ic_val_sin_sector": sin,
"ic_val_con_sector": con})
pd.DataFrame(filas).set_index("modelo").round(4) ic_val_sin_sector ic_val_con_sector
modelo
xgboost 0.0077 0.0043
lightgbm 0.0070 0.0039
catboost 0.0088 0.0077Con 200 árboles, añadir el sector baja el IC de validación de XGBoost de 0,0077 a 0,0043 y el de LightGBM de 0,0070 a 0,0039: casi la mitad. CatBoost también empeora, pero menos (de 0,0088 a 0,0077). La figura añade el resultado del ejercicio 2: el mismo LightGBM con el sector en diez columnas one-hot apenas cambia (0,0068). La celda siguiente muestra por qué.
cortes = pd.Series(cat["lightgbm"].booster_.feature_importance("split"),
index=COL)
print(f"cortes en sector: {cortes['sector']} de {cortes.sum()}")
cortes.sort_values(ascending=False).head(6)cortes en sector: 104 de 547
sector 104
ret_1s 77
ruido_5 35
mom_12_1 34
sue 31
ret_1m 27
dtype: int32El sector es la feature con más cortes del modelo, 104 de 547, por delante de ret_1s (77), el efecto más fuerte del
panel. Una partición de diez categorías elegida con el propio objetivo encuentra casi siempre algo que mejora el ajuste
del entrenamiento: con 511 maneras de partir diez sectores en dos grupos, siempre hay una que coincide con el ruido de
esas semanas. Detrás aparece ruido_5, ruido con colas gruesas, con 35 cortes. Una categórica con muchas modalidades
y sin efecto es una fuente de sobreajuste; si hay que usarla, mejor con la protección de CatBoost, en one-hot o como
estadística calculada fuera de la muestra de entrenamiento.
Determinismo y velocidad
El curso exige que cada salida sea idéntica en cada ejecución. Las tres librerías lo permiten en CPU con los ajustes de la tabla; la celda lo comprueba ajustando dos veces cada modelo.
for nombre in ["xgboost", "lightgbm", "catboost"]:
a = modelos()[nombre]
a.set_params(**{"iterations" if nombre == "catboost"
else "n_estimators": 100})
p1 = a.fit(X.loc[ent, FE], yr[ent]).predict(X.loc[val, FE])
p2 = a.fit(X.loc[ent, FE], yr[ent]).predict(X.loc[val, FE])
print(f"{nombre:9s} idénticas: {np.array_equal(p1, p2)}")xgboost idénticas: True
lightgbm idénticas: True
catboost idénticas: TrueIdénticas no significa iguales en otra máquina: con otra CPU u otro número de hilos, las sumas en coma flotante pueden
cambiar en los últimos decimales y, en un corte que está en el límite, cambiar un árbol. Por eso las salidas del curso
se redondean a lo que necesita la conclusión, y en producción se guarda el modelo entrenado en vez de reentrenarlo para
reproducir una predicción. En GPU (device="cuda" en XGBoost, task_type="GPU" en CatBoost) hay operaciones que no
garantizan el mismo orden de suma; el curso solo usa CPU.
La velocidad es fácil de medir y difícil de comparar, porque depende de la máquina, de los hilos y de la carga. El código para medirla, sin ejecutar en el curso (sus tiempos cambiarían en cada ejecución):
import time
for nombre, m in modelos().items():
t0 = time.perf_counter()
m.fit(X.loc[tr_todo, FE], yr[tr_todo])
print(f"{nombre:9s} {time.perf_counter() - t0:5.1f} s")En la máquina donde se escribió la lección (16 núcleos compartidos con otros procesos, 4 hilos por modelo), 800
árboles con 145 200 filas tardaron unos 3 s en XGBoost, 4 s en LightGBM y 4-4,5 s en CatBoost; Ridge, 0,04 s. Con
deterministic=True y force_row_wise=True LightGBM renuncia a parte de su velocidad, y con árboles de profundidad 2
la ventaja del crecimiento por hojas no aparece. Las comparaciones de velocidad publicadas usan otros datos y otros
ajustes: lo único seguro es que los tres son lo bastante rápidos para reentrenar cada semana y que Ridge es cien veces
más rápido.
Errores típicos
- Usar los valores por defecto. Profundidad 6 o 31 hojas y tasa 0,1-0,3 están pensados para problemas con señal; con rendimientos sobreajustan en pocos árboles.
- Parar con la pérdida en vez de con la métrica del objetivo. El error cuadrático eligió 14 árboles; el IC, 25. Y en cualquier caso, la validación purgada.
- Elegir el número de árboles en la prueba. La curva de 2019-2025 no se mira para decidir; para eso está la validación. Tras elegir, se reentrena con entrenamiento y validación juntos.
- Meter categóricas sin efecto. El sector se lleva más cortes que la mejor feature y baja el IC a la mitad.
- Olvidar la baseline. Un IC de 0,0160 parece bueno hasta que se ve que Ridge, sobre las mismas semanas y en una fracción del tiempo, da 0,0180.
- Reproducibilidad a medias. Sin
deterministic=Trueyforce_row_wise=Trueen LightGBM, o sinallow_writing_files=Falseen CatBoost, el resultado o el directorio de trabajo cambian en cada ejecución.
Ejercicios
- La comparación con Ridge se ha hecho con una sola partición. Repítela con un walk-forward anual de 2015 a 2025 (reentrenando cada enero con todo lo anterior y una semana de purga) para LightGBM con 25 árboles. ¿Se mantiene?
Solución
anio = pd.DatetimeIndex(sem).year.to_numpy()
p_lg = np.full(len(d), np.nan)
p_r = np.full(len(d), np.nan)
for a in range(2015, 2026):
tr = sem < fe(f"{a}-01-01") - np.timedelta64(7, "D")
t_ = anio == a
m = modelos()["lightgbm"].set_params(n_estimators=25)
p_lg[t_] = m.fit(X.loc[tr, FE], yr[tr]).predict(X.loc[t_, FE])
p_r[t_] = (Ridge(alpha=10.0).fit(X.loc[tr, FE], yr[tr])
.predict(X.loc[t_, FE]))
wf = anio >= 2015
i_lg, i_r = ic(p_lg[wf], wf), ic(p_r[wf], wf)
dif = i_lg - i_r
print(f"LightGBM {i_lg.mean():.4f} Ridge {i_r.mean():.4f} "
f"diferencia {dif.mean():+.4f} ± "
f"{dif.std() / np.sqrt(len(dif)):.4f}")LightGBM 0.0137 Ridge 0.0178 diferencia -0.0041 ± 0.0018Se mantiene: en 574 semanas de prueba, LightGBM da 0,0137 y Ridge 0,0178, una diferencia de −0,0041 ± 0,0018 (t ≈ −2,3). El número de árboles (25) se eligió con la validación de 2017-2018, que forma parte de varios entrenamientos de este walk-forward; un procedimiento completamente limpio lo elegiría dentro de cada año, como se hizo con el α de la lección 5.5.1, y eso solo podría empeorar al booster.
- Codifica el sector en diez columnas one-hot (
pd.get_dummies) y repite la medida de LightGBM con 200 árboles. ¿Por qué el one-hot hace menos daño que la categórica nativa?
Solución
uno = pd.get_dummies(d["sector"], prefix="s", dtype="float32")
Xo = pd.concat([X[FE], uno], axis=1)
m = modelos()["lightgbm"].set_params(n_estimators=200)
m.fit(Xo[ent], yr[ent])
print(f"IC validación con sector en one-hot: "
f"{ic(m.predict(Xo[val]), val).mean():.4f}")IC validación con sector en one-hot: 0.0068El IC de validación queda en 0,0068, frente a 0,0070 sin sector y 0,0039 con la categórica nativa. Con one-hot, cada corte solo puede separar un sector del resto (diez opciones en vez de 511 particiones), y una columna con un 10 % de unos rara vez gana a una feature continua con hojas de 3 000 filas como mínimo. La documentación de LightGBM dice que el one-hot es subóptimo para los árboles, y lo es cuando la categoría tiene efecto: hacen falta varios cortes para separar grupos de categorías. Cuando no lo tiene, esa limitación es justo lo que protege.
- Sin código: un compañero sustituye el Ridge de producción por un LightGBM porque «en el backtest el IC sube de 0,018 a 0,031». El LightGBM tiene 31 hojas, 1 000 árboles, tasa 0,1, el sector y la industria como categóricas, y el número de árboles se eligió con la parada temprana sobre los dos últimos años del backtest. ¿Qué revisarías?
Solución
Primero, la validación: si los dos últimos años del backtest sirvieron para la parada temprana, el modelo se eligió con los datos con los que se evalúa; hace falta una prueba que no haya tocado ninguna decisión, con purga y embargo (lecciones 5.4.2 y 5.4.3). Segundo, la configuración: 31 hojas, tasa 0,1 y 1 000 árboles sobreajustan con rendimientos en muy pocas iteraciones, y un IC de 0,031 es más del doble del de un buen modelo lineal; o el modelo encontró interacciones que existen (hay que poder nombrarlas y verlas en una dependencia parcial, lección 5.5.2) o hay una fuga. Tercero, las categóricas: sector e industria con la codificación nativa se llevan cortes sin efecto real; compararía el resultado sin ellas y con one-hot. Por último, la diferencia emparejada con Ridge semana a semana, el número de configuraciones probadas para corregir con el Deflated Sharpe (lección 5.4.4) y el backtest con costes, porque un modelo más nervioso suele rotar más.
Respuesta plegada en cada pregunta
Autoevaluación
Con tasa de aprendizaje 0,02, árboles de profundidad 2 y una validación purgada de 2017-2018, el IC de validación de XGBoost es 0,0098 con 25 árboles y 0,0035 con 800. ¿Qué indica?
- AQue con más árboles el modelo aprende la señal poco a poco
- BQue la validación tiene una fuga
- CQue hay que subir la tasa de aprendizaje
- DQue cada árbol nuevo ajusta sobre todo ruido del entrenamiento: con esta relación señal/ruido, el boosting sobreajusta enseguida y el número de árboles es un hiperparámetro que hay que validar
Ver respuesta
Respuesta D. Que cada árbol nuevo ajusta sobre todo ruido del entrenamiento: con esta relación señal/ruido, el boosting sobreajusta enseguida y el número de árboles es un hiperparámetro que hay que validar
A diferencia de un bosque, el boosting sí sobreajusta al añadir árboles: cada uno corrige los residuos de los anteriores, y los residuos de una etiqueta así son casi todo ruido. El máximo de XGBoost y LightGBM está en el borde inferior de la rejilla (25 árboles).
La parada temprana de LightGBM con el error cuadrático de validación se detiene en la iteración 14. ¿Por qué no es una buena elección por defecto aquí?
- APorque 14 árboles son pocos para cualquier problema
- BPorque LightGBM no permite parada temprana con validación
- CPorque el error cuadrático penaliza la escala de las predicciones y lo que se opera es el orden: la métrica de parada debe ser la del objetivo (el IC semanal), calculada por semanas
- DPorque el error cuadrático siempre elige demasiados árboles
Ver respuesta
Respuesta C. Porque el error cuadrático penaliza la escala de las predicciones y lo que se opera es el orden: la métrica de parada debe ser la del objetivo (el IC semanal), calculada por semanas
Con una señal tan débil, casi cualquier predicción con varianza aumenta el error cuadrático aunque ordene algo mejor las acciones. Una cartera larga-corta por deciles solo usa el orden. Por eso la lección calcula el IC de validación para cada número de árboles.
Añadir el sector (sin efecto plantado) como categórica nativa baja el IC de validación de LightGBM de 0,0070 a 0,0039 y el sector es la feature con más cortes (104 de 547). ¿Qué mecanismo lo explica?
- AEl sector tiene un efecto que el generador no documenta
- BLightGBM ordena las categorías por su gradiente medio en cada nodo y parte el conjunto en dos grupos: con diez sectores hay muchas particiones posibles y alguna siempre ajusta ruido del entrenamiento
- CEl sector está mal codificado
- DLas categóricas no se pueden usar con árboles
Ver respuesta
Respuesta B. LightGBM ordena las categorías por su gradiente medio en cada nodo y parte el conjunto en dos grupos: con diez sectores hay muchas particiones posibles y alguna siempre ajusta ruido del entrenamiento
Elegir la mejor partición de diez categorías según el propio objetivo es un ajuste muy flexible. CatBoost protege con estadísticas del objetivo calculadas en orden (baja solo de 0,0088 a 0,0077) y el one-hot lo limita a cortes de una categoría contra el resto (0,0070 a 0,0068).
En la prueba 2019-2025, los tres boosters dan IC de 0,0136 a 0,0160 frente a 0,0180 de Ridge. ¿Qué conclusión es correcta?
- AQue en este panel la señal es casi lineal en los rangos y los boosters pagan en varianza lo que no ganan en flexibilidad; en datos con interacciones que importen pueden ganar, y por eso siempre se comparan con la baseline lineal
- BQue el gradient boosting no sirve para finanzas
- CQue hay que usar más árboles
- DQue CatBoost es mejor que XGBoost en cualquier problema
Ver respuesta
Respuesta A. Que en este panel la señal es casi lineal en los rangos y los boosters pagan en varianza lo que no ganan en flexibilidad; en datos con interacciones que importen pueden ganar, y por eso siempre se comparan con la baseline lineal
La verdad del panel se explica en un 61 % con un modelo lineal en los rangos (lección 5.5.1). En datos reales de EE. UU., Gu, Kelly y Xiu (2020) atribuyen la ventaja de árboles y redes a interacciones no lineales entre predictores; si no existen o no se encuentran, la flexibilidad solo añade varianza.
¿Qué hay que fijar en LightGBM para que dos ajustes con los mismos datos den exactamente la misma predicción?
- ASolo
random_state - BNada: LightGBM siempre es determinista
- C
random_state,n_jobsfijo,deterministic=Trueyforce_row_wise=True - DHay que usar un solo hilo
Ver respuesta
Respuesta C.
random_state,n_jobsfijo,deterministic=Trueyforce_row_wise=TrueCon submuestreo hace falta la semilla; con varios hilos,
deterministic=Truey una estrategia de construcción de histogramas fija (force_row_wise). En XGBoost bastarandom_statecontree_method="hist"en CPU; en CatBoost,random_seedythread_count.- ASolo
Ideas clave
- El gradient boosting suma árboles pequeños; cada uno se ajusta al gradiente de la pérdida de los anteriores y entra multiplicado por la tasa de aprendizaje. A diferencia de un bosque, sobreajusta al añadir árboles.
- Con la misma configuración (profundidad 2, hojas de 3 000 filas, submuestreo 0,5, L2 = 10, tasa 0,02), XGBoost y LightGBM alcanzan su mejor IC de validación con 25 árboles y CatBoost con 400. Ninguno llega al de Ridge (0,0155).
- La parada temprana de serie mira la pérdida (error cuadrático: 14 árboles), no el IC. Se elige el número de árboles con la métrica del objetivo, en una validación purgada, y se reentrena con todo el entrenamiento.
- Fuera de muestra, los tres quedan por debajo de Ridge (diferencias emparejadas de −0,0044, −0,0041 y −0,0020); en un walk-forward anual, LightGBM pierde −0,0041 ± 0,0018. En un problema casi lineal, la flexibilidad cuesta varianza.
- Una categórica sin efecto empeora a los tres: LightGBM nativo de 0,0070 a 0,0039 de IC de validación; CatBoost, con estadísticas ordenadas, solo de 0,0088 a 0,0077; el one-hot apenas cambia nada.
- Determinismo: semillas, hilos fijos y, en LightGBM,
deterministic=Trueyforce_row_wise=True. Las tres librerías dan predicciones idénticas en dos ajustes.
Recursos
Documentación
Libros
- Friedman, J. H. (2001): Greedy Function Approximation: A Gradient Boosting Machine. The Annals of Statistics 29(5), 1189-1232
- Chen, T. y Guestrin, C. (2016): XGBoost: A Scalable Tree Boosting System. KDD '16, 785-794
- Ke, G. et al. (2017): LightGBM: A Highly Efficient Gradient Boosting Decision Tree. NeurIPS 30
- Prokhorenkova, L. et al. (2018): CatBoost: unbiased boosting with categorical features (arXiv:1706.09516)
- Grinsztajn, L., Oyallon, E. y Varoquaux, G. (2022): Why do tree-based models still outperform deep learning on tabular data? (arXiv:2207.08815)