神经网络(六)——更多RNN

一、门控循环单元(GRU)

门控循环单元与普通的循环神经网络之间的关键区别在于:前者支持隐状态的门控。这意味着模型有专门的机制来确定应该何时更新隐状态,以及应该何时重置隐状态。这些机制是可学习的,并且能够解决了上面列出的问题。例如,如果第一个词元非常重要,模型将学会在第一次观测之后不更新隐状态。同样,模型也可以学会跳过不相关的临时观测。最后,模型还将学会在需要的时候重置隐状态。

1.1 重置门和更新门

首先介绍重置门(reset gate)和更新门(update gate)。把它们设计成区间中的向量,这样就可以进行凸组合。重置门允许控制“可能还想记住”的过去状态的数量;更新门将允许控制新状态中有多少个是旧状态的副本。

从构造这些门控开始。下图描述了门控循环单元中的重置门和更新门的输入,输入是由当前时间步的输入和前一时间步的隐状态给出。两个门的输出是由使用sigmoid激活函数的两个全连接层给出。

在门控循环单元模型中计算重置门和更新门

看一下门控循环单元的数学表达。对于给定的时间步,假设输入是一个小批量(样本个数,输入个数),上一个时间步的隐状态是(隐藏单元个数)。那么,重置门和更新门的计算如下所示:

其中是权重参数,是偏置参数。使用sigmoid函数将输入值转换到区间

1.2 候选隐状态

将重置门与RNN中的常规隐状态更新机制集成,得到在时间步候选隐状态(candidate hidden state)

其中是权重参数,是偏置项,符号是Hadamard积(按元素乘积)运算符。在这里,使用tanh非线性激活函数来确保候选隐状态中的值保持在区间中。

与RNN相比,中的的元素相乘可以减少以往状态的影响。每当重置门中的项接近时,恢复一个如RNN中的普通的循环神经网络。对于重置门中所有接近的项,候选隐状态是以作为输入的多层感知机的结果。因此,任何预先存在的隐状态都会被重置为默认值。

下图说明了应用重置门之后的计算流程。

在门控循环单元模型中计算候选隐状态(Recurrent Neural Network,RNN)

1.3 隐状态

上述的计算结果只是候选隐状态,仍然需要结合更新门的效果。这一步确定新的隐状态在多大程度上来自旧的状态和新的候选状态。更新门仅需要在之间进行按元素的凸组合就可以实现这个目标。这就得出了门控循环单元的最终更新公式:

每当更新门接近时,模型就倾向只保留旧状态。此时,来自的信息基本上被忽略,从而有效地跳过了依赖链条中的时间步。相反,当接近时,新的隐状态就会接近候选隐状态。这些设计可以帮助我们处理循环神经网络中的梯度消失问题,并更好地捕获时间步距离很长的序列的依赖关系。例如,如果整个子序列的所有时间步的更新门都接近于,则无论序列的长度如何,在序列起始时间步的旧隐状态都将很容易保留并传递到序列结束。

下图说明了更新门起作用后的计算流。

计算门控循环单元模型中的隐状态

总之,门控循环单元具有以下两个显著特征:

  • 重置门有助于捕获序列中的短期依赖关系;
  • 更新门有助于捕获序列中的长期依赖关系。

1.4 训练代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = load_data_time_machine(batch_size, num_steps)
vocab_size, num_hiddens, device = len(vocab), 256, try_gpu()
num_epochs, lr = 500, 1

num_inputs = vocab_size
gru_layer = nn.GRU(num_inputs, num_hiddens)
model = RNNModel(gru_layer, len(vocab))
model = model.to(device)
train8(model, train_iter, vocab, lr, num_epochs, device)
1
2
3
困惑度 1.0, 1031424.0 词元/秒 cuda:0
time traveller for so it will be convenient to speak of himwas e
travelleryou can show black is white by argument said filby

二、长短期记忆网络(LSTM)

长期以来,隐变量模型存在着长期信息保存和短期输入缺失的问题。解决这一问题的最早方法之一是长短期存储器(long short-term memory,LSTM)它有许多与门控循环单元一样的属性。

门控记忆元

长短期记忆网络的设计灵感来自于计算机的逻辑门。长短期记忆网络引入了记忆元(memory cell),或简称为单元(cell)。有些文献认为记忆元是隐状态的一种特殊类型,它们与隐状态具有相同的形状,其设计目的是用于记录附加的信息。为了控制记忆元,需要许多门。其中一个门用来从单元中输出条目,将其称为输出门(output gate)。另外一个门用来决定何时将数据读入单元,将其称为输入门(input gate)。还需要一种机制来重置单元的内容,由遗忘门(forget gate)来管理,这种设计的动机与门控循环单元相同,能够通过专用机制决定什么时候记忆或忽略隐状态中的输入。

