Index Ai Academy
Phase 3 · Machine learning · Models

Random forests and boosting

Many weak models voting beat one strong one.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Why a crowd beats an expert
  2. Averaging guesses at the fair
  3. Bagging: each tree gets its own sample
  4. Random forests: each split gets random questions
  5. Boosting: trees that fix each other's mistakes
  6. Comparing them on churn
  7. 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.

p^=1T∑t=1Tpt\hat{p} = \frac{1}{T} \sum_{t=1}^{T} p_t

One symbol at a time:

  • TT is the number of trees.
  • tt counts the trees: tree 1, tree 2, and so on up to tree TT.
  • ptp_t is the probability that tree number tt gives.
  • ∑\sum means "add these up", like a for loop.
  • p^\hat{p}, 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.

  1. Add them: 0.8 + 0.4 + 0.6 = 1.8.
  2. 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 runs right here in your browser, with nothing to install.

It prints 0.6.

Worked example

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")
In the lesson you can change this and run it.

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 nn guesses is divided by n\sqrt{n}, the square root of nn. For 100 guesses, 100=10\sqrt{100} = 10. So the typical miss falls about tenfold: from 77.4 to 8.3. For 10 guesses, 10\sqrt{10} 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.

Quick check
You train one tree, then make 50 exact copies of it and average all 51. Compared with the single tree, the average is:
  1. Less accurate, because averaging blurs the predictions
  2. Perfect, because 51 trees always beat one
  3. About 7 times more accurate, since the square root of 51 is about 7
  4. Exactly as accurate, because identical errors can't cancel
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:

  1. Draw a fresh bootstrap sample for each tree.
  2. Train each tree on its own sample.
  3. 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 1−1/e1 - 1/e, where ee is the number from the exponentials and logarithms lesson, about 2.718. It holds for large data sets.

Quick check
Your training data has 5 rows, numbered 0 to 4. Which list could be a bootstrap sample of it?
  1. [3, 0, 3, 4, 1]
  2. [0, 1, 2, 3, 4, 4]
  3. [5, 1, 2, 0, 3]
  4. [0, 1, 2, 3]
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
Try it now

Same data and depth. Compare one tree's jagged boxes with the smoother boundary from averaging 25.

Quick check
What makes the trees in a random forest different from each other?
  1. Each is trained on the errors of the previous tree
  2. Each is trained on a bootstrap sample and considers random subsets of features at each split
  3. They aren't different; they're copies
  4. Each uses a different loss function
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.

Fnew(x)=Fold(x)+η h(x)F_{\text{new}}(x) = F_{\text{old}}(x) + \eta \, h(x)

One symbol at a time:

  • xx is one customer's features.
  • Fold(x)F_{\text{old}}(x) is the ensemble's prediction so far.
  • h(x)h(x) is the new tree's prediction of the leftover error.
  • η\eta, pronounced "eta", is the learning rate, a number such as 0.1.
  • Fnew(x)F_{\text{new}}(x) 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.

  1. Leftover: 100 − 60 = 40. Add half: 60 + 0.5 × 40 = 80.
  2. Leftover: 100 − 80 = 20. Add half: 80 + 0.5 × 20 = 90.
  3. 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.

Quick check
A boosted model starts at 30, the target is 50, and the learning rate is 0.5. Each tree predicts the leftover exactly. What does this print?
target, prediction = 50, 30.0
for _ in range(2):
    prediction = prediction + 0.5 * (target - prediction)
    print(prediction)
  1. 40.0 then 50.0
  2. 40.0 then 45.0
  3. 35.0 then 40.0
  4. 50.0 then 50.0
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.

Quick check
In the forest, days_since_last_order has the highest feature importance. What can you safely conclude?
  1. The forest relied on it most when making its predictions
  2. Reducing days since last order will cause customers to stay
  3. The other features don't matter for churn
  4. A single tree would rank the features the same way
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

Create a free account

Try a quiz question

What's the key difference between bagging and boosting?

  1. Bagging trains models independently and averages them; boosting adds models one by one, each fixing the previous errors
  2. Bagging combines decision trees, while boosting combines neural networks, and each of them only ever works on its own kind of model
  3. Boosting needs its features scaled before training, while bagging doesn't, and otherwise the two work in the same way and give similar results
  4. There is no real difference: both train many trees on samples of the data and average them, and the two names come from different research fields

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson