Class Imbalance
Class Imbalance
Fraud is 0.1% of transactions. Disease is 2% of screenings. Equipment failure is a handful of hours in a year of logs. Imbalance is the normal condition of any problem worth solving, because the interesting class is the rare one β that is usually why it is interesting.
The standard advice is to fix it: resample, reweight, generate synthetic minority points. Most of that advice is aimed at the wrong thing, and the lab on this page measures how little of it helps.
What is actually wrong
Imbalance by itself is not a defect in the data. Two other things are, and they get blamed on it:
- The metric is wrong. Accuracy under a 2% positive rate is a report on the majority class. It has to go, and what replaces it depends on what you are doing.
- The threshold is wrong. A model outputs a score; turning that score into a decision requires a cut, and
0.5is a default, not an answer. The right cut comes from what the two kinds of error cost.
Fix those two and there is usually very little left for resampling to do β and resampling has a cost of its own: it destroys the meaning of the probabilities the model outputs.
1. The metric
The first table of the lab makes the case better than any argument. One model, one set of scores, four different class mixes:
prevalence | majority_class_acc | roc_auc | avg_precision |
|---|---|---|---|
| 0.300 | 0.700 | 0.968 | 0.934 |
| 0.100 | 0.900 | 0.969 | 0.836 |
| 0.020 | 0.980 | 0.972 | 0.652 |
| 0.005 | 0.995 | 0.967 | 0.457 |
"""One model, one set of scores, four different class mixes.
The model is trained once and never touched again. All that changes is how many
negatives are kept in the evaluation set β so the model's ability to rank is
identical in every row, by construction.
Watch the three metrics disagree about what happened:
majority_acc what you get by predicting "no" every time β it goes UP
roc_auc flat, because it is a property of the ranking alone
avg_precision falls by half, because finding the positives really does get
harder as they get rarer
Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
N, DIM, TRAIN = 60_000, 10, 20_000
rng = np.random.default_rng(11)
direction = rng.normal(size=DIM)
X = rng.normal(size=(N, DIM))
risk = X @ direction + 1.0 * rng.normal(size=N)
y = (risk > np.quantile(risk, 0.70)).astype(int)
fitted = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(X[:TRAIN], y[:TRAIN])
scores = fitted.predict_proba(X[TRAIN:])[:, 1]
held = y[TRAIN:]
positives, negatives = np.flatnonzero(held == 1), np.flatnonzero(held == 0)
draw = np.random.default_rng(0)
print("| `prevalence` | `majority_class_acc` | `roc_auc` | `avg_precision` |")
print("|---:|---:|---:|---:|")
for target in (0.30, 0.10, 0.02, 0.005):
keep = min(len(positives), int(target * len(negatives) / (1 - target)))
rows = np.concatenate([draw.choice(positives, keep, replace=False), negatives])
subset, p = held[rows], scores[rows]
print(f"| {subset.mean():.3f} | {max(subset.mean(), 1 - subset.mean()):.3f} "
f"| {roc_auc_score(subset, p):.3f} | **{average_precision_score(subset, p):.3f}** |")
The model is trained once and never touched. As the positives get rarer:
- Accuracy of the trivial predictor goes up, from 0.700 to 0.995. The metric says the problem became easier. It did not; the metric became meaningless.
- ROC-AUC stays flat, 0.968 to 0.967. It is a property of the ranking alone and therefore blind to how rare the positives are. Useful, but it will not tell you your task got hard.
- Average precision falls by half, 0.934 to 0.457. It is the one that noticed. Finding the positives really did get harder, because for the same recall you now wade through far more negatives.
| Metric | What it answers | Use it when |
|---|---|---|
| Accuracy | how often am I right? | classes are balanced and errors cost the same |
| Precision | of what I flagged, how much was real? | false alarms are expensive |
| Recall / sensitivity | of what was real, how much did I catch? | misses are expensive |
| F1 | a compromise between those two | you need one number and the costs are roughly symmetric |
| Average precision (PR-AUC) | over all thresholds, how good is the ranking of the positives? | the default summary under imbalance |
| ROC-AUC | how well are positives ranked above negatives? | comparing models; be aware it is insensitive to prevalence |
| MCC | a balanced summary of the whole confusion matrix | binary, very imbalanced, no strong cost asymmetry |
ROC-AUC flatters an imbalanced problem
With 99% negatives, a large absolute number of false positives is a small false-positive rate, and the ROC curve is drawn in rates. That is why a ROC-AUC of 0.99 can coexist with a precision of 0.07, as it does in the panel below. Report average precision as well, and always report the prevalence next to it β an AP of 0.45 is poor at 30% prevalence and excellent at 0.5%.
2. The threshold
A classifier does not output a decision. It outputs a score, and somebody has to choose where to cut. predict() cuts at 0.5 because it has to cut somewhere.
Set the panel to 2% positives and a miss that costs ten false alarms, and the cheapest threshold is 0.85, not 0.50 β moving it there roughly halves the bill. Make misses fifty times more expensive and the best cut drops to 0.65. Make them cheap and it climbs to 0.97. Raise the prevalence to 30% and the best cut falls below 0.5.
The threshold is not a model property. It is where the model's output meets your problem's economics, and it is free to change β no retraining, no resampling, no new data.
from sklearn.metrics import precision_recall_curve
p = model.predict_proba(X_val)[:, 1] # on validation, never on test
precision, recall, thresholds = precision_recall_curve(y_val, p)
# if you know the costs, minimize the cost directly
cost = lambda t: MISS_COST * ((p < t) & (y_val == 1)).sum() + ((p >= t) & (y_val == 0)).sum()
best = min(thresholds, key=cost)
# if you do not, at least pick for the constraint you actually have
enough = recall >= 0.90 # "we must catch 90% of fraud"
best = thresholds[np.argmax(precision[:-1][enough[:-1]])]
The question that replaces 'how do I handle imbalance?'
What does a miss cost, relative to a false alarm? A missed tumour and a needless follow-up scan are not comparable errors, and the ratio between them β even a rough one, even an order of magnitude β determines the threshold and therefore the entire behaviour of the system. If nobody can answer it, that is the finding, and it needs resolving before any modelling.
3. Resampling and class weights
Now the interventions everybody reaches for first.
- Class weights multiply the loss on minority examples. In
sklearn,class_weight='balanced'; in PyTorch,pos_weightinBCEWithLogitsLoss. - Random oversampling duplicates minority rows until the classes are even.
- SMOTE synthesizes new minority points by interpolating between neighbours, to avoid literal duplicates.
- Undersampling throws away majority rows, which is the only one that also makes training faster.
strategy | roc_auc | avg_precision | f1@0.5 | f1@best | brier | mean_pred |
|---|---|---|---|---|---|---|
baseline | 0.993 | 0.761 | 0.683 | 0.697 | 0.0088 | 0.020 |
threshold_tuned | 0.993 | 0.761 | 0.683 | 0.697 | 0.0088 | 0.020 |
class_weight_balanced | 0.992 | 0.758 | 0.443 | 0.693 | 0.0360 | 0.075 |
random_oversample | 0.992 | 0.758 | 0.441 | 0.688 | 0.0355 | 0.074 |
oversample_then_corrected | 0.992 | 0.758 | 0.681 | 0.688 | 0.0090 | 0.022 |
"""Four ways to "handle imbalance", and what each one actually changes.
A 2% positive rate, one model family, four treatments. Read the table by
column rather than by row:
roc_auc barely moves β which is the first thing to notice about it
avg_precision barely moves either: none of these improves the ranking
f1@0.5 moves a lot, and downward for the resampled variants
f1@best back to level once the threshold is tuned for each
brier, mean_pred wrecked by weighting and by oversampling, and restored by
the prior correction on the last row
Logistic regression rather than a network, because `class_weight` is available
and the run is deterministic. For a network the equivalent of class weights is
`pos_weight` in `BCEWithLogitsLoss`, with exactly the same consequence for the
probabilities it outputs.
Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (average_precision_score, brier_score_loss,
f1_score, roc_auc_score)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
N, DIM, POSITIVE = 20_000, 10, 0.02
rng = np.random.default_rng(11)
direction = rng.normal(size=DIM)
X = rng.normal(size=(N, DIM))
risk = X @ direction + 1.0 * rng.normal(size=N)
y = (risk > np.quantile(risk, 1 - POSITIVE)).astype(int)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, stratify=y, random_state=0
)
model = lambda **kw: make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, **kw))
def row(name, p):
grid = np.quantile(p, np.linspace(0.90, 0.9999, 300))
best = max(f1_score(y_test, p >= t) for t in grid)
print(f"| `{name}` | {roc_auc_score(y_test, p):.3f} | **{average_precision_score(y_test, p):.3f}** "
f"| {f1_score(y_test, p >= 0.5):.3f} | **{best:.3f}** "
f"| {brier_score_loss(y_test, p):.4f} | {p.mean():.3f} |")
print("| `strategy` | `roc_auc` | `avg_precision` | `f1@0.5` | `f1@best` | `brier` | `mean_pred` |")
print("|---|---:|---:|---:|---:|---:|---:|")
plain = model().fit(X_train, y_train).predict_proba(X_test)[:, 1]
row("baseline", plain)
row("threshold_tuned", plain) # the same model β only the cut moves
weighted = model(class_weight="balanced").fit(X_train, y_train)
row("class_weight_balanced", weighted.predict_proba(X_test)[:, 1])
minority, majority = np.flatnonzero(y_train == 1), np.flatnonzero(y_train == 0)
copies = np.random.default_rng(0).choice(minority, len(majority) - len(minority), replace=True)
index = np.concatenate([np.arange(len(y_train)), copies])
resampled = model().fit(X_train[index], y_train[index])
p_over = resampled.predict_proba(X_test)[:, 1]
row("random_oversample", p_over)
# Undo the base rate the resampling invented (Elkan / King & Zeng): shift the odds
# by the ratio of the true prior to the training prior.
prior_train, prior_true = y_train[index].mean(), y_train.mean()
odds = p_over / (1 - p_over) * (prior_true / (1 - prior_true)) * ((1 - prior_train) / prior_train)
row("oversample_then_corrected", odds / (1 + odds))
Read that table by column, and it says something the usual advice does not.
Nothing improved the ranking. Average precision is 0.758β0.761 across all five rows, ROC-AUC 0.992β0.993. Whatever these treatments do, they do not make the model better at telling positives from negatives.
The F1 differences are threshold differences. At the default 0.5, weighting and oversampling look worse than the baseline β 0.443 and 0.441 against 0.683. At each model's own best threshold they are level again: 0.697, 0.693, 0.688. Reweighting did not improve the model; it moved the operating point, which the threshold does for free.
And they broke the probabilities. The Brier score goes from 0.0088 to 0.0360, four times worse, and the mean predicted probability from 0.020 β which is exactly the true prevalence β to 0.075. The model now believes positives are almost four times more common than they are, because you told it so by showing it a training set where they were.
Resampling changes the base rate the model learns
If anything downstream consumes your probabilities β expected value, a risk score, a cost calculation, a calibration plot β a resampled model is lying to it. The distortion is correctable in closed form: shift the log-odds by the ratio of the true prior to the training prior.
# Elkan's correction, undoing a training prevalence of pi_train
odds = p / (1 - p) * (pi_true / (1 - pi_true)) * ((1 - pi_train) / pi_train)
p_corrected = odds / (1 + odds)
The last row of the lab applies exactly this: Brier back to 0.0090, mean prediction back to 0.022.
So when is resampling worth it?
There are real cases, and they are narrower than the reputation suggests:
- The minority class is so rare that batches contain none of it. With 0.01% positives and a batch of 256, most batches carry no signal at all. Balanced batch sampling fixes a gradient problem, not a statistical one.
- Undersampling as a compute decision. Dropping 90% of the majority class makes training ten times faster at little cost in AP; that is a legitimate trade even if it buys no accuracy.
- The loss itself is the problem. Focal loss down-weights easy, well-classified examples β the vast bulk of the negatives β so the gradient signal comes from the hard cases. This is a different mechanism from reweighting by class, and it is the one that has held up best in deep learning.1
Wherever you resample, do it inside the training fold
Oversampling before the split puts copies of the same row on both sides of it, and the model is then scored on rows it memorized. Measured in the leakage chapter: +0.173 AUC of pure fiction, the largest of the four leaks on that page.
4. Deep learning specifics
For a network trained on a large dataset, the practical order is:
- Fix the metric. Report average precision and the prevalence, and whichever of precision/recall your problem is about.
- Tune the threshold on validation, from the costs.
- Weight the loss if the gradient is genuinely dominated by negatives β
pos_weight, or focal loss. Remember it decalibrates, and correct afterwards if anything consumes the probabilities. - Balance the batches only if batches are coming up empty of positives.
- Collect more positives. Almost always the highest-value action, and almost always the one nobody costs out.
import torch
# pos_weight scales the positive term of the loss; N_neg / N_pos is the usual starting point
loss = torch.nn.BCEWithLogitsLoss(pos_weight=torch.tensor([n_neg / n_pos]))
# focal loss: down-weight the easy examples instead of up-weighting a whole class
def focal(logits, target, gamma=2.0, alpha=0.25):
bce = torch.nn.functional.binary_cross_entropy_with_logits(logits, target, reduction='none')
pt = torch.exp(-bce) # the probability assigned to the true class
return (alpha * (1 - pt) ** gamma * bce).mean()
What the labs are teaching
The reflex is to change the data. The measurements say to change the report and the cut first: the metric, because accuracy and ROC-AUC will both tell you an imbalanced problem is going fine, and the threshold, because it is where the costs enter and it is the only lever that moves the outcome without touching the model. Resampling is a third-order effect that arrives with a calibration bill attached.
-
Lin, T.-Y., Goyal, P., Girshick, R., He, K., DollΓ‘r, P. Focal Loss for Dense Object Detection, ICCV 2017. See also Chawla, N. V. et al. SMOTE: Synthetic Minority Over-sampling Technique, JAIR 2002, and Elkan, C. The Foundations of Cost-Sensitive Learning, IJCAI 2001, which is where the prior correction above comes from. β©