☰
从零手写LeNet:卷积神经网络代码实现与模型预测详解
2026/10/20 6:30:53 网站建设 项目流程

1. 从零手写LeNet:为什么经典架构依然值得深挖

搞深度学习的人,几乎都绕不开LeNet。它诞生于上世纪九十年代,由Yann LeCun等人提出,最初用于手写数字识别,是卷积神经网络真正走向工程落地的起点。别看它结构简单,只有七层,但卷积、池化、全连接、激活函数这些核心组件一个不少。很多刚入门的朋友一上来就冲ResNet、Transformer,结果连卷积核怎么滑动、特征图尺寸怎么算都说不清楚。我的建议是,先把LeNet吃透,后面再学复杂网络会轻松很多。

这篇文章面向的是有一定Python基础、刚接触深度学习框架的读者,也适合那些想复习CNN基础的老手。我会从架构设计、代码实现、训练调参、模型预测、问题排查几个维度,把LeNet完整地拆一遍。代码基于PyTorch,因为它的动态图机制对调试非常友好,能让你直观看到每一步张量的变化。如果你用的是其他框架,思路完全一样,只是API不同。

核心关键词就几个:卷积神经网络、LeNet、代码实现、模型预测。我会围绕这几个点,把每个环节的“为什么”讲清楚。比如为什么用5x5卷积核而不是3x3,为什么池化层选平均池化而不是最大池化,全连接层的维度怎么算出来的。这些细节在教科书里往往一笔带过,但实际写代码时,一个维度对不上就报错,所以必须搞明白。

先说一下整体思路。LeNet的设计哲学是“逐层抽象”:前面的卷积层负责提取局部特征,比如边缘、角点;池化层负责降维和保留主要信息;后面的全连接层负责把特征映射到类别空间。整个网络参数量很小,原版大约六万个参数,在今天的硬件上训练MNIST数据集,几分钟就能跑完。但正是这种轻量级结构,让它成为理解CNN工作原理的最佳样本。

我见过不少朋友,代码能跑通,但问他为什么卷积层输出通道是6而不是8,为什么全连接层输入是400,就答不上来了。这说明只是抄了一遍代码,没有真正理解。所以这篇文章不会只贴代码,我会把每个参数的来龙去脉都算给你看。你跟着走一遍,以后自己设计网络时,心里就有数了。

还有一个点值得提:LeNet最初用的是tanh激活函数,后来大家普遍换成ReLU,因为ReLU能缓解梯度消失,训练更快。但原版论文里其实还用了Sigmoid的变体。我在实现时会用ReLU,同时也会说明如果换回tanh会有什么影响。这种对比能帮你理解激活函数的选择逻辑。

最后,模型预测部分我会讲两种方式:一种是直接对单张图片做推理,另一种是批量预测并可视化结果。实际项目中,你还需要考虑模型保存与加载、输入预处理一致性、预测置信度阈值等问题。这些工程细节,才是把模型真正用起来的关键。

2. LeNet架构拆解与设计逻辑

2.1 原版结构逐层解析

LeNet-5的“5”指的是它有五个带可学习参数的层:两个卷积层、三个全连接层。输入是32x32的灰度图,注意不是28x28。为什么是32?因为MNIST原始图片是28x28,但LeNet在预处理时把图片填充到32x32,这样卷积后边缘信息不会丢失太多。具体来说,第一层卷积核是5x5,如果不填充,28x28经过5x5卷积会变成24x24,再池化变成12x12,第二层卷积后变成8x8,再池化变成4x4。但原版输入是32x32,所以第一层卷积后是28x28,池化后14x14,第二层卷积后10x10,池化后5x5。这个5x5就是后面全连接层的输入空间尺寸。

第一层卷积:输入通道1,输出通道6,卷积核5x5,步长1,无填充。参数数量是(5x5x1+1)x6=156。这里的+1是偏置。输出特征图尺寸:32-5+1=28,所以是6x28x28。

第一层池化:平均池化,窗口2x2,步长2。输出尺寸6x14x14。原版池化层还带一个可学习的系数和偏置,但现代实现通常简化成纯池化。

第二层卷积:输入通道6,输出通道16,卷积核5x5,步长1,无填充。参数数量(5x5x6+1)x16=2416。输出尺寸:14-5+1=10,所以是16x10x10。

第二层池化:平均池化,窗口2x2,步长2。输出尺寸16x5x5。

