优化和深度学习


本节将讨论优化与深度学习之间的关系以及在深度学习中使用优化的挑战。对于深度学习问题,我们通常会先定义损失函数。一旦我们有了损失函数,我们就可以使用优化算法来尝试最小化损失。在优化中,损失函数通常被称为优化问题的目标函数。按照传统惯例,大多数优化算法都关注的是最小化。如果我们需要最大化目标,那么有一个简单的解决方案:在目标函数前加负号即可。

优化的目标

尽管优化提供了一种最大限度地减少深度学习损失函数的方法,但本质上,优化和深度学习的目标是根本不同的。前者主要关注的是最小化目标,后者则关注在给定有限数据量的情况下寻找合适的模型。在 :numref:sec_model_selection中,我们详细讨论了这两个目标之间的区别。例如,训练误差和泛化误差通常不同:由于优化算法的目标函数通常是基于训练数据集的损失函数,因此优化的目标是减少训练误差。但是,深度学习(或更广义地说,统计推断)的目标是减少泛化误差。为了实现后者,除了使用优化算法来减少训练误差之外,我们还需要注意过拟合。

1
2
3
4
5
%matplotlib inline  
import numpy as np
import torch
from mpl_toolkits import mplot3d
from d2l import torch as d2l

为了说明上述不同的目标,引入两个概念风险经验风险。如 :numref:subsec_empirical-risk-and-risk所述,经验风险是训练数据集的平均损失,而风险则是整个数据群的预期损失。下面我们定义了两个函数:风险函数f和经验风险函数g。假设我们只有有限的训练数据。因此,这里的g不如f平滑。

1
2
3
4
5
def f(x):  
return x * torch.cos(np.pi * x)

def g(x):
return f(x) + 0.2 * torch.cos(5 * np.pi * x)

下图说明,训练数据集的最低经验风险可能与最低风险(泛化误差)不同。

1
2
3
4
5
6
7
8
9
def annotate(text, xy, xytext):  #@save  
d2l.plt.gca().annotate(text, xy=xy, xytext=xytext,
arrowprops=dict(arrowstyle='->'))

x = torch.arange(0.5, 1.5, 0.01)
d2l.set_figsize((4.5, 2.5))
d2l.plot(x, [f(x), g(x)], 'x', 'risk')
annotate('min of\nempirical risk', (1.0, -1.2), (0.5, -1.1))
annotate('min of risk', (1.1, -1.05), (0.95, -0.5))

300

深度学习中的优化挑战

本章将关注优化算法在最小化目标函数方面的性能,而不是模型的泛化误差。在 :numref:sec_linear_regression中,我们区分了优化问题中的解析解和数值解。在深度学习中,大多数目标函数都很复杂,没有解析解。相反,我们必须使用数值优化算法。本章中的优化算法都属于此类别。

深度学习优化存在许多挑战。其中最令人烦恼的是局部最小值、鞍点和梯度消失

局部最小值

对于任何目标函数,如果在处对应的值小于在附近任意其他点的值,那么可能是局部最小值。如果处的值是整个域中目标函数的最小值,那么是全局最小值。

例如,给定函数

我们可以近似该函数的局部最小值和全局最小值。

1
2
3
4
x = torch.arange(-1.0, 2.0, 0.01)  
d2l.plot(x, [f(x), ], 'x', 'f(x)')
annotate('local minimum', (-0.3, -0.25), (-0.77, -1.0))
annotate('global minimum', (1.1, -0.95), (0.6, 0.8))

300

深度学习模型的目标函数通常有许多局部最优解。当优化问题的数值解接近局部最优值时,随着目标函数解的梯度接近或变为零,通过最终迭代获得的数值解可能仅使目标函数*局部最优,而不是*全局最优。只有一定程度的噪声可能会使参数跳出局部最小值。事实上,这是小批量随机梯度下降的有利特性之一**。在这种情况下,小批量上梯度的自然变化能够将参数从局部极小值中跳出。

鞍点

除了局部最小值之外,鞍点是梯度消失的另一个原因。鞍点(saddle point)是指函数的所有梯度都消失但既不是全局最小值也不是局部最小值的任何位置。考虑这个函数。它的一阶和二阶导数在时消失。这时优化可能会停止,尽管它不是最小值。

1
2
3
x = torch.arange(-2.0, 2.0, 0.01)  
d2l.plot(x, [x**3], 'x', 'f(x)')
annotate('saddle point', (0, -0.2), (-0.52, -5.0))

300

如下例所示,较高维度的鞍点甚至更加隐蔽。考虑这个函数。它的鞍点为。这是关于的最大值,也是关于的最小值。此外,它看起来像个马鞍,这就是鞍点的名字由来。

1
2
3
4
5
6
7
8
9
10
11
12
13
x, y = torch.meshgrid(  
torch.linspace(-1.0, 1.0, 101), torch.linspace(-1.0, 1.0, 101))
z = x**2 - y**2

ax = d2l.plt.figure().add_subplot(111, projection='3d')
ax.plot_wireframe(x, y, z, **{'rstride': 10, 'cstride': 10})
ax.plot([0], [0], [0], 'rx')
ticks = [-1, 0, 1]
d2l.plt.xticks(ticks)
d2l.plt.yticks(ticks)
ax.set_zticks(ticks)
d2l.plt.xlabel('x')
d2l.plt.ylabel('y');

250

我们假设函数的输入是维向量,其输出是标量,因此其Hessian矩阵(也称黑塞矩阵)将有个特征值。函数的解可能是局部最小值、局部最大值或函数梯度为零位置处的鞍点:

  • 当函数在零梯度位置处的Hessian矩阵的特征值全部为正值时,我们有该函数的局部最小值;
  • 当函数在零梯度位置处的Hessian矩阵的特征值全部为负值时,我们有该函数的局部最大值;
  • 当函数在零梯度位置处的Hessian矩阵的特征值为负值和正值时,我们有该函数的一个鞍点。

对于高维度问题,至少部分特征值为负的可能性相当高。这使得鞍点比局部最小值更有可能出现。我们将在下一节介绍凸性时讨论这种情况的一些例外。简而言之,凸函数是Hessian函数的特征值永远不为负值的函数。不幸的是,大多数深度学习问题并不属于这一类。尽管如此,它还是研究优化算法的一个很好的工具。

梯度消失

可能遇到的最隐蔽问题是梯度消失。回想一下我们在 :numref:subsec_activation_functions中常用的激活函数及其衍生函数。例如,假设我们想最小化函数,然后我们恰好从开始。正如我们所看到的那样,的梯度接近零。更具体地说,,因此是。因此,在我们取得进展之前,优化将会停滞很长一段时间。事实证明,这是在引入ReLU激活函数之前训练深度学习模型相当棘手的原因之一。

1
2
3
x = torch.arange(-2.0, 5.0, 0.01)  
d2l.plot(x, [torch.tanh(x)], 'x', 'f(x)')
annotate('vanishing gradient', (4, 1), (2, 0.0))

300

正如我们所看到的那样,深度学习的优化充满挑战。幸运的是,有一系列强大的算法表现良好,即使对于初学者也很容易使用。此外,没有必要找到最优解。局部最优解或其近似解仍然非常有用。

小结

  • 最小化训练误差并不能保证我们找到最佳的参数集来最小化泛化误差。
  • 优化问题可能有许多局部最小值。
  • 一个问题可能有很多的鞍点,因为问题通常不是凸的。
  • 梯度消失可能会导致优化停滞,重参数化通常会有所帮助。对参数进行良好的初始化也可能是有益的。

练习

  1. 考虑一个简单的MLP,它有一个隐藏层,比如,隐藏层中维度为和一个输出。证明对于任何局部最小值,至少有个等效方案。
  2. 假设我们有一个对称随机矩阵,其中条目各自从某种概率分布中抽取。此外,假设,即分布是对称的(详情请参见 :cite:Wigner.1958)。
    1. 证明特征值的分布也是对称的。也就是说,对于任何特征向量,关联的特征值满足的概率为
    2. 为什么以上没有暗示
  3. 你能想到深度学习优化还涉及哪些其他挑战?
  4. 假设你想在(真实的)鞍上平衡一个(真实的)球。
    1. 为什么这很难?
    2. 能利用这种效应来优化算法吗?

凸性


:label:sec_convexity

凸性(convexity)在优化算法的设计中起到至关重要的作用,
这主要是由于在这种情况下对算法进行分析和测试要容易。
换言之,如果算法在凸性条件设定下的效果很差,
那通常我们很难在其他条件下看到好的结果。
此外,即使深度学习中的优化问题通常是非凸的,
它们也经常在局部极小值附近表现出一些凸性。
这可能会产生一些像 :cite:Izmailov.Podoprikhin.Garipov.ea.2018这样比较有意思的新优化变体。

1
2
3
4
5
%matplotlib inline  
import numpy as np
import torch
from mpl_toolkits import mplot3d
from d2l import torch as d2l

定义

在进行凸分析之前,我们需要定义凸集(convex sets)和凸函数(convex functions)。

凸集

凸集(convex set)是凸性的基础。
简单地说,如果对于任何,连接的线段也位于中,则向量空间中的一个集合(convex)的。
在数学术语上,这意味着对于所有,我们得到

这听起来有点抽象,那我们来看一下 :numref:fig_pacman里的例子。
第一组存在不包含在集合内部的线段,所以该集合是非凸的,而另外两组则没有这样的问题。

第一组是非凸的,另外两组是凸的。
:label:fig_pacman

接下来来看一下交集 :numref:fig_convex_intersect
假设是凸集,那么也是凸集的。
现在考虑任意
因为是凸集,
所以连接的线段包含在中。
鉴于此,它们也需要包含在中,从而证明我们的定理。

两个凸集的交集是凸的。
:label:fig_convex_intersect

我们可以毫不费力地进一步得到这样的结果:
给定凸集$\mathcal{X}i\cap{i} \mathcal{X}_i\mathcal{X} \cap \mathcal{Y} = \emptyseta \in \mathcal{X}b \in \mathcal{Y}\mathcal{X} \cap \mathcal{Y} = \emptyset$,
在 :numref:fig_nonconvex中连接的线段需要包含一部分既不在也不在中。
因此线段也不在中,因此证明了凸集的并集不一定是凸的,即非凸(nonconvex)的。

两个凸集的并集不一定是凸的。
:label:fig_nonconvex

通常,深度学习中的问题是在凸集上定义的。
例如,,即实数的-维向量的集合是凸集(毕竟中任意两点之间的线存在)中。
在某些情况下,我们使用有界长度的变量,例如球的半径定义为

凸函数

现在我们有了凸集,我们可以引入凸函数(convex function)
给定一个凸集,如果对于所有和所有,函数是凸的,我们可以得到

为了说明这一点,让我们绘制一些函数并检查哪些函数满足要求。
下面我们定义一些函数,包括凸函数和非凸函数。

1
2
3
4
5
6
7
8
9
f = lambda x: 0.5 * x**2  # 凸函数  
g = lambda x: torch.cos(np.pi * x) # 非凸函数
h = lambda x: torch.exp(0.5 * x) # 凸函数

x, segment = torch.arange(-2, 2, 0.01), torch.tensor([-1.5, 1])
d2l.use_svg_display()
_, axes = d2l.plt.subplots(1, 3, figsize=(9, 3))
for ax, func in zip(axes, [f, g, h]):
d2l.plot([x, segment], [func(x), func(segment)], axes=ax)

不出所料,余弦函数为非凸的,而抛物线函数和指数函数为凸的。
请注意,为使该条件有意义,是凸集的要求是必要的。
否则可能无法很好地界定的结果。

詹森不等式

给定一个凸函数,最有用的数学工具之一就是詹森不等式(Jensen’s inequality)。
它是凸性定义的一种推广:


:eqlabel:eq_jensens-inequality

其中是满足的非负实数,是随机变量。
换句话说,凸函数的期望不小于期望的凸函数,其中后者通常是一个更简单的表达式。
为了证明第一个不等式,我们多次将凸性的定义应用于一次求和中的一项。

詹森不等式的一个常见应用:用一个较简单的表达式约束一个较复杂的表达式。
例如,它可以应用于部分观察到的随机变量的对数似然。
具体地说,由于,所以

