[翻译] WILDML RNN系列教程 第三部分 BPTT算法和梯度扩散

翻译自WILDML博客文章: Recurrent Neural Networks Tutorial, Part 3 - Backpropagation Through Time and Vanishing Gradients

这份教程是比较通俗易懂的RNN教程,从基本知识到RNN的实现,再到GRU/LSTM等变种均有详述。但是原帖中由于渲染的问题,很多LaTeX的公式都显示不了。本文初衷是为了归档这个系列的教程,并解决公式显示的问题。而后索性将其译为中文,方便以后重新回顾。

笔者才疏学浅,翻译过程中难免有误,请见谅,亦烦请勘误。

文中部分术语会按照笔者所认为之惯用词语进行翻译,英文原词亦包括其中,以供参考。

在之前的教程中,我们从无到有实现了一个RNN,但是我们并没有相近阐述如何使用Backpropagation Through Time (BPTT)算法计算得到梯度。在这篇教程中,我们将会概览BPTT并且去探究其与传统的BP算法的区别。然后我们将会介绍梯度扩散(vanishing gradient)问题,这个问题导致了LSTMs和GRUs的产生。这两个模型是在NLP(和其他领域)最常用也是最有效的模型。梯度扩散问题最早被Seep Hochreiter在1991年发现,并且由于深度架构的普及而在最近备受关注。

想要深刻理解教程的这一部分,我建议你首先熟悉偏导数与传统的BP算法的基本原理。如果不熟悉,你可以在这里这里这里找到一些优秀的教程。

Backpropagation Through Time (BPTT)

让我们快速回忆一下RNN的基本公式。要注意这里有一个细微的修改,就是我将\(o\)修改成了\(\hat{y}\)。这么做只是为了与我引用的一些材料保持一致。

$$ s_t=\tanh(Ux_t+Ws_{t-1}) \\
\hat{y_t}=\mathrm{softmax}(Vs_t) $$

我们也用交叉熵定义了损失函数、或者说误差函数:

$$ E_t(y_t,\hat{y_t})=-y_t\log \hat{y_t}\\
E(y,\hat{y})=\sum_tE_t(y_t,\hat{y_t}) = -\sum_t y_t\log \hat{y_t}
$$

其中,\(y_t\)是指在时间步\(t\)的正确单词,而\(\hat{y_t}\)表示我们的预测结果。我们特意以整个序列(句子)作为一个训练样本,所以总误差就是将在每一个时间步(词)的误差相加。

展开的RNN

记住,我们的目标是计算\(U\),\(V\)和\(W\)之中每个参数所对应的梯度,然后利用随机梯度下降(Stochastic Gradient Descent)算法学习到一组合适的参数。就像我们将误差相加一样,对于每个训练样本,我们也将梯度在每个时间步也相加:\(\frac{\partial E}{\partial W}=\sum_t \frac{\partial E_t}{\partial W}\)。

为了计算这些梯度,我们使用导数的链式法则。BP算法就是这样从误差反向开始。在这篇文章剩下的部分,我们以\(E_3\)为例,并且用具体的数字来介绍。

$$ \frac{\partial E_3}{\partial V} = \frac{\partial E_3}{\partial \hat{y_3}}\frac{\hat{y_3}}{\partial V} = \frac{\partial E_3}{\partial \hat{y_3}}\frac{\partial \hat{y_3}}{\partial z_3}\frac{\partial z_3}{\partial V} = (\hat{y_3}-y_3) \otimes s_3 $$

在上面的公式中,\(z_3=Vs_3\),\(\otimes\)是两个向量的外积。如果没跟上上面的公式也别着急,因为我为了让你自己计算一下这些导数(值得练习!),跳过了一些步骤。值得一提的是,\(\frac{\partial E_3}{\partial V}\)值依赖于当前时间步,即\(\hat{y_3},y_3,s_3\)。如果你有了这些值,就可以用简单的矩阵乘法直接计算出\(V\)的梯度。

但是对于计算\(\frac{\partial E_3}{\partial W}\)(和\(U\))就有点儿复杂了。为什么?让我们像上面一样使用链式法则:

$$ \frac{\partial E_3}{\partial W}=\frac{\partial E_3}{\partial\hat{y_3}}\frac{\partial \hat{y_3}}{\partial s_3}\frac{\partial s_3}{\partial W} $$

需要注意的是,\(s_3=\tanh(Ux_t+Ws_2)\)依赖于\(s_2\),而\(s_2\)又依赖于\(W\)和\(s_1\),以此类推(译注:复合函数求导)。所以如果我们想要得到相对于\(W\)的导数,我们不能简单地认为\(s_2\)是一个常量!我们像这样应该应用链式法则: $$ \frac{\partial E_3}{\partial W}=\sum_{k=0}^3\frac{\partial E_3}{\partial\hat{y_3}}\frac{\partial\hat{y_3}}{\partial s_3}\frac{\partial s_3}{\partial s_k}\frac{\partial s_k}{\partial W} $$

我们把每一个时间步对梯度的贡献相加在一起。换句话说,因为\(W\)在直到输出的每一个时间步都被用到,而且我们很关心它的值,所以我们需要反向计算出从\(t=3\)倒\(t=0\)中间所有通路的梯度:

RNN BPTT

其实,这个与我们在深度前向神经网络中利用到的反向传导算法完全相同。关键的区别在于我们将\(W\)在所有时间步的梯度相加到一起。在传统的神经网络中,并不需要在层间共享权值,所以我们不需要将梯度们求和。但是在我看来BPTT其实就是标准的BP算法在展开的RNN上的一种叫法。就像使用BP算法你可以定义一个残差向量,然后反向传播,比如说:\(\delta^{(3)}_2=\frac{\partial E_3}{\partial z_2}=\frac{\partial E_3}{\partial s_3}\frac{\partial s_3}{\partial s_2}\frac{\partial s_2}{\partial z_2}\),其中\(z_2=Ux_2+Ws_1\)。然后用相同的公式即可。

在代码中,我们实现了一个很初级的BPTT,大概长这样:

def bptt(self, x, y):  
    T = len(y)
    # 进行前向传播
    o, s = self.forward_propagation(x)
    # 将梯度在这些变量中累加
    dLdU = np.zeros(self.U.shape)
    dLdV = np.zeros(self.V.shape)
    dLdW = np.zeros(self.W.shape)
    delta_o = o
    delta_o[np.arange(len(y)), y] -= 1.
    # 对每一个输出反向传播
    # 译注:产生一个倒序序列,长度为样本中的词数
    for t in np.arange(T)[::-1]:
        dLdV += np.outer(delta_o[t], s[t].T)
        # 首先计算:dL/dz
        delta_t = self.V.T.dot(delta_o[t]) * (1 - (s[t] ** 2))
        # 开始BPTT步骤 (最多进行self.bptt_truncate步)
        for bptt_step in np.arange(max(0, t-self.bptt_truncate), t+1)[::-1]:
            # print "Backpropagation step t=%d bptt step=%d " % (t, bptt_step)
            # 加到之前每一步的梯度上
            dLdW += np.outer(delta_t, s[bptt_step-1])              
            dLdU[:,x[bptt_step]] += delta_t
            # 更新t-1时刻的残差dL/dz
            delta_t = self.W.T.dot(delta_t) * (1 - s[bptt_step-1] ** 2)
    return [dLdU, dLdV, dLdW]

所以你应该清楚为什么标准的RNNs非常难以训练:因为序列(句子)特别的长,有可能长达20个甚至以上的词数。因此你需要反向传导很多层。在实践中,很多人将反向传播截断成很短的几步。

梯度扩散(Vanishing Gradient)问题

在之前的教程中,我曾经提到RNNs很难学习到长期的依赖关系——就是相距几步的词之间的依赖关系。这会造成很大的困难,由于英语句子的意义经常为离得非常远得单词所确定:“The man who wore a wig on his head went inside”。这个句子是在说一个人正在进屋(going inside),而跟戴不戴假发无关(wore a wig)。但是看上去简单的RNN很难捕捉到这些信息。想要了解原因,我们仔细看看我们上面算的梯度:

$$ \frac{\partial E_3}{\partial W}=\sum_{k=0}^3\frac{\partial E_3}{\partial\hat{y_3}}\frac{\partial\hat{y_3}}{\partial s_3}\frac{\partial s_3}{\partial s_k}\frac{\partial s_k}{\partial W} $$

请注意\(\frac{\partial s_3}{\partial s_k}\)部分是链式法则本身。例如\(\frac{s_3}{\partial s_1}=\frac{\partial s_3}{\partial s_2}\frac{\partial s_2}{\partial s_1}\)。还要注意:我们所说的导数都是指一个向量函数对于一个向量的导数,所以结果应该是一个矩阵(称为雅各比矩阵)每一个元素都应该是一个导数。我们可以这样重写一下梯度公式:

$$ \frac{\partial E_3}{\partial W}=\sum_{k=0}^3\frac{\partial E_3}{\partial\hat{y_3}}\frac{\partial\hat{y_3}}{\partial s_3}\left(\prod_{j=k+1}^3\frac{\partial s_j}{\partial s_{j-1}}\right)\frac{\partial s_k}{\partial W} $$

可以证明(我没证明,但这篇论文详细介绍过)上面雅各比矩阵的二范数(你可以认为它是一个绝对值)的上界是1。这个很容易想得出来,因为\(\tanh\)(或者\(\mathrm{sigmoid}\))激活函数将数值映射到-1和1之间,而导数将被限制在1以内(如果是\(sigmoid\)则限制在\(\frac{1}{4}\)以内):

tanh和sigmoid Tanh和Sigmoid函数,来源:http://nn.readthedocs.org/en/rtd/transfer/

你可以看到\(\tanh\)和\(\mathrm{sigmoid}\)函数两端的导数都是0。它们近似与一条直线。当发生这种情况,我们说这个对应的神经元已经饱和了。因此,矩阵中的元素值很小,多个矩阵相乘(特别是\(t-k\))得到的梯度值将会以指数级的速度减小,最终在几步之前完全消失(译注:实际上就是在说,越往前的梯度就需要越多的“小于1的数字”相乘,乘多了这个数就没了)。离当前步很远的时间步对梯度的贡献变为0,然后在这些时间步的状态将会对你的学习过程没有任何帮助:你在还没学习到长范围的依赖的时候就结束训练了。梯度扩散现象不止出现在RNNs中。它也发生在深度前向神经网络中。只是因为RNNs很容易特别深(在我们的例子中,深度跟句子长度相关),这将会导致很多共性的问题。

我们很容易想象到,由于使用了不同的激活函数和网络参数,我们很有可能因为雅各比矩阵数值太大,使得梯度“爆炸(exploding)”而不是消失,确实,这个问题称为“梯度爆炸”问题。梯度扩散比梯度爆炸更受关注主要是因为两个方面。第一,梯度爆炸很容易发现。因为梯度会变成NaN(not a number),然后你的程序会崩溃。第二,在一个预定义的阈值处剪切梯度(在这篇文章中有所提到)是一个解决梯度爆炸的有效方法。梯度扩散更容易造成问题是由于当这个问题发生的时候并不是那么容易察觉。并且难以找到解决的方法。

幸运的是,还有几种方法能够与梯度扩散相抗衡。适当的初始化\(W\)矩阵将会减轻梯度扩散的影响。所以我们可以其正则化。一个更好的方法是使用ReLU作为激活函数,而不用\(\tanh\)和\(\mathrm{sigmoid}\)。ReLU的导数被限制成非1即0,所以这将会更能容忍梯度扩散。一个更流行的解决方法是使用长短期记忆(Long Short-Term Memory, LSTM)或者Gated Recurrent Unit(GRU)结构。LSTMs在1997年就被第一次提出,而且这个可能是现在使用最广泛的NLP模型。GRUs在2014年首次提出,它是LSTMs的一种简化版本。这两种RNN结构都是经过精心设计被用来解决梯度扩散和长期依赖问题的。我们将会在这个教程的下一个部分介绍它们。

Friskit

继续阅读此作者的更多文章