Skip to content

第 18 章 · 偏差与方差:Bagging 治什么、治不了什么 ​

这一章要解决什么:上一章最后留了一个尴尬的结果 —— 200 棵 max_depth=2 的树(0.89407)居然不如一条直线(0.94253)。 为什么"种更多树"救不了它?这一章把"模型的病"分成两种,并给你一条能自己下诊断的判据。

这一章是"补充章":源材料里没有它,但它回答的是全书最实用的一个问题 —— "我的模型下一步该往哪改?"

最容易踩的坑:

  • 看到训练分低就以为"模型太简单",看到训练分高就以为"模型很好"(训练分不是成绩单,是体温计);
  • 把"加树 / 加数据 / 加特征 / 加正则"当成四个可以随便试的旋钮(它们治的是不同的病);
  • 以为"过拟合"只看训练分有多高(要看的是训练与测试的差距)。

本章你需要完成的代码:demos/ch18-bias-variance.py

18.1 先给结论:模型只有两种病,处方完全不同 ​

这一章的全部内容,可以先压缩成一张表:

你看到的现象诊断病灶先试什么
训练 R² 低、测试 R² 低、两者差距小高偏差(欠拟合)模型太简单,学不到规律加特征 / 加复杂度 / 换更强的模型
训练 R² 高、测试 R² 低、差距大高方差(过拟合)模型太灵活,把噪声也记住了加数据 / 降复杂度 / Bagging(森林) / 正则化
训练 R² 高、测试 R² 高、差距小健康—别再调模型,去改特征
两条线都低,但测试线还在涨数据不够样本太少加数据
两条线都低,测试线已经平了卡在特征上特征信息量不够换特征,不是换模型

⚠️ 最后两行里的"测试线",必须是【交叉验证量出来的平均线】 —— 单次划分量出来的曲线会抖得看不出趋势(本章练习 3 会亲手撞一次)。

★ 记住这张表,比记住"偏差方差分解"的公式有用一百倍。

两个词的定义(人话版):

偏差(bias)  = 就算给你无限多数据,这个模型【平均下来】还是会错多少
                  → 它是"模型这一类东西的天生局限"

方差(variance)= 换一批训练数据,这个模型的预测会变多少
                  → 它是"模型对这批具体数据的敏感程度"

注意"就算给你无限多数据"这个限定:

高偏差的问题,靠【加数据】是治不好的:
   模型本身表达不了这个规律,喂它 100 万行也一样错
   ↓
   只能换模型 / 加特征

这一条解释了第 17 章那个尴尬的结果:

200 棵 max_depth=2 的树 = 一群"只会画方块"的模型
   ↓
给再多数据、种再多棵树,它们也表达不了曲线
   ↓
测试 R² 0.89407,不如一条直线(0.94253)
   ↓
【高偏差,Bagging 治不了】

18.2 一个思想实验:为什么必须拆成两块 ​

"误差"这个词太笼统了。拆开它的理由,用一句话说就是:

同样是"预测错了 0.05",一个模型可能是因为"它天生就歪", 另一个可能是因为"它每次都歪得不一样"。这两种错,处方完全不同。

做一个思想实验(这一节不用代码,靠想象;下一节真的跑一遍):

假设你知道真值函数 f(x) = sin(1.5πx) + 0.5x
假设你有能力【反复重来】:
   第 1 次:随机抽 40 个点 → 训练一个模型 → 记录它在测试点上的预测
   第 2 次:换 40 个点 → 再训一个 → 再记录
   ...
   第 200 次

现在,把 200 次预测叠在同一个测试点上:
   ├─ 这 200 条线【平均下来】离真值有多远?  →  偏差(²)
   └─ 这 200 条线【彼此之间】散得有多开?    →  方差

两种病的可视化(这一章的图会画出来):

高偏差:200 条线紧紧挤在一起,但【整体偏离】真值
        ↑ 像一个总是往左偏的秤:稳定,但稳定地错

高方差:200 条线【漫天飞舞】,平均下来倒还行
        ↑ 像一把抖得厉害的手:有时候准,但你不敢用它

★ 一个必须建立的直觉:

偏差是"瞄准"的问题,方差是"稳定"的问题。 提高稳定(Bagging)不会改善瞄准;改善瞄准(换更强的模型)也不会提高稳定。

