Lab 09
Decision Trees
Overview of the lab
What are decision trees used for?
A decision tree is a supervised model that predicts by asking a sequence of simple yes/no (or multiple-choice) questions about the features. Each answer sends the example down one branch, until it reaches a leaf that holds the prediction. You saw the animal tree in Lab 06. Trees are used for both kinds of supervised task:
- Classification tree: the leaf predicts a class, the majority class of the training examples that reach it. Example: will we play tennis today, Yes or No?
- Regression tree: the leaf predicts a number, the mean target value of the training examples that reach it. Example: the price of a flat from its size.
Anatomy of a tree
- Root node: the first question; all examples start here.
- Internal (decision) node: a test on one feature, e.g. \(x_1 \le 4.5\) or "Outlook = ?".
- Branch: one outcome of the test.
- Leaf (terminal) node: no more questions, only the prediction.
- Depth: the number of questions on the longest root-to-leaf path.
See it: one idea, two tasks
Each question on a numeric feature cuts the feature space with a line parallel to an axis, so a tree splits the space into rectangles and gives one answer per rectangle. Switch between the two tasks and click the plot to send a new example down the tree.
A decision tree in action: Play Tennis
The classic dataset: on 14 days we recorded the weather and whether tennis was played. Four categorical features, one class label.
| Day | Outlook | Temperature | Humidity | Wind | Play? |
|---|
From this data a learning algorithm (we will build it below) produces the following tree. It classifies all 14 days correctly and uses only three questions. Temperature is never asked: it does not help.
Try it: classify a day
Choose the weather, or click a row of the table above. The highlighted path shows the questions the tree asks.
💡 A tree is easy to read as a set of rules, one per leaf: IF Outlook = Sunny AND Humidity = Normal THEN Play = Yes. This interpretability is the main reason trees are popular.
How a tree is built: recursive splitting
Trees are learned top-down and greedily (algorithms ID3, C4.5, CART). Pick the best question for the current data, split the data by its answers, then solve each part the same way. It is a recursive divide-and-conquer algorithm.
BuildTree(S, features):
if all examples in S have the same class c: # base case: pure node
return Leaf(c)
if features is empty: # base case: nothing left to ask
return Leaf(majority class of S)
A ← the best feature to split S # "best" = makes the children purest
node ← Node(question on A)
for each value v of A:
S_v ← examples of S with A = v
if S_v is empty:
node.child[v] ← Leaf(majority class of S)
else:
node.child[v] ← BuildTree(S_v, features − {A}) # recursion
return node
- Greedy: each split is chosen by looking only at the current node. The algorithm never goes back to change an earlier question.
- Stopping: a branch ends when its node is pure (one class only) or there is nothing left to ask.
- Key question: how do we measure which feature is "best"? We need a number for impurity. That number is entropy.
Try it: grow the Play Tennis tree step by step
Each node shows its class counts [Yes, No]. The rows of the table that reach the current node are highlighted. Dashed nodes are children whose recursive call has not run yet; the call stack lists the calls that are currently active.
Call stack (active BuildTree calls)
Log
| Day | Outlook | Temp. | Humidity | Wind | Play? |
|---|
Entropy: expected surprise
A node is pure when it contains one class only: we are certain of the answer. It is most impure when the classes are mixed equally: we can only guess. Entropy measures this uncertainty. It is easiest to build from the idea of surprise.
Surprise of one outcome
How surprised are we to see an outcome with probability \(p\)? We want:
- Certain events are no surprise: \(p = 1 \Rightarrow\) surprise \(= 0\).
- Rare events are very surprising: as \(p \to 0\), surprise \(\to \infty\).
- Surprises add up: for two independent events, \(p = p_1 p_2\), and the surprise should be the sum of the two surprises. Only the logarithm turns products into sums.
With base 2 the unit is the bit: a fair coin flip gives \(-\log_2 \tfrac12 = 1\) bit of surprise.
Entropy = the average surprise
Before we look, we do not know which outcome will come. The entropy is the surprise we expect on average: each outcome's surprise weighted by its probability.
For a node \(S\) of a tree, \(p_i\) is the fraction of examples in \(S\) of class \(i\). By convention \(0 \cdot \log_2 0 = 0\).
- Minimum \(H = 0\): a pure node, one class with \(p = 1\). No surprise at all.
- Maximum \(H = \log_2 k\): all \(k\) classes equally likely. For two classes the maximum is 1 bit.
- A rare class has a large surprise but a small weight, so it adds little to the average.
- Play Tennis root, 9 Yes and 5 No: \(H = -\tfrac{9}{14}\log_2\tfrac{9}{14} - \tfrac{5}{14}\log_2\tfrac{5}{14} = 0.940\) bits.
Try it: entropy of a bag of marbles
Set how many marbles of each colour are in the bag (a node's class counts). Every colour sits on the surprise curve \(-\log_2 p\) at its probability; the bar below each point is its contribution \(p \cdot (-\log_2 p)\). The entropy is the sum of the bars. Start: the Play Tennis root, 9 and 5.
Try it: the two-class entropy curve
With two classes the entropy depends only on the fraction \(p\) of the first class: \(H(p) = -p\log_2 p - (1-p)\log_2(1-p)\). It is 0 at the pure ends and peaks at 1 bit for a 50/50 node.
💡 CART uses another impurity measure, the Gini impurity \(G = 1 - \sum_i p_i^2\) (the probability of mislabelling a random example if we label it randomly by the node's class distribution). It has the same shape: 0 when pure, largest when mixed. The dashed curve above shows \(2G\) for comparison. In practice both give very similar trees.
Exercises: entropy
Solve on paper first, then open the solution. Useful values: \(\log_2 3 \approx 1.585\), \(\log_2 5 \approx 2.322\), \(\log_2 6 \approx 2.585\), \(\log_2 7 \approx 2.807\). Check your numbers with the marble widget above.
1A node holds 8 examples of class Yes and 0 of class No. What is its entropy? What if it holds 4 Yes and 4 No?
[8, 0]: one class with \(p = 1\), so \(H = -1 \cdot \log_2 1 = 0\). A pure node.
[4, 4]: \(H = -\tfrac12\log_2\tfrac12 - \tfrac12\log_2\tfrac12 = \tfrac12 + \tfrac12 = 1\) bit, the maximum for two classes.
2A bag has 4 red, 2 blue and 2 green marbles. Compute the surprise of drawing each colour and the entropy of the bag.
\(p = \tfrac12, \tfrac14, \tfrac14\). Surprises: red \(-\log_2\tfrac12 = 1\) bit, blue and green \(-\log_2\tfrac14 = 2\) bits each.
3A biased coin lands heads with probability 0.9. Which outcome is more surprising? What is the entropy of one flip? Compare with a fair coin.
Heads: \(-\log_2 0.9 = 0.152\) bits. Tails: \(-\log_2 0.1 = 3.322\) bits, much more surprising.
A fair coin has 1 bit. The biased coin is more predictable, so on average it surprises us less: the big surprise of tails happens only 10% of the time.
4Compute the entropy of the Play Tennis node Outlook = Sunny, which holds 2 Yes and 3 No.
5Which node is more impure: [3 Yes, 1 No] or [6 Yes, 2 No]?
Neither. Both have \(p = \tfrac34, \tfrac14\), so both have \(H = \tfrac34 \cdot 0.415 + \tfrac14 \cdot 2 = 0.811\) bits. Entropy depends only on the proportions, not on the number of examples.
6What is the entropy of a fair six-sided die? Of a node with 3 classes, 5 examples each? Can a two-class node have entropy larger than 1?
Die: six outcomes with \(p = \tfrac16\), \(H = 6 \cdot \tfrac16 \log_2 6 = \log_2 6 \approx 2.585\) bits.
Three equal classes: \(H = \log_2 3 \approx 1.585\) bits.
No: with \(k\) classes \(H \le \log_2 k\), and \(\log_2 2 = 1\).
7A node holds 7 examples: 5 Yes and 2 No. You add one more Yes. Does the entropy go up or down? Compute both.
Before: \(H(\tfrac57) = -\tfrac57\log_2\tfrac57 - \tfrac27\log_2\tfrac27 = 0.347 + 0.516 = 0.863\) bits.
After, [6, 2]: \(H(\tfrac34) = 0.811\) bits. It goes down: the node moved further from 50/50, towards pure.
Information gain
A good question leaves the children purer than the parent. Information gain is the drop in entropy caused by a split: the parent's entropy minus the weighted average entropy of the children (weighted by how many examples go to each child).
\(S_v\) is the subset of \(S\) with \(A = v\). At every node, ID3 splits on the feature with the largest gain.
Worked example: the root of Play Tennis
\(H(S) = 0.940\) for [9 Yes, 5 No]. Splitting on Outlook:
- Sunny [2, 3]: \(H = 0.971\) · Overcast [4, 0]: \(H = 0\) · Rain [3, 2]: \(H = 0.971\)
Splitting on Wind: Weak [6, 2] with \(H = 0.811\), Strong [3, 3] with \(H = 1\):
| Feature | Children [Yes, No] | Weighted child entropy | Gain |
|---|---|---|---|
| Outlook | Sunny [2,3] · Overcast [4,0] · Rain [3,2] | 0.694 | 0.247 |
| Humidity | High [3,4] · Normal [6,1] | 0.788 | 0.152 |
| Wind | Weak [6,2] · Strong [3,3] | 0.892 | 0.048 |
| Temperature | Hot [2,2] · Mild [4,2] · Cool [3,1] | 0.911 | 0.029 |
Outlook wins, which is why it is the root. Then we repeat on each child: on the Sunny node, Humidity has gain 0.971 (both children pure). On the Rain node, Wind has gain 0.971.
💡 Information gain is the mutual information between the feature and the class: how many bits of uncertainty about the class we remove, on average, by learning the feature's value.
Try it: pick the split
Choose a node and a feature. Each child is drawn as a row of marbles (Yes / No) with its entropy; the bars on the right rank every feature by gain. Try Day at the end.
💡 Bias towards many-valued features. Day puts every example in its own child. Every child is pure, so the gain is the maximum possible (0.940), yet the question is useless for a new day. Drop ID-like features before training. C4.5 also divides the gain by the split information \(-\sum_v \frac{|S_v|}{|S|}\log_2\frac{|S_v|}{|S|}\) (the gain ratio), which penalises splits into many small children.
Numeric features: thresholds
For a numeric feature \(x\), the questions are of the form \(x \le t\). Sort the training values and try each threshold \(t\) halfway between two neighbouring values. Keep the one with the largest gain. This is how the trees in the first widget and in the overfitting section are grown. For regression trees the entropy is replaced by the variance (mean squared error) of the targets in a node.
Decision tree in Python
We implement ID3 from scratch, one function at a time, and check the numbers against the page. Each row is a dictionary, e.g. {"Outlook": "Sunny", …, "Play": "No"}.
1. The data
data.py Python
FEATURES = ["Outlook", "Temperature", "Humidity", "Wind"]
TARGET = "Play"
raw = """Sunny Hot High Weak No
Sunny Hot High Strong No
Overcast Hot High Weak Yes
Rain Mild High Weak Yes
Rain Cool Normal Weak Yes
Rain Cool Normal Strong No
Overcast Cool Normal Strong Yes
Sunny Mild High Weak No
Sunny Cool Normal Weak Yes
Rain Mild Normal Weak Yes
Sunny Mild Normal Strong Yes
Overcast Mild High Strong Yes
Overcast Hot Normal Weak Yes
Rain Mild High Strong No"""
rows = [dict(zip(FEATURES + [TARGET], line.split())) for line in raw.splitlines()]
2. Entropy
Count the classes, turn the counts into probabilities, sum \(-p \log_2 p\).
entropy.py Python
import math
from collections import Counter
def entropy(rows):
n = len(rows)
counts = Counter(r[TARGET] for r in rows)
return -sum(c / n * math.log2(c / n) for c in counts.values()) # classes with count 0 never appear
print(round(entropy(rows), 3)) # 0.94
3. Split and information gain
gain.py Python
def split(rows, feature):
groups = {} # value -> list of rows
for r in rows:
groups.setdefault(r[feature], []).append(r)
return groups
def information_gain(rows, feature):
n = len(rows)
children = split(rows, feature).values()
weighted = sum(len(ch) / n * entropy(ch) for ch in children)
return entropy(rows) - weighted
for f in FEATURES:
print(f, round(information_gain(rows, f), 3))
# Outlook 0.247
# Temperature 0.029
# Humidity 0.152
# Wind 0.048
4. Build the tree recursively (ID3)
A leaf is a class label (a string); an internal node is a dictionary {"feature": …, "children": {value: subtree}, "majority": …}. The code follows the pseudocode from the build section.
id3.py Python
def majority(rows):
return Counter(r[TARGET] for r in rows).most_common(1)[0][0]
def build_tree(rows, features, max_depth=None, depth=0):
classes = set(r[TARGET] for r in rows)
if len(classes) == 1: # pure node
return classes.pop()
if not features or (max_depth is not None and depth == max_depth):
return majority(rows) # nothing left to ask / depth limit
best = max(features, key=lambda f: information_gain(rows, f))
rest = [f for f in features if f != best]
return {
"feature": best,
"majority": majority(rows), # used for values unseen in training
"children": {v: build_tree(sub, rest, max_depth, depth + 1)
for v, sub in split(rows, best).items()},
}
tree = build_tree(rows, FEATURES)
print(tree)
# {'feature': 'Outlook', 'majority': 'Yes', 'children': {
# 'Sunny': {'feature': 'Humidity', 'majority': 'No', 'children': {'High': 'No', 'Normal': 'Yes'}},
# 'Overcast': 'Yes',
# 'Rain': {'feature': 'Wind', 'majority': 'Yes', 'children': {'Weak': 'Yes', 'Strong': 'No'}}}}
💡 split only creates children for values that occur in the node, so the "empty \(S_v\)" case of the pseudocode never happens here. A value that was not seen in training is handled at prediction time by the stored majority.
5. Predict and print
predict.py Python
def predict(tree, x):
while isinstance(tree, dict): # walk down until we reach a leaf
tree = tree["children"].get(x[tree["feature"]], tree["majority"])
return tree
def print_tree(tree, indent=""):
if not isinstance(tree, dict):
print(indent + "→ " + tree)
return
for value, child in tree["children"].items():
print(f"{indent}{tree['feature']} = {value}")
print_tree(child, indent + " ")
day = {"Outlook": "Sunny", "Temperature": "Cool", "Humidity": "High", "Wind": "Strong"}
print(predict(tree, day)) # No
print(sum(predict(tree, r) == r[TARGET] for r in rows), "/", len(rows)) # 14 / 14
print_tree(tree)
6. Numeric features: best threshold
threshold.py Python
def entropy_of(labels):
n = len(labels)
return -sum(c / n * math.log2(c / n) for c in Counter(labels).values())
def best_threshold(xs, ys):
"""Best question 'x <= t' for one numeric feature. Returns (t, gain)."""
pairs = sorted(zip(xs, ys))
parent, n = entropy_of(ys), len(ys)
best_t, best_gain = None, -1.0
for i in range(1, n):
if pairs[i - 1][0] == pairs[i][0]:
continue # no threshold between equal values
t = (pairs[i - 1][0] + pairs[i][0]) / 2 # midpoint between neighbours
left = [y for _, y in pairs[:i]]
right = [y for _, y in pairs[i:]]
gain = parent - (len(left) / n * entropy_of(left) + len(right) / n * entropy_of(right))
if gain > best_gain:
best_t, best_gain = t, gain
return best_t, best_gain
temps = [64, 65, 68, 69, 70, 71, 72, 75, 80, 81, 83, 85]
play = ["Yes", "No", "Yes", "Yes", "Yes", "No", "No", "Yes", "No", "Yes", "Yes", "No"]
t, g = best_threshold(temps, play)
print(t, round(g, 3)) # 84.0 0.113
7. The same with scikit-learn
scikit-learn implements CART: binary splits on numeric features. Categorical features must be encoded as numbers first (here one-hot). The pruning parameters are explained in the last section.
sklearn_tree.py Python
import pandas as pd
from sklearn.tree import DecisionTreeClassifier, export_text
df = pd.DataFrame(rows)
X = pd.get_dummies(df[FEATURES]) # Outlook_Sunny, Outlook_Rain, ... (0/1 columns)
y = df[TARGET]
clf = DecisionTreeClassifier(
criterion="entropy", # or "gini" (the default)
max_depth=None, # pre-pruning: limit the depth
min_samples_leaf=1, # pre-pruning: minimum examples in a leaf
ccp_alpha=0.0, # post-pruning: cost-complexity parameter
random_state=0,
).fit(X, y)
print(export_text(clf, feature_names=list(X.columns)))
print("training accuracy:", clf.score(X, y)) # 1.0
💡 The printed tree differs from ID3's: every question is binary, e.g. Outlook_Overcast <= 0.5 ("is it not overcast?"), so one three-way question on Outlook becomes two yes/no questions, and the greedy choices change.
Disadvantages and overfitting
If we let the recursion run until every leaf is pure, a tree can always reach 100% training accuracy (unless two identical examples have different labels). In the extreme, it puts every training example into its own leaf. That is memorising, not learning.
| Advantages ✅ | Disadvantages ❌ |
|---|---|
| Interpretable: the model is a set of readable if-then rules | Overfitting: a fully grown tree fits the noise in the training data |
| Little preprocessing: no feature scaling or normalisation needed | High variance, unstable: a small change in the data can give a completely different tree |
| Numeric and categorical features, and multi-class problems, are handled naturally | Greedy: each split is only locally best, so the tree is not guaranteed to be the best tree |
| Non-linear: can model interactions between features | Axis-parallel boundaries: a diagonal or round boundary becomes a staircase |
| Fast prediction: only as many questions as the depth | Bias towards features with many values (see Day above) |
| Built-in feature selection: useless features are never asked (Temperature) | Regression trees predict a step function and cannot extrapolate beyond the training range |
Why trees overfit
- Deep nodes see few examples. Each split divides the data, so after a few levels a node decides based on 2–3 examples. Its "best" split is often explained by noise.
- No limit on complexity. A deeper tree has more leaves, which means more rectangles and a more wiggly boundary. In Lab 06 terms, depth is the complexity knob: shallow trees have high bias (underfit), deep trees have high variance (overfit).
- Instability. A different split near the root changes everything beneath it.
Try it: grow the tree deeper
120 training points in two classes. The true boundary is a circle, but 12% of the labels were flipped at random (noise). The tree is grown with information gain on the two numeric features. Accuracy is measured on the training points and on 1000 fresh test points from the same distribution. Increase the depth and watch the train and test curves separate. Click New training data at a large depth to see the instability.
training accuracy test accuracy
Dealing with overfitting
The idea is always the same: keep the tree smaller than it could be, so every leaf is backed by enough data. There are two families of pruning, plus ensembles.
1. Pre-pruning (early stopping)
Stop the recursion early, adding extra base cases to BuildTree:
- Maximum depth: do not split below depth \(d\).
- Minimum samples per split / per leaf: do not split a node with fewer than \(m\) examples, or do not create a child with fewer than \(m\) examples.
- Minimum information gain: do not split when the best gain is below a threshold.
- Maximum number of leaves.
Pre-pruning is simple and fast, but can stop too early: a split with a small gain may enable very good splits below it (the classic example is XOR).
2. Post-pruning
Grow the full tree, then cut back from the leaves upwards, replacing a subtree by a leaf (its majority class) when the subtree does not pay off:
- Reduced-error pruning: hold out a validation set; replace a subtree by a leaf if validation accuracy does not decrease. Repeat while it helps.
- Cost-complexity pruning (CART,
ccp_alphain scikit-learn): minimise error plus a penalty per leaf,\[ R_\alpha(T) = R(T) + \alpha \cdot |\text{leaves}(T)| \]where \(R(T)\) is the training error rate. \(\alpha = 0\) keeps the full tree. A larger \(\alpha\) makes each leaf more expensive, so weak subtrees are collapsed first. \(\alpha\) is chosen by cross-validation.
Try it: prune the tree
Same data as above (the current training set). The full tree is grown first with the pre-pruning limit min samples per leaf, then post-pruned with \(\alpha\). Compare the result with the unpruned tree. Push either slider too far and the tree underfits: at \(\alpha = 0.1\) only the root is left.
3. Ensembles of trees
Instead of one careful tree, combine many trees (the ensemble methods from Lab 06):
- Random forest: train many deep trees, each on a bootstrap sample of the data (bagging) and with a random subset of features considered at each split; predict by majority vote (or average). Averaging many high-variance trees reduces the variance.
- Boosting (AdaBoost, gradient boosted trees such as XGBoost): train small trees one after another, each one correcting the errors of the previous ones.
💡 Whatever method you use, choose the hyperparameters (depth, min samples, \(\alpha\)) with a validation set or k-fold cross-validation, never with the test set.
Summary: key takeaways
- Decision trees predict by asking a sequence of feature questions; leaves hold a class (classification) or a mean value (regression).
- Building is recursive, top-down and greedy: choose the best split, partition the data, recurse; stop at pure nodes.
- Surprise of an outcome is \(-\log_2 p\); entropy is the expected surprise \(H = -\sum_i p_i \log_2 p_i\): 0 for a pure node, \(\log_2 k\) for a uniform one.
- Information gain \(= H(\text{parent}) -\) weighted average \(H(\text{children})\); ID3 splits on the feature with the largest gain. It is biased towards many-valued features (gain ratio corrects this).
- Trees are interpretable but overfit easily and are unstable (high variance).
- Remedies: pre-pruning (max depth, min samples, min gain), post-pruning (reduced-error, cost-complexity \(\alpha\)), and ensembles (random forests, boosting), all tuned with validation or cross-validation.
25 auto-graded tasks, 100 points: reading a tree, recursive greedy splitting, entropy, information gain, numeric thresholds, the many-valued feature trap, overfitting and pruning.