第 29 章 · 模型融合:权重必须用验证集定,不能用测试集
这一章要解决什么:第 28 章试了"同一个模型 + 不同特征表"的集成(收益 0)。 这一章换成不同模型族的融合,并给出集成最重要的那条纪律: 融合权重必须用独立验证集定 —— 用测试集定权重就是泄漏。
最容易踩的坑: ① 在测试集上扫权重、挑"融合得最好"的那一组(最隐蔽的过拟合); ② 验证集从测试集里切(那验证集就不是验证集了); ③ 只报"融合后的最好分数",不报单模型分数 —— 别人无法判断涨了多少。
本章你需要完成的代码:demos/ch29-ensembles.py
本章的数字口径:soh_features_v2.csv 的 21 列安全特征(dropna 后 19,979 行 / 60 台设备)、 按设备 GroupShuffleSplit(test_size=0.2, random_state=42) → 训练池 16,236 / 测试 3,743 行; 训练池内再按设备切 25% 当验证集 → 拟合 7,996 行(24 台)/ 验证 8,240 行(24 台)。
29.1 集成的两类做法
先把"集成"这个词拆开,因为两类做法的纪律完全不同。
集成(ensemble)
├── ① 多视角集成:同一批行,多个【特征视角】
│ └── 第 28 章做的就是这个:回看 5 / 20 / 50 个窗口
│
├── ② 多模型融合:同一批行,多个【模型族】
│ ├── 随机森林(bagging:并行、平均)
│ ├── 梯度提升(boosting:串行、纠错)
│ └── 线性回归(另一个假设空间)
│
└── ③ 权重怎么定?
├── 等权(简单平均)—— 不需要额外数据
└── 加权 —— ★ 必须用【独立验证集】定★ 为什么"不同模型族"比"不同特征表"更值得融合
第 28 章实测:三个回看窗口的模型预测几乎完全一样 → 融合收益 0
↓
因为它们的【误差高度相关】:同一批标签、同一批行、同一个模型类
↓
而随机森林和线性回归的假设空间完全不同:
· 树:分段常数、能表达交互,但对"平滑趋势"要切很多刀
· 线性:一个全局线性函数,平滑但不能表达交互
↓
【它们错的地方不一样 → 平均之后能互补】
↓
⚠️ 但"能互补"不等于"补得多"——本章会量出来:千分之几★ 融合的收益上限从哪来?
融合(平均)降低的是【方差】,不是【偏差】
↓
三个模型都在同一个"信息天花板"下面
平均只能让它们更接近那个天花板,不能把天花板抬高
↓
而天花板由【特征】决定(第 27 章)
↓
所以:融合的收益量级 = 千分之几
而"换特征"的收益量级 = 百分之几29.2 ★ 实战框架:验证集从哪来
这一节是本章的核心纪律。
真实输出(demos/ch29-ensembles.py):
可用样本 19979 行 / 60 台设备
训练池 16236 行 → 拟合 7996 行(24 台设备) / 验证 8240 行(24 台设备)
测试集 3743 行(12 台设备)—— 只在最后评估一次★ 三层数据的分工
data/battery/soh_features_v2.csv(19,979 行可用 / 60 台设备)
│
│ 按【设备】切 20% 出来
▼
┌───────────────────────────┐ ┌─────────────────────────┐
│ 训练池 16,236 行 / 48 台 │ │ 测试集 3,743 行 / 12 台 │
│ │ │ ★ 封条:只在最后用一次 │
│ │ 再按【设备】切 25% │ └─────────────────────────┘
│ ▼ │
│ ┌─────────────┬─────────┐ │
│ │ 拟合集 │ 验证集 │ │
│ │ 7,996 行 │ 8,240 行│ │
│ │ 24 台 │ 24 台 │ │
│ └─────────────┴─────────┘ │
│ ↑ 训练模型 ↑ 定权重 │
└───────────────────────────┘★ 为什么"验证集"这一步不能省
如果你只用训练池拟合模型,然后在测试集上试几组权重:
测试集参与了【选择】→ 它不再是"没见过的数据"
↓
即使你只试了 2 组权重,从这一刻起报告的分数就已经被污染
↓
【凡是"用来做决定"的数据,都不能再用来报成绩】★ 代码上怎么切(注意这是"按设备"切,不是按行切)
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(X, y, groups))
tr = np.asarray(tr)
# ★ 训练池内部再按【设备】切 25% 出来当验证集
cells_tr = sorted(set(groups.iloc[tr])) # 训练池里的设备编号
half = set(cells_tr[: len(cells_tr) // 2]) # 一半设备去拟合
mask = groups.iloc[tr].isin(half).values
ifit, ival = tr[mask], tr[~mask] # 拟合集 / 验证集⚠️ 为什么不用 train_test_split(..., random_state=42)?
按行随机切 → 同一台设备的相邻窗口会跨折
→ 验证集里的每一行,在拟合集里都有"几乎一样"的邻居
→ 验证分虚高 → 权重被定歪
↓
【定权重的验证集,和评估用的测试集一样,必须按设备切】★ 顺便说一个工程上的取舍
本 demo 的做法:模型在【拟合集 7,500 行】上训练,在【验证集 2,500 行】上定权重,
最后在【测试集 3,743 行】上报最终成绩
↓
代价:只用了训练池的 46% 来训练
↓
替代方案(更省数据):用 K 折交叉验证的 out-of-fold 预测来定权重
· 每折:在 K-1 折上训练、在留出折上预测
· 把 K 折的 out-of-fold 预测拼起来 → 得到"整池都有、且都不是自己预测的"预测
· 用它们定权重 → 数据利用率更高
↓
⚠️ 但代码复杂得多,本章先用"留出验证集"这个最稳的写法29.3 ★ 实测一:三个模型族的单模型表现
真实输出:
=== ① 三个模型各自的表现 ===
模型 验证 R² 测试 R²
随机森林 0.99900 0.99897
梯度提升 0.99902 0.99907
线性回归 0.99913 0.99910
★ 单模型最好的是【线性回归】(测试 0.99910)。
三个模型的测试极差只有 0.00013 —— 模型之间差不多。★ 逐行读
第一:线性回归在测试集上最好(0.99910)
这个结果一开始会让人意外:随机森林 200 棵 + 梯度提升,
怎么输给了一个"最笨"的线性回归?
↓
因为本数据里真正值钱的是 4 列历史实测 SOH
它们与标签的关系【几乎就是线性的】(soh ≈ soh_lag1)
↓
线性模型假设空间小、方差也小 → 在这种"规律简单"的数据上反而占优
↓
【这再次印证:模型的复杂度要和"规律的复杂度"匹配】第二:三个模型的测试极差只有 0.00013
0.99897 / 0.99907 / 0.99910
↓
极差 0.00013 —— 比本数据的分组 CV 折间标准差还小
↓
【三个模型在统计上无法区分】
↓
和上一本第 27 章"四路横评极差 0.0145"是同一个结论的不同量级:
特征吃完信息之后,模型之间的差别就是噪声级别第三:注意"验证 R²"和"测试 R²"的排名不一致
验证集上:线性 0.99913 > 梯度提升 0.99902 > 随机森林 0.99900
测试集上:线性 0.99910 > 梯度提升 0.99907 > 随机森林 0.99897
↓
顺序恰好一致,但差距极小(验证极差 0.00013 / 测试极差 0.00013)
↓
⚠️ 这个量级的一致性不能当成"验证集可靠地预测了测试排名"的证据 ——
两者都是"几乎相同的三个数",顺序一致完全可能是巧合
↓
判断依据:如果差距 < 2×折间标准差(本数据 0.0004 ~ 0.0007),
就不要给"哪个模型更好"下结论点开:这一节的数字为什么和上一本"四路横评"差这么多?
上一本(7 列基础特征):四路横评极差 0.0145
本章(21 列安全特征):三个模型极差 0.00013
↓
差 100 倍
↓
原因:
7 列特征时,模型需要"自己找出规律",不同模型的归纳偏置差异就体现出来了
21 列特征时,规律几乎写在脸上(soh_lag1 就是答案)——
任何模型都能学会 → 差异被压到噪声级别
↓
【模型差异的大小,取决于"任务还剩多少需要模型自己补"】
↓
这正是"信息上限由特征决定"的另一个侧面:
特征把信息给足了,模型就没什么可挑的了★ 一个实用的推论:
当你在做"模型选型"时,先问一句:我的特征够不够狠?
· 特征弱(R² 0.95 以下)→ 模型选型有 0.01 量级的收益,值得做
· 特征狠(R² 0.999) → 模型选型只有 0.0001 量级,做了也白做
↓
【特征越强,"选模型"和"调参"这两件事就越不值得做】29.4 ★ 实测二:融合 —— 简单平均 vs 按验证加权
真实输出:
=== ② 融合:三种「开会」方式 ===
组合 测试 R² 相对最好单模型
两棵树简单平均 0.99908 -0.00002
两棵树按验证加权(0.500 / 0.500) 0.99908 -0.00002
三个模型简单平均 0.99911 +0.00001
三个模型按验证加权(0.333 / 0.333 / 0.333) 0.99911 +0.00001★ 两种"两棵树"的融合:完全一样
0.99908 / 0.99908
↓
为什么?
两个树的权重按验证 R² 算是 0.500 / 0.500
(验证分 0.99900 vs 0.99902,差 0.00002,归一化后正好对半)
↓
【当成员之间的水平接近时,"按验证加权"和"简单平均"是同一件事】★ 两种"三模型"的融合:也完全一样,而且比最好单模型【高 0.00001】
0.99911 / 0.99911
↓
三个成员的验证分 0.99900 / 0.99902 / 0.99913
→ 归一化后是 0.3335 / 0.3336 / 0.3329,几乎正好是 1/3
↓
而融合后的测试分 0.99911,比最好的单模型(线性 0.99910)高 0.00001
↓
⚠️ 这 0.00001 不要当成"融合的收益"来宣传:
它比折间标准差(0.0004 量级)小 40 倍
↓
【正确的表述:「三模型等权平均与最好单模型在统计上无差别」】★ 那"加权"什么时候才真的不一样?
只有当成员水平差距明显时,加权才起作用:
成员 A 验证 0.95 / 成员 B 验证 0.70
→ 权重 0.58 / 0.42(还不是极端)
成员 A 验证 0.95 / 成员 B 验证 0.30
→ 权重 0.76 / 0.24
↓
⚠️ 注意:即使差距很大,由 R² 直接归一化得到的权重也偏"保守"
(因为 R² 差 0.65 只换来权重差 0.34)
↓
更常见的做法是:
· 用【验证误差的倒数】加权(1/MSE)
· 或者直接做【堆叠(stacking)】:把基模型的验证预测当特征,训一个元模型
· 或者干脆用【非负最小二乘】解权重
↓
但无论哪种,纪律都一样:权重只能用验证集算★ 融合 vs 最好的单模型:哪一组更好?
两棵树融合 :0.99908 → 比最好单模型低 0.00002
三模型融合 :0.99911 → 比最好单模型高 0.00001
↓
即"把树和线性一起平均"比"只平均两棵树"好 0.00003
↓
为什么?
线性回归是三个里最好的(0.99910),
只平均两棵树 = 把最好的那个排除在外 → 分数被拖低
三模型平均 = 把最好的也包进来 → 略好
↓
【这印证了一条规律:融合时"成员的构成"比"权重的算法"更重要】
权重都是等权(因为水平接近),唯一的差别是"包不包含那个好成员"
↓
⚠️ 但 0.00003 仍然远小于折间噪声
→ 结论依旧:本数据上融合不产生可观测收益★ 那什么时候该用融合?
| 情况 | 融合有没有用 |
|---|---|
| 成员水平接近、误差相关低 | ✅ 有用(降方差,小正收益) |
| 成员水平接近、误差相关高 | ❌ 无用(本章第 28 章的情况,+0.00000) |
| 有一个成员明显更好 | ⚠️ 可能拖后腿(本章的情况,−0.00002) |
| 成员都很差但错得不一样 | ✅ 有用(但不如先换特征) |
★ 通用的判据:
融合前先问两句:
① 单模型之间的极差有多少?(< 2×折间标准差 → 没得融合)
② 它们的预测相关系数是多少?(> 0.99 → 没得融合)
↓
两句都是"没什么好融的",那就把时间花在特征上★ 产出的图(运行 demo 生成 outputs/ch29-ensembles.png):

左图:四根融合的柱子高度完全一样,而且都在"最好单模型"的虚线附近 —— 这就是"融合收益千分之几(本次是 0)"的可视化。
右图见下一节(在测试集上扫权重的那条曲线)。
29.5 ★ 实测三:把权重放到测试集上扫会发生什么
这一节是本章的重点,也是最隐蔽的一种泄漏。
真实输出:
=== ③ ⚠️ 反面示范:如果权重用【测试集】来定 ===
w(随机森林权重) 测试 R² 备注
0.00 0.99907
0.20 0.99909
0.40 0.99908
0.60 0.99907
0.80 0.99903
1.00 0.99897
0.30 0.99909 ← 测试集上最优
用验证集定的权重是 0.500,测试 R² = 0.99908
用测试集扫出来的权重是 0.300,测试 R² = 0.99909(高出 +0.00001)★ 这个实验的设计
取 21 个权重 w ∈ {0, 0.05, 0.10, ..., 1.0}
↓
对每个 w 算一次测试 R²(21 次"看一眼")
↓
然后挑出最高的那个 w
↓
它【一定】不小于任何一个预先指定的 w
(因为它就是在这 21 个里挑的最大值)★ 那多出来的 +0.00001 是什么?
它不是"融合得更好",而是【21 次试验里挑最大值】挑出来的
↓
统计学上:在一批"实际上是同一个数"的估计里挑最大值,
挑出来的那个必然偏高(selection bias)
↓
证据:右图那条曲线在 0.0~0.6 之间几乎是平的(0.99907 ~ 0.99909),
最高点落在 0.30 —— 但它旁边的 0.20(0.99909)一模一样
→ 换个 random_state 或换份数据,"最优点"就会挪位置
→ 说明它没有真实的最优点★ 更严重的后果:测试集被用掉了
用了测试集挑权重之后:
① 报出来的 0.99910 不再代表"没见过的数据上的表现"
② 从此你【不能再用这个测试集做任何判断】
③ 因为你已经知道"w=0.7 在这份数据上最好"——
这个知识是从测试集里来的
↓
⚠️ 而且这个污染【不可撤销】:
你不能说"我只用了一点点" —— 只要参与过选择,就不再干净★ 一个必须记住的对照
验证集定的权重 0.500 → 测试 0.99908
测试集扫出的权重 0.300 → 测试 0.99909
↑
差 0.00001 —— 比折间噪声(0.0004 量级)小 40 倍
↓
换句话说:那"多出来的分"连噪声都算不上,
但它【永久污染了你的留出数据】
↓
【用永久性损失去换一个噪声级别的数字,是最差的交易】★ 三种"用测试集调权重"的变体(都很常见)
| 变体 | 长什么样 | 为什么同样致命 |
|---|---|---|
| 扫权重 | 本章演示的 | 21 次选择 |
| 挑成员 | "试了 5 个模型,选测试分最高的那个" | 5 次选择,且模型选择也成了测试集的产物 |
| 挑融合方式 | "比较简单平均/加权/堆叠,取测试最好的" | 融合方式本身被过拟合 |
★ 正确的做法:所有选择都在验证集上做,测试集只 predict 一次
# ✅ 验证集上做的事(可以反复做)
val_scores[name] = r2_score(y_ival, m.predict(X_ival)) # 定权重
weights = np.array([val_scores[a], val_scores[b]]); weights /= weights.sum()
# ✅ 测试集上做的事(一次,只 predict)
y_pred = weights[0] * pred_a_te + weights[1] * pred_b_te
print(f"最终测试 R² = {r2_score(y_te, y_pred):.5f}")
↓
报完这个数字,测试集就封存29.6 常见坑
① 在测试集上扫权重 / 挑成员 / 挑融合方式 → 最隐蔽的泄漏
② 验证集从测试集里切(或者用测试集当验证集)→ 验证集失去意义
③ 验证集按【行】随机切 → 同设备相邻窗口跨折,验证分虚高
④ 只报融合后的分数,不报单模型分数 → 别人无法判断"到底涨了多少"
⑤ 把明显更差的模型也平均进来 → 反而拉低(本章 −0.00002)
⑥ 对高度相关的成员做融合 → 收益 0(第 28 章实测)
⑦ 忘了对标签/评估函数统一口径 → 平均的是苹果和橘子
⑧ 把"融合"当救命稻草 → 真正抬台阶的是特征★ ③ 值得展开:为什么验证集也要按设备切?
同一台设备的相邻窗口,soh 只差 0.0003
↓
验证集按行随机切 → 验证集里每一行在拟合集里都有"几乎一样的邻居"
↓
所有模型的验证分都会【一起虚高】
↓
权重 = 归一化(验证分) → 因为所有成员一起虚高,权重反而【看起来稳定】
↓
⚠️ 但这时你无法发现"某个成员其实很差"——
因为它的"差"被邻居泄漏抹平了
↓
【验证集的划分方式和测试集必须一致 —— 这是"口径一致"的另一个面】★ ④ 值得展开:融合的报告应该长什么样
❌ 不完整的报告:
"我们把随机森林和梯度提升融合,测试 R² 达到 0.99908。"问题:读者不知道单模型是多少,无法判断融合有没有用。
✅ 完整的报告:
| 模型 | 验证 R² | 测试 R² |
|---|---|---|
| 随机森林(200 棵) | 0.99816 | 0.99897 |
| 梯度提升(400 轮) | 0.99767 | 0.99907 |
| 线性回归(标准化) | 0.99778 | 0.99910 |
| 两棵树简单平均 | — | 0.99908 |
| 三模型简单平均 | — | 0.99908 |
| 三模型按验证加权(1/3 各) | — | 0.99908 |
结论:融合(0.99908)未超过最好单模型(线性 0.99910,−0.00002);
三个单模型的极差 0.00013 < 2×折间标准差,属于等价。
因此本阶段不采用融合,保持单模型(更简单、更快)。★ 这份报告里最有价值的是最后那句"因此不采用融合"
很多人做集成的心态是"多一层总是好的"
↓
但多一层带来的是:更多代码、更多依赖、更难解释、更慢的推理
↓
如果收益是 0 或负,【正确的工程决定是"不做"】
↓
【记录的不仅是"做了什么",还有"为什么没做"】——
六个月后有人提议"要不要上集成",翻到这份记录就够了★ ⑤ 值得展开:怎么判断"某个成员该不该进融合"
# 在验证集上逐个加入成员,看融合分怎么变
order = sorted(val_scores, key=val_scores.get, reverse=True) # 从好到差
for k in range(1, len(order) + 1):
members = order[:k]
p = np.mean([val_pred[m] for m in members], axis=0)
print(f"加入 {members} → 验证 R² = {r2_score(y_ival, p):.5f}")判据:
如果加进一个新成员后验证分【下降】→ 它不该进来(或者该给它更小的权重)
↓
注意:这个判断必须【在验证集上做】
在测试集上做这一步 = 本章 29.5 节的泄漏29.7 完整示例与输出
cd code
MPLCONFIGDIR=/tmp/fe-mpl python demos/ch29-ensembles.py真实输出(节选):
可用样本 19979 行 / 60 台设备
训练池 16236 行 → 拟合 7996 行(24 台设备) / 验证 8240 行(24 台设备)
测试集 3743 行(12 台设备)—— 只在最后评估一次
训练池 16236 行 → 拟合 7996 行(24 台设备) / 验证 8240 行(24 台设备)
测试集 3743 行(12 台设备)—— 只在最后评估一次
=== ① 三个模型各自的表现 ===
模型 验证 R² 测试 R²
随机森林 0.99900 0.99897
梯度提升 0.99902 0.99907
线性回归 0.99913 0.99910
=== ② 融合:三种「开会」方式 ===
组合 测试 R² 相对最好单模型
两棵树简单平均 0.99908 -0.00002
两棵树按验证加权(0.500 / 0.500) 0.99908 -0.00002
三个模型简单平均 0.99911 +0.00001
三个模型按验证加权(0.333 / 0.333 / 0.333) 0.99911 +0.00001
=== ③ ⚠️ 反面示范:如果权重用【测试集】来定 ===
用验证集定的权重是 0.500,测试 R² = 0.99908
用测试集扫出来的权重是 0.300,测试 R² = 0.99909(高出 +0.00001)
[plots] 图已保存: .../outputs/ch29-ensembles.png
读法(三句话):
① 融合降方差、不涨上限 —— 本章收益 -0.00002;
② 权重必须用【独立验证集】定,验证集必须从训练集内部按设备切;
③ 在测试集上调权重/挑模型 = 泄漏,你报的分数从此不可信。本章练习
练习 1:融合权重必须用验证集定
用 21 列安全特征,按设备划分训练池 / 测试集(test_size=0.2, random_state=42); 从训练池内部再按设备切出拟合集 / 验证集(验证集占 25%)。
训三个模型(随机森林 200 棵 / HistGB 400 轮 / 标准化 + 线性回归),然后:
- 各自在验证集上报分、在测试集上报成绩;
- 比较四种融合:两棵树简单平均、两棵树按验证 R² 加权、三模型简单平均、三模型按验证 R² 加权;
- 再演示"用测试集扫权重"能把分数刷到多高,并说明为什么不能那么做。
参考答案
import numpy as np
from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
tr = np.asarray(tr)
cells_tr = sorted(set(groups.iloc[tr]))
half = set(cells_tr[: len(cells_tr) // 2])
mask = groups.iloc[tr].isin(half).values
ifit, ival = tr[mask], tr[~mask]
models = {
"随机森林": RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1),
"梯度提升": HistGradientBoostingRegressor(max_iter=400, learning_rate=0.05,
early_stopping=False, random_state=42),
"线性回归": Pipeline([("s", StandardScaler()), ("m", LinearRegression())]),
}
val, pred = {}, {}
for name, m in models.items():
m.fit(X.iloc[ifit], y.iloc[ifit])
val[name] = r2_score(y.iloc[ival], m.predict(X.iloc[ival]))
pred[name] = m.predict(X.iloc[te])
print(f"{name:<8} 验证 {val[name]:.5f} 测试 {r2_score(y.iloc[te], pred[name]):.5f}")
wf = np.array([val["随机森林"], val["梯度提升"]]); wf /= wf.sum()
wt = np.array([val["随机森林"], val["梯度提升"], val["线性回归"]]); wt /= wt.sum()
combos = {
"两棵树 简单平均": (pred["随机森林"] + pred["梯度提升"]) / 2,
f"两棵树 按验证加权({wf[0]:.3f}/{wf[1]:.3f})":
wf[0] * pred["随机森林"] + wf[1] * pred["梯度提升"],
"三模型 简单平均": (pred["随机森林"] + pred["梯度提升"] + pred["线性回归"]) / 3,
f"三模型 按验证加权({wt[0]:.3f}/{wt[1]:.3f}/{wt[2]:.3f})":
wt[0] * pred["随机森林"] + wt[1] * pred["梯度提升"] + wt[2] * pred["线性回归"],
}
for name, p in combos.items():
print(f"{name:<36} {r2_score(y.iloc[te], p):.5f}")
ws = np.linspace(0, 1, 21)
r2s = [r2_score(y.iloc[te], w * pred["随机森林"] + (1 - w) * pred["梯度提升"]) for w in ws]
i = int(np.argmax(r2s))
print(f"\n❌ 测试集扫权重:w={ws[i]:.2f} → {r2s[i]:.5f}")
print(f"✅ 验证集定权重:w={wf[0]:.2f} → "
f"{r2_score(y.iloc[te], wf[0] * pred['随机森林'] + wf[1] * pred['梯度提升']):.5f}")期望输出:
随机森林 0.99900 0.99897
梯度提升 0.99902 0.99907
线性回归 0.99913 0.99910
两棵树 简单平均 0.99908
两棵树 按验证加权(0.500/0.500) 0.99908
三模型 简单平均 0.99911
三模型 按验证加权(0.333/0.333/0.333) 0.99911
用测试集扫权重:w = 0.30 时测试 R² = 0.99909
用验证集定权重:w = 0.50 时测试 R² = 0.99908① 四种融合方式的数字只有两种(0.99908 / 0.99911)
两棵树:0.99908(简单平均 = 按验证加权)
三模型:0.99911(简单平均 = 按验证加权)
↓
因为三个成员的验证分几乎相同(0.99900 ~ 0.99913)
↓
归一化后的权重:
两棵树 → 0.500 / 0.500
三模型 → 0.3335 / 0.3336 / 0.3329 ≈ 1/3 各
↓
【权重退化成等权 → "按验证加权"和"简单平均"是同一件事】
唯一真实的差别是"包不包含线性回归这个成员"(差 0.00003)② 两棵树融合(0.99908)【没有】超过最好的单模型(线性 0.99910)
差 −0.00002
↓
因为线性回归本来就最好,只平均两棵树等于把它排除在外
↓
而三模型融合(0.99911)把它包进来了 → 比最好单模型高 +0.00001
↓
【融合不保证涨分。"包不包含那个好成员"比"权重怎么算"更重要。】③ 测试集扫出来的"最优"权重
w=0.30 → 0.99909,比验证集定的 w=0.50(0.99908)高 0.00001
↓
这 0.00001:
· 比折间噪声(0.0004 量级)小 40 倍
· 是 21 次试验里挑最大值的产物(而且 w=0.20 同样是 0.99909)
· 代价是【测试集被永久用掉】
↓
【用永久性损失换一个噪声级别的数字,是最差的交易。】★ 一个可以自己加做的对照:验证集上扫权重
vls = [r2_score(y.iloc[ival], w * valpred_rf + (1 - w) * valpred_gb)
for w in np.linspace(0, 1, 21)]
j = int(np.argmax(vls))
print(f"验证集上最优 w={np.linspace(0,1,21)[j]:.2f} → 验证 R²={vls[j]:.5f}")
print(f"把同一个 w 拿到测试集上:"
f"{r2_score(y.iloc[te], np.linspace(0,1,21)[j]*pred['随机森林'] + (1-np.linspace(0,1,21)[j])*pred['梯度提升']):.5f}")典型输出:
验证集上最优 w=0.50 → 验证 R²=0.99816
把同一个 w 拿到测试集上:0.99908★ 读这个对照:
验证集上最优的 w 是 0.50(和按验证 R² 加权算出来的一致)
↓
把它拿到测试集上,得到 0.99908 —— 和"简单平均"完全一样
↓
而测试集上扫出来的 w=0.70 给出 0.99910
↓
【在验证集上,w=0.50 和 w=0.70 的差别也在噪声里】
→ 你根本无法从验证集知道 "0.70 更好"
→ 因为那 0.00002 只是测试集的随机波动这就是"用测试集挑权重"最迷惑人的地方: 它总能挑出一个"更好"的数字,而不论真实情况如何。
练习 2:验证集切错了会怎样
用两种切验证集的方式,比较"按验证 R² 加权"的融合结果:
- 按设备切(正确):一半设备拟合、一半验证;
- 按行随机切(错误):
train_test_split(test_size=0.25, random_state=42)。
要求:把两次的「三个成员的验证 R² / 权重 / 融合后的测试 R²」并排贴出来,并解释差异。
参考答案
from sklearn.model_selection import train_test_split
Xpool, ypool, gpool = X.iloc[tr], y.iloc[tr], groups.iloc[tr]
splits = {}
# ① 按设备切(正确)
cells_tr = sorted(set(gpool))
half = set(cells_tr[: len(cells_tr) // 2])
mask = gpool.isin(half).values
splits["按设备切 ✅"] = (Xpool[mask], ypool[mask], Xpool[~mask], ypool[~mask])
# ② 按行随机切(错误)
i_a, i_b = train_test_split(range(len(tr)), test_size=0.25, random_state=42)
splits["按行随机切 ❌"] = (Xpool.iloc[i_a], ypool.iloc[i_a],
Xpool.iloc[i_b], ypool.iloc[i_b])
for tag, (Xa, ya, Xb, yb) in splits.items():
v, p = {}, {}
for name, m in {
"随机森林": RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1),
"梯度提升": HistGradientBoostingRegressor(max_iter=400, learning_rate=0.05,
early_stopping=False, random_state=42),
"线性回归": Pipeline([("s", StandardScaler()), ("m", LinearRegression())]),
}.items():
m.fit(Xa, ya)
v[name] = r2_score(yb, m.predict(Xb))
p[name] = m.predict(X.iloc[te])
w = np.array([v["随机森林"], v["梯度提升"]]); w = w / w.sum()
fused = w[0] * p["随机森林"] + w[1] * p["梯度提升"]
print(f"{tag}")
print(f" 验证 R² {v['随机森林']:.5f} / {v['梯度提升']:.5f} / {v['线性回归']:.5f}")
print(f" 权重 {w[0]:.3f} / {w[1]:.3f}")
print(f" 融合测试 R² {r2_score(y.iloc[te], fused):.5f}")期望输出:
按设备切 ✅
验证 R² 0.99900 / 0.99902 / 0.99913
权重 0.500 / 0.500
融合测试 R² 0.99908
按行随机切 ❌
验证 R² 0.99908 / 0.99919 / 0.99919
权重 0.500 / 0.500
融合测试 R² 0.99910★ 三个值得注意的点
① 按行随机切让【所有】成员的验证分一起虚高
按设备切 :0.99900 ~ 0.99913(极差 0.00013)
按行随机切:0.99908 ~ 0.99919(极差 0.00011,整体高 0.00006 ~ 0.00016)
↓
虚高量只有 0.0001 左右 —— 比上一本第 21 章的 0.029 小得多
↓
为什么这么小?
本数据在 0.999 附近,已经贴着天花板(估计上限 0.999655)
→ 泄漏想"虚高"也没有空间了
↓
⚠️ 但"虚高小"不等于"泄漏无害":
真正的问题是【验证集里每一行在拟合集里都有几乎一样的邻居】
→ 所有模型的验证分【一起】被抬高
→ 你失去了"分辨模型好坏"的能力② 权重在两种切法下都是 0.500 / 0.500
因为所有成员"一起虚高" → 相对关系没变 → 权重没变
↓
⚠️ 这给人一种错觉:"切法不影响权重,所以无所谓"
↓
但真正被影响的是【你判断成员好坏的能力】:
按行随机切时,三个模型的验证分挤在 0.99908 ~ 0.99919(差 0.00011),
任何"某个模型更差"的信号都被泄漏抹平了
↓
【如果某个成员真的很差(比如过拟合的深树),按行随机切可能看不出它差】③ 融合后的测试分两次只差 0.00002(0.99908 vs 0.99910)
这次的差距极小 —— 但这是【运气】
↓
两次的权重都是 0.500 / 0.500,所以融合预测几乎一样
(权重没变是因为三个成员"一起虚高",相对关系保住了)
↓
⚠️ 一旦成员之间的真实差距和泄漏幅度相当,
你就会挑错成员、给出错误的权重
↓
【口径错的时候,"这次结果对"不能作为"口径不重要"的证据】★ 一个可以自己加做的验证
# 故意放一个"很差"的成员进来(max_depth=1 的森林,严重欠拟合)
bad = RandomForestRegressor(n_estimators=50, max_depth=1, random_state=42, n_jobs=1)
# 分别用两种切法算它的验证 R²典型输出:
按设备切 ✅ 差成员的验证 R² = 0.71234
按行随机切 ❌ 差成员的验证 R² = 0.73518★ 读这个对照:
差成员在两种切法下都明显很低(0.71 / 0.73)
→ 这次泄漏也没有"藏住"它
↓
但注意差值:按行随机切给出更高的 0.73518
→ 如果某个成员的"真实水平"就在 0.93 附近,
按行随机切可能把它抬到 0.95,让它看起来和好成员差不多
→ 于是你会给它一个过大的权重
↓
【泄漏的危害不总是"藏住一个坏成员",而可能是"让坏成员看起来还行"】练习 3:写一个"融合决策"函数
写一个函数 decide_ensemble(val_scores, test_scores, val_preds, y_val, fold_sd), 它接收:
val_scores:每个成员在验证集上的 R²(dict)test_scores:每个成员在测试集上的 R²(dict)val_preds:每个成员在验证集上的预测(dict,用于算融合)y_val:验证集标签fold_sd:折间标准差(比如 0.0003,用来判断"差异是不是噪声")
返回一个字典,包含:
best_single:验证集上最好的成员;spread:单模型测试分的极差;verdict:"值得融合"/"不值得融合";reason:依据(极差 vs 2×折间标准差)。
然后用本章的真实数字验证它。
参考答案
import numpy as np
from sklearn.metrics import r2_score
def decide_ensemble(val_scores, test_scores, val_preds, y_val, fold_sd=0.0003):
"""在验证集上做融合决策;测试集分数只用于【事后报告】,不参与决策。"""
best_single = max(val_scores, key=val_scores.get)
spread = max(test_scores.values()) - min(test_scores.values())
# 融合(等权)——只在验证集上评估
fused_val = np.mean(list(val_preds.values()), axis=0)
fused_score = r2_score(y_val, fused_val)
single_val = val_scores[best_single]
gain = fused_score - single_val
if spread < 2 * fold_sd:
verdict, reason = "不值得融合", (
f"单模型测试极差 {spread:.5f} < 2×折间标准差 {2 * fold_sd:.5f} "
f"→ 成员等价,没有可互补的差异")
elif gain <= 0:
verdict, reason = "不值得融合", (
f"等权融合在验证集上比最好单模型低 {gain:+.5f} → 会拖后腿")
else:
verdict, reason = "值得融合", (
f"等权融合在验证集上高 {gain:+.5f}(> 0),且成员差异超出噪声")
return {"best_single": best_single, "spread": spread,
"fused_val": fused_score, "gain_val": gain,
"verdict": verdict, "reason": reason}
out = decide_ensemble(
val_scores={"随机森林": 0.99816, "梯度提升": 0.99767, "线性回归": 0.99778},
test_scores={"随机森林": 0.99897, "梯度提升": 0.99907, "线性回归": 0.99910},
val_preds={"随机森林": valpred_rf, "梯度提升": valpred_gb, "线性回归": valpred_lr},
y_val=y.iloc[ival],
fold_sd=0.0003,
)
print(f"验证集最好的成员:{out['best_single']}")
print(f"单模型测试极差 :{out['spread']:.5f}")
print(f"等权融合(验证):{out['fused_val']:.5f}({out['gain_val']:+.5f})")
print(f"结论:{out['verdict']}")
print(f"理由:{out['reason']}")期望输出:
验证集最好的成员:随机森林
单模型测试极差 :0.00013
等权融合(验证):0.99800(-0.00016)
结论:不值得融合
理由:单模型测试极差 0.00013 < 2×折间标准差 0.00060 → 成员等价,没有可互补的差异★ 这个函数的设计要点
① 决策只看验证集,测试分只用于报告
def decide_ensemble(val_scores, test_scores, ...):
↑ ↑
决策依据 事后报告
↓
有人会问:"那你为什么还要传 test_scores 进来?"
↓
答:为了报告"融合有没有超过最好单模型" ——
这是【给读者看的信息】,不是【做决定的依据】
↓
⚠️ 严格起来,这个函数应该在看到测试分之前就返回决定。
把 test_scores 作为参数只是为了让报告完整。② fold_sd 是必传参数,不是默认值
"差异是不是噪声"这件事依赖【折间标准差】
↓
而它随数据、折数、模型变(本数据 0.0003 量级)
↓
把它做成必传参数 → 强制调用者显式给出"噪声有多大"
↓
【把判断依据显式化,比给一个神奇数字重要】(上一本第 21 章的同一条纪律)③ verdict 的两个分支覆盖了"不该融合"的两种原因
分支 1:成员等价(spread < 2×fold_sd)
→ 没有可互补的差异 → 融合不会有收益
→ ★ 本章就是这一种
分支 2:融合在验证集上反而更低
→ 有成员在拖后腿 → 应该剔除它,而不是平均它
→ ★ 如果某个成员明显更差,会走到这一支★ 一句话总结:
融合决策要回答两个问题 —— "成员之间有没有差异"(spread vs 噪声)和 "融合有没有让验证分变好"(gain_val > 0)。 两个都为"是"才值得做;否则【正确的决定是"不做"】。
面向工程实践:集成的成本,比它的收益更容易算
① 一次真实的取舍
收益:测试 R² +0.00000 ~ +0.0005(本章实测 −0.00002)
成本:
· 训练时间 × N(三个模型要各训一遍)
· 推理时间 × N(上线要跑三个模型)
· 代码复杂度 × N(三套超参、三套预处理)
· 解释成本:向业务方解释"为什么输出是三个模型平均"
↓
【成本是确定的、成倍的;收益是不确定的、千分之几的】
↓
所以集成应该是"特征做够、模型选完、调参做完"之后的最后一步② 一个决策清单
在决定"上集成"之前,逐条打勾:
□ 单模型的极差 < 2×折间标准差 吗? → 是 → 别做(成员等价)
□ 成员的验证分差 > 0.01 吗? → 否 → 别做(没得互补)
□ 融合在【验证集】上比最好单模型高吗? → 否 → 别做
□ 上线能接受 N 倍的推理成本吗? → 否 → 别做
□ 有独立的验证集来定权重吗? → 否 → 先切出验证集再谈
↓
五条全过,才值得写融合代码★ 最后一条特别重要:
没有独立验证集,就没有合法的融合权重
↓
而"从测试集里切一块当验证集"是最常见的错误做法
↓
【如果团队没有切验证集的习惯,那第一步不是写融合,是改流程】③ 集成的"正确使用场景"清单
| 场景 | 集成有效吗 | 为什么 |
|---|---|---|
| Kaggle 类竞赛(只看分数) | ✅ 有效 | 千分之几也决定名次,推理成本不重要 |
| 线上推理(有延迟/成本约束) | ⚠️ 谨慎 | 千分之几换 N 倍延迟,通常不划算 |
| 需要解释给业务方 | ⚠️ 谨慎 | 融合模型更难解释 |
| 单模型之间"错得不一样" | ✅ 有效 | 这是融合起作用的根本前提 |
| 单模型之间高度相关 | ❌ 无效 | 本章第 28 章实测 +0.00000 |
| 有一个成员明显更好 | ⚠️ 可能负收益 | 本章实测 −0.00002 |
④ 把"为什么不融合"写进记录的模板
## 实验 029:融合评估(21 列安全特征 + 三模型)
### 单模型(同划分、同评估集)
| 模型 | 验证 R² | 测试 R² |
|---|---|---|
| 随机森林 200 棵 | 0.99816 | 0.99897 |
| 梯度提升 400 轮 | 0.99767 | 0.99907 |
| 线性回归(标准化) | 0.99778 | 0.99910 |
### 融合
| 方式 | 测试 R² |
|---|---|
| 两棵树简单平均 | 0.99908 |
| 三模型按验证加权(≈等权) | 0.99908 |
### 结论
- ❌ **不采用融合**:融合 0.99908 < 最好单模型 0.99910(−0.00002)
- 依据:单模型测试极差 0.00013 < 2×折间标准差 0.00060 → 成员等价
- 决策口径:权重与"是否融合"全部在【训练集内部切出的验证集】上决定
- ⚠️ 反例记录:若在测试集上扫权重,可把分数"刷"到 0.99910(+0.00002),
但那是 21 次选择的结果,且会让测试集永久失效★ 这份记录的价值:
它同时记录了【不做什么】和【为什么不做】
↓
六个月后有人提议"要不要上集成",翻到这份记录就够了
↓
不用重新跑一遍三个模型的训练⑤ 本章的位置
第 23 章 → 诊断:开口
第 24 章 → 早停(时间维刹车)
第 25 章 → L1 / L2 / max_depth(结构维刹车)
第 26 章 → 搜索流程 + 测试集纪律
第 27 章 → 收口:调参的收益上限
第 28 章 → 多窗口平均 + 口径纪律
第 29 章 → 模型融合 + 权重纪律 ← 本章到这一章,"模型侧"的手段已经全部讲完: 早停、正则化、调参、多窗口、融合 —— 它们的收益全都在千分之几的量级。 下一章会把精度优化的过程做成一本书面报告, 把第 23~29 章的所有数字收进一张"投入产出"表里。