再加一块:噪声(noise)

噪声 = 数据本身的随机性
   ↓
这部分误差【任何模型都消不掉】
   ↓
它不是"你的模型不行",它是"这个问题就有这么难"

★ 这三块拼起来就是全书的"分数天花板":

你在测试集上看到的误差 ≈ 偏差² + 方差 + 噪声
                                    ↑
                        决定了 R² 的上限在哪

这就是第 04 章那句"标签方差决定可预测空间"的技术版: 本数据的噪声来自两层(每支设备固定的个体偏置 + 每个窗口的测量波动), 任何模型都消不掉它 —— 所以 R² 在 0.96 附近就封顶了(第 27 章会正式给结论)。

18.3 亲手拆一次:在一个已知真值的世界里 ​

这一节的代码在 demos/ch18-bias-variance.py 的第 ① 段。

做法:

python
def truth(x):
    return np.sin(1.5 * np.pi * x) + 0.5 * x          # 故意让我们知道真值

sigma = 0.30                                          # 观测噪声
x_test = np.linspace(0.02, 0.98, 200)                 # 固定的测试网格
# 每次实验:抽 40 个点 → 加噪声 → 拟合 → 记录 200 个测试点上的预测
# 重复 200 次

真实输出:

text
  真值函数:f(x) = sin(1.5πx) + 0.5x     (x ∈ [0,1])
  观测噪声:y = f(x) + N(0, σ²),σ = 0.30 → 噪声方差 σ² = 0.0900
  做法:每次随机抽 40 个点当训练集,重复 200 次,
        在固定的 200 个测试点上记录每一次的预测。
  注意:测试目标是【干净的真值 f(x)】,所以下表的实测误差里不含噪声项。

  模型                     偏差²        方差       偏差²+方差        实测误差
  --------------------------------------------------------------
  多项式 degree=1      0.16425   0.01641      0.18066     0.18066
  多项式 degree=3      0.00255   0.01046      0.01302     0.01302
  多项式 degree=5      0.00010   0.01567      0.01577     0.01577
  多项式 degree=9      0.01577   1.85578      1.87156     1.87156
  多项式 degree=15     5.192244238.39373   4243.58598  4243.58598
  --------------------------------------------------------------

  最小的实测误差出现在 degree=3(0.01302)
  degree=1 :偏差² 0.16425(大)  方差 0.01641(小)→ 高偏差
  degree=15:偏差² 5.19224(小)  方差 4238.39373(大)→ 高方差爆炸
  degree=15 的方差是 degree=3 的 405029 倍(实测误差 325986 倍)
  → 复杂度失控时,误差不是「慢慢变大」,是几个数量级地炸开。

★ 先看最右边两列:

"偏差²+方差" 和 "实测误差" 逐行相等(0.18066 / 0.01302 / 0.01577 / 1.87156 / 4243.58598)
   ↓
这不是巧合,是一个恒等式:
     对真值的误差 = 偏差² + 方差

★ 再看这张表讲的故事:

模型偏差²方差总误差病
degree=1(太简单)0.164250.016410.18066高偏差
degree=3(刚好)0.002550.010460.01302健康
degree=50.000100.015670.01577偏差已经到底,方差开始涨
degree=90.015771.855781.87156方差占了 99%
degree=15(太复杂)5.192244238.393734243.58598方差爆炸

三个可以直接读出来的结论:

① 复杂度上去,偏差【降】(0.16425 → 0.00010,降了 1600 倍)
② 复杂度再上去,方差【涨】(0.01046 → 1.85578 → 4238.39373)
③ 总误差是两者之和,所以它是一条【U 形曲线】:
     太简单 → 偏差主导;太复杂 → 方差主导

★ 第 ③ 条就是"调参"这件事的全部本质:

调参就是在 U 形曲线上找最低点。 往左走会碰到偏差,往右走会碰到方差 —— 两边都会让分数变差。

★ 顺便注意 degree=15 的量级:

方差 4238.39373 → 实测误差 4243.58598
   ↓
是 degree=3(0.01302)的 32.6 万倍
   ↓
复杂度失控时,误差不是"慢慢变大",是【几个数量级地炸开】

这就是为什么"把模型堆得更大"从来不是一个安全的默认策略。

