[NLP]机器翻译介绍与基于transformer的实现

致谢Mr.Dong提供的资源与教学

机器翻译

机器翻译是指将一段文本从一种语言自动翻译到另一种语言。因为一段文本序列在不同语言中的长度不一定相同,所以我们使用机器翻译为例来介绍编码器—解码器和注意力机制的应用。

1 读取和预处理数据

我们先定义一些特殊符号。其中“”(padding)符号用来添加在较短序列后,直到每个序列等长,而“”和“”符号分别表示序列的开始和结束。

!tar -xf d2lzh_pytorch.tar
import collections
import os
import io
import math
import torch  # 导入PyTorch深度学习框架
from torch import nn  # 导入nn模块,包含定义神经网络层的类
import torch.nn.functional as F  # 导入F模块,包含各种非线性函数和神经网络层
import torchtext.vocab as Vocab  # 导入torchtext.vocab模块,用于构建词汇表
import torch.utils.data as Data  # 导入Data模块,提供用于数据加载和预处理的工具

import sys  # 导入sys模块,用于与Python解释器进行交互
# sys.path.append("..")  # 将上层目录添加到模块搜索路径,用于导入自定义模块
import d2lzh_pytorch as d2l  # 导入d2lzh_pytorch模块,这是一个自定义的深度学习库

PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'  # 定义特殊符号,表示填充、序列起始和序列终止
os.environ["CUDA_VISIBLE_DEVICES"] = "0"  # 设置CUDA可见设备,指定GPU编号为0
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')  # 根据CUDA是否可用选择设备

print(torch.__version__, device)  # 打印PyTorch版本和当前使用的设备类型
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)  # 将当前序列的词添加到总词列表中
    seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)  # 在序列末尾添加EOS,并用PAD填充至max_seq_len长度
    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'隔开。在读取数据时,我们在句末附上“”符号,并可能通过添加“”符号使每个序列的长度均为max_seq_len。我们为法语词和英语词分别创建词典。法语词的索引和英语词的索引相互独立。

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')  # 拆分输入和输出序列,rstrip去除行末的空白字符,split按制表符分割
        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:  # 如果加上EOS后长于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  # 定义最大序列长度
# 调用read_data函数读取数据并处理成模型可以使用的形式
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]))

2 含注意力机制的编码器—解码器

我们将使用含注意力机制的编码器—解码器来将一段简短的法语翻译成英语。下面我们来介绍模型的实现。

2.1 编码器

在编码器中,我们将输入语言的词索引通过词嵌入层得到词的表征,然后输入到一个多层门控循环单元中。正如我们在6.5节(循环神经网络的简洁实现)中提到的,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)  # 定义一个词嵌入层,将词索引转换为密集向量表示
        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)  # 执行词嵌入操作并对维度进行置换,变为(seq_len, batch, input_size)
        return self.rnn(embedding, state)  # 将处理后的输入embedding传入GRU网络进行计算,返回输出和最终状态

    def begin_state(self):
        return None  # 编码器的初始状态始终为None,因为在解码器的注意力机制中,不需要初始状态

下面我们来创建一个批量大小为4、时间步数为7的小批量序列输入。设门控循环单元的隐藏层个数为2,隐藏单元个数为16。编码器对该输入执行前向计算后返回的输出形状为(时间步数, 批量大小, 隐藏单元个数)。门控循环单元在最终时间步的多层隐藏状态的形状为(隐藏层个数, 批量大小, 隐藏单元个数)。对于门控循环单元来说,state就是一个元素,即隐藏状态;如果使用长短期记忆,state是一个元组,包含两个元素即隐藏状态和记忆细胞。

encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)  # 创建一个编码器实例,定义了词汇量大小、词嵌入维度、隐藏单元数和层数
# 使用编码器处理一个形状为(4, 7)的全零张量,并获取输出和状态
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]))

2.2 注意力机制

