尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
PyTorch实战:手写数字识别从数据加载到模型迁移的完整指南
1. 为什么一个手写数字识别值得反复折腾很多人第一次接触深度学习框架做的第一个项目就是手写数字识别。这个数据集小、任务简单、跑得快看起来像是玩具项目。但我个人的经验是越是简单的项目越能暴露一个人对框架底层机制的理解程度。同样是用PyTorch搭一个卷积神经网络跑这个数据集有人写出来的代码只能跑一次换个数据集就崩有人写出来的代码结构清晰、参数可调、训练过程可观测稍加修改就能迁移到更复杂的任务上。这两者之间的差距不在于会不会调API而在于是否理解每一个设计决策背后的原因。这篇文章面向的读者是已经装好了PyTorch、知道张量是什么、但还没有完整跑通过一个训练流程的人或者跑通过别人的代码但说不清楚每一行在干什么的人。我会从数据加载、网络结构设计、训练循环、结果验证这几个环节逐一拆解把每个环节中容易忽略的细节和常见的坑都讲清楚。全文的代码都是实测跑通的环境是PyTorch 2.x配合CPU或单卡GPU不需要多卡并行也不需要分布式训练。在开始之前先明确一下这个任务的基本情况。手写数字数据集包含0到9共10个类别的手写数字灰度图每张图是28×28像素。训练集有60000张测试集有10000张。图像是单通道的灰度图像素值范围是0到255。这个任务的基线准确率大概在99%左右一个设计合理的卷积网络在几分钟内就能达到这个水平。但如果你只是随便搭几层卷积池化可能会发现准确率卡在97%上不去或者训练过程中损失震荡得厉害。这些问题背后都有具体的原因我会在对应章节里逐一分析。2. 数据加载环节那些看起来不起眼但影响很大的细节2.1 为什么必须做归一化而不是简单除以255很多人拿到图像数据的第一反应是直接把像素值除以255把范围缩到0到1之间。这个操作本身没错但不够好。正确的做法是使用均值和标准差做标准化让数据分布接近标准正态分布。对于手写数字数据集全局的均值大约是0.1307标准差大约是0.3081。这两个数字是经过统计得出的直接拿来用就行。为什么标准化比简单除以255更好因为神经网络的权重初始化通常假设输入数据是零均值、单位方差的。如果你的输入数据均值是0.5那么第一层卷积的输出就会有一个整体偏移这个偏移会随着层数加深被放大导致训练初期梯度方向不稳定。我实测过用简单除以255的方式训练前几个epoch的损失下降曲线明显比标准化方式更抖收敛需要的epoch数也更多。代码实现上用torchvision.transforms组合起来非常简洁import torch from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform ) test_dataset datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransform )注意Normalize的参数是两个元组因为图像是单通道的所以每个元组里只有一个值。如果是RGB三通道图像就需要写三个值。这个细节看起来小但如果你从单通道任务迁移到三通道任务时忘了改程序会直接报维度不匹配的错误。2.2 DataLoader的batch_size和shuffle该怎么选DataLoader负责把数据集分批送进网络。这里有两个关键参数batch_size和shuffle。shuffleTrue在训练集上是必须的因为如果每次迭代都按固定顺序取数据网络会学到样本顺序带来的虚假模式。测试集上则不需要shuffle因为测试时不涉及梯度更新顺序无所谓。batch_size的选择更讲究一些。太小比如8或16会导致每个batch的梯度估计噪声很大训练过程震荡太大比如1024或2048虽然梯度估计更准但会降低参数更新的频率而且对显存或内存的压力更大。对于这个数据集我实测下来64是一个比较均衡的选择。训练集60000张分成938个batch每个epoch迭代938次这个频率足够让网络在合理的时间内收敛。还有一个容易被忽略的参数是num_workers。在Windows系统上如果设置num_workers大于0可能会遇到多进程启动失败的问题。稳妥的做法是在Windows上设为0在Linux上设为2或4。这个参数影响的是数据加载的并行度设得太小可能成为训练速度的瓶颈设得太大则会占用过多内存。from torch.utils.data import DataLoader train_loader DataLoader( train_dataset, batch_size64, shuffleTrue, num_workers0 ) test_loader DataLoader( test_dataset, batch_size1000, shuffleFalse, num_workers0 )测试集的batch_size可以设大一些因为测试时不需要反向传播显存占用小一次多处理一些数据能加快评估速度。3. 网络结构设计从两层卷积到特征图尺寸的精确计算3.1 卷积层参数的计算逻辑一个卷积层涉及几个关键参数输入通道数、输出通道数、卷积核大小、步长、填充。这些参数决定了输出特征图的空间尺寸。计算公式是输出尺寸 (输入尺寸 2 × 填充 - 卷积核大小) / 步长 1以第一层卷积为例输入是28×28的单通道图像卷积核大小选3填充选1步长选1。代入公式(28 2×1 - 3) / 1 1 28。也就是说输出特征图的空间尺寸仍然是28×28。这就是所谓的same padding在卷积核为3时填充设为1可以保持空间尺寸不变。第二层卷积同样用3×3核、填充1、步长1输入是14×14经过一次池化后输出仍然是14×14。池化层用2×2窗口、步长2每次池化把空间尺寸减半。我设计的网络结构是这样的import torch.nn as nn import torch.nn.functional as F class ConvNet(nn.Module): def __init__(self): super(ConvNet, self).__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(kernel_size2, stride2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.dropout nn.Dropout(0.25) def forward(self, x): x self.pool(F.relu(self.conv1(x))) x self.pool(F.relu(self.conv2(x))) x x.view(-1, 64 * 7 * 7) x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x这里的关键在于x.view(-1, 64 * 7 * 7)这一行。经过两次池化后28×28变成了7×7通道数是64所以展平后的维度是64×7×73136。这个数字必须和fc1的输入维度完全一致否则会报维度错误。我见过不少人在这里卡住原因就是没有仔细计算每一层之后的特征图尺寸。3.2 通道数为什么选32和64而不是16和32通道数决定了网络能提取多少种不同的特征。第一层卷积的32个通道意味着网络可以学习32种不同的边缘、纹理检测器。第二层的64个通道则是在第一层特征的基础上组合出更复杂的模式。如果通道数太少比如8和16网络的表达能力不足准确率会明显下降。我实测过8和16的配置测试准确率大概在98.2%左右比32和64的配置低了将近1个百分点。但通道数也不是越多越好。如果第一层就用128个通道参数量会急剧增加训练时间变长而且容易过拟合。对于这个数据集32和64是一个经过验证的合理配置在准确率和训练效率之间取得了平衡。3.3 Dropout层的位置和丢弃率选择Dropout是一种正则化手段在训练时随机将一部分神经元的输出置零防止网络对某些特定神经元产生过度依赖。我把它放在第一个全连接层之后丢弃率设为0.25。这个位置的选择有讲究卷积层通常不需要Dropout因为卷积核的参数共享机制本身就有一定的正则化效果全连接层参数量大容易过拟合所以在这里加Dropout更有效。丢弃率0.25意味着每次训练迭代有25%的神经元被随机丢弃。如果设得太高比如0.5网络欠拟合的风险增加训练损失下降会变慢设得太低比如0.1正则化效果不明显。0.25到0.3之间是比较稳妥的范围。4. 训练循环损失函数、优化器和学习率的配合4.1 交叉熵损失与Softmax的关系分类任务用交叉熵损失是标准做法。在PyTorch中nn.CrossEntropyLoss内部已经包含了Softmax操作所以网络的最后一层不需要再加Softmax。这一点很容易搞混如果你在网络的forward里加了F.softmax然后再用CrossEntropyLoss相当于做了两次Softmax会导致梯度计算错误训练效果大打折扣。criterion nn.CrossEntropyLoss()这行代码看起来简单但背后涉及的概率论原理值得说清楚。交叉熵衡量的是预测分布和真实分布之间的差异。对于one-hot编码的真实标签交叉熵损失实际上就是负对数似然正确类别的预测概率越高损失越小。当预测概率接近1时损失趋近于0当预测概率接近0时损失趋向于无穷大。这种特性使得网络会强烈地惩罚自信的错误预测从而推动参数向正确方向更新。4.2 优化器选Adam还是SGDAdam和SGD是两种最常用的优化器。Adam的自适应学习率机制使得它在训练初期收敛很快适合快速验证网络结构是否合理。SGD配合学习率衰减策略在训练后期往往能达到更好的泛化性能。对于这个项目我建议先用Adam跑一遍确认网络结构和数据加载没有问题准确率能到99%左右。然后再换成SGD加动量观察最终准确率是否有提升。Adam的初始学习率设0.001SGD的初始学习率设0.01配合动量0.9。optimizer torch.optim.Adam(model.parameters(), lr0.001)如果你用SGD代码是这样的optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9)我实测下来Adam在5个epoch内就能达到99%的测试准确率SGD大概需要8到10个epoch。但SGD最终收敛到的准确率有时会略高零点几个百分点。这个差异在实际应用中意义不大选哪个取决于你更看重训练速度还是最终精度。4.3 完整的训练循环与验证逻辑训练循环的核心逻辑是前向传播计算输出计算损失反向传播计算梯度更新参数。每个epoch结束后在测试集上评估一次记录准确率。def train(model, device, train_loader, optimizer, criterion, 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(fEpoch {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}] f Loss: {loss.item():.6f}) def test(model, device, test_loader, criterion): model.eval() test_loss 0 correct 0 with torch.no_grad(): for data, target in test_loader: data, target data.to(device), target.to(device) output model(data) test_loss criterion(output, target).item() pred output.argmax(dim1, keepdimTrue) correct pred.eq(target.view_as(pred)).sum().item() test_loss / len(test_loader) accuracy 100. * correct / len(test_loader.dataset) print(fTest Loss: {test_loss:.4f}, Accuracy: {accuracy:.2f}%) return accuracy这里有几个细节值得注意。model.train()和model.eval()的切换是必须的因为Dropout层在训练和评估时的行为不同。训练时Dropout随机丢弃神经元评估时则使用全部神经元但缩放输出。如果不切换测试结果会不稳定。torch.no_grad()在测试时使用可以关闭梯度计算减少内存占用并加速计算。这个上下文管理器的作用是告诉PyTorch接下来的计算不需要构建计算图。optimizer.zero_grad()的位置在反向传播之前。如果忘了这一步梯度会在多个batch之间累积导致参数更新幅度越来越大训练发散。这是新手最容易犯的错误之一。5. 实测结果与常见问题排查5.1 训练过程的典型输出与准确率曲线在CPU上跑这个网络每个epoch大概需要30到60秒取决于CPU性能。在GPU上则只需要几秒钟。我用Adam优化器跑了5个epoch测试准确率的变化是这样的Epoch测试准确率测试损失198.12%0.0621298.76%0.0398399.01%0.0312499.15%0.0276599.21%0.0254可以看到第一个epoch结束后准确率就接近98%说明网络结构是合理的。后续epoch的提升逐渐放缓这是正常的收敛行为。如果准确率在97%左右就停滞不前通常说明网络容量不足或者正则化过强。5.2 损失不下降或准确率卡住的排查思路如果你跑出来的结果和上面的表格差距很大可以按以下顺序排查第一检查数据预处理。确认Normalize的均值和标准差是否正确确认ToTensor()是否把像素值转到了0到1之间。如果忘了ToTensor()数据还是0到255的整数网络几乎不可能收敛。第二检查网络最后一层的输出维度。fc2的输出必须是10对应10个类别。如果写成了其他数字损失计算会出错或者准确率极低。第三检查是否误加了Softmax。前面说过CrossEntropyLoss内部已经包含了Softmax重复添加会导致梯度消失。第四检查学习率是否过大。如果损失在训练初期就变成NaN大概率是学习率太大导致梯度爆炸。把学习率降到0.0001试试。第五检查zero_grad()是否调用。如果忘了这一步梯度累积会导致参数更新失控。5.3 过拟合与欠拟合的判断和处理训练准确率远高于测试准确率比如训练99.9%测试97%说明过拟合了。处理方法是增加Dropout丢弃率、增加权重衰减weight_decay参数、或者减少网络参数量。训练准确率和测试准确率都很低比如都在95%以下说明欠拟合。处理方法是增加网络层数或通道数、减小Dropout丢弃率、增加训练epoch数。我实测中发现一个有意思的现象如果训练集不做shuffle测试准确率会比做shuffle低0.3到0.5个百分点。这是因为不做shuffle时每个batch内的样本标签分布可能不均匀导致梯度估计有偏。这个细节在文档里通常不会强调但实际影响是存在的。6. 模型保存与加载的实用技巧训练完成后需要把模型参数保存到磁盘方便后续加载和部署。PyTorch提供了两种保存方式保存整个模型对象和只保存参数字典。推荐后者因为参数字典更轻量加载时不受代码结构变化的影响。torch.save(model.state_dict(), mnist_cnn.pth)加载时先实例化网络结构再加载参数model ConvNet().to(device) model.load_state_dict(torch.load(mnist_cnn.pth, map_locationdevice)) model.eval()map_location参数在跨设备加载时很有用。比如你在GPU上训练并保存了模型但加载时只有CPU就需要指定map_locationcpu否则会报错。还有一个实用技巧在保存时把优化器的状态也一起保存这样可以在训练中断后恢复训练而不是从头开始。保存的内容是一个字典checkpoint { epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), accuracy: accuracy } torch.save(checkpoint, checkpoint.pth)恢复训练时先加载模型和优化器的状态再从保存的epoch继续训练。这个做法在训练大模型时几乎是必须的因为训练时间可能长达数小时甚至数天中途中断的代价很高。7. 从手写数字到真实场景的迁移思路这个项目跑通之后你可能会想把它用到自己的数据上。迁移时需要注意几个关键差异。第一输入图像的尺寸和通道数可能不同需要调整第一层卷积的输入通道数和全连接层的输入维度。第二类别数可能不是10需要修改最后一层的输出维度。第三数据预处理的方式可能需要调整比如彩色图像的均值和标准差要用三个通道各自的值。我个人的经验是迁移时最容易出问题的地方是全连接层的输入维度计算。每次改变卷积层或池化层的参数后都要重新计算特征图尺寸确保展平后的维度和全连接层的输入一致。一个偷懒的办法是用nn.AdaptiveAvgPool2d把特征图直接池化到固定尺寸这样就不需要手动计算了但会损失一些空间信息。另外如果新数据集的样本量很小比如每类只有几十张直接训练卷积网络很容易过拟合。这时候可以考虑用预训练的模型做特征提取只训练最后的分类层。这种做法叫迁移学习在小样本场景下效果很好。最后分享一个我在实际项目中养成的习惯每次修改网络结构或训练参数后先用一个很小的子集比如1000张训练图跑一两个epoch确认代码没有维度错误或逻辑错误再放到全量数据上训练。这样能节省大量等待时间避免跑了半小时才发现代码有问题。
RELATED

