TT Lab
开始
学习 学习路径 课程

Transformer — 手算一遍注意力

位置不是加上去的,是转出来的

在 TT Lab 中继续学习

一句话总结

不是把位置加到向量上,而是把向量按位置旋转,这样 query 和 key 的点积就只取决于两个位置的差。在 3 号和 5 号测得的值,与在 4000 号和 4002 号测得的值相同。

为什么需要它

前面做过正弦/余弦位置向量并加到输入上。由此顺序确实被带进去了。问题是怎样带进去的。

注意力分数由 query 和 key 的一个点积决定。加上位置向量 P 之后把点积展开,会得到四项。

(q + P[m]) · (k + P[n])
  = q·k  +  q·P[n]  +  P[m]·k  +  P[m]·P[n]

最前面一项是内容之间的分数,最后一项是位置之间的分数。问题在中间两项。q·P[n] 只含 n,P[m]·k 只含 m。只含一侧位置的项,无法归结为差。所以即使间隔同样是二,在句子前部测得的值和在后部测得的值也不同。

这为什么不好?语言中重要的多半是“前面隔几格的词”,而不是“文档的第几个字符”。定语所修饰的名词就紧跟在后面,代词所指的对象在几句话之前。全都是以距离来表述的关系。然而模型看到的分数里,混进了绝对位置。如果训练时没见过的位置编号在服务中出现——放进更长的文本,马上就是这样——那些项会变成什么值,没人知道。

想修复的尝试由来已久。把位置向量变成可学习的,在表里没有的位置上就直接停住;给分数按距离加上不同的常数,只是多了一项,内容与位置混在一起的问题依然存在。哪一种都没能触及点积本身。

于是,RoFormer 论文提出的问题是这个。能不能让点积从一开始就只看相对位置?不是算出分数之后再修,而是在生成 query 和 key 的地方就已经如此。

不加,而是转

答案是改变运算。不要加,要转。

把偶数维度每两个分成一组,看作平面上的点。64 维就是 32 对。为每一对定好角度,m 号位置的向量会让每一对旋转 m 乘以该角度。

theta_i = base ** (-2*i/d)      # i 번째 쌍의 회전 속도
(x0, x1) -> (x0*cos(a) - x1*sin(a),  x0*sin(a) + x1*cos(a))    # a = m * theta_i

第 0 对以速度 1 转得最快,越往后按几何级数变得越慢。底数在论文的定义中是 10000。

这里重要的是,什么都没有加。没有往向量里掺入新的值。只是把原来值的方向按位置转了一下。

只在对内旋转这一点也值得留意。第 0 对只混合第 0 维和第 1 维,第 1 对只混合第 2 维和第 3 维。不是把全部维度混在一起的大矩阵,而是对角线上排着一长串 2 × 2 的小旋转。所以乘法不用整个做一遍,对每一对乘以 cos 和 sin 再相加就完成了。

为什么只剩下相对位置

只取出一对来看,一行就结束了。旋转矩阵 R 的转置是反方向的旋转。

(R(m*theta) q) · (R(n*theta) k) = q · R(m*theta)ᵀ R(n*theta) k
                                = q · R((n-m)*theta) k

m 和 n 各自在哪里消失了,只剩下差。每一对都各自发生这件事,而点积是把它们相加,所以在整个向量上也同样成立。

用两个圆盘画出的 RoPE。位置 3 和位置 5 的向量所成的角,与位置 4000 和位置 4002 的向量所成的角,同样是 2θ。把两个向量朝同一方向一起转,夹角不会变,所以点积只取决于两个位置的差

说成白话是这样。把两个向量朝同一方向一起转,夹角不变。就像把两根时针整个转动,它们张开的角度依然不变。点积归根结底由长度和夹角决定,夹角不变,分数就不变。

而且旋转不改变长度。因为 cos 和 sin 的平方之和是 1。相加的方式会在原来的信号上叠加别的值,改变大小,而旋转什么都不叠加。相当于放进了位置信息,却没有碰内容。

离得远了,剩下什么

每一对速度不同这一点,在这里发挥作用。

在相距间隔 delta 的两个位置之间,第 i 对会张开 delta * theta_i 的角度。把它除以一圈(2π),就得到转了几圈。快的对只要稍微离远一点,就会转好几圈。超过一圈的对,会把 delta 和“从 delta 里减去一圈的距离”写成同样的角度——意思是光看那一对,无法区分这两者。

所以距离一远,还没有转满一圈的慢对才能正确区分那个距离。近的距离由快对细分,远的距离由慢对粗分。把速度按几何级数铺开的原因就在这里。相当于叠放了好几把尺子,刻度密的尺子只量短的,刻度疏的尺子量长的。

在实验中用 64 维亲自测量,数字会很清楚。间隔为 1 时,32 对全都在第一圈之内,但间隔越大,这个数量就越少。亲眼看过减少的样子,就能明白为什么“拉长上下文”会牵涉到调整角度——因为必须把剩下的尺子做得更长,或者把尺子重新铺得更疏。

在现场相遇的样子

第一,拉长上下文,质量就崩了。超过训练时用的长度,连慢对都会进入从没见过的角度。角度本身是算得出来的,但模型没学过在那个角度上该做什么。

第二,搞混加在哪一层。加位置向量的方式,只要在输入嵌入上加一次就完了。旋转不是这样——是在注意力使用 query 和 key 的位置应用。值(V)不旋转。旋转的话,内容就会被位置牵着走。

第三,query 和 key 用了不同的约定。把对按相邻的配成一对的实现,和把前一半与后一半配对的实现,两种都很常见。不管哪一种,性质都一样,但如果把一边的权重放进另一边的代码,就会悄悄出错。不会报错,只是分数变得奇怪。

第四,往缓存里放了旋转过的值,又重新数位置。把已经旋转过的 key 放在缓存里,以后又转一次,位置就被放进了两次。生成越长,偏差越大。

第五,改了底数就成了另一个模型。底数是整体决定各对速度的值。用与训练时不同的底数推理,所有对的角度都会错位。

实际工作中真正重要的事

下一项实验要做什么

一步步扩展 /root/work/tf-rope/rope.py。不使用 numpy,也不使用 torch——这个 Pod 的系统 Python 里没有 numpy(只在 /opt/onnx-lab/bin/python 里才有),也没有互联网。标准库 math 就足够了。

从生成每对不同的旋转速度开始,做出一次二维旋转、把整个向量按位置转动、旋转后的 query 与 key 的分数。然后并排做出相加的方式,在同一个位置比较两个值。

最关键的是第六步。把间隔固定为二,起始位置依次换成 3、10、100、4000,用两种方式测量分数。旋转一侧在四个位置上得到相同的值,而相加一侧会抖动。你会亲自用数字看到那种分散的幅度。

最后数一数间隔变远时每对转了几圈,测量还没有超过一圈的对还剩多少。评分器不会相信你写下的说明——它会真正导入你的模块,每次用不同的维度和位置检验函数,并与它另外计算的值在容许误差之内对照。