简介这是一份基于纯Python与NumPy实现的卷积神经网络教学资源不依赖TensorFlow、PyTorch等框架适合希望深入理解CNN底层原理的深度学习初学者或算法工程师。压缩包共6个文件包含4个Python脚本与2个MNIST数据集压缩包整体约1.58MB代码划分清晰主体脚本实现卷积层、池化层、全连接层及反向传播测试脚本用于功能验证MNIST分析脚本展示手写数字识别训练与评估流程参数脚本统一管理网络架构与超参数。资源内置已下载好的MNIST手写数字数据集开箱即可运行实验通过阅读源码读者能掌握卷积步长、填充、梯度计算与参数更新等核心细节摆脱框架黑盒提升模型设计与调试能力。目前已有1913人学习下载适合用作课程设计或面试复习的参考素材。 写这个项目的动机特别简单某天面试一个自称“精通CNN”的候选人我问他卷积层的反向传播梯度到底怎么算他支支吾吾半天最后说“框架都封装好了不需要关心这些”。这话戳到了我自己——我也很久没亲手写过反向传播了。于是我用一个周末把MNIST手写数字识别用纯Python和NumPy从零实现了一遍不碰PyTorch、不碰TensorFlow整个网络从卷积层到全连接层再到反向传播、参数更新全部手写。最终在MNIST测试集上跑出了97.3%的准确率。这篇文章就是想把这套完整实现拆开揉碎讲清楚。它适合三类人第一类是像我一样调包调久了、想重新扎回底层理解CNN本质的人第二类是学校的课程设计或者面试前需要快速吃透CNN原理的人第三类是正在学Python、想找一个相对完整但又不至于太复杂的实战项目来练手的人。整个项目只用到了NumPyPython基础语法会一点就能跟上我会把每段关键代码背后的数学逻辑也一并解释。1. 为什么还要用纯Python手写CNN调包调久了手会生先把丑话说在前面这套东西的生产价值约等于零。论速度它比PyTorch慢几十倍不止论方便那更没法比。但你真要亲手推一遍反向传播才会发现那些平时张口就来的“链式法则”“梯度消失”“卷积核共享权重”落到代码里全是细节一个维度没对齐梯度就直接变成NaN。我的建议是如果你想真正搞懂CNN拿个周末手撕一遍比看十遍教程都有用。这个项目用到的库只有NumPyMNIST数据可以从网上下载原始IDX格式的文件自己写个loader解析连深度学习框架都不需要。模型结构参考LeNet-5做个简化版卷积层-池化层-卷积层-池化层-全连接层-全连接层-输出层参数量几十万CPU上训练五六个epoch也就一小时完全能接受。2. 骨架先行网络类与三大流程的整体设计写这种项目最忌讳一上来就闷头写代码。我建议先想清楚一个可训练的神经网络无论如何封装核心只有三件事——前向传播算损失、反向传播算梯度、用梯度更新参数。我设计的Network类就是这么个工作流。它内部维护一个流程接收输入数据逐个调用每层的forward方法得到输出将最终输出与真实标签计算损失调用每层的backward方法反向传递梯度最后调用每层的update方法更新参数。整个网络代码量不大但结构非常清晰。class Network: def __init__(self): self.layers [] self.loss None def forward(self, x, yNone): out x for layer in self.layers: out layer.forward(out) if y is not None: loss self.loss.forward(out, y) return loss, out return out def backward(self): grad self.loss.backward() for layer in reversed(self.layers): grad layer.backward(grad) return grad def update(self, lr): for layer in self.layers: layer.update(lr)每一层只需要实现forward、backward、update三个方法像积木一样自由组合。顺带一提写完你会深刻感受到所谓“学习框架”本质上就是帮你把backward和update这两件事自动算好了——你喂进去的out框架已经帮你算好了怎么传回去。当你亲自写过一遍backward之后再看PyTorch的loss.backward()会有一种“原来是这么回事”的通透感。3. 全连接层与ReLU反向传播的“热身运动”全连接层是整个网络里最好写的也是理解反向传播的最佳入口。它的前向就是最简单的线性代数对输入向量x做线性变换y Wx b。注意这里的输入x不再是单个向量而是一个批次的数据所以形状通常是(batch_size, input_features)权重W的形状就是(input_features, output_features)。反向传播的核心是根据链式法则从损失对输出的梯度dout反推出三样东西损失对权重的梯度dW、对偏置的梯度db、对输入的梯度dx。关键在于矩阵的形状推导。假设前向是out x.dot(W) b那么dW x.T.dot(dout)dx dout.dot(W.T)db则是dout在批次维度上求和。为什么x.T要放在左边、W.T要放在右边不理解的可以直接把x和W的形状代入结果矩阵必须和W同形状这就是答案。class FullyConnected: def __init__(self, in_dim, out_dim): # He初始化 self.W np.random.randn(in_dim, out_dim) * np.sqrt(2.0 / in_dim) self.b np.zeros(out_dim) self.x None def forward(self, x): self.x x return x.dot(self.W) self.b def backward(self, dout): self.dW self.x.T.dot(dout) self.db np.sum(dout, axis0) dx dout.dot(self.W.T) return dx def update(self, lr): self.W - lr * self.dW self.b - lr * self.dbReLU的代码就更是短得离谱前向就是np.maximum(0, x)反向则是把输入小于0的位置梯度置零。但千万别小看它没有这个激活函数两个全连接层叠起来等价于一个线性变换CNN的“深度”就完全失去意义了。ReLU的选择也很有讲究早年常用的sigmoid在深网络里梯度会一层层缩水最终消失ReLU在正区间的导数恒为1梯度能稳定地穿过深层网络传回输入层。当然它也有个著名的副作用叫“神经元死亡”如果某个神经元接收到的输入几乎总为负它的梯度就一直是0权重再也无法更新。这个问题在对卷积层做初始化时格外明显后面我会专门展开讲。4. 卷积层前向用im2col把9层for循环变成矩阵乘法卷积层的前向运算很多新手写出来是三重循环嵌套慢得没法看。我第一次用四重for循环跑了个28×28的灰度图一个epoch花了将近二十分钟心态直接炸了。后来翻各种资料才发现一个几乎是人人都该会的优化技巧im2col把卷积操作化成一整块矩阵乘法。Core思想把每一个卷积窗口里的数据“摊平”成一行那么对整张图的所有滑窗位置我们就把输入变成了一个大矩阵每行对应一个窗口内的数据。然后你再看卷积操作的本质它其实就是这个“窗口矩阵”乘以权重矩阵。整个过程从N层for循环变成了一次矩阵乘法NumPy的矩阵乘法底层有BLAS加速速度能快上几十倍。def im2col(x_pad, kernel_size, stride, out_h, out_w): n, c, h, w x_pad.shape k kernel_size cols np.zeros((n, c, out_h, out_w, k, k)) for i in range(out_h): for j in range(out_w): cols[:, :, i, j, :, :] x_pad[:, :, i*stride:i*stridek, j*stride:j*stridek] return cols.transpose(0, 2, 3, 1, 4, 5).reshape(n * out_h * out_w, c * k * k)前向就变得非常清爽了。先把输入padding了一圈零边界处理全靠这一下。然后调用im2col把滑窗摊平一次性乘上卷积核的reshape版本最后把结果还原成(batch, out_channels, out_h, out_w)的形状。def forward(self, x): self.x x n, c, h, w x.shape h_out (h 2*self.pad - self.ksize) // self.stride 1 w_out (w 2*self.pad - self.ksize) // self.stride 1 x_pad np.pad(x, ((0,0), (0,0), (self.pad,self.pad), (self.pad,self.pad))) self.cols im2col(x_pad, self.ksize, self.stride, h_out, w_out) out self.cols.dot(self.W.reshape(self.out_ch, -1).T).reshape(n, h_out, w_out, self.out_ch).transpose(0, 3, 1, 2) return out self.b.reshape(1, -1, 1, 1)这里有个细节需要重点提醒pad到底要加多少通常取pad (kernel_size - 1) // 2这样卷积后空间尺寸不变这也是“same卷积”的原理。其实理解了输出尺寸公式(H 2*pad - k) // stride 1你就能自己推算任意情况下的padding需求而不是只记结论。5. 卷积层反向梯度穿过滑动窗口的完整推导这是整个项目最烧脑的部分也是你手写CNN最重要的收获。网上很多文章讲卷积层反向传播只给结论不解释过程导致读者背代码背到崩溃。咱们把逻辑一步一步捋清楚。先说清楚前向做了什么输入窗口矩阵cols形状是(N*H_out*W_out, C_in*k*k)权重Wreshape后的矩阵W_view形状是(C_out, C_in*k*k)输出就是out cols.dot(W_view.T)。这个过程实际上就是一个全连接层只是这个“全连接层”的输入不是原图而是原图上所有滑窗组成的矩阵。既然如此反向传播就直接复用全连接层的公式设dout的形状为(N, C_out, H_out, W_out)先把它reshape成(N*H_out*W_out, C_out)那么对W_view的梯度就是dW_view dout_flat.T.dot(cols) / n_batch对cols的梯度则是dout_flat.dot(W_view)。到这里已经解决了最麻烦的两步。剩下的是把cols的梯度还原回输入x这就需要对im2col做“逆操作”。既然im2col把x的每个局部窗口拷贝到了cols的某一行里那反向时就是把这个窗口位置的梯度累加回原图的对应位置def backward(self, dout): n, c_out, h_out, w_out dout.shape dout_flat dout.transpose(0, 2, 3, 1).reshape(-1, c_out) self.dW dout_flat.T.dot(self.cols) / n self.db np.sum(dout, axis(0, 2, 3)) / n dcols dout_flat.dot(self.W.reshape(c_out, -1)) dx_pad np.zeros_like(self.x_pad) # 将梯度还原回原图 for i in range(h_out): for j in range(w_out): # dcols中有对应的窗口形状(n, c_in, k, k) window dcols.reshape(n, h_out, w_out, self.in_ch, self.ksize, self.ksize)[:, i, j, :, :, :] dx_pad[:, :, i*self.stride:i*self.strideself.ksize, j*self.stride:j*self.strideself.ksize] window # 去掉padding if self.pad 0: return dx_pad[:, :, self.pad:-self.pad, self.pad:-self.pad] return dx_pad你可能会问为什么反向是而不是因为im2col展开时原图的一个像素可能被多个滑动窗口覆盖也就是它在正向被“复制”了很多份。反向传播时来自不同窗口的梯度都要汇聚到这一个像素上必须累加。这个细节没写对整个训练过程会直接崩掉。还有一个容易被忽略的点卷积核权重在反向传播时为什么要除以batch_size因为前向传播的结果是所有样本的平均误差梯度计算出来自然也要除以样本数。别小看这个与不除的差别不除的后果是学习率要跟着样本数量反复调调参会调到怀疑人生。全连接层和卷积层的update方法都一样W - lr * dWb - lr * db。6. 池化层简单到极致反向传播却容易写错池化层的作用可以概括为“降采样”在保留位置相对特征的同时把特征图变小减少后续计算量还顺带提供了一点平移不变性。最常用的Max Pooling代码极其简单前向后向加起来不到15行但奇怪的是我见过至少三个手写CNN的人在这一层写错过反向。Max Pooling前向很简单遍历每个2×2窗口取最大值作为输出。例如[[1, 3], [2, 4]]直接输出4。真正有门道的是反向传播。因为前向只选了最大值其他位置的信息被丢弃了反向传播时梯度就必须只回传给“被选中”的位置其他位置梯度为0。这就是为什么前向必须在每次池化时记录一个“掩码”——每个输出值是从哪个输入位置来的。class MaxPool: def forward(self, x): self.x x n, c, h, w x.shape h_out, w_out h // 2, w // 2 self.mask np.zeros((n, c, h_out, w_out, 2, 2), dtypebool) out np.zeros((n, c, h_out, w_out)) for i in range(h_out): for j in range(w_out): window x[:, :, i*2:i*22, j*2:j*22] max_val window.max(axis(2, 3)) out[:, :, i, j] max_val self.mask[:, :, i, j] (window max_val[..., None, None]) return out def backward(self, dout): dx np.zeros_like(self.x) n, c, h_out, w_out dout.shape for i in range(h_out): for j in range(w_out): dx[:, :, i*2:i*22, j*2:j*22] dout[:, :, i, j][..., None, None] * self.mask[:, :, i, j] return dx如果你想要更优雅的实现可以用一个叫np.max配合np.indices之类的技巧把for循环去掉但为了可读性我保留这种逐步遍历的写法。这里再提个易错点Max Pooling里如果有两个相同的最大值前向取的是第一个np.argmax返回首个索引那反向梯度该分给谁我的实现用的是window max_val这个掩码如果窗口里有两个最大值梯度会被同时分给它们两个各一份。严格来说这并不完全正确但在实践中几乎没有影响反而省去了解析索引位置的麻烦。你可以自己选择更严格的写法但别在这个地方浪费太多时间。还有一个选择要不要加Average Pooling它在反向传播时更简单——把梯度均匀分配到整个窗口。但在现代网络里Max Pooling的“稀疏选择”机制往往比Average Pooling效果更好所以模型主体我用Max Pooling就够了。7. Softmax与交叉熵分类器收尾的那步“数学魔法”全连接层最后输出的是一组实数可能正可能负范围也不固定。要让它们变成“这是一个数字的概率”就需要Softmax它把一组实数归一化成加起来等于1且非负的概率分布。公式很简单对每个类别得分计算exp(z_i) / sum(exp(z_j))。但直接这么写会踩一个巨大的坑当输入某个值很大比如100exp(100)直接溢出成无穷大。解决办法是让每个类别得分都减去该批次中的最大值这样最高的得分变成0其他得分都小于等于0exp之后最大值不会超过1数值就稳了。def softmax(x): shift x - x.max(axis-1, keepdimsTrue) expx np.exp(shift) return expx / expx.sum(axis-1, keepdimsTrue)交叉熵损失的定义是-log(p_true_class)即“真实类别的预测概率越接近1损失越小”。很多人在这里记一堆公式但真正实现时有一个天大的简化在Softmax配合交叉熵的情况下损失对网络原始输出z的梯度恰好等于Softmax的输出减去真实标签的one-hot编码。class SoftmaxCrossEntropy: def forward(self, logits, labels): self.probs softmax(logits) self.labels labels n logits.shape[0] loss -np.log(self.probs[np.arange(n), labels] 1e-9).mean() return loss def backward(self): grad self.probs.copy() grad[np.arange(grad.shape[0]), self.labels] - 1 return grad / grad.shape[0]这个结论很多书直接给你但只有亲自推一遍你才会意识到为什么分类网络的最后一层通常只能用Softmax为什么多数框架都直接把Softmax和交叉熵耦合在一起以提高数值稳定性和计算效率。不夸张地说理解了这一步反向传播80%的难点就已经解决了。8. 训练回合让LeNet-5在MNIST上跑起来模型结构清楚了代码也都写好了接下来就是把所有模块拼成完整的LeNet-5简化版。我用的具体配置如下输入是28×28的灰度图第一个卷积层输出6个特征图卷积核5×5padding 0这样输出尺寸自动变成24×24随后接2×2的MaxPool第二个卷积层输出16个特征图卷积核5×5随后再接2×2的MaxPool池化输出摊平后过一层120维的全连接ReLU再接84维的全连接最后接10维的Softmax输出。你可以根据自己需求微调通道数这里用6/16是致敬LeNet-5的经典结构。代码里的Sequential组装方式如下net Sequential() net.add(Conv2D(in_ch1, out_ch6, ksize5, stride1, pad0)) net.add(MaxPool(2)) net.add(Conv2D(in_ch6, out_ch16, ksize5, stride1, pad0)) net.add(MaxPool(2)) net.add(Flatten()) net.add(FullyConnected(16 * 5 * 5, 120)) net.add(ReLU()) net.add(FullyConnected(120, 84)) net.add(ReLU()) net.add(FullyConnected(84, 10))训练时我选了最简单的SGD配上Momentum。Momentum的本质是让更新方向带有“惯性”如果前几步一直在往同一个方向走那就顺水推舟继续加速如果方向一直在震荡那就自动刹车。这能显著加速收敛也让损失曲线更平滑实现只需要在update方法里为每个权重多维护一个“速度”变量self.vW self.momentum * self.vW - lr * self.dW self.W self.vW关于学习率我的经验是MNIST这样的小数据集上0.05-0.1是个不错的起点。学习率开太大容易在训练后期loss震荡开太小则收敛慢得让人着急。下载MNIST后我把像素值直接除以255归一到[0, 1]并用one-hot或者整数标签均可我这里直接用整数标签喂给SoftmaxCrossEntropy。我这样训练5个epoch批大小128测试集准确率稳定在97%以上。这中间你还会见到一个经典现象训练集准确率一直在涨但测试集准确率涨到一定程度就不动了甚至开始往下滑这就是过拟合。如果你不搞数据增强也不加正则97%基本就是这套简单结构的天花板。想再往上够到99%你得引入数据平移、旋转等增强手段。但作为无框架手写CNN这个成绩已经足够说明你写的不是玩具而是一只真正能学到特征的网络。9. 实战踩坑记数值梯度检验、NaN排查与提速技巧最后这部分是我最想塞给你的私货全是真金白银换来的教训。第一个坑如何确认backward写对了用数值梯度。反向传播的代码一旦维度写错前向传播还能跑但损失就是下不去。我的建议是在正式训练前写一个梯度检查函数。原理很朴素——直接用导数的定义去近似斜率(f(xepsilon) - f(x-epsilon)) / (2*epsilon)epsilon取1e-6。把它和解析梯度对比如果相对误差在1e-6量级说明backward基本正确。这个小函数价值千金它能帮你把全连接层和卷积层的backward各个击破而不是等到整个网络跑起来之后才一头雾水地debug。第二个坑损失变成NaN99%是梯度爆炸不是学习率设错了。数据不作归一化时MNIST的784维特征值范围在0到255经过多层加权求和后数值会变得巨大梯度也随之爆炸。我实测把输入不做归一化直接训练默认学习率0.1下几乎必现NaN。解决办法一是特征归一化二是He初始化——它的发明就是为了配合ReLU把权重的标准差设为sqrt(2/n_in)让前向和反向的数据方差都维持稳定。另外如果你的网络更深还可以考虑加BatchNorm层但由于我们的主体结构不算深有归一化加He初始化已经足够。第三个坑特别忌讳在反向传播里用替代。在卷积层和池化层的反向还原过程中同一像素会被多个窗口映射到梯度必须累积回原位置。这个坑我在Max Pooling的反向里栽过一次当时定位问题的方式是给网络喂相同输入对比数值梯度在哪一层开始计算出偏差逐层定位修复。强烈建议你也掌握分层检查法而不是每次都把整个网络重跑一遍。第四个坑尝尝Python循环的滋味有多痛。我最初写的im2col是纯for循环版本训练一个epoch要将近三十分钟。改成im2col矩阵运算后一个epoch压缩到两分钟左右。两者数学上完全等价性能差距却是一个数量级。写底层实现时不管怎样都要优先考虑“把循环向量化”Python慢在解释器循环快在底层C的矩阵库调用。最后一个实用提速技巧如果你觉得训练还是慢可以把MNIST数据集降到只取前几千个样本试验先验证代码的正确性和损失下降趋势再全量训练。千万别一上来就拿6万张图开跑。另外每次训练时打印当前epoch的损失值和测试准确率时务必带console flush否则终端会连续刷屏不更新你会以为程序卡住了。这也是很多新手第一次跑大型训练时的常见迷惑。最后再多说一句。这套代码写完之后我的一个特别明显的变化是再回去看PyTorch的卷积层源码无论是F.conv2d还是nn.Conv2d内部实现已经能无障碍读懂它在干什么了。框架只是工具但底层原理永远属于你自己。如果你也想验证一把自己的理解程度不妨试着在现有代码基础上加个BatchNorm层或者把SGD换成Adam优化器。改一轮下来你对“无框架”这三个字的体会绝对比看十篇博客都深。本文还有配套的精品资源点击获取