这里,是典型的未观察到的随机变量,是它可能如何分布的最佳猜测,是将积分后的分布。
例如,在聚类中可能是簇标签,而在应用簇标签时,是生成模型。

性质

下面我们来看一下凸函数一些有趣的性质。

局部极小值是全局极小值

首先凸函数的局部极小值也是全局极小值。
下面我们用反证法给出证明。

假设是一个局部最小值,则存在一个很小的正值,使得当满足时,有

现在假设局部极小值不是的全局极小值:存在使得
则存在
,比如,使得

然而,根据凸性的性质,有

这与是局部最小值相矛盾。
因此,不存在满足
综上所述,局部最小值也是全局最小值。

例如,对于凸函数,有一个局部最小值,它也是全局最小值。

1
2
3
f = lambda x: (x - 1) ** 2  
d2l.set_figsize()
d2l.plot([x, segment], [f(x), f(segment)], 'x', 'f(x)')

300

凸函数的局部极小值同时也是全局极小值这一性质是很方便的。
这意味着如果我们最小化函数,我们就不会“卡住”。
但是请注意,这并不意味着不能有多个全局最小值,或者可能不存在一个全局最小值。
例如,函数区间上都是最小值。
相反,函数上没有取得最小值。对于,它趋近于,但是没有

凸函数的下水平集是凸的

我们可以方便地通过凸函数的下水平集(below sets)定义凸集。
具体来说,给定一个定义在凸集上的凸函数,其任意一个下水平集

是凸的。

让我们快速证明一下。
对于任何,我们需要证明:当时,
因为,所以

凸性和二阶导数

当一个函数的二阶导数存在时,我们很容易检查这个函数的凸性。
我们需要做的就是检查
即对于所有.
例如,函数是凸的,因为,即其导数是单位矩阵。

更正式地讲,为凸函数,当且仅当任意二次可微一维函数是凸的。
对于任意二次可微多维函数
它是凸的当且仅当它的Hessian

首先,我们来证明一下一维情况。
为了证明凸函数的,我们使用:

因为二阶导数是由有限差分的极限给出的,所以遵循

为了证明可以推导是凸的,
我们使用这样一个事实:意味着是一个单调的非递减函数。
假设中的三个点,
其中,.
根据中值定理,存在,使得

通过单调性,因此

由于,所以

从而证明了凸性。

第二,我们需要一个引理证明多维情况:

是凸的当且仅当对于所有


是凸的。

为了证明的凸性意味着是凸的,
对于所有的我们可以证明,(这样有),

为了证明这一点,我们可以证明对
中所有的

最后,利用上面的引理和一维情况的结果,我们可以证明多维情况:
多维函数是凸函数,当且仅当是凸的,这里
根据一维情况,
此条成立的条件为,当且仅当对于所有
)。
这相当于根据半正定矩阵的定义,

约束

凸优化的一个很好的特性是能够让我们有效地处理约束(constraints)。
即它使我们能够解决以下形式的约束优化(constrained optimization)问题:

这里是目标函数,是约束函数。
例如第一个约束,则参数被限制为单位球。
如果第二个约束,那么这对应于半空间上所有的
同时满足这两个约束等于选择一个球的切片作为约束集。

拉格朗日函数

通常,求解一个有约束的优化问题是困难的,解决这个问题的一种方法来自物理中相当简单的直觉。
想象一个球在一个盒子里,球会滚到最低的地方,重力将与盒子两侧对球施加的力平衡。
简而言之,目标函数(即重力)的梯度将被约束函数的梯度所抵消(由于墙壁的“推回”作用,需要保持在盒子内)。
请注意,任何不起作用的约束(即球不接触壁)都将无法对球施加任何力。

这里我们简略拉格朗日函数的推导,上述推理可以通过以下鞍点优化问题来表示:

这里的变量)是所谓的拉格朗日乘数(Lagrange multipliers),它确保约束被正确地执行。
选择它们的大小足以确保所有
例如,对于中任意,我们最终会选择
此外,这是一个鞍点(saddlepoint)优化问题。
在这个问题中,我们想要使相对于最大化(maximize),同时使它相对于最小化(minimize)。
有大量的文献解释如何得出函数
我们这里只需要知道的鞍点是原始约束优化问题的最优解就足够了。

惩罚

一种至少近似地满足约束优化问题的方法是采用拉格朗日函数。除了满足之外,我们只需将添加到目标函数
这样可以确保不会严重违反约束。

事实上,我们一直在使用这个技巧。
比如权重衰减 :numref:sec_weight_decay,在目标函数中加入,以确保不会增长太大。
使用约束优化的观点,我们可以看到,对于若干半径,这将确保
通过调整的值,我们可以改变的大小。

通常,添加惩罚是确保近似满足约束的一种好方法。
在实践中,这被证明比精确的满意度更可靠。
此外,对于非凸问题,许多使精确方法在凸情况下的性质(例如,可求最优解)不再成立。

投影

满足约束条件的另一种策略是投影(projections)。
同样,我们之前也遇到过,例如在 :numref:sec_rnn_scratch中处理梯度截断时,我们通过

确保梯度的长度以为界限。

这就是在半径为的球上的投影(projection)。
更泛化地说,在凸集上的投影被定义为

$$\mathrm{Proj}\mathcal{X}(\mathbf{x}) = \mathop{\mathrm{argmin}}{\mathbf{x}’ \in \mathcal{X}} |\mathbf{x} - \mathbf{x}’|.$$

它是中离最近的点。

Convex Projections.
:label:fig_projections

投影的数学定义听起来可能有点抽象,为了解释得更清楚一些,请看 :numref:fig_projections
图中有两个凸集,一个圆和一个菱形。
两个集合内的点(黄色)在投影期间保持不变。
两个集合(黑色)之外的点投影到集合中接近原始点(黑色)的点(红色)。
虽然对的球面来说,方向保持不变,但一般情况下不需要这样。

凸投影的一个用途是计算稀疏权重向量。
在本例中,我们将权重向量投影到一个的球上,
这是 :numref:fig_projections中菱形例子的一个广义版本。

小结

在深度学习的背景下,凸函数的主要目的是帮助我们详细了解优化算法。
我们由此得出梯度下降法和随机梯度下降法是如何相应推导出来的。

  • 凸集的交点是凸的,并集不是。
  • 根据詹森不等式,“一个多变量凸函数的总期望值”大于或等于“用每个变量的期望值计算这个函数的总值“。
  • 一个二次可微函数是凸函数,当且仅当其Hessian(二阶导数矩阵)是半正定的。
  • 凸约束可以通过拉格朗日函数来添加。在实践中,只需在目标函数中加上一个惩罚就可以了。
  • 投影映射到凸集中最接近原始点的点。

练习

  1. 假设我们想要通过绘制集合内点之间的所有直线并检查这些直线是否包含来验证集合的凸性。i.证明只检查边界上的点是充分的。ii.证明只检查集合的顶点是充分的。

  2. -范数表示半径为的球,证明对于所有是凸的。

  3. 已知凸函数表明也是凸函数。证明是非凸的。

  4. 证明Softmax函数的规范化是凸的,即的凸性。

  5. 证明线性子空间是凸集。

  6. 证明在线性子空间的情况下,对于矩阵的投影可以写成

  7. 证明对于凸二次可微函数,对于,我们可以写成

  8. 给定一个凸集和两个向量证明了投影不会增加距离,即

梯度下降


:label:sec_gd

尽管梯度下降(gradient descent)很少直接用于深度学习,
但了解它是理解下一节随机梯度下降算法的关键。
例如,由于学习率过大,优化问题可能会发散,这种现象早已在梯度下降中出现。
同样地,预处理(preconditioning)是梯度下降中的一种常用技术,
还被沿用到更高级的算法中。
让我们从简单的一维梯度下降开始。

一维梯度下降

为什么梯度下降算法可以优化目标函数?
一维中的梯度下降给我们很好的启发。
考虑一类连续可微实值函数
利用泰勒展开,我们可以得到


:eqlabel:gd-taylor

即在一阶近似中,可通过处的函数值和一阶导数得出。
我们可以假设在负梯度方向上移动的会减少
为了简单起见,我们选择固定步长,然后取
将其代入泰勒展开式我们可以得到


:eqlabel:gd-taylor-2

如果其导数没有消失,我们就能继续展开,这是因为
此外,我们总是可以令小到足以使高阶项变得不相关。
因此,

这意味着,如果我们使用

来迭代,函数的值可能会下降。
因此,在梯度下降中,我们首先选择初始值和常数
然后使用它们连续迭代,直到停止条件达成。
例如,当梯度的幅度足够小或迭代次数达到某个值时。

下面我们来展示如何实现梯度下降。为了简单起见,我们选用目标函数
尽管我们知道能取得最小值,
但我们仍然使用这个简单的函数来观察的变化。

1
2
3
4
%matplotlib inline  
import numpy as np
import torch
from d2l import torch as d2l
1
2
3
4
5
def f(x):  # 目标函数  
return x ** 2

def f_grad(x): # 目标函数的梯度(导数)
return 2 * x


接下来,我们使用作为初始值,并假设
使用梯度下降法迭代共10次,我们可以看到,的值最终将接近最优解。

1
2
3
4
5
6
7
8
9
10
def gd(eta, f_grad):  
x = 10.0
results = [x]
for i in range(10):
x -= eta * f_grad(x)
results.append(float(x))
print(f'epoch 10, x: {x:f}')
return results

results = gd(0.2, f_grad)
1
epoch 10, x: 0.060466

对进行优化的过程可以绘制如下。

1
2
3
4
5
6
7
8
def show_trace(results, f):  
n = max(abs(min(results)), abs(max(results)))
f_line = torch.arange(-n, n, 0.01)
d2l.set_figsize()
d2l.plot([f_line, results], [[f(x) for x in f_line], [
f(x) for x in results]], 'x', 'f(x)', fmts=['-', '-o'])

show_trace(results, f)

300

学习率

:label:subsec_gd-learningrate

学习率(learning rate)决定目标函数能否收敛到局部最小值,以及何时收敛到最小值。
学习率可由算法设计者设置。
请注意,如果我们使用的学习率太小,将导致的更新非常缓慢,需要更多的迭代。
例如,考虑同一优化问题中的进度。
如下所示,尽管经过了10个步骤,我们仍然离最优解很远。

1
show_trace(gd(0.05, f_grad), f)
1
2
3
epoch 10, x: 3.486784

<Figure size 350x250 with 1 Axes>

300

相反,如果我们使用过高的学习率,对于一阶泰勒展开式可能太大。
也就是说, :eqref:gd-taylor中的可能变得显著了。
在这种情况下,的迭代不能保证降低的值。
例如,当学习率为时,超出了最优解并逐渐发散。

1
show_trace(gd(1.1, f_grad), f)
1
2
3
epoch 10, x: 61.917364

<Figure size 350x250 with 1 Axes>

300

局部最小值

为了演示非凸函数的梯度下降,考虑函数,其中为某常数。
这个函数有无穷多个局部最小值。
根据我们选择的学习率,我们最终可能只会得到许多解的一个。
下面的例子说明了(不切实际的)高学习率如何导致较差的局部最小值。

1
2
3
4
5
6
7
8
9
c = torch.tensor(0.15 * np.pi)  

def f(x): # 目标函数
return x * torch.cos(c * x)

def f_grad(x): # 目标函数的梯度
return torch.cos(c * x) - c * x * torch.sin(c * x)

show_trace(gd(2, f_grad), f)
1
2
3
epoch 10, x: -1.528166

<Figure size 350x250 with 1 Axes>

300

多元梯度下降

现在我们对单变量的情况有了更好的理解,让我们考虑一下的情况。
即目标函数将向量映射成标量。
相应地,它的梯度也是多元的,它是一个由个偏导数组成的向量:

梯度中的每个偏导数元素代表了当输入处的变化率。
和先前单变量的情况一样,我们可以对多变量函数使用相应的泰勒近似来思考。
具体来说,


:eqlabel:gd-multi-taylor

换句话说,在的二阶项中,
最陡下降的方向由负梯度得出。
选择合适的学习率来生成典型的梯度下降算法:

这个算法在实践中的表现如何呢?
我们构造一个目标函数
并有二维向量作为输入,
标量作为输出。
梯度由给出。
我们将从初始位置通过梯度下降观察的轨迹。

我们还需要两个辅助函数:
第一个是update函数,并将其应用于初始值20次;
第二个函数会显示的轨迹。

