Bagging and Random Forest
The scene: one tree is unstable
At the end of Chapter 18 we measured something: the tree's root question changes with the train split.
That's a serious problem. If a model gives a different answer every time, you can't trust it.
The fix is surprisingly simple: don't trust one tree. Grow a hundred and let them vote.
Column names
The data is synthetic Tashkent rental listings, and its column names are Uzbek. xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), narx_usd monthly rent in USD. The target ijaraga_berildi_30k means rented within 30 days.
The wisdom of crowds
In 1906 the statistician Francis Galton ran an experiment at a country fair. 787 people guessed the weight of an ox. Nobody got it exactly, but the average of the 787 guesses was only 1% off the real weight.
The same principle works in ML, with one condition: the errors have to be independent of each other.
import pandas as pd import numpy as np r = np.random.default_rng(42) n_models, n_samples = 100, 2000 truth = r.choice([0, 1], n_samples) # each "model" is 60% accurate, but its mistakes are INDEPENDENT independent = np.array([np.where(r.random(n_samples) < 0.60, truth, 1 - truth) for _ in range(n_models)]) vote = (independent.mean(axis=0) > 0.5).astype(int) print(f"Accuracy of one model : {(independent[0] == truth).mean():.4f}") print(f"Average score of the 100 models: {(independent == truth).mean():.4f}") print(f"The VOTE : {(vote == truth).mean():.4f}")
Accuracy of one model : 0.6065 Average score of the 100 models: 0.6001 The VOTE : 0.9795
A hundred weak models at 60% accuracy combined into a much higher result.
Now, what happens if the errors are correlated? That matters too:
# every model makes the SAME mistakes (fully correlated) one_pattern = np.where(r.random(n_samples) < 0.60, truth, 1 - truth) correlated = np.array([one_pattern for _ in range(n_models)]) vote_c = (correlated.mean(axis=0) > 0.5).astype(int) print(f"When the errors are CORRELATED, the vote: {(vote_c == truth).mean():.4f}") print() print("Conclusion: an ensemble only works if the models DIFFER from each other.")
When the errors are CORRELATED, the vote: 0.5885 Conclusion: an ensemble only works if the models DIFFER from each other.
The one condition of an ensemble
If you train a hundred identical models, the result is no different from one model.
So the key question in bagging and Random Forest is: "how do we make the trees differ from each other?"
Bagging: different data for every tree
The first method: train every tree on a different sample of the data.
The sample is drawn with replacement (a bootstrap), so one row can appear several times.
sample = np.arange(10) for i in range(3): bs = np.random.default_rng(i).choice(sample, size=10, replace=True) missing = sorted(set(sample) - set(bs)) print(f" bootstrap {i+1}: {bs} | left out: {missing}") print() # in every bootstrap about 37% of the rows are left out left_out = [len(set(np.arange(1000)) - set(np.random.default_rng(s).choice(1000, 1000, replace=True)))/1000 for s in range(50)] print(f"Average share of rows left out: {np.mean(left_out):.1%}") print("That's a mathematical fact: (1 - 1/n)^n -> 1/e = 0.368")
bootstrap 1: [8 6 5 2 3 0 0 0 1 8] | left out: [np.int64(4), np.int64(7), np.int64(9)] bootstrap 2: [4 5 7 9 0 1 8 9 2 3] | left out: [np.int64(6)] bootstrap 3: [8 2 1 2 4 8 4 0 3 6] | left out: [np.int64(5), np.int64(7), np.int64(9)] Average share of rows left out: 36.8% That's a mathematical fact: (1 - 1/n)^n -> 1/e = 0.368
The 37% left out is a free test set. Its name: OOB (out-of-bag).
from sklearn.model_selection import train_test_split, cross_val_score from sklearn.tree import DecisionTreeClassifier from sklearn.ensemble import BaggingClassifier, RandomForestClassifier df = pd.read_csv(DATA / "uy_ijara.csv") num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel"] X, y = df[num], df["ijaraga_berildi_30k"] X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y) print(f"{'model':<40} {'CV F1':>9} {'±':>8}") print("-" * 58) for name, m in [ ("One tree (unlimited)", DecisionTreeClassifier(random_state=42)), ("One tree (max_depth=6)", DecisionTreeClassifier(max_depth=6, random_state=42)), ("Bagging (100 unlimited trees)", BaggingClassifier( DecisionTreeClassifier(random_state=42), n_estimators=100, random_state=42)), ]: b = cross_val_score(m, X, y, cv=5, scoring="f1") print(f"{name:<40} {b.mean():>9.4f} {b.std():>8.4f}")
model CV F1 ± ---------------------------------------------------------- One tree (unlimited) 0.6504 0.0318 One tree (max_depth=6) 0.6794 0.0179 Bagging (100 unlimited trees) 0.6924 0.0134
Bagging is built from unlimited trees, each of which overfits on its own. But their mistakes differ, so the average comes out stable.
Random Forest: one clever addition
In bagging, the trees can still end up alike, because they all pick the same strong column as their first question.
Random Forest fixes that: at every split it looks at a random subset of the columns.
show(pd.DataFrame([ {"Step": "1. Bootstrap sample", "Bagging": "YES", "Random Forest": "YES"}, {"Step": "2. A SUBSET of the columns at each split", "Bagging": "no (all of them)", "Random Forest": "YES: max_features"}, ])) print() print("That single addition makes the trees much more different.")
That single addition makes the trees much more different.
| Step | Bagging | Random Forest |
|---|---|---|
| 1. Bootstrap sample | YES | YES |
| 2. A SUBSET of the columns at each split | no (all of them) | YES: max_features |
print(f"{'model':<40} {'CV F1':>9} {'±':>8}") print("-" * 58) for name, m in [ ("Bagging (every column)", BaggingClassifier( DecisionTreeClassifier(random_state=42), n_estimators=100, random_state=42)), ("Random Forest (max_features='sqrt')", RandomForestClassifier( n_estimators=100, random_state=42)), ("Random Forest (max_features=None)", RandomForestClassifier( n_estimators=100, max_features=None, random_state=42)), ]: b = cross_val_score(m, X, y, cv=5, scoring="f1") print(f"{name:<40} {b.mean():>9.4f} {b.std():>8.4f}") print() print("The third row: with max_features=None, RF actually turns into Bagging.")
model CV F1 ± ---------------------------------------------------------- Bagging (every column) 0.6924 0.0134 Random Forest (max_features='sqrt') 0.6788 0.0205 Random Forest (max_features=None) 0.6913 0.0152 The third row: with max_features=None, RF actually turns into Bagging.
Is the instability fixed? Let's measure
In Chapter 18 a single tree's root question kept changing. Now let's check how stable the ensemble's predictions are.
from sklearn.metrics import f1_score print(f"{'random_state':>13} {'one tree':>14} {'Random Forest':>15}") print("-" * 46) tree_scores, rf_scores = [], [] for rs in [0, 1, 7, 13, 42, 99, 123, 2026]: Xa, Xb, ya, yb = train_test_split(X, y, test_size=0.3, random_state=rs, stratify=y) d = DecisionTreeClassifier(random_state=42).fit(Xa, ya) rf = RandomForestClassifier(n_estimators=100, random_state=42).fit(Xa, ya) df_ = f1_score(yb, d.predict(Xb)); rf_ = f1_score(yb, rf.predict(Xb)) tree_scores.append(df_); rf_scores.append(rf_) print(f"{rs:>13} {df_:>14.4f} {rf_:>15.4f}") print("-" * 46) print(f"{'spread':>13} {np.std(tree_scores):>14.4f} {np.std(rf_scores):>15.4f}")
random_state one tree Random Forest
----------------------------------------------
0 0.6307 0.6630
1 0.6316 0.6856
7 0.6507 0.7147
13 0.6322 0.6723
42 0.6264 0.7069
99 0.6596 0.6973
123 0.5600 0.6814
2026 0.6364 0.7123
----------------------------------------------
spread 0.0279 0.0179
The last row is the answer. Random Forest's results are much less spread out than a single tree's.
The OOB score: free validation
rf_oob = RandomForestClassifier(n_estimators=300, oob_score=True, random_state=42).fit(X_tr, y_tr) print(f"OOB score (free, no CV needed): {rf_oob.oob_score_:.4f}") print(f"Accuracy on the test set : {rf_oob.score(X_te, y_te):.4f}") cv_acc = cross_val_score(RandomForestClassifier(n_estimators=300, random_state=42), X, y, cv=5).mean() print(f"5-fold CV accuracy (expensive) : {cv_acc:.4f}")
OOB score (free, no CV needed): 0.6488 Accuracy on the test set : 0.7167 5-fold CV accuracy (expensive) : 0.6817
Compare the three numbers: OOB 0.6488, CV 0.6817, test 0.7167.
OOB came out lowest, and that's not a coincidence. For each row, the OOB estimate is computed only from the trees that didn't see that row, which is roughly 37% of them. So OOB is usually a little pessimistic.
So OOB isn't an exact replacement for CV. It's a cheap and cautious estimate: it costs one training run, but it shows the real result a bit lower.
Use OOB on large data
If cross_val_score is too slow, set oob_score=True. It gives you a validation estimate for the price of one training run.
The limitation: it only exists for bagging-based models (RF, BaggingClassifier), not for boosting.
How many trees do you need
import time print(f"{'n_estimators':>13} {'CV F1':>9} {'±':>8} {'time':>9}") print("-" * 44) for n in [1, 10, 50, 200, 500]: t0 = time.perf_counter() b = cross_val_score(RandomForestClassifier(n_estimators=n, random_state=42, n_jobs=1), X, y, cv=5, scoring="f1") dt = time.perf_counter() - t0 print(f"{n:>13} {b.mean():>9.4f} {b.std():>8.4f} {dt:>8.1f}s")
n_estimators CV F1 ± time
--------------------------------------------
1 0.6138 0.0198 0.0s
10 0.6446 0.0298 0.1s
50 0.6699 0.0184 0.2s
200 0.6824 0.0185 0.9s
500 0.6889 0.0141 2.2s
Raising n_estimators does not overfit
This is an important difference. With other models, raising a parameter leads to overfitting. In a Random Forest it doesn't: add trees and the result improves or levels off, but it doesn't get worse.
So you set n_estimators "big enough" (100-500) and you don't tune it. The only cost is time.
The parameters you tune are others: max_depth, min_samples_leaf, max_features.
Bagging vs Boosting: an interview pair
show(pd.DataFrame([ {"": "Bagging / RF", "How the trees are grown": "IN PARALLEL, independently of each other", "What each tree does": "Solves the whole task on its own", "What it reduces": "VARIANCE (instability)", "Kind of tree": "Deep, unlimited"}, {"": "Boosting", "How the trees are grown": "IN SEQUENCE, each looks at the previous one's mistakes", "What each tree does": "Fixes the previous mistakes", "What it reduces": "BIAS (lack of accuracy)", "Kind of tree": "Shallow (depth 3-6)"}, ]))
| How the trees are grown | What each tree does | What it reduces | Kind of tree | |
|---|---|---|---|---|
| Bagging / RF | IN PARALLEL, independently of each other | Solves the whole task on its own | VARIANCE (instability) | Deep, unlimited |
| Boosting | IN SEQUENCE, each looks at the previous one's mistakes | Fixes the previous mistakes | BIAS (lack of accuracy) | Shallow (depth 3-6) |
This table is one of the most common interview questions. The key difference: parallel and independent vs sequential and dependent.
Chapter 20 is about boosting.
Feature importance: RF is more reliable
from sklearn.inspection import permutation_importance rf = RandomForestClassifier(n_estimators=300, random_state=42).fit(X_tr, y_tr) d1 = DecisionTreeClassifier(max_depth=6, random_state=42).fit(X_tr, y_tr) pi = permutation_importance(rf, X_te, y_te, n_repeats=20, random_state=42, scoring="f1") show(pd.DataFrame({ "feature": num, "one tree": d1.feature_importances_.round(4), "Random Forest": rf.feature_importances_.round(4), "RF permutation": pi.importances_mean.round(4), }).sort_values("RF permutation", ascending=False))
| feature | one tree | Random Forest | RF permutation |
|---|---|---|---|
| maydon_m2 | 0.725 | 0.362 | 0.126 |
| xonalar | 0 | 0.108 | 0.07 |
| metro_km | 0.13 | 0.238 | 0.001 |
| mebel | 0.009 | 0.03 | -0.001 |
| qavatlar_soni | 0.068 | 0.139 | -0.007 |
| qavat | 0.068 | 0.123 | -0.026 |
Because RF's importance is averaged over many trees, it's more stable than a single tree's. But the Chapter 18 warning still holds: between related columns the importance gets split.
For regression
from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler yn = df["narx_usd"] print(f"{'model':<34} {'CV R2':>9} {'±':>8}") print("-" * 52) for name, m in [ ("LinearRegression (Chapter 15)", Pipeline([("sc", StandardScaler()), ("m", LinearRegression())])), ("DecisionTreeRegressor", __import__("sklearn.tree", fromlist=["DecisionTreeRegressor"]).DecisionTreeRegressor(max_depth=8, random_state=42)), ("RandomForestRegressor", RandomForestRegressor(n_estimators=200, random_state=42)), ]: b = cross_val_score(m, X, yn, cv=5, scoring="r2") print(f"{name:<34} {b.mean():>9.4f} {b.std():>8.4f}")
model CV R2 ± ---------------------------------------------------- LinearRegression (Chapter 15) 0.8759 0.0091 DecisionTreeRegressor 0.7869 0.0159 RandomForestRegressor 0.8524 0.0105
Look at the result and apply the Chapter 10 rule: is the gap bigger than the fold spread?
A tree does NOT extrapolate in regression
This is an important limitation. A tree returns the average of a leaf, so it never goes outside the range it saw in training.
A linear model will compute a price for a 500 m2 flat (right or wrong). A tree returns the price of the largest flat it has seen.
big = pd.DataFrame([{"xonalar": 10, "maydon_m2": 500.0, "qavat": 3, "qavatlar_soni": 5, "metro_km": 1.0, "mebel": 1}]) lin = Pipeline([("sc", StandardScaler()), ("m", LinearRegression())]).fit(X, yn) rfr = RandomForestRegressor(n_estimators=200, random_state=42).fit(X, yn) print(f"Highest price in training : {yn.max()}") print(f"Prediction for a 500 m2 flat:") print(f" LinearRegression : {lin.predict(big)[0]:.0f} <- went OUTSIDE the range") print(f" RandomForest : {rfr.predict(big)[0]:.0f} <- STAYED in the range")
Highest price in training : 1515 Prediction for a 500 m2 flat: LinearRegression : 4538 <- went OUTSIDE the range RandomForest : 1370 <- STAYED in the range
What we learned today
show(pd.DataFrame([ {"Concept": "The ensemble condition", "In one sentence": "The models must DIFFER from each other"}, {"Concept": "Bootstrap", "In one sentence": "Sampling with replacement; ~37% of rows are left out"}, {"Concept": "Bagging", "In one sentence": "A different sample for every tree"}, {"Concept": "Random Forest", "In one sentence": "Bagging + a subset of the columns at each split"}, {"Concept": "What it fixes", "In one sentence": "Variance: the instability of a single tree"}, {"Concept": "OOB score", "In one sentence": "Free validation, 5 times cheaper than CV"}, {"Concept": "n_estimators", "In one sentence": "Raising it does NOT overfit: it only costs time"}, {"Concept": "Bagging vs Boosting", "In one sentence": "Parallel+independent vs sequential+dependent"}, {"Concept": "The regression limitation", "In one sentence": "A tree doesn't extrapolate"}, ]))
| Concept | In one sentence |
|---|---|
| The ensemble condition | The models must DIFFER from each other |
| Bootstrap | Sampling with replacement; ~37% of rows are left out |
| Bagging | A different sample for every tree |
| Random Forest | Bagging + a subset of the columns at each split |
| What it fixes | Variance: the instability of a single tree |
| OOB score | Free validation, 5 times cheaper than CV |
| n_estimators | Raising it does NOT overfit: it only costs time |
| Bagging vs Boosting | Parallel+independent vs sequential+dependent |
| The regression limitation | A tree doesn't extrapolate |
The next chapter takes the second road: we grow trees not in parallel but in sequence, and each one fixes the previous one's mistakes.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.