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 संभालना सही चीज़ मापने से शुरू होता है।
许多最有价值的预测任务都与罕见事件有关:欺诈、疾病、设备故障、网络攻击。当某一类很罕见时,模型可以在常用指标上得分很高却毫无用处,因为一直说“不是”几乎总是对的。处理不平衡要从衡量正确的东西开始。
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、精确率–召回率曲线以及平衡准确率。
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())}")
Step 2 — Use the Right Metrics 📏Step 2 — सही Metrics इस्तेमाल करें 📏第 2 步 —— 使用正确的指标 📏
# 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}")
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।
同一模型、同样的分数。保留所有欺诈、删除部分正常交易来改变欺诈率。虚线:随机模型。
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 比较模型,并报告你实际使用的阈值下的精确率和召回率。
Six Strategies — and a Better Order 🧭छह Strategies — और बेहतर क्रम 🧭六种策略 —— 以及更好的顺序 🧭
(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 节)。
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})")
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 真实欺诈率。
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" 只改变报告和早停所用的指标,不改变训练损失。
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 个最近少数类邻居中选一个,在两者之间的直线上某处生成新点。它比复制行更聪明,但并不增加信息:每个合成点都由已有的点构成,而相距较远的欺诈点之间的连线可能直接穿过正常区域。
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)")
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 的原因。
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 पहचाननी होती हैं।
如果先对整个训练集过采样、再做交叉验证,每个验证折里都塞满了由训练折中的欺诈生成的合成欺诈。模型只需要认出近似复制品。
For one CV fold: the distance from each validation fraud to the nearest training fraud.
एक CV fold के लिए: हर validation fraud से सबसे नज़दीकी training fraud की दूरी।
对其中一折:每个验证欺诈点到最近训练欺诈点的距离。
# 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})")
(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 不是稳赚的改进;要像其他选择一样去验证。
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))]))
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)] 可能越界。
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())}")
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}")
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 起测试欺诈来说,这些差异都在噪声范围内。
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% 的真实欺诈率。
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 之间。对于根本学不会少数类的模型,或无法调阈值的场景,它们可能更有帮助。但最便宜可靠的步骤是:正确的指标、好的模型、在验证集上选定的阈值。如果需要真实概率(用于定价或预期损失),请避免重采样,或在之后重新校准。
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'}")
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%})")
Golden Rules for Imbalanced Data 🏆Imbalanced Data के सुनहरे नियम 🏆不平衡数据黄金法则 🏆
stratify=y and cross-validate with StratifiedKFold.stratify=y से split करें और StratifiedKFold से cross-validate करें।stratify=y 划分,用 StratifiedKFold 交叉验证。imblearn.pipeline.Pipeline, so it touches training folds only. Never resample before splitting.imblearn.pipeline.Pipeline के अंदर करें, ताकि यह सिर्फ़ training folds छुए। Split से पहले कभी resample न करें।imblearn.pipeline.Pipeline 内重采样,确保只作用于训练折。绝不在划分前重采样。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 —— 与任何重采样方法相当 —— 而且不扭曲概率。如果有帮助就在管道内重采样,绝不在划分之前,并让每种错误的业务代价来决定阈值。