引言
在阅读一篇文章时,我们的大脑并不平等地处理每一个字词,而是根据上下文自动筛选出核心信息进行深入理解。注意力机制正是借鉴了这一生物学灵感,使得机器学习模型能够动态地分配其“注意力”资源,针对不同的输入部分赋予不同的重视程度,从而在纷繁复杂的数据中捕捉到最相关的特征。
从Transformer架构的横空出世,到BERT等预训练语言模型的惊艳表现,注意力机制已经成为推动自然语言处理乃至整个AI领域迅猛发展的核心驱动力之一。它不仅解决了长距离依赖问题,还使得模型能够更加灵活和高效地学习,即便是面对高度变异性或结构复杂的数据也能游刃有余。
注意力机制介绍
我们观察事物时,之所以能够快速判断一种事物(当然允许判断是错误的), 是因为我们大脑能够很快把注意力放在事物最具有辨识度的部分从而作出判断,而并非是从头到尾的观察一遍事物后,才能有判断结果. 正是基于这样的理论,就产生了注意力机制。
它需要三个指定的输入Q(query), K(key), V(value), 然后通过计算公式得到注意力的结果, 这个结果代表query在key和value作用下的注意力表示. 当输入的Q=K=V时, 称作自注意力计算规则。
常见的注意力计算规则
-
将Q,K进行纵轴拼接, 做一次线性变化, 再使用softmax处理获得结果最后与V做张量乘法.Attention(Q,K,V)=Softmax(Linear([Q,K]))⋅V
-
将Q,K进行纵轴拼接, 做一次线性变化后再使用tanh函数激活, 然后再进行内部求和, 最后使用softmax处理获得结果再与V做张量法.Attention(Q,K,V)=Softmax(sum(tanh(Linear([Q,K]))))⋅V
-
将Q与K的转置做点积运算, 然后除以一个缩放系数, 再使用softmax处理获得结果最后与V做张量乘法.Attention(Q,K,V)=Softmax(Q⋅KT / √dk)⋅V
-
说明:当注意力权重矩阵和V都是三维张量且第一维代表为batch条数时, 则做bmm运算.bmm是一种特殊的张量乘法运算
bmm运算演示:
# 如果参数1形状是(b × n × m), 参数2形状是(b × m × p), 则输出为(b × n × p)
>>> input = torch.randn(10, 3, 4)
>>> mat2 = torch.randn(10, 4, 5)
>>> res = torch.bmm(input, mat2)
>>> res.size()
torch.Size([10, 3, 5])
注意力机制是注意力计算规则能够应用的深度学习网络的载体, 同时包括一些必要的全连接层以及相关张量处理, 使其与应用网络融为一体. 使用自注意力计算规则的注意力机制称为自注意力机制,NLP领域中, 当前的注意力机制大多数应用于seq2seq架构, 即编码器和解码器模型。
注意力机制实现步骤
- 第一步: 根据注意力计算规则, 对Q,K,V进行相应的计算.
- 第二步: 根据第一步采用的计算方法, 如果是拼接方法,则需要将Q与第二步的计算结果再进行拼接, 如果是转置点积, 一般是自注意力, Q与V相同, 则不需要进行与Q的拼接.
- 第三步: 最后为了使整个attention机制按照指定尺寸输出, 使用线性层作用在第二步的结果上做一个线性变换, 得到最终对Q的注意力表示
import torch
import torch.nn as nn
import torch.nn.functional as F
class Attn(nn.Module):
def __init__(self, query_size, key_size, value_size1, value_size2, output_size):
"""初始化函数中的参数有5个, query_size代表query的最后一维大小
key_size代表key的最后一维大小, value_size1代表value的导数第二维大小,
value = (1, value_size1, value_size2)
value_size2代表value的倒数第一维大小, output_size输出的最后一维大小"""
super(Attn, self).__init__()
# 将以下参数传入类中
self.query_size = query_size
self.key_size = key_size
self.value_size1 = value_size1
self.value_size2 = value_size2
self.output_size = output_size
# 初始化注意力机制实现第一步中需要的线性层.
self.attn = nn.Linear(self.query_size + self.key_size, value_size1)
# 初始化注意力机制实现第三步中需要的线性层.
self.attn_combine = nn.Linear(self.query_size + value_size2, output_size)
def forward(self, Q, K, V):
"""forward函数的输入参数有三个, 分别是Q, K, V, 根据模型训练常识, 输入给Attion机制的
张量一般情况都是三维张量, 因此这里也假设Q, K, V都是三维张量"""
# 第一步, 按照计算规则进行计算,
# 我们采用常见的第一种计算规则
# 将Q,K进行纵轴拼接, 做一次线性变化, 最后使用softmax处理获得结果
attn_weights = F.softmax(
self.attn(torch.cat((Q[0], K[0]), 1)), dim=1)
# 然后进行第一步的后半部分, 将得到的权重矩阵与V做矩阵乘法计算,
# 当二者都是三维张量且第一维代表为batch条数时, 则做bmm运算
attn_applied = torch.bmm(attn_weights.unsqueeze(0), V)
# 之后进行第二步, 通过取[0]是用来降维, 根据第一步采用的计算方法,
# 需要将Q与第一步的计算结果再进行拼接
output = torch.cat((Q[0], attn_applied[0]), 1)
# 最后是第三步, 使用线性层作用在第三步的结果上做一个线性变换并扩展维度,得到输出
# 因为要保证输出也是三维张量, 因此使用unsqueeze(0)扩展维度
output = self.attn_combine(output).unsqueeze(0)
return output, attn_weights
调用输出打印:
query_size = 32
key_size = 32
value_size1 = 32
value_size2 = 64
output_size = 64
attn = Attn(query_size, key_size, value_size1, value_size2, output_size)
Q = torch.randn(1,1,32)
K = torch.randn(1,1,32)
V = torch.randn(1,32,64)
out = attn(Q, K ,V)
print(out[0])
print(out[1])
#
tensor([[[ 0.5516, -0.3521, -0.3781, 0.3092, 0.1177, -0.0565, 0.1061,
-0.4302, -0.6292, 0.0413, 0.0801, 0.2090, 0.2203, -0.1348,
0.5017, 0.4179, 0.1984, 0.0271, -0.0231, -0.2771, 0.1479,
-0.0940, -0.5132, -0.3395, 0.2101, -0.2790, -0.0369, 0.3575,
0.3478, -0.2412, 0.0185, 0.3209, -0.0266, -0.1229, 0.1988,
0.5011, 0.2373, -0.0945, -0.2623, 0.1937, -0.7264, -0.1000,
0.0942, -0.7034, 0.0833, 0.0088, -0.1904, 0.5210, 0.8732,
-0.1510, 0.2940, -0.3701, 0.4335, 0.3952, -0.1875, 0.0576,
-0.0145, 0.2639, 0.4688, 0.0203, 0.2685, 0.2491, -0.5202,
-0.3083]]], grad_fn=<UnsqueezeBackward0>)
tensor([[0.0215, 0.0283, 0.0376, 0.0398, 0.0245, 0.0393, 0.0443, 0.0188, 0.0355,
0.0616, 0.0721, 0.0136, 0.1269, 0.0221, 0.0099, 0.0248, 0.0108, 0.0232,
0.0203, 0.0316, 0.0235, 0.0168, 0.0599, 0.0156, 0.0204, 0.0257, 0.0128,
0.0157, 0.0210, 0.0320, 0.0196, 0.0306]], grad_fn=<SoftmaxBackward0>)
注意力机制示意图
Attention机制的工作原理并不复杂,我们可以用下面这张图做一个总结
💥举个例子:
一张图片有树、时光塔、雪、人、路。
可能对于一开始我们会先注意到雪和时光塔,然后其次是树、人、路。
我们可以抽象的为其设置权重:90、80、50、50、20,这些权值就是k,这些元素就是Value。
然后给出一个词:生物,我们就会首先注意到人和树,其次是后三个元素,这时我们大脑已经把权重重新分配了,人和树的权重为:90、80。此时的'生物'就是q,即query。
Attention计算过程
- 阶段一: query 和 key 进行相似度计算,得到一个query 和 key 相关性的分值
- 阶段二: 将这个分值进行归一化(softmax),得到一个注意力的分布
- 阶段三: 使用注意力分布和 value 进行计算,得到一个融合注意力的更好的 value 值
为了更好的说明上面的情况, 我们通过注意力来做一个机器翻译的任务,机器翻译中,我们会使用 seq2seq 的架构,每个时间步从词典里生成一个翻译的结果。
在没有注意力之前,我们每次都是根据 Encoder 部分的输出结果来进行生成,提出注意力后,就是想在生成翻译结果时并不是看 Encoder 中所有的输出结果,而是先来看看想生成的这部分和哪些单词可能关系会比较大,关系大的我多借鉴些;关系小的,少借鉴些。就是这样一个想法,我们看看该如何操作。
Attention计算逻辑
当然,Attention 并不是只有这一种计算方式,后来还有很多人找到了各种各样的计算注意力的方法, 比如我们上面介绍的三种计算规则, 但是从本质上,它们都遵循着这个三步走的逻辑:
- query 和 key 进行相似度计算,得到一个query 和 key 相关性的分值
- 将这个分值进行归一化(softmax),得到一个注意力的分布
- 使用注意力分布和 value 进行计算,得到一个融合注意力的更好的 value 值
有无attention模型对比
无attention机制的模型
文本处理领域的Encoder-Decoder框架可以这么直观地去理解:可以把它看作适合处理由一个句子(或篇章)生成另外一个句子(或篇章)的通用处理模型。对于句子对,我们的目标是给定输入句子Source,期待通过Encoder-Decoder框架来生成目标句子Target。Source和Target可以是同一种语言,也可以是两种不同的语言。而Source和Target分别由各自的单词序列构成:
encoder顾名思义就是对输入句子Source进行编码,将输入句子通过非线性变换转化为中间语义表示C:
对于解码器Decoder来说,其任务是根据句子Source的中间语义表示C和之前已经生成的历史信息,y_1, y_2…y_i-1来生成i时刻要生成的单词y_i
上述图中展示的Encoder-Decoder框架是没有体现出“注意力模型”的,所以可以把它看作是注意力不集中的分心模型。为什么说它注意力不集中呢?请观察下目标句子Target中每个单词的生成过程如下:
-
💥其中f是Decoder的非线性变换函数。从这里可以看出,在生成目标句子的单词时,不论生成哪个单词,它们使用的输入句子Source的语义编码C都是一样的,没有任何区别。
-
💥每个yi都依次这么产生,那么看起来就是整个系统根据输入句子Source生成了目标句子Target。如果Source是中文句子,Target是英文句子,那么这就是解决机器翻译问题的Encoder-Decoder框架;如果Source是一篇文章,Target是概括性的几句描述语句,那么这是文本摘要的Encoder-Decoder框架;如果Source是一句问句,Target是一句回答,那么这是问答系统或者对话机器人的Encoder-Decoder框架。由此可见,在文本处理领域,Encoder-Decoder的应用领域相当广泛。
问题点是: 语义编码C是由句子Source的每个单词经过Encoder 编码产生的,这意味着不论是生成哪个单词,还是,其实句子Source中任意单词对生成某个目标单词yi来说影响力都是相同的,这是为何说这个模型没有体现出注意力的缘由。这类似于人类看到眼前的画面,但是眼中却没有注意焦点一样。
有attention机制的模型
💯如果拿机器翻译来解释这个分心模型的Encoder-Decoder框架更好理解,比如输入的是英文句子:Tom chase Jerry,Encoder-Decoder框架逐步生成中文单词:“汤姆”,“追逐”,“杰瑞”。在翻译“杰瑞”这个中文单词的时候,分心模型里面的每个英文单词对于翻译目标单词“杰瑞”贡献是相同的,很明显这里不太合理,显然“Jerry”对于翻译成“杰瑞”更重要,但是分心模型是无法体现这一点的,这就是为何说它没有引入注意力的原因。
💦没有引入注意力的模型在输入句子比较短的时候问题不大,但是如果输入句子比较长,此时所有语义完全通过一个中间语义向量来表示,单词自身的信息已经消失,可想而知会丢失很多细节信息,这也是为何要引入注意力模型的重要原因。
💦上面的例子中,如果引入Attention模型的话,应该在翻译“杰瑞”的时候,体现出英文单词对于翻译当前中文单词不同的影响程度,比如给出类似下面一个概率分布值:(Tom,0.3)(Chase,0.2) (Jerry,0.5).每个英文单词的概率代表了翻译当前单词“杰瑞”时,注意力分配模型分配给不同英文单词的注意力大小。这对于正确翻译目标语单词肯定是有帮助的,因为引入了新的信息。
同理,目标句子中的每个单词都应该学会其对应的源语句子中单词的注意力分配概率信息。这意味着在生成每个单词的时候,原先都是相同的中间语义表示C会被替换成根据当前生成单词而不断变化的。理解Attention模型的关键就是这里,即由固定的中间语义表示C换成了根据当前输出单词来调整成加入注意力模型的变化的。
生成目标句子单词的过程成了下面的形式:
而每个Ci可能对应着不同的源语句子单词的注意力分配概率分布,比如对于上面的英汉翻译来说,其对应的信息如下:
💥f2函数代表Encoder对输入英文单词的某种变换函数,比如如果Encoder是用的RNN模型的话,这个f2函数的结果往往是某个时刻输入后隐层节点的状态值;g代表Encoder根据单词的中间表示合成整个句子中间语义表示的变换函数,一般的做法中,g函数就是对构成元素加权求和。
-
Lx代表输入句子source的长度, a_ij代表在Target输出第i个单词时source输入句子中的第j个单词的注意力分配系数, 而hj则是source输入句子中第j个单词的语义编码, 假设Ci下标i就是上面例子所说的'汤姆', 那么Lx就是3, h1=f('Tom'), h2=f('Chase'),h3=f('jerry')分别输入句子每个单词的语义编码, 对应的注意力模型权值则分别是0.6, 0.2, 0.2, 所以g函数本质上就是加权求和函数, 如果形象表示的话, 翻译中文单词'汤姆'的时候, 数学公式对应的中间语义表示Ci的形成过程类似下图:
Self-attention介绍
Self-attention就本质上是一种特殊的attention。这种应用在transformer中最重要的结构之一。前面我们介绍了attention机制,它能够帮我们找到子序列和全局的attention的关系,也就是找到权重值𝑤𝑖。Self-attention向对于attention的变化,其实就是寻找权重值的𝑤𝑖过程不同。
为了能够产生输出的向量𝑦𝑖 ,self-attention其实是对所有的输入做了一个加权平均的操作,这个公式和上面的attention是一致的。
-
𝑗代表整个序列的长度,并且j𝑗个权重的相加之和等于1。值得一提的是,这里的 wij𝑤𝑖𝑗并不是一个需要神经网络学习的参数,它是来源于𝑥𝑖和𝑥𝑗的之间的计算的结果(这里𝑤𝑖𝑗的计算发生了变化)。它们之间最简单的一种计算方式,就是使用点积的方式。
Self-attention和Attention使用方法
- 在神经网络中,通常来说你会有输入层(input),应用激活函数后的输出层(output),在RNN当中你会有状态(state)。如果attention (AT) 被应用在某一层的话,它更多的是被应用在输出或者是状态层上,而当我们使用self-attention(SA),这种注意力的机制更多的实在关注input上。
- Attention (AT) 经常被应用在从编码器(encoder)转换到解码器(decoder)。比如说,解码器的神经元会接受一些AT从编码层生成的输入信息。在这种情况下,AT连接的是**两个不同的组件**(component),编码器和解码器。但是如果我们用**SA**,它就不是关注的两个组件,它只是在关注你应用的**那一个组件**。那这里他就不会去关注解码器了,就比如说在Bert中,使用的情况,我们就没有解码器。
- SA可以在一个模型当中被多次的、独立的使用(比如说在Transformer中,使用了18次;在Bert当中使用12次)。但是,AT在一个模型当中经常只是被使用一次,并且起到连接两个组件的作用。
- SA比较擅长在一个序列当中,寻找不同部分之间的关系。比如说,在词法分析的过程中,能够帮助去理解不同词之间的关系。AT却更擅长寻找两个序列之间的关系,比如说在翻译任务当中,原始的文本和翻译后的文本。这里也要注意,在翻译任务重,SA也很擅长,比如说Transformer。
- AT可以连接两种不同的模态,比如说图片和文字。SA更多的是被应用在同一种模态上,但是如果一定要使用SA来做的话,也可以将不同的模态组合成一个序列,再使用SA。
Self-attetion实现步骤
self-attention机制的实现步骤:
- 第一步: 准备输入
- 第二步: 初始化参数
- 第三步: 获取key,query和value
- 第四步: 给input1计算attention score
- 第五步: 计算softmax
- 第六步: 给value乘上score
- 第七步: 给value加权求和获取output1
- 第八步: 重复步骤4-7,获取output2,output3
# 这里我们随机设置三个输入, 每个输入的维度是一个4维向量
import torch
x = [
[1, 0, 1, 0], # Input 1
[0, 2, 0, 2], # Input 2
[1, 1, 1, 1] # Input 3
]
x = torch.tensor(x, dtype=torch.float32)
初始化参数
# 每一个输入都有三个表示,分别为key(橙黄色)query(红色)value(紫色)。比如说,每一个表示我们希望是一个3维的向量。由于输入是4维,所以我们的参数矩阵为 4*3 维。
# 为了能够获取这些表示,每一个输入(绿色)要和key,query和value相乘,在例子中,我们使用如下的方式初始化这些参数。
w_key = [
[0, 0, 1],
[1, 1, 0],
[0, 1, 0],
[1, 1, 0]
]
w_query = [
[1, 0, 1],
[1, 0, 0],
[0, 0, 1],
[0, 1, 1]
]
w_value = [
[0, 2, 0],
[0, 3, 0],
[1, 0, 3],
[1, 1, 0]
]
w_key = torch.tensor(w_key, dtype=torch.float32)
w_query = torch.tensor(w_query, dtype=torch.float32)
w_value = torch.tensor(w_value, dtype=torch.float32)
print("w_key: \n", w_key)
print("w_query: \n", w_query)
print("w_value: \n", w_value)