Search Tech Journey

Find topics, journeys and posts

6-month learning plan94 / 130
back to blog
mlintermediate 55m read

S094 · Imbalanced Data — SMOTE, Class Weights, Thresholds

Fraud, churn, disease, ad clicks — the interesting problems are almost always imbalanced. Learn the three families of fixes (resampling, class weights, threshold tuning), when each one helps, when each one hurts, and the honest evaluation you need to prove either way.

🤖Machine LearningM11 · Classical ML· Session 094 of 130 90 min

🎯 Handle a 1-in-100 or 1-in-10,000 class imbalance in three defensible ways and know when each one is the wrong choice.

Why this session exists

Every ML problem worth solving is imbalanced. Fraud is 0.1%. Churn is 5%. Cancer screening is 1%. Ad clicks are 0.01%. The default classifier does the boring thing — predicts "no" for everything and scores 99% accuracy while catching zero fraud. This session teaches you the three families of countermeasures (resampling, class weights, threshold tuning), plus the honest evaluation to prove which one — if any — actually helped. Ninety percent of "my model doesn't work" tickets in industry are actually "my class balance is wrong and I didn't notice."

You will be able to
  • Diagnose class imbalance and articulate why it destroys default classifiers.
  • Apply SMOTE, random oversampling, and random undersampling — and know which one to try first.
  • Use class_weight='balanced' as a zero-cost baseline and know its limitations.
  • Tune the classification threshold to hit a business SLA on precision or recall.
  • Evaluate all three fixes with PR-AUC and confusion matrices, not accuracy.

Prerequisites

  • S092 · Evaluation metrics — you know why accuracy is a lie and PR-AUC exists.
  • S093 · Feature engineering — you know the Pipeline pattern.
  • S090 · Logistic regression — you have a classifier that outputs probabilities.


(a) Intuition · 5 min

The lifeguard who never blows the whistle
🌍 Real world

You're a lifeguard at a beach where drownings happen once a summer. If you spend the day never blowing the whistle, you'll be right 99.99% of the time. Your ‘accuracy’ is perfect. And people will still die because you never intervened.

The job isn't to be right most of the time. The job is to catch the rare, dangerous events without blowing the whistle every ten seconds and getting everyone angry.

💻 Code world

A default classifier on imbalanced data behaves exactly like the never-whistling lifeguard. It optimises accuracy, which is maximised by predicting the majority class always. It never sees enough positive examples to learn what "fraud" or "churn" or "cancer" actually looks like.

Your job is to change the learning signal — by resampling, reweighting, or by changing the decision threshold at inference — so that the model pays attention to the rare class. Then evaluate honestly with metrics that care about the rare class (PR-AUC, recall at a target precision).

The three fixes, ranked by "try first"

The order matters — start cheap, escalate only if needed
  • Threshold tuning — free, done at inference. Move the probability threshold so recall meets your business SLA.
  • Class weights — one argument (class_weight='balanced'). Reweights the loss so minority errors count more. Zero data movement.
  • Resampling — SMOTE, random over/undersampling. Change the training distribution. Must live inside a Pipeline to avoid leakage.
  • Anomaly-detection framing — if positives are TRULY rare (< 0.01%) or shift over time, treat it as outlier detection (IsolationForest, one-class SVM) instead of classification.
  • New features / new data — the real fix. If none of the above hits your business target, no resampling trick will save you; you need a better signal.

A quick history

  1. 1997
    Class-imbalanced learning · Fayyad review
    First survey papers naming the imbalance problem in mining KDD workflows.
  2. 2002
    SMOTE · Chawla et al.
    Synthetic Minority Oversampling Technique. Interpolates new minority points between existing ones. 30,000+ citations.
  3. 2009
    ADASYN · adaptive SMOTE
    Weights synthesis toward hard-to-learn minority regions.
  4. 2014
    imbalanced-learn library
    Guillaume Lemaître ships a scikit-learn-compatible library for every resampling method. Ends 5 years of hand-rolled implementations.
  5. 2020
    Threshold-first movement
    Fraud & search teams publish widely: ‘just tune the threshold, don't resample.’ The zero-cost fix finally goes mainstream.

(b) Visual walkthrough · 15 min

The three families of fixes

How SMOTE actually works

11
For each minority-class sample

Iterate through every positive example x_i in the training fold.

22
Find its k nearest minority neighbours

k=5 is the default. Distance in feature space, so scale first.

33
Pick one neighbour x_j at random

Uniform sample from the k found in step 2.

44
Linear interpolation

New synthetic sample = x_i + λ · (x_j − x_i), where λ is uniform in [0, 1]. It lies on the line between two real minority points.

55
Repeat until class balance ratio met

Default: oversample minority to match majority count. You can under-oversample (e.g. reach only 50% ratio) if fully-balanced hurts.

When to use which resampler

Random Oversampling

Duplicate minority rows

  • Simplest possible fix
  • No new information — just weights
  • Risk of overfitting to the exact minority rows
  • Use as a sanity baseline before SMOTE
Random Undersampling

Drop majority rows

  • Fast — smaller training set
  • Throws away real, informative majority data
  • Only viable when you have oceans of majority data (billions of legit transactions)
  • Often combined with SMOTE (SMOTETomek, SMOTEENN)
SMOTE

Synthesise minority via interpolation

  • The default ‘I actually tried’ answer
  • Requires numeric, scaled features (distance-based)
  • Doesn't work on categoricals (use SMOTENC for mixed)
  • Can blur decision boundary if minorities are noisy
class_weight='balanced'

Reweight the loss

  • Zero data movement
  • Same effect as inversely proportional class weights
  • Supported by LogisticRegression, SVM, RandomForest, XGBoost
  • The first thing to try before any resampling

Common misconception
✗ What most people think

"My classes are imbalanced, so the model will be biased toward the majority. I need to rebalance — SMOTE or resampling — to fix it before training."

✓ What is actually true

Imbalance is not inherently a problem; it is a problem only when it prevents the model from ranking well or when your metric is inappropriate. A well-fit model on imbalanced data usually ranks fine — what breaks is the threshold and the metric, both of which are free to fix afterwards. Resampling changes the base rate the model sees, which systematically decalibrates its probabilities: a model trained on artificially balanced data outputs probabilities for a world where the event happens 50% of the time, not 1%.

Why the myth is so sticky

The myth is sticky because the diagnostic everyone runs first is accuracy at threshold 0.5, and at a 1% positive rate that genuinely reports "predicts all negative". That looks exactly like a model that learned nothing, so rebalancing "fixes" it — accuracy becomes meaningful, recall appears. But nothing about the model's ranking improved; you moved the operating point by moving the data instead of by moving the threshold. The same improvement was available for free, without distorting your probabilities.

Prove it to yourself

Show that ranking was fine all along and only the threshold was wrong:

from sklearn.metrics import roc_auc_score, average_precision_score

m = model.fit(Xtr, ytr)                 # no resampling at all
p = m.predict_proba(Xte)[:, 1]

print(roc_auc_score(yte, p), average_precision_score(yte, p))
print('positives predicted at 0.5:', (p >= 0.5).sum())   # often 0
print('positives predicted at 0.05:', (p >= 0.05).sum())  # plenty

# AUC was already good. The model ranked correctly.
# Only the decision rule was wrong -- and that is one number.
From first principles
Start with the question

Why does SMOTE frequently fail — or actively hurt — on real high-dimensional data, when the idea (synthesise minority examples by interpolating between neighbours) sounds obviously reasonable?

  1. 1
    SMOTE creates a new point on the straight line segment between a minority sample and one of its k nearest minority neighbours, then labels it minority.
    forced by · the algorithm assumes the region between two same-class points also belongs to that class
  2. 2
    That assumption is a claim about geometry: the minority class must occupy a convex, connected region in feature space. If the minority class forms several disjoint clusters, the segment between two clusters passes straight through majority territory.
    forced by · a line between two points of a non-convex set can leave the set
  3. 3
    Fraud, faults, and rare diseases are typically not one coherent mode — they are several unrelated mechanisms. So SMOTE manufactures synthetic "positives" in regions where real positives never occur, blurring the boundary rather than sharpening it.
    forced by · rare events are usually a union of distinct causes, not samples from one blob
  4. 4
    The problem compounds in high dimensions, where nearest neighbours become nearly equidistant and the notion of "a close neighbour to interpolate with" loses meaning. The chosen neighbour is close to arbitrary, so the synthetic point is close to arbitrary.
    forced by · distance concentration makes k-NN unstable as dimensionality grows
  5. 5
    And SMOTE cannot work on categorical or one-hot features at all in its basic form — interpolating halfway between two one-hot vectors produces a row that is 0.5 of one category and 0.5 of another, which corresponds to no real entity.
    forced by · interpolation requires a metric space where intermediate points are meaningful, and categories have none
⇒ Therefore

Therefore SMOTE is only sound when the minority class is unimodal, roughly convex, low-dimensional, and continuous — a set of conditions that low-dimensional benchmark datasets satisfy and most production problems do not.

