0. 简介
学完多层感知机之后,我很自然地产生了一个想法:既然增加隐藏单元、增加隐藏层可以增强模型的表达能力,那是不是模型越复杂越好?
答案是否定的。
模型太简单时,它可能连训练数据中的基本规律都学不会;模型太复杂时,它又可能把训练数据中特有的噪声也当成规律记下来。前一种情况叫做欠拟合 ,后一种情况叫做过拟合。
所以,从这一节开始,我们关注的问题发生了一点变化:
text
前面:怎样让模型学会训练数据中的规律?
现在:怎样判断模型学到的规律,对没见过的数据是否仍然有效?
这就是模型选择要解决的核心问题。
0.1 训练误差和泛化误差
训练模型时,我们可以直接计算模型在训练集上的误差,这叫做训练误差(training error)。
但实际使用模型时,真正关心的不是它能否答对已经见过的题,而是能否处理从未见过的新数据。模型在新数据上的预期误差叫做泛化误差(generalization error)。
可以把它类比成考试:
| 机器学习 | 考试 |
|---|---|
| 训练集 | 平时做过的练习题 |
| 训练误差 | 在练习题上的错误率 |
| 未见过的数据 | 正式考试中的新题 |
| 泛化误差 | 在新题上的预期错误率 |
如果一个学生只是记住了练习题答案,那么他在练习题上的错误率可能很低,但换一种问法就不会了。模型也是一样:训练误差低,只能说明模型拟合了训练数据,不能直接说明模型具有良好的泛化能力。
真正的泛化误差无法被精确计算,因为我们不可能穷举未来所有可能出现的数据。因此,实际中只能拿一份没有参与训练的数据来估计它。
0.2 为什么需要验证集?
一份完整的数据通常会被划分成:
| 数据集 | 作用 |
|---|---|
| 训练集 | 学习模型参数,例如权重和偏置 |
| 验证集 | 比较不同模型和超参数,完成模型选择 |
| 测试集 | 在所有选择完成后,评估最终模型 |
这里最容易混淆的是验证集和测试集。
比如我们想比较隐藏单元数为64、128还是256时效果更好,这个数字不能通过梯度下降自动学习,它属于超参数。我们会分别训练多个模型,再根据验证集表现选择一个。
测试集原则上应该留到最后使用。如果每次调整模型都去看测试集结果,那么测试集也间接参与了模型选择。反复尝试之后,我们可能只是选出了一个"刚好适合这份测试集"的模型,此时测试结果也会变得过于乐观。
整个过程可以简单表示为:
text
训练集:训练多个候选模型
↓
验证集:选择模型结构和超参数
↓
测试集:只评估最终选中的模型
当数据量很少,不舍得单独划出一份验证集时,还可以使用 K折交叉验证:把训练数据分成 K份,每次用其中 K−1份训练、剩余1份验证,一共训练 K次,最后对验证结果取平均。
0.3 怎么判断欠拟合和过拟合?
欠拟合和过拟合不能只看一条训练曲线,而要同时比较训练误差与验证误差。
| 训练误差 | 验证误差 | 可能的状态 |
|---|---|---|
| 很高 | 很高,且两者接近 | 欠拟合 |
| 很低 | 也很低 | 拟合较合适 |
| 很低 | 明显较高 | 过拟合 |
0.3.1 欠拟合
欠拟合是模型连训练数据都没有学好。
例如,数据真实规律是一条弯曲的曲线,我们却只允许模型画一条直线。无论训练多久,它都不可能同时贴合曲线的各个部分。这说明问题不是"模型没有记住训练数据",而是模型的表达能力不够。
常见的改善方向包括:
- 使用更复杂的模型;
- 增加有效特征;
- 适当增加训练轮数;
- 减弱过强的正则化。
0.3.2 过拟合
过拟合是模型把训练数据学得很好,但没有学到能够推广到新数据的稳定规律。
例如,我们想学习"房屋面积和价格之间的大致关系",训练数据中却有一套因为急售而价格异常低的房子。复杂模型可能专门绕过去贴住这个异常点,训练误差因此下降,但它对其他正常房屋的预测反而可能变差。
常见的改善方向包括:
- 增加训练数据;
- 降低模型复杂度;
- 使用权重衰减、Dropout等正则化方法;
- 使用数据增强;
- 在合适的位置提前停止训练。
因此,欠拟合和过拟合不是简单的"误差高"和"误差低",而是模型复杂度、数据量以及泛化能力之间的关系。
0.4 为什么这里突然讲多项式回归?
这是我阅读这一节时最困惑的地方:前面还在讲模型选择、泛化误差、欠拟合和过拟合,后面为什么直接变成了多项式回归?
理解这个转折的关键是:多项式回归不是这一节突然要学习的新目标,而是用来演示欠拟合和过拟合的实验工具。
我们需要设计一个实验,同时满足下面几个条件:
- 数据背后的真实规律是已知的;
- 模型复杂度可以方便地增大或减小;
- 除了模型复杂度,其他训练条件尽量保持不变;
- 最终结果可以直观地比较。
多项式回归恰好满足这些要求。对于同一个输入 x,我们可以分别使用一次、三次或更高次多项式进行拟合:
y^=w0+w1x+w2x2+⋯+wdxd
其中 d就是一个非常直观的"模型复杂度旋钮":
text
d较小 -> 模型简单,可能欠拟合
d合适 -> 能描述真实规律
d很大 -> 模型复杂,可能追随噪声而过拟合
这比直接拿不同层数的神经网络做实验更适合入门。因为改变神经网络层数时,参数量、优化难度、初始化等因素都会一起变化;而多项式回归只需要改变输入包含到 x的多少次幂,就能清楚地控制模型复杂度。
还有一个很重要的点:多项式回归虽然对原始输入 x来说是非线性的,但对于待学习的参数 w来说仍然是线性回归。
例如三阶多项式:
y^=w0+w1x+w2x2+w3x3
把输入转换成新的特征:
ϕ(x)=1,x,x2,x3
那么模型就可以重新写成:
y^=ϕ(x)Tw
它仍然是"特征乘权重再求和"。模型没有改变线性回归的训练方法,只是先把原始特征 x扩展成了多项式特征。
所以,这一节的完整逻辑应该是:
text
模型越复杂,不一定越好
↓
需要观察模型复杂度如何影响训练误差和泛化误差
↓
多项式的阶数可以方便地控制模型复杂度
↓
用低阶、合适阶数和高阶模型分别演示欠拟合、正常拟合和过拟合
这样看,多项式回归就不再是一次突兀的转折,而是把前面理论变成可观察实验的桥梁。
1. 多项式回归
1.1 生成数据集
书中先人为指定一个三阶多项式作为真实规律:
y=5+1.2x−3.42!x2+5.63!x3+ϵ
其中噪声满足:
ϵ∼N(0,0.12)
也就是说,数据的大体规律由三阶多项式决定,但每个标签还会受到一点随机噪声影响。这更接近真实数据,因为现实中的观测值通常不会完美落在某个函数上。
对应代码如下:
python
max_degree = 20
n_train, n_test = 100, 100
true_w = np.zeros(max_degree)
true_w[0:4] = np.array([5, 1.2, -3.4, 5.6])
true_w先创建了一个长度为20的全零数组,然后只给前4项赋值:
text
w0 = 5
w1 = 1.2
w2 = -3.4
w3 = 5.6
w4到w19 = 0
这表示真实规律只包含0次到3次项,后面的高阶项实际上都不存在。
这里的max_degree = 20准确来说生成了20个多项式特征,对应 x0到 x19,所以最高次幂是19。变量名沿用了书中的写法,不影响后面的实验含义。
接着生成200个原始输入,其中100个用于训练,100个用于测试:
python
features = np.random.normal(size=(n_train + n_test, 1))
np.random.shuffle(features)
然后把每个 x转换成20个多项式特征:
python
poly_features = np.power(
features,
np.arange(max_degree).reshape(1, -1)
)
对于某个输入 x,这一行会得到:
x0,x1,x2,...,x19
其中 x0=1。这个常量特征后面会承担偏置项的作用。
1.1.1 为什么要除以阶乘?
书中又对多项式特征进行了缩放:
python
for i in range(max_degree):
poly_features[:, i] /= math.gamma(i + 1)
由于:
Γ(i+1)=i!
因此最终使用的特征是:
1,x,2!x2,3!x3,...,19!x19
如果直接计算高次幂,输入 x稍微大一点, x19就可能变得非常大。不同特征之间的数值范围相差过大,会产生很大的损失和梯度,使梯度下降难以稳定训练。
除以阶乘的主要目的不是改变"几阶多项式"这个实验,而是控制高阶特征的数值规模,让优化过程更稳定。
最后,根据真实权重生成标签并添加噪声:
python
labels = np.dot(poly_features, true_w)
labels += np.random.normal(scale=0.1, size=labels.shape)
因为true_w只有前4项非零,所以无论poly_features保存了多少阶特征,生成标签时真正起作用的仍然只有:
1,x,2!x2,3!x3
这使我们明确知道:三阶模型才是与数据真实规律匹配的模型。
1.2 评估模型损失
书中定义了一个函数,用于计算模型在某个数据集上的平均损失:
python
def evaluate_loss(net, data_iter, loss):
metric = d2l.Accumulator(2)
for X, y in data_iter:
out = net(X)
y = y.reshape(out.shape)
l = loss(out, y)
metric.add(l.sum(), l.numel())
return metric[0] / metric[1]
d2l.Accumulator(2)内部保存两个累计值:
text
metric[0]:所有样本的损失之和
metric[1]:参与统计的样本数量
因此最终返回的是:
平均损失=样本数量损失总和
y.reshape(out.shape)是为了让标签和模型输出具有相同形状。模型输出通常是(batch_size, 1),而传入的标签有时是(batch_size,)。统一形状可以避免广播导致不符合预期的损失计算。
1.3 定义训练函数
训练函数看起来比较长,但核心仍然是前面已经学过的线性回归:
python
def train(train_features, test_features, train_labels, test_labels,
num_epochs=400):
loss = nn.MSELoss(reduction='none')
input_shape = train_features.shape[-1]
net = nn.Sequential(nn.Linear(input_shape, 1, bias=False))
batch_size = min(10, train_labels.shape[0])
train_iter = d2l.load_array(
(train_features, train_labels.reshape(-1, 1)), batch_size
)
test_iter = d2l.load_array(
(test_features, test_labels.reshape(-1, 1)),
batch_size,
is_train=False
)
trainer = torch.optim.SGD(net.parameters(), lr=0.01)
animator = d2l.Animator(
xlabel='epoch', ylabel='loss', yscale='log',
xlim=[1, num_epochs], ylim=[1e-3, 1e2],
legend=['train', 'test']
)
for epoch in range(num_epochs):
d2l.train_epoch_ch3(net, train_iter, loss, trainer)
if epoch == 0 or (epoch + 1) % 20 == 0:
animator.add(
epoch + 1,
(evaluate_loss(net, train_iter, loss),
evaluate_loss(net, test_iter, loss))
)
print('weight:', net[0].weight.data.numpy())
1.3.1 模型复杂度是怎么变化的?
这段代码中最关键的一行是:
python
input_shape = train_features.shape[-1]
传入多少个多项式特征,线性层就有多少个输入权重:
| 传入的特征 | input_shape |
模型能表达的最高次数 |
|---|---|---|
[1, x] |
2 | 一次 |
[1, x, x²/2!, x³/3!] |
4 | 三次 |
| 全部20个特征 | 20 | 十九次 |
因此,三个实验使用的是同一个train函数、同一种损失函数和同一个优化器。真正变化的只有输入特征数量,也就是模型可以使用的多项式阶数。
这正是一个比较模型复杂度的控制变量实验。
1.3.2 为什么nn.Linear不使用偏置?
模型定义为:
python
net = nn.Sequential(nn.Linear(input_shape, 1, bias=False))
这里关闭bias并不是说多项式没有偏置,而是偏置已经包含在输入特征中了。
第一个多项式特征始终是:
x0=1
这个特征对应的权重 w0会产生:
1×w0=w0
它的作用和普通线性层中的偏置完全相同。如果此时仍然设置bias=True,模型就会同时拥有常量特征的权重和线性层偏置,出现两个功能重复的常数项。
1.4 三组对照实验
数据由三阶多项式生成,书中却分别使用一次、三次和最高十九次的模型拟合它。三个实验共同展示了模型复杂度从不足、合适到过高的变化。
1.4.1 三阶多项式:正常拟合
python
train(poly_features[:n_train, :4],
poly_features[n_train:, :4],
labels[:n_train], labels[n_train:])
:4表示只选择前4个特征:
1,x,2!x2,3!x3
它们与生成数据时使用的真实特征完全一致,因此模型既有能力表达真实规律,也没有加入大量不必要的高阶项。
训练后,学到的权重通常会接近:
text
[5, 1.2, -3.4, 5.6]
由于标签中存在随机噪声,结果不会每次都精确等于真实权重,但训练损失和测试损失都会下降到较低水平,而且两者比较接近。

