seq2seqGRU

seq2seq的改进

Posted by lifanchen on March 12, 2019

2 - Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation

我们将根据Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation中实现一个改进版的seq2seq模型。 该模型能够提高测试集上的困惑度,同时仅在编码器和解码器中使用单层RNN。

上一篇博文介绍的模型的缺点

前一个模型的一个最大的缺点是解码器试图将大量信息全部塞入隐藏状态

在解码时,隐藏状态(hidden state)需要包含关于整个源(input sequence)序列的信息,以及到目前为止已经解码的所有token!通过减少一些信息压缩,我们可以创建一个更好的模型!!!

我们还将使用GRU(门控循环单元)而不是LSTM(长短期记忆)。 为什么? 主要是因为这是他们在论文中所使用的循环神经架构(本文还介绍了GRU),也因为我们上次使用了LSTM。 如果您想了解GRU(和LSTM)与标准RNNS的不同之处,请查看this链接。 GRU比LSTM好吗?研究显示它们的性能几乎相同,并且两者都优于标准RNN。

Preparing Data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 os
import time
SEED = 1

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

spacy_de = spacy.load('de')
spacy_en = spacy.load('en')

以前我们颠倒了源(德语)句子,但是在我们正在实现的论文中他们没有这样做,所以我们也不会这样做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def tokenize_de(text):
    """
    Tokenizes German text from a string into a list of strings
    """
    return [tok.text for tok in spacy_de.tokenizer(text)]

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

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)

train_data, valid_data, test_data = Multi30k.splits(exts=('.de', '.en'), fields=(SRC, TRG))

然后创建我们的词汇表,将所有出现次数小于两次的token转化成<UNK>token。

1
2
3
4
5
6
7
8
SRC.build_vocab(train_data, min_freq=2)
TRG.build_vocab(train_data, min_freq=2)

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)

Building the Seq2Seq Model

Encoder

编码器与前一个编码器类似,只是把多层的LSTM转换为单层的GRU。 我们也不将dropout作为参数传递给GRU。因为在多层RNN的每一层之间可以使用dropout。 但是我们只有一层,如果我们尝试传递一个dropout值,pytorch将发出一个警告。

关于GRU的另一个注意事项是它只需要并返回一个隐藏状态(hidden state),没有LSTM中的单元状态(cell state)!

从上面的公式看起来RNN和GRU似乎是相同的。 然而,在GRU内部有许多门控机制,它们控制隐藏状态的信息流的进出(类似于LSTM)。 再次,有关更多信息,请查看this优秀的帖子。

编码器的其余部分和上一篇博文介绍的内容相同,它需要输入一个序列,$ X = {x_1,x_2,…,x_T } $,反复计算隐藏状态,$ H = {h_1, h_2,…,h_T } $,并返回上下文向量(最终隐藏状态),$ z = h_T $。

这与一般seq2seq模型的编码器相同,所有“魔法”都发生在GRU内!!!

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
class Encoder(nn.Module):
    def __init__(self, input_dim, emb_dim, hid_dim, dropout):
        super().__init__()
        
        self.input_dim = input_dim
        self.emb_dim = emb_dim
        self.hid_dim = hid_dim
        self.dropout = dropout
        
        self.embedding = nn.Embedding(input_dim, emb_dim) #no dropout as only one layer!
        
        self.rnn = nn.GRU(emb_dim, hid_dim)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, src):
        
        #src = [src sent len, batch size]
        
        embedded = self.dropout(self.embedding(src))
        
        #embedded = [src sent len, batch size, emb dim]
        
        outputs, hidden = self.rnn(embedded) #no cell state!
        
        #outputs = [src sent len, batch size, hid dim * n directions]
        #hidden = [n layers * n directions, batch size, hid dim]
        
        #outputs are always from the top hidden layer
        
        return hidden

Decoder

解码器的实现与以前的模型有很大不同,我们减轻了一些信息压缩。

与上一个模型的解码器中的GRU不同的是,本文实现的模型不仅仅使用目标token$ y_t $和上一步的隐藏状态$ s_ {t-1} $作为输入,它还使用上下文向量(context vector)$ z $。

注意这个上下文向量$ z $如何没有时间$ t $下标,这意味着我们 在解码器中重复使用编码器返回的相同的上下文向量!!!

在此之前,线性连接层$ f $预测下一个token$ \hat{y}_{t + 1} $时,仅使用最顶层解码器的隐藏状态 $ s_t $,即 $ \hat{y} _ {t + 1} = f(s_t ^ L)$。 现在,我们还将当前预测出的token $ \hat {y} _t $和上下文向量$ z $传递给线性连接层。

