ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

信用卡欺诈检测实战:时间验证、特征工程与不平衡处理全解析

信用卡欺诈检测实战:时间验证、特征工程与不平衡处理全解析 简介针对IEEE-CIS欺诈检测竞赛数据一个以JupyterNotebook为核心的EDA资源完整展示二分类场景下从数据探索到特征构造的流程。该竞赛目标是根据用户行为与交易属性预测点击欺诈概率资源面向希望上手真实风控数据的机器学习初学者、数据科学竞赛爱好者以及需要快速完成数据清洗与特征工程的从业者通过可视化与统计方法识别与欺诈强相关的关键变量并附有数据集描述与字段含义说明。压缩包仅1.02MB共6个文件包含1个交互式notebook和3个Python工具脚本分别覆盖深入分析、数据清洗、可视化报告与特征工程另有README说明和gitignore配置notebook代码与注释结合便于逐段复现工具函数封装良好可直接调用整体结构清晰可按模块复用。目前已有598人浏览学习。读者既能跟随notebook理解EDA分析思路也能直接提取其中成熟的数据清洗函数、可视化作图函数和特征构造模板迁移到其他反欺诈或二分类数据集节省大量重复编码与调试时间。1. 先把问题钉死匿名字段、极低正样本率、时间漂移这三件事一起出现才叫欺诈检测这个项目代号是 IEEE-CIS-Fraud-Detection本质是一个二分类问题给定一笔交易的特征判断它是否欺诈。难的不是二分类本身而是它把三个最棘手的情况叠在了一起大部分特征被匿名化处理你根本不知道某个字段代表什么欺诈样本占比常常低到千分之几普通模型会直接学成“全预测正常”同时交易行为随时间快速漂移用随机划分训练出来的分数一到新的时间窗口就缩水。很多第一次接触这个项目的开发者把数据简单洗一洗直接丢给树模型验证集 AUC 跑出 0.94 就以为稳了结果换到按时间切分的验证集上只有 0.88。这篇文章要解决的问题很具体这个项目到底该怎么理解数据、特征工程做到什么程度就够了、基线怎么搭、不平衡怎么处理、哪些地方最容易翻车。我按自己实操时的顺序来讲先搭验证体系再做特征最后再谈模型和阈值。适合正在做支付风控、反欺诈建模或者想在表格数据竞赛里建立一套可复用流程的人。2. 理解数据字段全是代号验证集怎么切是第一个生死线第一次拿到训练数据时第一反应是找字段文档结果大部分字段根本没有文字说明。常见做法是不要被字段名牵着走先把字段按可推断的含义分组再决定每一组怎么进模型。这个项目的特征不是单一表格更像几组匿名变量拼在一起的集合处理方式完全不同。2.1 字段分组怎么看哪些是数值、哪些是身份、哪些干脆是噪声字段分组是有先验规律的。TransactionDT 是交易发生的秒级时间戳TransactionAmt 是交易金额ProductCD 是产品代码接着是 card 系列、addr 系列、dist 系列这样的身份与距离字段再往后是 C、D、M、V 这几组匿名特征。V 系列的字段数量非常多缺失率也不低如果不做筛选模型会花大量精力去拟合纯噪声。字段分组可推断含义常规建模处理TransactionDT交易时间秒级时间戳不直接用原始值抽取小时、星期等周期特征TransactionAmt交易金额做 log1p 变换同时保留原始值ProductCD产品代码类别特征缺失值补一个独立类别card、addr、dist 系列卡、地址、距离信息高基数类别做频率编码控制维度C 系列匿名数值字段直接进树模型先做缺失率筛选D 系列可能与时间差值相关观察缺失情况按数值特征处理M 系列匿名类别字段类别编码不要独热V 系列大量匿名数值字段缺失率过高的删除其余统一填充第一次处理时不要试图把所有字段都用上。我的习惯是先跑一个最小读取脚本看一眼形状、缺失率和目标分布再决定后续动作。代码长这样import pandas as pd train pd.read_csv(train.csv) y train[isFraud] X train.drop(columns[isFraud]) print(train shape:, X.shape) print(positive ratio:, y.mean()) missing X.isnull().mean() drop_cols missing[missing 0.9].index X X.drop(columnsdrop_cols) print(drop columns:, len(drop_cols)) print(left columns:, X.shape[1])这段代码做了三件事加载数据统计正样本占比把缺失率超过 90% 的列直接删掉。正样本占比决定后面类别权重怎么设缺失率筛选主要针对 V 系列这种大量稀疏匿名字段。缺失率超过 90% 的列即使里面有点信号也会被填充带来的噪声盖过大多数时候删掉更干净。注意这里还没有做任何数据清洗也没有做特征工程。先看形状和分布能帮你判断这个数据集的规模和稀疏程度。如果正样本占比在 0.01 以下后面的训练策略就必须围绕不平衡展开而不是普通分类的思路。2.2 验证集按时间切不按行随机切这个项目里最常见的错误就是随机划分训练集和验证集。随机划分假设样本独立同分布但交易数据天然有时间结构。欺诈模式会随着风控策略、支付渠道、季节发生变化今天学到的规则可能下个月就失效。我一般会先把数据按 TransactionDT 排序再取前 80% 做训练后 20% 做验证。这样做出来的分数才接近未来真实的表现。代码实现很简单X X.sort_values(TransactionDT).reset_index(dropTrue) y y.loc[X.index] split_day X[TransactionDT].quantile(0.8) train_mask X[TransactionDT] split_day val_mask X[TransactionDT] split_day X_train, X_val X[train_mask], X[val_mask] y_train, y_val y[train_mask], y[val_mask] print(train days:, X_train[TransactionDT].min(), X_train[TransactionDT].max()) print(val days:, X_val[TransactionDT].min(), X_val[TransactionDT].max())这里用 TransactionDT 的 80% 分位数作为切分点而不是直接按行数切好处是训练集和验证集在时间上是连续的不会出现中间断档。实际比赛中不同时间段的欺诈比例波动很大建议再做一次按小时或按天的画图确认看验证集里正样本占比有没有异常。时间切分付出的代价是验证集表现往往略低于随机划分因为时间漂移本身就是一种额外的干扰。这才是真实上线时会遇到的情况别因为分数不好看就换回随机划分那是自己在骗自己。2.3 数据泄漏从哪里来统计量泄漏和身份字段重叠切完验证集之后还要检查一个隐蔽问题特征里的统计量是不是用全量数据算出来的。比如对 card 字段做频率编码如果先拼接整个数据集再统计训练集和验证集之间就带着未来的信息。正确做法是先只统计训练集再把映射应用到验证集和测试集。身份类字段的重叠也要注意。card、email、addr 这类字段在训练集和验证集里可能出现大量交叉如果模型发现“这张卡在训练集出现过且不是欺诈”它会直接用这个记忆去判断验证集时间切分也会被这种捷径破坏。处理方式是只保留身份字段的频率特征不保留原始卡号之类的标识让模型学的是“这张卡出现的次数”而不是“这张卡的编号”。3. 特征工程与基线模型先拿到 0.90 的 AUC再谈调参特征工程在这个项目里不需要做得夸张。数据质量本身并不差差的是验证方式和模型参数。我的经验是先把三类特征做出来金额变换、时间周期特征、身份字段频率统计。这三样东西够跑出一个过得去的基线之后再根据模型表现决定是否深入。3.1 最小特征集金额做对数变换时间抽周期身份字段做频率金额特征 TransactionAmt 是右偏分布大额交易少但数值极端直接喂给树模型会导致切分点集中在大额一侧。常见的补救是取 log1p把长尾压缩。时间戳不能直接用原始秒数否则模型会记住某个具体时间段而不是学到周期性规律。身份字段基类别很多独热会爆炸频率编码是更稳妥的方案。import numpy as np def make_features(df, card_count_mapNone): df df.copy() df[TransactionAmt_log] np.log1p(df[TransactionAmt]) df[hour] (df[TransactionDT] // 3600) % 24 df[weekday] (df[TransactionDT] // (24 * 3600)) % 7 if card_count_map is None: card_count_map df[card1].value_counts().to_dict() df[card1_count] df[card1].map(card_count_map) df[TransactionAmt_deviation] ( df[TransactionAmt] - df[TransactionAmt].mean() ) / df[TransactionAmt].std() return df, card_count_mapTransactionAmt_log 解决长尾分布问题hour 和 weekday 把时间戳转成周期性特征让模型能捕捉凌晨、工作日晚上的欺诈波动card1_count 表示这张卡在训练集中出现的次数新卡和高频卡在风险上差异很大TransactionAmt_deviation 是一个简单的离群程度指标对金额异常交易有直接的区分能力。注意 card1_count 的映射只用训练集计算。对于测试集里没见过的 card1map 会返回 NaN这里要补 0表示“这张卡的历史次数未出现过”而不是把全量数据的统计结果带进去。这是频率编码最容易出错的地方很多人顺手对全量数据做 value_counts验证集看似很高实际上已经泄漏。3.2 基线怎么搭线性模型和树模型同时跑对比才有效基线模型我一般同时跑两个逻辑回归和轻量级梯度提升树。逻辑回归用来验证特征是否有线性区分度树模型用来验证非线性结构。不要只跑一个否则你不知道特征工程起作用是线性规律还是非线性规律。逻辑回归需要处理缺失值和标准化树模型则可以直接吃 NaN。简单起见数值列统一用中位数填充再标准化。代码里只演示训练和评估部分from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score num_cols X_train.select_dtypes(include[np.number]).columns X_train_num X_train[num_cols].fillna(X_train[num_cols].median()) X_val_num X_val[num_cols].fillna(X_train[num_cols].median()) lr LogisticRegression(max_iter500, C1.0) lr.fit(X_train_num, y_train) pred_lr lr.predict_proba(X_val_num)[:, 1] print(LR AUC:, roc_auc_score(y_val, pred_lr))这里有一个细节验证集的缺失值用训练集的中位数填充而不是验证集自己的中位数。原因和频率编码一样避免把验证集信息泄露进预测过程。C1.0 是逻辑回归默认的正则强度对这个项目可以先不调等特征改完再动。3.3 树模型参数表先把手感调对再去做精细搜索树模型是这类表格数据的默认答案。LightGBM 这类库上手快但参数自由度大。不要一上来就用随机搜索先掌握几个关键参数的大致效果。参数常见范围影响learning_rate0.02 - 0.1越低效果越好但训练越长num_leaves16 - 64控制模型复杂度太大容易过拟合min_child_samples20 - 100防止个别样本被单拎出来建模subsample0.7 - 0.9行采样降低方差colsample_bytree0.7 - 0.9列采样提升泛化能力n_estimators由早停决定不要手动固定用早停控制树模型的玄学就在这不同数据集上最优参数差异很大但方向是一致的学习率高容易过拟合叶子数大容易记住孤立模式行采样和列采样能换稳定性。我一般先用 learning_rate0.1num_leaves31min_child_samples50再看验证集 AUC如果表现正常再降学习率拉长训练。代码里用 early_stopping 控制树的数量import lightgbm as lgb model lgb.LGBMClassifier( objectivebinary, learning_rate0.1, num_leaves31, min_child_samples50, subsample0.8, colsample_bytree0.8, n_estimators5000, ) model.fit( X_train, y_train, eval_set[(X_val, y_val)], eval_metricauc, callbacks[lgb.early_stopping(200)], )这里 n_estimators 给 5000但不代表会真正训练 5000 棵early_stopping 会在验证集 AUC 连续 200 轮不提升时自动停止。注意 eval_metric 要显式指定为 auc否则默认依据 Log Loss 的下降判断早停而在这个极端不平衡的数据里Log Loss 的下降节奏和 AUC 并不完全同步容易提前停掉。4. 不平衡处理的正确姿势权重、早停、融合三层推进不平衡是这个项目里绕不开的话题。正样本占比可能只有 0.005模型很容易陷入“全部预测正常也能拿很高准确率”的假象。但欺诈检测最后看的是排序能力和阈值选择不是准确率。所以处理不平衡的核心不是让模型预测出更多正类而是让正类的排序更靠前。4.1 不要盲目下采样先试类别权重下采样是把正负样本比例人为拉平训练更快但会丢掉大量负样本中的结构信息。上采样则容易让模型过拟合到重复的正样本。常见做法是给损失函数加类别权重让少数类的损失贡献更大相当于模型每看一个正样本就从中学到更多信息。正负样本比值可以算出来但不要直接把这个比值设成权重那样会把模型推得过于激进验证集上可能 PR 曲线很好看换一段新数据立刻崩。pos_ratio (y_train 0).sum() / (y_train 1).sum() print(pos_ratio:, pos_ratio) model_weighted lgb.LGBMClassifier( objectivebinary, scale_pos_weightpos_ratio / 10, learning_rate0.05, num_leaves31, min_child_samples50, n_estimators5000, ) model_weighted.fit( X_train, y_train, eval_set[(X_val, y_val)], eval_metricauc, callbacks[lgb.early_stopping(200)], )scale_pos_weight 设为 pos_ratio / 10意思是只补正样本损失的十分之一。完整比值是“完全均衡”的情况通常权重太高。到底多少合适取决于验证集 PR-AUC而不是训练集 AUC。先用小权重把基线稳住再逐步加大看到验证集 AUC 不再涨就停。4.2 训练策略三件套低学习率、早停轮数加大、权重温和这个项目不适合用默认的 0.1 学习率一步到位。我一般降到 0.05 或 0.02然后把早停轮数从 100 提到 300。学习率低意味着每一棵树的贡献小模型需要更多轮数才能收敛但泛化性更好。有人觉得早停 100 和 300 差别不大实际在正样本极稀疏的数据里差别非常明显。如果模型在验证集上表现不稳定还可以把行采样降到 0.7列采样降到 0.7减少每棵树之间的相关性。梯度提升树本质是很多弱树求和每棵树都看过全部数据会导致后续树难以为前一轮的残差提供新信息。4.3 融合单模型不涨分就先做概率排名平均单模型调到头之后最常见有效的提升手段是多种模型融合。但这里的融合不是粗暴地平均概率因为不同模型输出的概率尺度不一样逻辑回归的概率偏高树模型的概率相对保守。直接平均会把逻辑回归的极端值带进来。常见做法是先把每个模型的预测值转成排名再做平均。这相当于只取每个模型的排序意见不关心它的概率绝对值。代码里把排名都归一化到 0 到 1 之间再按权重合并。from scipy.stats import rankdata pred_lgb model_weighted.predict_proba(X_val)[:, 1] pred_lr lr.predict_proba(X_val_num)[:, 1] rank_lgb rankdata(pred_lgb) / len(pred_lgb) rank_lr rankdata(pred_lr) / len(pred_lr) final_score 0.7 * rank_lgb 0.3 * rank_lr print(Rank blend AUC:, roc_auc_score(y_val, final_score))权重怎么定如果树模型明显强于线性模型给树模型 0.7 是合理的起点两个模型差距不大就给 0.5 对半分。融合提升通常是零点零零几的幅度别期待大幅上涨。如果发现融合之后 AUC 反而下降优先怀疑验证集没切好而不是融合方法本身。5. 避坑指南欺诈检测里最容易翻车的 5 个地方这个项目的坑不像普通分类任务那么直观很多问题藏在数据结构和验证方式里。以下几条都是实操中反复出现的问题每一条我都按“现象 → 原因 → 解决”写清楚。5.1 随机打乱验证集AUC 虚高到不真实现象按全量数据随机抽样做训练验证AUC 能到 0.95 以上非常漂亮换成时间切分立刻掉到 0.90 左右测试集直接不认账。原因随机划分默认样本独立同分布但交易数据有强时间结构验证集里混入了与训练集同一时期的高相似样本模型偷偷学到了时间批次特征和身份字段记忆。解决验证集一律按 TransactionDT 排序后切分。更稳妥的做法是按时段切分比如前 30 天训练后 7 天验证并且确认验证集时间范围完全不在训练集之后。评估阶段只认这种验证方式的结果。5.2 金额特征不做变换树模型白白浪费了区分度现象TransactionAmt 的特征重要性排第一可是把特征删掉后 AUC 反而没怎么降甚至提升了一点点。原因金额右偏严重少数大额交易把切分点拉走树模型把大量分裂浪费在低频大额区间中低频小额的正常交易反而区分不开。解决两个方案都要试。一种是 log1p 变换后入模另一种是同时保留原始金额和 log 金额让模型自己判断用哪种形态。对比验证集 AUC选更好的组合别想当然只留一个。5.3 类别权重拉满召回率上去了精确率崩了现象把 scale_pos_weight 设成正负样本全比值验证集召回率明显升高但验证集上一旦按阈值截断预测为正类的样本里绝大多数是误杀。原因权重过大让模型把概率整体推高阈值不变时误判率剧增。业务上风控会把正常用户拦下来这在欺诈检测中是直接等于损失。解决权重从比值的十分之一起步一步一步往上加每次都在验证集看 PR-AUC 和精确率召回率曲线不只是 AUC。记住目标不是提高正样本预测数量而是让排序更合理把真正欺诈的样本排在历史最高位。5.4 早停轮数设太小模型还没学完就被叫停现象训练到一半验证集 AUC 停止上升早停 50 轮后就停了最后测试分数低且玄学重跑一次结果还不一样。原因GBDT 的验证集 AUC 曲线不是单调的中间可能有一段短暂停滞然后继续爬升。早停窗口太短会把后续有效学习全部打断参数上随机性又大导致结果不稳定。解决学习率降到 0.02 到 0.05早停轮数提到 200 到 300。如果你用的是 0.1 的高学习率模型本身就容易震荡这种情况下先降学习率再考虑早停窗口。核心原则是让模型在“平稳收敛”的状态下确定停止点而不是在“偶然波动”里被迫停。5.5 把 TransactionDT 原始时间戳当特征换一段数据就失灵现象把 TransactionDT 直接作为数值特征加进模型验证集 AUC 立刻涨了幅度还不小但把验证集换成另一个时间段重新评估分数暴跌。原因模型学到了具体时间点与目标之间的相关性本质是记忆批次效应不是学习欺诈模式。欺诈样本集中在某几个时间窗模型只需要记住那几天就能在验证集上刷出好成绩。解决从时间戳中抽取 hour、weekday、是否周末这种周期性特征删除原始时间戳。如果你要保留连续性时间信息可以用“距离数据集起始日期的天数”作为趋势项但要在多个时间段上验证它的稳定性不要只看一个验证集。6. 最后的把关从排名分变成决策分再看分组稳定性训练做完了通常手头是验证集上的一组分数。此时 AUC 已经不能决定业务能不能用真正要面对的问题是阈值设在哪以及这个模型在每一段时间里是不是都稳定。6.1 阈值怎么定用验证集精确率召回率曲线找平衡点先把验证集上的预测分数和标签拉出来用精确率召回率曲线找到满足业务约束的阈值。比如风控要求召回率不能低于 0.6就去曲线上找到对应的精确率再反查出阈值。不要用默认的 0.5树模型在这种不平衡数据上给出的概率分布是偏的0.5 几乎没有意义。from sklearn.metrics import precision_recall_curve precision, recall, thresholds precision_recall_curve(y_val, final_score) target_recall 0.6 valid_idx recall target_recall best_threshold thresholds[valid_idx][precision[valid_idx].argmax()] print(best threshold:, best_threshold)这段代码的意思是在召回率不低于 0.6 的区间里选择能让精确率最大化的那个阈值。阈值只从验证集上取确定之后不要在测试集上反复调整否则又会过拟合到测试集。6.2 分组稳定性检查把预测分数按月、按周拆开看最后一个习惯是把验证集按时间窗拆开比如每 2 天一组看每组 AUC 和正样本捕获率。如果模型在某个时间段 AUC 明显下降通常意味着欺诈模式发生了漂移或者某个新渠道上线带来了新数据分布。val_df X_val[[TransactionDT]].copy() val_df[pred] final_score val_df[y] y_val.values val_df[day] val_df[TransactionDT] // (24 * 3600) for day, group in val_df.groupby(day): auc roc_auc_score(group[y], group[pred]) print(day, auc:, round(auc, 4), count:, len(group))我最早做这个项目时就是栽在随机切分和时间戳当特征这两个坑里前前后后浪费了一个多星期分数怎么都上不去。后来把验证体系修正成时间切分把身份类字段的频率统计控制在训练集内部同一套特征直接涨了几千分。这个项目值得做的价值不在模型多复杂而在于建立一套不撒谎的评估流程。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进