简介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 左连接合并与内存降级合并本身一行代码但有两个细节决定后面能不能跑得动。第一是连接方式主表是交易身份表是可选补充所以必须howleft用indicatorTrue顺手拿到合并结果标记转成 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, onTransactionID, howleft, indicatorTrue) df[has_identity] (df[_merge] both).astype(int8) df df.drop(columns[_merge]) df downcast(df) print(f合并后 {df.shape}占用 {df.memory_usage(deepTrue).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(dropTrue) 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_onuid, right_indexTrue, howleft) # 没见过的 uid 聚合值为空树模型能自动处理不用强行填 0 print(df.filter(likeuid_).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(dropTrue) # 同一 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, targetisFraud, k5, smooth20): 折外目标编码训练集自身用 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, dtypefloat32) skf StratifiedKFold(n_splitsk, shuffleTrue, random_state42) 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_vasmooth20是收缩强度含义是“一个类别至少要有 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( objectivebinary, metricauc, boosting_typegbdt, learning_rate0.03, # 配合早停学习率压低换更稳的树 num_leaves128, # 400 列特征叶子数不要一口气拉到 1024 min_child_samples200, # 正样本稀少叶子最小样本数必须抬高 feature_fraction0.25, # 每棵树只用 1/4 特征抗高维共线性 bagging_fraction0.8, bagging_freq1, lambda_l10.5, lambda_l25.0, n_jobs-1, verbose-1, ) dtr lgb.Dataset(X_tr, y_tr) dva lgb.Dataset(X_va, y_va, referencedtr) model lgb.train( params, dtr, num_boost_round5000, 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_samples200欺诈率 3.5% 意味着 200 条样本里平均只有 7 个正样本再往下压叶子里的正样本就只剩个位数模型会开始记噪声。这个值我一般按“验证集正样本数的千分之一”来估量级对得上就行。feature_fraction0.25是这套数据的关键。近 400 列里V 系列占了 300 多列且彼此高度相关不降采样的话每棵树都会反复用同一批强特征模型方差很大。压到 0.25 之后每棵树只看 100 列左右树与树之间差异变大bagging 才真正起作用。num_boost_round5000配合early_stopping(200)是标配别手动指定一个固定轮数。早停的 patience 设 200 是因为学习率压到了 0.03AUC 曲线的抖动周期大概在 100 轮量级patience 太小会过早停在一个局部平台。还有一点不要用scale_pos_weight去“平衡”样本。它对 AUC 这个排序指标几乎没有影响但会把输出概率整体抬高后面选阈值时全部对不上。样本不平衡的问题交给min_child_samples和is_unbalanceFalse的默认设置就够了。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(indexFalse))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(groupsuid)要么直接按时间切。做竞赛时可以两者都报但做业务必须按时间切。想量化偏差有多大可以算一下“验证集里 UID 在训练集中出现过的比例”这个数超过 60% 就说明随机切分的结论不可用。坑三用测试集统计频次。现象本地跑得好好的换一批数据重跑map()之后整列变成 NaN。原因vc df[c].value_counts()里的df包含了测试集训练时用了未来数据的统计口径换环境后测试集没了映射自然全空。解决统计量只从训练数据来并且把value_counts()的结果序列化存下来线上推理时加载同一份映射表。这一点在资源提供的 Notebook 里通常不会写因为竞赛场景下测试集是固定的但搬进生产就必须补。5.2 资源类跑不完与跑不对坑四合并后行数膨胀。现象merge之后len(df)比原表多了几千行模型照跑不误AUC 还微微涨了一点。原因身份表里 TransactionID 存在重复记录howleft时一对多会把主表行放大被复制的交易在训练中权重翻倍。解决合并前先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, binsqs)[0] / len(base), 1e-6, None) c np.clip(np.histogram(cur, binsqs)[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, ascendingFalse) print(drift.head(15).to_string(indexFalse))这段代码有两个设计点。箱边界取自训练集的分位点而不是等宽分箱因为金额、时间差这类特征长尾极重等宽分箱会让 90% 的样本挤在第一个箱里PSI 永远接近 0等于没测。clip(1e-6, None)是为了处理空箱某一段在验证集里一个样本都没有时比例是 0log(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 pandas2.0,3.0 numpy1.24,2.0 \ lightgbm4.0,5.0 scikit-learn1.3,2.0 jupyter # 无头执行整本 Notebook超时设 2 小时输出到新文件保留原始版本 jupyter nbconvert --to notebook --execute fraud_pipeline.ipynb \ --output fraud_pipeline_out.ipynb \ --ExecutePreprocessor.timeout7200 \ --ExecutePreprocessor.kernel_namepython3set -euo pipefail这三个开关在流水线脚本里基本是必须的-e让任何一步失败立即中断避免在半成品环境上继续装包-u拦住未定义变量防止$DATA写错却静默通过pipefail让管道中间环节的失败也能被捕获。--ExecutePreprocessor.timeout7200是给特征工程留的余量59 万行做几轮 groupby 聚合慢机器上一轮就要十几分钟。输出到_out.ipynb而不是覆盖原文件是为了保留一份“干净版”方便对比改动前后的输出单元格差异。再补一个我自己的习惯跑完之后不只看最终 AUC还会把model.feature_importance(gain)的前三十个特征导出来存成 CSV和上一版做 diff。新增特征如果挤掉了某个老特征的位置要确认这是预期内的替换如果某个特征的 gain 突然翻了三倍八成是它和标签之间出现了不该有的关联回头查泄漏。这套流程我在几个不同规模的数据集上重跑过最耗时的从来不是建模而是第 2 章的数据拼装和第 5 章的泄漏排查。从那以后我每次把这类 Notebook 搬进生产前都会强制走一遍“按时间切 → 单特征 AUC → PSI 巡检”这三道关任何一道不过就直接打回不再抱着“先上线看看”的侥幸心理。希望帮到你。本文还有配套的精品资源点击获取