Data Preparation / Data Preprocessing 📂 Data Collection · 12 of 13 268 min read

Handling Imbalanced Data in Machine Learning

A story-driven, comprehensive guide to detecting and treating class imbalance — covering oversampling (SMOTE), undersampling, class weights, threshold tuning, and evaluation metrics that actually work on imbalanced datasets — with live diagrams, before/after comparisons, and complete reusable sklearn pipeline code.

🌐
SectionSection第 01 节

The Class Imbalance Problem ⚖️Class Imbalance की समस्या ⚖️类别不平衡问题 ⚖️

Many of the most useful prediction tasks are about rare events: fraud, disease, machine failure, network attacks. When one class is rare, a model can score very well on the usual metrics while doing nothing useful, because simply saying "no" is almost always right. Handling imbalance starts with measuring the right thing.

सबसे काम की कई prediction समस्याएँ दुर्लभ घटनाओं के बारे में हैं: fraud, बीमारी, मशीन ख़राबी, network हमले। जब एक class दुर्लभ हो, तो model आम metrics पर बहुत अच्छा score कर सकता है बिना कुछ काम का किए, क्योंकि बस "नहीं" कहना लगभग हमेशा सही होता है। Imbalance संभालना सही चीज़ मापने से शुरू होता है।

许多最有价值的预测任务都与罕见事件有关:欺诈、疾病、设备故障、网络攻击。当某一类很罕见时,模型可以在常用指标上得分很高却毫无用处,因为一直说“不是”几乎总是对的。处理不平衡要从衡量正确的东西开始。

The Fraud Model That Caught 7 of 76वह Fraud Model जिसने 76 में से 7 पकड़े76 起欺诈只抓到 7 起的模型
Our seeded data has 30,000 card transactions and 1.3% fraud (Section 12). A "model" that always answers "not fraud" scores 98.73% accuracy on the test set and catches 0 of 76 frauds. A real logistic regression scores 98.85% — barely higher — and at the default 0.5 cut-off it flags just 7 transactions (all real frauds), so 69 frauds slip through. Its ranking is actually good (ROC-AUC 0.92); the problem is the metric and the cut-off. (The original page told a ₹4.2 crore story with invented numbers; this one is measured.)
हमारे seeded data में 30,000 card transactions और 1.3% fraud हैं (Section 12)। एक "model" जो हमेशा "fraud नहीं" कहता है, test set पर 98.73% accuracy पाता है और 76 में से 0 frauds पकड़ता है। असली logistic regression 98.85% पाता है — मुश्किल से ज़्यादा — और default 0.5 cut-off पर सिर्फ़ 7 transactions flag करता है (सभी असली frauds), यानी 69 frauds निकल जाते हैं। इसकी ranking असल में अच्छी है (ROC-AUC 0.92); समस्या metric और cut-off में है। (मूल page में गढ़ी संख्याओं वाली ₹4.2 करोड़ की कहानी थी; यह मापी गई है।)
我们带随机种子的数据有 30,000 笔银行卡交易,欺诈占 1.3%(第 12 节)。一个永远回答“不是欺诈”的“模型”在测试集上准确率 98.73%,76 起欺诈一起也没抓到。真正的逻辑回归准确率 98.85% —— 几乎没高多少 —— 在默认阈值 0.5 下只标记了 7 笔(都是真欺诈),69 起欺诈漏网。它的排序其实不错(ROC-AUC 0.92);问题出在指标和阈值上。(原页面讲的是数字编造的 ₹4200 万故事;这里是实测结果。)
📊 PICTURE 1 — HOW RARE IS RARE?तस्वीर 1 — दुर्लभ कितना दुर्लभ?图 1 — 多稀有才算稀有?
How rare is rare 1 : 4 mild · 20% minorityहल्का · 20% minority轻度 · 少数类 20%churn, reviewschurn, reviews流失、评论 1 : 10 moderate · 9% minorityमध्यम · 9% minority中度 · 少数类 9%loan default, screeningloan default, screening贷款违约、筛查 1 : 100 severe · 1% minorityगंभीर · 1% minority严重 · 少数类 1%fraud, defectsfraud, defects欺诈、缺陷 1 : 1000 extreme · 0.1% minorityअत्यधिक · 0.1% minority极端 · 少数类 0.1%rare disease, intrusionrare disease, intrusion罕见病、入侵 our fraud data: 1 : 78 (1.3%)हमारा fraud data: 1 : 78 (1.3%)我们的欺诈数据:1 : 78(1.3%)
⚠️
Accuracy Hides the Rare ClassAccuracy दुर्लभ Class को छिपा देती है准确率会掩盖少数类

With 1.3% fraud, "always no" already scores 98.7%. A good model can score higher (ours: 98.85%) — so accuracy is not capped there, it is just nearly blind to the class you care about. Report precision, recall and F1 for the rare class, the precision–recall curve, and balanced accuracy.

1.3% fraud में "हमेशा नहीं" पहले ही 98.7% पाता है। अच्छा model इससे ज़्यादा पा सकता है (हमारा: 98.85%) — यानी accuracy वहाँ रुकती नहीं, बस उस class के लिए लगभग अंधी है जिसकी आपको परवाह है। दुर्लभ class के लिए precision, recall और F1, precision–recall curve, और balanced accuracy बताएँ।

欺诈率 1.3% 时,“永远说不是”就有 98.7%。好模型可以更高(我们的:98.85%)—— 所以准确率并不是封顶在那里,只是它对你关心的那一类几乎视而不见。请报告少数类的精确率、召回率和 F1、精确率–召回率曲线以及平衡准确率。


SectionSection第 02 节

Step 1 — Measure the Imbalance 🔍Step 1 — Imbalance मापें 🔍第 1 步 —— 度量不平衡 🔍

Count both classes, the ratio and the absolute number of rare rows. The absolute number matters as much as the ratio: 228 frauds in training is enough to learn from; 12 would not be. Split with stratify=y so train, validation and test keep the same fraud rate. Rough guide: up to about 1:10 often needs nothing special; beyond 1:100 plan carefully — but always let validation results decide.

दोनों classes, ratio और दुर्लभ rows की असल संख्या गिनें। असल संख्या ratio जितनी ही अहम है: training में 228 frauds से सीखा जा सकता है; 12 से नहीं। stratify=y से split करें ताकि train, validation और test में fraud दर एक जैसी रहे। मोटा अनुमान: लगभग 1:10 तक अक्सर कुछ ख़ास नहीं चाहिए; 1:100 से आगे ध्यान से योजना बनाएँ — पर फ़ैसला हमेशा validation नतीजों पर छोड़ें।

统计两类的数量、比例以及少数类的绝对数量。绝对数量与比例同样重要:训练集中 228 起欺诈足以学习;12 起就不够。用 stratify=y 划分,让训练、验证和测试集保持相同的欺诈率。粗略参考:约 1:10 以内通常无需特殊处理;超过 1:100 要谨慎规划 —— 但最终总由验证结果决定。

