复数卷积神经网络Python源码:原理、实现与MNIST实战
简介:这是一套复数卷积神经网络的Python源码实现,完整覆盖复数卷积、复数池化、复数激活函数和复数全连接层,并以详细注释展示每一步计算逻辑。项目源自课程设计实践,获得导师认可并评为98分,适合计算机、电子信息工程和数学等专业学生用于期末大作业、课程设计或毕业设计参考。压缩包共5个文件,包括4个py脚本和1个markdown文档,分别实现网络结构搭建、反向传播、辅助测试、MNIST手写数字识别示例及说明文档,整体仅7KB,结构轻量清晰,便于阅读和二次修改。目前已有74人浏览学习,适合希望从实值CNN过渡到复数域、深入理解复数运算在神经网络中实际应用的学习者;借助这份代码可快速掌握自定义复数层与反向传播的写法,并利用MNIST示例验证模型效果,为后续研究或项目扩展提供可运行的起点。
1. 复数卷积神经网络:这份Python源码包里到底藏了什么
做深度学习的人都知道,常规CNN把输入、权重、激活全部限定在实数域,图像变换、信号相位这类信息在卷积过程里被当成“可有可无的附带品”。但如果你的任务是雷达回波分析、通信信号调制识别、或者带相位特征的图像分类,实数网络会丢掉一个关键维度:相位。这份基于复数的卷积神经网络源码,做的就是让卷积、池化、激活、全连接层层都跑在复数域上,网络内部同时处理幅度和相位。它包括 network.py、backward.py、mnist.py、test_complex.py 四个核心文件,带详细中文注释,能在MNIST数据集上完整跑通前向和反向传播。对计算机、电子信息工程、数学专业的学生来说,它既是课程设计和期末大作业的现成骨架,也是理解复数域深度学习的入门材料——不需要自己从零推导复数矩阵乘法,打开就能看每一步是怎么算的。
2. 复数卷积、池化、激活、全连接:把四个核心模块拆开看
2.1 复数卷积的前向计算:实部虚部怎么组合
复数卷积和实数卷积最大的区别在于:输入、滤波器都是复数,卷积结果要同时保留实部与虚部。很多人第一次看复数卷积代码会懵,以为直接用现成卷积函数处理复数张量就行,实际上 NumPy 和主流深度学习框架都不会隐式帮你算复数卷积的实虚交叉项,得拆开手动组合。
复数乘法公式是 (a+bi)(c+di) = (ac-bd) + (ad+bc)i。放到卷积场景里,输入 x = xr + xii,滤波器 w = wr + wii,输出 y 的实部等于 “xr 与 wr 卷积 - xi 与 wi 卷积”,虚部等于 “xr 与 wi 卷积 + xi 与 wr 卷积”。源码里正是按这个思路实现的核心计算:
import numpy as np def complex_conv2d(x_real, x_imag, w_real, w_imag, stride=1, padding=0): """ 复数卷积前向:分别对实部和虚部做实数卷积再组合 x_real, x_imag: 输入实部、虚部,形状 (N, C, H, W) w_real, w_imag: 卷积核实部、虚部,形状 (F, C, kH, kW) """ # 实部 = 实部卷实部 - 虚部卷虚部 out_real = conv2d(x_real, w_real, stride, padding) - conv2d(x_imag, w_imag, stride, padding) # 虚部 = 实部卷虚部 + 虚部卷实部 out_imag = conv2d(x_real, w_imag, stride, padding) + conv2d(x_imag, w_real, stride, padding) return out_real, out_imag这里的 conv2d 可以直接用 scipy.signal 或自己写滑窗,源码为了不依赖框架,用的是 NumPy 手动实现。
需要注意两个细节。第一,卷积核的实部和虚部是独立初始化的,但初始化范围不能瞎给,否则相位信息一开始就被破坏;第二,stride 和 padding 对实部虚部必须完全一致,不能出现实部减一行、虚部不减的情况——这种bug极难排查,因为loss照样会下降,但提取的特征是错的。
复数卷积的输出形状和实数卷积一致,(N, F, H_out, W_out),后续接池化层时不需要特殊处理维度。有一点对新手友好:复数卷积本身不需要额外引入参数,只是把实数卷积的次数从1次变成4次,计算量翻4倍,但网络表达能力换来的是相位和幅度两个维度的信息保留。
2.2 复数池化:平均池化在复数域怎么算
池化层在复数CNN里的作用不只是降维,还要在缩小特征图尺寸的同时不破坏相位结构。复数池化不能像实数那样直接对每个通道取最大值,因为复数没有“大小”的自然定义——两个复数谁大谁小?
源码里采用的做法是对幅度(模长)池化:先算出每个位置复数的模长,再按照实数池化的规则决定选哪个位置,然后把选中的复数(实部和虚部一起)送入下一层。以平均池化为例,复数平均池化的计算公式是:对池化窗口内所有复数的实部求平均得到输出实部,对虚部求平均得到输出虚部,等价于先逐元素求幅度再做加权组合。
def complex_avg_pool2d(x_real, x_imag, pool_size=2, stride=2): """ 复数平均池化:实部虚部分开做平均池化 这样等价于对窗口内复数向量的均值,保留了整体相位趋势 """ N, C, H, W = x_real.shape H_out, W_out = H // pool_size, W // pool_size out_real = np.zeros((N, C, H_out, W_out)) out_imag = np.zeros((N, C, H_out, W_out)) for i in range(H_out): for j in range(W_out): # 取池化窗口 window_real = x_real[:, :, i*pool_size:(i+1)*pool_size, j*pool_size:(j+1)*pool_size] window_imag = x_imag[:, :, i*pool_size:(i+1)*pool_size, j*pool_size:(j+1)*pool_size] out_real[:, :, i, j] = window_real.mean(axis=(2, 3)) out_imag[:, :, i, j] = window_imag.mean(axis=(2, 3)) return out_real, out_imag复数的平均池化有一个容易被忽略的特性:如果窗口内两个复数幅度相近但相位相反,实部虚部分别平均后可能得到一个模长很小的结果,这在物理上表示“信息抵消”。实数平均池化不存在这个问题,因为数值只会取正。如果做的是信号分类,这种相位抵消恰恰是有意义的特征;如果做图像分类,它可能造成信息丢失。所以选择平均池化还是最大池化,取决于数据集里相位信息是否重要——这是做课程设计答辩时能拿得出手的分析点。
2.3 复数激活函数:为什么ReLU不能直接拿来用
实数域的ReLU激活函数是 max(0, x),但复数没有天然的大小比较关系。如果把ReLU直接套在实部和虚部上,等于把相位信息野蛮地截断了:相位为45°的复数 (1, 1) 经过“实部虚部分别ReLU”后相位变成45°不变,但相位为135°的复数 (-1, 1) 会变成 (0, 1),相位从135°被扭曲成90°。相位被破坏,复数CNN的核心优势就废了。
源码里实现的复数激活函数有两种方案。第一种叫 ModReLU,公式是 f(z) = z · max(0, |z| + b) / |z|,其中 b 是可学习的偏置参数。它的思路是:方向(相位)不变,只对幅度做非线性缩放,类似在极坐标下做ReLU。第二种是直接对幅度做ReLU然后乘回原方向。
def modrelu(z_real, z_imag, bias, threshold=0.0): """ ModReLU 激活:保持相位,只调整幅度 z = (|z| + bias) / |z| * z, 当 |z| + bias > 0 bias: 可学习参数,复数全连接/卷积层后一般初始化为0或小正数 """ # 计算模长 magnitude = np.sqrt(z_real**2 + z_imag**2) # 缩放系数:模长+bias,小于0的位置置0 scale = np.maximum(magnitude + bias, threshold) / (magnitude + 1e-12) out_real = scale * z_real out_imag = scale * z_imag return out_real, out_imag注意代码里的 1e-12,这是为了避免模长为0时出现除零错误。实际调试中我遇到过模长极小(1e-8量级)的情况,这时候缩放系数会被放大到离谱,造成梯度爆炸。常见的做法是给模长加一个较小的 epsilon 而不是用 max 函数硬截断到0,比如 1e-6,能让训练稳定很多。
期末大作业答辩时,有人问“为什么不用复数版本的tanh或sigmoid”,答复思路是:这些激活函数在复数域存在无界性和多值性问题,实现复杂且训练不稳定。ModReLU 保留了ReLU家族计算简单、梯度稀疏的优点,同时不破坏相位,是复数CNN里性价比最高的选择。这一个点就能看出你不是照着代码抄的,而是理解过选型逻辑。
2.4 复数全连接层:从特征到分类输出的最后一步
复数全连接和实数全连接的结构一样:输入向量乘以权重矩阵加偏置,区别只在每个元素都是复数,权重矩阵的实部和虚部需要分别定义。前向计算的代码逻辑如下:
def complex_linear(x_real, x_imag, w_real, w_imag, b_real, b_imag): """ 复数全连接层前向 w_real, w_imag: 形状 (out_features, in_features) 的权重实部和虚部 """ # 复数矩阵乘法:同样遵循 (a+bi)(c+di) 的分解规则 out_real = x_real @ w_real.T - x_imag @ w_imag.T + b_real out_imag = x_real @ w_imag.T + x_imag @ w_real.T + b_imag return out_real, out_imag复数全连接的输出是实部虚部两路,在进入损失函数之前必须合并成真正的复数或者取模长。源码里分类任务的做法是取输出复数向量的模长作为置信度分数,然后接 softmax。这里有个值得注意的设计决策:取模长意味着丢弃了输出的相位信息,如果任务是相位敏感的(比如IQ信号解调),这一步会损失关键信息;如果任务只是分类,取模长反而是最合理的,因为类别的判断不应该依赖于输出的方向。
模型整体的信息流是:输入图像转换成复数表示 → 复数卷积层提取相位幅度特征 → 复数池化降维 → ModReLU非线性 → 展平 → 复数全连接 → 取模长 → softmax 分类。每一步都保留了复数的双通道特性,直到最后一刻才压缩成实数概率。test_complex.py 文件里有一段验证复数运算正确性的测试代码,包含复数乘法、加法、模长计算的单元测试,初次上手建议先跑这个文件确认环境没问题,再进训练。
3. 从 network.py 到 backward.py:复数反向传播的梯度推导
3.1 复数链式法则:实部和虚部要分别求梯度
复数反向传播是这整套代码里最有含金量的部分,也是课程设计答辩时最容易暴露“懂不懂”的环节。实数神经网络的反向传播你用链式法则一路求导就行,但复数网络里每个中间变量都是复数,梯度必须同时作用在实部和虚部上,而且复数函数的可导性条件(柯西-黎曼方程)在深度网络里几乎不成立,所以不能简单地对复数变量“求导数”。
工程上的处理方式是 Wirtinger 微积分:把复数 z = x + yi 看成两个独立实数 x 和 y 的函数,损失对实部的梯度 ∂L/∂x 和损失对虚部的梯度 ∂L/∂y 分别计算,再用这两个梯度去更新实部和虚部。反过来看,更新参数学的是“实部权重要往哪个方向调、虚部权重要往哪个方向调”,而不是“整个复数要往哪个方向调”。
3.2 反向传播的核心代码走读
backward.py 里实现了各层的反向传播。以复数全连接层为例,假设前向是 out = x·w^T + b,反向传播需要计算三组梯度:对输入 x 的梯度(传给上一层)、对权重 w 的梯度(用于参数更新)、对偏置 b 的梯度。复数乘法里实部和虚部交叉耦合,所以梯度也是交叉的,代码逻辑如下:
def complex_linear_backward(grad_out_real, grad_out_imag, x_real, x_imag, w_real, w_imag): """ 复数全连接层反向传播 grad_out_real, grad_out_imag: 来自损失函数对输出实部虚部的梯度 前向: out_real = x_real @ w_real.T - x_imag @ w_imag.T + b_real out_imag = x_real @ w_imag.T + x_imag @ w_real.T + b_imag 所以反向传播需要算清楚每个输入变量对两个输出的贡献 """ # 对输入实部的梯度:同时收到 out_real 和 out_imag 两路的回传 grad_x_real = grad_out_real @ w_real + grad_out_imag @ w_imag # 对输入虚部的梯度 grad_x_imag = grad_out_imag @ w_real - grad_out_real @ w_imag # 对权重实部的梯度 grad_w_real = grad_out_real.T @ x_real + grad_out_imag.T @ x_imag # 对权重虚部的梯度 grad_w_imag = grad_out_imag.T @ x_real - grad_out_real.T @ x_imag return grad_x_real, grad_x_imag, grad_w_real, grad_w_imag这段代码是复数CNN最容易写错的地方,因为好几个梯度的符号是反直觉的。虚部权重的梯度里有一项是负号,很多人在手写实现时漏掉这个负号,导致训练发散。怎么自查呢?你可以把复数乘法拆回实数形式验证:out_real = x_real·w_real - x_imag·w_imag,那 ∂out_real/∂x_imag = -w_imag,这个负号对应的梯度项就是 grad_x_imag 里减掉的那一项,逻辑完全自洽。
卷积层的反向传播更复杂一点,需要把卷积运算转换成矩阵乘法再求梯度。源码的做法是把卷积核构造成 Toeplitz 矩阵,然后用全连接层的反向传播逻辑处理。这个设计的思路值得学:你在 debug 的时候不用去推导卷积的滑动窗梯度,只需要复用已经验证过的全连接梯度代码。
3.3 梯度检验:怎么确认反向传播写对了
复数反向传播写没写对,不能靠训练收敛来判断——因为即使梯度方向有偏差,只要大部分是正确的,SGD也能凑合收敛,但收敛速度和最终精度都会受损。可靠的方法是数值梯度检验:用小扰动 h 在实部和虚部分别加扰动,比对解析梯度和差分梯度。
def gradient_check(x_real, x_imag, w_real, w_imag, epsilon=1e-6): """ 数值梯度检验:验证反向传播梯度是否写对 对每个权重在实部方向加 epsilon 扰动,观察 loss 的变化率 """ loss_plus = forward(x_real, x_imag, w_real + epsilon, w_imag) loss_minus = forward(x_real, x_imag, w_real - epsilon, w_imag) numerical_grad = (loss_plus - loss_minus) / (2 * epsilon) # 对比解析梯度,相对误差小于 1e-5 就认为实现正确 analytical_grad = backward(x_real, x_imag, w_real, w_imag)[2] relative_error = np.abs(numerical_grad - analytical_grad) / (np.abs(numerical_grad) + np.abs(analytical_grad) + 1e-12) assert relative_error < 1e-5, f"梯度校验失败: {relative_error}" print(f"梯度校验通过,相对误差: {relative_error:.2e}")跑梯度检验时注意 epsilon 的量级不能太大(梯度被平滑掉)也不能太小(浮点精度导致误差上升),1e-5 到 1e-6 比较合适。网络里每一层都要做一次这个检验,测试通过之后再开始训练,能帮你把“训练失败到底是优化问题还是梯度写错”这个变量直接排除掉。mnist.py 配套的数据和训练流程可以在几分钟内复现一个可用的模型,而不需要等十几个小时训练完才发现梯度算错了。
4. MNIST手写数字识别:mnist.py 的完整训练流程复现
4.1 数据预处理:实数图像怎么转成复数
MNIST数据集是灰度图像,每个像素是0到255的实数,直接塞进复数网络当然不行,源码里的做法是把实数像素映射成复数表示。最直接的方式是把像素值当作复数的实部,虚部补零,这样等于在复数域里跑一个普通CNN,相位信息全是0,没有发挥复数网络的优势。
更好的方式是把图像做傅里叶变换或者小波变换后再取实虚部,这种表示天然有相位信息。源码选择了一条折中路线:每个像素的值 v 映射成 z = v + i·v,即实部和虚部相等,这样相当于给每个像素一个固定45°的相位,在卷积运算里会与复数滤波器产生丰富的相位交互。对于MNIST这种图像分类任务,虚部补零和虚部复制都能收敛,但复制方式的收敛速度略快,因为等于把有效梯度的数值范围扩大了一倍。
def load_mnist_to_complex(image_data): """ 把 MNIST 灰度图转成复数输入 像素值归一化到 [0, 1] 后,实部虚部相等 """ # 归一化像素值 normalized = image_data.astype(np.float32) / 255.0 # 扩一维做复数通道:实部 = 像素值, 虚部 = 像素值 x_real = normalized.reshape(-1, 1, 28, 28) x_imag = normalized.reshape(-1, 1, 28, 28) return x_real, x_imag数据读取部分用到了一般的 np.load 逻辑或自带的数据加载函数,第一次跑之前确认数据目录路径正确即可。MNIST的60000张训练图片,CPU上训练几百个epoch大概几十分钟,比实值CNN同规模要慢不少——毕竟每次卷积都算4次实数卷积,这是复数网络的固有代价。
4.2 训练超参设置:学习率是复数网络最容易翻车的参数
超参数配置在源码里可以直接改,几个关键参数值得展开说明。第一是学习率,复数网络的学习率通常要比实数网络小一个数量级,因为复数的实部和虚部梯度耦合,同一个参数更新会同时影响输出幅度和相位,学习率稍大就会导致相位震荡。第二是权重初始化,源码里用的是幅度固定为常数、相位随机均匀分布,比如 |w| = 0.5,θ 在 (0, 2π) 均匀采样,保证每个滤波器初始状态覆盖各个方向。第三是batch size,建议取32或64,太大容易让幅度梯度累计过快。
# mnist.py 里的训练配置示例 config = { 'learning_rate': 0.001, # 比实值CNN小一个量级 'batch_size': 64, 'epochs': 20, 'init_amplitude': 0.5, # 权重初始化幅度 'activation_bias': 0.1 # ModReLU 的初始偏置 }训练循环和普通PyTorch/TensorFlow的训练循环结构一样:前向计算得到输出和loss,反向传播拿到梯度,用梯度下降更新复数全连接层和卷积层的实部虚部。注意一个细节:损失函数的梯度是分别对输出实部和虚部求的,不是对复数模长求的。源码里在这个位置处理得很干净,直接取出实部虚部两路梯度回传。
4.3 训练结果的经验判断:怎么确认复数网络真的在学东西
跑训练后不能只盯着loss下降,建议每隔几个epoch把卷积核可视化一次。做法是把复数卷积核的实部虚部分别画成灰度图,再画幅度图和相位图。如果幅度图出现了类似边缘检测、纹理分组的结构,说明网络学到的是有意义的特征;如果幅度图全是均匀的噪点,说明训练可能出问题了。这个验证手段在实值CNN里是锦上添花,在复数CNN里是必做功课——因为复数特征的“学到东西”的表现形式和实数完全不同,幅度和相位要分开看。
另外一个更直接的验证方式是和实值CNN基线对比。在相同数据、相同层数、相同训练轮数下,复数网络在MNIST上的准确率能达到98%左右,比优秀实值CNN的99.2%略低,这是正常的,因为复数卷积的参数利用率低、训练难度高。但如果你的复数网络连95%都达不到,先检查是不是前面说的梯度符号问题,再检查激活函数是否生效。这个数字对比放到答辩PPT里很有说服力:复数网络在MNIST上略逊于实值网络,恰恰说明相位特征对灰度图像分类没有增益——你是在展示对方法边界的理解,而不是无脑吹复数网络全面领先。
5. 避坑与常见问题:跑复数CNN翻车的五个现场
5.1 权重初始化不规范导致训练完全不动
现象:loss从第一步开始就恒定不变,或者下降极其缓慢,准确率始终在10%左右(随机水平)。
原因:复数权重初始化时实部虚部分别用标准正态分布采样,初始复数幅度的期望值接近0.8,但偶尔会出现幅度极小(小于1e-4)的滤波器,经过复数卷积和ModReLU后梯度被非线性压缩到接近0,网络局部死掉。更常见的情况是把实部虚部都用 np.random.randn 直接采样,导致初始相位分布不均匀,某些方向的表达能力先天缺失。
解决:按“固定幅度+均匀相位”初始化。幅度取 1/√fan_in(fan_in是输入通道数乘卷积核尺寸),相位在(-π, π)均匀采样,然后合成实部虚部。代码里用幅度乘 cos/sin 生成权重,而不是直接随机实部虚部,这个处理保证所有方向都有可学习的初始特征。
5.2 梯度爆炸集中爆发在虚部通道
现象:训练到十几个epoch时loss突然飙到NaN,查看梯度发现虚部梯度的L2范数比实部大几个数量级。
原因:复数全连接层的反向传播里,虚部梯度有两项符号相反,当网络权重幅度较大时,这两项相减会造成灾难性抵消后残留一个很大的误差项。这种情况在实值网络里不存在,是复数域特有的数值问题。
解决:把学习率先降价十倍到0.0001,同时给卷积层的输出加一个小的谱归一化或直接限制权重幅度不超过1。源码之所以每层卷积后都接ModReLU而不是别的激活,也有这层考虑——ModReLU的缩放系数天然把输出幅度限制在与输入同量级的范围内,对抑制梯度爆炸有帮助。
5.3 NumPy复数运算的类型不兼容报错
现象:代码报错 TypeError: numpy.complex128 is not supported,或者复数数组的 dtype 在运算中悄悄变成 object。
原因:NumPy 早期版本里 np.complex 是复数类型别名,但如果你手动用 list 混装实部虚部再 np.array 转换,dtype 会被推断成 complex128 没错,可后续和 int 数组做四则运算时可能被自动强转成 object 类型,速度慢到离谱还报错。
解决:所有复数中间结果显式声明 dtype。输入数据统一用 np.complex64 或 np.complex128,卷积核对齐用同一类型,绝不依赖 NumPy 自动类型推断。test_complex.py 里的单元测试覆盖了这几种数据类型转换场景,改代码时先跑一遍它,能拦截掉八成类型问题。如果用的 python 版本比较新,建议先把 numpy 升级到最新版再跑,老版本在复数数组广播行为上有坑。
5.4 池化操作把相位信息洗没了
现象:模型训练正常、loss正常下降,但分类精度始终比实值CNN低3个点以上,而且怎么调参都上不去。
原因:复数最大池化按幅度取最大值时,选中的位置可能只在该局部幅度大,但相位是随机跳变的,相当于在池化窗口内做了相位重采样,破坏了相邻位置的相位连续性。对图像分类任务这种破坏程度可能可接受,但对信号任务就是致命的。
解决:换用复数平均池化,或者把最大池化改成“按幅度加权平均”——幅度大的位置权重高,幅度小的位置权重低,既降维又保留相位趋势。这个加权平均的思路和 attention 机制有异曲同工之处。源码里默认给的是平均池化,不要轻易改成最大池化不加验证。
5.5 损失函数选错导致模型“假装收敛”
现象:训练损失降到了0.05以下,准确率却只有85%,看起来loss很低但泛化很差。
原因:损失函数的梯度作用在输出复数的实虚部上,模型学会了“让实部虚部都变成同一个方向的小值”来骗过loss,而不是真正把类别区分开。取模长后再算交叉熵,等价于只用幅度信息,模型会倾向缩小幅度来降低loss。
解决:把损失改成复数域专用的形式,比如对输出实虚部各自计算交叉熵再加权合并,或者用复数域对比损失。最简单的做法是直接检查输出复数的幅度分布:如果所有类别的输出幅度都趋近于0,说明模型在用死路骗loss,这时候加一个幅度的正则项,强制幅度保持在一定量级以上。
6. 把模块抽出来迁移:一套能复用的复数CNN组件写法
这个项目最大的价值不在它本身跑通了MNIST,而在于四个文件拆开后都是自治模块:network.py 只管前向和结构,backward.py 只管梯度,mnist.py 是数据接入层。你换成自己的数据集时,不需要碰反向传播代码,只要改数据预处理和网络入口。
我自己的建议是:把 test_complex.py 当作“复数运算的单元测试集”来用。每次修改网络层代码,先跑这个文件,再跑一次梯度检验,最后才进训练。这一套流程下来,我的复数网络代码从写出来到稳定跑通,花的调试时间比预期少了一半还多。
迁移时碰到最典型的坑是新数据怎么转复数。图像数据我建议先用傅里叶变换把时域/空域信号变换到频域,再取实部虚部作为两个通道输入,这样相位信息就是真实物理含义上的相位,而不是硬造的。如果任务是时序信号分类,直接对原始波形做短时傅里叶变换再构建复数输入,效果远好于把原始值塞进复数网络。
还有一个小技巧:把复数层包装成和现有框架接口兼容的形式。比如你只需要定义一个类,forward时返回(real, imag)元组,backward时接收两路梯度,这样就能在PyTorch的autograd框架里用自定义Function方式嵌入复数层,后续改造成本最低。那以后我每拆一个复数网络项目,都强制走一遍“单元测试→梯度检验→小数据过拟合→全量训练”四步流程,遇到问题先从数值稳定性查起,最后再做结构调优。希望这套思路对你的课程设计或者期末大作业有实际帮助,少走我当初走过的那些弯路。
本文还有配套的精品资源,点击获取