神经网络模型是通过梯度下降的优化算法来训练的。输入的训练数据帮助这些模型学习,而损失函数则衡量每次迭代的参数得到更新后的预测性能的准确性。随着训练的进行,目标是通过迭代调整参数来减少损失函数/预测误差。具体来说,梯度下降算法有一个前向步骤和一个后向步骤,这使它能够做到这一点。
- 在前向传播中,输入向量/数据通过网络向前移动,使用一个公式来计算下一层的每个神经元。该公式由输入/输出、激活函数f、权重W和偏置b组成。

这种计算一直向前迭代,直到达到一个输出或预测。然后,我们计算由损失函数定义的差异,例如,平均平方误差MSE,目标变量y(在输出层)和每个预测,y cap之间。

- 有了这个初始评估,我们通过一个后向通道(又称反向传播)来调整每一层中每个神经元的权重和偏置。为了更新我们的神经网络,我们首先计算梯度,这只不过是损失函数对权重和偏差的导数。然后,我们促使我们的算法采取梯度下降的步骤,使损失函数最小化(其中α是学习率)。

在这种情况下,可能会发生两种相反的情况:导数项变得极小,即接近于零;而这个项变得极大,并溢出。这些问题分别被称为 "消失 "和 "爆炸 "梯度。
当你训练你的模型一段时间后,性能似乎没有得到改善,你的模型很有可能受到消失梯度或爆炸梯度的困扰。
这篇文章就是针对这些问题的,具体来说,我们将涵盖。
- 消失和爆炸梯度问题背后的直觉
- 为什么会发生这些梯度问题
- 如何在模型训练过程中识别梯度问题
- 解决梯度消失和爆炸的案例演示和解决方案
- 消失的梯度
- ReLU作为激活函数
- 降低模型的复杂性
- 带方差的权重初始化器
- 更好的优化器,具有良好的学习速率
- 爆炸的梯度
- 梯度剪裁
- 适当的权重初始化器
- L2规范化
- 消失的梯度
消失或爆炸的梯度--问题背后的直觉
消失
在反向传播过程中, 权重更新公式中的(部分)导数/梯度 的计算遵循链式规则,其中前面各层的梯度是后面各层梯度的乘法。

其中

由于梯度经常变小,直到接近零,新的模型权重(初始层的)将与没有任何更新的旧权重几乎相同。因此,梯度下降算法永远不会收敛到最优解。这就是所谓的梯度消失问题,它是神经网络不稳定行为的一个例子。
爆炸
相反,如果随着反向传播的进行,梯度越来越大,甚至是NaN,我们就会出现爆炸性梯度,有大的权重更新,导致梯度下降算法的分歧。
为什么会发生梯度消失或爆炸的问题?
有了对什么是消失/爆炸梯度的直观理解,你一定想知道--为什么梯度首先会消失或爆炸,也就是说,为什么这些梯度值在回传网络的过程中会减弱或爆炸?
消失
简单地说,当我们在隐藏层中使用Sigmoid 或Tanh 激活函数时,就会出现梯度消失的问题;这些函数将一个大的输入空间压缩成一个小的空间。以Sigmoid为例,我们有以下p.d.f。

对参数x取导数,我们得到。

如果我们把Sigmoid函数和它的导数形象化。

