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

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

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

相关推荐
shepherd1262 小时前
一次把 Spring MVC 文件上传参数“查没了”的排查:multipart、Filter 与 request body 的连环坑
java·后端·spring·mvc
贺国亚3 小时前
Prompt-Injection攻防与OWASP-LLM
后端
卷无止境3 小时前
《Deep Analysis with Polars》进阶分析技巧——深入解读
后端·python
Csvn4 小时前
Python 开发技巧 · logging 最佳实践 —— 告别 print,搭建工程级日志系统
后端·python
智码看视界4 小时前
Day19 Spring Boot启动优化:启动时间从8秒降到2秒
java·spring boot·后端·启动优化
IT_陈寒4 小时前
React中useEffect的依赖项把我坑惨了
前端·人工智能·后端
SQL-First布道者5 小时前
Spring JDBC Ultra 十边型战士
java·spring boot·后端·mysql·spring·mybatis
Dovis(誓平步青云)5 小时前
《精讲Spring Boot后端跑另一个端口:如何解决暴露服务器问题》
服务器·人工智能·spring boot·后端·生成对抗网络
进击的程序猿~5 小时前
Go Slice源码深度解析指南
开发语言·后端·golang