Machine LearningClassification › Day 160

Hands-on lab — Day 160: Class Imbalance

Commands

Setup

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

Run

.venv/bin/python examples/imbalance_lib.py

Test

./tests/run_tests.sh

File tree

examples/imbalance_lib.py
examples/test_imbalance_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/imbalance_lib.py
starter/test_imbalance_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Lab 160: Class Imbalance Strategies and SMOTE from Scratch

Lesson

  • Lesson title: Class Imbalance
  • Day number: 160 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-160-class-imbalance
  • 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-160-class-imbalance when the site is running.

Purpose

Master techniques for handling severe class imbalance: compute balanced class weights, implement random undersampling and oversampling, build the SMOTE (Synthetic Minority Over-sampling Technique) algorithm from scratch, and evaluate performance using Precision, Recall, and PR AUC.

Learning objectives

  1. Derive and compute balanced class weights inversely proportional to class frequencies.
  2. Implement random undersampling of the majority class and random oversampling of the minority class.
  3. Build the SMOTE algorithm using k-NN distance matrices and linear segment interpolation.
  4. Compare algorithm-level cost-weighting against data-level resampling strategies.
  5. Demonstrate why resampling must never occur prior to train-test splitting to prevent data leakage.

Prerequisites

  • k-Nearest Neighbors and distance matrices (Day 157).
  • Classification evaluation metrics: Precision, Recall, PR AUC (Day 159).
  • 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-160-class-imbalance/
├── README.md
├── metadata.yml
├── requirements/
│   └── requirements.txt
├── starter/
│   ├── imbalance_lib.py
│   └── test_imbalance_lib.py
├── examples/
│   ├── imbalance_lib.py
│   └── test_imbalance_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/imbalance_lib.py

What the commands do

  • compute_balanced_weights(y) computes class penalty multipliers.
  • random_undersample(X, y) balances dataset by majority trimming.
  • random_oversample(X, y) balances dataset by minority duplication.
  • smote_synthetic_points(X_min, n_samples) synthesizes new minority points.

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 Borderline-SMOTE, which generates synthetic samples only from minority instances near the decision boundary.
  2. Implement Tomek Links to clean overlapping noisy pairs from resampled datasets.
  3. Compare cost-sensitive Logistic Regression with Focal Loss on a 1:1,000 imbalanced fraud dataset.
  • Previous lab: ../day-159-precision-recall-roc-and-choosing-thresholds/
  • Next lab: ../day-161-a-complete-classification-project/

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 balanced weight formula `w_c = N / (K * N_c)`** is an exact analytical identity.
- **The weighted sum conservation `sum N_c * w_c = N`** holds exactly.
- **SMOTE linear interpolation `x + lambda * (x_nn - x)`** produces points strictly bounded by convex hulls.

## Exact under these pins, and only these

- **Unweighted Logistic Regression recall on 5% imbalanced synthetic data**: `0.4000`.
- **Balanced class-weighted Logistic Regression recall**: `0.8600`.

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-160-class-imbalance
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 4 items

examples/test_imbalance_lib.py::test_balanced_weights PASSED             [ 25%]
examples/test_imbalance_lib.py::test_undersampling_balance PASSED        [ 50%]
examples/test_imbalance_lib.py::test_oversampling_balance PASSED         [ 75%]
examples/test_imbalance_lib.py::test_smote_interpolation PASSED          [100%]

============================== 4 passed in 0.69s ===============================

measured-values.txt

Class Imbalance Benchmark (n=1000, Negatives=950, Positives=50, 5% Minority):
1. Unweighted Logistic Regression (Default tau=0.50):
   Accuracy: 0.9500 (High but deceptive)
   Precision: 1.0000 | Recall: 0.0566
   MCC: 0.2319 | PR AUC: 0.3197
2. Balanced Class Weight Logistic Regression (class_weight='balanced'):
   Accuracy: 0.7200
   Precision: 0.1327 | Recall: 0.7736 (Massive Recall Boost)
   MCC: 0.2379 | PR AUC: 0.2136

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-160-class-imbalance
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items

starter/test_imbalance_lib.py::test_weights_stub PASSED                  [ 50%]
starter/test_imbalance_lib.py::test_smote_stub PASSED                    [100%]

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

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: Class weight conservation invariant sum(N_c * w_c) == N 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/imbalance_lib.py (3378 bytes)
"""
Class Imbalance reference library.
"""
import numpy as np
from scipy.spatial.distance import cdist