因此,我们的解码器现在看起来像这样:

decoder

注意,初始隐藏状态$ s_0 $仍然是上下文向量$ z $,因此在生成第一个token时,我们实际上是在GRU中输入两个相同的上下文向量。

这两个变化如何减少信息压缩? 好吧,假设解码器的隐藏状态$ s_t $不再需要包含有关源序列(input sequence)的信息,因为它作为输入始终可以被访问到。 因此,它只需要包含迄今为止生成的token的信息。 向线性层添加$ y_t $也意味着该层可以直接查看token是什么,而无需从隐藏状态获取此信息。

然而,这个假设只是一个假设,不可能确定模型如何实际使用提供给它的信息(不要听任何以不同方式告诉你的人)。 然而这是一个坚实的直觉,结果似乎表明这样的修改是一个好主意!

在具体的实现中,我们通过将$ y_t $和$ z $连接在一起传递给GRU,因此GRU的输入维度现在是emb_dim + hid_dim(因为上下文向量的大小为hid_dim)。 线性层也将$ y_t,s_t $和$ z $连接在一起,因此输入维度现在是emb_dim + hid_dim * 2。 我们也没有将dropout传递给GRU,因为它只使用单个层。

forward现在采用context参数。 在forward中,我们将$ y_t $和$ z $连接成emb_con然后输入GRU,我们将$ y_t $,$ s_t $和$ z $连接成output,然后输入显性连接层去预测下一个token,$ \hat{y}_{t + 1} $。

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
54
55
56
57
class Decoder(nn.Module):
    def __init__(self, output_dim, emb_dim, hid_dim, dropout):
        super().__init__()

        self.emb_dim = emb_dim
        self.hid_dim = hid_dim
        self.output_dim = output_dim
        self.dropout = dropout
        
        self.embedding = nn.Embedding(output_dim, emb_dim)
        
        self.rnn = nn.GRU(emb_dim + hid_dim, hid_dim)
        
        self.out = nn.Linear(emb_dim + hid_dim*2, output_dim)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, input, hidden, context):
        
        #input = [batch size]
        #hidden = [n layers * n directions, batch size, hid dim]
        #context = [n layers * n directions, batch size, hid dim]
        
        #n layers and n directions in the decoder will both always be 1, therefore:
        #hidden = [1, batch size, hid dim]
        #context = [1, batch size, hid dim]
        
        input = input.unsqueeze(0)
        
        #input = [1, batch size]
        
        embedded = self.dropout(self.embedding(input))
        
        #embedded = [1, batch size, emb dim]
                
        emb_con = torch.cat((embedded, context), dim=2)
            
        #emb_con = [1, batch size, emb dim + hid dim]
            
        output, hidden = self.rnn(emb_con, hidden)
        
        #output = [sent len, batch size, hid dim * n directions]
        #hidden = [n layers * n directions, batch size, hid dim]
        
        #sent len, n layers and n directions will always be 1 in the decoder, therefore:
        #output = [1, batch size, hid dim]
        #hidden = [1, batch size, hid dim]
        
        output = torch.cat((embedded.squeeze(0), hidden.squeeze(0), context.squeeze(0)), dim=1)
        
        #output = [batch size, emb dim + hid dim * 2]
        
        prediction = self.out(output)
        
        #prediction = [batch size, output dim]
        
        return prediction, hidden

Seq2Seq Model

将编码器和解码器放在一起,我们得到:

df

同样,在该实现中,我们需要确保编码器和解码器中的隐藏层维度是相同的。

简要介绍所有步骤:

  • 创建outputs张量以保存所有预测,$ \hat{Y} $
  • 源序列$ X $被送入编码器以获取context向量
  • 初始解码器隐藏状态设置为context向量,$ s_0 = z = h_T $
  • 我们使用一批<sos>token作为第一个input,$ y_1 $输入解码器
  • 然后我们在循环中解码:
    • 将输入标记$ y_t $,上一步的隐藏状态,$ s_ {t-1} $和上下文向量$ z $输入入解码器
    • 接收预测,$ \hat {y} _ {t + 1} $,以及新的隐藏状态,$ s_t $
    • 然后我们决定是否要使用teacher forcing,适当地选择下一个输入
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
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!"
        
    def forward(self, src, trg, teacher_forcing_ratio=0.5):
        
        #src = [src sent len, batch size]
        #trg = [trg sent 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]
        max_len = trg.shape[0]
        trg_vocab_size = self.decoder.output_dim
        
        #tensor to store decoder outputs
        outputs = torch.zeros(max_len, batch_size, trg_vocab_size).to(self.device)
        
        #last hidden state of the encoder is the context
        context = self.encoder(src)
        
        #context also used as the initial hidden state of the decoder
        hidden = context
        
        #first input to the decoder is the <sos> tokens
        input = trg[0,:]
        
        for t in range(1, max_len):
            
            output, hidden = self.decoder(input, hidden, context)
            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

