简介:这是一套面向数据分析和机器学习学习者的桑坦德客户满意度竞赛项目,围绕Kaggle经典赛题展开,使用Jupyter Notebook完成从数据探索到模型构建的完整流程。压缩包共19个文件,约21.23MB,包含7个CSV格式的训练/测试数据与预测结果、6个已保存的机器学习模型(AdaBoost、梯度提升、决策树、随机森林、Logit和SVM)、2个Python脚本及对应pyc缓存,以及1个主分析Notebook和配套README,同时还提供网格搜索结果记录,便于对比不同模型的效果。项目通过加载预览、清洗缺失值、特征工程、EDA可视化和多模型训练评估等环节,系统展示了处理二分类满意度问题的标准思路。目前已有223人学习下载,适合希望掌握Pandas数据处理、Scikit-learn建模与调参技巧的初学者或竞赛入门者。读者可从中获得完整可复现的代码方案、模型对比结果与实际调参经验,按Notebook逐步运行即可理解各步骤的设计逻辑。
1. 这个匿名数据项目到底在预测什么:371 列特征背后的客户满意度二分类
Project---Santander-Customer-Satisfaction 这份数据,表面上是某银行客户满意度调研产出的三百多列匿名数值,实质是一道标准二分类题:用 371 列(含 ID 和 TARGET)预测一个客户会不会给差评。训练集 76020 行,TARGET 里约 96% 是满意客户(0),不满意的只有约 3.6%,线上评估只看 AUC。我第一次拿到时也对着黑匣子发过呆——特征全部脱敏、没有任何业务字段说明、连缺失值都几乎没有,仿佛只能靠玄学调参。做完一轮才发现,这类全匿名表格数据的套路恰恰是最固定的:先确认目标分布和特征与目标之间的单调关系,再做保守清洗与秩变换,最后用梯度提升树把 AUC 推到 0.82 以上。这篇就把这套流程完整拆开,适合正在做表格分类、想搞清匿名特征该怎么下手的工程师,也适合对照自己的项目踩坑。
2. 先看数据再动手:目标分布、特征单调性与基线 AUC
2.1 读入数据与目标分布:为什么说这是"最难做的简单分类"
拿到数据第一步永远不是建模,而是确认三件事:形状、缺失、目标比例。这份数据的标准形态是训练集 76020 行、371 列,其中 ID 是行号、TARGET 是标签,剩下 369 列几乎全是 float,没有 categorical 列。这意味着 pandas 的 describe 和 value_counts 基本就能把数据摸干净,不需要复杂的数据字典。真正要留神的是正例只有 2700 多个,任何按标签分组的统计量都会因为样本太少而剧烈抖动,后面所有分箱、编码操作都得把这个分母记在脑子里。
import pandas as pd import numpy as np df = pd.read_csv("train.csv") print(df.shape) # (76020, 371) print(df.dtypes.value_counts()) y = df["TARGET"] print(y.value_counts(normalize=True)) # 0 ~ 0.9636 # 1 ~ 0.0364 print("缺失值总数:", df.isna().sum().sum())这段代码做三件事。shape 先确认列数,防止读文件时多带索引列;dtypes 的 value_counts 可以看到绝大多数列是 float,说明数据做过数值化脱敏;value_counts(normalize=True) 直接给出正负样本比例,1 的比例约 3.6%,这个数字是后面所有决策的分母。缺失值总数打印出来是 0,但不要高兴太早——没有 NaN 不代表没有脏值,我习惯紧接着看每列的取值分布,尤其是那种"某个值占了绝大多数"的列。
print(df["var3"].value_counts().head(5))var3 是这份数据里最经典的异常列:值 2 占了七万多行,-999999 只占一小撮。这种"单值绝对主导 + 少量人造极端值"的形态,在匿名数据里基本是缺失标记或者业务上的特殊状态。它不叫缺失,但比缺失更坑,因为树模型会认真拿它做分裂条件,白白浪费深度。这个点第 3 章专门处理,现在先记下来。
这个数据还有一个容易误导新手的属性:评估指标是 AUC,不是 accuracy。96% 的负样本意味着你全预测 0 也能拿到 96% 准确率,但这在 AUC 面前毫无意义。AUC 只看正样本的预测分数是否排在负样本前面,所以它天然免疫类别不平衡,也意味着我们不需要上来就做采样、调阈值。这点想通之后,整个建模目标就变得很纯粹:把正样本的概率排名往前提。
2.2 特征与目标的单调关系:这份数据最有价值的先验信息
这个项目最出名也最容易被忽略的先验是:几乎所有特征和目标之间都存在单调关系。把任意一个特征按分位数切成 20 箱,算每个箱里 TARGET 的均值,你会发现箱号越大 TARGET 均值越高(或越低),中间既没有"倒 U",也没有反复震荡。这说明这些特征不是原始业务字段,而是经过某种统计变换生成的量,信息已经按目标排好序了。这个先验直接决定预处理策略:分箱、秩变换、特征选择全部以"单调"为前提,而不是以传统分布形态为前提。
def bucket_target_mean(series, target, bins=20): cut = pd.qcut(series.rank(method="first"), bins, labels=False) tmp = pd.DataFrame({"b": cut, "y": target}) return tmp.groupby("b")["y"].mean() means = bucket_target_mean(df["var15"], y) print(means.round(4)) trend = np.corrcoef(np.arange(len(means)), means)[0, 1] print("box index vs target mean:", round(trend, 3))这里用 rank 而不是原始值做 qcut,是因为特征里有极端值会带偏分位点;groupby mean 得到 20 个箱的目标均值序列;最后算箱序号和箱均值的相关系数,接近 ±1 说明单调性强。把 369 列全跑一遍,你会发现绝大多数特征的相关系数绝对值超过 0.9。注意:这个相关性是"箱均值和目标均值"的,不是"特征值和目标值"的直接相关,所以它不代表强预测力,只代表结构稳定。
bins=20 是经验值。76020 行切 20 箱,每箱约 3800 行,目标均值足够稳;如果把 bins 放到 50,尾部箱只剩几百行,均值开始乱跳,单调趋势被噪声打断。后面所有分箱都用 20,除非某个特征取值种类极少,那种列可以直接看原始 value_counts,不需要分箱。
这个先验还有一个推论:哪些特征真正有用,看它的箱均值曲线跨度就知道。跨度大的特征,正样本概率从最低箱到最高箱可能差 10 倍;跨度小的,基本是噪声列。我一般会把所有特征的跨度排序,尾部那些曲线几乎水平的列,在后续建模里基本不贡献,可以留着不处理,也可以删除,对结果影响很小。
2.3 用逻辑回归建立基线:先有一个能跑的分数
任何预处理和调参都要有对照系。对这个项目,我习惯先用逻辑回归在原始特征上跑一个 OOF AUC,不做特征选择、不做清洗。原因很简单:LR 对尺度敏感、对噪声敏感,如果 LR 在某个特征集上分数明显提升,说明改进来自特征本身;反过来,如果 LR 没变化、XGB 却涨了,那多半是树结构在起作用。两个模型互为参照,比单看一个模型更容易判断每一步操作的真实收益,也避免你把树的拟合能力误当成特征工程的功劳。
from sklearn.model_selection import StratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score X = df.drop(columns=["ID", "TARGET"]) y = df["TARGET"] folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) oof_lr = np.zeros(len(df)) for tr, va in folds.split(X, y): scaler = StandardScaler().fit(X.iloc[tr]) X_tr = scaler.transform(X.iloc[tr]) X_va = scaler.transform(X.iloc[va]) lr = LogisticRegression(C=0.1, max_iter=1000) lr.fit(X_tr, y.iloc[tr]) oof_lr[va] = lr.predict_proba(X_va)[:, 1] print("LR OOF AUC:", round(roc_auc_score(y, oof_lr), 4))这里有三个细节必须强调。第一,StratifiedKFold 保证每折正例比例一致,对 3.6% 正例的数据这是底线,普通 KFold 可能把某一折的正例分到只剩 300 个。第二,StandardScaler 必须在训练折上 fit,再 transform 验证折,整份数据统一 fit 会引入验证信息,属于最常见的隐性泄漏。第三,C=0.1 是正则偏强的起点,369 列匿名特征之间存在大量共线,默认 C=1 容易让 LR 去拟合特征间的噪声协方差。
裸 LR 在这个数据上 OOF AUC 大约在 0.77~0.79 之间,这就是原始特征下线性模型的真实水平。这个分数本身已经说明一件事:特征里包含的信息量是足够的,纯线性模型不加任何交互就能到 0.78。问题只出在噪声和尺度上,这正是第 3 章要解决的。如果 LR 基线连 0.7 都不到,我会先怀疑数据读错了或者标签方向反了,而不是继续往下调参。
3. 特征清洗与离散化:把单调特征变成模型能吃透的形态
3.1 为什么对匿名特征做分箱要基于目标均值
一般特征分箱(等宽、等频)的目的只是把连续值粗化,减少过拟合,但在这份数据上,分箱的真正目的是隔离噪声。因为特征与目标单调,每个箱的 TARGET 均值本应平滑递增,一旦某个箱的均值突然掉头,基本就是极端值或者空值标记在作怪。所以这里的分箱不是"造特征",而是"质检":用箱均值曲线反推特征质量。做法是当前折上算箱均值、验证折上应用,而不是全量计算,否则就变成了泄漏。
另一个必须处理的是 var3 里的 -999999。这种人造极端值在匿名数据里几乎总是脏标记,可能是缺失值编码,也可能是某种特殊账户状态。把它留在列里,LR 的系数会被这一个值扯动,树模型则会专门为它切一个分支,而这个分支在测试集上对应的样本语义未必相同。常见做法是替换成该列众数。替换之后 var3 基本退化成常数列,对模型不贡献信息,但至少不再制造假分裂。
3.2 处理 -999999 与秩变换:两个可复制的操作
先解决 var3 的极端值,这一步逻辑简单但顺序有讲究:
# 1) 把 var3 的 -999999 视为缺失 fill_val = df["var3"].mode()[0] # 众数,通常是 2 df["var3"] = df["var3"].replace(-999999, np.nan) df["var3"] = df["var3"].fillna(fill_val) print(df["var3"].value_counts().head(3))fill_val 必须在训练集上先算好,测试集直接用同一个值填,绝不可以在测试集上重新算众数。原因很直白:测试集是一个没有标签的样本,任何从测试集统计出来的值都会让你的预处理管线在线上部署时无法复现。替换完打印 value_counts 是为了确认列已经退化为近常数,后面建模时这列基本不会被选中分裂。
第二步是秩变换,这是这份数据上我几乎必做的一步:
from scipy.stats import rankdata def rank_transform(X_df): arr = X_df.to_numpy(dtype=float) for j in range(arr.shape[1]): arr[:, j] = rankdata(arr[:, j]) / arr.shape[0] return arr对每一列做百分位秩归一化,输出均匀分布在 0 到 1 之间。这和前文说的单调结构天然匹配:特征值的高低顺序就是目标概率的高低顺序,秩变换保住顺序、丢掉原始间距,而原始间距里恰恰藏着极端值噪声。对树模型来说,分裂只依赖排序,所以秩变换后 XGB 的分数变化不大;对 LR 和神经网络,这个变换通常能带来 0.005~0.01 的 AUC 提升,因为它同时解决了尺度问题和长尾问题。
rankdata 的 method="average" 处理并列值:等价的值取平均排名,不会人为造出虚假顺序。这一步在训练集和测试集上要独立做,还是那句话,各自用自己的分布算排名,不要拿训练集的排名参数去映射测试集,因为测试集的行数不同、分布也可能有偏移。如果你用的是 sklearn 的 RankGauss 或者自定义函数,注意它 fit 之后 transform 的行为,本质上和这里一样。
做完这两步,我通常还会顺手删掉方差接近 0 的列。秩变换之后,常数列会变成均匀分布的伪随机数——注意这不是信息,这是噪声。所以删除要在变换之前做,判断依据是变换前的原始取值种类数,比如 var3 替换后基本只有一两个不同值,直接 drop,别让它进模型。
3.3 清洗前后的 AUC 对比:什么时候该停手
清洗有没有效果,不能靠感觉,要在同一套折上对比。下面这段代码直接复用 2.3 的 folds 对象,把秩变换后的数组喂给同一个 LR 配置:
X_clean = rank_transform(df.drop(columns=["ID", "TARGET"])) oof_lr_clean = np.zeros(len(df)) for tr, va in folds.split(X_clean, y): scaler = StandardScaler().fit(X_clean[tr]) lr = LogisticRegression(C=0.1, max_iter=1000) lr.fit(scaler.transform(X_clean[tr]), y.iloc[tr]) oof_lr_clean[va] = lr.predict_proba(scaler.transform(X_clean[va]))[:, 1] print("LR clean OOF AUC:", round(roc_auc_score(y, oof_lr_clean), 4))把结果放到一起看,节奏非常清楚:
| 方案 | LR OOF AUC | 说明 |
|---|---|---|
| 原始特征 | 约 0.78 | 含极端值噪声,尺度不统一 |
| 替换 var3 后 | 约 0.79 | 去掉人造极端值,系数更稳 |
| 再秩变换 | 约 0.80 | 尺度统一,长尾收敛,线性可分性提升 |
每一步提升大概在 0.005~0.01,这是正常节奏。真正难的是知道什么时候该停手。我的判断标准就一条:当一步操作带来的提升小于 0.002,并且代码开始为了这 0.002 增加复杂度时,立刻停。这个量级的提升大概率是噪声拟合,换个 CV 种子就消失。所有对比都必须在同一套折、同一随机种子下进行,这比具体的预处理技巧更重要。
还有一类操作要特别警惕:对这个数据做 PCA 或者聚类特征。特征本身是单调统计量,PCA 会把这种结构打散,聚类则因为样本量 7.6 万、正例太少而极不稳定,基本只能做出运气成分。我见过有人在这里花了大量时间,最后 AUC 变化在正负 0.001 之间晃,属于典型的白忙活。清洗做到秩变换这一步已经足够,剩余的空间留给第 4 章的树模型。
4. 用 XGBoost 把分数推上去:参数与调参顺序
4.1 为什么梯度提升树是这类表格数据的主模型
LR 到 0.80 之后基本到顶,瓶颈在于特征间存在大量非线性交互,而匿名特征又没有业务语义让你手工构造交互项。树模型的自动分裂正好接手这件事:它会在训练中自动找到"这个特征大于某个分位且另一个特征小于某个分位"这样的组合条件。XGBoost、LightGBM、CatBoost 三选一,我选 XGB 的原因很实际:这个数据量(76020 行、369 列)用 hist 算法跑得足够快,调参资料最全,而且它对缺省参数更宽容,不容易因为一两个参数没设对就在种子间剧烈波动。
LightGBM 在同样数据上通常更快,但 leaf-wise 生长策略在这个强单调结构的数据上容易过拟合,需要更紧的正则;CatBoost 的有序提升在这个纯数值、无类别特征的项目上没有发挥空间。所以主模型选 XGB,LightGBM 留到最后一章做融合的多样性来源。
4.2 一组能直接跑的核心参数与早停配置
下面这组参数是我在这个项目上反复用、每次都能跑到 0.82~0.83 的起点配置。直接看代码:
from xgboost import XGBClassifier oof_xgb = np.zeros(len(df)) for tr, va in folds.split(X_clean, y): model = XGBClassifier( n_estimators=3000, learning_rate=0.02, max_depth=6, subsample=0.85, colsample_bytree=0.8, min_child_weight=5, reg_lambda=1.0, eval_metric="auc", tree_method="hist", n_jobs=-1, random_state=2024, ) model.fit( X_clean[tr], y.iloc[tr], eval_set=[(X_clean[va], y.iloc[va])], early_stopping_rounds=100, verbose=False, ) oof_xgb[va] = model.predict_proba(X_clean[va])[:, 1] print("XGB OOF AUC:", round(roc_auc_score(y, oof_xgb), 4))X_clean 是第 3 章秩变换后的 numpy 数组。早停用验证折决定最佳迭代数,100 轮 patience 在 lr=0.02 下,相当于允许模型在验证集上连续 100 轮不进步才停,这个窗口足够宽,不会因为单轮抖动就提前截断。用验证折做早停会让 OOF AUC 有一点点乐观,通常偏差小于 0.002,可接受;但要知道它存在,第 5 章的陷阱四会专门讲这个落差的处理。
参数的选择思路比参数本身更重要。先看这张表:
| 参数 | 取值 | 作用 |
|---|---|---|
| learning_rate | 0.02 | 小学习率让每个树只做微调,配合早停控制迭代数 |
| max_depth | 6 | 控制单棵树复杂度,5~8 都是平坦区 |
| subsample | 0.85 | 行采样,降低每棵树的方差 |
| colsample_bytree | 0.8 | 列采样,369 列下增加树间多样性 |
| min_child_weight | 5 | 拦住收益过小的分裂,是对匿名噪声最有效的正则 |
| reg_lambda | 1.0 | L2 正则,和 min_child_weight 配合 |
| tree_method | hist | 近似分箱直方图,7.6 万行下速度提升明显 |
最重要的两个是 max_depth 和 min_child_weight。max_depth 等于 6 在这份数据上是明显的平坦区:小于 5 欠拟合,大于 8 开始把单调结构里的细碎噪声也学进去,OOF 反而下降。min_child_weight=5 是关键中的关键,匿名数据里大量分裂的收益极小,这个参数要求每个叶子节点至少积累 5 的样本权重和,把这些噪声分裂直接挡在门外。调参顺序我一般是先定 lr 和早停,然后只动 max_depth 和 min_child_weight,确认这两个的平坦区后,再试 subsample 和 colsample_bytree,最后才碰 reg_lambda。不要一上来就 grid search 全排列,参数之间的交互在这个样本量下很容易把你带进局部最优。
这里还有一个容易被忽略的点:我没有在参数里写 scale_pos_weight。原因前面说过,AUC 是排序指标,类别权重只会改变模型输出概率的尺度,不会改变排序质量。如果你后面要接业务阈值,可以单独试 scale_pos_weight=3 这类温和取值,但判断依据永远是 OOF AUC 而不是召回率。
4.3 阈值的选择:在 96% 负样本下怎么定 cutoff
AUC 只回答排序质量,不回答"概率超过多少算不满意"。真实业务需要一个二分类标签时,才会涉及阈值。先把结论说清楚:在这个 96% 负样本的数据上,模型输出的概率整体偏低,如果你把阈值定在 0.5,召回率会非常难看,因为正样本的预测分数大多在 0.1 到 0.4 之间。常见做法是根本不设阈值,直接按概率排序,取前 5% 或者按客服工位预算取前 N 个。如果一定要一个硬阈值,就在 OOF 概率上画曲线,别去测试集上调。
from sklearn.metrics import precision_score, recall_score for cut in [0.05, 0.10, 0.20, 0.30, 0.50]: pred = (oof_xgb >= cut).astype(int) p = precision_score(y, pred, zero_division=0) r = recall_score(y, pred, zero_division=0) print(f"cut={cut:.2f} precision={p:.3f} recall={r:.3f}")这段代码的输出会呈现一个典型的规律:cut 越高精确率越高、召回率越低;cut 越低召回率上升但精确率崩得很快。具体选哪个点,取决于业务上"漏掉一个不满意客户"和"错打一个满意客户电话"哪个代价更大。满意度场景通常更怕漏掉不满客户,所以阈值会往 0.1 甚至更低压,代价是精确率下降、客服工作量上升。zero_division=0 是防止某个 cutoff 下完全没有正预测时 precision 报错。
阈值选定后还有一个稳定性校验:把 5 折各自的阈值-精确率曲线叠在一起画,选 5 折走势都一致的区间,不要选某单折最优的那个点。单折最优几乎总是运气,5 折一致才是数据本身的规律。这个校验做完,阈值这件事才算真正落地。
5. 避坑指南:这个项目上最容易翻车的 5 个细节
前面流程可以稳定跑到 0.82~0.83,但我在这个项目上见过不少翻车案例,问题高度集中在 5 个点上。每一条都是血泪经验,按"现象 → 原因 → 解决"拆开讲。
5.1 陷阱一:把 ID 列当特征,训练分数虚高
现象:有人图省事直接拿全表 fit,训练集 AUC 能到 0.99,OOF 却只有 0.83,线上更是一塌糊涂。还有人模型的 feature_importance 里 ID 排第一,怎么正则都压不下去。
原因:ID 是行号,每行一个唯一值,树模型可以在分裂时把 ID 当成记忆索引,直接查表把训练样本的标签背下来。早停根本拦不住这种"捷径",因为它在验证集上同样有效——验证集的 ID 也是唯一的。
解决:建模前务必 drop("ID"),测试集同样要丢,否则线上特征数量对不上直接报错。这里没有例外,哪怕你觉得"排第一的特征被我删了我吃亏",也删。ID 在这份数据里除了当行号没有任何语义,留着只有坏处。
5.2 陷阱二:用全量 target encoding 把单调特征变成"标签复印件"
现象:有人把全部特征做目标均值编码后,线下 AUC 冲到 0.85,比 XGB 还高。一上测试集崩回 0.82,然后开始怀疑是模型不够复杂,越调越崩。
原因:全量训练集上算目标均值,等于把标签直接抄进特征。这份数据的特征和目标强单调,泄漏会被放大好几倍,所以线下分数才虚高得离谱。测试集没有标签可抄,分数自然打回原形。
解决:这类全数值单调特征根本不需要目标编码,第 3 章的秩变换已经吃透了单调结构。如果非要编码,只能在每折训练集上计算、验证折上映射,并且只对离散化后的高基数特征做,不做全集。做任何基于标签的编码之前,先问自己一句:这个操作在测试集上能复现吗?不复现的就是泄漏。
5.3 陷阱三:交叉验证种子不固定,调参调了个寂寞
现象:同一组参数,random_state 从 42 换成 2024,OOF AUC 从 0.833 变成 0.831。你以为是参数波动,实际是种子波动,一来一回白调半天。
原因:正例只有 2700 多个,5 折里每一折正例的构成差异都会带来 0.002~0.005 的波动,这个幅度和参数调整带来的变化是同一量级的。不固定种子,你根本分不清分数的变化来自参数还是来自运气。
解决:全局固定一个 CV 骨架——同一个 folds 对象从头用到尾,所有对比都在同一划分上做。最后一轮评估时再用 5 个不同种子各跑一遍,报告均值加标准差,而不是单次最好值。如果标准差超过 0.003,说明你的模型本身不稳定,先加正则,别急着细调参数。
5.4 陷阱四:全量重训后线上分数比 CV 低,怀疑自己过拟合
现象:OOF AUC 0.833,全量重训后提交,线上 0.831。有人立刻开始删特征、加正则,结果越改越低,最后连 0.83 都回不去。
原因:OOF 因为用验证折做早停,本来就带一点乐观偏差;全量重训只是把这个偏差抹掉,0.002 的落差是正常量级,不是过拟合的信号。把重训结果和 OOF 严格对齐,反而说明你的验证流程是诚实的。
解决:把全量重训当成"交付动作"而不是"验证动作"。验证只看 OOF,重训时用 CV 平均最佳迭代数再乘 1.1,固定种子跑完直接出概率文件。重训之后不再做任何选型判断,不回头对比、不反复试参数,否则你一定会掉进用测试集调参的坑。这个习惯能避免大量自我怀疑。
5.5 陷阱五:正例太少就上 SMOTE,结果 AUC 原地踏步
现象:看到 3.6% 的正例比例,第一反应是"数据不平衡,要过采样"。SMOTE 之后召回率从 0.5 涨到 0.7,提交线上分数却一动不动甚至下降。
原因:评估指标是 AUC,它只关心排序,不关心召回率。SMOTE 生成的是插值样本,会改变正常样本在特征空间里的相对位置关系,排序质量不升反降。召回率涨是因为合成样本把决策边界推宽了,而 AUC 衡量的正是排序,所以它最诚实。
解决:3.6% 正例在 76020 行里对应 2700 多个样本,这个数量足够训练,不需要过采样。如果实在担心类别信息丢失,用 scale_pos_weight=3 这种温和权重试一次,看 OOF AUC 变化,涨就留,不涨就删。任何改变样本分布的操作,最后都要回到 OOF AUC 上接受审判。
6. 最后一步:融合与全量重训,把模型落成可交付的预测器
6.1 五折预测拼接:线下验证与线上交付的一致性
OOF 拼接是整个流程最关键的产物。5 折模型分别预测验证折,拼成一条和训练集等长的概率向量,这条向量既用来算最终 AUC,也用来定阈值、定融合权重。交付时用的模型是全量重训模型,但重训模型的一切选择依据都来自 OOF 这条向量。融合层面,常见做法是 XGB 和 LightGBM 各跑 3 个种子,在 OOF 上搜索最优加权权重,权重大概在 XGB 0.7、LGBM 0.3 附近,然后把权重固定死,不再回头调。融合提升通常有 0.002~0.005,但前提是两模型的预测相关性够低。
6.2 一份可交付的推理代码骨架
def load_and_predict(test_csv, output_csv): test = pd.read_csv(test_csv) ids = test["ID"] X_test = test.drop(columns=["ID"]) if "TARGET" in X_test.columns: X_test = X_test.drop(columns=["TARGET"]) model = XGBClassifier() model.load_model("final_model.ubj") prob = model.predict_proba(X_test)[:, 1] pd.DataFrame({"ID": ids, "TARGET": prob}).to_csv(output_csv, index=False) print("saved:", output_csv)推理时不设阈值,直接输出概率,因为评估是 AUC,排序即答案。两个容易漏的细节:一是训练时对 var3 做的替换值、秩变换的列顺序,要存成配置文件或者 pickle,测试集必须走完全相同的变换;二是保存 XGB 模型用 save_model 的原生格式 .ubj,不要用 pickle 存 XGB 模型,跨版本反序列化经常崩。load_model 直接读原生格式就稳。
6.3 交付前的自检清单
| 检查项 | 做法 | 通过标准 |
|---|---|---|
| 特征一致性 | 对比训练集和测试集 drop 后的列名集合 | 完全一致 |
| 预处理可复现 | var3 填充值和秩变换脚本固化为一个函数 | 跑两遍结果一致 |
| 多种子稳定性 | 5 个种子各跑一遍 OOF | AUC 标准差小于 0.002 |
| 预期差控制 | 全量重训模型在小验证集上的 AUC 与 CV 均值对比 | 差值小于 0.005 |
这套自检做完,模型才算真正交付。我现在的习惯是:拿到任何匿名表格数据,第一件事就是跑单调性检查和 LR 基线,再决定要不要上树。这个习惯就是从这个项目上养成的,它帮我过滤掉了一大半无效的预处理操作。希望这篇能帮你在同样的数据上少走几步冤枉路,直接做到能交付的水平。
本文还有配套的精品资源,点击获取