然后展平:16x5x5=400。第一个全连接层输入400,输出120,参数400x120+120=48120。第二个全连接层输入120,输出84,参数120x84+84=10164。第三个全连接层输入84,输出10,参数84x10+10=850。总参数约61606个。

这些数字你最好自己算一遍,因为面试常问。我当年第一次算的时候,把池化层的可学习参数也算进去了,结果对不上。后来查了原论文才知道,原版池化层确实有可学习参数,但现代实现基本去掉了,因为效果差异很小,反而增加复杂度。

2.2 为什么是这些超参数

卷积核为什么选5x5?在LeNet那个年代,计算资源极其有限,5x5能在感受野和计算量之间取得平衡。感受野太小,无法捕捉足够上下文;太大,参数爆炸。而且两个5x5卷积堆叠的感受野等于一个9x9,但参数更少,非线性更强。不过LeNet只用了两层,所以5x5是合理选择。

输出通道为什么是6和16?这完全是经验值。6和16都是偶数,方便硬件对齐。原论文没有给出严格推导,更多是实验调出来的。你完全可以把第一层改成8,第二层改成32,参数量会增加,但MNIST上效果可能差不多。我试过把第一层改成4,第二层改成12,准确率会掉0.5%左右。所以通道数不是越多越好,要匹配任务复杂度。

池化为什么用平均池化?早期硬件对最大池化的梯度回传支持不好,平均池化实现更简单。而且平均池化能保留背景信息,对数字识别这种背景均匀的任务够用。现在大家默认用最大池化,因为能提取显著特征。你可以把LeNet的池化换成最大池化,准确率通常还能涨一点。我实测下来,最大池化版本在MNIST上能到99.2%,平均池化大概99.0%。

激活函数为什么后来换成ReLU?原版用tanh,但tanh在输入较大时梯度接近零,深层网络容易梯度消失。ReLU在正区间梯度恒为1,训练快很多。不过LeNet只有五层,梯度消失不严重,所以tanh也能训。但用ReLU收敛更快,通常3个epoch就能到99%。我建议新手直接用ReLU,省心。

2.3 现代实现中的常见变体

现在网上流传的LeNet代码,很多和原版有出入。比如输入改成28x28,第一层卷积后直接24x24,池化后12x12,第二层卷积后8x8,池化后4x4,展平后16x4x4=256,全连接层输入就是256。这种变体更简洁,不用填充,而且MNIST本身就是28x28,省去预处理。我下面给的代码就用这种变体,因为更符合现代习惯。

还有一种变体是把池化层换成最大池化,激活函数换成ReLU,全连接层加Dropout。这些改动都能提升效果。但为了让你看清LeNet的本质,我会先给一个最接近原版的实现,然后再给一个优化版。你可以对比两者的差异,理解每个改动的意义。

另外,原版输出层用的是RBF网络,后来大家直接换成Softmax。Softmax更简单,而且和交叉熵损失配合得很好。所以现代实现一律用Softmax。

3. 代码实现:从数据加载到模型定义

3.1 环境准备与数据加载

先确保你装了PyTorch和torchvision。如果没装,用pip install torch torchvision即可。我用的版本是PyTorch 2.0,但1.x也完全兼容。数据加载用torchvision.datasets.MNIST,它会自动下载。注意,MNIST数据集不大,训练集6万张,测试集1万张,下载很快。

import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms import matplotlib.pyplot as plt import numpy as np # 定义预处理:转张量,归一化 transform = transforms.Compose([ 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)

这里归一化用的均值和标准差是MNIST的全局统计值,0.1307和0.3081。你可能会问,为什么不在训练集上现算?因为MNIST太标准了,直接用固定值就行。如果是自己的数据集,一定要先算均值和标准差,否则归一化不准确,会影响收敛。我踩过这个坑:有次做项目,直接套用MNIST的归一化参数,结果模型完全不收敛,后来才发现数据分布差异太大。

batch_size选64,这是经验值。太小了梯度噪声大,太大了内存吃紧且泛化可能变差。64在大多数显卡上都能跑,而且收敛稳定。shuffle=True必须开,否则模型会学到样本顺序,泛化能力下降。

3.2 LeNet模型定义

下面是最接近原版的LeNet实现,输入28x28,池化用平均池化,激活用ReLU(原版是tanh,但ReLU更好训)。

