4. d2l — 模型选择、欠拟合和过拟合

0. 简介

学完多层感知机之后,我很自然地产生了一个想法:既然增加隐藏单元、增加隐藏层可以增强模型的表达能力,那是不是模型越复杂越好?

答案是否定的。

模型太简单时,它可能连训练数据中的基本规律都学不会;模型太复杂时,它又可能把训练数据中特有的噪声也当成规律记下来。前一种情况叫做欠拟合 ,后一种情况叫做过拟合

所以,从这一节开始,我们关注的问题发生了一点变化:

text 复制代码
前面:怎样让模型学会训练数据中的规律?
现在:怎样判断模型学到的规律,对没见过的数据是否仍然有效?

这就是模型选择要解决的核心问题。

0.1 训练误差和泛化误差

训练模型时,我们可以直接计算模型在训练集上的误差,这叫做训练误差(training error)。

但实际使用模型时,真正关心的不是它能否答对已经见过的题,而是能否处理从未见过的新数据。模型在新数据上的预期误差叫做泛化误差(generalization error)。

可以把它类比成考试:

机器学习 考试
训练集 平时做过的练习题
训练误差 在练习题上的错误率
未见过的数据 正式考试中的新题
泛化误差 在新题上的预期错误率

如果一个学生只是记住了练习题答案,那么他在练习题上的错误率可能很低,但换一种问法就不会了。模型也是一样:训练误差低,只能说明模型拟合了训练数据,不能直接说明模型具有良好的泛化能力。

真正的泛化误差无法被精确计算,因为我们不可能穷举未来所有可能出现的数据。因此,实际中只能拿一份没有参与训练的数据来估计它。

0.2 为什么需要验证集?

一份完整的数据通常会被划分成:

数据集 作用
训练集 学习模型参数,例如权重和偏置
验证集 比较不同模型和超参数,完成模型选择
测试集 在所有选择完成后,评估最终模型

这里最容易混淆的是验证集和测试集。

比如我们想比较隐藏单元数为64128还是256时效果更好,这个数字不能通过梯度下降自动学习,它属于超参数。我们会分别训练多个模型,再根据验证集表现选择一个。

测试集原则上应该留到最后使用。如果每次调整模型都去看测试集结果,那么测试集也间接参与了模型选择。反复尝试之后,我们可能只是选出了一个"刚好适合这份测试集"的模型,此时测试结果也会变得过于乐观。

整个过程可以简单表示为:

text 复制代码
训练集:训练多个候选模型
            ↓
验证集:选择模型结构和超参数
            ↓
测试集:只评估最终选中的模型

当数据量很少,不舍得单独划出一份验证集时,还可以使用 KK K折交叉验证:把训练数据分成 KK K份,每次用其中 K−1K-1 K−1份训练、剩余1份验证,一共训练 KK K次,最后对验证结果取平均。

0.3 怎么判断欠拟合和过拟合?

欠拟合和过拟合不能只看一条训练曲线,而要同时比较训练误差与验证误差。

训练误差 验证误差 可能的状态
很高 很高,且两者接近 欠拟合
很低 也很低 拟合较合适
很低 明显较高 过拟合

0.3.1 欠拟合

欠拟合是模型连训练数据都没有学好。

例如,数据真实规律是一条弯曲的曲线,我们却只允许模型画一条直线。无论训练多久,它都不可能同时贴合曲线的各个部分。这说明问题不是"模型没有记住训练数据",而是模型的表达能力不够。

常见的改善方向包括:

  • 使用更复杂的模型;
  • 增加有效特征;
  • 适当增加训练轮数;
  • 减弱过强的正则化。

0.3.2 过拟合

过拟合是模型把训练数据学得很好,但没有学到能够推广到新数据的稳定规律。

例如,我们想学习"房屋面积和价格之间的大致关系",训练数据中却有一套因为急售而价格异常低的房子。复杂模型可能专门绕过去贴住这个异常点,训练误差因此下降,但它对其他正常房屋的预测反而可能变差。

常见的改善方向包括:

  • 增加训练数据;
  • 降低模型复杂度;
  • 使用权重衰减、Dropout等正则化方法;
  • 使用数据增强;
  • 在合适的位置提前停止训练。

因此,欠拟合和过拟合不是简单的"误差高"和"误差低",而是模型复杂度、数据量以及泛化能力之间的关系。

0.4 为什么这里突然讲多项式回归?

这是我阅读这一节时最困惑的地方:前面还在讲模型选择、泛化误差、欠拟合和过拟合,后面为什么直接变成了多项式回归?

