【NLP】基于Transformer实现机器翻译(日译中)

一、内容简介


实现一个简单的基于Transformer机构的日译中翻译模型。

二、Transformer架构


Transformer是一种基于自注意力机制的神经网络架构,由编码器和解码器两部分组成,广泛应用于自然语言处理任务。其核心思想是通过自注意力机制(Self-Attention)计算输入序列中每个元素对其他元素的相关性,从而捕捉长距离依赖关系。为了保留序列中的位置信息,Transformer引入了位置编码(Positional Encoding)。此外,多头注意力机制(Multi-Head Attention)通过并行计算多个注意力头,使模型能够关注不同的子空间,提高了模型的表达能力和效果。


2.1 自注意力机制(self-attention)


首先,通过一个例子从宏观角度理解自注意力机制的工作原理与重要意义。
假设要将“His hair was so long that he was going to the barbershop in the afternoon to cut it off.”翻译为中文。作为人类,我们一眼就可以看出句子尾端的it指的是his hair。但是在计算机的视角里,它只是一串二进制代码,计算机很难知道二者之间的联系。再者,对于一词多义现象,我们很容易地可以根据上下文信息准确地判断该词到底是什么意思,但是这对于计算机来说,同样是极具挑战性的任务。

2.1.1 自注意力机制的工作原理


那么自注意力机制该如何处理这些问题呢?简单来说,就是通过计算该词与句子中其他词语的相似度,并根据相似度赋予对应的权重。相似度越高则权重越高,也就意味着词语之间相关度更高。然后再将这些词语的向量表示进行加权求和,以此将句子中所有词的信息按照相关程度揉合为该词的向量表示。
计算自注意力有两种方式:一种通过向量,一种通过矩阵。二者原理相同,通过矩阵运算只是将通过向量计算的所有向量组成矩阵,使用矩阵运算方法进行计算而已。

2.1.2自注意力机制


查询向量Query是当前单词的表示形式,用于对所有其他单词(key)进行评分。
键向量Key可以看作是序列中所有单词的标签,是我们在找相关单词时候的对照物。
值向量Value是单词的实际表示,一旦我们对每个单词的相关度打分之后,我们就要对Value进行相加表示当前正在处理的单词的value。


 
2.2 多头注意力机制(multi-head attention)


在自注意力机制的基础上,Transformer引入了多头注意力机制(Multi-Head Attention)。多头注意力机制将查询、键和值向量分成多个头(head),每个头独立计算注意力,然后将结果拼接起来,再通过线性变换得到最终的输出。以探索不同的子空间,捕捉序列中更丰富的特征信息。
 

2.3 位置编码(positional encoding)


由上述介绍不难看出,在计算自注意力得分时,输入序列中每个位置的单词都各自单独的路径流入编码器,即各个单词同时流入编码器中,不是排队进入。而这严重影响着句子的意思。比如,“小明喜欢我”和“我喜欢小明”,这两个句子只是,词序不同,却表达着截然相反的意思。此时再结合RNN的结构不难发现Transformer完全把时序信息给丢掉了。
为了解决时序的问题,Transformer的作者用了一个绝妙的办法:位置编码(Positional Encoding)。
即给每个位置编号,从而每个编号对应一个向量,最终通过结合位置向量和词向量,作为输入embedding,就给每个词都引入了一定的位置信息,这样Attention就可以分辨出不同位置的词了。

三、、基于Transformer实现机器翻译(日译中)

3.1 准备

日中机器翻译模型使用Transformer和PyTorch的教程 使用Jupyter Notebook,PyTorch,Torchtext和SentencePiece

首先,确保我们的系统中安装了以下包,如果发现有缺失的包,请确保安装它们。

spm.en.nopretok.model

spm.ja.nopretok.model

zh-ja.bicleaner05.txt

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)) 

1.获取平行数据集
在本教程中,我们将使用从JParaCrawl下载的日语-英语平行数据集!

http://www.kecl.ntt.co.jp/icl/lirg/jparacrawl] [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列表中移除索引为5972的元素
# 从trainja列表中移除索引为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 ...
我们还可以使用不同的并行数据集来遵循本文,只需确保我们可以将数据处理成两个字符串列表,如上所示,包含日语和英语句子。

2.准备分词器

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

en_tokenizer = spm.SentencePieceProcessor(model_file='spm.en.nopretok.model')
ja_tokenizer = spm.SentencePieceProcessor(model_file='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')
运行结果:

['▁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',
 '歳の',
 '全ての',
 '人は',
 '、',
 '公的',
 '年',
 '金',
 '制度',
 'に',
 '加入',
 'しなければなりません',
 '。']
