TAI BUI
Back to curriculum
BuildPython

Handling Imbalanced Data

When 99% of your data is "normal," accuracy is a lie.

TIME: ~90 minutesPREREQUISITES: Phase 2, Lessons 01-09 (especially evaluation metrics)

Learning Objectives

  • Implement SMOTE from scratch and explain how synthetic oversampling differs from random duplication
  • Evaluate imbalanced classifiers using F1, AUPRC, and Matthews Correlation Coefficient instead of accuracy
  • Compare class weighting, threshold tuning, and resampling strategies and select the right approach for a given imbalance ratio
  • Build a complete imbalanced data pipeline that combines SMOTE, class weights, and threshold optimization

The Problem

You build a fraud detection model. It hits 99.9% accuracy. You're ready to ship it, until you notice it predicts "not fraud" for every single transaction, no exceptions.

That's not a bug. It's the rational move when only 0.1% of transactions are fraudulent: guessing the majority class every time minimizes overall error. The model is technically correct and completely useless.

That pattern has a name: class imbalance, when the classes in a dataset are skewed badly enough that a model can win on the wrong metric just by favoring the majority class and ignoring the rest. It shows up everywhere classification actually matters. Disease diagnosis: 1% positive rate. Network intrusion: 0.01% attacks. Manufacturing defects: 0.5% defective. Spam filtering: 20% spam. Churn prediction: 5% churners. The more consequential the minority class, the rarer it tends to be.

Accuracy fails here because it treats every correct prediction as equal. Correctly labeling a legitimate transaction and correctly catching fraud both count as one point toward the same score, but catching fraud is the entire reason the model exists. What you actually need are metrics, techniques, and training strategies that force the model to notice the rare class it keeps ignoring.

Warm-up0/3
1. A fraud detection dataset has 99.9% legitimate transactions and 0.1% fraud. A model predicts 'legitimate' for every transaction. What is its accuracy?Warm-up
A grid of 1,000 dots, 999 legitimate transactions and 1 fraud, above a box saying the model outputs 'legitimate' for all of them.
Each dot is one transaction.
2. Which metric would correctly expose the always-predict-negative model as useless?Warm-up
A two by two table of outcomes for a model that predicts legitimate every time on 1,000 transactions: 0 frauds caught, 1 fraud missed, 0 false alarms, 999 legitimate transactions passed.
Counts follow from 999 legitimate and 1 fraud transaction, as in the previous question.
3. A spam filter flags 100 emails, and 80 of them really are spam. What is its precision?Warm-up
Diagram: an inbox feeds a spam filter, which puts 100 emails in a flagged folder; 80 of those emails are really spam. Precision is marked with a question mark.
Check: The Problem0/1
1. What is class imbalance?Define
Scatter plot of a 1,000-row two-feature dataset: a large cloud of 950 class A points and a small cluster of 50 class B points, with the row counts listed.
Lesson dataset from make_imbalanced_data. Only 330 of the 950 class A points are drawn.

The Concept

Why Accuracy Fails

Take a dataset with 1000 samples: 990 negative, 10 positive. A model that always predicts negative produces this confusion matrix:

Predicted PositivePredicted Negative
Actually Positive0 (TP)10 (FN)
Actually Negative0 (FP)990 (TN)

Accuracy = (0 + 990) / 1000 = 99.0%

The model catches zero fraud. Zero disease. Zero defects. Accuracy still reports 99%. That's why accuracy is the wrong tool for imbalanced problems.

Better Metrics

Precision = TP / (TP + FP). Of everything flagged as positive, how many actually are? High precision means few false alarms.

Recall = TP / (TP + FN). Of everything actually positive, how many did the model catch? High recall means few missed positives.

F1 Score = 2 * precision * recall / (precision + recall). The harmonic mean of the two. It punishes a big gap between precision and recall harder than a plain average would.

F-beta Score = (1 + beta^2) * precision * recall / (beta^2 * precision + recall). Set beta above 1 and recall matters more; set it below 1 and precision does. F2 shows up constantly in fraud detection, because missing fraud costs more than a false alarm.

AUPRC (Area Under Precision-Recall Curve). Like AUC-ROC, but far more informative for imbalanced data: a random classifier scores an AUPRC equal to the positive class rate, not 0.5 like ROC, so real improvements actually show up in the number.

