动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)


文章标题

  • 摘要
  • 1. RNN循环神经网络
    • 1.1 无隐藏状态的神经网络
    • 1.2 有隐藏状态的循环神经网络
    • 1.3 以文本数据集为例实现RNN预测模型
      • 1.3.1 读取数据
      • 1.3.2 独热编码(词)
      • 1.3.3 初始化RNN的模型参数
      • 1.3.4 定义RNN模型
      • 1.3.5 创建一个类来包装这些函数
      • 1.3.6 梯度裁剪
      • 1.3.7 预测(未训练先预测)
      • 1.3.8 开始训练
    • 1.4 简洁实现RNN文本预测模型
  • 2. 门控循环单元(GRU)
    • 2.1 重置门和更新门
    • 2.2 候选隐藏状态
    • 2.3 隐藏状态
    • 2.4 代码实现GRU模型
      • 2.4.1 从零开始
  • 2.4.2 简洁实现
  • 3. LSTM模型复习与GRU的对比
    • 3.1 输入门、忘记门和输出门
    • 3.2 候选记忆单元
    • 3.3 记忆单元
    • 3.4 隐藏状态
  • 4.代码实现LSTM
    • 4.1读取数据集
    • 4.2 初始化模型参数
    • 4.3 定义模型
    • 4.4 训练和预测
  • 4.5 使用框架简洁实现
  • 总结与展望

摘要 本节一是主要复习RNN模型的计算逻辑,与MLP的区别,以及RNN的特点,与缺陷和解决方法(梯度剪裁),接着以文本数据集为训练样本,从零开始代码实现RNN预测模型,与借助深度学习框架的简洁实现。二是学习 门控循环单元(GRU),构造重置门(reset gate)和 更新门(update gate)的结构,以及计算候选隐藏状态,与隐藏状态。GRU模型可以更好的捕获序列中的长期依赖关系,而且比LSTM要简单。并继续使用文本数据集为样本,代码实现GRU模型的预测。三是对LSTM模型的代码实现,继续以文本数据集,与深度学习框架可简洁明了的完成训练与预测。
1. RNN循环神经网络 首先隐藏层和隐藏状态指的是两个截然不同的概念。隐藏层是在输入到输出的路径上以观测角度来理解的隐藏的层,而隐藏状态则是在给定步骤所做的任何事情以技术角度来定义的 输入,并且这些状态只能通过先前时间步的数据来计算。
循环神经网络(Recurrent neural networks, RNNs)是具有隐藏状态的神经网络。
1.1 无隐藏状态的神经网络 先看只有单隐藏层的多层感知机(MLP/ANN)。设隐藏层的激活函数为? \phi ?。给定一个小批量样本X ∈ R n × d \mathbf{X} \in \mathbb{R}^{n \times d} X∈Rn×d,其中批量大小为n n n,输入维度为d d d,则隐藏层的输出H ∈ R n × h \mathbf{H} \in \mathbb{R}^{n \times h} H∈Rn×h 通过下式计算:
H = ? ( X W x h + b h ) . \mathbf{H} = \phi(\mathbf{X} \mathbf{W}_{xh} + \mathbf{b}_h). H=?(XWxh?+bh?).
隐藏层权重参数为W x h ∈ R d × h \mathbf{W}_{xh} \in \mathbb{R}^{d \times h} Wxh?∈Rd×h、偏置参数为b h ∈ R 1 × h \mathbf{b}_h \in \mathbb{R}^{1 \times h} bh?∈R1×h,以及隐藏单元的数目为h h h。
将隐藏变量 用作输出层的输入。输出层由下式给出:
O = H W h q + b q , \mathbf{O} = \mathbf{H} \mathbf{W}_{hq} + \mathbf{b}_q, O=HWhq?+bq?,
其中, O ∈ R n × q \mathbf{O} \in \mathbb{R}^{n \times q} O∈Rn×q 是输出变量, W h q ∈ R h × q \mathbf{W}_{hq} \in \mathbb{R}^{h \times q} Whq?∈Rh×q 是权重参数, b q ∈ R 1 × q \mathbf{b}_q \in \mathbb{R}^{1 \times q} bq?∈R1×q 是输出层的偏置参数。如果是分类问题,我们可以用softmax ( O ) \text{softmax}(\mathbf{O}) softmax(O) 来计算输出类别的概率分布。
1.2 有隐藏状态的循环神经网络 对于 n n n个序列样本的小批量, X t \mathbf{X}_t Xt?的每一行对应于来自该序列的时间步 t t t处的一个样本。接下来,用 H t ∈ R n × h \mathbf{H}_t \in \mathbb{R}^{n \times h} Ht?∈Rn×h表示时间步 t t t的隐藏变量。与多层感知机不同的是,我们在这里保存了前一个时间步的隐藏变量 H t ? 1 \mathbf{H}_{t-1} Ht?1?,并引入了一个新的权重参数 W h h ∈ R h × h \mathbf{W}_{hh} \in \mathbb{R}^{h \times h} Whh?∈Rh×h来描述如何在当前时间步中使用前一个时间步的隐藏变量。具体地说,当前时间步隐藏变量的计算由当前时间步的输入与前一个时间步的隐藏变量一起确定:
H t = ? ( X t W x h + H t ? 1 W h h + b h ) . \mathbf{H}_t = \phi(\mathbf{X}_t \mathbf{W}_{xh} + \mathbf{H}_{t-1} \mathbf{W}_{hh} + \mathbf{b}_h). Ht?=?(Xt?Wxh?+Ht?1?Whh?+bh?).
从相邻时间步的隐藏变量 H t \mathbf{H}_t Ht?和 H t ? 1 \mathbf{H}_{t-1} Ht?1?之间的关系可知,这些变量捕获并保留了序列直到其当前时间步的历史信息,就如当前时间步下神经网络的状态或记忆,因此这样的隐藏变量被称为 隐藏状态(hidden state)。
输出层的输出类似于多层感知机中的计算:
O t = H t W h q + b q . \mathbf{O}_t = \mathbf{H}_t \mathbf{W}_{hq} + \mathbf{b}_q. Ot?=Ht?Whq?+bq?.
输出层的权重 W h q ∈ R h × q \mathbf{W}_{hq} \in \mathbb{R}^{h \times q} Whq?∈Rh×q和偏置 b q ∈ R 1 × q \mathbf{b}_q \in \mathbb{R}^{1 \times q} bq?∈R1×q。
注意:即使在不同的时间步,循环神经网络也总是使用这些模型参数。因此,循环神经网络的参数开销不会随着时间步的增加而增加。
下面简要描述循环神经网络的计算逻辑:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

