做数据挖掘类竞赛的人大概都有这种体验前半程读题、看字段、翻数据字典信心满满真正卡住手的地方往往不是模型结构够不够花哨而是表格里那一片片空缺值以及严重偏斜的标签分布。这篇接着上一次的话题往下聊把缺失值处理、过采样和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, targetNone, topk30): rep pd.DataFrame({ dtype: df.dtypes.astype(str), missing_cnt: df.isna().sum(), missing_rate: df.isna().mean().round(4), nunique: df.nunique(dropnaTrue), }) rep rep[rep[missing_cnt] 0].sort_values(missing_rate, ascendingFalse) 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, targetlabel))拿到报告后分组填充这么写def group_median_fill(df, col, group_key, fallbackglobal): 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_keyregion_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(strategymedian)), (scaler, StandardScaler()), (sampler, BorderlineSMOTE(random_state42, k_neighbors5)), (clf, lgb.LGBMClassifier( n_estimators800, learning_rate0.05, num_leaves31, class_weightNone, random_state42)), ]) cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores cross_val_score(pipe, X, y, cvcv, scoringf1, 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( objectivebinary, metricauc, learning_rate0.03, num_leaves31, min_child_samples50, feature_fraction0.8, bagging_fraction0.8, bagging_freq1, lambda_l21.0, verbose-1, seed42, ) oof np.zeros(len(X)) pred np.zeros(len(X_test)) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) 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_round5000, valid_sets[dva], callbacks[lgb.early_stopping(100), lgb.log_evaluation(0)], ) oof[va] model.predict(X.iloc[va], num_iterationmodel.best_iteration) pred model.predict(X_test, num_iterationmodel.best_iteration) / skf.n_splits print(ffold {fold} best_iter{model.best_iteration})这套写法有个好处oof 是完整的折外预测可以直接拿去算整体指标、扫阈值、做 stacking 的输入pred 是测试集的平均预测比单模型稳。4.3 参数搜索的性价比判断老实说到 baseline 阶段调参的边际收益很低。我的经验是先把learning_rate降到 0.03 左右num_leaves控制在 31–63min_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 条的时候你设 k5 没问题但如果某折里少数类只剩 3 条直接报错。稳妥做法是先判断少数类数量少于 10 条就别采样了改用 class_weight。第二个坑类别特征在 SMOTE 之后会变成小数。原版 SMOTE 对离散列也做插值生成出“职业 2.37”这种东西。解决办法是用 SMOTENC把类别列的索引传进去它会按众数生成而不是插值。第三个坑填充顺序影响结果。如果你先做了分组聚合特征再填缺失聚合结果里就混进了缺失值如果先填再聚合聚合结果被填充值污染。我的做法是先构造缺失指示变量再做填充再做聚合聚合时对填充值做标记排除。第四个坑验证集的填充统计量必须来自训练集。哪怕只是均值填充也要用训练集的均值去填验证集而不是验证集自己的均值。这个差异在小数据集上能到半个点。第五个坑也是最容易忽略的线下评估的指标要跟赛题完全一致。赛题按宏平均 F1 评估你就别拿微平均去调赛题只算正类的 F1你就别用 sklearn 默认的二分类平均。指标写错后面所有优化方向都是歪的。我自己在这类赛题上折腾下来最大的感受是越到后面越要克制“加东西”的冲动。每次只改一个地方改完立刻跑 CV记下数字涨了就留、跌了就撤。听起来笨但这套笨办法让我避免了很多次“改了一堆、结果不知道是哪一步起了作用”的尴尬。真正稳定的提分从来不是靠灵光一现的大改而是靠十几次小步验证攒出来的。
阅读完成 · 觉得有帮助?