RNN MXNET Implementation

循环神经网络

本节将介绍循环神经网络。它并非刚性地记忆所有固定长度的序列,而是通过隐藏状态来储存之前时间步的信息。首先我们回忆一下前面介绍过的多层感知机,然后描述如何添加隐藏状态来将它变成循环神经网络。

不含隐藏状态的神经网络

让我们考虑一个单隐藏层的多层感知机。给定样本数为 $n$、输入个数(特征数或特征向量维度)为 $d$ 的小批量数据样本 $\boldsymbol{X} \in \mathbb{R}^{n \times d}$。设隐藏层的激活函数为 $\phi$,那么隐藏层的输出 $\boldsymbol{H} \in \mathbb{R}^{n \times h}$ 计算为

其中隐藏层权重参数 $\boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h}$,隐藏层偏差参数 $\boldsymbol{b}_h \in \mathbb{R}^{1 \times h}$,$h$ 为隐藏单元个数。上式相加的两项形状不同,因此将按照广播机制相加。把隐藏变量 $\boldsymbol{H}$ 作为输出层的输入,且设输出个数为 $q$(例如分类问题中的类别数),输出层的输出为

其中输出变量 $\boldsymbol{O} \in \mathbb{R}^{n \times q}$, 输出层权重参数 $\boldsymbol{W}_{hq} \in \mathbb{R}^{h \times q}$, 输出层偏差参数 $\boldsymbol{b}_q \in \mathbb{R}^{1 \times q}$。如果是分类问题,我们可以使用 $\text{softmax}(\boldsymbol{O})$ 来计算输出类别的概率分布。

含隐藏状态的循环神经网络

现在我们考虑输入数据存在时间相关性的情况。假设 $\boldsymbol{X}_t \in \mathbb{R}^{n \times d}$ 是序列中时间步 $t$ 的小批量输入,$\boldsymbol{H}_t \in \mathbb{R}^{n \times h}$ 是该时间步的隐藏层变量。跟多层感知机不同的是,这里我们保存上一时间步的隐藏变量 $\boldsymbol{H}_{t-1}$,并引入一个新的权重参数 $\boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h}$,该参数用来描述在当前时间步如何使用上一时间步的隐藏变量。具体来说,当前时间步的隐藏变量的计算由当前时间步的输入和上一时间步的隐藏变量共同决定:

与多层感知机相比,我们在这里添加了 $\boldsymbol{H}_{t-1} \boldsymbol{W}_{hh}$ 一项。由上式中相邻时间步的隐藏变量 $\boldsymbol{H}_t$ 和 $\boldsymbol{H}_{t-1}$ 之间的关系可知,这里的隐藏变量捕捉了截至当前时间步的序列的历史信息,就像是神经网络当前时间步的状态或记忆一样。因此,该隐藏变量也称为隐藏状态。由于隐藏状态在当前时间步的定义使用了它在上一时间步相同的定义,上式的计算是循环的。使用循环计算的网络即循环神经网络。

循环神经网络有很多种不同的构造方法。含上式所定义的隐藏状态的循环神经网络是极为常见的一种。如无特别说明,本章中的循环神经网络基于上式中隐藏状态的循环计算。在时间步 $t$,输出层的输出和多层感知机中的计算类似:

循环神经网络的参数包括隐藏层的权重 $\boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h}, \boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h}$ 和偏差 $\boldsymbol{b}_h \in \mathbb{R}^{1 \times h}$,以及输出层的权重 $\boldsymbol{W}_{hq} \in \mathbb{R}^{h \times q}$ 和偏差 $\boldsymbol{b}_q \in \mathbb{R}^{1 \times q}$。值得一提的是,即便在不同时间步,循环神经网络始终使用这些模型参数。因此,循环神经网络模型参数的数量不随时间步的递增而增长。

图 6.1 展示了循环神经网络在三个相邻时间步的计算逻辑。在时间步 $t$,隐藏状态的计算可以看成是将输入 $\boldsymbol{X}_t$ 和前一时间步隐藏状态 $\boldsymbol{H}_{t-1}$ 连结后输入一个激活函数为 $\phi$ 的全连接层。该全连接层的输出就是当前时间步的隐藏状态 $\boldsymbol{H}_t$,且模型参数为 $\boldsymbol{W}_{xh}$ 与 $\boldsymbol{W}_{hh}$ 的连结,偏差为 $\boldsymbol{b}_h$。当前时间步 $t$ 的隐藏状态 $\boldsymbol{H}_t$ 将参与下一个时间步 $t+1$ 的隐藏状态 $\boldsymbol{H}_{t+1}$ 的计算,并输入到当前时间步的全连接输出层。