隐藏状态的计算可以被视为:
1、拼接当前时间步 t t t的输入 X t \mathbf{X}_t Xt?和前一时间步 t ? 1 t-1 t?1的隐藏状态 H t ? 1 \mathbf{H}_{t-1} Ht?1?;
2、将拼接的结果送入带有激活函数 ? \phi ?的全连接层。全连接层的输出是当前时间步 t t t的隐藏状态 H t \mathbf{H}_t Ht?。
3、当前时间步 t t t的隐藏状态 H t \mathbf{H}_t Ht?将参与计算下一时间步 t + 1 t+1 t+1的隐藏状态 H t + 1 \mathbf{H}_{t+1} Ht+1?。而且 H t \mathbf{H}_t Ht?还将送入全连接输出层用于计算当前时间步 t t t的输出 O t \mathbf{O}_t Ot?。
接着使用一个简单的代码片段实现:
隐藏状态中 X t W x h + H t ? 1 W h h \mathbf{X}_t \mathbf{W}_{xh} + \mathbf{H}_{t-1} \mathbf{W}_{hh} Xt?Wxh?+Ht?1?Whh?的计算,相当于 X t \mathbf{X}_t Xt?和 H t ? 1 \mathbf{H}_{t-1} Ht?1?的拼接与 W x h \mathbf{W}_{xh} Wxh?和 W h h \mathbf{W}_{hh} Whh?的拼接的矩阵乘法。
import torch from d2l import torch as d2lX, W_xh = torch.normal(0, 1, (3, 1)), torch.normal(0, 1, (1, 4)) H, W_hh = torch.normal(0, 1, (3, 4)), torch.normal(0, 1, (4, 4)) torch.matmul(X, W_xh) + torch.matmul(H, W_hh)###1.依据公式计算输出矩阵 tensor([[ 0.6291, -4.1447, -0.5398,2.7443], [-0.0836, -0.7853,0.1801, -0.9263], [-2.8293,4.8625,2.6801,1.1304]])### 2.依据矩阵拼接后相乘 输出相同的矩阵 torch.matmul(torch.cat((X, H), 1), torch.cat((W_xh, W_hh), 0))tensor([[ 0.6291, -4.1447, -0.5398,2.7443], [-0.0836, -0.7853,0.1801, -0.9263], [-2.8293,4.8625,2.6801,1.1304]])