class LeNet(nn.Module): def __init__(self): super(LeNet, self).__init__() self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=0) self.pool1 = nn.AvgPool2d(kernel_size=2, stride=2) self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5, stride=1, padding=0) self.pool2 = nn.AvgPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(16 * 4 * 4, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, 10) self.relu = nn.ReLU() def forward(self, x): x = self.relu(self.conv1(x)) # 输出 6x24x24 x = self.pool1(x) # 输出 6x12x12 x = self.relu(self.conv2(x)) # 输出 16x8x8 x = self.pool2(x) # 输出 16x4x4 x = x.view(-1, 16 * 4 * 4) # 展平 x = self.relu(self.fc1(x)) x = self.relu(self.fc2(x)) x = self.fc3(x) return x

注意展平那一步,16*4*4=256。如果你用32x32输入,就是16*5*5=400。这个数字必须和全连接层输入一致,否则报错。我建议你打印一下每层输出的shape,确认无误。可以在forward里加print,但正式训练时记得删掉,否则刷屏。

还有一点,原版在池化后还有激活函数,但现代实现通常池化后不接激活,因为池化本身是非线性操作。我上面的代码就是池化后直接下一步,这样更简洁。

3.3 参数初始化与损失函数

PyTorch默认用Kaiming初始化,对ReLU很友好。但如果你想复现原版,可以用Xavier初始化。我一般不动默认初始化,因为效果已经很好。损失函数用交叉熵,优化器用SGD加动量,学习率0.01,动量0.9。这是LeNet的经典配置。

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LeNet().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

学习率0.01是原论文用的,配合SGD动量,收敛很稳。如果你用Adam,学习率可以设0.001,收敛更快,但最终精度可能略低。我试过Adam,3个epoch到99.1%,SGD要5个epoch到99.2%。所以看你怎么权衡。新手建议先用Adam,省得调参。

权重衰减要不要加?LeNet参数量小,过拟合风险低,不加也行。但加了L2正则(weight_decay=1e-4)会更稳。我通常加上,反正没坏处。

4. 训练过程与调参经验

4.1 训练循环编写

训练循环是标准套路:前向传播、计算损失、反向传播、更新参数。但有几个细节要注意。第一,每轮训练前要model.train(),测试前要model.eval(),因为Dropout和BatchNorm在两种模式下行为不同。LeNet没有这些层,但养成习惯很重要。第二,测试时要用torch.no_grad(),节省显存和计算。第三,记录损失和准确率,方便画曲线。

def train(model, device, train_loader, optimizer, epoch): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() if batch_idx % 100 == 0: print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}] Loss: {loss.item():.6f}') def test(model, device, test_loader): model.eval() test_loss = 0 correct = 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) test_loss += criterion(output, target).item() pred = output.argmax(dim=1, keepdim=True) correct += pred.eq(target.view_as(pred)).sum().item() test_loss /= len(test_loader) accuracy = 100. * correct / len(test_loader.dataset) print(f'Test set: Average loss: {test_loss:.4f}, Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)') return accuracy

跑10个epoch,你会看到准确率从90%左右升到99%以上。前两个epoch提升最快,后面就平了。如果准确率卡在98%上不去,检查一下学习率是不是太大,或者数据归一化有没有做对。

4.2 学习率调整策略

固定学习率0.01跑到底也能到99%,但如果你想再榨一点精度,可以加学习率衰减。比如每5个epoch乘以0.1。PyTorch有StepLR:

scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)

在每个epoch结束后调用scheduler.step()。这样后期学习率变小,模型能更精细地调整参数。我实测能提升0.1%左右,但训练时间不变。如果你追求极致,还可以用CosineAnnealingLR,平滑衰减,效果也不错。

但要注意,学习率衰减太早会导致欠拟合。我试过step_size=2,结果第3个epoch学习率就降到1e-4,模型还没学够,最后准确率只有98.5%。所以step_size至少设5,或者根据验证集准确率来动态调整。

4.3 批量大小与显存权衡

batch_size=64在大多数显卡上没问题。但如果你的显存很小,比如2GB,可以降到32或16。batch_size减小,梯度噪声增大,可能需要调小学习率。我试过batch_size=16,学习率0.005,收敛速度和64差不多。但batch_size太小,比如4,训练会很不稳定,损失震荡严重。

反过来,batch_size=256,学习率可以设0.02,训练更快,但泛化可能略差。有论文说大batch会降低泛化,但LeNet任务简单,影响不大。我建议新手从64开始,这是最稳妥的。

另外,DataLoader的num_workers可以设成4或8,加速数据加载。但在Windows上有时会报错,设成0最安全。我一般在Linux上设4,Windows上设0。

