DEV Community

Cover image for XGBoost: The Accountant Who Read the Tax Code So Carefully He Found Money Nobody Else Saw
Sachin Kr. Rajput
Sachin Kr. Rajput

Posted on

XGBoost: The Accountant Who Read the Tax Code So Carefully He Found Money Nobody Else Saw

The One-Line Summary: XGBoost is gradient boosting with three changes that each look like a footnote and together won a decade of competitions — it uses the second derivative so each leaf knows how stiff its correction is rather than only how wrong, it writes the cost of every new leaf directly into the objective so pruning becomes arithmetic instead of a max_depth guess, and it treats a missing value as a direction to be learned rather than a hole to be filled.


The Parable of the Counting House at Anand & Sons

Every autumn the grain merchants of the river towns had to declare what their business was worth, and every autumn they got it wrong. Anand & Sons had solved this the way everyone did: hire twelve clerks, and have each one correct the previous clerk's figure by exactly the amount it was off.

It half worked. It also, twice in living memory, produced a declaration so wrong the family nearly lost the warehouse.


The Old Way: Twelve Clerks, One Instruction

The instruction each clerk received was a single sentence. Find where the last figure was off, and move it by that much.

THE LEDGER, CORRECTED TWELVE TIMES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
line              off by    clerk writes
warehouse rent     -400      +400
sack commission    -400      +400
cartage             -80       +80

Next season:
warehouse rent        0      settled, correct
sack commission    +900      WILDLY over
cartage             -10      nearly right

The rent is a FIXED sum. Move it by 400 and it
moves by 400. Honest work.

The commission is a PERCENTAGE OF TURNOVER. Move
it by 400 and turnover shifts, and the commission
moves by rather more than 400. The clerk overshot,
the next clerk overshot back, and the figure
oscillated for four seasons.
Enter fullscreen mode Exit fullscreen mode

The clerks were not careless. They had been given one number per line — how wrong — and they needed two. Nobody had ever asked how much a line moves when you push it.


The Innovation: A Second Number Per Line

Rukmini was hired to audit the clerks and instead rewrote their instruction. She had read the revenue code end to end, which nobody at Anand & Sons had done in thirty years, and she came back with a question.

"You all know how wrong each line is. Does any of you know how stiff it is?"

She asked every clerk to record two numbers instead of one. How far off the line sits, and how much the total shifts when the line is nudged by a single rupee. The first number is the correction you want. The second is how much of it you dare take.

THE SAME LEDGER, WITH STIFFNESS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
line             off by   stiffness   correction
warehouse rent    -400       1.0        400/1.0 = 400
sack commission   -400       4.0        400/4.0 = 100
cartage            -80       1.0         80/1.0 =  80

The rent takes its full correction. The springy
commission takes a quarter of it, because it will
carry itself the rest of the way.

Divide the error by the stiffness. Nothing else
changed. The oscillation stopped in one season.
Enter fullscreen mode Exit fullscreen mode

The Second Fix: Make Every Line Pay Rent

Within two years the ledger had swollen to nine hundred lines, because a clerk who spots a twelve-rupee discrepancy is rewarded for writing a line about it.

The old remedy was a senior partner who went through in December and struck out lines he judged frivolous. He was often right and never consistent, and no two partners struck out the same lines.

Rukmini found the answer in the code itself — a clause about the cost of maintaining records.

"Charge every new line a fee. Then a line only exists if it is worth more than its fee. We stop arguing about which lines are frivolous, because the ledger tells us."

A FEE PER LINE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Splitting one line into two is worth 112.50
                fee per new line:   20.00
                              net:  +92.50   KEEP

Splitting a different line is worth   2.00
                fee per new line:   20.00
                              net:  -18.00   DROP

The senior partner is out of a job, and the
decision is now the same every December.
Enter fullscreen mode Exit fullscreen mode

That is the whole trick, and it is not a trick. Deciding what to keep stopped being a matter of judgement applied afterwards and became a term inside the sum being minimised.


