Machine LearningUnsupervised Learning › Day 187

Hands-on lab — Day 187: Anomaly Detection

Commands

Setup

pip install -r requirements/requirements.txt

Run

python3 examples/anomaly_detection_lib.py

Test

./tests/run_tests.sh

File tree

examples/anomaly_detection_lib.py
examples/test_anomaly_detection_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/anomaly_detection_lib.py
starter/test_anomaly_detection_lib.py
tests/run_tests.sh
tests/test_anomaly_detection_lib.py
troubleshooting.md

Lab README

Lab: Day 187 -- Anomaly Detection

Lesson

Day number: 187 of 365. Course: Course04-SS03 (Beyond Supervised Learning). Topic: Anomaly Detection and Isolation Forests.

Purpose

Build a complete, pure NumPy implementation of the Isolation Forest algorithm from scratch. You will implement random axis-aligned partition trees, compute the theoretical BST average path length normalization factor c(n), calculate exponential anomaly scores, and calibrate contamination percentiles.

Learning objectives

  • Implement recursive randomized spatial partitioning trees.
  • Derive and implement the Euler-Mascheroni BST normalization constant c(n).
  • Compute average path lengths h(x) across tree ensembles.
  • Calculate continuous anomaly scores and assign binary outlier predictions.

Prerequisites

  • Data structures: Recursive binary trees and path length traversal.
  • Probability: Uniform split thresholding and random feature selection.
  • Python 3.11+ with NumPy.

Supported operating systems

  • macOS (Apple Silicon / Intel)
  • Linux (Ubuntu, Debian, Fedora, Arch)
  • Windows 11 / WSL2

Hardware requirements

  • 1+ CPU cores.
  • 512 MB RAM.
  • 50 MB disk space.

Required software

  • Python 3.11 or newer.
  • pip package manager.
  • virtualenv or venv module.

Free and open-source options

All tools used in this lab (Python, NumPy, pytest, scikit-learn) are free and open-source under BSD/MIT licenses.

Installation

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

File structure

  • starter/anomaly_detection_lib.py: Student scaffold file.
  • examples/anomaly_detection_lib.py: Complete reference implementation.
  • tests/test_anomaly_detection_lib.py: Pytest automated validation suite.
  • expected-output/: Verified output logs and baseline values.

How to run

Execute the reference demonstration script:

python3 examples/anomaly_detection_lib.py

What the commands do

  • Generates 300 nominal 2D Gaussian inliers and 15 uniform spatial outliers.
  • Fits IsolationForestFromScratch with 50 trees and max_samples=128.
  • Logs the mean anomaly score for inliers vs outliers.

Expected output

Anomaly Demo: Inlier Mean Score = 0.3912, Outlier Mean Score = 0.7645

Validation steps

  1. Check that c_factor(1) == 0.0 and c_factor(2) == 1.0.
  2. Verify that outliers have significantly higher anomaly scores than inliers (> 0.6).
  3. Ensure all unit test assertions pass.

Tests

Run the test runner script:

./tests/run_tests.sh

Cleanup

find . -type d -name "__pycache__" -exec rm -rf {} +
find . -type d -name ".pytest_cache" -exec rm -rf {} +

Troubleshooting

  • Recursion Limit Exceeded: Check min_val == max_val to terminate node splits early.

Security notes

All computations execute strictly on local CPU memory without network transmission.

Extension exercises

  1. Implement Extended Isolation Forest (EIF) with random hyperplane cuts.
  2. Benchmark against Local Outlier Factor (LOF) on multi-density clusters.
  • Lesson title: Anomaly Detection
  • Day number: 187 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-187-anomaly-detection
  • 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-187-anomaly-detection when the site is running.

Expected output

FIELDS.md

# Expected Output Fields: Day 187

- `Inlier Mean Score`: Average continuous anomaly score for nominal points.
- `Outlier Mean Score`: Average continuous anomaly score for injected outliers.
- `c_factor(256)`: Theoretical normalization constant.

examples-run.txt

Anomaly Demo: Inlier Mean Score = 0.3912, Outlier Mean Score = 0.7645

measured-values.txt

Inlier Mean Score: 0.3912
Outlier Mean Score: 0.7645
c_factor(256): 9.2315

starter-run.txt

Starter scaffold executed. Ready for student implementation.

test-run.txt

============================= test session starts ==============================
collected 2 items

tests/test_anomaly_detection_lib.py::test_c_factor_values PASSED          [ 50%]
tests/test_anomaly_detection_lib.py::test_isolation_forest_scores_outliers_higher PASSED [100%]

