Seq2Seq 与 Attention

自然语言处理是典型的序列问题,其底层算法在最近几年迅速发展,比如去年年底发布的 BERT 在 11 项自然语言处理任务中表现卓越,今年 GPT-2 生成文本(写作)的水平也有了显著提高。

目前这些最先进的技术都基于 Transformer 模型,该模型从 RNN,LSTM,Seq2Seq,Attention,ConvS2S,Transformer 一步步进化而来,还涉及自然语言处理的相关知识,包含的知识点太多,无法一次说清。笔者将其分成几篇,从其进化过程逐步引入。之前已经介绍过 RNN 及 LSTM,本篇将介绍 Seq2Seq 和 Attention 算法。

翻译功能

深度学习中的自然语言处理常用于自动翻译、语言识别、问答系统、提取概要、写作等等领域。

其中自动翻译是一项非常典型的应用,在翻译过程中,输入和输出的词汇个数可长可短,不能一一对应,不同语言词汇顺序又可能不同,并且还有一词多义,一义多词,词在不同位置含义不同的情况……是相对复杂的自然语言处理问题。

先来看看人怎么解决翻译问题,面对一种完全不认识的语言,人把句子分解成词,通过查字典的方式将词转换成母语,然后再通过语法组合成句。其中主要涉及词的实际含义、内容的先后关系,两种语言对应关系。机器既不需要了解各个词的含义和语法,也不需要字典,就能通过大量训练实现翻译功能,并且效果还不错。这让神经网络看起来更加难以理解。

一开始的深度学习神经网络,没有逐词翻译的逻辑,主要实现的是序列生成模型,根据前面的一个词或者几个词去推测后面的词。所以人们认为,机器并没有真正理解语言,以及两种语言之间的对应关系,通过训练生成的知识分散在网络各个节点用权重表示,也不能提炼总结,完全是个黑盒。同时,它也不能代入已有的知识,如果换成与训练数据不同的情境,就无法正常工作了。

翻译模型发展到今天,已很大程度改善了这一问题,现在的模型可以通过训练学习到什么是“苹果”,也可以生成翻译词典。而且这些规则不需要事先输入,是它自己“学”出来的。通过注意力算法,不仅能实现翻译,还能找到词间的对应关系(双语词典);词向量可以从多个角度描述词的特征,对比“苹果”和“沙果”的相似度(词汇含义);据此,就可以把高频率出现的规则总结成知识。

Seq2Seq

1. 引入

设想最简单的情况,将一句中文 X(x1,x2,x3,x4) 翻译成英文 Y(y1,y2,y3)。

如果把模型想像成黑盒,则如图下所示:

由于不同语言的词汇不存在绝对的一一对应关系,人工翻译一般是看完输入的完整句子,才开始翻译并输出,如果有条件,最好还能看一下上下文语境。模型处理数据流也是如此。

前几篇介绍了循环神经网络 RNN,它不断向后传递隐藏层 h 的内容,使得序列中的信息逐步向后传递,下图是 RNN 网络在翻译问题中最简单的用法,LSTM 和 GRU 原理与 RNN 相同。

在 RNN 循环网络中,神经网络的每个时间步对应同一组参数,这些参数存储着翻译功能所包含的大量信息;在翻译任务中,两种语言的词汇语法不同,用同一组参数描述它们显然比较粗糙。如果能对两种语言生成两种规则,用不同网络的不同参数描述,则更加合理。于是,将翻译过程拆分为编码 Encoder 和解码 Decoder 两个子模型,可把这个过程想像成:先把中文翻译成一种语义编码 c,再把语义编码 c 翻译成英文。

进一步细化,在 Decoder 过程中,生成每个词汇时,除了需要依赖上一步的隐藏层输出,还需要参考输出序列的前一个词,使得生成的序列符合语法规则(如介词的位置),设置输出序列的第一个词为、、<start>,最后一个词为<end>,细化后的逻辑如下图示。

2. 概念

Seq2Seq 也被称为 S2S,是 Sequence to Sequence 的简称,即序列到序列的转换。它始于谷歌在 2014 年发表的一篇论文《Sequence to Sequence Learning with Neural Networks》。

