5.4. 数值稳定性和模型初始化
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

到目前为止,我们实现的每个模型都要求我们根据指定的分布来初始化其参数。到目前为止,我们理所当然地接受了初始化方案,忽略了如何做出这些选择的细节。你甚至可能会觉得这些选择并不特别重要。相反,初始化方案的选择在神经网络学习中起着重要的作用,它对保持数值稳定性至关重要。此外,这些选择可能与非线性激活函数的选择以有趣的方式联系在一起。我们选择哪个函数以及如何初始化参数可以决定优化算法收敛的速度。在这里,糟糕的选择可能会导致我们在训练中遇到梯度爆炸或消失。在本节中,我们将更详细地探讨这些主题,并讨论一些有用的启发式方法,您会发现在您的深度学习职业生涯中很有用。

%matplotlib inline
import torch
from d2l import torch as d2l
%matplotlib inline
from mxnet import autograd, np, npx
from d2l import mxnet as d2l

npx.set_np()
%matplotlib inline
import jax
from jax import grad
from jax import numpy as jnp
from jax import vmap
from d2l import jax as d2l
No GPU/TPU found, falling back to CPU. (Set TF_CPP_MIN_LOG_LEVEL=0 and rerun for more info.)
%matplotlib inline
import tensorflow as tf
from d2l import tensorflow as d2l

5.4.1. 梯度消失和梯度爆炸

考虑一个有\(L\)层的深度网络,输入\(\mathbf{x}\)和输出\(\mathbf{o}\)。每一层\(l\)由一个变换\(f_l\)定义,该变换由权重\(\mathbf{W}^{(l)}\)参数化,其隐藏层输出为\(\mathbf{h}^{(l)}\)(令\(\mathbf{h}^{(0)} = \mathbf{x}\)),我们的网络可以表示为:

(5.4.1)\[\mathbf{h}^{(l)} = f_l (\mathbf{h}^{(l-1)}) \textrm{ and thus } \mathbf{o} = f_L \circ \cdots \circ f_1(\mathbf{x}).\]

如果所有的隐藏层输出和输入都是向量,我们可以将\(\mathbf{o}\)关于任何一组参数\(\mathbf{W}^{(l)}\)的梯度写成如下形式:

(5.4.2)\[\partial_{\mathbf{W}^{(l)}} \mathbf{o} = \underbrace{\partial_{\mathbf{h}^{(L-1)}} \mathbf{h}^{(L)}}_{ \mathbf{M}^{(L)} \stackrel{\textrm{def}}{=}} \cdots \underbrace{\partial_{\mathbf{h}^{(l)}} \mathbf{h}^{(l+1)}}_{ \mathbf{M}^{(l+1)} \stackrel{\textrm{def}}{=}} \underbrace{\partial_{\mathbf{W}^{(l)}} \mathbf{h}^{(l)}}_{ \mathbf{v}^{(l)} \stackrel{\textrm{def}}{=}}.\]

换句话说,这个梯度是 \(L-l\) 个矩阵 \(\mathbf{M}^{(L)} \cdots \mathbf{M}^{(l+1)}\) 和梯度向量 \(\mathbf{v}^{(l)}\) 的乘积。因此,我们容易受到与将太多概率相乘时经常出现的数值下溢相同的问题的影响。在处理概率时,一个常见的技巧是切换到对数空间,即将压力从数值表示的尾数转移到指数。不幸的是,我们上面的问题更严重:最初矩阵 \(\mathbf{M}^{(l)}\) 可能有各种各样的特征值。它们可能很小或很大,它们的乘积可能*非常大*或*非常小*。

不稳定梯度带来的风险超出了数值表示的范围。不可预测大小的梯度也威胁到我们优化算法的稳定性。我们可能面临参数更新要么(i)过大,破坏我们的模型(*梯度爆炸*问题);要么(ii)过小(*梯度消失*问题),使得学习无法进行,因为参数在每次更新时几乎不动。

5.4.1.1. 梯度消失

导致梯度消失问题的一个常见原因是,在每层线性运算后附加的激活函数 \(\sigma\) 的选择。历史上,sigmoid函数 \(1/(1 + \exp(-x))\) (在5.1节中介绍) 很受欢迎,因为它类似于一个阈值函数。由于早期的人工神经网络受到生物神经网络的启发,神经元要么*完全*激发要么*根本不*激发的想法(就像生物神经元一样)似乎很有吸引力。让我们仔细看看sigmoid函数,看看为什么它会导致梯度消失。

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))
../_images/output_numerical-stability-and-init_e60514_18_0.svg
x = np.arange(-8.0, 8.0, 0.1)
x.attach_grad()
with autograd.record():
    y = npx.sigmoid(x)
y.backward()

d2l.plot(x, [y, x.grad], legend=['sigmoid', 'gradient'], figsize=(4.5, 2.5))
[21:56:14] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
[21:56:14] ../src/base.cc:48: GPU context requested, but no GPUs found.
../_images/output_numerical-stability-and-init_e60514_21_1.svg
x = jnp.arange(-8.0, 8.0, 0.1)
y = jax.nn.sigmoid(x)
grad_sigmoid = vmap(grad(jax.nn.sigmoid))
d2l.plot(x, [y, grad_sigmoid(x)],
         legend=['sigmoid', 'gradient'], figsize=(4.5, 2.5))
../_images/output_numerical-stability-and-init_e60514_24_0.svg
x = tf.Variable(tf.range(-8.0, 8.0, 0.1))
with tf.GradientTape() as t:
    y = tf.nn.sigmoid(x)
d2l.plot(x.numpy(), [y.numpy(), t.gradient(y, x).numpy()],
         legend=['sigmoid', 'gradient'], figsize=(4.5, 2.5))
../_images/output_numerical-stability-and-init_e60514_27_0.svg

如你所见,当输入很大或很小时,sigmoid函数的梯度都会消失。此外,当通过许多层进行反向传播时,除非我们处于“金发姑娘区”(Goldilocks zone),即许多sigmoid函数的输入接近于零,否则整个乘积的梯度可能会消失。当我们的网络有很多层时,除非我们小心,否则梯度很可能会在某个层被截断。实际上,这个问题曾经困扰着深度网络的训练。因此,更稳定(但神经学上不太合理)的ReLU已成为从业者的默认选择。

5.4.1.2. 梯度爆炸

相反的问题,当梯度爆炸时,也同样令人烦恼。为了更好地说明这一点,我们绘制100个高斯随机矩阵,并将它们与某个初始矩阵相乘。对于我们选择的尺度(方差的选择\(\sigma^2=1\)),矩阵乘积会爆炸。当这种情况由于深度网络的初始化而发生时,我们没有机会让梯度下降优化器收敛。

M = torch.normal(0, 1, size=(4, 4))
print('a single matrix \n',M)
for i in range(100):
    M = M @ torch.normal(0, 1, size=(4, 4))
print('after multiplying 100 matrices\n', M)
a single matrix
 tensor([[-0.8755, -1.2171,  1.3316,  0.1357],
        [ 0.4399,  1.4073, -1.9131, -0.4608],
        [-2.1420,  0.3643, -0.5267,  1.0277],
        [-0.1734, -0.7549,  2.3024,  1.3085]])
after multiplying 100 matrices
 tensor([[-2.9185e+23,  1.3915e+25, -1.1865e+25,  1.4354e+24],
        [ 4.9142e+23, -2.3430e+25,  1.9979e+25, -2.4169e+24],
        [ 2.6578e+23, -1.2672e+25,  1.0805e+25, -1.3072e+24],
        [-5.2223e+23,  2.4899e+25, -2.1231e+25,  2.5684e+24]])
M = np.random.normal(size=(4, 4))
print('a single matrix', M)
for i in range(100):
    M = np.dot(M, np.random.normal(size=(4, 4)))
print('after multiplying 100 matrices', M)
a single matrix [[ 2.2122064   1.1630787   0.7740038   0.4838046 ]
 [ 1.0434403   0.29956347  1.1839255   0.15302546]
 [ 1.8917114  -1.1688148  -1.2347414   1.5580711 ]
 [-1.771029   -0.5459446  -0.45138445 -2.3556297 ]]
after multiplying 100 matrices [[ 3.4459747e+23 -7.8040759e+23  5.9973355e+23  4.5230040e+23]
 [ 2.5275059e+23 -5.7240258e+23  4.3988419e+23  3.3174704e+23]
 [ 1.3731275e+24 -3.1097129e+24  2.3897754e+24  1.8022945e+24]
 [-4.4951091e+23  1.0180045e+24 -7.8232368e+23 -5.9000419e+23]]
get_key = lambda: jax.random.PRNGKey(d2l.get_seed())  # Generate PRNG keys
M = jax.random.normal(get_key(), (4, 4))
print('a single matrix \n', M)
for i in range(100):
    M = jnp.matmul(M, jax.random.normal(get_key(), (4, 4)))