Matthews Correlation Coefficient = (TP * TN - FP * FN) / sqrt((TP+FP)(TP+FN)(TN+FP)(TN+FN)). Ranges from -1 to +1 and only climbs when the model does well on both classes, staying balanced even when the classes are wildly different sizes.

Run the "always predict negative" model through all of this: precision = 0/0 (undefined, usually set to 0), recall = 0/10 = 0, F1 = 0, MCC = 0. Every one of these correctly calls the model worthless.

The Imbalanced Data Pipeline

Mermaid diagram

SMOTE: Synthetic Minority Oversampling Technique

Random oversampling duplicates existing minority samples. That works, but it risks overfitting because the model keeps seeing identical points over and over.

SMOTE creates new synthetic minority samples that are plausible but not copies. The algorithm:

  1. For each minority sample x, find its k nearest neighbors among other minority samples
  2. Pick one neighbor at random
  3. Create a new sample on the line segment between x and that neighbor

The formula: new_sample = x + random(0, 1) * (neighbor - x)

It interpolates between real minority points, generating samples in the same region of feature space without copying anything that already exists.

Mermaid diagram

Sampling Strategies Compared

Random Oversampling: duplicate minority samples to match majority count.

  • Pros: simple, no information loss
  • Cons: exact duplicates cause overfitting, increases training time

Random Undersampling: remove majority samples to match minority count.

  • Pros: fast training, simple
  • Cons: throws away potentially useful majority data, higher variance

SMOTE: create synthetic minority samples via interpolation.

  • Pros: generates new data points, reduces overfitting compared to random oversampling
  • Cons: can create noisy samples near the decision boundary, does not account for majority class distribution
StrategyData ChangedRiskWhen to Use
OversampleMinority duplicatedOverfittingSmall datasets, moderate imbalance
UndersampleMajority removedInformation lossLarge datasets, want fast training
SMOTESynthetic minority addedBoundary noiseModerate imbalance, enough minority samples for k-NN

Class Weights

Instead of changing the data, change how the model treats errors: assign a higher weight to misclassifying the minority class.

For a binary problem with 950 negative and 50 positive samples:

  • Weight for negative class = n_samples / (2 * n_negative) = 1000 / (2 * 950) = 0.526
  • Weight for positive class = n_samples / (2 * n_positive) = 1000 / (2 * 50) = 10.0

The positive class gets 19x the weight. Misclassifying one positive sample now costs as much as misclassifying 19 negative samples, so the model has no choice but to pay attention to the minority class.

In logistic regression, this modifies the loss function:

weighted_loss = -sum(w_i * [y_i * log(p_i) + (1-y_i) * log(1-p_i)])

where w_i depends on the class of sample i.

Class weights are mathematically equivalent to oversampling in expectation, but without creating new data points. That makes them faster and sidesteps the overfitting risk of duplicated samples.

Threshold Tuning

Most classifiers output a probability, and the default threshold is 0.5: if P(positive) >= 0.5, predict positive. But 0.5 is arbitrary, and when classes are imbalanced the optimal threshold is usually much lower. Threshold tuning means picking that cutoff on purpose: sweep candidate values, score each one on the metric you actually care about, and keep whichever threshold wins, instead of defaulting to 0.5 out of habit.

The process:

  1. Train a model
  2. Get predicted probabilities on the validation set
  3. Sweep thresholds from 0.0 to 1.0
  4. Compute F1 (or your chosen metric) at each threshold
  5. Pick the threshold that maximizes your metric

Mermaid diagram

A model might output P(fraud) = 0.15 for a fraudulent transaction. At threshold 0.5, that gets classified as not fraud. At threshold 0.10, it's caught correctly. The probability calibration matters less than the ranking: as long as fraud scores higher than non-fraud, some threshold exists that separates them.

Cost-Sensitive Learning

Cost-sensitive learning generalizes class weights. Instead of uniform costs, it assigns specific misclassification costs:

Predict PositivePredict Negative
Actually Positive0 (correct)C_FN = 100
Actually NegativeC_FP = 10 (correct)

Missing a fraudulent transaction (FN) costs 100x more than a false alarm (FP), and the model optimizes for total cost, not total error count.

This is the most principled approach when you can actually estimate real-world costs. A missed cancer diagnosis costs something very different from a false alarm that leads to an extra biopsy. Making those costs explicit is what forces the right tradeoff.

Decision Flowchart

Mermaid diagram

