Learn machine learning from scratch

Start at chapter 1 and build up: what a model is, how it measures its own mistakes, and how it learns. Everything is worked out in plain Python, with the maths shown rather than assumed. Two shorter tracks hold reference notes on the maths and on modern architectures.

Track Progress: 0 Completed
0%
Core CourseLEVEL 2 · INTERMEDIATEChapter 12

Twenty Questions: Decision Trees

A different kind of model: no knobs and no gradients, just yes-or-no questions found one at a time. On Chapter 9's activities a two-question tree finds the hidden rule, and like any flexible model it memorizes when it is allowed to keep growing.

48 min Prerequisites: Chapters 3 and 9 (cross-entropy, the activities) and Chapter 11 (validation and test sets)
Inspect Architecture: Decision tree (CART, ID3, C4.5)

1. The idea

Every model so far has been a function with knobs, trained by nudging the knobs downhill. A decision tree is a different kind of model. It is a list of yes-or-no questions about the input, each answer leading to the next question, until it reaches a leaf that gives the prediction. Is the rain above 0.5? Then they stayed in to read. If not, is the wind above 0.6? Then they flew a kite, and otherwise they had a picnic. That happens to be exactly the rule Chapter 9 used to label its 150 days, before one day in ten was replaced by something random.

A tree is grown one question at a time. To choose a question we need a number for how mixed a group of days is, and two are in common use. Gini impurity is the chance that two days drawn at random did different things: 0.6308 for the 150 days (48 picnics, 31 kites, 71 reads) and 0 for a group that all did the same thing. Entropy is Chapter 3's cross-entropy of the group against its own proportions, 1.0445 nats here. The best question is the one whose two answers leave the least mixed groups. Trying every threshold on every feature, the winner is 'is the rain at most 0.505?', which brings the size-weighted Gini down from 0.6308 to 0.3006. The best question about the sun only reaches 0.6173, because the sun has nothing to do with the labels.

Repeat inside each group and a depth-2 tree asks about the wind at 0.610 next, recovering the rule's thresholds of 0.5 and 0.6 from noisy data. It gets 96.7% of its training days and 93.5% of 1,000 new days right, against 94.1% for the rule itself. Nothing in it was trained by gradient descent, and nothing could be: a tree's output is the same everywhere inside a box, so nudging an input changes the prediction by exactly 0 and the gradient Chapter 4 relies on is 0 too. Training is a search over questions instead.

Trees overfit like everything else in Chapter 11. Grown until every leaf is pure, the tree has 15 leaves for 150 days, gets all of them right and only 88.3% of the new days, because several leaves hold a single noisy day. Limiting the depth, or refusing any question that leaves only a few days on one side, is a tree's regularization. On the test set the depth-2 tree scores 91.7%, ahead of Chapter 11's best network on the same 150 days (88.4%): the rule is made of threshold questions, which is exactly what a tree asks. Trained on 1,500 days it scores 92.6%, the rule's own score. The capstone puts a tree against a neural network on text, where the picture changes.

Two charts. Left, Chapter 9's 150 training days plotted by rain (across) and wind (up), marked as picnic, kite or read. A dashed vertical line at rain 0.505 separates the read days on the right from the rest; on its left, a dashed horizontal line at wind 0.61 separates kite days above from picnic days below. Right, accuracy against the tree's maximum depth from 1 to 5: on the training days it climbs from 76.7% to 100%; on 1,000 new days it rises to 93.5% at depth 2, just under the rule's 94.1%, then falls to 88.3% at depth 5.Two charts. Left, Chapter 9's 150 training days plotted by rain (across) and wind (up), marked as picnic, kite or read. A dashed vertical line at rain 0.505 separates the read days on the right from the rest; on its left, a dashed horizontal line at wind 0.61 separates kite days above from picnic days below. Right, accuracy against the tree's maximum depth from 1 to 5: on the training days it climbs from 76.7% to 100%; on 1,000 new days it rises to 93.5% at depth 2, just under the rule's 94.1%, then falls to 88.3% at depth 5.
Two questions carve the weather into three boxes that match the rule behind the labels. Letting the tree keep asking questions makes it perfect on its 150 days and worse on new ones.

