☰
PyTorch CNN手写数字识别:从零搭建到调参部署实战
2026/9/28 3:19:24 网站建设 项目流程

简介:这是一份基于Python与卷积神经网络(CNN)的手写数字识别资源包,面向希望入门计算机视觉或深度学习的中级开发者,解决从原始像素数据到高精度模型训练的完整流程问题。包内以CSV数据文件搭配Python脚本的形式呈现,共4个文件,约13.26MB,包含训练集、测试集、预测结果以及一个可直接运行的CNN脚本,便于读者快速复现并理解每个环节。已有472人学习下载。脚本覆盖数据预处理、CNN网络构建、反向传播训练、评估与预测等关键步骤;训练与测试数据采用MNIST风格的手写数字灰度图像,通过卷积层、池化层与全连接层实现特征提取与分类,最终模型准确率可达约99%。预测结果文件还可用于对比实际标签,辅助分析模型泛化能力。对于想掌握图像分类基础、熟悉Keras或TensorFlow建模流程的读者,这份资源提供了清晰的代码骨架与实验数据,是实践手写数字识别任务的便捷起点。

1. 手写识别入门为什么绕不开 CNN:从一张 28×28 灰度图说起

把一张手写数字图片丢给 Python,让程序告诉我它是 0 到 9 里的哪一个,这是大多数人第一次真正跑通卷积神经网络时都会选的题目。原因很简单:MNIST 手写数字识别把问题收敛到了 28×28 单通道灰度图,尺寸小、类别固定、标注干净,一套朴素的 CNN 神经网络代码就能把准确率推到 99% 以上。问题是网上能搜到的 python 代码很多,能在一个下午内跑通、能讲清楚每个参数为什么这么设、能在翻车之后知道去哪排查的并不多。下面这套方案是我反复重做过的最短路径:用 PyTorch 写 CNN,覆盖数据加载、模型定义、训练、调参、避坑和最终用自己手写的数字做验证。适合正在入门深度学习、想给团队拉一个可复现 baseline 的开发者,也适合面试前需要把手写识别这条链路完整过一遍的人。

2. 用 Python 写一套最小可跑的 CNN 手写数字识别:数据、模型与训练循环

2.1 选型理由:为什么用 PyTorch 而不是 Keras 或纯 NumPy 手写

我最早学手写数字识别时,先尝试过用纯 Python 加 NumPy 手写两层卷积。前向传播还好说,反向传播要自己推导每一层的梯度,再手工实现 im2col、pooling 的反向过程,一个 28×28 的输入能调出四五十分钟的 bug,而且 debug 时根本分不清是数学公式错还是 reshape 错。

后来换到 PyTorch,核心逻辑只剩一行:在训练循环里调用loss.backward(),梯度全部交给自动微分。比起 Keras,PyTorch 更接近“看得见中间过程”的风格。你可以在 forward 里随时 print 某一层的张量形状,可以在 loss 计算前手动修改特征图,这在调试手写识别这种输入输出都很直观的任务时非常友好。Keras 写起来更短,但遇到自定义损失、想往中间层插一个可视化钩子时,API 藏细节的问题就暴露出来了。

我的结论:除非团队里已经有成熟的 TensorFlow 推理链路,否则新项目用手写识别练手、做原型、做 baseline,优先选 PyTorch。这套代码后续要接 ONNX 导出、转成端侧模型,PyTorch 的生态工具也是现成的。

2.2 数据加载:torchvision 的 MNIST 与三个默认参数

MNIST 数据集本身不需要自己找。torchvision 已经包好了下载、解析和缓存逻辑,第一次运行时自动下载到./data目录。关键在预处理管线。

import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), # [0,255] -> [0,1],并转为 [N,1,28,28] transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值/标准差,做标准化 ]) train_data = datasets.MNIST(root="./data", train=True, download=True, transform=transform) test_data = datasets.MNIST(root="./data", train=False, download=True, transform=transform) train_loader = DataLoader(train_data, batch_size=64, shuffle=True, num_workers=0) test_loader = DataLoader(test_data, batch_size=256, shuffle=False, num_workers=0)

ToTensor()做的不只是转张量:它把 PIL 图像从 0 到 255 的整数像素值缩放到 0 到 1 的浮点数,同时把通道维放到最前面。MNIST 是单通道灰度图,所以最终每个样本形状是[1, 28, 28]。Normalize((0.1307,), (0.3081,))是 MNIST 全体像素的统计值,直接用官方统计结果而不是自己算,能省一个到处是坑的步骤。

