得分与注意力
Q/K/V 给人一种强烈的诱导,让人不得不去设想一个 “查-键-值” 的数据库搜索过程,似乎在暗示一种语言内在的语法规则,学得越多越困惑,靠字面的 query key value 的自然语言说法是理解不了 LLM 如何建模语言的,必须拆开实际的计算过程才能明白为什么。
Q/K/V 的诱导
任何一个初学者,都会被 Q/K/V 的命名所吸引,并会构想:Q 为什么是 query、K 为什么是 key、V 为什么是 value,然后会持续追问 QK^T 在查询什么?最后点乘一个 V 又代表什么?总之,这会陷入一个还原论的怪圈:这些计算是否代表了某种特定的 “语法过程” ?比如注意力是注意到其中的主谓宾语法关系?
虽然模型内部分注意力头确实具备某些语法结构的偏好,但那是梯度下降的结果,要想全面理解 QKV 必须要抛弃字面意思,只从纯粹的矩阵计算(张量计算)看看其过程细节,只有在这个层面上才能真正理解注意力机制如何实现 token 间俩俩互相看看并给出语义的建模过程,以下是今天要讨论的内容,首先 Q K V 三个由可学习的矩阵投影而来,然后是自注意力公式、及其构件:
建模:token 间俩俩相互看看
LLM 是自回归输出的,每次生成 nextToken 都要回头看看当前的 “上下文”,怎么看呢?最直接的方式就是弄一个方阵,row 行代表 token 下标,col 列代表 row 对 col 的 “关注度得分” 或者说 “注意力得分”,为了让建模能实现 token 只关注历史 token,需要对每行未来的注意力得分掩盖为 -\infty 代表不需要关注,即需要盖住 i 行 > j 列的格子:
正是因为要实现 token 间俩俩相互看看,最直接且覆盖最全面的方式就是通过一个 \mathbb{R}^{T \times T} 的方阵直接表达,T 代表总长度,每行代表对应 token 下标对其他 token 的 “注意力得分”,并通过设置 -\infty 的方式实现对后续 token 的屏蔽
好,那么:注意力得分方阵如何拿到?
最基础的方式,你不是要 \mathbb{R}^{T \times T} 方阵吗,直接用嵌入后的 X \in \mathbb{R}^{T \times d} 凑一个出来不就行了: (d 是词嵌入维度)
这种方式可以是可以,但会有很多问题,比如点积运算的最大特点就是跟自己越像的值越大,这种方式会让模型更关注跟自己长得像的词,其次是语言是有序的:“我/爱/打/游戏” 中的 (我_{t_0} \to 爱_{t_1}) 跟 (爱_{t_1} \to 我_{t_0}) 的语义截然不同,但点积运算满足交换律,导致这种方阵并不能准确理解位置和语义:
虽然可以通过位置编码解决这类问题,但这只是给 token 下标做了建模,远不足以表达一个 token 看另外一个 token 的语言关系,因此需要给每个 token 配备两套不同的投影以适配它们在注意力看与被看的角色切换:分别对应 Q 和 K,它们通过可学习的投影矩阵 W_Q 和 W_K 得到。
这样一来,(我_{t_0} \to 爱_{t_1}) 的注意力得分就变成了 q_{t_0} \cdot k_{t_1},而 (爱_{t_1} \to 我_{t_0}) 变成了 q_{t_1} \cdot k_{t_0}。因为 W_Q \neq W_K,token “主动看”和“被动看”被建模成两个可学习的投影矩阵,通过训练塑造成完全不同的角色模式(梯度)
Q 和 K 的形状都是 \mathbb{R}^{T \times d},因此乘积 QK^T 的形状为 \mathbb{R}^{T \times T},这种内积运算能让 Q 的每一行跟 K 的每一行直接算内积相似度作为注意力得分 最后加上因果掩码,得到的 masked scores(注意力得分,假设 T = 4)
最后,不同量级数值容易造成数值爆炸或消失的问题,因此需要对注意力得分做归一化处理,把一个词看其他词的总注意力当作单位 1,然后按其具体得分进行分配,最经典的方式是 softmax,一方面他永远大于 0 避免负值注意力得分的解释问题,另外一方面它使输出光滑限定在 [0, 1] 内:
如何解释注意力得分方阵?
注意力得分只描述了 token 间的关注度,并没有指出这种关注度应该是怎样的语义,因此需要通过一个可学习的矩阵直接投影得到 V = X \cdot W_V 最后计算 scores \cdot V,行列扫过每一个 V 实现加权分配以表达:这样的注意力得分能代表什么信息:
即用 scores 点乘扫一遍 V,依据 scores 内的行权重代表某 token 应该对其他 token 的关注量,0~1 的系数,在这个层面上实现了 token 对 token 的注意力分配,以及这样的注意力分配能得到什么,复杂度 O(n^2),比如第一行是第一个 token 通常是 bos,启动只需要关注它自己,因此只有 v0 有注意力分配,其他都是 0,第二行则关注自己和前一个,通过因果编码让它不要注意到未来的 token,依次类推,从这个角度实现了语义的捕捉和映射,达成了 token 间俩俩互相看看并依据看看的结果得到注意到的语义,以 scores 表达 token 间的注意力分配系数、以最后的点乘 V 得到注意到的语义,最后通过梯度塑造 W_Q \, W_K \, W_V
为什么一定要 V ?不可以直接 V = X 吗?
如果 V 是 X,那么 attention 就变成了:
如果 V 还是原来输入的 X 那注意力得分方阵的含义就会变成加权调整输入的 X 了,这只能得到 X 的凸包,表达能力严重受限,即解空间太小了远比不上 V = X \cdot W_V 这里展开解释一下凸包:
当 V = X 时,我们只能得到这样的输出:
这个公式表达了 O_{i} 必定位于所有输入的 X_i 所围成的凸包之内,意味着:
- 模型所能创造的任何 O_{i} 输出,都只能是已有词向量 X_{i} 之间的词加权 “混合物” (想象一个简化的二维凸包 {(0, 1), (1, 1), (1, 0)} 这三个向量构成的一个凸包就是一个三角形,输出只能在这个三角形内)
- 表达能力被当前上下文长度限制住,如果输入序列只有“我/爱/打/游戏”这4个词,那模型的输出,只能落在这 4 个词的词向量围成的那个凸包空间里。
- 深层网络失去意义,无论堆叠12层还是120层,模型只能反复地对同一个输入 X 内做凸组合输出,输出永远被困在这个凸包里,scaling 失效
所以引入 W_K 是必要且必须的,它将真正代表注意力分数代表的语义,而不是简单的“关注度”,关注度只是从这个语义里加权抽取出来,这也是它为啥叫做 Value 的原因。
不要神化 “注意力” 这个词,更应该关注数学与建模
“注意力” 这个词只是一个比喻,用于解释上面的加权分配是一种好理解的说法,它完全可以是 “对其他 token 的重视度” “语义关联” 等等什么别的都行,最重要的是,用数学表达建模才是最重要的:
- 建模某个 token 应该如何关注其他 token 的方式最直接的方式就是方阵和因果掩码,但代价是 O(N^2) 的复杂度
- masked 掩码设计实现让 token 不能关注到未来的 token,也就是对未来的 token 的注意力分配直接设置为 -\infty
- 如何定义注意力得分呢?通过 Q \cdot K^T 进行,分别通过 W_Q W_K 投影实现主动看和被动看的建模
- 绞尽脑汁地去详细拆解 Attention "自然语言过程" 以及什么 "比喻直觉" 并没有多大意义,很容易陷入馋妄,这类思考不能让你明辨模型的构造和解释
- 大模型为什么能成是因为对 nextToken 问题的正确建模,以及理论和工程均堪称优雅的交叉熵损失函数和梯度 \dfrac{\partial \mathcal{L}}{\partial z_k} \,\, = \,\, q_k - p_k,以其作为反向传播的信号起点,依靠残差流梯度,实现深网损失的稳定下降
由于注意力机制在设计上非常简洁,且其主要运算集中在 MatMul,非常适合 GPU 实现大规模 scale,能达到骇人的参数量和推理效果。(大概率几年内估计都没新的架构能直接替代了,资源虹吸太厉害了,其他结构的研究更难拿到经费和算力)
结语
从纯粹的语义过程来看:注意力机制通过加权求和的方式实现了 token 间互相看看的建模,这个看是在词向量空间内的看,不是自然语言的那种主谓宾的看,最后通过 V 将这份得分转为 hidden_state 代表高维语义。
从纯粹的计算角度来看:上下文越长,分配的注意力越容易稀疏,这个稀疏在数学上指的是 softmax 的项增多后更容易造成熵增,这会给模型聚焦关键信息带来困难,是“长度外推”和“长文本”能力的一个核心挑战;另外,历史计算过的 K V 未来可以不用在算,这是 KV-Cache 的优化原理