作者 | WA2114152 潘豪
机器翻译是指将一段文本从一种语言自动翻译到另一种语言。由于不同语言的文本长度不一定相同,因此我们需要一种灵活的翻译模型来处理这种差异。在本文中,我们将探讨如何使用编码器—解码器和注意力机制来实现机器翻译。
1. 读取和预处理数据
在开始构建模型之前,我们需要处理输入数据。我们定义了一些特殊符号,例如“<pad>”(填充)、“<bos>”(序列开始)和“<eos>”(序列结束),并通过辅助函数来读取和预处理数据。
定义特殊符号及初始化环境
import collections
import os
import io
import torch
from torch import nn
import torch.nn.functional as F
import torchtext.vocab as Vocab
import torch.utils.data as Data
PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(torch.__version__, device)
辅助函数:处理和构建数据
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)
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>符号将所有序列填充到相同长度。
读取数据
def read_data(max_seq_len):
in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []
with io.open('fr-en-small.txt') as f:
lines = f.readlines()
for line in lines:
in_seq, out_seq = line.rstrip().split('\t')
in_seq_tokens, out_seq_tokens = in_seq.split(' '), out_seq.split(' ')
if max(len(in_seq_tokens), len(out_seq_tokens)) > max_seq_len - 1:
continue
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)
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)
print(dataset[0])
2. 含注意力机制的编码器—解码器
接下来,我们将使用含注意力机制的编码器—解码器模型来进行翻译。
在编码器中,我们将输入语言的词索引通过词嵌入层得到词的表征,然后输入到一个多层门控循环单元中。正如我们在6.5节(循环神经网络的简洁实现)中提到的,PyTorch的nn.GRU
实例在前向计算后也会分别返回输出和最终时间步的多层隐藏状态。其中的输出指的是最后一层的隐藏层在各个时间步的隐藏状态,并不涉及输出层计算。注意力机制将这些输出作为键项和值项。
编码器
编码器将输入语言的词索引通过词嵌入层转换为词向量表征,然后输入到一个多层门控循环单元(GRU)中。
class Encoder(nn.Module):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, drop_prob=0):
super(Encoder, self).__init__()
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob)
def forward(self, inputs, state):
embedding = self.embedding(inputs.long()).permute(1, 0, 2)
return self.rnn(embedding, state)
def begin_state(self):
return None
encoder = Encoder(len(in_vocab), 64, 64, 2)
output, state = encoder(torch.zeros((4, 7)), encoder.begin_state())
print(output.shape, state.shape)
注意力机制
我们将实现一个含单隐藏层的多层感知机(MLP),并使用注意力机制计算当前时间步的背景变量。
def attention_model(input_size, attention_size):
model = nn.Sequential(nn.Linear(input_size, attention_size, bias=False),
nn.Tanh(),
nn.Linear(attention_size, 1, bias=False))
return model
def attention_forward(model, enc_states, dec_state):
dec_states = dec_state.unsqueeze(dim=0).expand_as(enc_states)
enc_and_dec_states = torch.cat((enc_states, dec_states), dim=2)
e = model(enc_and_dec_states)
alpha = F.softmax(e, dim=0)
return (alpha * enc_states).sum(dim=0)
seq_len, batch_size, num_hiddens = 10, 4, 8
model = attention_model(2*num_hiddens, 10)
enc_states = torch.zeros((seq_len, batch_size, num_hiddens))
dec_state = torch.zeros((batch_size, num_hiddens))
print(attention_forward(model, enc_states, dec_state).shape)
含注意力机制的解码器
我们直接将编码器在最终时间步的隐藏状态作为解码器的初始隐藏状态。这要求编码器和解码器的循环神经网络使用相同的隐藏层个数和隐藏单元个数。
在解码器的前向计算中,我们先通过刚刚介绍的注意力机制计算得到当前时间步的背景向量。由于解码器的输入来自输出语言的词索引,我们将输入通过词嵌入层得到表征,然后和背景向量在特征维连结。我们将连结后的结果与上一时间步的隐藏状态通过门控循环单元计算出当前时间步的输出与隐藏状态。最后,我们将输出通过全连接层变换为有关各个输出词的预测,形状为(批量大小, 输出词典大小)。
class Decoder(nn.Module):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, attention_size, drop_prob=0):
super(Decoder, self).__init__()
self.embedding = nn.Embedding(vocab_size, embed_size)
self.attention = attention_model(2*num_hiddens, attention_size)
self.rnn = nn.GRU(num_hiddens + embed_size, num_hiddens, num_layers, dropout=drop_prob)
self.out = nn.Linear(num_hiddens, vocab_size)
def forward(self, cur_input, state, enc_states):
c = attention_forward(self.attention, enc_states, state[-1])
input_and_c = torch.cat((self.embedding(cur_input), c), dim=1)
output, state = self.rnn(input_and_c.unsqueeze(0), state)
output = self.out(output).squeeze(dim=0)
return output, state
def begin_state(self, enc_state):
return enc_state
3. 训练模型
定义训练函数,并在每个小批量上计算损失。使用交叉熵损失函数,使用掩码变量忽略填充项对损失的影响。
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)
dec_input = torch.tensor([out_vocab.stoi[BOS]] * batch_size)
mask, num_not_pad_tokens = torch.ones(batch_size,), 0
l = torch.tensor([0.0])
for y in Y.permute(1,0):
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()
mask = mask * (y != out_vocab.stoi[EOS]).float()
return l / num_not_pad_tokens
def train(encoder, decoder, dataset, lr, batch_size, num_epochs):
enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=lr)
dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=lr)
loss_fn = nn.CrossEntropyLoss(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_fn)
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)
4. 预测不定长的序列
使用贪婪搜索方法生成解码器输出序列,并通过模型预测函数进行翻译。
def translate(encoder, decoder, input_seq, max_seq_len):
input_tokens = input_seq.split(' ')
enc_input = torch.tensor([in_vocab.stoi[w] for w in input_tokens] + [in_vocab.stoi[PAD]] * (max_seq_len - len(input_tokens) - 1))
enc_outputs, enc_state = encoder(enc_input.unsqueeze(0), encoder.begin_state())
dec_input, dec_state = torch.tensor([out_vocab.stoi[BOS]]), encoder.begin_state(enc_state)
output_tokens = []
for _ in range(max_seq_len):
dec_output, dec_state = decoder(dec_input, dec_state, enc_outputs)
pred = dec_output.argmax(dim=1)
pred_token = out_vocab.itos[pred.item()]
if pred_token == EOS:
break
else:
output_tokens.append(pred_token)
dec_input = pred
return output_tokens
print(translate(encoder, decoder, 'elle est difficile .', max_seq_len))
5. 评价翻译结果
为了评价模型的翻译效果,我们可以计算BLEU(Bilingual Evaluation Understudy)得分。
评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)[1]。对于模型预测序列中任意的子序列,BLEU考察这个子序列是否出现在标签序列中。
具体来说,设词数为𝑛𝑛的子序列的精度为𝑝𝑛𝑝𝑛。它是预测序列与标签序列匹配词数为𝑛𝑛的子序列的数量与预测序列中词数为𝑛𝑛的子序列的数量之比。举个例子,假设标签序列为𝐴𝐴、𝐵𝐵、𝐶𝐶、𝐷𝐷、𝐸𝐸、𝐹𝐹,预测序列为𝐴𝐴、𝐵𝐵、𝐵𝐵、𝐶𝐶、𝐷𝐷,那么𝑝1=4/5,𝑝2=3/4,𝑝3=1/3,𝑝4=0𝑝1=4/5,𝑝2=3/4,𝑝3=1/3,𝑝4=0。设𝑙𝑒𝑛label𝑙𝑒𝑛label和𝑙𝑒𝑛pred𝑙𝑒𝑛pred分别为标签序列和预测序列的词数,那么,BLEU的定义为
exp(min(0,1−𝑙𝑒𝑛label𝑙𝑒𝑛pred))∏𝑛=1𝑘𝑝1/2𝑛𝑛,exp(min(0,1−𝑙𝑒𝑛label𝑙𝑒𝑛pred))∏𝑛=1𝑘𝑝𝑛1/2𝑛,
其中𝑘𝑘是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU为1。
因为匹配较长子序列比匹配较短子序列更难,BLEU对匹配较长子序列的精度赋予了更大权重。例如,当𝑝𝑛𝑝𝑛固定在0.5时,随着𝑛𝑛的增大,0.51/2≈0.7,0.51/4≈0.84,0.51/8≈0.92,0.51/16≈0.960.51/2≈0.7,0.51/4≈0.84,0.51/8≈0.92,0.51/16≈0.96。另外,模型预测较短序列往往会得到较高𝑝𝑛𝑝𝑛值。因此,上式中连乘项前面的系数是为了惩罚较短的输出而设的。举个例子,当𝑘=2𝑘=2时,假设标签序列为𝐴𝐴、𝐵𝐵、𝐶𝐶、𝐷𝐷、𝐸𝐸、𝐹𝐹,而预测序列为𝐴𝐴、𝐵𝐵。虽然𝑝1=𝑝2=1𝑝1=𝑝2=1,但惩罚系数exp(1−6/2)≈0.14exp(1−6/2)≈0.14,因此BLEU也接近0.14。
以下是计算BLEU得分的代码:
import nltk
def compute_bleu(pred_seq, label_seq, k):
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
reference = 'it is difficult .'
prediction = ' '.join(translate(encoder, decoder, 'elle est difficile .', max_seq_len))
bleu_score = compute_bleu(prediction, reference, 4)
print(f'BLEU score: {bleu_score:.3f}')
通过上述步骤,我们可以使用编码器—解码器模型及注意力机制实现并评估机器翻译。进一步的优化和大规模数据集训练可以提高模型的翻译效果。