avatar


9.LSTM和GRU

东邪西毒

《东邪西毒》是一部非常经典的电影,王家卫导演挪用了金庸先生的故事以及武侠片的形式,上演了另一个主题:回忆

而相比中文名,电影的英文名"Ashes of Time(时间的灰烬)"或许更贴近影片主题。

以前发生过的事情留在回忆里,像是时间被燃烧成灰烬一样,而且回忆会淡去,就像灰烬会随风扬尽。

在影片最后,还有这么一段话。
令自己不要忘记

我们这一章要讨论的LSTM和GRU,就和这个有关,令自己不要忘记

RNN的遗忘

在影片开头,讲述了一个关于"遗忘"的故事。

遗忘

我们也先讨论"遗忘"。
那么,谁会遗忘?循环神经网络会遗忘。

循环神经网络

ht=tanh(Wxhxt+Whhht1+b)\bold{h}_t = \tanh(\bold{W}_{xh}\bold{x}_t + \bold{W}_{hh}\bold{h}_{t-1} + b)

如图和公式所示,在循环迭代多次后,最初的那个x0\bold{x}_0x1\bold{x}_1不知道还"记得多少"。
更形象的描述就像下图,我们看到初始的输入在之后的圆中,所占的面积越来越小。
RNN

x0\bold{x}_0x1\bold{x}_1等这些初始的输入,已经过去了。我们唯一可以做的,就是令自己不要忘记。

那么,就不得不讨论长短期记忆神经网络。

LSTM的结构

LSTM,长短期记忆神经网络(Long Short-Term Memory)。

长短期记忆神经网络

如图所示,就是长短期记忆神经网络的结构。我们为了记住过去,新增了一个状态量c

可是正如影片中所述,

最大的烦恼是记性太好
我们也不能所有的事情都记住,有些需要忘记。

门控机制

我们通过来忘记。
既然是呢,要么,要么,再要么就是虚掩。总之总是在01之间,所以,我们需要一个函数,再把这个函数套在合适的激活函数里面,这样就输出[0,1][0,1]之间的值。
那么选哪个激活函数呢?
复习一下:

激活函数 值域 备注
阶跃函数 0011 不可以用梯度下降
sigmoid [0,1][0,1]
tanh [1,1][-1,1]
ReLU [0,+)[0,+\infty)
SoftMax [0,1][0,1] 多个输出值,所有输出值之和为1

显然,我们决定用sigmoid作为激活函数。

  • σ(g)=0\sigma(\bold{g}) = 0时,门控全部关闭。
  • σ(g)=1\sigma(\bold{g}) = 1时,门控全部打开。
  • σ(g)(0,1)\sigma(\bold{g}) \in (0,1)时,门控半开合状态。

门控机制

遗忘门

遗忘门

正如之前的讨论,cc用来记住过去,sigmoid函数用来做门控函数最外面一层的激活函数。

gf=σ(Wf[ht1,xt]+bf)\bold{g}_f = \sigma(\bold{W}_f \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_f)

  • Wf\bold{W}_fbf\bold{b}_f是遗忘门的参数,通过梯度下降和反向传播进行优化。
  • σ\sigma代表激活函数sigmoid

通过这个操作,可以得到[0,1][0,1]之间的值,然后再和ct1\bold{c}_{t-1}相乘,最后状态向量变成gfct1\bold{g}_f\bold{c}_{t-1},如此实现门控。

输入门

通过遗忘门,可以忘记过去,或者部分忘记过去。但是总有新的事情会发生,新的事情也不一定都要记住。这就是输入门。
输入门

c~t=tanh(Wc[ht1,xt]+bc)\bold{\tilde{c}}_t = \tanh(\bold{W}_c \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_c)

  • Wc\bold{W}_cbc\bold{b}_c是输入门的参数,通过梯度下降和反向传播进行优化。
  • tanh\tanh是激活函数,其值域是[1,1][-1,1]

gi=σ(Wi[ht1,xt]+bi)\bold{g}_i = \sigma(\bold{W}_i \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_i)

  • Wi\bold{W}_ibi\bold{b}_i是输入门的参数,通过梯度下降和反向传播进行优化。
  • σ\sigma代表激活函数sigmoid

然后将两者相乘。

gic~t\bold{g}_i \cdot \bold{\tilde{c}}_t