Training the Seq2Seq Model

我们初始化我们的编码器,解码器和seq2seq模型(如果有的话,将它放在GPU上)。 如前所述,编码器和解码器之间的嵌入维度和dropout可能不同,但隐藏层的维度(hid_dim)必须保持不变

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
INPUT_DIM = len(SRC.vocab)
OUTPUT_DIM = len(TRG.vocab)
ENC_EMB_DIM = 256
DEC_EMB_DIM = 256
HID_DIM = 512
ENC_DROPOUT = 0.5
DEC_DROPOUT = 0.5

enc = Encoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM, ENC_DROPOUT)
dec = Decoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, DEC_DROPOUT)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

model = Seq2Seq(enc, dec, device).to(device)
optimizer = optim.Adam(model.parameters())

pad_idx = TRG.vocab.stoi['<pad>']
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 = [trg sent len, batch size]
        #output = [trg sent len, batch size, output dim]
        
        output = output[1:].view(-1, output.shape[-1])
        trg = trg[1:].view(-1)
        
        #trg = [(trg sent len - 1) * batch size]
        #output = [(trg sent 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)
    
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 sent len, batch size]
            #output = [trg sent len, batch size, output dim]

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

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

            loss = criterion(output, trg)
            epoch_loss += loss.item()
        
    return epoch_loss / len(iterator)
    
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
SAVE_DIR = 'models'
MODEL_SAVE_PATH = os.path.join(SAVE_DIR, 'tut2_model.pt')

best_valid_loss = float('inf')

if not os.path.isdir(f'{SAVE_DIR}'):
    os.makedirs(f'{SAVE_DIR}')

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(), MODEL_SAVE_PATH)
    
    print(f'| Epoch: {epoch+1:03} | Time: {epoch_mins}m {epoch_secs}s| Train Loss: {train_loss:.3f} | Train PPL: {math.exp(train_loss):7.3f} | Val. Loss: {valid_loss:.3f} | Val. PPL: {math.exp(valid_loss):7.3f} |')
1
2
3
4
5
6
7
8
9
10
| Epoch: 001 | Time: 0m 30s| Train Loss: 4.659 | Train PPL: 105.580 | Val. Loss: 4.357 | Val. PPL:  78.001 |
| Epoch: 002 | Time: 0m 30s| Train Loss: 3.631 | Train PPL:  37.734 | Val. Loss: 3.864 | Val. PPL:  47.652 |
| Epoch: 003 | Time: 0m 30s| Train Loss: 3.182 | Train PPL:  24.101 | Val. Loss: 3.666 | Val. PPL:  39.103 |
| Epoch: 004 | Time: 0m 30s| Train Loss: 2.879 | Train PPL:  17.803 | Val. Loss: 3.602 | Val. PPL:  36.683 |
| Epoch: 005 | Time: 0m 30s| Train Loss: 2.636 | Train PPL:  13.961 | Val. Loss: 3.616 | Val. PPL:  37.190 |
| Epoch: 006 | Time: 0m 29s| Train Loss: 2.442 | Train PPL:  11.495 | Val. Loss: 3.590 | Val. PPL:  36.249 |
| Epoch: 007 | Time: 0m 29s| Train Loss: 2.311 | Train PPL:  10.088 | Val. Loss: 3.540 | Val. PPL:  34.472 |
| Epoch: 008 | Time: 0m 30s| Train Loss: 2.183 | Train PPL:   8.870 | Val. Loss: 3.598 | Val. PPL:  36.530 |
| Epoch: 009 | Time: 0m 31s| Train Loss: 2.090 | Train PPL:   8.081 | Val. Loss: 3.629 | Val. PPL:  37.685 |
| Epoch: 010 | Time: 0m 30s| Train Loss: 2.003 | Train PPL:   7.412 | Val. Loss: 3.649 | Val. PPL:  38.454 |

最后,我们测试我们的模型……

1
2
3
4
5
model.load_state_dict(torch.load(MODEL_SAVE_PATH))

test_loss = evaluate(model, test_iterator, criterion)

print(f'| Test Loss: {test_loss:.3f} | Test PPL: {math.exp(test_loss):7.3f} |')

只看测试损失,我们发现改进的模型取得了更好的表现。 这是一个非常好的迹象,证明这个模型架构正在做正确的事情! 缓解信息压缩似乎是有道理的方式,在下一篇博文中,我们将进一步研究注意力(attention)