1
2
3
4
5
6
7
8
9
10
11
12
13
def train_2d(trainer, steps=20, f_grad=None):  #@save  
"""用定制的训练机优化2D目标函数"""
# s1和s2是稍后将使用的内部状态变量
x1, x2, s1, s2 = -5, -2, 0, 0
results = [(x1, x2)]
for i in range(steps):
if f_grad:
x1, x2, s1, s2 = trainer(x1, x2, s1, s2, f_grad)
else:
x1, x2, s1, s2 = trainer(x1, x2, s1, s2)
results.append((x1, x2))
print(f'epoch {i + 1}, x1: {float(x1):f}, x2: {float(x2):f}')
return results
1
2
3
4
5
6
7
8
9
def show_trace_2d(f, results):  #@save  
"""显示优化过程中2D变量的轨迹"""
d2l.set_figsize()
d2l.plt.plot(*zip(*results), '-o', color='#ff7f0e')
x1, x2 = torch.meshgrid(torch.arange(-5.5, 1.0, 0.1),
torch.arange(-3.0, 1.0, 0.1), indexing='ij')
d2l.plt.contour(x1, x2, f(x1, x2), colors='#1f77b4')
d2l.plt.xlabel('x1')
d2l.plt.ylabel('x2')

接下来,我们观察学习率时优化变量的轨迹。
可以看到,经过20步之后,的值接近其位于的最小值。
虽然进展相当顺利,但相当缓慢。

1
2
3
4
5
6
7
8
9
10
11
12
def f_2d(x1, x2):  # 目标函数  
return x1 ** 2 + 2 * x2 ** 2

def f_2d_grad(x1, x2): # 目标函数的梯度
return (2 * x1, 4 * x2)

def gd_2d(x1, x2, s1, s2, f_grad):
g1, g2 = f_grad(x1, x2)
return (x1 - eta * g1, x2 - eta * g2, 0, 0)

eta = 0.1
show_trace_2d(f_2d, train_2d(gd_2d, f_grad=f_2d_grad))
1
2
3
epoch 20, x1: -0.057646, x2: -0.000073

<Figure size 350x250 with 1 Axes>

300

随机梯度下降


:label:sec_sgd

在前面的章节中,我们一直在训练过程中使用随机梯度下降,但没有解释它为什么起作用。为了澄清这一点,我们刚在 :numref:sec_gd中描述了梯度下降的基本原则。本节继续更详细地说明随机梯度下降(stochastic gradient descent)。

1
2
3
4
%matplotlib inline  
import math
import torch
from d2l import torch as d2l

随机梯度更新

在深度学习中,目标函数通常是训练数据集中每个样本的损失函数的平均值。给定个样本的训练数据集,我们假设是关于索引的训练样本的损失函数,其中是参数向量。然后我们得到目标函数

的目标函数的梯度计算为

如果使用梯度下降法,则每个自变量迭代的计算代价为,它随线性增长。因此,当训练数据集较大时,每次迭代的梯度下降计算代价将较高

随机梯度下降(SGD)可降低每次迭代时的计算代价。在随机梯度下降的每次迭代中,我们对**数据样本随机均匀采样一个索引**,其中,并计算梯度以更新

其中是学习率。我们可以看到,每次迭代的计算代价从梯度下降的降至常数。此外,我们要强调,随机梯度是对完整梯度的无偏估计,因为

$$\mathbb{E}i \nabla f_i(\mathbf{x}) = \frac{1}{n} \sum{i = 1}^n \nabla f_i(\mathbf{x}) = \nabla f(\mathbf{x}).$$

这意味着,平均而言,随机梯度是对梯度的良好估计。

现在,我们将把它与梯度下降进行比较,方法是向梯度添加均值为0、方差为1的随机噪声,以模拟随机梯度下降。

1
2
3
4
5
def f(x1, x2):  # 目标函数  
return x1 ** 2 + 2 * x2 ** 2

def f_grad(x1, x2): # 目标函数的梯度
return 2 * x1, 4 * x2
1
2
3
4
5
6
7
def sgd(x1, x2, s1, s2, f_grad):  
g1, g2 = f_grad(x1, x2)
# 模拟有噪声的梯度
g1 += torch.normal(0.0, 1, (1,)).item()
g2 += torch.normal(0.0, 1, (1,)).item()
eta_t = eta * lr()
return (x1 - eta_t * g1, x2 - eta_t * g2, 0, 0)
1
2
3
4
5
6
def constant_lr():  
return 1

eta = 0.1
lr = constant_lr # 常数学习速度
d2l.show_trace_2d(f, d2l.train_2d(sgd, steps=50, f_grad=f_grad))
1
epoch 50, x1: 0.069102, x2: 0.051447

300

小批量随机梯度下降


:label:sec_minibatch_sgd

到目前为止,我们在基于梯度的学习方法中遇到了两个极端情况:
:numref:sec_gd中使用完整数据集来计算梯度并更新参数,
:numref:sec_sgd中一次处理一个训练样本来取得进展。
二者各有利弊:每当数据非常相似时,梯度下降并不是非常“数据高效”。
而由于CPU和GPU无法充分利用向量化,随机梯度下降并不特别“计算高效”。
这暗示了两者之间可能有折中方案,这便涉及到小批量随机梯度下降(minibatch gradient descent)。

向量化和缓存

使用小批量的决策的核心是计算效率。
当考虑与多个GPU和多台服务器并行处理时,这一点最容易被理解。在这种情况下,我们需要向每个GPU发送至少一张图像。
有了每台服务器8个GPU和16台服务器,我们就能得到大小为128的小批量。

当涉及到单个GPU甚至CPU时,事情会更微妙一些:
这些设备有多种类型的内存、通常情况下多种类型的计算单元以及在它们之间不同的带宽限制。
例如,一个CPU有少量寄存器(register),L1和L2缓存,以及L3缓存(在不同的处理器内核之间共享)。
随着缓存的大小的增加,它们的延迟也在增加,同时带宽在减少。
可以说,处理器能够执行的操作远比主内存接口所能提供的多得多。

首先,具有16个内核和AVX-512向量化的2GHz CPU每秒可处理高达个字节。
同时,GPU的性能很容易超过该数字100倍。
而另一方面,中端服务器处理器的带宽可能不超过100Gb/s,即不到处理器满负荷所需的十分之一。
更糟糕的是,并非所有的内存入口都是相等的:内存接口通常为64位或更宽(例如,在最多384位的GPU上)。
因此读取单个字节会导致由于更宽的存取而产生的代价。

其次,第一次存取的额外开销很大,而按序存取(sequential access)或突发读取(burst read)相对开销较小。
有关更深入的讨论,请参阅此维基百科文章

减轻这些限制的方法是使用足够快的CPU缓存层次结构来为处理器提供数据。
这是深度学习中批量处理背后的推动力。
举一个简单的例子:矩阵-矩阵乘法。
比如,我们有很多方法来计算。例如,我们可以尝试以下方法:

  1. 我们可以计算$\mathbf{A}{ij} = \mathbf{B}{i,:} \mathbf{C}_{:,j}^\top$,也就是说,我们可以通过点积进行逐元素计算。
  2. 我们可以计算$\mathbf{A}{:,j} = \mathbf{B} \mathbf{C}{:,j}^\top\mathbf{A}\mathbf{A}_{i,:}$。
  3. 我们可以简单地计算
  4. 我们可以将分成较小的区块矩阵,然后一次计算的一个区块。

如果我们使用第一个选择,每次我们计算一个元素$\mathbf{A}{ij}访\mathbf{B}\mathbf{C}{:,j}$保留在CPU缓存中。
它将内存带宽需求减半,相应地提高了访问速度。
第三种选择表面上是最可取的,然而大多数矩阵可能不能完全放入缓存中。
第四种选择提供了一个实践上很有用的方案:我们可以将矩阵的区块移到缓存中然后在本地将它们相乘。
让我们来看看这些操作在实践中的效率如何。

除了计算效率之外,Python和深度学习框架本身带来的额外开销也是相当大的。
回想一下,每次我们执行代码时,Python解释器都会向深度学习框架发送一个命令,要求将其插入到计算图中并在调度过程中处理它。
这样的额外开销可能是非常不利的。
总而言之,我们最好用向量化(和矩阵)。

1
2
3
4
5
6
7
8
9
10
%matplotlib inline  
import numpy as np
import torch
from torch import nn
from d2l import torch as d2l

timer = d2l.Timer()
A = torch.zeros(256, 256)
B = torch.randn(256, 256)
C = torch.randn(256, 256)

按元素分配只需遍历分别为的所有行和列,即可将该值分配给

1
2
3
4
5
6
# 逐元素计算A=BC  
timer.start()
for i in range(256):
for j in range(256):
A[i, j] = torch.dot(B[i, :], C[:, j])
timer.stop()
1
2.1955742835998535

更快的策略是执行按列分配。

1
2
3
4
5
# 逐列计算A=BC  
timer.start()
for j in range(256):
A[:, j] = torch.mv(B, C[:, j])
timer.stop()
1
0.01396489143371582

最有效的方法是在一个区块中执行整个操作。让我们看看它们各自的操作速度是多少。

1
2
3
4
5
6
7
8
9
# 一次性计算A=BC  
timer.start()
A = torch.mm(B, C)
timer.stop()

# 乘法和加法作为单独的操作(在实践中融合)
gigaflops = [2/i for i in timer.times]
print(f'performance in Gigaflops: element {gigaflops[0]:.3f}, '
f'column {gigaflops[1]:.3f}, full {gigaflops[2]:.3f}')
1
performance in Gigaflops: element 0.911, column 143.216, full 401.043

小批量

:label:sec_minibatches

之前我们会理所当然地读取数据的小批量,而不是观测单个数据来更新参数,现在简要解释一下原因。
处理单个观测值需要我们执行许多单一矩阵-矢量(甚至矢量-矢量)乘法,这耗费相当大,而且对应深度学习框架也要巨大的开销。
这既适用于计算梯度以更新参数时,也适用于用神经网络预测。
也就是说,每当我们执行时,消耗巨大。其中

$$\mathbf{g}t = \partial{\mathbf{w}} f(\mathbf{x}_{t}, \mathbf{w}).$$

我们可以通过将其应用于一个小批量观测值来提高此操作的计算效率。
也就是说,我们将梯度替换为一个小批量而不是单个观测值

$$\mathbf{g}t = \partial{\mathbf{w}} \frac{1}{|\mathcal{B}t|} \sum{i \in \mathcal{B}t} f(\mathbf{x}{i}, \mathbf{w}).$$

让我们看看这对的统计属性有什么影响:由于和小批量的所有元素都是从训练集中随机抽出的,因此梯度的期望保持不变。
另一方面,方差显著降低。
由于小批量梯度由正在被平均计算的个独立梯度组成,其标准差降低了
这本身就是一件好事,因为这意味着更新与完整的梯度更接近了。

直观来说,这表明选择大型的小批量将是普遍可行的。
然而,经过一段时间后,与计算代价的线性增长相比,标准差的额外减少是微乎其微的。
在实践中我们选择一个足够大的小批量,它可以提供良好的计算效率同时仍适合GPU的内存。
下面,我们来看看这些高效的代码。
在里面我们执行相同的矩阵-矩阵乘法,但是这次我们将其一次性分为64列的“小批量”。

1
2
3
4
5
timer.start()  
for j in range(0, 256, 64):
A[:, j:j+64] = torch.mm(B, C[:, j:j+64])
timer.stop()
print(f'performance in Gigaflops: block {2 / timer.times[3]:.3f}')
1
performance in Gigaflops: block 1003.062

显而易见,小批量上的计算基本上与完整矩阵一样有效。
需要注意的是,在 :numref:sec_batch_norm中,我们使用了一种在很大程度上取决于小批量中的方差的正则化。
随着后者增加,方差会减少,随之而来的是批量规范化带来的噪声注入的好处。
关于实例,请参阅 :cite:Ioffe.2017,了解有关如何重新缩放并计算适当项目。

读取数据集

让我们来看看如何从数据中有效地生成小批量。
下面我们使用NASA开发的测试机翼的数据集不同飞行器产生的噪声来比较这些优化算法。
为方便起见,我们只使用前样本。
数据已作预处理:我们移除了均值并将方差重新缩放到每个坐标为