如此对现在的输入实现门控。

最后

ct=gic~t+gfct1\bold{c}_t = \bold{g}_i \bold{\tilde{c}}_t + \bold{g}_f \bold{c}_{t-1}

通过这个方法,刷新记忆。

根据ct\bold{c}_t的公式,我们发现,遗忘门控和输入门控的不同组合,会对记忆产生不同的影响。

遗忘门控 输入门控 LSTM行为
1 0 只有记忆
1 1 综合输入和记忆
0 0 清空所有,新的开始
0 1 忘记过去,只记今朝

在影片中,还给我们讲了一个极端的情况。每一个神经元的遗忘门控和输入门控都是0。
今后的每一天将会是一个新的开始

输出门

到目前位置,LSTM都是只对过去和现在的输入进行处理,从而形成记忆。
而对于输出,LSTM同样存在一个门,输出门控。在输出门控的作用下,并不是所有的输入都会被输出。

输出门

go=σ(Wo[ht1,xt]+bo)\bold{g}_o = \sigma(\bold{W}_o \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_o)

  • Wo\bold{W}_obo\bold{b}_o是输出门的参数,通过梯度下降和反向传播进行优化。
  • σ\sigma代表激活函数sigmoid

ht=gotanh(ct)\bold{h}_t = \bold{g}_o * \tanh(\bold{c}_t)

即当前的记忆ct\bold{c}_t经过tanh\tanh激活函数后,与go\bold{g}_o相乘,最后形成输出ht\bold{h}_t

因为go\bold{g}_o的最外层是一个sigmoid函数,所以其值域是[0,1][0,1];而tanh\tanh函数的值域是[1,1][-1,1]。所以ht[1,1]\bold{h}_t \in [-1,1]

小结

最后我们进行一个小结。
刚刚我们讨论三种门:

gf=σ(Wf[ht1,xt]+bf)gi=σ(Wi[ht1,xt]+bi)go=σ(Wo[ht1,xt]+bo)\begin{aligned} \bold{g}_f &= \sigma(\bold{W}_f \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_f) \\ \bold{g}_i &= \sigma(\bold{W}_i \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_i) \\ \bold{g}_o &= \sigma(\bold{W}_o \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_o) \end{aligned}

而这三个门都是由ht1\bold{h}_{t-1}xt\bold{x}_t控制的。

一个中间状态:

c~t=tanh(Wc[ht1,xt]+bc)\bold{\tilde{c}}_t = \tanh(\bold{W}_c \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_c)

两个输出:

ct=gic~t+gfct1ht=gotanh(ct)\begin{aligned} \bold{c}_t &= \bold{g}_i \bold{\tilde{c}}_t + \bold{g}_f \bold{c}_{t-1} \\ \bold{h}_t &= \bold{g}_o * \tanh(\bold{c}_t) \end{aligned}

也有些时候,我们会写成这种形式。
LSTM

LSTM梯度

现在我们来讨论LSTM的梯度。
首先损失函数lossloss肯定是关于输出ht\bold{h}_t的函数,所以lossht\frac{\partial loss}{\partial \bold{h}_t}这个我们是知道的。

ht=gotanh(ct)htW=(go)(tanh(ct))+(go)(tanh(ct))\begin{aligned} \bold{h}_t &= \bold{g}_o * \tanh(\bold{c}_t) \\ \frac{\partial \bold{h}_t}{\partial \bold{W}} &= (\bold{g}_o)'(\tanh(\bold{c}_t)) + (\bold{g}_o)(\tanh(\bold{c}_t))' \end{aligned}

go=σ(Wo[ht1,xt]+bo)\bold{g}_o = \sigma(\bold{W}_o \cdot [ \bold{h}_{t-1},\bold{x}_t ] + \bold{b}_o),所以(go)(\bold{g}_o)'是可以求出的。
那么,

ct=gic~t+gfct1\bold{c}_t = \bold{g}_i \bold{\tilde{c}}_t + \bold{g}_f \bold{c}_{t-1}

gi\bold{g}_ic~t\bold{\tilde{c}}_tgf\bold{g}_f,都是关于ht1\bold{h}_{t-1}的函数,而ht1\bold{h}_{t-1}是关于ct1\bold{c}_{t-1}的函数。所以ct\bold{c}_{t}是关于ct1\bold{c}_{t-1}的函数
这个东西是不是似成相识?之前在循环神经网络中也有。

