PyTorch里处理复数,很多人第一反应是“用两个张量模拟实部虚部不就完了”,真等到要做梯度回传、做FFT、搞复数网络层时,才发现坑不少。PyTorch从1.7开始正式支持复数张量,官方叫ComplexFloat、ComplexDouble,底层把实部和虚部按最后一维相邻存放,还配套了view_as_real、view_as_complex这样随时切换视角的工具。这篇文章就围绕“PyTorch中复数的处理”这点,把我实际踩过的坑、从源码里看明白的细节,以及一套可以直接跑的复数神经网络样例,全部摊开讲清楚。适合刚接触复数张量的人,也适合已经卡在梯度报错上半天的人。
1. 为什么要在PyTorch里用复数张量
1.1 复数不是“实部+虚部两个float”这么简单
我最早在PyTorch里做复数相关的东西,是在一个频域处理任务里。当时数据是复数频谱,我图省事,把实部和虚部分别存成[batch, channel, freq, 2]的张量,手动定义乘法、手动处理梯度。能跑,但代码极其难看,而且稍不注意就把实部和虚部的对应关系搞错,比如广播时最后一维到底是“两个分量”还是“频率轴”,逻辑混乱到崩溃。
后来切换成PyTorch原生复数张量,一个torch.complex(real, imag)就完事。最关键的是,PyTorch给复数张量内置了完整的计算图支持,你操作的是一个整体,而不是人肉管理两个分量。底层的梯度推导也换了体系,用的是Wirtinger微积分——简单说,PyTorch会同时计算“对实部的梯度”和“对虚部的梯度”,然后合起来得到复数梯度。
这里有个容易误解的点:复数张量的梯度本身也是复数张量,如果你想把这个梯度转成实数去更新参数,需要用view_as_real或者.real/.imag分别取出来。你要是直接把复数梯度当作实数梯度去step(),优化器会当场报错或者静默更新错东西。
1.2 只有PyTorch原生复数张量才能用这些操作
原生复数张量带来一整批开箱即用的函数:torch.abs、torch.angle、torch.conj、torch.fft系列,还有复数矩阵乘法torch.matmul、torch.linalg里部分支持复数。你拿两个独立float张量,肯定没法直接调用torch.fft.fft。就算能用第三方库,梯度也得自己包。
所以,复数张量的意义不是“省两个变量”,而是让复数成为一个头等公民的数据类型,所有操作、所有梯度,都按照复数域的数学规则自动完成。从写代码的角度,也更接近公式本身。
2. 复数张量的创建、查看与底层布局
2.1 三种常见创建方式
第一种:用torch.complex函数,从两个实数张量构造。
import torch real = torch.tensor([1.0, 2.0, 3.0]) imag = torch.tensor([4.0, 5.0, 6.0]) c = torch.complex(real, imag) print(c) # tensor([1.+4.j, 2.+5.j, 3.+6.j]) print(c.dtype) # torch.complex64注意real和imag的shape必须完全一致,否则会报错。
第二种:直接从Python复数列表创建,PyTorch会推断成complex64或者complex128。
c2 = torch.tensor([1+2j, 3+4j]) print(c2.dtype) # torch.complex64如果你想要双精度,用torch.tensor(..., dtype=torch.complex128)。
第三种:先建立一个存储空间,然后原地填充。这个在需要预分配缓冲区的时候很有用。
c3 = torch.empty(3, dtype=torch.complex64) c3.real = torch.tensor([1., 2., 3.]) c3.imag = torch.tensor([4., 5., 6.])注意c3.real和c3.imag是复数张量的视图,直接给它们赋值是可以修改实部和虚部的,但前提是你没有对c3做复杂操作导致不是标准布局。
2.2 底层存储:最后一维是“实+虚”或者“双通道”
理解view_as_real本质,才能看懂后面所有跟内存布局有关的操作。
复数张量在PyTorch内部,沿着最后一个维度,将每个复数拆成两个连续的浮点数:先实部,后虚部。比如torch.complex(real, imag)得到的shape是(3,)的复数张量,view_as_real()之后会变成(3, 2)的实数张量。
cr = c.view_as_real() print(cr) # tensor([[1., 4.], # [2., 5.], # [3., 6.]]) print(cr.stride()) # (2, 1)这里stride的最后一位是1,也就是相邻的实部虚部物理上挨着。知道这一点,你就明白view_as_complex为什么要求最后一个维度必须是2,其实就是把最后一维合并成复数。反过来,view_as_real把复数张量的最后一维变成2。
实际内存中,一个复数张量的缓冲区大小,是numel * 2 * element_size,和两个浮点张量拼起来没有区别。所以你要手动处理模型参数、自定义算子时,可以放心地把复数参数view_as_real传给SGD,或者用它来做量化、剪枝。
2.3 常用查看操作与坑
.real和.imag返回的也是视图,但它们是只读视图。一个常见的坑是,直接写t.real = something,在早期版本里会报“无法赋值给只读属性”。我上面创建方式里给的c3.real = ...,是原地修改属性,等价于torch.view_as_real(c3)[..., 0] = ...,确实能跑。但是如果你的c3不是“最后一维紧凑排列”的复数张量,比如经过某些转置后,c3.real可能不是连续内存,赋值时可能会失败。最稳妥的做法是,用view_as_real显式转换后再操作分量。
另外,.conj()方法返回共轭复数张量,它也是视图,但会在背后记录负虚部。这是一个非常有用的操作,配合FFT或者做相关运算时很常见。
cj = c.conj() print(cj) # tensor([1.-4.j, 2.-5.j, 3.-6.j])3. 复数的基本运算与自动求导细节
3.1 加减乘除、幂、指数、对数的计算规则
复数张量支持绝大多数逐元素运算,和实数用法一致。比如加法c1 + c2、乘法c1 * c2,都是按复数乘法公式走。矩阵乘法torch.matmul也是标准复数乘法,内部会处理实虚部交叉项。除法在复数域要处理分母的共轭,但PyTorch已经封装好,你直接写就行。
a = torch.tensor([1+2j, 2-1j]) b = torch.tensor([2+0j, 1+1j]) print(a + b) # tensor([3.+2.j, 3.+0.j]) print(a * b) # tensor([2.+4.j, 3.+1.j]) print(a / b) # tensor([1.+1.j, 1.5-0.5j])乘方a**2、指数torch.exp(a)、对数torch.log(a)都是支持的。这些函数在底层已经有复数域正确的分支,比如torch.log(负数复数)会返回带虚部pi的结果,而不是NaN。这一点比某些第三方库强很多。同样,torch.sqrt(-1+0j)会返回0+1j,而不是报DomainError。
3.2 自动求导:Wirtinger微积分快速理解
复数自动求导是新手最容易懵的地方。PyTorch官方文档在torch.autograd部分专门讲了复数梯度,用到的数学就是Wirtinger微积分。
简单说:对于一个复数函数f(z),其中z = x + iy,我们希望定义梯度,使得梯度下降对实部虚部都有效。PyTorch实际计算的是两个偏导:
- 共轭函数关于
z的偏导(记为∂f/∂z*),这是主梯度。 - 另外还维护一个
∂f/∂z,用来处理非解析函数(比如torch.abs、torch.real)。
对于大多数深度学习场景,我们关注的是标量损失函数。假设损失L是实数,那么PyTorch要求我们的计算图最终能定义出grad_output。当你对一个复数张量调用.backward()时,PyTorch会为复数张量生成一个“共轭梯度”,这个梯度同时包含实部虚部的信息。
一个特别容易踩的坑是,复数张量作为模型参数,但损失函数是实数的,梯度回传到复数参数时,你会得到一个复数梯度。比如:
z = torch.randn(4, dtype=torch.complex64, requires_grad=True) loss = torch.abs(z).sum() # 实数损失 loss.backward() print(z.grad) # 一个复数张量 print(z.grad.real) # 实部梯度 print(z.grad.imag) # 虚部梯度如果此时你用SGD更新z:
with torch.no_grad(): z -= lr * z.grad这样直接减复数梯度,实际上相当于同时更新了实部和虚部,方向是负共轭梯度方向,在某些情况下这并不会让损失稳定下降,严格来说应该使用z.grad.conj()或者更新实部虚部。官方文档特别提到,复数参数的梯度是∂L/∂z*的共轭?其实不同框架定义不一样。PyTorch官方给出的更新方法是:把复数参数z拆成实部虚部,用view_as_real分别更新,或者使用torch.optim中的优化器,比如Adam,它内部会正确处理复数参数吗?
实测下来,torch.optim.SGD和torch.optim.Adam从1.7开始就能处理复数参数,它们内部会调用view_as_real相关逻辑,把复数梯度当作一个两倍维度的实数梯度来做更新,所以直接用优化器是最省心的方式。但如果是自己手写更新规则,一定不要直接复制实数写法。
3.3 梯度直接计算与backward的两种路径
如果你的模型比较复杂,还可以手动调用torch.autograd.grad来观察梯度。
z = torch.tensor([1+2j, 3+4j], dtype=torch.complex64, requires_grad=True) y = (z * z).sum() grad = torch.autograd.grad(y, z, grad_outputs=torch.tensor([1. + 0j]))这里grad_outputs本身必须是复数张量。如果你用浮点标量1,PyTorch会自动把它包装成复数,但建议显式给复数。
常见的问题:RuntimeError: Expected a floating point value for grad_output but got ComplexFloat。这个就是你给了一个复数计算图,但grad_outputs用了实数。解决方法是把grad_outputs改成torch.ones_like(y, dtype=torch.complex64)或者用torch.complex包一层。
4. 复数数据在神经网络中的实践:从复线性层到完整训练
4.1 为什么需要复数网络层
很多信号处理、通信、音频场景下,数据天然是复数形式。比如短时傅里叶变换后的频谱,实部和虚部都有物理含义。直接把它们当成两个实数通道送进网络,虽然也能学,但会破坏复数乘法结构——复数的乘法耦合了实部和虚部的交叉项,两个独立通道的线性层学不到这种耦合关系。复数线性层就不一样,它的权重本身是复数,输入是复数,输出也是复数:
y = W * x + b
其中W、x、b全是复数。展开来看,输出实部等于W_real * x_real - W_imag * x_imag + b_real,输出虚部等于W_real * x_imag + W_imag * x_real + b_imag。这个结构天然保留了复数乘法,而且参数量是实线性层的4倍(因为一个复数权重对应4个实数参数)。从效果上看,在频域数据处理场景,复数网络比实数网络更参数高效,因为权重共享了实虚部之间的耦合信息。
4.2 实现一个复数线性层
PyTorch本身没有直接给ComplexLinear这个模块,需要自己造。好在有view_as_real和torch.nn.Parameter,实现非常简单。
import torch import torch.nn as nn class ComplexLinear(nn.Module): def __init__(self, in_features, out_features, bias=True): super().__init__() self.in_features = in_features self.out_features = out_features # 权重参数用复数张量 self.weight = nn.Parameter( torch.randn(in_features, out_features, dtype=torch.complex64) / (in_features ** 0.5) ) if bias: self.bias = nn.Parameter(torch.zeros(out_features, dtype=torch.complex64)) else: self.register_parameter('bias', None) def forward(self, x): y = torch.matmul(x, self.weight) if self.bias is not None: y = y + self.bias return y注意torch.matmul输入是复数,输出就是复数。这里权重初始化用randn然后除以sqrt(in_features),是为了让输出方差保持在合理范围,避免梯度爆炸。如果你想要更规范,可以参考Glorot初始化,但复数域的初始化有专门公式,我这边为了演示,这样已经够用了。
4.3 复激活函数:搞错就废
复数域没有天然一一对应的激活函数,因为激活函数必须满足柯西-黎曼方程才可微,而relu这类分段函数在复数域不成立。实践中常用的做法是“分裂激活”:对实部和虚部分别做ReLU,或者用复数模长归一化加相位保持。我这里用“分裂ReLU”作为示意。
class ComplexReLU(nn.Module): def forward(self, x): real = x.real.clamp(min=0) imag = x.imag.clamp(min=0) return torch.complex(real, imag)注意,x.real是只读视图,不能原地修改,但可以通过torch.complex重建一个张量,没问题。
另外一个常见选择是ModReLU,它对复数模长做max(m - b, 0),保留相位,实现也不难。如果不想纠结,直接当作两个实数特征图处理,也就是view_as_real后过一组实数激活再view_as_complex,也可以。
4.4 完整训练样例:复数信号拟合
我来模拟一个任务:输入是一个复数序列x_n,输出是它的平方加一个固定复数偏移,即y = x^2 + c。我们用一个很小的复数MLP(一个复数线性层加ReLU再加一个复数线性层)去拟合。
import torch import torch.nn as nn import torch.optim as optim torch.manual_seed(42) # 生成数据:100个样本,每个样本4维复数 N = 100 dim = 4 x = torch.randn(N, dim, dtype=torch.complex64) true_c = torch.tensor([1+2j, -0.5+1j, 2-1j, 0.3+0.7j], dtype=torch.complex64) y = x ** 2 + true_c model = nn.Sequential( ComplexLinear(dim, 16), ComplexReLU(), ComplexLinear(16, dim) ) opt = optim.Adam(model.parameters(), lr=0.02) loss_fn = nn.MSELoss(reduction='mean') model.train() for epoch in range(200): opt.zero_grad() pred = model(x) mse = (pred - y).abs().pow(2).mean() # 复数域的MSE,abs平方后是实数 mse.backward() opt.step() if epoch % 20 == 0: print(f"epoch {epoch}, mse {mse.item():.6f}")这段代码里最关键的是(pred - y).abs().pow(2).mean()。因为pred和y都是复数,直接(pred - y).pow(2).mean()算出来还是复数,而损失必须是实数标量。所以先取模长再平方,得到实数损失。这就是我前面说的“对复数网络做训练,损失计算必须拆到实数域”。
4.5 与实数双通道网络的对比
我拿同一个数据,用实数双通道MLP训练,同样的参数量级,跑对比:
- 复数网络在相同训练轮数下收敛速度明显更快,最终MSE也更低。
- 实数网络需要把输入从4维复数变成8维实数,然后输出8维实数再拆成实虚部,代码更多,但因为完全丢掉了交叉项结构,学到的映射不够“准”。
当然这不能一概而论,但在频域任务里这种结果很常见。核心原因就是复数乘法这种先验知识被编码进了网络结构,数据利用率更高。
5. 常见问题与排查技巧实录
5.1 内存布局相关:view_as_complex的报错
最常见的报错是:
RuntimeError: view_as_complex is only supported for tensors with last dimension of stride 1
什么意思?就是说你想把最后一个维度为2的实数张量变成复数张量,要求最后一个维度的stride是1,也就是物理上相邻。如果你的张量经过了某些切片、转置、维度交换,最后维度不再是连续内存,就会报这个错。
比如:
r = torch.randn(3, 2) r_t = r.transpose(0, 1) # shape 变成(2,3),但stride被调换了 try: torch.view_as_complex(r_t) except RuntimeError as e: print(e)解决办法是调用.contiguous():
r_t_contig = r_t.contiguous() c = torch.view_as_complex(r_t_contig)还有一次我用了torch.permute,把原本最后一维为2的维度移到了中间,再使用view_as_complex就报错。这时候要么permute后再contiguous(),要么在设计张量布局时就坚持把复数的两个分量放最后一维。
5.2 backward时出现“未实现”错误
复数支持是逐步扩展的,早期版本里一些算子不支持复数反向。哪怕现在,也偶尔能看到:
RuntimeError: derivative for 'relu' is not implemented for ComplexFloat
这是因为复数ReLU本身不符合全纯条件,PyTorch没有为其定义梯度,或者你用了某些实数算子但加了复数输入。处理方式就是我上面那样,自定义激活函数,或者将其拆成real/imag分别计算梯度。总之,能用torch.complex重新组合解决。
5.3 CPU与GPU的行为差异
大部分复数运算在GPU上有对应CUDA实现,但个别函数在CPU上能跑,到了GPU上就报“not implemented for CUDA”。我遇到过torch.linalg.det对复数支持不完整的情况。另外,AMP混合精度训练对复数支持也有坑,torch.cuda.amp.GradScaler在更新复数参数时可能会累积误差,目前建议对复数参数单独管理或者关掉AMP。
5.4 复数张量无法直接保存为numpy?
Numpy支持复数,但PyTorch的复数张量转numpy没问题:
c = torch.tensor([1+2j]) print(c.numpy()) # array([1.+2.j]) torch.from_numpy(c.numpy()) # 转回来要注意的是,torch.from_numpy对complex128会映射到torch.complex128,对complex64会映射到torch.complex64。如果你想保存到文件,建议直接torch.save(c, 'c.pt'),或者用np.save配合.numpy()。
5.5 一个容易被忽略的梯度方向问题
如果你手写参数更新,比如:
with torch.no_grad(): param -= lr * param.grad这个更新方向和真正的梯度方向不一致。因为PyTorch给出的复数梯度是Wirtinger梯度,它同时编码了关于实部和虚部的偏导。要正确手写更新,最好做:
with torch.no_grad(): real_grad = torch.view_as_real(param.grad)[..., 0] imag_grad = torch.view_as_real(param.grad)[..., 1] param_real = torch.view_as_real(param)[..., 0] param_imag = torch.view_as_real(param)[..., 1] param_real -= lr * real_grad param_imag -= lr * imag_grad或者直接使用官方优化器,让PyTorch内部处理。
6. 高效使用复数张量的几个独家技巧
6.1 用view_as_real完成参数初始化
复数参数如果直接用torch.randn初始化,实部和虚部独立采样,得到的复数模长分布其实是瑞利分布,方差较大。想要控制方差,可以先初始化一个实数参数矩阵,view_as_real后对两通道统一缩放。
weight_real = nn.Parameter(torch.randn(out_features, in_features, 2)) weight_complex = torch.view_as_complex(weight_real)这样参数还是复数,但底层是两个实数通道。代码上,建议把复数参数保持为nn.Parameter(dtype=torch.complex64),因为这样model.parameters()才会正确收集。如果你只保留weight_real为参数,就没有复数参数了,计算图也能走,但复数相关梯度就没法直接拿到,比较别扭。
6.2 用FFT时注意频率轴
PyTorch的torch.fft模块可以很自然地处理复数张量。常用场景是torch.fft.fft(x),返回复数张量。如果你想做实数信号的频谱,输入实数,输出复数。整个过程不需要你手动构造虚部,非常简洁。
x = torch.randn(64) X = torch.fft.fft(x) print(X.dtype) # torch.complex64但这里有一个坑:如果你对一批复数张量做FFT,比如shape为(batch, time),默认是在最后一维做变换。如果你想要沿着时间轴做FFT,而时间轴是倒数第二维,务必加dim参数,否则结果会莫名其妙。
6.3 复数张量拼接、堆叠的注意事项
torch.cat对复数张量沿着dim=0或者已有维度拼,没问题。但如果你想用torch.stack生成一个“把复数看作分量”的维度,比如stack实部和虚部,得到[..., 2],那么再想view_as_complex就要小心排列顺序。如果之前是torch.stack([real, imag], dim=-1),那么维度顺序是实、虚,直接view_as_complex可以。如果是torch.stack([imag, real], dim=-1),那会得到错误的复数。建议统一使用torch.complex(real, imag)这种凭语义创建的方式,避免人为搞混。
6.4 写一个复数感知的调试小工具
调试复数张量时,我经常需要打印实部、虚部、模长、相位。可以直接写一个函数:
def dump_complex(name, tensor): print(f"{name}: shape={tuple(tensor.shape)}, dtype={tensor.dtype}, abs_mean={tensor.abs().mean().item():.4f}")能够在排查NaN的时候快速定位是实部还是虚部出了问题。经验是,复数模型梯度爆掉时,模长会变得巨大,这时候先用上面的函数打日志,看是哪个层输出的模长异常,然后检查该层的权重和输入。
结尾
关于PyTorch复数处理,说一个我自己最近常用的小技巧:训练复数神经网络时,如果发现损失下降很慢,不妨先把所有复数参数view_as_real看一下实部和虚部的梯度分布,很多情况下虚部梯度波动特别大,这时候可以给虚部单独配一个更小的学习率,或者用torch.fft在频域把权重中心化一下。这个做法不是从什么论文里看来的,是某次做一个类似复数频谱预测的项目时,试了很多初始化都不稳定,后来发现虚部梯度方差是实部的两三倍,于是手动给复数优化器加权重衰减,马上就稳了。复数处理本身并不难,难的是把Wirtinger梯度和内存布局搞透,只要这两块理解了,后面“复数只是另一种浮点张量”,很多问题自然就通了。希望上面这些记录能帮你少踩我当年踩过的坑。