1.4.2 一次多项式:欠拟合
python
train(poly_features[:n_train, :2],
poly_features[n_train:, :2],
labels[:n_train], labels[n_train:])
:2只保留:
1,x
也就是说,模型只能学习:
y^=w0+w1x
但真实数据中还包含 x2和 x3。模型只能画一条直线去逼近一条三次曲线,所以即使继续训练,也无法消除这种由表达能力不足造成的误差。
这时通常会看到:
text
训练损失较高
测试损失也较高
两者差距不一定很大

这里"训练损失和测试损失差距不大"并不代表模型很好,因为它们可能是一起很差。判断模型时既要看两者差距,也要看误差本身是否足够低。
1.4.3 高阶多项式:过拟合
python
train(poly_features[:n_train, :],
poly_features[n_train:, :],
labels[:n_train], labels[n_train:],
num_epochs=1500)
这里选择了全部20个特征,模型可以一直使用到 x19。但我们已经知道真实规律中4次及以上的权重都应该是0。
理想情况下,模型应该自己学出:
text
w0到w3接近真实值
w4到w19全部接近0
问题在于训练数据只有100个样本,而且标签中还带有随机噪声。高阶模型拥有更多可调整参数,它可能利用本来不需要的高阶项去迎合训练样本中的偶然波动。
于是会出现:
text
训练损失不断降低
测试损失仍然较高,或者与训练损失差距明显