Sigmoid函数及其导数 | 来源:中国社会科学网作者
我们可以看到,Sigmoid函数将我们的输入空间挤压到[0,1]之间,当输入变得相当小或相当大时,这个函数 饱和度 在0或1处。这些区域被称为 "饱和区域",其导数变得极其接近于零。这同样适用于Tanh函数,它 饱和 在-1和1处。
假设我们的输入位于任何一个饱和区域,我们基本上就没有梯度值可以传播回来,导致早期层权重的更新为零。通常,对于只有几层的浅层网络来说,这并不是什么大问题,然而,当我们增加更多的层时,初始层的梯度消失将导致模型训练或收敛失败。
这是由于在n层网络中,计算早期层的梯度时要乘以n个这些小数字的影响,这意味着梯度会随着n的增加而呈指数级下降,而早期层的训练速度非常慢,因此整个网络的性能会下降。
爆炸式
接着是爆炸性梯度,简而言之,这个问题是由于分配给神经网络的 初始权重 造成了巨大的损失。大的梯度值可以积累到观察到大的参数更新的地步,导致梯度下降震荡而不至于达到全局最小值。
更糟糕的是,这些参数可能大到溢出并返回无法再更新的NaN值。
如何识别梯度消失或爆炸的问题?
承认梯度问题是我们需要避免的,或者当它们发生时需要修复,那么我们应该如何知道一个模型正在遭受梯度消失或爆炸的问题?以下是一些迹象。
消失
- 后面几层的参数有很大的变化,而前面几层的参数变化很小或保持不变
- 在某些情况下,随着训练的进行,早期层的权重可能变成0。
- 模型的学习速度很慢,很多时候,训练在几次迭代后就停止了。
- 模型性能很差
爆发式
- 与消失的情况相反,爆炸梯度表现为不稳定的,从一批/迭代到另一批/迭代的大量参数变化。
- 模型的权重会很快变成NaN
- 模型损失也会变成NaN
解决消失或爆炸梯度问题的方法
考虑到梯度问题的这些指标,让我们来探讨解决这些问题的潜在补救措施。
- 首先,我们将专注于消失的情况:模拟一个受到这个问题困扰的二元分类网络模型,然后展示各种解决方案来修复它
- 同样的道理,我们稍后将用一个回归 网络模型来解决爆炸的情况
通过解决不同类型的深度学习任务,我的目标是展示不同的场景,供大家参考。还请注意,本文致力于为你提供实用的方法和技巧,所以我们只讨论每种方法背后的一些直觉,而跳过数学或理论上的证明。
由于观察是确定上述这些问题的关键部分,我们将使用Neptune.ai来跟踪我们的建模管道。
import neptune.new as neptune
import os
myProject = 'YourUserName/YourProjectName'
project = neptune.init(api_token=os.getenv('NEPTUNE_API_TOKEN'),
project=myProject)
project.stop()
梯度消失时的解决方案
首先,让我们在Neptune.ai中定义几个辅助函数来训练和记录我们的模型。
- 记录梯度和权重。
def getBatchGradWgts(grads, wgts, lossVal,
gradHist, lossHist, wgtsHist,
recordWeight=True, npt_exp=None):
dataGrad, dataWeight = {}, {}
## batch update 'weights'
for wgt, grad in zip(wgts, grads):
if '/kernel:' not in wgt.name:
continue
layerName = wgt.name.split("/")[0]
dataGrad[layerName] = grad.numpy()
dataWeight[layerName] = wgt.numpy()
## Log in Neptune
if npt_exp:
npt_exp[f'MeanGrads{layerName.upper()}'].log(np.mean(grad.numpy()))
npt_exp[f'MeanWgtBatch{layerName.upper()}'].log(np.mean(wgt.numpy()))
gradHist.append(dataGrad)
lossHist.append(lossVal.numpy())
if recordWeight:
wgtsHist.append(dataWeight)
- 训练模型并使用`tensorflow.GradientTape`来跟踪和计算梯度。
def fitModel(X, y, model, optimizer,
n_epochs=n_epochs, curBatch_size=batch_size, npt_exp=None):
lossFunc = tf.keras.losses.BinaryCrossentropy()
subData = tf.data.Dataset.from_tensor_slices((X, y))
subData = subData.shuffle(buffer_size=42).batch(curBatch_size)
gradHist, lossHist, wgtsHist = [], [], []
for epoch in range(n_epochs):
print(f'== Starting epoch {epoch} ==')
for step, (x_batch, y_batch) in enumerate(subData):
with tf.GradientTape() as tape:
## Predict with the model and calculate loss
yPred = model(x_batch, training=True)
lossVal = lossFunc(y_batch, yPred)
## Calculate gradients using tape and update the weights
grads = tape.gradient(lossVal, model.trainable_weights)
wgts = model.trainable_weights
optimizer.apply_gradients(zip(grads, model.trainable_weights))
## Save the Interaction#5 from each epoch
if step == 5:
getBatchGradWgts(gradHist=gradHist, lossHist=lossHist, wgtsHist=wgtsHist,
grads=grads, wgts=wgts, lossVal=lossVal, npt_exp=npt_exp)
if npt_exp:
npt_exp['BatchLoss'].log(lossVal)
getBatchGradWgts(gradHist=gradHist, lossHist=lossHist, wgtsHist=wgtsHist,
grads=grads, wgts=wgts, lossVal=lossVal, npt_exp=npt_exp)
return gradHist, lossHist, wgtsHist
- 将每一层的平均梯度可视化。
def gradientsVis(curGradHist, curLossHist, modelName):
fig, ax = plt.subplots(1, 1, sharex=True, constrained_layout=True, figsize=(7,5))
ax.set_title(f"Mean gradient {modelName}")
for layer in curGradHist[0]:
ax.plot(range(len(curGradHist)), [gradList[layer].mean() for gradList in curGradHist], label=f'Layer_{layer.upper()}')
ax.legend()
return fig
梯度消失的模型
现在,我们将模拟一个数据集并建立我们的基线二元 分类神经网络。
## Input data simulation
X, y = make_moons(n_samples=3000, shuffle=True , noise=0.25, random_state=1234)
batch_size, n_epochs = 32, 100
npt_exp = neptune.init(
api_token=os.getenv('NEPTUNE_API_TOKEN'),
project=myProject,
name='VanishingGradSigmoid',
description='Vanishing Gradients with Sigmoid Activation Function',
tags=['vanishingGradients', 'sigmoid', 'neptune'])
## Define Neptune callback
neptune_cbk = NeptuneCallback(run=npt_exp, base_namespace='metrics')
def binaryModel(curName, curInitializer, curActivation, x_tr=None):
model = Sequential()
model.add(InputLayer(input_shape=(2, ), name=curName+"0"))
model.add(Dense(10, kernel_initializer=curInitializer, activation=curActivation, name=curName+"1"))
model.add(Dense(10, kernel_initializer=curInitializer, activation=curActivation, name=curName+"2"))
model.add(Dense(5, kernel_initializer=curInitializer, activation=curActivation, name=curName+"3"))
model.add(Dense(1, kernel_initializer=curInitializer, activation='sigmoid', name=curName+"4"))
return model
curOptimizer = tf.keras.optimizers.RMSprop()
optimizer = curOptimizer
curInitializer = RandomUniform(-1, 1)
## Compile the model
model = binaryModel(curName="SIGMOID", curInitializer=curInitializer, curActivation="sigmoid")
model.compile(optimizer=curOptimizer, loss='binary_crossentropy', metrics=['accuracy'])
## Train and Log in Neptune
curGradHist, curLossHist, curWgtHist = fitModel(X, y, model, optimizer=curOptimizer, npt_exp=npt_exp)
## log in the plot comparing all layers
npt_exp['Comparing All Layers'].upload(neptune.types.File.as_image(gradientsVis(curGradHist, curLossHist, modelName='Sigmoid_Raw')))
npt_exp.stop()
有几个注意事项。
- 我们目前的vanilla/baseline模型由3个隐藏层组成,每个隐藏层都有一个sigmoid激活。
- 我们使用RMSprop作为优化器,使用Uniform [-1, 1] 作为权重初始化器。
运行这个模型会返回Neptune.ai中所有历时中每个层的(平均)梯度,下面是第1层和第4层之间的比较。