The Third Fix: A Blank Is Not a Zero

Some suppliers never sent invoices. The clerks wrote in the average invoice for that supplier's trade, which felt responsible and was quietly catastrophic — the suppliers who failed to invoice were disproportionately the ones in trouble, and a blank was the single most informative mark on the page.

Rukmini's rule: do not fill it in. Send every blank the same direction, and let the ledger discover which direction that should be.

WHAT TO DO WITH A BLANK
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FILL IT IN     write the average. The blank now
               looks exactly like an ordinary
               middling supplier. Its information
               is destroyed on purpose.

SEND IT ONE WAY   try both directions once, keep
                  whichever the ledger prefers.
                  A blank stays visible as a blank.

The blanks were not missing data. They were the
data.
Enter fullscreen mode Exit fullscreen mode

What Is XGBoost?

Hard pivot. Every one of Rukmini's three fixes is a named feature.

Gradient boosting, from day 9, builds a running sum Fm=Fm1+νhmF_m = F_{m-1} + \nu h_m where hmh_m is fit to the negative gradient. XGBoost keeps that skeleton and changes what gets optimised at each round.

The Objective

Write the objective at round mm as the loss plus a penalty on the tree itself:

L(m)=i=1nL(yi,  Fm1(xi)+fm(xi))+Ω(fm) \mathcal{L}^{(m)} = \sum_{i=1}^{n} L\big(y_i,\; F_{m-1}(x_i) + f_m(x_i)\big) + \Omega(f_m)
Ω(f)=γT+12λj=1Twj2 \Omega(f) = \gamma T + \tfrac{1}{2}\lambda \sum_{j=1}^{T} w_j^2

where TT is the number of leaves and wjw_j their values. γ\gamma is the fee per line. λ\lambda is an L2 penalty on how large a correction any leaf may make.

The Second-Order Step

Now expand the loss to second order in fmf_m — this is the whole difference from a plain GBM:

L(m)i[gifm(xi)+12hifm(xi)2]+Ω(fm) \mathcal{L}^{(m)} \simeq \sum_i \Big[ g_i f_m(x_i) + \tfrac{1}{2} h_i f_m(x_i)^2 \Big] + \Omega(f_m)
gi=LFFm1,hi=2LF2Fm1 g_i = \frac{\partial L}{\partial F}\bigg|{F{m-1}}, \qquad h_i = \frac{\partial^2 L}{\partial F^2}\bigg|{F{m-1}}

gig_i is how wrong. hih_i is how stiff. A plain GBM only ever computes the first.

The Leaf Weight Falls Out

Fix the tree's structure and let IjI_j be the rows landing in leaf jj , with Gj=iIjgiG_j = \sum_{i \in I_j} g_i and Hj=iIjhiH_j = \sum_{i \in I_j} h_i . The objective becomes a sum of independent quadratics in each wjw_j , and a quadratic has a closed-form minimum:

wj=GjHj+λ w_j^{*} = -\frac{G_j}{H_j + \lambda}

No line search, no gradient step — the optimal leaf value is an exact formula. Substituting it back gives the quality of a whole tree structure:

L=12j=1TGj2Hj+λ+γT \mathcal{L}^{*} = -\frac{1}{2}\sum_{j=1}^{T} \frac{G_j^2}{H_j + \lambda} + \gamma T

And Therefore the Split Criterion

A split is worth making if it lowers L\mathcal{L}^* . Subtracting the parent from its two children:

Gain=12[GL2HL+λ+GR2HR+λ(GL+GR)2HL+HR+λ]γ \text{Gain} = \frac{1}{2}\left[\frac{G_L^2}{H_L+\lambda} + \frac{G_R^2}{H_R+\lambda} - \frac{(G_L+G_R)^2}{H_L+H_R+\lambda}\right] - \gamma

This is the payoff. Gini and entropy are heuristics chosen because they behave sensibly. This is derived — it is the exact reduction in the objective you are already minimising, with the cost of the new leaf subtracted. A negative gain means don't split, and that is pre-pruning with a receipt.

