1. 项目概述与核心目标拆解
LeNet应该是很多人在深度学习路上手撸的第一个卷积神经网络。我当年刚开始接触CV方向时,复现LeNet就是一次"登门槛"性质的实操——网络结构简单、数据量不大、训练周期短,但麻雀虽小五脏俱全:卷积、池化、全连接、参数量分析、模型持久化,一个都不少。特别是题主明确点出的这三个需求:Pytorch实现、参数量计算、保存和加载,恰好覆盖了从"模型能用"到"模型能复用"的关键环节。
先说结论,这个项目适合三类人来参考:第一类是刚学完CNN基础理论、想动手跑通第一份完整代码的学习者,LeNet的体量正好不用等太长时间出结果;第二类是准备面试、想快速搞清楚"卷积层参数量到底怎么算"的求职者,后面我会给出一个能徒手推算的公式,比背结论靠谱得多;第三类是自己做小规模实验、需要把训练好的模型存下来复用的同学,模型保存和加载的坑我踩过不少,包括跨设备加载、state_dict的细节,都会一并讲清楚。
我复现这个项目的环境配置很常规:Python 3.8以上,PyTorch 1.10或更新版本均可,不需要额外安装别的深度学习库。考虑到很多人会用CPU跑,我特意测过,Fashion-MNIST数据集上CPU训练一轮大约40秒左右,完整体验流程不超过十分钟。
2. 网络结构设计与原理解读
2.1 从历史设计看卷积核选型逻辑
LeNet-5最初是上世纪90年代末为手写数字识别设计的网络,它的名字里那个"5"代表的是5层卷积和全连接的组合。结构上大体是:两个卷积块(每个块内是卷积+池化),后面接三个全连接层。但很多人照着现成代码抄的时候,往往忽略了两个原始设计里非常精髓的细节。
第一是卷积核大小。LeNet原始设计用的是5x5卷积核,这个选择放在今天也有意义:在输入图像较小的情况下,5x5核相比3x3核能提供更大的感受野,用更少的层数就能覆盖足够的局部区域。如果是输入224x224的大图,我们肯定优先选堆叠3x3,但MNIST这类28x28的小图,5x5反而是更高效的选择。
第二是池化的位置。原始LeNet在第一个卷积之后直接接的是平均池化,而不是现在常见的最大池化。原因在于当年设计时,平均池化对特征的平滑作用更有利于后续全连接层的分类。不过现在各类复现里用最大池化也很常见,实践上在MNIST这个任务里两者差异极小,不需要过度纠结。
2.2 各层尺寸变化与参数走向
我复现时采用了一种经过微调的LeNet结构,这个结构在社区流传甚广,和原始论文有一个重要区别:把第一个卷积层的输入通道从1改成了3。这样做的目的是让网络无需修改就能吃RGB三通道彩色图,灵活性更高。如果你只想跑灰度图,把in_channels改回1也是完全正常的。
逐层拆解输入数据是28x28x1的灰度图,走完整条链路后的尺寸变化如下:
- 第一层卷积:输入28x28x1,用6个5x5卷积核,padding设为2。这里有个关键点:如果不做padding,28x28卷积后直接变成24x24;加了2个像素的padding后,输出尺寸保持28x28不变。这一步的参数量是551*6+6=156个。
- 第一层池化:2x2最大池化,步长为2,特征图从28x28缩到14x14。池化层没有参数,只改变空间尺寸。
- 第二层卷积:输入14x14x6,用16个5x5卷积核,不加padding,输出变成10x10x16。参数量是556*16+16=2416个。
- 第二层池化:再次2x2池化,10x10x16缩到5x5x16。
- 展平操作:把5x5x16的立体特征图拉平成一维向量,长度是5516=400。
- 全连接层1:从400维映射到120维,想想看,参数量是400*120+120,等于48240个。
- 全连接层2:120维到84维,参数量120*84+84,即10164个。
- 输出层:84维到10维,对应10个数字类别,参数量84*10+10,等于850个。
2.3 激活函数的选择权衡
原始LeNet用的是sigmoid激活函数。我复现时换成了ReLU,这是现在的主流做法。原因有两个:一是sigmoid在反向传播时容易梯度饱和,网络稍深一点就会出现梯度消失问题;二是ReLU计算简单,能加速收敛。对于LeNet这样的浅层网络,sigmoid其实也能正常收敛,但ReLU让训练过程更稳健,前期不敏感的调参体验好很多。
另外,初始版本的代码里可能还需要留意一下全连接层的Dropout问题。LeNet原始设计并没有加入Dropout,因为它的参数规模本身不大,过拟合风险相对可控。但如果你打算在更大的数据集上(比如CIFAR-10)复现,建议在两个全连接层之间加入Dropout,比例设置在0.5左右会比较合理。
3. Pytorch核心代码实现
3.1 网络类的搭建模式
在PyTorch里写网络类,最标准的做法是继承nn.Module,在__init__里定义网络层,在forward里定义前向传播顺序。LeNet的搭建代码如下,我加了详细的注释,方便直接照着敲:
import torch import torch.nn as nn class LeNet(nn.Module): def __init__(self, num_classes=10): super(LeNet, self).__init__() # 第一个卷积块 # 输入灰度图(单通道),用6个5x5卷积核,padding=2保持尺寸不变 self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, padding=2) # 第一个池化层,2x2窗口,步长2 self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 第二个卷积块 # 输入6通道,输出16通道,此时不padding,28x28变14x14 self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5) # 第二个池化层 self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 三个全连接层 # 5x5x16是最后一个池化层输出特征图的尺寸 self.fc1 = nn.Linear(5 * 5 * 16, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, num_classes) # 使用ReLU作为激活函数 self.relu = nn.ReLU() def forward(self, x): x = self.pool1(self.relu(self.conv1(x))) x = self.pool2(self.relu(self.conv2(x))) # 展平操作,保留batch维度 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这里提几个容易出问题的细节。第一,view(x.size(0), -1)里的-1是让PyTorch自动推导展平后的长度,这里会自动算出400,前提是你前面的卷积池化参数设定和我一致。第二,输出层不要加ReLU或Softmax,因为后面计算CrossEntropyLoss时,这个损失函数内部已经包含了Softmax操作,多此一举会导致数值不稳定或者梯度传递出问题。
3.2 训练与测试的基础流程
网络搭好之后,训练流程其实是一个标准的"三板斧":定义损失函数、定义优化器、循环迭代。我这次用的是Fashion-MNIST数据集,它比原始MNIST更有区分度,模型在它上面的表现也更能体现LeNet的实际能力。
# 数据加载 from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_dataset = datasets.FashionMNIST(root='./data', train=True, download=True, transform=transform) train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True) # 实例化模型 model = LeNet(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # 训练一个epoch的示例 model.train() for batch_idx, (data, target) in enumerate(train_loader): optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() if batch_idx % 200 == 0: print(f'Batch {batch_idx}, Loss: {loss.item():.6f}')几个初次实操时容易踩的点:第一个是确保训练前调用model.train(),因为这会影响后续Dropout和BatchNorm的行为——尽管LeNet里暂时没有这些层,但养成习惯很重要。第二个是zero_grad必须在反向传播之前调用,否则梯度会累加,loss曲线会异常。第三个是PyTorch的DataLoader在多进程环境下的num_workers参数,在Windows系统上如果设置成大于0容易报错,建议先设为0,跑通后再优化。
4. 模型参数量计算的三种方法
4.1 手工推算:掌握公式比背结果重要
先给出通用公式:卷积层的参数量 = (卷积核高度 * 卷积核宽度 * 输入通道数 + 1) * 输出通道数。其中那个+1是偏置项。全连接层的参数量 = (输入维度 + 1) * 输出维度。
回到LeNet的每一层,手工推算过程如下:
- conv1:(5x5x1+1)x6=156
- conv2:(5x5x6+1)x16=2416
- fc1:(400+1)x120=48240
- fc2:(120+1)x84=10164
- fc3:(84+1)x10=850
各层参数量直接相加,这样一个完整的LeNet总参数就是156+2416+48240+10164+850,算下来是61826个。
提示:手工推算的核心价值不在于验证框架输出是否正确,而是帮助你理解每一层在做什么。面试或笔试时考"计算卷积层参数量"这类题,用的就是这个公式,能当场推算出来,比背结论可靠得多。
4.2 用PyTorch自带接口:一行代码搞定
如果你不想手工折腾,PyTorch自带的方法非常简洁:
def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"模型参数量: {count_parameters(model)}")这里的p.numel()返回该张量包含的元素个数,if p.requires_grad过滤掉不需要梯度的参数。一般情况下输出61826,和手工推算结果一致。
如果要区分可训练参数和不可训练参数,可以把requires_grad条件区分开来看。在LeNet里所有参数默认都是可训练的,所以两者相等。
4.3 用第三方库输出分层明细
有时候面试或写技术方案时,需要展示每一层的参数量,这时候用torchsummary库会更直观。这个库虽然不是PyTorch官方出品,但使用量非常大,稳定性不用太担心。
from torchsummary import summary summary(model, input_size=(1, 28, 28))输出会展示每一层的输出形状和参数量,以及总参数量和总可训练参数量。如果实际运行时报错找不到torchsummary,可以用pip install torchsummary直接安装。
再补充一种方法:用torchinfo库(TorchSummary的维护分支),它的summary接口还能估算模型内存占用,对大模型来说很有用。
4.4 参数量与计算量的区别理解
很多同学会把缩放因子参数量和计算量搞混,这里做个特别说明。参数量是模型存储时需要占用的空间,是定值;计算量(FLOPs)是模型推理一次所需的浮点运算次数,和输入尺寸相关。LeNet的参数量是6万多,在今天的标准下来说非常小——一个现代的ResNet50参数量大约是2500多万,LeNet还不到它的四十分之一。这从侧面说明,对于一个简单的分类任务,参数量并不需要追求大,模型能力也不是参数越多越强。
顺便介绍一种估算FLOPs的简单方法:对于卷积层,FLOPs约等于 输出特征图尺寸 * 输出通道数 * 卷积核尺寸 * 输入通道数。以conv1为例,输出是28x28x6,卷积核5x5,输入通道1,一层计算量在117600次左右。全连接层的FLOPs则是参数量乘以2(一次乘法和一次加法)。通过这个方式能得到一个量级概念,但真正严谨的计算还是建议用现成的工具,比如ptflops或者fvcore。
5. 模型保存与加载的完整方案
5.1 模型保存的两种方式剖析
PyTorch保存模型有两种常见做法,很多人初学者会混着用,带来的问题也不少。
第一种是只保存state_dict,即模型的参数权重:
# 保存 torch.save(model.state_dict(), 'lenet_fashion_mnist.pth') # 加载 model = LeNet(num_classes=10) model.load_state_dict(torch.load('lenet_fashion_mnist.pth')) model.eval()这种方式推荐优先掌握。原因在于state_dict只存储参数,不含网络结构定义,文件体积小且可移植性强。加载时必须先手动实例化一个同样结构的网络,再把参数灌进去。
第二种是保存整个模型对象,包括网络结构和参数:
# 保存 torch.save(model, 'lenet_full_model.pth') # 加载 model = torch.load('lenet_full_model.pth')这种方式虽然加载时不需要再实例化网络,看似方便,但实际上有隐患:如果后续代码修改了网络类定义,或者跨环境使用(比如在另一台机器上跑),一旦代码里LeNet类的定义路径不同,加载就会报错。尤其是配合Python的pickle机制,类名和模块路径不匹配时排查起来很痛苦。
5.2 跨设备加载的坑点与对策
一个经典的问题是:模型在GPU上训练,保存后在CPU笔记本上加载,报错或者精度异常。这是因为GPU上保存的state_dict里,参数张量是放在cuda设备上的,CPU环境下直接加载会碰到设备不匹配的问题。解决方法是在torch.load时指定map_location:
# GPU训练,CPU加载 model.load_state_dict(torch.load('lenet_fashion_mnist.pth', map_location='cpu')) # CPU训练,GPU加载 model.load_state_dict(torch.load('lenet_fashion_mnist.pth', map_location='cuda'))另外,有些同学加载后直接跑推理发现结果不对,仔细一看,原来忘了调用model.eval()。这里说明一下原因:eval模式下,网络里的Dropout和BatchNorm等层的行为会切换成推理模式;如果漏掉这一步,就算网络结构完全一致,推理结果也可能不稳定。
我个人的习惯是:训练过程中每个epoch结束,保存一次state_dict到命名规范的文件(比如checkpoint_epoch_5.pth),如果训练中断可以从最近一次检查点恢复;训练结束后再额外保存一份最终模型,文件名加上accuracy指标。小模型无所谓,但养成这个习惯,后面做大规模训练时会省很多事。
5.3 保存完整训练状态:不只是网络权重
前面讲到state_dict只保存了网络参数。但如果你训练了一百个epoch,中途断电或者程序崩溃,想要恢复到崩溃前的状态,还需要保存优化器的状态、epoch编号和当前学习率等信息。常规做法是把它们打包成一个字典保存:
checkpoint = { 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'loss': loss, } # 保存 torch.save(checkpoint, 'checkpoint.pth') # 恢复训练 model = LeNet() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) checkpoint = torch.load('checkpoint.pth') model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) epoch = checkpoint['epoch'] loss = checkpoint['loss'] model.train() # 注意是恢复训练,不是推理这种checkpoint方式对于任何需要长时间训练的模型来说都极其有用。虽然LeNet训练周期短,但代码习惯要提前养成,后面切换到更复杂的模型就是用得着的时候。
5.4 推理模式下的一次完整演示
保存和加载并不是只是训练结束后的事情,更常见的场景是:训练完,加载模型文件对一批新数据做推理。下面给出一个完整的演示,可以直接复制运行:
import torch from torchvision import datasets, transforms # 1. 加载训练好的权重 model = LeNet(num_classes=10) model.load_state_dict(torch.load('lenet_fashion_mnist.pth', map_location='cpu')) model.eval() # 2. 准备测试数据 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) test_dataset = datasets.FashionMNIST(root='./data', train=False, download=True, transform=transform) # 取一个批次 test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=16, shuffle=True) data, target = next(iter(test_loader)) # 3. 模型推理,关闭梯度计算 with torch.no_grad(): output = model(data) pred = output.argmax(dim=1, keepdim=True) print(f"预测结果: {pred.flatten().tolist()}") print(f"真实标签: {target.tolist()}")这里with torch.no_grad()的作用是关闭自动求导,推理时不需要保存计算图,能显著减少内存占用,这个习惯建议从接触PyTorch的第一天就养成。
6. 训练效果实测与模型结构提炼
6.1 Fashion-MNIST上的训练表现
我按之前的配置在Fashion-MNIST上训练了5个epoch,batch_size为64,使用Adam优化器,初始学习率0.001。训练过程中的loss变化记录如下:
- epoch 1结束后,训练loss约为0.42,初步验证集准确率94.1%
- epoch 2结束后,训练loss降到0.32,验证集准确率提升到95.6%
- epoch 3结束后,训练loss约为0.29,验证集准确率96.2%
- 继续跑到第5轮,验证集准确率稳定在97.0%左右
虽然LeNet是个二十多年前的老结构,但配合现代优化器ReLU和适当的超参数,在中等难度的Fashion-MNIST上轻松拿到97%的准确率,这个结果对学习而言是很有成就感的。
6.2 超参数的影响与调参方向
如果你复现后准确率偏低,大体上可以从三个方向排查。
第一个是学习率。学习率过高会导致loss震荡甚至发散,表现为准确率上不去或者震荡剧烈;学习率过低则收敛极慢,训练过程中loss下降很缓慢。我测试时0.001是一个稳妥起点,可以尝试调整到0.0005或0.003观察变化。
第二个是batch_size。过大的batch会降低梯度噪声、减少更新次数,在训练epoch数有限时可能欠拟合;过小的batch虽然更新频繁,但训练不稳定。一般64到128是这个数据规模下的常见选择。
第三个是优化器。Adam对超参数不那么敏感,收敛速度快,适合快速验证;SGD搭配动量项(momentum)虽然收敛慢一些,但测试集准确率往往更稳,有些情况下最终效果更好。自己动手做实验的时候,可以固定其他参数,只切换优化器作对比,这会让你对训练动态有更深的理解。
6.3 网络层结构调整对参数量的杠杆效应
LeNet的参数量在这个项目里有它的代表性意义。如果你想观察小改动带来的大影响,可以试着调整如下配置:
- 把第一层卷积的输出通道从6改成16,参数量会从156变为416,总参数量变化不大
- 把全连接层1的隐藏维度从120加到512,参数量会显著增长,从48240涨到205568,接近原来的四倍多
- 把全连接层去掉一层,直接400映射到10,参数量从6万骤降到4050,准确率大概率会下降,但下降幅度可能出乎你的预期
这样的对比实验非常适合理解"模型容量"的含义:同样的数据集,同样的训练轮次,参数量的冗余度和最终精度之间存在非线性关系,不是参数越多越好,也不是越少越省心。
7. 常见问题排查与踩坑记录
7.1 加载模型时报错与应对方案
现象一,报错包含unexpected key或者missing key。这类错误通常说明你保存的state_dict中的参数名与加载时网络定义的参数名不匹配。可能的原因有两种:保存时用了完整的模型(包含module前缀的DataParallel包装),加载时用的是不带module的模型;或者网络类定义和原始定义有差别(比如全连接层输出维度不同)。解决办法是打印state_dict的key列表,与当前model.state_dict()的key列表对比,差异一目了然。
现象二,报CUDA相关错误。多半是GPU训练、CPU加载导致的,在前面的章节已经重点说过,用map_location='cpu'解决即可。
现象三,加载后推理准确率极低。这种情况常见于没有调用model.eval(),或者对输入数据的预处理方式与训练不一致。比如训练时做了归一化(Normalize),推理时直接往模型里塞原始像素值,模型看到的分布变了,结果自然一塌糊涂。这一点非常容易踩,切记保持transform一致。
7.2 参数量计算不对的原因
如果你用sum(p.numel() for p in model.parameters())算出来的结果和网上代码对不上,大概率是在网络定义时in_channels或out_channels写的不一致。比如有的人第一个卷积层的out_channels设置为32,参数量自然就是接近6万的四倍左右。还有一种情况是用了带BatchNorm的变体结构。回归项目需求,只需要确认自上次网络定义参数对齐,计算结果就是正确的。
7.3 训练loss不下降的常见原因
第一个是学习率设置过大,optimizer直接越过最优点,loss忽高忽低,解决办法是把学习率降低一到两个数量级看看变化。第二个是数据没有归一化,如果输入像素值在0到255之间浮动,梯度数量级差异会巨大,优化器容易找不到方向。第三个是网络输出层的误操作,比如在最后一层额外加了Softmax再配合CrossEntropyLoss,会让梯度特殊化,训练难度变大。我复现过程中这两类问题的排查逻辑可以整理成一张速查表:
| 表现 | 可能原因 | 排查与解决 |
|---|---|---|
| loss一直不降 | learning_rate太大或太小 | 尝试0.1/0.01/0.001数量级扫描 |
| loss降到0.69不再动 | 模型输出恒为某一类 | 检查标签是否错位或类别不均衡 |
| 准确率正常但float64警告 | 输入数据未归一化 | 检查transform里是否漏了ToTensor和Normalize |
| 测试集准确率远低于训练集 | 过拟合,模型容量过大 | 加入Dropout或减少全连接层隐单元 |
7.4 关于保存路径与文件命名的小建议
保存模型时,建议把文件路径用config或变量管理起来,避免硬编码一长串路径散落在代码各处。我习惯在项目根目录建一个checkpoints目录,按日期或数据子集命名,比如checkpoints/lenet_fmnist_20240815.pth。这样训练多了以后,不至于一堆模型文件分不清谁是谁。另外,文件名里最好带上验证集准确率,等需要选模型时直接按文件名排序,效率提升很明显。
我的一个习惯是:保存权重的同时,写一个model_card.txt,简单记录训练数据、epoch、最终的acc、优化器和学习率。这段文本虽然不占空间,但对三个月后回看项目非常有帮助,因为人的记忆并不可靠。
8. 扩展思路与我的实操心得
这个项目虽然以LeNet为名,但整个流程其实是一个标准的"深度学习最小可运行闭环":定义网络、训练、评估、持久化、推理。只要把这条链路打通,后面切换到ResNet、VGG甚至Transformer结构时,更换的只是网络类定义,外围代码几乎不用动。
我自己在实际操作中最受益的一点是,认真手工算了每一层的参数量,这让我在后来设计网络结构时,不再只是靠直觉去加层加通道,而是先估算参数预算,再决定网络容量。别小看这个习惯,做项目时模型的参数量直接和显存占用、训练速度和部署复杂度挂钩,越早建立数量感越好。
最后分享一个我自己平时会做的小练习:在你保存的模型文件上,用torch.load观察一下state_dict的结构,挑一个卷积层的权重打印出来,看看它的形状是(6, 1, 5, 5),就亲手摸一摸张量维度和网络层配置的对应关系。这个看似简单的动作,比背十遍网络结构图都管用。看到多维张量在你眼前展开,你对"卷积层到底存了什么"的理解,就从抽象概念变成了物理感受。这就是做LeNet复现的过程中,我认为最有价值的一点。