查看全课大纲(第 12 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

经典网络演进:LeNet → AlexNet → VGG → ResNet

卷积网络如何越变越深?从 LeNet 到 ResNet 的演进逻辑

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

学完本节,你将能清晰地回答:为什么深度卷积网络会从 LeNet 的 5 层发展到 ResNet 的 152 层?这背后的核心设计思想(如小卷积核、残差连接)是如何解决网络加深带来的梯度消失和退化问题的?你将掌握 VGG、ResNet 等经典架构的关键模块,并能用 PyTorch 实现一个基础的残差块。

本节承接前两讲的「卷积的直觉与参数共享」和「池化、感受野与下采样」,把卷积、池化这些零件拼成完整的网络,再看它们如何一代代变深。后续章节将基于这些特征提取器,进入序列模型和 Transformer 的学习。

💡 核心导读

  1. 网络加深的困境:单纯堆叠层数会导致梯度消失和网络退化(训练误差不降反升),这是深度网络设计的核心挑战。
  2. VGG 的深度化策略:通过堆叠多个 3×3 小卷积核来替代大卷积核,在增加网络深度的同时,减少了参数量并增强了非线性。
  3. ResNet 的残差学习:核心创新是引入“恒等快捷连接”,让网络层学习输入与输出之间的残差,这使得训练极深的网络(如152层)成为可能。
  4. 演进脉络:LeNet(奠基)→ AlexNet(深度化实践)→ VGG(小卷积核堆叠)→ ResNet(残差学习),每一步都旨在构建更深、更易训练的网络。

🧭 前置知识

  • 卷积操作、步长、填充的概念及输出形状计算。
  • 激活函数(ReLU)与池化(MaxPooling)的作用。
  • 基本的 PyTorch 张量操作和 nn.Module 的用法。
  • 链式求导法则(理解梯度反向传播即可,不展开矩阵求导)。

从 LeNet 到 AlexNet:深度化的初步尝试

LeNet-5(LeCun et al., 1998)是首个成功应用的卷积神经网络,用于手写数字识别。它总共只有 7 层、约 6 万个参数,结构极浅。然而,把它直接搬到更大规模、更复杂的图像数据集(如 ImageNet)上时,深度和宽度都远远不够。

AlexNet(Krizhevsky, Sutskever & Hinton, 2012, NIPS)在 LeNet 的基础上做出了多项关键改进,开启了深度卷积网络的时代:

  1. 使用 ReLU 激活函数:替代 Sigmoid,有效缓解了深层网络的梯度消失问题,加快了训练速度。
  2. 使用 Dropout:在全连接层随机丢弃部分神经元,减少了过拟合。
  3. 使用重叠最大池化:池化窗口(3×3)步长(2)小于窗口尺寸,产生重叠,提升了特征的丰富性。
  4. 局部响应归一化(LRN):在相邻的特征图通道间进行归一化,模拟神经元的侧抑制机制。但后续研究(如 VGG 论文)表明其提升有限且增加计算量,现代网络已较少使用。
  5. 数据增强与多 GPU 训练:首次大规模使用 GPU 并行训练,并采用裁剪、翻转等数据增强技术。

AlexNet 的成功证明了增加网络深度和宽度对提升性能的有效性,但其结构仍有优化空间,例如第一层使用了较大的 11×11 卷积核。

VGGNet:小卷积核的深度堆叠

VGGNet(Simonyan & Zisserman, 2014,arXiv:1409.1556)的核心思想非常简洁:使用更小的卷积核(3×3),通过堆叠更多的卷积层来构建更深的网络。

为什么是 3×3 卷积核?

  1. 相同的感受野:两个堆叠的 3×3 卷积层,其有效感受野相当于一个 5×5 卷积层;三个堆叠的 3×3 卷积层,其有效感受野相当于一个 7×7 卷积层。
  2. 更少的参数:假设输入和输出通道数均为 C。
    • 一个 7×7 卷积层的参数量为:72C2=49C27^2 C^2 = 49C^2。
    • 三个 3×3 卷积层的参数量为:3×(32C2)=27C23 \times (3^2 C^2) = 27C^2。
    • 参数量减少了约 45%。
  3. 更多的非线性:每个卷积层后都跟随一个 ReLU 激活函数,堆叠更多层引入了更多的非线性变换,使模型的判别能力更强。

VGG 网络配置

VGG 论文探索了从 11 层(A)到 19 层(E)的不同配置。最常用的是 VGG-16(配置 D)和 VGG-19(配置 E)。其结构具有高度一致性:卷积层均为 3×3,填充为 1(保持空间尺寸);池化层均为 2×2 最大池化,步长为 2(尺寸减半);通道数在每次池化后翻倍,但翻到 512 就封顶(64→128→256→512→512)。

下面我们用 PyTorch 实现一个简化的 VGG 块,感受其“堆叠”的设计:

import torch
import torch.nn as nn

torch.manual_seed(0)  # 固定随机种子,保证结果可复现

class VGGBlock(nn.Module):
    """一个简单的 VGG 块,包含两个 3x3 卷积 + ReLU"""
    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1)
        self.relu1 = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
        self.relu2 = nn.ReLU(inplace=True)
        self.pool = nn.MaxPool2d(kernel_size=2, stride=2)

    def forward(self, x):
        x = self.conv1(x)
        x = self.relu1(x)
        x = self.conv2(x)
        x = self.relu2(x)
        x = self.pool(x)
        return x

