Attention
本章我们将进一步探索 seq2seq 的可能性(以及 RNN 的可能性)。Attention 毫无疑问是近年来深度学习领域最重要的技术之一。本章的目标是在代码层面理解 Attention 的结构,然后将其应用于实际问题。
# 1. Attention 的结构
我们将介绍进一步强化 seq2seq 的注意力机制。基于 Attention 机制,seq2seq 可以像我们人类一样,将“注意力”集中在必要的信息上。
上一章我们已经对 seq2seq 进行了改进,但那些只能算是“小改进”。下面将要说明的 Attention 技术才是解决 seq2seq 的问题的“大改进”。
# 1.1 seq2seq 存在的问题
seq2seq 中使用编码器对时序数据进行编码,输出是固定长度的向量,问题在于无论输入语句的长度如何,其信息都会被塞入一个固定长度的向量中。而这早晚都会遇到瓶颈,有用的信息会从向量中溢出。
现在我们就来改进 seq2seq。首先改进编码器,然后再改进解码器。
# 1.2 改进 Encoder
编码器的输出的长度应该根据输入文本的长度相应地改变。之前我们只将 LSTM 层的最后的隐藏状态传递给解码器,改进为可以使用各个时刻的 LSTM 层的隐藏状态,从而可以获得和输入的单词数相同数量的向量:
如上例,输入了 5 个单词,此时编码器输出 5 个向量。这样一来,编码器就摆脱了“一个固定长度的向量”的制约。
在许多深度学习框架中,在初始化 RNN 层时,可以选择是返回“全部时刻的隐藏状态向量”,还是返回“最后时刻的隐藏状态向量”。比如,在 Keras 中,在初始化 RNN 层时,可以设置
return_sequences
为 True 或者 False。
我们需要关注 LSTM 层的隐藏状态的“内容”。有一点可以确定的是,各个时刻的隐藏状态中包含了大量当前时刻的输入单词的信息:
- 编码器输出的
矩阵就可以视为各个单词对应的向量集合。
因为编码器是从左向右处理的,所以严格来说,刚才的“猫”向量中含有“吾輩”“は”“猫”这3个单词的信息。考虑整体的平衡性,最好均衡地含有单词“猫”周围的信息。在这种情况下,从两个方向处理时序数据的双向RNN(或者双向LSTM)比较有效。
以上就是我们的改进:将编码器的全部时刻的隐藏状态取出来,从而编码器可以根据输入语句的长度,成比例地编码信息。
接下来,我们对解码器进行改进。因为解码器的改进有许多值得讨论的地方,所以我们分 3 部分进行。
# 1.3 解码器的改进 ①
编码器和解码器的关系:编码器整体输出各个单词对应的 LSTM 层的隐藏状态向量
我们在进行翻译时,大脑做了什么呢?比如,在将“吾輩は猫である”这句话翻译为英文时,肯定要用到诸如“吾輩 = I”“猫 = cat”这样的知识。也就是说,可以认为我们是专注于某个单词(或者单词集合),随时对这个单词进行转换的。那么,我们可以在 seq2seq 中重现同样的事情吗?确切地说,我们可以让 seq2seq 学习“输入和输出中哪些单词与哪些单词有关”这样的对应关系吗?
在机器翻译的历史中,很多研究都利用“猫=cat”这样的单词对应关系的知识。这样的表示单词(或者词组)对应关系的信息称为对齐(alignment)。到目前为止,对齐主要是手工完成的,而我们将要介绍的 Attention 技术则成功地将对齐思想自动引入到了 seq2seq 中。这也是从“手工操作”到“机械自动化”的演变。
从现在开始,我们的目标是找出与“翻译目标词”有对应关系的“翻译源词”的信息,然后利用这个信息进行翻译。也就是说,我们的目标是仅关注必要的信息,并根据该信息进行时序转换。这个机制称为 Attention。
先看一下 Decoder 的整体框架:
我们新增一个进行“某种计算”的层。这个“某种计算”接收(解码器)各个时刻的 LSTM 层的隐藏状态和编码器的
该网络所做的工作是提取单词对齐信息。具体来说,就是从
神经网络的学习一般通过误差反向传播法进行。因此,如果使用可微分的运算构造网络,就可以在误差反向传播法的框架内进行学习;而如果不使用可微分的运算,基本上也就没有办法使用误差反向传播法。
将“选择”这一操作换成可微分的运算的一个思路是:与其“单选”,不如“全选”,并另行计算表示各个单词重要度(贡献值)的权重:
这里使用了表示各个单词重要度的权重,记为
计算单词向量的加权和,这里将结果称为上下文向量, 并用符号 c 表示。这个加权和计算基本代替了“选择”向量的操作。比如上图中的上下文向量就含有较多的“吾輩”向量的成分(如此便实现了注意力的集中)。
上下文向量
中包含了当前时刻进行变换(翻译)所需的信息。更确切地说,模型要从数据中学习出这种能力。
这里随意地生成编码器的输出
import numpy as np
T, H = 5, 4
hs = np.random.randn(T, H)
a = np.array([0.8, 0.1, 0.03, 0.05, 0.02])
ar = a.reshape(5, 1).repeat(4, axis=1)
print(ar.shape)
# (5, 4)
t = hs * ar
print(t.shape)
# (5, 4)
c = np.sum(t, axis=0)
print(c.shape)
# (4,)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 时序数据的长度 T=5,隐藏状态向量的元素个数 H=4
- 代码
ar = a.reshape(5, 1).repeat(4, axis=1)
将 a 转化为 ar:
- 先计算 hs 与 ar 的对应元素的乘积,然后通过
c=sum(hs*ar, axis=0)
消除第 0 个轴得到形状为 (4,) 的张量,即加权和。
repeat()
方法复制多维数组的元素生成新的多维数组,axis 指定要进行复制的轴(维度),比如在 x 的形 状为 (X, Y, Z) 的情况下,x.repeat(3, axis=1)
沿 x 的第1个轴方向(第 1个维度)进行复制,生成形状为 (X, 3*Y, Z) 的多维数组。这里其实也可以不用 repeat 而是使用 numpy 的广播功能,但我们为了显式表现出 repeat 节点,所以采用了显式调用
repeat
函数:
这里计算加权和的计算图可以绘制为:
我们将这个计算加权和的计算图实现为 Weight Sum 层。
Weight Sum 层的实现
class WeightSum:
def __init__(self):
self.params, self.grads = [], []
self.cache = None
def forward(self, hs, a):
N, T, H = hs.shape
ar = a.reshape(N, T, 1).repeat(H, axis=2)
t = hs * ar
c = np.sum(t, axis=1)
self.cache = (hs, ar)
return c
def backward(self, dc):
hs, ar = self.cache
N, T, H = hs.shape
dt = dc.reshape(N, 1, H).repeat(T, axis=1)
dar = dt * hs
dhs = dt * ar
da = np.sum(dar, axis=2)
return dhs, da
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- 这个层没有要学习的参数
# 1.4 解码器的改进 ②
有了表示各个单词重要度的权重
下面我们来看一下各个单词的权重
用
计算向量相似度的方法有好几种。除了内积之外,还有使用小型的神经网络输出得分的做法。
文献[49]提出了几种输出得分的方法
下面用图表示基于内积计算向量间相似度的处理流程:
- 这里通过向量内积算出 h 和 hs 的各个单词向量之间 的相似度,并将其结果表示为
。不过这个 是正规化之前的值,也称为得分。
接下来使用 softmax 对 s 进行正规化:
使用 Softmax 函数之后,输出的
从代码的角度看一下这个处理过程:
N, T, H = 10, 5, 4
hs = np.random.randn(N, T, H)
h = np.random.randn(N, H)
hr = h.reshape(N, 1, H).repeat(T, axis=1)
# hr = h.reshape(N, 1, H) # 广播
t = hs * hr
print(t.shape)
# (10, 5, 4)
s = np.sum(t, axis=2)
print(s.shape)
# (10, 5)
softmax = Softmax()
a = softmax.forward(s)
print(a.shape)
# (10, 5)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
计算图绘制如下:
我们将这个计算图表示的处理实现为 AttentionWeight
类:
class AttentionWeight:
def __init__(self):
self.params, self.grads = [], []
self.softmax = Softmax()
self.cache = None
def forward(self, hs, h):
N, T, H = hs.shape
hr = h.reshape(N, 1, H).repeat(T, axis=1)
t = hs * hr
s = np.sum(t, axis=2)
a = self.softmax.forward(s)
self.cache = (hs, hr)
return a
def backward(self, da):
hs, hr = self.cache
N, T, H = hs.shape
ds = self.softmax.backward(da)
dt = ds.reshape(N, T, 1).repeat(H, axis=2)
dhs = dt * hr
dhr = dt * hs
dh = np.sum(dhr, axis=1)
return dhs, dh
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
# 1.5 解码器的改进 ③
之前我们实现了 Weight Sum 层和 Attention Weight 层,现在我们将这两层组合起来:
上图显示了用于获取上下文向量
- Attention Weight 层关注编码器输出的各个单词向量
,并计算各个单词的权重 - Weight Sum 层计算
和 的加权和,并输出上下文向量
我们将进行这一系列计算的层称为 Attention 层:
以上就是 Attention 技术的核心内容:它关注编码器传递的信息
class Attention:
def __init__(self):
self.params, self.grads = [], []
self.attention_weight_layer = AttentionWeight()
self.weight_sum_layer = WeightSum()
self.attention_weight = None
def forward(self, hs, h):
a = self.attention_weight_layer.forward(hs, h)
out = self.weight_sum_layer.forward(hs, a)
self.attention_weight = a
return out
def backward(self, dout):
dhs0, da = self.weight_sum_layer.backward(dout)
dhs1, dh = self.attention_weight_layer.backward(da)
dhs = dhs0 + dhs1
return dhs, dh
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
我们将这个 Attention 层放在 LSTM 层和 Affine 层的中间:
编码器的输出
上下文向量和隐藏状态向量这两个向量被输入 Affine 层。如前所述,这意味着将这两个向量拼接起来,将拼接后的向量输入 Affine 层。
最后,我们在时序方向上扩展多个 Attention 层整体实现为 Time Attention 层:
- Time Attention 层只是组合了多个 Attention 层
Time Attention 的实现
class TimeAttention:
def __init__(self):
self.params, self.grads = [], []
self.layers = None
self.attention_weights = None
def forward(self, hs_enc, hs_dec):
N, T, H = hs_dec.shape
out = np.empty_like(hs_dec)
self.layers = []
self.attention_weights = []
for t in range(T):
layer = Attention()
out[:, t, :] = layer.forward(hs_enc, hs_dec[:,t,:])
self.layers.append(layer)
self.attention_weights.append(layer.attention_weight)
return out
def backward(self, dout):
N, T, H = dout.shape
dhs_enc = 0
dhs_dec = np.empty_like(dout)
for t in range(T):
layer = self.layers[t]
dhs, dh = layer.backward(dout[:, t, :])
dhs_enc += dhs
dhs_dec[:,t,:] = dh
return dhs_enc, dhs_dec
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
- 这里仅创建必要数量的 Attention 层(代码中为 T 个),各自进行正向传播和反向传播
attention_weights
列表中保存了各个 Attention 层对各个单词的权重
下面我们使用 Attention 来实现 seq2seq,并尝试挑战一个真实问题,以确认 Attention 的效果。
# 2. 带 Attention 的 seq2seq 的实现
我们分别实现 3 个类:AttentionEncoder、AttentionDecoder 和 AttentionSeq2seq。
# 2.1 编码器(AttentionEncoder)的实现
它与上一章的 Encoder 唯一的区别在于 Encoder 类的 forward 仅返回 LSTM 层的最后一个隐藏状态向量,而 AttentionEncoder 返回所有的隐藏状态向量:
class AttentionEncoder(Encoder):
def forward(self, xs):
xs = self.embed.forward(xs)
hs = self.lstm.forward(xs)
return hs
2
3
4
5
# 2.2 解码器(AttentionDecoder)的实现
使用了 Attention 的解码器的层结构如下图所示:
与之前一样,解码器还多了一个生成新单词序列的 generate()
方法。
这里给出其核心实现:
class AttentionDecoder:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
embed_W = (rn(V, D) / 100).astype('f')
lstm_Wx = (rn(D, 4 * H) / np.sqrt(D)).astype('f')
lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
lstm_b = np.zeros(4 * H).astype('f')
affine_W = (rn(2*H, V) / np.sqrt(2*H)).astype('f')
affine_b = np.zeros(V).astype('f')
self.embed = TimeEmbedding(embed_W)
self.lstm = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True)
self.attention = TimeAttention()
self.affine = TimeAffine(affine_W, affine_b)
layers = [self.embed, self.lstm, self.attention, self.affine]
self.params, self.grads = [], []
for layer in layers:
self.params += layer.params
self.grads += layer.grads
def forward(self, xs, enc_hs):
h = enc_hs[:,-1]
self.lstm.set_state(h)
out = self.embed.forward(xs)
dec_hs = self.lstm.forward(out)
c = self.attention.forward(enc_hs, dec_hs)
out = np.concatenate((c, dec_hs), axis=2)
score = self.affine.forward(out)
return score
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
34
最后,我们使用 AttentionEncoder 类和 AttentionDecoder 类来实现 AttentionSeq2seq 类。
# 2.3 seq2seq 的实现
AttentionSeq2seq 类的实现也和上一章实现的 seq2seq 几乎一样。区别仅在于,编码器使用 AttentionEncoder 类,解码器使用 AttentionDecoder 类。因此,只要继承上一章的 Seq2seq 类,并改一下初始化方法,就可以实现 AttentionSeq2seq 类:
class AttentionSeq2seq(Seq2seq):
def __init__(self, vocab_size, wordvec_size, hidden_size):
args = vocab_size, wordvec_size, hidden_size
self.encoder = AttentionEncoder(*args)
self.decoder = AttentionDecoder(*args)
self.softmax = TimeSoftmaxWithLoss()
self.params = self.encoder.params + self.decoder.params
self.grads = self.encoder.grads + self.decoder.grads
2
3
4
5
6
7
8
9
# 3. Attenetion 的评价
我们通过研究“日期格式转换”问题来确认带 Attention 的 seq2seq 的效果:
其实应该研究翻译问题来确认其效果,但没能找到合适的数据集。WMT 是一个有名的翻译数据集,在许多研究中都被作为基准使用,经常用于评价 seq2seq 的性能,不过它的数据量很大(超过 20 GB),使用起来不是很方便。
采用该问题的原因:这个问题的输入形式较为复杂,所以手工编写转换规则也比较复杂。其次问句与回答之间存在明显对应关系,可以用于确认 Attention 有没有有正确地关注各自的对应元素。
我们的数据集:
我们对输入语句通过填充空格来对齐。因为这个问题输出的字符数是恒定的,所以无须使用分隔符来指示输出的结束。
# 3.1 带 Attention 的 seq2seq 的学习
我们在日期转换用的数据集上进行 AttentionSeq2seq 的学习,具体的学习代码可见鱼书的附带资源。
在学习数据的过程中还使用了反转输入语句的技巧,在每个 epoch 使用测试数据计算正确率。随着学习的进行,结果如图:
随着学习的深入,带 Attention 的 seq2seq 变聪明了。实际上,没过多久,它就对大多数问题给出了正确答案:
与之前的模型相比:
在这次的实验中,就最终精度来看,Attention 和 Peeky 取得了差不多的结果。但是,随着时序数据变长、变复杂,除了学习速度之外,Attention 在精度上也会变得更有优势。
# 3.2 Attention 的可视化
在我们的实现中,Time Attention 层中的成员变量 attention_weights 保存了各个时刻的 Attention 权重,据此可以将输入语句和输出语句的各个单词的对应关系绘制成一张二维地图:
- 我们可以看到,当 seq2seq 输出第 1 个“1”时,注意力集中在输入语句的“1”上
- 输入语句的“AUGUST”对应于表示月份的“08”,这表明 seq2seq 从数据中学习到了“August”和“8 月”的对应关系。
像这样,使用 Attention,seq2seq 能像我们人一样将注意力集中在必要的信息上。
我们没有办法理解神经网络内部进行了什么工作(基于何种逻辑工作),而 Attention 赋予了模型“人类可以理解的结构和意义”。在上面的例子中,通过 Attention,我们看到了单词和单词之间的关联性。由此,我们可以判断模型的工作逻辑是否符合人类的逻辑。
下一节我们继续围绕 Attention,介绍它的几个高级技巧。
# 4. 关于 Attention 的其他话题
我们研究了带 Attention 的 seq2seq,现在我们介绍几个之前未涉及的话题。
# 4.1 双向 RNN
这里我们关注 seq2seq 的编码器:
这里我们是从左向右阅读句子的,因此单词“猫”的对应向量编码了“吾輩”“は”“猫”这 3 个单词的信息。如果考虑整体的平衡性,我们希望向量能更均衡地包含单词“猫”周围的信息。
为此,可以让 LSTM 从两个方向进行处理,这就是名为双向 LSTM 的技术:
- 双向 LSTM 在之前的 LSTM 层上添加了一个反方向处理的 LSTM 层。然后,拼接各个时刻的两个 LSTM 层的隐藏状态,将其作为最后的隐藏状态向量(除了拼接之外,也可以“求和”或者“取平均”等)
通过这样的双向处理,各个单词对应的隐藏状态向量可以从左右两个方向聚集信息。这样一来,这些向量就编码了更均衡的信息。
双向 LSTM 的实现非常简单。一种实现方式是准备两个 LSTM 层(本章中是 Time LSTM 层),并调整输入各个层的单词的排列。具体而言,其中一个层的输入语句与之前相同,这相当于从左向右处理输入语句的常规的 LSTM 层。而另一个 LSTM 层的输入语句则按照从右到左的顺序输入。如果原文是“A B C D”,就改为“D C B A”。通过输入改变了顺序的输入语句,另一个 LSTM 层从右向左处理输入语句。之后,只需要拼接这两个 LSTM 层的输出,就可以创建双向 LSTM 层。
# 4.2 Attention 层的使用方法
之前我们将 Attention 层插入了 LSTM 层和 Affine 层之间:
实际上,使用 Attention 的模型还有其他好几种方式。文献[48]以下图的结构 使用了 Attention:
- Attention 层的输出(上下文向量)被连接到了下一时刻的 LSTM 层的输入处。通过这种结构,LSTM 层得以使用上下文向量的信息。相对地,我们实现的模型则是 Affine 层使用了上下文向量。
Attention 层的位置的不同对最终精度有何影响呢?答案要试一下才知道。实际上,这只能使用真实数据来验证。不过,在上面的两个模型中,上下文向量都得到了很好的应用。因此,在这两个模型之间,我们可能看不到太大的精度差异。
# 4.3 seq2seq 的深层化和 skip connection
通过加深层,可以创建表现力更强的模型,带 Attention 的 seq2seq 也是如此。那么,如果我们加深带 Attention 的 seq2seq,结果会怎样呢?以下图为例:
- 编码器和解码器使用了 3 层 LSTM 层
- 这里将解码器 LSTM 层的隐藏状态输入 Attention 层,然后将上下文向量(Attention 层的输出)传给解码器的多个层(LSTM 层和 Affine 层)
如本例所示,编码器和解码器中通常使用层数相同的 LSTM 层。
另外,在加深层时使用到的另一个重要技巧是残差连接(skip connection,也称为 residual connection 或 shortcut),这时一种跨层连接的简单技巧:
所谓残差连接,就是指“跨层连接”,在残差连接的连接处,有两个输出被相加。因为加法在反向传播时“按原样”传播梯度,所以残差连接中的梯度可以不受任何影响地传播到前一个层。这样一来,即便加深了层,梯度也能正常传播,而不会发生梯度消失(或者梯度爆炸),学习可以顺利进行。
- 在时间方向上,RNN 层的反向传播会出现梯度消失或梯度爆炸的问题。梯度消失可以通过 LSTM、GRU 等 Gated RNN 应对,梯度爆炸可以通过梯度裁剪应对。
- 而在深度方向上的梯度消失,这里介绍的残差连接很有效。
# 5. Attention 的应用
到目前为止,我们仅将 Attention 应用在了 seq2seq 上,但是 Attention 这一想法本身是通用的。本节我们将介绍 3 个使用了 Attention 的前沿研究。
# 5.1 GNMT
回看机器翻译的历史,我们可以发现主流方法随着时代的变迁而演变。从“基于规则的翻译”到“基于用例的翻译”,再到“基于统计的翻译”。现在,神经机器翻译(Neural Machine Translation)取代了这些过往的技术,获得了广泛关注。
神经机器翻译现在已经成为使用了 seq2seq 的机器翻译的统称。
谷歌推出的 GNMT(Google Neural Machine Translation)也是由由编码器、解码器和 Attention 构成,还有许多为了提高翻译精度而做的改进。除此以外,还进行了低频词处理、用于加速推理的量化等工作,从而得到了非常好的结果。
# 5.2 Transformer
使用 RNN 可以很好地处理可变长度的时序数据,但存在并行处理的问题。RNN 需要基于上一个时刻的计算结果逐步进行计算,导致了无法在时间方向上并行计算,这会成为一个很大的瓶颈。
现在关于去除 RNN 的研究(可以并行计算的 RNN 的研究)很活跃,其中一个著名的模型是 Transformer 模型。Transformer 是在“Attention is all you need”这篇论文中提出来的方法。如论文标题所示,Transformer 不用 RNN,而用 Attention 进行处理。
除此之外,还有研究用 CNN 代替 RNN 来实现并行计算。
Transformer 是基于 Attention 构成的,其中使用了 Self-Attention 技巧,这一点很重要。Self-Attention 是以一个时序数据为对象的 Attention,旨在观察一个时序数据中每个元素与其他元素的关系:
上面左图的 Time Attention 层的两个输入中输入的是不同的时序数据,而右图的 Self-Attention 的两个输入中输入的是同一个时序数据,这样可以求得一个时序数据内各个元素之间的对应关系。
至此,对 Self-Attention 的说明就结束了,下面我们看一下 Transformer 的层结构:
- Transformer 中用 Attention 代替了 RNN,编码器和解码器两者都使用了 Self-Attention
- Feed Forward 层表示前馈神经网络(在时间方向上独立的网络)
- 图中的
表示灰色背景包围的元素被堆叠了 N 次 - 这个图是简化的 Transformer,实际上,Skip Connection、Layer Normalization 等技巧也会被用到。
使用 Transformer 可以控制计算量,充分利用 GPU 并行计算带来的好处,使得学习时间得以大幅减少。在翻译精度方面也实现了精度的提升。
由这个研究可知,Attention 其实可以用来替换 RNN。这样一来,利用 Attention 的机会可能会进一步增加。
# 5.3 NTM
可见计算机的内存操作可以通过神经网络复现。我们可以立刻想到一个方法:在 RNN 的外部配置一个存储信息的存储装置,并使用 Attention 向这个存储装置读写必要的信息。实际上,这样的研究有好几个,NTM (Neural Turing Machine,神经图灵机)[55] 就是其中比较有名的一个。
基于外部存储装置的扩展技术和 Attention 会越来越重要,今后将被应用在各种地方。
本部分不再展开,内容可参考鱼书或其他资料。
# 参考文献
文献引用
[48] Bahdanau, Dzmitry, Kyunghyun Cho, Yoshua Bengio:Neural machine translation by jointly learning to align and translate[J]. arXiv preprint arXiv:1409.0473, 2014.
[49] Luong, Minh-Thang, Hieu Pham, Christopher D. Manning.Effective approaches to attention-based neural machine translation[J]. arXiv prelprint arXiv:1508.04025, 2015.
[55] Graves, Alex, Greg Wayne, Ivo Danihelka,Neural turing machines[J]. arXiv preprint arXiv:1410.5401, 2014.