Python从零实现手写数字识别CNN:MNIST训练与踩坑全记录

发布时间:2026/9/28 3:19:38
Python从零实现手写数字识别CNN:MNIST训练与踩坑全记录 简介面向Python初学者与计算机视觉入门者的手写数字识别项目资源基于卷积神经网络CNN实现覆盖数据加载、模型搭建、训练评估全流程。压缩包中共4个文件含3个CSV数据文件和1个Python脚本整体大小为13.26MBCSV文件提供训练集、测试集及预测结果Python脚本则实现完整的CNN模型。目前已有472人学习下载。脚本完整演示了将28x28灰度像素数据转换为二维数组、归一化与One-Hot编码再构建卷积层、池化层、全连接层并通过交叉熵损失与Adam优化器完成反向传播训练在测试集上可达约99%准确率预测结果文件可用于对比分析模型的泛化能力。数据文件按28x28灰度图像格式组织还可参考超参数调整与数据增强思路以进一步提升图像分类表现。该资源适合课程设计、深度学习入门或作为手写识别模型的实战参考。1. 为什么选 CNN 做手写数字识别一个 28×28 像素的经典战场MNIST 手写数字识别几乎是每个接触深度学习的人绕不过去的第一道坎。它只有 10 个类别、图像是 28×28 的灰度图数据集规模也不大但就是这样一个看似“玩具级”的任务却能把 CNN 卷积神经网络的核心机制讲透也能把训练过程中的各种翻车现场完整暴露出来。很多人以为手写数字识别已经是老掉牙的东西实际上一线做 OCR、表单识别、票据自动录入的工程师依然把 MNIST 当作验证网络结构和调参思路的基准。用 Python 从零实现手写数字识别 CNN最大的价值不是跑通一个 99% 准确率的模型——这个用 PyTorch 或 TensorFlow 几行代码就能做到。真正有价值的是理解每一层在做什么卷积核为什么能提取局部特征池化层为什么能降低计算量全连接层为什么放在最后以及训练时学习率设置不当会带来什么样的后果。这篇文章不讲理论推导而是按照我平时做图像分类项目的套路从数据加载、网络搭建、训练调参到踩坑排查完整走一遍。全程只依赖 Python 生态里的常见库没有复杂的工程依赖适合新手跟着复现也适合熟手对照检查自己的习惯。2. 从数据到张量MNIST 的加载、预处理与可视化验证2.1 为什么先处理数据而不是先搭模型做图像识别项目我见过太多人上来就写模型结果训练时发现数据格式不对、标签对不上、图像方向翻转折腾几个小时全在排查数据问题。手写数字识别虽然数据干净但依然要养成先加载数据、先做可视化的习惯。MNIST 本身是 60 万张训练图加 1 万张测试图的经典数据集但因为历史原因原始文件是 IDX 格式不是常见的 PNG 或 JPG直接用图像库读不了。一般做法有两种一是用深度学习框架自带的数据集接口PyTorch 的torchvision.datasets.MNIST和 TensorFlow 的keras.datasets.mnist都能一键加载二是用 Python 手写解析 IDX 格式的脚本。第一种省事但不理解底层结构第二种麻烦但对数据流的理解更深。我建议至少手写一次解析脚本因为实际做项目时拿到的一定不是现成接口能读的格式。import numpy as np import struct def load_mnist_from_idx(image_path, label_path): 手动解析 IDX 格式的 MNIST 原始文件 with open(label_path, rb) as f: magic, num struct.unpack(II, f.read(8)) labels np.frombuffer(f.read(), dtypenp.uint8) with open(image_path, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) images np.frombuffer(f.read(), dtypenp.uint8).reshape(num, rows, cols) return images, labels # 假设你已经下载了四个 gz 文件并解压 train_images, train_labels load_mnist_from_idx( train-images.idx3-ubyte, train-labels.idx1-ubyte) test_images, test_labels load_mnist_from_idx( t10k-images.idx3-ubyte, t10k-labels.idx1-ubyte) print(fTrain: {train_images.shape}, {train_labels.shape}) print(fTest: {test_images.shape}, {test_labels.shape}) print(f像素范围: {train_images.min()} ~ {train_images.max()})这段代码的核心是struct.unpack(II, ...)表示大端字节序MNIST 文件格式规定所有整数都是大端存储如果漏掉直接解析读出来的图像数量会是一个巨大的乱码数字。num, rows, cols分别代表样本数、图像高度、图像宽度解析后 reshape 成(num, rows, cols)就能得到标准的图像张量。这里有个容易忽略的细节像素值是 0 到 255 的整数直接喂给 CNN 不是不行但会导致梯度更新不稳定。常见做法是除以 255 归一化到 [0,1] 区间或者进一步做标准化。我一般选择除以 255因为 MNIST 背景是黑色数字是白色对比度本身很强不需要复杂的标准化处理。2.2 训练集、验证集与测试集的划分逻辑很多跑 MNIST 的教程直接拿训练集训练、测试集测试这在小数据集上问题不大但一旦开始调超参数就会陷入过拟合测试集的陷阱。正确做法是从训练集中切出一部分做验证集用来监控训练过程、决定何时停止、比较不同超参数的效果测试集只用来做最终评估。常用比例是训练集 90%、验证集 10%。MNIST 训练集有 6 万张切出 6000 张做验证集完全够用。PyTorch 里可以用torch.utils.data.random_split配合Subset实现TensorFlow 则用tf.data.Dataset配合skip和take。这里的核心原则是用验证集调参用测试集一锤定音不能让测试集参与任何决策过程。数据预处理还有一个容易忽视的点数据增强。很多初学者以为 MNIST 不需要增强但实际上手写数字的笔迹变化非常大简单的随机平移、旋转、缩放能显著提升泛化能力。PyTorch 里用torchvision.transforms.RandomAffine加随机旋转 10 度、平移 0.1 倍训练出来的模型对稍微倾斜的数字更鲁棒。2.3 可视化数据看一眼才知道问题在哪脚本跑通只是第一步必须把加载出来的图像画出来看一眼。我曾经遇到过标签文件和解压文件不匹配导致标签整体偏移的问题如果不做可视化模型训练到 99% 准确率才发现某些数字永远识别不对那才叫真正的血泪教训。import matplotlib.pyplot as plt fig, axes plt.subplots(2, 5, figsize(10, 4)) for i in range(10): ax axes[i // 5][i % 5] ax.imshow(train_images[i], cmapgray) ax.set_title(fLabel: {train_labels[i]}) ax.axis(off) plt.tight_layout() plt.show()运行这段代码后人工检查十张图的数字和标签是否一致。同时统计一下每个类别的样本数量确认没有严重的类别不平衡。MNIST 的样本分布相对均匀但如果是自己采集的手写数据这一步能提前发现某个数字样本极少影响训练效果。3. 用 PyTorch 手搭 CNN 网络从卷积层到全连接层的结构拆解3.1 CNN 基础结构卷积、池化、激活、全连接的分工逻辑关于手写数字识别的网络结构业界已经有一套非常成熟的范式输入 1 个通道的 28×28 灰度图经过若干组卷积加池化提取特征最后展平送入全连接层分类。为什么 CNN 在这个任务上远远胜过传统全连接网络原因是图像是二维结构数字的笔画是局部连贯的模式卷积核天然适合提取这类局部特征。经典的 LeNet-5 结构是卷积层提取边缘和纹理池化层做下采样降低特征图尺寸再继续卷积提取更高层语义最后全连接层把特征映射到 10 个类别。现在用 PyTorch 实现这个结构代码量不大但每一层的参数含义需要理解透彻。特别需要注意的是输入维度PyTorch 默认的卷积层输入格式是(batch, channels, height, width)而很多人从别的框架转过来会习惯性拿(batch, height, width, channels)的格式去喂报错信息往往让人一头雾水。3.2 手写 LeNet-5 风格网络的 PyTorch 实现import torch import torch.nn as nn import torch.nn.functional as F class HandwrittenCNN(nn.Module): 手写数字识别的 CNN 网络LeNet-5 风格的简化版 def __init__(self, num_classes10): super(HandwrittenCNN, self).__init__() # 第一组卷积1通道 - 6通道5x5卷积核 self.conv1 nn.Conv2d(in_channels1, out_channels6, kernel_size5, padding2) # 池化层2x2 最大池化 self.pool1 nn.MaxPool2d(kernel_size2, stride2) # 第二组卷积6通道 - 16通道5x5卷积核 self.conv2 nn.Conv2d(in_channels6, out_channels16, kernel_size5) self.pool2 nn.MaxPool2d(kernel_size2, stride2) # 全连接层16*5*5 - 120 - 84 - 10 self.fc1 nn.Linear(16 * 5 * 5, 120) self.fc2 nn.Linear(120, 84) self.fc3 nn.Linear(84, num_classes) def forward(self, x): # 输入: (batch, 1, 28, 28) x F.relu(self.conv1(x)) x self.pool1(x) # - (batch, 6, 14, 14) x F.relu(self.conv2(x)) x self.pool2(x) # - (batch, 16, 5, 5) x x.view(x.size(0), -1) # 展平 x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) # 输出10个类别得分 return x为什么第一层卷积加了padding228×28 的输入经过 5×5 卷积核不加 padding输出是 24×24两次卷积加两次池化后特征图会缩到 4×4后面全连接层的输入尺寸就要跟着改。加 padding 保持尺寸不变网络设计更灵活。第一次实现时我偷懒没加 padding结果手算全连接层输入维度时算错了两次直接报维度不匹配的错误。全连接层输入维度的计算方法是第二次池化后特征图大小是 5×5通道数是 16所以展平后的长度是 16×5×5400。如果改了卷积核大小、padding 或者池化步长这里的数字必须重新手算。一个实用技巧是先用一个虚拟输入跑一次model(torch.randn(1, 1, 28, 28))用 PyTorch 的报错信息来验证维度计算是否正确。3.3 前馈神经网络与 CNN 的区别为什么全连接做不好这件事在热词里出现了前馈神经网络恰好可以和 CNN 做一组对比。传统的前馈网络把 28×28 的图像展平成 784 个一维输入然后接若干全连接层这样做的问题在于完全丢失了像素之间的空间关系。数字 7 的横和竖在图像上是相邻的像素展平后可能被拉到了距离很远的特征位置网络很难学到“横竖交叉构成 7”这种局部模式。CNN 通过卷积核的滑动窗口天然保持了空间局部性每个卷积核只关注一个小区域多个卷积核组合起来又能覆盖整张图像。这就是为什么在手写数字识别任务上CNN 用更少的参数量能达到比全连接网络更高的准确率。全连接网络参数量巨大还容易过拟合CNN 的权值共享机制让参数量大幅下降训练更快泛化更强。4. 训练循环与调参学习率、批次大小、损失函数与优化器选择4.1 损失函数和优化器为什么这样配分类任务的标准配置是交叉熵损失加 Adam 优化器。交叉熵损失适合多分类输出层的 10 个得分经过 softmax 变成概率分布再和真实标签计算损失。PyTorch 里的nn.CrossEntropyLoss已经把 softmax 和损失计算合并了所以网络最后一层不需要额外加 softmax直接输出原始得分即可。这一点新手常犯错误在forward里加了F.softmax最后计算损失时发现准确率很低其实是因为梯度传播路径被 softmax 截断了。优化器的选择上Adam 是默认首选因为它自带自适应学习率对新手最友好不用太担心学习率设置不当导致梯度爆炸或消失。但 Adam 也有缺点后期收敛变慢最终准确率可能略逊于精细调参的 SGD。我一般先用 Adam 跑通流程确定网络结构和数据没问题后再换成 SGD 加动量做最终训练争取那一两个百分点的提升。4.2 完整训练循环PyTorch 实现与每个参数的含义import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset # 转换为张量并归一化 X_train_t torch.tensor(train_images, dtypetorch.float32).unsqueeze(1) / 255.0 y_train_t torch.tensor(train_labels, dtypetorch.long) X_test_t torch.tensor(test_images, dtypetorch.float32).unsqueeze(1) / 255.0 y_test_t torch.tensor(test_labels, dtypetorch.long) # 切分验证集前5000张做验证 train_ds TensorDataset(X_train_t[5000:], y_train_t[5000:]) val_ds TensorDataset(X_train_t[:5000], y_train_t[:5000]) test_ds TensorDataset(X_test_t, y_test_t) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size128, shuffleFalse) test_loader DataLoader(test_ds, batch_size128, shuffleFalse) model HandwrittenCNN(num_classes10) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * labels.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, device): model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) return correct / total for epoch in range(10): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device) val_acc evaluate(model, val_loader, device) print(fEpoch {epoch1}: Loss {train_loss:.4f}, fTrain Acc {train_acc:.4f}, Val Acc {val_acc:.4f})unsqueeze(1)的作用是把形状从(60000, 28, 28)变成(60000, 1, 28, 28)因为 Conv2d 要求输入是四维张量第二维是通道数灰度图是单通道所以为 1。归一化除以 255 在前面的代码里已经做了这里在转换成张量时又做了一次注意不要重复归一化否则数据会变成一个非常小的数。batch_size64是常见经验值。太大比如 512 会导致每轮迭代少梯度更新不够频繁训练不稳定太小比如 8 会让梯度更新方向噪声大训练慢且收敛不稳。新手先从 64 或 128 开始跑一个 epoch 观察损失是否下降再决定是否调整。optimizer.zero_grad()是每一轮迭代必须做的因为 PyTorch 的梯度会累积如果不清零上一批数据的梯度会叠加到这一批上。这个坑我见太多人踩过表现就是损失函数忽高忽低训练曲线像过山车一开始还以为是学习率问题排查半天才发现是梯度没清零。4.3 训练过程中的监控指标准确率之外还要看什么很多人只盯着准确率但训练过程中有比准确率更重要的监控指标。第一是损失值的下降曲线如果损失在最初几个迭代内没有明显下降大概率是学习率太大或数据预处理有问题。第二是训练准确率和验证准确率的差值这个差值持续拉大说明在过拟合如果两者都偏低说明模型欠拟合。我的经验是每个 epoch 打印四列数据训练损失、训练准确率、验证准确率、训练耗时。训练损失下降但准确率不动通常意味着模型在收敛但类别判定阈值不对训练准确率高、验证准确率低过拟合的典型表现。下面的表格可以作为判断依据现象可能原因排查方向损失不降学习率过大/过小调整 lr检查归一化训练准确率高、验证低过拟合加 dropout、数据增强训练和验证都低网络容量不足增加卷积层通道数验证准确率波动大batch_size 太小加大 batch_size 或降低学习率epoch 数量也不是越多越好。从头训练 10 个 epoch 足够 MNIST 达到 99% 左右继续训练收益甚微甚至出现验证准确率下降。更优雅的做法是配合早停机制在验证准确率连续几个 epoch 不提升时自动停止训练节省时间也避免过拟合。5. 避坑与常见问题排查手写数字识别 CNN 的 5 个典型翻车现场5.1 维度不匹配全连接层输入尺寸算错现象运行训练代码时报错size mismatch或mat1 and mat2 shapes cannot be multiplied提示的维度数和预期不符。原因修改了卷积层参数后没有同步修改全连接层的输入维度。比如卷积核从 5×5 改成 3×3特征图尺寸变化展平后的长度自然不同。解决在模型定义里加一行打印语句用随机输入跑一次前向传播观察每一层输出的形状。或者直接在代码里写一个维度计算辅助函数。我一般会先跑model(torch.randn(1, 1, 28, 28))验证无误后再开始训练。5.2 PyTorch 图像数据格式错误把通道维放在最后现象训练时报错RuntimeError: Expected 4-dimensional input for 4-dimensional weight。原因输入数据形状是(batch, height, width, channels)而 PyTorch 要求(batch, channels, height, width)。很多人从 OpenCV 读图得到 HWC 格式直接喂给模型就报错。解决用tensor.permute(0, 3, 1, 2)做维度变换或者在数据加载时就统一成 CHW 格式。我习惯在数据集类的__getitem__里直接返回 CHW 格式这样下游所有处理都不用再担心维度顺序问题。5.3 模型在测试集上准确率高但实际手写图片怎么都识别不对现象MNIST 测试集准确率 99%把自己的手写图片输入模型识别结果随机且错误率极高。原因训练数据全部来自统一采集的 MNIST 数据集分布比较干净背景是黑色、数字居中且大小统一。真实手机拍的手写图片背景可能是白纸、阴影数字可能偏小或偏左分布完全不同模型没见过这类数据自然表现差。解决对训练数据做数据增强模拟真实场景的平移、旋转、缩放、背景噪声变化。如果增强后仍不够需要考虑用更大、更多样的数据集。另外真实图片要先做预处理灰度化、二值化、找到数字的边界框并裁剪缩放到 28×28才能适配 MNIST 训练出来的模型。这一步在很多项目里被称作“数据对齐”重要性丝毫不比模型结构低。5.4 训练损失下降但准确率原地踏步现象损失从 2.3 降到 1.2但训练准确率一直徘徊在 80% 左右上不去。原因一种可能是学习率设置过大模型在损失平面上震荡无法落入最优点另一种可能是网络结构容量不够特征提取能力有限。解决先把学习率降到原来的十分之一再观察几个 epoch。如果没变化再检查网络层数或通道数。我调试时习惯用学习率查找法跑一个小批量数据每步增大学习率看损失值在哪个区间下降最快然后选那个量级作为初始学习率。5.5 使用了 GPU 但训练速度没提升现象代码里设置了cuda也输出了训练日志但训练时间几乎没变。原因最常见的是数据加载和 GPU 计算速度不匹配数据加载成了瓶颈。DataLoader的num_workers0意味着只有主进程加载数据GPU 大部分时间在空等。解决设置num_workers2或更高打开pin_memoryTrue减少数据从内存到显存的拷贝时间。还要确认模型和输入数据都在 GPU 上如果模型在 CPU 上、数据在 GPU 上或者反过来都会触发隐式拷贝。检查方法很简单打印model.parameters()里任意一个参数的device属性。6. 进阶用法把模型导出成 ONNX 并在本地跑通一张手写图片的完整推理6.1 训练结束后先做测试集全量评估训练完成不等于项目完成第一步是用从未参与训练的测试集做一次全量评估这一步得出的是模型真实泛化能力的最终结论。同时生成混淆矩阵看哪两个数字最容易被混淆通常 4 和 9、3 和 8 是重灾区因为它们的笔画结构确实相似。from sklearn.metrics import confusion_matrix, classification_report all_preds, all_labels [], [] model.eval() with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm confusion_matrix(all_labels, all_preds) print(cm) print(classification_report(all_labels, all_preds))classification_report会输出每一类的精确率、召回率、F1 分数。如果某个数字的召回率偏低说明模型倾向把别的数字识别成它。处理这类问题可以增加该数字的训练样本或者调整类别权重。对于手写识别类项目我通常会保留下这个评估脚本模型迭代一次就跑一遍防止改动让原有能力退化。6.2 导出 ONNX模型部署的后悔药PyTorch 模型直接保存成.pth文件只能在 PyTorch 环境里加载如果要在 C、Java 或其他平台部署最通用的方案是导出 ONNX 格式。ONNX 是一个开放的模型交换格式几乎主流推理引擎都支持转换后模型结构和权重被统一封装部署端不需要安装 PyTorch。dummy_input torch.randn(1, 1, 28, 28, devicedevice) model.eval() torch.onnx.export( model, dummy_input, handwritten_mnist.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}}, opset_version11 )dynamic_axes参数指定哪个维度是可变的这里把批处理维度设为动态这样部署时既可以批量推理 32 张图也可以只推理 1 张图不需要重新导出。新手如果不加这个参数导出的模型默认只支持固定批量大小部署时换一个 batch 就会报错。ONNX 导出后可以用onnxruntime做验证确保导出过程没有破坏精度。加载 ONNX 模型跑一遍测试集的前 100 张图对比 PyTorch 原始模型的输出准确率差异应该在 0.5% 以内。这一步是对模型部署流程的完整闭环验证血泪经验告诉我不验证直接部署线上环境出问题的代价远高于这几分钟检查时间。6.3 一张真实手写图片的完整推理流程对齐预处理比模型更重要模型导出了最后一步是用一张不在 MNIST 里的手写图片做端到端测试。手机拍一张白纸上的数字按下面的流程预处理后喂给模型import cv2 import onnxruntime as ort def preprocess_single_image(img_path): 从任意路径读取手写图片并转换为模型输入格式 img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 二值化去除纸张背景和阴影 _, img cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 找到数字轮廓裁剪并保持宽高比 contours, _ cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(contours) 0: raise ValueError(未找到数字区域) x, y, w, h cv2.boundingRect(contours[0]) digit img[y:yh, x:xw] # 缩放到 20x20再居中放入 28x28 画布模拟 MNIST 的数字分布 scale 20.0 / max(w, h) resized cv2.resize(digit, (int(w * scale), int(h * scale))) canvas np.zeros((28, 28), dtypenp.uint8) offset_x (28 - resized.shape[1]) // 2 offset_y (28 - resized.shape[0]) // 2 canvas[offset_y:offset_y resized.shape[0], offset_x:offset_x resized.shape[1]] resized # 归一化到 [0, 1] 并添加 channel 和 batch 维 tensor canvas.astype(np.float32) / 255.0 return tensor[np.newaxis, np.newaxis, :, :] ort_session ort.InferenceSession(handwritten_mnist.onnx) image_tensor preprocess_single_image(my_digit.jpg) outputs ort_session.run(None, {input: image_tensor}) pred int(np.argmax(outputs[0][0])) print(f识别结果: {pred}, 置信度: {float(np.max(outputs[0][0])):.4f})这里的预处理逻辑是模仿 MNIST 的数据分布数字主体被缩放到 20×20再居中放到 28×28 画布的黑底白字上。只有对齐了训练时的数据分布模型才能发挥出测试集上的表现。Otsu 二值化是关键的一步它自动计算阈值把灰度图变成黑白两色避免因为光照不均匀导致数字笔画断裂。做完这一步整个项目才算真正闭环。从手写 IDX 解析脚本到 PyTorch 网络定义到训练调试再到 ONNX 导出和单张图片推理每一环都有完整的代码和明确的验证方式。我自己的习惯是把这份代码整理成模板后续做名片识别、票据数字识别时直接复用前面的数据对齐和推理流程只需要替换训练数据和调整网络输出类别数。手写数字识别这个老任务真正学会之后会发现它更像一把钥匙——把 CNN 的底层逻辑和数据工程的完整思路都打开了一遍。希望这些操作细节能帮你的项目少走弯路。本文还有配套的精品资源点击获取