Transformer注意力机制解析与实现

191 阅读13分钟

  • 什么是注意力

    • 我们观察事物时,之所以能够快速判断―种事物(当然允许判断是错误的),是因为我们大脑能够很快把注意力放在事物最具有辨识度的部分从而作出判断,而并非是从头到尾的观察一遍事物后,才能有判断结果.正是基于这样的理论,就产生了注意力机制.

Transformer的注意力机制详解

Transformer模型是由Vaswani等人于2017年提出的,用于自然语言处理(N语言处理)等任务,并广泛应用于诸如机器翻译、文本生成、语音识别等领域。其核心创新之一就是注意力机制(Attention Mechanism),特别是自注意力机制(Self-Attention),它通过并行计算和全局依赖关系的建模,替代了传统的循环神经网络(RNN)和卷积神经网络(CNN),大大提高了训练效率和效果。

1. 什么是注意力机制?

注意力机制源自人类处理信息的方式:我们不会在所有信息上投入相同的注意力,而是关注某些对当前任务最重要的信息。在神经网络中,注意力机制是通过加权求和的方式来突出重要的信息,忽略无关的部分。

具体来说,注意力机制的核心思想是:对于输入的每一个元素(如词向量),计算与其他元素的相关性(即"注意力"值),并根据这些相关性加权输入的元素,得到一个加权的输出。这种机制使得模型能够灵活地对输入进行聚焦,增强对上下文的理解。

2. Transformer中的注意力机制

在Transformer模型中,最重要的注意力机制是Scaled Dot-Product Attention,它在计算时不依赖于序列的顺序,而是对所有输入进行并行计算。

2.1 Scaled Dot-Product Attention

其计算过程如下:

给定输入序列中每个元素的查询(Query)键(Key)、**值(Value)**向量,我们使用以下公式计算注意力分数:

  1. 计算查询和键的点积:首先,将查询向量 (Q) 和键向量 (K) 做点积,计算每个查询和所有键的相关性得分。这告诉我们每个元素对于其他元素的“注意力”。

    Attention Score=QKT\text{Attention Score} = Q \cdot K^T

    其中,(Q) 是查询向量,(K) 是键向量,(Q \cdot K^T) 表示点积。

  2. 缩放:由于点积的数值可能随着维度的增加而增大,因此在计算时会对点积结果进行缩放。缩放的方式是除以一个常数 (\sqrt{d_k}),其中 (d_k) 是键向量的维度。

    Scaled Attention Score=QKTd_k\text{Scaled Attention Score} = \frac{Q \cdot K^T}{\sqrt{d\_k}}

    缩放的目的是避免点积值过大导致数值不稳定。

  3. 应用Softmax:将缩放后的得分通过Softmax函数进行归一化,得到权重,表示查询向量与每个键向量的相关程度(即注意力)。

    Attention Weights=Softmax(QKTd_k)\text{Attention Weights} = \text{Softmax}\left(\frac{Q \cdot K^T}{\sqrt{d\_k}}\right)
  4. 加权求和:最后,将注意力权重与值向量 (V) 做加权求和,得到最终的输出。

    Output=Attention WeightsV\text{Output} = \text{Attention Weights} \cdot V

总结一下,Scaled Dot-Product Attention的计算流程如下:

  1. 计算查询和键的点积。
  2. 对点积结果进行缩放。
  3. 对缩放后的结果应用Softmax得到权重。
  4. 使用权重对值进行加权求和。

2.2 自注意力机制(Self-Attention)

在Transformer中,最核心的部分就是自注意力机制。与传统的注意力机制不同,自注意力允许一个输入的元素根据其他所有输入元素的情况进行加权,这样可以捕捉到不同位置之间的依赖关系。

例如,在机器翻译中,句子中的某些词语可能依赖于其他词语(如句法关系),自注意力机制能够直接捕捉这种依赖关系。

自注意力的步骤与普通的注意力计算相同,只是此时查询、键和值都来自同一个输入序列。

2.3 多头注意力(Multi-Head Attention)

单头注意力虽然能够捕捉到输入序列中不同位置的关系,但它并不够充分。多头注意力是Transformer中对单头注意力机制的扩展,它将查询、键、值分别拆分成多个“头”(head),每个头独立地计算一次注意力得分,然后将各个头的输出拼接起来,最终通过一个线性层得到最终的结果。

多头注意力的计算步骤如下:

  1. 分割:将查询、键、值向量分别分成多个子向量(称为头)。
  2. 计算每个头的注意力:独立计算每个头的注意力输出。
  3. 拼接:将每个头的输出拼接起来。
  4. 线性变换:对拼接后的结果应用一个线性变换。

多头注意力的引入,使得模型能够同时从多个不同的子空间(头)来学习信息,从而捕捉到不同层次的依赖关系。