同样 40 个点、200 次重抽:浅模型稳但偏,深模型平均下来对、单次很野

看图的方法:

左图(degree=1):
   60 条线几乎是同一条 → 方差极小
   但它们【整体】偏离红色真值线 → 偏差大

右图(degree=9):
   60 条线散布得很宽 → 方差大
   虚线(200 次平均)反倒贴得比较近 → 偏差小
   ↓
   "平均下来对,但单次很野" —— 这就是高方差模型的样子

复杂度上去,偏差降、方差涨;总误差是个 U 形

点开:为什么真实项目里"重抽 200 次"根本做不到,这个实验还有什么用?

你的真实项目里只有一个训练集,真值函数也不知道。 所以这个实验"不能直接照做"。但它给了你三样可以迁移的东西:

① 拆解的思想:你手里的"误差"总是这两种病之和, 所以看到误差时,第一个问题应该是"偏的多,还是散的多"。

② 两条可替代的估计(真实项目里能算的):

用【训练分 vs 测试分】估计方差:
   换一批数据分数就掉很多 → 方差大

用【测试分本身的水平】估计偏差:
   测试分连 0.94 都上不去 → 偏差大(模型表达力不够)

③ 一个校准工具(第 21 章的学习曲线):

把训练集按 10% → 100% 逐步放大,每个规模都画训练分与验证分
   ↓
两条线的形状能直接告诉你:
   是"数据不够"(验证线还在涨)
   还是"模型不够"(两条线早早挤在一起且都低)

★ 为什么这本书要先讲这个"做不到的实验"

因为它给你一套词汇。 有了"偏差 / 方差"这两个词,你和同事讨论"模型下一步怎么改"时, 对话会从"我觉得可以再调调参"变成:

"验证线在 8000 行以后走平了,测试分 0.9581 —— 这是特征问题,不是数据问题。"
                                    ↑
                          第 21 章的实测数字

后者是能拿去做决策的。

18.4 ★ 判据:怎么知道我得的是哪种病 ​

理论讲完了,现在给你一个真实项目里能用的判据。

判据只有一句话:

看训练分与测试分的【差距】。 差距大 = 方差问题;差距小但两条线都低 = 偏差问题。

下面在真实数据上把三种情形都跑出来(demos/ch18-bias-variance.py 的第 ② 段, 划分方式同前:按设备编号 GroupShuffleSplit,训练 17,244 行 / 48 支,测试 3,995 行 / 12 支)。

真实输出(a)把模型复杂度一路加上去(单棵树,限制深度):

text
  决策树 max_depth=1                 训练 0.71398  测试 0.71063  差距 0.00335   → 高偏差(欠拟合)
  决策树 max_depth=2                 训练 0.88745  测试 0.88319  差距 0.00426   → 高偏差(欠拟合)
  决策树 max_depth=3                 训练 0.92927  测试 0.92497  差距 0.00430   → 高偏差(欠拟合)
  决策树 max_depth=5                 训练 0.95573  测试 0.94433  差距 0.01139   → 两边都还行
  决策树 max_depth=8                 训练 0.97268  测试 0.95916  差距 0.01352   → 两边都还行
  决策树 max_depth=None              训练 1.00000  测试 0.93347  差距 0.06653   → 高方差(过拟合)

真实输出(b)复杂度不变,换"治方差"的药(随机森林加树):

text
  随机森林 1 棵(无限深)            训练 0.98812  测试 0.94096  差距 0.04717   → 高方差(过拟合)
  随机森林 5 棵(无限深)            训练 0.99640  测试 0.96002  差距 0.03638   → 高方差(过拟合)
  随机森林 25 棵(无限深)           训练 0.99798  测试 0.96444  差距 0.03355   → 高方差(过拟合)
  随机森林 200 棵(无限深)          训练 0.99838  测试 0.96503  差距 0.03335   → 高方差(过拟合)

真实输出(c)复杂度不变,换"治偏差"的药(浅树提升加树):

text
  HistGB 1 棵(depth=3, lr=0.1)    训练 0.17656  测试 0.17699  差距 -0.00043   → 高偏差(欠拟合)
  HistGB 10 棵(depth=3, lr=0.1)   训练 0.82914  测试 0.82409  差距 0.00505   → 高偏差(欠拟合)
  HistGB 50 棵(depth=3, lr=0.1)   训练 0.96930  测试 0.95897  差距 0.01034   → 两边都还行
  HistGB 300 棵(depth=3, lr=0.1)  训练 0.97874  测试 0.96558  差距 0.01316   → 两边都还行