使用Neptune.ai生成的基线Sigmoid模型的第1层和第4层之间的比较 | 来源
对于第4层,我们看到随着训练的进行,平均梯度有明显的波动,然而对于第1层,梯度几乎为零,也就是说,数值大约小于0.006。消失的梯度发生了!现在我们来谈谈如何解决这个问题。
使用ReLU作为激活函数
如前所述,梯度消失问题是由于Sigmoid或Tanh函数的饱和性质造成的。因此,一个有效的补救措施是改用其他导数不饱和的激活函数,例如ReLU(整流线性单元)。

ReLU作为激活函数|来源
如该图所示,ReLU对于正的输入x不会饱和。当x<=0时,ReLU的导数/梯度=0,而当x>0时,导数/梯度=1。因此,将ReLU导数相乘,要么返回0,要么返回1;因此,不会出现梯度消失的情况。
为了实现ReLU激活,我们可以简单地在我们的模型函数中指定`relu`,如下图。
## Compile the model
model = binaryModel(curName="Relu", curInitializer=curInitializer, curActivation="relu")
model.compile(optimizer=curOptimizer, loss='binary_crossentropy', metrics=['accuracy'])
运行这个模型并比较从这个模型计算出来的梯度,我们观察到梯度在不同的历时中的变化,即使是第一层标记为RELU1。

