Random forests: bagging with one extra rule

ml basics

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. Next
  9. 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”. In banking it is probably the most used model for tabular data, because it works with little tuning: credit scoring, fraud, operations risk.

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

ρ, the correlation between the trees' errorsσ², 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:

ModelTrees whose first question is “20-day volatility?”
Bagging293 of 300

(The other 7 start with the 5-day volatility, which says nearly the same thing.) Each tree sees a different bootstrap sample, but the signal is so strong that almost 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.

ModelFeatures allowed per splitTrees starting with “20-day volatility?”
Baggingall 7293 of 300
Random forest3 of 7122 of 300
Random forest1 of 734 of 300

Now the trees genuinely differ: 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; The Elements of Statistical Learning suggests p/3p/3 for regression.

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 3,685 training rows (2019 to 2021) and 2,405 test rows (2022 to 2023) as in the earlier posts. Every tree is grown until its leaves hold 20 rows, so the only difference between rows is the randomness. Scores are AUC on the test months (0.5 is a coin flip, 1.0 is perfect):

ModelTest AUCΔ vs bagging
One tree0.752−0.057
Bagging, 300 trees0.809—
Random forest, 300 trees, 3 of 7 features per split0.820+0.011
Random forest, 300 trees, 1 of 7 features per split0.821+0.012
Extra trees, 300 trees, 3 of 7 features, random cut-offs0.826\key{0.826}+0.017

Are gaps this small real, or just the seed? I re-ran each ensemble with five random seeds: bagging scored 0.808–0.809, the forest with 3 of 7 features 0.817–0.820, with 1 of 7 features 0.820–0.821, and extra trees 0.825–0.826. The ranges don’t overlap, so the order holds; only the two forests are close enough to call a near tie.

Each step adds more randomness, and each step 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 trade 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.

FeatureImpurity importancePermutation importance (drop in test AUC)
20-day volatility0.4370.171
5-day volatility0.2620.022
20-day return0.1200.012
Price vs 20-day average0.0750.005
5-day return0.0450.006
Bollinger position0.035−0.001
1-day return0.0260.003

The two columns are in different units (share of all impurity reduction vs. drop in test AUC), so compare the rankings, not the values.

2026-10-15T18:29:21.774520 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 0.000 0.025 0.050 0.075 0.100 0.125 0.150 0.175 Drop in test AUC when the feature is shuffled (bar = mean of 10 shuffles, line = spread) Bollinger position 1-day return price vs 20-day average 5-day return 20-day return 5-day volatility 20-day volatility Permutation importance: which features the forest relies on

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.022, an eighth 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 negative permutation score, like the Bollinger position’s, just means noise: shuffling it changed nothing and the test score wobbled.

The code

from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance

forest = RandomForestClassifier(
    n_estimators=300,          # number of trees
    max_features=3,            # features considered at each split (m)
    min_samples_leaf=20,       # stop splitting below 20 rows
    oob_score=True,            # score each tree on the rows it never saw
    n_jobs=-1,                 # use all CPU cores
    random_state=0,
)
forest.fit(X_train, y_train)
forest.oob_score_                                      # out-of-bag accuracy, 0.878 here (optimistic on a time series)
forest.predict_proba(X_test)[:, 1]                     # probability of a stress month

result = permutation_importance(forest, X_test, y_test, scoring="roc_auc", n_repeats=10)
result.importances_mean                                # drop in AUC per shuffled feature

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 (though deep trees on small data still can).
  • 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, and it’s slower to score. Model risk teams usually ask for importance analysis and example explanations alongside it.

Summary

IdeaRuleRemember it as
Random forestbagging + random subset of m features at each splitforce the trees to differ
Why it workslower correlation ρ between treeslower floor ρσ² on variance
Default m√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

Next

Boosting: instead of training trees independently and averaging, train them one after another, each one focusing on the mistakes of the ones before.

References