☰
IEEE-CIS欺诈检测实战:结构化数据特征工程与LightGBM调优指南
2026/10/14 14:22:56 网站建设 项目流程

简介:面向金融风控与Kaggle竞赛学习者,这份资源以IEEE-CIS欺诈检测数据集为核心,给出了一套可直接落地的探索性数据分析与特征工程方案。点击欺诈二分类场景中,目标变量表示用户是否触发欺诈行为,模型效果往往取决于对数据分布、类别不平衡以及特征关联的洞察深度。包内的Jupyter Notebook完整演示了从数据概览、缺失值分析到多维可视化对比的探索流程,另外三个Python脚本分别承担数据清洗、报表生成和特征构建任务,覆盖了竞赛实践中“清洗—分析—特征准备”的关键环节。文件一共六个,包含notebook脚本、Python程序及说明文档,整体压缩包只有1.02MB,轻量且结构清晰。目前已有598人学习浏览,对于正在备赛IEEE-CIS相关题目或研究交易欺诈检测的初中级数据爱好者来说,既能理解分析思路,又能直接复用代码,是高性价比的参考资料。

1. 用结构化数据把欺诈识别 AUC 推到 0.95:IEEE-CIS Fraud Detection 在解决什么问题

信用卡欺诈检测的难点从来不是模型选型,而是数据形态。IEEE-CIS Fraud Detection 这个竞赛提供的是来自某支付服务商的两张真实脱敏表:一张是交易流水,一张是持卡人身份与环境信息,加起来四百多列,其中大部分是匿名化特征。比赛用 AUC 评估,训练集里欺诈样本占比只有 3.5% 左右,这意味着一个"全部预测为正常"的傻瓜模型就能拿到 0.5 的准确率,但 AUC 会很难看。这个标题背后的真实诉求是:在极度不平衡、特征高度匿名、还带有时间序列属性的表格数据上,如何做出稳定且可解释的欺诈评分模型。本文适合正在做风控建模、反欺诈系统或准备打类似表格赛的从业者,我会按数据底子、特征工程、训练策略、踩坑记录这条完整链路来拆解。

2. 数据底子:两张表的字段语义与第一轮数据合并

2.1 表结构、字段分组与字段含义推断

先看数据形态。交易表以每一笔交易为一行,主键是 TransactionID,核心字段包括交易时间 TransactionDT、交易金额 TransactionAmt、产品代码 ProductCD,然后是 card1 到 card15 的卡片信息、addr1 和 addr2 的地址信息、P_emaildomain 和 R_emaildomain 的邮箱域名,以及一批匿名化分组特征:C1-C14 通常是计数类,D1-D15 一般是距离或时长,M1-M9 是匹配结果标志,V1-V339 是经过 PCA 或其他方式脱敏后的数值特征。身份表则是以持卡人环境信息为主,包含 DeviceType、DeviceInfo 和 id_01 到 id_38,这些字段描述的是下单设备、浏览器、IP 归属等环境信号。两张表通过 TransactionID 一一关联,但身份表不是每一笔交易都有记录。

拿到这种数据的第一步不是建模,而是先做字段语义分组。我习惯把特征分成四类:金额与时间、卡片与地址、设备与身份、匿名化的 V 组。分组的目的是后续做特征工程时能按组批量处理,而不是一个列一个列地试。匿名化 V 组里存在大量缺失和全空列,如果不去重、不去空,直接丢进模型只会拖慢训练并对验证集产生误导。另一个关键点是 TransactionDT 的单位是秒,起点是某个固定时间戳,训练集和测试集在时间上是连续的,这对后面的切分策略影响很大。

2.2 合并逻辑与内存优化:为什么不能直接 concat

这个数据集最常见的一个翻车点是把两张表直接按行拼接。交易表和身份表不是同一粒度,身份表是交易表的补充信息,必须用 TransactionID 做主键做 left join,以交易表为基准。另一个容易被忽视的问题是内存:交易表加身份表合并后接近 60 万行、四百多列,如果全部用默认的 int64 和 float64 读取,内存占用会到几个 GB,很多机器直接卡死。常见的做法是读取时指定 dtype,把整数列压到 int32 甚至 int16,把 card 这类高基数 ID 转成 category,能省下大半内存。