print('after multiplying 100 matrices\n', M)
a single matrix
 [[-1.0048904   1.1341982   1.5850214   0.8235143 ]
 [-0.7436763   0.09992406 -0.6734362   0.7048596 ]
 [-0.9216905   0.19545755 -1.2625741  -1.1358675 ]
 [-0.20375538  0.92977744 -0.06995536  0.25450018]]
after multiplying 100 matrices
 [[-1.8952711e+23  3.4676785e+22 -2.3112275e+23 -2.6086595e+23]
 [-4.1839269e+22  7.6551432e+21 -5.1021794e+22 -5.7587781e+22]
 [ 1.9117462e+23 -3.4978364e+22  2.3313199e+23  2.6313371e+23]
 [-1.7659436e+23  3.2310639e+22 -2.1535172e+23 -2.4306532e+23]]
M = tf.random.normal((4, 4))
print('a single matrix \n', M)
for i in range(100):
    M = tf.matmul(M, tf.random.normal((4, 4)))
print('after multiplying 100 matrices\n', M.numpy())
a single matrix
 tf.Tensor(
[[ 0.26746088 -0.85279125  0.62144196  0.77845275]
 [-0.33319342 -0.3220635  -1.4750956  -0.7840103 ]
 [-0.97709286 -0.4522292   0.09627204 -0.7390586 ]
 [-0.02809991  0.8314656  -0.3524848  -0.88602906]], shape=(4, 4), dtype=float32)
after multiplying 100 matrices
 [[-1.5920840e+24  4.8595814e+24 -2.4045445e+24 -2.6546461e+23]
 [ 3.5805372e+23 -1.0929017e+24  5.4077304e+23  5.9701986e+22]
 [-4.9779040e+23  1.5194256e+24 -7.5181903e+23 -8.3001719e+22]
 [ 3.4783725e+24 -1.0617176e+25  5.2534298e+24  5.7998498e+23]]

5.4.1.3. 打破对称性

神经网络设计中的另一个问题是其参数化中固有的对称性。假设我们有一个带有一个隐藏层和两个单元的简单多层感知机。在这种情况下,我们可以置换第一层的权重\(\mathbf{W}^{(1)}\),并同样置换输出层的权重以获得相同的函数。没有什么特别的东西可以区分第一个和第二个隐藏单元。换句话说,我们在每一层的隐藏单元之间具有置换对称性。

这不仅仅是一个理论上的麻烦。考虑上述的带两个隐藏单元的单隐藏层多层感知机。为了说明,假设输出层将这两个隐藏单元转换为只有一个输出单元。想象一下,如果我们将隐藏层的所有参数初始化为\(\mathbf{W}^{(1)} = c\),其中\(c\)是某个常数,会发生什么。在这种情况下,在前向传播过程中,两个隐藏单元都接收相同的输入和参数,产生相同的激活,然后被馈送到输出单元。在反向传播过程中,对参数\(\mathbf{W}^{(1)}\)求导得到的梯度所有元素都取相同的值。因此,经过基于梯度的迭代(例如,小批量随机梯度下降)后,\(\mathbf{W}^{(1)}\)的所有元素仍然取相同的值。这样的迭代永远无法自行*打破对称性*,我们可能永远无法实现网络的表达能力。隐藏层的行为就好像它只有一个单元。请注意,虽然小批量随机梯度下降不会打破这种对称性,但dropout正则化(稍后介绍)会!

5.4.2. 参数初始化

解决——或至少缓解——上述问题的一种方法是仔细进行初始化。正如我们稍后将看到的,在优化过程中的额外关注和适当的正则化可以进一步增强稳定性。

5.4.2.1. 默认初始化

在前面的章节中,例如在3.5节中,我们使用正态分布来初始化权重的数值。如果我们不指定初始化方法,框架将使用默认的随机初始化方法,这对于中等规模的问题在实践中通常效果很好。

5.4.2.2. Xavier 初始化

让我们看一下某个全连接层*不带非线性*的输出\(o_{i}\)的尺度分布。对于该层的\(n_\textrm{in}\)个输入\(x_j\)及其相关的权重\(w_{ij}\),一个输出由下式给出

(5.4.3)\[o_{i} = \sum_{j=1}^{n_\textrm{in}} w_{ij} x_j.\]

权重\(w_{ij}\)都是从同一个分布中独立抽取的。此外,我们假设这个分布的均值为零,方差为\(\sigma^2\)。注意,这并不意味着分布必须是高斯分布,只是均值和方差需要存在。现在,我们假设层的输入\(x_j\)也具有零均值和方差\(\gamma^2\),并且它们与\(w_{ij}\)相互独立,也彼此独立。在这种情况下,我们可以计算\(o_i\)的均值