三个参数按经验取值:batch_size=64对 CNN 起步够稳,太小梯度抖动大,太大训练变慢且内存压力大;shuffle=True只在训练集开,测试集不需要,否则评估结果会受乱序影响;num_workers=0在 Windows 上最保险,Linux 上可以调到 4,后面避坑章节细说。

2.3 模型结构:两层卷积加全连接,每层形状怎么算

CNN 基本结构就是“卷积提取局部特征、池化降采样、全连接分类”三段式。手写数字识别不需要很深,两层卷积已经能到 99% 附近。下面这个定义是这套方案的核心。

import torch.nn as nn class DigitCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), # [1,28,28] -> [32,28,28] nn.ReLU(inplace=True), nn.MaxPool2d(2), # [32,28,28] -> [32,14,14] nn.Conv2d(32, 64, kernel_size=3, padding=1), # [32,14,14] -> [64,14,14] nn.ReLU(inplace=True), nn.MaxPool2d(2) # [64,14,14] -> [64,7,7] ) self.classifier = nn.Sequential( nn.Flatten(), # [64,7,7] -> [64*7*7] nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplace=True), nn.Dropout(0.25), # 训练时随机丢弃 25%,抑制过拟合 nn.Linear(128, 10) # 10 个数字类别,输出 logits ) def forward(self, x): return self.classifier(self.features(x))

kernel_size=3加padding=1的组合,是卷积层最常用的配置:卷积后特征图尺寸不变,通道数从 1 变 32。MaxPool2d(2)把长宽各减半,28 变 14、14 变 7。最后展平时,特征图是64×7×7,所以第一个全连接层的输入维度固定写64 * 7 * 7,不要写成 4096 这种“拍脑袋”的数字,后面如果你调整了卷积层深度,这里的维度必须跟着重算。

整个模型参数量大概 42 万,CPU 上训练一个 epoch 在 1 分钟左右,GPU 上几秒。对一个入门方案来说,这个体积的性价比正合适:参数量够表达数字的笔画结构,又不至于大到需要分布式训练。

2.4 训练循环:损失函数、优化器、epoch 与 batch_size 的取值逻辑

手写数字识别本质是 10 类分类,损失函数用CrossEntropyLoss,优化器先上Adam,这是最不需要动脑子的起步组合。

import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = DigitCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) for epoch in range(5): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in train_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() * images.size(0) correct += (outputs.argmax(dim=1) == labels).sum().item() total += labels.size(0) print(f"Epoch {epoch+1}: loss={total_loss/total:.4f}, acc={correct/total:.4f}")

CrossEntropyLoss在 PyTorch 里已经内置了 Softmax,所以模型最后一层输出的是裸 logits,不要在最后一层再接LogSoftmax,否则训练初期 loss 计算会出现数值别扭、收敛变慢的情况。outputs.argmax(dim=1)拿预测类别,和标签逐元素比较,累加正确数。

batch_size=64配合Adam(lr=1e-3)是我在 MNIST 上最稳的起步组合。学习率低于 1e-4 收敛慢到让人焦虑,高于 1e-2 会在前几个 epoch 出现训练集准确率来回横跳。epoch 先设 5,跑完看趋势再决定要不要加到 10。

2.5 基线结果:第一次训练就该看到的数值

这套代码第一次跑通时,epoch 1 训练集准确率应当在 95% 以上,epoch 3 到 98%,epoch 5 稳定在 98.5% 到 99% 之间。如果 epoch 1 只有 80% 甚至更低,优先检查shuffle有没有开、归一化参数有没有写反。如果是 0.9 以下但 loss 在正常下降,多半是模型结构里某个卷积的 padding 算错,导致特征图尺寸对不上。

提示:先跑通拿到这个基线,再往下调参。没有基线就调参等于在噪声里找信号,后面所有结论都不可信。

3. 从零跑通:环境安装、脚本组织与训练日志解读

3.1 环境准备:Python 版本、虚拟环境与 torch 安装命令

手写识别代码对 Python 版本不挑,3.9 到 3.12 都能跑。唯一要求是别用系统自带的 Python 裸装包,长期会出依赖冲突。先建虚拟环境再装 PyTorch,这是新人最值得养成的一个习惯,能省掉后面“环境坏了”的很多血泪时间。

python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate python -m pip install --upgrade pip pip install torch torchvision

