NLP实验——基于Transformer&pytorch的日汉机器翻译模型

一、实验原理

1.1 背景简介

Attention Is All You Need

"Attention Is All You Need" 是一篇由Vaswani等人在2017年发表在NIPS会议上的论文,介绍了Transformer模型的核心思想和架构。这篇论文对于解决传统序列模型中存在的长距离依赖问题提出了一种全新的方法,即自注意力机制(Self-Attention),并以此构建了Transformer模型。

Transformer

Transformer是由Vaswani等人在2017年提出的一种创新的神经网络架构,用于处理序列到序列(seq2seq)任务,如机器翻译和自然语言生成。在此之前,循环神经网络(RNNs)和长短时记忆网络(LSTMs)等传统神经网络结构在处理长距离依赖和序列信息时存在一定的局限性,因此Transformer的提出标志着一种全新的序列建模方式的出现。

1.2 Transformer

1.2.1 基础结构

Transformer模型主要是基于编码器—解码器(Encoder-Decoder)结构,在此基础上完全放弃循环和卷积,只基于attention自注意力机制。一个简单的黑盒模型如下图所示,将Transformer看成一个函数。在机器翻译任务中,将一种语言的一个句子作为输入,然后将其翻译成另一种语言的一个句子作为输出。

打开黑盒子:编码组件由多层编码器(Encoder)组成(在上文提到的论文中作者使用了 6 层编码器);解码组件也是由相同层数的解码器(Decoder)组成(在论文也使用了 6 层)。

编码器(Encoder)由两个子层组成:自注意力(Self-Attention )层+前馈网络层(Position-wise Feed Forward Network, FFN)。每个编码器结构都是相同的,但是它们使用不同的权重参数。

解码器(Decoder)由三个子层组成:自注意力层+编解注意力层( Encoder-Decoder Attention)+前馈网络层,如下图所示。注意力层用来帮忙解码器关注输入句子的相关部分,即捕获Encoder部分的信息(类似于 seq2seq 模型中的注意力)。

1.2.2 主要模块解析

参考论文"Attention Is All You Need"中作者所设计的Transfomer总体架构如下图所示,使用堆叠的self-attention层、point-wise和全连接层,分别用于encoder和decoder,如图的左半部分和右半部分所示。

这里,encoder将符号表示的输入序列\left ( x1,x2,...xn \right )映射成一个连续表示的序列z=z(z1,z2,...zn)。给定z ,解码器以一次生成一个字符的方式生成输出序列(y1,y2,ym) 。

1. Attention机制及其改进

Attention机制可以描述为将一个query和一组key-value对映射到一个输出,其中query,keys,values和输出均是向量。输出是values的加权求和,其中每个value的权重通过query与相应key的兼容函数来计算。

自注意力机制:会关注整个序列的所有单词,帮助模型对单词更好的编码。

Attention(Q,K,V)=softmax(\frac{Q*K^{t}}{\sqrt{d^{_{k}}}})V

计算过程:第一步,对编码器每个输入词嵌入向量都计算三个向量,即查询值(query)、键值(key)和value向量;第二步,计算注意力得分即单词的query与其它词key的点乘积;第三步除以key向量维度的平方根;第四步,进行softmax归一化计算,注意力分数相加和为1,且都为正数;第五步,将每个value向量乘以注意力分数;最后,将上一步结果相加,输出本位置的注意结果。

计算得到的向量直接传递给FNN,但实际计算为了快速直接使用矩阵计算。

