
简介面向 IEEE-CIS 欺诈检测竞赛数据集的探索性数据分析EDA工具包适合正在学习 Kaggle 二分类建模、希望掌握欺诈检测数据清洗与特征工程技巧的数据科学爱好者与竞赛选手。压缩包内共 6 个文件核心为 EDA.ipynb 笔记本与 3 个 Python 工具脚本并附带 README 说明文档与 .gitignore 配置。其中 util_data_cleaning.py 封装了大量数据清洗函数可处理缺失值、异常值与原始表合并util_feature_engineering.py 负责特征准备与整理便于构建建模输入util_reporting.py 则提供可视化和报告函数帮助快速输出分布图与相关性分析。整体包体仅 1.02MB轻量却覆盖从数据探查到特征构建的完整分析链路。目前已有 599 人学习适合需要快速参考 EDA 流程、复用清洗与特征工程函数、理解类别不平衡与欺诈识别关键环节的读者对照 notebook 与脚本即可二次开发或迁移到自己的竞赛方案中。1. IEEE-CIS-Fraud-Detection一张交易表一张身份表欺诈检测的提分全在特征匹配IEEE-CIS-Fraud-Detection 是我拆过的金融欺诈检测项目里最典型的一个数据来自真实电商交易场景核心任务很直接根据交易记录预测每一笔订单是否为欺诈。整个项目最反直觉的地方在于——模型本身不是瓶颈XGBoost 和 LightGBM 都能轻松跑到不错的分数真正拉开差距的是 transaction 表和 identity 表的匹配方式以及 V 系特征那几千列空值怎么填。大多数照着跑一遍就扔在一边的开发者都栽在“特征看起来很多、实际上有效信号稀疏”这一步。这套基于 JupyterNotebook 的完整复现流程适合做大流量风控建模、竞赛练手或者想系统梳理特征工程的从业者你能从中拿到的不是几行调参代码而是一整套可迁移到其他风控场景的建模框架。2. 数据解构transaction 与 identity 两张表先看分布再动手2.1 两张表的关系TransactionID 是唯一连接键这个数据集的原始结构是两张表transaction 表记录每一笔订单的交易属性identity 表记录设备与网络环境的匿名信息。TransactionID 是两表共享的唯一主键业务含义上是一笔交易对应一个设备事件。由于 identity 表不是每笔交易都有记录所以这里存在一个非常常见的建模陷阱直接把两张表 inner join会丢掉大量没有设备信息的交易而这些交易往往欺诈率偏高——因为没有设备指纹本身就是一个风险信号。我一般会先分别确认两表行数计算 identity 表的覆盖比例再决定连接方式。覆盖率高就用 inner join 减少内存压力覆盖率低就保留缺失标记做特征而不是简单丢弃。import pandas as pd train_tx pd.read_csv(train_transaction.csv) train_id pd.read_csv(train_identity.csv) # 看覆盖率和缺失模式 merged_id_count train_tx[TransactionID].isin(train_id[TransactionID]).sum() print(ftransaction行数: {len(train_tx)}) print(fidentity 行数: {len(train_id)}) print(f身份信息覆盖率: {merged_id_count / len(train_tx):.4f})这段代码的作用是量化 identity 表的覆盖率而不只是看行数。覆盖率低于 60% 时我会倾向保留缺失标记而不是强行补齐覆盖率高于 90% 时则可以直接 join。逻辑在于缺失本身就是一种类别模型需要看到“有”和“没有”两种情况下的欺诈差异而不是只看“有”的子集。2.2 特征家族C系、D系、V系、id系各是什么角色这个项目最劝退新手的一点是几百个脱敏列名里藏着完全不同的信息类型。公开讨论里基本可以确认的大致分工是特征家族大致角色特点与实战用法C 系列计数类数值本身离散直接做类别编码比当连续量用好D 系列时间/距离差存在大量缺失常与交易间隔相关V 系列脱敏数值维度最多、缺失率极端是算力消耗大头M 系列匿名类别低基数适合直接编码id 系列设备属性高基数必须做频率编码TransactionAmt真实金额唯一可以直接解释的强特征必须重点加工在动手之前把这些家族分清楚能省掉一大半试错成本。V 系不是一个同质整体有的列缺失率 0.1%有的缺失率 95%处理策略完全不同。2.3 JupyterNotebook 里的快速 EDA加载、合并、看目标分布对于这种几百列的数据集逐列人工检查不现实。我的日常做法是先跑一个总览函数把缺失率、基数、数据类型一次性打出来再人工挑重点。def overview(df, sample_colsNone): cols sample_cols if sample_cols else df.columns stat pd.DataFrame({ dtype: df[cols].dtypes, missing_ratio: df[cols].isnull().mean().round(4), nunique: df[cols].nunique() }).sort_values(missing_ratio, ascendingFalse) return stat overview(train_tx, sample_cols[C1,C2,V1,V2,D1,D4,TransactionAmt])参数说明sample_cols控制只看哪些列避免几百列同时输出刷屏missing_ratio用均值计算缺失比例nunique用于判断列是高基数类别还是连续数值。输出之后我一般会按缺失率 0.9、0.9 缺失率 0.3、缺失率 0.3 分三档分别走不同的填充策略这个后面特征工程章节展开。目标分布也要第一时间看。这个任务的正样本占比非常低只有百分之几的量级如果按默认阈值做预测模型的准确率会虚高。JupyterNotebook 下用一行value_counts(normalizeTrue)就能确认这也是后面要不要做采样、怎么做验证的重要依据。3. 特征工程从“脱敏列”里提炼出模型认得的信号3.1 金额与时间两个高解释力的原始特征TransactionAmt 是这个数据集里少见的可直接解释变量。实际业务里欺诈交易的金额分布往往比正常交易更集中在特定区间或者单笔金额显著偏离用户历史水平。所以不要只把 TransactionAmt 当单列数值要围绕它构造对比维度。# 金额的常用衍生特征 train[Amt_Log] np.log1p(train[TransactionAmt]) train[Amt_Mean_Card] train.groupby(card1)[TransactionAmt].transform(mean) train[Amt_Dev_Card] train[TransactionAmt] - train[Amt_Mean_Card] train[Amt_Ratio_Card] train[TransactionAmt] / (train[Amt_Mean_Card] 1e-8)逻辑说明Amt_Log压缩金额长尾Amt_Mean_Card是同一张卡的历史平均金额Amt_Dev_Card是偏差Amt_Ratio_Card是相对倍数。这几个特征捕获的是“这张卡在同样金额区间内是否正常”。参数上1e-8是防除零的常数项不能省。TransactionDT 的单位是秒实际是一个相对时间戳。我一般会转成“距数据集最早时间的天数”和“小时”因为欺诈在凌晨时段分布异常时间戳本身模型学不到周期规律必须拆出周期性。train[DT_Days] train[TransactionDT] // 86400 train[Hour] (train[TransactionDT] % 86400) // 3600// 86400算出整数天% 86400取当天剩余秒数再除以 3600 得到小时。这里的数值逻辑很简单但能直接给模型补充周期性信号等于白送的提分点。3.2 类别特征Frequency Encoding 替代 Label Encodingcard1 到 card6、addr1、addr2、P_emaildomain 这些列直接喂给树模型会出问题在于基数太高。以 card1 为例上百种取值Label Encoding 会制造出模型无法理解的“数值大小顺序”而 True Label Encoding 的随机排序会扰乱分裂点。频率编码是更稳的做法把类别替换成它出现的次数或占比本质是“稀有类别本身可能是风险信号”的建模。def freq_encode(df, col): freq df[col].value_counts().to_dict() return df[col].map(freq), df[col].map(freq) / len(df) for col in [card1, card2, addr1, P_emaildomain]: train[col _freq], train[col _freq_ratio] freq_encode(train, col)参数说明value_counts()统计类别出现次数map(freq)实现逐行映射。_freq 特征是绝对次数_freq_ratio 是相对占比两者信息不同后者能弱化数据总量的规模差异。对高基数类别频率编码后模型能在小样本类别上做出判断减少对稀有类别的过拟合。对比过 label encoding 和频率编码的验证结果频率编码在离线 AUC 上通常有稳定的提升大约一到两个千分点看起来不多但在这种正样本比例极低的数据集里已经是明显差距。3.3 V系特征的空值策略分组填充与 -999 标注V 系列是这个项目最大的黑匣子几百个匿名数值列缺失率分布极其不均。最土但最有效的做法不是全局填充而是分组填充。同一个 card1 下的交易往往共享设备环境用组内均值填充比全局均值更能保留内部一致性。def group_fill(df, group_col, value_col): grouped_mean df.groupby(group_col)[value_col].transform(mean) df[value_col _fill] df[value_col].fillna(grouped_mean) return df[value_col].isnull().astype(int) # 缺失标记 train[V1_missing_flag] group_fill(train, card1, V1)逻辑说明transform(mean)对每一行返回其所属组的均值fillna只对空值生效。函数返回值是缺失标记这一列单独作为特征。原理是V 系的缺失本身可能代表特殊交易通道标记列给了模型显式信号。如果某列缺失率高于 90%组内均值也极不稳定这种情况我会直接填一个固定值-999让树模型按孤立分支处理。你可以用一个完整的循环跑 V1-V339但不建议全量做只挑缺失率在 10%-80% 之间的列过高过低都不值得。3.4 身份表聚合User 级别的历史统计identity 表的 id_01 到 id_38 大多与设备环境相关单列信息弱聚合后价值就出来了。常见做法是以 card1 或 addr1 为粒度统计 id 系列的高基数列出现过多少种不同取值类似“这张卡在一段时间内见过多少种设备”的风险度量。# 聚合设备多样性 card_device_count train.groupby(card1)[id_03].nunique() train[Card_Device_Diversity] train[card1].map(card_device_count) # 聚合金额维度 card_amt_std train.groupby(card1)[TransactionAmt].std() train[Card_Amt_Std] train[card1].map(card_amt_std)这里nunique统计组内不同取值数std是组内金额标准差。这两个特征表达的实际业务含义是一张卡是否突然在多种设备环境上高频出现、金额波动是否异常。注意这类聚合特征强烈依赖训练集统计测试集上如果 card1 不存在会返回 NaN需要额外填充一个默认值通常是 0。4. 建模与验证LightGBM 为核心AUC 为准4.1 数据切分与交叉验证这个数据集不适合随机切分。TransactionDT 时间跨度很大随机切分会让模型“看到未来”产生虚高的验证分数。我的做法是严格按时间顺序切分用前 80% 的时间段训练后 20% 验证。某些讨论里也有人用 StratifiedKFold 随机切分实测分数会偏高但上线就翻车属于典型的“验证分数很好看换批数据就露馅”。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) for train_idx, valid_idx in tscv.split(X): X_train, X_valid X.iloc[train_idx], X.iloc[valid_idx] # 后续训练与验证TimeSeriesSplit强制按顺序划分每次训练集的时间范围严格早于验证集。参数n_splits5表示做 5 折时序交叉验证。每一折都很脏但正因为脏得到的 AUC 才接近真实线下表现。我在这个项目里主要报验证集 AUC因为测试集没有标签只能离线模拟。4.2 类别不平衡模型层面与训练策略正样本比例极低直接训练会让模型偏向预测负样本AUC 不受影响但概率值会整体偏低。处理上我不做下采样因为数据量本来就大丢掉正样本太浪费。更稳的路线是调整 LightGBM 的 scale_pos_weight让模型对少数类的梯度惩罚加重。import lightgbm as lgb params { objective: binary, metric: auc, boosting_type: gbdt, scale_pos_weight: 30, learning_rate: 0.03, num_leaves: 63, max_depth: 7, min_child_samples: 100, subsample: 0.8, colsample_bytree: 0.7, reg_alpha: 0.1, reg_lambda: 0.1, verbosity: -1 }参数说明scale_pos_weight30是正负样本比例的量级具体值要根据数据分布微调我一般先跑一个粗略验证看欺诈召回率再往回调num_leaves63对应深度 7 的上限太大会过拟合到少数类的噪声上min_child_samples100避免生成叶子样本过少的节点。这套参数不是最优解但它是稳健起步点后续调参都从这里展开。训练时用早停回调每轮在验证集上算 AUC连续 50 轮不提升就停止防止过拟合。4.3 候选特征集与调参顺序特征总数会达到四百列以上全部送进去训练不仅慢还会引入噪声。我先用三折 LightGBM 的特征重要性过滤一遍把重要性为 0 的列直接删掉再保留 Top 200 列做正式训练。调参顺序也有讲究先调num_leaves和max_depth控制模型复杂度再调min_child_samples防过拟合最后调正则和采样比例。跳过前两步直接调learning_rate是最常见的浪费时间方式。# 特征重要性筛选 lgb_clf lgb.train(params, lgb_train, num_boost_round500, valid_sets[lgb_valid], callbacks[lgb.early_stopping(50)]) importance pd.DataFrame({ feature: X.columns, gain: lgb_clf.feature_importance(gain) }).sort_values(gain, ascendingFalse) keep_features importance.nlargest(200, gain)[feature].tolist()逻辑说明feature_importance(gain)返回的是特征在分裂时带来的总增益比默认的split次数更能反映真实贡献。keep_features保留前 200 个特征后续用这份名单重新构造训练集。注意重要性要在验证集上评估而不是训练集否则高频特征会主导结果。5. 避坑指南这个项目最容易翻车的五个现场5.1 现象V 系特征全量直接填充离线 AUC 掉了 0.005先把所有 V 列缺失值填成 -999训练完之后发现 AUC 比只做分组填充低了近千分之五。原因在于 V 系不同列的分布差异极大同一固定值把大量信息压成了同一个分支。解决只对缺失率高于 90% 的列用 -999其余列按 card1 分组均值填充缺失标记单独做成独立特征。从那以后我处理任何高维稀疏特征都先按缺失率分档。5.2 现象merge 后行数对不上模型效果差得不合理用pd.merge(train_tx, train_id, onTransactionID, howleft)之后发现欺诈样本大量消失。排查后原因有两个一是 identity 表本身覆盖率只有六成inner join 会把无设备指纹交易全部丢掉而这些交易欺诈率偏高二是两表 TransactionID 的数据类型不一致transaction 里是 intidentity 里是 float导致匹配失败。解决先统一转成 int64再用howleft保留全部交易并额外增加 identity 是否存在标记。5.3 现象同卡多笔交易的目标值在训练集里互相泄漏按 card1 聚合特征后验证分数很高但测试集分数断崖式下跌。原因是某些 card1 在训练集里只出现过欺诈样本聚合统计直接编码了目标值。解决对聚合特征做交叉验证内的批量计算先只在训练折内统计并映射到验证折测试集不参与任何 groupby 统计。这也是这个项目里最容易踩的泄漏点没有之一。5.4 现象模型训练占用内存高达 40GB 以上三百多列 float64 数据加上独热编码直接撑爆了本地内存。原因很简单float64 的精度对树模型没有任何意义白白多占一倍空间。解决所有特征统一降为 float32字符串类别只保留频率编码结果identity 表与 transaction 表合并前先删掉重复的 TransactionID 列。5.5 现象设备特征 DeviceInfo 直接 Label Encode 后过拟合严重DeviceInfo 基数超过两千直接编码后模型在该特征上分裂极深验证集 AUC 稳定但测试集表现崩塌。解决只在 DeviceInfo 上做频率编码并额外构造 Top 20 设备类型标记其余全部归为 other。低频设备本身就是异常信号频率编码比任何高级编码都稳。6. 进阶验证用伪标签与模型融合把 AUC 再推高 0.002如果你跑通了基础流程想让模型分值再上一个台阶伪标签和模型融合是这个项目里本金最重的两个技巧。伪标签的做法是先训练一个基础模型对无标签数据量大的测试集打置信度高的预测标签再把高置信样本并回训练集继续训练。这套做法在这个项目中有效的前提是测试集和训练集的时间分布不完全一致但底层的用户与设备行为模式相似。高置信样本相当于给模型补充了对未知时间段的学习树模型在获得更多边界样本后可以在保持精度的同时对不同时间段的风险分布给出更稳的估计。注意置信度阈值要保守正样本阈值通常取 0.9 以上负样本阈值取 0.1 以下避免噪声标签污染训练集。test_proba model.predict_proba(X_test)[:, 1] pseudo_pos X_test[test_proba 0.9] pseudo_neg X_test[test_proba 0.1] X_aug pd.concat([X_train, pseudo_pos, pseudo_neg]) y_aug pd.concat([y_train, pd.Series([1]*len(pseudo_pos)), pd.Series([0]*len(pseudo_neg))])逻辑说明只取 0.9 和 0.1 之外的样本本质上是在用模型最确定的部分反哺模型本身。参数上要注意伪标签数据量不宜超过训练集的三成否则模型会固步自封。融合方面单棵 LightGBM 得分已经稳定再叠加 XGBoost 和 CatBoost 做 rank average能让 AUC 再往上走。融合不是简单平均概率而是先把每个模型对单条样本的预测值转成排名再做平均因为不同模型的概率分布尺度差异很大。最后一步可以用验证集线性搜索最佳权重系数。from scipy.stats import rankdata # 把预测概率转成排名消除尺度差异 rank_lgb rankdata(lgb_pred) / len(lgb_pred) rank_xgb rankdata(xgb_pred) / len(xgb_pred) final_pred 0.6 * rank_lgb 0.4 * rank_xgb纸上谈兵的意义不大我把这套代码放在 JupyterNotebook 里跑通后每次做特征实验都会同时开一份基线对比。从那以后我再也不相信只看训练损失来判断特征好坏——时间序列切分的重要性排在一切调参之前。用这种工作流走一遍之后你在别的风控数据集上也会习惯性地先查时间范围、再查身份信息覆盖率、最后才碰模型参数这个顺序能帮你少走很多弯路希望帮到你。本文还有配套的精品资源点击获取