如果机器没有 NVIDIA 显卡,或者只想先在 CPU 上跑通,不需要安装完整的 CUDA 版 PyTorch。PyTorch 官方提供 CPU 专用 wheel,安装体积小很多,命令是在 pip install 时加一个指向官方 CPU 版本源的参数。这一点常被忽略:很多人直接pip install torch装上了几百 MB 的 CUDA 依赖,训练时却提示 CUDA 不可用,白白浪费时间。

3.2 一份可以直接跑的 train.py:目录与文件职责

我建议把代码拆成三个文件,而不是在一个文件里塞到底。手写识别代码量不大,但拆开之后,训练、定义、推理各管各的,后面调参和做验证会舒服很多。

mnist_cnn/ ├── model.py # DigitCNN 模型定义 ├── train.py # 数据加载 + 训练循环 + 保存权重 ├── predict.py # 加载权重 + 对单张图片推理 ├── requirements.txt └── data/ # 存放 MNIST,第一次运行时自动生成

train.py的主体逻辑就是第 2 章的代码,最后加一行保存权重:

torch.save(model.state_dict(), "mnist_cnn.pt")

注意只存state_dict(),不要整个torch.save(model)。前者只保存参数,文件小、跨版本兼容性好;后者把整个模型对象一起序列化,PyTorch 升级后经常加载报错。

requirements.txt写清楚核心依赖:

torch torchvision pillow numpy

不用锁死版本号,但建议在pip freeze里看一眼实际版本,如果项目要给团队用,再加一行torch==2.x.x这种精确约束。我见过太多“刚才还能跑”的案例,最后查出来是某次重装 PyTorch 升级了大版本,torchvision.transforms的接口行为变了。

3.3 训练日志怎么看:loss 和 acc 先快后慢的曲线含义

跑完 5 个 epoch,你会看到 loss 从 0.2 量级快速降到 0.05 以下,准确率从 95% 爬向 99%。这个“前 3 个 epoch 猛涨、后面变平”的曲线是正常的,不是模型坏了。CNN 在前几个 epoch 学到的多是笔画边缘、角点这类低级特征,对 MNIST 这种干净数据集已经足够区分大部分数字;后面的提升主要来自那些容易混淆的样本,比如手写 4 和 9、3 和 8。

判断训练是否健康,看两件事:第一,训练集 loss 是否持续下降,如果出现先降后升的 U 形曲线,十有八九是学习率太大;第二,训练集准确率和测试集准确率的差距是否越拉越大,正常差距在 1% 到 2%,超过 5% 就要警惕过拟合。

3.4 复现的底线:随机种子与依赖固定

深度学习训练涉及数据加载随机顺序、模型参数初始化随机数、Dropout 随机丢弃三处随机性。同一个脚本跑两次,准确率差 0.2% 到 0.5% 都很正常,但如果要做对比实验、要让同事复现你的结果,就必须固定随机种子。

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) set_seed(42)

把set_seed(42)放在train.py最前面。它不能保证两次训练结果一字不差,但能把误差控制在极小范围,足以支撑调参结论。

注意:CuDNN 的算法选择本身也有随机性。想要更严格的复现,可以在模型训练前加一行torch.backends.cudnn.benchmark = False,代价是训练会慢一点。

4. 调参:把准确率从 97% 抬到 99% 的 CNN 四个关键旋钮

4.1 卷积核大小与第一层通道数:改变感受野和参数量的起点

新手常把kernel_size=3当成默认值照抄。其实卷积核大小直接决定感受野:3×3 看一个点周围 8 个邻居,5×5 看更大范围但参数量变成原来的 2.7 倍左右。MNIST 数字笔画粗细通常就 2 到 3 个像素,3×3 足够捕捉;改用 5×5 在 MNIST 上准确率提升很小,参数量却明显增加,训练时间变长,收益不成正比。

第一层通道数同理。32 个通道起步是性价比拐点,升到 64 通道准确率大约能提 0.1% 到 0.2%,但参数和显存接近翻倍。通道数从 16 起步则会让模型欠拟合,epoch 5 准确率卡在 97% 左右上不去。对 MNIST 这类单通道输入,32 是最合适的起点。

self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(32, 32, kernel_size=3, padding=1), # 加一层不加宽 nn.ReLU(inplace=True), nn.MaxPool2d(2), ... )

想增深模型时,优先“加一层卷积但不扩通道”,比直接扩通道更稳。

4.2 池化与激活:CNN 基本结构里被低估的降采样

网络结构里最常见的 MaxPool 自己并不训练参数,但它影响的是“对笔画偏移的容忍度”。最大池化取窗口里响应最强的点,对平移变化更鲁棒;平均池化把区域里所有激活都平均掉,对噪声更鲁棒。MNIST 手写数字笔画清晰、背景干净,最大池化明显优于平均池化,所以我一直用 MaxPool2d(2)。如果你换到一个背景有噪声的数据集,平均池化反而可能表现更好。

