4.8. 数值稳定性和模型初始化¶ Open the notebook in SageMaker Studio Lab
到目前为止,我们实现的每个模型都是根据某个预先指定的分布来初始化模型的参数。 有人会认为初始化方案是理所当然的,忽略了如何做出这些选择的细节。甚至有人可能会觉得,初始化方案的选择并不是特别重要。 相反,初始化方案的选择在神经网络学习中起着举足轻重的作用, 它对保持数值稳定性至关重要。 此外,这些初始化方案的选择可以与非线性激活函数的选择有趣的结合在一起。 我们选择哪个函数以及如何初始化参数可以决定优化算法收敛的速度有多快。 糟糕选择可能会导致我们在训练时遇到梯度爆炸或梯度消失。 本节将更详细地探讨这些主题,并讨论一些有用的启发式方法。 这些启发式方法在整个深度学习生涯中都很有用。
4.8.1. 梯度消失和梯度爆炸¶
考虑一个具有\(L\)层、输入\(\mathbf{x}\)和输出\(\mathbf{o}\)的深层网络。 每一层\(l\)由变换\(f_l\)定义, 该变换的参数为权重\(\mathbf{W}^{(l)}\), 其隐藏变量是\(\mathbf{h}^{(l)}\)(令 \(\mathbf{h}^{(0)} = \mathbf{x}\))。 我们的网络可以表示为:
如果所有隐藏变量和输入都是向量, 我们可以将\(\mathbf{o}\)关于任何一组参数\(\mathbf{W}^{(l)}\)的梯度写为下式:
换言之,该梯度是\(L-l\)个矩阵 \(\mathbf{M}^{(L)} \cdot \ldots \cdot \mathbf{M}^{(l+1)}\) 与梯度向量 \(\mathbf{v}^{(l)}\)的乘积。 因此,我们容易受到数值下溢问题的影响. 当将太多的概率乘在一起时,这些问题经常会出现。 在处理概率时,一个常见的技巧是切换到对数空间, 即将数值表示的压力从尾数转移到指数。 不幸的是,上面的问题更为严重: 最初,矩阵 \(\mathbf{M}^{(l)}\) 可能具有各种各样的特征值。 他们可能很小,也可能很大; 他们的乘积可能非常大,也可能非常小。
不稳定梯度带来的风险不止在于数值表示; 不稳定梯度也威胁到我们优化算法的稳定性。 我们可能面临一些问题。 要么是梯度爆炸(gradient exploding)问题: 参数更新过大,破坏了模型的稳定收敛; 要么是梯度消失(gradient vanishing)问题: 参数更新过小,在每次更新时几乎不会移动,导致模型无法学习。
4.8.1.1. 梯度消失¶
曾经sigmoid函数\(1/(1 + \exp(-x))\)( 4.1节提到过)很流行, 因为它类似于阈值函数。 由于早期的人工神经网络受到生物神经网络的启发, 神经元要么完全激活要么完全不激活(就像生物神经元)的想法很有吸引力。 然而,它却是导致梯度消失问题的一个常见的原因, 让我们仔细看看sigmoid函数为什么会导致梯度消失。
%matplotlib inline
from mxnet import autograd, np, npx
from d2l import mxnet as d2l
npx.set_np()
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))
[07:03:21] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
[07:03:21] ../src/base.cc:48: GPU context requested, but no GPUs found.
%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))
%matplotlib inline
import tensorflow as tf
from d2l import tensorflow as d2l
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))
%matplotlib inline
import warnings
from d2l import paddle as d2l
warnings.filterwarnings("ignore")
import paddle
x = paddle.arange(start=-8.0, end=8.0, step=0.1, dtype='float32')
x.stop_gradient = False
y = paddle.nn.functional.sigmoid(x)
y.backward(paddle.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系列函数已经成为从业者的默认选择(虽然在神经科学的角度看起来不太合理)。
4.8.1.2. 梯度爆炸¶
相反,梯度爆炸可能同样令人烦恼。 为了更好地说明这一点,我们生成100个高斯随机矩阵,并将它们与某个初始矩阵相乘。 对于我们选择的尺度(方差\(\sigma^2=1\)),矩阵乘积发生爆炸。 当这种情况是由于深度网络的初始化所导致时,我们没有机会让梯度下降优化器收敛。
M = np.random.normal(size=(4, 4))
print('一个矩阵 \n', M)
for i in range(100):
M = np.dot(M, np.random.normal(size=(4, 4)))
print('乘以100个矩阵后\n', M)
一个矩阵
[[ 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 ]]
乘以100个矩阵后
[[ 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]]
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]])
M = tf.random.normal((4, 4))
print('一个矩阵 \n', M)
for i in range(100):
M = tf.matmul(M, tf.random.normal((4, 4)))
print('乘以100个矩阵后\n', M.numpy())
一个矩阵
tf.Tensor(
[[ 0.2560883 0.00797761 -1.0682204 -0.16417116]
[ 2.0060067 0.3481633 -0.74592876 1.2011837 ]
[ 0.21468055 0.09542773 -0.40310776 0.17152807]
[-1.077096 -0.8505044 -2.3125033 -1.2070065 ]], shape=(4, 4), dtype=float32)
乘以100个矩阵后
[[-4.5526118e+23 1.2209238e+23 -5.6382419e+22 1.4789259e+23]
[-2.1546440e+24 5.7783439e+23 -2.6684469e+23 6.9994081e+23]
[-4.0427488e+23 1.0841861e+23 -5.0067922e+22 1.3132947e+23]
[-1.7901018e+23 4.8007174e+22 -2.2169757e+22 5.8151884e+22]]
M = paddle.normal(0, 1, shape=(4,4))
print('一个矩阵 \n',M)
for i in range(100):
M = paddle.mm(M, paddle.normal(0, 1, shape=(4, 4)))
print('乘以100个矩阵后\n', M)
一个矩阵
Tensor(shape=[4, 4], dtype=float32, place=Place(cpu), stop_gradient=True,
[[ 0.09555759, 0.33683282, 1.20171154, 1.25189281],
[-0.18284278, -0.65445828, 0.14309669, 0.55920464],
[ 0.97072512, -0.47076491, -2.04063487, -0.57572478],
[ 1.14641619, 1.06490767, 0.76046497, -0.27298930]])
乘以100个矩阵后
Tensor(shape=[4, 4], dtype=float32, place=Place(cpu), stop_gradient=True,
[[-1939696468398394579091456., -2089295959956477146300416.,
2721163381582732258705408., -3558180070471660887605248.],
[-307949261019363894362112. , -331699912388626188599296. ,
432016261022814285332480. , -564902317902972638461952. ],
[ 2247077303087190278930432., 2420383743083751137083392.,
-3152382253269922833498112., 4122040255420859993292800.],
[ 169666774892908099141632. , 182752361912601774391296. ,
-238022355993941958983680. , 311236780660953299550208. ]])
4.8.1.3. 打破对称性¶
神经网络设计中的另一个问题是其参数化所固有的对称性。 假设我们有一个简单的多层感知机,它有一个隐藏层和两个隐藏单元。 在这种情况下,我们可以对第一层的权重\(\mathbf{W}^{(1)}\)进行重排列, 并且同样对输出层的权重进行重排列,可以获得相同的函数。 第一个隐藏单元与第二个隐藏单元没有什么特别的区别。 换句话说,我们在每一层的隐藏单元之间具有排列对称性。
假设输出层将上述两个隐藏单元的多层感知机转换为仅一个输出单元。 想象一下,如果我们将隐藏层的所有参数初始化为\(\mathbf{W}^{(1)} = c\), \(c\)为常量,会发生什么? 在这种情况下,在前向传播期间,两个隐藏单元采用相同的输入和参数, 产生相同的激活,该激活被送到输出单元。 在反向传播期间,根据参数\(\mathbf{W}^{(1)}\)对输出单元进行微分, 得到一个梯度,其元素都取相同的值。 因此,在基于梯度的迭代(例如,小批量随机梯度下降)之后, \(\mathbf{W}^{(1)}\)的所有元素仍然采用相同的值。 这样的迭代永远不会打破对称性,我们可能永远也无法实现网络的表达能力。 隐藏层的行为就好像只有一个单元。 请注意,虽然小批量随机梯度下降不会打破这种对称性,但暂退法正则化可以。
4.8.2. 参数初始化¶
解决(或至少减轻)上述问题的一种方法是进行参数初始化, 优化期间的注意和适当的正则化也可以进一步提高稳定性。
4.8.2.1. 默认初始化¶
在前面的部分中,例如在 3.3节中, 我们使用正态分布来初始化权重值。如果我们不指定初始化方法, 框架将使用默认的随机初始化方法,对于中等难度的问题,这种方法通常很有效。
4.8.2.2. Xavier初始化¶
让我们看看某些没有非线性的全连接层输出(例如,隐藏变量)\(o_{i}\)的尺度分布。 对于该层\(n_\mathrm{in}\)输入\(x_j\)及其相关权重\(w_{ij}\),输出由下式给出
权重\(w_{ij}\)都是从同一分布中独立抽取的。 此外,让我们假设该分布具有零均值和方差\(\sigma^2\)。 请注意,这并不意味着分布必须是高斯的,只是均值和方差需要存在。 现在,让我们假设层\(x_j\)的输入也具有零均值和方差\(\gamma^2\), 并且它们独立于\(w_{ij}\)并且彼此独立。 在这种情况下,我们可以按如下方式计算\(o_i\)的平均值和方差:
保持方差不变的一种方法是设置\(n_\mathrm{in} \sigma^2 = 1\)。 现在考虑反向传播过程,我们面临着类似的问题,尽管梯度是从更靠近输出的层传播的。 使用与前向传播相同的推断,我们可以看到,除非\(n_\mathrm{out} \sigma^2 = 1\), 否则梯度的方差可能会增大,其中\(n_\mathrm{out}\)是该层的输出的数量。 这使得我们进退两难:我们不可能同时满足这两个条件。 相反,我们只需满足:
这就是现在标准且实用的Xavier初始化的基础, 它以其提出者 (Glorot and Bengio, 2010) 第一作者的名字命名。 通常,Xavier初始化从均值为零,方差 \(\sigma^2 = \frac{2}{n_\mathrm{in} + n_\mathrm{out}}\) 的高斯分布中采样权重。 我们也可以将其改为选择从均匀分布中抽取权重时的方差。 注意均匀分布\(U(-a, a)\)的方差为\(\frac{a^2}{3}\)。 将\(\frac{a^2}{3}\)代入到\(\sigma^2\)的条件中,将得到初始化值域:
尽管在上述数学推理中,“不存在非线性”的假设在神经网络中很容易被违反, 但Xavier初始化方法在实践中被证明是有效的。
4.8.2.3. 额外阅读¶
上面的推理仅仅触及了现代参数初始化方法的皮毛。 深度学习框架通常实现十几种不同的启发式方法。 此外,参数初始化一直是深度学习基础研究的热点领域。 其中包括专门用于参数绑定(共享)、超分辨率、序列模型和其他情况的启发式算法。 例如,Xiao等人演示了通过使用精心设计的初始化方法 (Xiao et al., 2018), 可以无须架构上的技巧而训练10000层神经网络的可能性。
如果有读者对该主题感兴趣,我们建议深入研究本模块的内容, 阅读提出并分析每种启发式方法的论文,然后探索有关该主题的最新出版物。 也许会偶然发现甚至发明一个聪明的想法,并为深度学习框架提供一个实现。
4.8.3. 小结¶
梯度消失和梯度爆炸是深度网络中常见的问题。在参数初始化时需要非常小心,以确保梯度和参数可以得到很好的控制。
需要用启发式的初始化方法来确保初始梯度既不太大也不太小。
ReLU激活函数缓解了梯度消失问题,这样可以加速收敛。
随机初始化是保证在进行优化前打破对称性的关键。
Xavier初始化表明,对于每一层,输出的方差不受输入数量的影响,任何梯度的方差不受输出数量的影响。
4.8.4. 练习¶
除了多层感知机的排列对称性之外,还能设计出其他神经网络可能会表现出对称性且需要被打破的情况吗?
我们是否可以将线性回归或softmax回归中的所有权重参数初始化为相同的值?
在相关资料中查找两个矩阵乘积特征值的解析界。这对确保梯度条件合适有什么启示?
如果我们知道某些项是发散的,我们能在事后修正吗?看看关于按层自适应速率缩放的论文 (You et al., 2017) 。