← 返回学习笔记

MiniMind — 阶段 2.3:RoPE

AIMiniMind

2026-03-22 · 全文

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 少了两个操作

操作LayerNormRMSNorm
减均值(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-50.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]])

  1. 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的内部转换,整个流程是:

  1. 输入
  2. RMSNorm
  3. Attention
  4. 残差连接
  5. RMSNorm
  6. FFN / SwiGLu
  7. 残差连接
  8. 输出

所以我理解是在输入之后 和 Attention输出之后分别进行了一次 RMSNorm

  1. 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-NormPost-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-NormMiniMind 用 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-NormPost-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 是怎么巧妙解决这个问题的。

回答:我认为至少有两个核心问题,一个是长度外推差,即超过训练长度就失效。另一个是位置的偏移会造成理解的不同,即使语义是相同的,比如说“猫吃鱼”这句话无论出现在开始、中间还是结尾,它的意义并没有发生变化,但绝对位置却差了很多

非常好!✅✅ 你说得比我预期的还要深刻!

你提到的两个问题完全正确:

  1. 长度外推差 — 超过训练长度就失效
  2. 绝对位置忽略了相对关系 — “猫吃鱼”无论出现在哪里,三个 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θ,相同

另外我想确认下:

  1. RoPE是通过 Token 旋转跟 绝对位置 相关的角度,得到一个编码的位置值,这个值是和绝对位置相关的
  2. 两个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 维?

提示:不需要想得太复杂,想想最简单的办法就好。

  1. 你先解释下为什么旋转公式只能处理2维
  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
函数 1precompute_freqs_cis → 预计算所有位置的 cos/sin
函数 2apply_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]]