激活函数选 ReLU 而不是 sigmoid,原因在于手写识别网络一旦加深,sigmoid 在反向传播时容易把梯度压到接近 0,浅层卷积学不到东西。ReLU 的导数在正区间恒为 1,梯度传导更顺畅。inplace=True是为了省内存,13 行小模型无所谓,大模型建议都加上。

4.3 优化器、学习率与 batch_size:曲线不动的第一排查对象

模型结构没变,准确率却在 97% 附近卡住,我一般先动学习率而不是模型结构。Adam 默认 lr=1e-3,在 MNIST 上前 3 个 epoch 效果好,但后期收敛变慢。常见做法是改成小学习率微调,或者切换到带动量的 SGD。

optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1)

SGD 加动量在 MNIST 上到了后期往往比 Adam 更稳,准确率能再挤 0.1% 到 0.2%。如果不想换优化器,那就保留 Adam 并把 lr 降到 3e-4,同时把 epoch 从 5 加到 10。batch_size从 64 调到 128,训练速度明显变快,准确率几乎不降;但调到 256 以上,梯度更平滑、泛化略差,在 MNIST 上能观察到准确率轻微下降。

4.4 数据增强与归一化:MNIST 上最有效且最容易被忘掉的一招

MNIST 训练集有 6 万张图,对一个 42 万参数的 CNN 来说偏少,过拟合风险是真实存在的。数据增强是最直接的解法。我常用的组合是轻微旋转和位移,因为手写数字的自然变化本就集中在角度和位置。

transform_train = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1), scale=(0.9, 1.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])

候选配置对比:

配置测试集准确率(约)说明
无增强 + 5 epoch98.8%最省事,够用
旋转 10° + 位移 10%99.2%防线最好,推荐
再加缩放 0.9~1.199.1%提升不明显
裁剪到 24×2499.0%相当于裁剪增强,可用
翻转不建议数字左右翻转会改变语义,7 变 1、6 变 9 的误导比增强收益大

增强要克制。degrees=10已经足够让 97% 的卡点松动,转太多反而会让 6、9 这类数字更难区分。另一个容易踩的坑是:训练时用了增强,测试和推理时必须用相同的手写识别预处理方式,否则你验证时看到的准确率会低于训练的预期。

5. 避坑:手写数字识别 CNN 训练中的 5 个翻车现场

5.1 准确率长期卡在 10% 附近:输出层或标签出了问题

现象:训练 5 个 epoch,训练集准确率一直徘徊在 10% 上下,和随机猜差不多。

原因:大概率不是模型参数没调好,而是代码逻辑把标签搞错了,或者模型最后一层输出维度不对。10% 这个数字本身就是强烈的信号——10 个类别随机猜就是 10%。

解决:从三个位置查。第一,datasets.MNIST里的train=True/False是不是写反了;第二,outputs.argmax(dim=1)是不是误写成了dim=0,那会沿着 batch 维取最大值,结果完全错位;第三,最后一个Linear(128, 10)是不是被改成了Linear(128, 1),这种错误不会报错,只会让准确率永远上不去。

5.2 训练集 99%、测试集 90%:过拟合的信号

现象:训练集准确率漂亮,验证集准确率上不去,差距越拉越大。

原因:模型容量对 6 万张训练图来说偏大了,或者 Dropout 开太小、数据增强没用。手写识别模型到后期记住的是训练集里某些特异笔画,而不是泛化的数字结构。

解决:先把Dropout从 0.25 提到 0.5,再看测试集差距有没有缩小;同时把第 4.4 节的RandomAffine加上。这样组合之后,MNIST 训练集和测试集差距通常会压回 1% 之内。如果你的数据不是 MNIST,而是自己收集的手写样本,过拟合概率更高,数据增强的力度还要加大。

5.3 4 和 9、7 和 1 总混淆:数据增强与模型容量的权衡

现象:验证集错误预测集中在 4↔9、7↔1、3↔8 几对数字。

原因:这些数字对在 MNIST 里本就存在大量写法接近的样本。4 上面不封口、9 的圈写得扁,肉眼都容易看错。模型在这种样本上产生混淆是正常的,不用慌。

