LightGBM欺诈检测实战:时间切分、特征工程与数据泄漏避坑 简介这份 IEEE-CIS 欺诈检测资源包面向希望系统理解 Kaggle 二分类赛题流程的数据分析与机器学习学习者核心围绕欺诈行为识别中的探索性分析与特征处理展开。压缩包共 6 个文件约 1.02MB以 3 个 Python 脚本、1 个 Jupyter Notebook 为主辅以 Markdown 说明和忽略配置脚本分别承担数据清洗、可视化报告与特征工程等可复用功能。资源中深入拆解了数据集结构、变量分布与异常模式并将分析过程沉淀为 Notebook便于读者对照复现 EDA 思路理解从原始数据检查到建模前准备的完整链路。已有 599 人学习适合需要参考赛题分析框架、积累特征工程与报告展示经验的中级读者。1. 一套跑了三百万行交易的风控 notebook到底在做什么拿到一份陌生的欺诈检测 notebook最怕的不是报错是它一声不吭地跑完了你还不知道它凭什么给出那个概率。这套项目就是典型几百万行交易流水配一张只有两成多覆盖率身份表正样本比例低到个位数模型本身一句话能讲完剩下八成工作量全在数据侧。它适合两类人手里已经有「行为表 实体表」这种结构、想做二分类风险识别却不想从零搭特征管线的开发者以及习惯在 Jupyter Notebook 里做探索、最后要落成能重复跑的脚本的人。下面按我拆解的先后顺序写能照着跑也能照着改。2. 数据解构交易表和身份表怎么拼成一张能训练的表2.1 目录结构与文件职责这类项目在仓库里通常长这样交易表和身份表是分开的两个 CSV训练和测试各一套一共四个文件。别小看这个结构很多人第一次跑挂掉就是因为只拷了train_transaction.csv忘了身份表。fraud-detection/ ├── data/ │ ├── train_transaction.csv # 训练集交易主表每行一笔交易 │ ├── train_identity.csv # 训练集身份表覆盖率约 24% │ ├── test_transaction.csv # 测试集交易主表 │ └── test_identity.csv # 测试集身份表 ├── notebooks/ │ ├── 01_eda.ipynb # 分布、缺失率、正负样本对比 │ ├── 02_feature.ipynb # 特征构造产出中间 parquet │ └── 03_lgbm.ipynb # 训练 验证 生成提交文件 ├── src/ │ ├── features.py # 从 notebook 抽出来的特征函数 │ └── train.py # 可重复执行的训练入口 └── requirements.txtnotebooks和src并存是这类项目的常态前者是探索现场后者是沉淀结果。我一般要求自己任何一个在 notebook 里验证有效的特征函数当天就搬进features.py否则过两周回来那段单元格早就被覆盖得面目全非了。中间产物建议存成 parquet 而不是 CSV几百列 float 存 CSV 慢得让人怀疑人生parquet 带类型、体积小、读回来不用再猜 dtype。requirements.txt里核心就三个pandas、numpy、lightgbm加上scikit-learn算指标。版本上没必要锁太死但 pandas 2.x 和 1.x 在 groupby 的observed默认值上有差异类别列聚合结果会不一样团队协作时最好把大版本写清。2.2 字段地图哪些列能进模型哪些列只能当索引先看两张表怎么拼。TransactionID是两表唯一能对齐的键交易表里它是主键身份表里它是外键而且不是每笔交易都有身份记录。所以只能用左连接以交易表为准。import pandas as pd import numpy as np # TransactionID 直接作为索引读进来后面 merge 用 index 对齐避免多出一列 train_tr pd.read_csv(data/train_transaction.csv, index_colTransactionID) train_id pd.read_csv(data/train_identity.csv, index_colTransactionID) # howleft 是关键身份表覆盖率不足 100%用 inner 会直接丢掉四分之三的样本 df train_tr.merge(train_id, left_indexTrue, right_indexTrue, howleft) print(df.shape) # (行数, 交易表列数 身份表列数 - 1) print(train_id.shape[0] / train_tr.shape[0]) # 身份表覆盖率通常在 0.2~0.25 之间 print(df[isFraud].mean()) # 正样本比例通常 0.035 上下index_colTransactionID这一步很值如果不用索引而用普通列 merge一旦身份表里同一个TransactionID出现重复行结果行数会翻倍你会在训练到一半才发现样本量对不上。howleft之后的覆盖率打印不是装饰它是后面判断「身份类特征该怎么补缺失」的依据——覆盖率两成多意味着任何依赖id_xx的特征在八成样本上都是空值树模型能忍但你得知道自己在喂什么。字段可以按语义分成几组处理策略完全不同字段组典型列类型处理方式金额与产品TransactionAmt、ProductCD数值 / 低基数类别直接入模金额取 log 更稳卡与地址card1~card6、addr1、addr2高基数类别频率编码 作为 UID 构件计数类C1~C14数值直接入模注意缺失时间差类D1~D15数值天直接入模负值有意义匹配标志M1~M9三态类别T/F/NaN转 0/1/缺失三类匿名数值V1~V339数值大量缺失直接入模交给树模型身份表字段id_01~id_38、DeviceInfo数值 / 高基数类别缺失单独成一类ProductCD、card4、card6、M1~M9这种低基数类别列转成 pandas 的categorydtype 交给 LightGBM 自己处理就行DeviceInfo、P_emaildomain这种几百上千个取值的硬做 one-hot 会把特征维度撑到几千稀疏得没法看用频率编码更划算。2.3 时间切分为什么随机 KFold 在这份数据上一定会骗你这是整套流程里最容易被忽略、后果最严重的一步。TransactionDT是一个从某个起点开始累计的秒数偏移不是真实时间戳但它保留了完整的时间先后顺序。欺诈手法的分布会随时间漂移——某段时间某个卡段被集中攻击过一阵又换一批。如果你用随机 KFold验证集里混进了「未来」的样本模型等于提前见过答案本地 AUC 漂亮得不像话上线就现原形。我的做法是按时间切一刀前 80% 训练、后 20% 验证切完之后再在训练段内部做时间序列 CV。# 先按时间排序重置索引但保留原 TransactionID 到列里 df df.sort_values(TransactionDT).reset_index() cut int(len(df) * 0.8) train df.iloc[:cut].copy() valid df.iloc[cut:].copy() # 确认切分点两侧的时间不重叠这一步别省 assert train[TransactionDT].max() valid[TransactionDT].min() print(train[isFraud].mean(), valid[isFraud].mean())assert那行看着多余实际上救过我两次有一次我在前面的单元格里对df做过一次无序的sample排序被冲掉了切分点两侧时间交叉本地指标虚高 0.03。两段的正样本率也值得打印如果验证段的正样本率是训练段的五六倍说明数据分布已经变了这时候讨论模型结构没意义先去看业务上那段时间发生了什么。提示时间切分会让你损失一部分「未来信息」本地指标通常比随机 KFold 低 1~3 个点这不是模型变差了是之前那个数字本来就不真。3. 特征工程从 TransactionDT 到 UID 聚合的四类特征3.1 时间差特征把秒级偏移翻译成行为节奏原始TransactionDT对树模型几乎没用因为它是个单调递增的数模型只会学到「后面的样本更可能是欺诈」这恰恰是最危险的时间泄漏。要把它拆成周期性和间隔性两类特征。df df.sort_values(TransactionDT).reset_index(dropTrue) # 周期性一天中的第几小时、一周中的第几天 df[hour] (df[TransactionDT] // 3600) % 24 df[dow] (df[TransactionDT] // 86400) % 7 # 间隔性同一张卡相邻两笔交易的时间差这是强特征 df[card1_diff] df.groupby(card1)[TransactionDT].diff() df[card1_diff] df[card1_diff].fillna(-1) # 首次交易单独标记 # 同一张卡在历史上出现过多少次累计计数只算过去不算未来 df[card1_cumcnt] df.groupby(card1).cumcount()% 24和% 7是把绝对偏移映射到周期上// 3600先转成小时。真实数据里起点不一定是零点严格来说要先减去最小偏移再取模但树模型对整体平移不敏感这个近似可以接受。groupby(...).diff()必须在排序之后执行否则拿到的是文件顺序上的差值毫无意义。card1_diff描述的是「这张卡多久没动了」欺诈团伙集中刷卡时这个值往往很小属于典型的强特征血泪经验是别急着上复杂模型先把这类间隔特征做出来AUC 就能涨一截。cumcount()比transform(count)更值得用因为它是累计到当前行为止的数量不带未来信息。这个区别在离线评估里看不出来上线之后才知道后悔药没处买。3.2 UID 构造与聚合让单条交易带上「这个人的历史」单笔交易本身信息有限真正有价值的是「这个实体过去干过什么」。这类数据里没有真实用户 ID只能靠卡号、地址、邮箱拼一个伪实体出来。# 拼一个伪用户 ID用卡号 地址空值统一成字符串 nan 避免拼接报错 df[uid] (df[card1].astype(str) _ df[card2].astype(str) _ df[addr1].astype(str)) # transform 会把聚合结果按原索引广播回每一行保持行数不变 grp df.groupby(uid)[TransactionAmt] df[uid_amt_mean] grp.transform(mean) df[uid_amt_std] grp.transform(std) df[uid_cnt] grp.transform(count) # 相对量往往比绝对量更有区分度这笔金额偏离该用户均值多少倍 df[amt_ratio] df[TransactionAmt] / (df[uid_amt_mean] 1)transform和agg的差别在这里是决定性的agg返回的是每个 uid 一行的结果要回到原表还得再 merge 一次行数一多就慢得离谱transform直接按原索引广播一步到位。uid_cnt是这块里最容易被低估的特征——低频实体的欺诈率显著更高因为团伙作案往往是一次性的。但也别把它当银弹下面第 5 章会讲这类聚合特征是最容易踩泄漏坑的地方。分母那个1是防除零不是数学上的严谨做法纯粹是为了让inf别污染特征分布。uid的构造方式值得多试几组只用card1、card1addr1、card1card2addr1P_emaildomain粒度和覆盖率都不一样。粒度越细聚合越精确但覆盖率越低、越容易过拟合。我一般保留两到三个不同粒度的 uid 各做一套聚合让模型自己挑。3.3 频率编码与类别列处理高基数特征的省事解法高基数类别列card1有上万取值addr1几千个不能 one-hot目标编码又容易泄漏频率编码是性价比最高的折中。freq_cols [card1, card2, card3, card5, addr1, P_emaildomain, R_emaildomain, DeviceInfo] for col in freq_cols: # value_counts 统计每个取值出现次数map 回原列就是「这个值有多常见」 vc df[col].value_counts(dropnaFalse) df[f{col}_freq] df[col].map(vc).astype(float32) # 低基数类别列转 categoryLightGBM 可以直接吃不需要 one-hot cat_cols [ProductCD, card4, card6, M1, M2, M3] for col in cat_cols: df[col] df[col].astype(category)value_counts(dropnaFalse)里的dropnaFalse别省否则缺失值那一档不会被统计map之后整列缺失比例会突然变大排查起来很费劲。.astype(float32)是内存优化频率值本来就不需要双精度几百个特征列省下来的内存很可观。M1~M9这几个三态列有个细节它们的取值是T/F/ 缺失而缺失本身是有信息量的说明匹配字段没拿到。直接转 category 会把缺失也当成一类这是对的但如果你先fillna(unknown)再转语义一样只是类别名更好读。提示频率编码统计的是全量数据训练测试的分布这在正式比赛里是允许的因为用不到标签。但如果你要做严格的线上模拟频率也只能在训练段上统计再 map 到验证段没出现过的值给 0。4. 模型训练LightGBM 参数、早停与提交文件的生成4.1 参数配置不平衡二分类的起点正样本只有 3.5% 左右这个比例下不建议一上来就scale_pos_weight或者过采样先把基准跑出来再说。LightGBM 对不平衡的容忍度比想象中高auc作为优化目标时它关心的是排序不是绝对概率。import lightgbm as lgb params { objective: binary, # 二分类输出概率 metric: auc, # 训练过程中监控的指标 boosting_type: gbdt, learning_rate: 0.03, # 小学习率 多轮数泛化更稳 num_leaves: 256, # 叶子数上限受 max_depth 约束 max_depth: -1, # -1 表示不限制由 num_leaves 控制复杂度 min_data_in_leaf: 100, # 叶子最小样本数防过拟合的关键参数 feature_fraction: 0.6, # 每棵树随机用 60% 特征抑制强特征垄断 bagging_fraction: 0.8, # 行采样比例 bagging_freq: 1, lambda_l1: 0.1, lambda_l2: 0.1, num_threads: 8, verbose: -1, }learning_rate给 0.03 而不是默认的 0.1是因为这份数据特征维度大几百列 V 系列学习率大容易在前几百棵树就把训练集拟合穿。min_data_in_leaf100是我踩过坑之后固定下来的值默认 20 的时候模型会疯狂在那些只在几十条样本里出现的 uid 上切分训练 AUC 冲到 0.99验证集纹丝不动。feature_fraction0.6这个设置针对的是强特征垄断问题。V 系列里有几个匿名列和标签相关性极高不采样的话每棵树都从它们开始分裂模型退化成「只看三列」其余几百列白做。lambda_l1/l2各给 0.1 是保守起步真要调的话先动num_leaves和min_data_in_leaf这两个对结果的影响比正则项大得多。4.2 训练与验证AUC 之外必须盯住 PR-AUCAUC 是这个任务的通用指标但它在极端不平衡数据上会骗人——负样本太多假阳性率稍微涨一点AUC 几乎不动。真正决定业务效果的是「在固定精确率下能召回多少欺诈」所以 PR-AUC平均精确率必须一起看。from sklearn.metrics import roc_auc_score, average_precision_score feature_cols [c for c in train.columns if c not in (isFraud, TransactionDT, uid)] # uid 是字符串不能入模TransactionDT 会让模型学到时间趋势必须剔除 dtrain lgb.Dataset(train[feature_cols], labeltrain[isFraud]) dvalid lgb.Dataset(valid[feature_cols], labelvalid[isFraud], referencedtrain) model lgb.train( params, dtrain, num_boost_round3000, valid_sets[dvalid], callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)], ) pred model.predict(valid[feature_cols]) print(AUC :, roc_auc_score(valid[isFraud], pred)) print(AP :, average_precision_score(valid[isFraud], pred)) print(best_iteration:, model.best_iteration)referencedtrain不能省它把训练集的 bin 边界传给验证集保证两边分箱口径一致不加的话 LightGBM 会自己重新分箱验证指标会有轻微偏移。early_stopping(100)是连续 100 轮验证指标不涨就停配合 0.03 的学习率实际跑下来通常停在 800~1500 轮之间。feature_cols那个排除列表是重点uid是字符串不能直接喂TransactionDT必须扔——留着它模型会学到一个「越靠后的交易越可能是欺诈」的伪规律验证集时间在后指标会虚高得离谱这是这份数据上最经典的一个陷阱。AP的绝对值一般在 0.4~0.6 之间比 AUC 那种 0.9 上下的数字难看得多但它更接近真实业务感受。两个指标一起看AUC 涨了但 AP 没动说明模型只是把负样本排得更靠后了对抓欺诈没帮助。4.3 阈值选择与提交0.5 从来不是答案模型输出的是概率业务要的是「抓还是不抓」。3.5% 的正样本率决定了 0.5 这个阈值几乎抓不到东西得往下压。# 在验证集上扫一遍阈值看不同召回下的精确率 for th in [0.1, 0.2, 0.3, 0.4, 0.5]: flag pred th tp ((flag 1) (valid[isFraud] 1)).sum() fp ((flag 1) (valid[isFraud] 0)).sum() recall tp / valid[isFraud].sum() precision tp / max(tp fp, 1) print(fth{th:.1f} recall{recall:.3f} precision{precision:.3f})这段输出是给业务方看的不是给模型调参用的。人工审核有成本一笔误判的工单成本大概是多少、一笔漏判的损失是多少这个比例决定了阈值该落在哪。多数情况下 0.2 附近是个平衡点召回能到七成以上精确率还不至于让人工团队崩掉。提交文件的生成反而是最容易翻车的一步行数、顺序、ID 对不上平台直接判格式错误。# 测试集要和训练集走完全相同的特征流程列顺序必须一致 test_pred model.predict(test[feature_cols]) submission pd.DataFrame({ TransactionID: test[TransactionID].values, isFraud: test_pred, }) submission.to_csv(submission.csv, indexFalse) print(submission.shape, submission[isFraud].isna().sum())test[TransactionID].values取的是原始 ID不是重置后的行号这个区别在中间做过排序、采样之后尤其致命。最后那行打印是自检形状对不上的话八成是特征列没对齐isna().sum()不为 0 的话说明有特征在测试集上算不出来比如某个 uid 只在测试集出现需要回特征环节补默认值。5. 避坑与排查六条血泪记录5.1 读取与合并阶段的坑现象notebook 一打开就内核崩溃或者读 CSV 卡住十几分钟。原因基本是默认 dtype 惹的祸——几百列 float 全按float64读内存占用直接翻倍加上 pandas 读 CSV 时会先按object存一遍再推断类型峰值内存能到文件体积的五六倍。解决办法是显式指定 dtype把用得上的数值列压到float32类别列转categorydtypes {c: float32 for c in numeric_like_cols} dtypes.update({c: category for c in category_cols}) df pd.read_csv(path, index_colTransactionID, dtypedtypes)float32的精度损失对树模型没有影响分箱只看顺序。这一招能把内存从二十多 G 压到七八 G笔记本也能跑。现象merge 之后行数比交易表多出几万行。原因是身份表里同一个TransactionID出现了重复记录左连接时一对多把行数撑开了。解决分两步merge 前对身份表做drop_duplicates(subsetTransactionID)merge 后用assert len(df) len(train_tr)卡一道。这个断言我现在的模板里是默认带的因为行数错了不会报错只会在训练出一个莫名其妙的模型之后才被发现。现象card1_diff、uid_cnt这类特征重要性排第一但线上效果一塌糊涂。原因是聚合统计用了全量数据——包括验证段和测试段。离线时模型等于偷看了未来线上没有未来可偷。正确做法是在训练段上算聚合值再map到验证段没见过的 key 给全局默认值。这个改动会掉指标但掉下去的才是真的。5.2 验证与提交阶段的坑现象本地 AUC 0.97线上只有 0.90。十有八九是随机 KFold 造成的时间泄漏验证集里混了未来样本。改法就是第 2.3 节那套按TransactionDT排序后切尾段切完用assert校验两侧时间不重叠。现象训练指标一路涨验证指标从第 200 轮开始横盘然后下滑。这是过拟合的典型形态但根因不一定是模型太复杂更常见的是min_data_in_leaf太小加上高基数 uid 聚合特征。先把min_data_in_leaf从默认值拉到 100 以上再把feature_fraction降到 0.6通常能压住。现象提交文件行数正确但评分极低接近随机。检查测试集的特征列顺序和训练集是否完全一致。model.predict接收的是 numpy 数组它不认列名列顺序错了照样能算出结果只是全是噪声。稳妥写法是训练时把feature_cols存成一个列表落盘预测时严格按这个列表取列cols json.load(open(feature_cols.json)) test_pred model.predict(test[cols]) assert list(test[cols].columns) cols现象isFraud提交值里出现了 NaN。说明某些特征在测试集上无法计算比如某个 uid 在训练段从没出现过uid_amt_mean就是空。解决是在特征函数里给所有聚合结果统一fillna(-1)并在提交前打一次空值统计——宁可填个哨兵值也别让 NaN 混进提交文件。6. 二次开发把 notebook 拆成能上线的特征管线notebook 跑通只是及格线真要拿去用第一步是把它拆成三段特征计算、训练、推理各自独立可执行。我一般的拆法是features.py里放纯函数输入是原始 DataFrame输出是加工后的 DataFrame不碰任何全局变量train.py只负责读数据、调函数、训练、存模型inference.py加载模型和feature_cols.json对新数据走完全相同的函数。关键在于特征函数必须无状态——任何依赖「全量数据统计」的操作都得显式传参进去否则离线训练和线上推理用的分布不一样这是最隐蔽的一类事故。第二步是替换验证策略。把单次时间切分换成滚动窗口用第 1~3 个月训练、第 4 个月验证再往后滑一格重复四五次看指标的方差而不只是均值。方差大的特征组合线上一定不稳。第三步是特征升级。把现在这种「全量聚合」改成「截至当前交易的滚动聚合」用groupby(...).expanding().mean()之类的方式算每个时间点只用过去的数据这样离线评估的数字才有参考价值。代价是计算慢通常要配合按天分区的增量计算。改造方向具体做法预期收益结构拆分features / train / inference 三段分离可复用、可回归测试验证升级滚动窗口 CV 替代单次切分指标方差可量化特征升级expanding 聚合替代全量聚合离线在线一致监控对 TOP20 特征算 PSI提前发现分布漂移第四步是加监控。上线不是终点欺诈手法会变特征分布会漂。挑重要性前 20 的特征每周算一次 PSI超过 0.2 就报警比等业务方反馈「最近拦得太多了」要早两周。从那以后我每次把 notebook 转成脚本都强制先跑一遍时间切分的 sanity check 和特征列顺序断言哪怕只有两行代码也一定写进去。这两行救过我的次数比任何调参技巧都多。希望帮到你。本文还有配套的精品资源点击获取