简介:这是一份面向Python初学者与深度学习入门者的手写数字识别实战资源,围绕卷积神经网络(CNN)识别手写数字这一经典计算机视觉任务展开,帮助读者理解图像特征提取与分类的完整流程。压缩包共13个文件,约6.49MB,以10张jpg示例图片、1个py主程序、1个hdf5模型权重文件为主,另含少量系统隐藏文件;图片可用于直观查看待识别样本与预测效果,py脚本承载模型构建与训练逻辑,hdf5则保存已训练好的LeNet-5网络参数,便于直接加载推理或继续微调。资源涉及MNIST数据集的加载与预处理、卷积层与池化层的搭建、模型编译训练评估及预测等环节,并留有数据增强、正则化、Dropout、学习率调度等优化思路供深入探索。目前已有289人学习下载,适合希望以最小成本跑通手写数字识别全流程、积累CNN实战经验的读者参考。
1. 手写数字识别 Python 实战:从 MNIST 到能跑通的推理脚本
很多人第一次接触深度学习,都是从 MNIST 手写数字识别开始的。它数据集小、任务清晰、CPU 也能跑,看起来是个完美的入门项目。但真正动手时你会发现,卡住新手的从来不是模型结构,而是环境配置、数据格式、张量维度、训练不收敛这些琐碎问题。这篇笔记就围绕 handwrite_recognition_python 这条线,把从环境搭建、数据加载、模型定义、训练调参到推理部署的完整路径讲清楚,重点放在能复现、能排错、能改参数上。适合刚配好 Python 环境想跑第一个深度学习项目的人,也适合想把手写数字识别做成一个小工具、需要知道边界在哪的开发者。下面所有代码都基于 PyTorch,因为它在调试友好度和社区资料上对新手最友好。
2. 环境与数据:把 MNIST 手写数字识别的地基打稳
2.1 Python 环境配置与 PyTorch 安装的取舍
手写数字识别对算力要求极低,CPU 版本足够跑通全流程,但如果你后续想扩展到更复杂的模型,建议直接装 CUDA 版本。常见做法是用 conda 建独立环境,避免和系统 Python 冲突。下面这套命令在 Windows、Linux、macOS 上都能用,只是 CUDA 版本号需要按显卡驱动调整。
# 创建独立环境,Python 版本选 3.9 或 3.10 兼容性最好 conda create -n mnist python=3.10 -y conda activate mnist # 安装 PyTorch,CPU 版本(最省事,先跑通再说) pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # 如果需要 GPU,换成对应 CUDA 版本的命令,例如 CUDA 11.8 # pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 验证安装 python -c "import torch; print(torch.__version__, torch.cuda.is_available())"这段命令的逻辑是:先隔离环境,再装框架,最后验证。参数上,python=3.10是经验值,3.12 在某些 torchvision 版本上会有兼容问题;--index-url指定官方 wheel 源,比默认源快很多。验证时如果输出True说明 GPU 可用,输出False也不影响 MNIST 训练,只是速度慢一点。如果你用 VSCode 或 PyCharm,记得把解释器切到这个 conda 环境,否则会出现「明明装了却 import 失败」的玄学问题。
2.2 MNIST 数据集的加载与预处理参数
MNIST 包含 60000 张训练图和 10000 张测试图,每张是 28x28 的灰度图。torchvision 已经内置了下载和加载接口,但预处理这一步不能省。下面是最小可用的数据加载代码。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 定义预处理:转张量 + 归一化 transform = transforms.Compose([ transforms.ToTensor(), # 把 PIL 图像转成 [0,1] 的张量 transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值和标准差 ]) # 下载并加载训练集和测试集 train_set = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_set = datasets.MNIST(root='./data', train=False, download=True, transform=transform) # 包装成 DataLoader train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2) test_loader = DataLoader(test_set, batch_size=1000, shuffle=False, num_workers=2) print(len(train_set), len(test_set))逻辑说明:ToTensor把像素值从 0-255 压到 0-1,Normalize再减均值除标准差,让输入分布接近标准正态,这是训练稳定的关键。参数上,batch_size=64是 MNIST 上的常用值,太小会导致训练抖动,太大显存和内存占用上升但收益递减;shuffle=True只在训练集开,测试集必须关掉,否则评估结果没有可比性;num_workers在 Windows 上建议设 0,否则容易报多进程相关的错。归一化用的0.1307和0.3081是 MNIST 训练集的统计值,直接抄即可,自己重算反而容易出错。
提示:第一次运行会自动下载约 10MB 的数据到
./data目录,如果下载卡住,可以手动下载后放到对应目录,文件名保持MNIST/raw结构。
3. 模型定义与训练:一个能收敛的 CNN 长什么样
3.1 卷积网络结构的选择与维度推导
MNIST 用全连接网络也能到 97% 左右,但 CNN 能轻松到 99% 以上,而且参数量更少。下面这个结构是我反复用过、稳定收敛的版本:两个卷积块加两个全连接层。
import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 32, 3, padding=1) # 输入1通道,输出32通道,3x3卷积 self.conv2 = nn.Conv2d(32, 64, 3, padding=1) # 32进64出 self.pool = nn.MaxPool2d(2, 2) # 2x2池化,尺寸减半 self.fc1 = nn.Linear(64 * 7 * 7, 128) # 两次池化后 28->14->7 self.fc2 = nn.Linear(128, 10) # 10个类别 self.drop = nn.Dropout(0.25) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) # [B,32,14,14] x = self.pool(F.relu(self.conv2(x))) # [B,64,7,7] x = x.view(x.size(0), -1) # 展平成 [B, 64*7*7] x = F.relu(self.fc1(x)) x = self.drop(x) x = self.fc2(x) return x model = Net() print(sum(p.numel() for p in model.parameters()))逻辑说明:padding=1保证卷积后尺寸不变,池化负责降维,view把特征图拉平送进全连接。参数上,64*7*7这个数字必须和前面的结构严格对应,改卷积层或池化层时这里要同步改,否则会报维度不匹配。Dropout(0.25)放在全连接层之间,是为了抑制过拟合,MNIST 上不用也能跑,但用了泛化更稳。整个模型参数量在 40 万左右,CPU 训练一轮几秒钟。
3.2 训练循环、损失函数与优化器参数
训练循环是新手最容易写错的地方,核心就三件事:梯度清零、反向传播、参数更新。下面是一个带测试评估的完整循环。
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = Net().to(device) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() def train_epoch(epoch): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) optimizer.zero_grad() # 清空上一轮梯度 output = model(data) # 前向 loss = criterion(output, target) # 计算损失 loss.backward() # 反向传播 optimizer.step() # 更新参数 if batch_idx % 200 == 0: print(f'Epoch {epoch} [{batch_idx*len(data)}/{len(train_set)}] loss={loss.item():.4f}') def test(): model.eval() correct, total_loss = 0, 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) total_loss += criterion(output, target).item() pred = output.argmax(dim=1) correct += pred.eq(target).sum().item() acc = 100. * correct / len(test_set) print(f'Test loss={total_loss/len(test_loader):.4f} acc={acc:.2f}%') return acc for epoch in range(1, 6): train_epoch(epoch) test()逻辑说明:model.train()和model.eval()会切换 Dropout 和 BatchNorm 的行为,漏写会导致评估结果偏低。zero_grad必须在backward之前,否则梯度会累加。参数上,Adam的lr=1e-3是默认推荐值,MNIST 上 5 轮就能到 99% 左右;如果换成 SGD,学习率要调到 0.01 并加动量。argmax(dim=1)取每行最大值的索引作为预测类别,eq做逐元素比较后求和得到正确数。测试时用torch.no_grad()关闭梯度计算,省显存也更快。
注意:如果 loss 一直不降,先检查数据是否归一化、标签是否对得上、学习率是否过大。这三项能解决八成不收敛问题。
4. 推理与部署:把训练好的模型变成能用的工具
4.1 模型保存、加载与单张图片推理
训练完不保存等于白跑。PyTorch 推荐保存state_dict而不是整个模型,这样加载时更灵活。
# 保存 torch.save(model.state_dict(), 'mnist_cnn.pth') # 加载 model = Net().to(device) model.load_state_dict(torch.load('mnist_cnn.pth', map_location=device)) model.eval() # 单张图片推理 from PIL import Image def predict(image_path): img = Image.open(image_path).convert('L') # 转灰度 img = img.resize((28, 28)) # 统一尺寸 img = transform(img).unsqueeze(0).to(device) # 加 batch 维度 with torch.no_grad(): output = model(img) prob = torch.softmax(output, dim=1) pred = output.argmax(dim=1).item() return pred, prob[0][pred].item() print(predict('test_digit.png'))逻辑说明:convert('L')保证输入是单通道,resize保证尺寸一致,unsqueeze(0)补上 batch 维度,因为模型期望输入是[B,1,28,28]。softmax把输出转成概率,方便看置信度。参数上,map_location在 CPU 加载 GPU 训练的模型时必填,否则会报设备不匹配。实际使用中,手写图片往往有背景噪声,建议先做二值化和居中裁剪,否则准确率会明显下降。
4.2 用 Gradio 快速搭一个手写识别界面
如果想让别人也能用,Gradio 是最省事的方案,几行代码就能出一个网页界面。
import gradio as gr import numpy as np def recognize(sketch): if sketch is None: return "请先写一个数字" img = Image.fromarray(sketch).convert('L').resize((28, 28)) img = transform(img).unsqueeze(0).to(device) with torch.no_grad(): output = model(img) pred = output.argmax(dim=1).item() prob = torch.softmax(output, dim=1)[0][pred].item() return f"预测结果:{pred},置信度:{prob:.2%}" demo = gr.Interface(fn=recognize, inputs=gr.Sketchpad(), outputs="text", title="手写数字识别") demo.launch()逻辑说明:Sketchpad返回的是 numpy 数组,需要转成 PIL 图像再走和训练一致的预处理。参数上,resize((28,28))必须和训练时一致,否则分布对不上。这个界面适合演示和自测,但不适合高并发场景,生产环境还是建议封装成 API 服务。
5. 避坑与排查:手写数字识别最常见的 5 个翻车现场
5.1 现象:训练 loss 正常下降,但测试准确率始终在 10% 左右
原因通常是标签和输出维度对不上,或者评估时忘了model.eval()导致 Dropout 仍在随机丢弃。解决方法是先打印一个 batch 的target看取值范围,确认是 0-9;再检查评估函数里有没有model.eval()和torch.no_grad()。如果用的是自己写的 Dataset,重点检查__getitem__返回的标签是不是从 0 开始。
5.2 现象:报错RuntimeError: Given groups=1, weight of size [32,1,3,3], expected input[64,3,28,28] to have 1 channels
这是输入通道数不匹配。MNIST 是灰度图,通道数为 1,但如果你用Image.open没加convert('L'),或者用了彩色图,就会变成 3 通道。解决办法是在预处理里强制转灰度,或者把模型第一层改成nn.Conv2d(3, 32, 3, padding=1)。更稳妥的做法是在 Dataset 里就统一转灰度。
5.3 现象:训练速度极慢,CPU 占用很高但 GPU 利用率接近 0
常见原因是num_workers设得太大导致进程调度开销,或者数据没放到 GPU 上。先检查data.to(device)和target.to(device)是否都写了,再尝试把num_workers降到 0 或 2。Windows 上多进程 DataLoader 容易出问题,直接设 0 最稳。另外,如果 batch_size 太小,GPU 也跑不满,可以适当加大到 128 或 256。
5.4 现象:自己手写的数字识别总是错,但测试集准确率很高
这是典型的分布偏移。MNIST 的图片是居中、二值化、黑底白字的,而你用画板写的数字往往有抗锯齿、灰边、位置偏移。解决办法是在推理前做和训练一致的预处理:转灰度、二值化、裁剪到数字边界、缩放到 28x28、再归一化。如果还不行,可以用 MNIST 的训练集做数据增强,加入旋转、平移、缩放,让模型更鲁棒。
5.5 现象:保存模型后再加载,预测结果全乱
多半是保存和加载的结构不一致。torch.save(model.state_dict())只存参数,加载时必须先实例化同样的网络结构,再load_state_dict。如果改了网络层名或层数,就会报 key 不匹配。解决方法是把模型定义单独放在一个文件里,训练和推理都 import 同一个类。另外,map_location在跨设备加载时一定要写。
6. 进阶技巧:把 MNIST 准确率推到 99.5% 以上的几个实操手段
想让模型从 99% 再往上走,靠的不是换更深的网络,而是把训练细节做扎实。第一个手段是学习率调度,用StepLR或CosineAnnealingLR在训练后期降低学习率,让模型收敛到更平坦的极小值。第二个手段是数据增强,RandomRotation(10)和RandomAffine(0, translate=(0.1,0.1))能显著提升对手写风格变化的鲁棒性。第三个手段是集成,训练 3 到 5 个不同初始化的模型,推理时对 softmax 输出取平均,准确率通常能再涨 0.2 到 0.3 个百分点。
from torch.optim.lr_scheduler import StepLR from torchvision import transforms # 带增强的预处理 train_transform = transforms.Compose([ transforms.RandomRotation(10), transforms.RandomAffine(0, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # 学习率调度 scheduler = StepLR(optimizer, step_size=2, gamma=0.5) for epoch in range(1, 11): train_epoch(epoch) test() scheduler.step() # 每 2 轮学习率乘 0.5参数上,step_size=2表示每两轮调一次,gamma=0.5表示学习率减半,这两个值在 MNIST 上比较温和,不会导致训练崩溃。数据增强只加在训练集,测试集保持原样,否则评估结果不可比。集成的时候注意每个模型要用不同的随机种子,否则多样性不够,提升有限。
还有一个容易被忽略的点是权重初始化。默认初始化在 MNIST 上够用,但如果你把网络加深,用 Kaiming 初始化能让训练更稳。另外,把输入像素从 0-1 再减均值除标准差这一步,很多人图省事跳过,结果就是收敛慢、准确率低一截。我自己的习惯是:任何图像任务,先把归一化做对,再谈模型结构。这个项目跑通之后,建议你试着把同样的流程套到 FashionMNIST 或 EMNIST 上,看看哪些参数需要改、哪些坑会重复出现。希望帮到你。
本文还有配套的精品资源,点击获取