1. 卷积网络在做什么
对图像张量 (N, C, H, W),卷积层用局部卷积核在空间上共享参数:
- 局部连接让模型关注邻域结构;
- 参数共享显著少于同尺寸全连接层;
- 多层堆叠逐步扩大感受野,从边缘、纹理走向更抽象的模式;
- 池化或带步幅卷积降低空间分辨率并提高计算效率。
二维卷积的单个空间维输出大小为:
其中
2. LeNet 结构
下面比较两种相近但不完全相同的实现。
D2L 风格实现
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:
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 个值直接交给:
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(logits, labels)
CrossEntropyLoss 内部已经组合了 log_softmax 与负对数似然。训练前不要再手动 softmax()。
3. ResNet 解决的是深层网络退化
简单增加普通网络深度时,可能出现更深模型的训练误差反而更高,这叫退化问题。它与梯度消失有关联,但不能把二者完全等同;BatchNorm、合理初始化已经缓解梯度问题后,优化深层恒等映射仍然困难。
残差块把目标映射写成:
如果理想映射接近恒等映射,残差分支只需把
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 卷积调整:
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 1Bottleneck,在控制计算量的同时扩展通道。
4. ResNet-18 迁移到 K 分类
迁移学习可以加载预训练骨干,再替换最终分类层。
现代 TorchVision 写法:
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)
冻结骨干只训练分类头:
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、混淆矩阵或每类召回率。
- [ ] 最佳模型由验证集决定,测试集只做一次最终评估。
- [ ] 保存权重、类别映射、模型版本和预处理配置。