两种药分别把什么推上去

★ 把三组数放在一起,判据就出来了:

你看到的说明该做什么
训练 0.90 / 测试 0.89(差距 ~0.00)两条线一起低偏差问题:加复杂度 / 加特征
训练 1.00 / 测试 0.93(差距 0.07)训练线飞到顶,测试线跟不上方差问题:Bagging / 降复杂度 / 加数据
训练 0.98 / 测试 0.964(差距 0.015)差距不大,分数也不低健康:该去改特征了

★ 反直觉但重要的一条:

"训练分高"本身不是优点,它只是"模型容量"的体温计。

单棵树能到训练 1.00000 —— 那不是成绩,那是它把训练集背下来的证据。

18.5 参数怎么影响偏差和方差(可操作对照表) ​

这一段是这一章最"能直接抄去用"的部分。

你调的参数偏差方差一句话解释
树的深度 max_depth ↑↓ 降↑ 涨树能表达更复杂的形状,也更容易记住噪声
树的数量 n_estimators ↑(森林)→ 不变↓ 降(然后饱和)平均只能压方差,压不了偏差
树的数量 ↑(提升)↓ 降↑ 涨(后期)提升是串行纠错,它真的在降偏差
max_features ↓(森林)→ 略升↓ 降树之间的差异更大,平均效果更强
学习率 learning_rate ↓ + 树数 ↑(提升)同水平↓ 降步子小、走得稳;代价是慢
加特征(信息量真的更多)↓ 降→ 不变唯一能同时不伤方差的降偏差手段
加数据→ 不变↓ 降只是让"记住噪声"变得更难
正则化(第 22 章)↑ 略升↓ 降用一点点偏差换很多方差
特征数很多且弱相关 ↑↑ 略升↑ 涨维度越高,"碰巧记住"的机会越多

★ 三行要看两遍:

① 森林加树:偏差不变、方差降 → 所以它治不了欠拟合(第 17 章那个 0.89407)
② 提升加树:偏差降 → 所以它能治欠拟合,但加多了会开始涨方差(第 19 章)
③ 加数据:只降方差 → 所以"模型太简单"这个病,加数据是白费

★ 一个特别好用的推论:

如果你的模型"训练低、测试也低"
   → 千万别去加数据(那只降方差,你的方差已经很小了)
   → 去加特征,或者换成能表达更复杂关系的模型

如果你的模型"训练高、测试低"
   → 加特征可能让情况更糟(特征越多,越容易记住噪声)
   → 先上 Bagging / 正则化 / 加数据

★ 这一节和第 21~22 章的关系:

第 21 章(学习曲线):把"数据够不够"这件事变成图
第 22 章(正则化)  :给你一个"用少量偏差换大量方差"的旋钮
第 27 章(阶段目标):把这三章合起来,给出"该停下来改特征"的判断

18.6 常见坑 ​

text
① 用训练分判断模型好坏
   → 打乱标签的森林训练 R² 也能到 0.85429;训练分是体温计,不是成绩单

② 看到"两条线都低"就去加数据
   → 两条线都低且贴得紧 = 高偏差,加数据只降方差,白费

③ 看到"训练分高"就以为模型好
   → 单棵树训练 1.00000、测试 0.93347,差距 0.06653

④ 把"差距大"当成唯一指标
   → 单棵树 max_depth=1 的差距只有 0.00335,但它在欠拟合

⑤ 用单次划分判断"加数据有没有用"
   → 练习 3 的实测:8 支 0.92187 → 24 支 0.97222 → 48 支 0.96487,不是单调的

⑥ 以为"降复杂度"总是安全
   → 从深度 8 降到深度 1,方差是降了,偏差涨到 0.71 的测试分

⑦ 把偏差/方差当成"两种模型"
   → 它们是同一份误差的两个来源,任何模型都同时有这两项

★ 其中 ② 和 ⑤ 是同一类错误:

都是"用错了诊断工具":
   ② 用错了处方(加数据治不了偏差)
   ⑤ 用错了测量(单次划分量不出趋势)
   ↓