2.1 输入门、忘记门和输出门

就如在门控循环单元中一样,当前时间步的输入和前一个时间步的隐状态作为数据送入长短期记忆网络的门中,如下图所示。它们由三个具有sigmoid激活函数的全连接层处理,以计算输入门、遗忘门和输出门的值。因此,这三个门的值都在的范围内。

长短期记忆模型中的输入门、遗忘门和输出门

假设有个隐藏单元,批量大小为,输入数为。因此,输入为,前一时间步的隐状态为。相应地,时间步的门被定义如下:输入门是,遗忘门是,输出门是。它们的计算方法如下:

其中是权重参数,是偏置参数。

2.2 候选记忆元

由于还没有指定各种门的操作,所以先介绍候选记忆元(candidate memory cell)。它的计算与上面描述的三个门的计算类似,但是使用函数作为激活函数,函数的值范围为。下面导出在时间步处的方程:

其中是权重参数,是偏置参数。

候选记忆元的如下图所示。

长短期记忆模型中的候选记忆元

2.3 记忆元

在门控循环单元中,有一种机制来控制输入和遗忘(或跳过)。类似地,在长短期记忆网络中,也有两个门用于这样的目的:输入门控制采用多少来自的新数据,而遗忘门控制保留多少过去的记忆元的内容。使用按元素乘法,得出:

如果遗忘门始终为且输入门始终为,则过去的记忆元将随时间被保存并传递到当前时间步。引入这种设计是为了缓解梯度消失问题,并更好地捕获序列中的长距离依赖关系。

这样就得到了计算记忆元的流程图,如图。

在长短期记忆网络模型中计算记忆元

2.4 隐状态

最后,需要定义如何计算隐状态,这就是输出门发挥作用的地方。在长短期记忆网络中,它仅仅是记忆元的的门控版本。这就确保了的值始终在区间内:

只要输出门接近,就能够有效地将所有记忆信息传递给预测部分,而对于输出门接近,只保留记忆元内的所有信息,而不需要更新隐状态。

下图提供了数据流的图形化演示。

在长短期记忆模型中计算隐状态

2.5 训练代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = load_data_time_machine(batch_size, num_steps)
vocab_size, num_hiddens, device = len(vocab), 256, try_gpu()
num_epochs, lr = 500, 1

num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = RNNModel(lstm_layer, len(vocab))
model = model.to(device)
train8(model, train_iter, vocab, lr, num_epochs, device)
1
2
3
困惑度 1.0, 689053.2 词元/秒 cuda:0
time traveller with a slight accession ofcheerfulness really thi
travelleryou can show black is white by argument said filby

三、深度循环神经网络(Deep-RNN)

可以将多层循环神经网络堆叠在一起,通过对几个简单层的组合,产生了一个灵活的机制。特别是,数据可能与不同层的堆叠有关。例如,保持有关金融市场状况(熊市或牛市)的宏观数据可用,而微观数据只记录较短期的时间动态。下图描述了一个具有个隐藏层的深度循环神经网络,每个隐状态都连续地传递到当前层的下一个时间步和下一层的当前时间步。

深度循环神经网络结构

3.1 函数依赖关系

假设在时间步有一个小批量的输入数据(样本数:,每个样本中的输入数:)。同时,将隐藏层()的隐状态设为(隐藏单元数:),输出层变量设为(输出数:)。设置,第个隐藏层的隐状态使用激活函数,则:

其中,权重和偏置都是第个隐藏层的模型参数。

最后,输出层的计算仅基于第个隐藏层最终的隐状态:

其中,权重和偏置都是输出层的模型参数。

与多层感知机一样,隐藏层数目和隐藏单元数目都是超参数。另外,用门控循环单元或长短期记忆网络的隐状态来代替中的隐状态进行计算,可以很容易地得到深度门控循环神经网络或深度长短期记忆神经网络。

3.2 训练代码

代码与之前在LSTM中使用的代码非常相似,实际上唯一的区别是指定了层的数量,而不是使用单一层这个默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = load_data_time_machine(batch_size, num_steps)
vocab_size, num_hiddens, device,num_layers = len(vocab), 256, try_gpu(),2
num_epochs, lr = 500, 2

