MiniMind — 阶段 2.3:RoPE
mean(x) 和 std(x) 是什么?
① mean(x):均值(Mean)
就是求平均:
x = [150.0, 200.0, 180.0]
mean(x) = (150 + 200 + 180) / 3 = 176.7
② std(x):标准差(Standard Deviation)
衡量数据离均值有多远,也就是”数据有多分散”:
x = [150.0, 200.0, 180.0],mean = 176.7
① 每个值减去均值,再平方:
(150 - 176.7)² = 712.9
(200 - 176.7)² = 542.9
(180 - 176.7)² = 10.9
② 求平均:
(712.9 + 542.9 + 10.9) / 3 = 422.2
③ 开根号:
sqrt(422.2) ≈ 20.5
所以 std(x) ≈ 20.5,意思是这组数据平均偏离均值约 20.5。
LayerNorm 完整过程
x = [150.0, 200.0, 180.0]
mean = 176.7, std = 20.5
LayerNorm(x) = (x - mean) / std * γ + β
① 减均值(centering,中心化):
[150-176.7, 200-176.7, 180-176.7] = [-26.7, 23.3, 3.3]
② 除以标准差(scaling,缩放):
[-26.7/20.5, 23.3/20.5, 3.3/20.5] = [-1.30, 1.14, 0.16]
③ 乘 γ,加 β(都是 learnable parameters)
LayerNorm和RMSNorm的差异
现在回到刚才的问题,其实 RMSNorm 比 LayerNorm 少了两个操作:
| 操作 | LayerNorm | RMSNorm |
|---|---|---|
| 减均值(centering) | ✅ x - mean(x) | ❌ 没有 |
| 缩放 | ✅ / std(x) | ✅ / RMS(x) |
| 乘 γ | ✅ | ✅ |
| 加 β(bias) | ✅ + β | ❌ 没有 |
你发现的 +β 是第一个少的 ✅
还多少了一步:不减均值(no centering)✅
为什么 MiniMind(和 LLaMA)选择 RMSNorm?
一句话:效果几乎一样,但计算更快。
- 少算 mean → 省计算
- 少了 β 参数 → 省内存
- 实验证明对 LLM 来说,centering 对效果影响很小
现在我们理解了 RMSNorm 的数学原理。接下来我想让你看看真实代码长什么样。
在你之前克隆的 MiniMind 项目中,打开这个文件:
minimind/model/model.py
找到 class RMSNorm,看看它的 forward 方法。
问题:代码里有一个很小的数 self.eps(即公式里的 ε),你觉得它是干什么用的?为什么需要它?
(提示:想想如果 RMS(x) 的值等于 0 会发生什么)
回答
没有 model.py 文件,实际项目里是 model_minimind.py 文件:
class RMSNorm(torch.nn.Module):
def __init__(self, dim: int, eps: float = 1e-5):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def _norm(self, x):
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
def forward(self, x):
return self.weight * self._norm(x.float()).type_as(x)
ε 可能是最初输入的值,避免多次转换之后与原始输入偏差过大?
为0会导致计算异常?根据公式来看,RMS(x) 会作为除数,而0是不能作为除数的
ε 的作用:防止除以零(Division by Zero)
你的第二个猜测完全正确! ✅
RMS(x) = sqrt( mean(x²) + ε )
如果所有值恰好为 0:
x = [0.0, 0.0, 0.0]
mean(x²) = 0
没有 ε → sqrt(0) = 0 → x / 0 = 💥 报错!
有了 ε → sqrt(0 + 0.00001) = 0.00316 → 安全!
ε = 1e-5 即 0.00001,极小,不影响正常计算,但能保底。
这是深度学习中非常常见的技巧,叫做 numerical stability trick。
逐行读懂 RMSNorm 代码
第一部分:__init__(初始化)
def __init__(self, dim: int, eps: float = 1e-5):
super().__init__()
self.eps = eps # 存储 ε
self.weight = nn.Parameter(torch.ones(dim)) # 就是公式里的 γ
dim:向量的维度(比如 512)nn.Parameter:告诉 PyTorch “这个变量需要训练”torch.ones(dim):初始值全是 1,即γ = [1, 1, 1, ..., 1]
第二部分:_norm(核心计算)
def _norm(self, x):
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
拆解每一步:
x.pow(2) # x²,每个元素求平方
.mean(-1, # 沿最后一个维度求均值(mean)
keepdim=True) # 保持维度不变(方便后续运算)
+ self.eps # 加上 ε,防止为 0
torch.rsqrt(...) # rsqrt = 1/sqrt(...),即"先开根号,再取倒数"
x * ... # x 乘以 1/sqrt(...),等价于 x / sqrt(...)
对应公式:x / sqrt(mean(x²) + ε) ✅
💡 为什么用
rsqrt(倒数平方根)而不是x / sqrt(...)? 因为乘法比除法计算更快,在 GPU 上尤其如此。
第三部分:forward(前向传播)
def forward(self, x):
return self.weight * self._norm(x.float()).type_as(x)
x.float() # 转为 float32 高精度,确保计算精确
self._norm(...) # 做归一化
.type_as(x) # 转回原来的数据类型(可能是 float16/bfloat16)
self.weight * ... # 乘以 γ(learnable parameter)
💡 为什么要先转 float32 再转回来? 模型训练常用半精度(float16/bfloat16)来省内存加速, 但归一化涉及求平方和开根号,精度不够容易出错, 所以临时用 float32 算,算完再转回去。这叫 mixed precision(混合精度)。
🔨 动手任务
在终端中运行以下 Python 代码,亲手验证 RMSNorm 的计算:
import torch
import torch.nn as nn
# 创建一个 RMSNorm(维度=3)
rms = nn.Module() # 我们手动算
x = torch.tensor([[150.0, 200.0, 180.0]]) # 1个Token,3维
# 手动计算 RMSNorm
eps = 1e-5
rms_val = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps)
output = x * rms_val
print(f"输入: {x}")
print(f"RMS 倒数: {rms_val}")
print(f"输出: {output}")
看看输出是否接近我们之前手算的 [0.84, 1.12, 1.01]。
RMSNorm 在 MiniMind 模型中被用在了哪些位置?
提示:在 model_minimind.py 中搜索 RMSNorm,看看它出现在哪里。想想为什么要在这些位置做归一化?
下一步预告:零件 ② —— RoPE(Rotary Position Embedding,旋转位置编码),让模型知道每个 Token 的”位置信息”。
回答
动手题运行结果:输出: tensor([[0.8434, 1.1245, 1.0120]])
:
- 在
MiniMindBlock的__init中有2处
class MiniMindBlock(nn.Module):
def __init__(self, layer_id: int, config: MiniMindConfig):
super().__init__()
self.num_attention_heads = config.num_attention_heads
self.hidden_size = config.hidden_size
self.head_dim = config.hidden_size // config.num_attention_heads
self.self_attn = Attention(config)
self.layer_id = layer_id
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.mlp = FeedForward(config) if not config.use_moe else MOEFeedForward(config)
# ...其他代码
这里之前有提到过,正是 TransformBlock的内部转换,整个流程是:
- 输入
- RMSNorm
- Attention
- 残差连接
- RMSNorm
- FFN / SwiGLu
- 残差连接
- 输出
所以我理解是在输入之后 和 Attention输出之后分别进行了一次 RMSNorm
- 在
MiniMindModel的__init中有1处
class MiniMindModel(nn.Module):
def __init__(self, config: MiniMindConfig):
super().__init__()
self.config = config
self.vocab_size, self.num_hidden_layers = config.vocab_size, config.num_hidden_layers
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
self.dropout = nn.Dropout(config.dropout)
self.layers = nn.ModuleList([MiniMindBlock(l, config) for l in range(self.num_hidden_layers)])
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
freqs_cos, freqs_sin = precompute_freqs_cis(dim=config.hidden_size // config.num_attention_heads,
end=config.max_position_embeddings, rope_base=config.rope_theta,
rope_scaling=config.rope_scaling)
self.register_buffer("freqs_cos", freqs_cos, persistent=False)
self.register_buffer("freqs_sin", freqs_sin, persistent=False)
# ...其他代码
这里我理解就是整个初始化的时候进行了一次RMSNorm,这里从代码看应该是完成 MiniMindBlock 的多层处理后,对最终的一个结果进行RMSNorm
RMSNorm在哪里使用?
三处全部找到,理解也非常到位!👏
为什么在这三个位置做 RMSNorm?
位置 ①② :MiniMindBlock 内部(每一层都有)
你总结的流程完全正确。我补充一个重要术语:
Pre-Norm
这种先归一化,再计算的模式叫 Pre-Norm:
Pre-Norm(MiniMind / LLaMA 的做法):
→ RMSNorm → Attention → 残差连接
→ RMSNorm → FFN → 残差连接
Post-Norm
还有一种相反的做法叫 Post-Norm(原始 Transformer 论文的做法):
Post-Norm(原始 Transformer):
→ Attention → 残差连接 → LayerNorm
→ FFN → 残差连接 → LayerNorm
为什么现在主流 LLM 都用 Pre-Norm?
| Pre-Norm | Post-Norm | |
|---|---|---|
| 归一化时机 | 计算之前 | 计算之后 |
| 训练稳定性 | ✅ 更稳定 | ⚠️ 深层网络容易不稳定 |
| 是否需要 warm-up | 不太依赖 | 强烈依赖 learning rate warm-up |
| 使用者 | LLaMA, GPT-3, MiniMind | 原始 Transformer, BERT |
💡 直觉理解: Pre-Norm 就像”先洗菜再炒”——保证每次 Attention / FFN 拿到的输入都是干净的。 Post-Norm 像”先炒完再洗”——如果中间爆了(数值爆炸),来不及补救。
位置 ③:所有层结束后的最终 RMSNorm
你的理解完全正确。数据经过多层 MiniMindBlock 后,数值可能又漂移了。
在送入最后的 output head(输出层,预测下一个 Token)之前,再做一次归一化,确保稳定:
Embedding → [Block 0 → Block 1 → ... → Block N] → RMSNorm → Output Head
↑
你找到的位置 ③
完整的 RMSNorm 地图
输入 Token IDs
↓
Embedding(查表,得到向量)
↓
┌─── Block 0 ──────────────────┐
│ ① RMSNorm → Attention → 残差 │
│ ② RMSNorm → FFN → 残差 │
└──────────────────────────────┘
↓
┌─── Block 1 ──────────────────┐
│ ① RMSNorm → Attention → 残差 │
│ ② RMSNorm → FFN → 残差 │
└──────────────────────────────┘
↓
...(重复 N 层)
↓
③ RMSNorm(最终归一化)
↓
Output Head(预测下一个 Token)
本节要点总结
| 要点 | 内容 |
|---|---|
| Pre-Norm vs Post-Norm | MiniMind 用 Pre-Norm,训练更稳定 |
| RMSNorm 出现 3 处 | Attention 前、FFN 前、所有层之后 |
| 目的一致 | 每次重要计算前/后,稳定数值范围 |
🤔 (承上启下)
我们现在知道了 Transformer Block 内部的流程:
→ RMSNorm → Attention → 残差 → RMSNorm → FFN → 残差
其中 Attention(注意力机制) 是最核心的部分,它让每个 Token 能”看到”其他 Token。
但有一个问题:Attention 计算的是 Token 之间的关系强弱,它本身并不知道 Token 的顺序。
比如这两句话:
"猫 吃 鱼"
"鱼 吃 猫"
如果没有位置信息,Attention 会认为它们完全一样(因为包含的 Token 相同)。
那么问题来了:怎么让模型知道每个 Token 在句子中的位置?
这就是下一个零件要解决的问题。你有什么猜想吗?或者准备好了,我们就进入零件 ② ——RoPE(Rotary Position Embedding,旋转位置编码) 🚀
回答
回答:单独用一个向量维度,和原信息叠加处理,记录每个Token的位置,比如通过绝对位置或相对位置,或者是其他位置方式
解释下这里:是否需要 warm-up 不太依赖 强烈依赖 learning rate warm-up,这里的warm-up是什么?
什么是 Learning Rate Warm-up?
你的猜想非常准确!👏 位置编码的核心思路就是”给每个 Token 附加位置信息”。后面讲 RoPE 时我们会看到 MiniMind 具体怎么做。
现在先解答你的问题——
先理解 Learning Rate(学习率)
训练模型的过程,本质是不断调整参数,让预测越来越准。
学习率决定了每次调整的步幅大小:
新参数 = 旧参数 - 学习率 × 梯度方向
打个比方,你蒙着眼走下山:
| 学习率 | 比喻 | 效果 |
|---|---|---|
| 太大 | 每步跨 10 米 | 💥 直接跳过山谷,来回震荡,永远到不了最低点 |
| 太小 | 每步挪 1 厘米 | 🐌 安全但极慢,训练要等很久 |
| 合适 | 每步跨 1 米 | ✅ 稳步下山 |
问题:训练刚开始时,参数是随机的
训练第一步时,模型的参数都是随机初始化的,梯度(方向指引)非常不靠谱:
训练初期:
- 参数:随机的 ❓
- 梯度:很不准,方向乱指 🌀
- 如果学习率大 → 大步往错误方向走 → 💥 数值爆炸(loss 变成 NaN)
Warm-up:开始慢走,逐渐加速
Warm-up 就是在训练最初的几步,先用很小的学习率,然后逐渐增大到正常值:
学习率
↑
│ ╭────────────────── ← 正常学习率
│ /
│ /
│ /
│ /
│ /
│ /
│ /
│/
└──────────────────────────→ 训练步数
|warm-up| 正常训练阶段
| 阶段 |
就像开车:
❌ 没有 warm-up:一脚油门踩到底 → 轮胎打滑 / 失控
✅ 有 warm-up:慢慢起步 → 平稳加速 → 巡航速度
为什么 Pre-Norm 不太依赖 Warm-up?
回到之前的对比:
Pre-Norm: → RMSNorm → Attention → 残差连接
Post-Norm: → Attention → 残差连接 → LayerNorm
Pre-Norm:每次计算前都先归一化,输入值始终在稳定范围内。即使训练初期学习率稍大,数值也不容易爆炸。
Post-Norm:先计算,后归一化。训练初期参数随机,如果学习率大,Attention 的输出可能先爆掉了,还没来得及被 LayerNorm 救回来。
| Pre-Norm | Post-Norm | |
|---|---|---|
| 计算前数值 | ✅ 已归一化,稳定 | ⚠️ 没归一化,可能很大 |
| 初期容错性 | 高,不太需要 warm-up | 低,必须用 warm-up 保命 |
💡 一句话总结:Pre-Norm 自带”安全气囊”,Post-Norm 需要你”慢慢开车”。
MiniMind 中的学习率策略
你以后会在训练脚本中看到类似这样的代码(train_pretrain.py):
# 学习率调度:先 warm-up,再 cosine 衰减
完整的学习率变化通常是这样的:
学习率
↑
│ ╭─╮
│ / ╲
│ / ╲
│ / ╲
│ / ╲
│ / ╲
│ / ╲___
│ /
└──────────────────────────────→ 训练步数
|warm-up| cosine 衰减(逐渐降低)
先慢慢加速 → 到达最高 → 逐渐减速(精细调整)
这个我们到阶段 4 预训练时会详细讲解和实操。
回顾一下你刚才的猜想:
“单独用一个向量维度,和原信息叠加处理,记录每个Token的位置”
在实践中,主要有三种方式给 Token 加位置信息:
| 方式 | 代表 | 做法 |
|---|---|---|
| 绝对位置编码 | 原始 Transformer | 位置向量直接加到输入上 |
| 相对位置编码 | T5, ALiBi | 编码 Token 之间的距离 |
| 旋转位置编码(RoPE) | LLaMA, MiniMind | 通过旋转角度编码位置 |
MiniMind 选择了第三种——RoPE。准备好了我们就开始!🚀
RoPE 知识点 ①:为什么需要位置编码?
Attention(注意力机制)的学习是:让每个 Token 去看其他 Token,判断谁跟我最相关。
比如这句话:
"小猫 喜欢 吃 鱼"
当模型处理”吃”这个 Token 时,Attention 会计算:
- “吃” 和 “小猫” 有多相关?
- “吃” 和 “喜欢” 有多相关?
- “吃” 和 “鱼” 有多相关?
问题来了
Attention 计算相关性时,本质上是比较两个向量的相似度(点积)。
它看到的是这样的东西:
Token 0 → 向量 [0.3, 0.8, 0.1, ...]
Token 1 → 向量 [0.5, 0.2, 0.9, ...]
Token 2 → 向量 [0.7, 0.4, 0.6, ...]
Token 3 → 向量 [0.1, 0.9, 0.3, ...]
但它不知道哪个是第 0 个,哪个是第 3 个!
就像你面前有 4 张牌,你知道每张牌的花色和点数,但不知道它们的排列顺序。
一个具体的例子
句子A: "猫 吃 鱼"
句子B: "鱼 吃 猫"
这两句话包含的 Token 完全相同,只是顺序不同。
如果没有位置信息,Attention 看到的是:
句子A: {猫的向量, 吃的向量, 鱼的向量}
句子B: {鱼的向量, 吃的向量, 猫的向量}
它会认为这两句话一模一样!但意思完全不同——一个是猫吃鱼,一个是鱼吃猫 🐱🐟
我们需要一种方法,把”你在第几个位置”这个信息注入到每个 Token 的向量中。
这就是位置编码(Position Encoding) 要做的事情。
🤔 为什么 Attention 本身无法区分 Token 的顺序
在继续之前,你能用自己的话回答这个问题吗:
为什么 Attention 本身无法区分 Token 的顺序?
理解了这一点,我们再往下走——看看”怎么把位置信息加进去”。
回答
回答:因为没有位置信息
对!✅ Attention 只做”向量之间的相似度比较”,不带任何位置信息。
RoPE 知识点 ②:最简单的位置编码方式——绝对位置编码
最直觉的想法
既然每个 Token 缺少位置信息,那我们造一个位置向量,直接加上去不就行了?
原始:
Token 0 的向量: [0.3, 0.8, 0.1]
Token 1 的向量: [0.5, 0.2, 0.9]
Token 2 的向量: [0.7, 0.4, 0.6]
位置向量(人为规定的):
位置 0: [1.0, 0.0, 0.0]
位置 1: [0.0, 1.0, 0.0]
位置 2: [0.0, 0.0, 1.0]
相加后:
Token 0: [0.3+1.0, 0.8+0.0, 0.1+0.0] = [1.3, 0.8, 0.1]
Token 1: [0.5+0.0, 0.2+1.0, 0.9+0.0] = [0.5, 1.2, 0.9]
Token 2: [0.7+0.0, 0.4+0.0, 0.6+1.0] = [0.7, 0.4, 1.6]
现在每个 Token 的向量都不一样了,即使原始内容相同,位置不同,加完之后的向量也不同。
这就是绝对位置编码的基本思路,原始 Transformer(2017)和 GPT-2 用的就是这种方式。
但这种方式有一个大问题
假设模型训练时,最长只见过 512 个 Token 的文本。
现在你给它一个 1000 个 Token 的文本:
位置 0 ~ 511: ✅ 训练时见过,有对应的位置向量
位置 512 ~ 999: ❌ 训练时没见过!没有对应的位置向量!
模型会懵掉,因为它完全不知道位置 512 之后该怎么处理。
💡 这就叫长度外推失败:训练时见过的长度之外,模型表现急剧下降。
本节要点
| 要点 | 内容 |
|---|---|
| 绝对位置编码 | 给每个位置造一个向量,直接加到 Token 向量上 |
| 优点 | 简单直观 |
| 缺点 | 超过训练长度就失效(长度外推差) |
🤔 绝对位置编码的核心缺陷是什么
绝对位置编码的核心缺陷是什么?用一句话说。
理解了之后,下一步我们看 RoPE 是怎么巧妙解决这个问题的。
回答:我认为至少有两个核心问题,一个是长度外推差,即超过训练长度就失效。另一个是位置的偏移会造成理解的不同,即使语义是相同的,比如说“猫吃鱼”这句话无论出现在开始、中间还是结尾,它的意义并没有发生变化,但绝对位置却差了很多
非常好!✅✅ 你说得比我预期的还要深刻!
你提到的两个问题完全正确:
- 长度外推差 — 超过训练长度就失效
- 绝对位置忽略了相对关系 — “猫吃鱼”无论出现在哪里,三个 Token 之间的关系是一样的,但绝对位置编码会让它们看起来完全不同
第二点特别关键,它引出了一个重要概念👇
RoPE 知识点 ③:为什么相对位置更重要
你的直觉是对的
语言理解中,Token 之间的距离比Token 在哪个位置更重要。
句子A: "我 觉得 猫 吃 鱼 很 有趣"
↑ ↑
位置2 位置4,距离 = 2
句子B: "嗯 嗯 嗯 嗯 嗯 我 觉得 猫 吃 鱼 很 有趣"
↑ ↑
位置7 位置9,距离 = 2
“猫”和”鱼”的距离都是 2,它们的语义关系完全相同。
理想的位置编码应该让模型关注的是”距离=2”,而不是”位置 2 和 4”或”位置 7 和 9”。
那我们需要什么?
一种位置编码方式,能同时做到:
| 需求 | 说明 |
|---|---|
| ✅ 区分不同位置 | 位置 0 和位置 1 要不一样 |
| ✅ 相对距离才是核心 | 距离相同 → Attention 分数相同 |
| ✅ 长度外推 | 超过训练长度也能合理处理 |
RoPE 就是为了同时满足这三个需求而设计的。
它的巧妙之处在于:
编码的是绝对位置(每个位置有自己的角度),但计算 Attention 时,效果只取决于相对距离。
🤔 RoPE 想同时实现哪两件看似矛盾的事
回答:编码的是绝对位置,输出的是相对距离
完美!✅ 一句话说到了本质。
现在我们进入 RoPE 最精彩的部分——旋转!
RoPE 知识点 ④:用”旋转”来编码位置
从生活中理解”旋转”
想象一个时钟的秒针:
位置 0 → 秒针指向 12 点方向(0°)
位置 1 → 秒针转了一小格(10°)
位置 2 → 秒针转了两小格(20°)
位置 3 → 秒针转了三小格(30°)
...
每个位置对应一个旋转角度,位置越大,转得越多。
RoPE 的核心思想就是:把每个 Token 的向量”旋转”一个跟位置有关的角度。
2D 平面上的旋转
先从最简单的情况看:假设一个向量只有 2 个维度(一个点在平面上)。
原始向量: (x, y) = (1.0, 0.0) ← 指向右边的一个箭头
把它旋转角度 θ 之后:
旋转后: (x', y')
x' = x·cos(θ) - y·sin(θ)
y' = x·sin(θ) + y·cos(θ)
这是高中数学的旋转公式。如果你没学过也没关系,只需要记住:
给一个 2D 向量和一个角度 θ,就能把这个向量旋转 θ 度。
画个图帮你理解:
y
↑
| ╱ 旋转后 (x', y')
| ╱
| ╱ θ角
|╱_____→ x
原始 (1, 0)
RoPE 怎么用旋转?
假设 Token 的向量是 [x, y](先只看 2 维):
位置 0 的 Token: 旋转 0×θ = 0° → 不动
位置 1 的 Token: 旋转 1×θ = θ° → 转一点
位置 2 的 Token: 旋转 2×θ = 2θ° → 转多一点
位置 3 的 Token: 旋转 3×θ = 3θ° → 转更多
规律:位置 m 的 Token,旋转角度 = m × θ
每个 Token 根据自己的位置,被旋转了不同的角度。这样:
- 不同位置的 Token → 旋转角度不同 → 向量不同 → ✅ 区分了位置
- 角度的差值只取决于位置的距离 → ✅ 体现了相对关系
关键直觉
两个 Token 做 Attention 时,会计算点积(相似度)。
位置 m 的 Token:旋转了 m×θ
位置 n 的 Token:旋转了 n×θ
它们的点积结果,只跟角度差 (m-n)×θ 有关!
也就是说:不管 m 和 n 具体是多少,只要 m-n 相同,Attention 分数就相同!
这就实现了你上一步说的:编码的是绝对位置(每个位置有自己的角度),输出的是相对距离(点积只跟差值有关)!
本节要点
| 要点 | 内容 |
|---|---|
| RoPE 的操作 | 把 Token 向量旋转一个角度,角度 = 位置 × 基础角度 |
| 旋转公式 | x’ = x·cos(θ) - y·sin(θ), y’ = x·sin(θ) + y·cos(θ) |
| 为什么有效 | 两个向量的点积只取决于角度差(相对距离) |
| 直觉理解 | 每个位置像时钟上的不同刻度,距离相同则 Attention 相同 |
🤔 思考题
位置 5 的 Token 和位置 8 的 Token,它们的旋转角度差是多少?它和位置 100 与位置 103 的旋转角度差相比如何?
答对了我们就看 RoPE 怎么处理高维向量(不止 2 维)。
回答:旋转角度差是 3θ,相同
另外我想确认下:
- RoPE是通过 Token 旋转跟 绝对位置 相关的角度,得到一个编码的位置值,这个值是和绝对位置相关的
- 两个Token之间的相对距离,是通过计算点积得到的,点积的结果体现了相对关系(只跟角度差
(m-n)*θ相关),只要m-n相同,则Attention分数就相同
确认点:相对位置信息和绝对位置信息
完美!✅ 3θ,完全相同!
你的两个确认点理解得非常准确,我帮你再精炼一下措辞:
✅ 确认点 1:完全正确
RoPE 给每个 Token 的向量旋转一个与绝对位置成正比的角度(位置 m → 旋转 m×θ)。 旋转之后的向量确实包含了绝对位置信息。
✅ 确认点 2:理解正确,但有一个小细节值得澄清
你说的核心意思完全对,但注意一个措辞:
点积并不是”计算出相对距离”,而是点积的结果自然只受相对距离影响。
区别在于:
❌ "通过点积计算出相对距离是3" ← 不是显式算出一个数字
✅ "点积结果只取决于相对距离(m-n)" ← 距离相同,结果自动相同
打个比方:
你站在旋转木马上,手里拿着一根棍子。
你在位置 5,朋友在位置 8 → 你们之间隔了 3 格 → 棍子够不到
你在位置 100,朋友在位置 103 → 还是隔了 3 格 → 棍子同样够不到
你不需要"计算"距离是 3,
而是因为物理上隔了相同的距离,结果自然相同。
RoPE 也是这样:数学结构保证了点积结果只跟距离有关,不需要模型显式地去”算”距离。
完整流程图(到目前为止)
Token 向量 [x, y]
│
│ 位置 m
▼
旋转 m×θ 角度 ──→ 新向量 [x', y'] (包含绝对位置信息)
│
│ 与另一个位置 n 的 Token 做点积
▼
Attention 分数(只取决于 |m-n| 的大小)
本节要点
| 要点 | 内容 |
|---|---|
| RoPE 编码 | 每个 Token 旋转绝对位置对应的角度 |
| RoPE 效果 | 点积结果自然只受相对距离影响,无需显式计算 |
| 关键词 | ”自然涌现”的相对位置,不是”显式计算”的 |
🤔 如何扩展高纬
在往高维扩展之前,想一个问题:
我们的 Token 向量通常有 512 维甚至更多,但旋转公式只能处理 2 维 (x, y)。你觉得 RoPE 会怎么把旋转扩展到 512 维?
提示:不需要想得太复杂,想想最简单的办法就好。
- 你先解释下为什么旋转公式只能处理2维
- 解释下点积的概念
好问题!👏 这两个都是非常基础且重要的数学概念,搞清楚它们,后面理解 RoPE 会非常顺畅。
问题 1:为什么旋转公式只能处理 2 维?
旋转的本质是什么?
想象你在桌子上放一张纸,纸上画了一个箭头。你用手指按住原点,旋转这张纸:
y
↑
| ╱ 旋转后
| ╱
| ╱ θ
|╱_____→ x
这个旋转发生在一个平面上。
关键认知:旋转 = 在一个平面内转动。一个平面 = 2 个维度。
所以旋转公式天生就是 2 维的操作:
x' = x·cos(θ) - y·sin(θ)
y' = x·sin(θ) + y·cos(θ)
它只涉及两个坐标 (x, y),因为一个平面只需要两个轴来描述。
3 维呢?
你可能会想:3D 空间也可以旋转啊?
没错!但仔细想想——
3D 旋转的例子:
- 绕 z 轴旋转 → 其实是在 x-y 平面上旋转 → 还是 2 维旋转!
- 绕 x 轴旋转 → 其实是在 y-z 平面上旋转 → 还是 2 维旋转!
任何旋转,本质上都是在某个 2D 平面内进行的。
3D 空间中的”复杂旋转”不过是多个 2D 平面旋转的组合。
所以问题来了
如果我们的向量是 512 维的:
Token 向量: [x₀, x₁, x₂, x₃, x₄, ..., x₅₁₁]
↑ ↑ ↑ ↑
512 个数字
一次 2D 旋转只能转 2 个数字。其余 510 个完全不受影响。
这就是为什么我说”旋转公式只能处理 2 维”。
问题 2:点积是什么?
从最直觉的理解开始
点积(Dot Product)是衡量两个向量”有多相似”的一种计算。
计算方法(超级简单)
两个向量,对应位置相乘,然后全部加起来:
向量 A = [1, 2, 3]
向量 B = [4, 5, 6]
点积 = 1×4 + 2×5 + 3×6
= 4 + 10 + 18
= 32
就这么简单!用 Python 写:
A = [1, 2, 3]
B = [4, 5, 6]
# 手动算
dot = A[0]*B[0] + A[1]*B[1] + A[2]*B[2] # 结果: 32
# 或者用循环
dot = sum(a * b for a, b in zip(A, B)) # 结果: 32
点积和”相似度”的关系
看几个例子:
例1:方向相同
A = [1, 0] →
B = [1, 0] →
点积 = 1×1 + 0×0 = 1 ✅ 正数,很大
例2:方向相反
A = [ 1, 0] →
B = [-1, 0] ←
点积 = 1×(-1) + 0×0 = -1 ❌ 负数
例3:方向垂直(完全无关)
A = [1, 0] →
B = [0, 1] ↑
点积 = 1×0 + 0×1 = 0 😐 零
总结规律:
点积 > 0 → 方向相似(越大越相似)
点积 = 0 → 完全无关(垂直)
点积 < 0 → 方向相反
在 Attention 中的作用
回到 Transformer,Attention 要回答的问题是:
“这个 Token 应该关注哪些其他 Token?”
方法就是用点积算相似度:
Q(Query)= "猫" 的查询向量:[0.3, 0.8, 0.1]
K₁(Key) = "吃" 的键向量: [0.2, 0.7, 0.2]
K₂(Key) = "天气" 的键向量: [0.9, 0.1, 0.6]
"猫" 和 "吃" 的点积 = 0.3×0.2 + 0.8×0.7 + 0.1×0.2 = 0.64 ← 相似!
"猫" 和 "天气" 的点积 = 0.3×0.9 + 0.8×0.1 + 0.1×0.6 = 0.41 ← 不太相似
点积越大 → 越相似 → Attention 权重越高 → 越关注这个 Token。
RoPE 和点积的关系
现在你就能理解了:
位置 5 的 Token:向量旋转了 5θ
位置 8 的 Token:向量旋转了 8θ
它们做点积时:
结果只取决于角度差 (8-5)×θ = 3θ
位置 100 的 Token:旋转了 100θ
位置 103 的 Token:旋转了 103θ
它们做点积时:
结果只取决于角度差 (103-100)×θ = 3θ ← 完全一样!
旋转之后做点积,角度差相同 → 结果相同。这就是 RoPE 的数学魔法。
本节要点
| 要点 | 内容 |
|---|---|
| 旋转 = 2D 操作 | 旋转本质是在一个平面内转动,一个平面 = 2 维 |
| 高维旋转 | 不存在一次性的高维旋转,只能在 2D 平面上旋转 |
| 点积公式 | 对应位置相乘再求和 |
| 点积意义 | 衡量两个向量的相似程度 |
| Attention 中 | Q 和 K 做点积 → 算相似度 → 决定关注谁 |
| RoPE 中 | 旋转后做点积,结果只取决于旋转角度差(相对距离) |
现在你知道了:
- 旋转只能处理 2 维
- 但 Token 向量有 512 维
你觉得 RoPE 会怎么把旋转扩展到 512 维?
提示:想想最”笨”但最直接的办法 😄
回答:以2个为一组去做旋转运算?
另外我要确认下:你说的相似度,是指两个token之间的关联关系,还是相对位置,比如说 猫吃鱼 这里是 猫 和 鱼 的语义关系还是位置关系,影响相似度?
完美!✅ 两两分组旋转,完全正确!
你的确认问题问得非常好,这涉及到 Attention 最核心的理解。
先确认你的答案:两两分组 ✅
对!就是这么”笨”而直接的办法:
512 维向量:[x₀, x₁, x₂, x₃, x₄, x₅, ..., x₅₁₀, x₅₁₁]
╰──╯ ╰──╯ ╰──╯ ╰────╯
第1组 第2组 第3组 ... 第256组
每组 2 个维度,各自独立旋转 → 共 256 组 → 256 次 2D 旋转
这个我们等下详细展开。先回答你更重要的问题 👇
相似度到底是语义关系还是位置关系?
答案:两者都有!
Attention 的点积结果同时受两个因素影响:
点积结果 = 语义相似度 + 位置关系
╰───┬───╯ ╰───┬──╯
Q、K 向量本身 RoPE 旋转带来的
我们拆开来看:
第一层:没有 RoPE 时 → 纯语义
假设没有任何位置编码:
"猫 吃 鱼"
Q("猫") · K("吃") = 0.64 ← "猫"和"吃"语义相关(猫会吃东西)
Q("猫") · K("鱼") = 0.71 ← "猫"和"鱼"语义也相关(猫爱吃鱼)
Q("猫") · K("天气") = 0.12 ← "猫"和"天气"语义无关
这时候点积只反映语义关系,完全不知道谁在前谁在后。
第二层:加上 RoPE 后 → 语义 + 位置
RoPE 在做点积之前,先把 Q 和 K 旋转:
"猫 吃 鱼"
位置0 位置1 位置2
Q("猫") 旋转 0×θ → Q'("猫")
K("吃") 旋转 1×θ → K'("吃")
K("鱼") 旋转 2×θ → K'("鱼")
然后用旋转后的向量做点积:
Q'("猫") · K'("吃") = 语义相似度 × 受距离1影响的调整
Q'("猫") · K'("鱼") = 语义相似度 × 受距离2影响的调整
旋转并没有”替换”掉语义信息,而是在语义的基础上”叠加”了位置信息。
打个比方
想象你在派对上选择跟谁聊天:
没有位置信息(纯语义):
"这个人和我兴趣相同,想跟他聊" → 只看关系
加上位置信息(语义+位置):
"这个人和我兴趣相同,而且就站在我旁边" → 既看关系,也看距离
"那个人和我兴趣也相同,但站在很远的角落" → 关系好但距离远,可能就不聊了
RoPE 让模型同时考虑:
- 语义:这两个词有没有关系?(猫和鱼有关系)
- 位置:这两个词距离多远?(相邻的词可能更重要)
用图总结
原始 Q, K 向量(语义信息)
│
│ RoPE 旋转(注入位置信息)
▼
旋转后的 Q', K'(语义 + 位置)
│
│ 点积
▼
Attention 分数(同时反映语义相似度和相对位置)
本节要点
| 要点 | 内容 |
|---|---|
| 高维旋转策略 | 两两分组,每组独立做 2D 旋转 |
| 点积含义 | 同时反映语义关系和位置关系 |
| 没有 RoPE | 点积 = 纯语义相似度 |
| 有 RoPE | 点积 = 语义相似度 + 位置关系的综合结果 |
| RoPE 的作用 | 不是替换语义,而是在语义上叠加位置信息 |
🤔 距离远关注度下降有好处吗
如果 RoPE 让距离远的 Token 之间点积偏小(关注度下降),你觉得这对语言理解有好处吗?能想到什么例子?
答完这个,我们就进入 RoPE 的代码实现!🔥
回答:比较纠结,我认为有好处,但是也会带来一些问题。大多数情况下距离相当远的语义联系确实是薄弱的,所以降低关注度有其合理性,但实际使用AI的时候,我们有时候会希望一些关键信息被始终记住,如果降低关注度,这个时候又会产生问题。
你的回答非常成熟!👏 这不是一个有标准答案的问题,你的思考角度完全正确。
你的分析:完全到位
✅ 好处:距离衰减符合语言直觉
"今天天气真好,我决定带着我的猫去公园散步"
╰──近距离──╯ ╰──近距离──╯
"天气"和"好"强相关 "猫"和"散步"强相关
"天气"和"散步"?→ 距离远,关联弱 → 降低关注合理
大多数情况下,局部上下文比远距离上下文更重要。
✅ 问题:远距离关键信息被遗忘
你说的这个问题在业界是一个真实存在且被广泛研究的问题:
用户输入一个很长的 prompt:
"你是一个专业医生,请用严谨的医学术语回答问题。
[中间 5000 字的病历资料...]
请问这个患者应该怎么治疗?"
问题:开头的 "你是专业医生" 这个关键指令
距离最后的问题非常远
→ 注意力可能下降 → 模型"忘了"自己是医生
这个现象有个专门的名字叫 “Lost in the Middle”(中间丢失)——
实验发现:
┌──────────────────────────────────┐
│ 开头的信息 → 记得还行 │
│ 中间的信息 → 最容易被遗忘 😱 │
│ 结尾的信息 → 记得最清楚 │
└──────────────────────────────────┘
这也是为什么后来出现了各种长文本优化技术(比如我们之后会学的 YaRN),就是在解决你说的这个问题。
你的直觉和业界顶级研究者关注的问题完全一致,非常棒! 🎯
好,基础概念全部打通了!现在进入激动人心的环节——RoPE 代码实现 🔥
回顾:我们要做什么
512 维向量 → 分成 256 组 → 每组 2 维 → 各自旋转
但有一个重要细节还没讲:
每组旋转的角度一样吗?
不一样! 每组有不同的基础频率 θ:
第 0 组 (x₀, x₁) → 基础频率 θ₀(变化快)
第 1 组 (x₂, x₃) → 基础频率 θ₁(稍慢)
第 2 组 (x₄, x₅) → 基础频率 θ₂(更慢)
...
第 255 组 (x₅₁₀, x₅₁₁) → 基础频率 θ₂₅₅(变化很慢)
每组的 θ 按这个公式计算:
θᵢ = 1 / (10000 ^ (2i / d))
其中:
i = 第几组 (0, 1, 2, ..., 255)
d = 向量总维度 (512)
算几个具体值感受一下:
θ₀ = 1 / 10000^(0/512) = 1 / 1 = 1.0 ← 转得快
θ₁ = 1 / 10000^(2/512) = 1 / 1.018 = 0.982 ← 稍慢
θ₂ = 1 / 10000^(4/512) = 1 / 1.036 = 0.965 ← 更慢
...
θ₂₅₅ = 1 / 10000^(510/512) = 1 / 9772 = 0.0001 ← 几乎不转
为什么不同组要用不同的旋转速度?
如果所有组都用相同速度会怎样?
假设所有 256 组都用同一个 θ,那么:
位置 0 的 Token:所有组都旋转 0°
位置 1 的 Token:所有组都旋转 θ
位置 2 的 Token:所有组都旋转 2θ
...
看起来也能区分位置?但有一个致命问题——
旋转是周期性的!
转 0° 和 转 360° → 一模一样!
转 30° 和 转 390° → 一模一样!
如果 θ = 1°,那么:
位置 0:旋转 0°
位置 360:旋转 360° = 0° 😱 和位置 0 一模一样!
模型分不清位置 0 和位置 360!
而且因为所有组都用同一个 θ,所有组都在同一时刻”重复”,没有任何补救。
用时钟来理解
这是我认为最直觉的比喻:
只有秒针的钟 ⏱️
秒针:每 60 秒转一圈
时刻 0 秒 → 秒针指向 12 点
时刻 60 秒 → 秒针指向 12 点
时刻 120 秒→ 秒针指向 12 点
你看一眼钟:秒针指向 12 点
→ 现在是第 0 秒?第 60 秒?第 120 秒?
→ 你分不清!❌
有秒针 + 分针的钟 🕐
时刻 0 秒 → 秒针 12 点,分针 12 点
时刻 60 秒 → 秒针 12 点,分针 12:01
时刻 120 秒→ 秒针 12 点,分针 12:02
你看一眼钟:秒针指向 12 点,分针指向 12:02
→ 一定是第 120 秒!✅
秒针虽然转回来了(重复了),但分针还没转回来,所以能区分!
有秒针 + 分针 + 时针的钟 🕐
时刻 0 → 秒 12,分 12,时 12
时刻 3600 秒 → 秒 12,分 12,时 1 点
哪怕秒针和分针都转回来了,时针还在移动
→ 能区分更远的时间!✅✅
对应到 RoPE
第 0 组:θ₀ 大 → 转得快 → 像秒针 → 区分相邻位置(位置 1 和 2)
第 1 组:θ₁ 稍小 → 稍慢 → 像分针 → 区分中等距离(位置 50 和 80)
第 2 组:θ₂ 更小 → 更慢 → 像时针 → 区分远距离(位置 500 和 800)
...
第 255 组:θ₂₅₅ 极小 → 几乎不转 → 区分极远距离
256 组不同频率组合在一起 = 一个 256 根指针的超级时钟 🕐
每个位置在这个超级时钟上有唯一的指针组合,所以每个位置都能被唯一标识。
一句话总结
不同频率 = 不同精度的尺子,叠加在一起,就能从”很近”到”很远”全覆盖。
MiniMind 真实 RoPE 源码(已和本地代码对比)
文件位置:model/model_minimind.py
一共两个关键函数:
函数 1:precompute_freqs_cis —— 预计算 cos 和 sin
从源码可以看到,核心频率计算为 freqs = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
完整函数(忽略 YaRN 长文本部分,之后再学):
def precompute_freqs_cis(dim, end=1024, rope_base=10000.0, rope_scaling=None):
# ========= 第一步:计算每组的基础频率 θᵢ =========
freqs = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
attn_factor = 1.0
# (YaRN 长文本扩展部分,暂时跳过)
# if rope_scaling is not None: ...
# ========= 第二步:每个位置 × 每组频率 = 旋转角度 =========
t = torch.arange(end).float() # [0, 1, 2, ..., end-1] 位置编号
freqs = torch.outer(t, freqs) # 外积 → 形状 [end, dim//2]
# ========= 第三步:算出 cos 和 sin =========
freqs_cos = torch.cos(freqs) * attn_factor
freqs_sin = torch.sin(freqs) * attn_factor
return freqs_cos, freqs_sin
在模型初始化时,这个函数被调用:freqs_cos, freqs_sin = precompute_freqs_cis(dim=config.hidden_size // config.num_attention_heads, end=config.max_position_embeddings, rope_base=config.rope_theta, rope_scaling=config.rope_scaling)
函数 2:apply_rotary_pos_emb —— 对 Q、K 执行旋转
MiniMind 的实际代码:
def apply_rotary_pos_emb(q, k, cos, sin, position_ids=None, unsqueeze_dim=1):
def rotate_half(x):
return torch.cat((-x[..., x.shape[-1] // 2:], x[..., : x.shape[-1] // 2]), dim=-1)
q_embed = (q * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(q) * sin.unsqueeze(unsqueeze_dim))
k_embed = (k * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(k) * sin.unsqueeze(unsqueeze_dim))
return q_embed, k_embed
这就是 MiniMind 的全部 RoPE 代码,非常精炼!
我们现在不急着全部看懂。先只理解第一步:为什么频率要不同。
freqs = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
相当于:
/**
* 计算 RoPE 旋转位置编码的频率向量
* 对应 Python: freqs = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[:(dim // 2)].float() / dim))
*/
function computeRopeFreqs(dim: number, ropeBase: number): number[] {
const half = Math.floor(dim / 2);
const freqs: number[] = [];
for (let i = 0; i < half; i++) {
// 等价于 torch.arange(0, dim, 2) → [0, 2, 4, ..., dim-2]
const exponent = (i * 2) / dim;
// 1.0 / (rope_base ** exponent)
freqs.push(1.0 / (ropeBase ** exponent));
}
return freqs;
}
这一行信息量很大,拆开来看:
# 假设 dim = 8(实际 MiniMind 是 64,这里简化举例)
torch.arange(0, 8, 2) # → [0, 2, 4, 6] 每隔2取一个
[: (8 // 2)] # → [0, 2, 4, 6] 取前 dim/2 个(4个)
.float() / 8 # → [0.0, 0.25, 0.5, 0.75] 除以 dim
rope_base ** (...) # → [10000^0.0, 10000^0.25, 10000^0.5, 10000^0.75]
# = [1, 10, 100, 1000]
1.0 / (...) # → [1/1, 1/10, 1/100, 1/1000]
# = [1.0, 0.1, 0.01, 0.001]
结果是 4 个频率值,从大到小排列:
freqs = [1.0, 0.1, 0.01, 0.001]
第0组 第1组 第2组 第3组
转最快 ↓ ↓ 转最慢
为什么要从大到小?
就是上节讲的时钟原理:
第0组 频率 1.0 → 秒针 → 每个位置转 1.0 弧度 → 区分相邻位置
第1组 频率 0.1 → 分针 → 每个位置转 0.1 弧度 → 区分稍远位置
第2组 频率 0.01 → 时针 → 每个位置转 0.01 弧度 → 区分更远位置
第3组 频率 0.001 → 日历 → 每个位置转 0.001 弧度 → 区分极远位置
如果只用 1 组(只有秒针),转一圈就回来了,分不清位置 0 和位置 6.28。 4 组组合在一起,每个位置有唯一的 (cos, sin) 组合。
本节要点
| 要点 | 内容 |
|---|---|
| 源码位置 | model/model_minimind.py |
| 函数 1 | precompute_freqs_cis → 预计算所有位置的 cos/sin |
| 函数 2 | apply_rotary_pos_emb → 用 cos/sin 旋转 Q 和 K |
| 核心一行 | 1.0 / (10000 ** (arange / dim)) → 生成从大到小的频率 |
| 不同频率原因 | 快频率区分近距离,慢频率区分远距离,组合起来覆盖所有距离 |
🤔 计算 torch.outer
回到代码中的这一步:
t = torch.arange(end).float() # [0, 1, 2, ..., 1023] freqs = torch.outer(t, freqs) # 外积
torch.outer是外积,就是把两个向量每对元素相乘。如果
t = [0, 1, 2](3个位置),freqs = [1.0, 0.1](2组频率)请问
torch.outer(t, freqs)的结果是什么?(提示:结果是一个 3×2 的矩阵)
回答:是 [[0,1,2],[0,0.1,0.2]]