02 · Gradient Boosting & XGBoost¶
Where random forests build many independent trees and average them, boosting builds trees sequentially, each one specifically trained to correct the errors of the ensemble so far. It's usually the single strongest technique for tabular data, and XGBoost/LightGBM are the production-standard implementations.
The core idea: fit the residuals¶
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.ensemble import GradientBoostingClassifier
data = load_breast_cancer(as_frame=True)
X, y = data.data, data.target
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y
)
gbm = GradientBoostingClassifier(
n_estimators=200, learning_rate=0.05, max_depth=3, random_state=42
)
gbm.fit(X_train, y_train)
print(f"gradient boosting: {gbm.score(X_test, y_test):.3f}") # ~0.972
Each of the 200 trees is shallow (max_depth=3 — a "weak learner") and adds
only a small, learning_rate-scaled correction. No single tree tries to
solve the whole problem; the ensemble solves it collectively over many
small steps.
XGBoost in practice¶
from xgboost import XGBClassifier
xgb = XGBClassifier(
n_estimators=300, learning_rate=0.05, max_depth=4,
subsample=0.8, colsample_bytree=0.8,
eval_metric="logloss", random_state=42,
)
xgb.fit(
X_train, y_train,
eval_set=[(X_test, y_test)], verbose=False,
)
print(f"xgboost: {xgb.score(X_test, y_test):.3f}") # ~0.979
# Early stopping: stop adding trees once validation loss stalls
xgb_es = XGBClassifier(
n_estimators=2000, learning_rate=0.05, max_depth=4,
early_stopping_rounds=20, eval_metric="logloss", random_state=42,
)
xgb_es.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)
print("trees actually used:", xgb_es.best_iteration) # e.g. 143 of 2000
subsample/colsample_bytree (row and column subsampling per tree) inject
bagging-style randomness into boosting to fight overfitting — XGBoost is,
under the hood, "boosting + a bit of bagging."
Worked example: tuning the learning rate / n_estimators trade-off¶
import numpy as np
from sklearn.metrics import accuracy_score
for lr, n in [(0.3, 50), (0.1, 150), (0.02, 800)]:
m = XGBClassifier(n_estimators=n, learning_rate=lr, max_depth=3, random_state=42)
m.fit(X_train, y_train)
print(f"lr={lr:<5} n={n:<4} acc={accuracy_score(y_test, m.predict(X_test)):.3f}")
# lr=0.3 n=50 acc=0.965
# lr=0.1 n=150 acc=0.972
# lr=0.02 n=800 acc=0.979
Smaller learning_rate + more trees generally generalizes better (finer,
more cautious corrections) at the cost of training time — the classic
boosting trade-off.
Cheat sheet¶
| Knob | Effect |
|---|---|
n_estimators |
More trees = more correction rounds; too many overfits without early stopping |
learning_rate |
Shrinks each tree's contribution; smaller = needs more trees, generalizes better |
max_depth |
Boosted trees are shallow (3-6) by design — depth here isn't "how complex a single learner is" |
subsample / colsample_bytree |
Row/column bagging per tree, fights overfitting |
early_stopping_rounds |
Stop once validation metric stops improving |
How It Actually Works¶
Gradient boosting literally performs gradient descent, but in "function
space" instead of weight space. At each round m, the ensemble's current
prediction is F_m(x). Gradient boosting computes the negative gradient
of the loss with respect to that prediction for every training point — for
log loss this works out to residual_i = y_i - p_i (true label minus
predicted probability, the same intuition as "how wrong am I, and in which
direction") — and fits the next tree h_m(x) to predict those residuals,
then updates F_{m+1}(x) = F_m(x) + learning_rate · h_m(x). This is exactly
the w ← w - lr·grad update from Module 09's neural network training, except
the "parameter" being updated is the entire ensemble function F, and each
"gradient step" is itself a whole decision tree rather than a number. That's
why weak, shallow trees are used as the per-round learner: each one only
needs to nudge the ensemble a little in the right direction, not solve the
problem outright.
Why shrinking the learning rate and adding more trees usually generalizes
better. A single large step (lr=0.3, few trees) commits hard to whichever
residual pattern the early trees found — including noise in that pattern.
Many small steps (lr=0.02, hundreds of trees) let the ensemble revise its
trajectory gradually, averaging out noisy residual estimates across rounds
much the way bagging averages out noisy trees — this is why the
lr=0.02, n=800 configuration in the worked example outperforms lr=0.3,
n=50 despite ending up as a "bigger" model.
Early stopping is a direct read of the bias-variance trade-off along the
boosting trajectory. Training loss decreases monotonically with more
rounds (each new tree is fit specifically to reduce the current residual),
but validation loss follows a U-shape: it falls while new trees still
capture real signal in the residuals, then rises once trees start fitting
noise in residuals that no longer contains real signal. early_stopping_
rounds=20 mechanically watches the validation metric after each round and
halts once it hasn't improved for 20 consecutive rounds, keeping the model
at (approximately) the bottom of that U rather than at the training-loss
minimum — which is why best_iteration (143) is far short of the 2000-tree
budget: that's the empirically located bottom of the U for this dataset.
Exercise¶
Reproduce the "worked example" sweep, but add a fourth setting lr=0.3,
n=800 (large learning rate, many trees). Explain, from the negative-gradient
mechanism above, why this configuration is likely to overfit rather than
just take longer to converge — then confirm by comparing its train accuracy
(should be ~1.0) against its test accuracy.