2021SC@SDUSC
在上一篇博客中,我们分析了model.py中的Decoder类,并对LSTM作了简要的介绍,以实例来说明了构建模型时的各个参数,以及引入了嵌入层。本篇博客我们将从RNN模型的分类入手,分析Seq2Seq模型的框架并分析本篇论文中的Seq2Seq代码。
Encoder-Decoder框架是一个End-to-End学习的算法。简单来说,Seq2Seq(即Sequence to Sequence)以一个Encoder来编码输入的Sequence,再以一个Decoder来输出Sequence。其大致框架是一个序列经过Encoderr得到一个隐状态,再通过这个隐状态使用Decoder得到最终需要的序列。
图1:Seq2Seq模型示例
一、 RNN的分类
接下来我们将由RNN的分类引出Seq2Seq模型。按照输入和输出的结构,可以将RNN分为:
- N vs N - RNN
- N vs 1 - RNN
- 1 vs N - RNN
- N vs M - RNN
1. N vs N - RNN
图2:N vs N - RNN图示
这是RNN最基础的结构形式,其特点是输出和输入序列是等长的,但也由于这个限制的存在,其适用范围比较小,可以用于生成等长的诗句。
2. N vs 1 - RNN
图3:N vs 1 - RNN图示
有时候我们需要处理的问题输入是一个序列,但输出的是一个单独的值而不是序列,我们只需要在隐藏层输出h上进行线性变换就可以了,大部分情况下,为了更好地明确结果,还需要sigmoid或者softmax进行处理,这种结构通常被用在文本分类上。注意,这里的本身已经包含之前上一隐藏层的信息和当前时间步输入的信息,而由依赖于之前隐藏层的信息和输入,所以可以利用所有输入与隐藏层信息。
3. 1 vs N - RNN
图4:1 vs N - RNN图示
当输入的不是序列而输出的是序列时,我们需要将输入作用在每次输出之上,这种结构可以用于将图片生成文字任务。
4. N vs M - RNN
图5:N vs M - RNN图示
这是一种不限制输入输出长度的RNN结构,它由编码器和解码器两部分组成,可以理解为编码器部分是N vs 1 - RNN,解码部分为1 vs N - RNN,这类RNN就被理解为Seq2Seq架构,输入数据首先经过编码器,最终输出一个隐含变量,然后将作用在解码器解码的每一步,以保证输入信息被有效利用。
二、Seq2Seq问题描述
该模型接受句子作为输入,使用Encoder来生成向量,再用Decoder来生成目标语句。将上面两个模块结合起来就得到了整体的模型。在序列到序列处理不定长序列的过程中,采用了序列的起始标志<sos>和终止标志<eos>来“告诉”编码器的编码过程何时开始与结束,也就是间接反映了当前序列的长度信息。这里将结合到我们后面分析的vocabulary构建字典,对于语料库中的所有词,构建一个字典,实现word2idx和idx2word方法,对于语句的开始和结尾,用<sos>和<eos>填充,对于没有出现在字典中的词,用<unk>填充,然后转为idx,再用模型处理。
三、代码分析
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 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
#存储decoder输出的张量
outputs = torch.zeros(trg_len, batch_size, trg_vocab_size).to(self.device)
#encoder最后的隐藏层
context = self.encoder(src)
#encoder最后的隐藏层作为decoder初始的隐藏层
hidden = context
#首先输入decoder的是<sos>
input = trg[0,:]
for t in range(1, trg_len):
#插入嵌入的输入标记,之前的隐藏层状态和context
#接收输出张量和新的隐藏层状态
output, hidden = self.decoder(input, hidden, context)
#将预测放在一个张量中,该张量有每个token的预测
outputs[t] = output
#是否使用teaching_force
teacher_force = random.random() < teacher_forcing_ratio
#从预测中获得最高的预测token
top1 = output.argmax(1)
#使用teaching_force,用实际的下一个token作为输入
#未使用teaching_force,用预测的token
input = trg[t] if teacher_force else top1
return outputs
将源序列x馈入编码器以接收上下文张量,创建输出张量保留所有预测。使用一批<sos>令牌作为第一个输入𝑦1,然后,我们在一个循环中解码,将输入令牌𝑦𝑡,先前的隐藏状态𝑠𝑡−1和上下文向量插入解码器,接收预测𝑡+ 1以及新的隐藏状态𝑠𝑡。
训练模型:
def train(model, iterator, optimizer, criterion, clip):
model.train()
epoch_loss = 0
for i, batch in enumerate(iterator):
# 从批处理中后去源句子和目标句子X,Y
src = batch.src
trg = batch.trg
# 将最后一批计算出的梯度归零
optimizer.zero_grad()
# 将源和目标放入模型中输出下一个y
output = model(src, trg)
#trg = [trg len, batch size]
#output = [trg len, batch size, output dim]
output_dim = output.shape[-1]
# 由于损失函数是仅适用于具有1d目标的2d输入,因此我们用view将其展平输入
# 将输出张量和目标张量的第一列切开
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()
# clip the gradients 防止其梯度爆炸
torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
# 执行优化程序步骤来更新模型的参数
optimizer.step()
# 损失值求和
epoch_loss += loss.item()
# 返回所有批次的平均损失
return epoch_loss / len(iterator)
从批处理中后去源句子和目标句子X,Y,将最后一批计算出的梯度归零,将源和目标放入模型中输出下一个y,计算梯度同时要防止梯度爆炸,损失值求和最后返回所有批次的平均损失。
评估部分代码:
def evaluate(model, iterator, criterion):
# 设置为评估模式,关闭Dropout(弱使用批处理规范化,则也关闭)
model.eval()
epoch_loss = 0
with torch.no_grad():
# 确保该模块内不计算梯度
for i, batch in enumerate(iterator):
src = batch.src
trg = batch.trg
# 必须确保关闭teacher_forcing 参数
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)
计算运行时间:
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),然后在测试集上运行模型
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} |')
四、Attention模型
传统的Encoder-Decoder是有很多弊端的,后来又提出了Attention模型,这种模型在产生输出的时候,还会产生一个“注意力范围”表示接下来输出的时候要重点关注输入序列中的哪些部分,然后根据关注的区域来产生下一个输出,如此往复。模型的大概示意图如下所示。
目前的attention模型可大致分为两类:
- 聚焦式(focus)注意力:自上而下的有意识的注意力,主动注意——是指有预定目的、依赖任务的、主动有意识地聚焦于某一对象的注意力;
- 显著性(saliency-based)注意力:自下而上的有意识的注意力,被动注意——基于显著性的注意力是由外界刺激驱动的注意,不需要主动干预,也和任务无关;