上图中的 Encoder-Decoder 网络结构就是 Seq2Seq,Encoder 和 Decoder 可以使用 RNN,LSTM,GRU 等基础模型。简言之,就是把翻译中原来的一个循环网络变成了两个。

除了翻译,Seq2Seq 也被用于提取概要,问答,语音识别等场景之中,处理输入和输出规则不同的情况,但是在生成文本的任务中,比如通过前面文字续写后续文字,输入和输出都是同样的序列,则无需 Seq2Seq。

转换词向量

在自然语言处理中,常将单词作为序列中的元素。

模型只能接收数值型数据,代入模型前,需要把词汇转换成数值,如果使用 One-Hot 编码,数据维度将非常大,并且无法描述词与词之间的相似度。更常用的方法是词嵌入 Word Embedding,它将每个词表示成向量,比如把“hello”,转换成三维的值 [-1.7123, -0.6566, -0.6055],可将该操作理解成:把一个词汇拆分成为多个属性。通过比较各个属性的差异可以计算两个词汇之间的距离。

在不同层面,不同角度将看到事物的不同属性(特征),比如梨和苹果都是水果,但是颜色差异很大,通过模型计算出来的词属性与训练的目标以及训练数据有关。词汇的特征通过反向传播计算得来,从这个角度看,神经网络对每个词进行了特征提取,也可作为词特征提取工具来使用。

在 Pythorch 中使用 torch.nn.Embedding 可实现该功能,它提供了词的索引号与向量之间的转换表。用法是:torch.nn.Embedding(m, n) 其中 m 表示单词的总数目,n 表示词嵌入的维度(一个词转成几个特征,常用的维度是 256-512),词嵌入相当于将输入的词序列转换成一个矩阵,矩阵的每一行表示一个单词,列为每个单词的多个特征。Embedding 也是一层网络,其参数通过训练求得。而词对应的每一维特征的具体值如 -1.7123 通过这些参数计算得出。

下面例程,将词序列“hello world”转换成矩阵。

1
2
3
4
5
6
7
8
9
10
11
from torch import nn
from torch.autograd import Variable

dic = {'hello':0, 'world':1} # 词汇与索引号转换字典
embed = nn.Embedding(2, 3) # 共两个词汇,每个词汇转换成三个特征
# Embedding的输入是一个LongTensor。
print(embed(Variable(torch.LongTensor([1])))) # 1为词汇的索引号
# 输出结果:tensor([[-1.5716, 0.8978, 0.4581]], grad_fn=<EmbeddingBackward>)
print(embed(Variable(torch.LongTensor([dic['hello'],dic['world']]))))
# 输出结果:tensor([[-1.7123, -0.6566, -0.6055],
# [-1.5716, 0.8978, 0.4581]], grad_fn=<EmbeddingBackward>)

Attention

注意力 Attention 指的是一类算法,常见的有 local attention,global attention,self attention 等等。

注意力方法最初出现在图像处理问题之中,当人眼观察一幅图像时,某一时刻的视觉焦点只集中在一点上,其注意力是不均衡的,视觉注意力焦点可提高效率和准确性。算法借鉴了人类注意力机制,实现方法是给不同的数据分配不同的权重。

在上述的 Seq2Seq 模型中,生成目标句子中的单词时,不论生成哪个单词,都根据语义编码 C,比如将“I love you” 翻译成“我爱你”时,“I love you”三个词对“我”的贡献度都一样,而我们希望“I”对“我”的贡献度更大,于是使用了 Attention 算法。

实现 Attention 的方式有很多种,这里展示比较常用的一种。在 Encoder 的过程中保留每一步 RNN 单元的隐藏状态 h1……hn,组成编码的状态矩阵 Encoder_outputs;在解码过程中,原本是通过上一步的输出 yt-1和前一个隐藏层 h 作为输入,现又加入了利用 Encoder_outputs 计算注意力权重 attention_weight 的步骤。

