
第一次正经用 PyTorch 做深度学习项目绝大多数人都会选 MNIST 手写数字识别。原因很直白数据集小、任务明确、不需要 GPU 也能跑、而且能完整体验“数据处理 → 模型构建 → 训练 → 评估”的全部流程。很多人误以为它太简单实际上 MNIST 是检验自己对张量、自动求导、网络结构是否真正理解的试金石。我用 DNN 而不是 CNN 来做这个任务是因为 DNN 的每一层都直观可见什么反向传播、梯度消失、过拟合全都会在这个 28×28 的小图上原形毕露。这篇文章就围绕“Pytorch DNN 实现 MNIST 手写数字识别”这条主线把每一步的思考、代码、坑和调优心得都摊开讲适合刚入门深度学习、想在 PyTorch 上跑通第一个完整项目的读者。1. 经典任务背后的核心思路DNN 如何对手写数字建模1.1 MNIST 数据集到底长什么样MNIST 里的每张图片是 28×28 像素的灰度图展开之后就是 784 个浮点数对应 784 个输入特征。整个数据集包含 0 到 9 共十个类别训练集 60000 张测试集 10000 张。之所以说是灰度图而不是彩色图是因为每个像素只有一个亮度通道取值范围是 0 到 2550 代表纯黑255 代表纯白。对于神经网络来说这个数值范围其实不太友好因为大量梯度类优化算法对尺度敏感所以在数据加载阶段几乎必做归一化。DNN 做手写数字识别本质上就是把 784 维的像素向量映射到 10 维的类别概率向量上。中间可以插入若干隐藏层每一层先做线性变换再做非线性激活。线性变换做的事情是加权求和比如某个隐藏神经元可能学到“左上角的弧形边缘”这种模式而更深层的神经元则可能把多个模式组合成某个数字的完整结构。这听起来像是黑盒但实际训练出来的权重是可以被理解的尤其在 MNIST 这样的小图上有大量可视化手段能验证这一点。1.2 为什么先用 DNN 而不是直接上 CNN现在的图像识别教程动辄就是卷积神经网络但卷积里包含的感受野、权值共享、池化等概念对刚接触深度学习的人容易造成认知负担。DNN 没有那些花哨操作每个输入神经元和下一层神经元之间都有独立权重前向传播就是矩阵乘法和激活函数反向传播就是链式法则加梯度下降。先把 DNN 跑通至少能理解三个问题损失函数怎么把预测和真实标签联系到一起梯度怎么从最后一层传回第一层学习率如何影响参数更新的步长。而且 DNN 在 MNIST 上并不是不能打一个含 256 个隐藏神经元的单隐藏层网络把准确率做到 97% 以上非常轻松。虽然和 CNN 的 99% 还有差距但已经证明了一个朴素的线性加非线性模型能够从像素分布中提取出足够区分手写数字的模式。这个结论本身就会让你对神经网络建立起信心之后再引入 CNN关注点就可以放在卷积带来的结构先验上而不是手忙脚乱地同时应付多个新概念。1.3 核心组件串联起完整流程PyTorch 训练一个 DNN至少需要四样东西数据集和 DataLoader、模型结构、损失函数与优化器、训练循环。数据部分负责把原始图片转成张量并分批次交给模型模型部分用nn.Module定义层结构损失函数告诉模型错得有多远优化器决定如何根据梯度修正权重。很多人会忽略训练循环里的一个细节每轮迭代要分为“前向传播 → 计算损失 → 反向传播 → 更新参数”四个动作PyTorch 中分别对应model(x)、criterion(output, target)、loss.backward()和optimizer.step()。而且每批数据开始前必须optimizer.zero_grad()否则上一批的梯度会累加到当前批中导致更新方向混乱。把这些组件装进脑子里再往代码里填细节就会顺很多。2. 环境准备与数据加载把基础组件装好2.1 依赖安装与版本选择我用 PyTorch 做这个项目时的环境是 Python 3.10、PyTorch 2.x、TorchVision 0.15 左右。对于 MNIST 这种任务版本不需要太新只要保证torch、torchvision、matplotlib、numpy这四个库能正常 import 就行。安装方式看自己的机器CPU 版直接用 pip 即可pip install torch torchvision matplotlib numpy如果在 Linux 上带 NVIDIA 显卡建议去 PyTorch 官网按 CUDA 版本安装对应的 wheel。需要注意的一点CPU 环境下训练这个 DNN一个 epoch 大概几秒到十几秒完全够用GPU 反而不一定有优势因为模型小、数据在 CPU 和 GPU 之间搬运的耗时可能超过计算耗时。所以第一次跑不要纠结硬件只要能跑 PyTorch 就行。2.2 数据集的下载与归一化TorchVision 里已经内置了 MNIST不需要去其他地方找。用datasets.MNIST可以自动下载并且通过transform参数在每次读取时做预处理。常见做法是先转为 Tensor再归一化。归一化时使用均值和标准差 0.1307、0.3081这是 MNIST 官方统计的像素分布参数。不归一化直接让像素值保持在 0 到 255 时也能训练但损失会震荡得更厉害收敛也更慢。原因在于梯度大小和输入尺度直接相关输入过大容易导致梯度爆炸或让 sigmoid 类激活函数快速饱和。加载和预处理代码写法如下from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, transformtransform, downloadTrue) test_dataset datasets.MNIST(root./data, trainFalse, transformtransform, downloadTrue)ToTensor有两个作用把 PIL 图片或 numpy 数组转成 PyTorch 张量同时把数值范围从 0 到 255 缩放到 0 到 1。之后Normalize再根据公式(x - mean) / std计算让数据近似服从均值为 0、标准差为 1 的分布。这里的root参数是数据集保存路径第一次运行需要联网下载之后会直接读本地缓存。2.3 DataLoader 的作用与 batch 设置DataLoader 是 PyTorch 数据管线的核心它负责从数据集中按批次取数据并且自动打乱顺序。打乱顺序很重要如果每轮都按原始顺序取模型可能会学到某种序号相关的假规律导致训练结果不稳定。典型设置如下train_loader DataLoader(datasettrain_dataset, batch_size64, shuffleTrue) test_loader DataLoader(datasettest_dataset, batch_size256, shuffleFalse)训练集中 batch_size 设为 64意思是每轮迭代随机取 64 张图计算这 64 张图的总损失再更新一次参数。测试集 batch_size 可以大一些因为测试时不需要计算梯度、不需要更新参数只为了批量前向传播做预测。shuffle在测试时通常为 False这样方便对预测结果和真实标签做对应分析。有一点要理解一个 epoch 表示把完整训练集过一遍。60000 张图除以 batch_size 64等于 938 个批次也就是一个 epoch 内参数会更新 938 次。如果你感觉训练不够充分不要随便说“多跑几个 epoch”而是先想明白每个 epoch 里模型到底看过了多少数据、更新了多少次参数。3. 构建 DNN 模型从 784 维输入到 10 维输出3.1 网络结构设计方案我采用一个三层全连接网络结构是 784 → 256 → 128 → 10。中间两个隐藏层分别用 ReLU 激活函数最后一层不加激活函数因为 PyTorch 提供的交叉熵损失函数内部已经包含了 Softmax 计算。如果你在最后一层强行加 Softmax再配合CrossEntropyLoss就会得到错误的结果因为对数损失里已经做了 log_softmax正确做法是网络输出原始 logits损失函数内部完成归一化。选择 256 和 128 两个隐藏层并没有什么魔法这是经过反复试验后性价比很高的组合。神经元太少模型表达能力不足准确率会卡在 95% 左右神经元太多比如每层 1024MNIST 这种规模的数据容易出现过拟合训练时间也增加。对于 10 分类问题最后一层输出 10 个实数每个实数可以理解为“属于对应类别的未归一化得分”。3.2 用 nn.Module 定义模型类PyTorch 自定义模型的标准做法是继承nn.Module在__init__里定义层在forward里定义前向传播逻辑。代码如下import torch from torch import nn class DNN(nn.Module): def __init__(self, input_dim784, hidden_dim1256, hidden_dim2128, num_classes10): super(DNN, self).__init__() self.fc1 nn.Linear(input_dim, hidden_dim1) self.relu1 nn.ReLU() self.fc2 nn.Linear(hidden_dim1, hidden_dim2) self.relu2 nn.ReLU() self.fc3 nn.Linear(hidden_dim2, num_classes) def forward(self, x): x x.view(x.size(0), -1) x self.fc1(x) x self.relu1(x) x self.fc2(x) x self.relu2(x) x self.fc3(x) return x这里的x.view(x.size(0), -1)很关键。DataLoader 取出的每个 batch 形状是[batch_size, 1, 28, 28]其中 1 是灰度通道数28 和 28 是宽高。nn.Linear要求输入是二维张量第一维是 batch第二维是特征数所以必须把每个样本从二维图像展平成一维向量。x.size(0)取 batch_size-1表示自动计算剩余维度也就是 28×28×1784。注意如果忘了 reshape会直接报mat1 and mat2 shapes cannot be multiplied这种维度和索引不对应的错误。新手刚跑通代码后最容易在后续项目中犯这个错因为不同数据集输入的 shape 可能不一样养成在forward开头处理输入形状的习惯很重要。3.3 参数初始化对训练的影响PyTorch 默认会对nn.Linear的权重进行初始化通常是 Kaiming 均匀分布。这已经能保证 MNIST 任务正常收敛。不过如果你想深入了解模型行为可以用nn.init手动控制初始化方式例如对全连接层用 Xavier 初始化def init_weights(self): for m in self.modules(): if isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias)一般情况不推荐手动初始化除非遇到明显的训练不收敛、梯度爆炸或梯度消失问题。MNIST 这种任务下默认初始化基本够用而且手动初始化万一选错分布反而可能让训练效果变差。4. 训练流程让模型真正开始学习4.1 损失函数与优化器选型损失函数采用nn.CrossEntropyLoss()它把LogSoftmax和NLLLoss合并在一起输入为模型的原始 logits目标为类别索引输出为平均损失。既然是十类分类这就是最标准的做法。优化器我选择 Adam学习率设置为 0.001。Adam 的优势是它会为每个参数自适应调节学习率并且带有动量效果在 MNIST 这种小任务上收敛速度明显快于普通随机梯度下降。代码示例如下model DNN() criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001)在设置优化器之前要传入model.parameters()这里包含了所有nn.Linear层中的权重和偏置。优化器会为这些参数维护状态比如 Adam 中的一阶矩和二阶矩估计。每训练一步优化器就根据梯度对参数做增量更新。4.2 训练循环的完整代码与逐行解析训练循环是整个项目中最需要动手写熟的部分。完整代码如下def train(dataloader, model, criterion, optimizer, device): model.train() total_loss 0 correct 0 for images, labels in dataloader: 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() preds outputs.argmax(dim1) correct (preds labels).sum().item() avg_loss total_loss / len(dataloader) accuracy correct / len(dataloader.dataset) return avg_loss, accuracy每一步的含义都值得细看model.train()把模型切换到训练模式。对于 DNN 来说这主要影响 Dropout 和 BatchNorm 的行为虽然这里没用到但这是一个规范动作。images.to(device)把数据搬到指定设备GPU 就用cuda。optimizer.zero_grad()清空上一次迭代留下的梯度。如果不清空梯度会累积等于让参数一次性迈出好几步损失会非常不稳定。outputs model(images)前向传播得到形状为[batch_size, 10]的 logits。loss.backward()根据链式法则计算每个参数的梯度。optimizer.step()用计算好的梯度更新参数。同时统计本批次损失和预测正确数方便打印 epoch 级结果。在循环结束后用len(dataloader)求平均损失用dataloader.dataset得到总样本数。为什么用correct / len(dataloader.dataset)而不是correct / len(images)因为最后一个 batch 可能不足 batch_size直接除以 batch 数会导致分母错误。4.3 评估函数与测试集验证训练完每个 epoch 后要在测试集上评估这能判断模型是真正学到了泛化规律还是只在训练集上死记硬背。评估代码与训练代码类似区别在于不需要反向传播和参数更新也不需要计算梯度因此可以用torch.no_grad()包裹来节省内存并加速。def evaluate(dataloader, model, criterion, device): model.eval() total_loss 0 correct 0 with torch.no_grad(): for images, labels in dataloader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() preds outputs.argmax(dim1) correct (preds labels).sum().item() avg_loss total_loss / len(dataloader) accuracy correct / len(dataloader.dataset) return avg_loss, accuracymodel.eval()同样是一个模式切换它告诉模型当前处于评估或预测阶段。如果模型里有 Dropout 或 BatchNorm行为会和训练模式完全不同所以训练和评估必须严格切换模式否则测试结果会很奇怪。主循环里设置训练轮数 epoch 为 10 到 20 之间每轮打印训练损失、训练准确率、测试损失和测试准确率epochs 10 for epoch in range(1, epochs 1): train_loss, train_acc train(train_loader, model, criterion, optimizer, device) test_loss, test_acc evaluate(test_loader, model, criterion, device) print(fEpoch {epoch:02d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.4f})输出大概是Epoch 01 | Train Loss: 0.2983 | Train Acc: 0.9115 | Test Loss: 0.1512 | Test Acc: 0.9557 Epoch 02 | Train Loss: 0.1214 | Train Acc: 0.9646 | Test Loss: 0.1013 | Test Acc: 0.9687 ... Epoch 10 | Train Loss: 0.0351 | Train Acc: 0.9893 | Test Loss: 0.0778 | Test Acc: 0.9773测试准确率稳定在 97.5% 到 98% 之间是正常水平。如果只跑 10 个 epoch 就能得到 97%说明 DNN 对这种简单数据集已经足够强。4.4 用一张图理解训练状态为了观察训练过程中有没有过拟合可以每轮记录损失和准确率最后用 matplotlib 画两条曲线。训练损失持续下降但测试损失在第 5 轮左右开始反弹这是过拟合的典型信号。MNIST 上过拟合出现得比较晚因为数据量足够大很多时候 10 到 15 个 epoch 内测试准确率会随训练提升而继续提升。不过当你把 hidden_dim 调到 1024 或 2048过拟合就会更明显曲线分层更早出现。画图代码不复杂但需要把每轮的数值保存到列表里这比你只盯着终端打印的最后一个数字更能看清趋势。5. 评估结果与预测可视化看清模型到底学会了什么5.1 混淆矩阵哪两个数字最容易被搞混准确率只是一个宏观数字要看细节就需要看混淆矩阵。MNIST 里 4 和 9、7 和 2、3 和 8 这种长相接近的数字是 DNN 最容易混淆的。用sklearn.metrics.confusion_matrix可以一行得到矩阵然后通过 matplotlib 画成热力图。代码写法大致是这样import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix all_preds [] all_labels [] model.eval() with torch.no_grad(): for images, labels in test_loader: outputs model(images) preds outputs.argmax(dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm confusion_matrix(all_labels, all_preds) print(cm)混淆矩阵对角线上的数字是各个类别被正确分类的样本数非对角线上的数字则是错误分类。通过矩阵你可以定位模型在哪个类别对之间最犹豫这比单纯调参更有指导意义因为它能告诉你网络的表示能力边界在哪。5.2 把预测结果画出来看训练模型过程中我最喜欢做的一步是随机抽取测试集里的若干张图把真实标签和预测标签并列展示。预测错误的样本往往很有意思有的图像在人眼来看都很难辨认有些则是书写风格过于潦草。把错误样本可视化能帮助你建立对模型能力的直观认识而不是只看冷冰冰的准确率。可视化核心代码大致如下import matplotlib.pyplot as plt def show_predictions(model, test_dataset, device, num_images8): model.eval() fig, axes plt.subplots(1, num_images, figsize(12, 6)) indices np.random.choice(len(test_dataset), num_images, replaceFalse) with torch.no_grad(): for i, idx in enumerate(indices): image, label test_dataset[idx] output model(image.unsqueeze(0).to(device)) pred output.argmax(dim1).item() axes[i].imshow(image.squeeze(), cmapgray) axes[i].set_title(fTrue: {label} | Pred: {pred}, fontsize12) axes[i].axis(off) plt.tight_layout() plt.show()image.unsqueeze(0)是在第 0 维加一个 batch 维度因为模型要求输入至少是二维或四维张量单张图只有[1, 28, 28]需要变成[1, 1, 28, 28]。看到预测结果和你的直观判断一致时会有一种“它真的学会了”的实感。5.3 分析权重与神经元响应深度网络可解释性差但 MNIST 的 DNN 非常透明。把第一个隐藏层某个神经元的权重矩阵还原成 28×28 的图像你可以看到这个神经元在寻找什么特征。第一个隐藏层的权重形状是[256, 784]取第 k 个神经元的一行权重model.fc1.weight[k].detach().numpy().reshape(28, 28)用灰度图显示亮色和暗色区域就代表了该神经元对不同位置像素的偏好。这种可视化虽然不影响模型效果但能让你完全破除“神经网络只是黑盒”的恐惧。6. 常见问题与调优经验实录6.1 损失不下降应该怎么排查遇到损失不下降时先确认数据是否做了归一化。未归一化时网络很可能在最初的几个 step 内产生极大梯度导致损失直接变成 NaN。如果代码里没有transform.Normalize补充上去再重跑。接着检查学习率Adam 默认 0.001 对于全连接网络是比较安全的如果调成 0.1 有大概率发散。最好在训练循环里逐 batch 打印一次前几次迭代的 loss如果第一个 batch 后的 loss 没有明显下降趋势说明网络结构或数据预处理有问题而不是单纯需要更长训练时间。还有一种容易被忽略的问题标签类型不对。CrossEntropyLoss要求标签是整数张量不能是浮点数或 one-hot 向量。如果你在预处理时不小心做了label.float()训练时会报Expected dtype long之类的错误。6.2 过拟合怎么办DNN 在 MNIST 上有轻微过拟合表现典型特征就是训练准确率接近 100%测试准确率停在 97% 到 98%。解决方法按优先级排序降低模型容量比如把 hidden_dim 从 1024 降到 128。增大训练数据噪声例如对输入图片加入少量高斯噪声或做随机平移。在隐藏层之间加入 Dropout以 0.2 到 0.3 的概率随机丢弃神经元这能迫使网络学习更冗余的特征。使用 L2 正则化在优化器中设置weight_decay1e-4。更保守地降低学习率并增加 epoch让模型更慢地收敛到最优区域。我不是特别推荐一开始就用 Dropout因为对于 MNIST 这种简单任务先调整网络容量和训练轮数往往更有效。Dropout 适合当你已经在模型容量受限的情况下仍然出现过拟合时使用。6.3 准确率停在 95% 上下怎么办如果你的单隐藏层或两层网络准确率一直卡在 95%先检查激活函数和隐藏层数量。用一个隐藏层 128 个神经元确实可能只有 95% 左右因为模型表达能力不够。此时应该增加隐藏层宽度或再加一层深度。另外检查输入展开是否正确很多人会在forward里把view写成x x.view(-1, 28)这会让每个样本变成 28 列但行数不固定维度直接错乱即使不报错训练效果也会很差。这种隐藏 bug 很难通过报错发现一定要养成用print(x.shape)检查张量形状的习惯。下面是我整理的常见问题速查表现象可能原因解决方案训练损失不下降学习率过高或未归一化降低学习率到 0.001补上 Normalize损失变成 NaN梯度爆炸或标签类型错误缩小学习率确认标签为 long 类型训练准确率 100%测试 96%过拟合减少神经元数加入 Dropout增加数据增强准确率一直 95% 左右模型容量不足增加隐藏层宽度或深度收敛特别慢输入尺度未归一化或学习率过低检查 transform适当提升学习率不同运行结果差异大初始化随机性固定随机种子6.4 关于超参数的三个建议学习率是最重要的超参数。Adam 0.001 足够跑大多数任务但如果想要更好效果可以用余弦退火或学习率调度器比如每 5 个 epoch 把学习率乘以 0.5。batch size 的选择也很关键64 是最常见选择。把 batch size 从 64 改成 256意味着每个 epoch 参数更新次数从 938 次变成 235 次模型需要更多 epoch 才能收敛到同等水平。而 batch size 设为 16 时梯度噪声大收敛不稳定反而可能让最终测试准确率略有下降。hidden_dim 我建议从 128、256、512 里选不要一上来就上 2048因为 MNIST 的简单性决定了容量增加带来的边际收益极其有限。6.5 把代码写得模块化一点实际项目里我不会把训练逻辑和评估逻辑全部写在一个 for 循环里。拆成load_data、build_model、train、evaluate、plot_result五个函数会让调试方便很多。遇到问题时能单独测试某一段不用每次重跑全部流程。对应地我建议训练脚本里加一个if __name__ __main__:来尽量进行其他代码的引用。代码组织能力在第一个项目里就要有意培养否则后面接触复杂网络时会寸步难行。7. 后续还能往哪里扩展MNIST 的 DNN 跑通之后可以按下面几个方向做自然延伸把 DNN 换成 CNN感受卷积层如何通过局部连接大幅减少参数量同时把准确率提到 99% 以上。用同样的代码结构尝试 Fashion-MNIST 数据集任务复杂度提升了但代码改动非常小可以体会特征设计对模型性能的影响。在 DNN 中加入 BatchNorm 和 Dropout观察训练曲线和泛化能力的变化。保存模型权重到本地再用 PyTorch 的torch.load加载做推理理解训练和推理状态的差异。尝试自己手写几张数字图片用同样的预处理方式送入模型看模型能不能识别你写的字。我个人在实际操作中最强烈的体会是MNIST 之所以能成为一个经典项目不仅因为它简单而是因为它把所有深度学习的核心概念浓缩在一个小小的数据集里。第一次跑通可能只要十几分钟但之后每当我遇到陌生框架或新概念都会先回到 MNIST 上做一个对照组用最朴素的方式验证自己的理解。写代码时千万要带着问题去写每改一个超参数都记录一下结果不要盲目尝试。这个过程会比单纯的“调包跑通”有价值得多。