首页 / 资讯中心 / 文章详情

交易欺诈检测实战:从特征工程到LightGBM与神经网络融合的完整建模基线

交易欺诈检测实战:从特征工程到LightGBM与神经网络融合的完整建模基线 ★ FEATURED ARTICLE
简介面向Kaggle IEEE-CIS欺诈检测竞赛的Jupyter Notebook资源聚焦用户是否会发生点击欺诈的二进制分类问题适合正在备赛的机器学习初学者与进阶选手。资源围绕探索性数据分析EDA展开覆盖数据清洗、可视化分析与特征工程三个核心环节能帮助读者快速掌握该数据集的分布规律、缺失情况和异常模式从而为后续特征筛选与模型选择提供依据。压缩包仅1.02MB共6个文件包括1个EDA.ipynb主分析笔记本、3个Python工具脚本分别负责数据清洗、图表报告与特征准备、1个README说明文档及gitignore配置结构紧凑便于按需查看。目前已有599人学习下载。借助这套代码读者可系统地走一遍EDA流程直接复用现成的清洗与可视化函数节省重复编码时间同时还能借鉴作者在Kaggle内核中简化EDA流程的思路将分析结论平滑落地为高效基线模型对入门欺诈检测与金融风控场景很有帮助。1. 一份 Notebook 就能跑通的交易欺诈检测基线从原始表到 AUC 指标做交易欺诈检测的第一感觉是数据太多能用的标签太少。某次拿到一份匿名信用卡交易数据里面有近 60 万笔交易欺诈样本占比不到 4%特征还被拆成两张表交易行为主表和用户身份信息表后者里几十列缺失率超过 70%。直接把原始特征丢给梯度提升树本地交叉验证 AUC 只有 0.87 左右误报率也压不下来。这份 Jupyter Notebook 项目给了一条完整的落地路线把两张表按交易编号对齐构造金额偏差、时间周期、历史频次和缺失标记再用 5 折交叉验证训练树模型最后与一个浅层神经网络做加权融合验证 AUC 能推到 0.93 附近。它不是玄学调参而是一套可以快速复现的风控基线。想进入信贷风控、反欺诈建模的同学或者想把竞赛里学到的特征工程和交叉验证思路搬回业务数据的开发者都可以照着跑一遍看到从原始 CSV 到最终概率分数的完整链路。2. 数据合并与特征工程Transaction 和 Identity 两张表如何变成宽表2.1 先看清主表Transation 行为特征和 Identity 补充信息怎么对齐这类竞赛数据的原始形态一般是一张交易行为表加一张身份信息表。交易行为表里有交易编号、交易时间戳、交易金额、产品编码、卡号相关的匿名列、地址区号、邮箱域名等身份信息表里则记录设备类型、网络参数、操作系统和浏览器环境很多列以id_开头缺失率非常高。问题核心是两表靠什么连接、连接后会不会把样本量拉偏。我一般会先用交易行为表做训练主表通过交易编号和身份信息表做 left join。这样做能保证每一条交易样本都保留在原位身份表匹配不上的样本变成缺失列而不是把正常交易直接删掉。如果反过来用 inner join看起来没有缺失值但会损失大量没有身份记录的交易在风控场景里没有设备信息的用户并不代表风险更低很可能只是埋点没收集全。import pandas as pd train_tx pd.read_csv(../data/train_transaction.csv) train_id pd.read_csv(../data/train_identity.csv) df train_tx.merge(train_id, onTransactionID, howleft) print(df.shape) print(df[isFraud].mean())这段代码的关键参数是howleft左边是交易行为表右边是身份表合并后左表行数保持不变。第一行打印能看到数据的宽表规模第二行打印的是训练集欺诈比例通常在 0.03 到 0.04 之间。这样一组输出能让你在动手做特征之前就确认主表行数和标签分布没有在合并阶段变形。合并后要注意主键类型。交易编号在两张表里如果一列是 int64、另一列是字符串merge 时会报错或者产生大量空匹配。常见做法是先统一转成 int32 再合并既避免类型不一致也为后续内存优化做铺垫。身份表一般不是每一行交易都能匹配上所以身份表那些列会出现大量 NaN这种缺失对树模型可以接受但在神经网络里要单独处理。2.2 特征工程小套路金额偏差、时间周期和滚动频次做完表结构层面的合并下一步是把原始列变成模型更容易学习的数值特征。金额字段最常见的做法是取对数变换TransactionAmt分布很偏少数大额交易会把均值拉得很高取对数后更接近正态分布。但这只是一个单列变换更有效的是金额偏差特征让单笔金额与该客户该产品线的平均金额做对比。import numpy as np df[TransactionAmt_log] np.log1p(df[TransactionAmt]) # 用产品的全局均值做分母刻画单笔金额是否偏离常规 df[amt_deviation] df[TransactionAmt] / ( df.groupby(ProductCode)[TransactionAmt].transform(mean) 1 )np.log1p等于log(1x)好处是金额为 0 时不会产生负无穷。transform(mean)会给每一行返回所属产品编码的平均金额不需要 reduce 之后再 merge批量操作效率高。分母加 1 是防除零的小细节。这个偏差特征在欺诈检测里往往比原始金额更重要因为盗刷行为常常表现为金额和用户历史习惯不一致。时间特征不要只把交易时间戳当作连续数值。交易时间戳在原始数据里是从某个起始点开始的秒数直接丢给树模型模型只会学到“时间越大越靠后”对欺诈判断意义不大。我一般会从时间戳里拆出小时、星期、是否是凌晨等周期特征。风控经验里深夜交易、非工作时段的行为往往更容易踩中风险规则。dt_seconds df[TransactionDT] df[hour] (dt_seconds // 3600) % 24 df[weekday] (dt_seconds // 86400 3) % 7hour的取法是把总秒数换算成小时再对 24 取余weekday则需要根据起点日期做偏移偏移量不同得到的结果也会不同。特征构造完成后最好先交叉个列表统计一下欺诈比例如果发现凌晨 3 点欺诈率是白天的两倍这才说明特征有区分度。另一个非常容易踩坑的是频次类特征。很多人拿到某个高基数类别列比如邮箱域名会直接value_counts()后把频次映射回去这就是常见的特征穿越问题你用了全量数据统计频次意味着模型在训练阶段就已经看到了未来交易对该邮箱的计数。解决方法是使用滚动频次也就是按时间顺序对某个分组列做累计计数新出现的一笔交易只依赖它出现之前的历史。df df.sort_values(TransactionDT).reset_index(dropTrue) for feat in [emaildomain, card4]: df[f{feat}_hist_cnt] df.groupby(feat).cumcount() 1cumcount()在同一个分组内从 0 开始递增加 1 后表示当前这笔交易是该实体出现过的第几笔。它只使用该笔之前的数据天然没有未来泄漏。这个方法不需要做窗口回看代码很短但对 fraud 检测场景特别合适。缺点是老实体的计数值会非常大模型需要对长尾数值做学习所以训练时一般配合分箱或对数变换使用。2.3 内存优化按列降精度别让 Jupyter 内核直接崩合并后的宽表很容易到 200 列以上Pandas 默认把数值列读成 int64 和 float64每列 8 字节几百列乘上 60 万行内存很快就超过 1GB。在资源有限的机器上这个量级还不足以崩但如果后来又做了多个频次映射和 mergeJupyter 内核就可能会重启前面的运行结果全部丢失。降精度是最有效的手段float64 转 float32 内存减半float32 的精度对模型打分和 AUC 评估几乎无影响。def downcast(df): 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) elif df[col].dtype object: if df[col].nunique() 500: df[col] df[col].astype(category) return df df downcast(df)astype(category)会把字符串列转成内部整数编码在数据量大的时候能明显压缩内存。但要注意nunique() 500这个门槛不是绝对的。如果某个字符串列基数很小但 null 占比很高转 category 后缺失值会变成一个隐藏类别后续分组和建模时容易出问题。另一个更稳妥的做法是在读取阶段就指定 dtype我通常先读前 1000 行打印各列类型再手动构造一个 dtype 映射字典把已知的高缺失 float 列设为float32大整数主键设为int32。内存优化这里还有个小坑转 category 后如果用它做groupby聚合Pandas 在某些版本下会保留所有类别包括已经不在当前样本里的类别导致后续 merge 时出现意外行数。所以我会在全部特征工程做完、准备建模之前再把真正需要的字符串列转成 category而不是一上来就转。3. 核心模型与验证一次留出验证的 AUC 是怎么从 0.85 涨到 0.93 的3.1 评估指标为什么用 AUC 而不是准确率欺诈检测天然是一个类别不平衡问题训练集里欺诈样本只有 3.5% 左右。如果按 0.5 为阈值判断把所有交易预测为正常准确率也能到 96.5%看起来模型很强实际上一个欺诈交易都找不出来。准确率在极不平衡分类任务里几乎不算指标我通常会先看 AUC。AUC 表示随机一个正样本得分比随机一个负样本得分高的概率。它不看阈值只关注排序能力所以换阈值调分类边界时AUC 不会大起大落。AUC 0.93 的含义是把欺诈样本和正常样本放在一起让模型打分93% 的概率欺诈样本排在前面。这个数值在竞赛和业务基线里都很直观。除了 AUC我还会同时输出召回率、精确率以及假阳性率在 1% 时的召回率方便后续风控规则做成本估算。3.2 LightGBM 参数与 5 折交叉验证的训练框架模型主体我选的是梯度提升树里的 LightGBM。它的优点是训练快、原生支持类别特征、对缺失值不敏感。在这个项目里我采用 5 折交叉验证而不是简单留出集因为单一验证集容易被某个时间段的极端分布带偏AUC 波动太大。为了观察泛化能力我还会保留一份按时间排序的最后 20% 数据作为最终复测集。from sklearn.model_selection import StratifiedKFold import lightgbm as lgb import numpy as np params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, min_child_samples: 40, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbosity: -1, } X df.drop(columns[isFraud, TransactionID, TransactionDT]) y df[isFraud].astype(int) folds StratifiedKFold(n_splits5, shuffleTrue, random_state42) oof np.zeros(len(df)) for fold, (tr_idx, va_idx) in enumerate(folds.split(X, y)): train_data lgb.Dataset(X.iloc[tr_idx], y.iloc[tr_idx]) valid_data lgb.Dataset(X.iloc[va_idx], y.iloc[va_idx], referencetrain_data) model lgb.train( params, train_data, num_boost_round2000, valid_sets[valid_data], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)] ) oof[va_idx] model.predict(X.iloc[va_idx], num_iterationmodel.best_iteration)StratifiedKFold(n_splits5, shuffleTrue, random_state42)的含义是把样本均匀分成 5 份每一份都保留原始欺诈比例。shuffleTrue让样本尽量打散避免连续时间段内的相同环境特征被分成一簇。early_stopping(100)表示如果连续 100 轮验证集 AUC 没有提升就停止训练最终用model.best_iteration对应的迭代次数做预测这一步能显著降低过拟合。参数里num_leaves31控制树的复杂度min_child_samples40要求叶子节点至少有 40 个样本才允许分裂防止模型记住个别异常值。feature_fraction0.8每次迭代只用 80% 的列bagging_fraction0.8每轮只采样 80% 样本两者都是轻量正则。如果不做任何限制树模型在 2000 轮训练里很容易把验证集 AUC 拉到很高但到时间序列复测集上就崩掉。3.3 集成策略LightGBM 与简单神经网络的加权融合树模型对表格特征很擅长但会把原始类别和数值列当作独立关系学习神经网络在类别 embedding 和特征交叉上更有优势。我在这份 Notebook 里并没有把神经网络做得很复杂只用了两层隐藏层把特征压缩到 64 维再降到 32 维最后输出一个概率分数。import torch import torch.nn as nn class FraudMLP(nn.Module): def __init__(self, in_dim): super().__init__() self.net nn.Sequential( nn.Linear(in_dim, 64), nn.BatchNorm1d(64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1), nn.Sigmoid(), ) def forward(self, x): return self.net(x).view(-1)BatchNorm1d的作用是让每一层的输入保持稳定缩放尤其是在金额和时间这类数值尺度差异很大的场景里不加 BatchNorm 模型很难收敛。Dropout(0.2)每次训练随机丢弃 20% 的神经元目的是让网络不要过度依赖某几个特征通道。神经网络输入前数值特征需要先标准化缺失值用 0 填充类别列做顺序编码后放进 embedding或者简单转成 one-hot 交给 Linear 层这里我为了减少代码篇幅直接用整表数值接进去。树模型和神经网络各自会输出一个落在 0 到 1 之间的分数。常见做法是加权平均我一般先用经验值 0.7 和 0.3再在验证集上对权重做一次简单搜索。# lgb_oof 和 nn_oof 都是在交叉验证里产生的 out-of-fold 预测 for w in np.arange(0.5, 1.0, 0.05): blend w * lgb_oof (1 - w) * nn_oof print(w, round(roc_auc_score(y, blend), 5))out-of-fold是指在每次折里模型只预测它没见过的验证样本因此最终得到一组对全样本公平的概率。用这组概率来调融合权重不会像“用测试集调参”那样产生数据透视误差。权重搜索的步长设为 0.05兼顾效率和精度。实际结果通常是 0.75 左右融合效果最好这说明树模型在该问题上的贡献确实大于神经网络但神经网络提供的互补信息依然有价值。4. 翻车记录与常见问题排查处理这份数据时最容易踩的 5 个坑4.1 内存溢出与 dtype内核重启跑了半小时的单元格全部失效现象读取训练表后又 merge 身份表再构造了几十个聚合特征Jupyter 内核直接重启所有运行变量丢失前面的预览输出也不见了。原因Pandas 默认读入的 float64 和 int64 每列占用 8 字节。当特征数膨胀到 200 列以上时中间临时数组的内存占用远超最终 Dataframe内核崩溃其实是内存峰值超限。身份表里大量缺失列也会以 NaN 的形式占内存不被低精度类型替换。解决不要只对最终 DataFrame 做降精度要在读取阶段就按列指定 dtype。先临时读入一个小样本打印出所有列的类型再构造 dtype 映射字典。dtype_dict { TransactionID: int32, TransactionDT: int32, TransactionAmt: float32, card1: float32, addr1: float32, } df pd.read_csv(../data/train_transaction.csv, dtypedtype_dict)高缺失的匿名列在模型里会被当作缺失值它们的实际大小并不需要 double 精度全部用float32就够了。用 dict 映射而不是读完之后统一循环转能减少一次中间复制对大文件尤其明显。从那以后我先是检查列类型再决定要不要继续跑。这里养成习惯比堆配置更重要。4.2 特征穿越和验证集切分本地分数很高换时间切分后立刻打回原形现象在随机 5 折交叉验证里 A 同学看到了 AUC 0.95兴奋地把同一个模型换成时间序列切分后AUC 只剩 0.91毫无竞赛竞争力。原因随机切分把同一时间段内互相相似的交易同时放入训练和验证模型学到了时段特有的分布规律而不是稳定可迁移的欺诈行为模式。另一个隐藏原因是某些频次特征使用了全局统计量验证集中的样本参与过特征计算等于标签经过特征工程间接流入了训练集。解决改用时间顺序切分把前 80% 作为训练集后 20% 作为验证集。如果是交叉验证则按时间分成多段每一折用过去的数据预测未来的数据。同时把所有基于value_counts()或groupby().transform(mean)的聚合特征改成滚动模式。split_point int(len(df) * 0.8) train_part df.iloc[:split_point] valid_part df.iloc[split_point:]这里没有shuffle所以验证集永远是训练集之后发生的数据。模型在验证集上得到的指标比随机切分更接近线上真实情况。对于频次特征如果特征计算依赖整个训练集那验证集里的计数就会包含它自身信息这一点要通过代码审查发现而不能只看 AUC 数字。4.3 阈值不合理概率全部集中在 0.2 以下默认 0.5 完全无效现象模型训练结束调用预测概率后把所有大于等于 0.5 的样本标记为欺诈结果精确率很低召回率几乎为零仿佛模型白训了。原因正样本占比只有 3.5%模型输出的概率分数整体偏向 0 附近几乎没有样本能超过 0.5。0.5 只是平衡二分类的默认边界不是类别不平衡场景下的业务阈值。解决在验证集上用 ROC 曲线寻找满足业务约束的阈值。假设业务要求假阳性率控制在 1% 以内那就找出 FPR 最接近 0.01 的阈值作为最终截断点。from sklearn.metrics import roc_curve fpr, tpr, thres roc_curve(y_valid, prob_valid) idx np.argmin(np.abs(fpr - 0.01)) best_thr thres[idx]thres数组里存的是 roc 曲线对应的判断阈值np.argmin找到离目标假阳性率最近的位置再用同一位置的阈值去判断在线交易。需要注意模型每次更新后这个阈值都可能变化不能把上一次的阈值当作固定配置写进代码否则会引入新的线上和离线不一致。5. 从 Notebook 到可复现脚本环境、目录和执行顺序5.1 先用虚拟环境再把文件按“数据、预处理、训练、评估”分层Notebook 能交互地跑单步调参但拿到新机器上复现时如果代码散落在几十个单元格里顺序稍微错一点就会得到完全不同的结果。为了把这个项目变成一份别人也能直接跑起来的资源我会先建一个干净目录再用虚拟环境锁住依赖版本。fraud_detection_project/ ├── data/ │ ├── train_transaction.csv │ ├── train_identity.csv │ ├── test_transaction.csv │ └── test_identity.csv ├── configs.py ├── preprocessing.py ├── train_model.ipynb ├── inference.py └── submission.csvconfigs.py存的是列名映射、dtype 字典、模型参数和随机种子Notebook 里所有关键配置从它读取。这样做的好处是改参数不用在单元格里搜索字符串两个命名相同的变量不会在不同 notebook 状态里互相覆盖。preprocessing.py执行完后会产生一个df.parquet文件train_model.ipynb读取这个中间文件训练模型inference.py读取测试表用训练好的特征管线做预测。三个文件各自只负责一个阶段排查问题时能直接定位。python -m venv .venv source .venv/bin/activate pip install pandas1.5.3 numpy1.24.3 scikit-learn1.2.2 lightgbm4.0.0 pip install jupyter nbconvert版本号锁定并不是为了限制升级而是避免 numpy 2.0、pandas 2.x 和旧版本 scikit-learn 在某些接口上不兼容导致同一个 Notebook 在别人机器上报一堆底层异常。重点查看pandas1.5.3和scikit-learn1.2.2这个组合在分类任务里的稳定表现LightGBM 用 4.0.0 即可。5.2 运行顺序先转脚本验证再回 Notebook 调参第一次复现时我不会直接在整个 Notebook 上反复点击单元格而是建议先把核心训练流程导成 Python 脚本完整跑一遍确认没有顺序性 bug。nbconvert能把.ipynb转成.py转出来的脚本会保留单元格顺序但需要稍微调整文件路径和魔术命令。python preprocessing.py jupyter nbconvert --to script train_model.ipynb --output train_script python train_script.pytrain_script.py会从训练数据中读取中间文件训练五折模型并输出 OOF 概率。如果直接跑通了后续调参仍然可以在 Notebook 中进行。有个细节要注意nbconvert转换后的脚本不会把%matplotlib inline这种魔术命令自动剔除运行时会有提示但不会中断不影响模型训练。5.3 资源占用和预期输出别拿低配机器硬扛全量数据五折交叉验证加一个简单神经网络常规 8 核 CPU、16GB 内存的机器跑完整轮大概需要 20 到 40 分钟。LightGBM 部分占大头神经网络如果只训练几个 epoch几分钟就能跑完。内存峰值集中在特征工程阶段所以跑preprocessing.py时最好关掉浏览器里其他重型任务。表格里是一组典型运行表现阶段耗时内存峰值输出读取与合并2 分钟2.1GB宽表特征工程5 分钟1.6GB特征表五折 LightGBM15 分钟3.8GBOOF 概率神经网络8 分钟2.5GBOOF 概率加权融合1 分钟不足 1GB最终分数同一份脚本在不同机器上的运行时间有浮动但只要随机种子固定AUC 应该在 0.92 到 0.94 之间。如果看到验证集表现极低优先检查是不是把isFraud、TransactionID这些标识列不小心留在了模型特征里。submission.csv是最后一列交易编号加预测概率提交之前还要检查行数和测试表情数是否一致。6. 二次开发方向把它嵌进在线风控的改造习惯如果只是把这份 Notebook 跑通那它还停留在竞赛复现层面。真要做二次开发我建议先把训练和推理的特征对齐机制固化下来。Notebook 里那几十个单元格可以调参但生产预测环境不允许缺一个步骤。一个核心原则是线上预测时拿到的每一条特征必须能用当时的存量数据算出来。常见做法是把所有特征构造逻辑封装成一个转换类。训练时对全量数据做历史频次统计推理时对这单笔交易单独做相同统计两类结果的语义必须一致。如果训练时用的是整个数据集算出来的均值到了单条推理就无法复现这就是线上线下不一致的根因。我一般会把每个特征的依赖列和窗口周期写进配置每次新增特征先跑一遍统一检查脚本。class FraudScoring: def __init__(self, model, threshold0.4, rule_weight0.4): self.model model self.threshold threshold self.rule_weight rule_weight def score(self, feature_row, rule_score): prob float(self.model.predict_proba([feature_row])[0, 1]) blended self.rule_weight * rule_score (1 - self.rule_weight) * prob return blendedrule_score是由业务规则算出来的风险分比如设备指纹命中黑名单直接加 0.5历史拒付率超过阈值再加 0.2。blended把规则分和模型概率合成一个最终风险分。阈值 0.4 不是我随手写的而是用上一章提到的假阳性率约束在最近一个月的验证集上重新搜索出来的。规则和模型的权重也需要定期重算因为黑商户的逃逸策略会变固定权重会在某个阶段失效。这只是其中一个习惯。真实工程里模型文件要用joblib.dump固化到对象存储每次上线前要在保留的时间序列切片上回放一遍特征和预测对比新旧模型在 FPR 1% 时的召回率是否下降。特征文件一旦缺失宁可拒绝预测也不要返回概率 0因为缺失特征被系统静默填充为 0 的话模型会高估正常交易的风险。同行 bug 排查时我见过太多因为模型文件换机器后无法加载而翻车的现场。从那以后我每次做这类项目都要求自己至少执行三件事用时间切分跑通最小模型把特征和模型全用 pickle 备份上线前强制检查一轮实际特征计算结果。以习惯约束流程比临时记住技巧更可靠希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站