我们将实现10.11节(注意力机制)中定义的函数 a a a:将输入连结后通过含单隐藏层的多层感知机变换。其中隐藏层的输入是解码器的隐藏状态与编码器在所有时间步上隐藏状态的一一连结,且使用tanh函数作为激活函数。输出层的输出个数为1。两个Linear实例均不使用偏差。其中函数 a a a定义里向量 v \boldsymbol{v} v的长度是一个超参数,即attention_size

def attention_model(input_size, attention_size):
    """
    创建一个简单的注意力模型,用于计算注意力权重。

    参数:
    - input_size:输入特征的大小
    - attention_size:注意力层中间隐藏层的大小

    返回:
    - model:包含线性层和非线性激活函数的Sequential模型
    """
    model = nn.Sequential(
        nn.Linear(input_size, attention_size, bias=False),  # 输入特征到注意力层的线性变换,无偏置
        nn.Tanh(),  # 使用双曲正切作为激活函数
        nn.Linear(attention_size, 1, bias=False)  # 将注意力层的隐藏表示映射到一个标量值,无偏置
    )
    return model

注意力机制的输入包括查询项、键项和值项。设编码器和解码器的隐藏单元个数相同。这里的查询项为解码器在上一时间步的隐藏状态,形状为(批量大小, 隐藏单元个数);键项和值项均为编码器在所有时间步的隐藏状态,形状为(时间步数, 批量大小, 隐藏单元个数)。注意力机制返回当前时间步的背景变量,形状为(批量大小, 隐藏单元个数)。

def attention_forward(model, enc_states, dec_state):
    """
    使用注意力模型计算注意力权重并计算编码器状态的加权平均作为背景变量。

    参数:
    - model:注意力模型,用于计算注意力权重
    - enc_states:编码器的所有时间步的隐藏状态,形状为(时间步数, 批量大小, 隐藏单元个数)
    - dec_state:解码器当前时间步的隐藏状态,形状为(批量大小, 隐藏单元个数)

    返回:
    - context:加权平均得到的背景变量,形状为(批量大小, 隐藏单元个数)
    """
    # 将解码器隐藏状态广播到和编码器隐藏状态形状相同后进行连结
    dec_states = dec_state.unsqueeze(dim=0).expand_as(enc_states)
    enc_and_dec_states = torch.cat((enc_states, dec_states), dim=2)    
    # 使用注意力模型计算注意力能量e,形状为(时间步数, 批量大小, 1)
    e = model(enc_and_dec_states)    
    # 对时间步维度进行softmax运算,得到注意力权重alpha,形状为(时间步数, 批量大小, 1)
    alpha = F.softmax(e, dim=0)
    # 计算加权平均得到的背景变量,形状为(批量大小, 隐藏单元个数)
    context = (alpha * enc_states).sum(dim=0)
    return context

在下面的例子中,编码器的时间步数为10,批量大小为4,编码器和解码器的隐藏单元个数均为8。注意力机制返回一个小批量的背景向量,每个背景向量的长度等于编码器的隐藏单元个数。因此输出的形状为(4, 8)。

seq_len, batch_size, num_hiddens = 10, 4, 8
model = attention_model(2*num_hiddens, 10)  # 创建注意力模型,输入大小为编码器和解码器隐藏状态的两倍,输出大小为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
torch.Size([4, 8])

2.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,输入包含注意力输出和实际输入,所以输入尺寸是 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])
        # 将嵌入后的输入和背景向量在特征维连结,形状为 (batch_size, 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)
        # 移除时间步维,输出形状为 (batch_size, vocab_size)
        output = self.out(output).squeeze(dim=0)
        return output, state

    def begin_state(self, enc_state):
        # 直接将编码器最终时间步的隐藏状态作为解码器的初始隐藏状态
        return enc_state

3 训练模型