Step by Step

  1. Start from a constant F0F_0 .
  2. For m=1,,Mm = 1, \dots, M :
    1. Compute gig_i and hih_i for every row.
    2. Grow a tree greedily, scoring each candidate split by the Gain formula and refusing any split with Gain 0\leq 0 .
    3. Set each leaf to wj=Gj/(Hj+λ)w_j^* = -G_j/(H_j+\lambda) .
    4. Update Fm=Fm1+νfmF_m = F_{m-1} + \nu f_m .
  3. Return FMF_M .

Regularisation You Can Watch Working

The leaf formula, on one leaf holding four rows with residuals 3, 5, 4, 8:

import numpy as np

resid = np.array([3.0, 5.0, 4.0, 8.0])
g = -resid                       # squared error: g = pred - y
G, H = g.sum(), float(len(g))    # h_i = 1 for squared error
print(f"  G = {G:+.1f}   H = {H:.0f}")
print("\n  lambda    w* = -G/(H+lambda)    plain mean residual")
for lam in (0.0, 1.0, 4.0, 12.0, 40.0):
    print(f"  {lam:>6}    {-G/(H+lam):>18.4f}    {resid.mean():.4f}")
Enter fullscreen mode Exit fullscreen mode
  G = -20.0   H = 4

  lambda    w* = -G/(H+lambda)    plain mean residual
     0.0                5.0000    5.0000
     1.0                4.0000    5.0000
     4.0                2.5000    5.0000
    12.0                1.2500    5.0000
    40.0                0.4545    5.0000
Enter fullscreen mode Exit fullscreen mode

At λ=0\lambda = 0 the leaf is the mean residual — exactly what a GBM would have written. Every λ>0\lambda > 0 pulls it toward zero, and note what it did not touch: the learning rate. These are two independent brakes, which is why XGBoost tolerates deeper trees than a GBM of the same learning_rate.

Now the gain formula deciding two different splits:

import numpy as np

def score(g, lam):
    return g.sum()**2 / (len(g) + lam)

def gain(left, right, lam, gamma):
    gL, gR = -np.array(left), -np.array(right)
    both = np.concatenate([gL, gR])
    return 0.5*(score(gL, lam) + score(gR, lam) - score(both, lam)) - gamma

print("  a split with real separation: [-8,-6] vs [7,9]")
for lam in (0.0, 1.0, 10.0):
    for gm in (0.0, 20.0, 100.0):
        g = gain([-8, -6], [7, 9], lam, gm)
        print(f"    lambda={lam:<5} gamma={gm:<6} gain={g:>+9.4f}  "
              f"{'split' if g > 0 else 'PRUNE'}")

print("\n  a split that is basically noise: [3,5] vs [4,8]")
for lam in (0.0, 1.0):
    g = gain([3, 5], [4, 8], lam, 0.0)
    print(f"    lambda={lam:<5} gamma=0.0    gain={g:>+9.4f}  "
          f"{'split' if g > 0 else 'PRUNE'}")
Enter fullscreen mode Exit fullscreen mode
  a split with real separation: [-8,-6] vs [7,9]
    lambda=0.0   gamma=0.0    gain=+112.5000  split
    lambda=0.0   gamma=20.0   gain= +92.5000  split
    lambda=0.0   gamma=100.0  gain= +12.5000  split
    lambda=1.0   gamma=0.0    gain= +74.9333  split
    lambda=1.0   gamma=20.0   gain= +54.9333  split
    lambda=1.0   gamma=100.0  gain= -25.0667  PRUNE
    lambda=10.0  gamma=0.0    gain= +18.6905  split
    lambda=10.0  gamma=20.0   gain=  -1.3095  PRUNE
    lambda=10.0  gamma=100.0  gain= -81.3095  PRUNE

  a split that is basically noise: [3,5] vs [4,8]
    lambda=0.0   gamma=0.0    gain=  +2.0000  split
    lambda=1.0   gamma=0.0    gain=  -5.3333  PRUNE