使用Neptune.ai生成的ReLu模型的第1层和第4层的比较 。
在大多数情况下,类似ReLU的激活函数本身应该足以处理梯度消失的问题。然而,这是否意味着我们应该总是使用ReLU而完全抛弃Sigmoid?那么,梯度消失的事实不应该阻止你使用Sigmoid,它有许多理想的特性,如单调性和易微分性。即使使用Sigmoid激活函数,也有一些方法可以绕过这个问题,这些方法就是我们在下面的课程中要实验的。
降低模型的复杂性
由于梯度消失的根本原因在于一堆小梯度的相乘,从直觉上讲,通过减少梯度的数量来解决这个问题是合理的,也就是减少网络中的层数。例如,与其在我们的基线模型中指定3个隐藏层,我们可以只保留1个隐藏层,以使我们的模型更简单。
def binaryModel(curName, curInitializer, curActivation, x_tr=None):
model = Sequential()
model.add(InputLayer(input_shape=(2, ), name=curName+"0"))
model.add(Dense(3, kernel_initializer=curInitializer, activation=curActivation, name=curName+"3"))
model.add(Dense(1, kernel_initializer=curInitializer, activation='sigmoid', name=curName+"4"))
return model
这个模型给了我们清晰的梯度更新,显示在这个图中。
使用Neptune.ai生成的缩小的Sigmoid模型的第1层和第4层的比较。 来源
这种方法的一个注意事项是,我们的模型性能可能不如更复杂的模型(有更多隐藏层)。
使用带方差的权重初始化器
当我们的初始权重设置得太小或缺乏方差时,往往会导致梯度消失。回顾一下,在我们的基线模型中,我们将权重初始化为均匀的[-1, 1]分布,这可能会落入一个陷阱,即这些权重太小了
在2010年的论文中,Xavier Glorot和Yoshua Bengio提供了理论上的理由,即从一定方差的均匀分布或正态分布中抽出初始权重,并保持所有层的激活方差相同。
在 Keras/Tensorflow中,这种方法被实现为Glorot Normal `glorot_normal`和Glorot Uniform `glorot_uniform`,正如其名称所示,它们分别从(截断的)正态分布和均匀分布中采样初始权重。两者都考虑到了输入和输出单元的数量。
对于我们的模型,让我们用glorot_uniform来做实验,根据Keras的文档,它可以。