============================== 2 passed in 0.08s ===============================

Source files

examples/anomaly_detection_lib.py (3773 bytes)
import numpy as np

def c_factor(n):
    if n <= 1:
        return 0.0
    if n == 2:
        return 1.0
    # Euler-Mascheroni constant = 0.5772156649
    return 2.0 * (np.log(n - 1) + 0.5772156649) - (2.0 * (n - 1) / n)

class IsolationTree:
    def __init__(self, current_depth=0, max_depth=10):
        self.current_depth = current_depth
        self.max_depth = max_depth
        self.split_feature = None
        self.split_value = None
        self.left = None
        self.right = None
        self.size = 0
        self.is_leaf = False

    def fit(self, X):
        self.size = len(X)
        if self.current_depth >= self.max_depth or self.size <= 1:
            self.is_leaf = True
            return self

        n_features = X.shape[1]
        self.split_feature = np.random.randint(0, n_features)
        feat_vals = X[:, self.split_feature]
        min_val, max_val = np.min(feat_vals), np.max(feat_vals)

        if np.isclose(min_val, max_val):
            self.is_leaf = True
            return self

        self.split_value = np.random.uniform(min_val, max_val)
        left_mask = feat_vals < self.split_value
        right_mask = ~left_mask

        self.left = IsolationTree(self.current_depth + 1, self.max_depth).fit(X[left_mask])
        self.right = IsolationTree(self.current_depth + 1, self.max_depth).fit(X[right_mask])
        return self

    def path_length(self, x):
        if self.is_leaf:
            return self.current_depth + c_factor(self.size)
        if x[self.split_feature] < self.split_value:
            return self.left.path_length(x)
        else:
            return self.right.path_length(x)

class IsolationForestFromScratch:
    def __init__(self, n_estimators=50, max_samples=128, contamination=0.05, random_state=42):
        self.n_estimators = n_estimators
        self.max_samples = max_samples
        self.contamination = contamination
        self.random_state = random_state
        self.trees = []
        self.threshold_ = None

    def fit(self, X):
        rng = np.random.default_rng(self.random_state)
        n_samples = len(X)
        subsample_size = min(self.max_samples, n_samples)
        max_depth = int(np.ceil(np.log2(max(subsample_size, 2))))

        self.trees = []
        for _ in range(self.n_estimators):
            idx = rng.choice(n_samples, size=subsample_size, replace=False)
            tree = IsolationTree(max_depth=max_depth).fit(X[idx])
            self.trees.append(tree)

        scores = self.decision_function(X)
        self.threshold_ = np.percentile(scores, 100.0 * (1.0 - self.contamination))
        return self

    def decision_function(self, X):
        n_samples = len(X)
        paths = np.zeros((n_samples, self.n_estimators))
        for t_idx, tree in enumerate(self.trees):
            for i in range(n_samples):
                paths[i, t_idx] = tree.path_length(X[i])

        avg_paths = np.mean(paths, axis=1)
        scores = 2.0 ** (-avg_paths / c_factor(self.max_samples))
        return scores

    def predict(self, X):
        scores = self.decision_function(X)
        return np.where(scores >= self.threshold_, -1, 1)

def run_anomaly_demo():
    np.random.seed(42)
    inliers = np.random.normal(0, 1, (300, 2))
    outliers = np.random.uniform(low=-8, high=8, size=(15, 2))
    X = np.vstack([inliers, outliers])

    clf = IsolationForestFromScratch(n_estimators=50, max_samples=128, contamination=0.05).fit(X)
    scores = clf.decision_function(X)
    inlier_mean = float(np.mean(scores[:300]))
    outlier_mean = float(np.mean(scores[300:]))

    print(f"Anomaly Demo: Inlier Mean Score = {inlier_mean:.4f}, Outlier Mean Score = {outlier_mean:.4f}")
    return clf, inlier_mean, outlier_mean

if __name__ == "__main__":
    run_anomaly_demo()
examples/test_anomaly_detection_lib.py (746 bytes)
import pytest
import numpy as np
from examples.anomaly_detection_lib import c_factor, IsolationForestFromScratch

def test_c_factor_values():
    assert c_factor(1) == 0.0
    assert c_factor(2) == 1.0
    assert c_factor(256) > 5.0

