本节我们通过使用MXnet,来从零开始的实现一个含有隐藏状态的循环神经网络。
前序工作
- 数据集预处理
- 进行采样
实现循环神经网络
完成前序工作后,即可开始实现循环神经网络。本文首先构建一个具有隐状态的循环神经网络。其结构如图所示:
接下来,我们一边讲解循环神经网络的结构,一边构建循环神经网络。
首先需要引入使用到的库,并读取数据集,设置批量大小为32,时间步为35,通过前文(前序工作中的第二项)的加载数据集的函数对数据集进行读取,并完成采样:
%matplotlib inline
import math
from mxnet import autograd, gluon, np, npx
from d2l import mxnet as d2lnpx.set_np()batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
独热编码
将数字索引直接进行训练会使模型训练十分困难,因此我们使用独热编码(one-hot encoding)将训练集的数据进行转换——假设词元表中不同的词元共有N个,则生成一个长度为N的数组,将数组的对应位置设为1,其他位置均为零,这样做可以更好的展现数据的特征。
每次采样的数据形状是(批量大小,时间步数),通过独热编码,我们希望我们构建的训练数据的形状为(时间步数,批量大小,词表大小),即num_steps个,批量大小x词表大小的二维数组。
所以我们需要先对输入X进行转置操作,随后进行独热编码,具体操作如下:
npx.one_hot(X.T, len(vocab))
初始化模型参数
我们直接使用如下的get_params函数来初始化模型参数,其中vocab_size表示词表大小,num_hiddens为超参数,表示隐藏层的大小,device为规定使用cpu运算还是gpu运算。
注:经过作者检验,此处RNN的训练是否使用GPU运算不太重要,都很快!
def get_params(vocab_size, num_hiddens, device):num_inputs = num_outputs = vocab_sizedef normal(shape):return np.random.normal(scale=0.01, size=shape, ctx=device)# 隐藏层参数W_xh = normal((num_inputs, num_hiddens))W_hh = normal((num_hiddens, num_hiddens))b_h = np.zeros(num_hiddens, ctx=device)# 输出层参数W_hq = normal((num_hiddens, num_outputs))b_q = np.zeros(num_outputs, ctx=device)# 附加梯度params = [W_xh, W_hh, b_h, W_hq, b_q]for param in params:param.attach_grad()return params
内部函数normal对所有参数进行随机的初始化,在隐蔽层参数中,分别为隐藏层的权重和偏差,表示输出层的权重和偏差,将这些参数全部附上梯度。
循环神经网络模型
在定义循环神经网络模型之前,我们还需要定义一个函数来在初始化时返回隐状态。这里使用语法使得返回的隐状态是一个元组,隐状态的大小为(批量大小,隐藏单元个数)。
def init_rnn_state(batch_size, num_hiddens, device):return (np.zeros((batch_size, num_hiddens), ctx=device), )
现在,我们来开始定义循环神经网络。
我们可以将循环神经网络看成是一个类似于单隐层多层感知机的结构,隐藏状态类似于多层感知机的隐层。先将输入X进行处理后形成一个新的隐藏状态,然后将输入的旧的隐藏状态进行处理产生另一个新的隐藏状态,将两个新的隐藏状态相加,得到当前时间步的隐藏状态。之后通过矩阵乘法处理隐藏状态进行输出。最后,将输出与状态进行连结。
def rnn(inputs, state, params):# inputs的形状:(时间步数量,批量大小,词表大小)W_xh, W_hh, b_h, W_hq, b_q = paramsH, = stateoutputs = []# X的形状:(批量大小,词表大小)for X in inputs:H = np.tanh(np.dot(X, W_xh) + np.dot(H, W_hh) + b_h)Y = np.dot(H, W_hq) + b_qoutputs.append(Y)return np.concatenate(outputs, axis=0), (H,)
注:在对这个代码块进行学习时,我曾产生一个疑惑,通过concatenate来连接隐状态和输出,岂不是隐状态的大小越来越大了吗?但是事实上我发现,H在for-each循环的第一步进行了一下更新,使得H相当于重新进行了初始化,这样做,每个时间步新输出的H将与输入的H具有同样大小。
那么接下来,我们创建一个类对上述函数进行包装。包装完成后,即可来定义一个该类的神经网络net,后面将利用net来完成前向计算。
class RNNModelScratch: #@save"""从零开始实现的循环神经网络模型"""def __init__(self, vocab_size, num_hiddens, device, get_params,init_state, forward_fn):self.vocab_size, self.num_hiddens = vocab_size, num_hiddensself.params = get_params(vocab_size, num_hiddens, device)self.init_state, self.forward_fn = init_state, forward_fndef __call__(self, X, state):X = npx.one_hot(X.T, self.vocab_size)return self.forward_fn(X, state, self.params)def begin_state(self, batch_size, ctx):return self.init_state(batch_size, self.num_hiddens, ctx)num_hiddens = 512
net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,init_rnn_state, rnn)
预测
使用predict_ch8这一函数来生成prefix之后的新字符,prefix为用户输入的一个具有若干连续字符的字符串,通过这一阶段来对隐状态H进行一定程度的更新,因此,这一状态被称为预热期(warm-up)。
def predict_ch8(prefix, num_preds, net, vocab, device): #@save"""在prefix后面生成新字符"""state = net.begin_state(batch_size=1, ctx=device)outputs = [vocab[prefix[0]]]get_input = lambda: np.array([outputs[-1]], ctx=device).reshape((1, 1))for y in prefix[1:]: # 预热期_, state = net(get_input(), state)outputs.append(vocab[y])for _ in range(num_preds): # 预测num_preds步y, state = net(get_input(), state)outputs.append(int(y.argmax(axis=1).reshape(1)))return ''.join([vocab.idx_to_token[i] for i in outputs])
梯度裁剪
使用梯度裁剪主要是为了缓解梯度爆炸或梯度消失,关于梯度裁剪的具体数学原理,这里暂时不做讨论,但使用梯度裁剪在RNN中是必不可少的。在更新模型参数之前裁剪梯度,即使训练过程中某个点上发生了梯度爆炸,也能保证模型不会发散。
def grad_clipping(net, theta): #@saveif isinstance(net, gluon.Block):params = [p.data() for p in net.collect_params().values()]else:params = net.paramsnorm = math.sqrt(sum((p.grad ** 2).sum() for p in params))if norm > theta:for param in params:param.grad[:] *= theta / norm
训练
经过预热后,我们现在可以开始训练循环神经网络模型了。在代码之前,我们首先对使用到的各种信息进行说明,便于大家进行了解。
参数说明
- train_iter:训练集的迭代器,返回每个训练样本。
- vocab:词表,返回用到的词及其对应的编号id。
- lr:学习率,一个超参数。
- num_epochs:学习轮数,一个超参数。
- device:使用的计算设备。
方法说明
- SoftmaxCrossEntropyLoss:gluon自带的交叉熵损失函数。
- Animator:使训练结果更加可视化的方法。
def train_ch8(net, train_iter, vocab, lr, num_epochs, device, #@saveuse_random_iter=False):loss = gluon.loss.SoftmaxCrossEntropyLoss()animator = d2l.Animator(xlabel='epoch', ylabel='perplexity',legend=['train'], xlim=[10, num_epochs])# 初始化if isinstance(net, gluon.Block):net.initialize(ctx=device, force_reinit=True,init=init.Normal(0.01))trainer = gluon.Trainer(net.collect_params(),'sgd', {'learning_rate': lr})updater = lambda batch_size: trainer.step(batch_size)else:updater = lambda batch_size: d2l.sgd(net.params, lr, batch_size)predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)# 训练和预测for epoch in range(num_epochs):ppl, speed = train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter)if (epoch + 1) % 10 == 0:animator.add(epoch + 1, [ppl])print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')print(predict('time traveller'))print(predict('traveller'))
其中的train_epoch_ch8()函数为每一轮的训练过程。先进行前向计算得到预测值,之后通过反向计算来更新权重、偏差,这些参数值。
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):state, timer = None, d2l.Timer()metric = d2l.Accumulator(2) # 训练损失之和,词元数量for X, Y in train_iter:if state is None or use_random_iter:# 在第一次迭代或使用随机抽样时初始化statestate = net.begin_state(batch_size=X.shape[0], ctx=device)else:for s in state:s.detach()y = Y.T.reshape(-1)X, y = X.as_in_ctx(device), y.as_in_ctx(device)with autograd.record():y_hat, state = net(X, state)l = loss(y_hat, y).mean()l.backward()grad_clipping(net, 1)updater(batch_size=1) # 因为已经调用了mean函数metric.add(l * d2l.size(y), d2l.size(y))return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()
通过以下示例来观察训练的结果:
num_epochs, lr = 500, 1
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu())
困惑度 1.0, 23960.0 词元/秒 gpu(0) time traveller for so it will be convenient to speak of himwas e travelleryou can show black is white by argument said filby
简洁实现循环神经网络
这里,我们直接使用mxnet内的RNN类,用这些高级api完成RNN的计算。如果对RNN的原理不感兴趣,只是需要使用RNN的话,可以直接阅读和使用这一部分的代码。
num_hiddens = 256
rnn_layer = rnn.RNN(num_hiddens)
rnn_layer.initialize()
state = rnn_layer.begin_state(batch_size=batch_size)
class RNNModel(nn.Block):def __init__(self, rnn_layer, vocab_size, **kwargs):super(RNNModel, self).__init__(**kwargs)self.rnn = rnn_layerself.vocab_size = vocab_sizeself.dense = nn.Dense(vocab_size)def forward(self, inputs, state):X = npx.one_hot(inputs.T, self.vocab_size)Y, state = self.rnn(X, state)output = self.dense(Y.reshape(-1, Y.shape[-1]))return output, statedef begin_state(self, *args, **kwargs):return self.rnn.begin_state(*args, **kwargs)device = d2l.try_gpu()
net = RNNModel(rnn_layer, len(vocab))
net.initialize(force_reinit=True, ctx=device)
num_epochs, lr = 500, 1
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)
训练结果如下:
perplexity 1.2, 144941.9 tokens/sec on gpu(0) time travellerit s against reason said filby of course a solid b travelleryou can show black is whine be move the endled the