Check: The Concept0/26
1. How does SMOTE generate synthetic minority samples?Define
Scatter plot of eight minority points and ten majority points in two features, with a circle holding a question mark where a new minority point is to be added.
Typed toy coordinates, for illustration.
2. What are class weights?Define
Table of six training rows: four negative and two positive, each with a per-row loss value and a class weight column showing a question mark.
Loss values are typed for illustration.
3. What is threshold tuning?Define
Number line from 0 to 1 with a dot for each of 200 held-out rows at its predicted probability, positives in orange and negatives as blue dots, and a dashed vertical line at 0.5.
Real outputs of the lesson's code, class-weighted model on its 200 test rows.
4. You lower the classification threshold from 0.5 to 0.3 on an imbalanced dataset. What happens to precision and recall?Define
Number line of model scores for 50 rows, 10 positives as orange dots and 40 negatives as blue dots, with two dashed vertical cutoff lines at 0.3 and 0.5.
Simulated scores (seeded), for illustration.
5. What is AUPRC?Define
Line chart of precision against recall for a classifier on 200 held-out rows, with a label reading AUPRC equals question mark.
Real outputs of the lesson's code, test split.
6. What makes Matthews Correlation Coefficient (MCC) useful for imbalanced classification?Define
Two confusion matrices side by side for 1,000 rows with 10 positives. Model A: TP 0, FN 10, FP 0, TN 990. Model B: TP 8, FN 2, FP 20, TN 970. The metric values are marked with a question mark.
Counts typed for illustration.
7. A false negative costs 100 and a false positive costs 1 in a cost matrix. What does cost-sensitive learning make the model optimize?Define
A two by two cost table: a false negative costs 100, a false positive costs 1, and both correct outcomes are marked correct. Below it, what the model optimizes is marked with a question mark.
8. What is random oversampling?Define
Bar chart of the lesson's training set, 764 negative rows against 36 positive rows, with a box for the class counts after random oversampling marked with question marks.
Real split from make_imbalanced_data (800 training rows).
9. The always-predict-negative model scores 99% accuracy on 990 negatives and 10 positives, yet its F1 and MCC are 0. Why?Explain
A grid of 1,000 dots, 990 negative and 10 positive, with a box saying the model predicts negative for every row, and the reported scores accuracy 99 percent, F1 0 and MCC 0.
Counts from the question.
10. Why is the F2 score common in fraud detection specifically?Explain
Horizontal bars for two fraud models. Model A: precision 0.9, recall 0.3. Model B: precision 0.3, recall 0.9.
Illustrative values, typed.
11. Why is F1 defined as a harmonic mean of precision and recall instead of a plain average?Explain
Two bars, precision 0.95 and recall 0.10, with two boxes below for the plain average and the F1 score, each marked with a question mark.
Typed values.
12. Why is AUPRC more informative than AUC-ROC for highly imbalanced datasets?Explain
Two line charts for one classifier scored on 200 rows (14 positive): its ROC curve on the left and its precision-recall curve on the right, with no baselines drawn.
Real outputs of the lesson's code on its test split.
13. Why does SMOTE usually overfit less than random oversampling?Explain
Scatter of the lesson's training split: 36 orange minority points in a small cluster and a larger cloud of blue-grey majority points, with the row counts listed.
Real lesson data. Only 200 of the 764 majority points are drawn.
14. What is a known downside of SMOTE that plain duplication of minority points does not have?Explain
Scatter of a simulated training set where 75 orange minority points sit among a larger blue-grey majority cloud.
Simulated overlapping data (seeded). Only 300 majority points are drawn.
15. What is a key risk of random undersampling compared to SMOTE?Explain
Bars for a training set of 950 negative and 50 positive rows, and two boxes for the strategies random undersampling and SMOTE, each marked with a question mark for the number of rows after balancing.
Counts from the lesson's 950 / 50 example.
16. Why are class weights often preferred to random oversampling?Explain
Scatter of the lesson's training rows, 764 negative and 36 positive, with a white line showing where a plain logistic regression predicts probability 0.5.
Real run of the lesson's logistic regression. Only 250 negative points drawn.
17. A fraudulent transaction gets P(fraud) = 0.15, below the 0.5 cutoff. Why can threshold tuning still catch it?Explain
Number line from 0 to 1 with a fraudulent transaction marked at probability 0.15 and a dashed cutoff line at 0.5, so the transaction falls on the not-fraud side.
18. How does cost-sensitive learning generalize class weights?Explain
Left, a table giving class weights 0.526 for the negative class and 10.0 for the positive class. Right, an empty two by two table of costs per error type with question marks in every cell.
Weights from the lesson's 950 / 50 example.
19. With 950 negative and 50 positive samples and weight = n_samples / (2 * n_class), what weight does the positive class get?Apply
Bars for 950 negative and 50 positive samples, the formula weight equals n_samples over two times n_class, and the positive class weight marked with a question mark.
20. A model has precision 0.5 and recall 1.0. What is its F1 score?Apply
Bars for precision 0.5 and recall 1.0 with the F1 formula and a question mark for its value.
21. A model on a 0.5% positive dataset reports 99% accuracy and a high AUC-ROC, yet analysts say it misses most fraud. Which check would expose the problem?Analyze
A grid of 1,000 transactions with 5 fraud dots in orange among 995 legitimate, a small table reporting accuracy 99 percent and AUC-ROC high, and the analysts' complaint that the model misses most fraud.
Counts follow from the 0.5% rate; the report values are from the question.
22. After random oversampling, a model's recall on the training data is near perfect, but recall on the test set stays low. What is the likely cause?Analyze
Two bars: recall 1.00 on the training rows and 0.32 on the test rows for a model trained after random oversampling.
Toy run: 1-nearest-neighbor classifier on simulated overlapping data (seeded), after the lesson's random_oversample.
23. Classes overlap heavily. After applying SMOTE, precision drops with many false positives near the boundary. What is the most likely cause?Analyze
Scatter of the 400 test rows from a simulated overlapping dataset with a line where the SMOTE-trained model predicts 0.5; 75 blue-grey negative points predicted positive are drawn in red against 22 true positives.
Simulated overlapping data; model is the lesson's logistic regression trained after the lesson's smote.
24. You lowered the threshold to 0.1. Recall is 0.95, but precision is 0.05 and the review team is swamped by false alarms. What is the best response?Analyze
A 10 by 10 grid of 100 alerts, 5 orange for real fraud and 95 blue-grey for false alarms, with recall 0.95 and precision 0.05 stated at cutoff 0.1.
Squares drawn from precision 0.05 in the question; positions are arbitrary.
25. A hospital can estimate that a missed diagnosis is about 100 times costlier than a false alarm. Which approach fits best?Design
Two boxes: a missed diagnosis where a sick patient is sent home, and a false alarm where a healthy patient is retested, with a note that a missed diagnosis costs about 100 times a false alarm.
Relationship from the question; wording of each mistake is illustrative.
26. You are screening for a disease where a missed case is far worse than a false alarm. Which evaluation metric should guide tuning?Design
Four outcome cards for a disease screening: sick and flagged is caught, sick and not flagged is a missed case that is far worse, healthy and flagged is a false alarm, healthy and not flagged is correct.

