一、机器翻译定义
机器翻译指的是使用计算机技术和算法将一种自然语言的文本自动转换成另一种自然语言的文本的过程。其主要目的是使得人们能够快速、准确地将一种语言的信息转换成另一种语言,从而帮助跨语言沟通和理解。机器翻译的发展旨在减少人工翻译的工作量,提高翻译效率,并在多种应用场景中有广泛的应用,例如跨国企业的沟通、互联网内容的本地化、多语种文档的处理等。
二、读取和预处理数据
先定义一些特殊符号。其中“<pad>”(padding)符号用来添加在较短序列后,直到每个序列等长,而“<bos>”和“<eos>”符号分别表示序列的开始和结束。
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"#在有多个GPU时很有用,可以限制程序使用特定的GPU。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')#检查是否有CUDA可用的 GPU
print(torch.__version__, device)
接着定义两个辅助函数对后面读取的数据进行预处理。
# 将一个序列中所有的词记录在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)
#在当前序列末尾添加EOS,并用PAD填充至最大序列长度
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])
#将所有序列中的词转换为对应的词索引,并构造成Tensor
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和out分别是input和output的缩写
#初始化存储tokens和序列的列表
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')
#分割输入和输出序列的词tokens
in_seq_tokens, out_seq_tokens = in_seq.split(' '), out_seq.split(' ')
#如果加上EOS后长于max_seq_len,则忽略掉此样本
if max(len(in_seq_tokens), len(out_seq_tokens)) > max_seq_len - 1:
continue
#处理输入序列和输出序列的tokens并存储到相应列表中
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)
#构建输入和输出序列的词典和TensorDataset
in_vocab, in_data = build_data(in_tokens, in_seqs)
out_vocab, out_data = build_data(out_tokens, out_seqs)
#返回输入词典、输出词典和TensorDataset对象
return in_vocab, out_vocab, Data.TensorDataset(in_data, out_data)
三、含注意力机制的编码器—解码器
3.1编码器
在编码器中,我们将输入语言的词索引通过词嵌入层得到词的表征,然后输入到一个多层门控循环单元中。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) #调用父类构造函数
#初始化词嵌入层和GRU循环神经网络层
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 #返回初始状态,这里未实现具体的初始状态逻辑
3.2注意力机制
注意力机制(Attention Mechanism)是一种用于改善机器学习和人工智能模型性能的技术。它可以根据当前正在执行的任务,选择性地关注输入的不同部分,从而有效地分配计算资源和精力。
在机器学习中,注意力机制最初是在自然语言处理领域中提出的,尤其是在机器翻译任务中。传统的序列到序列(Seq2Seq)模型在进行翻译时,将整个输入序列编码成一个固定长度的向量,然后解码器基于该向量生成翻译结果。然而,这种方法可能会丢失输入序列中重要的局部信息,也无法明白当前序列中那些是重要信息哪些是不太重要的信息,特别是对于长序列而言。
注意力机制通过动态地对输入序列的不同部分分配不同的注意力权重,使得模型可以更加关注与当前生成的输出密切相关的部分。
具体来说,注意力机制允许模型在生成每个输出(如单词或标记)时,将注意力集中在输入序列的特定位置,而不是固定地对整个序列执行操作。这样可以提高模型对输入序列的理解和表达能力,从而提升任务性能。
注意力机制的关键组成部分包括:
-
查询(Query):用于指定当前要生成的输出的上下文。
-
键(Key) 和 值(Value):这两者用于表示输入序列中的不同部分。注意力机制通过计算查询与键之间的相似度,然后使用这些相似度来加权计算输入序列中每个位置的值,最终得到一个加权和作为输入的表示。
-
注意力权重(Attention Weights):确定每个输入位置在计算输出时的贡献程度。这些权重是通过查询和键之间的相似度计算得到的,通常通过 softmax 函数归一化以确保总和为1。
上图描绘了注意力机制如何为解码器在时间步2计算背景变量。首先,函数𝑎根据解码器在时间步1的隐藏状态和编码器在各个时间步的隐藏状态计算softmax运算的输入。softmax运算输出概率分布并对编码器各个时间步的隐藏状态做加权平均,从而得到背景变量。
函数a:将输入连结后通过含单隐藏层的多层感知机变换。其中隐藏层的输入是解码器的隐藏状态与编码器在所有时间步上隐藏状态的一一连结,且使用tanh函数作为激活函数。输出层的输出个数为1。两个Linear
实例均不使用偏差。其中函数a定义里向量v的长度是一个超参数,即attention_size
。
def attention_model(input_size, attention_size):
#定义一个序列模型
model = nn.Sequential(nn.Linear(input_size, attention_size, bias=False),
#第一层线性变换,输入大小为input_size,输出大小为attention_size,无偏置
nn.Tanh(),#Tanh激活函数
nn.Linear(attention_size, 1, bias=False))#第二层线性变换,输入大小为attention_size,输出大小为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
#创建注意力模型,输入大小为2*num_hiddens,注意力大小为10
model = attention_model(2*num_hiddens, 10)
#创建编码器状态张量,形状为(seq_len, batch_size, num_hiddens),全零初始化
enc_states = torch.zeros((seq_len, batch_size, num_hiddens))
#创建解码器状态张量,形状为(batch_size, num_hiddens),全零初始化
dec_state = torch.zeros((batch_size, num_hiddens))
#调用注意力前向传播函数,计算注意力输出的形状
attention_forward(model, enc_states, dec_state).shape
结果如下图所示:
3.3含注意力机制的解码器
我们直接将编码器在最终时间步的隐藏状态作为解码器的初始隐藏状态。这要求编码器和解码器的循环神经网络使用相同的隐藏层个数和隐藏单元个数。
在解码器的前向计算中,先通过注意力机制计算得到当前时间步的背景向量。由于解码器的输入来自输出语言的词索引,将输入通过词嵌入层得到表征,然后和背景向量在特征维连结。我们将连结后的结果与上一时间步的隐藏状态通过门控循环单元计算出当前时间步的输出与隐藏状态。最后,我们将输出通过全连接层变换为有关各个输出词的预测,形状为(批量大小, 输出词典大小)。
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)#创建线性层,输出大小为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
。之后,解码器在某时间步的输入为样本输出序列在上一时间步的词,即强制教学,同时使用掩码变量避免填充项对损失函数计算的影响。
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)
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()
# 每隔10个epoch打印一次平均损失
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
#定义注意力模型的大小、dropout概率、学习率、批大小和训练轮数
attention_size, drop_prob, lr, batch_size, num_epochs = 10, 0.5, 0.01, 2, 50
#创建编码器实例,输入词汇表大小,嵌入大小,隐藏单元数,层数和dropout概率
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)
#调用训练函数,传入编码器、解码器、数据集、学习率、批大小和训练轮数
train(encoder, decoder, dataset, lr, batch_size, num_epochs)
五、预测不定长的序列
def translate(encoder, decoder, input_seq, max_seq_len):
# 将输入序列分割为单词,并添加EOS和PAD进行填充以匹配最大序列长度
in_tokens = input_seq.split(' ')
in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)
# 将输入序列转换为对应的索引Tensor,形状为(batch=1, max_seq_len)
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的索引Tensor
dec_input = torch.tensor([out_vocab.stoi[BOS]])
# 使用编码器最终状态初始化解码器的初始状态
dec_state = decoder.begin_state(enc_state)
# 初始化输出的token序列为空列表
output_tokens = []
# 开始解码过程,最多进行max_seq_len次解码
for _ in range(max_seq_len):
# 解码器解码,得到解码输出和更新后的状态
dec_output, dec_state = decoder(dec_input, dec_state, enc_output)
# 选取输出中概率最高的token作为预测结果
pred = dec_output.argmax(dim=1)
# 将预测的token转换为实际的字符串token
pred_token = out_vocab.itos[int(pred.item())]
# 如果预测的token是结束标记EOS,则停止解码过程
if pred_token == EOS:
break
else:
# 将预测的token添加到输出序列中
output_tokens.append(pred_token)
# 更新解码器的输入为当前预测的token的索引
dec_input = pred
# 返回解码得到的输出token序列
return output_tokens
六、 评价翻译结果
评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)它是预测序列与标签序列匹配词数为n的子序列的数量与预测序列中词数为n的子序列的数量之比。设lenlabel和lenpred分别为标签序列和预测序列的词数,那么,BLEU的定义如下图所示
其中k是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU为1。
def bleu(pred_tokens, label_tokens, k):
# 计算预测序列和参考序列的长度
len_pred, len_label = len(pred_tokens), len(label_tokens)
# 计算长度惩罚因子,以e为底的最小值函数,确保分子小于等于分母时不产生正数
score = math.exp(min(0, 1 - len_label / len_pred))
# 计算n-gram的BLEU分数,从1到k-gram逐步计算
for n in range(1, k + 1):
# 初始化匹配数和参考子串字典
num_matches, label_subs = 0, collections.defaultdict(int)
# 统计参考序列中所有长度为n的子串出现次数
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))
# 返回计算得到的BLEU分数
return score
最后,定义一个辅助打印函数
def score(input_seq, label_seq, k):
# 使用编码器和解码器进行翻译,得到预测的词汇序列
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)))
七、Transformer
7.1transformer结构
左半边为编码器部分,右半边为解码器部分
编码器由n个子encoder构成,同样的解码器也由n个子decoder构成,一般来说n=6
所有的编码器在结构上都是相同的,但它们没有共享参数。每个编码码器都可以分解成两个子层:自注意力和前馈神经网络
同样的,每个子Decoder 的结构相同:自注意力+编解注意力+全连接
7.2transformer的流程
最后一层Encoder的输出会输入给每一个Decoder层
从编码器输入的句子首先会经过一个自注意力(self-attention)层,这层帮助编码器在对每个单词编码时关注输入句子的其他单词。自注意力层的输出会传递到前馈(feed-forward)神经网络中。每个位置的单词对应的前馈神经网络都完全一样(译注:另一种解读就是一层窗口为一个单词的一维卷积神经网络)。
7.2.1输入
像大部分NLP应用一样,我们首先将每个输入单词通过词嵌入算法转换为词向量
同时因为Transformer的数据是可以一起进行处理,即并行化处理,虽然增快了速度但是忽略了单词间的先后顺序,句子中的词汇顺序和位置都是非常重要的,它们定义了语法,从而定义了句子的实际语意,所以我们在此处要引入一个位置编码。
最后的输入=Embedding + 位置编码
7.2.2注意力机制计算
第一步 对编码器的每个输入向量都计算三个向量,就是对每个输入向量都算一个query、key、value向量。把输入的词嵌入向量与三个权重矩阵相乘。权重矩阵是模型训练阶段训练出来的。
第二步 计算注意力得分。假设我们现在在计算输入中第一个单词Thinking的自注意力。我们需要使用自注意力给输入句子中的每个单词打分,这个分数决定当我们编码某个位置的单词的时候,应该对其他位置上的单词给予多少关注度。这个得分是query和key的点乘积得出来的。
第三步 将计算获得的注意力分数除以
第四步 除第三步结果扔进softmax计算,使结果归一化,softmax之后注意力分数相加等于1,并且都是正数
第五步 将每个value向量乘以注意力分数。这是为了留下我们想要关注的单词的value,并把其他不相关的单词丢掉。
第六步 将上一步的结果相加,输出本位置的注意力结果。
这就是自注意力的计算。计算得到的向量直接传递给前馈神经网络。但是为了处理的更迅速,实际是用矩阵进行计算的。接下来我们看一下怎么用矩阵计算
下图中x矩阵的每一行代表句中的一个单词,一整个矩阵就是句子的全部
计算方式如下:
7.2.3解码器
编码器通过处理输入序列开启工作。顶端编码器的输出之后会变转化为一个包含向量K(键向量)和V(值向量)的注意力向量集 。这些向量将被每个解码器用于自身的“编码-解码注意力层” ,而这些层可以帮助解码器关注输入序列哪些位置合适
解码组件最后会输出一个实数向量。我们如何把浮点数变成一个单词?这便是线性变换层要做的工作,它之后就是Softmax层。线性变换层是一个简单的全连接神经网络,它可以把解码组件产生的向量投射到一个比它大得多的、被称作对数几率(logits)的向量里。不妨假设我们的模型从训练集中学习一万个不同的英语单词(我们模型的“输出词表”)。因此对数几率向量为一万个单元格长度的向量——每个单元格对应某一个单词的分数。接下来的Softmax 层便会把那些分数变成概率(都为正数、上限1.0)。概率最高的单元格被选中,并且它对应的单词被作为这个时间步的输出
八、基于Transformer&PyTorch的日汉机器翻译模型
8.1获取并行数据集
我们将使用从JParaCrawl下载的日英并行数据集JParaCrawl
它被描述为NTT创建的最大的公开的英语-日语平行语料库。它是通过大量抓取网络和自动对齐平行句子创建的。
数据读取如下:
import pandas as pd
# 读取 CSV 文件,分隔符为 \t,引擎为 Python,无列标题
df = pd.read_csv('./zh-ja/zh-ja.bicleaner05.txt', sep='\\t', engine='python', header=None)
# 提取第 3 列数据并转换为 Python 列表,存储在 trainen 中
trainen = df[2].values.tolist() # 可以选择只取前 10000 行的数据[:10000]
# 提取第 4 列数据并转换为 Python 列表,存储在 trainja 中
trainja = df[3].values.tolist() # 可以选择只取前 10000 行的数据[:10000]
# trainen.pop(5972)
# trainja.pop(5972)
8.2准备tokenizers
因为日语与其它语言不同,日语句子不包含空格来分隔单词,所以我们可以使用JParaCrawl提供的标记器,该标记器是使用日语和英语的句子片段创建的
# 创建一个英文句子分词器,加载模型文件 'enja_spm_models/spm.en.nopretok.model'
en_tokenizer = spm.SentencePieceProcessor(model_file='enja_spm_models/spm.en.nopretok.model')
# 创建一个日文句子分词器,加载模型文件 'enja_spm_models/spm.ja.nopretok.model'
ja_tokenizer = spm.SentencePieceProcessor(model_file='enja_spm_models/spm.ja.nopretok.model')
加载了标记器之后,进行测试
# 使用英文句子分词器对输入的英文句子进行编码,将结果以字符串形式输出
en_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.", out_type='str')
# 使用日文句子分词器对输入的英文句子进行编码,将结果以字符串形式输出
ja_tokenizer.encode("年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。", out_type='str')
8.3构建TorchText Vocab对象并将句子转换为Torch张量
使用标记器和原始句子,我们构建从TorchText导入的Vocab对象。这个过程可能需要几秒钟或几分钟,这取决于我们数据集的大小和计算能力。不同的标记器也会影响构建vocab所需的时间,
def build_vocab(sentences, tokenizer):
# 初始化一个计数器对象
counter = Counter()
# 对于输入的每个句子,使用给定的分词器进行编码,并更新计数器
for sentence in sentences:
counter.update(tokenizer.encode(sentence, out_type=str))
# 使用计数器中的统计信息构建一个词汇表对象,包括特殊标记 '<unk>', '<pad>', '<bos>', '<eos>'
return Vocab(counter, specials=['<unk>', '<pad>', '<bos>', '<eos>'])
# 使用日文分词器和训练数据 trainja 构建日文词汇表 ja_vocab
ja_vocab = build_vocab(trainja, ja_tokenizer)
# 使用英文分词器和训练数据 trainen 构建英文词汇表 en_vocab
en_vocab = build_vocab(trainen, en_tokenizer)
在有了词汇表对象之后,可以使用vocab和标记器对象来为我们的训练数据构建张量。
def data_process(ja, en):
# 初始化一个空列表,用于存储处理后的数据
data = []
# 遍历输入的日文列表 ja 和英文列表 en 中的每一对句子
for (raw_ja, raw_en) in zip(ja, en):
# 使用日文词汇表 ja_vocab 将日文句子 raw_ja 编码成整数张量 ja_tensor_
ja_tensor_ = torch.tensor([ja_vocab[token] for token in ja_tokenizer.encode(raw_ja.rstrip("\n"), out_type=str)],
dtype=torch.long)
# 使用英文词汇表 en_vocab 将英文句子 raw_en 编码成整数张量 en_tensor_
en_tensor_ = torch.tensor([en_vocab[token] for token in en_tokenizer.encode(raw_en.rstrip("\n"), out_type=str)],
dtype=torch.long)
# 将处理后的日文和英文整数张量组成元组,并添加到 data 列表中
data.append((ja_tensor_, en_tensor_))
# 返回处理后的数据列表
return data
# 使用训练集数据 trainja 和 trainen 进行数据处理,得到训练数据集 train_data
train_data = data_process(trainja, trainen)
8.4创建要在训练期间迭代的DataLoader对象
在这里,将BATCH_SIZE设置为16,以防止“cuda内存不足”,但这取决于各种因素,如机器内存容量、数据大小等,所以可以根据您的需要随意更改批量大小(注意:PyTorch的教程使用Multi30k德语-英语数据集将批量大小设置为128。)
BATCH_SIZE = 8 # 定义批量大小为 8
PAD_IDX = ja_vocab['<pad>'] # 获取日文词汇表中 '<pad>' 标记的索引
BOS_IDX = ja_vocab['<bos>'] # 获取日文词汇表中 '<bos>' 标记的索引
EOS_IDX = ja_vocab['<eos>'] # 获取日文词汇表中 '<eos>' 标记的索引
def generate_batch(data_batch):
ja_batch, en_batch = [], []
# 遍历数据批次中的每个样本
for (ja_item, en_item) in data_batch:
# 在日文样本的开头和结尾添加 '<bos>' 和 '<eos>' 标记,并组成一个整数张量
ja_batch.append(torch.cat([torch.tensor([BOS_IDX]), ja_item, torch.tensor([EOS_IDX])], dim=0))
# 在英文样本的开头和结尾添加 '<bos>' 和 '<eos>' 标记,并组成一个整数张量
en_batch.append(torch.cat([torch.tensor([BOS_IDX]), en_item, torch.tensor([EOS_IDX])], dim=0))
# 对日文和英文样本进行填充,使它们具有相同的长度
ja_batch = pad_sequence(ja_batch, padding_value=PAD_IDX)
en_batch = pad_sequence(en_batch, padding_value=PAD_IDX)
# 返回填充后的日文和英文批次
return ja_batch, en_batch
# 使用 DataLoader 对训练数据集 train_data 进行批处理,每批次包含 BATCH_SIZE 个样本,shuffle=True 表示打乱数据顺序,
# collate_fn=generate_batch 表示使用 generate_batch 函数对每个批次进行处理
train_iter = DataLoader(train_data, batch_size=BATCH_SIZE,
shuffle=True, collate_fn=generate_batch)
8.5Sequence-to-sequence Transformer
我没有做任何更改,除了BATCH_SIZE和单词de_vocab被改为ja_vocab。
Seq2Seq模型,用于解决机器翻译任务。转换器模型由编码器和解码器块组成,每个块包含固定数量的层。编码器通过一系列多头注意和前馈网络层传播输入序列来处理输入序列。编码器的输出(称为存储器)与目标张量一起被馈送到解码器。编码器和解码器使用教师强制技术以端到端的方式进行训练。
多头注意力(Multi-head Attention)是Transformer模型中的关键组件之一,用于处理输入序列中的不同关系和依赖关系。它在自然语言处理任务中特别有效,如机器翻译和文本生成。
在传统的注意力机制中,模型会计算一个加权的平均值,以便在输入序列的不同位置上集中注意力。然而,单一的注意力集中机制可能会有限制,尤其是在处理长序列或复杂关系时。
多头注意力通过允许模型同时对输入序列的不同表示进行多次并行的注意力计算,以增强模型的表达能力和泛化能力。具体来说,它将输入通过几个不同的线性投影,然后分别应用注意力机制。每个头注意力都生成一个输出向量,最后通过拼接或加权平均等操作将这些向量组合起来,形成最终的多头注意力输出。
from torch.nn import (TransformerEncoder, TransformerDecoder,
TransformerEncoderLayer, TransformerDecoderLayer)
class Seq2SeqTransformer(nn.Module):
def __init__(self, num_encoder_layers: int, num_decoder_layers: int,
emb_size: int, src_vocab_size: int, tgt_vocab_size: int,
dim_feedforward:int = 512, dropout:float = 0.1):
super(Seq2SeqTransformer, self).__init__()
# 定义编码器层
encoder_layer = TransformerEncoderLayer(d_model=emb_size, nhead=NHEAD,
dim_feedforward=dim_feedforward)
# 实例化Transformer编码器
self.transformer_encoder = TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)
# 定义解码器层
decoder_layer = TransformerDecoderLayer(d_model=emb_size, nhead=NHEAD,
dim_feedforward=dim_feedforward)
# 实例化Transformer解码器
self.transformer_decoder = TransformerDecoder(decoder_layer, num_layers=num_decoder_layers)
# 生成器,将解码器输出转换为最终目标词汇空间的分布
self.generator = nn.Linear(emb_size, tgt_vocab_size)
# 源语言和目标语言的token嵌入
self.src_tok_emb = TokenEmbedding(src_vocab_size, emb_size)
self.tgt_tok_emb = TokenEmbedding(tgt_vocab_size, emb_size)
# 位置编码
self.positional_encoding = PositionalEncoding(emb_size, dropout=dropout)
def forward(self, src: Tensor, trg: Tensor, src_mask: Tensor,
tgt_mask: Tensor, src_padding_mask: Tensor,
tgt_padding_mask: Tensor, memory_key_padding_mask: Tensor):
# 对源语言序列进行嵌入并加上位置编码
src_emb = self.positional_encoding(self.src_tok_emb(src))
# 对目标语言序列进行嵌入并加上位置编码
tgt_emb = self.positional_encoding(self.tgt_tok_emb(trg))
# 编码器,接收源语言嵌入序列和掩码,生成编码后的记忆
memory = self.transformer_encoder(src_emb, src_mask, src_padding_mask)
# 解码器,接收目标语言嵌入序列、编码器记忆和相关掩码,生成解码后的输出
outs = self.transformer_decoder(tgt_emb, memory, tgt_mask, None,
tgt_padding_mask, memory_key_padding_mask)
# 最终通过线性层生成目标词汇空间的分布
return self.generator(outs)
def encode(self, src: Tensor, src_mask: Tensor):
# 对输入序列进行词嵌入,然后加入位置编码
# 将编码后的序列送入Transformer编码器进行处理
return self.transformer_encoder(self.positional_encoding(
self.src_tok_emb(src)), src_mask)
def decode(self, tgt: Tensor, memory: Tensor, tgt_mask: Tensor):
# 将编码后的目标序列和编码器的记忆输入Transformer解码器进行处理
return self.transformer_decoder(self.positional_encoding(
self.tgt_tok_emb(tgt)), memory,
tgt_mask)
文本标记通过使用标记嵌入来表示。位置编码被添加到标记嵌入中,以引入单词顺序的概念。
位置编码(Positional Encoding)是一种用于处理序列数据的技术,特别是在自然语言处理中,如机器翻译、文本生成和语音识别等任务中广泛使用。它的主要目的是为模型提供关于序列中单词或时间步的位置信息,以便模型能够区分不同位置的单词或输入。
在神经网络中,特别是对于基于自注意力(Self-Attention)机制的模型(如Transformer),模型并不具备显式地理解输入序列的顺序或位置。因此,引入位置编码是为了在输入向量中添加位置信息,使得模型能够在处理不同位置的信息时表现更为准确。
class PositionalEncoding(nn.Module):
def __init__(self, emb_size: int, dropout, maxlen: int = 5000):
super(PositionalEncoding, self).__init__()
# 计算位置编码矩阵
den = torch.exp(- torch.arange(0, emb_size, 2) * math.log(10000) / emb_size)
pos = torch.arange(0, maxlen).reshape(maxlen, 1)
pos_embedding = torch.zeros((maxlen, emb_size))
pos_embedding[:, 0::2] = torch.sin(pos * den)# 偶数维度使用sin函数
pos_embedding[:, 1::2] = torch.cos(pos * den) # 奇数维度使用cos函数
pos_embedding = pos_embedding.unsqueeze(-2)# 在倒数第二维添加一个维度
self.dropout = nn.Dropout(dropout)
self.register_buffer('pos_embedding', pos_embedding)# 将位置编码矩阵注册为模型的buffer变量
def forward(self, token_embedding: Tensor):
# 将位置编码矩阵加到token_embedding中,并应用dropout
return self.dropout(token_embedding +
self.pos_embedding[:token_embedding.size(0),:])
class TokenEmbedding(nn.Module):
def __init__(self, vocab_size: int, emb_size):
super(TokenEmbedding, self).__init__()
self.embedding = nn.Embedding(vocab_size, emb_size)# 嵌入层,将词汇映射为指定维度的向量
self.emb_size = emb_size# 嵌入维度
def forward(self, tokens: Tensor):
# 前向传播函数,将输入的token序列转换为对应的词嵌入向量,并乘以sqrt(emb_size)进行缩放
return self.embedding(tokens.long()) * math.sqrt(self.emb_size)
我们创建一个后续单词掩码来阻止目标单词关注其后续单词,同时还创建掩码,用于掩码源和目标填充令牌
def generate_square_subsequent_mask(sz):
# 创建一个上三角矩阵,并将其转置,确保每个位置只能看到它之前的位置
mask = (torch.triu(torch.ones((sz, sz), device=device)) == 1).transpose(0, 1)
# 将上三角矩阵转换为浮点型,并用特定值填充掩码
mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0))
return mask
def create_mask(src, tgt):
# 计算源序列和目标序列的长度
src_seq_len = src.shape[0]
tgt_seq_len = tgt.shape[0]
# 生成目标序列的自注意力掩码
tgt_mask = generate_square_subsequent_mask(tgt_seq_len)
# 创建源序列的掩码,默认为全零矩阵
src_mask = torch.zeros((src_seq_len, src_seq_len), device=device).type(torch.bool)
# 创建源序列和目标序列的填充掩码
src_padding_mask = (src == PAD_IDX).transpose(0, 1)
tgt_padding_mask = (tgt == PAD_IDX).transpose(0, 1)
return src_mask, tgt_mask, src_padding_mask, tgt_padding_mask
SRC_VOCAB_SIZE = len(ja_vocab) # 源语言词汇表大小
TGT_VOCAB_SIZE = len(en_vocab) # 目标语言词汇表大小
EMB_SIZE = 512 # 词嵌入维度大小
NHEAD = 8 # 注意力头数
FFN_HID_DIM = 512 # FeedForward层隐藏单元大小
BATCH_SIZE = 16 # 批量大小
NUM_ENCODER_LAYERS = 3 # 编码器层数
NUM_DECODER_LAYERS = 3 # 解码器层数
NUM_EPOCHS = 16 # 训练周期数
# 创建Seq2SeqTransformer模型实例
transformer = Seq2SeqTransformer(NUM_ENCODER_LAYERS, NUM_DECODER_LAYERS,
EMB_SIZE, SRC_VOCAB_SIZE, TGT_VOCAB_SIZE,
FFN_HID_DIM)
# 对模型参数进行Xavier均匀初始化
for p in transformer.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
# 将模型移动到指定的设备(如GPU)
transformer = transformer.to(device)
# 定义损失函数,交叉熵损失,忽略填充位置的损失计算
loss_fn = torch.nn.CrossEntropyLoss(ignore_index=PAD_IDX)
# 定义优化器,使用Adam优化器
optimizer = torch.optim.Adam(
transformer.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9
)
def train_epoch(model, train_iter, optimizer):
model.train() # 设置模型为训练模式
losses = 0
for idx, (src, tgt) in enumerate(train_iter): # 遍历训练数据迭代器
src = src.to(device) # 将源语言数据移动到指定设备(如GPU)
tgt = tgt.to(device) # 将目标语言数据移动到指定设备(如GPU)
tgt_input = tgt[:-1, :] # 去掉目标语言句子末尾的EOS标记,作为解码器输入
# 创建掩码
src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)
# 前向传播计算logits
logits = model(src, tgt_input, src_mask, tgt_mask,
src_padding_mask, tgt_padding_mask, src_padding_mask)
optimizer.zero_grad() # 梯度清零
tgt_out = tgt[1:, :] # 去掉目标语言句子开头的SOS标记,作为模型预测目标
# 计算损失
loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
loss.backward() # 反向传播计算梯度
optimizer.step() # 更新模型参数
losses += loss.item() # 累加损失值
return losses / len(train_iter) # 返回平均损失
def evaluate(model, val_iter):
model.eval() # 设置模型为评估模式
losses = 0
for idx, (src, tgt) in enumerate(val_iter): # 遍历验证数据迭代器
src = src.to(device) # 将源语言数据移动到指定设备(如GPU)
tgt = tgt.to(device) # 将目标语言数据移动到指定设备(如GPU)
tgt_input = tgt[:-1, :] # 去掉目标语言句子末尾的EOS标记,作为解码器输入
# 创建掩码
src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)
# 前向传播计算logits
logits = model(src, tgt_input, src_mask, tgt_mask,
src_padding_mask, tgt_padding_mask, src_padding_mask)
tgt_out = tgt[1:, :] # 去掉目标语言句子开头的SOS标记,作为模型预测目标
# 计算损失
loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
losses += loss.item() # 累加损失值
return losses / len(val_iter) # 返回平均损失
8.6开始训练
最后,在准备好必要函数之后,就可以训练模型了。但完成训练所需的时间可能会因计算能力、参数和数据集大小等因素的不同而有很大差异。
import tqdm # 导入 tqdm 库,用于显示训练进度条
for epoch in tqdm.tqdm(range(1, NUM_EPOCHS+1)): # 使用 tqdm 显示训练进度条,遍历每个 epoch
start_time = time.time() # 记录当前 epoch 开始时间
train_loss = train_epoch(transformer, train_iter, optimizer) # 调用 train_epoch 函数进行训练,并获取训练损失
end_time = time.time() # 记录当前 epoch 结束时间
print((f"Epoch: {epoch}, Train loss: {train_loss:.3f}, " # 打印当前 epoch 的训练损失和训练时间
f"Epoch time = {(end_time - start_time):.3f}s"))
8.7尝试使用经过训练的模型翻译日语句子
首先,我们创建翻译新句子的功能,包括获取日语句子、标记化、转换为张量,然后将结果解码回句子等步骤,但这次是用英语。
def greedy_decode(model, src, src_mask, max_len, start_symbol):
src = src.to(device) # 将输入的源语言句子转移到设备上(通常是GPU)
src_mask = src_mask.to(device) # 将源语言句子的掩码也转移到设备上
memory = model.encode(src, src_mask) # 对源语言句子进行编码得到记忆(编码器的输出)
ys = torch.ones(1, 1).fill_(start_symbol).type(torch.long).to(device) # 初始化目标语言句子,以起始符号开始
for i in range(max_len-1):
memory = memory.to(device) # 将记忆(编码器输出)转移到设备上
memory_mask = torch.zeros(ys.shape[0], memory.shape[0]).to(device).type(torch.bool) # 生成目标语言句子的掩码
tgt_mask = (generate_square_subsequent_mask(ys.size(0))
.type(torch.bool)).to(device) # 生成目标语言句子的自注意力掩码
out = model.decode(ys, memory, tgt_mask) # 解码器解码得到输出
out = out.transpose(0, 1) # 调整输出的维度顺序
prob = model.generator(out[:, -1]) # 使用生成器(线性层)预测下一个词的概率分布
_, next_word = torch.max(prob, dim=1) # 获取概率最大的下一个词的索引
next_word = next_word.item() # 将下一个词的索引转为Python整数
ys = torch.cat([ys,
torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=0) # 将预测的下一个词添加到目标语言句子中
if next_word == EOS_IDX: # 如果预测的下一个词是终止符号,则停止解码
break
return ys
def translate(model, src, src_vocab, tgt_vocab, src_tokenizer):
model.eval() # 设置模型为评估模式
tokens = [BOS_IDX] + [src_vocab.stoi[tok] for tok in src_tokenizer.encode(src, out_type=str)] + [EOS_IDX] # 对源语言句子进行分词和索引化
num_tokens = len(tokens) # 获取源语言句子的长度(词数)
src = (torch.LongTensor(tokens).reshape(num_tokens, 1)) # 将源语言句子转为PyTorch张量并调整形状
src_mask = (torch.zeros(num_tokens, num_tokens)).type(torch.bool) # 生成源语言句子的掩码
tgt_tokens = greedy_decode(model, src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX).flatten() # 使用贪婪解码生成目标语言句子的索引序列
return " ".join([tgt_vocab.itos[tok] for tok in tgt_tokens]).replace("<bos>", "").replace("<eos>", "") # 将目标语言句子的索引序列转为文本并返回
然后,我们可以直接调用translate函数并传递所需的参数。
translate(transformer, "HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)", ja_vocab, en_vocab, ja_tokenizer)
# 将日语文本翻译成英语
trainen.pop(5)#移除列表中索引为5的元素,并返回该元素的值
trainja.pop(5)#移除列表中索引为5的元素,并返回该元素的值
8.8保存Vocab对象和训练的模型
最后,在训练完成后,我们将首先使用Pickle保存Vocab对象(en_Vocab和ja_Vocab)
import pickle
# 打开一个文件,用于存储数据
file = open('en_vocab.pkl', 'wb')
# 将数据转储到文件中
pickle.dump(en_vocab, file)
# 关闭文件
file.close()
# 打开另一个文件,用于存储数据
file = open('ja_vocab.pkl', 'wb')
# 将数据转储到文件中
pickle.dump(ja_vocab, file)
# 关闭文件
file.close()
最后,我们还可以使用PyTorch保存和加载函数保存模型以供以后使用。
通常,有两种方法可以保存模型,具体取决于我们以后要使用它们。第一个仅用于推理,我们可以稍后加载模型,并使用它将日语翻译为英语。
# save model for inference
# 保存用于推理的模型
torch.save(transformer.state_dict(), 'inference_model')
第二个也用于推理,但也用于稍后加载模型并恢复训练时
# 保存模型和检查点,以便稍后恢复训练
torch.save({
'epoch': NUM_EPOCHS, # 当前训练的轮次数
'model_state_dict': transformer.state_dict(), # 模型的状态字典
'optimizer_state_dict': optimizer.state_dict(), # 优化器的状态字典
'loss': train_loss, # 当前的训练损失
}, 'model_checkpoint.tar')