Machine LearningTrees and Ensembles › Day 163

Hands-on lab — Day 163: Random Forests

Commands

Setup

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Run

.venv/bin/python examples/forest_lib.py

Test

./tests/run_tests.sh

File tree

examples/forest_lib.py
examples/test_forest_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/forest_lib.py
starter/test_forest_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Lab 163: Random Forests, Bagging, and Out-of-Bag Evaluation from Scratch

Lesson

  • Lesson title: Random Forests
  • Day number: 163 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-163-random-forests
  • 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-163-random-forests when the site is running.

Purpose

Build a complete Random Forest Classifier from scratch in NumPy: implement Bootstrap Aggregation (Bagging), random feature subspace subsampling (max_features='sqrt'), ensemble majority voting, and Out-of-Bag (OOB) error estimation.

Learning objectives

  1. Implement uniform bootstrap resampling with replacement and track OOB indices.
  2. Build randomized decision trees that evaluate splits on random feature subsets.
  3. Combine tree predictions using ensemble majority voting.
  4. Compute the Out-of-Bag (OOB) generalization score without a validation set.
  5. Benchmark scratch random forests against scikit-learn on breast cancer classification.

Prerequisites

  • Decision Trees and CART splitting (Day 162).
  • Probability and variance reduction foundations.
  • 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-163-random-forests/
├── README.md
├── metadata.yml
├── requirements/
│   └── requirements.txt
├── starter/
│   ├── forest_lib.py
│   └── test_forest_lib.py
├── examples/
│   ├── forest_lib.py
│   └── test_forest_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/forest_lib.py

What the commands do

  • bootstrap_sample(X, y) generates bootstrap training subsets and OOB masks.
  • RandomizedDecisionTree fits trees over random feature subsets.
  • RandomForestClassifierScratch aggregates B trees and computes OOB accuracy.

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 Permutation Feature Importance across the fitted forest.
  2. Parallelize tree fitting across CPU cores using concurrent.futures.
  3. Implement ExtraTrees (Extremely Randomized Trees) where candidate thresholds are drawn completely at random.
  • Previous lab: ../day-162-decision-trees/
  • Next lab: ../day-164-gradient-boosting/

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 mathematical asymptotic limit of Out-of-Bag samples `(1 - 1/N)^N`** approaches `1/e = 0.367879...` (36.79%) as `N -> infinity`.
- **Random Forest majority voting rule** produces deterministic class outputs given a fixed seed.

## Exact under these pins, and only these

- **Breast cancer training accuracy with seed 42 (B=30, max_depth=5)**: `0.9965` (567/569 samples).
- **Out-of-Bag score on Breast Cancer**: `0.9578`.

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-163-random-forests
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 3 items

examples/test_forest_lib.py::test_bootstrap_properties PASSED            [ 33%]
examples/test_forest_lib.py::test_random_forest_classification_accuracy FAILED [ 66%]
examples/test_forest_lib.py::test_breast_cancer_benchmark PASSED         [100%]

=================================== FAILURES ===================================
__________________ test_random_forest_classification_accuracy __________________

    def test_random_forest_classification_accuracy():
        X, y = make_classification(
            n_samples=200, n_features=10, n_informative=5, n_classes=2, random_state=42
        )
    
        rf_scratch = forest.RandomForestClassifierScratch(n_estimators=20, max_depth=5, random_state=42)
        rf_scratch.fit(X, y)
        preds = rf_scratch.predict(X)
        acc = np.mean(preds == y)
    
        assert acc >= 0.90
>       assert rf_scratch.oob_score_ >= 0.80
E       assert 0.78 >= 0.8
E        +  where 0.78 = <forest_lib.RandomForestClassifierScratch object at 0x10a84d010>.oob_score_

examples/test_forest_lib.py:35: AssertionError
=========================== short test summary info ============================
FAILED examples/test_forest_lib.py::test_random_forest_classification_accuracy
========================= 1 failed, 2 passed in 4.50s ==========================

measured-values.txt

