多棵不稳定的树为何能更可靠?随机森林的 Bagging、特征子采样与 OOB 估计
从单棵树的高方差出发,手算自助采样、概率平均与袋外预测,解释特征子采样如何降低相关性,并实现可诊断的 sklearn 随机森林。
上一篇构造了一棵决策树:每个节点贪心选择最能降低不纯度的特征与阈值。它能表达非线性规则、推理很快,但训练数据只改动几个样本,根节点就可能换掉,后续规则也随之重排。
这是一类典型的高方差(High Variance)学习器。把树剪得更浅可以降低方差,却也可能丢掉真实的细粒度规律。有没有办法保留许多较深的树,同时让最终预测不那么依赖某一份训练样本?
随机森林(Random Forest)的答案是:制造许多“各不相同但还算有用”的树,再平均它们的预测。 本文只聚焦三件紧密相连的事:自助采样怎样改变每棵树的数据、特征子采样怎样降低树之间的相关性,以及袋外样本怎样提供无需额外切分的诊断信号。
01 随机森林不是“把同一棵树复制很多次”#
如果 棵树看到完全相同的数据和特征,并用相同算法训练,它们通常会得到相同规则;复制再多次,投票也没有新增信息。
随机森林给每棵树注入两类随机性:
- 自助采样(Bootstrap Sampling):每棵树从 个训练样本中有放回地抽取 次;有些样本重复,有些没有被抽中。
- 特征子采样(Feature Subsampling):每个节点只随机查看部分特征,再从其中选择最佳分裂。
完整数据流是:
X_train [N,D] + y_train [N]
│
├─ bootstrap #1 + 每节点随机特征 ─► Tree 1
├─ bootstrap #2 + 每节点随机特征 ─► Tree 2
├─ bootstrap #3 + 每节点随机特征 ─► Tree 3
│ ...
└─ bootstrap #M + 每节点随机特征 ─► Tree M
X_query [Q,D]
│
├─► proba_1 [Q,C] ─┐
├─► proba_2 [Q,C] ─┼─► 按树平均 ─► forest_proba [Q,C]
└─► proba_M [Q,C] ─┘ │ argmax
▼
prediction [Q]text每棵树仍执行上一篇的不纯度下降搜索;森林改变的是训练样本与每个节点可见的候选特征,最后再聚合预测。
02 自助采样怎样制造不同训练集?#
假设训练集只有 4 个样本,编号为 [1,2,3,4]。每棵树有放回抽 4 次:
| 树 | 抽到的样本编号 | 重复样本 | 未抽中的样本 |
|---|---|---|---|
| Tree 1 | [1,1,2,4] | 1 | 3 |
| Tree 2 | [2,3,3,4] | 3 | 1 |
| Tree 3 | [1,2,2,3] | 2 | 4 |
同一编号可以出现多次,因为每次抽取后都放回。每棵树看到的经验分布不同,于是容易在不同位置分裂。
对某个固定样本,一次抽取没有选中它的概率是 ;连续抽 次仍未选中的概率为:
因此,大样本下每棵树平均约有 36.8% 的训练样本没有被抽中。这些样本叫袋外样本(Out-of-Bag Samples,OOB);被抽中的不同样本比例约为 63.2%,不是说树只执行了 次抽取——重复项仍占据训练权重。
03 多棵树究竟怎样投票?#
分类森林通常平均成员树的类别概率,再取最大值。假设三个树对同一查询点给出正类概率:
Tree 1: P(y=1|x) = 1.0
Tree 2: P(y=1|x) = 0.0
Tree 3: P(y=1|x) = 1.0text森林结果为:
阈值为 0.5 时预测正类。真实树的叶节点未必纯净,例如三个概率也可能是 [0.8,0.4,0.7],森林平均为约 0.633。
对 类分类:
- :树的数量;
- :第 棵树给类别 的叶节点比例;
- :森林平均后的类别概率。
平均能削弱某一棵树因噪声产生的极端预测,但不能自动保证概率校准。所有树共享同一训练来源且叶节点很小时,0.9 仍未必对应真实事件 90% 的发生率。
04 为什么平均能降低方差?#
先把每棵树对某个目标的预测看成方差为 的随机变量。若树之间彼此独立, 棵树均值的方差是:
但真实森林中的树共享数据,预测相关。若任意两棵树的相关系数近似为 :
这个式子揭示两个不同旋钮:
- 增大 会缩小第二项,但无法消除由相关性留下的 ;
- 降低树之间的相关性 ,才能继续压低共同波动。
自助采样改变每棵树的数据;特征子采样进一步迫使树寻找不同规则。它们不是为了让单棵树更准确,而是在“成员树足够强”和“成员之间不要太相似”之间做折中。
05 特征子采样为何必须发生在每个节点?#
假设有 100 个特征,其中一个强特征几乎总能带来最大不纯度下降。若每棵树的每个节点都查看全部特征,许多树会在根节点选择同一特征,随后结构高度相似;Bootstrap 带来的差异可能不够。
分类森林常在每个节点随机查看约 个特征:
Tree 1 根节点候选:{x₂, x₇, x₉} ─► 选 x₇
Tree 2 根节点候选:{x₁, x₄, x₈} ─► 选 x₁
Tree 3 根节点候选:{x₃, x₇, x₁₀} ─► 选 x₃text关键是“每个节点重新抽候选特征”,不是每棵树只抽一次固定列子集。子树的不同节点仍可能使用全部 个特征中的不同部分。
max_features | 单节点候选数 | 常见影响 |
|---|---|---|
None | 单树较强,但树更相似 | |
'sqrt' | 分类森林常用折中 | |
| 很小的整数或比例 | 更少 | 相关性下降,但节点可能错过有效特征 |
交互判断:max_features 越小,森林一定越好吗?
不一定。它可能降低树间相关性,却也让每棵树更弱。如果大多数节点看不到任何有用特征,偏差会明显上升。应在开发数据上结合准确性、方差和成本验证。
06 用四个样本手算 OOB 预测#
回到前三棵树的抽样结果:
Tree 1 没见过样本 3
Tree 2 没见过样本 1
Tree 3 没见过样本 4text袋外估计(OOB Estimate)只让“训练时没见过该样本”的树为它预测:
| 样本 | 可参与 OOB 预测的树 | 正类概率 |
|---|---|---|
| 1 | Tree 2 | 0.2 |
| 3 | Tree 1 | 0.8 |
| 4 | Tree 3 | 0.6 |
| 2 | 本例没有 | NaN / 不可估计 |
若样本 1 的真实标签是 0,OOB 概率 0.2 对应预测正确;样本 3 的真实标签是 1,概率 0.8 也正确。样本 2 在这个极小森林中被每棵树抽到,无法得到 OOB 预测。
树数量足够多时,大部分样本都会被若干棵树留在袋外。OOB 分数可以作为快速诊断,尤其在数据宝贵、不想额外划出验证集时有用。
但 OOB 不是测试集:你若反复根据 OOB 结果选择特征、超参数和数据清洗规则,它就参与了模型开发。最终泛化结论仍应由封存测试集给出;时间序列、分组数据等不满足普通 Bootstrap 结构的任务也不能机械依赖 OOB。
07 Bagging 训练的伪代码#
input: X [N,D], y [N], number of trees M
for m in 1 ... M:
bootstrap_indices = sample N indices with replacement
X_m, y_m = X[bootstrap_indices], y[bootstrap_indices]
grow a decision tree:
at every node:
randomly choose max_features candidate columns
among those columns, select the best impurity split
for query batch X_query [Q,D]:
probabilities = stack(
tree_m.predict_proba(X_query) for m in 1 ... M
) # [M,Q,C]
forest_proba = mean(probabilities, axis=0) # [Q,C]
prediction = argmax(forest_proba, axis=1) # [Q]text训练阶段每棵树可以相对独立地构建,因此易于并行;推理时也可以分树并行,但最终必须归并所有成员输出。
08 用当前 scikit-learn API 落地#
截至本文写作时,scikit-learn 1.9 的 RandomForestClassifier 默认 n_estimators=100、max_features='sqrt'、bootstrap=True。以下代码显式设置关键参数,避免默认值或实验意图含糊:
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import balanced_accuracy_score, log_loss
# X_train: [N,D];y_train: [N]
forest = RandomForestClassifier(
n_estimators=500,
criterion='gini',
max_features='sqrt',
min_samples_leaf=2,
bootstrap=True,
oob_score=True,
n_jobs=-1,
random_state=42,
)
forest.fit(X_train, y_train)
val_proba = forest.predict_proba(X_val) # [num_val, C]
val_pred = forest.predict(X_val) # [num_val]
print('classes:', forest.classes_)
print('OOB score:', forest.oob_score_)
print('validation balanced accuracy:',
balanced_accuracy_score(y_val, val_pred))
print('validation log loss:', log_loss(y_val, val_proba))
oob_proba = forest.oob_decision_function_ # [N,C]
valid_oob_rows = np.isfinite(oob_proba).all(axis=1)
print('OOB coverage:', valid_oob_rows.mean())python重要 API 与属性:
n_estimators:森林中的树数量;增加它通常让结果更稳定,也线性增加模型大小和计算;bootstrap=True:每棵树使用有放回样本;oob_score仅在此时可用;max_samples:控制每棵树抽取多少次,默认为与训练集样本数相同;max_features='sqrt':每个节点随机候选特征数约为 ;oob_score_:默认以准确率计算的袋外总体分数;当前 API 也允许给oob_score传入指标函数;oob_decision_function_:每个训练样本的 OOB 类别概率,形状[N,C];树太少时某些行可能含NaN;estimators_:已训练成员树列表;estimators_samples_可查看各树抽中的训练索引;n_jobs=-1:让fit、predict、decision_path和apply在树之间使用可用处理器并行。
scikit-learn 1.9 的随机森林还原生支持数值特征中的 NaN 路由:训练时学习缺失值应走左右哪一侧。不过这不等于缺失机制已被正确建模;线上缺失率、缺失含义和数据管道仍必须单独监控。
09 OOB、交叉验证与测试集如何分工?#
| 评估来源 | 每个样本由谁预测 | 适合用途 | 主要限制 |
|---|---|---|---|
| OOB | 没抽到该样本的树 | 快速监控、减少额外切分 | 只适用于 Bootstrap 语义;会参与调参 |
| 交叉验证 | 当前折未用该样本训练的整个森林 | 比较超参数、估计波动 | 计算成本约乘折数 |
| 独立测试集 | 完全冻结方案后训练的最终模型 | 一次最终验收 | 不能反复查看后继续修改 |
OOB 与交叉验证结果不一致时,先检查:样本是否有时间或群组结构、类别是否极不平衡、树数量是否足够、两者使用的评分指标是否相同。不要只选择数值更好看的那一个。
10 怎样判断树的数量够不够?#
增加树数量一般不会像增加单树深度那样直接加剧过拟合,但收益会递减。可以观察 OOB 指标或固定验证集指标随树数量是否稳定:
tree_counts = [50, 100, 200, 500, 1000]
for count in tree_counts:
model = RandomForestClassifier(
n_estimators=count,
max_features='sqrt',
min_samples_leaf=2,
bootstrap=True,
oob_score=True,
n_jobs=-1,
random_state=42,
)
model.fit(X_train, y_train)
print(count, model.oob_score_)python记录的不应只有分数,还要包括模型文件大小、训练时长、P50/P99 推理延迟和峰值内存。当曲线已稳定而成本继续线性增长时,再增加树没有工程收益。
warm_start=True 可以在后续 fit 中保留旧树并增加 n_estimators,适合逐步观察树数;它不会让旧树用新超参数重训,也不是任意增量学习接口。正式交叉验证中更清晰的做法通常是独立拟合各候选模型。
11 特征重要性为何容易被误读?#
feature_importances_ 汇总所有树中某特征带来的加权不纯度下降。它计算快,但有两个常见陷阱:
- 可选切分点多的连续或高基数特征更容易偶然获得大下降;
- 两个高度相关特征可以互相替代,重要性会被分散或随机偏向其中一个。
更可靠的补充是只在验证集上做置换重要性(Permutation Importance):随机打乱一列,观察指标下降多少。
from sklearn.inspection import permutation_importance
result = permutation_importance(
estimator=forest,
X=X_val,
y=y_val,
scoring='balanced_accuracy',
n_repeats=20,
random_state=42,
n_jobs=-1,
)
order = np.argsort(result.importances_mean)[::-1]
for feature_id in order[:10]:
print(
feature_names[feature_id],
result.importances_mean[feature_id],
result.importances_std[feature_id],
)python即使置换重要性很高,也只能说明模型在当前验证分布下依赖该特征,不代表特征对真实结果具有因果作用。
12 常见错误与最短调试路径#
- 以为每棵树只随机一次特征。
max_features在每个节点生效;检查成员树的tree_.feature可看到不同路径使用不同列。 - 关闭 Bootstrap 却打开 OOB。
oob_score需要bootstrap=True,否则没有“未被抽中”的定义。 - 树太少就相信 OOB 概率。 先检查
oob_decision_function_是否含NaN以及每个样本有多少 OOB 成员。 - 只增加树,不控制单树叶子。 平均能降方差,却不能修复泄漏、无意义 ID、错误标签或极端小叶产生的不可靠概率。
- 嵌套并行耗尽资源。 外层交叉验证和森林都设
n_jobs=-1可能造成 CPU 与内存争用;只让一层负责并行。 - 把 OOB 当最终测试。 OOB 被反复用于决策后就是开发信号,仍需封存测试集。
- 部署时只测平均延迟。 500 棵深树会带来模型体积、缓存未命中和尾延迟;同时测 P99 与并发吞吐。
- 类别失衡只看默认 OOB 准确率。 改用适当指标、
class_weight或独立验证,并检查每类召回率。
最小诊断代码:
assert X_train.ndim == 2 and y_train.ndim == 1
assert len(forest.estimators_) == forest.n_estimators
assert forest.oob_decision_function_.shape == (
X_train.shape[0], len(forest.classes_)
)
depths = np.array([tree.get_depth() for tree in forest.estimators_])
leaves = np.array([tree.get_n_leaves() for tree in forest.estimators_])
print('depth median/max:', np.median(depths), depths.max())
print('leaves median/max:', np.median(leaves), leaves.max())python若训练分数接近 1、OOB 与验证都明显较低,优先调大 min_samples_leaf、限制深度或改善数据;若 OOB 很好而时间切分验证很差,优先怀疑分布漂移或划分结构,而不是继续增加树。
13 它与相近集成方法有什么区别?#
| 方法 | 样本随机性 | 特征随机性 | 树之间关系 | 主要目标 |
|---|---|---|---|---|
| 单棵决策树 | 无 | 通常看全部特征 | 只有一棵 | 得到可追踪规则 |
| Bagging Trees | Bootstrap | 通常看全部特征 | 并行、独立训练 | 通过平均降低方差 |
| 随机森林 | Bootstrap | 每节点随机子集 | 并行、独立训练 | 降低方差并降低树间相关性 |
| Extra Trees | 可选 Bootstrap | 每节点随机子集且阈值更随机 | 并行 | 进一步随机化、常更快 |
| Gradient Boosting | 通常非 Bootstrap 核心 | 依实现而定 | 后一棵拟合前一轮残差/梯度 | 顺序降低偏差 |
随机森林的成员树可以并行训练;Boosting 的树有顺序依赖,不能把它理解成“另一种投票森林”。随机森林通常是表格数据的强基线,但面对高维稀疏线性问题、需要平滑外推的回归、极严格模型体积或延迟约束时,线性模型、专门梯度提升实现或更紧凑模型可能更合适。
14 今天真正需要记住什么?#
- 随机森林让每棵树看到不同的 Bootstrap 样本,并在每个节点随机限制候选特征。
- 平均多棵高方差树能降低独立波动;树间相关性越高,继续增加树的收益越有限。
- 特征子采样的作用是降低成员相关性,不保证单棵树更准确;
max_features过小也会增加偏差。 - 每棵树约有 36.8% 的样本位于袋外,可用于 OOB 诊断,但 OOB 参与调参后不能替代最终测试集。
- 树数量要结合指标稳定性、模型大小、训练成本和尾延迟选择;特征重要性只能说明模型依赖,不能证明因果。
15 思考题与小练习#
练习 1:计算一次 Bootstrap 的 OOB 集合
训练编号为 [1,2,3,4,5],某棵树抽到 [2,2,5,1,2]。不同袋内样本是 {1,2,5},袋外集合是 {3,4};虽然只出现 3 个不同样本,训练序列仍有 5 个位置。
练习 2:相关性为何决定平均上限?
若 ,均值方差为 。把树从 100 增到 1000 只能降到约 0.201;降低相关性比继续堆树更有潜力。
练习 3:比较 OOB 与时间验证
在带日期的数据上同时计算随机森林 OOB 分数和“过去训练、未来验证”的分数。若 OOB 明显更高,解释随机 Bootstrap 如何混合时间分布,以及为何线上预期应更相信时间验证。
相关工作#
- Breiman: Bagging Predictors ↗:用 Bootstrap 聚合不稳定学习器、降低预测方差的奠基论文。
- Ho: Random Decision Forests ↗:随机子空间构造决策森林的早期代表性工作。
- Amit & Geman: Shape Quantization and Recognition with Randomized Trees ↗:随机化树用于识别问题的重要前置工作。
- Breiman: Random Forests ↗:系统提出随机森林并分析强度、相关性与泛化误差。
- scikit-learn: RandomForestClassifier ↗:当前 Bootstrap、OOB、特征子采样、缺失值和并行接口的官方说明。
16 下一篇预告#
随机森林让许多树并行、独立地生长,再通过平均降低方差。下一篇将转向另一条集成路线:让后一棵浅树专门修正前一轮仍然犯错的方向,建立梯度提升树的逐步加法模型。