Rust in Everything
基于 tenferro-linalg 0.8.0

分解的梯度

主线位置:TRG 主线「一步 SVD 截断加收缩」之前,先确认 svd、eigh、qr、solve 这几个分解在两边求导的结果一致。
版本:JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native,单线程)
flowchart TB
  a["矩阵 A"] --> j["jnp.linalg"]
  a --> t["TracedTensorLinalgExt"]
  j --> lj["与符号无关的损失"]
  t --> lt["与符号无关的损失"]
  lj --> gj["jax.grad"]
  lt --> gt["AdContext grad"]
  gj --> fd["对照中心差分"]
  gt --> fd

两边的分解都是薄分解,求导规则也是同一套公式,tenferro 把系数 F 展宽了,只在奇异值相等或极接近时结果和 JAX 不同(见 简并奇异值);它的 linearize_svd 连 JAX 的复数规范修正项都照搬了(规则本身见 线性代数 的「SVD 的反向规则」)。所以在普通矩阵上,梯度两边相同到 1e-15 量级。要注意的是另外几件事:分解得到的向量符号不同;测试矩阵别选秩亏的;solve 的右端项形状。

tenferro 一侧的求导要用带 tenferro_linalg::semantic_ad_rules() 的 AdContext,TracedTensorAdExt::grad 的规则集是空的,经过 SVD 会报错,见 grad、vjp、jvp 的「AdContext 和 TracedTensorAdExt」和常见坑 unsupported vjp AD rule。

实验代码:JAX 一侧是 grads.py,tenferro 一侧是 bin grads(release,CpuEngine::serial()),对照脚本 compare_grads.py。M2 Max、macOS 27、rustc 1.99.0。

函数对照

JAXtenferro(TracedTensorLinalgExt)说明
jnp.linalg.svd(a, full_matrices=False) → u, s, vha.svd()? → (u, s, vt)都是薄 SVD,s 降序;jnp.linalg.svd(a) 默认是满的,见 SVD 的输出
jnp.linalg.eigh(a) → w, va.eigh()? → (w, v)特征值升序,v 的列是特征向量
jnp.linalg.qr(a) → q, ra.qr()? → (q, r)默认都是 reduced:6×4 的 a 得到 6×4 的 q、4×4 的 r
jnp.linalg.solve(a, b)a.solve(&b)?tenferro 的 b 必须是二维,见下文
eps 一类的参数没有svd_with_options(SvdOptions)、eigh_with_options、qr_with_options求导用的 derivative_eps 和符号规范

四个损失,两边对差分

损失和试验阶段的 linalg_grads 一样:对每个分解构造一个只依赖「和符号无关」的量的标量,比如奇异值、投影矩阵 U₂U₂ᵀ、diag(R)²。矩阵元素是 sin(1 + 0.7i² + 2.3j + 0.5ij²),权重是 cos(i − 2j)。

def loss_svd(a):
  u, s, vh = jnp.linalg.svd(a, full_matrices=False)
  u2 = u[:, :2]
  return jnp.sum(jnp.array([1.0, 0.5, -0.3, 0.2]) * s) + jnp.sum(wmat(6, 6) * (u2 @ u2.T))

g = jax.jit(jax.grad(loss_svd))(a)        # 再和步长 1e-6 的中心差分比
fn loss_svd(a: &TracedTensor) -> R<TracedTensor> {
  let (u, s, _vt) = a.svd()?;
  let u2 = (&u * &colmask(6, 4, &[0, 1])?)?; // u[:, :2] 写成掩码,原因见「SVD 的输出」
  Ok((&wsum(&s, &vecc(&[1.0, 0.5, -0.3, 0.2])?)? + &wsum(&outer(&u2)?, &wmat(6, 6)?)?)?)
}

let g = ad.grad(&loss_svd(&x)?, &x)?;     // ad = AdContext + linalg 规则;编译、准备、执行后读回

eigh 的损失是 A + Aᵀ 的特征值加权和加最大特征向量的投影;qr 是 QQᵀ 和 diag(R)² 的加权和;solve 是 (A + 3I)x = b 的解的加权和。

