门控循环单元(GRU)— 从0开始

上一节中,我们介绍了循环神经网络中的梯度计算方法。我们发现,循环神经网络的隐含层变量梯度可能会出现衰减或爆炸。虽然梯度裁剪可以应对梯度爆炸,但无法解决梯度衰减的问题。因此,给定一个时间序列,例如文本序列,循环神经网络在实际中其实较难捕捉两个时刻距离较大的文本元素(字或词)之间的依赖关系。

门控循环神经网络(gated recurrent neural networks)的提出,是为了更好地捕捉时序数据中间隔较大的依赖关系。其中,门控循环单元(gated recurrent unit,简称GRU)是一种常用的门控循环神经网络。它由Cho、van Merrienboer、 Bahdanau和Bengio在2014年被提出。

门控循环单元

我们先介绍门控循环单元的构造。它比循环神经网络中的隐含层构造稍复杂一点。

重置门和更新门

门控循环单元的隐含状态只包含隐含层变量\(\mathbf{H}\)。假定隐含状态长度为\(h\),给定时刻\(t\)的一个样本数为\(n\)特征向量维度为\(x\)的批量数据\(\mathbf{X}_t \in \mathbb{R}^{n \times x}\)和上一时刻隐含状态\(\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}\),重置门(reset gate)\(\mathbf{R}_t \in \mathbb{R}^{n \times h}\)和更新门(update gate)\(\mathbf{Z}_t \in \mathbb{R}^{n \times h}\)的定义如下:

\[\mathbf{R}_t = \sigma(\mathbf{X}_t \mathbf{W}_{xr} + \mathbf{H}_{t-1} \mathbf{W}_{hr} + \mathbf{b}_r)\]
\[\mathbf{Z}_t = \sigma(\mathbf{X}_t \mathbf{W}_{xz} + \mathbf{H}_{t-1} \mathbf{W}_{hz} + \mathbf{b}_z)\]

其中的\(\mathbf{W}_{xr}, \mathbf{W}_{xz} \in \mathbb{R}^{x \times h}\)\(\mathbf{W}_{hr}, \mathbf{W}_{hz} \in \mathbb{R}^{h \times h}\)是可学习的权重参数,\(\mathbf{b}_r, \mathbf{b}_z \in \mathbb{R}^{1 \times h}\)是可学习的偏移参数。函数\(\sigma\)自变量中的三项相加使用了广播

需要注意的是,重置门和更新门使用了值域为\([0, 1]\)的函数\(\sigma(x) = 1/(1+\text{exp}(-x))\)。因此,重置门\(\mathbf{R}_t\)和更新门\(\mathbf{Z}_t\)中每个元素的值域都是\([0, 1]\)

候选隐含状态

我们可以通过元素值域在\([0, 1]\)的更新门和重置门来控制隐含状态中信息的流动:这通常可以应用按元素乘法符\(\odot\)。门控循环单元中的候选隐含状态\(\tilde{\mathbf{H}}_t \in \mathbb{R}^{n \times h}\)使用了值域在\([-1, 1]\)的双曲正切函数tanh做激活函数:

\[\tilde{\mathbf{H}}_t = \text{tanh}(\mathbf{X}_t \mathbf{W}_{xh} + \mathbf{R}_t \odot \mathbf{H}_{t-1} \mathbf{W}_{hh} + \mathbf{b}_h)\]

其中的\(\mathbf{W}_{xh} \in \mathbb{R}^{x \times h}\)\(\mathbf{W}_{hh} \in \mathbb{R}^{h \times h}\)是可学习的权重参数,\(\mathbf{b}_h \in \mathbb{R}^{1 \times h}\)是可学习的偏移参数。

需要注意的是,候选隐含状态使用了重置门来控制包含过去时刻信息的上一个隐含状态的流入。如果重置门近似0,上一个隐含状态将被丢弃。因此,重置门提供了丢弃与未来无关的过去隐含状态的机制。

隐含状态

隐含状态\(\mathbf{H}_t \in \mathbb{R}^{n \times h}\)的计算使用更新门\(\mathbf{Z}_t\)来对上一时刻的隐含状态\(\mathbf{H}_{t-1}\)和当前时刻的候选隐含状态\(\tilde{\mathbf{H}}_t\)做组合,公式如下:

\[\mathbf{H}_t = \mathbf{Z}_t \odot \mathbf{H}_{t-1} + (1 - \mathbf{Z}_t) \odot \tilde{\mathbf{H}}_t\]