这就是过拟合:模型不仅学习了真实的三阶规律,还把训练集中的噪声也解释成了某种高阶规律。
三个实验可以总结为:
| 使用的特征 | 模型复杂度 | 结果 |
|---|---|---|
| 1,x | 太低 | 无法表达真实三阶规律,欠拟合 |
| 1,x,x2/2!,x3/3! | 合适 | 与真实规律匹配,训练和测试表现都较好 |
| 一直到 x19/19! | 太高 | 容易拟合训练噪声,过拟合 |
1.5 模型复杂度和数据量的关系
模型是否过拟合,不只取决于模型本身,也取决于数据量。
同一个十九次多项式,在只有少量训练数据时,很容易找到一条复杂曲线穿过这些点;如果提供大量覆盖充分的数据,高阶模型就更难靠偶然的弯曲欺骗所有样本,也更容易判断不必要的高阶权重应该接近0。
所以,模型复杂度没有脱离数据集的绝对标准:
text
模型复杂 + 数据很少 -> 更容易过拟合
模型简单 + 规律复杂 -> 更容易欠拟合
模型复杂 + 数据充足 -> 可能发挥更强的表达能力
这也解释了为什么深度学习往往需要大量数据。神经网络具有很强的表达能力,如果只有很少的样本,它也更容易记住训练数据中的偶然细节。
1.6 小结
这一节并不是要把重点从多层感知机转移到多项式回归,而是开始讨论一个更普遍的问题:模型学会训练数据之后,能不能在新数据上仍然有效。
多项式回归之所以出现在这里,是因为它把模型复杂度变成了一个容易控制的变量。通过改变多项式阶数,我们可以在同一份数据上清楚地观察:
text
模型太简单 -> 训练集也学不好,欠拟合
复杂度合适 -> 学到主要规律,能够泛化
模型太复杂 -> 容易追随训练噪声,过拟合
我觉得这一节最需要记住的不是"几阶多项式最好",而是下面这句话:
训练误差低只是必要条件,不是最终目标;我们真正关心的是模型在未见数据上的表现。