1
2
3
4
5
6
7
8
9
10
11
12
#@save  
d2l.DATA_HUB['airfoil'] = (d2l.DATA_URL + 'airfoil_self_noise.dat',
'76e5be1548fd8222e5074cf0faae75edff8cf93f')

#@save
def get_data_ch11(batch_size=10, n=1500):
data = np.genfromtxt(d2l.download('airfoil'),
dtype=np.float32, delimiter='\t')
data = torch.from_numpy((data - data.mean(axis=0)) / data.std(axis=0))
data_iter = d2l.load_array((data[:n, :-1], data[:n, -1]),
batch_size, is_train=True)
return data_iter, data.shape[1]-1

从零开始实现

:numref:sec_linear_scratch一节中已经实现过小批量随机梯度下降算法。
我们在这里将它的输入参数变得更加通用,主要是为了方便本章后面介绍的其他优化算法也可以使用同样的输入。
具体来说,我们添加了一个状态输入states并将超参数放在字典hyperparams
此外,我们将在训练函数里对各个小批量样本的损失求平均,因此优化算法中的梯度不需要除以批量大小

1
2
3
4
def sgd(params, states, hyperparams):  
for p in params:
p.data.sub_(hyperparams['lr'] * p.grad)
p.grad.data.zero_()

下面实现一个通用的训练函数,以方便本章后面介绍的其他优化算法使用。
它初始化了一个线性回归模型,然后可以使用小批量随机梯度下降以及后续小节介绍的其他算法来训练模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#@save  
def train_ch11(trainer_fn, states, hyperparams, data_iter,
feature_dim, num_epochs=2):
# 初始化模型
w = torch.normal(mean=0.0, std=0.01, size=(feature_dim, 1),
requires_grad=True)
b = torch.zeros((1), requires_grad=True)
net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
# 训练模型
animator = d2l.Animator(xlabel='epoch', ylabel='loss',
xlim=[0, num_epochs], ylim=[0.22, 0.35])
n, timer = 0, d2l.Timer()
for _ in range(num_epochs):
for X, y in data_iter:
l = loss(net(X), y).mean()
l.backward()
trainer_fn([w, b], states, hyperparams)
n += X.shape[0]
if n % 200 == 0:
timer.stop()
animator.add(n/X.shape[0]/len(data_iter),
(d2l.evaluate_loss(net, data_iter, loss),))
timer.start()
print(f'loss: {animator.Y[0][-1]:.3f}, {timer.avg():.3f} sec/epoch')
return timer.cumsum(), animator.Y[0]

让我们来看看批量梯度下降的优化是如何进行的。
这可以通过将小批量设置为1500(即样本总数)来实现。
因此,模型参数每个迭代轮数只迭代一次。

1
2
3
4
5
6
def train_sgd(lr, batch_size, num_epochs=2):  
data_iter, feature_dim = get_data_ch11(batch_size)
return train_ch11(
sgd, None, {'lr': lr}, data_iter, feature_dim, num_epochs)

gd_res = train_sgd(1, 1500, 10)

300

当批量大小为1时,优化使用的是随机梯度下降。
为了简化实现,我们选择了很小的学习率。
在随机梯度下降的实验中,每当一个样本被处理,模型参数都会更新。
在这个例子中,这相当于每个迭代轮数有1500次更新。
可以看到,目标函数值的下降在1个迭代轮数后就变得较为平缓。
尽管两个例子在一个迭代轮数内都处理了1500个样本,但实验中随机梯度下降的一个迭代轮数耗时更多。
这是因为随机梯度下降更频繁地更新了参数,而且一次处理单个观测值效率较低

1
sgd_res = train_sgd(0.005, 1)

300

最后,当批量大小等于100时,我们使用小批量随机梯度下降进行优化。
每个迭代轮数所需的时间比随机梯度下降和批量梯度下降所需的时间短。

1
mini1_res = train_sgd(.4, 100)

300

将批量大小减少到10,每个迭代轮数的时间都会增加,因为每批工作负载的执行效率变得更低。

1
mini2_res = train_sgd(.05, 10)

300

现在我们可以比较前四个实验的时间与损失。
可以看出,尽管在处理的样本数方面,随机梯度下降的收敛速度快于梯度下降,但与梯度下降相比,它需要更多的时间来达到同样的损失,因为逐个样本来计算梯度并不那么有效。
小批量随机梯度下降能够平衡收敛速度和计算效率
大小为10的小批量比随机梯度下降更有效;
大小为100的小批量在运行时间上甚至优于梯度下降。

1
2
3
4
5
d2l.set_figsize([6, 3])  
d2l.plot(*list(map(list, zip(gd_res, sgd_res, mini1_res, mini2_res))),
'time (sec)', 'loss', xlim=[1e-2, 10],
legend=['gd', 'sgd', 'batch size=100', 'batch size=10'])
d2l.plt.gca().set_xscale('log')

300

简洁实现

下面用深度学习框架自带算法实现一个通用的训练函数,我们将在本章中其它小节使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#@save  
def train_concise_ch11(trainer_fn, hyperparams, data_iter, num_epochs=4):
# 初始化模型
net = nn.Sequential(nn.Linear(5, 1))
def init_weights(m):
if type(m) == nn.Linear:
torch.nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights)

optimizer = trainer_fn(net.parameters(), **hyperparams)
loss = nn.MSELoss(reduction='none')
animator = d2l.Animator(xlabel='epoch', ylabel='loss',
xlim=[0, num_epochs], ylim=[0.22, 0.35])
n, timer = 0, d2l.Timer()
for _ in range(num_epochs):
for X, y in data_iter:
optimizer.zero_grad()
out = net(X)
y = y.reshape(out.shape)
l = loss(out, y)
l.mean().backward()
optimizer.step()
n += X.shape[0]
if n % 200 == 0:
timer.stop()
# MSELoss计算平方误差时不带系数1/2
animator.add(n/X.shape[0]/len(data_iter),
(d2l.evaluate_loss(net, data_iter, loss) / 2,))
timer.start()
print(f'loss: {animator.Y[0][-1]:.3f}, {timer.avg():.3f} sec/epoch')

下面使用这个训练函数,复现之前的实验。

1
2
3
data_iter, _ = get_data_ch11(10)  
trainer = torch.optim.SGD
train_concise_ch11(trainer, {'lr': 0.01}, data_iter)

300

小结

  • 由于减少了深度学习框架的额外开销,使用更好的内存定位以及CPU和GPU上的缓存,向量化使代码更加高效。
  • 随机梯度下降的“统计效率”与大批量一次处理数据的“计算效率”之间存在权衡。小批量随机梯度下降提供了两全其美的答案:计算和统计效率。
  • 在小批量随机梯度下降中,我们处理通过训练数据的随机排列获得的批量数据(即每个观测值只处理一次,但按随机顺序)。
  • 在训练期间降低学习率有助于训练。
  • 一般来说,小批量随机梯度下降比随机梯度下降和梯度下降的速度快,收敛风险较小。

练习

  1. 修改批量大小和学习率,并观察目标函数值的下降率以及每个迭代轮数消耗的时间。
  2. 将小批量随机梯度下降与实际从训练集中取样替换的变体进行比较。会看出什么?
  3. 一个邪恶的精灵在没通知你的情况下复制了你的数据集(即每个观测发生两次,数据集增加到原始大小的两倍,但没有人告诉你)。随机梯度下降、小批量随机梯度下降和梯度下降的表现将如何变化?

动量法


:label:sec_momentum

在 :numref:sec_sgd一节中,我们详述了如何执行随机梯度下降,即在只有嘈杂的梯度可用的情况下执行优化时会发生什么。
对于嘈杂的梯度,我们在选择学习率需要格外谨慎。
如果衰减速度太快,收敛就会停滞。
相反,如果太宽松,我们可能无法收敛到最优解。

基础

本节将探讨更有效的优化算法,尤其是针对实验中常见的某些类型的优化问题。

泄漏平均值

上一节中我们讨论了小批量随机梯度下降作为加速计算的手段。
它也有很好的副作用,即平均梯度减小了方差。
小批量随机梯度下降可以通过以下方式计算:

$$\mathbf{g}{t, t-1} = \partial{\mathbf{w}} \frac{1}{|\mathcal{B}t|} \sum{i \in \mathcal{B}t} f(\mathbf{x}{i}, \mathbf{w}_{t-1}) = \frac{1}{|\mathcal{B}t|} \sum{i \in \mathcal{B}t} \mathbf{h}{i, t-1}.
$$

为了保持记法简单,在这里我们使用$\mathbf{h}{i, t-1} = \partial{\mathbf{w}} f(\mathbf{x}i, \mathbf{w}{t-1})i使t-1t-1$。
如果我们能够从方差减少的影响中受益,甚至超过小批量上的梯度平均值,那很不错。
完成这项任务的一种选择是用泄漏平均值(leaky average)取代梯度计算:

$$\mathbf{v}t = \beta \mathbf{v}{t-1} + \mathbf{g}_{t, t-1}$$

其中
这有效地将瞬时梯度替换为多个“过去”梯度的平均值。
被称为动量(momentum),
它累加了过去的梯度
为了更详细地解释,让我们递归地将扩展到

$$\begin{aligned}
\mathbf{v}t = \beta^2 \mathbf{v}{t-2} + \beta \mathbf{g}{t-1, t-2} + \mathbf{g}{t, t-1}
= \ldots, = \sum_{\tau = 0}^{t-1} \beta^{\tau} \mathbf{g}_{t-\tau, t-\tau-1}.
\end{aligned}$$

其中,较大的相当于长期平均值,而较小的相对于梯度法只是略有修正。
新的梯度替换不再指向特定实例下降最陡的方向,而是指向过去梯度的加权平均值的方向
这使我们能够实现对单批量计算平均值的大部分好处,而不产生实际计算其梯度的代价。

上述推理构成了”加速”梯度方法的基础,例如具有动量的梯度。
在优化问题条件不佳的情况下(例如,有些方向的进展比其他方向慢得多,类似狭窄的峡谷),”加速”梯度还额外享受更有效的好处。
此外,它们允许我们对随后的梯度计算平均值,以获得更稳定的下降方向。
诚然,即使是对于无噪声凸问题,加速度这方面也是动量如此起效的关键原因之一。

