☰
欺诈检测实战:内存优化与特征工程驱动的LightGBM建模
2026/10/14 18:22:54 网站建设 项目流程

简介:针对IEEE-CIS欺诈检测竞赛,这套配套代码提供了完整的探索性数据分析流程,面向金融风控类数据竞赛选手和希望学习反欺诈建模、二分类特征工程与用户行为分析的机器学习初学者。资源包共6个文件,以Jupyter Notebook分析脚本和三个Python工具模块为主,另含README与gitignore配置,压缩包整体仅1.02MB,轻量便于快速部署。其中EDA笔记本覆盖从数据加载、缺失值与分布检查到可视化洞察的完整探索过程;三个Python模块分别承担数据清洗、报表可视化与特征工程,可帮助读者构建从原始表格到建模特征的标准处理链路。已有598人学习下载,适合用于参考用户点击欺诈识别中的异常检测思路、高基数特征处理和特征提取实践。通过研读这套代码,可以掌握此类竞赛场景下的EDA组织方式和可复用脚本写法,显著提升数据挖掘效率。

1. 先看这个数据集再动手:它跟普通二分类不一样在哪

网上常见的手写数字、房价预测这类入门项目,特征名字都是人话;而 IEEE-CIS-Fraud-Detection 这个欺诈检测项目一上来就是 390 个匿名变量,V1 到 V339 光是记字段就得花半页纸。交易数据和设备身份数据分开存放,最终评估指标是 AUC 而不是准确率,建模链路里每一步都埋着坑。这份基于 Jupyter Notebook 的完整实现,把数据合并、内存压缩、特征构造、LightGBM 训练与验证串成一条可以直接跑通的流水线,适合已经会 pandas 和 sklearn、想在真实规模表格数据上练手的人。看完你会发现,它真正值钱的部分不是调参,而是对脏数据、缺失值和内存压力的处理顺序。

2. 先把数据拆明白:交易表、身份表与合并方式

2.1 两张表的字段构成与类型分布

拿到这份资源时,你第一件事不是跑模型,而是先弄清楚手里两张表的家底。和常见的宽表不同,IEEE-CIS-Fraud-Detection 把交易信息和设备身份拆成了两个文件,很多第一次接触的人直接pd.read_csv读进来就 merge,结果要么内存爆掉,要么因为 join 方式不对做出一堆全缺失列。

交易表train_transaction.csv大概 59 万行、394 个字段,是建模的主力;身份表train_identity.csv只有约 14 万行、41 个字段。字段分布很有规律:TransactionDT是 Unix 时间戳,TransactionAmt是美元金额,ProductCD是产品代码,card1到card6是银行卡匿名字段,addr1和addr2是地址码,C1到C14与卡交易计数有关,D1到D15与时间和距离间隔相关,M1到M9是匹配标志,剩下 V1 到 V339 全部是匿名化计算特征。身份表里则是DeviceType、DeviceInfo以及一批id_01到id_38的设备指纹类匿名特征。

import pandas as pd import numpy as np trans = pd.read_csv('data/train_transaction.csv') iden = pd.read_csv('data/train_identity.csv') print('transaction shape:', trans.shape) print('identity shape:', iden.shape) print(trans.dtypes.value_counts())

逻辑说明:先打印 shape 和 dtype 分布,目的是判断哪些列是 float、哪些是 int、哪些是 object。dtype 分布决定后面内存压缩的策略,比如 float64 占大头时,转 float32 能省一半空间。参数说明:TransactionID是两表共有的主键,决定 merge 的 on 条件;两表行数差异悬殊,意味着身份表覆盖率只有约四分之一,后面 join 方式的选择会直接改变训练样本量。

2.2 inner join 与 left join 的选择:样本量取舍与身份特征覆盖

identity表只覆盖大约四分之一的交易记录,所以 join 方式不是随便选的。用 inner join,两表都存在的交易才会留下,样本量会从 59 万掉到大概 50 万;用 left join,交易表全量保留,但大约四分之三的行的身份特征全是 NaN。对树模型来说,NaN 本身可以作为一个分支方向,身份特征是缺失还是存在,往往就代表“有没有设备画像”这个信号。