在循环神经网络中:ht=tanh(Wxhxt+Whhht1+b)\text{在循环神经网络中:}\bold{h}_t = \tanh(\bold{W}_{xh}\bold{x}_t + \bold{W}_{hh}\bold{h}_{t-1} + b)

又是t1t-1时的因变量,作为了tt时刻的自变量。上次就是因为这个原因,出现了连乘,导致了梯度爆炸和梯度弥散。
那么这次呢,我们来试一下。

ctct1=(gi)(c~t)+(gi)(c~t)+(gf)(ct1)+(gf)(ct1)\frac{\partial \bold{c}_t}{\partial \bold{c}_{t-1}} = (\bold{g}_i)'(\bold{\tilde{c}}_t) + (\bold{g}_i)(\bold{\tilde{c}}_t)' + (\bold{g}_f)'(\bold{c}_{t-1}) + (\bold{g}_f)(\bold{c}_{t-1})'

我们注意到最后一部分。

(gf)(ct1)=gf(\bold{g}_f)(\bold{c}_{t-1})' = \bold{g}_f

gf\bold{g}_f是什么?遗忘门控。所以只要没有忘记,或者几乎没有忘记。即gf=1\bold{g}_f = 1gf1\bold{g}_f \approx 1,那么梯度就不会弥散。

所以,LSTM不容易出现梯度弥散。

LSTM层的实现

与RNN一样,在TensorFlow中,LSTM层同样有两种方法。

  1. keras.layers.LSTMCell
  2. keras.layers.LSTM

LSTMCell

方法:

1
keras.layers.LSTMCell

LSTMCell的trainable_variables

我们将LSTMCellSimpleRNNCell进行比较。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from tensorflow.keras import layers

# 5 的意思是经过神经元后,维度转化为5维
# [b, 10] -> [b, 5],b是句子数量
lstmcell = layers.LSTMCell(5)
rnncell = layers.SimpleRNNCell(5)
# 10 的意思是每个汉字的表示向量的维度是10
lstmcell.build(input_shape=(None,10))
rnncell.build(input_shape=(None,10))

for var in zip(lstmcell.trainable_variables,rnncell.trainable_variables):
print(var[0].name,var[0].shape,var[0].dtype)
print(var[1].name,var[1].shape,var[1].dtype)
print('\n')

运行结果:

1
2
3
4
5
6
7
8
9
10
kernel:0 (10, 20) <dtype: 'float32'>
kernel:0 (10, 5) <dtype: 'float32'>


recurrent_kernel:0 (5, 20) <dtype: 'float32'>
recurrent_kernel:0 (5, 5) <dtype: 'float32'>


bias:0 (20,) <dtype: 'float32'>
bias:0 (5,) <dtype: 'float32'>

我们发现LSTMCell的参数量是SimpleRNNCell的四倍。哪四倍呢?

  1. Wf\bold{W}_f
  2. Wi\bold{W}_i
  3. Wo\bold{W}_o
  4. Wc\bold{W}_c

前向计算

对于前向计算,我们需要初始化的状态有两个:h0\bold{h}_0c0\bold{c}_0
示例代码:

1
2
3
4
5
6
7
8
9
10
11
# 4个句子,且根据之前的设定,输出的维度是5维
# 4个句子并行进行前向计算
state0 = [tf.zeros([4, 5]),tf.zeros([4, 5])]
# 4个句子,每个句子7个字,每个字的表示向量维度是10维
x = tf.random.normal([4, 7, 10])
# 取每个句子的第一个字
xt = x[:,0,:]
# 前向计算
out, state1 = lstmcell(xt, state0)
print(out.shape, state1[0].shape,state1[1].shape)
print(id(out),id(state1[0]),id(state1[1]))

运行结果:

1
2
(4, 5) (4, 5) (4, 5)
140528518879184 140528518879184 140528544545600

正如运行结果所示,返回的输出out\bold{out}和List的第一个元素ht\bold{h}_t的id是相同的。

串联成LSTM层

我们可以发挥循环神经网络的精髓。
网络循环接受序列的每个特征向量xt\bold{x}_t,并刷新内部状态向量ht\bold{h}_t,同时形成输出ot\bold{o}_t

示例代码:

