1. 序列到序列模型简介
序列到序列( sequence to sequence, seq2seq) 是指输入和输出各为一个序列(如一句话) 的任务。本节将输入序列称作源序列,输出序列称作目标序列。序列到序列有非常多的重要应用, 其中最有名的是机器翻译( machine translation), 机器翻译模型的输入是待翻译语言(源语言) 的文本,输出则是翻译后的语言(目标语言) 的文本。
此外, 序列到序列的应用还有: 改写( paraphrase), 即将输入文本保留原意, 用意思相近的词进行重写; 风格迁移( style transfer), 即转换输入文本的风格(如口语转书面语、负面评价改为正面评价、现代文改为文言文等); 文本摘要( summarization), 即将较长的文本总结为简短精练的短文本; 问答( question answering), 即为用户输入的问题提供回答;对话( dialog),即对用户的输入进行回应。这些都是自然语言处理中非常重要的任务。另外,还有许多任务,尽管它们并非典型的序列到序列任务,但也可以使用序列到序列的方法解决,例如后面章节将要提到的命名实体识别任务,即识别输入句子中的人名、地名等实体,既可以使用后面章节将要介绍的序列标注方法来解决,也可以使用序列到序列方法解决,举例如下:
源序列: 小红在上海旅游。
目标序列:[小红|人名]在[上海|地名]旅游。
类似地,后续章节将要介绍的成分句法分析( constituency parsing)、语义角色标注( semantic role labeling, SRL)、共指消解( coreference resolution) 等任务也都可以使用序列到序列的方法解决。值得一提的是,虽然前面提到的这些任务可以利用序列到序列的方式解决,但是许多情况下效果不如其最常用的方式(如利用序列标注方法解决命名实体识别)。
对于像机器翻译这一类经典的序列到序列任务,采用基于神经网络的方法具有非常大的优势。在早期的相关研究被提出后不久,基于神经网络序列到序列的机器翻译模型效果就迅速提升并超过了更为传统的统计机器翻译模型,成为主流的机器翻译方案。因此,本节主要介绍基于神经网络的序列到序列方法,包括模型、学习和解码。随后介绍序列到序列模型中常用的指针网络与拷贝机制。最后介绍序列到序列任务的一些延伸和扩展。
2. 基于神经网络的序列到序列模型
序列到序列模型与上个章节介绍的语言模型十分类似,都需要在已有文字序列的基础上预测下一个词的概率分布。其区别是,语言模型只需建模一个序列, 而序列到序列模型需要建模两个序列,因此需要包含两个模块:一个编码器用于处理源序列,一个解码器用于生成目标序列。本小节将依次介绍基于循环神经网络、注意力机制以及 Transformer的序列到序列模型。
2.1 循环神经网络
如下图所示, 基于循环神经网络(包括长短期记忆等变体)的序列到序列模型与前几个章节介绍的循环神经网络非常相似,但是按输入不同分成了编码器、解码器两部分, 其中编码器依次接收源序列的词,但不计算任何输出。编码器最后一步的隐状态成为解码器的初始隐状态,这个隐状态向量有时称作上下文向量( context vector),它编码了整个源序列的信息。解码器在第一步接收特殊符号“< sos>”作为目标序列的起始符, 并预测第一个词的概率分布,从中解码出第一个词(解码方法将在下面讨论);随后将第一个词作为下一步的输入, 继续解码第二个词,以此类推, 直到最后解码出终止符“< oos>”,意味着目标序列已解码完毕。这种方式即前面所介绍的自回归过程。
下面介绍基于循环神经网络的编码器和解码器的代码实现。首先是作为编码器的循环神经网络。
import torch
import torch.nn as nn
class RNNEncoder(nn.Module):
def __init__(self, vocab_size, hidden_size):
super(RNNEncoder, self).__init__()
# 隐层大小
self.hidden_size = hidden_size
# 词表大小
self.vocab_size = vocab_size
# 词嵌入层
self.embedding = nn.Embedding(self.vocab_size,\
self.hidden_size)
self.gru = nn.GRU(self.hidden_size, self.hidden_size,\
batch_first=True)
def forward(self, inputs):
# inputs: batch * seq_len
# 注意门控循环单元使用batch_first=True,因此输入需要至少batch为1
features = self.embedding(inputs)
output, hidden = self.gru(features)
return output, hidden
接下来是作为解码器的另一个循环神经网络的代码实现。
class RNNDecoder(nn.Module):
def __init__(self, vocab_size, hidden_size):
super(RNNDecoder, self).__init__()
self.hidden_size = hidden_size
self.vocab_size = vocab_size
# 序列到序列任务并不限制编码器和解码器输入同一种语言,
# 因此解码器也需要定义一个嵌入层
self.embedding = nn.Embedding(self.vocab_size, self.hidden_size)
self.gru = nn.GRU(self.hidden_size, self.hidden_size,\
batch_first=True)
# 用于将输出的隐状态映射为词表上的分布
self.linear = nn.Linear(self.hidden_size, self.vocab_size)
# 解码整个序列
def forward(self, encoder_outputs, encoder_hidden, target_tensor=None):
batch_size = encoder_outputs.size(0)
# 从<sos>开始解码
decoder_input = torch.empty(batch_size, 1,\
dtype=torch.long).fill_(SOS_token)
decoder_hidden = encoder_hidden
decoder_outputs = []
# 如果目标序列确定,最大解码步数确定;
# 如果目标序列不确定,解码到最大长度
if target_tensor is not None:
seq_length = target_tensor.size(1)
else:
seq_length = MAX_LENGTH
# 进行seq_length次解码
for i in range(seq_length):
# 每次输入一个词和一个隐状态
decoder_output, decoder_hidden = self.forward_step(\
decoder_input, decoder_hidden)
decoder_outputs.append(decoder_output)
if target_tensor is not None:
# teacher forcing: 使用真实目标序列作为下一步的输入
decoder_input = target_tensor[:, i].unsqueeze(1)
else:
# 从当前步的输出概率分布中选取概率最大的预测结果
# 作为下一步的输入
_, topi = decoder_output.topk(1)
# 使用detach从当前计算图中分离,避免回传梯度
decoder_input = topi.squeeze(-1).detach()
decoder_outputs = torch.cat(decoder_outputs, dim=1)
decoder_outputs = F.log_softmax(decoder_outputs, dim=-1)
# 为了与AttnRNNDecoder接口保持统一,最后输出None
return decoder_outputs, decoder_hidden, None
# 解码一步
def forward_step(self, input, hidden):
output = self.embedding(input)
output = F.relu(output)
output, hidden = self.gru(output, hidden)
output = self.out(output)
return output, hidden
2.2 注意力机制
在序列到序列循环神经网络上加入注意力机制的方式同样与上一章节介绍的方式非常相似,区别在于,注意力机制在这里仅用于解码时建模从目标序列到源序列的依赖关系。具体而言,在解码的每一步,将解码器输出的隐状态特征作为查询,将编码器计算的源序列中每个元素的隐状态特征作为键和值,从而计算注意力输出向量; 这个输出向量会与解码器当前步骤的隐状态特征一起用于预测目标序列的下一个元素。
序列到序列中的注意力机制使得解码器能够直接“看到”源序列,而不再仅依赖循环神经网络的隐状态传递源序列的信息。此外,注意力机制提供了一种类似于人类处理此类任务时的序列到序列机制。人类在进行像翻译这样的序列到序列任务时,常常会边看源句边进行翻译,而不是一次性读完源句之后记住它再翻译, 而注意力机制模仿了这个过程。最后,注意力机制为序列到序列模型提供了一些可解释性: 通过观察注意力分布,可以知道解码器生成每个词时在注意源句中的哪些词,这可以看作源句和目标句之间的一种“软性”对齐。
下面介绍基于注意力机制的循环神经网络解码器的代码实现。我们使用一个注意力层来计算注意力权重,其输入为解码器的输入和隐状态。这里使用 Bahdanau注意力( Bahdanau attention) , 这是序列到序列模型中应用最广泛的注意力机制,特别是对于机器翻译任务。该注意力机制使用一个对齐模型( alignment model) 来计算编码器和解码器隐状态之间的注意力分数,具体来讲就是一个前馈神经网络。相比于点乘注意力, Bahdanau注意力利用了非线性变换。
import torch.nn.functional as F
class BahdanauAttention(nn.Module):
def __init__(self, hidden_size):
super(BahdanauAttention, self).__init__()
self.Wa = nn.Linear(hidden_size, hidden_size)
self.Ua = nn.Linear(hidden_size, hidden_size)
self.Va = nn.Linear(hidden_size, 1)
def forward(self, query, keys):
# query: batch * 1 * hidden_size
# keys: batch * seq_length * hidden_size
# 这一步用到了广播(broadcast)机制
scores = self.Va(torch.tanh(self.Wa(query) + self.Ua(keys)))
scores = scores.squeeze(2).unsqueeze(1)
weights = F.softmax(scores, dim=-1)
context = torch.bmm(weights, keys)
return context, weights
class AttnRNNDecoder(nn.Module):
def __init__(self, vocab_size, hidden_size):
super(AttnRNNDecoder, self).__init__()
self.hidden_size = hidden_size
self.vocab_size = vocab_size
self.embedding = nn.Embedding(self.vocab_size, self.hidden_size)
self.attention = BahdanauAttention(hidden_size)
# 输入来自解码器输入和上下文向量,因此输入大小为2 * hidden_size
self.gru = nn.GRU(2 * self.hidden_size, self.hidden_size,\
batch_first=True)
# 用于将注意力的结果映射为词表上的分布
self.out = nn.Linear(self.hidden_size, self.vocab_size)
# 解码整个序列
def forward(self, encoder_outputs, encoder_hidden, target_tensor=None):
batch_size = encoder_outputs.size(0)
# 从<sos>开始解码
decoder_input = torch.empty(batch_size, 1, dtype=\
torch.long).fill_(SOS_token)
decoder_hidden = encoder_hidden
decoder_outputs = []
attentions = []
# 如果目标序列确定,最大解码步数确定;
# 如果目标序列不确定,解码到最大长度
if target_tensor is not None:
seq_length = target_tensor.size(1)
else:
seq_length = MAX_LENGTH
# 进行seq_length次解码
for i in range(seq_length):
# 每次输入一个词和一个隐状态
decoder_output, decoder_hidden, attn_weights = \
self.forward_step(
decoder_input, decoder_hidden, encoder_outputs
)
decoder_outputs.append(decoder_output)
attentions.append(attn_weights)
if target_tensor is not None:
# teacher forcing: 使用真实目标序列作为下一步的输入
decoder_input = target_tensor[:, i].unsqueeze(1)
else:
# 从当前步的输出概率分布中选取概率最大的预测结果
# 作为下一步的输入
_, topi = decoder_output.topk(1)
# 使用detach从当前计算图中分离,避免回传梯度
decoder_input = topi.squeeze(-1).detach()
decoder_outputs = torch.cat(decoder_outputs, dim=1)
decoder_outputs = F.log_softmax(decoder_outputs, dim=-1)
attentions = torch.cat(attentions, dim=1)
# 与RNNDecoder接口保持统一,最后输出注意力权重
return decoder_outputs, decoder_hidden, attentions
# 解码一步
def forward_step(self, input, hidden, encoder_outputs):
embeded = self.embedding(input)
# 输出的隐状态为1 * batch * hidden_size,
# 注意力的输入需要batch * 1 * hidden_size
query = hidden.permute(1, 0, 2)
context, attn_weights = self.attention(query, encoder_outputs)
input_gru = torch.cat((embeded, context), dim=2)
# 输入的隐状态需要1 * batch * hidden_size
output, hidden = self.gru(input_gru, hidden)
output = self.out(output)
return output, hidden, attn_weights
2.3 Transformer
Transformer模型同样也可以用于序列到序列任务。编码器与上一章介绍的 Transformer结构几乎相同,仅有两方面区别。一方面,由于不需要像语言模型那样每一步只能看到前置序列,而是需要看到完整的句子, 因此掩码多头自注意力模块中去除了注意力掩码。另一方面,由于编码器不需要输出,因此去掉了顶层的线性分类器。
解码器同样与上一章介绍的 Transformer结构几乎相同,但在掩码多头自注意力模块之后增加了一个交叉多头注意力模块,以便在解码时引入编码器所计算的源序列的信息。交叉注意力模块的设计与上面介绍的循环神经网络上的注意力机制是类似的。具体而言,交叉注意力模块使用解码器中自注意力模块的输出计算查询,使用编码器顶端的输出计算键和值,不使用任何注意力掩码, 其他部分与自注意力模块一样。
基于 Transformer的序列到序列模型通常也使用自回归的方式进行解码, 但 Transformer不同位置之间的并行性,使得非自回归方式的解码成为可能。非自回归解码器的结构与自回归解码器类似,但解码时需要先预测目标句的长度,将该长度对应个数的特殊符号作为输入,此外自注意力模块不需要掩码,所有位置的计算并行执行。有关具体细节这里不再展开。
接下来我们复用上一章的代码,实现基于 Transformer的编码器和解码器。
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import sys
sys.path.append('./code')
from transformer import *
class TransformerEncoder(nn.Module):
def __init__(self, vocab_size, max_len, hidden_size, num_heads,\
dropout, intermediate_size):
super().__init__()
self.embedding_layer = EmbeddingLayer(vocab_size, max_len,\
hidden_size)
# 直接使用TransformerLayer作为编码层,简单起见只使用一层
self.layer = TransformerLayer(hidden_size, num_heads,\
dropout, intermediate_size)
# 与TransformerLM不同,编码器不需要线性层用于输出
def forward(self, input_ids):
# 这里实现的forward()函数一次只能处理一句话,
# 如果想要支持批次运算,需要根据输入序列的长度返回隐状态
assert input_ids.ndim == 2 and input_ids.size(0) == 1
seq_len = input_ids.size(1)
assert seq_len <= self.embedding_layer.max_len
# 1 * seq_len
pos_ids = torch.unsqueeze(torch.arange(seq_len), dim=0)
attention_mask = torch.ones((1, seq_len), dtype=torch.int32)
input_states = self.embedding_layer(input_ids, pos_ids)
hidden_states = self.layer(input_states, attention_mask)
return hidden_states, attention_mask
class MultiHeadCrossAttention(MultiHeadSelfAttention):
def forward(self, tgt, tgt_mask, src, src_mask):
"""
tgt: query, batch_size * tgt_seq_len * hidden_size
tgt_mask: batch_size * tgt_seq_len
src: keys/values, batch_size * src_seq_len * hidden_size
src_mask: batch_size * src_seq_len
"""
# (batch_size * num_heads) * seq_len * (hidden_size / num_heads)
queries = self.transpose_qkv(self.W_q(tgt))
keys = self.transpose_qkv(self.W_k(src))
values = self.transpose_qkv(self.W_v(src))
# 这一步与自注意力不同,计算交叉掩码
# batch_size * tgt_seq_len * src_seq_len
attention_mask = tgt_mask.unsqueeze(2) * src_mask.unsqueeze(1)
# 重复张量的元素,用以支持多个注意力头的运算
# (batch_size * num_heads) * tgt_seq_len * src_seq_len
attention_mask = torch.repeat_interleave(attention_mask,\
repeats=self.num_heads, dim=0)
# (batch_size * num_heads) * tgt_seq_len * \
# (hidden_size / num_heads)
output = self.attention(queries, keys, values, attention_mask)
# batch * tgt_seq_len * hidden_size
output_concat = self.transpose_output(output)
return self.W_o(output_concat)
# TransformerDecoderLayer比TransformerLayer多了交叉多头注意力
class TransformerDecoderLayer(nn.Module):
def __init__(self, hidden_size, num_heads, dropout,\
intermediate_size):
super().__init__()
self.self_attention = MultiHeadSelfAttention(hidden_size,\
num_heads, dropout)
self.add_norm1 = AddNorm(hidden_size, dropout)
self.enc_attention = MultiHeadCrossAttention(hidden_size,\
num_heads, dropout)
self.add_norm2 = AddNorm(hidden_size, dropout)
self.fnn = PositionWiseFNN(hidden_size, intermediate_size)
self.add_norm3 = AddNorm(hidden_size, dropout)
def forward(self, src_states, src_mask, tgt_states, tgt_mask):
# 掩码多头自注意力
tgt = self.add_norm1(tgt_states, self.self_attention(\
tgt_states, tgt_states, tgt_states, tgt_mask))
# 交叉多头自注意力
tgt = self.add_norm2(tgt, self.enc_attention(tgt,\
tgt_mask, src_states, src_mask))
# 前馈神经网络
return self.add_norm3(tgt, self.fnn(tgt))
class TransformerDecoder(nn.Module):
def __init__(self, vocab_size, max_len, hidden_size, num_heads,\
dropout, intermediate_size):
super().__init__()
self.embedding_layer = EmbeddingLayer(vocab_size, max_len,\
hidden_size)
# 简单起见只使用一层
self.layer = TransformerDecoderLayer(hidden_size, num_heads,\
dropout, intermediate_size)
# 解码器与TransformerLM一样,需要输出层
self.output_layer = nn.Linear(hidden_size, vocab_size)
def forward(self, src_states, src_mask, tgt_tensor=None):
# 确保一次只输入一句话,形状为1 * seq_len * hidden_size
assert src_states.ndim == 3 and src_states.size(0) == 1
if tgt_tensor is not None:
# 确保一次只输入一句话,形状为1 * seq_len
assert tgt_tensor.ndim == 2 and tgt_tensor.size(0) == 1
seq_len = tgt_tensor.size(1)
assert seq_len <= self.embedding_layer.max_len
else:
seq_len = self.embedding_layer.max_len
decoder_input = torch.empty(1, 1, dtype=torch.long).\
fill_(SOS_token)
decoder_outputs = []
for i in range(seq_len):
decoder_output = self.forward_step(decoder_input,\
src_mask, src_states)
decoder_outputs.append(decoder_output)
if tgt_tensor is not None:
# teacher forcing: 使用真实目标序列作为下一步的输入
decoder_input = torch.cat((decoder_input,\
tgt_tensor[:, i:i+1]), 1)
else:
# 从当前步的输出概率分布中选取概率最大的预测结果
# 作为下一步的输入
_, topi = decoder_output.topk(1)
# 使用detach从当前计算图中分离,避免回传梯度
decoder_input = torch.cat((decoder_input,\
topi.squeeze(-1).detach()), 1)
decoder_outputs = torch.cat(decoder_outputs, dim=1)
decoder_outputs = F.log_softmax(decoder_outputs, dim=-1)
# 与RNNDecoder接口保持统一
return decoder_outputs, None, None
# 解码一步,与RNNDecoder接口略有不同,RNNDecoder一次输入
# 一个隐状态和一个词,输出一个分布、一个隐状态
# TransformerDecoder不需要输入隐状态,
# 输入整个目标端历史输入序列,输出一个分布,不输出隐状态
def forward_step(self, tgt_inputs, src_mask, src_states):
seq_len = tgt_inputs.size(1)
# 1 * seq_len
pos_ids = torch.unsqueeze(torch.arange(seq_len), dim=0)
tgt_mask = torch.ones((1, seq_len), dtype=torch.int32)
tgt_states = self.embedding_layer(tgt_inputs, pos_ids)
hidden_states = self.layer(src_states, src_mask, tgt_states,\
tgt_mask)
output = self.output_layer(hidden_states[:, -1:, :])
return output
3. 学习
序列到序列模型可以看成一种条件语言模型,以源句x为条件计算目标句的条件概率该条件概率通过概率乘法公式分解为从左到右每个词的条件概率之积:
序列到序列模型的监督学习需要使用平行语料,其中每个数据点都包含一对源句和目标句。以中译英机器翻译为例,平行语料的每个数据点就是一句中文句子和对应的一句英文句子。机器翻译领域较为有名的平行语料库来自机器翻译研讨会( workshop on machine translation, WMT), 其中的语料来自新闻、维基百科、小说等各种领域。给定平行语料中的每个数据点, 我们希望最大化条件似然, 即最小化以下损失函数:
其中, y* 表示平行语料中源句x对应的目标句。
训练序列到序列模型的常用方法为教师强制( teacher forcing), 即使用真实的目标序列作为解码器的输入,而不是像解码时那样使用解码器每一步的预测作为下一步的输入。教师强制会使训练过程更稳定且收敛更快,但是也会产生所谓曝光偏差( exposurebias) 的不利影响,即模型在训练时只见过正确输入,因而当解码时前置步骤出现了不正确的预测时模型后续的预测都会变得不准确。
下面以机器翻译(中译英) 为例展示如何训练序列到序列模型。这里使用的是中英文 Books数据,其中中文标题来源于前面所使用的数据集, 英文标题是使用已训练好的机器翻译模型从中文标题翻译而得,因此该数据并不保证准确性,仅用于演示。
首先需要对源语言和目标语言分别建立索引, 并记录词频。
SOS_token = 0
EOS_token = 1
class Lang:
def __init__(self, name):
self.name = name
self.word2index = {}
self.word2count = {}
self.index2word = {0: "<sos>", 1: "<eos>"}
self.n_words = 2 # Count SOS and EOS
def addSentence(self, sentence):
for word in sentence.split(' '):
self.addWord(word)
def addWord(self, word):
if word not in self.word2index:
self.word2index[word] = self.n_words
self.word2count[word] = 1
self.index2word[self.n_words] = word
self.n_words += 1
else:
self.word2count[word] += 1
def sent2ids(self, sent):
return [self.word2index[word] for word in sent.split(' ')]
def ids2sent(self, ids):
return ' '.join([self.index2word[idx] for idx in ids])
import unicodedata
import string
import re
import random
# 文件使用unicode编码,我们将unicode转为ASCII,转为小写,并修改标点
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn'
)
def normalizeString(s):
s = unicodeToAscii(s.lower().strip())
# 在标点前插入空格
s = re.sub(r"([,.!?])", r" \1", s)
return s.strip()
# 读取文件,一共有两个文件,两个文件的同一行对应一对源语言和目标语言句子
def readLangs(lang1, lang2):
# 读取文件,分句
lines1 = open(f'{lang1}.txt', encoding='utf-8').read()\
.strip().split('\n')
lines2 = open(f'{lang2}.txt', encoding='utf-8').read()\
.strip().split('\n')
print(len(lines1), len(lines2))
# 规范化
lines1 = [normalizeString(s) for s in lines1]
lines2 = [normalizeString(s) for s in lines2]
if lang1 == 'zh':
lines1 = [' '.join(list(s.replace(' ', ''))) for s in lines1]
if lang2 == 'zh':
lines2 = [' '.join(list(s.replace(' ', ''))) for s in lines2]
pairs = [[l1, l2] for l1, l2 in zip(lines1, lines2)]
input_lang = Lang(lang1)
output_lang = Lang(lang2)
return input_lang, output_lang, pairs
# 为了快速训练,过滤掉一些过长的句子
MAX_LENGTH = 30
def filterPair(p):
return len(p[0].split(' ')) < MAX_LENGTH and \
len(p[1].split(' ')) < MAX_LENGTH
def filterPairs(pairs):
return [pair for pair in pairs if filterPair(pair)]
def prepareData(lang1, lang2):
input_lang, output_lang, pairs = readLangs(lang1, lang2)
print(f"读取 {len(pairs)} 对序列")
pairs = filterPairs(pairs)
print(f"过滤后剩余 {len(pairs)} 对序列")
print("统计词数")
for pair in pairs:
input_lang.addSentence(pair[0])
output_lang.addSentence(pair[1])
print(input_lang.name, input_lang.n_words)
print(output_lang.name, output_lang.n_words)
return input_lang, output_lang, pairs
input_lang, output_lang, pairs = prepareData('zh', 'en')
print(random.choice(pairs))
2157 2157
读取 2157 对序列
过滤后剩余 2003 对序列
统计词数
zh 1368
en 3287
['鸿 衣 赋 古 风 创 意 造 型 与 摄 影 集', "♪ the ancient wind's creative form and photo collection ♪"]
为了便于训练,对每一对源-目标句子需要准备一个源张量(源句子的词元索引)和一个目标张量(目标句子的词元索引)。在两个句子的末尾会添加“\<eos\>”。
def get_train_data():
input_lang, output_lang, pairs = prepareData('zh', 'en')
train_data = []
for idx, (src_sent, tgt_sent) in enumerate(pairs):
src_ids = input_lang.sent2ids(src_sent)
tgt_ids = output_lang.sent2ids(tgt_sent)
# 添加<eos>
src_ids.append(EOS_token)
tgt_ids.append(EOS_token)
train_data.append([src_ids, tgt_ids])
return input_lang, output_lang, train_data
input_lang, output_lang, train_data = get_train_data()
2157 2157
读取 2157 对序列
过滤后剩余 2003 对序列
统计词数
zh 1368
en 3287
接下来是训练代码。
from tqdm import trange
import matplotlib.pyplot as plt
from torch.optim import Adam
import numpy as np
# 训练序列到序列模型
def train_seq2seq_mt(train_data, encoder, decoder, epochs=20,\
learning_rate=1e-3):
# 准备模型和优化器
encoder_optimizer = Adam(encoder.parameters(), lr=learning_rate)
decoder_optimizer = Adam(decoder.parameters(), lr=learning_rate)
criterion = nn.NLLLoss()
encoder.train()
decoder.train()
encoder.zero_grad()
decoder.zero_grad()
step_losses = []
plot_losses = []
with trange(n_epochs, desc='epoch', ncols=60) as pbar:
for epoch in pbar:
np.random.shuffle(train_data)
for step, data in enumerate(train_data):
# 将源序列和目标序列转为 1 * seq_len 的tensor
# 这里为了简单实现,采用了批次大小为1,
# 当批次大小大于1时,编码器需要进行填充
# 并且返回最后一个非填充词的隐状态,
# 解码也需要进行相应的处理
input_ids, target_ids = data
input_tensor, target_tensor = \
torch.tensor(input_ids).unsqueeze(0),\
torch.tensor(target_ids).unsqueeze(0)
encoder_optimizer.zero_grad()
decoder_optimizer.zero_grad()
encoder_outputs, encoder_hidden = encoder(input_tensor)
# 输入目标序列用于teacher forcing训练
decoder_outputs, _, _ = decoder(encoder_outputs,\
encoder_hidden, target_tensor)
loss = criterion(
decoder_outputs.view(-1, decoder_outputs.size(-1)),
target_tensor.view(-1)
)
pbar.set_description(f'epoch-{epoch}, '+\
f'loss={loss.item():.4f}')
step_losses.append(loss.item())
# 实际训练批次为1,训练损失波动过大
# 将多步损失求平均可以得到更平滑的训练曲线,便于观察
plot_losses.append(np.mean(step_losses[-32:]))
loss.backward()
encoder_optimizer.step()
decoder_optimizer.step()
plot_losses = np.array(plot_losses)
plt.plot(range(len(plot_losses)), plot_losses)
plt.xlabel('training step')
plt.ylabel('loss')
plt.show()
hidden_size = 128
n_epochs = 20
learning_rate = 1e-3
encoder = RNNEncoder(input_lang.n_words, hidden_size)
decoder = AttnRNNDecoder(output_lang.n_words, hidden_size)
train_seq2seq_mt(train_data, encoder, decoder, n_epochs, learning_rate)
epoch-19, loss=0.0047: 100%|█| 20/20 [46:11<00:00, 138.55s/i
上面实现的基于循环神经网络和基于 Transformer的编码器和解码器具有相似的接口,大家可以尝试更换编码器和解码器,训练基于 Transformer的序列到序列模型, 此处不再重复展示。
4. 解码
这里介绍主流的贪心解码和束搜索解码方法。
4.1 贪心解码
在解码过程中,需要根据解码器所计算的词的概率分布一步步(自回归)地生成词。理想情况下我们希望找到概率最大的目标句子,但这无疑是很困难的, 因为所有可能的序列数量呈指数级增长, 并且由于词之间不存在条件独立性,因此不存在可求解的多项式复杂度算法。一个很简单的近似解决方式是贪心解码,即每步取概率最大的词。 然而,这种方式存在所谓错误累积问题,如下面这个例子所示。
输入: I love Natural Language Processing。
解码第1步: 我_。
解码第2步: 我爱_。
解码第3步: 我爱天然_。
解码第4步: 我爱天然语_。
解码第5步: 我爱天然语加工。
在解码第3步,模型错误地将“ Natural”翻译成了“天然”,而在贪心解码中一旦模型输出一个词,就再也无法回滚和修改。更糟糕的是,“天然”这个词会被作为后续解码的条件,从而有可能让模型产生新的错误,然后这些错误又会引发更多错误,最终导致输出低质量的目标序列。
下面的代码演示如何使用贪心解码对模型进行验证。评估与训练类似,但是评估时不提供目标句子作为输入, 因此需要将解码器每一步的输出作为下一步的输入, 当预测到“< eos>”时停止。我们也可以存储解码器的注意力输出以用于分析和展示。
def greedy_decode(encoder, decoder, sentence, input_lang, output_lang):
with torch.no_grad():
# 将源序列转为 1 * seq_length 的tensor
input_ids = input_lang.sent2ids(sentence)
input_tensor = torch.tensor(input_ids).unsqueeze(0)
encoder_outputs, encoder_hidden = encoder(input_tensor)
decoder_outputs, decoder_hidden, decoder_attn = \
decoder(encoder_outputs, encoder_hidden)
# 取出每一步预测概率最大的词
_, topi = decoder_outputs.topk(1)
decoded_ids = []
for idx in topi.squeeze():
if idx.item() == EOS_token:
break
decoded_ids.append(idx.item())
return output_lang.ids2sent(decoded_ids), decoder_attn
encoder.eval()
decoder.eval()
for i in range(5):
pair = random.choice(pairs)
print('input:', pair[0])
print('target:', pair[1])
output_sentence, _ = greedy_decode(encoder, decoder, pair[0],
input_lang, output_lang)
print('pred:', output_sentence)
print('')
input: 商 业 分 析 方 法 与 案 例 超 越 报 表 的 商 业 智 能 ( 第 2 版 )
target: business analysis methods and cases business intelligence beyond reporting (version 2)
pred: business analysis , methods and cases business intelligence beyond reporting (version 2)
input: 精 解 w i n d o w s 1 0
target: precision windows10
pred: precision windows10 precision windows10 2016 precision 2016 solidworks 2018 chinese precision windows10 precision windows10 2016 solidworks 2018 chinese precision windows10 precision windows10 2016 solidworks 2018 chinese precision windows10 precision windows10
input: k a f k a 入 门 与 实 践
target: kafka introduction and practice
pred: kafka introduction and practice
input: 长 期 价 值 投 资 如 何 稳 健 地 积 累 财 富 ( 签 名 版 )
target: long-term value investment how to build wealth safely (signed version)
pred: long-term value investment how to build wealth safely (signed version)
input: j r o c k i t 权 威 指 南 深 入 理 解 j v m
target: jrockit's authoritative guide to an in-depth understanding of jvm .
pred: jrockit's authoritative guide to an in-depth understanding of jvm .
在这个演示中,训练数据太少, 模型也很小, 所以贪心解码的效果不太好。
4.2 束搜索解码
束搜索解码可以缓解贪心解码的问题。在束搜索解码中,每一步都会保留k个优选的候选结果,其中k被称为束宽。具体而言,在每一步,我们会将上一步保留的k个候选结果中的每一个作为条件,生成k个当前步骤概率最大的词, 从而得到k²个新的候选结果,再从中优选k个予以保留。候选结果之间的比较是基于当前已解码序列的概率对数:
束搜索解码如何判断终止条件呢? 一旦贪心解码解码出终止符“< eos>”就终止解码。然而在束搜索解码中,不同的解码序列可能会在不同的时刻输出终止符“<cos>”,因此, 当一个解码序列预测了终止符“< eos>”时并不会终止整个解码过程,而只是终止这一个解码序列并继续其他解码序列, 直到满足以下两个条件之一:
解码达到了时间步上线 T;
已经有n个解码序列终止。
这里T和n均为预定义的超参数。解码终止后,我们得到了最多n个已终止的解码序列,如何从中选择最终输出的目标序列呢?一个很直接的想法是选择概率最高的解码序列。但需要注意的是,由于越长的句子需要对更多的词的概率求积,因此概率往往越低,这导致单纯依据序列概率会趋向于选择更短的句子,但很多情况下短句并不一定是最好的选择。为了缓解这个问题,可以使用词的平均对数概率来选择最终输出的目标序列:
虽然束搜索解码仍然无法保证最终预测是最优解,甚至无法保证一定优于贪心解码, 但是它的效果好于贪心解码,因为它考虑了更多可能的目标序列。贪心解码其实可以看作k=1的束搜索解码。
接下来使用束搜索解码来验证模型。
# 定义容器类用于管理所有的候选结果
class BeamHypotheses:
def __init__(self, num_beams, max_length):
self.max_length = max_length
self.num_beams = num_beams
self.beams = []
self.worst_score = 1e9
def __len__(self):
return len(self.beams)
# 添加一个候选结果,更新最差得分
def add(self, sum_logprobs, hyp, hidden):
score = sum_logprobs / max(len(hyp), 1)
if len(self) < self.num_beams or score > self.worst_score:
# 可更新的情况:数量未饱和或超过最差得分
self.beams.append((score, hyp, hidden))
if len(self) > self.num_beams:
# 数量饱和需要删掉一个最差的
sorted_scores = sorted([(s, idx) for idx,\
(s, _, _) in enumerate(self.beams)])
del self.beams[sorted_scores[0][1]]
self.worst_score = sorted_scores[1][0]
else:
self.worst_score = min(score, self.worst_score)
# 取出一个未停止的候选结果,第一个返回值表示是否成功取出,
# 如成功,则第二个值为目标候选结果
def pop(self):
if len(self) == 0:
return False, None
for i, (s, hyp, hid) in enumerate(self.beams):
# 未停止的候选结果需满足:长度小于最大解码长度;不以<eos>结束
if len(hyp) < self.max_length and (len(hyp) == 0\
or hyp[-1] != EOS_token):
del self.beams[i]
if len(self) > 0:
sorted_scores = sorted([(s, idx) for idx,\
(s, _, _) in enumerate(self.beams)])
self.worst_score = sorted_scores[0][0]
else:
self.worst_score = 1e9
return True, (s, hyp, hid)
return False, None
# 取出分数最高的候选结果,第一个返回值表示是否成功取出,
# 如成功,则第二个值为目标候选结果
def pop_best(self):
if len(self) == 0:
return False, None
sorted_scores = sorted([(s, idx) for idx, (s, _, _)\
in enumerate(self.beams)])
return True, self.beams[sorted_scores[-1][1]]
def beam_search_decode(encoder, decoder, sentence, input_lang,
output_lang, num_beams=3):
with torch.no_grad():
# 将源序列转为 1 * seq_length 的tensor
input_ids = input_lang.sent2ids(sentence)
input_tensor = torch.tensor(input_ids).unsqueeze(0)
# 在容器中插入一个空的候选结果
encoder_outputs, encoder_hidden = encoder(input_tensor)
init_hyp = []
hypotheses = BeamHypotheses(num_beams, MAX_LENGTH)
hypotheses.add(0, init_hyp, encoder_hidden)
while True:
# 每次取出一个未停止的候选结果
flag, item = hypotheses.pop()
if not flag:
break
score, hyp, decoder_hidden = item
# 当前解码器输入
if len(hyp) > 0:
decoder_input = torch.empty(1, 1,\
dtype=torch.long).fill_(hyp[-1])
else:
decoder_input = torch.empty(1, 1,\
dtype=torch.long).fill_(SOS_token)
# 解码一步
decoder_output, decoder_hidden, _ = decoder.forward_step(
decoder_input, decoder_hidden, encoder_outputs
)
# 从输出分布中取出前k个结果
topk_values, topk_ids = decoder_output.topk(num_beams)
# 生成并添加新的候选结果到容器
for logp, token_id in zip(topk_values.squeeze(),\
topk_ids.squeeze()):
sum_logprobs = score * len(hyp) + logp.item()
new_hyp = hyp + [token_id.item()]
hypotheses.add(sum_logprobs, new_hyp, decoder_hidden)
flag, item = hypotheses.pop_best()
if flag:
hyp = item[1]
if hyp[-1] == EOS_token:
del hyp[-1]
return output_lang.ids2sent(hyp)
else:
return ''
encoder.eval()
decoder.eval()
for i in range(5):
pair = random.choice(pairs)
print('input:', pair[0])
print('target:', pair[1])
output_sentence = beam_search_decode(encoder, decoder,\
pair[0], input_lang, output_lang)
print('pred:', output_sentence)
print('')
input: h 5 和 w e b g l 3 d 开 发 实 战 详 解
target: elaboration of the h5 and webgl 3d development
pred: elaboration of the h5 and webgl 3d development
input: 跨 境 电 子 商 务 英 语 ( 音 频 指 导 版 )
target: cross-border e-commerce english (audio guide version)
pred: cross-border e-commerce english (audio guide version)
input: 采 购 与 供 应 商 管 理 常 用 制 度 与 表 格 范 例
target: examples of common systems and forms for procurement and vendor management
pred: examples of common systems and forms for procurement and vendor management
input: 大 学 生 职 业 生 涯 规 划 与 就 业 创 业 指 导 ( 微 课 版 )
target: career planning and entrepreneurship guidance for university students (micro-curricular version)
pred: career planning and entrepreneurship guidance for university students (micro-curricular version)
input: p y t h o n 数 据 挖 掘 入 门 与 实 践 第 2 版
target: python data digging introduction and practice 2nd edition
pred: python data digging introduction and practice 2nd edition
可以看到束搜素解码在这个演示中的效果想比贪心解码有所改善。
4.3 其他解码问题和解码技巧
贪心解码和束解码只是最基础的解码方法,其解码结果会出现许多问题。这里主要介绍3种常见问题,并简单介绍解决方案。
4.3.1 重复性问题
有时我们会发现序列到序列模型不断重复的输出同一个词。一个解决方案是解码时在所预测的词的概率分布上添加一个惩罚项,以减少已生成的词的概率。类似的惩罚项也可以添加到训练损失函数中, 即每个时刻的损失函数J₁修改为
另一个解决方案是修改注意力机制。如果当前的注意力分布与之前步骤的注意力分布相近,即关注相同的词, 那么模型往往会生成相同的词。因此可以通过避免注意力集中在相同的词上来减少重复。
4.3.2 多样性问题
在诸如对话这样的任务中,我们往往不希望模型总是刻板地回复同样的句子, 而是希望模型的回复有足够的多样性。但是如果使用贪心解码,对于类似的源序列, 往往最好的目标序列的确是一样的(如“我不知道”“很好”等)。一个解决方案是不再寻求概率最高的目标序列,转而在每一步从所预测的词的概率分布中进行采样。这样一来,即使是完全相同的源序列,也很可能会输出不同的目标序列。
4.3.3 防止“幻觉”
在翻译、文本总结等任务中,模型有时会出现幻觉( hallucination), 也就是说输出的目标文本里包含源文本里没有的元素。产生这个问题有许多原因和对应的解决方案。例如,如果模型的训练文本主要来自新闻领域,那么对于来自美食评论领域的源文本, 模型输出的目标文本很有可能还是带有许多和新闻相关的文字。对于上述问题,增加模型训练文本的领域数量是个比较直接有效的解决方案。当然,“幻觉”的产生还有很多更为复杂和微妙的原因,需要根据具体情况分析和解决, 这里不做更多讨论。
5. 指针网络
在有些场景下, 我们希望模型能将部分源序列中的元素直接复制到目标序列中,例如在机器翻译中,命名实体、数值、日期等短语往往不需要进行翻译, 而是可以直接复制,以如下源句和目标句为例。
源句: John and Mary spent 7 dollar on fried chicken。
目标句: John 和 Mary花了7美元买炸鸡。
源句中的命名实体“ John”和“ Mary”以及数字“7”都直接复制到了目标句中。此外,我们可以回顾一下本章开头给出的基于序列到序列的命名实体识别的例子。
源句: 小红在上海旅游。
目标句:[小红]人名]在[上海|地名]旅游。
目标句中的大部分词都是复制于源句, 仅有一小部分是需要生成的分隔符和实体标签。
在前面所提出的序列到序列模型的基础上, 指针网络( pointer network) ,也称为拷贝机制,可以建模这种复制源句内容的行为。下图展示了指针网络的架构。在解码的每一步, 模型会计算一个生成和复制二选一的伯努利分布,其中:
生成——依照前面介绍的方法, 生成一个词的概率分布;
复制——预测一个源句中词的概率分布(可看作预测一个指向源句中词的“指针”),预测方法和计算注意力分布类似。
最终用于解码的词的概率分布是生成分布和复制分布的加权和, 权重便是伯努利分布的概率。
6. 序列到序列任务的延伸
自然语言处理领域存在不少与序列到序列相近的任务,而前文介绍的序列到序列模型和方法也可以修改和扩展以适用于这些任务。
在一些任务中,输入数据是非序列的其他形式和模态, 而输出数据依然是序列,例如图像文字说明( image captioning) 的输入是单张图像, 视觉/视频问答( visual/ video question answering) 的输入是单张图像或单个视频以及一个问题文本, 结构化数据转文字( structured data to text) 的输入数据是结构化数据(如表格), 语音-文本转换( speech to text) 的输入是一段语音。对于这些任务,需要使用专门针对输入数据形式和模态的编码器,例如图像输入可以使用卷积神经网络或视觉 Transformer编码器,而解码器与序列到序列模型的解码器类似,通过自回归的方式生成文本。此外, 前面介绍的语言模型也可以看成一个无输入的序列到序列问题。
在另一些任务中,输入数据是序列,而输出数据是非序列的其他形式和模态, 例如在序列到集合任务中,输出是一个集合,输出元素之间不存在顺序关系。很多自然语言处理任务可以看作序列到集合任务,例如命名实体识别可以看作预测输入句子所包含的实体集合。序列到集合任务可以使用序列到序列模型求解,即假设输出元素之间存在某种顺序,也可以使用前面描述的使用非自回归解码的 Transformer模型,但其中不使用位置编码, 从而保证预测元素之间不存在顺序。