自然语言处理是典型的序列问题,其底层算法在最近几年迅速发展,比如去年年底发布的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过程中,生成每个词汇时,除了需要依赖上一步的隐藏层输出,还需要参考输出序列的前一个词,使得生成的序列符合语法规则(如介词的位置),设置输出序列的第一个词为
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”转换成矩阵。
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=)
print(embed(Variable(torch.LongTensor([dic['hello'],dic['world']]))))
# 输出结果:tensor([[-1.7123, -0.6566, -0.6055],
# [-1.5716, 0.8978, 0.4581]], grad_fn=)
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。
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解码模块,注意力逻辑主要实现在该模块中:
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是训练的原句和译文。
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