ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

纯NumPy手写二维卷积神经网络:从原理到反向传播全解析

2026/9/2 4:18:46 拓冰建站 浏览量
纯NumPy手写二维卷积神经网络:从原理到反向传播全解析 简介一份手写实现的二维卷积神经网络2D CNNPython代码主要面向深度学习初学者、计算机视觉方向的学生以及希望深入理解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, pad1)(4, 28, 28)提取低层边缘特征ReLU(4, 28, 28)非线性激活MaxPool2D(2×2)(4, 14, 14)下采样Conv2D(4→8, 3×3, pad1)(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 1MNIST 图片是 28×283×3 卷积核、padding1、stride1 时输出仍然是 28×28。第二步卷积同样保持尺寸池化后再减到 7×7。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 # 使用 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)), modeconstant) 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_size2, stride2): 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), dtypeint) 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, axis0, keepdimsTrue) dx np.dot(dout, self.W.T) return dx注意反向传播的三个梯度公式dW x^T dout损失对权重矩阵的梯度。db sum(dout, axis0)偏置梯度是输出梯度在 batch 方向上的和。dx dout W^T梯度继续回传给上一层。最后是损失函数。我用 Softmax 把 10 个类别的得分变成概率再用交叉熵计算损失。手写的时候要注意一个经典问题log直接吃负数和零会出 NaN。所以要么对 Softmax 结果做 clip要么在 Softmax 内部减去最大值做数值稳定。我这里选择了减 max 的方式。def softmax_and_loss(logits, labels): # 数值稳定先减去每行最大值 shifted logits - np.max(logits, axis1, keepdimsTrue) exp np.exp(shifted) probs exp / np.sum(exp, axis1, keepdimsTrue) 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*sk, j*s : j*sk]因此卷积核的梯度是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_startk, w_start:w_startk] grad * self.W[oc]意思是某个输出位置的梯度乘以卷积核的权重加回对应的输入窗口区域。由于一个输入像素可能出现在多个输出窗口里所以这里用的是累加而不是赋值。这是卷积反向传播最容易写错的点很多新手在这里写成导致梯度丢失。另外一个容易踩的坑在 padding 裁剪这里。如果pad0直接用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都算好之后就可以更新参数了。我直接用最朴素的 SGDlr 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_size64): # 前向 损失 # 反向 # 参数更新 print(fepoch {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(), dtypenp.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(), dtypenp.uint8) return data训练前还需要把标签转成 one-hot 格式方便后面算交叉熵def one_hot(labels, num_classes10): return np.eye(num_classes)[labels]5.2 训练结果与分析我用这个手写模型在 MNIST 上训练 5 个 epochbatch 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。虽然现在跑框架很少需要这样调试了但这个习惯我一直保留着。本文还有配套的精品资源点击获取