Imbalanced and messy data
The case where accuracy lies loudest.
Many problems worth solving have a rare answer: one transaction in a thousand is fraud, one part in five hundred is defective. If the rare case is one per cent of the data, a model that always says no is right ninety-nine per cent of the time and completely useless. This topic is that situation and the handling of it — choosing a metric that cannot be gamed this way, weighting the rare class more heavily, resampling, and moving the threshold at which the model commits to an answer.
Accuracy conceals total failure here, and accuracy is the number everyone reports first. What ships is either a model that never fires, which nobody notices for a quarter, or one that fires so often the alerts get switched off. Both look fine in the notebook.
It rests on model evaluation, which is where precision, recall and thresholds come from, and it recurs in everything with rare events: fraud, defect detection, medical screening, anomaly detection. It pairs with data quality, since real imbalanced datasets are also the messy ones.
With a rare class, accuracy is a number you can score highly on while doing nothing at all.
In more detailWhen one class is 1% of the data, a model that always predicts the majority scores 99%. Resampling, class weighting and threshold tuning help, but the first move is choosing a metric that cannot be gamed this way.
Learn first
When one class is rare
By the end: You can build a useful model when the interesting class is 1 percent of the data.
- Why accuracy fails here
- Resampling and class weights
- Threshold tuning
- What to actually optimise
When the thing you care about is one percent of the data, a model that always says no is ninety-nine percent accurate and completely useless. This section is about the case where the interesting class is rare: why the usual metric lies loudest here, what resampling and class weights actually do, and why the most useful lever is often the threshold you apply afterwards rather than anything in training.
Base rate Accuracy of "always no" What actually changes
10% 90% almost nothing; use the normal tools
1% 99% stop reporting accuracy; move the threshold
0.1% 99.9% threshold and cost become the whole problem
0.01% 99.99% ranking, not classification: score and review the top k
1 in a million 99.9999% this is anomaly detection, not classificationThe setup#
Fraud, defects, disease, churn. The interesting class is rare, which means a model predicting the majority every time scores extremely well and is useless.
1000 transactions, 10 fraudulent
Always predict "not fraud":
accuracy 99%
fraud caught 0The arithmetic is worth doing once at a realistic ratio rather than a convenient one. Everything below uses a generated dataset of 200,000 rows with a positive rate of about 0.14 per cent, which is 700 to 1. That is an ordinary fraud or defect rate, and it is far enough into the table above that the usual advice starts to break down.
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, accuracy_score
X, y = make_classification(n_samples=200_000, n_features=20, n_informative=6,
weights=[0.999, 0.001], flip_y=0.001, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, stratify=y, random_state=0)
print(ytr.sum(), len(ytr)) # 201 positives in 140,000 rows = 0.144%
print(1 - yte.mean()) # 0.99857 accuracy of a model that says no to everything
plain = LogisticRegression(max_iter=2000).fit(Xtr, ytr)
s = plain.predict_proba(Xte)[:, 1]
tn, fp, fn, tp = confusion_matrix(yte, s >= 0.5).ravel()
print(accuracy_score(yte, s >= 0.5), tp, fp, fn)
# 0.99858 tp 1 fp 0 fn 85That is the failure in full. The model is not broken and it is not untrained; it has correctly learned that predicting the majority is almost always right, because that is exactly what the loss function asked for. Accuracy went up. One case in 86 was caught. Reported as 99.86 per cent accurate, this would be signed off.
The options#
- Class weights: tell the model a mistake on the rare class costs more. Simplest, usually try first.
- Oversampling the minority, including synthetic methods like SMOTE. Risks overfitting to the few real examples.
- Undersampling the majority. Fast, and throws away data you paid for.
- Threshold tuning: leave the model alone and move the decision boundary. Frequently the largest win for the least effort.
model = LogisticRegression(class_weight="balanced").fit(Xtr, ytr)
probs = model.predict_proba(Xte)[:, 1]
preds = probs > 0.2 # not 0.5, chosen from the PR curveWeighting and resampling are usually presented as alternatives. They are close to the same operation: balanced class weights multiply the rare class's contribution to the loss by the inverse of its frequency, and duplicating each rare row that many times adds it to the loss that many times. Written out side by side and measured, they land in the same place.
import numpy as np
# (a) class weights: one argument
bal = LogisticRegression(max_iter=2000, class_weight="balanced").fit(Xtr, ytr)
# (b) random oversampling, by hand, on the training fold only
pos, neg = np.where(ytr == 1)[0], np.where(ytr == 0)[0]
up = np.random.default_rng(0).choice(pos, len(neg), replace=True) # draw with replacement
idx = np.concatenate([neg, up])
over = LogisticRegression(max_iter=2000).fit(Xtr[idx], ytr[idx])
s_bal = bal.predict_proba(Xte)[:, 1] # scores, kept for the comparison below
s_over = over.predict_proba(Xte)[:, 1]
# accuracy precision recall tp fp fn
# plain, threshold 0.5 0.99858 1.000 0.012 1 0 85
# class_weight=balanced 0.76190 0.005 0.756 65 14265 21
# oversampled 1:1 0.76205 0.005 0.756 65 14256 21Both moved recall from 0.012 to 0.756, which looks like the problem solved. It is not. They also produced 14,265 alerts to catch 65 real cases, so 99.5 per cent of everything sent to a human is wrong. Whether that is progress depends entirely on what an alert costs, which is a question neither method asked.
The threshold does the same job, better#
The plain model was never unable to find the positives. It ranked them fine and then applied a cut-off of 0.5, which is a number with no connection to anything. Move it, and the untouched model covers the whole range of behaviours the rebalanced ones offered.
for t in (0.5, 0.1, 0.01, 0.005, 0.002):
tn, fp, fn, tp = confusion_matrix(yte, s >= t).ravel()
print(t, tp, fp, fn)
# threshold precision recall tp fp fn
# 0.500 1.000 0.012 1 0 85
# 0.100 0.938 0.174 15 1 71
# 0.010 0.035 0.500 43 1185 43
# 0.005 0.015 0.605 52 3455 34
# 0.002 0.006 0.733 63 10182 23 <- compare with the rebalanced rowsSo the comparison is not close. At roughly the same recall the plain model with a moved threshold produces 10,182 false positives where the rebalanced ones produced 14,265, which is 29 per cent less work for the team reading the alerts. And the underlying ranking, which is what the threshold is slicing, was measurably damaged by rebalancing.
from sklearn.metrics import roc_auc_score, average_precision_score
for name, sc in [("plain", s), ("balanced", s_bal), ("oversampled", s_over)]:
print(name, round(roc_auc_score(yte, sc), 4), round(average_precision_score(yte, sc), 4))
# plain roc_auc 0.8451 average_precision 0.2332
# balanced roc_auc 0.8460 average_precision 0.1751
# oversampled roc_auc 0.8463 average_precision 0.1731That is the finding worth carrying away, because it is the opposite of the usual advice. Rebalancing changes where the model puts its decision boundary, which the threshold already controls for free, and in doing so it distorts the fit in a way that can make the ranking worse. Try the threshold first. Reach for weights when the model genuinely cannot separate the classes at any threshold, which the precision-recall curve will tell you.
Rebalancing destroys the probabilities
A model trained with balanced weights on this data predicts a mean probability of 0.357 where the true rate is 0.00143, overstating the risk by a factor of 250. The rank order survives; the numbers do not. If anything downstream multiplies the score by a transaction value, or shows a percentage to a person, rebalance and you are now lying by two orders of magnitude. Calibrate afterwards, or do not rebalance.
Resample training only, never validation
If you balance the validation set you are measuring on a distribution that does not exist, and the reported performance will not survive contact with production. Resample the training fold; evaluate on the real distribution.
Optimise the business quantity#
Precision and recall are proxies. What usually matters is a cost: a missed fraud costs the transaction value, a false positive costs a review. Expressing that directly gives you an unambiguous threshold rather than an argument about metrics.
def expected_cost(y_true, probs, threshold, miss_cost, review_cost):
preds = probs > threshold
misses = ((y_true == 1) & ~preds).sum()
reviews = preds.sum()
return misses * miss_cost + reviews * review_cost
# A missed case costs 500, a reviewed alert costs 8. Sweep and take the minimum.
grid = [0.001, 0.002, 0.005, 0.01, 0.05, 0.1, 0.5]
for t in grid:
print(t, expected_cost(yte, s, t, 500, 8))
best = min(grid, key=lambda t: expected_cost(yte, s, t, 500, 8))
print("cheapest threshold", best)
# 0.001 159216
# 0.002 93460
# 0.005 45056
# 0.01 31324
# 0.05 34972
# 0.1 35628
# 0.5 42508
# cheapest threshold 0.01Done properly that needs three splits, not two: train the model, choose the threshold on validation, and confirm on a test set that neither has touched. Choosing the threshold on the same data you report is the same overfitting as any other hyperparameter, and with a rare class it is worse, because the choice is being made from a handful of positives.
from sklearn.metrics import precision_recall_curve
Xtr, Xtmp, ytr, ytmp = train_test_split(X, y, test_size=0.4, stratify=y, random_state=0)
Xva, Xte, yva, yte = train_test_split(Xtmp, ytmp, test_size=0.5, stratify=ytmp, random_state=0)
m = LogisticRegression(max_iter=2000).fit(Xtr, ytr)
sva, ste = m.predict_proba(Xva)[:, 1], m.predict_proba(Xte)[:, 1]
MISS, REVIEW = 400, 5 # pounds per missed case, per alert reviewed
_, _, thr = precision_recall_curve(yva, sva)
best_t = min(thr, key=lambda t: MISS * ((sva < t) & (yva == 1)).sum() + REVIEW * (sva >= t).sum())
print(round(best_t, 5)) # 0.01656 chosen on validation only
# confirmed on the held-out test set:
# threshold 0.5 alerts 1 caught 1/57 missed 56 cost 22,405
# cost-chosen alerts 308 caught 26/57 missed 31 cost 13,940The two costs only have to be right relative to each other; scaling both changes nothing. That is useful in practice because nobody can tell you what a missed case costs, but almost everyone can tell you whether it is worth ten reviews or a thousand. Ask the question that way and you will get an answer.
At 1000 to 1, optimise the ranking#
Far enough down the table at the top of this section, the framing changes. Nobody is going to act on 14,000 alerts, so the model is not deciding anything; it is ordering a queue that a team will work down until they run out of day. The right question stops being where the threshold goes and becomes how much of the rare class sits in the top k, where k is a staffing number rather than a modelling one.
for k in (100, 500, 2000):
top = np.argsort(-ste)[:k]
print(k, round(yte[top].mean(), 3), round(yte[top].sum() / yte.sum(), 3))
# review top 100: precision 0.170 recall 0.298
# review top 500: precision 0.058 recall 0.509
# review top 2000: precision 0.018 recall 0.649Read those rows as an operational choice. One hundred reviews a day catches 30 per cent of cases and one alert in six is real, which a team will tolerate. Two thousand reviews catches 65 per cent and 98 per cent of the work is wasted, which no team will tolerate for long, and the usual outcome is that they stop reading the alerts entirely. The model that maximises recall is frequently the model that gets switched off.
Metric Use it when Why not otherwise
accuracy never, with a rare class the majority class answers it
ROC AUC comparing models during development flattered by the huge TN count
average precision comparing models, rare class respects the base rate
precision at k a fixed review capacity matches what the team does
recall at fixed alerts a fixed review capacity the same trade, stated the other way
expected cost the costs are roughly known turns the argument into arithmetic
lift over base rate explaining value to a non-specialist "120 times better than random"Not enough positives to measure
With 201 positives in the training set, every number here has a wide confidence interval, and a difference of two percentage points of recall is eight cases and probably noise. Bootstrap the test set to get an interval before believing any comparison, use stratified cross-validation rather than a single split, and accept that below roughly fifty positives in the evaluation set you cannot distinguish two decent models at all. Collecting more labelled positives is then a better use of a week than any modelling change.
Where people go wrong
Balancing the test set. Performance then looks strong on a distribution that does not exist, and drops immediately in production.
Build this: Take an imbalanced dataset and compare class weights, SMOTE and threshold tuning. Report precision, recall and expected cost for each, and note which gave the most improvement per unit of effort.
Open this section on its own pageMetrics and thresholds, worked through
By the end: You can read a precision-recall trade-off, choose a threshold from costs, and check whether the model's probabilities can be trusted.
- A confusion matrix with real numbers
- Precision-recall curves
- Why ROC AUC flatters rare-event models
- Choosing a threshold from costs
- Calibration
A classifier produces a score; the threshold turns it into a decision, and choosing it is a business question rather than a modelling one. This section works through it with real numbers on a rare-event problem: reading a confusion matrix, why ROC AUC flatters a model when the positive class is 0.2% of the data while precision-recall describes what the team reviewing alerts will actually experience, and how to pick the threshold from the cost of a miss against the cost of a review. It ends on calibration, which you need the moment anything downstream treats the score as a real probability.
The numbers#
100,000 transactions, 200 are fraud (0.2%). Threshold 0.5:
predicted fraud predicted fine
actual fraud 150 50
actual fine 850 98,950
precision = 150 / (150 + 850) = 15% (of alerts, how many are fraud)
recall = 150 / (150 + 50) = 75% (of fraud, how much we caught)
accuracy = 99,100 / 100,000 = 99.1% (a model saying 'fine' always gets 99.8%)Read the two definitions carefully, because the difference between them is the whole section. Precision divides by the number of alerts, so it answers the reviewer's question: if this fires, how often is it real. Recall divides by the number of real cases, so it answers the risk question: of everything that happened, how much did we see. They are computed from the same confusion matrix and they move in opposite directions as the threshold slides, which is why quoting one without the other says nothing.
Every threshold is a different model#
The classifier outputs a score. The threshold turns it into a decision. Raising it gives fewer alerts, higher precision and lower recall. A precision-recall curve shows every trade-off at once, and the area under it summarises the model across thresholds.
from sklearn.metrics import precision_recall_curve, average_precision_score
precision, recall, thresholds = precision_recall_curve(y_val, scores)
print("PR AUC (average precision):", average_precision_score(y_val, scores))One detail about the curve that catches people: precision is not monotonic along it. Raising the threshold usually raises precision, but it can drop when the next few cases excluded happen to be real, and at the extreme right-hand end the curve is computed from a handful of examples and is mostly noise. Do not choose an operating point from the ragged end of a PR curve; it will not reproduce.
A concrete curve, base rate 0.14%, 60,000 rows:
threshold precision recall what it means operationally
0.0508 0.333 0.246 1 alert in 3 is real, you catch a quarter
0.0200 0.107 0.404 1 in 9 is real, you catch 40%
0.0100 0.041 0.544 1 in 24 is real, you catch half
0.0050 0.016 0.649 1 in 62 is real
0.0020 0.006 0.789 1 in 164 is real, you catch four fifths
average precision 0.221 a random model would score 0.0014Average precision has a property that makes it the right summary here: its random baseline is the base rate itself. So 0.221 against a base rate of 0.0014 means the model is roughly 150 times better than guessing, and that ratio stays meaningful as the base rate changes. ROC AUC has a fixed random baseline of 0.5 regardless of imbalance, which is precisely why it cannot tell you how hard your problem is.
Why ROC AUC can mislead here#
ROC curves use the false positive rate, which divides by the huge number of legitimate transactions. 850 false alarms out of 99,800 is a false positive rate under 1%, which looks excellent, while analysts are drowning in alerts that are 85% wrong. For rare events, precision-recall tells you what the team will experience.
The same three operating points, described both ways, is the clearest version of this. Nothing differs but the denominator.
threshold false positive rate precision recall
0.010 0.0198 0.0350 0.500
0.005 0.0577 0.0148 0.605
0.002 0.1699 0.0061 0.733
At threshold 0.01: "a 2% false positive rate" and "96.5% of alerts are wrong"
are the same sentence about the same model.print(round(roc_auc_score(yte, s), 4)) # 0.8451 random = 0.5
print(round(average_precision_score(yte, s), 4)) # 0.2332 random = 0.00143Pick the threshold from money#
COST_MISSED_FRAUD = 400 # average loss per missed fraud
COST_REVIEW = 5 # analyst time per alert
best = min(
thresholds,
key=lambda t: COST_MISSED_FRAUD * ((scores < t) & (y_val == 1)).sum()
+ COST_REVIEW * (scores >= t).sum(),
)Can you trust the probabilities?#
A model is calibrated if, among cases it scores at 0.3, about 30% are positive. Resampling and class weights, common fixes for imbalance, distort probabilities. If anything downstream uses the number as a probability, such as expected loss, calibrate on held-out data and check with a reliability curve.
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
calibrated = CalibratedClassifierCV(model, method="isotonic", cv=5).fit(X_train, y_train)
frac_pos, mean_pred = calibration_curve(y_val, calibrated.predict_proba(X_val)[:, 1], n_bins=10)The quickest check is one line before any curve: compare the mean predicted probability with the observed rate. A calibrated model matches, because the average of its predictions has to equal the average outcome. Measured on the same 0.14 per cent dataset:
for name, sc in [("plain", s), ("balanced", s_bal)]:
print(name, round(sc.mean(), 5), round(yte.mean(), 5))
# plain mean predicted 0.00147 observed 0.00143 ratio 1.0x
# balanced mean predicted 0.35665 observed 0.00143 ratio 248.8xA reliability curve is the same idea per bucket: sort by score, cut into groups, and compare what was predicted with what happened. Quantile bins rather than equal-width bins, because with a rare class every equal-width bin above 0.1 is empty.
Plain model, five quantile bins:
predicted observed
0.00005 0.00008
0.00019 0.00058
0.00047 0.00050
0.00110 0.00067
0.00555 0.00533 close on every bin: this model can be trusted as a probabilityIsotonic regression repairs the scale, and it is worth knowing exactly what it does and does not repair. Applied to the rebalanced model it brings the mean prediction back from 0.357 to 0.00140 against a true rate of 0.00143, which is the fix working. It does not recover the ranking that rebalancing cost.
cal = CalibratedClassifierCV(LogisticRegression(max_iter=2000, class_weight="balanced"),
method="isotonic", cv=3).fit(Xtr, ytr)
sc = cal.predict_proba(Xte)[:, 1]
print(round(sc.mean(), 5), round(yte.mean(), 5)) # 0.00140 0.00143 fixed
print(round(average_precision_score(yte, s_bal), 4),
round(average_precision_score(yte, sc), 4)) # 0.1751 -> 0.1680 not fixedIsotonic or Platt
Isotonic fits an arbitrary increasing step function and is the better choice with enough data, roughly a thousand positives. Below that it overfits, producing flat steps where a whole range of scores maps to one probability. Platt scaling fits a one-parameter sigmoid instead, which cannot overfit in the same way and is the right default when positives are scarce, which on a rare-event problem they always are. Fit either on held-out data, never on the training set.
Where people go wrong
Using the default 0.5 threshold. For a rare class it is almost never the right one, because nothing about 0.5 relates to your costs.
Build this: Take an imbalanced dataset, train a model with class weights, plot its precision-recall curve, choose a threshold from assumed costs, and draw a reliability curve before and after isotonic calibration.
Open this section on its own pageWhere you use this
A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.
- Stage 5, Choose the metric, then choose the threshold: What changes when the positive class is 3% of rows, and which of the standard fixes are worth applying.
Now actually do it
Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.
1Three approaches, measured by cost
5hCompare class weights, SMOTE and threshold tuning on the same imbalanced data. Report precision, recall and expected cost using realistic error costs.
Done when: You chose a threshold from expected cost rather than from a metric argument.
2Balance the test set, then regret it
5hDeliberately resample validation and record the inflated performance. Then evaluate on the real distribution.
Done when: You know the size of the inflation, which is why you will not do it again.
10 hours of practice
Words used here
3 terms this topic teaches, each with a one-line definition if one of them stopped you.
- PR AUC
- Area under the precision-recall curve, which unlike ROC AUC takes no credit for correctly dismissing the abundant negative class. The right summary metric when positives are rare.
- Class imbalance
- One class making up a small fraction of the data, so a model minimising average error learns to ignore it. Fraud, faults and disease all look like this.
- SMOTE
- Creating extra minority-class rows by interpolating between real ones and their neighbours, rather than duplicating them.
Interview questions
5 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.
When one class is rare
Your classes are 99 to 1. What do you do?
Stop using accuracy, and use precision, recall and PR AUC. Try class weights first, tune the decision threshold from the precision-recall curve rather than leaving it at 0.5, and evaluate on the real distribution rather than a rebalanced one.
Should you oversample before or after splitting?
After, and only the training fold. Oversampling before splitting puts copies of the same example in both train and test, which inflates the score. The validation set must keep the real class balance.
How do you pick the decision threshold?
From the cost of each error type. Compute expected cost across thresholds using the real cost of a miss and of a false positive, and pick the minimum. That turns a metric argument into an arithmetic answer.
Metrics and thresholds, worked through
Your fraud model has 0.97 ROC AUC. The fraud team says it is useless. Who is right?
Possibly both. With fraud at 0.2%, even a low false positive rate produces far more false alerts than real fraud, so precision at the operating threshold can be very low. I would look at the precision-recall curve and at precision and alert volume at the threshold actually in use.
Then they push further
How would you choose the threshold?
From costs: the average loss from a missed fraud against the cost of reviewing an alert, plus any capacity limit on the team. Pick the threshold that minimises expected cost on validation data, then confirm on held-out data.
The team can only review 500 alerts a day. Does that change anything?
Yes. Then the question becomes which 500 to review, so rank by score and take the top 500. The metric that matters is precision in the top 500, and improving the model means pushing more fraud into that set.
What gets marked up. Strong answers explain why ROC AUC flatters rare-event models and move to precision at the operating point and costs. Treating AUC as the final answer is marked down.
What does it mean for a classifier to be calibrated, and when do you need it?
Its scores match observed frequencies: cases scored 0.3 are positive about 30% of the time. You need it whenever the score is used as a probability, such as in expected cost calculations or when showing risk to people.
Resources
Free to watch and read, picked for this topic.
Videos
For when one class is rare
For metrics and thresholds, worked through
Full courses: the whole topic, start to finish