Build It

Step 1: Generate an imbalanced dataset

python
import numpy as np
 
 
def make_imbalanced_data(n_majority=950, n_minority=50, seed=42):
    rng = np.random.RandomState(seed)
 
    X_maj = rng.randn(n_majority, 2) * 1.0 + np.array([0.0, 0.0])
    X_min = rng.randn(n_minority, 2) * 0.8 + np.array([2.5, 2.5])
 
    X = np.vstack([X_maj, X_min])
    y = np.concatenate([np.zeros(n_majority), np.ones(n_minority)])
 
    shuffle_idx = rng.permutation(len(y))
    return X[shuffle_idx], y[shuffle_idx]

Step 2: SMOTE from scratch

python
def euclidean_distance(a, b):
    return np.sqrt(np.sum((a - b) ** 2))
 
 
def find_k_neighbors(X, idx, k):
    distances = []
    for i in range(len(X)):
        if i == idx:
            continue
        d = euclidean_distance(X[idx], X[i])
        distances.append((i, d))
    distances.sort(key=lambda x: x[1])
    return [d[0] for d in distances[:k]]
 
 
def smote(X_minority, k=5, n_synthetic=100, seed=42):
    rng = np.random.RandomState(seed)
    n_samples = len(X_minority)
    k = min(k, n_samples - 1)
    synthetic = []
 
    for _ in range(n_synthetic):
        idx = rng.randint(0, n_samples)
        neighbors = find_k_neighbors(X_minority, idx, k)
        neighbor_idx = neighbors[rng.randint(0, len(neighbors))]
        t = rng.random()
        new_point = X_minority[idx] + t * (X_minority[neighbor_idx] - X_minority[idx])
        synthetic.append(new_point)
 
    return np.array(synthetic)

