纯NumPy手写CNN:从卷积反向传播到MNIST实战

简介:本资源是一份面向深度学习初学者与原理探究者的纯Python卷积神经网络(CNN)实现方案,不依赖TensorFlow、PyTorch等框架,聚焦底层计算逻辑与反向传播机制的理解。适合高校学生、转行学习者及希望夯实CNN数学基础的开发者,用于图像识别原理教学、课程设计或自主实验。压缩包共6个文件,含4个核心Python脚本(myCnn.py实现卷积/池化/全连接层及训练流程;analysisMNIST.py提供完整MNIST手写数字识别训练与评估流程;myCnnTest.py用于模块功能验证;gParam.py统一管理网络结构、学习率等关键参数)及2个.gz格式MNIST测试集数据文件,整体大小仅1.58MB,轻量易解压运行。已有1917人学习下载,资源结构清晰、注释充分,附带可直接执行的端到端训练-验证-分析链路,帮助读者从零掌握权重更新、特征图计算、损失梯度推导等关键环节,是理解深度学习内核不可多得的实践范例。

1. 从零手写 CNN:不靠 PyTorch、不调 TensorFlow,纯 Python + NumPy 实现前向传播、反向传播与参数更新,跑通 MNIST 分类(准确率 92.3%)

你有没有试过,在面试被问“CNN 的卷积层怎么反向传播”时,张嘴就卡壳?不是记不住公式,而是——从来没见过它在代码里真实长什么样。这个项目就是为这种时刻准备的:它用不到 800 行纯 Python(仅依赖 NumPy),完整实现了卷积层、池化层、全连接层、Softmax 损失、SGD 优化器,连np.pad()和np.flip()的使用细节都暴露在阳光下。它不封装、不抽象、不跳步,每个.forward()和.backward()方法都对应教科书级的数学推导。适合两类人:一是刚学完 CNN 理论、想亲手把 δ 误差从输出层一层层“推回去”的学生;二是需要快速验证某层梯度逻辑、或给嵌入式设备移植轻量 CNN 的工程师。它不是玩具,MNIST 上实测 92.3% 准确率(训练 5 轮,batch=32);它也不是工业级框架,没有自动广播、没有 CUDA 加速、没有图优化——正因如此,你才能看清权重更新时dW += input.T @ delta这一行背后,input 是哪一维、delta 是哪一维、为什么不能写成delta @ input.T。如果你厌倦了model.fit()黑匣子,想亲手拧紧每一颗梯度螺丝,这就是你该下载并逐行调试的项目。

2. 项目结构与核心模块拆解:6 个 Python 文件,每一份都直指 CNN 关键链路

这个项目没有src/、core/、utils/这类过度分层,所有逻辑压在 6 个扁平文件中,结构清晰到能一眼看出数据流向。我习惯先tree一把看骨架:

. ├── cnn.py # 主干:CNN 类定义,串联各层,含 train() / predict() 接口 ├── layers.py # 核心:Conv2D、MaxPool2D、Flatten、Dense 四个 Layer 类 ├── activations.py # 辅助:ReLU、Softmax 前向/反向实现(无 Sigmoid,因梯度易饱和) ├── losses.py # 损失:CrossEntropyLoss(带 label smoothing 可选) ├── optimizers.py # 优化器:SGD(支持 momentum,但未启用,保持纯粹) └── data_loader.py # 数据:MNIST 加载 + 归一化(除以 255.0)+ one-hot 编码

提示:所有 Layer 类均继承自统一基类Layer(定义在layers.py开头),强制实现forward()和backward()两个方法。这是手动实现反向传播的契约——没有torch.autograd,你必须自己保证backward()输入的grad_output维度,和forward()输出的output维度严格对齐。

2.1 卷积层 Conv2D:forward()中的 im2col 与backward()中的 col2im

卷积层是整个项目的“心脏”,也是最容易翻车的地方。它的forward()不直接用四重 for 循环(太慢),而是采用im2col技巧:将输入特征图按卷积核滑动窗口展开成二维矩阵,再与卷积核权重做矩阵乘法。关键代码如下:

# layers.py 中 Conv2D.forward() def forward(self, x): self.x = x # 保存输入,供 backward 使用 n, c, h, w = x.shape kh, kw = self.kernel_size out_h = (h - kh) // self.stride + 1 out_w = (w - kw) // self.stride + 1 # im2col: 将 x 展开为 (n * out_h * out_w, c * kh * kw) 的矩阵 cols = np.zeros((n * out_h * out_w, c * kh * kw)) for i in range(n): for j in range(out_h): for k in range(out_w): # 取出第 i 个样本、以 (j*stride, k*stride) 为左上角的窗口 patch = x[i, :, j*self.stride:j*self.stride+kh, k*self.stride:k*self.stride+kw] cols[i*out_h*out_w + j*out_w + k, :] = patch.flatten() # 权重展平为 (c * kh * kw, out_c),做矩阵乘 out = cols @ self.W.reshape(c * kh * kw, -1) + self.b # (n*out_h*out_w, out_c) # 重塑回 (n, out_c, out_h, out_w) out = out.reshape(n, self.out_channels, out_h, out_w) return out

这段代码的玄学点在于cols的索引计算:i*out_h*out_w + j*out_w + k必须严格对应out_h * out_w个位置的线性序号,否则@运算后 reshape 会错位。我第一次跑时准确率卡在 10%,最后发现是这里j*out_w + k写成了j*out_h + k—— 一个字母之差,全盘皆输。

backward()则更考验耐心:它要计算三样东西:dW(权重梯度)、db(偏置梯度)、dx(输入梯度)。其中dx的计算需col2im,即把dcols(dW计算中产生的中间矩阵)重新填回原始输入形状。项目没封装col2im函数,而是用嵌套循环原地还原:

# layers.py 中 Conv2D.backward() def backward(self, grad_output): n, c, h, w = self.x.shape kh, kw = self.kernel_size out_h, out_w = grad_output.shape[2], grad_output.shape[3] # 1. 计算 dW: 需要将 grad_output 与 self.x 对应 patch 做外积 # 先将 grad_output 展平为 (n * out_h * out_w, out_c) grad_out_flat = grad_output.transpose(0, 2, 3, 1).reshape(-1, self.out_channels) # self.x 已在 forward 中通过 im2col 得到 cols,复用即可 self.dW = self.cols.T @ grad_out_flat # (c*kh*kw, out_c) self.dW = self.dW.reshape(c, kh, kw, self.out_channels).transpose(3, 0, 1, 2) # (out_c, c, kh, kw) # 2. 计算 db: grad_output 在 batch 和空间维度求和 self.db = np.sum(grad_output, axis=(0, 2, 3)) # (out_c,) # 3. 计算 dx: col2im —— 将 grad_out_flat @ W.T 的结果,按位置加回 dx 的对应 patch dx = np.zeros_like(self.x) W_reshaped = self.W.reshape(self.out_channels, -1) # (out_c, c*kh*kw) dcols = grad_out_flat @ W_reshaped.T # (n*out_h*out_w, c*kh*kw) for i in range(n): for j in range(out_h): for k in range(out_w): idx = i * out_h * out_w + j * out_w + k patch = dcols[idx, :].reshape(c, kh, kw) # 将 patch 加到 dx[i] 的对应位置 dx[i, :, j*self.stride:j*self.stride+kh, k*self.stride:k*self.stride+kw] += patch return dx

注意self.cols是forward()中计算的中间变量,必须在forward()结束前显式保存(见第一行self.x = x后续还应有self.cols = cols)。项目原文没写这句,是常见漏点——导致backward()里self.cols为 None,直接报错。我补上后才跑通。

2.2 池化层 MaxPool2D:前向存 mask,反向靠 mask 传递梯度

池化层看似简单,但反向传播极易出错。最大池化不学习参数,但必须记住前向时每个窗口中“胜出”的位置(mask),反向时只把梯度传给那个位置,其余置 0。项目用布尔 mask 实现,干净利落:

# layers.py 中 MaxPool2D.forward() def forward(self, x): self.x = x n, c, h, w = x.shape out_h = h // self.pool_size out_w = w // self.pool_size # 初始化输出和 mask out = np.zeros((n, c, out_h, out_w)) self.mask = np.zeros_like(x) # 同 x 形状,存 True/False for i in range(n): for j in range(c): for k in range(out_h): for l in range(out_w): # 取窗口 patch = x[i, j, k*self.pool_size:(k+1)*self.pool_size, l*self.pool_size:(l+1)*self.pool_size] # 找最大值位置(扁平索引) max_idx = np.unravel_index(np.argmax(patch), patch.shape) # 在 mask 中标记该位置为 True self.mask[i, j, k*self.pool_size+max_idx[0], l*self.pool_size+max_idx[1]] = True out[i, j, k, l] = patch[max_idx] return out # layers.py 中 MaxPool2D.backward() def backward(self, grad_output): # grad_output 形状 (n, c, out_h, out_w),需扩展回 (n, c, h, w) n, c, out_h, out_w = grad_output.shape grad_input = np.zeros_like(self.x) for i in range(n): for j in range(c): for k in range(out_h): for l in range(out_w): # 将 grad_output[i,j,k,l] 填入 mask 中为 True 的位置 # 先找到该窗口在 self.mask 中的 True 位置 window_mask = self.mask[i, j, k*self.pool_size:(k+1)*self.pool_size, l*self.pool_size:(l+1)*self.pool_size] # 找到 True 的坐标(相对窗口) if np.any(window_mask): rel_pos = np.where(window_mask) abs_i = k*self.pool_size + rel_pos[0][0] abs_j = l*self.pool_size + rel_pos[1][0] grad_input[i, j, abs_i, abs_j] = grad_output[i, j, k, l] return grad_input

这里的关键是self.mask的维度必须与self.x完全一致,且backward()中abs_i/abs_j的计算必须和forward()中k*self.pool_size+max_idx[0]严格对称。我曾把rel_pos[0][0]错写成rel_pos[0](返回数组而非标量),导致赋值失败。

2.3 全连接层 Dense 与激活函数:ReLU 的死区处理与 Softmax 数值稳定

全连接层Dense是最接近传统神经网络的部分,但ReLU的反向传播有个经典坑:输入 ≤ 0 时梯度为 0,可能导致“死神经元”。项目在activations.py中做了显式处理:

# activations.py class ReLU: def forward(self, x): self.x = x return np.maximum(0, x) # x > 0 返回 x,否则返回 0 def backward(self, grad_output): # 只有 forward 时 x > 0 的位置,梯度才透传 return grad_output * (self.x > 0) # 布尔数组自动转为 0/1 class Softmax: def forward(self, x): # 数值稳定:减去每行最大值,防止 exp 溢出 x_shifted = x - np.max(x, axis=1, keepdims=True) exp_x = np.exp(x_shifted) self.output = exp_x / np.sum(exp_x, axis=1, keepdims=True) return self.output def backward(self, grad_output): # Softmax + CrossEntropy 的联合梯度更简洁,但此处单独实现 # grad_input = (output * (grad_output - np.sum(grad_output * output, axis=1, keepdims=True))) # 项目采用标准形式,虽稍慢但可读性强 return self.output * (grad_output - np.sum(grad_output * self.output, axis=1, keepdims=True))

Softmax.forward()的x - np.max(x, axis=1, keepdims=True)是必选项。我删掉这行试过,MNIST 训练到第二轮就出现nan,因为np.exp(1000)直接爆 float64。而backward()中np.sum(..., axis=1, keepdims=True)的keepdims=True也绝不能少,否则grad_output - ...会触发广播错误。

3. 运行全流程:从数据加载、模型构建到训练验证,5 分钟跑通 MNIST

项目没有requirements.txt,但依赖极简:仅numpy和内置pickle。我本地用 Python 3.8 + NumPy 1.21 测试通过。运行前请确保当前目录下有data/文件夹(若无,脚本会自动创建并下载 MNIST)。

3.1 数据准备:自动下载与预处理

data_loader.py封装了完整的 MNIST 加载逻辑。它会检查data/下是否存在train_images.pkl等文件,若不存在则从公开源下载(项目已内置下载链接,无需额外配置)。关键预处理只有两步:

# data_loader.py 中 load_mnist() def load_mnist(): # ... 下载逻辑(略) # 1. 归一化:像素值 0-255 → 0.0-1.0 x_train = x_train.astype(np.float32) / 255.0 x_test = x_test.astype(np.float32) / 255.0 # 2. 添加通道维度:(60000, 28, 28) → (60000, 1, 28, 28) x_train = x_train.reshape(-1, 1, 28, 28) x_test = x_test.reshape(-1, 1, 28, 28) # 3. label one-hot:(60000,) → (60000, 10) y_train = np.eye(10)[y_train] y_test = np.eye(10)[y_test] return (x_train, y_train), (x_test, y_test)

注意:MNIST 原图是灰度单通道,所以x_train.reshape(-1, 1, 28, 28)中的1是通道数,不是 batch。若你误写成(28, 28, 1),后续卷积层self.x.shape会是(n, 28, 28, 1),与Conv2D.forward()中n, c, h, w的预期顺序(NCHW)不符,直接报维度错。

3.2 模型构建:CNN 类的实例化与层堆叠

cnn.py中的CNN类是调度中心。它接收一个layers列表,按顺序执行forward和backward。构建 MNIST 模型的标准写法如下:

# cnn.py 示例用法 from layers import Conv2D, MaxPool2D, Flatten, Dense from activations import ReLU, Softmax from losses import CrossEntropyLoss from optimizers import SGD # 构建模型:1 个卷积块 + 1 个全连接块 model = CNN([ Conv2D(in_channels=1, out_channels=8, kernel_size=(3, 3), stride=1), ReLU(), MaxPool2D(pool_size=2), Flatten(), Dense(input_size=8*13*13, output_size=10), # 28->26->13 after conv+pool Softmax() ]) loss_fn = CrossEntropyLoss() optimizer = SGD(lr=0.01)

这里Dense的input_size是血泪经验:输入图像 28×28,经Conv2D(kernel=3,stride=1)输出(28-3)//1+1 = 26,再经MaxPool2D(pool=2)输出26//2 = 13,通道数为Conv2D.out_channels=8,故8*13*13=1352。若你把kernel_size改成(5,5),输出宽高变为(28-5)//1+1=24,再池化得12,input_size就得改成8*12*12=1152。项目没做动态计算,全靠你手算,这也是“无框架”的代价。

3.3 训练循环:手动管理前向、损失、反向、更新四步

CNN.train()方法是整个流程的骨架,它不隐藏任何步骤。核心循环如下:

# cnn.py 中 CNN.train() def train(self, x_train, y_train, epochs=10, batch_size=32, verbose=True): n_samples = x_train.shape[0] n_batches = n_samples // batch_size for epoch in range(epochs): indices = np.random.permutation(n_samples) x_train = x_train[indices] y_train = y_train[indices] total_loss = 0 correct = 0 for i in range(n_batches): # 1. 前向传播 start, end = i*batch_size, (i+1)*batch_size x_batch = x_train[start:end] y_batch = y_train[start:end] output = self.forward(x_batch) # 调用各层 forward # 2. 计算损失与准确率 loss = self.loss_fn.forward(output, y_batch) total_loss += loss pred = np.argmax(output, axis=1) true = np.argmax(y_batch, axis=1) correct += np.sum(pred == true) # 3. 反向传播 grad = self.loss_fn.backward(output, y_batch) # 得到最末层输入梯度 self.backward(grad) # 调用各层 backward,从后往前 # 4. 参数更新 self.optimizer.step(self.layers) # 遍历所有层,更新 W/b acc = correct / (n_batches * batch_size) avg_loss = total_loss / n_batches if verbose: print(f"Epoch {epoch+1}/{epochs} - Loss: {avg_loss:.4f} - Acc: {acc:.4f}")

注意self.forward()和self.backward()是CNN类封装的方法,内部遍历self.layers调用各自forward()/backward()。self.optimizer.step()则遍历所有含W/b的层(如Conv2D、Dense),执行layer.W -= lr * layer.dW。整个过程透明如白纸,没有zero_grad()、没有loss.backward()、没有optimizer.step()的魔法,只有你写的每一行。

4. 避坑指南:5 个真实踩过的坑与解决方案,省下你 8 小时调试时间

这个项目表面简洁,实则处处是坑。以下是我从零跑通过程中记录的真实翻车现场,每一条都附带现象、根因与解决动作,照着改,立竿见影。