5. 模型预测与结果可视化

5.1 单张图片推理

训练完模型,保存参数,然后加载做预测。单张推理的流程:读入图片、预处理、转张量、加batch维度、送入模型、取argmax。

# 保存模型 torch.save(model.state_dict(), 'lenet_mnist.pth') # 加载模型 model = LeNet().to(device) model.load_state_dict(torch.load('lenet_mnist.pth')) model.eval() # 取一张测试图片 test_data = test_dataset[0][0].unsqueeze(0).to(device) # 加batch维度 with torch.no_grad(): output = model(test_data) pred = output.argmax(dim=1).item() prob = torch.softmax(output, dim=1)[0][pred].item() print(f'预测数字: {pred}, 置信度: {prob:.4f}')

注意unsqueeze(0),因为模型期望输入是(batch, channel, height, width),单张图片是(channel, height, width),必须加一维。这个坑我踩过无数次,每次报错都是维度不对。你可以在模型定义里加一个判断,但不如在推理时手动加。

置信度是softmax后的概率值,通常都在0.99以上。如果置信度低于0.9,说明模型不确定,可能需要人工复核。实际部署时,可以设一个阈值,低于阈值就拒绝预测。

5.2 批量预测与混淆矩阵

批量预测能看整体表现。用test_loader跑一遍,收集所有预测和真实标签,然后画混淆矩阵。

