A controlled experiment: does SMOTE (and its cousins) actually help imbalanced fraud classification, or does a tuned class-weighted baseline match it? Two datasets, six resampling methods, three classifiers — 36 cells, 5-fold stratified CV each.
No — and it actively hurts with strong learners. baseline_weighted ties or beats every SMOTE variant on PR-AUC in 5 of 6 cells; on IEEE-CIS the penalty is statistically reliable (Cohen's d ≈ −2.5 for LightGBM).
The reason: a model that supports per-sample loss weighting already does what SMOTE attempts. SMOTE additionally distorts the input distribution with synthetic linear interpolations, producing a manifold the model has never seen at deployment — which causes over-confident predictions and worse calibration.
The one exception is SMOTEENN on extreme-imbalance + clean features (creditcard_ulb × lgbm, 0.811 vs 0.767). Its cleanup step partially undoes the distortion. This advantage vanishes on messy real features (IEEE-CIS).
| Priority | Method | When |
|---|---|---|
| 1 | class_weight='balanced' / scale_pos_weight |
Always try first — one parameter, no distribution distortion |
| 2 | Post-hoc calibration (isotonic / Platt) | When the weighted baseline over-corrects the prior (moderate imbalance, ~3–5% positive rate) |
| 3 | SMOTEENN | Only if the learner cannot be weighted and features are clean |
| ✗ | Random undersampling | Never — worst PR-AUC and calibration in every cell |
With a strong learner (LightGBM) and a tuned decision threshold, SMOTE will not produce a meaningful PR-AUC improvement over a weighted baseline, and will degrade calibration. With a weak learner (logistic regression), SMOTE may help. Cleanup hybrids (SMOTEENN, SMOTETomek) will outperform vanilla SMOTE.
| Dataset | Source | n | Features | Positive rate |
|---|---|---|---|---|
creditcard_ulb |
Kaggle mlg-ulb/creditcardfraud (ULB 2013, full) |
284,807 | 30 | 0.173% |
ieee_cis |
Kaggle ieee-fraud-detection (stratified subsample) |
80,000 | 218 | 3.499% |
The two datasets bracket two regimes:
- Extreme imbalance, PCA-clean features (creditcard): the textbook case where SMOTE is often recommended.
- Moderate imbalance, messy real features (IEEE-CIS): where calibration matters because the prior matters.
Note on Dataset A: the brief specified "Kaggle Credit Card Fraud 2023." That redistribution (nelgiriyewithana/...) is artificially class-balanced 50/50, which defeats the experiment, so we default to the original ULB version (DATASET_A_SLUG is configurable). Dataset B is stratified-subsampled to 80k rows to keep runtime tractable; full data via FRAUD_IEEE_FULL=1.
methods: baseline_weighted (class_weight='balanced', no resampler)
random_undersample
smote (k_neighbors=5)
borderline_smote
smoteenn (SMOTE + Edited Nearest Neighbours cleanup)
smotetomek (SMOTE + Tomek-link cleanup)
classifiers: lgbm (LGBMClassifier, n_estimators=300, lr=0.05)
logreg (LogisticRegression, max_iter=1000)
rf (RandomForestClassifier, n_estimators=200)
Pipeline structure for every cell: StandardScaler → (resampler) → classifier, built with imblearn.pipeline.Pipeline so resamplers are correctly skipped at predict time.
- 5-fold stratified CV directly on the full dataset.
- Out-of-fold (OOF) probabilities via
cross_val_predict. Per fold, the OTHER four folds' OOF probabilities are used as the threshold-tuning validation slice — disjoint from the test slice for that fold, so threshold-dependent metrics (F1@opt, recall@p≥0.80) are not chosen and reported on the same observations. - Leakage guards live inside the pipeline:
SimpleImputerandStandardScalerare fit per-fold, and resamplers (SMOTE et al.) are skipped at predict time by theimblearn.pipeline.Pipelinecontract — so no statistic from the test fold ever reaches model fitting.
- PR-AUC (
average_precision_score) — headline. - Brier score — calibration squared error.
- ECE (expected calibration error, 10 equal-width bins).
- Recall at precision ≥ 0.80, threshold picked on the validation slice.
- F1 at the threshold that maximises F1 on the validation slice.
Default-threshold (0.5) classification metrics are deliberately omitted — they are essentially meaningless on imbalanced data.
| Dataset | Classifier | baseline_weighted | random_undersample | smote | borderline_smote | smoteenn | smotetomek |
|---|---|---|---|---|---|---|---|
| creditcard_ulb | lgbm | 0.854 ± 0.029 | 0.724 ± 0.033 | 0.767 ± 0.171 | 0.848 ± 0.048 | 0.811 ± 0.055 | 0.767 ± 0.171 |
| creditcard_ulb | logreg | 0.730 ± 0.035 | 0.701 ± 0.020 | 0.729 ± 0.041 | 0.718 ± 0.025 | 0.739 ± 0.036 | 0.729 ± 0.041 |
| creditcard_ulb | rf | 0.852 ± 0.036 | 0.737 ± 0.024 | 0.854 ± 0.036 | 0.850 ± 0.042 | 0.843 ± 0.042 | 0.854 ± 0.036 |
| ieee_cis | lgbm | 0.582 ± 0.013 | 0.516 ± 0.019 | 0.560 ± 0.006 | 0.566 ± 0.008 | 0.551 ± 0.010 | 0.562 ± 0.007 |
| ieee_cis | logreg | 0.302 ± 0.029 | 0.244 ± 0.022 | 0.300 ± 0.029 | 0.293 ± 0.034 | 0.283 ± 0.033 | 0.300 ± 0.028 |
| ieee_cis | rf | 0.574 ± 0.008 | 0.488 ± 0.029 | 0.560 ± 0.015 | 0.554 ± 0.019 | 0.536 ± 0.017 | 0.560 ± 0.012 |
baseline_weighted is the top cell (or within CI of the top) in 5 / 6 dataset × classifier combinations. The one exception is creditcard_ulb × rf, where vanilla SMOTE and SMOTETomek edge it out by 0.002 PR-AUC — well inside noise.
See
results/figures/pr_auc.pngfor the bar chart with CIs.
| Dataset | Classifier | Δ mean | Cohen's d | 95% CI |
|---|---|---|---|---|
| creditcard_ulb | lgbm | −0.087 | −0.62 | [−0.260, +0.086] |
| creditcard_ulb | logreg | −0.001 | −0.13 | [−0.008, +0.007] |
| creditcard_ulb | rf | +0.002 | +0.35 | [−0.006, +0.011] |
| ieee_cis | lgbm | −0.022 | −2.45 | [−0.033, −0.011] |
| ieee_cis | logreg | −0.002 | −1.69 | [−0.004, −0.001] |
| ieee_cis | rf | −0.014 | −2.04 | [−0.022, −0.005] |
On IEEE-CIS the difference is small in magnitude but the within-fold variance is even smaller — Cohen's d in the −2 range, with paired 95% CIs that exclude zero. SMOTE doesn't just fail to help; it reliably hurts.
| Dataset | Classifier | baseline | random_under | smote | borderline | smoteenn | smotetomek |
|---|---|---|---|---|---|---|---|
| creditcard_ulb | lgbm | 0.00040 | 0.02397 | 0.00097 | 0.00043 | 0.00097 | 0.00097 |
| creditcard_ulb | logreg | 0.02277 | 0.02927 | 0.02390 | 0.00847 | 0.02428 | 0.02390 |
| creditcard_ulb | rf | 0.00042 | 0.03213 | 0.00053 | 0.00042 | 0.00063 | 0.00053 |
| ieee_cis | lgbm | 0.06073 | 0.11539 | 0.02218 | 0.02180 | 0.02494 | 0.02208 |
| ieee_cis | logreg | 0.15438 | 0.16467 | 0.15600 | 0.13907 | 0.18063 | 0.15610 |
| ieee_cis | rf | 0.02140 | 0.12739 | 0.02346 | 0.02277 | 0.02751 | 0.02347 |
The calibration story has two faces, and they tell different mechanistic tales:
-
creditcard_ulb(0.17%): baseline_weighted is the most-calibrated model. SMOTE roughly doubles the Brier of LightGBM here, and the reliability plot (results/figures/calibration_creditcard_ulb_lgbm.png) shows SMOTE-trained LightGBM is wildly over-confident at the high-probability end — it predicts ~0.9 for cases where the empirical positive rate is ~0.15. -
ieee_cis(3.5%): baseline_weighted is the least-calibrated model.class_weight='balanced'on a 3.5%-positive dataset upweights the rare class by a factor of ~14, which shifts the predicted score distribution sharply right. The model now over-predicts positives, ECE jumps to 0.14, Brier to 0.06. Here SMOTE actually beats the weighted baseline on calibration (Brier 0.022 vs 0.061) even though it loses on PR-AUC. Ranking and calibration come apart.
random_undersample is the consistent loser: lowest PR-AUC in every cell except creditcard_ulb × logreg (tied with smote variants), and Brier scores 30–80× worse than baseline_weighted on LightGBM/RF cells with extreme imbalance — predictably, because throwing away 99.8% of the negatives leaves the model thinking positives are common, and probabilities are wildly inflated.
In every cell, vanilla SMOTE and SMOTETomek give identical PR-AUC, Brier, ECE, and recall numbers — within the printed precision. Tomek-link removal found essentially nothing to remove on these feature spaces. The "cleanup" was a no-op.
| Prediction | Verdict |
|---|---|
| SMOTE will not improve PR-AUC over a weighted baseline with LightGBM. | Confirmed. SMOTE loses on both datasets; difference is large and reliable on ieee_cis × lgbm (d=−2.45). |
| SMOTE will degrade calibration with LightGBM. | Confirmed for creditcard_ulb. Contradicted for ieee_cis, but only because class_weight='balanced' over-corrects the prior on a 3.5%-rate dataset. The contradiction is a critique of the baseline, not a vindication of SMOTE. |
| SMOTE may help logistic regression. | Did not materialise. Logreg with class_weight='balanced' already exploits the imbalance information SMOTE provides. Paired diff is essentially zero. |
| Cleanup hybrids will outperform vanilla SMOTE. | Mixed. SMOTEENN beats vanilla SMOTE on creditcard_ulb × lgbm (0.811 vs 0.767) and creditcard_ulb × logreg. It loses on the other four cells. SMOTETomek is identical to SMOTE. So: "hybrids dominate" is too strong — they help when the feature space contains genuine label noise to clean. |
A weighted-loss strong learner (gradient-boosted trees, in particular) is already doing the only thing SMOTE accomplishes for it — telling the model to care more about minority examples. The cost function difference between "duplicate the minority point 100× synthetically" and "multiply its loss contribution by 100" is small in expectation; what differs is the variance and the calibration. SMOTE introduces synthetic samples that are linear interpolations in feature space, which is plausible for PCA-derived features (creditcard) but compresses the input distribution onto a lower-dimensional manifold than the real data lives on. The classifier learns confident decisions on a non-representative input distribution → over-confident at deployment.
Class weighting leaves the input distribution alone and changes only the gradient. For models that natively support it (LightGBM, sklearn logreg, RF), it is strictly less invasive.
Looking at the cells where SMOTE did outperform: creditcard_ulb × logreg × smoteenn (+0.009), and matched on creditcard_ulb × rf. The pattern is not "weak learner = SMOTE helps." It's "noise-cleaning cousins of SMOTE help under extreme imbalance with PCA-clean features." A small effect, never larger than the spread between classifier choices.
- Single train/test protocol per cell. 5-fold CV variance estimates are honest but small; a fully nested CV for threshold tuning would be more rigorous (and ~5× the compute).
- Raw features only. No feature engineering on either dataset. IEEE-CIS in particular has known wins from grouping transaction sequences by card / device; we held that constant to keep the SMOTE-vs-not axis isolated.
- Two datasets. Generalising "SMOTE doesn't help" beyond similar tabular fraud-style data needs more.
- IEEE-CIS is subsampled (80k of 590k rows). Full-data results may differ at the margins, especially for the SMOTE variants whose synthetic samples scale with the minority count.
- No ensemble-of-resamplers methods. BalancedBaggingClassifier, BalancedRandomForest, EasyEnsemble are out of scope. Conservatively, those should be compared too before declaring "weight-only baselines win."
- One hyperparameter point per classifier. A tuned LightGBM (
scale_pos_weightswept, leaf size, etc.) might widen or shrink the gap. We held this fixed because the comparison axis is the resampling step.
uv sync
uv run main.py # checks/downloads data, trains 36 cells, writes results + figuresmain.py is the single end-to-end entrypoint. It detects existing data files and skips the Kaggle downloads if they're already present. Individual stages are also exposed as standalone commands for partial runs:
uv run fraud-download # just (re)download data
uv run fraud-detection # just train (assumes data is present)
uv run fraud-analyze # just regenerate summary CSVs and figuresKaggle authentication: drop a kaggle.json token at ~/.kaggle/kaggle.json AND copy the raw key string into ~/.kaggle/access_token (the new KGAT_*-style tokens require both for competition endpoints). Accept the IEEE-CIS competition rules at https://www.kaggle.com/c/ieee-fraud-detection/rules once.
main.py # single entrypoint: ensure data -> train -> analyze
src/fraud_detection/
data.py # download + load + stratified subsample
methods.py # 6 resampler factories
pipeline.py # 3 classifier factories + imblearn pipeline builder
evaluate.py # PR-AUC, Brier, ECE, recall@p80, F1@opt
run.py # 36-cell orchestrator (fraud-detection)
analyze.py # summary CSVs + figures (fraud-analyze)
results/ # results.csv, calibration.csv, summary*, figures/
data/ # gitignored