到这里,我们将第一次介绍真正的深度网络。最简单的深度网络称为多层感知机。多层感知机由多层神经元组成,每一层与它的上一层相连,从中接收输入;同时每一层也与它的下一层相连,影响当前层的神经元。
当我们训练容量较大的模型时,我们面临着过拟合的风险。因此,本章将从基本的概念介绍开始讲起,包括过拟合、欠拟合和模型选择。为了解决这些问题,本章将介绍权重衰减和暂退法等正则化技术。我们还将讨论数值稳定性和参数初始化相关的问题,这些问题是成功训练深度网络的关键。在本章的最后,我们将把所介绍的内容应用到一个真实的案例:房价预测。
关于模型计算性能、可伸缩性和效率相关的问题,我们将放在后面讨论。
多层感知机
隐藏层
前面DL03描述了仿射变换,它是一种带有偏置项的线性变换。首先,回想一下softmax回归的模型架构。该模型通过单个仿射变换将我们的输入直接映射到输出,然后进行softmax操作。如果我们的标签通过仿射变换后确实与我们的输入数据相关,那么这种方法确实足够了。但是,仿射变换中的线性是一个很强的假设。
线性模型可能会出错
例如,线性意味着单调假设:任何特征的增大都会导致模型输出的增大(如果对应的权重为正),或者导致模型输出的减小(如果对应的权重为负)。有时这是有道理的。例如,如果我们试图预测一个人是否会偿还贷款。我们可以认为,在其他条件不变的情况下,收入较高的申请人比收入较低的申请人更有可能偿还贷款。但是,虽然收入与还款概率存在单调性,但它们不是线性相关的。收入从0增加到5万,可能比从100万增加到105万
带来更大的还款可能性。处理这一问题的一种方法是对我们的数据进行预处理,使线性变得更合理,如使用收入的对数作为我们的特征。
然而我们可以很容易找出违反单调性的例子。例如,我们想要根据体温预测死亡率。对体温高于37摄氏度的人来说,温度越高风险越大。然而,对体温低于37摄氏度的人来说,温度越高风险就越低。在这种情况下,我们也可以通过一些巧妙的预处理来解决问题。例如,我们可以使用与37摄氏度的距离作为特征。
但是,如何对猫和狗的图像进行分类呢?增加位置(13, 17)处像素的强度是否总是增加(或降低)图像描绘狗的似然?对线性模型的依赖对应于一个隐含的假设,即区分猫和狗的唯一要求是评估单个像素的强度。在一个倒置图像后依然保留类别的世界里,这种方法注定会失败。
与我们前面的例子相比,这里的线性很荒谬,而且我们难以通过简单的预处理来解决这个问题。这是因为任何像素的重要性都以复杂的方式取决于该像素的上下文(周围像素的值)。我们的数据可能会有一种表示,这种表示会考虑到我们在特征之间的相关交互作用。在此表示的基础上建立一个线性模型可能会是合适的,但我们不知道如何手动计算这么一种表示。对于深度神经网络,我们使用观测数据来联合学习隐藏层表示和应用于该表示的线性预测器。
在网络中加入隐藏层
我们可以通过在网络中加入一个或多个隐藏层来克服线性模型的限制,使其能处理更普遍的函数关系类型。要做到这一点,最简单的方法是将许多全连接层堆叠在一起。每一层都输出到上面的层,直到生成最后的输出。我们可以把前L−1层看作表示,把最后一层看作线性预测器。这种架构通常称为多层感知机(multilayerperceptron),通常缩写为MLP。下面,我们以图的方式描述了多层感知机
这个多层感知机有4个输入,3个输出,其隐藏层包含5个隐藏单元。输入层不涉及任何计算,因此使用此网络产生输出只需要实现隐藏层和输出层的计算。因此,这个多层感知机中的层数为2。注意,这两个层都是全连接的。每个输入都会影响隐藏层中的每个神经元,而隐藏层中的每个神经元又会影响输出层中的每个神经元。
具有全连接层的多层感知机的参数开销可能会高得令人望而却步。即使在不改变输入或输出大小的情况下,可能在参数节约和模型有效性之间进行权衡。
从线性到非线性
我们通过矩阵X ∈ Rn×d 来表示n个样本的小批量,其中每个样本具有d个输入特征。对于具有h个隐藏单元的单隐藏层多层感知机,用H ∈ Rn×h表示隐藏层的输出,称为隐藏表示(hiddenrepresentations)。在数学或代码中,H也被称为隐藏层变量(hidden‐layer variable)或隐藏变量(hiddenvariable)。因为隐藏层和输出层都是全连接的,所以我们有隐藏层权重W(1) ∈ Rd×h 和隐藏层偏置b(1) ∈ R1×h以及输出层权重W(2) ∈ Rh×q 和输出层偏置b(2) ∈ R1×q。形式上,我们按如下方式计算单隐藏层多层感知机的输出 O∈ Rn×q:
注意在添加隐藏层之后,模型现在需要跟踪和更新额外的参数。可我们能从中得到什么好处呢?在上面定义的模型里,我们没有好处!原因很简单:上面的隐藏单元由输入的仿射函数给出,而输出(softmax操作前)只是隐藏单元的仿射函数。仿射函数的仿射函数本身就是仿射函数,但是我们之前的线性模型已经能够表示任何仿射函数。
我们可以证明这一等价性,即对于任意权重值,我们只需合并隐藏层,便可产生具有参数 W = W(1)W(2)和b = b(1)W(2) + b(2) 的等价单层模型:
为了发挥多层架构的潜力,我们还需要一个额外的关键要素:在仿射变换之后对每个隐藏单元应用非线性的激活函数(activation function)σ。激活函数的输出(例如,σ(·))被称为活性值(activations)。一般来说,有了激活函数,就不可能再将我们的多层感知机退化成线性模型:
通用近似定理
多层感知机可以通过隐藏神经元,捕捉到输入之间复杂的相互作用,这些神经元依赖于每个输入的值。我们可以很容易地设计隐藏节点来执行任意计算。
例如,在一对输入上进行基本逻辑操作,多层感知机是通用近似器。即使是网络只有一个隐藏层,给定足够的神经元和正确的权重,我们可以对任意函数建模,尽管实际中学习该函数是很困难的。神经网络有点像C语言。C语言和任何其他现代编程语言一样,能够表达任何可计算的程序。但实际上,想出一个符合规范的程序才是最困难的部分。
而且,虽然一个单隐层网络能学习任何函数,但并不意味着我们应该尝试使用单隐藏层网络来解决所有问题。事实上,通过使用更深(而不是更广)的网络,我们可以更容易地逼近许多函数。
激活函数
激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活,它们将输入信号转换为输出的可微运算。大多数激活函数都是非线性的。由于激活函数是深度学习的基础,下面简要介绍一些常见的激活函数。
%matplotlib inline
import torch
from d2l import torch as d2l
ReLU函数
最受欢迎的激活函数是修正线性单元(Rectified linear unit,ReLU),因为它实现简单,同时在各种预测任务中表现良好。ReLU提供了一种非常简单的非线性变换。给定元素x,ReLU函数被定义为该元素与0的最大值:
通俗地说,ReLU函数通过将相应的活性值设为0,仅保留正元素并丢弃所有负元素。为了直观感受一下,我们可以画出函数的曲线图。正如从图中所看到,激活函数是分段线性的。
x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = torch.relu(x)
d2l.plot(x.detach(), y.detach(), 'x', 'relu(x)', figsize=(5, 2.5))
当输入为负时,ReLU函数的导数为0,而当输入为正时,ReLU函数的导数为1。注意,当输入值精确等于0时,ReLU函数不可导。在此时,我们默认使用左侧的导数,即当输入为0时导数为0。我们可以忽略这种情况,因为输入可能永远都不会是0。这里引用一句古老的谚语,“如果微妙的边界条件很重要,我们很可能是在研究数学而非工程”,这个观点正好适用于这里。下面我们绘制ReLU函数的导数。
y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of relu', figsize=(5, 2.5))
使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。这使得优化表现得更好,并且ReLU减轻了困扰以往神经网络的梯度消失问题(稍后将详细介绍)。
注意,ReLU函数有许多变体,包括参数化ReLU(Parameterized ReLU,pReLU)函数 (He et al., 2015)。该变体为ReLU添加了一个线性项,因此即使参数是负的,某些信息仍然可以通过:
sigmoid函数
对于一个定义域在R中的输入,sigmoid函数将输入变换为区间(0, 1)上的输出。因此,sigmoid通常称为挤压函数(squashing function):它将范围(‐inf, inf)中的任意输入压缩到区间(0, 1)中的某个值:
在最早的神经网络中,科学家们感兴趣的是对“激发”或“不激发”的生物神经元进行建模。因此,这一领域的先驱可以一直追溯到人工神经元的发明者麦卡洛克和皮茨,他们专注于阈值单元。阈值单元在其输入低于某个阈值时取值0,当输入超过阈值时取值1。
当人们逐渐关注到到基于梯度的学习时,sigmoid函数是一个自然的选择,因为它是一个平滑的、可微的阈值单元近似。当我们想要将输出视作二元分类问题的概率时,sigmoid仍然被广泛用作输出单元上的激活函数(sigmoid可以视为softmax的特例)。然而,sigmoid在隐藏层中已经较少使用,它在大部分时候被更简单、更容易训练的ReLU所取代。在后面关于循环神经网络的章节中,我们将描述利用sigmoid单元来控制时序信息流的架构。
下面,我们绘制sigmoid函数。注意,当输入接近0时,sigmoid函数接近线性变换。
y = torch.sigmoid(x)
d2l.plot(x.detach(), y.detach(), 'x', 'sigmoid(x)', figsize=(5, 2.5))
sigmoid函数的导数为下面的公式:
sigmoid函数的导数图像如下所示。注意,当输入为0时,sigmoid函数的导数达到最大值0.25;而输入在任一方向上越远离0点时,导数越接近0。
# 清除以前的梯度
x.grad.data.zero_()
y.backward(torch.ones_like(x),retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of sigmoid', figsize=(5, 2.5))
tanh函数
与sigmoid函数类似,tanh(双曲正切)函数也能将其输入压缩转换到区间(‐1, 1)上。tanh函数的公式如下:
下面我们绘制tanh函数。注意,当输入在0附近时,tanh函数接近线性变换。函数的形状类似于sigmoid函数,不同的是tanh函数关于坐标系原点中心对称。
y = torch.tanh(x)
d2l.plot(x.detach(), y.detach(), 'x', 'tanh(x)', figsize=(5, 2.5))
tanh函数的导数是:
tanh函数的导数图像如下所示。当输入接近0时,tanh函数的导数接近最大值1。与我们在sigmoid函数图像中看到的类似,输入在任一方向上越远离0点,导数越接近0。
# 清除以前的梯度
x.grad.data.zero_()
y.backward(torch.ones_like(x),retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of tanh', figsize=(5, 2.5))
总结一下,我们现在了解了如何结合非线性函数来构建具有更强表达能力的多层神经网络架构。顺便说一句,这些知识已经让你掌握了一个类似于1990年左右深度学习从业者的工具。在某些方面,你比在20世纪90年代工作的任何人都有优势,因为你可以利用功能强大的开源深度学习框架,只需几行代码就可以快速构建模型,而以前训练这些网络需要研究人员编写数千行的C或Fortran代码。
- 多层感知机在输出层和输入层之间增加一个或多个全连接隐藏层,并通过激活函数转换隐藏层的输出。
- 常用的激活函数包括ReLU函数、sigmoid函数和tanh函数。
多层感知机的从零开始实现
前面描述了多层感知机(MLP),现在让我们尝试自己实现一个多层感知机。为了与之前softmax回归获得的结果进行比较,我们将继续使用Fashion‐MNIST图像分类数据集。
import torch
from torch import nn
from d2l import torch as d2l
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
初始化模型参数
回想一下,Fashion‐MNIST中的每个图像由 28 × 28 = 784个灰度像素值组成。所有图像共分为10个类别。忽略像素之间的空间结构,我们可以将每个图像视为具有784个输入特征和10个类的简单分类数据集。首先,我们将实现一个具有单隐藏层的多层感知机,它包含256个隐藏单元。注意,我们可以将这两个变量都视为超参数。通常,我们选择2的若干次幂作为层的宽度。因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。
我们用几个张量来表示我们的参数。注意,对于每一层我们都要记录一个权重矩阵和一个偏置向量。跟以前一样,我们要为损失关于这些参数的梯度分配内存。
num_inputs, num_outputs, num_hiddens = 784, 10, 256
W1 = nn.Parameter(torch.randn(
num_inputs, num_hiddens, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))
W2 = nn.Parameter(torch.randn(
num_hiddens, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))
params = [W1, b1, W2, b2]
激活函数
为了确保我们对模型的细节了如指掌,我们将实现ReLU激活函数,而不是直接调用内置的relu函数。
def relu(X):
a = torch.zeros_like(X)
return torch.max(X, a)
模型
因为我们忽略了空间结构,所以我们使用reshape将每个二维图像转换为一个长度为num_inputs的向量。只需几行代码就可以实现我们的模型。
def net(X):
X = X.reshape((-1, num_inputs))
H = relu(X@W1 + b1) # 这里“@”代表矩阵乘法
return (H@W2 + b2)
损失函数
这里我们直接使用高级API中的内置函数来计算softmax和交叉熵损失。
loss = nn.CrossEntropyLoss(reduction='none')
训练
幸运的是,多层感知机的训练过程与softmax回归的训练过程完全相同。可以直接调用d2l包的train_ch3函数,将迭代周期数设置为10,并将学习率设置为0.1.
num_epochs, lr = 10, 0.1
updater = torch.optim.SGD(params, lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, updater)
为了对学习到的模型进行评估,我们将在一些测试数据上应用这个模型。
d2l.predict_ch3(net, test_iter)
其它参数不变,学习率改为0.15结果
多层感知机的简洁实现
与softmax回归的简洁实现相比,唯一的区别是我们添加了2个全连接层(之前我们只添加了1个全连接层)。第一层是隐藏层,它包含256个隐藏单元,并使用了ReLU激活函数。第二层是输出层。
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10))
def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights);
训练过程的实现与我们实现softmax回归时完全相同,这种模块化设计使我们能够将与模型架构有关的内容独立出来。
batch_size, lr, num_epochs = 256, 0.1, 10
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=lr)
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
我们可以使用高级API更简洁地实现多层感知机。
对于相同的分类问题,多层感知机的实现与softmax回归的实现相同,只是多层感知机的实现里增加了带有激活函数的隐藏层。
模型选择、欠拟合和过拟合
作为机器学习科学家,我们的目标是发现模式(pattern)。但是,我们如何才能确定模型是真正发现了一种泛化的模式,而不是简单地记住了数据呢?例如,我们想要在患者的基因数据与痴呆状态之间寻找模式,其中标签是从集合{痴呆, 轻度认知障碍, 健康}中提取的。因为基因可以唯一确定每个个体(不考虑双胞胎),所以在这个任务中是有可能记住整个数据集的。
我们不想让模型只会做这样的事情:“那是鲍勃!我记得他!他有痴呆症!”。原因很简单:当我们将来部署该模型时,模型需要判断从未见过的患者。只有当模型真正发现了一种泛化模式时,才会作出有效的预测。更正式地说,我们的目标是发现某些模式,这些模式捕捉到了我们训练集潜在总体的规律。如果成功做到了这点,即使是对以前从未遇到过的个体,模型也可以成功地评估风险。如何发现可以泛化的模式是机器学习的根本问题。
困难在于,当我们训练模型时,我们只能访问数据中的小部分样本。最大的公开图像数据集包含大约一百万张图像。而在大部分时候,我们只能从数千或数万个数据样本中学习。在大型医院系统中,我们可能会访问数十万份医疗记录。当我们使用有限的样本时,可能会遇到这样的问题:当收集到更多的数据时,会发现之前找到的明显关系并不成立。
将模型在训练数据上拟合的比在潜在分布中更接近的现象称为过拟合(overfitting),用于对抗过拟合的技术称为正则化(regularization)。在前面的章节中,有些读者可能在用Fashion‐MNIST数据集做实验时已经观察到了这种过拟合现象。在实验中调整模型架构或超参数时会发现:如果有足够多的神经元、层数和训练迭代周期,模型最终可以在训练集上达到完美的精度,此时测试集的准确性却下降了。
训练误差和泛化误差
为了进一步讨论这一现象,我们需要了解训练误差和泛化误差。训练误差(training error)是指,模型在训练数据集上计算得到的误差。泛化误差(generalization error)是指,模型应用在同样从原始样本的分布中抽取的无限多数据样本时,模型误差的期望。
问题是,我们永远不能准确地计算出泛化误差。这是因为无限多的数据样本是一个虚构的对象。在实际中,我们只能通过将模型应用于一个独立的测试集来估计泛化误差,该测试集由随机选取的、未曾在训练集中出现的数据样本构成。
下面的三个思维实验将有助于更好地说明这种情况。假设一个大学生正在努力准备期末考试。一个勤奋的学生会努力做好练习,并利用往年的考试题目来测试自己的能力。尽管如此,在过去的考试题目上取得好成绩并不能保证他会在真正考试时发挥出色。例如,学生可能试图通过死记硬背考题的答案来做准备。他甚至可以完全记住过去考试的答案。另一名学生可能会通过试图理解给出某些答案的原因来做准备。在大多数情况下,后者会考得更好。
类似地,考虑一个简单地使用查表法来回答问题的模型。如果允许的输入集合是离散的并且相当小,那么也许在查看许多训练样本后,该方法将执行得很好。但当这个模型面对从未见过的例子时,它表现的可能比随机猜测好不到哪去。这是因为输入空间太大了,远远不可能记住每一个可能的输入所对应的答案。例如,考虑28 × 28的灰度图像。如果每个像素可以取256个灰度值中的一个,则有256784个可能的图像。这意味着指甲大小的低分辨率灰度图像的数量比宇宙中的原子要多得多。即使我们可能遇到这样的数据,我们也不可能存储整个查找表。
统计学习理论
要成为一名优秀的机器学习科学家需要具备批判性思考能力。假设是存在漏洞的,即很容易找出假设失效的情况。如果我们根据从加州大学旧金山分校医学中心的患者数据训练死亡风险预测模型,并将其应用于马萨诸塞州综合医院的患者数据,结果会怎么样?这两个数据的分布可能不完全一样。此外,抽样过程可能与时间有关。比如当我们对微博的主题进行分类时,新闻周期会使得正在讨论的话题产生时间依赖性,从而违反独立性假设。
有时候我们即使轻微违背独立同分布假设,模型仍将继续运行得非常好。比如,我们有许多有用的工具已经应用于现实,如人脸识别、语音识别和语言翻译。毕竟,几乎所有现实的应用都至少涉及到一些违背独立同分布假设的情况。
有些违背独立同分布假设的行为肯定会带来麻烦。比如,我们试图只用来自大学生的人脸数据来训练一个人脸识别系统,然后想要用它来监测疗养院中的老人。这不太可能有效,因为大学生看起来往往与老年人有很大的不同。
在接下来的章节中,我们将讨论因违背独立同分布假设而引起的问题。目前,即使认为独立同分布假设是理所当然的,理解泛化性也是一个困难的问题。此外,能够解释深层神经网络泛化性能的理论基础,也仍在继续困扰着学习理论领域最伟大的学者们。
当我们训练模型时,我们试图找到一个能够尽可能拟合训练数据的函数。但是如果它执行地“太好了”,而不能对看不见的数据做到很好泛化,就会导致过拟合。这种情况正是我们想要避免或控制的。深度学习中有许多启发式的技术旨在防止过拟合。
模型复杂性
当我们有简单的模型和大量的数据时,我们期望泛化误差与训练误差相近。当我们有更复杂的模型和更少的样本时,我们预计训练误差会下降,但泛化误差会增大。模型复杂性由什么构成是一个复杂的问题。一个模型是否能很好地泛化取决于很多因素。例如,具有更多参数的模型可能被认为更复杂,参数有更大取值范围的模型可能更为复杂。通常对于神经网络,我们认为需要更多训练迭代的模型比较复杂,而需要早停(early stopping)的模型(即较少训练迭代周期)就不那么复杂。
我们很难比较本质上不同大类的模型之间(例如,决策树与神经网络)的复杂性。就目前而言,一条简单的经验法则相当有用:统计学家认为,能够轻松解释任意事实的模型是复杂的,而表达能力有限但仍能很好地解释数据的模型可能更有现实用途。在哲学上,这与波普尔的科学理论的可证伪性标准密切相关:如果一个理论能拟合数据,且有具体的测试可以用来证明它是错误的,那么它就是好的。这一点很重要,因为所有的统计估计都是事后归纳。也就是说,我们在观察事实之后进行估计,因此容易受到相关谬误的影响。目前,我们将把哲学放在一边,坚持更切实的问题。
模型选择
在机器学习中,我们通常在评估几个候选模型后选择最终的模型。这个过程叫做模型选择。有时,需要进行比较的模型在本质上是完全不同的(比如,决策树与线性模型)。又有时,我们需要比较不同的超参数设置下的同一类模型。
例如,训练多层感知机模型时,我们可能希望比较具有不同数量的隐藏层、不同数量的隐藏单元以及不同的激活函数组合的模型。为了确定候选模型中的最佳模型,我们通常会使用验证集。
验证集
原则上,在我们确定所有的超参数之前,我们不希望用到测试集。如果我们在模型选择过程中使用测试数据,可能会有过拟合测试数据的风险,那就麻烦大了。如果我们过拟合了训练数据,还可以在测试数据上的评估来判断过拟合。但是如果我们过拟合了测试数据,我们又该怎么知道呢?
因此,我们决不能依靠测试数据进行模型选择。然而,我们也不能仅仅依靠训练数据来选择模型,因为我们无法估计训练数据的泛化误差。
在实际应用中,情况变得更加复杂。虽然理想情况下我们只会使用测试数据一次,以评估最好的模型或比较一些模型效果,但现实是测试数据很少在使用一次后被丢弃。我们很少能有充足的数据来对每一轮实验采用全新测试集。
解决此问题的常见做法是将我们的数据分成三份,除了训练和测试数据集之外,还增加一个验证数据集(validation dataset),也叫验证集(validation set)。但现实是验证数据和测试数据之间的边界模糊得令人担忧。除非另有明确说明,否则在这本书的实验中,我们实际上是在使用应该被正确地称为训练数据和验证数据的数据集,并没有真正的测试数据集。因此,书中每次实验报告的准确度都是验证集准确度,而不是测试集准确度。
k折交叉验证
当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集。这个问题的一个流行的解决方案是采用K折交叉验证。这里,原始训练数据被分成K个不重叠的子集。然后执行K次模型训练和验证,每次在K − 1个子集上进行训练,并在剩余的一个子集(在该轮中没有用于训练的子集)上进行验证。最后,通过对K次实验的结果取平均来估计训练和验证误差。
欠拟合还是过拟合?
当我们比较训练和验证误差时,我们要注意两种常见的情况。首先,我们要注意这样的情况:训练误差和验证误差都很严重,但它们之间仅有一点差距。如果模型不能降低训练误差,这可能意味着模型过于简单(即表达能力不足),无法捕获试图学习的模式。此外,由于我们的训练和验证误差之间的泛化误差很小,我们有理由相信可以用一个更复杂的模型降低训练误差。这种现象被称为欠拟合(underfitting)。
另一方面,当我们的训练误差明显低于验证误差时要小心,这表明严重的过拟合(overfitting)。注意,过拟合并不总是一件坏事。特别是在深度学习领域,众所周知,最好的预测模型在训练数据上的表现往往比在保留(验证)数据上好得多。最终,我们通常更关心验证误差,而不是训练误差和验证误差之间的差距。
是否过拟合或欠拟合可能取决于模型复杂性和可用训练数据集的大小,这两个点将在下面进行讨论。
模型复杂性
为了说明一些关于过拟合和模型复杂性的经典直觉,我们给出一个多项式的例子。给定由单个特征x和对应实数标签y组成的训练数据,我们试图找到下面的d阶多项式来估计标签y。
这只是一个线性回归问题,我们的特征是x的幂给出的,模型的权重是wi给出的,偏置是w0给出的(因为对于所有的x都有x0 = 1)。由于这只是一个线性回归问题,我们可以使用平方误差作为我们的损失函数。高阶多项式函数比低阶多项式函数复杂得多。高阶多项式的参数较多,模型函数的选择范围较广。因此在固定训练数据集的情况下,高阶多项式函数相对于低阶多项式的训练误差应该始终更低(最坏也是相等)。事实上,当数据样本包含了x的不同值时,函数阶数等于数据样本数量的多项式函数可以完美拟合训练集。在下图中,我们直观地描述了多项式的阶数和欠拟合与过拟合之间的关系。
数据集大小
另一个重要因素是数据集的大小。训练数据集中的样本越少,我们就越有可能(且更严重地)过拟合。随着训练数据量的增加,泛化误差通常会减小。此外,一般来说,更多的数据不会有什么坏处。对于固定的任务和数据分布,模型复杂性和数据集大小之间通常存在关系。给出更多的数据,我们可能会尝试拟合一个更复杂的模型。能够拟合更复杂的模型可能是有益的。如果没有足够的数据,简单的模型可能更有用。对于许多任务,深度学习只有在有数千个训练样本时才优于线性模型。从一定程度上来说,深度学习目前的生机要归功于廉价存储、互联设备以及数字化经济带来的海量数据集。
多项式回归
生成数据集
给定x,我们将使用以下三阶多项式来生成训练和测试数据的标签:
噪声项ϵ服从均值为0且标准差为0.1的正态分布。在优化的过程中,我们通常希望避免非常大的梯度值或损失值。这就是我们将特征从xi调整为xi / i ! 的原因,这样可以避免很大的i带来的特别大的指数值。我们将为训练集和测试集各生成100个样本。
import math
import numpy as np
import torch
from torch import nn
from d2l import torch as d2l
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])
features = np.random.normal(size=(n_train + n_test, 1))
np.random.shuffle(features)
poly_features = np.power(features, np.arange(max_degree).reshape(1, -1))
for i in range(max_degree):
poly_features[:, i] /= math.gamma(i + 1) # gamma(n)=(n-1)!
# labels的维度:(n_train+n_test,)
labels = np.dot(poly_features, true_w)
labels += np.random.normal(scale=0.1, size=labels.shape)
同样,存储在poly_features中的单项式由gamma函数重新缩放,其中Γ(n) = (n − 1)!。从生成的数据集中查看一下前2个样本,第一个值是与偏置相对应的常量特征。
# NumPy ndarray转换为tensor
true_w, features, poly_features, labels = [torch.tensor(x, dtype=torch.float32) for x in [true_w, features, poly_features, labels]]
'''
(tensor([[ 0.4730],
[-0.0504]]),
tensor([[ 1.0000e+00, 4.7295e-01, 1.1184e-01, 1.7632e-02, 2.0848e-03,
1.9720e-04, 1.5544e-05, 1.0503e-06, 6.2090e-08, 3.2629e-09,
1.5432e-10, 6.6350e-12, 2.6151e-13, 9.5138e-15, 3.2140e-16,
1.0134e-17, 2.9955e-19, 8.3337e-21, 2.1897e-22, 5.4507e-24],
[ 1.0000e+00, -5.0352e-02, 1.2676e-03, -2.1276e-05, 2.6782e-07,
-2.6971e-09, 2.2634e-11, -1.6281e-13, 1.0247e-15, -5.7328e-18,
2.8866e-20, -1.3213e-22, 5.5442e-25, -2.1474e-27, 7.7232e-30,
-2.5925e-32, 8.1586e-35, -2.4165e-37, 6.7596e-40, -1.7909e-42]]),
tensor([5.2267, 4.9471]))
'''
对模型进行训练和测试
首先让我们实现一个函数来评估模型在给定数据集上的损失。
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])
features = np.random.normal(size=(n_train + n_test, 1))
np.random.shuffle(features)
poly_features = np.power(features, np.arange(max_degree).reshape(1, -1))
for i in range(max_degree):
poly_features[:, i] /= math.gamma(i + 1) # gamma(n)=(n-1)!
# labels的维度:(n_train+n_test,)
labels = np.dot(poly_features, true_w)
labels += np.random.normal(scale=0.1, size=labels.shape)
现在定义训练函数。
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())
三阶多项式函数拟合(正常)
我们将首先使用三阶多项式函数,它与数据生成函数的阶数相同。结果表明,该模型能有效降低训练损失和测试损失。学习到的模型参数也接近真实值w = [5, 1.2, −3.4, 5.6]。
# 从多项式特征中选择前4个维度,即1,x,x^2/2!,x^3/3!
train(poly_features[:n_train, :4], poly_features[n_train:, :4], labels[:n_train], labels[n_train:])
线性函数拟合(欠拟合)
让我们再看看线性函数拟合,减少该模型的训练损失相对困难。在最后一个迭代周期完成后,训练损失仍然很高。当用来拟合非线性模式(如这里的三阶多项式函数)时,线性模型容易欠拟合。
# 从多项式特征中选择前2个维度,即1和x
train(poly_features[:n_train, :2], poly_features[n_train:, :2],
labels[:n_train], labels[n_train:])
权重衰减
这里我们将介绍一些正则化模型的技术。我们总是可以通过去收集更多的训练数据来缓解过拟合。但这可能成本很高,耗时颇多,或者完全超出我们的控制,因而在短期内不可能做到。假设我们已经拥有尽可能多的高质量数据,我们便可以将重点放在正则化技术上。
实际上,限制特征的数量是缓解过拟合的一种常用技术。然而,简单地丢弃特征对这项工作来说可能过于生硬。我们继续思考多项式回归的例子,考虑高维输入可能发生的情况。多项式对多变量数据的自然扩展称为单项式(monomials),也可以说是变量幂的乘积。单项式的阶数是幂的和。例如,x12x2和x3x52都是3次单项式。
在训练参数化机器学习模型时,权重衰减(weight decay)是最广泛使用的正则化的技术之一,它通常也被称为L2正则化。这项技术通过函数与零的距离来衡量函数的复杂度,因为在所有函数f中,函数f = 0(所有输入都得到值0)在某种意义上是最简单的。但是我们应该如何精确地测量一个函数和零之间的距离呢?没有一个正确的答案。事实上,函数分析和巴拿赫空间理论的研究,都在致力于回答这个问题。
一种简单的方法是通过线性函数 f(x) = w⊤x 中的权重向量的某个范数来度量其复杂性,例如∥w∥2。要保证权重向量比较小,最常用方法是将其范数作为惩罚项加到最小化损失的问题中。将原来的训练目标最小化训练标签上的预测损失,调整为最小化预测损失和惩罚项之和。现在,如果我们的权重向量增长的太大,我们的学习算法可能会更集中于最小化权重范数∥w∥2。这正是我们想要的。让我们回顾一下线性回归例子。我们的损失由下式给出:
回想一下,x(i)是样本i的特征,y(i)是样本i的标签,(w, b)是权重和偏置参数。为了惩罚权重向量的大小,我们必须以某种方式在损失函数中添加∥w∥2,但是模型应该如何平衡这个新的额外惩罚的损失?实际上,我们通过正则化常数λ来描述这种权衡,这是一个非负超参数,我们使用验证数据拟合:
对于λ = 0,我们恢复了原来的损失函数。对于λ > 0,我们限制∥w∥的大小。这里我们仍然除以2:当我们取一个二次函数的导数时,2和1/2会抵消,以确保更新表达式看起来既漂亮又简单。为什么在这里我们使用平方范数而不是标准范数(即欧几里得距离)?我们这样做是为了便于计算。通过平方L2范数,我们去掉平方根,留下权重向量每个分量的平方和。这使得惩罚的导数很容易计算:导数的和等于和的导数。
此外,为什么我们首先使用L2范数,而不是L1范数。事实上,这个选择在整个统计领域中都是有效的和受欢迎的。L2正则化线性模型构成经典的岭回归(ridge regression)算法,L1正则化线性回归是统计学中类似的基本模型,通常被称为套索回归(lasso regression)。使用L2范数的一个原因是它对权重向量的大分量施加了巨大的惩罚。这使得我们的学习算法偏向于在大量特征上均匀分布权重的模型。在实践中,这可能使它们对单个变量中的观测误差更为稳定。相比之下,L1惩罚会导致模型将权重集中在一小部分特征上,而将其他权重清除为零。这称为特征选择(feature selection),这可能是其他场景下需要的。
L2正则化回归的小批量随机梯度下降更新如下式:
根据之前章节所讲的,我们根据估计值与观测值之间的差异来更新w。然而,我们同时也在试图将w的大小缩小到零。这就是为什么这种方法有时被称为权重衰减。我们仅考虑惩罚项,优化算法在训练的每一步衰减权重。与特征选择相比,权重衰减为我们提供了一种连续的机制来调整函数的复杂度。较小的λ值对应较少约束的w,而较大的λ值对w的约束更大。
高维线性回归
我们通过一个简单的例子来演示权重衰减。
%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l
首先,我们像以前一样生成一些数据,生成公式如下:
我们选择标签是关于输入的线性函数。标签同时被均值为0,标准差为0.01高斯噪声破坏。为了使过拟合的效果更加明显,我们可以将问题的维数增加到d = 200,并使用一个只包含20个样本的小训练集。
n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5
true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05
train_data = d2l.synthetic_data(true_w, true_b, n_train)
train_iter = d2l.load_array(train_data, batch_size)
test_data = d2l.synthetic_data(true_w, true_b, n_test)
test_iter = d2l.load_array(test_data, batch_size, is_train=False)
从零开始实现
下面我们将从头开始实现权重衰减,只需将L2的平方惩罚添加到原始目标函数中。
初始化模型参数
首先,我们将定义一个函数来随机初始化模型参数。
def init_params():
w = torch.normal(0, 1, size=(num_inputs, 1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)
return [w, b]
定义L2范数惩罚
实现这一惩罚最方便的方法是对所有项求平方后并将它们求和。
def l2_penalty(w):
return torch.sum(w.pow(2)) / 2
定义训练代码实现
下面的代码将模型拟合训练数据集,并在测试数据集上进行评估。从 3节以来,线性网络和平方损失没有变化,所以我们通过d2l.linreg和d2l.squared_loss导入它们。唯一的变化是损失现在包括了惩罚项。
def train(lambd):
w, b = init_params()
net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
num_epochs, lr = 100, 0.003
animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
xlim=[5, num_epochs], legend=['train', 'test'])
for epoch in range(num_epochs):
for X, y in train_iter:
# 增加了L2范数惩罚项,
# 广播机制使l2_penalty(w)成为一个长度为batch_size的向量
l = loss(net(X), y) + lambd * l2_penalty(w)
l.sum().backward()
d2l.sgd([w, b], lr, batch_size)
if (epoch + 1) % 5 == 0:
animator.add(epoch + 1, (d2l.evaluate_loss(net, train_iter, loss),
d2l.evaluate_loss(net, test_iter, loss)))
print('w的L2范数是:', torch.norm(w).item())
忽略正则化直接训练
train(lambd=0)
# w的L2范数是: 12.963241577148438
使用权重衰减
下面,我们使用权重衰减来运行代码。注意,在这里训练误差增大,但测试误差减小。这正是我们期望从正则化中得到的效果。
train(lambd=3)
# w的L2范数是: 0.3620818555355072
至次,我们只接触到一个简单线性函数的概念。此外,由什么构成一个简单的非线性函数可能是一个更复杂的问题。例如,再生核希尔伯特空间(RKHS)允许在非线性环境中应用为线性函数引入的工具。不幸的是,基于RKHS的算法往往难以应用到大型、高维的数据。在这本书中,我们将默认使用简单的启发式方法,即在深层网络的所有层上应用权重衰减。
- 正则化是处理过拟合的常用方法:在训练集的损失函数中加入惩罚项,以降低学习到的模型的复杂度。
- 保持模型简单的一个特别的选择是使用L2惩罚的权重衰减。这会导致学习算法更新步骤中的权重衰减。
- 权重衰减功能在深度学习框架的优化器中提供。
- 在同一训练代码实现中,不同的参数集可以有不同的更新行为。
暂退法(Dropout)
重新审视过拟合
当面对更多的特征而样本不足时,线性模型往往会过拟合。相反,当给出更多样本而不是特征,通常线性模型不会过拟合。不幸的是,线性模型泛化的可靠性是有代价的。简单地说,线性模型没有考虑到特征之间的交互作用。对于每个特征,线性模型必须指定正的或负的权重,而忽略其他特征。
泛化性和灵活性之间的这种基本权衡被描述为偏差-方差权衡(bias‐variance tradeoff)。线性模型有很高的偏差:它们只能表示一小类函数。然而,这些模型的方差很低:它们在不同的随机数据样本上可以得出相似的结果。
深度神经网络位于偏差‐方差谱的另一端。与线性模型不同,神经网络并不局限于单独查看每个特征,而是学习特征之间的交互。例如,神经网络可能推断“尼日利亚”和“西联汇款”一起出现在电子邮件中表示垃圾邮件,但单独出现则不表示垃圾邮件。
深度网络的泛化性质令人费解,而这种泛化性质的数学基础仍然是悬而未决的研究问题。我们鼓励喜好研究理论的读者更深入地研究这个主题。本节,我们将着重对实际工具的探究,这些工具倾向于改进深层网络的泛化性。
扰动的稳健性
在2014年,斯里瓦斯塔瓦等人 (Srivastava et al., 2014) 就如何将毕晓普的想法应用于网络的内部层提出了一个想法:在训练过程中,他们建议在计算后续层之前向网络的每一层注入噪声。因为当训练一个有多层的深层网络时,注入噪声只会在输入‐输出映射上增强平滑性。
这个想法被称为暂退法(dropout)。暂退法在前向传播过程中,计算每一内部层的同时注入噪声,这已经成为训练神经网络的常用技术。这种方法之所以被称为暂退法,因为我们从表面上看是在训练过程中丢弃(dropout)一些神经元。在整个训练过程的每一次迭代中,标准暂退法包括在计算下一层之前将当前层中的一些节点置零。
需要说明的是,暂退法的原始论文提到了一个关于有性繁殖的类比:神经网络过拟合与每一层都依赖于前一层激活值相关,称这种情况为“共适应性”。作者认为,暂退法会破坏共适应性,就像有性生殖会破坏共适应的基因一样。
那么关键的挑战就是如何注入这种噪声。一种想法是以一种无偏向(unbiased)的方式注入噪声。这样在固定住其他层时,每一层的期望值等于没有噪音时的值。
在毕晓普的工作中,他将高斯噪声添加到线性模型的输入中。在每次训练迭代中,他将从均值为零的分布ϵ ∼N (0, σ2) 采样噪声添加到输入x,从而产生扰动点x′ = x + ϵ,预期是E[x′] = x。
在标准暂退法正则化中,通过按保留(未丢弃)的节点的分数进行规范化来消除每一层的偏差。换言之,每个中间活性值h以暂退概率p由随机变量h′替换,如下所示:
根据此模型的设计,其期望值保持不变,即E[h′] = h。
实践中的暂退法
当我们将暂退法应用到隐藏层,以p的概率将隐藏单元置为零时,结果可以看作一个只包含原始神经元子集的网络。比如在 图4.6.1中,删除了h2和h5,因此输出的计算不再依赖于h2或h5,并且它们各自的梯度在执行反向传播时也会消失。这样,输出层的计算不能过度依赖于h1, . . . , h5的任何一个元素。
通常,我们在测试时不用暂退法。给定一个训练好的模型和一个新的样本,我们不会丢弃任何节点,因此不需要标准化。然而也有一些例外:一些研究人员在测试时使用暂退法,用于估计神经网络预测的“不确定性”:如果通过许多不同的暂退法遮盖后得到的预测结果都是一致的,那么我们可以说网络发挥更稳定。
从零开始实现
要实现单层的暂退法函数,我们从均匀分布U[0, 1]中抽取样本,样本数与这层神经网络的维度一致。然后我们保留那些对应样本大于p的节点,把剩下的丢弃。
在下面的代码中,我们实现 dropout_layer 函数,该函数以dropout的概率丢弃张量输入X中的元素,如上所述重新缩放剩余部分:将剩余部分除以1.0-dropout。
import torch
from torch import nn
from d2l import torch as d2l
def dropout_layer(X, dropout):
assert 0 <= dropout <= 1
# 在本情况中,所有元素都被丢弃
if dropout == 1:
return torch.zeros_like(X)
# 在本情况中,所有元素都被保留
if dropout == 0:
return X
mask = (torch.rand(X.shape) > dropout).float()
return mask * X / (1.0 - dropout)
我们可以通过下面几个例子来测试dropout_layer函数。我们将输入X通过暂退法操作,暂退概率分别为0、0.5和1。
X = torch.arange(16, dtype=torch.float32).reshape((2, 8))
print(X)
print(dropout_layer(X, 0.))
print(dropout_layer(X, 0.5))
print(dropout_layer(X, 1.))
'''
tensor([[ 0., 1., 2., 3., 4., 5., 6., 7.],
[ 8., 9., 10., 11., 12., 13., 14., 15.]])
tensor([[ 0., 1., 2., 3., 4., 5., 6., 7.],
[ 8., 9., 10., 11., 12., 13., 14., 15.]])
tensor([[ 0., 0., 0., 0., 8., 10., 12., 14.],
[16., 0., 20., 22., 0., 26., 28., 30.]])
tensor([[0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0.]])
'''
定义模型参数
同样,我们使用Fashion‐MNIST数据集。我们定义具有两个隐藏层的多层感知机,每个隐藏层包含256个单元。
num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256
我们可以将暂退法应用于每个隐藏层的输出(在激活函数之后),并且可以为每一层分别设置暂退概率:常见的技巧是在靠近输入层的地方设置较低的暂退概率。下面的模型将第一个和第二个隐藏层的暂退概率分别设置为0.2和0.5,并且暂退法只在训练期间有效。
dropout1, dropout2 = 0.2, 0.5
class Net(nn.Module):
def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2,
is_training=True):
super(Net, self).__init__()
self.num_inputs = num_inputs
self.training = is_training
self.lin1 = nn.Linear(num_inputs, num_hiddens1)
self.lin2 = nn.Linear(num_hiddens1, num_hiddens2)
self.lin3 = nn.Linear(num_hiddens2, num_outputs)
self.relu = nn.ReLU()
def forward(self, X):
H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
# 只有在训练模型时才使用dropout
if self.training:
# 在第一个全连接层之后添加一个dropout层
H1 = dropout_layer(H1, dropout1)
H2 = self.relu(self.lin2(H1))
if self.training:
# 在第二个全连接层之后添加一个dropout层
H2 = dropout_layer(H2, dropout2)
out = self.lin3(H2)
return out
net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)
训练和测试
这类似于前面描述的多层感知机训练和测试。
num_epochs, lr, batch_size = 10, 0.5, 256
loss = nn.CrossEntropyLoss(reduction='none')
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
简洁实现
对于深度学习框架的高级API,我们只需在每个全连接层之后添加一个Dropout层,将暂退概率作为唯一的参数传递给它的构造函数。在训练时,Dropout层将根据指定的暂退概率随机丢弃上一层的输出(相当于下一层的输入)。在测试时,Dropout层仅传递数据。
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
# 在第一个全连接层之后添加一个dropout层
nn.Dropout(dropout1),
nn.Linear(256, 256),
nn.ReLU(),
# 在第二个全连接层之后添加一个dropout层
nn.Dropout(dropout2),
nn.Linear(256, 10))
def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights);
接下来,我们对模型进行训练和测试。
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
• 暂退法在前向传播过程中,计算每一内部层的同时丢弃一些神经元。
• 暂退法可以避免过拟合,它通常与控制权重向量的维数和大小结合使用的。
• 暂退法将活性值h替换为具有期望值h的随机变量。
• 暂退法仅在训练期间使用。
前向传播、反向传播和计算图
至此,我们已经学习了如何用小批量随机梯度下降训练模型。然而当实现该算法时,我们只考虑了通过前向传播(forward propagation)所涉及的计算。在计算梯度时,我们只调用了深度学习框架提供的反向传播函数,而不知其所以然。
梯度的自动计算(自动微分)大大简化了深度学习算法的实现。在自动微分之前,即使是对复杂模型的微小调整也需要手工重新计算复杂的导数,学术论文也不得不分配大量页面来推导更新规则。这里将通过一些基本的数学和计算图,深入探讨反向传播的细节。首先,我们将重点放在带权重衰减(L2正则化)的单隐藏层多层感知机上。
前向传播
前向传播(forward propagation或forward pass)指的是:按顺序(从输入层到输出层)计算和存储神经网络中每层的结果。
我们将一步步研究单隐藏层神经网络的机制,为了简单起见,我们假设输入样本是 x ∈ Rd,并且我们的隐藏层不包括偏置项。这里的中间变量是:
其中W(1) ∈ Rh×d 是隐藏层的权重参数。将中间变量z ∈ Rh通过激活函数ϕ后,我们得到长度为h的隐藏激活向量:
隐藏变量h也是一个中间变量。假设输出层的参数只有权重W(2) ∈ Rq×h,我们可以得到输出层变量,它是一个长度为q的向量:
假设损失函数为l,样本标签为y,我们可以计算单个数据样本的损失项,
根据L2正则化的定义,给定超参数λ,正则化项为
其中矩阵的Frobenius范数是将矩阵展平为向量后应用的L2范数。最后,模型在给定数据样本上的正则化损失为:
在下面的讨论中,我们将J称为目标函数(objective function)。
一些解释:
前向传播计算图
绘制计算图有助于我们可视化计算中操作符和变量的依赖关系。图4.7.1 是与上述简单网络相对应的计算图,其中正方形表示变量,圆圈表示操作符。左下角表示输入,右上角表示输出。注意显示数据流的箭头方向主要是向右和向上的。
反向传播
反向传播(backward propagation或backpropagation)指的是计算神经网络参数梯度的方法。简言之,该方法根据微积分中的链式规则,按相反的顺序从输出层到输入层遍历网络。该算法存储了计算某些参数梯度时所需的任何中间变量(偏导数)。假设我们有函数Y = f(X)和Z = g(Y),其中输入和输出X, Y, Z是任意形状的张量。利用链式法则,我们可以计算Z关于X的导数
在这里,我们使用prod运算符在执行必要的操作(如换位和交换输入位置)后将其参数相乘。对于向量,这很简单,它只是矩阵‐矩阵乘法。对于高维张量,我们使用适当的对应项。运算符prod指代了所有的这些符号。
单隐藏层简单网络的参数是 W(1)和W(2)。反向传播的目的是计算梯度∂J/∂W(1)和 ∂J/∂W(2)。为此,我们应用链式法则,依次计算每个中间变量和参数的梯度。计算的顺序与前向传播中执行的顺序相反,因为我们需要从计算图的结果开始,并朝着参数的方向努力。第一步是计算目标函数J = L + s相对于损失项L和正则项s的梯度。
接下来,我们根据链式法则计算目标函数关于输出层变量o的梯度:
接下来,我们计算正则化项相对于两个参数的梯度:
现在我们可以计算最接近输出层的模型参数的梯度 ∂J/∂W(2) ∈ Rq×h。使用链式法则得出:
为了获得关于W(1)的梯度,我们需要继续沿着输出层到隐藏层反向传播。关于隐藏层输出的梯度∂J/∂h ∈Rh由下式给出:
由于激活函数ϕ是按元素计算的,计算中间变量z的梯度∂J/∂z ∈ Rh 需要使用按元素乘法运算符,我们用⊙表示:
最后,我们可以得到最接近输入层的模型参数的梯度 ∂J/∂W(1) ∈ Rh×d。根据链式法则,我们得到:
反向传播的实际例子可参考:https://blog.csdn.net/baidu_41798699/article/details/120966344
训练神经网络
在训练神经网络时,前向传播和反向传播相互依赖。对于前向传播,我们沿着依赖的方向遍历计算图并计算其路径上的所有变量。然后将这些用于反向传播,其中计算顺序与计算图的相反。
在训练神经网络时,在初始化模型参数后,我们交替使用前向传播和反向传播,利用反向传播给出的梯度来更新模型参数。注意,反向传播重复利用前向传播中存储的中间值,以避免重复计算。带来的影响之一是我们需要保留中间值,直到反向传播完成。这也是训练比单纯的预测需要更多的内存(显存)的原因之一。此外,这些中间值的大小与网络层的数量和批量的大小大致成正比。因此,使用更大的批量来训练更深层次的网络更容易导致内存不足(out of memory)错误。
数值稳定性和模型初始化
到目前为止,我们实现的每个模型都是根据某个预先指定的分布来初始化模型的参数。有人会认为初始化方案是理所当然的,忽略了如何做出这些选择的细节。甚至有人可能会觉得,初始化方案的选择并不是特别重要。相反,初始化方案的选择在神经网络学习中起着举足轻重的作用,它对保持数值稳定性至关重要。此外,这些初始化方案的选择可以与非线性激活函数的选择有趣的结合在一起。我们选择哪个函数以及如何初始化参数可以决定优化算法收敛的速度有多快。糟糕选择可能会导致我们在训练时遇到梯度爆炸或梯度消失。
梯度消失和梯度爆炸
考虑一个具有L层、输入x和输出o的深层网络。每一层l由变换fl定义,该变换的参数为权重W(l),其隐藏变量是h(l)(令 h(0) = x)。我们的网络可以表示为:
如果所有隐藏变量和输入都是向量,我们可以将o关于任何一组参数W(l)的梯度写为下式:
换言之,该梯度是L − l个矩阵 M(L)· . . . · M(l+1) 与梯度向量 v(l)的乘积。因此,我们容易受到数值下溢问题的影响。当将太多的概率乘在一起时,这些问题经常会出现。在处理概率时,一个常见的技巧是切换到对数空间,即将数值表示的压力从尾数转移到指数。不幸的是,上面的问题更为严重:最初,矩阵 M(l) 可能具有各种各样的特征值。他们可能很小,也可能很大;他们的乘积可能非常大,也可能非常小。
不稳定梯度带来的风险不止在于数值表示;不稳定梯度也威胁到我们优化算法的稳定性。我们可能面临一些问题。要么是梯度爆炸(gradient exploding)问题:参数更新过大,破坏了模型的稳定收敛;要么是梯度消失(gradient vanishing)问题:参数更新过小,在每次更新时几乎不会移动,导致模型无法学习。
梯度消失
曾经sigmoid函数1/(1 + exp(−x))很流行,因为它类似于阈值函数。由于早期的人工神经网络受到生物神经网络的启发,神经元要么完全激活要么完全不激活(就像生物神经元)的想法很有吸引力。然而,它却是导致梯度消失问题的一个常见的原因,让我们仔细看看sigmoid函数为什么会导致梯度消失。
%matplotlib inline
import torch
from d2l import torch as d2l
x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = torch.sigmoid(x)
y.backward(torch.ones_like(x))
d2l.plot(x.detach().numpy(), [y.detach().numpy(), x.grad.numpy()],
legend=['sigmoid', 'gradient'], figsize=(4.5, 2.5))
正如上图,当sigmoid函数的输入很大或是很小时,它的梯度都会消失。此外,当反向传播通过许多层时,除非我们在刚刚好的地方,这些地方sigmoid函数的输入接近于零,否则整个乘积的梯度可能会消失。当我们的网络有很多层时,除非我们很小心,否则在某一层可能会切断梯度。事实上,这个问题曾经困扰着深度网络的训练。因此,更稳定的ReLU系列函数已经成为从业者的默认选择(虽然在神经科学的角度看起来不太合理)。
梯度爆炸
相反,梯度爆炸可能同样令人烦恼。为了更好地说明这一点,我们生成100个高斯随机矩阵,并将它们与某个初始矩阵相乘。对于我们选择的尺度(方差σ2 = 1),矩阵乘积发生爆炸。当这种情况是由于深度网络的初始化所导致时,我们没有机会让梯度下降优化器收敛。
M = torch.normal(0, 1, size=(4,4))
print('一个矩阵 \n',M)
for i in range(100):
M = torch.mm(M,torch.normal(0, 1, size=(4, 4)))
print('乘以100个矩阵后\n', M)
'''
一个矩阵
tensor([[-0.7872, 2.7090, 0.5996, -1.3191],
[-1.8260, -0.7130, -0.5521, 0.1051],
[ 1.1213, 1.0472, -0.3991, -0.3802],
[ 0.5552, 0.4517, -0.3218, 0.5214]])
乘以100个矩阵后
tensor([[-2.1897e+26, 8.8308e+26, 1.9813e+26, 1.7019e+26],
[ 1.3110e+26, -5.2870e+26, -1.1862e+26, -1.0189e+26],
[-1.6008e+26, 6.4559e+26, 1.4485e+26, 1.2442e+26],
[ 3.0943e+25, -1.2479e+26, -2.7998e+25, -2.4050e+25]])
'''
打破对称性
神经网络设计中的另一个问题是其参数化所固有的对称性。假设我们有一个简单的多层感知机,它有一个隐藏层和两个隐藏单元。在这种情况下,我们可以对第一层的权重W(1)进行重排列,并且同样对输出层的权重进行重排列,可以获得相同的函数。第一个隐藏单元与第二个隐藏单元没有什么特别的区别。换句话说,我们在每一层的隐藏单元之间具有排列对称性。
假设输出层将上述两个隐藏单元的多层感知机转换为仅一个输出单元。想象一下,如果我们将隐藏层的所有参数初始化为W(1) = c,c为常量,会发生什么?在这种情况下,在前向传播期间,两个隐藏单元采用相同的输入和参数,产生相同的激活,该激活被送到输出单元。在反向传播期间,根据参数W(1)对输出单元进行微分,得到一个梯度,其元素都取相同的值。因此,在基于梯度的迭代(例如,小批量随机梯度下降)之后,W(1)的所有元素仍然采用相同的值。这样的迭代永远不会打破对称性,我们可能永远也无法实现网络的表达能力。隐藏层的行为就好像只有一个单元。请注意,虽然小批量随机梯度下降不会打破这种对称性,但暂退法正则化可以。
参数初始化
解决(或至少减轻)上述问题的一种方法是进行参数初始化,优化期间的注意和适当的正则化也可以进一步提高稳定性。
默认初始化
在前面的部分中,我们使用正态分布来初始化权重值。如果我们不指定初始化方法,框架将使用默认的随机初始化方法,对于中等难度的问题,这种方法通常很有效。
Xavier初始化
环境和分布偏移
前面我们学习了许多机器学习的实际应用,将模型拟合各种数据集。然而,我们从来没有想过数据最初从哪里来?以及我们计划最终如何处理模型的输出?通常情况下,开发人员会拥有一些数据且急于开发模型,而不关注这些基本问题。
许多失败的机器学习部署(即实际应用)都可以追究到这种方式。有时,根据测试集的精度衡量,模型表现得非常出色。但是当数据分布突然改变时,模型在部署中会出现灾难性的失败。更隐蔽的是,有时模型的部署本身就是扰乱数据分布的催化剂。举一个有点荒谬却可能真实存在的例子。假设我们训练了一个贷款申请人违约风险模型,用来预测谁将偿还贷款或违约。这个模型发现申请人的鞋子与违约风险相关(穿牛津鞋申请人会偿还,穿运动鞋申请人会违约)。此后,这个模型可能倾向于向所有穿着牛津鞋的申请人发放贷款,并拒绝所有穿着运动鞋的申请人。
这种情况可能会带来灾难性的后果。首先,一旦模型开始根据鞋类做出决定,顾客就会理解并改变他们的行为。不久,所有的申请者都会穿牛津鞋,而信用度却没有相应的提高。总而言之,机器学习的许多应用中都存在类似的问题:通过将基于模型的决策引入环境,我们可能会破坏模型。
有些解决方案很简单(要求“正确”的数据),有些在技术上很困难(实施强化学习系统),还有一些解决方案要求我们完全跳出统计预测,解决一些棘手的、与算法伦理应用有关的哲学问题。
实战Kaggle比赛:预测房价
参考:https://zh.d2l.ai/chapter_multilayer-perceptrons/kaggle-house-price.html
下载和数据集缓存
在整本书中,我们将下载不同的数据集,并训练和测试模型。这里我们实现几个函数来方便下载数据。首先,我们建立字典DATA_HUB,它可以将数据集名称的字符串映射到数据集相关的二元组上,这个二元组包含数据集的url和验证文件完整性的sha‐1密钥。所有类似的数据集都托管在地址为DATA_URL的站点上。
import hashlib
import os
import tarfile
import zipfile
import requests
#@save
DATA_HUB = dict()
DATA_URL = 'http://d2l-data.s3-accelerate.amazonaws.com/'
下面的download函数用来下载数据集,将数据集缓存在本地目录(默认情况下为../data)中,并返回下载文件的名称。如果缓存目录中已经存在此数据集文件,并且其sha‐1与存储在DATA_HUB中的相匹配,我们将使用缓存的文件,以避免重复的下载。
def download(name, cache_dir=os.path.join('..', 'data')): #@save
"""下载一个DATA_HUB中的文件,返回本地文件名"""
assert name in DATA_HUB, f"{name} 不存在于 {DATA_HUB}"
url, sha1_hash = DATA_HUB[name]
os.makedirs(cache_dir, exist_ok=True)
fname = os.path.join(cache_dir, url.split('/')[-1])
if os.path.exists(fname):
sha1 = hashlib.sha1()
with open(fname, 'rb') as f:
while True:
data = f.read(1048576)
if not data:
break
sha1.update(data)
if sha1.hexdigest() == sha1_hash:
return fname # 命中缓存
print(f'正在从{url}下载{fname}...')
r = requests.get(url, stream=True, verify=True)
with open(fname, 'wb') as f:
f.write(r.content)
return fname
我们还需实现两个实用函数:一个将下载并解压缩一个zip或tar文件,另一个是将本书中使用的所有数据集从DATA_HUB下载到缓存目录中。
def download_extract(name, folder=None): #@save
"""下载并解压zip/tar文件"""
fname = download(name)
base_dir = os.path.dirname(fname)
data_dir, ext = os.path.splitext(fname)
if ext == '.zip':
fp = zipfile.ZipFile(fname, 'r')
elif ext in ('.tar', '.gz'):
fp = tarfile.open(fname, 'r')
else:
assert False, '只有zip/tar文件可以被解压缩'
fp.extractall(base_dir)
return os.path.join(base_dir, folder) if folder else data_dir
def download_all(): #@save
"""下载DATA_HUB中的所有文件"""
for name in DATA_HUB:
download(name)
Kaggle
Kaggle是一个当今流行举办机器学习比赛的平台,每场比赛都以至少一个数据集为中心。许多比赛有赞助方,他们为获胜的解决方案提供奖金。该平台帮助用户通过论坛和共享代码进行互动,促进协作和竞争。虽然排行榜的追逐往往令人失去理智:有些研究人员短视地专注于预处理步骤,而不是考虑基础性问题。但一个客观的平台有巨大的价值:该平台促进了竞争方法之间的直接定量比较,以及代码共享。这便于每个人都可以学习哪些方法起作用,哪些没有起作用。
在房价预测比赛页面可以通过下面的网址提交预测,并查看排名:
https://www.kaggle.com/competitions/house-prices-advanced-regression-techniques/overview
访问和读取数据集
注意,竞赛数据分为训练集和测试集。每条记录都包括房屋的属性值和属性,如街道类型、施工年份、屋顶类型、地下室状况等。这些特征由各种数据类型组成。例如,建筑年份由整数表示,屋顶类型由离散类别表示,其他特征由浮点数表示。这就是现实让事情变得复杂的地方:例如,一些数据完全丢失了,缺失值被简单地标记为“NA”。每套房子的价格只出现在训练集中(毕竟这是一场比赛)。我们将希望划分训练集以创建验证集,但是在将预测结果上传到Kaggle之后,我们只能在官方测试集中评估我们的模型。
开始之前,我们将使用pandas读入并处理数据,这是我们在 2.2节中引入的。因此,在继续操作之前,我们需要确保已安装pandas。幸运的是,如果我们正在用Jupyter阅读该书,可以在不离开笔记本的情况下安装pandas。
# 如果没有安装pandas,请取消下一行的注释
# !pip install pandas
%matplotlib inline
import pandas as pd
from mxnet import autograd, gluon, init, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l
npx.set_np()
为方便起见,我们可以使用上面定义的脚本下载并缓存Kaggle房屋数据集。
DATA_HUB['kaggle_house_train'] = ( #@save
DATA_URL + 'kaggle_house_pred_train.csv',
'585e9cc93e70b39160e7921475f9bcd7d31219ce')
DATA_HUB['kaggle_house_test'] = ( #@save
DATA_URL + 'kaggle_house_pred_test.csv',
'fa19780a7b011d9b009e8bff8e99922a8ee2eb90')
我们使用pandas分别加载包含训练数据和测试数据的两个CSV文件。
train_data = pd.read_csv(download('kaggle_house_train'))
test_data = pd.read_csv(download('kaggle_house_test'))
正在从http://d2l-data.s3-accelerate.amazonaws.com/kaggle_house_pred_train.csv下载../data/kaggle_house_pred_train.csv...
正在从http://d2l-data.s3-accelerate.amazonaws.com/kaggle_house_pred_test.csv下载../data/kaggle_house_pred_test.csv...
训练数据集包括1460个样本,每个样本80个特征和1个标签,而测试数据集包含1459个样本,每个样本80个特征。
print(train_data.shape)
print(test_data.shape)
让我们看看前四个和最后两个特征,以及相应标签(房价)。
print(train_data.iloc[0:4, [0, 1, 2, 3, -3, -2, -1]]
'''
Id MSSubClass MSZoning LotFrontage SaleType SaleCondition SalePrice
0 1 60 RL 65.0 WD Normal 208500
1 2 20 RL 80.0 WD Normal 181500
2 3 60 RL 68.0 WD Normal 223500
3 4 70 RL 60.0 WD Abnorml 140000
'''
我们可以看到,在每个样本中,第一个特征是ID,这有助于模型识别每个训练样本。虽然这很方便,但它不携带任何用于预测的信息。因此,在将数据提供给模型之前,我们将其从数据集中删除。
all_features = pd.concat((train_data.iloc[:, 1:-1], test_data.iloc[:, 1:]))
数据预处理
如上所述,我们有各种各样的数据类型。在开始建模之前,我们需要对数据进行预处理。首先,我们将所有缺失的值替换为相应特征的平均值。然后,为了将所有特征放在一个共同的尺度上,我们通过将特征重新缩放到零均值和单位方差来标准化数据:
其中µ和σ分别表示均值和标准差。直观地说,我们标准化数据有两个原因:首先,它方便优化。其次,因为我们不知道哪些特征是相关的,所以我们不想让惩罚分配给一个特征的系数比分配给其他任何特征的系数更大。
# 若无法获得测试数据,则可根据训练数据计算均值和标准差
numeric_features = all_features.dtypes[all_features.dtypes != 'object'].index
all_features[numeric_features] = all_features[numeric_features].apply(
lambda x: (x - x.mean()) / (x.std()))
# 在标准化数据之后,所有均值消失,因此我们可以将缺失值设置为0
all_features[numeric_features] = all_features[numeric_features].fillna(0)
接下来,我们处理离散值。这包括诸如“MSZoning”之类的特征。我们用独热编码替换它们,方法与前面将多类别标签转换为向量的方式相同。例如,“MSZoning”包含值“RL”和“Rm”。我们将创建两个新的指示器特征“MSZoning_RL”和“MSZoning_RM”,其值为0或1。根据独热编码,如果“MSZoning”的原始值为“RL”,:“MSZoning_RL”为1,“MSZoning_RM”为0。pandas软件包会自动为我们实现这一点。
# “Dummy_na=True”将“na”(缺失值)视为有效的特征值,并为其创建指示符特征
all_features = pd.get_dummies(all_features, dummy_na=True)
all_features.shape
# (2919, 330)
可以看到此转换会将特征的总数量从79个增加到330个。最后,通过values属性,我们可以从pandas格式中提取NumPy格式,并将其转换为张量表示用于训练。
n_train = train_data.shape[0]
train_features = np.array(all_features[:n_train].values, dtype=np.float32)
test_features = np.array(all_features[n_train:].values, dtype=np.float32)
train_labels = np.array(
train_data.SalePrice.values.reshape(-1, 1), dtype=np.float32)
训练
首先,我们训练一个带有损失平方的线性模型。显然线性模型很难让我们在竞赛中获胜,但线性模型提供了一种健全性检查,以查看数据中是否存在有意义的信息。如果我们在这里不能做得比随机猜测更好,那么我们很可能存在数据处理错误。如果一切顺利,线性模型将作为基线(baseline)模型,让我们直观地知道最好的模型有超出简单的模型多少。
loss = gluon.loss.L2Loss()
def get_net():
net = nn.Sequential()
net.add(nn.Dense(1))
net.initialize()
return net
房价就像股票价格一样,我们关心的是相对数量,而不是绝对数量。因此,我们更关心相对误差(y−yˆ)/y,而不是绝对误差y − yˆ。
例如,如果我们在俄亥俄州农村地区估计一栋房子的价格时,假设我们的预测偏差了10万美元,然而那里一栋典型的房子的价值是12.5万美元,那么模型可能做得很糟糕。另一方面,如果我们在加州豪宅区的预测出现同样的10万美元的偏差,(在那里,房价中位数超过400万美元)这可能是一个不错的预测。
解决这个问题的一种方法是用价格预测的对数来衡量差异。事实上,这也是比赛中官方用来评价提交质量的误差指标。即将δ for | log y − log yˆ| ≤ δ 转换为e−δ ≤yˆ/y ≤ eδ。这使得预测价格的对数与真实标签价格的对数之间出现以下均方根误差:
def log_rmse(net, features, labels):
# 为了在取对数时进一步稳定该值,将小于1的值设置为1
clipped_preds = np.clip(net(features), 1, float('inf'))
return np.sqrt(2 * loss(np.log(clipped_preds), np.log(labels)).mean())
与前面的部分不同,我们的训练函数将借助Adam优化器(我们将在后面更详细地描述它)。Adam优化器的主要吸引力在于它对初始学习率不那么敏感。
def train(net, train_features, train_labels, test_features, test_labels,
num_epochs, learning_rate, weight_decay, batch_size):
train_ls, test_ls = [], []
train_iter = d2l.load_array((train_features, train_labels), batch_size)
# 这里使用的是Adam优化算法
trainer = gluon.Trainer(net.collect_params(), 'adam', {
'learning_rate': learning_rate, 'wd': weight_decay})
for epoch in range(num_epochs):
for X, y in train_iter:
with autograd.record():
l = loss(net(X), y)
l.backward()
trainer.step(batch_size)
train_ls.append(log_rmse(net, train_features, train_labels))
if test_labels is not None:
test_ls.append(log_rmse(net, test_features, test_labels))
return train_ls, test_ls
K折交叉验证
我们首先需要定义一个函数,在K折交叉验证过程中返回第i折的数据。具体地说,它选择第i个切片作为验证数据,其余部分作为训练数据。注意,这并不是处理数据的最有效方法,如果我们的数据集大得多,会有其他解决办法。
def get_k_fold_data(k, i, X, y):
assert k > 1
fold_size = X.shape[0] // k
X_train, y_train = None, None
for j in range(k):
idx = slice(j * fold_size, (j + 1) * fold_size)
X_part, y_part = X[idx, :], y[idx]
if j == i:
X_valid, y_valid = X_part, y_part
elif X_train is None:
X_train, y_train = X_part, y_part
else:
X_train = torch.cat([X_train, X_part], 0)
y_train = torch.cat([y_train, y_part], 0)
return X_train, y_train, X_valid, y_valid
当我们在K折交叉验证中训练K次后,返回训练和验证误差的平均值。
def k_fold(k, X_train, y_train, num_epochs, learning_rate, weight_decay,
batch_size):
train_l_sum, valid_l_sum = 0, 0
for i in range(k):
data = get_k_fold_data(k, i, X_train, y_train)
net = get_net()
train_ls, valid_ls = train(net, *data, num_epochs, learning_rate,
weight_decay, batch_size)
train_l_sum += train_ls[-1]
valid_l_sum += valid_ls[-1]
if i == 0:
d2l.plot(list(range(1, num_epochs + 1)), [train_ls, valid_ls],
xlabel='epoch', ylabel='rmse', xlim=[1, num_epochs],
legend=['train', 'valid'], yscale='log')
print(f'折{i + 1},训练log rmse{float(train_ls[-1]):f}, '
f'验证log rmse{float(valid_ls[-1]):f}')
return train_l_sum / k, valid_l_sum / k
在本例中,我们选择了一组未调优的超参数,并将其留给读者来改进模型。找到一组调优的超参数可能需要时间,这取决于一个人优化了多少变量。有了足够大的数据集和合理设置的超参数,K折交叉验证往往对多次测试具有相当的稳定性。然而,如果我们尝试了不合理的超参数,我们可能会发现验证效果不再代表真正的误差。
k, num_epochs, lr, weight_decay, batch_size = 5, 100, 5, 0, 64
train_l, valid_l = k_fold(k, train_features, train_labels, num_epochs, lr,
weight_decay, batch_size)
print(f'{k}-折验证: 平均训练log rmse: {float(train_l):f}, '
f'平均验证log rmse: {float(valid_l):f}')
'''
折1,训练log rmse0.170259, 验证log rmse0.156975
折2,训练log rmse0.162665, 验证log rmse0.192699
折3,训练log rmse0.164036, 验证log rmse0.168522
折4,训练log rmse0.168221, 验证log rmse0.154573
折5,训练log rmse0.162865, 验证log rmse0.182574
5-折验证: 平均训练log rmse: 0.165609, 平均验证log rmse: 0.171069
'''
请注意,有时一组超参数的训练误差可能非常低,但K折交叉验证的误差要高得多,这表明模型过拟合了。在整个训练过程中,我们希望监控训练误差和验证误差这两个数字。较少的过拟合可能表明现有数据可以支撑一个更强大的模型,较大的过拟合可能意味着我们可以通过正则化技术来获益。
提交Kaggle预测
既然我们知道应该选择什么样的超参数,我们不妨使用所有数据对其进行训练(而不是仅使用交叉验证中使用的1 − 1/K的数据)。然后,我们通过这种方式获得的模型可以应用于测试集。将预测保存在CSV文件中可以简化将结果上传到Kaggle的过程。
def train_and_pred(train_features, test_features, train_labels, test_data,
num_epochs, lr, weight_decay, batch_size):
net = get_net()
train_ls, _ = train(net, train_features, train_labels, None, None,
num_epochs, lr, weight_decay, batch_size)
d2l.plot(np.arange(1, num_epochs + 1), [train_ls], xlabel='epoch',
ylabel='log rmse', xlim=[1, num_epochs], yscale='log')
print(f'训练log rmse:{float(train_ls[-1]):f}')
# 将网络应用于测试集。
preds = net(test_features).detach().numpy()
# 将其重新格式化以导出到Kaggle
test_data['SalePrice'] = pd.Series(preds.reshape(1, -1)[0])
submission = pd.concat([test_data['Id'], test_data['SalePrice']], axis=1)
submission.to_csv('submission.csv', index=False)
如果测试集上的预测与K倍交叉验证过程中的预测相似, 那就是时候把它们上传到Kaggle了。 下面的代码将生成一个名为submission.csv的文件。
train_and_pred(train_features, test_features, train_labels, test_data,
num_epochs, lr, weight_decay, batch_size)
提交到kaggle网站:https://www.kaggle.com/competitions/house-prices-advanced-regression-techniques/overview