Skip to content

第 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 集成的两类做法 ​

先把"集成"这个词拆开,因为两类做法的纪律完全不同。

text
集成(ensemble)
├── ① 多视角集成:同一批行,多个【特征视角】
│     └── 第 28 章做的就是这个:回看 5 / 20 / 50 个窗口
│
├── ② 多模型融合:同一批行,多个【模型族】
│     ├── 随机森林(bagging:并行、平均)
│     ├── 梯度提升(boosting:串行、纠错)
│     └── 线性回归(另一个假设空间)
│
└── ③ 权重怎么定?
      ├── 等权(简单平均)—— 不需要额外数据
      └── 加权 —— ★ 必须用【独立验证集】定

★ 为什么"不同模型族"比"不同特征表"更值得融合

第 28 章实测:三个回看窗口的模型预测几乎完全一样 → 融合收益 0
   ↓
因为它们的【误差高度相关】:同一批标签、同一批行、同一个模型类
   ↓
而随机森林和线性回归的假设空间完全不同:
  · 树:分段常数、能表达交互,但对"平滑趋势"要切很多刀
  · 线性:一个全局线性函数,平滑但不能表达交互
   ↓
【它们错的地方不一样 → 平均之后能互补】
   ↓
⚠️ 但"能互补"不等于"补得多"——本章会量出来:千分之几

★ 融合的收益上限从哪来?

融合(平均)降低的是【方差】,不是【偏差】
   ↓
三个模型都在同一个"信息天花板"下面
   平均只能让它们更接近那个天花板,不能把天花板抬高
   ↓
而天花板由【特征】决定(第 27 章)
   ↓
所以:融合的收益量级 = 千分之几
      而"换特征"的收益量级 = 百分之几

29.2 ★ 实战框架:验证集从哪来 ​

这一节是本章的核心纪律。

真实输出(demos/ch29-ensembles.py):

text
可用样本 19979 行 / 60 台设备
训练池 16236 行 → 拟合 7996 行(24 台设备) / 验证 8240 行(24 台设备)
测试集 3743 行(12 台设备)—— 只在最后评估一次

★ 三层数据的分工

text
data/battery/soh_features_v2.csv(19,979 行可用 / 60 台设备)
        │
        │ 按【设备】切 20% 出来
        ▼
┌───────────────────────────┐   ┌─────────────────────────┐
│ 训练池 16,236 行 / 48 台   │   │ 测试集 3,743 行 / 12 台 │
│                           │   │ ★ 封条:只在最后用一次  │
│  │ 再按【设备】切 25%      │   └─────────────────────────┘
│  ▼                        │
│ ┌─────────────┬─────────┐ │
│ │ 拟合集      │ 验证集  │ │
│ │ 7,996 行    │ 8,240 行│ │
│ │ 24 台       │ 24 台   │ │
│ └─────────────┴─────────┘ │
│   ↑ 训练模型    ↑ 定权重   │
└───────────────────────────┘

★ 为什么"验证集"这一步不能省

如果你只用训练池拟合模型,然后在测试集上试几组权重:
  测试集参与了【选择】→ 它不再是"没见过的数据"
   ↓
即使你只试了 2 组权重,从这一刻起报告的分数就已经被污染
   ↓
【凡是"用来做决定"的数据,都不能再用来报成绩】

★ 代码上怎么切(注意这是"按设备"切,不是按行切)

python
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 ★ 实测一:三个模型族的单模型表现 ​

真实输出:

text
=== ① 三个模型各自的表现 ===

模型              验证 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 按验证加权 ​

真实输出:

text
=== ② 融合:三种「开会」方式 ===

组合                                      测试 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):

左:单模型 vs 四种融合方式;右:在测试集上扫融合权重(❌ 泄漏)

左图:四根融合的柱子高度完全一样,而且都在"最好单模型"的虚线附近 —— 这就是"融合收益千分之几(本次是 0)"的可视化。

