ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

分类预测与集成学习落地:LightGBM、Stacking与数据泄漏避坑

分类预测与集成学习落地:LightGBM、Stacking与数据泄漏避坑

上周有个做运营的朋友甩给我一张表,两千多行、十几列,问能不能预测下个月哪些用户会流失。我说可以,但得先确认一件事:你想要的是一个能写进周报的准确率数字,还是一个能挂到后台上、每天自动吐出风险名单的东西。这两件事中间隔着的,就是分类预测和集成学习真正难的地方——模型本身不难调,难的是从标签口径、特征口径到验证切分、阈值落地,每一环都得扣住,一漏水,指标再漂亮也是假的。

这篇东西想聊的就是这套完整链路。我会从任务拆解讲起,说清楚分类模型到底在算什么、集成学习凭什么比单模型稳、LightGBM 和 Stacking 该怎么用,再把我这些年踩过的坑——数据穿越、目标编码泄漏、线下线上指标落差、类别不平衡乱上采样——一条条摆出来。不管你是刚跑完第一个train_test_split的新手,还是做过几个项目但总被业务方追问"这模型到底准不准"的同学,应该都能从里面拎走几段能直接抄的东西。

1. 先想清楚在做什么:分类预测的任务拆解与方案选型

1.1 分类模型的输出不是标签,而是概率加一个阈值

很多人对分类的第一印象就是"给我一个 0 或 1"。实际上绝大多数分类器吐出来的是概率,predict()只是拿predict_proba()的结果跟 0.5 比了一下。这个区别在业务里是致命的:风控每天只愿意人工复核 200 单,你却给他一个"预测为风险"的清单,数量对不上就是废的;运营的触达预算只够发 5% 的用户,模型说 30% 是流失高危,那也只能砍到 5%。所以真正可用的产出是"按风险从高到低排好序的名单 + 一个可调的切割线",而不是一个非黑即白的标签。

这也是为什么AUC 和 PR-AUC 这类排序指标比准确率重要。AUC 衡量的是"随机抽一个正样本和一个负样本,模型把正样本排前面的概率",它跟阈值无关,反映模型的排序能力天花板。而阈值是业务参数,可以上线后按人力预算、成本收益比去调,甚至可以按周滚动。我一般跟需求方沟通时会把这两件事分开讲:模型这边保证排序质量,业务那边定通过率,双方各自负责,扯皮少很多。

顺带说个常被忽略的点:概率校准。逻辑回归输出的概率天然比较可靠,树模型(尤其是随机森林和 GBDT)输出的分数经常挤在 0.3 到 0.7 这个区间,看着像概率其实不是。如果你的下游要用到"这个用户 80% 会流失"这样的绝对值做决策(比如算期望损失、算 ROI),那就得做校准,CalibratedClassifierCV加个isotonic或者sigmoid,几行代码的事,别省。

1.2 集成学习的三个流派,各自的收益来源不一样

单模型的天花板是有限的,一棵深树容易方差大、换个随机种子结果就抖;一棵浅树偏差大、学不动复杂关系。集成学习干的事情就是把这些不稳定的个体组合起来,用"集体决策"换稳定性。但组合方式不同,收益来源完全不同,搞混了就会用错场景。

Bagging 这一路(代表是随机森林):并行训练一堆模型,每个用自助采样出来的不同数据子集,再加上特征随机子采样,最后投票或平均。它的核心收益是降方差——单棵树对训练集里那点噪声特别敏感,几百棵树平均下来,噪声互相抵消,模型就稳了。所以数据量不大、特征维度中等、想要一个"开箱即用不用太调"的基线时,随机森林非常省心。

Boosting 这一路(代表是 GBDT、XGBoost、LightGBM、CatBoost):串行训练,每棵新树专门去拟合前面所有树的残差,也就是"专攻还没学好的那部分样本"。它的核心收益是降偏差,能把很复杂的关系一点点逼近出来。代价是参数敏感——学习率、树数量、叶子数、正则项,调不好就过拟合,而且对噪声标签比较敏感。按我这几年做表格数据的经验,GBDT 系列在结构化数据上仍然是很难被撼动的基线,这不是理论推导,是行业里反复验证过的普遍共识,深度学习在这些场景上想超过它得付出很大代价。