(5.4.4)\[\begin{split}\begin{aligned} E[o_i] & = \sum_{j=1}^{n_\textrm{in}} E[w_{ij} x_j] \\&= \sum_{j=1}^{n_\textrm{in}} E[w_{ij}] E[x_j] \\&= 0, \end{aligned}\end{split}\]

和方差

(5.4.5)\[\begin{split}\begin{aligned} \textrm{Var}[o_i] & = E[o_i^2] - (E[o_i])^2 \\ & = \sum_{j=1}^{n_\textrm{in}} E[w^2_{ij} x^2_j] - 0 \\ & = \sum_{j=1}^{n_\textrm{in}} E[w^2_{ij}] E[x^2_j] \\ & = n_\textrm{in} \sigma^2 \gamma^2. \end{aligned}\end{split}\]

保持方差固定的一种方法是设置\(n_\textrm{in} \sigma^2 = 1\)。现在考虑反向传播。在那里我们面临一个类似的问题,尽管梯度是从更靠近输出的层传播过来的。使用与前向传播相同的推理,我们看到梯度的方差可能会爆炸,除非\(n_\textrm{out} \sigma^2 = 1\),其中\(n_\textrm{out}\)是该层的输出数量。这让我们陷入了一个两难的境地:我们不可能同时满足这两个条件。相反,我们只是尝试满足

(5.4.6)\[\begin{aligned} \frac{1}{2} (n_\textrm{in} + n_\textrm{out}) \sigma^2 = 1 \textrm{ or equivalently } \sigma = \sqrt{\frac{2}{n_\textrm{in} + n_\textrm{out}}}. \end{aligned}\]

这是现在标准的且在实践中非常有益的*Xavier初始化*背后的原理,该方法以其创造者的第一作者(Glorot and Bengio, 2010)的名字命名。通常,Xavier初始化从一个均值为零,方差为\(\sigma^2 = \frac{2}{n_\textrm{in} + n_\textrm{out}}\)的高斯分布中采样权重。我们也可以调整这一点,以便在从均匀分布中采样权重时选择方差。请注意,均匀分布\(U(-a, a)\)的方差是\(\frac{a^2}{3}\)。将\(\frac{a^2}{3}\)代入我们对\(\sigma^2\)的条件中,促使我们根据以下方式进行初始化:

(5.4.7)\[U\left(-\sqrt{\frac{6}{n_\textrm{in} + n_\textrm{out}}}, \sqrt{\frac{6}{n_\textrm{in} + n_\textrm{out}}}\right).\]

虽然上述数学推理中关于神经网络中不存在非线性的假设很容易被违反,但Xavier初始化方法在实践中证明效果很好。

5.4.2.3. 超越

上述推理仅仅触及了现代参数初始化方法的皮毛。深度学习框架通常实现了超过十几种不同的启发式方法。此外,参数初始化仍然是深度学习基础研究的一个热门领域。其中包括专门用于绑定(共享)参数、超分辨率、序列模型和其他情况的启发式方法。例如,Xiao等人(2018)展示了通过使用精心设计的初始化方法,可以在没有架构技巧的情况下训练10000层的神经网络。

如果您对这个主题感兴趣,我们建议您深入了解本模块提供的内容,阅读提出并分析了每种启发式方法的论文,然后探索关于该主题的最新出版物。也许您会偶然发现甚至发明一个聪明的想法,并为深度学习框架贡献一个实现。

5.4.3. 小结

梯度消失和梯度爆炸是深度网络中的常见问题。在参数初始化时需要非常小心,以确保梯度和参数保持在良好控制的范围内。需要初始化启发式方法来确保初始梯度既不太大也不太小。随机初始化是确保在优化前打破对称性的关键。Xavier初始化建议,对于每一层,任何输出的方差不受输入数量的影响,任何梯度的方差不受输出数量的影响。ReLU激活函数可以缓解梯度消失问题。这可以加速收敛。

5.4.4. 练习

  1. 除了多层感知机层中的置换对称性外,您能设计出其他神经网络可能表现出需要打破的对称性的情况吗?

  2. 我们可以在线性回归或softmax回归中将所有权重参数初始化为相同的值吗?

  3. 查找两个矩阵乘积的特征值的解析界限。这对于确保梯度条件良好有什么启示?

  4. 如果我们知道某些项会发散,我们能否在事后修复它?可以从关于分层自适应速率缩放的论文中寻找灵感(You等人, 2017)