# 1) Measure the imbalance, and see why accuracy fools you
import numpy as np, pandas as pd
from collections import Counter
from sklearn.model_selection import train_test_split
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score, recall_score, precision_score
df = pd.read_csv("transactions.csv")
df["log_amount"] = np.log(df["amount"])
df["night"] = ((df["hour"] <= 4) | (df["hour"] >= 23)).astype(int)
feats = ["log_amount", "night", "km_from_home", "txns_last_hour", "account_age_days", "merchant_risk"]
X, y = df[feats], df["fraud"]
X_tmp, X_test, y_tmp, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)
X_train, X_val, y_train, y_val = train_test_split(X_tmp, y_tmp, test_size=0.25, stratify=y_tmp, random_state=0)   # 60 / 20 / 20
counts = y_train.value_counts()
print(f"train: {Counter(y_train)} → imbalance ratio {counts.max() / counts.min():.0f}:1")
for name, m in [("always 'not fraud'", DummyClassifier(strategy="most_frequent")),
                ("logistic regression", make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)))]:
    pred = m.fit(X_train, y_train).predict(X_test)
    print(f"{name:<20} accuracy {accuracy_score(y_test, pred):.4f} | recall {recall_score(y_test, pred):.3f} | "
          f"precision {precision_score(y_test, pred, zero_division=0):.3f} | frauds caught {int((pred & y_test).sum())}/{int(y_test.sum())}")
OUTPUT
train: Counter({0: 17772, 1: 228}) → imbalance ratio 78:1 always 'not fraud' accuracy 0.9873 | recall 0.000 | precision 0.000 | frauds caught 0/76 logistic regression accuracy 0.9885 | recall 0.092 | precision 1.000 | frauds caught 7/76

SectionSection第 03 节

Step 2 — Use the Right Metrics 📏Step 2 — सही Metrics इस्तेमाल करें 📏第 2 步 —— 使用正确的指标 📏

🔢 PICTURE 2 — THE CONFUSION MATRIX, WITH REAL NUMBERSतस्वीर 2 — CONFUSION MATRIX, असली संख्याओं के साथ图 2 — 混淆矩阵(真实数字)
Confusion matrix and metrics test set, threshold 0.14test set, threshold 0.14测试集,阈值 0.14 predicted legitpredicted legit预测为正常predicted fraudpredicted fraud预测为欺诈 5,865TN — legit, passedTN — legit, पासTN —— 正常,放行 59FP — false alarmFP — झूठा alarmFP —— 误报 46FN — fraud missedFN — fraud छूटाFN —— 漏掉欺诈 30TP — fraud caughtTP — fraud पकड़ाTP —— 抓到欺诈 Precision = TP ÷ (TP + FP)Precision = TP ÷ (TP + FP)精确率 = TP ÷ (TP + FP)30 ÷ 89 = 0.337of the alerts, how many were fraud?alerts में से कितने fraud थे?告警中有多少是真欺诈? Recall = TP ÷ (TP + FN)Recall = TP ÷ (TP + FN)召回率 = TP ÷ (TP + FN)30 ÷ 76 = 0.395of the frauds, how many did we catch?frauds में से कितने पकड़े?欺诈中抓到了多少? F1 = 2PR ÷ (P + R)0.364one number that needs bothएक संख्या जिसे दोनों चाहिए同时需要两者的单一指标 AccuracyAccuracy准确率0.9825dominated by the 5,924 legit rows5,924 legit rows से दबा हुआ被 5,924 条正常记录主导
# 2) Metrics that look at the rare class — and how ROC-AUC and PR-AUC react to rarity
from sklearn.metrics import (classification_report, confusion_matrix, roc_auc_score,
                             average_precision_score, balanced_accuracy_score, f1_score)
logit = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(X_train, y_train)
p_test = logit.predict_proba(X_test)[:, 1]
pred = (p_test >= 0.5).astype(int)
print(confusion_matrix(y_test, pred))
print(classification_report(y_test, pred, target_names=["legit", "fraud"], digits=3))
print(f"balanced accuracy {balanced_accuracy_score(y_test, pred):.3f} | ROC-AUC {roc_auc_score(y_test, p_test):.3f} | "
      f"PR-AUC (average precision) {average_precision_score(y_test, p_test):.3f}")
rand = np.random.default_rng(0).random(len(y_test))
print(f"random scores      : ROC-AUC {roc_auc_score(y_test, rand):.3f} | PR-AUC {average_precision_score(y_test, rand):.3f}  (≈ the fraud rate)")
# keep every fraud, drop legit rows to change how rare fraud is: ROC-AUC stays, PR-AUC moves
r = np.random.default_rng(1)
for keep in [1.0, 0.25, 0.05]:
    idx = np.r_[np.where(y_test == 1)[0], r.choice(np.where(y_test == 0)[0], int(keep * (y_test == 0).sum()), replace=False)]
    print(f"fraud rate {y_test.iloc[idx].mean():6.2%}: ROC-AUC {roc_auc_score(y_test.iloc[idx], p_test[idx]):.3f} | "
          f"PR-AUC {average_precision_score(y_test.iloc[idx], p_test[idx]):.3f}")
OUTPUT
[[5924 0] [ 69 7]] precision recall f1-score support legit 0.988 1.000 0.994 5924 fraud 1.000 0.092 0.169 76 accuracy 0.989 6000 macro avg 0.994 0.546 0.581 6000 weighted avg 0.989 0.989 0.984 6000 balanced accuracy 0.546 | ROC-AUC 0.921 | PR-AUC (average precision) 0.373 random scores : ROC-AUC 0.503 | PR-AUC 0.016 (≈ the fraud rate) fraud rate 1.27%: ROC-AUC 0.921 | PR-AUC 0.373 fraud rate 4.88%: ROC-AUC 0.925 | PR-AUC 0.577 fraud rate 20.43%: ROC-AUC 0.921 | PR-AUC 0.794
LIVELIVE互动ROC vs PR as Fraud Gets RarerFraud दुर्लभ होने पर ROC बनाम PR欺诈越稀有:ROC vs PR

Same model and scores. We keep every fraud and drop legit rows to change the fraud rate. Dashed lines: a random model.

वही model और scores। Fraud दर बदलने के लिए हम हर fraud रखते हैं और legit rows हटाते हैं। Dashed lines: random model।

同一模型、同样的分数。保留所有欺诈、删除部分正常交易来改变欺诈率。虚线:随机模型。

📌
ROC-AUC Is Not "Inflated" — It Answers a Different QuestionROC-AUC "फूला" नहीं — यह दूसरा सवाल पूछता हैROC-AUC 并非“虚高”—— 它回答的是另一个问题

The original page said ROC looks good "even for the naïve model". A constant or random model scores ROC-AUC 0.5, as above. ROC-AUC measures ranking and ignores how rare fraud is, so 0.92 here is real — but it says nothing about how many false alarms come with each catch. PR-AUC (average precision) does: its baseline is the fraud rate (0.013), and it changes when the fraud rate changes. Use PR-AUC to compare models on the same data, and report precision and recall at the threshold you will actually use.

मूल page ने कहा था ROC "naïve model के लिए भी" अच्छा दिखता है। Constant या random model का ROC-AUC 0.5 होता है, जैसा ऊपर। ROC-AUC ranking मापता है और fraud की दुर्लभता को नज़रअंदाज़ करता है, इसलिए यहाँ 0.92 असली है — पर यह नहीं बताता कि हर पकड़ के साथ कितने झूठे alarms आते हैं। PR-AUC (average precision) बताता है: इसका baseline fraud दर (0.013) है, और fraud दर बदलने पर यह बदलता है। एक ही data पर models की तुलना के लिए PR-AUC इस्तेमाल करें, और जिस threshold पर असल में चलाएँगे उस पर precision और recall बताएँ।