1.3 以文本数据集为例实现RNN预测模型 不用细看,这是从零开始实现的!没有借助深度学习框架的高级API提供的函数。
1.3.1 读取数据
以时光机数据集为训练数据,模型将在时光机数据集上训练,我们先是读取数据集:
%matplotlib inline import math import torch from torch import nn from torch.nn import functional as F from d2l import torch as d2lbatch_size, num_steps = 32, 35 # 参数 batch_size 指定了每个小批量中子序列样本的数目,参数 num_steps 是每个子序列中预定义的时间步数。 # 读取时光机数据集 ,引用之前定义的总函数 load_data_time_machine(),batch_size=批量大小,num_steps=时间步长 train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

1.3.2 独热编码(词)
把一个下标变成一个向量,以便做神经网络处理。即采用one-hot:
F.one_hot(torch.tensor([0, 2]), len(vocab))# vocab 词类总数长 =28 ## 输出 tensor([[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])

转换输入的维度,以便获得形状为(时间步数, 批量大小, 词汇表大小)的输出。这将使我们能够更方便地通过最外层的维度,一步一步地更新小批量数据的隐藏状态。
X = torch.arange(10).reshape((2, 5)) F.one_hot(X.T, 28).shape # 转置之后,可按时间步长去迭代##输出 批量数据的形状 torch.Size([5, 2, 28])

1.3.3 初始化RNN的模型参数
隐藏单元数num_hiddens是一个可调的超参数。当训练语言模型时,输入和输出来自相同的词汇表。因此,它们具有相同的维度,即词汇表的大小。
# 1. 定义可学习参数 的 get_params函数 def get_params(vocab_size, num_hiddens, device): num_inputs = num_outputs = vocab_size #输入输出都等于词汇表大小def normal(shape): # 定义normal 函数 ,均值为0,方差为1* 0.01 的初始化函数 return torch.randn(size=shape, device=device) * 0.01# 隐藏层参数 W_xh = normal((num_inputs, num_hiddens)) W_hh = normal((num_hiddens, num_hiddens)) b_h = torch.zeros(num_hiddens, device=device)# 输出层参数 W_hq = normal((num_hiddens, num_outputs)) b_q = torch.zeros(num_outputs, device=device)# 附加梯度 params = [W_xh, W_hh, b_h, W_hq, b_q] for param in params: param.requires_grad_(True) return params

1.3.4 定义RNN模型
定义一个init_rnn_state函数在初始化时返回隐藏状态,在0时刻也需要一个隐藏变量。
# 2. 初始化隐藏状态 def init_rnn_state(batch_size, num_hiddens, device):# 在0时刻,初始化隐藏状态,0/随机都可以。 return (torch.zeros((batch_size, num_hiddens), device=device), )

接下来就是做计算,下面的rnn函数定义了如何在一个时间步内计算隐藏状态和输出
# rnn函数定义了如何在一个时间步内计算隐藏状态和输出 def rnn(inputs, state, params): # input 是一个三维的tensor(时间步长,批量大小,词汇表大小) W_xh, W_hh, b_h, W_hq, b_q = params H, = state outputs = [] for X in inputs:# `X`的形状:(`批量大小`,`词表大小`), # 即就是沿着时间步长的维度去一个一个(从t1,t2,.....t10)去遍历 H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)# tanh 函数作为激活函数 Y = torch.mm(H, W_hq) + b_q outputs.append(Y) return torch.cat(outputs, dim=0), (H,) # torch.cat ()是将每个时间步长所得输出拼接起来,n个矩阵按dim=0(竖着拼),并更新隐藏状态(为什么要这样拼接?)

特别注意:循环神经网络模型通过inputs最外层的维度(时间步长)实现循环,以便逐时间步更新小批量数据的隐藏状态H。
1.3.5 创建一个类来包装这些函数
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_hiddens self.params = get_params(vocab_size, num_hiddens, device) self.init_state, self.forward_fn = init_state, forward_fndef __call__(self, X, state):# X 向量化 X = F.one_hot(X.T, self.vocab_size).type(torch.float32) return self.forward_fn(X, state, self.params)## 在此处forward_fn(, ,)定义的实际是上面的rnn函数def begin_state(self, batch_size, device): return self.init_state(batch_size, self.num_hiddens, device) # 调用init_state函数