正如人们所期望的,由于其功效,动量是深度学习及其后优化中一个深入研究的主题。
例如,请参阅[文章](https://distill.pub/2017/momentum/)(作者是 :cite:Goh.2017),观看深入分析和互动动画。
动量是由 :cite:Polyak.1964提出的。
:cite:Nesterov.2018在凸优化的背景下进行了详细的理论讨论。
长期以来,深度学习的动量一直被认为是有益的。
有关实例的详细信息,请参阅 :cite:Sutskever.Martens.Dahl.ea.2013的讨论。

条件不佳的问题

为了更好地了解动量法的几何属性,我们复习一下梯度下降,尽管它的目标函数明显不那么令人愉快。
回想我们在 :numref:sec_gd中使用了,即中度扭曲的椭球目标。
我们通过向方向伸展它来进一步扭曲这个函数

与之前一样,有最小值,
该函数在的方向上非常平坦。
让我们看看在这个新函数上执行梯度下降时会发生什么。

1
2
3
4
5
6
7
8
9
10
11
%matplotlib inline  
import torch
from d2l import torch as d2l

eta = 0.4
def f_2d(x1, x2):
return 0.1 * x1 ** 2 + 2 * x2 ** 2
def gd_2d(x1, x2, s1, s2):
return (x1 - eta * 0.2 * x1, x2 - eta * 4 * x2, 0, 0)

d2l.show_trace_2d(f_2d, d2l.train_2d(gd_2d))
1
epoch 20, x1: -0.943467, x2: -0.000073

300

1
2
eta = 0.6  
d2l.show_trace_2d(f_2d, d2l.train_2d(gd_2d))
1
2
3
epoch 20, x1: -0.387814, x2: -1673.365109

<Figure size 350x250 with 1 Axes>

300

动量法

动量法(momentum)使我们能够解决上面描述的梯度下降问题。
观察上面的优化轨迹,我们可能会直觉到计算过去的平均梯度效果会很好。
毕竟,在方向上,这将聚合非常对齐的梯度,从而增加我们在每一步中覆盖的距离。
相反,在梯度振荡的方向,由于相互抵消了对方的振荡,聚合梯度将减小步长大小。
使用而不是梯度可以生成以下更新等式:

$$
\begin{aligned}
\mathbf{v}t &\leftarrow \beta \mathbf{v}{t-1} + \mathbf{g}_{t, t-1}, \
\mathbf{x}t &\leftarrow \mathbf{x}{t-1} - \eta_t \mathbf{v}_t.
\end{aligned}
$$

请注意,对于,我们恢复常规的梯度下降。
在深入研究它的数学属性之前,让我们快速看一下算法在实验中的表现如何。

1
2
3
4
5
6
7
def momentum_2d(x1, x2, v1, v2):  
v1 = beta * v1 + 0.2 * x1
v2 = beta * v2 + 4 * x2
return x1 - eta * v1, x2 - eta * v2, v1, v2

eta, beta = 0.6, 0.5
d2l.show_trace_2d(f_2d, d2l.train_2d(momentum_2d))
1
2
3
epoch 20, x1: 0.007188, x2: 0.002553

<Figure size 350x250 with 1 Axes>

300

正如所见,尽管学习率与我们以前使用的相同,动量法仍然很好地收敛了。
让我们看看当降低动量参数时会发生什么。
将其减半至会导致一条几乎没有收敛的轨迹。
尽管如此,它比没有动量时解将会发散要好得多。

1
2
eta, beta = 0.6, 0.25  
d2l.show_trace_2d(f_2d, d2l.train_2d(momentum_2d))
1
epoch 20, x1: -0.126340, x2: -0.186632

300

请注意,我们可以将动量法与随机梯度下降,特别是小批量随机梯度下降结合起来。
唯一的变化是,在这种情况下,我们将梯度替换为
为了方便起见,我们在时间初始化

有效样本权重

回想一下$\mathbf{v}t = \sum{\tau = 0}^{t-1} \beta^{\tau} \mathbf{g}{t-\tau, t-\tau-1}\sum{\tau=0}^\infty \beta^\tau = \frac{1}{1-\beta}\eta\frac{\eta}{1-\beta}\beta$的不同选择的权重效果如何,请参考下面的图表。

1
2
3
4
5
6
7
d2l.set_figsize()  
betas = [0.95, 0.9, 0.6, 0]
for beta in betas:
x = torch.arange(40).detach().numpy()
d2l.plt.plot(x, beta ** x, label=f'beta = {beta:.2f}')
d2l.plt.xlabel('time')
d2l.plt.legend();

300

实际实验

让我们来看看动量法在实验中是如何运作的。
为此,我们需要一个更加可扩展的实现。

从零开始实现

相比于小批量随机梯度下降,动量方法需要维护一组辅助变量,即速度。
它与梯度以及优化问题的变量具有相同的形状。
在下面的实现中,我们称这些变量为states

1
2
3
4
def init_momentum_states(feature_dim):  
v_w = torch.zeros((feature_dim, 1))
v_b = torch.zeros(1)
return (v_w, v_b)
1
2
3
4
5
6
def sgd_momentum(params, states, hyperparams):  
for p, v in zip(params, states):
with torch.no_grad():
v[:] = hyperparams['momentum'] * v + p.grad
p[:] -= hyperparams['lr'] * v
p.grad.data.zero_()

让我们看看它在实验中是如何运作的。

1
2
3
4
5
6
7
def train_momentum(lr, momentum, num_epochs=2):  
d2l.train_ch11(sgd_momentum, init_momentum_states(feature_dim),
{'lr': lr, 'momentum': momentum}, data_iter,
feature_dim, num_epochs)

data_iter, feature_dim = d2l.get_data_ch11(batch_size=10)
train_momentum(0.02, 0.5)

300

当我们将动量超参数momentum增加到0.9时,它相当于有效样本数量增加到
我们将学习率略微降至,以确保可控。

1
train_momentum(0.01, 0.9)

300

降低学习率进一步解决了任何非平滑优化问题的困难,将其设置为会产生良好的收敛性能。

1
train_momentum(0.005, 0.9)

300

简洁实现

由于深度学习框架中的优化求解器早已构建了动量法,设置匹配参数会产生非常类似的轨迹。

1
2
trainer = torch.optim.SGD  
d2l.train_concise_ch11(trainer, {'lr': 0.005, 'momentum': 0.9}, data_iter)

300

理论分析

的2D示例似乎相当牵强。
下面我们将看到,它在实际生活中非常具有代表性,至少最小化凸二次目标函数的情况下是如此。

二次凸函数

考虑这个函数

这是一个普通的二次函数。
对于正定矩阵,即对于具有正特征值的矩阵,有最小化器为,最小值为
因此我们可以将重写为

梯度由给出。
也就是说,它是由和最小化器之间的距离乘以所得出的。
因此,动量法还是的线性组合。

由于是正定的,因此可以通过分解为正交(旋转)矩阵和正特征值的对角矩阵
这使我们能够将变量从更改为,以获得一个非常简化的表达式:

这里
由于只是一个正交矩阵,因此不会真正意义上扰动梯度。
表示的梯度下降变成

$$\mathbf{z}t = \mathbf{z}{t-1} - \boldsymbol{\Lambda} \mathbf{z}{t-1} = (\mathbf{I} - \boldsymbol{\Lambda}) \mathbf{z}{t-1}.$$

这个表达式中的重要事实是梯度下降在不同的特征空间之间不会混合。
也就是说,如果用的特征系统来表示,优化问题是以逐坐标顺序的方式进行的。
这在动量法中也适用。

$$\begin{aligned}
\mathbf{v}t & = \beta \mathbf{v}{t-1} + \boldsymbol{\Lambda} \mathbf{z}{t-1} \
\mathbf{z}t & = \mathbf{z}{t-1} - \eta \left(\beta \mathbf{v}
{t-1} + \boldsymbol{\Lambda} \mathbf{z}{t-1}\right) \
& = (\mathbf{I} - \eta \boldsymbol{\Lambda}) \mathbf{z}
{t-1} - \eta \beta \mathbf{v}_{t-1}.
\end{aligned}$$

在这样做的过程中,我们只是证明了以下定理:带有和带有不凸二次函数动量的梯度下降,可以分解为朝二次矩阵特征向量方向坐标顺序的优化。

标量函数

鉴于上述结果,让我们看看当我们最小化函数时会发生什么。
对于梯度下降我们有

时,这种优化以指数速度收敛,因为在步之后我们可以得到
这显示了在我们将学习率提高到之前,收敛率最初是如何提高的。
超过该数值之后,梯度开始发散,对于而言,优化问题将会发散。

1
2
3
4
5
6
7
8
lambdas = [0.1, 1, 10, 19]  
eta = 0.1
d2l.set_figsize((6, 4))
for lam in lambdas:
t = torch.arange(20).detach().numpy()
d2l.plt.plot(t, (1 - eta * lam) ** t, label=f'lambda = {lam:.2f}')
d2l.plt.xlabel('time')
d2l.plt.legend();

300

为了分析动量的收敛情况,我们首先用两个标量重写更新方程:一个用于,另一个用于动量。这产生了:

我们用来表示管理的收敛表现。
步之后,最初的值变为
因此,收敛速度是由的特征值决定的。
请参阅文章 :cite:Goh.2017了解精彩动画。
请参阅 :cite:Flammarion.Bach.2015了解详细分析。
简而言之,当时动量收敛。
与梯度下降的相比,这是更大范围的可行参数。
另外,一般而言较大值的是可取的。

小结

  • 动量法用过去梯度的平均值来替换梯度,这大大加快了收敛速度。
  • 对于无噪声梯度下降和嘈杂随机梯度下降,动量法都是可取的。
  • 动量法可以防止在随机梯度下降的优化过程停滞的问题。
  • 由于对过去的数据进行了指数降权,有效梯度数为
  • 在凸二次问题中,可以对动量法进行明确而详细的分析。
  • 动量法的实现非常简单,但它需要我们存储额外的状态向量(动量)。

练习

  1. 使用动量超参数和学习率的其他组合,观察和分析不同的实验结果。
  2. 试试梯度下降和动量法来解决一个二次问题,其中有多个特征值,即,例如。绘制出的值在初始化时如何下降。
  3. 推导的最小值和最小化器。
  4. 当我们执行带动量法的随机梯度下降时会有什么变化?当我们使用带动量法的小批量随机梯度下降时会发生什么?试验参数如何?

AdaGrad算法


:label:sec_adagrad

我们从有关特征学习中并不常见的问题入手。

稀疏特征和学习率

假设我们正在训练一个语言模型。
为了获得良好的准确性,我们大多希望在训练的过程中降低学习率,速度通常为或更低。
现在讨论关于稀疏特征(即只在偶尔出现的特征)的模型训练,这对自然语言来说很常见。
例如,我们看到“预先条件”这个词比“学习”这个词的可能性要小得多。
但是,它在计算广告学和个性化协同过滤等其他领域也很常见。

只有在这些不常见的特征出现时,与其相关的参数才会得到有意义的更新。
鉴于学习率下降,我们可能最终会面临这样的情况:常见特征的参数相当迅速地收敛到最佳值,而对于不常见的特征,我们仍缺乏足够的观测以确定其最佳值。
换句话说,学习率要么对于常见特征而言降低太慢,要么对于不常见特征而言降低太快。

解决此问题的一个方法是记录我们看到特定特征的次数,然后将其用作调整学习率。
即我们可以使用大小为的学习率,而不是
在这里计下了我们截至时观察到功能的次数。
这其实很容易实施且不产生额外损耗。

AdaGrad算法 :cite:Duchi.Hazan.Singer.2011通过将粗略的计数器替换为先前观察所得梯度的平方之和来解决这个问题。
它使用来调整学习率。
这有两个好处:首先,我们不再需要决定梯度何时算足够大。
其次,它会随梯度的大小自动变化。通常对应于较大梯度的坐标会显著缩小,而其他梯度较小的坐标则会得到更平滑的处理。
在实际应用中,它促成了计算广告学及其相关问题中非常有效的优化程序。
但是,它遮盖了AdaGrad固有的一些额外优势,这些优势在预处理环境中很容易被理解。

预处理

凸优化问题有助于分析算法的特点。
毕竟对大多数非凸问题来说,获得有意义的理论保证很难,但是直觉和洞察往往会延续。
让我们来看看最小化这一问题。

正如在 :numref:sec_momentum中那样,我们可以根据其特征分解重写这个问题,来得到一个简化得多的问题,使每个坐标都可以单独解出:

在这里我们使用了,且因此
修改后优化器为且最小值为
这样更容易计算,因为是一个包含特征值的对角矩阵。

如果稍微扰动,我们会期望在的最小化器中只产生微小的变化。
遗憾的是,情况并非如此。
虽然的微小变化导致了同样的微小变化,但的(以及的)最小化器并非如此。
每当特征值很大时,我们只会看到的最小值发声微小变化。
相反,对小的来说,的变化可能是剧烈的。
最大和最小的特征值之比称为优化问题的条件数(condition number)。

如果条件编号很大,准确解决优化问题就会很难。
我们需要确保在获取大量动态的特征值范围时足够谨慎:难道我们不能简单地通过扭曲空间来“修复”这个问题,从而使所有特征值都是
理论上这很容易:我们只需要的特征值和特征向量即可将问题从整理到中的一个。
在新的坐标系中,可以被简化为
可惜,这是一个相当不切实际的想法。
一般而言,计算特征值和特征向量要比解决实际问题“贵”得多。

虽然准确计算特征值可能会很昂贵,但即便只是大致猜测并计算它们,也可能已经比不做任何事情好得多。
特别是,我们可以使用的对角线条目并相应地重新缩放它。
这比计算特征值开销小的多。

在这种情况下,我们得到了$\tilde{\mathbf{Q}}{ij} = \mathbf{Q}{ij} / \sqrt{\mathbf{Q}{ii} \mathbf{Q}{jj}}i\tilde{\mathbf{Q}}_{ii} = 1$。
在大多数情况下,这大大简化了条件数。
例如我们之前讨论的案例,它将完全消除眼下的问题,因为问题是轴对齐的。

遗憾的是,我们还面临另一个问题:在深度学习中,我们通常情况甚至无法计算目标函数的二阶导数:对于,即使只在小批量上,二阶导数可能也需要空间来计算,导致几乎不可行。
AdaGrad算法巧妙的思路是,使用一个代理来表示黑塞矩阵(Hessian)的对角线,既相对易于计算又高效。

为了了解它是如何生效的,让我们来看看
我们有

其中的优化器。
因此,梯度的大小取决于和与最佳值的差值。
如果$\bar{\mathbf{x}} - \bar{\mathbf{x}}0\partial{\bar{\mathbf{x}}} \bar{f}(\bar{\mathbf{x}})$的大小就足够了。
由于AdaGrad算法是一种随机梯度下降算法,所以即使是在最佳值中,我们也会看到具有非零方差的梯度。
因此,我们可以放心地使用梯度的方差作为黑塞矩阵比例的廉价替代。
详尽的分析(要花几页解释)超出了本节的范围,请读者参考 :cite:Duchi.Hazan.Singer.2011

算法

让我们接着上面正式开始讨论。
我们使用变量来累加过去的梯度方差,如下所示:

$$
\begin{aligned}
\mathbf{g}t & = \partial{\mathbf{w}} l(y_t, f(\mathbf{x}_t, \mathbf{w})), \
\mathbf{s}t & = \mathbf{s}{t-1} + \mathbf{g}_t^2, \
\mathbf{w}t & = \mathbf{w}{t-1} - \frac{\eta}{\sqrt{\mathbf{s}_t + \epsilon}} \cdot \mathbf{g}_t.
\end{aligned}
$$

在这里,操作是按照坐标顺序应用。
也就是说,有条目
同样,有条目
并且有条目
与之前一样,是学习率,是一个为维持数值稳定性而添加的常数,用来确保我们不会除以
最后,我们初始化

就像在动量法中我们需要跟踪一个辅助变量一样,在AdaGrad算法中,我们允许每个坐标有单独的学习率。
与SGD算法相比,这并没有明显增加AdaGrad的计算代价,因为主要计算用在及其导数。

请注意,在中累加平方梯度意味着基本上以线性速率增长(由于梯度从最初开始衰减,实际上比线性慢一些)。
这产生了一个学习率,但是在单个坐标的层面上进行了调整。
对于凸问题,这完全足够了。
然而,在深度学习中,我们可能希望更慢地降低学习率。
这引出了许多AdaGrad算法的变体,我们将在后续章节中讨论它们。
眼下让我们先看看它在二次凸问题中的表现如何。
我们仍然以同一函数为例:

我们将使用与之前相同的学习率来实现AdaGrad算法,即
可以看到,自变量的迭代轨迹较平滑。
但由于的累加效果使学习率不断衰减,自变量在迭代后期的移动幅度较小。

1
2
3
4
%matplotlib inline  
import math
import torch
from d2l import torch as d2l
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def adagrad_2d(x1, x2, s1, s2):  
eps = 1e-6
g1, g2 = 0.2 * x1, 4 * x2
s1 += g1 ** 2
s2 += g2 ** 2
x1 -= eta / math.sqrt(s1 + eps) * g1
x2 -= eta / math.sqrt(s2 + eps) * g2
return x1, x2, s1, s2

def f_2d(x1, x2):
return 0.1 * x1 ** 2 + 2 * x2 ** 2

eta = 0.4
d2l.show_trace_2d(f_2d, d2l.train_2d(adagrad_2d))
1
epoch 20, x1: -2.382563, x2: -0.158591

300

1
2
eta = 2  
d2l.show_trace_2d(f_2d, d2l.train_2d(adagrad_2d))
1
2
3
epoch 20, x1: -0.002295, x2: -0.000000

<Figure size 350x250 with 1 Axes>

300

从零开始实现

同动量法一样,AdaGrad算法需要对每个自变量维护同它一样形状的状态变量。

1
2
3
4
5
6
7
8
9
10
11
12
def init_adagrad_states(feature_dim):  
s_w = torch.zeros((feature_dim, 1))
s_b = torch.zeros(1)
return (s_w, s_b)

def adagrad(params, states, hyperparams):
eps = 1e-6
for p, s in zip(params, states):
with torch.no_grad():
s[:] += torch.square(p.grad)
p[:] -= hyperparams['lr'] * p.grad / torch.sqrt(s + eps)
p.grad.data.zero_()

与 :numref:sec_minibatch_sgd一节中的实验相比,这里使用更大的学习率来训练模型。

1
2
3
data_iter, feature_dim = d2l.get_data_ch11(batch_size=10)  
d2l.train_ch11(adagrad, init_adagrad_states(feature_dim),
{'lr': 0.1}, data_iter, feature_dim);

300

简洁实现

我们可直接使用深度学习框架中提供的AdaGrad算法来训练模型。

1
2
trainer = torch.optim.Adagrad  
d2l.train_concise_ch11(trainer, {'lr': 0.1}, data_iter)

300

小结

  • AdaGrad算法会在单个坐标层面动态降低学习率。
  • AdaGrad算法利用梯度的大小作为调整进度速率的手段:用较小的学习率来补偿带有较大梯度的坐标。
  • 在深度学习问题中,由于内存和计算限制,计算准确的二阶导数通常是不可行的。梯度可以作为一个有效的代理。
  • 如果优化问题的结构相当不均匀,AdaGrad算法可以帮助缓解扭曲。
  • AdaGrad算法对于稀疏特征特别有效,在此情况下由于不常出现的问题,学习率需要更慢地降低。
  • 在深度学习问题上,AdaGrad算法有时在降低学习率方面可能过于剧烈。我们将在 :numref:sec_adam一节讨论缓解这种情况的策略。

练习

  1. 证明对于正交矩阵和向量,以下等式成立:。为什么这意味着在变量的正交变化之后,扰动的程度不会改变?
  2. 尝试对函数、以及它旋转45度后的函数即使用AdaGrad算法。它的表现会不同吗?
  3. 证明格什戈林圆盘定理,其中提到,矩阵的特征值在至少一个的选项中满足$|\lambda_i - \mathbf{M}{jj}| \leq \sum{k \neq j} |\mathbf{M}_{jk}|$的要求。
  4. 关于对角线预处理矩阵的特征值,格什戈林的定理告诉了我们什么?
  5. 尝试对适当的深度网络使用AdaGrad算法,例如,:numref:sec_lenet中应用于Fashion-MNIST的深度网络。
  6. 要如何修改AdaGrad算法,才能使其在学习率方面的衰减不那么激进?

RMSProp算法


:label:sec_rmsprop

:numref:sec_adagrad中的关键问题之一,是学习率按预定时间表显著降低。
虽然这通常适用于凸问题,但对于深度学习中遇到的非凸问题,可能并不理想。
但是,作为一个预处理器,Adagrad算法按坐标顺序的适应性是非常可取的。

:cite:Tieleman.Hinton.2012建议以RMSProp算法作为将速率调度与坐标自适应学习率分离的简单修复方法。
问题在于,Adagrad算法将梯度的平方累加成状态矢量$\mathbf{s}t = \mathbf{s}{t-1} + \mathbf{g}_t^2\mathbf{s}_t$持续增长,几乎上是在算法收敛时呈线性递增。

解决此问题的一种方法是使用
的合理分布来说,它将收敛。
遗憾的是,限制行为生效可能需要很长时间,因为该流程记住了值的完整轨迹。
另一种方法是按动量法中的方式使用泄漏平均值,即$\mathbf{s}t \leftarrow \gamma \mathbf{s}{t-1} + (1-\gamma) \mathbf{g}_t^2\gamma > 0$。
保持所有其它部分不变就产生了RMSProp算法。

算法

让我们详细写出这些方程式。

$$\begin{aligned}
\mathbf{s}t & \leftarrow \gamma \mathbf{s}{t-1} + (1 - \gamma) \mathbf{g}_t^2, \
\mathbf{x}t & \leftarrow \mathbf{x}{t-1} - \frac{\eta}{\sqrt{\mathbf{s}_t + \epsilon}} \odot \mathbf{g}_t.
\end{aligned}$$

常数通常设置为,以确保我们不会因除以零或步长过大而受到影响。
鉴于这种扩展,我们现在可以自由控制学习率,而不考虑基于每个坐标应用的缩放。
就泄漏平均值而言,我们可以采用与之前在动量法中适用的相同推理。
扩展定义可获得

$$
\begin{aligned}
\mathbf{s}t & = (1 - \gamma) \mathbf{g}t^2 + \gamma \mathbf{s}{t-1} \
& = (1 - \gamma) \left(\mathbf{g}t^2 + \gamma \mathbf{g}{t-1}^2 + \gamma^2 \mathbf{g}
{t-2} + \ldots, \right).
\end{aligned}
$$

同之前在 :numref:sec_momentum小节一样,我们使用
因此,权重总和标准化为且观测值的半衰期为
让我们图像化各种数值的在过去40个时间步长的权重。

1
2
3
import math  
import torch
from d2l import torch as d2l
1
2
3
4
5
6
d2l.set_figsize()  
gammas = [0.95, 0.9, 0.8, 0.7]
for gamma in gammas:
x = torch.arange(40).detach().numpy()
d2l.plt.plot(x, (1-gamma) * gamma ** x, label=f'gamma = {gamma:.2f}')
d2l.plt.xlabel('time');

300

从零开始实现

和之前一样,我们使用二次函数来观察RMSProp算法的轨迹。
回想在 :numref:sec_adagrad一节中,当我们使用学习率为0.4的Adagrad算法时,变量在算法的后期阶段移动非常缓慢,因为学习率衰减太快。
RMSProp算法中不会发生这种情况,因为是单独控制的。

1
2
3
4
5
6
7
8
9
10
11
12
13
def rmsprop_2d(x1, x2, s1, s2):  
g1, g2, eps = 0.2 * x1, 4 * x2, 1e-6
s1 = gamma * s1 + (1 - gamma) * g1 ** 2
s2 = gamma * s2 + (1 - gamma) * g2 ** 2
x1 -= eta / math.sqrt(s1 + eps) * g1
x2 -= eta / math.sqrt(s2 + eps) * g2
return x1, x2, s1, s2

def f_2d(x1, x2):
return 0.1 * x1 ** 2 + 2 * x2 ** 2

eta, gamma = 0.4, 0.9
d2l.show_trace_2d(f_2d, d2l.train_2d(rmsprop_2d))
1
epoch 20, x1: -0.010599, x2: 0.000000

300

1
2
3
4
def init_rmsprop_states(feature_dim):  
s_w = torch.zeros((feature_dim, 1))
s_b = torch.zeros(1)
return (s_w, s_b)
1
2
3
4
5
6
7
def rmsprop(params, states, hyperparams):  
gamma, eps = hyperparams['gamma'], 1e-6
for p, s in zip(params, states):
with torch.no_grad():
s[:] = gamma * s + (1 - gamma) * torch.square(p.grad)
p[:] -= hyperparams['lr'] * p.grad / torch.sqrt(s + eps)
p.grad.data.zero_()

我们将初始学习率设置为0.01,加权项设置为0.9。
也就是说,累加了过去的次平方梯度观测值的平均值。

1
2
3
data_iter, feature_dim = d2l.get_data_ch11(batch_size=10)  
d2l.train_ch11(rmsprop, init_rmsprop_states(feature_dim),
{'lr': 0.01, 'gamma': 0.9}, data_iter, feature_dim);

300

简洁实现

我们可直接使用深度学习框架中提供的RMSProp算法来训练模型。

1
2
3
trainer = torch.optim.RMSprop  
d2l.train_concise_ch11(trainer, {'lr': 0.01, 'alpha': 0.9},
data_iter)

300

小结

  • RMSProp算法与Adagrad算法非常相似,因为两者都使用梯度的平方来缩放系数。
  • RMSProp算法与动量法都使用泄漏平均值。但是,RMSProp算法使用该技术来调整按系数顺序的预处理器。
  • 在实验中,学习率需要由实验者调度。
  • 系数决定了在调整每坐标比例时历史记录的时长。

练习

  1. 如果我们设置,实验会发生什么?为什么?
  2. 旋转优化问题以最小化。收敛会发生什么?
  3. 试试在真正的机器学习问题上应用RMSProp算法会发生什么,例如在Fashion-MNIST上的训练。试验不同的取值来调整学习率。
  4. 随着优化的进展,需要调整吗?RMSProp算法对此有多敏感?

Adadelta


:label:sec_adadelta

Adadelta是AdaGrad的另一种变体( :numref:sec_adagrad),
主要区别在于前者减少了学习率适应坐标的数量。
此外,广义上Adadelta被称为没有学习率,因为它使用变化量本身作为未来变化的校准。
Adadelta算法是在 :cite:Zeiler.2012中提出的。

Adadelta算法

简而言之,Adadelta使用两个状态变量,用于存储梯度二阶导数的泄露平均值,用于存储模型本身中参数变化二阶导数的泄露平均值。请注意,为了与其他出版物和实现的兼容性,我们使用作者的原始符号和命名(没有其它真正理由让大家使用不同的希腊变量来表示在动量法、AdaGrad、RMSProp和Adadelta中用于相同用途的参数)。

以下是Adadelta的技术细节。鉴于参数du jour是,我们获得了与 :numref:sec_rmsprop类似的以下泄漏更新:

$$\begin{aligned}
\mathbf{s}t & = \rho \mathbf{s}{t-1} + (1 - \rho) \mathbf{g}_t^2.
\end{aligned}$$

与 :numref:sec_rmsprop的区别在于,我们使用重新缩放的梯度执行更新,即

$$\begin{aligned}
\mathbf{x}t & = \mathbf{x}{t-1} - \mathbf{g}_t’. \
\end{aligned}$$

那么,调整后的梯度是什么?我们可以按如下方式计算它:

$$\begin{aligned}
\mathbf{g}t’ & = \frac{\sqrt{\Delta\mathbf{x}{t-1} + \epsilon}}{\sqrt{\mathbf{s}_t + \epsilon}} \odot \mathbf{g}_t, \
\end{aligned}$$

其中是重新缩放梯度的平方$\mathbf{g}t’\Delta \mathbf{x}{0}0使\mathbf{g}_t’$更新它,即

$$\begin{aligned}
\Delta \mathbf{x}t & = \rho \Delta\mathbf{x}{t-1} + (1 - \rho) {\mathbf{g}_t’}^2,
\end{aligned}$$

(例如这样的小值)是为了保持数字稳定性而加入的。

代码实现

Adadelta需要为每个变量维护两个状态变量,即。这将产生以下实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
%matplotlib inline  
import torch
from d2l import torch as d2l


def init_adadelta_states(feature_dim):
s_w, s_b = torch.zeros((feature_dim, 1)), torch.zeros(1)
delta_w, delta_b = torch.zeros((feature_dim, 1)), torch.zeros(1)
return ((s_w, delta_w), (s_b, delta_b))

def adadelta(params, states, hyperparams):
rho, eps = hyperparams['rho'], 1e-5
for p, (s, delta) in zip(params, states):
with torch.no_grad():
# In-placeupdatesvia[:]
s[:] = rho * s + (1 - rho) * torch.square(p.grad)
g = (torch.sqrt(delta + eps) / torch.sqrt(s + eps)) * p.grad
p[:] -= g
delta[:] = rho * delta + (1 - rho) * g * g
p.grad.data.zero_()

对于每次参数更新,选择相当于10个半衰期。由此我们得到:

1
2
3
data_iter, feature_dim = d2l.get_data_ch11(batch_size=10)  
d2l.train_ch11(adadelta, init_adadelta_states(feature_dim),
{'rho': 0.9}, data_iter, feature_dim);

300

为了简洁实现,我们只需使用高级API中的Adadelta算法。

1
2
trainer = torch.optim.Adadelta  
d2l.train_concise_ch11(trainer, {'rho': 0.9}, data_iter)

300

小结

  • Adadelta没有学习率参数。相反,它使用参数本身的变化率来调整学习率。
  • Adadelta需要两个状态变量来存储梯度的二阶导数和参数的变化。
  • Adadelta使用泄漏的平均值来保持对适当统计数据的运行估计。

练习

  1. 调整的值,会发生什么?
  2. 展示如何在不使用的情况下实现算法。为什么这是个好主意?
  3. Adadelta真的是学习率为0吗?能找到Adadelta无法解决的优化问题吗?
  4. 将Adadelta的收敛行为与AdaGrad和RMSProp进行比较。

Adam算法


:label:sec_adam

本章我们已经学习了许多有效优化的技术。
在本节讨论之前,我们先详细回顾一下这些技术:

  • 在 :numref:sec_sgd中,我们学习了:随机梯度下降在解决优化问题时比梯度下降更有效。
  • 在 :numref:sec_minibatch_sgd中,我们学习了:在一个小批量中使用更大的观测值集,可以通过向量化提供额外效率。这是高效的多机、多GPU和整体并行处理的关键。
  • 在 :numref:sec_momentum中我们添加了一种机制,用于汇总过去梯度的历史以加速收敛。
  • 在 :numref:sec_adagrad中,我们通过对每个坐标缩放来实现高效计算的预处理器。
  • 在 :numref:sec_rmsprop中,我们通过学习率的调整来分离每个坐标的缩放。

Adam算法 :cite:Kingma.Ba.2014将所有这些技术汇总到一个高效的学习算法中。
不出预料,作为深度学习中使用的更强大和有效的优化算法之一,它非常受欢迎。
但是它并非没有问题,尤其是 :cite:Reddi.Kale.Kumar.2019表明,有时Adam算法可能由于方差控制不良而发散。
在完善工作中, :cite:Zaheer.Reddi.Sachan.ea.2018给Adam算法提供了一个称为Yogi的热补丁来解决这些问题。
下面我们了解一下Adam算法。

算法

Adam算法的关键组成部分之一是:它使用指数加权移动平均值来估算梯度的动量和二次矩,即它使用状态变量

$$\begin{aligned}
\mathbf{v}t & \leftarrow \beta_1 \mathbf{v}{t-1} + (1 - \beta_1) \mathbf{g}_t, \
\mathbf{s}t & \leftarrow \beta_2 \mathbf{s}{t-1} + (1 - \beta_2) \mathbf{g}_t^2.
\end{aligned}$$

这里是非负加权参数。
常将它们设置为
也就是说,方差估计的移动远远慢于动量估计的移动。
注意,如果我们初始化$\mathbf{v}_0 = \mathbf{s}0 = 0使\sum{i=0}^t \beta^i = \frac{1 - \beta^t}{1 - \beta}$来解决这个问题。
相应地,标准化状态变量由下式获得

有了正确的估计,我们现在可以写出更新方程。
首先,我们以非常类似于RMSProp算法的方式重新缩放梯度以获得

与RMSProp不同,我们的更新使用动量而不是梯度本身。
此外,由于使用而不是进行缩放,两者会略有差异。
前者在实践中效果略好一些,因此与RMSProp算法有所区分。
通常,我们选择,这是为了在数值稳定性和逼真度之间取得良好的平衡。

最后,我们简单更新:

$$\mathbf{x}t \leftarrow \mathbf{x}{t-1} - \mathbf{g}_t’.$$

回顾Adam算法,它的设计灵感很清楚:
首先,动量和规模在状态变量中清晰可见,
它们相当独特的定义使我们移除偏项(这可以通过稍微不同的初始化和更新条件来修正)。
其次,RMSProp算法中两项的组合都非常简单。
最后,明确的学习率使我们能够控制步长来解决收敛问题。

实现

从头开始实现Adam算法并不难。
为方便起见,我们将时间步存储在hyperparams字典中。
除此之外,一切都很简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
%matplotlib inline  
import torch
from d2l import torch as d2l


def init_adam_states(feature_dim):
v_w, v_b = torch.zeros((feature_dim, 1)), torch.zeros(1)
s_w, s_b = torch.zeros((feature_dim, 1)), torch.zeros(1)
return ((v_w, s_w), (v_b, s_b))

def adam(params, states, hyperparams):
beta1, beta2, eps = 0.9, 0.999, 1e-6
for p, (v, s) in zip(params, states):
with torch.no_grad():
v[:] = beta1 * v + (1 - beta1) * p.grad
s[:] = beta2 * s + (1 - beta2) * torch.square(p.grad)
v_bias_corr = v / (1 - beta1 ** hyperparams['t'])
s_bias_corr = s / (1 - beta2 ** hyperparams['t'])
p[:] -= hyperparams['lr'] * v_bias_corr / (torch.sqrt(s_bias_corr)
+ eps)
p.grad.data.zero_()
hyperparams['t'] += 1

现在,我们用以上Adam算法来训练模型,这里我们使用的学习率。

1
2
3
data_iter, feature_dim = d2l.get_data_ch11(batch_size=10)  
d2l.train_ch11(adam, init_adam_states(feature_dim),
{'lr': 0.01, 't': 1}, data_iter, feature_dim);

300

此外,我们可以用深度学习框架自带算法应用Adam算法,这里我们只需要传递配置参数。

1
2
trainer = torch.optim.Adam  
d2l.train_concise_ch11(trainer, {'lr': 0.01}, data_iter)

300

Yogi

Adam算法也存在一些问题:
即使在凸环境下,当的二次矩估计值爆炸时,它可能无法收敛。
:cite:Zaheer.Reddi.Sachan.ea.2018提出了的改进更新和参数初始化。
论文中建议我们重写Adam算法更新如下:

$$\mathbf{s}t \leftarrow \mathbf{s}{t-1} + (1 - \beta_2) \left(\mathbf{g}t^2 - \mathbf{s}{t-1}\right).$$

每当具有值很大的变量或更新很稀疏时,可能会太快地“忘记”过去的值。
一个有效的解决方法是将$\mathbf{g}t^2 - \mathbf{s}{t-1}\mathbf{g}_t^2 \odot \mathop{\mathrm{sgn}}(\mathbf{g}t^2 - \mathbf{s}{t-1})$。
这就是Yogi更新,现在更新的规模不再取决于偏差的量。

$$\mathbf{s}t \leftarrow \mathbf{s}{t-1} + (1 - \beta_2) \mathbf{g}_t^2 \odot \mathop{\mathrm{sgn}}(\mathbf{g}t^2 - \mathbf{s}{t-1}).$$

论文中,作者还进一步建议用更大的初始批量来初始化动量,而不仅仅是初始的逐点估计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def yogi(params, states, hyperparams):  
beta1, beta2, eps = 0.9, 0.999, 1e-3
for p, (v, s) in zip(params, states):
with torch.no_grad():
v[:] = beta1 * v + (1 - beta1) * p.grad
s[:] = s + (1 - beta2) * torch.sign(
torch.square(p.grad) - s) * torch.square(p.grad)
v_bias_corr = v / (1 - beta1 ** hyperparams['t'])
s_bias_corr = s / (1 - beta2 ** hyperparams['t'])
p[:] -= hyperparams['lr'] * v_bias_corr / (torch.sqrt(s_bias_corr)
+ eps)
p.grad.data.zero_()
hyperparams['t'] += 1

data_iter, feature_dim = d2l.get_data_ch11(batch_size=10)
d2l.train_ch11(yogi, init_adam_states(feature_dim),
{'lr': 0.01, 't': 1}, data_iter, feature_dim);

300

小结

  • Adam算法将许多优化算法的功能结合到了相当强大的更新规则中。
  • Adam算法在RMSProp算法基础上创建的,还在小批量的随机梯度上使用EWMA。
  • 在估计动量和二次矩时,Adam算法使用偏差校正来调整缓慢的启动速度。
  • 对于具有显著差异的梯度,我们可能会遇到收敛性问题。我们可以通过使用更大的小批量或者切换到改进的估计值来修正它们。Yogi提供了这样的替代方案。

练习

  1. 调节学习率,观察并分析实验结果。
  2. 试着重写动量和二次矩更新,从而使其不需要偏差校正。
  3. 收敛时为什么需要降低学习率
  4. 尝试构造一个使用Adam算法会发散而Yogi会收敛的例子。

学习率调度器


:label:sec_scheduler

到目前为止,我们主要关注如何更新权重向量的优化算法,而不是它们的更新速率。
然而,调整学习率通常与实际算法同样重要,有如下几方面需要考虑:

  • 首先,学习率的大小很重要。如果它太大,优化就会发散;如果它太小,训练就会需要过长时间,或者我们最终只能得到次优的结果。我们之前看到问题的条件数很重要(有关详细信息,请参见 :numref:sec_momentum)。直观地说,这是最不敏感与最敏感方向的变化量的比率。
  • 其次,衰减速率同样很重要。如果学习率持续过高,我们可能最终会在最小值附近弹跳,从而无法达到最优解。 :numref:sec_minibatch_sgd比较详细地讨论了这一点,在 :numref:sec_sgd中我们则分析了性能保证。简而言之,我们希望速率衰减,但要比慢,这样能成为解决凸问题的不错选择。
  • 另一个同样重要的方面是初始化。这既涉及参数最初的设置方式(详情请参阅 :numref:sec_numerical_stability),又关系到它们最初的演变方式。这被戏称为预热(warmup),即我们最初开始向着解决方案迈进的速度有多快。一开始的大步可能没有好处,特别是因为最初的参数集是随机的。最初的更新方向可能也是毫无意义的。
  • 最后,还有许多优化变体可以执行周期性学习率调整。这超出了本章的范围,我们建议读者阅读 :cite:Izmailov.Podoprikhin.Garipov.ea.2018来了解个中细节。例如,如何通过对整个路径参数求平均值来获得更好的解。

鉴于管理学习率需要很多细节,因此大多数深度学习框架都有自动应对这个问题的工具。
在本章中,我们将梳理不同的调度策略对准确性的影响,并展示如何通过学习率调度器(learning rate scheduler)来有效管理。

一个简单的问题

我们从一个简单的问题开始,这个问题可以轻松计算,但足以说明要义。
为此,我们选择了一个稍微现代化的LeNet版本(激活函数使用relu而不是sigmoid,汇聚层使用最大汇聚层而不是平均汇聚层),并应用于Fashion-MNIST数据集。
此外,我们混合网络以提高性能。
由于大多数代码都是标准的,我们只介绍基础知识,而不做进一步的详细讨论。如果需要,请参阅 :numref:chap_cnn进行复习。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
%matplotlib inline  
import math
import torch
from torch import nn
from torch.optim import lr_scheduler
from d2l import torch as d2l


def net_fn():
model = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120), nn.ReLU(),
nn.Linear(120, 84), nn.ReLU(),
nn.Linear(84, 10))