共同点:结论看起来很有据(有数字),但那个数字回答的不是你问的问题

★ 一句话总结这一章:

"我的模型下一步该改哪里"这个问题, 答案不在模型里,在【训练分与测试分的差距】里。

18.7 完整示例与输出 ​

bash
cd code
python demos/ch18-bias-variance.py

真实输出(节选):

text
==============================================================================
第 18 章 · 偏差与方差:Bagging 治什么、治不了什么
==============================================================================

=== ① 在一个已知真值的世界里,把误差拆开 ===
  多项式 degree=1     0.16425     0.01641     0.18066     0.18066
  多项式 degree=3     0.00255     0.01046     0.01302     0.01302
  多项式 degree=15    5.19224  4238.39373  4243.58598  4243.58598

=== ② 回到真实数据:差距模式告诉你得的是哪种病 ===
  (a) 把模型复杂度一路加上去(单棵树,限制深度):
  决策树 max_depth=1                 0.71398  0.71063  0.00335  → 高偏差(欠拟合)
  决策树 max_depth=None              1.00000  0.93347  0.06653  → 高方差(过拟合)
  (b) 复杂度不变,换「治方差」的药(随机森林,加树数):
  随机森林 200 棵(无限深)          0.99838  0.96503  0.03335  → 高方差(过拟合)
  (c) 复杂度不变,换「治偏差」的药(提升,加树数;浅树):
  HistGB 300 棵(depth=3, lr=0.1)   0.97874  0.96558  0.01316  → 两边都还行
  (d) 数据量扫描(用几支设备训练,模型固定为森林 100 棵):
  用  8 支设备( 3158 行)训练   训练 0.99938  测试 0.92187  差距 0.07752
  用 16 支设备( 6118 行)训练   训练 0.99916  测试 0.96292  差距 0.03624
  用 24 支设备( 8860 行)训练   训练 0.99910  测试 0.97222  差距 0.02688
  用 32 支设备(11736 行)训练   训练 0.99890  测试 0.96758  差距 0.03133
  用 48 支设备(17244 行)训练   训练 0.99834  测试 0.96487  差距 0.03347

★ 注意 (d) 那一段:测试线不是单调上升的

 8 支 → 0.92187
16 支 → 0.96292
24 支 → 0.97222   ← 最高
32 支 → 0.96758
48 支 → 0.96487

这说明单次划分量不出"学习曲线"的形状(原因见练习 3)。 要回答"该不该加数据",必须用交叉验证 + 学习曲线(第 21 章)。

★ 一次性把这个 demo 跑完,你会得到一张"诊断表" —— 以后拿到任何一个新模型,先跑这三行:

python
train_r2 = model.score(X_tr, y_tr)
test_r2 = model.score(X_te, y_te)
print(f"训练 {train_r2:.5f}  测试 {test_r2:.5f}  差距 {train_r2 - test_r2:.5f}")

然后对着 18.4 的表查:

差距大?        → 方差问题 → 森林 / 正则 / 加数据
两条线都低?    → 偏差问题 → 加特征 / 加复杂度 / 提升
都不差?        → 别再调模型了,去改特征

本章练习 ​

练习 1:自己在合成世界里拆一次

用 §18.3 的方法,但改两个设置:

  1. 噪声放大到 sigma = 0.5(噪声方差 0.25);
  2. 复杂度取 degree ∈ {1, 3, 5, 9, 15}。

打印"偏差² / 方差 / 偏差²+方差 / 实测误差"四列(各 100 次重复即可), 然后回答:噪声变大之后,最优复杂度变了吗?为什么?

参考答案
python
import warnings
import numpy as np

def truth(x):
    return np.sin(1.5 * np.pi * x) + 0.5 * x

rng = np.random.default_rng(42)
sigma, n_rep, n_train = 0.50, 100, 40
x_test = np.linspace(0.02, 0.98, 200)
f_test = truth(x_test)

