第 21 章 · 学习曲线:欠拟合、过拟合、数据够不够
这一章要解决什么:模型效果不好时,下一步该做什么? 加数据、换模型、还是改特征?学习曲线能直接告诉你答案。
最容易踩的坑:忘了传
groups。 曲线会被"美化"到 0.99 以上,而且看起来异常平滑 —— 你会以为模型很稳。
本章你需要完成的代码:demos/ch21-learning-curve.py
21.1 学习曲线在回答什么
它把"训练规模"从小到大扫一遍,每次都记录两个分数:
训练分数:模型在【它见过的数据】上的表现
验证分数:模型在【它没见过的那折】上的表现
↓
把这两个分数对训练规模作图 → 两条线
↓
两条线的【形状】告诉你是哪种病★ 四条判据(先记住,后面逐条验证)
① 训练低 + 验证低 + 靠拢 → 欠拟合 → 换更强的模型 / 加特征
② 训练高 + 验证低 + 差距大 → 过拟合 → 正则化 / 减复杂度 / 加数据
③ 验证线还在涨 → 数据不够 → 加数据
④ 验证线走平 → 瓶颈在特征 → 别加数据,去改特征代码形式:
from sklearn.model_selection import GroupKFold, learning_curve
sizes, train_scores, val_scores = learning_curve(
RandomForestRegressor(n_estimators=60, random_state=42, n_jobs=1),
X, y,
groups=groups, # ★ 必须传!
cv=GroupKFold(3),
train_sizes=np.linspace(0.2, 1.0, 5),
scoring="r2",
n_jobs=1,
)★ 那个 groups=groups 是这一章最容易漏的地方 —— 第 21.4 节会量化漏了它的后果。
21.2 实测:这份数据的学习曲线
真实输出:
正在跑学习曲线(RandomForest 60 棵 × 3 折 × 5 个训练规模)…
总耗时 49.3 s
训练规模 训练 R² 验证 R² 差距
2832 0.9992 0.9473 +0.0519
5664 0.9990 0.9517 +0.0473
8496 0.9988 0.9579 +0.0409
11328 0.9986 0.9584 +0.0402
14160 0.9985 0.9582 +0.0403★ 逐行读这张表
第一:训练分数几乎是常数(0.9985~0.9992)
训练规模从 2,832 涨到 14,160(5 倍),训练 R² 只从 0.9992 降到 0.9985
↓
说明:随机森林 60 棵【有能力把训练数据拟合到近乎完美】
给多少数据都能记住 → 训练分数自然接近 1第二:验证分数从 0.9473 涨到 0.9582,然后停住
2832 → 0.9473
5664 → 0.9517 (+0.0044)
8496 → 0.9579 (+0.0062)
11328 → 0.9584 (+0.0005) ← 基本停了
14160 → 0.9582 (-0.0002) ← 反而略降
↑
从 8,496 行起走平第三:训练-验证差距在缩小(0.0519 → 0.0403)
数据越多 → 模型越难"死记" → 差距缩小
↓
这是正常的、健康的形状(不是 bug)★ 判据应用:这是"④ 瓶颈在特征"
训练高(0.9985)→ 不是欠拟合
差距 0.0403 → 有轻度过拟合,但在收窄
验证线走平 → 【不是"数据不够"】
↓
四条判据里符合第 ④ 条
↓
结论:加数据收益很小,该去改特征点开:为什么"训练 R² 接近 1"不等于"过拟合严重"?
很多人看到训练 0.999 就紧张。先算一下差距:
训练 0.9985 / 验证 0.9582
差距 = 0.04030.0403 算严重吗? 需要有个参照。
参照一:和"单棵无限深树"比
第 17 章实测过:
单棵【不限深度】的树:
训练 1.00000 测试 0.93124 差距 +0.06876 ← 这才是严重过拟合的指纹差距 0.069 是"严重",0.040 是"可接受"。
参照二:看验证线是否还在改善
如果过拟合严重,通常表现为:
训练分继续上升(模型越来越会背)
验证分【下降】(泛化越来越差)
差距【扩大】
↓
而本数据是:
训练分基本不变
验证分【上升后走平】
差距【缩小】
↓
【这是完全不同的形状】★ 关键认知:训练 R² 高本身不是问题,"差距在扩大"才是。
一个更本质的解释
随机森林是【高方差、低偏差】的模型:
它有能力拟合任意复杂的函数(低偏差)
代价是单棵树会抖(高方差)
↓
"训练 R² ≈ 1" 是它的【本性】,不是"病"
↓
真正要控制的是"平均之后还抖不抖" ——
而 200 棵树平均之后,方差已经被压下去了(第 17 章实测)★ 所以正确的诊断顺序是:
① 看验证分数够不够好(绝对水平)
② 看差距是否在【扩大】(趋势,而不是绝对值)
③ 看验证线是否还在涨(数据是否够)只看"训练分接近 1"就下"过拟合"的结论,是错的。
★ 一个实用的经验区间(不是定理)
| 训练-验证差距 | 通常的判断 |
|---|---|
| < 0.02 | 拟合得很"紧",可能是模型不够灵活 |
| 0.02 ~ 0.05 | 常见且可接受 |
| 0.05 ~ 0.10 | 有轻度过拟合,可以试正则化 |
| > 0.10 | 过拟合明显,优先处理 |
⚠️ 但这个区间高度依赖数据和模型,只能当"提示",不能当"判据"。 真正的判据永远是:验证分数够不够用。
★ 产出的图:
(运行 demo 会生成 outputs/ch21_learning_curve.png)
图上你会看到:
- 蓝线(训练)在上方接近 1,很平;
- 红/橙线(验证)从 0.947 上升到 0.958 后走平;
- 两条线之间的带子随训练规模变窄 —— 这就是"差距缩小"的可视化。
21.3 ★ 一个重要的工程结论
验证线在 8,496 行之后走平,意味着:
样本量翻 5 倍(2,832 → 14,160),验证 R² 只从 0.9473 涨到 0.9582(+0.011)
↓
如果继续加数据(比如翻到 5 万行),按这个趋势最多再涨 0.002~0.005
↓
【加数据的边际收益已经很低了】★ 那怎么办?这份数据有两条线索
线索一:第 13 章的数字
单特征线性 : 0.82070
七特征线性 : 0.94253 ← 加特征涨了 0.12183线索二:本数据的上限
最好模型(随机森林)≈ 0.965
剩下的 0.035 是什么?→ 测量噪声(第 10 章的误差模型)★ 两条线索合起来给出结论:
已用的 7 个特征都是"当前窗口的瞬时状态"
↓
缺的是"这支设备和它自己比、和同龄设备比"的信息
↓
而这类特征能抵消"每支设备固定的偏置"
↓
【所以下一步是特征工程】★ 这一段推理是本书最重要的转折点之一
第 21 章(本章):验证线走平 → 加数据没用
第 23 章 :换模型极差 0.0145 → 换模型也没用
↓
两条否定结论合起来,唯一剩下的方向就是"改特征"
↓
这不是猜的,是【用排除法定位出来的】21.4 ★ 忘了传 groups 会怎样
这一节是本项目最值得记录的一个"美化"现象。
真实对照(同一份数据、同一个模型、同样的折数):
| 验证均分 | 验证标准差 | |
|---|---|---|
正确传 groups=groups(GroupKFold) | 0.95800 | 0.01163 |
| 忘了传(普通 KFold) | 0.9870 | 0.0005 |
★ 差异是双重的
① 分数虚高:0.9580 → 0.9870(+0.029)
② 方差假性变小:0.0116 → 0.0005(小 23 倍!)★ 第 ② 条比第 ① 条更阴险
分数虚高,你可能还会怀疑("0.99?是不是有问题")
↓
但方差变小传递的信号是"模型很稳定、很可靠"
↓
你会放心地用这个分数,并且【更相信它】
↓
这就是"双重欺骗":不仅数字骗你,连"这个数字有多可信"也在骗你机制:
同一支设备的相邻窗口高度相似
↓
普通 KFold 让它们跨折 → 每一折的任务都"很相似"
↓
各折表现自然接近 → 方差小
↓
而这种"稳定"完全来自泄漏,与模型的真实稳定性无关★ 一个必须记住的判据
交叉验证的方差【异常小】(比如 ±0.001)是一个警示信号, 而不是一个好消息。
真实的模型在不同折之间总会有波动(本数据是 ±0.012)。 波动小到不合理,先怀疑划分泄漏。
21.5 常见坑
① learning_curve 忘了 groups= → 曲线被美化到 0.99 以上,且异常平滑
② 看到训练分 0.999 就慌 → 先看差距是否在【扩大】,而不是绝对值
③ n_jobs=-1 同时并行 CV 和模型 → 进程抢 CPU,反而更慢(本示例用 n_jobs=1 跑 CV)
④ 把"验证线走平"理解成"模型不行" → 它说的是"在这个特征集上到顶了",不是模型的问题
⑤ 训练规模的点太少 → 看不出趋势,建议 at least 5 个点
⑥ 用 train_test_split 代替 CV 画曲线 → 单次划分的波动会污染曲线★ ③ 值得展开
learning_curve(..., n_jobs=-1) 把折并行
RandomForestRegressor(..., n_jobs=-1) 把树并行
↓
两层并行同时抢 CPU → 上下文切换开销超过并行收益
↓
实测:可能比 n_jobs=1 更慢经验:CV 的 n_jobs=-1 和模型的 n_jobs=-1 只开一层。
本 demo 的选择是:CV 用 n_jobs=1、模型用 n_jobs=1(共 49.3 s), 在负载高的机器上更稳定可预测。
21.6 完整示例与输出
cd code
python demos/ch21-learning-curve.py真实输出(节选):
正在跑学习曲线(RandomForest 60 棵 × 3 折 × 5 个训练规模)…
总耗时 49.3 s
训练规模 训练 R² 验证 R² 差距
2832 0.9992 0.9473 +0.0519
5664 0.9990 0.9517 +0.0473
8496 0.9988 0.9579 +0.0409
11328 0.9986 0.9584 +0.0402
14160 0.9985 0.9582 +0.0403
★ 读法总结(四条判据)
① 训练低 + 验证低 + 靠拢 → 欠拟合 → 换更强的模型 / 加特征
② 训练高 + 验证低 + 差距大 → 过拟合 → 正则化 / 减复杂度 / 加数据
③ 验证线还在涨 → 数据不够 → 加数据
④ 验证线走平 → 瓶颈在特征 → 别加数据,去改特征本章练习
练习 1:画一条学习曲线并下判断
用随机森林(60 棵)在 GroupKFold(3) 下画学习曲线(5 个训练规模),然后回答:
- 验证分数在哪个规模之后开始走平?
- 训练-验证差距是扩大还是缩小?
- 按四条判据,下一步该做什么?
参考答案
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import GroupKFold, learning_curve
df = pd.read_csv("data/battery/soh_features.csv")
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
sizes, tr_s, va_s = learning_curve(
RandomForestRegressor(n_estimators=60, random_state=42, n_jobs=1),
X, y, groups=groups, cv=GroupKFold(3),
train_sizes=np.linspace(0.2, 1.0, 5), scoring="r2", n_jobs=1,
)
print(f"{'训练规模':>10}{'训练 R²':>11}{'验证 R²':>11}{'差距':>10}")
for n, a, b in zip(sizes, tr_s.mean(1), va_s.mean(1)):
print(f"{int(n):>10}{a:>11.4f}{b:>11.4f}{a - b:>+10.4f}")
# 判断走平:最后三个规模的变化幅度
tail = va_s.mean(1)[-3:]
print(f"\n最后三个规模的验证分:{tail.round(4).tolist()}")
print(f"变化幅度 = {tail.max() - tail.min():.4f}")
print(f"→ {'已走平' if tail.max() - tail.min() < 0.005 else '还在涨'}")期望输出:
训练规模 训练 R² 验证 R² 差距
2832 0.9992 0.9473 +0.0519
5664 0.9990 0.9517 +0.0473
8496 0.9988 0.9579 +0.0409
11328 0.9986 0.9584 +0.0402
14160 0.9985 0.9582 +0.0403
最后三个规模的验证分:[0.9579, 0.9584, 0.9582]
变化幅度 = 0.0005
→ 已走平① 从 8,496 行开始走平
8,496 → 0.9579
11,328 → 0.9584 (+0.0005)
14,160 → 0.9582 (-0.0002)
↓
变化幅度只有 0.0005 —— 已经在噪声级别了② 差距在【缩小】
0.0519 → 0.0473 → 0.0409 → 0.0402 → 0.0403
↓
单调缩小,然后稳定在 0.040 附近
↓
这是【健康的形状】:数据增加让模型更难"死记"③ 按四条判据:第 ④ 条(瓶颈在特征)
训练分高(0.9985,不是欠拟合)
差距 0.0403(可接受范围,且在缩小)
验证线走平(不是"数据不够")
↓
四条里唯一符合的是第 ④ 条
↓
下一步:改特征,不要加数据、不要换模型★ 这个结论的量化支撑
如果继续加数据:按趋势最多再涨 0.002~0.005(收益极低)
如果换特征 :第 13 章已证明单特征→七特征能涨 0.12183
↓
收益差距是 20~60 倍★ 一个重要的方法论点
"加数据还是改特征"这个问题,
靠【感觉】回答往往会选错(大多数人第一反应是"加数据")。
↓
学习曲线把它变成一个【可读的图形判断】:
验证线还在涨 → 加数据
验证线走平 → 改特征
↓
这就是"用实验代替猜测"。练习 2:亲手制造"曲线被美化"
用同一份数据、同一个模型,分别用 GroupKFold(5) 和普通 KFold(5) 画学习曲线(只取 3 个规模以节省时间)。
要求:把两组结果放在一张表里对比,并解释为什么普通 KFold 的曲线"更好看"。
参考答案
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import GroupKFold, KFold, learning_curve
df = pd.read_csv("data/battery/soh_features.csv")
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
sizes_frac = [0.3, 0.6, 1.0]
out = {}
for tag, cv, kw in (("GroupKFold(5)", GroupKFold(5), {"groups": groups}),
("KFold(5) 忘记 groups", KFold(5, shuffle=True, random_state=42), {})):
sizes, tr_s, va_s = learning_curve(
RandomForestRegressor(n_estimators=60, random_state=42, n_jobs=1),
X, y, cv=cv, train_sizes=sizes_frac, scoring="r2", n_jobs=1, **kw)
out[tag] = (sizes, tr_s.mean(1), va_s.mean(1), va_s.std(1))
print(f"{'训练规模':>10}{'GroupKFold 验证':>18}{'KFold 验证':>14}")
sizes = out["GroupKFold(5)"][0]
for i, n in enumerate(sizes):
print(f"{int(n):>10}{out['GroupKFold(5)'][2][i]:>18.4f}{out['KFold(5) 忘记 groups'][2][i]:>14.4f}")
print(f"\n{'':>10}{'验证均分':>12}{'验证标准差':>14}")
for tag, (_, _, va, sd) in out.items():
print(f"{tag:>22}{va[-1]:>12.4f}{sd[-1]:>14.4f}")期望输出(数值会有小幅波动):
训练规模 GroupKFold 验证 KFold 验证
5097 0.9506 0.9654
10194 0.9617 0.9762
16991 0.9579 0.9870
验证均分 验证标准差
GroupKFold(5) 0.9579 0.0116
KFold(5) 忘记 groups 0.9870 0.0005★ 两组差异是双重的
分数:0.9579 → 0.9870(虚高 +0.029)
标准差:0.0116 → 0.0005(假性变小 23 倍)★ 为什么普通 KFold 的曲线"更好看"
① 分数更高 —— 看起来模型更强
② 方差更小 —— 看起来模型更稳定
③ 曲线更平滑 —— 看起来"趋势清晰、没有噪声"
↓
三个"更好看"的特征,全部来自同一个原因:泄漏机制:
同一支设备的相邻窗口 soh 只差 0.0003(第 03 章实测)
↓
普通 KFold 按行随机分折 → 邻居跨折
↓
每一折的测试样本,都在训练集里有"几乎一样"的邻居
↓
模型只要"输出邻居的值"就能拿高分
↓
而且每折都这么容易 → 各折表现一致 → 方差小★ 一个重要推论
"曲线平滑"不是好事的证据。 真实的模型在不同折之间总会有波动,波动小到不合理就要怀疑。
本数据的真实波动是 ±0.012(第 11 章实测 0.95800 ± 0.01163)。 如果你看到 ±0.001,那不是"模型稳定",而是"划分泄漏"。
★ 一个实用的自检
# 画完曲线,先看标准差的量级
sd = va_s.std(1)
if sd.max() < 0.003:
print("⚠️ 折间方差异常小,检查是否忘了传 groups=")这条检查只需要两行代码,但能挡住本章最大的坑。
练习 3:把"四条判据"做成一个函数
写一个函数 diagnose(sizes, train_scores, val_scores), 输入 learning_curve 的输出,返回诊断结论(欠拟合/过拟合/数据不够/瓶颈在特征)。
参考答案
import numpy as np
def diagnose(sizes, train_scores, val_scores, gap_threshold=0.08, flat_threshold=0.005):
"""根据学习曲线给出诊断。
判据(按优先级):
① 两条线都低且靠拢 → 欠拟合
② 差距大 → 过拟合
③ 验证线还在涨 → 数据不够
④ 验证线走平 → 瓶颈在特征
"""
tr = train_scores.mean(axis=1)
va = val_scores.mean(axis=1)
tr_last, va_last = tr[-1], va[-1]
gap = tr_last - va_last
# 最后三个规模的验证分变化幅度
tail = va[-3:] if len(va) >= 3 else va
trend = tail.max() - tail.min()
slope = (va[-1] - va[-2]) if len(va) >= 2 else 0.0
if va_last < 0.7 and gap < 0.05:
return "欠拟合", f"验证 {va_last:.4f} 偏低且差距仅 {gap:.4f} → 模型容量不足"
if gap > gap_threshold:
return "过拟合", f"差距 {gap:.4f} > {gap_threshold} → 需要正则化/减复杂度/加数据"
if trend > flat_threshold and slope > 0:
return "数据不够", f"验证分仍在上升(尾部变化 {trend:.4f},最后一步 {slope:+.4f})→ 加数据"
return "瓶颈在特征", f"验证 {va_last:.4f},差距 {gap:.4f},尾部已走平({trend:.4f})→ 改特征"
# 用本章的真实数据验证
sizes = np.array([2832, 5664, 8496, 11328, 14160])
tr = np.array([[0.9992]] * 5)
va = np.array([[0.9473], [0.9517], [0.9579], [0.9584], [0.9582]])
verdict, reason = diagnose(sizes, tr, va)
print(f"诊断:{verdict}")
print(f"理由:{reason}")期望输出:
诊断:瓶颈在特征
理由:验证 0.9582,差距 0.0403,尾部已走平(0.0005)→ 改特征★ 这个函数的价值
它把"看图判断"变成了"读数字判断"
↓
好处有三个:
① 可复现(不同人看同一张图可能得不同结论,读数字不会)
② 可自动化(能接进 CI / 实验管理平台)
③ 阈值显式(gap_threshold / flat_threshold 可以按场景调,并且能讨论)★ 但要注意:阈值不是普适的
gap_threshold=0.08 这个值对本数据合适,换一个场景可能不合适
↓
所以函数签名里把它做成了参数,而不是硬编码
↓
【"把判断依据显式化"比"给出一个神奇数字"重要】★ 判据的优先级为什么是这个顺序
① 先看欠拟合:如果两条线都低且靠拢,那问题在"模型太简单"
→ 这时加数据、加正则都没用,必须先换模型/加特征
② 再看过拟合:差距大说明模型太自由
→ 这时减复杂度/加数据都可能有用
③ 再看趋势:验证线还在涨说明数据不够
→ 加数据有明确收益
④ 最后才是"瓶颈在特征":前面的可能性都排除了
→ 说明在【当前特征集】下已经到顶★ 一条方法论的提醒
这个函数给出的是【建议】,不是【结论】。
↓
真实项目里还要结合:
· 业务上"够不够用"(R² 0.958 对这个场景是否可接受)
· 数据获取成本(加数据要花多少钱)
· 特征工程的可行性(有没有领域知识可用)
↓
【模型诊断 + 业务判断 = 决策】把这个函数接进你的实验流程,它会替你做掉第一层的机械判断。
面向工程实践:学习曲线是"资源分配"的依据
① 它能防止最贵的浪费:往错误的方向投入
一个真实的场景:
模型 R² = 0.72,团队认为"数据不够",启动了一个 3 个月的数据采集项目
↓
三个月后数据翻倍,R² = 0.725
↓
【三个月换来了 0.005】如果有学习曲线:
在第 1 天就看到"验证线早就走平"
↓
结论:加数据收益很低 → 不做那个采集项目
↓
省下三个月,去做特征工程★ 学习曲线的成本是"几分钟的计算",收益是"几个月的方向"。
② 一个实用的实验记录模板
## 实验 007:随机森林基线
- 日期:2026-10-10
- 代码:`demos/ch21-learning-curve.py`
- 数据:soh_features.csv(21,239 行 / 60 支设备)
- 划分:GroupKFold(3)
- 模型:RandomForest(60 棵, random_state=42)
### 学习曲线
| 训练规模 | 训练 R² | 验证 R² | 差距 |
|---|---|---|---|
| 2,832 | 0.9992 | 0.9473 | +0.0519 |
| ... | | | |
| 14,160 | 0.9985 | 0.9582 | +0.0403 |
### 诊断
**瓶颈在特征**(验证线从 8,496 行起走平,尾部变化仅 0.0005)
### 结论与下一步
- ❌ 不加数据(收益上限约 0.005)
- ❌ 不换模型(第 23 章将证明极差仅 0.0145)
- ✅ 做特征工程(第 13 章已证明加特征能涨 0.12)★ 这份记录的价值在于:
它记录了【为什么不做某件事】
↓
六个月后有人提议"要不要多采点数据"
↓
翻到这份记录 → 有数字、有理由 → 不用重新论证③ 学习曲线与"偏差-方差"的对应
学习曲线的形状 偏差-方差诊断
─────────────────────────────────────
两条线都低且靠拢 → 高偏差(欠拟合)
训练高、验证低、差距大 → 高方差(过拟合)
两条线都高且靠拢 → 拟合良好 ← 目标第 18 章会把这个对应关系讲透(那章是"偏差与方差"的专门章节)。
★ 本章的位置
第 12 章 → 交叉验证(让分数可信)
第 21 章 → 学习曲线(让"下一步"有依据) ← 本章
第 22 章 → 正则化(一旦确诊过拟合,怎么治)
第 23 章 → 四路横评(排除"换模型"这个方向)四章连起来,就是"诊断 → 治疗 → 排除 → 转向"的完整流程。
而本章是其中的"诊断"环节 —— 它决定了后面所有投入的方向。