我一般会先把两种 join 都跑出来对比一下。先 inner join 作为主模型,再单独做一个 left join 版本,比较验证集 AUC。如果提升很小,就说明身份特征的覆盖度不足以撑起额外的样本损耗,那后续特征工程就专注在 V/C/D 系列上。

# 用 inner join 保留两表都存在的交易记录 df_inner = pd.merge(trans, iden, on='TransactionID', how='inner', suffixes=('_t', '_i')) print(df_inner.shape) # 对照版本:left join 保留全量交易 df_left = pd.merge(trans, iden, on='TransactionID', how='left', suffixes=('_t', '_i')) print(df_left.shape)

逻辑说明:suffixes=('_t', '_i')用来区分来自交易表和身份表的同名列,比如card1在两表中都存在时,合并后会出现card1_t和card1_i,避免直接覆盖。参数说明:how='inner'会丢弃身份表里可能存在的多余行以及交易表里没有身份记录的行;on='TransactionID'要求两边主键名称一致,如果名称不一致,需要先用rename统一。

2.3 合并后的重复列与索引清理

merge 完成之后还有两个容易翻车的细节。第一个是带了_t和_i后缀的同名列,比如card1_t和card1_i,如果不清理,后面 LightGBM 会把它们当成两个不同特征,造成冗余。第二个是索引错位:merge 后生成的索引来自两个原始 DataFrame 的叠加,如果不reset_index,之后做iloc切片或train_test_split时会出现索引不连续的问题。

# 找出所有带后缀的同名列 suffix_cols = [c for c in df_inner.columns if c.endswith('_t') or c.endswith('_i')] print('duplicate suffix cols:', len(suffix_cols)) # 重置索引,避免后续抽样错位 df_inner = df_inner.reset_index(drop=True) # 对确实重复的列,优先保留身份侧 for c in suffix_cols: base = c.rsplit('_', 1)[0] if base + '_t' in df_inner.columns and base + '_i' in df_inner.columns: df_inner[base] = df_inner[base + '_i'].fillna(df_inner[base + '_t'])

逻辑说明:rsplit('_', 1)把card1_t拆成card1和t,再检查另一个后缀是否存在。身份侧设备画像更细,所以优先取身份侧的值,取不到再用交易侧兜底。注意:清理完重复列后,建议把原始后缀列删掉,避免建模时又把重复信息带进去。参数说明:reset_index(drop=True)里的drop=True表示不保留旧索引为新列,否则 DataFrame 会多出一列index,白白占内存。

3. 内存优化与缺失值盘查:跑得动才有后面的事

3.1 全表 dtype 压缩:把 float64 降到 float32

这个数据集在 Jupyter Notebook 里最容易犯的错,就是读完原始 CSV 直接做特征工程。V1 到 V339 大多是 float64,59 万行乘以 339 列,内存很快就到 2GB 以上;如果再算上 merge 后的身份列和中间变量,Notebook 内核随时可能崩溃。所以我拿到数据的第二步,永远是先做一轮 dtype 压缩。

def reduce_mem_usage(df, skip_cols=()): for col in df.columns: if col in skip_cols: continue col_type = df[col].dtype if col_type == 'object': continue c_min = df[col].min() c_max = df[col].max() if str(col_type)[:3] == 'int': if c_min > np.iinfo(np.int8).min and c_max < np.iinfo(np.int8).max: df[col] = df[col].astype(np.int8) elif c_min > np.iinfo(np.int16).min and c_max < np.iinfo(np.int16).max: df[col] = df[col].astype(np.int16) elif c_min > np.iinfo(np.int32).min and c_max < np.iinfo(np.int32).max: df[col] = df[col].astype(np.int32) else: if c_min > np.finfo(np.float16).min and c_max < np.finfo(np.float16).max: df[col] = df[col].astype(np.float32) else: df[col] = df[col].astype(np.float32) return df df_inner = reduce_mem_usage(df_inner, skip_cols=('TransactionID',)) print(df_inner.memory_usage(deep=True).sum() / 1024**2, 'MB')