我们先实现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)  # 使用编码器最终隐藏状态初始化解码器的隐藏状态
    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])  # 初始化损失为0

    # 遍历目标序列Y的每个时间步
    for y in Y.permute(1, 0):  # Y的形状为(batch, seq_len),使用permute进行维度调整,变成(seq_len, batch)
        dec_output, dec_state = decoder(dec_input, dec_state, enc_outputs)  # 解码器前向计算,得到输出和新的隐藏状态
        l = l + (mask * loss(dec_output, y)).sum()  # 计算损失,使用掩码变量忽略填充项PAD的损失
        dec_input = y  # 使用强制教学,将当前目标词作为下一个时间步的输入
        num_not_pad_tokens += mask.sum().item()  # 更新非填充词数目
        mask = mask * (y != out_vocab.stoi[EOS]).float()  # 更新掩码变量,遇到EOS后面的时间步mask设为0

    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')  # 定义损失函数为交叉熵损失,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)))  # 打印当前epoch的平均损失

接下来,创建模型实例并设置超参数。然后,我们就可以训练模型了。

# 定义编码器和解码器的隐藏单元数、层数和嵌入维度
embed_size, num_hiddens, num_layers = 64, 64, 2
# 定义注意力机制的大小、dropout概率、学习率、批量大小和训练epoch数
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)
epoch 10, loss 0.483
epoch 20, loss 0.175
epoch 30, loss 0.088
epoch 40, loss 0.052
epoch 50, loss 0.031

4 预测不定长的序列

在10.10节(束搜索)中我们介绍了3种方法来生成解码器在每个时间步的输出。这里我们实现最简单的贪婪搜索。

In [20]:

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)
    
    # 将输入序列转换为索引序列并添加一个批量维度,形状为(1, max_seq_len)
    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 = []
    
    # 迭代解码器的最大序列长度次数,或直到输出EOS为止
    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())]
        
        # 如果预测的单词为EOS,则停止生成序列
        if pred_token == 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', '.']

5 评价翻译结果

评价机器翻译结果通常使用BLEU(Bilingual Evaluation Understudy)[1]。对于模型预测序列中任意的子序列,BLEU考察这个子序列是否出现在标签序列中。

具体来说,设词数为 n n n的子序列的精度为 p _ n p\_n p_n。它是预测序列与标签序列匹配词数为 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 p_1=4/5,p_2=3/4,p_3=1/3,p_4=0。设 l e n _ label len\_{\text{label}} len_label l e n _ pred len\_{\text{pred}} len_pred分别为标签序列和预测序列的词数,那么,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,1len_predlen_label))_n=1kp_n1/2n,

其中 k k k是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU为1。

