How to Implement Gradient Boosting from Scratch

How to Implement Gradient Boosting from Scratch

A mathematical and practical deep dive into Gradient Boosting Decision Trees (GBDT) and XGBoost — Taylor series expansions, gradient/Hessian optimization, split gain formulas, and a complete NumPy implementation.

In competitive machine learning and tabular data benchmarks, one class of algorithms reigns supreme above all others: Gradient Boosted Decision Trees (GBDT).

While deep neural networks dominate computer vision and natural language processing, gradient boosting frameworks like XGBoost, LightGBM, and CatBoost consistently deliver state-of-the-art accuracy on structured enterprise datasets. In this comprehensive guide, we build a Gradient Boosting Regressor completely from scratch in Python. We derive the underlying loss optimization mathematics, explore second-order Taylor expansion approximations, analyze split-finding gain metrics, implement shrinkage rates and tree regularization, and benchmark convergence dynamics.


1. The Intuition: The Relay Putting Team

1.1 The Golf Putting Team Analogy

Imagine a relay golf team trying to sink a ball into a hole located exactly 100 meters away. Instead of having one star golfer hit the ball repeatedly from the starting tee, the team works sequentially in a cooperative chain:

  • Golfer 1 (Initial Model $F_0$): Hits the ball 80 meters down the fairway. The remaining distance (residual error) is $+20$ meters.
  • Golfer 2 (Weak Learner $h_1$): Walks to where Golfer 1's ball landed. Instead of aiming at the original tee, Golfer 2 aims specifically at the $+20$ meter residual error, hitting the ball 16 meters. The new remaining residual error is $+4$ meters.
  • Golfer 3 (Weak Learner $h_2$): Aims at the $+4$ meter error, hitting the ball 3.8 meters, leaving a final error of $+0.2$ meters.

By scaling each golfer's stroke by a learning rate $\eta = 0.1$ to prevent overshooting, the team's combined prediction $F_M(x) = F_0(x) + \eta h_1(x) + \eta h_2(x) + \dots + \eta h_M(x)$ achieves pinpoint accuracy. Gradient Boosting operates on this exact principle: each new decision tree is trained to predict the negative gradient (the residual errors) of the ensemble's previous predictions.

flowchart TD X["Input Features X"] F0["Initial Base Model F0(x) = Mean(Y)"] R1["Compute Residuals r1 = Y - F0(x)"] T1["Train Decision Tree h1(x) on Residuals r1"] F1["Update Ensemble F1(x) = F0(x) + η * h1(x)"] R2["Compute New Residuals r2 = Y - F1(x)"] T2["Train Decision Tree h2(x) on Residuals r2"] FM["Final Ensemble Prediction FM(x)"] X --> F0 F0 --> R1 R1 --> T1 T1 --> F1 F1 --> R2 R2 --> T2 T2 --> FM

Diagram: Additive sequential boosting workflow training decision trees on pseudo-residuals.

Pitfall — Confusing Bagging (Random Forests) with Boosting (GBDT): Random Forests build deep, independent trees in parallel using bootstrap samples (bagging) to reduce variance. Gradient Boosting builds shallow, sequential trees serially on residual errors (boosting) to reduce bias. Training deep trees in GBDT causes immediate, catastrophic overfitting.


2. Mathematical Formulation of Gradient Boosting

2.1 Loss Minimization & Pseudo-Residuals

Given a dataset $\{(x_i, y_i)\}_{i=1}^n$ and a differentiable loss function $L(y, F(x))$, our goal is to find an additive ensemble model $F(x)$ that minimizes total empirical risk:

$$ \arg\min_{F} \sum_{i=1}^n L(y_i, F(x_i)) $$

At iteration $m$, the model is updated as $F_m(x) = F_{m-1}(x) + \eta \cdot h_m(x)$. To determine the optimal direction for $h_m(x)$, we calculate the negative gradient of the loss function with respect to the current model's predictions, termed the **pseudo-residuals** $r_{im}$:

$$ r_{im} = -\left[ \frac{\partial L(y_i, F(x_i))}{\partial F(x_i)} \right]_{F(x) = F_{m-1}(x)} $$

2.2 Mean Squared Error (MSE) vs Log-Loss Derivations

For Mean Squared Error loss $L(y, F) = \frac{1}{2}(y - F)^2$:

$$ \frac{\partial L}{\partial F} = -(y - F) \implies r_{im} = y_i - F_{m-1}(x_i) $$

Under MSE loss, pseudo-residuals simplify exactly to the standard residual difference $y_i - F_{m-1}(x_i)$. For binary classification using Log-Loss $L(y, F) = -y \log(p) - (1-y) \log(1-p)$ where $p = \frac{1}{1 + e^{-F}}$, the pseudo-residual is $r_{im} = y_i - p_i$.

2.3 Robust Regression with Huber Loss

When training datasets contain extreme outliers, Mean Squared Error ($L_2$) loss penalizes outliers quadratically, pulling decision tree splits far off-target. Mean Absolute Error ($L_1$) loss is robust to outliers but has a non-differentiable discontinuity at 0. **Huber Loss** combines the best of both worlds by acting quadratically for small errors and linearly for large errors:

$$ L_\delta(y, F) = \begin{cases} \frac{1}{2}(y - F)^2 & \text{for } |y - F| \le \delta \\[6pt] \delta |y - F| - \frac{1}{2}\delta^2 & \text{for } |y - F| > \delta \end{cases} $$

The corresponding pseudo-residuals $r_{im}$ for Huber loss switch dynamically based on threshold $\delta$:

$$ r_{im} = \begin{cases} y_i - F_{m-1}(x_i) & \text{for } |y_i - F_{m-1}(x_i)| \le \delta \\[6pt] \delta \cdot \text{sgn}(y_i - F_{m-1}(x_i)) & \text{for } |y_i - F_{m-1}(x_i)| > \delta \end{cases} $$

By clipping large pseudo-residuals to $\pm \delta$, Huber loss prevents outliers from dominating tree node splits while maintaining smooth convergence for small residuals.

Pitfall — Setting learning rate (η) too high: Setting $\eta = 1.0$ turns off shrinkage, causing the model to over-correct on early noisy samples. Always use a small learning rate ($\eta \in [0.01, 0.1]$) combined with early stopping based on validation loss.


3. Second-Order Taylor Expansion & XGBoost Objectives

3.1 The Second-Order Taylor Series Approximation

Standard GBDT uses only first-order gradients. XGBoost improves optimization speed by taking a **second-order Taylor expansion** of the objective function around the previous prediction $F_{m-1}(x_i)$:

$$ L(y_i, F_{m-1}(x_i) + f_m(x_i)) \approx L(y_i, F_{m-1}(x_i)) + g_i f_m(x_i) + \frac{1}{2} h_i f_m(x_i)^2 $$

Where $g_i$ is the first-order gradient and $h_i$ is the second-order Hessian:

$$ g_i = \frac{\partial L(y_i, F_{m-1}(x_i))}{\partial F_{m-1}(x_i)}, \quad h_i = \frac{\partial^2 L(y_i, F_{m-1}(x_i))}{\partial F_{m-1}(x_i)^2} $$

3.2 Leaf Weight Optimization with Regularization

Removing constant terms $L(y_i, F_{m-1}(x_i))$ and adding L2 tree regularization $\Omega(f) = \gamma T + \frac{1}{2}\lambda \sum_{j=1}^T w_j^2$ yields the simplified objective for leaf node $j$ containing sample set $I_j$:

$$ \tilde{\mathcal{L}}^{(m)} = \sum_{j=1}^T \left[ \left(\sum_{i \in I_j} g_i\right) w_j + \frac{1}{2} \left(\sum_{i \in I_j} h_i + \lambda\right) w_j^2 \right] + \gamma T $$

Differentiating with respect to leaf weight $w_j$ and setting to 0 yields the **Optimal Leaf Weight $w_j^*$**:

$$ w_j^* = -\frac{\sum_{i \in I_j} g_i}{\sum_{i \in I_j} h_i + \lambda} $$

3.3 The Split Gain Equation

To evaluate whether a candidate split divides leaf node $I$ into left child $I_L$ and right child $I_R$, XGBoost calculates the **Split Gain $G$**:

$$ G = \frac{1}{2} \left[ \frac{(\sum_{i \in I_L} g_i)^2}{\sum_{i \in I_L} h_i + \lambda} + \frac{(\sum_{i \in I_R} g_i)^2}{\sum_{i \in I_R} h_i + \lambda} - \frac{(\sum_{i \in I} g_i)^2}{\sum_{i \in I} h_i + \lambda} \right] - \gamma $$