用图和文字很难说清楚,看代码更容易,下面分析将 Pytorch 官方教程 Attention 模型的核心部分,完整程序见:

https://pytorch.org/tutorials/intermediate/seq2seq_translation_tutorial.html

建议读者运行该例程,跟踪每一步的输入和输出,可以尝试修改代码实现中文互译功能。

下面为编码器 Encoder 的实现部分,编码器包含:词向量转换 embedding 和循环网络 GRU。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class EncoderRNN(nn.Module):
# 参数:input_size为输入语言包含的词个数
def __init__(self, input_size, hidden_size):
super(EncoderRNN, self).__init__()
self.hidden_size = hidden_size
self.embedding = nn.Embedding(input_size, hidden_size) #每词 hidden_size个属性
self.gru = nn.GRU(hidden_size, hidden_size)

def forward(self,input, hidden):
embedded = self.embedding(input).view(1,1,-1)
output = embedded
output, hidden = self.gru(output, hidden)
return output, hidden

def initHidden(self):
return torch.zeros(1,1, self.hidden_size, device=device)

其中 forward 每次处理序列中的一个元素(一个词)。

难度较大的是 Decoder 解码模块,注意力逻辑主要实现在该模块中:

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
class AttnDecoderRNN(nn.Module):
# 参数:output_size为输出语言包含的所有单词数
def __init__(self,hidden_size,output_size, dropout_p=0.1, max_length = MAX_LENGTH):
super(AttnDecoderRNN, self).__init__()
self.hidden_size = hidden_size
self.output_size = output_size
self.dropout_p = dropout_p
self.max_length = max_length
self.embedding = nn.Embedding(self.output_size, self.hidden_size)
self.attn = nn.Linear(self.hidden_size*2, self.max_length)
self.attn_combine = nn.Linear(self.hidden_size*2, self.hidden_size)
self.dropout = nn.Dropout(self.dropout_p)
self.gru = nn.GRU(self.hidden_size, self.hidden_size)
self.out = nn.Linear(self.hidden_size, self.output_size) # 把256个特征转换成输出语言的词汇个数

# 参数:input每步输入,hidden上一步结果,encoder_outputs编码的状态矩阵
# 计算的值是各词出现的概率
def forward(self, input, hidden, encoder_outputs):
embedded = self.embedding(input).view(1,1,-1)
embedded = self.dropout(embedded)
attn_weights = F.softmax(
self.attn(torch.cat([embedded[0],hidden[0]],1)),dim=1)
attn_applied = torch.bmm(attn_weights.unsqueeze(0), # unsqueeze维度增加
encoder_outputs.unsqueeze(0))
output = torch.cat([embedded[0], attn_applied[0]],1) # 注意力与当前输入拼接
output = self.attn_combine(output).unsqueeze(0)
output = F.relu(output) # 激活函数
output, hidden = self.gru(output, hidden)
output = F.log_softmax(self.out(output[0]),dim=1)
return output, hidden, attn_weights

def initHidden(self):
return torch.zeros(1,1, self.hidden_size, device=devic

代码核心是前向传播函数 forward,第一个难点是计算 attn_weights,先用 cat 组装输入词向量 embedded 和隐藏层 hidden 信息 256+256=512,转入全连接层 attn,转换后输出 10 维数据(序列最长 10 个单词),再用 softmax 转成和为 1 的比例值。计算结果是注意力权重 attn_weights 大小为 [1,10],它描述的是输入 encoder 中各位置元素对当前 decoder 输出单词的重要性占比,比如“I love you”对“爱”字的重要性分别是 [0.2,0.6,0.2]。训练调整 attn 层参数以实现这一功能。

然后计算 attn_applied,用注意力权重 attn_weights[1,10](每个位置的重要性)乘记录 encoder 每一步状态的矩阵 encoder_outputs[10,256](每个位置的状态)。得到一个综合权重 attn_applied[1,256],用于描述“划了重点”之后的输入序列对当前预测这个单词的影响。得出 attn_applied 之后,再与词向量 embed 值组合、转换维度、经过激活函数处理后,和隐藏层一起传入 gru 循环网络。

最后通过全连接层 out 把 256 维特征转换成输出语言对应的单词个数,其中每维度的值描述了生成该词的可能性,再用 log_softmax 转换成输出要求格式,以便与其误差函数配合使用(后面详细介绍)。

下面是训练部分,每调用一次 train 训练一个句子。其中传入的 encoder 和 decoder 分别是上面定义的 EncoderRNN 和 AttnDecoderRNN,input_tensor 和 target_tensor 是训练的原句和译文。

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
def train(input_tensor, target_tensor, encoder, decoder, encoder_optimizer,
decoder_optimizer, criterion, max_length = MAX_LENGTH):
encoder_hidden = encoder.initHidden()
encoder_optimizer.zero_grad() # 分别优化encoder和decoder
decoder_optimizer.zero_grad()
input_length = input_tensor.size(0)
target_length = target_tensor.size(0)
encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device)
loss = 0

