返回关卡地图
预判 0 / 2 闯关题:0 / 2 XP 0 / 200

Level 03 | 零基础导学关卡

RoPE Tutorial

每个 Mission 先猜一猜(押个假设解锁讲解)→ 看图解原理 → 做巩固题 → 回 notebook 写代码。带着问题学,比直接读记得牢。

03_RoPE_Tutorial.ipynb PositionAttentionRotation
先猜一猜
图解原理
巩固 + 作业
q_rot = q * cos(pos) + rotate_half(q) * sin(pos)
Mission 1 TODO 1

位置变转角:为什么‘旋转’能编码相对距离

🎯 先猜一猜(下注解锁)

老办法是给每个位置发一个固定向量、加到 token 上。RoPE 偏不加,而是按位置把向量“转”一个角度。多此一举?还是另有玄机?

先押一个假设:用“旋转”代替“相加”,最关键的好处是?

先押一个假设,下面的讲解就会解锁。猜错完全没关系——带着疑问读,记得最牢。

先补的知识

  • 一个 token 在序列里的位置就是它的序号 m(0,1,2,…)。
  • 二维平面上的一个向量可以画成从原点出发的“小箭头”,旋转角度 θ 只改方向、不改长度。
  • 两个向量的点积 = |a||b|·cos(夹角);夹角只取决于两者各自转了多少。

图解原理

RoPE 的核心魔法:把“位置 m”变成“把箭头转 mθ 角度”。当 query 在位置 m、key 在位置 n 时,它们的点积只跟差值 (m−n) 有关——模型于是天生感知“相对距离”,而不用记死“绝对位置”。这一关先建立这个直觉,再做出那张‘位置 × 频率 = 角度’的表。

0. 痛点:绝对位置编码“记死了”,换个长度就懵

老式做法是给每个位置发一个固定向量(位置 0 一个、位置 1 一个…)再加到 token 上。问题是:训练时只见过 0~4095,推理来个第 8000 位,模型从没见过,直接懵。我们真正想要的,其实不是“你在第几位”,而是“你俩离多远”——也就是相对距离

换个思路:不把位置“加”进去,而是按位置把向量“转”一个角度
1. 位置 = 转角:序号越大,箭头转得越多

把 token 向量看成平面上的小箭头。位置 m 就让它转 mθ:位置 0 不转,位置 1 转 θ,位置 2 转 2θ…… 像钟表指针,走得越远转得越多。

位置 0:转 0 位置 1:转 θ 位置 2:转 2θ
关键收益:点积只剩下“差值”
2. 魔法发生处:q 转 mθ、k 转 nθ,点积只依赖 (m−n)

注意力要算 query·key。query 在位置 m 转了 mθ,key 在位置 n 转了 nθ。两个箭头的夹角正好是 (m−n)θ,于是点积 = |q||k|·cos((m−n)θ)——绝对位置 m、n 各自消失了,只剩相对距离 (m−n)。这就是 RoPE 能自然泛化到更长序列的根本原因。

q(位置 m,转 mθ) k(位置 n,转 nθ) 夹角 = (m−n)θ
q·k = |q||k| · cos((m−n)θ)只剩相对距离 (m−n)
为什么要“多组”频率:让模型既能分清近处,也能感知远处
3. 不同维度配不同频率:像秒针/分针/时针

如果所有维度都用同一个 θ,转太快的远处会“绕圈重合”,转太慢的近处又分不开。RoPE 让 head_dim 里不同的“维度对”用不同频率:低索引维度转得快(管细微差别),高索引维度转得慢(管大跨度距离)。频率公式:θ_i = 10000−2i/d,i 越大频率越低。

维度对 0:高频(秒针)维度对 1:中频(分针)维度对 i:低频(时针)
把上面这些角度,按 位置 × 频率 摆成一张表(这就是 TODO 1)
4. 做表:每行一个位置,每列一组频率,格子里是“要转的角度”

torch.outer(位置, 频率) 一次性算出所有 (位置, 维度) 的角度,再用 torch.polar(1, 角度) 把角度变成复数 cos+i·sin(模长固定为 1,所以只转不缩)。最终表的 shape 是 [seq_len, head_dim/2]——列数是 head_dim/2,因为后面要两两配对成复数。

位置 mfreq0 (快)freq1… freqi (慢)
0000
11·θ01·θ11·θi
22·θ02·θ12·θi

语法热身:用 outer 把“位置 × 频率”摊成一张角度表

pos   = torch.arange(3).float()        # 位置 0,1,2
freqs = torch.tensor([1.0, 0.1])       # 两组频率:一快一慢

# outer:第 i 行第 j 列 = pos[i] * freqs[j]
angle = torch.outer(pos, freqs)        # shape [3, 2]
# tensor([[0.0, 0.0],     位置0:都不转
#         [1.0, 0.1],     位置1:快的转1.0,慢的转0.1
#         [2.0, 0.2]])    位置2:转得更多

