一、前置知识展示
1.1、编码器与解码器
1.1.1、编码器
考虑批量大小为1的时序数据样本。假设输入序列是𝑥1,…,𝑥𝑇,例如𝑥𝑖是输入句子中的第𝑖个词。在时间步𝑡,循环神经网络将输入𝑥𝑡的特征向量𝑥𝑡和上个时间步的隐藏状态ℎ𝑡−1变换为当前时间步的隐藏状态ℎ𝑡。我们可以用函数𝑓表达循环神经网络隐藏层的变换:
ℎ𝑡=𝑓(𝑥𝑡,ℎ𝑡−1)
接下来,编码器通过自定义函数𝑞将各个时间步的隐藏状态变换为背景变量
𝑐=𝑞(ℎ1,…,ℎ𝑇).
例如,当选择𝑞(ℎ1,…,ℎ𝑇)=ℎ𝑇时,背景变量是输入序列最终时间步的隐藏状态ℎ𝑇。
1.1.2、解码器
编码器输出的背景变量𝑐编码了整个输入序列𝑥1,…,𝑥𝑇的信息。给定训练样本中的输出序列𝑦1,𝑦2,…,𝑦𝑇′𝑦1,𝑦2,…,𝑦𝑇′,对每个时间步𝑡′𝑡′(符号与输入序列或编码器的时间步𝑡有区别),解码器输出𝑦𝑡′的条件概率将基于之前的输出序列𝑦1,…,𝑦𝑡′−1和背景变量𝑐,即
𝑃(𝑦𝑡′∣𝑦1,…,𝑦𝑡′−1,𝑐)
为此,我们可以使用另一个循环神经网络作为解码器。在输出序列的时间步𝑡′,解码器将上一时间步的输出𝑦𝑡′−1以及背景变量𝑐作为输入,并将它们与上一时间步的隐藏状态𝑠𝑡′−1变换为当前时间步的隐藏状态𝑠𝑡′。因此,我们可以用函数𝑔表达解码器隐藏层的变换:
𝑠𝑡′=𝑔(𝑦𝑡′−1,𝑐,𝑠𝑡′−1)
有了解码器的隐藏状态后,我们可以使用自定义的输出层softmax运算来计算𝑃(𝑦𝑡′∣𝑦1,…,𝑦𝑡′−1,𝑐),例如,基于当前时间步的解码器隐藏状态 𝑠𝑡′、上一时间步的输出𝑦𝑡′−1以及背景变量𝑐来计算当前时间步输出𝑦𝑡′的概率分布。
1.2、贪婪搜索
对于输出序列任一时间步𝑡′𝑡′,我们从y个词中搜索出条件概率最大的词
作为输出。一旦搜索出"<eos>"符号,或者输出序列长度已经达到了最大长度𝑇′,便完成输出。
我们在描述解码器时提到,基于输入序列生成输出序列的条件概率是
我们将该条件概率最大的输出序列称为最优输出序列。而贪婪搜索的主要问题是不能保证得到最优输出序列。
1.3、BLUE函数
评价翻译结果通常使用的是BLUE函数,设词数为𝑛的子序列的精度为,它是预测序列与标签序列匹配词数为𝑛的子序列的数量与预测序列中词数为𝑛的子序列的数量之比。
设和分别为标签序列和预测序列的词数,则BLUE函数定义如下
其中𝑘是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU为1。
二、机器翻译
2.1、读取数据与预处理
!tar -xf d2lzh_pytorch.tar
解压一个名为 d2lzh_pytorch.tar
的 tar 归档文件,该文件中包含了我们所需要的数据,之后导入库函数,预定义填充字符,这些填充字符可以方便我们后续对输入的转化,之后再检查是否有可用的GPU
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)
在本机器上运行后结果如下
可以看到本机器没有可用的GPU
定义两个辅助函数,用于对读取的数据进行预处理
def process_one_seq(seq_tokens, all_tokens, all_seqs, max_seq_len):
# 将当前序列的词加入总词汇表中
all_tokens.extend(seq_tokens)
# 添加结束符EOS并填充PAD直到序列长度为max_seq_len
seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)
# 将处理后的序列加入所有序列列表中
all_seqs.append(seq_tokens)
def build_data(all_tokens, all_seqs):
# 使用Vocab类构造词典,基于所有词的计数,并指定特殊标记PAD、BOS和EOS
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)
process_one_seq函数用于将我们输入的词汇添加EOS结束符并填充预定义的字符,使其变成固定长度,之后将填充过后的序列加入到序列表中。
build_data函数vocab类构造词典并将次转换为词索引序列。
这样我们就得到了我们想要的词汇表与索引。
再定义read_data函数
def read_data(max_seq_len):
# in和out分别是input和output的缩写
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 # 如果加上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)
# 构建input和output的词典以及转换为TensorDataset的数据集
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)
该函数的作用如下:
-
读取数据:使用
io.open
打开文件fr-en-small.txt
并将所有行读入lines
变量中。 -
处理每行数据:遍历
lines
中的每一行数据;根据制表符 ('\t'
) 将每行数据分割成两部分 (in_seq
和out_seq
)。这假设文件中每一行包含以制表符分隔的两个序列。 -
分词和过滤:使用空格 (’ ') 将
in_seq
和out_seq
分割成单词 (split(' ')
);检查加上一个序列结束标记(EOS)后,任一序列的长度是否超过max_seq_len - 1
。如果超过,则跳过该样本。 -
处理单词:调用
process_one_seq
函数(可能在其他地方定义)进一步处理单词,并将它们添加到in_tokens
、out_tokens
、in_seqs
和out_seqs
中。 -
构建词汇表和数据集:调用
build_data
函数(也假设在其他地方定义)来构建词汇表(in_vocab
和out_vocab
),并将序列 (in_seqs
和out_seqs
) 转换为TensorDataset
(in_data
和out_data
)。 -
返回结果:返回
in_vocab
、out_vocab
,以及包含in_data
和out_data
的TensorDataset
。
之后我们将最大序列长度设为7,并读取第一个样本,查看其法语与英语的索引
max_seq_len = 7 # 设置序列的最大长度为7。
in_vocab, out_vocab, dataset = read_data(max_seq_len)
dataset[0] #获取数据集中的第一个样本
得到法语与英语的索引分别如下
2.2、含注意力机制的解码器与编码器
首先定义编码器
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) # 定义词嵌入层
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob) # 定义GRU循环神经网络层
def forward(self, inputs, state):
# 输入形状是(批量大小, 时间步数)。将输出互换样本维和时间步维
embedding = self.embedding(inputs.long()).permute(1, 0, 2) # 对输入序列进行词嵌入并转置维度
return self.rnn(embedding, state) # 将词嵌入后的序列输入GRU,并返回输出和状态
def begin_state(self):
return None # 编码器不需要初始状态,直接返回None
在此编码器中,将输入语言的词索引通过词嵌入层得到词的表征,然后输入到一个多层门控循环单元中。
下面我们来创建一个批量大小为4、时间步数为7的小批量序列输入。设门控循环单元的隐藏层个数为2,隐藏单元个数为16。编码器对该输入执行前向计算后返回的输出形状为(时间步数, 批量大小, 隐藏单元个数)。门控循环单元在最终时间步的多层隐藏状态的形状为(隐藏层个数, 批量大小, 隐藏单元个数)。state
是一个元组,包含两个元素即隐藏状态和记忆细胞。
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2) # 初始化编码器实例
output, state = encoder(torch.zeros((4, 7)), encoder.begin_state()) # 输入全零张量,获取编码器的输出和状态
output.shape, state.shape # 输出编码器的输出形状和状态形状,GRU的状态是h,而LSTM的是一个元组(h, c)
运行后得到编码器的输出形状与状态形状为
2.3、注意力机制
首先定义我们的注意力模型
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) # 定义注意力模型的第二个线性层,输出注意力权重
)
return model
隐藏层的输入是解码器的隐藏状态与编码器在所有时间步上隐藏状态的一一连结,且使用tanh函数作为激活函数。输出层的输出个数为1。
定义注意力前向计算函数
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) # 输入连结后的状态,输出注意力权重
alpha = F.softmax(e, dim=0) # 在时间步维度进行softmax运算,得到注意力权重
return (alpha * enc_states).sum(dim=0) # 返回加权后的编码器隐藏状态作为背景变量
注意力机制的输入包括查询项、键项和值项,查询项为解码器在上一时间步的隐藏状态,形状为(批量大小, 隐藏单元个数);键项和值项均为编码器在所有时间步的隐藏状态,形状为(时间步数, 批量大小, 隐藏单元个数)。注意力机制返回当前时间步的背景变量,形状为(批量大小, 隐藏单元个数)。
接下来进行实例化操作,编码器的时间步数为10,批量大小为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)) # 编码器隐藏状态,形状为(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 # 调用注意力前向传播函数,返回的背景变量的形状
运行后可以看到输出形状为(4,8)
2.4、含注意力机制的解码器
定义解码器
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) # 定义GRU层
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])
# 将嵌入后的输入和背景向量在特征维连结, (批量大小, 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
在此解码器中,我们将输入通过词嵌入层得到表征,然后和背景向量在特征维连结。我们将连结后的结果与上一时间步的隐藏状态通过门控循环单元计算出当前时间步的输出与隐藏状态。最后,我们将输出通过全连接层变换为有关各个输出词的预测,形状为(批量大小, 输出词典大小)。
2.5、模型训练
定义batch_loss
函数计算一个小批量的损失
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) # 解码器初始输入为BOS对应的索引
# 初始化损失和掩码变量
mask, num_not_pad_tokens = torch.ones(batch_size,), 0
l = torch.tensor([0.0])
# 遍历目标序列Y的每个时间步
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 # 强制教学:下一个时间步的输入是当前的目标输出y
num_not_pad_tokens += mask.sum().item() # 更新非PAD token数量
# 更新掩码:如果当前时间步的目标输出为EOS,那么之后的时间步掩码都置为0
mask = mask * (y != out_vocab.stoi[EOS]).float()
return l / num_not_pad_tokens # 返回平均损失
该函数的作用如下
-
编码器前向计算: 使用编码器将输入序列
X
转换为编码器的输出enc_outputs
和最终隐藏状态enc_state
。 -
解码器初始化: 使用编码器的最终隐藏状态初始化解码器,并设置初始解码器输入为起始标记(如 BOS)的索引。
-
损失计算: 针对目标序列
Y
的每个时间步,通过解码器生成预测并计算预测值与真实目标的损失。损失在遍历整个目标序列时累加,并考虑序列结束标记以及非 PAD token 的数量。 -
返回平均损失: 将累积的损失除以非 PAD token 的总数,得到一个批次数据的平均损失。
同时,在迭代过程中更迭解码器与编码器的参数
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): # 迭代训练epoch次数
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: # 每10个epoch输出一次损失
print("epoch %d, loss %.3f" % (epoch + 1, l_sum / len(data_iter))) # 打印平均损失
在训练过程中,我们使用nn.CrossEntropyLoss
定义交叉熵损失函数,设置reduction='none'
表示不进行损失的平均化,而是对每个元素单独计算损失。
之后设置超参数,并开始训练
# 定义编码器和解码器的隐藏层大小、嵌入维度、注意力大小等超参数
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)
可以看到,随着训练轮数的增加,损失值在逐渐减小,最终损失在0.027
2.6、预测不定长序列
这里我们采用上文提到过的贪婪搜索
def translate(encoder, decoder, input_seq, 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序列
for _ in range(max_seq_len):
# 解码器前向计算
dec_output, dec_state = decoder(dec_input, dec_state, enc_output)
# 预测最大概率的token并转换为文本token
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) # 将预测token加入输出序列
dec_input = pred # 将当前预测token作为下一个解码器的输入
return output_tokens # 返回最终的输出token序列
我们使用训练好的编码器-解码器模型进行输入序列翻译的过程,并展示序列到序列任务中的典型步骤 。
定义完翻译函数后,输入法语句子“ils regardent.”
input_seq = 'ils regardent .'
# 调用翻译函数进行序列翻译
translate(encoder, decoder, input_seq, max_seq_len)
翻译后的句子是They are watching,翻译正确
2.7、评价翻译结果
下面是BLUE函数与辅助打印函数的代码实现
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
def score(input_seq, label_seq, k):
# 使用翻译函数生成预测的token序列
pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
# 将参考标签序列分词为token
label_tokens = label_seq.split(' ')
# 计算并打印BLEU评分及预测的token序列
print('bleu %.3f, predict: %s' % (bleu(pred_tokens, label_tokens, k),
' '.join(pred_tokens)))
之后我们输入两个句子来查看他们的翻译分数
score('ils regardent .', 'they are watching .', k=2)
score('ils sont canadienne .', 'they are canadian .', k=2)
可以看到第一句的翻译完全正确,分数为1,而第二句中的canadian翻译为russian,故分数只有0.658。
翻译任务结束。