1
2
3
4
5
6
state = state0
# 在序列长度维度上解开,循环送入 LSTM Cell 单元
for xt in tf.unstack(x, axis=1):
# 前向计算
out, state = lstmcell(xt, state)
print(out.shape,state[0].shape,state[1].shape)

运行结果:

1
2
3
4
5
6
7
(4, 5) (4, 5) (4, 5)
(4, 5) (4, 5) (4, 5)
(4, 5) (4, 5) (4, 5)
(4, 5) (4, 5) (4, 5)
(4, 5) (4, 5) (4, 5)
(4, 5) (4, 5) (4, 5)
(4, 5) (4, 5) (4, 5)

LSTM层

除了串联LSTMCell,我们还有更简洁的方法。

1
layers.LSTM()

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
import tensorflow as tf
from tensorflow.keras import layers

# 状态向量是5维
layer = layers.LSTM(5)
# 4句话,每句话7个字,每个字的表示向量是10维的
x = tf.random.normal([4, 7, 10])
out = layer(x)
print(out)
print('\n')

for var in layer.trainable_variables:
print(var.name,var.shape,var.dtype)

运行结果:

1
2
3
4
5
6
7
8
9
10
tf.Tensor(
[[-0.10342102 0.01817901 -0.47463024 0.14861609 -0.07993239]
[ 0.26894122 -0.02338248 0.38075954 0.21347676 0.20355701]
[ 0.03350078 -0.06494962 0.08367281 -0.09063848 -0.07871907]
[ 0.1321046 0.42025885 0.19594304 0.03045102 0.06093809]], shape=(4, 5), dtype=float32)


lstm/lstm_cell/kernel:0 (10, 20) <dtype: 'float32'>
lstm/lstm_cell/recurrent_kernel:0 (5, 20) <dtype: 'float32'>
lstm/lstm_cell/bias:0 (20,) <dtype: 'float32'>

在上面的代码中,我们还专门看了一下layer的trainable_variables,和LSTMCell的trainable_variables是一样的,即做了权值共享。

和RNNCell一样,如果需要每一个LSTMCell都输出,可以通过设置return_sequences=True来实现。
示例代码:

1
2
3
4
5
6
# 状态向量是5维
layer = layers.LSTM(5,return_sequences=True)
# 4句话,每句话7个字,每个字的表示向量是10维的
x = tf.random.normal([4, 7, 10])
out = layer(x)
print(out.shape)

运行结果:

1
(4, 7, 5)

当然,我们也堆叠多个LSTM层。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import tensorflow as tf
from tensorflow.keras import layers,Sequential

net = Sequential([ # 构建 2 层 RNN 网络
# 除最末层外,都需要返回所有时间戳的输出,用作下一层的输入
layers.LSTM(5, return_sequences=True),
layers.LSTM(5),
])
# 4句话,每句话7个字,每个字的表示向量是10维的
x = tf.random.normal([4, 7, 10])
out = net(x)
print(out)
for var in net.trainable_variables:
print(var.name,var.shape,var.dtype)

运行结果:

1
2
3
4
5
6
7
8
9
10
11
tf.Tensor(
[[ 0.01342524 -0.03180564 0.05031351 0.01574086 0.12032484]
[-0.03389003 0.03127829 0.07479815 0.06893347 0.13489318]
[-0.02699067 0.04662055 0.02154445 0.15134797 -0.03523358]
[-0.09501531 0.12560178 0.1094329 0.14707239 0.08788656]], shape=(4, 5), dtype=float32)
lstm_1/lstm_cell_1/kernel:0 (10, 20) <dtype: 'float32'>
lstm_1/lstm_cell_1/recurrent_kernel:0 (5, 20) <dtype: 'float32'>
lstm_1/lstm_cell_1/bias:0 (20,) <dtype: 'float32'>
lstm_2/lstm_cell_2/kernel:0 (5, 20) <dtype: 'float32'>
lstm_2/lstm_cell_2/recurrent_kernel:0 (5, 20) <dtype: 'float32'>
lstm_2/lstm_cell_2/bias:0 (20,) <dtype: 'float32'>

LSTM的实现

还是以IMDB电影评论的积极消极分类为例。
网络结构也和RNN一样,除了把RNNCell换成了LSTMCell。
LSTM

基于LSTMCell的实现