2. The math

A tree is a list of questions
rain > 0.5? yes: read no: wind > 0.6? yes: kite no: picnic
This is the rule that labelled Chapter 9's days. Each question looks at one feature and one threshold, so the leaves are boxes in the input space, as the figure shows.
How mixed is a group: Gini impurity
G = 1 − Σ p_k² p_k = share of the group in class k 150 days, 48 / 31 / 71: G = 0.6308 an even 3-way mix: 0.6667 45 / 5 / 0: 0.1800 one class: 0
Σ p_k² is the chance that two days drawn at random (with replacement) did the same thing, so G is the chance they did different things.
Entropy, in nats
H = −Σ p_k · ln p_k 150 days: H = 1.0445 an even 3-way mix: ln 3 = 1.0986 45 / 5 / 0: H = 0.3251 one class: H = 0
The cross-entropy of Chapter 3, measured against the group's own proportions: the average surprise of a day drawn from the group. Like G it is 0 only for a pure group and largest for an even mix.
Scoring a question
after 'is x_f ≤ t?': score = (n_L/n)·G(left) + (n_R/n)·G(right) rain ≤ 0.505: 78 days 46/30/2, G 0.5036; 72 days 2/1/69, G 0.0806 score = (78/150)·0.5036 + (72/150)·0.0806 = 0.3006
Each group's impurity counts in proportion to its size. The parent's G is 0.6308, so this question removes more than half the mix. The first derivation shows that no question can make the weighted score worse.
The best question about each feature
gini: sun ≤ 0.615: 0.6173 rain ≤ 0.505: 0.3006 wind ≤ 0.615: 0.5100 entropy: sun ≤ 0.615: 1.0224 rain ≤ 0.550: 0.4907 wind ≤ 0.615: 0.7510
Both measures pick rain first. They disagree only on where to cut: four training days have rain between 0.505 and 0.550, and the two measures weigh their mixed labels differently.
The depth-2 tree finds the rule
rain ≤ 0.505? yes: wind ≤ 0.610? yes: picnic 46/1/0 no: kite 0/29/2 no: sun ≤ 0.005? yes: picnic 1/0/0 no: read 1/1/69 train 96.7% new days 93.5% the rule itself 94.1%
The thresholds land next to the rule's 0.5 and 0.6. The fourth question is already memorizing: it carves out a single picnic on a day with no sun at all.
Leaves give probabilities
p_k = (count_k + α) / (n + 3α) leaf 46/1/0 with α = 1: picnic 0.94 kite 0.04 read 0.02 loss on new days: 0.286 with α = 1, 0.318 with α = 0.1
A leaf predicts the proportions of its training days. α adds pretend days of every class, so a class never seen in a leaf still gets a little probability, and Chapter 3's cross-entropy stays finite.
Deeper trees memorize
max depth 1: 2 leaves train 76.7% new days 77.3% max depth 2: 4 leaves train 96.7% new days 93.5% max depth 4: 12 leaves train 98.0% new days 89.9% no limit: 15 leaves train 100.0% new days 88.3%
Grown until every leaf is pure, the tree stops at depth 5. Refusing any question that leaves fewer than 5 to 20 days on one side works as well as a depth limit here: 93.7% on the new days.
No gradient to follow
inside each box the prediction is constant: ∂p/∂x = 0 nudge one day's rain by 0.0001: probabilities change by 0.0 at a threshold the prediction jumps: no derivative at all
Chapter 4's nudging finds nothing to follow, so trees are grown by search: try every feature and threshold, keep the best, repeat. The thresholds are found, never trained.
The final exam, against Chapter 11's network
test accuracy (the rule: 92.6%) tree, depth 2, 150 days: 91.7% grown fully: 85.0% network, L2, 150 days: 88.4% on 1,500 days: 90.8% tree, depth 2, 1,500 days: 92.6% grown fully: 86.0%
When the truth is a few threshold questions, a tree finds it from less data than a network. More data doesn't rescue the fully grown tree: it just grows 166 leaves to memorize the extra noise.

