首页 / 资讯中心 / 文章详情

PyTorch实现MNIST手写数字识别:多层全连接网络实战

PyTorch实现MNIST手写数字识别:多层全连接网络实战 ★ FEATURED ARTICLE
1. 项目概述与核心需求解析1.1 为什么选择MNIST作为深度学习入门项目提起深度学习入门MNIST手写数字识别几乎是所有人绕不开的第一站。这个包含0到9共10类手写数字、6万张训练图片和1万张测试图片的数据集虽然已经诞生了三十年但至今仍然是最适合理解神经网络全流程的“实验田”。我最初接触这个项目时也一度困惑都什么年代了还在用这么老的数据集后来真正跑通才发现恰恰是MNIST的“简单”让它成为教学利器。每张图片只有28×28像素灰度单通道数据量适中训练一轮在普通CPU上也就几十秒。这意味着你可以把精力完全放在网络结构设计、超参数调整、训练流程理解上而不需要像处理ImageNet那样先解决显存不够、训练太慢等工程问题。用PyTorch实现MNIST分类本质上是把一个完整的深度学习工作流压缩到了最小可运行规模数据加载与预处理、模型定义、损失函数选择、优化器配置、训练循环、评估与可视化。这个流程里每一个环节在真实工业级项目里也都是同样的套路。这也是为什么我说这个项目“麻雀虽小五脏俱全”。1.2 多层全连接网络为什么仍是必学基础有人会问现在CNN、Transformer大行其道为什么还要花时间写全连接网络我的看法是全连接网络是理解神经网络本质的最佳载体。它把“加权求和非线性激活”这个核心操作暴露得最直观。你不需要理解卷积的感受野、池化的降采样逻辑只需要关注权重矩阵的形状、每一层的输入输出维度、损失函数如何反向传播更新参数。把这些基础打牢再去学CNN、RNN、Transformer你会发现很多概念其实是相通的。举个例子我教过几个零基础的朋友。直接让他们看CNN结构卷积核、padding、stride这些概念堆在一起很容易劝退。但先写一个三层的全连接网络他们很快就能理解“网络就是一堆矩阵乘法”这个核心思想。等他们上手了全连接回头再看CNN里的卷积其实不过是带权重的局部连接而已。这个项目适合的读者范围也很广刚接触PyTorch的初学者、准备面试想快速复习基础模型的开发者、想系统梳理训练流程细节的从业者都可以从中获得一些参考价值。2. 环境配置与数据准备全攻略2.1 开发环境搭建要点先把环境搞定。我推荐使用Python 3.8以上的版本PyTorch选择1.13或2.x系列均可。这里要注意一个细节PyTorch 2.x引入了编译优化机制但对我们这种小型全连接网络性能提升感知不强反而要留意版本兼容问题。如果你用的是PyTorch 2.x某些旧教程里的API写法可能需要微调比如函数不再直接暴露在顶层命名空间。安装方式我建议用conda创建独立环境避免和系统Python产生依赖冲突。考虑到国内网络情况使用国内镜像源能省不少时间conda create -n mnist python3.9 conda activate mnist pip install torch torchvision -i https://pypi.tuna.tsinghua.edu.cn/simpletorchvision是必需品因为MNIST数据集的下载和预处理都要靠它。安装完成后建议先跑一个小测试验证CUDA是否可用import torch print(torch.__version__) print(torch.cuda.is_available())如果你的机器有NVIDIA显卡并且正确安装了CUDA版PyTorch第二行会输出True。没有GPU也没关系这个项目的训练规模用CPU完全跑得动顶多多等几十秒。2.2 数据集加载与预处理细节PyTorch的数据加载流程可以用一套非常标准的代码实现import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms 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 ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size64, shuffleFalse)这段代码里有三个细节值得展开讲。第一个是transforms.ToTensor()。很多刚入门的朋友不理解为什么不能直接用原始数据训练。MNIST原始数据是PIL Image对象像素值范围在0到255之间。ToTensor做了两件事把图像从HWC格式转成CHW格式即从宽度×高度×通道变成通道×宽度×高度同时把像素值缩放到0到1之间。第二个是transforms.Normalize((0.1307,), (0.3081,))。这两个值是MNIST数据集的全局均值和标准差属于官方推荐的标准预处理参数。Normalize的作用是把像素分布从0到1的区间转换到均值为0、标准差为1的标准正态分布附近。这样做的目的很朴素如果输入数据的尺度差异过大网络训练时梯度更新会非常不稳定。你可以自己算一下如果不做标准化输入值全在0到1之间经过几层线性变换后梯度很容易出现震荡。第三个是shuffle参数。训练集设置shuffleTrue是为了打乱样本顺序避免模型学到数据中的排列规律。比如原始数据集里可能前5000张全是数字1如果不打乱模型会倾向于认为连续出现的样本属于同一类别。测试集不需要打乱因为评估时我们不更新参数样本顺序不影响结果。2.3 数据洞察先看清再动手在写模型之前我强烈建议先花两分钟看看你手里的数据长什么样。这不是走形式而是建立对问题的直观感受。import matplotlib.pyplot as plt data_iter iter(train_loader) images, labels next(data_iter) fig, axes plt.subplots(2, 5, figsize(10, 4)) for i in range(10): ax axes[i // 5][i % 5] ax.imshow(images[i].squeeze(), cmapgray) ax.set_title(fLabel: {labels[i].item()}) ax.axis(off) plt.show()这段代码取一个批次的前10张图片可视化。你会发现MNIST的图片信息量确实很小每张图的主体只有一个数字笔画粗细、倾斜角度、书写风格各不相同但人类一眼就能识别出来。这个观察对后续评估模型很有用如果模型准确率低于99%说明它还没有达到“看一眼就能认出来”的水平那问题多半出在网络结构或者训练配置上。3. 多层全连接网络的结构设计与实现3.1 网络层的维度推导现在我们来设计网络。输入是28×28的图片展平后得到784维向量。输出是10个类别的概率分布。中间加两层隐藏层每层128个神经元。整体结构如下import torch.nn as nn class MLP(nn.Module): def __init__(self): super(MLP, self).__init__() self.fc1 nn.Linear(784, 128) self.fc2 nn.Linear(128, 128) self.fc3 nn.Linear(128, 10) self.relu nn.ReLU() self.softmax nn.Softmax(dim1) def forward(self, x): x x.view(x.size(0), -1) x self.relu(self.fc1(x)) x self.relu(self.fc2(x)) x self.fc3(x) return x这里有一个很重要的设计决策为什么最后一层不做softmax很多人会习惯性地在网络最后加softmax觉得输出的是概率更“好看”。但从训练稳定性角度这是错误的做法。PyTorch的交叉熵损失函数nn.CrossEntropyLoss()内部已经包含了softmax计算如果你在模型里先做了softmax相当于对概率再算一次对数损失不仅多此一举还会导致梯度计算不稳定。正确的做法是模型的最后一层输出叫做logits也就是未经过softmax的原始分数损失函数内部处理概率转换。只有在推理阶段需要查看概率分布时才单独调用softmax。3.2 激活函数的选择逻辑我在隐藏层使用了ReLU。为什么不选更经典的Sigmoid或Tanh关键在于梯度消失问题。Sigmoid函数在自变量很大或很小时导数值趋近于0。在深层网络中梯度需要从输出层逐层回传每经过一个Sigmoid层梯度幅度就要乘以一个接近0的数。层数一多梯度就指数级衰减到几乎为0网络再也学不动了。ReLU在正半轴的导数恒为1梯度可以无损地回传到前面的层这很大程度上缓解了梯度消失。但ReLU也有自己的问题Dead ReLU现象。当某个神经元的输入一直为负时ReLU的梯度为0权重永远得不到更新这个神经元就“死”了。解决办法有几种使用LeakyReLU变体或者在初始化时更小心。对于MNIST这种浅层网络ReLU的死亡问题影响不大因为网络层数少即使个别神经元失效其余神经元也足够学习特征。3.3 权重初始化策略权重初始化是个容易被忽视但实际影响很大的环节。PyTorch的nn.Linear默认会采用一种均匀分布的初始化方案范围取决于输入输出维度。对于全连接网络更常见的选择是Kaiming初始化也称He初始化它专门为ReLU这类非线性激活函数设计。def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, modefan_in, nonlinearityrelu) nn.init.constant_(m.bias, 0) model MLP() model.apply(init_weights)Kaiming初始化的核心思想是让每一层输出的方差在传播过程中保持稳定避免信号在深层网络中逐步放大或衰减。你可以简单理解为“给每一层的权重设定合适的初始尺度”如果初始化过大会导致激活值饱和过小则信号很快消失。用model.apply(init_weights)可以把初始化函数递归应用到所有子模块上这段代码建议在每次构建新模型后执行。4. 训练流程搭建与超参数调优实战4.1 损失函数与优化器配置训练的核心代码不长但每个组件都值得推敲。import torch.optim as optim model MLP() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)损失函数选择CrossEntropyLoss这点没有悬念。分类任务的标准配置就是它内部组合了LogSoftmax和NLLLoss数值稳定性更好。优化器我选择Adam而不是传统的SGD。这里我要说几句实在话Adam确实是目前最省心的优化器它自适应地调整每个参数的学习率对学习率的敏感度低基本不需要手动调整学习率策略。但如果你追求极致的准确率SGD配合动量在充分调参后表现往往更优。对于这个项目Adam的默认参数就能轻松跑到98%以上完全够用。学习率0.001是Adam的默认值也是我用下来最稳的一个起点。你可以试过0.01训练前期震荡比较剧烈准确率爬升明显更慢调到0.0001训练过程平稳但收敛速度太慢。4.2 完整训练循环代码解析def train_one_epoch(model, train_loader, criterion, optimizer, epoch): model.train() total_loss 0 correct 0 for batch_idx, (data, target) in enumerate(train_loader): optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() optimizer.step() total_loss loss.item() pred output.argmax(dim1, keepdimTrue) correct pred.eq(target.view_as(pred)).sum().item() if batch_idx % 100 0: print(fEpoch {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}] fLoss: {loss.item():.6f}) avg_loss total_loss / len(train_loader) accuracy 100. * correct / len(train_loader.dataset) return avg_loss, accuracy我想重点解释几个新手容易踩坑的点。第一个是optimizer.zero_grad()。PyTorch的梯度是累积的如果不清零每次backward()计算的梯度会叠加到上一次的梯度上。很多初学者忘记这一行导致loss出现奇怪的波动。我见过一个朋友调试了半天最后发现只是忘了清梯度。第二个是model.train()和model.eval()的切换。虽然全连接网络没有Dropout和BatchNorm时这两种模式行为一致但养成切换的习惯非常重要。等你以后写CNN或更复杂模型时这个切换直接影响模型行为。我习惯在每个epoch的训练前调用train()评估前调用eval()。第三个是output.argmax(dim1)。模型的输出是10个类别的logits取最大值的索引就是预测的类别编号。4.3 评估函数与准确率计算def evaluate(model, test_loader, criterion): model.eval() test_loss 0 correct 0 with torch.no_grad(): for data, target in test_loader: 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) return test_loss, accuracy这里最关键的是with torch.no_grad():。在评估阶段不需要计算梯度关闭梯度计算可以大幅减少内存占用同时加速推理。如果不加这个上下文管理器PyTorch默认会构建完整的计算图保存中间激活值白白浪费内存。测试集一共1万张图片我的经验是跑完一次评估大约需要1到2秒所以每个epoch结束后评估一次完全没有性能压力。4.4 超参数对训练效果的影响实测我把自己调参过程中的一组实测数据分享出来方便大家对照参考。用的是三层全连接网络784-128-128-10Adam优化器交叉熵损失训练10个epoch测试集准确率结果如下学习率批大小最终测试准确率收敛速度训练稳定性0.0016498.1%快稳定0.00112897.8%快稳定0.016497.5%很快前期震荡0.00016497.9%慢非常稳定0.00151296.9%中等稳定从这组数据可以看到学习率0.001搭配批大小64是性价比最高的组合。学习率0.01虽然前期收敛快但准确率上限反而略低因为步长太大容易在最优解附近震荡。批大小增大到512后准确率明显下降这是因为大批次会让梯度估计更加平滑但也会让优化过程更容易陷入尖锐的局部最小值。我还做过一个实验把隐藏层从两层增加到四层每层128个神经元。结果有趣测试准确率从98.1%小幅下降到97.8%。原因很可能是过拟合模型参数量增加了但训练数据量没变多余的表达能力学习到了训练集中的噪声。这个现象值得大家注意更深并不总是更好尤其在数据量有限的时候。5. 训练过程的可视化与结果分析5.1 Loss曲线与准确率曲线的洞察训练过程中的可视化是理解和调试模型的重要手段。我习惯在每个epoch结束后记录训练损失、训练准确率、测试损失、测试准确率全部画在一张图上import matplotlib.pyplot as plt def plot_training_history(history): fig, (ax1, ax2) plt.subplots(1, 2, figsize(12, 4)) epochs range(1, len(history[train_loss]) 1) ax1.plot(epochs, history[train_loss], labelTrain Loss) ax1.plot(epochs, history[test_loss], labelTest Loss) ax1.set_xlabel(Epoch) ax1.set_ylabel(Loss) ax1.legend() ax2.plot(epochs, history[train_acc], labelTrain Acc) ax2.plot(epochs, history[test_acc], labelTest Acc) ax2.set_xlabel(Epoch) ax2.set_ylabel(Accuracy (%)) ax2.legend() plt.show()训练好的模型在测试集上准确率通常在98%左右训练集准确率能达到99.5%以上。训练损失和测试损失之间的差距如果持续拉大说明模型开始过拟合。对于MNIST这个数据集因为任务本身相对简单即使过拟合测试准确率也不会掉太多但在真实项目中这个信号需要你高度重视。我观察到的一个典型现象是第一个epoch结束时测试准确率可能已经达到95%左右后面几个epoch提升非常缓慢。这说明手写数字分类这个任务确实简单前向传播和反向传播跑一遍模型就能学到大部分规律。真正拉开差距的是最后那几个百分点的优化空间。5.2 错误案例的可视化与归因分析准确率达到98%意味着测试集上大约有200张图片被认错。我建议把这些错误样本可视化出来看看对理解模型的行为非常有帮助。def show_misclassified(model, test_loader): model.eval() misclassified [] with torch.no_grad(): for data, target in test_loader: output model(data) pred output.argmax(dim1) for i in range(len(data)): if pred[i] ! target[i]: misclassified.append((data[i], target[i], pred[i])) fig, axes plt.subplots(2, 5, figsize(12, 5)) for i in range(10): img, true_label, pred_label misclassified[i] ax axes[i // 5][i % 5] ax.imshow(img.squeeze(), cmapgray) ax.set_title(fTrue: {true_label.item()}, Pred: {pred_label.item()}) ax.axis(off) plt.show()我跑过一次错误样本分析发现最常见的错误模式是数字4被识别成9、7被识别成2、3被识别成8。这些错误在人类看来也很容易混淆因为有些手写体的确写得模棱两可。这说明模型并没有“傻”它的错误是有意义上的混淆而非完全的随机猜测。还有一个有趣的发现错误样本中有不少图片的笔画特别潦草甚至人眼都需要仔细辨认。这一类“主观难度”本来就高的样本模型认错并不奇怪。通过这种方式你能直观地感受到模型的“认知边界”。5.3 模型参数与推理性能的量化分析训练完成后对模型做一次简单的量化评估也很有意义。我的三层全连接网络784-128-128-10参数量分布如下第一层784×128个权重加上128个偏置共100480个参数第二层128×128个权重加上128个偏置共16512个参数第三层128×10个权重加上10个偏置共1290个参数总计约11.8万个参数11.8万个参数在今天的深度学习模型里算是轻量级了。作为对比一个常见的CNN模型参数量往往在百万级别而大语言模型的参数量以十亿计。这种规模意味着什么单次前向传播只需要做约12万次乘加运算在CPU上处理一张图片耗时不足1毫秒。模型文件大小也只有几百KB。这带来一个直观感受解决MNIST这样的简单分类问题确实不需要特别复杂的模型。“参数越多越好”的观念在这个项目里是被实证否定的。6. 常见问题与排查技巧实录6.1 损失不下降的排查思路我在帮别人调试这个项目时遇到过的最典型问题就是“训练好几轮损失纹丝不动”。排查思路一般按顺序走先看数据预处理。如果忘了ToTensor()直接把原始PIL Image丢给模型nn.Linear接收到的输入维度是错的会直接报错。如果忘了Normalize()损失下降会明显变慢。再看学习率。学习率过小比如0.00001可能导致每步更新幅度极小损失下降在肉眼上看起来停滞。学习率过大比如0.1以上则可能导致损失直接变成NaN或者疯狂震荡。还要看损失函数和模型输出是否匹配。比如在模型里加了softmax又用CrossEntropyLoss虽然不会报错但损失下降会更慢效果也差一些。最后检查权重初始化。如果初始化不当比如所有权重初始化为0那么每个神经元的输出都一样梯度也一样网络永远学不出有区分度的特征。6.2 准确率上不去的瓶颈定位如果你的模型准确率卡在90%左右上不去首先怀疑模型表达力不足。可以把隐藏层神经元从128增加到256看看效果如果提升了说明原模型的容量不够。如果准确率能到97%但上不了98%很可能是超参数调优问题。回看我在前面列出的那张实验表格调整学习率和批大小往往能带来1到2个百分点的提升。还有一个被我多次踩过的坑训练和测试时的数据预处理不一致。比如训练时用了Normalize测试时忘了用数据分布对不上准确率会大幅下降。这类bug很隐蔽因为你看到代码逻辑每个环节都对但结果就是不对。6.3 训练速度优化的实用建议虽然是CPU也能跑的项目但速度优化仍然有价值。我推荐几个简单有效的手段第一优先使用GPU。把模型和数据都移动到CUDA上训练速度能提升几十倍。代码改动很小只需在模型和每批数据后调用.cuda()或.to(device)。第二增大批大小。批大小从64增加到256每个epoch的迭代次数减少整体训练时间缩短。代价是准确率可能小幅下降需要权衡。第三关闭梯度计算。评估阶段一定使用with torch.no_grad():这个习惯能显著减少内存占用和计算时间。第四如果使用PyTorch 2.x可以尝试torch.compile(model)。它会自动融合算子、优化内存分配对这种小模型的提升虽然有限但几乎零成本就能用上。6.4 训练重复性的稳定性管理深度学习训练有一个让人头疼的特性同样的代码跑两次结果不完全一样。原因在于随机性——权重的随机初始化、数据加载时的随机打乱。如果你想让实验可复现可以在代码开头固定随机种子import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)固定种子后同一台机器上运行两次应该能得到完全相同的结果。这对调试和理解超参数影响很有帮助因为你能排除随机性因素的干扰。7. 项目扩展方向与进阶思路7.1 从全连接到卷积的迁移路径跑通全连接网络之后最自然的下一步是把网络替换成CNN。代码改动不大但效果提升明显测试准确率通常能轻松超过99%。一个简单的CNN结构可以是卷积层1→32通道加ReLU加最大池化再卷积层32→64通道加ReLU加最大池化展平后接全连接层输出。torch.nn.Conv2d和torch.nn.MaxPool2d都是PyTorch自带组件替换模型类里的__init__和forward就行。通过对比全连接和CNN在MNIST上的表现差异你能够直观理解为什么在图像任务中CNN通常优于全连接网络——权重共享大幅降低了参数量局部感受野让模型更容易学到笔画、边缘等局部特征。7.2 正则化技巧的实战对比在原有模型基础上加一层Dropout设置p0.5在训练时随机丢弃一半的神经元。这会迫使网络学习更加鲁棒的特征而不是过度依赖某些特定的神经元组合。实际操作中加了Dropout之后训练集准确率会下降一些但测试集准确率往往保持稳定甚至略微提升这就是泛化能力增强的表现。我实测过不加Dropout的模型测试准确率98.1%加Dropout后约98.2%。提升不大但方向是对的。如果想看得更明显可以把隐藏层扩到256或512个神经元这时过拟合更严重Dropout的作用会更突出。另外可以尝试注意力机制的引入虽然传统技术但对于理解模型决策过程有很好的教育意义。把注意力权重可视化出来你能看到模型在识别不同数字时关注了哪些像素区域。7.3 将模型部署为可用服务训练完模型只是第一步把它用起来才是完整闭环。PyTorch提供了torch.jit.script和torch.onnx.export两种模型导出方式。ONNX格式是开放的模型交换格式可以被多种推理引擎加载比如ONNX Runtime在CPU上的推理速度比PyTorch原生执行更快。我还尝试过用FastAPI封装一个简单的HTTP接口接收图片字节流返回识别结果。整个过程不到100行代码但带来的成就感是完全不一样的——你写的东西不再只是终端里的一串数字而是可以被网页、小程序调用的真实服务。在实际部署中要注意两点导出模型时固定输入尺寸因为全连接网络要求输入必须是784维向量图片预处理必须和训练时完全一致生产环境建议用ONNX Runtime而不是直接加载PyTorch模型内存占用更低启动速度更快。8. 写在最后的实操心得这个项目我已经带过不少朋友入门自己也反复重写过多次。每一次重写都能发现一些新的细节比如某个API的新写法、某个训练技巧对结果的影响。我个人的体会是MNIST作为“Hello World”最大的价值不是让你学会调参到99%以上而是让你把整个深度学习的工作流跑通一遍。数据加载、模型构建、训练循环、评估可视化、错误分析这一套流程在任何真实项目中都是同构的。你在MNIST上犯的错误、排过的坑换个数据集、换个模型大概率还是会遇到。最后分享一个小技巧把训练好的模型保存下来文件名用mnist_mlp.pth代码里加一行torch.save(model.state_dict(), mnist_mlp.pth)就行。以后想复现结果或者做部署演示不用重新训练直接加载模型权重即可。加载的代码也很简单model MLP() model.load_state_dict(torch.load(mnist_mlp.pth)) model.eval()这个项目后续的扩展空间其实很大比如尝试不同的激活函数、对比不同优化器的收敛曲线、加入学习率衰减策略、使用交叉验证选择超参数。每改动一个变量都值得记录实验结果积累自己的经验库。等积累到一定程度你会发现自己对神经网络的理解已经远超“能跑通代码”的层面而是真正知道每一步为什么这么做、出了问题该从哪里找原因。
阅读完成 · 觉得有帮助?
咨询建站