目录
一、引言
随着人工智能领域的快速发展,机器翻译作为自然语言处理的重要分支,在过去几十年里取得了显著进展。传统的统计机器翻译模型在一定程度上解决了翻译任务,然而,随着深度学习技术的兴起,基于神经网络的机器翻译模型日益成为主流。其中,注意力机制作为一种关键的技术,极大地提升了机器翻译系统的性能和效果。因此本文采用基于注意力机制的机器翻译模型实现快速高校的机器翻译任务。
二、注意力机制
2.1 注意力机制的起源
注意力机制是一种机器学习中的关键技术,特别在自然语言处理领域中被广泛应用。它源于对传统编码-解码结构的改进需求,通过动态调整输入序列中不同位置的注意力权重,使得模型能够集中关注与当前生成目标语言单词最相关的源语言部分。这种机制不仅提升了机器翻译的准确性,还有效解决了长距离依赖问题,使得模型能够处理更长的句子并在多语种翻译任务中表现出色。
从它的命名方式看,很明显借鉴了人类的注意力机制,因此,我们首先介绍人类的视觉注意力。
视觉注意力机制是人类视觉所特有的大脑信号处理机制。人类视觉通过快速扫描全局图像,获得需要重点关注的目标区域,也就是一般所说的注意力焦点,而后对这一区域投入更多注意力资源,以获取更多所需要关注目标的细节信息,而抑制其他无用信息。
这是人类利用有限的注意力资源从大量信息中快速筛选出高价值信息的手段,是人类在长期进化中形成的一种生存机制,人类视觉注意力机制极大地提高了视觉信息处理的效率与准确性。
上图形象化展示了人类在看到一副图像时是如何高效分配有限的注意力资源的,其中红色区域表明视觉系统更关注的目标,很明显对于上图所示的场景,人们会把注意力更多投入到人的脸部,文本的标题以及文章首句等位置。
2.2 注意力模型
深度学习中的注意力机制从本质上讲和人类的选择性视觉注意力机制类似,核心目标也是从众多信息中选择出对当前任务目标更关键的信息。
注意力模型从大量信息 Values 中筛选出少量重要信息,这些重要信息一定是相对于另外一个信息 Query 而言是重要的,例如对于上面那张婴儿图,Query 就是观察者。也就是说,我们要搭建一个注意力模型,我们必须得要有一个 Query 和一个 Values,然后通过 Query 这个信息从 Values 中筛选出重要信息。通过 Query 这个信息从 Values 中筛选出重要信息,简单点说,就是计算 Query 和 Values 中每个信息的相关程度。
如上图所示,attention模型的具体计算过可以分为三个阶段:
三、基于注意力机制的机器翻译模型实现
首先需要导入几个相关的库,并确认cuda的可用情况。
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 # 导入d2lzh_pytorch库
PAD, BOS, EOS = '<pad>', '<bos>', '<eos>' # 定义特殊标记
os.environ["CUDA_VISIBLE_DEVICES"] = "0" # 设置环境变量CUDA_VISIBLE_DEVICES,指定使用的GPU设备编号
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 根据CUDA是否可用选择设备
接着定义两个辅助函数对后面读取的数据进行预处理。
# 将一个序列中所有的词记录在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_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: # 如果序列长度超过max_seq_len
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) # 调用read_data函数读取数据并生成词汇表和数据集
dataset[0] # 访问数据集中的第一个样本
结果如下:(tensor([ 5, 4, 45, 3, 2, 0, 0]), tensor([ 8, 4, 27, 3, 2, 0, 0]))
接下来我们将使用含注意力机制的编码器—解码器来将一段简短的法语翻译成英语。下面我们来介绍模型的实现。
在编码器中,我们将输入语言的词索引通过词嵌入层得到词的表征,然后输入到一个多层门控循环单元中。PyTorch的nn.GRU例在前向计算后也会分别返回输出和最终时间步的多层隐藏状态。其中的输出指的是最后一层的隐藏层在各个时间步的隐藏状态,并不涉及输出层计算。注意力机制将这些输出作为键项和值项。
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) # 定义embedding层,将词索引映射为稠密向量
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob) # 定义GRU循环神经网络
def forward(self, inputs, state):
# inputs的形状是(批量大小, 时间步数),将输出互换样本维和时间步维
embedding = self.embedding(inputs.long()).permute(1, 0, 2) # 将输入序列经过embedding层,并转置维度
return self.rnn(embedding, state) # 将转置后的embedding向量和状态输入GRU网络
def begin_state(self):
return None # 初始状态为空,由于GRU不需要初始状态而返回None
下面我们来创建一个批量大小为4、时间步数为7的小批量序列输入。设门控循环单元的隐藏层个数为2,隐藏单元个数为16。编码器对该输入执行前向计算后返回的输出形状为(时间步数, 批量大小, 隐藏单元个数)。门控循环单元在最终时间步的多层隐藏状态的形状为(隐藏层个数, 批量大小, 隐藏单元个数)。对于门控循环单元来说,state
就是一个元素,即隐藏状态;如果使用长短期记忆,state
是一个元组,包含两个元素即隐藏状态和记忆细胞。
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2) # 创建Encoder对象,设置参数
output, state = encoder(torch.zeros((4, 7)), encoder.begin_state()) # 将输入张量(4, 7)传入Encoder并获取输出和状态
output.shape, state.shape # 输出GRU的输出张量形状和状态张量形状,GRU的状态是h,而LSTM的状态是一个元组(h, c)
接下来我们展示了如何定义和使用一个简单的注意力模型,并通过前向传播函数计算了注意力加权后的编码器状态。
def attention_model(input_size, attention_size):
# 定义一个注意力模型,接受输入大小和注意力大小作为参数
model = nn.Sequential(
nn.Linear(input_size, attention_size, bias=False), # 使用线性层将输入映射到注意力大小
nn.Tanh(), # 使用Tanh激活函数
nn.Linear(attention_size, 1, bias=False) # 再次使用线性层将注意力大小映射到1维
)
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)
alpha = F.softmax(e, dim=0) # 在时间步维度做softmax运算
return (alpha * enc_states).sum(dim=0) # 返回背景变量
seq_len, batch_size, num_hiddens = 10, 4, 8 # 定义序列长度、批量大小和隐藏单元数
model = attention_model(2*num_hiddens, 10) # 使用定义好的attention_model创建注意力模型
enc_states = torch.zeros((seq_len, batch_size, num_hiddens)) # 创建编码器状态张量,形状为(seq_len, batch_size, num_hiddens)
dec_state = torch.zeros((batch_size, num_hiddens)) # 创建解码器状态张量,形状为(batch_size, num_hiddens)
attention_forward(model, enc_states, dec_state).shape # 调用attention_forward函数计算注意力加权后的输出张量形状
然后我们定义解码器的内容
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输出的c和实际输入, 所以尺寸是 num_hiddens+embed_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):
"""
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
接下来我们实现一个完整的机器翻译模型的训练过程,包括了编码器和解码器的定义、损失函数的计算、优化器的使用以及数据的加载和迭代。
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):
enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=lr) # 创建编码器优化器,使用Adam优化器,学习率为lr
dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=lr) # 创建解码器优化器,使用Adam优化器,学习率为lr
loss = nn.CrossEntropyLoss(reduction='none') # 定义交叉熵损失函数,reduction='none'表示不对批量求平均
data_iter = Data.DataLoader(dataset, batch_size, shuffle=True) # 创建数据迭代器,用于批量加载数据集,每批大小为batch_size,打乱顺序
for epoch in range(num_epochs): # 循环训练epoch次
l_sum = 0.0 # 初始化损失累加器
for X, Y in data_iter: # 遍历数据迭代器,获取每个批次的输入X和标签Y
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: # 每10个epoch打印一次
print("epoch %d, loss %.3f" % (epoch + 1, l_sum / len(data_iter))) # 打印当前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 # 定义注意力大小、dropout概率、学习率、批量大小和训练epoch数
encoder = Encoder(len(in_vocab), embed_size, num_hiddens, num_layers,
drop_prob) # 创建编码器实例,输入词汇表大小、嵌入大小、隐藏单元数、层数和dropout概率
decoder = Decoder(len(out_vocab), embed_size, num_hiddens, num_layers,
attention_size, drop_prob) # 创建解码器实例,输出词汇表大小、嵌入大小、隐藏单元数、层数、注意力大小和dropout概率
train(encoder, decoder, dataset, lr, batch_size, num_epochs) # 调用train函数进行模型训练,传入编码器、解码器、数据集、学习率、批量大小和训练epoch数
训练过程如下图所示:
接下来我们进行翻译测试,这里我们实现最简单的贪婪搜索。
def translate(encoder, decoder, input_seq, max_seq_len):
# 将输入序列按空格分割成单词,并添加结束标记EOS和填充标记PAD,保证长度为max_seq_len
in_tokens = input_seq.split(' ')
in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)
# 将输入序列转换为对应的索引序列,构造成张量,batch大小为1
enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]])
# 获取编码器的初始状态
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 = [] # 用于存储输出的token序列
# 循环生成序列,最多生成max_seq_len个token
for _ in range(max_seq_len):
# 使用解码器进行解码,得到解码输出和更新的解码器状态
dec_output, dec_state = decoder(dec_input, dec_state, enc_output)
# 获取预测的下一个token
pred = dec_output.argmax(dim=1)
pred_token = out_vocab.itos[int(pred.item())] # 将预测的token索引转换为实际token
# 如果预测到结束标记EOS,则停止生成序列
if pred_token == EOS:
break
else:
output_tokens.append(pred_token) # 将预测的token添加到输出序列
dec_input = pred # 更新解码器的输入为当前预测的token
return output_tokens # 返回生成的输出token序列
简单测试一下模型。输入法语句子“ils regardent.”,翻译后的英语句子应该是“they are watching.”。
input_seq = 'ils regardent .'
translate(encoder, decoder, input_seq, max_seq_len)
翻译结果如下所示:
['they', 'are', 'watching', '.']
最后,我们需要定义一个评价指标,这里我们采用BLEU(Bilingual Evaluation Understudy)。对于模型预测序列中任意的子序列,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)) # 计算长度惩罚,避免过短的预测序列
# 计算n-gram的匹配数量和频率
for n in range(1, k + 1):
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
# 计算n-gram匹配的精确率,乘以相应的权重
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函数生成预测的token序列
pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
# 将参考序列按空格分割成单词,作为参考的token序列
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 actors .
至此,本实验结束,有兴趣的同学可以去复现一下本实验。
四、小结与致谢
以上内容就是我对基于注意力机制的机器翻译模型的浅薄理解,欢迎各位批评指正,同时也欢迎大家留言讨论。在此,我要感谢对本文有巨大帮助的董老师,以及其他帮助过我的同学们,希望大家都能有个美好的未来。谢谢大家。