You won't be taught — you will discover. Every step builds on the last. Trust the process.
Imagine a bag of coloured balls — some Red, some Black.
We want a number that captures how mixed the bag is:
impurity(0.5, 0.5) → 1 # maximally mixed: impurity is 1
impurity(0, 1) → 0 # all one colour: impurity is 0
impurity(1, 0) → 0
Important: Here impurity = 0 means completely pure (easy to classify), and impurity = 1 means maximally mixed (hard to classify).
Before coding, answer these questions:
impurity(0.9, 0.1)? Higher or lower than impurity(0.8, 0.2)?impurity(p, q) have when p = 1 - q?impurity(p, q) the same as impurity(q, p)? Why should it be?Think about symmetry: swapping the labels (calling Red "Black" and Black "Red") shouldn't change how mixed the bag is. What does that mean for impurity(p, q) vs impurity(q, p)?
The function should be symmetric — impurity(p, q) == impurity(q, p). It should peak when p == q == 0.5 and be zero at the extremes (p = 0 or p = 1).
impurity(0.9, 0.1) is lower than impurity(0.8, 0.2). The 90/10 bag is more dominated by one colour, hence purer.q = 1 - p, the function is really a function of one variable. It must equal 0 at the endpoints (p = 0 and p = 1) and reach its maximum at p = 0.5. It should be a smooth, symmetric, concave curve.impurity(p, q) == impurity(q, p).We're going to invent the formula for impurity(p, q) where:
p = fraction of Red balls (between 0 and 1)q = fraction of Black balls (between 0 and 1)p + q = 1 alwaysStep A: Consider the function $f(p) = p \times (1 - p)$. Fill in the table below by computing it:
| p | 1-p | p × (1-p) |
|---|---|---|
| 0.0 | 1.0 | ? |
| 0.2 | ? | |
| 0.4 | ? | |
| 0.5 | ? | |
| 0.6 | ? | |
| 0.8 | ? | |
| 1.0 | ? |
Tasks:
p does it occur?def plot_impurity_candidate(f, title="Candidate impurity function"):
ps = [i/100 for i in range(101)]
vals = [f(p) for p in ps]
plt.figure(figsize=(7, 4))
plt.plot(ps, vals, 'b-', linewidth=2)
plt.xlabel('p (fraction of Red)')
plt.ylabel('value')
plt.title(title)
plt.grid(True)
plt.show()
Start with $p = 0$: what is $0 \times (1 - 0)$? Then $p = 0.5$: what is $0.5 \times 0.5$?
The maximum of $p(1-p)$ is $0.25$ at $p = 0.5$. It's zero at $p = 0$ and $p = 1$. The shape is right (parabola opening downward), but the peak value is $0.25$ instead of $1$.
Completed table:
| p | 1-p | p × (1-p) |
|---|---|---|
| 0.0 | 1.0 | 0.00 |
| 0.2 | 0.8 | 0.16 |
| 0.4 | 0.6 | 0.24 |
| 0.5 | 0.5 | 0.25 |
| 0.6 | 0.4 | 0.24 |
| 0.8 | 0.2 | 0.16 |
| 1.0 | 0.0 | 0.00 |
The maximum is 0.25 at p = 0.5. The minimum is 0 at p = 0 and p = 1. The function has the right shape (zero at the extremes, peaks in the middle) but its peak is 0.25 instead of 1.
for p in [0.0, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0]:
print(f"{p:.1f} {1-p:.1f} {p*(1-p):.2f}")
plot_impurity_candidate(lambda p: p * (1 - p), title="f(p) = p * (1-p)")
From Exercise 1.2, you found that $f(p) = p \times (1-p)$ peaks at $p = 0.5$ with a value of $0.25$.
But we want impurity(0.5, 0.5) = 1 (max impurity = 1, not 0.25).
So we need a function that:
p = 0.5 (most uncertain)p = 0 or p = 1 (most certain)Your task:
Take $f(p) = p \times (1 - p)$ and apply a simple transformation so that its maximum becomes 1.
def impurity(p, q) and plot it.Your derivation:
impurity(p) = ...If the maximum is $0.25$, what simple operation turns $0.25$ into $1$?
Multiply by $4$: $\text{impurity}(p, q) = 4 \times p \times q$. Verify: $4 \times 0.5 \times 0.5 = 1$.
Derivation:
f(p) = p*(1-p) is 0.25 at p = 0.5.impurity(p, q) = 4 * p * q4 * 0.5 * 0.5 = 14 * 0 * 1 = 04 * 1 * 0 = 0def impurity(p, q):
"""
Returns the impurity of a set with fraction p of Red and q of Black.
impurity(0.5, 0.5) = 1 (most mixed)
impurity(1, 0) = 0 (all same)
impurity(0, 1) = 0
"""
return 4 * p * q
# Verify:
print(impurity(0.5, 0.5)) # 1
print(impurity(0, 1)) # 0
print(impurity(1, 0)) # 0
print(impurity(0.8, 0.2)) # 0.64
# Ordering:
print(impurity(0.8, 0.2) < impurity(0.7, 0.3)) # True
print(impurity(0.7, 0.3) < impurity(0.6, 0.4)) # True
plot_impurity_candidate(lambda p: impurity(p, 1 - p),
title="My impurity function")
Now that you have impurity(p, q), explore it.
Tasks:
| Bag | Contents | p | q |
|---|---|---|---|
| A | 9 Red, 1 Black | 0.9 | 0.1 |
| B | 7 Red, 3 Black | 0.7 | 0.3 |
| C | 6 Red, 4 Black | 0.6 | 0.4 |
| D | 5 Red, 5 Black | 0.5 | 0.5 |
| E | 3 Red, 7 Black | 0.3 | 0.7 |
| F | 10 Red, 0 Black | 1.0 | 0.0 |
impurity agree?impurity(0.3, 0.7) equal to impurity(0.7, 0.3)? Why should it be?Just plug the values into your impurity(p, q) function. Remember: higher impurity = more mixed.
impurity(0.3, 0.7) == impurity(0.7, 0.3) == 0.84. This must hold because swapping colour labels does not change how mixed the bag is. Our formula 4*p*q is symmetric.1 - (p1^2 + p2^2 + p3^2) = 1 - 3*(1/3)^2 = 1 - 1/3 = 2/3 ≈ 0.667.bags = [
("A", 0.9, 0.1),
("B", 0.7, 0.3),
("C", 0.6, 0.4),
("D", 0.5, 0.5),
("E", 0.3, 0.7),
("F", 1.0, 0.0),
]
for name, p, q in bags:
print(f"Bag {name}: impurity({p}, {q}) = {impurity(p, q):.4f}")
# Results:
# Bag A: 0.3600 Bag B: 0.8400 Bag C: 0.9600
# Bag D: 1.0000 Bag E: 0.8400 Bag F: 0.0000
A group contains 50 apples and 50 oranges. Another group contains 95 apples and 5 oranges. Which group has higher impurity?
If you randomly pick an item from each group, in which group are you more likely to guess wrong?
Impurity measures how mixed a group is. The 50/50 group is maximally mixed — a random guess has only 50% chance of being right. The 95/5 group is nearly pure — guessing "apple" is right 95% of the time. Higher impurity means the group is harder to predict, which is exactly what the impurity formula you built captures: it peaks at a 50/50 split and approaches 0 for pure groups.
So far, impurity(p, q) takes fractions directly. But in practice, you have a list like:
["R", "B", "B", "R", "R"]
You need to compute p and q from the list yourself.
Tasks:
["R", "B", "B", "R", "R"], manually compute:
p (fraction of R)q (fraction of B)list_impurity(items) that:
p and qimpurity(p, q)[]? Decide and document your choice.Test your function:
list_impurity(["R", "B", "B", "R", "R"]) # what do you expect?
list_impurity(["R", "R", "R", "R"]) # all same → what value?
list_impurity(["B", "B", "B", "B"]) # all same → what value?
list_impurity(["R", "B"]) # exactly half → what value?
list_impurity([]) # empty → your decision
Try these additional lists and rank them by impurity (most mixed to least mixed):
list_A = ["R", "R", "R", "R", "R", "R", "R", "R", "B", "B"]
list_B = ["R", "R", "R", "R", "R", "B", "B", "B", "B", "B"]
list_C = ["R", "R", "R", "R", "R", "R", "R", "B", "B", "B"]
Use items.count("R") and len(items) to compute the fractions.
For the empty list edge case, returning 0 is a reasonable choice — an empty set has no mixing.
def list_impurity(items):
"""
Computes the impurity of a list of "R" and "B" elements.
Returns 0 for an empty list (no mixing possible).
"""
if len(items) == 0:
return 0
total = len(items)
count_r = items.count("R")
count_b = total - count_r
p = count_r / total
q = count_b / total
return impurity(p, q)
# Tests:
print(list_impurity(["R", "B", "B", "R", "R"])) # 0.96
print(list_impurity(["R", "R", "R", "R"])) # 0
print(list_impurity(["B", "B", "B", "B"])) # 0
print(list_impurity(["R", "B"])) # 1.0
print(list_impurity([])) # 0
# Ranking:
list_A = ["R"]*8 + ["B"]*2 # p=0.8 imp=0.64
list_B = ["R"]*5 + ["B"]*5 # p=0.5 imp=1.0
list_C = ["R"]*7 + ["B"]*3 # p=0.7 imp=0.84
# Most mixed to least: B (1.0) > C (0.84) > A (0.64)
When we split a list into two parts, we get two separate impurity values — one for each part.
But how do we combine them into one number?
Think about it: If one group has 100 items and another has 2 items, should they count equally?
Your task:
left and right, that accounts for their sizes.
total_impurity(left, right) that takes two lists and returns a single number.Test it:
total_impurity(["R", "R", "R"], ["B", "B", "B"]) # perfectly separated
total_impurity(["R", "B", "R"], ["B", "R", "B"]) # each group is mixed
total_impurity(["R"]*9 + ["B"], ["B"]) # unequal sizes
Reflection: For the last test case, the right group has 1 item (["B"]) so it's pure (impurity = 0). But it only has 1 item out of 11 total. Does your formula give it less weight? It should.
Think about weighted average. A group with more items should have more influence on the total.
$\text{total} = \frac{|L|}{|L|+|R|} \times \text{impurity}(L) + \frac{|R|}{|L|+|R|} \times \text{impurity}(R)$
def total_impurity(left, right):
"""
Returns the weighted total impurity of two groups.
Each group's impurity is weighted by its size relative
to the combined total.
"""
n_left = len(left)
n_right = len(right)
n_total = n_left + n_right
if n_total == 0:
return 0
return (n_left / n_total) * list_impurity(left) \
+ (n_right / n_total) * list_impurity(right)
# Tests:
print(total_impurity(["R","R","R"], ["B","B","B"]))
# 0.0 — perfectly separated, each group is pure
print(total_impurity(["R","B","R"], ["B","R","B"]))
# ~0.889 — both groups are 2/3 vs 1/3
print(total_impurity(["R"]*9 + ["B"], ["B"]))
# ~0.327 — left is slightly impure (10/11 weight),
# right is pure (1/11 weight)
You are given an ordered list:
items = ["R", "B", "B", "R", "R"]
You can split it at position k (0-indexed), meaning:
items[:k] — the first k itemsitems[k:] — the remaining itemsFor example, splitting at position k=2:
["R", "B"]["B", "R", "R"]Tasks:
items = ["R", "B", "B", "R", "R"], print the left and right parts for every possible split position k = 1, 2, 3, 4.
k=0 or k=5? What would those give?)total_impurity(left, right). Print all values.Use a loop: for k in range(1, len(items)). Print items[:k] and items[k:] for each.
items = ["R", "B", "B", "R", "R"]
for k in range(1, len(items)):
left = items[:k]
right = items[k:]
imp = total_impurity(left, right)
print(f"k={k}: left={left}, right={right}, "
f"total_impurity={imp:.4f}")
# k=0 gives left=[] (empty) — no meaningful split.
# k=5 gives right=[] (empty) — no meaningful split.
#
# Results:
# k=1: left=['R'], right=['B','B','R','R'] → 0.75
# k=2: left=['R','B'],right=['B','R','R'] → 0.9333
# k=3: left=['R','B','B'], right=['R','R'] → 0.5333
# k=4: left=['R','B','B','R'], right=['R'] → 0.75
#
# k=3 gives the minimum total impurity (0.5333).
# Left=['R','B','B'] and Right=['R','R'] — the right
# group is pure and the left is mostly B.
Now automate it: write a function find_best_split_position(items) that:
k = 1 to len(items) - 1total_impurity at each positionk that gives the minimum total impurityTasks:
find_best_split_position(items). It should return the best k and the minimum total impurity.Test it on these lists:
["R", "B", "B", "R", "R"]
["R", "R", "R", "B", "B", "B"]
["B", "R", "B", "R", "B", "R"]
["R", "R", "R", "R", "B"]
For ["R", "R", "R", "B", "B", "B"], the answer should be obvious from visual inspection. Does your function agree?
["B", "R", "B", "R", "B", "R"] (perfectly alternating), what split position does it find? Are there multiple "tied" positions?Track best_k and min_impurity as you loop. Update them whenever you find a lower impurity.
Initialize min_impurity = float('inf') so any real value will be smaller.
def find_best_split_position(items):
"""
Finds the split position k that minimises
total_impurity(items[:k], items[k:]).
Returns (best_k, min_impurity).
"""
best_k = None
min_imp = float('inf')
for k in range(1, len(items)):
left = items[:k]
right = items[k:]
imp = total_impurity(left, right)
if imp < min_imp:
min_imp = imp
best_k = k
return best_k, min_imp
# Tests:
for lst in [["R","B","B","R","R"],
["R","R","R","B","B","B"],
["B","R","B","R","B","R"],
["R","R","R","R","B"]]:
best_k, min_imp = find_best_split_position(lst)
print(f"{lst}")
print(f" best k={best_k}, left={lst[:best_k]}, "
f"right={lst[best_k:]}, impurity={min_imp:.4f}")
# ["R","R","R","B","B","B"] → k=3 (obvious visual boundary)
# ["B","R","B","R","B","R"] → all positions are equally
# mixed, so the first k with minimum is returned.
Use the plotting helper below to visualise how total impurity changes at each split position.
Tasks:
plot_split_scores on all four test lists from Exercise 3.2.["R", "R", "R", "B", "B", "B"]: is the minimum sharp or flat? Why?["B", "R", "B", "R", "B", "R"]: what does the plot look like? Is there a clear winner?def plot_split_scores(items, title=None):
ks = list(range(1, len(items)))
scores = [total_impurity(items[:k], items[k:]) for k in ks]
best_k = ks[scores.index(min(scores))]
plt.figure(figsize=(8, 4))
plt.plot(ks, scores, 'b-o')
plt.axvline(x=best_k, color='red', linestyle='--',
label=f'best k={best_k}')
plt.xlabel('Split position k')
plt.ylabel('Total impurity')
plt.title(title or f'Split scores for {items}')
plt.legend(); plt.grid(True); plt.show()
A "sharp" minimum means one position is clearly best. A "flat" minimum means several positions are nearly equal — the data doesn't have a clean boundary.
For ["R","R","R","B","B","B"] the minimum is sharp at k=3 because there is a clean boundary between the two colours.
For ["B","R","B","R","B","R"] the plot is nearly flat — no split position is much better than any other because the data is perfectly interleaved. There is no clear winner.
test_lists = [
["R", "B", "B", "R", "R"],
["R", "R", "R", "B", "B", "B"],
["B", "R", "B", "R", "B", "R"],
["R", "R", "R", "R", "B"],
]
for lst in test_lists:
plot_split_scores(lst)
You are given a dataset of students in a class. Each student has a height (in cm) and a gender ("M" or "F").
Before coding, answer these questions:
import random
random.seed(42)
heights = [158, 162, 165, 167, 168, 170, 171, 172, 174, 175,
176, 178, 179, 180, 181, 182, 183, 185, 187, 190]
genders = ["F", "F", "F", "F", "M", "F", "M", "M", "F", "M",
"M", "M", "M", "M", "F", "M", "M", "M", "M", "M"]
Look for the region where the labels switch from mostly "F" to mostly "M". Notice there are some outliers (e.g. "M" at 168, "F" at 174 and 181).
Run the plotting helper below to see the data.
Call plot_height_gender(heights, genders) to see the scatter plot.
def plot_height_gender(heights, genders, split_height=None,
title="Height vs Gender"):
colors = {"M": "blue", "F": "red"}
y_jitter = {"M": 1, "F": 0}
plt.figure(figsize=(10, 3))
for h, g in zip(heights, genders):
plt.scatter(h, y_jitter[g], color=colors[g], s=80, zorder=5)
plt.text(h, y_jitter[g] + 0.05, g, ha='center', fontsize=8)
if split_height is not None:
plt.axvline(x=split_height, color='green', linestyle='--',
linewidth=2, label=f'split at {split_height}')
plt.legend()
plt.yticks([0, 1], ["F", "M"])
plt.xlabel("Height (cm)")
plt.title(title)
plt.grid(axis='x'); plt.tight_layout(); plt.show()
The scatter plot shows F (red) clustered at the lower heights and M (blue) at the higher heights, with some overlap in the 168-181 range.
plot_height_gender(heights, genders)
Now the key idea: instead of splitting at a position in a list, we split at a threshold value in a feature.
For a given threshold t:
height <= theight > tTasks:
split_by_threshold(heights, genders, t) that:
t(left_genders, right_genders)split_by_threshold(heights, genders, 172) should return the genders of everyone with height <= 172 and height > 172.For the thresholds below, compute and print total_impurity(left, right):
t = 160, 165, 170, 172, 175, 178, 182, 185, 188
Which threshold gives the lowest total impurity? Verify visually using plot_height_gender with split_height=t.
Use list comprehensions: [g for h, g in zip(heights, genders) if h <= t] for the left group.
The threshold t = 167 gives the lowest total impurity. At that split the left group (height <= 167) is [F, F, F, F] (pure) and the right group is mostly M with a few F outliers.
def split_by_threshold(heights, genders, t):
"""
Splits the dataset at threshold t on height.
Returns (left_genders, right_genders) where:
left_genders = genders where height <= t
right_genders = genders where height > t
"""
left_genders = [g for h, g in zip(heights, genders) if h <= t]
right_genders = [g for h, g in zip(heights, genders) if h > t]
return left_genders, right_genders
# Test:
left, right = split_by_threshold(heights, genders, 172)
print("Left (height <= 172):", left)
print("Right (height > 172):", right)
print("Total impurity:", total_impurity(left, right))
# Scan thresholds:
for t in [160, 165, 170, 172, 175, 178, 182, 185, 188]:
left, right = split_by_threshold(heights, genders, t)
imp = total_impurity(left, right)
print(f"t={t}: left={len(left)}, right={len(right)}, "
f"impurity={imp:.4f}")
Instead of manually trying thresholds, automate it.
Key insight: You only need to try thresholds at the unique values in your data. Why? Because splitting at 173.5 vs 174 makes no difference if there's no data point between them.
Tasks:
find_best_threshold(heights, genders) that:
total_impurity for eachplot_height_gender(heights, genders, split_height=best_t).Get unique thresholds with sorted(set(heights)). Skip the last one (splitting there puts everything on the left).
Same pattern as find_best_split_position: track best_t and min_imp, update in a loop.
The best threshold is height <= 167. The left group is all F (pure). The right group is mostly M but contains a few F outliers (at 170, 174, 181), so the split is good but not perfect.
def find_best_threshold(heights, genders):
"""
Finds the height threshold that minimises total_impurity.
Returns (best_threshold, min_impurity).
"""
best_t = None
min_imp = float('inf')
for t in sorted(set(heights)):
left = [g for h, g in zip(heights, genders) if h <= t]
right = [g for h, g in zip(heights, genders) if h > t]
if not left or not right:
continue
imp = total_impurity(left, right)
if imp < min_imp:
min_imp = imp
best_t = t
return best_t, min_imp
best_t, best_imp = find_best_threshold(heights, genders)
print(f"Best threshold: height <= {best_t}")
print(f"Minimum total impurity: {best_imp:.4f}")
left, right = split_by_threshold(heights, genders, best_t)
print(f"Left group: {left}")
print(f"Right group: {right}")
plot_height_gender(heights, genders, split_height=best_t)
Use the plotting helper below to visualise how impurity changes as the threshold varies.
Run plot_threshold_scores(heights, genders) and observe the curve.
def plot_threshold_scores(heights, genders,
title="Impurity vs Threshold"):
unique_heights = sorted(set(heights))
scores = []
for t in unique_heights:
left = [g for h, g in zip(heights, genders) if h <= t]
right = [g for h, g in zip(heights, genders) if h > t]
if left and right:
scores.append((t, total_impurity(left, right)))
ts, imps = zip(*scores)
best_t = ts[imps.index(min(imps))]
plt.figure(figsize=(9, 4))
plt.plot(ts, imps, 'b-o')
plt.axvline(x=best_t, color='red', linestyle='--',
label=f'best t={best_t}')
plt.xlabel('Threshold (height cm)')
plt.ylabel('Total impurity')
plt.title(title)
plt.legend(); plt.grid(True); plt.show()
The curve shows impurity dropping to a minimum at t = 167, then rising again. The minimum is relatively sharp, confirming that 167 is the best single-feature split for this dataset.
plot_threshold_scores(heights, genders)
Your algorithm found that the best split for a height-based gender classifier is at 167 cm. What does this threshold mean for prediction?
The threshold splits the data into two groups. Each group predicts the most common label within it.
The threshold divides the data into two groups. Each group makes predictions based on its own majority label. If the left group (height < 167) is mostly female, it predicts "female" for everyone in it. If the right group (height >= 167) is mostly male, it predicts "male". The threshold isn't the average — it's the split point that minimizes impurity (maximizes the purity of both groups).
Before we build a full tree from data, let's understand the structure of a decision tree by constructing one by hand.
A decision tree is made of nodes. There are two kinds:
True (YES) or False (NO). No children.Here is a tree for deciding whether to accept a job offer:
Is salary > 1000?
├── NO → Reject the offer
└── YES → Is distance <= 40?
├── YES → Accept the offer
└── NO → Reject the offer
Before coding, answer these questions:
salary > 1000.)Your answers:
Trace each case from the root. The first check is salary > 1000. If NO (salary <= 1000), you immediately reject.
salary > 1000. Since 1000 is NOT greater than 1000, we go left → Reject (NO). (Equivalently: salary <= 1000 means go left.)Design a class DecisionTreeNode that can be either a boundary node or a leaf node.
Requirements:
decision (True = YES, False = NO).boundary — the feature name (a string, e.g. "salary")boundary_value — the threshold (a number)left — the child node for when feature <= boundary_valueright — the child node for when feature > boundary_valuecheck(features) that:
{"salary": 1200, "distance": 30}decisionAlso create two helper functions:
YES() — returns a leaf node with decision TrueNO() — returns a leaf node with decision FalseTasks:
DecisionTreeNode, YES(), and NO().Is salary <= 1000? → NO()
Else: Is distance <= 40? → YES() else NO()
Class structure hint:
class DecisionTreeNode:
def __init__(self, ...):
# your attributes here
pass
def check(self, features):
# if leaf: return decision
# if boundary: go left or right
pass
Use a single class with optional parameters. If decision is not None, it's a leaf. Otherwise it's a boundary node with boundary, boundary_value, left, right.
In check(): if it's a leaf, return self.decision. Otherwise, compare features[self.boundary] to self.boundary_value and recurse into self.left or self.right.
class DecisionTreeNode:
def __init__(self, decision=None, boundary=None,
boundary_value=None, left=None, right=None):
self.decision = decision
self.boundary = boundary
self.boundary_value = boundary_value
self.left = left
self.right = right
def check(self, features):
if self.decision is not None:
return self.decision
if features[self.boundary] <= self.boundary_value:
return self.left.check(features)
else:
return self.right.check(features)
def YES():
return DecisionTreeNode(decision=True)
def NO():
return DecisionTreeNode(decision=False)
# Build the job-offer tree:
job_tree = DecisionTreeNode(
boundary="salary", boundary_value=1000,
left=NO(),
right=DecisionTreeNode(
boundary="distance", boundary_value=40,
left=YES(),
right=NO()
)
)
# Test:
print(job_tree.check({"salary": 800, "distance": 20})) # False
print(job_tree.check({"salary": 1200, "distance": 30})) # True
print(job_tree.check({"salary": 1500, "distance": 60})) # False
print(job_tree.check({"salary": 1000, "distance": 15})) # False
Now build a more complex tree by hand. This tree has 3 levels:
Is salary <= 1000?
├── YES → NO (reject)
└── NO (salary > 1000):
Is distance <= 40?
├── YES (close enough):
│ Is coffee == 1? ← 1 means "office has free coffee"
│ ├── YES → YES (accept)
│ └── NO → NO (reject)
└── NO (too far) → NO (reject)
Tasks:
DecisionTreeNode, YES(), and NO().| salary | distance | coffee | Expected |
|---|---|---|---|
| 1200 | 30 | 1 | YES |
| 1200 | 30 | 0 | NO |
| 1200 | 60 | 1 | NO |
| 900 | 10 | 1 | NO |
Nest the DecisionTreeNode constructors. The outermost node splits on salary, its right child splits on distance, and that node's left child splits on coffee.
Note: we split on coffee <= 0 (go left = NO) vs coffee > 0 (go right = YES). This is equivalent to checking coffee == 1.
job_tree_v2 = DecisionTreeNode(
boundary="salary", boundary_value=1000,
left=NO(),
right=DecisionTreeNode(
boundary="distance", boundary_value=40,
left=DecisionTreeNode(
boundary="coffee", boundary_value=0,
left=NO(), # coffee <= 0 → no coffee → reject
right=YES() # coffee > 0 → has coffee → accept
),
right=NO()
)
)
# Test:
tests = [
{"salary": 1200, "distance": 30, "coffee": 1}, # True
{"salary": 1200, "distance": 30, "coffee": 0}, # False
{"salary": 1200, "distance": 60, "coffee": 1}, # False
{"salary": 900, "distance": 10, "coffee": 1}, # False
]
for job in tests:
result = job_tree_v2.check(job)
print(f"salary={job['salary']}, distance={job['distance']}, "
f"coffee={job['coffee']} → {result}")
Now the dataset has two features: height and weight. We want to find which feature and which threshold gives the best split.
data = [
{"height": 158, "weight": 52, "gender": "F"},
{"height": 162, "weight": 55, "gender": "F"},
{"height": 165, "weight": 58, "gender": "F"},
{"height": 167, "weight": 61, "gender": "F"},
{"height": 168, "weight": 70, "gender": "M"},
{"height": 170, "weight": 60, "gender": "F"},
{"height": 171, "weight": 73, "gender": "M"},
{"height": 172, "weight": 75, "gender": "M"},
{"height": 174, "weight": 63, "gender": "F"},
{"height": 175, "weight": 78, "gender": "M"},
{"height": 176, "weight": 80, "gender": "M"},
{"height": 178, "weight": 82, "gender": "M"},
{"height": 179, "weight": 84, "gender": "M"},
{"height": 180, "weight": 85, "gender": "M"},
{"height": 181, "weight": 66, "gender": "F"},
{"height": 182, "weight": 88, "gender": "M"},
{"height": 183, "weight": 90, "gender": "M"},
{"height": 185, "weight": 92, "gender": "M"},
{"height": 187, "weight": 95, "gender": "M"},
{"height": 190, "weight": 98, "gender": "M"},
]
The dataset is loaded and printed. Notice that weight tends to be higher for M than for F, but there are some overlaps (e.g. F at weight 63 with height 174, and F at weight 66 with height 181). The question is: does splitting on weight produce a cleaner separation than splitting on height?
The question: Should we split on height or weight, and at what value?
Write a function find_decision_boundary(data, features, label) that:
data), a list of feature names (features), and the label column name (label)total_impurity for that splitTasks:
find_decision_boundary.features=["height", "weight"] and label="gender".Nested loops: outer loop over features, inner loop over unique values of that feature. Track the best (feature, threshold, impurity) triple.
Extract unique values: sorted(set(row[feature] for row in data)). For each threshold t, the left group is [row[label] for row in data if row[feature] <= t].
Weight typically wins as the best feature because it separates M and F more cleanly than height does (fewer outliers cross the weight boundary).
def find_decision_boundary(data, features, label):
"""
Finds the best (feature, threshold) split across all
given features.
Returns (best_feature, best_threshold, min_impurity).
"""
best_feature = None
best_threshold = None
min_imp = float('inf')
for feature in features:
unique_vals = sorted(set(row[feature] for row in data))
for t in unique_vals:
left = [row[label] for row in data
if row[feature] <= t]
right = [row[label] for row in data
if row[feature] > t]
if not left or not right:
continue
imp = total_impurity(left, right)
if imp < min_imp:
min_imp = imp
best_feature = feature
best_threshold = t
return best_feature, best_threshold, min_imp
best_feat, best_thresh, best_imp = find_decision_boundary(
data, ["height", "weight"], "gender")
print(f"Best split: {best_feat} <= {best_thresh}")
print(f"Minimum total impurity: {best_imp:.4f}")
left_group = [row["gender"] for row in data
if row[best_feat] <= best_thresh]
right_group = [row["gender"] for row in data
if row[best_feat] > best_thresh]
print(f"Left: {left_group}")
print(f"Right: {right_group}")
Use the helper below to visualise both the height split and the weight split.
Tasks:
find_decision_boundary on each feature separately.def plot_feature_split(data, feature, label, split_value=None,
title=None):
color_map = {"M": "blue", "F": "red"}
y_map = {"M": 1, "F": 0}
plt.figure(figsize=(10, 3))
for row in data:
x = row[feature]; y = y_map[row[label]]
c = color_map[row[label]]
plt.scatter(x, y, color=c, s=80, zorder=5)
plt.text(x, y + 0.06, row[label], ha='center', fontsize=8)
if split_value is not None:
plt.axvline(x=split_value, color='green', linestyle='--',
linewidth=2, label=f'split at {split_value}')
plt.legend()
plt.yticks([0, 1], ["F", "M"])
plt.xlabel(feature)
plt.title(title or f"{feature} vs {label}")
plt.grid(axis='x'); plt.tight_layout(); plt.show()
Comparing the two plots, weight gives a cleaner separation (lower impurity) because the M and F weight ranges overlap less than the height ranges.
best_h, best_ht, imp_h = find_decision_boundary(
data, ["height"], "gender")
best_w, best_wt, imp_w = find_decision_boundary(
data, ["weight"], "gender")
print(f"Best height split: height <= {best_ht}, "
f"impurity = {imp_h:.4f}")
print(f"Best weight split: weight <= {best_wt}, "
f"impurity = {imp_w:.4f}")
plot_feature_split(data, "height", "gender",
split_value=best_ht,
title=f"Height split at {best_ht} (imp={imp_h:.3f})")
plot_feature_split(data, "weight", "gender",
split_value=best_wt,
title=f"Weight split at {best_wt} (imp={imp_w:.3f})")
After the first split, you have two groups. Each group may still be impure (mixed).
The idea: repeat the splitting on each group independently, until each group is pure or has only 1 element.
Before coding, trace through manually using the height-only dataset from Part 4:
heights = [158, 162, 165, 167, 168, 170, 171, 172, 174, 175,
176, 178, 179, 180, 181, 182, 183, 185, 187, 190]
genders = ["F", "F", "F", "F", "M", "F", "M", "M", "F", "M",
"M", "M", "M", "M", "F", "M", "M", "M", "M", "M"]
Your manual tree sketch:
height <= ???
├── Left: ??? (pure? yes/no)
│ └── If not pure, split again at height <= ???
│ ├── ...
│ └── ...
└── Right: ??? (pure? yes/no)
└── If not pure, split again at height <= ???
├── ...
└── ...
The best first split from Part 4 is around height 167. After that, neither group is perfectly pure because of the outlier data points.
Manual trace using the height-only dataset:
height <= 167 (found in Part 4).height <= 167
├── Left: [F, F, F, F] → LEAF: F (pure)
└── Right: [M, F, M, M, F, M, M, M, M, M, F, M, M, M, M, M]
└── Split again on height (or weight if available)
to isolate the remaining F outliers...
Before writing the recursive function, think about when to stop.
When should we NOT split further?
When we stop, what do we return? A leaf node with the majority label in that group.
Tasks:
majority_label(labels) that takes a list of labels and returns the most common one. Document your tie-breaking rule.is_pure(labels) that returns True if all labels are the same.majority_label(["M", "M", "F", "M"]) # → "M"
majority_label(["F", "M"]) # → tie: your choice
is_pure(["M", "M", "M"]) # → True
is_pure(["M", "M", "F"]) # → False
For majority_label, count each label and pick the one with the highest count. For ties, just pick the first one you find.
For is_pure: return len(set(labels)) <= 1
def majority_label(labels):
"""
Returns the most common label in the list.
Tie-breaking: returns whichever label appears first
among those with the highest count.
"""
if not labels:
return None
counts = {}
for label in labels:
counts[label] = counts.get(label, 0) + 1
return max(counts, key=counts.get)
def is_pure(labels):
"""
Returns True if all labels are the same (or the list
is empty).
"""
return len(set(labels)) <= 1
# Tests:
print(majority_label(["M", "M", "F", "M"])) # M
print(majority_label(["F", "M"])) # F (tie → first)
print(is_pure(["M", "M", "M"])) # True
print(is_pure(["M", "M", "F"])) # False
print(is_pure([])) # True
Now write build_tree(data, features, label) that:
data is empty, or has 1 element, or is pure — return a DecisionTreeNode leaf with the majority label.find_decision_boundary.data into left_data and right_data.build_tree on each.DecisionTreeNode boundary node with the two subtrees.Important: The leaf node now stores a label string (like "M" or "F"), not just True/False. You may need to adapt your DecisionTreeNode slightly.
Tasks:
build_tree(data, features, label).print_tree(node, depth=0) that prints the tree in an indented format:[height <= 175]
LEFT:
[weight <= 63]
LEFT: LEAF → F
RIGHT: LEAF → M
RIGHT:
LEAF → M
Think about the safety check: what if find_decision_boundary returns a split where all data goes to one side? You need to stop and return a leaf to avoid infinite recursion.
Split the data: left_data = [row for row in data if row[best_feat] <= best_thresh] and similarly for right. If either is empty, return a leaf.
For print_tree: check if node.decision is not None (leaf) vs boundary. Use " " * depth for indentation and recurse on left/right children.
def build_tree(data, features, label):
"""
Recursively builds a decision tree.
Returns a DecisionTreeNode (leaf or boundary).
"""
labels = [row[label] for row in data]
# Base case 1: empty data
if len(data) == 0:
return DecisionTreeNode(decision=None)
# Base case 2: pure or single element
if is_pure(labels) or len(data) == 1:
return DecisionTreeNode(decision=majority_label(labels))
# Recursive case
best_feat, best_thresh, best_imp = \
find_decision_boundary(data, features, label)
left_data = [row for row in data
if row[best_feat] <= best_thresh]
right_data = [row for row in data
if row[best_feat] > best_thresh]
# Safety: if split doesn't separate, return leaf
if len(left_data) == 0 or len(right_data) == 0:
return DecisionTreeNode(decision=majority_label(labels))
left_tree = build_tree(left_data, features, label)
right_tree = build_tree(right_data, features, label)
return DecisionTreeNode(
boundary=best_feat,
boundary_value=best_thresh,
left=left_tree,
right=right_tree
)
def print_tree(node, depth=0, label="ROOT"):
indent = " " * depth
if node.decision is not None:
print(f"{indent}[{label}] LEAF → {node.decision}")
else:
print(f"{indent}[{label}] {node.boundary} "
f"<= {node.boundary_value}?")
print_tree(node.left, depth+1, "YES (left)")
print_tree(node.right, depth+1, "NO (right)")
# Build and print:
tree = build_tree(data, ["height", "weight"], "gender")
print_tree(tree)
Now use your built tree to predict the gender of each row in the original data.
Tasks:
tree.check(row) to predict the gender for each row.Since the tree was built on this exact data and we didn't limit its depth, it should get 100% accuracy on the training data. If not, check your build_tree stopping conditions.
The tree achieves 100% accuracy on the training data because it keeps splitting until every leaf is pure. This is expected — with no depth limit the tree memorises the training set perfectly. Whether this generalises to new data is another question (see overfitting discussion in Part 9).
correct = 0
for row in data:
predicted = tree.check(row)
true_label = row["gender"]
match = "Y" if predicted == true_label else "N"
if predicted == true_label:
correct += 1
print(f"height={row['height']}, weight={row['weight']}: "
f"true={true_label}, pred={predicted} {match}")
print(f"\nAccuracy: {correct}/{len(data)} "
f"= {correct/len(data)*100:.1f}%")
A decision tree is allowed to grow until every leaf contains exactly one data point. It achieves 100% accuracy on the training data. Is this a good model?
If every leaf has one data point, the tree has essentially memorized the location of each training example. What happens when a new example falls in a slightly different spot?
A tree that splits until every leaf has one point has memorized the training data — it hasn't learned general patterns, just the exact positions of the training examples. This is called overfitting. New data points, even slightly different from training data, may land in the wrong leaf. This is why real decision trees use stopping conditions like max_depth or min_samples_split — they sacrifice some training accuracy to gain the ability to generalize to unseen data.
Real ML libraries use a standard interface:
fit(X, y) — train the model on data X (features) and labels ypredict(X) — given new data, return predictionsHere, X will be a list of dicts (one per sample), and y will be a list of labels.
Before coding, think about these questions:
fit(X, y): how do you know which features to try splitting on?predict(X): X is a list of rows — so predict should return a list of predictions.check is called on it?Your answers:
predict returns: ...The features are just the keys of the first dict in X: list(X[0].keys()).
list(X[0].keys()). This way fit automatically discovers the available features.predict returns: A list of predicted labels, one per row in X. Each prediction comes from traversing the tree for that row."M" or "F"), and check() returns it directly.Wrap everything into a clean class:
class SimpleDecisionTree:
def fit(self, X, y):
"""
X : list of dicts (each dict is one row of features)
y : list of labels (strings or booleans)
Builds the decision tree and stores it in self.root.
"""
pass
def predict(self, X):
"""
X : list of dicts
Returns a list of predicted labels, one per row.
"""
pass
Tasks:
SimpleDecisionTree using build_tree inside fit.X and y inside fit to pass to build_tree.Test it on the height+weight dataset:
X = [{"height": row["height"], "weight": row["weight"]}
for row in data]
y = [row["gender"] for row in data]
clf = SimpleDecisionTree()
clf.fit(X, y)
predictions = clf.predict(X)
Compute accuracy.
new_people = [
{"height": 163, "weight": 54}, # your guess: F or M?
{"height": 185, "weight": 91}, # your guess: F or M?
{"height": 172, "weight": 65}, # your guess: F or M?
]
In fit: merge X[i] and {label: y[i]} into one dict per row: [{**x, "__label__": yi} for x, yi in zip(X, y)]. Use "__label__" as the label column name when calling build_tree.
In predict: return [self.root.check(row) for row in X]
class SimpleDecisionTree:
def __init__(self):
self.root = None
self.features = None
def fit(self, X, y):
self.features = list(X[0].keys())
label_col = "__label__"
combined = [{**x, label_col: yi}
for x, yi in zip(X, y)]
self.root = build_tree(combined,
self.features,
label_col)
def predict(self, X):
return [self.root.check(row) for row in X]
# Test:
X = [{"height": row["height"], "weight": row["weight"]}
for row in data]
y = [row["gender"] for row in data]
clf = SimpleDecisionTree()
clf.fit(X, y)
predictions = clf.predict(X)
correct = sum(p == t for p, t in zip(predictions, y))
print(f"Training accuracy: {correct}/{len(y)} "
f"= {correct/len(y)*100:.1f}%")
# Predict on new data:
new_people = [
{"height": 163, "weight": 54},
{"height": 185, "weight": 91},
{"height": 172, "weight": 65},
]
new_preds = clf.predict(new_people)
for person, pred in zip(new_people, new_preds):
print(f" height={person['height']}, "
f"weight={person['weight']} → {pred}")
Use your print_tree function to display the tree learned by SimpleDecisionTree.
Tasks:
print_tree(clf.root).A noisier dataset means more exceptions to every rule, so the tree has to keep splitting to isolate them — it gets deeper. This is overfitting.
The tree is several levels deep. Each split isolates one or more data points into pure leaves. The first split is on the feature (height or weight) that gives the best separation. Subsequent splits handle the remaining impure groups.
With more noise the tree would be deeper, because more exceptions force more splits to achieve purity. This is overfitting: the tree memorises the training data rather than learning a general pattern.
print_tree(clf.root)
If you've made it here, you've built a decision tree from scratch — impurity formula, split search, recursive tree growth, and a full ML-style class. Here are open-ended extensions.
Your tree keeps splitting until each leaf is pure. This can lead to overfitting — the tree memorises the training data but won't generalise to new data.
Add a max_depth parameter to SimpleDecisionTree (and build_tree):
max_depth, stop and return a leaf regardless of purity.Tasks:
max_depth=1, 2, 3. How does accuracy on the training set change?max_depth=1? This is called a decision stump.Add a depth parameter to build_tree. In each recursive call, pass depth + 1. At the top, check if depth >= max_depth: return leaf.
With max_depth=1 the tree is a decision stump (one split). Training accuracy is lower but the model is simpler and less likely to overfit.
def build_tree_depth(data, features, label,
depth=0, max_depth=None):
labels = [row[label] for row in data]
if len(data) == 0:
return DecisionTreeNode(decision=None)
if is_pure(labels) or len(data) == 1:
return DecisionTreeNode(decision=majority_label(labels))
# NEW: stop if max depth reached
if max_depth is not None and depth >= max_depth:
return DecisionTreeNode(decision=majority_label(labels))
best_feat, best_thresh, _ = \
find_decision_boundary(data, features, label)
left_data = [r for r in data if r[best_feat] <= best_thresh]
right_data = [r for r in data if r[best_feat] > best_thresh]
if not left_data or not right_data:
return DecisionTreeNode(decision=majority_label(labels))
return DecisionTreeNode(
boundary=best_feat, boundary_value=best_thresh,
left=build_tree_depth(left_data, features, label,
depth+1, max_depth),
right=build_tree_depth(right_data, features, label,
depth+1, max_depth)
)
# Test with max_depth = 1, 2, 3:
label_col = "__label__"
combined = [{**{"height": r["height"], "weight": r["weight"]},
label_col: r["gender"]} for r in data]
feats = ["height", "weight"]
for md in [1, 2, 3, None]:
t = build_tree_depth(combined, feats, label_col,
max_depth=md)
preds = [t.check(r) for r in data]
acc = sum(p == r["gender"] for p, r in zip(preds, data))
print(f"max_depth={md}: accuracy={acc}/{len(data)}")
print_tree(t)
Another way to prevent overfitting: don't split a group if it has fewer than min_samples rows.
Add a min_samples parameter: if len(data) < min_samples, return a leaf.
Try min_samples=1 (default) vs min_samples=3 vs min_samples=5. How does the tree structure change?
Just add one more base-case check at the top of build_tree: if len(data) < min_samples: return leaf.
Higher min_samples produces a shallower tree with fewer splits, trading training accuracy for simplicity and better generalisation.
def build_tree_ms(data, features, label, min_samples=1):
labels = [row[label] for row in data]
if len(data) == 0:
return DecisionTreeNode(decision=None)
if is_pure(labels) or len(data) == 1:
return DecisionTreeNode(decision=majority_label(labels))
# NEW: stop if too few samples
if len(data) < min_samples:
return DecisionTreeNode(decision=majority_label(labels))
best_feat, best_thresh, _ = \
find_decision_boundary(data, features, label)
left_data = [r for r in data if r[best_feat] <= best_thresh]
right_data = [r for r in data if r[best_feat] > best_thresh]
if not left_data or not right_data:
return DecisionTreeNode(decision=majority_label(labels))
return DecisionTreeNode(
boundary=best_feat, boundary_value=best_thresh,
left=build_tree_ms(left_data, features, label,
min_samples),
right=build_tree_ms(right_data, features, label,
min_samples)
)
# Compare min_samples = 1, 3, 5:
for ms in [1, 3, 5]:
t = build_tree_ms(combined, feats, label_col,
min_samples=ms)
preds = [t.check(r) for r in data]
acc = sum(p == r["gender"] for p, r in zip(preds, data))
print(f"min_samples={ms}: accuracy={acc}/{len(data)}")
print_tree(t)
print()
Try your tree on the Iris dataset — a classic ML benchmark.
from sklearn.datasets import load_iris
iris = load_iris()
feature_names = iris.feature_names # 4 features
X_iris = [dict(zip(feature_names, row)) for row in iris.data]
y_iris = [iris.target_names[t] for t in iris.target]
Tasks:
SimpleDecisionTree on the Iris dataset.sklearn.tree.DecisionTreeClassifier. Do they make the same splits?Your tree should get ~100% training accuracy on Iris. The first split is usually on petal width (cm) at around 0.8 — this separates setosa from the other two perfectly.
The tree should achieve 100% training accuracy. The first split is usually on petal width (cm) at around 0.8 cm, perfectly separating setosa from the other two species. Test accuracy is typically 93-97%.
from sklearn.datasets import load_iris
iris = load_iris()
feature_names = iris.feature_names
X_iris = [dict(zip(feature_names, row))
for row in iris.data]
y_iris = [iris.target_names[t] for t in iris.target]
clf_iris = SimpleDecisionTree()
clf_iris.fit(X_iris, y_iris)
preds = clf_iris.predict(X_iris)
acc = sum(p == t for p, t in zip(preds, y_iris))
print(f"Iris training accuracy: "
f"{acc}/{len(y_iris)} = {acc/len(y_iris)*100:.1f}%")
print("\nTree structure:")
print_tree(clf_iris.root)
# Bonus: train/test split
import random
random.seed(42)
indices = list(range(len(X_iris)))
random.shuffle(indices)
split = int(0.8 * len(indices))
train_idx, test_idx = indices[:split], indices[split:]
X_train = [X_iris[i] for i in train_idx]
y_train = [y_iris[i] for i in train_idx]
X_test = [X_iris[i] for i in test_idx]
y_test = [y_iris[i] for i in test_idx]
clf2 = SimpleDecisionTree()
clf2.fit(X_train, y_train)
test_preds = clf2.predict(X_test)
test_acc = sum(p == t for p, t in zip(test_preds, y_test))
print(f"\nTest accuracy: {test_acc}/{len(y_test)} "
f"= {test_acc/len(y_test)*100:.1f}%")
The impurity function you invented is based on $p(1-p)$. Look up Gini impurity:
$$\text{Gini} = 1 - \sum_k p_k^2$$
where the sum is over all classes $k$, and $p_k$ is the fraction of class $k$.
Tasks:
impurity function with the Gini formula and re-run everything. Do the results change?For two classes: $\text{Gini} = 1 - p^2 - q^2$. Since $q = 1-p$, expand and simplify — compare to $4pq$.
$1 - p^2 - (1-p)^2 = 2p(1-p)$. Your formula was $4p(1-p)$. They differ by a factor of 2, but this doesn't affect which split wins — both rank splits the same way.
For two classes: Gini = 1 - p^2 - q^2 = 2pq. Your formula 4pq differs from Gini by a factor of 2, but this does not affect which split wins — both rank all possible splits identically. Entropy has a slightly different shape (it peaks at 1.0 instead of 0.5) but also ranks splits the same way.
def gini_impurity(p, q):
"""
Gini impurity for two classes.
Gini = 1 - (p^2 + q^2) = 2*p*q
Ranges from 0 (pure) to 0.5 (maximally mixed).
"""
return 1 - (p**2 + q**2)
def entropy_impurity(p, q):
"""
Entropy for two classes.
H = -(p*log2(p) + q*log2(q))
Handle p=0 or q=0 as contributing 0.
Ranges from 0 (pure) to 1.0 (maximally mixed).
"""
result = 0
if p > 0:
result -= p * math.log2(p)
if q > 0:
result -= q * math.log2(q)
return result
# Plot all three:
ps = [i/100 for i in range(101)]
plt.figure(figsize=(8, 4))
plt.plot(ps, [impurity(p, 1-p) for p in ps],
label='Your formula (4pq)')
plt.plot(ps, [gini_impurity(p, 1-p) for p in ps],
label='Gini (2pq)', linestyle='--')
plt.plot(ps, [entropy_impurity(p, 1-p) for p in ps],
label='Entropy', linestyle=':')
plt.xlabel('p'); plt.ylabel('impurity')
plt.title('Comparing impurity measures')
plt.legend(); plt.grid(True); plt.show()
Take a moment to answer these questions in your own words.
fit do? What does predict do?What you just invented: a complete decision tree classifier — from the impurity measure ($4pq$, equivalent to Gini impurity up to a constant factor), through the greedy split-search algorithm (try every feature and threshold, pick the one that minimizes weighted impurity), to recursive tree growth with stopping conditions, and finally a scikit-learn-style fit/predict interface. This is exactly how sklearn.tree.DecisionTreeClassifier works under the hood.