Handling Imbalanced Data
Practical techniques for class imbalance — resampling, cost-sensitive learning, threshold tuning, and proper evaluation.
Real-World Scenario
A fraud detection model trained on 1 million transactions where 0.1% are fraud. Without handling imbalance, the model learns to always predict “not fraud” and scores 99.9% accuracy. The real goal: catch 90% of fraud (recall) while keeping false positive rate under 1% (precision). This requires resampling, cost-sensitive training, and threshold tuning.
Diagnosing the Problem
import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
classification_report, confusion_matrix,
roc_auc_score, average_precision_score
)
# Create imbalanced dataset: 98% class 0, 2% class 1
X, y = make_classification(
n_samples=10_000,
n_features=20,
n_informative=10,
weights=[0.98, 0.02], # 98/2 split
random_state=42,
)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
stratify=y, random_state=42)
print(f"Training positives: {y_train.sum()} / {len(y_train)} ({y_train.mean():.1%})")
print(f"Test positives: {y_test.sum()} / {len(y_test)} ({y_test.mean():.1%})")
# Naive model — ignores imbalance
naive = LogisticRegression(max_iter=1000)
naive.fit(X_train, y_train)
print("\nNaive model (no imbalance handling):")
y_pred = naive.predict(X_test)
print(classification_report(y_test, y_pred, target_names=["normal", "fraud"]))
print(f"AUC-ROC: {roc_auc_score(y_test, naive.predict_proba(X_test)[:,1]):.4f}")
print(f"Avg Prec: {average_precision_score(y_test, naive.predict_proba(X_test)[:,1]):.4f}")
Class Weights
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.utils.class_weight import compute_class_weight
from sklearn.metrics import classification_report, roc_auc_score, average_precision_score
import numpy as np
# class_weight="balanced" automatically sets weight inversely proportional to frequency
lr_balanced = LogisticRegression(class_weight="balanced", max_iter=1000)
lr_balanced.fit(X_train, y_train)
y_pred = lr_balanced.predict(X_test)
print("Balanced logistic regression:")
print(classification_report(y_test, y_pred, target_names=["normal", "fraud"]))
print(f"AUC-ROC: {roc_auc_score(y_test, lr_balanced.predict_proba(X_test)[:,1]):.4f}")
print(f"Avg Prec: {average_precision_score(y_test, lr_balanced.predict_proba(X_test)[:,1]):.4f}")
# Manual weight computation (same result, but you can tune it)
weights = compute_class_weight("balanced", classes=np.array([0, 1]), y=y_train)
class_weight_dict = {0: weights[0], 1: weights[1]}
print(f"\nAuto-computed weights: class 0 = {weights[0]:.2f}, class 1 = {weights[1]:.2f}")
# For HistGradientBoosting, use sample_weight
from sklearn.utils import class_weight as cw_module
sample_weights = np.where(y_train == 1, weights[1], weights[0])
hgb = HistGradientBoostingClassifier(max_iter=200, random_state=42)
hgb.fit(X_train, y_train, sample_weight=sample_weights)
print(f"\nHGB with sample weights — AUC: {roc_auc_score(y_test, hgb.predict_proba(X_test)[:,1]):.4f}")
Resampling with imbalanced-learn
# pip install imbalanced-learn
from imblearn.over_sampling import SMOTE, ADASYN
from imblearn.under_sampling import RandomUnderSampler, TomekLinks
from imblearn.combine import SMOTETomek
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, average_precision_score
# SMOTE: Synthetic Minority Over-sampling Technique
# Creates synthetic minority samples by interpolating between existing ones
smote = SMOTE(sampling_strategy=0.1, random_state=42) # minority:majority = 1:10
X_res, y_res = smote.fit_resample(X_train, y_train)
print(f"After SMOTE: {y_res.sum()} positives / {len(y_res)} total ({y_res.mean():.1%})")
# Train on resampled data
lr = LogisticRegression(max_iter=1000)
lr.fit(X_res, y_res)
print(f"SMOTE + LR — AUC: {roc_auc_score(y_test, lr.predict_proba(X_test)[:,1]):.4f}")
# Combine oversampling + Tomek Links cleaning (removes borderline samples)
smt = SMOTETomek(sampling_strategy=0.1, random_state=42)
X_comb, y_comb = smt.fit_resample(X_train, y_train)
lr.fit(X_comb, y_comb)
print(f"SMOTETomek + LR — AUC: {roc_auc_score(y_test, lr.predict_proba(X_test)[:,1]):.4f}")
# Best practice: resampling inside pipeline to prevent leakage
imb_pipeline = ImbPipeline([
("smote", SMOTE(sampling_strategy=0.1, random_state=42)),
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=1000)),
])
imb_pipeline.fit(X_train, y_train)
print(f"Imb-Pipeline AUC: {roc_auc_score(y_test, imb_pipeline.predict_proba(X_test)[:,1]):.4f}")
Threshold Tuning
import numpy as np
from sklearn.metrics import (
precision_recall_curve, f1_score, precision_score, recall_score
)
import matplotlib # noqa — for documentation; use matplotlib.pyplot to plot
# Get predicted probabilities (don't use default 0.5 threshold)
y_probs = hgb.predict_proba(X_test)[:, 1]
# Find the threshold that maximizes F1 score
precisions, recalls, thresholds = precision_recall_curve(y_test, y_probs)
f1_scores = 2 * precisions[:-1] * recalls[:-1] / (precisions[:-1] + recalls[:-1] + 1e-9)
best_idx = f1_scores.argmax()
best_threshold = thresholds[best_idx]
print(f"Default threshold (0.5):")
y_pred_default = (y_probs >= 0.5).astype(int)
print(f" Precision: {precision_score(y_test, y_pred_default):.4f}")
print(f" Recall: {recall_score(y_test, y_pred_default):.4f}")
print(f" F1: {f1_score(y_test, y_pred_default):.4f}")
print(f"\nOptimal threshold ({best_threshold:.3f}):")
y_pred_optimal = (y_probs >= best_threshold).astype(int)
print(f" Precision: {precision_score(y_test, y_pred_optimal):.4f}")
print(f" Recall: {recall_score(y_test, y_pred_optimal):.4f}")
print(f" F1: {f1_score(y_test, y_pred_optimal):.4f}")
# Business-driven threshold: require recall >= 0.90
recall_90_mask = recalls[:-1] >= 0.90
if recall_90_mask.any():
# Among thresholds with recall >= 0.90, maximize precision
best_prec_at_90recall = precisions[:-1][recall_90_mask].max()
idx_90 = np.where(recall_90_mask & (precisions[:-1] == best_prec_at_90recall))[0][0]
threshold_90 = thresholds[idx_90]
y_pred_90 = (y_probs >= threshold_90).astype(int)
print(f"\nThreshold for recall ≥ 90% ({threshold_90:.3f}):")
print(f" Precision: {precision_score(y_test, y_pred_90):.4f}")
print(f" Recall: {recall_score(y_test, y_pred_90):.4f}")
Evaluation Metrics for Imbalance
from sklearn.metrics import (
roc_auc_score, average_precision_score,
balanced_accuracy_score, matthews_corrcoef,
confusion_matrix,
)
import numpy as np
y_probs = hgb.predict_proba(X_test)[:, 1]
y_pred = (y_probs >= best_threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
print("Evaluation metrics for imbalanced classification:")
print(f" AUC-ROC: {roc_auc_score(y_test, y_probs):.4f} (threshold-independent)")
print(f" Avg Precision (AP): {average_precision_score(y_test, y_probs):.4f} (area under PR curve)")
print(f" Balanced Accuracy: {balanced_accuracy_score(y_test, y_pred):.4f} (mean recall per class)")
print(f" MCC: {matthews_corrcoef(y_test, y_pred):.4f} (robust to imbalance)")
print(f"\nConfusion matrix:")
print(f" True Positives: {tp:4d} (fraud caught)")
print(f" False Positives: {fp:4d} (false alarms)")
print(f" False Negatives: {fn:4d} (missed fraud)")
print(f" True Negatives: {tn:4d} (correct clear)")
print(f"\n Fraud catch rate (recall): {tp/(tp+fn):.1%}")
print(f" False alarm rate: {fp/(fp+tn):.1%}")
# Which metrics matter depends on the business cost of each error type
COST_FN = 500 # cost of missing one fraud
COST_FP = 10 # cost of false alarm (manual review)
business_cost = fn * COST_FN + fp * COST_FP
print(f"\nBusiness cost: ${business_cost:,.0f} (FN=${fn*COST_FN:,.0f}, FP=${fp*COST_FP:,.0f})") Frequently Asked Questions
Why is accuracy a bad metric for imbalanced data?
A model that predicts 'not fraud' for every transaction achieves 99.9% accuracy on a dataset with 0.1% fraud rate — yet it's useless. Accuracy hides what matters: the model's ability to detect the rare class. Use precision, recall, F1, AUC-ROC, or average precision instead. Always check performance per class.
Should I oversample, undersample, or use class weights?
Try class weights first — it's free, doesn't change the dataset, and most sklearn models support it via class_weight='balanced'. If that's insufficient, try SMOTE (synthetic oversampling). Undersampling discards data and only works when you have very large datasets. In practice, class weights + threshold tuning solves most imbalance problems.