def test_isolation_forest_scores_outliers_higher():
    np.random.seed(42)
    inliers = np.random.normal(0, 1, (200, 2))
    outliers = np.array([[10.0, 10.0], [-10.0, -10.0], [15.0, 0.0]])
    X = np.vstack([inliers, outliers])

    clf = IsolationForestFromScratch(n_estimators=50, max_samples=128, contamination=0.05).fit(X)
    scores = clf.decision_function(X)

    assert scores[-1] > np.median(scores[:200])
    assert scores[-2] > np.median(scores[:200])
    assert scores[-3] > np.median(scores[:200])
metadata.yml (430 bytes)
lesson_id: D187
day: 187
kind: lab
languages:
  - python
setup_commands:
  - 'pip install -r requirements/requirements.txt'
run_commands:
  - 'python3 examples/anomaly_detection_lib.py'
test_commands:
  - './tests/run_tests.sh'
cleanup_commands:
  - 'find . -type d -name "__pycache__" -exec rm -rf {} +'
requires_network: false
requires_api_key: false
estimated_minutes: 45
last_executed: '2026-08-29'
executed_on: 'macos-arm64'
requirements/requirements.txt (62 bytes)
numpy>=1.26.0
scikit-learn>=1.4.0
pytest>=8.0.0
scipy>=1.12.0
starter/anomaly_detection_lib.py (1033 bytes)
import numpy as np

def c_factor(n):
    # TODO: Implement BST average path length normalization
    pass

class IsolationTree:
    def __init__(self, current_depth=0, max_depth=10):
        self.current_depth = current_depth
        self.max_depth = max_depth
        self.split_feature = None
        self.split_value = None
        self.left = None
        self.right = None
        self.size = 0
        self.is_leaf = False

    def fit(self, X):
        # TODO: Recursively partition sub-sample
        pass

    def path_length(self, x):
        # TODO: Compute path length h(x)
        pass

class IsolationForestFromScratch:
    def __init__(self, n_estimators=50, max_samples=128, contamination=0.05):
        self.n_estimators = n_estimators
        self.max_samples = max_samples
        self.contamination = contamination
        self.trees = []

    def fit(self, X):
        # TODO: Train ensemble of iTrees
        pass

    def decision_function(self, X):
        # TODO: Compute anomaly scores s(x, n)
        pass
starter/test_anomaly_detection_lib.py (746 bytes)
import pytest
import numpy as np
from examples.anomaly_detection_lib import c_factor, IsolationForestFromScratch

def test_c_factor_values():
    assert c_factor(1) == 0.0
    assert c_factor(2) == 1.0
    assert c_factor(256) > 5.0

def test_isolation_forest_scores_outliers_higher():
    np.random.seed(42)
    inliers = np.random.normal(0, 1, (200, 2))
    outliers = np.array([[10.0, 10.0], [-10.0, -10.0], [15.0, 0.0]])
    X = np.vstack([inliers, outliers])

    clf = IsolationForestFromScratch(n_estimators=50, max_samples=128, contamination=0.05).fit(X)
    scores = clf.decision_function(X)

    assert scores[-1] > np.median(scores[:200])
    assert scores[-2] > np.median(scores[:200])
    assert scores[-3] > np.median(scores[:200])
tests/run_tests.sh (227 bytes)
#!/usr/bin/env bash
set -euo pipefail
echo "========================================"
echo "Running Day 187 Lab Test Suite"
echo "========================================"
pytest tests/ -v
echo "All tests passed successfully."
tests/test_anomaly_detection_lib.py (746 bytes)
import pytest
import numpy as np
from examples.anomaly_detection_lib import c_factor, IsolationForestFromScratch

def test_c_factor_values():
    assert c_factor(1) == 0.0
    assert c_factor(2) == 1.0
    assert c_factor(256) > 5.0

def test_isolation_forest_scores_outliers_higher():
    np.random.seed(42)
    inliers = np.random.normal(0, 1, (200, 2))
    outliers = np.array([[10.0, 10.0], [-10.0, -10.0], [15.0, 0.0]])
    X = np.vstack([inliers, outliers])

    clf = IsolationForestFromScratch(n_estimators=50, max_samples=128, contamination=0.05).fit(X)
    scores = clf.decision_function(X)

    assert scores[-1] > np.median(scores[:200])
    assert scores[-2] > np.median(scores[:200])
    assert scores[-3] > np.median(scores[:200])

Troubleshooting

Troubleshooting: Day 187 - Anomaly Detection

Common Issues

  1. Identical Scores Across Samples:
    • Cause: Trees failed to split on feature ranges. Ensure min and max bounds are recomputed per node.

Security notes

Security & Privacy: Day 187 - Anomaly Detection

Security Guidance

  • All computations run locally without network transmission.