本文基于Pytorch实现,省略细节专注于seq2seq模型的大体框架
介绍
大部分的NLP中的Seq2seq模型都是使用的encoder-decoder框架,即以一个Encoder来编码输入的Sequence,再以一个Decoder来输出Sequence。其中具体的细节会在后面对应的 Encoder 与 Decoder 中展开介绍,
这里只需要知道Seq2seq模型的大致框架是一个序列经过decoder得到一个隐状态,再通过这个隐状态使用decoder得到最终需要的序列。如下图所示为一个德语翻译为英语的文本翻译任务,这个图就很好的展示了seq2seq模型的构造。
Embedding
在说具体模型实现之前,我们需要知道模型需要的输入和输出并非直接是一整个句子,它无法处理这些句子。所以我们就需要帮模型处理好这些句子,模型需要的是sequence,即一个接一个的token。输入每一个黄色方块的就是一个token,不难理解token指得就是一个单词,由于我们的Encoder与Decoder采用的都是RNN,在每一个时间步中只需要一个token,所以这就是为什么我们把句子拆分为多个token。可能你已经注意到了"guten morgen"是一句德语句子,那么开头和结尾的和是什么呢?
这两个token是我们人为规定的,有大用途。由于我们的句子有时候是成批进行输入的,很多个句子都头尾相接的一股脑输入,所以需要用这两个token来区分从哪到哪是一条句子,这是原因之一。除此之外,由于在Decoder中预测句子的时候是一个词一个词预测的,在预测其中一个词的时候只知道前面的词是什么,并不知道后面的词,那预测第一个词的时候怎么办?这时候就以作为第一个词的前一个token。同样的,模型如何知道句子预测完了?也就是如何知道哪个是最后一个词?所以这时候就站出来了。除此之外也还有其他人为规定的token,最常用的就是这个token,意思是unknown,以它来代表不常出现的单词,需要他的原因就是如果每个单词都有一个token的话,那我们可能存不下这么多的token,所以就把罕见词的token表示为来节省空间。
说到这,其实还没说到关键的Embedding部分,只是在上面那个图里的黄色方框之前的输入部分而已,而Embedding的工作就是将token变成那个黄色方块。黄色方块代表的就是一个向量,这个向量的大小应该是[1, emb_size],其中emb_size是嵌入词向量的大小。简单介绍下为什么要这样,因为模型无法接受token的输入,模型的运算只接受向量或者矩阵,所以只能将token转换为向量。而如果直接用one-hot形式的向量(也就是每一个token对应一个类别,如果是这个token这一列的值为1,否则全为0)会导致向量过大,且会出现很多无用的空间(一个向量大部分都是0)。所以进行一次矩阵相乘来压缩向量大小,将这个one-hot向量与一个系数矩阵相乘从而得到更小的向量,这就是Embedding的过程,它的关键就是找到一个好的系数矩阵,来使得到的这个更小的向量能更准确的表示这个token。所以说白了Embedding就是一种表示方式,把token表示为向量。
现在用的Embedding大多都是采用预训练好的Embedding模型来做,例如word2vec和GloVe等(因为自己做太麻烦了,而且这两个模型都是很经典很好用的模型,且他们用的数据量比我们能接触的大得多)。
于是我们终于得到了输入Encoder之前的黄色方块,接下来就要开始真正的第一步———编码。
Encoder
现在我们有输入$X = \{x_1,x_2,...,x_T\}$,就是上一节中讲的token的序列,通过Embedding得到很多个向量,定义$x_t$得到的向量为$e(x_t)$。然后再人为给定一个初始隐状态$h_0$,通常它被初始化为全0,或是一些可训练的参数。
大部分基础的Seq2Seq模型中的Encoder与Decoder都是用的RNN,我们这里也不例外。当然,现在很少人直接用RNN了,一般都采用RNN的变种LSTM或是GRU,这里以RNN笼统的代表诸如这一类的神经网络。
所以每一个时间步的隐状态就是
\[h_t = EncoderRNN(e(x_t),h_{t-1}) \]
那么以上这个公式就是Encoder的核心公式,在我们的模型里,最重要的是得到这个网络训练出来的最后一层隐状态\(h_T\),也就是图中的红色方块\(z\),以供Decoder使用。可以看作是这个模块干的事就是将所有输入的序列信息全部集成到这一个小方块中,来作为后面模块的输出。
本文的实现基于LSTM来进行编码,抛开LSTM的细节不谈,这里需要知道的就是LSTM中有两个传输状态,除了隐状态\(h_t\),还有一个cell state \(c_t\),具体的计算方法这里就不细说了,可以去了解一下LSTM。
所以Encoder的核心公式就变成了
\[(h_t,c_t) = LSTM(e(x_t),h_{t-1},c_{t-1}) \]
抽象地说,就是原本第\(t\)个时间步中的绿色方块本来只有一个状态\(h_t\),现在增加了一个状态\(c_t\)。所以相应的初始状态也会增加一个\(c_0\),于是Encoder就变成了这样:
\[(h_t^1,c_t^1) = EncoderLSTM^1(e(x_t),(h_{t-1}^1,c_{t-1}^1)) \]
\[(h_t^2,c_t^2) = EncoderLSTM^2(h_t^1,(h_{t-1}^2,c_{t-1}^2)) \]
其实两层的计算相似,只是第二层需要的不是输入的embedding,而是第一层的隐状态输出,图上就画的十分直观。
理解了这些原理后,就可以使用torch.nn.Module
来实现我们的Encoder了,具体的解释都写在代码中。
class Encoder(nn.Module):
def __init__(self, input_dim, emb_dim, hid_dim, n_layers, dropout):
super().__init__()
self.hid_dim = hid_dim # hid_dim是hidden和cell状态的维度(即向量的大小)
self.n_layers = n_layers # 指有几层,刚刚说的就是2层的模型
self.embedding = nn.Embedding(input_dim, emb_dim) # input_dim就是输入的维度,也就是将输入的单词转成one-hot向量的向量大小。emb_dim就是进行embedding后的向量大小
self.rnn = nn.LSTM(emb_dim, hid_dim, n_layers, dropout = dropout)
self.dropout = nn.Dropout(dropout) # 是一个正则化参数,用以防止过拟合,在embedding层使用
def forward(self, src):
# src就是就是输入语句,因为实际训练的过程中不是一句一句训练而是一个batch一个batch训练,所以这里batch size就是句子的条数。src的大小就是句子长度*句子条数
#src = [src len, batch size]
embedded = self.dropout(self.embedding(src)) # 源语句embedding后再经过一层drop得到RNN的输入
# 这里多出一维是因为每个单词都变成了一个向量,emb_dim就是向量的大小
#embedded = [src len, batch size, emb dim]
outputs, (hidden, cell) = self.rnn(embedded) # outputs是每一个时间步最顶层的输出结果,如上图中绿色方块最上面输出的全部h_t与c_t,而(hidden, cell)是每一层的最后一个时间步的输出结果z
# 下面维度中的n directions指的是RNN是单向还是双向的,我们这里用的是单向,即默认值1。双向时值为2
#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 # 返回这个是因为decoder中需要的就是最后一个时间步的输出结果z,而不是所有时间步的顶层输出
Decoder
接下来讲讲Decoder。总体上看,它只接受Encoder的一个状态输出,然后输出句子$\hat{Y} = \{\hat{y_1},\hat{y_2},...,\hat{y_t}\}$,这里$y$头上戴个帽子是因为它是预测值,为了与真实值$Y = \{y_1,y_2,...,y_t\}$区分开来。
有了Encoder的经验,不难看懂Decoder的核心公式:
$$s_t = DecoderRNN(d(y_t),s_{t-1})$$
为了区分不同模块的隐状态,这里用$s_t$来代表Decoder中的隐状态。在每一个时间步,Decoder接受当前单词的embedding表示与上一个时间步的隐状态,来计算这个时间步的隐状态,而当前单词的embedding是由上一个时间步生成的。同Encoder一样是RNN模型,所以Decoder也需要一个初始的隐状态,这里不像Encoder全0或随机初始化,它以Encoder最后输出的隐状态来做为它的初始隐状态,这样就可以集成所有输入的信息了。
相似的,我们采用2层LSTM来建造Decoder。其第1层与第2层的计算公式如下:
\[(s_t^1,c_t^1) = DecoderLSTM^1(d(x_t),(s_{t-1}^1,c_{t-1}^1)) \]
\[(s_t^2,c_t^2) = DecoderLSTM^2(s_t^1,(s_{t-1}^2,c_{t-1}^2)) \]
除此之外与Encoder不同的是,由于RNN顶层的输出还是一个隐状态,为了得到预测出的这个位置的词,我们将顶层的隐状态\(s_t^L\)传入一个线性层\(f\),这样就会得到一个所有词在这个位置出现的概率分布,从中选出概率最高的那个词\(\hat{y_{t+1}}\)作为我们预测出的词,即
\[\hat{y_{t+1}} = f(s_t^L) \]
直观地理解,就是将Encoder中的最终隐状态拿来做这里的初始隐状态,然后以作为第一个token来,生成的词作为第二个token,以此进行下去直到生成的token为或者达到指定的长度为止。
根据这些来实现这个Decoder:
class Decoder(nn.Module):
def __init__(self, output_dim, emb_dim, hid_dim, n_layers, dropout):
super().__init__()
self.output_dim = output_dim # 输出的one-hot向量大小,来表示是哪个词
self.hid_dim = hid_dim # hidden和cell状态的维度(即向量的大小)
self.n_layers = n_layers # 指有几层,刚刚说的就是2层的模型
self.embedding = nn.Embedding(output_dim, emb_dim) # 和encoder一样
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]
# context就是Encoder输出的hidden,在这里作为初始隐状态
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
Seq2Seq
最后我们将Encoder与Decoder结合起来,完成我们最终的模型。这个模型接受句子作为输入,使用Encoder来生成context向量,再用Decoder来生成目标语句。将上面两个模块结合起来就得到了整体的模型
在这里还用到了teacher forcing技术,主要是为了更好的训练我们的模型。在Decoder逐词生成句子的时候,我们会设置一个比重,若生成的词概率分布中最大的概率都小于这个比重,说明生成的词严重不正确,我们就强制将其替换成正确的值。就像学生在学习时学到某个地方严重偏离了学习轨迹,教师将其纠正过来以保证后面的学习没有问题,这个技术大概就是这个思想。
但是在我们的模型中用到的teacher forcing技术不同,为了节省操作,我们设置用以预测下一个时间步的词中50%使用目标语句中绝对正确的词,而50%的词使用上一个时间步预测到的词。所以在下面实现代码的teacher_forcing_ratio
这个变量就设置为使用目标语句中绝对正确的词占比。
接下来看实现
class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder, device):
super().__init__()
self.encoder = encoder
self.decoder = decoder
self.device = device # 如果有GPU,device用以将张量放入GPU中计算
# 由于我们这里的题目设计要求我们的encoder和decoder的hidden层的隐状态大小相同,且层数相同,所以写了这两个断言。实际可以依照自己需求略微改变模型
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]
batch_size = trg.shape[1]
trg_len = trg.shape[0]
trg_vocab_size = self.decoder.output_dim
outputs = torch.zeros(trg_len, batch_size, trg_vocab_size).to(self.device) # 存放最终生成的结果
hidden, cell = self.encoder(src) # 使用encoder的最后一个隐状态得到需要的decoder的初始隐状态
input = trg[0,:] # decoder的第一个输入即正确答案的第一个token——<sos>
# 一个时间步一个时间步的循环生成词
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)
outputs[t] = output # 将预测结果放入存放所有预测结果的tensor中
teacher_force = random.random() < teacher_forcing_ratio # 决定是否要使用teacher forcing
top1 = output.argmax(1) # 得到预测词概率分布中概率最大的词
input = trg[t] if teacher_force else top1 # 若不用teacher forcing则使用这次预测到的词作为下一个时间步的input,反之则用正确答案的词
return outputs
总结