检查输出是否具有正确的形状,例如,是否保证了隐藏状态的维数保持不变。
num_hiddens = 512 # 隐藏层大小 net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params, # 可学习参数 init_rnn_state, rnn) # 模型类型state = net.begin_state(X.shape[0], d2l.try_gpu()) # 初始化隐藏状态 Y, new_state = net(X.to(d2l.try_gpu()), state) Y.shape, len(new_state), new_state[0].shape # 输出形状,与隐藏状态的形状 (torch.Size([10, 28]), 1, torch.Size([2, 512]))

输出形状是(时间步数 × 批量大小,词汇表大小)输出Y是经拼接的(就那个竖着拼的),而隐藏状态形状保持不变,即(批量大小, 隐藏单元数)。
1.3.6 梯度裁剪
应用梯度裁剪去解决优化导致梯度爆炸的问题,通过将梯度 投影回给定半径(例如 )的球来裁剪梯度 。如下式:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

梯度范数永远不会超过 ,并且更新后的梯度完全与 的原始方向对齐。它还有一个值得拥有的副作用,即限制任何给定的小批量数据(以及其中任何给定的样本)对参数向量的影响,这赋予了模型一定程度的稳定性。梯度裁剪提供了一个快速修复梯度爆炸的方法。
# 定义一个函数来裁剪模型的梯度 def grad_clipping(net, theta):#@save """裁剪梯度。""" if isinstance(net, nn.Module): params = [p for p in net.parameters() if p.requires_grad] else: params = net.params# 取网络层所有的可参与训练的参数 norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))# 所有层的参数p,求和,开根号,即是求L2范数 if norm > theta: # 预防梯度过大 for param in params: param.grad[:] *= theta / norm

1.3.7 预测(未训练先预测)
定义预测函数来生成prefix之后的新字符
def predict_ch8(prefix, num_preds, net, vocab, device):#@save,num_preds是预测几个字符(词),net是训练好的模型,vocab能map成真实的字符串值。 """在`prefix`后面生成新字符。""" state = net.begin_state(batch_size=1, device=device) outputs = [vocab[prefix[0]]]## 将第一个词或字符放到vocab里,拿到对应的整型下标,放入output get_input = lambda: torch.tensor([outputs[-1]], device=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)# 将每上一次的输出的最后一个,当作下一个输入, y就是1*vocab.size的向量 outputs.append(int(y.argmax(dim=1).reshape(1))) #将y的最大坐标取出,换成标量,拿到outputs去 return ''.join([vocab.idx_to_token[i] for i in outputs])

1.3.8 开始训练
  1. 定义一个函数在一个迭代周期内训练模型
#@save 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: # 在第一次迭代或使用随机抽样时初始化`state` state = net.begin_state(batch_size=X.shape[0], device=device) else: if isinstance(net, nn.Module) and not isinstance(state, tuple): # `state`对于`nn.GRU`是个张量 state.detach_() else: # `state`对于`nn.LSTM`或对于我们从零开始实现的模型是个张量 for s in state: s.detach_() y = Y.T.reshape(-1) # 将Y转置,拉成一个向量 X, y = X.to(device), y.to(device) y_hat, state = net(X, state) l = loss(y_hat, y.long()).mean()# 这里就解释了为啥y拼接,在损失函数看来本质上是一个多分类问题 if isinstance(updater, torch.optim.Optimizer): updater.zero_grad() l.backward() grad_clipping(net, 1) updater.step() else: l.backward() grad_clipping(net, 1) # 因为已经调用了`mean`函数 updater(batch_size=1) metric.add(l * y.numel(), y.numel()) return math.exp(metric[0] / metric[1]), metric[1] / timer.stop() # 得到平均交叉熵,困惑度

  1. 循环神经网络模型的训练函数
#@save def train_ch8(net, train_iter, vocab, lr, num_epochs, device, use_random_iter=False): """训练模型""" loss = nn.CrossEntropyLoss() animator = d2l.Animator(xlabel='epoch', ylabel='perplexity', legend=['train'], xlim=[10, num_epochs]) # 初始化 if isinstance(net, nn.Module): updater = torch.optim.SGD(net.parameters(), lr) 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: print(predict('time traveller')) animator.add(epoch + 1, [ppl]) print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}') print(predict('time traveller')) print(predict('traveller'))

