做数据挖掘类竞赛的人大概都有这种体验:前半程读题、看字段、翻数据字典,信心满满;真正卡住手的地方,往往不是模型结构够不够花哨,而是表格里那一片片空缺值,以及严重偏斜的标签分布。这篇接着上一次的话题往下聊,把缺失值处理、过采样和baseline训练这三件事串成一条能跑通、能复现、也能往上叠特征的流水线。简单说,这是一份面向表格类分类赛题的“后半程施工手册”:数据脏怎么救、正负样本悬殊怎么调、模型怎么从“能跑”做到“能上榜”。适合已经跑通过一次提交、但分数卡在原地不动的选手,也适合刚接触这类赛题、想少走弯路的朋友——代码我会尽量给到能直接抄的程度,思路部分会讲清楚每一步为什么这么做。
1. 先把赛题当成工程问题:整体思路与方案选型
1.1 后半程拼的其实是数据,不是模型
我参与过几次类似的行业数据建模赛,一个很稳定的规律是:前 20% 的队伍靠模型调参互相咬,后面 80% 的队伍差距全在数据处理上。原因不复杂——表格类赛题给的特征通常只有几十到几百列,其中真正有信息量的可能不到三分之一,剩下的要么是缺失严重的“半废”字段,要么是分布极度偏斜、模型根本学不到东西的噪声列。这种情况下你换上更深的网络、更复杂的集成,收益可能只有千分之几;但你要是把缺失机制判断对了、把几个关键字段的重编码做对了,AUC 抬两个点是常有的事。
所以我的整体判断是:这个阶段的时间分配应该是 5:2:3——五成时间花在数据理解与特征处理上,两成花在采样与验证策略上,三成才是模型与调参。很多人反过来,八成时间在调参,结果就是分数在原地打磨,越调越焦虑。
1.2 三段式流水线的设计取舍
整条流水线我是这么排的:原始表 → 缺失处理 → 编码与特征构造 → 折内重采样 → 模型训练 → 折外预测 → 提交。顺序不能随便动,原因有三条。
第一,缺失处理必须在编码之前。很多类别型字段本身就有缺失,如果你先做独热编码,缺失会变成一个独立的哑变量列,虽然能跑,但会稀释信息,而且后续的统计类填充(比如按组中位数填充)就没法做了。
第二,重采样必须放在交叉验证的折内。这是最容易翻车的地方,后面第 3 章会专门讲。简单说,你在全集上先 SMOTE 再切分,验证集的合成样本跟训练集是“同源”的,评估结果会虚高,线下 0.92、线上 0.85,白高兴一场。
第三,模型选型优先考虑对缺失和类别特征友好的树模型。这类赛题的数据大多是结构化表格,LightGBM 和 XGBoost 在默认参数下就能给出一个相当稳的 baseline,而且原生支持缺失值处理(LightGBM 会把缺失分到一个默认方向)。相比之下,线性模型需要你先做完备的填充和标准化,逻辑回归跑出来的 baseline 往往会低一截,用它来定位问题可以,拿来当主力不划算。
1.3 从第一行代码起就要防数据泄漏
我把这条单独拎出来说,是因为它造成的损失是隐性的——你不会报错,只会得到一个漂亮但假的分数。常见泄漏点有这么几个:在全量数据上算均值/中位数去填充(验证集的统计信息渗进了训练集,虽然影响小,但不干净);在全量数据上做目标编码(这个影响极大,标签信息直接泄漏);在全量数据上做标准化;在全量数据上做 SMOTE。处理办法很朴素:所有“需要 fit”的操作,一律塞进 Pipeline,让它在每一折的训练子集上单独 fit。
注意:填充用的统计量本身也是“学”出来的参数。凡是带 fit 的东西,都不该看见验证集。这句话值得贴在显示器上。
2. 缺失值处理:先判断机制,再决定填什么
2.1 三种缺失机制与五分钟快速判别
教科书把缺失分成完全随机缺失、随机缺失、非随机缺失三类。实际比赛里没时间做严格的统计检验,我用一套很土但很有效的判别流程:
第一步,算每列的缺失率,画一张缺失率条形图,同时做缺失共现矩阵。如果某几列的缺失总是同时出现,说明它们大概率是同一个业务流程留下的“未采集”标记,比如“审核未通过”的样本,后面一串字段全空。这种情况不要分开填,要么整块删,要么整块构造一个“流程未完成”的指示变量。
第二步,把目标变量按“某列是否缺失”分组,看两组的正样本率差异。差异在 1 个百分点以内,基本可以放心填;差异超过 5 个百分点,说明缺失本身携带强信息,这时候填充只能是辅助,缺失指示变量才是主角。
第三步,看缺失率和字段重要性排序的关系。经验上,缺失率超过 60% 的列,除非它的缺失指示变量单独就有区分度,否则直接丢掉更划算。
| 缺失率区间 | 我的常规处置 | 理由 |
|---|---|---|
| 0% | 不动 | 无 |
| 1%–10% | 简单填充(中位数/众数/分组中位数) | 影响小,不值得投入 |
| 10%–40% | 分组填充 + 缺失指示变量 | 信息量可观,值得花时间 |
| 40%–70% | 先做指示变量验证,无效则删列 | 填充误差可能大于信息量 |
| >70% | 直接删列,或仅保留指示变量 | 填充基本等于编数据 |
2.2 数值型与类别型字段的填充策略对比
数值型字段我最常用的不是均值,而是分组中位数。原因很实在:这类赛题的数据往往来自多个业务条线或多地区,同一个字段在不同分组下的量纲和分布差别极大。全局均值填充会把高端组的空值拉到中位水平,等于人为制造了一批“假中等”样本。按分组做中位数填充,至少保住了组内的分布形状。
类别型字段优先级是:众数填充 < 单独一类“未知” < 按分组众数填充 < 用其他字段做规则推断。最后一种效果最好但最费时间,比如“职业”缺失但“学历”和“收入区间”都在,可以写几条规则补上。
还有一种情况要警惕:零值其实是伪装的缺失。有些字段的 0 不是真实的 0,而是系统默认值,比如“贷款笔数 = 0”可能是“未查询到”。判断方法是看该列 0 的占比是否异常高,以及 0 值样本的标签分布是否与缺失样本接近。如果接近,把 0 替换成 NaN 再做统一处理,往往能涨一点。
| 策略 | 适用场景 | 优点 | 代价 |
|---|---|---|---|
| 均值/中位数 | 缺失率低、分布近似对称 | 一行代码,稳定 | 压缩方差 |
| 分组中位数 | 存在明显分组效应 | 保住组内分布 | 需要选对分组键 |
| 众数/单独类别 | 类别型、缺失率低 | 简单 | 引入人为类别 |
| KNN 填充 | 数值特征相关性高、样本量中等 | 保留样本间关系 | 慢,对量纲敏感,必须标准化 |
| 模型填充(如 LightGBM 回归) | 该列重要且有其他强相关列 | 精度最高 | 实现复杂,有泄漏风险 |
| 不填,交给树模型 | LightGBM/XGBoost | 零成本,效果常不差 | 只能用树模型 |
我的实测体会:在表格赛题里,“交给 LightGBM 自己处理”这个选项被严重低估了。很多时候 KNN 填充折腾一下午,还不如直接用原生缺失处理 + 一个缺失指示变量。
2.3 让“缺失”本身变成特征
这是我认为性价比最高的一步。做法是把每个重要字段的缺失状态做成 0/1 指示列,然后看它的特征重要性。我遇到过一次很典型的情况:某字段缺失率 35%,填充做得再怎么精细,模型都不太用它;但把“该字段是否缺失”做成指示变量之后,它直接进了重要性前五。这说明缺失与否本身就是业务上的强信号——很可能是“该客户未被风控系统覆盖”,这类客户的风险分布跟被覆盖的完全不一样。
指示变量不要无脑全建,会撑爆特征维度。我的筛选条件是:该列缺失率在 5%–70% 之间,且单独做单变量分析(比如分箱后的 WOE 或直接看正样本率差异)有区分度。满足这两条才建。
2.4 一套可复用的缺失处理管道
下面这段代码我基本每个表格赛题都会拿来改改用,先出报告,再决定策略:
import pandas as pd import numpy as np def missing_report(df, target=None, topk=30): rep = pd.DataFrame({ "dtype": df.dtypes.astype(str), "missing_cnt": df.isna().sum(), "missing_rate": df.isna().mean().round(4), "nunique": df.nunique(dropna=True), }) rep = rep[rep["missing_cnt"] > 0].sort_values("missing_rate", ascending=False) if target is not None and target in df.columns: # 缺失 vs 非缺失 的目标均值差异 rates = [] for col in rep.index: m = df[col].isna() if m.sum() == 0 or (~m).sum() == 0: rates.append(np.nan) else: rates.append(round(df.loc[m, target].mean() - df.loc[~m, target].mean(), 5)) rep["target_diff"] = rates return rep.head(topk) print(missing_report(train, target="label"))拿到报告后,分组填充这么写:
def group_median_fill(df, col, group_key, fallback="global"): df[col] = df.groupby(group_key)[col].transform( lambda s: s.fillna(s.median()) ) if fallback == "global": df[col] = df[col].fillna(df[col].median()) return df for c in ["income", "loan_amount", "work_years"]: train = group_median_fill(train, c, group_key="region_code")缺失指示变量:
na_cols = [c for c in train.columns if 0.05 <= train[c].isna().mean() <= 0.70] for c in na_cols: train[f"{c}__isna"] = train[c].isna().astype(np.int8) test[f"{c}__isna"] = test[c].isna().astype(np.int8)这里有个坑:指示变量的构造必须同时作用于训练集和测试集,且不能依赖标签。如果测试集某列完全没有缺失,建出来的指示变量就是全 0 常量列,LightGBM 会直接忽略,问题不大,但记得在特征筛选时把方差为 0 的列剔掉。
3. 过采样:为什么你的 SMOTE 越采越差
3.1 类别不平衡的真实代价与指标选择
先说结论:不平衡本身不是问题,用错指标才是问题。假设正样本占 3%,你全预测负类,准确率 97%,看起来很漂亮,实际毫无价值。这类赛题如果按准确率评估,那就要在提交前手动调阈值;如果按 AUC 评估,那其实不太需要重采样——AUC 对类别分布不敏感,重采样带来的提升往往来自“让模型更关注少数类的排序”,而不是真的学到了新信息。
我遇到的绝大多数情况是:评估指标是 F1、召回率或 PR-AUC 这类对少数类敏感的指标。这时候才需要认真处理不平衡。处理之前先算一个基线:不做任何采样,直接训练,看 F1 是多少。这个数字很重要,很多人跳过这一步直接上 SMOTE,最后不知道提升到底来自采样还是来自别的改动。
3.2 SMOTE 家族的实际表现对比
SMOTE 的原理是在少数类样本之间做线性插值生成新样本。听起来合理,但表格数据有个特点:很多特征是离散的或者有强业务约束(比如年龄 23.7 岁这种合成值毫无意义)。所以原版 SMOTE 在表格赛题上的表现经常不如预期。
| 方法 | 核心思路 | 适合的场景 | 我在实战中的观感 |
|---|---|---|---|
| 随机过采样 | 有放回复制少数类 | 样本量小、想快速验证 | 容易过拟合,但作为基线够用 |
| SMOTE | 少数类近邻插值 | 数值特征为主、连续性强 | 表格数据上不稳,离散列要小心 |
| Borderline-SMOTE | 只在决策边界附近插值 | 类别重叠区域大 | 比原版稳,是我常用的一档 |
| ADASYN | 按学习难度自适应加权 | 少数类内部分布不均 | 对噪声敏感,容易造出离群点 |
| 随机欠采样 + 集成 | 多次欠采样训练多个模型 | 多数类样本极多 | 稳定,配合 bagging 效果好 |
| 仅调 class_weight | 不改数据,改损失权重 | 数据量本来就小 | 最省事,很多时候够了 |
我自己的优先级是:class_weight > 欠采样集成 > Borderline-SMOTE > 原版 SMOTE。尤其是样本量本来就只有几千条的时候,合成样本很容易把模型带偏,反而是调权重来得干净。
3.3 顺序问题:先切分再采样,一步都不能错
这是我在早期踩得最惨的坑。当时的做法是:全量数据 → SMOTE → 划分训练测试 → 训练。结果线下 F1 0.87,线上只有 0.79。原因就是合成样本在划分时被同时分到了训练和测试两边,测试集里混进了“训练集的近邻副本”,评估结果严重虚高。
正确姿势有两种。第一种是用 imblearn 的 Pipeline,把采样器当成 pipeline 里的一环,交叉验证时它只会在训练折上 fit。第二种是手动在每一折内部做:先切出训练折,在训练折上 fit_resample,再训练,再在原始验证折上评估。第二种更透明,调试时我一般用手动版;跑批量实验时用 Pipeline 版。
from imblearn.pipeline import Pipeline as ImbPipeline from imblearn.over_sampling import BorderlineSMOTE from sklearn.preprocessing import StandardScaler from sklearn.impute import SimpleImputer from sklearn.model_selection import StratifiedKFold, cross_val_score import lightgbm as lgb pipe = ImbPipeline([ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler()), ("sampler", BorderlineSMOTE(random_state=42, k_neighbors=5)), ("clf", lgb.LGBMClassifier( n_estimators=800, learning_rate=0.05, num_leaves=31, class_weight=None, random_state=42)), ]) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(pipe, X, y, cv=cv, scoring="f1", n_jobs=-1) print(scores.mean(), scores.std())注意这里有个细节:SMOTE 之前必须先把缺失填完,因为多数实现不接受 NaN。而且标准化放在 SMOTE 之前,否则近邻距离会被量纲大的特征主导,合成出来的点全挤在某个维度上。
3.4 一个容易被忽略的点:采样之后阈值也要重调
过采样会改变模型的先验概率,输出概率的分布整体右移。这时候如果你按默认 0.5 切阈值,召回率会虚高、精确率会崩。正确做法是在验证集上扫一遍阈值,选 F1 最大的那个点,然后把这个阈值固定下来用到测试集上。这一步能带来一到两个点的提升,而且几乎零成本,属于必做项。
from sklearn.metrics import f1_score import numpy as np proba = pipe.predict_proba(X_valid)[:, 1] best_t, best_f1 = 0.5, 0 for t in np.arange(0.20, 0.81, 0.01): f1 = f1_score(y_valid, (proba >= t).astype(int)) if f1 > best_f1: best_t, best_f1 = t, f1 print(best_t, best_f1)注意:阈值只能在验证集上选,选完就固定,不要拿测试集反馈去反复微调阈值,那是另一种形式的过拟合。
4. baseline 训练:从能跑通到能上榜
4.1 特征工程先做最值钱的那几刀
我不建议一上来就堆几百个交叉特征,容易把 baseline 搞成一个没法调试的黑箱。我的顺序是:先只做清洗,跑一个纯净 baseline 拿到基准分;然后按重要性排序,逐个加特征,每次加完看线下 CV 变化。加特征的原则是“能解释得通”,如果一个特征你想不清楚它为什么有效,那它大概率只是拟合了噪声。
最值钱的几刀通常是这几类。第一类是比率型特征,比如“月还款额/月收入”,这类特征往往比原始的两个字段加起来还有效,因为它直接把业务的判断逻辑编进去了。第二类是分组聚合特征,比如某地区的人均收入、某类产品的平均单价,注意这类特征要防止泄漏——用训练集的统计量去映射验证集,或者干脆用留一法。第三类是时间差特征,如果有多个时间戳字段,两两相减经常能挖出东西。
还有一类是频次编码,把高基数类别列替换成出现次数。这个方法在类别特别多(几百上千种)的时候比独热编码好用得多,因为独热会把维度撑爆,而频次编码保留了“稀有程度”这个信息。
4.2 交叉验证与早停怎么配
分层 K 折是标配,5 折起步,数据量小的时候上 10 折。种子我一般固定三个(42、2024、7),跑三次看 CV 的波动范围。如果三次的标准差超过 0.01,说明模型不稳,这时候不要急着信 CV 分数,先把特征里的高噪声列清一清。
早停这块,LightGBM 用callbacks=[lgb.early_stopping(100)]配合验证集就行,但要注意验证集必须是从训练折里再切出来的,不能直接用 CV 的验证折,否则早停就看到了评估数据,分数同样虚高。
import lightgbm as lgb from sklearn.model_selection import StratifiedKFold import numpy as np params = dict( objective="binary", metric="auc", learning_rate=0.03, num_leaves=31, min_child_samples=50, feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1, lambda_l2=1.0, verbose=-1, seed=42, ) oof = np.zeros(len(X)) pred = np.zeros(len(X_test)) skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (tr, va) in enumerate(skf.split(X, y)): dtr = lgb.Dataset(X.iloc[tr], y.iloc[tr]) dva = lgb.Dataset(X.iloc[va], y.iloc[va]) model = lgb.train( params, dtr, num_boost_round=5000, valid_sets=[dva], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(0)], ) oof[va] = model.predict(X.iloc[va], num_iteration=model.best_iteration) pred += model.predict(X_test, num_iteration=model.best_iteration) / skf.n_splits print(f"fold {fold} best_iter={model.best_iteration}")这套写法有个好处:oof 是完整的折外预测,可以直接拿去算整体指标、扫阈值、做 stacking 的输入;pred 是测试集的平均预测,比单模型稳。
4.3 参数搜索的性价比判断
老实说,到 baseline 阶段,调参的边际收益很低。我的经验是:先把learning_rate降到 0.03 左右,num_leaves控制在 31–63,min_child_samples给到 50–100 防过拟合,剩下的用默认值先跑。这三四个参数定完,基本就吃到了调参能给的八成收益。
真要搜,用 Optuna 搜 50 次左右,搜索空间别开太大,num_leaves和min_child_samples两个参数配合着搜最有效。搜完记得用固定种子重跑一遍确认稳定,别被一次幸运的随机搜索骗了。
| 阶段 | 投入时间 | 期望收益 | 建议 |
|---|---|---|---|
| 数据清洗 + 基础特征 | 大头 | 高,常有 2–5 个点 | 优先做 |
| 采样策略 + 阈值 | 中等 | 中,1–2 个点 | 必做 |
| 特征工程迭代 | 中等 | 中高 | 按重要性逐个加 |
| 超参搜索 | 少 | 低,0.3–1 个点 | 最后做 |
| 模型融合 | 中等 | 中,0.5–1.5 个点 | 有余力再做 |
4.4 提交文件与可复现性
提交前我固定做三件事。第一,检查测试集预测的分布,正样本率跟训练集不能差太远,如果预测出来的正样本率只有训练集的十分之一,多半是某个特征在测试集上分布不一致,得回头查。第二,检查 ID 对齐,合并测试集特征时用 left join 并且校验行数不变。第三,把随机种子、特征列表、参数全部写进一个配置文件,保证下次跑出来的分数能对上。
血泪教训:有一次我改了一版特征,线下涨了 0.6,兴冲冲提交,线上掉了 0.4。回头查发现是合并测试集时用了 inner join,丢了三百多行,提交文件的行数和要求不一致,系统按缺失补了默认值。从那以后我每次提交前都跑一遍行数断言。
5. 常见问题与排查速查表
5.1 症状、原因、处置对照
| 症状 | 最可能的原因 | 处置办法 |
|---|---|---|
| 线下高、线上低很多 | 折内泄漏、SMOTE 用在全集、目标编码没做折内 | 全部塞进 Pipeline,逐项排查 |
| 训练集 AUC 0.99,验证集 0.7 | 特征里有 ID 类或时间戳泄漏 | 检查高重要性但业务上讲不通的列 |
| 换了采样方法分数没变化 | 模型没真正用到合成样本,或指标对不平衡不敏感 | 换 F1 或 PR-AUC 重新评估 |
| 预测正样本率异常低 | 阈值没调、采样后先验偏移 | 扫阈值,重新定切分点 |
| CV 三次结果波动大 | 样本量小、噪声特征多 | 上 10 折,清低重要性列 |
| 加特征后 CV 反而降 | 特征间共线严重,或引入了噪声 | 一次只加一组,做消融 |
| 提交后报格式错误 | 行数、列名、ID 顺序不对 | 写断言校验,对照样例文件 |
| 训练很慢 | 特征维度爆炸、类别列未编码 | 频次编码替代独热,降维 |
5.2 那些文档里不会写的坑
第一个坑:SMOTE 的 k_neighbors 不能大于少数类样本数。少数类只有 30 条的时候你设 k=5 没问题,但如果某折里少数类只剩 3 条,直接报错。稳妥做法是先判断少数类数量,少于 10 条就别采样了,改用 class_weight。
第二个坑:类别特征在 SMOTE 之后会变成小数。原版 SMOTE 对离散列也做插值,生成出“职业 = 2.37”这种东西。解决办法是用 SMOTENC,把类别列的索引传进去,它会按众数生成而不是插值。
第三个坑:填充顺序影响结果。如果你先做了分组聚合特征再填缺失,聚合结果里就混进了缺失值;如果先填再聚合,聚合结果被填充值污染。我的做法是:先构造缺失指示变量,再做填充,再做聚合,聚合时对填充值做标记排除。
第四个坑:验证集的填充统计量必须来自训练集。哪怕只是均值填充,也要用训练集的均值去填验证集,而不是验证集自己的均值。这个差异在小数据集上能到半个点。
第五个坑,也是最容易忽略的:线下评估的指标要跟赛题完全一致。赛题按宏平均 F1 评估,你就别拿微平均去调;赛题只算正类的 F1,你就别用 sklearn 默认的二分类平均。指标写错,后面所有优化方向都是歪的。
我自己在这类赛题上折腾下来,最大的感受是:越到后面,越要克制“加东西”的冲动。每次只改一个地方,改完立刻跑 CV,记下数字,涨了就留、跌了就撤。听起来笨,但这套笨办法让我避免了很多次“改了一堆、结果不知道是哪一步起了作用”的尴尬。真正稳定的提分,从来不是靠灵光一现的大改,而是靠十几次小步验证攒出来的。