Machine Learning › Classification › Day 160
Hands-on lab — Day 160: Class Imbalance
- ← Back to the Day 160 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-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-imbalancewhen 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
- Derive and compute balanced class weights inversely proportional to class frequencies.
- Implement random undersampling of the majority class and random oversampling of the minority class.
- Build the SMOTE algorithm using k-NN distance matrices and linear segment interpolation.
- Compare algorithm-level cost-weighting against data-level resampling strategies.
- 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
- Implement Borderline-SMOTE, which generates synthetic samples only from minority instances near the decision boundary.
- Implement Tomek Links to clean overlapping noisy pairs from resampled datasets.
- Compare cost-sensitive Logistic Regression with Focal Loss on a 1:1,000 imbalanced fraud dataset.
Navigation
- 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_splitleaks 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.