简介:IEEE-CIS 欺诈检测赛题的配套分析代码包,面向参加 Kaggle 二分类竞赛、希望系统练习表格型数据挖掘的机器学习学习者。围绕「用户是否产生欺诈交易」这一目标变量,项目将数据理解、清洗、特征构建与结果呈现拆成可复用模块,帮助读者快速建立对赛题数据分布与类别不平衡问题的整体认知。压缩包共 6 个文件,约 1.02MB,以 3 个 Python 脚本和 1 个 Jupyter Notebook 为主,辅以 README 说明与 gitignore 配置;其中 notebook 承载了完整的探索性分析过程,各 py 文件分别负责数据清洗、可视化报告与特征工程,按功能分层,便于单独调用或改造。目前已有 599 人学习下载。读者可直接复用其中的清洗函数与报告工具,参考特征工程脚本搭建自己的建模流水线,并借助 notebook 中的图表与统计结论排查数据异常、验证特征有效性,适合作为欺诈检测入门到进阶的实操底稿。
1. 一份跑得出 0.96 AUC 的 Jupyter Notebook,搬进真实交易只剩 0.71
上周有个做支付风控的同行发给我一份 Jupyter Notebook,说这套欺诈检测方案在他的验证集上跑到 0.96 的 AUC,搬到公司三个月真实交易上只剩 0.71。我从头跑了一遍,数据并不脏;问题出在代码里每一步特征工程都默认了“全量数据可见”:UID 聚合用到了未来交易,频次编码把验证集也统计进去了,切分方式还是随机打乱。资源本身是完整的,一个 Jupyter Notebook 串起 EDA、特征工程、LightGBM 训练和提交文件生成,配套交易表与身份表两份 CSV,训练集约 59 万行、展开后近 400 列。它适合想学不平衡表格数据完整链路的人,适合需要快速搭欺诈检测基线的风控团队,也适合准备复现方案的学生。下面按我重跑的路径拆,重点放在会翻车的地方。
2. 交易表与身份表合并:59 万行拼成一张建模宽表的三步
这套资源的数据组织方式很有代表性:一张交易主表记录每笔订单的金额、卡信息、设备指纹和一批脱敏数值,另一张身份表记录设备型号、浏览器、屏幕分辨率这类只覆盖部分交易的信息。建模第一步不是调参,而是把两张表按时序拼好、把内存压下来、把时间轴切对。这三件事任何一件做歪,后面所有指标都不可信。
2.1 先摸清列族结构与缺失格局
拿到宽表先别急着describe(),那会输出几百行噪声。有用的做法是按列族分类,再按缺失率分档看。这个数据集里大致有这么几族:
| 列族 | 典型列名 | 类型 | 建模时的用法 |
|---|---|---|---|
| 交易金额 | TransactionAmT | 连续长尾 | 对数变换后入模,长尾明显 |
| 卡与地址 | card1~card6、addr1、addr2 | 类别/序数 | 构造客户标识的主力 |
| 计时基准 | TransactionDT | 整数秒偏移 | 只能做相对时间,不能还原真实日期 |
| 累计计数 | C1~C14 | 整数 | 关联卡/地址在观测窗内的行为次数 |
| 时间差 | D1~D15 | 连续 | 距上一次同类行为的天数,缺失本身有信息 |
| 匹配标志 | M1~M9 | 类别 T/F | 姓名、地址、设备等字段是否一致 |
| 脱敏数值 | V1~V339 | 连续 | 已做过工程的数值,分布不规则 |
| 邮箱域名 | P_emaildomain、R_emaildomain | 高基数类别 | 频次编码比 one-hot 更合适 |
身份表的覆盖率只有两成多,也就是说近八成交易在这张表上是全空。这不是数据质量问题,而是一个强信号:能拿到设备信息的交易,行为路径往往不一样。所以合并之后一定要补一个“有没有身份信息”的标志位,让树模型自己去学这个分叉。
import pandas as pd import numpy as np DATA = "./input" tr_txn = pd.read_csv(f"{DATA}/train_transaction.csv") tr_idt = pd.read_csv(f"{DATA}/train_identity.csv") print("交易表", tr_txn.shape, "身份表", tr_idt.shape) # 缺失率按 10% 一档分桶,一眼看出哪些列基本没有信息量 miss = tr_txn.isna().mean() print(miss.groupby((miss * 10).round() / 10).size.sort_index()) # 身份表覆盖率:这个数字决定了后面要不要单独建标志位 cover = tr_txn["TransactionID"].isin(tr_idt["TransactionID"]).mean() print(f"身份表覆盖率 {cover:.2%}")这段代码的作用是把“几百列”压缩成几个数字。缺失率分桶里如果某一档堆了大量列(比如 0.9 到 1.0 之间有一百多列),这些列在树模型里几乎不会被选中,可以直接列个清单待定,不必一开始就删——LightGBM 对无效列有天然的抗性,但在内存吃紧时这批列就是第一批该砍的。
参数上只有一个地方要注意:round()/10的分桶粒度。用 0.1 就够,用 0.01 会得到几十个桶,反而看不出分布形状。另外身份表覆盖率的计算要用isin而不是 merge 之后看行数,因为身份表里 TransactionID 理论上有唯一性,但真实数据里偶有重复,直接 merge 会让主表行数膨胀。
2.2 左连接合并与内存降级
合并本身一行代码,但有两个细节决定后面能不能跑得动。第一是连接方式:主表是交易,身份表是可选补充,所以必须how="left",用indicator=True顺手拿到合并结果标记,转成 0/1 特征。第二是类型降级,pandas 读 CSV 默认给float64和int64,近 400 列乘以 59 万行,一个 float64 就是 4.7MB,全表轻松破 2GB,做聚合时再复制几份就 OOM。
def downcast(df: pd.DataFrame) -> pd.DataFrame: """把 64 位数值降到 32 位,整数按取值范围收窄,内存一般能省 45%~55%""" for col in df.columns: if df[col].dtype == "float64": df[col] = df[col].astype("float32") elif df[col].dtype == "int64": cmin, cmax = df[col].min(), df[col].max() if cmin >= 0 and cmax < 2 ** 31: df[col] = df[col].astype("int32") elif cmin >= -2 ** 15 and cmax < 2 ** 15: df[col] = df[col].astype("int16") return df df = tr_txn.merge(tr_idt, on="TransactionID", how="left", indicator=True) df["has_identity"] = (df["_merge"] == "both").astype("int8") df = df.drop(columns=["_merge"]) df = downcast(df) print(f"合并后 {df.shape},占用 {df.memory_usage(deep=True).sum() / 1024 ** 2:.0f} MB")downcast的顺序有讲究:先处理 float 再处理 int,否则整数列降级后又被 float 覆盖。float32的精度大约是 7 位有效数字,对这笔数据里的金额和计数完全够用——但如果你后面要做金额的精确求和校验,记得在降级前先算好,或者单独保留一份float64的金额列。
还有一个容易忽略的点:合并后一定要检查行数。assert len(df) == len(tr_txn)这一句看着多余,但在身份表存在重复 ID 的时候能第一时间拦住问题。我见过一次因为身份表重复导致主表膨胀了三千多行,模型照跑不误,只是那些被复制的交易在训练集里权重翻倍,AUC 看着涨了 0.002,纯属自欺欺人。
2.3 按 TransactionDT 切时间轴,而不是随机打乱
这是整套流程里最关键的一步。TransactionDT是从某个未知基准点起的秒数偏移,你没法还原成真实日期,但这不影响使用——它的大小顺序就是真实的时间顺序。欺诈检测的线上形态永远是“用过去预测未来”,所以验证集必须取时间轴末端的一段。
# 按秒数偏移排序,后 20 天作为验证集 df = df.sort_values("TransactionDT").reset_index(drop=True) cut = df["TransactionDT"].max() - 20 * 86400 train = df[df["TransactionDT"] <= cut].copy() valid = df[df["TransactionDT"] > cut].copy() print(f"训练 {len(train)} 条,验证 {len(valid)} 条") print(f"训练欺诈率 {train['isFraud'].mean():.4f},验证欺诈率 {valid['isFraud'].mean():.4f}")cut的算法是“最大时间戳减去 20 天”。为什么用天数而不是比例?因为比例切分在数据分布随时间波动时会给出大小不稳定的验证集。固定 20 天意味着验证集规模随数据量变化,但时间跨度稳定,评估口径一致,做版本对比时更可靠。
切完之后必须核对两边的欺诈率。这笔数据整体欺诈率在 3.5% 上下,如果验证集突然变成 1.2%,说明最后那段时间的欺诈模式变了,这时候拿验证集调参就是在拟合一个已经不存在的分布。遇到这种情况,我的做法是把验证窗口往前挪,或者干脆做成两个窗口分别评估,看模型在不同时间段的表现是否稳定。
3. 特征工程落地:UID 聚合、频次编码与时间差的四段代码
这套 Notebook 的特征部分是全篇最值得抄的地方,也是最容易抄错的地方。它的核心思路是造一个“客户标识”,然后围绕这个标识做行为聚合——单笔交易的特征永远有限,但“这个客户过去一周交易了几次、平均金额多少、上次交易隔了多久”这类特征才是欺诈检测真正吃得下的东西。区别只在于,聚合的统计口径必须严格限制在历史窗口内。
3.1 构造 UID 与频次编码
UID 不是数据里给的,是拼出来的。常用拼法是卡号主键 + 地址 + 邮箱域名,这三个字段在真实风控里也基本是同一套逻辑:同一个人的卡、账单地址、邮箱域名组合很难同时被大量复用到不同人身上。
# 空值先填 -1 再转字符串,避免 NaN 在字符串拼接时变成 "nan" 与真实值混淆 for c in ["card1", "card2", "card3", "card5", "addr1"]: df[c] = df[c].fillna(-1).astype("int32") df["uid"] = ( df["card1"].astype(str) + "_" + df["addr1"].astype(str) + "_" + df["P_emaildomain"].fillna("unknown") ) # 频次编码:只统计训练集窗口,验证集和线上没见过的统一落到 0 freq_cols = ["card1", "card2", "addr1", "P_emaildomain", "uid"] for c in freq_cols: vc = train[c].value_counts() df[f"{c}_freq"] = df[c].map(vc).fillna(0).astype("float32")关键的参数说明在最后三行。vc是从train里统计出来的,不是从整个df。这个区别在两份代码里只差一个变量名,但效果差了一个量级:用全量统计,验证集里每一个类别的频次都包含了它自己的贡献,等于是把答案提前告诉了模型。fillna(0)而不是填中位数,是因为“没出现过”和“出现过一次”在业务上是两件完全不同的事——前者是新客户,后者是有过一笔记录的客户。0 恰好能表达“没出现过”,而uid频次为 0 的交易在你的训练集里根本不存在,所以这个值只会出现在验证集和线上,模型对它天然陌生,反而不会乱用。
如果你的数据里新客户占比很高,可以考虑再补一个“是否为首次出现”的二值特征,把这种陌生感显式化。
3.2 聚合统计:把行为画像压缩成几列
有了 UID,就能做分组聚合。这里最容易犯的错还是一样——统计范围。正确做法有两种:一是像上面频次编码那样只用训练窗口的统计量映射到全表;二是做“扩展窗口聚合”,即每一行只用它之前的数据算统计量。第二种更严谨,但计算量大,一般用groupby().expanding()配合shift(1)实现。
# 方案 A:训练窗口聚合,映射到全表(实现简单,适合基线) agg_map = { "TransactionAmT": ["mean", "std", "max"], "TransactionID": ["count"], "D1n": ["mean"], "C13": ["mean"], } g = train.groupby("uid").agg(agg_map) g.columns = ["uid_" + "_".join(c) for c in g.columns] df = df.merge(g, left_on="uid", right_index=True, how="left") # 没见过的 uid 聚合值为空,树模型能自动处理,不用强行填 0 print(df.filter(like="uid_").isna().mean().round(3))agg_map里的四项各有分工。金额的均值反映客户消费档位,标准差反映消费稳定性——欺诈账户的金额分布往往比正常账户更极端,要么全是小额试探,要么突然一笔大额。count就是交易频次,是这套特征里最强的单变量之一。D1n的均值是“距上次同类行为的天数”的平均,刻画行为节奏;C13属于累计计数族,反映关联卡的使用广度。
g.columns那一行是给聚合后的多级列名拍平,因为 merge 之后多级索引会带来一堆麻烦,拍平成uid_TransactionAmT_mean这种形式,后面做特征重要性分析时可读性也更好。
注意最后打印的缺失率:没见过的 uid 聚合出来是 NaN,这个 NaN 是有意义的,别急着fillna(0)。填 0 会让“没见过的新客户”和“金额均值为 0 的客户”混在一起,树模型切分时会把这两种情况塞进同一个叶子。
3.3 时间差与行为节奏特征
金额和频次之外,最能区分异常的是“节奏”。正常用户的交易间隔有规律,被盗卡的第一笔往往和上一笔隔了很久,随后在极短时间内连续试探。
df = df.sort_values(["uid", "TransactionDT"]).reset_index(drop=True) # 同一 uid 相邻两笔交易的时间间隔(秒) gap = df.groupby("uid")["TransactionDT"].diff() # 负值来自排序跨界,用 -1 标记,并裁剪到 30 天,避免长尾把分布拉平 df["uid_gap"] = gap.fillna(-1).clip(-1, 30 * 86400).astype("float32") # 绝对时间特征:小时与星期,不还原日期也能拿到周期性信息 df["txn_hour"] = (df["TransactionDT"] // 3600 % 24).astype("int8") df["txn_dow"] = (df["TransactionDT"] // 86400 % 7).astype("int8")diff()在同一 uid 内按时间排序后取差,第一笔必然是 NaN,填 -1 作为哨兵值。clip(-1, 30*86400)这个上限不是随手写的:交易间隔的原始分布是极端长尾,存在隔了几个月的记录,不裁剪会让这个特征在标准化和分箱时几乎失效;30 天这个阈值对应的是“一个月内是否有过交易”,业务含义清晰,也符合大多数风控策略的观察窗设定。
小时和星期的计算利用了一个巧合:TransactionDT是秒偏移,除以 3600 取模 24 得到的虽然不一定是真实钟点,但因为偏移量是固定的,这个“相对小时”在整个数据集内部是一致的,周期性照样能被模型学到。这点很重要——很多人卡在“没法还原真实时间”上,其实完全不需要还原。真正需要警惕的是把TransactionDT直接当数值特征丢进去,那样模型会学到“越晚的交易越可能是欺诈”这种时间趋势,换一批数据就完全失效。
3.4 类别特征编码与高维取舍
剩下的类别特征怎么处理,取决于你用什么模型。给 LightGBM 用,最省事的是astype("category")然后交给原生类别支持;给线性模型或神经网络用,就得考虑目标编码,而目标编码是泄漏重灾区。
cat_cols = ["ProductCD", "card4", "card6", "M4", "P_emaildomain"] for c in cat_cols: df[c] = df[c].astype("category") # 目标编码务必在训练折内计算,用 K 折外推的方式生成,不能用全量 from sklearn.model_selection import StratifiedKFold def target_encode(tr_df, va_df, col, target="isFraud", k=5, smooth=20): """折外目标编码:训练集自身用 K 折生成,验证集用全量训练统计量""" prior = tr_df[target].mean() stat = tr_df.groupby(col)[target].agg(["mean", "count"]) # 平滑:样本少的类别往全局均值收缩,避免单样本类别给出极端值 stat["enc"] = (stat["mean"] * stat["count"] + prior * smooth) / (stat["count"] + smooth) out_tr = np.full(len(tr_df), prior, dtype="float32") skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=42) for fit_idx, hold_idx in skf.split(tr_df, tr_df[target]): s = tr_df.iloc[fit_idx].groupby(col)[target].agg(["mean", "count"]) e = (s["mean"] * s["count"] + prior * smooth) / (s["count"] + smooth) out_tr[hold_idx] = tr_df.iloc[hold_idx][col].map(e).fillna(prior).values out_va = va_df[col].map(stat["enc"]).fillna(prior).astype("float32").values return out_tr, out_vasmooth=20是收缩强度,含义是“一个类别至少要有 20 条样本,它的编码值才有一半权重来自自己”。这个参数在欺诈率 3.5% 的数据上很关键:某个邮箱域名只有 3 笔交易,其中 1 笔欺诈,不做平滑的话编码值就是 0.33,比全局均值高一个数量级,模型会把它当成强特征,实际上纯属噪声。收缩到 20 之后,3 条样本的类别编码值基本贴着全局均值,噪声被压掉了。
折外生成那一段是防泄漏的核心。训练集自己的编码值必须来自“没见过自己”的折,否则每一行都用自己的标签算了一次统计量,AUC 能虚高 0.05 以上。验证集则用全量训练统计量映射,因为它本来就不参与训练。
4. LightGBM 建模与验证:为什么按时间切比 StratifiedKFold 低 0.04
到这一步表已经造好了,剩下的是验证方案和参数。很多人在这里会经历一次心理落差:同一份特征,随机 K 折能跑到 0.94,按时间切只有 0.90,于是回头去改切分方式。这个 0.04 的差距不是模型变差了,而是之前那个数字本来就是假的。
4.1 两种切分的本质差别
StratifiedKFold保证每折的正负样本比例一致,这在类别不平衡场景下是标准做法,但它有一个隐含前提:样本之间独立同分布。欺诈检测的数据恰恰不满足这条——同一个 UID 会有多笔交易,时间上相邻的交易共享同一批行为模式。随机切分会让同一个 UID 的交易同时出现在训练折和验证折里,模型实际上是在“认人”而不是“认模式”,指标自然好看。
按时间切则强制模型面对“没见过的新客户”,这才是线上真实的处境。两种方案的差距在这里有一个可量化的观察:如果随机 K 折的折间 AUC 标准差小于 0.002,而按时间切的单次评估波动能到 0.01,说明数据里的 UID 复用程度很高,随机切分的乐观偏差很大。
我的做法是两个都跑,但只用时间切的结果做决策,随机 K 折的结果只用来判断特征是否稳定(折间方差大说明特征对样本敏感)。如果团队里有人拿随机 K 折的数字去汇报,那基本等于给自己埋雷。
4.2 参数配置与早停
这套数据的特点是高维、稀疏、类别多、正样本少,参数上要往“抗过拟合”方向调,而不是往“拟合能力”方向堆。
import lightgbm as lgb from sklearn.metrics import roc_auc_score feature_cols = [c for c in df.columns if c not in ("isFraud", "TransactionID", "TransactionDT", "uid")] X_tr, y_tr = train[feature_cols], train["isFraud"] X_va, y_va = valid[feature_cols], valid["isFraud"] params = dict( objective="binary", metric="auc", boosting_type="gbdt", learning_rate=0.03, # 配合早停,学习率压低换更稳的树 num_leaves=128, # 400 列特征,叶子数不要一口气拉到 1024 min_child_samples=200, # 正样本稀少,叶子最小样本数必须抬高 feature_fraction=0.25, # 每棵树只用 1/4 特征,抗高维共线性 bagging_fraction=0.8, bagging_freq=1, lambda_l1=0.5, lambda_l2=5.0, n_jobs=-1, verbose=-1, ) dtr = lgb.Dataset(X_tr, y_tr) dva = lgb.Dataset(X_va, y_va, reference=dtr) model = lgb.train( params, dtr, num_boost_round=5000, valid_sets=[dva], callbacks=[lgb.early_stopping(200), lgb.log_evaluation(200)], ) print("验证集 AUC", round(roc_auc_score(y_va, model.predict(X_va)), 5))几个参数值得单独说。min_child_samples=200:欺诈率 3.5% 意味着 200 条样本里平均只有 7 个正样本,再往下压,叶子里的正样本就只剩个位数,模型会开始记噪声。这个值我一般按“验证集正样本数的千分之一”来估,量级对得上就行。
feature_fraction=0.25是这套数据的关键。近 400 列里,V 系列占了 300 多列且彼此高度相关,不降采样的话每棵树都会反复用同一批强特征,模型方差很大。压到 0.25 之后,每棵树只看 100 列左右,树与树之间差异变大,bagging 才真正起作用。
num_boost_round=5000配合early_stopping(200)是标配,别手动指定一个固定轮数。早停的 patience 设 200 是因为学习率压到了 0.03,AUC 曲线的抖动周期大概在 100 轮量级,patience 太小会过早停在一个局部平台。
还有一点:不要用scale_pos_weight去“平衡”样本。它对 AUC 这个排序指标几乎没有影响,但会把输出概率整体抬高,后面选阈值时全部对不上。样本不平衡的问题交给min_child_samples和is_unbalance=False的默认设置就够了。
4.3 从 AUC 到能落地的阈值
AUC 是排序指标,它告诉你模型把欺诈排在前面,但不告诉你在哪个点拦截。真实业务里必须落到“拦多少、漏多少、错杀多少”这三个数上,因为有审核成本。
p_va = model.predict(X_va) # 成本矩阵:错杀一笔(误拦正常交易)5 元,漏判一笔 800 元 COST_FP, COST_FN = 5, 800 rows = [] for t in np.arange(0.05, 0.95, 0.05): flag = (p_va >= t).astype(int) fp = int(((flag == 1) & (y_va.values == 0)).sum()) fn = int(((flag == 0) & (y_va.values == 1)).sum()) rows.append({ "threshold": round(float(t), 2), "拦截量": int(flag.sum()), "误拦": fp, "漏判": fn, "总成本": fp * COST_FP + fn * COST_FN, }) result = pd.DataFrame(rows).sort_values("总成本").head(5) print(result.to_string(index=False))COST_FP和COST_FN这两个数必须来自业务口径,不能拍脑袋。5 元和 800 元的比例是 1:160,这个量级下最优阈值通常会很低(0.1~0.2 区间),因为漏一笔的代价太大,宁可多拦。如果实际业务里误拦会引发客诉和流失,那这个比例可能只有 1:20,最优阈值会往上走到 0.4 附近。
我习惯把这个成本表按时间段分组各算一遍。如果最优阈值在不同时间段之间跳动超过 0.1,说明模型输出的概率分布不稳定,这时候别急着上线,回去查特征漂移。
5. 避坑与排查:五个把离线 AUC 刷高、上线就失效的坑
下面这五条全部来自实际复现时的翻车现场,每条都按“现象 → 原因 → 解决”写。排名不分先后,但前两条的杀伤力最大。
5.1 泄漏类:指标虚高的三个源头
坑一:用全量数据做聚合和目标编码。
现象:随机 K 折 AUC 稳定在 0.95 以上,折间标准差不到 0.001,换到时间切分的验证集直接掉 0.05。
原因:df.groupby("uid").agg(...)这类写法默认对整张表统计,验证集每一行的聚合值里都包含了它自己和它未来交易的信息。目标编码更严重,每行都用自身标签参与了统计。
解决:所有统计量必须来自“当前行之前”的数据。频次编码和聚合统计只用训练窗口算,然后 map 到验证集;目标编码用折外生成。判断方法很简单——把特征工程代码里所有的df换成train再跑一遍,如果指标下降,说明原来的写法确实在泄漏。
坑二:随机切分让同一 UID 跨折。
现象:五折 AUC 分别是 0.9412、0.9408、0.9415、0.9411、0.9409,整齐得不像话;但线上跑一周,指标对不上。
原因:同一个 UID 的多笔交易被随机分到了不同折,模型在验证折里见到的是训练折里见过的客户。欺诈检测的核心难点就是识别新客户,这样切等于把难点绕过去了。
解决:要么用GroupKFold(groups=uid),要么直接按时间切。做竞赛时可以两者都报,但做业务必须按时间切。想量化偏差有多大,可以算一下“验证集里 UID 在训练集中出现过的比例”,这个数超过 60% 就说明随机切分的结论不可用。
坑三:用测试集统计频次。
现象:本地跑得好好的,换一批数据重跑,map()之后整列变成 NaN。
原因:vc = df[c].value_counts()里的df包含了测试集,训练时用了未来数据的统计口径,换环境后测试集没了,映射自然全空。
解决:统计量只从训练数据来,并且把value_counts()的结果序列化存下来,线上推理时加载同一份映射表。这一点在资源提供的 Notebook 里通常不会写,因为竞赛场景下测试集是固定的,但搬进生产就必须补。
5.2 资源类:跑不完与跑不对
坑四:合并后行数膨胀。
现象:merge之后len(df)比原表多了几千行,模型照跑不误,AUC 还微微涨了一点。
原因:身份表里 TransactionID 存在重复记录,how="left"时一对多会把主表行放大,被复制的交易在训练中权重翻倍。
解决:合并前先tr_idt = tr_idt.drop_duplicates("TransactionID"),合并后加一句assert len(df) == len(tr_txn)。这个断言值五分钱,能省三天排查时间。
坑五:NaN 统一填 0 导致信号丢失。
现象:做完fillna(0)之后,特征重要性榜单上has_identity从第三名掉到二十名开外,AUC 掉了 0.01 多。
原因:身份表那 300 多列在八成交易上是空的,“空”本身就是最强的信号之一——没有设备信息的交易和有的交易,欺诈率差异明显。统一填 0 之后,这个信息被抹平成了“设备型号为 0”,树模型很难再切出来。
解决:区分“结构性缺失”和“数值缺失”。结构性缺失保留 NaN,或者单独建has_xxx标志位;只有数值型特征里少量缺失才考虑填充。判断标准是看某列的缺失率和标签的相关性,如果缺失本身和isFraud有明显关联,就绝不能填。
另外还有一条不算坑但经常被忽略:TransactionDT不要试图还原成真实日期。它只是一个相对偏移,任何“加一个基准时间戳”的做法都会引入一个无意义的绝对时间,模型可能学到年份、月份这种和业务无关的东西。
5.3 排查顺序:指标异常时先看什么
拿到一个不对劲的指标,我的排查顺序是固定的四步,避免东一榔头西一棒子。
第一步看切分。打印训练集和验证集的时间范围、样本量、欺诈率,三个数对不上就先修切分,别往下走。
第二步看特征分布。挑十个最重要的特征,对比训练集和验证集的均值、分位数、缺失率。任何一项差异超过 20%,回去查这个特征的生成逻辑,八成是泄漏或者口径不一致。
第三步看单特征 AUC。把每个特征单独拿去算 AUC,如果某个特征单独就能到 0.85 以上,几乎可以断定是泄漏——真实业务里没有哪个单特征有这么强的判别力。
第四步才是调参。前三步都干净了,参数的影响通常只有 0.005 量级,不值得花太多时间。
6. 上线前的一道校验:特征稳定性巡检与一键复现脚本
模型训练完只是半成品,真正决定它能不能用的,是下一批数据进来时特征分布有没有漂。这一步在竞赛里不需要,在业务里是刚需。我习惯用 PSI(群体稳定性指数)做巡检,它对分箱不敏感、对量纲无要求,跑起来也快。
def psi(base: np.ndarray, cur: np.ndarray, bins: int = 10) -> float: """PSI:以训练集分位点为箱边界,比较两批数据的分布差异 <0.1 稳定;0.1~0.25 观察;>0.25 需要重训或排查上游""" qs = np.quantile(base[~np.isnan(base)], np.linspace(0, 1, bins + 1)) qs[0], qs[-1] = -np.inf, np.inf # 空箱用 1e-6 兜底,避免 log(0) b = np.clip(np.histogram(base, bins=qs)[0] / len(base), 1e-6, None) c = np.clip(np.histogram(cur, bins=qs)[0] / len(cur), 1e-6, None) return float(np.sum((c - b) * np.log(c / b))) report = [] for col in feature_cols: if train[col].dtype.name in ("float32", "float64", "int32", "int16", "int8"): report.append((col, round(psi(train[col].values, valid[col].values), 4))) drift = pd.DataFrame(report, columns=["feature", "psi"]).sort_values("psi", ascending=False) print(drift.head(15).to_string(index=False))这段代码有两个设计点。箱边界取自训练集的分位点而不是等宽分箱,因为金额、时间差这类特征长尾极重,等宽分箱会让 90% 的样本挤在第一个箱里,PSI 永远接近 0,等于没测。clip(1e-6, None)是为了处理空箱,某一段在验证集里一个样本都没有时,比例是 0,log(0)会直接给出-inf,把整列 PSI 毁掉。
阈值上,0.1 和 0.25 是行业里用得比较多的两档,但具体要看你特征的业务容忍度。像TransactionAmT这种受促销活动影响的特征,0.15 也算正常;像uid_freq这种反映客户结构的特征,超过 0.1 就该查是不是上游的埋点逻辑改了。我一般的做法是给每个特征单独配阈值,把配置写进一个 YAML,巡检脚本读配置出报告,而不是全局一个数。
复现整套流程时,环境这一关也经常卡人。这份资源是 Jupyter Notebook,依赖不算多但版本敏感,尤其是 pandas 2.x 之后groupby的默认行为和 1.x 有差异,LightGBM 的verbose参数也在不同版本间改过名。我的习惯是锁版本 + 无头执行,避免出现“我这儿能跑”的扯皮。
#!/usr/bin/env bash set -euo pipefail python -m venv .venv source .venv/bin/activate # 锁大版本,避免 pandas 1.x/2.x 的 groupby 行为差异 pip install -q "pandas>=2.0,<3.0" "numpy>=1.24,<2.0" \ "lightgbm>=4.0,<5.0" "scikit-learn>=1.3,<2.0" jupyter # 无头执行整本 Notebook,超时设 2 小时,输出到新文件保留原始版本 jupyter nbconvert --to notebook --execute fraud_pipeline.ipynb \ --output fraud_pipeline_out.ipynb \ --ExecutePreprocessor.timeout=7200 \ --ExecutePreprocessor.kernel_name=python3set -euo pipefail这三个开关在流水线脚本里基本是必须的:-e让任何一步失败立即中断,避免在半成品环境上继续装包;-u拦住未定义变量,防止$DATA写错却静默通过;pipefail让管道中间环节的失败也能被捕获。--ExecutePreprocessor.timeout=7200是给特征工程留的余量,59 万行做几轮 groupby 聚合,慢机器上一轮就要十几分钟。输出到_out.ipynb而不是覆盖原文件,是为了保留一份“干净版”,方便对比改动前后的输出单元格差异。
再补一个我自己的习惯:跑完之后不只看最终 AUC,还会把model.feature_importance("gain")的前三十个特征导出来存成 CSV,和上一版做 diff。新增特征如果挤掉了某个老特征的位置,要确认这是预期内的替换;如果某个特征的 gain 突然翻了三倍,八成是它和标签之间出现了不该有的关联,回头查泄漏。
这套流程我在几个不同规模的数据集上重跑过,最耗时的从来不是建模,而是第 2 章的数据拼装和第 5 章的泄漏排查。从那以后我每次把这类 Notebook 搬进生产前,都会强制走一遍“按时间切 → 单特征 AUC → PSI 巡检”这三道关,任何一道不过就直接打回,不再抱着“先上线看看”的侥幸心理。希望帮到你。
本文还有配套的精品资源,点击获取