primer.ml.classical

Trees and boosting: the other workhorse

Run: python -m primer.ml.classical

New to the notation? primer.notation explains every symbol used here from zero. This lesson builds on overfitting from primer.ml.regularization and uses the small network from primer.ml.neural_net as its sparring partner.

Level 1: The practitioner's guide

In one sentence. On a table of data, where each column means something on its own, an ensemble of decision trees (a random forest or gradient boosting) is usually the model to beat, trains in seconds, needs no feature scaling, and should be the first thing you try before any neural network.

When you need it. Whenever one input is a row: a customer, a payment, a day, a shipment, with columns in their own units (years, dollars, counts, a region code). Loan approval, fraud flags, churn, demand forecasting, ad ranking on tabular features: these are tree problems. The tell is that a domain expert could write some of the rules by hand ("four or more late payments"), because a tree asks exactly that kind of question. This lesson's loan table shows the gap: gradient boosting scores 96.0% and a random forest 95.75% on the raw table, while a small neural network on the same raw columns scores 77.75%, which is the score for always predicting "fine". Rescaling every column lifts the net to 86.25%; hand-engineering the features (log income, one column per region) lifts it to 92.25%, where it ties a single depth-5 tree and still trails both ensembles. You do not need trees when the input is an image, an audio clip or a text, where the meaning lives in the arrangement of thousands of raw values; that is what convolutions and attention learn, and where a pretrained network brings knowledge a tree starts without.

Your options. From the most readable to the most accurate on a table, then the cases where the table is not the whole story:

Option What it does What it guarantees What it costs Where it lives
One shallow decision tree Asks yes/no questions about one column at a time, chosen greedily by impurity A model you can print and read as if/else rules Accuracy: a depth-5 tree scores 92.25% where the ensembles score 96% scikit-learn's decision trees; rules a compliance team can audit
Random forest Grows hundreds of deep trees on bootstrap samples with random feature subsets and averages them Forgiving defaults; more trees never overfit, only cost time; a free out-of-bag error estimate Not readable; a plateau set by how correlated the trees are The standard first model on a new table
Gradient-boosted trees Adds small trees in sequence, each fitted to what the model still gets wrong, shrunk by a learning rate The best accuracy on most tables of thousands to millions of rows Two knobs to tune (depth, learning rate) and a stopping round chosen on validation data; more rounds do overfit XGBoost, LightGBM, CatBoost
A neural network on the table Learns its own features from combinations of columns Smooth trends, extrapolation, end-to-end training with other neural parts Scaling and feature engineering just to get started; more data to pin down its weights; on this lesson's table it still trails When the table is huge or mixed with text and images
Trees on neural embeddings A network turns text or images into vectors, which become extra columns for a boosted-tree model The table's structure and the perceptual data's meaning in one model Two models to maintain Common in production ranking and risk systems
A neural network on perceptual or sequential data Convolutions, attention, pretrained weights The only option that works: a tree cannot read a photo one pixel at a time Everything the rest of this primer covers Images, audio, text

How to choose. The first question is the shape of one input, not what is fashionable.

  • A row of a table, thousands to millions of rows: gradient-boosted trees, or a random forest if you want something that works untuned. Make any other model beat them on your validation data.
  • The model must be explained to a person, line by line: one shallow tree, and accept the accuracy it costs. A forest's importances are a summary, not an explanation.
  • Values outside the training range matter (prices next year, loads at a new scale): trees predict flat beyond the edge of their data; this lesson's four-house model prices a house of size 40 the same as size 4. Use a model with a slope, or add a trend feature.
  • Pixels, audio samples, words: a neural network, usually pretrained.
  • A table that also carries free text or images: embed those with a network and hand the vectors to the trees.
  • Whatever you pick, tune the tree's depth and the boosting learning rate on validation data, and pick the number of boosting rounds by early stopping. Those two knobs are most of the tuning that matters.

What it costs. Training a forest or a boosted model on a table of modest size takes seconds on a laptop, with no GPU, no scaling and no feature engineering: the trees read dollars, years and region codes as they come. A forest grows its trees independently, so it parallelises across cores and machines; boosting is sequential, and a smaller learning rate buys accuracy with rounds. In this lesson's sweep, a learning rate of 1 reaches its best validation error (0.122) at round 4, 0.3 reaches 0.112 at round 9, and 0.1 reaches the lowest, 0.110, at round 31. A forest's accuracy climbs over the first ten or twenty trees and then flattens; past the plateau, every extra tree is only prediction time. The cost that surprises people is on the neural side: the net needed rescaled inputs and hand-made features to reach a score a single tree matched, and Grinsztajn, Oyallon and Varoquaux (2022) found tuned tree ensembles still ahead of tuned deep learning across 45 tabular datasets of about 10,000 rows.

What breaks.

  • A tree grown to purity. No depth limit gives 100% on training data and the worst validation score in this lesson's sweep (82%, against 89.5% at depth 2). Limit the depth or the leaf size, or use an ensemble.
  • Too many boosting rounds. Every learning rate's validation curve bottoms out and climbs; 200 rounds memorise 80 points. Stop early on validation data.
  • A forest that has stopped improving. The floor is the correlation between trees: with correlation 0.3, going from 10 trees to 1,000 moves the variance from 0.37 to 0.30. Lower the correlation (fewer features per split) rather than adding trees.
  • Trusting impurity importance. It is measured on training data and favours columns with many distinct values: here a column of pure noise gets 11% of the credit. Permutation importance on held-out data gives it 0.25 points. Correlated columns split the credit between them, and neither kind measures cause.
  • Extrapolation. Leaves hold averages of training values, so a tree never predicts outside the range it saw.
  • A network on raw columns. Incomes in the tens of thousands swamp every other column and saturate the neurons; the net learns nothing. Scale first, or use trees.
  • Diagonal boundaries. A tree cuts parallel to the axes and can only approximate a slope with a staircase; if the rule is a ratio of two columns, give it that ratio as a feature.

In the wild. XGBoost (Chen and Guestrin, 2016), LightGBM and CatBoost are the boosting libraries behind most winning tabular models, adding a second-order step per leaf, penalties on tree size, histogram-based split search and native missing values to the loop this lesson builds. scikit-learn's decision trees, random forests and permutation importance are the reference implementations for the rest, with its user guides in Further reading. The recipe is Breiman's: bagging (1996) and random forests (2001), which also introduced out-of-bag error and permutation importance; the boosting loop is Friedman's gradient boosting machine (2001); and the tree itself is CART (Breiman, Friedman, Olshen and Stone, 1984). Strobl et al. (2007) documented the bias in impurity importance that this lesson reproduces with its noise column. In primer.ml.interpretability the question of what a model relies on returns for neural networks, where no tree can be printed.

Go deeper. Level 2 grows a tree by hand on eight emails, scores every candidate question with Gini impurity and entropy, sweeps depth to watch overfitting appear, builds a random forest from bootstrap samples and derives the correlation floor on its variance, runs gradient boosting on four houses one round at a time and shows why the residual is a gradient, then stages the loan-table showdown and both kinds of feature importance. If you only needed to know which model to reach for on a table, you are done.

Level 2: How it works, from scratch.

Level 2: How it works, from scratch

Most of this primer is about neural networks, because language models are neural networks. But open the models that decide whether a loan is approved, whether a card payment looks like fraud, which ad to show, or how many umbrellas a shop should stock, and very often you find no neural network at all. You find hundreds of small decision trees, added together.

Those problems share a shape: a table. Each row is a customer, a payment or a day; each column is a fact measured in its own units (age in years, income in dollars, number of late payments, a region code). On tables of modest size, ensembles of trees have stayed the model to beat, and they train in seconds on a laptop. This lesson builds them from scratch, so you can see why they work and, just as usefully, recognise when a neural network is the wrong tool.

The plan: one tree (how it chooses its questions), why a single tree overfits, how a random forest fixes that by averaging many trees, how gradient boosting fixes it differently by adding trees one after another, and finally a head-to-head against a small neural network from primer.ml.neural_net on a loan table.

A decision tree is a game of twenty questions

Everyday picture. A nurse at an emergency desk runs a flowchart: "Is the patient breathing normally? No: resuscitation room. Yes: is there chest pain? Yes: see a doctor now. No: take a seat." Each question looks at one fact and sends the patient left or right, and the last box gives the answer. A decision tree is exactly that flowchart, except the questions are chosen by the computer from past examples.

Tiny example. Eight emails, each described by two facts: how many links it contains, and whether it comes from a sender you have written to before.

email links known sender spam?
1 0 yes no
2 1 yes no
3 4 yes no
4 0 no no
5 3 no yes
6 5 no yes
7 6 no yes
8 1 no yes

The tree this lesson grows from those eight rows:

flowchart TD Q1{"known sender?"} -->|yes| L1["not spam<br/>(emails 1, 2, 3)"] Q1 -->|no| Q2{"more than 0 links?"} Q2 -->|no| L2["not spam<br/>(email 4)"] Q2 -->|yes| L3["spam<br/>(emails 5, 6, 7, 8)"]