return model

loss = nn.CrossEntropyLoss()
device = d2l.try_gpu()

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)

# 代码几乎与d2l.train_ch6定义在卷积神经网络一章LeNet一节中的相同
def train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
scheduler=None):
net.to(device)
animator = d2l.Animator(xlabel='epoch', xlim=[0, num_epochs],
legend=['train loss', 'train acc', 'test acc'])

for epoch in range(num_epochs):
metric = d2l.Accumulator(3) # train_loss,train_acc,num_examples
for i, (X, y) in enumerate(train_iter):
net.train()
trainer.zero_grad()
X, y = X.to(device), y.to(device)
y_hat = net(X)
l = loss(y_hat, y)
l.backward()
trainer.step()
with torch.no_grad():
metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0])
train_loss = metric[0] / metric[2]
train_acc = metric[1] / metric[2]
if (i + 1) % 50 == 0:
animator.add(epoch + i / len(train_iter),
(train_loss, train_acc, None))

test_acc = d2l.evaluate_accuracy_gpu(net, test_iter)
animator.add(epoch+1, (None, None, test_acc))

if scheduler:
if scheduler.__module__ == lr_scheduler.__name__:
# UsingPyTorchIn-Builtscheduler
scheduler.step()
else:
# Usingcustomdefinedscheduler
for param_group in trainer.param_groups:
param_group['lr'] = scheduler(epoch)