print(f"{'模型':<16}{'偏差²':>10}{'方差':>10}{'偏差²+方差':>13}{'实测误差':>11}")
for deg in (1, 3, 5, 9, 15):
    preds = np.zeros((n_rep, len(x_test)))
    mses = []
    for r in range(n_rep):
        x_tr = rng.uniform(0, 1, n_train)
        y_tr = truth(x_tr) + rng.normal(0, sigma, n_train)
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            preds[r] = np.polyval(np.polyfit(x_tr, y_tr, deg), x_test)
        mses.append(float(np.mean((preds[r] - f_test) ** 2)))
    bias2 = float(np.mean((preds.mean(axis=0) - f_test) ** 2))
    var = float(np.mean(preds.var(axis=0)))
    print(f"degree={deg:<8d}{bias2:>10.5f}{var:>10.5f}"
          f"{bias2 + var:>13.5f}{np.mean(mses):>11.5f}")

期望输出:

text
模型                偏差²        方差    偏差²+方差      实测误差
degree=1           0.16415     0.02451     0.18866     0.18866
degree=3           0.00266     0.02561     0.02827     0.02827
degree=5           0.00039     0.04792     0.04831     0.04831
degree=9           0.01114     0.65199     0.66312     0.66312
degree=15        129.87517 11407.01812 11536.89329 11536.89329

回答:最优复杂度没变(还是 degree=3),但有两件事变了

① "偏差² + 方差 = 实测误差"这个恒等式照样成立

0.16415 + 0.02451 = 0.18866  ✓
0.00266 + 0.02561 = 0.02827  ✓
129.87517 + 11407.01812 = 11536.89329  ✓

② 最优复杂度仍在 degree=3,但"上界"抬高了

σ=0.3 时:degree=1 的偏差² 是 0.16425
σ=0.5 时:degree=1 的偏差² 是 0.16415   ← 几乎一模一样
   ↓
低复杂度那一侧的偏差,几乎是"模型类的固有属性",
和噪声大小没什么关系

★ 但别把这句话推得太远:degree=15 的偏差² 从 5.19 涨到了 129.88。 那是因为高复杂度时"200 次预测的平均"本身就已经被噪声带跑了 —— "偏差只取决于模型和真值"这句话,在模型稳定的时候才成立。

★ 真正随噪声变化的是这一列:

方差(degree=9):σ=0.3 时 1.85578 → σ=0.5 时 0.65199
   ↑ 直接比较这两个数没有意义!

为什么不许直接比:

σ=0.3 的那张表用的是【200 次】重复,
σ=0.5 的这张表用的是【100 次】重复,
而且两次实验用的是同一条随机数流的不同片段。
   ↓
变的不只是 σ,还有重复次数和抽样
   ↓
要比较,必须【只改一个变量】

★ 这一题真正要你记住的(比数字重要):

① 恒等式 偏差² + 方差 = 实测误差,它在任何 σ 下都成立
② 复杂度太低 → 偏差主导;复杂度太高 → 方差主导;
   中间某处总误差最小,这个位置要靠实验找,不能靠猜
③ 做对照实验时,一次只改一个变量 ——
   我故意在这道题里放了一个"看起来能比其实不能比"的对照,
   就是要你养成先看"这两组数之间改了几个东西"的习惯

★ 一个更严格的版本(可以自己补做):固定 n_rep=200、只改 σ, 再跑一遍 σ ∈ {0.1, 0.3, 0.5},把三条曲线画在同一张图上。 你会看到:σ 越大,整条曲线越高,而且最低点略微往左移 (噪声越大,越应该选简单模型)。

练习 2:给四个模型下诊断

用同一份数据、同一个划分,打印下面四个模型的训练/测试/差距, 并对每一个给出诊断(高偏差 / 高方差 / 健康):

  1. 线性回归;
  2. 单棵树 max_depth=2;
  3. 单棵树(不限深度);
  4. 随机森林 200 棵(不限深度)。
参考答案
python
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
from sklearn.tree import DecisionTreeRegressor

FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
         "coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]

df = pd.read_csv("data/battery/soh_features.csv")
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2,
                                random_state=42).split(X, y, groups=groups))
X_tr, X_te, y_tr, y_te = X.iloc[tr], X.iloc[te], y.iloc[tr], y.iloc[te]