右图见下一节(在测试集上扫权重的那条曲线)。

29.5 ★ 实测三:把权重放到测试集上扫会发生什么 ​

这一节是本章的重点,也是最隐蔽的一种泄漏。

真实输出:

text
=== ③ ⚠️ 反面示范:如果权重用【测试集】来定 ===

           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 在这份数据上最好"——
     这个知识是从测试集里来的
      ↓
⚠️ 而且这个污染【不可撤销】:
   你不能说"我只用了一点点" —— 只要参与过选择,就不再干净

★ 一个必须记住的对照

text
验证集定的权重 0.500  →  测试 0.99908
测试集扫出的权重 0.300 →  测试 0.99909
                       ↑
             差 0.00001 —— 比折间噪声(0.0004 量级)小 40 倍
   ↓
换句话说:那"多出来的分"连噪声都算不上,
         但它【永久污染了你的留出数据】
   ↓
【用永久性损失去换一个噪声级别的数字,是最差的交易】

★ 三种"用测试集调权重"的变体(都很常见)

变体长什么样为什么同样致命
扫权重本章演示的21 次选择
挑成员"试了 5 个模型,选测试分最高的那个"5 次选择,且模型选择也成了测试集的产物
挑融合方式"比较简单平均/加权/堆叠,取测试最好的"融合方式本身被过拟合

★ 正确的做法:所有选择都在验证集上做,测试集只 predict 一次

python
# ✅ 验证集上做的事(可以反复做)
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 常见坑 ​

text
① 在测试集上扫权重 / 挑成员 / 挑融合方式 → 最隐蔽的泄漏
② 验证集从测试集里切(或者用测试集当验证集)→ 验证集失去意义
③ 验证集按【行】随机切 → 同设备相邻窗口跨折,验证分虚高
④ 只报融合后的分数,不报单模型分数 → 别人无法判断"到底涨了多少"
⑤ 把明显更差的模型也平均进来 → 反而拉低(本章 −0.00002)
⑥ 对高度相关的成员做融合 → 收益 0(第 28 章实测)
⑦ 忘了对标签/评估函数统一口径 → 平均的是苹果和橘子
⑧ 把"融合"当救命稻草 → 真正抬台阶的是特征

★ ③ 值得展开:为什么验证集也要按设备切?

同一台设备的相邻窗口,soh 只差 0.0003
   ↓
验证集按行随机切 → 验证集里每一行在拟合集里都有"几乎一样的邻居"
   ↓
所有模型的验证分都会【一起虚高】
   ↓
权重 = 归一化(验证分) → 因为所有成员一起虚高,权重反而【看起来稳定】
   ↓
⚠️ 但这时你无法发现"某个成员其实很差"——
   因为它的"差"被邻居泄漏抹平了
      ↓
【验证集的划分方式和测试集必须一致 —— 这是"口径一致"的另一个面】

★ ④ 值得展开:融合的报告应该长什么样

❌ 不完整的报告:

text
"我们把随机森林和梯度提升融合,测试 R² 达到 0.99908。"

问题:读者不知道单模型是多少,无法判断融合有没有用。

✅ 完整的报告:

text
| 模型 | 验证 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 或负,【正确的工程决定是"不做"】
   ↓
【记录的不仅是"做了什么",还有"为什么没做"】——
   六个月后有人提议"要不要上集成",翻到这份记录就够了

★ ⑤ 值得展开:怎么判断"某个成员该不该进融合"

python
# 在验证集上逐个加入成员,看融合分怎么变
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 完整示例与输出 ​

bash
cd code
MPLCONFIGDIR=/tmp/fe-mpl python demos/ch29-ensembles.py

真实输出(节选):