需要注意的是,更新门可以控制过去的隐含状态在当前时刻的重要性。如果更新门一直近似1,过去的隐含状态将一直通过时间保存并传递至当前时刻。这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时序数据中间隔较大的依赖关系。

我们对门控循环单元的设计稍作总结:

  • 重置门有助于捕捉时序数据中短期的依赖关系。
  • 更新门有助于捕捉时序数据中长期的依赖关系。

输出层的设计可参照循环神经网络中的描述。

实验

为了实现并展示门控循环单元,我们依然使用周杰伦歌词数据集来训练模型作词。这里除门控循环单元以外的实现已在循环神经网络中介绍。

数据处理

我们先读取并对数据集做简单处理。

In [1]:
import zipfile
with zipfile.ZipFile('../data/jaychou_lyrics.txt.zip', 'r') as zin:
    zin.extractall('../data/')

with open('../data/jaychou_lyrics.txt') as f:
    corpus_chars = f.read()

corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
corpus_chars = corpus_chars[0:20000]

idx_to_char = list(set(corpus_chars))
char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])
corpus_indices = [char_to_idx[char] for char in corpus_chars]

vocab_size = len(char_to_idx)
print('vocab size:', vocab_size)
vocab size: 1465

我们使用onehot来将字符索引表示成向量。

In [2]:
def get_inputs(data):
    return [nd.one_hot(X, vocab_size) for X in data.T]

初始化模型参数

以下部分对模型参数进行初始化。参数hidden_dim定义了隐含状态的长度。

In [3]:
import mxnet as mx

# 尝试使用GPU
import sys
sys.path.append('..')
from mxnet import nd
import utils
ctx = utils.try_gpu()
print('Will use', ctx)

input_dim = vocab_size
# 隐含状态长度
hidden_dim = 256
output_dim = vocab_size
std = .01

def get_params():
    # 隐含层
    W_xz = nd.random_normal(scale=std, shape=(input_dim, hidden_dim), ctx=ctx)
    W_hz = nd.random_normal(scale=std, shape=(hidden_dim, hidden_dim), ctx=ctx)
    b_z = nd.zeros(hidden_dim, ctx=ctx)

    W_xr = nd.random_normal(scale=std, shape=(input_dim, hidden_dim), ctx=ctx)
    W_hr = nd.random_normal(scale=std, shape=(hidden_dim, hidden_dim), ctx=ctx)
    b_r = nd.zeros(hidden_dim, ctx=ctx)

    W_xh = nd.random_normal(scale=std, shape=(input_dim, hidden_dim), ctx=ctx)
    W_hh = nd.random_normal(scale=std, shape=(hidden_dim, hidden_dim), ctx=ctx)
    b_h = nd.zeros(hidden_dim, ctx=ctx)

    # 输出层
    W_hy = nd.random_normal(scale=std, shape=(hidden_dim, output_dim), ctx=ctx)
    b_y = nd.zeros(output_dim, ctx=ctx)

    params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hy, b_y]
    for param in params:
        param.attach_grad()
    return params
Will use gpu(0)

定义模型

我们将前面的模型公式翻译成代码。

In [4]:
def gru_rnn(inputs, H, *params):
    # inputs: num_steps 个尺寸为 batch_size * vocab_size 矩阵
    # H: 尺寸为 batch_size * hidden_dim 矩阵
    # outputs: num_steps 个尺寸为 batch_size * vocab_size 矩阵
    W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hy, b_y = params
    outputs = []
    for X in inputs:
        Z = nd.sigmoid(nd.dot(X, W_xz) + nd.dot(H, W_hz) + b_z)
        R = nd.sigmoid(nd.dot(X, W_xr) + nd.dot(H, W_hr) + b_r)
        H_tilda = nd.tanh(nd.dot(X, W_xh) + R * nd.dot(H, W_hh) + b_h)
        H = Z * H + (1 - Z) * H_tilda
        Y = nd.dot(H, W_hy) + b_y
        outputs.append(Y)
    return (outputs, H)

训练模型

下面我们开始训练模型。我们假定谱写歌词的前缀分别为“分开”、“不分开”和“战争中部队”。这里采用的是相邻批量采样实验门控循环单元谱写歌词。

In [5]:
seq1 = '分开'
seq2 = '不分开'
seq3 = '战争中部队'
seqs = [seq1, seq2, seq3]

