随笔——Transformer 注意力机制

103 阅读14分钟

了解 Attention 的结构

Single Self-Attention

先了解一下 Transformer 中单头 Self-Attention 的样子,大概是这样子的

single-attention.png

将输入矩阵,分别乘以对应的 Wq,Wk,Wv 权重矩阵,投影得到 Q,K,V 矩阵;然后再通过 Attention(Q, K, V) 公式,得到最终输出结果 Z

Attention(Q, K, V) 的具体公式如下所示:

attention.png

举个例子,假如输入序列 “I love you song”,通过 Word Embeddings 和位置编码,得到了一个 4 * 512 维的矩阵

# 经过 Embeddings 和 位置编码得到的向量矩阵,4 * 512 维
inputs = [[0, 2, 0, 1, 4, ......],
          [4, 3, 0, 1, 2, ......],
          [0, 1, 4, 6, 2, ......],
          [3, 4, 1, 4, 7, ......]]

然后对这个 4 * 512 维的矩阵,分别乘以 Wq,Wk,Wv 权重矩阵得到 Q,K,V 目标矩阵

# Wq 权重矩阵,512 * 512 维
wq = [[0, 1, 0, 2, 0, ......],
      [1, 0, 0, 1, 0, ......],
      [0, 0, 0, 7, 0, ......],
      ......]
# Wk 权重矩阵,512 * 512 维
wk = [[1, 1, 4, 2, 4, ......],
      [3, 6, 0, 1, 0, ......],
      [6, 0, 4, 7, 0, ......],
      .....]
# Wv 权重矩阵,512 * 512 维
wv = [[3, 8, 2, 2, 0, ......],
      [9, 3, 7, 5, 0, ......],
      [2, 0, 6, 2, 4, ......],
      ......]
# 对输入矩阵 inputs 进行 Q,K,V 投影
# Q 矩阵,4 * 512 维
q = np.array(inputs) @ np.array(wq)
# K 矩阵,4 * 512 维
k = np.array(inputs) @ np.array(wk)
# V 矩阵,4 * 512 维
v = np.array(inputs) @ np.array(wv)

得到 Q,K,V 投影矩阵后再进行 Attention 公式计算,得到最终输出值 Z

# 计算分数
scores = q @ k.T
# dk 为向量维度,这里的 dk = 512
scores /= np.sqrt(q.shape[-1])

# 判断有无掩码
#   mask 矩阵,如果无掩码为 None,有的话维度为 seq_len_q * seq_len_k,即 4 * 4,0 表示需要掩码位置
if mask is not None:
    # 若有,掩码位置设为负无穷,这样 softmax 后权重为 0
    scores = np.where(mask == 0, -1e9, scores)

# 使用 softmax 对 scores 进行归一化操作,得到对应权重
weights = np.exp(scores) / np.sum(np.exp(scores), axis=-1, keepdims=True)

# 对值向量进行加权求和,得到一个完整的上下文表示输出
# 得到最终结果 z,此时的维度为 4 * 512,即 seq_len_q * d_v
z = weigths @ v

Multi Self-Attention

而多头自注意力机制,其实就是将 d model 拆分成多个部分,然后再分别对其进行 Self-Attention

无标题-2025-10-07-1906.png

Multi Self-Attention 其实相比 Single Self-Attention,就多了一步拆分合并操作,引入了 h 参数,即 head 数,拆分时,会拆分成 h 个部分,举个例子,输入序列矩阵还是上面那个

# 经过 Embeddings 和 位置编码得到的向量矩阵,4 * 512 维
inputs = [[0, 2, 0, 1, 4, ......],
          [4, 3, 0, 1, 2, ......],
          [0, 1, 4, 6, 2, ......],
          [3, 4, 1, 4, 7, ......]]

此时,权重矩阵的数量就不是三个了,而是 Wq,Wk,Wv 分别有 8 个(假设 h = 8),以 Wq1,Wk1,Wv1 举例,另外 7 组的权重矩阵操作是一致的。也就是说,每组都有自己的独立权重矩阵,会独立的对输入进行 Q,K,V 投影,独立的去计算注意力分数和权重,然后独立的对值向量进行加权求和,从而得到一个独立的上下文表示输出