Step 3: Random oversampling and undersampling

python
def random_oversample(X, y, seed=42):
    rng = np.random.RandomState(seed)
    classes, counts = np.unique(y, return_counts=True)
    max_count = counts.max()
 
    X_resampled = list(X)
    y_resampled = list(y)
 
    for cls, count in zip(classes, counts):
        if count < max_count:
            cls_indices = np.where(y == cls)[0]
            n_needed = max_count - count
            chosen = rng.choice(cls_indices, size=n_needed, replace=True)
            X_resampled.extend(X[chosen])
            y_resampled.extend(y[chosen])
 
    X_out = np.array(X_resampled)
    y_out = np.array(y_resampled)
    shuffle = rng.permutation(len(y_out))
    return X_out[shuffle], y_out[shuffle]
 
 
def random_undersample(X, y, seed=42):
    rng = np.random.RandomState(seed)
    classes, counts = np.unique(y, return_counts=True)
    min_count = counts.min()
 
    X_resampled = []
    y_resampled = []
 
    for cls in classes:
        cls_indices = np.where(y == cls)[0]
        chosen = rng.choice(cls_indices, size=min_count, replace=False)
        X_resampled.extend(X[chosen])
        y_resampled.extend(y[chosen])
 
    X_out = np.array(X_resampled)
    y_out = np.array(y_resampled)
    shuffle = rng.permutation(len(y_out))
    return X_out[shuffle], y_out[shuffle]

Step 4: Logistic regression with class weights

python
def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
 
 
def logistic_regression_weighted(X, y, weights, lr=0.01, epochs=200):
    n_samples, n_features = X.shape
    w = np.zeros(n_features)
    b = 0.0
 
    for _ in range(epochs):
        z = X @ w + b
        pred = sigmoid(z)
        error = pred - y
        weighted_error = error * weights
 
        gradient_w = (X.T @ weighted_error) / n_samples
        gradient_b = np.mean(weighted_error)
 
        w -= lr * gradient_w
        b -= lr * gradient_b
 
    return w, b
 
 
def compute_class_weights(y):
    classes, counts = np.unique(y, return_counts=True)
    n_samples = len(y)
    n_classes = len(classes)
    weight_map = {}
    for cls, count in zip(classes, counts):
        weight_map[cls] = n_samples / (n_classes * count)
    return np.array([weight_map[yi] for yi in y])

Step 5: Threshold tuning

python
def find_optimal_threshold(y_true, y_probs, metric="f1"):
    best_threshold = 0.5
    best_score = -1.0
 
    for threshold in np.arange(0.05, 0.96, 0.01):
        y_pred = (y_probs >= threshold).astype(int)
        tp = np.sum((y_pred == 1) & (y_true == 1))
        fp = np.sum((y_pred == 1) & (y_true == 0))
        fn = np.sum((y_pred == 0) & (y_true == 1))
 
        if metric == "f1":
            precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
            recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
            score = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
        elif metric == "recall":
            score = tp / (tp + fn) if (tp + fn) > 0 else 0.0
        elif metric == "precision":
            score = tp / (tp + fp) if (tp + fp) > 0 else 0.0
 
        if score > best_score:
            best_score = score
            best_threshold = threshold
 
    return best_threshold, best_score

Step 6: Evaluation functions

python
def confusion_matrix_values(y_true, y_pred):
    tp = np.sum((y_pred == 1) & (y_true == 1))
    tn = np.sum((y_pred == 0) & (y_true == 0))
    fp = np.sum((y_pred == 1) & (y_true == 0))
    fn = np.sum((y_pred == 0) & (y_true == 1))
    return tp, tn, fp, fn
 
 
