NLP——机器翻译

1. 机器翻译

1. 读取和预处理数据

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

!tar -xf d2lzh_pytorch.tar
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"  # 设置环境变量,指定可见的CUDA设备为编号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 中,用于构建词典
    all_tokens.extend(seq_tokens)
    
    # 在当前序列末尾添加 EOS(结束符)并用 PAD 填充至最大序列长度 max_seq_len
    seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)
    
    # 将处理后的序列加入到 all_seqs 中
    all_seqs.append(seq_tokens)

# 使用所有的词来构造词典。并将所有序列中的词变换为词索引后构造Tensor
def build_data(all_tokens, all_seqs):
    # 使用 collections.Counter 统计所有词的频次,并传入 Vocab 构造函数中构建词典
    vocab = Vocab.Vocab(collections.Counter(all_tokens),
                        specials=[PAD, BOS, EOS])
    
    # 将所有序列中的词转换为对应的索引表示
    indices = [[vocab.stoi[w] for w in seq] for seq in all_seqs]
    
    # 返回构建好的词典 vocab 和将序列转换为 Tensor 后的结果 indices
    return vocab, torch.tensor(indices)

为了演示方便,我们在这里使用一个很小的法语—英语数据集。在这个数据集里,每一行是一对法语句子和它对应的英语句子,中间使用'\t'隔开。在读取数据时,我们在句末附上“<eos>”符号,并可能通过添加“<pad>”符号使每个序列的长度均为max_seq_len。我们为法语词和英语词分别创建词典。法语词的索引和英语词的索引相互独立。


# 读取数据并预处理
def read_data(max_seq_len):
    # 初始化用于存储所有词和序列的列表
    in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []
    
    # 打开文件,读取所有行
    with io.open('fr-en-small.txt', 'r', encoding='utf-8') 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(' ')
        
        # 如果加上 EOS 后的长度超过 max_seq_len,则忽略此样本
        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, Data.TensorDataset(in_data, out_data)

将序列的最大长度设成7,然后查看读取到的第一个样本。该样本分别包含法语词索引序列和英语词索引序列。

max_seq_len = 7
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. 含注意力机制的编码器—解码器

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

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)
        
        # 定义编码器的循环神经网络层(GRU),输入维度是嵌入大小,隐藏单元数量是num_hiddens
        # num_layers 表示堆叠的GRU层的数量,dropout表示丢弃率
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob)

    def forward(self, inputs, state):
        # inputs的形状是(批量大小, 时间步数),将其转换为(时间步数, 批量大小, 输入大小)
        embedding = self.embedding(inputs.long()).permute(1, 0, 2)  # (seq_len, batch, input_size)
        
        # 将输入embedding和初始状态state传递给GRU层进行前向传播
        return self.rnn(embedding, state)

    def begin_state(self):
        # 编码器的初始状态通常是None或者全零张量,具体情况取决于模型的设计和需求
        return None

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

运行结果:

(torch.Size([7, 4, 16]), torch.Size([2, 4, 16]))

2. 注意力机制

我们将实现注意力机制中定义的函数𝑎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),
                          nn.Tanh(),
                          nn.Linear(attention_size, 1, bias=False))
    return model

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


def attention_forward(model, enc_states, dec_state):
    """
    Attention前向计算函数
    
    参数:
    - 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)  # e 的形状为 (时间步数, 批量大小, 1)
    
    # 对注意力权重进行 softmax 操作,沿着时间步维度进行归一化
    alpha = F.softmax(e, dim=0)  # alpha 的形状为 (时间步数, 批量大小, 1)
    
    # 使用注意力权重对编码器的隐藏状态进行加权求和,得到背景变量
    context_vector = (alpha * enc_states).sum(dim=0)  # 形状为 (批量大小, 隐藏单元个数)
    
    return context_vector

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

# 示例数据
seq_len, batch_size, num_hiddens = 10, 4, 8
model = attention_model(2*num_hiddens, 10)  # 假设 attention_model 是一个模型或函数,输入大小为 2*num_hiddens 和 10
enc_states = torch.zeros((seq_len, batch_size, num_hiddens))  # 编码器隐藏状态的零张量
dec_state = torch.zeros((batch_size, num_hiddens))  # 解码器当前隐藏状态的零张量

# 计算注意力前向传播的结果,并输出形状
attention_forward(model, enc_states, dec_state).shape

运行结果:

torch.Size([4, 8])

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)
        self.out = nn.Linear(num_hiddens, vocab_size)

    def forward(self, cur_input, state, enc_states):
        """
        cur_input shape: (batch, )
        state shape: (num_layers, batch, num_hiddens)
        """
        # 使用注意力机制计算背景向量
        c = attention_forward(self.attention, enc_states, state[-1])
        # 将嵌入后的输入和背景向量在特征维连结, (批量大小, num_hiddens+embed_size)
        input_and_c = torch.cat((self.embedding(cur_input), c), dim=1) 
        # 为输入和背景向量的连结增加时间步维,时间步个数为1
        output, state = self.rnn(input_and_c.unsqueeze(0), state)
        # 移除时间步维,输出形状为(批量大小, 输出词典大小)
        output = self.out(output).squeeze(dim=0)
        return output, state

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

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)
    # 解码器在最初时间步的输入是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):
    # 定义编码器和解码器的优化器,使用 Adam 优化器,学习率为 lr
    enc_optimizer = torch.optim.Adam(encoder.parameters(), lr=lr)
    dec_optimizer = torch.optim.Adam(decoder.parameters(), lr=lr)

    # 定义损失函数为交叉熵损失,reduction='none'表示不进行降维
    loss = nn.CrossEntropyLoss(reduction='none')
    
    # 创建数据加载器,用于批量加载数据集
    data_iter = Data.DataLoader(dataset, batch_size, shuffle=True)
    
    # 迭代训练 num_epochs 次
    for epoch in range(num_epochs):
        l_sum = 0.0  # 用于累加每个 epoch 的总损失
        # 遍历数据加载器,逐批加载数据
        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

# 创建编码器和解码器实例
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)

运行结果:

epoch 10, loss 0.467
epoch 20, loss 0.142
epoch 30, loss 0.064
epoch 40, loss 0.046
epoch 50, loss 0.030

4. 预测不定长的序列

这里我们实现最简单的贪婪搜索。

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)
    
    # 将输入序列转换为对应的索引序列(tensor形式),假设输入序列为batch=1
    enc_input = torch.tensor([[in_vocab.stoi[tk] for tk in in_tokens]])  # in_vocab为输入语言的词汇表
    
    # 初始化编码器的初始状态并进行编码
    enc_state = encoder.begin_state()
    enc_output, enc_state = encoder(enc_input, enc_state)
    
    # 初始化解码器的输入为目标语言的起始符号BOS的索引
    dec_input = torch.tensor([out_vocab.stoi[BOS]])  # out_vocab为输出语言的词汇表
    
    # 使用编码器的最终状态初始化解码器的初始状态
    dec_state = decoder.begin_state(enc_state)
    
    output_tokens = []  # 用于存储输出的目标语言单词序列
    
    # 开始解码过程,最多进行max_seq_len次解码
    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. 评价翻译结果

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):
    # 使用已定义的 translate 函数生成预测结果 pred_tokens
    pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
    
    # 将标签序列 label_seq 拆分为单词列表 label_tokens
    label_tokens = label_seq.split(' ')
    
    # 计算预测结果 pred_tokens 和标签序列 label_tokens 之间的 BLEU 分数
    bleu_score = bleu(pred_tokens, label_tokens, k)
    
    # 打印 BLEU 分数和预测的目标语言序列(以字符串形式)
    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 .

2. 基于Transformer实现机器翻译(日译中)

1.导入所需的包

首先,让我们确保我们的系统中安装了以下软件包,如果你发现一些软件包丢失了,请确保安装它们。

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,请在你自己的电脑上尝试运行这一套代码

运行结果:

NVIDIA GeForce RTX 3080 Ti

 

device

运行结果:

device(type='cuda')

2.获取并行数据集

 在本教程中,我们将使用从JParaCrawl下载的日英并行数据集![http://www.kecl.ntt.co.jp/icl/lirg/jparacrawl]它被描述为“由NTT创建的最大的公开可用的英日平行语料库。它是通过大量抓取网络并自动对齐平行句子而创建的。”你也可以在这里看到这篇论文。


# 从CSV文件中读取数据到DataFrame
df = pd.read_csv('zh-ja.bicleaner05.txt', sep='\\t', engine='python', header=None)

# 提取第三列和第四列的数据,并转换为列表
trainen = df[2].values.tolist()  # 第三列数据,即英文数据
trainja = df[3].values.tolist()  # 第四列数据,即日文数据

# 如果需要,可以取消以下两行代码的注释来删除特定索引的数据项
# 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 ...

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


# 创建英文文本的SentencePieceTokenizer对象,加载预训练的模型文件'spm.en.nopretok.model'
en_tokenizer = spm.SentencePieceProcessor(model_file='spm.en.nopretok.model')

# 创建日文文本的SentencePieceTokenizer对象,加载预训练的模型文件'spm.ja.nopretok.model'
ja_tokenizer = spm.SentencePieceProcessor(model_file='spm.ja.nopretok.model')

加载完标记器后,您可以测试它们,例如,通过执行下面的代码。

# 使用英文的SentencePieceTokenizer对象(en_tokenizer)对文本进行编码
encoded_text = en_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.")

运行结果:

[227,
 2980,
 8863,
 373,
 8,
 9381,
 126,
 91,
 649,
 11,
 93,
 240,
 19228,
 11,
 419,
 14926,
 102,
 5]
# 使用日文的SentencePieceTokenizer对象(ja_tokenizer)对文本进行编码
encoded_text = ja_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.")

运行结果:

[2388,
 4,
 5539,
 1250,
 1478,
 43,
 4,
 2412,
 234,
 2022,
 2119,
 4,
 3329,
 4,
 10520,
 14651,
 5696,
 3304,
 4,
 17375,
 1803,
 1790,
 2175,
 4981,
 4,
 442,
 20322,
 1803,
 4,
 5455,
 2668,
 23468,
 4,
 19479,
 15]

3. 构建TorchText Vocab对象并将句子转换为Torch tensors

使用标记器和原始句子,我们然后构建从TorchText导入的Vocab对象。这个过程可能需要几秒或几分钟,具体取决于数据集的大小和计算能力。不同的分词器也会影响构建vocab所需的时间,我尝试了其他几种日语分词器,但SentencePiece对我来说似乎工作得很好,也足够快。


def build_vocab(sentences, tokenizer):
    # 创建一个计数器对象,用于统计词频
    counter = Counter()
    
    # 遍历每个句子并更新词频计数器
    for sentence in sentences:
        counter.update(tokenizer.encode(sentence, out_type=str))
    
    # 使用词频计数器创建词汇表对象,并指定特殊标记
    return Vocab(counter, specials=['<unk>', '<pad>', '<bos>', '<eos>'])

# 创建日语(ja)词汇表
ja_vocab = build_vocab(trainja, ja_tokenizer)

# 创建英语(en)词汇表
en_vocab = build_vocab(trainen, en_tokenizer)

有了词汇对象之后,我们就可以使用vocab和tokenizer对象来为我们的训练数据构建张量。

def data_process(ja, en):
    data = []
    for (raw_ja, raw_en) in zip(ja, en):
        # 对日语句子进行编码成 tensor
        ja_tensor_ = torch.tensor([ja_vocab[token] for token in ja_tokenizer.encode(raw_ja.rstrip("\n"), out_type=str)],
                                  dtype=torch.long)
        
        # 对英语句子进行编码成 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

# 调用 data_process 函数处理训练数据
train_data = data_process(trainja, trainen)

4. 创建要在训练期间迭代的DataLoader对象

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


BATCH_SIZE = 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:
        # 在每个句子的开头和结尾添加起始符和结束符,并转换为 tensor
        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

# 创建数据加载器,用于每次从训练数据中获取一个批次的数据
train_iter = DataLoader(train_data, batch_size=BATCH_SIZE,
                        shuffle=True, collate_fn=generate_batch)

5. 序列间变压器

接下来的几个代码和文本解释(用斜体书写)摘自PyTorch教程原文[https://py torch . org/tutorials/初学者/translation_transformer.html]。除了BATCH_SIZE和单词de _ vocab(改为ja_vocab)之外,我没有做任何更改。 Transformer是一个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__()
        
        # 初始化 Transformer 编码器和解码器层
        encoder_layer = TransformerEncoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward)
        self.transformer_encoder = TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)
        
        decoder_layer = TransformerDecoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward)
        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):
        """
        初始化位置编码层。

        Args:
        - emb_size (int): token嵌入的维度大小。
        - dropout (float): 要应用的Dropout率。
        - maxlen (int, optional): 输入序列的最大长度。默认为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)
        pos_embedding[:, 1::2] = torch.cos(pos * den)
        pos_embedding = pos_embedding.unsqueeze(-2)

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

    def forward(self, token_embedding: Tensor):
        """
        位置编码层的前向传播。

        Args:
        - token_embedding (Tensor): 形状为 (seq_len, batch_size, emb_size) 的token嵌入张量。

        Returns:
        - Tensor: 经过位置编码后的张量,形状为 (seq_len, batch_size, emb_size)。
        """
        # 将位置编码加到token嵌入上,并应用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):
        """
        初始化token嵌入层。

        Args:
        - vocab_size (int): 词汇表的大小。
        - emb_size (int): 每个token的嵌入维度。
        """
        super(TokenEmbedding, self).__init__()
        self.embedding = nn.Embedding(vocab_size, emb_size)
        self.emb_size = emb_size

    def forward(self, tokens: Tensor):
        """
        token嵌入层的前向传播。

        Args:
        - tokens (Tensor): 形状为 (seq_len, batch_size) 的长整型张量,表示token的索引。

        Returns:
        - Tensor: 嵌入张量,形状为 (seq_len, batch_size, emb_size)。
        """
        # 根据Vaswani等人(2017)的建议,将嵌入乘以嵌入维度的平方根
        return self.embedding(tokens.long()) * math.sqrt(self.emb_size)

