☰
表格分类赛后半程:缺失值处理、过采样与baseline训练
2026/10/27 2:36:02 网站建设 项目流程

做数据挖掘类竞赛的人大概都有这种体验:前半程读题、看字段、翻数据字典,信心满满;真正卡住手的地方,往往不是模型结构够不够花哨,而是表格里那一片片空缺值,以及严重偏斜的标签分布。这篇接着上一次的话题往下聊,把缺失值处理、过采样和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,记下数字,涨了就留、跌了就撤。听起来笨,但这套笨办法让我避免了很多次“改了一堆、结果不知道是哪一步起了作用”的尴尬。真正稳定的提分,从来不是靠灵光一现的大改,而是靠十几次小步验证攒出来的。

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

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

立即咨询