一、机器翻译与数据集
1.1 背景介绍
机器翻译是序列到序列学习课题中的一个典型案例,所以接下来的学习会以机器翻译这一案例展开。基于神经网络的翻译方法通常被称为神经机器翻译(neural machine translation),后续编写代码时将使用nmt这一缩写来代替名词神经机器翻译。
在学习序列到序列模型的过程中,将使用到一个新的机器翻译数据集,数据集下载链接如下:http://d2l-data.s3-accelerate.amazonaws.com/fra-eng.zip。
1.2 代码实现
nmtDataLoader.py:
导入依赖包:
import os
import torch
from preprocessing import Vocab
用于读取翻译数据集的函数:
def read_data_nmt(path):
with open(os.path.join(path), 'r', encoding='utf-8') as f:
return f.read()
翻译文本预处理函数:
def preprocess_nmt(text):
# 判断标点符号(,.!?)前面有没有空格,如果没有返回True
def no_space(char, prev_char):
return char in set(',.!?') and prev_char != ' '
# \u202f 是一个Unicode字符,代表窄非断行空格(Narrow No-Break Space)
# \xa0 是另一个表示空格的字符,在Unicode中代表不间断空格(Non-Breaking Space)
text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
# 遍历text中的每一个字符,如果该字符属于标点符号(,.!?)且前一个字符不是空格,
# 那么在这个标点前面添加一个空格,方便后续把标点符号分割成一个单独的token
out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char for i, char in enumerate(text)]
return ''.join(out)
从翻译数据集中提取num_examples个token:
def tokenize_nmt(text, num_examples=None):
source, target = [], []
# 遍历翻译数据集中的每一行
for i, line in enumerate(text.split('\n')):
# 判断已提取的token数量是否达到上限
if num_examples and i > num_examples:
break
# 根据制表符分割出每一行的法语部分和英语部分
parts = line.split('\t')
if len(parts) == 2:
source.append(parts[0].split(' '))
target.append(parts[1].split(' '))
return source, target
为了保证每个文本序列具有相同的长度,定义一个用于截断或填充文本序列的函数:
def truncate_pad(line, num_steps, padding_token):
# 截断
if len(line) > num_steps:
return line[:num_steps]
# 填充
return line + [padding_token] * (num_steps - len(line))
将机器翻译的文本序列转换成小批量:
def build_array_nmt(lines, vocab, num_steps):
# token to index,实际上是调用了Vocab的__getitem__成员函数
# 如:[['go', '.'], ['go', '.'], ['go', '.']] -> [[47, 4], [47, 4], [47, 4]]
lines = [vocab[l] for l in lines]
# 将特定的“<eos>”词元添加到序列末尾
# 如:[[47, 4], [47, 4], [47, 4]] -> [[47, 4, 3], [47, 4, 3], [47, 4, 3]]
lines = [l + [vocab['<eos>']] for l in lines]
array = torch.tensor([truncate_pad(l, num_steps, vocab['<pad>']) for l in lines])
# 统计每个序列除去填充词元后的有效长度
# array != vocab['<pad>']操作会生成一个新的张量,其形状与array相同,如果array的某个元素满足不等式条件,则新张量在该位置上的值为True,否则为False
# .type(torch.int32)操作将上一步得到的布尔张量转换为torch.int32类型的张量,即False变为0,True变为1
# .sum(1)操作沿着张量的第二个维度进行求和
valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)
return array, valid_len
将上面实现的功能封装在一个函数中,方便外部模块调用。该函数的主要功能是返回翻译数据迭代器和词表:
def load_data_nmt(path, batch_size, num_steps, num_examples=600):
text = preprocess_nmt(read_data_nmt(path))
# source:[['go', '.'], ['hi', '.'], ['run', '!'], ...]
# target:[['va', '!'], ['salut', '!'], ['cours', '!'], ...]
source, target = tokenize_nmt(text, num_examples)
# min_freq=2表示出现频率小于2的token会被忽略
# <pad>:在小批量时用于将序列填充到相同长度;<bos>:begin of sentence,开始词元;<eos>:end of sentence,结束词元
src_vocab = Vocab(source, min_freq=2, reserved_tokens=['<pad>', '<bos>', '<eos>'])
tgt_vocab = Vocab(target, min_freq=2, reserved_tokens=['<pad>', '<bos>', '<eos>'])
# 经过处理后,原序列和目标序列中的子序列的长度都为num_steps
src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)
data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)
# is_train=True表示打乱文本序列的次序,每次返回batch_size个子序列
data_iter = load_array(data_arrays, batch_size, is_train=True)
return data_iter, src_vocab, tgt_vocab
主函数测试:
if __name__=='__main__':
train_iter, src_vocab, tgt_vocab = load_data_nmt('fra-eng/fra.txt', batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
print('X:', X.type(torch.int32))
print('X的有效长度:', X_valid_len)
print('Y:', Y.type(torch.int32))
print('Y的有效长度:', Y_valid_len)
break
二、序列到序列学习
2.1 编码器-解码器架构
无论是CNN还是RNN,我们都可以把它们看作是一种编码器-解码器架构。
于CNN而言,编码的过程实际上是从一副原始图片中提取特征的过程;输出层根据提取到的抽象特征还原出人类能够读懂的信息的过程则可以被认为是解码过程。
于RNN而言,网络模型将输入信息、过去时刻的隐状态加权求和,得到当前时刻、抽象的隐状态,这个过程可看作是编码过程;而输出层根据隐状态得到预测结果的过程则可以被看作是解码过程。
所以,以上提到的网络模型实际上都可以被分为两块:编码器处理输入,把它表示成一个中间的抽象态(CNN中的feature map、RNN中的hidden state);解码器可以额外处理一些输入,主要负责生成人类易懂的输出结果。
2.2 seq2seq实现思路
下图展示了一个使用编码器-解码器架构设计的seq2seq网络模型。图中网络的功能是将输入的一段英语序列翻译成法语。
该网络的编码器部分和解码器部分实际上是两个不同的RNN,其中,由于编码器需要考虑到输入句子的上下文信息,所以它适合使用双向循环神经网络来作为其主体。当然,也可以使用GRU、LSTM等其它RNN模型。
而解码器则更像是一个预测模型,它在预测当前的翻译结果时同时考虑了编码器的输出和上一时刻的翻译结果,不适合使用双向循环神经网络来作为其主体。
更简洁的网络结构如下图所示。独热编码后的token十分稀疏,资源占用大,Embedding层用于将token映射到向量空间,简单来说就是通过矩阵乘法降维,节省后续的计算资源。
2.3 评价指标BLEU
BLEU(Bilingual Evaluation Understudy)是一种基于n-gram特征的机器翻译评价指标。
在介绍BLEU前,需要先简单介绍如何计算预测中所有n-gram的精度。
假设某个句子的真实翻译结果为X,而模型预测得到的翻译结果为Y:
X = (A B C D E F)
Y = (A B B C D)
1、计算1-gram的预测精度:
X1 = (A, B, C, D, E, F)
Y1 = (A, B, B, C, D)
Y1有5个元素,其中元素A,B,C,D能够在X1中找到唯一与其匹配的元素,所以1-gram的预测精度p1 = 4/5。
2、计算2-gram的预测精度:
X2 = (AB, BC, CD, DE, EF)
Y2 = (AB, BB, BC, CD)
Y2有4个元素,其中元素AB,BC,CD能够在X2中找到唯一与其匹配的元素,所以2-gram的预测精度p2 = 3/4。
3、计算3-gram的预测精度:
X3 = (ABC, BCD, CDE, DEF)
Y3 = (ABB, BBC, BCD)
Y3有3个元素,其中只有元素BCD能够在X3中找到唯一与其匹配的元素,所以3-gram的预测精度p3 = 1/3。
4、计算4-gram的预测精度:
X4 = (ABCD, BCDE, CDEF)
Y4 = (ABBC, BBCD)
Y4有2个元素,没有一个元素能够在X4中找到唯一与其匹配的元素,所以4-gram的预测精度p4 = 0。
计算得到n-gram的预测精度后,可以根据如下公式计算BLEU指标:
BLEU的计算结果在0到1之间,越接近1表示模型的翻译结果越好。
如果模型预测的翻译结果很短,而真实的翻译结果很长,那么计算出来的n-gram精度会很高,这显然是不符合我们预期的,所以BLEU的定义式中有一个BP(Brevity Penalty)惩罚因子,用于惩罚过短的预测。
BP项中,ls表示真实翻译序列的长度,lc表示模型预测序列的长度,如果lc越短于ls,BP项越接近于0,惩罚越严重。
1/2^n是每个n-gram预测精度的权重,n越大,计算n-gram时组合序列的长度越长,Pn^(1/2^n)就越大,这种让长匹配有高权重的方式有助于矫正模型的预测长度。
2.4 代码实现
utils.py:
seq2seq.py:
导入依赖包:
import collections
import math
import coder
import torch
from torch import nn
from utils import Animator, Timer, Accumulator, show
from rnnModel import grad_clipping
from nmtDataLoader import load_data_nmt, truncate_pad
编码器:
class Seq2SeqEncoder(coder.Encoder):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs):
super(Seq2SeqEncoder, self).__init__(**kwargs)
# token经过独热编码后的向量长度为vocab_size,嵌入层降维后向量长度为embed_size
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.LSTM(embed_size, num_hiddens, num_layers, dropout=dropout)
#self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=dropout)
def forward(self, X, *args):
# 输入'X'的形状:(batch_size, num_steps);输出'X'的形状:(batch_size, num_steps, embed_size)
X = self.embedding(X)
# 在循环神经网络模型中,第一个轴对应于时间步,此时输出'X'的形状:(num_steps, batch_size, embed_size)
X = X.permute(1, 0, 2)
# output的形状:(num_steps, batch_size, num_hiddens)
# 对于LSTM而言,此处返回的state是一个tuple,包括H和C,H的形状:(num_layers, batch_size, num_hiddens)
# 编码器的初始化state为0,输入参数不需要state
output, state = self.rnn(X)
return output, state
解码器:
class Seq2SeqDecoder(coder.Decoder):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs):
super(Seq2SeqDecoder, self).__init__(**kwargs)
# 解码器除了处理编码器的输出外,还要处理额外的输入,所以它有自己的嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
# 解码器rnn的输入是编码器输出和额外输入的 concatenate,所以输入维度为 embed_size + num_hiddens
self.rnn = nn.LSTM(embed_size + num_hiddens, num_hiddens, num_layers, dropout=dropout)
#self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers, dropout=dropout)
# 输出层
self.dense = nn.Linear(num_hiddens, vocab_size)
def init_state(self, enc_outputs, *args):
# enc_outputs包含编码器返回的outputs和state,enc_outputs[1]表示只取state
# 对于LSTM而言,编码器返回的隐状态state是(H, C)形式的,enc_outputs[1][0]能确保我们拿到的是H
# state中H的形状是 (num_layers, batch_size, num_hiddens),enc_outputs[1][0][-1]能确保我们拿到的是最上面一层的隐状态
# self.context后续会作为解码器输入的一部分
self.context = enc_outputs[1][0][-1]
return enc_outputs[1]
def forward(self, X, state):
# 输入'X'的形状:(batch_size, num_steps);输出'X'的形状:(batch_size, num_steps, embed_size)
X = self.embedding(X)
# 在循环神经网络模型中,第一个轴对应于时间步,此时输出'X'的形状:(num_steps, batch_size, embed_size)
X = X.permute(1, 0, 2)
# self.context的尺寸:(batch_size, num_hiddens)
# 这里相当于在第一个维度对self.context复制了X.shape[0]次,得到的context的尺寸为:(num_steps, batch_size, num_hiddens)
# 这样做的目的是让context张量的形状与X张量兼容,以便后续操作
context = self.context.repeat(X.shape[0], 1, 1)
# 2表示沿着第三个维度进行拼接
X_and_context = torch.cat((X, context), 2)
# 输入参数state用于初始化解码器LSTM网络中的隐状态H和C
output, state = self.rnn(X_and_context, state)
output = self.dense(output).permute(1, 0, 2)
# output的形状:(batch_size, num_steps, vocab_size)
# 对于LSTM,state=(H, C),其中H的形状:(num_layers, batch_size, num_hiddens)
return output, state
对于某个序列而言,它的有效长度是除'<pad>'符外,其它token组成的序列的长度。
下面定义一个函数用于标注出序列中的'<pad>'符,并用value的值代替'<pad>'符处原来的值:
def sequence_mask(X, valid_len, value=0):
# 输入X的尺寸:(batch_size, num_steps),此处X.size(1)表示取X第二个维度的大小
maxlen = X.size(1)
# 创建一个从0到maxlen-1的等差数列,并将其置于与valid_len相同的设备上
indices = torch.arange(maxlen, dtype=torch.float32, device=X.device)
# [None, :] 是pytorch中的一种语法,其作用是在张量第一个维度插入一个新的轴,:表示第二个维度上保持原来的元素不变
# 将indices扩展为二维张量,以便与valid_len进行广播比较
# indices的形状变为 [1, num_steps],valid_len的形状变为 [batch_size, 1]
indices_expanded = indices[None, :]
valid_len_expanded = valid_len[:, None]
# pytorch广播比较,mask是一个尺寸为 (batch_size, num_steps) 的布尔型张量
"""
pytorch广播比较的原理如下:
对于 indices_expanded,PyTorch会在其第一个维度(行维度)上重复其唯一的元素 batch_size 次,
以匹配 valid_len_expanded 的第一个维度。
因此,在广播后的张量中,每一行都将与 indices_expanded 原始的行相同。
对于 valid_len_expanded,PyTorch会在其第二个维度(列维度)上重复其唯一的元素 maxlen 次,
以匹配 indices_expanded 的第二个维度。
因此,在广播后的张量中,每一列都将包含 valid_len_expanded 中对应行的有效长度值。
"""
mask = indices_expanded < valid_len_expanded
X[~mask] = value
return X
屏蔽掉填充符'<pad>',计算一个batch里的batch_size个序列在num_steps个时间步下的平均交叉熵损失:
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
def forward(self, pred, label, valid_len):
# pred的形状:(batch_size, num_steps, vocab_size)
# label的形状:(batch_size, num_steps)
# valid_len的形状:(batch_size, )
# 获取一个形状与label相同且值全为1的张量
weights = torch.ones_like(label)
# 将非有效位标记为0
weights = sequence_mask(weights, valid_len)
self.reduction = 'none'
# 调用父类的损失函数计算损失
unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(pred.permute(0, 2, 1), label)
# 乘以掩码矩阵,屏蔽掉填充符对应的loss,然后沿num_steps维度求和,最后求出一个batch里每个序列在各个时间步的平均损失
weighted_loss = torch.where(valid_len != 0,
(unweighted_loss * weights).sum(dim=1) / valid_len,
(unweighted_loss * weights).mean(dim=1)
)
# 输出的weighted_loss的形状:torch.Size([batch_size])
return weighted_loss
seq2seq训练函数:
def train(net, data_iter, lr, num_epochs, target_vocab, device):
# xavier初始化
def xavier_init_weights(m):
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
if type(m) == nn.LSTM:
for param in m._flat_weights_names:
if 'weight' in param:
nn.init.xavier_uniform_(m._parameters[param])
# apply 是 nn.Module 类的一个方法,apply 方法会遍历模型的所有子模块,对每个模块调用指定的函数xavier_init_weights
net.apply(xavier_init_weights)
net.to(device)
# 优化器设置为Adam(五大优化器:SGD、SGDM、Adagrad、RMSProp、Adam)
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
loss = MaskedSoftmaxCELoss()
net.train()
animator = Animator(xlabel='epoch', ylabel='loss', xlim=[10, num_epochs])
for epoch in range(num_epochs):
timer = Timer()
metric = Accumulator(2)
for batch in data_iter:
# 每个批次开始时,先清楚之前积累的梯度
optimizer.zero_grad()
X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
# bos:begin of sentence,这里是构造一个形状为 (batch_size, 1) 的张量
bos = torch.tensor([target_vocab['<bos>']] * Y.shape[0], device=device).reshape(-1, 1)
# Y的尺寸:(batch_size, num_steps),Y[:, :-1]是指原张量 (batch_size, num_steps - 1)的部分
# 最后的参数1表示沿第二个维度(num_steps维度)拼接
# 根据seq2seq的网络架构图,解码器的输入的第一个token是起始符'<bos>',所以这里把起始符拼接到每个输入序列的首位
dec_input = torch.cat([bos, Y[:, :-1]], 1)
# X是encoder的输入,dec_input是decoder的输入
# 与预测不同,训练时以真实序列作为decoder的输入,预测时是以上一时刻decoder的输出作为当前时刻decoder的输入
Y_hat, _ = net(X, dec_input, X_valid_len)
# 根据损失函数反向传播,计算网络中每个参数的梯度
l = loss(Y_hat, Y, Y_valid_len)
l.sum().backward()
# 梯度裁剪
grad_clipping(net, 1)
# 更新模型参数
optimizer.step()
# 绘图时不涉及梯度运算,于是禁用梯度计算,减少资源消耗
num_tokens = Y_valid_len.sum()
with torch.no_grad():
metric.add(l.sum(), num_tokens)
if (epoch + 1) % 10 == 0:
animator.add(epoch + 1, (metric[0] / metric[1],))
print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
f'tokens/sec on {str(device)}')
show()
seq2seq预测函数:
def predict(net, src_sentence, src_vocab, tgt_vocab, num_steps, device, save_attention_weights=False):
# 在预测时将net设置为评估模式
net.eval()
src_tokens = src_vocab[src_sentence.lower().split(' ')] + [src_vocab['<eos>']]
enc_valid_len = torch.tensor([len(src_tokens)], device=device)
# 当输入序列长度不够时,用'<pad>'词元将输入序列填充到num_steps长度
src_tokens = truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
# 为torch.tensor(src_tokens)添加一个batch_size维度,使得encoder的输入enc_X的尺寸满足:(batch_size, num_steps),batch_size=1
enc_X = torch.unsqueeze(torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
enc_outputs = net.encoder(enc_X, enc_valid_len)
# 初始化解码器的隐状态H和C
dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
# 与enc_X同理,为decoder的输入添加一个batch_size维度,因为是预测不是训练,所以batch_size=1
# 预测时解码器不再是以真实翻译结果作为输入,而是以上一时刻解码器的输出作为当前的输入,是一步一步进行的,所以此处dec_X的尺寸为:(1, 1),num_steps=1
dec_X = torch.unsqueeze(torch.tensor([tgt_vocab['<bos>']], dtype=torch.long, device=device), dim=0)
output_seq, attention_weight_seq = [], []
for _ in num_steps:
# Y的尺寸:(1, 1, vocab_size)
Y, decode_state = net.decoder(dec_X, dec_state)
# 使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
# Y.argmax(dim=2)表示在Y的vocab_size维度找到最大值,返回最大值处的索引
# dec_X的尺寸:(1, 1)
dec_X = Y.argmax(dim=2)
# squeeze(dim=0)消去第一个维度,item()用于将向量转换为标量
# 这一步操作后,pred就是一个标量,储存着当前步模型预测的token在词典vocab中对应的索引
pred = dec_X.squeeze(dim=0).type(torch.int32).item()
# 保存注意力权重(在注意力机制中会用到)
if save_attention_weights:
attention_weight_seq.append(net.decoder.attention_weights)
# 一旦序列结束词元被预测,输出序列的生成就完成了
if pred == tgt_vocab['<eos>']:
break
output_seq.append(pred)
# 将预测的tokens组合成字符串饭返回,并返回注意力权重序列
return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq
计算BLEU:
def 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)
# 计算惩罚因子BP
score = math.exp(min(0, 1 - len_label / len_pred))
for n in range(1, k + 1):
# 用于记录成功匹配的token的数量
num_matches = 0
# 创建一个默认值为0的字典,如果在该字典中查找一个不存在的键,会返回0值
label_subs = 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
主函数测试:
encoder_test = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
# 让模型进入评估模式
encoder_test.eval()
# 批量大小为4,时间步为7
X = torch.zeros((4, 7), dtype=torch.long)
# nn.Module类已经重写了__call__方法,其内部会默认调用我们定义的forward函数
output, state = encoder_test(X)
# torch.Size([7, 4, 16])
print(output.shape)
# 如果模型以LSTM作为网络主体,那么state的类型为<class 'tuple'>
# 如果模型以GRU作为网络主体,那么state的类型为<class 'torch.tensor'>
print(type(state))
# torch.Size([2, 4, 16])
print(state[-1].shape)
decoder_test = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
decoder_test.eval()
state_dec = decoder_test.init_state([output, state])
output_dec, state_dec = decoder_test(X, state_dec)
# torch.Size([4, 7, 10])
print(output_dec.shape)
# 训练
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, torch.device('cuda')
train_iter, src_vocab, tgt_vocab = load_data_nmt('fra-eng/fra.txt', batch_size, num_steps)
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers, dropout)
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers, dropout)
net = coder.EncoderDecoder(encoder, decoder)
train(net, train_iter, lr, num_epochs, tgt_vocab, device)
# 预测结果测试
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
translation, attention_weight_seq = predict(net, eng, src_vocab, tgt_vocab, num_steps, device)
print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')
参考链接:
《动手学深度学习》 — 动手学深度学习 2.0.0 documentationhttps://zh-v2.d2l.ai/