一.背景工作
1.1 配置要求
GUP在8.0以上可以正常运行,在其以下建议租用服务器进行实验。
1.2 运行环境
本实验运行环境为python
需要导入的库有以下类型:
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
其中,以下两种库需指定版本
sentencepiece==0.2.0
torchtext==0.6.0
本次实验所用GPU为A800-80GB,实测运行一次时间为54分钟。
二.数据预处理
本实验所需要的数据可从此链接下载http://www.kecl.ntt.co.jp/icl/lirg/jparacrawl],该数据集可公开使用,它主要通过网络抓取并自动对齐并行句子创建而成。
以下是对数据的获取,zh-ja.bicleaner05.txt拥有三列数据,第一列是爬取的网址,第二列是英文内容,第三列是对应的日文内容。
# 读取文件
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条。
三.模型构建
3.1 准备分词器
鉴于日语句子中没有空格来分隔单词,因此需要JParaCrawl提供的分词器,使用SentencePiece来处理日语和英语。
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)
#将英文句子编码为 SentencePiece 模型使用的特定编码序列,并返回一个字符串表示的编码结果
ja_tokenizer.encode("年金 日本に住んでいる20歳~60歳の全ての人は、公的年金制度に加入しなければなりません。", out_type=str)
#将日文句子编码为 SentencePiece 模型使用的特定编码序列,并返回一个字符串表示的编码结果
分词结果如下:
3.2构建TorchText的词汇对象
# 定义构建词汇表的函数
def build_vocab(sentences, tokenizer):
# 初始化一个计数器,用于统计token出现的次数
counter = Counter()
# 遍历每个句子并更新计数器
for sentence in sentences:
# 使用tokenizer对句子进行编码,并以字符串形式返回结果
encoded_sentence = tokenizer.encode(sentence, out_type=str)
# 更新计数器,统计每个token的出现次数
counter.update(encoded_sentence)
# 使用统计好的计数器创建词汇表对象,并指定特殊标记
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):
# 对日语句子进行编码成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)
# 将编码后的日语和英语tensor对添加到数据列表中
data.append((ja_tensor_, en_tensor_))
return data
# 使用data_process函数处理训练集数据trainja和trainen,得到训练数据train_data
train_data = data_process(trainja, trainen)
使用词汇表和分词器对象来为我们的训练数据构建张量。
3.3 创建DataLoader对象
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>(句子结束标志),并拼接成tensor
ja_batch.append(torch.cat([torch.tensor([BOS_IDX]), ja_item, torch.tensor([EOS_IDX])], dim=0))
# 在英语句子开头添加<BOS>(句子开头标志),在结尾添加<EOS>(句子结束标志),并拼接成tensor
en_batch.append(torch.cat([torch.tensor([BOS_IDX]), en_item, torch.tensor([EOS_IDX])], dim=0))
# 对日语句子和英语句子的批次进行填充,使用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
train_iter = DataLoader(train_data, batch_size=BATCH_SIZE,
shuffle=True, collate_fn=generate_batch)
# 创建一个数据加载器DataLoader,用于批量加载训练数据train_data。
3.4序列到序列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)
# 初始化位置编码矩阵为全零,形状为(maxlen, emb_size)
pos_embedding = torch.zeros((maxlen, emb_size))
# 根据位置张量和分母计算sin和cos函数值,填充到位置编码矩阵的偶数列和奇数列中
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):
# 将输入的token_embedding张量与位置编码张量相加,并应用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):
# 根据输入的tokens张量获取对应的词嵌入向量,并乘以sqrt(emb_size)进行缩放
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替换为负无穷(-inf),以便在softmax计算中屏蔽填充位置
# 将1替换为0,保留原始的attention权重
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,这里初始化为全零,因为在Transformer中通常不需要额外的源序列mask
src_mask = torch.zeros((src_seq_len, src_seq_len), device=device).type(torch.bool)
# 创建源序列和目标序列的填充mask
# src_padding_mask将源序列中PAD_IDX位置设为True,其余位置为False
# tgt_padding_mask将目标序列中PAD_IDX位置设为True,其余位置为False
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
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)
# 将模型移动到指定的设备(如GPU)
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
)
def train_epoch(model, train_iter, optimizer):
# 将模型设置为训练模式
model.train()
# 初始化损失累计值
losses = 0
# 遍历训练数据迭代器
for idx, (src, tgt) in enumerate(train_iter):
# 将输入数据和目标数据移动到指定的设备(如GPU)
src = src.to(device)
tgt = tgt.to(device)
# 准备目标输入序列(不包含最后一个token)
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()
# 准备目标输出序列(不包含第一个token)
tgt_out = tgt[1:,:]
# 计算损失
# 将logits和目标序列展平为二维张量,然后计算交叉熵损失
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):
# 将输入数据和目标数据移动到指定的设备(如GPU)
src = src.to(device)
tgt = tgt.to(device)
# 准备目标输入序列(不包含最后一个token)
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)
# 准备目标输出序列(不包含第一个token)
tgt_out = tgt[1:,:]
# 计算损失
# 将logits和目标序列展平为二维张量,然后计算交叉熵损失
loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
# 累加当前批次的损失值
losses += loss.item()
# 返回平均损失值
return losses / len(val_iter)
此时模型已经构建完成,定义了三个类,序列到序列转变器,位置编码和标记嵌入。
,GPU高性能条件下,NUM_ENCODER_LAYERS 和 NUM_DECODER_LAYERS 设置为3或者更高,NHEAD设置8,EMB_SIZE设置为512。
四.开始训练
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}, "
f"Epoch time = {(end_time - start_time):.3f}s"))
该过程颇费时间,运行结果如下:
在使用GPU为A800-80GB的配置下,实际运行时间为55分钟,在使用GPU为4090D的配置下,每轮运行时间为370s,在第六轮之后运行时间逐渐降低,最后接近五分钟,总计用时一小时四十分钟,两种配置下的损失几乎相同。
五.测试数据
def greedy_decode(model, src, src_mask, max_len, start_symbol):
# 将源语言序列和掩码移动到指定的设备(如GPU)
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):
# 将内部表示移动到指定的设备(如GPU)
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)
# 将编码后的源语言句子转换为PyTorch Tensor,并且将形状调整为 (num_tokens, 1)
src = torch.LongTensor(tokens).reshape(num_tokens, 1)
# 创建源语言句子的掩码,这里使用全零的掩码
src_mask = torch.zeros(num_tokens, num_tokens).type(torch.bool)
# 使用贪婪解码生成目标语言序列的索引表示
tgt_tokens = greedy_decode(model, src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX).flatten()
# 将生成的目标语言序列的索引表示转换为对应的词汇,并且将特殊符号去除
return " ".join([tgt_vocab.itos[tok] for tok in tgt_tokens]).replace("<bos>", "").replace("<eos>", "")
translate(transformer, "HSコード 8515 はんだ付け用、ろう付け用又は溶接用の機器(電気式(電気加熱ガス式を含む。)", ja_vocab, en_vocab, ja_tokenizer)
测试结果如下:
' ▁H S 代 码 85 15 ▁ 是 一 种 用 于 焊 接 的 、 电 气 加 热 设 备 ( 包 括 电 气 加 热 加 热 ) 。 '
测试完备,可以正确的进行transformer。