print(f'train loss {train_loss:.3f}, train acc {train_acc:.3f}, '
f'test acc {test_acc:.3f}')

让我们来看看如果使用默认设置,调用此算法会发生什么。
例如设学习率为并训练次迭代。
留意在超过了某点、测试准确度方面的进展停滞时,训练准确度将如何继续提高。
两条曲线之间的间隙表示过拟合。

1
2
3
4
lr, num_epochs = 0.3, 30  
net = net_fn()
trainer = torch.optim.SGD(net.parameters(), lr=lr)
train(net, train_iter, test_iter, num_epochs, loss, trainer, device)

300

学习率调度器

我们可以在每个迭代轮数(甚至在每个小批量)之后向下调整学习率。
例如,以动态的方式来响应优化的进展情况。

1
2
3
lr = 0.1  
trainer.param_groups[0]["lr"] = lr
print(f'learning rate is now {trainer.param_groups[0]["lr"]:.2f}')
1
learning rate is now 0.10

更通常而言,我们应该定义一个调度器。
当调用更新次数时,它将返回学习率的适当值。
让我们定义一个简单的方法,将学习率设置为

1
2
3
4
5
6
class SquareRootScheduler:  
def __init__(self, lr=0.1):
self.lr = lr

def __call__(self, num_update):
return self.lr * pow(num_update + 1.0, -0.5)