# Wq 权重矩阵,512 * 64 维,d_q = d_model / h = 512 / 8 = 64
wq = [[0, 1, 0, 2, 0, ......],
      [1, 0, 0, 1, 0, ......],
      [0, 0, 0, 7, 0, ......],
      ......]
# Wk 权重矩阵,512 * 64 维
wk = [[1, 1, 4, 2, 4, ......],
      [3, 6, 0, 1, 0, ......],
      [6, 0, 4, 7, 0, ......],
      .....]
# Wv 权重矩阵,512 * 64 维
wv = [[3, 8, 2, 2, 0, ......],
      [9, 3, 7, 5, 0, ......],
      [2, 0, 6, 2, 4, ......],
      ......]
# 对输入矩阵 inputs 进行 Q,K,V 投影
# Q 矩阵,4 * 64 维
q = np.array(inputs) @ np.array(wq)
# K 矩阵,4 * 64 维
k = np.array(inputs) @ np.array(wk)
# V 矩阵,4 * 64 维
v = np.array(inputs) @ np.array(wv)

得到 Q1,K1,V1 投影矩阵后,进行 Attention 计算,得到输出矩阵 Z1

# 计算分数
scores = q @ k.T
# dk 为向量维度,这里的 dk = 64
scores /= np.sqrt(q.shape[-1])

# 判断有无掩码
#   mask 矩阵,如果无掩码为 None,有的话维度为 seq_len_q * seq_len_k,即 4 * 4,0 表示需要掩码位置
if mask is not None:
    # 若有,掩码位置设为负无穷,这样 softmax 后权重为 0
    scores = np.where(mask == 0, -1e9, scores)

# 使用 softmax 对 scores 进行归一化操作,得到对应权重
weights = np.exp(scores) / np.sum(np.exp(scores), axis=-1, keepdims=True)

# 对值向量进行加权求和,得到 h = 1 部分的上下文表示输出
# 得到最终结果 z1,此时的维度为 4 * 64,即 seq_len_q * d_v
z1 = weigths @ v

得到 Z1,Z2,... Z8 时,将其进行拼接

# 4 * 512 维
concat_z = np.hstack([z1, z2, ... z8])

得到拼接后的 concat_z 之后,最后通过 FC 层,将多头进行合并

# 权重矩阵,512 * 512 维,即 d_model * d_model
w = [[2, 8, 5, 6, 0, ......],
     [3, 5, 2, 5, 7, ......],
     [0, 5, 4, 6, 4, ......],
     ......]
# 得到最终完整的上下文输出矩阵 Z,4 * 512 维
z = concat_z @ w

Attention 为什么要这么设计

我认为其实就是在开始训练前,将所有的输入序列,转换成一个向量矩阵,训练时直接使用这个向量矩阵去进行运算,而不用像 RNN 那样,一个一个的去读取,避免串行化问题,这样才能充分利用 GPU 嘛,提高训练/推理效率,同时又能降低其成本。而为了实现这个目标,在开始训练前,需要为输入序列矩阵中的每个向量,即每个 Token,都要赋予其上下文信息以及位置信息。位置信息这里就不说了,就是位置编码

注意:注意力机制,在 Transformer 之前就有,并不是 Transformer 首创的

如何加载上下文信息

首先得明白,为什么要加载上下文信息。原因很简单,同一个词语,可能在不同语境下,意义是不一样的,比如 “你这是什么意思” 和 “没什么,就是意思意思” 这两句话,假设 “意思” 是一个 Token,模型获取到这个 Token 后,如果你不告诉模型这个 Token 所处在那个语境里,很可能导致模型理解这句话的意思和你完全不一样,那最后生成 Token 的时候,结果可能直接跑飞了,完全不是你想要的。