相关推荐

Word图文粘贴进帝国CMS后台:版式保留的完整实战指南

Word图文粘贴进帝国CMS后台:版式保留的完整实战指南

做内容运营和网站维护的人,八成都有过这种经历:辛辛苦苦在Word里排好的图文文档,复制粘贴进帝国整站程序后台的编辑器,一点确定,预览一看——图片全没了、文字挤成一团、行距忽大忽小、表格直接冲出页面边界。运气好点…

📅 2026/10/17 9:04:26
工作天天CRUD,为什么还要学数据结构与算法?附零基础路线

工作天天CRUD,为什么还要学数据结构与算法?附零基础路线

说句实在话,我带新人时最常被问的问题不是“这行代码怎么写”,而是“工作里天天 CRUD,我为什么要学数据结构与算法?”每听到一次,我都会想起自己刚入行那年的状态:觉得算法题就是面试造火箭,上班…

📅 2026/10/17 9:04:26
OpenCvSharp+USB摄像头录像:从VideoCapture到H264的C#实现指南

OpenCvSharp+USB摄像头录像:从VideoCapture到H264的C#实现指南

简介:面向C#/.NET平台的视频采集与编码开发者,这份资源围绕使用OpenCVSharp库在Windows环境下驱动USB摄像头完成录像,并采用H264标准进行压缩输出的核心需求,提供了一套完整可参考的Visual Studio工程。压缩包包含134个文件&#…

