用旋转把位置放进去
目标
只用标准库亲手实现旋转位置编码(RoPE)。把偶数维度每两个分成一对,每一对以不同的速度按位置旋转,并用数字确认这样旋转后的 query 与 key 的点积只取决于两个位置的差。并排做出相加的方式(绝对位置编码),比较在同样间隔下值的抖动,并测量旋转不改变长度,以及距离变远时哪些成分会留下。
为什么重要
加上位置向量,把点积展开后,会留下像 q·P[n] 和 P[m]·k 这样只含一侧位置的项。这些项无法归结为差,所以即使间隔完全相同,在句子前部测得的值和在后部测得的值也不同。语言中重要的多半是前面隔几格的词,而分数里却混进了绝对位置。
旋转从运算本身解决了这个问题。把两个向量朝同一方向一起转,夹角不变,所以各自按自己的位置旋转后再做点积,值只取决于差。这不是近似,而是等式,所以可以用数字确认。
本实验不调用模型。这个 Pod 的系统 Python 里没有 numpy,也没有互联网。只用 math 来做,只使用在这里测出来的数字。所以“某个模型用什么底数”这样的话,这里不说。
评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的维度和位置检验函数,并与评分器另外计算的值在容许误差之内对照。输入每次运行都会变,所以无法把值背下来填进去。
步骤
- 在 /root/work/tf-rope/rope.py 中创建
DIM = 64、THETA_BASE = 10000.0、demo_vectors()、dot(a, b)、thetas(d, base=THETA_BASE)。thetas返回每一对不同的旋转速度。 - 增加
rotate_pair(x0, x1, angle),把平面上的一个点逆时针旋转 angle 弧度。 - 增加
apply_rope(vec, pos, base=THETA_BASE),把向量分成对,每一对旋转pos * theta_i,返回新向量。 - 创建
rope_score(q, k, m, n, base=THETA_BASE),把 m 号位置的 query 和 n 号位置的 key 各自旋转后再做点积。 - 创建
sin_pos(pos, d, base=THETA_BASE)和add_score(q, k, m, n, base=THETA_BASE),测量相加的方式下的同一个分数。 - 创建
offset_scan(q, k, offset, starts),在多个起始位置上测量同样的间隔。返回的值是(시작자리, 회전 점수, 더하는 점수)(占位符依次为起始位置、旋转分数、相加分数)对的列表。 - 创建
turns(d, delta, base=THETA_BASE)和slow_pairs(d, delta, base=THETA_BASE),测量每个间隔下各对转了几圈,以及还没有超过一圈的对有几个。 - 把测得的值记录到 /root/work/tf-rope/rope_report.json 和 /root/work/tf-rope/rope_report.md 中。
参考
- 执行契约:评分器会把
/root/work/tf-rope/rope.py当作 Python 模块导入,直接使用DIM、THETA_BASE、demo_vectors、dot、thetas、rotate_pair、apply_rope、rope_score、sin_pos、add_score、offset_scan、turns、slow_pairs。它不会作为脚本运行,所以可以没有if __name__ == "__main__"。 demo_vectors()返回(q, k)。不用随机数,而是用公式钉死——q[j] = math.cos(0.7 * j + 0.3),k[j] = math.sin(0.4 * j + 1.1),两者的长度都是DIM。thetas(d, base)是长度为d // 2的列表,第 i 个值是base ** (-2 * i / d)。第一个值总是 1.0,越往后越小。不是每个维度,而是每一对一个。rotate_pair(1.0, 0.0, math.pi / 2)是(0.0, 1.0)。方向钉死为逆时针。返回的是两个元素的元组。apply_rope像(vec[0], vec[1])、(vec[2], vec[3])这样把相邻的配成对。第 i 对的旋转角是pos * thetas(len(vec), base)[i],每一对的角度不同。不要在原处修改传入的列表,要新建一个列表。pos为 0 时与原向量相同。rope_score(q, k, m, n)就是dot(apply_rope(q, m), apply_rope(k, n))。不除以 √d。sin_pos(pos, d, base)是长度为 d 的列表,把angle = pos / (base ** ((2 * (j // 2)) / d))设好后,第 j 个值在 j 为偶数时是math.sin(angle),为奇数时是math.cos(angle)。add_score(q, k, m, n)是把sin_pos(m, len(q))加到 q 上,把sin_pos(n, len(k))加到 k 上,各自相加之后再做点积。只加在一侧是不行的。offset_scan(q, k, offset, starts)对starts中的每个位置 s,把 query 放在s + offset,把 key 放在s。顺序与starts相同。turns(d, delta, base)是长度为d // 2的列表,第 i 个值是delta * theta_i / (2 * math.pi)。slow_pairs是该值小于 1.0 的对的个数(整数)。- 第 8 步的报告用
demo_vectors()的 q·k 来测量。间隔是 2,起始位置是 3、10、100、4000,测量范数的位置是 0、1、7、100、4096,数圈数的间隔是 1、16、256、4096。JSON 键是dim、base、offset、starts、rope_scores、rope_spread、add_scores、add_spread、norm_before、norm_max_gap、deltas、slow_pairs、turns_first、turns_last。turns_first是每个间隔下第 0 对的圈数,turns_last是最后一对的圈数。 rope_spread和add_spread分别是四个分数的最大值减最小值。norm_before是 q 的范数,norm_max_gap是在五个位置旋转之后的范数与原范数之差中最大的绝对值。- 不要用
==比较实数。评分器用abs(a - b) <= 1e-9 + 1e-6 * abs(b)来判断。位置变大,角度也变大,误差随之增加,所以本实验只判定位置在 0 到 4096 之内的情况。 - 这个 Pod 没有互联网。
pip install无法使用,numpy 只在/opt/onnx-lab/bin/python里,所以在系统 Python 中import numpy是不行的。只用math就足够了。 - 官方文档:RoFormer — Rotary Position Embedding · Attention Is All You Need · Python math
- 常见错误:按维度而不是按对来设角度、给所有对都用第 0 对的角度、把旋转方向弄反、只旋转 query 而不动 key、在相加的方式中只在一侧加位置向量、数圈数时不除以 2π、在原处修改传入的列表。
每一对不同的旋转速度
在 /root/work/tf-rope/rope.py 中创建 DIM = 64、THETA_BASE = 10000.0、demo_vectors()、dot(a, b)、thetas(d, base=THETA_BASE)。thetas 是长度为 d // 2 的列表,第 i 个值是 base ** (-2 * i / d)。demo_vectors() 返回长度为 DIM 的两个列表,q[j] = math.cos(0.7 * j + 0.3),k[j] = math.sin(0.4 * j + 1.1)。
不是每个维度,而是每一对一个——64 维就是 32 个。i 为 0 时指数为 0,所以第一个值是 1.0,越往后按几何级数变得越小。如果用整数做除法,指数会全部变成 0,值全都成了 1.0,所以要像 -2.0 * i / d 这样用实数。dot 用 zip 配对相乘再相加,一行就够了。
在平面上旋转一次
增加 rotate_pair(x0, x1, angle)。返回把点 (x0, x1) 按 angle 弧度逆时针旋转后的两个元素的元组。rotate_pair(1.0, 0.0, math.pi / 2) 是 (0.0, 1.0)。
是 (x0*cos - x1*sin, x0*sin + x1*cos)。两个符号中只要反一个,就成了顺时针,后面步骤的值会全部不同。角度为 0 时必须与原来的点完全相同,不管给什么角度,到原点的距离都不能变——因为 cos 和 sin 的平方之和是 1。
把向量按位置旋转
增加 apply_rope(vec, pos, base=THETA_BASE)。像 (vec[0], vec[1])、(vec[2], vec[3]) 这样把相邻的配成对,把第 i 对旋转 pos * thetas(len(vec), base)[i],返回新列表。传入的列表保持原样。
每一对的角度不同——如果所有对都用第 0 对的角度,就只是整体转了一下,位置信息只放进了一层。pos 为 0 时所有角度都是 0,必须与原向量相同,不管给哪个位置,向量的范数都不能变。别忘了接收 base 并传给 thetas——只用默认值的话,用别的底数调用时会悄悄出错。
旋转后的 query 与 key 的分数
创建 rope_score(q, k, m, n, base=THETA_BASE)。把 m 号位置的 query 和 n 号位置的 key 各自旋转后再做点积,返回得到的实数。不除以 √d。
dot(apply_rope(q, m, base), apply_rope(k, n, base)) 一行。只旋转一侧的话,绝对位置会原样留下,后面步骤的性质就崩了。m 和 n 相同时,必须得到与没有旋转的 dot(q, k) 相同的值——因为是朝同一方向一起旋转,夹角不变。
并排放上相加的方式
创建 sin_pos(pos, d, base=THETA_BASE) 和 add_score(q, k, m, n, base=THETA_BASE)。sin_pos 是长度为 d 的位置向量,把 angle = pos / (base ** ((2 * (j // 2)) / d)) 设好后,第 j 个值在 j 为偶数时是 math.sin(angle),为奇数时是 math.cos(angle)。add_score 把位置向量分别加到 q 和 k 上之后再做点积。
这边是生成值再加上去的方式。只加在一侧的话,比较就不成立。把点积展开,除了 q·k,还会出现 q·P[n] 和 P[m]·k,这两项只含一侧位置,无法归结为差——下一步会用数字看到它的结果。
间隔相同,分数就相同
创建 offset_scan(q, k, offset, starts)。对 starts 中的每个位置 s,把 query 放在 s + offset,把 key 放在 s,用两种方式测量分数。返回的值是 (s, 회전 점수, 더하는 점수)(占位符依次为旋转分数与相加分数)对的列表,顺序与 starts 相同。做完之后请亲自打印 offset_scan(q, k, 2, [3, 10, 100, 4000]),亲眼确认旋转一侧的四个值相同,而相加一侧在抖动。
直接调用前面做的 rope_score 和 add_score,五行就够了。把 query 和 key 放反,间隔的符号就会反过来,得到不同的值。旋转一侧的四个值,应该到小数点后十二位都相同才算正常——并不是完全相同,会留下极小的差别,那就是浮点误差。请与相加一侧的分散程度在数量级上比较一下。
离得远了,哪些对会留下
创建 turns(d, delta, base=THETA_BASE) 和 slow_pairs(d, delta, base=THETA_BASE)。turns 是长度为 d // 2 的列表,第 i 个值是 delta * theta_i / (2 * math.pi),也就是那一对转的圈数。slow_pairs 是该值小于 1.0 的对的个数(整数)。
一圈是 2π 弧度。忘了除的话,得到的不是圈数而是角度,个数会完全不同。超过一圈的对,会把那个距离和减去一圈的距离写成同样的角度,所以无法区分二者。用间隔 1、16、256、4096 调用,看看剩下的对的数量是怎样减少的。
把测得的内容留成记录
用 demo_vectors() 的 q·k 测量,在 /root/work/tf-rope/rope_report.json 中写入 dim、base、offset、starts、rope_scores、rope_spread、add_scores、add_spread、norm_before、norm_max_gap、deltas、slow_pairs、turns_first、turns_last,在 /root/work/tf-rope/rope_report.md 中用 ## 무엇을 쟀나(韩文,意为“测量了什么”)、## 같은 간격이면 같은 점수다(韩文,意为“间隔相同,分数就相同”)、## 더하는 방식은 왜 다른가(韩文,意为“相加的方式为什么不同”)、## 멀어지면 어느 성분이 남는가(韩文,意为“离得远了,哪些成分会留下”)四节来写。间隔是 2,起始位置是 3、10、100、4000,测量范数的位置是 0、1、7、100、4096,数圈数的间隔是 1、16、256、4096。
数字不要手写,要用实际运行你的代码得到的值来填。rope_scores、add_scores 从 offset_scan 返回的对中取出。rope_spread、add_spread 分别是最大值减最小值,norm_max_gap 是在五个位置测得的差的绝对值中最大的一个。turns_first 是每个间隔下第 0 对的圈数,turns_last 是最后一对的圈数。报告正文中要用数字写出旋转一侧的第一个分数、相加一侧的分散程度、q 的范数——评分器会检查正文中有没有这三个值。