ARTICLE DETAIL

资讯详情

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

PyTorch CNN手写数字识别:从零搭建到调参部署实战

PyTorch CNN手写数字识别:从零搭建到调参部署实战 简介这是一份基于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()梯度全部交给自动微分。比起 KerasPyTorch 更接近“看得见中间过程”的风格。你可以在 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, trainTrue, downloadTrue, transformtransform) test_data datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_data, batch_size64, shuffleTrue, num_workers0) test_loader DataLoader(test_data, batch_size256, shuffleFalse, num_workers0)ToTensor()做的不只是转张量它把 PIL 图像从 0 到 255 的整数像素值缩放到 0 到 1 的浮点数同时把通道维放到最前面。MNIST 是单通道灰度图所以最终每个样本形状是[1, 28, 28]。Normalize((0.1307,), (0.3081,))是 MNIST 全体像素的统计值直接用官方统计结果而不是自己算能省一个到处是坑的步骤。三个参数按经验取值batch_size64对 CNN 起步够稳太小梯度抖动大太大训练变慢且内存压力大shuffleTrue只在训练集开测试集不需要否则评估结果会受乱序影响num_workers0在 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_size3, padding1), # [1,28,28] - [32,28,28] nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # [32,28,28] - [32,14,14] nn.Conv2d(32, 64, kernel_size3, padding1), # [32,14,14] - [64,14,14] nn.ReLU(inplaceTrue), 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(inplaceTrue), nn.Dropout(0.25), # 训练时随机丢弃 25%抑制过拟合 nn.Linear(128, 10) # 10 个数字类别输出 logits ) def forward(self, x): return self.classifier(self.features(x))kernel_size3加padding1的组合是卷积层最常用的配置卷积后特征图尺寸不变通道数从 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(), lr1e-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(dim1) labels).sum().item() total labels.size(0) print(fEpoch {epoch1}: loss{total_loss/total:.4f}, acc{correct/total:.4f})CrossEntropyLoss在 PyTorch 里已经内置了 Softmax所以模型最后一层输出的是裸 logits不要在最后一层再接LogSoftmax否则训练初期 loss 计算会出现数值别扭、收敛变慢的情况。outputs.argmax(dim1)拿预测类别和标签逐元素比较累加正确数。batch_size64配合Adam(lr1e-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里看一眼实际版本如果项目要给团队用再加一行torch2.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(seed42): 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_size3当成默认值照抄。其实卷积核大小直接决定感受野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_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, kernel_size3, padding1), # 加一层不加宽 nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ... )想增深模型时优先“加一层卷积但不扩通道”比直接扩通道更稳。4.2 池化与激活CNN 基本结构里被低估的降采样网络结构里最常见的 MaxPool 自己并不训练参数但它影响的是“对笔画偏移的容忍度”。最大池化取窗口里响应最强的点对平移变化更鲁棒平均池化把区域里所有激活都平均掉对噪声更鲁棒。MNIST 手写数字笔画清晰、背景干净最大池化明显优于平均池化所以我一直用 MaxPool2d(2)。如果你换到一个背景有噪声的数据集平均池化反而可能表现更好。激活函数选 ReLU 而不是 sigmoid原因在于手写识别网络一旦加深sigmoid 在反向传播时容易把梯度压到接近 0浅层卷积学不到东西。ReLU 的导数在正区间恒为 1梯度传导更顺畅。inplaceTrue是为了省内存13 行小模型无所谓大模型建议都加上。4.3 优化器、学习率与 batch_size曲线不动的第一排查对象模型结构没变准确率却在 97% 附近卡住我一般先动学习率而不是模型结构。Adam 默认 lr1e-3在 MNIST 上前 3 个 epoch 效果好但后期收敛变慢。常见做法是改成小学习率微调或者切换到带动量的 SGD。optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9) scheduler optim.lr_scheduler.StepLR(optimizer, step_size3, gamma0.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(degrees10, 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 的误导比增强收益大增强要克制。degrees10已经足够让 97% 的卡点松动转太多反而会让 6、9 这类数字更难区分。另一个容易踩的坑是训练时用了增强测试和推理时必须用相同的手写识别预处理方式否则你验证时看到的准确率会低于训练的预期。5. 避坑手写数字识别 CNN 训练中的 5 个翻车现场5.1 准确率长期卡在 10% 附近输出层或标签出了问题现象训练 5 个 epoch训练集准确率一直徘徊在 10% 上下和随机猜差不多。原因大概率不是模型参数没调好而是代码逻辑把标签搞错了或者模型最后一层输出维度不对。10% 这个数字本身就是强烈的信号——10 个类别随机猜就是 10%。解决从三个位置查。第一datasets.MNIST里的trainTrue/False是不是写反了第二outputs.argmax(dim1)是不是误写成了dim0那会沿着 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, labelsrange(10)) print(cm)把测试集所有预测结果和真实标签保存下来跑一次混淆矩阵比凭感觉猜错误类型有效得多。5.4 训练时 loss 正常推理时全错预处理不一致现象训练阶段 loss 正常下降eval 时准确率也正常一旦把训练代码里的图片换成自己写的数字图片预测结果错得离谱。原因训练时用的预处理是ToTensor()加NormalizePIL 读进来的手写图片是 0 到 255 的像素值没有做同样的缩放和标准化或者图片是彩色、背景是白底黑字和 MNIST 的黑底白字反过来了。解决推理脚本里必须原样复用训练时的 transforms 管线并保证图像是 28×28、单通道、黑底白字。如果拿到的图片是白底黑字先要做像素反转255 - img再送进模型。这类问题最难排查因为不会报错只会输出一个自信的错误答案。5.5 DataLoader 在 Windows 上崩溃或显存突然爆掉现象训练脚本在 Linux 上跑得好好的换到 Windows 就报DataLoader worker exit unexpected或者训练到中间某个 epoch 显存溢出。原因Windows 下num_workers0时DataLoader 多进程需要if __name__ __main__保护主模块否则 multiprocessing 会重新执行整个脚本触发递归创建进程。显存溢出则多是因为输入分辨率或 batch_size 设计脱离了显存容量。解决Windows 上把num_workers0先保证能跑显存溢出优先把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_locationcpu)) model.eval() with torch.no_grad(): logits model(preprocess(image_path)) pred logits.argmax(dim1).item() prob torch.softmax(logits, dim1)[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_version13, dynamic_axes{input: {0: batch}} )导出后端侧推理时最容易犯的错是只移植了模型参数、没移植预处理逻辑。灰度转换、归一化的均值方差、通道顺序这三样只要有一个和训练时不一致整个模型就废了。我做这个项目最深的个人教训是永远把推理脚本和训练代码放在同一个仓库里用同一套 transforms 函数不要复制粘贴一段预处理到新文件里然后因为Image.open默认读出来的是 RGB 而翻车。手写数字识别本身不难难的是让数据在训练和推理两条链路上始终保持一致。希望你把这套方案跑通之后再回去看自己之前卡住的那些报错能一眼看出问题在哪。希望帮到你。本文还有配套的精品资源点击获取
返回列表