num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens,num_layers)
model = RNNModel(lstm_layer, len(vocab))
model = model.to(device)
train8(model, train_iter, vocab, lr*1.0, num_epochs, device)
1
2
3
困惑度 1.0, 594344.0 词元/秒 cuda:0
time traveller with a slight accession ofcheerfulness really thi
travelleryou can show black is white by argument said filby

四、双向循环神经网络(BI_RNN)

4.1 隐马尔可夫模型中的动态规划

类似填空的任务不仅需要参考上文,还要参考下文。如果想用概率图模型来解决这个问题,可以设计一个隐变量模型:在任意时间步,假设存在某个隐变量,通过概率控制我们观测到的。此外,任何转移都是由一些状态转移概率给出。这个概率图模型就是一个隐马尔可夫模型(hidden Markov model,HMM),
如图所示。

隐马尔可夫模型

因此,对于有个观测值的序列,在观测状态和隐状态上具有以下联合概率分布:

假设观测到所有的,除了,并且目标是计算,其中。由于中没有隐变量,因此考虑对选择构成的所有可能的组合进行求和。如果任何可以接受个不同的值(有限的状态数),这意味着需要对个项求和,有个巧妙的解决方案:动态规划(dynamic programming)。

要了解动态规划的工作方式,考虑对隐变量的依次求和。根据观测状态和隐状态的联合概率分布,将得出:

通常,将前向递归(forward recursion)写为:

递归被初始化为。符号简化,也可以写成,其中是一些可学习的函数。这看起来就像在循环神经网络中讨论的隐变量模型中的更新方程。

与前向递归一样,也可以使用后向递归对同一组隐变量求和。这将得到:

因此,可以将后向递归(backward recursion)写为:

初始化。前向和后向递归都允许对个隐变量在(线性而不是指数)时间内对的所有值求和。
这是使用图模型进行概率推理的巨大好处之一。结合前向和后向递归,能够计算

因为符号简化的需要,后向递归也可以写为,其中是一个可以学习的函数。同样,这看起来非常像一个更新方程,只是不像在循环神经网络中看到的那样前向运算,而是后向计算。

4.2 双向模型

如果希望在循环神经网络中拥有一种机制, 使之能够提供与隐马尔可夫模型类似的前瞻能力, 就需要修改循环神经网络的设计。只需要增加一个“从最后一个词元开始从后向前运行”的循环神经网络,而不是只有一个在前向模式下“从第一个词元开始运行”的循环神经网络。双向循环神经网络(bidirectional RNNs)添加了反向传递信息的隐藏层,以便更灵活地处理此类信息。下图描述了具有单个隐藏层的双向循环神经网络的架构。

双向循环神经网络架构

这与隐马尔可夫模型中的动态规划的前向和后向递归没有太大区别。其主要区别是,在隐马尔可夫模型中的方程具有特定的统计意义。双向循环神经网络没有这样容易理解的解释,只能把它们当作通用的、可学习的函数。这种转变集中体现了现代深度网络的设计原则:首先使用经典统计模型的函数依赖类型,然后将其参数化为通用形式。

4.2.1 定义

对于任意时间步,给定一个小批量的输入数据(样本数,每个示例中的输入数),并且令隐藏层激活函数为。在双向架构中,设该时间步的前向和反向隐状态分别为,其中是隐藏单元的数目。前向和反向隐状态的更新如下:

其中,权重和偏置都是模型参数。

接下来,将前向隐状态和反向隐状态连接起来,获得需要送入输出层的隐状态。在具有多个隐藏层的深度双向循环神经网络中,该信息作为输入传递到下一个双向层。最后,输出层计算得到的输出为是输出单元的数目):

这里,权重矩阵和偏置是输出层的模型参数。事实上,这两个方向可以拥有不同数量的隐藏单元。

双向循环神经网络可以使用来自过去和未来的观测信息来预测当前的观测。但是在对下一个词元进行预测的情况中,这样的模型并不会得到很好的精度。双向循环神经网络的计算速度非常慢。其主要原因是网络的前向传播需要在双向层中进行前向和后向递归,并且网络的反向传播还依赖于前向传播的结果。因此,梯度求解将有一个非常长的链。

双向层的使用在实践中非常少,并且仅仅应用于部分场合。例如,填充缺失的单词、词元注释(例如,用于命名实体识别)以及作为序列处理流水线中的一个步骤对序列进行编码(例如,用于机器翻译)。

4.3 代码训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import torch
from torch import nn
from d2l import torch as d2l