逻辑说明:逐列先看数值范围,再决定降级到哪个类型。整数列按极值选择 int8、int16 或 int32;浮点列这里统一降到 float32,不降到 float16,因为 float16 在多数机器学习库中会被自动转回 float32,反而多一次转换开销。参数说明:skip_cols用来跳过TransactionID这类主键,主键不适合压缩;col_type == 'object'的列先跳过,比如P_emaildomain这类字符串,后面单独处理。

3.2 缺失率统计:哪些列该留,哪些该删

合并完成之后,身份表引入的大量 NaN 会让缺失率分布变得非常难看。很多列缺失率超过 90%,比如id_01到id_38中相当一部分,以及 V 系列里的部分列。常见做法是:缺失率超过 90% 的列,先保留跑一轮 LightGBM,看 feature importance 里有没有它们的身影;如果重要性始终为 0,再删不迟。

nan_rate = df_inner.isnull().mean().sort_values(ascending=False) high_nan = nan_rate[nan_rate > 0.9] print('缺失率超过90%的列数:', len(high_nan)) # 构造一个缺失计数特征,把“缺失程度”本身变成信号 df_inner['nan_count'] = df_inner.isnull().sum(axis=1).astype(np.int16)

逻辑说明:isnull().mean()按列计算缺失比例,排序后能直接看到缺失最严重的列。缺失计数特征把每行缺失字段的数量汇总成一个新列,LightGBM 可以利用它捕捉“设备信息越少,风险越高”的规律。参数说明:astype(np.int16)是因为缺失计数最大不可能超过 400,int16 足够,不会再占用 float64 的内存。

3.3 Jupyter Notebook 里的内存回落观测

压缩完 dtype 后,不要急着往下跑,先在 Notebook 里确认内存到底降了多少。我习惯把它写成一个单元格,每次特征工程前后都手动跑一次,对比差值。如果发现某个步骤内存暴涨,说明产生了临时副本,需要回头检查是不是groupby返回的 Series 与原索引不对齐。

import gc mem_before = df_inner.memory_usage(deep=True).sum() / 1024**2 # 这里放特征工程代码 df_inner['amt_log'] = np.log1p(df_inner['TransactionAmt']) mem_after = df_inner.memory_usage(deep=True).sum() / 1024**2 print(f'内存变化: {mem_before:.1f} MB -> {mem_after:.1f} MB') gc.collect()

逻辑说明:memory_usage(deep=True)会连 object 列里字符串实际占用的内存一起计算,比默认的浅统计准确得多。gc.collect()手动触发一次垃圾回收,把前面read_csv留下的临时 DataFrame 引用清掉。参数说明:这里没有额外参数,但其意义在于让每一步特征工程的增量成本可见;如果新增一列后内存涨了 500MB,说明中间变量没有被正确释放。

3.4 高基数 ID 类特征:label encoding 与 frequency encoding

card1到card6这类银行卡字段,唯一值数量动辄几万到几十万。直接 one-hot 编码会把内存撑爆,也不适合树模型。常见做法是 label encoding 或者 frequency encoding。label encoding 把类别映射成整数,快但会丢失频次信息;frequency encoding 统计每个类别出现的次数,对欺诈检测往往更有效,因为高频卡和低频卡的欺诈率差异明显。

from sklearn.preprocessing import LabelEncoder # label encoding 版本 le = LabelEncoder() df_inner['card1_le'] = le.fit_transform(df_inner['card1'].astype(str)) # frequency encoding 版本 card1_freq = df_inner['card1'].value_counts() df_inner['card1_freq'] = df_inner['card1'].map(card1_freq).astype(np.int32)

逻辑说明:fit_transform会把card1的所有唯一值排序后映射到 0 到 n-1 的整数;.astype(str)是为了处理 NaN,否则 LabelEncoder 会报错。value_counts()生成频次表,再用map把每行的card1替换成该卡号出现的次数。参数说明:astype(np.int32)是因为频次最大值可能在几十万量级,int16 不够;如果后续还要做 groupby 聚合,建议把这两列都参与计算,但不要同时把原始card1和编码列都留进模型,冗余会干扰特征重要性排序。

4. 特征工程从这四类入手:金额、时间、设备与域名交叉