多头注意力机制(Multi-head attention自注意力的一个改进,Multi-Head Attention就是在self-attention的基础上,对于输入的embedding矩阵使用多组的queries,keys和values,在不用的映射版本下,并行的执行attention函数,生成dv 维输出值。它们被拼接起来再次映射,生成一个最终值。

MultiHead(Q,K,V)=Concat(head_{1},...head_{h})W^{O}

 head^{i}=Attention(QW_{i}^{Q},KW_{i}^{K},VW_{i}^{V})

从自注意力机制到多头注意力机制:

2. Positional Encoding(位置编码)

因为 Transformer 不采用 RNN 的结构,而是使用全局信息,不能利用单词的顺序信息,而这部分信息对于 NLP 来说非常重要。所以 Transformer为了处理序列的顺序信息, 使用位置 Embedding 保存单词在序列中的相对或绝对位置,以便模型学习序列的顺序和位置信息。

实现:将位置信息编码加入到输入的嵌入向量中,二者维度一致可以相加,输出是带有位置信息的embedding。

3. Mask Self-Attention(掩码自注意力)

传统编解器-解码器:训练阶段使用了上下文的信息,而在测试阶段只使用了上文信息,两个阶段的decoder输入形式不一致。

解决:训练时模拟测试阶段,使用掩码将下文信息屏蔽掉,使得当前单词信息只与上文单词有关。

4. Add&Norm

Add:残差连接(Residual Connection),通常用于解决多层网络训练的问题,可以让网络只关注当前差异的部分,可以解决梯度弥散和梯度爆炸的问题;

Norm:归一化(Normalization),在每个子层的输出上,都会进行残差连接,然后在做层归一化(Layer-Norm)。

1.2.3实际应用

Transformer模型通过引入自注意力机制和逐层堆叠的编码器-解码器架构,成功地解决了处理长距离依赖的问题,并且能够有效地并行处理,大大提高了训练速度。这种结构使得Transformer在机器翻译、文本生成、语言建模等自然语言处理任务中表现出色,同时也在其他领域如计算机视觉中得到了一定的应用。

  • 机器翻译:Transformer在机器翻译任务中表现出色,例如Google的翻译服务就采用了Transformer模型,能够有效地处理长距离依赖和上下文信息。

  • 文本生成:包括对话系统、摘要生成、文本生成等任务,Transformer通过自注意力机制捕捉到文本中的长距离依赖关系,生成更加连贯和语义合理的文本。

  • 语言建模:Transformer模型也广泛应用于语言建模任务,如预训练的语言模型(如BERT、GPT系列),能够为下游任务提供优秀的语言表示。

  • 图像处理:Transformer不仅限于处理文本数据,还被应用于计算机视觉领域,如图像生成和图像分类任务中,取得了一定的成效。

二、实验过程

2.1 GPU环境搭建

2.1.1 AutoDL算力云平台

1.简介

AutoDL是一个由深度智慧团队开发的自动机器学习(AutoML)框架,特别专注于自动化的深度学习模型设计和优化。它的目标是让开发者、研究者甚至是不具备专业深度学习知识的用户都能够轻松地构建高性能的神经网络模型。AutoDL的核心在于其智能搜索算法和高效的模型并行训练策略,包括模型架构搜索(NAS)、超参数优化、并行训练等关键技术点。这些技术使得AutoDL能够在多GPU或多节点环境下快速训练大规模模型,大大缩短了实验周期。

2.平台使用

AutoDL算力云网页链接

https://www.autodl.com/home

首次使用时需要进行账户注册,注册完成后即可进入算力市场租用所需的GPU。

我们先了解学习AutoDL算力云网站上提供的帮助文档。帮助文档中有快速开始的新手教程,

也有官网录制的B站教学视频。一般遇到的平台使用问题几乎都可以在帮助文档中找到解答。

3.容器实例使用(连接pycharm)

本次实验选择租用 GPURTX 4090D(24GB) * 1,下面是其详细信息。其中可以根据实验具体需要进行GPU基础镜像的升降配置等。以下是进行本次实验我连接Pycharm的过程。

打开控制台找到容器实例,将所租用的容器实例开机后,在SHH登录一列中会出现登录指令和密码,复制SSH登录指令(快捷工具栏中也出现了内容,实验过程中可以借助JupyterLab快速开始代码的训练)。

打开Pycharm在主界面左下角新建解释器,或者打开左上角文件选择设置添加新解释器;选择SSH解释器,将登录指令中包含的信息填写到对应的位置。

当我创建并成功连接到SSH服务器后,会有小段等待时间,这是因为需要更新解释器、将主机包含的代码文件上传到远端主机中等等。在进行SSH服务器连接过程中,我们可以使用工具栏中的部署进行一些简易操作,如本机中上传文件、远端中下载文件、同步文件、浏览远程主机等等。

如果将需要运行的数据文件、代码文件成功上传到远程主机中,就可进行调试运行。

2.1.2本次实验一些准备

1.实验导入相关库

本次实验需要导入相关库如下所示。

import math  # 导入数学库,用于数学运算
import torchtext  # 导入torchtext,用于处理文本数据
import torch  # 导入PyTorch深度学习框架
import torch.nn as nn  # 导入PyTorch的神经网络模块
from torch import Tensor  # 导入PyTorch的张量数据结构
from torch.nn.utils.rnn import pad_sequence  # 导入PyTorch的序列填充工具,用于处理变长序列
from torch.utils.data import DataLoader  # 导入PyTorch的数据加载工具,用于批量加载数据
from collections import Counter  # 导入Python标准库中的Counter,用于计数
from torchtext.vocab import Vocab  # 导入torchtext中的词汇表工具,用于构建词汇表
from torch.nn import TransformerEncoder, TransformerDecoder, TransformerEncoderLayer, TransformerDecoderLayer  # 导入Transformer模型中的编码器和解码器相关类
import io  # 导入Python标准库中的io模块,用于处理输入输出
import time  # 导入Python标准库中的time模块,用于计时
import pandas as pd  # 导入Pandas库,用于数据处理
import numpy as np  # 导入NumPy库,用于数值计算
import pickle  # 导入Python标准库中的pickle模块,用于序列化和反序列化对象
import tqdm  # 导入tqdm模块,用于显示进度条
import sentencepiece as spm  # 导入SentencePiece库,用于分词和生成词汇表

torch.__version__ # 如果需要 使用该语句查看pytorch版本

torch.manual_seed(0) # 设置随机种子,保证结果的可重复性
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 检测是否有GPU,如果有则使用GPU,否则使用CPU
print(torch.cuda.get_device_name(0)) # 如果你有GPU,请在你自己的电脑上尝试运行这一套代码
device # 设备

可以看到我所租用的GPU型号如下:

如果在运行调试中遇到未下载的库,可以在pycharm终端下载需要的库,也可以选择在JupyterLab的终端下载(可能需要在pycharm中更新一下解释器)。终端下载库时,选择使用镜像网站下载库的速度会有所提升。

pip list # 可以查看已经下载过的库及其对应版本
pip install torch==版本号 # 可以将torch改为你所需要下载的库
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple torch==版本号 #使用镜像网站,可以指定库的版本,需要注意兼容的问题
2.实验所需库的版本

在实验过程中,当代码真正运行我出现了以下错误。

第一次报错“RuntimeError: unknown out_type=str”,提示我分词器无法识别属性out_type=int。

于是我尝试使用以下代码来让编码结果以字符串形式输出。

en_text = "All residents aged 20 to 59 years who live in Japan must enroll in public pension system."
ja_text = "年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。"

en_encoded = en_tokenizer.encode(en_text)
en_encoded_str = ' '.join([en_tokenizer.id_to_piece(piece_id) for piece_id in en_encoded])

ja_encoded = ja_tokenizer.encode(ja_text)
ja_encoded_str = ' '.join([ja_tokenizer.id_to_piece(piece_id) for piece_id in ja_encoded])

但是紧接着遇到第二个错误,“TypeError: init() got an unexpected keyword argument 'specials'”的报错,提示我Vocab没有special参数。

我试着寻找原因,发现这可能是SSH解释器中所下载库版本的原因。我查看解释器中包含库的版本,有些库版本很高,可能在版本更新的过程中舍弃了某些功能。我尝试将torchtext库版本改为torchtext==0.6.0,sentencepiece库版本改为sentencepiece==0.1.96,库版本的问题就迎刃而解啦。

2.2 机器翻译模型

2.2.1 获取并行数据集

本实验的数据集选取自JParaCrawl![http://www.kecl.ntt.co.jp/icl/lirg/jparacrawl] ,最号称为“NTT创建的最大的公开的英语-日语平行语料库。它是通过大量抓取网络和自动对齐平行句子创建的。”

下面时读取数据集并存储为列表的代码。

# 读取数据集,使用pandas读取以'\t'分隔的文本文件,没有表头
df = pd.read_csv('./zh-ja/zh-ja.bicleaner05.txt', sep='\\t', engine='python', header=None)

# 将英文和日文数据分别存储为列表
trainen = df[2].values.tolist()#[:10000]
trainja = df[3].values.tolist()#[:10000]
# print(trainen.pop(5972))
# print(trainja.pop(5972))

print(trainen[500]) # 打印出列表trainen中索引为500的英文句子
print(trainja[500]) # 打印出列表trainja中索引为500的日文句子

打印查看数据集中索引为500的语句,结果如下所示。

准备分词器,可使用SentencePiece库加载英文和日文的SentencePiece模型,其中model_file指定模型文件的路径。

# 使用SentencePiece加载英文和日文的SentencePiece模型,model_file指定模型文件的路径
en_tokenizer = spm.SentencePieceProcessor(model_file='enja_spm_models/spm.en.nopretok.model')
ja_tokenizer = spm.SentencePieceProcessor(model_file='enja_spm_models/spm.ja.nopretok.model')

# 使用英文的SentencePiece模型对一段英文文本进行编码,将结果以字符串形式输出
en_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.", out_type='str')

# 使用日文的SentencePiece模型对一段日文文本进行编码,将结果以字符串形式输出
ja_tokenizer.encode("年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。", out_type='str')

对文本进行编码,结果以字符串形式输出。

2.2.2 数据集处理

1.构建TorchText Vocabde 对象并将句子转换为Torch张量

在这里我们使用了两个函数:

build_vocab(sentences, tokenizer)函数:构建词汇表,输入语句和标分词器,使用了从torch text导入的Vocab对象,返回词汇表对象。构建词汇表的速度既会受到数据集大小和计算能力的影响,也会受到上文选择的分词器的影响(选择了SentencePiece效果较好)。

data_process(ja, en)函数:将句子转换为torch张量,输入需要转换的训练数据,使用torch.tensor转换为张量并返回。

# 词汇表创建函数
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_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):
    # 将日语句子编码为词汇表中的索引,并转换为PyTorch张量
        ja_tensor_ = torch.tensor([ja_vocab[token] for token in ja_tokenizer.encode(raw_ja.rstrip("\n"), out_type=str)],
                            dtype=torch.long)
     # 将英语句子编码为词汇表中的索引,并转换为PyTorch张量
        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)