import pandas as pd import numpy as np # 读取时直接降精度,避免全表 int64 / float64 撑爆内存 train_tx = pd.read_csv("train_transaction.csv", dtype={ "TransactionAmt": "float32", "card1": "int32", "card2": "float32", "addr1": "float32", "addr2": "float32", "dist1": "float32", "dist2": "float32" }) train_id = pd.read_csv("train_identity.csv") # 以交易表为基准做 left join,身份表没有记录的行自动补 NaN train = train_tx.merge(train_id, on="TransactionID", how="left") del train_tx, train_id # 对高基数 ID 列做 category 压缩,LightGBM 可以直接吃 category for col in ["card1", "card2", "card3", "card5", "addr1", "addr2"]: if col in train.columns: train[col] = train[col].astype("category")

这里的逻辑是:left join 保留了交易表的完整行数,身份信息成为附属列;category 类型对 LightGBM 来说是原生支持的,训练时会自动按类别分裂,不需要额外做独热编码。注意 card2 和 addr1 这类列存在缺失值,转 category 时缺失会被看作一个独立的类别,这样处理比 fillna 更安全,因为模型能学到"缺失"本身的环境信号。

2.3 缺失率分布与第一轮简单清洗

合并完后第一件事是统计缺失率。身份表里的 id_ 系列列缺失率普遍在 60% 到 90%,个别列甚至超过 95%;交易表里 card2、card3、card5、dist1、dist2 也有不同程度的缺失。面对这种缺失结构,我的原则是:不对高缺失列做复杂的插补,而是生成一个是否缺失的指示列,让模型自己决定怎么用。对 V 组特征,先按缺失率 95% 的阈值做一轮删除,再把剩余列的缺失值统一填成 -1,因为 LightGBM 这类树模型对 -1 这种极端值并不敏感,但需要保持训练和测试填充策略一致。

# 缺失率统计和 V 组清洗 miss_ratio = train.isnull().mean() # 删除缺失率超过 95% 的列,这些列信息量趋近于零 drop_cols = miss_ratio[miss_ratio > 0.95].index.tolist() train = train.drop(columns=drop_cols) # 对剩余数值列统一填 -1,并额外记录缺失指示 for col in train.select_dtypes(include=[np.number]).columns: if train[col].isnull().any(): train[col + "_na"] = train[col].isnull().astype(np.int8) train[col] = train[col].fillna(-1)

缺失指示列的作用经常被低估。比如 id_02 的缺失可能代表设备信息收集失败,而这个失败本身就和欺诈率相关,如果不加指示列,模型只能学到"缺失被填充后的值",丢失了"缺失发生的模式"。填充 -1 而不是 0 的原因是不让缺失值混入正常数值分布区间,降低树模型把 -1 误判为某种真实取值的概率。这一轮清洗不追求把特征做完,而是先把数据形态整理到可以安全进入特征工程的状态。

3. 特征工程:从时间窗口、金额比值到身份特征的风控编码

3.1 金额和时间特征:TransactionAmt 和 TransactionDT 的衍生方式

交易金额是风控模型里最直接的行为信号,但直接用原始金额意义不大,因为不同卡片的消费水平差异很大。我一般先对金额取对数压缩长尾,再按卡片分组计算历史均值,生成"单笔金额 / 卡片历史均值"的比值特征。这个比值描述了该笔交易偏离持卡人常规消费模式的程度,偏离越大,欺诈风险越高。同时生成金额的排序百分位特征,把该笔金额放到该卡片全部交易分布里看位置,这比绝对值稳定得多。

