我想去面试系列——Attention与Transformer

2020-10-29

假如捕获了你的注意力,我已然成为了变形金刚…

基本原理

Attention的基本原理就是对于输入实现有权重的加权。大体思路是:输入$X$,分别乘三个权重矩阵$W^Q,W^K,W^V$得到$Q,K,V$,然后按照$Softmax(sim(Q,K))V$得到结果。

Seq2Seq中的Attention

Encoder部分先得到隐藏状态 $(h_1,h_2,…,h_T)$,然后在Decoder部分,比如在$t$时刻进行解码时,比如我们已经知道了前一个时间步骤decoder的隐藏状态$s_{t-1}$,我们先计算每一个encoder的位置的隐藏状态和decoder当前时间隐藏状态的关联性,$e_{tj}=a(s_{t-1},h_j),j\in[1,T]$,写成向量的形式就是$e_t$,对其进行softmax归一化就得到了对于decoder当前隐藏状态的attention分布$\alpha_{t j}=\frac{\exp \left(e_{t j}\right)}{\sum_{k=1}^{T} \exp \left(e_{t k}\right)}$。从而利用$\alpha$作为权重对encoder的隐藏状态加权求和就得到了对应的上下文向量$c_t=\sum_{j=1}^{T}\alpha_{tj}h_j$。由此就可以计算decoder的下一个隐藏状态$s_t=f(s_{t-1},y_{t-1},c_t)$以及该位置的输出 $p(y_t|y_1,…,y_{t-1},x)=g(y_{i-1},s_i,c_i)$。

这里的Encoder实际上还是用了RNN那一套老路子,只是对Decoder解码的时候进行了一些改进,所以算是比较基础的东西,为了进一步改进,克服RNN编码长距离信息能力较弱的特点,进入了self-attention自注意力机制。

Self-Attention

引入这玩意的优势就在于,1.编码可以拥有更加完善的全局信息了,而不再受距离的限制;2.可以并行化了,而不是像之前RNN一样必须前面的算完才能算后续时间步的。self-attention就是利用了注意力机制,在计算某个单词时,考虑该单词与其他所有单词之间的关联。下面我们来看一看Self-attention的结构究竟是啥样的。

对于self-attention来讲,Q(Query), K(Key), V(Value)三个矩阵均来自同一输入(由同一输入X乘以三个不同的权重矩阵),首先我们要计算Q与K之间的点乘,然后为了防止其结果过大,会除以一个尺度标度$\sqrt{d_k}$(这里的这个操作属于缩放点积,事实上不只有这一种计算方式),其中$d_k = hidden_size / num_heads$。再利用Softmax操作将其结果归一化为概率分布,然后再乘以矩阵V就得到权重求和的表示。该操作可以表示为

https://jalammar.github.io/illustrated-transformer/里给了一个示意图。

上图的具体的含义可以解释为,假如我们要翻译一个词组Thinking Machines,分别转换成embedding之后是$x_1$、$x_2$,然后分别乘以$W^Q,W^K,W^V$得到两个词分别对应的向量以thinking为例得到$q_{thinking}、k_{thinking}、v_{thinking}$,然后需要计算Thinking这个词与句子中所有词的attention score,相当于拿$q_{thinking}$作为搜索的Query,去和句子中所有词(包含其本身)的Key做匹配,看相关性有多高。这里直接用点乘作为相似度计算函数,然后就得到了Thinking与Thinking和Machines两个单词的相似度,再做个尺度缩放和softmax归一化,最后就得到了一个注意力分布,再拿这个注意力分布就乘每个词分别对应的Value,就可以得到Thinking翻译后得到的结果变量。可以解释为,当我们在思考如何翻译Thinking的时候,主要注意力是在这个词本身上,同时还注意到了一点它周围上下文词的语境含义。

