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_col='TransactionID') train_id = pd.read_csv('data/train_identity.csv', index_col='TransactionID') # how='left' 是关键:身份表覆盖率不足 100%,用 inner 会直接丢掉四分之三的样本 df = train_tr.merge(train_id, left_index=True, right_index=True, how='left') print(df.shape) # (行数, 交易表列数 + 身份表列数 - 1) print(train_id.shape[0] / train_tr.shape[0]) # 身份表覆盖率,通常在 0.2~0.25 之间 print(df['isFraud'].mean()) # 正样本比例,通常 0.035 上下index_col='TransactionID'这一步很值:如果不用索引而用普通列 merge,一旦身份表里同一个TransactionID出现重复行,结果行数会翻倍,你会在训练到一半才发现样本量对不上。how='left'之后的覆盖率打印不是装饰,它是后面判断「身份类特征该怎么补缺失」的依据——覆盖率两成多,意味着任何依赖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(drop=True) # 周期性:一天中的第几小时、一周中的第几天 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、card1+addr1、card1+card2+addr1+P_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(dropna=False) df[f'{col}_freq'] = df[col].map(vc).astype('float32') # 低基数类别列转 category,LightGBM 可以直接吃,不需要 one-hot cat_cols = ['ProductCD', 'card4', 'card6', 'M1', 'M2', 'M3'] for col in cat_cols: df[col] = df[col].astype('category')value_counts(dropna=False)里的dropna=False别省,否则缺失值那一档不会被统计,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_leaf=100是我踩过坑之后固定下来的值:默认 20 的时候,模型会疯狂在那些只在几十条样本里出现的 uid 上切分,训练 AUC 冲到 0.99,验证集纹丝不动。
feature_fraction=0.6这个设置针对的是强特征垄断问题。V 系列里有几个匿名列和标签相关性极高,不采样的话每棵树都从它们开始分裂,模型退化成「只看三列」,其余几百列白做。
lambda_l1/l2各给 0.1 是保守起步,真要调的话先动num_leaves和min_data_in_leaf,这两个对结果的影响比正则项大得多。
4.2 训练与验证:AUC 之外必须盯住 PR-AUC
AUC 是这个任务的通用指标,但它在极端不平衡数据上会骗人——负样本太多,假阳性率稍微涨一点,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], label=train['isFraud']) dvalid = lgb.Dataset(valid[feature_cols], label=valid['isFraud'], reference=dtrain) model = lgb.train( params, dtrain, num_boost_round=3000, 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)reference=dtrain不能省,它把训练集的 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(f'th={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', index=False) 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,类别列转category:
dtypes = {c: 'float32' for c in numeric_like_cols} dtypes.update({c: 'category' for c in category_cols}) df = pd.read_csv(path, index_col='TransactionID', dtype=dtypes)float32的精度损失对树模型没有影响,分箱只看顺序。这一招能把内存从二十多 G 压到七八 G,笔记本也能跑。
现象:merge 之后行数比交易表多出几万行。原因是身份表里同一个TransactionID出现了重复记录,左连接时一对多把行数撑开了。解决分两步:merge 前对身份表做drop_duplicates(subset='TransactionID'),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 和特征列顺序断言,哪怕只有两行代码,也一定写进去。这两行救过我的次数,比任何调参技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取