本文将手把手教你使用Pytorch搭建一个Seq2Seq模型来实现机器翻译的功能。所使用的数据集为torchtext自带的Multi30k翻译数据集,在没有使用注意力机制的前提下测试集ppl可以达到50.78。所有代码亲测可运行,码字不易,转载请注明出处,谢谢!
https://blog.csdn.net/weixin_43632501/article/details/98731800
本文将使用PyTorch和TorchText构建一个深度学习模型,该模型是《Sequence to Sequence Learning with Neural Networks》这篇论文的Pytorch实现,作者将其应用于机器翻译问题。同时,Seq2Seq模型还可以应用于涉及从一个序列到另一个序列的任何问题,例如文本摘要、聊天机器人、图片描述等自然语言处理任务。
除了PyTorch和TorchText,我们还将使用spaCy进行分词,spaCy是一个Python自然语言处理工具包,可是实现对文本做分词、词性分析、命名实体识别、依存分析等功能。
import torch
import torch.nn as nn
import torch.optim as optim
from torchtext.datasets import TranslationDataset, Multi30k
from torchtext.data import Field, BucketIterator
import spacy
import random
import math
import time
设置随机数种子
SEED = 1234
random.seed(SEED)
torch.manual_seed(SEED)
torch.backends.cudnn.deterministic = True
接下来,我们将使用spacy创建分词器(tokenizers),分词器的作用是将一个句子转换为组成该句子的单个符号列表,例如。“good morning!”变成了“good”,“morning”和“!”。在对某种语言进行分词前,spacy需要下载该语言的模型。
//分别下载英语和德语的模型
python -m spacy download en
python -m spacy download de
加载模型
spacy_de = spacy.load('de')
spacy_en = spacy.load('en')
创建分词器函数以便传递给TorchText 。在原论文中,作者发现颠倒源语言的输入的顺序可以取得不错的翻译效果,例如,一句话为“good morning!”,颠倒顺序分词后变为"!", “morning”, 和"good"。
def tokenize_de(text):
//将德语进行分词并颠倒顺序
return [tok.text for tok in spacy_de.tokenizer(text)][::-1]
def tokenize_en(text):
//将英语进行分词,不颠倒顺序
return [tok.text for tok in spacy_en.tokenizer(text)]
下面是TorchText出场,它主要包含以下组件:
Field对象 :指定要如何处理某个字段,比如指定分词方法,是否转成小写,起始字符,结束字符,补全字符以及词典等。
Dataset类 :用于加载数据,torchtext的Dataset是继承自pytorch的Dataset,提供了一个可以下载压缩数据并解压的方法(支持.zip, .gz, .tgz)。splits方法可以同时读取训练集,验证集,测试集。TabularDataset可以很方便的读取CSV, TSV, or JSON格式的文件
迭代器 : 返回模型所需要的处理后的数据。迭代器主要分为Iterator,BucketIterator,BPTTIterator三种。
其他 : torchtext提供常用文本数据集(datasets),并可以直接加载使用,现在包含的数据集包括:
首先,我们创建SRC和TRG两个Field对象,tokenize为我们刚才定义的分词器函数,在每句话的开头加入字符SOS,结尾加入字符EOS,将所有单词转换为小写。
SRC = Field(tokenize = tokenize_de,
init_token = '' ,
eos_token = '' ,
lower = True)
TRG = Field(tokenize = tokenize_en,
init_token = '' ,
eos_token = '' ,
lower = True)
然后,我们加载训练集、验证集和测试集,生成dataset类。使用torchtext自带的Multi30k数据集,这是一个包含约30000个平行的英语、德语和法语句子的数据集,每个句子包含约12个单词。
// splits方法可以同时加载训练集,验证集和测试集,
//参数exts指定使用哪种语言作为源语言和目标语言,fileds指定定义好的Field类
train_data, valid_data, test_data = Multi30k.splits(exts = ('.de', '.en'),
fields = (SRC, TRG))
我们可以查看一下加载完的数据集
print(f"Number of training examples: {len(train_data.examples)}")
print(f"Number of validation examples: {len(valid_data.examples)}")
print(f"Number of testing examples: {len(test_data.examples)}")
Number of training examples: 29000
Number of validation examples: 1014
Number of testing examples: 1000
还可以看一下生成的第一个训练样本,可以看到源语言的顺序已经颠倒了。
print(vars(train_data.examples[0]))
{'src': ['.', 'büsche', 'vieler', 'nähe', 'der', 'in', 'freien', 'im', 'sind', 'männer', 'weiße', 'junge', 'zwei'], 'trg': ['two', 'young', ',', 'white', 'males', 'are', 'outside', 'near', 'many', 'bushes', '.']}
下一步我们将构建词表,所谓构建词表,即需要给每个单词编码,也就是用数字表示每个单词,这样才能传入模型。可以使用dataset类中的build_vocab()方法传入用于构建词表的数据集。注意,源语言和目标语言的词表是不同的,而且词表应该只从训练集构建,而不是验证/测试集,这可以防止“信息泄漏”到模型中。
// 设置最小词频为2,当一个单词在数据集中出现次数小于2时会被转换为字符。
SRC.build_vocab(train_data, min_freq = 2)
TRG.build_vocab(train_data, min_freq = 2)
查看一下生成的词表大小
print(f"Unique tokens in source (de) vocabulary: {len(SRC.vocab)}")
print(f"Unique tokens in target (en) vocabulary: {len(TRG.vocab)}")
Unique tokens in source (de) vocabulary: 7855
Unique tokens in target (en) vocabulary: 5893
最后一步是创建迭代器,在训练神经网络时,是对一个batch的数据进行操作,因此我们需要使用torchtext内部的迭代器对数据进行处理。
在此之前需要定义一个torch.device。这是用来告诉TorchText是否把张量放在GPU上。我们使用torch.cuda.is_available()函数,如果在我们的计算机上检测到GPU,该函数将返回True。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
当使用迭代器生成一个batch时,我们需要确保所有的源语言句子都padding到相同的长度,目标语言的句子也是。这些功能torchtext 可以自动的完成,其使用了动态 padding,意味着 一个batch内的所有句子会 pad 成 batch 内最长的句子长度。
//设置batch_size为128
BATCH_SIZE = 128
train_iterator, valid_iterator, test_iterator = BucketIterator.splits(
(train_data, valid_data, test_data),
batch_size = BATCH_SIZE,
device = device)
我们可以查看一下生成的batch
batch = next(iter(train_iterator))
print(batch)
[torchtext.data.batch.Batch of size 128 from MULTI30K]
[.src]:[torch.cuda.LongTensor of size 23x128 (GPU 0)]
[.trg]:[torch.cuda.LongTensor of size 21x128 (GPU 0)]
我们将分别创建编码器(Encoder)、解码器(Eecoder)和seq2seq模型。
原论文使用了一个4层的单向LSTM,出于训练时间的考虑,我们将其缩减到了2层。结构如图所示:
class Encoder(nn.Module):
def __init__(self, input_dim, emb_dim, hid_dim, n_layers, dropout):
super().__init__()
self.input_dim = input_dim
self.emb_dim = emb_dim
self.hid_dim = hid_dim
self.n_layers = n_layers
self.dropout = dropout
self.embedding = nn.Embedding(input_dim, emb_dim)
self.rnn = nn.LSTM(emb_dim, hid_dim, n_layers, dropout = dropout)
self.dropout = nn.Dropout(dropout)
def forward(self, src):
//src:(sent_len, batch_size)
embedded = self.dropout(self.embedding(src))
//embedded:(sent_len, batch_size, emb_dim)
outputs, (hidden, cell) = self.rnn(embedded)
//outputs:(sent_len, batch_size, hid_dim)
//hidden:(n_layers, batch_size, hid_dim)
//cell:(n_layers, batch_size, hid_dim)
return hidden, cell
参数说明:
在forward函数中,我们传入源语言src,经过embedding层将其转换为密集向量,然后应用dropout,然后将这些词嵌入传递到LSTM。读者可能注意到,我们没有将初始隐藏状态h_0和单元格状态c_0传递给LSTM。这是因为如果没有向LSTM传递隐藏/单元格状态,它将自动创建一个全0的张量作为初始状态。
decoder网络同样是一个2层的LSTM(原论文中为4层),结构如图所示:
class Decoder(nn.Module):
def __init__(self, output_dim, emb_dim, hid_dim, n_layers, dropout):
super().__init__()
self.emb_dim = emb_dim
self.hid_dim = hid_dim
self.output_dim = output_dim
self.n_layers = n_layers
self.dropout = dropout
self.embedding = nn.Embedding(output_dim, emb_dim)
self.rnn = nn.LSTM(emb_dim, hid_dim, n_layers, dropout = dropout)
self.out = nn.Linear(hid_dim, output_dim)
self.dropout = nn.Dropout(dropout)
def forward(self, input, hidden, cell):
//
//input:(batch_size) -> input:(1, batch_size)
input = input.unsqueeze(0)
//embedded: (1, batch_size, emb_dim)
embedded = self.dropout(self.embedding(input))
//hidden:(n_layers, batch size, hid_dim)
//cell:(n_layers, batch size, hid_dim)
//output(1, batch_size, hid_dim)
output, (hidden, cell) = self.rnn(embedded, (hidden, cell))
//prediction: (batch_size, output_dim)
prediction = self.out(output.squeeze(0))
return prediction, hidden, cell
Decoder网络的参数和初始化类似于Encoder,不同的地方在于:
output_dim:输入解码器的one-hot向量维度,等于目标语言词汇表的大小。
添加了Linear层,用于预测最终输出。
在forward函数中,我们接受目标语言trg作为输入数据,由于目标语言每次是输入一个词(源语言每次输入一句话),因此用unsqueeze()方法给为其添加一个句子长度为1的维度(即将一维变为二维,以便能够作为embedding层的输入)。
然后,与编码器类似,我们通过一个embedding层并应用dropout。然后,将这些
嵌入与Encoder层生成的隐藏状态h_n和单元格状态c_n一起传递到LSTM。注意:在Encoder中,我们使用了一个全0的张量作为初始隐藏状态h_0和单元格状态c_0,在Decoder中,我们使用的是Encoder生成的h_n和c_n作为初始的隐藏状态和单元格状态,这就相当于我们在翻译时使用了源语言的上下文信息。
Seq2Seq网络将Encoder和Decoder网络组合在,实现以下功能:
class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder, device):
super().__init__()
self.encoder = encoder
self.decoder = decoder
self.device = device
assert encoder.hid_dim == decoder.hid_dim, \
"Hidden dimensions of encoder and decoder must be equal!"
assert encoder.n_layers == decoder.n_layers, \
"Encoder and decoder must have equal number of layers!"
def forward(self, src, trg, teacher_forcing_ratio = 0.5):
//src: (sent_len, batch size)
//trg: (sent_len, batch size)
batch_size = trg.shape[1]
max_len = trg.shape[0]
trg_vocab_size = self.decoder.output_dim
//创建outputs张量存储Decoder的输出
outputs = torch.zeros(max_len, batch_size, trg_vocab_size).to(self.device)
hidden, cell = self.encoder(src)
//输入到Decoder网络的第一个字符是(句子开始标记)
input = trg[0,:]
for t in range(1, max_len):
//注意前面的hidden、cell和后面的是不同的
output, hidden, cell = self.decoder(input, hidden, cell)
outputs[t] = output
teacher_force = random.random() < teacher_forcing_ratio
top1 = output.max(1)[1]
input = (trg[t] if teacher_force else top1)
return outputs
参数说明:
在该网络中,编码器和解码器的层数(n_layers)和隐藏层维度(hid_dim)是相等。但是,在其他的Seq2seq模型中不一定总是需要相同的层数或相同的隐藏维度大小。例如,编码器有2层,解码器只有1层,这就需要进行相应的处理,如对编码器输出的两个上下文向量求平均值,或者只使用最后一层的上下文向量作为解码器的输入等。
编码器和解码器的嵌入层维度(emb_dim)和dropout可以不同,但是层数和隐藏层维度必须相同。
INPUT_DIM = len(SRC.vocab)
OUTPUT_DIM = len(TRG.vocab)
ENC_EMB_DIM = 256
DEC_EMB_DIM = 256
HID_DIM = 512
N_LAYERS = 2
ENC_DROPOUT = 0.5
DEC_DROPOUT = 0.5
enc = Encoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM, N_LAYERS, ENC_DROPOUT)
dec = Decoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, N_LAYERS, DEC_DROPOUT)
//使用to方法可以容易地将对象移动到不同的设备上(CPU或者GPU)
model = Seq2Seq(enc, dec, device).to(device)
下一步是初始化模型的参数。在原论文中,作者将所有参数初始化为-0.08和+0.08之间的均匀分布。我们通过创建一个函数来初始化模型中的参数权重。当使用apply方法时,模型中的每个模块和子模块都会调用init_weights函数。
def init_weights(m):
for name, param in m.named_parameters():
nn.init.uniform_(param.data, -0.08, 0.08)
model.apply(init_weights)
可以看到模型的所有参数
Seq2Seq(
(encoder): Encoder(
(embedding): Embedding(7855, 256)
(rnn): LSTM(256, 512, num_layers=2, dropout=0.5)
(dropout): Dropout(p=0.5)
)
(decoder): Decoder(
(embedding): Embedding(5893, 256)
(rnn): LSTM(256, 512, num_layers=2, dropout=0.5)
(out): Linear(in_features=512, out_features=5893, bias=True)
(dropout): Dropout(p=0.5)
)
)
看一下模型中可训练参数的总数量
def count_parameters(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f'The model has {count_parameters(model):,} trainable parameters')
结果如下:
The model has 13,899,013 trainable parameters
optimizer = optim.Adam(model.parameters())
使用交叉熵损失作为损失函数,由于Pytorch在计算交叉熵损失时在一个batch内求平均,因此需要忽略target为的值(在数据处理阶段,一个batch里的所有句子都padding到了相同的长度,不足的用补齐),否则将影响梯度的计算
PAD_IDX = TRG.vocab.stoi['' ]
criterion = nn.CrossEntropyLoss(ignore_index = PAD_IDX)
def train(model, iterator, optimizer, criterion, clip):
model.train()
epoch_loss = 0
for i, batch in enumerate(iterator):
src = batch.src
trg = batch.trg
optimizer.zero_grad()
output = model(src, trg)
//trg: (sent_len, batch size) -> (sent_len-1) * batch size)
//output: (sent_len, batch_size, output_dim) -> ((sent_len-1) * batch_size, output_dim))
output = output[1:].view(-1, output.shape[-1])
trg = trg[1:].view(-1)
loss = criterion(output, trg)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
optimizer.step()
epoch_loss += loss.item()
return epoch_loss / len(iterator)
参数说明:
评估阶段和训练阶段的区别是不需要更新任何参数
def evaluate(model, iterator, criterion):
model.eval()
epoch_loss = 0
with torch.no_grad():
for i, batch in enumerate(iterator):
src = batch.src
trg = batch.trg
//0 : 关闭teacher forcing
output = model(src, trg, 0)
output = output[1:].view(-1, output.shape[-1])
trg = trg[1:].view(-1)
loss = criterion(output, trg)
epoch_loss += loss.item()
return epoch_loss / len(iterator)
参数说明:
设置一个函数计算每一轮epoch消耗的时间
def epoch_time(start_time, end_time):
elapsed_time = end_time - start_time
elapsed_mins = int(elapsed_time / 60)
elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
return elapsed_mins, elapsed_secs
N_EPOCHS = 10
CLIP = 1
//初始化为正无穷
best_valid_loss = float('inf')
for epoch in range(N_EPOCHS):
start_time = time.time()
train_loss = train(model, train_iterator, optimizer, criterion, CLIP)
valid_loss = evaluate(model, valid_iterator, criterion)
end_time = time.time()
epoch_mins, epoch_secs = epoch_time(start_time, end_time)
if valid_loss < best_valid_loss:
best_valid_loss = valid_loss
torch.save(model.state_dict(), 'tut1-model.pt')
print(f'Epoch: {epoch+1:02} | Time: {epoch_mins}m {epoch_secs}s')
print(f'\tTrain Loss: {train_loss:.3f} | Train PPL: {math.exp(train_loss):7.3f}')
print(f'\t Val. Loss: {valid_loss:.3f} | Val. PPL: {math.exp(valid_loss):7.3f}')
参数说明:
笔者是在自己的笔记本上进行训练的,所用的显卡为GF 1050Ti,训练时间和结果如图所示:
使用最佳的参数在测试集中验证模型
model.load_state_dict(torch.load('tut1-model.pt'))
test_loss = evaluate(model, test_iterator, criterion)
print(f'| Test Loss: {test_loss:.3f} | Test PPL: {math.exp(test_loss):7.3f} |')
测试结果:
| Test Loss: 3.928 | Test PPL: 50.780 |
参考链接
https://github.com/bentrevett/pytorch-seq2seq
https://pytorch-cn.readthedocs.io/zh/latest/
https://blog.csdn.net/leo_95/article/details/87708267#Field_7