SVD 的梯度全是 NaN
主线位置:traced 主线的
grad,经过tenferro-linalg的svd。SVD 的反向规则、F矩阵的展宽、derivative_eps和一张简并、零奇异值的实测表见 线性代数 的「SVD 的反向规则」;规则怎么注册见 semantic AD 与扩展。
flowchart TB a["4x3 矩阵 s3 恰为 0"] --> svd["svd"] svd --> rule["linearize_svd"] rule --> f["F 和 1/s 有 eps 展宽"] rule --> proj["m 大于 n 的投影项"] proj --> div["除以 s 无展宽"] div --> nan["0 除 0 得 NaN"] nan --> all["整个梯度 NaN"]
实验 crate exp-tenferro8/pit2-exp,bin p23_svd_nan,CPU(CpuEngine::serial()),debug,M2 Max、macOS 27、rustc 1.99.0。
现象
4×3 的对角矩阵 diag(2, 1, 0),损失只依赖前两个左奇异向量:L = Σ W ∘ (U₂ U₂ᵀ),U₂ 是 U 的前两列(用掩码乘出来),W 是固定的权重。这个函数在这一点可微,中心差分给出的梯度是有限的:
let (u, _s, _vt) = x.svd()?; // 或 svd_with_options(SvdOptions::default().derivative_eps(1e-6))
let u2 = (&u * &mask)?; // 只留前两列
let p = u2.dot_general(&u2, /* 收缩第 1 维 */)?; // U2 U2^T
let loss = (&p * &w)?.reduce_sum(None)?;
let g = ad.grad(&loss, &x)?; // ad 带 linalg 和 fft 的规则
central difference = [0.000000, 0.000000, -0.600000, 0.700000, 0.000000, 0.000000, 1.400000, -0.400000, 0.000000, 0.000000, 0.000000, 0.000000]
4x3 diag(2,1,0): s = [2.0, 1.0, 0.0], 12/12 NaN, grad = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]
4x3 diag(2,1,0) eps=1e-6: s = [2.0, 1.0, 0.0], 12/12 NaN, grad = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]
- 12 个分量全是
NaN,不报错。 - 损失根本没用到第三个奇异向量,掩码已经把它乘成了 0。
- 调大
derivative_eps不起作用。
转置过来的 3×4(n > m)同样会中招,只是换成了 V 一侧:
3x4 transpose, loss on U: s = [2.0, 1.0, 0.0], 0/12 NaN, grad = [0.000000, 0.000000, 0.200000, 0.000000, 0.000000, -0.600000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000]
3x4 transpose, loss on Vt: 12/12 NaN
对角矩阵、补零的数据、秩亏损的设计矩阵都会让 faer 给出恰好为 0 的奇异值;同一组奇异值经过正交变换后,faer 算出的是 1e-16 量级的数,梯度就是有限的(线性代数 的表)。所以这个坑看起来像是「偶尔出现」。
出处
linearize_svd 里,奇异值两两之间的系数 F 和 1/s 都写成了带 ε² 的形式,间隙或奇异值为 0 时不会除以零;derivative_eps 调的就是这个 ε。例外是 m > n 时 dU 的投影修正 (I − U Uᴴ) dA V S⁻¹,这一行 直接除以原始的 s:
let proj = linear_sub(builder, d_av, u_ut_d_av);
let s_broadcast = broadcast_in_dim_fixed(
builder,
s.clone(),
matrix_shape(m, &k, batch_shape),
vector_to_matrix_broadcast_dims(batch_shape.len()),
);
let s_broadcast =
convert_fixed_ref_to_dtype(builder, ValueRef::Local(s_broadcast), s_dtype, dtype);
let correction = linear_div_fixed(builder, proj, s_broadcast);
n > m 时 dV 的修正(第 888 行)也一样。s₃ = 0 时第三列是 0/0;掩码在反向里把那一列的余切乘 0,可 0 · NaN 还是 NaN,再经过矩阵乘扩散到梯度的每个元素。
修正项只在用到的那一侧生成:dU 在 u_active 时才建,dV 在 vt_active 时才建。所以 3×4 的情形损失只用 U 时没有问题,用 Vt 时就是 NaN。方阵两项都没有,不受影响。同一份规则在 F 和 1/s 上都做了展宽,这里漏了,是 0.8.0 仍在的上游 bug(0.7.1 一样)。JAX 0.12.0.1 的 SVD 求导规则在这一项上也直接除以 s,同样的零列矩阵在 JAX 里梯度也全是 NaN,所以这不是 tenferro 独有的问题,只是 tenferro 别处都做了展宽,这一项显得不一致。
修法
两种绕法,都让投影项不再除以 0:
// 1. 补一列 0,变成方阵:没有 m > n 的投影项
let mut sq = a.clone();
sq.extend([0.0; 4]);
report(&mut cpu, "4x4 padded", 4, 4, &sq, None)?;
// 2. 把恰好为 0 的那一维扰动成极小的数
let mut j = a.clone();
j[2 + 8] = 1e-12;
report(&mut cpu, "4x3 diag(2,1,1e-12)", 4, 3, &j, None)?;
4x4 padded: s = [2.0, 1.0, 0.0, 0.0], 0/16 NaN, grad = [0.000000, 0.000000, -0.600000, 0.700000, 0.000000, 0.000000, 1.400000, -0.400000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000]
4x3 diag(2,1,1e-12): s = [2.0, 1.0, 1e-12], 0/12 NaN, grad = [0.000000, 0.000000, -0.600000, 0.700000, 0.000000, 0.000000, 1.400000, -0.400000, -0.000000, 0.000000, 0.000000, 0.000000]
两种都和中心差分一致。补零后的梯度是 4×4,前 12 个(列主序的前三列)就是原矩阵的梯度。补零更干净,不改变前向结果;扰动简单,但要保证扰动量远小于数据本身的尺度。
如果损失真的依赖那个零奇异值对应的向量(比如用全部的 U Uᴴ),这个函数在秩亏损处本身不可微,绕过 NaN 之后得到的是 1e15 量级的数,也不能用(线性代数 的表)。重复奇异值不出 NaN,而是 1e9 量级的梯度,原因和对策见同一页的动脑筋答案。
怎么确认没中招
- 求完梯度检查
g.iter().any(|v| v.is_nan()),再看一眼s里有没有恰好等于0.0的值。 - 非方阵的 SVD 求导,测试数据别只用随机矩阵:加一组对角或秩亏损的输入,并且分别测只用
U和只用Vt的损失。 - 梯度的量级突然到 1e9 以上,打印相邻奇异值的差。
小结
- 非方阵里恰好为 0 的奇异值让 SVD 的整个梯度变成
NaN:m > n 时dU、n > m 时dV的投影修正项直接除以s,没有 ε 展宽,derivative_eps管不到。0.8.0 仍在的上游 bug。 - 只在损失用到的那一侧出现:4×3 用
U、3×4 用Vt时中招。 - 绕法:补零成方阵,或把零奇异值扰动成 1e-12 这样的小数;两种梯度都和差分一致。