时间特征的处理要小心:TransactionDT 是秒级偏移量,直接用数值训练会忽略周期规律。常见做法是转成小时、星期几、是否周末、是否深夜,再加一个"距离该卡上一笔交易的时间间隔"。间隔特征对欺诈识别非常有效,因为盗刷通常发生在持卡人不活跃的时间段,或者与历史消费习惯产生明显的时间跳跃。注意训练集和测试集的时间并不对齐,所以不要对 TransactionDT 做全局归一化,否则测试集的时间分布会被强行拉到训练集的范围内。

def build_time_amt_features(df): # 时间周期特征:小时、星期几、是否夜间 df["hour"] = (df["TransactionDT"] // 3600) % 24 df["weekday"] = (df["TransactionDT"] // 3600 // 24) % 7 df["is_night"] = ((df["hour"] >= 23) | (df["hour"] <= 5)).astype(np.int8) # 金额对数与卡片均值比值 df["TransactionAmt_log"] = np.log1p(df["TransactionAmt"]) card_mean = df.groupby("card1")["TransactionAmt"].transform("mean") df["amt_dev_from_card_mean"] = df["TransactionAmt"] / (card_mean + 1e-6) df["amt_dev_from_card_mean"] = np.log1p(df["amt_dev_from_card_mean"]) return df

把金额与卡片均值的比值再取对数,是为了让跨度极大的比值压缩到可比较的尺度。加 1e-6 是防止除数为零。transform("mean") 会为每一行赋予其 card1 分组的历史均值,这里的"历史"在单阶段特征工程里包含当前行,会有轻微的未来泄漏,但在这个竞赛的实际操作里影响可控;如果做严格的时间序列建模,应该改成扩展窗口均值,我在后面的避坑章节会展开说。小时特征用整除取模实现,是因为 TransactionDT 单位是秒,除以 3600 得到小时数,再对 24 取余就是当天小时。

3.2 频率编码与聚合特征:用 groupby 把卡片和地址的历史行为压成特征

频率编码是这个数据集性价比最高的特征类型。card1 到 card6 的每一种取值组合代表一张具体的卡或一类卡,addr1 是账单地址,这些字段天然具备分组语义。对分组做 count 编码,模型就能学到"这张卡在样本里出现了多少次"这一信息,高频卡通常是正常的常规消费,低频卡或首次出现的卡风险更高。同理,对 addr1、P_emaildomain 做频率编码,能捕捉到地址和邮箱被重复使用的异常模式。

聚合特征更进一步,把数值列在分组内的统计量作为新特征。比如按 card1 分组,计算 TransactionAmt 的均值、标准差、最大值;按 addr1 分组,计算 D1(距离类特征)的均值。这些聚合特征描述了该分组下的历史行为分布,单笔交易再和这些分布做对比,就能得到偏差类特征。需要注意的是 groupby 的 transform 是全局计算,如果后续按时间切分训练和验证,这里就会产生信息泄漏,所以更严格的做法是只在训练集的一部分上计算聚合统计量,再映射到全体。

def build_freq_and_agg(df): freq_cols = ["card1", "card2", "card3", "card5", "addr1", "P_emaildomain", "R_emaildomain"] for col in freq_cols: fname = col + "_freq" df[fname] = df[col].map(df[col].value_counts()) # 聚合:卡片分组下的金额与 D1 距离统计 agg_dict = { "TransactionAmt": ["mean", "std", "max"], "D1": ["mean", "std"] } for col in ["card1", "card2", "card5"]: grp = df.groupby(col)["TransactionAmt"].agg(["mean", "std", "max"]) grp.columns = [f"{col}_amt_{s}" for s in ["mean", "std", "max"]] df = df.merge(grp, left_on=col, right_index=True, how="left") return df

value_counts 的频率编码把类别列变成数值列,LightGBM 可以直接用于分裂。聚合特征用 merge 而不是 transform 的原因是 groupby agg 返回的是分组统计表,merge 后每一行都能拿到对应分组的统计量。这里要注意:如果某个分组只有一条记录,std 会是 NaN,需要后续填充。这些特征的作用是让树模型能够做分组间的对比分裂,比如"该卡的金额标准差很大,是否说明这张卡被多个不同来源使用",这类规律光靠原始列是学不到的。