# 加载数据
batch_size, num_steps, device = 32, 35, try_gpu()
train_iter, vocab = load_data_time_machine(batch_size, num_steps)
# 通过设置“bidirective=True”来定义双向LSTM模型
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers, bidirectional=True)
model = RNNModel(lstm_layer, len(vocab))
model = model.to(device)
# 训练模型
num_epochs, lr = 500, 1
train8(model, train_iter, vocab, lr, num_epochs, device)
1
2
3
困惑度 1.1, 550112.9 词元/秒 cuda:0
time travellerererererererererererererererererererererererererer
travellerererererererererererererererererererererererererer

由于双向循环神经网络使用了过去的和未来的数据,所以不能盲目地将这一语言模型应用于任何预测任务。尽管模型产出的困惑度是合理的,该模型预测未来词元的能力却可能存在严重缺陷。上述代码是一个错误示范。

情感分类任务

定义双向循环神经网络

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
class BiRNN(nn.Module):
def __init__(self, vocab_size, embed_size, num_hiddens,
num_layers, **kwargs):
super(BiRNN, self).__init__(**kwargs)
self.embedding = nn.Embedding(vocab_size, embed_size)
# 将bidirectional设置为True以获取双向循环神经网络
self.encoder = nn.LSTM(embed_size, num_hiddens, num_layers=num_layers,
bidirectional=True)
self.decoder = nn.Linear(4 * num_hiddens, 2)

def forward(self, inputs):
# inputs的形状是(批量大小,时间步数)
# 因为长短期记忆网络要求其输入的第一个维度是时间维,
# 所以在获得词元表示之前,输入会被转置。
# 输出形状为(时间步数,批量大小,词向量维度)
embeddings = self.embedding(inputs.T)
self.encoder.flatten_parameters()
# 返回上一个隐藏层在不同时间步的隐状态,
# outputs的形状是(时间步数,批量大小,2*隐藏单元数)
outputs, _ = self.encoder(embeddings)
# 连结初始和最终时间步的隐状态,作为全连接层的输入,
# 其形状为(批量大小,4*隐藏单元数)
encoding = torch.cat((outputs[0], outputs[-1]), dim=1)
outs = self.decoder(encoding)
return outs

训练模型

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
import torch
from torch import nn
from d2l import torch as d2l

batch_size = 64
train_iter, test_iter, vocab = d2l.load_data_imdb(batch_size)

embed_size, num_hiddens, num_layers = 100, 100, 2
devices = d2l.try_all_gpus()
net = BiRNN(len(vocab), embed_size, num_hiddens, num_layers)
def init_weights(m):
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
if type(m) == nn.LSTM:
for param in m._flat_weights_names:
if "weight" in param:
nn.init.xavier_uniform_(m._parameters[param])
net.apply(init_weights);

glove_embedding = d2l.TokenEmbedding('glove.6b.100d')

embeds = glove_embedding[vocab.idx_to_token]

net.embedding.weight.data.copy_(embeds)
net.embedding.weight.requires_grad = False

lr, num_epochs = 0.01, 5
trainer = torch.optim.Adam(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss(reduction="none")
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
devices)
1
2
loss 0.286, train acc 0.882, test acc 0.852
6174.9 examples/sec on [device(type='cuda', index=0)]

预测

1
2
3
4
5
6
def predict_sentiment(net, vocab, sequence):
"""预测文本序列的情感"""
sequence = torch.tensor(vocab[sequence.split()], device=d2l.try_gpu())
label = torch.argmax(net(sequence.reshape(1, -1)), dim=1)
return 'positive' if label == 1 else 'negative'
predict_sentiment(net, vocab, 'this movie is so great')
1
'positive'

五、序列到序列学习(seq2seq)

5.1 数据预处理

下载一个由Tatoeba项目的双语句子对组成的“英-法”数据集,数据集中的每一行都是制表符分隔的文本序列对,序列对由英文文本序列和翻译后的法语文本序列组成。

1
2
3
4
5
6
7
8
9
10
11
12
13
import os
import torch
from d2l import torch as d2l

d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip',
'94646ad1522d915e7b0f9296181140edcf86a4f5')

def read_data_nmt():
"""载入“英语-法语”数据集"""
data_dir = d2l.download_extract('fra-eng')
with open(os.path.join(data_dir, 'fra.txt'), 'r',
encoding='utf-8') as f:
return f.read()

使用空格代替不间断空格(non-breaking space), 使用小写字母替换大写字母,并在单词和标点符号之间插入空格。

