Pytorch-seq2seq 学习

0 项目介绍

0.1 预备工作

安装torchtext:

1
pip install torchtext

该项目需要使用spacy作为分词工具,我们需要安装其英文版和德文版。

1
2
python -m spacy download en
python -m spacy download de

这里出现了连接错误的问题。

1
requests.exceptions.ConnectionError: HTTPSConnectionPool(host='raw.githubusercontent.com', port=443): Max retries exceeded with url: /explosion/spacy-models/master/shortcuts-v2.json (Caused by NewConnectionError('<urllib3.connection.VerifiedHTTPSConnection object at 0x000002C3A0045390>: Failed to establish a new connection: [Errno 11004] getaddrinfo failed'))

最后选择手动安装,分别在英文德语下载,然后在命令行运行

1
pip install \你的下载路径\en_core_web_md-2.3.0.tar.gz

特别需要注意的是,model和spcay的版本对应,可以在上面的链接中看到。

数据集下载

0.2 项目介绍

  • 1- [神经网络的序列到序列学习]

    第一个教程介绍了带有TorchText seq2seq项目的PyTorch的工作流程。我们将使用编码器-解码器模型介绍seq2seq网络的基础知识,如何在PyTorch中实现这些模型,以及如何使用TorchText在文本处理方面进行所有繁重的工作。该模型本身将基于使用多层LSTM的神经网络序列到序列学习的实现。

  • 2- 使用RNN编解码器学习短语表示以进行统计机器翻译

    现在我们已经涵盖了基本的工作流程,本教程将着重于改善我们的结果。基于从上一教程中获得的PyTorch和TorchText的知识,我们将介绍第二个模型,该模型有助于解决编码器-解码器模型所面临的信息压缩问题。该模型将基于使用用于GRU的用于统计机器翻译的RNN编码器/解码器实现的学习短语表示来实现。

  • 3- 通过共同学习对齐和翻译的神经机器翻译

    接下来,我们通过共同学习对齐和翻译来实现神经机器翻译,从而了解注意力。通过允许解码器通过创建作为编码器隐藏状态的加权和的上下文向量来“回顾”输入语句,进一步解决了信息压缩问题。通过注意机制计算该加权和的权重,在该机制中,解码器学习注意输入句子中最相关的词。

  • 4- 压缩填充序列,屏蔽,推论和BLEU]

    在本笔记本中,我们将通过添加打包的填充序列masking来改进以前的模型架构。这是NLP中常用的两种方法。压缩填充序列允许我们仅使用RNN处理输入句子的非填充元素。遮罩用于强制模型忽略某些我们不希望其查看的元素,例如对填充元素的关注。在一起,这些都会给我们带来一点点性能提升。我们还介绍了使用模型进行推理的非常基本的方法,它使我们能够获取要提供给模型的任何句子的翻译,以及如何查看这些翻译在源序列上的注意值。最后,我们展示了如何从翻译中计算BLEU指标。

  • 5- 卷积序列到序列学习

    我们最终摆脱了基于RNN的模型,并实现了完全卷积模型。RNN的缺点之一是它们是顺序的。也就是说,在RNN处理一个单词之前,还必须处理所有先前的单词。卷积模型可以完全并行化,从而可以更快地进行训练。我们将实现卷积序列到序列模型,该模型在编码器和解码器中都使用多个卷积层,并且在它们之间具有关注机制。

  • 6- transformer

    继续基于非RNN的模型,我们从Attention Is All You Need实施了Transformer模型。该模型仅基于注意力机制,并介绍了多头注意力。编码器和解码器由多层组成,每层包括“多头注意”和“位置方式前馈”子层。该模型当前用于许多最新的序列到序列和转移学习任务中。

1 Sequence to Sequence Learning with Neural Networks