And note what this predicts: SMOTE applied before the train/test split must leak catastrophically, because synthetic points interpolated from a test-set positive land near that test point and end up in training. The model then "predicts" a point it has effectively already seen. That is why every correct implementation resamples inside the CV fold and never touches the validation data — and why so many reported SMOTE gains evaporate when the pipeline is fixed.

Mental modelImbalance is a decision problem wearing a data-problem costume

Separate three things that get conflated. The model produces a ranking. The threshold converts that ranking into a decision. The metric judges the decision. Imbalance breaks the second and third almost always, and the first only occasionally — when the absolute count of minority examples is too small to learn from, which is a data-volume problem, not a ratio problem.

So ask which one is actually broken. 100 positives in 10,000 rows is a genuine learning problem: there is not enough signal. 100,000 positives in 10,000,000 rows has the same 1% ratio and no learning problem at all — just a threshold you have not set.

  • Fix the metric first (PR-AUC, precision@k, expected cost), then the threshold, and only then consider touching the data. Most imbalance "problems" disappear at step two.
  • Prefer class_weight / scale_pos_weight over resampling: it reweights the loss without fabricating or discarding rows, so it is cheaper and does not invent data.
  • Any resampling must happen inside the CV fold and never on validation or test data. The test set must retain the true base rate or every number you compute is fiction.
  • Resampling and class weights both decalibrate probabilities. If anything downstream multiplies your output by a cost, recalibrate on held-out data with the real base rate.
🔔 Fires when you see

Fire this the moment you see: SMOTE applied before the split · a balanced test set · accuracy reported on a rare event · a threshold left at 0.5 · resampling attempted before anyone checked AUC · undersampling that discards the bulk of the majority class without noting the information lost.

The tradeoff

You have 1% positives. Do you use class weights, resample the data, or leave it alone and just move the threshold?

Nothing — tune the threshold only
+ you gain no distortion whatsoever: probabilities stay calibrated to the real world, the pipeline stays simple, training cost is unchanged, and there is zero leakage surface. Frequently matches or beats every alternative, and it is one number to tune.
− you pay does nothing when the minority count is genuinely too small for the model to learn the pattern; and gradient-based models may allocate little capacity to the rare class, since it contributes a small share of total loss
pick when the absolute number of minority examples is adequate (thousands, not dozens) and AUC/PR-AUC already look reasonable — check this before anything else
Class weights / scale_pos_weight
+ you gain increases the loss contribution of minority examples so the optimiser attends to them, without fabricating or discarding a single row; it is one parameter, supported natively everywhere, and adds no leakage risk or preprocessing complexity
− you pay decalibrates output probabilities by design — they now reflect the reweighted distribution, not reality; over-weighting can push the model to overfit the few minority examples it has; and for tree ensembles the effect is often modest compared to simply moving the threshold
pick when the model is underfitting the minority class specifically (poor recall even at a low threshold), and probability calibration is not required downstream
Resampling (SMOTE, undersampling, or both)
+ you gain undersampling the majority dramatically cuts training time on very large datasets, which can be the real motivation; oversampling can genuinely help when minority examples are few and the class is unimodal and continuous
− you pay undersampling throws away real information — the majority class also defines the boundary; SMOTE fabricates data under geometric assumptions that usually do not hold; both decalibrate; and both create a leakage trap if applied outside the fold
pick when undersampling when the dataset is too large to train on and you can afford to lose majority rows; SMOTE only after verifying the minority class is unimodal and continuous, and only inside the fold
What a senior engineer actually does

Try the boring order and stop as soon as it works: fix the metric, tune the threshold, then class weights, then resampling last. In practice the first two resolve the majority of cases, and the effort spent on SMOTE pipelines is frequently effort spent recovering from having skipped them. If the model ranks well, imbalance was never your problem.

The reframe that settles the argument: imbalance is only a modelling problem when you have too few absolute minority examples. The ratio itself is a red herring — the model does not experience a percentage, it experiences a count of examples to learn from. When that count is genuinely small, the honest answers are collecting more positives, transfer learning, or anomaly-detection framing, and none of them are fixed by duplicating the rows you already have.


(c) Hands-on · 25 min

Head-to-head: default vs class-weighted vs SMOTE vs threshold-tuned, all evaluated with PR-AUC on a 1% synthetic fraud dataset. Save as imbalance_lab.py, uv pip install imbalanced-learn, uv run imbalance_lab.py.

"""imbalance_lab.py — four fixes for imbalanced classification, honestly evaluated."""
from __future__ import annotations
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline as SkPipeline
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import (
    average_precision_score,
    precision_recall_curve,
    confusion_matrix,
    classification_report,
)
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.over_sampling import SMOTE
 