1
2
3
4
5
6
7
8
9
10
11
12
def preprocess_nmt(text):
"""预处理“英语-法语”数据集"""
def no_space(char, prev_char):
return char in set(',.!?') and prev_char != ' '

# 使用空格替换不间断空格
# 使用小写字母替换大写字母
text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
# 在单词和标点符号之间插入空格
out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char
for i, char in enumerate(text)]
return ''.join(out)

词元化

此函数返回两个词元列表:sourcetargetsource[i]是源语言(这里是英语)第个文本序列的词元列表,target[i]是目标语言(这里是法语)第个文本序列的词元列表。

1
2
3
4
5
6
7
8
9
10
11
def tokenize_nmt(text, num_examples=None):
"""词元化“英语-法语”数据数据集"""
source, target = [], []
for i, line in enumerate(text.split('\n')):
if num_examples and i > num_examples:
break
parts = line.split('\t')
if len(parts) == 2:
source.append(parts[0].split(' '))
target.append(parts[1].split(' '))
return source, target

词表

由于机器翻译数据集由语言对组成,因此可以分别为源语言和目标语言构建两个词表。使用单词级词元化时,词表大小将明显大于使用字符级词元化时的词表大小。为了缓解这一问题,这里将出现次数少于2次的低频率词元视为相同的未知(“<unk>”)词元。除此之外,还指定了额外的特定词元,例如在小批量时用于将序列填充到相同长度的填充词元(“<pad>”),以及序列的开始词元(“<bos>”)和结束词元(“<eos>”)。这些特殊词元在自然语言处理任务中比较常用。

1
2
3
4
5
raw_text = read_data_nmt()
text = preprocess_nmt(raw_text)
source, target = tokenize_nmt(text)
src_vocab = Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])

加载数据集

语言模型中的[序列样本都有一个固定的长度],这个固定长度是由num_steps(时间步数或词元数量)参数指定的。在机器翻译中,每个样本都是由源和目标组成的文本序列对,其中的每个文本序列可能具有不同的长度。

为了提高计算效率,仍然可以通过截断(truncation)和填充(padding)方式实现一次只处理一个小批量的文本序列。假设同一个小批量中的每个序列都应该具有相同的长度num_steps,那么如果文本序列的词元数目少于num_steps时,继续在其末尾添加特定的“<pad>”词元,直到其长度达到num_steps;反之,截断文本序列,只取其前num_steps 个词元,并且丢弃剩余的词。这样,每个文本序列将具有相同的长度,
以便以相同形状的小批量进行加载。

如前所述,下面的truncate_pad函数将(截断或填充文本序列)。

1
2
3
4
5
def truncate_pad(line, num_steps, padding_token):
"""截断或填充文本序列"""
if len(line) > num_steps:
return line[:num_steps] # 截断
return line + [padding_token] * (num_steps - len(line)) # 填充

现在定义一个函数,可以将文本序列[转换成小批量数据集用于训练]。将特定的“<eos>”词元添加到所有序列的末尾,用于表示序列的结束。当模型通过一个词元接一个词元地生成序列进行预测时,生成的“<eos>”词元说明完成了序列输出工作。此外,还记录了每个文本序列的长度,统计长度时排除了填充词元。

1
2
3
4
5
6
7
8
def build_array_nmt(lines, vocab, num_steps):
"""将机器翻译的文本序列转换成小批量"""
lines = [vocab[l] for l in lines]
lines = [l + [vocab['<eos>']] for l in lines]
array = torch.tensor([truncate_pad(
l, num_steps, vocab['<pad>']) for l in lines])
valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)
return array, valid_len

最后,定义load_data_nmt函数来返回数据迭代器,以及源语言和目标语言的两种词表。

1
2
3
4
5
6
7
8
9
10
11
12
13
def load_data_nmt(batch_size, num_steps, num_examples=600):
"""返回翻译数据集的迭代器和词表"""
text = preprocess_nmt(read_data_nmt())
source, target = tokenize_nmt(text, num_examples)
src_vocab = Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
tgt_vocab = Vocab(target, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)
data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)
data_iter = load_array(data_arrays, batch_size)
return data_iter, src_vocab, tgt_vocab

5.2 编码器-解码器架构

机器翻译是序列转换模型的一个核心问题,其输入和输出都是长度可变的序列。为了处理这种类型的输入和输出,可以设计一个包含两个主要组件的架构:第一个组件是一个编码器(encoder):它接受一个长度可变的序列作为输入,并将其转换为具有固定形状的编码状态。第二个组件是解码器(decoder):它将固定形状的编码状态映射到长度可变的序列。这被称为编码器-解码器(encoder-decoder)架构,如图所示。

