赞
踩
机器翻译是指将一段文本从一种语言自动翻译到另一种语言。因为一段文本序列在不同语言中的长度不一定相同,所以我们使用机器翻译为例来介绍编码器—解码器和注意力机制的应用。
我用过亚马逊的算力平台,一天免费四小时,说实话配置环境什么都挺方便的,可能外国的不用换源
国内的autodl我也用过,可以按小时租用gpu,也不错。推荐给大家
我们先定义一些特殊符号。其中“<pad>”(padding)符号用来添加在较短序列后,直到每个序列等长,而“<bos>”和“<eos>”符号分别表示序列的开始和结束。
!tar -xf d2lzh_pytorch.tar # 在Jupyter Notebook中执行shell命令,使用tar工具从名为d2lzh_pytorch.tar的归档文件中提取文件
import collections import os import io import math import torch from torch import nn import torch.nn.functional as F 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') print(torch.__version__, device)
1.5.0 cpu
接着定义两个辅助函数对后面读取的数据进行预处理。
# 将一个序列中所有的词记录在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)# 将序列中的所有词添加到all_tokens中 # 在序列末尾添加EOS符号,并使用PAD符号填充序列直到其长度达到max_seq_len seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1) # 将处理后的序列添加到all_seqs列表中 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] # 返回词汇表和表示序列索引的Tensor return vocab, torch.tensor(indices)
为了演示方便,我们在这里使用一个很小的法语—英语数据集。在这个数据集里,每一行是一对法语句子和它对应的英语句子,中间使用'\t'
隔开。在读取数据时,我们在句末附上“<eos>”符号,并可能通过添加“<pad>”符号使每个序列的长度均为max_seq_len
。我们为法语词和英语词分别创建词典。法语词的索引和英语词的索引相互独立。
def read_data(max_seq_len): # 定义输入和输出的token列表和序列列表 in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], [] # 读取文件 'fr-en-small.txt' 中的所有行 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(' ') # 将序列分割成单词列表 # 如果加上EOS后序列长度超过最大长度,则忽略此样本 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) # 返回输入词汇表、输出词汇表以及包含输入和输出数据的Tensor数据集 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]
(tensor([ 5, 4, 45, 3, 2, 0, 0]), tensor([ 8, 4, 27, 3, 2, 0, 0]))
我们将使用含注意力机制的编码器—解码器来将一段简短的法语翻译成英语。下面我们来介绍模型的实现。
在编码器中,我们将输入语言的词索引通过词嵌入层得到词的表征,然后输入到一个多层门控循环单元中。正如我们在6.5节(循环神经网络的简洁实现)中提到的,PyTorch的nn.GRU
实例在前向计算后也会分别返回输出和最终时间步的多层隐藏状态。其中的输出指的是最后一层的隐藏层在各个时间步的隐藏状态,并不涉及输出层计算。注意力机制将这些输出作为键项和值项。
# 定义编码器类,继承自nn.Module class Encoder(nn.Module): def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, drop_prob=0, **kwargs): super(Encoder, self).__init__(**kwargs) # 定义词嵌入层,输入是词汇表大小,输出是词嵌入的维度 self.embedding = nn.Embedding(vocab_size, embed_size) # 定义GRU层,输入是词嵌入的维度,输出是隐藏状态的维度,层数和dropout概率 self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob) # 前向传播函数,定义输入和状态 def forward(self, inputs, state): # 将输入的类型转换为long,并通过词嵌入层,得到的形状是(批量大小, 时间步数, 词嵌入维度) embedding = self.embedding(inputs.long()).permute(1, 0, 2) # (时间步数, 批量大小, 词嵌入维度) # 通过GRU层,返回输出和隐藏状态 return self.rnn(embedding, state) # 初始化状态 def begin_state(self): return None
下面我们来创建一个批量大小为4、时间步数为7的小批量序列输入。设门控循环单元的隐藏层个数为2,隐藏单元个数为16。编码器对该输入执行前向计算后返回的输出形状为(时间步数, 批量大小, 隐藏单元个数)。门控循环单元在最终时间步的多层隐藏状态的形状为(隐藏层个数, 批量大小, 隐藏单元个数)。对于门控循环单元来说,state
就是一个元素,即隐藏状态;如果使用长短期记忆,state
是一个元组,包含两个元素即隐藏状态和记忆细胞。
# 初始化一个Encoder对象
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
# 使用全零输入张量(形状为(批量大小=4, 时间步数=7))来测试Encoder
output, state = encoder(torch.zeros((4, 7)), encoder.begin_state())
# 打印输出和状态的形状
output.shape, state.shape # GRU的state是h, 而LSTM的是一个元组(h, c)
(torch.Size([7, 4, 16]), torch.Size([2, 4, 16]))
我们将实现注意力机制中定义的函数
a
a
a:将输入连结后通过含单隐藏层的多层感知机变换。其中隐藏层的输入是解码器的隐藏状态与编码器在所有时间步上隐藏状态的一一连结,且使用tanh函数作为激活函数。输出层的输出个数为1。两个Linear
实例均不使用偏差。其中函数
a
a
a定义里向量
v
\boldsymbol{v}
v的长度是一个超参数,即attention_size
。
# 定义注意力模型函数
def attention_model(input_size, attention_size):
# 创建一个顺序容器,包含多个神经网络层
model = nn.Sequential(
# 第一个线性层,输入维度为input_size,输出维度为attention_size,没有偏置项
nn.Linear(input_size, attention_size, bias=False),
# Tanh激活函数
nn.Tanh(),
# 第二个线性层,输入维度为attention_size,输出维度为1,没有偏置项
nn.Linear(attention_size, 1, bias=False)
)
return model
注意力机制的输入包括查询项、键项和值项。设编码器和解码器的隐藏单元个数相同。这里的查询项为解码器在上一时间步的隐藏状态,形状为(批量大小, 隐藏单元个数);键项和值项均为编码器在所有时间步的隐藏状态,形状为(时间步数, 批量大小, 隐藏单元个数)。注意力机制返回当前时间步的背景变量,形状为(批量大小, 隐藏单元个数)。
def attention_forward(model, enc_states, dec_state): """ 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) # 形状为(时间步数, 批量大小, 1) # 在时间步维度做softmax运算,得到注意力权重 alpha = F.softmax(e, dim=0) # 形状为(时间步数, 批量大小, 1) # 计算背景变量(context vector) return (alpha * enc_states).sum(dim=0) # 形状为(批量大小, 隐藏单元个数)
在下面的例子中,编码器的时间步数为10,批量大小为4,编码器和解码器的隐藏单元个数均为8。注意力机制返回一个小批量的背景向量,每个背景向量的长度等于编码器的隐藏单元个数。因此输出的形状为(4, 8)。
# 定义序列长度、批量大小和隐藏单元个数
seq_len, batch_size, num_hiddens = 10, 4, 8
# 初始化注意力模型
model = attention_model(2 * num_hiddens, 10) # 输入维度为2*num_hiddens,注意力维度为10
# 创建全零的编码器隐藏状态和解码器隐藏状态张量
enc_states = torch.zeros((seq_len, batch_size, num_hiddens)) # (10, 4, 8)
dec_state = torch.zeros((batch_size, num_hiddens)) # (4, 8)
# 调用attention_forward函数计算背景变量,并打印其形状
attention_forward(model, enc_states, dec_state).shape # 应输出: torch.Size([4, 8])
torch.Size([4, 8])
我们直接将编码器在最终时间步的隐藏状态作为解码器的初始隐藏状态。这要求编码器和解码器的循环神经网络使用相同的隐藏层个数和隐藏单元个数。
在解码器的前向计算中,我们先通过刚刚介绍的注意力机制计算得到当前时间步的背景向量。由于解码器的输入来自输出语言的词索引,我们将输入通过词嵌入层得到表征,然后和背景向量在特征维连结。我们将连结后的结果与上一时间步的隐藏状态通过门控循环单元计算出当前时间步的输出与隐藏状态。最后,我们将输出通过全连接层变换为有关各个输出词的预测,形状为(批量大小, 输出词典大小)。
# 定义解码器类,继承自nn.Module 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) # 定义GRU层,输入为attention输出的背景向量和实际输入的拼接,所以输入维度是num_hiddens + embed_size self.rnn = nn.GRU(num_hiddens + embed_size, num_hiddens, num_layers, dropout=drop_prob) # 定义输出层,将GRU的输出映射到词汇表大小的向量 self.out = nn.Linear(num_hiddens, vocab_size) def forward(self, cur_input, state, enc_states): """ cur_input shape: (batch, ) state shape: (num_layers, batch, num_hiddens) """ # 使用注意力机制计算背景向量 c = attention_forward(self.attention, enc_states, state[-1]) # 将嵌入后的输入和背景向量在特征维度上进行拼接,形状为(批量大小, num_hiddens + embed_size) input_and_c = torch.cat((self.embedding(cur_input), c), dim=1) # 为输入和背景向量的拼接增加时间步维度,时间步数为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
我们先实现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]) # 初始化损失为0 # 遍历Y的每个时间步(Y的形状是(批量大小, 序列长度)) for y in Y.permute(1, 0): # 将Y的维度变为(序列长度, 批量大小) 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): # 初始化优化器 enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=lr) dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=lr) # 定义损失函数 loss = 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) # 计算批量损失 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))) # 每10个epoch打印一次损失
接下来,创建模型实例并设置超参数。然后,我们就可以训练模型了。
# 定义模型参数
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)
epoch 10, loss 0.424
epoch 20, loss 0.212
epoch 30, loss 0.093
epoch 40, loss 0.046
epoch 50, loss 0.013
这里我们实现最简单的贪婪搜索来生成解码器在每个时间步的输出。
def translate(encoder, decoder, input_seq, max_seq_len): # 将输入序列按空格分割成词 in_tokens = input_seq.split(' ') # 在输入序列末尾添加EOS和PAD符号,直到其长度为max_seq_len in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1) # 将输入序列转换为张量表示,批量大小为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) # 初始化解码器的输入为BOS 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)
['they', 'are', 'watching', '.']
评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)[1]。对于模型预测序列中任意的子序列,BLEU考察这个子序列是否出现在标签序列中。
具体来说,设词数为 n n n的子序列的精度为 p n p_n pn。它是预测序列与标签序列匹配词数为 n n n的子序列的数量与预测序列中词数为 n n n的子序列的数量之比。举个例子,假设标签序列为 A A A、 B B B、 C C C、 D D D、 E E E、 F F F,预测序列为 A A A、 B B B、 B B B、 C C C、 D D D,那么 p 1 = 4 / 5 , p 2 = 3 / 4 , p 3 = 1 / 3 , p 4 = 0 p_1 = 4/5, p_2 = 3/4, p_3 = 1/3, p_4 = 0 p1=4/5,p2=3/4,p3=1/3,p4=0。设 l e n label len_{\text{label}} lenlabel和 l e n pred len_{\text{pred}} lenpred分别为标签序列和预测序列的词数,那么,BLEU的定义为
exp ( min ( 0 , 1 − l e n label l e n pred ) ) ∏ n = 1 k p n 1 / 2 n , \exp\left(\min\left(0, 1 - \frac{len_{\text{label}}}{len_{\text{pred}}}\right)\right) \prod_{n=1}^k p_n^{1/2^n}, exp(min(0,1−lenpredlenlabel))n=1∏kpn1/2n,
其中 k k k是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU为1。
因为匹配较长子序列比匹配较短子序列更难,BLEU对匹配较长子序列的精度赋予了更大权重。例如,当 p n p_n pn固定在0.5时,随着 n n n的增大, 0. 5 1 / 2 ≈ 0.7 , 0. 5 1 / 4 ≈ 0.84 , 0. 5 1 / 8 ≈ 0.92 , 0. 5 1 / 16 ≈ 0.96 0.5^{1/2} \approx 0.7, 0.5^{1/4} \approx 0.84, 0.5^{1/8} \approx 0.92, 0.5^{1/16} \approx 0.96 0.51/2≈0.7,0.51/4≈0.84,0.51/8≈0.92,0.51/16≈0.96。另外,模型预测较短序列往往会得到较高 p n p_n pn值。因此,上式中连乘项前面的系数是为了惩罚较短的输出而设的。举个例子,当 k = 2 k=2 k=2时,假设标签序列为 A A A、 B B B、 C C C、 D D D、 E E E、 F F F,而预测序列为 A A A、 B B B。虽然 p 1 = p 2 = 1 p_1 = p_2 = 1 p1=p2=1,但惩罚系数 exp ( 1 − 6 / 2 ) ≈ 0.14 \exp(1-6/2) \approx 0.14 exp(1−6/2)≈0.14,因此BLEU也接近0.14。
下面来实现BLEU的计算。
def bleu(pred_tokens, label_tokens, k): len_pred, len_label = len(pred_tokens), len(label_tokens) # 预测序列和标签序列的长度 # 计算BLEU得分的惩罚项:如果预测序列长度小于标签序列,惩罚为exp(1 - len_label / len_pred) score = math.exp(min(0, 1 - len_label / len_pred)) for n in range(1, k + 1): # 遍历从1到k的n-gram num_matches, label_subs = 0, collections.defaultdict(int) # 统计标签序列中所有n-gram的出现次数 for i in range(len_label - n + 1): label_subs[''.join(label_tokens[i: i + n])] += 1 # 统计预测序列中n-gram的匹配次数 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 # 更新BLEU得分 score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n)) return score # 返回最终的BLEU得分
接下来,定义一个辅助打印函数。
def score(input_seq, label_seq, k):
# 调用translate函数,将输入序列翻译成预测序列
pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
# 将标签序列按空格分割成词列表
label_tokens = label_seq.split(' ')
# 计算预测序列和标签序列的BLEU得分,并打印结果
print('bleu %.3f, predict: %s' % (bleu(pred_tokens, label_tokens, k),
' '.join(pred_tokens)))
预测正确则分数为1。
score('ils regardent .', 'they are watching .', k=2)
bleu 1.000, predict: they are watching .
score('ils sont canadienne .', 'they are canadian .', k=2)
bleu 0.658, predict: they are russian .
在训练过程中,将强制教学(teacher forcing)替换为使用解码器在上一时间步的输出作为解码器在当前时间步的输入,这种方法被称为“自回归”(autoregressive)训练。结果会有以下几方面的变化:
收敛速度变慢
在自回归训练中,由于解码器在每个时间步使用的是自己在前一个时间步的输出作为输入,错误可能会积累和传播。这可能导致模型收敛变慢,训练过程需要更多的时间。
训练不稳定性增加
因为解码器在每个时间步使用自己的输出作为输入,错误的预测可能会导致接下来的预测变得更加不准确,进而导致训练过程变得不稳定。
模型泛化能力可能增强
尽管收敛速度变慢且训练不稳定性增加,自回归训练可以使模型更好地适应测试时的实际情况。在测试时,模型没有参考正确的目标序列,只能依靠自己在前一个时间步的输出。因此,自回归训练可以提升模型在测试时的性能,使得模型在实际应用中表现更好。
训练和推理的一致性
使用自回归训练可以让训练和推理过程更加一致。在测试时,模型也会逐步生成每个时间步的输出,这与自回归训练时的过程相同。因此,这种一致性可能会导致模型在实际使用中的性能更好。
将强制教学替换为自回归训练会导致训练收敛速度变慢、训练不稳定性增加,但可能提升模型的泛化能力和推理时的一致性。这种方法能够让模型更好地适应实际使用中的情况,提高模型的实际性能。
[1] Papineni, K., Roukos, S., Ward, T., & Zhu, W. J. (2002, July). BLEU: a method for automatic evaluation of machine translation. In Proceedings of the 40th annual meeting on association for computational linguistics (pp. 311-318). Association for Computational Linguistics.
[2] WMT. http://www.statmt.org/wmt14/translation-task.html
[3] Tatoeba Project. http://www.manythings.org/anki/
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。