理解这个转折的关键是:多项式回归不是这一节突然要学习的新目标,而是用来演示欠拟合和过拟合的实验工具。

我们需要设计一个实验,同时满足下面几个条件:

  • 数据背后的真实规律是已知的;
  • 模型复杂度可以方便地增大或减小;
  • 除了模型复杂度,其他训练条件尽量保持不变;
  • 最终结果可以直观地比较。

多项式回归恰好满足这些要求。对于同一个输入 xx x,我们可以分别使用一次、三次或更高次多项式进行拟合:
y^=w0+w1x+w2x2+⋯+wdxd \hat{y}=w_0+w_1x+w_2x^2+\cdots+w_dx^d y^=w0+w1x+w2x2+⋯+wdxd

其中 dd d就是一个非常直观的"模型复杂度旋钮":

text 复制代码
d较小  -> 模型简单,可能欠拟合
d合适  -> 能描述真实规律
d很大  -> 模型复杂,可能追随噪声而过拟合

这比直接拿不同层数的神经网络做实验更适合入门。因为改变神经网络层数时,参数量、优化难度、初始化等因素都会一起变化;而多项式回归只需要改变输入包含到 xx x的多少次幂,就能清楚地控制模型复杂度。

还有一个很重要的点:多项式回归虽然对原始输入 xx x来说是非线性的,但对于待学习的参数 ww w来说仍然是线性回归。

例如三阶多项式:
y^=w0+w1x+w2x2+w3x3 \hat{y}=w_0+w_1x+w_2x^2+w_3x^3 y^=w0+w1x+w2x2+w3x3

把输入转换成新的特征:
ϕ(x)=1,x,x2,x3\phi(x)=1,x,x\^2,x\^3 ϕ(x)=1,x,x2,x3

那么模型就可以重新写成:
y^=ϕ(x)Tw \hat{y}=\phi(x)^\mathrm{T}\mathbf{w} y^=ϕ(x)Tw

它仍然是"特征乘权重再求和"。模型没有改变线性回归的训练方法,只是先把原始特征 xx x扩展成了多项式特征。

所以,这一节的完整逻辑应该是:

text 复制代码
模型越复杂,不一定越好
        ↓
需要观察模型复杂度如何影响训练误差和泛化误差
        ↓
多项式的阶数可以方便地控制模型复杂度
        ↓
用低阶、合适阶数和高阶模型分别演示欠拟合、正常拟合和过拟合

这样看,多项式回归就不再是一次突兀的转折,而是把前面理论变成可观察实验的桥梁。

1. 多项式回归

1.1 生成数据集

书中先人为指定一个三阶多项式作为真实规律:
y=5+1.2x−3.4 x22! +5.6 x33! +ϵy=5+1.2x-3.4\frac{x^2}{2!}+5.6\frac{x^3}{3!}+\epsilon y=5+1.2x−3.42!x2+5.63!x3+ϵ

其中噪声满足:
ϵ∼N(0,0.12)\epsilon\sim\mathcal{N}(0,0.1^2) ϵ∼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个多项式特征,对应 x0x^0 x0到 x19x^{19} x19,所以最高次幂是19。变量名沿用了书中的写法,不影响后面的实验含义。

接着生成200个原始输入,其中100个用于训练,100个用于测试:

python 复制代码
features = np.random.normal(size=(n_train + n_test, 1))
np.random.shuffle(features)

然后把每个 xx x转换成20个多项式特征:

python 复制代码
poly_features = np.power(
    features,
    np.arange(max_degree).reshape(1, -1)
)

对于某个输入 xx x,这一行会得到:
x0,x1,x2,...,x19x\^0,x\^1,x\^2,\\ldots,x\^{19} x0,x1,x2,...,x19

其中 x0=1x^0=1 x0=1。这个常量特征后面会承担偏置项的作用。

1.1.1 为什么要除以阶乘?

书中又对多项式特征进行了缩放:

python 复制代码
for i in range(max_degree):
    poly_features[:, i] /= math.gamma(i + 1)

由于:
Γ(i+1)=i!\Gamma(i+1)=i! Γ(i+1)=i!

因此最终使用的特征是:
1,x, x22! , x33! ,..., x1919! \left1,x,\\frac{x\^2}{2!},\\frac{x\^3}{3!},\\ldots,\\frac{x\^{19}}{19!}\\right 1,x,2!x2,3!x3,...,19!x19