4.1 金额特征:对数变换与分组统计

TransactionAmt是典型的右偏分布,大多数交易金额集中在几十到一百美元之间,偶尔出现几千美元的大额交易。对数变换能让分布更接近正态,树模型虽然不受单调变换影响,但后续如果接线性模型或做统计特征,对数值会更稳。更有用的是按card1分组计算金额均值和标准差,这样能刻画“这张卡平时怎么消费”的画像。

df_inner['amt_log'] = np.log1p(df_inner['TransactionAmt']) df_inner['amt_card_mean'] = df_inner.groupby('card1')['TransactionAmt'].transform('mean').astype(np.float32) df_inner['amt_card_std'] = df_inner.groupby('card1')['TransactionAmt'].transform('std').astype(np.float32)

逻辑说明:groupby('card1')['TransactionAmt'].transform('mean')会为每一行返回它所属卡号的金额均值,长度与原 DataFrame 一致,不会改变行数。std是标准差,如果某行金额偏离卡均值好几个标准差,可能是盗刷信号。参数说明:.astype(np.float32)一定不能省,因为groupby返回的临时 Series 通常是 float64,直接赋值会把整列撑大内存。

4.2 时间特征:小时切片与周期编码

TransactionDT是从某个固定起点开始的秒数,直接当数值特征喂给模型,模型很难理解“凌晨 3 点”和“下午 3 点”的区别。常见做法是先转成小时和星期,再用 sin/cos 做周期编码。周期编码的意义在于:23 点和 0 点在数值上相邻,但 23 和 0 的差是 23,直接做数值特征会让模型误以为它们差别很大。

