简介:南京大学《机器学习导论》第08章集成学习讲义是一份面向机器学习初学者的章节性入门资料,聚焦如何通过构建并结合多个学习器来提升泛化性能,解决单一模型精度有限、稳定性不足的问题。讲义从同质与异质集成的基本概念讲起,系统梳理了AdaBoost、GradientBoost等序列化方法,以及Bagging、随机森林等并行化方法;在此基础上,进一步介绍投票法、平均法、Stacking等结合策略,并讨论误差-分歧分解、多样性度量、数据样本扰动与输入属性扰动等关键机制,帮助读者理解“好而不同”的个体学习器如何带来更优整体性能,以及选择性集成的意义。资源包内为单份PDF文件,共14页,大小约840KB,内容精炼,适合课前预习、课后复习或快速搭建集成学习知识框架;目前已有128人学习,作为“机器学习导论”系列章节之一,与后续聚类、降维等内容搭配使用效果更佳。
1. 一份14页的集成学习讲义,为什么比很多厚书更值得动手
我拿到某高校机器学习导论课程的讲义,集成学习那一章只有14页。翻完才发现,整门课里对工程实战最有用的恰恰是这14页。集成学习的核心思想朴素得不像高级话题:单个模型会犯错,但让多个模型一起表决、加权、分层组合,错误就能被互相抵消,整体结果往往比最好的单模型还稳。这篇笔记顺着讲义里的 Bagging、Boosting、模型融合三条路线展开,把原理、可复制的 sklearn 代码,以及我实际踩过的坑一次性讲透。适合两类人:一类是想用一个下午把集成学习跑起来的新手;另一类是已经跑过单模型、总在"调参还是换模型"之间犹豫的从业者。
2. Bagging 与随机森林:装袋采样为什么能压住方差
2.1 偏差-方差分解:14页讲义最核心的一张图
如果只留一张图,那一定是偏差-方差分解。一个模型的泛化误差可以拆成三块:偏差的平方、方差、还有数据本身的噪声。偏差描述的是"平均意义下模型离真相有多远",方差描述的是"换一拨训练数据,模型的预测值会抖多厉害"。决策树这个基学习器的性格非常典型:树一深,拟合得狠,偏差小但方差大,稍微换点数据就换一套分裂规则;树一浅,稳定了但欠拟合,偏差压不下去。单个模型只能在两者之间找平衡,而 Bagging 提供了一条跳出平衡的路。
Bagging 的做法是对训练集做有放回抽样,训练出若干棵"略有差异"的树,再把它们的预测值平均。平均操作对偏差几乎没有修正作用,却能实打实地把方差压下来。这里有个容易被忽略的细节:为什么必须是有放回抽样?因为有放回才能让每棵树看到的数据分布略有不同,树之间才有差异;如果改成无放回,每棵树拿到的数据大同小异,模型几乎变成同一个,平均就没有意义了。
随机森林又往前走了一步。它不仅在样本维度上做 bootstrap,还在每次分裂时随机抽特征子集(max_features),进一步降低树与树之间的相关性。相关性越低,平均之后的方差压缩效果越明显。这也是为什么随机森林在默认参数下往往比单棵决策树强出一大截,却几乎不需要怎么调参的原因。
2.2 用 sklearn 跑一个随机森林分类器:最小命令与参数
我一般习惯先用模拟数据把流程跑通,再替换成真实数据,这样能排除数据清洗带来的干扰。下面这段代码是一个可以直接复现的最小分类流程:
import numpy as np from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score # 生成 2000 个样本、20 个特征的模拟分类数据 X, y = make_classification( n_samples=2000, n_features=20, n_informative=15, n_redundant=3, random_state=0 ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) clf = RandomForestClassifier( n_estimators=200, # 树的数量 max_depth=None, # 不限制深度,让树自由生长 max_features="sqrt", # 每次分裂随机抽 sqrt(n_features) 个特征 min_samples_leaf=2, # 叶节点至少 2 个样本,轻微正则 oob_score=True, # 计算袋外分数,相当于内置验证 n_jobs=-1, # 用满所有 CPU 核 random_state=0, ) clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print("test acc:", accuracy_score(y_test, y_pred)) print("oob score:", clf.oob_score_)这段代码的逻辑是:先用 make_classification 生成一份带有 15 个有效特征、3 个冗余特征的模拟数据,然后按 8:2 切分训练集和测试集。RandomForestClassifier 在 fit 阶段会完成 bootstrap 采样、并行建树、袋外预测统计三件事,fit 结束后 oob_score_ 已经算好了,不需要额外跑验证集。
参数里最值得关注的是 max_features。分类任务默认是"sqrt",也就是每次分裂只看特征总数的平方根个特征,这能有效降低树间相关性;回归任务里默认变成 1.0,即全部特征参与分裂。如果你的特征维度很高,比如上千维,可以试试把 max_features 调到 0.3 或 0.4,让每棵树更"专"一些。n_estimators 并不是越大越好,这一点在第 5 章避坑里会说。min_samples_leaf 是控制过拟合最直接的旋钮,数据噪声大时把它从 1 调到 5 或 10,效果往往比限制 max_depth 更明显。
2.3 讲义没写但工程里必会的三个 RF 细节
第一,oob_score 到底是什么。每棵树 bootstrap 抽样时,大约有 36.8% 的样本不会被抽到,这些样本被称为袋外样本。随机森林可以在建树过程中顺便用这些袋外样本做预测,汇总成一个袋外分数。它的价值在于:不需要单独切验证集,就能估计模型在未见数据上的表现。我通常在调参初期只用 oob_score 做快速筛选,等参数定下来再用独立的测试集做最终评估,省出一批样本。
第二,特征重要性是随机森林自带的附属品。训练完成后,feature_importances_ 属性会给出每个特征对不纯度下降的贡献占比,所有特征的重要性加起来等于 1。这是做特征粗筛最省事的方式,但要注意它对高基数数值特征有天然偏好,后面第 6 章我会说怎么正确使用它。
第三,随机森林对特征尺度和单调变换不敏感。你不需要提前做标准化、归一化,甚至把某个特征取对数后对树模型的影响也极小。这和 SVM、逻辑回归完全是两个思路。所以当一份数据里同时有连续值、离散值、缺失值时,随机森林往往是成本最低的 baseline。
3. Boosting:从 AdaBoost 到梯度提升树,串行修正误差
3.1 AdaBoost 的权重机制:为什么错题要反复做
Bagging 是并行地训练多个独立模型,Boosting 则是串行地训练一串模型,每个新模型都盯着上一个模型的错误。AdaBoost 是这条路线的经典起点,它的机制很像刷题:初始化时所有样本权重相同,训练一棵浅树;然后根据这棵树的错误结果,把分错样本的权重调大、分对样本的权重调小;下一轮训练时,模型被迫更关注那些"错题"。如此循环直到达到设定的迭代轮数。
AdaBoost 的基学习器在 sklearn 里默认是最大深度为 1 的决策树,也就是只生长一次的"树桩"。这个选择很讲究:树桩本身能力很弱,但正因为弱,每次只能从一个特征里找到一点规律,串联起来反而能逐步逼近复杂边界。如果你把基学习器换成很深的树,每轮拟合能力太强,AdaBoost 反而容易过拟合,而且训练时间大幅上升。
from sklearn.ensemble import AdaBoostClassifier from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import accuracy_score # 基学习器选最大深度 1 的树桩 stump = DecisionTreeClassifier(max_depth=1, random_state=0) ada = AdaBoostClassifier( estimator=stump, # 新版 sklearn 参数名是 estimator n_estimators=200, # 迭代轮数 learning_rate=0.8, # 每轮模型贡献的缩放系数 random_state=0, ) ada.fit(X_train, y_train) y_pred = ada.predict(X_test) print("adaboost acc:", accuracy_score(y_test, y_pred))这段代码里有一个容易踩的版本坑:sklearn 老版本里这个参数叫 base_estimator,从 1.2 版本开始改名为 estimator,直接把老代码拿到新环境会报 TypeError。不确定当前环境版本时,先跑一下 ada.get_params() 看一下参数名。
learning_rate 和 n_estimators 是一对配合使用的参数。learning_rate 越小,每棵树的贡献被压缩得越多,需要更多轮迭代才能达到同等效果。实践中常见做法是固定 n_estimators=500,learning_rate 从 0.1 开始调;如果训练时间紧张,就先调大 learning_rate 到 0.5 左右看趋势。另外,AdaBoost 对噪声很敏感,如果数据里标签错误率偏高,它的权重机制会把噪声样本的权重越推越高,模型逐渐被异常点带偏,这种场景下 Bagging 反而更稳。
3.2 梯度提升树:让下一棵树拟合残差
梯度提升树(GBDT)和 AdaBoost 的串行思路一致,但修正对象从"样本权重"换成了"残差"。每一轮新树去拟合上一轮预测值与真实值之间的差值,最终预测是所有树预测值的累加。这个思路更直接,也更容易扩展到回归任务。sklearn 里有两套实现:GradientBoostingClassifier 是经典版本,训练偏慢;HistGradientBoostingClassifier 是后来的工程优化版,用直方图算法加速分裂点搜索,还直接支持缺失值,速度通常快一个量级,我一般默认用后者。
from sklearn.ensemble import HistGradientBoostingClassifier from sklearn.metrics import accuracy_score hgb = HistGradientBoostingClassifier( max_iter=300, # 最大迭代树数 learning_rate=0.05, # 步长,越小越稳但需要更多树 max_leaf_nodes=31, # 每个棵树的叶节点上限 min_samples_leaf=20, # 叶节点最少样本数 l2_regularization=0.1, # L2 正则强度 early_stopping=True, # 开启早停 validation_fraction=0.1, # 从训练集切 10% 做验证 n_iter_no_change=20, # 连续 20 轮验证集分数不升则停 random_state=0, ) hgb.fit(X_train, y_train) y_pred = hgb.predict(X_test) print("hgb acc:", accuracy_score(y_test, y_pred))HistGradientBoostingClassifier 有几个参数需要理解到位。max_iter 等价于树的数量,它是加法模型的迭代轮数,不是传统意义上的"树有多少棵",因此它和 learning_rate 是强耦合的:你调小 learning_rate 后如果不增大 max_iter,模型会欠拟合。max_leaf_nodes 控制每棵树的复杂度,默认 31,经验上 20 到 50 都能用,数据集越大可以适当放宽。
min_samples_leaf 在这里比随机森林里的意义更大,因为它直接影响每棵树拟合残差时的平滑程度,太小容易让树记住离群点的残差。l2_regularization 是个容易被忽略的好参数,当数据噪声大、验证集分数总是不稳时,把它从 0 调到 0.1 或 0.5 往往比调 learning_rate 更有效。early_stopping 三件套是工程标配,后面避坑章节会专门讲它为什么不能省。
3.3 Bagging 和 Boosting 怎么选:一张选型表
新手最容易纠结的问题就是"到底用随机森林还是梯度提升树"。我的建议是先建一张选型表,再根据手头数据的实际情况对号入座:
| 维度 | Bagging / 随机森林 | Boosting / GBDT |
|---|---|---|
| 训练方式 | 并行建树,可充分利用多核 | 串行迭代,天然比 Bagging 慢 |
| 对噪声样本敏感度 | 低,个别异常点影响有限 | 高,权重或残差机制会放大异常 |
| 小样本表现 | 尚可,树间差异能对冲波动 | 容易过拟合,需要强正则 |
| 缺失值处理 | 需先填充或删除 | HistGradientBoosting 原生支持 |
| 默认参数效果 | 稳健,几乎不用调 | 依赖 learning_rate 和 max_iter 配合 |
| 效果上限 | 中上,很难突破 | 更高,尤其表格数据 |
| 训练时间 | 可并行,一般更快 | 慢,但 HGB 直方图实现接近 RF |
这张表的核心结论是:没有万能模型。常规做法是先用随机森林跑出一个 baseline,把数据清洗和特征工程的问题暴露出来,然后再训练一个 HistGradientBoosting 做对比。如果两者分数接近,优先用随机森林,因为它在工程上更好维护、更容易并行、也不容易因为数据噪声翻车;如果 HGB 明显领先,再投入时间调 Boosting 的参数。很多项目的最终方案,其实是这两种模型再融合出来的,这正是下一章的主题。
4. 把模型焊在一起:投票法与 Stacking 的最小可运行方案
4.1 硬投票和软投票在什么时候值得用
当你手里已经有两个或三个效果相近的模型,而且它们在结构上有明显差异时,投票法是最便宜的融合手段。硬投票是每个模型投一票,少数服从多数;软投票是先把每个模型的预测概率平均,再取概率最高的类别。软投票通常比硬投票更稳,但前提是每个模型的 predict_proba 输出在尺度上是可比的,否则一个模型概率普遍偏高会带偏平均结果。
一个容易忽略的前提是:参与投票的模型之间相关性越低,融合效果越好。如果三个模型本质上是同一类算法、只是随机种子不同,投票只是把方差又压了一遍,收益有限;如果一个是随机森林、一个是线性模型、一个是 KNN,它们看到的"数据结构"完全不同,投票才能真正互补。我在实际业务里见到最常见的用法是:逻辑回归兜底线性关系,随机森林捕捉非线性交互,KNN 补充局部结构,三者软投票,稳定性比任何一个单独用都好。
from sklearn.ensemble import VotingClassifier from sklearn.svm import SVC from sklearn.neighbors import KNeighborsClassifier rf_v = RandomForestClassifier(n_estimators=100, random_state=0) svm_v = SVC(probability=True, random_state=0) # 注意必须开启 probability knn_v = KNeighborsClassifier(n_neighbors=7) vote = VotingClassifier( estimators=[("rf", rf_v), ("svm", svm_v), ("knn", knn_v)], voting="soft", # soft 投票需要所有模型支持 predict_proba ) vote.fit(X_train, y_train) y_pred = vote.predict(X_test) print("voting acc:", accuracy_score(y_test, y_pred))这里有个细节要特别说明:SVC 默认不输出概率,必须显式设置 probability=True,而这个开关会在训练时引入额外的交叉验证来校准概率,训练时间会明显增加。如果你只是想做硬投票,建议把 SVC 换成 LinearSVC,硬投票不需要概率输出。投票法另一个常见误用是"把所有模型丢进去就完事",如果其中一个模型明显比其他模型弱很多,投票反而会把整体分数拉低,不如先砍掉弱模型再融合。
4.2 Stacking 的灵魂:第二层特征必须由交叉验证生成
投票法的问题在于"怎么结合"是人为拍板的,而 Stacking 把这个决策也交给模型去学。第一层是若干个基学习器,第二层是一个元模型,元模型的输入特征就是第一层各模型在样本上的预测输出。听起来简单,但这里藏着一个大部分新手都会踩的致命坑:不能把第一层模型在训练集上的预测值直接拼成第二层的输入。
原因很直白:第一层模型在训练集上的预测,是"见过答案之后"的输出,里面包含了大量标签信息。第二层拿到这种输入再去拟合标签,等于偷看答案,本地验证分数会虚高得吓人,但一到测试集就原形毕露。正确的做法是对第一层每个模型做 K 折交叉验证:每一折用其中 K-1 份训练、留 1 份预测,把留出部分的预测收集起来;K 折全部跑完后,拼成一个完整的元特征向量。这个元特征里每个样本的预测,都来自于一个没见过该样本训练过的模型,信息不泄露。
sklearn 的 StackingClassifier 把这一整套流程封装好了,你只要传 base 模型列表和一个元模型,再指定 cv 折数即可:
from sklearn.ensemble import StackingClassifier from sklearn.linear_model import LogisticRegression base_learners = [ ("rf", RandomForestClassifier(n_estimators=100, random_state=0)), ("svm", SVC(probability=True, random_state=0)), ("knn", KNeighborsClassifier(n_neighbors=7)), ] meta_clf = LogisticRegression(max_iter=1000) stack = StackingClassifier( estimators=base_learners, final_estimator=meta_clf, cv=5, # 第一层做 5 折交叉验证生成元特征 stack_method="predict_proba", passthrough=False, # 不把原始特征传给第二层 ) stack.fit(X_train, y_train) y_pred = stack.predict(X_test) print("stacking acc:", accuracy_score(y_test, y_pred))这段代码的逻辑是:训练阶段,第一层每个基学习器在训练集上做 5 折交叉验证,生成各模型的预测概率作为元特征;第二层的逻辑回归拿这些元特征学习"如何组合第一层模型"。推理阶段,测试集先经过每个基学习器预测出概率,拼成元特征,再交给逻辑回归输出最终类别。cv 越大,元特征质量越高但训练成本越高,通常 5 折是性价比平衡点。
4.3 第二层模型选什么:逻辑回归够用了
很多初学者习惯性在第二层也放一个复杂模型,比如再堆一个梯度提升树,这通常不是一个好选择。第二层的输入维度很低,就是基学习器数量乘以类别数,特征之间高度相关,而且第一层输出的概率本身带有噪声。在这个规模上用复杂模型,过拟合的风险远大于收益。常见做法是逻辑回归,或者带 L1/L2 正则的线性分类器。
另一个值得注意的参数是 passthrough。当它设为 True 时,原始特征也会拼到元特征里,让第二层同时看到原始信息和第一层输出。数据量小、基学习器数量少的时候,这个选项能补回一些信息损失,但数据量大时原始特征维度高,第二层反而容易被原始特征主导,融合效果变差。我的习惯是:特征维数低于 50 时设 True 试试,高维稀疏场景保持 False。
5. 集成学习避坑指南:5 个我实际踩过的坑
5.1 现象:树从 200 加到 2000 棵,分数纹丝不动
随机森林的 oob_score 从 200 棵树之后就稳住了,我一度以为是数据量不够,把 n_estimators 拉到 2000 重新训练,结果 test 分数没有任何变化,训练时间却从几分钟变成半小时。原因在于 Bagging 的方差下降曲线是指数衰减趋势,树的数量到达一定规模后进入平台期,继续加树只是在边际上降低理论方差,真实数据集上偏差和噪声才是短板。解决方法是先跑一组小规模对比:n_estimators 分别取 50、100、200、300,画出 oob_score 随树数量的变化曲线,找到饱和点后停在饱和点附近。我现在的习惯是分类任务默认 200 棵起步,分数不动就停,不盲目加树。如果你发现 500 棵树还在明显涨分,那大概率是数据方差极大,可以继续加,但这种情况比较少见。
5.2 现象:手写 Stacking 本地 CV 0.98,测试集直接掉到 0.75
这个坑我翻车过一次。当时手写 Stacking,图省事把第一层模型在训练集上的预测概率直接拼成元特征,逻辑回归在本地交叉验证里分数接近满分,当时还很兴奋,结果一上测试集立刻崩盘。原因是第一层的预测值带有训练集标签信息,第二层等于是"背答案"而不是"学规律"。解决方式就是第 4 章写的 K 折交叉验证生成元特征,而且测试集的处理也要完整走一遍:第一层每个模型在全部训练集上重新训练,再对测试集预测,这些预测概率拼好后喂给第二层。
5.3 现象:Boosting 训练集 loss 一直降,验证集先降后弹
这是 Boosting 类模型最经典的翻车姿势。训练集上的 log loss 持续下降,验证集先降后升,形成一条 U 型曲线。原因是加法模型每多一轮迭代,就多一次机会拟合训练集噪声,迭代次数超过某个阈值后,模型开始把噪声也当成规律。解决方式有两个:一是直接开 early_stopping=True,加上 validation_fraction 和 n_iter_no_change,让训练自己停下来;二是手动控制 max_iter,先用较小的值跑通流程,再逐步增大观察验证集分数变化。我一般两种方案都会做:手动跑一组小规模实验摸清大概的迭代量级,再开早停做最终训练。
5.4 现象:随机森林 n_jobs=-1 训练时内存直接翻倍
笔记本上有 8 个物理核,我图省事把 n_jobs=-1 拉满,结果训练一个中等规模的数据,内存占用直接翻倍,系统卡到几乎没法操作。原因是 sklearn 的并行机制默认会为每个 worker 准备一份数据副本,数据量大时多进程复制造成巨大的内存开销。解决方式是:数据量在几万级别、特征上百维时,n_jobs 开到一半核数就够;数据量更大时,要么改用 HistGradientBoosting,它的直方图算法对内存友好很多,要么使用支持增量训练的模型分块处理。
5.5 现象:准确率 95%,少数类却一个都没召回
有一份样本严重不平衡的数据,正负比例大约 10:1。随机森林测试准确率 95%,我差点直接上线,后来看混淆矩阵才发现,少数类的召回率是 0——模型把所有样本都判成了多数类。原因是 bagging 的 bootstrap 采样完全按原始分布进行,每棵树看到的类别比例同样是 10:1,模型自然倾向于多预测多数类。解决方式是给分类器加 class_weight="balanced",或者在随机森林里用 "balanced_subsample" 让每次 bootstrap 时做类别再平衡。评估指标也要从 accuracy 换成 precision、recall 和 AUC,只看准确率在类别不平衡场景下没有意义。
6. 让集成学习真正出效果的三个进阶习惯
6.1 把随机森林当特征筛选器,而不是最终模型
随机森林的 feature_importances_ 能快速给特征排序,但它基于不纯度下降,对数值型高基数特征有偏好。我的习惯是用它做粗筛:保留重要性排名前 20 到 50 的特征,再交给线性模型或梯度提升树训练。这样既利用了树模型的非线性筛选能力,又避免把随机森林当作唯一答案。某次项目里我直接沿用随机森林的全量特征训练 GBDT,效果反而不如先做一轮筛选,去掉无关特征后模型更稳定。
6.2 Boosting 手动早停:用 warm_start 控制迭代节奏
HistGradientBoostingClassifier 有内置早停,但不是所有场景都适用。如果你需要更灵活地控制训练过程,可以用 warm_start 来实现手动早停:每次 fit 会沿用上一轮已训练好的树,只新增一轮迭代,这样可以在循环里边训边验证:
from sklearn.ensemble import GradientBoostingClassifier gb = GradientBoostingClassifier( n_estimators=10, learning_rate=0.05, warm_start=True, random_state=0, ) best_score = 0.0 for i in range(10, 210, 10): gb.set_params(n_estimators=i) gb.fit(X_train, y_train) val_acc = accuracy_score(y_val, gb.predict(X_val)) if val_acc > best_score: best_score = val_acc else: print("early stop at", i, "trees") break这个技巧在内存充足、想精确控制最佳迭代次数时非常实用,本质上是把早停的判断权拿回自己手里。
6.3 模型分歧度:集成学习附赠的风险提示
集成学习除了提升准确率,还自带一个副产品:多个模型对同一条样本的预测分歧度。投票法里两个模型选 A、一个模型选 B 的样本,往往是业务里最需要人工复核的对象。我在几个项目里都验证过,这类样本要么是特征边界上的异常,要么是数据标注存在争议。不需要额外开发,只需要在预测阶段把 predict_proba 的方差或投票比分记录下来,作为样本置信度输出。
我自己的教训是:某次项目把梯度提升树当最终模型直接上线,没做特征筛选也没看预测分歧,结果模型在常规样本上表现稳定,一到边缘场景就静默出错。后来养成的习惯是,任何集成模型上线前都必须留两份产物:特征重要性排序和每个样本的预测概率。前者用于定位模型看什么,后者用于发现模型犹豫什么。希望帮到你。
本文还有配套的精品资源,点击获取