原页面说 ROC “即使对朴素模型”也很好看。常数或随机模型的 ROC-AUC 是 0.5,如上所示。ROC-AUC 衡量排序、不考虑欺诈有多稀有,所以这里的 0.92 是真实的 —— 但它不说明每抓到一起会带来多少误报。PR-AUC(平均精确率)能说明:它的基线等于欺诈率(0.013),欺诈率变化它也会变化。在同一份数据上用 PR-AUC 比较模型,并报告你实际使用的阈值下的精确率和召回率。


SectionSection第 04 节

Six Strategies — and a Better Order 🧭छह Strategies — और बेहतर क्रम 🧭六种策略 —— 以及更好的顺序 🧭

Threshold tuning 🎯Threshold tuning 🎯阈值调整 🎯
predict_proba ≥ t
Keep the model, move the cut-off. Choose t on validation data. Cheap and often the biggest win.
Model वही, cut-off खिसकाएँ। t को validation data पर चुनें। सस्ता और अक्सर सबसे बड़ी जीत।
模型不变,移动阈值。在验证集上选 t。成本低,往往收益最大。
Class weights ⚖️Class weights ⚖️类别权重 ⚖️
class_weight='balanced'
Bigger penalty for missing the rare class. No rows change; probabilities shift up.
दुर्लभ class चूकने पर बड़ी penalty। Rows नहीं बदलतीं; probabilities ऊपर खिसकती हैं।
漏判少数类惩罚更大。不改数据;概率整体上移。
SMOTE 🧬SMOTE 🧬SMOTE 🧬
imblearn SMOTE
Synthetic rare rows on lines between real ones. Only inside an imblearn Pipeline.
असली rows के बीच की रेखाओं पर synthetic दुर्लभ rows। सिर्फ़ imblearn Pipeline के अंदर।
在真实样本之间的连线上生成合成少数类样本。只能放在 imblearn Pipeline 中。
Undersampling ✂️Undersampling ✂️欠采样 ✂️
RandomUnderSampler
Drop common-class rows. Fast for huge data; throws information away.
आम class की rows हटाएँ। बड़े data के लिए तेज़; जानकारी फेंकता है।
删除多数类样本。数据量巨大时快;会丢信息。
Combined 🔀Combined 🔀组合 🔀
SMOTETomek · SMOTEENN
Oversample, then remove points that sit in the other class's area.
Oversample करें, फिर दूसरी class के इलाक़े में बैठे बिंदु हटाएँ।
先过采样,再删除落在另一类区域里的点。
Model choice 🌲Model choice 🌲模型选择 🌲
BalancedRandomForest · scale_pos_weight
Some models resample or reweight internally. Still judge them with PR-AUC.
कुछ models अंदर ही resample या reweight करते हैं। फिर भी PR-AUC से परखें।
有些模型内部自带重采样或加权。仍要用 PR-AUC 评估。
🧭
A Practical Orderएक व्यावहारिक क्रम实用顺序

(1) Pick metrics that watch the rare class. (2) Build the best model you can and check its PR-AUC with stratified cross-validation. (3) Tune the threshold on validation data to match the business cost of misses vs false alarms. (4) Only then try class weights or resampling, and keep them only if validation PR-AUC or your business metric improves. On our data, step 3 alone matched every resampling method (Section 10).

(1) ऐसे metrics चुनें जो दुर्लभ class पर नज़र रखें। (2) सबसे अच्छा model बनाएँ और stratified cross-validation से उसका PR-AUC जाँचें। (3) छूटे fraud बनाम झूठे alarm की business क़ीमत के हिसाब से validation data पर threshold tune करें। (4) तभी class weights या resampling आज़माएँ, और उन्हें तभी रखें जब validation PR-AUC या आपका business metric सुधरे। हमारे data पर अकेले step 3 ने हर resampling method की बराबरी की (Section 10)।

(1) 选择能关注少数类的指标。(2) 尽量建好模型,并用分层交叉验证检查其 PR-AUC。(3) 根据漏判与误报的业务代价,在验证集上调整阈值。(4) 然后才尝试类别权重或重采样,只有当验证 PR-AUC 或业务指标改善时才保留。在我们的数据上,仅第 3 步就追平了所有重采样方法(第 10 节)。


SectionSection第 05 节

Class Weights ⚖️Class Weights ⚖️类别权重 ⚖️

class_weight="balanced" multiplies each row's loss by n_rows ÷ (2 × n_rows_in_its_class). Here that is 0.506 for legit and 39.5 for fraud — a 78:1 ratio, the same as the imbalance (the original page printed 0.505 and 50.5, which do not match its own 9,910 : 90 data; those give 0.505 and 55.6). No rows are added or removed.

class_weight="balanced" हर row के loss को n_rows ÷ (2 × उसकी class की rows) से गुणा करता है। यहाँ legit के लिए 0.506 और fraud के लिए 39.5 — 78:1 का ratio, imbalance के बराबर (मूल page ने 0.505 और 50.5 छापा था, जो उसके अपने 9,910 : 90 data से मेल नहीं खाता; वहाँ 0.505 और 55.6 आता)। कोई row जोड़ी या हटाई नहीं जाती।

class_weight="balanced" 会把每行的损失乘以 行数 ÷ (2 × 该类行数)。这里正常类为 0.506、欺诈类为 39.5 —— 比值 78:1,与不平衡比例相同(原页面打印的 0.505 和 50.5 与它自己的 9,910 : 90 数据不符,应为 0.505 和 55.6)。不增加也不删除任何行。

# 3) Class weights: same rows, bigger penalty for missing a fraud
from sklearn.utils.class_weight import compute_class_weight
from sklearn.ensemble import HistGradientBoostingClassifier
w = compute_class_weight("balanced", classes=np.array([0, 1]), y=y_train)
print(f"'balanced' weights: legit {w[0]:.3f}, fraud {w[1]:.1f}  (= n_rows / (2 × n_class); ratio {w[1] / w[0]:.0f}:1)")
def report(name, model):
    model.fit(X_train, y_train)
    p = model.predict_proba(X_test)[:, 1]; pr = (p >= 0.5).astype(int)
    print(f"{name:<34} recall {recall_score(y_test, pr):.3f} | precision {precision_score(y_test, pr, zero_division=0):.3f} | "
          f"F1 {f1_score(y_test, pr):.3f} | PR-AUC {average_precision_score(y_test, p):.3f} | mean prob {p.mean():.3f}")
    return p
report("logistic", make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)))
report("logistic, class_weight='balanced'", make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, class_weight="balanced")))
report("boosting", HistGradientBoostingClassifier(random_state=0))
report("boosting, class_weight='balanced'", HistGradientBoostingClassifier(random_state=0, class_weight="balanced"))
print(f"(real fraud rate in test: {y_test.mean():.3f})")
OUTPUT
'balanced' weights: legit 0.506, fraud 39.5 (= n_rows / (2 × n_class); ratio 78:1) logistic recall 0.092 | precision 1.000 | F1 0.169 | PR-AUC 0.373 | mean prob 0.012 logistic, class_weight='balanced' recall 0.816 | precision 0.061 | F1 0.113 | PR-AUC 0.372 | mean prob 0.249 boosting recall 0.066 | precision 0.625 | F1 0.119 | PR-AUC 0.198 | mean prob 0.010 boosting, class_weight='balanced' recall 0.592 | precision 0.079 | F1 0.139 | PR-AUC 0.212 | mean prob 0.152 (real fraud rate in test: 0.013)
LIVELIVE互动Slide the Fraud WeightFraud Weight को Slide करें拖动欺诈权重