for ei in range(input_length): # 每次传入序列中一个元素
encoder_output, encoder_hidden = encoder(input_tensor[ei], encoder_hidden)
encoder_outputs[ei]=encoder_output[0,0] # seq_len为1,batch_size为1,大小为 hidden_size

decoder_input = torch.tensor([[SOS_token]], device=device) # SOS为标记句首
decoder_hidden = encoder_hidden # 把编码的最终状态作为解码的初始状态

for di in range(target_length): # 每次预测一个元素
decoder_output, decoder_hidden, decoder_attention = decoder(
decoder_input, decoder_hidden, encoder_outputs)
topv, topi = decoder_output.topk(1) # 将可能性最大的预测值加入译文序列
decoder_input = topi.squeeze().detach()
loss+=criterion(decoder_output, target_tensor[di])
if decoder_input.item()==EOS_token:
break

loss.backward()
encoder_optimizer.step()
decoder_optimizer.step()
return loss.item() / target_length

其中第一个循环为 Encoder,程序对输入序列中每个元素做 encoder,并把每一次返回的中间状态 hidden 存入 encoder_outputs,最终生成保存所有位置状态的矩阵 encoder_outputs。

第二个循环为 Decoder,程序利用当前的隐藏状态 decoder_hidden,解码序列的前一个元素 decoder_input,和输入的状态矩阵 encoder_outputs 做解码,并从解码器的输出中选中最有可能的单词作为后序的输入,直到序列结束。其整体误差是每个元素误差的平均值。

Attention 还有很多变型,比如 local attention 为了减少计算量,加入了窗口的概念,只对其中一部分位置操作(选一个点,向右左扩展窗口),窗口以外都取 0;self attention 将在下篇 Transformer 中详细介绍。

词向量转换成词

翻译的第一步是将词的索引号转换成词向量,相对的,最后一步将词向量转换成词的索引号,以确定具体的词。Decoder 的最后部分实现了该功能,它使用全连接层 out 进行维度转换,最后使用 log_softmax 转换成概率的 log 值。

softmax 输出的是概率,整体可能性为 1。比如输出的语言只有三个词汇 [‘a’,’b’,’c’],softmax 求出它们的可能性分别是 [0.1,0.1,0.9],那么此外最可能是’c’。Log_softmax 是对 softmax 的结果再做 log 运算,生成对数概率向量。

log 函数曲线如下:

由于 softmax 输出的各个值在 0-1 之间,梯度太小对反向传播不利,于是 log_softmax 将 0-1 映射到负无穷到 0 之间更宽的区域之中,从而放大了差异。同时,它与损失函数 NLLLoss 配合使用,NLLLoss 的输入是一个对数概率向量和一个目标标签,正好对应最后一层是 log_softmax 的网络。另外,也可以使用交叉熵作为误差函数:CrossEntropyLoss=log_softmax + NLLLoss。

参考

Seq2Seq 论文《Sequence to Sequence Learningwith Neural Networks》

https://papers.nips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf

Attention 论文《Neural machine translation by jointly learning to align and translate》

https://arxiv.org/pdf/1409.0473v2.pdf