一、前言
1.编码器——解码器
1.1.编码器
编码器的作用是把一个不定长的输入序列变换成一个定长的背景变量𝑐𝑐,并在该背景变量中编码输入序列信息。常用的编码器是循环神经网络。
让我们考虑批量大小为1的时序数据样本。假设输入序列是𝑥1,…,𝑥𝑇𝑥1,…,𝑥𝑇,例如𝑥𝑖𝑥𝑖是输入句子中的第𝑖𝑖个词。在时间步𝑡𝑡,循环神经网络将输入𝑥𝑡𝑥𝑡的特征向量𝑥𝑡𝑥𝑡和上个时间步的隐藏状态ℎ𝑡−1ℎ𝑡−1变换为当前时间步的隐藏状态ℎ𝑡ℎ𝑡。我们可以用函数𝑓𝑓表达循环神经网络隐藏层的变换:
ℎ𝑡=𝑓(𝑥𝑡,ℎ𝑡−1).ℎ𝑡=𝑓(𝑥𝑡,ℎ𝑡−1).
接下来,编码器通过自定义函数𝑞𝑞将各个时间步的隐藏状态变换为背景变量
𝑐=𝑞(ℎ1,…,ℎ𝑇).𝑐=𝑞(ℎ1,…,ℎ𝑇).
例如,当选择𝑞(ℎ1,…,ℎ𝑇)=ℎ𝑇𝑞(ℎ1,…,ℎ𝑇)=ℎ𝑇时,背景变量是输入序列最终时间步的隐藏状态ℎ𝑇ℎ𝑇。
以上描述的编码器是一个单向的循环神经网络,每个时间步的隐藏状态只取决于该时间步及之前的输入子序列。我们也可以使用双向循环神经网络构造编码器。在这种情况下,编码器每个时间步的隐藏状态同时取决于该时间步之前和之后的子序列(包括当前时间步的输入),并编码了整个序列的信息。
1.2.解码器
刚刚已经介绍,编码器输出的背景变量𝑐𝑐编码了整个输入序列𝑥1,…,𝑥𝑇𝑥1,…,𝑥𝑇的信息。给定训练样本中的输出序列𝑦1,𝑦2,…,𝑦𝑇′𝑦1,𝑦2,…,𝑦𝑇′,对每个时间步𝑡′𝑡′(符号与输入序列或编码器的时间步𝑡𝑡有区别),解码器输出𝑦𝑡′𝑦𝑡′的条件概率将基于之前的输出序列𝑦1,…,𝑦𝑡′−1𝑦1,…,𝑦𝑡′−1和背景变量𝑐𝑐,即𝑃(𝑦𝑡′∣𝑦1,…,𝑦𝑡′−1,𝑐)𝑃(𝑦𝑡′∣𝑦1,…,𝑦𝑡′−1,𝑐)。
为此,我们可以使用另一个循环神经网络作为解码器。在输出序列的时间步𝑡′𝑡′,解码器将上一时间步的输出𝑦𝑡′−1𝑦𝑡′−1以及背景变量𝑐𝑐作为输入,并将它们与上一时间步的隐藏状态𝑠𝑡′−1𝑠𝑡′−1变换为当前时间步的隐藏状态𝑠𝑡′𝑠𝑡′。因此,我们可以用函数𝑔𝑔表达解码器隐藏层的变换:
𝑠𝑡′=𝑔(𝑦𝑡′−1,𝑐,𝑠𝑡′−1).𝑠𝑡′=𝑔(𝑦𝑡′−1,𝑐,𝑠𝑡′−1).
有了解码器的隐藏状态后,我们可以使用自定义的输出层和softmax运算来计算𝑃(𝑦𝑡′∣𝑦1,…,𝑦𝑡′−1,𝑐)𝑃(𝑦𝑡′∣𝑦1,…,𝑦𝑡′−1,𝑐),例如,基于当前时间步的解码器隐藏状态 𝑠𝑡′𝑠𝑡′、上一时间步的输出𝑦𝑡′−1𝑦𝑡′−1以及背景变量𝑐𝑐来计算当前时间步输出𝑦𝑡′𝑦𝑡′的概率分布。
1.3.训练模型
根据最大似然估计,我们可以最大化输出序列基于输入序列的条件概率
并得到该输出序列的损失
在模型训练中,所有输出序列损失的均值通常作为需要最小化的损失函数。在图10.8所描述的模型预测中,我们需要将解码器在上一个时间步的输出作为当前时间步的输入。与此不同,在训练中我们也可以将标签序列(训练集的真实输出序列)在上一个时间步的标签作为解码器在当前时间步的输入。这叫作强制教学(teacher forcing)。
2.搜索方法
2.1.贪婪搜索
让我们先来看一个简单的解决方案:贪婪搜索(greedy search)。对于输出序列任一时间步𝑡′,我们从|y|个词中搜索出条件概率最大的词
作为输出。一旦搜索出"<eos>"符号,或者输出序列长度已经达到了最大长度𝑇′,便完成输出。
我们在描述解码器时提到,基于输入序列生成输出序列的条件概率是∏𝑇′𝑡′=1𝑃(𝑦𝑡′∣𝑦1,…,𝑦𝑡′−1,𝑐)∏𝑡′=1𝑇′𝑃(𝑦𝑡′∣𝑦1,…,𝑦𝑡′−1,𝑐)。我们将该条件概率最大的输出序列称为最优输出序列。而贪婪搜索的主要问题是不能保证得到最优输出序列。
下面来看一个例子。假设输出词典里面有“A”“B”“C”和“<eos>”这4个词。图10.9中每个时间步下的4个数字分别代表了该时间步生成“A”“B”“C”和“<eos>”这4个词的条件概率。在每个时间步,贪婪搜索选取条件概率最大的词。因此,图10.9中将生成输出序列“A”“B”“C”“<eos>”。该输出序列的条件概率是0.5×0.4×0.4×0.6=0.0480.5×0.4×0.4×0.6=0.048。
图10.9 在每个时间步,贪婪搜索选取条件概率最大的词
接下来,观察图10.10演示的例子。与图10.9中不同,图10.10在时间步2中选取了条件概率第二大的词“C”。由于时间步3所基于的时间步1和2的输出子序列由图10.9中的“A”“B”变为了图10.10中的“A”“C”,图10.10中时间步3生成各个词的条件概率发生了变化。我们选取条件概率最大的词“B”。此时时间步4所基于的前3个时间步的输出子序列为“A”“C”“B”,与图10.9中的“A”“B”“C”不同。因此,图10.10中时间步4生成各个词的条件概率也与图10.9中的不同。我们发现,此时的输出序列“A”“C”“B”“<eos>”的条件概率是0.5×0.3×0.6×0.6=0.0540.5×0.3×0.6×0.6=0.054,大于贪婪搜索得到的输出序列的条件概率。因此,贪婪搜索得到的输出序列“A”“B”“C”“<eos>”并非最优输出序列。
图10.10 在时间步2选取条件概率第二大的词“C”
2.2.穷举搜索
如果目标是得到最优输出序列,我们可以考虑穷举搜索(exhaustive search):穷举所有可能的输出序列,输出条件概率最大的序列。
虽然穷举搜索可以得到最优输出序列,但它的计算开销很容易过大。例如,当|y|=10000且𝑇′=10时,我们将评估1000010=10401000010=1040个序列:这几乎不可能完成。而贪婪搜索的计算开销是,通常显著小于穷举搜索的计算开销。例如,当|𝑌|=10000且𝑇′=10时,我们只需评估10000×10=100000个序列。
2.3.束搜索
束搜索(beam search)是对贪婪搜索的一个改进算法。它有一个束宽(beam size)超参数。我们将它设为𝑘。在时间步1时,选取当前时间步条件概率最大的𝑘个词,分别组成𝑘个候选输出序列的首词。在之后的每个时间步,基于上个时间步的𝑘𝑘个候选输出序列,从𝑘|y|个可能的输出序列中选取条件概率最大的𝑘个,作为该时间步的候选输出序列。最终,我们从各个时间步的候选输出序列中筛选出包含特殊符号“<eos>”的序列,并将它们中所有特殊符号“<eos>”后面的子序列舍弃,得到最终候选输出序列的集合。
3.注意力机制
在许多先进的NMT系统中,还引入了“注意力机制”,以解决传统编码器—解码器结构在处理长句子时可能遗忘源语言信息的问题。注意力机制允许解码器在生成每个词时“关注”源语言句子中的某些特定部分,即动态地给予源语言句子中不同词以不同的权重。这样,解码器可以根据当前翻译的需要,聚焦于源语言句子中最相关的信息。
我们将实现10.11节(注意力机制)中定义的函数𝑎𝑎:将输入连结后通过含单隐藏层的多层感知机变换。其中隐藏层的输入是解码器的隐藏状态与编码器在所有时间步上隐藏状态的一一连结,且使用tanh函数作为激活函数。输出层的输出个数为1。两个Linear实例均不使用偏差。其中函数𝑎𝑎定义里向量𝑣𝑣的长度是一个超参数,即attention_size。
注意力机制的输入包括查询项、键项和值项。设编码器和解码器的隐藏单元个数相同。这里的查询项为解码器在上一时间步的隐藏状态,形状为(批量大小, 隐藏单元个数);键项和值项均为编码器在所有时间步的隐藏状态,形状为(时间步数, 批量大小, 隐藏单元个数)。注意力机制返回当前时间步的背景变量,形状为(批量大小, 隐藏单元个数)。
二、机器翻译
机器翻译是指将一段文本从一种语言自动翻译到另一种语言。因为一段文本序列在不同语言中的长度不一定相同,所以我们使用机器翻译为例来介绍编码器—解码器和注意力机制的应用。
1.读取和与处理数据
我们先定义一些特殊符号。其中“<pad>”(padding)符号用来添加在较短序列后,直到每个序列等长,而“<bos>”和“<eos>”符号分别表示序列的开始和结束。
import collections
import os
import io
import math
import torch
from torch import nn # PyTorch中的神经网络模块
import torch.nn.functional as F# 用于激活函数等功能的Functional API
import torchtext.vocab as Vocab
import torch.utils.data as Data
import sys
# sys.path.append("..")
import d2lzh_pytorch as d2l
PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')# 确定用于计算的设备;如果有可用的GPU则使用GPU,否则使用CPU
print(torch.__version__, device)# 打印当前使用的PyTorch版本和设备类型
接着定义两个辅助函数对后面读取的数据进行预处理。
# 将一个序列中所有的词记录在all_tokens中以便之后构造词典,然后在该序列后面添加PAD直到序列
# 长度变为max_seq_len,然后将序列保存在all_seqs中
def process_one_seq(seq_tokens, all_tokens, all_seqs, max_seq_len):
all_tokens.extend(seq_tokens)
seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)
all_seqs.append(seq_tokens)
# 使用所有的词来构造词典。并将所有序列中的词变换为词索引后构造Tensor
def build_data(all_tokens, all_seqs):
vocab = Vocab.Vocab(collections.Counter(all_tokens),
specials=[PAD, BOS, EOS])
indices = [[vocab.stoi[w] for w in seq] for seq in all_seqs]
return vocab, torch.tensor(indices)
为了演示方便,我们在这里使用一个很小的法语—英语数据集。在这个数据集里,每一行是一对法语句子和它对应的英语句子,中间使用'\t'
隔开。在读取数据时,我们在句末附上“<eos>”符号,并可能通过添加“<pad>”符号使每个序列的长度均为max_seq_len
。我们为法语词和英语词分别创建词典。法语词的索引和英语词的索引相互独立。
def read_data(max_seq_len):
# in和out分别是input和output的缩写
in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []
# 使用io.open打开文本文件,以处理不同操作系统下的文件路径问题
with io.open('fr-en-small.txt') as f:
lines = f.readlines()
for line in lines:
in_seq, out_seq = line.rstrip().split('\t')# 通过制表符'\t'分割每一行,得到输入序列和输出序列
in_seq_tokens, out_seq_tokens = in_seq.split(' '), out_seq.split(' ')# 进一步将输入和输出序列通过空格分割成单词列表
# 检查最长的序列(输入或输出)是否超过最大序列长度减1(预留位置给EOS符号)
if max(len(in_seq_tokens), len(out_seq_tokens)) > max_seq_len - 1:
continue # 如果加上EOS后长于max_seq_len,则忽略掉此样本
process_one_seq(in_seq_tokens, in_tokens, in_seqs, max_seq_len)
process_one_seq(out_seq_tokens, out_tokens, out_seqs, max_seq_len)
# 调用build_data函数,基于收集的token和序列信息,构建输入和输出的词典及数据
in_vocab, in_data = build_data(in_tokens, in_seqs)
out_vocab, out_data = build_data(out_tokens, out_seqs)
return in_vocab, out_vocab, Data.TensorDataset(in_data, out_data)
将序列的最大长度设成7,然后查看读取到的第一个样本。该样本分别包含法语词索引序列和英语词索引序列。
max_seq_len = 7
in_vocab, out_vocab, dataset = read_data(max_seq_len)
dataset[0]
2.训练模型
我们先实现batch_loss
函数计算一个小批量的损失。解码器在最初时间步的输入是特殊字符BOS
。之后,解码器在某时间步的输入为样本输出序列在上一时间步的词,即强制教学。此外,同10.3节(word2vec的实现)中的实现一样,我们在这里也使用掩码变量避免填充项对损失函数计算的影响。
def batch_loss(encoder, decoder, X, Y, loss):
batch_size = X.shape[0]
enc_state = encoder.begin_state()
enc_outputs, enc_state = encoder(X, enc_state)
# 初始化解码器的隐藏状态
dec_state = decoder.begin_state(enc_state)
# 解码器在最初时间步的输入是BOS
dec_input = torch.tensor([out_vocab.stoi[BOS]] * batch_size)
# 我们将使用掩码变量mask来忽略掉标签为填充项PAD的损失, 初始全1
mask, num_not_pad_tokens = torch.ones(batch_size,), 0
l = torch.tensor([0.0])
for y in Y.permute(1,0): # Y shape: (batch, seq_len)
dec_output, dec_state = decoder(dec_input, dec_state, enc_outputs)
l = l + (mask * loss(dec_output, y)).sum()
dec_input = y # 使用强制教学
num_not_pad_tokens += mask.sum().item()
# EOS后面全是PAD. 下面一行保证一旦遇到EOS接下来的循环中mask就一直是0
mask = mask * (y != out_vocab.stoi[EOS]).float()
return l / num_not_pad_tokens
在训练函数中,我们需要同时迭代编码器和解码器的模型参数。
def train(encoder, decoder, dataset, lr, batch_size, num_epochs):
# 初始化优化器,使用Adam算法
enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=lr)
dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=lr)
loss = nn.CrossEntropyLoss(reduction='none') # 定义损失函数为交叉熵损失,reduction='none'表示不对损失求和或平均
data_iter = Data.DataLoader(dataset, batch_size, shuffle=True) # 创建数据迭代器,用于分批次加载数据集
for epoch in range(num_epochs):
l_sum = 0.0
for X, Y in data_iter:
# 梯度清零,防止梯度累计
enc_optimizer.zero_grad()
dec_optimizer.zero_grad()
l = batch_loss(encoder, decoder, X, Y, loss)# 计算当前批次的损失
l.backward()# 反向传播计算梯度
enc_optimizer.step()# 更新模型参数
dec_optimizer.step()
l_sum += l.item()# 累加损失
if (epoch + 1) % 10 == 0:
print("epoch %d, loss %.3f" % (epoch + 1, l_sum / len(data_iter)))
接下来,创建模型实例并设置超参数。然后,我们就可以训练模型了。
embed_size, num_hiddens, num_layers = 64, 64, 2# 定义模型参数
# 嵌入层维度、隐藏层单元数、隐藏层数量
attention_size, drop_prob, lr, batch_size, num_epochs = 10, 0.5, 0.01, 2, 50
encoder = Encoder(len(in_vocab), embed_size, num_hiddens, num_layers,
drop_prob)# 构建编码器模型
decoder = Decoder(len(out_vocab), embed_size, num_hiddens, num_layers,
attention_size, drop_prob)# 构建解码器模型
train(encoder, decoder, dataset, lr, batch_size, num_epochs)
3.预测不定长序列
在之前我们介绍了3种方法来生成解码器在每个时间步的输出。这里我们实现最简单的贪婪搜索。
def translate(encoder, decoder, input_seq, max_seq_len):
in_tokens = input_seq.split(' ')# 处理输入序列:分词、添加EOS和PAD
in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)
enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]]) # batch=1
enc_state = encoder.begin_state() # 将输入序列词索引化
enc_output, enc_state = encoder(enc_input, enc_state) # 初始化编码器状态并获取编码输出和最终状态
dec_input = torch.tensor([out_vocab.stoi[BOS]])
dec_state = decoder.begin_state(enc_state)
output_tokens = []
for _ in range(max_seq_len):
dec_output, dec_state = decoder(dec_input, dec_state, enc_output) # 通过解码器进行一步解码
pred = dec_output.argmax(dim=1)
pred_token = out_vocab.itos[int(pred.item())]# 获取最有可能的预测词索引及其对应的词
if pred_token == EOS: # 当任一时间步搜索出EOS时,输出序列即完成
break
else:
output_tokens.append(pred_token)
dec_input = pred
return output_tokens
简单测试一下模型。输入法语句子“ils regardent.”,翻译后的英语句子应该是“they are watching.”。
input_seq = 'ils regardent .'
translate(encoder, decoder, input_seq, max_seq_len)
4.评价翻译结果
评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)[1]。对于模型预测序列中任意的子序列,BLEU考察这个子序列是否出现在标签序列中。
下面来实现BLEU的计算。
def bleu(pred_tokens, label_tokens, k):
len_pred, len_label = len(pred_tokens), len(label_tokens)# 计算预测序列和参考序列的长度
score = math.exp(min(0, 1 - len_label / len_pred))# 初始化BLEU得分,考虑长度惩罚
for n in range(1, k + 1):
num_matches, label_subs = 0, collections.defaultdict(int) # 初始化匹配计数和构建参考n-gram计数表
# 统计参考文本中所有n-gram的频率
for i in range(len_label - n + 1):
label_subs[''.join(label_tokens[i: i + n])] += 1
# 遍历预测序列中的n-gram,计算匹配数并更新label_subs以避免重复计数
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))# 更新BLEU得分,考虑n-gram的精确率和长度
return score
接下来,定义一个辅助打印函数。
def score(input_seq, label_seq, k):
pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
label_tokens = label_seq.split(' ')
print('bleu %.3f, predict: %s' % (bleu(pred_tokens, label_tokens, k),
' '.join(pred_tokens)))
预测正确则分数为1。
score('ils regardent .', 'they are watching .', k=2)
score('ils sont canadienne .', 'they are canadian .', k=2)