我们可以查看一下所生成的词汇表的一部分。

print("Japanese Vocabulary Length:", len(ja_vocab))
print("Sample Japanese Vocabulary:")
print(list(ja_vocab.stoi.items())[:20])  # 打印词汇表前20 vocab (word, index)

print("\nEnglish Vocabulary Length:", len(en_vocab))
print("Sample English Vocabulary:")
print(list(en_vocab.stoi.items())[:20])  # 打印词汇表前20 vocab (word, index)

结果以(word,index)的形式输出。

2.创建DataLoader对象

参数BATCH_SIZE的设置是灵活的,本次实验在训练时将其设置为了8。

但是在运行过程中出现了这样的错误:“torch.cuda.OutOfMemoryError: CUDA out of memory.”

我尝试将这里的批量大小设置大一点比如16,让我的内存轻松一点。

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 # 返回填充后的日语和英语批次

 # 创建数据加载器,用于生成批次数据,同时进行随机打乱和填充处理
train_iter = DataLoader(train_data, batch_size=BATCH_SIZE,
                        shuffle=True, collate_fn=generate_batch)

2.2.3 Seq-Seq Transformer

1. 构造Sequence-to-sequence Transformer的PyTorch模型类

来到了本实验最核心的部分。定义了一个Sequence-to-sequence Transformer的PyTorch模型类,包括编码器方法、解码器方法、前向传播方法等。