Stacking / Blending 这一路:拿几个已经训好的异质模型(比如逻辑回归 + 随机森林 + LightGBM),把它们的预测结果当成新特征,再训一个"元模型"去做最终判断。它的收益是利用模型之间的差异性——线性模型擅长抓单调的线性关系,树模型擅长抓交互和非线性,两者的错误往往不重叠,融合起来能互补。代价是训练成本高(要跑多折 OOF)、调参空间大、可解释性变差。

我做过一个粗略对比,同样的特征、同样的切分,在一个中等规模的用户流失任务上:逻辑回归 AUC 0.78,随机森林 0.82,LightGBM 调过参 0.845,三层 Stacking 0.849。看着 Stacking 只多了 0.004,但代价是三倍训练时间和一堆维护成本。所以我的判断标准很朴素——AUC 提升不到 0.005 就别上 Stacking,收益配不上复杂度。这条经验在不同项目里验证过几次,供参考。

1.3 工具选型的现实考量

选工具不是选最先进的,是选维护成本最低、团队能接手的。下面这张表是我自己常用的组合,也是踩过坑之后的结论。

工具我一般用它干什么需要提前知道的事
scikit-learn预处理、Pipeline、交叉验证、线性模型、随机森林原生不认类别特征,必须自己编码;版本更新偶尔改 API
LightGBM中大规模表格数据的主力模型小样本容易过拟合,参数要收着调,叶子数别贪大
XGBoost需要更强正则、稀疏特征多的场景默认参数偏保守,同样数据下训练比 LightGBM 慢一些
CatBoost类别特征多、不想手工编码的项目训练偏慢,但省掉大量特征工程,类别特征处理确实省心
Optuna超参搜索搜索空间的设计比搜索算法本身更影响结果
SHAP解释特征贡献、跟业务方对账树模型用 TreeExplainer 快,全量数据算会很慢,抽样即可

选型时还有个隐性成本容易被低估:团队里谁能改这个模型。你要是上个很少人用的框架,三个月后想加个特征都得翻半天文档,那不如老实用 LightGBM。工程上的可维护性,往往比那零点几个点的指标值钱。

2. 数据与评估:决定成败的八成工作量

2.1 拿到表格先做的五项体检

我自己有个固定的检查清单,不管什么项目,拿到数据先跑一遍,能挡掉后面一大半麻烦。

第一项是标签口径。这是最容易出大问题的地方。你说"预测流失",那流失的定义是什么?连续 30 天没登录算流失,还是 60 天?标签从哪个时间点开始算?观察窗口和表现窗口怎么切?我见过一个项目,标签用的是"未来 30 天是否下单",特征里却放了一个"最近 30 天订单数",两个窗口重叠,模型等于提前看到了答案,AUC 冲到 0.95,上线直接腰斩。所以我把特征分成"观察期特征"和"表现期标签",中间留一段缓冲期,这个习惯救过我很多次。

第二项是缺失值和异常值。先看缺失比例,超过 60% 的列直接考虑扔掉,除非业务上明确说它很关键。缺失值本身也有信息量,"这个字段是空的"往往意味着某种用户行为,所以我会额外造一个xxx_is_null的 0/1 特征,实测下来偶尔能贡献一两个点的提升。异常值先看分布,极端值如果不是录入错误就别急着删,树模型对异常值本来就不敏感。

第三项是重复与泄漏。同一个用户 ID 出现多次、正负标签矛盾、某个特征在正样本里全是同一个值——这些都是泄漏的味道。最土但最有效的办法是:把每个特征单独拿去跟标签算一下相关性或者 IV 值,如果某个特征单独就能到 AUC 0.9,那它八成有问题,赶紧查它的生成逻辑。