def compute_metrics(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix_values(y_true, y_pred)
    accuracy = (tp + tn) / (tp + tn + fp + fn)
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
 
    denom = np.sqrt(float((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)))
    mcc = (tp * tn - fp * fn) / denom if denom > 0 else 0.0
 
    return {
        "accuracy": accuracy,
        "precision": precision,
        "recall": recall,
        "f1": f1,
        "mcc": mcc,
    }

Step 7: Compare all approaches

python
X, y = make_imbalanced_data(950, 50, seed=42)
split = int(0.8 * len(y))
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
 
# Baseline: no treatment
w_base, b_base = logistic_regression_weighted(
    X_train, y_train, np.ones(len(y_train)), lr=0.1, epochs=300
)
probs_base = sigmoid(X_test @ w_base + b_base)
preds_base = (probs_base >= 0.5).astype(int)
 
# Oversampled
X_over, y_over = random_oversample(X_train, y_train)
w_over, b_over = logistic_regression_weighted(
    X_over, y_over, np.ones(len(y_over)), lr=0.1, epochs=300
)
preds_over = (sigmoid(X_test @ w_over + b_over) >= 0.5).astype(int)
 
# SMOTE
minority_mask = y_train == 1
X_minority = X_train[minority_mask]
synthetic = smote(X_minority, k=5, n_synthetic=len(y_train) - 2 * int(minority_mask.sum()))
X_smote = np.vstack([X_train, synthetic])
y_smote = np.concatenate([y_train, np.ones(len(synthetic))])
w_sm, b_sm = logistic_regression_weighted(
    X_smote, y_smote, np.ones(len(y_smote)), lr=0.1, epochs=300
)
preds_smote = (sigmoid(X_test @ w_sm + b_sm) >= 0.5).astype(int)
 
# Class weights
sample_weights = compute_class_weights(y_train)
w_cw, b_cw = logistic_regression_weighted(
    X_train, y_train, sample_weights, lr=0.1, epochs=300
)
probs_cw = sigmoid(X_test @ w_cw + b_cw)
preds_cw = (probs_cw >= 0.5).astype(int)
 
# Threshold tuning (tune on held-out validation set, not test set)
val_split = int(0.75 * len(y_train))
X_tr, X_val = X_train[:val_split], X_train[val_split:]
y_tr, y_val = y_train[:val_split], y_train[val_split:]
val_weights = compute_class_weights(y_tr)
w_val, b_val = logistic_regression_weighted(X_tr, y_tr, val_weights, lr=0.1, epochs=300)
probs_val = sigmoid(X_val @ w_val + b_val)
best_thresh, best_f1 = find_optimal_threshold(y_val, probs_val, metric="f1")
preds_thresh = (probs_cw >= best_thresh).astype(int)

The code file runs all of this in a single script and prints results.

Check: Build It0/8
1. Inside smote(), X_minority[idx] is [1, 1], the chosen neighbor is [3, 5], and rng.random() returns 0.25. What is new_point?Apply
Grid plot with two minority points at [1, 1] and [3, 5] joined by a dashed segment, and a note that t equals 0.25 and the new point is marked with a question mark.
2. compute_class_weights is called with y = [0, 0, 0, 0, 0, 0, 1, 1]. What weight does each positive sample get?Apply
Eight boxes for y equals 0, 0, 0, 0, 0, 0, 1, 1, with the two class 1 boxes marked with question marks for the weight each gets.
3. y holds 950 zeros and 50 ones. What does random_undersample(X, y) return?Apply
Bars for y with 950 zeros and 50 ones, and a dashed box showing that random_undersample returns an unknown number of rows.
Counts from the question.
4. y_train has 800 samples, 40 of them minority. The code calls smote with n_synthetic = len(y_train) - 2 * 40 and stacks the result onto the training set. What are the final class counts?Apply
A bar showing 800 training samples split into 760 majority and 40 minority, the line n_synthetic equals len(y_train) minus 2 times 40, and the final class counts marked with question marks.
Counts from the question.
5. compute_metrics is called with tp=0, tn=990, fp=0, fn=10. What does it return?Apply
Confusion matrix with TP 0, FN 10, FP 0, TN 990 and a box saying compute_metrics returns accuracy, precision, recall, f1 and mcc, with the return value marked by a question mark.
6. find_optimal_threshold is called with metric='recall' on any dataset. Which threshold does it return?Apply
A number line of the candidate cutoffs from 0.05 to 0.95 in steps of 0.01, the starting values best_threshold 0.5 and best_score -1.0, and the returned threshold marked with a question mark.
7. A team picks the threshold that maximizes F1 on the test set and reports that F1 as the final result. What is wrong?Analyze
Four steps: train the model, score the test set, sweep cutoffs on test F1, report the best F1; the best cutoff on the 200 test rows is 0.67 with F1 0.929.
Real numbers from the lesson's code on its test split.
8. You must ship a classifier for data with 0.5% positives. Which end-to-end plan follows the lesson's pipeline?Design
Three stages in a row, preparing the training data, choosing the cutoff, and reporting final performance, each pointing to a dashed box asking which data and metric are used.

Use It

With scikit-learn and imbalanced-learn, these techniques are one-liners:

python
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, f1_score
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
from imblearn.pipeline import Pipeline
 
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y)
 
