5.2 带注意力机制的Encoder-Decoder模型
结合上一章的图4-10可知,在生成目标句子的单词时,不论生成哪个单词,是y 1、y 2也好,是y 3也好,使用的句子X的语义编码C都是一样的,没有任何区别。而语义编码C是由句子X的每个单词经过Encoder编码产生的,这意味着无论生成哪个单词,y 1、y 2还是y 3,其实句子X中任意单词对生成某个目标单词y i来说影响力都是相同的,没有任何区别。
我们以一个具体例子说明,用机器翻译(输入英文,输出中文)来解释这个Encoder-Decoder模型会更好理解,比如:
输入英文句子:Tom chase Jerry Encoder-Decoder模型逐步生成中文单词:“汤姆”“追逐”“杰瑞”
在翻译“杰瑞”这个中文单词的时候,分心模型里面的每个英文单词对于翻译目标单词“杰瑞”的贡献是相同的,很明显这并不合理,因为“Jerry”对于“杰瑞”更重要,但是分心模型是无法体现这一点的,这也是说它没有引入注意力机制的原因。
5.2.1 引入注意力机制
没有引入注意力机制的模型在输入句子比较短的时候估计问题不大,但是如果输入句子比较长,此时所有语义完全通过一个中间语义向量来表示,单词自身的信息已经消失,可想而知会丢失很多细节信息。
在上面的例子中,如果引入注意力机制,则应该在翻译“杰瑞”的时候,体现出英文单词对于翻译当前中文单词的不同的影响程度,比如给出类似下面一个概率分布值:
(Tom,0.3)(Chase,0.2)(Jerry,0.5)
每个英文单词的概率代表了翻译当前单词“杰瑞”时,注意力模型分配给不同英文单词的注意力大小。这对于正确翻译目标语单词肯定是有帮助的,因为引入了新的信息。同理,目标句子中的每个单词都应该学会其对应的源语句中单词的注意力分配概率信息。这意味着在生成单词y i的时候,原先相同的中间语义表示C会替换成根据当前生成单词而不断变化的Ci。理解AM的关键就是这里,即由固定的中间语义表示C换成了根据当前输出单词来调整成加入注意力模型的变化的Ci。引入注意力机制的Encoder-Decoder模型架构示意图如图5-3所示。
图5-3 引入注意力机制的Encoder-Decoder模型架构[1]
即生成目标句子单词的过程可以写成下面的形式:
y 1=g(C 1) (5.4)
y 2=g(C 2,y 1) (5.5)
y 3=g(C 3,y 1,y 2) (5.6)
而每个C i可能对应着不同的源语句单词的注意力分配概率分布,比如对于上面的英汉翻译来说,其对应的信息可能如下。
注意力分布矩阵:
第i行表示y i收到的所有来自输入单词的注意力分配概率。y i的语义向量C i由这些注意力分配概率和Encoder对单词x j的转换函数f 2相乘得到,例如:
C 1 = C 汤姆 = g(0.6*f 2("Tom"),0.2*f 2("Chase"),0.2*f 2("Jerry")) (5.8)
C 2 = C 追逐 = g(0.2*f 2("Tom"),0.7*f 2("Chase"),0.1*f 2("Jerry")) (5.9)
C 3 = C 杰瑞 = g(0.3*f 2("Tom"),0.2*f 2("Chase"),0.5*f 2("Jerry")) (5.10)
其中,f 2函数代表Encoder对输入英文单词的某种变换函数,比如Encoder使用RNN模型的话,这个f 2函数的结果往往是某个时刻输入x i后隐藏层节点的状态值;g代表Encoder根据单词的中间表示合成整个句子中间语义表示的变换函数。一般的,g函数就是对构成元素加权求和,也就是我们看到的下列公式:
假设C i中的i就是上面的“汤姆”,那么T x就是3,代表输入句子的长度,h 1 = f 2("Tom"),h 2 = f 2("Chase"),h 3 = f 2("Jerry"),对应的注意力模型权值分别是0.6,0.2,0.2,所以g函数就是一个加权求和函数。如果形象表示的话,翻译中文单词“汤姆”的时候,数学公式对应的中间语义表示C i的生成过程如图5-4所示。
图5-4 C i的生成过程
还有一个问题:生成目标句子某个单词,比如“汤姆”的时候,怎么知道注意力机制所需要的输入句子单词注意力分配概率分布值呢?其实,该值就是“汤姆”对应的概率分布:(Tom, 0.6)(Chase, 0.2)(Jerry, 0.2)。
5.2.2 计算注意力分配值
如何计算注意力分配值?为便于说明,假设对本书第4章图4-10的没有引入注意力机制的Encoder-Decoder模型进行细化,即Encoder采用RNN模型,Decoder也采用RNN模型(这是比较常见的一种模型配置),则可转换为如图5-5所示。
图5-5 采用RNN的Encoder-Decoder模型架构示意图
图5-6可以较为便捷地说明注意力分配概率分布值的通用计算过程。
图5-6 注意力分配概率计算过程
我们的目的是计算生成y i时,输入句子中的单词“Tom”“Chase”“Jerry”对y i的注意力分配概率分布。这些概率可以用目标输出句子i-1时刻的隐藏层节点状态H i-1与输入句子中每个单词对应的RNN隐藏层节点状态h j进行一一对比,即通过对齐函数F(h j,H i-1)来获得目标单词和每个输入单词对应的对齐可能性。然后函数F的输出经过Softmax函数进行归一化就得到一个0-1的注意力分配概率分布数值。注意,函数F(h j,H i-1)在不同论文里采取的方法可能不同。
当输出单词为“Tom”时刻对应的输入句子单词的对齐概率。绝大多数带注意力机制的模型都是采取上述计算过程来计算注意力分配概率的分布信息,只是在函数F的定义上可能有所不同。y t值的生成过程可参考图5-7。
图5-7 由输入语句(x 1,x 2,x 3…x T)生成第t个输出y t
其中:
p(y t|{y 1,…,y t-1},x)=g(y t-1,s t,C t) (5.12)
s t=f(s t-1,y t-1,C t) (5.13)
y t=g(y t-1,s t,C t) (5.14)
e tj=a(s t-1,h j) (5.17)
上述内容就是软注意力机制模型的基本思想,那么怎么理解带注意力机制的模型的物理含义呢?一般文献里会把注意力机制模型看作单词对齐模型,因为目标句子生成的每个单词对应输入句子单词的概率分布可以理解为输入句子单词与这个目标生成单词的对齐概率,这在机器翻译语境下是非常直观的。
当然,从概念上理解的话,把带注意力机制的模型理解成影响力模型也是合理的。也就是说,在生成目标单词时,输入句子每个单词对于生成这个单词有多大的影响程度。这种想法也是理解带注意力机制的模型的物理意义的一种方式。
注意力机制除软注意力还有硬注意力、全局注意力、局部注意力、自注意力等,它们都对原有的注意力框架进行了一些改进,其中自注意力将在5.3.3节介绍。
到目前为止,在我们介绍的Encoder-Decoder架构中,构成Encoder或Decoder的一般是循环神经网络(如RNN、LSTM、GRU等),这种架构在遇上大语料库时运行将非常缓慢,这主要是由于循环神经网络无法并行处理所致。既然如此,卷积神经网络并行处理能力较强,是否可以使用卷积神经网络呢?卷积神经网络也有一些天然不足,如无法处理长度不一的语句、对时间序列不敏感等。为解决这些问题,人们研究出一种注意力新架构—Transformer,具体将在5.3节介绍。
5.2.3 使用PyTorch实现带注意力机制的Encoder-Decoder模型
上节我们简单介绍了带注意力机制的Encoder-Decoder模型,这节我们使用PyTorch来实现它。
1. 构建Encoder
用PyTorch构建Encoder比较简单,把输入句子中的每个单词用torch.nn.Embedding(m, n)转换为词向量,然后通过一个编码器(这里采用GRU模型),对于每个输入字,输出向量和隐藏状态,并将隐藏状态用于下一个输入字。具体可参考图5-8。
图5-8 编码器结构图
对应代码实现如下:
class EncoderRNN(nn.Module): def __init__(self, input_size, hidden_size): super(EncoderRNN, self).__init__() self.hidden_size = hidden_size self.embedding = nn.Embedding(input_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)
2. 构建简单Decoder
我们先构建一个简单的解码器,使用编码器的最后输出作为解码器的初始隐藏状态。这最后一个输出有时称为上下文向量,因为它在整个序列中编码上下文。在解码的每一步,解码器都被赋予一个输入指令和隐藏状态,初始输入是指令字符串开始的<SOS>指令,第一个隐藏状态是上下文向量(编码器的最后隐藏状态),其网络结构如图5-9所示。
图5-9 解码器结构
对应实现代码如下:
class DecoderRNN(nn.Module): def __init__(self, hidden_size, output_size): super(DecoderRNN, self).__init__() self.hidden_size = hidden_size self.embedding = nn.Embedding(output_size, hidden_size) self.gru = nn.GRU(hidden_size, hidden_size) self.out = nn.Linear(hidden_size, output_size) self.softmax = nn.LogSoftmax(dim=1) def forward(self, input, hidden): output = self.embedding(input).view(1, 1, -1) output = F.relu(output) output, hidden = self.gru(output, hidden) output = self.softmax(self.out(output[0])) return output, hidden def initHidden(self): return torch.zeros(1, 1, self.hidden_size, device=device)
3. 构建注意力Decoder
这里以典型的Bahdanau注意力架构为例,主要有四层。嵌入层将输入字转换为矢量,计算每个编码器输出的注意能量的层、RNN层和输出层。
由前面图5-7可知,解码器的输入包括循环网络最后的隐含状态s i-1、最后输出y i-1、所有编码器的所有输出h *。
1)这些输入,分别被不同的层接收,其中,y t-1作为嵌入层的输入。
embedded = embedding(last_rnn_output)
2)注意力层的函数a的输入为s t-1和h j,输出为e tj,标准化处理后为α tj。
attn_energies[j] = attn_layer(last_hidden, encoder_outputs[j]) attn_weights = normalize(attn_energies)
3)向量C t为编码器各输出的注意力加权平均。
context = sum(attn_weights * encoder_outputs)
4)循环层f的输入为(s t-1,y t-1,c t),输出为内部隐含状态及s t。
rnn_input = concat(embedded, context) rnn_output, rnn_hidden = rnn(rnn_input, last_hidden)
5)输出层g的输入为(y i-1,s i,c i),输出为y i。
output = out(embedded, rnn_output, context)
6)综合以上各步,即可得到Bahdanau注意力的解码器。
class BahdanauAttnDecoderRNN(nn.Module): def __init__(self, hidden_size, output_size, n_layers=1, dropout_p=0.1): super(AttnDecoderRNN, self).__init__() #定义参数 self.hidden_size = hidden_size self.output_size = output_size self.n_layers = n_layers self.dropout_p = dropout_p self.max_length = max_length # 定义层 self.embedding = nn.Embedding(output_size, hidden_size) self.dropout = nn.Dropout(dropout_p) self.attn = GeneralAttn(hidden_size) self.gru = nn.GRU(hidden_size * 2, hidden_size, n_layers, dropout=dropout_p) self.out = nn.Linear(hidden_size, output_size) def forward(self, word_input, last_hidden, encoder_outputs): # 前向传播每次运行一个时间步,但使用所有的编码器输出 # 获取当前词嵌入 (last output word) word_embedded = self.embedding(word_input).view(1, 1, -1) # S=1 x B x N word_embedded = self.dropout(word_embedded) # 计算注意力权重并使用编码器输出 attn_weights = self.attn(last_hidden[-1], encoder_outputs) context = attn_weights.bmm(encoder_outputs.transpose(0, 1)) # B x 1 x N # 把词嵌入与注意力context结合在一起,然后传入循环网络 rnn_input = torch.cat((word_embedded, context), 2) output, hidden = self.gru(rnn_input, last_hidden) # 定义最后输出层 output = output.squeeze(0) # B x N output = F.log_softmax(self.out(torch.cat((output, context), 1))) #返回最后输出、隐含状态及注意力权重 return output, hidden, attn_weights
[1]5.2节部分参考了张俊林的博客:https://blog.csdn.net/malefactor/article/details/78767781。