编码器-解码器架构

遵循编码器-解码器架构的设计原则,循环神经网络编码器使用长度可变的序列作为输入,将其转换为固定形状的隐状态。换言之,输入序列的信息被编码到循环神经网络编码器的隐状态中。为了连续生成输出序列的词元,独立的循环神经网络解码器是基于输入序列的编码信息和输出序列已经看见的或者生成的词元来预测下一个词元。下图演示了如何在机器翻译中使用两个循环神经网络进行序列到序列学习。

使用循环神经网络编码器和循环神经网络解码器的序列到序列学习

在图中,特定的“<eos>”表示序列结束词元。一旦输出序列生成此词元,模型就会停止预测。在循环神经网络解码器的初始化时间步,有两个特定的设计决定:首先,特定的“<bos>”表示序列开始词元,它是解码器的输入序列的第一个词元。其次,使用循环神经网络编码器最终的隐状态来初始化解码器的隐状态。编码器最终的隐状态在每一个时间步都作为解码器的输入序列的一部分。类似于语言模型的训练,可以允许标签成为原始的输出序列,从源序列词元“<bos>”“Ils”“regardent”“.”到新序列词元“Ils”“regardent”“.”“<eos>”来移动预测的位置。

5.2.1 编码器

从技术上讲,编码器将长度可变的输入序列转换成形状固定的上下文变量,并且将输入序列的信息在该上下文变量中进行编码。可以使用循环神经网络来设计编码器。

考虑由一个序列组成的样本(批量大小是)。假设输入序列是,其中是输入文本序列中的第个词元。在时间步,循环神经网络将词元的输入特征向量(即上一时间步的隐状态)转换为(即当前步的隐状态)。使用一个函数来描述循环神经网络的循环层所做的变换:

总之,编码器通过选定的函数,将所有时间步的隐状态转换为上下文变量:

比如,当选择时(就像 :numref:fig_seq2seq中一样),上下文变量仅仅是输入序列在最后时间步的隐状态

使用一个单向循环神经网络来设计编码器,其中隐状态只依赖于输入子序列,这个子序列是由输入序列的开始位置到隐状态所在的时间步的位置(包括隐状态所在的时间步)组成。也可以使用双向循环神经网络构造编码器,其中隐状态依赖于两个输入子序列,两个子序列是由隐状态所在的时间步的位置之前的序列和之后的序列(包括隐状态所在的时间步),因此隐状态对整个序列的信息都进行了编码。

现在,实现循环神经网络编码器。注意,下述代码使用了嵌入层(embedding layer)来获得输入序列中每个词元的特征向量。嵌入层的权重是一个矩阵,其行数等于输入词表的大小(vocab_size),其列数等于特征向量的维度(embed_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
class Encoder(nn.Module):
"""编码器-解码器架构的基本编码器接口"""
def __init__(self, **kwargs):
super(Encoder, self).__init__(**kwargs)

def forward(self, X, *args):
raise NotImplementedError

class Seq2SeqEncoder(Encoder):
"""用于序列到序列学习的循环神经网络编码器"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqEncoder, self).__init__(**kwargs)
# 嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers,
dropout=dropout)

def forward(self, X, *args):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X)
# 在循环神经网络模型中,第一个轴对应于时间步
X = X.permute(1, 0, 2)
# 如果未提及状态,则默认为0
output, state = self.rnn(X)
# output的形状:(num_steps,batch_size,num_hiddens)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, state

5.2.2 解码器

正如上文提到的,编码器输出的上下文变量对整个输入序列进行编码。来自训练数据集的输出序列,对于每个时间步(与输入序列或编码器的时间步不同),解码器输出的概率取决于先前的输出子序列和上下文变量,即

为了在序列上模型化这种条件概率,可以使用另一个循环神经网络作为解码器。在输出序列上的任意时间步,循环神经网络将来自上一时间步的输出和上下文变量作为其输入,然后在当前时间步将它们和上一隐状态转换为隐状态。因此,可以使用函数来表示解码器的隐藏层的变换:

在获得解码器的隐状态之后,可以使用输出层和softmax操作来计算在时间步时输出的条件概率分布

当实现解码器时,直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元。为了进一步包含经过编码的输入序列的信息,上下文变量在所有的时间步与解码器的输入进行拼接(concatenate)。为了预测输出词元的概率分布,在循环神经网络解码器的最后一层使用全连接层来变换隐状态。

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
class Decoder(nn.Module):
"""编码器-解码器架构的基本解码器接口"""
def __init__(self, **kwargs):
super(Decoder, self).__init__(**kwargs)

def init_state(self, enc_outputs, *args):
raise NotImplementedError

def forward(self, X, state):
raise NotImplementedError

class Seq2SeqDecoder(d2l.Decoder):
"""用于序列到序列学习的循环神经网络解码器"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqDecoder, self).__init__(**kwargs)
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
self.dense = nn.Linear(num_hiddens, vocab_size)

def init_state(self, enc_outputs, *args):
return enc_outputs[1]

def forward(self, X, state):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X).permute(1, 0, 2)
# 广播context,使其具有与X相同的num_steps
context = state[-1].repeat(X.shape[0], 1, 1)
X_and_context = torch.cat((X, context), 2)
output, state = self.rnn(X_and_context, state)
output = self.dense(output).permute(1, 0, 2)
# output的形状:(batch_size,num_steps,vocab_size)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, state

