Random forests: bagging with one extra rule

On this page
  1. The problem with bagging: every tree looks the same
  2. The fix: limit the choice at each split
  3. The results
  4. Which features matter?
  5. The code
  6. Why banks like random forests
  7. Summary
  8. References

Random forests are covered in section 8.2.2 of An Introduction to Statistical Learning. The method is Leo Breiman’s, from his 2001 paper “Random Forests”. It works well with little tuning on tables of features, the shape most finance data comes in: credit applications, payments, daily prices.

The bagging post ended on a limit. Averaging many trees cuts variance only down to a floor:

ρ, the correlation between the trees' errorsσ2, the variance of one tree

floor=ρ σ2\text{floor} = \key{\rho}\,\risk{\sigma^2}

ρ\key{\rho} is the correlation between the trees’ errors: 1 means they all miss in the same way, 0 means their misses are unrelated. σ2\risk{\sigma^2} is the variance of a single tree, how much it changes when it’s trained on different data. Averaging shrinks the second term towards zero but can’t touch the first. Random forests attack ρ\key{\rho}.

The problem with bagging: every tree looks the same

On the stress-month data, the strongest single signal of a rough month ahead is the 20-day volatility of the month just gone. I bagged 300 trees and looked at the first question each tree asks (Table 1):

Table 1. First question asked by 300 bagged trees
ModelTrees whose first question is “20-day volatility?”
Bagging300 of 300

Each tree sees a different bootstrap sample, but the signal is so strong that every tree starts with it, and probably makes similar choices after it. The trees are different, but not very different. Their mistakes are correlated, so averaging them can’t cancel much.

The fix: limit the choice at each split

A random forest adds one rule to bagging:

At every split, pick a random subset of m\key{m} features, and only consider those.

Sometimes the 20-day volatility isn’t in the subset. Then the tree has to find the next-best question using the other features, and it learns something the other trees didn’t.

Table 2. First question by features allowed per split, bagging against forests
ModelFeatures allowed per splitTrees starting with “20-day volatility?”
Baggingall 7300 of 300
Random forest3 of 7122 of 300
Random forest1 of 734 of 300

Now the trees genuinely differ (Table 2): some start with the 5-day volatility, some with the 20-day return, some with the price against its moving average. Each is a bit weaker on its own, but they are wrong in more different ways, so their average is better.

One split, with seven features and m=3\key{m} = 3:

sample three at random

seven features

1-day return

5-day return

20-day return

5-day volatility

20-day volatility

price vs 20-day average

Bollinger position

best split among those three

How big should m\key{m} be? With pp features, the usual starting point for classification is

m, the features each split may use

m≈p\key{m} \approx \sqrt{p}

which is what ISLP recommends and what scikit-learn’s RandomForestClassifier uses by default. With seven features, 7≈2.6\sqrt{7} \approx 2.6, so the default is 2; the forests above used 3 and 1.

Note. scikit-learn’s RandomForestRegressor defaults to all features, which is plain bagging. Set max_features yourself, and tune it.

These are starting points, not laws: tune m\key{m} on held-out data. Setting m=p\key{m} = p turns a random forest back into plain bagging.

The results

Same 4,930 training rows (2020 to 2023) and 2,205 test rows (2024 to 2025) as in the earlier posts. Every tree is grown until no leaf holds fewer than 20 rows, so the only difference between the models in Table 3 is the randomness. Scores are AUC on the test rows (0.5 is a coin flip, 1.0 is perfect):

Table 3. Test AUC of one tree, bagging, random forests and extra trees
ModelTest AUCΔ vs bagging
One tree0.854−0.035
Bagging, 300 trees0.889
Random forest, 300 trees, 3 of 7 features per split0.891+0.002
Random forest, 300 trees, 1 of 7 features per split0.897+0.008
Extra trees, 300 trees, 3 of 7 features, random cut-offs0.899\key{0.899}+0.010

Are gaps this small real, or just the random seed, the number that starts the random draws? I re-ran each ensemble with five random seeds: bagging scored 0.888–0.890, the forest with 3 of 7 features 0.891–0.892, with 1 of 7 features 0.897 on all five seeds, and extra trees 0.898–0.900. None of the four ranges overlap, so the order in Table 3 holds, though the gap between neighbours is never more than 0.006.

Each step adds more randomness, and each step up to the last helps. The last row is a variation called extremely randomised trees (scikit-learn’s ExtraTreesClassifier). It also picks the cut-off values at random instead of searching for the best one, which makes the trees even more different and very fast to train.

With only seven features, several of which say similar things, the gains are modest. Random forests shine most with many features, where a few strong ones would otherwise dominate every tree.

Which features matter?

Random forests can report how much each feature contributes. There are two common ways, and they can disagree badly.

Impurity importance (scikit-learn’s feature_importances_): add up how much each feature reduced Gini impurity across all the splits in all the trees. Gini impurity measures how mixed the classes are in a node: 0 means every row in it has the same outcome. It’s free, since it comes from training.

Permutation importance: take the test set, randomly shuffle one feature’s column so it no longer carries any information, and measure how much the test score drops. The bigger the drop, the more the model relied on that feature.

Table 4. Impurity against permutation importance for the seven features
FeatureImpurity importancePermutation importance (drop in test AUC)
20-day volatility0.5380.200
5-day volatility0.2490.016
20-day return0.0800.003
Price vs 20-day average0.050−0.000
5-day return0.0350.003
Bollinger position0.024−0.001
1-day return0.0230.001

