Handling Imbalanced Classes: SMOTE, Class Weights and Threshold Tuning

Handling imbalanced data is one of those problems that looks solved on paper and then breaks the moment you deploy. In my experience, a model reporting 98% accuracy on a fraud or disease detection dataset is almost always a model that learned to predict the majority class and call it a day. Early in my career I shipped a churn model with that exact accuracy, only to find it flagged almost none of the actual churners. The minority class is usually where all the business value lives, and standard training pipelines are explicitly designed to ignore it. Getting this right requires more than a single trick — it demands a deliberate choice between rebalancing your data, reweighting your loss, or moving your decision threshold, and understanding how each interacts with your validation strategy and production constraints.

Why Accuracy Lies: The Real Cost of Skewed Distributions

When your class ratio is 99:1, a naive classifier that always predicts "negative" achieves 99% accuracy while being completely useless. The problem is that empirical risk minimization optimizes for overall error, so the loss contribution of the minority class is drowned out. I've found that the first step with any stakeholder is to reframe the problem in terms of costs: what does a false negative cost relative to a false positive? For a credit card fraud model, a false negative might cost $800 on average, while a false positive costs $5 in manual review. That 160:1 cost ratio should drive your entire modeling strategy, not a generic metric.

For imbalanced classification, you need metrics that are sensitive to the minority class. Precision-recall curves are far more informative than ROC curves when positives are rare, because ROC's false positive rate is diluted by the huge number of true negatives. I typically track PR-AUC, F1 or F-beta with a beta tuned to the business, and Matthews Correlation Coefficient (MCC) for a single balanced summary. You should also always inspect the confusion matrix at multiple thresholds rather than trusting a single aggregated score. One pattern I've seen repeatedly is models with strong ROC-AUC (0.88+) but PR-AUC below 0.35 — a clear sign the model ranks well overall but struggles to identify positives with confidence.

Establishing a Cost-Sensitive Baseline Before Any Fix

Before applying SMOTE or class weights, train a well-tuned baseline with stratified cross-validation and no rebalancing. Use this to understand if the problem is learnable at all. In many tabular problems, a vanilla XGBoost or LightGBM with proper Feature Engineering for Tabular Data: Techniques That Actually Work already separates classes reasonably well, and the remaining issue is decision calibration, not representation. Evaluate that baseline using stratified folds that preserve the original imbalance in the validation set. If you rebalance before splitting, you leak synthetic information and your validation scores will be wildly optimistic. This baseline also gives you a reference point to measure whether a complex rebalancing technique actually adds value or just adds variance.

Choosing Metrics That Align With Business Impact

For a medical screening model where missing a positive case is critical, you will optimize for recall at a fixed precision floor, say 90% recall at >=30% precision. For fraud, you might optimize for precision at a given recall because investigators have limited bandwidth. I recommend defining an explicit utility function: Utility = (TP * benefit_TP) - (FP * cost_FP) - (FN * cost_FN). You can then directly search for thresholds and hyperparameters that maximize utility on validation data. This is more defensible than reporting F1 alone because it ties modeling choices to dollars or operational capacity, which is what finally convinces product teams. For a deeper discussion of how this choice interacts with model complexity, see Bias-Variance Tradeoff: A Practical Guide to Model Selection.

SMOTE Under the Hood: When Synthetic Oversampling Helps and Hurts

Synthetic Minority Over-sampling Technique (SMOTE), introduced by Chawla et al. and widely referenced in recent work on arXiv Machine Learning, generates new minority examples by interpolating between existing ones. For a given minority sample x_i, it selects one of its k-nearest minority neighbors x_j and creates a new point x_new = x_i + lambda * (x_j - x_i) where lambda is sampled uniformly in [0,1]. It does not duplicate data; it creates points along line segments in feature space. The intuition is to densify the minority manifold without simply repeating noise.

In my experience, SMOTE works best when three conditions hold: features are continuous and meaningful under linear interpolation, the minority class forms relatively compact clusters, and you have enough minority samples to define a local neighborhood (typically at least 20-50). On a manufacturing defect dataset with 30 dense sensor features and about 400 defects out of 50k units, SMOTE lifted PR-AUC from 0.41 to 0.58 for a random forest. On another project with 200 sparse, high-cardinality categorical features one-hot encoded to 5k dimensions, SMOTE was disastrous — synthetic points landed in nonsensical regions of space and hurt generalization.

The Interpolation Mechanism and Its Variants