If the Gain $G < 0$, the split is rejected and the node is pruned, directly controlled by the minimum gain complexity parameter $\gamma$.

Pitfall — Setting lambda (L2 regularization) to zero: Without L2 regularization ($\lambda = 0$), leaf nodes with very few samples and near-zero Hessians ($\sum h_i \to 0$) produce enormous leaf weights ($w_j^* \to \infty$), destabilizing training. Keep $\lambda \ge 1.0$.


4. Complete Python Implementation from Scratch

Below is a complete, object-oriented Python implementation of a Gradient Boosting Regressor built from scratch using NumPy and a custom DecisionTreeRegressor:

import numpy as np
 
class DecisionNode:
    def __init__(self, feature=None, threshold=None, left=None, right=None, value=None):
        self.feature = feature # Split feature index
        self.threshold = threshold # Split threshold value
        self.left = left # Left sub-tree
        self.right = right # Right sub-tree
        self.value = value # Leaf prediction value
 
class SingleTreeRegressor:
    def __init__(self, max_depth=3, min_samples_split=2):
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.root = None
 
    def _mse(self, y):
        return np.mean((y - np.mean(y)) ** 2) if len(y) > 0 else 0
 
    def _best_split(self, X, y):
        best_gain = -1
        split_idx, split_thresh = None, None
        parent_mse = self._mse(y)
        n_samples, n_features = X.shape
 
        for feat in range(n_features):
            thresholds = np.unique(X[:, feat])
            for thresh in thresholds:
                left_mask = X[:, feat] <= thresh
                right_mask = ~left_mask
                if np.sum(left_mask) == 0 or np.sum(right_mask) == 0: continue
 
                n_l, n_r = np.sum(left_mask), np.sum(right_mask)
                child_mse = (n_l / n_samples) * self._mse(y[left_mask]) + (n_r / n_samples) * self._mse(y[right_mask])
                gain = parent_mse - child_mse
                if gain > best_gain:
                    best_gain, split_idx, split_thresh = gain, feat, thresh
        return split_idx, split_thresh
 
    def _build_tree(self, X, y, depth=0):
        if depth >= self.max_depth or len(y) < self.min_samples_split:
            return DecisionNode(value=np.mean(y))
        feat, thresh = self._best_split(X, y)
        if feat is None: return DecisionNode(value=np.mean(y))
 
        left_mask = X[:, feat] <= thresh
        left_sub = self._build_tree(X[left_mask], y[left_mask], depth + 1)
        right_sub = self._build_tree(X[~left_mask], y[~left_mask], depth + 1)
        return DecisionNode(feature=feat, threshold=thresh, left=left_sub, right=right_sub)
 
    def fit(self, X, y): self.root = self._build_tree(X, y)
    def _predict_row(self, node, x):
        if node.value is not None: return node.value
        return self._predict_row(node.left if x[node.feature] <= node.threshold else node.right, x)
    def predict(self, X): return np.array([self._predict_row(self.root, x) for x in X])
 
class GradientBoostingRegressorScratch:
    def __init__(self, n_estimators=100, learning_rate=0.1, max_depth=3):
        self.n_estimators = n_estimators
        self.lr = learning_rate
        self.max_depth = max_depth
        self.trees = []
        self.F0 = None
 
    def fit(self, X, y):
        self.F0 = np.mean(y) # Step 1: Base prediction
        F = np.full_like(y, self.F0, dtype=np.float64)
 
        for m in range(self.n_estimators):
            residuals = y - F # Step 2: Compute pseudo-residuals under MSE loss
            tree = SingleTreeRegressor(max_depth=self.max_depth)
            tree.fit(X, residuals) # Step 3: Fit decision tree on residuals
            prediction = tree.predict(X)
            F += self.lr * prediction # Step 4: Add shrinkage-scaled tree output
            self.trees.append(tree)
 
    def predict(self, X):
        F = np.full(X.shape[0], self.F0, dtype=np.float64)
        for tree in self.trees: F += self.lr * tree.predict(X)
        return F

5. Handling Categorical Features & Missing Values

5.1 Default Direction for Missing Data in XGBoost