text
可用样本 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 轮 / 标准化 + 线性回归),然后:

  1. 各自在验证集上报分、在测试集上报成绩;
  2. 比较四种融合:两棵树简单平均、两棵树按验证 R² 加权、三模型简单平均、三模型按验证 R² 加权;
  3. 再演示"用测试集扫权重"能把分数刷到多高,并说明为什么不能那么做。
参考答案
python
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}")

期望输出:

text
随机森林          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)
  · 代价是【测试集被永久用掉】
   ↓
【用永久性损失换一个噪声级别的数字,是最差的交易。】

★ 一个可以自己加做的对照:验证集上扫权重

python
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}")

典型输出:

text
验证集上最优 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² 加权"的融合结果:

  1. 按设备切(正确):一半设备拟合、一半验证;
  2. 按行随机切(错误):train_test_split(test_size=0.25, random_state=42)。

要求:把两次的「三个成员的验证 R² / 权重 / 融合后的测试 R²」并排贴出来,并解释差异。

参考答案
python
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}")

期望输出:

text
按设备切 ✅
  验证 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,所以融合预测几乎一样
   (权重没变是因为三个成员"一起虚高",相对关系保住了)
   ↓
⚠️ 一旦成员之间的真实差距和泄漏幅度相当,
   你就会挑错成员、给出错误的权重
   ↓
【口径错的时候,"这次结果对"不能作为"口径不重要"的证据】

★ 一个可以自己加做的验证

python
# 故意放一个"很差"的成员进来(max_depth=1 的森林,严重欠拟合)
bad = RandomForestRegressor(n_estimators=50, max_depth=1, random_state=42, n_jobs=1)
# 分别用两种切法算它的验证 R²

典型输出:

text
按设备切 ✅  差成员的验证 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,用来判断"差异是不是噪声")

返回一个字典,包含:

  1. best_single:验证集上最好的成员;
  2. spread:单模型测试分的极差;
  3. verdict:"值得融合" / "不值得融合";
  4. reason:依据(极差 vs 2×折间标准差)。

然后用本章的真实数字验证它。

参考答案
python
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']}")

期望输出:

text
验证集最好的成员:随机森林
单模型测试极差  :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(三套超参、三套预处理)
  · 解释成本:向业务方解释"为什么输出是三个模型平均"
   ↓
【成本是确定的、成倍的;收益是不确定的、千分之几的】
   ↓
所以集成应该是"特征做够、模型选完、调参做完"之后的最后一步

② 一个决策清单

text
在决定"上集成"之前,逐条打勾:
  □ 单模型的极差 < 2×折间标准差 吗? → 是 → 别做(成员等价)
  □ 成员的验证分差 > 0.01 吗?      → 否 → 别做(没得互补)
  □ 融合在【验证集】上比最好单模型高吗? → 否 → 别做
  □ 上线能接受 N 倍的推理成本吗?    → 否 → 别做
  □ 有独立的验证集来定权重吗?       → 否 → 先切出验证集再谈
      ↓
五条全过,才值得写融合代码

★ 最后一条特别重要:

没有独立验证集,就没有合法的融合权重
   ↓
而"从测试集里切一块当验证集"是最常见的错误做法
   ↓
【如果团队没有切验证集的习惯,那第一步不是写融合,是改流程】

③ 集成的"正确使用场景"清单

场景集成有效吗为什么
Kaggle 类竞赛(只看分数)✅ 有效千分之几也决定名次,推理成本不重要
线上推理(有延迟/成本约束)⚠️ 谨慎千分之几换 N 倍延迟,通常不划算
需要解释给业务方⚠️ 谨慎融合模型更难解释
单模型之间"错得不一样"✅ 有效这是融合起作用的根本前提
单模型之间高度相关❌ 无效本章第 28 章实测 +0.00000
有一个成员明显更好⚠️ 可能负收益本章实测 −0.00002

④ 把"为什么不融合"写进记录的模板

markdown
## 实验 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 章的所有数字收进一张"投入产出"表里。


下一章:第 30 章 · 阶段目标:精度优化报告