第四项是类别分布。正样本占比多少?如果只有 2%,那准确率这个指标完全没意义——全预测成负样本也有 98% 准确率。这时候就得换指标、换采样策略、换阈值。

第五项是样本代表性。训练集的时间跨度、用户群体,跟线上要预测的那批人是不是一回事?如果模型只用去年数据训,今年业务大改过一次,那得考虑加时间衰减权重或者干脆只用近半年数据。模型不是越老越香,越贴合当前分布越好。

2.2 特征工程里最容易翻车的两个地方

目标编码(Target Encoding)是表格数据里性价比很高的手段——用某个类别对应的正样本比例代替原来的类别名,既降维又保留了信息。但它也是泄漏重灾区。如果你在全集上算这个比例再切训练测试集,测试集的标签信息就漏进特征里了,线下指标虚高一大截。正确做法只有一个:在交叉验证的每一折内部,用训练折的数据算编码,应用在验证折上。sklearn 的TargetEncoder在新版本里已经内置了折内处理,或者你手写也行,核心就是"只准用训练折的信息"。这条我强调多少遍都不为过,见过太多人在这一步栽跟头。

时间穿越特征是另一个隐蔽的坑。比如"用户累计订单数",听着无害,但如果这个累计是把未来也算进去的,那模型就是在作弊。还有滑动窗口的统计量,要严格用shift()把当前时刻之后的数据排除掉。写这类特征我会习惯性地在 SQL 或 pandas 里加一行断言,比如"特征的计算截止时间必须早于标签开始时间",宁可多写几行校验,也别指望上线前能人工发现。

除了这两个坑,几个通用的经验:数值特征做标准化主要对线性模型和神经网络有用,树模型不需要;类别特征基数特别高(几千个取值)时别直接 One-Hot,用目标编码或者让 LightGBM、CatBoost 原生处理;交叉特征(比如"渠道 × 会员等级")交给 GBDT 自己去学,手工造的边际收益已经不高了,除非你有明确的业务假设。

2.3 指标怎么选、验证集怎么切,决定了结论可不可信

指标这块我给一张对照表,基本上按这张表选不会错:

场景主看指标辅助指标说明
正负样本均衡、成本对称Accuracy、F1AUC实际业务里几乎不存在这种理想情况
类别不平衡(正样本 <10%)PR-AUC、F1Recall@K别用 Accuracy,会骗人
排序为主、阈值后定ROC-AUC、KSPR-AUC风控、营销名单常用
需要概率绝对值LogLoss、Brier Score校准曲线下游要算期望值时必看
关注前 K 个命中率Precision@K、Lift@KRecall@K人力有限、只能处理 Top N 的场景

切分策略上,随机切分只在数据独立同分布时才成立。如果数据带时间属性,老老实实用时间切分——用前 8 个月训练,后 2 个月验证,这样得到的指标才接近线上真实表现。如果同一个用户有多条记录,用GroupKFold按用户分组切,否则同一个用户的样本同时出现在训练和验证里,模型等于背了答案。如果类别极不平衡,用StratifiedKFold保证每折的正样本比例一致。这三种切分方式我一般会根据数据形态选一种,很少用朴素随机切分。

还有一个细节:验证集一定要留着不碰。很多人习惯在验证集上反复试参数、试特征,试了几十轮之后,验证集实际上已经被"拟合"了,指标虚高。我的做法是切三份:训练、验证、测试,验证集用来调参和 early stopping,测试集只在最后看一次,看完就封存。测试集上的数字如果跟验证集差很多,说明过拟合验证集了,得回去精简。

3. 实操:从零跑通一条可复现的分类与集成流程

3.1 第一步:把预处理和模型焊死在一个 Pipeline 里

Pipeline 不只是为了代码好看,它是防止泄漏的物理隔离带。所有填充、编码、标准化都只在训练折上 fit,然后 transform 验证折,sklearn 自动帮你做这件事。你要是手动fit_transform全集再切分,那前面的功夫全白费。

