高中觉得最没用的"虚数 i",其实是大模型旋转位置编码最优雅的写法
虚数
i、复数、复平面、模长与幅角、欧拉公式……高中(或大一)学的时候满脑子问号: "一个平方等于 −1 的数,现实里根本不存在,学它干嘛?" 因为老师只教你"复数怎么算、怎么在复平面画点",没告诉你三件事:第一,虚数不是数学家闲着没事"发明"的,是 450 年前解三次方程时被逼出来的—— 它一出生就是一座"以虚补实"的桥:从实数出发,借道虚数,回到实数答案。 第二,复数的灵魂不是"实部+虚部"那个二元组,是一个"旋转 + 缩放"的操作。 第三,今天每一个大模型(LLaMA、Qwen、GPT、DeepSeek)源码里,旋转位置编码 RoPE 的真实实现, 就是把词向量看成复数、乘上一个单位复数
e^(iθ)——一行代码完成二维旋转。这篇文章不背一个公式、不做一道复数化简,全部用能跑起来的 PyTorch 代码, 把复数最核心的"乘法即旋转"本质,一路接到大模型源码里的
view_as_complex。 并且顺手回答八个灵魂拷问:
- 虚数到底是怎么冒出来的?真是数学家为了解
x² = −1硬造的吗?- 一个"现实里不存在"的数,跟贷款买房、科技股估值、你家插座有什么关系?
- 平方等于 −1 的虚数
i"根本不存在",怎么会出现在大模型的正经代码里?- 凭什么说"复数乘法就是旋转"?乘一个
i到底在转什么?- 欧拉公式
e^(iθ) = cosθ + i·sinθ凭什么把指数、三角、虚数三样东西缝在一起?- LLaMA 源码里那句
view_as_complex/freqs_cis到底在干嘛?为什么向量要两两配对成复数?- 词向量靠几千维装语义、超过三维人类就没法想象——复数多出的那根虚轴,也是这种"高维"吗?
- 分数只认相对距离,那"谁在前谁在后"还分得出来吗?不同语言的快排聚到同一片语义空间,算"同频"吗?
(读过本系列《sin 和 cos》那篇的话,这篇是它的"第二种讲法":那篇用旋转矩阵,这篇用复数——你会看到工业界真正用的是后者。)
上一篇发出后,有读者评论得一针见血:
旋转用复数的三角表示来计算会比较方便。可是,复数在高中数学的地位,前两道送分的选择题而已。
前半句,就是这篇文章全部的技术内容——你会看到大模型源码里真的就是这么干的。 后半句,是真正的病根:高考卷上的复数,永远是"求共轭""算模长""除一下"的送分题, 两分钟拿分走人,考完即忘。一个能把"旋转"压缩成一次乘法、今天躺在每个大模型源码里的工具, 在课堂上的全部戏份只有送分题——不是复数没用,是它被教得只剩下算术的皮: 旋转的魂、出身的史、生活里的影子,全被剪掉了。 这篇就从被剪掉的部分讲起。
-1. 虚数的身世:不是"发明"出来的,是被三次方程逼出来的
先纠正一个几乎所有教科书都在暗示的误解:虚数不是为了解 x² = −1 硬造的。
x² = −1 无解就无解了,古人说一句"此方程无解",天塌不下来——没有任何人需要为它发明一种新数。
虚数真正的出生地,是三次方程。而且出生的方式非常憋屈:
1545 年,意大利数学家卡尔达诺(Cardano)在《大术》里公布了三次方程的求根公式。 1572 年,工程师出身的邦贝利(Bombelli)拿这个公式去解一个再普通不过的方程:
这个方程明明有一个人畜无害的实数解 x = 4(代进去:64 = 60 + 4,成立)。
但卡尔达诺公式算到一半,会冒出一个当时无法接受的东西:
√−121?负数开平方?按当时的常识这就是"无意义",公式失效,收工。
可邦贝利不甘心:答案明明是实实在在的 4,凭什么中间过程走不通?
于是他做了一件数学史上第一次的事:硬着头皮把 √−121 当成一个"合法的量"继续算——
结果 ∛(2+√−121) = 2+√−1,∛(2−√−121) = 2−√−1,两个一相加,虚的部分正负抵消:
从实数出发,借道一段"不存在"的虚数,最后回到实数答案。 这就是虚数的出生证明。 用代码验一遍邦贝利在 1572 年的手算:
import torch
# 卡尔达诺公式中间冒出的两个"怪物":2 ± √(-121) = 2 ± 11i
a = torch.complex(torch.tensor(2.0), torch.tensor(11.0))
b = torch.complex(torch.tensor(2.0), torch.tensor(-11.0))
# 邦贝利的发现:它们的立方根是 2 ± i
ra = torch.complex(torch.tensor(2.0), torch.tensor(1.0))
rb = torch.complex(torch.tensor(2.0), torch.tensor(-1.0))
print("(2+i)³ =", ra**3) # (2+11j) —— 确实是 2 + √(-121)
print("(2−i)³ =", rb**3) # (2-11j)
x = ra + rb # 两个立方根一相加,虚部抵消
print("x =", x) # (4+0j) —— 借道虚数,回到实数解 4
print("验证: 4³ = 15×4 + 4 ?", 4**3 == 15*4 + 4) # True
注意这个出生姿势,它决定了虚数的本性:虚数一出生就不是"目的",是"通道"—— 现实问题(实数)进,现实答案(实数)出,虚数是中间那段必须借用的桥。以虚补实。
出生之后还坐了两百多年冷板凳:笛卡尔给它起名 "imaginary"(想象的数),本来就是嘲讽;
连大量使用它的欧拉都说它"只存在于想象中"。直到 1797–1831 年,韦塞尔、阿尔冈、高斯三人
先后给出同一个解释:复数不是什么幽灵,就是平面上的一个点;乘复数,就是旋转。
高斯说过一句话,大意是:当初要是把 i 叫做"侧向单位"而不是"虚数",就没人觉得它神秘了。
——i 的问题从来不是数学问题,是起名事故。(这句"乘复数=旋转"就是本文后面全部内容。)
-0.5 别急着进代码:生活里你早就在用"复数思维"
有人会说:桥归桥,那毕竟是解方程,跟我生活有什么关系? 关系大了。数学从来不是先有符号再找应用,每一种"新数"都是被生活逼出来的:
- 数猎物 → 自然数;
- 分猎物 → 分数;
- 欠猎物 → 负数。注意:世界上并不存在"−3 个苹果"这种东西,你永远拿不出来给我看。 但"欠三个苹果"是真实的生活关系。负数就是给"欠"记账——它记的不是东西,是关系。 当年负数也被欧洲数学家骂了几百年"荒谬之数",今天你看银行卡余额变负会觉得荒谬吗?
虚数是同一个故事的下一章:它记的也不是"东西",而是另一个维度上的量。生活里到处都是:
① 借贷:花未来的钱,补今天的窟窿——这就是"以虚补实"。 你买房,手上只有首付,中间凭空多出一笔"不存在的钱"(贷款)把交易补齐闭环。 这笔钱此刻在现实中并不存在——它是未来三十年收入的"预支",是个虚的量。 但没有这个虚的量,"今天住进房子"这个实的结果就达不成。 对照邦贝利:实数进(首付+房子)→ 借道虚量(负债)→ 实数出(还清、房子归你)。 结构一模一样:中间量"不存在",但没有它到不了终点。
② 科技股估值:把"虚"的未来,折算进"实"的现价。 一家科技公司今天可能还在亏损,股价却高得离谱——因为股价买的不是它的今天, 是它未来现金流折现到今天的值。未来还没发生,是彻头彻尾的"虚部"; 但这个虚部实实在在地写在今天每一笔成交价里。市场天天在做"虚部驱动实部"的运算。 故事变了(虚部变了),哪怕今天的业务一分没变(实部没动),股价照样天翻地覆。
③ 你家墙上的插座:虚数每天在给你供电。 交流电的电压电流是来回振荡的,电力工程师算它们时全部用复数(工程上叫"相量")。 你电表读到的功率是实部;而虚部(无功功率、相位差)你看不见, 但空调压缩机能不能启动、电网会不会崩,恰恰取决于它。 实部是你看得见的那一面,虚部是躲在后面但真实起作用的那一维——这不是比喻,是国家电网每秒都在解的方程。
④ 虚拟空间:游戏币、Q币、会员等级、社交账号。 它们没有一件摸得着,全是"虚"的;但它们能换真钱、真服务、真社会关系——虚的维度反过来改写实的世界。 数字时代的基本操作,就是在"实"的世界旁边搭一个"虚"的维度,两边持续兑换。 复平面干的就是这个:在实数轴旁边,垂直立起一根虚数轴。
看出共同点了吗?"复数思维" = 承认世界不止一根轴。 只用一根实数轴记账的人,理解不了负债的人为什么敢买房、亏损的公司为什么值钱; 加上第二根轴(未来、信用、相位、虚拟资产),这些"荒谬"瞬间全部合理。 脱离生活讲数学是没有意义的——反过来,一旦接上生活,"平方等于 −1"这种鬼话 就还原成一句大白话:有些量不在你眼前这根轴上,它在侧面那根轴上。
轴讲完了,还差一个动作——"旋转"在生活里又是什么? 答:那种分量不变、只变阶段/方向/时机的变化。 一根轴的世界里,变化只有两种:增减(加法)、掉头(乘 −1); 承认第二根轴之后,才解锁了第三种——转。转不增不减(模长不动), 却真实地改变处境(幅角在走)。这种变化生活里到处都是,只是没人告诉你它叫旋转:
⑤ 转型不清零——"pivot"这个词本身就是旋转。
在实数轴上,从 +1 走到 −1 必须路过 0:从盈利到亏损,要穿过"一无所有"。
但在复平面上,从 +1 到 −1 可以绕着走——取道 i,全程模长为 1。
这就是 i² = −1 的生活版:两次 90° 的转身 = 一次彻底的掉头,但每一步都不需要归零。
创业圈管转型叫 pivot,这个词的本义就是"绕轴旋转":团队还是那个团队、
能力还是那些能力(模长不变),只是方向乘了一个 e^(iθ)。
诺基亚从造纸转到橡胶、转到电缆、转到手机,转了一百年,从没解散重来过。
⑥ 凡是"周期",背后都是一个在转的角度。
四季就是一年转 360°,春夏秋冬是四个 90°——春→夏→秋→冬→春,正是 i⁴ = 1。
月相、昼夜、潮汐、经济周期(复苏、繁荣、衰退、萧条,恰好四个象限)、情绪起伏,全是转圈。
而"转圈从侧面看是波浪",就是欧拉公式的生活版:摩天轮上的人在转圆圈(复平面),
远处的你只看到他上上下下(实部投影)——那条起伏的曲线就是 sin 波。
交流电是这么回事,后文大模型里的 freqs_cis 也是这么回事,同一幅画。
⑦ "合拍""同频""步调一致"——日常语言里早就有"相位差"。 两个人处不处得来,不取决于各自的绝对状态,取决于相位差: 一个在播种期、一个在收获期,比较他们此刻的"实部"(眼下可见的产出)毫无意义。 乐队要合拍、划龙舟要同步、谈恋爱讲步调一致——全是"只认相位差、不认绝对相位"。 记住这个感觉:后文 RoPE 的注意力只认相对距离,就是它的代码版—— 分数 = 夹角的余弦,夹角 = 相位差。
所以复数的完整生活译法是:复数 = 分量(模长)× 时机(幅角)。
一根轴只够记"有多少",第二根轴记的是"走到哪一步了"。
中国人说"做事要讲时机""天时地利",说的就是:
同样的分量,乘上不同的 e^(iθ),结果天差地别。 生活早就承认了这根轴,也早就在用这个乘法。
而"侧面那根轴",正是高斯说的"侧向单位";"转",正是下面每一节的主角。
接下来我们就到复平面上,看看"乘一个 i"到底是在转什么——然后一路走进大模型的源码。
0. 一句话主线
如果只能留一句话,那就是这句:
单位复数
e^(iθ)就是"纯旋转 θ 度"这个操作本身。给一个数乘上它,就把这个数转了 θ 度。
RoPE 要给第 m 个位置的词向量"转 m 个单位角度",用复数写出来就一句话:乘上 e^(i·m·θ)。
import torch
# 单位复数 e^(iθ) —— 一个"纯旋转 θ 度"的操作
theta = torch.tensor(torch.pi / 2) # 90°
z = torch.polar(torch.tensor(1.0), theta) # 模长 1、角度 θ 的复数
print("e^(i·90°) =", z) # ≈ 0 + 1j,正是"转到正上方"
记住这个画面:复数不是用来算的,是用来"转"的。一个单位复数 = 一次旋转。 下面所有东西都挂在"乘复数 = 旋转"这一件事上。
1. 乘一个 i,就是逆时针转 90°
高中把 i 定义成"平方等于 −1 的数",一个抽象到没有现实感的符号。
但在复平面上,i 有一个极具体的身份——"转 90°"这个动作:
import torch
p = torch.complex(torch.tensor(1.0), torch.tensor(0.0)) # 复平面上的点 (1, 0),指向正右
i = torch.complex(torch.tensor(0.0), torch.tensor(1.0)) # 虚数单位 i
print("p =", p) # (1+0j) 指向正右
print("p * i =", p * i) # (0+1j) 转到正上方 —— 乘 i = 逆时针转 90°
print("p * i*i =", p * i * i) # (-1+0j) 再转 90° = 转到正左(这就是 i²=−1 的几何意义!)
p * i 把指向正右的点转到了正上方;再乘一次 i,转到正左——也就是 -1。
"i² = −1"这句让你困惑多年的话,几何上不过是"转两个 90° = 转 180° = 掉头"。
i 从来不是什么"不存在的数",它是复平面上最干净的一个旋转操作。
2. 欧拉公式:e^(iθ) 就是"转 θ 度"
i 只能转 90°,那想转任意角度 θ 呢?答案就是那个"数学最美公式"——欧拉公式:
它看着吓人,其实只说了一件事:e^(iθ) 是复平面单位圆上、转到角度 θ 的那个点。 代码验证:
import torch
theta = torch.tensor(1.0) # 任意角度(弧度)
euler = torch.polar(torch.tensor(1.0), theta) # e^(iθ)
manual = torch.complex(torch.cos(theta), torch.sin(theta)) # cosθ + i·sinθ
print("e^(iθ) =", euler)
print("cosθ + i·sinθ =", manual)
print("两者相等吗?", torch.allclose(euler, manual)) # True —— 这就是欧拉公式
print("它的模长 :", euler.abs()) # 1.0,永远待在单位圆上
True。欧拉公式把"指数 e""三角 cos/sin""虚数 i"三样东西,缝成了同一个东西:旋转。
e^(iθ) 的模长恒为 1,意味着它只改方向、不改长度——这正是位置编码需要的性质(别动词向量的大小)。
记住这个"旋转发生器":要转 θ 度,就乘 e^(iθ)。
🤔 疑惑点一:凭什么"乘一个复数"就等于"旋转"?
因为复数乘法有一条黄金性质:模长相乘、幅角相加。两个单位复数相乘,模长都是 1 乘完还是 1,而它们的角度直接相加——所以"乘 e^(iα) 再乘 e^(iβ)"就等于"转 α 再转 β",也就是"一口气转 (α+β)"。旋转的叠加,变成了角度的加法。
用代码把这条"幅角相加"性质验出来——它是 RoPE 全部魔法的来源:
import torch
a = torch.polar(torch.tensor(1.0), torch.tensor(0.5)) # 转 0.5 弧度
b = torch.polar(torch.tensor(1.0), torch.tensor(1.2)) # 转 1.2 弧度
print("(a*b) 的幅角:", torch.angle(a * b).item()) # 1.7 = 0.5 + 1.2
print("直接转 1.7 :", torch.angle(torch.polar(torch.tensor(1.0), torch.tensor(1.7))).item())
# 复数相乘 -> 角度相加。"先转 a 再转 b" = "直接转 a+b"
两者的幅角都是 1.7。复数乘法 = 角度相加,这就是"旋转可以叠加"的最简洁写法。
(如果你读过《sin 和 cos》那篇的"和角公式",这里是同一件事的复数版——而且干净得多。)
下一节,我们用这一个性质,把 RoPE 整个搭出来。
2.5 RoPE 的复数版:给位置 m 的向量乘 e^(i·m·θ)
思路简单到令人发指:把 2 维词向量 (x₀, x₁) 看成一个复数 x₀ + i·x₁,
再乘上 e^(i·m·θ),它就被转了 m·θ 度——位置 m 就这样被"转"进了向量里。
import torch
def rope_complex(x, pos, theta=1.0):
z = torch.complex(x[0], x[1]) # 2 维向量看成复数
rot = torch.polar(torch.tensor(1.0), torch.tensor(pos * theta)) # e^(i·pos·θ)
out = z * rot # 乘一下 = 转 pos·θ 度
return torch.tensor([out.real, out.imag])
word = torch.tensor([1.0, 0.5]) # 同一个词的向量
for pos in [0, 1, 2, 3]:
print(f"位置 {pos}: {rope_complex(word, pos).round(decimals=3)}")
# 同一个词,放在不同位置,被转到了不同方向 —— 位置被"转"进了向量里
同一个词向量,放在位置 0、1、2、3,被转成了四个方向。"它排第几"这个信息,
现在藏在"它被乘了多少次旋转"里。 而且因为 |e^(iθ)|=1,向量长度纹丝不动。
3. 为什么这样最妙:注意力分数只依赖"相对位置"
RoPE 最漂亮的地方在这。注意力要算 query 和 key 的相关度(复数里就是 qₘ* · kₙ 的实部,
* 是共轭)。当 query 在位置 m、key 在位置 n,各自被旋转后,这个分数会只依赖 n − m:
import torch
def rot(pos, theta=0.5):
return torch.polar(torch.tensor(1.0), torch.tensor(pos * theta))
q = torch.complex(torch.tensor(0.8), torch.tensor(0.6)) # 一个 query(复数)
k = torch.complex(torch.tensor(0.3), torch.tensor(0.9)) # 一个 key(复数)
for m, n in [(0, 3), (2, 5), (10, 13)]:
q_m = q * rot(m) # query 转到位置 m
k_n = k * rot(n) # key 转到位置 n
score = (q_m.conj() * k_n).real # 复数"点积"的实部 = 注意力分数
print(f"query@{m:2d}, key@{n:2d} (相对距离={n-m}) -> 分数 = {score:.4f}")
# 三行分数完全相等 —— 只要相对距离一样,注意力就一样
三行输出的分数一模一样。原因用复数写出来一目了然:
qₘ* · kₙ = q* · k · e^(i(n−m)θ)——旋转项只剩下 (n−m) 这个相对量,绝对位置被自动消掉了。
RoPE 让模型天生理解"相对位置":它不在乎你这句话从第几个字开始,只在乎"这个词离那个词隔了多远"。
3.5 两个追问:聚类算"同频"吗?"前后"还分得出来吗?
追问一:大模型把 Python 版和 C 版的快速排序聚到同一片语义空间——那算"同频"吗?
不算,那是同向。这件事发生在语义那一半:训练把不同语言快排的向量指向拉近了,
所以余弦相似度高。这个直觉有一半是对的——点积/余弦相似度字面上就是在量夹角,
"合拍"的感觉没错;但"频率"这个词在 RoPE 里有专属含义:每对维度的转速 θⱼ,
它只管位置、不管语义,快排聚类跟转速毫无关系。
真要说"同频",RoPE 里它体现在另一处:全部 token 共用同一组转速——
正因为所有人的钟走得一样快,"相位差"才可比。就像时区:大家用同一套钟表系统,"差三个小时"才有意义。
追问二:分数只依赖 n−m,那它带符号吗?"A 在 B 前"和"A 在 B 后"分数一样吗?
把一对维度的分数写开,夹角其实由两部分叠加:
代码一验就清楚:
import numpy as np
theta = 0.35
q = 0.8 + 0.6j # 内容自带初始幅角
k = 0.3 + 0.9j
def score(m, n):
return (np.conj(q * np.exp(1j*m*theta)) * (k * np.exp(1j*n*theta))).real
print("q@5, k@8 (k 在后, Δ=+3):", round(score(5, 8), 4)) # -0.0803
print("q@8, k@5 (k 在前, Δ=-3):", round(score(8, 5), 4)) # 0.8565 —— 前后分数不同!
print("q@15, k@18 (整体平移, Δ=+3):", round(score(15, 18), 4)) # -0.0803 —— 平移依旧免疫
三个结论:
- 距离由相位差决定:整体平移 (5,8)→(15,18),分数纹丝不动(都是 −0.0803);
- 先后也分得出来:交换前后,Δ 从 +3 变 −3,只要内容初始夹角
φ0 ≠ 0,cos(φ0 + Δθ) ≠ cos(φ0 − Δθ)——实验里 k 在后是 −0.08、k 在前是 0.86,天差地别。 反过来说更有意思:如果词向量没有自己的"内容相位"(φ0 = 0),cos 是偶函数, RoPE 反而分不清前后。 先后感是"语义 × 位置"乘出来的—— 这正是 RoPE 用乘法而不是加法注入位置的深意; - 多频率分工:快转速把"相邻谁先谁后"拉开,慢转速守住"隔五十个词"的远距离顺序。
一个诚实的补充:decoder 型大模型"不能看未来"不是 RoPE 干的,是因果 mask 硬性规定的; RoPE 负责的是可见范围内"离多远、什么顺序"的度量。
一句话收束:一次点积同时读出两个分量——分类靠方向(内容项 q*k),
排序靠相位(旋转项 e^(i(n−m)θ)),"是什么"和"在哪里"在同一个乘法里结账。
🤔 疑惑点二:LLaMA 源码里的 view_as_complex / freqs_cis 到底在干嘛?
就是把上面这套"看成复数、乘 e^(iθ)"批量做完:先把几百维的词向量两两配对,凑成 d/2 个复数;再乘上预先算好的一整排单位复数 e^(i·位置·频率)(源码里叫 freqs_cis,cis 就是 cos+i·sin);最后转回实数。不同的配对用不同的频率(转速),快的分辨相邻词、慢的记住远距离——和钟表的时针分针秒针一个道理。
把 LLaMA 的真实做法用最小代码复刻一遍:
import torch
d, seq_len = 8, 4
i = torch.arange(0, d // 2).float()
freqs = 1.0 / (10000 ** (2 * i / d)) # 每一对维度的转速(快->慢)
pos = torch.arange(seq_len).float()
angles = torch.outer(pos, freqs) # (seq_len, d/2):每个位置每对的角度
freqs_cis = torch.polar(torch.ones_like(angles), angles) # 预算好的 e^(i·角度),即源码的 freqs_cis
x = torch.randn(seq_len, d) # 词向量
x_c = torch.view_as_complex(x.reshape(seq_len, d // 2, 2)) # 两两配对成复数
x_rot = torch.view_as_real(x_c * freqs_cis).reshape(seq_len, d) # 乘 e^(iθ) 再摊平回实数
print("旋转前各词模长:", x.norm(dim=1).round(decimals=3))
print("旋转后各词模长:", x_rot.norm(dim=1).round(decimals=3))
print("模长完全不变?", torch.allclose(x.norm(dim=1), x_rot.norm(dim=1))) # True
这段代码——view_as_complex(两两配对成复数)、乘 freqs_cis(旋转)、view_as_real(转回实数)——
就是 LLaMA 里 apply_rotary_emb 的骨架。 你会看到旋转前后每个词的模长分毫不差,
因为乘的是单位复数,只转方向不改大小。那个源码里看着神秘的 freqs_cis,本质就是你高中的 cosθ + i·sinθ。
🤔 疑惑点三:apply_rope 短短两行,凭什么先要 view_as_complex?
后面马上要写的 apply_rope 长这样,LLaMA 源码同款,但第一眼像咒语:
def apply_rope(x, freqs_cis):
x_c = torch.view_as_complex(x.reshape(*x.shape[:-1], -1, 2))
return torch.view_as_real(x_c * freqs_cis).reshape(x.shape)
两个问题:为什么要 reshape(..., -1, 2) 两两配对?为什么非得 view_as_complex?
第一问的答案:因为"旋转"是二维平面里的动作,一个孤零零的实数根本没法转。
一维数轴上的数只有两种命运——伸缩、翻号,你没办法"转"它 30°。
要旋转,至少得凑够两个坐标 (x, y) 组成一个平面。
所以 RoPE 的第一步是把 d 维向量相邻两个数一组,切成 d/2 个互不干扰的二维小平面——
每一对就是一根可以独立拨动的钟表指针(后面会看到,每根指针转速还不一样)。
第二问的答案:view_as_complex 不是计算,是换副眼镜。
它是零拷贝操作——内存里的数一个字节都没动,只是把"两个挨着的 float"重新宣布为"一个复数"。
为什么要换这副眼镜?因为在复数眼镜下,"二维旋转"那套 cos/sin 四则运算会坍缩成一次乘法(第 2 节的老朋友)。
空口无凭,把 apply_rope 拆成慢镜头,一行一行放。用一个 4 维玩具向量、全部转 90°,数字一眼能心算:
import torch
x = torch.tensor([1.0, 0.0, 0.0, 2.0]) # 一个 4 维"词向量"
# 想象成 2 根指针:第一对 (1,0) 指向正右;第二对 (0,2) 指向正上、长度 2
# ── 第 1 步:reshape,两两配对 ─────────────────────────────
pairs = x.reshape(-1, 2)
print(pairs) # [[1., 0.], [0., 2.]] —— 两个二维点,此刻还是普通 float
# ── 第 2 步:view_as_complex,换副眼镜(内存零拷贝)─────────
z = torch.view_as_complex(pairs)
print(z) # [1.+0.j, 0.+2.j] —— 同样的字节,现在被"读作"两个复数
# ── 第 3 步:乘 e^(iθ),一次乘法 = 一次旋转 ─────────────────
theta = torch.tensor(torch.pi / 2) # 90°
rot = torch.polar(torch.ones(2), theta.repeat(2)) # 两根指针都乘 e^(i·90°)
z_rot = z * rot
print(z_rot) # ≈ [0+1j, -2+0j] —— 右→上,上→左,各转 90°(0 会带 1e-8 级浮点零头)
# ── 第 4 步:view_as_real + reshape,摘掉眼镜摊平回去 ───────
out = torch.view_as_real(z_rot).reshape(4)
print(out) # ≈ [0., 1., -2., 0.] —— 还是 4 个 float,位置已经"转"了进去
四步里真正做了运算的只有第 3 步那一次乘法;第 1、2、4 步全在倒腾形状,一个数值都没改。
apply_rope 那两行"咒语",就是这四步的紧凑写法:配对 → 戴眼镜 → 乘 → 摘眼镜。
那不戴复数眼镜行不行?行,就是丑。 二维旋转的裸公式是 4 项乘加:
# 同一件事的"裸写"版:x' = x·cosθ − y·sinθ, y' = x·sinθ + y·cosθ
cos_t, sin_t = torch.cos(theta), torch.sin(theta)
manual = torch.stack([pairs[:, 0] * cos_t - pairs[:, 1] * sin_t,
pairs[:, 0] * sin_t + pairs[:, 1] * cos_t], dim=-1).reshape(4)
print(manual) # [0., 1., -2., 0.] —— 分毫不差
print(torch.allclose(out, manual)) # True
两个版本输出一模一样——view_as_complex 没有任何魔法。
把复数乘法按定义展开:(a+bi)(cosθ + i·sinθ) = (a·cosθ − b·sinθ) + i·(a·sinθ + b·cosθ),
实部虚部正是上面那两行旋转公式。PyTorch 替你背了公式,你只管"乘一下 = 转一下"。
(顺带白赚一个工程好处:复数乘法在 GPU 上有现成的高效实现,LLaMA 这么写既短又快。)
🤔 疑惑点四:复数也是"高维"吗?——给维度花钱的两种方式
前面把几千维向量切成 d/2 个复平面,很容易冒出一个疑问: 词向量搞几千维,是为了装下更多语义、让超平面能切开更多类别——维度越高容量越大, 代价是超过三维人类就没法想象了,只能投影到二三维看个残影。 那复数多出来的那根虚轴,是不是同一种"想象不了的东西"的最小版?
恰恰相反——这是两种完全不同的花钱方式。
高维词向量:用维度买"容量"。 这条路线里维度是同质的、可互换的:
第 3071 维没有任何专属分工,"国王 − 男人 + 女人 ≈ 女王"这种意义是训练统计出来的,
事先谁也说不出哪一维管什么。正因为没有结构可抓,人才理解不了,
PCA / t-SNE 投影出来的图只是丢掉了绝大部分信息的残影。这条路线上维度是廉价的:
R^n 想加多少加多少,从 768 加到 4096 再到 16384,没有任何数学障碍。
复数:用维度买"结构"。 复数只有 2 维,而且第 2 维有明确分工——
就是高斯说的那根"侧向轴",专门负责相位/旋转。多出这一维不是为了多装东西,
是为了买两条运算性质:"旋转"变成一次乘法(本文全部内容),
以及代数封闭——任何多项式方程在复数里都有根,
邦贝利当年那次"借道 √−121"之所以能走通,靠的正是这条。
所以一个复数 = 复平面上一支箭头,完全画得出来、完全理解得了:
它不是"难以想象的高维怪物",恰恰是数系扩张史上最可视化的一步。
而且"买结构"这条路贵得惊人,很快就到头了。 想再往上走:
四元数用 4 维买到"三维空间的旋转",代价是丢掉乘法交换律(ab ≠ ba);
八元数用 8 维继续买,连结合律也丢了;然后数学上被证明此路封死——
能构成这种带除法数系的维度只有 1、2、4、8 四个(Hurwitz 定理),
第 3 维、第 5 维想"当一种数"都不行。对比容量路线的随便加维,
你就能看清这是两条完全不同的路:容量廉价而无形,结构昂贵而有形。
RoPE 恰好是两条路线的合流,这正是它优雅的地方: 容量来自那几千个实数维度——语义装在向量指向哪里; 结构来自把它们看成 d/2 个复平面——位置装在每个平面被拨了多少度。 两半分得干干净净,代码可以验证:
import torch
torch.manual_seed(0)
d = 8
x = torch.randn(d)
x_c = torch.view_as_complex(x.reshape(-1, 2)) # d/2 个复平面
freqs = 1.0 / (10000 ** (2 * torch.arange(d // 2) / d))
pos = 3
y_c = x_c * torch.polar(torch.ones(d // 2), pos * freqs) # RoPE:乘 e^(i·pos·freq)
print("转前各平面指针长度:", x_c.abs().round(decimals=3))
print("转后各平面指针长度:", y_c.abs().round(decimals=3)) # 一模一样:容量那半分毫未动
diff = torch.remainder(y_c.angle() - x_c.angle(), 2 * torch.pi)
print("各平面实际被拨的角度:", diff.round(decimals=3))
print("理论上该拨的 pos·freq:", (pos * freqs).round(decimals=3)) # 精确等于:结构那半在干活
每个平面的指针长度分毫不动(语义容量没被碰),角度被精确拨了 pos·freq(旋转结构在干活)。
这也回答了"可视化会不会骗人":文章里画的 d/2 根指针不是高维投影的残影,
而是旋转机制的无损呈现——真正画不出来的,从头到尾只有语义那一半。
等等——d 个实数变成 d/2 个复数,维度砍半,这不就是"降维"吗?信息不丢吗?
一个比特都不丢。因为一个复数从来就不是"一个数",而是"两个实数打包":实部一个、虚部一个。
8 个实数换成 4 个复数,是 8 张一元纸币换 4 张二元——钱没变,只是两两捆在了一起。
"维度"这个词在这里会骗人:C^(d/2) 用复数当尺子量是 d/2 维,
换回实数当尺子量还是 d 维(复维度 × 2 = 实维度)。
数字减半是因为换了计量单位,不是东西变少了——就像 500 克改写成 0.5 千克。
分辨真降维和换单位,看一条铁标准:可不可逆。
真降维(比如 PCA 把 4096 维投到 2 维画图)是不可逆的——投影完就回不去了,信息真的扔了;
而 view_as_complex 完全可逆,逆操作就是 view_as_real,转回来的张量逐比特相等:
import torch
x = torch.randn(8) # 8 个 float32,32 字节
z = torch.view_as_complex(x.reshape(-1, 2)) # 4 个 complex64,还是 32 字节
back = torch.view_as_real(z).reshape(8)
print(torch.equal(x, back)) # True —— 逐比特相等,零损失
print(x.numel() * x.element_size(), "字节 ->", z.numel() * z.element_size(), "字节") # 32 -> 32
所以判断有没有损失,别数"个数",看字节和可逆性:32 字节进、32 字节出、原路可退。
降维是扔坐标;view_as_complex 是把坐标两两装订成册——书页一页没少,只是换了装订方式。
(真正"变少"的其实是另一样东西:允许的操作。把一对实数看成复数后,RoPE 只对它做 "乘单位复数"这一种动作——二维平面上任意线性变换有 4 个自由度,纯旋转只有 1 个。 但这是主动选择只用旋转(为了不碰模长、只盖位置戳),是限制"动作",不是压缩"数据"。)
顺带说一句"超过三维怎么办":数学家的办法其实不是硬想象,
而是像复数这样给维度指派结构,然后用代数代替视觉。
Hinton 有句玩笑说得透彻:"要处理 14 维空间,就想象一个 3 维空间,
然后大声对自己说'十四'。"——能画就画(复平面),不能画就换代数(e^(iθ)),
这本身就是复数教给我们的方法论。
🎬 动手:一个最小复数版 RoPE,接进注意力
把上面所有画面缝进一个能跑的最小复数 RoPE。这次做一个最能体现"位置感"的实验: 固定一个 query 词和一个 key 词,把它俩在句子里整体平移,看注意力分数动不动。
import torch
def build_freqs_cis(seq_len, d, base=10000.0):
i = torch.arange(0, d // 2).float()
freqs = 1.0 / (base ** (2 * i / d)) # d/2 根指针各自的转速(快 -> 慢)
angles = torch.outer(torch.arange(seq_len).float(), freqs)
return torch.polar(torch.ones_like(angles), angles) # e^(i·pos·freq)
def apply_rope(x, freqs_cis): # 疑惑点三拆过的四步:
x_c = torch.view_as_complex(x.reshape(*x.shape[:-1], -1, 2)) # 配对 + 戴眼镜
return torch.view_as_real(x_c * freqs_cis).reshape(x.shape) # 乘(=转) + 摘眼镜
torch.manual_seed(0)
d = 8
q_word = torch.randn(d) # 一个固定的 query 词
k_word = torch.randn(d) # 一个固定的 key 词
freqs_cis = build_freqs_cis(seq_len=32, d=d)
def score(m, n):
"""query 放在位置 m、key 放在位置 n 时的注意力分数"""
q = apply_rope(q_word, freqs_cis[m]) # 转 m 份角度
k = apply_rope(k_word, freqs_cis[n]) # 转 n 份角度
return (q @ k / d ** 0.5).item()
print("同一对词,相对距离固定为 3,整体平移:")
for m, n in [(0, 3), (7, 10), (20, 23)]:
print(f" query@{m:2d}, key@{n:2d} -> 分数 = {score(m, n):.6f}")
print("同一对词,相对距离改变:")
for m, n in [(0, 1), (0, 3), (0, 8)]:
print(f" query@{m:2d}, key@{n:2d} -> 分数 = {score(m, n):.6f}")
跑出来你会看到:上面三行分数小数点后六位都一样——这对词从句首平移到句子中段、再到第 20 个词,
注意力纹丝不动;下面三行一改相对距离,分数立刻变了。
这就是"位置感"的准确含义:模型对"隔了几个词"敏感,对"整句话从哪开始"完全免疫——
第 3 节用单个复数证明的 qₘ* · kₙ 只依赖 n−m,现在在真实形状的向量上原样复现。
短短几行,就是今天所有主流大模型位置编码的内核。仓库里的动画脚本把这套"复平面旋转"画了出来:
python scripts/complex_rope_visualization.py
画面里,d/2 根单位复数像钟表指针一样在复平面上转,相对位置每加 1 就各自多转一个固定角度, 快指针(红)负责分辨相邻词、慢指针(蓝)负责记住远方——这就是
freqs_cis的全部内容。 右边那条曲线是"注意力分数 vs 相对距离 r":分数只由 r 决定, 无论把绝对位置整体平移多少,这条曲线纹丝不动——"只认相对位置"的直观证据。
缝合:把所有画面接起来
回到开头,现在每个概念都有了画面和代码出处:
| 复数概念 | 高中怎么讲 | 这篇文章怎么看(画面) | 在生活里是什么 | 在大模型里是什么 |
|---|---|---|---|---|
虚数 i |
平方等于 −1 的数 | "转 90°"这个动作(第 1 节) | 侧面那根轴:信用、未来、相位 | 复平面旋转的最小单位 |
| 虚数的出身 | (从来不讲) | 解三次方程借道的桥(第 -1 节) | 贷款买房:借虚补实,闭环回实 | ——它的第 450 年应用 |
i² = −1 |
一条要背的规则 | 转两个 90° = 掉头(第 1 节) | pivot:两次转身 = 掉头,不必归零 | 旋转可叠加的起点 |
| 欧拉公式 | e^(iθ)=cosθ+isinθ |
"转 θ 度"的发生器(第 2 节) | 交流电相量,每秒 50 圈 | RoPE 里的 freqs_cis |
| 复数乘法 | 模长相乘、幅角相加 | 乘一下 = 转一下(疑惑点一) | 分量 × 时机:同样的话看什么时候说 | 给词向量盖"位置戳" |
共轭 z* |
虚部取反 | 反向旋转,消掉绝对位置(第 3 节) | 借了就要还,虚部对消 | 注意力只认相对距离 |
| 复数的 2 维 | (从不解释为什么是 2 维) | 用维度买"结构"不是买"容量"(疑惑点四) | 第二根轴有分工:相位、信用、未来 | d 维装语义 × d/2 平面装位置 |
四句话总结这篇文章:
虚数是被生活(三次方程)逼出来的,不是发明出来的——它一出生就是"以虚补实"的桥: 实数进、借道虚数、实数出,和贷款买房、科技股折现未来是同一套思维(第 -1、-0.5 节); 复数的灵魂是"旋转"——
e^(iθ)就是"转 θ 度"这个操作,乘一下就转一下(第 1、2 节); 复数乘法 = 幅角相加,让"位置 m"变成"乘了 m 次旋转",相对位置变成角度相减(疑惑点一、第 3 节); 于是 RoPE 的工业实现就是复数乘法,LLaMA 源码里的view_as_complex + freqs_cis,正是你高中的欧拉公式——view_as_complex只是零拷贝地"换副眼镜",把两两配对的 float 读作复数,好让四行旋转公式坍缩成一次乘法(疑惑点二、三)。
当年虚数学得那么憋屈,不是因为它难,是因为它被从生活里连根拔了出来:
没人告诉你它是三次方程逼出来的救命通道,没人告诉你你每次刷信用卡、看科技股、开空调
都在用"虚的维度撬动实的世界"这套思维,也没人告诉你——
那个"根本不存在"的 i,最后转进了每一个大模型的位置编码里,
成了它记住"谁先谁后"最优雅的那行代码。
数学来源于生活,也终将回到生活。现在把上面每段代码跑一遍,比高中刷十套复数题都值。
也算回应开头那位读者:"前两道送分选择题"是考卷给复数的地位,"旋转"才是复数给自己挣的地位——
考卷只考了它的皮,工业界用的是它的魂。
备注(选题/标题): 这篇是《sin 和 cos》的"第二种讲法",务必错峰发布—— 让 sin/cos 那篇先跑满一两周(同属 RoPE 机制,紧挨着发会互相分流),再用这篇做"RoPE 第二波"。 差异点要在开头讲清楚:sin/cos 篇用旋转矩阵,这篇用复数,且落到工业界真实源码(
view_as_complex), 是更"硬核、贴代码"的版本,适合吸引进阶读者。 若并入"大学4年"主系列,标题可换成:《大学4年没让你真正搞懂的复数,被一段 LLaMA 源码讲透了》。