让我们在一系列值上绘制它的行为。

1
2
scheduler = SquareRootScheduler(lr=0.1)  
d2l.plot(torch.arange(num_epochs), [scheduler(t) for t in range(num_epochs)])

300
现在让我们来看看这对在Fashion-MNIST数据集上的训练有何影响。
我们只是提供调度器作为训练算法的额外参数。

1
2
3
4
net = net_fn()  
trainer = torch.optim.SGD(net.parameters(), lr)
train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
scheduler)

300

这比以前好一些:曲线比以前更加平滑,并且过拟合更小了。
遗憾的是,关于为什么在理论上某些策略会导致较轻的过拟合,有一些观点认为,较小的步长将导致参数更接近零,因此更简单。
但是,这并不能完全解释这种现象,因为我们并没有真正地提前停止,而只是轻柔地降低了学习率。

策略

虽然我们不可能涵盖所有类型的学习率调度器,但我们会尝试在下面简要概述常用的策略:多项式衰减和分段常数表。
此外,余弦学习率调度在实践中的一些问题上运行效果很好。
在某些问题上,最好在使用较高的学习率之前预热优化器。

单因子调度器

多项式衰减的一种替代方案是乘法衰减,即其中
为了防止学习率衰减到一个合理的下界之下,
更新方程经常修改为

1
2
3
4
5
6
7
8
9
10
11
12
class FactorScheduler:  
def __init__(self, factor=1, stop_factor_lr=1e-7, base_lr=0.1):
self.factor = factor
self.stop_factor_lr = stop_factor_lr
self.base_lr = base_lr

def __call__(self, num_update):
self.base_lr = max(self.stop_factor_lr, self.base_lr * self.factor)
return self.base_lr

scheduler = FactorScheduler(factor=0.9, stop_factor_lr=1e-2, base_lr=2.0)
d2l.plot(torch.arange(50), [scheduler(t) for t in range(50)])

300

接下来,我们将使用内置的调度器,但在这里仅解释它们的功能。

多因子调度器

训练深度网络的常见策略之一是保持学习率为一组分段的常量,并且不时地按给定的参数对学习率做乘法衰减。
具体地说,给定一组降低学习率的时间点,例如
每当时,降低
假设每步中的值减半,我们可以按如下方式实现这一点。

1
2
3
4
5
6
7
8
9
10
11
12
net = net_fn()  
trainer = torch.optim.SGD(net.parameters(), lr=0.5)
scheduler = lr_scheduler.MultiStepLR(trainer, milestones=[15, 30], gamma=0.5)

def get_lr(trainer, scheduler):
lr = scheduler.get_last_lr()[0]
trainer.step()
scheduler.step()
return lr

d2l.plot(torch.arange(num_epochs), [get_lr(trainer, scheduler)
for t in range(num_epochs)])

300
这种分段恒定学习率调度背后的直觉是,让优化持续进行,直到权重向量的分布达到一个驻点。
此时,我们才将学习率降低,以获得更高质量的代理来达到一个良好的局部最小值。
下面的例子展示了如何使用这种方法产生更好的解决方案。

1
2
train(net, train_iter, test_iter, num_epochs, loss, trainer, device,  
scheduler)

300

余弦调度器

余弦调度器是 :cite:Loshchilov.Hutter.2016提出的一种启发式算法。
它所依据的观点是:我们可能不想在一开始就太大地降低学习率,而且可能希望最终能用非常小的学习率来“改进”解决方案。
这产生了一个类似于余弦的调度,函数形式如下所示,学习率的值在之间。

这里是初始学习率,是当时的目标学习率。
此外,对于,我们只需将值固定到而不再增加它。
在下面的示例中,我们设置了最大更新步数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class CosineScheduler:  
def __init__(self, max_update, base_lr=0.01, final_lr=0,
warmup_steps=0, warmup_begin_lr=0):
self.base_lr_orig = base_lr
self.max_update = max_update
self.final_lr = final_lr
self.warmup_steps = warmup_steps
self.warmup_begin_lr = warmup_begin_lr
self.max_steps = self.max_update - self.warmup_steps

def get_warmup_lr(self, epoch):
increase = (self.base_lr_orig - self.warmup_begin_lr) \
* float(epoch) / float(self.warmup_steps)
return self.warmup_begin_lr + increase

def __call__(self, epoch):
if epoch < self.warmup_steps:
return self.get_warmup_lr(epoch)
if epoch <= self.max_update:
self.base_lr = self.final_lr + (
self.base_lr_orig - self.final_lr) * (1 + math.cos(
math.pi * (epoch - self.warmup_steps) / self.max_steps)) / 2
return self.base_lr

scheduler = CosineScheduler(max_update=20, base_lr=0.3, final_lr=0.01)
d2l.plot(torch.arange(num_epochs), [scheduler(t) for t in range(num_epochs)])

300

在计算机视觉的背景下,这个调度方式可能产生改进的结果。
但请注意,如下所示,这种改进并不一定成立。

1
2
3
4
net = net_fn()  
trainer = torch.optim.SGD(net.parameters(), lr=0.3)
train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
scheduler)

300

预热

在某些情况下,初始化参数不足以得到良好的解。
这对某些高级网络设计来说尤其棘手,可能导致不稳定的优化结果。
对此,一方面,我们可以选择一个足够小的学习率,
从而防止一开始发散,然而这样进展太缓慢。
另一方面,较高的学习率最初就会导致发散。

解决这种困境的一个相当简单的解决方法是使用预热期,在此期间学习率将增加至初始最大值,然后冷却直到优化过程结束。
为了简单起见,通常使用线性递增。
这引出了如下表所示的时间表。

1
2
scheduler = CosineScheduler(20, warmup_steps=5, base_lr=0.3, final_lr=0.01)  
d2l.plot(torch.arange(num_epochs), [scheduler(t) for t in range(num_epochs)])

300
注意,观察前5个迭代轮数的性能,网络最初收敛得更好。

1
2
3
4
net = net_fn()  
trainer = torch.optim.SGD(net.parameters(), lr=0.3)
train(net, train_iter, test_iter, num_epochs, loss, trainer, device,
scheduler)

300
预热可以应用于任何调度器,而不仅仅是余弦。
有关学习率调度的更多实验和更详细讨论,请参阅 :cite:Gotmare.Keskar.Xiong.ea.2018
其中,这篇论文的点睛之笔的发现:预热阶段限制了非常深的网络中参数的发散程度 。
这在直觉上是有道理的:在网络中那些一开始花费最多时间取得进展的部分,随机初始化会产生巨大的发散。

小结

  • 在训练期间逐步降低学习率可以提高准确性,并且减少模型的过拟合。
  • 在实验中,每当进展趋于稳定时就降低学习率,这是很有效的。从本质上说,这可以确保我们有效地收敛到一个适当的解,也只有这样才能通过降低学习率来减小参数的固有方差。
  • 余弦调度器在某些计算机视觉问题中很受欢迎。
  • 优化之前的预热期可以防止发散。
  • 优化在深度学习中有多种用途。对于同样的训练误差而言,选择不同的优化算法和学习率调度,除了最大限度地减少训练时间,可以导致测试集上不同的泛化和过拟合量。

练习

  1. 试验给定固定学习率的优化行为。这种情况下可以获得的最佳模型是什么?
  2. 如果改变学习率下降的指数,收敛性会如何改变?在实验中方便起见,使用PolyScheduler
  3. 将余弦调度器应用于大型计算机视觉问题,例如训练ImageNet数据集。与其他调度器相比,它如何影响性能?
  4. 预热应该持续多长时间?
  5. 可以试着把优化和采样联系起来吗?首先,在随机梯度朗之万动力学上使用 :cite:Welling.Teh.2011的结果。