ml-models / Classical ML / Gradient boosting core

Gradient boosting

Gradient boosting builds a model as a sum of small trees, each one fitted to the mistakes of the sum so far. This page shows why "fit the residuals" is gradient descent on the loss, how XGBoost's second-order version scores a split with G²/(H+λ), what the learning rate and early stopping do, and how histogram binning makes it fast.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Fitting the negative gradient
  5. The second-order view
  6. Shrinkage, subsampling and early stopping
  7. Histograms, missing values and the fast implementations
  8. The main knobs
  9. Worked example
  10. Boosting stumps by hand
  11. A second-order split, scored by G and H
  12. What λ, γ and min_child_weight do
  13. Learning rate against number of rounds
  14. Implementation
  15. Tradeoffs
  16. Check yourself
  17. Connections
  18. Read first
  19. Read next
  20. References

Problem

A single decision tree is readable but unstable, and a deep one memorizes its training set. A shallow one is stable but too crude to follow a curved relationship. You would like the flexibility of many splits without the variance of one big tree.

Boosting gets there by addition. Start from a constant guess, look at how wrong it is on every training row, fit a small tree to that error, add a damped copy of the tree to the guess, and repeat. On tabular data this recipe, in its XGBoost, LightGBM and scikit-learn forms, is still the model most often hard to beat.

Intuition

Think of a golfer who cannot see the hole. The first shot goes in the average direction. Each later shot is aimed at the remaining gap, but only a fraction of the way, because a bad estimate of the gap should not overshoot. The sum of many small corrections lands close, and a wrong correction early is repaired by later ones.

The formal version is gradient descent where the thing being moved is the prediction function itself. With squared error L = ½(y − F)², the negative gradient with respect to the prediction F is exactly the residual y − F. So "fit a tree to the residuals" is "fit a tree to the negative gradient". Change the loss and only the target of the next tree changes: for log loss at predicted probability p it is y − p. A tree can only be fitted to a finite set of rows, so each step moves the predictions in the direction the tree can express, not the exact gradient.

The boosting loop A chain of boxes: the initial constant F0, then a tree h1 fitted to the residuals of F0 and added with weight eta, then a tree h2 fitted to the residuals of F1 and added with weight eta, and so on to F_M. Dashed arrows run from each sum back to the next tree to show that each tree sees the residuals of the model so far. F₀ = mean(y) h₁ fits y − F₀ F₁ = F₀ + η·h₁ h₂ fits y − F₁ F₂ = F₁ + η·h₂ … F_M = F₀ + η Σ h_m

Each stage looks at the training rows, computes what the current sum gets wrong, fits a small tree to that, and adds a fraction η of it.

Mechanics

Fitting the negative gradient

For a loss ℓ(y, F), stage m computes, for each training row i, the pseudo-residual and fits a regression tree hm to it:

ri = − ∂ℓ(yi, F)/∂F at F = Fm−1(xi)     Fm = Fm−1 + η · hm

For squared error ri = yi − Fm−1(xi). For the logistic loss on a raw score F with p = σ(F), g = p − y is the gradient and r = y − p. Friedman's original algorithm then re-fits each leaf's value by a one-dimensional line search on the true loss. In scikit-learn that is _update_terminal_regions: for squared error it changes nothing, and for binary log loss it is one Newton step, the leaf's mean of r divided by the mean of p(1 − p). The initial F₀ is the constant that minimizes the loss, which is the target mean for squared error and the log-odds of the base rate for log loss.

The second-order view

XGBoost, and the histogram boosters in LightGBM and scikit-learn, use both the gradient gi and the curvature hi = ∂²ℓ/∂F² of the loss. Expanding the loss to second order around the current prediction and adding a penalty on the new tree's size and leaf weights gives, for a candidate tree f with T leaves and leaf weights w:

L ≈ Σi [ gi f(xi) + ½ hi f(xi)² ] + γT + ½ λ Σj wj²

Rows that land in leaf j contribute Gj = Σ gi and Hj = Σ hi, so the leaf's part of the loss is Gj w + ½ (Hj + λ) w². This quadratic is minimized at:

w*j = − Gj / (Hj + λ)     with objective value − ½ Gj² / (Hj + λ) + γ

Splitting a node into left and right children replaces one leaf with two, so the improvement is the difference of those objective values:

Gain = ½ [ GL²/(HL+λ) + GR²/(HR+λ) − (GL+GR)²/(HL+HR+λ) ] − γ

For squared error g = F − y and h = 1, so G is minus the residual sum and H is the row count, and w* is the mean residual shrunk by n/(n + λ). λ pulls leaf values toward zero; γ is the price of an extra leaf. The code differs from the paper's formula in a detail worth knowing: XGBoost's CalcGain returns G²/(H+λ) with no ½, and its loss_chg is the bracket above, compared against γ directly. So a split survives when the unhalved bracket is at least gamma, which is twice as lenient as the paper's ½[…] − γ would suggest. The worked example below checks this.