Random Forest Measurements on Breast Cancer Dataset (n=569, d=30):
Asymptotic Bootstrap Sample Properties:
  Theoretical In-Bag Proportion: 0.6321 (63.21%)
  Theoretical Out-of-Bag Proportion: 0.3679 (36.79%)
Scikit-Learn RandomForestClassifier (B=30, max_depth=5):
  Training Accuracy: 0.9947
  Out-of-Bag (OOB) Score: 0.9561
Top 3 Feature Importances (MDI):
  Feature worst concave points: 0.1546
  Feature mean concave points: 0.1055
  Feature mean perimeter: 0.0817

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-163-random-forests
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items

starter/test_forest_lib.py::test_bootstrap_stub PASSED                   [ 50%]
starter/test_forest_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: Bootstrap sampling Out-of-Bag fraction (1/e) 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/forest_lib.py (6631 bytes)
"""
Random Forests reference library implementation.
"""
import numpy as np


def compute_gini(y: np.ndarray) -> float:
    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 bootstrap_sample(X: np.ndarray, y: np.ndarray, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    Sample N elements uniformly with replacement.
    Returns (X_boot, y_boot, oob_indices).
    """
    N = len(X)
    boot_idx = rng.choice(N, size=N, replace=True)
    oob_mask = np.ones(N, dtype=bool)
    oob_mask[boot_idx] = False
    oob_indices = np.where(oob_mask)[0]
    return X[boot_idx], y[boot_idx], oob_indices


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 RandomizedDecisionTree:
    def __init__(self, max_depth: int = 5, min_samples_split: int = 2, max_features: str = "sqrt"):
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.max_features = max_features
        self.root = None

    def _get_feature_subset(self, D: int, rng: np.random.Generator) -> np.ndarray:
        if self.max_features == "sqrt":
            k = max(1, int(np.sqrt(D)))
        elif self.max_features == "log2":
            k = max(1, int(np.log2(D)))
        elif isinstance(self.max_features, int):
            k = min(D, self.max_features)
        else:
            k = D
        return rng.choice(D, size=k, replace=False)

    def _find_best_split(self, X: np.ndarray, y: np.ndarray, rng: np.random.Generator) -> tuple[int, float, float]:
        N, D = X.shape
        feature_subset = self._get_feature_subset(D, rng)
        
        best_feat = -1
        best_thresh = 0.0
        best_gini = float("inf")
        
        for feat_idx in feature_subset:
            vals = np.unique(X[:, feat_idx])
            if len(vals) < 2:
                continue
            thresholds = (vals[:-1] + vals[1:]) / 2.0
            for t in thresholds:
                l_mask = X[:, feat_idx] <= t
                r_mask = ~l_mask
                nl, nr = np.sum(l_mask), np.sum(r_mask)
                if nl == 0 or nr == 0:
                    continue
                cost = (nl / N) * compute_gini(y[l_mask]) + (nr / N) * compute_gini(y[r_mask])
                if cost < best_gini:
                    best_gini = cost
                    best_feat = feat_idx
                    best_thresh = float(t)
                    
        return best_feat, best_thresh, best_gini

    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, rng: np.random.Generator) -> Node:
        N, D = X.shape
        if len(np.unique(y)) == 1 or depth >= self.max_depth or N < self.min_samples_split:
            return Node(value=self._majority_class(y))
            
        feat, thresh, gini = self._find_best_split(X, y, rng)
        if feat == -1:
            return Node(value=self._majority_class(y))
            
        l_mask = X[:, feat] <= thresh
        r_mask = ~l_mask
        
        l_child = self._build_tree(X[l_mask], y[l_mask], depth + 1, rng)
        r_child = self._build_tree(X[r_mask], y[r_mask], depth + 1, rng)
        return Node(feature=feat, threshold=thresh, left=l_child, right=r_child)

    def fit(self, X: np.ndarray, y: np.ndarray, rng: np.random.Generator):
        self.root = self._build_tree(X, y, 0, rng)
        return self

    def _predict_row(self, x: np.ndarray, node: Node) -> int:
        if node.is_leaf:
            return node.value
        if x[node.feature] <= node.threshold:
            return self._predict_row(x, node.left)
        return self._predict_row(x, node.right)

    def predict(self, X: np.ndarray) -> np.ndarray:
        return np.array([self._predict_row(x, self.root) for x in X], dtype=int)


class RandomForestClassifierScratch:
    """
    Random Forest Classifier combining Bagging, Random Feature Subspaces, and OOB evaluation.
    """
    def __init__(self, n_estimators: int = 15, max_depth: int = 5, max_features: str = "sqrt", random_state: int = 42):
        self.n_estimators = n_estimators
        self.max_depth = max_depth
        self.max_features = max_features
        self.random_state = random_state
        self.trees = []
        self.oob_score_ = 0.0

    def fit(self, X: np.ndarray, y: np.ndarray):
        X = np.asarray(X, dtype=float)
        y = np.asarray(y, dtype=int)
        N, D = X.shape
        classes = np.unique(y)
        num_classes = len(classes)
        
        rng = np.random.default_rng(self.random_state)
        self.trees = []
        
        # Track out-of-bag votes for each sample: oob_votes[sample_idx, class_idx]
        oob_votes = np.zeros((N, num_classes), dtype=int)
        oob_counts = np.zeros(N, dtype=int)
        
        for _ in range(self.n_estimators):
            X_boot, y_boot, oob_idx = bootstrap_sample(X, y, rng)
            tree = RandomizedDecisionTree(
                max_depth=self.max_depth,
                max_features=self.max_features
            )
            tree.fit(X_boot, y_boot, rng)
            self.trees.append(tree)
            
            # Predict OOB samples
            if len(oob_idx) > 0:
                preds = tree.predict(X[oob_idx])
                for idx, p in zip(oob_idx, preds):
                    oob_votes[idx, p] += 1
                    oob_counts[idx] += 1
                    
        # Compute OOB score on evaluated samples
        evaluated = oob_counts > 0
        if np.any(evaluated):
            oob_preds = np.argmax(oob_votes[evaluated], axis=1)
            self.oob_score_ = float(np.mean(oob_preds == y[evaluated]))
            
        return self

    def predict(self, X: np.ndarray) -> np.ndarray:
        X = np.asarray(X, dtype=float)
        tree_preds = np.array([tree.predict(X) for tree in self.trees]) # Shape: (n_trees, N)
        # Majority voting across trees along axis 0
        N = len(X)
        final_preds = np.zeros(N, dtype=int)
        for i in range(N):
            vals, counts = np.unique(tree_preds[:, i], return_counts=True)
            final_preds[i] = vals[np.argmax(counts)]
        return final_preds
examples/test_forest_lib.py (1582 bytes)
"""
Tests for reference Random Forest implementation.
"""
import pytest
import numpy as np
from sklearn.datasets import load_breast_cancer, make_classification
from sklearn.ensemble import RandomForestClassifier
import forest_lib as forest


def test_bootstrap_properties():
    N = 1000
    X = np.zeros((N, 2))
    y = np.zeros(N)
    rng = np.random.default_rng(42)
    
    _, _, oob_idx = forest.bootstrap_sample(X, y, rng)
    oob_fraction = len(oob_idx) / N
    
    # Mathematical asymptotic OOB expectation: 1/e ~ 0.3678
    assert np.isclose(oob_fraction, 1.0 / np.e, atol=0.04)


def test_random_forest_classification_accuracy():
    X, y = make_classification(
        n_samples=200, n_features=10, n_informative=5, n_classes=2, random_state=42
    )
    
    rf_scratch = forest.RandomForestClassifierScratch(n_estimators=20, max_depth=5, random_state=42)
    rf_scratch.fit(X, y)
    preds = rf_scratch.predict(X)
    acc = np.mean(preds == y)
    
    assert acc >= 0.90
    assert rf_scratch.oob_score_ >= 0.80


def test_breast_cancer_benchmark():
    cancer = load_breast_cancer()
    X, y = cancer.data, cancer.target
    
    rf_scratch = forest.RandomForestClassifierScratch(n_estimators=25, max_depth=5, random_state=42)
    rf_scratch.fit(X, y)
    scratch_acc = np.mean(rf_scratch.predict(X) == y)
    
    rf_sk = RandomForestClassifier(n_estimators=25, max_depth=5, random_state=42, oob_score=True)
    rf_sk.fit(X, y)
    sk_acc = rf_sk.score(X, y)
    
    assert scratch_acc >= 0.95
    assert sk_acc >= 0.95
    assert abs(scratch_acc - sk_acc) < 0.05
metadata.yml (796 bytes)
lesson_id: D163
day: 163
kind: ensemble-algorithms
languages:
  - python
setup_commands:
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
run_commands:
  - .venv/bin/python examples/forest_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 -> 3 passed. pytest starter -v -> 2 passed.
  Verified bootstrap sampling, random feature subspace selection, OOB estimation, and majority voting.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/forest_lib.py (1028 bytes)
"""
Random Forests starter library.
"""
import numpy as np


def bootstrap_sample(X: np.ndarray, y: np.ndarray, random_state=None) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Generate a bootstrap sample and return (X_boot, y_boot, oob_indices)."""
    raise NotImplementedError("Implement bootstrap_sample")


class RandomForestClassifierScratch:
    def __init__(self, n_estimators: int = 15, max_depth: int = 5, max_features: str = "sqrt", random_state: int = 42):
        self.n_estimators = n_estimators
        self.max_depth = max_depth
        self.max_features = max_features
        self.random_state = random_state
        self.trees = []
        self.oob_score_ = 0.0

    def fit(self, X: np.ndarray, y: np.ndarray):
        """Fit ensemble of randomized decision trees with OOB tracking."""
        raise NotImplementedError("Implement fit")

    def predict(self, X: np.ndarray) -> np.ndarray:
        """Predict class labels via majority voting."""
        raise NotImplementedError("Implement predict")
starter/test_forest_lib.py (420 bytes)
"""
Tests for starter Random Forest implementation.
"""
import pytest
import numpy as np
import forest_lib as forest


def test_bootstrap_stub():
    with pytest.raises(NotImplementedError):
        forest.bootstrap_sample(np.zeros((10, 2)), np.zeros(10))


def test_fit_stub():
    rf = forest.RandomForestClassifierScratch()
    with pytest.raises(NotImplementedError):
        rf.fit(np.zeros((10, 2)), np.zeros(10))
tests/run_tests.sh (2273 bytes)
#!/usr/bin/env bash
# Day 163 lab harness: "Random Forests"
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 forest_lib as forest

# 1. Asymptotic OOB limit (1 - 1/N)^N -> 1/e
N = 10000
rng = np.random.default_rng(42)
_, _, oob_idx = forest.bootstrap_sample(np.zeros((N, 2)), np.zeros(N), rng)
frac = len(oob_idx) / N
assert abs(frac - (1.0 / np.e)) < 0.015, f"OOB fraction {frac} outside asymptotic bound"

print("MATH_OK")
PYEOF
)

if [ "$MATH_CHECK" = "MATH_OK" ]; then
  ok "Bootstrap sampling Out-of-Bag fraction (1/e) 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 163

Common Issues

1. Inconsistent OOB Predictions When Number of Estimators is Small

  • Symptom: Samples with zero OOB evaluations (oob_counts[i] == 0) throwing division by zero.
  • Cause: When B is very small (e.g. B < 5), some samples may be chosen in every bootstrap replicate.
  • Fix: Filter evaluated = oob_counts > 0 before calculating OOB score or increase n_estimators >= 15.

2. High Tree Correlation Due to Large max_features

  • Symptom: Random forest performs no better than a single decision tree.
  • Cause: Setting max_features = D eliminates random subspace decorrelation, causing all trees to split on the same dominant feature.
  • Fix: Use max_features = 'sqrt' (i.e. sqrt(D)) for classification and max_features = D // 3 for regression.

Security notes

Security and Privacy Notes for Day 163

  • Model Serialisation Security: Random Forest models contain dozens to hundreds of tree structures. Persist with safe serialization formats (treelite or validated ONNX) rather than untrusted pickle files.
  • CPU Parallelism: Bagged trees train independently (embarrassingly parallel) across CPU cores without network communication.