# JAX
svd          loss=+3.115374357904 max|grad|=7.877e-01 max|grad-fd|=1.556e-09 nan=0
eigh         loss=+2.571532979194 max|grad|=1.480e+00 max|grad-fd|=1.222e-09 nan=0
qr           loss=+3.339249699594 max|grad|=4.612e+00 max|grad-fd|=1.279e-09 nan=0
solve        loss=-0.500244841286 max|grad|=1.517e-01 max|grad-fd|=1.526e-10 nan=0
# tenferro
svd          loss=+3.115374357904 max|grad|=7.877e-1 max|grad-fd|=2.082e-9 nan=0
eigh         loss=+2.571532979194 max|grad|=1.480e0 max|grad-fd|=1.295e-9 nan=0
qr           loss=+3.339249699594 max|grad|=4.612e0 max|grad-fd|=1.388e-9 nan=0
solve        loss=-0.500244841286 max|grad|=1.517e-1 max|grad-fd|=1.970e-10 nan=0
# 两边的梯度逐元素比
grad svd    max|jax - tenferro| = 2.359e-15  max|grad| = 0.788
grad eigh   max|jax - tenferro| = 5.204e-16  max|grad| = 1.480
grad qr     max|jax - tenferro| = 2.665e-15  max|grad| = 4.612
grad solve  max|jax - tenferro| = 2.776e-17  max|grad| = 0.152
  • 损失值 12 位相同,对差分的误差都在 1e-9 量级。这个量级来自差分本身:步长 1e-6 的中心差分,截断误差和舍入误差加起来就是这么大,两边差不多。
  • 两边的梯度相差 1e-15 量级,相对梯度的大小(0.15 到 4.6)是几个 ulp。分解由不同的库算(JAX 走 LAPACK,tenferro 走 faer),反向里的矩阵乘顺序也不同,所以不逐位相同。
  • 奇异值、特征值、R 两边差 1.8e-15、1.8e-15、3.3e-16。

奇异向量的符号不同

同一个矩阵的分解因子直接相减:

svd_s   max|jax - tenferro| = 1.776e-15
eigh_w  max|jax - tenferro| = 1.776e-15
qr_r    max|jax - tenferro| = 3.331e-16  sign per vector = [1, 1, 1, 1]  after sign fix 3.331e-16
svd_u   max|jax - tenferro| = 1.253e+00  sign per vector = [1, 1, -1, 1]  after sign fix 4.441e-16
svd_vt  max|jax - tenferro| = 1.586e+00  sign per vector = [1, 1, -1, 1]  after sign fix 6.661e-16
qr_q    max|jax - tenferro| = 3.331e-16  sign per vector = [1, 1, 1, 1]  after sign fix 3.331e-16
eigh_v  max|jax - tenferro| = 1.580e+00  sign per vector = [-1, -1, -1, -1, -1]  after sign fix 3.469e-16

sign per vector 是两边对应向量内积的符号。SVD 的第 3 个奇异向量方向相反(U 的列和 Vᵀ 的行一起反,U S Vᵀ 不变);eigh 的 5 个特征向量全反;QR 两边一样,diag(R) 都是 [-1.742, 1.692, -1.585, -1.010]。把符号对齐后,差都回到 1e-16 量级。

奇异向量和特征向量本来就只确定到一个符号,哪个符号由算法的细节决定,LAPACK 和 faer 各有各的。这对搬代码有两个后果:

  • 损失要和符号无关,比如投影 uuᵀ、奇异值、|u|²。JAX 的测试里写 u[:, 0] 的加权和,在 tenferro 里梯度可能整个反号,差分也一样跟着反,两边各自是「对的」,但不能互相比。
  • 把 JAX 算出的向量当参考值存下来、再和 tenferro 比,要先对齐符号。

tenferro 有一个 JAX 没有的选项:SvdOptions::gauge(SvdGauge::CanonicalPivot),把 U 每列绝对值最大的元素变成正数,并把同一个符号乘到 Vᵀ 的对应行上(SvdGauge)。EighOptions、QrOptions 也有类似的选项。JAX 一侧只能手工做同样的规范:

let (uc, _sc, vtc) = a.svd_with_options(SvdOptions::default().gauge(SvdGauge::CanonicalPivot))?;
piv = np.sign(u[np.argmax(np.abs(u), axis=0), range(4)])   # JAX 的 U 每列主元的符号
u_canon, vt_canon = u * piv, vh * piv[:, None]
svd_u CanonicalPivot vs JAX U with pivot made positive: 4.441e-16 pivot signs of JAX U [1, -1, -1, 1]
svd_vt CanonicalPivot vs JAX Vt with the same row signs: 6.661e-16

规范之后两边的 U、Vᵀ 差 1e-16 量级。默认(SvdGauge::Raw)不做这一步。

📝NOTE
冷知识:符号规范是后加的,而且故意不当默认

PR #1338 引入 SvdOptions 和 SvdGauge::CanonicalPivot,同一天的 PR #1339(「Add opt-in linalg gauge conventions」)补上 EighGauge、QrGauge 和 qr_with_options,两者都首次发布于 v0.3.0。之前三个分解只有后端给的原始符号。#1339 随附的工作记录写明了取舍:「Kept gauge fixing opt-in. svd, eigh, and qr still use backend raw signs/phases by default.」以及「Did not add new gauge-aware AD rules. The existing AD formulas still operate on the chosen forward outputs」。规范只是前向输出的后处理,求导规则不变;所以上面四个损失的梯度不受这个选项影响,而一个依赖符号的损失在 Raw 和 CanonicalPivot 下会得到不同的值。

秩亏的测试矩阵

试验阶段记下一条:秩亏的测试矩阵会让 JAX 的 svd、qr 梯度和差分对不上。重跑时两边一起测。把 6×4 矩阵的第 4 列换成第 1、2 列之和(浮点相加,秩在舍入意义上是 3):

dup = base.copy()
dup[:, 3] = dup[:, 0] + dup[:, 1]
# JAX
dup svd s = [2.54062902e+00 2.17416489e+00 1.46539222e+00 1.21167608e-16]  diag(R) = [-1.74204082e+00  1.69209801e+00 -1.58541566e+00 -3.81934349e-16]
svd dup      loss=+3.524811646420 max|grad|=1.209e+00 max|grad-fd|=6.858e-02 nan=0
qr dup       loss=+2.823253820524 max|grad|=1.620e+15 max|grad-fd|=1.620e+15 nan=0
# tenferro
dup svd s = ["2.5406290203799005e0", "2.17416488595396e0", "1.4653922170352183e0", "2.3602035230609313e-16"] diag(R) = ["-1.7420408237631937e0", "1.6920980060104915e0", "-1.5854156582908705e0", "-2.775555224048112e-16"]
svd dup      loss=+3.524811646420 max|grad|=1.209e0 max|grad-fd|=6.860e-2 nan=0
qr dup       loss=+2.474531276122 max|grad|=3.476e15 max|grad-fd|=3.476e15 nan=0

两边都对不上,而且对不上的方式一样。问题出在测试点,不在求导:

  • svd:损失里有 0.2 · s₄,s₄ 在 0 附近像 |x|,在 0 处不可导。差分取的是两侧斜率的平均,梯度取的是其中一侧,于是差 7e-2。两边的梯度差同样多,对差分的误差 6.858e-2 和 6.860e-2。
  • qr:Q 的第 4 列在秩亏处没有定义,算出来是什么由舍入决定。连损失值都不一样:LAPACK 给 2.823,faer 给 2.475。梯度含 R⁻¹,r₄₄ 是 1e-16 量级,梯度就到 1e15。

所以测试分解的梯度,矩阵要随机生成或者像上面那样用解析式构造,并检查最小奇异值离 0 和彼此之间都足够远;想测秩亏或简并的行为,就单独测,并且损失只用定义良好的量(见 简并奇异值)。