model_weighted = LogisticRegression(class_weight="balanced")
model_weighted.fit(X_train, y_train)
print(classification_report(y_test, model_weighted.predict(X_test)))
 
smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)
model_smote = LogisticRegression()
model_smote.fit(X_resampled, y_resampled)
print(classification_report(y_test, model_smote.predict(X_test)))
 
pipeline = Pipeline([
    ("smote", SMOTE()),
    ("model", LogisticRegression(class_weight="balanced")),
])
pipeline.fit(X_train, y_train)
print(classification_report(y_test, pipeline.predict(X_test)))

The from-scratch implementations show exactly what each technique does. SMOTE is just k-NN interpolation on the minority class. Class weights multiply the loss. Threshold tuning is a for-loop over cutoffs. No magic.

Check: Use It0/2
1. What does the imblearn Pipeline in the Use It section combine into a single object?Explain
Flow diagram: X_train and y_train enter a Pipeline with two unlabeled steps marked with question marks, producing one fitted object.
Structure only; the step contents are the question.
2. You use scikit-learn's LogisticRegression and want to handle imbalance without resampling the data. Which argument does it?Apply
Flow diagram: X_train and y_train with 1,000 rows go into LogisticRegression with an unknown argument marked by a question mark, producing a model, with no rows added or removed.
Structure only.

Ship It

This lesson produces:

  • outputs/skill-imbalanced-data.md, a decision checklist for handling imbalanced classification problems

Exercises

  1. Borderline-SMOTE: modify the SMOTE implementation to only generate synthetic samples for minority points that are near the decision boundary (those whose k-nearest neighbors include majority class samples). Compare results with standard SMOTE on a dataset where classes overlap.

  2. Cost matrix optimization: implement cost-sensitive learning where the cost matrix is a parameter. Create a function that takes a cost matrix and returns optimal predictions that minimize expected cost. Test with different cost ratios (1:10, 1:100, 1:1000) and plot how the precision-recall tradeoff changes.

  3. Threshold calibration: implement Platt scaling (fit a logistic regression on the model's raw outputs to produce calibrated probabilities). Compare the precision-recall curve before and after calibration. Show that calibration does not change the ranking (AUC stays the same) but makes the probabilities more meaningful.

  4. Ensemble with balanced bagging: train multiple models, each on a balanced bootstrap sample (all minority + random subset of majority). Average their predictions. Compare this approach against a single model with SMOTE. Measure both performance and variance across runs.

  5. Imbalance ratio experiment: take a balanced dataset and progressively increase the imbalance ratio (50/50, 70/30, 90/10, 95/5, 99/1). For each ratio, train with and without SMOTE. Plot F1 vs imbalance ratio for both approaches. At what ratio does SMOTE start making a meaningful difference?

Key Terms

TermWhat people sayWhat it actually means
Class imbalance"One class has way more samples"The distribution of classes in the dataset is significantly skewed, causing models to favor the majority class
SMOTE"Synthetic oversampling"Creates new minority samples by interpolating between existing minority samples and their k-nearest minority neighbors
Class weights"Making errors on rare classes more expensive"Multiplying the loss function by class-specific weights so the model penalizes minority misclassification more heavily
Threshold tuning"Moving the decision boundary"Changing the probability cutoff for classification from the default 0.5 to a value that optimizes the desired metric
Precision-recall tradeoff"You cannot have both"Lowering the threshold catches more positives (higher recall) but also flags more false positives (lower precision), and vice versa
AUPRC"Area under the PR curve"Summarizes the precision-recall curve into a single number; more informative than AUC-ROC when classes are heavily imbalanced
Matthews Correlation Coefficient"The balanced metric"A correlation between predicted and actual labels that produces a high score only when the model performs well on both classes
Cost-sensitive learning"Different mistakes cost different amounts"Incorporating real-world misclassification costs into the training objective so the model optimizes for total cost, not error count
Random oversampling"Duplicate the minority"Repeating minority class samples to balance class counts; simple but risks overfitting to duplicated points

Further Reading

Code

Ships With