3. 位置编码(Positional Encoding)

由于Transformer模型没有循环结构(RNN)或者卷积结构(CNN),它无法直接处理输入序列中元素的顺序信息。因此,需要引入位置编码(Positional Encoding)来为每个输入元素提供位置信息。

位置编码向量是与输入向量相加的,其形式通常使用正弦和余弦函数来表示不同位置的编码。这使得每个位置的编码在相邻的位置上有所不同,并且有助于模型理解序列中各元素的相对或绝对位置。

常用的位置编码公式如下:

PE_(pos,2i)=sin(pos100002i/d)PE\_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d}}\right)
PE_(pos,2i+1)=cos(pos100002i/d)PE\_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d}}\right)

其中,(pos) 是词语的位置,(i) 是编码的维度索引,(d) 是编码的维度(即向量的长度)。

4. 总结

Transformer的注意力机制通过计算查询(Query)与键(Key)之间的相关性(点积),然后对值(Value)进行加权求和,来捕捉输入序列中的依赖关系。通过自注意力机制和多头注意力的结合,Transformer能够高效地并行处理序列,且能够捕捉长范围的依赖关系。同时,位置编码为Transformer提供了位置信息,使得它能够理解元素的顺序。

这种基于注意力的机制,相较于传统的RNN和CNN方法,具有更好的并行性和效果,因此成为了现代NLP模型的核心构建块。

  • 注意力计算规则

    • 需要三个指定的输入Q(query),K(key),V(value),然后通过公式得到注意力的的计算结果,这个结果大地表query在key和value作用下的表示,而这恶具体的计算规则有很多种

    • 计算规则

      Attention(Q,K,V)=softmax(QKTdk)VAttention(Q,K,V)=softmax(\frac{QK^T}{\sqrt{d_k}})V


  • Q,K,V的比喻解释
key相当于关键词提示,文本信息相当于query,读完之后脑海里浮现的就是value。
第一次看不是很聪明,脑海中浮现的值与key的提示值基本相同。
但是随着对问题的深入理解,通过思考积累的东西越来越多,并且可以通过query提取关键信息。

key和value一般情况下是默认相同的,与query是不同的,这是我们一般的注意力输入形式。

但是有一种特殊情况,即QKV都相同。这种情况称为自注意机制,需要给定文本自身表达自己,相当于文本自身的一次特征提取。


  • 注意力机制

    • 注意力机制是注意力计算规则能够应用的深度学习网络的载体,除了注意力计算规则外,还包括一些必要的全连接层以及相关张量处理,使其与应用网络融为一体.使用自注意力计算规则的注意力机制称为自注意力机制.

注意力机制架构

  • Scale:规范化层
  • MathMul:矩阵相乘
  • Mask:掩码层

Attention(Q,K,V)=softmax(QKTdk)VAttention(Q,K,V)=softmax(\frac{QK^T}{\sqrt{d_k}})V

注意力机制代码分析

def attention(query, key, value, mask=None, dropout=None):
		'''dropout是nn.Dropout层的实例化对象,默认为None'''
		# 首先取query的最后一维的大小, 一般情况下就等同于我们的词嵌入维度,命名为d_k
    d_k = query.size(-1)
		
		# 按照公式,将query和key的转置相乘,这里将最后两个维度进行转置,再除以缩放系数
		# 得到注意力的分张量scores
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
	
		#判断是否使用掩码张量
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9) #用一个非常小的数值,让其不可能被选中
        p_attn = F.softmax(scores, dim=-1)
		
		#判断是否随机置0
    if dropout is not  None:
        p_attn = dropout(p_attn)
		# p与value相乘获得最终的query注意力表示
    return torch.matmul(p_attn, value), p_attn

tensor.mask_fill(x,y),把张量里所有值为x的数值填充为y

attention演示

x = Variable(torch.randn(5, 5))
mask = Variable(torch.zeros((5, 5)))

x.masked_fill(mask == 0, -1e9)

#pe_result是经过了embedding层之后输出的张量
query = key = value = pe_result
attn, p_attn1 = attention(query, key, value)
print("attn", attn)
print(attn.shape)
print("p_attn", p_attn1)
print(p_attn1.shape)
attn tensor([[[ -7.4317, -24.2674,  -7.6284,  ...,  10.7943,  27.2769,  21.4286],
         [  7.5172,   0.0000, -26.9758,  ...,   5.2556, -27.7003,  17.7018],
         [  0.0000,   8.0614, -20.9750,  ...,  25.9957, -26.5770,  19.6320],
         [  0.0000,  29.3752,  24.0670,  ...,   0.0000,  -8.3910,  -4.7299]],

        [[  6.4225, -14.3576,  -9.1815,  ...,   7.3881,  28.1498,  23.0300],
         [ -2.7328, -11.5093,  10.7317,  ...,   0.0000,  -5.4851,   0.0000],
         [ 31.5057,   6.9880,  28.3289,  ...,   0.0000,  -2.6400,  19.7603],
         [ -5.9998,   0.0000, -22.1414,  ...,  31.1233,  12.0654,   8.2619]]],
       grad_fn=<UnsafeViewBackward>)