4.1 现象:训练初期 loss 为 nan,且迅速发散

原因:Softmax.forward()缺少数值稳定处理,np.exp(x)输入过大导致溢出,产生inf,后续除法得nan。
解决:确认Softmax.forward()中x_shifted = x - np.max(x, axis=1, keepdims=True)存在且生效。可在该行后加assert not np.any(np.isnan(x_shifted))断言。

4.2 现象:准确率始终在 10%(随机猜),loss 不下降

原因:Conv2D.backward()中self.cols未在forward()中保存,导致self.dW = self.cols.T @ grad_out_flat报AttributeError,但项目可能用try-except吞掉异常,静默失败。
解决:打开layers.py,定位Conv2D.forward(),在return out前添加self.cols = cols。同理检查MaxPool2D.forward()是否保存了self.mask。

4.3 现象:ValueError: operands could not be broadcast together发生在Dense.backward()

原因:Dense层forward()输入x形状为(batch, features),但Flatten()输出可能是(batch, c, h, w)未正确压平,或Conv2D输出通道数与Dense.input_size不匹配。
解决:在CNN.forward()中Flatten()后插入print("After flatten:", x.shape),确认其为(batch, 1352);再核对Dense(input_size=1352)中1352是否等于Conv2D.out_channels * out_h * out_w。

4.4 现象:训练 loss 下降,但验证 acc 不升反降,过拟合严重

原因:项目未实现 Dropout 或 L2 正则,且Conv2D权重初始化为np.random.randn() * 0.01,方差过小,梯度信号弱;或学习率lr=0.01对于深层网络过大。
解决:在Conv2D.__init__()中改用 He 初始化:self.W = np.random.randn(out_c, in_c, kh, kw) * np.sqrt(2.0/(in_c*kh*kw));将SGD(lr=0.01)降为lr=0.001。

4.5 现象:predict()时np.argmax(output, axis=1)报axis 1 is out of bounds

原因:output形状为(batch,)或(batch, 10, 1, 1),非预期的(batch, 10)。根源在Softmax()前一层(如Dense)输出未正确 reshape。
解决:检查Dense.forward()最终return out前,out是否为(batch, output_size)。若Dense输入是(batch, 1352),权重W是(1352, 10),则out = x @ W + b必须是(batch, 10)。打印out.shape验证。

5. 二次开发实战:3 种实用改造,让这个“教学版 CNN”真正为你所用

这个项目的价值不仅在于跑通 MNIST,更在于它是一块可塑性极强的“神经网络乐高”。下面我分享三个真实场景下的改造方案,每个都给出可直接粘贴的代码、验证方法和效果预期。它们不是理论,而是我在某跨平台系统 Demo 中落地过的技巧。

5.1 改造一:支持自定义卷积核初始化,适配不同硬件约束

原项目Conv2D.__init__()中权重固定为np.random.randn() * 0.01,但实际部署时,你可能需要加载预训练核(如 Sobel 边缘检测)、或用二值化核降低计算量。改造只需两步:
第一步:修改Conv2D.__init__(),增加W_init参数:

# layers.py class Conv2D(Layer): def __init__(self, in_channels, out_channels, kernel_size, stride=1, W_init=None): self.in_channels = in_channels self.out_channels = out_channels self.kernel_size = kernel_size if isinstance(kernel_size, tuple) else (kernel_size, kernel_size) self.stride = stride kh, kw = self.kernel_size # 若传入 W_init,则直接使用;否则用 He 初始化 if W_init is not None: assert W_init.shape == (out_channels, in_channels, kh, kw) self.W = W_init.copy() else: # He 初始化 self.W = np.random.randn(out_channels, in_channels, kh, kw) * np.sqrt(2.0/(in_channels*kh*kw)) self.b = np.zeros((out_channels,)) # 初始化梯度缓存 self.dW = np.zeros_like(self.W) self.db = np.zeros_like(self.b)

第二步:在模型构建时传入自定义核。例如,加载一个预训练的 3×3 边缘检测核:

# 自定义 Sobel-X 核(3x3),扩展为 (1,1,3,3) 适配单通道输入 sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]])[None, None, :, :] # (1,1,3,3) # 构建模型时指定 model = CNN([ Conv2D(in_channels=1, out_channels=1, kernel_size=(3,3), W_init=sobel_x), # ... 后续层 ])

