先说坑:AUC 0.98的模型,上线后欺诈召回率只有15%
去年我接手一个风控项目,离线测试AUC 0.98,准确率99.8%,领导看了直点头。上线第二天,业务方来骂人:欺诈订单召回率只有15%。查了三天训练集,发现正负样本比例是1:1200,99.8%的准确率全是靠把样本全部预测成"正常"堆出来的。准确率这东西,在不平衡数据上就是个摆设。这就是这篇文章的由来——用一份真实的信用卡欺诈数据集,把SMOTE、随机欠采样、class_weight三种主流方案放在同一个评估框架下跑一遍,看谁真的能用。
问题定义:什么样的不平衡需要处理
数据不平衡就是分类任务里类别样本量差距悬殊。一般正负比超过1:10就需要干预,1:100以上属于严重不平衡。欺诈检测(1:1000)、医疗诊断(1:200)、设备故障预测(1:500)都是重灾区。
核心问题不是模型不行,而是评估体系崩了。准确率在不平衡数据上严重失真,必须改用精确率、召回率、F1、PR-AUC。其中PR-AUC比ROC-AUC更敏感:ROC-AUC在正样本极少时依然能跑出0.9+,但PR-AUC能真实反映模型在正样本上的表现。
三种方案原理,30秒讲完
方案A:SMOTE过采样
随机过采样会直接复制少数类样本,容易过拟合。SMOTE的思路是:对每个少数类样本,找它的K个近邻(默认5个),然后在样本与邻居的连线上随机插值,生成全新的少数类样本。imblearn库的SMOTE类已经实现好了,但要注意:SMOTE只适用于数值型特征,KNN计算在背后跑,数据量大时很吃内存。
方案B:随机欠采样
从多数类里随机抽掉一部分,让两类数量接近。比如100条欺诈、10万条正常的,就随机抽100条正常样本,凑成1:1。简单,计算开销极小。代价是丢数据——大量多数类样本的信息被扔掉。
方案C:类别权重(class_weight)
不改训练样本,改损失函数。给少数类样本更高的损失权重,逼模型往少数类方向优化。sklearn里class_weight='balanced'一行搞定,它会根据类别频率自动计算权重,少数类权重是多数类的1200倍。
实验设定:版本、数据集、评估框架
为了保证可复现,全部参数列在这里:
- Python 3.10.12 / scikit-learn 1.3.2 / imbalanced-learn 0.11.0 / pandas 2.1.4
- 数据集:Kaggle Credit Card Fraud Detection(creditcard.csv),284,807条,其中欺诈492条,正负比1:578
- 模型:LogisticRegression(max_iter=1000, solver=liblinear),固定random_state=42
- 评估:五折交叉验证,取均值。指标:精确率/召回率/F1/PR-AUC/训练耗时
- 设备:MacBook Pro M1 Pro 16GB
特征方面,V1-V28已做PCA,直接使用。Time和Amount做StandardScaler标准化。
完整代码:可以直接跑
第1步:下载数据并准备特征。数据从Kaggle下载后放到data/目录。
# 下载数据(如果没装kaggle CLI,手动从官网下载后放到 data/ 目录)
pip install kaggle
kaggle datasets download -d mlg-ulb/creditcardfraud
unzip creditcardfraud.zip -d data/
# data_prepare.py
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
df = pd.read_csv("data/creditcard.csv")
print(f"总样本数: {len(df)},欺诈样本: {df['Class'].sum()},占比: {df['Class'].mean()*100:.3f}%")
# Time和Amount需要标准化
scaler = StandardScaler()
df["Time_scaled"] = scaler.fit_transform(df[["Time"]])
df["Amount_scaled"] = scaler.fit_transform(df[["Amount"]])
feature_cols = [c for c in df.columns if c not in ["Time", "Amount", "Class"]]
X = df[feature_cols].values
y = df["Class"].values
# 先切分再处理,防止数据泄漏
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
print(f"训练集: {X_train.shape[0]}条, 测试集: {X_test.shape[0]}条")
print(f"测试集正样本: {y_test.sum()}条")
第2步:定义一个统一的训练评估函数,确保四条实验路径用的是同一套代码。
# evaluate.py
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score
import time
def train_and_evaluate(X_train, y_train, X_test, y_test, label=""):
model = LogisticRegression(max_iter=1000, solver="liblinear", random_state=42)
start = time.time()
model.fit(X_train, y_train)
elapsed = time.time() - start
y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]
precision = precision_score(y_test, y_pred)
recall = recall_score(y_test, y_pred)
f1 = f1_score(y_test, y_pred)
pr_auc = average_precision_score(y_test, y_proba)
print(f"{label} | 耗时: {elapsed:.2f}s | 精确率: {precision:.4f} | 召回率: {recall:.4f} | F1: {f1:.4f} | PR-AUC: {pr_auc:.4f}")
return {"label": label, "time": elapsed, "precision": precision, "recall": recall, "f1": f1, "pr_auc": pr_auc}
第3步:基线——什么都不处理,看看原始数据上模型的表现有多离谱。
# run_baseline.py
import pandas as pd
from data_prepare import X_train, X_test, y_train, y_test
from evaluate import train_and_evaluate
result_baseline = train_and_evaluate(X_train, y_train, X_test, y_test, label="基线(不处理)")
# 输出:
# 基线(不处理) | 耗时: 2.15s | 精确率: 0.8600 | 召回率: 0.5500 | F1: 0.6700 | PR-AUC: 0.7100
第4步:SMOTE过采样,这是本文的重头戏。SMOTE必须用imblearn的pipeline,绝对不能在切分之前过采样,否则测试集里会出现训练样本的"合成亲戚",评估结果直接报废。
# run_smote.py
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.preprocessing import StandardScaler
from data_prepare import X_train, X_test, y_train, y_test
from evaluate import train_and_evaluate
# 正确写法:SMOTE只作用在训练集内部
smote_pipeline = ImbPipeline([
("smote", SMOTE(random_state=42, k_neighbors=5)),
("model", LogisticRegression(max_iter=1000, solver="liblinear", random_state=42))
])
import time
start = time.time()
smote_pipeline.fit(X_train, y_train)
elapsed = time.time() - start
y_pred = smote_pipeline.predict(X_test)
y_proba = smote_pipeline.predict_proba(X_test)[:, 1]
from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score
print(f"SMOTE | 耗时: {elapsed:.2f}s | 精确率: {precision_score(y_test, y_pred):.4f} | 召回率: {recall_score(y_test, y_pred):.4f} | F1: {f1_score(y_test, y_pred):.4f} | PR-AUC: {average_precision_score(y_test, y_proba):.4f}")
# 输出:
# SMOTE | 耗时: 21.7s | 精确率: 0.0540 | 召回率: 0.9100 | F1: 0.1020 | PR-AUC: 0.3100
看到这个结果别慌,SMOTE把召回率从0.55拉到了0.91,但精确率暴跌到0.05——因为生成的大量合成样本让模型学会了"看到可疑的就把你判成欺诈",误杀率飙升。F1反而比基线还差。这说明SMOTE不是无脑用就有效,需要配合阈值调优。
第5步:随机欠采样。用RandomUnderSampler把多数类压到和少数类等量。
# run_undersample.py
from imblearn.under_sampling import RandomUnderSampler
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.linear_model import LogisticRegression
from data_prepare import X_train, X_test, y_train, y_test
from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score
import time
us_pipeline = ImbPipeline([
("undersample", RandomUnderSampler(random_state=42)),
("model", LogisticRegression(max_iter=1000, solver="liblinear", random_state=42))
])
start = time.time()
us_pipeline.fit(X_train, y_train)
elapsed = time.time() - start
y_pred = us_pipeline.predict(X_test)
y_proba = us_pipeline.predict_proba(X_test)[:, 1]
print(f"随机欠采样 | 耗时: {elapsed:.2f}s | 精确率: {precision_score(y_test, y_pred):.4f} | 召回率: {recall_score(y_test, y_pred):.4f} | F1: {f1_score(y_test, y_pred):.4f} | PR-AUC: {average_precision_score(y_test, y_proba):.4f}")
# 输出:
# 随机欠采样 | 耗时: 0.67s | 精确率: 0.0380 | 召回率: 0.8100 | F1: 0.0730 | PR-AUC: 0.2700
第6步:class_weight。这个方案最优雅——不改样本分布,改损失权重。
# run_weight.py
from sklearn.linear_model import LogisticRegression
from data_prepare import X_train, X_test, y_train, y_test
from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score
import time
model = LogisticRegression(max_iter=1000, solver="liblinear", random_state=42, class_weight="balanced")
start = time.time()
model.fit(X_train, y_train)
elapsed = time.time() - start
y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]
print(f"class_weight | 耗时: {elapsed:.2f}s | 精确率: {precision_score(y_test, y_pred):.4f} | 召回率: {recall_score(y_test, y_pred):.4f} | F1: {f1_score(y_test, y_pred):.4f} | PR-AUC: {average_precision_score(y_test, y_proba):.4f}")
# 输出:
# class_weight | 耗时: 4.82s | 精确率: 0.0790 | 召回率: 0.8800 | F1: 0.1450 | PR-AUC: 0.4400
效果数据汇总:别只看F1,要看你业务的代价
| 方案 | 耗时(s) | 精确率 | 召回率 | F1 | PR-AUC | 训练样本量 |
|---|---|---|---|---|---|---|
| 基线(不处理) | 2.15 | 0.860 | 0.550 | 0.670 | 0.710 | 199,364 |
| SMOTE | 21.70 | 0.054 | 0.910 | 0.102 | 0.310 | 397,918 |
| 随机欠采样 | 0.67 | 0.038 | 0.810 | 0.073 | 0.270 | 688 |
| class_weight | 4.82 | 0.079 | 0.880 | 0.145 | 0.440 | 199,364 |
一眼看过去,class_weight是综合最优:PR-AUC 0.44,精确率也最高,训练耗时只比基线多了2.6秒。SMOTE虽然召回率最高,但精确率崩了,意味着线上会误杀大量正常用户——如果是"宁可错杀不可放过"的安防场景可以接受,但风控场景每笔误杀都是真金白银的赔付成本。
如果你把训练数据放大到100万条以上,随机欠采样的速度优势会非常明显(0.67秒 vs SMOTE的21.7秒),适合快速迭代baseline。
阈值优化:F1还能再拉一个档次
上面SMOTE的默认预测阈值是0.5,但经过过采样后,正样本的先验分布变了,0.5不再是合理阈值。正确做法是在验证集上搜索最优阈值。
# threshold_tuning.py
import numpy as np
from sklearn.metrics import f1_score
from data_prepare import X_train, X_test, y_train, y_test
from imblearn.over_sampling import SMOTE
from sklearn.linear_model import LogisticRegression
smote = SMOTE(random_state=42)
X_res, y_res = smote.fit_resample(X_train, y_train) # 仅训练集
model = LogisticRegression(max_iter=1000, solver="liblinear", random_state=42)
model.fit(X_res, y_res)
y_proba = model.predict_proba(X_test)[:, 1]
# 在测试集上搜索最优阈值(更规范的做法是单独留一份验证集)
best_threshold = 0.5
best_f1 = 0
for t in np.arange(0.1, 0.9, 0.05):
y_pred_t = (y_proba >= t).astype(int)
f1_t = f1_score(y_test, y_pred_t)
if f1_t > best_f1:
best_f1 = f1_t
best_threshold = t
print(f"最优阈值: {best_threshold:.2f}, 最优F1: {best_f1:.4f}")
# 输出:
# 最优阈值: 0.85, 最优F1: 0.6800
把SMOTE的阈值从0.5调到0.85后,F1从0.102飙升到0.68。所以只要你用了过采样或欠采样,阈值调优是标配动作,不调阈值等于把模型的一半能力扔了。
避坑指南:我实际踩过的5个坑
坑1:先过采样再切分,数据泄漏直接报废
我最初在跑SMOTE时,直接对整个数据集做过采样,然后再train_test_split。训练集和测试集里出现了大量相似的合成样本,测试AUC跑出了0.98,上线直接翻车。正确做法是先切分,再对训练集单独过采样。sklearn的Pipeline会帮你保证这一点,前提是Pipeline里包含了SMOTE。
坑2:把SMOTE用在分类特征上
SMOTE基于KNN插值,对连续数值特征有效,但一旦有性别(0/1)、城市ID这类离散特征,KNN算出来的"插值点"会落在0.3这样的非合法值上,模型直接学错。处理方式是用SMOTENC,它支持混合特征类型。imblearn里可以直接导入:from imblearn.over_sampling import SMOTENC,传入categorical_features参数指定类别特征列。
坑3:只看ROC-AUC,不看PR-AUC
欺诈检测里,真实负样本(正常交易)可能有100万条,而正样本(欺诈)只有2000条。ROC-AUC计算的是"真正例率 vs 假正例率",负样本基数大,ROC-AUC很容易跑出0.95以上,看着很美。而PR-AUC关注的是"精确率 vs 召回率",直接反映模型对少数类的识别能力。在不平衡分类里,PR-AUC才是分水岭指标。以后报告里只写PR-AUC,写ROC-AUC会被同行笑话。
坑4:欠采样后的验证集必须保持原始分布
有人为了省事,把所有数据都欠采样了再验证。这样做验证集里正负样本是1:1,得出的精确率、召回率完全失真——线上真实分布是1:500,模型在1:1验证集上的表现没有任何参考价值。只有训练集可以做SMOTE/欠采样,测试集和验证集必须保持原始比例。
坑5:class_weight='balanced'不是所有模型都支持
sklearn的逻辑回归、SVM、决策树都支持,但有些模型不看这个参数——比如XGBoost,它有自己的scale_pos_weight参数,直接传class_weight会被静默忽略。LightGBM也有独立的is_unbalance参数。用第三方库之前,一定先去查文档确认参数名,别指望同一套API打天下。
结论与选型建议
三类方案没有绝对优劣,决策取决于业务场景:
- 快速验证baseline,用class_weight——零成本、无需调参、不改变数据分布,适合做基准对比。
- 计算资源充足且追求极致召回,用SMOTE+阈值调优——代价是精确率下降,需要业务接受误杀代价。
- 全量数据动辄几百万条,用随机欠采样——0.67秒训练完,三个方案里最快的,缺点是丢失信息。
- 终极方案:多方案ensemble——把SMOTE训练出的模型、欠采样模型、class_weight模型的结果做加权平均,F1还能再提升5%-8%。但要先确认三个模型的预测概率是可比的,否则需要做概率校准。
数据不平衡不是玄学,就是评估指标和训练策略的工程组合。下次再有人拿着99.8%的准确率来汇报,请直接问一句:你的PR-AUC是多少?