Skip to content

第 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 垂直边缘核为例,它检测「左暗右亮」的边界:

python
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):

层操作输出尺寸
C16 个 5×5 卷积28×28×6
S22×2 平均池化14×14×6
C316 个 5×5 卷积10×10×16
S42×2 平均池化5×5×16
C5120 个 5×5 卷积(全连接化)120
F6全连接84
OUTPUT全连接(RBF)10

今天的实现一般用 ReLU 替代 sigmoid、用交叉熵替代 RBF,骨架不变:卷积-池化 ×2 → 全连接。这个「浅层学局部、深层学整体」的层次结构,统治了之后二十年的计算机视觉。

examples/ch03_lenet.py 在 Fashion-MNIST 上复现 LeNet-5(输入补零到 32×32):

python
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 的成功来自三个设计:

  1. 局部感受野:参数只覆盖局部,符合图像统计特性;
  2. 逐层抽象:浅层特征图检测边缘/纹理,深层组合成部件与对象;
  3. 平移稳健:池化 + 权重共享让同一特征无论出现在哪里都能被检测。

这三条后来被 AlexNet 原样继承(第 5 章),只是更大、更深、更快。

完整代码 ​

本章用到的完整示例代码:

examples/ch03_conv_pool.py ​

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 ​

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}%")

动手实践 ​

  1. 运行 examples/ch03_conv_pool.py,把 Sobel 核换成水平边缘核,观察响应位置的变化。
  2. 修改 examples/ch03_lenet.py 的池化:把 AvgPool2d 换成 MaxPool2d,训练 3 轮对比准确率。
  3. 把 LeNet-5 的卷积核从 5×5 换成 3×3,保持输出尺寸不变(调整 padding),比较参数量与准确率。
  4. 打印 examples/ch03_lenet.py 中每个层的输出形状(在前向里加 print(x.shape)),对照 3.4 的尺寸表。

常见错误 ​

错误写法现象原因
卷积后 padding 算错RuntimeError: shape mismatch输出尺寸 = (H + 2p - k)/s + 1,先算清再写
池化后忘了重算全连接输入维度mat1 and mat2 shapes cannot be multiplied16×6×6 是从最后特征图尺寸来的,改动前面层必须同步改
Conv2d 输入写 4 维之外的形状Expected 4-dimensional input卷积输入是 (B, C, H, W)
灰度图用 nn.Conv2d(3, ...)维度不匹配灰度图通道数是 1
训练时 model.eval() 忘了切回 model.train()结果怪但能跑eval 模式影响 BN/Dropout;训练循环里要保持 train

章末练习 ​

基础

  1. 输入 32×32,核 5×5,padding 2,步长 1,输出尺寸是多少?
  2. 为什么说卷积是「局部感受野 + 权重共享」?
  3. LeNet-5 中 C5 层为什么叫「全连接化卷积」?

提高

  1. 实现一个 2 层卷积网络(不用池化),比较它与加池化版本在 Fashion-MNIST 上的参数量与准确率。
  2. 在 8×8 随机图上,分别用 3×3 最大池化和平均池化,比较输出差异,解释各自适合什么场景。
  3. 把 LeNet-5 的特征图数量从 6/16 翻倍(12/32),观察参数量和准确率的变化。

挑战

  1. 手工实现一个 3×3 卷积(用 F.unfold 或循环),与 nn.Conv2d 比较输出是否一致。
  2. 设计一个「LeNet-5 变体」输入 64×64,推算各层尺寸并实现,在合成的大尺寸数据集上验证前向传播。

章末自测 ​

  1. 卷积输出特征图的数量由什么决定?
    • A. 输入通道数
    • B. 卷积核个数
    • C. 池化大小
    • D. 图片宽度
  2. 2×2、步长 2 的池化把 32×32 变成?
    • A. 16×16
    • B. 8×8
    • C. 30×30
    • D. 64×64
  3. 「权重共享」指的是?
    • A. 所有层共用一组权重
    • B. 同一个核滑过整张图
    • C. 不同样本共享权重
    • D. 权重在训练中不变
  4. LeNet-5 是哪一年提出的?
    • A. 1980
    • B. 1998
    • C. 2006
    • D. 2012
  5. Neocognitron 的提出者是?
    • A. LeCun
    • B. Fukushima(福岛邦彦)
    • C. Hopfield
    • D. Rosenblatt
  6. nn.Conv2d(1, 6, 5) 中的 6 表示?
    • A. 输入通道
    • B. 输出特征图个数
    • C. 核大小
    • D. 步长
  7. 平均池化的作用是?
    • A. 取最大值
    • B. 取平均值并下采样
    • C. 增加分辨率
    • D. 反转图片
  8. LeNet-5 的输入尺寸是?
    • A. 28×28
    • B. 32×32
    • C. 224×224
    • D. 64×64
  9. 卷积层参数量与什么无关?
    • A. 核大小
    • B. 输入、输出通道数
    • C. 图片尺寸
    • D. 偏置
  10. LeNet-5 当年实际用于?
    • A. 人脸识别
    • B. 手写支票数字识别
    • C. 自动驾驶
    • D. 语音识别