S091 · Gradient Boosting — XGBoost, LightGBM
The algorithm that has won more Kaggle competitions than everything else combined — sequentially fit trees to residuals, then wrap it in modern tricks (histograms, GPU, DART) to make it fly.
🎯 Understand gradient boosting from first principles, then wield XGBoost and LightGBM like a Kaggle grandmaster — with the right hyperparameters and early stopping.
Why this session exists
Between 2015 and 2024, gradient-boosted trees won ~70 % of tabular Kaggle competitions. In industry, they're the default choice for fraud detection, credit scoring, ad ranking, click prediction, and every ‘score this table’ problem you can name. Unlike random forest, which reduces variance by parallel averaging, gradient boosting reduces bias by sequential correction — each new tree fits the errors of the previous ensemble. Understanding this session unlocks the model family that pays the salary of most ML engineers on Earth.
- Explain gradient boosting as ‘fit a tree to the residuals, add it with a learning rate, repeat.’
- Derive the update rule from a Taylor expansion of the loss.
- Wield XGBoost with sane defaults + early stopping in ~20 lines.
- Choose between XGBoost, LightGBM, and CatBoost based on data characteristics.
- Diagnose the three ways boosting fails (learning rate too high, no early stopping, unbalanced classes).
Prerequisites
- S089 · Decision trees — single tree is the building block.
- S090 · Random forest — you understand ensembling.
- S087 · Regularization — boosting relies on it heavily.
(a) Intuition · 5 min
You're playing golf. First shot lands 50 yards short and left. Next shot corrects: aim 50 yards further, a bit right. Still short. Next shot corrects again. Each swing is a small correction to the previous miss. Eventually you're in the hole.
You could try to sink it in one shot, but a mishit is catastrophic. Many small corrections are safer AND more accurate.
Gradient boosting is the same. Fit a small model that captures some signal. Look at the residuals (what's left unexplained). Fit ANOTHER small model to those residuals. Add it, scaled by a small learning rate. Repeat 100-1000 times.
Each new tree is a small correction. The learning rate ensures no single tree overcorrects. The ensemble sum is a very expressive model with controllable capacity.
The three ideas to own before we code
- Sequential — each tree fits the residual (or gradient of loss) of the current ensemble. Bagging is parallel; boosting is serial.
- Small learning rate — each tree is added with weight η ≈ 0.05-0.1. Many small steps, not few big ones. Trades compute for accuracy.
- Regularize hard — max_depth 4-8, L1/L2 on leaf values, min_child_weight, subsample rows and columns per tree. Boosting overfits fast without these.
A brief history
- 1997AdaBoost · Freund & SchapireFirst practical boosting algorithm — reweight misclassified examples. Won the 2003 Gödel Prize. Still used in face detection (Viola-Jones).
- 1999Gradient Boosting · FriedmanThe general framework. AdaBoost is a special case with exponential loss. Works with any differentiable loss.
- 2001GBM in R + sklearnSlow, single-threaded, but widely available. Ruled academic benchmarks for a decade.
- 2014XGBoost · Tianqi ChenRegularized objective, sparse-aware split finding, distributed training. Dominates Kaggle immediately.
- 2017LightGBM · MicrosoftHistogram-based algorithm, leaf-wise growth. Often 10× faster than XGBoost, similar accuracy.
- 2018CatBoost · YandexNative handling of categorical features via ordered target encoding. Great when you have many categorical columns.
- 2024Still SOTA for tabularGrinsztajn et al 2022 paper: trees beat deep learning on 45 medium-sized tabular datasets. Gradient boosting isn't going anywhere.
(b) Visual walkthrough · 15 min
The boosting loop
For regression with MSE loss, residuals ARE the gradients. For classification (log loss), residuals are the gradients of the loss — which is why it's called "gradient" boosting, not "residual" boosting.
The math — Taylor expansion of the loss
Deriving the update rule (XGBoost objective)
Bagging vs Boosting — the great tabular tradeoff
Parallel · variance killer
- Independent trees, parallel training.
- Reduces variance, not bias.
- Robust to hyperparameters — hard to break.
- Free OOB score, no validation set needed.
- Great baseline, moderate ceiling.
Sequential · bias killer
- Trees added one by one to fix residuals.
- Reduces bias, controls variance via regularization.
- Very sensitive to hyperparameters (LR, depth, rounds).
- REQUIRES early stopping on val set.
- Higher ceiling; wins Kaggle.
Which boosting library to use?
The battle-tested standard
- Great single-node performance.
- Best distributed training (multi-GPU, Spark).
- Extensive documentation, mature ecosystem.
- Slightly slower than LightGBM on single machine.
- First choice if you're unsure.
Fastest single-node
- Histogram-based split finding — 5-10× faster.
- Leaf-wise growth (deeper on strong branches).
- Slightly more prone to overfitting → tune num_leaves + min_data_in_leaf.
- Best for large single-node datasets.
- Default for many Kaggle winners since 2018.
Categorical-first
- Native categorical encoding (no manual one-hot).
- Ordered boosting reduces prediction bias.
- Great defaults — often works with zero tuning.
- Slower to train than LGBM.
- Best when data is category-heavy (e-commerce, ad-tech).
Hyperparameters that matter (and their sane defaults)
Small step size per tree. Lower = more trees needed but better generalisation. Grid: 0.01, 0.05, 0.1.
Depth of each tree. Deeper = more capacity, more overfit risk. XGBoost default 6; LGBM controlled via num_leaves.
Never tune n_estimators manually. Set high, let early stopping find the right round using val AUC/RMSE.
Bagging on top of boosting. Reduces variance. Standard defaults 0.7-0.9.
Regularization: refuse to split leaves below this weight. Prevents overfitting to noise.
L2 (lambda) and L1 (alpha) on leaf weights. Add lambda if overfit; add alpha for sparsity.
"Gradient boosting is just boosting with gradients — each tree fits the errors of the previous one. So it's basically the same idea as Random Forest, only sequential instead of parallel."
They are opposites in what they attack. Random Forest averages many low-bias, high-variance deep trees to kill variance. Boosting sums many high-bias, low-variance shallow trees to kill bias, and it adds variance as it goes. That is why forests cannot overfit by adding trees while boosters absolutely can, why forest trees are deep and booster trees are stumps, and why one needs early stopping and the other does not.
The myth is sticky because both are "ensembles of trees" and both appear in the same chapter, so they get filed together. The sequential-versus-parallel difference is the visible one, and it seems sufficient. But that is a description of the implementation; the real difference is which term of the error decomposition each one targets, and everything else — tree depth, tree count behaviour, sensitivity to noise, parallelisability — is downstream of that single choice.
Watch validation error in a booster turn upward as trees accumulate — the thing a forest never does:
from sklearn.ensemble import GradientBoostingClassifier
import numpy as np
m = GradientBoostingClassifier(n_estimators=1000, learning_rate=0.1,
max_depth=3).fit(Xtr, ytr)
val = np.array([s for s in m.staged_score(Xte, yte)])
print(val.argmax(), val.max(), val[-1])
# best score arrives at some middle iteration, then DECLINES.
# argmax is the tree count early stopping would have chosen.
# repeat with RandomForest and the curve only ever flattens.Why is it called gradient boosting when the trees appear to just fit residuals? Where is the gradient, and why does that reframing matter enough to name the algorithm after it?
- 1Think of the ensemble prediction F(x) not as a formula but as a point in function space: the vector of predictions on your n training points. Training is a search for the vector that minimises total loss.forced by · the loss depends on the model only through its predictions, so predictions are the true optimisation variable
- 2Gradient descent says: to reduce the loss, step in the direction of the negative gradient of the loss with respect to that vector — one component per training point.forced by · the negative gradient is the direction of steepest local decrease, in any space where a gradient is defined
- 3Compute that gradient for squared loss L = ½(y − F)². The derivative with respect to F is −(y − F), so the negative gradient is exactly
(y − F): the residual. Residual-fitting is not a heuristic — it is the special case of gradient descent under squared loss.forced by · the residual literally is the negative gradient for that particular loss - 4But the negative gradient is only defined on the n training points. To take a step you can apply to new data, you need a function that approximates it everywhere — so you fit a shallow tree to those gradient values. The tree is the generalisable version of the step direction.forced by · a step must be a function, not n numbers, if the model is to predict on unseen inputs
- 5Then take a small step:
F ← F + η·h(x), with η the learning rate. Small because the tree only approximates the gradient, and because the gradient is only locally valid — the same reason learning rates exist anywhere else.forced by · an approximate direction is trustworthy only over a short distance
Therefore gradient boosting is gradient descent performed in function space, where each "step" is a tree and the learning rate is the step size. The gradient framing is not decoration — it is what generalises the algorithm.
And note what this predicts: since nothing in the derivation required squared loss, the algorithm must work for any differentiable loss — you simply plug in a different derivative. Log-loss gives (y − p) for classification; absolute error gives sign(y − F), which is why boosting with MAE fits the sign of the residual and is therefore robust to outliers; pinball loss gives quantile regression. One algorithm, and the loss function is a parameter. That is why boosting libraries expose a custom-objective hook at all.
Start with a constant prediction — the mean, or the base log-odds. Then repeatedly ask: "where am I currently wrong, and in which direction?" Fit a deliberately weak tree that answers only that question, and add a small fraction of its answer to the running total. Repeat a thousand times.
No individual tree is trying to solve the problem. Each one is a small correction to the accumulated total, and the strength comes from compounding many tiny, focused adjustments. The learning rate is how much of each correction you accept — take too much and you overshoot into the noise; take too little and you need far more trees to arrive.
- Learning rate and tree count trade off directly: halving η roughly doubles the trees needed. Lower η with more trees generalises better, so set η small and let early stopping pick the count.
- Base learners must be weak — depth 3 to 8, or a fixed leaf budget. Deep trees fit the residuals too well and the ensemble overfits within a handful of iterations.
- Early stopping on a validation set is not optional; it is the regularisation. Without it, extra trees eventually make the model worse.
- Boosting concentrates capacity on the examples it currently gets wrong, so mislabelled rows receive escalating attention. It is materially more sensitive to label noise than bagging.
Fire this the moment you see: a booster trained to a fixed tree count with no validation curve · learning_rate left at default while n_estimators is tuned · deep trees inside a boosting model · boosting applied to data with known label noise · a booster's probabilities used directly in an expected-value calculation without calibration.
You are shipping gradient boosting. Do you pick a low learning rate with many trees, a higher learning rate with fewer, or push regularisation and subsampling instead?
Fix a small learning rate, set the tree count deliberately high, and let early stopping on a proper validation set choose the actual number. That converts two coupled hyperparameters into one, and it is the single most reliable configuration decision in boosting. Tuning n_estimators by grid search is wasted compute — the validation curve already hands you the answer.
Then spend the remaining tuning budget on tree complexity (depth or leaf count) and subsampling, in that order, because those move the score most. And before shipping, check calibration: boosters optimise ranking loss well but their raw probabilities are frequently over-confident, so if anything downstream multiplies your output by a cost, fit a calibration layer on held-out data rather than trusting the number.
(c) Hands-on · 25 min
We're going to fit XGBoost on a real dataset with early stopping, tune the essentials, and compare against LightGBM.
# gradient_boosting.py — XGBoost + LightGBM in production-ready form, ~130 lines.
import numpy as np
import pandas as pd
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score
import xgboost as xgb
# If LightGBM installed, uncomment:
# import lightgbm as lgb
np.random.seed(42)
# --- Data ---
data = fetch_california_housing(as_frame=True)
X: pd.DataFrame = data.frame.drop(columns=["MedHouseVal"])
y: pd.Series = data.frame["MedHouseVal"]
X_trainval, X_test, y_trainval, y_test = train_test_split(X, y, test_size=0.15, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(X_trainval, y_trainval, test_size=0.20, random_state=42)
print(f"train={len(X_train)} val={len(X_val)} test={len(X_test)}")
# =====================================================
# 1. XGBoost with sane defaults + early stopping
# =====================================================
model = xgb.XGBRegressor(
n_estimators=2000, # generous upper bound; early stopping decides
learning_rate=0.05,
max_depth=6,
subsample=0.8,
colsample_bytree=0.8,
reg_alpha=0.0,
reg_lambda=1.0,
min_child_weight=5,
random_state=42,
n_jobs=-1,
early_stopping_rounds=50, # stop if val doesn't improve for 50 rounds
eval_metric="rmse",
)
model.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False)
print(f"[XGBoost] stopped at round {model.best_iteration}")
print(f" train RMSE = {mean_squared_error(y_train, model.predict(X_train), squared=False):.4f}")
print(f" val RMSE = {mean_squared_error(y_val, model.predict(X_val), squared=False):.4f}")
print(f" test RMSE = {mean_squared_error(y_test, model.predict(X_test), squared=False):.4f}")
# =====================================================
# 2. Manual boosting from scratch — for understanding
# =====================================================
from sklearn.tree import DecisionTreeRegressor
def gbm_scratch(X_train, y_train, X_test, n_estimators=200, learning_rate=0.05, max_depth=4):
"""Very simple gradient boosting for regression with MSE loss."""
# Init prediction = mean
F_train = np.full(len(y_train), y_train.mean())
F_test = np.full(X_test.shape[0], y_train.mean())
trees = []
for m in range(n_estimators):
residual = y_train - F_train # for MSE, negative gradient = residual
tree = DecisionTreeRegressor(max_depth=max_depth, random_state=m)
tree.fit(X_train, residual)
F_train += learning_rate * tree.predict(X_train)
F_test += learning_rate * tree.predict(X_test)
trees.append(tree)
return F_test
pred_scratch = gbm_scratch(X_train.values, y_train.values, X_test.values, n_estimators=200)
print(f"\n[scratch GBM] test RMSE = {mean_squared_error(y_test, pred_scratch, squared=False):.4f}")
# =====================================================
# 3. Learning-rate sensitivity (why boosting needs care)
# =====================================================
print("\n=== Learning-rate sweep (XGBoost) ===")
for lr in [0.01, 0.05, 0.1, 0.3, 1.0]:
m = xgb.XGBRegressor(
n_estimators=1500, learning_rate=lr, max_depth=6,
subsample=0.8, colsample_bytree=0.8,
random_state=42, n_jobs=-1,
early_stopping_rounds=50, eval_metric="rmse",
)
m.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False)
rmse = mean_squared_error(y_test, m.predict(X_test), squared=False)
print(f" lr={lr:>5}: rounds={m.best_iteration:>4}, test RMSE={rmse:.4f}")
# =====================================================
# 4. Feature importance (gain-based, XGBoost specific)
# =====================================================
print("\n=== Feature importance (gain) ===")
importances = model.get_booster().get_score(importance_type="gain")
for feat, imp in sorted(importances.items(), key=lambda x: -x[1])[:8]:
print(f" {feat:<20} {imp:>10.2f}")
# =====================================================
# 5. Uncomment for LightGBM comparison
# =====================================================
# lgb_model = lgb.LGBMRegressor(
# n_estimators=2000, learning_rate=0.05, num_leaves=31, max_depth=-1,
# subsample=0.8, colsample_bytree=0.8, min_child_samples=10,
# random_state=42, n_jobs=-1,
# )
# lgb_model.fit(X_train, y_train,
# eval_set=[(X_val, y_val)],
# callbacks=[lgb.early_stopping(stopping_rounds=50)])
# rmse = mean_squared_error(y_test, lgb_model.predict(X_test), squared=False)
# print(f"[LightGBM] test RMSE = {rmse:.4f}")What each block does
Anatomy of the script
Remove early_stopping_rounds and set n_estimators=3000. After training, plot the train + val RMSE per round using model.evals_result_.
You'll see:
- Train RMSE drops smoothly to near zero.
- Val RMSE drops, plateaus, and starts RISING after some round.
Overfitting is not subtle in boosting — it's obvious. Always use early stopping.
(d) Production reality · 15 min
Between XGBoost's release (2014) and today, gradient-boosted trees have won roughly 70 % of Kaggle tabular competitions. The 2022 Grinsztajn et al paper formalised this: on 45 medium-sized tabular datasets, trees beat neural networks on almost every one.
Uber's Michelangelo ML platform was originally XGBoost-heavy for ETAs, pricing, and fraud detection. These models had to be retrained daily on billions of rows and served with <10 ms latency.
Airbnb's initial neural network attempt to replace their GBT-based ranking model underperformed. It took two years of feature engineering + architecture iteration before a deep model beat the GBT baseline.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What is gradient boosting in one sentence? (fit trees sequentially to residuals with a small learning rate)
- Why is early stopping non-negotiable? (boosting overfits fast; val curve is the honest signal)
- When would you pick GBT over deep learning? (tabular data, moderate sample size, interpretability matters)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.