๐Ÿ“‰ Gradient Boosting from Scratch

Fit the negative gradient - and why "fit the residual" is only the squared-error special case
Trees & Ensembles Intermediate
Contents

    Gradient Boosting from Scratch

    [[adaboost]] reweights the training rows. [[xgboost]], [[lightgbm]] and [[catboost]] all describe themselves as gradient boosting. This page is the step in between โ€” the idea those three libraries are optimised implementations of.

    The idea in one sentence: fit each new tree to the negative gradient of the loss with respect to the current predictions.

    That sounds abstract until you compute the gradient for squared error, at which point it becomes something much more familiar:

    $$L = \tfrac{1}{2}(y - F)^2 \qquad \frac{\partial L}{\partial F} = -(y - F) \qquad -\frac{\partial L}{\partial F} = y - F = \text{the residual}$$

    So "fit the next tree to the residuals" is not a separate algorithm โ€” it is what gradient boosting becomes when the loss is squared error. Change the loss and the same machinery gives you something else, which is the whole point of the generalisation.

    Every number below is computed by hand on 12 EEG recordings and checked against scikit-learn.

    Display helpers ready: banner, step, check, expand, show_matrix, show_matmul, show_elementwise.
    12 recordings x 3 features
    
        id      region   line_length    spike_rate           age   y_class    y_reg
    -------------------------------------------------------------------------------
     rec00     frontal          12.0           0.5          24.0         0      0.2
     rec01    temporal          15.0           1.2          31.0         0      0.5
     rec02    temporal          42.0           6.8          58.0         1      6.4
     rec03   occipital          38.0           5.1          47.0         1      4.8
     rec04     frontal          11.0           0.3          19.0         0      0.1
     rec05    temporal          55.0           8.2          63.0         1      9.1
     rec06   occipital          40.0           1.9          35.0         1      3.5
     rec07    temporal          47.0           7.4          52.0         1      7.2
     rec08     frontal          14.0           0.8          28.0         0      0.3
     rec09   occipital          51.0           6.1          60.0         1      6.9
     rec10     frontal          45.0           2.4          41.0         0      1.6
     rec11    temporal          35.0           4.7          30.0         1      4.1
    -------------------------------------------------------------------------------
    
    class balance: 5 negative, 7 positive
    y_reg mean = 3.7250, std = 3.0518

    1. Three rounds, by hand

    The algorithm (Friedman, 2001):

    1. Start with a constant prediction $F_0$ โ€” the value minimising the loss with no features. For squared error that is the mean of $y$.
    2. For $m = 1 \dots M$:
    3. compute the pseudo-residuals $r_i = -\partial L/\partial F(x_i)$ evaluated at the current $F_{m-1}$
    4. fit a shallow regression tree to those residuals
    5. update $F_m = F_{m-1} + \eta\, h_m$, where $\eta$ is the learning rate

    The trees are deliberately weak โ€” depth 1 or 2 โ€” and $\eta$ is small. Each round corrects a little of what remains.

    ==============================================================================
      GRADIENT BOOSTING, THREE ROUNDS ON 12 RECORDINGS
    ==============================================================================
    
    Round 0: the constant that minimises squared error
    ==============================================================================
      F_0 = mean(y) = (0.2 + 0.5 + 6.4 + 4.8 + ...) / 12 = 3.7250
      SSE = 111.7625
    
    Round 1
    ==============================================================================
      pseudo-residuals r = y - F  (this IS -dL/dF for squared error)
            id        y         F    r = y-F
         rec00     0.20    3.7250    -3.5250
         rec01     0.50    3.7250    -3.2250
         rec02     6.40    3.7250     2.6750
         rec03     4.80    3.7250     1.0750
         rec04     0.10    3.7250    -3.6250
         rec05     9.10    3.7250     5.3750
         rec06     3.50    3.7250    -0.2250
         rec07     7.20    3.7250     3.4750
         rec08     0.30    3.7250    -3.4250
         rec09     6.90    3.7250     3.1750
         rec10     1.60    3.7250    -2.1250
         rec11     4.10    3.7250     0.3750
    
      tree 1: split on spike_rate <= 3.550
        left  leaf (<= 3.550) : -2.6917
        right leaf (>  3.550) : +2.6917
        check: mean(r | left)  = -2.6917   MATCH: True
               mean(r | right) = +2.6917   MATCH: True
    
      update F <- F + 0.5 * h
        SSE now = 46.5569
    
    Round 2
    ==============================================================================
      pseudo-residuals r = y - F  (this IS -dL/dF for squared error)
            id        y         F    r = y-F
         rec00     0.20    2.3792    -2.1792
         rec01     0.50    2.3792    -1.8792
         rec02     6.40    5.0708     1.3292
         rec03     4.80    5.0708    -0.2708
         rec04     0.10    2.3792    -2.2792
         rec05     9.10    5.0708     4.0292
         rec06     3.50    2.3792     1.1208
         rec07     7.20    5.0708     2.1292
         rec08     0.30    2.3792    -2.0792
         rec09     6.90    5.0708     1.8292
         rec10     1.60    2.3792    -0.7792
         rec11     4.10    5.0708    -0.9708
    
      tree 2: split on age <= 49.500
        left  leaf (<= 49.500) : -1.1646
        right leaf (>  49.500) : +2.3292
        check: mean(r | left)  = -1.1646   MATCH: True
               mean(r | right) = +2.3292   MATCH: True
    
      update F <- F + 0.5 * h
        SSE now = 22.1443
    
    Round 3
    ==============================================================================
      pseudo-residuals r = y - F  (this IS -dL/dF for squared error)
            id        y         F    r = y-F
         rec00     0.20    1.7969    -1.5969
         rec01     0.50    1.7969    -1.2969
         rec02     6.40    6.2354     0.1646
         rec03     4.80    4.4885     0.3115
         rec04     0.10    1.7969    -1.6969
         rec05     9.10    6.2354     2.8646
         rec06     3.50    1.7969     1.7031
         rec07     7.20    6.2354     0.9646
         rec08     0.30    1.7969    -1.4969
         rec09     6.90    6.2354     0.6646
         rec10     1.60    1.7969    -0.1969
         rec11     4.10    4.4885    -0.3885
    
      tree 3: split on line_length <= 36.500
        left  leaf (<= 36.500) : -1.2952
        right leaf (>  36.500) : +0.9251
        check: mean(r | left)  = -1.2952   MATCH: True
               mean(r | right) = +0.9251   MATCH: True
    
      update F <- F + 0.5 * h
        SSE now = 11.3600
    
    Three rounds of progress
    ==============================================================================
       round         SSE   reduction
      ------------------------------
           0    111.7625            
           1     46.5569     65.2056
           2     22.1443     24.4126
           3     11.3600     10.7843
      ------------------------------

    2. Checking it against scikit-learn

    The hand-built model above should be exactly what GradientBoostingRegressor produces with the same settings. Worth confirming, because it means the mental model is the implementation, not an analogy for it.

    ==============================================================================
      HAND-BUILT vs SCIKIT-LEARN
    ==============================================================================
          id        y    by hand    sklearn        diff
      ------------------------------------------------
       rec00     0.20     1.1493     1.1493    0.00e+00
       rec01     0.50     1.1493     1.1493    0.00e+00
       rec02     6.40     6.6980     6.6980    0.00e+00
       rec03     4.80     4.9511     4.9511    0.00e+00
       rec04     0.10     1.1493     1.1493    0.00e+00
       rec05     9.10     6.6980     6.6980    0.00e+00
       rec06     3.50     2.2594     2.2594   -4.44e-16
       rec07     7.20     6.6980     6.6980    0.00e+00
       rec08     0.30     1.1493     1.1493    0.00e+00
       rec09     6.90     6.6980     6.6980    0.00e+00
       rec10     1.60     2.2594     2.2594   -4.44e-16
       rec11     4.10     3.8409     3.8409    0.00e+00
      ------------------------------------------------
      max|diff| = 4.44e-16   MATCH: True
    
      init prediction: mine 3.725000  |  sklearn 3.725000  |  MATCH: True

    3. The learning rate: why shrink at all?

    Nothing forces $\eta < 1$. With $\eta = 1$ each tree is applied at full strength and the training error falls fastest. So why shrink?

    Because each tree was fitted to residuals computed from the current model, and it is only a good correction near that point. Taking the full step overshoots into territory the tree knows nothing about. Small steps, many of them, track the true function more carefully โ€” the same argument as a learning rate in [[optimisers-sgd-to-adam]], and it produces the same trade: lower $\eta$ needs more rounds.

    The table below shows both halves โ€” training error and held-out error โ€” because they say different things.

    ==============================================================================
      LEARNING RATE AND NUMBER OF ROUNDS
    ==============================================================================
    
    Training error always prefers the bigger step
    ==============================================================================
         eta      5 trees   20 trees  100 trees
      ----------------------------------------
        0.05      20.4023    10.4408     1.8296
         0.1      15.7170     5.5012     0.8946
         0.5       4.5781     1.0351     0.0302
         1.0       3.9095     0.9189     0.0016
      ----------------------------------------
    
    Held-out error does not
    ==============================================================================
         eta      5 trees   20 trees  100 trees  400 trees
      ---------------------------------------------------
        0.05      21.6237    13.0418     5.8020     4.8544
         0.1      17.5169     8.8375     5.0782     4.8759
         0.5       8.5494     6.8702     6.9667     6.9842
         1.0      10.3849    10.8073    11.2455    11.2466
      ---------------------------------------------------
      best held-out: eta=0.05, 400 trees -> 4.8544
    
      eta=1.0 reaches its best early and then gets WORSE with more trees.
      Small eta keeps improving for longer. Shrinkage and number of rounds
      are one decision, not two -- which is why tuning them independently
      wastes most of a grid ([[hyperparameter-search]]).

    4. Changing the loss changes the algorithm

    This is the payoff for phrasing it as gradients rather than residuals. Swap the loss and everything else stays put:

    loss $-\partial L/\partial F$ behaviour
    squared error $\tfrac12(y-F)^2$ $y - F$ the plain residual; sensitive to outliers
    absolute error $\lvert y-F \rvert$ $\operatorname{sign}(y - F)$ every row pulls with equal force โ€” robust
    Huber residual, clipped squared near zero, absolute in the tail
    log loss (binary) $y - p$, with $p = \sigma(F)$ classification, on the log-odds scale

    Look at the absolute-error row. The gradient is $\pm 1$ regardless of how wrong the prediction is, so a wildly outlying point pulls no harder than a slightly wrong one. That is the entire robustness mechanism, and it falls straight out of the derivative.

    ==============================================================================
      THE SAME MACHINERY, DIFFERENT LOSSES
    ==============================================================================
    
    Pseudo-residuals for one badly outlying point
    ==============================================================================
      recording rec03 corrupted: 4.8 -> 60.0
    
          id        y      y - F   squared-err grad   abs-err grad
      ----------------------------------------------------------
       rec00      0.2    -3.6000            -3.6000           -1.0
       rec02      6.4     2.6000             2.6000            1.0
       rec03     60.0    56.2000            56.2000            1.0
       rec05      9.1     5.3000             5.3000            1.0
      ----------------------------------------------------------
      The outlier's squared-error gradient is 56.2 -- it will
      dominate the next tree entirely. Its absolute-error gradient is 1,
      exactly the same pull as any other under-predicted row.
    
    What that does to the fitted model
    ==============================================================================
      12 rows is too few to show this: with 60 trees the model simply
      memorises every row, outlier included, and nothing is damaged.
      So this needs a real dataset and a held-out CLEAN test set.
    
      240 training rows, 14 of them (6%) corrupted by +/-60
      test set is 160 CLEAN rows the model never sees
    
                  loss   test MAE
      ----------------------------
         squared error     5.1901
                 huber     2.8239
        absolute error     1.7613
      ----------------------------
       (no corruption)     1.5127   <- the target to recover
    
      Squared error is 3.4x the clean baseline: 14 corrupted rows
      out of 240 dominate every tree, because a residual of 60
      contributes a gradient of 60.
      Absolute error gets within 0.25 of that
      baseline, because those rows pull with force 1, exactly like
      everything else. Huber sits between the two, by construction.
    
      ordering as predicted (abs < huber < squared): True
    
    Binary log loss: the gradient is y - p
    ==============================================================================
      base rate = 0.5833  ->  F_0 = log-odds = 0.3365
      p = sigmoid(F_0) = 0.5833   (recovers the base rate: True)
    
          id    y         p   grad = y - p
      ------------------------------------
       rec00    0    0.5833        -0.5833
       rec01    0    0.5833        -0.5833
       rec02    1    0.5833         0.4167
       rec03    1    0.5833         0.4167
       rec04    0    0.5833        -0.5833
       rec05    1    0.5833         0.4167
      ------------------------------------
      Positives pull up by 1-p, negatives pull down by p. Boosting then
      proceeds identically -- fit a tree to that column, add eta * h.
      The trees live on the LOG-ODDS scale; sigmoid is applied at the end.

    5. Where XGBoost goes further

    Everything above uses only the first derivative โ€” it is gradient descent in function space. XGBoost's central change is to use the second derivative too, making it Newton's method in function space.

    Expand the loss to second order around the current prediction, with $g_i = \partial L/\partial F$ and $h_i = \partial^2 L/\partial F^2$:

    $$L \approx \sum_i \left[ g_i f(x_i) + \tfrac{1}{2} h_i f(x_i)^2 \right] + \Omega(f)$$

    Minimising over a leaf containing rows $I$, with $L_2$ regularisation $\lambda$, gives the optimal leaf weight directly:

    $$w^*_I = -\frac{\sum_{i \in I} g_i}{\sum_{i \in I} h_i + \lambda}$$

    Compare with plain gradient boosting, where the leaf value is just the mean of the gradients in that leaf. The denominator is the difference: leaves whose rows have high curvature get smaller steps. For squared error $h_i = 1$, so $w^* = -\bar{g}\cdot n/(n+\lambda)$ and the two coincide apart from shrinkage โ€” which is why the distinction is invisible on regression and matters for classification, where $h_i = p_i(1-p_i)$ varies a great deal across rows.

    ==============================================================================
      FIRST-ORDER vs SECOND-ORDER LEAF VALUES
    ==============================================================================
    
    For squared error the two agree (h = 1 everywhere)
    ==============================================================================
      leaf with 6 rows
        gradient-boosting leaf = mean(residual)  = -0.208333
        XGBoost leaf = -sum(g)/(sum(h)+lambda)   = -0.208333
        MATCH: True
    
    For log loss they do NOT (h = p(1-p) varies)
    ==============================================================================
          id        p   g = -(y-p)   h = p(1-p)
      ----------------------------------------
       rec00   0.0759       0.0759       0.0701
       rec01   0.1419       0.1419       0.1217
       rec02   0.9002      -0.0998       0.0898
       rec03   0.8022      -0.1978       0.1587
       rec04   0.0474       0.0474       0.0452
       rec05   0.9427      -0.0573       0.0540
       rec06   0.7109      -0.2891       0.2055
       rec07   0.8699      -0.1301       0.1132
       rec08   0.1091       0.1091       0.0972
       rec09   0.9168      -0.0832       0.0763
       rec10   0.3543       0.3543       0.2288
       rec11   0.7503      -0.2497       0.1874
      ----------------------------------------
      h ranges from 0.0452 to 0.2288 -- a 5x spread.
      Confident rows (p near 0 or 1) have tiny curvature and barely move.
    
      lambda=0.0:  first-order leaf = +0.014959   second-order leaf = +0.166358   ratio 11.121
      lambda=1.0:  first-order leaf = +0.014959   second-order leaf = +0.058301   ratio 3.897
    
      This is the step from this page to [[xgboost]]: same loop, same
      trees, a better leaf value -- plus the regularisation term that
      lambda is doing in that denominator.

    6. What to take away

    "Fit the residual" is a special case, not the definition. Gradient boosting fits the negative gradient; for squared error that happens to be the residual. Phrasing it as a gradient is what lets the same loop do robust regression, classification, ranking, and survival analysis.

    The loss determines the behaviour. Squared error chases outliers because its gradient grows with the error; absolute error ignores them because its gradient is $\pm 1$. Neither is a special robustness feature โ€” both are just derivatives.

    Shrinkage and rounds are one decision. A large $\eta$ minimises training error fastest and generalises worst. Tune the pair together.

    XGBoost is this plus a second derivative. Leaf value $-\sum g / (\sum h + \lambda)$ rather than the mean gradient. Identical for squared error, materially different for log loss.

    Verified in this notebook

    • Three hand-built rounds reproduce GradientBoostingRegressor to machine precision, including the initial constant
    • Each stump's leaf values equal the mean of the residuals falling in that leaf
    • $\eta = 1.0$ gets worse with more trees on held-out data while $\eta = 0.05$ keeps improving
    • The first- and second-order leaf formulas agree exactly for squared error and diverge for log loss, where curvature spans a large range

    Where this connects

    [[adaboost]] is the same idea with exponential loss and row reweighting. [[xgboost]] adds the second-order step and explicit regularisation; [[lightgbm]] and [[catboost]] change how the trees are grown and how splits are found. [[optimisers-sgd-to-adam]] is the same learning-rate argument in parameter space rather than function space.

    References

    1. Greedy Function Approximation: A Gradient Boosting Machine โ€” Friedman, 2001 (Annals of Statistics)
    2. Stochastic Gradient Boosting โ€” Friedman, 2002 (Computational Statistics & Data Analysis)