​ 在本项目中,我们将使用PyTorch和TorchText构建一个机器学习模型,从一个序列转到另一个序列。这将在德语到英语的翻译中完成,但是模型可以应用于涉及从一个序列到另一个序列的任何问题,例如摘要,即在同一语言中从一个序列到一个较短的序列。
​ 我们将开始简单的理解一般概念,通过实现从序列到序列的学习与神经网络的论文模型。

1.1 Introduction

 最常见的序列到序列(seq2seq)模型是编码器-解码器模型,它通常使用循环神经网络(RNN)将源(输入)语句编码为单个向量。在本文中,我们将把这个向量称为上下文向量。我们可以认为上下文向量是整个输入语句的抽象表示。然后,这个向量被第二个RNN解码,该RNN通过一次生成一个单词来学习输出目标(输出)句子。

img

​ 上图是一个翻译的示例,输入语句“guten morgen”通过嵌入层(黄色)后输入到编码层(绿色),在句首和句尾增加作为标签。
在每一个时间步:

  • encoder的输入不仅是当前单词$e(x_{t}) $,还是上一个时间步的隐藏状态$h_{t-1}$。
  • encoder输出当前时间步的隐藏状态$h_{t}$
    我们可以把隐藏状态视为句子的向量表示,RNN的公式可以表达为:

​ 这里的RNN可以为任何递归网络,比如LSTM或者GRU。
​ 如果$ \mathbf{X}=\left\{x_{1}, x_{2}, \ldots, x_{T}\right\}$,则$x_1=$,$x_2=guten$,以此类推。初始的隐藏状态$h_0$通常被设置为0或者学习参数。
​ 最后一个词 $ x_T$通过隐藏层进入RNN后,最后一个隐藏状态 $h_T $被视为上下文向量,也就是图中的z,即$h_T=z$,这是整个源句子的向量表示。
​ 有了上下文向量后我们就可以开始解码过程,首先仍然是将句首和句尾表示添加到目标句子。
​ 在每一个时间步,输入到RNN(蓝色)中的是当前词的嵌入$d$和上一个时间步的隐藏状态$s_{t-1}$。初始的隐藏状态$s_0$是上下文向量,也就是说$s_0=z=h_T$。解码层的公式可以表达为:

​ 在decoder中,我们需要将隐藏状态转换为一个实际的单词,因此在每一个时间步中,我们通过$s_t$来预测序列中的下一个词$\hat{y}_t$(通过一个全连接层,图中为紫色)。

注意:decoder的第一个输入一定是,但是对于后续的输入$y_{t>1}$,我们有时候使用序列中的真值$y_t$,有时候使用解码器预测的值$\hat{y}_{t-1}$。这被称为Teacher forcing,具体内容可以参考链接
​ 在训练/测试我们的模型时,我们总是知道目标句子中有多少个单词,所以一旦我们命中了那么多单词,就停止生成单词。在推理过程中,通常会一直生成单词,直到模型输出标记或生成一定数量的单词。
​ 预测出句子后$\hat{Y} = \{ \hat{y}_1, \hat{y}_2, …, \hat{y}_T \}$,我们将它与实际的目标句子$Y = \{ y_1, y_2, …, y_T \}$,作比较,计算损失,更新参数。

1.2 准备数据

​ 我们使用pytorch来组建模型,使用torchtext来预处理数据,使用spaCy来分词。

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
import torch.nn as nn
import torch.optim as optim

from torchtext.datasets import Multi30k
from torchtext.data import Field, BucketIterator

import spacy
import numpy as np

import random
import math
import time

设置随机种子

1
2
3
4
5
6
7
SEED = 1234

random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.cuda.manual_seed(SEED)
torch.backends.cudnn.deterministic = True

加载spacy模型

1
2
spacy_de = spacy.load("en_core_web_sm")
spacy_en = spacy.load("de_core_news_sm")

建立分词函数传递给torchtext

1
2
3
4
5
6
7
8
9
10
11
def tokenize_de(text):
"""
Tokenizes German text from a string into a list of strings (tokens) and reverses it
"""
return [tok.text for tok in spacy_de.tokenizer(text)][::-1]

