集成方法:把弱学习器拧成强学习器 本节摘要:一群弱学习器,组合得当,就变成一个强学习器——这不是比喻,是一个定理。集成方法(Ensemble Methods)是表格数据上最可靠的技术:它横扫 Kaggle 表格赛,驱动着多数生产 ML 系统,并直接展示了偏差方差权衡在行动中如何运作。其核心机制只有三条:Bagging(自助聚合)通过在不同数据子集上训练多个模型再平均预测来降方差;Boosting(提升)通过顺序建模、每个新模型专攻集成当前的错误来降偏差;Stacking(堆叠)用一个元学习器学习「在哪种输入上该信哪个模型」。
本节摘要:一群弱学习器,组合得当,就变成一个强学习器——这不是比喻,是一个定理。集成方法(Ensemble Methods)是表格数据上最可靠的技术:它横扫 Kaggle 表格赛,驱动着多数生产 ML 系统,并直接展示了偏差方差权衡在行动中如何运作。其核心机制只有三条:Bagging(自助聚合)通过在不同数据子集上训练多个模型再平均预测来降方差;Boosting(提升)通过顺序建模、每个新模型专攻集成当前的错误来降偏差;Stacking(堆叠)用一个元学习器学习「在哪种输入上该信哪个模型」。本节将推导「多数投票为何会提升准确率」(关键要求是多样性),从零实现 AdaBoost 和梯度提升,讲透 XGBoost 为何在表格数据上常年压制神经网络,并给出「该用哪种集成」的决策表。
阅读完本节,你应当能够:
一棵单独的决策树训练快、好解释,但它过拟合。一个单独的线性模型在复杂边界上欠拟合。你可以花几天去雕琢完美的模型架构。或者,你可以把一堆不完美的模型组合起来,得到比任何一个都更好的东西。
集成方法做的就是这件事。它的核心直觉来自一个数学事实:假设你有 N 个相互独立的分类器,每个准确率 p > 0.5。多数投票的准确率是:
P(多数正确) = 对 k > N/2 求和 C(N,k) * p^k * (1-p)^(N-k)
21 个准确率各为 60% 的分类器,多数投票准确率约 74%。101 个分类器则升到 84%。当模型犯不同的错误时,错误相互抵消。
关键要求是多样性。如果所有模型犯同样的错,组合它们毫无帮助。集成方法之所以有效,是因为它们通过不同方式制造多样化:
💡 「集成」是赢得 Kaggle 表格赛最可靠的技术,也是多数生产 ML 系统背后的引擎。在表格数据上,梯度提升几乎总是强于神经网络。
Bagging 通过在训练数据的不同自助样本上训练每个模型来制造多样性。
自助样本是从原始数据有放回抽取的,大小与原始数据相同。每个自助样本里约 63.2% 是不重复的样本。剩下的 36.8%(袋外样本,out-of-bag)提供了一个免费的验证集。
Bagging 降方差而几乎不增偏差。每棵单独的树都过拟合自己的自助样本,但每个的过拟合方式不同,平均就把噪声抵消了。
随机森林(Random Forests) 是 Bagging 加一个额外花样:在每次分裂时,只考虑特征的随机子集。这强制树之间更加多样。候选特征数的典型取值是分类用 sqrt(n_features),回归用 n_features / 3。
Boosting 顺序训练模型。每个新模型聚焦于前一些模型搞错的样本。
Boosting 降偏差。每个新模型纠正集成到目前为止的系统性错误。最终预测是所有模型的加权和,更好的模型获得更高权重。
代价是:Boosting 跑太多轮会过拟合,因为它持续拟合更难的样本,其中有些是噪声。
AdaBoost(Adaptive Boosting,自适应提升)是第一个实用的提升算法。它能配合任何基学习器,通常用决策树桩(depth-1 的树)。算法如下:
1. 初始化样本权重:对所有 i,w_i = 1/N 2. 对 t = 1 到 T: a. 在带权重数据上训练弱学习器 h_t b. 计算加权错误率: err_t = sum(w_i * I(h_t(x_i) != y_i)) / sum(w_i) c. 计算模型权重: alpha_t = 0.5 * ln((1 - err_t) / err_t) d. 更新样本权重: w_i = w_i * exp(-alpha_t * y_i * h_t(x_i)) e. 归一化权重使其和为 1 3. 最终预测:H(x) = sign(sum(alpha_t * h_t(x)))
错误率低的模型得到更高的 alpha。被错分的样本得到更高权重,于是下一个模型聚焦它们。
梯度提升把提升推广到任意损失函数。它不再重加权样本,而是让每个新模型去拟合当前集成的残差(损失的负梯度)。
1. 初始化:F_0(x) = argmin_c sum(L(y_i, c)) 2. 对 t = 1 到 T: a. 计算伪残差: r_i = -dL(y_i, F_{t-1}(x_i)) / dF_{t-1}(x_i) b. 用树 h_t 拟合残差 r_i c. 找最优步长: gamma_t = argmin_gamma sum(L(y_i, F_{t-1}(x_i) + gamma * h_t(x_i))) d. 更新: F_t(x) = F_{t-1}(x) + learning_rate * gamma_t * h_t(x) 3. 最终预测:F_T(x)
对平方误差损失,伪残差就是真实残差:r_i = y_i - F_{t-1}(x_i)。每棵树字面意义上在拟合前一个集成的错误。
学习率(收缩,shrinkage)控制每棵树的贡献。更小的学习率需要更多树但泛化更好。典型取值:0.01 到 0.3。
XGBoost(eXtreme Gradient Boosting,极端梯度提升)是梯度提升加一系列工程优化,使它又快又准又抗过拟合:
对表格数据,XGBoost(及其后继 LightGBM)持续压制神经网络。这种局面短期内不会变。如果你的数据能装进一张行列分明的表里,先从梯度提升开始。
Stacking 用多个基模型的预测作为元学习器的特征。
元学习器学习「在哪种输入上该信哪个基模型」。如果随机森林在某些区域更好、SVM 在另一些区域更好,元学习器会学会相应地路由。
为避免数据泄漏,基模型的预测必须通过在训练集上交叉验证生成。永远不要在同一份数据上既训练基模型又生成元特征。
最简单的集成。直接组合预测。
| 方法 | 降低 | 最适合 | 当心 |
|---|---|---|---|
| Bagging / 随机森林 | 方差 | 噪声数据,多特征 | 对偏差无帮助 |
| AdaBoost | 偏差 | 干净数据,简单基学习器 | 对离群点和噪声敏感 |
| 梯度提升 | 偏差 | 表格数据,竞赛 | 训练慢,不调参易过拟合 |
| XGBoost / LightGBM | 两者 | 生产表格 ML | 超参数多 |
| Stacking | 两者 | 抠最后 1~2% 准确率 | 复杂,元学习器有过拟合风险 |
| 投票 | 方差 | 快速组合多样模型 | 只在模型多样时有用 |
对多数表格预测问题,尝试顺序是:
n_estimators、learning_rate、max_depth、min_child_weight。表格数据上的神经网络几乎总是不如梯度提升,尽管研究持续在尝试。TabNet、NODE 等架构偶尔能持平,但很少击败调好的 XGBoost。
code/ensembles.py 从零实现全部内容。逐步来看。
class DecisionStump: def __init__(self): self.feature_idx = None self.threshold = None self.polarity = 1 self.alpha = None def fit(self, X, y, weights): n_samples, n_features = X.shape best_error = float("inf") for f in range(n_features): thresholds = np.unique(X[:, f]) for thresh in thresholds: for polarity in [1, -1]: pred = np.ones(n_samples) pred[polarity * X[:, f] < polarity * thresh] = -1 error = np.sum(weights[pred != y]) if error < best_error: best_error = error self.feature_idx = f self.threshold = thresh self.polarity = polarity def predict(self, X): n = X.shape[0] pred = np.ones(n) idx = self.polarity * X[:, self.feature_idx] < self.polarity * self.threshold pred[idx] = -1 return pred
class AdaBoostScratch: def __init__(self, n_estimators=50): self.n_estimators = n_estimators self.stumps = [] self.alphas = [] def fit(self, X, y): n = X.shape[0] weights = np.full(n, 1 / n) for _ in range(self.n_estimators): stump = DecisionStump() stump.fit(X, y, weights) pred = stump.predict(X) err = np.sum(weights[pred != y]) err = np.clip(err, 1e-10, 1 - 1e-10) alpha = 0.5 * np.log((1 - err) / err) weights *= np.exp(-alpha * y * pred) weights /= weights.sum() stump.alpha = alpha self.stumps.append(stump) self.alphas.append(alpha) def predict(self, X): total = sum(a * s.predict(X) for a, s in zip(self.alphas, self.stumps)) return np.sign(total)
class GradientBoostingScratch: def __init__(self, n_estimators=100, learning_rate=0.1, max_depth=3): self.n_estimators = n_estimators self.lr = learning_rate self.max_depth = max_depth self.trees = [] self.initial_pred = None def fit(self, X, y): self.initial_pred = np.mean(y) current_pred = np.full(len(y), self.initial_pred) for _ in range(self.n_estimators): residuals = y - current_pred tree = SimpleRegressionTree(max_depth=self.max_depth) tree.fit(X, residuals) update = tree.predict(X) current_pred += self.lr * update self.trees.append(tree) def predict(self, X): pred = np.full(X.shape[0], self.initial_pred) for tree in self.trees: pred += self.lr * tree.predict(X) return pred
代码验证从零实现与 sklearn 的 AdaBoostClassifier、GradientBoostingClassifier 给出相近准确率,并把所有方法放一起对比。
from sklearn.ensemble import ( BaggingClassifier, RandomForestClassifier, AdaBoostClassifier, GradientBoostingClassifier, VotingClassifier, StackingClassifier, ) # 随机森林(Bagging + 随机特征子采样) rf = RandomForestClassifier(n_estimators=100, max_features="sqrt", random_state=42) rf.fit(X_train, y_train) # AdaBoost ada = AdaBoostClassifier(n_estimators=50, learning_rate=1.0, random_state=42) ada.fit(X_train, y_train) # 梯度提升 gb = GradientBoostingClassifier(n_estimators=100, learning_rate=0.1, max_depth=3) gb.fit(X_train, y_train)
from sklearn.ensemble import VotingClassifier from sklearn.linear_model import LogisticRegression from sklearn.svm import SVC voting = VotingClassifier( estimators=[ ("lr", LogisticRegression()), ("rf", RandomForestClassifier()), ("svm", SVC(probability=True)), ], voting="soft", # 用概率平均,通常优于 "hard" ) voting.fit(X_train, y_train)
from sklearn.ensemble import StackingClassifier stack = StackingClassifier( estimators=[ ("lr", LogisticRegression()), ("rf", RandomForestClassifier()), ("knn", KNeighborsClassifier()), ], final_estimator=LogisticRegression(), # 元学习器 cv=5, # 内部交叉验证防泄漏 ) stack.fit(X_train, y_train)
StackingClassifier 内部用交叉验证生成基模型预测,避免在同一份数据上既训练基模型又训练元学习器导致的数据泄漏。
| 维度 | 手写实现 | scikit-learn |
|---|---|---|
| 控制粒度 | 可逐步跟踪权重、残差 | 封装好,默认行为合理 |
| 速度 | 纯 Python,慢 | C 实现,快 |
| 适用 | 理解原理 | 生产环境 |
💡 实战中 XGBoost/LightGBM 已成表格数据事实标准,sklearn 的
GradientBoostingClassifier仍是很好的学习起点和基线。
本节产出 outputs/prompt-ensemble-selector.md——一个帮你为给定数据集选对集成方法的提示词。喂给它你的数据描述(规模、特征类型、噪声水平、类别平衡)和你解决的问题,它会走一遍决策清单、推荐方法、给出起始超参数,并警告该方法常见的坑。另产出 outputs/skill-ensemble-builder.md,含完整的选择指南。
Python 代码(code/ensembles.py)是独立的从零实现,可与 sklearn 直接对照验证。
跟踪 AdaBoost 训练曲线:修改 AdaBoost 实现,记录每轮训练后的准确率,画出准确率随估计器数的变化曲线。它何时收敛?
从零实现随机森林:在回归树里加入随机特征子采样,训练 100 棵树(max_features=sqrt(n_features)),平均预测。对比单棵树,展示方差下降。
梯度提升加早停:在梯度提升实现里加早停,每轮跟踪验证损失,连续 10 轮无改善就停。它实际需要多少棵树?
搭 Stacking:用三个基模型(逻辑回归、决策树、k 近邻)和一个逻辑回归元学习器搭 Stacking,用 5 折交叉验证生成元特征,对比每个基模型单独的表现。
对比 XGBoost:在同一数据集上跑默认参数的 XGBoost,对比从零梯度提升的准确率和耗时,差距有多大?
sqrt(分类)或 n/3(回归)个特征,进一步增加树间多样性。下一节,我们讲超参数调优——网格搜索、随机搜索、贝叶斯优化如何用更少算力找到更好的超参数组合。