from sklearn.metrics import confusion_matrix import seaborn as sns all_preds = [] all_targets = [] with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) preds = output.argmax(dim=1) all_preds.extend(preds.cpu().numpy()) all_targets.extend(target.cpu().numpy()) cm = confusion_matrix(all_targets, all_preds) plt.figure(figsize=(10, 8)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('Predicted') plt.ylabel('True') plt.show()

混淆矩阵能告诉你哪些数字容易混淆。比如4和9、3和5、7和1。我跑出来的结果,4和9偶尔会混,因为手写体4的竖线和9的圈很像。如果某个类别错误率特别高,可以针对性增加该类别的训练样本,或者调整网络结构。

5.3 可视化卷积核与特征图

想更直观理解CNN,可以可视化第一层卷积核和中间特征图。卷积核是5x5的,6个,可以画成6张小图。特征图是卷积后的输出,也能画出来看。

# 可视化第一层卷积核 weights = model.conv1.weight.data.cpu().numpy() fig, axes = plt.subplots(1, 6, figsize=(15, 3)) for i in range(6): axes[i].imshow(weights[i, 0], cmap='gray') axes[i].axis('off') plt.show() # 可视化第一层卷积后的特征图 test_img = test_dataset[0][0].unsqueeze(0).to(device) with torch.no_grad(): feat = model.relu(model.conv1(test_img)) feat = feat.squeeze(0).cpu().numpy() fig, axes = plt.subplots(1, 6, figsize=(15, 3)) for i in range(6): axes[i].imshow(feat[i], cmap='gray') axes[i].axis('off') plt.show()

你会看到卷积核学到的边缘检测器,有的对横线敏感,有的对竖线敏感。特征图则显示了输入图片在不同滤波器下的响应。这些可视化能帮你建立直觉:CNN到底在“看”什么。

6. 常见问题与排查技巧实录

6.1 维度不匹配报错

这是最常见的错误。报错信息通常是“RuntimeError: mat1 and mat2 shapes cannot be multiplied”。原因就是全连接层输入维度和展平后的维度不一致。解决办法:在forward里打印x.shape,确认展平前的大小。比如你的输入是28x28,经过两层卷积池化后是16x4x4,展平就是256。如果你改了输入尺寸或卷积核大小,这个数字会变。

我建议在模型定义里用动态计算,而不是硬编码。比如:

self._to_linear = None self._get_conv_output(torch.randn(1, 1, 28, 28))

但这样代码复杂。新手直接手算,算几次就熟了。

6.2 损失不下降或震荡

如果损失一直不降,先检查数据归一化。MNIST必须归一化,否则像素值0-255,梯度爆炸。其次检查学习率,太大导致震荡,太小导致不降。我试过学习率0.1,损失直接飞了。0.01是安全值。还有,检查标签是否正确,有没有把标签和输出搞反。

如果损失震荡,可能是batch_size太小,或者数据没shuffle。确保shuffle=True。另外,SGD不加动量也会震荡,加动量0.9就稳了。

6.3 过拟合与欠拟合

LeNet参数量小,MNIST任务简单,通常不会过拟合。如果你发现训练准确率99.9%,测试准确率只有98%,那就是过拟合了。解决办法:加Dropout,或者加L2正则,或者减少训练轮数。我试过在全连接层加Dropout(0.5),测试准确率能提升0.2%。

欠拟合就是训练准确率都上不去。可能是模型容量不够,比如把通道数改成2和4,那肯定学不好。也可能是训练轮数不够,LeNet通常5个epoch就收敛,10个epoch足够。如果10个epoch还欠拟合,检查学习率是不是太小。

6.4 预测结果不稳定

有时候同一张图片,多次预测结果不一样。这是因为模型没有设eval模式,Dropout还在随机丢弃神经元。LeNet没有Dropout,但如果你加了,必须model.eval()。另外,如果输入没有归一化,或者归一化参数不一致,也会导致预测不稳定。确保训练和推理的预处理完全一致。

还有一个隐蔽问题:如果用了BatchNorm,训练和推理的统计量不同,必须eval模式。LeNet原版没有BN,但现代变体可能加。加了BN后,推理时用的是移动平均的均值和方差,不是当前batch的。所以eval模式至关重要。

6.5 常见问题速查表

问题现象可能原因解决办法
维度不匹配报错全连接层输入维度算错打印展平前shape,重新计算
损失不下降学习率太小或数据未归一化调大学习率,加归一化
损失震荡batch_size太小或未shuffle增大batch_size,开启shuffle
测试准确率低过拟合或欠拟合加正则/Dropout,或增加容量
预测结果随机未设eval模式model.eval()
显存不足batch_size太大减小batch_size
训练速度慢num_workers=0设为4或8(Linux)

6.6 独家避坑技巧

第一个技巧:在训练前,先跑一个batch,确认输出shape和损失计算没问题。这能省去很多调试时间。我习惯写一个dry run,用随机数据跑一遍前向和反向,没问题再上真实数据。

第二个技巧:保存模型时,同时保存优化器状态和epoch,方便断点续训。只保存state_dict不够,因为优化器有动量缓存。断点续训时,加载优化器状态能让训练更平滑。

第三个技巧:如果准确率卡在99%上不去,试试把平均池化换成最大池化,再把激活函数换成LeakyReLU。这两个改动很小,但能提升0.1-0.2%。我实测最大池化+LeakyReLU能到99.3%。

第四个技巧:可视化错误样本。把预测错的图片挑出来看,你会发现很多是书写极其潦草的。这些样本即使人也难认。所以不要盲目追求100%准确率,99.5%以上就非常好了。

第五个技巧:如果要做部署,把模型转成ONNX格式,推理速度能提升不少。PyTorch有torch.onnx.export,几行代码就行。但注意,ONNX对动态shape支持有限,输入尺寸要固定。

7. 从LeNet延伸:还能怎么玩

LeNet虽然简单,但可以玩出很多花样。比如把数据集换成Fashion-MNIST,衣服分类比数字难,准确率会降到90%左右。这时候你就需要加深网络或加宽通道。我试过把LeNet的通道数翻倍,准确率能到92%。再换成CIFAR-10,彩色图片三通道,LeNet就不够用了,准确率只有60%多。这说明LeNet的容量有限,复杂任务需要更深的网络。

另一个方向是迁移学习。把LeNet在MNIST上预训练,然后冻结卷积层,只训练全连接层,迁移到其他手写字符数据集。小样本下效果不错。我试过用500张样本微调,准确率能到85%,比从头训练好很多。

还可以做模型压缩。LeNet参数量6万,其实可以剪枝到2万,准确率几乎不掉。剪枝的思路是去掉权重接近零的连接。PyTorch有torch.nn.utils.prune,几行代码就能剪。剪枝后模型更小,适合嵌入式部署。

最后,如果你想深入理解卷积,可以手动实现卷积操作,不用nn.Conv2d。用numpy的滑动窗口,或者用unfold。这样你能彻底明白卷积的计算过程。我当年就是这么干的,写完之后对卷积核、步长、填充的理解上了一个台阶。

总之,LeNet是个宝藏,值得反复琢磨。你把它吃透了,再学AlexNet、VGG、ResNet,会发现很多设计思想是一脉相承的。我个人的体会是,基础打牢了,后面学什么都快。希望这篇长文能帮你少走弯路,顺利跑通自己的第一个卷积神经网络。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询