Vanilla SMOTE interpolates blindly, even across class boundaries. Borderline-SMOTE and ADASYN try to be smarter by focusing synthesis near decision boundaries where the model is most confused. SMOTE-ENN and SMOTE-Tomek add cleaning steps that remove synthetic points whose nearest neighbors belong to the wrong class. I have gotten the most consistent gains from Borderline-SMOTE1 on tabular data with moderate overlap, but I treat the sampler as a hyperparameter to tune alongside k_neighbors. Never use k=5 as gospel; with only 20 minority samples, k=5 means you are connecting globally, not locally. Search k in [3, 7] and the sampling_strategy (desired ratio after resampling) rather than always resampling to 1:1. Often a 1:2 or 1:3 minority-to-majority ratio provides the same benefit with less overfitting.

Where SMOTE Breaks Down in Production

The biggest failure mode I see is leakage. If you apply SMOTE before your train/validation split, synthetic points generated from validation neighbors leak into training, and your cross-validation will report near-perfect recall that collapses in production. SMOTE must live inside a pipeline that is fit only on each training fold. It also interacts poorly with high-dimensional data, distance metrics that don't reflect semantics, and any data where interpolation is invalid — like time series, text embeddings where linear paths go off-manifold, or datasets with many categorical variables. For categorical features, use SMOTENC which handles mixed types, or consider embedding categoricals first and applying SMOTE in that continuous space. And crucially, SMOTE does not help if your features do not contain the signal to separate classes; synthesizing more of an inseparable distribution just synthesizes noise.

# Correct pipeline: SMOTE inside cross-validation, never before the split
from imblearn.pipeline import Pipeline
from imblearn.over_sampling import BorderlineSMOTE
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold, cross_validate

# Never apply SMOTE to the full X, y before splitting
pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('smote', BorderlineSMOTE(
        k_neighbors=5, 
        sampling_strategy=0.5,  # resample to 1:2 ratio, not 1:1
        kind='borderline-1',
        random_state=42
    )),
    ('clf', RandomForestClassifier(
        n_estimators=300, 
        max_depth=None,
        min_samples_leaf=5,
        n_jobs=-1,
        random_state=42
    ))
])

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# Use PR-AUC (average_precision) as the primary metric for rare positives
results = cross_validate(
    pipeline, X, y,
    cv=cv,
    scoring=['average_precision', 'f1', 'roc_auc'],
    n_jobs=-1
)
print(f"PR-AUC: {results['test_average_precision'].mean():.3f} +/- {results['test_average_precision'].std():.3f}")

Reweighting the Loss: Implementing Class Weights Without Destabilizing Training

Class weighting leaves your data untouched and instead tells the optimizer that mistakes on the minority class are more expensive. In the loss function, each sample's contribution is multiplied by a weight w_y where w_1 >> w_0. For logistic regression or neural networks, this is equivalent to balancing the gradient signal so the minority class can influence the weights despite its low frequency. I prefer this approach in most production systems because it adds zero preprocessing complexity, works with any data type, and does not create synthetic artifacts.

The standard "balanced" heuristic is w_j = n_samples / (n_classes * n_samples_j). For a dataset with 98,000 negatives and 2,000 positives, that yields w_0 ~ 0.51 and w_1 ~ 25. This equalizes total weight per class. In practice, that full 25x multiplier is often too aggressive and pushes the model to over-predict positives, collapsing precision. I've found that using the square root of the balanced weight or tuning a multiplier in [0.5x, 0.8x] of the balanced value gives a better precision-recall tradeoff. Treat the weight as a hyperparameter, not a fixed formula.

Deriving Stable Weight Values Across Frameworks

Different libraries expose weighting differently. In scikit-learn, most classifiers accept class_weight='balanced' which applies the heuristic internally. In gradient boosting frameworks like XGBoost and LightGBM, you set scale_pos_weight = n_negative / n_positive, which is the same idea but expressed as a ratio. In neural networks, you pass a per-class weight tensor to your loss. The PyTorch documentation at PyTorch Documentation shows that nn.CrossEntropyLoss and nn.BCEWithLogitsLoss both accept a weight argument that directly scales the loss per class. A critical detail: when you use class weights with stochastic gradient descent, the effective learning rate for minority samples increases. I often lower the global learning rate by 10-20% when introducing strong weighting to prevent oscillation, especially with Adam.

For extremely skewed problems (1:500 or worse), consider focal loss, which down-weights easy negatives dynamically rather than applying a static multiplier. Focal loss with gamma=2 reduces the loss contribution of well-classified negatives, letting the model focus on hard positives without the instability of a 500x class weight. In my work on document classification with 1 positive per 1000, focal loss outperformed static weighting by 4 points of PR-AUC while keeping training stable.

# Class weights in scikit-learn, XGBoost, and PyTorch

# 1) scikit-learn: balanced weights with tuned strength
from sklearn.utils.class_weight import compute_class_weight
import numpy as np

