1. 这周的路线图单模型差一口气集成学习接棒每周机器学习笔记来到第二十八周笔记编号排到了二十九这周的主题很聚焦集成学习。起因其实是一个绕不过去的坎——我拿一份虚构的电商流失数据集做练习单棵决策树、逻辑回归、带正则的线性模型轮番上阵样本外 AUC 一直卡在 0.76 到 0.78 之间怎么换特征都顶不破。反复试了几天才意识到不是某个模型不够努力而是单个模型的能力结构天然有边界。这周的笔记就从这里开始把 Bagging、Gradient Boosting、模型评估、特征工程和调参逻辑串成一条线适合正处于会用接口但说不清原理阶段的读者也适合准备系统梳理评估和特征环节的同学。1.1 单模型的结构边界先说说我为什么在 0.78 附近卡了那么久。单棵决策树擅长捕捉非线性关系和特征交互但树叶一深就严重过拟合样本稍一扰动边界全变样。逻辑回归这类线性模型稳定性好可它表达不了复杂模式特征交互全靠手工构造。换更复杂的模型也不是万能解药在小表格数据上深度网络往往欠拟合数据量根本喂不饱那么多参数。问题不在某个模型不够努力而在单模型只能在一个维度上取舍——要么偏差偏高要么方差偏高很难同时压低。1.2 三种集成协作方式的定位这周把 Bagging、Boosting、Stacking 三种路线系统过了一遍先用一张表把各自的思路理顺集成方式协作关系缓解的问题典型代表Bagging并行投票或平均高方差模型的抖动随机森林Boosting串行聚焦上一轮错误高偏差模型的欠拟合GBDT 系列Stacking分层元模型学组合单模型信号不完整两层 Stacker我的理解是Bagging 像开多个独立的检验员开会大家独立判断最后取平均能压掉个体随机性Boosting 像一个老师反复批改作业每次重点盯上一轮做错的题Stacking 则是把不同风格的分析师聚在一起让一个领导模型学习如何综合他们的意见。三种思路没有谁绝对更好只有匹配不匹配。这一周为了把原理落到地上我准备的数据集规模不大2 万条样本、20 个特征正样本占比约 15%任务是预测用户是否会在下个月流失。数据本身带有明显的类别不平衡和部分缺失正好能覆盖评估指标、特征工程和调参三块内容。下面的笔记就按这条路线展开。2. 手推 Bagging 时想明白的一件事方差是被平均掉的2.1 有放回采样为什么是必须的先看最简单的问题如果只是训练多个模型然后平均为什么不直接把训练集切成若干份给每个模型答案是有放回采样带来的子集多样性是 Bagging 的核心。对 n 个样本做有放回抽样某个样本在一次抽样中不被抽到的概率是 (1 - 1/n)^n当 n 足够大时这个值趋近于 e 的负一次方约等于 0.368。也就是说每个自助采样集大概只包含原始样本中约 63.2% 的不同样本剩下的都是重复样本。这 63% 才是关键。每个子模型在不同的样本视角下训练结果会有差异平均之后差异中随机的那部分被抵消系统性模式被保留。如果每个模型都在完全相同的数据上训练平均多少个都没有意义——大家犯同样的错平均不会减少错误。这也是为什么我在实际构建随机森林时除了样本有放回还会额外做特征抽样让每棵树之间的相关性进一步下降。相关性越低平均的收益越大这是集成学习里最核心的一条直觉。2.2 一个能跑的最小实现为了确认自己的理解我手写了一个极简 Bagging 回归器核心逻辑不到二十行import numpy as np from sklearn.tree import DecisionTreeRegressor from sklearn.utils import resample class SimpleBagging: def __init__(self, n_models50, max_depthNone, seed42): self.n_models n_models self.max_depth max_depth self.seed seed self.models [] def fit(self, X, y): rng np.random.RandomState(self.seed) for i in range(self.n_models): X_boot, y_boot resample(X, y, replaceTrue, n_sampleslen(X), random_staterng) tree DecisionTreeRegressor(max_depthself.max_depth) tree.fit(X_boot, y_boot) self.models.append(tree) def predict(self, X): return np.mean([m.predict(X) for m in self.models], axis0)实测效果很明显。同样的不剪枝决策树单棵测试误差方差很大平均之后曲线平滑很多。在这个流失数据集上单棵满深度树的 AUC 波动范围可能在 0.62 到 0.72 之间而 50 棵树 Bagging 之后能稳定在 0.78 左右。这不是模型变强了而是波动被平均掉了这是理解集成学习的第一块基石。2.3 什么时候该想 BaggingBagging 适合的场景是基学习器方差大、偏差可接受最常见的就是高方差模型比如不剪枝的决策树或者深度较大的树。对于逻辑回归这类本身低方差的模型Bagging 帮助有限因为平均一个本身稳定模型和直接用原模型差别微乎其微。随机森林在 Bagging 之上又做了特征抽样效果往往更好但如果数据里本身噪声很大单纯增加树的数量并不能挽回特征质量低的问题。这一点在后面特征工程部分会详细说清楚。3. Gradient Boosting 的残差派对为什么拟合残差、学习率为什么是主角3.1 拟合残差本质上是拟合负梯度GBDT 的入门解释通常会说每一棵新树拟合上一轮的残差。这话在回归 MSE 损失下恰好成立但如果不理解背后的负梯度逻辑换成二分类就很容易绕晕。先看回归最简单的情况如果模型当前对第 i 个样本的预测是 F(x_i)真实值是 y_i那么残差 r_i y_i - F(x_i) 就是优化目标对预测值求导相反数的结果。MSE 损失 L (y - F)² / 2 对 F 求导得到 -(y - F)取负号方向就是残差。所以拟合残差其实是沿着负梯度方向更新在平方损失下的特例。到了交叉熵损失里残差不再是 y - F而是该点上的概率残差 y - p。很多教程直接沿用回归的说法导致初学者在做分类时对到底拟合什么犯迷糊。我的经验是心里永远记住梯度提升拟合的是损失函数在当前预测点上的负梯度残差只是平方损失时的缩写。想通这一点再看分类任务的伪残差、自定义损失函数思路都会顺很多。3.2 学习率和小步慢走第二个容易想不通的地方是既然残差是真实方向和距离为什么每棵树只往前走一小步这是 Boosting 最反直觉的设计。如果不加收缩第一棵树直接完全拟合残差后续树只能去拟合极其微弱的剩余信息整个序列立刻陷入过拟合。加入学习率 η 之后每一棵树迈的步伐变小后续树有机会补足不同方向上的细节相当于给训练过程增加了阻尼。小步慢走让最终的预测函数更平滑泛化能力显著提升代价是需要更多棵树。在实际调参里learning_rate 和 n_estimators 是强耦合的学习率越小需要的树越多。这也是为什么很多比赛方案里会见到上千棵树 0.01 学习率这种配置而不是50 棵树 0.3 学习率。真要把 GBDT 调好先放下那些自动化工具理解这一层再上手方向感会清晰很多。3.3 最小复现十几行代码看到 Boosting 的迭代过程我自己在理解阶段写过一段最简 GBDT只处理回归代码量不大import numpy as np from sklearn.tree import DecisionTreeRegressor class SimpleGBDT: def __init__(self, n_estimators50, learning_rate0.1, max_depth2): self.n_estimators n_estimators self.learning_rate learning_rate self.max_depth max_depth self.base_pred None self.trees [] def fit(self, X, y): self.base_pred y.mean() F np.full(len(y), self.base_pred) for _ in range(self.n_estimators): residual y - F tree DecisionTreeRegressor(max_depthself.max_depth) tree.fit(X, residual) self.trees.append(tree) F self.learning_rate * tree.predict(X) self.train_loss np.mean((y - F) ** 2) def predict(self, X): F np.full(len(X), self.base_pred) for tree in self.trees: F self.learning_rate * tree.predict(X) return F我把每轮的训练损失打印出来能看到一条典型曲线前 10 棵树损失下降很快之后每棵新树的边际贡献越来越小。如果 max_depth 设得太大损失甚至会在一开始飙升——因为第一棵树就去拟合所有残差后面全是无用功。这个实验比任何文档都直观。3.4 现代工具里被包装掉的概念差异落到常用梯度提升工具上框架普遍做了大量优化直方图近似、叶子分裂的并行化、正则项、原生类别支持等但核心依然是拟合负梯度 收缩。我用的几个库默认参数差异很大用的时候要特别留心有的默认学习率偏大有的默认对类别特征做特殊处理有的指标变化曲线在不同损失函数下含义完全不同。不要因为接口简单就跳过原理后面调参部分会看到不理解偏差方差来源调参就只是撞运气。4. 评估阶段踩过的三个坑泄露、误选指标与验证集划分4.1 数据泄露预处理必须在划分之后这周最大的翻车现场发生在数据预处理顺序上。我最初为了省事把标准化、缺失值填补都写在全量数据 fit 之后再做划分。测试集看起来指标很高但拿到新的线上数据后立刻打回原形。原因很直白标准化是在用测试集的均值和方差来缩放训练数据缺失值填充也可能把全量分布信息泄露给训练过程模型在见过答案的测试上当然表现好。正确做法是把预处理封装进管道或者严格遵循 fit 训练集、transform 训练集和测试集分离的流程。数据泄露是评估里最隐蔽的坑因为程序不报错指标反而更好看只有上真实场景才露馅。我当时写错的模式大概长这样# 错误先在全量数据上 fit再划分 scaler StandardScaler() X_scaled scaler.fit_transform(X) X_train, X_test, y_train, y_test train_test_split(X_scaled, y, ...) # 正确先划分再只用训练集 fit X_train, X_test, y_train, y_test train_test_split(X, y, ...) scaler StandardScaler().fit(X_train) X_train scaler.transform(X_train) X_test scaler.transform(X_test)特征选择也会踩同样的坑——如果先用全量数据算特征重要性并筛出一部分特征再去做交叉验证重要性分数本身就吸收了测试集信息。凡是涉及全局统计量的步骤都要放进训练集内做这一点值得反复检查。4.2 指标误选在 15% 正样本的数据上准确率会骗你这个流失数据正样本只占 15%如果只看准确率把所有用户预测成不流失都能拿到 85% 的分数模型却毫无实用价值。因为分类阈值默认 0.5 时模型在少数类上的查准率只有可怜的零点几营销预算全花在错判的用户上。我最后同时看了四类指标精确率、召回率、F1、AUC。AUC 告诉我们整体排序质量但它对类别不平衡不敏感更适合前期快速验证真正决定业务动作的是精确率和召回率之间的取舍。在流失预测场景里召回率优先级通常更高——漏掉一个真实流失用户的代价大于多安抚一个正常用户但也不能无限放大召回否则运营成本失控。对这类问题更推荐 PR 曲线而非 ROC 曲线正样本占比越低PR 曲线对分类器在少数类上的表现越敏感。我还养成了一个习惯每次实验都记录不同阈值下的精确率和召回率组合而不是只记默认阈值下的一个点。这样后期给业务方谈阈值怎么定时手里有完整曲线不是拍脑袋。4.3 验证集划分随机划分在时间序列场景会炸流失预测本质上是时间相关的用户行为特征随月份变化训练期的分布和决策期的分布天然不同。如果直接做随机 K 折同一用户在两个月内的行为可能同时出现在训练集和测试集模型记忆了用户级特征测试分数虚高。对这类数据既要做时间切分也要注意同一用户的分组——用时间序列切分方式按时间滚动验证才能模拟真实的用历史预测未来。即使没有严格时序的数据我也倾向于做多次重复的 K 折而不是单次划分。单次划分的结果波动很大在不稳定的数据上两个模型之间 0.01 的 AUC 差异根本说明不了优劣。用 KFold 重复五次并对结果取均值和标准差判断才靠得住。5. 特征工程组合拳从原始表格到能打的特征5.1 缺失值处理不是填空是编码缺失本身缺失值在真实表格里几乎必然存在。直接删除带缺失的行在样本充足时可以用但很多列是有业务含义的比如注册天数缺失可能对应未注册用户最近登录时间缺失可能意味着很久没来过。把这些缺失粗暴填成均值会抹掉重要信号我的做法是保留缺失指示列再根据不同列的分布决定填充值。对于数值列中位数比均值稳健尤其在有离群值的时候有时候按分箱填充或者按分组填充比如按用户等级填充中位数效果更好。这一步也可以交给树模型直接处理不少树实现天然支持训练时缺失值走最优分裂方向。但在本轮实验里手工构造的缺失标志列往往带来额外收益因为模型能明确学到缺失本身就是一种状态。5.2 Target Encoding 的正确姿势必须在折内做流失数据里有几个高基数的分类列比如城市码和渠道码直接 One-Hot 会引入几十上百个稀疏列训练缓慢且容易过拟合。这周试了 Target Encoding——用目标变量均值编码类别特征效果提升明显但方法安全性要求极高绝对不能用全量数据的 target 均值给本类别编码否则就是最典型的 target leakage。我的做法是在每一折交叉验证内部单独计算编码具体来讲把训练集分成五份对当前折的验证部分用其余四份计算基于 target 的类别均值配合平滑参数 m公式类似enc (count * group_mean m * global_mean) / (count m)m 控制置信度小类别因为 count 小会往全局均值收缩避免把极少数样本的偶然差异当作信号。测试集的编码则统一用训练集算好的映射表一类一类查过去。实现不复杂但漏一步就会让评估结果虚高到失真。5.3 交互特征和特征筛选加法之外还要乘法表格模型的一大优势是容易构造交互特征。比如单看注册天数和最近活跃天数分开都有用但两者比值——活跃占比——更直接反映用户黏性。我构造了一批比率特征和差值特征在 GBDT 类模型上有稳定提升因为树虽然能做隐式交互但深度的限制让它未必总能找到最优切分维度组合。特征多了之后筛选这关必须跟上。基于训练集的特征重要性对树模型很方便但有个毛病两个强相关特征会分摊贡献导致单个特征的重要性被低估。更稳的是排列重要性把某一列随机打乱观察验证集指标下降多少下降越多说明模型越依赖它。对于业务上想清楚了必须保留的列直接留着就行别纯粹按重要性筛到太干净——模型不怕冗余特征怕的是丢失信息。6. 调参路线从网格搜索迷信到偏差方差视角6.1 参数分层先管偏差再管方差很多新手调参喜欢上来就穷举网格搜索问题是参数空间维度高每个参数对模型行为的影响权重完全不同乱搜既慢又难解释。我倾向于从偏差方差两个角度先给参数排队。树模型有两条主线。一条是拟合能力线主要受学习率、树数量、最大深度影响学习率太低或树太少会欠拟合深度太小同样会欠拟合。另一条是稳健性线主要受子树采样比例、特征采样比例、叶节点最小样本数影响这些参数决定模型对局部噪声的敏感度。调参顺序上先把第一条线拉到接近过拟合的位置再回头用第二条线的正则手段压方差。6.2 一种我验证过比较顺手的调试顺序我在这份数据集上的实际顺序大概是这样固定最大深度为 4 到 6learning_rate 先设 0.1用早停确定树数量的上限观察训练集和验证集损失曲线如果训练损失低、验证损失高是方差问题优先调整采样比例、特征采样比例、叶节点最小样本数如果两边损失都高说明欠拟合把小学习率调大一点或者放宽最大深度限制最后用很小的学习率0.02 或 0.01配合早停跑一次完整训练这时树数量可能上千但泛化往往最好。对树数量我很少手动卡死统一用早停来看验证集最优迭代轮数。这样写出来的实验流程可重复也比盲目网格搜索稳定得多。参数主要控制偏差/方差方向建议阶段learning_rate每一步更新幅度越小整体越平滑偏低方差第 1 轮n_estimators模型容量过多增大过拟合风险搭配早停max_depth单树复杂度越深越强拟合偏高方差第 2 轮采样比例 / 特征采样比例数据与特征采样越小越降方差第 3 轮叶节点最小样本数叶节点最小容量越大越约束复杂第 4 轮6.3 实验日志记录失败才是调参最大的复利最后分享一个不是技术但很重要的习惯每次实验都记录配置与结果包括用的哪份数据切分、哪些特征、哪些预处理、模型参数、验证指标、以及最关键的一句话结论。上周我已经在笔记里过期了一版特征工程靠日志定位到是某个编码步骤在验证时用了全局均值导致虚高。这种问题如果不记录下次会以另一种形式再出现。调参这件事真正值钱的不是搜到最优参数而是建立一条可复现、可追溯的迭代链。这周笔记先到这儿。下周我打算把 Stacking 往深再推一层顺便整理采样策略。如果你也正卡在某个指标平台期我的建议很简单先别急着换模型也别打开网格搜索回到评估链路和特征工程这两步检查一遍——我这周大约一半的提升都是这么挤出来的。