# 模拟一个输入图像 (batch=2, channel=3, height=32, width=32);真实 VGG 用 224×224,
# 这里缩小尺寸只为在 CPU 上快速跑通形状变化
input_tensor = torch.randn(2, 3, 32, 32)
block = VGGBlock(in_channels=3, out_channels=64)

output_tensor = block(input_tensor)
print("输入形状:", tuple(input_tensor.shape))
print("VGG块输出形状:", tuple(output_tensor.shape))
print("参数量(该块):", sum(p.numel() for p in block.parameters()))
输入形状: (2, 3, 32, 32)
VGG块输出形状: (2, 64, 16, 16)
参数量(该块): 38720

从输出可以看到,经过一个 VGG 块(两次卷积+一次池化),特征图尺寸减半,通道数增加。通过堆叠多个这样的块,VGG 构建了非常深的网络。VGG-16 在 ILSVRC-2014 上拿到分类任务第二名、定位任务第一名(分类冠军是 GoogLeNet),但它结构极简、权重迁移性很好,至今仍常被当作骨干网络或特征提取器;代价是全连接层参数量巨大(VGG-16 约 1.38 亿参数,其中 1.24 亿在三个全连接层里),网络加深后训练也明显变难。

ResNet:残差学习破解深度训练难题

随着网络深度增加,一个反直觉的现象出现了:网络退化(degradation)。即更深的网络在训练集上的误差反而比更浅的网络更高,这并非过拟合导致。ResNet(He et al., 2015,arXiv:1512.03385) 通过引入残差学习(Residual Learning) 巧妙地解决了这一问题。

这里要区分两个常被混淆的概念。过拟合是训练误差低、验证误差高;退化是训练误差本身也随深度增加而升高——网络不是学多了,而是学不动了。原论文的对照实验很说明问题:在 CIFAR-10 上,56 层网络的训练误差反而高于 20 层网络;若只是过拟合,更深的网络训练误差不该更差。更关键的是,这个 56 层网络并非没有能力表示 20 层的映射——把多出来的 36 层设成恒等映射,它至少能追平 20 层。所以问题不在容量,而在优化:深堆叠的非线性让“学出恒等映射”这件事本身都变得困难。

从反向传播看,这个解释很直接。把残差块写成 xl+1=xl+F(xl)\mathbf{x}_{l+1} = \mathbf{x}_l + F(\mathbf{x}_l),展开到第 LL 层得到 xL=xl+∑i=lL−1F(xi)\mathbf{x}_L = \mathbf{x}_l + \sum_{i=l}^{L-1} F(\mathbf{x}_i),于是

∂L∂xl=∂L∂xL(1+∂∂xl∑i=lL−1F(xi))\frac{\partial \mathcal{L}}{\partial \mathbf{x}_l} = \frac{\partial \mathcal{L}}{\partial \mathbf{x}_L}\left(1 + \frac{\partial}{\partial \mathbf{x}_l}\sum_{i=l}^{L-1} F(\mathbf{x}_i)\right)

括号里的 1 就是那条恒等通路:无论中间多少层把梯度衰减到什么程度,梯度都能沿这条通路几乎无损地传回去。普通网络没有这个 1,梯度只能靠一串雅可比矩阵连乘,很容易趋近于 0。

残差块与恒等快捷连接

核心思想:不再让堆叠的层直接学习目标映射 H(x)H(x),而是学习残差映射 F(x)=H(x)−xF(x) = H(x) - x。那么原始映射就变为 H(x)=F(x)+xH(x) = F(x) + x。

在实现上,这通过“恒等快捷连接(Identity Shortcut)”完成:将块的输入 xx 直接加到块的输出 F(x)F(x) 上。这要求 F(x)F(x) 的输出形状与 xx 相同。

假设:如果最优的映射接近恒等映射(即 H(x)≈xH(x) \approx x),那么让网络学习残差 F(x)≈0F(x) \approx 0 比学习一个恒等映射更容易。

一个基础的残差块(针对特征图尺寸不变的情况)实现如下:

import torch
import torch.nn as nn

torch.manual_seed(0)

