Imbalanced Data
In many important problems, the class you care about is rare. Perhaps 2 in 100 UPI payments are fraud, 1 in 50 machines fails this month, or 5 in 100 customers cancel. A model trained on such data learns that "almost always the common class" is a safe bet, and it can look excellent by accuracy while missing nearly every case that matters.
In this lesson you will see the problem in action, measure it properly, and fix it with three tools: class weights, threshold tuning and resampling.
The accuracy paradox in action
make_classification creates synthetic data with a chosen class balance. Here, 3% of 5,000 payments are fraud.
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y = make_classification(n_samples=5000, n_features=10, n_informative=4,
weights=[0.97, 0.03], class_sep=1.0,
n_clusters_per_class=1, flip_y=0, random_state=42)
print(f"Fraud share: {y.mean():.3f}")
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
model = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train)
pred = model.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, pred):.3f}")
print(classification_report(y_test, pred, target_names=["genuine", "fraud"], digits=3))Fraud share: 0.030
Accuracy: 0.984
precision recall f1-score support
genuine 0.984 1.000 0.992 1455
fraud 1.000 0.467 0.636 45
accuracy 0.984 1500
macro avg 0.992 0.733 0.814 1500
weighted avg 0.984 0.984 0.981 1500Accuracy looks wonderful, but the fraud row tells the real story: the model catches only about half of the fraud cases. This is the accuracy paradox: with imbalanced classes, accuracy mostly measures how common the majority class is. Note stratify=y in the split; without it, a test set could easily end up with very few fraud cases.
Better metrics for rare classes
Judge imbalanced problems with metrics that focus on the rare class (see Classification Metrics):
- Recall and precision of the rare class, and their F1 score.
- Balanced accuracy: the average of the recall of each class, so the rare class counts as much as the common one. A model that always predicts "genuine" gets 0.5.
- Average precision (
average_precision_score, the area under the precision-recall curve): a threshold-free score that, unlike ROC-AUC, is sensitive to false alarms when positives are rare. A random model scores about the positive share (0.03 here).
Fix 1: class weights
Most classifiers accept class_weight. With class_weight="balanced", each class gets a weight inversely proportional to its frequency, so every fraud case counts about 32 times as much as a genuine one during training. The model can no longer ignore the rare class cheaply.
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (average_precision_score, balanced_accuracy_score,
f1_score, precision_score, recall_score)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y = make_classification(n_samples=5000, n_features=10, n_informative=4,
weights=[0.97, 0.03], class_sep=1.0,
n_clusters_per_class=1, flip_y=0, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
for name, weight in [("plain", None), ("balanced", "balanced")]:
model = make_pipeline(StandardScaler(), LogisticRegression(class_weight=weight))
model.fit(X_train, y_train)
pred = model.predict(X_test)
proba = model.predict_proba(X_test)[:, 1]
print(f"{name:<9} recall {recall_score(y_test, pred):.3f} precision {precision_score(y_test, pred):.3f} "
f"F1 {f1_score(y_test, pred):.3f} balanced acc {balanced_accuracy_score(y_test, pred):.3f} "
f"avg precision {average_precision_score(y_test, proba):.3f}")plain recall 0.467 precision 1.000 F1 0.636 balanced acc 0.733 avg precision 0.710 balanced recall 0.800 precision 0.113 F1 0.198 balanced acc 0.803 avg precision 0.432
With balanced weights, recall jumps: the model now catches most fraud cases. But precision collapses, so most alarms are now false, and average precision, which judges the ranking of payments at every threshold, actually gets worse. Class weights are a blunt tool that changes the whole fit. Often a gentler fix works better: keep the model and move its decision threshold.
Fix 2: tune the decision threshold
A classifier's predict uses a probability threshold of 0.5. For a rare class, a lower threshold is usually better. You could pick one by hand, but choosing it on the test set would be cheating. TunedThresholdClassifierCV (added in scikit-learn 1.5) finds the threshold that maximises a metric of your choice using cross-validation on the training data. FixedThresholdClassifier simply applies a threshold you already decided.
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score, precision_score, recall_score
from sklearn.model_selection import (FixedThresholdClassifier, TunedThresholdClassifierCV,
train_test_split)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y = make_classification(n_samples=5000, n_features=10, n_informative=4,
weights=[0.97, 0.03], class_sep=1.0,
n_clusters_per_class=1, flip_y=0, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
base = make_pipeline(StandardScaler(), LogisticRegression())
tuned = TunedThresholdClassifierCV(base, scoring="f1", cv=5).fit(X_train, y_train)
fixed = FixedThresholdClassifier(base, threshold=0.1).fit(X_train, y_train)
print(f"Threshold chosen by cross-validation: {tuned.best_threshold_:.3f}")
for name, model in [("default 0.5", base.fit(X_train, y_train)), ("tuned for F1", tuned), ("fixed 0.1", fixed)]:
pred = model.predict(X_test)
print(f"{name:<13} recall {recall_score(y_test, pred):.3f} "
f"precision {precision_score(y_test, pred):.3f} F1 {f1_score(y_test, pred):.3f}")Threshold chosen by cross-validation: 0.172 default 0.5 recall 0.467 precision 1.000 F1 0.636 tuned for F1 recall 0.600 precision 0.900 F1 0.720 fixed 0.1 recall 0.689 precision 0.585 F1 0.633
Threshold tuning lets you pick the balance between catching fraud and raising alarms on purpose. Maximising F1 is one choice; in a real bank, you might instead require at least 80% recall, or minimise a money cost, as in the exercise below.
Fix 3: resampling
Resampling changes the training data itself. Oversampling repeats rare-class rows (drawn with replacement) until the classes are balanced; undersampling throws away majority rows. Resample the training set only. The test set must keep the real-world balance, or your scores will be fiction.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.utils import resample
X, y = make_classification(n_samples=5000, n_features=10, n_informative=4,
weights=[0.97, 0.03], class_sep=1.0,
n_clusters_per_class=1, flip_y=0, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
X_fraud, X_ok = X_train[y_train == 1], X_train[y_train == 0]
X_fraud_up = resample(X_fraud, replace=True, n_samples=len(X_ok), random_state=42)
X_bal = np.vstack([X_ok, X_fraud_up])
y_bal = np.array([0] * len(X_ok) + [1] * len(X_fraud_up))
print("Training rows before:", np.bincount(y_train), " after:", np.bincount(y_bal))
model = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_bal, y_bal)
pred = model.predict(X_test) # the test set keeps its real 3% fraud
print(f"recall {recall_score(y_test, pred):.3f} precision {precision_score(y_test, pred):.3f}")Training rows before: [3395 105] after: [3395 3395] recall 0.800 precision 0.114
Random oversampling has an effect similar to class weights. The popular imbalanced-learn library adds smarter methods such as SMOTE, which creates new synthetic minority samples between existing ones, and its own pipeline that resamples only during fit. It is not available in the browser, but on your computer it looks like this:
# python -m pip install imbalanced-learn
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import make_pipeline as make_imb_pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
model = make_imb_pipeline(StandardScaler(), SMOTE(random_state=42), LogisticRegression())
# model.fit(X_train, y_train) resamples the training folds onlyWhich fix should you use?
Start with the simplest: stratified splits, the right metrics, then class_weight="balanced" or threshold tuning. They need no extra data handling and work inside pipelines and cross-validation. Try resampling when those are not enough, and always compare options with cross-validated scores on data that keeps the real class balance. Above all, choose the threshold from the real costs: a missed fraud may cost ₹5,000, while a false alarm costs a ₹100 phone call.
Common mistakes
- Reporting accuracy. Report recall, precision, F1, balanced accuracy or average precision for the rare class.
- Resampling before splitting. Copies of the same fraud row then appear in both training and test sets, which is leakage. Resample only the training data, inside cross-validation.
- Choosing the threshold on the test set. Use
TunedThresholdClassifierCVor a validation set. - Splitting without
stratify. A small or unlucky test set may contain almost no positive cases.
Key points
- With imbalanced classes, a model can have high accuracy and still miss most rare cases.
- Use stratified splits and metrics focused on the rare class, such as recall, F1, balanced accuracy and average precision.
class_weight="balanced"makes rare-class mistakes more costly during training.TunedThresholdClassifierCVpicks the decision threshold with cross-validation;FixedThresholdClassifierapplies a chosen one.- Resample only the training data; imbalanced-learn offers SMOTE and leak-free resampling pipelines.
Exercise
A bank says a missed fraud costs ₹5,000 and a false alarm costs ₹100. Using the plain logistic regression pipeline on the same data, compute predict_proba on the test set and, for thresholds 0.02, 0.05, 0.1, 0.2, 0.3 and 0.5, print the number of missed frauds, the number of false alarms and the total cost. Which threshold is cheapest? (In a real project you would choose it on validation data, not the test set.)
Show solution
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y = make_classification(n_samples=5000, n_features=10, n_informative=4,
weights=[0.97, 0.03], class_sep=1.0,
n_clusters_per_class=1, flip_y=0, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
model = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
for threshold in [0.02, 0.05, 0.1, 0.2, 0.3, 0.5]:
flagged = proba >= threshold
missed = int(((y_test == 1) & ~flagged).sum())
alarms = int(((y_test == 0) & flagged).sum())
print(f"threshold {threshold:<4} missed {missed:>2} false alarms {alarms:>3} "
f"cost ₹{missed * 5000 + alarms * 100:,}")threshold 0.02 missed 8 false alarms 349 cost ₹74,900 threshold 0.05 missed 11 false alarms 97 cost ₹64,700 threshold 0.1 missed 14 false alarms 22 cost ₹72,200 threshold 0.2 missed 18 false alarms 2 cost ₹90,200 threshold 0.3 missed 21 false alarms 1 cost ₹105,100 threshold 0.5 missed 24 false alarms 0 cost ₹120,000
A threshold of 0.05 is cheapest here: it misses 11 frauds and raises 97 false alarms, for ₹64,700. Because a missed fraud costs 50 times more than a false alarm, the best threshold sits far below the default 0.5, which would cost ₹1.2 lakh by missing 24 frauds.
Quick Quiz
1. A fraud model has 97% accuracy on data with 3% fraud. What can you conclude?
On imbalanced data, accuracy mostly reflects the majority class; check recall and precision.
2. What does class_weight="balanced" do?
Balanced weights stop the model from ignoring the rare class to minimise its loss.
3. What does TunedThresholdClassifierCV choose?
It keeps the model and tunes only the cut-off used to turn probabilities into classes.
4. When should you resample the data?
Resampling before the split leaks copies of rows into the test set and inflates scores.
Learn Machine Learning with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