回到有3个隐藏层的原始模型,我们将模型权重初始化为glorot_uniform。
def binaryModel(curName, curInitializer, curActivation, x_tr=None):
model = Sequential()
model.add(InputLayer(input_shape=(2, ), name=curName+"0"))
model.add(Dense(10, kernel_initializer=curInitializer, activation=curActivation, name=curName+"1"))
model.add(Dense(10, kernel_initializer=curInitializer, activation=curActivation, name=curName+"2"))
model.add(Dense(5, kernel_initializer=curInitializer, activation=curActivation, name=curName+"3"))
model.add(Dense(1, kernel_initializer=curInitializer, activation='sigmoid', name=curName+"4"))
return model
curOptimizer = tf.keras.optimizers.RMSprop()
optimizer = curOptimizer
### Weight needs variance
curInitializer = 'glorot_uniform'
## log in the plot comparing all layers
npt_exp['Comparing All Layers'].upload(neptune.types.File.as_image(gradientsVis(curGradHist, curLossHist,
modelName='Sigmoid_NormalWeightInit')))
npt_exp.stop()
检查我们的Neptune.ai追踪器,我们看到梯度随着这个权重初始化而变化,尽管第1层(在左边)与最后一层相比显示出较少的波动。 neptune.ai/blog/vanish…
使用Neptune.ai生成的Glorot权重初始化模型的第1层和第4层的比较 | 来源
选择更好的优化器并调整学习率
现在,我们已经解决了导数和初始权重的选择问题,公式中的最后一块是学习率。随着梯度接近零,优化器会被困在次优的局部最小值或鞍点中。为了克服这一挑战,我们可以采用一个具有动量的优化器,将以前积累的梯度作为因素。例如, Adam有一个动量项,计算为过去梯度的指数衰减平均值。
此外,作为一个高效的优化器,Adam可以快速收敛或发散。因此,稍微降低学习率将有助于防止你的网络太容易发散,从而减少梯度接近零的可能性。要使用亚当优化器,我们需要修改的是`curOptimizer`参数。
curOptimizer = keras.optimizers.Adam(learning_rate=0.008) ## reduce the learning rate with Adam
curInitializer = RandomUniform(-1, 1)
## Compile the model
model = binaryModel(curName="SIGMOID", curInitializer=curInitializer, curActivation="sigmoid")
model.compile(optimizer=curOptimizer, loss='binary_crossentropy', metrics=['accuracy'])
在上面的代码中,我们指定Adam为模型优化器,同时指定了相对较小的学习率0.008,激活函数设置为sigmoid。下面是第1层和第4层梯度的比较。
使用Neptune.ai生成的亚当模型的第1层和第4层的比较 | 来源
正如我们所看到的,使用Adam和调整好的小的学习率,我们看到梯度的变化从零开始,我们的模型也会根据下面的损失图收敛到局部最小值。
选择更好的优化器并调整学习率 | 来源
到此为止,我们已经了解了梯度消失的解决方案,接下来让我们来看看梯度爆炸的问题。
梯度爆炸时的解决方案
对于梯度爆炸的问题,我们来看看这个回归模型。
# Generate regression dataset
nfeatures = 15
X, y = make_regression(n_samples=1500, n_features=nfeatures, noise=0.2, random_state=42)
# Define the regression model
def regressionModel(X, y, curInitializer, USE_L2REG, secondLayerAct='relu'):
## Construct the neural nets
inp = Input(shape = (X.shape[1],))
if USE_L2REG:
## need to change activation function as well
x = Dense(35, activation='tanh', kernel_initializer=curInitializer,
kernel_regularizer=regularizers.l2(0.01),
activity_regularizer=regularizers.l2(0.01))(inp)
else:
x = Dense(35, activation=secondLayerAct, kernel_initializer=curInitializer)(inp)
out = Dense(1, activation='linear')(x)
model = Model(inp, out)
return model
为了编译这个模型,我们将使用Uniform [4, 5] 权重初始化器和ReLu激活,目的是为了创造一个爆炸性梯度的情况。
sgd = tf.keras.optimizers.SGD()
curOptimizer = sgd
#### Uniform init
curInitializer = RandomUniform(4,5)
model = regressionModel(X, y, curInitializer, USE_L2REG=False)
model.compile(loss='mean_squared_error', optimizer=curOptimizer, metrics=['mse'])
curModelName = 'Relu_Raw'
## Train and Log in Neptune
curGradHist, curLossHist, curWgtHist = fitModel(X, y, model, optimizer=curOptimizer, modelType = 'regression', npt_exp=npt_exp)
npt_exp['Comparing All Layers'].upload(neptune.types.File.as_image(gradientsVis(curGradHist, curLossHist,
modelName=curModelName)))
npt_exp.stop()
有了这么大的权重初始化,随着训练的进行,以下错误信息出现在我们的Neptune.ai追踪器中就不足为奇了,正如之前讨论的,这清楚地表明我们的梯度爆炸了。