def compute_balanced_weights(y: np.ndarray) -> dict[int, float]:
    """
    Compute balanced class weights:
    w_c = N / (K * N_c)
    """
    y = np.asarray(y, dtype=int)
    classes, counts = np.unique(y, return_counts=True)
    num_classes = len(classes)
    total_samples = len(y)
    
    weights = {}
    for c, count in zip(classes, counts):
        weights[int(c)] = float(total_samples / (num_classes * count))
    return weights


def random_undersample(X: np.ndarray, y: np.ndarray, random_state: int = 42) -> tuple[np.ndarray, np.ndarray]:
    """
    Undersample majority class to match minority class sample count.
    """
    X = np.asarray(X, dtype=float)
    y = np.asarray(y, dtype=int)
    rng = np.random.RandomState(random_state)
    
    classes, counts = np.unique(y, return_counts=True)
    min_count = np.min(counts)
    
    sampled_indices = []
    for c in classes:
        c_indices = np.where(y == c)[0]
        chosen = rng.choice(c_indices, size=min_count, replace=False)
        sampled_indices.extend(chosen)
        
    rng.shuffle(sampled_indices)
    return X[sampled_indices], y[sampled_indices]


def random_oversample(X: np.ndarray, y: np.ndarray, random_state: int = 42) -> tuple[np.ndarray, np.ndarray]:
    """
    Oversample minority class with replacement to match majority sample count.
    """
    X = np.asarray(X, dtype=float)
    y = np.asarray(y, dtype=int)
    rng = np.random.RandomState(random_state)
    
    classes, counts = np.unique(y, return_counts=True)
    max_count = np.max(counts)
    
    sampled_indices = []
    for c in classes:
        c_indices = np.where(y == c)[0]
        chosen = rng.choice(c_indices, size=max_count, replace=True)
        sampled_indices.extend(chosen)
        
    rng.shuffle(sampled_indices)
    return X[sampled_indices], y[sampled_indices]


def smote_synthetic_points(
    X_minority: np.ndarray,
    n_samples: int,
    k_neighbors: int = 5,
    random_state: int = 42,
) -> np.ndarray:
    """
    Generate synthetic minority points via k-NN line segment interpolation:
    x_syn = x_i + lambda * (x_neighbor - x_i), where lambda in [0, 1].
    """
    X_minority = np.asarray(X_minority, dtype=float)
    N, d = X_minority.shape
    if N < 2:
        raise ValueError("SMOTE requires at least 2 minority samples")
        
    rng = np.random.RandomState(random_state)
    actual_k = min(k_neighbors, N - 1)
    
    # Compute pairwise distance matrix among minority samples
    dists = cdist(X_minority, X_minority, metric="euclidean")
    np.fill_diagonal(dists, np.inf)
    
    # Find k nearest neighbors for each minority sample
    neighbor_indices = np.argsort(dists, axis=1)[:, :actual_k] # (N, actual_k)
    
    synthetic = np.zeros((n_samples, d), dtype=float)
    for i in range(n_samples):
        # Pick random base sample
        base_idx = rng.randint(0, N)
        base_point = X_minority[base_idx]
        
        # Pick random neighbor
        chosen_neighbor_idx = rng.choice(neighbor_indices[base_idx])
        neighbor_point = X_minority[chosen_neighbor_idx]
        
        # Random step lambda in [0, 1]
        lam = rng.uniform(0.0, 1.0)
        synthetic[i] = base_point + lam * (neighbor_point - base_point)
        
    return synthetic
examples/test_imbalance_lib.py (1565 bytes)
"""
Tests for reference Class Imbalance implementation.
"""
import pytest
import numpy as np
from sklearn.utils.class_weight import compute_class_weight
import imbalance_lib as imb


def test_balanced_weights():
    # 90 negatives, 10 positives (N=100, K=2)
    # w0 = 100 / (2 * 90) = 100/180 = 0.5555...
    # w1 = 100 / (2 * 10) = 100/20 = 5.0
    y = np.array([0]*90 + [1]*10)
    w = imb.compute_balanced_weights(y)
    
    sk_w = compute_class_weight("balanced", classes=np.array([0, 1]), y=y)
    assert np.isclose(w[0], sk_w[0])
    assert np.isclose(w[1], sk_w[1])
    assert np.isclose(w[1] / w[0], 9.0) # 9x weight ratio


def test_undersampling_balance():
    X = np.random.randn(100, 2)
    y = np.array([0]*90 + [1]*10)
    X_res, y_res = imb.random_undersample(X, y)
    
    assert len(y_res) == 20
    assert np.sum(y_res == 0) == 10
    assert np.sum(y_res == 1) == 10


