Skip to content

08 · Imbalanced Data & Advanced Evaluation

Module 05 introduced class_weight="balanced" as one fix for imbalance. This module goes deeper: resampling strategies, ROC and precision-recall curves, and choosing a decision threshold deliberately instead of accepting scikit-learn's default of 0.5.

A realistically imbalanced dataset

from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

X, y = make_classification(
    n_samples=5000, weights=[0.97, 0.03], flip_y=0.01, random_state=42
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y
)
print("train class counts:", dict(zip(*__import__("numpy").unique(y_train, return_counts=True))))
# train class counts: {0: 3638, 1: 112}

Resampling: oversampling, undersampling, SMOTE

from imblearn.over_sampling import RandomOverSampler, SMOTE
from imblearn.under_sampling import RandomUnderSampler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import recall_score, precision_score

def fit_and_report(X_tr, y_tr, label):
    clf = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
    pred = clf.predict(X_test)
    print(f"{label:16s} precision={precision_score(y_test, pred):.3f} "
          f"recall={recall_score(y_test, pred):.3f}")

fit_and_report(X_train, y_train, "baseline")

ros = RandomOverSampler(random_state=42)
X_ro, y_ro = ros.fit_resample(X_train, y_train)          # duplicates minority rows
fit_and_report(X_ro, y_ro, "oversampled")

sm = SMOTE(random_state=42)
X_sm, y_sm = sm.fit_resample(X_train, y_train)            # synthesizes new minority rows
fit_and_report(X_sm, y_sm, "SMOTE")

rus = RandomUnderSampler(random_state=42)
X_ru, y_ru = rus.fit_resample(X_train, y_train)           # drops majority rows
fit_and_report(X_ru, y_ru, "undersampled")
# baseline        precision=0.833  recall=0.179
# oversampled     precision=0.220  recall=0.786
# SMOTE           precision=0.241  recall=0.821
# undersampled    precision=0.198  recall=0.857

All three resampling techniques are applied only to the training set — never to X_test, which must stay a faithful sample of real-world class proportions to give an honest performance estimate.

ROC and precision-recall curves: evaluating across all thresholds

from sklearn.metrics import roc_curve, roc_auc_score, precision_recall_curve, average_precision_score

clf = LogisticRegression(max_iter=1000, class_weight="balanced").fit(X_train, y_train)
proba = clf.predict_proba(X_test)[:, 1]

fpr, tpr, roc_thresh = roc_curve(y_test, proba)
print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}")   # ~0.93

prec, rec, pr_thresh = precision_recall_curve(y_test, proba)
print(f"Average precision: {average_precision_score(y_test, proba):.3f}")   # ~0.55

On heavily imbalanced data, precision-recall AUC (average precision) is more informative than ROC AUC — ROC's false-positive rate is computed against the huge majority class, so it stays deceptively low even when precision on the rare class is poor.

Choosing a threshold deliberately

import numpy as np

f1_scores = 2 * prec * rec / (prec + rec + 1e-12)
best_idx = np.argmax(f1_scores)
best_threshold = pr_thresh[best_idx]
print(f"best threshold: {best_threshold:.3f}  (F1={f1_scores[best_idx]:.3f})")

custom_pred = (proba >= best_threshold).astype(int)
print(f"precision={precision_score(y_test, custom_pred):.3f} recall={recall_score(y_test, custom_pred):.3f}")

Cheat sheet

Technique Effect
class_weight="balanced" Reweights loss, no data duplication
RandomOverSampler Duplicates minority rows
SMOTE Synthesizes new minority rows by interpolation
RandomUnderSampler Drops majority rows (loses data)
ROC AUC Threshold-independent, but optimistic on imbalance
Average precision (PR AUC) Better summary for rare-class problems
Custom threshold (proba >= t) instead of the default 0.5

How It Actually Works

SMOTE creates synthetic points by linear interpolation in feature space, not by copying. For each minority-class point x, SMOTE finds its k nearest minority-class neighbors (Module 05's k-NN distance mechanism), picks one neighbor x_n at random, and generates a new synthetic point x_new = x + λ(x_n - x) for a random λ ∈ (0, 1) — a point on the line segment between x and its neighbor. Doing this repeatedly densifies the minority class's region of feature space with plausible-looking new points rather than exact duplicates, which is mechanically why SMOTE tends to generalize better than RandomOverSampler (which literally duplicates existing rows, giving the model zero new information, just more weight on the same points) — though it can misfire when minority points from different sub-clusters get connected across an empty region that shouldn't contain minority examples.

Why ROC AUC looks better than it should feel on imbalanced data. ROC plots true positive rate (TP/(TP+FN), recall) against false positive rate (FP/(FP+TN)) as the threshold sweeps. On a 97:3 dataset, TN is huge (thousands of majority-class rows), so even a substantial number of false positives is a tiny fraction of all negatives — FPR = FP/(FP+TN) stays near 0 for a wide range of thresholds regardless of how many false alarms are actually generated relative to the (few) true positives. Precision (TP/(TP+FP)), by contrast, divides by TP+FP — a quantity dominated by how many false alarms occurred, not diluted by the majority class's size — which is exactly why the precision-recall curve exposes problems (like the baseline model's dismal recall=0.179 despite a respectable precision) that ROC AUC's denominator structurally hides.

The threshold is a free knob completely external to model training, and moving it is pure arithmetic on predict_proba. predict() internally does nothing more than (predict_proba(X)[:, 1] >= 0.5).astype(int) — the 0.5 cutoff is a convention, not something learned during fit(). Sweeping the threshold and recomputing precision/recall at each value (which is literally what precision_recall_curve does — it tries every distinct proba value as a candidate cutoff and recomputes the confusion-matrix counts from Module 05 at each) is why threshold selection requires zero retraining: the probabilities are already computed once, and every downstream metric is just a different way of drawing the line between "predict positive" and "predict negative" across those same fixed numbers.

Exercise

Repeat the SMOTE + logistic regression pipeline, but compare SMOTE against class_weight="balanced" (no resampling at all) using average precision as the metric, on 5 different random_state splits. Report whether one approach consistently wins, and connect any instability you see to how few true minority-class examples (112) the training set actually contains.