models = {
    "线性回归": LinearRegression(),
    "单棵树 max_depth=2": DecisionTreeRegressor(max_depth=2, random_state=42),
    "单棵树 无限深": DecisionTreeRegressor(random_state=42),
    "随机森林 200 棵": RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=-1),
}
for name, m in models.items():
    m.fit(X_tr, y_tr)
    a, b = r2_score(y_tr, m.predict(X_tr)), r2_score(y_te, m.predict(X_te))
    gap = a - b
    verdict = ("高偏差(欠拟合)" if b < 0.94 else
               "高方差(过拟合)" if gap > 0.02 else "健康")
    print(f"{name:<20s} 训练 {a:.5f}  测试 {b:.5f}  差距 {gap:.5f}  → {verdict}")

期望输出:

text
线性回归              训练 0.94296  测试 0.94253  差距 0.00043  → 健康
单棵树 max_depth=2    训练 0.88745  测试 0.88319  差距 0.00426  → 高偏差(欠拟合)
单棵树 无限深         训练 1.00000  测试 0.93347  差距 0.06653  → 高方差(过拟合)
随机森林 200 棵       训练 0.99838  测试 0.96503  差距 0.03335  → 高方差(过拟合)

★ 三处值得盯着看的:

① 线性回归是"健康",但它不是最好的 测试 0.94253 < 森林 0.96503。"没有明显病"和"分数最高"是两件事。

② 单棵树 max_depth=2 是典型的高偏差 训练 0.88745、测试 0.88319、差距只有 0.00426 —— 两条线都低、而且贴得很紧,这就是欠拟合的样子。

③ 森林 200 棵按判据仍是"高方差"(差距 0.03335 > 0.02) 它只是把病减轻了一半(0.06653 → 0.03335),没有根治。 要根治得动特征(第 27 章的结论),或者换一条完全不同的路(第 19 章)。

★ 一个可以自动化的写法:

python
def diagnose(train_r2, test_r2, low=0.94, gap=0.02):
    if test_r2 < low:
        return "高偏差(欠拟合)"
    if train_r2 - test_r2 > gap:
        return "高方差(过拟合)"
    return "健康"

⚠️ 注意这两个阈值(0.94 / 0.02)是这本书为这个问题定的经验值, 换个数据集必须重新定 —— 定阈值的依据是"业务能接受的下限"和 "分数波动的量级"(第 25 章的置信区间会给出后者的估计)。

练习 3:加数据还有用吗?

用随机森林(100 棵)在下面每个规模上训练,打印测试 R² 和比上一档的增量:

训练用的设备数816243248

然后回答:

  1. 这张表里的测试线是单调上升的吗?
  2. 如果它不是单调的,"加数据没用"这个结论下得对吗?缺了什么?
  3. 要真正回答"该不该加数据",你还需要什么工具?
参考答案
python
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit

FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
         "coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]

df = pd.read_csv("data/battery/soh_features.csv")
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2,
                                random_state=42).split(X, y, groups=groups))
X_tr, X_te, y_tr, y_te = X.iloc[tr], X.iloc[te], y.iloc[tr], y.iloc[te]

cells = list(np.random.default_rng(42).permutation(groups.iloc[tr].unique()))

prev = None
for k in (8, 16, 24, 32, 48):
    keep = set(cells[:k])
    mask = groups.iloc[tr].isin(keep).to_numpy()
    m = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)
    m.fit(X_tr[mask], y_tr[mask])
    b = r2_score(y_te, m.predict(X_te))
    gain = "" if prev is None else f"  比上一档 {b - prev:+.5f}"
    print(f"{k:2d} 支设备({int(mask.sum()):5d} 行)  测试 R² {b:.5f}{gain}")
    prev = b

期望输出:

text
 8 支设备( 3158 行)  测试 R² 0.92187
16 支设备( 6118 行)  测试 R² 0.96292  比上一档 +0.04105
24 支设备( 8860 行)  测试 R² 0.97222  比上一档 +0.00931
32 支设备(11736 行)  测试 R² 0.96758  比上一档 -0.00465
48 支设备(17244 行)  测试 R² 0.96487  比上一档 -0.00270

① 不是单调上升的。它在 24 支设备时最高(0.97222),之后反而往下走。

 8 支:0.92187
16 支:0.96292   +0.04105
24 支:0.97222   +0.00931   ← 最高点
32 支:0.96758   -0.00465   ← 掉了
48 支:0.96487   -0.00270   ← 又掉了

② 不对 —— 这张表根本不足以支撑"加数据有没有用"的结论。

★ 为什么:

测试集只有 12 支设备
   ↓
"换一批训练设备"会让测试分整体上下波动
   ↓