所以,加载上下文信息是很必要的。那么传统 RNN 是如何加载的,它是一个一个加载的,比如 “I love you song” 这个输入序列,RNN 加载了 “I”,“Love” 之后,再去加载 “you”,等加载完 “you” 之后再去加载 “song”,就好比一个字一个字的读文章,等全部读完了,自然就知道上下文信息了。但是这样做有几个问题,假设序列很长,那么在读取序列后面的 Token 时,最前面的 Token 可能已经被稀释的差不多了,就像读文章,一个字一个字的读,读到后面,可能忘了开头说的啥了。而且这种读取方式,完美的使用了串行化方式,训练/推理的时候,GPU 想帮你提提速都不行。而且这种方式,在某些应用场景下是很难的,比如在做完形填空,你一个一个的读,只知道前面的,后面的不知道,必须全部读完才能推理这个空,等你全部读完了,万一句子或文章很长,在读取的时候,模型忘记了一部分的上下文,那不天塌了。

而 Transformer 就很好的解决了上面这些问题,他是通过自注意力的方式,一下子就读取到了全部的上下文信息,而且是每个 Token 同时处理的,很好的规避了串行化问题,这样在 训练/推理 的时候,GPU 表示这波我熟,提速交给我。

自注意力机制

Transformer 中的自注意力,本质上其实就是带着问题去找答案,而不是通读全文。

那么如何实现带着问题去找答案,这就用到 Q,K,V 了。Q 就是搜索引擎的关键词(Query),就是你要问的问题;K 就是网页索引(Key),就好比爬虫,我肯定得知道目标网站才行,不可能把网上的所有网站全部爬一遍;V 就是网页内容(Value),也就是你想要的 “答案”;

Q,K,V 就是 “通过 Q 与 K 的匹配度(内积)筛选出重要的 V ”。也就是说,自注意力机制通过 Q 和 K 分别对全文中的所有 Value 就行打分,得分高的 Value,那么对 “问题” 的影响就大,得分低的 Value,对 “问题” 的影响就小,然后将其全部加起来,就找到这个 “问题” 的 “答案” 了(“问题” 指的就是要赋予其上下文信息的 Token;“答案” 就是上下文信息)

多头注意力机制

既然有了自注意力机制,已经能获取到上下文信息了,而且之前通过位置编码,模型也知道 Token 的位置信息了,都全乎了,直接开始训练就行,为什么还要有多头注意力机制。

因为自注意力机制,只有一组 Q,K,V,所以无法捕捉到多种关系,它更擅长捕捉某种特定的关系;同时又限制了模型的表达能力,因为只有一组 Q,K,V,限制了模型从不同方面提取信息的能力;而且在 Attention 计算时,会有加权平均的这个操作,会将所有信息全部压缩,然后混合到一个单一的输出向量里,阻碍了对不同表征子空间的并行独立关注。所以引入了多头注意力机制,允许模型同时关注来自不同位置,不同表示子空间的信息。

其实就是和 P 图一样,要是想出片的话,不可能只调一个吧,那不得色温,高光,锐化啥的都要适当调一下,才能出现神作

Attention 技术细节

Attention 公式

attention.png

这个公式,我觉得最重要的部分,在于如何打分,他是通过内积的方式来打分的,也就是查看两者之间的相似度(模型里看相似度的三个方法:欧氏距离、余弦相似度、内积,内积和余弦相似度本质上是一样的,余弦相似度 = 内积 / 模长)

公式中的 Q,K,V 就是查询矩阵、键矩阵和值矩阵,分别包含 n 个查询向量、n 个键向量和 n 个值向量;dk 指的是键向量的维度,而 sqrt(dk) 就是缩放因子。

先说一下 Q,K,V 是怎么来的,一个 Token 向量,乘以 Wq 权重矩阵,投影映射出一个查询向量(总结一下,需要问什么问题);乘以 Wk 权重矩阵,投影映射出一个键向量(就像上面说的,你的网站总得有个 url 地址,才能让查询向量去找哇);乘以 Wv 权重矩阵,投影映射出一个值向量(把对应的信息内容提炼出来);每个 Token 向量,要做的步骤都是一样的,要是串行的一个一个的去求,效率太慢了,将其合并成一个矩阵,同时进行,效率不就上来了么,这样就能避免了串行化问题,将其演变成矩阵相乘的方式去并行执行,这样 GPU 才能有用武之地。

而公式中的 Q 矩阵乘以 K 矩阵的转置,为什么?其实很巧妙,看个图就明白了

attention-flow.png

