VJP:向量版链式法则
从标量到向量
标量链式法则:y 经中间量 z 依赖于 x,则 dxdy=dzdy⋅dxdz,两个数相乘完事。
训练时的格局是:损失 L 是一个标量,参数 x 是上万个分量,中间量 z 也是向量。设 x∈Rn,z∈Rm,记雅可比矩阵(Jacobian)
J=∂x∂z∈Rm×n,Jij=∂xj∂zi
向量版链式法则就是把标量乘法换成转置矩阵乘法:
dxdL=J⊤dzdL
左边是 n 维梯度向量,右边是 n×m 矩阵乘 m 维向量。这个「上游梯度乘 J⊤ 得到下游梯度」的操作就叫 VJP(向量-雅可比积,Vector-Jacobian Product)。
JVP 与 VJP 的方向之分
- JVP(雅可比-向量积):算 Jv。给参数一个扰动方向 v,问输出会往哪动——「给方向问结果」,对应前向模式自动微分。
- VJP:算 J⊤v。给输出端一个梯度(余切向量),问每个参数该承担多少责任——「给结果问方向」,对应反向模式自动微分。
训练只有一个标量损失、却有海量参数:前向模式要为每个参数各跑一遍才能得到完整梯度,反向模式一遍逆序扫描就拿到所有参数的梯度,代价只是前向的常数倍。这就是训练必须用 VJP 的原因。
永远不真的造出 J
实现反向传播时从不把 J 这个矩阵显式存下来——上万个参数对上百个中间量,矩阵存不下。代码里写的是「给定 dL/dz,直接算出 J⊤(dL/dz)」的那几行算术。3DGS 反向里的 T 链除回、acc 递推,本质上都是逐个算子的手写 VJP。
执行模型:逆序重放
光栅化是按 tile(16×16 像素块)并行、块内按深度排序后的顺序前到后合成的。反向的执行模型与之镜像:
- 按 tile:每个像素独立反传,tile 之间天然并行;
- 按 order:沿前向的排序序列逆序重放——前向先合成的高斯,反向最后处理。
前向每处理一个高斯只做 O(1) 状态更新,所以逆序重放只需要重建这 O(1) 状态,不需要存整条历史。
参考
前向存档三量:T、C、n_contrib
前向合成的递推结构
α 混合(alpha blending)按深度前到后合成,每像素的循环体只有两行状态更新:
C+=ciαiT,T×=(1−αi)
其中 ci 是第 i 个高斯的颜色,αi 是它在该像素的不透明度,T 是透射率(transmittance),初值 1。T 的物理含义:光穿过前面所有高斯后还剩的比例。
本集算例(沿用 EP03):α=(0.50,0.80,0.95,0.99),c=(0.2,0.7,0.4,0.9),逐项贡献 ciαiTi=(0.100,0.280,0.038,0.004),累计 C=0.422,T 末值 5×10−5。
早退与 n_contrib
前向带早退(early exit):当 T<10−4 时,后面的高斯贡献被透射率压到可忽略,直接终止循环,并把处理到的下标记为 n_contrib。本例 ncontrib=4:第五个高斯被跳过。
n_contrib 是反向的循环边界:反向只重放前 ncontrib 个,之后的高斯梯度一律为零——它们前向就没参与合成,链式法则上导数确实是零。
为什么三个数就够
反向逆序重放时需要重建的每步状态只有 T 和「身后累计量」。关键在于:
- T 链可以靠除法逆推:知道末值 T 和当前 αi,就恢复出进入第 i 步之前的 Ti=T/(1−αi),不需要存链上每个值;
- 「身后累计量」可以边走边用一行递推滚出来(见 dL/dα 的 acc 递推),初值为零,也不需要存;
- αi、ci 本身反向时现场重算:高斯参数(投影协方差、像素偏移)都在,按前向同一公式再算一遍即可,比存档再读回更省显存带宽;
- C 作为 acc 递推的自检终点顺带可用。
这是典型的「时间换显存」:前向每像素只写三个数,反向用少量重算换掉了整条中间链的存储。
容易看错的点
- 存的 T 是末值,不是链。表格里「更新后 T」一列是递推的中间过程,前向并不逐个保存。
- 重算 α 要求前向、反向公式逐位一致。任何一处实现偏差(比如抗混叠项、指数近似的差异)都会让反向的 α 与前向对不上,T 链恢复随之整体错位。
T 链逆序恢复
一行公式的来历
前向透射率递推是 Ti+1=Ti(1−αi)(Ti 指处理第 i 个高斯之前的透射率,T1=1)。每一步只是乘一个因子,所以逆向就是除回去:
Ti=1−αiT
其中 T 是循环里当前持有的值(初始为前向存档的末值)。每处理完第 i 个高斯的梯度,执行一次 T←T/(1−αi),就得到了处理前一个高斯所需的 Ti−1 之后的位置——准确说,除法发生在取用之前:逆序走到第 i 个时,先除出 Ti,再用它算梯度。
四步恢复(本集算例)
末值 T=5×10−5,α=(0.5,0.8,0.95,0.99),从远到近:
| 步 i | 除式 | 恢复 Ti |
|---|
| 4 | 5×10−5/(1−0.99) | 0.005 |
| 3 | 0.005/(1−0.95) | 0.1 |
| 2 | 0.1/(1−0.8) | 0.5 |
| 1 | 0.5/(1−0.5) | 1.0 |
与前向链 (1,0.5,0.1,0.005) 逐项吻合。注意 T1=1 天然成立——它是初值,不依赖任何高斯,恢复出 1 同时就是对整条链的一次校验。
为什么敢除
- 精确性:前向每步只乘了一个 (1−αi),没有加性项、没有分支,逆映射就是干净的一除。只要反向重算的 αi 与前向逐位一致,恢复就是精确的(差一个 (1−α) 因子都会让整条链错位——评审曾抓到过「反向漏乘」导致 T 链整体差一档的 bug)。
- 零存档:链上 n 个值压成末值一个数,逆序除回免费重建。
数值上的坑
- αi→1 时 1−αi→0,除法病态。前向用 T<10−4 早退间接兜了底:一路高 α 会迅速把 T 压到阈值以下,循环截止,病态区段的梯度直接置零。
- T 链恢复是后续所有梯度的乘性基底:Ti 出现在 dL/dαᵢ 的每一项里。T 错一格,整个像素的 α 梯度集体错——所以它是反向链里最先要验证的量。
dL/dαᵢ 与 acc 递推
梯度公式的推导
像素颜色是加权和 C=∑jTjαjcj。对 αi 求导时,αi 出现在两处:自己的项里(Tiαici),以及身后所有项的透射率里(j>i 的 Tj 都含因子 (1−αi))。由 Tj=Ti(1−αi)∏i<k<j(1−αk) 得 ∂Tj/∂αi=−Tj/(1−αi),于是
∂αi∂C=Tici−1−αi1∑j>iTjαjcj
两边乘上游梯度 dL/dC(逐通道内积后)整理成
dαidL=Ti(Gi⟨dCdL,ci⟩−acci)
其中 acci=∑j>iGjαj∏i<k<j(1−αk):第 i 个高斯身后(更远处)已经叠出的加权色,权重以上游梯度 dL/dC 调制。
acc 的一行递推
acc 不需要单独存档,逆序遍历时边算边滚:
acc←acc⋅(1−αi)+⟨dCdL,ci⟩⋅αi,acc 初值 0
归纳可见不变式:处理完第 i 个后,acc 恰好等于「i 及以远」的加权和 ∑j≥iGjαj∏i≤k<j(1−αk)——每项的透射率都折算到 i 之后的位置。所以下一轮处理第 i−1 个时,acc 正好就是 acci−1。灰度情形 dL/dC=1 时 Gi=ci,递推退化成旁白里的「acc ← acc·(1−α) + cᵢ·α」。
直觉读法
梯度符号由 ci 与 acc 的对比决定(设 dL/dC>0,即损失希望像素更亮):
- 比背景亮(Gi>acc):增大 αi 会用更亮的自己替换身后均值,像素变亮,梯度为正;
- 比背景暗(Gi<acc):梯度为负,该减小 αi 把自己「让开」。
acc 在这里的角色就是「此刻的背景色」:每个高斯只跟身后已合成的画面比明暗,不跟全局比。
与源码的对应
该定义与自研训练器 backward.comp 逐行对应:Ga = T·(dot(dLdC, c) − accum),随后 accum = accum·(1−α) + dot(dLdC, c)·α。两个要点:acc 累计的是 dL/dC 加权后的颜色,不是裸颜色(只有 dL/dC=1 时两者才相等);acc 的更新发生在当前梯度算完之后,顺序反了 acc 就把自己也算进了背景。
参考
acc 四步手算
设定
灰度单通道,取 dL/dC=1(即上游梯度全 1,此时 Gi=ci)。数据:α=(0.5,0.8,0.95,0.99),c=(0.2,0.7,0.4,0.9),恢复好的 T 链为 (1,0.5,0.1,0.005)。逆序从 i=4 走到 i=1,每步两件事:
dαidL=Ti(ci−acc),acc←acc(1−αi)+ciαi
四步明细
| 步 i | Ti | ci−acc | dL/dαi | acc 更新 |
|---|
| 4 | 0.005 | 0.90−0 | +0.0045 | 0→0.891 |
| 3 | 0.1 | 0.40−0.891 | −0.049 | 0.891→0.425 |
| 2 | 0.5 | 0.70−0.425 | +0.138 | 0.425→0.645 |
| 1 | 1.0 | 0.20−0.645 | −0.445 | 0.645→0.422 |
逐步验算 acc:0.891=0.9×0.99;0.425=0.891×0.05+0.4×0.95;0.645=0.425×0.2+0.7×0.8。每步两次乘法,与 GPU 着色器里的成本一致。
读符号:i=4 身后没人(acc = 0),它最亮,梯度为正;i=3 比背景暗,梯度为负;i=1 自己只有 0.2,却压着身后 0.645 的背景,梯度 −0.445 最大——第一个高斯把画面压得太暗,最该减 α。
天然自检:acc 终值 = C
四步走完 acc = 0.422,恰好等于前向累计色 C。不是巧合:当 dL/dC=1 时 acc 的不变式退化为 C 的同一结构——
acc终=∑jcjαjTj=C
acc 递推与颜色累计同构,只是反向走。这给了实现一个零成本断言:跑完反向,acc 应与存档的 C 一致(浮点精度内),不等就是 T 链或 α 重算出了错。
截止之后:梯度置零
ncontrib 之后的高斯(本例第 5 个)前向被早退跳过、未参与合成,链式法则上对所有参数的梯度都是零。反向循环到 ncontrib 为止,不要继续往下走——那些高斯的 T 链恢复会用到被跳过的 α,除回去只会得到无意义的数。早退省下的不只是前向时间,也是反向时间。
容易踩的坑
- acc 初值是 0,不是背景色。最远高斯的「身后」是空合成,空就是零;场景背景色若存在,是在 dL/dC 里体现的,不进 acc。
- 先算梯度再更新 acc。两行顺序写反,当前高斯就被算进自己的背景,梯度整体偏差。
μ 的两条梯度路径
为什么 μ 特殊
颜色、不透明度这类参数在合成公式里只出现一次,链式法则一条链走到底。三维中心 μ 却出现在前向管线的两处,损失对它的全导数必须是两条路径之和。
一阶路径:挪中心
投影把 μ 映射为屏幕上的椭圆中心 μ2D。挪动 μ,椭圆整体平移,改变该像素相对椭圆中心的偏移量,进而改变 power(指数项里的马氏距离)和 α,最终改变 C:
μ→μ2D→power→α→C
这是直觉路径:梯度告诉你「椭圆该往哪挪」。
二阶路径:μ 还住在 J 里
投影不是线性映射,是针孔相机的除法:x′=ftx/tz。把三维协方差投到屏幕时用局部线性化
cov2d=JVrkJ⊤
其中 Vrk=WΣW⊤ 是相机系协方差,而雅可比 J 的取值点是 t=W(μ−相机位置)——J 依赖于 μ。μ 一动,线性化点就动,J 跟着变,于是:
μ→J(μ)→cov2d→conic→power→α→C
椭圆的中心没挪,但形状、朝向、大小变了,同样影响像素。这条路径不显式经过 μ2D,肉眼看不见,所以容易漏。
全导数 = 两项之和
链式法则对多路径依赖自动给出加法结构:
dμdL=一阶∂μ∂LJ 固定+二阶∂μ∂Lμ2D 固定,经 J
只写一阶项是真实出现过的评审级 bug:训练照样收敛(一阶项通常占主导),但梯度系统性缺一块,表现为收敛「差一口气」、近相机区域拟合发虚。漏掉的不是噪声,是几何上真实存在的耦合——μ 不仅决定椭圆在哪里,还决定线性化在哪个点做。
参考
μ 梯度:一阶 + 二阶
完整公式
dμ3DdL=W⊤J⊤dμ2DdL+K′,K′=H⋅J⋅Vrk
每一项是谁对谁的导数(Q6 考点):
| 符号 | 含义 | 导数关系 |
|---|
| W | 视图矩阵的旋转部分(世界系 → 相机系) | ∂t/∂μ3D,t=W(μ−相机位置) |
| J | 投影雅可比,∈R2×3 | ∂μ2D/∂t,在 t 处取值 |
| H | 投影黑塞(Hessian),三阶张量 | ∂2μ2D/∂t2=∂J/∂t |
| Vrk | 相机系协方差 WΣW⊤ | 非导数,是被投影的量 |
H 与 J 是「兄弟」:同一个投影函数的一阶、二阶导数。一阶项用 J 把像素梯度搬回三维;二阶项用 H 刻画「J 自身随取值点变化」的那部分耦合。
一阶项:两次转置搬回世界系
像素平面上的梯度 dL/dμ2D 先经 J⊤ 从屏幕搬到相机系(2 维 → 3 维),再经 W⊤ 从相机系转回世界系。注意转置顺序与前向相反:前向是 μ3DWtJμ2D,反向就倒着走 W⊤J⊤。
代入本集设计算例(f=935.3074,z=tz=4):J 的主对角元约为 f/z=233.8,dL/dμ2D=(0.01,0.02) 给出一阶项 ≈(2.34,4.68,0)。
二阶项 K′:量级与何时不能丢
投影 x′=ftx/tz 对 t 的二阶导数含 −f/tz2 型项。代入 z=4:−f/z2=−935.3074/16≈−58.5,乘上本例的协方差与梯度后二阶项 ≈−0.23。
与一阶的 4.68 相比约占 5%——平时确实小。但注意二阶项随 1/z2 缩放,一阶项只随 1/z:高斯贴近相机时,二阶项相对放大一倍速度,近距、大椭球场景下丢掉它,μ 梯度就系统性失真。
容易看错的点
- K′ 不是对 μ²ᴰ 求导的修正,而是对 J 的。它出现在 μ 的梯度里,是因为线性化点 t 本身依赖于 μ;协方差参数(s、q)的梯度里没有这一项。
- 两项符号可以相反。本例 +4.68 与 −0.23 异号:二阶项在「往回拉」。它不是误差,是几何曲率的真实修正。
参考
四元数的切空间投影
约束:参数住在单位球面上
旋转用单位四元数 q 表示,∥q∥=1。参数空间不是 R4,而是三维球面 S3。普通反向传播给出的梯度 g∈R4 是「 ambient 空间」的方向,直接沿它走会偏离球面。
几何上,q 点的可行扰动方向构成该点的切平面:所有与 q 正交的向量。梯度在径向(沿 q 方向)的分量只改变模长、不改变旋转,必须剔除。
投影公式与手算
把 g 正交投影到 q 处的切平面(∥q∥=1 时 q^=q):
gtan=g−(g⋅q^)q^
本集算例:q=(0.5,0.5,0.5,0.5),g=(0.8,0.4,0.2,0)。
- g⋅q=0.8×0.5+0.4×0.5+0.2×0.5+0=0.7
- 径向分量 0.7q=(0.35,0.35,0.35,0.35)
- gtan=(0.45,0.05,−0.15,−0.35),验证:gtan⋅q=0.225+0.025−0.075−0.175=0 ✓
步进对比:投不投影差多少
步长 ε=0.2,q′=q+εgtan(或不投影时 q+εg),最后归一化:
| 走法 | ∥q′∥2 | 模长膨胀 |
|---|
| 不投影 | 1.3136 | +14.6%(∥q′∥=1.146) |
| 投影后 | 1.0140 | +0.7% |
投影后仍有 0.7% 残差,因为切平面是局部近似:沿切线走 ε 后必然略离球面。这个残差有精确表达式——由 gtan⊥q,勾股定理给出
∥q+εgtan∥2=1+ε2∥gtan∥2=1+0.04×0.35=1.014
残差是 O(ε2) 的二阶小量,步进后再归一化一次即回到球面。
为什么剔除径向是安全的
旋转矩阵对四元数是齐次的:R(λq)=R(q),任意非零缩放不改变旋转,损失对径向方向本来就不敏感。更深层的事实:实现里 q 总是先归一化再构造 R,而对 q^=q/∥q∥ 求导,其雅可比正是 (I−q^q^⊤)/∥q∥——切空间投影就是 normalize 算子的 VJP,不是额外加的约束技巧。换句话说,只要把归一化写进前向,正确的反向自动包含这一步。
容易看错的点
- 投影用的是归一化后的 q^。若 q 因累计误差偏离单位球,先归一化再投影,公式里的 q^ 不能偷懒用 q 代替。
- 投影 ≠ 只归一化。只归一化不剔径向:径向分量在归一化时会耦合进旋转方向,等效于给有效步长乘了个不确定的因子。
o 的梯度与 3.5 倍坑
链式推导
不透明度在存储与优化中用的是 logit ℓ(sigmoid 的反函数),前向时 σ=σ(ℓ),像素不透明度是它与高斯求值项的乘积:
α=σ(ℓ)⋅epower
对 ℓ 求导:epower 与 logit 无关,是常数因子;sigmoid 的导数是 σ′=σ(1−σ)。于是
∂ℓ∂α=σ(1−σ)epower=α(1−σ)
乘回上游梯度:
dℓdL=dαdL⋅α(1−σ)
坑:把 α 又当成了 σ
手写反向时极容易顺手把 sigmoid 导数套到最终结果上,写成 α(1−α)——把「激活后的 α」误当成 σ。两者只差一个字母,数值上差多少,代数说了算:
正确版错误版=α(1−σ)α(1−α)=1−σ1−α
取 o=σ=6/7≈0.857、α=0.5(即 epower=0.5/0.857≈0.583):
1−o1−α=1/70.5=3.5
代入看两个因子本身:正确版 α(1−o)=0.5×0.143=0.0715,错误版 α(1−α)=0.25——全体 opacity 梯度偏大 3.5 倍。
为什么这种 bug 最难抓
- 符号不变:α,σ∈(0,1),两个因子都为正,梯度方向(符号)不受影响,只是幅度错。训练照样收敛,只是慢、糊——没有 NaN、没有发散,常规哨兵全部静默。
- 倍率不是常数:3.5 只是这个采样点的值;一般倍率 (1−α)/(1−σ) 随像素变化,连「统一缩放错了」这种表现都没有,曲线看上去只是「收敛质量不太好」。
- 它藏在正确的框架里:链式结构、前向公式全对,错的是一个括号的下标。这类 bug 只能靠数值梯度检验(gradcheck)或对拍抓出来——本系列训练器正是靠 gradcheck 门禁(rel-err < 1e-3)在评审阶段拦下的。
符号约定提醒
全系列统一:o 或 σ 指 sigmoid 激活后的不透明度参数(∈(0,1)),α 指乘上高斯求值项之后的像素级不透明度。α=o⋅epower,两者一般不相等——这个区分是本坑的根源。
参考