Enter fullscreen mode Exit fullscreen mode

Two things worth staring at.

The noise split has a positive gain of 2.0 at lambda=0 and a plain GBM would happily take it. Setting lambda=1 flips it to prune — because each of the two new leaves pays λ\lambda in its denominator while the parent paid it once. reg_lambda is not only shrinking leaf values; it is quietly raising the bar for splitting at all.

And the real split survives lambda=10, gamma=0 at a gain of 18.69 but dies at gamma=20. γ\gamma is an absolute toll and λ\lambda is proportional — they are not interchangeable, and tuning one does not cover the other.


Does Second Order Actually Buy Anything?

The honest test: same trees, same depth, same learning rate, same data. The only difference is whether each leaf is the mean gradient (first order) or G/(H+λ)-G/(H+\lambda) (second order). Logistic loss, where hi=pi(1pi)h_i = p_i(1-p_i) genuinely varies from row to row.

import numpy as np
from sklearn.tree import DecisionTreeRegressor
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import log_loss

X, y = make_classification(n_samples=3000, n_features=20, n_informative=8,
                           flip_y=0.05, random_state=4)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=4)
sig = lambda z: 1.0 / (1.0 + np.exp(-z))


def boost(order, rounds=200, lr=0.1, depth=3, lam=1.0):
    F = np.zeros(len(ytr))
    Fte = np.zeros(len(Xte))
    curve = []
    for _ in range(rounds):
        p = sig(F)
        g = p - ytr                     # first derivative
        h = p * (1.0 - p)               # second derivative
        t = DecisionTreeRegressor(max_depth=depth,
                                  random_state=0).fit(Xtr, -g)
        leaf, leaf_te = t.apply(Xtr), t.apply(Xte)
        w = {}
        for L in np.unique(leaf):
            m = leaf == L
            w[L] = (-g[m]).mean() if order == 1 \
                else -g[m].sum() / (h[m].sum() + lam)
        F += lr * np.array([w[L] for L in leaf])
        Fte += lr * np.array([w.get(L, 0.0) for L in leaf_te])
        curve.append(Fte.copy())
    return curve


c1, c2 = boost(1), boost(2)
print(" rounds |  first-order  |  second-order")
for r in (10, 25, 50, 100, 200):
    print(f"  {r:>5}  |    {log_loss(yte, sig(c1[r-1])):.4f}     |"
          f"    {log_loss(yte, sig(c2[r-1])):.4f}")
Enter fullscreen mode Exit fullscreen mode
 rounds |  first-order  |  second-order
     10  |    0.5916     |    0.4462
     25  |    0.5056     |    0.3589
     50  |    0.4346     |    0.3322
    100  |    0.3794     |    0.3150
    200  |    0.3436     |    0.3091
Enter fullscreen mode Exit fullscreen mode

Second order at 50 rounds (0.3322) beats first order at 200 rounds (0.3436). I checked how far first order would have to go to catch up: it never reaches 0.3322 within 200 rounds at all. Four times the trees, still behind.

That is Rukmini's first fix, and it is the reason XGBoost felt like a step change rather than an optimisation. Dividing by the summed curvature is not a speed trick — it lands somewhere a first-order method does not reach.


XGBoost from Scratch

Sixty lines, import xgboost used only to check the answer:

import numpy as np, xgboost as xgb
from sklearn.datasets import make_regression
from sklearn.model_selection import train_test_split
from sklearn.metrics import r2_score

X, y = make_regression(n_samples=600, n_features=8, n_informative=5,
                       noise=8.0, random_state=13)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=13)
LAM, GAMMA, DEPTH, LR, ROUNDS, BASE = 1.0, 0.0, 3, 0.3, 40, 0.0


class Node:
    __slots__ = ("feat", "thr", "left", "right", "val")


def sc(G, H):
    return G * G / (H + LAM)


