第 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 的第 ① 段。
做法:
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 次真实输出:
真值函数: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.16425 | 0.01641 | 0.18066 | 高偏差 |
| degree=3(刚好) | 0.00255 | 0.01046 | 0.01302 | 健康 |
| degree=5 | 0.00010 | 0.01567 | 0.01577 | 偏差已经到底,方差开始涨 |
| degree=9 | 0.01577 | 1.85578 | 1.87156 | 方差占了 99% |
| degree=15(太复杂) | 5.19224 | 4238.39373 | 4243.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 万倍
↓
复杂度失控时,误差不是"慢慢变大",是【几个数量级地炸开】这就是为什么"把模型堆得更大"从来不是一个安全的默认策略。

看图的方法:
左图(degree=1):
60 条线几乎是同一条 → 方差极小
但它们【整体】偏离红色真值线 → 偏差大
右图(degree=9):
60 条线散布得很宽 → 方差大
虚线(200 次平均)反倒贴得比较近 → 偏差小
↓
"平均下来对,但单次很野" —— 这就是高方差模型的样子
点开:为什么真实项目里"重抽 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)把模型复杂度一路加上去(单棵树,限制深度):
决策树 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)复杂度不变,换"治方差"的药(随机森林加树):
随机森林 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)复杂度不变,换"治偏差"的药(浅树提升加树):
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 常见坑
① 用训练分判断模型好坏
→ 打乱标签的森林训练 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 完整示例与输出
cd code
python demos/ch18-bias-variance.py真实输出(节选):
==============================================================================
第 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 跑完,你会得到一张"诊断表" —— 以后拿到任何一个新模型,先跑这三行:
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 的方法,但改两个设置:
- 噪声放大到
sigma = 0.5(噪声方差 0.25); - 复杂度取
degree ∈ {1, 3, 5, 9, 15}。
打印"偏差² / 方差 / 偏差²+方差 / 实测误差"四列(各 100 次重复即可), 然后回答:噪声变大之后,最优复杂度变了吗?为什么?
参考答案
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}")期望输出:
模型 偏差² 方差 偏差²+方差 实测误差
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:给四个模型下诊断
用同一份数据、同一个划分,打印下面四个模型的训练/测试/差距, 并对每一个给出诊断(高偏差 / 高方差 / 健康):
- 线性回归;
- 单棵树
max_depth=2; - 单棵树(不限深度);
- 随机森林 200 棵(不限深度)。
参考答案
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}")期望输出:
线性回归 训练 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 章)。
★ 一个可以自动化的写法:
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² 和比上一档的增量:
| 训练用的设备数 | 8 | 16 | 24 | 32 | 48 |
|---|
然后回答:
- 这张表里的测试线是单调上升的吗?
- 如果它不是单调的,"加数据没用"这个结论下得对吗?缺了什么?
- 要真正回答"该不该加数据",你还需要什么工具?
参考答案
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期望输出:
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 棵本来就是无限深树, 再少的数据它也能背下来。这就是"训练分是体温计"的又一例证。
面向工程实践:把"诊断"变成流程里的一步
① 每次训练完,先看三个数,再看绝对值
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 行后走平,因此下一步投入在特征迭代, 不在数据扩容,也不在模型调参。
能写出这句话,说明你已经不需要靠"再试一个模型"来做决定了。