def tokenize_en(text):
"""
Tokenizes English text from a string into a list of strings (tokens)
"""
return [tok.text for tok in spacy_en.tokenizer(text)]

建立field

1
2
3
4
5
6
7
8
9
SRC = Field(tokenize = tokenize_de, 
init_token = '<sos>',
eos_token = '<eos>',
lower = True)

TRG = Field(tokenize = tokenize_en,
init_token = '<sos>',
eos_token = '<eos>',
lower = True)

设置训练集、验证机和测试集,exts指向语言。

1
2
3
4
5
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)}")
print(vars(train_data.examples[0]))

从训练集中构建词汇表,只构建出现两次及以上的单词,出现一次的标为

1
2
3
4
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)}")

创建迭代器

1
2
3
4
5
6
7
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
BATCH_SIZE = 128
train_iterator,valid_iterator,test_iterator = BucketIterator.splits(
(train_data,valid_data,test_data),
batch_size=BATCH_SIZE,
device=device
)

1.3 建立seq2seq模型

1.3.1Encoder

编码器是一个两层的LSTM,第一层的隐藏状态:

第二层的隐藏状态:

我们需要知道的是,LSTM是一种RNN,它不是仅仅处于隐藏状态并且每个时间步返回一个新的隐藏状态,而是每次接收并返回一个单元状态$c_t$

所以我们的上下文向量也改为最终隐藏状态和最终单元状态,即$z^l = (h_T^l, c_T^l)$。
所以我们的LSTM为:

所以encoder图为:img

使用的参数有:

  • input_dim 输入维度,输入的词汇表大小

  • emb_dim:嵌入层维度

  • hid_dim:隐藏层和单元层维度

  • n_layers:LSTM层数

  • dropout:丢失量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def __init__(self, input_dim, emb_dim, hid_dim, n_layers, dropout):
super().__init__()

self.hid_dim = hid_dim
self.n_layers = n_layers

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 = [src len, batch size]

embedded = self.dropout(self.embedding(src))

#embedded = [src len, batch size, emb dim]

outputs, (hidden, cell) = self.rnn(embedded)

#outputs = [src len, batch size, hid dim * n directions]
#hidden = [n layers * n directions, batch size, hid dim]
#cell = [n layers * n directions, batch size, hid dim]

#outputs are always from the top hidden layer

return hidden, cell

1.3.2Decoder

Decoder也是一个两层的LSTM

img

解码层每个时间步只输出一个token,第一层接受从上一个时间步传来的隐藏和单元状态,然后通过LSTM产生新的隐藏和单元状态,后面的层将会使用前面层的隐藏状态。
初始的隐藏和单元状态就是来自同一层的Encoder的上下文变量(如图z)

最后通过一个全连接层来做预测 $\hat{y}_{t+1} = f(s_t^L)$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Decoder(nn.Module):
def __init__(self, output_dim, emb_dim, hid_dim, n_layers, dropout):
super().__init__()

self.output_dim = output_dim
self.hid_dim = hid_dim
self.n_layers = n_layers

self.embedding = nn.Embedding(output_dim, emb_dim)

self.rnn = nn.LSTM(emb_dim, hid_dim, n_layers, dropout = dropout)

self.fc_out = nn.Linear(hid_dim, output_dim)

self.dropout = nn.Dropout(dropout)

def forward(self, input, hidden, cell):

#input = [batch size]
#hidden = [n layers * n directions, batch size, hid dim]
#cell = [n layers * n directions, batch size, hid dim]

#n directions in the decoder will both always be 1, therefore:
#hidden = [n layers, batch size, hid dim]
#context = [n layers, batch size, hid dim]

input = input.unsqueeze(0)

#input = [1, batch size]

embedded = self.dropout(self.embedding(input))

#embedded = [1, batch size, emb dim]

