Skip to content

Gradient Boosting (XGBoost, LightGBM, CatBoost)

What you’ll learn

  • why fitting the residual is gradient descent, taken in function space
  • three boosting stages computed by hand, with the MSE at each one
  • shrinkage measured: ten times smaller steps need roughly ten times more of them
  • early stopping via staged_predictstaged_predict, and why it is not optional here
  • how XGBoost, LightGBM and CatBoost actually differ, and when the difference matters
  • an honest comparison where a boosted model does not win

Intuition

AdaBoost made the next learner focus on hard cases by reweighting the samples. Gradient boosting does the same thing with a change that turns out to be far more general: instead of reweighting, the next learner is fitted directly to what is left over.

Predict the mean. Look at the residuals. Fit a small tree to the residuals. Add it. Look at the new residuals. Repeat.

That reframing is what lets gradient boosting handle any differentiable loss — squared error, absolute error, log loss, Poisson, quantile — with no change to the algorithm. AdaBoost’s reweighting turns out to be the special case for exponential loss.

diagram Diagram mermaid

The math

Why the residual is a gradient

Take squared error as the loss for a single observation:

L(y,F)=12(yF)2L(y, F) = \tfrac{1}{2}\left(y - F\right)^2

Differentiate with respect to the prediction FF, not with respect to any parameter:

LF=(yF)LF=yF=residual\frac{\partial L}{\partial F} = -(y - F) \qquad\Longrightarrow\qquad -\frac{\partial L}{\partial F} = y - F = \text{residual}

The residual is the negative gradient of the loss with respect to the prediction. So fitting a tree to the residuals is fitting a tree to the direction of steepest descent, and adding it is a gradient step:

Fm(x)=Fm1(x)+ηhm(x)F_{m}(\mathbf{x}) = F_{m-1}(\mathbf{x}) + \eta \, h_m(\mathbf{x})

Compare that with the parameter update from Gradient Descent Explained: θθαJ\boldsymbol{\theta} \leftarrow \boldsymbol{\theta} - \alpha\nabla J. Same shape. The difference is that the step is a whole function rather than a parameter vector — hence “gradient descent in function space”.

The general algorithm

For any differentiable loss LL:

  1. F0(x)=argminciL(yi,c)F_0(\mathbf{x}) = \arg\min_c \sum_i L(y_i, c) — the best constant.
  2. For m=1,,Mm = 1, \ldots, M:
    • compute pseudo-residuals rim=[L(yi,F(xi))/F(xi)]F=Fm1r_{im} = -\left[\partial L(y_i, F(\mathbf{x}_i)) / \partial F(\mathbf{x}_i)\right]_{F = F_{m-1}}
    • fit a regression tree hmh_m to the rimr_{im}
    • update Fm=Fm1+ηhmF_m = F_{m-1} + \eta\, h_m

Swap the loss and only the pseudo-residual changes:

LossPseudo-residualUse
Squared erroryFy - FRegression, default
Absolute errorsign(yF)\operatorname{sign}(y - F)Regression with outliers
HuberyFy - F clipped at δ\deltaRegression, robust and smooth
Log lossyσ(F)y - \sigma(F)Binary classification
Quantileasymmetric signPrediction intervals

Notice the log-loss row: the pseudo-residual is yσ(F)y - \sigma(F), exactly the gradient from logistic regression. The same expression appears for a third time, and for the same reason.

Worked example by hand

Three points: x=[1,2,3]x = [1, 2, 3], y=[10,20,30]y = [10, 20, 30]. Learning rate η=0.5\eta = 0.5, and each hmh_m is a depth-1 stump.

Stage 0 — the constant. For squared error the best constant is the mean:

F0=10+20+303=20,MSE=100+0+1003=66.667F_0 = \frac{10 + 20 + 30}{3} = 20, \qquad \text{MSE} = \frac{100 + 0 + 100}{3} = 66.667

Stage 1 — fit the residuals. r1=yF0=[10,0,+10]r_1 = y - F_0 = [-10, 0, +10]. A stump splitting at x1.5x \le 1.5 gives the left leaf 10-10 and the right leaf 0+102=5\frac{0 + 10}{2} = 5:

h1=[10,  5,  5]F1=F0+0.5h1=[15,  22.5,  22.5]h_1 = [-10,\; 5,\; 5] \qquad F_1 = F_0 + 0.5\,h_1 = [15,\; 22.5,\; 22.5]
r2=yF1=[5,  2.5,  +7.5],MSE=25+6.25+56.253=29.167r_2 = y - F_1 = [-5,\; -2.5,\; +7.5], \qquad \text{MSE} = \frac{25 + 6.25 + 56.25}{3} = 29.167

