高中背了三年的 sin 和 cos,原来是大模型记住"谁先谁后"的秘密
正弦、余弦、正切、和角公式、正弦定理……高中背了三年,考完只记得"sin30° = 0.5"。 因为老师只教你"查表算三角形",没告诉你两件事:
第一,sin 和 cos 的灵魂根本不是三角形,是"旋转"。 第二,今天每一个大模型(LLaMA、Qwen、GPT、DeepSeek)记住"哪个词在前、哪个词在后", 靠的正是给每个位置转一个角度——这套机制叫 RoPE(旋转位置编码)。
这篇文章不背一个公式、不证一个定理,全部用能跑起来的 PyTorch 代码, 把三角函数最核心的"旋转"本质,一路接到大模型的 RoPE。 并且顺手回答四个灵魂拷问:
- sin、cos 明明是算三角形的,怎么会和"位置""顺序"扯上关系?
- "给一个向量转一个角度"到底是什么意思?为什么要转?
- Transformer 不是号称"并行处理"吗,它究竟怎么知道哪个词在前、哪个在后?
- RoPE 公式里那些
sin(pos / 10000^...)到底在干嘛?
0. 一句话主线
如果只能留一句话,那就是这句:
三角函数的灵魂是"旋转"。sin 和 cos,就是单位圆上一根转动的钟表指针的两个坐标(纵、横)。
一根指针从正右方(0°)开始转,转到角度 θ 时,它的指尖坐标正好是 (cos θ, sin θ)。
所以"sin/cos"根本不是三角形的边长比,而是"转了 θ 角之后,落在了哪里"。
大模型给第 m 个位置的词"转 m 个单位角度",就能把"它排第几"这件事,悄悄编码进向量里。
import torch, math
# 一根从正右方出发的单位指针,转动角度 theta 后的指尖坐标
for deg in [0, 30, 90, 180]:
theta = math.radians(deg)
tip = torch.tensor([math.cos(theta), math.sin(theta)]) # (cos, sin) = 指尖位置
print(f"转 {deg:3d}° -> 指尖坐标 (cos, sin) = ({tip[0]:.2f}, {tip[1]:.2f})")
# 0°->(1,0)正右 30°->(0.87,0.5) 90°->(0,1)正上 180°->(-1,0)正左
记住这个画面:cos 是指针的横坐标,sin 是纵坐标,它俩一起描述"转了多少"。 下面所有东西都挂在这根转动的指针上。
1. sin、cos 不是三角形,是单位圆上的钟表指针
高中把 sin 定义成"对边比斜边",把你锁死在一个直角三角形里。
但真正有用的定义是单位圆:让一根长度为 1 的指针绕原点转,转到哪,(cos, sin) 就报出它在哪。
这个视角一换,三角函数立刻从"算三角形"升级成"描述旋转":
import torch
# 让指针转一整圈,记录轨迹 —— 它画出的正是一个圆
angles = torch.linspace(0, 2 * torch.pi, 8)
tips = torch.stack([torch.cos(angles), torch.sin(angles)], dim=1)
print(tips.round(decimals=2))
# 每一行是指针转到某个角度时的落点,8 个点均匀铺在单位圆上
print("每个落点到原点的距离:", tips.norm(dim=1).round(decimals=2)) # 全是 1.0
所有落点到原点的距离都是 1——它们严格躺在单位圆上。 这就是 sin/cos 的真身:一台"角度 → 坐标"的翻译机。喂进去一个角度,吐出来圆上的一个位置。
高中你被"对边斜边"框住了,其实只要记住:cos/sin = 转了 θ 角之后,指针尖在哪。 有了这个,下一节的"旋转"就水到渠成。
2. 旋转矩阵:把任意一个向量"转 θ 度"
指针能转,任意向量当然也能转。把一个向量转 θ 度,用的就是那个高中偷偷教过、却没说它多重要的旋转矩阵:
(如果你读过本系列《线性代数》那篇,这台"旋转机器"你已经见过。)代码转一个向量看看:
import torch, math
def rotation(theta): # 造一台"转 theta 度"的机器
c, s = math.cos(theta), math.sin(theta)
return torch.tensor([[c, -s],
[s, c]])
v = torch.tensor([1.0, 0.0]) # 指向正右的向量
R90 = rotation(math.radians(90))
print(R90 @ v) # tensor([0., 1.]) —— 被转到正上方
print((R90 @ v).norm()) # 1.0 —— 长度不变,只改方向
旋转矩阵只改方向、不改长度。 这一点后面是关键:位置编码只能"标记位置", 绝不能把词向量本身的大小改掉,否则语义就毁了。旋转恰好满足这个要求——它温柔地只动方向。
3. 旋转的黄金性质:转 a 再转 b = 转 (a+b)
这是整篇文章的枢纽,也是 RoPE 全部魔法的来源。三角函数的"和角公式"
(cos(a+b) = cos a cos b − sin a sin b 那一坨,你一定背过),翻译成人话就一句:
先转 a 度,再转 b 度,等于一口气转 (a+b) 度。角度是可以直接相加的。
用代码验证这个"角度可加"性质——它看起来平平无奇,却是位置编码能work的根基:
import torch, math
def rotation(theta):
c, s = math.cos(theta), math.sin(theta)
return torch.tensor([[c, -s], [s, c]])
a, b = math.radians(25), math.radians(40)
left = rotation(a) @ rotation(b) # 先转 b 再转 a
right = rotation(a + b) # 直接转 a+b
print(torch.allclose(left, right, atol=1e-6)) # True —— 转两次 = 转一次总和
角度可加,意味着"位置 5"就是"位置 3"再多转 2 步——位置之间的关系,变成了角度之间的减法。 这正是我们想要的:一个能自然表达"谁比谁靠后多少"的机制。记住这句,第 5 节它会变成主角。
🤔 疑惑点一:Transformer 不是"并行"的吗?它到底怎么知道词的先后顺序?
默认情况下,它根本不知道——纯粹的自注意力是"集合运算",把词序打乱,输出只会跟着一起打乱,模型察觉不到顺序。所以必须人工把位置信息"注入"进去。
这是最反直觉的一点。注意力机制里,每个词和所有词算相关度、再加权求和, 这个过程对词的排列是"等变"的:你把输入的词换个顺序,输出也只是换同样的顺序, 没有任何一步依赖"绝对位置"。用代码把这个"顺序失明"证出来:
import torch
import torch.nn.functional as F
def attention(X): # 最简自注意力,不含任何位置信息
d = X.shape[-1]
scores = X @ X.transpose(-2, -1) / d ** 0.5
return F.softmax(scores, dim=-1) @ X
torch.manual_seed(0)
X = torch.randn(3, 4) # 3 个词,每个 4 维
perm = torch.tensor([2, 0, 1]) # 把词序打乱成 (第2, 第0, 第1)
out_then_perm = attention(X)[perm] # 先算注意力,再打乱输出
perm_then_out = attention(X[perm]) # 先打乱输入,再算注意力
print(torch.allclose(out_then_perm, perm_then_out, atol=1e-6)) # True
结果为 True 说明:打乱输入 = 打乱输出,注意力对"顺序"完全无感。
"猫追狗"和"狗追猫"在纯注意力眼里,只是同一堆词的两种摆法,算出的关系一模一样。
这显然不行——语言里顺序就是意义。所以我们必须主动给每个位置盖一个"第几号"的戳。
RoPE 盖戳的方式,就是第 0~3 节那根转动的指针。
4. RoPE:给第 m 个位置的向量,转 m 个单位角度
思路简单到令人发指:既然旋转能优雅地标记角度,那就让第 m 个位置的词向量,转 m × θ 度。
位置 0 不转,位置 1 转 θ,位置 2 转 2θ……位置本身,就变成了"转了多少圈"。
import torch
def rope_2d(x, pos, theta=1.0): # 把 2 维向量 x 按它的位置 pos 旋转
ang = pos * theta # 第 pos 个位置转 pos*theta 度
c, s = torch.cos(torch.tensor(ang)), torch.sin(torch.tensor(ang))
x0, x1 = x[..., 0], x[..., 1]
return torch.stack([x0 * c - x1 * s, # 旋转矩阵作用在 (x0, x1) 上
x0 * s + x1 * c], dim=-1)
word = torch.tensor([1.0, 0.5]) # 同一个词的向量
for pos in [0, 1, 2, 3]:
print(f"位置 {pos}: {rope_2d(word, pos).round(decimals=3)}")
# 同一个词,放在不同位置,被转到了不同方向 —— 位置被"转"进了向量里
同一个词向量 [1.0, 0.5],放在位置 0、1、2、3,被转成了四个不同方向的向量。
"它排第几"这个信息,现在藏在"它被转了多少角度"里。 位置编码完成,而且没动向量的长度(第 2 节的好处)。
5. 为什么"转角度"这么妙:两个词的相关度,只取决于它们的相对距离
这是 RoPE 最漂亮的地方,也是它吊打老式位置编码的原因。注意力要算 query · key(第 1 节讲过,
点积衡量两个向量多相关)。当 query 在位置 m、key 在位置 n,各自被 RoPE 旋转后,它们的点积会神奇地只依赖 m − n(相对距离),而与它们各自的绝对位置无关。
回到第 3 节的"角度可加/可减":R(mθ)ᵀ R(nθ) = R((n−m)θ),所以旋转后的点积只剩下 (n−m) 这个相对量。空口无凭,代码验证——固定相对距离,绝对位置随便挪,点积纹丝不动:
import torch
def rope_2d(x, pos, theta=0.5):
ang = torch.tensor(pos * theta)
c, s = torch.cos(ang), torch.sin(ang)
x0, x1 = x[..., 0], x[..., 1]
return torch.stack([x0 * c - x1 * s, x0 * s + x1 * c], dim=-1)
torch.manual_seed(0)
q = torch.randn(2) # 一个 query 向量
k = torch.randn(2) # 一个 key 向量
# 三组 (m, n),相对距离 n - m 都等于 3,但绝对位置完全不同
for m, n in [(0, 3), (2, 5), (10, 13)]:
score = torch.dot(rope_2d(q, m), rope_2d(k, n))
print(f"query@{m:2d}, key@{n:2d} (相对距离={n-m}) -> 点积 = {score:.4f}")
# 三行的点积完全相等 —— 只要相对距离一样,注意力分数就一样
三行输出的点积一模一样。这意味着:RoPE 让模型天生理解"相对位置"—— 它不在乎你这句话从第几个字开始,只在乎"这个词离那个词隔了多远"。 这正是语言的本质("形容词修饰它后面的名词"是相对关系,和整句话在文档第几行无关)。 老式的绝对位置编码做不到这一点,RoPE 靠一根转动的指针就做到了。
🤔 疑惑点二:RoPE 公式里那一堆 10000^(2i/d) 是什么鬼?
那是给向量的不同维度配上"不同的转速",像钟表的时针、分针、秒针——快针分辨相邻词,慢针记住远距离,组合起来就能覆盖从"隔壁"到"很远"的所有位置关系。
前面为了讲清楚,我们只转了一个 2 维向量、用了同一个转速 θ。真实的词向量有几百上千维, RoPE 把它们两两配对,每一对用不同的角速度去转:低维配对转得快,高维配对转得慢。
import torch
d = 8 # 向量维度(配成 4 对)
i = torch.arange(0, d // 2).float()
theta = 10000 ** (-2 * i / d) # 每一对的"转速",从快到慢
print("四对维度各自的转速 theta:", theta.round(decimals=4))
# tensor([1.0000, 0.1000, 0.0100, 0.0010]) —— 第一对转最快,最后一对转最慢
pos = 5 # 某个位置
angles = pos * theta # 该位置下,每一对各自要转的角度
print(f"位置 {pos} 时各对的旋转角度:", angles.round(decimals=4))
为什么要多个转速?打个比方就懂了——这就是钟表的智慧:
秒针转得飞快,用来分辨"1 秒 vs 2 秒"这种近距离差别; 时针转得极慢,用来记录"3 点 vs 9 点"这种大跨度。 只有一根针,要么分不清相邻、要么表示不了大范围。 RoPE 给向量配上一整套"快慢不同的指针",于是它既能分辨相邻词,又能记住隔了几十上百个词的远距离关系——用一组三角函数,把从近到远的所有位置尺度一网打尽。
这就是那个吓人的 10000^(2i/d) 的全部含义:它只是在给每一对维度分配一个转速,凑齐一套时针分针秒针。
(顺带一提:这里的"转速"就是高中 y=Asin(ωx+φ) 里那个角频率 ω——只是当年没人告诉你它能这么用。)
🎬 动手:一个最小 RoPE,看位置如何"转"进注意力
把上面所有画面缝进一个能跑的最小 RoPE,作用在一串词上,然后看注意力分数如何随相对位置变化:
import torch
def build_rope(seq_len, d, base=10000.0): # 预先算好每个位置、每一对的 cos/sin
i = torch.arange(0, d, 2).float()
theta = base ** (-i / d) # 每一对的转速
pos = torch.arange(seq_len).float()
ang = torch.outer(pos, theta) # (seq_len, d/2):每个位置每对的角度
return torch.cos(ang), torch.sin(ang)
def apply_rope(x, cos, sin): # 把 x 的每一对维度旋转对应角度
x0, x1 = x[..., 0::2], x[..., 1::2] # 偶数维、奇数维配对
out0 = x0 * cos - x1 * sin
out1 = x0 * sin + x1 * cos
return torch.stack([out0, out1], dim=-1).flatten(-2)
torch.manual_seed(0)
seq_len, d = 6, 8
X = torch.randn(seq_len, d) # 6 个词,每个 8 维
cos, sin = build_rope(seq_len, d)
Xr = apply_rope(X, cos, sin) # 注入位置信息后的词向量
scores = Xr @ Xr.transpose(-2, -1) / d ** 0.5 # 加了位置的注意力相关度
print("第 0 个词对其余各词的注意力打分:")
print(scores[0].round(decimals=3)) # 现在这些分数"知道"了谁离谁多远
和"疑惑点一"里那个顺序失明的注意力对比一下:现在 scores 里已经悄悄编码了相对位置,
打乱词序结果就会变了——模型终于长出了"顺序感"。
仓库里的动画脚本把这根"转动的指针"画了出来:
python rope_rotation_visualization.py
画面里,同一个词向量被放在位置 0、1、2、3……你会看到它像钟表指针一样, 每往后挪一个位置,就多转一个固定的角度;而多频率那一版里,几对指针以不同转速同时旋转, 快的负责相邻、慢的负责远方。最后一幅图把"点积只依赖相对距离"画成一条曲线—— 无论绝对位置怎么平移,曲线的形状纹丝不动。
缝合:把所有画面接起来
回到开头,现在每个概念都有了画面和代码出处:
| 三角/旋转概念 | 高中怎么讲 | 这篇文章怎么看(画面) | 在大模型里是什么 |
|---|---|---|---|
| sin / cos | 对边、邻边比斜边 | 单位圆指针的纵、横坐标(第 0、1 节) | 位置编码的原材料 |
| 旋转矩阵 | 一个数表 | 把向量转 θ 度、只改方向(第 2 节) | 给词向量盖"位置戳" |
| 和角公式 | cos(a+b)=… 一坨 |
转 a 再转 b = 转 a+b(第 3 节) | RoPE 能表达相对位置的根基 |
| 多个频率 | 三角函数的周期 / 角频率 ω(y=Asin(ωx+φ)) |
时针分针秒针,快慢配合(疑惑点二) | 10000^(2i/d) 的各维转速 |
| 点积 | 向量数量积 a·b,求夹角、投影 |
转完后只依赖相对距离(第 5 节) | 注意力天生懂"相对位置" |
三句话总结这篇文章:
三角函数的灵魂是"旋转"——sin/cos 是单位圆指针的坐标,不是三角形的边(第 0、1 节); 旋转能优雅地把"位置"编码成"角度",而"角度可加"让位置关系变成相对减法(第 2、3、4 节); 于是 RoPE 让注意力天生理解"谁离谁多远",这就是今天所有主流大模型记住词序的秘密(第 5 节、疑惑点二)。
当年三角函数背得那么苦,不是因为它难,是因为没人告诉你: 那根在单位圆上转啊转的 sin/cos 指针,最后转进了每一个大模型的注意力里, 成了它记住"谁先谁后"的办法。 现在把上面每段代码跑一遍,比高中刷十套三角卷子都值。
备注(选题/标题): 这篇走的是"高中知识其实是 AI 基石"的钩子(和《求根公式》那篇同一路子)。 若想并入"大学4年"主系列,标题可换成: 《大学4年没让你真正搞懂的三角函数,被一段 RoPE 代码讲透了》。