# polar(模长, 角度) → 复数 cos+i·sin,模长全取 1 表示“只转不缩”
cis = torch.polar(torch.ones_like(angle), angle)  # complex [3,2]

读法:outer(a,b)[i,j] = a[i]*b[j],正好对应“位置 × 频率 = 该转的角度”。polar 把角度打包成可直接相乘的复数旋转因子 e

巩固一下

巩固(动手算):seq_len=8、head_dim=64 时,freqs_cis 这张角度表的 shape 应该是?

回到 notebook 的作业

这里不直接写答案。你已经拿到足够输入,最后用 notebook 的 TODO 做举一反三。

Mission 2 TODO 2 / TODO 3

把数字两两配成小箭头,用复数乘法转一下

🎯 先猜一猜(下注解锁)

head_dim=64,意味着要把 32 对维度各转一个角度。如果写循环一对一对转,又慢又丑。有没有一行代码全转完的办法?

先押一个假设:RoPE 用什么技巧一次性旋转所有维度对?

先押一个假设,下面的讲解就会解锁。猜错完全没关系——带着疑问读,记得最牢。

先补的知识

  • head_dim 是偶数,可以把相邻两个数配成一对 (x, y) 当作平面坐标。
  • 复数乘法 = 旋转:(a+bi)·(cosθ+i·sinθ) 就是把箭头 (a,b) 转 θ 度。
  • 旋转只改方向不改长度,所以旋转前后向量模长不变(测试会查这一点)。

图解原理

拿到上一关的复数角度表后,把 query/key 的最后一维也两两配对、看成复数,直接乘以角度表——一次复数乘法就完成了所有维度对的旋转。最后再把复数拆回实数、展平回原 shape。

1. 两两配对:head_dim 个数 → head_dim/2 个小箭头

把最后一维 reshape 成 [..., head_dim/2, 2],相邻两个数 (x0,x1) 组成一个 2D 坐标,再用 view_as_complex 看成一个复数 x0 + i·x1。head_dim=64 就得到 32 个复数(32 个待旋转的小箭头)。

x0 x1 x2 x3 … 共 head_dim 个 复数 x0 + i·x1 复数 x2 + i·x3
复数乘法 = 旋转:乘以 e^{iθ} 就把箭头转 θ 度
2. 一次乘法转全部:x_复数 × freqs_cis

把配好的复数张量乘以上一关的 freqs_cis(每个位置、每个维度对该转的角度)。复数乘法 (a+bi)(cosθ+i·sinθ) 自动展开成二维旋转矩阵的效果——一行代码替你转了所有维度对。

旋转前 旋转后 转 θ,长度不变
(a + bi)(cosθ + i·sinθ)= 把箭头 (a,b) 旋转 θ
转完拆回实数,并补上 FP32 精度这道坑
3. 还原 shape + 精度陷阱

view_as_real 把复数拆回 (实部, 虚部) 两个数,再 flatten 合并回 head_dim,shape 与输入完全一致。关键坑:复数乘法在 FP16/BF16 下极易出 NaN,所以进复数前先 .float() 升到 FP32 计算,算完再 type_as(xq) 转回原精度——这是 LLaMA 源码强制要求的。旋转不改长度,所以测试会校验旋转前后模长一致。

reshape [..,d/2,2]view_as_complex× freqs_cisview_as_real → flatten

语法热身:把二维坐标当复数旋转,再拆回来

import math
# 一个小箭头 (1, 0),想转 90°
v = torch.tensor([[1.0, 0.0]])          # shape [1, 2]
z = torch.view_as_complex(v)            # 1 + 0i

theta = math.pi / 2                     # 90°
rot = torch.polar(torch.ones(1), torch.tensor([theta]))  # e^{iθ}

z_rot = z * rot                         # 复数乘法 = 旋转
out = torch.view_as_real(z_rot)         # 拆回 [[~0, 1]] → (0,1)
print(out, out.norm())                  # 长度仍是 1(旋转不变性)

读法:view_as_complex 把最后一维 2 个数看成一个复数;乘 e 旋转;view_as_real 再拆回两个实数。真实代码里进这步前要 .float() 防 NaN。

巩固一下

巩固:RoPE 进复数乘法前为什么要先把 q/k 转成 float32?

回到 notebook 的作业

这里不直接写答案。你已经拿到足够输入,最后用 notebook 的 TODO 做举一反三。

完成 2 道闯关题后,本关即算完成;作业 checklist 用来辅助你回 notebook 练习。
进度只保存在当前浏览器 localStorage,分享 HTML 不会带走你的记录。
上一关:L02 下一关:L04