因为匹配较长子序列比匹配较短子序列更难,BLEU对匹配较长子序列的精度赋予了更大权重。例如,当 p _ n p\_n p_n固定在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/20.7,0.51/40.84,0.51/80.92,0.51/160.96。另外,模型预测较短序列往往会得到较高 p _ n p\_n p_n值。因此,上式中连乘项前面的系数是为了惩罚较短的输出而设的。举个例子,当 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 p_1=p_2=1,但惩罚系数 exp ⁡ ( 1 − 6 / 2 ) ≈ 0.14 \exp(1-6/2) \approx 0.14 exp(16/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分数为一个基础分数,考虑预测序列长度和标签序列长度的比例
    score = math.exp(min(0, 1 - len_label / len_pred))
    
    # 对于每个n-gram长度,计算匹配数量并更新BLEU分数
    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数量计算权重调整后的BLEU分数
        score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
    
    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分数
    bleu_score = bleu(pred_tokens, label_tokens, k)
    print('bleu %.3f, predict: %s' % (bleu_score, ' '.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 .

小结

  • 可以将编码器—解码器和注意力机制应用于机器翻译中。
  • BLEU可以用来评价翻译结果。

练习

  • 如果编码器和解码器的隐藏单元个数不同或层数不同,我们该如何改进解码器的隐藏状态初始化方法?

当编码器和解码器的隐藏单元个数或层数不同,我们不能直接把编码器的隐藏状态传给解码器。一个简单的办法是通过一个线性变换(如全连接层)来调整编码器的隐藏状态,使其适应解码器的要求。例如,我们可以用一个全连接层将编码器的隐藏状态转换为解码器所需的大小和形状。这样,我们就能保证解码器在初始化时能够接受适合自己的隐藏状态,从而更好地进行解码。#填写答案,可根据需要将当前Cell的CellType设置为Markdown或继续添加新的Cell

  • 在训练中,将强制教学替换为使用解码器在上一时间步的输出作为解码器在当前时间步的输入。结果有什么变化吗?

将强制教学改为自由运行,训练过程中可能会遇到一些不同的结果。强制教学用真实的目标序列来指导解码器,这使得训练更稳定,收敛更快。但是,这也会导致模型在推理时表现较差,因为它没有学会处理自己的预测作为输入。自由运行更贴近实际的推理场景,因为每一步的输入都是前一步的预测,这能帮助模型更好地适应实际使用时的情况。然而,自由运行在训练初期会比较困难,因为错误的预测会累积,使得训练过程更不稳定、收敛更慢。所以,两种方法在训练速度和模型泛化性能上会有不同的表现。

  • 试着使用更大的翻译数据集来训练模型,例如 WMT [2] 和 Tatoeba Project [3]。
import os
import collections
import torch
from torch.utils.data import DataLoader, Dataset
import torchtext.vocab as Vocab
import torch.nn as nn
import torch.nn.functional as F

# 定义特殊标记
PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'
max_seq_len = 10  # 设置最大序列长度

# 数据预处理函数,用于处理单个序列
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)  # 添加EOS标记并填充PAD
    all_seqs.append(seq_tokens)  # 将处理后的序列添加到序列列表中

# 构建词汇表和索引数据
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)  # 返回词汇表和索引数据

# 读取数据函数
def read_data(file_path, max_seq_len):
    in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []  # 初始化输入输出序列和词汇列表
    with open(file_path, 'r') 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  # 忽略超长的序列
        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, DataLoader(list(zip(in_data, out_data)), batch_size=32, shuffle=True)  # 返回词汇表和数据迭代器

# 读取训练和验证数据集
train_file_path = 'fr-en-small.txt'  # 训练数据文件路径
val_file_path = 'fr-en-small.txt'  # 验证数据文件路径
in_vocab, out_vocab, train_iter = read_data(train_file_path, max_seq_len)  # 读取训练数据
_, _, val_iter = read_data(val_file_path, max_seq_len)  # 读取验证数据

# 检查数据
print(f"Vocabulary size: {len(in_vocab)}")  # 打印输入词汇表大小
print(f"First training example (input): {train_iter.dataset[0][0]}")  # 打印第一个训练样本的输入
print(f"First training example (output): {train_iter.dataset[0][1]}")  # 打印第一个训练样本的输出
Vocabulary size: 46
First training example (input): tensor([ 5,  4, 45,  3,  2,  0,  0,  0,  0,  0])
First training example (output): tensor([ 8,  4, 27,  3,  2,  0,  0,  0,  0,  0])
class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, drop_prob=0):
        super(Encoder, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob)

    def forward(self, inputs, state):
        embedding = self.embedding(inputs.long()).permute(1, 0, 2)
        return self.rnn(embedding, state)

    def begin_state(self):
        return None

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 = self.attention_model(2*num_hiddens, attention_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 attention_model(self, input_size, attention_size):
        model = nn.Sequential(nn.Linear(input_size, attention_size, bias=False),
                              nn.Tanh(),
                              nn.Linear(attention_size, 1, bias=False))
        return model

    def forward(self, cur_input, state, enc_states):
        c = self.attention_forward(self.attention, enc_states, state[-1])
        input_and_c = torch.cat((self.embedding(cur_input), c), dim=1)
        output, state = self.rnn(input_and_c.unsqueeze(0), state)
        output = self.out(output).squeeze(dim=0)
        return output, state

    def attention_forward(self, model, 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)
        return (alpha * enc_states).sum(dim=0)

    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)
    dec_input = torch.tensor([out_vocab.stoi[BOS]] * batch_size)
    mask, num_not_pad_tokens = torch.ones(batch_size,), 0
    l = torch.tensor([0.0])
    for y in Y.permute(1, 0):
        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()
        mask = mask * (y != out_vocab.stoi[EOS]).float()
    return l / num_not_pad_tokens

