Pytorch实现Seq2Seq模型:以机器翻译为例

       本文将手把手教你使用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三种。

  • Iterator:标准迭代器
  • BucketIterator:相比于标准迭代器,会将类似长度的样本当做一批来处理,因为在文本处理中经常会需要将每一批样本长度补齐为当前批中最长序列的长度,因此当样本长度差别较大时,使用BucketIerator可以带来填充效率的提高。除此之外,我们还可以在Field中通过fix_length参数来对样本进行截断补齐操作。
  • BPTTIterator:基于BPTT(基于时间的反向传播算法)的迭代器,一般用于语言模型中。

其他 : torchtext提供常用文本数据集(datasets),并可以直接加载使用,现在包含的数据集包括:

  • Sentiment analysis: SST and IMDb
  • Question classification: TREC
  • Entailment: SNLI
  • Language modeling: WikiText-2
  • Machine translation: Multi30k, IWSLT, WMT14

首先,我们创建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)]

创建Seq2Seq模型

我们将分别创建编码器(Encoder)、解码器(Eecoder)和seq2seq模型。

Encoder

原论文使用了一个4层的单向LSTM,出于训练时间的考虑,我们将其缩减到了2层。结构如图所示:
Pytorch实现Seq2Seq模型:以机器翻译为例_第1张图片

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

参数说明:

  • input_dim:输入编码器的one-hot向量的维度,等于源语言词汇表的大小。
  • emb_dim:embedding层的维度
  • hid_dim:隐藏层h和c的维度
  • n_layers:LSTM网络的层数
  • dropout:如果非零的话,将会在LSTM的输出上加个dropout,最后一层除外。

在forward函数中,我们传入源语言src,经过embedding层将其转换为密集向量,然后应用dropout,然后将这些词嵌入传递到LSTM。读者可能注意到,我们没有将初始隐藏状态h_0和单元格状态c_0传递给LSTM。这是因为如果没有向LSTM传递隐藏/单元格状态,它将自动创建一个全0的张量作为初始状态。

Decoder

decoder网络同样是一个2层的LSTM(原论文中为4层),结构如图所示:
Pytorch实现Seq2Seq模型:以机器翻译为例_第2张图片

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网络

Seq2Seq网络将Encoder和Decoder网络组合在,实现以下功能:

  • 使用源语言句子作为输入
  • 使用Encoder生成上下文向量
  • 使用Decoder预测目标语言句子

结构如图所示:
Pytorch实现Seq2Seq模型:以机器翻译为例_第3张图片

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

参数说明:

  • device :把张量放到GPU上。新版的Pytorch使用to方法可以容易地将对象移动到不同的设备(代替以前的cpu()或cuda()方法)。
  • outputs:存储Decoder所有输出的张量
  • teacher_forcing_ratio:该参数的作用是,当使用teacher force时,decoder网络的下一个input是目标语言的下一个字符,当不使用时,网络的下一个input是其预测出的那个字符。

在该网络中,编码器和解码器的层数(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

可视化的结构和参数:
Pytorch实现Seq2Seq模型:以机器翻译为例_第4张图片
使用Adam作为优化器

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)

参数说明:

  • model.train() : 让model变为训练模式,启用 batch normalization(本模型未使用)和 Dropout。
  • clip_grad_norm: 进行梯度裁剪,防止梯度爆炸。clip:梯度阈值
  • view函数: 减少output和trg的维度以便进行loss计算。由于trg每句话的开头都是标记符sos,为了提高准确度,output和trg的第一列将不参与计算损失。

定义测试函数

评估阶段和训练阶段的区别是不需要更新任何参数

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)

参数说明:

  • model.eval(): 开启测试模式,关闭batch normalization(本模型未使用)和 dropout。
  • torch.no_grad():关闭autograd 引擎(不会进行反向传播计算),这样的好处是减少内存的使用并且加速计算。
  • teacher_forcing_ratio = 0:在测试阶段须要关闭teacher forcing,保证模型使用预测的结果作为下一步的输入。

设置一个函数计算每一轮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}')

参数说明:

  • state_dict():使用最佳验证损失的epoch参数作为模型的最终参数。
  • math.exp():使用一个batch内的平均损失计算困惑度

笔者是在自己的笔记本上进行训练的,所用的显卡为GF 1050Ti,训练时间和结果如图所示:
Pytorch实现Seq2Seq模型:以机器翻译为例_第5张图片

验证模型

使用最佳的参数在测试集中验证模型

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} |')
  • load_state_dict:加载训练好的参数

测试结果:

| 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

你可能感兴趣的:(Pytorch实现Seq2Seq模型:以机器翻译为例)