• Split a group into a left part with a share w of its examples and a right part with share 1 − w. For each class k the parent's proportion is the weighted average of the children's: p_k = w·a_k + (1 − w)·b_k.

• Squaring is convex: the square of an average is at most the average of the squares. So p_k² = (w·a_k + (1 − w)·b_k)² ≤ w·a_k² + (1 − w)·b_k², with equality only when a_k = b_k.

• Add over k and subtract from 1: G(parent) = 1 − Σ p_k² ≥ w·(1 − Σ a_k²) + (1 − w)·(1 − Σ b_k²) = w·G(left) + (1 − w)·G(right). ∎

• So every question scores at most the parent's impurity, and it scores exactly the parent's only when both children have the parent's mix. That is the stopping rule in grow_tree: if no question beats the parent, make a leaf.

• The same argument works for entropy, because −p·ln p is concave too.

• A leaf predicts its most common class, so the number of mistakes in a group is the count of everything else. Take a group of 12 days, 10 picnics and 2 kites: 2 mistakes, an error rate of 2/12.

• Split it into 6 picnics with 2 kites, and 4 picnics alone. Both children still predict picnic, so the mistakes are the same 2 kites: the error rate stays 2/12 and the question looks useless.

• Gini sees the progress. The parent has G = 1 − (100 + 4)/144 = 0.2778. The children have G = 1 − (36 + 4)/64 = 0.375 on 8 days and 0 on 4 days, a weighted score of (8/12)·0.375 = 0.25.

• In general, a split that leaves the majority class unchanged in both children never changes the error count, however much purer it makes one of them. ∎

• Such splits are often the first step towards a good tree: the pure group is finished, and the next question can work on the mixed one. Gini and entropy reward them; counting mistakes can't.

3. How it works

1
Measure the mix
Count each class in the group and compute its Gini impurity, 1 − Σ p². A pure group scores 0 and needs no more questions.
2
Try every question
For each feature, sort its distinct values and try a threshold halfway between each neighbouring pair. Score each question by the size-weighted impurity of its two answers and keep the lowest.
3
Split and repeat
Send the 'yes' rows left and the 'no' rows right, and grow each side the same way. Stop when a group is pure or no question makes it purer.
4
Stop before it memorizes
Cap the depth, or refuse questions that leave only a few examples on one side, and choose the limit by the validation score as in Chapter 11.
5
Read the leaves
Each leaf predicts the class proportions of its training examples. Add a small α of every class if you will score the probabilities with cross-entropy.

4. The code (python)

cart.py
# Chapter 12: a different kind of model. A decision tree learns by asking questions, not by turning knobs.
# Plain NumPy: no gradients anywhere, so no tensor.py this time. Save gini, best_split, grow_tree,
# predict_proba and count_leaves as tree.py, with 'import numpy as np' at the top: the capstone uses them.
import numpy as np

ACTIVITIES = ["picnic", "kite", "read"]
FEATURES = ["sun", "rain", "wind"]


def make_days(n, seed):
    """Chapter 9's process: read if rainy, else kite if windy, else picnic; 1 day in 10 at random."""
    r = np.random.default_rng(seed)
    X = np.round(r.random((n, 3)), 2)
    rule = np.where(X[:, 1] > 0.5, 2, np.where(X[:, 2] > 0.6, 1, 0))
    labels = rule.copy()
    noisy = r.random(n) < 0.1
    labels[noisy] = r.integers(0, 3, noisy.sum())
    return X, labels, rule

X_train, y_train, _ = make_days(150, 9)            # Chapter 9's 150 days
X_val, y_val, rule_val = make_days(1000, 99)       # Chapter 11's validation days
X_test, y_test, rule_test = make_days(1000, 123)   # and its test days, looked at once at the end

# === 1. How mixed is a group? ======================================================
def gini(counts):
    """Chance that two days drawn at random (with replacement) from the group did different things."""
    p = counts / counts.sum()
    return 1.0 - np.sum(p ** 2)


