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?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?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) = ...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?A group contains 50 apples and 50 oranges. Another group contains 95 apples and 5 oranges. Which group has higher impurity?
A. The 95/5 group — it has more items
B. The 50/50 group — it is the most mixed, so predicting the label of a random item is hardest
C. Both have the same impurity
D. Impurity can only be computed with the formula, not by intuition
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"]
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.
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.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?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?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:
Run the plotting helper below to see the data.
Call plot_height_gender(heights, genders) to see the scatter plot.
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.
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).Use the plotting helper below to visualise how impurity changes as the threshold varies.
Run plot_threshold_scores(heights, genders) and observe the curve.
Your algorithm found that the best split for a height-based gender classifier is at 167 cm. What does this threshold mean for prediction?
A. Everyone shorter than 167 cm is male
B. People below 167 cm get the majority label of the left group, and people at or above get the majority label of the right group
C. 167 cm is the average height in the dataset
D. People exactly at 167 cm cannot be classified
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:
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
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 |
Now the dataset has two features: height and weight. We want to find which feature and which threshold gives the best split.
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".Use the helper below to visualise both the height split and the weight split.
Tasks:
find_decision_boundary on each feature separately.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 <= ???
├── ...
└── ...
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
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
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.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?
A. Yes — 100% accuracy means perfect predictions
B. Probably not — it has likely memorized the training data (including noise) and will perform poorly on new, unseen data
C. It depends on whether the data is sorted
D. No — decision trees can never achieve 100% accuracy
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: ...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?
]
Use your print_tree function to display the tree learned by SimpleDecisionTree.
Tasks:
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.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?
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?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?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.
You've just invented a decision tree classifier from scratch — impurity, split search, recursive tree growth, and a full ML interface. That's the real thing. sklearn's DecisionTreeClassifier works on exactly these principles.
Source on GitHub · Back to all chapters
© 2026 CloudxLab. All rights reserved.