torch.Size([2, 4, 512])
p_attn tensor([[[1., 0., 0., 0.],
         [0., 1., 0., 0.],
         [0., 0., 1., 0.],
         [0., 0., 0., 1.]],

        [[1., 0., 0., 0.],
         [0., 1., 0., 0.],
         [0., 0., 1., 0.],
         [0., 0., 0., 1.]]], grad_fn=<SoftmaxBackward>)
torch.Size([2, 4, 4])

加入mask后

query = key = value = pe_result
mask = Variable(torch.zeros(2, 4, 4))
attn, p_attn1 = attention(query, key, value, mask=mask)
print("attn", attn)
print(attn.shape)
print("p_attn", p_attn1)
print(p_attn1.shape)
attn tensor([[[ 7.0380,  1.1413, -3.9913,  ..., -0.0413, 10.6952, 14.9099],
         [ 7.0380,  1.1413, -3.9913,  ..., -0.0413, 10.6952, 14.9099],
         [ 7.0380,  1.1413, -3.9913,  ..., -0.0413, 10.6952, 14.9099],
         [ 7.0380,  1.1413, -3.9913,  ..., -0.0413, 10.6952, 14.9099]],

        [[-5.6095,  7.0606, 13.9848,  ...,  6.9042,  9.1353, -5.9173],
         [-5.6095,  7.0606, 13.9848,  ...,  6.9042,  9.1353, -5.9173],
         [-5.6095,  7.0606, 13.9848,  ...,  6.9042,  9.1353, -5.9173],
         [-5.6095,  7.0606, 13.9848,  ...,  6.9042,  9.1353, -5.9173]]],
       grad_fn=<UnsafeViewBackward>)
torch.Size([2, 4, 512])
p_attn tensor([[[0.2500, 0.2500, 0.2500, 0.2500],
         [0.2500, 0.2500, 0.2500, 0.2500],
         [0.2500, 0.2500, 0.2500, 0.2500],
         [0.2500, 0.2500, 0.2500, 0.2500]],

        [[0.2500, 0.2500, 0.2500, 0.2500],
         [0.2500, 0.2500, 0.2500, 0.2500],
         [0.2500, 0.2500, 0.2500, 0.2500],
         [0.2500, 0.2500, 0.2500, 0.2500]]], grad_fn=<SoftmaxBackward>)
torch.Size([2, 4, 4])

可以看到shape没变,但数据规整了很多

多头注意机制


  • 结构

image.png

  • 什么是多头注意力机制: 从多头注意力的结构图中,貌似这个所谓的多个头就是指多组线性变换层,其实并不是,我只有使用了一组线性变化层,即三个变换张量对Q,K,V分别进行线性变换,这些变换不会改变原有张量的尺寸,因此每个变换矩阵都是方阵,得到输出结果后,多头的作用才开始显现,每个头开始从词义层面分割输出的张量,也就是每个头都想获得一组Q,K,V进行注意力机制的计算,但是句子中的每个词的表示只获得一部分,也就是只分割了最后一维的词嵌入向量.这就是所谓的多头,将每个头的获得的输入送到注意力机制中,就形成多头注意力机制.

  • 多头注意力的作用

    • 这种结构设计可以让每个注意力机制去优化每个词汇的不同特征部分,从而均衡同一种注意力机制可能产生的偏差,让词汇拥有来自更多元的表达

代码分析

import copy #深度拷贝的copy工具包

# 定义克隆函数,因为在多头注意机制中,要用到多个结构相同的线性层
# 我们将使用clone函数将他们一同初始化在一个网络层列表对象中,之后的结构中也会用到该函数
def clones(module, N): #module:要克隆的目标网络层 要把module连续克隆N份
		"""深度拷贝后,每一个module是一个独立的层,他们之间互不干扰"""
		# 拷贝完之后放在nn.MouduleList类型的列表中(一个专门放不同模型的列表)
    return nn.ModuleList([copy.deepcopy(module) for _ in range(N)]) #用for函数克隆多份

