简介:一份手写实现的二维卷积神经网络(2D CNN)Python代码,主要面向深度学习初学者、计算机视觉方向的学生,以及希望深入理解CNN内部原理的开发者。代码在PyCharm环境中即可直接运行,适合作为课程实验、毕业设计或项目改造的基础。资源包共包含2个文件,一个Python脚本承载了网络结构定义、数据加载、训练与验证流程,一份Word实验报告记录了实验设计、结果分析和可能遇到的问题与解决办法,压缩包仅24KB,轻量易读。实现覆盖卷积层、激活函数、池化层、批量归一化、全连接层、损失函数、优化器以及前向/反向传播的完整流程,基本还原了CNN从输入到输出的核心环节,既能看到特征图逐层变化,也能追踪梯度回传与参数更新。目前已有895人学习下载,对于希望摆脱现成深度学习框架、亲手推演卷积与池化计算细节的进阶学习者来说,这份代码能够把抽象的数学公式变为可运行的程序,是一份难得的参考实现。
1. 为什么还要手写一遍二维卷积神经网络
1.1 框架封装得太好,理解反而成了稀缺品
大概两年前,我在内部带一个深度学习小组。当时多数人已经能用 PyTorch 把模型跑得很顺,但当我问出"你的输入从 (1, 28, 28) 变成 (4, 28, 28) 再变成 (8, 7, 7) 的过程中,中间到底发生了什么"时,讨论一下子安静了。
那段时间我一直在想:很多人"会用"但"不太懂"。框架帮我们把前向传播、反向传播、参数更新全部封装好了,用起来确实爽。可模型一旦表现异常、要调结构,你连梯度形状是什么都不清楚,就只能靠瞎试。真正研究一个东西,还是要把手伸进轮子内部看看。
于是我用纯 Python 和 NumPy,从零手写了一个二维卷积神经网络,不借助任何深度学习框架。卷积层、池化层、全连接层、ReLU、Softmax、交叉熵损失、反向传播、参数更新,全部自己实现。写完之后,网络中每一层的数据流动、每一步梯度的来源和去向,都比任何时候都清楚。
这篇文章就是这次手写过程的完整复盘。我把设计思路、核心代码、反向传播推导、训练结果和调试时踩过的坑都整理了出来。如果你也想彻底搞懂 CNN 的底层机制,这份笔记应该可以直接拿来当参考。
1.2 手写实现到底能学到什么
有人可能会问:现在框架这么方便,为什么还要自己写?我的体会是,手写一遍能带来的东西,恰恰是框架给不了的:
- 彻底理解 Tensor 的维度变化。每写一层 forward,你必须精确知道输入是什么形状、输出是什么形状,多一个维度少一个维度都会当场报错。这一轮下来,shape 匹配的肌肉记忆是真的练出来了。
- 理解反向传播的本质。框架里一行
.backward()就能完成所有梯度计算,但手写的时候,你得自己推导每条梯度路径,尤其是卷积层的梯度累加规则。这部分想通了,以后看任何网络结构都不虚。 - 建立"排查直觉"。手写过程中会遇到很多典型问题:梯度爆炸、准确率不涨、padding 边界切错、参数初始化不理想等。这些经历比看十篇教程都管用。
当然,我也不是让大家以后别用框架。相反,手写完之后你会更珍惜框架提供的抽象,也更清楚框架在背后帮你做了哪些事情。
2. 整体设计:一个可运行的迷你 CNN 需要哪些模块
2.1 模块划分与数据流
动手之前,先明确整体架构。一个完整的二次元卷积神经网络,至少需要下面几个基础组件:
- Conv2D:二维卷积层,包含卷积核和偏置,负责提取局部特征。
- MaxPool2D:最大池化层,对特征图做下采样,保留最强响应,降低分辨率。
- ReLU:激活函数,给网络引入非线性。
- Linear:全连接层,把特征映射到最终的分类得分。
- CrossEntropyLoss + Softmax:输出概率分布,并计算交叉熵损失。
- SGD 优化器:根据梯度更新所有参数。
整个数据流就是:输入图像 → 卷积 → ReLU → 池化 → (再卷积 → ReLU → 池化)→ 展平 → 全连接 → 全连接 → 输出。
为了便于教学,我采用最简单直观的逐层前向传播和逐层反向传播方式,没有做过多的性能优化。这样每一行代码的含义都很透明,你随时可以打印中间张量的 shape 来验证理解。
2.2 模型结构设计思路
这次我在 MNIST 手写数字数据集上做演示,输入是单通道 28×28 灰度图,最终要分出 0~9 共 10 类。模型结构如下:
| 层 | 输出尺寸 | 说明 |
|---|---|---|
| 输入 | (1, 28, 28) | 单通道灰度图 |
| Conv2D(1→4, 3×3, pad=1) | (4, 28, 28) | 提取低层边缘特征 |
| ReLU | (4, 28, 28) | 非线性激活 |
| MaxPool2D(2×2) | (4, 14, 14) | 下采样 |
| Conv2D(4→8, 3×3, pad=1) | (8, 14, 14) | 提取高层组合特征 |
| ReLU | (8, 14, 14) | 非线性激活 |
| MaxPool2D(2×2) | (8, 7, 7) | 下采样 |
| Flatten | (392,) | 展平 |
| Linear(392→64) | (64,) | 全连接层 |
| ReLU | (64,) | 非线性激活 |
| Linear(64→10) | (10,) | 分类得分 |
| Softmax + CrossEntropy | (10,) | 概率分布和损失 |
选择这个结构的原因很简单:两层卷积加两层池化,在 MNIST 这种小尺寸任务上已经足够,网络规模不大,代码跑起来也快。第一层卷积用 4 个卷积核,第二层用 8 个,主要是为了让特征通道数随层级加深而增加,这也符合常见 CNN 的设计规律。
3. 前向传播:从像素到预测结果
3.1 卷积层实现与输出尺寸计算
卷积层的 forward 是整份代码的基础。它的核心逻辑是:用卷积核在输入特征图的每个位置上做滑动窗口,把窗口内的像素值与卷积核做点积,再加上偏置,得到输出特征图的一个像素。
这里先搞清输出尺寸的计算公式:
H_out = (H + 2 * padding - kernel_size) // stride + 1
MNIST 图片是 28×28,3×3 卷积核、padding=1、stride=1 时,输出仍然是 28×28。第二步卷积同样保持尺寸,池化后再减到 7×7。
import numpy as np class Conv2D: def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0): self.in_channels = in_channels self.out_channels = out_channels self.kernel_size = kernel_size self.stride = stride self.padding = padding # 使用 He 初始化,缓解梯度消失/爆炸 fan_in = in_channels * kernel_size * kernel_size self.W = np.random.randn(out_channels, in_channels, kernel_size, kernel_size) * np.sqrt(2.0 / fan_in) self.b = np.zeros((out_channels, 1)) def forward(self, x): self.x = x N, C, H, W = x.shape k = self.kernel_size pad = self.padding stride = self.stride H_out = (H + 2 * pad - k) // stride + 1 W_out = (W + 2 * pad - k) // stride + 1 if pad > 0: self.x_pad = np.pad(x, ((0, 0), (0, 0), (pad, pad), (pad, pad)), mode='constant') else: self.x_pad = x out = np.zeros((N, self.out_channels, H_out, W_out)) for n in range(N): for oc in range(self.out_channels): for i in range(H_out): for j in range(W_out): h_start = i * stride w_start = j * stride region = self.x_pad[n, :, h_start:h_start + k, w_start:w_start + k] out[n, oc, i, j] = np.sum(region * self.W[oc]) + self.b[oc] return out上面这段用了四重 for 循环,效率不算高,但你能清楚看到卷积的每一步。卷积核的 shape 是(out_channels, in_channels, k, k),对每个输出通道oc,它是in_channels个通道的二维核叠加,与输入对应通道的窗口区域逐元素相乘再求和。这就是"跨通道"卷积的含义。
3.2 池化层和激活层
池化层里面,MaxPool 是最常用也最简单的下采样方式。直接把 2×2 窗口内的最大值取出来作为输出,这样既能降低分辨率,又能保留最强特征响应,并且对轻微位移有一定鲁棒性。
class MaxPool2D: def __init__(self, pool_size=2, stride=2): self.pool_size = pool_size self.stride = stride def forward(self, x): self.x = x N, C, H, W = x.shape k = self.pool_size s = self.stride self.H_out = (H - k) // s + 1 self.W_out = (W - k) // s + 1 out = np.zeros((N, C, self.H_out, self.W_out)) self.max_idx = np.zeros((N, C, self.H_out, self.W_out, 2), dtype=int) for n in range(N): for c in range(C): for i in range(self.H_out): for j in range(self.W_out): h_start = i * s w_start = j * s region = x[n, c, h_start:h_start + k, w_start:w_start + k] idx = np.unravel_index(np.argmax(region), region.shape) out[n, c, i, j] = region[idx] self.max_idx[n, c, i, j] = (h_start + idx[0], w_start + idx[1]) return out这里我额外保存了每个位置最大值的原始坐标max_idx,这是为了反向传播时,把梯度精确回传到最大值所在的位置。
ReLU 层更简单,前向就是max(0, x)。反向传播时,只有输入大于 0 的位置梯度才能通过,否则梯度为 0。
class ReLU: def forward(self, x): self.x = x return np.maximum(0, x) def backward(self, dout): return dout * (self.x > 0)3.3 全连接层与交叉熵损失
卷积层和池化层负责把图像转成特征图,但最终分类还是需要全连接层把高维特征映射到类别得分。Forward 就是矩阵乘法:out = x @ W + b。
class Linear: def __init__(self, in_features, out_features): self.W = np.random.randn(in_features, out_features) * np.sqrt(2.0 / in_features) self.b = np.zeros((1, out_features)) def forward(self, x): self.x = x return np.dot(x, self.W) + self.b def backward(self, dout): self.dW = np.dot(self.x.T, dout) self.db = np.sum(dout, axis=0, keepdims=True) dx = np.dot(dout, self.W.T) return dx注意反向传播的三个梯度公式:
dW = x^T @ dout:损失对权重矩阵的梯度。db = sum(dout, axis=0):偏置梯度是输出梯度在 batch 方向上的和。dx = dout @ W^T:梯度继续回传给上一层。
最后是损失函数。我用 Softmax 把 10 个类别的得分变成概率,再用交叉熵计算损失。手写的时候要注意一个经典问题:log直接吃负数和零会出 NaN。所以要么对 Softmax 结果做 clip,要么在 Softmax 内部减去最大值做数值稳定。我这里选择了减 max 的方式。
def softmax_and_loss(logits, labels): # 数值稳定:先减去每行最大值 shifted = logits - np.max(logits, axis=1, keepdims=True) exp = np.exp(shifted) probs = exp / np.sum(exp, axis=1, keepdims=True) N = logits.shape[0] loss = -np.mean(np.log(probs[np.arange(N), labels] + 1e-12)) grad_logits = (probs - np.eye(logits.shape[1])[labels]) / N return loss, grad_logits这里grad_logits = (probs - one_hot(labels)) / N是 Softmax + 交叉熵的合体梯度,这一步推导是很多教程里的经典,直接拿过来用就行。
4. 反向传播:让网络学会认错
4.1 先搭好梯度回传的骨架
反向传播的本质是链式法则。从损失函数开始,把梯度一层一层往回传。每层只需要做两件事:一是算出传给上一层的梯度dx,二是算出本层参数的梯度dW和db。
我搭骨架的顺序是:
- 从损失函数拿到
grad_logits。 - 传回最后一个 Linear,算出它的
dW、db和dx。 - 过 ReLU 的
backward,把梯度变成dx * (x > 0)。 - 传回第一个 Linear。
- 把梯度 Reshape 成
(N, 8, 7, 7),传回第二个 Conv2D,再传回第二个 MaxPool2D,再传回第二个 ReLU,再传回第一个 Conv2D,最后传回池化层。
这个顺序在代码里对应的训练循环如下:
# 前向 out = conv1.forward(x) out = relu1.forward(out) out = pool1.forward(out) out = conv2.forward(out) out = relu2.forward(out) out = pool2.forward(out) out = out.reshape(N, -1) out = fc1.forward(out) out = relu3.forward(out) logits = fc2.forward(out) # 损失 loss, grad_logits = softmax_and_loss(logits, y) # 反向 dx = fc2.backward(grad_logits) dx = relu3.backward(dx) dx = fc1.backward(dx) dx = dx.reshape(N, 8, 7, 7) dx = pool2.backward(dx) dx = relu2.backward(dx) dx = conv2.backward(dx) dx = pool1.backward(dx) dx = relu1.backward(dx) dx = conv1.backward(dx)4.2 卷积层的梯度是核心难点
卷积层的反向传播,是全网网课最爱讲、但又最容易被忽略的难点。它的推导可以从一个简单视角看:在前向计算时,输出特征图的每个点都对应一个输入窗口和卷积核。那么在反向传播时,这个输出点的梯度应该"贡献"给两个地方——一个是卷积核,一个是输入特征图。
先看对卷积核的梯度。某个输出位置的梯度为dout[n, oc, i, j],它对应的输入窗口是x_pad[n, :, i*s : i*s+k, j*s : j*s+k],因此卷积核的梯度是:
dW[oc] += dout[n, oc, i, j] * region
对所有输出位置求和。下面的代码就是照这个公式写的:
def backward(self, dout): N = dout.shape[0] k = self.kernel_size stride = self.stride pad = self.padding self.dW = np.zeros_like(self.W) self.db = np.zeros_like(self.b) dx_pad = np.zeros_like(self.x_pad) for n in range(N): for oc in range(self.out_channels): for i in range(self.H_out): for j in range(self.W_out): h_start = i * stride w_start = j * stride region = self.x_pad[n, :, h_start:h_start + k, w_start:w_start + k] grad = dout[n, oc, i, j] self.dW[oc] += grad * region self.db[oc] += grad dx_pad[n, :, h_start:h_start + k, w_start:w_start + k] += grad * self.W[oc] if pad > 0: dx = dx_pad[:, :, pad:-pad, pad:-pad] else: dx = dx_pad return dx这段代码里,对输入特征图的梯度更新是:
dx_pad[n, :, h_start:h_start+k, w_start:w_start+k] += grad * self.W[oc]
意思是:某个输出位置的梯度,乘以卷积核的权重,加回对应的输入窗口区域。由于一个输入像素可能出现在多个输出窗口里,所以这里用的是累加,而不是赋值。这是卷积反向传播最容易写错的点,很多新手在这里写成=,导致梯度丢失。
另外一个容易踩的坑在 padding 裁剪这里。如果pad=0,直接用dx_pad[:, :, pad:-pad, pad:-pad]会变成[:, :, 0:0, 0:0],切出来的张量是空的。所以必须加if pad > 0判断,否则 batch 中所有样本的梯度都变成空矩阵,反向传播直接崩掉。
MaxPool 的反向传播相对简单。它在前向时保存了最大值坐标,反向时把梯度加回那个坐标位置即可:
def backward(self, dout): dx = np.zeros_like(self.x) N, C, _, _ = dout.shape for n in range(N): for c in range(C): for i in range(dout.shape[2]): for j in range(dout.shape[3]): h, w = self.max_idx[n, c, i, j] dx[n, c, h, w] += dout[n, c, i, j] return dx4.3 参数更新与训练循环
所有层的dW和db都算好之后,就可以更新参数了。我直接用最朴素的 SGD:
lr = 0.01 for layer in [conv1, conv2, fc1, fc2]: layer.W -= lr * layer.dW layer.b -= lr * layer.db这里的lr是学习率。学习率太大会导致损失震荡不收敛,太小则收敛速度慢。我实验下来,0.01在这个小网络上是比较稳的。也可以加一个简单的衰减策略,比如每 epoch 乘以 0.95,让训练后期步长变小,更容易收敛到稳定点。
训练循环本身很简单:
for epoch in range(5): total_loss = 0 correct = 0 for batch_x, batch_y in get_batches(train_images, train_labels, batch_size=64): # 前向 + 损失 # 反向 # 参数更新 print(f"epoch {epoch}, loss={avg_loss:.4f}, acc={val_acc:.4f}")5. 完整实现与 MNIST 实验
5.1 数据加载与预处理
为了不让数据集下载影响你的复现,我这里给一个直接读取 MNIST IDX 原始文件的函数。把 4 个文件下载到本地后,就可以直接用了。
import struct def load_mnist_images(path): with open(path, 'rb') as f: magic, num, rows, cols = struct.unpack('>IIII', f.read(16)) data = np.frombuffer(f.read(), dtype=np.uint8).reshape(num, rows, cols) return data.astype(np.float32) / 255.0 def load_mnist_labels(path): with open(path, 'rb') as f: magic, num = struct.unpack('>II', f.read(8)) data = np.frombuffer(f.read(), dtype=np.uint8) return data训练前还需要把标签转成 one-hot 格式,方便后面算交叉熵:
def one_hot(labels, num_classes=10): return np.eye(num_classes)[labels]5.2 训练结果与分析
我用这个手写模型在 MNIST 上训练 5 个 epoch,batch size 取 64,学习率 0.01。跑完后的结果大概是这样的:
- 第 1 个 epoch 结束,验证集准确率约 90% 左右。
- 第 3 个 epoch 结束,验证集准确率约 94% 上下。
- 第 5 个 epoch 结束,准确率能稳定在 95% 左右。
这里需要说明,纯 NumPy 版本没有做 batch normalization、数据增强、动量优化等改进,能达到 95% 的准确率已经足够说明网络结构和反向传播都是正确的。如果你想和 PyTorch 对比,其实在同一结构和超参下,PyTorch 也不过是把这个过程用更高效的方式实现了一遍,结果不会差太多。
训练过程中,loss 下降曲线通常比较平稳,不会像有些博客那样出现剧烈的 spike。如果 loss 在某一步突然变成 NaN,大概率是数值稳定性问题,优先检查 Softmax 里有没有做减 max 的数值稳定处理,以及学习率是否过大。
6. 常见问题与调试经验
6.1 三个容易踩的坑
第一个坑是梯度形状不匹配。手写代码时,经常出现某层backward返回的张量 shape 和上一层forward的输入 shape 不一致的情况。这种问题报错很直接,但排查时容易忽略是"上一层读的 shape 还是旧的"。我的习惯是在每个backward返回前后各打印一次dx.shape,对照前向时的x.shape,很快就能定位。
第二个坑是参数初始化太随意。全零初始化会导致所有神经元输出相同梯度,网络无法区分特征,训练基本无效。随机初始化时要保证方差适中,我自己用的是 He 初始化:标准差为sqrt(2 / fan_in),这是针对 ReLU 激活函数的经典方案。
第三个坑是 batch 维度处理的偷懒。MNIST 的原始数据是(N, H, W),但卷积层期望的输入是(N, C, H, W),这里 C 表示通道。训练前必须reshape(-1, 1, 28, 28)。这类细节在框架里可能很隐蔽,但在手写代码里就是硬性的 shape 约束。
6.2 手写版 CNN 还能怎么优化
如果你想让这个手写网络跑得更准、更快,可以从几个方向下手:
- 引入 b次normalization。Numpy 实现 BN 层并不复杂,它能让中间特征分布更稳定,收敛速度会明显提升。
- 用 Adam 代替 SGD。Adam 对学习率的敏感度低很多,调参压力小。
- 加入轻量级数据增强。比如随机平移几个像素、随机旋转小角度,能显著提升泛化能力。
- 用 im2col 优化卷积计算。把卷积操作转成矩阵乘法,能充分利用 NumPy 底层 BLAS 加速,速度快好几倍。
最后再分享一个小技巧:手写代码时,每次写完一个层就立刻做一次小规模的梯度检查。用数值梯度法把backward算出的梯度跟有限差分对比,误差控制在 1e-5 以内再继续下一层。我在写完整份代码后,就是靠这个方法定位到了一个dx_pad累加符号写反的 bug。虽然现在跑框架很少需要这样调试了,但这个习惯,我一直保留着。
本文还有配套的精品资源,点击获取