def entropy(counts):
    """Chapter 3's cross-entropy of the group against its own proportions, in nats."""
    p = counts[counts > 0] / counts.sum()
    return float(-np.sum(p * np.log(p))) + 0.0         # + 0.0 turns -0.0 into 0.0

root = np.bincount(y_train, minlength=3)
print("150 days:", dict(zip(ACTIVITIES, root.tolist())))
print(f"gini {gini(root):.4f}   entropy {entropy(root):.4f} nats   (an even 3-way mix: {gini(np.ones(3)):.4f}, {np.log(3):.4f})")
for name, c in [("all one activity", np.array([50, 0, 0])), ("45 / 5 / 0", np.array([45, 5, 0]))]:
    print(f"  {name:17} gini {gini(c):.4f}   entropy {entropy(c):.4f}")

# === 2. The tree: find the best question, split, repeat (save this part as tree.py) ===
def best_split(X, y, n_classes, min_leaf):
    """Try every feature and every threshold between two neighbouring values. Score = the children's gini,
    weighted by size. Counting is done once per distinct value, so a split costs O(n) per feature."""
    best = None
    n = len(y)
    for f in range(X.shape[1]):
        values, which = np.unique(X[:, f], return_inverse=True)
        if len(values) < 2:
            continue
        table = np.bincount(which * n_classes + y, minlength=len(values) * n_classes).reshape(len(values), n_classes)
        left = np.cumsum(table, axis=0)[:-1]          # class counts with x <= each value (all but the last)
        right = left[-1] + table[-1] - left
        nl, nr = left.sum(1), right.sum(1)
        ok = (nl >= min_leaf) & (nr >= min_leaf)
        if not ok.any():
            continue
        score = (nl - (left ** 2).sum(1) / np.maximum(nl, 1) + nr - (right ** 2).sum(1) / np.maximum(nr, 1)) / n
        score[~ok] = np.inf
        i = int(np.argmin(score))
        if best is None or score[i] < best[0]:
            best = (float(score[i]), f, float((values[i] + values[i + 1]) / 2))
    return best


def grow_tree(X, y, n_classes, max_depth=None, min_leaf=1, depth=0):
    """A node is a dict. A leaf holds the class counts of its training examples; a question node
    holds the feature, the threshold, and two subtrees: x[f] <= t goes left."""
    counts = np.bincount(y, minlength=n_classes)
    node = {"counts": counts}
    if (max_depth is not None and depth >= max_depth) or counts.max() == len(y):
        return node
    split = best_split(X, y, n_classes, min_leaf)
    if split is None or split[0] >= gini(counts) - 1e-12:   # no question makes the groups purer
        return node
    _, f, t = split
    go_left = X[:, f] <= t
    node.update(feature=f, threshold=t,
                left=grow_tree(X[go_left], y[go_left], n_classes, max_depth, min_leaf, depth + 1),
                right=grow_tree(X[~go_left], y[~go_left], n_classes, max_depth, min_leaf, depth + 1))
    return node


def predict_proba(node, X, alpha=0.0):
    """Send every row down the tree; each gets its leaf's class proportions. alpha adds that many
    pretend examples of every class, so no class ever gets probability exactly 0."""
    out = np.zeros((len(X), len(node["counts"])))
    rows = [(node, np.arange(len(X)))]
    while rows:
        nd, idx = rows.pop()
        if "feature" not in nd:
            c = nd["counts"] + alpha
            out[idx] = c / c.sum()
            continue
        go_left = X[idx, nd["feature"]] <= nd["threshold"]
        rows += [(nd["left"], idx[go_left]), (nd["right"], idx[~go_left])]
    return out


def count_leaves(node):
    return 1 if "feature" not in node else count_leaves(node["left"]) + count_leaves(node["right"])


def tree_depth(node):
    return 0 if "feature" not in node else 1 + max(tree_depth(node["left"]), tree_depth(node["right"]))