def train(encoder, decoder, train_iter, val_iter, lr, 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')
    for epoch in range(num_epochs):
        l_sum = 0.0
        for X, Y in train_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()
        print(f"Epoch {epoch+1}, loss {l_sum/len(train_iter):.3f}")

# 参数设定
embed_size, num_hiddens, num_layers = 64, 64, 2
attention_size, drop_prob, lr, num_epochs = 10, 0.5, 0.01, 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, train_iter, val_iter, lr, num_epochs)

Epoch 1, loss 3.648
Epoch 2, loss 3.268
Epoch 3, loss 2.799
Epoch 4, loss 2.492
Epoch 5, loss 2.277
Epoch 6, loss 2.095
Epoch 7, loss 1.903
Epoch 8, loss 1.734
Epoch 9, loss 1.666
Epoch 10, loss 1.472
Epoch 11, loss 1.370
Epoch 12, loss 1.252
Epoch 13, loss 1.173
Epoch 14, loss 1.112
Epoch 15, loss 1.049
Epoch 16, loss 1.002
Epoch 17, loss 0.918
Epoch 18, loss 0.878
Epoch 19, loss 0.831
Epoch 20, loss 0.783
Epoch 21, loss 0.733
Epoch 22, loss 0.702
Epoch 23, loss 0.645
Epoch 24, loss 0.608
Epoch 25, loss 0.574
Epoch 26, loss 0.557
Epoch 27, loss 0.526
Epoch 28, loss 0.499
Epoch 29, loss 0.466
Epoch 30, loss 0.448
Epoch 31, loss 0.454
Epoch 32, loss 0.441
Epoch 33, loss 0.426
Epoch 34, loss 0.394
Epoch 35, loss 0.391
Epoch 36, loss 0.392
Epoch 37, loss 0.388
Epoch 38, loss 0.383
Epoch 39, loss 0.346
Epoch 40, loss 0.337
Epoch 41, loss 0.327
Epoch 42, loss 0.319
Epoch 43, loss 0.325
Epoch 44, loss 0.305
Epoch 45, loss 0.311
Epoch 46, loss 0.285
Epoch 47, loss 0.278
Epoch 48, loss 0.274
Epoch 49, loss 0.260
Epoch 50, loss 0.247

参考文献

