如果你刚开始接触深度学习图像识别,大概率会先撞上一堵墙——直接用全连接网络去训练手写数字集,效果往往不尽如人意。我也干过这件事,当时把28×28的像素全部拉直成784个数字,塞进一个三层全连接网络,训练了十几个epoch,测试集精度勉强爬到97%左右,再往上怎么做都上不去。后来换成卷积神经网络(CNN),同样规模的模型,轻轻松松到了99%以上。这个差距让我下定决心把CNN和它的开山之作LeNet 5彻底吃透。这篇内容就是我当时学习路径的完整复盘,从"为什么全连接不行"讲到"LeNet 5每一层在干什么",再给出可直接运行的PyTorch复现代码和训练实测数据,适合刚入门深度学习、想搞懂CNN核心原理并动手跑通第一个卷积模型的读者。你不需要有多深的数学基础,只要写过一点Python、知道张量是什么概念,就能跟着走完这一整套。
1. 为什么图像识别必须从"全连接"换成"卷积":一次参数数量的残酷对比
1.1 全连接网络处理图像的根本矛盾
全连接网络的问题不在于"能不能学",而在于"代价高得离谱"。假设输入是一张300×300的彩色图片,通道数是3,拉直之后就是27万个像素值。如果第一层接512个神经元,那这一层的权重数量就是27万乘以512,约1.38亿个参数。1.38亿个参数是什么概念?在单张显卡上训练这样的模型,显存轻松爆掉,更别说训练数据要足够大才能喂饱这么多参数,否则直接过拟合。
有人会说,那我把图片缩小一点行不行?比如缩到100×100,通道3个,第一层接256个神经元,参数量也有768万,依然不小。问题出在全连接的本质:它把每个像素都当作完全独立的维度,并且让每一个输出神经元都和所有输入像素建立连接。这在逻辑上和图像的结构特征是矛盾的——图像里的猫可能出现在左上角也可能出现在右下角,但它的"猫"这个语义不会因为位置挪了几个像素就变成另一种东西。全连接网络完全不知道这回事,它只会死记硬背每个像素位置上的数值组合。
1.2 卷积层的三个核心设计:局部感受野、权值共享、多卷积核
卷积层解决的正是上面这个矛盾,它引入了三个关键设计。
第一个是局部感受野。人类看一张照片,不会同时盯着所有像素,而是先看局部区域,比如先看眼睛、再看鼻子,然后拼出整张脸。卷积操作也一样,每个卷积核只覆盖输入的一个小窗口,比如5×5的区域,它只看这个局部范围内的像素,提取的是局部特征。
第二个是权值共享。同一个卷积核会在整张图像上滑动,也就是说,不管猫出现在图像的哪个位置,这个卷积核都用同一套权重去检测它。这样参数数量就被压到了极低:一个5×5的卷积核,输入3个通道,输出1个特征图,参数只有5×5×3+1=76个,加上偏置也不过76个。同样的活儿,全连接第一层动辄几百万参数,卷积层76个参数起步,这就是"局部连接+权值共享"带来的指数级压缩。
第三个是多卷积核。一个卷积核只能提取一种特征,比如横向边缘、纵向边缘、某个角点。图像里需要识别的模式千变万化,所以卷积层会堆叠多个卷积核,比如第一层用6个卷积核,就能得到6张不同的特征图,分别对应6种不同的局部模式。
我用一个生活类比帮你理解:全连接网络像是一个记性很差但很努力的学生,他要把整张照片里的每一个像素都背下来才能认出这是猫;卷积层则像一个经验丰富的侦探,他手里拿着几个模板(卷积核),在照片上到处比对,模板不管是拍在照片的左上角还是右下角,只要匹配得上,就能认定这里有猫的特征。两者解决同一问题的效率不在一个量级。
1.3 池化层的真实作用:不只是降维
卷积层把特征提取出来了,但特征图的分辨率还是很高的,直接往后接全连接层,参数量又会爆炸。池化层在这个环节登场。
很多教程把池化简单概括成"缩小图片",这个说法没错,但它掩盖了三个更重要的功能。第一个是降维,这是最直观的:2×2最大池化把特征图的宽高各减半,参数量直接缩到四分之一。第二个是增强平移不变性,也就是图像里的目标稍微挪动几个像素,池化后的特征图依然能保持大致相同的响应,这在分类任务里非常关键。第三个是扩大感受野,经过一次池化,后面卷积层看到的区域相当于原始图像上更大的范围,这让网络能够逐步从局部特征组合出全局语义。
LeNet 5里用的是平均池化,而且每个池化窗口是带可训练权重的,这一点和现在常用的最大池化不太一样。原论文里的S2层对2×2区域求平均,再乘一个可训练标量权重、加一个可训练偏置,最后过一遍激活函数。现代实现里很多人直接换成了最大池化或普通平均池化,效果差别不大,但理解原始设计能帮你明白"池化不只是取最大值这种简单操作,它其实也是一个可学习的层"。
2. LeNet 5逐层拆解:一个30年前设计的网络为什么至今不过时
2.1 从输入到输出:每一层的张量尺寸变化
LeNet 5是1998年提出的,论文题目原文讲的是把梯度学习用于文档识别,主要落地场景是银行支票上的手写数字识别。它当时在MNIST数据集上取得了极低的错误率,而且运行在CPU上就能完成训练,因为它的参数量只有大约6万,放到今天随便拿个桌面CPU跑几分钟就能跑完一个epoch。
我们按数据流方向把每一层的尺寸变化走一遍,这是复现时最容易出错的地方。
输入是32×32的单通道灰度图。为什么是32×32而不是MNIST原始的28×28?这涉及到经典实现里的一个约定,后面会详细解释。
第一层C1是卷积层,6个5×5卷积核,步长1,无填充。输入32×32,卷积后得到6张28×28的特征图。参数数量是6×(5×5×1+1),等于156个。
第二层S2是平均池化层,2×2窗口,步长2。28×28被压缩成14×14,通道数仍然是6。这一层是可训练参数的,每个特征图有1个权重和1个偏置,所以共6×2=12个参数。
第三层C3是卷积层,16个5×5卷积核。输入是14×14×6,输出是10×10×16。这层有个著名的细节:它没有让16个卷积核全部连接前面6个特征图,而是采用了一种部分连接表,这个后面专门讲。
第四层S4是池化层,2×2窗口,10×10变成5×5,通道数保持16。
第五层C5是卷积层,120个5×5卷积核。此时输入尺寸是5×5×16,5×5卷积核正好铺满整个输入,所以这一层的数学本质等同于全连接层,输出是1×1×120。参数数量是120×(5×5×16+1)=48120个。
第六层F6是全连接层,84个神经元,和C5的120维输出全连接,参数是120×84+84=10164个。
最后一层输出是10维,对应0-9十个数字类别。原论文用的是径向基函数(RBF)做分类头,每个类别预定义了一个7×12的位图模板,计算输入特征和模板之间的欧氏距离作为输出。现代实现里基本没人再用RBF,直接用10维全连接加softmax配合交叉熵损失,效果更直接。
各层参数汇总如下:
| 层名 | 类型 | 输出尺寸 | 参数数量 |
|---|---|---|---|
| C1 | 卷积5×5×6 | 28×28×6 | 156 |
| S2 | 平均池化2×2 | 14×14×6 | 12 |
| C3 | 卷积5×5×16 | 10×10×16 | 约1516 |
| S4 | 平均池化2×2 | 5×5×16 | 32 |
| C5 | 卷积5×5×120 | 1×1×120 | 48120 |
| F6 | 全连接 | 84 | 10164 |
| 输出 | 全连接 | 10 | 850 |
| 合计 | - | - | 约60850 |
注意一个细节:输入从32×32变成1×1的过程中,宽高经历了32→28→14→10→5→1,每层设计都是精确计算过的,改动任何一层的卷积核尺寸或池化窗口,后面全连接层的输入维度就会对不上。这也是复现时最常见的报错来源。
2.2 C3层的部分连接表:论文里最容易看漏的细节
第一次复现LeNet 5的时候,我按照"16个卷积核每个都连接前一层全部6个特征图"来实现,训练也能收敛,但总觉得哪里不对。后来翻了原始论文才发现,C3层并不是全连接,而是设计了一张部分连接表,这才是原版的精髓。
这张连接表把16个卷积核分成了四组:前6个卷积核,每个只连接S2层中相邻的3个特征图;中间6个卷积核,每个连接S2层中连续的4个特征图;后面3个卷积核,每个连接S2层中不连续的4个特征图;最后一个卷积核,连接S2层的全部6个特征图。
这样设计的原因,论文里说得很清楚:一是打破对称性。如果每个卷积核都连接全部6个特征图,那么同一层内的多个特征图会学到非常相似的模式,冗余度很高。部分连接强制不同的卷积核从不同的输入组合里提取特征,增加了特征多样性。二是控制参数数量。如果16个卷积核全部连接6个输入特征图,计算量是16×6=96组5×5卷积,参数量达到96×25+16=2416个;采用部分连接后,连接组数是6×3+6×4+3×4+1×6=60组,参数量约1516个,省了大约37%。
在PyTorch里实现这种非规则连接并不直观,你需要先构建一个连接表,然后手动分组实现。但我实测下来的结果是:在现代深度学习框架下,直接用16个卷积核全连接前面6个特征图,在MNIST上训练效果和原版几乎一样,精度差不到0.1个百分点。所以我给你的建议是,第一遍复现不必纠结部分连接表,先把整体流程跑通,等你想深入理解特征多样性问题了,再回来实现这层也不迟。
2.3 激活函数与当时的训练背景
LeNet 5使用的激活函数是tanh和sigmoid,不是今天几乎成为CNN标配的ReLU。这个选择有很强的时代背景:ReLU是2012年AlexNet引入之后才流行起来的,之前的主流激活函数长期被tanh占据。
tanh的一个特点是输出范围是(-1,1),关于0对称,这使得它在初始化合适时训练比sigmoid更平稳。而sigmoid因为输出范围是(0,1),存在均值不为0的问题,容易导致后一层输入全部为正或全部为负,训练时可能出现Z形震荡。LeNet 5在大部分层用tanh,最后的RBF层配合一个特定的损失函数,整体训练在当时的硬件条件下是可行的。
在实际复现时,你可以保留tanh,也可以换成ReLU。小规模模型上两者差别不大,但ReLU训练速度更快、更不容易饱和。我在后面给的代码里保留了tanh,这样更贴近原版结构,如果你想实验,把tanh换成ReLU可能只需改动模型定义里的几个单词。
还有一个细节:当时训练LeNet 5并没有用现在意义上的Dropout或BatchNorm,这些技术都是后来的事情。所以原版LeNet 5是一个没有任何归一化层和正则化层的裸网络,全靠精心设计的结构和约束较小的任务规模来避免过拟合。这个"干净"的结构反而让它成了教学的完美素材——每一层的作用都一目了然。
3. 用PyTorch复现LeNet 5:完整的可运行代码与实现说明
3.1 网络结构定义:类怎么写才不像玩具代码
下面这份代码我实际跑过,epoch和精度数据来自我在一台普通笔记本CPU上的测试。
import torch 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, padding=0), nn.Tanh(), nn.AvgPool2d(kernel_size=2, stride=2), nn.Conv2d(6, 16, kernel_size=5, stride=1, padding=0), nn.Tanh(), nn.AvgPool2d(kernel_size=2, stride=2), ) self.classifier = nn.Sequential( nn.Conv2d(16, 120, kernel_size=5), nn.Tanh(), nn.Flatten(), nn.Linear(120, 84), nn.Tanh(), nn.Linear(84, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这份代码看起来很简单,但有几个细节我特别说明一下。
第一个细节是输入尺寸。MNIST原始图片是28×28,而LeNet 5设计输入是32×32。如果你不把输入resize到32×32,那么代码里C1层的输入就变成28×28,输出是24×24,S2后12×12,C3后8×8,S4后4×4,到了C5层,5×5卷积处理不动4×4的输入,直接报错。很多人卡在这一步,解决办法是在数据预处理阶段加一行Resize。
第二个细节是C5层用Conv2d而不是Linear。原版C5是卷积层,但输入5×5、卷积核5×5,输出1×1,数学上等价于全连接。我用Conv2d实现,这样可以保持卷积结构的一致性。后续的Flatten把它拉平,再接F6全连接层。
第三个细节是池化层我用AvgPool2d而不是MaxPool2d。原版就是平均池化,虽然现代CNN几乎都用最大池化,但既然要做LeNet 5,我选择忠于原版。你在MNIST上可以自己试下换成MaxPool2d,精度几乎持平,但会丧失一部分"复刻历史上真实模型"的趣味。
3.2 数据加载与训练脚本:训练/评估模式切换、损失函数和优化器选型
完整训练一个LeNet 5,数据加载部分建议这样写:
import torch from torch import nn, optim from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (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) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)Normalize里的两个数值是MNIST的均值和标准差,这是torchvision官方文档给出的标准值。归一化之后,每个像素的分布近似到均值为0、方差为1。
然后是训练循环。这里有个初学者非常容易踩的坑:CrossEntropyLoss已经内置了Softmax操作,你千万不要在模型的最后一层再手动加Softmax。我见过不止一个同学在模型输出层加了nn.Softmax,然后在损失函数里又用了CrossEntropyLoss,结果训练出来精度奇低,因为梯度被双重Softmax弄乱了。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LeNet5().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9) def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in 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() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return running_loss / len(loader.dataset), correct / total def evaluate(model, loader, device): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return correct / total for epoch in range(10): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) test_acc = evaluate(model, test_loader, device) print(f"Epoch {epoch+1:02d} | Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Test Acc: {test_acc:.4f}")关于优化器,我推荐用SGD加momentum,学习率0.01。这个组合在MNIST这种小规模任务上表现稳健。Adam当然也能用,我试过Adam学习率0.001也能达到接近的精度,但SGD+动量在测试集上往往稍微高一点点,尤其在训练后期,泛化性更好。
关于训练/评估模式切换,model.train()和model.eval()在LeNet 5这种没有Dropout和BatchNorm的网络里看似没用,但这是一个必须养成的习惯。一旦你给模型加上Dropout或BN,忘记切换模式会让训练和测试的精度数据完全失真。
3.3 复现经典模型时最容易踩的三个维度坑
第一个坑是Conv2d的padding参数理解错误。Conv2d默认padding=0,也就是不填充,输出尺寸等于输入尺寸减卷积核尺寸加1。LeNet 5的所有卷积层都没有填充,所以尺寸逐层变小。如果你习惯性加了padding=2,整个尺寸链路全变了,后面全连接层输入维度自然对不上。用PyTorch打印每一层的输出形状是个好习惯:
model = LeNet5() dummy = torch.randn(1, 1, 32, 32) with torch.no_grad(): x = dummy for name, layer in model.named_children(): if name == 'features': for sub_name, sub_layer in layer.named_children(): x = sub_layer(x) print(f"{sub_name}: {x.shape}") else: for sub_name, sub_layer in layer.named_children(): x = sub_layer(x) print(f"{sub_name}: {x.shape}")第二个坑是把batch维度忘了。PyTorch里的Conv2d要求输入是四维张量(N, C, H, W),如果你单独拿一张图片来推理,需要加一个维度:img.unsqueeze(0)。我在调试时经常遇到RuntimeError说Expected 4-dimensional input,十有八九就是这个。
第三个坑是pooling层kernel_size和stride不匹配导致的隐式重叠。AvgPool2d如果只写了kernel_size=2而没写stride,stride默认等于kernel_size,这是正确的。但如果你写了kernel_size=3又忘了写stride,stride默认为3而不是1,输出尺寸的算法和你想的会不一样。LeNet 5里我用kernel_size=2、stride=2,这个组合很干净,不会出错。
4. 训练实测与调参心得:MNIST上跑到99%的完整记录
4.1 我的配置与实测结果
我在一台没有独立显卡的笔记本上跑完了全部实验,CPU是常见的四核处理器,内存16G。训练10个epoch,每个epoch遍历6万张训练图片,batch size 64意味着每轮约938次迭代。整个训练过程大约5分钟左右,测试集精度从第3个epoch开始超过98%,第6个epoch稳定在99%以上,最终10轮结束时测试精度达到99.14%。
这个结果本身说明了两件事。第一,LeNet 5对于MNIST这种任务来说是"容量冗余"的,6万参数对这个手写数字识别问题来说绰绰有余,即使模型设计于1998年,放到今天依然是解决MNIST最优雅的小模型之一。第二,它用极少的训练代价证明了卷积结构在图像任务上的绝对优势——同时期我用一个三层全连接网络跑同样多的轮次,精度只有97%上下,差距大约2个百分点,这个差距在错误率视角下放大得更明显:全连接的错误率是3%,LeNet 5是0.86%,差了将近4倍。
4.2 想跑快一点?batch size、优化器、学习率的组合建议
如果在你的机器上跑得慢,或者你想做消融实验,可以按这个思路调整超参。
batch size方面,64是个稳妥的起步值。MNIST单张图很小,显存占用基本不是瓶颈,你可以试着翻到128或256,训练速度会快一些,但精度可能会有0.1-0.2个百分点的波动。batch size太小(比如8以下)反而会导致梯度噪声过大,训练不稳定。
学习率方面,SGD+动量0.9搭配lr=0.01是经典配置。如果你想用lr=0.1,大概率会看到loss先下降后上升的发散曲线,因为LeNet 5没有任何归一化层,网络对学习率比较敏感。如果你换成Adam,建议lr直接降到0.001,这个组合下训练过程基本不用操心。
激活函数方面,原版tanh训练起来比ReLU稍慢,主要体现在收敛速度上。把tanh替换成ReLU后,通常第2个epoch精度就能冲上98%,但最终精度差别不大。如果你拿LeNet 5做教学实验,可以保留tanh,让学生体会到"历史真实"。
4.3 同一份代码迁移到CIFAR-10时为什么效果崩盘
很多人复现完LeNet 5会顺手把它拿去跑CIFAR-10,然后发现精度只有60%左右,怀疑代码写错了。我第一次也是这样,后来排查下来代码本身没问题,是模型和任务不匹配。
CIFAR-10是32×32的彩色图像,通道数从1变成3,第一层Conv2d的输入通道必须改成3。LeNet 5没有BatchNorm,没有数据增强,没有残差连接,参数量6万,而CIFAR-10的类别是10类,图像内容比手写数字复杂得多。6万参数的容量很难拟合彩色图像中的海量模式,60%多一点的准确率已经算是"体面的失败"了。
我当时的改进路径是:保持LeNet 5骨架,加入BatchNorm层,把卷积核数量翻倍(C1从6个变成16个,C3从16个变成32个,C5从120个变成256个),再加一点随机裁剪和水平翻转的数据增强,最终把精度拉到了78%左右。这个过程本身就是一个很好的实验——你亲手验证了"模型容量、数据增强和归一化层对CNN性能的巨大影响"。
这也引出一个更本质的认识:LeNet 5的意义不在于它今天还能打赢多少比赛,而在于它是理解所有现代CNN的最小公倍数。你搞懂了LeNet 5的每一层为什么这样设计,再去看VGG的"堆卷积核"、GoogLeNet的"多尺度分支"、ResNet的"残差连接",会发现它们都是在这套卷积+池化+全连接的骨架上做文章。
我自己带新人入门深度学习时,给的第一份任务清单永远包括三件事:手推一遍LeNet 5所有层的输出尺寸和参数量、用PyTorch从零写一遍模型并训练到99%、再把它迁移到CIFAR-10上记录失败原因。这三件事做完,CNN的底座就算打牢了。写代码的过程里新手最容易犯的毛病是复制粘贴模型结构却不打印中间张量形状,出了问题只能干瞪眼。我建议你不管用什么框架写网络定义,先扔一个假数据进去跑一遍forward,确认每一层输出尺寸和你的计算一致,再开始训练。
如果你还想继续往下走,可以尝试改动LeNet 5里的单个组件做对照实验:把平均池化换成最大池化,把tanh换成ReLU,把部分连接表完整实现一遍。每个改动都会带来一点可观测的差异,这种亲手摸清每个旋钮手感的过程,比看十篇论文都管用。