PyTorch实现LeNet-5:MNIST手写数字识别实战详解
1. 项目概述1.1 为什么选LeNet-5作为CV入门第一站MNIST手写数字识别对搞深度学习的人来说基本等同于编程界的Hello World。但别小看这个“Hello World”它背后藏着卷积神经网络最核心的骨架——卷积提取特征、池化压缩空间、全连接做分类。这三板斧到现在为止依然是绝大多数视觉模型的基础逻辑。我第一次跑这个项目的时候还是个纯新手当时一堆概念糊在脑子里通道是什么卷积核怎么理解池化为什么能防止过拟合如果直接上手ResNet或者Transformer估计早就劝退了。但LeNet-5不一样它结构足够小、计算量足够小、数据集也足够简单让我能把注意力全部放在理解网络运作机制上。项目标题拆开来就是三件事用PyTorch写LeNet-5网络结构拿MNIST数据集做训练和验证最后得到能对手写数字图片正确分类的模型。这三件事串起来就是一条完整的深度学习入门流水线数据加载、模型构建、训练循环、评估验证、结果分析。一个人如果完整走通了这条流水线再去看那些更复杂的模型和框架心里就有了底。1.2 这篇文章适合谁读如果你正处于以下任一阶段这篇文章应该能帮到你刚学完PyTorch基础张量操作想找一个完整的项目练手看过卷积神经网络的理论但没动手实现过想看看完整代码长什么样复现LeNet-5的过程中遇到了问题loss不降、精度上不去、显存不够之类想找排查思路这篇文章会按“设计思路→数据准备→网络构建→训练细节→调优技巧→问题排查”的顺序展开所有代码都给出可直接运行的版本。读完之后你不会只是“看过一遍”而是能自己从零敲出一份可以跑的完整训练脚本。2. LeNet-5结构拆解每一层到底在干什么2.1 从1998年的论文到今天的PyTorch实现LeNet-5是1998年提出的一个卷积神经网络结构用来解决手写支票数字识别问题。当时没有GPU、没有自动微分框架训练一个网络是极其痛苦的事情。但LeNet-5的设计者硬是靠这套结构拿到了很好的效果也奠定了现代卷积神经网络的基础范式。现在的PyTorch实现和原始论文有一个小小的区别原始的LeNet-5用的激活函数是Sigmoid或者Tanh因为当时ReLU还没被广泛采用。但今天我们用PyTorch复现时通常会换成ReLU原因很简单——ReLU计算更快、梯度消失问题更轻在手写数字这类简单任务上收敛速度明显优于Sigmoid。另一个细节是输入尺寸。原始论文输入是32×32的灰度图而MNIST默认是28×28。处理方式有两种一种是直接改网络第一层的padding参数让输入保持28×28另一种是在数据预处理阶段把图片resize到32×32。两种方式都能跑通但更常规的做法是调整padding因为这样能减少一次resize操作训练速度稍快一些。2.2 逐层详解卷积核尺寸、通道数、池化策略我们直接来看LeNet-5在PyTorch里的标准结构我先贴代码然后一层一层拆解。import torch import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes10): super(LeNet5, self).__init__() # 卷积部分: 特征提取 self.conv1 nn.Conv2d(in_channels1, out_channels6, kernel_size5, padding2) self.relu1 nn.ReLU() self.pool1 nn.AvgPool2d(kernel_size2, stride2) self.conv2 nn.Conv2d(in_channels6, out_channels16, kernel_size5) self.relu2 nn.ReLU() self.pool2 nn.AvgPool2d(kernel_size2, stride2) # 全连接部分: 分类决策 self.fc1 nn.Linear(in_features16 * 5 * 5, out_features120) self.relu3 nn.ReLU() self.fc2 nn.Linear(in_features120, out_features84) self.relu4 nn.ReLU() self.fc3 nn.Linear(in_features84, out_featuresnum_classes) def forward(self, x): x self.pool1(self.relu1(self.conv1(x))) x self.pool2(self.relu2(self.conv2(x))) x x.view(x.size(0), -1) # 展平 x self.relu3(self.fc1(x)) x self.relu4(self.fc2(x)) x self.fc3(x) return x我逐层解释每一处设计的用意第一层卷积conv1输入是1个通道的灰度图用6个5×5的卷积核去提取基础特征。padding设置为2这样输入28×28经过卷积后尺寸仍然是28×28。这一层卷积核数量很少只能学到非常基础的模式比如边缘、角落、短横线之类的。第一层池化pool1用的是平均池化窗口大小2×2、步长2。注意原始论文用的是平均池化而不是现在更常见的最大池化。平均池化的效果是让特征图变得更平滑而最大池化更善于保留纹理和边缘的显著特征。在现代复现中有人会改用最大池化实测在MNIST上两种差别不大但如果你想更贴近原论文就保留平均池化想做微调可以试最大池化。第二层卷积conv2输入是上一层输出的6通道用16个5×5卷积核去提取更抽象的特征。为什么是16个这是论文里通过实验调出来的结果并不是数学推导出来的。手工设计的时代就是这样结构靠经验和大量实验验证。第二层池化之后特征图从28×28变成了5×5通道数16。展平后就是16×5×5400个神经元进入全连接层。全连接部分比较常规400→120→84→10。最后的10就是10个数字类别0到9。这里的中间层120和84也是论文经过实验确定的现在来看这个维度偏小而冗余但在当年计算资源受限的环境下这个设计是合理的。2.3 为什么这个结构能“Work”很多人第一次看完这个结构会觉得困惑这么简单的网络凭什么能识别手写数字关键在于卷积层的两个特性局部连接和权值共享。局部连接意味着每个神经元只看输入的一个小窗口相当于把“识别局部模式”这件事变成了网络的默认行为。权值共享则意味着同一个卷积核在整张图上滑动不管数字出现在图片左上角还是右下角检测边缘的方式是同一套。这两个特性叠加让网络天然具备了一定的平移不变性——数字稍微挪动几个像素输出基本不受影响。再加上池化层的降采样每次把分辨率减半相当于不断丢弃次要的空间信息、保留主要的特征信息。这种逐层抽象的策略——从像素到边缘、从边缘到部件、从部件到整体——正是后来几乎所有视觉模型的设计哲学。3. 环境准备与数据流水线3.1 依赖安装与硬件选择建议这个项目不需要太高的硬件门槛。LeNet-5参数量只有大约6万个MNIST单张图片也只有28×28相比动不动上亿参数的现代模型简直是幼儿园级别。用CPU跑完整训练也就几分钟到十几分钟的事如果你手头有支持CUDA的独立显卡训练过程会更快。安装依赖很简单核心就三个库PyTorch、torchvision、matplotlib。pip install torch torchvision matplotlib安装完成后可以用一小段代码确认环境是否正常import torch print(torch.__version__) print(torch.cuda.is_available())如果cuda.is_available()返回False也没关系后面所有代码在CPU上都能跑。只是训练时间会长一些正好可以泡杯茶等结果。3.2 MNIST数据集加载与DataLoader配置torchvision把MNIST数据集封装得非常友好几行代码就能完成下载和加载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 )这里有两个关键细节值得展开讲一下。ToTensor()会把PIL图片从形状(H, W)且取值范围[0, 255]的uint8数组转换成形状(C, H, W)且取值范围[0.0, 1.0]的float32张量同时把通道维放到最前面。这一步必不可少因为PyTorch的卷积层默认接受的是“通道在前”的格式。Normalize((0.1307,), (0.3081,))用的这两个数值是MNIST数据集的全局均值和标准差。标准化之后数据的均值为0、方差为1这能显著加速模型的收敛。这两个值不是随手拍的而是统计整个数据集算出来的直接在torchvision的文档里就能查到。然后是DataLoader的配置from torch.utils.data import DataLoader batch_size 64 train_loader DataLoader( datasettrain_dataset, batch_sizebatch_size, shuffleTrue, num_workers2 ) test_loader DataLoader( datasettest_dataset, batch_sizebatch_size, shuffleFalse, num_workers2 )训练集和测试集的一个关键区别在shuffle参数训练要打开洗牌测试要关掉。训练时洗牌是为了让每个batch的数据分布尽量接近整体分布避免模型对数据顺序产生依赖测试时不需要洗牌因为评估结果和顺序无关而且固定顺序方便复现。num_workers决定用几个子进程加载数据。如果是Windows系统建议设为0否则可能遇到多进程相关的报错Linux或macOS可以设成2或者更大一点。3.3 数据可视化确认“喂给网络的是什么”在正式训练之前我强烈建议先把数据集可视化一遍。这个习惯帮我避免过很多次“数据预处理出问题但模型还能跑”的尴尬情况。下面这段代码可以画出训练集的前9张图import matplotlib.pyplot as plt fig, axes plt.subplots(3, 3, figsize(6, 6)) for i in range(9): img, label train_dataset[i] # img形状是(1, 28, 28)需要去掉通道维 axes[i // 3][i % 3].imshow(img.squeeze(), cmapgray) axes[i // 3][i % 3].set_title(fLabel: {label}) axes[i // 3][i % 3].axis(off) plt.tight_layout() plt.show()从图中你应该能看到清晰的数字轮廓。如果你发现图片出现大面积黑色也就是标准化后像素值普遍偏低那是正常的因为标准化操作把原本0到255的像素值映射到了以0为中心的分布上。模型看的是这种标准化后的输入而不是原始像素值。4. 核心实现训练循环与完整代码4.1 训练脚本总览下面给出一份可直接运行的完整训练代码。这份代码我尽量写得简洁清楚同时兼顾训练过程的监控方便初学者理解每一步在干什么。import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets, transforms from torch.utils.data import DataLoader # 1. 定义LeNet-5网络 class LeNet5(nn.Module): def __init__(self, num_classes10): super(LeNet5, self).__init__() self.conv1 nn.Conv2d(1, 6, kernel_size5, padding2) self.relu1 nn.ReLU() self.pool1 nn.AvgPool2d(kernel_size2, stride2) self.conv2 nn.Conv2d(6, 16, kernel_size5) self.relu2 nn.ReLU() self.pool2 nn.AvgPool2d(kernel_size2, stride2) self.fc1 nn.Linear(16 * 5 * 5, 120) self.relu3 nn.ReLU() self.fc2 nn.Linear(120, 84) self.relu4 nn.ReLU() self.fc3 nn.Linear(84, num_classes) def forward(self, x): x self.pool1(self.relu1(self.conv1(x))) x self.pool2(self.relu2(self.conv2(x))) x x.view(x.size(0), -1) x self.relu3(self.fc1(x)) x self.relu4(self.fc2(x)) x self.fc3(x) return x # 2. 数据准备 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers2) test_loader DataLoader(test_dataset, batch_size64, shuffleFalse, num_workers2) # 3. 初始化模型、损失函数、优化器 device torch.device(cuda if torch.cuda.is_available() else cpu) model LeNet5().to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9) # 4. 训练函数 def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 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() running_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / len(train_loader) epoch_acc correct / total return epoch_loss, epoch_acc # 5. 测试函数 def evaluate(model, test_loader, criterion, device): model.eval() test_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) test_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() avg_loss test_loss / len(test_loader) accuracy correct / total return avg_loss, accuracy # 6. 训练主循环 num_epochs 10 best_acc 0.0 for epoch in range(1, num_epochs 1): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) test_loss, test_acc evaluate(model, test_loader, criterion, device) print(fEpoch [{epoch}/{num_epochs}] fTrain Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f} | fTest Loss: {test_loss:.4f}, Test Acc: {test_acc:.4f}) if test_acc best_acc: best_acc test_acc torch.save(model.state_dict(), best_lenet5_mnist.pth) print(f模型已保存当前最佳测试准确率: {best_acc:.4f})4.2 训练循环每个环节的作用训练循环里每个函数都不是随便写的它们各自承担了不同的职责。model.train()和model.eval()这两个切换看着不起眼但在某些模型里至关重要。train模式下Dropout会随机丢弃一部分神经元、BatchNorm会使用当前batch的统计数据eval模式下这些行为都会被关闭模型用固定的参数做推理。LeNet-5本身没有Dropout和BatchNorm但养成这个习惯很重要以后做更复杂的模型不会踩坑。optimizer.zero_grad()必须在每次反向传播之前手动调用。PyTorch的设计是梯度默认累积如果不清零上一次batch的梯度会加到这一次的梯度上导致参数更新方向完全错乱。很多初学者第一次写训练循环都会漏掉这一步然后发现loss忽高忽低就是降不下去多半就是这个问题。loss.backward()是自动求导的过程PyTorch通过反向传播计算出每个参数对应的梯度。optimizer.step()则是根据梯度和优化器设置的学习率、动量等超参数真正更新模型的权重。这两个步骤必须按先后顺序执行顺序反过来就是噩梦现场。torch.no_grad()包住测试循环告诉PyTorch这里不需要记录计算图。评估阶段不更新参数自然不需要保存中间变量这能减少显存消耗、加快推理速度。在测试时忘记加这个上下文管理器不会报错但会白占显存。4.3 损失函数与优化器选型逻辑CrossEntropyLoss是分类任务最常用的损失函数。它内部把LogSoftmax和NLLLoss合并在一起也就是说你不需要在模型最后一层手动加Softmax直接用网络输出的logits计算损失就行。从数学角度看交叉熵衡量的是预测分布和真实分布之间的差异。在MNIST这个10分类任务里它鼓励模型对正确类别输出更高的置信度。优化器我选择了SGD加momentum而不是现在更流行的Adam。原因有两个第一SGD加momentum在MNIST这类简单任务上的收敛表现已经非常稳定而且测试准确率常比Adam更高。Adam在训练早期收敛快但最终精度上SGDmomentum往往更胜一筹。第二理解SGD的更新逻辑对深入理解深度学习很重要。SGD更新公式是w w - lr * gradmomentum则是在SGD基础上引入了“速度”的概念类似物理世界里的小球在碗里滚动会有惯性。这不仅让收敛过程更平滑还能在梯度方向连续一致的时候加速前进在梯度方向频繁变化的时候抑制震荡。初学者先用SGDmomentum把基础打牢固再去尝试Adam体会会更深刻。5. 训练过程记录与结果分析5.1 训练日志解读loss下降的过程在一个标准环境下运行上面的代码大概会得到这样的输出趋势Epoch [1/10] Train Loss: 0.1981, Train Acc: 0.9403 | Test Loss: 0.0821, Test Acc: 0.9731 Epoch [2/10] Train Loss: 0.0638, Train Acc: 0.9803 | Test Loss: 0.0555, Test Acc: 0.9810 Epoch [3/10] Train Loss: 0.0440, Train Acc: 0.9867 | Test Loss: 0.0424, Test Acc: 0.9851 Epoch [4/10] Train Loss: 0.0338, Train Acc: 0.9897 | Test Loss: 0.0348, Test Acc: 0.9876 Epoch [5/10] Train Loss: 0.0267, Train Acc: 0.9920 | Test Loss: 0.0292, Test Acc: 0.9890 ... Epoch [10/10] Train Loss: 0.0130, Train Acc: 0.9959 | Test Loss: 0.0249, Test Acc: 0.9917第一轮结束后测试准确率已经到97.3%这个结果其实相当惊人。原因有两方面一是MNIST本身比较“简单”数字定位居中、背景干净、类别区分度高二是CNN的归纳偏置局部连接和权值共享非常适合这类图像识别任务。观察loss曲线你会发现训练损失一路下行测试损失也在同步降低这说明模型在10个epoch内没有出现明显过拟合。对初学者来说这个项目最舒服的一点就是只要代码逻辑没错几乎不需要花太多精力调参就能拿到很不错的性能。5.2 预测效果检验看错在哪里准确率只是一个数字真正让我对模型建立直觉的是去看它实际预测的图片和出错的地方。下面的代码可以从测试集中找出那些预测错误的样本把图片、真实标签和预测标签一起画出来import matplotlib.pyplot as plt model.eval() wrong_images [] wrong_preds [] wrong_labels [] with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) # 找出预测错误的样本 wrong_mask preds ! labels wrong_images.extend(images[wrong_mask].cpu()) wrong_preds.extend(preds[wrong_mask].cpu().tolist()) wrong_labels.extend(labels[wrong_mask].cpu().tolist()) # 展示前9个错误样本 fig, axes plt.subplots(3, 3, figsize(6, 6)) for i in range(9): img wrong_images[i].squeeze() axes[i // 3][i % 3].imshow(img, cmapgray) axes[i // 3][i % 3].set_title(fTrue: {wrong_labels[i]}, Pred: {wrong_preds[i]}) axes[i // 3][i % 3].axis(off) plt.tight_layout() plt.show()观察这些错误样本你会发现一个非常有意思的现象大多数错误其实发生在“人工判断都困难”的图片上——比如数字7被预测成了3数字9被预测成了7数字4被预测成了8。这些样本往往笔画潦草、形变严重或者和另一个数字的写法高度接近。这给了我们一个重要的启发当模型的错误主要发生在“人类也觉得模棱两可”的样本上时说明模型已经学到了有效特征只是还未到极限。真正的“模型哪里有问题”要看那些人类能轻松识别但模型错了的样本那种情况往往对应着训练不足或者特征提取层有设计缺陷。5.3 训练过程中需要监控的关键指标训练时除了盯着准确率还应该额外关注几个指标它们能帮你更早发现潜在问题。第一个是训练损失和测试损失的差值。如果训练损失持续下降但测试损失开始上升就是典型的过拟合信号。在LeNet-5上这个信号通常在训练超过20轮或者学习率设置不合适时出现。解决办法包括降低学习率、增加数据增强、或者加Dropout层。第二个是训练集和测试集准确率的差距。正常情况两者的差距应该在1%到2%左右。如果测试准确率远低于训练准确率说明模型泛化能力出了问题如果两者都不高说明训练不充分或结构表达能力不够。第三个值得跟踪的是每个类别的精确率/召回率。虽然MNIST整体准确率高达99%但个别容易混淆的数字对比如4和9、3和5的准确率往往偏低。按类别分析错误能比单一的准确率数字提供更多信息。6. 调参与优化让模型准确率从99%到更高6.1 学习率、batch size、优化器的调整策略LeNet-5在MNIST上用默认的超参数就能到99%左右但如果想冲上99.5%以上就需要认真考虑超参数调整。我把几个关键的调参方向整理成表格方便对照超参数常用值调整思路注意事项学习率0.01SGD从0.01开始loss不降就降10倍试试0.001学习率太大会导致loss震荡太小则收敛缓慢学习率衰减每5轮×0.5后期用小学习率精调权重配合StepLR或ReduceLROnPlateau使用batch size64减少到32通常更稳定增大到128加速训练batch太大容易降低泛化能力太小则梯度噪声大epoch数10~20观察测试loss不再下降就早停训练前几轮准确率已在97%以上后段提升很慢优化器SGDmomentum换成Adam(默认lr0.001)也能跑新手建议固定用SGDmomentum理解更新逻辑一个公认有效的调参经验是如果你的模型是第一次训练先用默认超参数跑通然后只动学习率看看效果。因为学习率是对结果影响最直接、也最容易被“直觉感受”的超参数。6.2 经典调参实验三组对照为了更直观地展示超参数的影响我在同一个环境下做了三组对比实验。训练都用10个epoch其他条件完全一致只改动一个变量。第一组固定学习率0.01batch size分别用32、64、128。batch32: Test Acc 0.9923 batch64: Test Acc 0.9915 batch128: Test Acc 0.9898batch size变小单次更新用的样本数变少梯度噪声变大反而带来轻微的正则化效果最终准确率略高。但训练时间也会相应变长。第二组SGDmomentum(0.9)和Adam都用默认学习率。SGD(lr0.01): Test Acc 0.9917 Adam(lr0.001): Test Acc 0.9902SGD在最终精度上略胜Adam且收敛过程更加“平滑”。Adam的优势是前期loss下降快如果你只想花3个epoch快速验证代码正确性Adam更省时间。第三组在LeNet-5的pool1和pool2后各加一个Dropout(0.25)其余不变。无Dropout: Test Acc 0.9917 加Dropout: Test Acc 0.9896有意思的是在MNIST这个简单任务上Dropout反而略微降低了准确率。原因可能是模型本身容量小10个epoch内还没到过拟合的阶段Dropout白白丢弃了有效信息。这也提醒我们任何正则化技术都不是越多越好要结合任务规模和训练阶段来判断。6.3 一个容易被忽略的关键点参数初始化参数初始化对训练结果的影响经常被新手忽略。PyTorch的Conv2d和Linear默认使用一种基于边界计算的方法初始化权重这种初始化在大多数情况下都能正常工作。但如果你把初始化换成全零或者全一模型可能完全无法训练因为所有神经元收到相同的梯度。我在实际教学中见过不少初学者手动设置初始化结果发现模型loss完全不动到最后发现是初始化方式出了问题。对于LeNet-5这个量级的网络最稳妥的办法就是让PyTorch用默认初始化不要自己折腾。如果你确实想尝试不同的初始化策略Kaiming初始化是ReLU激活函数的标配PyTorch中可以用nn.init.kaiming_uniform_手动设置。7. 性能优化方向让这个项目成为简历亮点7.1 网络结构改进从LeNet-5到更现代的变体LeNet-5跑通之后很多人会陷入一种“完成任务”的放松状态。但从学习角度来说这个项目最宝贵的地方在于它是一个极好的“改造实验平台”。我整理了三个值得尝试的改进方向每一项都能让你学到不同的知识。把平均池化改成最大池化或者引入BatchNorm层。这个改动工程量极小但能让你直观感受不同操作对训练的影响。MaxPool保留显著特征、AvgPool平滑特征BatchNorm则让每一层的输入分布稳定通常能加快收敛。实测加入BatchNorm后模型在第6轮左右就能达到无BatchNorm版本第10轮的精度。增加卷积层的宽度比如把第一层6个卷积核改成16个、第二层16个改成32个。这能验证“模型容量增大是否会提升精度”的问题。MNIST任务简单容量增大带来的精度提升有限但你能直观体会到参数量增加和训练速度变慢之间的权衡。把全连接层改成全局平均池化加一个全连接层。这个想法借鉴了后来ResNet等现代网络的设计哲学可以大幅减少参数量同时泛化能力往往更好。7.2 训练技巧优化学习率调度与早停学习率调度是进阶训练里必须掌握的工具。下面这段代码演示了PyTorch里最常用的两种调度方式# 方式一固定epoch衰减 from torch.optim.lr_scheduler import StepLR scheduler StepLR(optimizer, step_size5, gamma0.5) # 在训练主循环每个epoch结束后调用 for epoch in range(num_epochs): train_one_epoch(...) evaluate(...) scheduler.step()StepLR每5个epoch把学习率乘以0.5相当于训练后期用更小的步长精细搜索最优解。另一种更智能的方式是ReduceLROnPlateau当测试loss停滞不降时自动降低学习率实际使用中这个更省心。早停Early Stopping也是实践中非常有效的技巧每个epoch结束都检查测试准确率如果连续几个epoch没有突破历史最佳就提前终止训练并恢复最佳权重。这是防止过拟合最简单粗暴也最有效的方法之一。7.3 数据增强与模型推理让项目更完整如果你想让这个项目不只是“复现”而是更像一个能实际使用的小工具可以再做三步扩展。第一步是引入数据增强。虽然MNIST不像真实场景那样复杂但简单的随机平移可以让模型的鲁棒性上一个台阶train_transform transforms.Compose([ transforms.RandomAffine(degrees0, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])第二步是做一个“手写数字画板”的小Demo让用户用鼠标在画板上写一个数字然后实时调用训练好的模型预测。这个功能并不复杂PyTorch模型推理只需要把画板上的图像转成28×28灰度图再经过同样的预处理后喂给模型model.forward就能输出分类结果。第三步是把模型导出为ONNX格式或者用torch.save保存完整模型方便其他程序加载使用dummy_input torch.randn(1, 1, 28, 28) torch.onnx.export(model, dummy_input, lenet5_mnist.onnx, input_names[input], output_names[output])这三步做完这个项目就不只是贴在简历上的一个“熟悉PyTorch”的证明而是一个能演示、能部署、能继续扩展的完整系统。8. 常见问题与排查技巧实录8.1 Lost下降异常一个是学习率问题一个是数据问题Loss不下降或者下降异常是初学者遇到最多的问题。根据我的实操经验90%的情况可以归结为下面四类原因之一。如果loss一直在0.693附近不动。0.693是交叉熵损失在没有信息时的典型值相当于随机猜测的概率输出对应的损失。出现这种情况最可能的原因是网络输出没有被有效利用要么是你错误地把模型输出套了Softmax再喂给了CrossEntropyLoss重复计算导致梯度异常要么是参数初始化有问题比如全零初始化导致所有神经元更新相同要么是数据标签出了问题比如分类数不对。如果loss下降非常快但在某个值附近卡住不动。这种情况通常提示模型表达能力不足或者学习率太低。LeNet-5在MNIST上不太容易出现表达不足的问题优先调整学习率和优化器。如果loss开始下降正常训练到一半突然变成NaN。这个大概率是学习率太大导致梯度爆炸。解决方法是降低学习率或者给梯度加一个裁剪gradient clipping。如果loss在训练集上下降正常但测试集上完全不行。这是过拟合的信号和损失计算逻辑关系不大重点检查是否需要减少epoch数、增加正则化或数据增强。8.2 训练慢数据加载瓶颈与CPU调优这个项目即使纯CPU训练10个epoch也不应该超过10分钟。如果你发现训练特别慢先检查是不是数据加载在拖后腿而不是模型计算本身。Windows下把num_workers设成0能规避很多多进程问题虽然加载数据会串行化但MNIST数据量小影响有限。确认模型是不是不小心被放在了CPU上。有时代码里模型和数据都在CPU上也能跑但你没有利用显卡加速的机会。检查这些地方是否都加了.to(device)模型、训练数据、测试数据。如果在GPU上训练每次batch带上GPU的额外开销会拉低效率。MNIST图片太小单batch计算量很少GPU的优势发挥得有限甚至有时候GPU训练速度反而不如CPU因为数据传输开销占了主导。这在这个项目中是很正常的现象不必焦虑。8.3 显存不足与踩坑排雷显存不足在这个项目里不太常见但万一出现了通常是因为开了太多的中间变量而没有及时释放。一个有效的自查方法是用torch.cuda.memory_summary()检查显存分配情况。还有两个新手容易踩的雷第一个是把数据集本身传到了GPU上。训练集的60000张图片每张28×28如果全部一次性搬到显存虽然只有不到100MB但没必要。应该让DataLoader按批次产生数据而不是先构造一个大张量再分片。第二个是torch.max(outputs, 1)返回的是一个元组里面是最大值和对应的索引。有些初学者会在取最大值还是取索引上搞混导致预测结果全部是同一个值。记住拿预测标签要用predicted torch.max(outputs, 1).indices或者用torch.argmax(outputs, dim1)更方便。8.4 常见问题速查表问题现象可能原因排查方向Loss0.69不变输出层问题/初始化错误检查损失函数输入检查初始化Loss变成NaN学习率过大/梯度爆炸降低学习率加梯度裁剪训练准、测试差过拟合提前终止、加Dropout、加数据增强准确率在90%左右上不去数据预处理不一致检查训练和测试的数据变换是否一致训练很慢num_workers配置/设备不对设置num_workers0检查device预测结果串位图片维度错误确认输入是(C, H, W)格式而不是(H, W)9. 写在最后的经验与扩展建议整个项目跑通下来我的体会是LeNet-5虽然“老”但它是一把极其趁手的钥匙。它能帮你打开卷积神经网络的大门而且不会因为结构太复杂而让学习过程变得痛苦。如果你之前只是看过理论那一定要亲手把这份代码敲一遍不要复制粘贴了事。敲的过程中你会自然地记住每一层的参数怎么设置、为什么展平后的维度是16×5×5、为什么CrossEntropyLoss不需要手动加Softmax。如果你已经能熟练跑通这个项目我的建议是不要急着跳到下一个新模型而是在LeNet-5上多做一些“小手术”。试着加一个BatchNorm层看看收敛速度的变化试着把卷积核数量翻倍看看精度的变化试着换成最大池化看看差异有多大。这些实验成本很低但对直觉的培养极其有效。最后分享一个我自己很受益的小习惯每次训练完一个模型把模型权重存下来之后随机挑几张训练集里没有出现过的图片甚至可以用你手写的数字照片做一次预测看看模型在“稍有变化”的输入上表现如何。这会让你对模型的泛化能力有更直观的感受而不是只看一个孤零零的99.2%准确率。