# === 3. The first question, and a depth-2 tree ======================================
def split_score(X, y, f, t, measure):
    """The two groups' impurity after asking 'is x[f] <= t?', each weighted by its share of the days."""
    left = X[:, f] <= t
    return sum(part.mean() * measure(np.bincount(y[part], minlength=3)) for part in (left, ~left) if part.any())

score, f, t = best_split(X_train, y_train, 3, 1)
print(f"\nbest first question: is {FEATURES[f]} <= {t:.3f}?   children's gini {score:.4f} (was {gini(root):.4f})")
print("best question about each feature, by gini and by entropy:")
for g in range(3):
    values = np.unique(X_train[:, g])
    thresholds = (values[:-1] + values[1:]) / 2
    for measure in (gini, entropy):
        s = [split_score(X_train, y_train, g, thr, measure) for thr in thresholds]
        i = int(np.argmin(s))
        print(f"  {FEATURES[g]:5} {measure.__name__:8} <= {thresholds[i]:.3f}   children {s[i]:.4f}"
              f"   (parent {measure(root):.4f})")


def show(node, depth=0, answer=""):
    pad = "    " * depth + answer
    if "feature" not in node:
        c = node["counts"]
        print(f"{pad}{ACTIVITIES[int(c.argmax())]}  {c.tolist()}")
        return
    print(f"{pad}{FEATURES[node['feature']]} <= {node['threshold']:.3f}?")
    show(node["left"], depth + 1, "yes: ")
    show(node["right"], depth + 1, "no:  ")

small = grow_tree(X_train, y_train, 3, max_depth=2)
print("\nthe depth-2 tree:")
show(small)


def accuracy(node, X, y):
    return float(np.mean(predict_proba(node, X).argmax(1) == y))


def log_loss(node, X, y, alpha=1.0):
    return float(-np.mean(np.log(predict_proba(node, X, alpha)[np.arange(len(y)), y])))

print(f"train {accuracy(small, X_train, y_train):.1%}   validation {accuracy(small, X_val, y_val):.1%}"
      f"   (the rule itself: {np.mean(rule_val == y_val):.1%})")

# === 4. Deeper trees memorize ==========================================================
print("\nmax depth   leaves   train acc   val acc   val loss (alpha = 1)")
for d in [1, 2, 3, 4, 5, None]:
    tr = grow_tree(X_train, y_train, 3, max_depth=d)
    print(f"{str(d) if d else 'no limit':>9}   {count_leaves(tr):6}   {accuracy(tr, X_train, y_train):9.1%}"
          f"   {accuracy(tr, X_val, y_val):7.1%}   {log_loss(tr, X_val, y_val):.3f}")
full = grow_tree(X_train, y_train, 3)
print(f"grown until every leaf is pure: depth {tree_depth(full)}, {count_leaves(full)} leaves for 150 days")

print("\nor stop splitting groups smaller than min_leaf:")
for m in [1, 5, 10, 20, 40]:
    tr = grow_tree(X_train, y_train, 3, min_leaf=m)
    print(f"  min_leaf {m:3}: {count_leaves(tr):3} leaves   train {accuracy(tr, X_train, y_train):.1%}"
          f"   validation {accuracy(tr, X_val, y_val):.1%}")

# === 5. The final exam, against Chapter 11's network ===================================
X_more, y_more, _ = make_days(1500, 10)            # Chapter 11's 1,500 days
print(f"\ntest set (Chapter 11: rule {np.mean(rule_test == y_test):.1%}, best network on 150 days 88.4%, "
      "unregularized network on 1,500 days 90.8%):")
for name, tr in [("depth 2, 150 days", small), ("no limit, 150 days", full),
                 ("depth 2, 1,500 days", grow_tree(X_more, y_more, 3, max_depth=2)),
                 ("no limit, 1,500 days", grow_tree(X_more, y_more, 3))]:
    print(f"  {name:22} validation {accuracy(tr, X_val, y_val):.1%}   test {accuracy(tr, X_test, y_test):.1%}"
          f"   leaves {count_leaves(tr)}")