import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, average_precision_score num_cols = ["age", "days_since_last_login", "order_cnt_30d", "avg_amount_90d"] cat_cols = ["city_level", "channel", "member_level"] num_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler()), ]) cat_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="constant", fill_value="missing")), # min_frequency 需要 sklearn 1.1+,把低频类别合并成 infrequent_sklearn ("onehot", OneHotEncoder(handle_unknown="ignore", min_frequency=50)), ]) preprocess = ColumnTransformer([ ("num", num_pipe, num_cols), ("cat", cat_pipe, cat_cols), ]) X_train, X_valid, y_train, y_valid = train_test_split( df[num_cols + cat_cols], df["label"], test_size=0.2, stratify=df["label"], random_state=42, )

这里两个参数值得说清楚。handle_unknown="ignore"保证了线上出现训练时没见过的类别值时不会报错,直接编码成全零,这是上线必须的。min_frequency=50是防止类别基数太高把特征维度撑爆,出现次数少于 50 的类别统一归到"其他",实测下来对效果几乎没影响,但内存和训练速度差别明显。如果你用的 sklearn 版本比较老没有这个参数,就自己在 pandas 里先做一次低频合并。

3.2 第二步:逻辑回归基线必须跑,别嫌它土

我知道很多人上来就想上 GBDT,但逻辑回归这个基线我是必跑的,原因有三个:一是它给了你一个下限参考,后面所有复杂模型都得跟它比,超不过就是白折腾;二是它的系数能看到方向和量级,业务方问"为什么这个人被判定为高风险"时,你能说得出话;三是它训练快到可以当特征筛选器用。

lr = Pipeline([ ("prep", preprocess), ("clf", LogisticRegression( C=1.0, penalty="l2", class_weight="balanced", max_iter=1000, solver="lbfgs", )), ]) lr.fit(X_train, y_train) proba_lr = lr.predict_proba(X_valid)[:, 1] print("LR AUC:", roc_auc_score(y_valid, proba_lr)) print("LR PR-AUC:", average_precision_score(y_valid, proba_lr))

C是正则强度的倒数,C越小正则越强,默认 1.0。如果特征之间共线性严重,可以往下调到 0.1 试试。class_weight="balanced"会让正样本的权重按照n_neg / n_pos放大,适合类别不平衡的场景,代价是输出的概率不再校准,如果你下游要用绝对值,就得关掉它单独做校准。这两个参数的取舍,本质上是在"排序能力"和"概率准确性"之间做选择,看你下游用哪个。

跑完记得看一眼系数,按绝对值排序,把前十个和前十个最负的拉出来,跟业务逻辑对一遍。如果某个特征的方向反了,比如"最近登录天数越多,流失概率越高",那大概率是特征本身构造错了,这种直觉校验比任何统计检验都来得快。

3.3 第三步:上 LightGBM,early stopping 是必须的

LightGBM 是我在表格数据上的主力。它的直方图算法把连续特征离散成桶,训练速度快;叶子生长策略是 leaf-wise,同样的叶子数下拟合能力比 level-wise 强;原生支持类别特征和缺失值,省了不少预处理。

import lightgbm as lgb # 注意:Pipeline 里直接塞 eval_set 会踩坑 # eval_set 里的数据不会走预处理器,特征维度跟训练数据对不上 # 所以 early stopping 场景我一般拆成两步:先 transform,再用原生 API prep = preprocess.fit(X_train, y_train) X_tr = prep.transform(X_train) X_va = prep.transform(X_valid) clf = lgb.LGBMClassifier( objective="binary", n_estimators=3000, learning_rate=0.03, num_leaves=31, min_child_samples=30, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=1.0, random_state=42, n_jobs=-1, ) clf.fit( X_tr, y_train, eval_set=[(X_va, y_valid)], eval_metric="auc", callbacks=[lgb.early_stopping(100, verbose=False), lgb.log_evaluation(200)], ) print("best_iteration:", clf.best_iteration_)