如果多个输入向量合并成矩阵的形式(矩阵运算的方式,使得 Self Attention 的计算能够并行化,这也是 Self Attention 最终的实现方式),就相当于来了一个输入矩阵$X$,然后用同一个$X$计算得到$Q、K、V$,当然这里的$Q、K、V$肯定不是一样的而是分别过三个不同的矩阵($W^Q$、$W^K$、$W^V$,这三个矩阵是可学习参数)得到的三个不同的矩阵。所以实际上self-attention就是$Q,K,V$都是由同一个输入计算来的的attention。经过self-attention之后,输出矩阵/向量$Z$,该输出就包含了每个词和其他上下文的综合信息,其中每个单词自身占大头,上下文占小头。可以将self-attention理解为一种融合上下文信息的方式。

Multi-Head-Attention

多头注意力,就是把X往不同的方向投影形成很多组$Q_i,K_i,V_i$,切割成不同方面的attention(也就是不同的head),期待每一个attention学出来的东西不一样,可以捕获不同特征。然后最后再把不同head捕获到的结果$Z_i$拼起来,乘以最终的大权重矩阵$W^O$就得到了最终的融合了更多信息的特征向量。实际中,K、V 矩阵的序列长度是一样的,而 Q 矩阵的序列长度可以不一样。

Attention分类

Soft or Hard Attention

  • Soft attention是指传统的attention,是可以被嵌入到模型中去训练并且传播梯度的;
  • Hard attention不计算所有输出,依据概率对encoder的输出采样,在反向传播时需采用蒙特卡洛进行梯度估计。

Global or Local Attention

  • Global attention是传统的attention,对所有encoder输出进行计算
  • Local attention是预测特定位置并选取一个窗口进行attention权重加权计算,介于soft和hard之间。

Transformer

attention的东西实际还是很好理解的,那么当attention引入特征提取器之后,就形成了现在盛行的特征提取器大杀器——Transformer。

对于encoder来说,就是直接利用最朴素的multi-head-attention,更准确的说因为输入只有一个所以应该是multi-head-self-attention,每一层(共N层)encoder的key, query, value均来自前一层encoder的输出,即encoder的每个位置都可以注意到之前一层encoder的所有位置。Encoder部分的计算归纳如下:

对于decoder来讲,我们注意到有两个与encoder不同的地方,一个是第一级的Masked Multi-head,另一个是第二级的Multi-Head Attention不仅接受来自前一级的输出,还要接收encoder的输出。Decoder的输入是Output(Target右移一位),也就是相当于一开始的输入是[CLS],然后结合输入句子的第一个词的Attention,mask掉除了[CLS]之后词,输出了第一个翻译过来的词,依次类推。下面分别解释一下是什么原理。

第一级decoder的key, query, value均来自前一层decoder的输出,但加入了Mask操作,即我们只能attend到前面已经翻译过的输出的词语,因为翻译过程我们当前还并不知道下一个输出词语,这是我们之后才会推测到的。(这里的Mask是指在预测第t个词的时候要把t+1到末尾的词遮住,只对前面t个词做self attention,并且mask只对于train的时候做,在predict阶段不用mask)。

而第二级decoder也被称作encoder-decoder attention layer,即它的query来自于之前一级的decoder层的输出,但其key和value来自于encoder的输出,这使得decoder的每一个位置都可以attend到输入序列的每一个位置。总结一下,$K$和$V$的来源总是相同的,$Q$在encoder及第一级decoder中与$K$、$V$来源相同,在encoder-decoder attention layer中与$K$、$V$来源不同。

Decoder部分的计算归纳如下,输出P代表着当前位置下输出词的概率分布:

还有一个要注意的是,原生transformer里的positional embedding是通过sin、cos直接计算的固定值,具体公式为$PositionalEmbedding(pos,2i)=sin(pos/10000^{2i/d_{model}});$$PositionalEmbedding(pos,2i+1)=cos(pos/10000^{2i/d_{model}})$。而该embedding的含义很明显就是加入单词在原句中的位置信息。残差Add的作用是为了解决多层神经网络训练困难的问题,通过将前一层的信息无差的传递到下一层,可以有效的仅关注差异部分,$Output(x) = F(x)+x$。正则LayerNorm的作用是通过对层的激活值的归一化,可以加速模型的训练过程,使其更快的收敛。这里要特别注意一下,编码可以并行计算,一次性全部encoding出来;但解码不能并行,因为需要像rnn一样一个一个顺序解码出来。