Neptune.ai中的错误信息 | 来源
梯度的剪裁
为了防止梯度爆炸,最有效的方法之一就是梯度剪裁。简而言之,梯度剪裁将导数限定在一个阈值,并使用限定的梯度来更新整个权重。如果你对这种方法的详细解释感兴趣,请参考这篇文章 "了解梯度剪裁(以及它如何解决爆炸性梯度问题)"。
梯度的上限可以通过`clipvalue`参数来指定,如下图所示。
### Gradients clipping
sgd = tf.keras.optimizers.SGD(clipvalue=50)
curOptimizer = sgd
curInitializer = 'glorot_normal'
model = regressionModel(X, y, curInitializer, USE_L2REG=False)
model.compile(loss='mean_squared_error', optimizer=curOptimizer, metrics=['mse'])
curModelName = 'GradClipping'
在运行这个模型时,我们可以将梯度保持在定义的范围内。
使用Neptune.ai生成的梯度剪裁模型的层梯度。 来源
正确的权重初始化器
如前所述,梯度爆炸的一个主要原因在于权重初始化和更新过大,这就是我们回归模型中梯度爆炸的原因。因此,正确初始化模型权重是解决梯度爆炸问题的关键。
与消失的梯度一样,我们将用正态分布实现Glorot初始化。
由于Glorot初始化在Tanh或Sigmoid下效果最好,我们将在本实验中指定Tanh作为激活函数。
curOptimizer = tf.keras.optimizers.SGD()
## Glorot init
curInitializer = 'glorot_normal'
## Tanh as the activation function
model = regressionModel(X, y, curInitializer, USE_L2REG=False, secondLayerAct='tanh')
model.compile(loss='mean_squared_error', optimizer=curOptimizer, metrics=['mse'])
curModelName = 'GlorotInit'
下面是这个模型的梯度图,它解决了梯度爆炸的问题。
使用Neptune.ai生成的Glorot初始化模型的层梯度。 来源
L2规范化
除了权重初始化,另一个很好的方法是采用L2规范化,它通过对损失函数施加模型权重的平方项来惩罚大权重值。

添加L2准则通常会使整个网络的权重更新更小,在Keras中实现这种正则化是相当直接的,参数为`kernal_regularizer`和`activity_regularizer`。
curInitializer = 'glorot_normal'
x = Dense(35, activation='tanh', kernel_initializer=curInitializer,
kernel_regularizer=regularizers.l2(0.01),
activity_regularizer=regularizers.l2(0.01))(inp)
### Using the regressionModel function
curInitializer = 'glorot_normal'
model = regressionModel(X, y, curInitializer, USE_L2REG=True)
model.compile(loss='mean_squared_error', optimizer=curOptimizer, metrics=['mse'])
curModelName = 'L2Reg'
我们将这个模型的初始权重设置为glorot normal,遵循2013年Razvan等人的建议,将参数初始化为小值和方差。这里显示了各层的损失曲线和梯度。
使用Neptune.ai生成的L2正则化模型的层梯度。 来源
同样,梯度在所有的历时中被控制在一个合理的范围内,并且模型逐渐收敛。
最后的话
除了我们在本文中讨论的主要技术外,其他值得尝试的避免/修复梯度的方法包括 批量归一化和 缩放输入数据.这两种方法都可以使你的网络更加健壮。直观的说,在backprop过程中,我们每一层的输入数据都会有巨大的变化(因为前一层的输出)。使用批处理归一化可以让我们固定每层输入数据的平均值和方差,从而防止其变化太大。有了一个更健壮的网络,就不容易遇到两个梯度的问题了。
在这篇文章中,我们讨论了与神经网络训练有关的两个主要问题--梯度消失和梯度爆炸问题。我们解释了它们的原因和后果。我们还介绍了解决这两个问题的各种方法。
希望你觉得这篇文章很有用,并学到了实用的技术,用于训练你自己的神经网络模型。为了供你参考,完整的代码可以在我的GitHub repo中找到,Neptune项目可以在这里找到。