#导入所需的PyTorch模块:TransformerEncoder、TransformerDecoder、TransformerEncoderLayer和TransformerDecoderLayer
from torch.nn import (TransformerEncoder, TransformerDecoder,
                      TransformerEncoderLayer, TransformerDecoderLayer)

#定义名为Seq2SeqTransformer的PyTorch模型类
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):
        '''
        num_encoder_layers:编码器(Encoder)中Transformer层的数量。
        num_decoder_layers:解码器(Decoder)中Transformer层的数量。
        emb_size:词嵌入的维度大小。
        src_vocab_size:源语言词汇表的大小。
        tgt_vocab_size:目标语言词汇表的大小。
        dim_feedforward:Transformer中全连接层的隐藏层维度,默认为512。
        dropout:Dropout层的丢弃概率,默认为0.1。
        '''
        super(Seq2SeqTransformer, self).__init__()
        #创建Transformer编码器层(TransformerEncoderLayer),d_model为词嵌入的维度,nhead为NHEAD,dim_feedforward设置为512
        encoder_layer = TransformerEncoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward)
        #多个编码器层(num_encoder_layers个)构建Transformer编码器(TransformerEncoder)
        self.transformer_encoder = TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)
        
        #创建Transformer解码器层(TransformerDecoderLayer)
        decoder_layer = TransformerDecoderLayer(d_model=emb_size, nhead=NHEAD,
                                                dim_feedforward=dim_feedforward)
        self.transformer_decoder = TransformerDecoder(decoder_layer, num_layers=num_decoder_layers)
        
        #定义一个线性层(nn.Linear),用于生成目标语言的词汇表大小的输出
        self.generator = nn.Linear(emb_size, tgt_vocab_size)
        #初始化源语言和目标语言的Token嵌入(TokenEmbedding)
        self.src_tok_emb = TokenEmbedding(src_vocab_size, emb_size)
        self.tgt_tok_emb = TokenEmbedding(tgt_vocab_size, emb_size)
        #初始化位置编码(PositionalEncoding),用于为输入的词嵌入添加位置信息
        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:源语言的输入张量。
        trg:目标语言的输入张量。
        src_mask:用于源语言输入的mask张量。
        tgt_mask:用于目标语言输入的mask张量。
        src_padding_mask:用于源语言输入的padding mask张量。
        tgt_padding_mask:用于目标语言输入的padding mask张量。
        memory_key_padding_mask:用于记忆(encoder-decoder attention)中的padding mask张量。
        '''
        #对输入的源语言和目标语言分别进行词嵌入(TokenEmbedding)和位置编码(PositionalEncoding)处理
        src_emb = self.positional_encoding(self.src_tok_emb(src))
        tgt_emb = self.positional_encoding(self.tgt_tok_emb(trg))
        #使用Transformer编码器处理源语言的嵌入向量,生成记忆
        memory = self.transformer_encoder(src_emb, src_mask, src_padding_mask)
        #使用Transformer解码器处理目标语言的嵌入向量和记忆,生成解码器的输出
        outs = self.transformer_decoder(tgt_emb, memory, tgt_mask, None,
                                        tgt_padding_mask, memory_key_padding_mask)
        #通过线性层(self.generator)将解码器的输出转换为最终的目标语言词汇表大小的预测结果,并返回
        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)
2.位置编码

(1)PositionalEncoding 类实现了Transformer模型中的位置编码。

__init__ 方法中,通过参数 emb_size 定义了位置编码的维度大小。使用了正弦和余弦函数来生成位置编码,以便编码输入序列中每个位置的信息。

forward 方法接收一个 token_embedding 张量,即输入序列的词嵌入向量。将词嵌入向量与位置编码相加,并应用dropout操作,然后返回结果。这样,每个词嵌入向量都包含了其对应位置的信息。

(2)TokenEmbedding 类负责将输入的词索引(tokens)转换为词嵌入向量。

__init__ 方法中,通过 nn.Embedding 创建了一个嵌入层,将词汇表大小为 vocab_size 的词索引映射到 emb_size 维度的词嵌入空间。

forward 方法接收一个张量 tokens,其中包含了输入序列的词索引。通过 self.embedding(tokens.long()) 将词索引转换为词嵌入向量,并乘以 math.sqrt(self.emb_size) 进行缩放。这是因为在原始Transformer中,词嵌入向量会乘以一个缩放因子,通常是词嵌入维度的平方根,以改善训练效果。

# 实现了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)
        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):
        return self.dropout(token_embedding +
                            self.pos_embedding[:token_embedding.size(0),:])

#将输入的词索引(tokens)转换为词嵌入向量
class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size: int, emb_size):
        super(TokenEmbedding, self).__init__()
        self.embedding = nn.Embedding(vocab_size, emb_size) # 通过 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)
 
3.掩码函数

在这里我们使用了两个函数:

generate_square_subsequent_mask(sz):这个函数用于生成一个下三角形状的掩码矩阵,主要用于自注意力机制中的解码器部分,以防止信息在解码过程中窥探未来的信息。

输入:sz(整数),表示要生成的掩码矩阵的大小;

输出:mask, 形状为 (sz, sz) 的下三角形掩码矩阵。

create_mask(src, tgt):这个函数用于生成序列模型中的掩码,主要包括两部分:一个是目标序列的掩码 (tgt_mask),另一个是源序列的填充掩码 (src_padding_mask) 和 目标序列的填充掩码 (tgt_padding_mask)。这些掩码用于在序列模型中屏蔽填充位置的信息,以便模型在处理序列时不会受到填充值的影响。

输入:src ,源序列;tgt,目标序列。

输出:src_mask,表示源序列的填充位置;tgt_mask,用于表示目标序列的下三角形状的掩码;

src_padding_mask,表示源序列中的填充位置;tgt_padding_mask,表示目标序列中的填充位置。

def generate_square_subsequent_mask(sz):
    # 创建一个下三角形的掩码矩阵,形状为 (sz, sz),设备为指定的 device
    mask = (torch.triu(torch.ones((sz, sz), device=device)) == 1).transpose(0, 1)
    # 将掩码矩阵转换为浮点型(float),并根据掩码的值进行填充
    mask = mask.float().masked_fill(mask == 0, float('-inf'))
    mask = mask.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)
    # 创建一个与源序列长度相同的零矩阵,并将其类型转换为布尔型(bool)
    src_mask = torch.zeros((src_seq_len, src_seq_len), device=device).type(torch.bool)

    # 生成源序列的填充掩码(PAD_IDX 是填充值的索引,将与 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

2.2.4模型训练

设置模型的相关参数,在GPU上运行选择参数如下。本次实验在训练时选择使用交叉熵损失函数、Adam优化器,同时定义了训练函数和评估函数。

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)
        
# 将模型移动到指定的设备
transformer = transformer.to(device)

# 定义损失函数为交叉熵损失,忽略填充标记的损失计算
loss_fn = torch.nn.CrossEntropyLoss(ignore_index=PAD_IDX)

# 使用Adam优化器,设置学习率、动量参数和epsilon参数
optimizer = torch.optim.Adam(
    transformer.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9
)

# 定义训练一个epoch的函数
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
        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(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)

模型开始训练时,我们使用tqdm库创建一个进度条,迭代训练的epoch次数(从1到NUM_EPOCHS),这样更加直观清晰。

for epoch in tqdm.tqdm(range(1, NUM_EPOCHS+1)):
    start_time = time.time() # 记录当前epoch的开始时间
    train_loss = train_epoch(transformer, train_iter, optimizer)#调用train_epoch函数进行一个epoch的训练,并返回该epoch的训练损失
    end_time = time.time() # 记录当前epoch的结束时间
    #打印当前epoch的序号和训练损失和当前epoch的训练时间,保留三位小数
    print((f"Epoch: {epoch}, Train loss: {train_loss:.3f}, "
          f"Epoch time = {(end_time - start_time):.3f}s"))

经过long long 的漫长等待,终于训练结束了。让我们来看看训练效果如何?

经过过16轮迭代训练的损失从4.574稳定下降至2.807,每轮训练的时间花费也在四分钟以内,不至于训练时间长到看不到“希望”。

此时我觉得训练过程还算可以,但是在后面使用这个训练好的模型进行机器翻译时,翻译结果却不尽如人意。

# 这是待翻译的语句
"HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)"

这是使用刚刚训练的模型所翻译的语句,看起来两个句子的意思丝毫不像呐。

难道这是最终结果了么?不是。

有了之前因为库版本问题而报错的经验,这次我尝试将GPU基础镜像的pytorch版本从一开始选择的2.3.1降低至2.1.2,再重新进行训练,虽然发现每轮epoch变长了些,但是迭代训练的损失从4.471稳定降低至1.758,有了很大的改善。那么使用这个模型所翻译的效果如何呢?我们可以期待一下。

2.3翻译模型使用

2.3.1 “训练千日,翻译一时”

这里使用两个函数进行机器翻译。两个函数结合起来实现了将源文本翻译为目标文本的功能,使用了Transformer模型和贪婪解码策略。

def greedy_decode(model, src, src_mask, max_len, start_symbol):
    '''
    输入:
    model: Transformer模型,包括编码器和解码器。
    src: 源序列张量,形状为 (seq_len, 1),表示经过编码器编码的源文本。
    src_mask: 源序列的掩码张量,用于屏蔽无效的输入位置。
    max_len: 解码的最大长度。
    start_symbol: 解码序列的起始符号索引。
    输出:
    ys: 解码后的目标序列张量,形状为 (tgt_len,),其中 tgt_len 是实际解码后序列的长度。
    '''
    src = src.to(device)
    src_mask = src_mask.to(device)
    memory = model.encode(src, src_mask) # 使用Transformer模型的encode方法对源序列进行编码,生成memory表示
    # 初始化目标序列ys,起始符号为start_symbol,并将其移到指定设备上
    ys = torch.ones(1, 1).fill_(start_symbol).type(torch.long).to(device)
    # 循环生成目标序列
    for i in range(max_len-1):
        # 初始化用于遮掩(mask)memory的mask张量
        memory = memory.to(device)
        memory_mask = torch.zeros(ys.shape[0], memory.shape[0]).to(device).type(torch.bool)
        # 生成用于目标序列的mask,确保在解码时每个位置只能看到自身及其之前的位置信息
        tgt_mask = (generate_square_subsequent_mask(ys.size(0))
                                    .type(torch.bool)).to(device)
        # decode方法解码目标序列
        out = model.decode(ys, memory, tgt_mask)
        out = out.transpose(0, 1)
        # 使用生成器(generator)对解码输出的最后一个时间步的概率进行计算
        prob = model.generator(out[:, -1])
        # 选取概率最高的词作为当前时间步的预测输出
        _, next_word = torch.max(prob, dim = 1)
        next_word = next_word.item()
        # 将预测的词追加到目标序列ys中
        ys = torch.cat([ys,
                        torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=0)
        # 如果预测的词是EOS(结束符),则终止解码
        if next_word == EOS_IDX:
            break
    return ys


def translate(model, src, src_vocab, tgt_vocab, src_tokenizer):
    '''
    输入:
    model: Transformer模型,用于进行翻译任务。
    src: 源文本字符串。
    src_vocab: 源语言词汇表。
    tgt_vocab: 目标语言词汇表。
    src_tokenizer: 源文本的tokenizer。
    输出:
    返回翻译后的目标文本字符串。
    '''
    model.eval() # 模型设为评估模式
    # 使用源文本的tokenizer对源文本进行编码,包括起始符(BOS_IDX)和结束符(EOS_IDX)
    tokens = [BOS_IDX] + [src_vocab.stoi[tok] for tok in src_tokenizer.encode(src, out_type=str)]+ [EOS_IDX]
    num_tokens = len(tokens)
    # 将编码后的源序列转换为PyTorch张量,并reshape成(num_tokens, 1)的形状
    src = (torch.LongTensor(tokens).reshape(num_tokens, 1) )
    # 初始化用于遮掩(mask)源序列的mask张量,确保在解码时每个位置只能看到自身及其之前的位置信息
    src_mask = (torch.zeros(num_tokens, num_tokens)).type(torch.bool)
    # 调用greedy_decode函数生成目标序列的tokens
    tgt_tokens = greedy_decode(model,  src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX).flatten()
    # 将生成的目标序列tokens转换为文本,并替换掉特殊符号"<bos>"和"<eos>",返回最终的翻译结果字符串
    return " ".join([tgt_vocab.itos[tok] for tok in tgt_tokens]).replace("<bos>", "").replace("<eos>", "")

调用translate函数进行语句翻译。查看列表中索引为5 的元素。

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

trainen.pop(5) # 从列表 trainen 中移除索引为 5 的元素
trainja.pop(5) # 从列表 trainja 中移除索引为 5 的元素

那么,训练模型效果如何呢?

从这次翻译结果来看,这个训练模型的效果比起上一个好了很多。

2.3.2 保存词汇表以及训练模型

保存我们的词汇表写入文件。

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) # 将日语词汇表 ja_vocab 序列化并写入文件
file.close()

保存我们的模型和检查点。

# 保存用于推理的模型
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')

check一下我们想要的文件有没有被保存下来。因为我在pycharm上面运行,看到当前目录下没有生成文件。于是我选择浏览远程主机的目录,果然找到了刚刚保存好的文件,下载到本机目录中就OK了。

三、实验总结

在自然语言处理领域,机器翻译一直是一个重要的研究方向。本实验旨在利用Transformer模型及其PyTorch实现,建立一个日语到汉语的机器翻译模型。Transformer模型由于其能够处理长距离依赖和并行计算的能力,已经成为机器翻译任务的主流模型之一。

本实验成功实现了在GPU环境下训练基于Transformer和PyTorch的日汉机器翻译模型,并利用训练好的模型取得了良好的翻译效果。 在NLP不断发展的未来,或许可以更好地进一步改进模型的性能,提升模型的泛化能力和翻译质量,亦或是探索在小数据集上的模型优化方法,以应对现实场景中数据稀缺的情况。经过不断地学习和进步,我们在自然语言处理中能够更加融会贯通,运以实际。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值