# === 6. Why there is no gradient =======================================================
day = X_val[:1].copy()
p0 = predict_proba(small, day)[0]
day[0, 1] += 1e-4                                   # nudge the rain a little, as in Chapter 4
print(f"\nnudging one day's rain by 0.0001 changes its probabilities by {np.abs(predict_proba(small, day)[0] - p0).max():.1f}")

5. Practice

Work these out on paper (or in Python) and type the number. Answers are checked with a small tolerance for rounding.

P1 A leaf holds 46 picnics and 1 kite. What is its Gini impurity?
G = 1 − (46/47)² − (1/47)².
Solution. 1 − (2116 + 1)/2209 = 92/2209 = 0.0416: almost pure.
P2 What is the entropy, in nats, of a group split evenly between two classes?
H = −Σ p·ln p with p = 0.5 twice.
Solution. −2 · 0.5 · ln 0.5 = ln 2 = 0.693 nats, the most mixed two classes can be.
P3 A question sends 78 of 150 days to a group with G = 0.5036 and the other 72 to a group with G = 0.0806. What is its score?
Weight each group's impurity by its share of the days.
Solution. (78/150) · 0.5036 + (72/150) · 0.0806 = 0.2619 + 0.0387 = 0.3006, the chapter's best first question.
P4 A feature takes the 101 values 0.00, 0.01, …, 1.00 in a group. How many thresholds does best_split try for it?
One threshold between each neighbouring pair of distinct values.
Solution. 100. Any threshold between the same two neighbours splits the rows the same way, so one per gap is enough.
P5 At most how many leaves can a tree of depth 5 have?
Every question doubles the number of paths.
Solution. 2⁵ = 32. The chapter's fully grown tree is depth 5 with 15 leaves, because most branches become pure sooner.
P6 A leaf holds 46 picnics, 1 kite and 0 reads. With α = 1, what probability does it give to a kite?
p = (count + α) / (n + 3α).
Solution. (1 + 1) / (47 + 3) = 0.04. Without α it would say 1/47 = 0.021, and a read would get exactly 0.
P7 The read leaf of the depth-2 tree holds 1 picnic, 1 kite and 69 reads. Without α, what probability does it give to reading?
The leaf's share of reads.
Solution. 69/71 = 0.9718.
P8 How many numbers does the depth-2 tree store, counting a feature number and a threshold for each question and three counts for each leaf?
3 questions and 4 leaves.
Solution. 3 × 2 + 4 × 3 = 18 numbers, against 451 knobs for Chapter 11's network, and it scores better on these days.

6. Go further

  1. Run grow_tree on Chapter 8's four XOR switch settings (inputs (0, 0), (0, 1), (1, 0), (1, 1), light off, on, on, off). What does it return, and why? Then force the first split anyway, grow both sides, and explain what this says about growing a tree greedily, one best question at a time.
  2. Grow a regression tree for Chapter 11's twelve rides: predict minutes from distance, score a question by the size-weighted variance of the minutes in its two groups, and let each leaf predict its average. Compare the validation error on the other 228 rides at depths 1 to 4 with the straight line's 2.18. Why can't a tree predict a 15 km ride well?
  3. Grow 50 fully grown trees, each on 150 days drawn at random with replacement from the training days, and let them vote. This is bagging; a random forest also lets each split look at only a random subset of the features. Does the vote beat a single fully grown tree's 85.0% on the test set, and why does averaging help a model that memorizes?

7. Check yourself

Answer all 5 questions correctly to complete the chapter · 0 / 5 done
Q1/5 Why can't gradient descent train a decision tree?
Q2/5 What is the Gini impurity of a group in which every example has the same class?
Q3/5 A tree grown until every leaf is pure gets 100% of its training days right but only 88.3% of new days. What is the tree's version of Chapter 11's fixes?
Q4/5 On the activities, the depth-2 tree beats Chapter 11's best network on the test set (91.7% against 88.4%). Why?
Q5/5 Grown on Chapter 8's four XOR points, grow_tree returns a single leaf. Why?

Finished this chapter?

Weekly newsletter

What changed in ML architectures this week

New models worth knowing, benchmark moves, and one architecture explained in plain words. Free, once a week, unsubscribe anytime.