位置不是加上去的,是转出来的
一句话总结
不是把位置加到向量上,而是把向量按位置旋转,这样 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 各自在哪里消失了,只剩下差。每一对都各自发生这件事,而点积是把它们相加,所以在整个向量上也同样成立。
说成白话是这样。把两个向量朝同一方向一起转,夹角不变。就像把两根时针整个转动,它们张开的角度依然不变。点积归根结底由长度和夹角决定,夹角不变,分数就不变。
而且旋转不改变长度。因为 cos 和 sin 的平方之和是 1。相加的方式会在原来的信号上叠加别的值,改变大小,而旋转什么都不叠加。相当于放进了位置信息,却没有碰内容。
离得远了,剩下什么
每一对速度不同这一点,在这里发挥作用。
在相距间隔 delta 的两个位置之间,第 i 对会张开 delta * theta_i 的角度。把它除以一圈(2π),就得到转了几圈。快的对只要稍微离远一点,就会转好几圈。超过一圈的对,会把 delta 和“从 delta 里减去一圈的距离”写成同样的角度——意思是光看那一对,无法区分这两者。
所以距离一远,还没有转满一圈的慢对才能正确区分那个距离。近的距离由快对细分,远的距离由慢对粗分。把速度按几何级数铺开的原因就在这里。相当于叠放了好几把尺子,刻度密的尺子只量短的,刻度疏的尺子量长的。
在实验中用 64 维亲自测量,数字会很清楚。间隔为 1 时,32 对全都在第一圈之内,但间隔越大,这个数量就越少。亲眼看过减少的样子,就能明白为什么“拉长上下文”会牵涉到调整角度——因为必须把剩下的尺子做得更长,或者把尺子重新铺得更疏。
在现场相遇的样子
第一,拉长上下文,质量就崩了。超过训练时用的长度,连慢对都会进入从没见过的角度。角度本身是算得出来的,但模型没学过在那个角度上该做什么。
第二,搞混加在哪一层。加位置向量的方式,只要在输入嵌入上加一次就完了。旋转不是这样——是在注意力使用 query 和 key 的位置应用。值(V)不旋转。旋转的话,内容就会被位置牵着走。
第三,query 和 key 用了不同的约定。把对按相邻的配成一对的实现,和把前一半与后一半配对的实现,两种都很常见。不管哪一种,性质都一样,但如果把一边的权重放进另一边的代码,就会悄悄出错。不会报错,只是分数变得奇怪。
第四,往缓存里放了旋转过的值,又重新数位置。把已经旋转过的 key 放在缓存里,以后又转一次,位置就被放进了两次。生成越长,偏差越大。
第五,改了底数就成了另一个模型。底数是整体决定各对速度的值。用与训练时不同的底数推理,所有对的角度都会错位。
实际工作中真正重要的事
- 把“相同间隔是否得到相同分数”固定成测试。移动位置去测,看值是否相同,这是几行的测试,但它一旦破裂,下面的一切都失去意义。
- 旋转只用于 query 和 key。不用于值。
- 确认长度保持。旋转之后范数变了,说明做的不是旋转而是别的东西。
- 要有浮点误差的心理准备。位置变大,角度也变大,误差随之增加。判断是否相同时,不要用
==,而要使用设有容许误差的比较。
下一项实验要做什么
一步步扩展 /root/work/tf-rope/rope.py。不使用 numpy,也不使用 torch——这个 Pod 的系统 Python 里没有 numpy(只在 /opt/onnx-lab/bin/python 里才有),也没有互联网。标准库 math 就足够了。
从生成每对不同的旋转速度开始,做出一次二维旋转、把整个向量按位置转动、旋转后的 query 与 key 的分数。然后并排做出相加的方式,在同一个位置比较两个值。
最关键的是第六步。把间隔固定为二,起始位置依次换成 3、10、100、4000,用两种方式测量分数。旋转一侧在四个位置上得到相同的值,而相加一侧会抖动。你会亲自用数字看到那种分散的幅度。
最后数一数间隔变远时每对转了几圈,测量还没有超过一圈的对还剩多少。评分器不会相信你写下的说明——它会真正导入你的模块,每次用不同的维度和位置检验函数,并与它另外计算的值在容许误差之内对照。