6. 门控循环单元(GRU)
上一节介绍了循环神经网络中的梯度计算方法。可以看出,当时间步较大或较小时,循环神经网络的梯度较容易出现衰减或爆炸。
虽然裁剪梯度可以应对梯度爆炸,但无法解决梯度衰减的问题。通常由于该原因,使得循环神经网络在实际中较难捕捉时间序列中时间步距离较大的依赖关系。
门控循环神经网络(gated recurrent neural network)的提出,正是为了更好地捕捉时间序列中时间步距离较大的依赖关系。它通过可以学习的门来控制信息的流动。其中,门控循环单元(gated recurrent unit,GRU)是一种常用的门控循环神经网络。
6.1 概念
下面将介绍门控循环单元的设计。它引入了重置门(reset gate)和更新门(update gate)的概念,从而修改了循环神经网络中隐藏状态的计算方式。
6.1.1 重置门和更新门
如下图所示,门控循环单元中的重置门和更新门的输入均为当前时间步输入 X t \boldsymbol{X}_t Xt与上一时间步隐藏状态 H t − 1 \boldsymbol{H}_{t-1} Ht−1,输出由激活函数为sigmoid函数的全连接层计算得到:
具体来说,假设隐藏单元个数为 h h h,给定时间步 t t t的小批量输入 X t ∈ R n × d \boldsymbol{X}_t \in \mathbb{R}^{n \times d} Xt∈Rn×d(样本数为 n n n,输入个数为 d d d)和上一时间步隐藏状态 H t − 1 ∈ R n × h \boldsymbol{H}_{t-1} \in \mathbb{R}^{n \times h} Ht−1∈Rn×h。重置门 R t ∈ R n × h \boldsymbol{R}_t \in \mathbb{R}^{n \times h} Rt∈Rn×h和更新门 Z t ∈ R n × h \boldsymbol{Z}_t \in \mathbb{R}^{n \times h} Zt∈Rn×h的计算如下:
R t = σ ( X t W x r + H t − 1 W h r + b r ) Z t = σ ( X t W x z + H t − 1 W h z + b z ) \begin{aligned} \boldsymbol{R}_t = \sigma(\boldsymbol{X}_t \boldsymbol{W}_{xr} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hr} + \boldsymbol{b}_r)\\ \boldsymbol{Z}_t = \sigma(\boldsymbol{X}_t \boldsymbol{W}_{xz} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hz} + \boldsymbol{b}_z) \end{aligned} Rt=σ(XtWxr+Ht−1Whr+br)Zt=σ(XtWxz+Ht−1Whz+bz)
其中, W x r \boldsymbol{W}_{xr} Wxr、 W x z ∈ R d × h \boldsymbol{W}_{xz} \in \mathbb{R}^{d \times h} Wxz∈Rd×h和 W h r \boldsymbol{W}_{hr} Whr、 W h z ∈ R h × h \boldsymbol{W}_{hz} \in \mathbb{R}^{h \times h} Whz∈Rh×h为权重参数, b r \boldsymbol{b}_r br、 b z ∈ R 1 × h \boldsymbol{b}_z \in \mathbb{R}^{1 \times h} bz∈R1×h为偏差参数。
多层感知机 小节中有介绍过,sigmoid函数可以将元素的值变换到0和1之间。因此,重置门 R t \boldsymbol{R}_t Rt和更新门 Z t \boldsymbol{Z}_t Zt中每个元素的值域都是 [ 0 , 1 ] [0, 1] [0,1]。
6.1.2 候选隐藏状态
门控循环单元将计算候选隐藏状态来辅助稍后的隐藏状态计算。
如下图所示,将当前时间步重置门的输出与上一时间步隐藏状态做按元素乘法(符号为
⊙
\odot
⊙):
若重置门中元素值接近0,那么,表示重置对应隐藏状态元素为0(即,丢弃上一时间步的隐藏状态);
若重置门中元素值接近1,那么,表示保留上一时间步的隐藏状态。
然后,将按元素乘法的结果与当前时间步的输入连结,再通过含激活函数tanh的全连接层计算出候选隐藏状态。其中,所有元素的值域为
[
−
1
,
1
]
[-1, 1]
[−1,1]。
具体来说,时间步
t
t
t的候选隐藏状态
H
~
t
∈
R
n
×
h
\tilde{\boldsymbol{H}}_t \in \mathbb{R}^{n \times h}
H~t∈Rn×h的计算为:
H
~
t
=
tanh
(
X
t
W
x
h
+
(
R
t
⊙
H
t
−
1
)
W
h
h
+
b
h
)
\tilde{\boldsymbol{H}}_t = \text{tanh}(\boldsymbol{X}_t \boldsymbol{W}_{xh} + \left(\boldsymbol{R}_t \odot \boldsymbol{H}_{t-1}\right) \boldsymbol{W}_{hh} + \boldsymbol{b}_h)
H~t=tanh(XtWxh+(Rt⊙Ht−1)Whh+bh)
其中, W x h ∈ R d × h \boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h} Wxh∈Rd×h和 W h h ∈ R h × h \boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h} Whh∈Rh×h为权重参数, b h ∈ R 1 × h \boldsymbol{b}_h \in \mathbb{R}^{1 \times h} bh∈R1×h为偏差参数。
从上述公式可得,重置门控制了上一时间步的隐藏状态如何流入当前时间步的候选隐藏状态。而上一时间步的隐藏状态可能包含了时间序列截至上一时间步的全部历史信息。因此,重置门可以用来丢弃与预测无关的历史信息。
6.1.3 隐藏状态
最后,时间步 t t t的隐藏状态 H t ∈ R n × h \boldsymbol{H}_t \in \mathbb{R}^{n \times h} Ht∈Rn×h的计算,使用当前时间步的更新门 Z t \boldsymbol{Z}_t Zt来对上一时间步的隐藏状态 H t − 1 \boldsymbol{H}_{t-1} Ht−1和当前时间步的候选隐藏状态 H ~ t \tilde{\boldsymbol{H}}_t H~t做组合:
H t = Z t ⊙ H t − 1 + ( 1 − Z t ) ⊙ H ~ t \boldsymbol{H}_t = \boldsymbol{Z}_t \odot \boldsymbol{H}_{t-1} + (1 - \boldsymbol{Z}_t) \odot \tilde{\boldsymbol{H}}_t Ht=Zt⊙Ht−1+(1−Zt)⊙H~t
值得注意的是,更新门可以控制隐藏状态应该如何被包含当前时间步信息的候选隐藏状态所更新。
如上图所示,假设更新门在时间步
t
′
t'
t′到
t
t
t(
t
′
<
t
t' < t
t′<t)之间一直近似1,那么,在时间步
t
′
t'
t′到
t
t
t之间的输入信息几乎没有流入时间步
t
t
t的隐藏状态
H
t
\boldsymbol{H}_t
Ht。
实际上,这可以看作是较早时刻的隐藏状态
H
t
′
−
1
\boldsymbol{H}_{t'-1}
Ht′−1一直通过时间保存并传递至当前时间步
t
t
t。
该设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。
对门控循环单元的设计稍作总结:
重置门有助于捕捉时间序列里短期的依赖关系;
更新门有助于捕捉时间序列里长期的依赖关系。
6.2 代码示例
6.2.1 读取数据集
为了实现并展示门控循环单元,依然使用周杰伦歌词数据集来训练模型作词。
其中,除门控循环单元以外的实现已在 循环神经网络 进行了介绍。
读取数据集,代码示例如下:
import tensorflow as tf
from tensorflow import keras
import time
import math
import sys
import numpy as np
import d2lzh_tensorflow2 as d2l
def load_data_jay_lyrics():
"""加载周杰伦歌词数据集"""
import zipfile
with zipfile.ZipFile('./data/jaychou_lyrics.txt.zip') as zin:
with zin.open('jaychou_lyrics.txt') as f:
corpus_chars = f.read().decode('utf-8')
corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
corpus_chars = corpus_chars[0:10000]
idx_to_char = list(set(corpus_chars))
char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])
vocab_size = len(char_to_idx)
corpus_indices = [char_to_idx[char] for char in corpus_chars]
return corpus_indices, char_to_idx, idx_to_char, vocab_size
(corpus_indices, char_to_idx, idx_to_char,vocab_size) = load_data_jay_lyrics()
6.2.2 简洁实现
使用 循环神经网络 小节已封装的函数:
class RNNModel(tf.keras.layers.Layer):
def __init__(self, rnn_layer, vocab_size, **kwargs):
super(RNNModel, self).__init__(**kwargs)
self.rnn = rnn_layer
self.vocab_size = vocab_size
self.dense = tf.keras.layers.Dense(units=vocab_size)
def call(self, inputs, state):
# 将输入转置为(num_steps, batch_size),再进行one-hot向量表示
X = tf.one_hot(indices=tf.transpose(inputs), depth=self.vocab_size)
Y, state = self.rnn(X, state)
# Y先reshape to (num_steps * batch_size, num_hiddens),再过dense层
# 最终输出形状: (num_steps * batch_size, vocab_size)
output = self.dense(tf.reshape(Y, shape=(-1, Y.shape[-1])))
return output, state
def get_initial_state(self, *args, **kwargs):
return self.rnn.cell.get_initial_state(*args, **kwargs)
def predict_rnn_keras(prefix, num_chars, model, vocab_size, idx_to_char, char_to_idx):
# 使用model的成员函数来初始化隐藏状态
state = model.get_initial_state(batch_size=1, dtype=tf.float32)
output = [char_to_idx[prefix[0]]]
for t in range(len(prefix)+num_chars-1):
X = np.array([output[-1]]).reshape((1, 1))
Y, state = model(X, state)
if t < len(prefix)-1:
output.append(char_to_idx[prefix[t+1]])
else:
# 取Y中max值
output.append(int(np.array(tf.argmax(Y, axis=-1))))
return ''.join([idx_to_char[i] for i in output])
def grad_clipping(grads, theta):
norm = np.array([0])
for i in range(len(grads)):
norm += tf.reduce_sum(grads[i]**2)
norm = np.sqrt(norm).item()
new_gradients = []
if norm > theta:
for grad in grads:
new_gradients.append(grad*theta/norm)
else:
for grad in grads:
new_gradients.append(grad)
return new_gradients
def train_and_predict_rnn_keras(model, num_hiddens, vocab_size,
corpus_indices, idx_to_char, char_to_idx,
num_epochs, num_steps, lr, clipping_theta,
batch_size, pred_period, pred_len, prefixes):
import time
import math
loss = tf.keras.losses.SparseCategoricalCrossentropy()
optimizer = tf.keras.optimizers.SGD(learning_rate=lr)
for epoch in range(num_epochs):
l_sum, n, start = 0.0, 0, time.time()
# 相邻采样
data_iter = d2l.data_iter_consecutive(corpus_indices, batch_size, num_steps)
state = model.get_initial_state(batch_size=batch_size, dtype=tf.float32)
for X, Y in data_iter:
with tf.GradientTape(persistent=True) as tape:
(outputs, state) = model(X, state)
y = Y.T.reshape((-1, ))
l = loss(y, outputs)
grads = tape.gradient(l, model.variables)
# 梯度裁剪
grads = grad_clipping(grads, clipping_theta)
optimizer.apply_gradients(zip(grads, model.variables))
l_sum += np.array(l).item()*len(y)
n += len(y)
if (epoch + 1) % pred_period == 0:
print('epoch %d, perplexity %f, time %.2f sec' % (epoch+1, math.exp(l_sum/n), time.time()-start))
for prefix in prefixes:
print(' -', predict_rnn_keras(prefix, pred_len, model, vocab_size, idx_to_char, char_to_idx))
调用tf.keras中的layers
模块中的GRU
类:
num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
num_epochs, num_steps, batch_size, lr, clipping_theta = 160, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 40, 50, ['分开', '不分开']
gru_layer = keras.layers.GRU(units=num_hiddens,time_major=True,return_sequences=True,return_state=True)
model = RNNModel(gru_layer, vocab_size)
train_and_predict_rnn_keras(model, num_hiddens, vocab_size,
corpus_indices, idx_to_char, char_to_idx,
num_epochs, num_steps, lr, clipping_theta,
batch_size, pred_period, pred_len, prefixes)
输出:
epoch 40, perplexity 102.459079, time 2.30 sec
- 分开
- 不分开
epoch 80, perplexity 25.586327, time 2.19 sec
- 分开
- 不分开
epoch 120, perplexity 39.114824, time 2.37 sec
- 分开始共渡每天都能够够不够 如果我的字萨 如果我的字膀 如果我的字膀 如果我的字膀 如果我的字膀 如果我
- 不分开始共渡每天都能够够不够 如果我的字萨 如果我的字膀 如果我的字膀 如果我的字膀 如果我的字膀 如果我