深度学习---机器翻译(Transformer)
目录
3.2 Sequence-to-sequence Transformer架构
一、机器翻译(MT)
机器翻译(machine translation)指的是 将序列从一种语言自动翻译成另一种语言。 事实上,这个研究领域可以追溯到数字计算机发明后不久的20世纪40年代, 特别是在第二次世界大战中使用计算机破解语言编码。 几十年来,在使用神经网络进行端到端学习的兴起之前, 统计学方法在这一领域一直占据主导地位 (Brown et al., 1990, Brown et al., 1988)。 因为统计机器翻译(statistical machine translation)涉及了 翻译模型和语言模型等组成部分的统计分析, 因此基于神经网络的方法通常被称为 神经机器翻译(neural machine translation), 用于将两种翻译模型区分开来。
机器翻译作为自然语言处理(NLP)领域的重要分支,已经有数十年的发展历史。从最早的基于规则的方法,到后来统计机器翻译(Statistical Machine Translation,SMT)的出现,再到如今深度学习方法的应用,机器翻译技术不断进步。然而,传统方法在处理长句子或上下文依赖问题时常常表现不佳。
二、Transformer
2.1 背景
Transformer 架构的诞生源于自然语言处理(NLP)领域的迫切需求。在过去,传统的循环神经网络(RNN)和卷积神经网络(CNN)在处理序列数据时面临一些挑战。RNN 虽然能够捕捉序列中的依赖关系,但由于其顺序处理的方式,导致计算效率低下,并且难以处理长距离依赖。而 CNN 虽然可以并行计算,但在处理变长序列时不够灵活。
为了克服这些挑战,2017 年,谷歌的 8 名研究人员联合发表了名为《Attention Is All You Need》的论文,并在这篇论文中提出了 Transformer 架构。该架构采用了自注意力机制,使得模型能够同时关注序列中的所有位置,从而捕捉长距离依赖关系。此外,Transformer 还采用了多头注意力和位置编码等技术,进一步提高了模型的性能。
Transformer 架构的出现,为自然语言处理领域带来了革命性的突破。它不仅提高了模型的性能和效率,还为后续的研究和发展奠定了基础。目前,Transformer 架构已经成为了自然语言处理领域的主流架构之一,并在机器翻译、文本生成、问答系统等任务中取得了显著的成果。
2.2 基本原理
当前主流的大语言模型都基于 Transformer 模型进行设计的。Transformer 是由多层的多头自注意力(Multi-head Self-attention)模块堆叠而成的神经网络模型。原始的 Transformer 模型由编码器和解码器两个部分构成,而这两个部分实际上可以独立使用,例如基于编码器架构的 BERT模型和解码器架构的 GPT 模型。与 BERT 等早期的预训练语言模型相比,大语言模型的特点是使用了更长的向量维度、更深的层数,进而包含了更大规模的模型参数,并主要使用解码器架构,对于 Transformer 本身的结构与配置改变并不大。
2.2.1 Transformer整体结构
Transformer作为编码器-解码器架构的一个实例,其整体架构图在图中展示。正如所见到的,Transformer是由编码器和解码器组成的。Transformer的编码器和解码器是基于自注意力的模块叠加而成的,源(输入)序列和目标(输出)序列的嵌入(embedding)表示将加上位置编码(positional encoding),再分别输入到编码器和解码器中。
图2.1.1.1 Transformer架构 (a)
图中概述了Transformer的架构。从宏观角度来看,Transformer的编码器是由多个相同的层叠加而成的,每个层都有两个子层(子层表示为sublayer)。第一个子层是多头自注意力(multi-head self-attention)汇聚;第二个子层是基于位置的前馈网络(positionwise feed-forward network)。具体来说,在计算编码器的自注意力时,查询、键和值都来自前一个编码器层的输出。受残差网络的启发,每个子层都采用了残差连接(residual connection)。在Transformer中,对于序列中任何位置的任何输入𝑥∈𝑅𝑑,都要求满足sublayer(𝑥)∈𝑅𝑑,以便残差连接满足𝑥+sublayer(𝑥)∈𝑅𝑑。在残差连接的加法计算之后,紧接着应用层规范化(layer normalization)。因此,输入序列对应的每个位置,Transformer编码器都将输出一个𝑑维表示向量。
Transformer解码器也是由多个相同的层叠加而成的,并且层中使用了残差连接和层规范化。除了编码器中描述的两个子层之外,解码器还在这两个子层之间插入了第三个子层,称为编码器-解码器注意力(encoder-decoder attention)层。在编码器-解码器注意力中,查询来自前一个解码器层的输出,而键和值来自整个编码器的输出。在解码器自注意力中,查询、键和值都来自上一个解码器层的输出。但是,解码器中的每个位置只能考虑该位置之前的所有位置。这种掩蔽(masked)注意力保留了自回归(auto-regressive)属性,确保预测仅依赖于已生成的输出词元。
图2.1.1.2 Transformer架构 (b)
2.2.2 多头注意力
相关注意力机制具体内容与实现可参考另一篇文章:基于注意力机制GRU网络的机器翻译
在实践中,当给定相同的查询、键和值的集合时, 我们希望模型可以基于相同的注意力机制学习到不同的行为, 然后将不同的行为作为知识组合起来, 捕获序列内各种范围的依赖关系 (例如,短距离依赖和长距离依赖关系)。 因此,允许注意力机制组合使用查询、键和值的不同 子空间表示(representation subspaces)可能是有益的。
图2.2.2.1 多头注意力
为此,与其只使用单独一个注意力汇聚, 我们可以用独立学习得到的ℎ组不同的 线性投影(linear projections)来变换查询、键和值。 然后,这ℎ组变换后的查询、键和值将并行地送到注意力汇聚中。 最后,将这ℎ个注意力汇聚的输出拼接在一起, 并且通过另一个可以学习的线性投影进行变换, 以产生最终输出。 这种设计被称为多头注意力(multihead attention)。 对于ℎ个注意力汇聚输出,每一个注意力汇聚都被称作一个头(head)。展示了使用全连接层来实现可学习的线性变换的多头注意力。
图2.2.2.2 多头注意力:多个头连结然后线性变换¶
2.2.3 基于位置的前馈网络
为了学习复杂的函数关系和特征,Transformer 模型引入了一个前馈网络层 (Feed Forward Netwok, FFN),对于每个位置的隐藏状态进行非线性变换和特征 提取。具体来说,给定输入 𝒙,Transformer 中的前馈神经网络由两个线性变换和 一个非线性激活函数组成:
基于位置的前馈网络对序列中的所有位置的表示进行变换时使用的是同一个多层感知机(MLP),这就是称前馈网络是基于位置的(positionwise)的原因。在下面的实现中,输入X
的形状(批量大小,时间步数或序列长度,隐单元数或特征维度)将被一个两层的感知机转换成形状为(批量大小,时间步数,ffn_num_outputs
)的输出张量。
图2.2.3.1 前馈网络
2.2.4 残差连接和层规范化
现在让我们关注架构中的加法和规范化(add&norm)组件。正如在本文开头所述,这是由残差连接和紧随其后的层规范化组成的。两者都是构建有效的深度架构的关键。
层规范化和批量规范化的目标相同,但层规范化是基于特征维度进行规范化。尽管批量规范化在计算机视觉中被广泛应用,但在自然语言处理任务中(输入通常是变长序列)批量规范化通常不如层规范化的效果好。
图2.2.4.1 规范化
2.2.5 编码器
在 Transformer 模型中,编码器(Encoder)的作用是将每个输入词元都编码成一个上下文语义相关的表示向量。编码器结构由多个相同的层堆叠而成,其中每一层都包含多头自注意力模块和前馈网络模块。在注意力和前馈网络后,模型使用层归一化和残差连接来加强模型的训练稳定度。其中,残差连接 (Residual Connection)将输入与该层的输出相加,实现了信息在不同层的跳跃传递,从而缓解梯度爆炸和消失的问题。而 LayerNorm 则对数据进行重新放缩,提升模型的训练稳定性。编码器接受经过位置编码层的 词嵌入序列 𝑿 作为输入,通过多个堆叠的编码器层来建模上下文信息,进而对于 整个输入序列进行编码表示。由于输入数据是完全可见的,编码器中的自注意力 模块通常采用双向注意力,每个位置的词元表示能够有效融合上下文的语义关系。 在编码器-解码器架构中,编码器的输出将作为解码器(Decoder)的输入,进行后续计算。形式化来说,第 𝑙 层(𝑙 ∈ {1, . . . , 𝐿})的编码器的数据处理过程如下所示:
其中,𝑿𝑙−1 和 𝑿𝑙 分别是该 Transformer 层的输入和输出,𝑿 ′ 𝑙 是该层中输入经过多 头注意力模块后的中间表示,LayerNorm 表示层归一化。
图2.2.5.1 编码器解码器架构
2.2.6 解码器
Transformer 架构中的解码器基于来自编码器编码后的最后一层的输出表示以及已经由模型生成的词元序列,执行后续的序列生成任务。与编码 器不同,解码器需要引入掩码自注意(Masked Self-attention)模块,用来在计算 注意力分数的时候掩盖当前位置之后的词,以保证生成目标序列时不依赖于未来 的信息。除了建模目标序列的内部关系,解码器还引入了与编码器相关联的多头 注意力层,从而关注编码器输出的上下文信息 𝑿𝐿。同编码器类似,在每个模块之后,Transformer 解码器 也采用了层归一化和残差连接。在经过解码器之后,模型会通过一个全连接层将输出映射到大小为 𝑉 的目标词汇表的概率分布,并基于某种解码策略生成对应的词元。在训练过程中,解码器可以通过一次前向传播,让每个词元的输出用于预测下一个词元。而在解码过程,解码器需要经过一个逐步的生成过程,将自回归地生成完整的目标序列。解码器的数据流程如下所示:
其中,𝒀𝑙−1 和 𝒀𝑙 分别是该 Transformer 层的输入和输出,𝒀 ′ 𝑙 和 𝒀 ′′ 𝑙 是该层中输入经过掩码多头注意力 MaskedMHA 和交叉多头注意力 CrossMHA 模块后的中间表示,LayerNorm 表示层归一化。
2.2.7 Transformer优缺点
Transformer虽然好,但它也不是万能地,还是存在着一些不足之处,接下来就来介绍一下它的优缺点:
优点:
- 1.效果好
- 2.可以并行训练,速度快
- 3.很好地解决了长距离依赖的问题
缺点:
- 1.完全基于self-attention,对于词语位置之间的信息有一定的丢失,虽然加入了positional encoding来解决这个问题,但也还存在着可以优化的地方。
三、中、日文机器翻译实战
声明:由于未找到合适的数据集,代码现实为英文-日文机器翻译。但实际上是中文-日文翻译,因此实验结果不尽相同,请读者留意一下。
首先,让我们确保在我们的系统中安装了以下软件包,如果你发现某些软件包缺失,一定要安装它们。
import math # 导入数学库
import torchtext # 导入torchtext库,用于文本预处理和数据加载
import torch # 导入PyTorch库
import torch.nn as nn # 导入PyTorch的神经网络模块
from torch import Tensor # 从torch中导入Tensor类
from torch.nn.utils.rnn import pad_sequence # 用于对序列数据进行填充,使得各序列长度一致
from torch.utils.data import DataLoader # 用于创建数据加载器,方便批处理和数据迭代
from collections import Counter # 导入Counter,用于统计元素频次
from torchtext.vocab import Vocab # 用于创建词汇表
from torch.nn import TransformerEncoder, TransformerDecoder, TransformerEncoderLayer, TransformerDecoderLayer
# 以上四行用于导入Transformer模型相关的编码器和解码器模块
import io # 导入io库,用于文件操作
import time # 导入时间库,常用于计时等
import pandas as pd # 导入pandas库,用于数据分析和操作
import numpy as np # 导入numpy库,用于数值计算
import pickle # 导入pickle库,用于对象的持久化存储
import tqdm # 导入tqdm库,用于显示进度条
import sentencepiece as spm # 导入sentencepiece库,通常用于文本分词和预处理
torch.manual_seed(0) # 设置随机种子,确保实验可重复
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 设置设备为GPU(如果可用)或CPU
# print(torch.cuda.get_device_name(0)) ## 如果你有GPU,请在你自己的电脑上尝试运行这一套代码
3.1 数据预处理
首先,读取我们得到的中文-日文数据,并将其转换成对应tensor数据。并且可视化数据,观察数据特点。
# 使用pandas库读取存储在'./zh-ja/zh-ja.bicleaner05.txt'路径下的文件,该文件使用制表符('\t')作为字段分隔符
df = pd.read_csv('./zh-ja/zh-ja.bicleaner05.txt', sep='\\t', engine='python', header=None)
# 从DataFrame中提取第三列,这通常对应于文件中的中文文本,将其转换为列表
trainen = df[2].values.tolist() # 你可以选择只取前10000个数据项,但这里取全部
# 从DataFrame中提取第四列,这通常对应于文件中的日语文本,将其转换为列表
trainja = df[3].values.tolist() # 同样,你可以选择只取前10000个数据项
# 以下两行被注释掉了,它们用于从列表中移除特定位置(索引5972)的数据项,可能是因为该数据项存在问题或不需要
# trainen.pop(5972)
# trainja.pop(5972)
print(trainen[500])
print(trainja[500])
与英语或其他字母语言不同,日语句子不包含空格来分隔单词。我们可以使用 JParaCrawl 提供的标记器,它是使用 SentencePiece 为日语和英语创建的,可以访问 JParaCrawl 网站下载。
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')
en_tokenizer.encode("All residents aged 20 to 59 years who live in Japan must enroll in public pension system.", out_type='str')
图3.1.1 英文分词测试
ja_tokenizer.encode("年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。", out_type='str')
图3.1.2 日文分词测试
构建 TorchText 词汇对象,并将句子转换为 Torch 张量
使用标记器和原始句子,然后我们构建从 TorchText 导入的词汇对象。不同的标记器也会影响构建词汇所需的时间,我为日语尝试了其他几种标记器,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_vocab = build_vocab(trainja, ja_tokenizer)
# 使用英语分词器对英语数据集构建词汇表
en_vocab = build_vocab(trainen, en_tokenizer)
在我们有了词汇对象之后,我们就可以使用词汇和标记器对象来为我们的训练数据构建张量。
复制重新生成
# 定义一个函数,用于处理数据,将文本转换为张量格式
def data_process(ja, en):
data = [] # 初始化一个列表,用来存储处理后的数据
# 使用zip函数同时遍历日语和英语的数据集
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)
创建在训练期间要迭代的 DataLoader 对象。
在这里,我将批大小设置为 16 以防止“cuda 内存不足”,但这取决于各种因素,例如你的机器内存容量、数据大小等,所以你可以根据自己的需要随意更改批大小。
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))
# 使用pad_sequence函数对批处理中的句子进行填充,使其长度一致,填充值为PAD_IDX
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)
3.2 Sequence-to-sequence Transformer架构
Transformer 是在“Attention is all you need”这篇论文中引入的用于解决机器翻译任务的序列到序列模型。Transformer 模型由一个编码器和一个解码器块组成,每个块都包含固定数量的层。
编码器通过一系列的多头注意力和前馈网络层来处理输入序列。从编码器输出的被称为记忆的内容,与目标张量一起被馈送到解码器。编码器和解码器使用教师强制技术以端到端的方式进行训练。
from torch.nn import (TransformerEncoder, TransformerDecoder,
TransformerEncoderLayer, TransformerDecoderLayer)
# 定义一个序列到序列的Transformer模型类
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)
# 堆叠多个编码器层
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):
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)
# 定义Dropout层
self.dropout = nn.Dropout(dropout)
# 注册位置编码为模型的buffer,使其不会在训练中被更新
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):
# 生成上三角矩阵,其中对角线和对角线以上的元素为1,其他为0
mask = (torch.triu(torch.ones((sz, sz), device=device)) == 1).transpose(0, 1)
# 将0的位置填充为负无穷大,将1的位置填充为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] # 获取目标序列的长度
# 生成目标序列的方形后续掩码,避免模型在训练时看到未来的目标词
tgt_mask = generate_square_subsequent_mask(tgt_seq_len)
# 生成源序列的掩码,这里全为False表示没有掩码
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 = 1 # 训练轮数
# 初始化Transformer模型
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)
# 定义损失函数,忽略填充值的损失
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
)
# 定义训练一个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 = 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) # 返回平均损失
3.3 模型训练
在准备好必要的类和函数后,我们准备好训练我们的模型。这不用说,但完成训练所需的时间可能会根据很多因素,如计算能力、参数和数据集大小等而有很大差异。
当我使用来自 JParaCrawl 的完整句子列表训练模型时,每种语言大约有 590 万条句子。
# 导入tqdm库,用于显示进度条
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() # 记录结束时间
# 打印当前epoch的训练损失和耗时
print((f"Epoch: {epoch}, Train loss: {train_loss:.3f}, "
f"Epoch time = {(end_time - start_time):.3f}s"))
图3.3.1 训练过程(a)
图3.3.2 训练过程(b)
3.4 模型测试
尝试使用训练好的模型翻译日文。
首先,我们创建用于翻译新句子的函数,包括获取日文、进行标记化、转换为张量、进行推理。
def greedy_decode(model, src, src_mask, max_len, start_symbol):
src = src.to(device) # 将源句子移动到设备(GPU或CPU)
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) # 初始化目标序列,起始符号为start_symbol
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).to(device)
src_mask = torch.zeros(num_tokens, num_tokens).type(torch.bool).to(device) # 生成源句子的掩码
# 使用贪婪解码生成目标序列
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(transformer, "HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)", ja_vocab, en_vocab, ja_tokenizer)
图3.4.1 Transformer架构机器翻译结果(epoch==100)
trainen.pop(5)
图3.4.2 中文token
trainja.pop(5)
图3.4.3 日文token
3.5 保存训练模型
保存词汇对象和训练好的模型,在训练完成后,我们首先使用 Pickle 来保存词汇对象(en_vocab 和 ja_vocab)。导入 pickle 模块(用于序列化和反序列化 Python 对象)。
import pickle # 导入pickle模块,用于序列化和反序列化Python对象
# 打开一个文件,用于存储英语词汇表
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函数保存模型的检查点,以便以后恢复训练
torch.save({
'epoch': NUM_EPOCHS, # 保存当前的训练轮数
'model_state_dict': transformer.state_dict(), # 保存模型的状态字典,包括所有的参数和缓冲区
'optimizer_state_dict': optimizer.state_dict(), # 保存优化器的状态字典,包括优化器的参数和动量
'loss': train_loss, # 保存当前的训练损失
}, 'model_checkpoint.tar') # 将这些信息保存到文件'model_checkpoint.tar'中
四、实验总结
本次实验旨在实现基于 Transformer 架构的中日机器翻译模型。实验过程中,我们首先对中日文本数据进行了预处理,包括分词、构建词汇表和转换为张量等步骤。然后,我们基于 PyTorch 实现了 Transformer 模型,并利用处理后的数据进行训练和评估。
在实验过程中,我们遇到了一些问题。例如,在数据预处理阶段,我们需要对中日文本进行分词,这是一个比较复杂的过程。我们尝试了多种分词方法,最终选择了适合中日文本的 SentencePiece 分词器。在模型训练阶段,我们需要对模型进行超参数调整,以获得更好的性能。我们通过实验发现,增加编码器和解码器的层数、增加多头注意力机制的头数以及增加词嵌入维度等方法都可以提高模型的性能。
通过本次实验,我们成功实现了基于 Transformer 架构的中日机器翻译模型,并取得了较好的性能。我们还对模型进行了评估,发现模型在翻译长句子和复杂句子时表现较好,但在翻译简单句子时可能会出现一些错误。这可能是由于数据集中简单句子的数量较少,导致模型对简单句子的学习不足。
Transformer是非常有潜力的模型,在Transformer基础上后来又衍生出来了BERT和GPT这两个NLP神器,而且依旧还存在着许多可以优化的地方。目前NLP在工业上的应用远不及CV广,但是自然语言是人类文明得以延续的重要的信息。没有文字,怎么回首古人的发展历史,没有语言,人类社会又怎么能够和谐运转,你看到的任何图片,听到的任何话语都在大脑里转换成里你自己能理解地文字信息,语言信息,所以NLP的前景依旧是无比巨大地。这是最好的时代,正因为NLP还一直处于探索阶段,所以现在开始学习仍旧不晚,希望能够对同学们有所帮助。
本人才疏学浅,有写错或者不懂的地方欢迎指正~
活到老,学到老,大家一起加油吧,奥利给!!!
五、参考文献
详解Transformer (Attention Is All You Need)