Stage 2 — fit those residuals. A stump splitting at x2.5x \le 2.5 gives the left leaf 5+(2.5)2=3.75\frac{-5 + (-2.5)}{2} = -3.75 and the right leaf 7.57.5:

h2=[3.75,  3.75,  7.5]F2=F1+0.5h2=[13.125,  20.625,  26.25]h_2 = [-3.75,\; -3.75,\; 7.5] \qquad F_2 = F_1 + 0.5\,h_2 = [13.125,\; 20.625,\; 26.25]
r3=[3.125,  0.625,  +3.75],MSE=8.073r_3 = [-3.125,\; -0.625,\; +3.75], \qquad \text{MSE} = 8.073
StagePredictionMSEReduction
020.000, 20.000, 20.00066.667
115.000, 22.500, 22.50029.16756%
213.125, 20.625, 26.2508.07372%

Two things to notice. The predictions are walking toward [10,20,30][10, 20, 30] without ever jumping there — that is η=0.5\eta = 0.5 holding them back. And no individual stump is any good: the best of them predicts three distinct values. The ensemble is good; no member is.

figureThree stages on a curved datasetmatplotlib
A two by three grid. The top row shows residuals and the stump fitted to them at each of three stages; the bottom row shows the running ensemble against the data, progressively matching the curve.A two by three grid. The top row shows residuals and the stump fitted to them at each of three stages; the bottom row shows the running ensemble against the data, progressively matching the curve.
Residual MSE falls 2.3073 to 0.2331 to 0.1162. The top row's amber points shrink toward zero as the bottom row's green curve grows to fit the blue data.

See it move

Stage by stage, the ensemble grows toward the data while the residuals collapse toward zero.

sketch Residuals shrinking, stage by stage p5.js
The green curve is the running ensemble and the amber stems are the residuals it has not yet explained. Each stage adds one shallow stump and the stems get shorter.

Shrinkage

figureFour learning rates, 1200 trees eachmatplotlib
Held-out error against number of trees on a log scale for four learning rates, each marked at its minimum. Larger learning rates reach their minimum much earlier.Held-out error against number of trees on a log scale for four learning rates, each marked at its minimum. Larger learning rates reach their minimum much earlier.
lr=1.0 bottoms out at 381 trees; lr=0.1 needs 1199 and has barely caught up; lr=0.03 has not converged at all. Smaller steps do not automatically mean a lower floor.

Measured on 1,560 held-out rows of the Hastie benchmark:

Learning rateBest errorTrees needed
1.00.0679381
0.30.07861,193
0.10.07141,199
0.030.0833936 (not converged)

Reading the plot

  1. Smaller learning rates need proportionally more trees. Roughly, η×M\eta \times M is conserved: 1.0 at 381 trees and 0.1 at 1,199 land within 0.4 points of each other.
  2. They did not reach a lower floor here. The common claim that shrinkage always improves generalisation is not supported by this run — lr=1.0 was the best of the four. Shrinkage buys stability and control, not a guaranteed better score.
  3. lr=0.03 has not converged at 1,200 trees. Its curve is still descending. Reporting it as “worse” would be reporting an unfinished experiment.
  4. The practical rule survives: set a low learning rate, use early stopping to decide the tree count, and let the compute budget cap it. Just do not expect the low rate to pay for itself automatically.

Early stopping

n_estimatorsn_estimators in a boosted model can overfit, so it must be chosen rather than set. staged_predictstaged_predict replays the ensemble one tree at a time:

early_stopping.py
import numpy as np
from sklearn.datasets import make_hastie_10_2
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
 
X, y = make_hastie_10_2(n_samples=2400, random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.35, random_state=0)
 
model = GradientBoostingClassifier(n_estimators=1200, learning_rate=0.1,
                                   max_depth=3, random_state=0).fit(X_tr, y_tr)
 
errors = [1 - (pred == y_te).mean() for pred in model.staged_predict(X_te)]
best_n = int(np.argmin(errors)) + 1
 
print(f"best error {min(errors):.4f} at {best_n} trees")
print(f"error at 1200 trees {errors[-1]:.4f}")
early_stopping.py
import numpy as np
from sklearn.datasets import make_hastie_10_2
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
 
X, y = make_hastie_10_2(n_samples=2400, random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.35, random_state=0)
 