代码实现

Pytorch有封装好的多头注意力,torch.nn.MultiheadAttention(embed_dim, num_heads, dropout=0.0, bias=True, add_bias_kv=False, add_zero_attn=False, kdim=None, vdim=None),调用时foward函数为forward(query, key, value, key_padding_mask=None, need_weights=True, attn_mask=None)

手写的话,代码如下,multihead的实现竟然只是用了一个权重矩阵,维度是$d_{hidden_size} \times d_{hidden_size}$,并且要求$d_{hidden_size}=d_{k} \times d_{num_heads}$,而不是多个权重矩阵获得多个$QKV$,竟然只是把一个300维的向量拆成6个50维的向量…(我上边说的那种用多个权重矩阵的应该属于另外一种实现方式,我写的那个方法的话$W_i$的维度应该是$d_{hidden_size} \times d_{k}$,然后搞$d_{num_heads}$个$W_i$):

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class MultiheadAttention(nn.Module):
# n_heads:多头注意力的数量
# hid_dim:每个词输出的向量维度
def __init__(self, hid_dim, n_heads, dropout):
super(MultiheadAttention, self).__init__()
self.hid_dim = hid_dim
self.n_heads = n_heads

# 强制 hid_dim 必须整除 h
assert hid_dim % n_heads == 0
# 定义 W_q 矩阵
self.w_q = nn.Linear(hid_dim, hid_dim)
# 定义 W_k 矩阵
self.w_k = nn.Linear(hid_dim, hid_dim)
# 定义 W_v 矩阵
self.w_v = nn.Linear(hid_dim, hid_dim)
self.fc = nn.Linear(hid_dim, hid_dim)
self.do = nn.Dropout(dropout)
# 缩放
self.scale = torch.sqrt(torch.FloatTensor([hid_dim // n_heads]))

def forward(self, query, key, value, mask=None):
# K: [64,10,300], batch_size 为 64,有 10 个词,每个词的 Query 向量是 300 维
# V: [64,10,300], batch_size 为 64,有 10 个词,每个词的 Query 向量是 300 维
# Q: [64,12,300], batch_size 为 64,有 12 个词,每个词的 Query 向量是 300 维
bsz = query.shape[0]
Q = self.w_q(query)
K = self.w_k(key)
V = self.w_v(value)
# 这里把 K Q V 矩阵拆分为多组注意力,变成了一个 4 维的矩阵
# 最后一维就是是用 self.hid_dim // self.n_heads 来得到的,表示每组注意力的向量长度, 每个 head 的向量长度是:300/6=50
# 64 表示 batch size,6 表示有 6组注意力,10 表示有 10 词,50 表示每组注意力的词的向量长度
# K: [64,10,300] 拆分多组注意力 -> [64,10,6,50] 转置得到 -> [64,6,10,50]
# V: [64,10,300] 拆分多组注意力 -> [64,10,6,50] 转置得到 -> [64,6,10,50]
# Q: [64,12,300] 拆分多组注意力 -> [64,12,6,50] 转置得到 -> [64,6,12,50]
# 转置是为了把注意力的数量 6 放到前面,把 10 和 50 放到后面,方便下面计算
Q = Q.view(bsz, -1, self.n_heads, self.hid_dim //
self.n_heads).permute(0, 2, 1, 3)
K = K.view(bsz, -1, self.n_heads, self.hid_dim //
self.n_heads).permute(0, 2, 1, 3)
V = V.view(bsz, -1, self.n_heads, self.hid_dim //
self.n_heads).permute(0, 2, 1, 3)

# 第 1 步:Q 乘以 K的转置,除以scale
# [64,6,12,50] * [64,6,50,10] = [64,6,12,10]
# attention:[64,6,12,10]
attention = torch.matmul(Q, K.permute(0, 1, 3, 2)) / self.scale

# 把 mask 不为空,那么就把 mask 为 0 的位置的 attention 分数设置为 -1e10
if mask is not None:
attention = attention.masked_fill(mask == 0, -1e10)

# 第 2 步:计算上一步结果的 softmax,再经过 dropout,得到 attention。
# 注意,这里是对最后一维做 softmax,也就是在输入序列的维度做 softmax
# attention: [64,6,12,10]
attention = self.do(torch.softmax(attention, dim=-1))

# 第三步,attention结果与V相乘,得到多头注意力的结果
# [64,6,12,10] * [64,6,10,50] = [64,6,12,50]
# x: [64,6,12,50]
x = torch.matmul(attention, V)

# 因为 query 有 12 个词,所以把 12 放到前面,把 5 和 60 放到后面,方便下面拼接多组的结果
# x: [64,6,12,50] 转置-> [64,12,6,50]
x = x.permute(0, 2, 1, 3).contiguous()
# 这里的矩阵转换就是:把多组注意力的结果拼接起来
# 最终结果就是 [64,12,300]
# x: [64,12,6,50] -> [64,12,300]
x = x.view(bsz, -1, self.n_heads * (self.hid_dim // self.n_heads))
x = self.fc(x)
return x


# batch_size 为 64,有 12 个词,每个词的 Query 向量是 300 维
query = torch.rand(64, 12, 300)
# batch_size 为 64,有 12 个词,每个词的 Key 向量是 300 维
key = torch.rand(64, 10, 300)
# batch_size 为 64,有 10 个词,每个词的 Value 向量是 300 维
value = torch.rand(64, 10, 300)
attention = MultiheadAttention(hid_dim=300, n_heads=6, dropout=0.1)
output = attention(query, key, value)
## output: torch.Size([64, 12, 300])
print(output.shape)

细节 & 面试题搜集

后续更新…

参考文献

  1. Attention机制详解(一)——Seq2Seq中的Attention:https://zhuanlan.zhihu.com/p/47063917
  2. Attention机制详解(二)——Self-Attention与Transformer:https://zhuanlan.zhihu.com/p/47282410
  3. 【NLP】Transformer模型原理详解:https://zhuanlan.zhihu.com/p/44121378
  4. NLP中的Attention原理和源码解析:https://zhuanlan.zhihu.com/p/43493999
  5. jalammar.github.io:https://jalammar.github.io/illustrated-transformer/
  6. 图解Transformer(完整版):https://mp.weixin.qq.com/s?__biz=MzU2OTA0NzE2NA==&mid=2247539981&idx=7&sn=07b00654c6f38d743a91b39d33d68d6a&chksm=fc86bc1ecbf135087d885408704ff90c95562c00108bcad7ded93fb468edd6534010e709299f&scene=0&xtrack=1#rd
  7. NLPer看过来,一些关于Transformer的问题整理:https://www.nowcoder.com/discuss/258321?type=post&order=time&pos=&page=1&channel=1009&source_id=search_post
  8. 一文看懂 Attention(本质原理+3大优点+5大类型):https://medium.com/@pkqiang49/%E4%B8%80%E6%96%87%E7%9C%8B%E6%87%82-attention-%E6%9C%AC%E8%B4%A8%E5%8E%9F%E7%90%86-3%E5%A4%A7%E4%BC%98%E7%82%B9-5%E5%A4%A7%E7%B1%BB%E5%9E%8B-e4fbe4b6d030
  9. 碎碎念:Transformer的解码加速:https://zhuanlan.zhihu.com/p/75796168
  10. Transformer的矩阵维度分析和Mask详解:https://blog.csdn.net/qq_35169059/article/details/101678207
  11. Transformer源码剖析:https://zhuanlan.zhihu.com/p/149766082
  12. transformer详解:transformer/ universal transformer/ transformer-XL:https://akeeper.space/blog/701.html
  13. Attention:https://looperxx.github.io/Attention/

本文来源:「想飞的小菜鸡」的个人网站 vodkazy.cn

版权声明:本文为「想飞的小菜鸡」的原创文章,采用 BY-NC-SA 许可协议,转载请附上原文出处链接及本声明。

原文链接:https://vodkazy.cn/2020/10/29/我想去面试系列——Attention与Transformer

支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者,更多文章请访问想飞的小菜鸡