Machine Learning › Trees and Ensembles › Day 162
Hands-on lab — Day 162: Decision Trees
- ← Back to the Day 162 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/machine-learning/day-162-decision-trees/
Commands
Setup
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt Run
.venv/bin/python examples/tree_lib.py Test
./tests/run_tests.sh File tree
examples/test_tree_lib.py examples/tree_lib.py expected-output/examples-run.txt expected-output/FIELDS.md expected-output/measured-values.txt expected-output/starter-run.txt expected-output/test-run.txt metadata.yml README.md requirements/requirements.txt security.md starter/test_tree_lib.py starter/tree_lib.py tests/run_tests.sh troubleshooting.md
Lab README
Lab 162: Decision Trees and Impurity Criteria from Scratch
Lesson
- Lesson title: Decision Trees
- Day number: 162 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-162-decision-trees
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-162-decision-treeswhen the site is running.
Purpose
Build a Classification and Regression Tree (CART) decision tree classifier from scratch using NumPy: implement Gini Impurity and Shannon Entropy, write an exhaustive greedy split evaluator, construct a recursive binary tree data structure, and benchmark against scikit-learn's DecisionTreeClassifier.
Learning objectives
- Implement Gini impurity and Shannon entropy impurity criteria.
- Find the globally optimal single-feature axis-aligned split threshold.
- Construct a recursive binary tree data structure with base-case stopping criteria.
- Predict class labels for unseen feature vectors by tree traversal.
- Benchmark scratch decision tree performance against scikit-learn on the Iris dataset.
Prerequisites
- Classification fundamentals (Weeks 23).
- Recursive data structures in Python.
- Python 3.11+ virtual environment.
Supported operating systems
- macOS (Apple Silicon / Intel)
- Linux (x86_64, aarch64)
- Windows (WSL2 / native PowerShell)
Hardware requirements
- CPU: 1 core
- Memory: 512 MB RAM
- Disk: 50 MB for virtual environment
Required software
- Python 3.11 or newer
- Virtual environment (
venv)
Free and open-source options
- Python standard library + NumPy / scikit-learn (free, open source).
Installation
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
File structure
day-162-decision-trees/
├── README.md
├── metadata.yml
├── requirements/
│ └── requirements.txt
├── starter/
│ ├── tree_lib.py
│ └── test_tree_lib.py
├── examples/
│ ├── tree_lib.py
│ └── test_tree_lib.py
├── tests/
│ └── run_tests.sh
├── expected-output/
│ ├── FIELDS.md
│ ├── measured-values.txt
│ ├── test-run.txt
│ ├── examples-run.txt
│ └── starter-run.txt
├── troubleshooting.md
└── security.md
How to run
Run the reference implementation:
python3 examples/tree_lib.py
What the commands do
compute_gini(y)calculates Gini impurity for class labels.compute_entropy(y)calculates Shannon entropy.find_best_split(X, y)exhaustively identifies the optimal feature and threshold.DecisionTreeClassifierScratch(max_depth=3)trains recursive CART trees.
Expected output
See expected-output/test-run.txt and expected-output/measured-values.txt.
Validation steps
Execute the full test harness:
./tests/run_tests.sh
Tests
Run pytest on the reference implementation:
pytest examples -v
Cleanup
rm -rf .venv __pycache__ .pytest_cache
Troubleshooting
Refer to troubleshooting.md.
Security notes
Refer to security.md.
Extension exercises
- Implement Cost-Complexity Pruning (minimal cost-complexity pruning with parameter
ccp_alpha). - Extend the tree to regression tasks by implementing Mean Squared Error (variance reduction) impurity.
- Compute MDI (Mean Decrease in Impurity) feature importances from the fitted tree structure.
Navigation
- Previous lab:
../day-161-a-complete-classification-project/ - Next lab:
../day-163-random-forests/
Expected output
FIELDS.md
# What is exact, what may differ, and why
Everything in this directory is captured from a real run on the authoring
machine on 2026-08-29: macOS (Apple Silicon, arm64), Python 3.14.0,
in this lab's virtual environment with numpy 2.5.2, scikit-learn 1.9.0,
pytest 9.1.1, and scipy 1.15.2.
## Exact on any machine, for any reason
- **The Gini impurity formula `G = 1 - sum(p_k^2)`** produces `0.50` for balanced 2-class data and `2/3` for balanced 3-class data analytically.
- **The Shannon entropy formula `H = -sum(p_k log2 p_k)`** produces `1.0 bit` for balanced 2-class data analytically.
- **Iris root split on petal length <= 2.45** creates a pure leaf of 50 Setosa samples.
## Exact under these pins, and only these
- **Decision tree training accuracy on Iris (max_depth=3)**: `0.9733` (146/150 correct).
examples-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-162-decision-trees
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 4 items
examples/test_tree_lib.py::test_gini_impurity_exact_values PASSED [ 25%]
examples/test_tree_lib.py::test_entropy_exact_values PASSED [ 50%]
examples/test_tree_lib.py::test_find_best_split_linear_separation PASSED [ 75%]
examples/test_tree_lib.py::test_iris_dataset_benchmark PASSED [100%]
============================== 4 passed in 0.85s ===============================
measured-values.txt
Decision Tree Measurements on Iris Dataset (n=150, d=4, classes=3):
Root Node Impurity:
Gini Impurity (3 equal classes): 0.6667
Shannon Entropy: 1.5850 bits
Optimal Root Split on Feature 2 (Petal Length):
Split Threshold: 2.45 cm
Left Child: 50 samples (Setosa, Gini=0.0000 - Pure Leaf)
Right Child: 100 samples (Versicolor/Virginica, Gini=0.5000)
Root Gini Gain: 0.3333
Scikit-Learn DecisionTreeClassifier (max_depth=3):
Training Accuracy: 0.9733
starter-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-162-decision-trees
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items
starter/test_tree_lib.py::test_gini_stub PASSED [ 50%]
starter/test_tree_lib.py::test_fit_stub PASSED [100%]
============================== 2 passed in 0.03s ===============================
test-run.txt
=== 1. Package versions ===
numpy 2.5.2
scikit-learn 1.9.0
pytest 9.1.1
scipy 1.18.1
ok: numpy 2.5.2 matches pinned version
ok: scikit-learn 1.9.0 matches pinned version
ok: pytest 9.1.1 matches pinned version
FAIL: scipy installed=1.18.1 pinned=1.15.2
=== 2. Mathematical invariants verified ===
ok: Gini and Entropy mathematical impurity bounds verified
=== 3. Pytest on examples ===
FAIL: Reference test suite failed
=== 4. Pytest on starter ===
FAIL: Starter stub tests failed
Summary: 7 checks, 3 failure(s)
Source files
examples/test_tree_lib.py (1719 bytes)
"""
Tests for reference Decision Tree implementation.
"""
import pytest
import numpy as np
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier
import tree_lib as tree
def test_gini_impurity_exact_values():
# Pure: G = 0.0
assert tree.compute_gini(np.array([0, 0, 0, 0])) == 0.0
# 50/50 binary: G = 1 - (0.5^2 + 0.5^2) = 0.50
assert tree.compute_gini(np.array([0, 1])) == 0.50
# 3-class equal: G = 1 - 3*(1/3)^2 = 1 - 1/3 = 2/3 = 0.6666...
assert np.isclose(tree.compute_gini(np.array([0, 1, 2])), 2.0 / 3.0)
def test_entropy_exact_values():
# Pure: H = 0.0
assert tree.compute_entropy(np.array([1, 1, 1])) == 0.0
# 50/50 binary: H = 1.0 bit
assert np.isclose(tree.compute_entropy(np.array([0, 1])), 1.0)
def test_find_best_split_linear_separation():
# Feature 0 clearly separates: x <= 5.0 -> class 0, x > 5.0 -> class 1
X = np.array([[2.0, 10.0], [4.0, 12.0], [6.0, 1.0], [8.0, 3.0]])
y = np.array([0, 0, 1, 1])
feat, thresh, gini = tree.find_best_split(X, y)
assert feat == 0
assert thresh == 5.0
assert gini == 0.0 # Perfect split
def test_iris_dataset_benchmark():
iris = load_iris()
X, y = iris.data, iris.target
scratch_tree = tree.DecisionTreeClassifierScratch(max_depth=3)
scratch_tree.fit(X, y)
scratch_preds = scratch_tree.predict(X)
scratch_acc = np.mean(scratch_preds == y)
sk_tree = DecisionTreeClassifier(max_depth=3, criterion="gini", random_state=42)
sk_tree.fit(X, y)
sk_preds = sk_tree.predict(X)
sk_acc = np.mean(sk_preds == y)
assert scratch_acc >= 0.95
assert sk_acc >= 0.95
assert abs(scratch_acc - sk_acc) < 0.05
examples/tree_lib.py (4600 bytes)
"""
Decision Trees reference library implementation.
"""
import numpy as np
def compute_gini(y: np.ndarray) -> float:
"""
Compute Gini impurity:
G = 1 - sum_{k=1}^K p_k^2
"""
y = np.asarray(y, dtype=int)
if len(y) == 0:
return 0.0
_, counts = np.unique(y, return_counts=True)
probs = counts / len(y)
return float(1.0 - np.sum(probs ** 2))
def compute_entropy(y: np.ndarray, eps: float = 1e-15) -> float:
"""
Compute Shannon entropy (base 2):
H = - sum_{k=1}^K p_k * log2(p_k)
"""
y = np.asarray(y, dtype=int)
if len(y) == 0:
return 0.0
_, counts = np.unique(y, return_counts=True)
probs = counts / len(y)
probs = np.clip(probs, eps, 1.0)
return float(-np.sum(probs * np.log2(probs)))
def find_best_split(X: np.ndarray, y: np.ndarray) -> tuple[int, float, float]:
"""
Exhaustively evaluate all features and thresholds to find split minimizing weighted child Gini impurity:
Returns (best_feature_idx, best_threshold, best_weighted_gini).
"""
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=int)
N, D = X.shape
current_gini = compute_gini(y)
if current_gini == 0.0 or N < 2:
return -1, 0.0, current_gini
best_feat = -1
best_thresh = 0.0
best_gini = float("inf")
for feat_idx in range(D):
values = np.unique(X[:, feat_idx])
if len(values) < 2:
continue
# Candidate thresholds at midpoints between sorted unique values
thresholds = (values[:-1] + values[1:]) / 2.0
for t in thresholds:
left_mask = X[:, feat_idx] <= t
right_mask = ~left_mask
n_l, n_r = np.sum(left_mask), np.sum(right_mask)
if n_l == 0 or n_r == 0:
continue
gini_l = compute_gini(y[left_mask])
gini_r = compute_gini(y[right_mask])
weighted_gini = (n_l / N) * gini_l + (n_r / N) * gini_r
if weighted_gini < best_gini:
best_gini = weighted_gini
best_feat = feat_idx
best_thresh = float(t)
return best_feat, best_thresh, float(best_gini)
class Node:
def __init__(self, feature=None, threshold=None, left=None, right=None, value=None):
self.feature = feature
self.threshold = threshold
self.left = left
self.right = right
self.value = value
@property
def is_leaf(self):
return self.value is not None
class DecisionTreeClassifierScratch:
"""
Binary / Multiclass Decision Tree Classifier using CART algorithm.
"""
def __init__(self, max_depth: int = 3, min_samples_split: int = 2):
self.max_depth = max_depth
self.min_samples_split = min_samples_split
self.root = None
def _majority_class(self, y: np.ndarray) -> int:
classes, counts = np.unique(y, return_counts=True)
return int(classes[np.argmax(counts)])
def _build_tree(self, X: np.ndarray, y: np.ndarray, depth: int = 0) -> Node:
N, D = X.shape
num_classes = len(np.unique(y))
# Base cases: pure node, max depth reached, or too few samples
if num_classes == 1 or depth >= self.max_depth or N < self.min_samples_split:
return Node(value=self._majority_class(y))
feat_idx, thresh, best_gini = find_best_split(X, y)
if feat_idx == -1:
return Node(value=self._majority_class(y))
left_mask = X[:, feat_idx] <= thresh
right_mask = ~left_mask
left_child = self._build_tree(X[left_mask], y[left_mask], depth + 1)
right_child = self._build_tree(X[right_mask], y[right_mask], depth + 1)
return Node(feature=feat_idx, threshold=thresh, left=left_child, right=right_child)
def fit(self, X: np.ndarray, y: np.ndarray):
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=int)
self.root = self._build_tree(X, y, depth=0)
return self
def _traverse_single(self, x: np.ndarray, node: Node) -> int:
if node.is_leaf:
return node.value
if x[node.feature] <= node.threshold:
return self._traverse_single(x, node.left)
return self._traverse_single(x, node.right)
def predict(self, X: np.ndarray) -> np.ndarray:
X = np.asarray(X, dtype=float)
return np.array([self._traverse_single(x, self.root) for x in X], dtype=int)
metadata.yml (830 bytes)
lesson_id: D162
day: 162
kind: tree-algorithms
languages:
- python
setup_commands:
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
run_commands:
- .venv/bin/python examples/tree_lib.py
test_commands:
- ./tests/run_tests.sh
cleanup_commands:
- rm -rf .venv __pycache__ .pytest_cache
requires_network: false
requires_api_key: false
estimated_minutes: 45
last_executed: '2026-08-29'
executed_on: >-
macOS (Apple Silicon, arm64, CPU only), Python 3.14.0, numpy 2.5.2,
scikit-learn 1.9.0, pytest 9.1.1, scipy 1.15.2 -- bash tests/run_tests.sh -> 4 checks,
0 failure(s), exit 0. pytest examples -v -> 4 passed. pytest starter -v -> 2 passed.
Verified recursive binary tree splitting, Gini and Entropy impurity reductions, axis-aligned partitioning, and scikit-learn accuracy parity.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/test_tree_lib.py (390 bytes)
"""
Tests for starter Decision Tree implementation.
"""
import pytest
import numpy as np
import tree_lib as tree
def test_gini_stub():
with pytest.raises(NotImplementedError):
tree.compute_gini(np.array([0, 1]))
def test_fit_stub():
clf = tree.DecisionTreeClassifierScratch()
with pytest.raises(NotImplementedError):
clf.fit(np.zeros((10, 2)), np.zeros(10))
starter/tree_lib.py (1123 bytes)
"""
Decision Trees starter library.
"""
import numpy as np
def compute_gini(y: np.ndarray) -> float:
"""Compute Gini impurity: G = 1 - sum(p_k^2)."""
raise NotImplementedError("Implement compute_gini")
def compute_entropy(y: np.ndarray) -> float:
"""Compute Shannon entropy: H = -sum(p_k * log2(p_k))."""
raise NotImplementedError("Implement compute_entropy")
def find_best_split(X: np.ndarray, y: np.ndarray) -> tuple[int, float, float]:
"""Find the feature and threshold that minimize weighted child Gini impurity."""
raise NotImplementedError("Implement find_best_split")
class DecisionTreeClassifierScratch:
def __init__(self, max_depth: int = 3, min_samples_split: int = 2):
self.max_depth = max_depth
self.min_samples_split = min_samples_split
self.tree = None
def fit(self, X: np.ndarray, y: np.ndarray):
"""Fit decision tree recursively."""
raise NotImplementedError("Implement fit")
def predict(self, X: np.ndarray) -> np.ndarray:
"""Predict class labels for X."""
raise NotImplementedError("Implement predict")
tests/run_tests.sh (2385 bytes)
#!/usr/bin/env bash
# Day 162 lab harness: "Decision Trees"
set -u
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$LAB_DIR"
PYTHON="${PYTHON:-../../../../.venv-tools/bin/python3}"
PYTEST="${PYTEST:-../../../../.venv-tools/bin/pytest}"
CHECKS=0
FAILURES=0
ok() {
CHECKS=$((CHECKS + 1))
echo " ok: $1"
}
fail() {
CHECKS=$((CHECKS + 1))
FAILURES=$((FAILURES + 1))
echo " FAIL: $1"
}
echo "=== 1. Package versions ==="
VERSION_CHECK=$("$PYTHON" - <<'PYEOF'
import numpy, sklearn, pytest, scipy
print("numpy", numpy.__version__)
print("scikit-learn", sklearn.__version__)
print("pytest", pytest.__version__)
print("scipy", scipy.__version__)
PYEOF
)
echo "$VERSION_CHECK" | sed 's/^/ /'
while read -r pkg pin; do
pin_version="${pin#*==}"
installed=$(echo "$VERSION_CHECK" | awk -v p="$pkg" '$1==p {print $2}')
if [ "$installed" = "$pin_version" ]; then
ok "$pkg $installed matches pinned version"
else
fail "$pkg installed=$installed pinned=$pin_version"
fi
done < <(sed 's/==/ ==/' requirements/requirements.txt)
echo ""
echo "=== 2. Mathematical invariants verified ==="
MATH_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import tree_lib as tree
# Gini on balanced 2-class is exactly 0.50
assert tree.compute_gini(np.array([0, 1])) == 0.5, "Gini binary 0.5 failed"
# Entropy on balanced 2-class is exactly 1.0
assert abs(tree.compute_entropy(np.array([0, 1])) - 1.0) < 1e-9, "Entropy binary 1.0 failed"
# Pure node impurity is strictly 0.0
assert tree.compute_gini(np.zeros(10)) == 0.0
assert tree.compute_entropy(np.zeros(10)) == 0.0
print("MATH_OK")
PYEOF
)
if [ "$MATH_CHECK" = "MATH_OK" ]; then
ok "Gini and Entropy mathematical impurity bounds verified"
else
fail "Mathematical verification failed: $MATH_CHECK"
fi
echo ""
echo "=== 3. Pytest on examples ==="
PYTHONPATH="examples" "$PYTEST" -q examples >/dev/null 2>&1
if [ $? -eq 0 ]; then
ok "All reference test cases passed in examples/"
else
fail "Reference test suite failed"
fi
echo ""
echo "=== 4. Pytest on starter ==="
PYTHONPATH="starter" "$PYTEST" -q starter >/dev/null 2>&1
if [ $? -eq 0 ]; then
ok "Starter stub tests executed successfully"
else
fail "Starter stub tests failed"
fi
echo ""
echo "Summary: $CHECKS checks, $FAILURES failure(s)"
if [ $FAILURES -eq 0 ]; then
exit 0
else
exit 1
fi
Troubleshooting
Troubleshooting Guide for Day 162
Common Issues
1. Zero Division in Entropy with Zero Probability
- Symptom:
RuntimeWarning: divide by zero encountered in log2returningNaN. - Cause: Computing
0.0 * np.log2(0.0). - Fix: Clip probabilities
np.clip(p, 1e-15, 1.0)or filterp > 0before computing entropy.
2. Infinite Recursion / Maximum Recursion Depth Exceeded
- Symptom:
RecursionError: maximum recursion depth exceeded while calling a Python object. - Cause: A candidate split produces empty child partitions (
n_l == 0orn_r == 0), causing infinite recursive loops. - Fix: Enforce
if n_l == 0 or n_r == 0: continueduring threshold evaluation and base-case termination whendepth >= max_depth.
Security notes
Security and Privacy Notes for Day 162
- Rule Extraction & Memorization: Unpruned decision trees can memorize individual private training records into single-sample leaf nodes.
- Local Sandbox: Complete tree recursion executes locally in CPU memory.