df_inner['hour'] = (df_inner['TransactionDT'] // 3600) % 24 df_inner['weekday'] = (df_inner['TransactionDT'] // 86400 + 3) % 7 df_inner['hour_sin'] = np.sin(2 * np.pi * df_inner['hour'] / 24).astype(np.float32) df_inner['hour_cos'] = np.cos(2 * np.pi * df_inner['hour'] / 24).astype(np.float32)

逻辑说明:// 3600把秒转成小时,% 24取当天的小时数;// 86400转成天数,+ 3再% 7是为了让起点对齐某个星期。sin/cos 把小时映射到单位圆上,0 点和 23 点在三角函数值上非常接近,模型能真正学到周期性。参数说明:astype(np.float32)保持压缩;weekday不转 float 也没事,LightGBM 会把它当数值特征处理。

4.3 设备特征:类别压缩与频率编码

DeviceType和DeviceInfo来自身份表,DeviceType通常是字符串,比如移动端和桌面端的标识,可以直接转 category;DeviceInfo是高基数字符串,包含具体设备型号,直接 LabelEncoder 会得到大量无意义的整数。相比之下,id_34这类匿名设备 ID 更适合 frequency encoding,因为它们经常是唯一标识,label encoding 反而会引入随机排序噪声。

df_inner['DeviceType'] = df_inner['DeviceType'].astype('category') df_inner['id_34_freq'] = df_inner.groupby('id_34')['TransactionID'].transform('count').astype(np.int32) df_inner['has_device'] = df_inner['id_01'].notna().astype(np.int8)

逻辑说明:astype('category')会把字符串转成 pandas 的分类类型,内存比 object 少一大截。id_34_freq统计同一设备 ID 出现的次数,如果某个设备 ID 只出现一次,很可能是一次性设备。has_device构造缺失标记,区分“没有设备画像”和“有设备画像但是未知型号”这两种情况。参数说明:transform('count')会忽略 NaN,所以id_34为空的行的频次会变成 0,正好当作“无设备信息”处理。

4.4 邮箱域名交叉:付款方与收款方的关系

P_emaildomain和R_emaildomain分别代表付款方和收款方的邮箱域名。两者相同时,交易双方很可能来自同一机构或同一人控制,这类交易的欺诈率往往不同。域名本身是高基数类别,直接编码意义不大,但把“是否相同”这个布尔信号拿出来,对树模型很友好。

df_inner['email_domain_same'] = (df_inner['P_emaildomain'] == df_inner['R_emaildomain']).astype(np.int8) df_inner['P_emaildomain'] = df_inner['P_emaildomain'].fillna('unknown')

逻辑说明:布尔比较生成 True/False,再转 int8 变成 0/1 特征。缺失的邮箱域名填'unknown',而不是直接删掉这一行,因为缺失本身可能表示交易流程不规范。参数说明:fillna('unknown')之后,P_emaildomain仍然可以参与后续的 factorize 或 frequency encoding;如果担心泄漏,不要在验证集上单独做fillna,要先合并全量数据再统一处理。

5. 避坑排查:五条高频翻车记录与对应处理

5.1 现象:merge 后列全变成 NaN,内存还暴涨

第一次跑这份资源时,我 merge 完看了一眼df_inner.isnull().mean(),发现card1_i这一列全是 NaN,V1_t也有接近一半丢失,而且内存从 1GB 直接跳到 4GB。原因:两张表的索引没有重置,merge 时 pandas 把索引也当作对齐条件的一部分,导致实际是按照“原索引 + TransactionID”做的笛卡尔积,行数被放大,匹配失败的地方自然全是 NaN。解决:merge 之前先对两个 DataFrame 执行reset_index(drop=True),merge 完成后再执行一次reset_index(drop=True)。这一步能解决九成以上的诡异 NaN 问题。

5.2 现象:第三轮特征工程后内存爆炸,内核被杀

在 Jupyter Notebook 里连续做了几个groupby.transform之后,内存从 2GB 涨到 7GB,最后内核直接重启。原因:groupby.transform返回的临时 Series 是 float64,赋值给原 DataFrame 时会产生一次完整拷贝;连续几个特征叠加,中间变量没有被释放,内存就爆了。解决:所有groupby聚合结果统一在结尾加.astype(np.float32),并且每做完一组特征立刻del掉不再用的临时变量,再gc.collect()。

import gc tmp = df_inner.groupby('card1')['TransactionAmt'].transform('mean').astype(np.float32) df_inner['amt_card_mean'] = tmp del tmp gc.collect()

逻辑说明:tmp被赋值给 DataFrame 后,原 Series 的引用计数并没有归零,如果不del再gc.collect(),它会一直占着内存直到 Notebook 内核重启。参数说明:gc.collect()不是万能的,它只回收引用计数为 0 的对象,如果还有别的变量引用tmp,需要用del把所有引用都清掉。

5.3 现象:本地验证 AUC 0.94,提交后线上只有 0.91

本地五折交叉验证跑得很漂亮,一提交就掉三个点。原因:我用随机分 fold,把未来交易日期的样本混进了训练集。交易数据本质上是时间序列,随机划分会让模型“偷看”到未来的分布,尤其是某些卡号或设备 ID 在训练集和验证集里同时出现。解决:按TransactionDT排序后,前 80% 做训练,后 20% 做验证,严格模拟线上“用过去预测未来”的场景。

df_inner = df_inner.sort_values('TransactionDT').reset_index(drop=True) split_idx = int(len(df_inner) * 0.8) train = df_inner.iloc[:split_idx].copy() valid = df_inner.iloc[split_idx:].copy() print(train.shape, valid.shape)

逻辑说明:sort_values按时间戳升序排列,iloc按行位置切分,不会重复采样。参数说明:时间切分的比例不是固定的,如果数据量够大可以 0.9/0.1;关键是验证集的时间范围一定要晚于训练集,否则 AUC 虚高没有参考价值。

5.4 现象:卡方检验筛完特征,AUC 反而更低

我在初版模型上用卡方检验选了 top50 特征,训完发现 AUC 比全量特征还低 0.01。原因:V 系列是匿名特征,它们与目标的关系不一定线性,卡方检验只看单变量相关性,会丢掉那些单看没用、组合起来有区分度的交互特征。解决:不要预先用统计检验筛特征,直接全量喂给 LightGBM,训练完看 feature importance 做后置裁剪;就算要裁剪,也至少保留 top200,而不是 top50。

# 先训全量模型,再按重要性剪枝 model = lgb.train(params, lgb.Dataset(train, label=y_train), num_boost_round=500) importance = pd.Series(model.feature_importance('gain'), index=feature_names).sort_values(ascending=False) keep_cols = importance.head(200).index.tolist()

逻辑说明:feature_importance('gain')返回每个特征在分裂时带来的增益总和,比卡方检验更贴近模型实际使用方式。参数说明:head(200)不是硬性标准,要结合特征总量调整;如果原始特征有 400 个,保留 200 个是安全起点,之后再逐步降。

5.5 现象:LightGBM 训练时 Notebook 内核反复崩溃

设置n_jobs=-1想快一点,结果训练一开始 Notebook 就无响应,等几分钟直接重启。原因:LightGBM 的多线程会抢占所有 CPU 核心,加上特征矩阵里 object 列没有编码,底层在做类型转换时大量拷贝内存,系统把占用过高的进程杀掉了。解决:限制n_jobs=4,并确保传给 LightGBM 的 DataFrame 里所有列都是数值或 category 类型,字符串列必须提前处理好。

import lightgbm as lgb params = { 'objective': 'binary', 'metric': 'auc', 'num_leaves': 63, 'max_depth': 7, 'n_jobs': 4, 'verbosity': -1 } lgb_train = lgb.Dataset(train[feature_cols], label=train['isFraud']) model = lgb.train(params, lgb_train, num_boost_round=300)

逻辑说明:num_leaves=63对应深度 7 的满二叉树,这个配置在 50 万样本上不会太激进;n_jobs=4比-1慢不了多少,但能保住内核不崩。参数说明:verbosity=-1关掉训练日志,减少 Notebook 输出缓冲压力;num_boost_round先用 300 试跑,配合早停再决定最佳轮数。

6. 提交前的最后几手:伪标签、模型融合与阈值切换

6.1 伪标签:用测试集的高置信预测扩充训练集

当你对线上分布没有把握时,伪标签是个双刃剑。常见做法是:先用时间切分的模型预测测试集,挑出预测概率大于 0.95 和小于 0.05 的样本,把它们的预测值当作伪标签加入训练集,再训一轮。注意两点:伪标签样本量不要超过训练集的 20%,并且第二轮的早停轮数要调小,防止模型把噪声也背下来。

test_probs = model.predict(test_df[feature_cols]) pseudo_idx = (test_probs > 0.95) | (test_probs < 0.05) pseudo_df = test_df[pseudo_idx].copy() pseudo_df['isFraud'] = (test_probs[pseudo_idx] > 0.5).astype(int) train_aug = pd.concat([train, pseudo_df], axis=0)

逻辑说明:pd.concat把伪标签样本拼到训练集尾部,第二轮训练直接用增强后的数据。参数说明:阈值 0.95 和 0.05 不是固定的,可以用验证集上的 precision-recall 曲线来定;如果伪标签样本量太少,说明模型本身置信度不高,这时候不要强行用。

6.2 模型融合与阈值切换

单模型 LightGBM 的验证 AUC 通常在 0.92 到 0.94 之间,想再往上走,我一般会再训一个 XGBoost,然后按验证 AUC 加权融合。欺诈检测评估看的是 AUC,而 AUC 只关心样本预测概率的顺序,不关心阈值。所以提交时直接提交概率本身,不要提前套 0.5 阈值。

from sklearn.metrics import roc_auc_score, precision_recall_curve val_probs = model.predict(valid[feature_cols]) val_auc = roc_auc_score(valid['isFraud'], val_probs) print('validation AUC:', val_auc) prec, rec, thr = precision_recall_curve(valid['isFraud'], val_probs)

逻辑说明:roc_auc_score评估整体排序能力;precision_recall_curve用来选定业务阈值,比如你想把精确率保持在 0.8 以上,就从曲线里反查对应的概率阈值。参数说明:融合权重一般按验证 AUC 比例设,比如 LightGBM 0.93、XGBoost 0.92,就按 0.93:0.92 加权平均概率。

从那以后我每次提交前,都强制走一遍流程:时间切分验证、内存压缩检查、伪标签开关测试。这套 Jupyter Notebook 帮我省下的重跑时间比调参多得多,很多坑不是靠模型能力绕开的,而是靠处理顺序提前避开的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询