参数的选择逻辑我展开说下。learning_rate=0.03配n_estimators=3000是最常见的组合,小学习率需要更多树,但泛化通常更好;如果你算力紧张,0.05配1500也够用。num_leaves=31是 LightGBM 的默认值,经验上它应该小于2^max_depth,你不用设max_depth,光靠num_leaves控制复杂度就行。min_child_samples=30是防过拟合的第一道闸门——叶子节点至少要有 30 个样本才允许分裂,样本量小的时候这个值得往上调,5000 行以下的数据我会设到 50 甚至 100。

subsample=0.8配合subsample_freq=1是行采样(注意subsample_freq必须大于 0 才会生效,这个坑很多人踩过,设了subsample不设subsample_freq等于没开)。colsample_bytree=0.8是列采样,跟随机森林的思路一样,增加模型间的差异性。reg_alpha和reg_lambda分别是 L1 和 L2 正则,L2 我一般设 1.0 起步,L1 在特征特别多的时候才开,因为它会把不重要的特征权重压到 0。

early_stopping(100)的意思是验证集指标连续 100 轮没提升就停。这个值不能太小,太小容易停早了;也不能太大,太大浪费时间。100 是个比较稳的中间值,数据量特别大的时候可以加到 200。用 early stopping 之后,实际用到的树数量是best_iteration_,不是n_estimators,这一点在算推理耗时时记得换算。

3.4 第四步:Stacking 融合,OOF 是命门

Stacking 最容易写错的地方是自己手撸。正确流程是:对每个基模型,用 K 折交叉验证生成折外预测(Out-of-Fold),这 K 份 OOF 拼起来正好覆盖全部训练样本,作为元模型的特征;然后用全量训练数据重新训一遍基模型,对验证集做预测,喂给元模型。sklearn 的StackingClassifier已经把这一套封装好了,直接调就行,别自己写。

from sklearn.ensemble import RandomForestClassifier, StackingClassifier estimators = [ ("lr", LogisticRegression(max_iter=1000, class_weight="balanced")), ("rf", RandomForestClassifier( n_estimators=500, max_depth=12, min_samples_leaf=20, class_weight="balanced_subsample", n_jobs=-1, random_state=42, )), ("lgbm", lgb.LGBMClassifier( n_estimators=800, learning_rate=0.05, num_leaves=31, min_child_samples=30, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0, random_state=42, n_jobs=-1, )), ] stack = Pipeline([ ("prep", preprocess), ("clf", StackingClassifier( estimators=estimators, final_estimator=LogisticRegression(max_iter=1000), cv=5, stack_method="predict_proba", passthrough=False, n_jobs=-1, )), ]) stack.fit(X_train, y_train) proba_stack = stack.predict_proba(X_valid)[:, 1] print("Stack AUC:", roc_auc_score(y_valid, proba_stack))

几个关键决策点解释一下。cv=5是折数,5 折是精度和耗时的平衡点,样本量小于 2000 的时候可以上 10 折。stack_method="predict_proba"比"predict"好,因为它保留了模型的置信度信息,元模型能学到"这个样本三个基模型都不太确定"这类信号。final_estimator我一般用逻辑回归,因为它简单、不容易过拟合,而且系数能看出各基模型的贡献权重;用 GBDT 当元模型也行,但小数据上很容易把 OOF 记住。

passthrough=False表示不把原始特征也传给元模型。设成True的话元模型能同时看到原始特征和基模型预测,理论上拟合能力更强,但参数翻倍、过拟合风险明显上升。我一般保持False,除非训练样本特别多。

还有个实用的翻车提醒:Stacking 里如果某个基模型本身就有严重过拟合,它生成的 OOF 会"太准",元模型会过度依赖它,最后整体崩盘。所以上 Stacking 之前,每个基模型单独跑一遍验证集指标,把明显掉队的剔掉。融合不是垃圾桶,什么都能往里塞。

3.5 第五步:超参搜索与阈值落地

超参搜索这块,我强烈建议用 Optuna 而不是 GridSearchCV。GridSearch 是维度灾难,7 个参数各取 5 个值就是 7 万多种组合,跑不完。Optuna 用 TPE 做贝叶斯优化,同样的时间预算下能搜到更好的解。

