Random forests and boosting
Many weak models voting beat one strong one.
Create a free account Try part of it here
Why it matters
Most business data lives in tables: customers, orders, campaigns. On data like that, groups of decision trees are usually the most accurate models you can train. They also need little preparation. A group of models that work together is called an ensemble. One kind, gradient-boosted trees, wins most competitions on spreadsheet-style data. You'll find it in tools such as XGBoost, LightGBM and CatBoost, and inside countless churn, fraud and pricing systems. The idea behind ensembles, many imperfect models combined, shows up again in LLM work as voting and self-consistency.
What you'll be able to do
- Explain why averaging many models beats one
- Describe bagging and random forests: trees on resampled data with random features
- Describe boosting: trees added one by one to fix the remaining errors
- Compare a single tree, a forest and a boosted model on held-out data, and read feature importances
What it covers
- Why a crowd beats an expert
- Averaging guesses at the fair
- Bagging: each tree gets its own sample
- Random forests: each split gets random questions
- Boosting: trees that fix each other's mistakes
- Comparing them on churn
- Practical notes
Why a crowd beats an expert
Picture a jar of jelly beans at a county fair. Everyone writes down a guess. Most guesses are off, some too high and some too low. Now average all the guesses. The average often lands closer than almost any single guess. The too-high misses and the too-low misses cancel out.
There's one condition. The guessers must make different mistakes. If everyone copies the same loud neighbor, the crowd is no wiser than the neighbor.
Decision trees behave like those guessers. A single deep tree is unstable. Train it on a slightly different sample of customers and its rules change a lot. Part of each tree's error is random, tied to the exact rows it happened to see. Average many trees whose errors are different, and the random parts cancel. The real pattern, which every tree picks up, stays.
That's a cut in variance. Here, variance means how much a model changes when you train it on a different sample of the same data. The bias-variance lesson, later in this phase, makes this precise.
Averaging, as a formula
Each tree gives a probability, such as "this customer has a 0.62 chance of leaving." The ensemble averages them.
One symbol at a time:
- is the number of trees.
- counts the trees: tree 1, tree 2, and so on up to tree .
- is the probability that tree number gives.
- means "add these up", like a
forloop. - , read "p-hat", is the ensemble's probability.
In plain words: add up every tree's probability, then divide by the number of trees. It's an ordinary average.
Worked example. Three trees look at one customer. They give 0.8, 0.4 and 0.6.
- Add them: 0.8 + 0.4 + 0.6 = 1.8.
- Divide by the number of trees: 1.8 ÷ 3 = 0.6.
The ensemble says 0.6. The same calculation in Python:
tree_probs = [0.8, 0.4, 0.6] forest_prob = sum(tree_probs) / len(tree_probs) print(round(forest_prob, 2))
It prints 0.6.
Averaging guesses at the fair
This code plays the jelly-bean game 1,000 times. The jar always holds 500 beans. Each guess is 500 plus a random error, so guessers miss by about 80 beans on a typical guess. The code compares four ways to guess. The last one is a crowd of 100 copycats who all write down the first person's guess.
import numpy as np rng = np.random.default_rng(0) true_count = 500 # jelly beans really in the jar errors = rng.normal(0, 100, size=(1000, 100)) # 1,000 fairs, 100 guessers each one_guess = true_count + errors[:, 0] crowd_of_10 = true_count + errors[:, :10].mean(axis=1) crowd_of_100 = true_count + errors.mean(axis=1) copycats = true_count + errors[:, [0] * 100].mean(axis=1) for name, guesses in [("one guesser", one_guess), ("average of 10", crowd_of_10), ("average of 100", crowd_of_100), ("100 copycats", copycats)]: print(f"{name:<15} typical miss {np.abs(guesses - true_count).mean():5.1f} beans")
One guesser misses by 77.4 beans on average. The average of 10 guesses misses by 24.8. The average of 100 misses by only 8.3. The 100 copycats miss by 77.4, the same as one guesser. Their errors are identical, so nothing cancels.
Real trees sit between those two extremes. They learn from the same customers, so their errors overlap. That's why a forest helps a lot, but less than this perfect crowd of independent guessers. The next two sections show how ensembles make their trees disagree on purpose.
Go deeper The square-root rule
Here the errors are independent. Then the spread of an average of guesses is divided by , the square root of . For 100 guesses, . So the typical miss falls about tenfold: from 77.4 to 8.3. For 10 guesses, is about 3.2, and 77.4 ÷ 3.2 is about 24, close to the 24.8 printed. You saw the same rule in Phase 2, where the uncertainty of an average shrank with the square root of the sample size.
Show the answer
Exactly as accurate, because identical errors can't cancel
Every copy gives the same answer, so the average is that same answer. Like the 100 copycats, the copies share one error. Averaging only helps when the models make different mistakes.
Bagging: each tree gets its own sample
So how do you get trees that make different mistakes? The first trick is to give each tree a different version of the training data.
You met the bootstrap in Phase 2. Picture the training rows as names in a hat. Draw one name, write it down, and put it back. Repeat until you've written down as many names as the hat holds. Some names come up twice or more. Others never come up. The list you end with is a bootstrap sample: the same size as the data, but a slightly different mix.
Bagging, short for bootstrap aggregating, works like this:
- Draw a fresh bootstrap sample for each tree.
- Train each tree on its own sample.
- Average the trees' predicted probabilities, with the formula above.
Each tree sees a different mix of customers, so each one makes partly different mistakes.
Go deeper How much of the data does each tree see?
Each draw has a small chance of missing a given row. Over many draws those misses add up. On average, a bootstrap sample contains about 63% of the distinct rows. The rest of the sample is repeats. The exact figure is , where is the number from the exponentials and logarithms lesson, about 2.718. It holds for large data sets.
Show the answer
[3, 0, 3, 4, 1]
A bootstrap sample has the same size as the data (5 rows) and draws with replacement, so repeats like the two 3s are normal. The second list is too short, the third is too long, and row 5 doesn't exist.
Random forests: each split gets random questions
Bagged trees still tend to look alike. Suppose one feature, such as days since last order, is the strongest signal. Almost every tree will ask about it first. Then the trees agree too much, and their errors overlap.
A random forest adds a second source of difference. At each split, a tree may only consider a random handful of the features. Sometimes the dominant feature isn't on the list. The tree has to find the best question among the others. The trees end up asking different questions, so they disagree more usefully.
How big is the handful? For classification, scikit-learn's default is the square root of the number of features. With 8 features, that's 2 per split, since the square root of 8 is about 2.8 and it rounds down.
Here's the whole idea for one customer:
tree 1: bootstrap sample A, random features at each split → P(churn) = 0.62 tree 2: bootstrap sample B, random features at each split → P(churn) = 0.35 ... tree 300 → P(churn) = 0.51 forest prediction = average → P(churn) = 0.48
Same data and depth. Compare one tree's jagged boxes with the smoother boundary from averaging 25.
Show the answer
Each is trained on a bootstrap sample and considers random subsets of features at each split
Two sources of randomness: a different bootstrap sample for each tree, and a random handful of features at each split. Training on errors one after another is boosting, not a random forest.
Boosting: trees that fix each other's mistakes
Think of a golfer on the green. The first shot gets the ball close. The next putt aims at the distance that's left. Each putt after that fixes a smaller leftover gap.
Boosting builds trees the same way, one after another. It starts with a simple guess, such as the average. Then each new tree is trained on what the ensemble so far gets wrong. These trees are small, often 3 to 6 questions deep. One small tree is a weak model on its own. Hundreds of them, each fixing a bit of the leftover error, add up to a strong one.
Boosting doesn't add each new tree in full. It adds a fraction of it, set by a learning rate. That's the same idea as the step size in the learning rate lesson. Small steps make each tree's mistakes matter less.
One symbol at a time:
- is one customer's features.
- is the ensemble's prediction so far.
- is the new tree's prediction of the leftover error.
- , pronounced "eta", is the learning rate, a number such as 0.1.
- is the ensemble's prediction after adding the new tree.
In plain words: new prediction = old prediction + learning rate × the new tree's correction.
Worked example. A customer really spent $100. Boosting predicts spend, a number, so here it starts from the average customer's spend: $60. The learning rate is 0.5. To keep the numbers small, pretend each new tree predicts the leftover error exactly.
- Leftover: 100 − 60 = 40. Add half: 60 + 0.5 × 40 = 80.
- Leftover: 100 − 80 = 20. Add half: 80 + 0.5 × 20 = 90.
- Leftover: 100 − 90 = 10. Add half: 90 + 0.5 × 10 = 95.
Each tree closes half of the gap that's left. The same steps in Python:
target = 100 # what the customer really spent prediction = 60.0 # starting guess: the average customer's spend learning_rate = 0.5 for tree in range(1, 5): leftover = target - prediction # the error the new tree learns prediction = prediction + learning_rate * leftover print(f"tree {tree}: leftover {leftover:4.1f} -> new prediction {prediction}")
It prints predictions of 80.0, 90.0, 95.0 and 97.5. This is a simplification. A real tree learns the leftovers of every training customer at once, so it only gets them roughly right.
The full name is gradient boosting. Each tree fits the downhill direction of the loss. That's the direction the predictions need to move to shrink the loss. It's the opposite of the gradient, which points uphill. For the squared-error loss, that direction is the leftover error itself, as above.
Go deeper Boosting for yes/no answers like churn
For churn, the model can't add up probabilities directly, since they must stay between 0 and 1. Instead scikit-learn adds up trees on the log-odds scale, the one from the logistic regression lesson. It turns the total into a probability at the end. The leftover error for each customer is their real label (1 for churned, 0 for stayed) minus the predicted probability. That's the downhill direction (the negative gradient) of the log loss. A customer who churned but got 0.3 has a leftover of 0.7.
Bagging and boosting fix different problems
- Bagging and random forests mainly cut variance. Each tree is deep and jumpy, and averaging calms them down.
- Boosting mainly cuts bias, the error from a model too simple for the pattern. Each small tree adds a bit more detail.
Boosting needs care not to overfit. Each new tree chases the leftover errors, and some of those are noise. Use a small learning rate. Use early stopping, too: keep checking the score on validation data, and stop adding trees when it stops improving.
target, prediction = 50, 30.0 for _ in range(2): prediction = prediction + 0.5 * (target - prediction) print(prediction)
Show the answer
40.0 then 45.0
The first leftover is 50 − 30 = 20. Half of it moves the prediction to 40.0. The next leftover is 10, and half of that gives 45.0. With a learning rate below 1, each step closes only part of the gap.
Comparing them on churn
The code below loads the shop's customers. It fills missing email open rates with 0. It holds 30% of the customers back as a test set. Then it trains three models on the rest:
- a single deep tree, with no depth limit;
- a random forest of 300 trees, with at least 3 customers in every leaf;
- gradient boosting with 150 trees, each 2 questions deep, and a learning rate of 0.05.
It scores each model on the test customers. At the end it prints the forest's feature importances. These say how much the forest leaned on each feature when it made its splits.
import pandas as pd import requests from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier from sklearn.metrics import roc_auc_score from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) df["email_open_rate"] = df["email_open_rate"].fillna(0) features = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order", "discount_share", "email_open_rate", "support_tickets", "trail_club"] X_tr, X_te, y_tr, y_te = train_test_split(df[features], df["churned"], test_size=0.3, random_state=0, stratify=df["churned"]) models = { "single deep tree": DecisionTreeClassifier(random_state=0), "random forest (300 trees)": RandomForestClassifier(n_estimators=300, min_samples_leaf=3, random_state=0), "gradient boosting": GradientBoostingClassifier(n_estimators=150, learning_rate=0.05, max_depth=2, random_state=0), } for name, model in models.items(): model.fit(X_tr, y_tr) auc = roc_auc_score(y_te, model.predict_proba(X_te)[:, 1]) print(f"{name:<27} test AUC {auc:.3f}") forest = models["random forest (300 trees)"] importance = pd.Series(forest.feature_importances_, index=features).sort_values(ascending=False) print("\nfeature importances:\n" + importance.round(3).to_string())
The score is AUC. It measures how well a model ranks churners above stayers. An AUC of 0.5 is a coin flip, and 1.0 is perfect. It gets its own lesson.
The single deep tree is barely better than chance on new customers. The two ensembles are far better. The importances show which features the forest leaned on most. Here that's recency (days since last order) and order frequency, matching what you've seen all phase.
Show the answer
The forest relied on it most when making its predictions
Importance shows what the model used, not what causes churn. A feature can also look less important when a closely related feature shares its credit.
Practical notes
- Random forests are hard to break. The defaults are sensible, they need little tuning, and features don't need rescaling. They make a great first strong model.
- Gradient boosting usually wins on accuracy once you tune it. The main settings are the number of trees, the learning rate and the tree depth. For real projects, use the dedicated libraries: XGBoost, LightGBM or CatBoost. They're much faster than scikit-learn's version.
- Importances aren't explanations of cause. When two features are closely related, they split the credit between them.
- Ensembles have a cost. Hundreds of trees are slower to predict with than one tree or a logistic regression. They're also much harder to read. If a simple model is nearly as good, the simple one is often the better product.
For later: with LLMs, you can ask the model the same question several times and take the majority answer. That's called self-consistency, and it's an ensemble too. It helps for the same reason: independent errors cancel.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
What's the key difference between bagging and boosting?