Logistic regression with class_weight={0: 1, 1: w}. Left: where the predicted probabilities land. Right: metrics at threshold 0.5. Bottom: average probability vs the real fraud rate.

class_weight={0: 1, 1: w} वाला logistic regression। बाएँ: predicted probabilities कहाँ पड़ती हैं। दाएँ: threshold 0.5 पर metrics। नीचे: औसत probability बनाम असली fraud दर।

使用 class_weight={0: 1, 1: w} 的逻辑回归。左:预测概率的分布。右:阈值 0.5 时的指标。下:平均概率 vs 真实欺诈率。

🧐
What Class Weights Really DoClass Weights असल में क्या करते हैं类别权重到底做了什么

For logistic regression, the balanced weight raised recall from 0.092 to 0.816 at threshold 0.5 — but precision fell to 0.061, F1 fell, and PR-AUC stayed at 0.372. The ranking of transactions hardly changed; the probabilities just moved up (average 0.249 for a 1.3% problem). So a weight mostly acts like a lower threshold, and the "probabilities" stop being real risks. Other notes from the original code: SVC(probability=True) is deprecated in scikit-learn 1.9 — use CalibratedClassifierCV; XGBoost's scale_pos_weight is the same idea; and eval_metric="aucpr" only changes what is reported and early-stopped on, not the training loss.

Logistic regression में balanced weight ने threshold 0.5 पर recall 0.092 से 0.816 किया — पर precision 0.061 पर गिरा, F1 गिरा, और PR-AUC 0.372 पर रहा। Transactions की ranking लगभग नहीं बदली; probabilities बस ऊपर गईं (1.3% की समस्या में औसत 0.249)। यानी weight ज़्यादातर कम threshold जैसा काम करता है, और "probabilities" असली जोखिम नहीं रहतीं। मूल code की और बातें: scikit-learn 1.9 में SVC(probability=True) deprecated है — CalibratedClassifierCV इस्तेमाल करें; XGBoost का scale_pos_weight यही विचार है; और eval_metric="aucpr" सिर्फ़ यह बदलता है कि क्या report और early-stop होता है, training loss नहीं।

对逻辑回归,balanced 权重在阈值 0.5 时把召回率从 0.092 提到 0.816 —— 但精确率跌到 0.061,F1 下降,PR-AUC 仍是 0.372。交易的排序几乎没变;只是概率整体上移(1.3% 的问题平均概率达 0.249)。所以权重的作用大体相当于降低阈值,而“概率”不再是真实风险。原代码的其他问题:scikit-learn 1.9 中 SVC(probability=True) 已弃用 —— 请用 CalibratedClassifierCV;XGBoost 的 scale_pos_weight 是同一思路;eval_metric="aucpr" 只改变报告和早停所用的指标,不改变训练损失。


SectionSection第 06 节

SMOTE Oversampling 🧬SMOTE Oversampling 🧬SMOTE 过采样 🧬

SMOTE picks a rare-class row, picks one of its k nearest rare-class neighbours, and creates a new point somewhere on the straight line between them. It is smarter than copying rows, but it does not add information: every synthetic point is made from points you already had, and lines between distant frauds can run straight through legit territory.

SMOTE एक दुर्लभ-class row चुनता है, उसके k नज़दीकी दुर्लभ-class पड़ोसियों में से एक चुनता है, और उनके बीच की सीधी रेखा पर कहीं नया बिंदु बनाता है। यह rows copy करने से समझदार है, पर जानकारी नहीं जोड़ता: हर synthetic बिंदु उन्हीं बिंदुओं से बनता है जो पहले से थे, और दूर के frauds के बीच की रेखाएँ legit इलाक़े के बीच से गुज़र सकती हैं।

SMOTE 选一个少数类样本,再从它的 k 个最近少数类邻居中选一个,在两者之间的直线上某处生成新点。它比复制行更聪明,但并不增加信息:每个合成点都由已有的点构成,而相距较远的欺诈点之间的连线可能直接穿过正常区域。

LIVELIVE互动Watch SMOTE Draw PointsSMOTE को बिंदु बनाते देखें看 SMOTE 如何生成点

A sample of real training rows on two features. Press the button to create synthetic frauds; change k and try again.

दो features पर असली training rows का sample। Synthetic frauds बनाने के लिए button दबाएँ; k बदलकर फिर आज़माएँ।

两个特征上的真实训练样本。点击按钮生成合成欺诈点;改变 k 再试。

# 4) SMOTE: make synthetic frauds — inside an imblearn Pipeline
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.pipeline import Pipeline as SkPipeline
X_res, y_res = SMOTE(sampling_strategy=0.5, k_neighbors=5, random_state=42).fit_resample(X_train, y_train)
print("after SMOTE(sampling_strategy=0.5):", Counter(y_res))
try:
    SkPipeline([("smote", SMOTE()), ("model", LogisticRegression())]).fit(X_train, y_train)
except TypeError as e:
    print("sklearn Pipeline + SMOTE →", str(e).split(" or be")[0])
smote_lr = ImbPipeline([("scale", StandardScaler()), ("smote", SMOTE(sampling_strategy=0.5, random_state=42)),
                        ("model", LogisticRegression(max_iter=2000))])
report("logistic + SMOTE (imblearn Pipeline)", smote_lr)
print("rows the model was trained on:", Counter(smote_lr.named_steps["smote"].fit_resample(StandardScaler().fit_transform(X_train), y_train)[1]),
      "| rows predicted:", len(X_test), "(test is never resampled)")
OUTPUT
after SMOTE(sampling_strategy=0.5): Counter({0: 17772, 1: 8886}) sklearn Pipeline + SMOTE → All intermediate steps should be transformers and implement fit and transform logistic + SMOTE (imblearn Pipeline) recall 0.711 | precision 0.091 | F1 0.161 | PR-AUC 0.379 | mean prob 0.168 rows the model was trained on: Counter({0: 17772, 1: 8886}) | rows predicted: 6000 (test is never resampled)
🐛
A Correction About PipelinesPipelines के बारे में एक सुधार关于管道的更正

The original code said "sklearn Pipeline applies SMOTE to test data too". In fact a scikit-learn Pipeline refuses SMOTE outright — SMOTE has fit_resample, not transform, so you get the TypeError shown above. imbalanced-learn's Pipeline calls the sampler only during fit and skips it at prediction time. That is why you must import Pipeline from imblearn.pipeline.

मूल code ने कहा था "sklearn Pipeline test data पर भी SMOTE लगाता है"। असल में scikit-learn Pipeline SMOTE को सीधे मना कर देता है — SMOTE में fit_resample है, transform नहीं, इसलिए ऊपर दिखाया TypeError आता है। imbalanced-learn का Pipeline sampler को सिर्फ़ fit के दौरान बुलाता है और prediction के समय छोड़ देता है। इसीलिए Pipeline को imblearn.pipeline से import करना ज़रूरी है।

原代码说“sklearn Pipeline 也会对测试数据做 SMOTE”。实际上 scikit-learn 的 Pipeline 直接拒绝 SMOTE —— SMOTE 只有 fit_resample 而没有 transform,所以会出现上面的 TypeError。imbalanced-learn 的 Pipeline 只在 fit 时调用采样器,预测时跳过。这就是必须从 imblearn.pipeline 导入 Pipeline 的原因。


