来源 | Deep Learning with PyTorch
作者 | Stevens, et al.
译者 | 杜小瑞
校对 | gongyouliu
编辑 | auroral-L
全文共7520字,预计阅读时间45分钟。
第五章 学习的机制(下)
5. PyTorch 的自动梯度(autograd):将一切反向传播
5.1 自动计算梯度
5.2 选择优化器
5.3 训练,验证和过拟合
5.4 关闭不需要的自动梯度
6. 结论
7. 练习
8. 总结
5. PyTorch的自动梯度(autograd): 将一切反向传播
在我们的小尝试中,我们只看到了一个简单的反向传播的例子:我们通过使用链式规则向后传播导数,计算了模型中复合函数(模型及损失)相对于其最里面参数(w和b)的梯度。这里的基本要求是,我们处理的所有函数都可以用解析的方法来求导。如果是这样的话,我们可以计算梯度,也就是我们之前所说的“损失变化率”。
即使我们有一个有数百万个参数的复杂模型,只要我们的模型是可微的,计算损失相对于参数的梯度相当于写出导数的解析表达式并对其进行一次求值。诚然,写线性和非线性函数的一个很复杂的组合的导数的解析表达式不是很有趣,也不是特别快。
5.1 自动计算梯度
PyTorch的一个称为autograd的组件可以拯救PyTorch张量复杂的求导。第三章全面介绍了什么是张量以及我们可以调用什么函数。然而,我们遗漏了一个非常有趣的方面:PyTorch张量可以记住它们来自何处,根据操作和产生它们的父张量,并且它们可以自动提供这些操作相对于其输入的导数链。这意味着我们不需要手工推导模型;给定一个正向表达式,不管它是如何嵌套的,PyTorch都会自动提供该表达式相对于其输入参数的梯度。
使用autograd
此时,最好的方法是重写我们的温度计校准代码,这次使用autograd,看看会发生什么。首先,我们回顾我们的模型和损失函数。
让我们再次初始化参数张量:
使用grad属性
注意到张量构造函数的requires_grad=True参数了吗?这个参数是告诉PyTorch跟踪操作参数的张量产生的整个家族树。换句话说,任何将params作为祖先的张量都可以访问从params到该张量的函数链。如果这些函数是可微的(大多数PyTorch张量运算都是可微的),则导数的值将自动填充为params张量的grad属性。
一般来说,所有PyTorch张量都有一个名为grad的属性。通常情况下值为None:
我们所要做的就是从一个张量开始,将requires_grad设置为True,然后调用模型并计算损失,然后反向调用损失张量:
此时,params的grad属性包含了损失相对于params的每个元素的导数。
当我们在参数w和b需要梯度的情况下计算损失时,除了执行实际的计算外,PyTorch还创建了以操作(黑色圆圈)为节点的autograd图,如图5.10的最上面一行所示。当我们调用loss.backward()时,PyTorch以相反的方向遍历这个图来计算梯度,如下图最下面一行中的箭头所示。
图 5.10 用autograd计算模型的前向图和后向图
累加梯度函数
我们可以有任意数量的张量和任意组合的函数。在这种情况下,PyTorch将计算整个函数链(计算图)中损失的导数,并在这些张量的grad属性(图的叶节点)中累积它们的值。
注意!不管是PyTorch新学习者还是许多更有经验的人,都经常在这个地方出错。我们只是对梯度做累积,而不是储存。
警告
调用backward会导致导数在叶节点上累积,使用梯度更新参数后,需要显式地将梯度归零。
让我们一起回顾一下:调用backward将导致导数在叶节点处累积。因此,如果前面调用backward,则会再次计算损失,再次调用backward(就像在任何训练循环中一样),并且每个叶子节点的梯度在上一次迭代计算的梯度上累积(即求和),这会导致梯度的值不正确。
为了防止这种情况发生,我们需要在每次迭代时显式地将梯度归零。我们可以使用zero_方法轻松地完成这项工作:
注意
你可能会好奇,为什么在复杂的模型中处理梯度时,将梯度归零是一个必需的步骤,而不是在我们调用backward时自动进行归零。
在我们的脑海中思考了这个提醒,让我们从头到尾看看我们的自动启动训练代码是什么样子的:
注意,我们的代码更新params并不像我们预期的那么简单。有两个特殊性。首先,我们使用Python with语句将更新封装在一个no_grad上下文中。这意味着在with块中,PyTorch autograd机制应该不做操作:即,不向正向图添加边。实际上,当我们执行这段代码时,PyTorch记录的前向图在我们调用backward时被使用,留下params叶节点。但是现在,我们要在开始在它上面构建一个新的前向图之前更改这个叶子节点。虽然这个用例通常被包装在我们第5.5.2节中讨论的优化器中,但是当我们在第5.5.4节中看到no_grad的另一个常见用法时,我们将进一步研究。
其次,我们在适当的地方更新参数。这意味着我们保持相同的参数张量,但从中减去我们的更新。当使用autograd时,我们通常避免就地更新,因为PyTorch的autograd引擎可能需要为backward修改的值。然而,在这里,我们没有自动加载,保持参数张量是有益的。当我们在第5.5.2节向优化器注册参数时,不通过给变量名分配新的张量来替换参数将变得至关重要。
让我们看看它是否有效:
结果和我们之前得到的一样。对我们有好处!这意味着,虽然我们能够手工计算导数,但我们不再需要这样做了。
5.2 选择优化器
在示例代码中,我们使用原始的梯度下降法进行优化,这对于我们的简单示例非常有效。我们有几种优化策略和技巧可以帮助模型收敛,特别是当模型变得复杂时。
我们将在后面的章节中深入探讨这个主题,但是现在是介绍PyTorch将优化策略从用户代码中抽象出来的方法的时候了:也就是说,我们已经检查过的训练循环。这样我们就不用再为自己的模型更新每一个参数了。torch模块有一个optim子模块,我们可以在其中找到实现不同优化算法的类。下面是一个简化列表(code/p1ch5/3_optimizers.ipynb):
每个优化器构造函数都将一个参数列表(也称为PyTorch张量,通常将requires_grad设置为True)作为第一个输入。传递给优化器的所有参数都保留在优化器对象中,因此优化器可以更新它们的值并访问它们的grad属性,如图5.11所示。
图 5.11 (A)优化器如何保存参数引用的概念表示 (B)在根据输入计算损失之后(C)调用.backward将导致在参数上填充.grad (D)此时,优化器可以访问.grad并计算参数更新。
每个优化器公开了两种方法:zero_grad和step.zero_grad在构造时将传递给优化器的所有参数的grad属性归零。step根据特定优化器实现的优化策略更新这些参数的值。
使用梯度下降优化器
让我们创建params并实例化一个梯度下降优化器:
这里SGD代表随机梯度下降。实际上,优化器本身就是一个普通的梯度下降(只要动量参数设置为0.0,这是默认值)。这里的随机性来自这样一个事实,即梯度通常是通过对所有输入样本的随机子集(称为minibatch)进行平均得到的。但是,优化器不知道是对所有样本(vanilla)还是对它们的随机子集(stochastic)评估损失,因此这两种情况下的算法字面上是相同的。
不管怎样,让我们来看看我们的新优化器:
params的值在调用step时被更新,而不需要我们操作!所发生的事情是优化器查看params.grad并更新params,从中减去learning_rate乘以grad,就像我们以前手工编写的代码一样。
准备好将此代码放入训练循环中了吗?不!我们还忘记了将梯度清零。如果我们在循环中调用前面的代码,那么每次调用backward时,梯度会在每一轮累积,我们的梯度会下降会变得一团糟!下面是完整的代码,在正确的位置有额外的zero_grad(就在调用backward之前):
完美!看看optim模块如何帮助我们抽象出特定的优化方案?我们所要做的就是为它提供一个参数列表(这个列表可能非常长,这是层数非常深的神经网络模型所需要的),我们可以忽略细节。
让我们相应地更新我们的训练循环:
同样,我们得到了和以前一样的结果。太好了:这进一步证实了我们知道如何手动进行梯度下降!
测试其他优化器
为了测试更多的优化器,我们所要做的就是实例化一个不同的优化器,比如Adam,而不是SGD。其余的代码不需要改进,十分方便。
关于Adam我们就不多说了;可以说,它是一个更复杂的优化器,其中学习速率是自适应设置的。此外,它对参数的缩放不太敏感,我们可以回到使用原始(非标准化)输入t_u,甚至将学习速率提高到1e-1,Adam都可以正常使用:
优化器不是我们训练循环中唯一灵活的部分。让我们把注意力转向模型。为了在相同的数据和相同的损耗下训练神经网络,我们只需要改变模型函数。在这种情况下没有什么特别的意义,因为我们知道将摄氏度转换成华氏度相当于一个线性变换,但我们将在第6章中进行。我们很快就会看到,神经网络允许我们消除关于我们应该近似的函数形状的任意假设。即便如此,我们将看到神经网络是如何训练的,即使其基本过程是高度非线性的(例如在第二章中用句子描述图像的情况下)。
我们已经接触了许多基本概念,这些概念将使我们能够训练复杂的深度学习模型,同时了解后台的情况:反向传播来估计梯度,自动加载,以及使用梯度下降或其他优化器优化模型的权重。真的,没有太多了。其余的大部分是填补空白,无论它们有多广泛。
接下来,我们将提供一个关于如何分割样本的旁白,因为这为学习如何更好地控制autograd建立了一个完美的用例。
5.3 训练,验证和过拟合
约翰内斯·开普勒教了我们最后一件事,我们到目前为止还没有讨论过,记得吗?他把一部分数据放在一边,这样他就可以通过独立观察来验证他的模型。这是一件至关重要的事情,尤其是当我们采用的模型可能近似任何形状的函数时,就像神经网络的情况一样。换句话说,一个适应性强的模型会倾向于使用它的许多参数来确保数据点处的损失最小,但是我们不能保证模型在远离数据点或数据点之间表现良好。毕竟,这就是我们要求优化器做的:最小化数据点的损失。果不其然,如果我们有独立的数据点,而不是用来评估我们的损失或沿着负梯度下降,我们很快就会发现,评估这些独立数据点的损失会产生比预期更高的损失。我们已经提到过这种现象,叫做过拟合。
改变过拟合的第一步是认识到它可能发生。为了做到这一点,正如开普勒在1600年所发现的,我们必须从我们的数据集(验证集)中提取一些数据点,并且只在剩余的数据点(训练集)上拟合我们的模型,如图5.12所示。然后,当我们拟合模型时,我们可以在训练集和验证集上评估一次损失。当我们试图确定我们的模型是否适合数据时,我们必须同时考虑这两个因素!
图 5.12 数据生成过程的概念表示以及训练数据和独立验证数据的收集和使用
评估培训损失
训练损失将告诉我们我们的模型是否能够完全适应训练集,换句话说,我们的模型是否有足够的能力处理数据中的相关信息。如果我们神秘的温度计设法用对数标度来测量温度,我们糟糕的线性模型就没有机会拟合这些测量值,并为我们提供到摄氏度的合理转换。在这种情况下,我们的训练损失(我们在训练循环中打印的损失)将在接近零之前停止下降。
深度神经网络可以潜在地逼近复杂的函数,前提是神经元的数目和参数足够高。参数的数目越少,我们的网络所能近似的函数的形状就越简单。所以,规则1:如果训练损失没有减少,那么模型对于数据来说就太简单了。另一种可能性是,我们的数据并不包含有意义的信息来解释输出:如果商店里的店员卖给我们一个气压计而不是温度计,我们就几乎没有机会仅仅通过压力来预测摄氏温度,即使我们使用Quebec最新的神经网络架构(www.umontreal.ca/en/artificialintelligence)。
推广到验证集
验证集呢?好吧,如果在验证集中评估的损失没有随着训练集的减少而减少,这意味着我们的模型正在改进它在训练过程中看到的样本的拟合,但是它不能推广到这个精确集之外的样本。一旦我们在新的,以前看不见的点上评估模型,损失函数的值就很差。所以,规则2:如果训练损失和验证损失相差过大,我们就过拟合了。
让我们稍微研究一下这个现象,回到温度计的例子。我们本可以决定用一个更复杂的函数来拟合数据,比如分段多项式或一个非常大的神经网络。它可以生成一个模型,在数据点中蜿蜒前行,如图5.13所示,因为它将损失推得非常接近于零。由于函数远离数据点的行为不会增加损失,因此没有什么可以让模型检查远离训练数据点的输入。
图 5.13 过拟合的极端例子
不过,有什么解决方法?问得好。从我们刚才所说的来看,过拟合实际上看起来像是一个问题,即确保数据点之间的模型行为对于我们试图近似的过程是合理的。首先,我们应该确保获得足够的数据。如果我们从正弦曲线中收集数据,定期对其进行低频采样,我们将很难将模型与之相匹配。
假设我们有足够的数据点,我们应该确保能够拟合训练数据的模型在它们之间尽可能规则。有几种方法可以实现这一点。一种是在损失函数中加入惩罚项,以使模型表现得更平稳、变化更慢(在一定程度上)。另一种方法是在输入样本中加入噪声,在训练样本之间人为地创建新的数据点,并强迫模型去拟合这些数据点。但我们能为自己做的最好的事情,至少作为第一步,是使我们的模型更简单。从直观的角度来看,一个简单的模型可能无法像一个更复杂的模型那样完美地拟合训练数据,但它可能会在数据点之间表现得更为规则。
我们有一些很好的权衡。一方面,我们需要模型有足够的容量来适应训练集。另一方面,我们需要模型来避免过拟合。因此,为了在参数方面为神经网络模型选择合适的大小,这个过程基于两个步骤:增加大小直到它适合,然后缩小它直到它停止过度适合。
我们将在第12章中看到更多关于这一点的内容,我们将发现我们的模型训练将是一个在拟合和过拟合之间的平衡行为。现在,让我们回到我们的示例,看看如何将数据拆分为训练集和验证集。我们将用同样的方法打乱t_u和t_c,然后将产生的散乱的张量分成两部分。
拆分数据集
对张量元素的洗牌相当于找到其指数的排列。randperm函数正是这样做的:
我们刚刚得到了索引张量,可以用来从数据张量开始构建训练和验证集:
我们的训练循环并没有改变。我们只想额外评估每个时期的验证损失,以便有机会识别我们是否过拟合:
在这里,我们对我们的模型并不完全公平。验证集非常小,因此验证loss在一定程度上是有意义的。在任何情况下,我们都注意到验证损失要比训练损失高,尽管不是一个数量级。我们期望模型在训练集上表现更好,因为模型参数是由训练集决定的。我们的主要目标是同时减少训练损失和验证损失。虽然理想情况下,这两个损失的值大致相同,但只要验证损失与训练损失保持合理的接近,我们就知道我们的模型正在继续学习关于数据的一般性知识。在图5.14中,情况C是理想的,而D是可接受的。在案例A中,模型根本没有学习;在案例B中,我们看到了过拟合。我们将在第12章中看到更多关于过度拟合的有意义的例子。
图 5.14 在查看训练(实线)和验证(虚线)损失时,过拟合场景 (A)训练和验证损失没有减少;由于数据中没有信息或模型容量不足,模型无法学习 (B)训练损失减少,而验证损失增加:过拟合。(C)训练和验证损失完全同步减少。由于模型不受过拟合的限制,性能可能会进一步提高 (D)训练和验证损失的绝对值不同,但趋势相似:过拟合得到控制。
5.4 关闭不需要的自动梯度
从前面的训练循环中,我们可以体会到,我们只会在训练损失时调用backward。因此,错误只会基于训练集进行反向传播。验证集用于对未用于训练的数据的模型输出的准确性进行独立评估。
好奇的读者此时会有一个问题。对模型进行两次评估,一次在train_t_u上,一次在value_t_u上,然后调用backward。这不会把autograd弄糊涂吗?在传递验证集的过程中生成的值不会影响backward吗?
幸运的是,情况并非如此。训练循环的第一行评估train_t_u上的模型以生成train_t_p。然后从train_t_p中评估train_loss。这将创建一个计算图,将train_t_u与train_t_p链接到train_loss。当对模型再次对val_t_u评估时,会产生val_u_p和val_loss。在这种情况下,将创建一个单独的计算图,将value_t_u与value_t_p与value_loss链接起来。单独的张量已通过相同的函数、模型和loss_fn运行,生成单独的计算图,如图5.15所示。
图 5.15 当对其中一个调用.backward时,显示梯度如何在有两个损失的图中传播
这两个图的唯一共同点是参数。当我们在train_loss上调用backward时,我们在第一个图形上反向传播。换言之,我们根据train_t_u产生的计算,累积train_loss对参数的导数。
如果我们(错误地)在val_loss上调用backward,我们将累积val_loss相对于相同叶节点上的参数的导数。还记得zero_grad的事情吗?每次我们调用backward时,梯度会互相叠加,除非我们明确地将梯度归零。好吧,这里会发生一些非常类似的事情:对val_loss的backward调用会导致在参数张量中累积梯度,在train_loss.backward()调用期间生成的梯度之上。在这种情况下,我们将在整个数据集上有效地训练我们的模型(训练和验证),因为梯度将依赖于两者。这很有趣。
这里还有另一个需要讨论的因素。既然我们从来没有对val_loss进行反向调用,那么我们为什么要首先构建这个图呢?实际上,我们可以将model和loss作为普通函数调用,而不跟踪计算。无论怎样优化,构建autograd图都会带来额外的成本,在验证过程中我们完全可以忽略这些成本,特别是当模型有数百万个参数时。
为了解决这个问题,PyTorch允许我们在不需要的时候关闭autograd,使用torch.no_ grad上下文管理器。在速度和内存消耗方面,我们看不到任何有意义的优势。然而,对于更大的模型来说,这些差异可能会累积起来。我们可以通过检查val_loss张量上requires_grad属性的值来确保这一点:
使用相关的set_grad_enabled上下文,我们还可以根据一个布尔表达式(通常表示我们是在训练模式还是在推理模式下运行)来调整代码的运行条件,使其启用或禁用自动标记。例如,我们可以定义一个calc_forward函数,该函数将数据作为输入,并根据一个Boolean train_is参数运行模型和损失函数(带或不带autograd):
6. 结论
我们从一个大问题开始这一章:机器如何从例子中学习?我们花了本章其余的时间来描述模型优化以适应数据的机制。我们选择了一个简单的模型,以便在忽略不必要的复杂细节的情况下看到所有的运动部件。
现在我们已经吃饱了开胃菜,在第6章中,我们将最终进入主菜:使用神经网络来拟合我们的数据。我们将致力于解决相同的温度计问题,但是使用torch.nn模块提供的更强大的工具。我们将采用同样的精神,用这个小问题来说明PyTorch的更大用途。这个问题不需要一个神经网络来解决,但它可以让我们对训练神经网络需要什么有一个更简单的理解。
7. 练习
1、重新将模型定义为w2*t_u**2+w1*t_u+b。
A. 训练循环的哪些部分等需要改变以适应这种重新定义?
B. 哪些部分对于替换模型是不可知的?
C. 训练后造成的损失是高还是低?
D. 实际结果是好还是坏?
8. 总结
√ 线性模型是用来拟合数据的最简单合理的模型。
√ 凸优化技术可以应用于线性模型,但不能推广到神经网络,因此本文重点研究了随机梯度下降的参数估计方法。
√ 深度学习可用于一般模型,这些模型不是为解决特定任务而设计的,而是可以自动进行调整,使他们自己专门处理手头的问题。
√ 学习算法相当于基于观测优化模型参数。损失函数是执行任务时误差的度量,例如预测输出和测量值之间的误差。目标是使损失函数尽可能低。
√ 损失函数相对于模型参数的变化率可以用来在减少损失的方向上更新相同的参数。
√ PyTorch中的optim模块提供了一组现成的优化器,用于更新参数和最小化损失函数。
√ 优化器使用PyTorch的autograd特性来计算每个参数的梯度,这取决于该参数对最终输出的贡献。这允许用户在复杂的向前传递过程中依赖于动态计算图。
√ 上下文管理器,比如torch.no_grad():可以用来控制autograd的行为。
√ 数据通常被分成不同的训练样本和验证样本集,这样我们就可以根据没有训练过的数据来评估模型。
√ 当模型的效果在训练集上继续提高,但在验证集上却下降时,就会发生过拟合。这通常是由于模型没有泛化,而是记住了训练集的期望输出。