class MultiHeadedAttention(nn.Module):
    def __init__(self, head, embedding_dim, dropout=0.1):
        super(MultiHeadedAttention, self).__init__()
				# 使用assert判断h是否能呗d_model整除
				# 因为我们之后要给每个头分配等量的词特征。判断head能否整除维度,不整除则认为不合理
        assert embedding_dim % head == 0
				
				# 得到每个头获得的分割词向量维度d_k
        self.d_k = embedding_dim // head

				#传入头h
        self.head = head
				# 拷贝一个全连接层,他的内部变换矩阵是一个emb_d x emb_d的矩阵
				# 4个的原因是q,k,v各需要一个,最后拼接的矩阵还需要一个(图里的concat层)
				self.linears = clones(nn.Linear(embedding_dim, embedding_dim), 4)

        self.attn = None

        self.dropout = nn.Dropout(p=dropout)

		def forward(self, query, key, value, mask=None):
        if mask is not None:
                # 如果是非空,直接对掩码张量做一个维度的扩展
                mask = mask.unsequeeze(1)
				# 获取一个batch_size的变量,他是query尺寸的第一个数字,代表有多少样本
        batch_size = query.size(0)

        # 之后进入多头处理环节 注解见下面
				
        query, key, value = \
				[model(x).view(batch_size, -1, self.head, self.d_k).transpose(1, 2)
         for model, x in zip(self.linears, (query, key, value))]
				# 最后进入attention环节
				# 直接调用之前的attention函数,同时也将mask和dropout传入其中.输出attention本身的输出x和注意力权重矩阵attn
				x , self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
				
				# 经过都头计算之后,我们得到了每个头计算结果组成的4维张量,要将其转化为输入的形状,以方便后续计算
				# x.transpose:之前把1,2换了,这边换回来
				# .contiguous : 能够让转置后的张量应用view方法,否则无法应用view
				# view重新把张量变成3维
        x = x.transpose(1, 2).contiguous().view(batch_size, -1, self.head * self.d_k)

				# linears是4个全连接层组成的张量,这里用第四个即图片里最上边的linear
        return  self.linears[-1](x)
  • 深度拷贝&浅度拷贝 :

    • 浅度:A拷贝给B后,AB占用同一块内存,A改变B也会改变
    • 深度: A拷贝给B后,给B再分配一块内存,AB再无关系
  • 多头处理环节的步骤:

    • for model, x in zip(self.linears, (query, key, value))]

      • 首先利用zip将输入的qkv和三个线性层组合在一起
      • zip():self.linears是4个线性层,(q,k,v)是三个参数,小于4,zip以小的参数传参,说明指传参只使用到了4个线性层的前三个层
      • model是3个线性层,x是(q,k,v)
    • [model(x).view(batch_size, -1, self.head, self.d_k).transpose(1, 2) :对x进行处理

      • model(x):把x放到全连接线性层进行变换,变换完之后进行“.”后面的操作

      • .view() :把变换完之后的张量进行维度划分,划分为4个

        • 最后一个维度应该为词嵌入维度,这里拆分为两个,即head和d_k,分别为头,和每个头里的词嵌入维度
        • d_k为embedding / head所得,所以正好可以划分
      • 第一个维度是维度大小,默认好的,第三个和第四个确认了,第二个维度用-1让其自适应即可

    • .transspose(1,2):把第一个维度和第二个维度(从0开始)进行转置,即-1和head进行转置,为了让代表句子长度维度和词向量维度能够相邻,这样注意力机制才能找到词义与句子位置的关系

操作完成后,整个列表形成了三个张量,分别赋值给query, key, value

此时到达了这里

image.png

下面进入attention部分


代码演示

  • tensor.view
  • torch.transpose

实例化

head = 8
embedding_dim = 512
dropout = 0.2

# 若干输出参数的初初始化
query = key = value = pe_result

# mask = Variable(torch.zeros(8, 4, 4))

mha = MultiHeadedAttention(head, embedding_dim, dropout)
mha_result = mha(query, key, value)
print(mha_result)
print(mha_result.shape)

注:这里mask出现问题,未解决

结果:

s'dsdtensorsda([[[ 12.7222, -16.0728,  -6.7428,  ..., -12.2073,  -7.2427,  -4.6397],
         [-11.1993,   0.3793,  -8.1008,  ...,  -1.2296,   9.7448,   9.1258],
         [ -4.1351, -16.8703,  -1.6960,  ...,  -6.9077,  -5.8158,  -4.0577],
         [  1.1339,  -8.2770,   5.2618,  ...,  20.7172,   4.6730,  10.6909]],

        [[  3.6561,   4.2705,   9.5026,  ...,  14.7039,  -6.6467,  -7.0385],
         [  8.0991,  -6.3526,  14.0134,  ...,   1.7765, -15.2767,   1.4701],
         [ 10.6718,   9.5458,  -9.0354,  ...,   5.3182,  12.0918,  -1.8381],
         [-13.0881,   4.7626,  -1.2760,  ...,  -3.4999,  -0.7227,  12.7524]]],
       grad_fn=<AddBackward0>)
torch.Size([2, 4, 512])