Lesson 5.5.3 · 70 min
Gradient boosting: XGBoost, LightGBM and CatBoost
The three gradient boosting libraries are the workhorses of tabular data. This lesson puts them to solve the same problem, with the same configuration and reproducible results, on the level's synthetic panel: it chooses the number of trees in a purged validation, tries the sector as a categorical variable and compares them with the Ridge baseline, which on an almost linear problem they should not beat by much.
- Module
- 5.5 · Classical models
- Duration
- 70 min
- Prerequisites
- 5.5.1 · Regularized regressions: Ridge, Lasso, Elastic Net and logistic, 5.5.2 · Decision trees, Random Forest and bagging, 5.4.2 · Purged k-fold with embargo and CPCV for ML models
- Author
- Jesús Cuesta
- Updated
Level 5 lessons
5.1 · ML fundamentals applied to finance
5.2 · Feature engineering
5.3 · Labelling and sampling
5.4 · Validation for financial series
5.5 · Classical models
- 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
- 5.5.2 Decision trees, Random Forest and bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
- 5.5.4 Optuna and the risk of overfitting the validation
- 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
- 5.5.6 Learning to rank for stock selection
5.6 · Deep learning with Keras 3
5.7 · Deep learning with PyTorch
- 5.7.1 PyTorch: tensors, autograd and the training loop
- 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
- 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
- 5.7.4 Graph neural networks for asset relationships
- 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
5.8 · Time series foundation models
- 5.8.1 Time series foundation models: pretrain once, forecast without training
- 5.8.2 Foundation model families: architecture, data and license
- 5.8.3 In practice: context, quantiles, covariates and fine-tuning
- 5.8.4 Foundation models in finance: what the evidence says
- 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
5.9 · Reinforcement learning
5.10 · Interpretability, robustness and model risk
Boosting: small trees that correct the previous ones
A forest (lesson 5.5.2) averages independent trees to lower their variance. Gradient boosting does something else: it builds the trees one after another and each one corrects what the previous ones get wrong. With the loss and an initial model (the mean of the label), at each step :
- the negative gradient of the loss is computed at each row, (with squared error, the residual );
- a small tree is fitted to those ;
- it is added with a small weight, the learning rate : , and the process is repeated up to trees.
Mermaid source
flowchart TB F0["F0 = mean"] -->|residuals| T1[Tree 1] T1 --> F1["F1 = F0 + η·T1"] F1 -->|residuals| T2[Tree 2] T2 --> F2["F2 = F1 + η·T2"]
The formulation is Friedman's (2001). XGBoost (Chen and Guestrin, 2016) added a second-order approximation of the loss with L2 regularization on the leaf values and a scalable algorithm; LightGBM (Ke et al., 2017), leaf-wise growth and gradient sampling; CatBoost (Prokhorenkova et al., 2018), symmetric trees and a treatment of categoricals designed to avoid target leakage. All three use histograms: they discretize each feature into a few hundred bins and search for splits only between bins, which makes fitting fast with hundreds of thousands of rows.
On tabular data from many domains, boosted trees still beat neural networks on medium-sized benchmarks (Grinsztajn, Oyallon and Varoquaux, 2022). In finance the relevant question is a different one: whether they beat a linear model with good features. On real US equity data, Gu, Kelly and Xiu (2020) find that trees and neural networks outperform penalized regressions and attribute the advantage to nonlinear interactions between predictors. If the signal is almost linear, as on this level's panel, there is nothing to gain and variance to pay. This lesson measures it.
The same problem for all three
The data are the level's synthetic panel (400 fictitious stocks, 21 weekly features, ground truth published in
public/curso/datos/nivel-5/README.md; nothing that comes out here says anything about real markets), with features and
label in normal ranks within each week as in lesson 5.5.2. Three partitions with a one-week purge between them are
needed, because the label of the last week of each stretch ends in the next one (lesson 5.4.2):
- Training 2012-2016 (259 weeks): to fit with different numbers of trees.
- Validation 2017-2018 (103 weeks): to choose the number of trees. It is never used to measure the final result.
- Test 2019-2025 (365 weeks): only once, with the model retrained on 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):
"""Normal rank within each week (lesson 5.2.4); missing, 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):
"""Rank IC of each week between score and the label."""
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
[("train", ent), ("val", val), ("test", 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: validation IC {ic_val_r:.4f} test {ic_r.mean():.4f} "
f"± {ic_r.std() / np.sqrt(len(ic_r)):.4f}"){'train': 259, 'val': 103, 'test': 365}
Ridge: validation IC 0.0155 test 0.0180 ± 0.0045The baseline: Ridge with α = 10 (with 145,000 rows it is, in practice, OLS; lesson 5.5.1) gives an IC of 0.0155 in the
2017-2018 validation training on 2012-2016, and of 0.0180 ± 0.0045 in the 2019-2025 test training on
2012-2018. The sector is stored as a pandas category: that is how XGBoost and LightGBM recognize it as categorical.
One configuration, three dialects
Each library names the same controls differently, and some have no exact equivalent. This is the translation of the lesson's configuration:
| Control | XGBoost 3.4 | LightGBM 4.7 | CatBoost 1.2 | Here |
|---|---|---|---|---|
| Number of trees | n_estimators | n_estimators | iterations | up to 800 |
| Learning rate | learning_rate | learning_rate | learning_rate | 0.02 |
| Tree size | max_depth | num_leaves and max_depth | depth | 2 (4 leaves) |
| Minimum rows per leaf | min_child_weight (sum of hessians: rows, with squared error) | min_child_samples | min_data_in_leaf (not with symmetric trees) | 3,000 |
| Row subsampling | subsample | subsample and subsample_freq | subsample with bootstrap_type="Bernoulli" | 0.5 |
| Candidate features | colsample_bytree | colsample_bytree | rsm | 0.8 |
| Leaf L2 | reg_lambda | reg_lambda | l2_leaf_reg | 10 |
| Growth | level-wise (depthwise) | leaf-wise (the one with the highest gain) | symmetric trees | |
| Categoricals | enable_categorical=True and dtype category | dtype category or categorical_feature | cat_features | sector |
| Determinism | random_state, n_jobs | random_state, n_jobs, deterministic, force_row_wise | random_seed, thread_count | 0 and 4 threads |
Three fundamental differences. Growth: XGBoost splits all the nodes of a level before moving on to the next one;
LightGBM splits the leaf with the largest loss reduction, wherever it is, and that is why its size is controlled with
num_leaves (with max_depth as a brake); CatBoost uses the same split in all the nodes of a level (symmetric
or oblivious trees), heavily regularized and fast to evaluate. With depth 2 the three trees have at
most four leaves, but they are not the same: CatBoost's uses a single question per level. Minimum leaf: CatBoost does not
support it with symmetric trees; its regularization comes from the symmetry and from l2_leaf_reg. Categoricals: each
handles them in its own way (section “The sector as a categorical variable”).
Why depth 2, 3,000-row leaves and a rate of 0.02? Because of what lesson 5.5.2 taught: on this panel, the useful trees are tiny and average many rows. A booster with the default values (depth 6 in XGBoost and CatBoost, 31 leaves in LightGBM, rate 0.1 to 0.3) overfits in a few iterations.
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):
"""Prediction with the first k trees."""
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 in LightGBM, and verbose=0 and allow_writing_files=False in CatBoost (otherwise it creates a
catboost_info/ folder with the training logs) keep the output clean. Each library has its own way of
predicting with the first trees of an already trained model (iteration_range, num_iteration, ntree_end):
that is how the whole curve is obtained with a single fit.
The figure draws the table. XGBoost and LightGBM give their best validation IC with 25 trees, the lower edge of the grid (the optimum could be even lower), and get worse with every tree added: from 0.0098 to 0.0035 and from 0.0077 to 0.0041. CatBoost, with stiffer symmetric trees, rises up to 400 trees (0.0095) and drops a little after that. All three stay below Ridge (0.0155) over the whole range. Unlike the forest of lesson 5.5.2, boosting does overfit as trees are added: each one is fitted to the residuals of the previous ones, and with this label the residuals are almost all noise.
Early stopping looks at the loss, not the IC
All three libraries have early stopping: they are given a validation set, evaluate the loss after each tree and stop when it has gone iterations without improving. It is convenient, and you need to know what it measures.
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("best iteration by squared error:", lg.best_iteration_)best iteration by squared error: 14With the validation squared error, LightGBM stops at 14 trees. That is not the same as the IC maximum: the squared
error punishes the scale of the predictions, and with such a weak signal any prediction with variance makes it
worse even if it ranks somewhat better; a decile portfolio only uses the ordering. That is why the previous cell computes the
validation IC, week by week, for each number of trees. You could pass a custom metric to early stopping
(an eval_metric that groups by week), but the explicit curve also shows how flat and noisy it is.
Two version details. In LightGBM 4.7, eval_set=[(X, y)] emits a deprecation warning: the validation sets
go in eval_X and eval_y. And the validation has to be purged: if the labels were 21-session ones,
a validation right next to the training set would share four weeks of label with it and early stopping
would choose a model that memorizes (lessons 5.4.1 and 5.4.2).
The chosen number of trees and the test
With the of highest validation IC, each model is retrained on all of 2012-2018 and evaluated only once on 2019-2025. The difference with Ridge is measured paired, week by week.
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.0019The figure at the top summarizes the result. XGBoost gives 0.0136, LightGBM 0.0139 and CatBoost 0.0160, versus 0.0180 for Ridge and 0.0256 for the oracle over the same period (lesson 5.5.1). The paired differences with Ridge are negative for all three: −0.0044 ± 0.0022 and −0.0041 ± 0.0022 (t ≈ −2) for XGBoost and LightGBM, −0.0020 ± 0.0019 for CatBoost. It is the expected result on this panel: the ground truth is almost linear in the ranks, the only planted interaction is small (lesson 5.5.2) and the boosters pay for it with variance. With the libraries' default values the result would be worse.
The sector as a categorical variable
The sector is the typical categorical variable of a stock panel. On the panel it has no planted effect: the true alpha does not depend on it (the sectors have different betas, but that cannot be predicted without knowing where the market will go). The right outcome would be for it not to help. The three libraries treat it differently:
- LightGBM sorts the categories of each node by their mean gradient (
sum_gradient / sum_hessian, according to its documentation) and searches for the best partition into two groups along that ordering. With ten sectors it is a very flexible split. - XGBoost with
enable_categorical=Truedoes something similar: with fewer categories thanmax_cat_to_onehot(4 by default) it tries one against the rest; with more, it distributes the categories between the node's two children. - CatBoost replaces each category with target statistics computed in order: it goes through the rows in a permutation and encodes each one only with the labels of the previous ones. That way the encoding of a row does not include its own label, a target leakage that its authors identify and correct.
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.0077With 200 trees, adding the sector lowers XGBoost's validation IC from 0.0077 to 0.0043 and LightGBM's from 0.0070 to 0.0039: almost by half. CatBoost also gets worse, but less (from 0.0088 to 0.0077). The figure adds the result of exercise 2: the same LightGBM with the sector in ten one-hot columns barely changes (0.0068). The next cell shows why.
cortes = pd.Series(cat["lightgbm"].booster_.feature_importance("split"),
index=COL)
print(f"splits on sector: {cortes['sector']} of {cortes.sum()}")
cortes.sort_values(ascending=False).head(6)splits on sector: 104 of 547
sector 104
ret_1s 77
ruido_5 35
mom_12_1 34
sue 31
ret_1m 27
dtype: int32The sector is the feature with the most splits in the model, 104 of 547, ahead of ret_1s (77), the strongest effect in the
panel. A partition of ten categories chosen with the objective itself almost always finds something that improves the
training fit: with 511 ways of splitting ten sectors into two groups, there is always one that matches the noise of
those weeks. Behind it appears ruido_5, fat-tailed noise, with 35 splits. A categorical with many levels
and no effect is a source of overfitting; if it has to be used, it is better with CatBoost's protection, as one-hot or as a
statistic computed outside the training sample.
Determinism and speed
The course requires every output to be identical on every run. The three libraries allow it on CPU with the settings in the table; the cell checks it by fitting each model twice.
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} identical: {np.array_equal(p1, p2)}")xgboost identical: True
lightgbm identical: True
catboost identical: TrueIdentical does not mean the same on another machine: with another CPU or another number of threads, floating-point sums can
change in the last decimals and, on a split that sits right at the limit, change a tree. That is why the course outputs
are rounded to what the conclusion needs, and in production the trained model is stored instead of retraining it to
reproduce a prediction. On GPU (device="cuda" in XGBoost, task_type="GPU" in CatBoost) there are operations that do not
guarantee the same summation order; the course only uses CPU.
Speed is easy to measure and hard to compare, because it depends on the machine, the threads and the load. The code to measure it, not run in the course (its timings would change on every run):
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")On the machine where the lesson was written (16 cores shared with other processes, 4 threads per model), 800
trees with 145,200 rows took about 3 s in XGBoost, 4 s in LightGBM and 4-4.5 s in CatBoost; Ridge, 0.04 s. With
deterministic=True and force_row_wise=True LightGBM gives up part of its speed, and with depth-2 trees
the advantage of leaf-wise growth does not show. Published speed comparisons use other data and other
settings: the only sure thing is that all three are fast enough to retrain every week and that Ridge is a hundred times
faster.
Common mistakes
- Using the default values. Depth 6 or 31 leaves and a rate of 0.1-0.3 are designed for problems with signal; on returns they overfit within a few trees.
- Stopping on the loss instead of the objective's metric. The squared error chose 14 trees; the IC, 25. And in any case, purged validation.
- Choosing the number of trees on the test. The 2019-2025 curve is not looked at to decide; that is what the validation is for. After choosing, retrain with training and validation together.
- Adding categoricals with no effect. The sector takes more splits than the best feature and halves the IC.
- Forgetting the baseline. An IC of 0.0160 looks good until you see that Ridge, over the same weeks and in a fraction of the time, gives 0.0180.
- Half-way reproducibility. Without
deterministic=Trueandforce_row_wise=Truein LightGBM, or withoutallow_writing_files=Falsein CatBoost, the result or the working directory changes on every run.
Exercises
- The comparison with Ridge was made with a single partition. Repeat it with an annual walk-forward from 2015 to 2025 (retraining every January on everything before it, with a one-week purge) for LightGBM with 25 trees. Does it hold?
Solution
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"difference {dif.mean():+.4f} ± "
f"{dif.std() / np.sqrt(len(dif)):.4f}")LightGBM 0.0137 Ridge 0.0178 difference -0.0041 ± 0.0018It holds: over 574 test weeks, LightGBM gives 0.0137 and Ridge 0.0178, a difference of −0.0041 ± 0.0018 (t ≈ −2.3). The number of trees (25) was chosen with the 2017-2018 validation, which is part of several training sets of this walk-forward; a completely clean procedure would choose it inside each year, as was done with the α of lesson 5.5.1, and that could only make the booster worse.
- Encode the sector in ten one-hot columns (
pd.get_dummies) and repeat the LightGBM measurement with 200 trees. Why does one-hot do less damage than the native categorical?
Solution
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"validation IC with sector as one-hot: "
f"{ic(m.predict(Xo[val]), val).mean():.4f}")validation IC with sector as one-hot: 0.0068The validation IC stays at 0.0068, versus 0.0070 without the sector and 0.0039 with the native categorical. With one-hot, each split can only separate one sector from the rest (ten options instead of 511 partitions), and a column with 10% ones rarely beats a continuous feature with leaves of at least 3,000 rows. The LightGBM documentation says that one-hot is suboptimal for trees, and it is when the category has an effect: several splits are needed to separate groups of categories. When it has none, that limitation is exactly what protects.
- No code: a colleague replaces the production Ridge with a LightGBM because “in the backtest the IC rises from 0.018 to 0.031”. The LightGBM has 31 leaves, 1,000 trees, a rate of 0.1, the sector and the industry as categoricals, and the number of trees was chosen with early stopping on the last two years of the backtest. What would you check?
Solution
First, the validation: if the last two years of the backtest were used for early stopping, the model was chosen with the data it is evaluated on; a test that no decision has touched is needed, with purging and an embargo (lessons 5.4.2 and 5.4.3). Second, the configuration: 31 leaves, a rate of 0.1 and 1,000 trees overfit on returns within very few iterations, and an IC of 0.031 is more than twice that of a good linear model; either the model found interactions that exist (you must be able to name them and see them in a partial dependence, lesson 5.5.2) or there is a leak. Third, the categoricals: sector and industry with native encoding take splits with no real effect; I would compare the result without them and with one-hot. Finally, the paired difference with Ridge week by week, the number of configurations tried to correct with the Deflated Sharpe (lesson 5.4.4) and the backtest with costs, because a more nervous model usually trades more.
Each answer is folded under its question
Self-assessment
With a learning rate of 0.02, depth-2 trees and a purged 2017-2018 validation, the validation IC of XGBoost is 0.0098 with 25 trees and 0.0035 with 800. What does it indicate?
- AThat with more trees the model learns the signal little by little
- BThat the validation has a leak
- CThat the learning rate has to be raised
- DThat each new tree mostly fits training noise: with this signal-to-noise ratio, boosting overfits right away and the number of trees is a hyperparameter that has to be validated
Show answer
Answer D. That each new tree mostly fits training noise: with this signal-to-noise ratio, boosting overfits right away and the number of trees is a hyperparameter that has to be validated
Unlike a forest, boosting does overfit when trees are added: each one corrects the residuals of the previous ones, and the residuals of such a label are almost all noise. The maximum of XGBoost and LightGBM is at the lower edge of the grid (25 trees).
LightGBM early stopping with the validation squared error stops at iteration 14. Why is it not a good default choice here?
- ABecause 14 trees are too few for any problem
- BBecause LightGBM does not allow early stopping with validation
- CBecause the squared error penalizes the scale of the predictions and what is traded is the ordering: the stopping metric must be that of the objective (the weekly IC), computed by week
- DBecause the squared error always chooses too many trees
Show answer
Answer C. Because the squared error penalizes the scale of the predictions and what is traded is the ordering: the stopping metric must be that of the objective (the weekly IC), computed by week
With such a weak signal, almost any prediction with variance increases the squared error even if it ranks the stocks somewhat better. A long-short decile portfolio only uses the ordering. That is why the lesson computes the validation IC for each number of trees.
Adding the sector (with no planted effect) as a native categorical lowers the LightGBM validation IC from 0.0070 to 0.0039 and the sector is the feature with the most splits (104 of 547). What mechanism explains it?
- AThe sector has an effect that the generator does not document
- BLightGBM sorts the categories by their mean gradient in each node and splits the set into two groups: with ten sectors there are many possible partitions and one of them always fits training noise
- CThe sector is wrongly encoded
- DCategoricals cannot be used with trees
Show answer
Answer B. LightGBM sorts the categories by their mean gradient in each node and splits the set into two groups: with ten sectors there are many possible partitions and one of them always fits training noise
Choosing the best partition of ten categories according to the objective itself is a very flexible fit. CatBoost protects with target statistics computed in order (it only drops from 0.0088 to 0.0077) and one-hot limits it to splits of one category against the rest (0.0070 to 0.0068).
In the 2019-2025 test, the three boosters give ICs from 0.0136 to 0.0160 versus 0.0180 for Ridge. Which conclusion is correct?
- AThat on this panel the signal is almost linear in the ranks and the boosters pay in variance what they do not gain in flexibility; on data with interactions that matter they can win, and that is why they are always compared with the linear baseline
- BThat gradient boosting is useless for finance
- CThat more trees have to be used
- DThat CatBoost is better than XGBoost on any problem
Show answer
Answer A. That on this panel the signal is almost linear in the ranks and the boosters pay in variance what they do not gain in flexibility; on data with interactions that matter they can win, and that is why they are always compared with the linear baseline
61% of the panel's ground truth is explained by a model that is linear in the ranks (lesson 5.5.1). On real US data, Gu, Kelly and Xiu (2020) attribute the advantage of trees and neural networks to nonlinear interactions between predictors; if they do not exist or are not found, flexibility only adds variance.
What has to be fixed in LightGBM for two fits with the same data to give exactly the same prediction?
- AOnly
random_state - BNothing: LightGBM is always deterministic
- C
random_state, a fixedn_jobs,deterministic=Trueandforce_row_wise=True - DA single thread has to be used
Show answer
Answer C.
random_state, a fixedn_jobs,deterministic=Trueandforce_row_wise=TrueWith subsampling the seed is needed; with several threads,
deterministic=Trueand a fixed histogram construction strategy (force_row_wise). In XGBoostrandom_statewithtree_method="hist"on CPU is enough; in CatBoost,random_seedandthread_count.- AOnly
Key points
- Gradient boosting sums small trees; each one is fitted to the gradient of the loss of the previous ones and enters multiplied by the learning rate. Unlike a forest, it overfits as trees are added.
- With the same configuration (depth 2, 3,000-row leaves, subsampling 0.5, L2 = 10, rate 0.02), XGBoost and LightGBM reach their best validation IC with 25 trees and CatBoost with 400. None reaches that of Ridge (0.0155).
- Out-of-the-box early stopping looks at the loss (squared error: 14 trees), not at the IC. The number of trees is chosen with the objective's metric, in a purged validation, and the model is retrained on the whole training set.
- Out of sample, all three fall below Ridge (paired differences of −0.0044, −0.0041 and −0.0020); in an annual walk-forward, LightGBM loses −0.0041 ± 0.0018. On an almost linear problem, flexibility costs variance.
- A categorical with no effect makes all three worse: native LightGBM goes from 0.0070 to 0.0039 validation IC; CatBoost, with ordered statistics, only from 0.0088 to 0.0077; one-hot barely changes anything.
- Determinism: seeds, fixed threads and, in LightGBM,
deterministic=Trueandforce_row_wise=True. The three libraries give identical predictions in two fits.
Resources
Documentation
Books
- Friedman, J. H. (2001): Greedy Function Approximation: A Gradient Boosting Machine. The Annals of Statistics 29(5), 1189-1232
- Chen, T. and 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. and Varoquaux, G. (2022): Why do tree-based models still outperform deep learning on tabular data? (arXiv:2207.08815)