MNIST 手写数字识别,可以说是计算机视觉里最经典的入门案例。它就像编程中的“Hello World”——数据简单、任务清晰、反馈直接。而 LeNet5 正是解决这个问题的经典卷积神经网络,也是很多人深度学习之旅的起点。这篇文章我会把整套流程完整走一遍:从数据预处理、网络结构设计、训练评估,到最后一堆实战中遇到的小坑。如果你是刚开始接触 CV,想亲手训练一个图像分类网络,这份内容可以直接照着做。
1. 项目概述:LeNet5 与 MNIST 的经典组合
1.1 这个项目解决什么问题
MNIST 数据集包含 0~9 十个数字类别,每张图片是 28×28 的灰度图,训练集有 6 万张,测试集有 1 万张。任务非常明确:输入一张手写数字图片,输出它对应的数字类别。这个问题看起来很简单,但它涵盖了图像分类的全部核心环节:图像输入、特征提取、特征降维、分类输出。很多抽象的概念,比如卷积、池化、全连接、损失函数,都可以在这个项目里获得非常具体的感知。
LeNet5 是上世纪针对这一类任务设计的卷积神经网络,结构非常紧凑,参数量只有六万左右。在当时的硬件条件下就已经能在 MNIST 上取得很不错的准确率,放到今天更是属于“轻量级”模型。我们用现代深度学习框架去复现它,普通 CPU 上几分钟就能完成训练,测试准确率可以稳定在 99% 左右。这本身就说明网络结构设计得非常经典,即使硬件和框架迭代了很多代,它的核心思想依然成立。
1.2 为什么选 LeNet5 作为入门项目
在实际学习和复现的过程中,我觉得 LeNet5 特别适合作为第一个从零搭建的 CNN 模型。原因可以归纳为三点:
- 结构小、参数量少,CPU 上训练几分钟就跑完,完全不用考虑 GPU 资源分配。
- 每一层的作用都很直观:卷积层提取局部特征,池化层压缩尺寸,全连接层做分类。结构层次与理论课上的内容一一对应,方便边写代码边理解。
- 在 MNIST 上效果极好,不需要复杂的调参就能拿到高准确率,能给初学者很强的正反馈。
当然,也有人会问:MNIST 已经被刷到 99.7% 以上了,再复现 LeNet5 是不是有点过时?我的看法是,MNIST 的价值不在于“挑战最高分”,而在于它的数据足够干净,任务足够简单,可以让你把注意力全部放在理解模型本身,而不是被大模型的数据处理和训练技巧淹没。LeNet5 是理解现代 CNN 的基石,后面再看 ResNet、EfficientNet 甚至 Vision Transformer,很多核心思想都能在这个小网络上找到源头。
1.3 工具选型与环境准备
这个项目我使用的是 PyTorch 框架,版本 2.x 都可以。选择 PyTorch 主要是因为它的动态图机制对新手非常友好,调试时可以直接打印中间张量的形状,非常直观。如果你用的是其他主流深度学习框架,核心思路也完全一样,只是代码写法有差异。我建议初学者先用 PyTorch 把这套逻辑跑通,后面再拓展其他框架时,迁移成本会低很多。
环境方面,只需要安装 Python 和 torch,再加一个 torchvision 就能跑通。torchvision 是必须的,因为加载 MNIST 数据集时需要用torchvision.datasets.MNIST,它会自动下载并解析原始数据。如果下载速度慢,可以手动把数据下载后放到对应目录,或者配置镜像源。这里我推荐用 conda 创建虚拟环境,避免污染系统 Python:
conda create -n lenet python=3.9 conda activate lenet pip install torch torchvision我没有指定 CUDA 版本,因为 MNIST 这个项目用 CPU 训练就够了。LeNet5 每一轮训练大概十几秒到几十秒,完全没有必要折腾显卡驱动。如果后续要切换到 CIFAR-10 或者更大的数据集,再装 GPU 版也不迟。
注意:torch 和 torchvision 的版本必须匹配,否则导入时会报错。建议在干净的虚拟环境里直接安装,避免和系统里的其他包冲突。这个坑看起来小,但很容易在项目刚开始时卡住你。
2. LeNet5 网络结构拆解:每一层到底在学什么
2.1 从输入到输出:完整的结构图
LeNet5 的完整输入并不是 MNIST 原始的 28×28,而是 32×32。这一点很多人会忽略。为什么是 32×32?因为网络里的第一个卷积核是 5×5,stride 为 1,没有 padding。如果输入是 28×28,经过第一层卷积后会变成 24×24,再往后尺寸缩得更快,到最后一层卷积时特征图可能已经无法正确处理。标准做法是先把图片 padding 到 32×32,让网络能够按照原始设计完成尺寸变换。
整个网络流程可以概括为:
- 输入:1 通道 32×32 灰度图
- C1:6 个 5×5 卷积核,输出 6 通道 28×28 特征图
- S2:2×2 平均池化,stride=2,输出 6 通道 14×14
- C3:16 个 5×5 卷积核,输出 16 通道 10×10
- S4:2×2 平均池化,输出 16 通道 5×5
- C5:120 个 5×5 卷积核,输出 120 通道 1×1
- F6:全连接层,输出 84 维
- Output:全连接层,输出 10 维分类得分
这里 C5 虽然是卷积层,但它的卷积核尺寸恰好等于输入特征图的尺寸,卷积后得到 1×1,所以实际上起到了全连接层的作用。这是 LeNet5 设计上很有意思的一个点:用卷积实现全局特征综合,避免了直接展平可能带来的维度计算混乱。理解这一点之后,再看其他 CNN 网络的“全局池化 + 全连接”结构,会更容易融会贯通。
2.2 各层参数计算与感受野分析
计算每一层的参数量,是理解网络最直接的方式。我们来完整算一遍。
C1 层:输入 1 通道,输出 6 通道,卷积核 5×5。每个输出特征图带一个偏置,参数量为1×5×5×6 + 6 = 156。
S2 层是池化层,没有可学习参数。原始 LeNet5 的池化层其实带有可学习的加权系数,但在现代简化版里,基本都用平均池化或最大池化替代。池化本身只做下采样,不需要训练权重,也不会明显影响最终精度。
C3 层:输入 6 通道,输出 16 通道,卷积核 5×5。按全连接的方式计算,参数量为6×5×5×16 + 16 = 2416。这里有一个细节:原始 LeNet5 中 C3 并没有对所有输入通道做全连接卷积,而是采用了部分连接策略,也就是某些输出通道只和部分输入通道相连,目的是打破特征图之间的对称性并减少计算量。在现代实现里,我们直接使用全连接卷积,效果完全不差,甚至更好,所以不必刻意复刻那种非对称连接。
S4 层:池化层,无参数。
C5 层:输入 16 通道,输出 120 通道,卷积核 5×5,输出 1×1。参数量为16×5×5×120 + 120 = 48120。
F6 层:输入 120,输出 84,参数量为120×84 + 84 = 10164。
Output 层:输入 84,输出 10,参数量为84×10 + 10 = 850。
总参数约为156 + 2416 + 48120 + 10164 + 850 = 61706。这个参数量放到今天非常小,甚至不如很多模型的一个残差模块多。但它在 MNIST 上能达到约 99% 的准确率,说明手写数字识别的特征空间确实很紧凑,不需要海量参数就能拟合得很好。
从感受野的角度看,第一个卷积层每次观察 5×5 的局部区域,随着网络加深,后面层的每个神经元能看到越来越大的输入范围,最终在 C5 层能够覆盖整张输入图像。这种从局部到全局的渐进式特征提取,就是 CNN 处理图像的核心逻辑。你在 MNIST 上把这里想明白了,以后理解 VGG、ResNet 的感受野计算就会非常轻松。
2.3 经典结构与现代实现的差异
在复现 LeNet5 时,你会发现不同代码版本之间有各种细节差异,主要集中在这几方面:
- 激活函数:原始设计使用 tanh,现代实现通常用 ReLU。ReLU 计算更简单,训练时梯度更稳定。我实测在 MNIST 上两者差别不大,但 ReLU 让网络收敛更容易,代码也更贴近现代风格。
- 池化方式:原始实现是带学习系数的加权平均池化,现代实现有的用平均池化,有的用最大池化。MNIST 上最大池化效果也完全可以,但我为了贴近原版,在下面的代码里使用了平均池化。
- 输出层:原始实现用了 RBF 径向基函数,现代实现直接用线性全连接层配交叉熵损失。这主要是训练习惯的变化,神经网络分类器通常不需要自己额外设计距离度量。
- 输入尺寸:很多代码为了省事,直接用 28×28 输入,然后手动调整网络里的卷积 padding 或全连接维度。这样做也能工作,但已经不是标准 LeNet5 了。
我的建议是,学习阶段尽量按照原版的设计来,也就是 32×32 输入、平均池化、ReLU 激活这套组合。既能领会原版的设计意图,又能利用现代优化技巧获得更好的训练效果。先理解经典,再去做简化或修改,才是站稳基础的正确路径。
3. 数据读取与预处理:细节决定准确率
3.1 MNIST 数据集的加载与归一化
用 torchvision 加载 MNIST 数据集非常方便,核心代码就几行:
from torchvision import datasets, transforms transform = transforms.Compose([ transforms.Pad(2), transforms.ToTensor(), transforms.Normalize(mean=(0.1307,), std=(0.3081,)) ]) train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)这段代码里有三个关键操作,很多人知道要写,但不太明白为什么。这里我展开说一下。
第一,Pad(2)的作用是把 28×28 的图片四周各补 2 个像素的黑边,变成 32×32。MNIST 背景本身就是黑色(像素值 0),所以补零不会引入任何额外噪声,数字仍然居中,形状完全不变。这个操作解决的是 LeNet5 标准输入尺寸问题。
第二,ToTensor()会把 PIL 图片转成张量,并将像素值从 0~255 缩放到 0~1 区间。这一步必须做,否则不归一化的像素值会让初始梯度太大,网络很难收敛。
第三,Normalize对每个通道做标准化。MNIST 是单通道灰度图,所以 mean 和 std 各一个值。0.1307 和 0.3081 是 MNIST 训练集的统计均值与标准差。用它们把数据变换到均值 0、标准差 1 的分布,有利于梯度下降时的数值稳定。
注意:标准化使用的 mean 和 std 必须是训练集的统计量,不应该混入测试集。MNIST 比较简单,即使写错影响也不大,但在真实项目中这是一个很容易犯且代价很高的错误。
3.2 尺寸对齐:padding 还是 resize?
我第一次复现时图省事,用的是transforms.Resize((32, 32))。准确率也正常,但心里总觉得不是原汁原味。后来改成了 padding 方案,也就是上面代码里的Pad(2)。
这两种方式有什么区别?Resize是对图片进行插值缩放,会改变像素内容,相当于对图像做了变形;Pad(2)只是在四周补黑边,保留了原始像素。从保持信息的角度看,padding 显然更干净。
实际效果上,MNIST 数字本来就很居中,两种方式对准确率的影响很小。但如果你以后要迁移到其他数据集或者复现更复杂的网络,预处理方式会和网络设计深度绑定。输入尺寸不对,网络结构再漂亮也白搭。所以建议从一开始就养成“先想清楚输入尺寸,再写网络”的习惯。
我还有一个习惯:在训练开始前,手动打印一个 batch 的数据统计值,确认标准化是否生效:
import torch images, labels = next(iter(train_loader)) print(images.mean(), images.std())理论上应该分别接近 0 和 1。如果偏差很大,说明 transform 没有正确应用到数据上,这时候就需要先排查预处理流程,而不是急着调模型。
3.3 DataLoader 配置与数据可视化
DataLoader 的配置有几个参数需要注意:
from torch.utils.data import DataLoader train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=2)batch_size我选了 64,对 LeNet5 来说很合适。如果太大,每个 epoch 的梯度更新次数变少,收敛速度会变慢;如果太小,比如 1,训练会非常不稳定且容易过拟合。新手可以固定为 64 或 128,不用过度纠结。
shuffle在训练集设置为 True,是为了打乱样本顺序,防止模型学到样本排列中的伪规律。测试集不需要打乱,因为评估时样本顺序不影响结果。
num_workers在 Windows 环境下建议设置为 0,否则可能会遇到多进程相关的报错。Linux 或 macOS 可以设为 2 或 4,加快数据读取。
在正式训练前,强烈建议先可视化一批图片。我会把一个小 batch 拼成网格,显示出来,同时打印对应的标签,确认图片和标签是否对齐。如果图片是颠倒的、标签对不上,那训练再久也没有意义。这一步只需要一次性的几行代码,却能避免很多后期排查时间。
数据这一关过了之后,就可以放心开始搭建模型了。很多时候模型表现不好,不是网络结构的问题,而是数据进模型之前就已经埋下了隐患。
4. 模型实现与训练全流程:从零写出一个可用的 LeNet5
4.1 PyTorch 代码实现 LeNet5
网络定义我严格按照前面说的结构来写,每一层都标注了尺寸变化,方便对照:
import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super(LeNet5, self).__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, stride=1), # C1: 32x32 -> 28x28 nn.ReLU(inplace=True), nn.AvgPool2d(kernel_size=2, stride=2), # S2: 28x28 -> 14x14 nn.Conv2d(6, 16, kernel_size=5, stride=1), # C3: 14x14 -> 10x10 nn.ReLU(inplace=True), nn.AvgPool2d(kernel_size=2, stride=2), # S4: 10x10 -> 5x5 nn.Conv2d(16, 120, kernel_size=5, stride=1),# C5: 5x5 -> 1x1 nn.ReLU(inplace=True) ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(120, 84), # F6 nn.ReLU(inplace=True), nn.Linear(84, num_classes) # Output ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这里Flatten()会把 C5 输出的(batch_size, 120, 1, 1)展平成(batch_size, 120),再送入全连接层。如果你喜欢更显式的写法,也可以直接调用x.view(x.size(0), -1),效果等价。
关于AvgPool2d和MaxPool2d的选择,原版 LeNet5 用的是平均池化,所以我这里保留了。如果你好奇最大池化的效果,也可以替换成nn.MaxPool2d(kernel_size=2, stride=2),在 MNIST 上通常也能得到可接受的结果,只是梯度传导方式略有不同。这种小实验非常适合用来感受不同操作对训练动态的影响。
4.2 训练脚本:损失函数、优化器与训练循环
损失函数我直接使用交叉熵损失。PyTorch 的CrossEntropyLoss已经内部整合了 Softmax,所以模型的最后一层不需要额外加 Softmax,直接输出 10 个原始得分即可。这一点经常有人搞错,在输出层先加了 Softmax,再用交叉熵,虽然也能跑,但梯度计算方式会受到影响,不如直接用标准写法。
优化器我选择了 Adam,学习率 0.001。经典 LeNet5 用的是 SGD 加动量,但 Adam 在 MNIST 上的调参成本最低,对新手非常友好。我也对比过 SGD 加 momentum,如果学习率调到 0.01,训练 15 个 epoch 左右同样能收敛,最终准确率甚至略高,但前期收敛比 Adam 慢。这里给出 Adam 版本,足够稳定。
训练一个 epoch 的函数可以这样写:
import torch import torch.nn as nn from torch.optim import Adam model = LeNet5() criterion = nn.CrossEntropyLoss() optimizer = Adam(model.parameters(), lr=0.001) def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy循环里还有几个值得一提的细节。optimizer.zero_grad()必须在每次反向传播前清空上一步的梯度,否则梯度会累积。loss.item()取的是 Python 标量,可以用来累加计算平均损失。torch.max(outputs, 1)返回每行最大值的索引,那就是模型预测的类别。
在训练循环中,我习惯在每个 epoch 结束后打印一次训练损失和准确率。如果你想要更直观的进度,可以用 tqdm 包装 data loader,按 batch 输出进度条。但 LeNet5 跑得很快,打印太多反而刷屏,所以我在正式代码里简洁了一点。
另一个非常重要的习惯是设置随机种子。深度学习训练中有很多随机性,如果不固定种子,每次结果都会有浮动。为了实验可复现,我会在训练前设置torch.manual_seed(42),如果有 CUDA 也一并设置。虽然 MNIST 这种任务即使不设置种子也能得到好结果,但从一开始就养成可复现的习惯,对后续做研究和比赛很有帮助。
4.3 模型评估:准确率与混淆矩阵
训练完成后,在测试集上评估模型。这里有一个很多新手会踩的坑:忘记切到评估模式。虽然 LeNet5 没有 Dropout 和 BatchNorm,train和eval模式差别不大,但一旦换了更深的网络,漏写model.eval()会导致结果完全不可靠。同时,评估阶段不需要计算梯度,用torch.no_grad()包起来可以显著减少内存和耗时。
def evaluate(model, test_loader, device): model.eval() 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) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) return correct / total在 MNIST 测试集上,LeNet5 的准确率通常落在 98.5%~99.2% 之间。如果训练到第 10 个 epoch,往往能到 98.8% 以上;偶尔调整一下随机种子或训练更久,能靠近 99.3%,但再往上提升空间不大,因为测试集里本身就有一些非常模糊、人类都难以辨认的图片。
光看准确率还不够,我建议再多看一个指标:混淆矩阵。它比单个数字更能反映模型在哪些类别上容易出错,也更容易定位问题。
from sklearn.metrics import confusion_matrix all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in test_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print(cm)我在多次实验里发现,最容易混淆的组合通常是 4 和 9,7 和 9,以及 3 和 8。这些数字在视觉上确实形态相近,尤其是快速手写时,人眼也不一定能分辨清楚。如果你发现某个数字的准确率特别低,比如 5,那就要回头检查数据预处理是否对这类图片造成了扭曲,或者考虑是否需要对类别做平衡。混淆矩阵能让你看到错误模式,而不是只盯着一个孤零零的准确率数字。
5. 常见问题与排查技巧实录
5.1 模型不收敛的排查清单
在实际训练 LeNet5 的过程中,我总结了一套排查“不收敛”问题的清单,按优先级排列,希望能帮你快速定位。
先检查输入数据范围。如果 transform 里漏写了ToTensor或Normalize,模型看到的是一张像素值 0~255 的图,初始梯度容易爆炸,损失可能直接变成 NaN。这是最常见的原因。解决方法是打印一个 batch 的images.min()和images.max(),确认数据确实在合理范围内。
再检查损失函数是否和最后的分类头匹配。多分类任务应该用CrossEntropyLoss,并且模型输出层不需要手动加 Softmax。如果你用了BCELoss,就会得到错误的结果;如果输出是 10 类但标签不是 0~9,也会有奇异行为。
然后是学习率。Adam 的默认学习率 0.001 对 LeNet5 来说是黄金值。如果学习率设为 0.1 以上,损失曲线会震荡甚至发散;如果设为 1e-5,收敛速度会慢到怀疑人生。可以打印前几个 batch 的 loss 变化,确认是否在下降。
最后检查shuffle。训练 DataLoader 里如果忘记shuffle=True,模型可能会学到样本顺序中的伪规律,训练集表现高,但测试集表现差。MNIST 数据本身是按类别排序的,不 shuffle 会让 0 类图片连续出现很多个 epoch,虽然最终也能收敛,但训练过程会很不稳定。
5.2 维度不匹配的错误处理
LeNet5 的结构虽然简单,但还是容易在 C5 到全连接层之间出现维度问题。最常见的报错是size mismatch,原因多半是输入图片没有 pad 到 32×32。
如果你把 28×28 图片直接输进去,C1 输出 24×24,S2 输出 12×12,C3 输出 8×8,S4 输出 4×4,这时候 C5 的 5×5 卷积就无法在 4×4 上滑动,直接报错。遇到这种问题,先在网络定义里加一行测试代码,方便定位是哪一层出了问题:
x = torch.randn(1, 1, 32, 32) print(model.features[0](x).shape) print(model.features[2](x).shape) print(model.features[4](x).shape) print(model.features[6](x).shape)这种“打印中间张量形状”的调试方式,是所有深度学习调试手段里最直接、最有效的。不要靠猜,直接看每一层的输出 shape,几十秒就能找出尺寸错误的具体位置。
另一个容易忽略的问题是Flatten的位置。如果 Flatten 写在全连接之前,但特征图不是 1×1,展平后的维度可能不是 120,而是更大的数字。比如你调整了 C5 的 padding 或者输入尺寸,C5 输出变成 3×3,Flatten 后就是 120×9=1080,再踩进 Linear 层就会报 mismatch。要避免这个问题,核心是保证 C5 的输出严格是 1×1,这样才能让展平后的维度保持 120。
5.3 过拟合与欠拟合的平衡
MNIST 简单,LeNet5 参数也少,过拟合风险不高,但并不是完全没有。尤其是如果你把 batch size 设成 1,然后训练几百个 epoch,模型确实会把训练集“背下来”,测试准确率反而不升反降。
判断过拟合的方式很简单:看训练集准确率和测试集准确率的差距。如果训练集已经接近 100%,但测试集准确率只有 95% 或者更低,那就说明模型泛化出了问题。这时候可以尝试三个方向:
- 增加数据多样性。对训练集做轻微平移、旋转或加入少量噪声,让模型见得更多。MNIST 本身不够多变,平移两个像素是成本最低且有效的增强方式。
- 降低模型容量或加入正则化。可以在分类器部分加一层
nn.Dropout(p=0.5),对抑制过拟合有帮助,但在这个任务上效果不算明显。 - 早停。训练到第 5 个 epoch 时测试准确率已经不再提升,就不要再盲目加 epoch。可以保存验证集上表现最好的模型,用那个权重做最终测试,而不是用最后一个 epoch 的权重。
相反,如果测试准确率卡在 60% 左右,那多半不是过拟合,而是网络结构或预处理出了问题。先换回最简单的配置:确认输入是 32×32、数据归一化正确、损失函数匹配。很多时候问题就藏在这几个基础环节里。
5.4 训练速度太慢怎么办
在 CPU 上训练 LeNet5 其实非常快。我用一台普通笔记本,每个 epoch 大概十几秒,10 个 epoch 也就三五分钟。如果你感觉速度慢得离谱,先看这几件事:
第一,测试评估阶段是否没有包torch.no_grad()。如果测试时还在计算梯度,消耗的时间和内存都会增加。加上之后,速度能快一倍以上。
第二,num_workers设置是否过高。Windows 下num_workers=0是最稳定的选择,2 或 4 虽然理论上能加速,但会产生进程切换和通信开销,在小数据集上反而可能更慢。
第三,batch size 是否太小。batch size 为 1 时,每次迭代只处理一张图,CPU 利用率很低,效率极差。调到 64 或 128 之后,向量化计算能充分发挥硬件性能。
还有一个不太起眼的点:数据集的存储位置。如果数据放在很慢的机械硬盘上,每次 epoch 都要重新读取,数据加载会成为瓶颈。可以把数据集放到内存或固态硬盘上,一般能明显感觉到训练过程更顺畅。
6. 个人经验与后续扩展方向
6.1 从 LeNet5 学到的通用方法论
做完这个项目之后,我最大的收获不是记住了一个网络结构,而是建立了一套调试深度学习模型的“感觉”。
第一个感觉,是对卷积层尺寸变化的敏感。无论哪个网络,只要告诉我卷积核大小、步长、padding,我都能立刻算出输出尺寸。这个熟练程度来自反复推导 LeNet5 每一层的输出形状。只要把最基础的计算弄熟,后面看任何 CNN 结构都会快很多。
第二个感觉,是理解“特征提取 + 分类器”的分层思想。LeNet5 前面的卷积层是在做特征提取,后面的全连接层是在做分类。现代很多模型仍然是这样的思路,只不过特征提取器从简单的卷积栈变成了非常深的残差网络甚至 Transformer,但整体框架一脉相承。你完全可以把 LeNet5 的features部分替换成任意一个预训练特征提取器,然后只训练后面的分类头,这就是最简单的迁移学习。
第三个感觉,是小模型也能解决很多问题。很多人一上来就想上大模型,其实在 MNIST 这种简单任务里,LeNet5 的六万参数已经绰绰有余。先跑通一个简单 baseline,再根据实际需求逐步增加复杂度,是工程上非常高效的策略。很多所谓的“模型效果不好”,其实是数据或流程的问题,而不是模型容量不够。
6.2 后续改进方向:从 MNIST 到更复杂的任务
做完 LeNet5 之后,你可以沿着几个方向继续深入,把这次学到的能力迁移到更广泛的场景。
一个方向是结构实验。把平均池化换成最大池化,在两卷基层之间加入 BatchNorm,或者在全连接层里加 Dropout,观察训练速度和最终准确率的变化。通过这些改动,你能直观地感受到每一个模块对网络行为的影响,而不仅是停留在纸面上的理解。
另一个方向是更换数据集。MNIST 太简单,你可以试试 Fashion-MNIST 或者 CIFAR-10。LeNet5 在 MNIST 上能到 99%,但在 CIFAR-10 上可能只有 60% 左右,这能拆掉你对网络结构的“迷信”,让你意识到数据分布对模型效果的影响有多大。
你还可以尝试用 LeNet5 做特征抽取器,把卷积层输出的特征送到 SVM 或随机森林里做分类。早期很多人用这种方法进行手写数字识别。虽然现代端到端训练已经普及,但亲手做一次特征抽取和分类器分离,能让你对表示学习有更直观的认识。
我后来把 LeNet5 的最后一个卷积层从 5×5 改成 3×3,在全连接层之间加了 Dropout,在 Fashion-MNIST 上也能得到很不错的分类效果。这说明 LeNet5 的骨架并不局限于 MNIST,它的核心设计理念完全可以迁移到其他图像任务上。
最后说一点个人体会:新手阶段很容易把注意力放在“刷高准确率”上,但 LeNet5 这个项目的真正价值不在于那个数字,而在于把数据、模型、训练、评估、排错这整条链路完整地走通一遍。后面无论你去做目标检测、图像分割还是自然语言处理,这些基础能力都会反复用到。所以,别急着追求花哨的网络结构,先把 LeNet5 吃透,你的深度学习地基就牢了。