Reading it: start at the top diamond with a new email and answer each question until you land in a box. Diamonds are questions (the tree's internal nodes); boxes are answers (its leaves), and each leaf lists the training emails that ended up there. Two questions sort all eight correctly. Notice what the tree did not ask: "more than 2 links?" looks reasonable, but it was worse. The next two sections show how the tree decides that.

In code: spam_emails returns the table above; DecisionTree grows the tree, and DecisionTree.rules prints it as nested if/else lines.

Measuring a mess: Gini impurity

Everyday picture. You are sorting laundry into piles. A pile of only socks is pure: pull out any two items and they match. A pile that is half socks, half shirts is as mixed-up as two kinds of item can be. A good sorting question is one that leaves piles as pure as possible.

Tiny example. Before any question, the eight emails are 4 spam and 4 not: a 50/50 pile. Pull out two emails at random (putting the first one back) and they disagree half the time. The pile of unknown senders is 1 "not spam" and 4 spam: purer, so two random picks disagree less often.

Gini impurity is exactly that chance of disagreement:

Level 3: the formula and its symbols

$$ G = 1 - \sum_{k=1}^{K} p_k^{2} $$

Symbols

Symbol Meaning here In the example
$G$ the Gini impurity of one pile of examples: 0 when pure, larger when mixed 0.5 for the eight emails
$K$ how many different labels there are 2 (spam, not spam)
$k$ a counter that walks over the labels 1 = not spam, 2 = spam
$p_k$ the share of the pile carrying label $k$ $p_1 = 4/8$, $p_2 = 4/8$
$p_k^{2}$ the chance two random picks both have label $k$ $0.5^2 = 0.25$
$\sum_{k=1}^{K}$ "add up over every label": the chance two picks match $0.25 + 0.25 = 0.5$
$1 - \ldots$ turns "chance they match" into "chance they disagree" $1 - 0.5$

In words: "the impurity of a pile is one minus the sum of the squared shares of each label: the chance that two examples drawn at random carry different labels."

With the numbers: all eight emails: $1 - (0.5^2 + 0.5^2) = 1 - 0.5 = 0.5$. The unknown-sender pile, 1 of 5 not spam and 4 of 5 spam: $1 - (0.2^2 + 0.8^2) = 1 - (0.04 + 0.64) = 0.32$. A pure pile: $1 - 1^2 = 0$.

Level 3: in Python

In Python:

# four spam (1) and four not spam (0)
labels = [1, 1, 1, 1, 0, 0, 0, 0]
# p_k: the share of each label
p = [labels.count(k) / len(labels) for k in (0, 1)]
p  # → [0.5, 0.5]
# G = 1 - Σ p_k²
1 - sum(p_k ** 2 for p_k in p)  # → 0.5
# the unknown-sender pile: 1 not spam, 4 spam
p = [1 / 5, 4 / 5]
round(1 - sum(p_k ** 2 for p_k in p), 2)  # → 0.32

Why it matters: the tree needs a single number to compare questions with, and it must be cheap, because a real tree scores millions of candidate questions. Gini needs only counts, one multiply per label, and no logarithm.

In code: gini computes $G$ for any list of labels.

Entropy: the same idea, counted in yes/no questions

Everyday picture. Entropy asks: if I had to guess an email's label by asking yes/no questions, how many would I need on average? A 50/50 pile needs one full question. A pure pile needs none, because you already know.

Tiny example. For the eight emails, one question ("is it spam?") settles it: entropy 1 bit. For the unknown-sender pile, 4 of 5 are spam, so you are usually right before asking and the average cost is about 0.72 of a question.

Level 3: the formula and its symbols

$$ H = -\sum_{k=1}^{K} p_k \log_2 p_k $$

Symbols

Symbol Meaning here In the example
$H$ the entropy of one pile, in bits (yes/no questions) 1 for the eight emails
$p_k$ the share of the pile carrying label $k$, as above 0.2 and 0.8
$\log_2 p_k$ the logarithm base 2: the power you raise 2 to in order to get $p_k$. It is negative for shares below 1 $\log_2 0.5 = -1$, $\log_2 0.2 = -2.32$
$-\sum$ add up over the labels, then flip the sign so the total is positive

In words: "entropy is the average, over the labels, of how surprising each label is, weighted by how often it appears." A label with share 0.5 costs $-\log_2 0.5 = 1$ bit of surprise.

With the numbers: eight emails: $-(0.5 \cdot (-1) + 0.5 \cdot (-1)) = 1$ bit. Unknown-sender pile: $-(0.2 \cdot \log_2 0.2 + 0.8 \cdot \log_2 0.8) = -(0.2 \cdot (-2.32) + 0.8 \cdot (-0.32)) = 0.722$ bits.

Level 3: in Python

In Python:

import math
# H = -Σ p_k log2 p_k
p = [0.5, 0.5]
-sum(p_k * math.log2(p_k) for p_k in p)  # → 1.0
p = [1 / 5, 4 / 5]
round(-sum(p_k * math.log2(p_k) for p_k in p), 3)  # → 0.722

Gini and entropy almost always pick the same question; Gini is a little cheaper. The drop in entropy a question produces is called information gain, the name the early ID3 algorithm used. See primer.notation for logarithms from scratch and primer.ml.losses for entropy's close cousin, cross-entropy.

In code: entropy computes $H$ in bits; DecisionTree takes criterion="entropy" to split by it instead of Gini.

Choosing the best question

Everyday picture. A question splits one pile into two. Judge it by how messy the two new piles are, but weigh each by its size: a tiny pure pile of one email is worth less than a big pure pile of five.

Tiny example. "Known sender?" makes a pile of 3 (all not spam, Gini 0) and a pile of 5 (1 not spam, 4 spam, Gini 0.32). "More than 2 links?" makes a pile of 4 (3 not spam, 1 spam) and another pile of 4 (1 not spam, 3 spam), Gini 0.375 each.

Level 3: the formula and its symbols

$$ G_{\text{split}} = \frac{n_L}{n}\, G(L) + \frac{n_R}{n}\, G(R) $$

Symbols

Symbol Meaning here For "known sender?"
$L$, $R$ the left pile (answer yes) and the right pile (answer no) known / unknown senders
$n_L$, $n_R$ how many examples land in each pile 3 and 5
$n$ how many examples reached this node, $n_L + n_R$ 8
$G(L)$, $G(R)$ the Gini impurity of each pile (the formula above) 0 and 0.32
$\frac{n_L}{n}$ the left pile's share of the examples: its weight 3/8
$G_{\text{split}}$ the impurity left after asking: lower is better 0.2

In words: "the mess left by a question is the average of the two piles' impurities, each weighted by the share of examples in it."

With the numbers: known sender: $\frac{3}{8} \cdot 0 + \frac{5}{8} \cdot 0.32 = 0.2$. More than 2 links: $\frac{4}{8} \cdot 0.375 + \frac{4}{8} \cdot 0.375 = 0.375$. The tree asks "known sender?" because 0.2 < 0.375. The impurity decrease (the gain) is $0.5 - 0.2 = 0.3$.

Level 3: in Python

In Python:

# known senders: 3 emails, all not spam
n_left, g_left = 3, 0.0
# unknown senders: 1 not spam, 4 spam
n_right, g_right = 5, 0.32
n = n_left + n_right
# G_split = (n_L/n)·G(L) + (n_R/n)·G(R)
round(n_left / n * g_left + n_right / n * g_right, 3)  # → 0.2
# the impurity the question removed
round(0.5 - 0.2, 3)  # → 0.3

Where do candidate questions come from? For a number such as "links", only thresholds between neighbouring values in the data can change which emails go where. The links column holds 0, 1, 3, 4, 5 and 6, so there are five thresholds worth trying (halfway points 0.5, 2, 3.5, 4.5, 5.5), plus one for the yes/no column. The tree scores all six and keeps the lowest.

Weighted Gini after each of six candidate first questions: known_sender scores 0.200, the best; the five links thresholds score between 0.333 and 0.467; all are below the 0.5 of the unsplit pile

Reading it: each bar is one question the tree could ask first, and its length is the weighted Gini impurity left afterwards. The dashed line is the impurity before asking anything (0.5). Every question helps a little, since every bar ends left of the line, but "known_sender > 0.5" (blue) leaves the least mess by a clear margin, so it becomes the root of the tree. Two links thresholds tie at 0.333: the greedy search would take the first if known sender did not exist.

In code: split_impurity is $G_{\text{split}}$; candidate_splits scores every question in the figure; best_split does the same search fast, by sorting each feature once and keeping running label counts, and returns a Split.

Why it matters: this greedy search is the whole learning algorithm. There is no gradient and no learning rate: the tree tries every feature and every threshold and takes the best. That is why trees handle columns in any units, as the last section shows.

Growing the tree: ask, split, repeat

Everyday picture. Sorting a big box of photos: first by year, then each year's pile by who is in them, then each of those by place, until every pile is tidy or too small to bother with.

Tiny example. After "known sender?", the left pile (emails 1, 2, 3) is already pure: it becomes a leaf. The right pile (4, 5, 6, 7, 8) is not, so the tree searches again inside that pile only. There, "more than 0 links?" separates email 4 from the four spam emails perfectly. Both new piles are pure, so growth stops at depth 2.

flowchart TD S["node: the examples that reached here"] --> P{"pure, too small,<br/>or at the depth limit?"} P -->|yes| LEAF["make a leaf:<br/>predict the majority label<br/>(or the average number)"] P -->|no| SEARCH["try every feature and every threshold;<br/>keep the lowest weighted impurity"] SEARCH --> SPLIT["send examples left (feature ≤ threshold)<br/>or right (feature > threshold)"] SPLIT --> LEFT["grow the left child"] & RIGHT["grow the right child"] LEFT -.-> S RIGHT -.-> S

Reading it: the same recipe runs at every node, which is why the code is a function that calls itself (recursion). The dotted arrows are those calls: each child is a fresh node holding only its share of the examples. The diamond is where growth stops; everything else is the search from the previous section. To predict, a new example simply follows the questions from the root down to a leaf.

Each question looks at a single feature and compares it with a threshold, so every question cuts the input space with a straight line parallel to an axis. A tree therefore carves the plane into rectangles:

A tree on two-moon data at depth 1, 3 and unlimited: one horizontal cut, then a few rectangles, then a patchwork of 51 boxes with thin strips around individual noisy points

Reading it: the dots are 300 training points of two interleaved half-moons with noise (primer.ml.neural_net.make_moons); the shaded background is what the tree would predict at each spot. At depth 1 there is one question, so one straight horizontal cut. At depth 3 there are 8 leaves, and the boundary is a staircase of vertical and horizontal edges that roughly follows the moons. With no limit, the tree keeps cutting until every leaf is pure: 51 boxes, including thin strips that reach into the other class's territory to capture a few noisy points. Every edge is axis-aligned: trees cannot draw a diagonal, only approximate one with steps.

A regression tree, predicting a number instead of a label, is the same recipe with two swaps: the impurity is the variance of the numbers in a pile (how spread out they are), and a leaf predicts their average. Gradient boosting, below, is built from those.

In code: DecisionTree grows by the recipe in the diagram (a depth limit and a minimum leaf size are its stopping rules, and the "squared" criterion makes it a regression tree), and DecisionTree.predict routes each row down to its leaf.

Overfitting: a question for every example

Everyday picture. Ask a friend to write rules for which of their emails are spam, and forbid them from stopping until every past email is sorted correctly. They will end up with rules like "an email from Pat at 9:14 on a Tuesday with 3 links is spam": perfect on the past, useless for the future.

Tiny example. On the noisy moons, a tree with no depth limit grows 51 leaves and gets 100% of its 300 training points right. On 200 fresh points it gets 82%. A tree stopped at depth 2 has 4 leaves, gets 88% on training and 89.5% on the fresh points: it learned the shape rather than the noise.

Training and validation accuracy against the depth limit: training rises from 78% to 100%; validation peaks at 89.5% for depth 2 and 3 and falls to 82% with no limit

Reading it: the horizontal axis is the depth limit, from one question to no limit at all. The blue training curve only ever rises, because every extra level can only fit the training points better. The orange validation curve peaks early, at depth 2 to 3, then slides down as the tree starts building boxes around individual noisy points. The widening gap between the two curves is overfitting, exactly as in primer.ml.regularization.

A single tree is the textbook high-variance model: change a few training points and the top question can flip, and everything below it changes with it. There are two ways out. Keep trees small (limit the depth, require a minimum number of examples per leaf, or grow fully then prune back the branches that do not help on validation data). Or keep trees big and combine many of them, which is what the rest of this lesson does.

In code: depth_sweep grows one tree per depth limit on moons_split data and measures both accuracies.

Why it matters: depth is the tree's capacity knob. Every tree library exposes it (along with a minimum leaf size), and choosing it on validation data is the first thing to tune.

Random forests: many noisy trees, one steady vote

Everyday picture. At a county fair, hundreds of people guess the weight of an ox. Individual guesses are wildly off in both directions, but the average of all of them lands remarkably close, because the errors point in different directions and cancel. A random forest does this with trees: grow many deep trees that each make different mistakes, then average them. The trick is making the mistakes different, since averaging copies of the same tree changes nothing.

Two sources of difference, both from Leo Breiman:

  1. Bagging (bootstrap aggregating): each tree trains on its own bootstrap sample, a resample of the training rows drawn with replacement.
  2. Random feature subsets: at every node, a tree may only consider a random handful of the features (commonly the square root of their number), so different trees are forced to find different questions.
flowchart LR D["training table<br/>(n rows)"] --> B1["bootstrap sample 1<br/>n rows drawn with replacement"] & B2["bootstrap sample 2"] & B3["..."] & BB["bootstrap sample B"] B1 --> T1["deep tree 1<br/>random features at each node"] B2 --> T2["deep tree 2"] B3 --> T3["..."] BB --> TB["deep tree B"] T1 & T2 & T3 & TB --> AVG["average their class shares<br/>(or their numbers)"] AVG --> OUT["prediction"]

Reading it: the table fans out into B different resamples, each resample grows its own full-depth tree, and the trees' answers are averaged at the end. Nothing flows between trees, so they can be grown in parallel on separate machines. Diversity enters twice: once in which rows each tree sees (the bootstrap boxes) and once in which features each node may use (inside the tree boxes).

Bagging: drawing names from a hat, and putting them back

Tiny example. Put the eight email numbers in a hat. Draw one, write it down, put it back, and repeat eight times. One draw might give 3, 1, 3, 7, 5, 1, 8, 2: emails 1 and 3 twice, and emails 4 and 6 not at all. Each tree sees a slightly different dataset, with some rows counted double and about a third missing.

How big is "about a third"? A particular email is missed on one draw with chance $1 - \frac{1}{n}$, and missed on all $n$ draws with:

Level 3: the formula and its symbols

$$ P(\text{left out}) = \left(1 - \frac{1}{n}\right)^{n} \;\longrightarrow\; \frac{1}{e} \approx 0.368 $$

Symbols

Symbol Meaning here In the example
$n$ the number of rows, and also the number of draws 8
$\frac{1}{n}$ the chance one draw picks this particular row 1/8
$1 - \frac{1}{n}$ the chance one draw misses it 7/8
$(\ldots)^{n}$ missing it on every one of the $n$ independent draws: multiply the chances $(7/8)^8$
$\longrightarrow$ "gets closer and closer to, as $n$ grows"
$e$ Euler's number, ≈ 2.718 (see primer.notation) $1/e = 0.368$

In words: "the chance a row never makes it into a bootstrap sample is the chance of missing it once, multiplied by itself once per draw; for big datasets that settles at about 37%."

With the numbers: $(7/8)^8 = 0.3436$ for the eight emails; for 100,000 rows, $(1 - 1/100000)^{100000} = 0.3679$, already equal to $1/e$ to four places.

Level 3: in Python

In Python:

import math
n = 8
# (1 - 1/n)^n: missed on every one of the n draws
round((1 - 1 / n) ** n, 4)  # → 0.3436
round((1 - 1 / 100_000) ** 100_000, 4)  # → 0.3679
round(1 / math.e, 4)  # → 0.3679

The rows a tree never saw are its out-of-bag rows: a free validation set for that tree, which random forests use to estimate their own accuracy without holding data back.

In code: bootstrap_sample draws the n row indices; out_of_bag_fraction is the formula; RandomForest gives every tree its own sample and its own feature subsets.

Why averaging helps, and what limits it

Tiny example. Three trees give a point spam probabilities of 0.9, 0.2 and 0.7. The forest says 0.6. If each tree's error is a coin flip that cancels out on average, three trees already wobble less than one; a hundred wobble far less. But if every tree makes the same mistake, averaging keeps it.

Level 3: the formula and its symbols

$$ \operatorname{Var}!\left(\frac{1}{B}\sum_{b=1}^{B} T_b\right) = \rho\,\sigma^{2} + \frac{1-\rho}{B}\,\sigma^{2} $$

Symbols

Symbol Meaning here In the example
$B$ the number of trees 10
$T_b$ tree number $b$'s prediction for one input 0.9, 0.2, 0.7, …
$\frac{1}{B}\sum_{b=1}^{B} T_b$ the forest's prediction: the average of all trees 0.6
$\operatorname{Var}$ variance: how much a prediction would swing if the training data were redrawn
$\sigma^{2}$ the variance of one tree on its own 1 (a unit, for comparison)
$\rho$ the correlation between two trees' errors: 0 if they err independently, 1 if they always err together (Greek letter rho) 0.3

In words: "the forest's wobble has two parts: a part shared by all trees, which no amount of averaging removes, and a private part, which shrinks as you add trees."

With the numbers: with $\rho = 0.3$ and $\sigma^2 = 1$: ten trees give $0.3 + 0.7/10 = 0.37$; a thousand trees give $0.3 + 0.7/1000 = 0.3007$. Going from 10 to 1,000 trees barely helps; lowering $\rho$ would. With fully independent trees ($\rho = 0$), ten trees would cut the variance to 0.1.

Level 3: in Python

In Python:

sigma2, rho = 1.0, 0.3
# ρσ² + (1 - ρ)σ²/B
round(rho * sigma2 + (1 - rho) * sigma2 / 10, 4)  # → 0.37
round(rho * sigma2 + (1 - rho) * sigma2 / 1000, 4)  # → 0.3007
# independent trees: the variance falls as 1/B
round(0.0 * sigma2 + (1 - 0.0) * sigma2 / 10, 4)  # → 0.1

That is why a forest adds random feature subsets on top of bagging: bagging alone leaves every tree free to pick the same strong feature at the top, so the trees stay correlated. Forcing each node to choose from a random subset pushes $\rho$ down, which is the only way to push the floor down. Each tree gets a little worse; the average gets better.

Left, one fully grown tree carves a jagged patchwork with small islands around noisy points and scores 82% on validation; right, a forest of 100 trees draws a smoother boundary and scores 88.5%

Reading it: both panels show the same 300 training points and the predicted class at every spot. On the left, a single unlimited tree fences off little islands around individual noisy points. On the right, a hundred unlimited trees vote: an island appears only where most trees agree, so the one-off islands mostly vanish and the boundary follows the two moons more smoothly. Each tree in the forest is just as overfitted as the one on the left; their average is not.

Validation accuracy against the number of trees on a log axis: one tree scores 85.5%, two dip to 83%, then the forest climbs to 88 to 89% by 10 to 20 trees and stays level, all above a single deep tree's 82%

Reading it: the horizontal axis is how many trees are averaged, on a log scale; the vertical axis is accuracy on the 200 validation points. The dashed line is a single deep tree grown on all the data. The first points are jumpy (two fully grown trees that disagree give a 50/50 tie, which is why two trees dip below one), but the curve climbs over the first ten or twenty trees and then flattens: the private part of the variance is gone, and what remains is the shared part the formula predicts. Unlike depth, adding trees does not overfit; past the plateau it only costs time.

In code: averaged_variance is the formula; forest_curve grows one forest and scores its first k trees for each k; RandomForest.predict_proba averages the trees' class shares.

Why it matters: a random forest is the most forgiving model in machine learning. Its defaults (a few hundred unpruned trees, square root of the features per node) work well on most tables with no tuning at all, which is why it is the standard first model on new tabular data.

Gradient boosting: each tree fixes the last one's mistakes

Everyday picture. Golf. Your first shot from the tee gets you near the green. Your second shot does not start over from the tee: it aims at what is left, the gap between the ball and the hole. Each shot is small and corrects what remains. Gradient boosting builds a model the same way: start with a crude guess, then add small trees one at a time, each trained only on what the model so far still gets wrong.

Where a forest grows big trees side by side and averages them, boosting grows small trees in sequence and adds them up. The forest fights variance; boosting fights bias (being systematically off), a little at a time.

flowchart LR F0["F₀: start with the average"] --> R["residuals:<br/>what is still wrong<br/>r = y − F"] R --> H["fit a small tree hₘ<br/>to the residuals"] H --> U["Fₘ = Fₘ₋₁ + η · hₘ<br/>take a small step"] U -->|"next round"| R U --> OUT["after M rounds:<br/>F₀ + η·(h₁ + h₂ + … + h_M)"]

Reading it: the loop in the middle is the whole algorithm. Each round measures what is still wrong (the residuals), trains a small tree to predict those, and adds a shrunken copy of that tree to the model. The model is never retrained from scratch; it only grows by addition. The final model is the starting guess plus every tree's contribution, which is why a boosted model is literally a sum of trees.

Tiny example. Four houses of size 1, 2, 3 and 4 sold for 1, 2, 6 and 7 (hundreds of thousands, say). Boost with stumps (trees with a single question) and learning rate $\eta = 0.5$:

size price $y$ $F_0$ residual $y - F_0$ stump $h_1$ $F_1 = F_0 + 0.5\,h_1$ new residual
1 1 4 −3 −2.5 2.75 −1.75
2 2 4 −2 −2.5 2.75 −0.75
3 6 4 2 2.5 5.25 0.75
4 7 4 3 2.5 5.25 1.75

$F_0 = 4$ is the average price, the best single guess. The stump's best question on the residuals is "size ≤ 2.5?", and each leaf predicts its average residual: −2.5 for the small houses, +2.5 for the big ones. Adding half of that moves every guess toward its price. The mean squared error falls from 6.5 to 1.8125 in one round, and a second round brings it to 0.64.

Residuals are the negative gradient

Why is this called gradient boosting? Because "fit what is still wrong" is a special case of gradient descent (primer.ml.optimizers), taken in the space of predictions instead of weights.

Level 3: the formula and its symbols

$$ L(y, F) = \tfrac{1}{2}\,(y - F)^{2} \qquad\Longrightarrow\qquad r = -\frac{\partial L}{\partial F} = y - F $$

Symbols

Symbol Meaning here For house 4
$y$ the true value 7
$F$ the model's current prediction $F_0 = 4$
$L(y, F)$ the loss: half the squared miss (the ½ only tidies the slope) $\frac{1}{2}(7 - 4)^2 = 4.5$
$\frac{\partial L}{\partial F}$ the derivative: how fast the loss changes as the prediction $F$ is nudged up $4 - 7 = -3$
$-\frac{\partial L}{\partial F}$ the direction that lowers the loss fastest, called the negative gradient 3
$r$ the residual: what each tree is trained to predict 3
$\Longrightarrow$ "which gives"

In words: "for squared error, the direction that most quickly reduces the loss at each example is simply the true value minus the prediction: the residual."

With the numbers: house 4 has $y = 7$ and $F = 4$. Nudge $F$ up to 4.001 and the loss falls from 4.5 to about 4.497, a slope of −3; the negative gradient is +3, which is exactly $7 - 4$.

Level 3: in Python

In Python:

y, F, eps = 7.0, 4.0, 1e-6
# ∂L/∂F, measured by nudging F both ways
slope = (0.5 * (y - (F + eps)) ** 2 - 0.5 * (y - (F - eps)) ** 2) / (2 * eps)
round(slope, 6)  # → -3.0
# r = -∂L/∂F is the plain residual y - F
round(-slope, 6), y - F  # → (3.0, 3.0)

The payoff of the gradient view: swap in any loss with a slope and the same loop still works. For yes/no labels with the log loss (primer.ml.losses), $F$ is a score in log-odds and the negative gradient is $y - \sigma(F)$, the label minus the predicted probability ($\sigma$ is the sigmoid, which squashes a score into 0 to 1). Each tree then fits "how surprised were we by each example".

In code: GradientBoosting.negative_gradient returns $y - F$ for loss="squared" and $y - \sigma(F)$ for loss="log".

The update, and the learning rate

Level 3: the formula and its symbols

$$ F_m(x) = F_{m-1}(x) + \eta\, h_m(x) $$

Symbols

Symbol Meaning here In the example
$x$ one input (a house's size) size 4
$m$ the round number, 1, 2, …, $M$ 1
$F_{m-1}(x)$ the model's prediction before this round $F_0(4) = 4$
$h_m(x)$ this round's small tree, trained on the residuals $h_1(4) = 2.5$
$\eta$ the learning rate (Greek letter eta), also called shrinkage: the fraction of each tree's correction actually applied 0.5
$F_m(x)$ the prediction after this round 5.25

In words: "after each round, the new prediction is the old prediction plus a shrunken copy of the tree that was trained to fix it."

With the numbers: big houses: $4 + 0.5 \times 2.5 = 5.25$; small houses: $4 + 0.5 \times (-2.5) = 2.75$. Mean squared error before: $(9 + 4 + 4 + 9)/4 = 6.5$; after: $(1.75^2 + 0.75^2 + 0.75^2 + 1.75^2)/4 = 1.8125$.

Level 3: in Python

In Python:

prices = [1, 2, 6, 7]
eta = 0.5
# F_0: the average price
F0 = sum(prices) / len(prices)
F0  # → 4.0
residuals = [y - F0 for y in prices]
residuals  # → [-3.0, -2.0, 2.0, 3.0]
# h_1: the stump predicts each side's average residual
small, big = (residuals[0] + residuals[1]) / 2, (residuals[2] + residuals[3]) / 2
h1 = [small, small, big, big]
h1  # → [-2.5, -2.5, 2.5, 2.5]
# F_1 = F_0 + η h_1
F1 = [F0 + eta * h for h in h1]
F1  # → [2.75, 2.75, 5.25, 5.25]
# mean squared error before and after the round
sum((y - F0) ** 2 for y in prices) / 4  # → 6.5
sum((y - f) ** 2 for y, f in zip(prices, F1)) / 4  # → 1.8125

Why not take the whole correction ($\eta = 1$)? Each tree is fitted to the training data, noise included. Taking the whole step lets the first few trees commit hard to whatever they saw; taking small steps means many trees must agree before the model moves far, which averages away some of the noise. The price is more rounds.

Boosted depth-2 trees fitting noisy points around a sine curve: after 1 round two flat steps, after 5 rounds a coarse staircase, after 50 rounds a jagged staircase that tracks the sine and starts chasing individual points

Reading it: grey dots are 80 noisy samples of the dotted sine curve, and the blue line is the boosted model's prediction everywhere between 0 and 6. After 1 round the model is the average plus 0.3 of one small tree: a line with just two flat steps. After 5 rounds it has the rough shape. After 50 it follows the curve closely and has begun to jump for individual noisy points, the first sign of the overfitting the next figure measures. Every version is made of flat steps, because every tree predicts a constant in each leaf; the curve is built from many small staircases added together.

Mean squared error per boosting round for learning rates 1, 0.3 and 0.1: dashed training curves fall toward zero; solid validation curves bottom out at round 4, 9 and 31 respectively, lowest for 0.1, and then climb

Reading it: dashed lines are training error and solid lines are error on 400 fresh points, per round, for three learning rates. Every training curve falls toward zero, since 200 rounds of trees can memorise 80 points. Each validation curve has a best round (the dot) and then rises, because later trees are fitting noise. With $\eta = 1$ the best comes at round 4 (0.122) and is poor; with $\eta = 0.1$ it comes at round 31 and is the lowest (0.110), close to the noise floor of 0.09 that no model can beat. Smaller steps generalize better but need more rounds, and every learning rate needs a stopping point chosen on validation data: early stopping, as in primer.ml.regularization.

In code: GradientBoosting runs the loop and records the training loss after each round; GradientBoosting.staged_loss scores held-out data after each round; boosting_worked_example is the four-house table; boosting_curves sweeps the learning rate on sine_data.

Why it matters: gradient-boosted trees (through libraries such as XGBoost, LightGBM and CatBoost) are the most common winning model on tabular data in practice. They add refinements this lesson leaves out, such as a second-order (Newton) step for each leaf's value, penalties on tree size, fast histogram-based split search, and native handling of missing values, but the loop is the one above. The two knobs that matter most are the ones you have met: tree depth and the learning rate, with the number of rounds chosen by early stopping.

When trees win, and when neural networks do

Everyday picture. A spreadsheet and a photograph are both grids of numbers, but they carry meaning differently. In a spreadsheet each column means something on its own: "income is under 30,000" is a sentence a loan officer would write. In a photo, "pixel (212, 40) is brighter than 0.7" means nothing; the meaning is in how thousands of pixels are arranged. A tree asks questions of one column at a time, which is perfect for the spreadsheet and hopeless for the photo. A neural network builds its own features out of combinations of inputs, which is what the photo needs.

Tiny example. A toy loan table: 800 training rows and 400 validation rows, five columns in their natural units: age in years, income in dollars (spanning a factor of 100), a count of late payments, a region code 0 to 5 (where region 4 is not "twice region 2"), and one column of pure random noise. The label follows rules of the kind a lender writes (four or more late payments; under 30,000 and under 30 years old; two late payments in certain regions or on a very low income), with 5% of labels flipped at random.

model what it was given validation accuracy
always predict "fine" nothing 77.75%
single tree, depth 5 the raw table 92.25%
random forest, 50 trees the raw table 95.75%
gradient boosting, 100 depth-3 trees the raw table 96.00%
small neural net (32 hidden units) the raw table 77.75%
same net every column rescaled to mean 0, spread 1 86.25%
same net rescaled, plus log income and one column per region 92.25%

Validation accuracy on the loan table: random forest 95.75% and gradient boosting 96.0% lead; the neural net scores 77.75% on raw inputs (the same as always saying fine), 86.25% scaled, and 92.25% with engineered features

Reading it: blue bars are tree models, orange bars are the same small neural network given three versions of the same table, and the dashed line is the score for ignoring the inputs and always predicting "fine". The trees read the table exactly as it came. The net on raw inputs sits on the dashed line: incomes around 50,000 swamp every other column and saturate its neurons, so it learns nothing. Rescaling rescues it partly; hand-made features (the logarithm of income, a yes/no column per region) rescue it more. Even then it only ties a single depth-5 tree, and the ensembles stay ahead.

flowchart TD START{"what does one input look like?"} -->|"a row of a table:<br/>each column means<br/>something alone"| SIZE{"how much data?"} START -->|"pixels, audio samples,<br/>words, sequences"| NN["neural network<br/>(often a pretrained one)"] SIZE -->|"thousands to millions of rows"| GBT["gradient-boosted trees<br/>or a random forest first"] SIZE -->|"huge, or mixed with<br/>text or images"| MIX["neural network, or trees<br/>on top of neural embeddings"] GBT --> CHECK["compare against a neural net<br/>only if the gap matters"]

Reading it: the first question is about the shape of one input, not about fashion. Tables go down the left; perceptual and sequential data go right. On the left, the size of the data decides: at typical business scale, start with trees. The bottom-right branch is common in practice: a neural network turns text or images into embeddings (primer.ml.embeddings.word2vec), and those numbers become extra columns for a boosted-tree model.

Why trees fit tables so well, each point visible in this lesson's code:

  • Units don't matter. A tree only asks "is this bigger than that?", so any order-keeping change to a column (dollars to thousands, a logarithm, a square) leaves every prediction unchanged. A neural net multiplies and adds columns, so their scales collide.
  • Thresholds are native. Rules like "four or more late payments" are one question to a tree; a net must approximate the sharp edge with smooth curves.
  • Irrelevant columns are mostly ignored, because a column that never wins a split is never used.
  • Modest data is enough. A few hundred rows can grow a useful tree; a net with thousands of weights needs far more to pin them down.

Where neural networks win, and trees cannot follow:

  • Perceptual and sequential data: images, audio, text. The useful features are built from arrangements of raw values, which is what convolutions (primer.ml.cnn_rnn) and attention (primer.ml.transformer) learn.
  • Huge data and pretraining. A net's capacity keeps paying off as data grows, and a pretrained model brings knowledge from billions of examples. A tree starts from nothing every time.
  • Smooth trends and extrapolation. A tree's leaves hold averages of training values, so it can never predict outside the range it saw. The four-house model predicts the same price for a house of size 40 as for size 4.
  • End-to-end training with other neural parts, since a tree has no gradient to pass along.

In a careful benchmark on 45 tabular datasets of about 10,000 rows each (Grinsztajn, Oyallon and Varoquaux, 2022), tuned tree-based models remained ahead of tuned deep learning models. Their analysis names three reasons that match this lesson: trees shrug off uninformative columns, they respect each column's own axis, and they fit irregular, step-like functions easily. Research on neural networks for tables is active and the gap may narrow; the practical rule is to start with boosted trees on a table and make any other model beat them on your validation data.

In code: make_tabular builds the loan table; tabular_showdown trains every model in the table above (the net is primer.ml.neural_net.MLP); engineer_features is the hand-made preparation the net needed.

Feature importance, and its limits

Everyday picture. After a football season you want to know which players mattered. One way: count how often each player touched the ball in training. Another: bench each player for a real match and see how much the score suffers. The first is quick but credits whoever is busy; the second measures what the team actually depends on.

Trees offer the first kind for free. Impurity importance adds up, for each feature, the impurity its splits removed (weighted by how many examples reached each split), measured on the training data. Permutation importance is the second kind: take held-out data, shuffle one column so it no longer lines up with the labels, and measure how much accuracy drops.

Tiny example. The 30-tree forest scores 96.0% on the 400 validation rows. Shuffle the late-payments column and it scores 77.5%: a drop of 18.5 points, so the model leans on that column heavily. Shuffle the noise column and it scores 95.75%, a drop of 0.25 points (one row in 400).

Level 3: the formula and its symbols

$$ I_j = \text{acc}(X_{\text{val}}) - \text{acc}\big(X_{\text{val}} \text{ with column } j \text{ shuffled}\big) $$

Symbols

Symbol Meaning here For late payments
$j$ the feature (column) being tested late_payments
$X_{\text{val}}$ held-out rows the model never trained on 400 rows
$\text{acc}(\ldots)$ the share of those rows the model gets right 0.96
shuffled the column's values randomly reordered: same values, links to the labels broken 0.775
$I_j$ the permutation importance of feature $j$: accuracy lost without it 0.185

In words: "a feature's importance is how much accuracy the model loses on fresh data when that feature is scrambled."

With the numbers: late payments $0.96 - 0.775 = 0.185$; noise $0.96 - 0.9575 = 0.0025$.

Level 3: in Python

In Python:

# validation rows answered correctly, out of 400
correct_of_400 = {"intact": 384, "late_payments shuffled": 310, "noise shuffled": 383}
acc = {k: v / 400 for k, v in correct_of_400.items()}
# I_j = acc(X_val) - acc(X_val with column j shuffled)
round(acc["intact"] - acc["late_payments shuffled"], 4)  # → 0.185
round(acc["intact"] - acc["noise shuffled"], 4)  # → 0.0025

Two bar charts over age, income, late payments, region and noise: impurity importance gives the noise column 11% of the credit; permutation importance gives it 0.25 points of accuracy, far below late payments at 18.5 points

Reading it: the left panel is impurity importance from training, as shares that add to 100%; the right is permutation importance on validation data, in accuracy lost. Both agree the real columns matter. They disagree about noise: on the left it gets 11% of the credit, because deep trees can always find some threshold on a continuous random column that tidies up a few training rows. On the right, scrambling it costs almost nothing, because none of those splits help on new data.

The limits worth remembering:

  • Impurity importance favours columns with many distinct values (continuous numbers, IDs), since they offer more thresholds to overfit with. Measure on held-out data instead.
  • Correlated columns split the credit. If income appears twice (in dollars and in thousands), each copy looks half as important, and shuffling one does little because the other covers for it.
  • Importance is not cause. It says what this model relies on to predict, not what would change the outcome in the world.

A single shallow tree is the one truly readable model here: print DecisionTree.rules and you are reading the entire model. A forest of hundreds of deep trees is not readable, and importances are a summary of it, not an explanation.

In code: permutation_importance shuffles one column at a time on held-out data; DecisionTree.feature_importances and RandomForest.feature_importances are the impurity kind.

In 20 seconds

  • A decision tree asks yes/no questions about one column at a time, choosing each question greedily to leave the purest piles (lowest weighted Gini impurity or entropy), until a stopping rule says stop.
  • Deep trees overfit: a question for every example gives 100% on training data and poor results on new data. Depth is the capacity knob.
  • Random forests average many deep trees, each grown on a bootstrap sample with random feature subsets. Averaging removes the variance the trees don't share; decorrelating them lowers the part they do.
  • Gradient boosting adds small trees one at a time, each fit to the residuals (the negative gradient of the loss), shrunk by a learning rate, with the number of rounds set by early stopping.
  • On tables, trees usually win: no scaling, native thresholds, modest data. Neural networks win on images, audio, text and huge datasets.

Self-test questions

Why is Gini impurity 0.5 for a pile that is half spam and half not? It is the chance that two emails drawn at random (with replacement) carry different labels: $1 - (0.5^2 + 0.5^2) = 0.5$. For two labels that is the most mixed a pile can be.

Why weight each child pile by its size when scoring a split? Otherwise a question that peels off one example into a tiny pure pile would look as good as one that sorts half the data cleanly. Weighting by size measures how much of the data the question actually tidied.

Why does a tree need no feature scaling, when a neural network does? A tree only compares one column against a threshold, so any change that keeps the order of values (rescaling, logarithms) produces the same splits and the same predictions. A network multiplies and adds columns together, so a column measured in tens of thousands drowns the others and saturates its neurons.

A tree scores 100% on training data and 80% on validation. What happened, and what are two fixes? It overfitted: it kept splitting until each leaf fenced off individual noisy examples. Limit its depth (or require a minimum number of examples per leaf, or prune), or replace it with a random forest that averages many such trees.

Why does a random forest use random feature subsets, not just bootstrap samples? The forest's variance is $\rho\sigma^2 + (1 - \rho)\sigma^2/B$. More trees only shrink the second term; the floor is set by the correlation $\rho$ between trees. With bagging alone every tree tends to pick the same strong feature first and they stay correlated. Random subsets force different trees down different paths, lowering $\rho$.

Does adding more trees overfit a random forest? Does adding more rounds overfit gradient boosting? More trees in a forest does not: accuracy rises and then levels off, only costing time. More rounds of boosting does: each round fits the training residuals more closely, so validation error reaches a minimum and then climbs. Boosting needs early stopping; forests don't.

Why is the residual the right target for each boosting tree? For squared-error loss $\frac{1}{2}(y - F)^2$, the negative derivative with respect to the prediction $F$ is $y - F$, the residual. Fitting a tree to it and taking a step is gradient descent on the predictions. With another loss, the tree fits that loss's negative gradient instead, such as $y - p$ for log loss.

What does a smaller learning rate buy in gradient boosting, and what does it cost? Each tree contributes only a fraction of its correction, so no single noisy tree can move the model far and many trees must agree. That usually generalizes better (0.110 against 0.122 in this lesson's sweep). It costs more rounds, so more training and prediction time.

Why can't a boosted-tree model predict a house price above the highest price it trained on? Every leaf predicts an average of training values, and the model is a sum of such leaves anchored at the training mean. Beyond the edge of the training data every question gives the same answer as at the edge, so the prediction stays flat.

What is wrong with trusting impurity-based feature importance? It is measured on training data, where splits on noise still tidy up piles. It favours columns with many distinct values and splits credit between correlated columns. Permutation importance on held-out data is more honest, and neither measures cause and effect.

When would you choose a neural network over gradient-boosted trees? When one input is an image, audio clip, text or other sequence whose meaning lies in the arrangement of raw values; when the data is huge or a pretrained model can be reused; when predictions must extrapolate smoothly; or when the model must be trained end to end with other neural parts.

The papers behind this lesson

  • Breiman, Friedman, Olshen and Stone, Classification and Regression Trees (1984): https://doi.org/10.1201/9781315139470. The CART book: binary trees split by Gini impurity, regression trees with averaged leaves, and cost-complexity pruning, the recipe this lesson's tree follows.
  • Quinlan, Induction of Decision Trees, Machine Learning 1 (1986): https://doi.org/10.1007/BF00116251. ID3, which grows trees by choosing the split with the largest information gain (drop in entropy).
  • Breiman, Bagging Predictors, Machine Learning 24 (1996): https://doi.org/10.1007/BF00058655. Showed that averaging models trained on bootstrap samples reduces the error of unstable learners such as trees.
  • Breiman, Random Forests, Machine Learning 45 (2001): https://doi.org/10.1023/A:1010933404324. Added random feature subsets at each split, out-of-bag error estimates, and permutation importance. Annotated companion
  • Friedman, Greedy Function Approximation: A Gradient Boosting Machine, Annals of Statistics 29 (2001): https://doi.org/10.1214/aos/1013203451. Framed boosting as gradient descent in function space, fitting each tree to the negative gradient of any differentiable loss, with shrinkage. Annotated companion
  • Chen and Guestrin, XGBoost: A Scalable Tree Boosting System (2016): https://arxiv.org/abs/1603.02754. A regularized, second-order boosting objective with fast, sparsity-aware split finding, which made boosted trees the default on tabular problems. Annotated companion
  • Grinsztajn, Oyallon and Varoquaux, Why do tree-based models still outperform deep learning on tabular data? (2022): https://arxiv.org/abs/2207.08815. A benchmark on 45 medium-sized tabular datasets where tuned tree ensembles beat tuned neural networks, and an analysis of why.

Further reading

on GitHub
   1r"""
   2# Trees and boosting: the other workhorse
   3
   4Run: `python -m primer.ml.classical`
   5
   6New to the notation? `primer.notation` explains every symbol used here from
   7zero. This lesson builds on overfitting from `primer.ml.regularization` and
   8uses the small network from `primer.ml.neural_net` as its sparring partner.
   9
  10## Level 1: The practitioner's guide
  11
  12**In one sentence.** On a table of data, where each column means something
  13on its own, an ensemble of decision trees (a random forest or gradient
  14boosting) is usually the model to beat, trains in seconds, needs no feature
  15scaling, and should be the first thing you try before any neural network.
  16
  17**When you need it.** Whenever one input is a row: a customer, a payment, a
  18day, a shipment, with columns in their own units (years, dollars, counts, a
  19region code). Loan approval, fraud flags, churn, demand forecasting, ad
  20ranking on tabular features: these are tree problems. The tell is that a
  21domain expert could write some of the rules by hand ("four or more late
  22payments"), because a tree asks exactly that kind of question. This
  23lesson's loan table shows the gap: gradient boosting scores 96.0% and a
  24random forest 95.75% on the raw table, while a small neural network on the
  25same raw columns scores 77.75%, which is the score for always predicting
  26"fine". Rescaling every column lifts the net to 86.25%; hand-engineering
  27the features (log income, one column per region) lifts it to 92.25%, where
  28it ties a single depth-5 tree and still trails both ensembles. You do not
  29need trees when the input is an image, an audio clip or a text, where the
  30meaning lives in the arrangement of thousands of raw values; that is what
  31convolutions and attention learn, and where a pretrained network brings
  32knowledge a tree starts without.
  33
  34**Your options.** From the most readable to the most accurate on a table,
  35then the cases where the table is not the whole story:
  36
  37| Option | What it does | What it guarantees | What it costs | Where it lives |
  38|---|---|---|---|---|
  39| One shallow decision tree | Asks yes/no questions about one column at a time, chosen greedily by impurity | A model you can print and read as if/else rules | Accuracy: a depth-5 tree scores 92.25% where the ensembles score 96% | scikit-learn's decision trees; rules a compliance team can audit |
  40| Random forest | Grows hundreds of deep trees on bootstrap samples with random feature subsets and averages them | Forgiving defaults; more trees never overfit, only cost time; a free out-of-bag error estimate | Not readable; a plateau set by how correlated the trees are | The standard first model on a new table |
  41| Gradient-boosted trees | Adds small trees in sequence, each fitted to what the model still gets wrong, shrunk by a learning rate | The best accuracy on most tables of thousands to millions of rows | Two knobs to tune (depth, learning rate) and a stopping round chosen on validation data; more rounds do overfit | XGBoost, LightGBM, CatBoost |
  42| A neural network on the table | Learns its own features from combinations of columns | Smooth trends, extrapolation, end-to-end training with other neural parts | Scaling and feature engineering just to get started; more data to pin down its weights; on this lesson's table it still trails | When the table is huge or mixed with text and images |
  43| Trees on neural embeddings | A network turns text or images into vectors, which become extra columns for a boosted-tree model | The table's structure and the perceptual data's meaning in one model | Two models to maintain | Common in production ranking and risk systems |
  44| A neural network on perceptual or sequential data | Convolutions, attention, pretrained weights | The only option that works: a tree cannot read a photo one pixel at a time | Everything the rest of this primer covers | Images, audio, text |
  45
  46**How to choose.** The first question is the shape of one input, not what
  47is fashionable.
  48
  49- A row of a table, thousands to millions of rows: gradient-boosted trees,
  50  or a random forest if you want something that works untuned. Make any
  51  other model beat them on your validation data.
  52- The model must be explained to a person, line by line: one shallow tree,
  53  and accept the accuracy it costs. A forest's importances are a summary,
  54  not an explanation.
  55- Values outside the training range matter (prices next year, loads at a
  56  new scale): trees predict flat beyond the edge of their data; this
  57  lesson's four-house model prices a house of size 40 the same as size 4.
  58  Use a model with a slope, or add a trend feature.
  59- Pixels, audio samples, words: a neural network, usually pretrained.
  60- A table that also carries free text or images: embed those with a
  61  network and hand the vectors to the trees.
  62- Whatever you pick, tune the tree's depth and the boosting learning rate
  63  on validation data, and pick the number of boosting rounds by early
  64  stopping. Those two knobs are most of the tuning that matters.
  65
  66**What it costs.** Training a forest or a boosted model on a table of
  67modest size takes seconds on a laptop, with no GPU, no scaling and no
  68feature engineering: the trees read dollars, years and region codes as they
  69come. A forest grows its trees independently, so it parallelises across
  70cores and machines; boosting is sequential, and a smaller learning rate
  71buys accuracy with rounds. In this lesson's sweep, a learning rate of 1
  72reaches its best validation error (0.122) at round 4, 0.3 reaches 0.112 at
  73round 9, and 0.1 reaches the lowest, 0.110, at round 31. A forest's
  74accuracy climbs over the first ten or twenty trees and then flattens; past
  75the plateau, every extra tree is only prediction time. The cost that
  76surprises people is on the neural side: the net needed rescaled inputs and
  77hand-made features to reach a score a single tree matched, and Grinsztajn,
  78Oyallon and Varoquaux (2022) found tuned tree ensembles still ahead of
  79tuned deep learning across 45 tabular datasets of about 10,000 rows.
  80
  81**What breaks.**
  82
  83- **A tree grown to purity.** No depth limit gives 100% on training data and
  84  the worst validation score in this lesson's sweep (82%, against 89.5% at
  85  depth 2). Limit the depth or the leaf size, or use an ensemble.
  86- **Too many boosting rounds.** Every learning rate's validation curve
  87  bottoms out and climbs; 200 rounds memorise 80 points. Stop early on
  88  validation data.
  89- **A forest that has stopped improving.** The floor is the correlation
  90  between trees: with correlation 0.3, going from 10 trees to 1,000 moves
  91  the variance from 0.37 to 0.30. Lower the correlation (fewer features per
  92  split) rather than adding trees.
  93- **Trusting impurity importance.** It is measured on training data and
  94  favours columns with many distinct values: here a column of pure noise
  95  gets 11% of the credit. Permutation importance on held-out data gives it
  96  0.25 points. Correlated columns split the credit between them, and
  97  neither kind measures cause.
  98- **Extrapolation.** Leaves hold averages of training values, so a tree
  99  never predicts outside the range it saw.
 100- **A network on raw columns.** Incomes in the tens of thousands swamp every
 101  other column and saturate the neurons; the net learns nothing. Scale
 102  first, or use trees.
 103- **Diagonal boundaries.** A tree cuts parallel to the axes and can only
 104  approximate a slope with a staircase; if the rule is a ratio of two
 105  columns, give it that ratio as a feature.
 106
 107**In the wild.** XGBoost (Chen and Guestrin, 2016), LightGBM and CatBoost
 108are the boosting libraries behind most winning tabular models, adding a
 109second-order step per leaf, penalties on tree size, histogram-based split
 110search and native missing values to the loop this lesson builds.
 111scikit-learn's decision trees, random forests and permutation importance
 112are the reference implementations for the rest, with its user guides in
 113Further reading. The recipe is Breiman's: bagging (1996) and random forests
 114(2001), which also introduced out-of-bag error and permutation importance;
 115the boosting loop is Friedman's gradient boosting machine (2001); and the
 116tree itself is CART (Breiman, Friedman, Olshen and Stone, 1984). Strobl et
 117al. (2007) documented the bias in impurity importance that this lesson
 118reproduces with its noise column. In `primer.ml.interpretability` the
 119question of what a model relies on returns for neural networks, where no
 120tree can be printed.
 121
 122**Go deeper.** Level 2 grows a tree by hand on eight emails, scores every
 123candidate question with Gini impurity and entropy, sweeps depth to watch
 124overfitting appear, builds a random forest from bootstrap samples and
 125derives the correlation floor on its variance, runs gradient boosting on
 126four houses one round at a time and shows why the residual is a gradient,
 127then stages the loan-table showdown and both kinds of feature importance.
 128If you only needed to know which model to reach for on a table, you are
 129done.
 130
 131## Level 2: How it works, from scratch
 132
 133Most of this primer is about neural networks, because language models are
 134neural networks. But open the models that decide whether a loan is approved,
 135whether a card payment looks like fraud, which ad to show, or how many
 136umbrellas a shop should stock, and very often you find no neural network at
 137all. You find hundreds of small **decision trees**, added together.
 138
 139Those problems share a shape: a **table**. Each row is a customer, a payment
 140or a day; each column is a fact measured in its own units (age in years,
 141income in dollars, number of late payments, a region code). On tables of
 142modest size, ensembles of trees have stayed the model to beat, and they
 143train in seconds on a laptop. This lesson builds them from scratch, so you
 144can see why they work and, just as usefully, recognise when a neural network
 145is the wrong tool.
 146
 147The plan: one tree (how it chooses its questions), why a single tree
 148overfits, how a **random forest** fixes that by averaging many trees, how
 149**gradient boosting** fixes it differently by adding trees one after another,
 150and finally a head-to-head against a small neural network from
 151`primer.ml.neural_net` on a loan table.
 152
 153## A decision tree is a game of twenty questions
 154
 155**Everyday picture.** A nurse at an emergency desk runs a flowchart: "Is the
 156patient breathing normally? No: resuscitation room. Yes: is there chest
 157pain? Yes: see a doctor now. No: take a seat." Each question looks at *one*
 158fact and sends the patient left or right, and the last box gives the answer.
 159A decision tree is exactly that flowchart, except the questions are chosen
 160by the computer from past examples.
 161
 162**Tiny example.** Eight emails, each described by two facts: how many links
 163it contains, and whether it comes from a sender you have written to before.
 164
 165| email | links | known sender | spam? |
 166|---|---|---|---|
 167| 1 | 0 | yes | no |
 168| 2 | 1 | yes | no |
 169| 3 | 4 | yes | no |
 170| 4 | 0 | no | no |
 171| 5 | 3 | no | yes |
 172| 6 | 5 | no | yes |
 173| 7 | 6 | no | yes |
 174| 8 | 1 | no | yes |
 175
 176The tree this lesson grows from those eight rows:
 177
 178```mermaid
 179flowchart TD
 180  Q1{"known sender?"} -->|yes| L1["not spam<br/>(emails 1, 2, 3)"]
 181  Q1 -->|no| Q2{"more than 0 links?"}
 182  Q2 -->|no| L2["not spam<br/>(email 4)"]
 183  Q2 -->|yes| L3["spam<br/>(emails 5, 6, 7, 8)"]
 184```
 185
 186**Reading it:** start at the top diamond with a new email and answer each
 187question until you land in a box. Diamonds are questions (the tree's
 188**internal nodes**); boxes are answers (its **leaves**), and each leaf lists
 189the training emails that ended up there. Two questions sort all eight
 190correctly. Notice what the tree did *not* ask: "more than 2 links?" looks
 191reasonable, but it was worse. The next two sections show how the tree
 192decides that.
 193
 194**In code:** `spam_emails` returns the table above; `DecisionTree` grows the tree, and `DecisionTree.rules` prints it as nested if/else lines.
 195
 196## Measuring a mess: Gini impurity
 197
 198**Everyday picture.** You are sorting laundry into piles. A pile of only
 199socks is *pure*: pull out any two items and they match. A pile that is half
 200socks, half shirts is as mixed-up as two kinds of item can be. A good
 201sorting question is one that leaves piles as pure as possible.
 202
 203**Tiny example.** Before any question, the eight emails are 4 spam and 4
 204not: a 50/50 pile. Pull out two emails at random (putting the first one
 205back) and they disagree half the time. The pile of unknown senders is 1
 206"not spam" and 4 spam: purer, so two random picks disagree less often.
 207
 208**Gini impurity** is exactly that chance of disagreement:
 209
 210$$
 211G = 1 - \sum_{k=1}^{K} p_k^{2}
 212$$
 213
 214**Symbols**
 215
 216| Symbol | Meaning here | In the example |
 217|---|---|---|
 218| $G$ | the Gini impurity of one pile of examples: 0 when pure, larger when mixed | 0.5 for the eight emails |
 219| $K$ | how many different labels there are | 2 (spam, not spam) |
 220| $k$ | a counter that walks over the labels | 1 = not spam, 2 = spam |
 221| $p_k$ | the share of the pile carrying label $k$ | $p_1 = 4/8$, $p_2 = 4/8$ |
 222| $p_k^{2}$ | the chance two random picks *both* have label $k$ | $0.5^2 = 0.25$ |
 223| $\sum_{k=1}^{K}$ | "add up over every label": the chance two picks match | $0.25 + 0.25 = 0.5$ |
 224| $1 - \ldots$ | turns "chance they match" into "chance they disagree" | $1 - 0.5$ |
 225
 226**In words:** "the impurity of a pile is one minus the sum of the squared
 227shares of each label: the chance that two examples drawn at random carry
 228different labels."
 229
 230**With the numbers:** all eight emails: $1 - (0.5^2 + 0.5^2) = 1 - 0.5 =
 2310.5$. The unknown-sender pile, 1 of 5 not spam and 4 of 5 spam:
 232$1 - (0.2^2 + 0.8^2) = 1 - (0.04 + 0.64) = 0.32$. A pure pile: $1 - 1^2 = 0$.
 233
 234**In Python:**
 235
 236```python
 237# four spam (1) and four not spam (0)
 238labels = [1, 1, 1, 1, 0, 0, 0, 0]
 239# p_k: the share of each label
 240p = [labels.count(k) / len(labels) for k in (0, 1)]
 241p  # → [0.5, 0.5]
 242# G = 1 - Σ p_k²
 2431 - sum(p_k ** 2 for p_k in p)  # → 0.5
 244# the unknown-sender pile: 1 not spam, 4 spam
 245p = [1 / 5, 4 / 5]
 246round(1 - sum(p_k ** 2 for p_k in p), 2)  # → 0.32
 247```
 248
 249**Why it matters:** the tree needs a single number to compare questions
 250with, and it must be cheap, because a real tree scores millions of candidate
 251questions. Gini needs only counts, one multiply per label, and no logarithm.
 252
 253**In code:** `gini` computes $G$ for any list of labels.
 254
 255### Entropy: the same idea, counted in yes/no questions
 256
 257**Everyday picture.** Entropy asks: if I had to guess an email's label by
 258asking yes/no questions, how many would I need on average? A 50/50 pile
 259needs one full question. A pure pile needs none, because you already know.
 260
 261**Tiny example.** For the eight emails, one question ("is it spam?")
 262settles it: entropy 1 bit. For the unknown-sender pile, 4 of 5 are spam, so
 263you are usually right before asking and the average cost is about 0.72 of a
 264question.
 265
 266$$
 267H = -\sum_{k=1}^{K} p_k \log_2 p_k
 268$$
 269
 270**Symbols**
 271
 272| Symbol | Meaning here | In the example |
 273|---|---|---|
 274| $H$ | the entropy of one pile, in **bits** (yes/no questions) | 1 for the eight emails |
 275| $p_k$ | the share of the pile carrying label $k$, as above | 0.2 and 0.8 |
 276| $\log_2 p_k$ | the **logarithm** base 2: the power you raise 2 to in order to get $p_k$. It is negative for shares below 1 | $\log_2 0.5 = -1$, $\log_2 0.2 = -2.32$ |
 277| $-\sum$ | add up over the labels, then flip the sign so the total is positive | |
 278
 279**In words:** "entropy is the average, over the labels, of how surprising
 280each label is, weighted by how often it appears." A label with share 0.5
 281costs $-\log_2 0.5 = 1$ bit of surprise.
 282
 283**With the numbers:** eight emails: $-(0.5 \cdot (-1) + 0.5 \cdot (-1)) = 1$
 284bit. Unknown-sender pile: $-(0.2 \cdot \log_2 0.2 + 0.8 \cdot \log_2 0.8) =
 285-(0.2 \cdot (-2.32) + 0.8 \cdot (-0.32)) = 0.722$ bits.
 286
 287**In Python:**
 288
 289```python
 290import math
 291# H = -Σ p_k log2 p_k
 292p = [0.5, 0.5]
 293-sum(p_k * math.log2(p_k) for p_k in p)  # → 1.0
 294p = [1 / 5, 4 / 5]
 295round(-sum(p_k * math.log2(p_k) for p_k in p), 3)  # → 0.722
 296```
 297
 298Gini and entropy almost always pick the same question; Gini is a little
 299cheaper. The drop in entropy a question produces is called **information
 300gain**, the name the early ID3 algorithm used. See `primer.notation` for
 301logarithms from scratch and `primer.ml.losses` for entropy's close cousin,
 302cross-entropy.
 303
 304**In code:** `entropy` computes $H$ in bits; `DecisionTree` takes `criterion="entropy"` to split by it instead of Gini.
 305
 306## Choosing the best question
 307
 308**Everyday picture.** A question splits one pile into two. Judge it by how
 309messy the two new piles are, but weigh each by its size: a tiny pure pile of
 310one email is worth less than a big pure pile of five.
 311
 312**Tiny example.** "Known sender?" makes a pile of 3 (all not spam, Gini 0)
 313and a pile of 5 (1 not spam, 4 spam, Gini 0.32). "More than 2 links?" makes
 314a pile of 4 (3 not spam, 1 spam) and another pile of 4 (1 not spam, 3 spam),
 315Gini 0.375 each.
 316
 317$$
 318G_{\text{split}} = \frac{n_L}{n}\, G(L) + \frac{n_R}{n}\, G(R)
 319$$
 320
 321**Symbols**
 322
 323| Symbol | Meaning here | For "known sender?" |
 324|---|---|---|
 325| $L$, $R$ | the left pile (answer yes) and the right pile (answer no) | known / unknown senders |
 326| $n_L$, $n_R$ | how many examples land in each pile | 3 and 5 |
 327| $n$ | how many examples reached this node, $n_L + n_R$ | 8 |
 328| $G(L)$, $G(R)$ | the Gini impurity of each pile (the formula above) | 0 and 0.32 |
 329| $\frac{n_L}{n}$ | the left pile's share of the examples: its weight | 3/8 |
 330| $G_{\text{split}}$ | the impurity left after asking: lower is better | 0.2 |
 331
 332**In words:** "the mess left by a question is the average of the two piles'
 333impurities, each weighted by the share of examples in it."
 334
 335**With the numbers:** known sender: $\frac{3}{8} \cdot 0 + \frac{5}{8} \cdot
 3360.32 = 0.2$. More than 2 links: $\frac{4}{8} \cdot 0.375 + \frac{4}{8}
 337\cdot 0.375 = 0.375$. The tree asks "known sender?" because 0.2 < 0.375.
 338The **impurity decrease** (the gain) is $0.5 - 0.2 = 0.3$.
 339
 340**In Python:**
 341
 342```python
 343# known senders: 3 emails, all not spam
 344n_left, g_left = 3, 0.0
 345# unknown senders: 1 not spam, 4 spam
 346n_right, g_right = 5, 0.32
 347n = n_left + n_right
 348# G_split = (n_L/n)·G(L) + (n_R/n)·G(R)
 349round(n_left / n * g_left + n_right / n * g_right, 3)  # → 0.2
 350# the impurity the question removed
 351round(0.5 - 0.2, 3)  # → 0.3
 352```
 353
 354Where do candidate questions come from? For a number such as "links", only
 355thresholds *between* neighbouring values in the data can change which
 356emails go where. The links column holds 0, 1, 3, 4, 5 and 6, so there are
 357five thresholds worth trying (halfway points 0.5, 2, 3.5, 4.5, 5.5), plus
 358one for the yes/no column. The tree scores all six and keeps the lowest.
 359
 360![Weighted Gini after each of six candidate first questions: known_sender scores 0.200, the best; the five links thresholds score between 0.333 and 0.467; all are below the 0.5 of the unsplit pile](figures/primer.ml.classical.spam_splits.svg)
 361
 362**Reading it:** each bar is one question the tree could ask first, and its
 363length is the weighted Gini impurity left afterwards. The dashed line is the
 364impurity before asking anything (0.5). Every question helps a little, since
 365every bar ends left of the line, but "known_sender > 0.5" (blue) leaves the
 366least mess by a clear margin, so it becomes the root of the tree. Two links
 367thresholds tie at 0.333: the greedy search would take the first if known
 368sender did not exist.
 369
 370**In code:** `split_impurity` is $G_{\text{split}}$; `candidate_splits` scores every question in the figure; `best_split` does the same search fast, by sorting each feature once and keeping running label counts, and returns a `Split`.
 371
 372**Why it matters:** this greedy search is the whole learning algorithm. There
 373is no gradient and no learning rate: the tree tries every feature and every
 374threshold and takes the best. That is why trees handle columns in any units,
 375as the last section shows.
 376
 377## Growing the tree: ask, split, repeat
 378
 379**Everyday picture.** Sorting a big box of photos: first by year, then each
 380year's pile by who is in them, then each of those by place, until every pile
 381is tidy or too small to bother with.
 382
 383**Tiny example.** After "known sender?", the left pile (emails 1, 2, 3) is
 384already pure: it becomes a leaf. The right pile (4, 5, 6, 7, 8) is not, so
 385the tree searches again *inside that pile only*. There, "more than 0 links?"
 386separates email 4 from the four spam emails perfectly. Both new piles are
 387pure, so growth stops at depth 2.
 388
 389```mermaid
 390flowchart TD
 391  S["node: the examples that reached here"] --> P{"pure, too small,<br/>or at the depth limit?"}
 392  P -->|yes| LEAF["make a leaf:<br/>predict the majority label<br/>(or the average number)"]
 393  P -->|no| SEARCH["try every feature and every threshold;<br/>keep the lowest weighted impurity"]
 394  SEARCH --> SPLIT["send examples left (feature ≤ threshold)<br/>or right (feature > threshold)"]
 395  SPLIT --> LEFT["grow the left child"] & RIGHT["grow the right child"]
 396  LEFT -.-> S
 397  RIGHT -.-> S
 398```
 399
 400**Reading it:** the same recipe runs at every node, which is why the code is
 401a function that calls itself (recursion). The dotted arrows are those
 402calls: each child is a fresh node holding only its share of the examples.
 403The diamond is where growth stops; everything else is the search from the
 404previous section. To predict, a new example simply follows the questions
 405from the root down to a leaf.
 406
 407Each question looks at a single feature and compares it with a threshold,
 408so every question cuts the input space with a straight line parallel to an
 409axis. A tree therefore carves the plane into rectangles:
 410
 411![A tree on two-moon data at depth 1, 3 and unlimited: one horizontal cut, then a few rectangles, then a patchwork of 51 boxes with thin strips around individual noisy points](figures/primer.ml.classical.regions.svg)
 412
 413**Reading it:** the dots are 300 training points of two interleaved
 414half-moons with noise (`primer.ml.neural_net.make_moons`); the shaded
 415background is what the tree would predict at each spot. At depth 1 there is
 416one question, so one straight horizontal cut. At depth 3 there are 8 leaves, and the
 417boundary is a staircase of vertical and horizontal edges that roughly
 418follows the moons. With no limit, the tree keeps cutting until every leaf is
 419pure: 51 boxes, including thin strips that reach into the other class's
 420territory to capture a few noisy points.
 421Every edge is axis-aligned: trees cannot draw a diagonal, only approximate
 422one with steps.
 423
 424A regression tree, predicting a number instead of a label, is the same
 425recipe with two swaps: the impurity is the **variance** of the numbers in a
 426pile (how spread out they are), and a leaf predicts their average. Gradient
 427boosting, below, is built from those.
 428
 429**In code:** `DecisionTree` grows by the recipe in the diagram (a depth limit and a minimum leaf size are its stopping rules, and the "squared" criterion makes it a regression tree), and `DecisionTree.predict` routes each row down to its leaf.
 430
 431## Overfitting: a question for every example
 432
 433**Everyday picture.** Ask a friend to write rules for which of their emails
 434are spam, and forbid them from stopping until every past email is sorted
 435correctly. They will end up with rules like "an email from Pat at 9:14 on a
 436Tuesday with 3 links is spam": perfect on the past, useless for the future.
 437
 438**Tiny example.** On the noisy moons, a tree with no depth limit grows 51
 439leaves and gets 100% of its 300 training points right. On 200 fresh points
 440it gets 82%. A tree stopped at depth 2 has 4 leaves, gets 88% on training
 441and 89.5% on the fresh points: it learned the shape rather than the noise.
 442
 443![Training and validation accuracy against the depth limit: training rises from 78% to 100%; validation peaks at 89.5% for depth 2 and 3 and falls to 82% with no limit](figures/primer.ml.classical.depth_sweep.svg)
 444
 445**Reading it:** the horizontal axis is the depth limit, from one question to
 446no limit at all. The blue training curve only ever rises, because every
 447extra level can only fit the training points better. The orange validation
 448curve peaks early, at depth 2 to 3, then slides down as the tree starts
 449building boxes around individual noisy points. The widening gap between
 450the two curves is overfitting, exactly as in `primer.ml.regularization`.
 451
 452A single tree is the textbook **high-variance** model: change a few training
 453points and the top question can flip, and everything below it changes with
 454it. There are two ways out. Keep trees small (limit the depth, require a
 455minimum number of examples per leaf, or grow fully then **prune** back the
 456branches that do not help on validation data). Or keep trees big and combine
 457many of them, which is what the rest of this lesson does.
 458
 459**In code:** `depth_sweep` grows one tree per depth limit on `moons_split` data and measures both accuracies.
 460
 461**Why it matters:** depth is the tree's capacity knob. Every tree library
 462exposes it (along with a minimum leaf size), and choosing it on validation
 463data is the first thing to tune.
 464
 465## Random forests: many noisy trees, one steady vote
 466
 467**Everyday picture.** At a county fair, hundreds of people guess the weight
 468of an ox. Individual guesses are wildly off in both directions, but the
 469average of all of them lands remarkably close, because the errors point in
 470different directions and cancel. A **random forest** does this with trees:
 471grow many deep trees that each make *different* mistakes, then average
 472them. The trick is making the mistakes different, since averaging copies of
 473the same tree changes nothing.
 474
 475Two sources of difference, both from Leo Breiman:
 476
 4771. **Bagging** (bootstrap aggregating): each tree trains on its own
 478   **bootstrap sample**, a resample of the training rows drawn *with
 479   replacement*.
 4802. **Random feature subsets**: at every node, a tree may only consider a
 481   random handful of the features (commonly the square root of their
 482   number), so different trees are forced to find different questions.
 483
 484```mermaid
 485flowchart LR
 486  D["training table<br/>(n rows)"] --> B1["bootstrap sample 1<br/>n rows drawn with replacement"] & B2["bootstrap sample 2"] & B3["..."] & BB["bootstrap sample B"]
 487  B1 --> T1["deep tree 1<br/>random features at each node"]
 488  B2 --> T2["deep tree 2"]
 489  B3 --> T3["..."]
 490  BB --> TB["deep tree B"]
 491  T1 & T2 & T3 & TB --> AVG["average their class shares<br/>(or their numbers)"]
 492  AVG --> OUT["prediction"]
 493```
 494
 495**Reading it:** the table fans out into B different resamples, each resample
 496grows its own full-depth tree, and the trees' answers are averaged at the
 497end. Nothing flows between trees, so they can be grown in parallel on
 498separate machines. Diversity enters twice: once in which rows each tree
 499sees (the bootstrap boxes) and once in which features each node may use
 500(inside the tree boxes).
 501
 502### Bagging: drawing names from a hat, and putting them back
 503
 504**Tiny example.** Put the eight email numbers in a hat. Draw one, write it
 505down, *put it back*, and repeat eight times. One draw might give 3, 1, 3, 7,
 5065, 1, 8, 2: emails 1 and 3 twice, and emails 4 and 6 not at all. Each tree
 507sees a slightly different dataset, with some rows counted double and about
 508a third missing.
 509
 510How big is "about a third"? A particular email is missed on one draw with
 511chance $1 - \frac{1}{n}$, and missed on all $n$ draws with:
 512
 513$$
 514P(\text{left out}) = \left(1 - \frac{1}{n}\right)^{n} \;\longrightarrow\; \frac{1}{e} \approx 0.368
 515$$
 516
 517**Symbols**
 518
 519| Symbol | Meaning here | In the example |
 520|---|---|---|
 521| $n$ | the number of rows, and also the number of draws | 8 |
 522| $\frac{1}{n}$ | the chance one draw picks this particular row | 1/8 |
 523| $1 - \frac{1}{n}$ | the chance one draw misses it | 7/8 |
 524| $(\ldots)^{n}$ | missing it on every one of the $n$ independent draws: multiply the chances | $(7/8)^8$ |
 525| $\longrightarrow$ | "gets closer and closer to, as $n$ grows" | |
 526| $e$ | Euler's number, ≈ 2.718 (see `primer.notation`) | $1/e = 0.368$ |
 527
 528**In words:** "the chance a row never makes it into a bootstrap sample is
 529the chance of missing it once, multiplied by itself once per draw; for big
 530datasets that settles at about 37%."
 531
 532**With the numbers:** $(7/8)^8 = 0.3436$ for the eight emails; for 100,000
 533rows, $(1 - 1/100000)^{100000} = 0.3679$, already equal to $1/e$ to four
 534places.
 535
 536**In Python:**
 537
 538```python
 539import math
 540n = 8
 541# (1 - 1/n)^n: missed on every one of the n draws
 542round((1 - 1 / n) ** n, 4)  # → 0.3436
 543round((1 - 1 / 100_000) ** 100_000, 4)  # → 0.3679
 544round(1 / math.e, 4)  # → 0.3679
 545```
 546
 547The rows a tree never saw are its **out-of-bag** rows: a free validation set
 548for that tree, which random forests use to estimate their own accuracy
 549without holding data back.
 550
 551**In code:** `bootstrap_sample` draws the n row indices; `out_of_bag_fraction` is the formula; `RandomForest` gives every tree its own sample and its own feature subsets.
 552
 553### Why averaging helps, and what limits it
 554
 555**Tiny example.** Three trees give a point spam probabilities of 0.9, 0.2
 556and 0.7. The forest says 0.6. If each tree's error is a coin flip that
 557cancels out on average, three trees already wobble less than one; a hundred
 558wobble far less. But if every tree makes the *same* mistake, averaging keeps
 559it.
 560
 561$$
 562\operatorname{Var}\!\left(\frac{1}{B}\sum_{b=1}^{B} T_b\right) = \rho\,\sigma^{2} + \frac{1-\rho}{B}\,\sigma^{2}
 563$$
 564
 565**Symbols**
 566
 567| Symbol | Meaning here | In the example |
 568|---|---|---|
 569| $B$ | the number of trees | 10 |
 570| $T_b$ | tree number $b$'s prediction for one input | 0.9, 0.2, 0.7, … |
 571| $\frac{1}{B}\sum_{b=1}^{B} T_b$ | the forest's prediction: the average of all trees | 0.6 |
 572| $\operatorname{Var}$ | **variance**: how much a prediction would swing if the training data were redrawn | |
 573| $\sigma^{2}$ | the variance of one tree on its own | 1 (a unit, for comparison) |
 574| $\rho$ | the **correlation** between two trees' errors: 0 if they err independently, 1 if they always err together (Greek letter rho) | 0.3 |
 575
 576**In words:** "the forest's wobble has two parts: a part shared by all trees,
 577which no amount of averaging removes, and a private part, which shrinks as
 578you add trees."
 579
 580**With the numbers:** with $\rho = 0.3$ and $\sigma^2 = 1$: ten trees give
 581$0.3 + 0.7/10 = 0.37$; a thousand trees give $0.3 + 0.7/1000 = 0.3007$.
 582Going from 10 to 1,000 trees barely helps; lowering $\rho$ would. With fully
 583independent trees ($\rho = 0$), ten trees would cut the variance to 0.1.
 584
 585**In Python:**
 586
 587```python
 588sigma2, rho = 1.0, 0.3
 589# ρσ² + (1 - ρ)σ²/B
 590round(rho * sigma2 + (1 - rho) * sigma2 / 10, 4)  # → 0.37
 591round(rho * sigma2 + (1 - rho) * sigma2 / 1000, 4)  # → 0.3007
 592# independent trees: the variance falls as 1/B
 593round(0.0 * sigma2 + (1 - 0.0) * sigma2 / 10, 4)  # → 0.1
 594```
 595
 596That is why a forest adds random feature subsets on top of bagging: bagging
 597alone leaves every tree free to pick the same strong feature at the top, so
 598the trees stay correlated. Forcing each node to choose from a random subset
 599pushes $\rho$ down, which is the only way to push the floor down. Each tree
 600gets a little worse; the average gets better.
 601
 602![Left, one fully grown tree carves a jagged patchwork with small islands around noisy points and scores 82% on validation; right, a forest of 100 trees draws a smoother boundary and scores 88.5%](figures/primer.ml.classical.forest_regions.svg)
 603
 604**Reading it:** both panels show the same 300 training points and the
 605predicted class at every spot. On the left, a single unlimited tree fences
 606off little islands around individual noisy points. On the right, a hundred
 607unlimited trees vote: an island appears only where most trees agree, so the
 608one-off islands mostly vanish and the boundary follows the two moons more
 609smoothly. Each tree in the forest is just as overfitted as the one on the
 610left; their average is not.
 611
 612![Validation accuracy against the number of trees on a log axis: one tree scores 85.5%, two dip to 83%, then the forest climbs to 88 to 89% by 10 to 20 trees and stays level, all above a single deep tree's 82%](figures/primer.ml.classical.forest_curve.svg)
 613
 614**Reading it:** the horizontal axis is how many trees are averaged, on a log
 615scale; the vertical axis is accuracy on the 200 validation points. The
 616dashed line is a single deep tree grown on all the data. The first points
 617are jumpy (two fully grown trees that disagree give a 50/50 tie, which is
 618why two trees dip below one), but the curve climbs over the first ten or
 619twenty trees and then flattens: the private
 620part of the variance is gone, and what remains is the shared part the
 621formula predicts. Unlike depth, adding trees does not overfit; past the
 622plateau it only costs time.
 623
 624**In code:** `averaged_variance` is the formula; `forest_curve` grows one forest and scores its first k trees for each k; `RandomForest.predict_proba` averages the trees' class shares.
 625
 626**Why it matters:** a random forest is the most forgiving model in machine
 627learning. Its defaults (a few hundred unpruned trees, square root of the
 628features per node) work well on most tables with no tuning at all, which is
 629why it is the standard first model on new tabular data.
 630
 631## Gradient boosting: each tree fixes the last one's mistakes
 632
 633**Everyday picture.** Golf. Your first shot from the tee gets you near the
 634green. Your second shot does not start over from the tee: it aims at what is
 635*left*, the gap between the ball and the hole. Each shot is small and
 636corrects what remains. **Gradient boosting** builds a model the same way:
 637start with a crude guess, then add small trees one at a time, each trained
 638only on what the model so far still gets wrong.
 639
 640Where a forest grows big trees side by side and averages them, boosting
 641grows small trees in sequence and adds them up. The forest fights variance;
 642boosting fights **bias** (being systematically off), a little at a time.
 643
 644```mermaid
 645flowchart LR
 646  F0["F₀: start with the average"] --> R["residuals:<br/>what is still wrong<br/>r = y − F"]
 647  R --> H["fit a small tree hₘ<br/>to the residuals"]
 648  H --> U["Fₘ = Fₘ₋₁ + η · hₘ<br/>take a small step"]
 649  U -->|"next round"| R
 650  U --> OUT["after M rounds:<br/>F₀ + η·(h₁ + h₂ + … + h_M)"]
 651```
 652
 653**Reading it:** the loop in the middle is the whole algorithm. Each round
 654measures what is still wrong (the residuals), trains a small tree to predict
 655*those*, and adds a shrunken copy of that tree to the model. The model is
 656never retrained from scratch; it only grows by addition. The final model is
 657the starting guess plus every tree's contribution, which is why a boosted
 658model is literally a sum of trees.
 659
 660**Tiny example.** Four houses of size 1, 2, 3 and 4 sold for 1, 2, 6 and 7
 661(hundreds of thousands, say). Boost with **stumps** (trees with a single
 662question) and learning rate $\eta = 0.5$:
 663
 664| size | price $y$ | $F_0$ | residual $y - F_0$ | stump $h_1$ | $F_1 = F_0 + 0.5\,h_1$ | new residual |
 665|---|---|---|---|---|---|---|
 666| 1 | 1 | 4 | −3 | −2.5 | 2.75 | −1.75 |
 667| 2 | 2 | 4 | −2 | −2.5 | 2.75 | −0.75 |
 668| 3 | 6 | 4 | 2 | 2.5 | 5.25 | 0.75 |
 669| 4 | 7 | 4 | 3 | 2.5 | 5.25 | 1.75 |
 670
 671$F_0 = 4$ is the average price, the best single guess. The stump's best
 672question on the residuals is "size ≤ 2.5?", and each leaf predicts its
 673average residual: −2.5 for the small houses, +2.5 for the big ones. Adding
 674half of that moves every guess toward its price. The mean squared error
 675falls from 6.5 to 1.8125 in one round, and a second round brings it to
 6760.64.
 677
 678### Residuals are the negative gradient
 679
 680Why is this called *gradient* boosting? Because "fit what is still wrong"
 681is a special case of gradient descent (`primer.ml.optimizers`), taken in the
 682space of predictions instead of weights.
 683
 684$$
 685L(y, F) = \tfrac{1}{2}\,(y - F)^{2}
 686\qquad\Longrightarrow\qquad
 687r = -\frac{\partial L}{\partial F} = y - F
 688$$
 689
 690**Symbols**
 691
 692| Symbol | Meaning here | For house 4 |
 693|---|---|---|
 694| $y$ | the true value | 7 |
 695| $F$ | the model's current prediction | $F_0 = 4$ |
 696| $L(y, F)$ | the **loss**: half the squared miss (the ½ only tidies the slope) | $\frac{1}{2}(7 - 4)^2 = 4.5$ |
 697| $\frac{\partial L}{\partial F}$ | the **derivative**: how fast the loss changes as the prediction $F$ is nudged up | $4 - 7 = -3$ |
 698| $-\frac{\partial L}{\partial F}$ | the direction that lowers the loss fastest, called the **negative gradient** | 3 |
 699| $r$ | the **residual**: what each tree is trained to predict | 3 |
 700| $\Longrightarrow$ | "which gives" | |
 701
 702**In words:** "for squared error, the direction that most quickly reduces
 703the loss at each example is simply the true value minus the prediction: the
 704residual."
 705
 706**With the numbers:** house 4 has $y = 7$ and $F = 4$. Nudge $F$ up to 4.001
 707and the loss falls from 4.5 to about 4.497, a slope of −3; the negative
 708gradient is +3, which is exactly $7 - 4$.
 709
 710**In Python:**
 711
 712```python
 713y, F, eps = 7.0, 4.0, 1e-6
 714# ∂L/∂F, measured by nudging F both ways
 715slope = (0.5 * (y - (F + eps)) ** 2 - 0.5 * (y - (F - eps)) ** 2) / (2 * eps)
 716round(slope, 6)  # → -3.0
 717# r = -∂L/∂F is the plain residual y - F
 718round(-slope, 6), y - F  # → (3.0, 3.0)
 719```
 720
 721The payoff of the gradient view: swap in any loss with a slope and the same
 722loop still works. For yes/no labels with the log loss (`primer.ml.losses`),
 723$F$ is a score in log-odds and the negative gradient is $y - \sigma(F)$, the
 724label minus the predicted probability ($\sigma$ is the **sigmoid**, which
 725squashes a score into 0 to 1). Each tree then fits "how surprised were we
 726by each example".
 727
 728**In code:** `GradientBoosting.negative_gradient` returns $y - F$ for `loss="squared"` and $y - \sigma(F)$ for `loss="log"`.
 729
 730### The update, and the learning rate
 731
 732$$
 733F_m(x) = F_{m-1}(x) + \eta\, h_m(x)
 734$$
 735
 736**Symbols**
 737
 738| Symbol | Meaning here | In the example |
 739|---|---|---|
 740| $x$ | one input (a house's size) | size 4 |
 741| $m$ | the round number, 1, 2, …, $M$ | 1 |
 742| $F_{m-1}(x)$ | the model's prediction before this round | $F_0(4) = 4$ |
 743| $h_m(x)$ | this round's small tree, trained on the residuals | $h_1(4) = 2.5$ |
 744| $\eta$ | the **learning rate** (Greek letter eta), also called **shrinkage**: the fraction of each tree's correction actually applied | 0.5 |
 745| $F_m(x)$ | the prediction after this round | 5.25 |
 746
 747**In words:** "after each round, the new prediction is the old prediction
 748plus a shrunken copy of the tree that was trained to fix it."
 749
 750**With the numbers:** big houses: $4 + 0.5 \times 2.5 = 5.25$; small houses:
 751$4 + 0.5 \times (-2.5) = 2.75$. Mean squared error before:
 752$(9 + 4 + 4 + 9)/4 = 6.5$; after: $(1.75^2 + 0.75^2 + 0.75^2 +
 7531.75^2)/4 = 1.8125$.
 754
 755**In Python:**
 756
 757```python
 758prices = [1, 2, 6, 7]
 759eta = 0.5
 760# F_0: the average price
 761F0 = sum(prices) / len(prices)
 762F0  # → 4.0
 763residuals = [y - F0 for y in prices]
 764residuals  # → [-3.0, -2.0, 2.0, 3.0]
 765# h_1: the stump predicts each side's average residual
 766small, big = (residuals[0] + residuals[1]) / 2, (residuals[2] + residuals[3]) / 2
 767h1 = [small, small, big, big]
 768h1  # → [-2.5, -2.5, 2.5, 2.5]
 769# F_1 = F_0 + η h_1
 770F1 = [F0 + eta * h for h in h1]
 771F1  # → [2.75, 2.75, 5.25, 5.25]
 772# mean squared error before and after the round
 773sum((y - F0) ** 2 for y in prices) / 4  # → 6.5
 774sum((y - f) ** 2 for y, f in zip(prices, F1)) / 4  # → 1.8125
 775```
 776
 777Why not take the whole correction ($\eta = 1$)? Each tree is fitted to the
 778training data, noise included. Taking the whole step lets the first few
 779trees commit hard to whatever they saw; taking small steps means many trees
 780must agree before the model moves far, which averages away some of the
 781noise. The price is more rounds.
 782
 783![Boosted depth-2 trees fitting noisy points around a sine curve: after 1 round two flat steps, after 5 rounds a coarse staircase, after 50 rounds a jagged staircase that tracks the sine and starts chasing individual points](figures/primer.ml.classical.boosting_steps.svg)
 784
 785**Reading it:** grey dots are 80 noisy samples of the dotted sine curve, and
 786the blue line is the boosted model's prediction everywhere between 0 and 6.
 787After 1 round the model is the average plus 0.3 of one small tree: a
 788line with just two flat steps. After 5 rounds it has the rough shape.
 789After 50 it follows the curve closely and has begun to jump for individual
 790noisy points, the first sign of the overfitting the next figure measures.
 791Every version is made of flat steps, because
 792every tree predicts a constant in each leaf; the curve is built from many
 793small staircases added together.
 794
 795![Mean squared error per boosting round for learning rates 1, 0.3 and 0.1: dashed training curves fall toward zero; solid validation curves bottom out at round 4, 9 and 31 respectively, lowest for 0.1, and then climb](figures/primer.ml.classical.boosting_curves.svg)
 796
 797**Reading it:** dashed lines are training error and solid lines are error on
 798400 fresh points, per round, for three learning rates. Every training curve
 799falls toward zero, since 200 rounds of trees can memorise 80 points. Each
 800validation curve has a best round (the dot) and then rises, because later
 801trees are fitting noise. With $\eta = 1$ the best comes at round 4 (0.122)
 802and is poor; with $\eta = 0.1$ it comes at round 31 and is the lowest
 803(0.110), close to the noise floor of 0.09 that no model can beat. Smaller
 804steps generalize better but need more rounds, and every learning rate needs
 805a stopping point chosen on validation data: early stopping, as in
 806`primer.ml.regularization`.
 807
 808**In code:** `GradientBoosting` runs the loop and records the training loss after each round; `GradientBoosting.staged_loss` scores held-out data after each round; `boosting_worked_example` is the four-house table; `boosting_curves` sweeps the learning rate on `sine_data`.
 809
 810**Why it matters:** gradient-boosted trees (through libraries such as
 811XGBoost, LightGBM and CatBoost) are the most common winning model on tabular
 812data in practice. They add refinements this lesson leaves out, such as a
 813second-order (Newton) step for each leaf's value, penalties on tree size,
 814fast histogram-based split search, and native handling of missing values,
 815but the loop is the one above. The two knobs that matter most are the ones
 816you have met: tree depth and the learning rate, with the number of rounds
 817chosen by early stopping.
 818
 819## When trees win, and when neural networks do
 820
 821**Everyday picture.** A spreadsheet and a photograph are both grids of
 822numbers, but they carry meaning differently. In a spreadsheet each column
 823means something on its own: "income is under 30,000" is a sentence a loan
 824officer would write. In a photo, "pixel (212, 40) is brighter than 0.7"
 825means nothing; the meaning is in how thousands of pixels are arranged. A
 826tree asks questions of one column at a time, which is perfect for the
 827spreadsheet and hopeless for the photo. A neural network builds its own
 828features out of combinations of inputs, which is what the photo needs.
 829
 830**Tiny example.** A toy loan table: 800 training rows and 400 validation
 831rows, five columns in their natural units: age in years, income in dollars
 832(spanning a factor of 100), a count of late payments, a region code 0 to 5
 833(where region 4 is not "twice region 2"), and one column of pure random
 834noise. The label follows rules of the kind a lender writes (four or more
 835late payments; under 30,000 and under 30 years old; two late payments in
 836certain regions or on a very low income), with 5% of labels flipped at
 837random.
 838
 839| model | what it was given | validation accuracy |
 840|---|---|---|
 841| always predict "fine" | nothing | 77.75% |
 842| single tree, depth 5 | the raw table | 92.25% |
 843| random forest, 50 trees | the raw table | 95.75% |
 844| gradient boosting, 100 depth-3 trees | the raw table | 96.00% |
 845| small neural net (32 hidden units) | the raw table | 77.75% |
 846| same net | every column rescaled to mean 0, spread 1 | 86.25% |
 847| same net | rescaled, plus log income and one column per region | 92.25% |
 848
 849![Validation accuracy on the loan table: random forest 95.75% and gradient boosting 96.0% lead; the neural net scores 77.75% on raw inputs (the same as always saying fine), 86.25% scaled, and 92.25% with engineered features](figures/primer.ml.classical.showdown.svg)
 850
 851**Reading it:** blue bars are tree models, orange bars are the same small
 852neural network given three versions of the same table, and the dashed line
 853is the score for ignoring the inputs and always predicting "fine". The
 854trees read the table exactly as it came. The net on raw inputs sits on the
 855dashed line: incomes around 50,000 swamp every other column and saturate
 856its neurons, so it learns nothing. Rescaling rescues it partly; hand-made
 857features (the logarithm of income, a yes/no column per region) rescue it
 858more. Even then it only ties a single depth-5 tree, and the ensembles stay
 859ahead.
 860
 861```mermaid
 862flowchart TD
 863  START{"what does one input look like?"} -->|"a row of a table:<br/>each column means<br/>something alone"| SIZE{"how much data?"}
 864  START -->|"pixels, audio samples,<br/>words, sequences"| NN["neural network<br/>(often a pretrained one)"]
 865  SIZE -->|"thousands to millions of rows"| GBT["gradient-boosted trees<br/>or a random forest first"]
 866  SIZE -->|"huge, or mixed with<br/>text or images"| MIX["neural network, or trees<br/>on top of neural embeddings"]
 867  GBT --> CHECK["compare against a neural net<br/>only if the gap matters"]
 868```
 869
 870**Reading it:** the first question is about the *shape* of one input, not
 871about fashion. Tables go down the left; perceptual and sequential data go
 872right. On the left, the size of the data decides: at typical business
 873scale, start with trees. The bottom-right branch is common in practice: a
 874neural network turns text or images into **embeddings**
 875(`primer.ml.embeddings.word2vec`), and those numbers become extra columns
 876for a boosted-tree model.
 877
 878Why trees fit tables so well, each point visible in this lesson's code:
 879
 880- **Units don't matter.** A tree only asks "is this bigger than that?", so
 881  any order-keeping change to a column (dollars to thousands, a logarithm,
 882  a square) leaves every prediction unchanged. A neural net multiplies and
 883  adds columns, so their scales collide.
 884- **Thresholds are native.** Rules like "four or more late payments" are
 885  one question to a tree; a net must approximate the sharp edge with smooth
 886  curves.
 887- **Irrelevant columns are mostly ignored**, because a column that never
 888  wins a split is never used.
 889- **Modest data is enough.** A few hundred rows can grow a useful tree; a
 890  net with thousands of weights needs far more to pin them down.
 891
 892Where neural networks win, and trees cannot follow:
 893
 894- **Perceptual and sequential data**: images, audio, text. The useful
 895  features are built from arrangements of raw values, which is what
 896  convolutions (`primer.ml.cnn_rnn`) and attention (`primer.ml.transformer`)
 897  learn.
 898- **Huge data and pretraining.** A net's capacity keeps paying off as data
 899  grows, and a pretrained model brings knowledge from billions of examples.
 900  A tree starts from nothing every time.
 901- **Smooth trends and extrapolation.** A tree's leaves hold averages of
 902  training values, so it can never predict outside the range it saw. The
 903  four-house model predicts the same price for a house of size 40 as for
 904  size 4.
 905- **End-to-end training** with other neural parts, since a tree has no
 906  gradient to pass along.
 907
 908In a careful benchmark on 45 tabular datasets of about 10,000 rows each
 909(Grinsztajn, Oyallon and Varoquaux, 2022), tuned tree-based models remained
 910ahead of tuned deep learning models. Their analysis names three reasons that
 911match this lesson: trees shrug off uninformative columns, they respect each
 912column's own axis, and they fit irregular, step-like functions easily.
 913Research on neural networks
 914for tables is active and the gap may narrow; the practical rule is to start
 915with boosted trees on a table and make any other model beat them on your
 916validation data.
 917
 918**In code:** `make_tabular` builds the loan table; `tabular_showdown` trains every model in the table above (the net is `primer.ml.neural_net.MLP`); `engineer_features` is the hand-made preparation the net needed.
 919
 920### Feature importance, and its limits
 921
 922**Everyday picture.** After a football season you want to know which
 923players mattered. One way: count how often each player touched the ball in
 924training. Another: bench each player for a real match and see how much the
 925score suffers. The first is quick but credits whoever is busy; the second
 926measures what the team actually depends on.
 927
 928Trees offer the first kind for free. **Impurity importance** adds up, for
 929each feature, the impurity its splits removed (weighted by how many examples
 930reached each split), measured on the training data. **Permutation
 931importance** is the second kind: take held-out data, shuffle one column so
 932it no longer lines up with the labels, and measure how much accuracy drops.
 933
 934**Tiny example.** The 30-tree forest scores 96.0% on the 400 validation
 935rows. Shuffle the late-payments column and it scores 77.5%: a drop of 18.5
 936points, so the model leans on that column heavily. Shuffle the noise column
 937and it scores 95.75%, a drop of 0.25 points (one row in 400).
 938
 939$$
 940I_j = \text{acc}(X_{\text{val}}) - \text{acc}\big(X_{\text{val}} \text{ with column } j \text{ shuffled}\big)
 941$$
 942
 943**Symbols**
 944
 945| Symbol | Meaning here | For late payments |
 946|---|---|---|
 947| $j$ | the feature (column) being tested | late_payments |
 948| $X_{\text{val}}$ | held-out rows the model never trained on | 400 rows |
 949| $\text{acc}(\ldots)$ | the share of those rows the model gets right | 0.96 |
 950| shuffled | the column's values randomly reordered: same values, links to the labels broken | 0.775 |
 951| $I_j$ | the permutation importance of feature $j$: accuracy lost without it | 0.185 |
 952
 953**In words:** "a feature's importance is how much accuracy the model loses
 954on fresh data when that feature is scrambled."
 955
 956**With the numbers:** late payments $0.96 - 0.775 = 0.185$; noise
 957$0.96 - 0.9575 = 0.0025$.
 958
 959**In Python:**
 960
 961```python
 962# validation rows answered correctly, out of 400
 963correct_of_400 = {"intact": 384, "late_payments shuffled": 310, "noise shuffled": 383}
 964acc = {k: v / 400 for k, v in correct_of_400.items()}
 965# I_j = acc(X_val) - acc(X_val with column j shuffled)
 966round(acc["intact"] - acc["late_payments shuffled"], 4)  # → 0.185
 967round(acc["intact"] - acc["noise shuffled"], 4)  # → 0.0025
 968```
 969
 970![Two bar charts over age, income, late payments, region and noise: impurity importance gives the noise column 11% of the credit; permutation importance gives it 0.25 points of accuracy, far below late payments at 18.5 points](figures/primer.ml.classical.importance.svg)
 971
 972**Reading it:** the left panel is impurity importance from training, as
 973shares that add to 100%; the right is permutation importance on validation
 974data, in accuracy lost. Both agree the real columns matter. They disagree
 975about noise: on the left it gets 11% of the credit, because deep trees can
 976always find some threshold on a continuous random column that tidies up a
 977few training rows. On the right, scrambling it costs almost nothing,
 978because none of those splits help on new data.
 979
 980The limits worth remembering:
 981
 982- **Impurity importance favours columns with many distinct values**
 983  (continuous numbers, IDs), since they offer more thresholds to overfit
 984  with. Measure on held-out data instead.
 985- **Correlated columns split the credit.** If income appears twice (in
 986  dollars and in thousands), each copy looks half as important, and
 987  shuffling one does little because the other covers for it.
 988- **Importance is not cause.** It says what *this model* relies on to
 989  predict, not what would change the outcome in the world.
 990
 991A single shallow tree is the one truly readable model here: print
 992`DecisionTree.rules` and you are reading the entire model. A forest of
 993hundreds of deep trees is not readable, and importances are a summary of it,
 994not an explanation.
 995
 996**In code:** `permutation_importance` shuffles one column at a time on held-out data; `DecisionTree.feature_importances` and `RandomForest.feature_importances` are the impurity kind.
 997
 998## In 20 seconds
 999
1000- **A decision tree** asks yes/no questions about one column at a time,
1001  choosing each question greedily to leave the purest piles (lowest
1002  weighted Gini impurity or entropy), until a stopping rule says stop.
1003- **Deep trees overfit**: a question for every example gives 100% on
1004  training data and poor results on new data. Depth is the capacity knob.
1005- **Random forests** average many deep trees, each grown on a bootstrap
1006  sample with random feature subsets. Averaging removes the variance the
1007  trees don't share; decorrelating them lowers the part they do.
1008- **Gradient boosting** adds small trees one at a time, each fit to the
1009  residuals (the negative gradient of the loss), shrunk by a learning rate,
1010  with the number of rounds set by early stopping.
1011- **On tables, trees usually win**: no scaling, native thresholds, modest
1012  data. Neural networks win on images, audio, text and huge datasets.
1013
1014## Self-test questions
1015
1016**Why is Gini impurity 0.5 for a pile that is half spam and half not?**
1017It is the chance that two emails drawn at random (with replacement) carry
1018different labels: $1 - (0.5^2 + 0.5^2) = 0.5$. For two labels that is the
1019most mixed a pile can be.
1020
1021**Why weight each child pile by its size when scoring a split?**
1022Otherwise a question that peels off one example into a tiny pure pile
1023would look as good as one that sorts half the data cleanly. Weighting by
1024size measures how much of the data the question actually tidied.
1025
1026**Why does a tree need no feature scaling, when a neural network does?**
1027A tree only compares one column against a threshold, so any change that
1028keeps the order of values (rescaling, logarithms) produces the same splits
1029and the same predictions. A network multiplies and adds columns together,
1030so a column measured in tens of thousands drowns the others and saturates
1031its neurons.
1032
1033**A tree scores 100% on training data and 80% on validation. What happened,
1034and what are two fixes?**
1035It overfitted: it kept splitting until each leaf fenced off individual
1036noisy examples. Limit its depth (or require a minimum number of examples per
1037leaf, or prune), or replace it with a random forest that averages many such
1038trees.
1039
1040**Why does a random forest use random feature subsets, not just bootstrap
1041samples?**
1042The forest's variance is $\rho\sigma^2 + (1 - \rho)\sigma^2/B$. More trees
1043only shrink the second term; the floor is set by the correlation $\rho$
1044between trees. With bagging alone every tree tends to pick the same strong
1045feature first and they stay correlated. Random subsets force different
1046trees down different paths, lowering $\rho$.
1047
1048**Does adding more trees overfit a random forest? Does adding more rounds
1049overfit gradient boosting?**
1050More trees in a forest does not: accuracy rises and then levels off, only
1051costing time. More rounds of boosting does: each round fits the training
1052residuals more closely, so validation error reaches a minimum and then
1053climbs. Boosting needs early stopping; forests don't.
1054
1055**Why is the residual the right target for each boosting tree?**
1056For squared-error loss $\frac{1}{2}(y - F)^2$, the negative derivative with
1057respect to the prediction $F$ is $y - F$, the residual. Fitting a tree to it
1058and taking a step is gradient descent on the predictions. With another
1059loss, the tree fits that loss's negative gradient instead, such as $y - p$
1060for log loss.
1061
1062**What does a smaller learning rate buy in gradient boosting, and what does
1063it cost?**
1064Each tree contributes only a fraction of its correction, so no single noisy
1065tree can move the model far and many trees must agree. That usually
1066generalizes better (0.110 against 0.122 in this lesson's sweep). It costs
1067more rounds, so more training and prediction time.
1068
1069**Why can't a boosted-tree model predict a house price above the highest
1070price it trained on?**
1071Every leaf predicts an average of training values, and the model is a sum of
1072such leaves anchored at the training mean. Beyond the edge of the training
1073data every question gives the same answer as at the edge, so the prediction
1074stays flat.
1075
1076**What is wrong with trusting impurity-based feature importance?**
1077It is measured on training data, where splits on noise still tidy up piles.
1078It favours columns with many distinct values and splits credit between
1079correlated columns. Permutation importance on held-out data is more honest,
1080and neither measures cause and effect.
1081
1082**When would you choose a neural network over gradient-boosted trees?**
1083When one input is an image, audio clip, text or other sequence whose
1084meaning lies in the arrangement of raw values; when the data is huge or a
1085pretrained model can be reused; when predictions must extrapolate smoothly;
1086or when the model must be trained end to end with other neural parts.
1087
1088## The papers behind this lesson
1089
1090- **Breiman, Friedman, Olshen and Stone, *Classification and Regression
1091  Trees* (1984)**: https://doi.org/10.1201/9781315139470. The CART book:
1092  binary trees split by Gini impurity, regression trees with averaged
1093  leaves, and cost-complexity pruning, the recipe this lesson's tree
1094  follows.
1095- **Quinlan, *Induction of Decision Trees*, Machine Learning 1 (1986)**:
1096  https://doi.org/10.1007/BF00116251. ID3, which grows trees by choosing
1097  the split with the largest information gain (drop in entropy).
1098- **Breiman, *Bagging Predictors*, Machine Learning 24 (1996)**:
1099  https://doi.org/10.1007/BF00058655. Showed that averaging models trained
1100  on bootstrap samples reduces the error of unstable learners such as
1101  trees.
1102- **Breiman, *Random Forests*, Machine Learning 45 (2001)**:
1103  https://doi.org/10.1023/A:1010933404324. Added random feature subsets at
1104  each split, out-of-bag error estimates, and permutation importance.
1105  [Annotated companion](../../papers/random-forests.html)
1106- **Friedman, *Greedy Function Approximation: A Gradient Boosting Machine*,
1107  Annals of Statistics 29 (2001)**: https://doi.org/10.1214/aos/1013203451.
1108  Framed boosting as gradient descent in function space, fitting each tree
1109  to the negative gradient of any differentiable loss, with shrinkage.
1110  [Annotated companion](../../papers/gradient-boosting-machine.html)
1111- **Chen and Guestrin, *XGBoost: A Scalable Tree Boosting System* (2016)**:
1112  https://arxiv.org/abs/1603.02754. A regularized, second-order boosting
1113  objective with fast, sparsity-aware split finding, which made boosted
1114  trees the default on tabular problems.
1115  [Annotated companion](../../papers/xgboost.html)
1116- **Grinsztajn, Oyallon and Varoquaux, *Why do tree-based models still
1117  outperform deep learning on tabular data?* (2022)**:
1118  https://arxiv.org/abs/2207.08815. A benchmark on 45 medium-sized tabular
1119  datasets where tuned tree ensembles beat tuned neural networks, and an
1120  analysis of why.
1121
1122## Further reading
1123
1124- scikit-learn user guide, *Decision Trees*: https://scikit-learn.org/stable/modules/tree.html
1125- scikit-learn user guide, *Ensembles: gradient boosting, random forests, bagging*: https://scikit-learn.org/stable/modules/ensemble.html
1126- scikit-learn user guide, *Permutation feature importance*: https://scikit-learn.org/stable/modules/permutation_importance.html
1127- XGBoost documentation, *Introduction to Boosted Trees*: https://xgboost.readthedocs.io/en/stable/tutorials/model.html
1128- Hastie, Tibshirani and Friedman, *The Elements of Statistical Learning* (free PDF from the authors; chapters 9, 10 and 15 cover trees, boosting and random forests): https://hastie.su.domains/ElemStatLearn/
1129- Strobl et al., *Bias in random forest variable importance measures* (2007): https://doi.org/10.1186/1471-2105-8-25
1130- Grinsztajn et al., *Why do tree-based models still outperform deep learning on tabular data?* (2022): https://arxiv.org/abs/2207.08815
1131"""
1132
1133from __future__ import annotations
1134
1135import functools
1136import math
1137from dataclasses import dataclass, field
1138
1139import numpy as np
1140
1141from primer._show import banner, say, table, takeaway
1142
1143# ---------------------------------------------------------------------------
1144# 1. Impurity: how mixed-up is a pile of labels?
1145# ---------------------------------------------------------------------------
1146
1147
1148def _class_shares(y: np.ndarray) -> np.ndarray:
1149    # Shares of each label present; absent labels contribute nothing to either measure.
1150    _, counts = np.unique(y, return_counts=True)
1151    return counts / counts.sum()
1152
1153
1154def gini(y: np.ndarray) -> float:
1155    """Gini impurity, 1 - Σ p_k²: the chance two labels drawn at random (with replacement) disagree."""
1156    if len(y) == 0:
1157        return 0.0
1158    p = _class_shares(y)
1159    return float(1.0 - np.sum(p**2))
1160
1161
1162def entropy(y: np.ndarray) -> float:
1163    """Entropy in bits, -Σ p_k log2 p_k: how many yes/no questions a label still costs on average."""
1164    if len(y) == 0:
1165        return 0.0
1166    p = _class_shares(y)
1167    return float(-np.sum(p * np.log2(p)))
1168
1169
1170def split_impurity(y_left: np.ndarray, y_right: np.ndarray, impurity=gini) -> float:
1171    """Impurity after a split: each side's impurity, weighted by its share of the examples."""
1172    n = len(y_left) + len(y_right)
1173    return len(y_left) / n * impurity(y_left) + len(y_right) / n * impurity(y_right)
1174
1175
1176# ---------------------------------------------------------------------------
1177# 2. Choosing a split: try every feature and every threshold
1178# ---------------------------------------------------------------------------
1179
1180
1181@dataclass(frozen=True)
1182class Split:
1183    """The best question found for a node: "is feature <= threshold?", and the impurity it leaves."""
1184
1185    feature: int
1186    threshold: float
1187    impurity: float
1188
1189
1190def _side_impurities(counts: np.ndarray, n_side: np.ndarray, criterion: str) -> np.ndarray:
1191    """Impurity of one side for every candidate threshold at once. counts: (candidates, classes)."""
1192    p = counts / n_side[:, None]
1193    if criterion == "gini":
1194        return 1.0 - np.sum(p**2, axis=1)
1195    # 0·log 0 counts as 0: an absent class adds no uncertainty.
1196    with np.errstate(divide="ignore", invalid="ignore"):
1197        return -np.sum(np.where(p > 0, p * np.log2(p), 0.0), axis=1)
1198
1199
1200def best_split(
1201    X: np.ndarray,
1202    y: np.ndarray,
1203    criterion: str = "gini",
1204    features: np.ndarray | None = None,
1205    min_samples_leaf: int = 1,
1206) -> Split | None:
1207    """The feature and threshold whose split leaves the lowest weighted impurity, or None.
1208
1209    `criterion` is "gini" or "entropy" for class labels (integers 0..K-1), or
1210    "squared" for numbers (impurity = variance, as in a regression tree).
1211    Sorting each feature once turns "try every threshold" into running totals,
1212    so a node costs O(n log n) per feature instead of O(n²).
1213    """
1214    n = len(y)
1215    if n < 2 * min_samples_leaf:
1216        return None
1217    if criterion == "squared":
1218        parent = float(np.var(y))
1219    else:
1220        parent = gini(y) if criterion == "gini" else entropy(y)
1221    if parent <= 1e-12:  # already pure: nothing left to separate
1222        return None
1223    features = np.arange(X.shape[1]) if features is None else features
1224    best: Split | None = None
1225    n_left = np.arange(1, n)  # candidate i puts the first i sorted examples on the left
1226    n_right = n - n_left
1227    for f in features:
1228        order = np.argsort(X[:, f], kind="stable")
1229        xs, ys = X[order, f], y[order]
1230        # A threshold can only sit between two different values; equal values must stay together.
1231        valid = (xs[1:] > xs[:-1]) & (n_left >= min_samples_leaf) & (n_right >= min_samples_leaf)
1232        if not valid.any():
1233            continue
1234        if criterion == "squared":
1235            # Sum of squared errors from running sums: Σy² - (Σy)²/n on each side.
1236            s, s2 = np.cumsum(ys)[:-1], np.cumsum(ys**2)[:-1]
1237            tot, tot2 = ys.sum(), (ys**2).sum()
1238            sse = (s2 - s**2 / n_left) + ((tot2 - s2) - (tot - s) ** 2 / n_right)
1239            weighted = sse / n
1240        else:
1241            onehot = np.eye(int(y.max()) + 1)[ys]  # (n, classes)
1242            left = np.cumsum(onehot, axis=0)[:-1]  # class counts left of each candidate
1243            right = onehot.sum(axis=0) - left
1244            weighted = (n_left * _side_impurities(left, n_left, criterion) + n_right * _side_impurities(right, n_right, criterion)) / n
1245        weighted = np.where(valid, weighted, np.inf)
1246        i = int(np.argmin(weighted))
1247        # Strictly lower wins, so ties go to the earliest feature: the tree is reproducible.
1248        if best is None or weighted[i] < best.impurity - 1e-12:
1249            best = Split(int(f), float((xs[i] + xs[i + 1]) / 2), float(weighted[i]))
1250    return best
1251
1252
1253def candidate_splits(X: np.ndarray, y: np.ndarray) -> list[Split]:
1254    """Every question worth asking at one node, with the Gini impurity it would leave.
1255
1256    Thresholds sit halfway between neighbouring distinct values of a
1257    feature; anywhere else in the gap gives the same split.
1258    """
1259    out = []
1260    for f in range(X.shape[1]):
1261        values = np.unique(X[:, f])
1262        for t in (values[1:] + values[:-1]) / 2:
1263            left = X[:, f] <= t
1264            out.append(Split(f, float(t), split_impurity(y[left], y[~left])))
1265    return out
1266
1267# ---------------------------------------------------------------------------
1268# 3. The tree: ask, split, repeat
1269# ---------------------------------------------------------------------------
1270
1271
1272@dataclass
1273class Node:
1274    """One box of the flowchart. A leaf has no question and carries the prediction in `value`."""
1275
1276    value: np.ndarray  # class shares (classification) or [mean] (regression)
1277    n_samples: int
1278    impurity: float
1279    feature: int | None = None
1280    threshold: float | None = None
1281    left: "Node | None" = None
1282    right: "Node | None" = None
1283
1284    @property
1285    def is_leaf(self) -> bool:
1286        return self.feature is None
1287
1288
1289@dataclass
1290class DecisionTree:
1291    """A CART-style decision tree, grown greedily from the top.
1292
1293    criterion: "gini" or "entropy" to classify integer labels, "squared" to
1294    predict numbers. max_depth None grows until every leaf is pure.
1295    max_features limits how many randomly chosen features each node may try
1296    ("sqrt" or an int); that is the random forest's extra ingredient.
1297    """
1298
1299    max_depth: int | None = None
1300    criterion: str = "gini"
1301    min_samples_leaf: int = 1
1302    max_features: int | str | None = None
1303    seed: int = 0
1304    root: Node | None = field(default=None, init=False)
1305
1306    def fit(self, X: np.ndarray, y: np.ndarray, n_classes: int | None = None) -> "DecisionTree":
1307        X = np.asarray(X, dtype=float)
1308        self.regression = self.criterion == "squared"
1309        y = np.asarray(y, dtype=float if self.regression else int)
1310        # A bootstrap sample can miss a class; the forest passes the true count so shares line up.
1311        self.n_classes = None if self.regression else (n_classes or int(y.max()) + 1)
1312        self.n_features = X.shape[1]
1313        self._importance = np.zeros(self.n_features)
1314        self._rng = np.random.default_rng(self.seed)
1315        self._n_total = len(y)
1316        self.root = self._grow(X, y, depth=0)
1317        return self
1318
1319    def _leaf_value(self, y: np.ndarray) -> np.ndarray:
1320        if self.regression:
1321            return np.array([y.mean()])
1322        return np.bincount(y, minlength=self.n_classes) / len(y)
1323
1324    def _impurity(self, y: np.ndarray) -> float:
1325        if self.regression:
1326            return float(np.var(y))
1327        return gini(y) if self.criterion == "gini" else entropy(y)
1328
1329    def _features_to_try(self) -> np.ndarray:
1330        d = self.n_features
1331        k = {None: d, "sqrt": max(1, int(math.sqrt(d)))}.get(self.max_features, self.max_features)
1332        if k >= d:
1333            return np.arange(d)
1334        # A fresh random subset at every node keeps the trees of a forest different from each other.
1335        return np.sort(self._rng.choice(d, size=k, replace=False))
1336
1337    def _grow(self, X: np.ndarray, y: np.ndarray, depth: int) -> Node:
1338        node = Node(self._leaf_value(y), len(y), self._impurity(y))
1339        if self.max_depth is not None and depth >= self.max_depth:
1340            return node
1341        split = best_split(X, y, self.criterion, self._features_to_try(), self.min_samples_leaf)
1342        if split is None:
1343            return node
1344        go_left = X[:, split.feature] <= split.threshold
1345        # Credit the feature with the impurity it removed, weighted by how many examples reached here.
1346        self._importance[split.feature] += len(y) / self._n_total * (node.impurity - split.impurity)
1347        node.feature, node.threshold = split.feature, split.threshold
1348        node.left = self._grow(X[go_left], y[go_left], depth + 1)
1349        node.right = self._grow(X[~go_left], y[~go_left], depth + 1)
1350        return node
1351
1352    def _values(self, X: np.ndarray) -> np.ndarray:
1353        """Route every row to its leaf and return the leaf values, (n, classes) or (n, 1)."""
1354        X = np.asarray(X, dtype=float)
1355        out = np.empty((len(X), len(self.root.value)))
1356
1357        def route(node: Node, rows: np.ndarray) -> None:
1358            if node.is_leaf:
1359                out[rows] = node.value
1360                return
1361            go_left = X[rows, node.feature] <= node.threshold
1362            route(node.left, rows[go_left])
1363            route(node.right, rows[~go_left])
1364
1365        route(self.root, np.arange(len(X)))
1366        return out
1367
1368    def predict_proba(self, X: np.ndarray) -> np.ndarray:
1369        """Class shares of the leaf each row lands in, (n, classes)."""
1370        return self._values(X)
1371
1372    def predict(self, X: np.ndarray) -> np.ndarray:
1373        """The leaf's mean for regression, or the leaf's majority class."""
1374        values = self._values(X)
1375        return values[:, 0] if self.regression else values.argmax(axis=1)
1376
1377    @property
1378    def depth(self) -> int:
1379        def d(node: Node) -> int:
1380            return 0 if node.is_leaf else 1 + max(d(node.left), d(node.right))
1381
1382        return d(self.root)
1383
1384    @property
1385    def n_leaves(self) -> int:
1386        def count(node: Node) -> int:
1387            return 1 if node.is_leaf else count(node.left) + count(node.right)
1388
1389        return count(self.root)
1390
1391    @property
1392    def feature_importances(self) -> np.ndarray:
1393        """Impurity removed by each feature's splits, as shares that sum to 1."""
1394        total = self._importance.sum()
1395        return self._importance / total if total > 0 else self._importance
1396
1397    def rules(self, names: list[str] | None = None, classes: list[str] | None = None) -> list[str]:
1398        """The tree as nested if/else lines: the whole model, readable by a person."""
1399        names = names or [f"x{i}" for i in range(self.n_features)]
1400        lines: list[str] = []
1401
1402        def leaf_text(node: Node) -> str:
1403            if self.regression:
1404                return f"predict {node.value[0]:.3g}"
1405            k = int(node.value.argmax())
1406            label = classes[k] if classes else str(k)
1407            noun = "example" if node.n_samples == 1 else "examples"
1408            return f"predict {label}  ({node.n_samples} {noun}, {node.value[k]:.0%} agree)"
1409
1410        def walk(node: Node, indent: str) -> None:
1411            if node.is_leaf:
1412                lines.append(indent + leaf_text(node))
1413                return
1414            lines.append(f"{indent}if {names[node.feature]} <= {node.threshold:g}:")
1415            walk(node.left, indent + "    ")
1416            lines.append(f"{indent}else:  # {names[node.feature]} > {node.threshold:g}")
1417            walk(node.right, indent + "    ")
1418
1419        walk(self.root, "")
1420        return lines
1421
1422
1423# ---------------------------------------------------------------------------
1424# 4. Worked examples and toy data
1425# ---------------------------------------------------------------------------
1426
1427SPAM_FEATURES = ["links", "known_sender"]
1428
1429
1430def spam_emails() -> tuple[np.ndarray, np.ndarray, list[str]]:
1431    """The eight emails of the worked example: (links, known sender?) and 1 = spam.
1432
1433    | email | links | known sender | spam? |
1434    |---|---|---|---|
1435    | 1 | 0 | yes | no |
1436    | 2 | 1 | yes | no |
1437    | 3 | 4 | yes | no |
1438    | 4 | 0 | no  | no |
1439    | 5 | 3 | no  | yes |
1440    | 6 | 5 | no  | yes |
1441    | 7 | 6 | no  | yes |
1442    | 8 | 1 | no  | yes |
1443    """
1444    X = np.array([[0, 1], [1, 1], [4, 1], [0, 0], [3, 0], [5, 0], [6, 0], [1, 0]], dtype=float)
1445    y = np.array([0, 0, 0, 0, 1, 1, 1, 1])
1446    return X, y, list(SPAM_FEATURES)
1447
1448
1449def moons_split(n: int = 500, noise: float = 0.35, n_train: int = 300, seed: int = 1):
1450    """Noisy two-moons points (`primer.ml.neural_net.make_moons`), cut into training and validation sets."""
1451    from primer.ml.neural_net import make_moons
1452
1453    X, y = make_moons(n=n, noise=noise, seed=seed)
1454    # make_moons lists one moon, then the other; shuffle so both sets hold both classes.
1455    order = np.random.default_rng(seed).permutation(n)
1456    X, y = X[order], y[order].astype(int)
1457    return X[:n_train], y[:n_train], X[n_train:], y[n_train:]
1458
1459
1460def _accuracy(model, X: np.ndarray, y: np.ndarray) -> float:
1461    return float(np.mean(model.predict(X) == y))
1462
1463
1464def depth_sweep(depths=(1, 2, 3, 4, 5, 6, 8, 10, 12, None)) -> list[dict]:
1465    """Train and validation accuracy of one tree per depth limit on the noisy moons."""
1466    X, y, X_val, y_val = moons_split()
1467    rows = []
1468    for depth in depths:
1469        tree = DecisionTree(max_depth=depth).fit(X, y)
1470        rows.append(dict(depth=depth, train_accuracy=_accuracy(tree, X, y), val_accuracy=_accuracy(tree, X_val, y_val), leaves=tree.n_leaves))
1471    return rows
1472
1473
1474# ---------------------------------------------------------------------------
1475# 5. Random forests: bagging plus random feature subsets
1476# ---------------------------------------------------------------------------
1477
1478
1479def bootstrap_sample(n: int, rng: np.random.Generator) -> np.ndarray:
1480    """n row indices drawn with replacement: some rows twice or more, about a third not at all."""
1481    return rng.integers(0, n, size=n)
1482
1483
1484def out_of_bag_fraction(n: int) -> float:
1485    """(1 - 1/n)^n: the chance a given row is never drawn in n draws. Tends to 1/e ≈ 0.368."""
1486    return (1 - 1 / n) ** n
1487
1488
1489def averaged_variance(sigma2: float, rho: float, n_trees: int) -> float:
1490    """Variance of the average of n_trees predictions, each with variance sigma2 and pairwise correlation rho.
1491
1492    ρσ² + (1 - ρ)σ²/B: more trees shrink only the second term, so the
1493    correlation between trees sets the floor. Random feature subsets exist
1494    to push ρ down.
1495    """
1496    return rho * sigma2 + (1 - rho) * sigma2 / n_trees
1497
1498
1499@dataclass
1500class RandomForest:
1501    """Many deep trees, each grown on its own bootstrap sample and trying only
1502    a random subset of features at every node; their class shares are averaged."""
1503
1504    n_trees: int = 100
1505    max_depth: int | None = None
1506    max_features: int | str | None = "sqrt"
1507    seed: int = 0
1508    trees: list[DecisionTree] = field(default_factory=list, init=False)
1509
1510    def fit(self, X: np.ndarray, y: np.ndarray) -> "RandomForest":
1511        X, y = np.asarray(X, dtype=float), np.asarray(y, dtype=int)
1512        rng = np.random.default_rng(self.seed)
1513        n_classes = int(y.max()) + 1
1514        self.trees = []
1515        for _ in range(self.n_trees):
1516            rows = bootstrap_sample(len(y), rng)
1517            # Each tree gets its own seed, so its feature subsets differ from its neighbours'.
1518            tree = DecisionTree(max_depth=self.max_depth, max_features=self.max_features, seed=int(rng.integers(2**31)))
1519            self.trees.append(tree.fit(X[rows], y[rows], n_classes=n_classes))
1520        return self
1521
1522    def predict_proba(self, X: np.ndarray) -> np.ndarray:
1523        return np.mean([t.predict_proba(X) for t in self.trees], axis=0)
1524
1525    def predict(self, X: np.ndarray) -> np.ndarray:
1526        return self.predict_proba(X).argmax(axis=1)
1527
1528    @property
1529    def feature_importances(self) -> np.ndarray:
1530        """Each tree's impurity importance, averaged over the forest."""
1531        return np.mean([t.feature_importances for t in self.trees], axis=0)
1532
1533
1534def forest_curve(n_trees=(1, 2, 3, 5, 10, 20, 50, 100), seed: int = 0) -> list[dict]:
1535    """Validation accuracy of a forest on the noisy moons as trees are added.
1536
1537    One forest of max(n_trees) is grown; the first k trees' average gives the
1538    k-tree forest, exactly as if it had been grown alone.
1539    """
1540    X, y, X_val, y_val = moons_split()
1541    forest = RandomForest(n_trees=max(n_trees), seed=seed).fit(X, y)
1542    probas = np.cumsum([t.predict_proba(X_val) for t in forest.trees], axis=0)
1543    single = DecisionTree().fit(X, y)
1544    return [
1545        dict(n_trees=k, val_accuracy=float(np.mean(probas[k - 1].argmax(axis=1) == y_val)), single_tree=_accuracy(single, X_val, y_val))
1546        for k in n_trees
1547    ]
1548
1549
1550# ---------------------------------------------------------------------------
1551# 6. Gradient boosting: each small tree fixes what the others still get wrong
1552# ---------------------------------------------------------------------------
1553
1554
1555def _sigmoid(z: np.ndarray) -> np.ndarray:
1556    return 1 / (1 + np.exp(-z))
1557
1558
1559@dataclass
1560class GradientBoosting:
1561    """F_m = F_{m-1} + η·h_m, where each h_m is a small regression tree fit to
1562    the negative gradient of the loss (the residuals, for squared loss).
1563
1564    loss="squared" predicts numbers; loss="log" classifies 0/1 labels, with F
1565    as log-odds. `train_loss` holds the training loss before any tree and
1566    after each round: mean squared error, or cross-entropy for "log".
1567    """
1568
1569    n_rounds: int = 100
1570    learning_rate: float = 0.1
1571    max_depth: int = 2
1572    loss: str = "squared"
1573    trees: list[DecisionTree] = field(default_factory=list, init=False)
1574    train_loss: list[float] = field(default_factory=list, init=False)
1575
1576    @staticmethod
1577    def negative_gradient(y: np.ndarray, F: np.ndarray, loss: str) -> np.ndarray:
1578        """-∂L/∂F for each example: the direction that most quickly reduces its loss.
1579
1580        Squared loss ½(y - F)² gives y - F, the plain residual. Log loss
1581        gives y - sigmoid(F): the label minus the predicted probability.
1582        """
1583        return y - F if loss == "squared" else y - _sigmoid(F)
1584
1585    def _loss(self, y: np.ndarray, F: np.ndarray) -> float:
1586        if self.loss == "squared":
1587            return float(np.mean((y - F) ** 2))
1588        p = np.clip(_sigmoid(F), 1e-12, 1 - 1e-12)
1589        return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
1590
1591    def fit(self, X: np.ndarray, y: np.ndarray) -> "GradientBoosting":
1592        X, y = np.asarray(X, dtype=float), np.asarray(y, dtype=float)
1593        # F_0: the best single constant. The mean for squared loss; the log-odds of the base rate for log loss.
1594        mean = y.mean()
1595        self.f0 = mean if self.loss == "squared" else math.log(mean / (1 - mean))
1596        F = np.full(len(y), self.f0)
1597        self.trees, self.train_loss = [], [self._loss(y, F)]
1598        for _ in range(self.n_rounds):
1599            residual = self.negative_gradient(y, F, self.loss)
1600            tree = DecisionTree(max_depth=self.max_depth, criterion="squared").fit(X, residual)
1601            # Take only a fraction η of the step: many small corrections generalize better than a few big ones.
1602            F = F + self.learning_rate * tree.predict(X)
1603            self.trees.append(tree)
1604            self.train_loss.append(self._loss(y, F))
1605        return self
1606
1607    def staged_scores(self, X: np.ndarray) -> np.ndarray:
1608        """F_0, F_1, ..., F_M for every row: (rounds + 1, n)."""
1609        steps = [np.full(len(X), self.f0)] + [self.learning_rate * t.predict(X) for t in self.trees]
1610        return np.cumsum(steps, axis=0)
1611
1612    def staged_loss(self, X: np.ndarray, y: np.ndarray) -> list[float]:
1613        """The loss on (X, y) before any tree and after each round: the validation curve."""
1614        return [self._loss(np.asarray(y, dtype=float), F) for F in self.staged_scores(X)]
1615
1616    def predict(self, X: np.ndarray) -> np.ndarray:
1617        F = self.staged_scores(X)[-1]
1618        return F if self.loss == "squared" else (F > 0).astype(int)
1619
1620
1621def boosting_worked_example(learning_rate: float = 0.5) -> dict:
1622    """Four houses (size 1..4, price 1, 2, 6, 7) and one round of boosting with a one-question tree."""
1623    X, y = np.array([[1.0], [2.0], [3.0], [4.0]]), np.array([1.0, 2.0, 6.0, 7.0])
1624    model = GradientBoosting(n_rounds=1, learning_rate=learning_rate, max_depth=1).fit(X, y)
1625    F0 = np.full(4, model.f0)
1626    residuals = GradientBoosting.negative_gradient(y, F0, "squared")
1627    return dict(
1628        F0=model.f0,
1629        residuals=residuals.tolist(),
1630        stump=model.trees[0].predict(X).tolist(),
1631        F1=model.predict(X).tolist(),
1632        mse=model.train_loss,
1633    )
1634
1635
1636def sine_data(n: int, noise: float = 0.3, seed: int = 0) -> tuple[np.ndarray, np.ndarray]:
1637    """n points of y = sin(x) + noise for x in [0, 6]: a curve for regression trees to approximate."""
1638    rng = np.random.default_rng(seed)
1639    x = rng.uniform(0, 6, n)
1640    return x[:, None], np.sin(x) + rng.normal(0, noise, n)
1641
1642
1643def boosting_curves(learning_rates=(1.0, 0.3, 0.1), n_rounds: int = 200, max_depth: int = 2) -> list[dict]:
1644    """Training and validation mean squared error per round on 80 noisy sine points, for each learning rate."""
1645    X, y = sine_data(80, seed=0)
1646    X_val, y_val = sine_data(400, seed=1)
1647    rows = []
1648    for lr in learning_rates:
1649        model = GradientBoosting(n_rounds=n_rounds, learning_rate=lr, max_depth=max_depth).fit(X, y)
1650        val = model.staged_loss(X_val, y_val)
1651        rows.append(dict(learning_rate=lr, train_loss=model.train_loss, val_loss=val, best_round=int(np.argmin(val))))
1652    return rows
1653
1654
1655# ---------------------------------------------------------------------------
1656# 7. When trees win: a tabular showdown
1657# ---------------------------------------------------------------------------
1658
1659TABULAR_FEATURES = ["age", "income", "late_payments", "region", "noise"]
1660
1661
1662def make_tabular(n: int = 1200, n_train: int = 800, seed: int = 0):
1663    """A toy loan table with the mix real spreadsheets have.
1664
1665    age in years, income in dollars (spanning 100×), a count of late
1666    payments, a region code 0..5 whose numbers mean nothing in order, and a
1667    column of pure noise. The label (1 = the loan went bad) follows
1668    threshold rules of the kind a lender writes, with 5% of labels flipped.
1669    Returns X_train, y_train, X_val, y_val, feature names.
1670    """
1671    rng = np.random.default_rng(seed)
1672    age = rng.uniform(18, 80, n)
1673    income = np.exp(rng.normal(np.log(50_000), 0.7, n))
1674    late = rng.poisson(1.2, n)
1675    region = rng.integers(0, 6, n)
1676    noise = rng.normal(0, 1, n)
1677    bad = (late >= 4) | ((income < 30_000) & (age < 30)) | (np.isin(region, [1, 4]) & (late >= 2)) | ((income < 20_000) & (late >= 2))
1678    flip = rng.random(n) < 0.05
1679    y = (bad ^ flip).astype(int)
1680    X = np.c_[age, income, late, region, noise]
1681    return X[:n_train], y[:n_train], X[n_train:], y[n_train:], list(TABULAR_FEATURES)
1682
1683
1684def permutation_importance(model, X: np.ndarray, y: np.ndarray, seed: int = 0) -> np.ndarray:
1685    """Accuracy lost on held-out data when one column is shuffled, per feature.
1686
1687    Shuffling breaks the link between that column and the label while
1688    keeping its values, so the drop measures how much the model relies on it
1689    for data it did not train on.
1690    """
1691    rng = np.random.default_rng(seed)
1692    base = _accuracy(model, X, y)
1693    drops = []
1694    for f in range(X.shape[1]):
1695        shuffled = X.copy()
1696        shuffled[:, f] = rng.permutation(shuffled[:, f])
1697        drops.append(base - _accuracy(model, shuffled, y))
1698    return np.array(drops)
1699
1700
1701def engineer_features(X: np.ndarray) -> np.ndarray:
1702    """What a neural network needs done by hand before it can read the loan table.
1703
1704    Income becomes its logarithm, so a raise from 20k to 40k counts as much
1705    as one from 100k to 200k. The region code becomes six yes/no columns,
1706    because region 4 is not "twice region 2". Columns: age, log income, late
1707    payments, region_0..region_5, noise.
1708    """
1709    region = np.eye(6)[X[:, 3].astype(int)]
1710    return np.c_[X[:, 0], np.log(X[:, 1]), X[:, 2], region, X[:, 4]]
1711
1712
1713def _standardize(X: np.ndarray, X_ref: np.ndarray) -> np.ndarray:
1714    # Scale with the training set's statistics only: using validation data here would leak it.
1715    return (X - X_ref.mean(axis=0)) / X_ref.std(axis=0)
1716
1717
1718@functools.lru_cache(maxsize=4)
1719def _showdown(seed: int) -> tuple[tuple[str, float], ...]:
1720    from primer.ml.neural_net import MLP, train
1721
1722    X, y, X_val, y_val, _ = make_tabular(seed=seed)
1723    scores = {
1724        "single tree (depth 5)": _accuracy(DecisionTree(max_depth=5).fit(X, y), X_val, y_val),
1725        "random forest": _accuracy(RandomForest(n_trees=50, seed=seed).fit(X, y), X_val, y_val),
1726        "gradient boosting": _accuracy(GradientBoosting(n_rounds=100, learning_rate=0.3, max_depth=3, loss="log").fit(X, y), X_val, y_val),
1727    }
1728    E, E_val = engineer_features(X), engineer_features(X_val)
1729    inputs = {
1730        "neural net (raw inputs)": (X, X_val),
1731        "neural net (scaled inputs)": (_standardize(X, X), _standardize(X_val, X)),
1732        "neural net (scaled + engineered)": (_standardize(E, E), _standardize(E_val, E)),
1733    }
1734    for label, (A, A_val) in inputs.items():
1735        net = MLP(n_in=A.shape[1], n_hidden=32, seed=seed)
1736        with np.errstate(over="ignore"):  # raw incomes of 50,000 overflow exp inside the sigmoid; that is the point
1737            train(net, A, y.astype(float), epochs=150, batch_size=32, lr=0.1, seed=seed)
1738            scores[label] = net.accuracy(A_val, y_val.astype(float))
1739    return tuple(scores.items())
1740
1741
1742def tabular_showdown(seed: int = 0) -> dict[str, float]:
1743    """Validation accuracy on `make_tabular` for a tree, two tree ensembles and a small MLP
1744    (`primer.ml.neural_net.MLP`) fed raw, scaled, and scaled plus engineered inputs.
1745
1746    Trees get the table exactly as it is. Every model is trained once per
1747    seed and remembered, because several callers ask for the same scores.
1748    """
1749    return dict(_showdown(seed))
1750
1751
1752
1753# ---------------------------------------------------------------------------
1754# 8. Figures (rendered into the HTML docs by `make figures`)
1755# ---------------------------------------------------------------------------
1756
1757
1758def figures() -> dict:
1759    """Plot this lesson's data. matplotlib is imported here, and only here,
1760    so the lesson itself needs nothing beyond NumPy."""
1761    import matplotlib
1762
1763    matplotlib.use("Agg")
1764    import matplotlib.pyplot as plt
1765    from matplotlib.colors import ListedColormap
1766
1767    BLUE, ORANGE, MUTED, DARK = "#2563eb", "#ea580c", "#9ca3af", "#4b5563"
1768    REGIONS = ListedColormap(["#dbeafe", "#ffedd5"])
1769    figs = {}
1770    X, y, X_val, y_val = moons_split()
1771    xx, yy = np.meshgrid(np.linspace(-1.8, 2.8, 220), np.linspace(-1.5, 1.9, 170))
1772    grid = np.c_[xx.ravel(), yy.ravel()]
1773
1774    def regions(ax, model, title):
1775        ax.contourf(xx, yy, model.predict(grid).reshape(xx.shape), levels=[-0.5, 0.5, 1.5], cmap=REGIONS)
1776        ax.scatter(*X[y == 0].T, s=9, color=BLUE, label="class 0")
1777        ax.scatter(*X[y == 1].T, s=9, color=ORANGE, label="class 1")
1778        ax.set_title(title)
1779        ax.set_xticks([])
1780        ax.set_yticks([])
1781        ax.grid(False)
1782
1783    # --- 1. Every candidate question on the eight emails -------------------
1784    Xs, ys, names = spam_emails()
1785    cands = candidate_splits(Xs, ys)
1786    labels = [f"{names[c.feature]} > {c.threshold:g}" for c in cands]
1787    scores = [c.impurity for c in cands]
1788    fig, ax = plt.subplots(figsize=(6, 3.2))
1789    colors = [BLUE if s == min(scores) else MUTED for s in scores]
1790    ax.barh(labels, scores, color=colors)
1791    ax.axvline(gini(ys), color=DARK, ls="--")
1792    ax.text(gini(ys) + 0.01, 0.5, "before any\nsplit: 0.5", va="center", color=DARK)
1793    for i, s in enumerate(scores):
1794        ax.text(s + 0.008, i, f"{s:.3f}", va="center", zorder=3, bbox=dict(facecolor="white", edgecolor="none", pad=1))
1795    ax.invert_yaxis()
1796    ax.set_xlim(0, 0.65)
1797    ax.set_xlabel("weighted Gini impurity after the split (lower is better)")
1798    ax.set_title("Six questions the tree could ask first")
1799    figs["spam_splits"] = fig
1800
1801    # --- 2. Axis-aligned decision regions as depth grows --------------------
1802    fig, axes = plt.subplots(1, 3, figsize=(10, 3.3))
1803    for ax, depth in zip(axes, (1, 3, None)):
1804        tree = DecisionTree(max_depth=depth).fit(X, y)
1805        regions(ax, tree, f"depth {depth or 'unlimited'}: {tree.n_leaves} leaves")
1806    axes[0].legend(frameon=False, loc="lower left", fontsize=8)
1807    fig.tight_layout()
1808    figs["regions"] = fig
1809
1810    # --- 3. Depth vs train/validation accuracy ------------------------------
1811    rows = depth_sweep()
1812    xs = [r["depth"] or 16 for r in rows]
1813    fig, ax = plt.subplots(figsize=(6, 3.4))
1814    ax.plot(xs, [r["train_accuracy"] for r in rows], "o-", color=BLUE, label="training")
1815    ax.plot(xs, [r["val_accuracy"] for r in rows], "o-", color=ORANGE, label="validation")
1816    ax.set_xticks(xs, [str(r["depth"] or "none") for r in rows])
1817    ax.set_xlabel("depth limit")
1818    ax.set_ylabel("accuracy")
1819    ax.set_ylim(0.75, 1.01)
1820    ax.set_title("Deeper trees memorise: training climbs, validation falls")
1821    ax.legend(frameon=False)
1822    figs["depth_sweep"] = fig
1823
1824    # --- 4. One deep tree vs a forest ---------------------------------------
1825    fig, axes = plt.subplots(1, 2, figsize=(8, 3.4))
1826    single = DecisionTree().fit(X, y)
1827    forest = RandomForest(n_trees=100).fit(X, y)
1828    regions(axes[0], single, f"one deep tree: {_accuracy(single, X_val, y_val):.1%} on validation")
1829    regions(axes[1], forest, f"forest of 100: {_accuracy(forest, X_val, y_val):.1%} on validation")
1830    fig.tight_layout()
1831    figs["forest_regions"] = fig
1832
1833    # --- 5. Validation accuracy vs number of trees --------------------------
1834    curve = forest_curve(n_trees=(1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 75, 100))
1835    fig, ax = plt.subplots(figsize=(6, 3.4))
1836    ax.plot([r["n_trees"] for r in curve], [r["val_accuracy"] for r in curve], "o-", color=BLUE, label="random forest")
1837    ax.axhline(curve[0]["single_tree"], color=ORANGE, ls="--", label="one deep tree, all the data")
1838    ax.set_xscale("log")
1839    ax.set_xlabel("number of trees (log scale)")
1840    ax.set_ylabel("validation accuracy")
1841    ax.set_title("Adding trees helps, then levels off")
1842    ax.legend(frameon=False, loc="lower right")
1843    figs["forest_curve"] = fig
1844
1845    # --- 6. Boosting builds a curve from steps ------------------------------
1846    Xr, yr = sine_data(80, seed=0)
1847    line = np.linspace(0, 6, 400)[:, None]
1848    model = GradientBoosting(n_rounds=50, learning_rate=0.3, max_depth=2).fit(Xr, yr)
1849    staged = model.staged_scores(line)
1850    fig, axes = plt.subplots(1, 3, figsize=(10, 3.2), sharey=True)
1851    for ax, m in zip(axes, (1, 5, 50)):
1852        ax.scatter(Xr[:, 0], yr, s=8, color=MUTED)
1853        ax.plot(line[:, 0], np.sin(line[:, 0]), color=DARK, lw=1, ls=":", label="true curve sin(x)")
1854        ax.plot(line[:, 0], staged[m], color=BLUE, lw=2, label="boosted trees")
1855        ax.set_title(f"after {m} round{'s' if m > 1 else ''}")
1856        ax.set_xlabel("x")
1857    axes[0].set_ylabel("y")
1858    axes[0].legend(frameon=False, loc="lower left", fontsize=8)
1859    fig.tight_layout()
1860    figs["boosting_steps"] = fig
1861
1862    # --- 7. Training and validation loss per round, by learning rate --------
1863    fig, ax = plt.subplots(figsize=(6.4, 3.6))
1864    for row, color in zip(boosting_curves(), (ORANGE, "#7c3aed", BLUE)):
1865        lr = row["learning_rate"]
1866        ax.plot(row["train_loss"], color=color, lw=1, ls="--")
1867        ax.plot(row["val_loss"], color=color, lw=2, label=f"η = {lr:g} (best validation at round {row['best_round']})")
1868        ax.plot(row["best_round"], min(row["val_loss"]), "o", color=color)
1869    ax.axhline(0.09, color=MUTED, lw=1)
1870    ax.text(200, 0.095, "noise floor 0.3² = 0.09", ha="right", va="bottom", color=DARK, fontsize=8)
1871    ax.set_ylim(0, 0.7)
1872    ax.set_xlabel("boosting round")
1873    ax.set_ylabel("mean squared error")
1874    ax.set_title("Solid: validation. Dashed: training.")
1875    ax.legend(frameon=False)
1876    figs["boosting_curves"] = fig
1877
1878    # --- 8. The tabular showdown --------------------------------------------
1879    scores = tabular_showdown()
1880    _, _, _, y_tab, _ = make_tabular()
1881    fig, ax = plt.subplots(figsize=(6.4, 3.4))
1882    names_ = list(scores)
1883    colors = [BLUE if not k.startswith("neural") else ORANGE for k in names_]
1884    ax.barh(names_, [scores[k] for k in names_], color=colors)
1885    majority = max(y_tab.mean(), 1 - y_tab.mean())
1886    ax.axvline(majority, color=DARK, ls="--")
1887    ax.text(majority + 0.004, -0.75, f"always say 'fine': {majority:.2%}", va="bottom", color=DARK, fontsize=8)
1888    for i, k in enumerate(names_):
1889        ax.text(scores[k] + 0.004, i, f"{scores[k]:.2%}", va="center")
1890    ax.set_ylim(len(names_) - 0.5, -1.1)
1891    ax.set_xlim(0.7, 1.0)
1892    ax.set_xlabel("validation accuracy on the loan table")
1893    ax.set_title("Trees read the table as it is; the net needs help")
1894    figs["showdown"] = fig
1895
1896    # --- 9. Impurity importance vs permutation importance -------------------
1897    Xt, yt, Xtv, ytv, tab_names = make_tabular()
1898    forest = RandomForest(n_trees=30).fit(Xt, yt)
1899    imp, perm = forest.feature_importances, permutation_importance(forest, Xtv, ytv)
1900    fig, (a1, a2) = plt.subplots(1, 2, figsize=(9, 3.2), sharey=True)
1901    a1.barh(tab_names, imp, color=BLUE)
1902    a2.barh(tab_names, perm, color=ORANGE)
1903    for a, vals, fmt in ((a1, imp, "{:.0%}"), (a2, perm, "{:.2%}")):
1904        for i, v in enumerate(vals):
1905            a.text(v + 0.003, i, fmt.format(v), va="center")
1906    a1.invert_yaxis()
1907    a1.set_title("Impurity importance (training data)")
1908    a1.set_xlabel("share of impurity removed")
1909    a2.set_title("Permutation importance (validation data)")
1910    a2.set_xlabel("accuracy lost when the column is shuffled")
1911    fig.tight_layout()
1912    figs["importance"] = fig
1913
1914    return figs
1915
1916
1917# ---------------------------------------------------------------------------
1918# 9. Narrated walkthrough
1919# ---------------------------------------------------------------------------
1920
1921
1922def demo() -> None:
1923    banner("1. Sorting eight emails: which question first?")
1924    X, y, names = spam_emails()
1925    say(
1926        f"""
1927        Eight emails, four spam and four not. Before any question the pile has
1928        Gini impurity {gini(y):.2f}: two labels drawn at random disagree half
1929        the time. The tree tries every question it could ask:
1930        """
1931    )
1932    table(["question", "weighted Gini after"], [(f"{names[c.feature]} > {c.threshold:g}", c.impurity) for c in candidate_splits(X, y)], floatfmt=".3f")
1933    takeaway("'Known sender?' leaves the least mess (0.2), so it becomes the first question.")
1934
1935    banner("2. The grown tree, as rules a person can read")
1936    tree = DecisionTree(max_depth=2).fit(X, y)
1937    print("\n".join(tree.rules(names, ["ham", "spam"])))
1938    print()
1939    say("Two questions sort all eight emails. The whole model is those five lines.")
1940
1941    banner("3. Overfitting: depth against training and validation accuracy")
1942    table(
1943        ["depth limit", "leaves", "train acc", "val acc"],
1944        [(r["depth"] or "none", r["leaves"], r["train_accuracy"], r["val_accuracy"]) for r in depth_sweep()],
1945        floatfmt=".3f",
1946    )
1947    takeaway("With no limit the tree scores 100% on training data and worst on validation: it memorised the noise.")
1948
1949    banner("4. Random forests: bootstrap samples, random features, one vote")
1950    say(
1951        f"""
1952        A bootstrap sample of n rows leaves each row out with chance
1953        (1 - 1/n)^n: {out_of_bag_fraction(8):.4f} for n = 8, approaching 1/e =
1954        {1 / math.e:.4f}. Averaging B trees with correlation 0.3 leaves variance
1955        {averaged_variance(1.0, 0.3, 10):.2f} at B = 10 and
1956        {averaged_variance(1.0, 0.3, 1000):.4f} at B = 1000: the correlation is the floor.
1957        """
1958    )
1959    table(["trees", "val acc", "one deep tree"], [(r["n_trees"], r["val_accuracy"], r["single_tree"]) for r in forest_curve()], floatfmt=".3f")
1960    takeaway("Many deep, decorrelated trees averaged together keep their low bias and lose much of their variance.")
1961
1962    banner("5. Gradient boosting by hand: four houses, one round")
1963    ex = boosting_worked_example()
1964    table(
1965        ["size", "price y", "F0", "residual", "stump h1", "F1 = F0 + 0.5·h1"],
1966        [(s, p, ex["F0"], r, h, f) for s, p, r, h, f in zip((1, 2, 3, 4), (1, 2, 6, 7), ex["residuals"], ex["stump"], ex["F1"])],
1967        floatfmt=".2f",
1968    )
1969    say(f"Mean squared error falls from {ex['mse'][0]} to {ex['mse'][1]} in one round.")
1970    for row in boosting_curves():
1971        say(
1972            f"""
1973            Learning rate {row['learning_rate']:g}: best validation MSE
1974            {min(row['val_loss']):.3f} at round {row['best_round']}; by round 200 it
1975            is {row['val_loss'][-1]:.3f} while training MSE is {row['train_loss'][-1]:.3f}.
1976            """
1977        )
1978    takeaway("Each tree fits what is still wrong. Smaller steps take longer and generalize better; too many rounds memorise.")
1979
1980    banner("6. When trees win: a loan table")
1981    scores = tabular_showdown()
1982    table(["model", "val accuracy"], list(scores.items()), floatfmt=".4f")
1983    say(
1984        """
1985        The trees took the table as it came: dollars, years, counts and region
1986        codes. The neural net learned nothing from raw inputs (it matches
1987        always predicting 'fine'), improved once scaled, and improved again
1988        once income was logged and regions one-hot encoded, and still trailed.
1989        """
1990    )
1991    Xt, yt, Xv, yv, tab_names = make_tabular()
1992    forest = RandomForest(n_trees=30).fit(Xt, yt)
1993    table(
1994        ["feature", "impurity importance", "permutation importance"],
1995        list(zip(tab_names, forest.feature_importances, permutation_importance(forest, Xv, yv))),
1996        floatfmt=".3f",
1997    )
1998    takeaway(
1999        "Impurity importance gives the pure-noise column real credit; shuffling it on held-out data shows the model "
2000        "barely relies on it. Check importances on data the model has not seen."
2001    )
2002
2003
2004if __name__ == "__main__":
2005    demo()
Level 3: the code, function by function.
def gini(y: numpy.ndarray) -> float: on GitHub
1155def gini(y: np.ndarray) -> float:
1156    """Gini impurity, 1 - Σ p_k²: the chance two labels drawn at random (with replacement) disagree."""
1157    if len(y) == 0:
1158        return 0.0
1159    p = _class_shares(y)
1160    return float(1.0 - np.sum(p**2))

Gini impurity, 1 - Σ p_k²: the chance two labels drawn at random (with replacement) disagree.

def entropy(y: numpy.ndarray) -> float: on GitHub
1163def entropy(y: np.ndarray) -> float:
1164    """Entropy in bits, -Σ p_k log2 p_k: how many yes/no questions a label still costs on average."""
1165    if len(y) == 0:
1166        return 0.0
1167    p = _class_shares(y)
1168    return float(-np.sum(p * np.log2(p)))

Entropy in bits, -Σ p_k log2 p_k: how many yes/no questions a label still costs on average.

def split_impurity( y_left: numpy.ndarray, y_right: numpy.ndarray, impurity=<function gini>) -> float: on GitHub
1171def split_impurity(y_left: np.ndarray, y_right: np.ndarray, impurity=gini) -> float:
1172    """Impurity after a split: each side's impurity, weighted by its share of the examples."""
1173    n = len(y_left) + len(y_right)
1174    return len(y_left) / n * impurity(y_left) + len(y_right) / n * impurity(y_right)

Impurity after a split: each side's impurity, weighted by its share of the examples.

@dataclass(frozen=True)
class Split: on GitHub
1182@dataclass(frozen=True)
1183class Split:
1184    """The best question found for a node: "is feature <= threshold?", and the impurity it leaves."""
1185
1186    feature: int
1187    threshold: float
1188    impurity: float

The best question found for a node: "is feature <= threshold?", and the impurity it leaves.

Split(feature: int, threshold: float, impurity: float)
feature: int
threshold: float
impurity: float
def best_split( X: numpy.ndarray, y: numpy.ndarray, criterion: str = 'gini', features: numpy.ndarray | None = None, min_samples_leaf: int = 1) -> Split | None: on GitHub
1201def best_split(
1202    X: np.ndarray,
1203    y: np.ndarray,
1204    criterion: str = "gini",
1205    features: np.ndarray | None = None,
1206    min_samples_leaf: int = 1,
1207) -> Split | None:
1208    """The feature and threshold whose split leaves the lowest weighted impurity, or None.
1209
1210    `criterion` is "gini" or "entropy" for class labels (integers 0..K-1), or
1211    "squared" for numbers (impurity = variance, as in a regression tree).
1212    Sorting each feature once turns "try every threshold" into running totals,
1213    so a node costs O(n log n) per feature instead of O(n²).
1214    """
1215    n = len(y)
1216    if n < 2 * min_samples_leaf:
1217        return None
1218    if criterion == "squared":
1219        parent = float(np.var(y))
1220    else:
1221        parent = gini(y) if criterion == "gini" else entropy(y)
1222    if parent <= 1e-12:  # already pure: nothing left to separate
1223        return None
1224    features = np.arange(X.shape[1]) if features is None else features
1225    best: Split | None = None
1226    n_left = np.arange(1, n)  # candidate i puts the first i sorted examples on the left
1227    n_right = n - n_left
1228    for f in features:
1229        order = np.argsort(X[:, f], kind="stable")
1230        xs, ys = X[order, f], y[order]
1231        # A threshold can only sit between two different values; equal values must stay together.
1232        valid = (xs[1:] > xs[:-1]) & (n_left >= min_samples_leaf) & (n_right >= min_samples_leaf)
1233        if not valid.any():
1234            continue
1235        if criterion == "squared":
1236            # Sum of squared errors from running sums: Σy² - (Σy)²/n on each side.
1237            s, s2 = np.cumsum(ys)[:-1], np.cumsum(ys**2)[:-1]
1238            tot, tot2 = ys.sum(), (ys**2).sum()
1239            sse = (s2 - s**2 / n_left) + ((tot2 - s2) - (tot - s) ** 2 / n_right)
1240            weighted = sse / n
1241        else:
1242            onehot = np.eye(int(y.max()) + 1)[ys]  # (n, classes)
1243            left = np.cumsum(onehot, axis=0)[:-1]  # class counts left of each candidate
1244            right = onehot.sum(axis=0) - left
1245            weighted = (n_left * _side_impurities(left, n_left, criterion) + n_right * _side_impurities(right, n_right, criterion)) / n
1246        weighted = np.where(valid, weighted, np.inf)
1247        i = int(np.argmin(weighted))
1248        # Strictly lower wins, so ties go to the earliest feature: the tree is reproducible.
1249        if best is None or weighted[i] < best.impurity - 1e-12:
1250            best = Split(int(f), float((xs[i] + xs[i + 1]) / 2), float(weighted[i]))
1251    return best

The feature and threshold whose split leaves the lowest weighted impurity, or None.

criterion is "gini" or "entropy" for class labels (integers 0..K-1), or "squared" for numbers (impurity = variance, as in a regression tree). Sorting each feature once turns "try every threshold" into running totals, so a node costs O(n log n) per feature instead of O(n²).

def candidate_splits(X: numpy.ndarray, y: numpy.ndarray) -> list[Split]: on GitHub
1254def candidate_splits(X: np.ndarray, y: np.ndarray) -> list[Split]:
1255    """Every question worth asking at one node, with the Gini impurity it would leave.
1256
1257    Thresholds sit halfway between neighbouring distinct values of a
1258    feature; anywhere else in the gap gives the same split.
1259    """
1260    out = []
1261    for f in range(X.shape[1]):
1262        values = np.unique(X[:, f])
1263        for t in (values[1:] + values[:-1]) / 2:
1264            left = X[:, f] <= t
1265            out.append(Split(f, float(t), split_impurity(y[left], y[~left])))
1266    return out

Every question worth asking at one node, with the Gini impurity it would leave.

Thresholds sit halfway between neighbouring distinct values of a feature; anywhere else in the gap gives the same split.

@dataclass
class Node: on GitHub
1273@dataclass
1274class Node:
1275    """One box of the flowchart. A leaf has no question and carries the prediction in `value`."""
1276
1277    value: np.ndarray  # class shares (classification) or [mean] (regression)
1278    n_samples: int
1279    impurity: float
1280    feature: int | None = None
1281    threshold: float | None = None
1282    left: "Node | None" = None
1283    right: "Node | None" = None
1284
1285    @property
1286    def is_leaf(self) -> bool:
1287        return self.feature is None

One box of the flowchart. A leaf has no question and carries the prediction in value.

Node( value: numpy.ndarray, n_samples: int, impurity: float, feature: int | None = None, threshold: float | None = None, left: Node | None = None, right: Node | None = None)
value: numpy.ndarray
n_samples: int
impurity: float
feature: int | None = None
threshold: float | None = None
left: Node | None = None
right: Node | None = None
is_leaf: bool on GitHub
1285    @property
1286    def is_leaf(self) -> bool:
1287        return self.feature is None
@dataclass
class DecisionTree: on GitHub
1290@dataclass
1291class DecisionTree:
1292    """A CART-style decision tree, grown greedily from the top.
1293
1294    criterion: "gini" or "entropy" to classify integer labels, "squared" to
1295    predict numbers. max_depth None grows until every leaf is pure.
1296    max_features limits how many randomly chosen features each node may try
1297    ("sqrt" or an int); that is the random forest's extra ingredient.
1298    """
1299
1300    max_depth: int | None = None
1301    criterion: str = "gini"
1302    min_samples_leaf: int = 1
1303    max_features: int | str | None = None
1304    seed: int = 0
1305    root: Node | None = field(default=None, init=False)
1306
1307    def fit(self, X: np.ndarray, y: np.ndarray, n_classes: int | None = None) -> "DecisionTree":
1308        X = np.asarray(X, dtype=float)
1309        self.regression = self.criterion == "squared"
1310        y = np.asarray(y, dtype=float if self.regression else int)
1311        # A bootstrap sample can miss a class; the forest passes the true count so shares line up.
1312        self.n_classes = None if self.regression else (n_classes or int(y.max()) + 1)
1313        self.n_features = X.shape[1]
1314        self._importance = np.zeros(self.n_features)
1315        self._rng = np.random.default_rng(self.seed)
1316        self._n_total = len(y)
1317        self.root = self._grow(X, y, depth=0)
1318        return self
1319
1320    def _leaf_value(self, y: np.ndarray) -> np.ndarray:
1321        if self.regression:
1322            return np.array([y.mean()])
1323        return np.bincount(y, minlength=self.n_classes) / len(y)
1324
1325    def _impurity(self, y: np.ndarray) -> float:
1326        if self.regression:
1327            return float(np.var(y))
1328        return gini(y) if self.criterion == "gini" else entropy(y)
1329
1330    def _features_to_try(self) -> np.ndarray:
1331        d = self.n_features
1332        k = {None: d, "sqrt": max(1, int(math.sqrt(d)))}.get(self.max_features, self.max_features)
1333        if k >= d:
1334            return np.arange(d)
1335        # A fresh random subset at every node keeps the trees of a forest different from each other.
1336        return np.sort(self._rng.choice(d, size=k, replace=False))
1337
1338    def _grow(self, X: np.ndarray, y: np.ndarray, depth: int) -> Node:
1339        node = Node(self._leaf_value(y), len(y), self._impurity(y))
1340        if self.max_depth is not None and depth >= self.max_depth:
1341            return node
1342        split = best_split(X, y, self.criterion, self._features_to_try(), self.min_samples_leaf)
1343        if split is None:
1344            return node
1345        go_left = X[:, split.feature] <= split.threshold
1346        # Credit the feature with the impurity it removed, weighted by how many examples reached here.
1347        self._importance[split.feature] += len(y) / self._n_total * (node.impurity - split.impurity)
1348        node.feature, node.threshold = split.feature, split.threshold
1349        node.left = self._grow(X[go_left], y[go_left], depth + 1)
1350        node.right = self._grow(X[~go_left], y[~go_left], depth + 1)
1351        return node
1352
1353    def _values(self, X: np.ndarray) -> np.ndarray:
1354        """Route every row to its leaf and return the leaf values, (n, classes) or (n, 1)."""
1355        X = np.asarray(X, dtype=float)
1356        out = np.empty((len(X), len(self.root.value)))
1357
1358        def route(node: Node, rows: np.ndarray) -> None:
1359            if node.is_leaf:
1360                out[rows] = node.value
1361                return
1362            go_left = X[rows, node.feature] <= node.threshold
1363            route(node.left, rows[go_left])
1364            route(node.right, rows[~go_left])
1365
1366        route(self.root, np.arange(len(X)))
1367        return out
1368
1369    def predict_proba(self, X: np.ndarray) -> np.ndarray:
1370        """Class shares of the leaf each row lands in, (n, classes)."""
1371        return self._values(X)
1372
1373    def predict(self, X: np.ndarray) -> np.ndarray:
1374        """The leaf's mean for regression, or the leaf's majority class."""
1375        values = self._values(X)
1376        return values[:, 0] if self.regression else values.argmax(axis=1)
1377
1378    @property
1379    def depth(self) -> int:
1380        def d(node: Node) -> int:
1381            return 0 if node.is_leaf else 1 + max(d(node.left), d(node.right))
1382
1383        return d(self.root)
1384
1385    @property
1386    def n_leaves(self) -> int:
1387        def count(node: Node) -> int:
1388            return 1 if node.is_leaf else count(node.left) + count(node.right)
1389
1390        return count(self.root)
1391
1392    @property
1393    def feature_importances(self) -> np.ndarray:
1394        """Impurity removed by each feature's splits, as shares that sum to 1."""
1395        total = self._importance.sum()
1396        return self._importance / total if total > 0 else self._importance
1397
1398    def rules(self, names: list[str] | None = None, classes: list[str] | None = None) -> list[str]:
1399        """The tree as nested if/else lines: the whole model, readable by a person."""
1400        names = names or [f"x{i}" for i in range(self.n_features)]
1401        lines: list[str] = []
1402
1403        def leaf_text(node: Node) -> str:
1404            if self.regression:
1405                return f"predict {node.value[0]:.3g}"
1406            k = int(node.value.argmax())
1407            label = classes[k] if classes else str(k)
1408            noun = "example" if node.n_samples == 1 else "examples"
1409            return f"predict {label}  ({node.n_samples} {noun}, {node.value[k]:.0%} agree)"
1410
1411        def walk(node: Node, indent: str) -> None:
1412            if node.is_leaf:
1413                lines.append(indent + leaf_text(node))
1414                return
1415            lines.append(f"{indent}if {names[node.feature]} <= {node.threshold:g}:")
1416            walk(node.left, indent + "    ")
1417            lines.append(f"{indent}else:  # {names[node.feature]} > {node.threshold:g}")
1418            walk(node.right, indent + "    ")
1419
1420        walk(self.root, "")
1421        return lines

A CART-style decision tree, grown greedily from the top.

criterion: "gini" or "entropy" to classify integer labels, "squared" to predict numbers. max_depth None grows until every leaf is pure. max_features limits how many randomly chosen features each node may try ("sqrt" or an int); that is the random forest's extra ingredient.

DecisionTree( max_depth: int | None = None, criterion: str = 'gini', min_samples_leaf: int = 1, max_features: int | str | None = None, seed: int = 0)
max_depth: int | None = None
criterion: str = 'gini'
min_samples_leaf: int = 1
max_features: int | str | None = None
seed: int = 0
root: Node | None = None
def fit( self, X: numpy.ndarray, y: numpy.ndarray, n_classes: int | None = None) -> DecisionTree: on GitHub
1307    def fit(self, X: np.ndarray, y: np.ndarray, n_classes: int | None = None) -> "DecisionTree":
1308        X = np.asarray(X, dtype=float)
1309        self.regression = self.criterion == "squared"
1310        y = np.asarray(y, dtype=float if self.regression else int)
1311        # A bootstrap sample can miss a class; the forest passes the true count so shares line up.
1312        self.n_classes = None if self.regression else (n_classes or int(y.max()) + 1)
1313        self.n_features = X.shape[1]
1314        self._importance = np.zeros(self.n_features)
1315        self._rng = np.random.default_rng(self.seed)
1316        self._n_total = len(y)
1317        self.root = self._grow(X, y, depth=0)
1318        return self
def predict_proba(self, X: numpy.ndarray) -> numpy.ndarray: on GitHub
1369    def predict_proba(self, X: np.ndarray) -> np.ndarray:
1370        """Class shares of the leaf each row lands in, (n, classes)."""
1371        return self._values(X)

Class shares of the leaf each row lands in, (n, classes).

def predict(self, X: numpy.ndarray) -> numpy.ndarray: on GitHub
1373    def predict(self, X: np.ndarray) -> np.ndarray:
1374        """The leaf's mean for regression, or the leaf's majority class."""
1375        values = self._values(X)
1376        return values[:, 0] if self.regression else values.argmax(axis=1)

The leaf's mean for regression, or the leaf's majority class.

depth: int on GitHub
1378    @property
1379    def depth(self) -> int:
1380        def d(node: Node) -> int:
1381            return 0 if node.is_leaf else 1 + max(d(node.left), d(node.right))
1382
1383        return d(self.root)
n_leaves: int on GitHub
1385    @property
1386    def n_leaves(self) -> int:
1387        def count(node: Node) -> int:
1388            return 1 if node.is_leaf else count(node.left) + count(node.right)
1389
1390        return count(self.root)
feature_importances: numpy.ndarray on GitHub
1392    @property
1393    def feature_importances(self) -> np.ndarray:
1394        """Impurity removed by each feature's splits, as shares that sum to 1."""
1395        total = self._importance.sum()
1396        return self._importance / total if total > 0 else self._importance

Impurity removed by each feature's splits, as shares that sum to 1.

def rules( self, names: list[str] | None = None, classes: list[str] | None = None) -> list[str]: on GitHub
1398    def rules(self, names: list[str] | None = None, classes: list[str] | None = None) -> list[str]:
1399        """The tree as nested if/else lines: the whole model, readable by a person."""
1400        names = names or [f"x{i}" for i in range(self.n_features)]
1401        lines: list[str] = []
1402
1403        def leaf_text(node: Node) -> str:
1404            if self.regression:
1405                return f"predict {node.value[0]:.3g}"
1406            k = int(node.value.argmax())
1407            label = classes[k] if classes else str(k)
1408            noun = "example" if node.n_samples == 1 else "examples"
1409            return f"predict {label}  ({node.n_samples} {noun}, {node.value[k]:.0%} agree)"
1410
1411        def walk(node: Node, indent: str) -> None:
1412            if node.is_leaf:
1413                lines.append(indent + leaf_text(node))
1414                return
1415            lines.append(f"{indent}if {names[node.feature]} <= {node.threshold:g}:")
1416            walk(node.left, indent + "    ")
1417            lines.append(f"{indent}else:  # {names[node.feature]} > {node.threshold:g}")
1418            walk(node.right, indent + "    ")
1419
1420        walk(self.root, "")
1421        return lines

The tree as nested if/else lines: the whole model, readable by a person.

SPAM_FEATURES = ['links', 'known_sender']
def spam_emails() -> tuple[numpy.ndarray, numpy.ndarray, list[str]]: on GitHub
1431def spam_emails() -> tuple[np.ndarray, np.ndarray, list[str]]:
1432    """The eight emails of the worked example: (links, known sender?) and 1 = spam.
1433
1434    | email | links | known sender | spam? |
1435    |---|---|---|---|
1436    | 1 | 0 | yes | no |
1437    | 2 | 1 | yes | no |
1438    | 3 | 4 | yes | no |
1439    | 4 | 0 | no  | no |
1440    | 5 | 3 | no  | yes |
1441    | 6 | 5 | no  | yes |
1442    | 7 | 6 | no  | yes |
1443    | 8 | 1 | no  | yes |
1444    """
1445    X = np.array([[0, 1], [1, 1], [4, 1], [0, 0], [3, 0], [5, 0], [6, 0], [1, 0]], dtype=float)
1446    y = np.array([0, 0, 0, 0, 1, 1, 1, 1])
1447    return X, y, list(SPAM_FEATURES)

The eight emails of the worked example: (links, known sender?) and 1 = spam.

email links known sender spam?
1 0 yes no
2 1 yes no
3 4 yes no
4 0 no no
5 3 no yes
6 5 no yes
7 6 no yes
8 1 no yes
def moons_split(n: int = 500, noise: float = 0.35, n_train: int = 300, seed: int = 1): on GitHub
1450def moons_split(n: int = 500, noise: float = 0.35, n_train: int = 300, seed: int = 1):
1451    """Noisy two-moons points (`primer.ml.neural_net.make_moons`), cut into training and validation sets."""
1452    from primer.ml.neural_net import make_moons
1453
1454    X, y = make_moons(n=n, noise=noise, seed=seed)
1455    # make_moons lists one moon, then the other; shuffle so both sets hold both classes.
1456    order = np.random.default_rng(seed).permutation(n)
1457    X, y = X[order], y[order].astype(int)
1458    return X[:n_train], y[:n_train], X[n_train:], y[n_train:]

Noisy two-moons points (primer.ml.neural_net.make_moons), cut into training and validation sets.

def depth_sweep(depths=(1, 2, 3, 4, 5, 6, 8, 10, 12, None)) -> list[dict]: on GitHub
1465def depth_sweep(depths=(1, 2, 3, 4, 5, 6, 8, 10, 12, None)) -> list[dict]:
1466    """Train and validation accuracy of one tree per depth limit on the noisy moons."""
1467    X, y, X_val, y_val = moons_split()
1468    rows = []
1469    for depth in depths:
1470        tree = DecisionTree(max_depth=depth).fit(X, y)
1471        rows.append(dict(depth=depth, train_accuracy=_accuracy(tree, X, y), val_accuracy=_accuracy(tree, X_val, y_val), leaves=tree.n_leaves))
1472    return rows

Train and validation accuracy of one tree per depth limit on the noisy moons.

def bootstrap_sample(n: int, rng: numpy.random._generator.Generator) -> numpy.ndarray: on GitHub
1480def bootstrap_sample(n: int, rng: np.random.Generator) -> np.ndarray:
1481    """n row indices drawn with replacement: some rows twice or more, about a third not at all."""
1482    return rng.integers(0, n, size=n)

n row indices drawn with replacement: some rows twice or more, about a third not at all.

def out_of_bag_fraction(n: int) -> float: on GitHub
1485def out_of_bag_fraction(n: int) -> float:
1486    """(1 - 1/n)^n: the chance a given row is never drawn in n draws. Tends to 1/e ≈ 0.368."""
1487    return (1 - 1 / n) ** n

(1 - 1/n)^n: the chance a given row is never drawn in n draws. Tends to 1/e ≈ 0.368.

def averaged_variance(sigma2: float, rho: float, n_trees: int) -> float: on GitHub
1490def averaged_variance(sigma2: float, rho: float, n_trees: int) -> float:
1491    """Variance of the average of n_trees predictions, each with variance sigma2 and pairwise correlation rho.
1492
1493    ρσ² + (1 - ρ)σ²/B: more trees shrink only the second term, so the
1494    correlation between trees sets the floor. Random feature subsets exist
1495    to push ρ down.
1496    """
1497    return rho * sigma2 + (1 - rho) * sigma2 / n_trees

Variance of the average of n_trees predictions, each with variance sigma2 and pairwise correlation rho.

ρσ² + (1 - ρ)σ²/B: more trees shrink only the second term, so the correlation between trees sets the floor. Random feature subsets exist to push ρ down.

@dataclass
class RandomForest: on GitHub
1500@dataclass
1501class RandomForest:
1502    """Many deep trees, each grown on its own bootstrap sample and trying only
1503    a random subset of features at every node; their class shares are averaged."""
1504
1505    n_trees: int = 100
1506    max_depth: int | None = None
1507    max_features: int | str | None = "sqrt"
1508    seed: int = 0
1509    trees: list[DecisionTree] = field(default_factory=list, init=False)
1510
1511    def fit(self, X: np.ndarray, y: np.ndarray) -> "RandomForest":
1512        X, y = np.asarray(X, dtype=float), np.asarray(y, dtype=int)
1513        rng = np.random.default_rng(self.seed)
1514        n_classes = int(y.max()) + 1
1515        self.trees = []
1516        for _ in range(self.n_trees):
1517            rows = bootstrap_sample(len(y), rng)
1518            # Each tree gets its own seed, so its feature subsets differ from its neighbours'.
1519            tree = DecisionTree(max_depth=self.max_depth, max_features=self.max_features, seed=int(rng.integers(2**31)))
1520            self.trees.append(tree.fit(X[rows], y[rows], n_classes=n_classes))
1521        return self
1522
1523    def predict_proba(self, X: np.ndarray) -> np.ndarray:
1524        return np.mean([t.predict_proba(X) for t in self.trees], axis=0)
1525
1526    def predict(self, X: np.ndarray) -> np.ndarray:
1527        return self.predict_proba(X).argmax(axis=1)
1528
1529    @property
1530    def feature_importances(self) -> np.ndarray:
1531        """Each tree's impurity importance, averaged over the forest."""
1532        return np.mean([t.feature_importances for t in self.trees], axis=0)

Many deep trees, each grown on its own bootstrap sample and trying only a random subset of features at every node; their class shares are averaged.

RandomForest( n_trees: int = 100, max_depth: int | None = None, max_features: int | str | None = 'sqrt', seed: int = 0)
n_trees: int = 100
max_depth: int | None = None
max_features: int | str | None = 'sqrt'
seed: int = 0
trees: list[DecisionTree]
def fit( self, X: numpy.ndarray, y: numpy.ndarray) -> RandomForest: on GitHub
1511    def fit(self, X: np.ndarray, y: np.ndarray) -> "RandomForest":
1512        X, y = np.asarray(X, dtype=float), np.asarray(y, dtype=int)
1513        rng = np.random.default_rng(self.seed)
1514        n_classes = int(y.max()) + 1
1515        self.trees = []
1516        for _ in range(self.n_trees):
1517            rows = bootstrap_sample(len(y), rng)
1518            # Each tree gets its own seed, so its feature subsets differ from its neighbours'.
1519            tree = DecisionTree(max_depth=self.max_depth, max_features=self.max_features, seed=int(rng.integers(2**31)))
1520            self.trees.append(tree.fit(X[rows], y[rows], n_classes=n_classes))
1521        return self
def predict_proba(self, X: numpy.ndarray) -> numpy.ndarray: on GitHub
1523    def predict_proba(self, X: np.ndarray) -> np.ndarray:
1524        return np.mean([t.predict_proba(X) for t in self.trees], axis=0)
def predict(self, X: numpy.ndarray) -> numpy.ndarray: on GitHub
1526    def predict(self, X: np.ndarray) -> np.ndarray:
1527        return self.predict_proba(X).argmax(axis=1)
feature_importances: numpy.ndarray on GitHub
1529    @property
1530    def feature_importances(self) -> np.ndarray:
1531        """Each tree's impurity importance, averaged over the forest."""
1532        return np.mean([t.feature_importances for t in self.trees], axis=0)

Each tree's impurity importance, averaged over the forest.

def forest_curve(n_trees=(1, 2, 3, 5, 10, 20, 50, 100), seed: int = 0) -> list[dict]: on GitHub
1535def forest_curve(n_trees=(1, 2, 3, 5, 10, 20, 50, 100), seed: int = 0) -> list[dict]:
1536    """Validation accuracy of a forest on the noisy moons as trees are added.
1537
1538    One forest of max(n_trees) is grown; the first k trees' average gives the
1539    k-tree forest, exactly as if it had been grown alone.
1540    """
1541    X, y, X_val, y_val = moons_split()
1542    forest = RandomForest(n_trees=max(n_trees), seed=seed).fit(X, y)
1543    probas = np.cumsum([t.predict_proba(X_val) for t in forest.trees], axis=0)
1544    single = DecisionTree().fit(X, y)
1545    return [
1546        dict(n_trees=k, val_accuracy=float(np.mean(probas[k - 1].argmax(axis=1) == y_val)), single_tree=_accuracy(single, X_val, y_val))
1547        for k in n_trees
1548    ]

Validation accuracy of a forest on the noisy moons as trees are added.

One forest of max(n_trees) is grown; the first k trees' average gives the k-tree forest, exactly as if it had been grown alone.

@dataclass
class GradientBoosting: on GitHub
1560@dataclass
1561class GradientBoosting:
1562    """F_m = F_{m-1} + η·h_m, where each h_m is a small regression tree fit to
1563    the negative gradient of the loss (the residuals, for squared loss).
1564
1565    loss="squared" predicts numbers; loss="log" classifies 0/1 labels, with F
1566    as log-odds. `train_loss` holds the training loss before any tree and
1567    after each round: mean squared error, or cross-entropy for "log".
1568    """
1569
1570    n_rounds: int = 100
1571    learning_rate: float = 0.1
1572    max_depth: int = 2
1573    loss: str = "squared"
1574    trees: list[DecisionTree] = field(default_factory=list, init=False)
1575    train_loss: list[float] = field(default_factory=list, init=False)
1576
1577    @staticmethod
1578    def negative_gradient(y: np.ndarray, F: np.ndarray, loss: str) -> np.ndarray:
1579        """-∂L/∂F for each example: the direction that most quickly reduces its loss.
1580
1581        Squared loss ½(y - F)² gives y - F, the plain residual. Log loss
1582        gives y - sigmoid(F): the label minus the predicted probability.
1583        """
1584        return y - F if loss == "squared" else y - _sigmoid(F)
1585
1586    def _loss(self, y: np.ndarray, F: np.ndarray) -> float:
1587        if self.loss == "squared":
1588            return float(np.mean((y - F) ** 2))
1589        p = np.clip(_sigmoid(F), 1e-12, 1 - 1e-12)
1590        return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
1591
1592    def fit(self, X: np.ndarray, y: np.ndarray) -> "GradientBoosting":
1593        X, y = np.asarray(X, dtype=float), np.asarray(y, dtype=float)
1594        # F_0: the best single constant. The mean for squared loss; the log-odds of the base rate for log loss.
1595        mean = y.mean()
1596        self.f0 = mean if self.loss == "squared" else math.log(mean / (1 - mean))
1597        F = np.full(len(y), self.f0)
1598        self.trees, self.train_loss = [], [self._loss(y, F)]
1599        for _ in range(self.n_rounds):
1600            residual = self.negative_gradient(y, F, self.loss)
1601            tree = DecisionTree(max_depth=self.max_depth, criterion="squared").fit(X, residual)
1602            # Take only a fraction η of the step: many small corrections generalize better than a few big ones.
1603            F = F + self.learning_rate * tree.predict(X)
1604            self.trees.append(tree)
1605            self.train_loss.append(self._loss(y, F))
1606        return self
1607
1608    def staged_scores(self, X: np.ndarray) -> np.ndarray:
1609        """F_0, F_1, ..., F_M for every row: (rounds + 1, n)."""
1610        steps = [np.full(len(X), self.f0)] + [self.learning_rate * t.predict(X) for t in self.trees]
1611        return np.cumsum(steps, axis=0)
1612
1613    def staged_loss(self, X: np.ndarray, y: np.ndarray) -> list[float]:
1614        """The loss on (X, y) before any tree and after each round: the validation curve."""
1615        return [self._loss(np.asarray(y, dtype=float), F) for F in self.staged_scores(X)]
1616
1617    def predict(self, X: np.ndarray) -> np.ndarray:
1618        F = self.staged_scores(X)[-1]
1619        return F if self.loss == "squared" else (F > 0).astype(int)

F_m = F_{m-1} + η·h_m, where each h_m is a small regression tree fit to the negative gradient of the loss (the residuals, for squared loss).

loss="squared" predicts numbers; loss="log" classifies 0/1 labels, with F as log-odds. train_loss holds the training loss before any tree and after each round: mean squared error, or cross-entropy for "log".

GradientBoosting( n_rounds: int = 100, learning_rate: float = 0.1, max_depth: int = 2, loss: str = 'squared')
n_rounds: int = 100
learning_rate: float = 0.1
max_depth: int = 2
loss: str = 'squared'
trees: list[DecisionTree]
train_loss: list[float]
@staticmethod
def negative_gradient(y: numpy.ndarray, F: numpy.ndarray, loss: str) -> numpy.ndarray: on GitHub
1577    @staticmethod
1578    def negative_gradient(y: np.ndarray, F: np.ndarray, loss: str) -> np.ndarray:
1579        """-∂L/∂F for each example: the direction that most quickly reduces its loss.
1580
1581        Squared loss ½(y - F)² gives y - F, the plain residual. Log loss
1582        gives y - sigmoid(F): the label minus the predicted probability.
1583        """
1584        return y - F if loss == "squared" else y - _sigmoid(F)

-∂L/∂F for each example: the direction that most quickly reduces its loss.

Squared loss ½(y - F)² gives y - F, the plain residual. Log loss gives y - sigmoid(F): the label minus the predicted probability.

def fit( self, X: numpy.ndarray, y: numpy.ndarray) -> GradientBoosting: on GitHub
1592    def fit(self, X: np.ndarray, y: np.ndarray) -> "GradientBoosting":
1593        X, y = np.asarray(X, dtype=float), np.asarray(y, dtype=float)
1594        # F_0: the best single constant. The mean for squared loss; the log-odds of the base rate for log loss.
1595        mean = y.mean()
1596        self.f0 = mean if self.loss == "squared" else math.log(mean / (1 - mean))
1597        F = np.full(len(y), self.f0)
1598        self.trees, self.train_loss = [], [self._loss(y, F)]
1599        for _ in range(self.n_rounds):
1600            residual = self.negative_gradient(y, F, self.loss)
1601            tree = DecisionTree(max_depth=self.max_depth, criterion="squared").fit(X, residual)
1602            # Take only a fraction η of the step: many small corrections generalize better than a few big ones.
1603            F = F + self.learning_rate * tree.predict(X)
1604            self.trees.append(tree)
1605            self.train_loss.append(self._loss(y, F))
1606        return self
def staged_scores(self, X: numpy.ndarray) -> numpy.ndarray: on GitHub
1608    def staged_scores(self, X: np.ndarray) -> np.ndarray:
1609        """F_0, F_1, ..., F_M for every row: (rounds + 1, n)."""
1610        steps = [np.full(len(X), self.f0)] + [self.learning_rate * t.predict(X) for t in self.trees]
1611        return np.cumsum(steps, axis=0)

F_0, F_1, ..., F_M for every row: (rounds + 1, n).

def staged_loss(self, X: numpy.ndarray, y: numpy.ndarray) -> list[float]: on GitHub
1613    def staged_loss(self, X: np.ndarray, y: np.ndarray) -> list[float]:
1614        """The loss on (X, y) before any tree and after each round: the validation curve."""
1615        return [self._loss(np.asarray(y, dtype=float), F) for F in self.staged_scores(X)]

The loss on (X, y) before any tree and after each round: the validation curve.

def predict(self, X: numpy.ndarray) -> numpy.ndarray: on GitHub
1617    def predict(self, X: np.ndarray) -> np.ndarray:
1618        F = self.staged_scores(X)[-1]
1619        return F if self.loss == "squared" else (F > 0).astype(int)
def boosting_worked_example(learning_rate: float = 0.5) -> dict: on GitHub
1622def boosting_worked_example(learning_rate: float = 0.5) -> dict:
1623    """Four houses (size 1..4, price 1, 2, 6, 7) and one round of boosting with a one-question tree."""
1624    X, y = np.array([[1.0], [2.0], [3.0], [4.0]]), np.array([1.0, 2.0, 6.0, 7.0])
1625    model = GradientBoosting(n_rounds=1, learning_rate=learning_rate, max_depth=1).fit(X, y)
1626    F0 = np.full(4, model.f0)
1627    residuals = GradientBoosting.negative_gradient(y, F0, "squared")
1628    return dict(
1629        F0=model.f0,
1630        residuals=residuals.tolist(),
1631        stump=model.trees[0].predict(X).tolist(),
1632        F1=model.predict(X).tolist(),
1633        mse=model.train_loss,
1634    )

Four houses (size 1..4, price 1, 2, 6, 7) and one round of boosting with a one-question tree.

def sine_data( n: int, noise: float = 0.3, seed: int = 0) -> tuple[numpy.ndarray, numpy.ndarray]: on GitHub
1637def sine_data(n: int, noise: float = 0.3, seed: int = 0) -> tuple[np.ndarray, np.ndarray]:
1638    """n points of y = sin(x) + noise for x in [0, 6]: a curve for regression trees to approximate."""
1639    rng = np.random.default_rng(seed)
1640    x = rng.uniform(0, 6, n)
1641    return x[:, None], np.sin(x) + rng.normal(0, noise, n)

n points of y = sin(x) + noise for x in [0, 6]: a curve for regression trees to approximate.

def boosting_curves( learning_rates=(1.0, 0.3, 0.1), n_rounds: int = 200, max_depth: int = 2) -> list[dict]: on GitHub
1644def boosting_curves(learning_rates=(1.0, 0.3, 0.1), n_rounds: int = 200, max_depth: int = 2) -> list[dict]:
1645    """Training and validation mean squared error per round on 80 noisy sine points, for each learning rate."""
1646    X, y = sine_data(80, seed=0)
1647    X_val, y_val = sine_data(400, seed=1)
1648    rows = []
1649    for lr in learning_rates:
1650        model = GradientBoosting(n_rounds=n_rounds, learning_rate=lr, max_depth=max_depth).fit(X, y)
1651        val = model.staged_loss(X_val, y_val)
1652        rows.append(dict(learning_rate=lr, train_loss=model.train_loss, val_loss=val, best_round=int(np.argmin(val))))
1653    return rows

Training and validation mean squared error per round on 80 noisy sine points, for each learning rate.

TABULAR_FEATURES = ['age', 'income', 'late_payments', 'region', 'noise']
def make_tabular(n: int = 1200, n_train: int = 800, seed: int = 0): on GitHub
1663def make_tabular(n: int = 1200, n_train: int = 800, seed: int = 0):
1664    """A toy loan table with the mix real spreadsheets have.
1665
1666    age in years, income in dollars (spanning 100×), a count of late
1667    payments, a region code 0..5 whose numbers mean nothing in order, and a
1668    column of pure noise. The label (1 = the loan went bad) follows
1669    threshold rules of the kind a lender writes, with 5% of labels flipped.
1670    Returns X_train, y_train, X_val, y_val, feature names.
1671    """
1672    rng = np.random.default_rng(seed)
1673    age = rng.uniform(18, 80, n)
1674    income = np.exp(rng.normal(np.log(50_000), 0.7, n))
1675    late = rng.poisson(1.2, n)
1676    region = rng.integers(0, 6, n)
1677    noise = rng.normal(0, 1, n)
1678    bad = (late >= 4) | ((income < 30_000) & (age < 30)) | (np.isin(region, [1, 4]) & (late >= 2)) | ((income < 20_000) & (late >= 2))
1679    flip = rng.random(n) < 0.05
1680    y = (bad ^ flip).astype(int)
1681    X = np.c_[age, income, late, region, noise]
1682    return X[:n_train], y[:n_train], X[n_train:], y[n_train:], list(TABULAR_FEATURES)

A toy loan table with the mix real spreadsheets have.

age in years, income in dollars (spanning 100×), a count of late payments, a region code 0..5 whose numbers mean nothing in order, and a column of pure noise. The label (1 = the loan went bad) follows threshold rules of the kind a lender writes, with 5% of labels flipped. Returns X_train, y_train, X_val, y_val, feature names.

def permutation_importance( model, X: numpy.ndarray, y: numpy.ndarray, seed: int = 0) -> numpy.ndarray: on GitHub
1685def permutation_importance(model, X: np.ndarray, y: np.ndarray, seed: int = 0) -> np.ndarray:
1686    """Accuracy lost on held-out data when one column is shuffled, per feature.
1687
1688    Shuffling breaks the link between that column and the label while
1689    keeping its values, so the drop measures how much the model relies on it
1690    for data it did not train on.
1691    """
1692    rng = np.random.default_rng(seed)
1693    base = _accuracy(model, X, y)
1694    drops = []
1695    for f in range(X.shape[1]):
1696        shuffled = X.copy()
1697        shuffled[:, f] = rng.permutation(shuffled[:, f])
1698        drops.append(base - _accuracy(model, shuffled, y))
1699    return np.array(drops)

Accuracy lost on held-out data when one column is shuffled, per feature.

Shuffling breaks the link between that column and the label while keeping its values, so the drop measures how much the model relies on it for data it did not train on.

def engineer_features(X: numpy.ndarray) -> numpy.ndarray: on GitHub
1702def engineer_features(X: np.ndarray) -> np.ndarray:
1703    """What a neural network needs done by hand before it can read the loan table.
1704
1705    Income becomes its logarithm, so a raise from 20k to 40k counts as much
1706    as one from 100k to 200k. The region code becomes six yes/no columns,
1707    because region 4 is not "twice region 2". Columns: age, log income, late
1708    payments, region_0..region_5, noise.
1709    """
1710    region = np.eye(6)[X[:, 3].astype(int)]
1711    return np.c_[X[:, 0], np.log(X[:, 1]), X[:, 2], region, X[:, 4]]

What a neural network needs done by hand before it can read the loan table.

Income becomes its logarithm, so a raise from 20k to 40k counts as much as one from 100k to 200k. The region code becomes six yes/no columns, because region 4 is not "twice region 2". Columns: age, log income, late payments, region_0..region_5, noise.

def tabular_showdown(seed: int = 0) -> dict[str, float]: on GitHub
1743def tabular_showdown(seed: int = 0) -> dict[str, float]:
1744    """Validation accuracy on `make_tabular` for a tree, two tree ensembles and a small MLP
1745    (`primer.ml.neural_net.MLP`) fed raw, scaled, and scaled plus engineered inputs.
1746
1747    Trees get the table exactly as it is. Every model is trained once per
1748    seed and remembered, because several callers ask for the same scores.
1749    """
1750    return dict(_showdown(seed))

Validation accuracy on make_tabular for a tree, two tree ensembles and a small MLP (primer.ml.neural_net.MLP) fed raw, scaled, and scaled plus engineered inputs.

Trees get the table exactly as it is. Every model is trained once per seed and remembered, because several callers ask for the same scores.

def figures() -> dict: on GitHub
1759def figures() -> dict:
1760    """Plot this lesson's data. matplotlib is imported here, and only here,
1761    so the lesson itself needs nothing beyond NumPy."""
1762    import matplotlib
1763
1764    matplotlib.use("Agg")
1765    import matplotlib.pyplot as plt
1766    from matplotlib.colors import ListedColormap
1767
1768    BLUE, ORANGE, MUTED, DARK = "#2563eb", "#ea580c", "#9ca3af", "#4b5563"
1769    REGIONS = ListedColormap(["#dbeafe", "#ffedd5"])
1770    figs = {}
1771    X, y, X_val, y_val = moons_split()
1772    xx, yy = np.meshgrid(np.linspace(-1.8, 2.8, 220), np.linspace(-1.5, 1.9, 170))
1773    grid = np.c_[xx.ravel(), yy.ravel()]
1774
1775    def regions(ax, model, title):
1776        ax.contourf(xx, yy, model.predict(grid).reshape(xx.shape), levels=[-0.5, 0.5, 1.5], cmap=REGIONS)
1777        ax.scatter(*X[y == 0].T, s=9, color=BLUE, label="class 0")
1778        ax.scatter(*X[y == 1].T, s=9, color=ORANGE, label="class 1")
1779        ax.set_title(title)
1780        ax.set_xticks([])
1781        ax.set_yticks([])
1782        ax.grid(False)
1783
1784    # --- 1. Every candidate question on the eight emails -------------------
1785    Xs, ys, names = spam_emails()
1786    cands = candidate_splits(Xs, ys)
1787    labels = [f"{names[c.feature]} > {c.threshold:g}" for c in cands]
1788    scores = [c.impurity for c in cands]
1789    fig, ax = plt.subplots(figsize=(6, 3.2))
1790    colors = [BLUE if s == min(scores) else MUTED for s in scores]
1791    ax.barh(labels, scores, color=colors)
1792    ax.axvline(gini(ys), color=DARK, ls="--")
1793    ax.text(gini(ys) + 0.01, 0.5, "before any\nsplit: 0.5", va="center", color=DARK)
1794    for i, s in enumerate(scores):
1795        ax.text(s + 0.008, i, f"{s:.3f}", va="center", zorder=3, bbox=dict(facecolor="white", edgecolor="none", pad=1))
1796    ax.invert_yaxis()
1797    ax.set_xlim(0, 0.65)
1798    ax.set_xlabel("weighted Gini impurity after the split (lower is better)")
1799    ax.set_title("Six questions the tree could ask first")
1800    figs["spam_splits"] = fig
1801
1802    # --- 2. Axis-aligned decision regions as depth grows --------------------
1803    fig, axes = plt.subplots(1, 3, figsize=(10, 3.3))
1804    for ax, depth in zip(axes, (1, 3, None)):
1805        tree = DecisionTree(max_depth=depth).fit(X, y)
1806        regions(ax, tree, f"depth {depth or 'unlimited'}: {tree.n_leaves} leaves")
1807    axes[0].legend(frameon=False, loc="lower left", fontsize=8)
1808    fig.tight_layout()
1809    figs["regions"] = fig
1810
1811    # --- 3. Depth vs train/validation accuracy ------------------------------
1812    rows = depth_sweep()
1813    xs = [r["depth"] or 16 for r in rows]
1814    fig, ax = plt.subplots(figsize=(6, 3.4))
1815    ax.plot(xs, [r["train_accuracy"] for r in rows], "o-", color=BLUE, label="training")
1816    ax.plot(xs, [r["val_accuracy"] for r in rows], "o-", color=ORANGE, label="validation")
1817    ax.set_xticks(xs, [str(r["depth"] or "none") for r in rows])
1818    ax.set_xlabel("depth limit")
1819    ax.set_ylabel("accuracy")
1820    ax.set_ylim(0.75, 1.01)
1821    ax.set_title("Deeper trees memorise: training climbs, validation falls")
1822    ax.legend(frameon=False)
1823    figs["depth_sweep"] = fig
1824
1825    # --- 4. One deep tree vs a forest ---------------------------------------
1826    fig, axes = plt.subplots(1, 2, figsize=(8, 3.4))
1827    single = DecisionTree().fit(X, y)
1828    forest = RandomForest(n_trees=100).fit(X, y)
1829    regions(axes[0], single, f"one deep tree: {_accuracy(single, X_val, y_val):.1%} on validation")
1830    regions(axes[1], forest, f"forest of 100: {_accuracy(forest, X_val, y_val):.1%} on validation")
1831    fig.tight_layout()
1832    figs["forest_regions"] = fig
1833
1834    # --- 5. Validation accuracy vs number of trees --------------------------
1835    curve = forest_curve(n_trees=(1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 75, 100))
1836    fig, ax = plt.subplots(figsize=(6, 3.4))
1837    ax.plot([r["n_trees"] for r in curve], [r["val_accuracy"] for r in curve], "o-", color=BLUE, label="random forest")
1838    ax.axhline(curve[0]["single_tree"], color=ORANGE, ls="--", label="one deep tree, all the data")
1839    ax.set_xscale("log")
1840    ax.set_xlabel("number of trees (log scale)")
1841    ax.set_ylabel("validation accuracy")
1842    ax.set_title("Adding trees helps, then levels off")
1843    ax.legend(frameon=False, loc="lower right")
1844    figs["forest_curve"] = fig
1845
1846    # --- 6. Boosting builds a curve from steps ------------------------------
1847    Xr, yr = sine_data(80, seed=0)
1848    line = np.linspace(0, 6, 400)[:, None]
1849    model = GradientBoosting(n_rounds=50, learning_rate=0.3, max_depth=2).fit(Xr, yr)
1850    staged = model.staged_scores(line)
1851    fig, axes = plt.subplots(1, 3, figsize=(10, 3.2), sharey=True)
1852    for ax, m in zip(axes, (1, 5, 50)):
1853        ax.scatter(Xr[:, 0], yr, s=8, color=MUTED)
1854        ax.plot(line[:, 0], np.sin(line[:, 0]), color=DARK, lw=1, ls=":", label="true curve sin(x)")
1855        ax.plot(line[:, 0], staged[m], color=BLUE, lw=2, label="boosted trees")
1856        ax.set_title(f"after {m} round{'s' if m > 1 else ''}")
1857        ax.set_xlabel("x")
1858    axes[0].set_ylabel("y")
1859    axes[0].legend(frameon=False, loc="lower left", fontsize=8)
1860    fig.tight_layout()
1861    figs["boosting_steps"] = fig
1862
1863    # --- 7. Training and validation loss per round, by learning rate --------
1864    fig, ax = plt.subplots(figsize=(6.4, 3.6))
1865    for row, color in zip(boosting_curves(), (ORANGE, "#7c3aed", BLUE)):
1866        lr = row["learning_rate"]
1867        ax.plot(row["train_loss"], color=color, lw=1, ls="--")
1868        ax.plot(row["val_loss"], color=color, lw=2, label=f"η = {lr:g} (best validation at round {row['best_round']})")
1869        ax.plot(row["best_round"], min(row["val_loss"]), "o", color=color)
1870    ax.axhline(0.09, color=MUTED, lw=1)
1871    ax.text(200, 0.095, "noise floor 0.3² = 0.09", ha="right", va="bottom", color=DARK, fontsize=8)
1872    ax.set_ylim(0, 0.7)
1873    ax.set_xlabel("boosting round")
1874    ax.set_ylabel("mean squared error")
1875    ax.set_title("Solid: validation. Dashed: training.")
1876    ax.legend(frameon=False)
1877    figs["boosting_curves"] = fig
1878
1879    # --- 8. The tabular showdown --------------------------------------------
1880    scores = tabular_showdown()
1881    _, _, _, y_tab, _ = make_tabular()
1882    fig, ax = plt.subplots(figsize=(6.4, 3.4))
1883    names_ = list(scores)
1884    colors = [BLUE if not k.startswith("neural") else ORANGE for k in names_]
1885    ax.barh(names_, [scores[k] for k in names_], color=colors)
1886    majority = max(y_tab.mean(), 1 - y_tab.mean())
1887    ax.axvline(majority, color=DARK, ls="--")
1888    ax.text(majority + 0.004, -0.75, f"always say 'fine': {majority:.2%}", va="bottom", color=DARK, fontsize=8)
1889    for i, k in enumerate(names_):
1890        ax.text(scores[k] + 0.004, i, f"{scores[k]:.2%}", va="center")
1891    ax.set_ylim(len(names_) - 0.5, -1.1)
1892    ax.set_xlim(0.7, 1.0)
1893    ax.set_xlabel("validation accuracy on the loan table")
1894    ax.set_title("Trees read the table as it is; the net needs help")
1895    figs["showdown"] = fig
1896
1897    # --- 9. Impurity importance vs permutation importance -------------------
1898    Xt, yt, Xtv, ytv, tab_names = make_tabular()
1899    forest = RandomForest(n_trees=30).fit(Xt, yt)
1900    imp, perm = forest.feature_importances, permutation_importance(forest, Xtv, ytv)
1901    fig, (a1, a2) = plt.subplots(1, 2, figsize=(9, 3.2), sharey=True)
1902    a1.barh(tab_names, imp, color=BLUE)
1903    a2.barh(tab_names, perm, color=ORANGE)
1904    for a, vals, fmt in ((a1, imp, "{:.0%}"), (a2, perm, "{:.2%}")):
1905        for i, v in enumerate(vals):
1906            a.text(v + 0.003, i, fmt.format(v), va="center")
1907    a1.invert_yaxis()
1908    a1.set_title("Impurity importance (training data)")
1909    a1.set_xlabel("share of impurity removed")
1910    a2.set_title("Permutation importance (validation data)")
1911    a2.set_xlabel("accuracy lost when the column is shuffled")
1912    fig.tight_layout()
1913    figs["importance"] = fig
1914
1915    return figs

Plot this lesson's data. matplotlib is imported here, and only here, so the lesson itself needs nothing beyond NumPy.

def demo() -> None: on GitHub
1923def demo() -> None:
1924    banner("1. Sorting eight emails: which question first?")
1925    X, y, names = spam_emails()
1926    say(
1927        f"""
1928        Eight emails, four spam and four not. Before any question the pile has
1929        Gini impurity {gini(y):.2f}: two labels drawn at random disagree half
1930        the time. The tree tries every question it could ask:
1931        """
1932    )
1933    table(["question", "weighted Gini after"], [(f"{names[c.feature]} > {c.threshold:g}", c.impurity) for c in candidate_splits(X, y)], floatfmt=".3f")
1934    takeaway("'Known sender?' leaves the least mess (0.2), so it becomes the first question.")
1935
1936    banner("2. The grown tree, as rules a person can read")
1937    tree = DecisionTree(max_depth=2).fit(X, y)
1938    print("\n".join(tree.rules(names, ["ham", "spam"])))
1939    print()
1940    say("Two questions sort all eight emails. The whole model is those five lines.")
1941
1942    banner("3. Overfitting: depth against training and validation accuracy")
1943    table(
1944        ["depth limit", "leaves", "train acc", "val acc"],
1945        [(r["depth"] or "none", r["leaves"], r["train_accuracy"], r["val_accuracy"]) for r in depth_sweep()],
1946        floatfmt=".3f",
1947    )
1948    takeaway("With no limit the tree scores 100% on training data and worst on validation: it memorised the noise.")
1949
1950    banner("4. Random forests: bootstrap samples, random features, one vote")
1951    say(
1952        f"""
1953        A bootstrap sample of n rows leaves each row out with chance
1954        (1 - 1/n)^n: {out_of_bag_fraction(8):.4f} for n = 8, approaching 1/e =
1955        {1 / math.e:.4f}. Averaging B trees with correlation 0.3 leaves variance
1956        {averaged_variance(1.0, 0.3, 10):.2f} at B = 10 and
1957        {averaged_variance(1.0, 0.3, 1000):.4f} at B = 1000: the correlation is the floor.
1958        """
1959    )
1960    table(["trees", "val acc", "one deep tree"], [(r["n_trees"], r["val_accuracy"], r["single_tree"]) for r in forest_curve()], floatfmt=".3f")
1961    takeaway("Many deep, decorrelated trees averaged together keep their low bias and lose much of their variance.")
1962
1963    banner("5. Gradient boosting by hand: four houses, one round")
1964    ex = boosting_worked_example()
1965    table(
1966        ["size", "price y", "F0", "residual", "stump h1", "F1 = F0 + 0.5·h1"],
1967        [(s, p, ex["F0"], r, h, f) for s, p, r, h, f in zip((1, 2, 3, 4), (1, 2, 6, 7), ex["residuals"], ex["stump"], ex["F1"])],
1968        floatfmt=".2f",
1969    )
1970    say(f"Mean squared error falls from {ex['mse'][0]} to {ex['mse'][1]} in one round.")
1971    for row in boosting_curves():
1972        say(
1973            f"""
1974            Learning rate {row['learning_rate']:g}: best validation MSE
1975            {min(row['val_loss']):.3f} at round {row['best_round']}; by round 200 it
1976            is {row['val_loss'][-1]:.3f} while training MSE is {row['train_loss'][-1]:.3f}.
1977            """
1978        )
1979    takeaway("Each tree fits what is still wrong. Smaller steps take longer and generalize better; too many rounds memorise.")
1980
1981    banner("6. When trees win: a loan table")
1982    scores = tabular_showdown()
1983    table(["model", "val accuracy"], list(scores.items()), floatfmt=".4f")
1984    say(
1985        """
1986        The trees took the table as it came: dollars, years, counts and region
1987        codes. The neural net learned nothing from raw inputs (it matches
1988        always predicting 'fine'), improved once scaled, and improved again
1989        once income was logged and regions one-hot encoded, and still trailed.
1990        """
1991    )
1992    Xt, yt, Xv, yv, tab_names = make_tabular()
1993    forest = RandomForest(n_trees=30).fit(Xt, yt)
1994    table(
1995        ["feature", "impurity importance", "permutation importance"],
1996        list(zip(tab_names, forest.feature_importances, permutation_importance(forest, Xv, yv))),
1997        floatfmt=".3f",
1998    )
1999    takeaway(
2000        "Impurity importance gives the pure-noise column real credit; shuffling it on held-out data shows the model "
2001        "barely relies on it. Check importances on data the model has not seen."
2002    )