At a glance
Key takeaways
- 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.
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.
Chapter 1
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.
| 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:
Figure 1 · Diagram
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)"]
In code: spam_emails returns the table above; DecisionTree grows the tree, and DecisionTree.rules prints it as nested if/else lines.
Chapter 2
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
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the Gini impurity of one pile of examples: 0 when pure, larger when mixed | 0.5 for the eight emails | |
| how many different labels there are | 2 (spam, not spam) | |
| a counter that walks over the labels | 1 = not spam, 2 = spam | |
| the share of the pile carrying label | , | |
| the chance two random picks both have label | ||
| "add up over every label": the chance two picks match | ||
| turns "chance they match" into "chance they disagree" |
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: . A pure pile: .
Level 3: 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 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
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the entropy of one pile, in bits (yes/no questions) | 1 for the eight emails | |
| the share of the pile carrying label , as above | 0.2 and 0.8 | |
| the logarithm base 2: the power you raise 2 to in order to get . It is negative for shares below 1 | , | |
| 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 bit of surprise.
With the numbers: eight emails: 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
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 in bits; DecisionTree takes criterion="entropy" to split by it instead of Gini.
Chapter 3
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
Symbols
| Symbol | Meaning here | For "known sender?" |
|---|---|---|
| , | the left pile (answer yes) and the right pile (answer no) | known / unknown senders |
| , | how many examples land in each pile | 3 and 5 |
| how many examples reached this node, | 8 | |
| , | the Gini impurity of each pile (the formula above) | 0 and 0.32 |
| the left pile's share of the examples: its weight | 3/8 | |
| 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\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 .
Level 3: 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.
Figure 2 · Chart
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
In code: split_impurity is ; 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.
Chapter 4
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.
Figure 3 · Diagram
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
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:
Figure 4 · Chart
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
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.
Chapter 5
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.
Figure 5 · Chart
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
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.
Chapter 6
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:
- Bagging (bootstrap aggregating): each tree trains on its own bootstrap sample, a resample of the training rows drawn with replacement.
- 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.
Figure 6 · Diagram
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"]
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 , and missed on all draws with:
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the number of rows, and also the number of draws | 8 | |
| the chance one draw picks this particular row | 1/8 | |
| the chance one draw misses it | 7/8 | |
| missing it on every one of the independent draws: multiply the chances | ||
| "gets closer and closer to, as grows" | ||
Euler's number, ≈ 2.718 (see primer.notation) |
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: for the eight emails; for 100,000 rows, , already equal to to four places.
Level 3: 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
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the number of trees | 10 | |
| tree number 's prediction for one input | 0.9, 0.2, 0.7, … | |
| the forest's prediction: the average of all trees | 0.6 | |
| variance: how much a prediction would swing if the training data were redrawn | ||
| the variance of one tree on its own | 1 (a unit, for comparison) | |
| 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 and : ten trees give ; a thousand trees give . Going from 10 to 1,000 trees barely helps; lowering would. With fully independent trees (), ten trees would cut the variance to 0.1.
Level 3: 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 down, which is the only way to push the floor down. Each tree gets a little worse; the average gets better.
Figure 7 · Chart
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%
Figure 8 · Chart
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%
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.
Chapter 7
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.
Figure 9 · Diagram
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)"]
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 :
| size | price | residual | stump | 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 |
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
Symbols
| Symbol | Meaning here | For house 4 |
|---|---|---|
| the true value | 7 | |
| the model's current prediction | ||
| the loss: half the squared miss (the ½ only tidies the slope) | ||
| the derivative: how fast the loss changes as the prediction is nudged up | ||
| the direction that lowers the loss fastest, called the negative gradient | 3 | |
| the residual: what each tree is trained to predict | 3 | |
| "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 and . Nudge 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 .
Level 3: 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),
is a score in log-odds and the negative gradient is , the
label minus the predicted probability ( 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 for loss="squared" and for loss="log".
The update, and the learning rate
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| one input (a house's size) | size 4 | |
| the round number, 1, 2, …, | 1 | |
| the model's prediction before this round | ||
| this round's small tree, trained on the residuals | ||
| the learning rate (Greek letter eta), also called shrinkage: the fraction of each tree's correction actually applied | 0.5 | |
| 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: ; small houses: . Mean squared error before: ; after: $(1.75^2 + 0.75^2 + 0.75^2 + 1.75^2)/4 = 1.8125$.
Level 3: 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 ()? 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.
Figure 10 · Chart
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
Figure 11 · Chart
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
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.
Chapter 8
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% |
Figure 12 · Chart
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
Figure 13 · Diagram
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"]
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
Symbols
| Symbol | Meaning here | For late payments |
|---|---|---|
| the feature (column) being tested | late_payments | |
| held-out rows the model never trained on | 400 rows | |
| 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 |
| the permutation importance of feature : 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 ; noise .
Level 3: 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
Figure 14 · Chart
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
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.
Test yourself
11 questions
Answer each one out loud or on paper before you open it. If you can explain it, you know it.
Question 1Why is Gini impurity 0.5 for a pile that is half spam and half not?Think it through, then reveal
It is the chance that two emails drawn at random (with replacement) carry different labels: . For two labels that is the most mixed a pile can be.
Question 2Why weight each child pile by its size when scoring a split?Think it through, then reveal
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.
Question 3Why does a tree need no feature scaling, when a neural network does?Think it through, then reveal
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.
Question 4A tree scores 100% on training data and 80% on validation. What happened, and what are two fixes?Think it through, then reveal
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.
Question 5Why does a random forest use random feature subsets, not just bootstrap samples?Think it through, then reveal
The forest's variance is . More trees only shrink the second term; the floor is set by the correlation 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 .
Question 6Does adding more trees overfit a random forest? Does adding more rounds overfit gradient boosting?Think it through, then reveal
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.
Question 7Why is the residual the right target for each boosting tree?Think it through, then reveal
For squared-error loss , the negative derivative with respect to the prediction is , 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 for log loss.
Question 8What does a smaller learning rate buy in gradient boosting, and what does it cost?Think it through, then reveal
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.
Question 9Why can't a boosted-tree model predict a house price above the highest price it trained on?Think it through, then reveal
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.
Question 10What is wrong with trusting impurity-based feature importance?Think it through, then reveal
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.
Question 11When would you choose a neural network over gradient-boosted trees?Think it through, then reveal
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.
Primary sources
The papers behind this lesson
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.
The paper ↗ID3, which grows trees by choosing the split with the largest information gain (drop in entropy).
The paper ↗Showed that averaging models trained on bootstrap samples reduces the error of unstable learners such as trees.
The paper ↗Added random feature subsets at each split, out-of-bag error estimates, and permutation importance.
Read the annotated companion →The paper ↗Framed boosting as gradient descent in function space, fitting each tree to the negative gradient of any differentiable loss, with shrinkage.
Read the annotated companion →The paper ↗A regularized, second-order boosting objective with fast, sparsity-aware split finding, which made boosted trees the default on tabular problems.
Read the annotated companion →The paper ↗A benchmark on 45 medium-sized tabular datasets where tuned tree ensembles beat tuned neural networks, and an analysis of why.
The paper ↗Researcher's shelf
Further reading
- scikit-learn user guide, Decision Trees: https://scikit-learn.org/stable/modules/tree.html
- scikit-learn user guide, Ensembles: gradient boosting, random forests, bagging: https://scikit-learn.org/stable/modules/ensemble.html
- scikit-learn user guide, Permutation feature importance: https://scikit-learn.org/stable/modules/permutation_importance.html
- XGBoost documentation, Introduction to Boosted Trees: https://xgboost.readthedocs.io/en/stable/tutorials/model.html
- 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/
- Strobl et al., Bias in random forest variable importance measures (2007): https://doi.org/10.1186/1471-2105-8-25
- Grinsztajn et al., Why do tree-based models still outperform deep learning on tabular data? (2022): https://arxiv.org/abs/2207.08815
About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit c8d5c21, so the two always agree: the explanation, the code that builds it and the tests that prove it.