SectionSection第 07 节

The SMOTE Leak 🔒SMOTE Leak 🔒SMOTE 泄漏 🔒

If you oversample the whole training set and then cross-validate, each validation fold is full of synthetic frauds that were built from frauds in the training folds. The model only has to recognise near-copies.

अगर आप पूरे training set को oversample करके फिर cross-validate करते हैं, तो हर validation fold उन synthetic frauds से भरा होता है जो training folds के frauds से बने थे। Model को बस लगभग-copies पहचाननी होती हैं।

如果先对整个训练集过采样、再做交叉验证,每个验证折里都塞满了由训练折中的欺诈生成的合成欺诈。模型只需要认出近似复制品。

LIVELIVE互动Where Do the Validation Frauds Come From?Validation Frauds कहाँ से आते हैं?验证集里的欺诈从哪来?

For one CV fold: the distance from each validation fraud to the nearest training fraud.

एक CV fold के लिए: हर validation fraud से सबसे नज़दीकी training fraud की दूरी।

对其中一折:每个验证欺诈点到最近训练欺诈点的距离。

SMOTE

# 5) The SMOTE leak: oversample first, then cross-validate → copies of the same fraud land on both sides
from sklearn.model_selection import StratifiedKFold, cross_val_score
cv = StratifiedKFold(5, shuffle=True, random_state=0)
res = {"wrong": [], "right": [], "wrong_roc": [], "right_roc": []}
for seed in range(5):
    Xs, ys = SMOTE(random_state=seed).fit_resample(X_train, y_train)               # wrong: before CV
    for sc in ["average_precision", "roc_auc"]:
        k = "" if sc == "average_precision" else "_roc"
        res["wrong" + k].append(cross_val_score(HistGradientBoostingClassifier(random_state=0), Xs, ys, cv=cv, scoring=sc).mean())
        res["right" + k].append(cross_val_score(ImbPipeline([("smote", SMOTE(random_state=seed)), ("m", HistGradientBoostingClassifier(random_state=0))]),
                                                X_train, y_train, cv=cv, scoring=sc).mean())
print(f"SMOTE before CV : PR-AUC {np.mean(res['wrong']):.3f} | ROC-AUC {np.mean(res['wrong_roc']):.3f}   ← looks amazing")
print(f"SMOTE inside CV : PR-AUC {np.mean(res['right']):.3f} | ROC-AUC {np.mean(res['right_roc']):.3f}   ← the honest number")
print(f"(no SMOTE at all: PR-AUC {cross_val_score(HistGradientBoostingClassifier(random_state=0), X_train, y_train, cv=cv, scoring='average_precision').mean():.3f})")
OUTPUT
SMOTE before CV : PR-AUC 0.966 | ROC-AUC 0.973 ← looks amazing SMOTE inside CV : PR-AUC 0.066 | ROC-AUC 0.799 ← the honest number (no SMOTE at all: PR-AUC 0.131)
🚨
Two Lessonsदो सबक़两点教训

(1) The leaky PR-AUC (0.966) is partly fake and partly a different question — the folds became 50% fraud, and PR-AUC rises with the fraud rate. ROC-AUC, which ignores the rate, still jumps from 0.799 to 0.973 — that part is pure leakage. (2) Done honestly, SMOTE with gradient boosting scored worse than no SMOTE (0.066 vs 0.131). SMOTE is not a guaranteed improvement; test it like any other choice.

(1) Leak वाला PR-AUC (0.966) कुछ नक़ली है और कुछ दूसरा सवाल — folds 50% fraud बन गए, और PR-AUC fraud दर के साथ बढ़ता है। ROC-AUC, जो दर को नज़रअंदाज़ करता है, फिर भी 0.799 से 0.973 पर कूदता है — वह हिस्सा शुद्ध leakage है। (2) ईमानदारी से करने पर gradient boosting के साथ SMOTE बिना SMOTE से ख़राब रहा (0.066 बनाम 0.131)। SMOTE पक्का सुधार नहीं; इसे भी किसी और चुनाव की तरह परखें।

(1) 泄漏得到的 PR-AUC(0.966)一部分是假的,一部分是换了问题 —— 各折变成了 50% 欺诈,而 PR-AUC 会随欺诈率上升。不受比例影响的 ROC-AUC 仍从 0.799 跳到 0.973 —— 这部分纯属泄漏。(2) 诚实地做时,SMOTE 配合梯度提升反而比不用 SMOTE 更差(0.066 vs 0.131)。SMOTE 不是稳赚的改进;要像其他选择一样去验证。


SectionSection第 08 节

Undersampling and Combined Methods ✂️Undersampling और Combined Methods ✂️欠采样与组合方法 ✂️

RandomUnderSampler throws away common-class rows until the ratio you ask for; with sampling_strategy=0.5 and our 228 frauds that leaves 456 legit rows (the original comment said 200 and 100). TomekLinks removes common-class points that form a "nearest-neighbour pair" with a rare point — only 100 rows here. SMOTETomek and SMOTEENN oversample and then clean. All of these use distances, so scale first.

RandomUnderSampler आम class की rows तब तक फेंकता है जब तक माँगा ratio न आ जाए; sampling_strategy=0.5 और हमारे 228 frauds के साथ 456 legit rows बचती हैं (मूल comment में 200 और 100 था)। TomekLinks उन आम-class बिंदुओं को हटाता है जो किसी दुर्लभ बिंदु के साथ "nearest-neighbour जोड़ी" बनाते हैं — यहाँ सिर्फ़ 100 rows। SMOTETomek और SMOTEENN oversample करके फिर साफ़ करते हैं। ये सब दूरियाँ इस्तेमाल करते हैं, इसलिए पहले scale करें।

RandomUnderSampler 会丢弃多数类样本直到达到指定比例;设 sampling_strategy=0.5、我们有 228 起欺诈时,剩下 456 条正常记录(原注释写的是 200 和 100)。TomekLinks 删除与某个少数类点构成“互为最近邻”的多数类点 —— 这里只删了 100 行。SMOTETomek 和 SMOTEENN 先过采样再清理。它们都用距离,所以先缩放。

# 6) Undersampling and combined methods
from imblearn.under_sampling import RandomUnderSampler, TomekLinks
from imblearn.combine import SMOTETomek, SMOTEENN
Xs_tr = StandardScaler().fit_transform(X_train)                              # Tomek / ENN use distances → scale first
for name, s in [("RandomUnderSampler(0.5)", RandomUnderSampler(sampling_strategy=0.5, random_state=42)),
                ("TomekLinks()", TomekLinks()),
                ("SMOTETomek", SMOTETomek(random_state=42)),
                ("SMOTEENN", SMOTEENN(random_state=42))]:
    print(f"{name:<24} → {dict(Counter(s.fit_resample(Xs_tr, y_train)[1]))}")
report("logistic + RandomUnderSampler", ImbPipeline([("sc", StandardScaler()), ("rus", RandomUnderSampler(sampling_strategy=0.5, random_state=42)), ("m", LogisticRegression(max_iter=2000))]))
report("logistic + SMOTETomek", ImbPipeline([("sc", StandardScaler()), ("st", SMOTETomek(random_state=42)), ("m", LogisticRegression(max_iter=2000))]))
OUTPUT
RandomUnderSampler(0.5) → {0: 456, 1: 228} TomekLinks() → {0: 17672, 1: 228} SMOTETomek → {0: 17741, 1: 17741} SMOTEENN → {0: 16216, 1: 17542} logistic + RandomUnderSampler recall 0.697 | precision 0.092 | F1 0.162 | PR-AUC 0.364 | mean prob 0.172 logistic + SMOTETomek recall 0.803 | precision 0.061 | F1 0.114 | PR-AUC 0.373 | mean prob 0.237