output, (hidden, cell) = self.rnn(embedded, (hidden, cell))

#output = [seq len, batch size, hid dim * n directions]
#hidden = [n layers * n directions, batch size, hid dim]
#cell = [n layers * n directions, batch size, hid dim]

#seq len and n directions will always be 1 in the decoder, therefore:
#output = [1, batch size, hid dim]
#hidden = [n layers, batch size, hid dim]
#cell = [n layers, batch size, hid dim]

prediction = self.fc_out(output.squeeze(0))

#prediction = [batch size, output dim]

return prediction, hidden, cell

1.3.3 seq2seq

在实现的最后一部分,我们将实现seq2seq模型。需要处理:

  • 接收输入/源语句
  • 使用编码器生成上下文向量

  • 使用解码器生成预测输出/目标语句

完整模型将如下所示:
img

Seq2Seq模型包含一个编码器、解码器和一个device(用于在GPU上放置张量,如果它存在的话)。
必须保证encoder和decoder的层数、隐藏层和单元层维数相等。
我们在forward方法中做的第一件事是创建一个输出张量,它将存储我们所有的预测,$\hat{Y}$。
然后,我们将输入/源语句src输入编码器,并接收最终的隐藏状态和单元状态。
解码器的第一个输入是序列开始()标记。由于trg张量已经附加了标记(当我们在trg字段中定义in it_标记时一直如此),我们通过切片得到$y_1$。我们知道我们的目标句子应该有多长(max_len),所以我们循环了很多次。最后一个输入到解码器中的令牌是令牌之前的令牌,令牌永远不会输入到解码器中。
在循环的每次迭代中:

  • 将输入、以前的隐藏和以前的单元状态($y_t,s_{t-1},c_{t-1}$)传递到解码器中

  • 从解码器接收预测、下一个隐藏状态和下一个单元格状态($\hat{y}_{t+1}、s_{t}、c_{t}$)

  • 把我们的预测,$\hat{y}_{t+1}$/输出 放入我们的预测张量,$\hat{y}$/输出

  • 是否要”teacher forcing“

    • 如果我们这样做了,下一个输入就是序列中的下一个基真值,$y_{t+1}$/trg[t]

    • 如果我们不这样做,下一个输入是序列中预测的下一个标记$\hat{y}_{t+1}$/top1,通过对输出张量做argmax得到

最后我们得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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 = [src len, batch size]
#trg = [trg len, batch size]
#teacher_forcing_ratio is probability to use teacher forcing
#e.g. if teacher_forcing_ratio is 0.75 we use ground-truth inputs 75% of the time

batch_size = trg.shape[1]
trg_len = trg.shape[0]
trg_vocab_size = self.decoder.output_dim

#tensor to store decoder outputs
outputs = torch.zeros(trg_len, batch_size, trg_vocab_size).to(self.device)

#last hidden state of the encoder is used as the initial hidden state of the decoder
hidden, cell = self.encoder(src)

#first input to the decoder is the <sos> tokens
input = trg[0,:]

for t in range(1, trg_len):

#insert input token embedding, previous hidden and previous cell states
#receive output tensor (predictions) and new hidden and cell states
output, hidden, cell = self.decoder(input, hidden, cell)

#place predictions in a tensor holding predictions for each token
outputs[t] = output

#decide if we are going to use teacher forcing or not
teacher_force = random.random() < teacher_forcing_ratio

#get the highest predicted token from our predictions
top1 = output.argmax(1)

#if teacher forcing, use actual next token as next input
#if not, use predicted token
input = trg[t] if teacher_force else top1

return outputs

1.4 训练

首先,我们将初始化模型。输入和输出维度由词汇表的大小定义。编码器和解码器的嵌入尺寸和损失可以不同,但层的数量和隐藏/单元状态的大小必须相同。
然后定义编码器、解码器,然后定义Seq2Seq模型,并将其放置在设备上。

