ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python CNN卷积神经网络代码实现:从MNIST手写识别到反向传播实战

Python CNN卷积神经网络代码实现:从MNIST手写识别到反向传播实战 简介基于Python和TensorFlow实现卷积神经网络CNN的完整代码说明面向深度学习初学者和图像识别方向学习者尤其适合正在完成课程设计或准备入门计算机视觉的读者。压缩包内仅包含1个PDF文档包体大小56KB文件虽小却覆盖了CNN从搭建到训练评测的核心环节目前已有3760人学习下载。PDF以MNIST手写数字识别为实例逐步讲解了数据读取、权重与偏置初始化、二维卷积与最大池化、全连接层、Dropout防过拟合以及Softmax分类的代码写法训练部分采用交叉熵损失函数和Adam优化器共迭代20轮。文档还提供了多组对比实验分别将训练集从1000增至100000、卷积核从16/32增至32/64并展示了对应准确率变化最终指出在训练数据有限时盲目增加卷积核数量对性能提升帮助不大而适当扩充数据集效果更明显。这份文档能够帮助读者快速掌握TensorFlow搭建CNN的标准流程同时理解网络结构、数据规模与模型效果之间的关系是一份简洁实用的入门参考。1. Python CNN 卷积神经网络代码实现起点是手写数字识别终点是能复现的完整脚本很多人 python 入门之后看 CNN 基本结构、CNN 和 RNN 的区别都能说上几句轮到动手做 Python CNN 卷积神经网络代码实现就卡在同一处卷积、池化、全连接这些词到底对应代码里的哪些变量、哪些循环、哪些张量形状。这篇就是为这个卡点写的。我用原生 numpy 手写一个能识别 MNIST 手写数字的小网络覆盖数据准备、im2col 前向传播、反向传播、训练与验收代码可以直接复制跑通。适合两类人想把卷积原理落成代码的 python 学习者以及准备迁移到深度学习框架、但想先知道框架背后在做什么的从业者。2. 先把数据准备写好MNIST 加载、归一化与批次切分CNN 代码实现的第一步不是写卷积层而是拿到一份“训得快、不会错”的数据。MNIST 是手写数字识别领域最常见的入门集28x28 灰度、一共 10 类单张只有 784 个像素CPU 上也能在几分钟内完成一轮训练。自定义图片集通常要先解决尺寸不一致、类别不均衡、标注格式不统一的问题这些和卷积代码本身混在一起出了问题很难定位。常见做法是先拿 MNIST 通流程之后再换成自己的数据。2.1 为什么先用 MNIST而不是自定义图片集MNIST 的好处是数据形状固定、标签干净不需要做目标检测里那种坐标标注。像“鸟类识别系统的设计与实现完整代码界面数据集项目”这类带界面的图像识别项目数据来自网上爬取或人工拍摄图片尺寸从几百像素到几千像素都有必须先做缩放、裁剪、统一通道数。而在 MNIST 上这些环节全部被省掉代码可以专注于网络本身。环境上只需要 numpy 和 scikit-learn用来取数画图时再加 matplotlib。如果照着 python 安装教程装好解释器用 PyCharm 配置 Python 环境或 VSCode 配置 Python 环境都行并不影响后面的代码。下面的数据管道是整个项目的地基import numpy as np from sklearn.datasets import fetch_openml np.random.seed(0) print(读取 MNIST先取 20000 张减少首次跑通压力) X, y fetch_openml(mnist_784, version1, return_X_yTrue, as_frameFalse) X X[:20000].astype(np.float32) / 255.0 y y[:20000].astype(np.int64) X X.reshape(-1, 1, 28, 28) def onehot(y, num_classes10): out np.zeros((len(y), num_classes), dtypenp.float32) out[np.arange(len(y)), y] 1.0 return out idx np.random.permutation(len(X)) X_train, y_train X[idx[:16000]], onehot(y[idx[:16000]]) X_test, y_test X[idx[16000:]], onehot(y[idx[16000:]]) def batch_loader(X, y, batch_size64): n len(X) perm np.random.permutation(n) for i in range(0, n - n % batch_size, batch_size): yield X[perm[i:i batch_size]], y[perm[i:i batch_size]] batch next(batch_loader(X_train, y_train, 64)) print(X 形状:, batch[0].shape, y 形状:, batch[1].shape)这里把像素从 0~255 归一化到 0~1是后面能训起来的前提。CNN 对输入尺度敏感原始灰度值范围太大第一层卷积输出的梯度很容易溢出。onehot把 0~9 的标签变成 10 维单位向量配合后面的 softmax 交叉熵正好对上。数据量取了 20000 张而不是全部 70000 张训练集 16000、测试集 4000比例约 8:2保证 CPU 上迭代 5 个 epoch 也能在十分钟左右跑完。2.2 批次切分与形状自检batch_loader 的实现很朴素每次打乱索引后按 batch_size 切片。yield让它在训练循环里按需产出 batch不会一次性把所有数据都拷进内存。batch_size 取 64 是经验值太大梯度平滑但收敛慢太小梯度抖动明显。手写 numpy 网络没有自动混合精度和显存限制64 对 28x28 输入是安全选择。接下来做一个形状自检函数把每一层的输入输出打出来。这一步能提前暴露 80% 的维度 bug尤其是卷积和池化的空间尺寸计算错误def debug_dim(model, x): h x print(input:, h.shape) for layer in model.layers: h layer.forward(h) print(layer.__class__.__name__, h.shape)有些文章会把形状自检放在网络写完之后我一般更早做。因为一旦某个形状对不上后面的反向传播梯度 reshape 全是错位视觉上很难发现。先花 10 分钟确认每层输出形状符合预期比训练到一半再回头追维度省事得多。2.3 训练前先验证一张图的像素范围还有一个容易忽略的自检确认输入数据没有奇怪的极值。MNIST 在除以 255 之后像素范围应该在 [0, 1]标签 onehot 后每行只有一个 1。如果从 OpenML 或本地文件读入的是 uint8切换成 float32 后才能正常参与矩阵乘法。sample_x X_train[0] sample_y y_train[0] print(像素范围:, sample_x.min(), sample_x.max()) print(标签索引:, np.argmax(sample_y))这里如果看到 min0、max255说明归一化那行没执行如果 np.argmax 结果不在 0~9说明标签对齐出了问题。数据管道是后面所有代码的输入边界输入错了网络写得再对也没有意义。3. 手写卷积层、池化层与 im2col前向传播的完整代码与维度推演现在进入核心。CNN 的前向传播由卷积、池化、全连接三类算子组成代码实现的关键是先定形状再写计算。很多人把网络图画在纸上觉得清楚一落到代码就不知道某个矩阵该是几行几列问题就出在没有先把“形状推导”写成一段确定的逻辑。3.1 先定输出形状padding 与 stride 的数学关系卷积层输入形状是 (n, in_channels, h, w)输出通道数由卷积核个数决定空间尺寸由下面这个公式决定def conv_out_size(h_in, w_in, kh, kw, pad, stride): h_out (h_in 2 * pad - kh) // stride 1 w_out (w_in 2 * pad - kw) // stride 1 return h_out, w_out print(conv_out_size(28, 28, 3, 3, 1, 1))这个公式是全部卷积代码里最不能错的一行。以 28x28 输入、3x3 卷积核、pad1、stride1 为例输出仍然是 28x28因为(282-3)//1128。手写实现时pad 大会看到输出变大stride 大会看到输出变小两者经常被同时设置错导致后面全连接层的输入维度对不上。我这里的网络结构定为卷积 1 通道到 4 通道池化一次卷积 4 通道到 8 通道池化一次最后拉平进 10 输出全连接。经过两次 2x2 池化28x28 变成 14x14 再变成 7x7所以全连接层输入是8*7*7392维。这个数字不是拍脑袋而是形状公式推出来的结果。代码里所有 reshape 都依赖这个 392。3.2 im2col把滑窗卷积变成矩阵乘法卷积最直接的实现是三到四层嵌套循环循环写法直观但慢而且反向传播要重复写循环。常见做法是使用 im2col把每个卷积窗口拉成一行所有窗口拼成一个二维矩阵再用卷积核矩阵乘以这个矩阵。这样一次 matmul 完成所有位置的卷积反向传播也只需要对矩阵做转置乘法。def im2col(images, kh, kw, pad, stride): n, c, h, w images.shape out_h (h 2 * pad - kh) // stride 1 out_w (w 2 * pad - kw) // stride 1 padded np.pad(images, ((0, 0), (0, 0), (pad, pad), (pad, pad)), modeconstant) cols np.zeros((n, c, kh, kw, out_h, out_w), dtypeimages.dtype) for i in range(kh): for j in range(kw): cols[:, :, i, j, :, :] padded[:, :, i:i out_h * stride:stride, j:j out_w * stride:stride] return cols.transpose(1, 2, 3, 0, 4, 5).reshape(c * kh * kw, n * out_h * out_w)n是 batch sizec是输入通道。np.pad在高度和宽度方向各补 pad 圈 0这是在处理图像边界。两个 for 循环遍历卷积核内的每个位置用步长为 stride 的切片取出所有滑动窗口然后把维度重排成(c*kh*kw, n*out_h*out_w)。这样每一列对应一个窗口内的像素值每一行对应一个通道/核内位置。这个函数的缺点是中间矩阵会放大 kh*kw 倍输入尺寸一大内存就吃紧。所以手写 numpy 网络适合小图若是 224x224 的图应该直接交给深度学习框架处理。理解 im2col 的意义在于它让卷积的反向传播从“对卷积核做 180 度旋转”变成“对矩阵做转置”逻辑上更容易验证。3.3 卷积层的前向与池化层的前向有了 im2col卷积层本身只剩几行class Conv2D: def __init__(self, in_channels, out_channels, kernel_size3, pad1, stride1): self.in_channels in_channels self.out_channels out_channels self.kh self.kw kernel_size self.pad pad self.stride stride self.W np.random.randn(out_channels, in_channels * kernel_size * kernel_size) * 0.1 self.b np.zeros(out_channels) self.vW np.zeros_like(self.W) self.vb np.zeros_like(self.b) def forward(self, x): n, c, h, w x.shape out_h, out_w conv_out_size(h, w, self.kh, self.kw, self.pad, self.stride) self.x x self.cols im2col(x, self.kh, self.kw, self.pad, self.stride) out self.W self.cols self.b.reshape(-1, 1) return out.reshape(self.out_channels, n, out_h, out_w).transpose(1, 0, 2, 3) def backward(self, grad): n, _, out_h, out_w grad.shape grad_flat grad.transpose(1, 0, 2, 3).reshape(self.out_channels, n * out_h * out_w) self.grad_W grad_flat self.cols.T self.grad_b grad_flat.sum(axis1) d_cols self.W.T grad_flat return col2im(d_cols, self.x.shape, self.kh, self.kw, self.pad, self.stride)初始化用* 0.1而不是标准正态的* 1.0是因为输入已经归一化到 [0,1]且网络只有两层卷积0.1 的尺度足够让梯度稳定。如果加深网络这个尺度就得换成 He 初始化否则深层梯度容易爆炸。vW和vb是动量缓冲给后面的更新步骤用。前向里self.W self.cols得到的是 (out_channels, nout_hout_w) 的矩阵reshape 再转置成 (n, out_channels, out_h, out_w)这时候每个样本的输出通道位置才对得上。池化层的前向本质是下采样用一个固定窗口取最大值窗口之间不重叠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 ps self.pool_size out_h h // ps out_w w // ps self.x x x_crop x[:, :, :out_h * ps, :out_w * ps] x_flat x_crop.reshape(n, c, out_h, ps, out_w, ps) x_flat x_flat.transpose(0, 1, 2, 4, 3, 5).reshape(n, c, out_h * out_w, ps * ps) self.idx np.argmax(x_flat, axis3) out x_flat.max(axis3).reshape(n, c, out_h, out_w) return out def backward(self, grad): n, c, out_h, out_w grad.shape ps self.pool_size grad_flat grad.reshape(n, c, out_h * out_w, 1) dx_flat np.zeros((n, c, out_h * out_w, ps * ps), dtypegrad.dtype) np.put_along_axis(dx_flat, self.idx[:, :, :, None], grad_flat, axis3) dx dx_flat.reshape(n, c, out_h, out_w, ps, ps) dx dx.transpose(0, 1, 2, 4, 3, 5).reshape(n, c, out_h * ps, out_w * ps) full np.zeros_like(self.x) full[:, :, :dx.shape[2], :dx.shape[3]] dx return full这里最难理解的是transpose。x_flat的窗口内像素是按“先列后行”排列的np.argmax拿到每个 2x2 窗口内最大值的扁平索引反向传播时梯度只还给最大值位置其他位置补 0。np.put_along_axis把这 4 个梯度值放回各自窗口的对应位置再用 transpose 还原成图像坐标。最后full处理输入尺寸不能被 2 整除时被裁剪掉的那一行/列梯度补 0。前向里保存self.idx是池化层能够反向传播的关键。4. 反向传播与训练闭环把梯度从全连接层传回卷积核前向传播只回答“输出是什么”真正让网络学会识别数字的是反向传播。手写反向传播比调框架难得多但逻辑链条很固定从损失函数求导开始经过全连接层、池化层、卷积层逐层往回传。每一步只看三样东西输入是什么、输出是什么、当前层的参数梯度是什么。4.1 softmax 交叉熵损失与全连接层梯度分类任务的输出是 10 个类别的得分softmax 把得分转成概率交叉熵衡量预测概率与真实标签的差距。两者组合后的梯度形式非常简洁softmax 概率减去 onehot 标签这个结论可以直接拿来用不需要展开中间所有偏导。数值稳定性上先把 logits 减掉最大值再做 exp防止指数溢出。def softmax_cross_entropy(logits, y_onehot): exp_logits np.exp(logits - logits.max(axis1, keepdimsTrue)) probs exp_logits / exp_logits.sum(axis1, keepdimsTrue) loss -np.mean(np.sum(y_onehot * np.log(probs 1e-12), axis1)) dlogits (probs - y_onehot) / len(y_onehot) return loss, dlogits, probsprobs是每个类别的预测概率dlogits是传递给下一层的梯度。除以len(y_onehot)是因为 loss 对 batch 取了平均梯度也要对应除以 batch 大小。1e-12是防止某个类别概率为 0 导致 log 出现负无穷。全连接层的反向传播是三块里最简单的因为本质上就是线性层y x W bclass FullyConnected: def __init__(self, in_features, out_features): self.W np.random.randn(in_features, out_features) * 0.01 self.b np.zeros(out_features) self.vW np.zeros_like(self.W) self.vb np.zeros_like(self.b) def forward(self, x): self.x x return x self.W self.b def backward(self, grad): self.grad_W self.x.T grad self.grad_b grad.sum(axis0) return grad self.W.Tgrad_W的形状与self.W完全一致更新时逐元素减学习率乘梯度即可。self.x.T grad看起来是矩阵乘法实质上是把所有样本的贡献累加到一起所以不需要再写 for 循环。手写反向传播最容易犯的错误是忘记保存前向输入self.x到 backward 时找不到数据这一点在框架里由自动缓存处理但手写时必须自己记得。4.2 卷积层反向传播col2im 与卷积核翻转的等价实现卷积层的反向传播有两种写法。一种是按卷积定义逐个位置循环求导需要对卷积核做 180 度旋转再套一层卷积另一种是用 im2col 的矩阵形式梯度经过W.T后自然完成了同样的重排。这里走的是第二种实现函数是 col2im它做的是 im2col 的逆操作def col2im(cols, img_shape, kh, kw, pad, stride): n, c, h, w img_shape out_h (h 2 * pad - kh) // stride 1 out_w (w 2 * pad - kw) // stride 1 cols cols.reshape(c, kh, kw, n, out_h, out_w).transpose(3, 0, 1, 2, 4, 5) padded np.zeros((n, c, h 2 * pad, w 2 * pad), dtypecols.dtype) for i in range(kh): for j in range(kw): padded[:, :, i:i out_h * stride:stride, j:j out_w * stride:stride] cols[:, :, i, j, :, :] return padded[:, :, pad:pad h, pad:pad w]col2im 做的事情是把散落在各个窗口里的梯度按滑动窗口的位置累加回原始输入。因为同一个输入像素可能出现在多个卷积窗口里所以用的是而不是覆盖赋值。这就是卷积反向传播的“累加”本质一个输入像素的梯度是所有覆盖到它的卷积窗口的梯度之和。卷积层完整反向传播在上一章 Conv2D.backward 里已经给出。self.grad_W grad_flat self.cols.T求的是卷积核参数的梯度d_cols self.W.T grad_flat求的是传给输入的梯度再交给 col2im 还原形状。这里W.T在做的事情等价于把卷积核旋转 180 度后再卷积只是它被矩阵转置掩盖了很多手写实现翻车的点就在这里。4.3 训练主循环动量、学习率与精度统计把前向、损失、反向、更新串起来的训练循环不长但结构值得固定下来class SimpleCNN: def __init__(self): self.conv1 Conv2D(1, 4, kernel_size3, pad1, stride1) self.pool1 MaxPool(2, 2) self.conv2 Conv2D(4, 8, kernel_size3, pad1, stride1) self.pool2 MaxPool(2, 2) self.fc FullyConnected(8 * 7 * 7, 10) self.layers [self.conv1, self.pool1, self.conv2, self.pool2, self.fc] def forward(self, x): self.cache1 self.conv1.forward(x) self.h1 np.maximum(self.cache1, 0) self.p1 self.pool1.forward(self.h1) self.cache2 self.conv2.forward(self.p1) self.h2 np.maximum(self.cache2, 0) self.p2 self.pool2.forward(self.h2) self.flat self.p2.reshape(x.shape[0], -1) self.logits self.fc.forward(self.flat) return self.logits def backward(self, dlogits): d self.fc.backward(dlogits) d d.reshape(self.p2.shape) d self.pool2.backward(d) d d * (self.cache2 0) d self.conv2.backward(d) d self.pool1.backward(d) d d * (self.cache1 0) d self.conv1.backward(d) return d def update(self, lr0.05, momentum0.9): for layer in self.layers: if hasattr(layer, update): layer.update(lr, momentum)Conv2D 和 FullyConnected 的 update 方法需要单独确认一下它用动量的方式累积历史梯度避免每一步都朝噪声方向大幅跳动。vW momentum * vW grad_W相当于把最近几次梯度做了加权平均W - lr * vW再更新参数。momentum0.9 是常见默认值对这个小网络很稳定。lr 取 0.05比全连接常用的 0.01 略大因为卷积核只有 9 个参数收敛速度相对慢学习率太小时 5 个 epoch 精度上不去。训练循环如下lr 0.05 momentum 0.9 epochs 5 model SimpleCNN() for epoch in range(epochs): losses [] correct 0 total 0 for x_batch, y_batch in batch_loader(X_train, y_train, 64): logits model.forward(x_batch) loss, dlogits, probs softmax_cross_entropy(logits, y_batch) model.backward(dlogits) model.update(lr, momentum) losses.append(loss) correct (np.argmax(probs, axis1) np.argmax(y_batch, axis1)).sum() total len(y_batch) test_logits model.forward(X_test) test_pred np.argmax(test_logits, axis1) test_acc (test_pred np.argmax(y_test, axis1)).mean() print(fepoch {epoch1}: loss {np.mean(losses):.4f}, train_acc {correct/total:.4f}, test_acc {test_acc:.4f})这个循环跑完正常情况下 loss 会从 0.5 附近降到 0.2 以下测试精度在 0.9 以上。如果精度一直停在 0.1 附近或者 loss 直接变成 NaN问题几乎都出在第 5 章的几类坑里。5. CNN 代码实现避坑指南维度偏差、梯度异常与训练不收敛手写 CNN 代码的难处不在原理而在调试。框架帮你封装好的维度检查、梯度缓存、自动求导这里全得自己盯。下面几条是从实际踩坑里整理出来的高频问题每一条都按“现象 → 原因 → 解决”的顺序写。5.1 卷积输出尺寸对不上padding 与 stride 反查现象程序报错提示 matmul 或 reshape 的维度不匹配比如全连接层期望 392 维输入实际得到 512 维。原因某层卷积或池化之后的 h、w 和预期不同。常见组合是 pad1、stride2或者 pad0、stride1算出来的尺寸与设计图不一致。解决不要靠肉眼数直接反查每一层。对每一层都调用 conv_out_size把中间形状打出来def check_shapes(model, x): h x for layer in model.layers: h layer.forward(h) print(layer.__class__.__name__, h.shape)如果发现 conv2 输出不是 14x14先检查上一层的池化输出是不是 14x14再检查卷积核大小和 pad。调维度时改一处就要重推一遍全连接输入不然会引发连锁错误。我的习惯是把 392 这个数字写成一个变量而不是在代码里写死数字这样网络结构调整后只改一处。5.2 损失下降但精度不动检查最后一层梯度现象loss 每轮都在降从 0.6 降到 0.3但 train_acc 一直徘徊在 0.1~0.2。看起来“在学”实际上模型只学会了把概率集中到某一个类并没有区分能力。原因softmax 交叉熵在模型输出接近全 0 时loss 会缓慢下降但梯度方向很弱不足以让特征提取层产生区分。常见触发点是初始化尺度过大、学习率过小或者卷积层输出经过 ReLU 后大量神经元死亡。解决打印每一层参数梯度的 L2 范数判断梯度是否健康。梯度完全为 0 是死亡梯度超过 1 是爆炸远小于 1e-6 是消失。for layer in model.layers: if hasattr(layer, grad_W): print(layer.__class__.__name__, grad W norm:, np.linalg.norm(layer.grad_W))如果最后一层 grad_W 范数正常但第一层卷积的梯度比它小几个数量级说明信号在反向传播途中衰减。这时优先把初始化尺度从 0.1 降到 0.05或者加一层批归一化。手写实现里加批归一化代码量不低实际项目里一般直接换用框架的 BatchNorm2d。5.3 归一化漏写灰阶 0/255 直接把梯度炸掉现象loss 从第一个 batch 就变成 NaN或者在第一轮之后突然变 NaN。有些人以为是学习率问题把 lr 调到 0.001 还是不行。原因输入像素没有除以 255范围在 0~255。28x28 像素乘以 3x3 卷积核和 0.1 量级权重后卷积输出很容易到几十甚至上百。softmax 交叉熵对这些大数值的梯度会指数级放大多传几层就溢出。解决数据管道里必须做归一化而且要确认在 reshape 之前执行。我习惯在拿到原始 X 后第一时间除以 255 并转成 float32这样后面所有层都工作在 [0,1] 范围内。如果数据显示X.min()是 0、X.max()是 255那就回到第 2 章的数据自检重新查一遍。5.4 反向传播忘记翻转卷积核梯度爆炸的经典现场现象前几轮 loss 还算正常第 3、4 轮开始剧烈震荡甚至变 NaN或者 loss 正常但 train_acc 一直在 0.1 附近。原因如果实现是用“卷积”而不是“矩阵转置”来做反向卷积核必须旋转 180 度。很多手写实现把前向的卷积核原样拿来做反向卷积梯度方向错误网络无法收敛。用 Conv2D.backward 里self.W.T grad_flat这种写法的人容易误以为没做旋转。解决先确认自己走的是哪条路。走矩阵乘法的W.T就是旋转的等价实现不需要额外翻转走循环卷积的必须在反向前执行np.rot90(W, 2, axes(1,2))这类操作。最稳妥的验证方法是对单个权重做数值梯度差分比对解析梯度与数值梯度误差在 1e-5 量级说明反向没问题。5.5 训练耗时超出预期小网络为什么也跑得慢现象5 个 epoch 跑了快一小时CPU 风扇狂转精度还没到 0.9。细看问题出在“小网络”三个字上网络参数确实少但 im2col 把每个 batch 的窗口展开了 9 倍数据量一点不小。原因20000 张图、64 的 batch、5 个 epoch实际上是 16000/64*51250 次前向反向。每次前向都要做 im2col 和矩阵乘Python 层循环和 numpy 的开销叠加起来并不低。如果再把 batch 调到 256矩阵变大但循环变少训练速度反而可能更慢因为单次计算时间没有线性缩短。解决先把数据量砍到 5000 张训练 4000、测试 1000确认代码逻辑没问题后再用完整数据跑精度。通道数也可以从 4/8 临时改成 2/4im2col 的列数会明显变小。真正常见的做法是只把这个手写版当作教学验证等到数据量上来或输入图变大就迁移到 PyTorch 这类框架让框架的底层算子接手。6. 收尾验收数值梯度检查、特征图可视化与数据替换方向手写 CNN 代码跑通训练后先别急着换数据集。真正可靠的自检是数值梯度检查把某个权重加一个微小扰动计算网络输出变化的比值与解析梯度对比。误差在 1e-5 以内说明整条反向传播链路是通的。这个检查应该在新网络刚搭好时立刻做而不是等训练出了问题再查。6.1 数值梯度检查是最早的后悔药def check_grad_fc(fc, x, y_onehot, eps1e-6): def loss_fn(): logits fc.forward(x) loss, _, _ softmax_cross_entropy(logits, y_onehot) return loss loss0 loss_fn() for i in range(3): fc.W[i, 0] eps loss1 loss_fn() fc.W[i, 0] - 2 * eps loss2 loss_fn() fc.W[i, 0] eps num_grad (loss1 - loss2) / (2 * eps) ana_grad fc.grad_W[i, 0] print(数值梯度, num_grad, 解析梯度, ana_grad)这段代码只检查前 3 个权重已经足够暴露梯度问题。解析梯度和数值梯度方向不一致一定是反向传播某行代码的问题方向一致但数量级差 10 倍通常是公式里漏了除法或累加。检查通过后再去做测试精度验收。6.2 特征图和卷积核可视化训练结束后把第一层卷积核和几张测试图对应的特征图画出来是判断网络有没有学到结构的直观方式。第一层 4 个 3x3 卷积核相当于 4 个边缘检测器。可以调用 matplotlib 的 imshow 把model.conv1.W[i].reshape(3,3)画成灰度图也可以取一张测试图过 conv1 前向把 4 个通道的 28x28 输出分别画出来。如果特征图和原图几乎一样说明卷积核没有学到有效滤波器常见原因是训练轮数太少或学习率过小。6.3 从手写网络往真实项目替换手写版稳定之后替换数据集的方向很清晰只要把输入改成 n 张、固定通道数、固定高宽的数组就能迁移到类似“深度学习模型 CNN 识别恶意软件”这类任务或者“鸟类识别系统的设计与实现”这类带界面的识别项目。替换时记得把图片统一缩放到相同尺寸类别标签重新从 0 编号数据量小时优先用数据增强扩充。再往上走就是把这一套手写算子换成框架实现数据管道和训练循环的结构基本不变。之前我给一个量化团队做过一版 CNN 因子最后就是把 numpy 手写网络换成简洁的框架训练脚本。那次踩得最深的坑是把数据规范化写在了单次数据加载函数里忘了对测试集做同样处理导致测试集和训练集分布不一致测试精度虚低。从那以后我每写一个网络都把训练、测试共用的数据预处理封装成同一个函数避免两边不一致。希望帮到你。本文还有配套的精品资源点击获取
返回列表