The two columns of Table 4 are in different units (share of all impurity reduction vs. drop in test AUC), so compare the rankings, not the values (Figure 1 draws the permutation column).

2026-10-31T01:56:02.769874 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 0.00 0.05 0.10 0.15 0.20 Drop in test AUC when the feature is shuffled (bar = mean of 10 shuffles, line = spread) Bollinger position price vs 20-day average 1-day return 5-day return 20-day return 5-day volatility 20-day volatility Permutation importance: which features the forest relies on
Figure 1. Permutation importance of the seven features on the test rows

Look at 5-day volatility. Impurity importance gives it a quarter of all the credit, a solid second place. Permutation importance says shuffling it costs almost nothing: 0.016, a twelfth of the 20-day figure. Neither is wrong. The gap between them is what matters.

Why do they disagree? The 5-day and 20-day volatility are strongly correlated; they measure the same thing over different windows. Inside the forest the trees split on both, so both earn impurity credit. But when you shuffle the 5-day column, the trees that leaned on it are still fed the 20-day column, which carries nearly the same information, and the forest barely notices. Impurity importance says “how often was it used”; permutation importance says “how much would we miss it”. For anything you’ll act on, the second question is the one you’re asking, so use permutation importance on test data. (scikit-learn’s own documentation adds a second warning: impurity importance is also biased towards features with many distinct values.) A permutation score at or below zero, like the Bollinger position’s or the price against its average, just means noise: shuffling it changed nothing and the test score wobbled.

The code

Made-up data with seven features, like the series’ seven, so it runs as pasted (pip install numpy scikit-learn). vol_20d carries the signal, vol_5d is its near-twin, and the other five carry nothing. X holds the features, one row per case; y the answer, 1 if a stress month followed. The first 3,000 rows train, the last 1,000 test.

import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.metrics import roc_auc_score

# Made-up data: seven features, like the series' seven
rng = np.random.default_rng(0)
n = 4000
vol_20d = rng.uniform(5, 50, n)
vol_5d = vol_20d + rng.normal(0, 4, n)          # a near-twin of vol_20d
others = rng.normal(size=(n, 5))                 # five features that carry nothing
X = np.c_[vol_20d, vol_5d, others]
names = ["vol_20d", "vol_5d", "f3", "f4", "f5", "f6", "f7"]
y = (vol_20d / 50 + rng.normal(0, 0.2, n) > 0.75).astype(int)   # 1 = stress month ahead
X_train, X_test, y_train, y_test = X[:3000], X[3000:], y[:3000], y[3000:]

# max_features=None lets every split look at all 7 features: that is plain bagging
for name, m in [("bagging (all 7)", None), ("forest, 3 of 7  ", 3), ("forest, 1 of 7  ", 1)]:
    model = RandomForestClassifier(300, max_features=m, min_samples_leaf=20,
                                   random_state=0, n_jobs=-1).fit(X_train, y_train)
    first = [t.tree_.feature[0] for t in model.estimators_]      # each tree's first question
    print(f"{name}: {first.count(0)} of 300 trees start with vol_20d, "
          f"test AUC {roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]):.3f}")

# Two ways to rank features, on the last forest (1 of 7 per split)
perm = permutation_importance(model, X_test, y_test, scoring="roc_auc", n_repeats=10, random_state=0)
for i in range(2):
    print(f"{names[i]}: impurity importance {model.feature_importances_[i]:.2f}, "
          f"drop in test AUC when shuffled {perm.importances_mean[i]:.3f}")

It prints, for bagging, 300 of 300 trees starting with vol_20d; for the forests, about 140 and 45. That is the same effect as Table 2: limiting the choice forces the trees to differ. On this toy data the three AUCs come out about the same (about 0.89); on the real data the gain was small too, 0.889 to 0.897. Then the two importances disagree on the twin, as on the real data: impurity importance gives vol_5d about 0.38, but shuffling it costs only about 0.05 of AUC, because vol_20d carries the same information. t.tree_.feature[0] is the feature a tree asks about at its top node.

The script behind the real numbers

The tables come from examples/random-forests/random_forest.py, which runs on the blog’s five tickers. To run it yourself, clone the examples repo: its README has a three-command Docker setup, or run uv run random-forests/random_forest.py.

Why banks like random forests

  • Few settings to get wrong. The defaults are usually decent.
  • Adding more trees doesn’t overfit. It just stops improving (ISLP section 8.2.2: “random forests will not overfit if we increase B”, the number of trees). Each tree can still overfit if it is grown deep on small data.
  • Handles mixed data (yes/no flags, amounts, counts) without scaling.
  • Built-in honesty check: out-of-bag scores, as in bagging (with the same time-series caveat).

And the trade-offs: a forest of 300 trees is much harder to explain to a regulator or a business user than one tree, which is why the importance analysis above matters; it is also slower to score.

Summary

Table 5 is the post in five lines.

Table 5. Random forests in five lines
IdeaRuleRemember it as
Random forestbagging + random subset of m features at each splitforce the trees to differ
Why it workslower correlation ρ\rho between treeslower floor ρσ2\rho\sigma^2 on variance
Default mp\sqrt{p}, so 2 of 7 here (set it yourself for regression in scikit-learn)then tune it
Extra treesalso random cut-offseven more different, very fast
Importanceprefer permutation importance on test dataimpurity importance over-credits correlated features

References

Tagged machine-learning, ensembles, finance, volatility← Home