第 3 章 1989—1998:卷积神经网络与 LeNet-5
学习目标
- 理解卷积与池化两个基本操作
- 理解局部感受野、权重共享、平移不变性
- 从 Neocognitron(1980)到 LeNet-5(1998)的演进脉络
- 用 PyTorch 实现并训练 LeNet-5
3.1 时代背景:全连接的低效
第 2 章的 MLP 把 28×28 图片拉平成 784 维向量,第一层就要 784×256 个参数。真实图片动辄百万像素,全连接会爆炸。而且图片的本质是局部结构:一只眼睛、一条边、一个纹理都只占据局部区域,位置平移后语义不变。全连接层既浪费参数,又学不到这种结构。
1980 年,福岛邦彦提出 Neocognitron,第一次把「局部感受野 + 逐层抽象」用于视觉:浅层检测边缘,深层组合成部件。1989 年,Yann LeCun 把反向传播与卷积结合,1998 年在 AT&T 提出 LeNet-5,用于识别手写支票上的数字,并投入银行系统实际使用——这是卷积神经网络第一次产生商业价值。
3.2 卷积:局部加权求和
卷积(convolution)用一个小的卷积核(kernel),在输入上滑动,每个位置做加权求和,得到一张特征图(feature map)。
以 3×3 的 Sobel 垂直边缘核为例,它检测「左暗右亮」的边界:
sobel_x = torch.tensor([[[[-1.0, 0.0, 1.0],
[-2.0, 0.0, 2.0],
[-1.0, 0.0, 1.0]]]])
edge = F.conv2d(img, sobel_x, padding=1)运行 examples/ch03_conv_pool.py,在一张「中间三列白色」的 8×8 图片上验证:
输入图像形状: torch.Size([1, 1, 8, 8])
Sobel 卷积输出形状: torch.Size([1, 1, 8, 8])
边缘响应(绝对值 >0 的位置即边界):
tensor([[0, 0, 1, 1, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 1, 1, 0],
... 共 8 行相同 ...])
可学习卷积输出形状: torch.Size([1, 4, 8, 8]) (4 个特征图)
最大池化后形状: torch.Size([1, 4, 4, 4]) (8→4)
平均池化后形状: torch.Size([1, 4, 4, 4])卷积的两个核心性质:
- 局部感受野:每个输出只看输入的一小块,参数只和核大小有关,与图片尺寸无关;
- 权重共享:同一个核滑过整张图,所以检测同一种特征时参数不重复。
PyTorch 中 nn.Conv2d(in_channels, out_channels, kernel_size) 自动初始化可学习核;out_channels 个核就产生 out_channels 张特征图。
3.3 池化:下采样与稳健性
池化(pooling)对局部区域做聚合,常见两种:
- 最大池化:取窗口内最大值,保留最强响应;
- 平均池化:取窗口内平均值,LeNet-5 当年的选择。
池化把特征图尺寸减半,减少计算量,同时让特征对微小位移更稳健(平移了一点,最大值往往还在窗口里)。LeNet-5 用了 2×2、步长 2 的池化,把 32×32 逐级降到 16×16、8×8。
3.4 LeNet-5:现代 CNN 的骨架
LeNet-5 的结构(1998 年论文,输入 32×32):
| 层 | 操作 | 输出尺寸 |
|---|---|---|
| C1 | 6 个 5×5 卷积 | 28×28×6 |
| S2 | 2×2 平均池化 | 14×14×6 |
| C3 | 16 个 5×5 卷积 | 10×10×16 |
| S4 | 2×2 平均池化 | 5×5×16 |
| C5 | 120 个 5×5 卷积(全连接化) | 120 |
| F6 | 全连接 | 84 |
| OUTPUT | 全连接(RBF) | 10 |
今天的实现一般用 ReLU 替代 sigmoid、用交叉熵替代 RBF,骨架不变:卷积-池化 ×2 → 全连接。这个「浅层学局部、深层学整体」的层次结构,统治了之后二十年的计算机视觉。
examples/ch03_lenet.py 在 Fashion-MNIST 上复现 LeNet-5(输入补零到 32×32):
class LeNet5(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), # 32→32
nn.ReLU(),
nn.AvgPool2d(kernel_size=2, stride=2), # 32→16
nn.Conv2d(6, 16, kernel_size=5), # 16→12
nn.ReLU(),
nn.AvgPool2d(kernel_size=2, stride=2), # 12→6
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(16 * 6 * 6, 120),
nn.ReLU(),
nn.Linear(120, 84),
nn.ReLU(),
nn.Linear(84, 10),
)运行结果:
LeNet-5 参数量:82,826
epoch 1:loss = 0.7730,训练准确率 = 81.14%
epoch 2:loss = 0.4632,训练准确率 = 84.95%
epoch 3:loss = 0.4029,训练准确率 = 85.64%
测试准确率 = 85.02%只有 8 万参数,3 个 epoch 就在 10 类服装上达到 85%。对比第 2 章 40 万参数的 MLP(87.6%),LeNet-5 用 1/5 的参数做到接近的水平——卷积的权重共享与局部性是效率的来源。
3.5 为什么这个架构能成功
LeNet-5 的成功来自三个设计:
- 局部感受野:参数只覆盖局部,符合图像统计特性;
- 逐层抽象:浅层特征图检测边缘/纹理,深层组合成部件与对象;
- 平移稳健:池化 + 权重共享让同一特征无论出现在哪里都能被检测。
这三条后来被 AlexNet 原样继承(第 5 章),只是更大、更深、更快。
完整代码
本章用到的完整示例代码:
examples/ch03_conv_pool.py
"""第 3 章示例(1980—1998):卷积与池化的原理。
用一张 8×8 的"图片"演示:卷积核对局部特征做加权求和,
池化对局部区域做下采样。两者组合就是卷积神经网络的骨架。
运行方式:
uv run python examples/ch03_conv_pool.py
"""
import torch
import torch.nn.functional as F
from torch import nn
# 一张 8×8 灰度图:中间 3 列是白色(1),其余黑色(0)
img = torch.zeros(1, 1, 8, 8)
img[:, :, :, 3:6] = 1.0
print("输入图像形状:", img.shape)
# 手工 Sobel 垂直边缘核:检测明暗交界
sobel_x = torch.tensor([[[[-1.0, 0.0, 1.0],
[-2.0, 0.0, 2.0],
[-1.0, 0.0, 1.0]]]])
edge = F.conv2d(img, sobel_x, padding=1)
print("Sobel 卷积输出形状:", edge.shape)
print("边缘响应(绝对值 >0 的位置即边界):")
print((edge.abs() > 0.01).int().squeeze())
# 可学习卷积核:nn.Conv2d
conv = nn.Conv2d(1, 4, kernel_size=3, padding=1)
out = conv(img)
print("可学习卷积输出形状:", out.shape, "(4 个特征图)")
# 最大池化:2×2 窗口取最大值,尺寸减半
pooled = F.max_pool2d(out, kernel_size=2)
print("最大池化后形状:", pooled.shape, "(8→4)")
# 平均池化(AvgPool)是 LeNet-5 当年的选择
avg = F.avg_pool2d(out, kernel_size=2)
print("平均池化后形状:", avg.shape)examples/ch03_lenet.py
"""第 3 章示例(1989—1998):LeNet-5 在 Fashion-MNIST 上训练。
LeNet-5 原论文输入为 32×32,这里把 28×28 的 Fashion-MNIST 补零到 32×32。
运行方式:
uv run python examples/ch03_lenet.py
"""
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
transform = transforms.Compose([
transforms.Pad(2),
transforms.ToTensor(),
transforms.Normalize((0.2860,), (0.3530,)),
])
train = datasets.FashionMNIST("data", train=True, download=True, transform=transform)
test = datasets.FashionMNIST("data", train=False, download=True, transform=transform)
train_loader = DataLoader(train, batch_size=256, shuffle=True)
test_loader = DataLoader(test, batch_size=1024)
class LeNet5(nn.Module):
"""LeNet-5(1998):两次卷积+平均池化,再进入三层全连接。"""
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), # 32→32
nn.ReLU(),
nn.AvgPool2d(kernel_size=2, stride=2), # 32→16
nn.Conv2d(6, 16, kernel_size=5), # 16→12
nn.ReLU(),
nn.AvgPool2d(kernel_size=2, stride=2), # 12→6
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(16 * 6 * 6, 120),
nn.ReLU(),
nn.Linear(120, 84),
nn.ReLU(),
nn.Linear(84, 10),
)
def forward(self, x):
return self.classifier(self.features(x))
model = LeNet5().to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
print(f"LeNet-5 参数量:{sum(p.numel() for p in model.parameters()):,}")
def evaluate(loader):
model.eval()
correct = total = 0
with torch.no_grad():
for xb, yb in loader:
pred = model(xb.to(device)).argmax(1)
correct += (pred == yb.to(device)).sum().item()
total += len(yb)
return correct / total
for epoch in range(3):
model.train()
total_loss = 0
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
loss = criterion(model(xb), yb)
loss.backward()
opt.step()
total_loss += loss.item() * len(xb)
print(f"epoch {epoch + 1}:loss = {total_loss / len(train):.4f},"
f"训练准确率 = {evaluate(train_loader) * 100:.2f}%")
print(f"测试准确率 = {evaluate(test_loader) * 100:.2f}%")动手实践
- 运行
examples/ch03_conv_pool.py,把 Sobel 核换成水平边缘核,观察响应位置的变化。 - 修改
examples/ch03_lenet.py的池化:把AvgPool2d换成MaxPool2d,训练 3 轮对比准确率。 - 把 LeNet-5 的卷积核从 5×5 换成 3×3,保持输出尺寸不变(调整 padding),比较参数量与准确率。
- 打印
examples/ch03_lenet.py中每个层的输出形状(在前向里加print(x.shape)),对照 3.4 的尺寸表。
常见错误
| 错误写法 | 现象 | 原因 |
|---|---|---|
卷积后 padding 算错 | RuntimeError: shape mismatch | 输出尺寸 = (H + 2p - k)/s + 1,先算清再写 |
| 池化后忘了重算全连接输入维度 | mat1 and mat2 shapes cannot be multiplied | 16×6×6 是从最后特征图尺寸来的,改动前面层必须同步改 |
Conv2d 输入写 4 维之外的形状 | Expected 4-dimensional input | 卷积输入是 (B, C, H, W) |
灰度图用 nn.Conv2d(3, ...) | 维度不匹配 | 灰度图通道数是 1 |
训练时 model.eval() 忘了切回 model.train() | 结果怪但能跑 | eval 模式影响 BN/Dropout;训练循环里要保持 train |
章末练习
基础
- 输入 32×32,核 5×5,padding 2,步长 1,输出尺寸是多少?
- 为什么说卷积是「局部感受野 + 权重共享」?
- LeNet-5 中 C5 层为什么叫「全连接化卷积」?
提高
- 实现一个 2 层卷积网络(不用池化),比较它与加池化版本在 Fashion-MNIST 上的参数量与准确率。
- 在 8×8 随机图上,分别用 3×3 最大池化和平均池化,比较输出差异,解释各自适合什么场景。
- 把 LeNet-5 的特征图数量从 6/16 翻倍(12/32),观察参数量和准确率的变化。
挑战
- 手工实现一个 3×3 卷积(用
F.unfold或循环),与nn.Conv2d比较输出是否一致。 - 设计一个「LeNet-5 变体」输入 64×64,推算各层尺寸并实现,在合成的大尺寸数据集上验证前向传播。
章末自测
- 卷积输出特征图的数量由什么决定?
- A. 输入通道数
- B. 卷积核个数
- C. 池化大小
- D. 图片宽度
- 2×2、步长 2 的池化把 32×32 变成?
- A. 16×16
- B. 8×8
- C. 30×30
- D. 64×64
- 「权重共享」指的是?
- A. 所有层共用一组权重
- B. 同一个核滑过整张图
- C. 不同样本共享权重
- D. 权重在训练中不变
- LeNet-5 是哪一年提出的?
- A. 1980
- B. 1998
- C. 2006
- D. 2012
- Neocognitron 的提出者是?
- A. LeCun
- B. Fukushima(福岛邦彦)
- C. Hopfield
- D. Rosenblatt
nn.Conv2d(1, 6, 5)中的 6 表示?- A. 输入通道
- B. 输出特征图个数
- C. 核大小
- D. 步长
- 平均池化的作用是?
- A. 取最大值
- B. 取平均值并下采样
- C. 增加分辨率
- D. 反转图片
- LeNet-5 的输入尺寸是?
- A. 28×28
- B. 32×32
- C. 224×224
- D. 64×64
- 卷积层参数量与什么无关?
- A. 核大小
- B. 输入、输出通道数
- C. 图片尺寸
- D. 偏置
- LeNet-5 当年实际用于?
- A. 人脸识别
- B. 手写支票数字识别
- C. 自动驾驶
- D. 语音识别