3.3 身份特征的低维编码:DeviceType 和 DeviceInfo 的归一化

身份表里的 DeviceType 取值很有限,通常就是移动端和桌面端两类,直接做标签编码就好。DeviceInfo 则是个重灾区,它包含大量的浏览器版本、操作系统型号、设备型号字符串,而且存在同一个设备型号在不同时间出现不同写法的现象。对这种自由文本型特征,我的常见做法是先做字符串清洗,拆出品牌和主版本号,再在清洗结果上做频率编码,而不是直接独热,因为独热会产生几百个稀疏列。

id_01 到 id_38 中有很多是设备相关数值,比如屏幕尺寸、时区偏移、操作系统位数等。这些字段的缺失率很高,但缺失本身有强烈的区分度:欺诈交易往往发生在设备信息不完整的环节。所以对 id_ 系列,我在前一章已经加了缺失指示列,这里重点做的是把 id_ 里取值只有两三个类别的列转成类别类型,让模型按类别切分;对取值较多的列,先做分箱再编码,降低噪声。

def normalize_device_info(df): # 提取设备品牌:取 DeviceInfo 的第一个单词作为主品牌 df["Device_brand"] = df["DeviceInfo"].astype(str).str.split().str[0] df["Device_brand"] = df["Device_brand"].str.lower() # DeviceType 直接标签编码 df["DeviceType_enc"] = df["DeviceType"].astype("category").cat.codes # 品牌频率编码 df["Device_brand_freq"] = df["Device_brand"].map(df["Device_brand"].value_counts()) return df

设备品牌字符串的第一个单词通常就是厂商名,比如 sm、samsung、apple、huawei 这类,但也有大量形如 "ios" 或 "windows" 的写法,所以清洗后要再做一次频率统计,把低频品牌统一归为 "other",防止类别数过多。DeviceType 用 cat.codes 转整数后,如果缺失会变成 -1,刚好和前面的缺失处理保持一致。这套编码的价值在于把零散字符串压成了两个数值信号,一个表示品牌归属,一个表示该品牌在样本里的常见程度,后者对识别非常用设备发起的交易很有帮助。

3.4 用 LightGBM 做特征重要性初筛:哪些特征值得留下

特征工程做完后,手头可能有上千列,其中大量 V 组匿名特征是噪声。直接全部丢进模型不仅慢,还会让验证集过拟合到一些无意义的模式上。我一般先跑一个浅层的 LightGBM,把特征重要性排序打出来,再按重要性阈值做筛选。这里的关键是:用训练集内部做交叉验证的重要性,而不是用测试集的表现去反推,否则就是拿测试集调特征,属于典型的作弊套路。

import lightgbm as lgb def quick_importance_rank(X, y, top_n=200): model = lgb.LGBMClassifier( n_estimators=300, learning_rate=0.05, num_leaves=31, max_depth=7, colsample_bytree=0.7, subsample=0.8, random_state=42 ) model.fit(X, y) imp = pd.Series(model.feature_importances_, index=X.columns) imp = imp.sort_values(ascending=False) keep_cols = imp.head(top_n).index.tolist() return keep_cols, imp

浅层模型跑得快,重要性相对稳定。colsample_bytree 设为 0.7 会让特征选择更稳健,避免某个特征和某棵树绑定。max_depth 限制在 7 防止过拟合导致重要性失真。筛选后我通常会保留 top 200 到 300 列,再手动把前面构造的核心特征(金额比值、时间间隔、频率编码)强制加回去,因为重要性排名有自己的偏好,不代表低排名特征没有组合价值。这一轮初筛的目的是把明显无用的 V 列和纯噪声列去掉,而不是追求最优特征子集。

4. 模型选型与训练策略:从单模型基线到集成上限

4.1 为什么不选 XGBoost 而用 LightGBM 打底