SectionSection第 09 节

Threshold Tuning 🎯Threshold Tuning 🎯阈值调整 🎯

A classifier gives a probability; predict() just compares it with 0.5. For rare events 0.5 is rarely the right cut-off. Pick the threshold on validation data — maximising F1, or better, minimising the real cost of misses and false alarms — then check it once on test. Two bugs fixed from the original: it tuned on the test set, and precision_recall_curve returns one more precision/recall value than thresholds, so thresholds[argmax(f1)] can go out of range.

Classifier एक probability देता है; predict() बस उसकी 0.5 से तुलना करता है। दुर्लभ घटनाओं के लिए 0.5 शायद ही सही cut-off हो। Threshold को validation data पर चुनें — F1 सबसे ज़्यादा करके, या बेहतर, छूटे frauds और झूठे alarms की असली क़ीमत सबसे कम करके — फिर test पर एक बार जाँचें। मूल से दो bugs ठीक किए: उसने test set पर tune किया था, और precision_recall_curve thresholds से एक ज़्यादा precision/recall मान देता है, इसलिए thresholds[argmax(f1)] range से बाहर जा सकता है।

分类器输出概率;predict() 只是把它与 0.5 比较。对罕见事件,0.5 很少是合适的阈值。在验证集上选阈值 —— 最大化 F1,或者更好地,最小化漏判与误报的真实成本 —— 然后在测试集上只检验一次。修正了原代码的两个错误:它在测试集上调阈值;而且 precision_recall_curve 返回的精确率/召回率比阈值多一个,所以 thresholds[argmax(f1)] 可能越界。

LIVELIVE互动Move the Decision ThresholdDecision Threshold हिलाएँ移动决策阈值

All 6,000 test transactions by predicted probability (log scale). Grey: legit (histogram). Orange circles: the 76 frauds. Boxes: the confusion matrix at this threshold.

सभी 6,000 test transactions predicted probability के हिसाब से (log scale)। धूसर: legit (histogram)। नारंगी वृत्त: 76 frauds। Boxes: इस threshold पर confusion matrix।

6,000 笔测试交易按预测概率排列(对数刻度)。灰色:正常(直方图)。橙色圆:76 起欺诈。方框:该阈值下的混淆矩阵。

# 7) Threshold tuning — choose the cut-off on VALIDATION data, check it once on test
from sklearn.metrics import precision_recall_curve
p_val = logit.predict_proba(X_val)[:, 1]
prec, rec, thr = precision_recall_curve(y_val, p_val)
f1s = 2 * prec[:-1] * rec[:-1] / np.maximum(prec[:-1] + rec[:-1], 1e-12)   # the last (prec, rec) pair has no threshold
best = thr[np.argmax(f1s)]
print(f"best threshold on validation (max F1): {best:.3f}")
for t in [0.5, 0.3, 0.2, best, 0.05]:
    pr = (p_test >= t).astype(int)
    print(f"threshold {t:.3f}: precision {precision_score(y_test, pr, zero_division=0):.3f} | recall {recall_score(y_test, pr):.3f} | "
          f"F1 {f1_score(y_test, pr):.3f} | alerts {pr.sum():>4} | frauds caught {int((pr & y_test).sum())}/{int(y_test.sum())}")
OUTPUT
best threshold on validation (max F1): 0.140 threshold 0.500: precision 1.000 | recall 0.092 | F1 0.169 | alerts 7 | frauds caught 7/76 threshold 0.300: precision 0.800 | recall 0.211 | F1 0.333 | alerts 20 | frauds caught 16/76 threshold 0.200: precision 0.462 | recall 0.316 | F1 0.375 | alerts 52 | frauds caught 24/76 threshold 0.140: precision 0.337 | recall 0.395 | F1 0.364 | alerts 89 | frauds caught 30/76 threshold 0.050: precision 0.138 | recall 0.592 | F1 0.224 | alerts 326 | frauds caught 45/76

SectionSection第 10 节

Which Strategy Wins? 🏁कौन सी Strategy जीतती है? 🏁哪种策略胜出?🏁

We gave every strategy the same logistic model, the same data and — to be fair — its own threshold tuned on validation data. The original page showed a table with recall up to 0.89 and F1 up to 0.77; those numbers were invented. Here is what really happened.

हमने हर strategy को वही logistic model, वही data और — निष्पक्षता के लिए — validation data पर tuned उसका अपना threshold दिया। मूल page ने 0.89 तक recall और 0.77 तक F1 वाली table दिखाई थी; वे संख्याएँ गढ़ी हुई थीं। असल में यह हुआ।

我们给每种策略相同的逻辑模型、相同的数据,并且 —— 为了公平 —— 各自在验证集上调好的阈值。原页面的表格里召回率高达 0.89、F1 高达 0.77,这些数字是编造的。下面是真实结果。

# 8) Fair comparison: every strategy also gets its own threshold tuned on validation data
def lr(**kw): return LogisticRegression(max_iter=2000, **kw)
strategies = {
    "no treatment": make_pipeline(StandardScaler(), lr()),
    "class_weight='balanced'": make_pipeline(StandardScaler(), lr(class_weight="balanced")),
    "random undersampling": ImbPipeline([("s", StandardScaler()), ("r", RandomUnderSampler(sampling_strategy=0.5, random_state=42)), ("m", lr())]),
    "SMOTE": ImbPipeline([("s", StandardScaler()), ("r", SMOTE(sampling_strategy=0.5, random_state=42)), ("m", lr())]),
    "SMOTE + class weights": ImbPipeline([("s", StandardScaler()), ("r", SMOTE(sampling_strategy=0.5, random_state=42)), ("m", lr(class_weight="balanced"))]),
    "SMOTETomek": ImbPipeline([("s", StandardScaler()), ("r", SMOTETomek(random_state=42)), ("m", lr())]),
}
def tuned_threshold(y, p):
    pr, rc, th = precision_recall_curve(y, p)
    return th[np.argmax(2 * pr[:-1] * rc[:-1] / np.maximum(pr[:-1] + rc[:-1], 1e-12))]
rows = []
for name, m in strategies.items():
    m.fit(X_train, y_train)
    pv, pt = m.predict_proba(X_val)[:, 1], m.predict_proba(X_test)[:, 1]
    t = tuned_threshold(y_val, pv)
    rows.append({"strategy": name, "recall@0.5": recall_score(y_test, pt >= .5), "precision@0.5": precision_score(y_test, pt >= .5, zero_division=0),
                 "F1@0.5": f1_score(y_test, pt >= .5), "tuned t": t, "F1@tuned": f1_score(y_test, pt >= t),
                 "PR-AUC": average_precision_score(y_test, pt), "mean prob": pt.mean()})