如果直接计算高次幂,输入 xx x稍微大一点, x19x^{19} 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, x22! , x33! 1,x,\frac{x^2}{2!},\frac{x^3}{3!} 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]:参与统计的样本数量

因此最终返回的是:
平均损失=损失总和样本数量 \text{平均损失}=\frac{\text{损失总和}}{\text{样本数量}} 平均损失=样本数量损失总和

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=1x^0=1 x0=1

这个特征对应的权重 w0 w_0 w0会产生:
1×w0=w0 1\times w_0=w_0 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, x22! , x33! \left1,x,\\frac{x\^2}{2!},\\frac{x\^3}{3!}\\right 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,x1,x 1,x

也就是说,模型只能学习:
y^=w0+w1x \hat{y}=w_0+w_1x y^=w0+w1x

但真实数据中还包含 x2x^2 x2和 x3x^3 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个特征,模型可以一直使用到 x19x^{19} x19。但我们已经知道真实规律中4次及以上的权重都应该是0。

理想情况下,模型应该自己学出:

text 复制代码
w0到w3接近真实值
w4到w19全部接近0

问题在于训练数据只有100个样本,而且标签中还带有随机噪声。高阶模型拥有更多可调整参数,它可能利用本来不需要的高阶项去迎合训练样本中的偶然波动。

于是会出现:

text 复制代码
训练损失不断降低
测试损失仍然较高,或者与训练损失差距明显

这就是过拟合:模型不仅学习了真实的三阶规律,还把训练集中的噪声也解释成了某种高阶规律。

三个实验可以总结为:

使用的特征 模型复杂度 结果
1,x1,x 1,x 太低 无法表达真实三阶规律,欠拟合
1,x,x2/2!,x3/3!1,x,x^2/2!,x^3/3! 1,x,x2/2!,x3/3! 合适 与真实规律匹配,训练和测试表现都较好
一直到 x19/19!x^{19}/19! x19/19! 太高 容易拟合训练噪声,过拟合

1.5 模型复杂度和数据量的关系

模型是否过拟合,不只取决于模型本身,也取决于数据量。

同一个十九次多项式,在只有少量训练数据时,很容易找到一条复杂曲线穿过这些点;如果提供大量覆盖充分的数据,高阶模型就更难靠偶然的弯曲欺骗所有样本,也更容易判断不必要的高阶权重应该接近0。

所以,模型复杂度没有脱离数据集的绝对标准:

text 复制代码
模型复杂 + 数据很少  -> 更容易过拟合
模型简单 + 规律复杂  -> 更容易欠拟合
模型复杂 + 数据充足  -> 可能发挥更强的表达能力

这也解释了为什么深度学习往往需要大量数据。神经网络具有很强的表达能力,如果只有很少的样本,它也更容易记住训练数据中的偶然细节。

1.6 小结

这一节并不是要把重点从多层感知机转移到多项式回归,而是开始讨论一个更普遍的问题:模型学会训练数据之后,能不能在新数据上仍然有效。

多项式回归之所以出现在这里,是因为它把模型复杂度变成了一个容易控制的变量。通过改变多项式阶数,我们可以在同一份数据上清楚地观察:

text 复制代码
模型太简单  -> 训练集也学不好,欠拟合
复杂度合适  -> 学到主要规律,能够泛化
模型太复杂  -> 容易追随训练噪声,过拟合

我觉得这一节最需要记住的不是"几阶多项式最好",而是下面这句话:

训练误差低只是必要条件,不是最终目标;我们真正关心的是模型在未见数据上的表现。

相关推荐
爱勇宝9 分钟前
没有 Fn 键关触控板?我做了一个双击即用的 Windows 小工具
前端·后端·程序员
沙湖遇雨43 分钟前
8.ByteBuf 的数据模型、生命周期和内存分配
后端
by组态1 小时前
Ricon组态系统API参考手册
前端·后端·物联网
再吃一根胡萝卜1 小时前
Agent 如何选择与生成 SQL —— Text2SQL 全链路详解
后端
苍何1 小时前
把操作流程直接录成 Skill,豆包太夯了!
后端
Go_error1 小时前
在 Go 服务层中如何优雅地管理事务?
后端·go
爱读源码的大都督2 小时前
DeepSeek面试官问:多租户 RAG 系统怎样实现细粒度权限控制?
后端·面试·架构
苍何2 小时前
我们终于成立了 AgentWork 开源社区,16.4 万字豆包工作蓝皮书同步开源(建议收藏)
后端