class BasicBlock(nn.Module):
    """基础的残差块,适用于特征图尺寸不变的情况"""
    def __init__(self, in_channels, out_channels):
        super().__init__()
        # 两个 3x3 卷积,不改变尺寸(padding=1)
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        # 快捷连接:如果输入输出通道数相同,则直接相加;否则需要 1x1 卷积进行投影
        self.shortcut = nn.Sequential()
        if in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False),
                nn.BatchNorm2d(out_channels)
            )

    def forward(self, x):
        identity = x
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)
        out = self.conv2(out)
        out = self.bn2(out)
        # 关键步骤:添加快捷连接
        out += self.shortcut(identity)
        out = self.relu(out)
        return out

def shortcut_desc(block):
    """用一句话描述快捷连接的类型,避免打印整段 repr 撑爆行宽。"""
    if len(block.shortcut) == 0:
        return "恒等映射(空 Sequential)"
    return "1x1 卷积 + BN(通道投影)"

# 测试残差块
# 情况1:输入输出通道相同
x1 = torch.randn(2, 64, 56, 56)
block1 = BasicBlock(64, 64)
out1 = block1(x1)
print("情况1 - 输入输出通道相同")
print("  输入形状:", tuple(x1.shape))
print("  输出形状:", tuple(out1.shape))
print("  快捷连接是:", shortcut_desc(block1))

# 情况2:输入输出通道不同
x2 = torch.randn(2, 64, 56, 56)
block2 = BasicBlock(64, 128)
# 注意:当通道数变化时,通常伴随下采样(stride=2)。这里为简化,仍保持尺寸。
# 实际 ResNet 中,下采样由第一个卷积的 stride=2 和 shortcut 中的 1x1 conv stride=2 完成。
out2 = block2(x2)
print("\n情况2 - 输入输出通道不同")
print("  输入形状:", tuple(x2.shape))
print("  输出形状:", tuple(out2.shape))
print("  快捷连接是:", shortcut_desc(block2))
情况1 - 输入输出通道相同
  输入形状: (2, 64, 56, 56)
  输出形状: (2, 64, 56, 56)
  快捷连接是: 恒等映射(空 Sequential)

情况2 - 输入输出通道不同
  输入形状: (2, 64, 56, 56)
  输出形状: (2, 128, 56, 56)
  快捷连接是: 1x1 卷积 + BN(通道投影)

Bottleneck:让 152 层还算得动

BasicBlock 是两层 3×3,适合 ResNet-18/34。到 50 层以上,ResNet 改用 bottleneck 结构:1×1 降维 → 3×3 → 1×1 升维。设输入输出通道都是 256、中间压到 64,参数量约为 12⋅256⋅64+32⋅64⋅64+12⋅64⋅256≈71^2\cdot256\cdot64 + 3^2\cdot64\cdot64 + 1^2\cdot64\cdot256 \approx 7 万;若直接用两个 3×3(256→256),则是 2×9×2562≈1182\times9\times256^2 \approx 118 万,差了一个数量级。1×1 卷积在这里不是“看邻域”,而是跨通道的信息重排与降维。

两个常被忽略的细节

一是快捷连接的两种形态:通道与尺寸都一致时用恒等映射(不引入参数);通道翻倍或下采样时用 1×1 卷积 + BN 做投影,保证相加时形状对齐——下面第二个代码块演示的就是后者。二是原版 ResNet 的残差块是 post-activation(Conv→BN→ReLU 之后再相加);后来的 pre-activation 版本把 BN/ReLU 移到卷积之前(He et al., 2016,arXiv:1603.05027),恒等通路更干净、梯度更好传,深层网络更容易训。

残差网络的优势

  1. 解决梯度消失:反向传播时,梯度可以通过快捷连接直接传回更早的层,避免了在多层非线性变换中逐渐消失。
  2. 缓解网络退化:即使堆叠的层没有学到有用的特征,残差块至少能执行恒等映射,保证性能不会差于浅层网络。
  3. 实现极深网络:基于残差块,ResNet 成功训练了 152 层的网络。原论文里 ResNet-152 单模型的 ImageNet top-5 错误率是 4.49%,把多个深度的残差网络集成起来后达到 3.57%,拿下 ILSVRC 2015 分类任务第一名。

📝 动手练一练

问题:请编写代码,构造一个包含两个 BasicBlock 的迷你 ResNet 第一阶段。输入为 (2, 3, 32, 32) 的张量,首先经过一个 nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3) 和 nn.MaxPool2d(kernel_size=3, stride=2, padding=1),然后通过两个 BasicBlock(64, 64)。计算最终输出的形状,并统计整个迷你网络的可训练参数量。

👉 点击查看参考答案