def build(X, g, h, depth):
    n = Node()
    G, H = g.sum(), h.sum()
    if depth == 0 or len(g) < 2:
        n.feat, n.val = -1, -G / (H + LAM)
        return n
    best, parent = (0.0, -1, 0.0), sc(G, H)
    for f in range(X.shape[1]):
        o = np.argsort(X[:, f], kind="stable")
        cg, ch, xs = np.cumsum(g[o]), np.cumsum(h[o]), X[o, f]
        for i in range(len(xs) - 1):
            if xs[i] == xs[i + 1]:
                continue
            gain = 0.5 * (sc(cg[i], ch[i])
                          + sc(G - cg[i], H - ch[i]) - parent) - GAMMA
            if gain > best[0]:
                best = (gain, f, (xs[i] + xs[i + 1]) / 2.0)
    if best[1] < 0:                      # no split pays for itself
        n.feat, n.val = -1, -G / (H + LAM)
        return n
    n.feat, n.thr = best[1], best[2]
    m = X[:, n.feat] < n.thr
    n.left = build(X[m], g[m], h[m], depth - 1)
    n.right = build(X[~m], g[~m], h[~m], depth - 1)
    return n


def pred1(n, row):
    while n.feat >= 0:
        n = n.left if row[n.feat] < n.thr else n.right
    return n.val


F, trees = np.full(len(ytr), BASE), []
for _ in range(ROUNDS):
    g = F - ytr                          # squared error
    h = np.ones_like(g)
    t = build(Xtr, g, h, DEPTH)
    trees.append(t)
    F += LR * np.array([pred1(t, r) for r in Xtr])

mine = np.full(len(Xte), BASE)
for t in trees:
    mine += LR * np.array([pred1(t, r) for r in Xte])

theirs = xgb.XGBRegressor(
    n_estimators=ROUNDS, learning_rate=LR, max_depth=DEPTH, reg_lambda=LAM,
    gamma=GAMMA, min_child_weight=0, base_score=BASE, reg_alpha=0,
    tree_method="exact").fit(Xtr, ytr).predict(Xte)

print(f"  from scratch R2 = {r2_score(yte, mine):.6f}")
print(f"  xgboost      R2 = {r2_score(yte, theirs):.6f}")
print(f"  correlation     = {np.corrcoef(mine, theirs)[0, 1]:.8f}")
print(f"  RMSE vs xgboost = {np.sqrt(((mine - theirs)**2).mean()):.6f}")
print(f"  y std (test)    = {yte.std():.2f}")
Enter fullscreen mode Exit fullscreen mode
  from scratch R2 = 0.931456
  xgboost      R2 = 0.931628
  correlation     = 0.99999692
  RMSE vs xgboost = 0.251010
  y std (test)    = 110.06
Enter fullscreen mode Exit fullscreen mode

Agreement to 0.0002 R2, with a per-row disagreement of 0.25 on a target whose standard deviation is 110 — about two parts in a thousand. The residual gap is split tie-breaking and float32 binning inside the real thing, exactly as it was for the GBM on day 9.

If you can write that loop, reg_lambda, gamma and min_child_weight stop being knobs you turn hopefully. You know which line of arithmetic each one sits in.


Missing Values Are a Direction, Not a Hole

Rukmini's third fix. 4,000 rows, six columns with values deleted — and the deletions are informative, 40% missing when y=1y=1 versus 10% when y=0y=0 .

import numpy as np, xgboost as xgb
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import log_loss, accuracy_score
from sklearn.impute import SimpleImputer

X, y = make_classification(n_samples=4000, n_features=20, n_informative=8,
                           flip_y=0.05, random_state=6)
rng = np.random.default_rng(6)
Xm = X.copy()
for j in range(6):                                  # informative missingness
    Xm[rng.random(len(y)) < np.where(y == 1, 0.40, 0.10), j] = np.nan