含隐藏状态的循环神经网络。

我们刚刚提到,隐藏状态中 $\boldsymbol{X}_t \boldsymbol{W}_{xh} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hh}$ 的计算等价于 $\boldsymbol{X}_t$ 与 $\boldsymbol{H}_{t-1}$ 连结后的矩阵乘以 $\boldsymbol{W}_{xh}$ 与 $\boldsymbol{W}_{hh}$ 连结后的矩阵。接下来,我们用一个具体的例子来验证这一点。首先,我们构造矩阵XW\_xhHW\_hh,它们的形状分别为(3,1)、(1,4)、(3,2)和(2,4)。将XW\_xhHW\_hh分别相乘,再把两个相乘的结果相加,得到形状为(3,4)的矩阵。

1
2
3
4
5
6
from mxnet import nd, autograd
import random
import zipfile
from mxnet.gluon import loss as gloss
import time
import math

读取数据集

1
2
3
4
with zipfile.ZipFile("jaychou_lyrics.txt.zip") as zin:
for file in zin.namelist():
with zin.open(file) as f:
corpus_chars = f.read().decode('utf-8')
1
2
3
corpus_chars = corpus_chars.replace('\n',' ').replace('\r',' ')
corpus_chars = corpus_chars[:10000]
corpus_chars[:40]
'想要有直升机 想要和你飞到宇宙去 想要和你融化在一起 融化在宇宙里 我每天每天每'

建立字符索引

1
2
3
4
index_to_char = list(set(corpus_chars))
vocab_size = len(index_to_char)
char_to_index = {char:i for i,char in enumerate(index_to_char)}
vocab_size
1027
1
2
3
4
indices_corpus = [char_to_index[char] for char in corpus_chars]
sample = indices_corpus[:20]
print("chars: {}".format("".join(index_to_char[idx] for idx in sample)))
print("index: {}".format(sample))
chars: 想要有直升机 想要和你飞到宇宙去 想要和
index: [61, 344, 208, 730, 798, 864, 723, 61, 344, 900, 763, 490, 826, 188, 217, 762, 723, 61, 344, 900]

时序数据的采样

  1. 随机采样
  2. 相邻采样

随机采样

  • 批量大小 batch_size指每个小批量的样本数,num_steps为每个样本所包含的时间步数。
  • 在随机采样中,每个样本是原始序列上任意截 取的一段序列。相邻的两个随机小批量在原始序列上的位置不一定相毗邻。因此,我们无法用一 个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态。
  • 在训练模型时,每次随机采样前都需要重新初始化隐藏状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def data_iter_random(indices_corpus, batch_size, num_steps, ctx=None):
num_example = (len(indices_corpus) - 1) // num_steps ## 总共有多少个样本
epoch_size = num_example // batch_size ## 每个 epoch 有多少个小批量
example_indices = list(range(num_example)) ## 样本的索引
random.shuffle(example_indices)

def _data(pos):
return indices_corpus[pos : pos + num_steps]

for i in range(epoch_size):
# 每次生成 batch_size 个随机样本
batch_start = i * batch_size
batch_indices = example_indices[batch_start:batch_start + batch_size] ## 小批量的样本索引
X = [_data( j * num_steps ) for j in batch_indices]
Y = [_data( j * num_steps + 1) for j in batch_indices]

yield nd.array(X, ctx), nd.array(Y, ctx)
1
2
3
my_seq = list(range(30))
for X, Y in data_iter_random(my_seq, batch_size=2, num_steps=6):
print('X: ', X, '\nY:', Y, '\n')
X:  
[[ 0.  1.  2.  3.  4.  5.]
 [12. 13. 14. 15. 16. 17.]]
<NDArray 2x6 @cpu(0)> 
Y: 
[[ 1.  2.  3.  4.  5.  6.]
 [13. 14. 15. 16. 17. 18.]]
<NDArray 2x6 @cpu(0)> 

X:  
[[ 6.  7.  8.  9. 10. 11.]
 [18. 19. 20. 21. 22. 23.]]
<NDArray 2x6 @cpu(0)> 
Y: 
[[ 7.  8.  9. 10. 11. 12.]
 [19. 20. 21. 22. 23. 24.]]
<NDArray 2x6 @cpu(0)> 

相邻采样