这个波动(0.92 → 0.97,跨 0.05)比"数据量翻 6 倍"带来的收益还大
   ↓
你量到的主要是【划分的运气】,不是【数据量的效果】

★ 这一题真正要你学的是"识别无效实验":

现象说明
用了一个固定的测试集✅ 对:这样才有可比性
只做了一次划分❌ 不够:单次划分的噪声掩盖了要测的效应
结论"加数据没用"❌ 下早了:你还没有把"运气"和"效果"分开

③ 要真正回答这个问题,需要两样东西:

① 多个划分(交叉验证):
   第 11 章教过 —— 用 GroupKFold,每个规模跑 5 折,
   报告「均值 ± 折间标准差」,而不是一个孤零零的数字

② 学习曲线的形状(第 21 章):
   把"训练规模 → 训练分/验证分"画成两条线
   验证线还在涨 → 加数据有用;已经平了 → 加数据没用

★ 第 21 章的实测结论(提前给你):

学习曲线(3 折 × 5 个规模):
   2832 行 → 0.9474
   ...
   14160 行 → 0.9581
   ↓
8000 行之后走平

注意它和本题的差别:

本题:单次划分,8 支 → 0.92187,24 支 → 0.97222(抖得没规律)
第 21 章:3 折平均,2832 行 → 0.9474,14160 行 → 0.9581(单调、平滑)
   ↓
同一个问题、同一份数据,只是把"一次"换成"多次平均"
   ↓
结论从"看不出来"变成"8000 行之后走平"

★ 这一题是全书"评估方法决定你能看到什么"的最好例子:

不是数据没有规律, 是你的测量工具精度不够 —— 单次划分只给你一个带噪声的点, 而学习曲线给你一条线。

★ 一个必须提醒的坑(顺带纠正本题的旧版答案):

❌ "测试线走平了 → 瓶颈在特征"
   ↑ 这句话没错,但前提是"那条线是用交叉验证量出来的"

✅ "单次划分下测试线不单调 → 先别下结论,
    用 GroupKFold 把每个规模的均值±标准差量出来,再看它有没有走平"

训练分那边倒是很"稳":8 支和 48 支的训练 R² 都在 0.998 以上 —— 它什么也告诉不了你,因为森林 200 棵本来就是无限深树, 再少的数据它也能背下来。这就是"训练分是体温计"的又一例证。

面向工程实践:把"诊断"变成流程里的一步 ​

① 每次训练完,先看三个数,再看绝对值

python
def train_and_report(model, X_tr, y_tr, X_te, y_te, name=""):
    model.fit(X_tr, y_tr)
    a, b = model.score(X_tr, y_tr), model.score(X_te, y_te)
    print(f"{name:<24s} 训练 {a:.5f}  测试 {b:.5f}  差距 {a - b:.5f}")
    return a, b

这三个数要一起进模型卡(model card):

❌ "模型 R² = 0.965"
✅ "测试 R² = 0.96503;训练 R² = 0.99838;差距 0.03335(按设备留出)"
                                    ↑
                        这一项说明"它还有多少过拟合没治"

② 为什么"差距"这一项在评审里特别值钱

离线分数高 → 上线掉分,最常见的原因不是"模型弱",是"过拟合没被发现"
   ↓
而"训练 0.998 / 测试 0.965"这种写法的报告,
一眼就能让人看出"这个模型有 0.033 的过拟合"
   ↓
它比"我们的模型 R² 0.965,非常好"诚实得多,也更难被反驳

③ 一个常见的组织问题:谁来决定"该加数据还是该加特征"

答案:让学习曲线来决定,不要开会吵。

第 21 章会给出一条硬判据:
   验证线还在涨 → 加数据的收益可以量化(每加 2,000 行涨多少)
   验证线已经平 → 加数据的收益约为 0,把人力投到特征上

★ 这一章的知识,最后会变成一句话写进项目文档:

本模型的误差以方差为主(训练 0.99838 / 测试 0.96503,差距 0.03335); 学习曲线显示验证线在约 8,000 行后走平,因此下一步投入在特征迭代, 不在数据扩容,也不在模型调参。

能写出这句话,说明你已经不需要靠"再试一个模型"来做决定了。


下一章:第 19 章 · 梯度提升:串行纠错