RNG = 42
 
 
def make_data(pos_frac: float = 0.01, n: int = 20_000):
    return make_classification(
        n_samples=n, n_features=20, n_informative=6,
        weights=[1 - pos_frac, pos_frac], random_state=RNG,
    )
 
 
def cv_pr_auc(pipe, X, y) -> float:
    cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RNG)
    return cross_val_score(pipe, X, y, cv=cv, scoring="average_precision").mean()
 
 
def evaluate_at_threshold(pipe, X_test, y_test, target_recall: float = 0.90) -> None:
    prob = pipe.predict_proba(X_test)[:, 1]
    prec, rec, thr = precision_recall_curve(y_test, prob)
    mask = rec[:-1] >= target_recall
    if not mask.any():
        print(f"  Cannot reach recall {target_recall}")
        return
    idx = int(np.argmax(prec[:-1][mask]))
    chosen_thr = float(thr[mask][idx])
    pred = (prob >= chosen_thr).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_test, pred).ravel()
    print(f"  threshold to hit recall≥{target_recall}: {chosen_thr:.3f}")
    print(f"  TP={tp:>4}  FP={fp:>5}  FN={fn:>3}  TN={tn:>5}")
    print(f"  precision@recall={target_recall}: {prec[:-1][mask][idx]:.3f}")
 
 
def build_default() -> SkPipeline:
    return SkPipeline([("scale", StandardScaler()), ("clf", LogisticRegression(max_iter=5000))])
 
 
def build_class_weighted() -> SkPipeline:
    return SkPipeline([
        ("scale", StandardScaler()),
        ("clf", LogisticRegression(max_iter=5000, class_weight="balanced")),
    ])
 
 
def build_smote() -> ImbPipeline:
    return ImbPipeline([
        ("scale", StandardScaler()),
        ("smote", SMOTE(random_state=RNG, k_neighbors=5)),
        ("clf", LogisticRegression(max_iter=5000)),
    ])
 
 
if __name__ == "__main__":
    X, y = make_data(pos_frac=0.01)
    print(f"Dataset: n={len(y)}, positives={int(y.sum())} ({y.mean():.2%})")
 
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, stratify=y, random_state=RNG)
 
    variants = {
        "default":         build_default(),
        "class_weighted":  build_class_weighted(),
        "smote":           build_smote(),
    }
 
    print("\n5-fold PR-AUC (train fold only):")
    for name, pipe in variants.items():
        score = cv_pr_auc(pipe, Xtr, ytr)
        print(f"  {name:<15s}: {score:.3f}")
 
    # Fit each on full train, evaluate operating point on test
    print("\nHeld-out test, threshold tuned to recall ≥ 0.90:")
    for name, pipe in variants.items():
        pipe.fit(Xtr, ytr)
        print(f"\n[{name}]")
        evaluate_at_threshold(pipe, Xte, yte, target_recall=0.90)
 
    # Full sklearn report for the SMOTE variant at 0.5 threshold as a sanity check
    print("\nSMOTE classifier @ default 0.5 threshold:")
    prob = variants["smote"].predict_proba(Xte)[:, 1]
    pred_default = (prob >= 0.5).astype(int)
    print(classification_report(yte, pred_default, digits=3))

Anatomy of the script

Anatomy of the script

Line 17 · from imblearn.pipeline import Pipeline as ImbPipeline
Drop-in replacement for sklearn's Pipeline that also supports samplers. This is what keeps SMOTE leakage-safe under cross_val_score.
core
Line 27 · make_classification(weights=[0.99, 0.01])
1% positives. Realistic for churn / mid-tier fraud. Bump to weights=[0.999, 0.001] for a harder version.
data
Line 33 · cross_val_score(..., scoring='average_precision')
'average_precision' is sklearn's name for PR-AUC. Don't use 'roc_auc' here — it will lie about model quality on imbalanced data.
eval
Line 39 · precision_recall_curve
Returns (precision, recall, thresholds). Note len(thr) == len(prec) - 1 — the last (prec, rec) point corresponds to threshold=+inf.
eval
Line 62 · SMOTE(random_state=RNG, k_neighbors=5)
Default k=5. If your minority is very small, k must be < |minority| in each CV fold or SMOTE errors out.
smote
Line 68 · pipe.fit(Xtr, ytr)
For the ImbPipeline, fit resamples on Xtr only — never touches Xte. This is the whole reason we use the pipeline instead of manual SMOTE.
safety
Line 73 · evaluate_at_threshold(target_recall=0.90)
Business-first evaluation: given a recall SLA, find the highest-precision threshold that meets it. This is what production teams do — not ‘pick threshold with highest F1’.
prod
Try itProve that resampling before splitting inflates your score

