在上一篇中,我们用“城市侦查系统”的比喻,一步步剖析了卷积神经网络(CNN)的基本模块,接下来我们来看一个 真实、完整、又不太复杂 的 CNN 结构:LeNet-5
首先我们回顾一下图像分类任务中,典型的卷积神经网络通常由以下几个主要组成部分构成:
模块 | 功能 |
---|
输入层 | 接收图像张量(如 3×32×32) |
卷积层(Conv) | 模拟人眼视觉的局部感受野 ,提取局部图像特征(如边缘、角落) |
激活函数(Activation) | 引入非线性能力,让网络能学习复杂模式 |
池化层(Pooling) | 降低特征图尺寸,保留主要信息,减少参数 |
展平操作(Flatten) | 将多维特征图转换为一维向量,准备进入全连接层 |
全连接层(FC) | 汇总全局信息,完成最终分类/回归 |
输出层 | 生成最终预测结果,如类别分数, 通常输出 raw logits,用于后续计算损失,训练时不需加 softmax |
LeNet:经典卷积神经网络结构介绍
LeNet 是最早的卷积神经网络(CNN)系列之一,由 Yann LeCun 等人在 1990 年代早期逐步提出,主要用于手写字符识别任务。其中最具代表性的版本是 LeNet-5,该结构于 1998 年在经典论文《Gradient-Based Learning Applied to Document Recognition》中正式发表,广泛应用于 MNIST 数据集的分类问题。它标志着深度学习在图像识别领域的早期应用,虽然在现代任务中已较少使用,但 LeNet 作为经典结构,常被用于教学示范、模型初学者入门练习,以及轻量级模型的参考框架。在边缘设备或资源受限的场景下,其简单高效的结构仍具有实际意义
网络结构
LeNet-5(可视化、论文)(该系列的第五个版本)是 LeNet 系列中最经典的结构,其主要由以下几个部分组成:
LeNet-5 网络结构总览
以下表格新增了参数数量与连接数,参数数量是 “要学习的内容“或者是单个可被训练调整的单位
比如:
- 一个 5×5 的卷积核扫描灰度图(1通道),它有 25 个权重 + 1 个偏置 = 26 个参数
- 如果有 6 个这样的卷积核(也就是 6 个侦查器)→ 参数数量:6 x1(1通道) × 26 = 156
连接数则代表了这些参数在一次完整扫描中与输入发生了多少次交互,也可以理解为:模型对每个像素进行了多少次计算
可以这样想:
- 一个 5×5 的卷积核每次滑动,会使用 25 个像素进行加权计算,输出 1 个“打分”
- 如果它在图像上滑动了 28×28 次(比如输出特征图是 28×28),那就总共产生了 784 个输出点
- 每个输出点都来自一次 25 像素 × 25 权重的操作,因此连接数 = 784 × 25 = 19,600
从比喻角度理解:
连接数就像是无人机探测器对城市像素进行比对的总次数——每次滑动它都查看一个区域,区域内的每个像素都和相应权重配对,整张图像被反复扫过,所有这些“扫描配对”操作加起来,就是连接数
简而言之:
- 参数数量 = 有多少“要学习的值”
- 连接数 = 这些参数在一次前向传播中总共参与了多少次像素计算
层级 | 类型 | 卷积核大小 / 数量 | 输出特征图尺寸 | 神经元数量 | 参数数量 | 连接数 | 说明 |
---|
输入层 | 输入图像 | – | 32×32×1 | 1024 | 0 | 0 | 注: 输入图像在CNN 通常不计入总层数,MNIST 原图28×28,通过 zero-padding 变为 32×32 灰度图 |
C1 | 卷积层 | 5×5×6 | 28×28×6 | 4,704 | (1×25+1)×6=156 | 28×28×6×(25+1)= 122,304 | 单通道,6个卷积核,输出6张特征图 每核5x5=25权重+1偏置=26连接,步长1,无Padding |
S2 | 平均池化层 | 2×2×6(stride=2) | 14×14×6 | 1,176 | 12 | 5*1176=5,880 | 每特征图:1权重+1偏置(详见下文解释), 每输出连接2×2输入+偏置=5连接 |
C3 | 卷积层(部分连接) | 5×5×16 | 10×10×16 | 1,600 | 1,516 | 156000 | 每个卷积核连接S2部分特征图(详见论文表1),共16张输出图 ,无Padding |
S4 | 平均池化层 | 2×2×16(stride=2) | 5×5×16 | 400 | 32 | 2,000 | 同S2 |
C5 | 卷积层 | 5×5×120 | 1×1×120 | 120 | 48,120 | 48,120 | 每个卷积核接收S4全部16个特征图(共400输入)+1偏置;等价于全连接 |
F6 | 全连接层 | – | 1×1×84 | 84 | 10,164 | 10,164 | 连接C5全部120个输出;每节点120权重+1偏置 |
输出层 | RBF输出层 | – | 1×1×10 | 10 | 840 | 840 | 连接F6全部84个输出;每类存储1个84维原型向量(不含偏置) |
特点与说明
神经元:指的是输出特征图中的数值总数(计算后输出的结果单元,是神经网络中实际“活跃”的“判断节点)
连接数:包括所有卷积核的乘法连接与偏置连接。对于全连接层,则为“输入数 × 输出数”
参数数量:= (输入通道 × kernel大小 + 偏置)× 输出通道
输入层说明:Input在 CNN 中不作为正式的网络层,但在教学中通常单独列出
RBF输出层:原 LeNet-5 使用欧氏距离判别,不使用 Softmax;每类使用一组权重向量
C3层连接方式:部分连接,共 1,516 个参数,151,600 个连接;避免对称性、提升特征多样性
激活函数:早期的一些教程和实现在未严格对照论文的情况下,用了 sigmoid 或者 ReLU 来替代 tanh,但 LeNet-5 原始设计确实用 tanh ,现代实现常替换为 ReLU(不影响参数总数)
第 6 页明确描述 C1 层(卷积层)
- 输入与连接: 输入为32×32的灰度图像。C1有6个卷积核,每个卷积核大小5×5,步幅为1,无填充。因此每个卷积核在输入上产生28×28的特征图(计算:(32−5)/1+1=28)。6个卷积核共得到6个输出特征图,尺寸为28×28。输出神经元总数为28×28×6=4,704个
- 参数计算: 每个卷积核有5*5=25个权重,加1个偏置,共26个参数。6个卷积核总参数 = (1 × 25 + 1) × 6= 156。这些参数包括所有可训练权重和偏置,与论文一致
- 连接数: C1 每个输出单元连接输入层一个5×5区域,加上偏置作为额外连接,共26条连接。整个C1层连接总数 = 26*(输出神经元数)=26*4704=122,304 条。有些资料只计权重连接(25×4704=117,600)不含偏置,但为严谨起见,我们包含偏置连接在内得到122,304,与原论文一致
S2 层(子采样/池化层)
- 输入与连接: S2对C1的6个特征图逐一池化。每个S2特征图对应C1的一个特征图。池化窗口为2×2,步幅2,采用平均池化(将2×2区域像素求和后乘以系数,再加偏置)。由于C1输出28×28,经2×2池化后降为14×14。因此S2有6个特征图,尺寸14×14,每个单元接收来自对应C1特征图2×2区域的输入
- 参数计算: 每个S2特征图有1个可训练权重系数(池化结果要乘个比例)和1个偏置,用于线性变换池化和加偏置,在现代 CNN 中,我们通常使用 MaxPooling,它是完全无参数的,仅仅取最大值。但在 LeNet-5 中,S2 层使用的是“带参数的平均池化”(subsampling + trainable scaling)。这种设计反映了早期 CNN 的一个思想:“子采样”不只是降维,也可以加入轻微的线性调节(如增强、压缩)能力,6个特征图参数总数 = 6*(1+1)=12。这些参数同样包含偏置
- 连接数: 每个S2单元连接来自C1的4个输入单元,再加1偏置系数作用(相当于将偏置看作固定输入1的连接),因此每个输出单元5条输入连接。S2共有14×14×6=1,176个单元,总连接数 = 5*1176=5,880(若不计偏置连接则为4×1176=4,704)。S2输出特征图面积正好是C1的1/4
C3 层(卷积层,部分连接)
- 输入与连接: C3有16个卷积核,大小5×5,来自S2层的输入。卷积步幅为1,无额外填充。S2提供6个14×14特征图作为输入。如果采用完全连接(每个卷积核连接S2所有6个输入图),输出特征图尺寸将是10×10(计算:(14−5)+1=10)。实际采用部分连接策略:每个C3卷积核只连接S2层的一个子集特征图,从而打破各特征图对称性并减少参数。这种“不完全连接”确保不同卷积核提取互补特征
- 部分连接模式: 原论文给出了16个特征图的具体连接方案。综合各资料描述,其模式如下:
- 前6个C3特征图:各连接 S2 的3个特征图(相邻的3幅组合)
- 中间6个C3特征图:各连接 S2 的4个特征图(相邻的4幅组合)
- 接着3个C3特征图:各连接 S2 的4个特征图(非相邻组合,以获取跨空间的特征)
- 最后1个C3特征图:连接 S2 的全部6个特征图。通过这种设计,不同卷积核看到不同的输入子集,既保留足够的感受野组合,又控制参数数量
每个卷积核连接S2部分特征图 - 参数计算: 按上述部分连接模式计算总参数:
- 对于连接3个输入图的6个卷积核:每核权重数 = 5*5*3=75,加偏置1,共76个参数;6核参数 = (3×25+1)×6=456
- 对于连接4个输入图的6个卷积核:每核参数 = 5*5*4+1=101;6核参数 = (4×25+1)×6=606
- 对于连接4个输入图的3个卷积核(另一组合方式):每核参数同为101;3核参数 = (4×25+1)×3=303
- 对于连接所有6个输入图的1个卷积核:参数 = (6×25+1)×1=151。将以上加总:456+606+303+151=1,516 个参数,这包含了所有权重和偏置,与论文报告的 C3 参数量1,516完全一致
- 输出规模: 每个C3特征图尺寸为10×10(与全连接情况相同,因为卷积核大小和步幅不变,只是输入通道不同)。C3层总神经元数为10×10×16 = 1,600
- 连接数: C3总连接数可以用输出神经元数乘以每个神经元的输入连接数估算。每个输出神经元的连接数等于该神经元卷积核的权重数(不计偏置)=25×(连接到的输入图数)。根据上述模式计算总连接:
- 10×10×6×(25+1)×3= 46800 +
- 10×10×6×(25+1)×4= 62400 +
- 10×10×3×(25+1)×4= 31200 +
- 10×10×1×(25+1)×6= 15600
- 应得到156000条连接。文献报道的连接数正是156000
说明: 许多二次资料未提及C3的部分连接,往往简化为“6输入通道、16输出通道的卷积层”,这会错误计算参数为(6×25+1)×16=2,416而非1,516。实际LeNet-5通过部分连接减少了900个参数,同时保留了丰富的特征组合。这一点在原论文中特别强调,用于打破对称、提高学习效果
S4 层(子采样/池化层)
- 结构: S4与S2类似,是对C3输出的池化层。输入为16个10×10特征图,经2×2平均池化(步幅2)后变为16个5×5特征图。每个S4单元汇聚C3对应特征图的2×2区域。
- 参数计算: 每个S4特征图有1个权重+1个偏置,共2个参数,16个特征图总参数 = 2*6=32。S4层参数包含偏置在内,共32个,可训练参数较少
- 输出与连接: 输出神经元数为5×5×16=400。每个输出单元连接4个输入+1偏置,同理总连接数 = 400*(4+1)=2,000条(若不计偏置则1,600条)。文献数据表明S4有2,000连接。注意: 个别资料误写S4输出尺寸为7×7,那是基于错误假设C3输出14×14(可能假定C3使用填充保持尺寸)。实际上C3未填充,输出10×10,池化后正确尺寸是5×5,请以5×5为准
C5 层(卷积层,等价全连接层)
- 结构: C5层有120个卷积核,大小5×5,输入来自S4的16个5×5特征图。由于输入特征图本身尺寸也是5×5,与卷积核相同,卷积覆盖整个输入图像,从而每个卷积核产生1×1的输出。因此C5实际上得到120个1×1的特征图(每个相当于一个数值),总计120个神经元。因为卷积核覆盖了输入的完整区域,功能上等价于一个全连接层:每个输出与前一层所有16×5×5输入单元相连
- 参数计算: 每个卷积核从16个输入图各5×5区域接收,即每核权重数 = 5*5*16=400,另加1个偏置,共401个参数。120个卷积核总参数 = 120*401 = 48,120。这与公式(16*25+1)*120一致。注意有的资料将C5参数简写为48,000(不计偏置);实际上应包含偏置达到48,120,与原论文吻合
- 连接数: C5层每个输出神经元连接前一层所有16*5*5=400个输入,加上偏置,共401条输入连接。因此总连接数 = 401*120 = 48,120,等于参数总数(每个权重对应一条连接)。文献中常提到C5有48k左右连接和参数
F6 层(全连接层,隐藏层)
- 结构: F6为全连接层,含84个隐含节点(神经元)。这个84的数量在LeNet-5中是经验选择的数值。每个F6节点与C5层全部120个输出相连
- 参数计算: 每个F6节点有120个输入权重,加1个偏置,共121个参数。84个节点总参数 = 84*121 = 10,164。可见参数公式为(120+1)*84=10,164,与原论文和权威资料完全一致
- 连接数: 每个F6节点接收120个输入(不含偏置),总连接数 = 120*84 = 10,080。若包括偏置作为特殊连接,则为10,164,与参数数相等。上一层C5只有120个神经元,因此全连接计算量仍较小
输出层(RBF输出层 / 第7层)
- 结构: LeNet-5的输出层有10个单元,对应数字分类0-9的10个类别。原论文采用径向基函数(RBF)单元作为输出,每个输出单元存储一个84维的原型向量,用来表示某一类别的“中心”。输出的计算并非传统Softmax,而是根据输入与该原型的距离来确定分类。具体来说,论文将每个输出单元的净输入定义为前一层F6输出与该类权重向量的内积。这实际上等价于计算欧氏距离平方差一部分(忽略了常数项),用于衡量与类别原型的接近程度——输出值越小(越接近0)表示越匹配该类。训练过程中,这些权重向量被调整为各类别的“模板”。
- 参数计算: 每个输出RBF单元有84个输入权重(对应F6层84维输出),通常不使用额外偏置(因为距离计算本质上自带阈值项)。因此每个输出单元参数=84个权重。10个类别单元总参数 = 84*10 = 840。这与文献所述“该层有84×10=840参数”完全一致。若引入偏置项,也可将其视为一个偏置参数,但LeNet-5的RBF实现里已将偏置吸收入权重或通过固定输入处理,因此常规计算中不额外计偏置
- 输出判别: 在推理时,对应每个类别计算得到的值可以视为输入与该类别中心的“距离”或“不相似度”度量。原论文使用Euclidean RBF单元,其输出经过一定处理,用以选择距离最小(输出值最小)的那个单元作为识别结果。后来许多实现简化为直接使用一个全连接层+Softmax替代RBF,但两者在MNIST任务上效果类似。应注意LeNet-5原始模型并未采用softmax概率输出,而是这种基于距离的RBF方案
代码练习
下面我们用代码练习一个基于 PyTorch 实现的 LeNet 简化版本,适用于处理如 CIFAR-10 等 32×32 RGB 图像的十分类任务,以下是我们的代码和原版的区别
项目 | LeNet-5 原版 | PyTorch 实现(简化版) |
---|
输入尺寸 | 32×32×1(MNIST灰度图+填充) | 32×32×3(RGB彩色图) |
输入通道数 | 1 通道(灰度) | 3 通道(彩色) |
激活函数 | tanh (双边饱和) | ReLU (非线性强、训练更快) |
池化方式 | 平均池化(AvgPooling) | 最大池化(MaxPooling) |
C3 层连接方式 | 部分连接(非全连接) | 全连接(每个输出通道连接所有输入) |
C5 层类型 | 卷积层(输出1×1×120) | 全连接层(Linear(800, 120) ) |
输出层 | RBF 单元(径向基函数),基于距离判断类别相似度 | 输出 raw logits,用于 CrossEntropyLoss |
应用目标 | MNIST(灰度手写数字识别) | CIFAR-10(彩色图十分类任务) |
网络深度 | 浅(2个卷积层 + 池化) | 同样浅,但结构略有现代化调整 |
PyTorch 实现(简化版)
在深入研究代码之前,请确保您的开发环境已正确设置
model
以下应该是model.py的标准版本:
import torch.nn as nn
import torch.nn.functional as F
class LeNet(nn.Module):
def __init__(self):
super(LeNet, self).__init__()
self.conv1 = nn.Conv2d(3, 16, 5)
self.pool1 = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(16, 32, 5)
self.pool2 = nn.MaxPool2d(2, 2)
self.fc1 = nn.Linear(32*5*5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
def forward(self, x):
x = F.relu(self.conv1(x)) # input(3, 32, 32) output(16, 28, 28)
x = self.pool1(x) # output(16, 14, 14)
x = F.relu(self.conv2(x)) # output(32, 10, 10)
x = self.pool2(x) # output(32, 5, 5)
x = x.view(-1, 32*5*5) # output(32*5*5)
x = F.relu(self.fc1(x)) # output(120)
x = F.relu(self.fc2(x)) # output(84)
x = self.fc3(x) # output(10) 内部自带softmax
return x
init 初始化函数用于函数定义,forward用于正向化传播过程,写完后,可以加一段测试代码(只做前向传播):
if __name__ == "__main__":
model = LeNet()
x = torch.randn(4, 3, 32, 32) # 模拟 batch_size=4 的 CIFAR 图像
out = model(x)
print(out.shape) # 应该输出 [4, 10]
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.nn
是 PyTorch 的“神经网络工具箱”模块,可以理解为“乐高积木库”:
nn.Conv2d
:二维卷积层nn.Linear
:全连接层nn.MaxPool2d
:池化层nn.Module
:网络模型的父类,模型继承于它
我们写网络时,结构定义用 nn
,激活函数等用 F
,比如 F.relu
、F.softmax
都属于 torch.nn.functional
模块 | 用途 | 举例 | 是否有参数 | 什么时候用 |
---|
torch.nn | 层级模块(包含权重的网络结构) | nn.Linear 、nn.Conv2d | ✅ 有 | 用在 __init__() 里定义结构 |
torch.nn.functional (简称 F ) | 函数式操作(无参数的计算操作) | F.relu 、F.softmax 、F.cross_entropy | ❌ 没有 | 用在 forward() 里做计算 |
Pytorch tensor通道排序:[batch, channel, height, width]
Conv2d
- 第一个参数:输入图像通道数(in Channels)
- RGB 彩色图(如 CIFAR-10) → 3
- 灰度图(如 MNIST) → 1
- 第二个参数:输出通道数(Out Channels)由你自己设计,代表提取的特征数量
16
表示提取 16 个特征图- 一般设置为
16
、32
、64
等 - 图像简单 → 起步用
16
;想提高准确率可用更高 - 通道越多 → 模型越大、越耗显存
- 第三个参数:卷积核大小
- LeNet 使用
5×5
卷积 → 所以看到是 5
- 现代 CNN(如 ResNet、VGG)常用
3×3
- 也有用
1×1
的轻量模型(如 MobileNet)
卷积层输出尺寸公式:
out = (input_size - kernel_size + 2 * padding) // stride + 1
例子:input=32, kernel=5, padding=0, stride=1 → (32 - 5 + 0) // 1 + 1 = 28
想写自己的模型推荐按这个顺序设计:
1. 明确图像大小和通道数(输入)
2. 定第一个卷积:in_channels = 输入通道数,out_channels 自己定
3. 加池化(建议),减少尺寸
4. 再加卷积(in_channels = 上一层 out_channels)
5. 重复 → 再接全连接
6. 展平后接 fc → 输出层(最后一个 fc 输出数量 = 分类数)
辅助工具来算输出尺寸
接下来根据官方文档看输入规范,然后做到可以把model、train、predict都能手打一遍
Train
import torch
import torchvision
import torch.nn as nn
from model import LeNet # 导入自定义的 LeNet 模型
import torch.optim as optim
import torchvision.transforms as transforms
def main():
# 数据预处理:转为 tensor,并归一化到 [-1, 1] 区间
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
# 加载训练集:CIFAR-10,共50000张图片
train_set = torchvision.datasets.CIFAR10(
root='./data', train=True, download=True, transform=transform)
train_loader = torch.utils.data.DataLoader(
train_set, batch_size=36, shuffle=True, num_workers=0)
# 加载验证集:CIFAR-10,共10000张图片
val_set = torchvision.datasets.CIFAR10(
root='./data', train=False, download=True, transform=transform)
val_loader = torch.utils.data.DataLoader(
val_set, batch_size=5000, shuffle=False, num_workers=0)
# 取出一个 batch 的验证数据(用于周期性验证准确率)
val_data_iter = iter(val_loader)
val_image, val_label = next(val_data_iter)
# 定义模型、损失函数、优化器
net = LeNet()
loss_function = nn.CrossEntropyLoss() # 用于分类任务的交叉熵损失
optimizer = optim.Adam(net.parameters(), lr=0.001) # 使用 Adam 优化器,学习率为 0.001
# 训练模型,共训练 5 个 epoch
for epoch in range(5):
running_loss = 0.0
# 每个 epoch 遍历全部训练数据
for step, data in enumerate(train_loader, start=0):
inputs, labels = data # 获取一个 batch 的输入图像和标签
optimizer.zero_grad() # 梯度清零
outputs = net(inputs) # 前向传播
loss = loss_function(outputs, labels) # 计算损失
loss.backward() # 反向传播
optimizer.step() # 参数更新
running_loss += loss.item()
# 每 500 个 batch 输出一次训练损失和验证准确率
if step % 500 == 499:
with torch.no_grad(): # 验证阶段不计算梯度,节省资源
outputs = net(val_image) # 得到模型预测结果
predict_y = torch.max(outputs, dim=1)[1] # 取每行最大值所在索引(即预测类别)
accuracy = torch.eq(predict_y, val_label).sum().item() / val_label.size(0)
print('[%d, %5d] train_loss: %.3f test_accuracy: %.3f' %
(epoch + 1, step + 1, running_loss / 500, accuracy))
running_loss = 0.0
print('Finished Training')
# 保存模型参数到文件
save_path = './Lenet.pth'
torch.save(net.state_dict(), save_path)
if __name__ == '__main__':
main()
Predict
import torch
import torchvision.transforms as transforms
from PIL import Image
from model import LeNet # 导入你定义好的 LeNet 模型
def main():
# 定义图片预处理流程(必须和训练时保持一致)
transform = transforms.Compose([
transforms.Resize((32, 32)), # 调整图像大小为 32×32(CIFAR-10 的尺寸)
transforms.ToTensor(), # 转换为 tensor,且会自动将像素归一化到 [0,1]
transforms.Normalize((0.5, 0.5, 0.5), # 再归一化到 [-1,1](保持和训练一致)
(0.5, 0.5, 0.5))
])
# CIFAR-10 的类别名称
classes = ('plane', 'car', 'bird', 'cat',
'deer', 'dog', 'frog', 'horse', 'ship', 'truck')
# 初始化模型并加载训练好的权重
net = LeNet()
net.load_state_dict(torch.load('Lenet.pth')) # 加载保存的模型参数
# 打开测试图片
im = Image.open('1.jpg') # 替换成你想预测的图片路径
im = transform(im) # 预处理:尺寸、归一化、转 tensor(结果为 [C, H, W])
im = torch.unsqueeze(im, dim=0) # 增加 batch 维度 → [1, C, H, W]
# 关闭梯度计算(推理时用)
with torch.no_grad():
outputs = net(im) # 前向传播,得到预测输出(logits)
predict = torch.max(outputs, dim=1)[1] # 取每行最大值的索引(即分类结果)
predict = predict.numpy() # 转为 NumPy 格式(方便打印)
# 输出预测结果对应的类别名
print(classes[int(predict)])
if __name__ == '__main__':
main()
Comments | NOTHING