
简介面向数据科学竞赛学习者与风控建模初学者的IEEE-CIS欺诈检测赛题EDA资源包聚焦二分类场景下用户欺诈与否的判别问题帮助读者在正式建模前完成数据理解与特征梳理。包内共6个文件以Python脚本与Jupyter Notebook为主辅以说明文档和配置项压缩包约1.02MB体积轻便易于本地运行。其中Notebook承载深入的探索性分析流程脚本文件分别覆盖数据清洗、特征工程与可视化报告等环节模块划分清晰便于按需调用或二次改造。已有599人学习适合希望借鉴成熟EDA组织方式、快速搭建赛题分析框架的读者参考也可作为风控类项目前期数据探查的实践样例。1. IEEE-CIS-Fraud-Detection一份被反复引用的风控赛题到底该怎么拆开做第一次拿到 IEEE-CIS-Fraud-Detection 这个数据集的人十有八九会先被它的表结构劝退一张交易主表、一张身份表两张表靠一个键拼起来字段名全是C1到C14、V1到V339这种匿名编码连业务含义都不给你。它来自一场公开的欺诈检测竞赛任务很直白——判断一笔线上交易是不是欺诈。但真正做过的人都知道难点从来不在模型选型而在数据本身极端不平衡、时间强相关、匿名特征无法解释、身份表大面积缺失。这篇笔记就按我实际复现这套方案的顺序把数据怎么读、特征怎么造、验证怎么切、模型怎么调、坑在哪一层层讲清楚。适合已经会写 sklearn 和 LightGBM、但还没系统做过风控建模的从业者也适合想拿这套数据练手、又不想在验证集上自欺欺人的同学。2. 先把数据读对表结构、缺失模式与时间轴2.1 两张表的关系与合并策略这套数据的主表是交易流水每行一笔交易带TransactionID、isFraud标签、金额、产品编码、卡信息等身份表是部分交易的身份补充信息带设备类型、浏览器、邮箱域名等。两张表通过TransactionID关联但身份表只覆盖了一部分交易合并后必然出现大量缺失。常见做法是左连接主表保留全部交易身份字段缺失就让它缺失不要急着填充——缺失本身在风控里就是信号很多欺诈交易恰恰是身份信息不全的。import pandas as pd train_trx pd.read_csv(train_transaction.csv) train_id pd.read_csv(train_identity.csv) # 左连接以交易为主身份信息能补则补 df train_trx.merge(train_id, onTransactionID, howleft) print(df.shape) print(df[isFraud].mean()) # 正样本比例通常不到 4% print(df[id_01].isna().mean()) # 身份表缺失比例往往超过 70%这段代码的关键在howleft。如果改成 inner join你会直接丢掉大部分交易而丢掉的很可能正是身份信息缺失的那批模型学到的分布就偏了。合并后先看两个数正样本比例和身份字段缺失率。前者决定你后面要不要做重采样后者决定身份特征能不能直接用。参数上没什么可调的但这一步的顺序不能反——先合并再看缺失而不是先填充再合并。2.2 时间列是隐藏的主键TransactionDT不是真实时间戳而是一个从某个起点开始的秒级偏移量。它不能直接当特征喂给模型但它是你做验证集切分、构造时间窗口特征、判断数据泄漏的唯一依据。我一般先把它换算成「天」看看数据覆盖了多长时间再决定按时间切还是按比例切。# 换算成天观察时间跨度 df[day] df[TransactionDT] // (24 * 60 * 60) print(df[day].min(), df[day].max()) print(df.groupby(day)[isFraud].mean().head(20))跑完你会发现两件事数据大概覆盖半年左右欺诈率在不同天之间波动明显不是均匀分布。这就意味着随机切分验证集会高估模型效果因为随机切分让模型在训练时「见过」未来时间段的模式。正确做法是按时间切用前面的时间训练、后面的时间验证模拟真实上线场景。这一步做错后面所有调参都是在优化一个虚假的指标。2.3 匿名特征的处理边界C系列和V系列特征都是匿名化的官方没给含义。C系列通常是类别型取值不多V系列是数值型很多是计数或排序类特征。对C系列我一般直接转 category 交给 LightGBM 处理不自己做 one-hot因为类别多、稀疏one-hot 会把维度撑爆。对V系列先看分布偏态严重的做 log1p 变换但不要无脑标准化——树模型对单调变换不敏感标准化对它没意义反而增加计算量。提示匿名特征不要试图反推业务含义去「讲一个故事」那是浪费时间。你要做的是让模型能利用它们的统计结构而不是解释它们。3. 特征工程金额、计数与时间窗口怎么造3.1 金额类特征的变换与聚合金额是这套数据里最有业务感的字段。原始金额分布极度右偏直接喂给模型效果一般。我一般做三件事取 log1p 压偏态、算金额与卡片历史均值的比值、按卡片聚合出消费频次和金额统计量。比值特征往往比原始金额更有区分度因为欺诈交易常常表现为「这张卡突然出现一笔远超日常的消费」。import numpy as np df[amt_log] np.log1p(df[TransactionAmt]) # 按卡片聚合该卡的历史交易次数与金额均值 card_agg df.groupby(card1)[TransactionAmt].agg([count, mean]).rename( columns{count: card_txn_cnt, mean: card_amt_mean}) df df.merge(card_agg, oncard1, howleft) # 金额相对卡片均值的比值 df[amt_ratio] df[TransactionAmt] / (df[card_amt_mean] 1)这里的聚合是在全量数据上做的严格来说有轻微泄漏风险因为验证集的信息也参与了均值计算。更严谨的做法是只在训练时间段内算聚合再映射到验证集。实操中如果时间切分做得干净这个泄漏影响很小但你要知道它存在。1是为了防止除零别省。3.2 计数特征与 UID 构造风控里最有效的一类特征是「这个实体出现了多少次」。单看一笔交易看不出问题但同一张卡、同一个邮箱域名在短时间内高频出现就很可疑。常见做法是把几个关键字段拼成一个 UID比如卡号加地址加邮箱域名然后统计这个 UID 的交易次数、涉及的不同设备数等。# 构造组合 UID统计出现频次 df[uid] df[card1].astype(str) _ df[addr1].astype(str) _ df[P_emaildomain].astype(str) df[uid_cnt] df.groupby(uid)[TransactionID].transform(count) # 同一 UID 下不同设备数的近似用设备信息字段去重计数 df[uid_device_nunique] df.groupby(uid)[DeviceInfo].transform(nunique)transform(count)和groupby().count()的区别要搞清楚前者把聚合结果广播回每一行保持行数不变后者会压缩成每个 UID 一行。特征工程里几乎都用transform。UID 的字段组合可以多试几组卡号加邮箱是一组卡号加地址是另一组不同组合捕捉不同的欺诈模式。但别拼太多字段拼得越细每个 UID 的样本越少统计量越不可靠。3.3 时间窗口特征把「最近」量化出来时间窗口特征是这套数据的分水岭。核心思路是对每笔交易看它之前一段时间内同一实体的行为。比如「这张卡在过去 1 天内的交易笔数」「这个 UID 在过去 6 小时内的金额总和」。这类特征计算量大但区分度极高。# 简化示例按卡片统计过去 1 天内的交易笔数 df df.sort_values(TransactionDT).reset_index(dropTrue) window 24 * 60 * 60 # 1 天单位秒 def rolling_count(group): times group[TransactionDT].values counts [] for i, t in enumerate(times): # 统计当前时刻往前 window 秒内的交易数 counts.append(((times t - window) (times t)).sum()) return pd.Series(counts, indexgroup.index) df[card_txn_1d] df.groupby(card1, group_keysFalse).apply(rolling_count)上面这段是教学版逐行循环在几十万行数据上会慢到无法接受。生产里我一般用排序加双指针或者用pd.merge_asof配合累计计数来做复杂度能降到接近线性。参数上窗口大小要结合业务节奏定1 天、7 天、30 天各造一组让模型自己选。窗口太短噪声大太长则平滑掉了异常。注意计算窗口特征前必须按TransactionDT排序否则「过去」就变成了「未来」这是最隐蔽的泄漏。4. 验证集切分与不平衡处理别让 AUC 骗了你4.1 按时间切分而不是随机切分前面提过随机切分会让模型偷看未来。正确做法是取时间轴上的一个点之前做训练之后做验证。我一般用前 80% 的时间做训练、后 20% 做验证并且保证验证集里正样本数量足够否则指标波动会很大。from sklearn.model_selection import train_test_split # 按时间排序后切分 df df.sort_values(TransactionDT).reset_index(dropTrue) split_idx int(len(df) * 0.8) train df.iloc[:split_idx] valid df.iloc[split_idx:] print(train[isFraud].mean(), valid[isFraud].mean())切完要检查两件事训练集和验证集的正样本率是否接近如果差太多说明时间点上欺诈模式发生了突变需要调整切分点验证集的正样本绝对数量是否够太少的话 AUC 的置信区间会很宽调参时容易被噪声带偏。4.2 不平衡处理的三种路线与取舍正样本不到 4%直接训练模型会偏向多数类。常见三条路线调scale_pos_weight、做欠采样、做 SMOTE 过采样。我的经验是优先调scale_pos_weight它不改变数据分布只是调整损失函数的权重最稳。欠采样会丢信息SMOTE 在匿名特征上生成的样本未必有业务意义容易引入噪声。import lightgbm as lgb # 用负正样本比例设置权重 neg (train[isFraud] 0).sum() pos (train[isFraud] 1).sum() spw neg / pos params { objective: binary, metric: auc, scale_pos_weight: spw, learning_rate: 0.05, num_leaves: 64, feature_fraction: 0.7, bagging_fraction: 0.8, bagging_freq: 1, verbose: -1 }scale_pos_weight设成负正比是起点不是终点。设得太大模型会过度关注正样本召回上去了但精确率掉得厉害实际业务里误杀正常交易的代价很高。我一般会在负正比的 0.5 到 1.5 倍之间试几档看验证集上的 PR 曲线而不是只看 AUC。num_leaves别开太大匿名特征多、噪声大叶子数超过 128 很容易过拟合。4.3 评估指标的选择AUC 是不平衡数据的常用指标但它有个毛病对正样本的排序敏感对实际阈值不敏感。业务上你最终要定一个阈值来决定拦不拦所以 PR 曲线和不同阈值下的召回率更有参考价值。我一般同时看 AUC、PR-AUC 和 top 1% 高风险交易的召回率——后者最接近真实风控场景因为人工审核资源有限只能看最可疑的那一小批。from sklearn.metrics import roc_auc_score, average_precision_score pred model.predict(valid[features]) print(AUC:, roc_auc_score(valid[isFraud], pred)) print(PR-AUC:, average_precision_score(valid[isFraud], pred)) # top 1% 高风险交易的召回率 threshold np.percentile(pred, 99) top_mask pred threshold recall_top valid.loc[top_mask, isFraud].sum() / valid[isFraud].sum() print(Top1% recall:, recall_top)这段代码里np.percentile(pred, 99)取的是预测分数的 99 分位意味着只拦最可疑的 1%。如果这个召回率能到 30% 以上说明模型在头部排序上是有用的如果很低说明模型只是把大多数样本排对了但对真正的欺诈没抓住这时候要回去检查特征而不是继续调参。5. 避坑与排查五个让我返工的血泪记录5.1 现象验证集 AUC 0.95上线后一塌糊涂原因几乎总是数据泄漏。最常见的是在构造聚合特征时用了全量数据验证集的信息渗进了训练特征其次是随机切分让模型见到了未来时间段的模式。排查方法很简单把特征逐个拿掉重训看哪个特征拿掉后验证集指标大幅下降那个特征大概率有问题。解决就是严格按时间切分所有聚合统计只在训练时间段内计算。5.2 现象模型训练报错提示类别特征类型不对LightGBM 对 category 类型有要求pandas 的 object 列直接喂进去会报错或者被当成数值处理。原因是C系列字段读进来是 object没转 category。解决是在训练前统一转换并且保证训练集和验证集的类别编码一致——用同一份 category 映射别各自转各自的否则同一类别在两边编码不同模型直接学错。for col in cat_cols: df[col] df[col].astype(category) # 用训练集的类别映射约束验证集 for col in cat_cols: valid[col] valid[col].cat.set_categories(train[col].cat.categories)5.3 现象窗口特征算出来全是 0 或者异常大原因是没排序就做 rolling或者时间单位搞错了。TransactionDT是秒窗口如果按天设要乘 86400。另一个常见错误是 groupby 之后索引错位导致特征对到了错误的行上。解决是计算前强制sort_values(TransactionDT)算完用reset_index对齐并且拿几行手工验算确认「过去 1 天」确实只包含当前时刻之前的交易。5.4 现象加入身份特征后效果反而变差身份表缺失率太高直接填充比如填 -1 或众数会引入大量虚假模式模型把「缺失」和「欺诈」错误关联。原因是填充方式粗暴没有区分「真缺失」和「有值」。解决是把缺失本身做成一个指示特征是否缺失 0/1原始值保留 NaN 交给 LightGBM 处理它对缺失有原生支持比手工填充更稳。5.5 现象本地 CV 很稳换一版数据就崩原因是特征里混入了对时间敏感的统计量比如用全量算的 UID 频次。数据一换频次分布整体偏移模型就失效。解决是把所有统计类特征改成「截至当前时刻」的累计量或者至少在训练集内计算再映射。这类问题在离线评估里很难发现只能靠严格的时序切分和特征审计来防。6. 进阶技巧用分组时序验证和特征重要性做减法走到这一步模型能跑、指标能看但真正决定这套方案值不值得投入的是你能不能判断「哪些特征在真正起作用」。我习惯在最终调参前做一次分组时序验证把验证集按时间再切成几段看模型在各段上的表现是否稳定。如果某一段突然掉点说明模型对那个时间段的模式不适应可能是特征漂移也可能是那段数据本身有异常。# 把验证集按时间分成 4 段看各段 AUC valid valid.sort_values(TransactionDT).reset_index(dropTrue) valid[seg] pd.qcut(valid[TransactionDT], 4, labelsFalse) for seg in sorted(valid[seg].unique()): sub valid[valid[seg] seg] if sub[isFraud].nunique() 1: auc roc_auc_score(sub[isFraud], model.predict(sub[features])) print(fSeg {seg}: AUC{auc:.4f}, n{len(sub)})各段 AUC 差距在 0.02 以内算稳定超过 0.05 就要警惕。这时候别急着加特征先做减法用 LightGBM 的feature_importance看增益排名把长期排在末尾、且业务上说不通的特征砍掉重训看指标是否保持。匿名特征多的时候砍掉一批低增益特征往往能让模型更稳因为噪声少了。另一个我常用的技巧是对高增益特征做单变量分析取增益前 20 的特征逐个看它在欺诈样本和正常样本上的分布差异。如果某个特征增益很高但分布几乎重合大概率是模型在过拟合噪声可以考虑去掉。这个动作花不了多少时间但能帮你把特征集从几百个压到几十个训练速度和解译性都会好很多。最后说个习惯每次改特征或调参我都固定记录三件事——验证集 AUC、PR-AUC、top 1% 召回率外加这次改动的具体内容。风控建模的迭代很容易陷入「感觉变好了」的错觉只有把指标钉在表格里才能看出哪次改动是真提升、哪次只是噪声波动。这套数据我前后复现过几轮最大的教训就是别迷信单次 CV 的高分时序切分和特征审计做到位比多调几个超参数有用得多。希望帮到你。本文还有配套的精品资源点击获取