Module 6: Trees and ensembles 40 min9 min quick readClassical ML
Random forests and gradient boosting
Combine many trees by bagging (random forests) or boosting (XGBoost, LightGBM, CatBoost), run boosting rounds by hand, and tune the knobs that matter.
Before this: Decision trees, Gradient descent
Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.
Mental model. Bagging asks many independent experts and averages their answers, so individual quirks cancel out. Boosting builds a team in sequence: each new member studies only the mistakes the team is still making, and contributes a small, careful correction.
You will learn to
- Explain bootstrap sampling and random feature selection in random forests.
- Compare bagging and boosting in terms of bias and variance.
- Run gradient-boosting rounds on a tiny regression dataset by hand.
- Tune learning rate, number of estimators, depth, and subsampling, and know how they interact.
- Explain feature importance and SHAP at a high level.
Why it matters. For structured, tabular data, gradient-boosted trees (XGBoost, LightGBM, CatBoost) are often the strongest model you can train, and random forests are a robust, low-effort baseline. Knowing their knobs is a core skill for production ML.
1. Intuition
A single deep tree has low bias but high variance: retrain on slightly different data and it changes a lot.
Bagging (bootstrap aggregating): train many deep trees, each on a random sample of the rows drawn with replacement (a bootstrap sample). Average their predictions (or vote). Individual trees' errors are partly independent, so averaging cancels much of the variance. Random forests add a twist: at each split a tree may consider only a random subset of features, which makes trees more different from each other and the average even more stable.
Boosting: start with a simple prediction (the mean). Fit a small tree to the current errors (residuals). Add a fraction of it (the learning rate) to the ensemble. Repeat hundreds of times. Each tree is weak, but the sum becomes strong. Boosting mainly reduces bias, and it can overfit if you keep adding trees, so you stop early using a validation set.
2. Visualization
Gradient boosting, round by round
Synthetic 1D regression (y = sin x + 0.3x + noise). Each round fits a one-split tree (a stump) to the current residuals and adds a fraction of it.
Try it (predict first, then check):
- At round 0 the prediction is a flat line. Predict where the first stump will split, then step to round 1.
- Set the learning rate to 1.0, then to 0.05. Which reaches its best test error in fewer rounds, and which has the lower best error?
- At the default settings, push rounds to 200. Predict whether test error turns back up. (Barely: with clean data, stumps overfit slowly.) Now press Too many rounds (noisy data), which raises label noise to σ = 0.6 and η to 1. Find the round where test error was lowest: that's what early stopping picks. What made the difference?
3. The math
Symbols
| Symbol | Meaning |
|---|---|
| number of trees in a forest | |
| prediction of tree | |
| boosted ensemble after rounds | |
| the small tree fitted in round | |
| learning rate (shrinkage) | |
| residual, or more generally the negative gradient of the loss |
Bagging
If each tree has variance and pairwise correlation , the average has variance . More trees shrink the second term; random feature selection lowers , shrinking the first.
Gradient boosting
For squared error the residual is exactly the negative gradient of the loss with respect to the prediction, so boosting is gradient descent in function space. Other losses (log loss for classification) replace with their negative gradient. XGBoost and LightGBM also use second derivatives and add regularization on leaf values.
Worked example: three boosting rounds
Data , ; stumps (depth-1 trees); .
Round 0. for every point. MSE .
Round 1. Residuals . The best stump splits at : left mean , right mean . . MSE .
Round 2. Residuals . Best stump splits at : left , right mean . . MSE .
Round 3. Residuals . Split at : left mean , right . . MSE .
Each round fixes part of what remains, and the learning rate keeps every correction partial.
4. Implementation
Build it from scratch
Both ensembles reuse the scratch tree from Decision trees (gini, best_split, grow, predict in tree_scratch.py). A forest tree differs in one way. At each split it may only look at max_features randomly chosen columns. That is the decorrelation trick that lowers in the variance formula.
def grow_random(X, y, rng, max_features):
"""A forest tree: like grow(), but each split may only look at max_features random columns."""
leaf = {"value": int(np.bincount(y, minlength=2).argmax())}
if gini(y) == 0:
return leaf
cols = rng.choice(X.shape[1], max_features, replace=False)
j, t, _ = best_split(X[:, cols], y)
if j is None:
return leaf
j = cols[j] # map back to the real column
mask = X[:, j] <= t
return {"feature": j, "threshold": t,
"left": grow_random(X[mask], y[mask], rng, max_features),
"right": grow_random(X[~mask], y[~mask], rng, max_features)}Predict: if max_features equals the number of columns, how does this differ from plain bagging?
Bagging is the outer loop. Each tree trains on a bootstrap sample: row indices drawn with replacement. The forest predicts by majority vote.
def random_forest(X, y, n_trees, max_features, rng):
"""Bagging: each tree sees a bootstrap sample (n rows drawn with replacement)."""
trees = []
for _ in range(n_trees):
rows = rng.integers(0, len(y), len(y))
trees.append(grow_random(X[rows], y[rows], rng, max_features))
return trees
def forest_predict(trees, X):
votes = np.array([predict(t, X) for t in trees]) # [n_trees, n_samples]
return (votes.mean(axis=0) > 0.5).astype(int) # majority vote for labels 0/1Predict: about what fraction of the original rows does one bootstrap sample miss? (Each row is missed with probability .) Those missed rows are the "out-of-bag" set.
Boosting needs a regression stump. It tries each threshold, predicts the mean residual on each side, and keeps the threshold with the smallest squared error.
def fit_stump(x, r):
"""Depth-1 regression tree on one feature: the threshold whose two leaf means minimize squared error."""
best = (np.inf, None, None, None)
values = np.unique(x)
for t in (values[:-1] + values[1:]) / 2:
left, right = r[x <= t], r[x > t]
sse = np.sum((left - left.mean()) ** 2) + np.sum((right - right.mean()) ** 2)
if sse < best[0]:
best = (sse, t, left.mean(), right.mean())
_, t, lv, rv = best
return lambda z: np.where(z <= t, lv, rv), tPredict: on round 1 of the worked example the residuals are . Which threshold wins, and what are the two leaf values?
The boosting loop is the update rule from section 3, line for line: start at the mean, fit the residuals, take a step of size .
def gradient_boost(x, y, rounds, lr):
"""F_0 = mean(y); each round fits a stump to the residuals y - F and adds lr * stump."""
F = np.full(len(y), y.mean())
history = [F.copy()]
for _ in range(rounds):
stump, _ = fit_stump(x, y - F) # residual y - F = negative gradient of (y - F)^2 / 2
F = F + lr * stump(x)
history.append(F.copy())
return historyPredict: with lr=1.0 instead of 0.5, what is ? Is the round-1 MSE lower or higher than 2.5625?
Use the library
scikit-learn's RandomForestClassifier and GradientBoostingRegressor/Classifier are these loops in optimized form. XGBoost and LightGBM add second-order gradients, leaf-weight regularization, histogram-binned splits, and early stopping on a validation set.
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier # pip install xgboost
rf = RandomForestClassifier(n_estimators=500, max_features="sqrt", n_jobs=-1, random_state=0)
rf.fit(X_train, y_train)
xgb = XGBClassifier(
n_estimators=2000, learning_rate=0.05, max_depth=5,
subsample=0.8, colsample_bytree=0.8,
early_stopping_rounds=100, eval_metric="logloss",
)
xgb.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False) # stops when validation loss stalls
print(xgb.best_iteration)The runnable script checks the scratch code against scikit-learn only, so no extra install is needed.
Run it
From guide/:
uv run code/06-trees-and-ensembles/ensembles_scratch.pyKey output:
round 0: F = [5.5, 5.5, 5.5, 5.5] MSE 7.2500
round 1: F = [4.25, 4.25, 6.75, 6.75] MSE 2.5625
round 2: F = [3.125, 4.625, 7.125, 7.125] MSE 1.2969
round 3: F = [2.8125, 4.3125, 6.8125, 8.0625] MSE 0.4180
scikit-learn after 3 rounds: [2.8125, 4.3125, 6.8125, 8.0625]
One unlimited tree: test 0.887
Scratch forest, 1 trees: test 0.873
Scratch forest, 10 trees: test 0.913
Scratch forest, 100 trees: test 0.917
scikit-learn forest, 100: test 0.917The boosting rounds reproduce the worked example exactly, and scikit-learn agrees to four decimals. On the synthetic two moons, one forest tree (bootstrap plus random features) is slightly worse than one full tree. Averaging 100 of them beats the single tree by 3 points. The figure and learning-rate curves come from code/06-trees-and-ensembles/trees.py.
5. Engineering
The knobs.
| Knob | Effect if increased | Typical risk |
|---|---|---|
n_estimators | more corrective trees | overfitting, slower training (use early stopping) |
learning_rate | larger contribution per tree | unstable or aggressive fit |
max_depth | more feature interactions | overfitting |
subsample | fraction of rows per tree | too low can underfit |
colsample_bytree | fraction of features per tree | too low can miss signal |
regularization (lambda, min_child_weight) | simpler leaf values | too much underfits |
Lower learning rate plus more trees (with early stopping) is the usual recipe.
Random forests need little tuning: more trees never hurts accuracy (only time), and max_features is the main knob. Out-of-bag error gives a free validation estimate.
Which library? LightGBM is fast on large data; CatBoost handles categorical features natively and is robust with defaults; XGBoost is mature and widely deployed.
Interpretation. Impurity-based importances are biased toward high-cardinality features; permutation importance on validation data is more honest. SHAP values attribute each prediction to features additively and are the standard for explaining boosted trees.
An honest note on the figure's data. On the noisy moons dataset in the script, a depth-6 tree, a random forest, and gradient boosting all reach about 0.92 test accuracy. Ensembles don't always win by a large margin on a small, easy 2D problem. Their advantages show up as stability across resamples and on larger, messier, higher-dimensional tables.
Watch outFailure modes. Boosting for too many rounds without early stopping; tuning on the test set; leakage through target-encoded categoricals; extrapolation (tree ensembles predict constants outside the training range); trusting impurity importance.
Common mistakes
- Treating
n_estimatorsas "more is better" for boosting (it is for forests). - Scaling features for tree models (unneeded).
- Comparing a tuned XGBoost to an untuned baseline and calling it a fair comparison.
6. Knowledge check
Quiz: Random forests and gradient boosting
Boosting with and learning rate 0.2. A tree predicts residual for an example. What is for that example?
Practice exercise. Data , , , stumps. Compute , the first residuals, the stump's predictions, and .
Solution
. Residuals . A stump separates the two points: . . With it takes many rounds to approach , which is the point of shrinkage.
Implementation challenge. Implement gradient boosting for regression from scratch using DecisionTreeRegressor(max_depth=2) as the weak learner, then compare its validation MSE curve with scikit-learn's GradientBoostingRegressor using the same settings.
Summary
- Bagging (random forests) averages many decorrelated deep trees and mainly reduces variance.
- Boosting adds small trees fitted to residuals (negative gradients), scaled by a learning rate, and mainly reduces bias.
- Low learning rate plus many trees with early stopping is the standard boosting recipe.
- Gradient-boosted trees are a top baseline for tabular data; explain them with SHAP or permutation importance.
Next: K-means clustering
Related: Decision trees · Gradient descent · Model card: gradient boosting · Model card: XGBoost
Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): Ensemble Methods.
Interview angle
Bagging versus boosting: what does each reduce, and why?
Bagging trains models independently on bootstrap samples and averages them. Averaging reduces variance: with trees of variance and pairwise correlation , the average has variance , so it works best on low-bias, high-variance models like deep trees; random forests also sample features at each split to lower . Boosting trains models sequentially, each fitting the residuals (the negative gradient of the loss) of the current ensemble, so it mainly reduces bias, building a strong model from shallow, high-bias trees. Consequences: forests are hard to overfit by adding trees and need little tuning; boosting usually reaches higher accuracy on tabular data but overfits with too many rounds, so it needs a learning rate, early stopping, and more tuning. Forests parallelize trivially; boosting rounds are sequential.
Each tree in a forest has variance σ² = 1 and pairwise correlation ρ = 0.3. What's the variance of a 100-tree average? What about infinitely many trees, and how would you do better?
Variance of the average is . With infinitely many trees it only falls to : past a few hundred trees, adding more barely helps, because the correlated part doesn't average away. Lowering the correlation to gives , nearly three times lower. That's the core logic of random forests: max_features (for example for classification) forces trees to choose among different features, which lowers at the cost of slightly weaker individual trees. Set it too small and each tree becomes too weak, so tune it. The practical rule that follows: set n_estimators high enough that out-of-bag error has flattened (more trees never hurt accuracy, only time), and spend tuning effort on decorrelation and tree depth.
Your gradient boosting model looks great in cross-validation but is much worse in production. Its top feature is a target-encoded categorical. What's going on?
Likely target-encoding leakage. If each category is replaced by its mean target computed on the full training set, every row's encoding includes its own label. Rare categories with one or two rows get an encoding that almost equals the label, and boosting exploits it. Cross-validation is fooled too if the encoding was computed before splitting. In production, new rows don't contribute to their own encoding, so the signal disappears. Fixes: compute encodings out-of-fold (each row encoded using only other folds), smooth rare categories toward the global mean, or use CatBoost, whose ordered target statistics are designed to prevent this. Also check for time leakage if encodings use future data, and confirm early stopping used a proper validation set. Verify with permutation importance on a clean, time-based holdout.
Walk through one round of gradient boosting for squared error. Why is it called "gradient" boosting?
Start with . Each round, compute residuals , fit a small tree to predict them, and update . In this lesson's example, starts at 5.5 with MSE 7.25; a stump at predicts residuals of , and with the predictions become , MSE 2.56. It's "gradient" because for squared loss , the negative gradient with respect to the prediction is exactly the residual. Each round is a gradient-descent step in function space, with the tree approximating the negative gradient and as the step size. For other losses, such as log loss, you fit the tree to that loss's negative gradient instead; XGBoost and LightGBM also use second derivatives.