bench = pd.DataFrame(rows).set_index("strategy")
pd.set_option("display.width", 160)
print(bench.round(3).to_string())
print(f"\nreal fraud rate in test: {y_test.mean():.3f}")
OUTPUT
recall@0.5 precision@0.5 F1@0.5 tuned t F1@tuned PR-AUC mean prob strategy no treatment 0.092 1.000 0.169 0.140 0.364 0.373 0.012 class_weight='balanced' 0.816 0.061 0.113 0.929 0.365 0.372 0.249 random undersampling 0.697 0.092 0.162 0.863 0.383 0.364 0.172 SMOTE 0.711 0.091 0.161 0.881 0.360 0.379 0.168 SMOTE + class weights 0.789 0.059 0.110 0.939 0.358 0.378 0.239 SMOTETomek 0.803 0.061 0.114 0.938 0.354 0.373 0.237 real fraud rate in test: 0.013
🏁 PICTURE 3 — F1 AT 0.5 vs AT A TUNED THRESHOLDतस्वीर 3 — 0.5 पर F1 बनाम TUNED THRESHOLD पर图 3 — 阈值 0.5 vs 调整后阈值下的 F1
F1 at 0.5 and at a tuned threshold 0.0 0.1 0.2 0.3 0.4 0.169 0.364 nonePR-AUC 0.373 0.113 0.365 balancedPR-AUC 0.372 0.162 0.383 undersamplePR-AUC 0.364 0.161 0.360 SMOTEPR-AUC 0.379 0.110 0.358 SMOTE + weightsPR-AUC 0.378 0.114 0.354 SMOTETomekPR-AUC 0.373 F1 at threshold 0.5threshold 0.5 पर F1阈值 0.5 时的 F1F1 at its own threshold (tuned on validation)अपने threshold पर F1 (validation पर tuned)各自阈值下的 F1(在验证集上调)

At 0.5 the strategies look very different. With their own tuned thresholds, all land between 0.354 and 0.383 — the differences are within noise for 76 test frauds.

0.5 पर strategies बहुत अलग दिखती हैं। अपने tuned thresholds पर सब 0.354 और 0.383 के बीच — 76 test frauds के लिए ये अंतर शोर के भीतर हैं।

在 0.5 时各策略差异很大。用各自调好的阈值后,全部落在 0.354 到 0.383 之间 —— 对 76 起测试欺诈来说,这些差异都在噪声范围内。

🌡️ PICTURE 4 — WHAT HAPPENS TO THE PROBABILITIESतस्वीर 4 — PROBABILITIES का क्या होता है图 4 — 概率发生了什么
Average predicted probability no treatment0.012 class_weight='balanced'0.249 random undersampling0.172 SMOTE0.168 SMOTE + class weights0.239 SMOTETomek0.237 real fraud rate 0.013असली fraud दर 0.013真实欺诈率 0.013 average predicted fraud probability on the test settest set पर औसत predicted fraud probability测试集上的平均预测欺诈概率

Every reweighting or resampling method inflates the average predicted probability far above the real 1.3% fraud rate.

हर reweighting या resampling method औसत predicted probability को असली 1.3% fraud दर से बहुत ऊपर फुला देता है।

每种加权或重采样方法都把平均预测概率抬到远高于 1.3% 的真实欺诈率。

⚖️
An Honest Summaryएक ईमानदार सार诚实的总结

On this data, resampling and class weights mainly move the threshold for you — and distort the probabilities while doing it. PR-AUC stayed between 0.364 and 0.379 for every strategy. They can help more with models that struggle to learn the rare class at all, or when you cannot tune a threshold. But the cheapest reliable steps are: the right metric, a good model, and a threshold chosen on validation data. If you need real probabilities (for pricing or expected-loss), avoid resampling or recalibrate afterwards.

इस data पर resampling और class weights मुख्य रूप से आपके लिए threshold खिसकाते हैं — और साथ में probabilities बिगाड़ते हैं। हर strategy का PR-AUC 0.364 और 0.379 के बीच रहा। वे उन models में ज़्यादा मदद कर सकते हैं जो दुर्लभ class को सीख ही नहीं पाते, या जब आप threshold tune नहीं कर सकते। पर सबसे सस्ते भरोसेमंद क़दम हैं: सही metric, अच्छा model, और validation data पर चुना threshold। अगर असली probabilities चाहिए (pricing या expected-loss के लिए), तो resampling से बचें या बाद में recalibrate करें।

在这份数据上,重采样和类别权重主要是替你移动了阈值 —— 同时扭曲了概率。每种策略的 PR-AUC 都在 0.364 到 0.379 之间。对于根本学不会少数类的模型,或无法调阈值的场景,它们可能更有帮助。但最便宜可靠的步骤是:正确的指标、好的模型、在验证集上选定的阈值。如果需要真实概率(用于定价或预期损失),请避免重采样,或在之后重新校准。


SectionSection第 11 节

A Complete Pipeline 🔗पूरी Pipeline 🔗完整管道 🔗

Use StratifiedKFold so every fold keeps the 1.3% fraud rate, score with average_precision, choose the threshold on validation data, and save it with the model — a model loaded without its threshold silently goes back to 0.5. Fixed from the original: X_val and new_raw_data were never defined, and SMOTE plus class_weight="balanced" corrected the imbalance twice. Note also how much the CV PR-AUC varies (± 0.031) with only about 45 frauds per fold.

StratifiedKFold इस्तेमाल करें ताकि हर fold में 1.3% fraud दर रहे, average_precision से score करें, validation data पर threshold चुनें, और उसे model के साथ save करें — बिना threshold load हुआ model चुपचाप 0.5 पर लौट जाता है। मूल से ठीक किया: X_val और new_raw_data कभी define नहीं हुए थे, और SMOTE के साथ class_weight="balanced" ने imbalance को दो बार ठीक किया। यह भी देखें कि हर fold में सिर्फ़ लगभग 45 frauds होने से CV PR-AUC कितना बदलता है (± 0.031)।

用 StratifiedKFold 让每一折都保持 1.3% 的欺诈率,用 average_precision 打分,在验证集上选阈值,并把阈值和模型一起保存 —— 没带阈值加载的模型会悄悄回到 0.5。修正了原代码:X_val 和 new_raw_data 从未定义;SMOTE 加 class_weight="balanced" 把不平衡纠正了两次。另外注意,每折只有约 45 起欺诈,CV PR-AUC 波动有多大(± 0.031)。

# 9) A complete pipeline: stratified CV on PR-AUC, threshold from validation, saved together
import joblib
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000))
scores = cross_val_score(pipe, X_train, y_train, cv=cv, scoring="average_precision")
print(f"5-fold stratified CV PR-AUC {scores.mean():.3f} ± {scores.std():.3f}")
pipe.fit(X_train, y_train)
threshold = tuned_threshold(y_val, pipe.predict_proba(X_val)[:, 1])
p = pipe.predict_proba(X_test)[:, 1]
print(f"threshold {threshold:.3f} → test precision {precision_score(y_test, p >= threshold):.3f}, "
      f"recall {recall_score(y_test, p >= threshold):.3f}, PR-AUC {average_precision_score(y_test, p):.3f}")
joblib.dump({"pipeline": pipe, "threshold": threshold}, "fraud_model.joblib")
art = joblib.load("fraud_model.joblib")
new_txn = pd.DataFrame([{"log_amount": np.log(42000), "night": 1, "km_from_home": 12.0, "txns_last_hour": 1,
                         "account_age_days": 400, "merchant_risk": 0.30}])
