简介:机器学习在金融风控与反欺诈场景中,面临的核心挑战往往不是模型复杂度,而是极端不平衡的样本分布——真实交易中欺诈占比常低于千分之一,常规准确率评估容易产生“模型很准”的幻觉。解决这一问题的关键在于理解数据预处理、特征筛选、标准化与重采样技术的正确顺序,其中SMOTE通过合成少数类样本有效缓解类别失衡,而Stacking集成学习则能融合多种模型的判断视角,提升欺诈样本召回率。本文从不平衡分类的基础原理出发,结合实际工程流程,剖析标准化泄漏、SMOTE切分顺序、PR-AUC评估与阈值移动等高频踩坑点,帮助读者构建一套可复现、可解释的金融反欺诈检测模型。
1. 金融反欺诈检测的 python 机器学习实战:为什么这份源码值得照着重跑
金融反欺诈检测用 python 机器学习来做,进门第一脚踩到的往往不是模型调参,而是样本极端不平衡:真实交易里欺诈占比常低于千分之一,模型全预测成正常,准确率也能超过 99%。这份课程设计正是围绕这一点展开的,从数据预处理、特征筛选、标准化、SMOTE 采样到 stacking 集成学习,把整条反欺诈建模流程走完,最后产出可跑的反欺诈模型.ipynb、HTML 展示页和数据报告。
拆这份源码时,我更在意几个关键顺序有没有踩坑:SMOTE 到底应该在切分前还是后做、标准化能不能在测试集上再 fit 一次、评估该用准确率还是召回率。这些细节决定了你是真正会做不平衡分类,还是只把代码跑通然后交差。
期末作业、课程设计选手,或者对 SMOTE 和 stacking 还停留在概念层的从业者,跟着这条链路走一遍,能把“理论 → 数据 → 模型 → 评估”整个闭环一次性打通。
2. 数据预处理:缺失值、高维特征与标准化的三板斧
2.1 读入数据与字段体检:先看形状和缺失,别急着建模
解压creditcard.rar后拿到的是一张交易明细表,第一行是表头。我一般不会直接丢给模型,而是先用一段探数代码把数据“摸”一遍,确认形状、缺失和类别分布。
import pandas as pd import numpy as np # 解压后的交易数据,第一行是表头,直接读入 df = pd.read_csv("creditcard.csv") print("数据形状: ", df.shape) print("字段类型统计:") print(df.dtypes.value_counts()) # 缺失值要逐列看,而不是只看总数 missing = df.isnull().sum() print("逐列缺失情况:") print(missing[missing > 0]) # 类别分布,先判断欺诈占比 print("类别分布:\n", df["Class"].value_counts()) print("欺诈占比: {:.4f}%".format(df["Class"].mean() * 100))df.shape第一眼确认行数和列数,重点观察是不是典型的 30 万行量级、31 列左右;dtypes.value_counts()看有没有字段被错误读成 object,比如金额列带逗号时就会被读成字符串;缺失值必须逐列输出,缺三行和缺一半的处理策略完全不同;Class.mean()在 0/1 编码下直接等于欺诈样本占比,这个缩写写法在反欺诈代码里很常见。
这里有一个很容易忽略的点:反欺诈数据的缺失值通常不多,但如果发现某一列缺失比例超过 30%,就不要用均值填充硬补了,直接删列更稳。项目源码里的处理顺序是“先看缺失 → 再做特征筛选”,这个顺序不能反,因为后面标准化会把缺失值问题放大。
资源包里几个文件的定位也可以先对齐,方便后面对照着看:
| 资源内文件 | 在流程中的位置 |
|---|---|
| creditcard.rar | 原始交易数据集,解压后读入 |
| 反欺诈模型.ipynb | 从数据体检到 stacking 评估的主流程 |
| 反欺诈模型.html | notebook 静态导出,不装环境也能看结果 |
| README.md | 环境版本与运行说明 |
| 项目报告.pptx | 答辩 / 汇报用数据报告 |
2.2 特征筛选:去掉 Time 与高相关特征,让模型聚焦
这种交易数据的特征大多是脱敏后的 PCA 结果(V1~V28),字段名不告诉你业务含义,所以特征筛选只能靠统计手段,不能靠拍脑袋。常见做法是双通道筛选:先看每个特征与标签的相关性,再看特征之间的相关性冗余,最后用随机森林重要性做交叉确认。
# 计算每个特征与标签的相关系数绝对值,降序排列 corr = df.corr()["Class"].abs().sort_values(ascending=False) print(corr.head(15)) # 随机森林做二次筛选,看重非线性关系 from sklearn.ensemble import RandomForestClassifier X_raw = df.drop(columns=["Class"]) y_raw = df["Class"] rf_probe = RandomForestClassifier(n_estimators=80, random_state=42, n_jobs=-1) rf_probe.fit(X_raw, y_raw) importance = pd.Series(rf_probe.feature_importances_, index=X_raw.columns) print(importance.sort_values(ascending=False).head(15))n_estimators=80对临时探数已经够用,random_state=42固定种子保证每次筛出来的结果一致,n_jobs=-1让多核 CPU 并行训练。相关系数和随机森林重要性两个通道互补:前者抓线性关系,后者抓非线性与组合关系,两者都垫底的特征才值得删除。
关于 Time 字段,反欺诈场景有争议:有些业务上认为凌晨交易更可疑,但大多数课程设计里 Time 会造成时间泄漏,而且它和欺诈标签之间没有稳定的业务因果,我一般直接 drop。注意“特征筛选”不是简单地取 top N 个特征,还要看特征间相关性。如果两个特征相关系数超过 0.9,建议只保留其中一个,否则进入逻辑回归或 stacking 后权重解释会失真。
2.3 标准化与切分:先切数据、再 fit 训练集,顺序错一个结果全变味
V1~V28 是 PCA 后的标准正态分布特征,但 Amount 的量纲可能是几十到几千,Time 的量纲是秒级。逻辑回归这类带梯度或距离度量的模型,遇到这种量纲差异,大数值特征会直接主导训练过程,所以标准化不能省。
from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 先做训练/测试切分,再做标准化 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # 关键顺序:只 fit 训练集,测试集只用 transform scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)fit_transform在训练集上计算均值和方差并完成转换;transform只把同一组参数套到测试集上,不重新计算。如果对测试集也来一次fit_transform,等于建模时偷看了测试集分布,这叫数据泄漏,后面所有评估数字都会虚高。stratify=y保证切分后训练集和测试集的欺诈比例保持一致,否则随机切分可能把本来就只有几百条的欺诈样本全分到测试集里。
标准化放切分之后做,是因为测试集实际上是“未来数据”,你不可能拿未来数据的均值和方差去修正历史。这一点在答辩时经常被问到,源码里如果顺序写反了,整套流程的可信度会大打折扣。
3. 不平衡数据处理:SMOTE 采样原理与参数设置
3.1 为什么随机过采样不够:SMOTE 的合成逻辑
欺诈样本占比常常在 0.1%~1% 之间,直接训练出来的模型几乎只会说“正常”。最原始的解法是随机过采样,也就是把少数类样本复制若干份,但这样模型看到的是同一批样本的复制品,练着练着就过拟合了:它记得住这些样本本身,却学不到欺诈样本的分布规律。
SMOTE(Synthetic Minority Over-sampling Technique)的做法是在特征空间里合成新样本,而不是复制旧样本。它的核心逻辑是:对每一个少数类样本,先找出它在特征空间里的 k 个近邻,然后在当前样本与某个近邻的连线上随机取一个插值点,作为新合成样本。新样本不是凭空捏造,而是两个真实样本的“中间态”,理论上能缓解过拟合。
用一句话概括:随机过采样复制的是“过去”,SMOTE 生成的是“附近”。对反欺诈这种特征维度高、样本量大的场景,SMOTE 明显更合适。当然它也有代价——合成样本里的业务含义不可解释,所以最终评估时一定要用测试集上的真实数据,不能用合成数据自欺欺人。
3.2 SMOTE 实现:k_neighbors、sampling_strategy 与 fit_resample 的用法
项目里用的是 imbalanced-learn 库,直接调用 SMOTE 类,核心方法是fit_resample,它一次性完成“拟合 + 重采样”,返回新的特征矩阵和标签数组。
from imblearn.over_sampling import SMOTE from collections import Counter smote = SMOTE( sampling_strategy="auto", k_neighbors=5, random_state=42 ) # 输入是上一章标准化后的训练数据 X_train_res, y_train_res = smote.fit_resample(X_train_scaled, y_train) print("重采样后训练集分布:", Counter(y_train_res))sampling_strategy="auto"表示把少数类过采样到与多数类等量;也可以写成0.5,表示少数类达到多数类的一半,这样能控制合成样本总量,减少噪声放大。k_neighbors=5是每个少数类样本插值时参考的近邻数量,这个值需要结合实际样本量调整;random_state=42固定插值过程的随机种子,保证每次跑出来的合成样本一致,方便复现。
注意这里传给 SMOTE 的是X_train_scaled,也就是标准化之后的训练集。标准化能保证插值时特征的量纲一致,不然金额这种大数值特征会主导“近邻”的计算。重采样后的训练集分布应该会看到少数类数量被拉平,多数类数量不变,这样后续模型就不会一边倒地偏向多数类了。
提示:如果你安装的 imbalanced-learn 和 sklearn 版本不匹配,调用 SMOTE 时可能报
TypeError或版本冲突错误,常规处理是升级这两个库到兼容版本后重装。
3.3 SMOTE 的边界问题:噪声放大与重叠样本怎么办
SMOTE 不是万能的,它在两类样本边界重叠严重的场景下反而会放大噪声。比如少数类样本本来就夹杂在多数类中间,SMOTE 在它与近邻之间插值,等于把边界噪声也复制了一遍,模型训练出来的边界会更混乱。
我实际用下来有两个经验:一是当数据里噪声点比较多时,优先用 SMOTEENN 或 SMOTETomek,它们在插值之后会额外清洗掉边界上的冲突样本;二是如果合成后模型的训练分数高得离谱、但测试集分数明显低一截,先怀疑是不是 k_neighbors 选大了,导致合成样本越过真实边界。
from imblearn.combine import SMOTEENN # SMOTE 之后再用 ENN 清洗边界样本,减少噪声放大 smote_enn = SMOTEENN( smote=SMOTE(k_neighbors=5, random_state=42), random_state=42 ) X_train_res, y_train_res = smote_enn.fit_resample(X_train_scaled, y_train) print("SMOTEENN 后训练集分布:", Counter(y_train_res))SMOTEENN 的用法和 SMOTE 完全一致,smote参数用来传入你配好的 SMOTE 实例,内部先合成再清洗。代价是清洗后少数类样本量会低于多数类,不再是一比一,但这反而更接近真实分布。如果你发现训练出来的模型在测试集上召回率一直上不去,往这个方向调比盲目增加合成样本量更有效。
4. 模型构造:stacking 集成学习的结构与调参
4.1 stacking 与 bagging、boosting 的核心区别
前面几步做完,训练集已经变成了“标准化的真实多数类 + 合成的少数类”。接下来要解决的问题是:怎么让多个模型的判断互相弥补。这里项目选的是 stacking 集成学习,而不是随机森林或 XGBoost 的单模型。
三个集成方向的差异可以用一张表说清:
| 集成方式 | 组合思路 | 典型代表 | 在反欺诈里的定位 |
|---|---|---|---|
| bagging | 并行训练多个模型,投票或平均 | 随机森林 | 降方差,抗噪声 |
| boosting | 串行拟合上一轮残差 | XGBoost | 降偏差,榨特征关系 |
| stacking | 基学习器输出喂给元学习器 | StackingClassifier | 组合不同算法视角 |
stacking 的工作方式分两层:第一层用 K 折交叉验证,让每个基学习器对训练集每一条样本生成一个“未见己”的预测;第二层把这些预测概率作为新的特征输入,训练一个元学习器来决定“这个模型的话该听多少”。它和 bagging 的最大区别不是投票,而是让元学习器去学习基学习器之间的组合权重。在反欺诈场景里,不同算法对欺诈的判断视角不同,stacking 能把这种差异变成提升。
4.2 搭一个三模型 stacking:两个基学习器加一个逻辑回归收口
项目里的模型构造用的是 sklearn + mlxtend 的组合,基学习器选随机森林和 XGBoost,元学习器用逻辑回归。这个组合在表格型数据上很稳:随机森林擅长抓特征交互,XGBoost 擅长逐步拟合残差,逻辑回归则负责把两边概率校准成最终预测。
from mlxtend.classifier import StackingClassifier from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from xgboost import XGBClassifier # 基学习器 1:随机森林 base_rf = RandomForestClassifier( n_estimators=200, max_depth=8, random_state=42, n_jobs=-1, class_weight="balanced" ) # 基学习器 2:XGBoost base_xgb = XGBClassifier( n_estimators=200, learning_rate=0.1, max_depth=4, random_state=42, eval_metric="logloss", use_label_encoder=False ) # 元学习器:逻辑回归,把两个基模型的概率组合起来 meta_lr = LogisticRegression(max_iter=1000) stack_model = StackingClassifier( classifiers=[base_rf, base_xgb], meta_classifier=meta_lr, use_probas=True, cv=5 ) stack_model.fit(X_train_res, y_train_res)classifiers传入基学习器列表,meta_classifier是元学习器,use_probas=True让元学习器接收概率而不是 0/1 硬预测,少损失信息;cv=5表示每个基学习器在训练时做五折交叉预测,避免基学习器用自己的训练标签直接当元特征,减小过拟合。
用逻辑回归做收口,除了概率校准好之外,还有一个现实原因:反欺诈模型需要向业务方解释“为什么这笔交易被判欺诈”。逻辑回归的系数能直观反映哪些基模型输出更可信,比直接用另一个黑箱模型做 meta 更容易讲清楚。
4.3 关键参数说明:概率输出、交叉验证与类别权重
几个参数值得单独展开。class_weight="balanced"加在随机森林上,这是和 SMOTE 并存的双保险:SMOTE 从样本层面拉平分布,类别权重从损失函数层面拉平惩罚;两者一起用,即使合成样本有噪声,模型也不会完全忽略少数类。
use_label_encoder=False是针对新版 XGBoost 的兼容参数,老版本不传会报警告;eval_metric="logloss"指定评估指标,避免版本更新后默认指标歧义。max_depth=4比默认值浅,因为反欺诈特征经过 PCA 后信息已经相对浓缩,树太深反而拟合到合成样本的噪声里。
如果不想引入 mlxtend,sklearn 也自带了StackingClassifier,写法稍有不同:
from sklearn.ensemble import StackingClassifier stack_model = StackingClassifier( estimators=[("rf", base_rf), ("xgb", base_xgb)], final_estimator=meta_lr, cv=5, stack_method="predict_proba" )estimators需要带名字的元组列表,stack_method="predict_proba"等价于 mlxtend 的use_probas=True。两种写法训练逻辑一致,选哪种取决于你环境里哪个库装得顺手。参数上唯一要注意的是:基学习器不要太强,也别太弱。太强会让元学习器只信任一个模型,失去“组合”的意义;太弱则喂给元学习器的特征没有区分度。深浅和树数量要控制在训练时间可接受的范围内。
5. 避坑手记:数据泄漏、准确率幻觉与重采样顺序混乱
5.1 坑一:先 SMOTE 再切分数据,验证结果一片大好,上线就翻车
现象:很多人拿到数据后,先把整份数据喂给 SMOTE,再切训练集和测试集,结果测试集上的准确率、召回率都高得惊人,答辩现场很漂亮,但部署到真实交易上立刻崩。
原因:SMOTE 在全量数据上合成样本时,会参照少数类样本的近邻生成插值。切分之后,一条少数类合成样本和它的“近邻源样本”很可能分别落进训练集和测试集,等于测试集里混进了训练数据的影子。模型在测试集上看到的不是“未来数据”,而是训练样本的亲戚,评估结果当然虚高。这属于典型的数据泄漏。
解决:顺序必须是“先切分,再 SMOTE”,而且 SMOTE 只能作用在训练集上。
# 正确顺序:先切分再重采样 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) smote = SMOTE(random_state=42) X_train_res, y_train_res = smote.fit_resample(X_train, y_train) # 测试集保持原始分布,不做任何重采样从那以后我只要看到有人把fit_resample写在train_test_split前面,就会多问一句“你的测试集是不是已经脏了”。这个坑在课程设计里出现频率极高,也是最容易被答辩老师一眼看穿的。
5.2 坑二:只看 accuracy,模型精准抓不到欺诈
现象:模型输出准确率 99.8%,看起来神勇无比。但把混淆矩阵拉出来一看,欺诈样本的召回率只有 0.1,几乎一个都没拦住。整个模型其实是“全体预测为正常”的复读机。
原因:准确率对不平衡数据极度不敏感。欺诈占比 0.17% 时,模型什么都不学,全预测为正常类,准确率也有 99.83%。准确率这个指标在极度不平衡的二分类里没有任何区分能力,它只会给你制造“模型很准”的幻觉。
解决:放弃用 accuracy 做唯一标准,改用混淆矩阵加 PR-AUC。反欺诈场景里真正该问的问题是“真实欺诈里拦住了多少”(召回率)和“拦下来的里面有多少误伤”(精确率),而 PR-AUC 同时看这两个指标随阈值变化的综合表现,比 ROC-AUC 对不平衡数据更敏感。
| 指标 | 容易被它骗的地方 |
|---|---|
| accuracy | 多数类占比太高,99% 不代表抓得住欺诈 |
| precision 单独看 | 可能只拦了十笔,全中但漏了剩下的 |
| recall 单独看 | 可能拦下一堆,但误伤正常用户 |
| PR-AUC | 对不平衡最敏感,适合做横向对比 |
5.3 坑三:对测试集又跑了一次 StandardScaler,评估虚高
现象:离线评估时模型分数很高,但把同一套代码换个时间段的数据预测,效果明显下滑,训练集和测试集的分布似乎对不上。
原因:有人在预处理阶段对 X_train 和 X_test 分别调用了fit_transform,导致测试集的均值和方差被重新计算。模型训练时看到的是“以测试集分布为基准”的特征,测试时就等于提前偷看了答案。更隐蔽的是,这种错误在评估阶段不报错、不警告,只会让数字悄悄变好,很难被发现。
解决:标准化只允许对训练集fit,测试集只允许transform。如果整个特征处理流程比较复杂,更保险的做法是用 sklearn 的Pipeline把标准化、SMOTE、模型串成一条流水线,让切分和 fit_resample 的边界在结构上不可越界。
from sklearn.pipeline import Pipeline from imblearn.pipeline import make_pipeline # 用 imblearn 的 pipeline,确保 SMOTE 只作用于训练集 pipe = make_pipeline( StandardScaler(), SMOTE(random_state=42), stack_model ) pipe.fit(X_train, y_train)用 pipeline 之后,fit和predict的边界由框架强制约束,从根上杜绝“测试集再 fit 一次”的错误。
6. 验证方法:从混淆矩阵到 PR-AUC 给反欺诈模型验明正身
6.1 先看混淆矩阵,再看分类报告
模型训练完成只是开始,验证才是真正见真章的地方。我拿到任意一个反欺诈模型,第一件事永远是拉混淆矩阵,而不是看训练损失或准确率。
from sklearn.metrics import confusion_matrix, classification_report, precision_recall_curve, auc # 用保持原始分布的测试集做预测 y_pred = stack_model.predict(X_test_scaled) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred)) # 计算 PR-AUC,作为不平衡场景的核心指标 y_prob = stack_model.predict_proba(X_test_scaled)[:, 1] precision, recall, _ = precision_recall_curve(y_test, y_prob) print("PR-AUC:", auc(recall, precision))混淆矩阵的四象限里,右下角“预测为欺诈且确实是欺诈”的值是核心,右上角“把正常用户误判为欺诈”决定了业务投诉量。分类报告里的召回率回答“抓到了多少”,精确率回答“误伤了谁”。两者不可偏废。
6.2 阈值移动:默认 0.5 几乎永远不是最优解
反欺诈模型最后输出的是概率,默认阈值 0.5 通常太保守。在欺诈占比极低的数据上,哪怕概率只有 0.2,也可能值得人工复核。调阈值的方法是画出 PR 曲线,看精确率和召回率的交叉位置,或者根据业务成本设定目标:宁可多误伤几笔换取高召回,还是宁可漏掉一部分换取低打扰。
# 找召回率不低于 0.8 时的最大精确率阈值 target_recall = 0.8 for threshold in [0.3, 0.2, 0.15, 0.1, 0.05]: y_prob_tmp = (y_prob >= threshold).astype(int) cm_tmp = confusion_matrix(y_test, y_prob_tmp) recall_tmp = cm_tmp[1, 1] / (cm_tmp[1, 0] + cm_tmp[1, 1]) precision_tmp = cm_tmp[1, 1] / (cm_tmp[0, 1] + cm_tmp[1, 1]) print(f"threshold={threshold}, recall={recall_tmp:.3f}, precision={precision_tmp:.3f}")这个循环把阈值从 0.5 一路降到 0.05,能直观看到“更低的阈值换来更高召回,但代价是误报增多”。资源包里如果只给了默认阈值的评估结果,建议自己补上这一步,答辩时这段比背概念有说服力得多。
从那以后,我每次拿到这类不平衡数据,都会强制走三件事:先切分再 SMOTE,标准化只 fit 训练集,评估只用混淆矩阵和 PR-AUC,顺手再做一遍阈值扫描。这套流程帮我绕过了不止一次“模型很准但实际抓不到欺诈”的翻车,希望帮到你。想完整复现就用资源包里的反欺诈模型.ipynb,按 README 搭好环境,把路径替换成你解压出的creditcard.csv,从头跑到尾,再自己改两版特征和阈值,理解会完全不一样。
本文还有配套的精品资源,点击获取