Xtr, Xte, ytr, yte = train_test_split(Xm, y, test_size=0.3, random_state=6)
print(f"  {np.isnan(Xm).mean()*100:.1f}% of all cells are missing")


def fit(a, b):
    return xgb.XGBClassifier(n_estimators=300, learning_rate=0.1,
                             max_depth=4, random_state=0).fit(a, b)


p = fit(Xtr, ytr).predict_proba(Xte)[:, 1]
print(f"  NaN passed through   logloss {log_loss(yte, p):.4f}  "
      f"acc {accuracy_score(yte, p > 0.5):.4f}")

imp = SimpleImputer(strategy="mean").fit(Xtr)
p2 = fit(imp.transform(Xtr), ytr).predict_proba(imp.transform(Xte))[:, 1]
print(f"  mean-imputed first   logloss {log_loss(yte, p2):.4f}  "
      f"acc {accuracy_score(yte, p2 > 0.5):.4f}")
print(f"  imputation cost {log_loss(yte, p2) - log_loss(yte, p):+.4f} logloss")
Enter fullscreen mode Exit fullscreen mode
  7.6% of all cells are missing
  NaN passed through   logloss 0.1909  acc 0.9250
  mean-imputed first   logloss 0.2034  acc 0.9225
  imputation cost +0.0125 logloss
Enter fullscreen mode Exit fullscreen mode

Imputing before fitting made the model worse. Not dramatically — 0.0125 log loss — but it cost something, for extra work, and the direction is the point: mean imputation destroys information on purpose. XGBoost tries both directions for the missing rows at every split and keeps the better one, so a NaN stays distinguishable from an average value all the way through.

The habit to break is df.fillna(df.mean()) as a reflex. If your missingness has any relationship to your target — and in production it usually does, because things go missing when systems are failing — hand the NaNs over and let the split decide.


When Does XGBoost Help Most?

XGBoost sklearn GBM Random Forest
Derivatives used first and second first none
Leaf value G/(H+λ)-G/(H+\lambda) , exact mean gradient mean target
Pruning gain 0\leq 0 , derived max_depth guess none needed
Missing values learned direction must impute must impute
Regularisation λ,γ,α\lambda, \gamma, \alpha in the objective shrinkage only n/a
Parallel within a tree yes no yes, across trees
Sensible without tuning mostly mostly yes

Use it for tabular data with real structure, a target worth the last two points of accuracy, and enough rows that tree_method="hist" matters. Don't use it for pixels, audio, raw text, or a first look at a dataset you don't understand yet — a Random Forest will tell you whether there is signal at all, faster and with fewer decisions.


Hyperparameters

Parameter What it does Typical range How to tune
learning_rate fraction of each tree added 0.01–0.1 set low, early-stop
n_estimators rounds ceiling, not target early_stopping_rounds
max_depth interaction order 3–8 4–6 is the usual sweet spot
min_child_weight minimum HH in a leaf 1–20 raise on noisy data
reg_lambda L2 on leaf values 1–10 also raises the split bar
gamma fixed toll per new leaf 0–5 try before raising depth
subsample rows per round 0.6–1.0 0.8
colsample_bytree columns per tree 0.6–1.0 0.8
tree_method split search hist hist unless tiny data
scale_pos_weight class imbalance n/n+n_-/n_+ for skewed binary targets

Note min_child_weight is a floor on the summed hessian, not the row count. On squared error, where every hi=1h_i = 1 , they coincide and everyone learns the wrong intuition. On logistic loss a leaf full of confidently-classified rows has tiny hessians, so min_child_weight=10 may reject a leaf holding hundreds of rows.


Quick Reference Card

XGBOOST: CHEAT SHEET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
WHAT IT IS:
  Gradient boosting where each round solves a
  regularised second-order problem exactly, instead
  of taking a first-order step.

THE THREE FORMULAS:
  leaf   w* = -G / (H + lambda)
  tree   L* = -0.5 * sum(G_j^2/(H_j+lambda)) + gamma*T
  split  Gain = 0.5*[ G_L^2/(H_L+lam)
                    + G_R^2/(H_R+lam)
                    - G^2/(H+lam) ] - gamma