解决:第一步加训练增强,重点加位移和细微旋转,让模型对不同写法更鲁棒;第二步把卷积层输出通道从 32/64 加到 48/96,给模型更多表达空间;第三步是看混淆矩阵,确认错误不是集中在某一种采样偏好上。如果部署场景是特定手写风格,更直接的做法是收集一批目标场景的手写图片做微调。

from sklearn.metrics import confusion_matrix cm = confusion_matrix(all_labels, all_preds, labels=range(10)) print(cm)

把测试集所有预测结果和真实标签保存下来,跑一次混淆矩阵,比凭感觉猜错误类型有效得多。

5.4 训练时 loss 正常,推理时全错:预处理不一致

现象:训练阶段 loss 正常下降,eval 时准确率也正常;一旦把训练代码里的图片换成自己写的数字图片,预测结果错得离谱。

原因:训练时用的预处理是ToTensor()加Normalize,PIL 读进来的手写图片是 0 到 255 的像素值,没有做同样的缩放和标准化;或者图片是彩色、背景是白底黑字,和 MNIST 的黑底白字反过来了。

解决:推理脚本里必须原样复用训练时的 transforms 管线,并保证图像是 28×28、单通道、黑底白字。如果拿到的图片是白底黑字,先要做像素反转:255 - img,再送进模型。这类问题最难排查,因为不会报错,只会输出一个自信的错误答案。

5.5 DataLoader 在 Windows 上崩溃或显存突然爆掉

现象:训练脚本在 Linux 上跑得好好的,换到 Windows 就报DataLoader worker exit unexpected;或者训练到中间某个 epoch 显存溢出。

原因:Windows 下num_workers>0时,DataLoader 多进程需要if __name__ == "__main__"保护主模块,否则 multiprocessing 会重新执行整个脚本,触发递归创建进程。显存溢出则多是因为输入分辨率或 batch_size 设计脱离了显存容量。

解决:Windows 上把num_workers=0,先保证能跑;显存溢出优先把batch_size从 128 降到 64 或 32,其次检查是不是图片尺寸不是 28×28 导致特征图变大、占用暴涨。别一开始就怀疑模型结构,大多数显存问题都是 batch 和分辨率带来的。

6. 用自己手写的数字做验证:一个可复用的推理脚本

6.1 推理脚本:加载权重、预处理、输出置信度

训练结束后真正要面对的问题是:模型认识我写的数字吗?我用下面的脚本验证。

from PIL import Image import torch import torchvision.transforms as transforms def preprocess(image_path): img = Image.open(image_path).convert("L") # 转单通道灰度 img = img.resize((28, 28), Image.Resampling.BILINEAR) # 统一尺寸 img = transforms.ToTensor()(img) img = transforms.Normalize((0.1307,), (0.3081,))(img) return img.unsqueeze(0) # 加 batch 维 def predict(model_path, image_path): model = DigitCNN() model.load_state_dict(torch.load(model_path, map_location="cpu")) model.eval() with torch.no_grad(): logits = model(preprocess(image_path)) pred = logits.argmax(dim=1).item() prob = torch.softmax(logits, dim=1)[0][pred].item() return pred, prob pred, prob = predict("mnist_cnn.pt", "my_digit.png") print(f"预测: {pred}, 置信度: {prob:.2f}")

注意model.eval()这一步不能省。它关闭 Dropout 的随机丢弃,否则同一张图两次预测结果会不同。推理时用torch.no_grad()包住前向过程,省内存、提速。置信度prob是 softmax 之后的结果,经验上低于 0.6 的预测基本不可信,这时候与其信模型,不如检查一下输入图的预处理方式。

6.2 导出到 ONNX 与部署前的最后一课

如果要把这套 CNN 代码接到移动端或后端服务,导出 ONNX 是常见做法。

dummy_input = torch.randn(1, 1, 28, 28) torch.onnx.export( model, dummy_input, "mnist_cnn.onnx", input_names=["input"], output_names=["output"], opset_version=13, dynamic_axes={"input": {0: "batch"}} )

导出后,端侧推理时最容易犯的错是只移植了模型参数、没移植预处理逻辑。灰度转换、归一化的均值方差、通道顺序,这三样只要有一个和训练时不一致,整个模型就废了。

我做这个项目最深的个人教训是:永远把推理脚本和训练代码放在同一个仓库里,用同一套 transforms 函数;不要复制粘贴一段预处理到新文件里,然后因为Image.open默认读出来的是 RGB 而翻车。手写数字识别本身不难,难的是让数据在训练和推理两条链路上始终保持一致。希望你把这套方案跑通之后,再回去看自己之前卡住的那些报错,能一眼看出问题在哪。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询