ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

匿名交易数据下的反欺诈竞赛:特征工程与时间验证实战解析

匿名交易数据下的反欺诈竞赛:特征工程与时间验证实战解析 简介面向数据科学竞赛选手的IEEE-CIS欺诈检测EDA资源包聚焦信用卡交易欺诈二分类场景帮助读者通过探索性数据分析判断用户点击是否存在欺诈行为。核心内容包含一张Jupyter Notebook记录了深入的特征探索与可视化分析流程另配有三个Python工具脚本分别覆盖数据清洗、可视化报告生成与特征工程便于独立复用或嵌入自己的竞赛流水线。资源包共6个文件以py、ipynb、md等类型为主压缩后仅1.02MB轻量且结构清晰。目前已有598人浏览学习。跟随其中的分析路径读者能够快速掌握大规模交易数据下的分布规律、异常特征关系等关键EDA技巧显著提升后续建模与特征筛选的效率。1. IEEE-CIS-Fraud-Detection 不是模型比赛是特征与验证的较量IEEE-CIS-Fraud-Detection 是 Kaggle 上少见的“模型再强也不如验证做对”的反欺诈赛事。比赛给的不是干净金融样本而是经过匿名化的真实交易记录信用卡和借记卡混在一起行数近六十万欺诈率却只有约百分之几。多数新手把时间耗在调 LightGBM 参数上结果往往是 Public 榜看似漂亮的 AUC到 Private 榜明显回落。真正拉开差距的是把时间花在数据读取、特征构造和验证方式上。这篇文章要解决的问题就是在一堆匿名 V 列、C 列、M 列和 D 列里找到能稳定泛化的欺诈信号。适合做支付风控、反欺诈、异常检测的算法工程师也适合想拿表格数据比赛练手、但手头没有真实业务标签的同学。2. 先把数据读进内存并跑出基线近 60 万笔交易的表结构与 AUC 下限2.1 读取 Transaction 与 Identity 并做内存压缩合并前的必备动作比赛把交易记录拆成了 train_transaction.csv 和 train_identity.csv 两张表。Transaction 表以 TransactionID 为主键记录金额、时间、卡信息、地址和几百个匿名 V 列Identity 表存放设备相关字段比如 DeviceType、DeviceInfo 以及 id_01 到 id_38 这一组匿名标识。Identity 的行数远少于 Transaction因为并不是每笔交易都能拿到设备信息这个缺失关系本身就是信号不能删。import pandas as pd import numpy as np def load_ieee(): trans pd.read_csv(train_transaction.csv) ident pd.read_csv(train_identity.csv) df trans.merge(ident, onTransactionID, howleft) for col in df.columns: if df[col].dtype float64: df[col] df[col].astype(float32) elif df[col].dtype int64: df[col] df[col].astype(int32) return df df load_ieee() print(df.shape) print(df[isFraud].mean())这段代码做了三件事先读两张原始表用 TransactionID 做 left join再把数值列降精度。left join 而不是 inner join是为了保住全部交易样本身份信息缺失的交易保留为 NaN后续可用缺失率特征表达。降精度是因为 V 列数量多float64 转 float32 对树模型分裂几乎没有影响但内存能省一半训练速度明显变快。需要注意 read_csv 默认会把大整数解析成 int64card1 这类卡号列的取值可能超过 int32 范围吗实际比赛数据里不会int32 足够。如果不放心可以在 astype 外面加 try-except但这里没必要。2.2 不做特征直接跑 lgb.cv先知道自己站在哪很多人一上来就想做一堆花哨特征结果连“不加工数据时模型能到多少”都不知道。先跑一个最朴素的基线能快速暴露数据读取、列筛选和评测代码里的低级错误。import lightgbm as lgb feat [c for c in df.columns if c not in (isFraud, TransactionID, TransactionDT)] params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, min_data_in_leaf: 100, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbose: -1, seed: 1, } cv lgb.cv( params, lgb.Dataset(df[feat], df[isFraud]), num_boost_round500, nfold5, stratifiedTrue, ) print(AUC:, cv[auc-mean][-1])这里的参数不是最终配置只用于快速迭代。lgb.cv 内部已经帮你做了 K 折划分与评估返回的是每一轮 AUC 的均值取最后一项就是 500 棵树下的分数。我一般会先看这个数字落在什么区间再决定特征工程投入多少。为什么不直接用 sklearn 的 cross_val_score 因为 LightGBM 的 Dataset 对象可以复用特征矩阵lgb.cv 还能顺便输出每轮迭代的 vali score方便判断模型是否欠拟合。stratifiedTrue 在这里有意义欺诈率只有 3% 左右随机 K 折可能把某一折切得几乎全负样本分层能保证每一折正样本比例接近全量。2.3 为什么 Public 榜不能代表真实表现欺诈模式的时序漂移用上面这段代码跑出来的基线 AUC大致在 0.85 到 0.9 之间。你以为调调参就能上 0.94于是开始在 Public 榜上较劲最后却被 Private 榜教育。这不是玄学而是比赛设计里的真实结构问题Public 和 Private 的测试样本来自不同的时间窗口欺诈团伙在换手法模型学到的是上一段时间的统计规律。所以从最开始验证方式就不能用随机 K 折。随机 K 折默认训练集和验证集同分布而现实中的欺诈检测训练数据永远来自过去决策永远面向未来。后面第 4 章会专门讲时间感知切分这里先记住结论基线用 StratifiedKFold 没问题最终模型评估必须换掉。3. 匿名交易特征怎么挖C、M、D、V 四组列的工程路线3.1 先确认“有没有身份记录”本身是不是强特征比赛里最容易忽略的特征是 Identity 表是否命中。同一笔交易有设备指纹和没有设备指纹对应的风险分布差别巨大。欺诈交易往往刻意避免留下稳定的设备信息所以“缺失”不等于信息缺失反而代表某种刻意行为。df[has_identity] (~df[DeviceType].isna()).astype(int) df[has_id_02] (~df[id_02].isna()).astype(int)has_identity 表示这列 DeviceType 是否非空有设备记录记为 1否则 0。has_id_02 同理id_02 是 Identity 表里缺失率较高的列之一单独拿出来观察是因为一些匿名 ID 只在异常时段才出现。这两个二值特征不是靠拍脑袋加的我建议你加完后跑一次特征重要性如果排名靠后再删掉也不迟。除了二值化还可以统计“每个 card1 卡号关联了多少个不同 DeviceType”。同一张卡在短时间内出现多个不同设备是盗刷的常见信号。dev_count df.groupby(card1)[DeviceType].nunique() df[card1_device_nunique] df[card1].map(dev_count)这段代码会先按 card1 分组统计每个卡号下 DeviceType 去重后的数量再 map 回原表。需要注意如果测试集里出现训练集没见过的 card1map 会得到 NaNLightGBM 能处理缺失所以不用着急填充。3.2 高频类别编码别用 LabelEncoder 处理 card1 这类匿名类别card1 到 card6、addr1、addr2 在比赛中都属于匿名离散列特征本身没有顺序含义。直接用 LabelEncoder 把它们变成 0 到几万的整数树模型会把这些整数当作有序数值分裂时倾向于按编码大小切结果很不稳定。更好的做法是频率编码把每个类别的出现次数作为该样本的特征值。def freq_encode(df, col): vc df[col].value_counts().to_dict() df[col _freq] df[col].map(vc) / len(df) return df for col in [card1, card2, card3, card4, card5, card6, addr1, addr2, P_emaildomain, R_emaildomain]: df freq_encode(df, col)value_counts 返回每个类别出现的绝对次数map 把它按行映射回原始 DataFrame再除以总行数得到出现频率。比如 card1 里有个卡号出现了一万次那么这列对应样本的 card1_freq 就是 10000 / len(df)大约 0.017。树模型对一个 0 到 1 的连续特征非常敏感它可以把频率阈值切分得足够细比整数 ID 更容易泛化。频率编码也有代价训练集里出现次数极少的类别在测试集里可能完全换了一批频率特征会退化成接近 0 的常数。这个问题放到第 5 章讲。3.3 C、M、D、V 四组列的缺失与极值处理填 -999 前先留一个 isna 开关C 列和 M 列是类别型匿名特征V 列是连续型匿名特征。V 列数量最多缺失率也高但树模型不需要做均值填补直接把缺失值保留给 LightGBM 处理即可。不过为了让模型能显式利用“缺失”这件事我会额外生成 isna 二值特征。for col in [c for c in df.columns if c.startswith(V)]: df[col _nan] df[col].isna().astype(int) df[col] df[col].fillna(-999)fillna(-999) 是给 LightGBM 一个明显的“空旷区域”让缺失值在分裂时集中到一边。加了 _nan 特征后即使 -999 分布被树记住模型也能区分“原本就是 -999”和“本来就是 NaN”。这两个信号一起喂进去比单独填一个数更稳。D 列的处理思路不同。D 列更像某种时序偏移量很多样本取值为 0但 0 在这个场景里不一定代表真实数值更像“未发生”的标记。我一般会把 D 列的 0 先转成 NaN再统一填充让模型自己判断 0 的语义。for col in [c for c in df.columns if c.startswith(D)]: df[col] df[col].replace(0, np.nan) df[col _zero] df[col].isna().astype(int) df[col] df[col].fillna(-1)replace(0, np.nan) 会把这些列里的 0 全部视为缺失再补一个 _zero 特征告诉模型“该列原本是否为零”。_zero 特征单独建模的价值很大因为业务上“距离为 0”“次数为 0”和“距离没记录”是两回事。注意这只适用于 D 列C 列和 M 列不要这样处理类别列里的 0 可能是一个合法类别。除了填空还应该加一列金额偏离特征。欺诈交易经常表现为“同一张卡突然出现一笔大额消费”用全量均值做除法容易受离群值影响稳健做法是按 card1 分组计算均值。amt_mean df.groupby(card1)[TransactionAmt].transform(mean) df[Amt_dev_card1] df[TransactionAmt] / amt_meantransform 会把每个卡号的均值广播回每一行再逐行相除。结果大于 1 表示该笔金额高于该卡历史平均小于 1 表示低于。这个特征在后续模型里通常能进重要性的前 20建议保留。4. 模型训练与验证为什么要和时间赛跑而不是和随机 K 折赛跑4.1 一份面向时间漂移的 LightGBM 参数与训练脚本特征做完接下来是模型。很多人直接套用默认参数num_leaves 默认 31learning_rate 默认 0.1在普通表格数据上问题不大但在这个赛题上交易欺诈正样本稀少且噪声大默认参数容易记住训练集里的偶然模式。我常用的一组稳定参数如下。params { objective: binary, metric: auc, learning_rate: 0.02, num_leaves: 63, min_data_in_leaf: 200, feature_fraction: 0.7, bagging_fraction: 0.8, bagging_freq: 1, lambda_l1: 1.0, lambda_l2: 5.0, max_bin: 255, verbosity: -1, }learning_rate 降到 0.02是为了配合更多迭代次数让模型一步步学不容易在早期冲过头。min_data_in_leaf 提到 200迫使每个叶子至少包含 200 个样本避免学到只有几个样本的噪声模式。lambda_l1 和 lambda_l2 是两个正则项匿名特征维度太高不加正则很容易让特征重要性集中在少数几个高频类别上。训练时配合早停验证集直接用时间切出的后半段数据import lightgbm as lgb split_time int(df[TransactionDT].quantile(0.9)) trn df[df[TransactionDT] split_time] val df[df[TransactionDT] split_time] feature_cols [c for c in trn.columns if c not in (isFraud, TransactionID, TransactionDT)] x_trn, y_trn trn[feature_cols], trn[isFraud] x_val, y_val val[feature_cols], val[isFraud] model lgb.train( params, lgb.Dataset(x_trn, y_trn), num_boost_round2000, valid_sets[lgb.Dataset(x_val, y_val)], callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)], )用 TransactionDT 的 90% 分位数作为切分点前 90% 训练后 10% 验证。lgb.early_stopping(100) 表示验证集 AUC 连续 100 轮不提升就停止lgb.log_evaluation(200) 表示每 200 轮打印一次分数避免刷屏。这样做出来的验证分数比随机 K 折更接近比赛 Private 榜的表现。4.2 时间感知验证train/valid 边界怎么定才不翻车切分点选在 0.9 分位数是我常用的默认值但不是唯一选择。边界太靠前验证集样本太少分数方差大边界太靠后训练集丢失太多近期数据模型学不到最新欺诈手法。比赛圈里常见的做法是取 0.85 到 0.95 之间几个点都试一遍看验证分数是否稳定。如果 0.8、0.9、0.95 切出来的 AUC 相差超过 0.01说明特征或模型对时间太敏感需要回到特征工程环节处理。时序验证还有一个细节分组不能只看一次切分。欺诈交易往往集中在某些卡号或某些设备上直接按行切分会把同一张卡 ID 的一部分分到训练集另一部分分到验证集造成信息泄漏。更严格的做法是按 card1 做 GroupKFold或者把 TransactionDT 排序后按卡号分组切分。不过在比赛时间有限的条件下按时间单调切分已经能过滤掉大部分乐观偏差。4.3 类别不平衡要不要动负采样、权重与全量训练的选择正样本占比约 3%第一反应可能是做负采样或调高正样本权重。这里我建议先做一次对比实验而不是直接默认降采样。LightGBM 自己会通过 min_data_in_leaf 和 bagging 控制负样本占比全量训练下 AUC 通常更好因为负样本里的真实交易模式对“什么不是欺诈”也很重要。下表是我在不同项目里的经验值处理方式优点代价全量训练AUC 最稳模型能看到全部真实交易分布训练时间增加内存压力大负采样训练速度快正样本占比提高容易低估真实负样本变化线上阈值要重新校准类别权重不改样本数实现简单权重过大容易过拟合正样本噪声SMOTE 过采样正样本样本量增加高维稀疏特征下合成样本失真泛化差我一般会先全量训练出基线再用 scale_pos_weight 从 1 到 10 做一轮小网格搜索。如果权重调到 5 以上 AUC 还没提升就放弃权重方案把精力挪回特征。5. IEEE-CIS 特征与验证常见问题排查五个翻车点与处理顺序5.1 Public 分数高、Private 分数掉得没法看现象Public AUC 0.94Private 只有 0.90差距比正常波动大得多。原因验证方式用了随机 K 折同时特征里有全样本统计量。你去算 card1_freq 时用的是整个 DataFrame里面包含测试集分布信息模型在训练时就“见过”了测试集的大致分布。时间窗口一换分布漂移特征失效分数回撤。解决所有频次、均值、Nunique 统计一律先用训练集单独算好再 map 到测试集。验证也改成按 TransactionDT 单调切分再回看 Public 和 Private 的差距是否缩小。5.2 特征重要性前几名全是 TransactionDT 和 raw ID 类列现象LightGBM 的 gain importance 里TransactionID、TransactionDT 排在前面业务特征反而靠后。原因树模型拿 TransactionID 当类别 ID 直接记忆拿 TransactionDT 当连续时间秒数切分很快发现只要记住 ID 和时间就能把训练集分开。这种模型完全没有泛化能力。解决TransactionID 直接删除TransactionDT 只保留拆分出来的小时、星期几、距第一笔交易的天数原始秒数不要进特征。同理card1 到 card6 这类匿名 ID 也不要裸着喂要么频率编码要么做聚合特征。5.3 V 列里出现 -0直接当成 0 填导致 AUC 不稳现象对 V 列做缺失统计时发现大量 -0一开始没注意和 0 一起保留结果验证分数忽高忽低。原因这个赛题里的 V 列经过了特殊变换-0 更像数据变换后留下的占位符并不等价于数值 0。树模型会把 -0 和 0 当作同一个值丢失了分布差异。解决先把 -0 替换成 NaN再统一走 fillna 流程。同时保留 _nan 特征。这样树模型至少有机会区分“原本是 -0”“原本是 0”“原本是 NaN”这三种状态。5.4 频率编码后测试集出现新卡号map 出来全是缺失现象freq_encode 跑完测试集里 card1_freq 大量为 NaN模型把缺失当成一种模式后分数随机性变大。原因欺诈检测的行数据是流式产生的训练集和测试集覆盖不同时间窗口新卡号在测试集首次出现是常态。map 找不到对应键就会返回 NaN。解决保留 NaN不要急着用 0 填充。LightGBM 会把缺失值单独处理后自动放到最优方向比硬填一个数值更稳。如果你一定要填可以用 0但必须同时保留 has_card1 特征告诉模型“这个卡号之前在训练集从没见过”。5.5 AUC 很高但实际人工复核队列命中率不高现象模型在验证集 AUC 0.93业务方拿前 1% 样本去复核发现里面不少是正常大额交易。原因AUC 衡量的是正负样本排序能力但对头部样本的区分能力不敏感。在欺诈检测中你真正关心的是“排序最靠前的几百个样本里有多少是真欺诈”而不是整体排序质量。这也是很多竞赛高分方案上线后效果打折的原因。解决回到验证集画出 PR 曲线和 KS 曲线重点看 top 1% 样本的精确率。如果头部样本杂乱需要调整损失函数比如用 focal loss 或给正样本更高的权重让模型更关注难分样本。6. 再往前一步深度融合与阈值校准两个小技巧6.1 两档深度 LightGBM 的简单加权融合单模型做到头之后我习惯用两个不同复杂度的 LightGBM 做融合而不是一上来就堆 XGBoost、CatBoost。选两个模型的原因是一个浅树模型学稳定模式一个深树模型学局部细节两者相关性低融合收益比多个同参数模型更明显。shallow_params dict(params, num_leaves8, learning_rate0.03, min_data_in_leaf500) deep_params dict(params, num_leaves128, learning_rate0.01, min_data_in_leaf50) model_shallow lgb.train(shallow_params, lgb.Dataset(x_trn, y_trn), num_boost_round3000, valid_sets[lgb.Dataset(x_val, y_val)], callbacks[lgb.early_stopping(100)]) model_deep lgb.train(deep_params, lgb.Dataset(x_trn, y_trn), num_boost_round3000, valid_sets[lgb.Dataset(x_val, y_val)], callbacks[lgb.early_stopping(100)]) pred 0.35 * model_shallow.predict(x_val) 0.65 * model_deep.predict(x_val)浅层模型叶子少分裂次数少学到的都是大方向规律深层模型叶子多能捕捉长尾模式但也容易过拟合。融合权重不需要精细搜索0.3 比 0.7 或 0.4 比 0.6 之间差异很小优先保证两个模型相关性低比调权重更重要。6.2 用 KS 而不是 0.5 找上线切点比赛提交的是概率值线上系统却总需要一个明确的分数阈值。很多人习惯用 0.5但在 3% 欺诈率的数据集里0.5 可能一个正样本都捞不到。正确做法是在验证集上计算 KS把阈值定在区分度最大的位置。from sklearn.metrics import roc_curve fpr, tpr, thresholds roc_curve(y_val, pred) ks tpr - fpr best_threshold thresholds[np.argmax(ks)] print(best KS threshold:, best_threshold)roc_curve 返回不同阈值下的假阳性率和真阳性率ks 取两者差距最大的点是“区间内正负分布差异最大”的位置。一般这个阈值会远小于 0.5比如 0.1 到 0.3 之间。上线后业务人员看到的不是概率而是这个阈值对应的批次命中率。我现在做风控模型仍然坚持先看验证集 KS 再决定上线切点而不是直接拿默认 0.5 去赌。这个从竞赛里沉淀下来的习惯希望能帮到你。本文还有配套的精品资源点击获取
返回列表