prob = art["pipeline"].predict_proba(new_txn)[0, 1]
print(f"new transaction: fraud probability {prob:.2f} → {'ALERT' if prob >= art['threshold'] else 'ok'}")
OUTPUT
5-fold stratified CV PR-AUC 0.248 ± 0.031 threshold 0.140 → test precision 0.337, recall 0.395, PR-AUC 0.373 new transaction: fraud probability 0.72 → ALERT

SectionSection第 12 节

Make the Practice Data 🐍Practice Data बनाएँ 🐍生成练习数据 🐍

Run this first. Fraud is more likely for large amounts, night-time payments, far from home, several transactions in the last hour, new accounts and risky merchants. About 1 transaction in 80 is fraud.

इसे पहले चलाएँ। बड़ी राशि, रात के भुगतान, घर से दूर, पिछले घंटे में कई transactions, नए accounts और जोखिम भरे merchants में fraud की संभावना ज़्यादा है। लगभग 80 में से 1 transaction fraud है।

请先运行它。金额大、夜间支付、离家远、过去一小时交易多、新账户和高风险商户更可能是欺诈。大约每 80 笔交易中有 1 笔是欺诈。

# 0) Practice data: 30,000 card transactions, about 1 in 80 is fraud
import numpy as np, pandas as pd
rng = np.random.default_rng(8)
n = 30000
amount = np.round(np.exp(rng.normal(7.0, 1.0, n)), 2)                      # ₹
hour = rng.choice(24, n, p=np.r_[np.full(6, .015), np.full(18, (1 - .09) / 18)])
km_from_home = np.round(rng.gamma(1.2, 6, n), 1)
txns_last_hour = rng.poisson(0.6, n)
account_age_days = rng.integers(1, 3650, n)
merchant_risk = np.round(rng.beta(2, 8, n), 3)
night = ((hour <= 4) | (hour >= 23)).astype(int)
z = (-7.3 + 0.8 * (np.log(amount) - 7) + 2.0 * night + 0.09 * km_from_home + 0.8 * txns_last_hour
     - 0.0009 * account_age_days + 7.0 * merchant_risk + rng.normal(0, 0.5, n))
fraud = (rng.random(n) < 1 / (1 + np.exp(-z))).astype(int)
pd.DataFrame({"amount": amount, "hour": hour, "km_from_home": km_from_home, "txns_last_hour": txns_last_hour,
              "account_age_days": account_age_days, "merchant_risk": merchant_risk, "fraud": fraud}).to_csv("transactions.csv", index=False)
print(f"transactions.csv: {n:,} rows, {fraud.sum()} frauds ({fraud.mean():.2%})")
OUTPUT
transactions.csv: 30,000 rows, 380 frauds (1.27%)

SectionSection第 13 节

Golden Rules for Imbalanced Data 🏆Imbalanced Data के सुनहरे नियम 🏆不平衡数据黄金法则 🏆

🎯 8 Rules Every Data Scientist Should Follow8 नियम जो हर Data Scientist को मानने चाहिए每位数据科学家都应遵守的 8 条法则
1
Do not judge by accuracy. Report precision, recall and F1 for the rare class, plus PR-AUC; add ROC-AUC for ranking.
Accuracy से न परखें। दुर्लभ class के लिए precision, recall और F1, साथ में PR-AUC बताएँ; ranking के लिए ROC-AUC जोड़ें।
不要用准确率评判。报告少数类的精确率、召回率、F1 以及 PR-AUC;排序能力可补充 ROC-AUC。
2
Split with stratify=y and cross-validate with StratifiedKFold.
stratify=y से split करें और StratifiedKFold से cross-validate करें।
用 stratify=y 划分,用 StratifiedKFold 交叉验证。
3
Tune the decision threshold on validation data, from the real cost of a miss vs a false alarm — and save it with the model.
Decision threshold को validation data पर, छूट बनाम झूठे alarm की असली क़ीमत से tune करें — और model के साथ save करें।
在验证集上、根据漏判与误报的真实代价调整决策阈值 —— 并与模型一起保存。
4
Treat class weights and resampling as options to test, not as fixes to apply by default. Keep them only if validation results improve.
Class weights और resampling को default इलाज नहीं, परखने लायक विकल्प मानें। तभी रखें जब validation नतीजे सुधरें।
把类别权重和重采样当作待验证的选项,而不是默认使用的修复手段。只有验证结果改善才保留。
5
Resample only inside an imblearn.pipeline.Pipeline, so it touches training folds only. Never resample before splitting.
Resample सिर्फ़ imblearn.pipeline.Pipeline के अंदर करें, ताकि यह सिर्फ़ training folds छुए। Split से पहले कभी resample न करें।
只在 imblearn.pipeline.Pipeline 内重采样,确保只作用于训练折。绝不在划分前重采样。
6
Remember resampling and weights distort probabilities. If you need real risks, calibrate or skip them.
याद रखें resampling और weights probabilities बिगाड़ते हैं। असली जोखिम चाहिए तो calibrate करें या इन्हें छोड़ें।
记住重采样和权重会扭曲概率。需要真实风险值时,请校准或不用它们。
7
SMOTE needs at least k + 1 rare rows (6 with the default k = 5) and works better with many more. It adds no new information.
SMOTE को कम से कम k + 1 दुर्लभ rows चाहिए (default k = 5 पर 6) और बहुत ज़्यादा rows के साथ बेहतर चलता है। यह नई जानकारी नहीं जोड़ता।
SMOTE 至少需要 k + 1 个少数类样本(默认 k = 5 时为 6 个),样本越多效果越好。它不增加新信息。
8
With few rare rows, every metric is noisy. Look at the spread across folds before trusting a small difference.
कम दुर्लभ rows में हर metric में शोर होता है। छोटे अंतर पर भरोसा करने से पहले folds में फैलाव देखें।
少数类样本少时,每个指标都有噪声。在相信微小差异之前,先看各折之间的波动。
🧮
Key Takeawayमुख्य सीख核心要点

Imbalance is first a measurement problem, then a decision problem. Our "do nothing" model had 98.7% accuracy and caught no fraud; a real model with a good ranking (ROC-AUC 0.92) still caught only 7 of 76 at the default cut-off. Moving the threshold on validation data raised F1 from 0.169 to 0.364 — as much as any resampling method — without distorting the probabilities. Resample inside a pipeline if it helps, never before the split, and let the business cost of each error choose the threshold.

Imbalance पहले मापने की समस्या है, फिर फ़ैसले की। हमारे "कुछ मत करो" model की accuracy 98.7% थी और उसने कोई fraud नहीं पकड़ा; अच्छी ranking (ROC-AUC 0.92) वाला असली model भी default cut-off पर 76 में से सिर्फ़ 7 पकड़ पाया। Validation data पर threshold खिसकाने से F1 0.169 से 0.364 हुआ — किसी भी resampling method जितना — बिना probabilities बिगाड़े। मदद करे तो pipeline के अंदर resample करें, split से पहले कभी नहीं, और हर ग़लती की business क़ीमत को threshold चुनने दें।

不平衡首先是度量问题,然后是决策问题。我们“什么都不做”的模型准确率 98.7%,却一起欺诈也没抓到;排序良好(ROC-AUC 0.92)的真实模型在默认阈值下也只抓到 76 起中的 7 起。在验证集上调整阈值,把 F1 从 0.169 提到 0.364 —— 与任何重采样方法相当 —— 而且不扭曲概率。如果有帮助就在管道内重采样,绝不在划分之前,并让每种错误的业务代价来决定阈值。