传统形式的RNN
反向传播
$$\begin{equation}S_t = f(UX_t+WS_{t-1})\tag{1}\end{equation}$$
$$\begin{equation}O_t=g(VS_t)\tag{2}\end{equation}$$
其中,$f$和$g$为激活函数,$U,W,V$为RNN的参数。
假设$T$时刻的loss为$L_T$,则反向传播时传递到$t$时刻的关于$W$的梯度为,
$$\begin{equation}[\frac{\partial L_T}{\partial W}]_t^T=\frac{\partial L_T}{\partial O_T}\frac{\partial O_T}{\partial S_T}(\Pi_{k=T-1}^{t-1}\frac{\partial S_{k+1}}{\partial S_k})\tag{3}\end{equation}$$
求$S_k$关于$S_{k-1}$的偏导(关于矩阵的求导,可以参考矩阵求导),
$$\begin{equation}\frac{\partial S_k}{\partial S_{k-1}}=\frac{\partial f(UX_k+WS_{k-1})}{\partial (UX_k+WS_{k-1})}\frac{\partial (UX_k+WS_{k-1})}{\partial S_{k-1}}=diag(f^{'}(UX_k+WS_{k-1}))W\tag{4}\end{equation}$$
梯度消失和梯度爆炸的原因
RNN常用的两种激活函数,sigmoid和tanh。如果选择sigmoid函数作为激活函数,即$f(z)=\frac{1}{1+e^{-z}}$,其导数为$f^{'}(z)=f(z)(1-f(z))$,导数的取值范围为0~0.25。如果选择tanh作为激活函数,即$f(z)=\frac{e^z-e^{-z}}{e^z+e^{-z}}$,其导数为$f^{'}(z)=1-f(z)^2$,导数的取值范围为0~1。并且RNN网络的同一层中,所有时间步的$W$都是共享的。
因此,当选用sigmoid或者tanh作为激活函数时,大多数时候$f^{'}(z)$都是大于0小于1的,如果W的值也是大于0小于1,在计算式(3)涉及到多次$f^{'}(z)$和$W$的多次连乘,结果会趋于0,从而造成了梯度消失的问题。如果W的值特别大,则多次连乘后就会出现梯度爆炸的问题。
如果使用relu作为激活函数,即$f(z)=max(0,z)$,relu的导数在x>0时恒为1,一定程度上可以缓解梯度消失问题,但是如果W的值特别大,也会出现梯度爆炸的问题。而且当$z<0$时,导数恒为0,会造成部分神经元无法激活(可通过设置小学习率部分解决)。
不同之处
DNN中梯度消失和RNN梯度消失意义不一样,DNN中梯度消失的问题是:梯度在反向传播过程中,传播到低层的网络时,梯度会变得很小,这样低层网络的参数就不会更新,但高层网络是更新的;而RNN中的梯度消失的问题是:时间步$T$的梯度无法传递到时间步$t$($t<T$且$t$与$T$时刻相差较大),因此时间步$t$在更新参数时,只会受到时间步$T^{'}$的影响($t\leq T^{'}$,$t$与$T^{'}$相差不大),即RNN中的参数还是可以更新的,因为RNN中同一层的参数都是一样的,并不会出现参数不更新的情况,但是没办法满足学习到长期依赖。
如图,在时间步1,更新参数时,按照公式3,应该考虑梯度
$$\begin{equation}\sum_{T=1}^{6}[\frac{\partial L_T}{\partial W}]_1^T\tag{5}\end{equation}$$
但是经过多层反向传播后,梯度$[\frac{\partial L_6}{\partial W}]_1^6$和$[\frac{\partial L_5}{\partial W}]_1^5$可能会消失,因此时间步1的在更新参数$W$时,只考虑了时间步1、2、3、4这几个距离它比较近的时间步,这也就导致了学习不到远距离的依赖关系。这与DNN的梯度消失是不同的,因为RNN中的参数$W$还是可以更新的。因为,RNN同一层的参数是一样的,而MLP/CNN 中不同的层有不同的参数。最终,参数$W$更新的梯度为各个时间步参数$W$的更新梯度之和。
LSTM
在原始RNN的基础上,LSTM和GRU被提出,通过引入门控机制,一定程度上缓解了梯度消失的问题。引入门控的目的在于将激活函数导数的连乘变为加法。
以LSTM为例,
$$\begin{equation}c^{(t)}=f^{(t)}\odot c^{(t-1)}+i^{(t)}\odot\tilde{c}^{(t-1)}\tag{6}\end{equation}$$
$$\begin{equation}h^{(t)}=o^{(t)}\odot \tanh(c^{(t)})\tag{7}\end{equation}$$
假设时刻T的损失$L_T$,考虑$L_T$对$c^{(t)}$求导,由式(6)和(7)可知有两条求导路径,分别为$L_T->c^{(t+1)}->c^{(t)}$和$L_T->h^{(t)}->c^{(t)}$。即,
$$\begin{equation}\begin{aligned}\frac{\partial L_T}{\partial c^{(t)}}&=\frac{\partial L_T}{\partial c^{(t+1)}}\frac{\partial c^{(t+1)}}{\partial c^{(t)}}+\frac{\partial L_T}{\partial h^{(t)}}\frac{\partial h^{(t)}}{\partial c^{(t)}}\\&=\frac{\partial L_T}{\partial c^{(t+1)}}\odot f^{(t+1)}+\frac{\partial L_T}{\partial h^{(t)}}\odot o^{(t)}\odot (1-\tanh^2(c^{(t)}))\end{aligned}\tag{8}\end{equation}$$
$$\begin{equation}[\frac{\partial L_T}{\partial W_f}]_t=\frac{\partial L_T}{\partial c^{(t)}}\frac{\partial c^{(t)}}{W_f}\tag{9}\end{equation}$$
$$\begin{equation}[\frac{\partial L_T}{\partial W_i}]_t=\frac{\partial L_T}{\partial c^{(t)}}\frac{\partial c^{(t)}}{W_i}\tag{10}\end{equation}$$
注意到,当$f^{(t)}$为1时,即使第二项很小,t+1时刻的梯度仍然可以很好地传导到上一时刻t。此时即使序列的长度很长,也不会发生梯度消失的问题。当$f^{(t)}$为0时,即t时刻的cell 信息不会影响到t+1时刻的信息,此时在反向传播过程中,t+1时刻的梯度也不会传导到t时刻。因此forget gate $f^{(t)}$起到了控制梯度传播的衰弱程度的作用。
多层LSTM一般只采用2~3层。
LSTM 中梯度的传播有很多条路径, 例如$c^{(t+1)}->c^{(t)}$这条路径上只有逐元素相乘和相加的操作,梯度流最稳定;但是其他路径,例如 $c^{(t+1)}->i^{(t+1)}->h^{(t)}->c^{(t)}$路径上梯度流与普通 RNN 类似,照样会发生相同的权重矩阵和激活函数的导数的反复连乘,因此依然会爆炸或者消失。但是,正如式(8)~(9)所示,在计算$T$时刻的损失传递到$t$时刻关于$W_f$和$W_i$的梯度时,具有多个梯度流,且形式类似于
$$\begin{equation}(a_1+a_2)(b1+b2+b3)(c_1+c_2)...\tag{10}\end{equation}$$
即在反向传播过程中,梯度流是一种和的乘积的形式,因此可以理解为总的远距离梯度 = 各条路径的远距离梯度之和,即便其他远距离路径梯度消失了,只要保证有一条远距离路径梯度不消失,总的远距离梯度就不会消失(正常梯度 + 消失梯度 = 正常梯度)。因此 LSTM一定程度上缓解了梯度消失的问题,但是梯度爆炸问题任然可能发生,因为正常梯度 + 爆炸梯度 = 爆炸梯度。但是由于LSTM 的梯度流路径非常崎岖,且和普通RNN相比多经过了很多次激活函数(导数都小于 1),因此 LSTM 发生梯度爆炸的频率要低得多。实践中梯度爆炸一般通过梯度裁剪来解决。
IndRNN
为了解决梯度消失和梯度爆炸问题,IndRNN将层内的神经元独立开来,对式(1)稍加修改,
$$\begin{equation}h^{(t)}=\sigma(Wx^{(t)} + u\odot h^{(t-1)}+b)\tag{11}\end{equation}$$
其中,激活函数$f$为relu函数。IndRNN中,在利用上一时刻t-1时刻的hidden state$h^{(t-1)}$计算当前时刻t的的hidden state $h^{(t)}$时,不再是与权重矩阵$U$相乘,而是与权重向量$u$计算哈达玛积(对应元素相乘),这就使得同一层的RNN Cell的神经元相互独立了。即$h^{(t)}$的第k个维度只与$h^{(t-1)}$的第k个维度有关。
将这种神经元之间解耦的思想应用到LSTM,进一步提出了IndyLSTM。
$$\begin{equation}f^{(t)} =\sigma_g(W_fx^{(t)}+u_f\odot h^{(t-1)}+b_f)\tag{12}\end{equation}$$
$$\begin{equation}i^{(t)}=\sigma_g(W_i x^{(t)}+u_i\odot h^{(t-1)}+b_i)\tag{13}\end{equation}$$
$$\begin{equation}o^{(t)}=\sigma_g(W_o x^{(t)}+u_o\odot h^{(t-1)}+b_o)\tag{14}\end{equation}$$
$$\begin{equation}\tilde{c}^{(t)}=\sigma_c(W_c x^{(t)}+u_c\odot h^{(t-1)}+b_c)\tag{15}\end{equation}$$
$$\begin{equation}c^{(t)}=f^{(t)}\odot c^{(t-1)}+i^{(t)}\odot \tilde{c}^{(t)}\tag{16}\end{equation}$$
$$\begin{equation}h^{(t)}=o^{(t)}\odot \sigma_h(c^{(t)})\tag{17}\end{equation}$$
对神经元进行解耦,使得在反向传播过程中,多条路径的梯度流都较为平稳,可以有效地缓解梯度下降问题和梯度爆炸问题。
源码
tensorflow中,可以通过tensorflow.nn.rnn_cell.LSTMCell
调用LSTM,通过tensorflow.contrib.rnn.IndyLSTMCell
调用IndyLSTM。
LSTMCell的build方法
输入门$i$的权重参数为$W_i$和$U_i$,遗忘门$f$的权重参数为$W_f$和$U_f$,输出门$o$的参数为$W_o$和$U_o$,候选cell$\tilde{c}$的参数为$W_c$和$U_c$。因此总的权重参数_kernel
的shape为[input_depth + h_depth, 4 * self._num_units]。
self._kernel = self.add_variable(
_WEIGHTS_VARIABLE_NAME,
shape=[input_depth + h_depth, 4 * self._num_units],
initializer=self._initializer,
partitioner=maybe_partitioner)
LSTMCell的call方法
在call方法中,将当前时间步的inputs
和上一时刻的hidden state $h$拼接,与权重矩阵相乘,在切分,得到输入门、候选的cell、遗忘门和输出门。
# i = input_gate, j = new_input, f = forget_gate, o = output_gate
lstm_matrix = math_ops.matmul(
array_ops.concat([inputs, m_prev], 1), self._kernel)
lstm_matrix = nn_ops.bias_add(lstm_matrix, self._bias)
i, j, f, o = array_ops.split(
value=lstm_matrix, num_or_size_splits=4, axis=1)
IndyLSTMCell的build方法
由式(12)~(15)可知,神经元进行了解耦,参数$W$任然为关于输入的权重矩阵,但是关于$h$的权重矩阵$U$变成了权重向量$u$。_kernel_w
的shape为[input_depth, 4 self._num_units],而权重向量_kernel_u
的shape为[1, 4 self._num_units]。
self._kernel_w = self.add_variable(
"%s_w" % rnn_cell_impl._WEIGHTS_VARIABLE_NAME,
shape=[input_depth, 4 * self._num_units],
initializer=self._kernel_initializer)
self._kernel_u = self.add_variable(
"%s_u" % rnn_cell_impl._WEIGHTS_VARIABLE_NAME,
shape=[1, 4 * self._num_units],
IndyLSTMCell的call方法
gen_array_ops.tile(h, [1, 4]) * self._kernel_u
即是在计算$u_i\odot h$、$u_c\odot h$、$u_f\odot h$和$u_o\odot h$。
gate_inputs = math_ops.matmul(inputs, self._kernel_w)
gate_inputs += gen_array_ops.tile(h, [1, 4]) * self._kernel_u
gate_inputs = nn_ops.bias_add(gate_inputs, self._bias)
# i = input_gate, j = new_input, f = forget_gate, o = output_gate
i, j, f, o = array_ops.split(
value=gate_inputs, num_or_size_splits=4, axis=one)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。