那么剩下的,为什么要有 softmax 和 缩放因子 先说一下 softmax,这个就是把缩放后的注意力分数按行进行归一化,转换成总和为一的概率分布。注意力权重矩阵,是模型可解释性的重要组成部分,每一行都表示输入序列中的每一个 Token,它对包含它在内的所有词的一个关注程度。也就是说,比如有一个 Token x,对输入序列中的所有 Token(也包括 x),进行一个打分,score 高,就代表 x 对这个 Token 关注度高,score 低,就代表 x 对这个 Token 关注度低,最后再将所有信息融合起来(根据对应的分数,加权求和)

至于为什么要除以缩放因子,先看一下 softmax 函数的定义

softmax.png

根据函数定义可以知道,如果出现 xi >> xj(x != j)这种情况,那么 softmax(xi) ≈ 1,而其他值 softmax(xj) ≈ 0;这种情况下,xi 附近的导数趋于 0(xi 附近的导数趋于 0,并不是代表该函数在 xi 处不可导,而是正常取值),进而很可能导致梯度消失的情况发生(注意,梯度消失,并不是代表函数在某点处不可导,而是指在反向传播中,梯度值过小,导致参数更新缓慢,甚至停滞

所以先看下,Q 和 K 矩阵点积的方差,如果除以缩放因子 math.sqrt(dk) 会怎样

11.png

从上图的公式可以看出,如果不除以缩放因子 math.sqrt(dk),那么 Q 和 K 矩阵点积的方差 = dk,倘若维度 dk 的值比较大时,方差值就会很大,函数值在相同或相似的输入下,输出值可能会出现显著差异,这样就会导致每次采样时,梯度值波动剧烈,模型在训练过程中很难收敛。如果除以缩放因子 math.sqrt(dk),那么 Q 和 K 矩阵点积的方差恒等于 1,是一种标准化状态,意味着函数输出值经过缩放后,其波动程度被严格控制在固定范围内,而且这样做有一个好处在于,与输入维度无关,只和 Q、K 矩阵的维度相关。

因此呢,如果不除以缩放因子 math.sqrt(dk),当缩放因子 math.sqrt(dk) 较大时,点积的方差也会变大;进而导致 softmax 函数输入的绝对值很大(点积的值会非常分散);这时候,梯度会变的非常小,影响模型训练(这也侧面说明,softmax 对极大值的敏感度很低)

所以综上所述,除以缩放因子 math.sqrt(dk),是很有必要的

为什么要有 Mask

12.png 看 Transformer 架构图可以了解到,Decoder 和 Encoder 部分是有区别的,在于 Decoder 部分为 Masked Multi-Head Attention,而 Encoder 部分为 Multi-Head Attention。

为什么这样子,我的理解是,应用场景不一样,在传统的 RNN 里,每一个输入的 Token,只能看到输入序列中,它自己,以及它之前所有 Token 的信息,但是后面的 Token 序列就看不到了。但是在 Transformer 中是可以看到全部的,也就是说,在 RNN 里可以看到它以及它之前所有 Token 的信息,但是 Transformer 里,整个序列中所有的 Token 信息是都能看到的。

有时候,我们希望 Transformer 可以猛一点,一下子获取全部的上下文信息(比如基于 Encoder 的 BERT,做一些完形填空,情感分类什么的);但是有些场景下,我们不希望 Transformer 这么猛,一下子全部看到所有信息(比如基于 Decoder 的 GPT,做一些生成式任务,或是训练什么的);

那么在不需要这么猛的场景下,如何只让 Token 看到它以及它之前的信息,忽略掉它后面所有的 Token 信息,这时候就用到 Mask 了,通过 Mask 标识,将后面的所有 Token 对应的权重,即 score,全部置为 0 来达到目的。

假定在生成式任务的训练场景里,给你一句话,然后指定 Token,将它后面所有的 Token 全部遮住,让模型去依靠 Token 以及它之前所有的 Token 信息去猜,以此达到训练目的。比如给你 “I love you song” 这个序列,然后将 “song” 通过 Mask 将其遮起来,让模型通过 “I love you” 去猜 “song”,通过将模型推理出来的 Token 和 “song” 去计算 loss 值,然后再根据 loss 值进行反向传播,更新模型参数,达到训练的目的。