import optuna from sklearn.model_selection import StratifiedKFold, cross_val_score def objective(trial): params = { "n_estimators": trial.suggest_int("n_estimators", 300, 2000, step=100), "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.2, log=True), "num_leaves": trial.suggest_int("num_leaves", 15, 127), "min_child_samples": trial.suggest_int("min_child_samples", 10, 100), "subsample": trial.suggest_float("subsample", 0.6, 1.0), "colsample_bytree": trial.suggest_float("colsample_bytree", 0.6, 1.0), "reg_lambda": trial.suggest_float("reg_lambda", 1e-3, 10.0, log=True), } model = lgb.LGBMClassifier( **params, subsample_freq=1, random_state=42, n_jobs=-1, ) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(model, X_tr, y_train, cv=cv, scoring="roc_auc", n_jobs=1) return scores.mean() study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=60, timeout=3600) print(study.best_params, study.best_value)

两个细节:learning_rate和reg_lambda用log=True做对数均匀采样,因为它们的有效区间是数量级的,线性采样会浪费大量预算在没意义的区域。n_jobs=1在训练循环里是故意的,因为 LightGBM 内部已经多线程了,外层再并行会抢 CPU,反而更慢。

阈值这块,两种定法看你业务特点。如果追求 F1 最大:

from sklearn.metrics import precision_recall_curve proba = stack.predict_proba(X_valid)[:, 1] prec, rec, thr = precision_recall_curve(y_valid, proba) f1 = 2 * prec * rec / np.clip(prec + rec, 1e-12, None) best_idx = int(np.argmax(f1[:-1])) # f1 比 thr 多一个元素,索引要对齐 best_thr = thr[best_idx] print("最佳阈值:", best_thr, "对应 F1:", f1[best_idx])

如果业务只关心"每天推多少人",直接用分位数:

top_rate = 0.05 # 每天只处理风险最高的 5% thr_by_rate = np.quantile(proba, 1 - top_rate)

第二种更贴近真实场景。我一般两个都算一遍,把"F1 最优阈值对应的通过率"和"业务能承受的通过率"放一起给需求方选,让他知道多覆盖 10% 的人群需要多花多少人力成本。这种把技术参数翻译成业务成本的做法,能让模型真正被用起来,而不是躺在 notebook 里。

4. 常见问题与排查技巧实录

4.1 线下 AUC 0.85,上线掉到 0.6

这是最高频的问题,没有之一。我按可能性从高到低排一个排查顺序。

第一位,数据泄漏。回去查特征的计算时间窗口,尤其是那些"累计""历史总数""最近 N 天"之类的字段,把每个特征的生成 SQL 或代码逻辑过一遍。常见的泄漏点:在全量数据上做了标准化或填充(应该只在训练集 fit)、目标编码在全集上算、标签的时间窗口和特征的时间窗口重叠。有个技巧:把模型特征按重要性排序,如果最重要的那个特征的业务含义说不通,八成就是它漏了。

第二位,训练和线上的特征口径不一致。训练时用的是离线数仓的数据,线上用的是实时接口,两边的空值处理、枚举值、单位可能都不一样,最隐蔽的是时间字段的时区。我一般会在上线前做一次"离线-线上一致性校验",取 1000 个样本,两边各算一遍特征,逐个字段比对差异率,超过 1% 就得查。

第三位,分布漂移。训练数据是三个月前的,现在的用户群体变了、产品改版了、渠道结构变了,模型自然失效。这个得靠监控——每周算一下线上特征的均值方差,跟训练集对比,偏移超过阈值的就报警。更直接的是监控预测分数的分布,如果线上平均分从 0.2 涨到 0.4,那模型已经在乱给了。

第四位,指标本身口径就不对。比如线下算的是predict_proba的 AUC,线上却用 0.5 阈值切完再算准确率,两者根本不是一回事。

4.2 类别不平衡:别急着上 SMOTE

正样本只有 2%,很多人第一反应是上 SMOTE 造样本。我劝你先想清楚三件事。

第一,你有没有真正的不平衡问题?不平衡本身不是问题,问题是"少数类太少了学不出规律"。如果正样本有 5000 条以上,即使占比 2%,模型照样能学好,你需要的只是换个指标(PR-AUC 而不是 Accuracy),调个阈值。这时候上采样纯属给自己找事。

第二,SMOTE 在表格数据上经常帮倒忙。它靠在近邻之间插值造新样本,但在高维空间里,"近邻"本身就没那么可靠,造出来的样本可能是噪声,还会破坏特征之间的相关结构。尤其是有大量类别特征的数据,SMOTE 根本不知道该怎么插值。实测下来,SMOTE 提升的案例远少于降级的案例。

第三,真正有效的顺序是这样的:先调阈值(零成本,最直接);再试class_weight="balanced"或 LightGBM 的scale_pos_weight(把少数类的损失权重放大,scale_pos_weight = n_neg / n_pos);还不行的话试欠采样(把多数类随机删一部分,成本是丢信息,但比造样本安全);最后才考虑 SMOTE 这类过采样,而且要配合imblearn的 Pipeline 保证只在训练折里采样。

我自己在多数项目里的做法是:不动数据分布,只调权重和阈值。这套组合拳能解决八成以上的不平衡场景,剩下的两成往往是数据本身的问题——正样本的定义太宽泛、或者正负标签的判定标准不一致,得回到数据源去解决,不是在算法层能救的。

4.3 模型结果每次跑都不一样,复现不了

这事的根源通常是随机性没控制住。检查清单如下:random_state在每个模型、每次切分、每个 CV 折里都设了吗?LightGBM 里bagging_seed、feature_fraction_seed这些子种子默认跟随random_state,但如果你手动设了subsample又不设subsample_freq,那就不生效。多线程训练时n_jobs大于 1 也可能导致浮点数累加顺序不同,产生微小差异,做最终复现实验时把n_jobs设成 1 会更稳。

另一个常被忽略的点是版本。requirements.txt里把scikit-learn、lightgbm锁死版本号,不然同事拉下来装个新版,同样的代码结果就不一样了。我吃过这个亏——某次升级后OneHotEncoder的默认行为变了,特征维度对不上,排查了大半天。

4.4 踩坑速查表

现象最可能的原因快速验证方法
线下指标好得离谱(AUC>0.95)数据泄漏逐个特征单独算 AUC,找异常高的
训练集分数高、验证集低很多过拟合加正则、减叶子数、加样本、做特征筛选
训练集和验证集分数都很低欠拟合或特征不足检查特征有效性、模型复杂度是否太低
不同随机种子结果差异大样本少或模型方差大多跑几个种子取平均,或换 Bagging 类模型
概率值集中在 0.3~0.7树模型输出未校准用 CalibratedClassifierCV 或换逻辑回归
重要特征全是"未来信息"时间穿越检查特征计算截止时间是否早于标签开始时间
Stacking 反而不如单模型元模型过拟合或基模型同质减少基模型数量,换简单元模型
线上预测全是一个值特征接口异常或全空打印线上输入特征的分布,跟训练集比对
训练特别慢特征维度太高或数据未压缩降到 float32、剔除低频类别、减少树数量
验证集指标波动大验证集太小增大验证集比例或改用交叉验证取均值

最后再分享一个我自己很依赖的习惯:每次建模都留一份"最小可复现脚本"。从原始数据读入到最终阈值输出,一个文件跑通,中间不依赖任何手工操作。这个脚本可能在项目结束后半年才被翻出来——业务方要复现某个名单,或者新人要接手——那时候你会庆幸当初多花了那半小时。模型本身会过时,这条链路不会。

我在实际项目里最大的感受是,分类预测这件事,模型选型占的权重真不高。真正决定成败的是那些不起眼的活:标签定义清了没、特征的时间窗口对不对、验证集切得合不合理、阈值有没有跟业务对齐。集成学习能帮你多榨出两三个点的 AUC,但救不了一个口径就错了的数据集。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进