[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/

Transformer & PyTorch 日语-汉语机器翻译模型

使用 Jupyter Notebook、PyTorch、Torchtext 和 SentencePiece 的教程

导入所需软件包

首先,确保系统中已安装以下软件包,如果发现缺少某些软件包,请务必安装。

import math
import torchtext
import torch
import torch.nn as nn
from torch import Tensor
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader
from collections import Counter
from torchtext.vocab import Vocab
from torch.nn import TransformerEncoder, TransformerDecoder, TransformerEncoderLayer, TransformerDecoderLayer
import io
import time
import pandas as pd
import numpy as np
import pickle
import tqdm
import sentencepiece as spm
torch.manual_seed(0)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# print(torch.cuda.get_device_name(0)) ## 如果你有GPU,请在你自己的电脑上尝试运行这一套代码

device
device(type='cpu')

获取并行数据集

在本教程中,我们将使用从 JParaCrawl![http://www.kecl.ntt.co.jp/icl/lirg/jparacrawl] 下载的日英并行数据集,该数据集被称为 “NTT 创建的最大的公开可用英日并行语料库”。它主要是通过抓取网络并自动对齐平行句子而创建的"。您还可以在此处查看论文。

df = pd.read_csv('zh-ja.bicleaner05.txt', sep='\\t', engine='python', header=None)
trainen = df[2].values.tolist()#[:10000]
trainja = df[3].values.tolist()#[:10000]
# trainen.pop(5972)
# trainja.pop(5972)

在导入所有日语及其对应英语后,我删除了数据集中的最后一个数据,因为它有一个缺失值。总的来说,trainen 和 trainja 中的句子数量为 5,973,071 个。不过,出于学习目的,通常建议在一次性使用所有数据之前,先对数据进行采样,确保一切正常,以节省时间。

下面是数据集中的一个句子示例。

print(trainen[500])
print(trainja[500])
Chinese HS Code Harmonized Code System < HS编码 2905 无环醇及其卤化、磺化、硝化或亚硝化衍生物 HS Code List (Harmonized System Code) for US, UK, EU, China, India, France, Japan, Russia, Germany, Korea, Canada ...
Japanese HS Code Harmonized Code System < HSコード 2905 非環式アルコール並びにそのハロゲン化誘導体、スルホン化誘導体、ニトロ化誘導体及びニトロソ化誘導体 HS Code List (Harmonized System Code) for US, UK, EU, China, India, France, Japan, Russia, Germany, Korea, Canada ...

我们也可以使用不同的并行数据集来跟进本文,只需确保我们能将数据处理成如上图所示的两个字符串列表,其中包含日语和英语句子。

准备标记符

与英语或其他字母语言不同,日语句子不包含分隔单词的空格。我们可以使用 JParaCrawl 提供的标记化器,该标记化器是使用 SentencePiece 为日语和英语创建的,您可以访问 JParaCrawl 网站下载,或点击此处。

en_tokenizer = spm.SentencePieceProcessor(model_file='spm.en.nopretok.model')
ja_tokenizer = spm.SentencePieceProcessor(model_file='spm.ja.nopretok.model')

After the tokenizers are loaded, you can test them, for example, by executing the below code.

en_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.", out_type='str')
['▁All',
 '▁residents',
 '▁aged',
 '▁20',
 '▁to',
 '▁59',
 '▁years',
 '▁who',
 '▁live',
 '▁in',
 '▁Japan',
 '▁must',
 '▁enroll',
 '▁in',
 '▁public',
 '▁pension',
 '▁system',
 '.']
ja_tokenizer.encode("年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。", out_type='str')
['▁',
 '年',
 '金',
 '▁日本',
 'に住んでいる',
 '20',
 '歳',
 '~',
 '60',
 '歳の',
 '全ての',
 '人は',
 '、',
 '公的',
 '年',
 '金',
 '制度',
 'に',
 '加入',
 'しなければなりません',
 '。']

构建 TorchText 词汇表对象并将句子转换为 Torch 张量

然后,我们使用标记符号和原始句子构建从 TorchText 导入的 Vocab 对象。根据数据集的大小和计算能力,这个过程可能需要几秒或几分钟。不同的标记符号化器也会影响构建词汇表所需的时间,我尝试过其他几种日语标记符号化器,但 SentencePiece 对我来说似乎效果不错,速度也够快。

from collections import Counter
from some_vocab_library import Vocab  # 从合适的库中导入 Vocab 类
def build_vocab(sentences, tokenizer):
    """
    使用指定的分词器从句子列表构建词汇表。
    """
    counter = Counter()  # 初始化一个计数器来跟踪词频
    for sentence in sentences:
        # 分词句子并更新计数器
        counter.update(tokenizer.encode(sentence, out_type=str))
    # 使用计数器创建 Vocab 对象,并添加特殊符号
    return Vocab(counter, specials=['<unk>', '<pad>', '<bos>', '<eos>'])
# 假设 trainja 和 trainen 是日语和英语句子的列表
# 假设 ja_tokenizer 和 en_tokenizer 分别是日语和英语的分词器
# 为日语句子构建词汇表
ja_vocab = build_vocab(trainja, ja_tokenizer)
# 为英语句子构建词汇表
en_vocab = build_vocab(trainen, en_tokenizer)

有了词汇表对象后,我们就可以使用词汇表和标记符对象为训练数据构建张量。

def data_process(ja, en):
    """
    处理日语和英语数据,将其转换为张量。
    """
    data = []  # 初始化数据列表
    for (raw_ja, raw_en) in zip(ja, en):
        # 将日语句子转换为张量
        ja_tensor_ = torch.tensor([ja_vocab[token] for token in ja_tokenizer.encode(raw_ja.rstrip("\n"), out_type=str)],
                                  dtype=torch.long)
        # 将英语句子转换为张量
        en_tensor_ = torch.tensor([en_vocab[token] for token in en_tokenizer.encode(raw_en.rstrip("\n"), out_type=str)],
                                  dtype=torch.long)
        # 将转换后的张量添加到数据列表
        data.append((ja_tensor_, en_tensor_))
    return data
# 处理训练数据
train_data = data_process(trainja, trainen)

创建数据加载器对象,以便在训练过程中进行迭代

在这里,我将 BATCH_SIZE 设置为 16,以防止出现 "cuda 内存不足 "的情况,但这取决于多种因素,例如您的机器内存容量、数据大小等,因此您可以根据自己的需要随意更改批处理大小(注:PyTorch 的教程使用 Multi30k 德语-英语数据集将批处理大小设置为 128)。

BATCH_SIZE = 8
PAD_IDX = ja_vocab['<pad>']
BOS_IDX = ja_vocab['<bos>']
EOS_IDX = ja_vocab['<eos>']
def generate_batch(data_batch):
    """
    生成一个批次的数据,添加起始和结束标记,并进行填充。
    """
    ja_batch, en_batch = [], []  # 初始化日语和英语批次列表
    for (ja_item, en_item) in data_batch:
        # 添加起始和结束标记到日语句子
        ja_batch.append(torch.cat([torch.tensor([BOS_IDX]), ja_item, torch.tensor([EOS_IDX])], dim=0))
        # 添加起始和结束标记到英语句子
        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_iter = DataLoader(train_data, batch_size=BATCH_SIZE,
                        shuffle=True, collate_fn=generate_batch)

学习Sequence-to-sequence Transformer

接下来的几段代码和文字说明(斜体)来自 PyTorch 的原始教程 [https://pytorch.org/tutorials/beginner/translation_transformer.html]。除了 BATCH_SIZE 和 de_vocab 改为 ja_vocab 之外,我没有做任何改动。

Transformer 是 "Attention is all you need "一文中介绍的用于解决机器翻译任务的 Seq2Seq 模型。转换器模型由编码器和解码器块组成,每个块包含固定层数。

编码器通过一系列多头注意和前馈网络层传播输入序列,从而处理输入序列。编码器的输出被称为内存,与目标张量一起输入解码器。编码器和解码器采用教师强迫技术,以端到端的方式进行训练。

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)
        # 定义源语言和目标语言的词嵌入
        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):
        # 编码源语言
        return self.transformer_encoder(self.positional_encoding(
                            self.src_tok_emb(src)), src_mask)

    def decode(self, tgt: Tensor, memory: Tensor, tgt_mask: Tensor):
        # 解码目标语言
        return self.transformer_decoder(self.positional_encoding(
                          self.tgt_tok_emb(tgt)), memory, tgt_mask)

文本标记通过标记嵌入来表示。标记嵌入中加入了位置编码,以引入词序概念。

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))
        # 偶数位置使用sin函数
        pos_embedding[:, 0::2] = torch.sin(pos * den)
        # 奇数位置使用cos函数
        pos_embedding[:, 1::2] = torch.cos(pos * den)
        pos_embedding = pos_embedding.unsqueeze(-2)

        self.dropout = nn.Dropout(dropout)
        self.register_buffer('pos_embedding', pos_embedding)  # 注册位置编码为缓冲区

    def forward(self, token_embedding: Tensor):
        # 添加位置编码并应用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):
        # 进行词嵌入并进行缩放
        return self.embedding(tokens.long()) * math.sqrt(self.emb_size)