Add this cheating baseline to the script:

def cheating_smote_score(X, y):
    from imblearn.over_sampling import SMOTE
    from sklearn.preprocessing import StandardScaler
    Xs = StandardScaler().fit_transform(X)
    Xr, yr = SMOTE(random_state=RNG).fit_resample(Xs, y)  # <-- fit on full X!
    Xtr, Xte, ytr, yte = train_test_split(Xr, yr, test_size=0.3, random_state=RNG)
    clf = LogisticRegression(max_iter=5000).fit(Xtr, ytr)
    return average_precision_score(yte, clf.predict_proba(Xte)[:, 1])

Compare its "PR-AUC" to the honest cross-validated one. It'll look better by a chunk. That gap is the amount of test signal you leaked — the same gap you'd get in production if you shipped this pipeline.

💡 Hint · This is data leakage — the synthetic minority examples ‘see’ the test set. Never do this in real code.

(d) Production reality · 15 min

War story Stripe — fraud detectionmillions of transactions per day
🔥 What broke

Early ML fraud models at every payments company hit the same wall: SMOTE oversamples the minority in the training set, which shifts the model's predicted probabilities upward. In production the base rate hasn't changed — so the model floods the fraud queue with false positives and reviewers get burned out within a week.

🧯 The fix

Two changes: (1) prefer class weights + threshold tuning over SMOTE — the model stays calibrated to the true base rate; (2) if you must use SMOTE, recalibrate probabilities after training (Platt scaling or isotonic regression) so the outputs match true probabilities.

Stripe's Radar and similar systems now report a calibrated probability and let the threshold be a business-tunable slider.

🎓 Lesson to steal
Resampling changes the training distribution, which shifts your model's output probabilities. If downstream code (queues, prioritisation, chained models) expects a probability, you must recalibrate — or skip resampling and use class weights + threshold instead.
Post-mortem
War story LinkedIn — connection recommendationbillions of candidate pairs, ~0.1% positive
🔥 What broke
An early ranking model was trained on all candidate pairs with negative down-sampling (keep 1% of negatives). The offline AUC was fantastic. In production, click-through rate on recommendations dropped 15% because the model had never seen the full negative distribution — it couldn't distinguish "medium-quality" from "poor-quality" negatives.
🧯 The fix

Two-stage evaluation: (1) train on down-sampled negatives but (2) evaluate ranking on the full negative distribution, and (3) recalibrate scores so the down-sampling factor is removed. Also: track precision-at-K on production traffic, not offline AUC.

🎓 Lesson to steal
Negative down-sampling is fine for training, but your evaluation must happen on the true distribution or you'll ship a model that only knows how to reject obvious negatives.
War story A healthcare vendor · common failure moderare disease screening (~0.1% prevalence)
🔥 What broke
The team applied SMOTE to reach a 50/50 balance, reported "AUC of 0.99!" and shipped. In deployment radiologists saw so many false positives that they stopped acting on the alerts within two weeks. The model was quietly turned off.
🧯 The fix

Reset expectations to the true prevalence. Report precision at recall = 0.95 on the production data distribution, not on the resampled data. If precision is 5%, that's the honest number — SMOTE didn't fix anything; it just hid the problem.

🎓 Lesson to steal
SMOTE + AUC = a demo. Deployment-prevalence PR-AUC + precision-at-recall = an honest evaluation. Never report metrics computed on resampled data as if they were metrics on production data.

Where this shows up in the rest of the plan

Imbalance handling is the backbone of every rare-event ML system
S095 · Model selection & CV
StratifiedKFold — the CV strategy that preserves class balance across folds. Non-negotiable when imbalanced.
S104 · Feature stores in production
Feature values differ by class — you'll see this only if you slice metrics by class.
S110 · Anomaly detection
When positives are extreme (&lt;0.01%) or shift over time, reframe as outlier detection.
S121 · LLM eval
‘hallucination’ is a rare event too — the same PR-AUC + threshold-tuning logic applies.
S128 · MLOps monitoring
Watch class prevalence in production — if it drifts by more than 20%, retrain.
S130 · Capstone
Your final model's review must include a base-rate section and threshold justification.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three to a friend without notes, redo the session:

  1. Why does the default classifier fail on imbalanced data?
  2. What are the three families of fixes, and in what order do you try them?
  3. Why must SMOTE live inside a Pipeline?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.