1. 为什么LeNet-5依然是动手学CNN最值得跑的入门项目
如果只让我推荐一个深度学习入门实战项目,我不会推那些花哨的Transformer或者大模型微调,而会告诉你:用PyTorch实现LeNet-5并在MNIST手写数字数据集上完成分类。这个项目我第一次跑通是在好几年前,之后每次带新人入门,我都会让他们先把这个项目完整走一遍。原因很简单:它麻雀虽小,但五脏俱全——CNN的卷积、池化、全连接、训练、评估、调参,所有核心环节一个不少。
先说这个项目到底解决的是什么问题。MNIST数据集是机器学习领域最经典的基准数据集之一,包含0到9共10个类别的手写数字灰度图片,每张图片尺寸是28x28像素。任务就是训练一个模型,输入一张手写数字图片,输出它对应的数字类别。LeNet-5是卷积神经网络的开山之作,早在1998年就被提出用于手写数字识别,当年在MNIST上已经能达到99%左右的准确率。用PyTorch把这样一个经典结构复现出来,你会真正理解CNN中每个组件为什么存在、参数怎么流动、维度怎么变化——这些理解是直接调用现成模型库永远得不到的。
这篇文章适合两类读者:一类是刚学完PyTorch基础语法、想跑通第一个完整CNN项目的初学者;另一类是已经能跑通一些现成模型,但想回过头来吃透经典网络结构细节的同学。我会从网络结构逐层拆解、数据加载细节、训练循环实现、测试评估、再到调优实录,完整走一遍我的实现过程,所有代码都可以直接照着写。
2. 网络结构逐层拆解:每个参数都有来龙去脉
2.1 从输入到输出:28x28图片在LeNet-5中经历的变化
LeNet-5的原始结构共7层,但原始设计输入是32x32。MNIST图片是28x28,所以实际实现时有两个选择:一是将MNIST图片padding到32x32,严格还原原始结构;二是在PyTorch实现中直接按28x28来调整第一层卷积的padding。我建议新手选择第二种,因为实现更直接,代码也更简洁,而核心的卷积池化结构保持不变。
先说清楚一张图片在完整网络中的维度变化。我用PyTorch的写法来展示每一层的变化:
import torch import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super(LeNet5, self).__init__() # C1: 卷积层,6个5x5卷积核,输入1通道,输出6通道 self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, padding=2) # S2: 池化层,2x2最大池化 self.pool1 = nn.AvgPool2d(kernel_size=2, stride=2) # C3: 卷积层,16个5x5卷积核 self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5) # S4: 池化层 self.pool2 = nn.AvgPool2d(kernel_size=2, stride=2) # C5: 全连接层,将16x5x5拉伸为400维,映射到120 self.fc1 = nn.Linear(16 * 5 * 5, 120) # F6: 全连接层,120 -> 84 self.fc2 = nn.Linear(120, 84) # 输出层:84 -> 10 self.fc3 = nn.Linear(84, num_classes) def forward(self, x): x = self.pool1(torch.relu(self.conv1(x))) x = self.pool2(torch.relu(self.conv2(x))) x = x.view(x.size(0), -1) x = torch.relu(self.fc1(x)) x = torch.relu(self.fc2(x)) x = self.fc3(x) return x输入是(batch_size, 1, 28, 28),经过conv1(padding=2保持尺寸不变,还是28x28),再经过2x2池化变成14x14,此时有6个通道。conv2是5x5卷积,无padding,从14x14变到10x10,通道数从6变到16。再池化一次,变成5x5,16个通道。展平后就是16×5×5=400维向量,后面接三个全连接层,最后输出10维向量对应10个数字类别。以上每一步,你在实现时可以随时打印x.shape验证,跑一遍基本就不会对维度产生迷惑。
2.2 激活函数:为什么现代实现把sigmoid换成了ReLU
原始LeNet-5在卷积层和全连接层之间使用的激活函数是tanh或sigmoid。我在实际实现中用的是ReLU。这不是偷懒,而是有明确理由:ReLU计算更快、梯度在正区间恒为1,能有效缓解深层网络中的梯度消失问题。
从实际效果看,在MNIST这个任务上,用ReLU和用tanh的最终准确率差距不大,可能都在99%左右,但ReLU训练收敛明显更快。我自己对比过,同样的超参数下,ReLU大约在第3到4个epoch就能达到95%以上,而tanh可能要慢上一两个epoch。而且PyTorch中torch.relu与F.relu直接可用,实现成本几乎为零。
需要提醒的是,LeNet-5原论文中S2和S4用的是平均池化(Average Pooling),而很多现代教程喜欢换成最大池化(Max Pooling)。两种我都实际跑过。在MNIST上,最大池化往往比平均池化最终准确率略高且收敛更稳定,这跟图像中边缘和关键纹理的激活值分布有关。我的建议是:新手先用最大池化跑通,稳定性更好;等对网络理解深入后,可以再切换回平均池化对比效果。
2.3 参数量计算:这个网络到底有多少个可学习参数
理解一个网络最好的方式之一是亲手算一遍参数量。很多初学者跑通代码后对网络规模完全没有概念,这是不行的。
按照上面实现的LeNet-5,我帮你逐层计算一遍总参数量:
- conv1:6个5x5卷积核,输入通道1,每个核有5×5+1(bias)=26个参数,共6×26=156个参数
- conv2:16个5x5卷积核,输入通道6,每个核有6×5×5+1=151个参数,共16×151=2416个参数
- fc1:400×120+120=48120个参数
- fc2:120×84+84=10164个参数
- fc3:84×10+10=850个参数
总和就是156+2416+48120+10164+850=61706个参数,约6.2万个。这个数字值得记住,因为对比现在动辄几亿、几十亿参数的模型,LeNet-5的规模让你可以毫无压力地在CPU上完成训练,这也是它作为入门项目不可替代的优势之一。
我自己写过一个简单函数来确认参数总量:
model = LeNet5() total_params = sum(p.numel() for p in model.parameters()) print(f"Total parameters: {total_params}") # Output: Total parameters: 61706跑完这个打印,你就能很直观地感受到:一个精巧的结构设计可以只用6万参数解决一个10分类视觉任务,这对建立对模型设计的直觉非常有价值。
3. 数据加载与预处理:不容忽视的0.1307和0.3081
3.1 归一化为什么是0.1307和0.3081
MNIST数据集在PyTorch中通过torchvision.datasets.MNIST可以直接下载和使用。几乎所有官方教程中都会出现下面这段代码:
transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = torchvision.datasets.MNIST( root='./data', train=True, transform=transform, download=True ) test_dataset = torchvision.datasets.MNIST( root='./data', train=False, transform=transform )很多初学者到这一步都是直接照抄,完全不理解这两个数字是什么。这两个数字分别是MNIST数据集全体样本的灰度均值和标准差。因为图片是单通道(灰度),所以只有一个均值和一个标准差,这就是为什么写法是(0.1307,)而不是(0.1307, 0.1307, 0.1307)。
归一化的目的是让输入数据的分布接近标准正态分布(均值为0、方差为1),这对神经网络的训练至关重要。如果输入像素值范围在[0, 1]之间,经过网络多层计算后数值容易产生偏移,梯度更新也会受影响;归一化后每个特征的尺度一致,梯度下降的收敛路径会更平滑。
这两个数字不是随便选的,而是基于整个MNIST训练集统计出来的真实值。你在自己的项目中也可以直接沿用,因为数据集是同一个。但如果换了其他数据集,就必须重新统计均值和标准差。
3.2 DataLoader的核心逻辑:batch_size、shuffle与多进程
数据加载还有一个关键的组件:DataLoader。新手最容易忽略它的参数设计。
train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=64, shuffle=True, num_workers=2 ) test_loader = torch.utils.data.DataLoader( test_dataset, batch_size=256, shuffle=False, num_workers=2 )我解释一下这几个参数为什么这么设置:
- batch_size:一次喂给模型多少张图片。64是训练集的常见选择,小batch能让梯度估计更频繁更新,同时不至于太慢;测试集用256是因为评估阶段不用更新梯度,可以更充分地在同一批次内完成计算,提高吞吐量。
- shuffle:训练集必须设为True。如果不打乱,每个epoch内模型看到的样本顺序完全一致,可能会学到样本顺序相关的虚假模式,影响泛化能力。测试集不需要打乱。
- num_workers:数据加载使用几个子进程。设为0表示主进程直接加载,设为2或4能加速大批量数据读取。但Windows上有时多进程会引发断点问题,保守起见设为0或2都能跑。
还有一个很常见的问题是:为什么要用torchvision.datasets.MNIST自带的ToTensor,而不直接读原始图片?因为ToTensor会把PIL图片或numpy数组的HWC格式转为CHW的Tensor,同时把像素值从[0, 255]缩放到[0.0, 1.0]。这个通道顺序转换正是PyTorch卷积层期望的输入格式,少了这一步网络根本跑不起来。
3.3 下载失败的常见原因与离线处理方法
MNIST数据集托管在互联网上,国内某些网络环境下直接download=True可能会失败。我在给同事配环境时遇到过不止一次。解决方案有两种。
第一种是设置镜像源。PyTorch官方提供了torchvision.datasets.MNIST的mirror参数,指向某个备用下载地址。但这个方法在新版本torchvision中其实已经不太好用了,因为底层下载逻辑变了。
第二种是我更推荐的离线方案:先手动下载好四个文件(train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz),放到./data/MNIST/raw/目录下,然后在创建MNIST对象时设置download=False。PyTorch会自动识别已经存在的原始文件并解压处理。这个方法在离线环境或内网机器上特别实用。
4. 训练循环的完整实现:从损失函数到优化器的每个细节
4.1 损失函数:交叉熵的前向计算与梯度传导
分类任务的默认损失函数是交叉熵。在PyTorch中,直接用nn.CrossEntropyLoss()即可。这里有一个非常容易混淆的点:CrossEntropyLoss内部已经包含了Softmax操作,所以模型的最后一层不需要额外添加Softmax激活函数。我在第一次实现时就在这里踩过坑——模型输出层后面又手动加了一层Softmax,导致训练时损失一直不降,后来检查发现梯度传播被Softmax的数值特性干扰了。
交叉熵损失的具体计算逻辑是:模型输出层得到10个原始分数(logits),CrossEntropyLoss内部先做Softmax将其转换为概率分布,然后用真实标签的one-hot分布去计算负对数似然。对于单个样本,如果模型给正确类别的置信度是0.95,那么该样本的损失就是-log(0.95)≈0.051;如果置信度只有0.5,损失就是-log(0.5)≈0.693。正确类别的置信度越低,损失越大,梯度就会推动模型把这个类别的分数推高。
下面是我完整训练函数的关键代码:
import torch.optim as optim def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for batch_idx, (images, labels) in enumerate(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() if batch_idx % 100 == 0: print(f'Train Epoch: {epoch} [{batch_idx * len(images)}/{len(train_loader.dataset)} ' f'({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}') avg_loss = running_loss / len(train_loader) accuracy = 100. * correct / total return avg_loss, accuracy关键步骤我用一句话串起来:optimizer.zero_grad()清空上一步的梯度,loss.backward()反向传播计算每个参数的梯度,optimizer.step()用优化器更新参数。这三行代码是PyTorch训练循环的铁三角,缺一不可。
4.2 优化器选择与学习率设置:SGD还是Adam
LeNet-5原论文时代用的是随机梯度下降(SGD)配合固定学习率。在现代PyTorch实现中,你有两个主流选择:SGD附带动量,或者Adam。我做过的多次对比实验结果如下:
| 优化器 | 学习率 | 动量 | 收敛速度 | 最终准确率(约) |
|---|---|---|---|---|
| SGD | 0.01 | 0.9 | 较慢 | 98.9% |
| SGD | 0.1 | 0.9 | 较快 | 99.0% |
| Adam | 0.001 | - | 快 | 99.1% |
| Adam | 0.01 | - | 快但震荡 | 97.8% |
结论很明确:Adam在默认学习率0.001下既快又稳,适合新手省心跑通;SGD需要调大学习率到0.1才能达到接近效果。但从理解的深度来说,我建议你在跑通Adam版本后,再手动把优化器换成SGD+动量(optim.SGD(model.parameters(), lr=0.1, momentum=0.9)),感受一下两种优化器的收敛曲线差异。这种对比带来的直观理解是任何书本文字都无法替代的。
我的实现选用了Adam:
optimizer = optim.Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss()4.3 训练超参数的确定:epochs设为多少合适
MNIST训练中,epochs的典型设置是5到15。我一开始习惯设10,但后来发现一个值得记录的现象:LeNet-5在MNIST上第2个epoch准确率就能超过98%,第5个epoch基本逼近99%,之后再增加epochs收益极小。
下面是我一次典型训练过程的真实记录:
| Epoch | 训练损失 | 训练准确率 | 测试准确率 |
|---|---|---|---|
| 1 | 0.1372 | 96.0% | 97.5% |
| 2 | 0.0410 | 98.8% | 98.7% |
| 3 | 0.0285 | 99.1% | 99.0% |
| 4 | 0.0210 | 99.3% | 99.0% |
| 5 | 0.0169 | 99.5% | 99.1% |
可以看到从第3个epoch之后,模型几乎进入平台期。因此如果你只是验证网络能否工作,5个epoch足够;如果想追求极致的准确率并学习学习率衰减,再跑更多epoch绑上StepLR调度器也不迟。
4.4 设备选择:CPU跑得动吗
MNIST图片尺寸小、网络结构浅、参数量只有6万,所以这个项目在CPU上完全可以训练。我测试过,在普通的四核CPU上,每个epoch大约需要20到40秒,5个epoch只要两分钟左右。用GPU的话,单epoch时间可以压缩到5秒以内。
如果你有GPU,只需在模型和batch数据上调用.to(device)即可:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = LeNet5().to(device)注意images.to(device)和labels.to(device)必须写对,否则会报Expected all tensors to be on the same device的错误。
5. 测试与评估:准确率之外还要看什么
5.1 标准测试流程:model.eval()和torch.no_grad()缺一不可
训练完成之后,测试流程是验证模型真实效果的关键环节。我第一次写测试代码时没有用model.eval(),结果测试准确率出现了诡异波动——后来才明白,模型中如果有Dropout或BatchNorm层,训练和测试时的行为是不同的。LeNet-5本身没有这两个层,但养成写model.eval()和torch.no_grad()的习惯非常重要。
标准测试函数:
def evaluate(model, test_loader, device): model.eval() correct = 0 total = 0 test_loss = 0.0 criterion = nn.CrossEntropyLoss() with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) test_loss += criterion(outputs, labels).item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() accuracy = 100. * correct / total avg_loss = test_loss / len(test_loader) print(f'Test set: Average loss: {avg_loss:.4f}, Accuracy: {correct}/{total} ({accuracy:.2f}%)') return accuracytorch.no_grad()是推理阶段的必备操作,告诉PyTorch不需要构建计算图,因为测试阶段不需要反向传播。这能显著减少显存/内存占用并加快计算速度。用torch.max(outputs, 1)取出每个样本最大分数所在的索引,就是模型的预测类别。
5.2 混淆矩阵:哪一个数字最容易混淆
准确率只是一个笼统指标。真正想了解模型在哪些地方犯错,必须看混淆矩阵。我用sklearn.metrics.confusion_matrix输出过一轮结果,最有价值的发现是:LeNet-5最容易混淆的类别组合是4和9,其次是7和2,以及3和8。这跟人类手写数字的视觉相似性高度一致——4和9的写法在模糊情况下确实容易看走眼。
通过分析混淆矩阵,你可以定位模型的薄弱类别,并针对性地扩充这类样本或者调整训练策略。这一步对理解模型行为远比单一准确率有价值。
5.3 可视化:把预测错的样本画出来看看
另一个让我收获很大的操作是可视化错误样本。做法很简单:从测试集中挑出预测错误的图片,用matplotlib画出来,旁边标注真实标签和预测标签。
import matplotlib.pyplot as plt import numpy as np def show_misclassified(model, test_loader, device, num_images=10): model.eval() misclassified = [] 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) for i in range(len(labels)): if preds[i] != labels[i]: misclassified.append((images[i].cpu(), labels[i].item(), preds[i].item())) if len(misclassified) >= num_images: break fig, axes = plt.subplots(2, 5, figsize=(12, 5)) for idx, (img, true_label, pred_label) in enumerate(misclassified[:num_images]): ax = axes[idx // 5, idx % 5] ax.imshow(img.squeeze(), cmap='gray') ax.set_title(f'True: {true_label}, Pred: {pred_label}') ax.axis('off') plt.show()我观察下来,错误样本很多是笔迹潦草、断笔、或者倾斜角度过大的手写体,这让我直观感受到模型与人类视觉系统的差异:模型看到的只是像素统计模式,而不是人类理解中的"形状"。这种具象认知对后续设计更复杂网络时会很有帮助。
6. 从85%到99.2%:我踩过坑后的调优实录
6.1 第一次训练:网络不收敛到最终调优的全过程
我见过太多人卡在整个项目的第一步,所以想完整回顾一下自己从失败到成功的调试链路。第一次实现时,我的准确率只有85%左右,远达不到预期。问题排查过程如下,每一步都值得你复现一遍:
第一步,检查损失函数是否下降。如果训练loss不减反增或震荡,多半是学习率过大或代码逻辑有误。我当时loss能稳定下降,排除这个问题。
第二步,检查输出层维度。我把fc3误写为nn.Linear(84, 1),导致输出是一个单一数值而不是10类概率分布,损失计算报错后我才发现。这个错误很基础,但恰好说明理清维度的重要性。
第三步,也是最隐蔽的一个问题:我一开始没有model.train()和model.eval()切换,BatchNorm层(当时的LeNet变体)在测试时行为错乱,导致验证结果忽高忽低。后来严格遵循这两个调用,问题立即消除。
真正让我从85%提升到99.2%的关键操作是三个:一是确保训练集数据加载时shuffle=True,避免同类数字聚集出现导致梯度更新方向偏斜;二是学习率从默认0.01降到0.001,让Adam收敛更稳定;三是在数据管道中加上归一化,把输入分布拉到标准正态范围。这三个调整叠加后,模型的测试准确率直接跨越到了99%以上。
6.2 常见坑位清单:为什么照抄代码还是报错
即便照着别人的完整代码抄,新手依然容易在几个固定位置翻车。我把高频错误全部列出来:
- 维度不匹配报错:卷积层输出展平后是16×5×5=400,
fc1输入维度必须写400。我曾见过有人写成16 * 4 * 4 = 256,实际是核尺寸计算错误。 - CUDA/CPU设备不一致:模型和数据分别留在不同设备,报错信息很直白,但新手容易忽略。
- Windows多进程数据加载卡死:
num_workers设为非0时可能报BrokenPipe错误,最简单的解法是设为0。 - 数据集路径重复下载:每次运行都再次下载占用时间,建议确认
./data/MNIST/raw目录下文件完整。 - 忘记
model.eval():测试结果波动、准确率异常低时,优先检查这一行。 - 验证集和测试集混用:我见过同学拿
train_loader去评估模型,看到99.9%准确率激动半天,直到发现他用的是训练集本身。评估必须用模型没见过的数据。
6.3 模型的最终性能与保存加载
训练完成后,保存模型的正确姿势有两种。第一种是保存整个模型:
torch.save(model, 'lenet5_mnist.pth')第二种是只保存状态字典,这是推荐做法:
torch.save(model.state_dict(), 'lenet5_mnist_state_dict.pth') # 加载时先实例化网络,再load_state_dict model = LeNet5() model.load_state_dict(torch.load('lenet5_mnist_state_dict.pth')) model.eval()注意加载后要调用model.eval()切换到推理模式。我个人最终模型在测试集上的典型结果:准确率99.2%左右,测试集损失约0.02。
6.4 后续你可以尝试的3个改进方向
跑通这个项目之后,如果你想进一步进阶,我按难度从小到大推荐三个扩展方向。
第一,调换激活函数进行A/B对比,或者尝试不同的池化方式,观察准确率和收敛速度的差异。这类实验成本极低,但能让你建立对每个组件作用的直觉。
第二,加一个Dropout层放到fc1之后,设置p=0.5,体验它对过拟合的抑制作用。虽然MNIST上LeNet-5的过拟合表现不算严重,但你能直观感受到正则化带来的泛化改善。
第三,改造网络结构,比如把卷积核数量翻倍、增加一个卷积层,观察参数量增加对效果的影响。这一步能让你切身体会网络容量与泛化能力之间的权衡。
写在最后的一点建议
从跑通代码到理解原理,再到能独立调优,我从这个项目中学到最多的并不是那几个经典层结构,而是一套"看数据、看损失、看错误样例"的调试思维。LeNet-5在MNIST上轻松刷到99%以上,反而让你有足够精力去观察那些剩余的1%错误——真正拉开一个工程师和调包侠差距的,往往就是那1%的分析深度。如果你是初学者,试试把每一层输出shape打印一遍,把每个超参数改乱一次看看会发生什么,你会比看十遍教程收获更大。