How XGBoost builds trees

XGBoost · Expert · 15 min

Understanding the algorithm explains what every hyperparameter really does.

For each boosting round, XGBoost computes for every training row the gradient g and hessian h (first and second derivative) of the loss with respect to the current prediction. For squared error, g = prediction - y and h = 1; for log loss, g = p - y and h = p(1 - p).

A new tree is fitted to these. For a leaf containing rows I, with G = sum of g and H = sum of h, the optimal leaf weight is w = -G / (H + lambda), and the leaf's contribution to the objective is -G² / (2(H + lambda)). A split into left and right children has

gain = 1/2 * [G_L²/(H_L+lambda) + G_R²/(H_R+lambda) - (G_L+G_R)²/(H_L+H_R+lambda)] - gamma

So reg_lambda shrinks leaf weights and gains, gamma is a fixed price per extra leaf, min_child_weight is a lower bound on H in each child, and reg_alpha soft-thresholds G. Leaf weights are multiplied by learning_rate before being added.

Split finding: the default tree_method="hist" buckets each feature into at most max_bin (256) quantile bins and accumulates gradient histograms per node, so finding the best split costs O(bins) rather than O(rows). A child's histogram is obtained by subtracting its sibling's from the parent's.

Sparsity-aware splits: missing values are excluded from the histograms and their G and H are tried on each side, giving the learned default direction.

base_score is the starting prediction, estimated from the target in recent versions.

Example

import numpy as np
import xgboost as xgb

X = np.array([[1.0], [2.0], [3.0], [4.0]])
y = np.array([1.0, 1.0, 3.0, 3.0])
params = {"max_depth": 1, "eta": 1.0, "lambda": 1.0, "base_score": 0.0,
          "min_child_weight": 0, "objective": "reg:squarederror"}
booster = xgb.train(params, xgb.DMatrix(X, label=y), num_boost_round=1)

# by hand: g = pred - y = -y, h = 1; left {1,2}: G=-2, H=2; right {3,4}: G=-6, H=2
lam = 1.0
print(-(-2) / (2 + lam), -(-6) / (2 + lam))       # 0.6666666666666666 2.0 (leaf weights)
gain = 0.5 * (4 / 3 + 36 / 3 - 64 / 5)
print(round(gain, 4))                             # 0.2667

df = booster.trees_to_dataframe()
print(df.loc[0, "Split"], round(df.loc[0, "Gain"], 4))   # 3.0 0.5333 (reported without the 1/2)
print(df.loc[1:, "Gain"].round(4).tolist())             # [0.6667, 2.0] (leaf values)

Remember

  • Each tree fits gradients and hessians; leaf weight = -G/(H + lambda)
  • lambda, gamma, min_child_weight appear directly in the gain formula
  • Histogram binning (max_bin) makes split finding fast

Watch out for

  • Thinking learning_rate only slows training; it shrinks every leaf weight, acting as regularisation.

Check yourself

  1. In XGBoost's split gain, what role does gamma play?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: xgboost.readthedocs.io/en/stable/tutorials/model.html