utils.train_and_predict_rnn(rnn=gru_rnn, is_random_iter=False, epochs=200,
                            num_steps=35, hidden_dim=hidden_dim,
                            learning_rate=0.2, clipping_norm=5,
                            batch_size=32, pred_period=20, pred_len=100,
                            seqs=seqs, get_params=get_params,
                            get_inputs=get_inputs, ctx=ctx,
                            corpus_indices=corpus_indices,
                            idx_to_char=idx_to_char, char_to_idx=char_to_idx)
Epoch 20. Training perplexity 272.648174
 -  分开 我不的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我
 -  不分开 我不的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我
 -  战争中部队 我不的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我的我

Epoch 40. Training perplexity 106.345046
 -  分开 我想要你想离 我想要你想离 我想要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不
 -  不分开 我想要你想离我 想不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我
 -  战争中部队 我想要你想开 我想要你想离 我想要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不要我想想 我不

Epoch 60. Training perplexity 28.353978
 -  分开 不想开了我离开 我不要再想 我不 我不 我不能 我不能 我不了我想要你 我不 我不 我不能再想我的微景我怎么我不能 我不能再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想
 -  不分开了 我想要你的微笑 我想你的想知在我 想要你的爱你在我的想 我不能再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想
 -  战争中部队 我想要你的世笑 我想你的想知在我 想要你的爱你在我的想 我不能再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想

Epoch 80. Training perplexity 7.941160
 -  分开了天 这时间的爱情好像顶不住那时间 为什么这样子 你看着我 说你说的话有 我说你的话 有一种味道叫做家 陆羽泡的茶 像幅泼墨的山水画 唐朝的让我面动的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的
 -  不分开 分水时间 不知不觉 说你就是我 有些球 剩不是痛 这不是痛 没人帮怎你走才会乐 你没有这样活 我该好好生活 我该好好生活 我该好好生活 我该好好生活 我该好好生活 我该好好生活 我该好好生活 我该好好
 -  战争中部队 这样的手你已坠入 那首来重东欧的民谣 一切又重重 一直一直我 学于它的脸 用一种墨 一人黄重 在人村不安 仙人就是你走那乐 没人帮着我 谁说球 别人中中 过去了空 三炭在一场秋 放过去 一直走 我想就

Epoch 100. Training perplexity 3.065355
 -  分开了难面的人 想要说你已经很久 别想躲 说你眼睛看着我 别发抖 快给我抬起头 有话去对医药箱说 别怪我 别怪我 说你 说我很 你不是 我不能 你不开 我不了 我不能 你不开 我不了 我不能 你不开 我不了
 -  不分开 分水在战壕里决了堤 在硝烟中想起冰棒汽水的味道 就那么过去 我摇不醒你 泪水在战壕里决了堤 在盒的雨变成美了 想要一直一步往上爬 等待阳光静静看着你的脸 小轻的风 有一种味道叫做家 陆羽泡的茶 像幅泼
 -  战争中部队的叹壕 想要说你已经很久 别想躲 说你眼睛看着我 别发抖 快给我抬起头 有话去对医药箱说 别怪我 别怪我 说你 说我很 你不是 我不能 你不开 我不了 我不能 你不开 我不了 我不能 你不开 我不了 我

Epoch 120. Training perplexity 1.678666
 -  分开了天 这时间的味防我像了能看上的出口 经过的你叫你妈的想爱你 那接来中已经 真的手 如不能够不 再一场是话 有一种钟六 基伤了我 说你说 分数怎么停留 一直在停留 谁让它停留的 为什么我女朋友场加加油
 -  不分开 分影悲是你才有一个恼 睡不知道没人办说 好好 不想开始的吧家 你想在等我开开 你说啊 你怎么打我手 你说啊 是不是你不想活 说你怎么面对我 甩开球我满腔的怒火 我想揍你已经很久 别想躲 说你眼睛看着我
 -  战争中部队 当时 我想离水ㄟ鱼 这时 我想离水ㄟ鱼 放抹记 剩最后一口气 放抹记 剩最后一口气 放抹记 放抹记 过去 你迷人ㄟ气质 过去 你迷人ㄟ气质 放抹记 剩抹记 过去 你迷人ㄟ气质 过去 你迷人ㄟ气质 放抹