我们创建一个后续单词掩码来阻止目标单词关注它的后续单词。我们还创建了屏蔽,用于屏蔽源和目标填充标记

def generate_square_subsequent_mask(sz):
    """
    生成一个用于自注意力机制的遮罩,对角线以下的位置为负无穷,对角线及以上位置为0。

    Args:
    - sz (int): 遮罩的大小(序列长度)。

    Returns:
    - torch.Tensor: 形状为 (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):
    """
    创建用于Transformer模型的遮罩张量。

    Args:
    - src (torch.Tensor): 形状为 (src_seq_len, batch_size) 的源张量。
    - tgt (torch.Tensor): 形状为 (tgt_seq_len, batch_size) 的目标张量。

    Returns:
    - torch.Tensor: 源遮罩,形状为 (src_seq_len, src_seq_len),布尔型。
    - torch.Tensor: 目标遮罩,形状为 (tgt_seq_len, tgt_seq_len),浮点型。
    - torch.Tensor: 源填充遮罩,形状为 (batch_size, src_seq_len),布尔型。
    - torch.Tensor: 目标填充遮罩,形状为 (batch_size, tgt_seq_len),布尔型。
    """
    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

Define model parameters and instantiate model. 这里我们服务器实在是计算能力有限,按照以下配置可以训练但是效果应该是不行的。如果想要看到训练的效果请使用你自己的带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)

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

# 定义交叉熵损失函数,忽略填充标记PAD_IDX的损失计算
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):
    """
    训练模型一个epoch,并返回平均损失。

    Args:
    - model (Seq2SeqTransformer): Transformer模型实例。
    - train_iter (DataLoader): 训练数据集的数据迭代器。
    - optimizer (torch.optim.Optimizer): 模型优化器。

    Returns:
    - float: 平均训练损失。
    """
    model.train()
    losses = 0
    for idx, (src, tgt) in enumerate(train_iter):
        src = src.to(device)
        tgt = tgt.to(device)

        tgt_input = tgt[:-1, :]

        # 创建遮罩
        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:,:]
        # 计算损失
        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):
    """
    在验证集上评估模型,并返回平均验证损失。

    Args:
    - model (Seq2SeqTransformer): Transformer模型实例。
    - val_iter (DataLoader): 验证数据集的数据迭代器。

    Returns:
    - float: 平均验证损失。
    """
    model.eval()
    losses = 0
    for idx, (src, tgt) in enumerate(val_iter):
        src = src.to(device)
        tgt = tgt.to(device)

        tgt_input = tgt[:-1, :]

        # 创建遮罩
        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:,:]
        # 计算损失
        loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
        losses += loss.item()
    return losses / len(val_iter)

6. 开始训练

最后,在准备好必要的类和函数之后,我们准备好训练我们的模型。这是不言而喻的,但完成训练所需的时间可能会因计算能力、参数和数据集大小等诸多因素而有很大差异。 当我使用JParaCrawl的完整句子列表(每种语言大约有590万个句子)训练模型时,使用单个NVIDIA GeForce RTX 3070 GPU每个时期花费了大约5个小时。

代码如下:

# 使用 tqdm 包装循环,显示训练进度条
for epoch in tqdm.tqdm(range(1, NUM_EPOCHS+1)):
    start_time = time.time()
    
    # 训练一个epoch,并获取训练损失
    train_loss = train_epoch(transformer, train_iter, optimizer)
    
    end_time = time.time()
    
    # 打印当前epoch的训练损失和训练时间
    print((f"Epoch: {epoch}, Train loss: {train_loss:.3f}, "
          f"Epoch time = {(end_time - start_time):.3f}s"))
 运行结果:

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

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

def greedy_decode(model, src, src_mask, max_len, start_symbol):
    """
    使用贪婪解码生成目标序列。

    Args:
    - model: Transformer模型
    - src: 源序列张量
    - src_mask: 源序列的掩码张量
    - max_len: 生成序列的最大长度
    - start_symbol: 开始符号索引

    Returns:
    - ys: 生成的目标序列张量
    """
    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):
    """
    翻译函数,将源文本翻译为目标文本。

    Args:
    - model: Transformer模型
    - src: 源文本字符串
    - src_vocab: 源语言词汇表
    - tgt_vocab: 目标语言词汇表
    - src_tokenizer: 源文本分词器

    Returns:
    - translated_text: 翻译后的目标文本字符串
    """
    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()

    # 将生成的目标序列索引转换为文本并返回
    translated_text = " ".join([tgt_vocab.itos[tok] for tok in tgt_tokens]).replace("<bos>", "").replace("<eos>", "")

    return translated_text

然后,我们可以调用translate函数并传递所需的参数。

translate(transformer, "HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)", ja_vocab, en_vocab, ja_tokenizer)

运行结果:

' ▁H S 还 是 焊 接 用 于 焊 接 、 电 气 ( 包 括 电 气 加 热 气 ) 。 
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 ...'

8. 保存Vocab对象和训练好的模型

最后,在训练结束后,我们将首先使用Pickle保存Vocab对象(en_vocab和ja_vocab)。

import pickle

# 打开一个文件,用于存储数据
file = open('en_vocab.pkl', 'wb')

# 将英语词汇表(en_vocab)的信息存储到文件中
pickle.dump(en_vocab, file)

file.close()

# 打开另一个文件,用于存储数据
file = open('ja_vocab.pkl', 'wb')

# 将日语词汇表(ja_vocab)的信息存储到文件中
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')

  • 9
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值