合并编码器和解码器

1
2
3
4
5
6
7
8
9
10
11
class EncoderDecoder(nn.Module):
"""编码器-解码器架构的基类"""
def __init__(self, encoder, decoder, **kwargs):
super(EncoderDecoder, self).__init__(**kwargs)
self.encoder = encoder
self.decoder = decoder

def forward(self, enc_X, dec_X, *args):
enc_outputs = self.encoder(enc_X, *args)
dec_state = self.decoder.init_state(enc_outputs, *args)
return self.decoder(dec_X, dec_state)

总之,上述循环神经网络“编码器-解码器”模型中的各层如图所示。

循环神经网络编码器-解码器模型中的层

5.3 损失函数

在每个时间步,解码器预测了输出词元的概率分布。类似于语言模型,可以使用softmax来获得分布,并通过计算交叉熵损失函数来进行优化。特定的填充词元被添加到序列的末尾,因此不同长度的序列可以以相同形状的小批量加载。但是,应该将填充词元的预测排除在损失函数的计算之外。

为此,可以使用下面的sequence_mask函数[通过零值化屏蔽不相关的项],以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。例如,如果两个序列的有效长度(不包括填充词元)分别为,则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。

1
2
3
4
5
6
7
def sequence_mask(X, valid_len, value=0):
"""在序列中屏蔽不相关的项"""
maxlen = X.size(1)
mask = torch.arange((maxlen), dtype=torch.float32,
device=X.device)[None, :] < valid_len[:, None]
X[~mask] = value
return X

可以[通过扩展softmax交叉熵损失函数来遮蔽不相关的预测]。最初,所有预测词元的掩码都设置为1。一旦给定了有效长度,与填充词元对应的掩码将被设置为0。最后,将所有词元的损失乘以掩码,以过滤掉损失中填充词元产生的不相关预测。

1
2
3
4
5
6
7
8
9
10
11
12
13
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
"""带遮蔽的softmax交叉熵损失函数"""
# pred的形状:(batch_size,num_steps,vocab_size)
# label的形状:(batch_size,num_steps)
# valid_len的形状:(batch_size,)
def forward(self, pred, label, valid_len):
weights = torch.ones_like(label)
weights = sequence_mask(weights, valid_len)
self.reduction='none'
unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
pred.permute(0, 2, 1), label)
weighted_loss = (unweighted_loss * weights).mean(dim=1)
return weighted_loss

5.4 训练

在循环训练过程中,如seq2seq边解码架构图所示,特定的序列开始词元(“<bos>”)和原始的输出序列(不包括序列结束词元“<eos>”)拼接在一起作为解码器的输入。这被称为强制教学(teacher forcing),因为原始的输出序列(词元的标签)被送入解码器。或者,将来自上一个时间步的预测得到的词元作为解码器的当前输入。

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
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
"""训练序列到序列模型"""
def xavier_init_weights(m):
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
if type(m) == nn.GRU:
for param in m._flat_weights_names:
if "weight" in param:
nn.init.xavier_uniform_(m._parameters[param])

