首页
应用
关于
Search
1
Pytorch DDP
2,489 阅读
2
Pytorch 常见问题
1,520 阅读
3
视频时序切分
1,358 阅读
4
Semi-Supervised + Noisy Label
1,055 阅读
5
中文场景下的CLIP图文预训练
1,054 阅读
AIGC
AIGC Daily Papers
AIGC Fundamentals
其他
职场经验复盘
多模态理解
购房/投资
阅读
广告
Segmentation
LeetCode
Pytorch
Python
Shell
C++
常用链接
Search
标签搜索
AIGC
论文速读
人工智能
ai
视频生成
DiT
对齐
蒸馏
图像生成
扩散模型
attention
transformer
视频编辑
3D生成
音频生成
稀疏注意力
diffusion
基础知识
多模态
文生图
Jefxiong
累计撰写
225
篇文章
累计收到
8
条评论
首页
应用
栏目
AIGC
AIGC Daily Papers
AIGC Fundamentals
其他
职场经验复盘
多模态理解
购房/投资
阅读
广告
Segmentation
LeetCode
Pytorch
Python
Shell
C++
常用链接
页面
关于
搜索到
38
篇与
AIGC Fundamentals
的结果
2026-08-23
AIGC 基本功|视频生成中的强化学习与奖励模型-VideoRL
视频生成中的强化学习与奖励模型 所属方向:对齐与强化学习 | 难度:前沿专题 | 前置知识:DiffusionRL、FlowMatching 关键词:视频强化学习、奖励模型、Flow-GRPO、时序一致性奖励、人类偏好、reward hacking 01. 为什么需要它 先看一个用于说明奖励投机的假设场景;下面的“两千步”不是本文实际运行的视频实验记录。 你有一个文生视频模型,想用 RL 把它对齐到人类偏好。你手上有个打分器,在图像上验证过效果不错——清晰度、细节、构图都能打分。你把它接上视频,跑 GRPO,训了两千步。奖励曲线漂亮地往上走。 然后你去看生成结果:画面几乎不动了。 人物站在原地,衣角不飘,背景纹理纹丝不动。prompt 明明写着 "a person walking across the street",模型给你一张会呼吸的照片。 这不是 bug,是模型算得比你清楚。静止画面有一系列白拿的好处: 形状永远不会崩——因为它不变 帧间没有闪烁——因为帧之间完全一样 细节可以做到极精细——不用分算力去渲染运动模糊 而它放弃的只有「动态性」这一项。如果静止带来的加分超过动态性与指令遵循等全部损失,那么静止可能比运动拿到更高奖励,RL 会毫不犹豫地找到它。 视频奖励增加了运动与时序一致性维度。在有限模型能力下,运动幅度与形变、闪烁等伪影可能存在经验权衡;这不是“运动必然降低画质”的物理定律。图像的清晰度、构图、语义也可能冲突,不能假设图像奖励天然相容。 后面几节会把这笔账算成具体数字——用 VisionReward 真实发布的 29 个权重。 02. 最小可用理解 三句话: 采一组。对同一个 prompt 采样 $G$ 条视频(比如 8 条),用奖励模型给每条打一个分。 组内比。把这组分数减去组内均值、除以组内标准差,得到每条的优势(advantage)——比同组平均好就是正,差就是负。 按优势推。优势为正的往上推概率,为负的往下压。不需要训练价值网络(critic),组内均值就当基线用了。 这概括了 GRPO 的组内优势机制;完整目标还包含后文的概率比、裁剪和可选 KL。它省掉 critic 的代价是必须一次采一组,好处是少训一个网络、少一堆调不动的超参。 如果只记一件事:RL 不会给你「更好的视频」,只会给你「奖励更高的视频」。这两者的差距全部藏在奖励模型里。本文一半篇幅在讲奖励模型,不是跑题。 03. 数学推导 3.1 从策略梯度到组内基线 目标很直白:最大化生成样本的期望奖励。 $$J(\theta) = \mathbb{E}_{x \sim \pi_\theta} [r(x)]$$ 其中 $\pi_\theta$ 是生成模型(策略),$x$ 是一条采样出来的视频,$r(x)$ 是奖励模型给的分。对参数求梯度,用对数导数技巧: $$\nabla_\theta J = \mathbb{E}_{x \sim \pi_\theta} [r(x) \nabla_\theta \log \pi_\theta(x)]$$ 这个式子能用,但方差大得没法训。原因是 $r(x)$ 的绝对值直接乘在梯度上:假如所有样本的奖励都在 8.0 到 8.2 之间,那么每个样本都在说「往我这边推」,只是力度略有差别。真正有用的信息是「谁比谁好」,而不是「绝对分多高」。 标准解法是减一个基线 $b$: $$\nabla_\theta J = \mathbb{E}[(r(x) - b) \nabla_\theta \log \pi_\theta(x)]$$ 只要 $b$ 与 $x$ 无关,这个替换是无偏的(因为 $\mathbb{E}[b \nabla_\theta \log \pi_\theta] = b \nabla_\theta \mathbb{E}[1] = 0$)。PPO 那一路用一个学出来的价值网络当 $b$,GRPO 的选择更省事——同一个 prompt 采一组,用组内均值当基线: $$A_i = \frac{r_i - \mathrm{mean}(r_1, \dots, r_G)}{\mathrm{std}(r_1, \dots, r_G)+\varepsilon}$$ 这里不能直接套用上面的无偏证明:组均值含有 $r_i$,依赖当前样本。固定 prompt、独立同分布采样且不除标准差时, $$E[(r_i-\bar r)\nabla\log\pi_\theta(x_i)]=(1-1/G)\nabla J.$$ 使用不含自身的 leave-one-out 均值可消除该缩放;除以随机组标准差还会改变样本及 prompt 的相对权重。$\varepsilon>0$ 防止同分组除零,同分组的奖励优势为零。标准化是有用的工程选择,不能据此宣称完整 GRPO 无偏;奖励均值高本身也不等于期望梯度大。 这张图要看什么:左图是同一个 prompt 采出的 8 条视频的奖励,全挤在 8.0 到 8.2 之间——如果直接把 $r_i$ 乘在梯度上,8 条样本会一起喊「往我这边推」,有用的信息全被绝对值淹没(箭头方向完全一致)。右图减掉组内均值、除以组内标准差之后,同样的 8 个数字变成了有正有负的优势:绿的往上推、红的往下压。这就是 GRPO 不用 critic 也能训的原因——基线是从同组样本里白捡的(图由 code/make_figures.py 生成,下同)。 GRPO 出自 DeepSeekMath(arXiv:2402.03300),原本是给语言模型的数学推理用的,后来被搬到视觉生成上。 3.1.1 完整目标里还有两个护栏 上面那个式子是骨架。真实的 GRPO 目标还包着两层保护,后面 04 节的最小实现会把它们省掉,这里先说清省的是什么。 第一层是重要性采样比的截断(沿用 PPO 的做法): $$\mathcal{L} = -\mathbb{E}\left[ \min\left( \rho_i A_i, \ \mathrm{clip}(\rho_i, 1-\epsilon, 1+\epsilon) A_i \right) \right]$$ 式子里的 $\rho_i$ 是新旧策略的概率比,$\rho_i = \pi_\theta(x_i) / \pi_{\theta_{\text{old}}}(x_i)$。采样用的是旧策略 $\pi_{\theta_{\text{old}}}$,更新的是新策略 $\pi_\theta$,$\rho_i$ 修正这个偏差。clip 截断的是朝有利方向继续外推的收益,不保证每个 ratio 都在区间内,也不是 KL 的硬约束。神经网络共享参数,仍可能越界;需结合 KL 和梯度日志监控。视觉去噪实现通常按每个随机转移计算 ratio,不能把这里的整段视频记号误当成可直接算出的边缘密度。 第二层是对参考模型的 KL 惩罚: $$\mathcal{L}_{\text{total}} = \mathcal{L} + \beta \, \mathrm{KL}\left[ \pi_\theta \, \| \, \pi_{\text{ref}} \right]$$ $\pi_{\text{ref}}$ 通常是 RL 开始前的初始模型。这一项把策略拴在原始分布附近,是对付 reward hacking 最基础的护栏——因为大多数 hack(比如 01 节那个静止画面)都要求策略跑到离原始分布很远的地方去。$\beta$ 调的就是「允许它跑多远」。 代价是 $\beta$ 很难调:太大则学不动,太小则护栏形同虚设,而合适的值随任务和奖励模型变化。这也是为什么 04 节要先把护栏拆掉看清裸的优化行为——理解 hacking 怎么发生,比直接套护栏更重要。 3.2 flow matching:怎样得到随机转移的似然 Flow matching 的 ODE 为 $dx_t/dt=v_\theta(x_t,t)$。本文用 $t=1$ 表示噪声、$t=0$ 表示数据:给定初始噪声,逐步转移是确定性的条件 delta,不能直接代入高斯转移 log-prob 做 PPO/GRPO。 这不等于输出没有概率分布。随机初值经正则、可逆的 ODE 流仍可产生具有边缘密度的输出,CNF 可用散度积分计算密度;可微奖励还可以沿 ODE 用路径导数优化,确定性策略梯度也有自己的适用条件。受限的是本文这条“随机逐步转移似然 + score function”的路线。 Flow-GRPO 第 4.2 节 为它构造反向时间 SDE: $$dx_t=\left[v_\theta(x_t,t)-\frac{\sigma_t^2}{2}\nabla\log p_t(x_t)\right]dt+\sigma_t\,d\bar W_t,\qquad dt<0.$$ 由于时间从 1 向 0 走,score 修正前是减号;离散噪声标准差为 $\sigma_t\sqrt{-dt}$。若改用从噪声出发递增的时间坐标,漂移和 score 符号必须一起变换。准确 score、匹配的速度场与连续时间求解条件下,SDE 和 ODE 共享边缘分布;近似网络、有限步长及 RL 更新会带来偏差,不能保证实现后精确不变。 这使被训练的非退化转移具有可计算的高斯 log-prob。终步若噪声为零,仍要排除或另行处理,不能对零方差求对数密度。Flow-GRPO 还用 Denoising Reduction 减少训练采样步数、保留完整推理步数;这是论文所测任务上的经验结果,需在视频任务重新验证。 3.3 奖励从哪来:把打分变成一串是非题 现在缺的是 $r(x)$。视频的「好」是多维的,而人类偏好数据通常只有「A 比 B 好」这种成对比较,信息量很稀疏。 VisionReward(arXiv:2412.21059)的做法是把打分拆成一串可解释的是非题,再线性加权: $$r(x) = \frac{1}{K} \sum_{k=1}^{K} w_k \cdot a_k(x), \quad a_k(x) \in \{+1, -1\}$$ 其中 $a_k$ 是一个视觉语言模型对第 $k$ 个问题的回答(yes 记 $+1$,no 记 $-1$),$w_k$ 是该问题的权重。视频版一共 $K = 29$ 个问题,从「是否满足 prompt 的全部要求」到「细节是否精细」。 这个设计有两个好处。可解释:分低的时候能看出是哪一维拖的,不是一个黑盒数字。可控:想让模型更重视某一维,直接改 $w_k$ 就行。 代价是它把「好」压成了一个线性组合。而线性组合最容易被套利——下一节就用它真实发布的权重,把套利空间算出来。 04. 代码实现 4.1 先把奖励函数照抄一遍 VisionReward 的打分逻辑短到可以完整贴出来。这是它仓库里 inference-video.py 的核心两行(2026-08 的 main 分支): answers = np.array([1 if answer == 'yes' else -1 for answer in answers]) return np.mean(answers * weight).item() 有个细节值得停一下:no 记的是 $-1$,不是 $0$。这让答错变成实打实的扣分,而不只是「没拿到加分」。同一个维度上,yes 和 no 之间差了 $2 w_k$ 而不是 $w_k$——算套利空间时这个因子 2 不能漏。 29 个权重是仓库里 VisionReward_Video/weight.json 直接给出的。先看看它们长什么样(code/reward_hacking.py): 29 个维度,权重总和 6.2772,均值 0.2165 最高 1.1418(指令-未完全失败) 最低 0.0085(细节-不粗糙) 最高/最低 = 134 倍 指令遵循三档合计 2.3486,占总权重 37.4% 构图+色彩合计 0.0924,占总权重 1.5% 两个数字值得盯着看。最高和最低差 134 倍——这不是一个「各维度都重要」的均衡奖励,而是有着强烈倾向的。指令遵循三档占了 37.4%,构图加色彩只占 1.5%:在真实的人类偏好数据里,指令三档的权重合计是构图与色彩权重合计的约 25 倍。但这不是人类把指令遵循看得比整体美感重 25 倍的因果结论:其他维度也反映画质,且题目相关性、回答频率与权重尺度会影响解释。 这个结构反直觉但合理——观众要的是他要的东西,不是一张漂亮但无关的图。 这张图要看什么:左图把 29 个权重从低到高排开——最高的「指令-未完全失败」1.1418,最低的「细节-不粗糙」0.0085,相差 134 倍(红色是指令遵循三档,灰色是构图加色彩)。右图按语义归堆后更刺眼:指令遵循合计 2.3486、一家吃掉 37.4%,构图加色彩只有 0.0924、占 1.5%。指令三档的权重合计是构图与色彩这两组的二十多倍——记住这个比例,4.2 和 4.4 节的套利与翻转全是从它推出来的。 4.2 静态套利的账 回到 01 节那个静止画面。它能白拿哪些分、要放弃哪些分,现在可以精确算: 静止能白拿:全程形状保持+不混乱+画质稳定 = 0.7166 静止要放弃:镜头高度动态+不微弱 = 0.1634 净收益 +0.5532 (yes/no 相差 2 个单位,实际影响 ×2 = +1.1064) 上述 +1.1064 是未除以 $K$ 的加权和变化;score() 实际变化是 $1.1064/29\approx0.03815$。权重总和 6.2772 对应分数上限 0.2165,分数全范围宽度约 0.4329,因此这笔差额约占全范围 8.8%。这只是所选维度的局部账,不能省略指令等其他维度后宣称静止全局最优。 那它会不会因为违背 prompt 而被罚回来?我构造了两个视频画像对比(prompt 要求「人物走过街道」): 视频 A(几乎静止的精致画面,没照做 prompt) +0.1220 视频 B(照做了 prompt,但有形变和抖动) +0.1144 奖励模型更偏好:A(静态) 差值 0.0076 静态那个赢了。 拆开看差异来自哪: -1.9088 指令-全部满足 +0.8586 细节-非常精细 +0.5372 画质-非常稳定 +0.5272 全程形状-不混乱 -0.5048 指令-大部分满足 +0.3688 全程形状-完美保持 静态视频在指令遵循上亏了 2.41(这是权重最高的维度!),但靠画质那几项一点点堆回来,最后反超 0.0076。 需要说清楚:这两个 yes/no 画像是我按各维度语义人工假设的,不是真实模型的输出,所以这个结论说明的是「权重结构允许这样的套利」,不是「VisionReward 实际会这么判」。差值只有 0.0076 也说明它非常接近临界——是否会被优化器实际找到,还取决于策略可达性、采样噪声与正则约束,不能由这一对假设画像推断必然收敛。 4.3 让 GRPO 自己去找套利 手工构造画像终究不如让算法自己找。下面是完整的单参数 GRPO(code/grpo_minimal.py),策略只有一个参数:运动强度 $m$。 维度压到 1 维是刻意的——才能看清 GRPO 究竟把策略推向哪里。玩具概率按人为假设设定:$m$ 越大,指令遵循和动态性越容易拿 yes,形状保持、画质稳定、细节精细越容易拿 no。 def grpo(steps=400, group=32, lr=0.35, sigma=0.15, theta0=0.0, seed=0, weight=None): rng = np.random.default_rng(seed) theta = theta0 for step in range(steps): # 策略:m = clip(sigmoid(theta) + noise),高斯探索 mu = 1 / (1 + np.exp(-theta)) eps = rng.normal(0, sigma, group) ms = np.clip(mu + eps, 0.0, 1.0) rs = np.array([reward(m, rng, weight) for m in ms]) # 组内相对优势:减均值除标准差(3.1 节那个式子) adv = (rs - rs.mean()) / (rs.std() + 1e-8) # ∂mu/∂theta = mu(1-mu) grad = float(np.mean(adv * eps / sigma ** 2) * mu * (1 - mu)) theta += lr * grad return 1 / (1 + np.exp(-theta)) m_final = grpo() print(f"GRPO 收敛到 m = {m_final:.4f}") # GRPO 收敛到 m = 0.9657 十几行,没有 critic,也刻意去掉了 3.1.1 节那两层护栏(clip 和 KL)——保留的是组标准化 REINFORCE 示意,不是完整 PPO/GRPO 更新。代码中的高斯 score 对应裁剪前潜在动作 $a=\mu+\epsilon$,环境再执行 $m=\mathrm{clip}(a,0,1)$;不能把它当成裁剪后混合分布的普通高斯 log-prob。用完整的 29 个权重跑: step 运动强度 m 组内平均奖励 0 0.5000 -0.0238 80 0.9227 +0.0395 160 0.9420 +0.0346 320 0.9649 +0.0452 399 0.9660 +0.0370 GRPO 收敛到 m = 0.9657 → 策略选择:动态(照做了 prompt) 没有 hack。 策略从中性的 0.5 一路推到 0.966,选择了照做 prompt。换三个不同初值(0.27 / 0.50 / 0.73)出发,都收敛到 0.977 附近,说明这不是初值凑巧。 原因就是 4.1 节那个 37.4%:指令遵循的权重足够大,压住了画质维度的全部诱惑。这是玩具里的结果,不能验证 Flow-GRPO 的视频表现。Flow-GRPO 在其图像实验中他们报告「very little reward hacking occurred」,奖励涨上去了而画质和多样性没有明显退化。 4.4 关键对照:把指令遵循拿掉 那 01 节那个失败场景怎么发生的?答案在奖励模型上,不在算法上。 01 节的设定是「手上有个在图像上验证过的打分器」——图像打分器很可能根本不看时序,或者对指令遵循的权重远没有这么高。把三个指令遵循维度的权重置零,模拟这种情况,同一个 GRPO 再跑一遍: step 运动强度 m 组内平均奖励 0 0.5000 -0.0016 80 0.0305 +0.0269 160 0.0178 +0.0363 320 0.0099 +0.0396 399 0.0084 +0.0324 收敛到 m = 0.0084(完整权重下是 0.9657) 从 0.966 翻转到 0.008。 策略选择了几乎完全静止。注意中间那列奖励——它一路从 $-0.0016$ 涨到 $+0.04$,总体上也比初始值高,但逐步有明显随机波动。这就是 01 节说的:奖励曲线不能告诉你模型学到了什么。 奖励曲面看得更清楚——同一组物理约束,两种权重下的形状完全相反: m 完整权重 去掉指令遵循 0.0 -0.0393 +0.0393 0.3 -0.0298 +0.0228 0.5 +0.0006 -0.0006 0.8 +0.0446 -0.0322 1.0 +0.0379 -0.0394 → 完整权重的最高点在 m = 0.8(+0.0446) → 去掉指令遵循后最高点在 m = 0.0(+0.0393) 完整权重下曲面在 $m = 0.8$ 处见顶;去掉指令遵循后,在这一玩具设定下整体偏向低运动强度;表格是每点 4000 次随机采样的估计,不能仅凭稀疏网格确立精确单调性或全局最优。 这张图要看什么:左图是同一组物理约束下的奖励曲面——完整权重(蓝实线)在 $m=0.8$ 附近见顶,去掉指令遵循三档后(红虚线)整条曲线翻转成单调递减,最高点直接落到 $m=0$。右图是同一个 GRPO、同一个初值跑 400 步:蓝线冲到 0.966(照做 prompt),红线掉到 0.008(躺平)。而两条浅色细线是各自的组内平均奖励——二者总体奖励改善,但都有采样噪声,不能靠奖励曲线判断是否符合需求。奖励曲线不能告诉你模型学到了什么。 结论落在这里:reward hacking 不是算法出了毛病,而是它精确地最大化了你真正写下来的那个目标。 同一个 GRPO、同一个物理约束,只改奖励的权重结构,行为就翻转了。查 hacking 该去查奖励函数,而不是调 RL 的超参。 还要区分两种目标:图中的曲面是在固定运动强度 $m$ 上的采样均值,策略优化的却是高斯探索后再裁剪的期望奖励。$\mu=0.966$ 是裁剪前策略均值参数,不是实际运动强度的期望;它与网格中 $m=0.8$ 的峰值不能直接比较。因此这些数字不能证明“策略梯度过冲”,更不能归因为探索噪声必然造成方向性偏置。 05. 工业级实现对照 参考实现(以 2026-08 的 main 分支为准,上游会重构): THUDM/VisionReward → inference-video.py:score、compare_two_videos THUDM/VisionReward → VisionReward_Video/weight.json:29 个线性权重 上面的最小实现把奖励当成一个现成函数,生产上它是个 VLM,差别都在这。 5.1 打分要跑 29 次前向 score() 的实际流程是:抽帧、对每个问题拼一次 prompt、让 VLM 输出 yes/no。29 个问题就是 29 次 VLM 前向。 这直接决定了 RL 训练的成本结构。GRPO 每步要采一组(比如 8 条视频),每条视频要 29 次 VLM 前向,也就是每个训练步 232 次前向——而这还没算生成那 8 条视频本身的去噪开销。奖励计算不是附带开销,它经常是训练循环里最贵的一环。这也是 Flow-GRPO 要做 Denoising Reduction 的现实压力来源。 5.2 两套问题清单,别拿错 仓库里有两个文件长得很像: VisionReward_video_qa.txt:64 个问题,用于细粒度查询(问模型某一项如何) VisionReward_video_qa_select.txt:29 个问题,用于打分 inference-video.py 顶部读的是 _select 那个: QUESTIONS_PATH = "VisionReward_Video/VisionReward_video_qa_select.txt" WEIGHT_PATH = "VisionReward_Video/weight.json" 29 条问题对应 29 个权重,一一对齐。拿 64 个那份去乘权重会直接维度不匹配——这算好事,至少会报错。 5.3 比较两个视频不是比分数 想判断 A 和 B 哪个好,直觉是各算一次 score() 再比大小。它没这么做——compare_two_videos() 的结尾是: answers1 = np.array([1 if answer == 'yes' else -1 for answer in answers1]) answers2 = np.array([1 if answer == 'yes' else -1 for answer in answers2]) diff = answers1 - answers2 return np.sum(diff * weight).item() > 0 先求两个答案向量的逐维差 diff,再加权求和判正负。 只要两次打分使用同一组答案与权重,这与比较两个 score() 完全等价(仅差正的常数 $K$),并不能阻止不同维度相互抵消。VisionReward 论文的“多维一致性”另指偏好优化时筛选在各语义维度都一致占优的样本对;不是把减法移到加权和里面。 5.4 那些数字:这套设计到底有多少提升 VisionReward 报告的两个结果: 偏好预测准确率比 VideoScore 高 17.2% 用它做奖励的文生视频模型,pairwise win rate 比用 VideoScore 高 31.6% 这两个百分比对应不同指标与实验,不能相除推断“奖励改进被 RL 放大了”。VisionReward 的偏好优化还包括 DPO 等流程;本文引用的是论文报告结果,不是视频 GRPO 的对照实测。 06. 代价与边界 杠杆是双向的。 持续优化可能放大奖励偏差,但幅度不是由 5.4 节的两个异质指标决定的。奖励模型里一个不起眼的偏见——比如偏爱暖色调、偏爱中心构图——在评测里可能只是一两个百分点,但 RL 会把它当成目标,训几千步之后就是全部输出都发黄。奖励模型的偏见不会被平均掉,会被放大。 优化的是奖励,不是质量。 这句话看着像废话,但它有个实践推论:奖励曲线上升不能作为训练成功的证据。4.4 节那次 hack 的奖励曲线($-0.0016 \to +0.04$)和 4.3 节那次成功训练长得一模一样。 那怎么才能发现?三个手段,按性价比排: 盯住奖励模型看不见的指标。 挑几个不参与奖励计算的量化指标定期记录,比如帧间光流的平均幅度、逐帧 LPIPS 差异。静态 hack 在这类指标上一眼就露馅(光流幅度趋近 0),而奖励模型完全不看它们。这个办法便宜、能自动化,应该默认开着。 看奖励的维度分解,不只看总分。 VisionReward 这种线性结构的好处正在这里——把 29 维的得分分别记下来。总分涨、但某几维在持续下跌,就是套利正在发生。4.4 节第 5 段那张表就是这么读的。 固定一批 prompt 定期人眼看。 最贵但不可替代。前两个手段只能发现你预料到的失效模式,人眼能发现你没预料到的。 至于用第二个奖励模型交叉验证——听起来对称,实际有个陷阱:如果两个奖励模型是用同源的偏好数据训的,它们很可能共享同样的盲区,交叉验证会一起点头。要用就得选训练数据和架构都不同的。 成本很实在。 按 5.1 节的账,每个训练步是「$G$ 条视频的完整采样+ $29G$ 次 VLM 前向」。视频采样本身就比图像贵一个量级(多了时间维度),再乘上组大小,这是 RL 在视频上落地慢的主要原因。Denoising Reduction 这类工程手段不是优化项,是可行性前提。 线性加权是双刃剑。 它带来可解释性,也带来可套利性——线性函数没有交互项,意味着「这一维差到极点」不会拖累其他维度的加分。真实的人类判断不是这样的:一个完全静止的「视频」在人眼里直接不合格,不管它多精细。线性模型表达不了这种否决关系,所以 04 节那个套利在数学上成立。 什么时候不该用 RL。 如果你的问题能用更直接的手段解决,就别上 RL。想让输出更清晰——去修数据和 VAE;想让它听懂 prompt——先确认是不是文本编码器或者标注质量的问题。RL 适合的是「说得清好坏、但写不出损失函数」的目标,比如整体美感、运动自然度这类只能靠比较来表达的偏好。上 RL 之前先问一句:我的奖励模型真的比我的损失函数更懂这件事吗? 07. 经典论文脉络 这条线分两支:怎么优化和拿什么当奖励。两支交替推进。 奖励这一支: ImageReward(arXiv:2304.05977,2023-04)第一个规模化的文生图人类偏好模型,确立了「收集成对偏好数据训一个打分器」这个范式。它是个黑盒标量,可解释性问题从这里就留下了。 VideoScore(arXiv:2406.15252,2024-06)把细粒度人类反馈搬到视频上,成为视频奖励模型的常用基线——也就是 VisionReward 那两个数字(17.2% / 31.6%)超越的对象。 VisionReward(arXiv:2412.21059,2024-12)用分层是非题 + 线性加权换来可解释性,图像和视频统一处理。代价是线性结构本身可被套利(06 节)。 优化这一支: DDPO(arXiv:2305.13301,2023-05)把去噪过程当成多步决策,第一次让 policy gradient 在扩散模型上跑通。 DPOK(arXiv:2305.16381,2023-05)同期工作,补上了 KL 正则——防止策略为了刷奖励跑离原始分布太远。这是对付 hacking 最基本的护栏。 Diffusion-DPO(arXiv:2311.12908,2023-11)绕开 RL:直接用成对偏好数据做监督式优化,省掉在线采样。简单稳定,代价是只能利用已有的偏好数据,没有探索。 GRPO(arXiv:2402.03300,2024-02)出自 DeepSeekMath,本是给语言模型数学推理用的。用组内相对优势替掉 critic,把 RLHF 的工程复杂度砍掉一大块。 Flow-GRPO(arXiv:2505.05470,2025-05)第一个把在线 policy gradient 接进 flow matching 的工作,靠 ODE→SDE 转换取得随机逐步转移的可计算似然(3.2 节)。在文生图上把 GenEval 从 63% 拉到 95%、视觉文字渲染从 59% 到 92%。 需要说明:Flow-GRPO 的实验是在文生图上做的(SD3.5-M),不是视频。它之所以是视频 RL 的关键前置,是因为主流视频模型也是 flow matching 训的,ODE→SDE 那套推导可以直接搬过来——但视频上的实证还远没有图像充分。 08. 常见误解 「奖励涨了就是训好了」。 01 节整节都在讲这件事。奖励是你自己写的目标函数,模型最大化它是本分。奖励上升唯一证明的是优化在工作,不是效果在变好。 「reward hacking 是 RL 算法的问题,换个算法就好了」。04.4 节那个对照实验就是为了拆掉这个想法:同一个 GRPO,只改奖励权重,行为从 $m = 0.966$ 翻到 $m = 0.008$。算法只是执行者。出了 hacking 该去审奖励函数。 「GRPO 比 PPO 好」。 它主要是更省——不用训 critic,少一个网络和一堆超参。省掉 critic 的代价是必须一次采一组样本才能估出基线,采样开销更大。在视频这种采样极贵的场景里,这个权衡并不是无脑赚。 「多加几个奖励维度就能防 hacking」。 加维度确实提高了套利难度,但只要还是线性加权,套利空间就存在——4.2 节那个例子里有 29 个维度,静态依然套出了 1.1 的净收益。真正起作用的是权重结构(指令遵循占 37.4% 才压住了它),以及 DPOK 那种把策略拴在原始分布附近的 KL 护栏。 「flow matching 模型可以直接套用扩散模型的 RL 方法」。按随机转移似然做 PPO/GRPO 时,需要先定义非退化随机转移,例如 3.2 节的 ODE→SDE 路线。确定性 ODE 的输出边缘密度、可微奖励路径导数等另有方法,并不是所有 RL 都必须给每一步加噪。 09. 动手验证 三个实验,用到的脚本全文都在文末附录,纯 NumPy,没有 GPU 依赖,复制存成同名文件就能跑。 实验一:给自己的奖励函数算套利空间。 跑 reward_hacking.py,看第 2 段那笔账。然后把你自己项目里奖励各维度的权重填进 DIMS,重算一遍「静止能白拿 / 要放弃」的净收益。预期结论是:只要净收益为正,你的模型早晚会发现它。 实验二:找出翻转的临界点。 跑 grpo_minimal.py,把指令遵循的三个权重乘一个系数 $c$(改 weight_without_instruction 为按比例缩放),从 $c = 1.0$ 逐步降到 $0$,看收敛的 $m$ 在哪一步从 0.97 掉到 0.01。预期是个比较陡的转折而不是平滑过渡——这意味着奖励权重的微小改动可能导致行为质变,调权重时值得扫一遍而不是只试一个值。 实验三:验证组大小对方差的影响。 还是 grpo_minimal.py,把 group 从 32 改成 4、8、64,各跑三个不同 seed。预期看到组越小、收敛点越不稳定——因为组内均值和标准差是用这几个样本估的,样本太少基线就不可靠。还要一起权衡采样成本、同分组概率、奖励稀疏度和并行吞吐。 本文三张配图由 make_figures.py 生成(这个需要额外装 pip install matplotlib,其余脚本只要 NumPy)。想复现就把权重换成你自己的:左边的权重账本换成你的 29 维,右边的曲面和 GRPO 轨迹会直接告诉你你的奖励函数在纵容哪种躺平。 10. 延伸阅读 这篇在知识树里是 L4(前沿专题),前面还有三级台阶。如果 03 节读起来吃力,按下面顺序补更省时间: 策略梯度与 PPO 基础——3.1 节那个对数导数技巧、为什么要减基线、clip 是干什么的,都属于那一篇 从 DPO 到 GRPO:去掉价值网络——GRPO 本身的完整讨论在那篇。本文只用到它「组内相对优势」这一个机制,没有展开和 PPO、DPO 的对比 把 RL 用到扩散模型上——DDPO 那套「把去噪当多步决策」的建模 流匹配与 Rectified Flow——3.2 节 ODE→SDE 转换的前提 读完这篇可以继续看: 人类偏好数据怎么收(还没写)——4.1 节那 29 个权重是怎么从成对比较数据里拟合出来的 视频质量评测指标(还没写)——06 节说的那三个检测手段该怎么落实,尤其是「奖励模型看不见的指标」具体该记哪些 附录:完整代码 09 节用到的脚本全文如下(make_figures.py、reward_hacking.py、grpo_minimal.py)。复制到本地存成同名文件,按各脚本开头的依赖说明准备环境后即可运行。 make_figures.py #!/usr/bin/env python3 # -*- coding: utf-8 -*- """生成「视频生成中的强化学习与奖励模型」的三张解释图。 数值一律从同目录两个脚本里取(reward_hacking 的 29 个真实权重、 grpo_minimal 的奖励曲面与 GRPO 轨迹),这里只负责画——那边改了这里要重跑, 免得图和正文数字打架。 三张图分别回答: 1. 29 个奖励权重的账本长什么样(为什么「有没有照做」压过「好不好看」) 2. 组内优势到底把什么信息抽出来了(原始奖励看着都一样,标准化后才有正负) 3. 只改奖励权重、不动算法,GRPO 为什么会从「照做」翻转成「躺平」 只依赖 numpy + matplotlib。跑法:python make_figures.py """ import textwrap from pathlib import Path import matplotlib.pyplot as plt import numpy as np import grpo_minimal as G from reward_hacking import NAMES, WEIGHT ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "figures" OUT.mkdir(parents=True, exist_ok=True) plt.rcParams.update({ "font.sans-serif": ["PingFang SC", "Arial Unicode MS", "DejaVu Sans"], "axes.unicode_minus": False, "figure.dpi": 160, "savefig.bbox": "tight", }) INK = "#1f2937" MUTE = "#6b7280" C_INSTR = "#d1495b" # 指令遵循:红 C_OTHER = "#2f6fb0" # 其余维度:蓝 C_AES = "#c9ced6" # 构图/色彩:灰 C_FULL = "#2f6fb0" # 完整权重 C_NOIN = "#d1495b" # 去掉指令遵循 C_POS = "#2f9e6f" # 优势为正 C_NEG = "#d1495b" # 优势为负 # 维度分组(下标对应 reward_hacking.DIMS 的顺序) GROUPS = [ ("指令遵循", [0, 1, 2], C_INSTR), ("构图+色彩", [3, 4, 6], C_AES), ("光照", [7, 8, 9, 10, 11, 12], C_OTHER), ("形状保持", [13, 14, 15, 16, 17], C_OTHER), ("运动", [5, 18, 19, 20, 21], C_OTHER), ("画质+细节", [22, 23, 24, 25], C_OTHER), ("文字+内容", [26, 27, 28], C_OTHER), ] def style(ax, title, xlabel=None, ylabel=None): ax.set_title(title, fontsize=11.5, color=INK, pad=10, loc="left") if xlabel: ax.set_xlabel(xlabel, fontsize=10, color=MUTE) if ylabel: ax.set_ylabel(ylabel, fontsize=10, color=MUTE) ax.tick_params(colors=MUTE, labelsize=9) for s in ("top", "right"): ax.spines[s].set_visible(False) for s in ("left", "bottom"): ax.spines[s].set_color("#d1d5db") ax.grid(alpha=0.25, linewidth=0.6, axis="y") ax.set_axisbelow(True) def footer(fig, text, width=118): """把「这张图要看什么」放到坐标轴下方。 必须放在 y<0:bbox_inches="tight" 会把负坐标的 artist 一起收进来, 放在 0~0.05 之间会和 x 轴标签叠在一起。 """ wrapped = "\n".join(textwrap.wrap(text, width=width)) fig.text(0.012, -0.13, wrapped, fontsize=8.5, color=MUTE, va="top", ha="left", linespacing=1.6) # ────────────────────────────────────────────────────────────────────── # 图 1:29 个奖励权重的账本 # ────────────────────────────────────────────────────────────────────── def fig_weight_ledger(): order = np.argsort(WEIGHT) # 从小到大,画出来是升序 colors = [C_INSTR if i in (0, 1, 2) else C_AES if i in (3, 4, 6) else C_OTHER for i in order] labels = [NAMES[i] for i in order] fig, axes = plt.subplots(1, 2, figsize=(12.6, 6.4), gridspec_kw={"width_ratios": [1.45, 1]}) fig.subplots_adjust(wspace=0.34) ax = axes[0] ax.barh(np.arange(29), WEIGHT[order], color=colors, height=0.72) ax.set_yticks(np.arange(29)) ax.set_yticklabels(labels, fontsize=7.6) ax.set_xlim(0, WEIGHT.max() * 1.18) ax.tick_params(axis="y", length=0) style(ax, "29 个维度的权重(升序)", "权重 $w_k$", None) # 最高 / 最低标注 ax.text(WEIGHT[order][-1] + 0.02, 28, f"{WEIGHT.max():.4f} 最高", fontsize=8.5, color=INK, va="center") ax.text(WEIGHT[order][0] + 0.02, 0, f"{WEIGHT.min():.4f} 最低", fontsize=8.5, color=MUTE, va="center") # 图例:红=指令遵循,灰=构图色彩,蓝=其余 from matplotlib.patches import Patch ax.legend(handles=[ Patch(color=C_INSTR, label="指令遵循(3 维)"), Patch(color=C_AES, label="构图+色彩(3 维)"), Patch(color=C_OTHER, label="其余(23 维)"), ], fontsize=8.2, frameon=False, loc="lower right") ax = axes[1] gcolor = {name: c for name, _, c in GROUPS} gs = [(name, float(WEIGHT[idx].sum())) for name, idx, _ in GROUPS] gs.sort(key=lambda t: -t[1]) total = WEIGHT.sum() ys = np.arange(len(gs)) # 颜色必须按排序后的名字取:按 GROUPS 顺序 zip 会跟排序后的柱子错位 ax.barh(ys, [v for _, v in gs], color=[gcolor[n] for n, _ in gs], height=0.7) ax.set_yticks(ys) ax.set_yticklabels([n for n, _ in gs], fontsize=9) ax.tick_params(axis="y", length=0) for i, (_, v) in enumerate(gs): ax.text(v + 0.03, i, f"{v:.3f} ({v / total * 100:.1f}%)", fontsize=8.2, color=INK, va="center") ax.set_xlim(0, max(v for _, v in gs) * 1.45) style(ax, "按语义归堆:谁在主导总分", "权重合计", None) fig.suptitle("图 1 VisionReward 的 29 个权重不是均衡的:" "指令遵循一家占了 37.4%,构图加色彩只有 1.5%", fontsize=12, color=INK, x=0.012, ha="left", y=1.02) footer(fig, "要看什么:左图是 29 个权重从低到高排开——最高的「指令-未完全失败」1.1418," "最低的「细节-不粗糙」0.0085,相差 134 倍,根本不是「各维度都重要」的均衡奖励。" "右图把它们按语义归堆后更刺眼:指令遵循三档合计 2.3486,一家吃掉 37.4%;" "而构图加色彩只有 0.0924、占 1.5%。真实人类偏好数据里," "「有没有照着 prompt 做」比「好不好看」重要二十多倍——这个结构是后面所有套利的源头。") fig.savefig(OUT / "reward_weight_ledger.png") plt.close(fig) print(f"[图1] 29 维权重合计 {total:.4f};最高 {WEIGHT.max():.4f}/最低 {WEIGHT.min():.4f}" f" = {WEIGHT.max() / WEIGHT.min():.0f} 倍;指令遵循占 " f"{WEIGHT[[0, 1, 2]].sum() / total * 100:.1f}%") # ────────────────────────────────────────────────────────────────────── # 图 2:组内优势把「谁比谁好」抽出来 # ────────────────────────────────────────────────────────────────────── def fig_group_advantage(): rng = np.random.default_rng(2024) rs = 8.0 + rng.random(8) * 0.2 # 同一个 prompt 采 8 条,分都挤在 8.0~8.2 adv = (rs - rs.mean()) / (rs.std() + 1e-8) idx = np.arange(1, 9) fig, axes = plt.subplots(1, 2, figsize=(12.6, 4.6)) fig.subplots_adjust(wspace=0.26) ax = axes[0] ax.bar(idx, rs, color=C_OTHER, width=0.62) ax.set_ylim(0, 8.9) ax.set_xticks(idx) style(ax, "原始奖励:8 条都在 8.0~8.2 之间", "组内第 i 条", "奖励 $r_i$") for i, v in zip(idx, rs): ax.arrow(i, v + 0.42, 0, 0.30, width=0.055, color=INK, length_includes_head=True, head_width=0.16, head_length=0.12) ax.text(0.99, 8.72, "每一条都在说:往我这边推(只是力度略有差别)", fontsize=8.6, color=MUTE, ha="right", va="top") ax.text(0.02, 0.30, f"极差只有 {rs.max() - rs.min():.2f}", fontsize=8.6, color=INK, transform=ax.transAxes) ax = axes[1] cols = [C_POS if a >= 0 else C_NEG for a in adv] ax.bar(idx, adv, color=cols, width=0.62) ax.axhline(0, color=INK, lw=1.0) ax.set_xticks(idx) ax.set_ylim(-2.0, 2.0) for i, v in zip(idx, adv): ax.text(i, v + (0.09 if v >= 0 else -0.09), f"{v:+.2f}", ha="center", va="bottom" if v >= 0 else "top", fontsize=8, color=INK) style(ax, "减均值除标准差后:谁该推、谁该压,一眼分清", "组内第 i 条", "优势 $A_i$") ax.text(0.98, 0.94, "绿的往上推,红的往下压;组内均值自动当基线", fontsize=8.6, color=MUTE, ha="right", va="top", transform=ax.transAxes) fig.suptitle("图 2 GRPO 的组内基线:把「绝对分多高」换成「谁比谁好」", fontsize=12, color=INK, x=0.012, ha="left", y=1.04) footer(fig, "要看什么:左图是同一个 prompt 采出的 8 条视频的奖励,全挤在 8.0 到 8.2 之间——" "如果直接把 $r_i$ 乘在梯度上,8 条样本会一起喊「往我这边推」,只是力气略有差别," "有用的信息被绝对值淹没了(箭头方向完全一致)。右图减掉组内均值、除以组内标准差之后," "同样的 8 个数字变成了有正有负的优势:一半往上推、一半往下压。" "这就是 GRPO 不用 critic 也能训的原因——基线是从同组样本里白捡的。") fig.savefig(OUT / "group_advantage.png") plt.close(fig) print(f"[图2] 8 条奖励 {rs.min():.3f}~{rs.max():.3f}(极差 {rs.max() - rs.min():.3f});" f"优势 {adv.min():+.2f}~{adv.max():+.2f}") # ────────────────────────────────────────────────────────────────────── # 图 3:只改奖励权重,同一个 GRPO 翻转 # ────────────────────────────────────────────────────────────────────── def _trace_grpo(weight=None, seed=0, steps=400, group=32, lr=0.35, sigma=0.15): """复刻 grpo_minimal.grpo 的循环,只是把每步的 m 和组内平均奖励记下来。 抽随机的顺序必须和原函数完全一致,否则数字对不上正文。 """ rng = np.random.default_rng(seed) theta = 0.0 ms, rr = [], [] for _ in range(steps): mu = 1 / (1 + np.exp(-theta)) eps = rng.normal(0, sigma, group) m_clip = np.clip(mu + eps, 0.0, 1.0) r = np.array([G.reward(m, rng, weight) for m in m_clip]) adv = (r - r.mean()) / (r.std() + 1e-8) grad = float(np.mean(adv * eps / sigma ** 2) * mu * (1 - mu)) theta += lr * grad ms.append(mu) rr.append(float(r.mean())) return np.array(ms), np.array(rr), 1 / (1 + np.exp(-theta)) def fig_surface_flip(): w_no = G.weight_without_instruction() # 奖励曲面:与 grpo_minimal.main 同口径(rng=7,每点 4000 次采样) rng = np.random.default_rng(7) grid = np.linspace(0, 1, 21) surf_full, surf_no = [], [] for m in grid: surf_full.append(float(np.mean([G.reward(m, rng) for _ in range(4000)]))) surf_no.append(float(np.mean([G.reward(m, rng, w_no) for _ in range(4000)]))) surf_full = np.array(surf_full) surf_no = np.array(surf_no) # 正文引用的那张 11 点表,峰值标注以它为准 rng2 = np.random.default_rng(7) tbl = np.linspace(0, 1, 11) tf, tn = [], [] for m in tbl: # 抽随机顺序与 grpo_minimal.main 完全一致(逐点交错),否则峰值数字对不上正文 tf.append(float(np.mean([G.reward(m, rng2) for _ in range(4000)]))) tn.append(float(np.mean([G.reward(m, rng2, w_no) for _ in range(4000)]))) tf, tn = np.array(tf), np.array(tn) pk_f, pk_n = tbl[int(tf.argmax())], tbl[int(tn.argmax())] ms_full, rr_full, m_end_full = _trace_grpo(weight=None, seed=0) ms_no, rr_no, m_end_no = _trace_grpo(weight=w_no, seed=0) fig, axes = plt.subplots(1, 2, figsize=(12.6, 5.0)) fig.subplots_adjust(wspace=0.30) ax = axes[0] ax.plot(grid, surf_full, lw=2.4, color=C_FULL, label="完整 29 维权重") ax.plot(grid, surf_no, lw=2.4, color=C_NOIN, ls="--", label="去掉指令遵循三档") ax.axhline(0, color="#9ca3af", lw=0.8) ax.scatter([pk_f], [tf.max()], s=70, color=C_FULL, zorder=5) ax.scatter([pk_n], [tn.max()], s=70, color=C_NOIN, zorder=5) ax.annotate(f"最高点 m={pk_f:.1f}", (pk_f, tf.max()), textcoords="offset points", xytext=(10, -30), fontsize=8.6, color=C_FULL, arrowprops=dict(arrowstyle="->", color=C_FULL, lw=1.0)) ax.annotate(f"最高点 m={pk_n:.1f}", (pk_n, tn.max()), textcoords="offset points", xytext=(18, 26), fontsize=8.6, color=C_NOIN, arrowprops=dict(arrowstyle="->", color=C_NOIN, lw=1.0)) ax.legend(fontsize=8.6, frameon=False, loc="lower center") style(ax, "同一组物理约束,两种权重下的奖励曲面形状完全相反", "运动强度 $m$", "期望奖励 $r$") ax = axes[1] ax.plot(ms_full, lw=2.4, color=C_FULL, label="m(完整权重)") ax.plot(ms_no, lw=2.4, color=C_NOIN, ls="--", label="m(去掉指令遵循)") ax.set_ylim(-0.05, 1.05) ax.set_xlabel("训练步", fontsize=10, color=MUTE) ax.set_ylabel("运动强度 $m$", fontsize=10, color=MUTE) ax2 = ax.twinx() ax2.plot(rr_full, lw=1.1, color=C_FULL, alpha=0.45) ax2.plot(rr_no, lw=1.1, color=C_NOIN, alpha=0.45, ls="--") ax2.set_ylabel("组内平均奖励(浅色细线)", fontsize=9, color=MUTE) ax2.tick_params(colors=MUTE, labelsize=8.5) ax2.spines["top"].set_visible(False) h1, l1 = ax.get_legend_handles_labels() ax.legend(h1, l1, fontsize=8.6, frameon=False, loc="center right") style(ax, "同一个 GRPO 跑 400 步:一条冲到 0.97,一条躺到 0.01", None, None) ax.set_ylabel("运动强度 $m$", fontsize=10, color=MUTE) ax.set_xlabel("训练步", fontsize=10, color=MUTE) ax.text(0.02, 0.06, f"完整权重收敛 m={m_end_full:.3f}\n" f"去掉指令遵循收敛 m={m_end_no:.3f}", fontsize=8.4, color=INK, transform=ax.transAxes, va="bottom", bbox=dict(fc="white", ec="#d1d5db", lw=0.7, pad=4)) fig.suptitle("图 3 奖励曲线一样漂亮,学到的东西完全相反", fontsize=12, color=INK, x=0.012, ha="left", y=1.03) footer(fig, "要看什么:左图是同一组物理约束下的奖励曲面。完整权重(蓝实线)在 m=0.8 附近见顶," "去掉指令遵循三档后(红虚线)整条曲线翻转成单调递减,最高点直接落到 m=0——彻底不动才是最优解。" "右图是同一个 GRPO、同一个初值跑 400 步:蓝线冲到 0.966 选择照做 prompt," "红线掉到 0.008 选择躺平。而两条浅色细线是各自的组内平均奖励——它们都在稳步上涨," "形状和一次成功的训练没有任何区别。奖励曲线不能告诉你模型学到了什么。") fig.savefig(OUT / "reward_surface_flip.png") plt.close(fig) print(f"[图3] 曲面峰值:完整 m={pk_f:.1f} ({tf.max():+.4f})," f"去掉指令 m={pk_n:.1f} ({tn.max():+.4f});" f"GRPO 收敛 {m_end_full:.4f} vs {m_end_no:.4f}") def main(): fig_weight_ledger() fig_group_advantage() fig_surface_flip() print(f"[OK] 三张图已写入 {OUT}") for f in sorted(OUT.glob("*.png")): print(f" {f.name} {f.stat().st_size / 1024:.0f} KB") if __name__ == "__main__": main() reward_hacking.py """用 VisionReward 的真实权重,算一遍「静态视频能不能骗到高分」。 权重与问题取自 THUDM/VisionReward(2026-08 的 main 分支): VisionReward_Video/weight.json 29 个线性权重 VisionReward_Video/VisionReward_video_qa_select.txt 29 个 yes/no 问题 打分公式同 inference-video.py 的 score():yes=+1,no=-1,乘权重后取均值。 注意:下面两个视频的 yes/no 画像是**按各维度语义做的人工假设**, 不是真实模型的输出。它用来说明权重结构本身允许什么样的套利, 不构成对 VisionReward 实际表现的评测。 运行 `python reward_hacking.py`。 """ import numpy as np # (权重, 维度简称) —— 顺序与上游 weight.json 严格一致 DIMS = [ (0.9544, "指令-全部满足"), (0.2524, "指令-大部分满足"), (1.1418, "指令-未完全失败"), (0.0350, "构图-美观"), (0.0252, "构图-无明显缺陷"), (0.1260, "镜头运动-无明显缺陷"), (0.0322, "色彩-不难看"), (0.1629, "光照-完全准确"), (0.2167, "光照-无明显错误"), (0.0197, "光照-存在"), (0.1360, "光照-极美"), (0.0965, "光照-美"), (0.1549, "光照-不难看"), (0.1294, "首帧形状-完全准确"), (0.0989, "首帧形状-无明显错误"), (0.1884, "首帧形状-不混乱"), (0.1844, "全程形状-完美保持"), (0.2636, "全程形状-不混乱"), (0.1117, "镜头运动-高度动态"), (0.0517, "镜头运动-不微弱"), (0.0256, "物体运动-非常平滑"), (0.4390, "物体运动-完全真实"), (0.2686, "画质-非常稳定"), (0.4293, "细节-非常精细"), (0.0085, "细节-不粗糙"), (0.1276, "细节-不显著粗糙"), (0.0580, "文字-全部正确"), (0.1446, "文字-存在"), (0.3942, "内容-属于物理世界"), ] WEIGHT = np.array([w for w, _ in DIMS]) NAMES = [n for _, n in DIMS] Y, N = 1, -1 # 视频 A:几乎静止的精致画面。prompt 要求「人物走过街道」,它只给了个站着的人。 STATIC = [ N, N, Y, # 指令:没走,但也不算完全没关系 Y, Y, # 构图:精心构图 Y, # 镜头运动无缺陷(没动,自然没缺陷) Y, # 色彩 Y, Y, Y, Y, Y, Y, # 光照:静态画面容易做好 Y, Y, Y, # 首帧形状:完美 Y, Y, # 全程形状:不动=完美保持 N, N, # 镜头动态:没有 Y, Y, # 物体运动:平滑(无运动可挑)、真实(无违反物理) Y, # 画质稳定:不动=极稳 Y, Y, Y, # 细节:静态可以渲染很精细 Y, Y, # 文字 Y, # 属于物理世界 ] # 视频 B:真的走起来了,但运动带来形变、抖动和轻微模糊。 DYNAMIC = [ Y, Y, Y, # 指令:确实照做了 N, Y, # 构图:运动中构图一般 Y, # 镜头运动无明显缺陷 Y, # 色彩 N, Y, Y, N, Y, Y, # 光照:运动中光照没那么完美 Y, Y, Y, # 首帧形状:还行 N, N, # 全程形状:走动中有形变 ← 运动的代价 Y, Y, # 镜头动态:有 Y, Y, # 物体运动:平滑且真实 N, # 画质稳定:运动带来抖动 ← 运动的代价 N, Y, Y, # 细节:动态模糊,不够精细 ← 运动的代价 Y, Y, # 文字 Y, # 属于物理世界 ] def score(answers): """复刻 inference-video.py 的 score()。""" a = np.array(answers) assert len(a) == len(WEIGHT), f"维度不匹配: {len(a)} vs {len(WEIGHT)}" return float(np.mean(a * WEIGHT)) def main(): print("=== 1. 权重结构 ===") print(f" 29 个维度,权重总和 {WEIGHT.sum():.4f},均值 {WEIGHT.mean():.4f}") print(f" 最高 {WEIGHT.max():.4f}({NAMES[int(WEIGHT.argmax())]})") print(f" 最低 {WEIGHT.min():.4f}({NAMES[int(WEIGHT.argmin())]})") print(f" 最高/最低 = {WEIGHT.max() / WEIGHT.min():.0f} 倍") instr = WEIGHT[0] + WEIGHT[1] + WEIGHT[2] print(f"\n 指令遵循三档合计 {instr:.4f},占总权重 {instr / WEIGHT.sum() * 100:.1f}%") aes = WEIGHT[3] + WEIGHT[4] + WEIGHT[6] print(f" 构图+色彩合计 {aes:.4f},占总权重 {aes / WEIGHT.sum() * 100:.1f}%") print("\n=== 2. 静态套利的账 ===") shape_stable = WEIGHT[[16, 17, 22]].sum() motion = WEIGHT[[18, 19]].sum() print(f" 静止能白拿:全程形状保持+不混乱+画质稳定 = {shape_stable:.4f}") print(f" 静止要放弃:镜头高度动态+不微弱 = {motion:.4f}") print(f" 净收益 {shape_stable - motion:+.4f}" f" (yes/no 相差 2 个单位,实际影响 ×2 = {(shape_stable - motion) * 2:+.4f})") print("\n=== 3. 两个视频的实际得分 ===") s_static, s_dynamic = score(STATIC), score(DYNAMIC) print(f" 视频 A(几乎静止的精致画面,没照做 prompt) {s_static:+.4f}") print(f" 视频 B(照做了 prompt,但有形变和抖动) {s_dynamic:+.4f}") winner = "A(静态)" if s_static > s_dynamic else "B(动态)" print(f" 奖励模型更偏好:{winner} 差值 {abs(s_static - s_dynamic):.4f}") print("\n=== 4. 差异来自哪几个维度 ===") diff = (np.array(STATIC) - np.array(DYNAMIC)) * WEIGHT order = np.argsort(-np.abs(diff)) print(" (正值=这一维让静态视频占便宜)") for i in order[:8]: if diff[i] != 0: print(f" {diff[i]:+.4f} {NAMES[i]}") if __name__ == "__main__": main() grpo_minimal.py """GRPO 的最小可运行实现,跑在 VisionReward 的真实权重上。 策略只有一个参数:运动强度 m。这是刻意的——把维度压到 1 维, 才能看清 GRPO 到底把策略推向哪里,而不是被高维噪声淹没。 人为设定的玩具权衡(不是视频物理定律或真实 VLM 的测量): m 越大 → 指令遵循、动态性维度更容易拿 yes m 越大 → 形状保持、画质稳定、细节精细更容易拿 no(运动带来形变/抖动/模糊) prompt 明确要求运动("a person walking across the street"), 所以「照做」在语义上就等于 m 要大。看 GRPO 最后选了什么。 运行 `python grpo_minimal.py`。 """ import numpy as np from reward_hacking import WEIGHT, NAMES # 各维度对运动强度 m 的敏感方向 # +1: m 越大越容易 yes -1: m 越大越容易 no 0: 与 m 无关 SENSITIVITY = np.zeros(29) SENSITIVITY[[0, 1, 2]] = +1.0 # 指令遵循(prompt 要求运动) SENSITIVITY[[18, 19]] = +1.0 # 镜头动态 SENSITIVITY[[20, 21]] = +0.3 # 运动平滑/真实:要有运动才谈得上,但过猛会假 SENSITIVITY[[16, 17]] = -1.0 # 全程形状保持 SENSITIVITY[22] = -1.0 # 画质稳定 SENSITIVITY[[23, 24, 25]] = -0.7 # 细节精细(动态模糊) SENSITIVITY[[7, 8, 10, 11]] = -0.3 # 光照准确/美观:运动中更难保持 def yes_prob(m): """给定运动强度 m∈[0,1],各维度答 yes 的概率。 基线 0.5 表示「说不准」,敏感度把它往两边拉。 """ return np.clip(0.5 + SENSITIVITY * (m - 0.5) * 1.6, 0.02, 0.98) def sample_video(m, rng): """按概率采一个 yes/no 画像,yes=+1 no=-1。""" return np.where(rng.random(29) < yes_prob(m), 1, -1) # 指令遵循的三个维度。把它们置零就得到一个「只看画质」的奖励模型, # 用来对照权重结构对 reward hacking 的影响。 INSTRUCTION_DIMS = [0, 1, 2] def reward(m, rng, weight=None): """复刻 inference-video.py 的 score():np.mean(answers * weight)。""" w = WEIGHT if weight is None else weight return float(np.mean(sample_video(m, rng) * w)) def weight_without_instruction(): w = WEIGHT.copy() w[INSTRUCTION_DIMS] = 0.0 return w def grpo(steps=400, group=32, lr=0.35, sigma=0.15, theta0=0.0, seed=0, weight=None, verbose=False): """单参数 GRPO,返回收敛后的运动强度 m。 每步采 group 个动作,用组内均值/标准差做 baseline 得到 advantage, 再按 advantage 加权更新——不需要 critic,这是 GRPO 的核心简化。 为了看清裸的优化行为,这里省掉了 PPO 式的 clip 和对参考模型的 KL 惩罚。 """ rng = np.random.default_rng(seed) theta = theta0 for step in range(steps): # 策略:m = clip(sigmoid(theta) + noise),高斯探索 mu = 1 / (1 + np.exp(-theta)) eps = rng.normal(0, sigma, group) ms = np.clip(mu + eps, 0.0, 1.0) rs = np.array([reward(m, rng, weight) for m in ms]) # 组内相对优势:减均值除标准差 adv = (rs - rs.mean()) / (rs.std() + 1e-8) # 潜在动作 a = mu + eps 的高斯 score,m=clip(a) 只是环境映射。 # 这不是含边界原子质量的裁剪后 m 分布的逐点 log-density。 # ∂mu/∂theta = mu(1-mu) grad = float(np.mean(adv * eps / sigma ** 2) * mu * (1 - mu)) theta += lr * grad if verbose and (step % 80 == 0 or step == steps - 1): print(f" {step:>4} {mu:.4f} {float(rs.mean()):+.4f}") return 1 / (1 + np.exp(-theta)) def main(): print("=== 1. 优化前后的运动强度 ===") print(" step 运动强度 m 组内平均奖励") m_final = grpo(verbose=True) print(f"\n GRPO 收敛到 m = {m_final:.4f}") verdict = "静态(放弃了 prompt 要求的运动)" if m_final < 0.35 else \ "动态(照做了 prompt)" if m_final > 0.65 else "折中" print(f" → 策略选择:{verdict}") print("\n=== 2. 从不同初值出发是否都收敛到同一处 ===") for name, t0 in (("偏静态 (m≈0.27)", -1.0), ("中性 (m=0.50)", 0.0), ("偏动态 (m≈0.73)", 1.0)): mf = grpo(theta0=t0, seed=1) print(f" 初值 {name:<18} → 收敛 m = {mf:.4f}") print("\n=== 3. 奖励曲面:各运动强度的期望得分 ===") rng = np.random.default_rng(7) w_no_instr = weight_without_instruction() print(" m 完整权重 去掉指令遵循 (每点 4000 次采样)") best, best_no = (-9e9, None), (-9e9, None) for m in np.linspace(0, 1, 11): r = float(np.mean([reward(m, rng) for _ in range(4000)])) r2 = float(np.mean([reward(m, rng, w_no_instr) for _ in range(4000)])) best = max(best, (r, m)) best_no = max(best_no, (r2, m)) print(f" {m:.1f} {r:+.4f} {r2:+.4f}") print(f" → 完整权重的最高点在 m = {best[1]:.1f}({best[0]:+.4f})") print(f" → 去掉指令遵循后最高点在 m = {best_no[1]:.1f}({best_no[0]:+.4f})") print("\n=== 4. 对照:奖励模型不看指令遵循时,GRPO 选什么 ===") print(" step 运动强度 m 组内平均奖励") m_hack = grpo(weight=w_no_instr, seed=0, verbose=True) print(f"\n 收敛到 m = {m_hack:.4f}" f"(完整权重下是 {m_final:.4f})") print(" → 同一个算法、同一个物理约束,只改奖励的权重结构," "\n 策略就从「照做」翻转成「静止不动」。这就是 reward hacking 的来源:" "\n 不是算法坏了,是它精确地最大化了你真正写下来的那个目标。") print("\n=== 5. 两个收敛点在各维度上的差别 ===") p_ok, p_hack = yes_prob(m_final), yes_prob(m_hack) gap = (p_hack - p_ok) * WEIGHT order = np.argsort(np.abs(gap))[::-1] print(" (正值=hack 后的策略在这一维更容易拿 yes)") for i in order[:6]: print(f" {gap[i]:+.4f} {NAMES[i]}" f" (yes 概率 {p_ok[i]:.2f} → {p_hack[i]:.2f})") if __name__ == "__main__": main()
2026年08月23日
9 阅读
0 评论
0 点赞
2026-08-23
AIGC 基本功|旋转位置编码 RoPE 的原理与实现-RoPE
AIGC 基本功|旋转位置编码 RoPE 的原理与实现-RoPE 所属方向:注意力与核心零件 | 难度:核心组件 | 前置知识:MHA 关键词:位置编码、RoPE、旋转位置编码、相对位置、复数旋转、长度外推 01. 为什么需要它 先说一个具体的失败场景。 你训了一个视频 DiT,训练时用 480p、49 帧。推理时想出 720p、97 帧,结果画面从第 50 帧开始漂——人物五官慢慢挪位,背景纹理开始流动。你换了采样器、加了 CFG、调了 shift,都没用。 先把这个假设例子的尺寸写全:训练输入是 832×480、49 帧,推理输入是 1280×720、97 帧。假设 VAE 空间压缩 8 倍、时间长度按 $T_{\text{latent}}=1+\lfloor(T-1)/4\rfloor$ 计算,再做空间 2×2 patchify;不再额外切时间 patch。两组宽高都能被 16 整除,不需要补边: 480p/49 帧 → 13 个 latent 帧 × 1560 tokens/帧 = 20280 tokens 720p/97 帧 → 25 个 latent 帧 × 3600 tokens/帧 = 90000 tokens 总量涨了约 4.44 倍,其中每帧 token 数涨约 2.31 倍、latent 帧数涨约 1.92 倍。网格从 $(13,30,52)$ 变成 $(25,45,80)$,依次对应时间、高、宽;空间 token 数的倍数不是任意一个轴的坐标缩放倍数。也就是说,模型要给四倍多的位置编号赋予意义,新增的较远坐标与注意力关系超出了训练范围(已有坐标仍然见过)。 这是一个待诊断的假设场景:位置编码、训练尺度、VAE、注意力规模和运动分布都可能造成失败,不能仅凭症状归因给 RoPE。 注意力机制本身是置换等变的:把输入序列的顺序打乱,输出也只是跟着打乱,每个 token 看到的上下文完全不变。换句话说,纯注意力分不清「第 3 帧」和「第 40 帧」。位置信息必须额外注入。 早期做法是可学习的绝对位置嵌入(learned absolute PE):给每个位置分配一个可训练向量,加到 token 上。这在训练长度内工作良好,但有个硬伤——没见过的位置没有对应向量。你训练时只见过 49 个位置,推理要 97 个,后面 48 个位置没有训练过的表项。可以扩表、插值或重新训练,但这些都要额外规定,质量也要验证;不能由“没表项”直接推出所有绝对位置方案必然失败。 RoPE 解决的正是这件事:它不给位置分配「向量」,而是给位置分配「旋转角度」。角度公式可计算到新位置;生成质量能否外推仍需训练或扩展策略验证。 02. 最小可用理解 三句话: 把 query 和 key 的特征维度两两配对看成复数,每个位置 $m$ 把它们旋转 $m\theta$ 弧度。 两个向量做内积时,指数项相减,绝对位置 $m$、$n$ 自动消掉,只剩相对距离 $(m-n)$。 所以对固定的 q、k,位置项只依赖 token 间的相对距离;完整分数仍依赖内容特征,而旋转角度可以外推到训练时没见过的位置。 如果只记一件事:本文标准 RoPE 是乘上去的旋转,不是加上去的向量。 它保持被旋转向量的模长;加法通常不保持。这个几何差别值得理解,但不是哪一种位置编码质量更好的证明。带幅度缩放的 RoPE 扩展还要单独讨论。 还有一个位置要记准:它作用在每一层注意力的 $q$ 和 $k$ 上,不是输入层加一次,通常不旋转 $v$。这是本文及常用实现的约定,某些变体也会旋转 value,不能一概判错。 03. 数学推导 3.1 我们到底想要什么性质 先补齐先修背景:注意力先做 $q=W_Qx$、$k=W_Kx$、$v=W_Vx$,再把 $q^\top k/\sqrt{d}$ 经掩码和 softmax 变成对 $v$ 的加权系数。这里 $d$ 是单个 head 的旋转维度,取正偶数;本文先考虑整个 head 都旋转。我们讨论的是 softmax 之前的单个点积,$1/\sqrt{d}$ 只是公共比例。 设 $x_m$ 是位置 $m$ 上的 token 特征,我们要找一个变换 $f$,使得变换后的 query 和 key 的内积满足: $$\langle f(q, m), f(k, n) \rangle = g(q, k, m-n)$$ 左边依赖两个绝对位置 $m$ 和 $n$,右边只依赖它们的差值 $m-n$。这就是「相对位置不变性」的精确表述。注意 $g$ 的具体形式我们不关心,只要求它不含 $m$、$n$ 的单独出现。这个性质也可叫共同平移不变性:同时把两者位置加同一个偏移,固定内容向量的点积不变。真实网络的内容向量、掩码及可见 token 集合仍可能变化。 3.2 复数域上的现成答案 二维情况下有个现成的东西满足这个条件:旋转。 把二维向量 $q = (q_0, q_1)$ 看成复数 $q_0 + iq_1$。复数乘法 $e^{im\theta}$ 的几何意义就是逆时针旋转 $m\theta$ 弧度,且不改变模长。 现在令 $f(q, m) = q e^{im\theta}$,算一下内积。复数域上的内积要取共轭: $$\langle f(q,m), f(k,n) \rangle = \mathrm{Re}\left[(q e^{im\theta}) \overline{(k e^{in\theta})}\right] = \mathrm{Re}\left[q \bar{k} e^{i(m-n)\theta}\right]$$ 关键一步在指数:$e^{im\theta} \cdot \overline{e^{in\theta}} = e^{im\theta} e^{-in\theta} = e^{i(m-n)\theta}$。绝对位置就这么消掉了,只留下 $(m-n)$。这就是 RoPE 的全部动机,后面所有工程细节都只是把这个二维结论铺到高维。 3.3 铺到高维 实际的 head_dim 是 64 或 128,不是 2。做法是把 $d$ 维切成 $d/2$ 组,每组两个相邻维度构成一个复数平面,第 $i$ 组用自己的旋转频率: $$\theta_i = \text{base}^{-2i/d}, \quad i = 0, 1, \dots, d/2 - 1$$ 这里的 base 就是代码里的 rope_theta;本文选 10000,不代表所有模型都用这个值。$i$ 是二维平面的组号,不是单个坐标号。 这个频率设计是理解 RoPE 行为的关键。展开看两端: $i = 0$ 时 $\theta_0 = 1$,位置每 +1 就转 1 弧度,转得最快 $i = d/2 - 1$ 时 $\theta_i = \text{base}^{-1+2/d}$,转得最慢。本文 $d=64$ 时是 $\theta_{31}=0.0001333521432$,不是精确的 $10^{-4}$;对应周期约 47117.24 个坐标单位 也就是说,高频维度负责区分近距离,低频维度负责区分远距离。一个位置差 1 的 token 对,主要靠前面几维的相位差来分辨;位置差较大时,高频相位已多次绕圈,而低频相位仍提供变化较慢的尺度;各频段并非互斥地负责某一距离。 这张图要看什么:左图是 $\cos(m\theta_i)$ 的热力图(横轴位置、纵轴维度组)——256 长度内转满一整圈的只有前 13 组;这些高频组提供较大的近距离相位变化,维度索引越大,相位变化越慢,但并非不携带近距离位置信息;右图把位置拉到 32768,高频组早就转了几万弧度,低频组才刚积累出可分辨的相位。这是 32 组频率提供的多尺度结构,不是互斥的距离分工;后面的 PI、NTK-aware、YaRN 都会调整这组频率;YaRN 还包含注意力幅度修正,不能只理解成频率重分配(图由 code/make_figures.py 生成)。 这个结构直接解释了长度外推的两种主流做法:位置插值(PI)把所有位置除以一个缩放因子,等价于整体降低旋转速度;NTK-aware 缩放只调 base,让低频维度转得更慢而尽量保住高频维度的分辨率。它们改的都是同一组 $\theta_i$。 写成矩阵形式,RoPE 就是一个分块对角的旋转矩阵 $R_m$,第 $i$ 个 $2\times2$ 块为: $$\begin{pmatrix} \cos m\theta_i & -\sin m\theta_i \\ \sin m\theta_i & \cos m\theta_i \end{pmatrix}$$ 对固定的未旋转内容向量 $q,k$,矩阵推导完整写成: $$\langle R_mq,R_nk\rangle=q^\top R_m^\top R_nk=q^\top R_{n-m}k$$ 因为每块旋转都满足 $R_m^\top=R_{-m}$、$R_aR_b=R_{a+b}$。这和 3.2 节的 $(m-n)$ 没有冲突:复数写法取 $q\bar{k}$,矩阵写法让相位作用在右侧的 $k$ 上。展开第 $i$ 对坐标,令 $\Delta=n-m$,二者都得到: $$ (q_{2i}k_{2i}+q_{2i+1}k_{2i+1})\cos(\Delta\theta_i)+(q_{2i+1}k_{2i}-q_{2i}k_{2i+1})\sin(\Delta\theta_i) $$ 位置差的正负和正弦前的交叉项要一起看,不能只照抄其中一个符号。独立脚本 rope_validation.py 同时检查复数、矩阵和直接旋转三种计算,最大绝对差约 $5.72\times10^{-13}$。 3.4 「不改模长」说明了什么 $R_m$ 是正交矩阵,所以 $\|R_mq\|=\|q\|$。加法通常不同: $$\|x+p_m\|^2=\|x\|^2+2x^\top p_m+\|p_m\|^2$$ 于是同一个 $x$ 放在不同位置,其模长可能改变。rope_decay.py 用 seed=0、64 维 float64 输入和独立同尺度随机 PE 作演示: 原始向量模长 7.315344 RoPE 后(位置 0/1/100/511) 7.315344 7.315344 7.315344 7.315344 跨位置模长极差 8.88e-16 加法式 PE 后 9.843323 10.080425 10.808198 10.190631 跨位置模长极差 0.9649 这组随机 PE 不是训练后的绝对位置嵌入,不能当质量对照。模长保持让位置注入的几何作用更容易分析,但 $q,k$ 的方向变了,softmax 权重照样会变;模长也不能直接解释成 token 的重要性或置信度。如果某扩展把 cos/sin 同乘 $a$,旋转结果就会再乘 $a$,$q,k$ 点积变为原来的 $a^2$ 倍,已不满足这里的单位幅度保模长结论。 04. 代码实现 下面用 NumPy 写出完整最小实现,输入采用浮点数组,旋转维度为正偶数。它把逐对旋转直接展开,便于对照公式。 import numpy as np def build_rope_cache(seq_len: int, dim: int, base: float = 10000.0): """预计算 RoPE 的 cos/sin 表。 dim 必须是偶数:每两个相邻维度配成一个复数参与旋转。 base 就是常说的 rope_theta,调它会按维度改变旋转速度;最高频 i=0 始终为 1。 """ assert dim % 2 == 0, f"dim 必须为偶数,收到 {dim}" # theta_i = base^(-2i/dim):i 越大转得越慢,对应越低的频率 inv_freq = 1.0 / (base ** (np.arange(0, dim, 2) / dim)) pos = np.arange(seq_len) freqs = np.outer(pos, inv_freq) # [seq_len, dim/2] return np.cos(freqs), np.sin(freqs) def apply_rope(x: np.ndarray, cos: np.ndarray, sin: np.ndarray) -> np.ndarray: """把旋转作用到 x 上。x: [seq_len, dim] 最后一维两两配对看成复数 (x_even + i*x_odd),乘上 e^{i*m*theta}: 实部 = x_even * cos - x_odd * sin 虚部 = x_even * sin + x_odd * cos """ x_even, x_odd = x[..., 0::2], x[..., 1::2] out = np.empty_like(x) out[..., 0::2] = x_even * cos - x_odd * sin out[..., 1::2] = x_even * sin + x_odd * cos return out 验证核心性质:固定一对向量,只改赋予它们的绝对位置,间距始终为 1。如果推导是对的,内积应该完全不变。 rng = np.random.default_rng(0) seq_len, dim = 8, 64 cos, sin = build_rope_cache(seq_len, dim) q = rng.standard_normal(dim) k = rng.standard_normal(dim) for shift in range(4): qr = apply_rope(q[None, :], cos[shift:shift + 1], sin[shift:shift + 1]) kr = apply_rope(k[None, :], cos[shift + 1:shift + 2], sin[shift + 1:shift + 2]) print(f"位置 ({shift}, {shift + 1}) → 内积 {float(qr[0] @ kr[0]):.6f}") 真实输出: cos/sin 形状: (8, 32) (8, 32) 位置 1 的前 4 维 cos: [0.5403, 0.7318, 0.846, 0.9124] 位置 1 的末 2 维 cos: [1.0, 1.0] 同一对向量,平移绝对位置(间距恒为 1): 位置 (0, 1) → 内积 -11.297869 位置 (1, 2) → 内积 -11.297869 位置 (2, 3) → 内积 -11.297869 位置 (3, 4) → 内积 -11.297869 对照组,改变间距: 间距 1 → 内积 -11.297869 间距 2 → 内积 -12.637566 间距 3 → 内积 -13.049657 未加 RoPE 的原始内积: -9.701179 三个数字值得盯一下。 第一,四行内积完全相同(-11.297869),小数点后六位都不差。这就是 3.1 节那个式子在数值上的体现:绝对位置从 (0,1) 挪到 (3,4),这些数打印到六位小数相同,底层浮点结果允许舍入误差;间距一变(1→2→3),这组向量的内积随之变化。 第二,位置 1 的前 4 维 cos 是 0.5403、0.7318、0.846、0.9124,递增。对应 $\theta_i$ 递减:第 0 维转了整整 1 弧度(cos 1 ≈ 0.5403),第 3 维只转了约 0.42 弧度。这就是「高频维度在前」的直接观测。 第三,末 2 维 cos 打印出来是 1.0。不是真的等于 1,而是最低频 $\theta_{31}=0.0001333521432$,其 cos 约为 0.9999999911,六位小数下显示成 1.0。这些维度在近距离上有很小但非零的相位变化;不能说它们只为远距离服务。NTK-aware 缩放对这些低频维度施加更大压缩;实际质量代价仍取决于训练后的频段使用方式。 上面这张图(code/make_figures.py 图 3)把本节的两件事画在了一起。左图是平移不变性的可视化:同一对 $q$、$k$,把绝对起点放在 0 / 500 / 2000,逐点画出内积随相对距离的曲线——三条曲线完全重合(最大偏差 5.2e-13),这就是上面输出里「四行内积完全相同」的图形版。右图是另一个常被混着说的现象:距离越远,加了 RoPE 的分数与不加位置编码的原始内积的相关性在所采样的区间整体降低,但并不单调(4000 对随机向量实测,$\Delta=1$ 时 0.97、$\Delta=4096$ 时接近 0;对独立各向同性输入,虚线为 $\frac{2}{d}\sum_i\cos(\Delta\theta_i)$,d 是特征维度、Δ 是相对距离)。注意衰减的是这个相关性,不是内积本身的大小——旋转保模长,本文独立各向同性高斯向量的内积分布与距离无关(实验三会验证这一点)。 完整脚本见 code/rope_minimal.py。 4.1 顺手把两种外推方法也实现掉 3.3 节说过 PI 和 NTK-aware「改的都是同一组 $\theta_i$」。这句话用代码写出来就是两个短函数——把它们并排放一起,先看清两种方法的几何差别;适用维度和参数条件见下文。 def theta_original(dim=64, base=10000.0): return 1.0 / (base ** (np.arange(0, dim, 2) / dim)) def theta_pi(dim=64, base=10000.0, scale=8): """Position Interpolation:位置索引整体除以 scale。 等价于把每个 theta_i 都乘上 1/scale——所有频率被同等减速。""" return theta_original(dim, base) / scale def theta_ntk(dim=64, base=10000.0, scale=8): """NTK-aware:把 base 放大,让减速量随维度索引递增(频率越低减速越多)。""" if dim <= 2 or dim % 2 or scale <= 0 or base <= 0: raise ValueError("NTK-aware requires even dim > 2, positive scale and base") new_base = base * scale ** (dim / (dim - 2)) return theta_original(dim, new_base) 跑 code/rope_extrapolate.py 看它们对各维度做了什么(外推 8 倍): 维度 i 原始 PI(÷8) NTK-aware PI减速比 NTK减速比 0 1.000e+00 1.250e-01 1.000e+00 8.00x 1.00x 4 3.162e-01 3.953e-02 2.418e-01 8.00x 1.31x 8 1.000e-01 1.250e-02 5.847e-02 8.00x 1.71x 16 1.000e-02 1.250e-03 3.419e-03 8.00x 2.92x 31 1.334e-04 1.667e-05 1.667e-05 8.00x 8.00x PI 那一列减速比是齐刷刷的 8.00x,从最高频到最低频一视同仁。NTK 那一列从 1.00x 单调爬到 8.00x——最高频维度完全没动,最低频维度承担了全部的 8 倍压缩。 这张图要看什么:左图把上表画成曲线——PI 是一条平线(每维都慢 8 倍),NTK 从最高频的 1.00x 单调爬到最低频的 8.00x,两条线夹出的面积就是「NTK 少减的部分」;右图是代价,PI 把每一组的相邻坐标相位步长降到 12.5%,NTK 第 0 组保留 100%,第 16 组约 34.2%,最低频第 31 组为 12.5%。这些是相位步长的比例。两种方法改的是同一组 $\theta_i$,区别只在把这次外推的代价放在哪个频段。 先只量几何变化:相邻坐标的相位步长还剩多少: 维度 i 原始 PI NTK PI保留 NTK保留 0 1.000000 0.125000 1.000000 12.5% 100.0% 4 0.316228 0.039528 0.241809 12.5% 76.5% 8 0.100000 0.012500 0.058472 12.5% 58.5% 16 0.010000 0.001250 0.003419 12.5% 34.2% PI 让每维相邻位置的相位增量降到 12.5%;这可能影响局部分辨,但不等于任务指标必然下降,微调与模型适应会改变实际结果。NTK 在第 0 维保住了 100%,代价是第 16 维只剩 34.2%。 还有一个容易看漏的点。NTK 外推后,最高频维度在位置 32767 处的相位是 32767 弧度,仍然远超训练时见过的 4095: 训练时见过的最大相位(位置 4095) 4095.0 rad 原始(直接外推) 位置 32767 32767.0 rad ← 超出 8.0 倍 PI 位置 32767 4095.9 rad ← 接近但略超训练端点 4095 NTK-aware 位置 32767 32767.0 rad ← 超出 8.0 倍 这是该缩放的设计取舍:最高频保留原始步长,低频逐渐压缩。未展开相位超过训练最大值不等于必然失效,相位本身具有周期性;只有最低频获得完整 8 倍压缩,不能据此保证所有频段已回到训练范围或质量安全。PI 则把所有维度的相位范围压到接近训练区间,代价是相邻位置相位步长整体下降;精确端点对齐还需使用端点跨度比。上面的 NTK-aware 是固定缩放教学版。将新 base 代回频率定义可得: $$\theta_i'=\theta_i s^{-2i/(d-2)},\quad d>2,\quad s>0$$ 所以第 0 组不动,第 $d/2-1$ 组恰好除以 $s$。本文为延长窗口使用 $s\geq1$;$d=2$ 时这个公式没有定义。它不是固定版本 HF 的动态 NTK:后者会用当前长度改变 base,见 5.3 节。 PI 若要求目标末坐标精确映回训练末坐标,应选 $s=(L_{\text{new}}-1)/(L_{\text{train}}-1)$,两长度都大于 1。4096→32768 时是 8.0017094,而非 8;用长度比 8 会得到 4095.875,略超 4095。两种约定要写清,不能把舍入差说成方法失效。 YaRN 的 v2 原文在频段插值之外还讨论注意力温度修正;本文只复现 PI 与固定 NTK-aware 的频率几何,没有复现 YaRN 的训练或长上下文质量。 05. 工业级实现对照 本节固定可复核的源码版本;它们是选定的实现快照,不表示当天最新版: Transformers v4.51.3 的 Llama 实现:LlamaRotaryEmbedding、rotate_half、apply_rotary_pos_emb。 同版本频率与动态更新:默认、线性及动态 NTK 参数和 dynamic_rope_update。 Diffusers v0.35.1 的位置嵌入:get_1d_rotary_pos_embed、get_3d_rotary_pos_embed。 下载的原文件保留 SHA256。本轮把上述类和函数的原始 AST 直接在 CPU 执行,提供简单配置对象及原生依赖;没有改写其数学实现,也不声称执行了完整模型或安装了完整框架。 下面按配对、频率缓存、坐标、精度四条线对照,再看视频的三轴扩展。 5.1 配对方式不同,而且不能混用 这是最容易踩的坑。本文把第 $2i$ 和 $2i+1$ 维配成一个复数(奇偶交错),而 HF 的写法是把第 $i$ 和第 $i + d/2$ 维配成一对(前后半切分): def rotate_half(x): """Rotates half the hidden dims of the input.""" x1 = x[..., : x.shape[-1] // 2] x2 = x[..., x.shape[-1] // 2 :] return torch.cat((-x2, x1), dim=-1) q_embed = (q * cos) + (rotate_half(q) * sin) 配合 emb = torch.cat((freqs, freqs), dim=-1)(cos/sin 表沿最后一维复制两遍),展开就是 $x_i \cos - x_{i+d/2} \sin$ 和 $x_{i+d/2} \cos + x_i \sin$——和 3.3 节的旋转矩阵一模一样,只是谁跟谁配对换了。 那两者是等价的吗?我写了个脚本实测(code/rope_pairing.py): === 1. 两种配对都满足相对位置不变性 === 奇偶交错: 间距恒为 3 时内积 -13.049657,四个绝对位置间的最大波动 3.55e-15 前后半切分: 间距恒为 3 时内积 -7.706177,四个绝对位置间的最大波动 1.78e-15 === 2. 同一份权重下,两种配对给出的分数不同 === 奇偶交错 -13.049657 前后半切分 -7.706177 差值 5.343480 ← 不为 0,实现和权重必须配套 === 3. 重排维度后两者完全一致 === 奇偶交错(原始排布) -13.049657 前后半切分(重排后权重) -13.049657 差值 0.00e+00 ← 只差一个固定的维度置换 三段结论连起来才是完整答案:作为位置编码方案两者等价(都满足相对位置不变性,波动 1e-15 是纯浮点误差),但对同一份权重不可互换(差了 5.34),因为它们只差一个固定的维度置换(重排后差值精确为 0)。 实践意义:移植权重时如果配对方式对不上,模型不会报错,只会变笨。这种 bug 极难查——loss 能降,输出也通顺,就是长距离能力莫名其妙地差。排查时要把 cos/sin 的复制方式与旋转算子一起核对:本节 Llama 的 cat((freqs, freqs)) 配 rotate_half;交错版的逐对旋转配 repeat_interleave。仅看一个 cat 不能推断陌生代码里所有后续维度重排。移植线性层时,若按 $q=W_Qx$ 记号,应同步置换 $W_Q,W_K$ 的输出行及偏置;GQA 也要逐 KV head 核对。 5.2 inv_freq 存成 buffer,而且不进 checkpoint self.register_buffer("inv_freq", inv_freq, persistent=False) self.original_inv_freq = self.inv_freq 这是固定 v4.51.3 原代码,第二行是普通属性引用,不是注册第二份 buffer。保存频率避免反复做幂运算;这个版本仍会在 forward 计算给定位置的 cos/sin,并不缓存所有位置的完整三角函数表。persistent=False 的含义是:它让 inv_freq 不写进 state_dict。好处是改 rope_theta 做长度外推时,不必担心 checkpoint 里存着旧频率把新配置覆盖掉——在这里,模型构建时先由 config 建频率,再按所选策略更新。不能推广为所有模型都不保存频率。留一份 original_inv_freq 是给 dynamic NTK 用的:那类方法会随序列长度临时改频率,短序列时可能需要退回原值。CPU 原类实跑确认 state_dict 没有 inv_freq,动态类增长后会改频率,长度回到原窗口内会复原。 5.3 接受 position_ids 而不是内部 arange def forward(self, x, position_ids): inv_freq_expanded = self.inv_freq[None, :, None].expand(position_ids.shape[0], -1, 1) position_ids_expanded = position_ids[:, None, :].float() 最小实现里位置就是 arange(seq_len),生产上不行,至少三个场景要显式传: KV cache 增量解码:第 101 个 token 单独前向,它的位置是 100 而不是 0 序列打包(packing):独立样本常各自从 0 编号,并使用对应的分段掩码。若只在段内注意且频率固定,每段统一加偏移也不改段内点积;关键是坐标策略与掩码一致,重置坐标本身不能防止跨样本泄漏 多模态与视频:图像 token 和文本 token 的位置编号规则不同,位置得由外部算好 动态频率还会影响 KV cache。固定频率时,历史 key 在其绝对坐标旋转一次后可复用;但如果新 query 改用新频率,旧 key 仍是旧频率,点积就不再是同一个 $R_m^\top R_n$。需核对框架是否冻结整次生成的频率、重算缓存,或采用专门的兼容策略;单看频率更新函数不能保证缓存正确。 HF v4.51.3 的动态 NTK 具体用 $b'=b[f\ell/L_0-(f-1)]^{d/(d-2)}$,其中 $b$ 为旧 base、$f$ 为配置 factor、$\ell$ 为当前长度且至少取原窗口 $L_0$;不是把本文固定缩放里的 $s$ 直接替换成 $\ell/L_0$。原类 CPU 演示在 $d=8,L_0=4,f=2$ 的配置中,旧 key 与按最终频率重算的 key 给出点积差约 0.128828;这是明确的缓存混频反例,不是框架完整生成的测试。 5.4 为什么这个实现用 fp32 算相位与 cos/sin with torch.autocast(device_type=device_type, enabled=False): freqs = (inv_freq_expanded @ position_ids_expanded).transpose(1, 2) emb = torch.cat((freqs, freqs), dim=-1) cos = emb.cos() * self.attention_scaling 注意它显式关闭 autocast,并先把位置和频率转成 FP32。BF16 有 8 位有效二进制精度(含隐含位),在 $[4096,8192)$ 的相邻可表示数间隔为 32;CPU 原生转换中,4096 和 4097 都会变成 4096。若先在 BF16 编号再转 FP32,丢失的坐标不会回来。频率本身先量化后再转高精度也同样不能恢复。 FP32 能避免这个例子的相邻坐标合并,但不等于任意长位置都安全;相位乘法与三角函数仍有误差,极长上下文应测目标范围,必要时用 FP64 等策略。固定 Llama 代码最后把 cos/sin 转回输入 dtype;“相位计算用 FP32”和“所有后续旋转必须 FP32”是两回事。本轮官方 FP32 输出与 NumPy float64 参考最大差约 $5.27\times10^{-5}$,同一份 FP32 cos/sin 下逐元素前向与输入梯度均精确对齐。 5.5 读陌生代码库时的四个问题 接手一个没见过的模型,想搞清它的 RoPE 怎么配的,按这个顺序问四个问题最快,也刚好对应上面四小节: cos/sin 表是怎么铺开的? 搜 cat 还是 repeat_interleave——torch.cat((freqs, freqs)) 是前后半切分,repeat_interleave(freqs, 2) 是奇偶交错。这决定了权重能不能和别的实现互换。 inv_freq 从哪来、会不会变? 搜 inv_freq 的赋值点。如果只在 __init__ 里算一次,就是静态 RoPE;如果 forward 里还会重算,大概是某种 dynamic NTK,那么推理时的行为会随序列长度变。 position_ids 谁给的? 如果是内部 arange,还要核对是否加了 cache offset 或传入各段位置;从零重启才会破坏 KV cache 增量解码,多模态场景基本要改。 三角函数在什么精度下算的? 搜附近有没有 float()、autocast、enabled=False。没有强制 fp32 的话,长序列下有精度隐患(5.4 节)。 这四个问题都能靠 grep 在几分钟内回答,比通读整个 attention 实现快得多。 5.6 视频侧:三个轴分走维度 视频 DiT 的 token 有时间、高、宽三个坐标,做法是把 head_dim 切成三份,每份跑一遍 1D RoPE 再拼起来。本节固定 Diffusers 函数的一种分配是: dim_t = embed_dim // 4 # 时间轴,占 25% dim_h = embed_dim // 8 * 3 # 高,占 37.5% dim_w = embed_dim // 8 * 3 # 宽,占 37.5% 这个 2:3:3 的分配是 CogVideoX 相关函数的一种维度预算,不能推广为所有视频模型的配置。各轴必须有可配对的偶数维度,需核对具体函数对 head_dim 的限制;64 维时是 16/24/24。CPU 原函数用时间 2、高 2、宽 3 的小网格,返回 cos/sin 均为 (12,64)。空间坐标还受 grid_type、裁剪范围和插值设置影响,不能只看 token 数。不能从空间占 75%、时间占 25% 推断哪个轴更先失败或失败症状;需结合训练范围、每轴频率和实际消融。详见3D RoPE。 06. 代价与边界 6.1 数学性质不替代外推验证 固定频率、单位幅度时,共同平移不变性对任意可计算的位置都成立,但不会自动保证超出训练长度后的质量。真实模型学到的是内容与多频段相位、掩码、上下文长度的联合关系;逐个频段的角度还具有周期性。“未展开相位超出训练最大值”不是充分的失败判据。PI 论文展示的是特定模型及适配训练结果,不能简化成只换公式就普遍无损。 6.2 三种不同的“衰减”要分清 第一种量是独立各向同性高斯 $q,k$ 的内积分布。正交旋转后 $R_\Delta k$ 与 $k$ 同分布且仍独立于 $q$,所以整个分布不变。rope_decay.py 的 2000 次独立采样给出绝对内积均值: 不加 RoPE 6.2076 gap=1 6.3182 gap=64 6.2415 gap=1024 6.2583 gap=4000 6.4830 这些数的波动来自有限采样,不能据某一个值略高或略低得出趋势;也不能把各向同性高斯的结论套到所有输入上。 第二种量是固定相似向量的分数。例如 $q=k$ 时,第 $i$ 对的交叉项抵消,得到: $$q^\top R_\Delta q=\sum_i(q_{2i}^2+q_{2i+1}^2)\cos(\Delta\theta_i)$$ 全 1、64 维向量在 gap=0/1/16/64/1024/4000 时依次为 64、61.8337、38.7456、27.8122、16.8272、0.0989。seed=7 的所选随机自配对向量从 50.96 降至约 0.97,也会在部分距离回升。有限采样点的下降不是普遍衰减定律:只打开第 0 个坐标、其余置零,分数就是 $\cos\Delta$,gap=0/3/6 为 1、−0.989992、0.960170。距离增加,分数可以回升、变负,再接近原值;这个整数坐标反例由 rope_validation.py 实跑。 第三种量是图 3 的与未旋转分数的相关性。对独立标准高斯输入,令 $S_\Delta=q^\top R_\Delta k$、$S_0=q^\top k$,均值都为 0。展开求和并用 $E[q_aq_b]=E[k_ak_b]=\delta_{ab}$,只留下相同索引的项,所以: $$\operatorname{Cov}(S_\Delta,S_0)=\operatorname{tr}(R_\Delta)=2\sum_i\cos(\Delta\theta_i),\quad \operatorname{Var}(S_\Delta)=\operatorname{Var}(S_0)=d$$ $$\operatorname{corr}(S_\Delta,S_0)=\frac{2}{d}\sum_i\cos(\Delta\theta_i)$$ 这个相关系数下降不代表分数幅度下降。独立核验 seed=19、20000 对向量、gap=64,理论值 0.434565,样本值 0.425446;样本误差不应伪装成精确相等。有限余弦和可能振荡,也没有这里所证明的“无穷远必趋零”性质。原 RoFormer §3.4.3 还用 Abel 求和讨论上界。令 $M=d/2$、$a_i=q_i^{\mathbb C}\overline{k_i^{\mathbb C}}$(即第 $i$ 对坐标的复数乘积)、$a_M=0$,并令 $S_j=\sum_{i=0}^{j-1}e^{\mathrm{i}(m-n)\theta_i}$,这里 $\mathrm{i}$ 是虚数单位: $$\left|\sum_{i=0}^{M-1}a_i e^{\mathrm{i}(m-n)\theta_i}\right|=\left|\sum_{j=1}^{M}S_j(a_{j-1}-a_j)\right|\leq\max_{1\leq j\leq M}|a_{j-1}-a_j|\sum_{j=1}^{M}|S_j|$$ 它把内容系数的变化与部分相位和分开;上界的示意行为不等于每对输入的实际分数都单调下降。rope_validation.py 独立核验这个恒等变形和不等式。原文的分析、本文的相关性和固定向量分数需要分别阅读,也不能据此把真实视频身份漂移归因给 RoPE。 6.3 相对位置不排除绝对锚点 RoPE 用绝对坐标产生旋转,固定内容的成对点积才只依赖差值。若有固定在位置 0 的 BOS、首帧或条件 token,那么“到锚点的相对距离”已经提供位置线索。因果掩码、边界和其他条件也可能提供线索;这不等于模型必然学会准确计数。需要指定时间戳或特殊首帧时,应确认坐标、掩码和条件机制如何共同表达任务,而不能简单断言 RoPE 无法表达绝对位置。 6.4 算术很少,不等于运行开销永远很少 统一口径:一次乘法加一次加法计 2 FLOPs,batch=1、$N=90000$、$H=24$ 个 query/key 头、head_dim $d=64$、$L=40$ 层,Q 和 K 都全维旋转,非 GQA。每个二维平面做 4 乘 2 加,即每个向量 $3d$ FLOPs: $$F_{\text{RoPE}}=6LNHd=33.1776\;\text{GFLOPs}$$ 同口径下,完整双向稠密注意力的两个矩阵乘 $QK^\top$ 和 $AV$ 共计: $$F_{\text{attention}}=4LHN^2d=1990.656\;\text{TFLOPs}$$ 旋转算术占比约 0.001667%。原文的 995.3 是这两个矩阵乘的 TMAC 近似量,不能直接写成 TFLOPs 再与旋转 FLOPs 相除。这里只数上述主项,不含线性投影、softmax、掩码、三角函数和访存;因果有效三角区域也不能照抄完整方阵预算。 cos/sin 按半维各存一张 FP32 表时是 $Nd\times4=23040000$ bytes,约 21.97 MiB;铺满 dim 是 46080000 bytes,约 43.95 MiB,BF16 存储再减半。这是可选预计算布局,不是所有实现都分配这张表;频率、坐标及 dtype 相同时才可共享。 独立旋转 kernel 还要读写 Q/K。FP16 下,仅读写这两者一次的理想最低数据量为 $4NHd\times2=1105920000$ bytes/层,未算表和中间张量;融合可减少额外往返。增量解码的新 query 对历史 cache 做注意力时,算量比例也不同于训练的整块 $N^2$。因此短序列、长 cache、kernel launch、融合、内存布局都要实际 profiling,不能从这笔 FLOPs 账得出“选 RoPE 从来不是性能问题”。 什么时候考虑其他方案:任务是否需要相对关系、模型是否已有训练好的位置机制、目标长度与硬件约束,决定采用或保留哪种方案。固定短序列仍可能从 RoPE 获益;也不能把“训练短、推理长”当成它唯一的价值。 07. 经典论文脉络 这里按问题串联,不把不同机制压成“都只改同一组频率”: RoFormer: Enhanced Transformer with Rotary Position Embedding(2021-04):引入 RoPE,用旋转构造相对位置点积。原文还讨论长距离行为;阅读时要区分它分析的量与本文三种数值统计。 Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation(ALiBi,2021-08):直接在分数上加距离偏置,是另一类位置机制;论文外推结果针对所测语言模型,不能泛化成所有任务更强。 A Length-Extrapolatable Transformer(xPos,2022-12):除了讨论注意力分辨率,还提出带尺度因子的 xPos 机制;不能概括成只提供评价指标,也不属于本文单位幅度纯旋转。 Extending Context Window of Large Language Models via Position Interpolation(PI,2023-06):缩小坐标后做适配训练;坐标插值的几何推导与论文模型的长上下文结果要分开。 YaRN: Efficient Context Window Extension of Large Language Models(2023-08,本文核对 v2):结合按频段的调整与注意力温度修正。它不是只换 base;本文没有复现其质量实验。 7.1 视频侧这条支线 Scalable Diffusion Models with Transformers(DiT,2022-12):原模型采用固定 2D sincos 绝对位置嵌入。它能用公式产生位置编码;这和前文“学出来的一张有限位置表”不同。改变分辨率后的质量也需验证。 CogVideoX: Text-to-Video Diffusion Models with An Expert Transformer(2024-08):把三维位置编码放进视频模型;5.6 节是选定 Diffusers 函数的具体对照。 HunyuanVideo: A Systematic Framework For Large Video Generative Models(2024-12):原文明确把 Q/K 特征按时间、高、宽拆成三段,分别注入坐标。Wan: Open and Advanced Large-Scale Video Generative Models(2025-03)则提供另一个视频 DiT 架构例子,其报告 §4.2 的 VAE 压缩与 (1,2,2) patchify 正好可对照第 01 节的尺寸账本;这里不把该报告当成具体 RoPE 频率配置的证据。轴维度、base、坐标与训练范围应分别查对应实现,不能据几个例子宣布所有视频 DiT 都采用同一配置。 语言侧的插值思路可以成为视频各轴的候选策略,但不代表全部外推方法可直接无损移植。对 01 节的网格,时间/高/宽的长度比是 25/13、45/30、80/52;若按末坐标精确对齐则是 24/12、44/29、79/51。先决定每轴的坐标约定,再做模型消融与质量验收;总 token 数比 90000/20280 不应直接作为三个轴共同的插值系数。 08. 常见误解 「RoPE 是加在 embedding 上的」。 不是。它作用在每一层注意力的 $q$、$k$ 上,而且是乘法(旋转)。$v$ 在本文与常用实现里不旋转;存在其他变体,不能把所有旋转 value 的方案都判为错误。把它当成输入层的一次性操作,是最常见的理解错误。 「调大 rope_theta 就能延长上下文」。 公式能算到新位置,不保证质量。调大 base 不改最高频 $\theta_0=1$,其他组按不同幅度减慢;不能说所有高频一起降速,也不能由几何比例推出短距离任务必然下降。要说明固定还是动态策略、训练或适配条件,以及缓存如何处理。 「相对位置不变性意味着模型对绝对位置完全无感」。 不成立。注意力有 causal mask,第 0 个 token 只能看到自己,第 100 个能看到 100 个——这种「可见范围」本身就泄露了绝对位置。研究里管这叫隐式位置信息。 「间距相同分数就一定相同」。 还必须固定同一对内容向量 q、k;真实模型里的它们也会随上下文变化。真实模型里中间还夹着 softmax 归一化和 causal mask,同一对 token 在不同上下文里拿到的注意力权重是不同的。04 节验证的是旋转这一步的数学性质,不是整个注意力层的行为。 「RoPE 自带远程衰减」需要限定输入与统计口径。 独立各向同性高斯输入的内积分布不随旋转改变;所选相似向量可能在某些距离明显降低、在另一些距离回升。示例不是普遍衰减定律,也不能单独证明长视频质量的因果关系。 09. 动手验证 四组不需要模型权重的 CPU 小实验,用到的脚本全文都在文末附录,除绘图需要 matplotlib 外,下面的教学数值脚本只依赖 NumPy,复制存成同名文件即可运行。新增 rope_validation.py 还需把它导入的三个同目录脚本一并保存。官方源码 CPU 对照另在本地质检证据中记录,不是读者运行这组实验的依赖。另外跑一遍 make_figures.py 可以把本文三张配图重新生成(这个需要 matplotlib,其余脚本只要 NumPy)。 实验一:看外推在哪一步崩。 跑 rope_minimal.py,把 seq_len 从 8 改成 4096,然后对比位置 (0, 1) 和位置 (4000, 4001) 的内积。预期结果是两者在浮点误差内相等——因为固定频率的数学性质对任意位置都成立。这只能说明该性质并未失效,不能单凭它诊断真实模型外推失败的原因。这是本文最容易被误解的一点,值得亲手确认一次。 实验二:验证配对方式不能混用。 跑 rope_pairing.py,观察第 2、3 段输出。预期看到同一份权重下两种配对差 5.34,而重排维度后差值精确为 0。把 dim 改成 128 再跑一次,结论不变——这是结构性的,与维度大小无关。 实验三:亲手推翻一个常识。 跑 rope_decay.py,重点看 2a 和 2b 的差别。预期看到独立各向同性高斯输入的绝对内积均值只在采样误差内波动;全 1 自配对向量在所选 gap=4000 点下降约 99.8%,这不是所有 $q=k$ 都如此。再跑 rope_validation.py,看单频反例从 gap=3 的 −0.989992 回升到 gap=6 的 0.960170,并核对相关性公式。然后把 2b 里的向量换成两个弱相关的向量试试(比如 k = 0.5 * q + 0.5 * rng.standard_normal(dim)),看衰减幅度怎么随相似度变化——这是理解 06 节那笔账的最快路径。 实验四:给自己的模型算外推预算。 跑 rope_extrapolate.py,把 SCALE 从 8 改成某一个轴按指定坐标规则计算出的倍数(第 7.1 节给出三轴示例),看 PI 和 NTK 各自在近距离分辨率上要付多少。预期规律是 PI 的保留率恒等于 $1/\text{scale}$,而 NTK 的保留率在高频维度接近 1、随维度下降。这些频率变化只能说明位置编码的几何差异,不能直接预测生成质量。如果任务依赖纹理、口型等近距离细节,应在目标分辨率上比较 PI、NTK 与必要的适配训练,结合质量和长距离一致性决定方案。 10. 延伸阅读 读这篇之前建议先看: 自注意力机制的计算与显存账本——RoPE 作用在 $q$、$k$ 上,得先清楚它们在注意力里的位置 读完这篇可以继续看: 视频 DiT 里的 3D RoPE 与分辨率外推——5.6 节那个 1:1.5:1.5 的示例维度分配,以及分辨率和帧数变化如何分别影响各轴相位 FlashAttention 为什么不需要存下注意力矩阵——RoPE 之后紧接着的那一步计算 附录:完整代码 09 节用到的脚本全文如下(make_figures.py、rope_validation.py、rope_decay.py、rope_minimal.py、rope_extrapolate.py、rope_pairing.py)。复制到本地存成同名文件,按各脚本开头的依赖说明准备环境后即可运行。 make_figures.py #!/usr/bin/env python3 # -*- coding: utf-8 -*- """生成「旋转位置编码 RoPE」的三张解释图。 数值全部来自同目录的三个脚本(rope_minimal / rope_extrapolate / rope_decay), 这里只负责画——改了那边这里要重跑,免得图和正文数字打架。 三张图分别回答: 1. 32 个频率是怎么分层铺在 64 维上的(高频相位在近距离变化更快) 2. PI 与 NTK-aware 把这 8 倍外推的代价分别摊到了哪些频段 3. 「相对位置不变」和「随距离衰减」在数值上长什么样 只依赖 numpy + matplotlib。跑法:python make_figures.py """ import textwrap from pathlib import Path import matplotlib.pyplot as plt import numpy as np import rope_decay as RD import rope_extrapolate as RE ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "figures" OUT.mkdir(parents=True, exist_ok=True) plt.rcParams.update({ "font.sans-serif": ["Microsoft YaHei", "SimHei", "DejaVu Sans"], "axes.unicode_minus": False, "figure.dpi": 160, "savefig.bbox": "tight", }) INK = "#1f2937" MUTE = "#6b7280" C_ORIG = "#2f6fb0" # 原始 RoPE:蓝 C_PI = "#d1495b" # PI:红 C_NTK = "#2f9e6f" # NTK-aware:绿 C_BAD = "#d1495b" C_OK = "#2f9e6f" def style(ax, title, xlabel=None, ylabel=None): ax.set_title(title, fontsize=11.5, color=INK, pad=10, loc="left") if xlabel: ax.set_xlabel(xlabel, fontsize=10, color=MUTE) if ylabel: ax.set_ylabel(ylabel, fontsize=10, color=MUTE) ax.tick_params(colors=MUTE, labelsize=9) for s in ("top", "right"): ax.spines[s].set_visible(False) for s in ("left", "bottom"): ax.spines[s].set_color("#d1d5db") ax.grid(alpha=0.25, linewidth=0.6) ax.set_axisbelow(True) def footer(fig, text, width=118): """把「这张图要看什么」放到坐标轴下方。 必须放在 y<0 的位置:bbox_inches="tight" 会把负坐标的 artist 一起收进来, 放在 0~0.05 之间的话会和 x 轴标签叠在一起。 """ wrapped = "\n".join(textwrap.wrap(text, width=width)) fig.text(0.012, -0.13, wrapped, fontsize=8.5, color=MUTE, va="top", ha="left", linespacing=1.6) # ────────────────────────────────────────────────────────────────────── # 图 1:32 个频率怎么分层铺在 64 维上 # ────────────────────────────────────────────────────────────────────── def fig_freq_layers(): dim = RE.DIM th = RE.theta_original() # [dim/2] pos_short = np.arange(0, 256) pos_long = np.arange(0, 32768, 8) cos_short = np.cos(np.outer(pos_short, th)) # [256, dim/2] phase_long = np.outer(pos_long, th) # 累积相位(不取模) fig, axes = plt.subplots(1, 2, figsize=(12.8, 4.4)) fig.subplots_adjust(wspace=0.42) ax = axes[0] im = ax.imshow(cos_short.T, aspect="auto", origin="lower", cmap="RdBu_r", vmin=-1, vmax=1, extent=[0, len(pos_short), -0.5, len(th) - 0.5]) ax.set_yticks([0, 8, 16, 24, 31]) cb = fig.colorbar(im, ax=ax, fraction=0.035, pad=0.02) cb.ax.tick_params(labelsize=8, colors=MUTE) cb.set_label(r"$\cos(m\theta_i)$", fontsize=9, color=MUTE) style(ax, "近距离(0~255):前 13 组完成至少一圈", "位置 m", "维度组 i") # 在 256 个位置内转满一整圈(2π)的维度组数 n_moving = int((pos_short[-1] * th / (2 * np.pi) >= 1).sum()) ax.annotate(f"256 长度内转满一整圈的只有前 {n_moving} 组", xy=(128, n_moving - 1), xytext=(12, n_moving + 7), fontsize=8.5, color="white", fontweight="bold", arrowprops=dict(arrowstyle="->", color=MUTE, lw=0.9)) ax = axes[1] for i, c in zip([0, 4, 8, 16, 24, 31], ["#1f6feb", "#2f9e6f", "#e0a03c", "#d1495b", "#8b5cf6", "#6b7280"]): ax.plot(pos_long, phase_long[:, i], lw=1.8, color=c, label=f"i={i}") trained = (RE.TRAIN_LEN - 1) * th[0] ax.axhline(trained, color=INK, ls="--", lw=1.2) ax.text(600, trained * 1.35, f"最高频训练最大相位 {trained:.0f} rad", fontsize=8.5, color=INK) ax.set_xscale("log") ax.set_yscale("log") ax.legend(fontsize=8.5, frameon=False, loc="upper left", ncol=3) style(ax, "远距离(到 32768):各频段的累积相位", "位置 m(对数轴)", r"累积相位 $m\theta_i$(rad,对数轴)") fig.suptitle("图 1 RoPE 把 64 维切成 32 组,从「每步 1 弧度」一路排到「每步约 1.33e-4 弧度」", fontsize=12, color=INK, x=0.012, ha="left", y=1.02) footer(fig, "左图:0 到 255 的相位差中,只有前 13 组超过一整圈;低频也有非零相位变化。" "右图:不同频率提供不同尺度,不是互斥的距离分工。虚线只代表最高频的训练相位上限;" "每个频段的训练相位上限都不同。") fig.savefig(OUT / "rope_freq_layers.png") plt.close(fig) print(f"[图1] 相位变化>=2π 的维度组数(0→255): {n_moving}/{len(th)}") # ────────────────────────────────────────────────────────────────────── # 图 2:PI 与 NTK-aware 把 8 倍外推的代价摊在哪 # ────────────────────────────────────────────────────────────────────── def fig_extrapolation(): t_orig, t_pi, t_ntk = (RE.theta_original(), RE.theta_pi(), RE.theta_ntk()) idx = np.arange(len(t_orig)) slow_pi = t_orig / t_pi slow_ntk = t_orig / t_ntk keep_pi = t_pi / t_orig * 100 keep_ntk = t_ntk / t_orig * 100 fig, axes = plt.subplots(1, 2, figsize=(11.6, 4.3)) ax = axes[0] ax.plot(idx, slow_pi, color=C_PI, lw=2.4, label=r"PI:各维一律减速 8.00x") ax.plot(idx, slow_ntk, color=C_NTK, lw=2.0, label=r"NTK-aware:1.00x $\to$ 8.00x") ax.fill_between(idx, slow_ntk, slow_pi, color=C_PI, alpha=0.10) ax.set_yscale("log") ax.set_yticks([1, 2, 4, 8]) ax.yaxis.set_minor_formatter(plt.NullFormatter()) ax.get_yaxis().set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:g}x")) ax.legend(fontsize=9, frameon=False, loc="center right") style(ax, "谁被减速了多少(对数轴)", "维度组 i", r"$\theta_i^{\mathrm{orig}} / \theta_i^{\mathrm{new}}$") ax = axes[1] ax.plot(idx, keep_pi, color=C_PI, lw=2.4, label="PI:全线只剩 12.5%") ax.plot(idx, keep_ntk, color=C_NTK, lw=2.0, label=rf"NTK:{keep_ntk[0]:.0f}% $\to$ {keep_ntk[16]:.1f}%(i=16)") ax.fill_between(idx, keep_pi, keep_ntk, color=C_NTK, alpha=0.10) ax.axhline(100, color=MUTE, ls=":", lw=1.0) ax.set_yscale("log") ax.set_yticks([10, 20, 50, 100]) ax.get_yaxis().set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:g}%")) ax.legend(fontsize=9, frameon=False, loc="lower left") style(ax, "代价:相邻 token 的相位步长还剩多少", "维度组 i", r"$\theta_i^{\mathrm{new}} / \theta_i^{\mathrm{orig}}$") fig.suptitle("图 2 PI 把减速平均分给每一维,NTK 从高频不压缩逐渐过渡到低频压缩 8 倍", fontsize=12, color=INK, x=0.012, ha="left", y=1.02) footer(fig, "要看什么:左图 PI 是一条平线(每维都慢 8 倍),NTK 从最高频的 1.00x 单调爬到" "最低频的 8.00x。右图是这个分配的代价——PI 把每一维的相位步长都砍到 12.5%," "这只是相位步长,不能直接预测任务质量;NTK 保住高频(100%),最低频与 PI 同为 12.5%。" "两者改的是同一组 theta,区别只在把代价放在哪个频段。") fig.savefig(OUT / "rope_extrapolation.png") plt.close(fig) print(f"[图2] NTK 减速比: i=0 {slow_ntk[0]:.2f}x -> i=31 {slow_ntk[-1]:.2f}x;" f"相位步长保留 i=16: PI {keep_pi[16]:.1f}% / NTK {keep_ntk[16]:.1f}%") # ────────────────────────────────────────────────────────────────────── # 图 3:相对位置不变 + 随距离衰减 # ────────────────────────────────────────────────────────────────────── def fig_relativity(): dim, seq_len = 64, 8192 cos, sin = RD.build_rope_cache(seq_len, dim) rng = np.random.default_rng(0) # ── 左:同一对 q,k,只平移绝对位置,内积应当逐点不变 ── q = rng.standard_normal(dim) k = rng.standard_normal(dim) gaps = np.arange(0, 512) starts = [0, 500, 2000] curves = {} for s in starts: qr = RD.apply_rope(q[None, :], cos[s:s + 1], sin[s:s + 1])[0] kr_all = RD.apply_rope( np.repeat(k[None, :], len(gaps), 0), cos[s:s + len(gaps)], sin[s:s + len(gaps)]) curves[s] = kr_all @ qr # ── 右:距离越远,RoPE 把「无位置编码的原始内积」改写得多彻底 ── # 独立各向同性高斯输入:corr(q^T R_gap k, q^T k) = (2/dim) Σ_i cos(gap θ_i)。 # 注意:这是「与原始内积的相关性」衰减,不是内积的绝对值变小—— # 独立各向同性高斯输入的内积分布与距离无关(RMS 为 sqrt(dim))。 Q = rng.standard_normal((4000, dim)) K = rng.standard_normal((4000, dim)) s0 = np.einsum("ij,ij->i", Q, K) th = 1.0 / (10000.0 ** (np.arange(0, dim, 2) / dim)) probe = np.unique(np.round(np.geomspace(1, 4096, 60)).astype(int)) emp, theo = [], [] for g in probe: qr = RD.apply_rope(Q, cos[0:1], sin[0:1]) kr = RD.apply_rope(K, cos[g:g + 1], sin[g:g + 1]) sg = np.einsum("ij,ij->i", qr, kr) emp.append(float(np.corrcoef(sg, s0)[0, 1])) theo.append(float(2.0 / dim * np.sum(np.cos(g * th)))) fig, axes = plt.subplots(1, 2, figsize=(11.6, 4.3)) ax = axes[0] cols = {0: C_ORIG, 500: C_NTK, 2000: "#e0a03c"} for s in starts: ax.plot(gaps, curves[s], lw=1.6, color=cols[s], label=f"绝对起点 {s}") ax.legend(fontsize=9, frameon=False, loc="upper right") style(ax, "同一对向量、同一组间距,换绝对位置后曲线完全重合", "相对距离 (n-m)", r"$q^\top R_{n-m} k$") ax = axes[1] ax.plot(probe, emp, marker="o", ms=3.5, lw=1.8, color=C_ORIG, label="实测(4000 对随机向量)") ax.plot(probe, theo, lw=1.4, ls="--", color=C_BAD, label=r"理论 $\frac{2}{D}\sum_i\cos(\Delta\theta_i)$") ax.axhline(0, color=MUTE, ls=":", lw=1.0) ax.set_xscale("log") ax.legend(fontsize=9, frameon=False, loc="upper right") style(ax, "独立高斯输入:与原始内积的相关性随距离振荡", "相对距离(对数轴)", r"$\mathrm{corr}(q^\top R_{\Delta} k, q^\top k)$") fig.suptitle("图 3 固定内容的相对位置不变性,与高斯输入下的分数相关性", fontsize=12, color=INK, x=0.012, ha="left", y=1.02) max_dev = max(float(np.abs(curves[s] - curves[0]).max()) for s in starts) footer(fig, f"左图:固定 q/k 的三条曲线在浮点误差内重合,最大偏差 {max_dev:.1e},不是逐比特相等。" "右图:D=64、Δ 为间距,理论相关性是有限余弦和;它会振荡,不保证单调或在无穷远趋于零。" "本图的独立各向同性高斯输入下,内积分布不随旋转改变。") fig.savefig(OUT / "rope_relativity.png") plt.close(fig) print(f"[图3] 不同绝对起点的最大逐点偏差: {max_dev:.2e};" f"corr: d=1 {emp[0]:.3f} → d={probe[-1]} {emp[-1]:.3f}") def main(): fig_freq_layers() fig_extrapolation() fig_relativity() print(f"[OK] 三张图已写入 {OUT}") for f in sorted(OUT.glob("*.png")): print(f" {f.name} {f.stat().st_size / 1024:.0f} KB") if __name__ == "__main__": main() rope_validation.py """RoPE 独立数学核验与反例;只需 NumPy,运行 python rope_validation.py。""" import json from pathlib import Path import numpy as np from rope_minimal import build_rope_cache, apply_rope from rope_extrapolate import theta_original, theta_pi, theta_ntk from rope_pairing import rope_interleaved, rope_half_split, interleave_to_half def validate(): rng = np.random.default_rng(19) dim = 64 q, k = rng.normal(size=(2, dim)) th = theta_original(dim) def rotate(x, p): angle = p * th return apply_rope(x, np.cos(angle), np.sin(angle)) m, n = 127, 4021 actual = float(rotate(q, m) @ rotate(k, n)) relative = float(q @ rotate(k, n - m)) zq, zk = q[0::2] + 1j*q[1::2], k[0::2] + 1j*k[1::2] complex_score = float(np.real(np.sum(zq*np.conj(zk)*np.exp(1j*(m-n)*th)))) assert max(abs(actual-relative), abs(actual-complex_score)) < 1e-11 # Abel summation: a finite upper bound does not assert monotonic scores. a = zq*np.conj(zk) a_next = np.concatenate((a[1:], [0])) partial = np.cumsum(np.exp(1j*(m-n)*th)) abel = np.sum(partial*(a-a_next)) direct = np.sum(a*np.exp(1j*(m-n)*th)) assert abs(abel-direct) < 1e-11 abel_bound = float(np.max(abs(a-a_next))*np.sum(abs(partial))) assert abs(direct) <= abel_bound cq, sk = build_rope_cache(8192, dim) assert abs(np.linalg.norm(apply_rope(q, cq[4096], sk[4096]))-np.linalg.norm(q)) < 1e-12 perm_error = max(float(np.max(np.abs(interleave_to_half(rope_interleaved(q,p,dim))-rope_half_split(interleave_to_half(q),p,dim)))) for p in [0,1,127,4096]) assert perm_error < 1e-12 # Independent exact moment calculation: Cov(q^T R k,q^T k)=tr(R), Var=d. gap = 64 rot = np.column_stack([rotate(v, gap) for v in np.eye(dim)]) trace_correlation = float(np.trace(rot)/dim) formula_correlation = float(2*np.cos(gap*th).sum()/dim) assert abs(trace_correlation-formula_correlation) < 1e-14 Q, K = rng.normal(size=(2, 20000, dim)) scores0 = np.einsum('ij,ij->i', Q, K) sg = np.einsum('ij,ij->i', Q, apply_rope(K,cq[gap],sk[gap])) corr = float(np.corrcoef(scores0,sg)[0,1]) assert abs(corr-formula_correlation) < .03 # One active plane: scores return upward at integer gap 6; no universal decay. one = np.zeros(dim);one[0] = 1 counter = {str(p): float(one@rotate(one,p)) for p in [0,3,6]} assert counter['6'] > .95 and counter['3'] < -.98 # A fixed key at coordinate 0 is an anchor; translating only q changes its score. anchor = {str(p): float(rotate(one,p)@one) for p in [0,1,2]} assert anchor['0'] != anchor['1'] s = 8 pi, ntk = theta_pi(scale=s), theta_ntk(scale=s) expected_ntk = th*s**(-2*np.arange(dim//2)/(dim-2)) assert np.allclose(ntk,expected_ntk,rtol=1e-14,atol=0) exact_scale = (32768-1)/(4096-1) endpoint = (32768-1)/exact_scale assert endpoint == 4095 # Standalone accounting: multiply+add=2 FLOPs; full QK^T and AV, no masks. N,H,d,L = 90000,24,64,40 rotation = 6*N*H*d*L attention = 4*N*N*H*d*L results = dict(sign_identity_abs=max(abs(actual-relative),abs(actual-complex_score)), abel_identity_abs=float(abs(abel-direct)),abel_bound=abel_bound, pairing_vector_max_abs=perm_error,lowest_frequency=float(th[-1]),lowest_period=float(2*np.pi/th[-1]), gaussian_correlation=dict(samples=20000,seed=19,gap=gap,theory=formula_correlation,empirical=corr), nonmonotonic_counterexample=counter,fixed_anchor_scores=anchor, ntk_lowest_retention=float(ntk[-1]/th[-1]),pi_highest_retention=float(pi[0]/th[0]), exact_endpoint_scale=exact_scale,exact_endpoint=endpoint, video=dict(train_grid=[13,30,52],target_grid=[25,45,80],train_tokens=13*30*52,target_tokens=25*45*80, axis_length_ratios=[25/13,45/30,80/52],axis_endpoint_ratios=[24/12,44/29,79/51]), cost=dict(rotation_flops=rotation,dense_attention_flops=attention,rotation_percent=100*rotation/attention, half_table_bytes=N*d*4,full_table_bytes=2*N*d*4,qk_fp16_min_read_write_per_layer_bytes=4*N*H*d*2), environment=dict(numpy=np.__version__,dtype='float64',hardware='CPU'),passed=True) return results if __name__ == '__main__': result = validate() print(json.dumps(result,ensure_ascii=False,indent=2)) Path(__file__).with_name('validation_results.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf8') rope_decay.py """观测 RoPE 的两个常被口头断言、但很少有人实测的性质。 1. 旋转不改变模长(对比加法式绝对位置嵌入) 2. 所选输入的内积随距离变化;并非普遍衰减定律 运行 `python rope_decay.py` 复现。 """ import numpy as np def build_rope_cache(seq_len, dim, base=10000.0): inv_freq = 1.0 / (base ** (np.arange(0, dim, 2) / dim)) freqs = np.outer(np.arange(seq_len), inv_freq) return np.cos(freqs), np.sin(freqs) def apply_rope(x, cos, sin): out = np.empty_like(x) out[..., 0::2] = x[..., 0::2] * cos - x[..., 1::2] * sin out[..., 1::2] = x[..., 0::2] * sin + x[..., 1::2] * cos return out def demo_norm_preserved(rng, dim=64, seq_len=512): """旋转保模长,加法不保。""" cos, sin = build_rope_cache(seq_len, dim) x = rng.standard_normal(dim) print("=== 1. 模长是否被位置编码改变 ===") print(f" 原始向量模长 {np.linalg.norm(x):.6f}") norms = [np.linalg.norm(apply_rope(x[None, :], cos[p:p + 1], sin[p:p + 1])) for p in (0, 1, 100, 511)] print(f" RoPE 后(位置 0/1/100/511) " f"{' '.join(f'{n:.6f}' for n in norms)}") print(f" 跨位置模长极差 {max(norms) - min(norms):.2e}") # 加法式绝对位置嵌入:同尺度的可学习向量直接相加 pe = rng.standard_normal((seq_len, dim)) add_norms = [np.linalg.norm(x + pe[p]) for p in (0, 1, 100, 511)] print(f" 加法式 PE 后 " f"{' '.join(f'{n:.6f}' for n in add_norms)}") print(f" 跨位置模长极差 " f"{max(add_norms) - min(add_norms):.4f} ← 同一个 token 的模长被位置改写了") def _score(cos, sin, q, k, gap): qr = apply_rope(q[None, :], cos[0:1], sin[0:1]) kr = apply_rope(k[None, :], cos[gap:gap + 1], sin[gap:gap + 1]) return float(qr[0] @ kr[0]) GAPS = (1, 4, 16, 64, 256, 1024, 4000) def demo_long_range_decay(rng, dim=64, seq_len=4096, trials=2000): """「RoPE 自带远程衰减」到底成不成立——分两种输入分布看。 结论:本文独立各向同性高斯 q/k 的内积分布不变;所选 q=k 示例会振荡。 """ cos, sin = build_rope_cache(seq_len, dim) print("\n=== 2a. 随机无关的 q/k:取 |内积| 均值 ===") baseline = float(np.mean([abs(float(rng.standard_normal(dim) @ rng.standard_normal(dim))) for _ in range(trials)])) print(f" 不加 RoPE {baseline:.4f} (基线)") for gap in GAPS: m = float(np.mean([abs(_score(cos, sin, rng.standard_normal(dim), rng.standard_normal(dim), gap)) for _ in range(trials)])) print(f" 相对距离 {gap:>5} {m:.4f} ({m / baseline * 100:5.1f}% of 基线)") print(" → 没有衰减趋势。独立各向同性高斯输入在正交旋转下分布不变。") print("\n=== 2b. q = k(高度相似的一对 token)===") for name, vec in (("全 1 向量", np.ones(dim)), ("某个随机向量", np.random.default_rng(7).standard_normal(dim))): # gap=0 时位置 0 的旋转角为 0,内积就是未加 RoPE 的原值 head = _score(cos, sin, vec, vec, 0) print(f" {name}(gap=0 时内积 {head:.2f}):") for gap in GAPS: s = _score(cos, sin, vec, vec, gap) print(f" 相对距离 {gap:>5} {s:+9.4f} ({s / head * 100:5.1f}% of gap=0)") print(" → 这些采样点多低于 gap=0,也有回升;不代表任意输入或距离都衰减。") if __name__ == "__main__": demo_norm_preserved(np.random.default_rng(0)) demo_long_range_decay(np.random.default_rng(1)) rope_minimal.py #!/usr/bin/env python3 # -*- coding: utf-8 -*- """RoPE 的最小可运行实现,验证「注意力分数只依赖相对位置」这一核心性质。 只依赖 numpy,直接 python rope_minimal.py 即可运行。 刻意不用 PyTorch:讲原理时框架的抽象反而是噪声,而且 numpy 谁都能跑。 生产实现见文章第 05 节对 transformers 的引用。 """ import numpy as np def build_rope_cache(seq_len: int, dim: int, base: float = 10000.0): """预计算 RoPE 的 cos/sin 表。 dim 必须是偶数:每两个相邻维度配成一个复数参与旋转。 base 就是常说的 rope_theta,调它会按维度改变旋转速度;最高频 i=0 始终为 1。 """ assert dim % 2 == 0, f"dim 必须为偶数,收到 {dim}" # theta_i = base^(-2i/dim):i 越大转得越慢,对应越低的频率 inv_freq = 1.0 / (base ** (np.arange(0, dim, 2) / dim)) pos = np.arange(seq_len) freqs = np.outer(pos, inv_freq) # [seq_len, dim/2] return np.cos(freqs), np.sin(freqs) def apply_rope(x: np.ndarray, cos: np.ndarray, sin: np.ndarray) -> np.ndarray: """把旋转作用到 x 上。x: [seq_len, dim] 最后一维两两配对看成复数 (x_even + i*x_odd),乘上 e^{i*m*theta}: 实部 = x_even * cos - x_odd * sin 虚部 = x_even * sin + x_odd * cos """ x_even, x_odd = x[..., 0::2], x[..., 1::2] out = np.empty_like(x) out[..., 0::2] = x_even * cos - x_odd * sin out[..., 1::2] = x_even * sin + x_odd * cos return out if __name__ == "__main__": rng = np.random.default_rng(0) seq_len, dim = 8, 64 cos, sin = build_rope_cache(seq_len, dim) print(f"cos/sin 形状: {cos.shape} {sin.shape}") print(f"位置 1 的前 4 维 cos: {np.round(cos[1, :4], 4).tolist()}") print(f"位置 1 的末 2 维 cos: {np.round(cos[1, -2:], 6).tolist()}") # 固定一对向量,只改赋予它们的绝对位置,间距始终为 1 q = rng.standard_normal(dim) k = rng.standard_normal(dim) print("\n同一对向量,平移绝对位置(间距恒为 1):") for shift in range(4): qr = apply_rope(q[None, :], cos[shift:shift + 1], sin[shift:shift + 1]) kr = apply_rope(k[None, :], cos[shift + 1:shift + 2], sin[shift + 1:shift + 2]) print(f" 位置 ({shift}, {shift + 1}) → 内积 {float(qr[0] @ kr[0]):.6f}") print("\n对照组,改变间距:") for gap in (1, 2, 3): qr = apply_rope(q[None, :], cos[0:1], sin[0:1]) kr = apply_rope(k[None, :], cos[gap:gap + 1], sin[gap:gap + 1]) print(f" 间距 {gap} → 内积 {float(qr[0] @ kr[0]):.6f}") print(f"\n未加 RoPE 的原始内积: {float(q @ k):.6f}") rope_extrapolate.py """两种长度外推方法(PI / NTK-aware)到底改了什么。 结论先说:它们改的是同一组 theta_i,区别只在「怎么分配这次减速」。 运行 `python rope_extrapolate.py` 复现。 """ import numpy as np DIM = 64 BASE = 10000.0 TRAIN_LEN = 4096 # 训练时见过的长度 SCALE = 8 # 想外推到 8 倍,即 32768 def theta_original(dim=DIM, base=BASE): return 1.0 / (base ** (np.arange(0, dim, 2) / dim)) def theta_pi(dim=DIM, base=BASE, scale=SCALE): """Position Interpolation:位置索引整体除以 scale。 等价于把每个 theta_i 都乘上 1/scale——所有频率被同等减速。 """ return theta_original(dim, base) / scale def theta_ntk(dim=DIM, base=BASE, scale=SCALE): """NTK-aware:把 base 放大,让减速量随维度索引递增(频率越低减速越多)。 base' = base * scale^(d/(d-2)),于是高频维度几乎不受影响, 最低频维度与 PI 同样减速 scale 倍,其余维度的减速更少。 """ if dim <= 2 or dim % 2 or scale <= 0 or base <= 0: raise ValueError("NTK-aware requires even dim > 2, positive scale and base") new_base = base * scale ** (dim / (dim - 2)) return theta_original(dim, new_base) def main(): t_orig, t_pi, t_ntk = theta_original(), theta_pi(), theta_ntk() print("=== 1. 三种方案下各维度的旋转速度 theta_i ===") print(" 维度 i 原始 PI(÷8) NTK-aware PI减速比 NTK减速比") for i in (0, 1, 4, 8, 16, 24, 31): print(f" {i:>4} {t_orig[i]:.3e} {t_pi[i]:.3e} {t_ntk[i]:.3e}" f" {t_orig[i] / t_pi[i]:>7.2f}x {t_orig[i] / t_ntk[i]:>7.2f}x") print(f" → PI 对每一维都减速 {t_orig[0] / t_pi[0]:.2f}x(均匀);" f"NTK 从最高频的 {t_orig[0] / t_ntk[0]:.2f}x 递增到最低频的 " f"{t_orig[-1] / t_ntk[-1]:.2f}x(越低频压得越狠)") print("\n=== 2. 外推到 8 倍长度后,各方案在第 0 维(最高频)的相位 ===") target = TRAIN_LEN * SCALE trained_max = (TRAIN_LEN - 1) * t_orig[0] print(f" 训练时见过的最大相位(位置 {TRAIN_LEN - 1}) {trained_max:9.1f} rad") for name, th in (("原始(直接外推)", t_orig), ("PI", t_pi), ("NTK-aware", t_ntk)): v = (target - 1) * th[0] flag = ("落在训练区间内" if v <= trained_max else f"接近但略超训练端点 {trained_max:g}" if v <= trained_max * 1.01 else f"超出 {v / trained_max:.1f} 倍") print(f" {name:<18} 位置 {target - 1} {v:9.1f} rad ← {flag}") print(" → NTK 在最高频维度上「不作为」是刻意的,它保留高频相位步长,逐渐压缩更低频的相位步长") print("\n=== 3. 代价:相位步长还剩多少 ===") print(" (相邻位置差 1 时转过的角度,按维度看。越小表示单个频段角度变化更小;不直接代表任务质量)") print(" 维度 i 原始 PI NTK PI保留 NTK保留") for i in (0, 4, 8, 16): print(f" {i:>4} {t_orig[i]:.6f} {t_pi[i]:.6f} {t_ntk[i]:.6f}" f" {t_pi[i] / t_orig[i] * 100:5.1f}% {t_ntk[i] / t_orig[i] * 100:5.1f}%") print(f" → PI 在每一维都只保留 {t_pi[0] / t_orig[0] * 100:.1f}% 的相位步长;" f"NTK 在第 0 维保留 {t_ntk[0] / t_orig[0] * 100:.1f}%、" f"第 16 维只剩 {t_ntk[16] / t_orig[16] * 100:.1f}%。") print(" 两者都在同一组 theta 上做手脚,只是把代价放在了不同频段——" "\n YaRN 进一步把这个分配变成可调的分段策略。") if __name__ == "__main__": main() rope_pairing.py """对照 RoPE 的两种维度配对方式,并验证它们只差一个固定置换。 奇偶交错(本文最小实现):第 2i 维和第 2i+1 维配成一个复数 前后半切分(HuggingFace):第 i 维和第 i+d/2 维配成一个复数 结论先说:两者都满足相对位置不变性,但对同一份权重不可互换。 运行 `python rope_pairing.py` 复现下面的数值。 """ import numpy as np def rope_interleaved(x, pos, dim, base=10000.0): """奇偶交错配对:(x_0,x_1), (x_2,x_3), ... 每对是一个复平面。""" inv_freq = 1.0 / (base ** (np.arange(0, dim, 2) / dim)) ang = pos * inv_freq # [dim/2] cos, sin = np.cos(ang), np.sin(ang) out = np.empty_like(x) out[0::2] = x[0::2] * cos - x[1::2] * sin out[1::2] = x[0::2] * sin + x[1::2] * cos return out def rope_half_split(x, pos, dim, base=10000.0): """前后半切分配对:(x_i, x_{i+d/2})。等价于 HF 的 rotate_half 写法。 HF 把 cos/sin 表沿最后一维复制两遍(cat((freqs, freqs))), 于是 q*cos + rotate_half(q)*sin 展开后就是下面两行。 """ half = dim // 2 inv_freq = 1.0 / (base ** (np.arange(0, dim, 2) / dim)) ang = pos * inv_freq # [dim/2] cos, sin = np.cos(ang), np.sin(ang) x1, x2 = x[:half], x[half:] return np.concatenate([x1 * cos - x2 * sin, x2 * cos + x1 * sin]) def interleave_to_half(x): """把奇偶交错的排布重排成前后半切分的排布。 交错版的第 2i 维(复数实部)搬到前半的第 i 位, 第 2i+1 维(虚部)搬到后半的第 i 位。 """ return np.concatenate([x[0::2], x[1::2]]) def attn_score(rope_fn, q, k, m, n, dim): return float(rope_fn(q, m, dim) @ rope_fn(k, n, dim)) def main(): rng = np.random.default_rng(0) dim = 64 q = rng.standard_normal(dim) k = rng.standard_normal(dim) print("=== 1. 两种配对都满足相对位置不变性 ===") for name, fn in (("奇偶交错", rope_interleaved), ("前后半切分", rope_half_split)): scores = [attn_score(fn, q, k, m, m + 3, dim) for m in range(4)] spread = max(scores) - min(scores) print(f" {name}: 间距恒为 3 时内积 {scores[0]:.6f}," f"四个绝对位置间的最大波动 {spread:.2e}") print("\n=== 2. 同一份权重下,两种配对给出的分数不同 ===") s_i = attn_score(rope_interleaved, q, k, 0, 3, dim) s_h = attn_score(rope_half_split, q, k, 0, 3, dim) print(f" 奇偶交错 {s_i:.6f}") print(f" 前后半切分 {s_h:.6f}") print(f" 差值 {abs(s_i - s_h):.6f} ← 不为 0,实现和权重必须配套") print("\n=== 3. 重排维度后两者完全一致 ===") qp, kp = interleave_to_half(q), interleave_to_half(k) s_h_perm = attn_score(rope_half_split, qp, kp, 0, 3, dim) print(f" 奇偶交错(原始排布) {s_i:.6f}") print(f" 前后半切分(重排后权重) {s_h_perm:.6f}") print(f" 差值 {abs(s_i - s_h_perm):.2e}" f" ← 只差一个固定的维度置换") if __name__ == "__main__": main()
2026年08月23日
14 阅读
0 评论
0 点赞
1
...
3
4
粤ICP备2021042327号