In traditional decision trees, missing values must be imputed before training. XGBoost handles missing values automatically during split-finding. For each candidate split, missing values are routed to the left child and the gain is calculated, then routed to the right child and calculated. XGBoost permanently assigns the default direction that yields the maximum gain.

5.2 Target Encoding & CatBoost Ordered Boosting

One-hot encoding high-cardinality categorical features creates sparse matrices that severely slow down tree building. CatBoost solves this using **Ordered Target Encoding**, calculating mean target values on historical permutations of data to eliminate target leakage.

5.3 CatBoost Ordered Target Encoding Mathematics

When converting high-cardinality categorical features into numerical values, naive target encoding replaces category $k$ with the mean target value of all rows matching $k$. This introduces severe target leakage and overfitting. CatBoost resolves this by calculating an **Ordered Target Encoder** over randomized permutations $\sigma$ of the training dataset:

$$ \hat{x}_{i, k} = \frac{\sum_{j \in D_i} \mathbb{I}(x_{j, k} = x_{i, k}) \cdot y_j + a \cdot P}{\sum_{j \in D_i} \mathbb{I}(x_{j, k} = x_{i, k}) + a} $$

Where $D_i$ is the set of historical samples appearing *before* instance $i$ in permutation $\sigma$, $P$ is the global target mean prior, and $a > 0$ is a smoothing parameter (usually $a = 1$). Because sample $i$'s own target value $y_i$ is excluded from its encoder calculation, target leakage is completely eliminated.

Pitfall — Overfitting with One-Hot Encoding on Tree Models: Applying One-Hot Encoding to a categorical feature with 100 levels creates 100 sparse binary columns. Decision trees struggle to split on sparse 0/1 features because individual binary splits isolate tiny fractions of data. Use target encoding or CatBoost categorical handling instead.


6. Advanced: Histogram-Based Boosting, GOSS, and EFB (LightGBM vs XGBoost)

6.1 Binning Continuous Features into 255 Bins

Evaluating every exact unique threshold value for continuous features costs $O(n \cdot d)$ time per split. **LightGBM** and `HistGradientBoostingRegressor` bin continuous floating-point features into 256 discrete integer bins (fitting inside 8-bit `uint8` RAM).

Instead of sorting raw floats, split-finding builds feature histograms in $O(n)$ time. Furthermore, histograms for a right child node can be computed instantaneously by subtracting the left child histogram from the parent histogram ($\text{Hist}_{\text{Right}} = \text{Hist}_{\text{Parent}} - \text{Hist}_{\text{Left}}$), speeding up training by **10x to 20x**.

6.2 Gradient-based One-Side Sampling (GOSS)

In standard GBDT, all data instances contribute equally to split gain computation. However, instances with small gradients (small pseudo-residuals) are already well-trained by previous trees and provide minimal information gain. **Gradient-based One-Side Sampling (GOSS)** downsamples instances intelligently:

  1. Sort instances by absolute value of their gradients $|g_i|$ in descending order.
  2. Keep the top $a \times 100\%$ instances with the largest gradients (e.g. top 20%).
  3. Randomly sample $b \times 100\%$ instances from the remaining small-gradient instances (e.g. 10% of the remaining 80%).
  4. Multiply the small-gradient samples by a constant weight factor $\frac{1 - a}{b}$ when computing split gain to restore the original data distribution without biasing gradient estimates.

6.3 Exclusive Feature Bundling (EFB)

High-dimensional tabular datasets are frequently sparse (e.g. one-hot encoded vectors where features are mutually exclusive, rarely taking non-zero values simultaneously). **Exclusive Feature Bundling (EFB)** reduces feature dimensionality $d$ by bundling mutually exclusive features into single composite feature bins using a graph coloring algorithm, slashing RAM overhead and split search complexity.


7. Advanced: Leaf-Wise vs Level-Wise Tree Growth

7.1 Growth Strategies Comparison

The structural growth strategy heavily impacts model accuracy and overfitting tendencies:

flowchart TD subgraph LevelWise["Level-Wise Growth (XGBoost / Scikit-Learn)"] A1["Depth 0 Node"] --> A2["Depth 1 Left"] & A3["Depth 1 Right"] A2 --> A4["Depth 2 L-L"] & A5["Depth 2 L-R"] A3 --> A6["Depth 2 R-L"] & A7["Depth 2 R-R"] end subgraph LeafWise["Leaf-Wise Growth (LightGBM)"] B1["Depth 0 Node"] --> B2["Max Gain Leaf"] & B3["Low Gain Leaf (Stop)"] B2 --> B4["Deep Max Gain Leaf"] & B5["Low Gain Leaf"] end

Diagram: Level-wise balanced tree growth vs Leaf-wise asymmetric max-gain growth.

Level-wise growth builds balanced trees layer-by-layer, preventing deep irregular branches. Leaf-wise growth splits the single leaf node offering the highest loss reduction regardless of depth, producing lower training loss but requiring strict `max_depth` or `max_leaves` constraints to prevent overfitting.


8. Ensemble Algorithm Comparison Matrix

The table below compares major decision tree ensemble implementations:

Framework Tree Growth Strategy Optimization Order Key Performance Advantage
Random Forest Level-wise (Deep Parallel Trees) None (Bagging) Robust against noise; zero hyperparameter tuning required
Classic GBDT Level-wise (Shallow Serial Trees) First-Order Gradients Reduces bias step-by-step on arbitrary loss functions
XGBoost Level-wise / Depth-wise Second-Order (Gradient + Hessian) Regularized objective ($\gamma, \lambda$); auto missing handling
LightGBM Leaf-wise (Best-first) Second-Order + Histogram Binning Ultra-fast training speed; low RAM footprint

9. Interactive: Sequential Boosting Simulator

Click "Step Boosting Iteration" to trace how pseudo-residuals decrease across 3 sequential boosting rounds $F_0 \to F_1 \to F_2$:

Simulation Idle. Click button to start boosting...
Round 0: Initial Mean Base Model F0(x)
Idle
Round 1: Tree h1(x) Fit on Residuals r1
Idle
Round 2: Tree h2(x) Fit on Residuals r2
Idle
Final Ensemble F2(x) = F0 + η*h1 + η*h2
Idle

10. Convergence Benchmark across Learning Rates

The chart below compares MSE validation loss decay across learning rate ($\eta$) configurations over 100 boosting iterations:


11. Frequently Asked Questions

Q1: Why do we fit decision trees on pseudo-residuals rather than raw target values?

Pseudo-residuals represent the exact direction of steepest descent for the loss function. Fitting trees on pseudo-residuals ensures each iteration directly minimizes the total loss of the ensemble.

Q2: How does second-order Hessian information improve XGBoost over standard GBDT?

Hessians provide curvature information about the loss landscape (Newton-Raphson step), allowing XGBoost to calculate exact optimal leaf weights and step sizes in fewer iterations.

Q3: What is the role of the learning rate (shrinkage η) in gradient boosting?

Shrinkage scales the contribution of each new tree by $\eta \in (0, 1]$, leaving room for future trees to improve the ensemble and preventing early trees from memorizing noise.

Q4: What is the difference between level-wise and leaf-wise tree growth?

Level-wise (XGBoost) grows balanced trees layer-by-layer. Leaf-wise (LightGBM) splits the single leaf node yielding the highest gain, producing lower training loss faster but risking overfitting if unconstrained.

Q5: How does XGBoost handle missing values during split finding?

XGBoost tests routing all missing values to the left child and right child for every split, automatically picking the default direction that maximizes split gain.

Q6: What is the purpose of L2 regularization (λ) in XGBoost leaf weights?

L2 regularization adds $\lambda$ to the denominator of leaf weights ($w_j^* = -\frac{\sum g_i}{\sum h_i + \lambda}$), preventing extreme predictions when a leaf contains very few samples.

Q7: Why are Shallow Trees (depth 3-6) preferred in Gradient Boosting?

Shallow trees act as weak learners with high bias and low variance. Boosting combines hundreds of high-bias weak trees into a high-capacity strong model without suffering from high variance.

Q8: How does Histogram-Based Binning speed up LightGBM?

Binning continuous features into 256 integer bins replaces $O(n \log n)$ sorting with $O(n)$ histogram construction and allows fast subtraction ($\text{Hist}_{\text{Right}} = \text{Hist}_{\text{Parent}} - \text{Hist}_{\text{Left}}$).

Post a Comment

Previous Post Next Post