表格数据在十万行级别以上时,LightGBM 是大多数从业者的首选,原因有几个:直方图算法让训练速度远快于 XGBoost 的精确贪心算法;原生支持类别特征,省去手动编码步骤;内存占用低。但这不是说 XGBoost 不能用,而是先用 LightGBM 把特征和流程跑通,再用 XGBoost 做第二模型参与融合,能获得更好的集成多样性。我见过很多团队一上来就上深度神经网络,在这个数据集上效果反而不如调好的 GBDT,因为表格数据的特征关系大多是分段的、非光滑的,树模型的分裂方式天然适配。

在这个竞赛里,还有一个更微妙的原因:评估指标是 AUC,AUC 只关心排序,不关心概率绝对值。树模型对特征的非线性组合能力足够强,而且不容易被个别异常值带偏,这在欺诈数据里非常重要,因为欺诈样本往往就是极端值本身,神经网络的 embedding 如果没调好,会把注意力浪费在拟合极端的正常样本上。

4.2 训练/验证切分:按时间切而不是随机切

欺诈检测数据有天然的时序属性,犯罪手法会随时间演化,随机切分会让验证集混入未来信息,导致线上效果和验证效果脱节。常见做法是按时间排序后,取前 80% 的时间段做训练,后 20% 做验证。具体到这个数据集,TransactionDT 跨度大约四个月,前三个月训练,后一个月验证是比较常用的比例。也可以用更严格的滑窗:训练集用前 70%,验证用 15%,留 15% 做最终测试。

# 按时间切分:前 75% 训练,后 25% 验证 train = train.sort_values("TransactionDT").reset_index(drop=True) cut_point = int(len(train) * 0.75) trn = train.iloc[:cut_point].copy() val = train.iloc[cut_point:].copy() y_trn = trn["isFraud"] X_trn = trn.drop(columns=["isFraud", "TransactionDT", "TransactionID"]) y_val = val["isFraud"] X_val = val.drop(columns=["isFraud", "TransactionDT", "TransactionID"])

按时间切分后,聚合特征必须重新思考。如果前面的聚合统计量是用全量数据算的,那么验证集里 card1 的频率编码已经包含了验证集自身的信息,这在严格意义上属于泄漏。常见的折中方案是:对频率特征和聚合特征,只在训练集内计算统计量,然后映射到验证集和测试集;如果测试集是下一个时间段,无法提前知道测试集内部的 value_counts,就只能用训练集的分布去近似。社区里的一个通用做法是接受这个小的泄漏并把它当作特征的一部分,因为测试集的 count 对训练目标没有直接因果,真正影响的是模型在新时间段是否能保持稳定。

4.3 类别不平衡处理:scale_pos_weight、采样与 AUC 的取舍

欺诈数据里正样本只有 3.5%,直接用原始分布训练 LightGBM 是可以的,树模型天然能处理不平衡,因为分裂时只看增益。但为了让模型在训练时给少数类更多权重,一个有效的参数是 scale_pos_weight。它不是一个需要精确计算的量,一般设为负样本数除以正样本数的比值即可,也就是大约 27。过采样和 SMOTE 在这个数据集上效果通常不理想,因为欺诈样本的多样性很高,人工合成样本会引入噪声;欠采样则会丢失大量正常行为模式,导致 AUC 下降。

AUC 这个指标本身对不平衡不敏感,它只关心正样本得分高于负样本的比例。所以我不追求把预测概率校准到接近真实概率,而是追求排序质量。如果后续业务需要把分数映射到拒绝率,再做一次概率校准即可。在这个阶段,所有调参目标都应该是验证集 AUC,而不是准确率或 F1。

model = lgb.LGBMClassifier( n_estimators=5000, learning_rate=0.02, num_leaves=63, max_depth=8, scale_pos_weight=27, colsample_bytree=0.5, subsample=0.7, subsample_freq=1, reg_alpha=0.1, reg_lambda=1.0, random_state=42 ) model.fit( X_trn, y_trn, eval_set=[(X_val, y_val)], eval_metric="auc", callbacks=[lgb.early_stopping(200), lgb.log_evaluation(200)] )