思路:按照描述顺序组合网络层。第一个卷积和池化用于快速下采样。两个 BasicBlock 保持通道数和尺寸不变。参数量用 sum(p.numel() for p in model.parameters()) 手动累加即可,不必引入额外依赖。

import torch
import torch.nn as nn

torch.manual_seed(0)

# 复用前面定义的 BasicBlock
class BasicBlock(nn.Module):
    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.shortcut = nn.Sequential()
        if in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False),
                nn.BatchNorm2d(out_channels)
            )
    def forward(self, x):
        identity = x
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)
        out = self.conv2(out)
        out = self.bn2(out)
        out += self.shortcut(identity)
        out = self.relu(out)
        return out

class MiniResNetStage1(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        self.block1 = BasicBlock(64, 64)
        self.block2 = BasicBlock(64, 64)

    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)
        x = self.block1(x)
        x = self.block2(x)
        return x

# 构造网络和输入
model = MiniResNetStage1()
input_tensor = torch.randn(2, 3, 32, 32)
output_tensor = model(input_tensor)

print("输入形状:", tuple(input_tensor.shape))
print("输出形状:", tuple(output_tensor.shape))

# 计算参数量
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("可训练参数量:", total_params)
输入形状: (2, 3, 32, 32)
输出形状: (2, 64, 8, 8)
可训练参数量: 157504

解释:输入先经 stride=2 的 7×7 卷积(32 → 16),再经 stride=2 的最大池化(16 → 8);两个 BasicBlock 保持 8×8 的尺寸不变。参数量集中在第一层卷积(3×64×7×7)和两个残差块里的 3×3 卷积,与输入尺寸无关——正因为如此,同一个网络结构才能直接套用到 224×224 的真实图像上(那时输出是 56×56,参数量完全一样)。

常见错误

错误理解或写法现象或报错原因正确做法
在残差块中忘记添加快捷连接,直接返回 F(x)。网络性能下降,深度增加时训练困难,退化问题重现。失去了残差学习“恒等映射”的保障,网络退化为普通的深层网络。确保在 forward 中执行 out = F(x) + shortcut(x)。
当快捷连接需要改变通道数或下采样时,仅使用恒等连接(shortcut 为空)。运行时错误:RuntimeError: The size of tensor a (64) must match the size of tensor b (128) ...。张量形状不匹配,无法进行逐元素相加。通过 1x1 卷积(必要时 stride=2)将 identity 投影到与 F(x) 相同的形状。
将 Batch Normalization 层放在残差相加之后。可能影响训练稳定性,与原始 ResNet 论文的实现不一致。原始 ResNet 设计中,每个卷积后立即进行 BN 和 ReLU,相加后再进行一次 ReLU。遵循 Conv -> BN -> ReLU -> Conv -> BN -> ADD -> ReLU 的顺序。

本章小结

  1. AlexNet 通过 ReLU、Dropout 和数据增强等技巧,首次证明了深度卷积网络在大规模图像识别上的巨大潜力。
  2. VGGNet 确立了使用堆叠的 3×3 小卷积核来构建深度网络的范式,在增加深度的同时控制了参数量。
  3. ResNet 提出的残差学习和恒等快捷连接,是训练极深度网络的关键突破,解决了梯度消失和网络退化问题。
  4. 网络演进的核心逻辑是:在增加深度以提升模型容量的同时,必须通过精巧的设计(小卷积核、残差块)来保证优化的可行性和稳定性。

📋 行动清单

  • 在代码中实现一个 BasicBlock,并尝试将其中的两个 3x3 卷积替换为三个 3x3 卷积,观察参数量和输出形状的变化。
  • 修改 MiniResNetStage1,在第一个 BasicBlock 中进行下采样(将 stride 设为 2),并调整快捷连接使其匹配,验证输出形状。
  • (选做)查阅 torchvision.models.resnet18 的源码,对比其 BasicBlock 实现与本节示例的异同。

延伸阅读

  1. Gradient-Based Learning Applied to Document Recognition(LeCun et al., 1998, Proceedings of the IEEE)—— LeNet-5 原始论文 - https://doi.org/10.1109/5.726791
  2. ImageNet Classification with Deep Convolutional Neural Networks(Krizhevsky, Sutskever & Hinton, 2012, NIPS)—— AlexNet 原始论文 - https://papers.nips.cc/paper_files/paper/2012/hash/c399862d3b9d6b76c8436e924a68c45b-Abstract.html
  3. Very Deep Convolutional Networks for Large-Scale Image Recognition(Simonyan & Zisserman, 2014, arXiv:1409.1556)—— VGG 原始论文 - https://arxiv.org/abs/1409.1556
  4. Deep Residual Learning for Image Recognition(He et al., 2015, arXiv:1512.03385)—— ResNet 原始论文 - https://arxiv.org/abs/1512.03385

—— 小象教研组