ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

手写数字识别实战:从零实现CNN训练MNIST模型

手写数字识别实战:从零实现CNN训练MNIST模型 简介一套基于Python与MNIST数据集的手写数字识别完整项目面向计算机相关专业学生的课程设计、期末大作业及机器学习入门实战。资源内含可直接运行的Python源码与全部MNIST数据代码已经严格调试解压后即可复现训练与测试全流程省去数据采集和配置环境的麻烦。资源包为zip格式共9个文件包含2个Python源文件含卷积神经网络实现、4个MNIST数据压缩包、说明文档及辅助文件等整体大小约11.07MB。文件划分清晰源码与数据分离便于逐模块学习和参数调整。目前已有64人学习/下载。通过实际操作学习者可掌握图像归一化、网络构建、训练调参与准确率评估等核心环节也能在此基础上尝试改进模型结构为深入理解深度学习和后续项目实战打下扎实基础。1. 手写数字识别课程设计里最经典的一条起跑线每年期末都能看到一堆同学在群里问手写数字识别的作业怎么写其实这个项目在计算机视觉里的地位差不多相当于编程语言的 Hello World。它要解决的事很具体给一张 28x28 的灰度图判断里面是 0 到 9 哪个数字。MNIST 数据集把这件事变成了一个标准问题——训练集 60000 张、测试集 10000 张图片已经做过居中处理。用卷积神经网络CNN跑这个任务测试准确率做到 99% 左右是完全可行的用朴素的全连接网络也能到 97% 上下。这份资源我拆完之后的结论是它适合两类人一是计算机专业学生拿来做课程设计或期末大作业二是刚入门深度学习、想完整跑通一个训练流程的初学者。源码是纯 Python 实现的卷积网络不依赖 torchvision 这类重量级框架反而更容易看清楚每个参数在干什么。下面从数据集结构开始一步步把它跑起来。2. MNIST 数据集与项目结构先搞清楚你手里有什么2.1 二进制数据格式idx3-ubyte 不是图片文件很多人第一次打开项目压缩包看到一堆.gz结尾的文件就懵了。这四个文件是 MNIST 官方的原始二进制格式不是普通图片也不能直接用cv2.imread去读。每个文件的命名已经把内容说清楚了train-images-idx3-ubyte.gz是训练图片train-labels-idx1-ubyte.gz是训练标签t10k-images-idx3-ubyte.gz是测试图片t10k-labels-idx1-ubyte.gz是测试标签。idx3 表示三维张量图片数量、行数、列数idx1 表示一维向量标签数量。这种格式的头部有一段固定长度的元信息用struct.unpack按大端序解出来就能拿到魔数、样本数和尺寸。魔数2051对应图片文件2049对应标签文件这是判断文件有没有损坏的第一道关卡。我拆项目时习惯先用一个独立的脚本把数据解出来验证一下确认每个字段都对得上再往下走。import gzip import struct import numpy as np def load_mnist_images(filename): # 注意gzip.open 默认是二进制模式直接用即可 with gzip.open(filename, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) # IIII 表示大端序的 4 个无符号 4 字节整数 if magic ! 2051: raise ValueError(魔数不对文件可能损坏或格式错误) data np.frombuffer(f.read(), dtypenp.uint8) return data.reshape(num, rows, cols) def load_mnist_labels(filename): with gzip.open(filename, rb) as f: magic, num struct.unpack(II, f.read(8)) if magic ! 2049: raise ValueError(标签文件魔数错误) return np.frombuffer(f.read(), dtypenp.uint8) # 加载训练集确认形状符合预期 train_images load_mnist_images(train-images-idx3-ubyte.gz) train_labels load_mnist_labels(train-labels-idx1-ubyte.gz) print(train_images.shape) # 期望 (60000, 28, 28) print(train_labels.shape) # 期望 (60000,)struct.unpack(IIII, f.read(16))里的是大端序标志这是 MNIST 官方格式的硬性要求。文件头共 16 字节读取顺序不能乱。np.frombuffer把剩余字节直接映射成 uint8 数组这里有个隐藏性能点它不复制数据只是创建一个视图所以 60000 张图片加载完内存也只占 50MB 左右。如果你先读成 list 再转 numpy内存会翻好几倍。2.2 源码文件组织convolutional.py 与 BUILD 的角色项目里有convolutional.py、__init__.py、BUILD和说明.txt这几个关键文件。convolutional.py是核心里面实现了完整的卷积神经网络包括卷积层、池化层、全连接层和前向/反向传播。__init__.py的存在说明源码是以 Python 包的形式组织的导入时用from convolutional import CNN这种路径就行。BUILD 文件是 Bazel 构建系统的配置文件在 Windows 上跑这个项目用不到它如果你用的是 PyCharm 或 VS Code直接运行convolutional.py入口即可不必折腾构建工具。我拆这类项目时有个习惯先打开主脚本找if __name__ __main__这一行从底部往上看执行流程。这个项目的入口通常包含数据加载、网络初始化、训练循环和测试评估四段弄清楚这段代码的执行顺序比盲目逐行读完整份源码高效得多。2.3 数据预处理归一化与样本可视化原始像素值范围是 0 到 255直接喂给网络有两个问题一是数值太大激活函数输出的梯度容易不稳定二是不同亮度等级的图片对权重的贡献不一致。常见做法是除以 255.0 归一化到 0 到 1 区间。项目正文里提到的中心化作用是让像素分布均值接近 0能加速收敛。实际操作时我一般只做归一化因为卷积网络对轻微的中心偏移并不敏感反而能保留更多原始信息。# 归一化到 0-1 区间同时把标签转成 one-hot train_images_norm train_images.astype(np.float32) / 255.0 test_images_norm test_images.astype(np.float32) / 255.0 # 标签转 one-hot方便算交叉熵损失 def one_hot(labels, num_classes10): eye np.eye(num_classes, dtypenp.float32) return eye[labels] train_labels_oh one_hot(train_labels) test_labels_oh one_hot(test_labels) # 随机挑 25 张可视化一下确认数据没读错 import matplotlib.pyplot as plt fig, axes plt.subplots(5, 5, figsize(6, 6)) for i, ax in enumerate(axes.ravel()): ax.imshow(train_images[i], cmapgray) ax.set_title(str(train_labels[i])) ax.axis(off) plt.tight_layout() plt.savefig(mnist_samples.png, dpi120)归一化这一步决定后续所有训练效果跳过它会让损失值大一个量级学习率也得跟着大改。可视化虽然不参与训练但它能验证读取链路是否正常——如果打印出来的图全是噪点或倒置就不用浪费时间去调网络了问题一定出在数据解析上。提示np.eye(10)[labels]这种写法比显式循环快得多数据量大时感受尤其明显建议养成习惯。3. 卷积网络实现从零写一个能跑的 CNN3.1 网络结构设计两组卷积池化加全连接这份资源里的convolutional.py实现了一个经典的小型 CNN结构设计思路和 LeNet-5 一脉相承输入是 1x28x28 的单通道灰度图第一组卷积层用 8 个 3x3 卷积核提取边缘和纹理特征经过 2x2 最大池化降采样到 14x14第二组卷积层用 16 个 3x3 卷积核池化后到 7x7然后展平成一维向量接一个 128 节点的全连接层最后输出 10 个类别得分。所有激活函数用 ReLU池化步长设为 2。为什么选这个结构而不是更复杂的 VGG 或 ResNet原因很现实这是课程设计你的重点是讲清楚 CNN 原理和完整训练流程而不是追求精度极限。小网络在 CPU 上几十秒就能训练完调试迭代快反向传播公式写起来也能控制篇幅。真要把 ResNet 搬上来光梯度传播的维度匹配就能劝退一大半初学者。3.2 前向传播与反向传播手写实现的关键函数class ConvLayer: def __init__(self, in_channels, out_channels, kernel_size3, lr0.01): # 权重用 He 初始化避免深层网络梯度消失 self.w np.random.randn(out_channels, in_channels, kernel_size, kernel_size) * 0.1 self.b np.zeros((out_channels, 1)) self.lr lr def forward(self, x): # x 形状: (in_channels, H, W) self.x x out_h x.shape[1] - self.w.shape[2] 1 out_w x.shape[2] - self.w.shape[3] 1 self.out np.zeros((self.w.shape[0], out_h, out_w)) for oc in range(self.w.shape[0]): for ic in range(x.shape[0]): for i in range(out_h): for j in range(out_w): patch x[ic, i:i3, j:j3] self.out[oc, i, j] np.sum(patch * self.w[oc, ic]) self.out[oc] self.b[oc] self.out np.maximum(self.out, 0) # ReLU 激活 return self.out def backward(self, dout): # dout 形状与 self.out 相同回传梯度到输入 dx np.zeros_like(self.x) for oc in range(self.w.shape[0]): for ic in range(self.x.shape[0]): for i in range(dout.shape[1]): for j in range(dout.shape[2]): patch_grad dout[oc, i, j] * self.w[oc, ic] dx[ic, i:i3, j:j3] patch_grad return dx上面是卷积层的前向和反向核心逻辑。前向里np.maximum(self.out, 0)就是 ReLU它把负值截断引入非线性反向传播时 ReLU 的导数是 0 或 1判断条件很简单——输入大于 0 就回传梯度否则梯度为 0。patch x[ic, i:i3, j:j3]取出局部感受野与卷积核self.w[oc, ic]做元素乘再加和这就是卷积运算的本质滑动窗口与核的相似度匹配。窗口索引i:i3里3就是 kernel_size如果改大了外层循环边界也要跟着变。梯度更新规则是w - lr * dw但这份源码里我需要你注意反向传播的完整实现还要更新self.w和self.b否则卷积核永远不会学习到特征。你可以在backward里把dw累加后做一次参数更新然后把梯度继续往上一层传。3.3 超参数怎么设学习率、批次大小与迭代次数超参数是这个项目里最玄学的部分也是课程设计答辩时老师最爱问的。学习率lr0.01是这份源码的默认值配合上面那个 0.1 的权重初始化标准差在 MNIST 上收敛稳定。批次大小batch_size128意味着每 128 张图片算一次平均梯度并更新参数迭代次数epochs10在这个小网络上足够收敛到 97% 以上。# 训练主循环骨架batch_size 设为 128 batch_size 128 epochs 10 lr 0.01 for epoch in range(epochs): permutation np.random.permutation(len(train_images_norm)) train_images_shuffled train_images_norm[permutation] train_labels_shuffled train_labels_oh[permutation] for i in range(0, len(train_images_norm), batch_size): x_batch train_images_shuffled[i:ibatch_size] y_batch train_labels_shuffled[i:ibatch_size] # 这行是关键整个小批量的前向传播 反向传播 参数更新 # 实际实现里 CNN 类的外部接口长这样 loss cnn.train_on_batch(x_batch, y_batch) # 每个 epoch 结束打印一次损失观察收敛曲线 print(fEpoch {epoch1}/{epochs}, loss: {loss:.4f})np.random.permutation打乱训练样本顺序很关键如果不打乱模型会学到样本的顺序特征导致验证准确率不稳定。batch_size 的选择是个 trade-off设大会让梯度更稳定但更新次数少、收敛慢设小会引入噪声但反而能跳出局部最优。提示做课程设计时我建议把这三个参数在答辩 PPT 里做成一个对比表格展示不同学习率下的损失曲线差异这比单纯贴代码有说服力得多。4. 模型训练与测试准确率、损失与混淆矩阵4.1 训练流程前向传播、损失计算与参数更新训练的核心动作其实就三步输入图片拿到预测结果前向传播、把预测结果和真实标签算差距损失函数、根据差距调整每个权重反向传播 梯度下降。这份源码里用的是交叉熵损失配合 softmax 输出层。交叉熵的惩罚力度比均方误差大尤其是预测概率和真实标签偏离较多时梯度更大收敛更快。def cross_entropy_loss(y_pred, y_true): # 加 1e-9 防止 log(0) 导致 nan return -np.sum(y_true * np.log(y_pred 1e-9)) / y_true.shape[0] def softmax(x): # 减最大值做数值稳定处理防止指数爆炸 exp_x np.exp(x - np.max(x, axis1, keepdimsTrue)) return exp_x / np.sum(exp_x, axis1, keepdimsTrue) # 训练一个批次后打印损失数值应逐批下降 for epoch in range(epochs): total_loss 0.0 for i in range(0, len(train_images_norm), batch_size): x_batch train_images_norm[i:ibatch_size].reshape(-1, 28*28) # 全连接视角 y_batch train_labels_oh[i:ibatch_size] # 前向 scores model.forward(x_batch) probs softmax(scores) # 计算损失 loss cross_entropy_loss(probs, y_batch) total_loss loss * x_batch.shape[0] # 反向 更新 model.backward(probs - y_batch) # softmax 交叉熵的梯度简化为 probs - y_true model.update_params(lr) avg_loss total_loss / len(train_images_norm) print(fEpoch {epoch1}, avg_loss: {avg_loss:.4f})softmax 交叉熵的梯度化简为probs - y_true这是反向传播里最精妙的结论两者结合后无需手动算 log 导数直接把预测概率减真实 one-hot 标签即可。损失函数的设计决定了模型的优化方向这部分在答辩时一定要能口头推导面试官常问。model.update_params(lr)里面做的是w - lr * dwdw 来自反向传播累积的梯度。如果打印出来的损失值不降反升先别怀疑代码检查数据处理和水流方向——是不是归一化忘了做标签 one-hot 写错了学习率过大这比你盯着反向传播公式死磕高效得多。4.2 模型评估测试集准确率与会说话的数字训练结束不能只看损失值真正的试金石是测试集准确率。MNIST 的测试集是模型从未见过的 10000 张图片它能反映泛化能力。评估时不需要梯度计算只做前向传播取 argmax和真实标签比对后统计命中率。def evaluate(model, images, labels): # images: (num, 28, 28)labels: (num,) 的原始数字标签 probs model.predict(images) preds np.argmax(probs, axis1) acc np.mean(preds labels) return acc, preds test_acc, test_preds evaluate(model, test_images, test_labels) print(fTest accuracy: {test_acc:.4f}) # 混淆矩阵看模型到底在哪一类上翻车 from sklearn.metrics import confusion_matrix cm confusion_matrix(test_labels, test_preds) print(cm)上面代码里的model.predict内部只做前向、不做反向所以速度比训练快一个量级。confusion_matrix的输出是 10x10 矩阵第 i 行第 j 列表示真实类别 i 被预测成 j 的次数对角线越亮越好。实际训练时我习惯打印每一类的精确率和召回率如果发现模型总是把 4 认成 9大概率是训练数据里这两个数字的笔画形态太相似可以通过数据增强解决比如随机旋转 10 度或平移一两个像素。提示一个合格的课程设计报告至少要有三张图——损失下降曲线、训练前后准确率对比、混淆矩阵热力图。这三张图能把你的工作量完整呈现出来比文字描述有力得多。5. 避坑与常见问题从 404 到过拟合的几处翻车记录5.1 torchvision 下载 MNIST 报 404 错误现象用torchvision.datasets.MNIST(root./data, downloadTrue)时报HTTP Error 404: Not Found下载卡死或失败。原因PyTorch 的 torchvision 默认从 Yann LeCun 教授的官方页面拉取 MNIST 压缩包这个链接在国外服务器上国内网络环境下经常不稳定偶尔也会因为 URL 变更为新版本而失效。404 本质是网络不可达或 URL 过期。解决不依赖 torchvision 下载直接使用这份项目压缩包里自带的四个.gz文件放在项目根目录的data文件夹下用第 2 章那个自定义加载函数读取。好处是数据链路完全离线不依赖任何外网期末答辩时现场演示也不会因没网而翻车。如果坚持用 torchvision也可以手动下载后把文件放到指定缓存目录绕过它的自动下载逻辑。5.2 归一化没做或顺序不对导致准确率上不去现象模型训练 10 个 epoch损失值一直在 2.3 左右徘徊测试准确率只有 11%跟瞎猜差不多。原因训练数据直接用的 0 到 255 的原始像素值。数值范围过大导致初始梯度爆炸softmax 输出的概率分布几乎均匀分布交叉熵损失接近 ln(10)2.3。这就是损失值卡在 2.3 不动的典型特征。解决回到数据加载函数里强制加一行images.astype(np.float32) / 255.0。顺便检查数据类型——如果astype时不小心转成了np.int8归一化的除法结果会截断成 0等于数据全黑。我写了个习惯归一化之后打印images.min()和images.max()确认是 0 和 1 再进训练循环。5.3 批次大小与学习率不匹配导致损失震荡现象损失曲线上下剧烈波动完全没有收敛趋势训练结束时准确率只有 85% 左右。原因参考默认参数是batch_size128, lr0.01。但如果有人贪快手动改成了batch_size16却没调学习率小批次样本的随机噪声会让梯度估计方差变大0.01 的学习率在这个噪声水平下显得太高权重要么震荡要么发散。解决批次调小时学习率跟着调小。经验公式是lr_new lr_old * (batch_new / batch_old)即 16/128 0.125学习率应降到 0.001 左右。做实验对比时只改变一个变量不要同时改批次和学习率否则你根本不知道是谁导致的震荡。5.4 训练轮次增加但测试准确率反而下降——过拟合现象训练集准确率到了 99.8%测试集却从 98.5% 掉到 97.8%继续训练掉得更多。原因网络容量对 60000 张训练图来说已经过大了模型开始死记训练样本的噪声和细节没有学到通用规律。MNIST 虽然简单但训练轮次超过一定阈值后过拟合一定会出现只是早晚问题。解决两个方向。一是加正则化在全连接层引入 dropout训练时随机丢弃一部分节点测试时保留全部但乘以保留概率二是数据增强对原始图片做随机平移或旋转制造更多样本。课程设计做这一步就已经是加分项了。如果你想看早期停止的效果可以每个 epoch 记一次测试准确率取最高点对应的模型参数作为最终结果。6. 进阶验证用你自己的手写数字测试模型6.1 自制手写数字并转为 28x28 灰度图训练完模型最好拿自己的字验证一次这一步能让答辩老师眼前一亮。用手机拍下写在白纸上的数字或者用 Windows 画图写一个然后做预处理转灰度、逆色MNIST 是白底黑字而普通照片是黑底白字、缩放并居中。import cv2 import numpy as np def preprocess_own_image(img_path): # 读入并转灰度 img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # MNIST 是白底黑字普通图是黑底白字用 255 - 原图反转 img 255 - img # 缩放前先找到前景的边界框裁剪后再等比缩放避免字形拉伸 coords cv2.findNonZero(img) x, y, w, h cv2.boundingRect(coords) digit img[y:yh, x:xw] # 等比缩放把最长边缩到 20 像素 scale 20 / max(digit.shape) digit cv2.resize(digit, None, fxscale, fyscale, interpolationcv2.INTER_AREA) # 粘贴到 28x28 画布中央保持几何中心一致 canvas np.zeros((28, 28), dtypenp.uint8) start_x (28 - digit.shape[1]) // 2 start_y (28 - digit.shape[0]) // 2 canvas[start_y:start_ydigit.shape[0], start_x:start_xdigit.shape[1]] digit # 归一化并 reshape 成网络输入格式 return canvas.astype(np.float32) / 255.0cv2.resize里的INTER_AREA是缩小图片时效果最好的插值算法能减少边缘锯齿。平移居中是 MNIST 官方的标准自己的图也必须对齐到中心否则模型会将其当作倾斜或偏移样本预测结果不稳定。测试时如果准确率低建议先可视化预处理结果确认字形居中且粗细合适。6.2 用训练好的模型做预测并输出概率分布sample preprocess_own_image(my_digit_7.png) probs model.predict(sample.reshape(1, 1, 28, 28)) # 加 batch 和通道维度 pred np.argmax(probs) confidence np.max(probs) * 100 print(f预测结果: {pred}置信度: {confidence:.2f}%) print(各类别概率:, np.round(probs[0], 4))reshape(1, 1, 28, 28)里的两个 1 分别代表 batch 大小和通道数。查看完整概率分布比只看结果更有价值——如果 4 和 9 的概率都在 40% 左右说明模型确实拿不准只能给出低置信度预测这类样本往往也是混淆矩阵里最容易出错的行。6.3 一个值得保留的习惯把训练与验证封装成固定流程这次拆完这份源码之后我每次做图像分类课程设计或类似练习都会强制自己走一遍同样流程先写一个独立的数据检查脚本验证原始数据形状和内容再写训练脚本最后写评估脚本三个文件分开。训练脚本里固定打印每个 epoch 的损失和测试准确率。这样做的好处是一旦哪一步翻车你能第一时间判断是数据问题、模型问题还是评估逻辑问题而不是在几百行代码里大海捞针。希望这份资源和这篇拆解能帮到你复现一次离真正的深度学习实战就近一步。本文还有配套的精品资源点击获取
返回列表