scale_pos_weight 设置过大会导致模型对正样本过度敏感,在验证集上 AUC 反而下降,因为排序被少数高置信负样本的误判干扰。num_leaves 设为 63 是为了配合 max_depth 8,让树有足够的复杂度拟合交互特征,但要注意控制 colsample_bytree 来抵消过拟合。early_stopping 200 轮保证训练不会白跑,n_estimators 设 5000 是给早停留足空间。AUC 作为 eval_metric 直接对应竞赛目标,不要用 binary_logloss 做早停依据,因为两者的最优轮次通常不同。

4.4 两阶段 Stacking:用不同随机种子和特征子集做集成

单模型在验证集 AUC 大约能到 0.93 左右,再往上靠的是集成。第一层用多个配置不同的 LightGBM:不同的随机种子、不同的特征子集、不同的 scale_pos_weight,让每个模型产生不同的偏差。第二层用这些模型的预测结果作为输入,训练一个简单的逻辑回归或另一个浅层 LightGBM。第二层模型过拟合风险高,所以特征只有几个预测值,逻辑回归就足够安全。

特征子集的选择不是随机抽,而是按特征分组抽:一个模型只用金额和时间特征,另一个用卡片和地址特征,第三个用身份和设备特征,第四个用全量特征。这样每个模型看到的信号面不同,彼此的相关性低,融合后的效果才会好。我在实践中发现,简单地做预测概率的均值融合,效果经常比 Stacking 还稳,因为第二层的过拟合有时候会把整体 AUC 拉低。

from sklearn.linear_model import LogisticRegression preds_trn = [] preds_val = [] seeds = [42, 2024, 7] for seed in seeds: m = lgb.LGBMClassifier( n_estimators=5000, learning_rate=0.02, num_leaves=63, max_depth=8, scale_pos_weight=27, random_state=seed, colsample_bytree=0.5, subsample=0.8 ) m.fit(X_trn, y_trn, eval_set=[(X_val, y_val)], eval_metric="auc", callbacks=[lgb.early_stopping(200)]) preds_trn.append(m.predict_proba(X_trn)[:, 1]) preds_val.append(m.predict_proba(X_val)[:, 1]) # 第二层用逻辑回归融合 stack_train = np.column_stack(preds_trn) stack_val = np.column_stack(preds_val) meta_model = LogisticRegression(C=0.1, max_iter=1000) meta_model.fit(stack_train, y_trn) val_pred = meta_model.predict_proba(stack_val)[:, 1]

每个模型的 early_stopping 轮数会因为随机种子不同而不同,所以 OOF 预测会和训练集有不同程度的自适应,这是融合时的一个隐藏风险。解决办法是第二层输入的训练集预测不要用模型对训练集自身的预测,而是用 K 折的 OOF 预测,否则第二层会学到"预测值高的一定是正样本",导致验证集表现虚高。我这里为了演示做了简化,实际操作建议用 5 折 OOF 生成第一层输出,再喂给第二层。

5. 避坑手册:IEEE-CIS 里最容易翻车的 5 个细节

5.1 同一个 TransactionID 出现多行时的泄漏风险

现象:合并完数据后训练 AUC 奇高,验证集 AUC 也高,但提交后分数掉了一截。检查数据发现,身份表里存在同一个 TransactionID 对应多行身份记录的情况,直接用 merge 会变成多对多,把行数撑多,同时让同一个标签被复制多份。原因:这是数据清洗阶段的常见陷阱,多对多合并让模型间接看到了分布在多行里的同一条标签,相当于变相泄漏。解决:合并前先对身份表按 TransactionID 去重,保留每个 ID 第一次出现的记录,或按行号排序后取最后一条,再执行 left join。务必检查合并前后行数是否一致。

5.2 身份表缺失率近 80%:缺失填充会让验证 AUC 虚高