只需要在基于RNNCell的RNN实现的基础上,对初始化方法做非常简单的修改。

  1. 需要提供h0\bold{h}_0c0\bold{c}_0两个初始状态。
  2. SimpleRNNCell换成LSTMCell

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def __init__(self, units):
super(LSTM, self).__init__()

# [b, 64]
self.state0 = [tf.zeros([batch_size, units]), tf.zeros([batch_size, units])]
self.state1 = [tf.zeros([batch_size, units]), tf.zeros([batch_size, units])]

# transform text to embedding representation
# [b, 100] => [b, 100, 150]
self.embedding = layers.Embedding(input_dim=total_words, output_dim=embedding_len, input_length=max_review_len)

# units=64
self.rnn_cell0 = layers.LSTMCell(units, dropout=0.5)
self.rnn_cell1 = layers.LSTMCell(units, dropout=0.5)

# 全连接层
# [b, 100, 150] => [b, 64] => [b, 1]
self.out = layers.Dense(1)

基于LSTM层的实现

只需要在基于RNN层的RNN实现的基础上,对初始化方法做非常简单的修改。

  • SimpleRNN换成LSTM

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def __init__(self, units):
super(LSTM, self).__init__()

# transform text to embedding representation
# [b, 80] => [b, 80, 100]
self.embedding = layers.Embedding(total_words, embedding_len, input_length=max_review_len)

# [b, 80, 100] , h_dim: 64
self.lstmlayer = keras.Sequential([
layers.LSTM(units, dropout=0.5, return_sequences=True, unroll=True),
layers.LSTM(units, dropout=0.5, unroll=True)
])

# fc, [b, 80, 100] => [b, 64] => [b, 1]
self.outlayer = layers.Dense(1)

LSTM的优缺点

和RNN相比。

优点有:

  1. 具有更长的记忆能力
  2. 不容易出现梯度弥散现象

缺点有:

  1. LSTM结构相对复杂,计算代价较高,模型参数量较大。

那么,有没有具有更长的记忆能力,不容易出现梯度弥散。结构还简单的网络模型呢?
GRU。

GRU的结构

GRU,门控循环网络(Gated Recurrent Unit)。

GRU网络结构

在GRU中c\bold{c}h\bold{h}被统一合并为h\bold{h}
门控数量也减少到2个:

  1. 复位门(Reset Gate)
  2. 更新门(Update Gate)

复位门

复位门用于控制上一个GRU神经元的ht1\bold{h}_{t-1}进入当前GRU。
复位门

gr=σ(Wr[ht1,xt]+br)\bold{g}_r = \sigma(\bold{W}_r[\bold{h}_{t-1},\bold{x}_t] + \bold{b}_r)

  • Wr\bold{W}_rbr\bold{b}_r是复位门的参数,通过梯度下降和反向传播进行优化。
  • σ\sigma代表激活函数sigmoid

h~t=tanh(Wh[grht1,xt]+bh)\bold{\tilde{h}}_t = \tanh(\bold{W}_h \cdot [ \bold{g}_r \bold{h}_{t-1},\bold{x}_t] + \bold{b}_h)

即,门控向量gr\bold{g}_r只控制ht1\bold{h}_{t-1},而不会控制输入xt\bold{x}_t

更新门

更新门则是控制当前GRU神经元输出至下一个神经元的ht\bold{h}_t
更新门

gz=σ(Wz[ht1,xt]+bz)\bold{g}_z = \sigma(\bold{W}_z[\bold{h}_{t-1},\bold{x}_t] + \bold{b}_z)

  • Wz\bold{W}_zbz\bold{b}_z是更新门的参数,通过梯度下降和反向传播进行优化。
  • σ\sigma代表激活函数sigmoid

ht=(1gz)ht1+gzh~t\bold{h}_t = (1-\bold{g}_z) \bold{h}_{t-1} + \bold{g}_z \bold{\tilde{h}}_t

即,gz\bold{g}_z控制h~t\bold{\tilde{h}}_t1gz1-\bold{g}_z控制ht1\bold{h}_{t-1}。或者可以理解成gz\bold{g}_z同时控制h~t\bold{\tilde{h}}_tht1\bold{h}_{t-1}

至于GRU层的实现GRU神经网络的实现,和之前讨论的"RNN"以及"LSTM"类似,这里不再赘述。

文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/10409
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

评论区