Seq2Seq 神经网络学习教程
本文是对教程的部分翻译,并加入了一些自己的理解和归纳(Edit by Kevin)
课程中一些注意点:
- ①Sequence to Sequence Learning with Neural Networks
使用LSTM;上下文向量$z^n = (h_T^n, c_T^n)$
- ②Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation
使用GRU;解码时加入不变的上下文向量(context vector z)
- ③Neural Machine Translation by Jointly Learning to Align and Translate
使用双向RNN(GRU);解码时加入Attention
- ④Packed Padded Sequences, Masking and Inference
- ⑤Convolutional Sequence to Sequence Learning
- ⑥Attention is All You Need
课程一: Basic学习
1.1 概况
在本系列文章中,我们将使用PyTorch和TorchText构建一个机器学习模型,从一个德语序列转换到另一个英语序列。模型可以应用于涉及从一个序列到另一个序列的任何问题,例如翻译、摘要总结。
在第一个笔记本中,我们将通过实现 Sequence to Sequence Learning with Neural Networks来简单地理解一般概念。
最常见的seq2seq模型是 encoder-decoder 模型, 通常使用一个 recurrent neural network (RNN) 去 编码(encode) 输入的源文本 (input) 语句成一个向量。在课程一中, 我们将把这个向量称为上下文向量。我们可以将上下文向量看作是整个输入语句的抽象表示。然后,第二个RNN对这个向量进行 解码。第二个RNN通过每次生成一个单词,来学习去输出目标句子。
上图显示了一个翻译示例。输入的源语句“guten morgen”,一次输入一个单词到绿色的编码器。 我们还分别在句首和句尾追加了一个start of sequence (‘<sos> ‘)和end of sequence (‘ <eos> ‘)令牌(Token)。在每一个时间步, 编码器RNN的输入都是当前单词, , 以及前一个时间步的隐藏状态, 。然后编码器RNN输出一个新的隐藏状态 $h_t$.。你可以把隐藏状态看作是至此句子的所有句的向量表示。这RNN可以表示为 和 的一个函数:
我们通常在这里使用术语RNN,它可以是任何递归架构,比如LSTM (长短时记忆)或 GRU (门控递归单元)。
这里,我们有 , 并且 $x_1 = \text{
最后一个单词, $x_T$, 被传递到RNN后, 我们使用最后的隐藏层状态, $h_T$, 作为上下文文向量(context vector), i.e. 。 这是整个源语句的向量表示。
现在我们有上下文向量, $z$, 我们可以开始解码得到目标句子, “good morning”。 同样,我们将开始和结束序列标记附加到目标语句中。在每一个时间步, 解码器RNN(蓝色)的输入是当前单词, $y_t$, 以及前一个时间步长的隐藏状态, $s_{t-1}$, 其中初始解码器隐藏状态, $s_0$, 是那个上下文向量, $s_0 = z = h_T$, i.e. 初始解码器(decoder)隐藏状态为最终编码器(encoder)隐藏状态.。因此,与编码器类似,我们可以将解码器表示为:
$$s_t = \text{DecoderRNN}(y_t, s_{t-1})$$
在解码器中, 我们需要将隐藏状态变成一个实际的单词,。因此,在每一个时间步我们使用 $s_t$ 去预测 (通过通过一个线性层Linear
layer,用紫色表示) 序列中的下一个单词, $\hat{y}_t$.
$$\hat{y}_t = f(s_t)$$
我们总是使用 <sos>
作为解码器的第一个输入, $y_1$, 但是对于后续的输入, $y_{t>1}$, 我们有时候会用真实的输出单词作为下一个输入单词, $y_t$ 有时候我们使用解码器的输出作为下一个输入单词, $\hat{y}_{t-1}$。这就是所谓的 teacher forcing。
我这里简要概括一下teacher forcing :对于模型的预测输出结果,我们计算错误带来的损失后丢弃此输出,而使用真实的输出单词作为下一个输入单词。你可以在这里了解更多。
当训练或测试模型的时候, 我们总是知道目标句中有多少个单词, 所以一旦我们达到这个数量,我们就停止创造单词。 在inference过程中(i.e. real world usage),通常会一直生成单词,直到模型输出一个“
一旦我们有了预期的目标句, $\hat{Y} = { \hat{y}_1, \hat{y}_2, …, \hat{y}_T }$, 我们将它与我们的实际目标句进行比较, $Y = { y_1, y_2, …, y_T }$, 计算我们的损失(Loss)。然后,我们使用这个损失来更新模型中的所有参数。
1.2 数据处理
我们将在PyTorch中编写模型代码,并使用TorchText帮助我们完成所需的所有预处理。我们还将使用spaCy来辅助数据的标记化。
步骤:
- 为确定性结果设置随机种子
- 使用spaCy创建对应语种的分词器(Tokenizer),用于将句子转为Token序列,e.g. “good morning!” becomes [“good”, “morning”, “!”]。
spacy_en = spacy.load(‘en_core_web_sm’)
spacy_de = spacy.load(‘de_core_news_sm’) - 创建可以传递给TorchText的tokenizer functions来实现分词器功能;这里将输入顺序颠倒,因为实现的论文认为颠倒次序“在数据中引入了许多短期依赖关系,使优化问题变得容易得多”。
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] TorchText的
Field
类处理数据。你可以在这里阅读所有可能的函数参数。SRC = Field(tokenize=tokenize_de, init_token=’
‘, eos_token=’ ‘, lower=True)
TRG = Field(tokenize=tokenize_en, init_token=’‘, eos_token=’ ‘, lower=True) 我们下载并加载训练、验证和测试数据。我们将使用的数据集是Multi30k数据集。这是一个包含约30000个平行的英语、德语和法语句子的数据集,每个句子包含约12个单词。
exts
指定使用哪种语言作为source和target(source goes first),fields
指定使用哪个fields 为 source和target所使用。train_data, valid_data, test_data = Multi30k.splits(exts=(‘.de’, ‘.en’), fields=(SRC, TRG))
为源语言和目标语言分别构建词汇表,使每个Token对应唯一的一个索引,用于构建最终输出的One-hot向量。注:词汇表只从训练集构建,而不是验证集或测试集
SRC.build_vocab(train_data, min_freq=2)
TRG.build_vocab(train_data, min_freq=2)
//min_freq指定最小频数,低于此数,Token使用<unk>
代替创建迭代器,对训练验证测试数据进行迭代,以返回一批具有
src
属性和trg
的数据(数据已使用索引代替Token)paddingBATCH_SIZE = 128
device = torch.device(‘cuda’ if torch.cuda.is_available() else ‘cpu’)
train_iterator, valid_iterator, test_iterator = BucketIterator.splits(
(train_data, valid_data, test_data), batch_size=BATCH_SIZE, device=device)
1.3 构建Seq2Seq模型
我们将分三部分构建模型。编码器、解码器和封装编码器和解码器的seq2seq模型,并将提供一种与它们进行交互的方法。
Encoder
编码器,使用一个2层LSTM构建。
对于多层RNN,输入的句子 $X$ 和隐藏状态 $H={h_1, h_2, …, h_T}$ ,进入多层RNN的底部第一层。该层的输出用作上面层RNN的输入。因此,用上标表示每一层,第一层中的隐藏状态可表示为:
$$h_t^1 = \text{EncoderRNN}^1(x_t, h_{t-1}^1)$$
第二层的隐藏状态可表示为:
$$h_t^2 = \text{EncoderRNN}^2(h_t^1, h_{t-1}^2)$$
使用多层RNN也意味着我们还需要给每层输入一个初始隐藏状态,$h_0^l$,每层也会输出一个上下文向量, $z^l$。
这里不详细介绍LSTMs(如果您想了解关于它们的更多信息,请参阅本文)。但我们需要知道的是,LSTM不仅在每个时间步中接收并返回隐藏状态,还接收并返回单元格状态 cell state, $c_t$:
$$\begin{align}
h_t &= \text{RNN}(x_t, h_{t-1})\
(h_t, c_t) &= \text{LSTM}(x_t, (h_{t-1}, c_{t-1}))
\end{align}$$
你可以将$c_t$看做另一种隐藏层状态。类似于$h_0^l$, $c_0^l$将初始化为一个所有0的张量。同样,我们的上下文向量现在将是最终的隐藏状态和最终的单元格状态,即$z^l = (h_T^l, c_T^l)$
将我们的multi-layer方程推广到LSTMs,得到:
$$\begin{align}
(h_t^1, c_t^1) &= \text{EncoderLSTM}^1(x_t, (h_{t-1}^1, c_{t-1}^1))\
(h_t^2, c_t^2) &= \text{EncoderLSTM}^2(h_t^1, (h_{t-1}^2, c_{t-1}^2))
\end{align}$$
注意!只有来自第一层的隐藏状态作为输入传递到第二层,单元格状态并没有传递到第二层。
我们的编码器是这样的:
这里不对Embedding和Pytorch的LSTM构建展开过多叙述,详细可以查看Pytorch官方文档和原教程
1 | class Encoder(nn.Module): |
Decoder
接下来,我们将构建解码器,解码器也是一个2层LSTM。
解码器只执行一个解码步骤。第一层将接收前一个时间步的隐藏状态和单元格状态$(s_{t-1}^1, c_{t-1}^1)$,并使用当前Token $y_t$ 一起传递给LSTM,生成一个新的隐藏状态和单元格状态$(s_t^1, c_t^1)$。后面的层将使用他们下面一层的隐藏状态$s_t^{l-1}$,还有前面一层的隐藏状态和单元格状态$(s_{t-1}^l, c_{t-1}^l)$。这提供了与编码器中非常相似的方程。
$$\begin{align}
(s_t^1, c_t^1) = \text{DecoderLSTM}^1(y_t, (s_{t-1}^1, c_{t-1}^1))\
(s_t^2, c_t^2) = \text{DecoderLSTM}^2(s_t^1, (s_{t-1}^2, c_{t-1}^2))
\end{align}$$
记住,解码器的初始隐藏状态和单元状态是来自同一层的编码器的最终隐藏状态和单元状态,也叫做上下文向量, i.e.
$$\begin{align}
$(s_0^l,c_0^l)=z^l=(h_T^l,c_T^l)$.
\end{align}$$
然后我们把顶层RNN的隐藏层状态, $s_t^L$,传递给线性层 ,$f$,去预测输出的目标序列的下一个Token是什么,$\hat{y}_{t+1}$。
$$\hat{y}_{t+1} = f(s_t^L)$$
Docoder模型查看Pytorch官方文档和原教程
1 | class Decoder(nn.Module): |
Seq2Seq
实现Seq2seq模型的最后一部分, 我们将要处理以下问题:
- 接收原句输入
- 使用编码器生成上下文向量
- 使用解码器生成预测的输出/目标句
我们的完整模型将是这样的:
注意,我们必须确保在编码器和解码器中层(layer)数和隐藏状态(和单元格)维数相等。不然多层编码器产生的多个上下文向量无法被一一对应接收。
我讲在这部分再回顾一遍整体过程:
模型的forward方法喂入源句、目标句和teacher-forcing比率。在对模型进行训练时,采用了 teacher forcing比率。解码时,在每个时间步我们将预测目标序列中的下一个Token将从以前的令牌解码, $\hat{y}_{t+1}=f(s_t^L)$ 。当概率等于teacher-forcing比率(teacher_forcing_ratio)时,我们将使用序列中的实际ground-truth next Token作为下一个时间步的解码器输入。但是,对于概率为1 - teacher_forced ing_ratio的情况,我们将使用模型预测的Token。
我们在模型forward方法中做的第一件事是创建一个outputs
张量,它将存储我们所有的预测,$\hat{Y}$。
然后给模型Encoder喂入原句, $X$/src
, 接收Encoder的最终输出隐藏层状态(hidden states)和单元状态(cell states)。
解码器的第一个输入是句子的开始—— (<sos>
) token。 由于我们的trg
张量已经附加了<sos>
token(回溯到我们在TRG
Field中定义init_token
的时候),我们通过切片得到了$y_1$。 我们使用 max_len
规定句子长度, 我们循环了很多次。在循环的每次迭代中,我们:
- 喂入输入数据, 输出 ($y_t, s_{t-1}, c_{t-1}$) 进入解码器
- 从解码器接收输出预测、下个隐藏层状态和单元状态 ($\hat{y}{t+1}, s{t}, c_{t}$)
- 把我们的预测结果, $\hat{y}_{t+1}$/
output
放置入存储预测结果集合的张量Tensor中, $\hat{Y}$/outputs
- 决定我们是否要”teacher force” :
- 如果要, 下一个时间步的解码器输入
input
使用序列中的实际ground-truth next Token, $y_{t+1}$/trg[t]
- 如果不要, 下一个时间步的解码器输入
input
使用模型预测的Token, $\hat{y}_{t+1}$/top1
- 如果要, 下一个时间步的解码器输入
一旦我们做出了所有的预测,我们就会返回一个充满预测的张量, $\hat{Y}$/outputs
.
1 | class Seq2Seq(nn.Module): |
训练/测试 模型
此处基本与通常训练步骤相似,不再展开。
注意点:
损失函数只对2d输入、1d输出的起效,所以计算损失前需要使用
.view
降维我们也不想计算序列开头
<sos>
token带来的损失。因此,我们切掉输出和目标张量的第一列
loss = criterion(output[1:].view(-1, output.shape[2]), trg[1:].view(-1))