现象:id_ 系列列缺失率极高,直接 fillna(0) 后模型在验证集 AUC 能到 0.95,但提交分数只有 0.91。原因:大量缺失被填充为同一个值后,树模型把"缺失"当作强区分信号,训练时它看到的是填充值 + 标签的强相关,而这种相关未必在新数据上成立。解决:不要只填充,要为每个高缺失列生成独立的 is_na 指示列,让模型同时看到原始值和缺失模式;对缺失率超过 90% 的列直接删除,只保留指示列。重点不是填充的正确值,而是缺失模式是否是稳定信号。

5.3 时间特征不能直接归一化:训练集和测试集时间窗口不一致

现象:对 TransactionDT 做 MinMaxScaler 后,验证集 AUC 正常,但测试集的分数分布和训练集明显不同,提交效果变差。原因:训练集和测试集是连续但不重叠的时间段,全局归一化会把测试集的时间值压缩到训练集范围内,丢失了时间段的绝对偏移信息,而模型本可以学到"越晚的时间欺诈率越高"这种趋势。解决:只用周期特征,如小时、星期、是否节假日,并保留原始 TransactionDT 作为一个数值特征让树模型自行切分,不要做全局归一化。

5.4 聚合特征在测试集尾部失效:card 出现次数是未来函数

现象:用全量数据做 value_counts 生成的频率特征在验证集上表现很好,但单独看测试集最后几天的表现,AUC 明显下降。原因:频率编码用的是全局统计量,对测试集尾部的新卡或低频卡,它们的频率值被全量统计高估了。解决:严格做法是只用训练集计算频率和聚合统计量,测试集卡片的频率值用训练集的映射;不在训练集里的卡片统一归为"未知"频率。更稳妥的方案是给频率特征分箱,弱化精确值的过拟合效应。

5.5 提交文件格式踩坑:ID 要一一对全

现象:本地验证 AUC 0.95,提交后分数异常低甚至报错。原因:提交文件的 TransactionID 顺序或数量和测试集不一致,或者把训练集的索引直接带进了提交文件。解决:提交前严格用测试集的 TransactionID 作为基准,预测结果按 ID 排序后写入;不要用模型预测的索引顺序代替 ID。写文件前验证长度和 ID 是否完全一致,这个检查只需要一行代码,但能避免一次无效提交。

6. 把 AUC 从 0.93 提到 0.95 的最后一公里:概率校准与针对性调参

单模型加简单融合到 0.93 之后,提升空间不在调参,而在两件事:概率校准和针对性特征精修。先说校准。LightGBM 输出的概率是叶节点均值的映射,不是真实概率,但我们要的是排序,所以校准不是必须的。如果你的业务需要把分数转换成具体的拒绝阈值,那就用验证集预测结果做 Isotonic Regression 或 Platt Scaling,注意校准数据必须来自 OOF,不能用训练集自身的预测,否则校准曲线会严重失真。

调参方面,真正值得精调的参数只有三个:learning_rate、num_leaves 和 min_child_samples。learning_rate 降到 0.01 配合更多的迭代次数通常能稳定提升零点几个千分点的 AUC;num_leaves 在 31 到 127 之间搜索,盯住验证集 AUC 的增量是否值得;min_child_samples 调大能抑制树对极少数样本的拟合,欺诈数据里极端的正常交易样本很多,这个参数能有效防止模型把长尾噪声当信号。特征精修的方向是继续做交叉组合:把 card1 和 addr1 拼接成一个新类别列再做频率编码,捕获"同一张卡在不同地址出现"的模式;把 P_emaildomain 和 R_emaildomain 是否相同做成二值特征,捕获收件邮箱和付款邮箱不一致的风险信号。

我在实际做这个竞赛时踩过最大的坑是过度信任验证集,用了不少带泄漏的特征把验证集 AUC 刷高,最后提交才意识到线上评估不吃这一套。后来养成的习惯是每次构造特征都要问一句:这个特征在测试集数据到达的时候是否已知?如果答案是否定的,就只能从训练集内部构造。这套思路不仅适用于这个竞赛,也适用于所有有穿越性质的风控建模。希望这些经验对你的项目有直接帮助,少走我走过的弯路。

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

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

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

立即咨询