Skip to content

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:

  1. 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.
  2. The threshold is wrong. A model outputs a score; turning that score into a decision requires a cut, and 0.5 is 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_weight in BCEWithLogitsLoss.
  • 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:

  1. Fix the metric. Report average precision and the prevalence, and whichever of precision/recall your problem is about.
  2. Tune the threshold on validation, from the costs.
  3. 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.
  4. Balance the batches only if batches are coming up empty of positives.
  5. 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.



  1. 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. β†©