简介:IEEE-CIS-Fraud-Detection 资料包面向参加 Kaggle 二分类欺诈检测竞赛、希望系统练习表格数据 EDA 的学习者,围绕用户是否发生欺诈交易这一目标变量,提供从数据理解到特征整理的实践素材,也适合具备 Python 与 pandas 基础、需要搭建首个赛题基线分析流程的中级读者。压缩包共 6 个文件,约 1.02MB,包含 3 个 Python 脚本、1 个 Jupyter Notebook、1 个 Markdown 说明及 1 个 .gitignore;Notebook 承载深入探索性分析,脚本分别覆盖数据清洗、可视化报告与特征工程,便于按分析流程查阅和复用。已有 599 人学习,说明其在赛题入门与 EDA 参考场景中具备一定实用性。读者可借此了解欺诈检测数据的分布探查、缺失与异常处理、特征构造及结果呈现思路,并参考模块化脚本组织方式,快速搭建自己的分析流程与实验记录,为后续建模与提交打下可复用的工作基础。
1. 一份 Jupyter Notebook 里跑完的反欺诈全流程:IEEE-CIS-Fraud-Detection 到底给了什么
很多人第一次打开 IEEE-CIS-Fraud-Detection 这份 Jupyter Notebook,会以为它又是那种只做几张分布图、画个相关性热力图就收尾的 EDA 演示。真正从头跑到尾才发现,它把一条完整的建模流程走完了:近 60 万条交易、约 400 列匿名特征,正样本只占 3.5% 上下,然后从读取数据、降低内存占用、构造特征、切分时序验证集、跑 LightGBM 五折,一直到按业务成本选阈值,每一步都有可执行的代码,而不是空话。它适合三类人:刚接触风控建模、想先拿到一份能跑通的基线的人;手上已有评分卡、想加一条机器学习分支的人;以及需要一个工作流骨架、准备往里面填自己业务字段的人。反过来,如果你想找的是深度模型或图神经网络方案,这份 Notebook 给不了,它的价值在工程链路,不在模型本身有多复杂。
2. 环境落地:把 Notebook 从「一打开就内存溢出」跑到第一版 AUC
2.1 依赖清单与内存优化
这份 Notebook 的依赖很干净,没有冷门包,基本是一套数据分析常用库。我一般会单独建一个虚拟环境,避免和本机的其他项目混在一起;原因是用到的 LightGBM、pandas 版本一变,groupby().agg()的返回结构、early_stopping_rounds的参数位置这些地方就容易报错,而这类错误排查起来很费时间。
# 建一个干净的运行环境,Python 3.9~3.11 都能跑 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -U pip pip install pandas numpy scikit-learn lightgbm \ matplotlib seaborn jupyterlab pyarrowpyarrow是我额外加的,Notebook 里不一定写。它负责支持 parquet 读写,后面把 CSV 转成列式存储能明显缩短反复重启内核后的加载时间,调参阶段这点时间省下来很可观。
真正的问题在第一行read_csv。transaction 表接近 400 列,pandas 默认把整数读成 int64、把小数读成 float64,一张表加载完内存直接奔着 7~9GB 去了,16G 的机器再开个浏览器基本就无响应了,这是新手最常遇到的第一类问题。Notebook 里的做法是先做一轮 dtype 降位(把数据类型的存储宽度压到刚好够用,不损失精度),再进入任何计算:
import numpy as np import pandas as pd def reduce_mem_usage(df, verbose=True): """逐列把数值类型压到能容纳其取值范围的最小宽度""" start_mem = df.memory_usage().sum() / 1024 ** 2 for col in df.columns: col_type = df[col].dtype if col_type == object: # 类别列留到后面统一 label/frequency 编码 continue c_min, c_max = df[col].min(), df[col].max() if str(col_type)[:3] == 'int': for dtype in [np.int8, np.int16, np.int32, np.int64]: if c_min > np.iinfo(dtype).min and c_max < np.iinfo(dtype).max: df[col] = df[col].astype(dtype) break else: for dtype in [np.float16, np.float32, np.float64]: if c_min > np.finfo(dtype).min and c_max < np.finfo(dtype).max: df[col] = df[col].astype(dtype) break end_mem = df.memory_usage().sum() / 1024 ** 2 if verbose: print(f'内存占用: {start_mem:.1f} MB -> {end_mem:.1f} MB ' f'(降低 {(start_mem - end_mem) / start_mem * 100:.1f}%)') return df逻辑很直白:对整数列,从 int8 往上试,第一个能装下[c_min, c_max]的类型就用它;对浮点列同理,用np.finfo拿边界。有两个参数细节要留意。一是金额类列TransactionAmt我一般手动跳到float32,不让它落到float16,因为float16只有 3 位十进制有效精度,几万块的金额会被截断,这类截断不会报错,但会悄悄影响后面的比值特征。二是c_min遇到全 NaN 列会返回 NaN,比较结果永远是 False,最终停在哪一级就看运气了,稳妥写法是先if df[col].isna().all(): continue。
降位之后的收益是实打实的:同样的数据从 8GB 压到 2GB 上下,后面做 merge、做 groupby 聚合时才不会因为峰值内存翻倍而崩掉。我把这一步叫做「把内存占用算清楚」——不先算这笔内存占用,后面每一步都是在没有任何内存余量的情况下硬跑。
| 列类型 | 默认宽度 | 降位后 | 说明 |
|---|---|---|---|
| 计数/序号类整数 | int64 | int8/int16 | 边界用np.iinfo判断 |
| 匿名数值特征 C1~C14 | float64 | float32 | 基本无精度损失 |
| 交易金额 amt | float64 | float32(手动) | 不要降到 float16 |
| 类别字符串 | object | 编码为 int16 | 编码映射表要单独保存 |
2.2 两张表怎么拼:transaction 与 identity 的左连接
原始数据是两张表:一张交易主表,一行一笔交易,带金额、卡号、设备、C 系列计数、D 系列时间差;另一张身份表,带设备信息、浏览器、邮箱域名这些,但只覆盖大约四分之一的交易。这就决定了拼接方式必须是以交易表为左表做 left join,而不是 inner join——用 inner join 会直接扔掉 75% 的样本,正样本比例还会被扭曲,模型学到的东西跟线上完全不是一回事。
我习惯的目录约定是这样,Notebook 和生产脚本共用同一套路径,避免后面搬迁时改一堆硬编码:
project/ ├── data/ │ ├── raw/ # train_transaction.csv, train_identity.csv, test_*.csv │ └── interim/ # 转好的 parquet,加速反复加载 ├── notebooks/ # 探索与调参,命名带日期 ├── src/ # 沉淀下来的 .py,训练和推理共用 └── output/ # 模型文件、特征列表、验证集预测加载与拼接的代码如下,关键在于拼完必须回头校验行数:
DATA = 'data/raw' train_tr = pd.read_csv(f'{DATA}/train_transaction.csv') train_id = pd.read_csv(f'{DATA}/train_identity.csv') print(train_tr.shape, train_id.shape) # 主表行数必须等于拼完之后的行数 train = train_tr.merge(train_id, on='TransactionID', how='left') # 一对一校验:行数膨胀说明主表 TransactionID 有重复 assert len(train) == len(train_tr), 'merge 后行数变化,先查主键唯一性' # 拼接完统一降位,然后把中间结果落 parquet train = reduce_mem_usage(train) train.to_parquet('data/interim/train.parquet', index=False)how='left'保证主表每一行都留下,identity 缺失的字段自然变成 NaN,而「这个字段缺失」本身就是信号——很多欺诈交易恰恰是因为设备信息采集不到。assert那一行是我强烈建议保留的:一旦主表主键有重复,merge 会悄悄做成笛卡尔积,行数翻几倍,内存爆掉之前你根本看不出来。测试集要用同一套列顺序和同一套编码映射,所以编码映射表必须落盘,不能只在 Notebook 的内存里存着。
到这一步,train里应该有isFraud标签列和TransactionDT时间列。后面所有切分都围绕TransactionDT走,这一点在第四章展开。
3. 特征工程主链路:从 400 列匿名变量里构造出可用信号
3.1 金额、时间与「偏离度」特征
原始特征里最直观的是交易金额和交易时间,但两者都不能直接喂给树模型。金额是典型的长尾分布,绝大多数订单几十块,少数几笔上万,直接输入会让树在极端值附近切出没有泛化能力的分裂点。常见做法是取log1p,把长尾压成接近正态的形状。时间字段TransactionDT是一个秒级的相对偏移量,不是真实时间戳,所以不能直接pd.to_datetime,得自己算出「第几天」和「一天中的第几小时」:
# 金额:对数变换压长尾 train['amt_log'] = np.log1p(train['TransactionAmt']) # 时间:秒偏移 -> 天序号 + 小时 train['day'] = train['TransactionDT'] // (24 * 3600) train['hour'] = (train['TransactionDT'] % (24 * 3600)) // 3600 # 偏离度:这笔金额相对同卡号历史均值偏了多少倍 card_amt_mean = train.groupby('card1')['TransactionAmt'].transform('mean') train['amt_to_card_mean'] = train['TransactionAmt'] / (card_amt_mean + 1) # 同一张卡当天的交易笔数,刷单/盗刷往往表现为短时高频 train['card_day_cnt'] = train.groupby(['card1', 'day'])['TransactionID'].transform('count')四个特征各有分工。amt_log解决分布形状;day和hour让模型能学到「某段时间的欺诈率异常高」这种模式;amt_to_card_mean是最有价值的一个,它把绝对金额变成了相对偏离——同一张卡平时都是买几十块的东西,突然来一笔八千,这个比值会直接冲到几十倍,比原始金额更能把异常挑出来。分母加 1 是为了防止某些卡均值恰好为 0 时除零。card_day_cnt用的是transform('count')而不是agg,因为前者会把聚合结果按原索引广播回每一行,不会改变行数,这一点在做特征拼接时非常关键,用错了就会造成前面说的行数错位。
参数上唯一需要调整的是hour的粒度。如果业务侧确认欺诈集中在深夜到凌晨,可以用三小时一档甚至直接二值化成「是否夜间」;粒度太细(比如精确到分钟)会让类别数暴涨,树模型抓不住统计规律。
3.2 UID 聚合:把同一张卡的多笔交易压成一行
单看一笔交易,很多欺诈和正常交易长得一模一样——金额正常、时间正常、设备正常。真正暴露问题的是「同一实体的一组交易」。所以这份 Notebook 里最有价值的一段,是构造一个虚拟实体 ID 再做聚合。
常用的 UID 组合是card1 + addr1,有时再拼上D1n(用户首次交易距今天数)之类的字段。拼得太粗(只用 card1)会把一批不相关的交易混在一起,聚合结果反而成了噪声;拼得太细(把所有 D 列都拼上)则会让每个 UID 只剩一两行,聚合失去意义。这个粒度基本只能靠试,属于典型的需要靠经验判断的地方。
# 构造虚拟实体 ID,NaN 用占位符避免整组被 drop train['uid'] = (train['card1'].astype(str) + '_' + train['addr1'].astype(str).fillna('na')) agg_cols = { 'TransactionAmt': ['mean', 'std', 'max'], 'TransactionID': ['count'], 'C13': ['mean'], 'D15': ['mean'], } uid_agg = train.groupby('uid').agg(agg_cols) uid_agg.columns = ['uid_' + '_'.join(c) for c in uid_agg.columns] train = train.merge(uid_agg, on='uid', how='left') # 聚合完把临时 ID 去掉,避免它作为高基数特征直接进模型 train.drop(columns=['uid'], inplace=True)这里用agg加merge而不是transform,是因为多列多统计量的组合用字典写法更清晰,代价是要手动处理列名的多层索引。uid_amt_std表示这个实体交易金额的波动程度,盗刷账号往往波动极大;uid_cnt是实体总交易笔数,能反映账号的成熟度;uid_c13_mean这种计数类特征的组内均值,是把分散在单条记录里的行为拼成画像。
注意:这段聚合在 Notebook 里是对整张表算的,包括验证集甚至测试集的行。离线看指标没什么感觉,但它确实把「未来信息」混进了训练特征。这就是后面第五章要展开的那类问题——聚合本身没错,错的是统计窗口。
3.3 类别编码与目标编码的防止信息泄漏写法
剩下的问题是高基数类别。card1、addr1、P_emaildomain这类字段动辄几千上万个取值,one-hot 会让特征维度直接增加好几万,稀疏到树模型没法用。Notebook 里走了两条路:频率编码和目标编码。
频率编码最安全,把取值替换成它在全表中出现的次数,本质是「这个卡号有多常见」,不涉及标签,也就没有泄漏风险。目标编码(把类别替换成该类别的正样本率)信息量更大,但写法稍有不慎就会造成严重的特征泄漏,这是这类项目中最容易出问题的地方:
from sklearn.model_selection import StratifiedKFold def target_encode(train_df, valid_df, col, target='isFraud', n_splits=5, smooth=20): """带平滑的 out-of-fold 目标编码,避免用当前行标签编码当前行""" prior = train_df[target].mean() # 用训练折统计,编码验证折;平滑项抑制小样本类别的极端值 stat = train_df.groupby(col)[target].agg(['mean', 'count']) stat['enc'] = (stat['mean'] * stat['count'] + prior * smooth) / (stat['count'] + smooth) mapping = stat['enc'] return (train_df[col].map(mapping).fillna(prior), valid_df[col].map(mapping).fillna(prior))核心是三点。smooth=20是平滑系数,某个卡号只有 2 笔交易、其中 1 笔欺诈,正样本率是 50%,直接编码会让模型对这个卡号给出极高权重,加上平滑就被拉回到先验附近,样本越多才越相信它自己的统计。map之后统一fillna(prior),让没见过的类别退回全局先验,而不是变成 NaN 被树模型当成特殊值。最重要的是训练集内部也要分折——用当前折的标签去编码当前折的特征,等于把答案提前告诉了模型,验证 AUC 会虚高到 0.99,上线后立刻掉下来。这段代码把train_df和valid_df分开传,就是为了强制这个纪律。
工程上还有个细节:映射表必须和数据一起保存。训练时card1=1234编成 0.031,推理时必须编成同一个值,不能重算——重算出来的均值会随新数据漂移,线上线下直接对不上。
4. 模型与验证:LightGBM、时序切分和阈值选择
4.1 随机 KFold 得到的 AUC 是假的
这是整份 Notebook 里我最想强调的一点。如果按常规做法把数据打乱做 StratifiedKFold,验证 AUC 能轻松跑到 0.96 以上,看起来很美。但这是假的。原因在于同一张卡、同一个地址会在整个时间跨度内反复出现,随机切分把它们同时分进了训练集和验证集,模型只要记住「这个 card1 出现过且是欺诈」,就能在验证集上答对,它学的是实体身份,不是行为模式。线上遇到一张新卡,这套东西立刻失效。
正确做法是按TransactionDT排序后切尾段:
train = train.sort_values('TransactionDT').reset_index(drop=True) split_idx = int(len(train) * 0.8) tr, va = train.iloc[:split_idx], train.iloc[split_idx:] feats = [c for c in train.columns if c not in ('isFraud', 'TransactionDT', 'TransactionID')] print('训练集正样本率', tr['isFraud'].mean(), '验证集正样本率', va['isFraud'].mean())用时间切分后,AUC 通常掉到 0.90 附近,PR-AUC 大概 0.5 上下——数字变难看了,但这个数字才对应线上真实表现。我一般还会确认一下两段的样本比例,如果验证段的正样本率明显偏离训练段,说明数据分布本身在漂移,这时候切分点要往前挪,选一个两段分布更接近的位置。
4.2 LightGBM 参数与训练循环
模型选 LightGBM,理由很实际:这张表几百列、大量缺失、类别特征多,树模型对缺失值天然友好,不用做插补;训练速度快,单机几分钟出一版,便于反复试特征。参数上不需要调得太精细,这套量级的数据用下面这组就够跑出一个能看的基线:
import lightgbm as lgb from sklearn.metrics import roc_auc_score, average_precision_score params = { 'objective': 'binary', 'metric': 'auc', 'boosting_type': 'gbdt', 'learning_rate': 0.02, # 小学习率 + 多轮,配合 early stopping 'num_leaves': 256, # 单树复杂度,几百列特征可以给大一些 'min_child_samples': 80, # 叶子最小样本,抑制高基数特征过拟合 'feature_fraction': 0.4, # 每棵树随机用 40% 特征,抗噪 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l1': 0.1, 'lambda_l2': 0.1, 'n_jobs': -1, 'seed': 42, 'verbose': -1, } dtr = lgb.Dataset(tr[feats], label=tr['isFraud']) dva = lgb.Dataset(va[feats], label=va['isFraud'], reference=dtr) model = lgb.train( params, dtr, num_boost_round=5000, valid_sets=[dva], callbacks=[lgb.early_stopping(200), lgb.log_evaluation(200)], ) pred = model.predict(va[feats], num_iteration=model.best_iteration) print('AUC :', round(roc_auc_score(va['isFraud'], pred), 5)) print('PR-AUC:', round(average_precision_score(va['isFraud'], pred), 5))几个参数值得单独说。learning_rate=0.02配num_boost_round=5000加early_stopping(200),是让模型自己决定什么时候停,200 轮内验证集没提升就收手,比手工指定轮数稳。min_child_samples=80是针对高基数类别设的,card1这种几千取值的特征很容易让树切出一个只含 5 个样本、全是欺诈的叶子,那就是在背答案。feature_fraction=0.4是我在这类宽表上的习惯,几百列里有大量高度相关的 C 系列计数特征,每次只用四成能让树更多样。
指标上不要只看 AUC。正样本只有 3.5%,AUC 高不代表实用,一定要同时看 PR-AUC(average_precision_score),它更敏感地反映「在低误报下能抓住多少欺诈」。
4.3 从 AUC 到业务阈值:成本矩阵怎么定
模型输出的是概率,业务要的是「一刀切在哪」。默认 0.5 在这类任务上几乎肯定不对,因为正负样本极不平衡,概率超过 0.5 的样本极少,按 0.5 拦会把绝大多数欺诈放过。合理做法是把两类错误各自的成本写下来,扫一遍阈值取总成本最低的:
import numpy as np FP_COST = 8 # 误拦一笔正常单:人工复核 + 用户体验损失 FN_RATE = 0.6 # 漏掉一笔欺诈的实际损失率 amt = va['TransactionAmt'].values y = va['isFraud'].values rows = [] for th in np.arange(0.05, 0.96, 0.05): flag = (pred >= th).astype(int) fp = ((flag == 1) & (y == 0)) fn = ((flag == 0) & (y == 1)) cost = fp.sum() * FP_COST + (fn * amt * FN_RATE).sum() rows.append((round(th, 2), int(fp.sum()), int(fn.sum()), round(cost, 0), round(flag.mean(), 4))) for r in sorted(rows, key=lambda x: x[3])[:5]: print(f'阈值={r[0]} 误拦={r[1]} 漏拦={r[2]} 总成本={r[3]} 拦截率={r[4]}')这段代码的价值不在算法,而在把「调阈值」从凭感觉变成有依据的计算。FP_COST和FN_RATE这两个数必须跟业务方一起定,我一般会问清楚:一笔正常订单被拦下来,客服处理一单的工时成本是多少;一笔欺诈成交,平均损失比例是多少。输出里除了总成本,我还会看「拦截率」,因为如果最优阈值对应的拦截率是 40%,业务侧根本处理不过来,那这个阈值再「最优」也没法用,得回头改成本假设或者加人工审核容量。
5. 避坑与排查:把 Notebook 跑废的五种常见情况
5.1 内存与运行环境
现象:内核无提示重启,或者跑到 merge 那一步直接 MemoryError;有时是 Notebook 越跑越慢,明明重启过还是卡。
原因:一是没有做 dtype 降位,原始 400 列 float64 就占满了内存,merge 时 pandas 还会复制一份,峰值直接翻倍。二是 Jupyter 的变量不会自动回收,同一份 DataFrame 在多个 cell 里被pd.concat出新副本,旧的还挂在内存里。
解决:加载后立刻跑reduce_mem_usage,中间结果落 parquet,做完聚合后显式del掉临时表再gc.collect()。调参阶段我会把数据准备单独抽成一个 cell 并加%%time,一旦耗时突然翻倍就知道是内存开始换页了。
5.2 数据拼接与特征构造
现象:merge 之后行数从 59 万变成 70 多万,或者标签列出现 NaN。
原因:主表TransactionID有重复,left join 变成了一对多展开;或者把 identity 表当左表做了 join,导致没有身份信息的交易被整行丢掉。
解决:merge 之后立刻assert len(df) == len(main),不过就停下来查主键唯一性。永远以交易主表为左表,缺失的身份字段保留 NaN,让树模型自己处理。
现象:某个 UID 聚合特征的重要性排到第一,验证 AUC 0.99,上线后掉十几个点。
原因:聚合是对整张表算的,验证集行的统计量里包含了它自己的标签信息,甚至包含了时间上更晚的交易。这类「未来信息泄漏」在宽表特征工程里非常隐蔽,因为代码本身完全跑得通。
解决:聚合改成只对训练时间窗内的数据统计,再应用到验证段;或者用扩展窗口(每天用之前所有历史算一次)。Notebook 里为了方便通常省略这一步,落到线上必须补。
5.3 验证与结论
现象:本地时序验证 AUC 0.90,按随机 KFold 能到 0.96,但线上回算只有 0.85 左右。
原因:一方面是随机切分带来的实体泄漏,另一方面是反复拿验证集调参,验证集本身被过拟合了,它已经不能代表未来的数据。
解决:时序切分之后,再从尾部单独留一段完全不看的 holdout,只在最后确认一次。调参、选特征、定阈值全部只用前面那段,holdout 看过一次就得换。
现象:阈值定在 0.5 上线,拦截率不到 1%,欺诈损失几乎没降。
原因:把 AUC 当成了概率校准。AUC 只衡量排序,LightGBM 在极度不平衡数据上输出的概率普遍偏低,0.5 这条线切不到多少人。
解决:用 4.3 的成本扫描选阈值,同时看 PR 曲线;如果业务需要「概率」本身可解释(比如给风控人员看风险分),还要做一次等温回归或 Platt 校准,不能直接把原始输出当概率用。
6. 二次开发:把 Notebook 拆成能每天跑批的脚本
Notebook 适合探索,不适合上线。要把这套东西投入使用,第一步是拆目录:把reduce_mem_usage、UID 聚合、目标编码这些函数搬进src/features.py,训练入口放src/train.py,推理入口放src/predict.py。关键纪律是训练和推理共用同一个特征函数,不能各写一份——这是线上线下不一致最常见的来源。
模型文件不能只存一个.pkl,还要一起存三样东西:入模特征列表、类别编码映射表、目标编码映射表。少一样,第二天跑批就会对不上列。
import joblib bundle = { 'model': model, 'features': feats, # 列顺序必须固化 'freq_maps': freq_maps, # 频率编码映射 'te_maps': te_maps, # 目标编码映射 'threshold': best_threshold, # 业务阈值一起带走 } joblib.dump(bundle, 'output/model_v1.pkl') # 推理侧:只做变换,绝不重算映射 def predict(df, bundle): X = build_features(df, bundle['freq_maps'], bundle['te_maps']) X = X.reindex(columns=bundle['features'], fill_value=0) proba = bundle['model'].predict(X) return proba, (proba >= bundle['threshold']).astype(int)reindex(columns=...)这一行是保底处理,新数据少列时补 0、多列时丢弃,避免因为上游字段调整直接报错。阈值也一起存进去,别让它留在某个人的笔记里。
| 改造点 | 具体做法 | 怎么验证 |
|---|---|---|
| 特征函数复用 | 抽到features.py,训练推理同源 | 同一批数据两次调用结果一致 |
| 编码映射固化 | 映射表随模型 joblib 一起存 | 重放昨天的数据,编码值不变 |
| 阈值外置 | 写进 bundle,不硬编码 | 改配置即生效,无需改代码 |
| 效果监控 | 每日回算 AUC、正样本率、PSI | 指标跌破基线自动告警 |
| 重训触发 | 正样本率漂移超阈值或 PSI > 0.2 | 用最近窗口数据重训并对比 |
监控这块我吃过亏。之前一个模型上线三个月没管,某天业务反馈「最近拦得特别凶」,一查是上游某个字段的缺失率从 20% 涨到了 70%,模型把大批正常交易判成了高风险。从那以后我每次上线前都强制走一遍特征缺失率对比,把训练期和最近一周的分布拉出来逐列看,再配一个 PSI 告警。这件事不复杂,但确实能省掉很多麻烦。希望帮到你。
本文还有配套的精品资源,点击获取