classes = np.unique(y_train)
weights = compute_class_weight('balanced', classes=classes, y=y_train)
# Down-tune slightly: 0.7 * balanced is often better than full balanced
tuned_weights = {c: w * 0.7 for c, w in zip(classes, weights)}

from sklearn.linear_model import LogisticRegression
clf = LogisticRegression(class_weight=tuned_weights, max_iter=1000)
clf.fit(X_train, y_train)

# 2) XGBoost / LightGBM: scale_pos_weight
import xgboost as xgb
neg, pos = (y_train == 0).sum(), (y_train == 1).sum()
scale = (neg / pos) * 0.6  # 60% of full correction as starting point

model = xgb.XGBClassifier(
    scale_pos_weight=scale,
    max_depth=6,
    learning_rate=0.05,
    subsample=0.8,
    eval_metric='aucpr'  # optimize PR-AUC directly
)
# See Gradient Boosting Showdown for framework-specific tuning:
# /gradient-boosting-xgboost-lightgbm-catboost
model.fit(X_train, y_train)

# 3) PyTorch: weighted BCEWithLogitsLoss
import torch
import torch.nn as nn

# pos_weight = n_negative / n_positive for BCE
pos_weight = torch.tensor([neg / pos * 0.6])
criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)

logits = model(X_batch)  # raw logits, no sigmoid
loss = criterion(logits, y_batch.float())

Beyond 0.5: Systematic Threshold Tuning for Business-Aligned Decisions

Most models output a probability or score, and we threshold at 0.5 by default only because the library had to pick something. That threshold implicitly assumes equal costs for false positives and false negatives and a calibrated 0.5 decision boundary, neither of which holds for imbalanced problems. I have recovered more business value from careful threshold tuning than from any resampling method. A well-calibrated model with a properly chosen threshold often beats an over-sampled model stuck at 0.5.

The workflow I use is: first calibrate probabilities, then sweep thresholds on a held-out validation set using the business metric, not accuracy. Platt scaling (sigmoid) or isotonic regression can correct for the distortion introduced by class weighting or for models like boosted trees that output poorly calibrated scores. After calibration, I run a precision-recall curve sweep and compute the utility or F-beta at each threshold. For an alerting system that can handle 50 investigations per day, I constrain the search to thresholds that yield <=50 predicted positives on validation and pick the one with highest recall within that budget.

Mapping Business Costs to Thresholds

If you can quantify costs, you can derive a threshold analytically. For expected cost minimization, the optimal threshold p* = cost_FP / (cost_FP + cost_FN). If a false negative costs $1000 and a false positive costs $50, p* = 50 / 1050 ≈ 0.048. You would flag anything with predicted probability above 5%. In practice, I use this as an anchor and then empirically fine-tune around it because model calibration is imperfect. You should also avoid tuning the threshold on the same data you used for early stopping or hyperparameter selection; keep a separate threshold-tuning split or use nested cross-validation. As discussed in Cross-Validation Strategies: Beyond K-Fold for Real-World Data, leakage in threshold selection is subtle but it will inflate your reported precision.

Calibration Before Tuning

Tuning a threshold on uncalibrated scores is fragile. I've seen models where the optimal threshold was 0.12 on validation and 0.31 on the next month's data simply because the score distribution drifted. Calibrating with isotonic regression on a holdout fold makes the threshold more stable and interpretable. Use sklearn's CalibratedClassifierCV with cv='prefit' after you have trained your weighted model. Then when you report "we flag at p > 0.2", that statement actually means 20% probability, which aligns with stakeholder intuition and allows consistent cost-based adjustment over time.

import numpy as np
from sklearn.metrics import precision_recall_curve, fbeta_score
from sklearn.calibration import CalibratedClassifierCV

# Assume model is already trained with class weights on train split
# Calibrate on a separate calibration split (not test)
calibrator = CalibratedClassifierCV(model, method='isotonic', cv='prefit')
calibrator.fit(X_calib, y_calib)

probs = calibrator.predict_proba(X_valid)[:, 1]

# Sweep thresholds using precision-recall curve
precisions, recalls, thresholds = precision_recall_curve(y_valid, probs)

# Option A: maximize F-beta (beta=2 favors recall)
f2_scores = []
for thresh in thresholds:
    preds = (probs >= thresh).astype(int)
    f2_scores.append(fbeta_score(y_valid, preds, beta=2, zero_division=0))

best_thresh_f2 = thresholds[np.argmax(f2_scores)]
print(f"Best threshold by F2: {best_thresh_f2:.3f}")

# Option B: maximize business utility
cost_fp, cost_fn = 50, 1000
utilities = []
for thresh in thresholds:
    preds = (probs >= thresh).astype(int)
    tp = ((preds == 1) & (y_valid == 1)).sum()
    fp = ((preds == 1) & (y_valid == 0)).sum()
    fn = ((preds == 0) & (y_valid == 1)).sum()
    utility = tp * cost_fn - fp * cost_fp - fn * cost_fn
    utilities.append(utility)