model = GradientBoostingClassifier(n_estimators=1200, learning_rate=0.1,
                                   max_depth=3, random_state=0).fit(X_tr, y_tr)
 
errors = [1 - (pred == y_te).mean() for pred in model.staged_predict(X_te)]
best_n = int(np.argmin(errors)) + 1
 
print(f"best error {min(errors):.4f} at {best_n} trees")
print(f"error at 1200 trees {errors[-1]:.4f}")

Or let scikit-learn stop for you, which avoids fitting trees you will discard:

python
GradientBoostingClassifier(
    n_estimators=2000,
    validation_fraction=0.15,     # hold out 15% internally
    n_iter_no_change=25,          # stop after 25 rounds without improvement
    tol=1e-4,
)
python
GradientBoostingClassifier(
    n_estimators=2000,
    validation_fraction=0.15,     # hold out 15% internally
    n_iter_no_change=25,          # stop after 25 rounds without improvement
    tol=1e-4,
)

HistGradientBoosting: the fast default

scikit-learn’s HistGradientBoosting*HistGradientBoosting* bins continuous features into 255 buckets before searching splits, which turns the split search from O(mlogm)O(m \log m) into O(bins)O(\text{bins}). It is the same idea LightGBM introduced, and it is dramatically faster on anything over a few thousand rows.

histgb.py
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import (HistGradientBoostingClassifier,
                              RandomForestClassifier)
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
X, y = load_breast_cancer(return_X_y=True)
 
models = {
    "HistGradientBoosting": HistGradientBoostingClassifier(random_state=0),
    "RandomForest": RandomForestClassifier(n_estimators=200, random_state=0, n_jobs=-1),
    "LogisticRegression": make_pipeline(StandardScaler(),
                                        LogisticRegression(max_iter=5000)),
}
 
for name, model in models.items():
    print(f"{name:<22} CV {cross_val_score(model, X, y, cv=5).mean():.4f}")
 
# HistGradientBoosting   CV 0.9648
# RandomForest           CV 0.9596
# LogisticRegression     CV 0.9807
histgb.py
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import (HistGradientBoostingClassifier,
                              RandomForestClassifier)
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
X, y = load_breast_cancer(return_X_y=True)
 
models = {
    "HistGradientBoosting": HistGradientBoostingClassifier(random_state=0),
    "RandomForest": RandomForestClassifier(n_estimators=200, random_state=0, n_jobs=-1),
    "LogisticRegression": make_pipeline(StandardScaler(),
                                        LogisticRegression(max_iter=5000)),
}
 
for name, model in models.items():
    print(f"{name:<22} CV {cross_val_score(model, X, y, cv=5).mean():.4f}")
 
# HistGradientBoosting   CV 0.9648
# RandomForest           CV 0.9596
# LogisticRegression     CV 0.9807

HistGradientBoostingHistGradientBoosting also handles missing values natively — it learns which side of each split a NaN should go — so no imputer is required.

The four libraries

scikit-learn HistGBHistGBXGBoostLightGBMCatBoost
Tree growthLevel-wise, binnedLevel-wise (depth-wise)Leaf-wise (best-first)Symmetric / oblivious
Speed on large dataFastFastFastestFast
Categorical featuresLimitedNeeds encodingNativeNative, ordered target stats
Missing valuesNativeNativeNativeNative
Default qualityGoodGood, needs tuningGood, needs tuningBest out of the box
GPUNoYesYesYes
Extra dependencyNoneYesYesYes

The differences that actually matter in practice:

  • LightGBM grows leaf-wise: it splits whichever leaf reduces loss most, rather than completing each level. Deeper, more unbalanced trees, faster convergence — and easier overfitting on small data, so num_leavesnum_leaves needs care.
  • CatBoost uses ordered target statistics for categorical features, which is target encoding done with a scheme that provably avoids the leakage a naive groupby().mean()groupby().mean() introduces. If your data is categorical-heavy, this is the reason to pick it.
  • XGBoost adds explicit regularisation to the objective (lambdalambda, alphaalpha, gammagamma) and was the first to popularise the second-order (Newton) approximation of the loss.

Start with HistGradientBoostingHistGradientBoosting. No new dependency, competitive accuracy, and if it is not enough you now know what you are shopping for.

algorithmGradient BoostingSupervised · Classification and Regression · Boosting ensemble

APIsklearn.ensemble.HistGradientBoostingClassifier / GradientBoostingRegressor

Assumes

  • The loss is differentiable with respect to the prediction
  • Shallow trees are adequate base learners for the residual structure
  • Labels are reasonably clean — sequential fitting chases errors

