Machine LearningTrees and Ensembles › Day 162

Hands-on lab — 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-trees when 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

  1. Implement Gini impurity and Shannon entropy impurity criteria.
  2. Find the globally optimal single-feature axis-aligned split threshold.
  3. Construct a recursive binary tree data structure with base-case stopping criteria.
  4. Predict class labels for unseen feature vectors by tree traversal.
  5. 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

  1. Implement Cost-Complexity Pruning (minimal cost-complexity pruning with parameter ccp_alpha).
  2. Extend the tree to regression tasks by implementing Mean Squared Error (variance reduction) impurity.
  3. Compute MDI (Mean Decrease in Impurity) feature importances from the fitted tree structure.
  • 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 log2 returning NaN.
  • Cause: Computing 0.0 * np.log2(0.0).
  • Fix: Clip probabilities np.clip(p, 1e-15, 1.0) or filter p > 0 before 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 == 0 or n_r == 0), causing infinite recursive loops.
  • Fix: Enforce if n_l == 0 or n_r == 0: continue during threshold evaluation and base-case termination when depth >= 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.