3.构建 TorchText Vocab 对象并将句子转换为 Torch 张量
使用分词器和原始句子,我们构建从 TorchText 导入的 Vocab 对象。此过程可能需要几秒钟或几分钟,具体取决于我们的数据集大小和计算能力。不同的分词器也会影响构建词汇所需的时间,尝试了其他几种日语分词器,但 SentencePiece 似乎运行良好且速度足够快。

def build_vocab(sentences, tokenizer):
  # 创建一个空的计数器对象
  counter = Counter()
  
  # 遍历每个句子
  for sentence in sentences:
    # 使用给定的tokenizer对句子进行编码,并以字符串形式输出,然后更新计数器
    counter.update(tokenizer.encode(sentence, out_type=str))
    
  # 使用计数器构建一个词汇表(Vocab),并指定特殊标记
  return Vocab(counter, specials=['<unk>', '<pad>', '<bos>', '<eos>'])
 
# 使用 build_vocab 函数构建日语(ja)和英语(en)的词汇表
ja_vocab = build_vocab(trainja, ja_tokenizer)  # 构建日语词汇表
en_vocab = build_vocab(trainen, en_tokenizer)  # 构建英语词汇表
在有了词汇表对象之后,我们可以使用词汇表和分词器对象来构建训练数据的张量。

def data_process(ja, en):
  data = []
 
  # 遍历日语(ja)和英语(en)数据的并行列表
  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
 
# 使用 data_process 函数处理训练数据 trainja 和 trainen
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:
    # 在日语句子的开头和结尾添加 '<bos>' 和 '<eos>' 标记,并使用 torch.tensor 创建张量
    ja_batch.append(torch.cat([torch.tensor([BOS_IDX]), ja_item, torch.tensor([EOS_IDX])], dim=0))
    # 在英语句子的开头和结尾添加 '<bos>' 和 '<eos>' 标记,并使用 torch.tensor 创建张量
    en_batch.append(torch.cat([torch.tensor([BOS_IDX]), en_item, torch.tensor([EOS_IDX])], dim=0))
  
  # 使用 pad_sequence 函数对日语批次和英语批次进行填充,使它们具有相同的长度
  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 对象,用于批量处理训练数据
train_iter = DataLoader(train_data, batch_size=BATCH_SIZE,
                        shuffle=True, collate_fn=generate_batch)
5.序列到序列转换器

接下来的几个代码和文本说明(用斜体写)取自原始的 PyTorch 教程 [https://pytorch.org/tutorials/beginner/translation_transformer.html]。除了BATCH_SIZE之外,我没有做任何更改,de_vocabwhich 这个词被改成了ja_vocab。

Transformer 是 “Attention is all you need” 论文中介绍的 Seq2Seq 模型,用于解决机器翻译任务。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)
        
        # 源语言和目标语言的词嵌入层
        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)
文本标记通过使用标记嵌入来表示。位置编码被添加到标记嵌入中,以引入词序的概念。

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

def generate_square_subsequent_mask(sz):
    # 创建一个大小为(sz, sz)的全1张量,并且上三角部分为1,下三角部分为0
    mask = (torch.triu(torch.ones((sz, sz), device=device)) == 1).transpose(0, 1)
    # 将mask张量转换为float型,并且将值为0的位置用负无穷大来填充
    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]
 
    # 生成目标序列的mask
    tgt_mask = generate_square_subsequent_mask(tgt_seq_len)
    
    # 创建一个全零张量作为源序列的mask,数据类型为bool型
    src_mask = torch.zeros((src_seq_len, src_seq_len), device=device).type(torch.bool)
 
    # 创建源序列和目标序列的padding mask,将PAD_IDX(填充索引)的位置标记为True
    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
transformer = Seq2SeqTransformer(NUM_ENCODER_LAYERS, NUM_DECODER_LAYERS,
                                 EMB_SIZE, SRC_VOCAB_SIZE, TGT_VOCAB_SIZE,
                                 FFN_HID_DIM)
 
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):
  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):
  model.eval()
  losses = 0
  for idx, (src, tgt) in (enumerate(valid_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)


3.2 开始训练

最后,在准备了必要的类和函数之后,准备训练我们的模型。但完成训练所需的时间可能会有很大差异,具体取决于很多因素,例如计算能力、参数和数据集的大小。

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

代码如下:

for epoch in tqdm.tqdm(range(1, NUM_EPOCHS+1)):
    start_time = time.time()  # 记录当前轮次开始时间
    train_loss = train_epoch(transformer, train_iter, optimizer)  # 训练模型一轮次并计算损失
    end_time = time.time()  # 记录当前轮次结束时间
    # 打印当前轮次的信息,包括轮次数、训练损失和每轮次的训练时间
    print(f"Epoch: {epoch}, Train loss: {train_loss:.3f}, Epoch time = {(end_time - start_time):.3f}s")

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值