除了对原始序列做随机采样之外,我们还可以令相邻的两个随机小批量在原始序列上的位置相毗邻。此时,我们就可以用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态, 从而使下一个小批量的输出也取决于当前小批量的输入,并如此循环下去。这对实现循环神经网络造成了两方面影响。一方面,在训练模型时,我们只需在每一个迭代周期开始时初始化隐藏状态。另一方面,当多个相邻小批量通过传递隐藏状态串联起来时,模型参数的梯度计算将依赖所有串联起来的小批量序列。同一迭代周期中,随着迭代次数的增加,梯度的计算开销会越来越大。 为了使模型参数的梯度计算只依赖一次迭代读取的小批量序列,我们可以在每次读取小批量前将隐藏状态从计算图分离出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def data_iter_consecutive(corpus_indices, batch_size, num_steps, ctx=None):
corpus_indices = nd.array(corpus_indices)
data_len = len(corpus_indices)
batch_len = data_len // batch_size

indices = corpus_indices[0: batch_size*batch_len].reshape((
batch_size, batch_len))

epoch_size = (batch_len - 1) // num_steps
for i in range(epoch_size):
i = i * num_steps
X = indices[:, i: i + num_steps]
Y = indices[:, i + 1: i + num_steps + 1]
yield X, Y
1
2
for X, Y in data_iter_consecutive(my_seq, batch_size=2, num_steps=6): 
print('X: ', X, '\nY:', Y, '\n')
X:  
[[ 0.  1.  2.  3.  4.  5.]
 [15. 16. 17. 18. 19. 20.]]
<NDArray 2x6 @cpu(0)> 
Y: 
[[ 1.  2.  3.  4.  5.  6.]
 [16. 17. 18. 19. 20. 21.]]
<NDArray 2x6 @cpu(0)> 

X:  
[[ 6.  7.  8.  9. 10. 11.]
 [21. 22. 23. 24. 25. 26.]]
<NDArray 2x6 @cpu(0)> 
Y: 
[[ 7.  8.  9. 10. 11. 12.]
 [22. 23. 24. 25. 26. 27.]]
<NDArray 2x6 @cpu(0)> 

循环神经网络的从零开始实现

One hot encoding

1
2
3
4
5
def to_onehot(X,size):
return [nd.one_hot(x,size) for x in X.T]
X = nd.arange(10).reshape((2, 5))
inputs = to_onehot(X, vocab_size)
len(inputs), inputs[0].shape # time_step = 5,batch_size = 2, features = 2582
(5, (2, 1027))

初始化模型参数

  • num_hiddens 是hidden cell的差参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
ctx = None

def get_params():
def _one(shape):
return nd.random.normal(scale=0.01, shape=shape, ctx=ctx)

# 隐藏层参数
W_xh = _one((num_inputs, num_hiddens))
W_hh = _one((num_hiddens, num_hiddens))
b_h = nd.zeros(num_hiddens, ctx=ctx)

# 输出层参数
W_hq = _one((num_hiddens, num_outputs))
b_q = nd.zeros(num_outputs, ctx=ctx)

# 附上梯度
params = [W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.attach_grad()

return params

定义模型

1
2
3
4
5
6
7
8
9
10
11
12
def init_rnn_state(batch_size, num_hiddens, ctx):
return (nd.zeros(shape=(batch_size, num_hiddens), ctx=ctx), )

def rnn(inputs, state, params):
W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
for X in inputs:
H = nd.tanh(nd.dot(X, W_xh) + nd.dot(H, W_hh) + b_h)
Y = nd.dot(H, W_hq) + b_q
outputs.append(Y)
return outputs, (H,)
1
2
3
4
state = init_rnn_state(X.shape[0], num_hiddens, ctx = None)
inputs = to_onehot(X, vocab_size)
params = get_params()
outputs, state_new = rnn(inputs, state, params)
1
len(outputs), outputs[0].shape, state_new[0].shape
(5, (2, 1027), (2, 256))

定义预测函数

以下函数基于前缀 prefix(含有数个字符的字符串)来预测接下来的 num_chars个字符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def predict_rnn(prefix, num_chars, rnn, params, init_rnn_state,
num_hiddens, vocab_size, ctx, idx_to_char, char_to_idx):
state = init_rnn_state(1, num_hiddens, ctx)
output = [char_to_idx[prefix[0]]]

for t in range(num_chars + len(prefix) - 1):
# 将上一时间步的输出作为当前时间步的输入。
X = to_onehot(nd.array([output[-1]], ctx=ctx), vocab_size)
(Y, state) = rnn(X, state, params)
if t < len(prefix) - 1:
output.append(char_to_idx[prefix[t + 1]])
else:
output.append(int(Y[0].argmax(axis=1).asscalar()))

return ''.join([idx_to_char[i] for i in output])
1
predict_rnn('分开', 10, rnn, params, init_rnn_state, num_hiddens, vocab_size, ctx, index_to_char, char_to_index)
'分开微寄载术苏沟及消等双'

梯度剪裁

1
2
3
4
5
6
7
8
def grad_clipping(params, theta, ctx = None):
norm = nd.array([0.0], ctx)
for param in params:
norm += (param.grad ** 2).sum()
norm = norm.sqrt().asscalar()
if norm > theta:
for param in params:
param.grad[:] *= theta / norm

梯度下降

1
2
3
def sgd(params, lr, batch_size): # 本函数已保存在 gluonbook 包中方便以后使用。 
for param in params:
param[:] = param - lr * param.grad / batch_size

整合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
vocab_size, corpus_indices, idx_to_char,
char_to_idx, is_random_iter, num_epochs, num_steps,
lr, clipping_theta, batch_size, pred_period,
pred_len, prefixes, ctx = None):
if is_random_iter:
data_iter_fn = data_iter_random
else:
data_iter_fn = data_iter_consecutive

