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.
🎯 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."
- 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
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.
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"
- 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
- 1997Class-imbalanced learning · Fayyad reviewFirst survey papers naming the imbalance problem in mining KDD workflows.
- 2002SMOTE · Chawla et al.Synthetic Minority Oversampling Technique. Interpolates new minority points between existing ones. 30,000+ citations.
- 2009ADASYN · adaptive SMOTEWeights synthesis toward hard-to-learn minority regions.
- 2014imbalanced-learn libraryGuillaume Lemaître ships a scikit-learn-compatible library for every resampling method. Ends 5 years of hand-rolled implementations.
- 2020Threshold-first movementFraud & 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
Iterate through every positive example x_i in the training fold.
k=5 is the default. Distance in feature space, so scale first.
Uniform sample from the k found in step 2.
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.
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
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
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)
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
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
"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."
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%.
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.
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.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?
- 1SMOTE 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
- 2That 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
- 3Fraud, 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
- 4The 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
- 5And 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 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.
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_weightover 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.
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.
You have 1% positives. Do you use class weights, resample the data, or leave it alone and just move the threshold?
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
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.
(d) Production reality · 15 min
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.
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.
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.
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.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three to a friend without notes, redo the session:
- Why does the default classifier fail on imbalanced data?
- What are the three families of fixes, and in what order do you try them?
- 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.