1
2
3
4
5
6
7
8
9
10
11
12
13
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)

model = Seq2Seq(enc, dec, device).to(device)

初始化权重

1
2
3
4
5
def init_weights(m):
for name, param in m.named_parameters():
nn.init.uniform_(param.data, -0.08, 0.08)

model.apply(init_weights)

计算可训练参数

1
2
3
4
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')

定义激活函数

1
optimizer = optim.Adam(model.parameters())

定义损失函数

1
2
3
TRG_PAD_IDX = TRG.vocab.stoi[TRG.pad_token]

criterion = nn.CrossEntropyLoss(ignore_index = TRG_PAD_IDX)

每次迭代时:

  • 从批处理中获取源语句和目标语句,$X$和$Y$

  • 将最后一批计算出的梯度归零

  • 将源和目标馈送到模型中以获取输出,$\hat{Y}$

  • 由于loss函数只适用于一维目标的二维输入,因此我们需要用.view将每个目标展平

  • 如前所述,我们将输出张量和目标张量的第一列切掉

  • 计算梯度向后损失()

  • 剪裁渐变以防止它们爆炸(RNN中的常见问题)

  • 通过执行优化器步骤更新模型的参数

  • 将损失值加总成连续总数

  • 最后,我们返回所有批次的平均损失。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 = [trg len, batch size]
#output = [trg len, batch size, output dim]

output_dim = output.shape[-1]

output = output[1:].view(-1, output_dim)
trg = trg[1:].view(-1)

#trg = [(trg len - 1) * batch size]
#output = [(trg len - 1) * batch size, output dim]

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)

评估

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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

output = model(src, trg, 0) #turn off teacher forcing

#trg = [trg len, batch size]
#output = [trg len, batch size, output dim]

output_dim = output.shape[-1]

output = output[1:].view(-1, output_dim)
trg = trg[1:].view(-1)

#trg = [(trg len - 1) * batch size]
#output = [(trg len - 1) * batch size, output dim]

loss = criterion(output, trg)

epoch_loss += loss.item()

return epoch_loss / len(iterator)
1
2
3
4
5
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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}')

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
The model has 13,898,757 trainable parameters
Epoch: 01 | Time: 0m 28s
Train Loss: 5.072 | Train PPL: 159.444
Val. Loss: 4.936 | Val. PPL: 139.215
Epoch: 02 | Time: 0m 27s
Train Loss: 4.571 | Train PPL: 96.614
Val. Loss: 5.450 | Val. PPL: 232.701
Epoch: 03 | Time: 0m 28s
Train Loss: 4.305 | Train PPL: 74.095
Val. Loss: 4.675 | Val. PPL: 107.252
Epoch: 04 | Time: 0m 28s
Train Loss: 4.061 | Train PPL: 58.049
Val. Loss: 4.549 | Val. PPL: 94.497
Epoch: 05 | Time: 0m 28s
Train Loss: 3.881 | Train PPL: 48.457
Val. Loss: 4.483 | Val. PPL: 88.475
Epoch: 06 | Time: 0m 28s
Train Loss: 3.729 | Train PPL: 41.623
Val. Loss: 4.339 | Val. PPL: 76.655
Epoch: 07 | Time: 0m 28s
Train Loss: 3.587 | Train PPL: 36.135
Val. Loss: 4.170 | Val. PPL: 64.725
Epoch: 08 | Time: 0m 27s
Train Loss: 3.438 | Train PPL: 31.120
Val. Loss: 4.079 | Val. PPL: 59.094
Epoch: 09 | Time: 0m 28s
Train Loss: 3.296 | Train PPL: 27.005
Val. Loss: 3.955 | Val. PPL: 52.205
Epoch: 10 | Time: 0m 28s
Train Loss: 3.196 | Train PPL: 24.446
Val. Loss: 3.948 | Val. PPL: 51.823
| Test Loss: 3.926 | Test PPL: 50.728 |