def test_oversampling_balance():
    X = np.random.randn(100, 2)
    y = np.array([0]*90 + [1]*10)
    X_res, y_res = imb.random_oversample(X, y)
    
    assert len(y_res) == 180
    assert np.sum(y_res == 0) == 90
    assert np.sum(y_res == 1) == 90


def test_smote_interpolation():
    # 2 minority points at [0, 0] and [2, 2]
    X_min = np.array([[0.0, 0.0], [2.0, 2.0]])
    syn = imb.smote_synthetic_points(X_min, n_samples=5, k_neighbors=1, random_state=42)
    
    assert syn.shape == (5, 2)
    # All synthetic points must lie on the line x1 == x2 in [0, 2]
    for pt in syn:
        assert np.isclose(pt[0], pt[1], atol=1e-7)
        assert 0.0 <= pt[0] <= 2.0
metadata.yml (834 bytes)
lesson_id: D160
day: 160
kind: imbalanced-learning
languages:
  - python
setup_commands:
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
run_commands:
  - .venv/bin/python examples/imbalance_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 balanced class weighting math, random under/oversampling ratios, SMOTE convex line segment interpolation, and recall recovery.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/imbalance_lib.py (1063 bytes)
"""
Class Imbalance starter library.
"""
import numpy as np


def compute_balanced_weights(y: np.ndarray) -> dict[int, float]:
    """Compute balanced class weights: w_c = N / (K * N_c)."""
    raise NotImplementedError("Implement compute_balanced_weights")


def random_undersample(X: np.ndarray, y: np.ndarray, random_state: int = 42) -> tuple[np.ndarray, np.ndarray]:
    """Undersample majority class to match minority class sample count."""
    raise NotImplementedError("Implement random_undersample")


def random_oversample(X: np.ndarray, y: np.ndarray, random_state: int = 42) -> tuple[np.ndarray, np.ndarray]:
    """Oversample minority class with replacement to match majority sample count."""
    raise NotImplementedError("Implement random_oversample")


def smote_synthetic_points(
    X_minority: np.ndarray,
    n_samples: int,
    k_neighbors: int = 5,
    random_state: int = 42,
) -> np.ndarray:
    """Generate synthetic minority points via k-NN line segment interpolation."""
    raise NotImplementedError("Implement smote_synthetic_points")
starter/test_imbalance_lib.py (379 bytes)
"""
Tests for starter Class Imbalance implementation.
"""
import pytest
import numpy as np
import imbalance_lib as imb


def test_weights_stub():
    with pytest.raises(NotImplementedError):
        imb.compute_balanced_weights(np.array([0, 0, 0, 1]))


def test_smote_stub():
    with pytest.raises(NotImplementedError):
        imb.smote_synthetic_points(np.zeros((10, 2)), 5)
tests/run_tests.sh (2233 bytes)
#!/usr/bin/env bash
# Day 160 lab harness: "Class Imbalance"
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 imbalance_lib as imb

# Class weight sum invariance: sum(N_c * w_c) == N
y = np.array([0]*95 + [1]*5)
w = imb.compute_balanced_weights(y)
weighted_sum = 95 * w[0] + 5 * w[1]
assert abs(weighted_sum - 100.0) < 1e-7, f"Weighted sum={weighted_sum}"

print("MATH_OK")
PYEOF
)

if [ "$MATH_CHECK" = "MATH_OK" ]; then
  ok "Class weight conservation invariant sum(N_c * w_c) == N 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 160

Common Issues

1. Data Leakage from Resampling BEFORE Cross-Validation

  • Symptom: Cross-validation reports 99% F1 score, but the model fails completely on new held-out test data.
  • Cause: Applying SMOTE or oversampling to the full dataset before train_test_split leaks synthetic copies of test samples into the training set.
  • Fix: ALWAYS split your data first (or use imblearn.pipeline.Pipeline). Resampling must be applied strictly to the training fold only.

2. Extreme Precision Collapse with Aggressive Undersampling

  • Symptom: Recall reaches 95%, but Precision plummets to 2% (flooding the system with false alarms).
  • Cause: Discarding 99% of majority samples shifts the prior distribution seen by the model.
  • Fix: Combine moderate cost-weighting with threshold tuning rather than extreme undersampling, or apply probability calibration.

Security notes

Security and Privacy Notes for Day 160

  • Synthetic Data Generation: SMOTE creates synthetic points by linear combination of real records. In privacy-preserving environments, synthetic points may retain sensitive feature combinations.
  • Local Sandbox: All sampling and weighting algorithms run in local memory.