net.apply(xavier_init_weights)
net.to(device)
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
loss = MaskedSoftmaxCELoss()
net.train()
animator = d2l.Animator(xlabel='epoch', ylabel='loss',
xlim=[10, num_epochs])
for epoch in range(num_epochs):
timer = d2l.Timer()
metric = d2l.Accumulator(2) # 训练损失总和,词元数量
for batch in data_iter:
optimizer.zero_grad()
X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],
device=device).reshape(-1, 1)
dec_input = torch.cat([bos, Y[:, :-1]], 1) # 强制教学
Y_hat, _ = net(X, dec_input, X_valid_len)
l = loss(Y_hat, Y, Y_valid_len)
l.sum().backward() # 损失函数的标量进行“反向传播”
grad_clipping(net, 1)
num_tokens = Y_valid_len.sum()
optimizer.step()
with torch.no_grad():
metric.add(l.sum(), num_tokens)
if (epoch + 1) % 10 == 0:
animator.add(epoch + 1, (metric[0] / metric[1],))
print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
f'tokens/sec on {str(device)}')

现在,在机器翻译数据集上,可以创建和训练一个循环神经网络“编码器-解码器”模型用于序列到序列的学习。

1
2
3
4
5
6
7
8
9
10
11
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, try_gpu()

train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size, num_steps)
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,
dropout)
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,
dropout)
net = EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
1
loss 0.019, 48305.9 tokens/sec on cuda:0

5.5 预测

为了采用一个接着一个词元的方式预测输出序列,每个解码器当前时间步的输入都将来自于前一时间步的预测词元。与训练类似,序列开始词元(“<bos>”)在初始时间步被输入到解码器中。
该预测过程如下图所示,当输出序列的预测遇到序列结束词元(“<eos>”)时,预测就结束了。

使用循环神经网络编码器-解码器逐词元地预测输出序列。

5.5.1 预测序列的评估

BLEU(bilingual evaluation understudy)最先是用于评估机器翻译的结果,但现在它已经被广泛用于测量许多应用的输出序列的质量。原则上说,对于预测序列中的任意元语法(n-grams),BLEU的评估都是这个元语法是否出现在标签序列中。

BLEU定义为:

其中表示标签序列中的词元数和表示预测序列中的词元数,是用于匹配的最长的元语法。另外,用表示元语法的精确度,它是两个数量的比值:第一个是预测序列与标签序列中匹配的元语法的数量,第二个是预测序列中元语法的数量的比率。具体地说,给定标签序列和预测序列,有

根据BLEU的定义,当预测序列与标签序列完全相同时,BLEU为。此外,由于元语法越长则匹配难度越大,所以BLEU为更长的元语法的精确度分配更大的权重。具体来说,当固定时,会随着的增长而增加(原始论文使用)。而且,由于预测的序列越短获得的值越高,所以BLEU的定义中乘法项之前的系数用于惩罚较短的预测序列。例如,当时,给定标签序列和预测序列,尽管,惩罚因子会降低BLEU。

[BLEU的代码实现]如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def bleu(pred_seq, label_seq, k):  #@save
"""计算BLEU"""
pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
len_pred, len_label = len(pred_tokens), len(label_tokens)
score = math.exp(min(0, 1 - len_label / len_pred))
for n in range(1, k + 1):
num_matches, label_subs = 0, collections.defaultdict(int)
for i in range(len_label - n + 1):
label_subs[' '.join(label_tokens[i: i + n])] += 1
for i in range(len_pred - n + 1):
if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
num_matches += 1
label_subs[' '.join(pred_tokens[i: i + n])] -= 1
score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
return score

最后,利用训练好的循环神经网络“编码器-解码器”模型,[将几个英语句子翻译成法语],并计算BLEU的最终结果。

1
2
3
4
5
6
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
translation, attention_weight_seq = predict_seq2seq(
net, eng, src_vocab, tgt_vocab, num_steps, device)
print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')
1
2
3
4
go . => va !, bleu 1.000
i lost . => j'ai perdu ., bleu 1.000
he's calm . => il est malade triste ., bleu 0.548
i'm home . => je suis chez !, bleu 0.609
  • 根据“编码器-解码器”架构的设计,可以使用两个循环神经网络来设计一个序列到序列学习的模型。
  • 在实现编码器和解码器时,可以使用多层循环神经网络。
  • 可以使用遮蔽来过滤不相关的计算,例如在计算损失时。
  • 在“编码器-解码器”训练中,强制教学方法将原始输出序列(而非预测结果)输入解码器。
  • BLEU是一种常用的评估方法,它通过测量预测序列和标签序列之间的元语法的匹配度来评估预测。
  1. 李沐-动手学深度学习第二版

神经网络(六)——更多RNN
https://mztchaoqun.com.cn/posts/D59_RNN_More/
作者
mztchaoqun
发布于
2025年1月27日
许可协议