复数求导
主线位置:变换主线的「grad / vjp / jvp」一步,输入换成复数。
版本:JAX 0.12.0.1(CPU,x64)、PyTorch 2.15.0(
torch.set_num_threads(1))、tenferro-rs 0.8.0(CPU,native,单线程)
flowchart TB L["实损失 L(z)"] --> tf["tenferro grad"] L --> pt["torch backward"] L --> jx["jax.grad"] tf --> a["∂x + i∂y"] pt --> a jx --> b["它的共轭"] a --> d["z 减 lr 乘梯度"] b --> cj["先取共轭再下降"]
实变量的梯度没有歧义。复变量 z = x + iy 的「梯度」要选一个表示:tenferro 和 PyTorch 给 ∂L/∂x + i·∂L/∂y,JAX 给它的共轭 ∂L/∂x − i·∂L/∂y。三家的导数本身都对,差的只是写法;但这个写法决定了「沿负梯度走」能不能直接写成 z − lr·g。tenferro 的约定写在上游文档 docs/guides/complex-ad.md 里:反向模式用 Hermitian 实内积 ⟨a, b⟩ = Re(conj(a)·b),全纯函数 y = f(z) 的 VJP 是 ȳ·conj(f'(z)),JVP 是 f'(z)·dz。规则层面复数乘法的转置为什么带共轭,见 tenferro 卷的 primitive AD。
实验代码:tenferro 一侧是 bin complex_tf,JAX 和 PyTorch 一侧是 complex_ref.py。M2 Max、macOS 27、rustc 1.99.0。z = [iπ/2, ln 2],和上游的例子相同。tenferro 用的是带 linalg、einsum 规则的 AdContext。
三种约定并排
jax.grad(lambda z: jnp.sum(jnp.abs(z) ** 2))(z)
jax.grad(lambda z: jnp.sum(jnp.exp(z)), holomorphic=True)(z)
_, pull = jax.vjp(jnp.exp, z1); pull(ct) # z1 = [iπ/2], ct = [2-3i]
jax.jvp(jnp.exp, (z1,), (ct,))
(zt.abs() ** 2).sum().backward() # PyTorch
torch.autograd.grad(z1t.exp(), z1t, grad_outputs=torch.tensor(ct))
torch.func.jvp(torch.exp, (torch.tensor(z1),), (torch.tensor(ct),))
let z = TracedTensor::from_vec_col_major(vec![2], zd.clone())?;
let m = z.abs()?; // C64 -> F64
let l = (&m * &m)?.reduce_sum(None)?;
ad.grad(&l, &z)
let le = z.exp()?.reduce_sum(None)?; // 复数输出
ad.grad(&le, &z)
let y1 = z1.exp()?;
ad.vjp(&y1, &z1, &ct) // ct = [2-3i]
ad.vjp(&y1, &z1, &ctc) // ctc = [2+3i],结果再取共轭
ad.jvp(&y1, &z1, &ct)
# JAX
jax grad(sum|z|^2) = [0. -3.141593j, 1.386294+0.j ]
jax grad(sum exp z) = TypeError: grad requires real-valued outputs (output dtype that is a sub-dtype of np.floating), but got complex128. For holomorphic differentiation, pass holomorphic=True. For differentiation of non-holomorphic functions involving complex outputs, use jax.vjp directly.
jax grad(sum exp z, holomorphic=True) = [0.+1.j, 2.+0.j]
jax vjp(exp)(2-3i) = [3.+2.j] ct*exp z = [3.+2.j]
jax conj(vjp(exp)(conj(2-3i))) = [-3.-2.j]
jax jvp(exp)(2-3i) = [3.+2.j]
# PyTorch
torch backward(sum|z|^2) = [0. +3.141593j, 1.386294+0.j ]
torch backward(sum exp z) = RuntimeError: grad can be implicitly created only for real scalar outputs but got torch.complex128
torch autograd.grad(sum exp z, grad_outputs=1) = [0.-1.j, 2.+0.j] conj(exp z) = [0.-1.j, 2.-0.j]
torch vjp(exp)(2-3i) = [-3.-2.j] ct*conj(exp z) = [-3.-2.j]
torch jvp(exp)(2-3i) = [3.+2.j]
# tenferro
abs(z) dtype F64
grad(sum|z|^2) = [0.000000+3.141593i, 1.386294+0.000000i]
grad(sum exp z) = [0.000000-1.000000i, 2.000000+0.000000i] (output dtype C64)
vjp(exp)(2-3i) = [-3.000000-2.000000i]
conj(vjp(exp)(conj(2-3i))) = [3.000000+2.000000i]
jvp(exp)(2-3i) = [3.000000+2.000000i]
eager backward(sum|z|^2) = [0.000000+3.141593i, 1.386294+0.000000i]
eager backward(sum exp z) = [0.000000-1.000000i, 2.000000+0.000000i]
| JAX | PyTorch | tenferro | |
|---|---|---|---|
| 实损失 `Σ | z | ²` 的 grad | 2·conj(z) |
全纯 Σ exp z 的 grad | 不加 holomorphic=True 报错;加了是 exp z | backward() 报错;grad_outputs=1 时是 conj(exp z) | 直接接受,conj(exp z) |
vjp,余切 c | c·f'(z) | c·conj(f'(z)) | c·conj(f'(z)) |
jvp,切向量 dz | f'(z)·dz | f'(z)·dz | f'(z)·dz |
- tenferro 和 PyTorch 是一家。 四行的数值都相同(全纯那一行 PyTorch 要显式给
grad_outputs),eager 的backward也一样。从 PyTorch 搬复数代码不用改约定。 - JAX 差一个共轭,只在反向。 jvp 是方向导数,三家相同;grad 和 vjp 是余切的表示,JAX 和另外两家互为共轭。
- 换算。 上游文档给的公式:JAX 的
vjp(c)等于 tenferro 的conj(vjp(conj(c)))。上面两行conj(vjp(exp)(conj(2-3i)))就是验证:tenferro 这样算得3+2i,正是 JAX 的vjp;反过来 JAX 这样算得-3-2i,正是 tenferro 的。实损失时余切是实数 1,公式退化成「梯度取共轭」,AdContext::grad的文档就是这么写的。 abs把 C64 变成 F64。 损失是实数,grad 的种子是实数 1。
问: JAX 和 PyTorch 都不肯对复数输出 Σ exp z 求 grad,tenferro 接受了。它算的到底是什么?
答: 是实部的梯度。种子 1 是实数余切,按 ⟨a, b⟩ = Re(conj(a)·b),它只「看见」输出的实部,所以 tenferro 的结果就是 Re(Σ exp z) 的梯度 ∂/∂x + i·∂/∂y:Re exp z = eˣ cos y,两个偏导拼起来是 eˣ(cos y − i sin y) = conj(exp z)。PyTorch 的 grad_outputs=1 给的是同一个数。下面「复数类型的实损失」一节还有一个例子。JAX 的 holomorphic=True 给的是导数 f'(z) = exp z,是另一个量;迁移时不要把两者对上。
拿梯度做下降
约定的差别在优化时最明显。L = Σ|z − w|²,z 从 0 出发,步长 0.25,走 20 步:
zz = zz - 0.25 * jax.grad(lossj)(zz) # 直接用 JAX 的梯度
zz = zz - 0.25 * np.conj(jax.grad(lossj)(zz)) # 先取共轭
opt = torch.optim.SGD([zz], lr=0.25) # PyTorch
let g = ad.grad(&l, &zp)?; // zp 是 C64 占位符,编译、准备一次,循环里只 run
zz = zz.iter().zip(gv.as_slice::<C>()?).map(|(a, b)| a - b * 0.25).collect();
z − 0.25·g,20 步之后 L 是多少?w = [1+2i, −1+0.5i],L 的初值是 |w|² = 6.25。
运算族的规则:SVD 和 einsum
约定不只在核心算子上。线性代数和 einsum 的规则在各自的 crate 里(semantic AD),也要守同一个约定。两个实损失:复矩阵 A(4×3)的核范数 Σ s(A),和 Σ|A x|²(A 3×4,对 x 求导,精确梯度 2Aᴴ A x)。
jax.grad(lambda a: jnp.sum(jnp.linalg.svd(a, compute_uv=False)))(a)
torch.linalg.svdvals(at).sum().backward()
jax.grad(lambda x: jnp.sum(jnp.abs(jnp.einsum("ij,j->i", a, x)) ** 2))(x)
let (_, s, _) = a.svd()?; // A 是 C64,s 是 F64
let l = s.reduce_sum(None)?;
ad.grad(&l, &a)
let y = at.dot_general(&xt, dg(&[1], &[0], &[], &[]))?; // 1. traced dot_general
let y = s.einsum(&[&ea, &ex], "ij,j->i")?; // 2. eager einsum,再 backward
let vy = tc.einsum(&[va, vx], "ij,j->i")?; // 3. TraceContext einsum,再 vjp_program
# JAX / PyTorch
L = 4.958876886415667 s = [3.131125, 1.728991, 0.098761]
max|torch - U Vh| = 9.992e-16 max|torch - FD| = 7.769e-10
max|jax - conj(U Vh)| = 0.000e+00 max|jax - FD| = 1.602e+00
torch grad[0, :] = [0.031504+0.800836j, 0.35844 +0.066523j, 0.34888 +0.198334j]
jax grad[0, :] = [0.031504-0.800836j, 0.35844 -0.066523j, 0.34888 -0.198334j]
max|torch - 2 A^H A x| = 1.986e-15 max|jax - conj(2 A^H A x)| = 8.951e-16
# tenferro
svd s dtype F64
L = 4.958876886415668
max|grad - FD| = 7.769e-10
grad[0, :] = [0.031504+0.800836i, 0.358440+0.066523i, 0.348880+0.198334i]
traced dot_general + ad.grad: max|. - 2 A^H A x| = 1.047e-15
eager einsum + backward: L = 2.719917165456075 max|. - 2 A^H A x| = 1.047e-15
TraceContext einsum + vjp_program: max|. - 2 A^H A x| = 1.047e-15
- 核范数。 差分按
∂L/∂x + i·∂L/∂y逐元素算(步长 1e-6)。tenferro 和 PyTorch 对它都差 7.8e-10,第一行打印出来相同;JAX 的结果正好是conj(U Vh),所以「对差分差 1.6」只是表示不同。复数 SVD 的s在 tenferro 里是 F64。 Σ|A x|²。 三条路(dot_general的核心规则、eager einsum 展开成的核心算子、einsum 自己的扩展规则)都给2Aᴴ A x,差 1.0e-15;einsum 的反向规则对复数输入取共轭(semantic_einsum_vjp里的semantic_conjugate_if_complex)。三条路怎么写见 einsum 的求导一节。
PR #998(「Fix complex AD and linalg dtype issues (#998)」,2026-06-11,首次发布于 v0.1.0)处理了三个问题:复数约定没有公开文档(#994),CPU 上复数 svd 返回的奇异值是复数张量、而 traced 的元数据说它是实数(#995),复数 eigh 反向的 dtype 不匹配(#996)。约定这一项,它的工作日志写的是「Documented the existing complex AD convention instead of changing semantics」,并列在放弃的方案里:「Did not change tenferro's complex reverse-mode convention to match another library's public presentation」。这个 PR 加了 docs/guides/complex-ad.md、可运行的例子 complex_ad_convention.rs,给 traced 和 eager 的 grad、vjp、backward 文档加了指向这份说明的一段话,grad 那段是「To compare seed-1 scalar gradients with JAX's public grad values, use the complex conjugate of this result」。#995 修完以后,复数 SVD 的 s 在元数据和运行时都是实数:上面 s dtype F64 是元数据,L 能按 f64 读出来是运行时。
复数类型的实损失
Σ z·conj(z) 数值上是实数,dtype 却是复数。三家的处理:
jax.grad(lambda z: jnp.sum(z * jnp.conj(z)))(z)
jax.grad(lambda z: jnp.real(jnp.sum(z * jnp.conj(z))))(z)
(zt * zt.conj()).sum().real.backward()
let q = (&z * &z.conj()?)?.reduce_sum(None)?;
ad.grad(&q, &z)
z.cast(DType::F64) // 取实部
z.convert(DType::F64)
# JAX / PyTorch
jax grad: TypeError: grad requires real-valued outputs (output dtype that is a sub-dtype of np.floating), but got complex128. For holomorphic differentiation, pass holomorphic=True. For differentiation of non-holomorphic functions involving complex outputs, use jax.vjp directly.
jax grad(real(sum z conj z)) = [0. -3.141593j, 1.386294+0.j ]
torch backward: RuntimeError: grad can be implicitly created only for real scalar outputs but got torch.complex128
torch backward(.real) = [0. +3.141593j, 1.386294+0.j ]
# tenferro
sum z conj z: dtype C64 value [2.947854+0.000000i]
grad(sum z conj z) = [0.000000+3.141593i, 1.386294+0.000000i]
cast(z, F64) = [0.0, 0.6931471805599453]
grad(sum cast(z)^2) = [0.000000+0.000000i, 1.386294+0.000000i]
convert(z, F64): Err(TracedTensor::convert: unsupported dtype conversion from C64 to F64: checked convert only accepts conversions allowed by dtype promotion; use explicit cast for lossy dtype projection)
- JAX 和 PyTorch 要先取实部;tenferro 直接给出
2z,就是实部的梯度(上面「没有蠢问题」)。 - tenferro 的
TracedTensor没有real/imag方法。取实部用cast(「explicit lossy dtype projection」),它能求导:Σ (Re z)²的梯度是2·Re z,虚部为 0。convert只做不丢信息的提升,C64 到 F64 被拒绝。 - tenferro 不检查输出是不是「本该是实数」。一个忘了取
abs的复数损失也会得到一个梯度,那是它实部的梯度;JAX 在这里会报错提醒你。
动脑筋答案
jax z -= lr*grad: z = [ 0.999999-6648.51346j , -0.999999-1662.128365j] L = 4.699e+07
jax z -= lr*conj(grad): z = [ 0.999999+1.999998j, -0.999999+0.5j ] L = 5.684e-12
torch SGD: z = [ 0.999999+1.999998j, -0.999999+0.5j ] L = 5.684e-12
# tenferro
z -= lr*grad: z = [0.999999+1.999998i, -0.999999+0.500000i] L = 5.684e-12
- 直接用 JAX 的梯度,
L从 6.25 涨到 4.7e7。实部收敛到Re w,虚部发散:JAX 的梯度是2·conj(z − w),虚部方向上符号反了,每步把Im(z − w)放大 1.5 倍(1 + 0.25·2),20 步约 3300 倍。 - 先取共轭(这正是 tenferro 和 PyTorch 给的量),20 步后
L = 5.7e-12,每步误差乘1 − 0.25·2 = 0.5。 - tenferro 和 PyTorch 的
SGD结果相同,不需要任何换算。从 JAX 搬优化循环时,删掉手写的conj(如果有的话),或者反过来在 JAX 一侧加上它。
小结
- 复数输入时,tenferro 和 PyTorch 的 grad 是
∂L/∂x + i·∂L/∂y,JAX 是它的共轭;vjp 同样差一个共轭,jvp 三家相同。 - 换算:JAX 的
vjp(c)= tenferro 的conj(vjp(conj(c)));实损失时就是梯度取共轭。 - tenferro 的梯度可以直接做下降;JAX 的梯度不取共轭直接用会在虚部方向发散。
- tenferro 的
grad接受复数输出,算的是实部的梯度,不报错;JAX 和 PyTorch 要求实数输出,JAX 的holomorphic=True是另一个量。 - SVD(核范数)、einsum 的规则守同一约定,和 PyTorch、差分一致;复数 SVD 的
s是 F64。 - 取实部用
cast(DType::F64),convert拒绝 C64 到 F64;没有real/imag方法。