📅 2026/10/17 9:04:26
MORE NEWS

更多资讯

📰

Ely Prism Launcher如何读取存档NBT数据?libnbtplusplus的World管理完全指南

【免费下载链接】Launcher This fork of Prism Launcher adds integrated support for Ely.by accounts. 项目地址: https://gitcode.com/GitHub_Trending/ely/Launcher 点击查看 免费下载 Ely Prism Launcher 是基于 Prism Launcher 的增强版 Minecraft 启动器&am…

📰

GLM-5部署实战:744B MoE架构与202K长上下文的工程落地指南

看到GLM-5开源的消息刷屏时,我第一反应不是“好强”,而是“744B MoE架构加202K超长上下文,我的机器到底跑不跑得动”。作为常年跟大模型部署较劲的人,我对这种大参数开源模型的态度一直是:先别看宣传稿,把权…

📰

CLI vs. MCP:为什么都在说MCP凉了?TaoToken统一Key视角下的选型复盘

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

傅里叶描述子与SVM手势识别:旋转不变性原理及完整实现

简介:这份资源是一套基于傅里叶算子的手势识别完整源代码,面向具备Python与机器学习基础、希望动手实践图像处理与分类算法的开发者及学生。代码在Win10与Python3.7环境下运行,覆盖图像平滑、OTSU阈值肤色分割、八邻域搜索轮廓检测&#xff0…

📰

使用 psycopg2 时遇到的两个坑:从 pg_config 缺失到 whl 安装的排查记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Git merge完全指南:从三方合并原理到冲突解决实战

用了这么多年Git,我发现自己被问得最多的问题不是"怎么撤销commit",而是"git merge到底怎么操作"。很多人会敲git merge dev,但合并完一看到冲突就懵了,或者不小心把别人的分支合进来不知道怎么回退。说实话&…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