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_models=50, max_depth=None, seed=42): 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, replace=True, n_samples=len(X), random_state=rng) tree = DecisionTreeRegressor(max_depth=self.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], axis=0)实测效果很明显。同样的不剪枝决策树,单棵测试误差方差很大,平均之后曲线平滑很多。在这个流失数据集上,单棵满深度树的 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_estimators=50, learning_rate=0.1, max_depth=2): 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_depth=self.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 到 6,learning_rate 先设 0.1,用早停确定树数量的上限;观察训练集和验证集损失曲线:如果训练损失低、验证损失高,是方差问题,优先调整采样比例、特征采样比例、叶节点最小样本数;如果两边损失都高,说明欠拟合,把小学习率调大一点,或者放宽最大深度限制;最后用很小的学习率(0.02 或 0.01)配合早停跑一次完整训练,这时树数量可能上千,但泛化往往最好。对树数量我很少手动卡死,统一用早停来看验证集最优迭代轮数。这样写出来的实验流程可重复,也比盲目网格搜索稳定得多。参数主要控制偏差/方差方向建议阶段learning_rate每一步更新幅度越小整体越平滑,偏低方差第 1 轮n_estimators模型容量过多增大过拟合风险搭配早停max_depth单树复杂度越深越强拟合,偏高方差第 2 轮采样比例 / 特征采样比例数据与特征采样越小越降方差第 3 轮叶节点最小样本数叶节点最小容量越大越约束复杂第 4 轮6.3 实验日志:记录失败,才是调参最大的复利最后分享一个不是技术但很重要的习惯:每次实验都记录配置与结果,包括用的哪份数据切分、哪些特征、哪些预处理、模型参数、验证指标、以及最关键的一句话结论。上周我已经在笔记里过期了一版特征工程,靠日志定位到是某个编码步骤在验证时用了全局均值导致虚高。这种问题如果不记录,下次会以另一种形式再出现。调参这件事,真正值钱的不是"搜到最优参数",而是建立一条可复现、可追溯的迭代链。这周笔记先到这儿。下周我打算把 Stacking 往深再推一层,顺便整理采样策略。如果你也正卡在某个指标平台期,我的建议很简单:先别急着换模型,也别打开网格搜索,回到评估链路和特征工程这两步检查一遍——我这周大约一半的提升,都是这么挤出来的。
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_models=50, max_depth=None, seed=42): 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, replace=True, n_samples=len(X), random_state=rng) tree = DecisionTreeRegressor(max_depth=self.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], axis=0)实测效果很明显。同样的不剪枝决策树,单棵测试误差方差很大,平均之后曲线平滑很多。在这个流失数据集上,单棵满深度树的 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_estimators=50, learning_rate=0.1, max_depth=2): 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_depth=self.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 到 6,learning_rate 先设 0.1,用早停确定树数量的上限;观察训练集和验证集损失曲线:如果训练损失低、验证损失高,是方差问题,优先调整采样比例、特征采样比例、叶节点最小样本数;如果两边损失都高,说明欠拟合,把小学习率调大一点,或者放宽最大深度限制;最后用很小的学习率(0.02 或 0.01)配合早停跑一次完整训练,这时树数量可能上千,但泛化往往最好。对树数量我很少手动卡死,统一用早停来看验证集最优迭代轮数。这样写出来的实验流程可重复,也比盲目网格搜索稳定得多。参数主要控制偏差/方差方向建议阶段learning_rate每一步更新幅度越小整体越平滑,偏低方差第 1 轮n_estimators模型容量过多增大过拟合风险搭配早停max_depth单树复杂度越深越强拟合,偏高方差第 2 轮采样比例 / 特征采样比例数据与特征采样越小越降方差第 3 轮叶节点最小样本数叶节点最小容量越大越约束复杂第 4 轮6.3 实验日志:记录失败,才是调参最大的复利最后分享一个不是技术但很重要的习惯:每次实验都记录配置与结果,包括用的哪份数据切分、哪些特征、哪些预处理、模型参数、验证指标、以及最关键的一句话结论。上周我已经在笔记里过期了一版特征工程,靠日志定位到是某个编码步骤在验证时用了全局均值导致虚高。这种问题如果不记录,下次会以另一种形式再出现。调参这件事,真正值钱的不是"搜到最优参数",而是建立一条可复现、可追溯的迭代链。这周笔记先到这儿。下周我打算把 Stacking 往深再推一层,顺便整理采样策略。如果你也正卡在某个指标平台期,我的建议很简单:先别急着换模型,也别打开网格搜索,回到评估链路和特征工程这两步检查一遍——我这周大约一半的提升,都是这么挤出来的。