采用顺序分区取样,去训练预测结果如下:
num_epochs, lr = 500, 1 train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu())

动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

可以看出困惑度是很好的,对于这个RNN模型,是以字符预测的,整体预测效果看似不错,但仍然存在很多问题,语义不清等。
接着采用随机抽样方法训练:
预测结果如下
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu(), use_random_iter=True)

动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

可见困惑度上升,计算也变得复杂,由于每次都需重新计算隐藏状态。预测结果与上面也是差不多,表现还算可以。
时光机数据集太小,后续可以通过加大训练样本集,看结果能不能有所改进。
1.4 简洁实现RNN文本预测模型 本节将展示如何使用深度学习框架的高级API提供的函数更有效地实现相同的语言模型。仍从读取时光机器数据集开始。
import torch from torch import nn from torch.nn import functional as F from d2l import torch as d2lbatch_size, num_steps = 32, 35#批量大小,时间步长 train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

定义模型
  1. 构造了一个具有256个隐藏单元的单隐藏层的循环神经网络层 rnn_layer:
num_hiddens = 256 rnn_layer = nn.RNN(len(vocab), num_hiddens)

  1. 使用张量来初始化隐藏状态,它的形状是(隐藏层数,批量大小,隐藏单元数)
state = torch.zeros((1, batch_size, num_hiddens)) #初始化隐藏状态 state.shape#输出 torch.Size([1, 32, 256])

  1. 通过一个隐藏状态和一个输入,我们就可以用更新后的隐藏状态计算输出。
X = torch.rand(size=(num_steps, batch_size, len(vocab))) Y, state_new = rnn_layer(X, state) # Y其实是隐藏层的y,并不是输出层的 #rnn_layer的“输出”(Y)不涉及输出层的计算:它是指每个时间步的隐藏状态,这些隐藏状态可以用作后续输出层的输入。 Y.shape, state_new.shape # 输出 (torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))

  1. 为一个完整的循环神经网络模型定义了一个RNNModel类
    rnn_layer 只包含隐藏的循环层,我们还需要创建一个单独的输出层。
class RNNModel(nn.Module): """循环神经网络模型。""" def __init__(self, rnn_layer, vocab_size, **kwargs): super(RNNModel, self).__init__(**kwargs) # super().__init__()派生方法。 self.rnn = rnn_layer self.vocab_size = vocab_size self.num_hiddens = self.rnn.hidden_size # 如果RNN是双向的,`num_directions`应该是2,否则应该是1。 if not self.rnn.bidirectional: self.num_directions = 1#单向RNN模型 self.linear = nn.Linear(self.num_hiddens, self.vocab_size)# 构造线性输出层 else: self.num_directions = 2 # 双向RNN模型 self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)def forward(self, inputs, state): X = F.one_hot(inputs.T.long(), self.vocab_size) X = X.to(torch.float32) # Y, state = self.rnn(X, state) # Y是中间隐藏层的y,形状(时间步长,批量大小,隐藏单元数)# 全连接层首先将`Y`的形状改为(`时间步数`*`批量大小`, `隐藏单元数`)output = self.linear(Y.reshape((-1, Y.shape[-1]))) # 它的输出形状是 (`时间步数`*`批量大小`, `词表大小`) return output, statedef begin_state(self, device, batch_size=1): if not isinstance(self.rnn, nn.LSTM): # `nn.GRU` 以张量作为隐藏状态 returntorch.zeros((self.num_directions * self.rnn.num_layers, batch_size, self.num_hiddens), device=device) else: # `nn.LSTM` 以张量作为隐藏状态 return ( torch.zeros(( self.num_directions * self.rnn.num_layers, batch_size, self.num_hiddens), device=device), torch.zeros(( self.num_directions * self.rnn.num_layers, batch_size, self.num_hiddens), device=device) )

  1. 训练与预测
基于一个具有随机权重的模型进行预测:
device = d2l.try_gpu() net = RNNModel(rnn_layer, vocab_size=len(vocab)) net = net.to(device) d2l.predict_ch8('time traveller', 10, net, vocab, device) ##预测结果(乱的) 'time travellerzzzzzzzzzz'

使用上节(从零实现)中定义的超参数 调用 train_ch8,并且使用高级API训练模型。
num_epochs, lr = 500, 1 d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)

