2 · 泛化、过拟合与偏差方差
核心问题:为什么训练集上表现好,测试集上却不行?如何定量判断一个模型是真好还是只是记住了数据?
一、先纠正一个直觉
多数人第一次遇到「过拟合」时的反应是:「模型把训练数据记住了,所以测试就差」。
这个说法不准确,但有用。准确的表述是:
模型的假设空间包含了训练集的一个特例(完美拟合),而我们优化算法恰好找到了这个特例。测试数据不属于这个特例,所以失效。
关键点:过拟合不是模型的缺点,而是「模型容量 vs 数据量」不匹配的表现。 容量没有错,错的是容量相对于数据量太大了。
推论很重要:
- 加数据能缓解过拟合(同一个高容量模型,数据越多越不容易找到「特例」)
- 减小模型容量也能缓解(限制假设空间)
- 改正则化方式也能缓解(但不是缩小空间,而是改变「哪些解更容易被优化找到」)
二、偏差-方差分解(Bias-Variance Decomposition)
这是本文最有价值的部分。它把「误差」拆成了三个可分别优化的部分。
2.1 误差的三项分解
对单个样本 x,模型的预测误差可以分解为:
E[(y−y^(x))2]=欠拟合Bias2[y^(x)]+过拟合Var[y^(x)]+噪声(不可消除)σ2
三项的含义:
| 项 | 定义 | 增大的原因 | 缓解手段 |
|---|---|---|---|
| 偏差 | 多次训练的预测的平均值偏离真实值 | 模型容量不足 / 欠拟合 | 增大模型、减少正则 |
| 方差 | 多次训练得到的预测的波动程度 | 模型容量过大、数据太少 | 加数据、正则化、集成 |
| 噪声 | 数据本身的噪声 | — | 不可消除,只能期望最小 |
「偏差」这个名字来自估计理论:模型给出的系统性错误的期望。
2.2 为什么叫「偏差」和「方差」
做个思想实验:把模型结构固定(比如固定层数的网络),换一批训练数据重新训练,看测试误差。
配置 A:简单模型(小网络)
训练1 → 测试误差 12%
训练2 → 测试误差 13%
训练3 → 测试误差 11%
→ 平均 12%,波动小 → 【低方差,高偏差】
配置 B:复杂模型(大网络)
训练1 → 测试误差 3%
训练2 → 测试误差 18%
训练3 → 测试误差 9%
→ 平均 10%,波动巨大 → 【高方差,可能低偏差】2
3
4
5
6
7
8
9
10
11
核心:偏差和方差是同一个模型的两种不同失败模式,不是两个独立的可调参数。
- 模型太弱:预测系统性地错 → 偏差大
- 模型太强:预测不稳定地错(换个数据就变) → 方差大
2.3 U 型曲线:最重要的一张图
横轴 = 模型容量(或训练轮数),纵轴 = 误差:
误差
↑
│ 方差(过拟合) ←── U型曲线 ──→ 偏差(欠拟合)
│ ╱ ╲
│ ╱ 最佳点 ╲
│ ╱ ● ╲
│ ╱ ╲
│ ╱ ╲
└──────────────────────────────────────────→ 模型容量 / 训练轮数2
3
4
5
6
7
8
9
这张图是所有超参数调整的指导依据。具体对应:
| 你在调什么 | 这条 U 型曲线的横轴是什么 | 最佳点判断 |
|---|---|---|
| 网络深度/宽度 | 模型容量 | 验证指标(accuracy/F1) |
| 训练轮数 epochs | 训练程度 | 验证指标开始下降处 |
| 正则化强度 λ | 有效容量(反向) | 验证指标 |
| 学习率 | 优化程度(间接) | 能否稳定下降 |
核心原则:永远用验证集判断,而不是训练集。 训练集误差永远单调下降,用它判断必然过拟合。
2.4 一个必须知道的推论
训练误差 + 训练误差/验证误差 的比值,是一个有效的诊断量(称为 generalization gap ratio):
| 训练误差 | 验证误差 | 诊断 | 行动 |
|---|---|---|---|
| 低 | 高 | 过拟合 | 加数据、加正则、减容量 |
| 高 | 高 | 欠拟合 | 加容量、换架构、继续训 |
| 低 | 低 | 正常 | 但要检查是否数据太简单 |
| 高 | 低 | 异常 | 数据泄漏!验证集混进了训练 |
最后一行是重大发现。如果验证误差低于训练误差,几乎肯定是数据泄漏(leakage)——验证集的信息通过某种方式泄漏进了训练。
常见泄漏源:
- 用全量数据统计做了标准化(应该只用训练集算 mean/std)
- 同一个样本的增强版本分别落在训练集和验证集
- 时序数据随机切分(未来信息泄漏到训练)
- 数据集本身有重复样本
三、数据集切分:三个容易踩的坑
坑 1:测试集被污染
规则:测试集只在最后评估时用一次。 如果你根据测试集表现反复调超参,测试集就变成了验证集,你的估计会乐观偏差。
正确做法:train / val / test 三分。所有决策只看 val,test 留到最后。
小数据时的折中:先用 val 调,最后用 test 验证一次。如果数据极少,可以用交叉验证(见下)。
坑 2:时序数据随机切分
如果数据有时间顺序,随机切分会泄漏未来信息。
❌ 错误:随机切分 → 训练集里有"未来"的数据,测试集有"过去"
✅ 正确:按时间切分(用最近的一段做测试)2
实际项目里的判断标准很简单:你能拿到「上线后」的数据吗? 评估方案就该模拟那个场景。
坑 3:数据本身不独立(同一个人多张照片)
如果同一个人的多张照片散落在训练集和验证集,模型学到的是「认出这个人」而不是「认出这个类别」。
必须按「组」切分(GroupSplit),保证同组数据全在一侧。
K 折交叉验证
数据少的时候,val 划分不可靠(val 太小 → 指标方差大)。用 K 折:
数据分成 K 份,轮流当验证集,其余当训练集
→得到 K 个 (train_score, val_score)
→ 报告平均值 ± 标准差2
3
from sklearn.model_selection import KFold, GroupKFold
# 普通 K 折
kf = KFold(n_splits=5, shuffle=True, random_state=42)
# 有分组时必须用 GroupKFold(否则同组数据会跨折泄漏)
gkf = GroupKFold(n_splits=5)
for train_idx, val_idx in gkf.split(X, y, groups=groups):
...2
3
4
5
6
7
8
9
注意:深度学习上跑 K 折代价是 K 倍训练时间,实践中常用「单次划 val」代替,除非数据量真的很小或者结论很重要。
四、过拟合的诊断流程
遇到「效果不好」时,按这个顺序排查:
Step 1: 看训练 vs 验证曲线
├─ 两者都高 → 欠拟合 → 增大模型 / 换架构 / 训练更久
├─ 训练低验证高 → 过拟合 → 进入 Step 2
└─ 验证比训练还低 → 数据泄漏 → 回去查切分
Step 2: 看训练曲线形状
├─ 训练 loss 还在降,但验证 loss 早就不降了 → 确认过拟合
└─ 训练 loss 卡住不动 → 优化问题(学习率、初始化、梯度)
Step 3: 数据问题排查(最容易被忽略)
├─ 类别不平衡?(检查各类样本数)
├─ 标签有噪声?(抽查错标样本)
├─ 训练/测试分布不一致?(对比两者的统计量)
└─ 数据量是否太少?
Step 4: 逐项试正则化,量化每项的贡献(做消融实验)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Step 3 是关键,很多「模型问题」实际上是数据问题。判断方法:人工看 50 个被分错的样本,如果里面有一半是标注错误,那问题在数据不在模型。
五、动手实验
实验 1:画出过拟合的 U 型曲线
这是本篇最重要的实验。用决策树深度作为「模型容量」的旋钮,扫描并观察训练/验证曲线的分叉。
(为什么用决策树而不是多项式回归?因为决策树在深度足够大时能做到训练集完美拟合(loss→0),U 型非常清晰。逻辑回归有 L2 正则压着,即使多项式阶数拉满也不会真正过拟合——我实测过,训练和验证曲线几乎重合,看不出教学效果。)
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
# 2D 月牙数据,加噪声让它不可完美分离
X, y = make_moons(n_samples=1000, noise=0.3, random_state=42)
X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size=0.3,
random_state=42, stratify=y)
depths = range(1, 16)
train_acc, val_acc = [], []
for d in depths:
model = DecisionTreeClassifier(max_depth=d, random_state=42)
model.fit(X_tr, y_tr)
train_acc.append(model.score(X_tr, y_tr))
val_acc.append(model.score(X_va, y_va))
print(f"{'深度':>4} {'训练准确率':>10} {'验证准确率':>10} {'泛化差距':>10}")
for d, t, v in zip(depths, train_acc, val_acc):
print(f"{d:>4} {t:>10.3f} {v:>10.3f} {t - v:>+10.3f}")
plt.plot(depths, train_acc, label="训练集", marker="o")
plt.plot(depths, val_acc, label="验证集", marker="s")
plt.xlabel("决策树最大深度(模型容量)")
plt.ylabel("准确率")
plt.title("过拟合:训练集一直涨到1.0,验证集在深度6后掉头向下")
plt.legend()
plt.grid(alpha=0.3)
plt.show()2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
实测输出:
深度 训练准确率 验证准确率 泛化差距
1 0.803 0.813 -0.010 ← 欠拟合(差距为负 = 验证反而更高)
2 0.887 0.913 -0.026
4 0.890 0.910 -0.020
6 0.941 0.920 +0.021 ← ★最佳点在这里
8 0.969 0.907 +0.062
10 0.981 0.893 +0.088
13 1.000 0.900 +0.100 ← 训练集完美拟合了
15 1.000 0.900 +0.1002
3
4
5
6
7
8
9
你应该观察到:
- 训练准确率单调上升,深度 13 之后达到 1.000(完全记住训练集)
- 验证准确率在深度 6 达到峰值 0.920,之后掉头向下
- 泛化差距从 -0.01 一路增长到 +0.10
实验 2:泛化差距告诉你「差多少」
best = int(np.argmax(val_acc))
print(f"最佳深度: {depths[best]}")
print(f"此时差距: {train_acc[best] - val_acc[best]:+.3f}") # +0.021 模型基本学对了
print(f"深度15差距: {train_acc[-1] - val_acc[-1]:+.3f}") # +0.100 模型在记噪声2
3
4
差距是模型「过度自信」的程度。0.021 说明模型学到的基本都对;0.100 说明它花了大量容量去记住噪声。
注意深度 13~15 的训练准确率完全一样(都是 1.000),但深度 1~2 差距是负数——这是因为小模型训练得不够充分,还没开始过拟合。负差距不是 bug,是「欠拟合且还没到过拟合阶段」的正常现象。
实验 3:加数据能不能缓解过拟合
这是最有说服力的实验:固定模型(一个会完全记住训练集的决策树),只变数据量。
import numpy as np
from sklearn.datasets import make_moons
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
X, y = make_moons(n_samples=2000, noise=0.3, random_state=42)
for frac in [0.1, 0.2, 0.4, 0.8, 1.0]:
n = int(len(X) * frac)
idx = np.random.default_rng(0).permutation(len(X))[:n]
Xa, ya = X[idx], y[idx]
Xt, Xv, yt, yv = train_test_split(Xa, ya, test_size=0.3,
random_state=42, stratify=ya)
# 不限制深度的决策树:一定会把训练集完全记住
clf = DecisionTreeClassifier(random_state=42)
clf.fit(Xt, yt)
print(f"数据量 {n:>5} (frac={frac}) 训练 {clf.score(Xt,yt):.3f} "
f"验证 {clf.score(Xv,yv):.3f} 差距 {clf.score(Xt,yt)-clf.score(Xv,yv):.3f}")2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
实测输出:
数据量200 (frac=0.1) 训练 1.000 验证 0.867 差距 0.133
数据量 400 (frac=0.2) 训练 1.000 验证 0.858 差距 0.142
数据量 800 (frac=0.4) 训练 1.000 验证 0.871 差距 0.129
数据量 1600 (frac=0.8) 训练 1.000 验证 0.906 差距 0.094
数据量 2000 (frac=1.0) 训练 1.000 验证 0.892 差距 0.1082
3
4
5
你会观察到:训练准确率恒定在 1.000(模型从头到尾都在过拟合),但验证准确率随数据量上升(0.867 → 0.906),差距整体在收窄。
这直接证明了开头的结论:过拟合的本质是「容量 vs 数据量」不匹配,加数据是最直接的解法。
注意:这个提升不是单调的(400→800 反而降了)。因为数据变少时验证集本身也在变小,指标方差变大。小数据上的指标波动是正常的,这也是为什么小数据要做 K 折交叉验证。
我原本用 MLP(64,64)跑这个实验,但 2000 条数据的月牙对 MLP 来说太简单了,训练准确率一直在 0.91~0.94 徘徊,根本没过拟合,也就看不到「训练集恒定、验证集上升」这个对比。做实验时如果观察不到预期现象,要先怀疑实验设置,而不是硬编一个解释。
实验 4:检测数据泄漏
# 检测泄漏的信号:验证误差 > 训练误差(超出正常波动范围)
leak_suspicious = any(v - t > 0.02 for t, v in zip(train_acc, val_acc))
print(f"疑似过拟合(差距 > 2%): {leak_suspicious}")
# 最常见的泄漏:用全量数据做标准化
from sklearn.preprocessing import StandardScaler
# ❌ 错误:fit 用全量(含测试集),mean/std 里带着测试集信息
scaler_bad = StandardScaler()
X_all = scaler_bad.fit_transform(X)
Xa, Xb = X_all[:700], X_all[700:]
# ✅ 正确:fit 只用训练集,transform 用训练集学到的参数
scaler_good = StandardScaler()
Xa2 = scaler_good.fit_transform(X[:700])
Xb2 = scaler_good.transform(X[700:])
# 注意:两张图的 sample mean 不一样(差得很小,但确实不同)
print("泄漏版训练集均值:", Xa.mean(axis=0)[:3])
print("正确版训练集均值:", Xa2.mean(axis=0)[:3])2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
判断泄漏的标准:如果 transform 时用了包含测试集统计量的参数,就已经是泄漏了,哪怕影响只有 0.1%。
深度学习里的对应陷阱:
transforms.Normalize(mean, std):mean/std 应该只从训练集算StandardScaler同理- 数据增强的随机变换:训练集和验证集不能来自同一张图的不同增强版本
为什么这类泄漏这么隐蔽:影响通常只有零点几个百分点,不会让模型「明显变好」,但会系统性地高估真实性能,让你误以为可以上线。
六、自测题
Q1:训练准确率 99.9%,验证准确率 60%。你会依次尝试什么?给出优先级顺序和理由。
答案
优先级 1:看数据,不是看模型。 抽查验证集里被分错的样本,判断有多少是标注错误、多少是「模型看起来错了但其实对了」。如果标注错误率 >10%,先清洗数据,否则任何模型调参都是浪费时间。
优先级 2:确认没有泄漏。 验证误差(60%)远低于训练误差(99.9%)时,这是个强信号。检查标准化统计量、重复样本、时序切分。
优先级 3:看数据量和类别分布。 如果类别极度不平衡(99% 都是一类),99.9% 训练准确率可能只是「全预测多数类」。此时应该看 F1/召回而不是准确率。
优先级 4:加正则化。 按性价比:数据增强(最有效)→ weight decay → dropout / early stopping。
优先级 5:减小模型。 前四步没效果才考虑。网络大不一定好。
不建议的做法:一上来就换架构。架构是最贵的手段,应该最后考虑。
Q2:为什么「训练误差和验证误差差不多」不代表模型没问题?
答案
因为两个模型可能同时很差。有几种典型情况:
- 欠拟合:训练误差 55%、验证误差 54%。差距很小,但两个都很高——模型根本没学到东西
- 数据太简单/标签有噪声:训练验证都是 50%,但真实上限就 50%(数据本身随机标注)
- 指标选错:accuracy 都是 50%,但 A 类的 recall 是 100%、B 类是 0
- 类别不平衡:全预测多数类也能拿到高准确率
结论:差距小只说明「没有过拟合」,不说明「拟合得好」。 必须同时看绝对值和业务指标。这就是为什么规范的做法是同时监控训练和验证的多个指标。
Q3:Dropout 在训练和推理时行为不同。如果忘了在推理时调 model.eval(),会发生什么?为什么这个 bug 很难发现?
答案
推理时仍然随机丢弃神经元 → 相当于用一个「随机衰减的次优网络」做预测。
为什么难发现:
- 代码完全不会报错
- 准确率会下降但不会崩到离谱,可能只降几个百分点,很容易被归因为「随机波动」
- 同一个模型的验证指标每次跑都不一样 → 看起来「验证集有噪声」,反而会让人去怀疑数据划分
排查方法:多次评估同一模型,如果指标方差异常大(比如 ±3%),大概率是漏了 eval()。因为 eval() 缺失时每次前向都不同。
这也是为什么 model.eval() 和 torch.no_grad() 总是成对出现:前者管正确性,后者管效率。
Q4:一个模型在测试集上 85%,另一个 86%。这个差异有意义吗?
答案
大概率没有意义,除非你能算出置信区间。
在 10000 个测试样本上,85% 和 86% 的差别是 100 个样本。估计标准误约为:
SE≈np(1−p)=100000.855×0.145≈0.0035=0.35%
所以 85.5% ± 0.7% (95% 置信)。86% 落在这个区间内,差异不显著。
怎么做才显著:要用 paired test(配对检验),因为两个模型在同一批样本上评估,样本间差异可以消掉。具体用 McNemar 检验(针对分类准确率)或 bootstrap。
实践意义:
- 单次评估的 1% 提升通常不可信
- 论文里报 ±std 是标准做法,单个数字说明实验不严谨
- 要判断改进是否真实,最可靠的是「多个随机种子 + 配对检验」
下一篇 → 正则化与容量控制