❗IMPORTANT
动脑筋:把第 4 列直接设成 0,s₄ 就恰好是 0。svd 损失的 24 个梯度分量里,JAX 和 tenferro 各有几个 NaN?

提示:6×4 是 m > n。tenferro 卷的常见坑 SVD 的梯度全是 NaN 讲了 tenferro 一侧的机制,那里把它记作上游 bug。JAX 会不会不一样?

solve 的右端项

jnp.linalg.solve(a, b) 接受一维的 b,当成一列。tenferro 不接受:

let b1 = TracedTensor::from_vec_col_major(vec![5], (0..5).map(|i| (i as f64).cos()).collect())?;
a5p.solve(&b1)
solve(a, b[5]): Err(tenferro-linalg.lu_factor_solve: rank mismatch: expected 2, actual 1)

错误在建图那一行就返回。右端项写成 [5, 1],解也是 [5, 1];上面 solve 的损失就是这样写的,权重也是 [5, 1]。

动脑筋答案

# JAX
zero svd s = [2.3266923  1.83134488 1.09677589 0.        ]  diag(R) = [-1.74204082  1.69209801 -1.58541566  0.        ]
svd zero     loss=+2.308468906516 max|grad|=nan max|grad-fd|=nan nan=24
qr zero      loss=+1.120156703074 max|grad|=nan max|grad-fd|=nan nan=18
# tenferro
zero svd s = ["2.3266923038930245e0", "1.8313448767848501e0", "1.0967758874392364e0", "0e0"] diag(R) = ["-1.7420408237631937e0", "1.6920980060104915e0", "-1.5854156582908705e0", "0e0"]
svd zero     loss=+2.308468906516 max|grad|=0.000e0 max|grad-fd|=0.000e0 nan=24
qr zero      loss=+1.120156703074 max|grad|=inf max|grad-fd|=inf nan=18
  • 两边都是 24 个里 24 个 NaN。tenferro 一行的 max|grad|=0.000e0 是打印的问题:fold(0.0, f64::max) 遇到 NaN 时取另一边,NaN 全被跳过了,要看 nan=24。

  • 原因两边一样。JAX 0.12.0.1 的 _svd_jvp_rule(jax/_src/lax/linalg.py)对 m > n 加的投影修正项直接除以 s:

    if m > n:
      dAV = dA @ V
      dU = dU + (dAV - U @ (Ut @ dAV)) / s_dim.astype(A.dtype)
    

    同一个规则里,1/s 的对角项用 s_inv = 1 / (s + s_zeros) - s_zeros 挡住了 s = 0,这一行没有挡。tenferro 的 这一行 也直接除以原始的 s。s₄ = 0 时这一列是 0/0,再经矩阵乘扩散到全部 24 个分量。

  • 所以 tenferro 卷记下的这个坑,JAX 0.12.0.1 也有。绕法也通用:补零成方阵,或者把恰好为 0 的奇异值扰动开(见那一页)。
  • qr 两边都是 18 个 NaN,tenferro 剩下的分量里还有 inf。损失用了整个 QQᵀ,r₄₄ = 0 时这个函数本身不可导。

小结

  • svd、eigh、qr、solve 的梯度两边对中心差分都在 1e-9 量级,彼此差 1e-15 量级;两边用的是同一套公式,不同的库和运算顺序造成末几位不同。
  • 奇异向量、特征向量的符号两边不同(这个矩阵上 SVD 反了 1 个,eigh 反了 5 个,QR 相同)。损失写成和符号无关的形式;要比较向量本身,先对齐符号,或者用 SvdGauge::CanonicalPivot 再在 JAX 一侧手工做同样的规范。
  • 秩亏的测试矩阵两边都对不上差分:最小奇异值在 0 处是尖点,Q 的多余列没有定义。恰好为 0 的奇异值在 m > n 时两边都给全 NaN,JAX 的规则和 tenferro 一样在投影修正项里除以 s。
  • tenferro 的 solve 要求 b 是二维,一维的 b 在建图时报 rank mismatch: expected 2, actual 1。

💬 关联讨论

发起讨论
加载中...