Cost

train
O(m · n · depth · trees), sequential in trees
predict
O(depth · trees)
memory
O(nodes · trees)

m = samples, n = features; histogram binning removes the m log m sort from the split search

Hyperparameters that matter

  • learning_ratedefault 0.1The shrinkage. Trades directly against tree count — their product is roughly conserved.
  • n_estimators / max_iterdefault 100CAN overfit, unlike a forest. Choose it with early stopping, never by guessing.
  • max_depth / max_leaf_nodesdefault 3 / 31Boosted trees should be shallow. Depth 3 to 8, or 31 leaves; deeper defeats the weak-learner premise.
  • subsampledefault 1.0Below 1.0 gives stochastic gradient boosting — faster and often a little better. Try 0.8.
  • min_samples_leafdefault 20 (HistGB)The main regularisation knob on noisy data.
  • l2_regularizationdefault 0.0Penalises leaf values. Worth tuning on small datasets.

Reach for it when

  • Tabular data with mixed feature types — this is the default winner
  • You need the strongest single model and can afford to tune
  • There are missing values you would rather not impute
  • Non-linear interactions matter and you cannot hand-engineer them

Look elsewhere when

  • Labels are noisy — sequential fitting amplifies bad labels
  • You need interpretability or a model a regulator can read
  • Training must parallelise across members; boosting is sequential
  • The problem is small and largely linear — try the baseline first, as measured above

Pitfalls

Compare

Random ForestAdaBoostGradient Boosting
MembersDeep treesStumpsShallow trees
Next member seesA bootstrap sampleReweighted samplesResiduals
Loss functionsFixedExponential onlyAny differentiable
Parallel trainingYesNoNo
More members overfitNoYesYes
Tuning burdenLowLowHigh
Typical tabular accuracyVery goodGoodBest
quizCheck yourself
  1. Why is fitting a tree to the residuals a form of gradient descent?

    Show answer

    B — For squared error the negative gradient of the loss with respect to the prediction is exactly y minus F — the residual — so each tree is a step in the steepest-descent direction — d/dF of half (y - F) squared is -(y - F). The residual IS the negative gradient, so adding a tree fitted to it is a gradient step in function space.

  2. In the hand-worked example, MSE fell 66.667 to 29.167 to 8.073. Was any individual stump a good model?

    Show answer

    B — No — each predicts only two or three distinct values. The ensemble is good precisely because no member needs to be — A depth-1 stump on three points can output two values. The strength comes from the sum, which is the whole boosting premise.

  3. lr=1.0 bottomed out at 381 trees; lr=0.1 needed 1,199. What is the relationship?

    Show answer

    B — Roughly reciprocal — learning rate times tree count is approximately conserved, so a tenfold smaller step needs about tenfold more steps — Each tree contributes eta times its prediction. Shrinking eta by ten requires roughly ten times as many contributions to travel the same distance.

  4. What does CatBoost's ordered target statistics solve?

    Show answer

    B — The leakage in naive target encoding — a row's own label contributing to its own encoded feature — A plain groupby-mean encoding lets each row's label leak into its own feature. Ordered statistics compute the encoding using only rows that came earlier in a random permutation.

🧪 Try It Yourself

Exercise 1 – The residual is the gradient

Exercise 2 – Two boosting stages by hand

Exercise 3 – Find the best number of trees

Exercise 4 – Run the cheap baseline first

Exercise 5 – Shrinkage trades against tree count

Recap

  • The residual is the negative gradient of squared loss with respect to the prediction, so boosting is gradient descent in function space.
  • Swapping the loss changes only the pseudo-residual, which is why gradient boosting handles log loss, Huber, quantile and Poisson with one algorithm.
  • Hand-worked: MSE 66.667 → 29.167 → 8.073 in two stages, from stumps that individually predict two values each.
  • Shrinkage trades against tree count roughly reciprocally — 1.0 peaked at 381 trees, 0.1 at 1,199 — and did not reach a lower floor in this run.
  • n_estimatorsn_estimators can overfit. Use staged_predictstaged_predict or n_iter_no_changen_iter_no_change.
  • Start with HistGradientBoostingHistGradientBoosting; reach for LightGBM on very large data and CatBoost on categorical-heavy data.
  • Logistic regression beat both ensembles on breast cancer. Run the cheap baseline first.

Exercise 6 – Three boosting rounds, by hand

Next

Continue to Stacking and Voting Classifiers — the third family, where the members come from different algorithms entirely and a second-level model learns how much to trust each one.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did