WHAT EACH KNOB TOUCHES:
  lambda  shrinks leaves AND raises the split bar
  gamma   flat toll per new leaf (pre-pruning)
  alpha   L1, drives leaves to exactly zero
  mcw     floor on summed HESSIAN, not row count

MEASURED:
  2nd order @ 50 rounds  0.3322 logloss
  1st order @ 200 rounds 0.3436  (never catches up)
  NaN passed through     0.1909
  mean-imputed           0.2034

WHEN TO USE:
  + tabular, structured, worth tuning
  + informative missingness
  + you need the last two points
WHEN NOT EFFECTIVE:
  - images, audio, raw text
  - first look at unfamiliar data
  - you cannot early-stop

SKLEARN-COMPATIBLE:
  from xgboost import XGBRegressor, XGBClassifier
  # or sklearn's HistGradientBoosting* for no new dep
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. The second derivative is the headline. Same trees, same rate: second order hit 0.3322 log loss in 50 rounds where first order sat at 0.3436 after 200 and never caught up.

  2. The leaf value is a closed form, not a step. w=G/(H+λ)w^* = -G/(H+\lambda) is the exact minimum of a quadratic, which is why there is no line search anywhere in XGBoost.

  3. The split criterion is derived, not chosen. Gain is the actual drop in the objective minus the cost of the new leaf. Gini and entropy are heuristics; this is arithmetic.

  4. reg_lambda does two jobs. It shrinks leaf values and raises the bar for splitting, because two children each pay λ\lambda where the parent paid it once. A noise split with gain +2.0 at lambda=0 became -5.33 at lambda=1.

  5. γ\gamma and λ\lambda are not interchangeable. One is a flat toll, one is proportional. A split survived lambda=10 at gain 18.69 and died at gamma=20.

  6. Don't impute before boosting. Passing NaN through scored 0.1909 against 0.2034 mean-imputed. Missingness is usually correlated with the target, and filling it in erases that on purpose.

  7. min_child_weight is a hessian floor, not a row count. Identical to a row count on squared error, very different on log loss — which is where the confusion always bites.

  8. Sixty lines reproduce it to two parts in a thousand. If you can write the gain formula from memory, every regularisation parameter has an address.


The One-Sentence Summary

XGBoost is what happens when someone reads the objective function as carefully as Rukmini read the tax code: expand the loss to second order and the optimal leaf value becomes an exact formula rather than a step, put the cost of each leaf inside that objective and pruning becomes a subtraction rather than a guess, and treat a missing value as a direction to be learned rather than a hole to be plastered over — three footnotes that between them turned gradient boosting from a good algorithm into the default one.


What's Next?

Now that you know where every XGBoost parameter lives in the maths, you're ready for:

  1. LightGBM — tomorrow. Histogram binning and leaf-wise growth: the same objective, an order of magnitude less arithmetic, and one genuinely dangerous default.
  2. CatBoost — ordered boosting, and the target-encoding leak the other two quietly live with.
  3. Bagging vs Boosting — the settled comparison, measured rather than asserted.
  4. Hyperparameter tuning — what to search when four parameters interact.

Follow me for the next article in the Boosting: The Complete Guide series!


Let's Connect!

If the counting house made the gain formula click, drop a heart!

Questions? Ask in the comments — I read and respond to every one.

Have you ever imputed missing values by reflex and never checked what it cost? I did it for years, in a pipeline where the missingness was the strongest feature in the dataset. 🗿


The thing I find genuinely elegant about XGBoost is that it did not invent a new idea. It took gradient boosting and asked what the objective function actually said, in the way that a good accountant reads a statute — not looking for loopholes, just refusing to skim. Second-order terms, a penalty on complexity, a decision rule for missing entries: all three were sitting in the maths, waiting for somebody to be pedantic enough to write them down.

Top comments (0)