得出预测结果:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

可以看出使用深度学习框架的模型,代码优化,所以缩短了降低困惑度的时间。
注意:使用框架(没有避免初始化状态)只是避免了初始化权重,与RNN怎么计算,却rnn_layer没有输出层,输出的Y并不是预测结果y。
2. 门控循环单元(GRU) GRU与普通RNN的主要区别在于,GRU具有隐藏状态的门控,而且是软控制。有专门的机制来确定应该何时 更新 隐藏状态,以及应该何时 重置 隐藏状态。这些机制是可学习的。
2.1 重置门和更新门 构造重置门(reset gate)和 更新门(update gate)的结构如下图所示:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

输入是由当前时间步的输入和前一时间步的隐藏状态给出,两个门的输出是由使用 sigmoid 激活函数的两个全连接层给出。
假设输入是一个小批量X t ∈ R n × d \mathbf{X}_t \in \mathbb{R}^{n \times d} Xt?∈Rn×d (样本个数: n n n,输入个数: d d d),上一个时间步的隐藏状态是H t ? 1 ∈ R n × h \mathbf{H}_{t-1} \in \mathbb{R}^{n \times h} Ht?1?∈Rn×h(隐藏单元个数: h h h)。然后,重置门R t ∈ R n × h \mathbf{R}_t \in \mathbb{R}^{n \times h} Rt?∈Rn×h 和更新门Z t ∈ R n × h \mathbf{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} \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), \end{aligned} Rt?=σ(Xt?Wxr?+Ht?1?Whr?+br?),Zt?=σ(Xt?Wxz?+Ht?1?Whz?+bz?),?
其中W x r , W x z ∈ R d × h \mathbf{W}_{xr}, \mathbf{W}_{xz} \in \mathbb{R}^{d \times h} Wxr?,Wxz?∈Rd×h 和W h r , W h z ∈ R h × h \mathbf{W}_{hr}, \mathbf{W}_{hz} \in \mathbb{R}^{h \times h} Whr?,Whz?∈Rh×h 是权重参数, b r , b z ∈ R 1 × h \mathbf{b}_r, \mathbf{b}_z \in \mathbb{R}^{1 \times h} br?,bz?∈R1×h 是偏置参数。
注意:在求和过程中会触发广播机制,使用 sigmoid 函数将输入值转换到区间( 0 , 1 ) (0, 1) (0,1)。
2.2 候选隐藏状态 将重置门R t \mathbf{R}_t Rt? 与RNN的常规隐状态更新机制集成,得到在时间步t t t 的候选隐藏状态H ~ t ∈ R n × h \tilde{\mathbf{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{\mathbf{H}}_t = \tanh(\mathbf{X}_t \mathbf{W}_{xh} + \left(\mathbf{R}_t \odot \mathbf{H}_{t-1}\right) \mathbf{W}_{hh} + \mathbf{b}_h), H~t?=tanh(Xt?Wxh?+(Rt?⊙Ht?1?)Whh?+bh?),
其中W x h ∈ R d × h \mathbf{W}_{xh} \in \mathbb{R}^{d \times h} Wxh?∈Rd×h 和W h h ∈ R h × h \mathbf{W}_{hh} \in \mathbb{R}^{h \times h} Whh?∈Rh×h 是权重参数, b h ∈ R 1 × h \mathbf{b}_h \in \mathbb{R}^{1 \times h} bh?∈R1×h 是偏置项。
注意:符号⊙ \odot ⊙ 是哈达码乘积(按元素乘积)运算符。使用 tanh 非线性激活函数来确保候选隐藏状态中的值保持在区间( ? 1 , 1 ) (-1, 1) (?1,1) 中。
2.3 隐藏状态 结合更新门Z t \mathbf{Z}_t Zt? 的效果。这确定新的隐藏状态H t ∈ R n × h \mathbf{H}_t \in \mathbb{R}^{n \times h} Ht?∈Rn×h 在多大程度上就是旧的状态H t ? 1 \mathbf{H}_{t-1} Ht?1? ,以及对新的候选状态H ~ t \tilde{\mathbf{H}}_t H~t? 的使用量。更新门Z t \mathbf{Z}_t Zt? 仅需要在H t ? 1 \mathbf{H}_{t-1} Ht?1? 和H ~ t \tilde{\mathbf{H}}_t H~t? 之间进行按元素的凸组合就可以实现这个目标。这就得出了门控循环单元的最终更新公式:
H t = Z t ⊙ H t ? 1 + ( 1 ? Z t ) ⊙ H ~ t . \mathbf{H}_t = \mathbf{Z}_t \odot \mathbf{H}_{t-1} + (1 - \mathbf{Z}_t) \odot \tilde{\mathbf{H}}_t. Ht?=Zt?⊙Ht?1?+(1?Zt?)⊙H~t?.
重置门,更新门起作用后的计算流如下:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

门控循环单元具有以下两个显著特征:
  1. 重置门有助于捕获序列中的短期依赖关系
  2. 更新门有助于捕获序列中的长期依赖关系
2.4 代码实现GRU模型 2.4.1 从零开始
  1. 读取数据集的代码(以文本为训练样本)
import torch from torch import nn from d2l import torch as d2lbatch_size, num_steps = 32, 35 train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

  1. 初始化模型参数
从标准差为 0.01 的高斯分布中提取权重,并将偏置项设为 0 ,超参数 num_hiddens 定义隐藏单元的数量,实例化与更新门、重置门、候选隐藏状态和输出层相关的所有权重和偏置。
(与之前RNN的初始化参数一样,只是参数增多了)
def get_params(vocab_size, num_hiddens, device): num_inputs = num_outputs = vocab_sizedef normal(shape): return torch.randn(size=shape, device=device)*0.01def three(): return (normal((num_inputs, num_hiddens)), normal((num_hiddens, num_hiddens)), torch.zeros(num_hiddens, device=device))W_xz, W_hz, b_z = three()# 更新门参数 W_xr, W_hr, b_r = three()# 重置门参数 # GRU多了上面两行参数比RNNW_xh, W_hh, b_h = three()# 候选隐藏状态参数 # 输出层参数 W_hq = normal((num_hiddens, num_outputs)) b_q = torch.zeros(num_outputs, device=device) # 附加梯度 params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q] for param in params: param.requires_grad_(True) return params

  1. 定义隐藏状态的初始化函数
def init_gru_state(batch_size, num_hiddens, device): return (torch.zeros((batch_size, num_hiddens), device=device), )

  1. 定义门控循环单元模型
模型的结构与基本的循环神经网络单元是相同的,只是权重更新公式更为复杂。
def gru(inputs, state, params): W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params # 一个隐藏层11 个参数 H, = state outputs = [] for X in inputs:# 每个时间拿出XZ = torch.sigmoid((X @ W_xz) + (H @ W_hz) + b_z) R = torch.sigmoid((X @ W_xr) + (H @ W_hr) + b_r) H_tilda = torch.tanh((X @ W_xh) + ((R * H) @ W_hh) + b_h) H = Z * H + (1 - Z) * H_tilda Y = H @ W_hq + b_qoutputs.append(Y) return torch.cat(outputs, dim=0), (H,)

  1. 训练与预测
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu() num_epochs, lr = 500, 1 model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_params, init_gru_state, gru) # 调用之前定义的RNNModelScratch()函数 d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)#调用之前的训练函数train_ch8()

预测结果如下:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

2.4.2 简洁实现 使用深度学习的框架,可以直接实例化门控循环单元模型,代码的运行速度要快。
num_inputs = vocab_size gru_layer = nn.GRU(num_inputs, num_hiddens) model = d2l.RNNModel(gru_layer, len(vocab)) model = model.to(device) d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

结果:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

3. LSTM模型复习与GRU的对比 LSTM计算逻辑流程图:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

3.1 输入门、忘记门和输出门 将当前时间步的输入和前一个时间步的隐藏状态作为数据送入长短期记忆网络门中,它们由三个具有 sigmoid 激活函数的全连接层处理,以计算输入门、遗忘门和输出门的值。因此,这三个门的值都在 (0,1) 的范围内。如图:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

3.2 候选记忆单元 候选记忆单元C,使用 tanh 函数作为激活函数,函数的值范围为 (?1,1) 。计算与RNN中的隐藏状态Ht类似,不过LSTM中会有两个状态。
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

3.3 记忆单元 在GRU中,有一种机制来控制输入和遗忘(或跳过)。类似地,在LSTM中,也有两个门用于这样的目的:
1.输入门 控制采用多少来自 候选隐藏状态 C ~ t \tilde{\mathbf{C}}_t C~t? 的的新数据;
2.遗忘门 控制保留了多少旧记忆单元 ?1∈?×? 的内容。
使用与前面相同的按元素做乘法的技巧,得出以下更新公式:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

3.4 隐藏状态 输出门发挥作用的地方,确保了 的值始终在区间 (?1,1) 内,则需要加上tanh 函数作为激活函数。动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

总而言之,这个机制就是在于要么看中当前的Xt,要么多注意之前的东西,或者重置当前信息。
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

输出 : Y t = H t W ( h q ) + b q Y_t=H_t W_(hq)+b_q Yt?=Ht?W(?hq)+bq?
对比——GRU计算逻辑流程:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

4.代码实现LSTM 4.1读取数据集
import torch from torch import nn from d2l import torch as d2lbatch_size, num_steps = 32, 35 train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

4.2 初始化模型参数
def get_lstm_params(vocab_size, num_hiddens, device): num_inputs = num_outputs = vocab_sizedef normal(shape):# 自定义一个初始化函数normal() return torch.randn(size=shape, device=device)*0.01def three(): return (normal((num_inputs, num_hiddens)), normal((num_hiddens, num_hiddens)), torch.zeros(num_hiddens, device=device))W_xi, W_hi, b_i = three()# 输入门参数 W_xf, W_hf, b_f = three()# 遗忘门参数 W_xo, W_ho, b_o = three()# 输出门参数 W_xc, W_hc, b_c = three()# 候选记忆单元参数# 输出层参数 W_hq = normal((num_hiddens, num_outputs)) b_q = torch.zeros(num_outputs, device=device) # 附加梯度 params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q] for param in params: param.requires_grad_(True) return params

4.3 定义模型
  1. 隐藏状态初始化:
def init_lstm_state(batch_size, num_hiddens, device): return (torch.zeros((batch_size, num_hiddens), device=device),# H的初始化,形状为(批量大小,隐藏单元数) torch.zeros((batch_size, num_hiddens), device=device))# C的初始化,形状为(批量大小,隐藏单元数)

  1. 定义lstm()函数
def lstm(inputs, state, params): [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q] = params (H, C) = state outputs = [] for X in inputs:## RNN ,GRU LSTM 的关键区别在于 Ht隐藏状态是怎样更新的。 I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i) F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f) O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o) C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c) C = F * C + I * C_tilda#*是按元素点乘, @矩阵乘法 H = O * torch.tanh(C)## 输出Y Y = (H @ W_hq) + b_qoutputs.append(Y) return torch.cat(outputs, dim=0), (H, C)

4.4 训练和预测
import os os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()num_epochs, lr = 500, 1 model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params, init_lstm_state, lstm)### 引入 RNNModelScratch 类 d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

结果输出:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

4.5 使用框架简洁实现
num_inputs = vocab_size lstm_layer = nn.LSTM(num_inputs, num_hiddens) ## nn.RNN(),nn.GRU(),nn.LSTM()的区别 model = d2l.RNNModel(lstm_layer, len(vocab)) # 隐藏层 lstm_layer model = model.to(device) d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

结果预测如下:
动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)
文章图片

总结与展望 一是MLP与RNN的区别在于前者没有隐藏状态,没有捕获并保留了序列直到其当前时间步的历史信息的能力,也就是不能捕获序列的长期依赖关系。RNN模型的代码实现,需要先读取数据集,对数据的预处理,这里只是简单处理成字符(样本英文),后续后是复杂的数据,要有更好的处理手段;借助深度学习框架简洁实现RNN预测模型会比之前要快而且简单。
二是GRU的构造的实现可以更好地捕获时间步距离很长的序列上的依赖关系,重置门打开时,门控循环单元包含基本循环神经网络;更新门打开时,门控循环单元可以跳过子序列。
三是 LSTM 有三种类型的门:输入门、遗忘门和控制信息流的输出门。LSTM的隐藏层输出包括“隐藏状态”和“记忆单元”。只有隐藏状态会传递到输出层,而记忆单元完全属于内部信息。LSTM可以缓解梯度消失和梯度爆炸。
【动手学深度学习|学习笔记——动手学深度学习(RNN,GRU,LSTM)】现在应用最广泛的LSTM模型,需要去进一步的改进,以及在不同的数据训练集上,会有怎样不同的性能体现,又该怎么去更好的处理数据,才能更好实现想要的结果。

    推荐阅读