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.
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.
2. The math
rain > 0.5? yes: read
no: wind > 0.6? yes: kite
no: picnicG = 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: 0H = −Σ 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 = 0after '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.3006gini: 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.7510rain ≤ 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%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.1max 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%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 alltest 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%• 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
4. The code (python)
# 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.
6. Go further
- 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.
- 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?
- 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
In the catalog
Finished this chapter?
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.