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.
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.
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_depth3 forGradientBoostingRegressor, 6 in XGBoost, unlimited withmax_leaf_nodes=31in 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.
subsamplefor rows, and column sampling in XGBoost.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
_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.
| Round | Split | Left leaf | Right leaf | Training MSE after |
|---|---|---|---|---|
| 0 | – | constant 2.650 | 1.047 | |
| 1 | x ≤ 2.5 | -1.100 | 0.367 | 0.745 |
| 2 | x ≤ 6.5 | 0.328 | -0.983 | 0.503 |
| 3 | x ≤ 2.5 | -0.714 | 0.238 | 0.376 |
| 4 | x ≤ 7.5 | 0.137 | -0.961 | 0.277 |
| 5 | x ≤ 1.5 | -0.776 | 0.111 | 0.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:
| Threshold | GL | HL | GR | HR | loss_chg |
|---|---|---|---|---|---|
| x ≤ 1.5 | 0.50 | 0.25 | -0.50 | 1.75 | 0.2909 |
| x ≤ 2.5 | 1.00 | 0.50 | -1.00 | 1.50 | 1.0667 |
| x ≤ 3.5 | 1.50 | 0.75 | -1.50 | 1.25 | 2.2857 |
| x ≤ 4.5 | 1.00 | 1.00 | -1.00 | 1.00 | 1.0000 |
| x ≤ 5.5 | 1.50 | 1.25 | -1.50 | 0.75 | 2.2857 |
| x ≤ 6.5 | 1.00 | 1.50 | -1.00 | 0.50 | 1.0667 |
| x ≤ 7.5 | 0.50 | 1.75 | -0.50 | 0.25 | 0.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:
| Setting | Chosen split | loss_chg | Leaf weights |
|---|---|---|---|
| λ = 0 | x ≤ 3.5 | 4.8000 | -2.00, 1.20 |
| λ = 1 (default) | x ≤ 3.5 | 2.2857 | -0.8571, 0.6667 |
| λ = 4 | x ≤ 3.5 | 0.9023 | -0.3158, 0.2857 |
| min_child_weight = 1 | x ≤ 4.5 | 1.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 |
|---|---|---|
| 1 | 0.228 / 0.388 | 0.415 / 0.624 |
| 2 | 0.126 / 0.314 | 0.379 / 0.575 |
| 5 | 0.054 / 0.353 | 0.312 / 0.498 |
| 10 | 0.013 / 0.437 | 0.232 / 0.396 |
| 20 | 0.000 / 0.462 | 0.147 / 0.305 |
| 40 | 0.000 / 0.463 | 0.077 / 0.290 |
| 100 | 0.000 / 0.463 | 0.018 / 0.358 |
| 150 | 0.000 / 0.463 | 0.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.
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
- Decision trees intro
- Derivatives and gradients intro
Read next
- Learning to rank core
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.