One stump against four stumps on eight points Eight data points rise from 1.2 at x = 1 to 4.1 at x = 5 and fall back to 1.5 at x = 8. The dashed step shows the prediction after the first boosting round, a single split at 2.5 with the left side near 2.10 and the right side near 2.83. The solid step shows the prediction after round four, which follows the hump more closely. 1 2 3 4 5 6 7 8 0 1 2 3 4 x data y after round 1 after round 4

Both fits use stumps (depth-1 trees) and η = 0.5. Four weak steps already trace the hump that one step cannot.

Shrinkage, subsampling and early stopping

Multiplying each tree by a learning rate η < 1 means no single tree, which was fitted to noisy residuals, is trusted fully. Smaller η needs more rounds but generalizes better, and in practice η and the number of rounds are tuned together: lower η, more trees. Row subsampling (subsample < 1) fits each tree to a random fraction of the rows, which adds variance reduction like bagging and makes a stochastic gradient boosting; scikit-learn also reports out-of-bag improvements when it is on. Because every extra round lowers the training loss, training loss cannot say when to stop. Early stopping holds out validation rows and ends training when the validation loss has not improved for n_iter_no_change rounds, then (in XGBoost) keeps the best round.

Histograms, missing values and the fast implementations

Scanning every distinct value as a threshold costs a sort per feature per node. The histogram method bins each feature once, into at most max_bin quantile-based bins (256 in XGBoost, 255 in scikit-learn's HistGradientBoosting, plus one more bin there for missing values). After that, a node's histogram holds (G, H) per bin, so a split search is a scan over a few hundred bins regardless of the row count, and a child's histogram can be obtained by subtracting its sibling's from the parent's. Missing values need no imputation: the split search tries sending the missing rows to the left and then to the right, and the better direction is stored as the node's default. XGBoost does this by scanning the bins in both directions. The cost is a coarser threshold set; with 256 bins that rarely matters.

The main knobs

  • learning rate η. 0.1 in scikit-learn, 0.3 in XGBoost's default. Lower is safer and slower.
  • Number of rounds. 100 by default in scikit-learn; set it high and let early stopping pick.
  • Tree size. max_depth 3 for GradientBoostingRegressor, 6 in XGBoost, unlimited with max_leaf_nodes=31 in HistGradientBoosting. Depth sets how many features may interact in one tree.
  • Regularization. reg_lambda (λ, default 1 in XGBoost), gamma (min split loss, 0), min_child_weight (smallest H in a child, 1), min_samples_leaf (20 in HistGradientBoosting).
  • Subsampling. subsample for rows, and column sampling in XGBoost.
ComponentShape/ParamsMeaningSource
_fit_stage one tree per class per round; raw_predictions (n, K) Computes the negative gradient, fits a DecisionTreeRegressor to it, runs the leaf line search, and adds learning_rate times the tree. _gb.py
_update_terminal_regions leaf values per tree Re-fits leaf values to the loss: identity for squared error, a Newton step for binomial log loss. _gb.py
_init_raw_predictions F₀ of shape (n, K) The starting constant, from a DummyRegressor unless init is supplied. _gb.py
_fit_stages n_iter_no_change, tol, validation_fraction The boosting loop: subsampling, out-of-bag scores, and the early-stopping test. _gb.py
GradientBoostingRegressor learning_rate=0.1, n_estimators=100, max_depth=3, subsample=1.0 The exact-split booster and its defaults. _gb.py
HistGradientBoosting max_bins=255, max_leaf_nodes=31, min_samples_leaf=20 Bins features once, then boosts histogram trees; early stopping switches on automatically above 10,000 rows. gradient_boosting.py
_split_gain (G, H) of left, right and parent The gain formula above, citing equation 7 of the XGBoost paper. splitting.pyx
compute_node_value scalar per node −G / (H + l2_regularization), the optimal leaf weight. splitting.pyx
TrainParam defaults eta=0.3, gamma=0, max_depth=6, min_child_weight=1, lambda=1, alpha=0 XGBoost's tree parameters. param.h
CalcWeight scalar −ThresholdL1(G, α) / (H + λ), the leaf weight. param.h
CalcGain scalar G² / (H + λ), without the ½. param.h
CalcSplitGain left and right statistics Left gain plus right gain, or negative infinity when a child violates min_child_weight. split_evaluator.h
EnumerateSplit histogram bins, one direction per call Scans the bins and sets loss_chg to the split gain minus the parent's; called in both directions to choose the default for missing values. evaluate_splits.h
ApplyTreeSplit new leaf pair Turns the chosen split into two leaves whose weights are multiplied by the learning rate. evaluate_splits.h
IsValidExpandEntry candidate split Rejects splits whose loss_chg is below min_split_loss (γ), or past the depth or leaf limits. driver.h
train num_boost_round, early_stopping_rounds The Python loop that calls update once per round and runs the early-stopping callback. training.py

Worked example

Boosting stumps by hand

Eight points: x = 1 … 8, y = 1.2, 1.9, 3.1, 3.8, 4.1, 3.4, 2.2, 1.5. The starting constant is the mean, F₀ = 2.650. Each round fits the best single split (a stump) to the current residuals and adds η = 0.5 times its leaf values.

RoundSplitLeft leafRight leafTraining MSE after
0–constant 2.6501.047
1x ≤ 2.5-1.1000.3670.745
2x ≤ 6.50.328-0.9830.503
3x ≤ 2.5-0.7140.2380.376
4x ≤ 7.50.137-0.9610.277
5x ≤ 1.5-0.7760.1110.213

Round 1 sees residuals -1.45, -0.75, 0.45, 1.15, 1.45, 0.75, -0.45, -1.15 and splits at 2.5, giving leaf values -1.100 and 0.367. Only half of that is added, so the prediction for x = 1 moves from 2.650 to 2.100, not to the leaf's full correction. The error falls every round (0.745, 0.503, 0.376, …) and the splits move around, because after one round the biggest remaining residuals are somewhere else. These numbers match scikit-learn's GradientBoostingRegressor(max_depth=1, learning_rate=0.5) on this data.

A second-order split, scored by G and H

Now a classification problem: the same eight x values with labels 0, 0, 0, 1, 0, 1, 1, 1, and an initial raw score of 0, so every p = ½. Then g = p − y = ½ − y and h = p(1 − p) = ¼. The root has G = 0.00 and H = 2.00. With λ = 1, each threshold gives:

ThresholdGLHLGRHRloss_chg
x ≤ 1.50.500.25-0.501.750.2909
x ≤ 2.51.000.50-1.001.501.0667
x ≤ 3.51.500.75-1.501.252.2857
x ≤ 4.51.001.00-1.001.001.0000
x ≤ 5.51.501.25-1.500.752.2857
x ≤ 6.51.001.50-1.000.501.0667
x ≤ 7.50.501.75-0.500.250.2909

The best split is x ≤ 3.5 (x ≤ 5.5 ties it by symmetry; the first is kept) with loss_chg 2.2857, which is GL²/(HL+λ) + GR²/(HR+λ) − G²/(H+λ) = 1.2857 + 1.0000 − 0.0000. The leaf weights are w = −G/(H+λ): -0.8571 on the left and 0.6667 on the right, in raw-score (log-odds) units. With η = 1 these are added to the score directly. I checked this against XGBoost with base_score=0.5, max_depth=1, eta=1, min_child_weight=0, which reports the same split, gain and leaf values.

What λ, γ and min_child_weight do

Changing one setting at a time on the same data:

SettingChosen splitloss_chgLeaf weights
λ = 0x ≤ 3.54.8000-2.00, 1.20
λ = 1 (default)x ≤ 3.52.2857-0.8571, 0.6667
λ = 4x ≤ 3.50.9023-0.3158, 0.2857
min_child_weight = 1x ≤ 4.51.0000-0.50, 0.50

Raising λ shrinks both the gain and the leaf weights, so the model takes smaller, more cautious steps. With min_child_weight 1 the winning split would leave a child with H = 0.75, which is under the minimum, so XGBoost picks the next best threshold instead. For γ, the default λ = 1 split has loss_chg 2.2857: with gamma = 2.2 it is kept, and with 2.3 it is pruned and the tree stays a single leaf. That is the unhalved comparison described above; the paper's formula would have pruned it at about 1.14.

Learning rate against number of rounds

A noisy sine wave: 40 training rows with y = sin(x) + noise (σ = 0.4) for x uniform on 0 to 10, a separate 400-row test set, and depth-2 trees fitted to the residuals. One draw, so read the shape.

Roundsη = 1.0 train / test MSEη = 0.1 train / test MSE
10.228 / 0.3880.415 / 0.624
20.126 / 0.3140.379 / 0.575
50.054 / 0.3530.312 / 0.498
100.013 / 0.4370.232 / 0.396
200.000 / 0.4620.147 / 0.305
400.000 / 0.4630.077 / 0.290
1000.000 / 0.4630.018 / 0.358
1500.000 / 0.4630.008 / 0.394

With η = 1 the test error bottoms out at 0.314 after round 2 and then rises as the trees chase noise. With η = 0.1 the minimum is 0.290 at round 40, lower and later, followed by a gentler rise. The training error only ever falls, which is why a validation set, and not the training curve, has to decide when to stop.

Test error against boosting rounds for two learning rates Two test-error curves over 150 rounds. With a learning rate of 1.0 the error drops quickly to about 0.31 within a few rounds and then climbs. With a learning rate of 0.1 it falls more slowly to about 0.29 and rises less afterwards. 0 75 150 boosting rounds 0 1.0 solid: η = 1.0 dashed: η = 0.1 test MSE (lower is better)

The same trees, damped or not. A small η trades more rounds for a lower and flatter minimum.

Implementation

from sklearn.ensemble import GradientBoostingRegressor, HistGradientBoostingClassifier
import xgboost as xgb

# exact-split booster: stages are explicit and inspectable
gbr = GradientBoostingRegressor(
    n_estimators=500, learning_rate=0.05, max_depth=3, subsample=0.8,
    n_iter_no_change=20, validation_fraction=0.1, random_state=0,
).fit(X_train, y_train)
gbr.n_estimators_                       # rounds actually used after early stopping
for pred in gbr.staged_predict(X_val):  # prediction after each round
    pass

# histogram booster: binned features, NaN allowed, early stopping on by default for large n
hgb = HistGradientBoostingClassifier(
    learning_rate=0.1, max_leaf_nodes=31, l2_regularization=1.0,
    early_stopping=True, random_state=0,
).fit(X_train, y_train)

# XGBoost: second-order trees with explicit validation and early stopping
dtrain, dval = xgb.DMatrix(X_train, label=y_train), xgb.DMatrix(X_val, label=y_val)
params = {"objective": "binary:logistic", "eta": 0.05, "max_depth": 4,
          "lambda": 1.0, "gamma": 0.0, "min_child_weight": 1,
          "tree_method": "hist", "eval_metric": "logloss"}
bst = xgb.train(params, dtrain, num_boost_round=2000,
                evals=[(dval, "val")], early_stopping_rounds=50)
bst.best_iteration

In the loop, every round takes the loss's gradient (and Hessian) at the current predictions, builds one histogram tree from them, scales its leaf weights by the learning rate and adds them to the running scores; prediction is the sum of all trees' outputs passed through the link function. There is no feature scaling anywhere, since trees only compare against thresholds.

Tradeoffs

  • Why use it. Excellent accuracy on tabular data with little preprocessing: mixed scales, interactions, missing values and monotone transformations all just work. Many loss functions are available by changing g and h.
  • Sequential and sensitive. Trees depend on each other, so training cannot be parallelized across rounds (only within a tree), and results depend on η, rounds, depth and regularization together. Tune them with a validation set, not by hand on training error.
  • Overfitting. Unlike a forest, adding rounds eventually hurts, as the curves above show. Use early stopping, a small η, shallow trees, subsampling and regularization.
  • Noisy labels. Residual fitting chases outliers under squared error; a robust loss such as Huber or absolute error helps.
  • No extrapolation. Like its trees, the ensemble is piecewise constant and flat outside the training range.
  • Interpretability. Hundreds of trees are not readable. Use feature importance (with the caveats from the decision trees page), permutation importance, partial dependence or SHAP values.
  • Alternatives compared. Against a random forest: boosting reduces bias while a forest reduces variance; a forest is easier to tune but is usually a little less accurate. Against neural networks: boosting is usually stronger on small and medium tabular data, while networks win on images, text and sequences.

Check yourself

With squared error, a row has y = 5 and the current prediction is 3.4. What target does the next tree see?

The residual 5 − 3.4 = 1.6, the negative gradient of ½(y − F)². The tree's leaf value, scaled by η, is added to 3.4.

A leaf has G = −3 and H = 2 with λ = 1. What is its weight, and what happens if λ becomes 4?

w = −G/(H+λ) = 3/3 = 1. With λ = 4, w = 3/6 = 0.5: a larger λ shrinks the leaf toward zero.

Training loss keeps falling and validation loss starts rising at round 120. What should you do?

The model is overfitting from there on. Use early stopping with a validation set (keeping about round 120's model), and consider a lower learning rate, shallower trees or more regularization.

Connections

Read first

Read next

References

  • Friedman. Greedy function approximation: a gradient boosting machine. Annals of Statistics 29(5), 2001. doi:10.1214/aos/1013203451.
  • Chen and Guestrin. XGBoost: a scalable tree boosting system. KDD 2016. arXiv:1603.02754.
  • Ke et al. LightGBM: a highly efficient gradient boosting decision tree. NeurIPS 2017.
  • Hastie, Tibshirani and Friedman. The Elements of Statistical Learning, chapter 10: boosting and additive trees.
  • scikit-learn 1.9.1: _fit_stage, _split_gain.
  • XGBoost v3.4.2: CalcGain, EnumerateSplit.