跳到主要内容
SUN / 笔记
← 全部文章

CNN:LeNet 与 ResNet

1. 卷积网络在做什么

对图像张量 (N, C, H, W),卷积层用局部卷积核在空间上共享参数:

  • 局部连接让模型关注邻域结构;
  • 参数共享显著少于同尺寸全连接层;
  • 多层堆叠逐步扩大感受野,从边缘、纹理走向更抽象的模式;
  • 池化或带步幅卷积降低空间分辨率并提高计算效率。

二维卷积的单个空间维输出大小为:

Hout=⌊Hin+2P−D(K−1)−1S+1⌋ H_{out}=\left\lfloor\frac{H_{in}+2P-D(K-1)-1}{S}+1\right\rfloor

其中 KK 是卷积核,SS 是步幅,PP 是填充,DD 是 dilation。

2. LeNet 结构

下面比较两种相近但不完全相同的实现。

D2L 风格实现

python
from torch import nn

net = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5, padding=2),
    nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5),
    nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Flatten(),
    nn.Linear(16 * 5 * 5, 120),
    nn.Sigmoid(),
    nn.Linear(120, 84),
    nn.Sigmoid(),
    nn.Linear(84, 10),
)

输入为 1 x 28 x 28 时:

层 输出形状
Conv2d(1, 6, 5, padding=2) 6 x 28 x 28
AvgPool2d(2, 2) 6 x 14 x 14
Conv2d(6, 16, 5) 16 x 10 x 10
AvgPool2d(2, 2) 16 x 5 x 5
Flatten 400
Linear(400, 120) 120
Linear(120, 84) 84
Linear(84, 10) 10 个 logits

接近原始 LeNet-5 的 C5 写法

LeNet_MNIST.ipynb 中把 16 x 5 x 5 通过一个 5 x 5 卷积映射到 120 x 1 x 1:

python
self.c5 = nn.Conv2d(16, 120, kernel_size=5)
self.flatten = nn.Flatten()
self.f6 = nn.Linear(120, 84)
self.output = nn.Linear(84, 10)

在固定输入尺寸下,这个 C5 卷积与全连接层作用接近,但表达方式保留了卷积结构。不同资料对 LeNet 的“层数”统计会忽略池化层或激活层,所以不要脱离具体实现争论 5 层还是 7 层。

输出是 logits,不是概率

最后一层的 10 个值直接交给:

python
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(logits, labels)

CrossEntropyLoss 内部已经组合了 log_softmax 与负对数似然。训练前不要再手动 softmax()。

3. ResNet 解决的是深层网络退化

简单增加普通网络深度时,可能出现更深模型的训练误差反而更高,这叫退化问题。它与梯度消失有关联,但不能把二者完全等同;BatchNorm、合理初始化已经缓解梯度问题后,优化深层恒等映射仍然困难。

残差块把目标映射写成:

y=F(x)+x y=F(x)+x

如果理想映射接近恒等映射,残差分支只需把 F(x)F(x) 学到接近 0。捷径连接还提供了更直接的信息和梯度传播路径。

flowchart LR
    X[x] --> C1[Conv + BN + ReLU]
    C1 --> C2[Conv + BN]
    X --> S[Identity / 1x1 Projection]
    C2 --> ADD((+))
    S --> ADD
    ADD --> R[ReLU]

何时需要投影捷径

逐元素相加要求张量形状相同。通道数变化或进行下采样时,用 1 x 1 卷积调整:

python
from torch import nn


class BasicBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(
            in_channels, out_channels, 3,
            stride=stride, padding=1, bias=False,
        )
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(
            out_channels, out_channels, 3,
            padding=1, bias=False,
        )
        self.bn2 = nn.BatchNorm2d(out_channels)

        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(
                    in_channels, out_channels, 1,
                    stride=stride, bias=False,
                ),
                nn.BatchNorm2d(out_channels),
            )
        else:
            self.shortcut = nn.Identity()

    def forward(self, x):
        identity = self.shortcut(x)
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        return self.relu(out + identity)

BasicBlock 与 Bottleneck

  • ResNet-18/34 主要使用两个 3 x 3 卷积的 BasicBlock。
  • ResNet-50/101/152 使用 1 x 1 -> 3 x 3 -> 1 x 1 Bottleneck,在控制计算量的同时扩展通道。

4. ResNet-18 迁移到 K 分类

迁移学习可以加载预训练骨干,再替换最终分类层。

现代 TorchVision 写法:

python
from torch import nn
from torchvision.models import ResNet18_Weights, resnet18

num_classes = 5
weights = ResNet18_Weights.DEFAULT
model = resnet18(weights=weights)
model.fc = nn.Linear(model.fc.in_features, num_classes)

冻结骨干只训练分类头:

python
for parameter in model.parameters():
    parameter.requires_grad_(False)

model.fc = nn.Linear(model.fc.in_features, num_classes)
optimizer = torch.optim.AdamW(model.fc.parameters(), lr=1e-3)

后续可用更小学习率解冻部分层做微调。预处理应优先来自 weights.transforms(),否则输入分布与预训练阶段不一致。

5. 图像分类实验的完整检查点

  • [ ] 类别名到索引的映射固定并保存。
  • [ ] 训练、验证、测试目录无重复图片。
  • [ ] 只在训练集使用随机增强。
  • [ ] 输入通道、尺寸和归一化符合模型要求。
  • [ ] 最后一层输出维度等于类别数。
  • [ ] CrossEntropyLoss 的标签为 long,范围是 [0, num_classes)。
  • [ ] 类别不平衡时记录 macro-F1、混淆矩阵或每类召回率。
  • [ ] 最佳模型由验证集决定,测试集只做一次最终评估。
  • [ ] 保存权重、类别映射、模型版本和预处理配置。

参考

相关文章

PyTorch:张量与自动微分 →PyTorch 训练流程与验证 →