params = get_params()
loss = gloss.SoftmaxCrossEntropyLoss()

for epoch in range(num_epochs):
if not is_random_iter: # 如使用相邻采样,在 epoch 开始时初始化隐藏状态。
state = init_rnn_state(batch_size, num_hiddens, ctx)

loss_sum, start = 0.0, time.time()
data_iter = data_iter_fn(indices_corpus, batch_size, num_steps, ctx)
for t, (X, Y) in enumerate(data_iter):
if is_random_iter: # 如使用随机采样,在每个小批量更新前初始化隐藏状态。
state = init_rnn_state(batch_size, num_hiddens, ctx)
else:
# 否则需要使用 detach 函数从计算图分离隐藏状态
for s in state:
s.detach()

with autograd.record():
inputs = to_onehot(X, vocab_size)
# outputs 有 num_steps 个形状为(batch_size,vocab_size)的矩阵。
(outputs, state) = rnn(inputs, state, params)
# 拼接之后形状为(num_steps * batch_size,vocab_size)。
outputs = nd.concat(*outputs, dim=0)
# Y 的形状是(batch_size,num_steps),转置后再变成⻓度为
# batch * num_steps 的向量,这样跟输出的行一一对应。
y = Y.T.reshape((-1,))
# 使用交叉熵损失计算平均分类误差。
l = loss(outputs, y).mean()
l.backward()
grad_clipping(params, clipping_theta, ctx) # 裁剪梯度。 gb.sgd(params, lr, 1) # 因为误差已经取过均值,梯度不用再做平均。 loss_sum += l.asscalar()
sgd(params, lr, 1) # 因为误差已经取过均值,梯度不用再做平均。
loss_sum += l.asscalar()

if (epoch + 1) % pred_period == 0:
print('epoch %d, perplexity %f, time %.2f sec' % (epoch + 1, math.exp(loss_sum / (t + 1)), time.time() - start))
for prefix in prefixes:
print(' -', predict_rnn(prefix, pred_len, rnn, params, init_rnn_state, num_hiddens, vocab_size, ctx, idx_to_char, char_to_idx))
1
2
3
4
5
6
7
8
num_epochs, num_steps, batch_size, lr, clipping_theta = 200, 35, 32, 1e2, 1e-2 
pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
vocab_size, indices_corpus, index_to_char,
char_to_index, False, num_epochs, num_steps,
lr, clipping_theta, batch_size, pred_period,
pred_len, prefixes, ctx = None)
epoch 50, perplexity 64.202943, time 1.44 sec
 - 分开 我想你这你 我不的你 我有你这 我有了双 我有一空 我有一空 我有一空 我有一空 我有一空 我有一
 - 不分开 我想你这你 我不的你 我有你这 我有一直 我有一空 我有一空 我有一空 我有一空 我有一空 我有一
epoch 100, perplexity 7.041272, time 1.50 sec
 - 分开 我想要这样牵着你的手 它在灌木丛旁折的 像入再里我想要 却你现没有经怎么  说有么 快我的没像错怎
 - 不分开 我不要再可牵我 你一 我有你的话爱 我想 这对了义了吧 说通 你想很久了吧 说真 你想很久了吧 说
epoch 150, perplexity 2.039475, time 1.48 sec
 - 分开 我想要再想坦 有你你被默默 一亮都掉 我跟得好节奏 后知后觉 又过了一个秋 后知后觉 快使用双截棍
 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生
epoch 200, perplexity 1.279734, time 1.47 sec
 - 分开 问候的风我 谁 在么胸 三经空 停么我遇着你说 满为了动 在来马步 硬底的功 全面裂纵 恨一作痛 
 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又知了一个秋 后知后觉 我该好好生活 我该好好生
1
2


Donate article here