Machine Learning course menu
Handling Imbalanced Data in Machine Learning (Python)
Train and evaluate classifiers when one class is rare: why accuracy fails, stratified splits, class_weight='balanced', threshold tuning with precision-recall curves, average precision, and resampling with under- and over-sampling.
On this page
In many important problems the interesting class is rare: fraud is a small fraction of transactions, defects a small fraction of products, churners a minority of customers. A model trained naively on such data learns that always predicting the common class is "accurate", and never finds the rare cases you care about.
The problem#
Simulated transactions where 3% are fraud:
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=6000, n_features=10, n_informative=5, weights=[0.97, 0.03],
class_sep=0.8, random_state=0)
print("fraud share:", round(y.mean(), 3))
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
print(classification_report(y_test, model.predict(X_test), digits=3))fraud share: 0.034
precision recall f1-score support
0 0.968 0.997 0.982 1738
1 0.400 0.065 0.111 62
accuracy 0.964 1800
macro avg 0.684 0.531 0.546 1800
weighted avg 0.948 0.964 0.952 1800Overall accuracy looks excellent, but the fraud row (class 1) tells the real story: the model catches only a small share of fraud cases (its recall). Always use stratified splits (as here) so the rare class appears in every split, and judge the model on the rare class's precision and recall.
Fix 1: class weights#
class_weight="balanced" makes mistakes on the rare class cost more during training, in proportion to how rare it is. Most scikit-learn classifiers support it:
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
X, y = make_classification(n_samples=6000, n_features=10, n_informative=5, weights=[0.97, 0.03],
class_sep=0.8, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
for weight in [None, "balanced"]:
model = LogisticRegression(max_iter=1000, class_weight=weight).fit(X_train, y_train)
pred = model.predict(X_test)
print(f"class_weight={str(weight):>8}: precision {precision_score(y_test, pred):.3f}, recall {recall_score(y_test, pred):.3f}")class_weight= None: precision 0.400, recall 0.065
class_weight=balanced: precision 0.112, recall 0.758Balanced weights catch far more fraud (higher recall) at the cost of more false alarms (lower precision). Whether that trade is worth it depends on what a missed fraud costs compared with a false alarm.
Fix 2: choose the threshold#
The model's probabilities already rank cases; the default 0.5 cut-off is just one choice. The precision-recall curve shows every option, and average precision (the area under it) summarises ranking quality on imbalanced data better than ROC AUC:
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, precision_recall_curve, roc_auc_score
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=6000, n_features=10, n_informative=5, weights=[0.97, 0.03],
class_sep=0.8, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
proba = LogisticRegression(max_iter=1000).fit(X_train, y_train).predict_proba(X_test)[:, 1]
print("ROC AUC:", round(roc_auc_score(y_test, proba), 3), " average precision:", round(average_precision_score(y_test, proba), 3))
precision, recall, thresholds = precision_recall_curve(y_test, proba)
target = np.argmax(recall[:-1] <= 0.6) # first threshold where recall drops to 60%
print(f"threshold {thresholds[target]:.3f}: precision {precision[target]:.3f}, recall {recall[target]:.3f}")ROC AUC: 0.816 average precision: 0.271
threshold 0.064: precision 0.157, recall 0.597A business can pick the threshold that meets a requirement such as "catch at least 60% of fraud" and see what precision that implies. ROC AUC can look reassuringly high on imbalanced data even when precision is poor; average precision exposes that.
Fix 3: resampling#
Resampling changes the training data so the classes are more balanced:
- undersampling removes some majority-class rows;
- oversampling repeats (or synthesises) minority-class rows. SMOTE, from the
imbalanced-learnlibrary, creates synthetic examples between existing minority points.
Simple random oversampling with plain NumPy:
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
X, y = make_classification(n_samples=6000, n_features=10, n_informative=5, weights=[0.97, 0.03],
class_sep=0.8, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
rng = np.random.default_rng(0)
minority = np.flatnonzero(y_train == 1)
extra = rng.choice(minority, size=(y_train == 0).sum() - minority.size, replace=True)
X_bal = np.vstack([X_train, X_train[extra]])
y_bal = np.concatenate([y_train, y_train[extra]])
print("training class counts after oversampling:", np.bincount(y_bal))
pred = LogisticRegression(max_iter=1000).fit(X_bal, y_bal).predict(X_test)
print(f"precision {precision_score(y_test, pred):.3f}, recall {recall_score(y_test, pred):.3f}")training class counts after oversampling: [4056 4056]
precision 0.110, recall 0.758Resample only the training data, after splitting (and inside each cross-validation fold). Resampling before the split puts copies of the same row in both training and test sets, which inflates the scores.
Which approach?#
- Start with stratified splits and the right metrics (precision, recall, average precision).
- Try
class_weight="balanced": no data changes, one line of code. - Tune the threshold on validation data to match the real costs.
- Consider resampling if the above is not enough.
Common mistakes#
Practice#
Practice exercise
Using the same simulated data and split as above, train RandomForestClassifier(n_estimators=200, random_state=0) with and without class_weight="balanced", and print precision, recall and average precision (from predict_proba) for each (3 decimals).
Show hint
Loop over the two class_weight values; average_precision_score(y_test, model.predict_proba(X_test)[:, 1]).
Show solution
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import average_precision_score, precision_score, recall_score
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=6000, n_features=10, n_informative=5, weights=[0.97, 0.03],
class_sep=0.8, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
for weight in [None, "balanced"]:
model = RandomForestClassifier(n_estimators=200, random_state=0, class_weight=weight, n_jobs=-1).fit(X_train, y_train)
pred = model.predict(X_test)
ap = average_precision_score(y_test, model.predict_proba(X_test)[:, 1])
print(weight, round(precision_score(y_test, pred), 3), round(recall_score(y_test, pred), 3), round(ap, 3))None 0.923 0.194 0.467
balanced 1.0 0.016 0.33Surprisingly, balanced weights made the forest catch fewer fraud cases here. A forest's fully grown trees end in pure leaves, so reweighting barely changes which leaf a case lands in but does change the votes in ways that can backfire. class_weight is not a reliable fix for tree ensembles; tuning the threshold on predict_proba (whose average precision shows the ranking is decent) is usually the better lever. Always measure rather than assume.
Key takeaway#
Next, learning without labels: k-Means Clustering.
Finished this lesson?