Boosting is covered in section 8.2.3 of An Introduction to Statistical Learning, and its
classic example is AdaBoost. Boosted trees, including the popular XGBoost and LightGBM libraries, are built on the same idea,
so it is worth knowing what they do.
Bagging vs boosting in one picture
Table 1. Bagging against boosting
Item
Bagging / random forest
Boosting
How trees are trained
independently, in parallel
one after another
What each tree sees
a random resample
the previous trees’ mistakes
Typical trees
deep (low bias, high variance)
shallow (high bias, low variance)
What it reduces
variance
mainly bias
Risk
can’t overfit by adding trees
can overfit if you add too many
In the bias and variance terms of Table 1: bagging takes complex,
unstable models and steadies them. Boosting takes simple, stable models and makes them smarter.
AdaBoost: reweight the mistakes
AdaBoost (adaptive boosting, Freund and Schapire, 1997) was the first widely used boosting method.
Give every training example the same weight.
Train a weak model, often a stump: a tree with a single question.
Measure its weighted errorε: the total weight of the examples it got wrong.
Give the model a vote α: the lower its error, the bigger its say.
Increase the weight of the examples it got wrong, decrease the rest, and repeat from step 2.
The final prediction is a weighted vote of all the models.
The formulas
α, the model's voteε, its weighted errorwrong examplesright examples
The vote of model t:
αt=21ln(εt1−εt)
If εt=0.5 (a coin flip), αt=0: no say. The smaller the error, the larger
αt.
The weight update for each example i, depending on whether model t got it wrong or right:
wi←wi×{eαte−αtif wrongif right
Then all the weights are divided by their total, so they add up to 1 again.
Worked example: one round, ten rows
Ten training rows, each with weight 0.1. The first stump gets 3 of them wrong:
ε=3×0.1ε=0.3α=21ln(0.30.7)α=0.4236
wrong rows are multiplied by e0.4236=1.5275, right ones by e−0.4236=0.6547 (Table 2):
Table 2. One AdaBoost weight update on ten rows
Item
Before
× factor
Unnormalised
After normalising (÷ 0.9165)
Each wrong row (3)
0.1
1.5275
0.15275
0.1667
Each right row (7)
0.1
0.6547
0.06547
0.0714
After one round, the 3 wrong rows carry half the total weight (3×0.1667=0.5), and the 7
right ones the other half. That’s always true after an AdaBoost update: the next model is forced to
treat the previous model’s mistakes as half of its whole problem.
Notation in other books
Murphy’s Probabilistic Machine Learning: An Introduction (section 18.5.3, equations 18.30 and
18.34) writes the same algorithm slightly differently. He calls the vote βm=21ln((1−errm)/errm),
and only multiplies the wrong examples, by eαm with αm=2βm, before
normalising. That’s the same result: either way, a wrong example ends up e2β times heavier
than a right one.
A note on scikit-learn’s numbers
scikit-learn’s AdaBoostClassifier (version 1.9) uses the SAMME form of the algorithm. With two
classes, the weight it reports is
αt=ln(εt1−εt)
without the 21. That’s exactly Murphy’s αm=2βm. Every vote is twice as
big, so the final ranking of predictions is the same; only the printed numbers differ. If you
compare a textbook with ada.estimator_weights_, that’s why.
What AdaBoost did on the stress-month data
I boosted stumps on the 4,930 training rows (2020 to 2023) of the
stress-month problem: will the next 20 days’
volatility be above 30%? 36.8% of training rows had a stress month ahead but only 24.1% of test
rows, so accuracy is misleading (saying “calm” every time scores 75.9% on the test rows); that’s
why the scores below are AUC (0.5 is a coin flip, 1.0 is perfect). Boosting
libraries also offer class weights (scale_pos_weight in XGBoost) for rarer classes than this. The
first three rounds are in Table 3:
Table 3. The first three AdaBoost rounds on the stress-month rows
Round
Stump’s question
Weighted error
scikit-learn weight (its name for the vote)
1
20-day volatility?
0.1868
1.4708
2
20-day volatility? (a different cut-off)
0.3970
0.4181
3
20-day volatility? (a third cut-off)
0.3754
0.5092
Round 1’s stump gets the easy cases: one cut on 20-day volatility is wrong on 18.7%
of the weight, far better than the 36.8% of calling everything calm. AdaBoost then gives the
rows it got wrong, mostly stress months that followed a calm one, half the total weight,
which forces round 2 to look at them.
Round 2 asks about the same feature at a different cut-off and is wrong on 39.7% of the
reweighted problem, so it earns a much smaller vote. Round 3 stays on 20-day volatility at a third
cut-off, is wrong on 37.5%, and earns a vote a little larger than round 2’s. Each round fixes a part of what the earlier ones missed, and each gets a vote sized by
how well it did on its version of the problem.
A single stump scores an AUC of 0.824 on the test rows. Boosting 81 of them reaches 0.895.
Gradient boosting: fit the leftover error
Gradient boosting (Friedman, 2001) generalises the idea. Instead of reweighting examples, each
new tree is trained to predict what the current model still gets wrong.
For regression with squared error, that’s literally the residuals. An Introduction to
Statistical Learning gives it as Algorithm 8.2. Start with f^(x)=0 and residuals
ri=yi. Then, for each round b=1,2,…,B, fit a small tree f^b to the residuals
and update:
λ, how much of each tree is addedr, the error left
The final model is the sum of all the shrunken trees (symbols in Table 4):
f^(x)=b=1∑Bλf^b(x)(8.12)
Table 4. Symbols in gradient boosting
Symbol
Meaning
f^(x)
the model so far
ri
the residuals: what’s still unexplained for example i
f^b
the small tree added in round b, trained to predict the residuals
λ
the shrinkage or learning rate, a small number like 0.1 (ISLP suggests 0.01 or 0.001)
B
the number of trees
Plain meaning: start with a rough guess. Look at the errors. Train a small tree to predict the
errors. Add a fraction of it to the guess. Repeat. For other losses, like the log loss used for
classification, “residuals” become the negative gradient of the loss (often called
pseudo-residuals), which gives the method its name.
The idea is the same.
The learning rate λ matters a lot. Each tree only corrects part of the error, so no single tree
can push the model too far (Figure 1):
Figure 1. Test AUC against number of trees for three boosted models
For Figure 1 the models trained on 2020 to 2022 (3,680 rows) and kept 2023 (1,250 rows)
aside as a validation set, after the fitting rows in time, never mixed in. The number of trees to use
is chosen on that validation set, never on the test set, and the dot on each line marks the
round it picked. Test AUC on 2024 to 2025 (Table 5):
Table 5. Test AUC by number of trees, and the count chosen on validation
Model
10 trees
50 trees
300 trees
Chosen on validation
Test AUC there
AdaBoost, stumps
0.880
0.891
0.898
81
0.895
Gradient boosting, depth-3 trees, rate 0.1
0.891
0.898
0.882
3
0.891
Gradient boosting, depth-3 trees, rate 1.0
0.874
0.854
0.839
3
0.888
Random forest (last post)
0.891
Two lessons from Figure 1:
Too high a learning rate overfits fast. At rate 1.0 each tree over-corrects. The score
peaks after a handful of trees, then falls to 0.85 by fifty and 0.84 by three hundred as the model fits
noise; the validation set stops it at 3.
Boosting can overfit by adding trees, unlike a random forest. Even at rate 0.1 the score
drifts down from 0.898 at fifty trees to 0.882 at 300. In practice you use early stopping: watch a
validation score and stop adding trees when it stops improving, which is what the dots show.
Here the 2023 validation year stopped rate 0.1 at three trees, well before the test curve’s
best (0.898 at fifty); the validation year is not the test years, and that gap is the price of
choosing without looking at the test set.
On this data the three finish within 0.004 of each other: AdaBoost 0.895, gradient boosting
0.891, random forest 0.891, a gap small enough to be noise. With few features, overlapping rows
and noisy labels, the model that is harder to overfit is the safer bet, and ISLP notes the same difference: random forests do not
overfit as trees are added, while boosting can.
The code
The worked example by hand, then both kinds of boosting on made-up data, so it runs as pasted
(pip install numpy scikit-learn). X holds two features, this month’s volatility and return;
y the answer, 1 if a stress month followed. The first 3,000 rows train, the last 1,000 test.
import numpy as npimport sklearnfrom sklearn.ensemble import AdaBoostClassifier, GradientBoostingClassifierfrom sklearn.metrics import roc_auc_scorefrom sklearn.tree import DecisionTreeClassifier# 1. One AdaBoost round by hand: ten rows, weight 0.1 each, three of them wrongw = np.full(10, 0.1)wrong = np.array([True] * 3 + [False] * 7)eps = w[wrong].sum() # weighted error: 0.3alpha = 0.5 * np.log((1 - eps) / eps) # the model's vote: 0.4236w = w * np.where(wrong, np.exp(alpha), np.exp(-alpha))w = w / w.sum() # back to a total of 1print(f"error {eps:.1f}, vote {alpha:.4f}, wrong rows now {w[0]:.4f}, right rows {w[-1]:.4f}, " f"wrong rows' share {w[wrong].sum():.1f}")# 2. Boosting on made-up data: two features, stress is rarerng = np.random.default_rng(0)n = 4000X = np.c_[rng.uniform(5, 50, n), rng.normal(0, 5, n)] # [volatility, return]y = ((X[:, 0] / 50 - X[:, 1] / 40 + rng.normal(0, 0.2, n)) > 0.8).astype(int)X_train, X_test, y_train, y_test = X[:3000], X[3000:], y[:3000], y[3000:]ada = AdaBoostClassifier(DecisionTreeClassifier(max_depth=1), n_estimators=100, random_state=0)ada.fit(X_train, y_train) # each round: a stump on reweighted rowsprint("AdaBoost: first three errors", np.round(ada.estimator_errors_[:3], 3), "and weights", np.round(ada.estimator_weights_[:3], 3))print("AdaBoost test AUC", round(roc_auc_score(y_test, ada.predict_proba(X_test)[:, 1]), 3))gb = GradientBoostingClassifier(n_estimators=300, learning_rate=0.1, max_depth=3, validation_fraction=0.1, n_iter_no_change=20, random_state=0)gb.fit(X_train, y_train) # stops when the held-back 10% stops improvingprint("gradient boosting used", gb.n_estimators_, "of 300 trees, test AUC", round(roc_auc_score(y_test, gb.predict_proba(X_test)[:, 1]), 3))print("scikit-learn", sklearn.__version__)
It prints the worked example’s numbers: error 0.3, vote 0.4236, the wrong rows at 0.1667, the
right ones at 0.0714, and the wrong rows’ share at exactly 0.5. Then the first three AdaBoost
rounds, each with its weighted error and scikit-learn’s weight. Check one: an error of 0.192 gives
ln(0.808/0.192)=1.437, the SAMME weight without the 21. Then the test AUCs, about 0.90 for
both, and the number of trees early stopping kept (about 77 of 300). The last line prints the
scikit-learn version, 1.9 when this was written.
For large datasets, scikit-learn’s HistGradientBoostingClassifier, XGBoost and LightGBM are much
faster versions of the same idea.
The script behind the real numbers
The tables and the chart come from
examples/boosting/boosting.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 boosting/boosting.py.
What the big libraries add
Speed and extras. XGBoost and LightGBM add penalties on tree size (regularisation),
histogram-based splits (faster, by bucketing each feature’s values) and built-in handling of
missing values.
Monotonic constraints, for example “predicted risk can only go up as a borrower’s debt goes
up”, so the model can’t contradict a rule everyone agrees on.
Explanations. SHAP values split one prediction into a contribution from each feature.
The trade-off: more settings to tune than a random forest, and easier to overfit.
Summary
Table 6 is the post in six lines.
Table 6. Boosting in six lines
Idea
Formula / rule
Remember it as
Boosting
weak models in sequence, each fixing the last
a team where each member covers the gaps
AdaBoost vote
α=21ln((1−ε)/ε)
lower error, bigger say
AdaBoost update
wrong×eα, right×e−α, normalise
mistakes end up with half the weight
scikit-learn AdaBoost
α=ln((1−ε)/ε) (SAMME)
twice the textbook value, same ranking
Gradient boosting
f^←f^+λf^b, f^b fits the residuals
keep fitting what’s left over
Main danger
too many trees or too high a rate
use a small rate and early stopping
That is the last model in the Machine Learning Series 1.
One post remains: putting all of them side by side.