Epoch 140. Training perplexity 1.229818
 -  分开的屋火 让人莹的泪滴 闪烁成回忆 伤人的美丽 你的完美主义 太彻底 让我连恨都难以下笔 将真心抽离写成日记 像是一场默剧 你的完美主义 太彻底 分手的话像语言暴力 我已无能为力再提起 决定中断熟悉 然后
 -  不分开 在你的手离倒我 甩悔的爹秋早破的老生  才有回忆去号的家戏模什么 我用求臂之开 你爱着我 你爱我 开了了-- 周杰伦 如果我 说你怎么面对 你在等我的太给 你的想变倒出 又彻 我想离水ㄟ鱼 放抹记 剩
 -  战争中部队 当时 我想离水都都有一个 将不想 别人开 在一个风霜 慢来一碗热粥 配上几斤的牛肉 我说店小二 三两银够不够 景色入秋 漫天黄沙凉过 塞北的客栈人多 牧草有没有 我马儿有些瘦 天涯尽头 满脸风霜落寞

Epoch 160. Training perplexity 1.097748
 -  分开的屋面 让人莹的泪滴 闪烁成回忆 伤人的美丽 你的完美主义 太彻底 让我连恨都难以下笔 将真心抽离写成日记 像是一场默剧 你的完美主义 太彻底 分手的话像语言暴力 我已无能为力再提起 决定中断熟悉 周杰
 -  不分开就走 把手慢慢交给我 放下心中的困惑 雨点从两旁划过 割开两种精神的我 经过老伯的家 篮框变得好高 爬过的那棵树 又何时变得渺小 这样也好 开始没人注意到你我 等雨变强之前 我们将会分化软弱 趁时间没发
 -  战争中部队 这样的我带你 那它在战匙中 有谁不安解 我们过好生脉 静静悄悄默默离开 陷入了危险边缘Baby~ 我的世界已狂风暴雨 Wu~ 爱情来的太快就像龙卷风 离不开暴风圈来不及逃 我不能再想 我不能再想 我不

Epoch 180. Training perplexity 1.062706
 -  分开的屋争 让晶莹的泪滴 闪烁成回忆 伤人的美丽 你的完美主义 太彻底 让我连恨都难以下笔 将真心抽离写成日记 像是一场默剧 你的完美主义 太彻底 分手的话像语言暴力 我已无能为力再提起 决定中断熟悉 周杰
 -  不分开就走 把手慢慢交给我 放下心中的困惑 雨点从两旁划过 割开两种精神的我 经过老伯的家 篮框变得好高 爬过的那棵树 又何时变得渺小 这样也好 开始没人注意到我 等雨变强之前 我们将会分化软弱 趁时间没发觉
 -  战争中部队 我想就这样打着年的身为  为什么我爸爸 那么凶 如果真的地球 只说你却声 我摇一定来祷告 仁慈的父我已坠入 看不见罪的国度 请原谅我的自负 没人能说没人可说 好难承受 荣耀的背后刻着一道孤独 仁慈的父

Epoch 200. Training perplexity 1.046985
 -  分开我想要一直走 我没有这种天份 包容你也接受他 不用担心的太多 我会一直好好过 你已经远远离开 我也会慢慢走开 为什么我连分开都迁就着你 我真的没有天份 安静的没这么快 我会学着放弃你 是因为我太爱你 是
 -  不分开就走 把手慢慢交给我 放下心中的困惑 雨点从两旁划过 割开两种精神的我 经过老伯的家 篮框变得好高 爬过的那棵树 又何时变得渺小 这样也好 开始没人注意到你我 等雨变强之前 我们将会分化软弱 趁时间没发
 -  战争中部队 暗瞑 但ㄚ连咪的酒 我说店动主风 晒又不要我妈腔的怒火 说不 很後简懦夫 单杰伦 的话的人知道 一直看他日落 一直到我们都睡着 我想就这样牵着你的手不放开 爱能不能够永远单纯没有悲哀 我 想带你骑单车

可以看到一开始学到简单的字符,然后简单的词,接着是复杂点的词,然后看上去似乎像个句子了。

结论

  • 门控循环单元的提出是为了更好地捕捉时序数据中间隔较大的依赖关系。
  • 重置门有助于捕捉时序数据中短期的依赖关系。
  • 更新门有助于捕捉时序数据中长期的依赖关系。

练习

  • 调调参数(例如数据集大小、序列长度、隐含状态长度和学习率),看看对运行时间、perplexity和预测的结果造成的影响。
  • 在相同条件下,比较门控循环单元和循环神经网络的运行效率。

吐槽和讨论欢迎点这里