我们创建了一个后续词掩码,以阻止目标词关注其后续词。我们还创建了掩码,用于掩码源填充标记和目标填充标记

def generate_square_subsequent_mask(sz):
    """
    生成大小为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

定义模型参数并将模型实例化。 这里我们服务器实在是计算能力有限,按照以下配置可以训练但是效果应该是不行的。如果想要看到训练的效果请使用你自己的带GPU的电脑运行这一套代码。

当你使用自己的GPU的时候,NUM_ENCODER_LAYERS 和 NUM_DECODER_LAYERS 设置为3或者更高,NHEAD设置8,EMB_SIZE设置为512。

SRC_VOCAB_SIZE = len(ja_vocab)
TGT_VOCAB_SIZE = len(en_vocab)
EMB_SIZE = 512
NHEAD = 8
FFN_HID_DIM = 512
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)

# 将模型移动到设备上
transformer = transformer.to(device)

# 定义损失函数,忽略填充符号的损失
loss_fn = torch.nn.CrossEntropyLoss(ignore_index=PAD_IDX)

# 定义优化器
optimizer = torch.optim.Adam(
    transformer.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9
)

def train_epoch(model, train_iter, optimizer):
    """
    训练模型一个epoch
    """
    model.train()  # 设置模型为训练模式
    losses = 0
    for idx, (src, tgt) in enumerate(train_iter):
        src = src.to(device)
        tgt = tgt.to(device)

        tgt_input = tgt[:-1, :]  # 去掉目标句子的最后一个token

        # 创建掩码
        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

        # 前向传播
        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:, :]  # 去掉目标句子的第一个token
        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)
        tgt = tgt.to(device)

        tgt_input = tgt[:-1, :]  # 去掉目标句子的最后一个token

        # 创建掩码
        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

        # 前向传播
        logits = model(src, tgt_input, src_mask, tgt_mask,
                       src_padding_mask, tgt_padding_mask, src_padding_mask)
        tgt_out = tgt[1:, :]  # 去掉目标句子的第一个token
        loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))  # 计算损失
        losses += loss.item()  # 累加损失
    return losses / len(val_iter)  # 返回平均损失

开始训练

最后,在准备好必要的类和函数后,我们就可以训练模型了。这一点不言而喻,但完成训练所需的时间会因计算能力、参数和数据集大小等诸多因素而有很大差异。

当我使用来自 JParaCrawl 的完整句子列表(每种语言约有 590 万个句子)来训练模型时,预计如果使用单个 NVIDIA GeForce RTX 3070 GPU,每个历时大约需要 5 个小时。

以下是代码:

for epoch in tqdm.tqdm(range(1, NUM_EPOCHS + 1)):
    start_time = time.time()  # 记录开始时间
    train_loss = train_epoch(transformer, train_iter, optimizer)  # 训练一个epoch
    end_time = time.time()  # 记录结束时间
    print((f"Epoch: {epoch}, Train loss: {train_loss:.3f}, "
           f"Epoch time = {(end_time - start_time):.3f}s"))  # 输出训练损失和epoch耗时
  0%|          | 0/16 [00:42<?, ?it/s]

尝试使用训练好的模型翻译一个日语句子

首先,我们创建了翻译一个新句子的函数,包括获取日语句子、标记化、转换为张量、推理等步骤,然后将结果解码为一个句子,但这次是英语句子。

def greedy_decode(model, src, src_mask, max_len, start_symbol):
    """
    使用贪婪解码法进行序列解码。
    """
    src = src.to(device)
    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()
        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)  # 将源句子转换为张量
    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)
'Chinese HS Code Harmonized Code System < HS编码 8515 : 电气(包括电热气体)、激光、其他光、光子束、超声波、电子束、磁脉冲或等离子弧焊接机器及装置,不论是否 HS Code List (Harmonized System Code) for US, UK, EU, China, India, France, Japan, Russia, Germany, Korea, Canada ...'
trainja.pop(5)
'Japanese HS Code Harmonized Code System < HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)、レーザーその他の光子ビーム式、超音波式、電子ビーム式、 HS Code List (Harmonized System Code) for US, UK, EU, China, India, France, Japan, Russia, Germany, Korea, Canada ...'

保存词汇表对象和训练过的模型

最后,在训练结束后,我们将首先使用 Pickle 保存词汇表对象(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,  # 保存当前训练的epoch数目
    'model_state_dict': transformer.state_dict(),  # 保存模型的状态字典
    'optimizer_state_dict': optimizer.state_dict(),  # 保存优化器的状态字典
    'loss': train_loss,  # 保存当前训练的损失值
}, 'model_checkpoint.tar')  # 将数据保存到名为'model_checkpoint.tar'的文件中
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值