
简介手写实现的二维卷积神经网络2D CNNPython代码面向希望深入理解CNN底层原理的Python学习者与图像处理入门者适合在PyCharm中运行调试。资源共2个文件包括一个.py源码文件和一个.docx实验报告压缩包仅24KB代码轻量精炼便于逐行研读。源码手动实现卷积层、激活函数、池化层、批量归一化、全连接层、损失函数、优化器以及前向/反向传播流程而非直接调用高层框架API能让读者清楚看到每个参数如何参与计算实验报告则补充了网络结构设计、超参数设置与训练验证步骤帮助排查过拟合、收敛慢等常见问题。当前已有895人下载学习适合用于课程实验、毕业设计或自学补充材料。 最近在一个项目里需要把模型从PyTorch部署到嵌入式环境光依赖框架不行逼着我把网络里的每一层都重新实现一遍。索性用纯Python加NumPy手写了一个二维卷积神经网络CNN卷积、池化、全连接、反向传播、训练循环全都有没有用任何深度学习框架。这篇文章把完整过程写下来整体设计思路、每层代码实现、训练调试方法以及踩过的坑希望对正在学深度学习原理、或者准备面试想脱离框架黑盒的朋友有帮助。手写CNN的最大价值不是为了替代PyTorch或TensorFlow而是让你真正理解卷积层、池化层的输入输出到底怎么流动梯度怎么从loss一路传回第一层卷积为什么某些初始化策略会让网络训不动。这些东西在用框架的时候通常被封装得严严实实一旦模型效果不对你连排查的入口都找不到。1. 整体设计与思路拆解1.1 卷积运算的计算本质二维卷积神经网络里最核心的运算就是卷积。深度学习框架里的卷积层实际做的是互相关cross-correlation跟数学课本里的卷积差一个卷积核翻转。因为滤波器的权重是训练学出来的翻转与否并不会影响表达能力所以框架全用互相关实现。你只需要记住一个朴素的图景一个小的滑动窗口卷积核在输入特征图上按步长移动每次做逐元素相乘再求和得到一个输出像素。假设输入尺寸是H×W卷积核大小是K填充padding是P步长stride是S那么输出尺寸由下面这个公式决定H_out (H 2P - K) / S 1 W_out (W 2P - K) / S 1这是手写CNN时必须刻在脑子里的公式几乎所有的维度报错都跟它有关。padding的作用是保留边缘信息stride控制下采样的力度。多通道输入的情况也简单输入是C个通道就有C个二维kernel分别跟对应通道做卷积再把C个结果加起来得到一个输出通道。输出有几个通道就对应几组这样的kernel。1.2 网络骨架设计我实现的是一个经典的小型CNN结构专门用于MNIST手写数字识别Conv(1→8, 3×3) → ReLU → MaxPool(2×2) → Conv(8→16, 3×3) → ReLU → MaxPool(2×2) → Flatten → Dense(256→10)选择这种结构有明确理由。MNIST是28×28的单通道灰度图其笔画特征比较简单两层卷积足以提取局部边缘和组合模式池化层把特征图从28×28降到14×14再到7×7既减少计算量也给全连接层缩小参数量。全连接层放在最后做分类输出的10维向量对应0到9十个数字。实现时我刻意不用im2col这类工程优化技巧而是用最直白的滑窗四重循环。虽然慢但每一个维度、每一次乘加都跟公式对得上理解完再去做向量化优化思路会清晰得多。2. 核心模块代码实现2.1 Conv2D层的正向传播与反向传播先看Conv2D层的完整实现。初始化时采用He初始化权重初始化为均值为0、方差为2/(fan_in)的正态分布这样做是为了配合ReLU激活函数避免梯度消失或爆炸。import numpy as np class Conv2D: def __init__(self, in_channels, out_channels, kernel_size, stride1, padding0): self.in_channels in_channels self.out_channels out_channels self.kernel_size kernel_size self.stride stride self.padding padding fan_in in_channels * kernel_size * kernel_size scale np.sqrt(2.0 / fan_in) self.W np.random.randn(out_channels, in_channels, kernel_size, kernel_size) * scale self.b np.zeros(out_channels) self.cache None self.dW None self.db None def forward(self, x): # x形状: (N, C_in, H, W) N, C, H, W x.shape K self.kernel_size P self.padding S self.stride H_out (H 2 * P - K) // S 1 W_out (W 2 * P - K) // S 1 x_pad np.pad(x, ((0, 0), (0, 0), (P, P), (P, P)), modeconstant) out np.zeros((N, self.out_channels, H_out, W_out)) for i in range(H_out): for j in range(W_out): h_start i * S w_start j * S x_patch x_pad[:, :, h_start:h_start K, w_start:w_start K] for f in range(self.out_channels): out[:, f, i, j] np.sum( x_patch * self.W[f, :, :, :], axis(1, 2, 3) ) self.b[f] self.cache (x, x_pad) return out前向传播的逻辑就是滑窗加乘累加。把输入pad好之后对每个输出位置取一个patch跟每个卷积核做逐元素乘法再求和加上偏置。反向传播是手写CNN最绕的部分。Conv2D层需要回传三个东西权重梯度dW、偏置梯度db、以及往上层传的输入梯度dx。观察前向公式可以发现梯度计算并不复杂def backward(self, dout, lr): x, x_pad self.cache N, C, H, W x.shape K self.kernel_size P self.padding S self.stride H_out (H 2 * P - K) // S 1 W_out (W 2 * P - K) // S 1 dx_pad np.zeros_like(x_pad) self.dW np.zeros_like(self.W) self.db np.zeros_like(self.b) for f in range(self.out_channels): self.db[f] np.sum(dout[:, f, :, :]) for i in range(H_out): for j in range(W_out): h_start i * S w_start j * S x_patch x_pad[:, :, h_start:h_start K, w_start:w_start K] for f in range(self.out_channels): d dout[:, f, i, j] self.dW[f, :, :, :] np.sum( x_patch * d[:, None, None, None], axis0 ) dx_pad[:, :, h_start:h_start K, w_start:w_start K] ( self.W[f, :, :, :] * d[:, None, None, None] ) self.dx dx_pad[:, :, P:P H, P:P W] self.W - lr * self.dW self.b - lr * self.db return self.dx反向的三个更新各自有明确含义。dW是输入patch和输出梯度相乘后累加db是输出梯度通道维度求和dx是把权重和梯度相乘后再累加回输入。这里有一个很容易踩的认知坑很多教程会画一大段推导证明卷积反向传播需要把kernel旋转180度但那是针对数学上的严格卷积。深度学习层的卷积层本身做的是互相关反向时权重不需要翻转。我第一次实现时照搬资料里的旋转操作梯度检查一直对不上去掉之后才通过。2.2 池化层与全连接层的实现池化层没有参数不需要学习但要记住前向时最大值的位置反向时才可以把梯度原样放回到那个位置其他位置填零。这就是MaxPool反向传播的全部逻辑class MaxPool: def __init__(self, pool_size2, stride2): self.pool_size pool_size self.stride stride def forward(self, x): N, C, H, W x.shape P self.pool_size S self.stride H_out (H - P) // S 1 W_out (W - P) // S 1 out np.zeros((N, C, H_out, W_out)) self.x x self.mask np.zeros_like(x) for i in range(H_out): for j in range(W_out): h_start i * S w_start j * S patch x[:, :, h_start:h_start P, w_start:w_start P] out[:, :, i, j] np.max(patch, axis(2, 3)) return out def backward(self, dout): dx np.zeros_like(self.x) N, C, H, W self.x.shape P self.pool_size S self.stride H_out (H - P) // S 1 W_out (W - P) // S 1 for i in range(H_out): for j in range(W_out): h_start i * S w_start j * S for n in range(N): for c in range(C): patch self.x[n, c, h_start:h_start P, w_start:w_start P] h_idx, w_idx np.unravel_index(np.argmax(patch), (P, P)) dx[n, c, h_start h_idx, w_start w_idx] dout[n, c, i, j] return dx严格来说mask可以在forward阶段就存下来backward直接查表不用再到原输入里找最大值位置。我代码里保留了两种写法倾向实际上存mask更高效但直接在backward里重新计算最大值位置更省内存两种都能跑通。全连接层就是普通的线性变换加反向传播矩阵运算比卷积层简单很多class Dense: def __init__(self, in_features, out_features): scale np.sqrt(2.0 / in_features) self.W np.random.randn(in_features, out_features) * scale self.b np.zeros(out_features) def forward(self, x): self.x x return x self.W self.b def backward(self, dout, lr): dx dout self.W.T self.dW self.x.T dout self.db np.sum(dout, axis0) self.W - lr * self.dW self.b - lr * self.db return dx线性层反向传播的核心就是两条链式法则对输入求梯度是拿dout乘W转置对权重求梯度是拿输入转置乘dout。有了dW之后用梯度下降更新参数即可。3. 训练一个能用的CNN3.1 数据准备与参数初始化在MNIST上做实验数据加载和预处理直接决定最终能跑多快、能收敛到什么水平。我用的方式是from sklearn.datasets import fetch_openml mnist fetch_openml(mnist_784, version1, as_frameFalse) X mnist.data.astype(np.float32) / 255.0 y mnist.target.astype(np.int64) X X.reshape(-1, 1, 28, 28) train_x, test_x X[:60000], X[60000:] train_y, test_y y[:60000], y[60000:]注意两个细节。第一个是归一化像素值从0到255缩放到0到1。如果不做这一步输入范围太大第一层卷积输出的数值会很大ReLU之后更容易梯度爆炸损失直接变成NaN。第二个是reshape成(N, 1, 28, 28)保留通道维后续卷积层才能按四维张量处理。参数初始化用的是He初始化。对ReLU网络来说Xavier初始化按fan_in方差放缩会让方差偏小层数一多输出趋近于零。He初始化把方差放大到2/fan_in让前向传播的信号和反向传播的梯度在深层网络里保持一个合适的尺度。全连接层我也沿用这个策略。超参方面我选择batch size 64、学习率1e-3训练10个epoch。这个组合在MNIST这种小数据集上相当稳定。学习率再大一点比如1e-2损失会在前几步疯狂震荡后面即使降下来准确率也上不去。3.2 训练循环与数值梯度检查有了数据就可以组装训练循环。软目标损失用Softmax加交叉熵前向输出后计算loss反向时把梯度从最后一层往回传def softmax(x): e np.exp(x - np.max(x, axis1, keepdimsTrue)) return e / np.sum(e, axis1, keepdimsTrue) def cross_entropy_loss(y_pred, y_true): N y_pred.shape[0] eps 1e-12 return -np.sum(np.log(y_pred[np.arange(N), y_true] eps)) / N def softmax_backward(y_pred, y_true): N y_pred.shape[0] dout y_pred.copy() dout[np.arange(N), y_true] - 1 return dout / N def relu(x): return np.maximum(0, x) def relu_backward(dout, x): dx dout.copy() dx[x 0] 0 return dx # 网络初始化 conv1 Conv2D(1, 8, 3, stride1, padding1) pool1 MaxPool(2, 2) conv2 Conv2D(8, 16, 3, stride1, padding1) pool2 MaxPool(2, 2) dense Dense(16 * 7 * 7, 10) batch_size 64 lr 1e-3 for epoch in range(10): for i in range(0, len(train_x), batch_size): x_batch train_x[i:i batch_size] y_batch train_y[i:i batch_size] h1 conv1.forward(x_batch) a1 relu(h1) h2 pool1.forward(a1) h3 conv2.forward(h2) a2 relu(h3) h4 pool2.forward(a2) flat h4.reshape(batch_size, -1) logits dense.forward(flat) probs softmax(logits) loss cross_entropy_loss(probs, y_batch) dout softmax_backward(probs, y_batch) dout dense.backward(dout, lr) dout dout.reshape(h4.shape) dout pool2.backward(dout) dout relu_backward(dout, h3) dout conv2.backward(dout, lr) dout pool1.backward(dout) dout relu_backward(dout, h1) dout conv1.backward(dout, lr) acc evaluate(conv1, conv2, dense, test_x, test_y) print(fepoch {epoch 1}, loss: {loss:.4f}, test acc: {acc:.4f})这段代码训练10轮最终测试准确率可以稳定跑到95%左右。如果你想复现可以观察前几个epoch的loss变化正常情况下第一个epoch结束loss就能从2.3附近降到0.15以下如果看不到这个下降趋势说明代码某处有bug先别急着调超参先做梯度检查。数值梯度检查是手写反向传播代码的救命工具。原理很简单用极限定义去近似梯度和backward算出来的解析梯度做对比。我通常先在小batch比如2个样本上单步检查def numerical_grad_check(layer, x, dout, eps1e-7): layer.forward(x) layer.backward(dout, lr0) # lr0不更新参数 numeric_dW np.zeros_like(layer.W) for idx in np.ndindex(layer.W.shape): orig layer.W[idx] layer.W[idx] orig eps out_plus layer.forward(x) layer.W[idx] orig - eps out_minus layer.forward(x) layer.W[idx] orig # 这里需要把输出转成标量loss再比较一般取 sum(out * dout) numeric_dW[idx] np.sum((out_plus - out_minus) * dout) / (2 * eps) diff np.max(np.abs(numeric_dW - layer.dW) / (np.abs(numeric_dW) np.abs(layer.dW) 1e-8)) print(fgrad diff: {diff:.2e})如果相对误差在1e-7到1e-5数量级说明解析梯度是对的。我通常在两层网络只有一个Conv和一个Dense上先验证逻辑通了再扩展到完整模型。这个方法帮我抓出过不少size对不上的问题。4. 常见问题与排查技巧4.1 典型报错与维度匹配手写CNN遇到最多的问题就是维度对不上。下面这份是我总结的速查表基本覆盖了初写者会撞上的大部分情况错误现象原因解决办法np.dot维度不匹配卷积输出尺寸算错用维度公式逐层手算打印每层shape核对loss一直是NaN输入没归一化或学习率过大数据除以255学习率降到1e-3梯度检查diff很大反向传播里kernel被旋转确认用的是互相关反向不转kernel池化反向得到全零梯度最大值位置记录错误打印argmax索引和原图比对训练loss降不下来初始化scale太小改用He初始化scale放大到sqrt(2/fan_in)尤其要强调第一行的维度检查。我在训练循环的关键节点都会加一行print(x.shape)从输入到输出每一层都打印一遍跟手算的结果对得上再往下走。这种每步验证的习惯能省下大量调试时间。4.2 训练不收敛的排查方法训练不收敛时我有一套固定的排查顺序。第一步先看loss是不是NaN如果是基本可以断定是输入范围太大或者学习率过高。第二步看loss的震荡幅度如果相邻几步的loss跳来跳去多半是学习率太大把1e-3降到1e-4试试。第三步看初始化的scale如果第一层输出全是一个常数反向时梯度全是零那多半是初始化sigma太小导致ReLU把神经元全关死了。这里有个小经验交叉熵softmax层的反向梯度是probs减去one-hot再除以batch_size。很多人在这一步忘记除以N导致梯度比真实值大N倍表现是刚开始训练时loss下降很快然后突然发散。我一开始也踩过加了那个除法之后训练就从过山车变成了滑滑梯。4.3 性能优化方向纯循环版在CPU上跑MNIST一个epoch大概要几分钟做研究肯定不行但理解原理完全够用。想进一步提速主要路径是把卷积运算换成矩阵乘法学名叫im2col。思路是把每个滑窗patch展开成一行所有patch堆成一个二维矩阵然后把卷积核也展开成矩阵一次矩阵乘就完成所有位置的计算。数据量翻了不少但线性代数库的矩阵乘高度优化实际速度可以快几十倍。在确保逻辑正确之后我还会用向量化把循环层数减少比如对batch维度、对输出通道维度的循环都可以用广播技术去掉。最终版本在CPU上跑MNIST的一轮加batch训练可以做到秒级和纯循环版正确率接近这就验证了向量化没有引入逻辑错误。5. 写在最后的体会这个手写CNN的项目做完之后我最大的收获不是终于写出了一个能识别的网络而是对框架里的每个参数都有了感觉。改学习率、换初始化、调卷积核大小时我不再是盲猜而是能大致猜出梯度会怎么流动、哪一层会先出问题。写框架代码时梯度检查这个习惯也被我保留了下来每次自定义网络结构都先跑一遍数值梯度验证再进训练循环效率反而比直接训练高得多。如果你也在学深度学习强烈建议找个小数据集把网络手写一遍。过程会有点折腾但过了这一关理解模型的视角会完全不一样。本文还有配套的精品资源点击获取