Skip to content

第 21 章 · 学习曲线:欠拟合、过拟合、数据够不够 ​

这一章要解决什么:模型效果不好时,下一步该做什么? 加数据、换模型、还是改特征?学习曲线能直接告诉你答案。

最容易踩的坑:忘了传 groups。 曲线会被"美化"到 0.99 以上,而且看起来异常平滑 —— 你会以为模型很稳。

本章你需要完成的代码:demos/ch21-learning-curve.py

21.1 学习曲线在回答什么 ​

它把"训练规模"从小到大扫一遍,每次都记录两个分数:

训练分数:模型在【它见过的数据】上的表现
验证分数:模型在【它没见过的那折】上的表现
    ↓
把这两个分数对训练规模作图 → 两条线
    ↓
两条线的【形状】告诉你是哪种病

★ 四条判据(先记住,后面逐条验证)

① 训练低 + 验证低 + 靠拢      →  欠拟合    →  换更强的模型 / 加特征
② 训练高 + 验证低 + 差距大    →  过拟合    →  正则化 / 减复杂度 / 加数据
③ 验证线还在涨                →  数据不够  →  加数据
④ 验证线走平                  →  瓶颈在特征 →  别加数据,去改特征

代码形式:

python
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 实测:这份数据的学习曲线 ​

真实输出:

text
正在跑学习曲线(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.0403

0.0403 算严重吗? 需要有个参照。

参照一:和"单棵无限深树"比

第 17 章实测过:

text
单棵【不限深度】的树:
  训练 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.958000.01163
忘了传(普通 KFold)0.98700.0005

★ 差异是双重的

① 分数虚高:0.9580 → 0.9870(+0.029)
② 方差假性变小:0.0116 → 0.0005(小 23 倍!)

★ 第 ② 条比第 ① 条更阴险

分数虚高,你可能还会怀疑("0.99?是不是有问题")
   ↓
但方差变小传递的信号是"模型很稳定、很可靠"
   ↓
你会放心地用这个分数,并且【更相信它】
   ↓
这就是"双重欺骗":不仅数字骗你,连"这个数字有多可信"也在骗你

机制:

同一支设备的相邻窗口高度相似
   ↓
普通 KFold 让它们跨折 → 每一折的任务都"很相似"
   ↓
各折表现自然接近 → 方差小
   ↓
而这种"稳定"完全来自泄漏,与模型的真实稳定性无关

★ 一个必须记住的判据

交叉验证的方差【异常小】(比如 ±0.001)是一个警示信号, 而不是一个好消息。

真实的模型在不同折之间总会有波动(本数据是 ±0.012)。 波动小到不合理,先怀疑划分泄漏。

21.5 常见坑 ​

text
① 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 完整示例与输出 ​

bash
cd code
python demos/ch21-learning-curve.py

真实输出(节选):

text
正在跑学习曲线(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 个训练规模),然后回答:

  1. 验证分数在哪个规模之后开始走平?
  2. 训练-验证差距是扩大还是缩小?
  3. 按四条判据,下一步该做什么?
参考答案
python
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 '还在涨'}")

期望输出:

text
      训练规模       训练 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 的曲线"更好看"。

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

期望输出(数值会有小幅波动):

text
      训练规模     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,那不是"模型稳定",而是"划分泄漏"。

★ 一个实用的自检

python
# 画完曲线,先看标准差的量级
sd = va_s.std(1)
if sd.max() < 0.003:
    print("⚠️ 折间方差异常小,检查是否忘了传 groups=")

这条检查只需要两行代码,但能挡住本章最大的坑。

练习 3:把"四条判据"做成一个函数

写一个函数 diagnose(sizes, train_scores, val_scores), 输入 learning_curve 的输出,返回诊断结论(欠拟合/过拟合/数据不够/瓶颈在特征)。

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

期望输出:

text
诊断:瓶颈在特征
理由:验证 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 天就看到"验证线早就走平"
   ↓
结论:加数据收益很低 → 不做那个采集项目
   ↓
省下三个月,去做特征工程

★ 学习曲线的成本是"几分钟的计算",收益是"几个月的方向"。

② 一个实用的实验记录模板

markdown
## 实验 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 章 → 四路横评(排除"换模型"这个方向)

四章连起来,就是"诊断 → 治疗 → 排除 → 转向"的完整流程。

而本章是其中的"诊断"环节 —— 它决定了后面所有投入的方向。


下一章:第 22 章 · 正则化:给模型上"刹车"