best_thresh_utility = thresholds[np.argmax(utilities)]
print(f"Best threshold by utility: {best_thresh_utility:.3f} (vs theoretical {cost_fp/(cost_fp+cost_fn):.3f})")

Assembling the Stack: A Comparative Framework for Choosing Your Strategy

No single technique dominates. I have seen teams stack SMOTE + class weights + aggressive threshold tuning and end up with a model that is precise on the resampled distribution but completely misaligned with production priors. My rule of thumb is to try the least invasive method first and only add complexity if validation shows a clear lift that justifies the operational cost. The order I recommend is: baseline → class weights → threshold tuning + calibration → SMOTE or other sampling only if the first three are insufficient and your data supports it.

Use Gradient Boosting Showdown: XGBoost, LightGBM and CatBoost Compared as a guide when choosing your estimator, because the interaction matters. Linear models and neural nets benefit most from class weights. Tree ensembles can handle imbalance via scale_pos_weight but sometimes still benefit from SMOTE when the minority class is very small and the trees are depth-limited. k-NN and SVM based models are more sensitive to local density and therefore respond better to SMOTE than weighting alone.

The table below summarizes the tradeoffs I use when deciding with teams. Cost here refers not just to training time, but to pipeline complexity, risk of leakage, and debuggability when the model drifts.

Strategy When It Shines Risk / Limitation Complexity & Inference Cost
Class Weights / scale_pos_weight Tabular data, any dimensionality, text, high-cardinality categoricals; first-line fix for most problems Too high weights cause precision collapse; can destabilize training if not tuned Low — single hyperparameter, no pipeline change, identical inference
SMOTE / Borderline-SMOTE Low-dimensional continuous features with clustered minority class and >50 minority samples Creates unrealistic samples in sparse/high-dim space; severe leakage risk if misapplied; hurts calibration Medium-High — adds resampling step, k tuning, must be nested in CV; no inference cost but training slower
Threshold Tuning + Calibration When probabilities are available and business costs are asymmetric or constrained; always complementary Requires well-calibrated probabilities and a separate holdout; threshold may drift with distribution shift Low — post-training step, simple to update without retraining; inference unchanged

In practice, I almost always combine class weights with threshold tuning — they address different parts of the problem. Weights shape the learned boundary during training, while the threshold maps that boundary to a business decision. SMOTE I treat as conditional: run an ablation where you compare weighted-only vs. weighted + SMOTE vs. SMOTE-only using PR-AUC and calibration error on untouched validation folds. If SMOTE does not deliver at least a 2-3 point PR-AUC gain that persists across folds, drop it. Simplicity pays dividends when you need to explain the model to risk or compliance teams, or when you retrain monthly.

Frequently Asked Questions

Should I apply SMOTE before or after train/test split?

Always after. SMOTE must be applied only to the training data inside each cross-validation fold, typically via an imblearn Pipeline. If you oversample before splitting, synthetic points derived from validation samples leak into training, inflating recall and PR-AUC. Your validation set should always reflect the real, imbalanced distribution you will see in production.

Do class weights and SMOTE work well together, or should I pick one?

They can work together but often overcorrect. Both amplify the minority signal, so combining a 1:1 SMOTE ratio with full balanced weights will push the model to over-predict positives. In my experience, start with class weights and a tuned threshold. Only add SMOTE if you still lack recall, and if you do, reduce the sampling_strategy to 0.3-0.5 and lower the class weight multiplier proportionally. Validate the combination against weights alone.

What metric should I optimize when positives are rare — ROC-AUC or PR-AUC?

Optimize PR-AUC (average precision) when positives are rare. ROC-AUC is insensitive to imbalance because its false positive rate uses the huge negative pool as denominator, so a model can have high ROC-AUC while flagging too many false positives. PR-AUC and F-beta directly measure performance on the minority class. I track ROC-AUC for completeness but make model selection decisions based on PR-AUC and business utility.

How do I handle imbalanced classes in deep learning without oversampling?

Use a weighted loss function or focal loss. In PyTorch, pass pos_weight to BCEWithLogitsLoss or weight to CrossEntropyLoss as shown in the PyTorch Documentation, or implement focal loss to dynamically down-weight easy negatives. Pair this with threshold tuning on a calibrated validation set. This keeps the data pipeline simple, avoids synthetic artifacts in embedding space, and trains efficiently with standard minibatch sampling. Reserve oversampling for cases where a class has so few examples that batches rarely contain a positive.

Related Articles

References & Standards: arXiv Machine Learning · PyTorch Documentation · Hugging Face Documentation