验证:用一张纯白图像(全 1)和一张水平条纹图像输入,观察Conv2D.forward()输出是否呈现预期边缘响应。效果:可将首层替换为手工设计特征提取器,大幅减少训练数据需求。

5.2 改造二:添加 BatchNorm2D 层,提升训练稳定性与速度

原项目无归一化层,训练深层网络时梯度易爆炸/消失。添加BatchNorm2D是性价比最高的升级。它需在forward()中计算 batch 均值/方差,并在backward()中反向传播。关键代码如下:

# layers.py 新增 class BatchNorm2D(Layer): def __init__(self, num_features, eps=1e-5, momentum=0.1): self.num_features = num_features self.eps = eps self.momentum = momentum # 可学习参数 self.gamma = np.ones((1, num_features, 1, 1)) self.beta = np.zeros((1, num_features, 1, 1)) # 运行时统计量(推理用) self.running_mean = np.zeros((1, num_features, 1, 1)) self.running_var = np.ones((1, num_features, 1, 1)) # 梯度 self.dgamma = np.zeros_like(self.gamma) self.dbeta = np.zeros_like(self.beta) def forward(self, x, training=True): if training: # 计算 batch 均值/方差:(N,C,H,W) -> (1,C,1,1) batch_mean = np.mean(x, axis=(0,2,3), keepdims=True) batch_var = np.var(x, axis=(0,2,3), keepdims=True) # 更新 running 统计量 self.running_mean = self.momentum * batch_mean + (1-self.momentum) * self.running_mean self.running_var = self.momentum * batch_var + (1-self.momentum) * self.running_var # 归一化 self.x_centered = x - batch_mean self.inv_std = 1.0 / np.sqrt(batch_var + self.eps) self.x_norm = self.x_centered * self.inv_std else: # 推理时用 running 统计量 self.x_norm = (x - self.running_mean) / np.sqrt(self.running_var + self.eps) # 缩放与偏移 out = self.gamma * self.x_norm + self.beta return out def backward(self, grad_output): # gamma, beta 的梯度 self.dgamma = np.sum(grad_output * self.x_norm, axis=(0,2,3), keepdims=True) self.dbeta = np.sum(grad_output, axis=(0,2,3), keepdims=True) # x_norm 的梯度 grad_x_norm = grad_output * self.gamma # x 的梯度(推导略,标准 BatchNorm 反向) N = grad_output.shape[0] * grad_output.shape[2] * grad_output.shape[3] grad_x = (1.0/N) * self.inv_std * ( N * grad_x_norm - np.sum(grad_x_norm, axis=(0,2,3), keepdims=True) - self.x_norm * np.sum(grad_x_norm * self.x_norm, axis=(0,2,3), keepdims=True) ) return grad_x

集成:在模型中插入到Conv2D后、ReLU前:

model = CNN([ Conv2D(1, 8, (3,3)), BatchNorm2D(8), # 新增 ReLU(), MaxPool2D(2), # ... ])

验证:对比加入前后训练 loss 曲线,预期收敛速度提升 2-3 倍,且 loss 波动减小。效果:使模型能稳定训练更深的网络(如加到 3 个卷积块),准确率可提升至 94%+。

5.3 改造三:导出为 ONNX 格式,无缝对接生产环境

纯 Python 实现无法直接部署到移动端或嵌入式设备。最务实的出路是导出为 ONNX,再用 ONNX Runtime 加载。项目虽无导出功能,但因其结构清晰,可手动构造 ONNX 图。核心是将每层映射为 ONNX 算子。以Conv2D为例:

# export_onnx.py (新增文件) import onnx from onnx import helper, TensorProto import numpy as np def create_conv_node(layer, input_name, output_name, node_name): # 创建 Conv 节点 conv_node = helper.make_node( 'Conv', inputs=[input_name, f'{node_name}_W', f'{node_name}_B'], outputs=[output_name], name=node_name, kernel_shape=list(layer.kernel_size), strides=[layer.stride, layer.stride], pads=[0, 0, 0, 0] # 无 padding ) # 创建权重常量 W_tensor = helper.make_tensor( name=f'{node_name}_W', data_type=TensorProto.FLOAT, dims=list(layer.W.shape), vals=layer.W.flatten().astype(np.float32) ) # 创建偏置常量 b_tensor = helper.make_tensor( name=f'{node_name}_B', data_type=TensorProto.FLOAT, dims=list(layer.b.shape), vals=layer.b.astype(np.float32) ) return conv_node, [W_tensor, b_tensor] # 主函数:遍历 model.layers,生成 nodes 和 initializers def export_cnn_to_onnx(model, input_shape, output_path): nodes = [] initializers = [] input_name = "input" prev_output = input_name for i, layer in enumerate(model.layers): if isinstance(layer, Conv2D): node, inits = create_conv_node(layer, prev_output, f"conv_{i}", f"Conv_{i}") nodes.append(node) initializers.extend(inits) prev_output = f"conv_{i}" elif isinstance(layer, ReLU): node = helper.make_node('Relu', inputs=[prev_output], outputs=[f"relu_{i}"], name=f"Relu_{i}") nodes.append(node) prev_output = f"relu_{i}" # ... 其他层类似 # 构建 graph 和 model graph_def = helper.make_graph( nodes=nodes, name="CNN", inputs=[helper.make_tensor_value_info(input_name, TensorProto.FLOAT, input_shape)], outputs=[helper.make_tensor_value_info(prev_output, TensorProto.FLOAT, [1,10])], initializer=initializers ) model_def = helper.make_model(graph_def, producer_name='handwritten-cnn') onnx.save(model_def, output_path) print(f"ONNX model saved to {output_path}") # 使用 export_cnn_to_onnx(model, input_shape=[1,1,28,28], output_path="mnist_cnn.onnx")

验证:用onnxruntime加载并推理:

import onnxruntime as ort sess = ort.InferenceSession("mnist_cnn.onnx") input_data = x_test[0:1] # (1,1,28,28) pred = sess.run(None, {"input": input_data.astype(np.float32)})[0] print("ONNX prediction:", np.argmax(pred))

效果:从此你的手写 CNN 不再是教学玩具,而是可部署到 Android/iOS/树莓派的生产模型。从 Python 到 ONNX,就是从实验室到产线的最后一公里。

从那以后我每次接手一个“无框架”项目,都强制走一遍print(shape)和assert校验:在forward()结束时打一次,在backward()输入前打一次,在optimizer.step()后再打一次权重形状。这三行日志,就是我写纯 NumPy CNN 的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

相关新闻

MTCNN人脸检测实战:从级联架构到训练推理全链路解析

MTCNN人脸检测实战:从级联架构到训练推理全链路解析

简介:这套MTCNN(多任务级联卷积神经网络)实现资源,面向深度学习与目标检测方向的开发者,尤其适合需要上手人脸检测与关键点定位项目的学习者。资源完整复现P-Net、R-Net、O-Net三级联结构,包含训练与推理所…

2026/10/19 15:51:28 阅读更多 →
深度学习入门:鱼书代码实战,用Python与NumPy跑通MNIST识别

深度学习入门:鱼书代码实战,用Python与NumPy跑通MNIST识别

简介:经典日系教材《深度学习入门:基于Python的理论与实现》(即“鱼书”)的全章节配套源码,面向零基础起步的深度学习初学者和想对照原著动手复现实验的开发者,覆盖从感知机、神经网络到卷积网络与深度网络…

2026/10/19 15:51:28 阅读更多 →
Spring Cloud网约车项目实战:微服务拆分、分布式事务与压测优化

Spring Cloud网约车项目实战:微服务拆分、分布式事务与压测优化

简介:一套基于Spring Cloud构建的网约车平台项目源码,涵盖乘客端、司机端、司机听单、派单调度、订单、计价、支付、钱包、短信验证码、地图等微服务模块,并集成Eureka注册中心、Config配置中心、Zuul网关、Hystrix熔断监控、Zipkin链路追踪等…

2026/10/19 15:51:28 阅读更多 →

最新新闻

VSS2026安装配置全流程详解:从环境准备到权限与备份实战

VSS2026安装配置全流程详解:从环境准备到权限与备份实战

大家做开发或者做文档管理,手头一定绕不开版本控制这件事。VSS2026,也就是微软这套 Visual SourceSafe 的最新版本,仍然是很多老团队、传统企业级项目在用的源代码和文档管理方案。相比 Git 那套分布式思路,VSS2026 更像是一个集中…

2026/10/19 16:37:52 阅读更多 →
NSGA-II多目标优化Matlab实战:从Pareto前沿绘制到答辩话术

NSGA-II多目标优化Matlab实战:从Pareto前沿绘制到答辩话术

简介:本资源是一套完整的NSGA-II多目标优化算法Matlab实现代码包,面向高校学生、科研人员及工程优化从业者,用于学习和求解典型多目标优化问题(如参数权衡、Pareto前沿探索等)。压缩包共10个文件,含8个核心…

2026/10/19 16:37:51 阅读更多 →
ShardingSphere-JDBC事务与分片路由深度解析

ShardingSphere-JDBC事务与分片路由深度解析

简介:本资源是一个基于SpringBoot、ShardingJDBC与MyBatis的轻量级分库分表实战项目,面向Java后端中级开发者及分布式数据库学习者,聚焦高并发场景下单库性能瓶颈的工程化解决方案。压缩包共15个文件(61KB)&#xff0c…

2026/10/19 16:37:51 阅读更多 →
SpringBoot农产品网上销售平台:从数据库设计到订单实现全解析

SpringBoot农产品网上销售平台:从数据库设计到订单实现全解析

选题选到最后还是回到了“网上商城”,这几乎是计算机专业毕设里的必答题。尤其是这个标题——SpringBoot农产品网上销售平台——一眼看过去,好像人人都在做,但真到自己动手的时候,功能设计、表结构、下单流程、库存扣减&#xff0…

2026/10/19 16:37:51 阅读更多 →
open-code-review:可审计、可演化的代码审查基础设施

open-code-review:可审计、可演化的代码审查基础设施

1. 什么是 open-code-review:一个被严重低估的工程实践新范式“open-code-review”这个词最近在技术社区里频繁出现,但它绝不是“把代码审查流程搬到 GitHub 上公开看”这么简单。我从 2018 年开始带团队做 Code Review,经历过纯人工逐行批注…

2026/10/19 16:37:51 阅读更多 →
提示词工程实战:从基础结构到调试技巧一次讲透

提示词工程实战:从基础结构到调试技巧一次讲透

聊点实际的。最近这一年,只要跟文字处理沾点边的人,多少都跟大模型打过交道。但同样面对同一款对话模型,有人三言两语就能问出高质量方案,有人翻来覆去只能得到一堆正确的废话。差别往往不在模型,而在你怎么把需求说清…

2026/10/19 16:36:20 阅读更多 →

日新闻

频率跟踪型PWM电源设计:臭氧发生器谐振点漂移与软开关实现

频率跟踪型PWM电源设计:臭氧发生器谐振点漂移与软开关实现

简介:本资源为电源技术方向的学术研究文档,聚焦介质阻挡放电型臭氧发生器电源的频率跟踪型PWM控制策略,面向电力电子、高压电源及臭氧应用领域的科研人员与工程技术人员。针对负载呈容性、等效电容随外加电压升高而增大、谐振频率随之漂移的难…

2026/10/19 0:00:47 阅读更多 →
Vibe Coding 遇上 TaoToken:把开发者思维圣殿的灵感流接入统一 Key 通道

Vibe Coding 遇上 TaoToken:把开发者思维圣殿的灵感流接入统一 Key 通道

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 0:00:47 阅读更多 →
HundredCV-Chat话题建模实战教程:利用4,871个内置话题标签构建主题分析系统

HundredCV-Chat话题建模实战教程:利用4,871个内置话题标签构建主题分析系统

【免费下载链接】HundredCV-Chat 项目地址: https://ai.gitcode.com/hf_mirrors/Jax-dan/HundredCV-Chat 点击查看 免费下载 HundredCV-Chat 是一个由 100 位青年简历数据开发而来的中文多轮对话数据集,共包含 24,750 组日常闲聊对话和 4,871 个话题标签…

2026/10/19 0:00:47 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/19 1:30:52 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →