环境:PyCharm + python3.8

👉【现代卷积神经网络】

按时间顺序:

  • AlexNet:第一个在大规模视觉竞赛中击败传统计算机视觉模型的大型神经网络;
    • 激活函数使用ReLU 代替 LeNet的sigmoid
  • 使用重复块的网络(VGG):重复堆叠相同结构的VGG块;
    • ​​VGG块​:多个卷积层+池化层
  • 网络中的网络(NiN):重复使用由卷积层和1*1卷积层(用来代替全连接层)来构建深层网络;
    • NiN块:普通卷积层+两个1*1卷积层
    • NiN块作用:通过多层1×1卷积模拟复杂非线性函数,增强特征提取能力
  • 含并行连结的网络(GoogLeNet):使用并行连结的网络,通过不同窗口大小的卷积层和最大汇聚层来并行抽取信息;
    • Inception块:(共4条路径) 并行1x1, 3x3, 5x5卷积和池化
      • 前三条1*1、3*3和5*5的卷积层(并行多尺度特征提取)
  • 残差网络(ResNet):通过残差块构建跨层的数据通道,是计算机视觉中最流行的体系架构;
    • 经典残差网络的数据流是:输出 = ReLU( 原始输入x + 非线性变换F(x) )
    • 残差块:(x 线性值) + (y 残差路径输出 即高度非线性的特征)
      • 残差函数就是f(x),就是y
      • 残差连接:
        • 要求两个输入(即x和y)的形状相同,以便加法操作后输出张量的形状相同。
        • 核心优势:无损梯度反向传播。
      • 残差块使得 更容易学习恒等映射
    • 残差块后,再对整体做非线性变换即ReLU激活,确保残差和主路径的交互更自然
  • 稠密连接网络(DenseNet):计算成本很高,但效果更好。
    • 把ResNet的连接方式:加法 → 拼接
    • 把ResNet的特征复用的交互:仅与前一层的残差交互 → 与所有历史层直接交互
  • 卷积神经网络(CNN)的发展是深度学习演进的一个缩影。
  • 下表展示这6种代表性网络的核心特征与演进关系:

网络名称

核心思想/贡献

激活函数

关键组件/结构特点

​​AlexNet​​

开启深度学习时代,证明了​​深度CNN在大规模数据集上的有效性​​

​​ReLU​​

5个卷积层、3个全连接层,

使用​​Dropout​​和​​LRN​

​

​​VGG​​

探索​​网络深度​​,通过堆叠小型卷积核(如3x3)构建更深的网络

ReLU

结构简洁规则,由可复用的​​VGG块​​堆叠而成

​​VGG块​:多个卷积层+池化层

​​NiN (Network in Network)​​

引入​​1x1卷积​​和​​全局平均池化​​,

增强非线性并减少参数量

ReLU

​​MLP卷积层​​(卷积后接1x1卷积),

用全局平均池化​​替代全连接层​

NiN块:普通卷积+两个1×1卷积​

​​GoogLeNet (Inception)​​

提出​​Inception模块​​,在单层内并行使用不同大小的卷积核,多尺度提取特征

ReLU

​​Inception模块​​(并行1x1, 3x3, 5x5卷积和池化),​​辅助分类器​​缓解梯度消失

​​ResNet​​

引入​​残差连接(跳跃连接)​​,

解决深度网络的梯度消失和退化问题

ReLU

​​残差块​​,包含恒等映射,允许网络学习残差函数,支持构建极深网络(如ResNet-152)

​​DenseNet​​

构建​​密集连接​​,每一层都与之前所有层直接相连,促进特征重用

ReLU

​​密集块​​,块内每一层的输入是前面所有层输出的通道拼接,过渡层控制复杂度

💡 网络演进的核心脉络

从表格中可以看出CNN发展的几个关键趋势:

  1. ​​深度化​​:网络层数不断增加,从AlexNet的8层到ResNet、DenseNet的上百层,深度带来了更强的特征抽象能力。

  2. ​​结构创新​​:从简单的层堆叠(VGG)发展到复杂的模块化设计(Inception、残差块、密集块),这些结构有效地解决了深度网络带来的梯度消失、参数爆炸等问题。

  3. ​​效率与性能平衡​​:通过1x1卷积(NiN, Inception)降维、深度可分离卷积等技术,在保持或提升性能的同时,显著减少了参数数量和计算量。

  4. ​​特征利用最大化​​:ResNet的残差连接和DenseNet的密集连接都旨在改善梯度流动和特征重用,让网络训练更高效,信息传递更充分。

这些经典的网络架构不仅是图像识别领域的基石,其设计思想(如残差连接、注意力机制)也深刻影响了其他深度学习领域。

1. 深度卷积神经网络(AlexNet)

LeNet 提出后卷积神经网络虽在计算机视觉和机器学习领域有名气,但未主导这些领域:

  1. 性能与可行性待研究:LeNet在小数据集效果好,但在更大、更真实数据集上训练的性能和可行性尚需研究,上世纪90年代初到2012年神经网络常被其他方法超越。
  2. 输入差异:计算机视觉中,卷积神经网络输入是原始或简单预处理像素值,传统机器学习方法输入是人工精心设计的特征,且传统方法进展多来自对特征的改进。
  3. 发展障碍:上世纪90年代虽有神经网络加速卡,但不足以开发有大量参数的深层多通道多层卷积神经网络,当时数据集小,训练神经网络的关键技巧缺失。
  4. 经典机器学习流水线:获取数据集(早期收集成本高)→手工预处理特征数据集→用标准特征提取算法 如SIFT(尺度不变特征变换)和SURF(加速鲁棒特征)或手动调整流水线输入数据→将提取特征送入分类器训练。
  5. 不同领域观点:机器学习研究人员重视理论证明模型性质;计算机视觉研究人员认为推动领域进步的是数据特征,更大更干净的数据集或改进的特征提取对最终模型精度影响远大于学习算法。

1.1. 学习表征

图像特征提取方法的发展脉络 及 关键突破:

  • 2012 年前图像特征提取主流做法:
    • 机械计算特征:图像特征靠机械计算得出,设计新特征函数、改进结果并撰写论文成为潮流,
    • 主导方法:SIFT (尺度不变特征变换)、SURF (加速鲁棒特征)、HOG (定向梯度直方图)、bags of visual words 等特征提取方法占据主导。
  • 部分研究人员的创新观点:
    • 核心观点:Yann LeCun 等一组研究人员认为特征应被学习,且在合理复杂度前提下,特征由多个 有可学习参数、共同学习的神经网络层组成,机器视觉中最底层可检测边缘、颜色和纹理。
  • AlexNet 的出现及成果:
    • 提出与命名:Alex Krizhevsky 等人提出卷积神经网络变体 AlexNet,以第一作者命名。
    • 竞赛成绩:在 2012 年 ImageNet 挑战赛取得轰动成绩;

有趣的是网络最底层学习到类似传统滤波器的特征抽取器。 图7.1.1是从AlexNet论文 (Krizhevsky et al., 2012)复制的,描述了底层图像特征。 

  • AlexNet的特征层级结构
    • 底层特征:网络最底层学习到类似传统滤波器的特征抽取器,描述底层图像特征。
    • 高层特征:
      • 更高层基于底层表示形成更大特征,如眼睛、鼻子等;
      • 更高层可检测整个物体,如人、飞机等;
      • 最终隐藏神经元能学习图像的综合表示,利于区分不同类别的数据。
  • 领域突破情况
    • 长期未突破:此前有研究者钻研视觉数据逐级表征但长期未获突破。
    • 突破时间:深度卷积神经网络在2012年取得突破,突破归因于两个关键因素。

1.1.1. 缺少的成分:数据

深度模型数据需求与早期研究局限

  • 深度模型因含诸多特征,需大量有标签数据才能显著优于传统凸优化方法(如线性、核方法)。
  • 但早期受计算机存储有限、研究预算不足限制,多数研究仅基于小公开数据集。
    • 像加州大学欧文分校(UCI)提供的部分数据集,图像多为非自然环境低分辨率拍摄,数量仅几百至几千张。

大数据浪潮带来的转变

  1. 数据集发布与挑战赛发起:2010年前后大数据浪潮兴起,2009年ImageNet数据集发布并举办挑战赛,要求从100万个样本中训练模型区分1000个不同类别对象。
  2. ImageNet数据集开发方式:由斯坦福教授李飞飞小组开发,利用谷歌图像搜索(Google Image Search)预筛选每类图像,借助亚马逊众包(Amazon Mechanical Turk)标注图片类别,规模前所未有。
  3. 挑战赛的推动作用:ImageNet挑战赛推动计算机视觉和机器学习研究发展,促使研究人员探索在更大数据规模下表现最佳的模型 。

1.1.2. 缺少的成分:硬件

深度学习计算需求 与 早期局限

深度学习对计算资源要求高,训练需数百迭代轮数,每次迭代要通过多个线性代数层传递数据,成本高昂。因此 20 世纪 90 年代至 21 世纪初,优化凸目标的简单算法受研究人员青睐。

GPU 改变深度学习格局 ———— 图形处理器(Graphics Processing Unit,GPU)

  • GPU 特性与通用化:GPU 早年用于加速图形处理,其数学运算与卷积层计算相似。英伟达(NVIDIA)和 ATI 已为通用计算操作优化 GPU 并作为通用 GPU(general-purpose GPUs,GPGPU)销售。(GPU可优化高吞吐量的4*4矩阵和向量乘法,从而服务于基本的图形任务)
  • GPU 与 CPU 对比
    • 中央处理器(Central Processing Unit   ,CPU)
    • 图形处理器(Graphics Processing Unit,GPU)
    • CPU 核心特点:每个核心高时钟频率运行,有数 MB 的三级缓存(L3Cache),适合执行各种指令,但制造成本高,需大量芯片面积和复杂支持结构(内存接口、内核之间的缓存逻辑、高速互连等等),性价比低,现代笔记本最多 4 核,高端服务器很少超 64 核。
    • GPU 核心优势:由 100 - 1000 个小处理单元组成,分成更大组,虽每个核心相对弱、时钟频率有时低于 1GHz,但核心数量庞大使其比 CPU 快几个数量级。如 NVIDIA Ampere GPU 架构每个芯片浮点性能高达 312 TFlops,CPU 未超 1 TFlops。原因在于功耗随时钟频率二次方增长,GPU 内核简单更节能,且深度学习中许多操作需高内存带宽,GPU 带宽是 CPU 的 10 倍。

2012 年深度学习重大突破

Alex Krizhevsky 和 Ilya Sutskever 实现可在 GPU 硬件上运行的深度卷积神经网络,他们认识到 卷积神经网络中 卷积和矩阵乘法是可(在硬件上)并行化操作 的计算瓶颈,使用两个显存为3GB的 NVIDIA GTX580 GPU 实现快速卷积运算,其创新成果 cuda-convnet 成为几年内行业标准,推动深度学习热潮。

1.2. AlexNet

2012 年 AlexNet 出现:

  • 首次证实学习到的特征能够超越手工设计特征,一举打破计算机视觉研究既有格局。
  • 它凭借8层卷积神经网络,在2012年 ImageNet 图像识别挑战赛中以显著优势获胜。
图7.1.2 从LeNet(左)到AlexNet(右)

AlexNet 与 LeNet 架构对比

  • 相似之处:AlexNet 和 LeNet 架构、设计理念相似, 如 图7.1.2所示,展示了从 LeNet(左)到 AlexNet(右)的演变(此处提供的是去除当年需两个小型 GPU 同时运算设计特点的精简版本 AlexNet)。
  • 显著差异
    • 网络深度:AlexNet 比相对较小的 LeNet5 深得多,由八层构成,包括五个卷积层、两个全连接隐藏层和一个全连接输出层。
    • 激活函数:AlexNet 采用 ReLU 作为激活函数,而非 LeNet 使用的 sigmoid。

1.2.1. 模型设计

AlexNet 网络结构细节

  • 卷积窗口形状
    • 第一层:卷积窗口形状为11×11。鉴于 ImageNet 中大多数图像宽高比 MNIST 图像大10倍以上,需更大卷积窗口来捕获目标。
    • 后续层:第二层卷积窗口形状缩减为5×5,之后变为3×3。
  • 最大汇聚层设置
    在第一层、第二层和第五层卷积层之后,添加窗口形状为3×3、步幅为 2 的最大汇聚层(最大池化层)。
  • 卷积通道数目
    AlexNet 的卷积通道数目是 LeNet 的 10 倍,这增强了网络对图像特征的提取能力。

全连接层情况

  1. 规模与参数:在最后一个卷积层后设置两个全连接层,每个全连接层均有 4096 个输出。这两个全连接层规模巨大,拥有将近 1GB 的模型参数。
  2. 原版设计原因:由于早期 GPU 显存有限,原版 AlexNet 采用双数据流设计,让每个 GPU 只负责存储和计算模型的一半参数,以此解决显存不足问题。
  3. 当下情况:如今 GPU 显存相对充裕,很少需要跨 GPU 分解模型,所以该处所呈现的 AlexNet 模型在此方面与原始论文存在一定差异。

1.2.2. 激活函数

  • 激活函数变更

    AlexNet 将传统使用的 sigmoid 激活函数 替换为更简单的 ReLU 激活函数。

  • ReLU 激活函数的优势

    • ReLU 能减少计算量:没有sigmoid 里的复杂求幂。【计算高效】
    • ReLU 避免了梯度消失:sigmoid 在输出非常接近 0 或 1 时梯度几乎为0,模型参数没有正确初始化时会更严重。而 ReLU 激活函数在正区间的梯度恒为 1。(梯度为 0 则无法继续更新部分模型参数)【训练更稳定可靠】

1.2.3. 容量控制和预处理

import torch
from torch import nn
import common

net = nn.Sequential( # 定义顺序容器
    # 这里使用一个11*11的更大窗口来捕捉对象。
    # 同时,步幅为4,以减少输出的高度和宽度。
    # 另外,输出通道的数目远大于LeNet
    nn.Conv2d(1, 96, kernel_size=11, stride=4, padding=1), nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2),
    # 减小卷积窗口,使用填充为2来使得输入与输出的高和宽一致,且增大输出通道数
    nn.Conv2d(96, 256, kernel_size=5, padding=2), nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2),

    # 使用三个连续的卷积层和较小的卷积窗口。
    # 除了最后的卷积层,输出通道的数量进一步增加。
    # 在前两个卷积层之后,汇聚层不用于减少输入的高度和宽度
    nn.Conv2d(256, 384, kernel_size=3, padding=1), nn.ReLU(),
    nn.Conv2d(384, 384, kernel_size=3, padding=1), nn.ReLU(),
    nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2),

    nn.Flatten(),
    # 这里,全连接层的输出数量是LeNet中的好几倍。使用dropout层来减轻过拟合
    nn.Linear(6400, 4096), nn.ReLU(),
    nn.Dropout(p=0.5),
    nn.Linear(4096, 4096), nn.ReLU(),
    nn.Dropout(p=0.5),
    # 最后是输出层。由于这里使用Fashion-MNIST,所以用类别数为10,而非论文中的1000
    nn.Linear(4096, 10))

这里构造一个 高宽皆为224的单通道数据,来观察每一层输出的形状。 它与 图7.1.2中的AlexNet架构相匹配。

图7.1.2 从LeNet(左)到AlexNet(右)
# 模拟输入:batch_size=1,通道=1,高度=224,宽度=224(为了适配AlexNet架构)
X = torch.randn(1, 1, 224, 224)
for layer in net:   # 逐层前向传播并打印输出形状
    X=layer(X)      # 前向计算
    print(layer.__class__.__name__,'output shape:\t\t',X.shape)

1.3. 读取数据集

  • 原文中AlexNet是在ImageNet上进行训练的,但这里使用的是Fashion-MNIST数据集。
  • 因为即使在现代GPU上,训练ImageNet模型,同时使其收敛可能需要数小时或数天的时间。
  • 将AlexNet直接应用于Fashion-MNIST有个问题:Fashion-MNIST图像的分辨率(28×28像素)低于ImageNet图像。
  • 因此,这里将它们增加到224×224(为了有效使用AlexNet架构)。
  • 这里使用load_data_fashion_mnist函数中的resize参数执行此调整。
batch_size = 128 # 每次处理128张图片
# 加载Fashion-MNIST数据集,返回 训练集和测试集的DataLoader对象
train_iter, test_iter = common.load_data_fashion_mnist(batch_size, resize=224)

1.4. 训练AlexNet

现在开始训练AlexNet。与(深度学习计算 & 卷积神经网络——动手学深度学习5~6_动手学深度学习,卷积神经网络-CSDN博客6.6节中 6. 卷积神经网络(LeNet))的LeNet相比,这里的主要变化是使用更小的学习速率训练,这是因为网络更深更广、图像分辨率更高,训练卷积神经网络就更昂贵。

lr, num_epochs = 0.01, 10 # 学习率0.01,训练10轮
common.train_ch6(net, train_iter, test_iter, num_epochs, lr, common.try_gpu())

小结

  • AlexNet的架构与LeNet相似,但使用了更多的卷积层和更多的参数来拟合大规模的ImageNet数据集。

  • 现如今AlexNet已被更有效的架构所超越,但它是从浅层网络到深层网络的关键一步。

  • 尽管AlexNet的代码只比LeNet多出几行,但学术界花了很多年才接受深度学习这一概念,并应用其出色的实验结果。这也是由于缺乏有效的计算工具。

  • Dropout、ReLU和预处理是提升计算机视觉任务性能的其他关键步骤。

2. 使用块的网络(VGG)

VGG网络的核心贡献:块的引入。

  • 提出背景:牛津大学的视觉几何组(visual geometry group),在2014年提出VGG网络,通过重复堆叠相同结构的块,简化了架构设计。
  • 实现优势(代码复用):使用循环和子程序,可以在 任何现代深度学习框架 的代码中实现 块的重复堆叠,避免手动编写重复代码。
  • 块的设计特点:
    • 重复模式:VGG使用多个连续的3x3卷积层和2x2最大池化层组成块,例如:
      Block = [Conv3x3, Conv3x3, MaxPool2x2] × 2 # VGG-16中的典型块
    • 参数共享:同一块内的层共享超参数(如卷积核大小、通道数),减少设计复杂度。
    • 深度可扩展性:通过增加块的重复次数(如VGG-11/13/16/19),可灵活控制网络深度。

2.1. VGG块

定义网络块,便于我们重复构建某些网络架构,不仅利于代码编写与阅读也利于后面参数的优化。

经典卷积神经网络的基本组成部分是下面的这个序列:

  1. 带填充以保持分辨率的卷积层;
  2. 非线性激活函数,如ReLU;
  3. 汇聚层(池化层),如最大汇聚层。

而一个VGG块与之类似,由一系列卷积层组成,后面再加上用于空间下采样的最大汇聚层。在最初的VGG论文中 (Simonyan and Zisserman, 2014),作者使用了:

  1. 带有3*3卷积核、填充为1(保持高度和宽度)的卷积层,和
  2. 带有汇聚窗口、步幅为2(每个块后的分辨率减半)的最大汇聚层。

在下面的代码中,定义了一个名为vgg_block的函数来实现一个VGG块,该函数有三个参数,分别对应于

  • 卷积层的数量     num_convs、
  • 输入通道的数量 in_channels 、
  • 输出通道的数量 out_channels.
import torch
from torch import nn
import common

"""
    定义了一个名为vgg_block的函数来实现一个VGG块:
    1、卷积层的数量   num_convs
    2、输入通道的数量 in_channels 
    3、输出通道的数量 out_channels
"""
def vgg_block(num_convs, in_channels, out_channels):
    layers = [] # 创建空网络结果,之后通过循环操作使用append函数进行添加
    for _ in range(num_convs): # 循环操作,添加卷积层和非线性激活层ReLU
        layers.append(nn.Conv2d(in_channels, out_channels,
                                kernel_size=3, padding=1))
        layers.append(nn.ReLU())
        in_channels = out_channels # 更新输入通道数为当前输出通道数
    # 最后添加最大值汇聚层
    layers.append(nn.MaxPool2d(kernel_size=2,stride=2))
    return nn.Sequential(*layers)  # 将列表转换为顺序模型

# 示例:构建一个包含2个卷积层的VGG块
block = vgg_block(num_convs=2, in_channels=64, out_channels=128)
print(f"VGG块的网络结构:\n{block}")

2.2. VGG网络

与AlexNet、LeNet一样,VGG网络可以分为两部分:

  • 第一部分 主要由卷积层和汇聚层组成,
  • 第二部分 由全连接层组成。

如 图7.2.1中所示:

图7.2.1 从AlexNet到VGG,它们本质上都是块设计

VGG神经网络连接 图7.2.1的几个VGG块(在vgg_block函数中定义)。其中有超参数变量conv_arch。该变量指定了每个VGG块里卷积层个数和输出通道数。全连接模块则与AlexNet中的相同。

  • 原始VGG网络有5个卷积块,其中前两个块各有一个卷积层,后三个块各包含两个卷积层。
  • 第一个模块有64个输出通道,每个后续模块将输出通道数量翻倍,直到该数字达到512。
  • 由于该网络使用8个卷积层和3个全连接层,因此它通常被称为VGG-11。

由于会重复用到卷积层、激活函数ReLU和汇聚层,我们将这三个组合成一个块,每次引用这个块来构建网络模型。(通过定义VGG块,使得重复的网络结构实现起来更加容易,也利于代码阅读。)

'''
原VGG网络有5个卷积块:
    前两个有一个卷积层,输出通道数逐步增加(64→128)
    后三个块有两个卷积层,输出通道数继续增加(256→512→512)
全连接层固定为3层(4096→4096→10),中间使用 Dropout(0.5) 防止过拟合
该网络使用8个卷积层和3个全连接层,因此它通常被称为VGG-11
'''
# (卷积层数,输出通道数)
conv_arch = ((1, 64), (1, 128), (2, 256), (2, 512), (2, 512)) # VGG-11架构配置

实现VGG-11 (使用8个卷积层和3个全连接层):(可以通过在conv_arch上执行for循环来简单实现)

# 卷积块通过 conv_arch 动态生成,便于调整架构
# 假设输入图像经过卷积和池化后,特征图尺寸为 7x7(需根据输入尺寸验证)
def vgg(conv_arch):
    # 定义空网络结构
    conv_blks = []  # 存储所有卷积块
    in_channels = 1 # 初始输入通道数(如MNIST为1,ImageNet为3)

    # 卷积层部分
    for (num_convs, out_channels) in conv_arch:
        # 添加vgg块
        conv_blks.append(vgg_block(num_convs, in_channels, out_channels))
        # 下一层输入通道数=当前层输出通道数
        in_channels = out_channels

    # 前面循环结束后,out_channels的值会保留为conv_arch中最后一个元组的第二个元素,即512
    return nn.Sequential( # 组合完整网络
        *conv_blks, nn.Flatten(), # 展平特征图
        # 全连接层部分(经典VGG-11配置)
        nn.Linear(out_channels * 7 * 7, 4096), nn.ReLU(), nn.Dropout(0.5),
        nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(0.5),
        nn.Linear(4096, 10))  # 输出10类(如Fashion-MNIST)

net = vgg(conv_arch)

构建一个高度和宽度为224的单通道数据样本,以观察每个层输出的形状:

# 构建一个高度和宽度为224的单通道数据样本(模拟224x224单通道输入,如MNIST),以观察每个层输出的形状
X = torch.randn(size=(1, 1, 224, 224))
for blk in net:
    X = blk(X)
    print(blk.__class__.__name__,'output shape:\t',X.shape) # 每一层的输出形状

正如从代码中所看到的,

  1. 在每个块的高度和宽度减半,最终高度和宽度都为7。
  2. 最后再展平表示,送入全连接层处理。

2.3. 训练模型

由于VGG-11比AlexNet计算量更大,因此这里构建一个通道数较少的网络,足够用于训练Fashion-MNIST数据集。

# 构建了一个通道数较少的网络,足够用于训练Fashion-MNIST数据集
ratio = 4 # 通道数缩减比例
small_conv_arch = [(pair[0], pair[1] // ratio) for pair in conv_arch] # 通道数整除4,//为整除
net = vgg(small_conv_arch) # 构建轻量级网络

除了使用略高的学习率外,模型训练过程与前面(1. 深度卷积神经网络(AlexNet))中的AlexNet类似。

lr, num_epochs, batch_size = 0.05, 10, 128 # 学习率=0.05,训练10轮,每轮处理128张图
train_iter, test_iter = common.load_data_fashion_mnist(batch_size, resize=224)
common.train_ch6(net, train_iter, test_iter, num_epochs, lr, common.try_gpu())

 

小结

  • VGG-11使用可复用的卷积块构造网络。不同的VGG模型可通过每个块中卷积层数量和输出通道数量的差异来定义。

  • 块的使用导致网络定义的非常简洁。使用块可以有效地设计复杂的网络。

  • 在VGG论文中,Simonyan和Ziserman尝试了各种架构。特别是他们发现深层且窄的卷积(即3*3比较浅层且宽的卷积更有效。

NiN与VGG的架构对比

特性VGGNiN
核心模块堆叠3×3卷积层NiN块(普通卷积+两个1×1卷积)
全连接层末端使用3个全连接层(参数量大)末端使用全局平均池化(无参数量)
参数量高(全连接层主导)低(1×1卷积优化)
非线性能力依赖ReLU和卷积层通过多层1×1卷积增强
输入适应性固定输入尺寸(如224×224)支持任意输入尺寸(GAP的灵活性)

3. 网络中的网络 (NiN) (Network in Network)

LeNet、AlexNet和VGG都有一个共同的设计模式:

  1. 通过一系列的卷积层与汇聚层来提取空间结构特征;
  2. 然后通过全连接层对特征的表征进行处理。

AlexNet和VGG 对 LeNet的改进要点:

  • 如何扩大和加深这两个模块。
  • 或者,可以想象在这个过程的早期使用全连接层。

但使用全连接层,可能会完全放弃表征的空间结构。

网络中的网络(NiN)简单解决了该问题:在每个像素的通道上分别使用多层感知机 (Lin et al., 2013)

  • NiN 核心思想:通过引入逐像素全连接层(即对每个空间位置独立应用全连接操作)来增强网络的表达能力。

3.1. NiN块

输入/输出结构:

  • 卷积层:由四维张量组成,(样本数 × 通道数 × 高度 × 宽度)。
  • 全连接层:二维张量,对应(样本数 × 特征数)。
  • NiN:将卷积层的空间维度(高度×宽度)视为“虚拟样本”,通道维度视为特征,从而在每个像素位置(针对每个高度和宽度)独立应用全连接操作。

NiN块 结构:(三部分:普通卷积层+两个1*1卷积层)

  • 普通卷积层:提取空间特征(如3×3卷积,输出通道数由用户设定)。
  • 两个1*1卷积层:充当逐像素全连接层,每层后接ReLU激活函数。
    • 第一个1×1卷积:窗口形状由用户设置(通常与普通卷积输出通道数一致)。
    • 第二个1×1卷积:窗口形状固定为1×1,用于进一步非线性变换。

NiN块 作用:通过多层1×1卷积模拟复杂非线性函数,增强特征提取能力。

1×1卷积的作用:

  • 通道降维/升维(改变通道数):通过调整1×1卷积的输出通道数,可灵活控制特征维度(如减少计算量或融合跨通道信息)。

  • 增加非线性能力:在1×1卷积后接ReLU激活函数,引入非线性变换,提升模型表达能力。

  • 参数效率 (相比全连接层,参数更少):相比传统全连接层,1×1卷积的参数量仅与输入/输出通道数相关

    • (如输入通道C₁,输出C₂,参数量为C₁×C₂),远低于全连接层的C₁×H×W×C₂。

图7.3.1说明了VGG和NiN及它们的块之间主要架构差异。

图7.3.1 对比 VGG 和 NiN 及它们的块之间主要架构差异
import torch
from torch import nn

def nin_block(in_channels, out_channels, kernel_size, strides, padding):
    return nn.Sequential(
        nn.Conv2d(in_channels, out_channels, kernel_size, strides, padding), # 普通卷积层
        nn.ReLU(),  # 激活函数
        nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU(),     # 1*1卷积
        nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU())     # 1*1卷积

# 示例:构建一个输入通道=64,输出通道=128的NiN块
block = nin_block(in_channels=64, out_channels=128, kernel_size=5, strides=1, padding=2)
print(f"NiN块的网络结构:\n{block}")

3.2. NiN模型

最初的NiN网络是在AlexNet后不久提出的,显然从中得到了一些启示。 NiN使用窗口形状为11*11、5*5和3*3的卷积层,输出通道数量与AlexNet中的相同。 每个NiN块后有一个最大汇聚层,汇聚窗口形状为3*3,步幅为2。

与AlexNet相比,NiN完全取消了全连接层。 相反,NiN使用一个NiN块,其输出通道数等于标签类别的数量。最后放一个全局平均汇聚层(global average pooling layer, GAP),生成一个对数几率 (logits)。NiN设计的一个优点是,它显著减少了模型所需参数的数量。然而,在实践中,这种设计有时会增加训练模型的时间。

全局平均池化(GAP)

  • 替代全连接层:NiN在最后阶段使用全局平均池化替代传统全连接层,将每个通道的特征图平均为一个标量,直接输出分类结果。
  • 优势:
    • 减少参数量:避免全连接层导致的参数量爆炸(如VGG-16的全连接层占参数量90%)。

    • 防止过拟合:全局平均池化对空间变换更鲁棒,且天然支持任意输入尺寸。

net = nn.Sequential(
    # 第一阶段
    nin_block(1, 96, kernel_size=11, strides=4, padding=0),
    nn.MaxPool2d(3, stride=2),

    # 第二阶段
    nin_block(96, 256, kernel_size=5, strides=1, padding=2),
    nn.MaxPool2d(3, stride=2),

    # 第三阶段
    nin_block(256, 384, kernel_size=3, strides=1, padding=1),
    nn.MaxPool2d(3, stride=2),

    # 分类器部分
    nn.Dropout(0.5), # 失活率=0.5的随机失活,防止过拟合
    # 标签类别数是10,输出通道10(分10类)
    nin_block(384, 10, kernel_size=3, strides=1, padding=1),
    nn.AdaptiveAvgPool2d((1, 1)), # 全局平均池化,用于替代全连接层,减少参数量
    # 将四维的输出转成二维的输出,其形状为(批量大小,10)
    nn.Flatten())

创建一个数据样本来查看每个块的输出形状:

# 网络前向传播测试
X = torch.rand(size=(1, 1, 224, 224)) # 创建一个随机输入张量(模拟224x224的灰度图像)
for layer in net:
    X = layer(X) # 逐层通过网络
    print(layer.__class__.__name__,'output shape:\t\t', X.shape) # 每层的输出形状

3.3. 训练模型

依旧使用Fashion-MNIST来训练模型。训练NiN与训练AlexNet、VGG时相似。

lr, num_epochs, batch_size = 0.1, 10, 128 # 学习率,训练轮数,(批量大小)每轮训练图像张数
train_iter, test_iter = common.load_data_fashion_mnist(batch_size, resize=224)
common.train_ch6(net, train_iter, test_iter, num_epochs, lr, common.try_gpu())

小结

  • NiN块由一个卷积层和多个1*1卷积层组成,可以在卷积神经网络中使用,以允许更多的每像素非线性。

  • NiN去将 容易造成过拟合的全连接层,替换为全局平均汇聚层(即在所有位置上进行求和)。该汇聚层通道数量为所需的输出数量(例如,Fashion-MNIST的输出为10)。

  • 移除全连接层,以可减少过拟合,同时显著减少NiN的参数。

  • NiN的设计影响了许多后续卷积神经网络的设计。

4. 含并行连结的网络(GoogLeNet)

2014年,GoogLeNet (Szegedy et al., 2015) 这个网络架构

  1. 吸收了NiN中 串联网络的思想,并在此基础上做了改进。
  2. 这篇论文的一个重点是:解决了 什么样大小的卷积核最合适 的问题。
  • 本文的一个观点是,有时使用不同大小的卷积核组合是有利的。
  • 这里介绍一个稍微简化的GoogLeNet版本:省略一些为稳定训练而添加的特殊特性,现在有了更好的训练方法,这些特性不是必要的。

4.1. Inception块

Inception块(Inception block)是GoogLeNet(也称为Inception v1)的核心创新,是其 基本的卷积块。它的设计灵感来自电影《盗梦空间》(Inception)中的一句话“我们需要走得更深”(“We need to go deeper”)这一概念。

图7.4.1 Inception块的架构

如 图7.4.1所示,Inception块 由四条并行路径组成:

  • 并行多尺度特征提取:前三条路径使用窗口大小为1*1、3*3和5*5的卷积层,从不同空间大小中提取信息(捕捉不同尺度的特征)。
  • 瓶颈层(Bottleneck)设计:中间两条在输入上用1*1卷积减少通道数,从而降低模型的复杂性(降低计算量,提高计算效率)。
  • 第四条路径使用3*3最大汇聚层,然后使用1*1卷积层来改变通道数。
  • 维度匹配:所有路径都使用合适的填充来使 输入与输出的高和宽一致,
  • 通道维度拼接:最后将每条线路的输出 在通道维度上连结,即 拼接(concat),并构成Inception块的输出。
  • 在Inception块中,通常调整的超参数是每层输出通道数。

四条并行路径详解

输入
│
├── 路径1: 1x1卷积
├── 路径2: 1x1卷积 → 3x3卷积
├── 路径3: 1x1卷积 → 5x5卷积
└── 路径4: 3x3最大池化 → 1x1卷积

每条路径的具体作用

  • 1x1卷积路径:
    • 直接对输入进行1x1卷积
    • 作用:捕捉跨通道信息,同时通过控制输出通道数来调整模型复杂度
  • 3x3卷积路径:
    • 先通过1x1卷积降维(减少通道数)
    • 再进行3x3卷积
    • 作用:捕捉中等空间范围的特征,1x1卷积前缀可减少计算量
  • 5x5卷积路径:
    • 类似3x3路径,先1x1卷积降维
    • 再进行5x5卷积
    • 作用:捕捉更大空间范围的特征
  • 池化路径:
    • 先进行3x3最大池化(通常使用padding保持尺寸)
    • 再通过1x1卷积
    • 作用:提供平移不变性,同时可以改变通道数
import torch
from torch import nn
from torch.nn import functional as F
import common

class Inception(nn.Module):
    # c1--c4是每条路径的输出通道数
    def __init__(self, in_channels, c1, c2, c3, c4, **kwargs):
        super(Inception, self).__init__(**kwargs)
        # 线路1,单1x1卷积层
        self.p1_1 = nn.Conv2d(in_channels, c1, kernel_size=1)
        # 线路2,1x1卷积层 后接 3x3卷积层
        self.p2_1 = nn.Conv2d(in_channels, c2[0], kernel_size=1)
        self.p2_2 = nn.Conv2d(c2[0], c2[1], kernel_size=3, padding=1)
        # 线路3,1x1卷积层 后接 5x5卷积层
        self.p3_1 = nn.Conv2d(in_channels, c3[0], kernel_size=1)
        self.p3_2 = nn.Conv2d(c3[0], c3[1], kernel_size=5, padding=2)
        # 线路4,3x3最大汇聚层 后接 1x1卷积层
        self.p4_1 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
        self.p4_2 = nn.Conv2d(in_channels, c4, kernel_size=1)

    def forward(self, x): # 通过不同大小的卷积核捕捉多尺度特征,最后在通道维度拼接
        p1 = F.relu(self.p1_1(x))
        p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))
        p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))
        p4 = F.relu(self.p4_2(self.p4_1(x)))
        # 在通道维度上连结输出
        return torch.cat((p1, p2, p3, p4), dim=1)

GoogLeNet这个网络有效的原因:滤波器(filter)的组合可以用各种滤波器尺寸探索图像,这意味着不同大小的滤波器可以有效地识别不同范围的图像细节。 同时,可以为不同的滤波器分配不同数量的参数。

4.2. GoogLeNet模型

如 图7.4.2所示,GoogLeNet一共使用9个Inception块和全局平均汇聚层的堆叠来生成其估计值。Inception块之间的最大汇聚层可降低维度。 第一个模块类似于AlexNet和LeNet,Inception块的组合从VGG继承,全局平均汇聚层避免了在最后使用全连接层。

图7.4.2 GoogLeNet架构

现在,逐一实现GoogLeNet的每个模块。

  • 第一个模块:使用64个通道、7*7卷积层。
# 网络整体构架:由5个模块(b1-b5)串联组成:
# 模块b1:初始卷积
# 通过大步长(2)和池化层快速降低空间维度
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),  # 输出: 64@48x48
                   nn.ReLU(),
                   nn.MaxPool2d(kernel_size=3, stride=2, padding=1))      # 输出: 64@24x24
  • 第二个模块:使用两个卷积层:
    • 第一个卷积层:64个通道、1*1卷积层;
    • 第二个卷积层:将通道数量增加三倍的 3*3卷积层。
    • 这对应于Inception块中的第二条路径。
# 模块b2:中间卷积
# 使用1x1卷积降维后 再用3x3卷积提取特征
# 进一步通过池化降低分辨率
b2 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=1),          # 1x1卷积
                   nn.ReLU(),
                   nn.Conv2d(64, 192, kernel_size=3, padding=1), # 3x3卷积
                   nn.ReLU(),
                   nn.MaxPool2d(kernel_size=3, stride=2, padding=1))      # 输出: 192@12x12
  • 第三个模块:串联两个完整的Inception块。
    • 第一个Inception块:输出通道数为 64+128+32+32=256。四个路径之间的输出通道数量比为 64:128:32:32 = 2:4:1:1。
      • 第二个和第三个路径首先将输入通道的数量分别减少到 96/192=1/2 和 16/192=1/12,然后连接第二个卷积层。
    • 第二个Inception块:输出通道数增加到 128+192+96+64=480,四个路径之间的输出通道数量比为 128:192:96:64 = 4:6:3:2。
      • 第二条和第三条路径首先将输入通道的数量分别减少到 128/256=1/2 和 32/256=1/8。
# 模块b3:第一个Inception块组
# 包含 2个Inception模块 和 池化层
# 通道数逐步增加(192→256→480)
b3 = nn.Sequential(Inception(192, 64, (96, 128), (16, 32), 32),   # 输出通道: 64+128+32+32=256
                   Inception(256, 128, (128, 192), (32, 96), 64), # 输出通道: 128+192+96+64=480
                   nn.MaxPool2d(kernel_size=3, stride=2, padding=1))  # 输出: 480@6x6
  • 第四模块更加复杂:它串联了5个Inception块,其输出通道数分别是
  • 192+208+48+64=512 、160+224+64+64=512 、128+256+64+64=512 、
  • 112+288+64+64=528 和 256+320+128+128=832 。
  • 这些路径的通道数分配和第三模块中的类似:
    • 首先是 含3*3卷积层的第二条路径输出最多通道,
    • 其次是 仅含1*1卷积层的第一条路径,
    • 之后是 含5*5卷积层的第三条路径 和 含3*3最大汇聚层的第四条路径。
    • 其中第二、第三条路径都会先按比例减小通道数。这些比例在各个Inception块中都略有不同。
# 模块b4:第二个Inception块组
# 包含 5个Inception模块 和 池化层
# 通道数先保持(480→512)后增加(512→528→832)
b4 = nn.Sequential(Inception(480, 192, (96, 208), (16, 48), 64),    # 输出: 192+208+48+64=512
                   Inception(512, 160, (112, 224), (24, 64), 64),   # 输出: 160+224+64+64=512
                   Inception(512, 128, (128, 256), (24, 64), 64),   # 输出: 112+288+64+64=528
                   Inception(512, 112, (144, 288), (32, 64), 64),   # 输出: 112+288+64+64=528
                   Inception(528, 256, (160, 320), (32, 128), 128), # 输出: 256+320+128+128=832
                   nn.MaxPool2d(kernel_size=3, stride=2, padding=1))  # 输出: 832@3x3
  • 第五模块:包含输出通道数为 256+320+128+128=832 和 384+384+128+128=1024 的两个Inception块。
    • 其中每条路径通道数的分配思路和第三、第四模块中的一致,只是在具体数值上有所不同。
    • 需要注意的是,第五模块的后面紧跟输出层,该模块同NiN一样使用全局平均汇聚层,将每个通道的高和宽变成1。
    • 最后将输出变成二维数组,再接上一个输出个数为标签类别数的全连接层。
# 模块b5:最终分类部分
# 包含2个Inception模块
# 通过全局平均池化将特征图降为1x1
# 最后展平为向量供全连接层使用
b5 = nn.Sequential(Inception(832, 256, (160, 320), (32, 128), 128),  # 输出: 256+320+128+128=832
                   Inception(832, 384, (192, 384), (48, 128), 128),  # 输出: 384+384+128+128=1024
                   nn.AdaptiveAvgPool2d((1,1)),   # 全局平均池化 → 1024@1x1 ,用于替代全连接层,减少参数量
                   nn.Flatten())                  # 展平为1024维向量

# 完整网络 最终输出层:1024维 → 10类(Fashion-MNIST)
net = nn.Sequential(b1, b2, b3, b4, b5, nn.Linear(1024, 10))

GoogLeNet模型的计算复杂,而且不如VGG那样便于修改通道数。为了使Fashion-MNIST上的训练短小精悍,这里将输入的高和宽从224降到96,这简化了计算。下面演示各个模块输出的形状变化:

X = torch.rand(size=(1, 1, 96, 96)) # 输入96x96图像,最终输出(1,10)
for layer in net:
    X = layer(X)
    print(layer.__class__.__name__,'output shape:\t', X.shape)

4.3. 训练模型

依旧使用Fashion-MNIST数据集来训练模型。在训练之前,将图片转换为96*96分辨率:

lr, num_epochs, batch_size = 0.1, 10, 128 # 学习率,训练轮数,(批量大小)每轮训练图像张数
train_iter, test_iter = common.load_data_fashion_mnist(batch_size, resize=96)
common.train_ch6(net, train_iter, test_iter, num_epochs, lr, common.try_gpu())

网络特点总结

  1. 多尺度特征提取:通过Inception模块并行使用不同大小的卷积核
  2. 通道数控制:使用1x1卷积减少计算量(如线路2、3中的第一个卷积)
  3. 渐进式降采样:通过步长和池化逐步降低空间分辨率
  4. 全局平均池化:替代全连接层,减少参数量
  5. 深而宽的结构:通过多个Inception块堆叠,同时增加通道数

小结

  • Inception块相当于一个有4条路径的子网络。通过不同窗口形状的卷积层和最大汇聚层来并行抽取信息,并使用1×1卷积层减少每像素级别上的通道维数从而降低模型复杂度。

  • GoogLeNet将多个设计精细的Inception块与其他层(卷积层、全连接层)串联起来。其中Inception块的通道数分配之比是在ImageNet数据集上通过大量的实验得来的。

  • GoogLeNet和它的后继者们一度是ImageNet上最有效的模型之一:它以较低的计算复杂度提供了类似的测试精度。

论文地址: Going Deeper with Convolutions

5. 批量规范化 (批量归一化)

批量规范化(batch normalization) (Ioffe and Szegedy, 2015)技术:可持续加速 深层网络的收敛速度。再结合在后面介绍的残差块,批量规范化使得研究人员能够训练100层以上的网络。

5.1. 训练深层网络

需要批量规范化层 的原因:

  1. 数据预处理影响大:数据预处理方式影响最终结果,如前面应用多层感知机预测房价(实战Kaggle比赛:预测房价——动手学深度学习4.10_在pycham运行kaggle中代码-CSDN博客)时,标准化输入特征(使其平均值为0,方差为1),统一参数量级,利于优化器工作。
  2. 中间层变量分布偏移阻碍收敛:多层感知机或卷积神经网络训练时,中间层变量 变化范围广,分布偏移可能阻碍网络收敛,需调整学习率(如 一个层的可变值是另一层的100倍时)。
  3. 深层网络易过拟合,正则化重要:深层网络复杂,容易过拟合,正则化更为重要。
  4. 批量规范化原理:应用于单个或所有可选层,每次训练迭代 先基于当前小批量处理 规范化输入(即 减去均值、并除以标准差。公式: (X - μ) / √(σ² + ε)),再应用比例系数和比例偏移;批量大小选择更重要,因为 小批量的大小 影响效果。只有使用足够大的小批量,批量规范化这种方法才是有效且稳定的。(如 使用大小为1的小批量应用批量规范化,会无法学到任何东西。因为减去均值后,每个隐藏单元将为0)。
  5. 批量规范化表达式:
    1. 用x∈B表示来自小批量B的输入,\mathrm{BN}(\mathbf{x}) = \boldsymbol{\gamma} \odot \frac{\mathbf{x} - \hat{\boldsymbol{\mu}}_\mathcal{B}}{\hat{\boldsymbol{\sigma}}_\mathcal{B}} + \boldsymbol{\beta}. ,其中:
    2. \hat{\boldsymbol{\mu}}_\mathcal{B}​ 是小批量B的 样本均值,
    3. \hat{\boldsymbol{\sigma}}_\mathcal{B}​ 是小批量B的 样本标准差,
    4. 拉伸参数(scale)γ 和 偏移参数(shift)β 是需学习的参数。(他俩形状x与相同)
    5. 应用标准化后,生成的小批量的平均值为0和单位方差为1。 
  6. 均值和方差计算:
    1. 小批量B的 样本均值 \hat{\boldsymbol{\mu}}_\mathcal{B}(估计值):,
    2. 小批量B的 样本方差 \hat{\boldsymbol{\sigma}}_\mathcal{B}^{2}(估计值):,
    3. 在方差估计值中添加常量ϵ 避免除以零(ϵ>0),噪声有益 (优化中的各种噪声源通常会加快训练和减少过拟合),这是正则化形式,相关理论揭示了批量规范化适应(50~100范围中的)中等批量大小的原因。
    4. 估计值\hat{\boldsymbol{\mu}}_\mathcal{B}和\hat{\boldsymbol{\sigma}}_\mathcal{B}通过使用平均值和方差的噪声(noise)估计来抵消缩放问题。
  7. 训练和预测模式功能不同:
    1. 训练模式:通过小批量统计数据规范化(因为训练过程中,无法得知使用整个数据集来估计平均值和方差,所以只能根据每个小批次的平均值和方差不断训练模型);
    2. 预测模式:通过数据集统计规范化,预测时可根据整个数据集精确计算(批量规范化所需的)平均值和方差。

5.2. 批量规范化层

  • 批量规范化和其他层之间的关键区别:
    • 批量规范化在完整小批量上运行,不能忽略批量的大小。
    • 全连接层和卷积层的批量规范化实现略有不同。

5.2.1. 全连接层

批量规范化层 置于 全连接层中的 仿射变换和激活函数之间,设:

  • 全连接层输入为 x,
  • 权重参数为 W,
  • 偏置参数为 b,
  • 激活函数为 ϕ,
  • 批量规范化运算符为 BN,

则 输出计算为 h=ϕ(BN(Wx+b)) ,均值和方差在 应用变换的“相同”小批量上 计算。

5.2.2. 卷积层

  • 在 卷积层之后 和 非线性激活函数之前 应用批量规范化。(即 卷积层和激活函数之间的位置)
  • 卷积有多个输出通道时,对每个通道输出执行批量规范化,每个通道各自有自己的拉伸(scale)和偏移(shift)参数 (这两个参数皆为标量)。
    • 假设小批量包含m个样本,每个通道卷积输出 高度p、宽度q。
    • 在每个输出通道的 m⋅p⋅q 个元素上同时执行批量规范化,计算平均值和方差时,收集所有空间位置的值,然后在给定通道内应用相同均值和方差,以便在每个空间位置 规范化值。

5.2.3. 预测过程中的批量规范化

训练好的模型用于预测时:

  • 不再需要 样本均值中的噪声 和 小批次估计的样本方差,
  • 常用 移动平均 估算整个训练集的样本均值和方差,并在预测时 使用它们得到确定输出。
  • 和暂退法(随机失活)一样,批量规范化层 在训练和预测模式下计算结果不同。

5.3. 从零实现

从头开始实现一个具有张量的批量规范化层:

import torch
from torch import nn
from common

'''
X: 输入数据,
    可以是 全连接层的2D张量 (batch_size, features)
    或      卷积层的4D张量 (batch_size, channels, height, width)
gamma      : 缩放参数(可学习)
beta       : 平移参数(可学习)
moving_mean: 移动平均均值(用于推理阶段)
moving_var : 移动平均方差(用于推理阶段)
eps        :防止除零的小常数
momentum   :移动平均的动量参数,控制移动平均的更新速度(默认 0.9)
'''
def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum):
    # 通过is_grad_enabled来判断当前模式是训练模式(需要计算梯度)还是预测模式(不需要梯度)
    if not torch.is_grad_enabled():
        # 预测模式:直接使用传入的移动平均所得的均值moving_mean和方差moving_var(避免训练时计算的统计量干扰)
        # 公式: (X - μ) / √(σ² + ε),规范化输入(减去均值后除以标准差)
        X_hat = (X - moving_mean) / torch.sqrt(moving_var + eps)
    else:
        # 训练模式:计算当前批次的均值和方差
        assert len(X.shape) in (2, 4) # 检查输入维度(确保输入是2D(全连接)或4D(卷积)张量)
        if len(X.shape) == 2:
            # 使用全连接层的情况,沿样本batch维度(dim=0)计算特征维上的均值和方差
            mean = X.mean(dim=0)
            var = ((X - mean) ** 2).mean(dim=0) # 方差
        else:
            # 卷积层:沿(batch, height, width)维度计算均值和方差(保持通道维度)
            # 使用二维卷积层的情况,计算通道维上(axis=1)的均值和方差。
            # keepdim=True保持维度,即 保持X的形状以便后面可以做广播运算
            # 结果形状为(1, channels, 1, 1)
            mean = X.mean(dim=(0, 2, 3), keepdim=True)
            var = ((X - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)

        # 标准化当前批次数据
        # 训练模式下,用当前的均值和方差做标准化
        X_hat = (X - mean) / torch.sqrt(var + eps) # eps:防止除零的小常数
        # 更新移动平均的均值和方差(指数加权平均)(更新全局统计量)
        # 公式: new_value = momentum * old_value + (1 - momentum) * current_value
        # 较大的momentum值(接近1)会给历史值更大权重
        moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
        moving_var = momentum * moving_var + (1.0 - momentum) * var
    Y = gamma * X_hat + beta  # 应用比例系数和比例偏移,缩放(gamma)和移位(beta)
    return Y, moving_mean.data, moving_var.data  # 返回结果和更新后的移动平均值(使用.data获取不包含梯度的张量)

现在可以创建一个正确的BatchNorm层。 这个层将保持适当的参数:拉伸gamma和偏移beta,这两个参数将在训练过程中更新。层会保存均值和方差的移动平均值,以便在模型预测期间随后使用。

撇开算法细节,注意实现层的基础设计模式。

  1. 通常用一个单独的函数定义其数学原理,比如说batch_norm。
  2. 然后,将此功能集成到一个自定义层中,其代码主要
    1. 处理数据移动到训练设备(如GPU)、
    2. 分配和初始化任何必需的变量、
    3. 跟踪移动平均线(此处为均值和方差)等问题。

为了方便起见,并不担心在这里自动推断输入形状,因此需要指定整个特征的数量。(深度学习框架中的批量规范化API能解决上述问题,后面会展示。)

class BatchNorm(nn.Module):
    # num_features:完全连接层的输出数量 或 卷积层的输出通道数
    # num_dims:2表示完全连接层,4表示卷积层(区分全连接层(2D)和卷积层(4D))
    def __init__(self, num_features, num_dims):
        super().__init__()
        # 根据输入类型(全连接层或卷积层)初始化形状
        if num_dims == 2:
            shape = (1, num_features) # 全连接层:形状为(1, num_features)
        else:
            shape = (1, num_features, 1, 1) # 卷积层:形状为(1, num_channels, 1, 1)

        # 可训练参数:gamma(缩放)和 beta(偏移)
        # 参与求梯度和迭代的拉伸和偏移参数,分别初始化成1和0
        self.gamma = nn.Parameter(torch.ones(shape)) # gamma初始化为1(保持初始尺度不变)
        self.beta = nn.Parameter(torch.zeros(shape)) # beta初始化为0(保持初始偏移不变)

        # 非训练参数:移动平均的均值和方差(初始化为0和1)
        # 非模型参数的变量初始化为0和1(避免初始标准化干扰)
        self.moving_mean = torch.zeros(shape)
        self.moving_var = torch.ones(shape)

    def forward(self, X):
        # 确保移动平均统计量与输入数据在同一设备上(CPU/GPU)
        # 如果X不在内存上,将moving_mean和moving_var
        # 复制到X所在显存上
        if self.moving_mean.device != X.device:
            self.moving_mean = self.moving_mean.to(X.device)
            self.moving_var = self.moving_var.to(X.device)

        # 调用底层批归一化函数,并更新移动平均值
        # 保存更新过的moving_mean和moving_var
        Y, self.moving_mean, self.moving_var = batch_norm(
            X, self.gamma, self.beta, self.moving_mean,
            self.moving_var, eps=1e-5, momentum=0.9)
        return Y

5.4. 使用批量规范化层的 LeNet

为了更好理解如何应用BatchNorm,下面将其应用于LeNet模型(深度学习计算 & 卷积神经网络——动手学深度学习5~6_动手学深度学习,卷积神经网络-CSDN博客中的 6. 卷积神经网络(LeNet))。

批量规范化应用在:

  • 卷积层或全连接层之后、
  • 相应的激活函数之前。
  • 即 (卷积层 — 批量归一化 — 激活函数),(全连接层 — 批量归一化 — 激活函数)
net = nn.Sequential(
    # 卷积层 + BatchNorm + 激活函数
    nn.Conv2d(1, 6, kernel_size=5), BatchNorm(6, num_dims=4), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),

    nn.Conv2d(6, 16, kernel_size=5), BatchNorm(16, num_dims=4), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),

    nn.Flatten(),

    # 全连接层 + BatchNorm + 激活函数
    nn.Linear(16*4*4, 120), BatchNorm(120, num_dims=2), nn.Sigmoid(),
    nn.Linear(120, 84), BatchNorm(84, num_dims=2), nn.Sigmoid(),
    nn.Linear(84, 10)) # 输出层(无 BatchNorm 和激活函数)

依旧在Fashion-MNIST数据集上训练网络。代码与第一次训练LeNet(深度学习计算 & 卷积神经网络——动手学深度学习5~6_动手学深度学习,卷积神经网络-CSDN博客中的 6. 卷积神经网络(LeNet))时几乎完全相同,主要区别在于学习率大得多(较大的学习率可能适用于Sigmoid激活函数):

lr, num_epochs, batch_size = 1.0, 10, 256 # 学习率,训练轮数,每轮处理的批量大小
train_iter, test_iter = common.load_data_fashion_mnist(batch_size) # 加载数据集
common.train_ch6(net, train_iter, test_iter, num_epochs, lr, common.try_gpu()) # 训练

 

从第一个批量规范化层中学到的拉伸参数gamma和偏移参数beta:

print(f"从第一个批量规范化层中学到的\n"
      f"拉伸参数 gamma:\n{net[1].gamma.reshape((-1,))},\n"
      f"偏移参数 beta: \n{net[1].beta.reshape((-1,))}")

5.5. 简明实现

除了使用刚刚定义的BatchNorm,也可以直接使用深度学习框架中定义的BatchNorm。该代码看起来几乎与上面的代码相同:

# 直接使用pytorch框架中的BatchNorm
net_useAPI = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5), nn.BatchNorm2d(6), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5), nn.BatchNorm2d(16), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
    nn.Linear(256, 120), nn.BatchNorm1d(120), nn.Sigmoid(),
    nn.Linear(120, 84), nn.BatchNorm1d(84), nn.Sigmoid(),
    nn.Linear(84, 10))

下面使用相同超参数来训练模型:

  • 注意,通常高级API变体运行速度快得多,
  • 因为它的代码已编译为C++或CUDA,而我们自己的自定义代码由Python实现。
common.train_ch6(net_useAPI, train_iter, test_iter, num_epochs, lr, common.try_gpu())

 

5.6. 争议

直观地说,批量规范化被认为可以使优化更加平滑。 然而,我们必须小心区分直觉和对我们观察到的现象的真实解释。 回想一下,我们甚至不知道简单的神经网络(多层感知机和传统的卷积神经网络)为什么如此有效。 即使在暂退法和权重衰减的情况下,它们仍然非常灵活,因此无法通过常规的学习理论泛化保证来解释它们是否能够泛化到看不见的数据。

  • 原理解释存疑:批量规范化论文作者用“减少内部协变量偏移(internal covariate shift)”解释其原理(据推测,作者所说的内部协变量转移 类似于上述的投机直觉,即变量值的分布在训练过程中会发生变化。),这种解释两个问题。
    • 一是此偏移与严格定义的协变量偏移(covariate shift)不同,用词不当;
    • 二是该解释仅提供不明确的直觉,未阐明技术有效原因。
    • 本书旨在传达实践者用来发展深层神经网络的直觉。然而,重要的是将这些指导性直觉与既定的科学事实区分开来。 最终,当你掌握了这些方法,并开始撰写自己的研究论文时,你会希望清楚地区分技术和直觉。
  • 引发学术讨论:随着批量规范化普及,“内部协变量偏移” 的解释引发技术文献辩论,成为“如何展示机器学习研究”讨论的焦点。Ali Rahimi在演讲中将“内部协变量偏移” 作为例子,把现代深度学习实践比作炼金术,还回顾了机器学习中相关令人不安的趋势;一些作者对批量规范化成功提出与原论文相反的解释(在某些方面,批量规范化的表现出与原始论文 (Santurkar et al., 2018)中声称的行为是相反的)
  • 方法不可或缺:尽管“内部协变量偏移”解释备受争议,但和众多模糊说法一样,因其受广泛认可而成争议焦点。批量规范化被证明不可或缺,适用于几乎所有图像分类器,并在学术界获数万次引用。

小结

  • 在模型训练过程中,批量规范化利用小批量的均值和标准差,不断调整神经网络的中间输出,使整个神经网络各层的中间输出值更加稳定。

  • 批量规范化在全连接层和卷积层的使用略有不同。(但皆放在激活函数前)

    • 卷积层:放在卷积层和激活函数之间。

    • 全连接层:放在全连接层和激活函数之间。

  • 批量规范化层和暂退层一样,在训练模式和预测模式下计算不同。

    • 训练模式:先计算当前小批量的均值和方差,由此计算出新的规范化输入。

    • 预测模式:直接使用传入的 移动平均所得的均值和方差 来计算新的规范化输入。

    • 得到新的规范化输入后,再应用缩放参数和偏移参数得到输出。

  • 批量规范化有许多有益的副作用,主要是正则化。另一方面,”减少内部协变量偏移“的原始动机似乎不是一个有效的解释。

6. 残差网络(ResNet)

  • 要想设计更深的网络,需要深刻理解“新添加的层如何提升神经网络的性能”。
  • 更重要的是设计网络的能力,在这种网络中,添加层会使网络更具表现力, 而一些数学基础知识,则是取得质的突破 的关键前提。

6.1. 函数类

  • 假设,\mathcal{F} 是一类特定的神经网络架构 ,它包括 学习速率和其他超参数设置。
  • 对于所有 f \in \mathcal{F},存在一些参数集(例如权重和偏置),这些参数可通过在合适的数据集上进行训练得到。
  • 现在假设 f^* 是真正想要找到的函数,若 f^* \in \mathcal{F},则可通过训练得到它,但通常不会那么幸运。
  • 相反,我们将尝试找到一个 在 \mathcal{F} 中的最佳选择,即 函数 f^*_\mathcal{F}。
  • 例如,给定一个具有 \mathbf{X}特性和标签\mathbf{y} 的数据集,可以尝试通过解决以下优化问题来找到 这个最接近 真正想找的函数f^* 的函数 f^*_\mathcal{F}:

f^*_\mathcal{F} := \mathop{\mathrm{argmin}}_f L(\mathbf{X}, \mathbf{y}, f) \text{ subject to } f \in \mathcal{F}.

  •  f^*_\mathcal{F}:在函数类 \mathcal{F} 中找到的最优函数 f
  • \mathop{\mathrm{argmin}}_f:表示使目标函数(这里是损失 L)取得最小值的函数 f
  • L(\mathbf{X}, \mathbf{y}, f):损失函数,衡量模型 f 在数据 (X,y) 上的预测误差
  • \text{ subject to } f \in \mathcal{F}:约束条件,要求最优函数 f 必须属于预设的函数类 \mathcal{F}

那么,怎样得到更近似真正 f^* 的函数呢?

  • 答案是 需要设计更强大的架构 \mathcal{F}'。
  • 换句话说,我们预计 f^*_{\mathcal{F}'} 比 f^*_\mathcal{F} “更近似”。
  • 然而,如果 \mathcal{F} \not\subseteq \mathcal{F}'(不是子集),则新的体系 f^*_{\mathcal{F}'} 不一定“更近似”,甚至可能更糟: 如 图7.6.1所示,
    • 对于非嵌套函数(non-nested function)类,较复杂的函数类并不总是向“真”函数 f^* 靠拢(复杂度由 \mathcal{F}_1 向 \mathcal{F}_6 递增)。在 图7.6.1的左边,虽然 \mathcal{F}_3 比 \mathcal{F}_1 更接近 f^*,但 \mathcal{F}_6 却离的更远了。
    • 相反对于 图7.6.1右侧的嵌套函数(nested function)类 \mathcal{F}_1 \subseteq \ldots \subseteq \mathcal{F}_6 ,则可以避免上述问题。
图7.6.1 对于非嵌套函数类,
较复杂(由较大区域表示)的函数类不能保证更接近“真”函数(f*)。
这种现象在嵌套函数类中不会发生。

因此,只有当较复杂的函数类包含较小的函数类时,才能确保提高它们的性能。对于深度神经网络,将新添加的层训练成恒等映射(identity function)f(\mathbf{x}) = \mathbf{x} 时,新模型不差于原模型且更容易降低训练误差()。

  • 添加层且恒等映射让其属于嵌套函数类,同时 新模型可能得出更优的解来拟合训练集。

残差网络(ResNet)的提出

  • 提出背景:针对这一问题,何恺明等人提出了残差网络(ResNet) (He et al., 2016),在2015年ImageNet图像识别挑战赛夺魁(夺冠),并深刻影响了后续深度神经网络的设计。
  • 核心思想:每个附加层应更易包含原始函数,由此诞生残差块(residual blocks),这个设计对如何建立深层神经网络影响深远。

6.2. 残差块

原理:聚焦神经网络局部:如图 图7.6.2所示,假设原始输入为x,希望学出的理想映射为 f(x)(作为 图7.6.2上方激活函数的输入):

  • 左图虚线框中的部分 需要直接拟合出该映射 f(x),
  • 而右图虚线框中的部分 则需要拟合出 残差映射 f(x)-x 。
  • 残差映射在现实中往往更容易优化。
  • 以本节开头提到的恒等映射作为我们希望学出的理想映射 f(x),我们只需将 图7.6.2中右图虚线框内上方的加权运算(如仿射)的权重和偏置参数设成0,那么 f(x) 即为恒等映射。
  • 实际中,当理想映射 f(x) 极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。 

图7.6.2右图是ResNet的基础架构–残差块(residual block)。在残差块中,输入可通过跨层数据线路更快地向前传播。

图7.6.2 一个正常块(左图)和一个残差块(右图)

ResNet沿用了VGG(使用块的网络)完整的3*3卷积层设计。

  1. 残差块里首先 有2个有相同输出通道数的 3*3卷积层。
  2. 每个卷积层后 接一个批量规范化层 和 ReLU激活函数。
  3. 然后通过跨层数据通路,跳过这2个卷积运算,将输入直接加在最后的ReLU激活函数前。
  • 这样的设计要求2个卷积层的输出与输入形状一样,从而使它们可以相加。
  • 若想改变通道数,则需引入一个额外的1*1卷积层来将输入变换成需要的形状后再做相加运算。

残差块的实现如下:

import torch
from torch import nn
from torch.nn import functional as F
import common

class Residual(nn.Module):  #@save
    def __init__(self, input_channels, num_channels,
                 use_1x1conv=False, strides=1):
        super().__init__()
        # 两个输出通道数相同的3*3卷积层
        self.conv1 = nn.Conv2d(input_channels, num_channels,
                               kernel_size=3, padding=1, stride=strides)
        self.conv2 = nn.Conv2d(num_channels, num_channels,
                               kernel_size=3, padding=1)
        # 可选的1x1卷积层(用于调整输入X的通道数和尺寸)
        if use_1x1conv:
            # 用于调整输入x的通道数和尺寸,使其能于y相加(即解决残差连接中的维度匹配问题)
            self.conv3 = nn.Conv2d(input_channels, num_channels,
                                   kernel_size=1, stride=strides)
        else:
            self.conv3 = None
        # 批量归一化层,用于稳定训练过程
        self.bn1 = nn.BatchNorm2d(num_channels)
        self.bn2 = nn.BatchNorm2d(num_channels)

    ''' 
    第二个卷积层后没有直接加激活函数的原因:
    1、是残差连接的需要,保证残差连接 X 和主路径 Y 的相加操作在线性空间中进行。
    残差块的核心是恒等映射(identity mapping),即允许原始输入X直接传递到输出(Y += X)
    若在conv2后直接加 eLU,残差X和主路径Y相加时,
            X是线性值,而Y是非线性值(经过ReLU),可能导致相加后的结果表达能力受限
    正确做法:先相加(Y+X),再对整体做非线性变换(ReLU),确保残差和主路径的交互更自然
    2、避免 ReLU 截断梯度,提升训练稳定性。
    3、遵循ResNet的原始设计,保持梯度流动。
    最终激活在相加后统一应用,确保非线性能力。
    这种设计是残差网络能够训练极深模型的关键之一!
    '''
    def forward(self, X):
        # 第一条路径(主路径):经过两个卷积层
        Y = F.relu(self.bn1(self.conv1(X))) # 卷积1 + BN + ReLU
        Y = self.bn2(self.conv2(Y))         # 卷积2 + BN(注意:这里没有ReLU!)
        # 第二条路径(残差连接):残差连接(直接传递X或通过1x1卷积调整)
        if self.conv3:
            X = self.conv3(X)   # 调整X的维度
        Y += X                  # 跳过两个卷积将输入直接加进来(残差相加)
        return F.relu(Y) # 对相加后的结果应用ReLU(在相加后统一激活)

如下图 图7.6.3所示,此代码生成两种类型的网络:

  • 一种是   当use_1x1conv=False时,应用ReLU非线性函数之前,将输入添加到输出。
  • 另一种是 当use_1x1conv=True时,添加通过1*1卷积调整通道和分辨率。
图7.6.3 包含以及不包含1*1卷积层的残差块

下面查看输入和输出形状一致的情况:

blk = Residual(3,3)
X = torch.rand(4, 3, 6, 6)
Y = blk(X)
print(f"输出形状:{Y.shape}")

也可以在增加输出通道数的同时,减半输出的高和宽:

blk = Residual(3,6, use_1x1conv=True, strides=2)
print(f"增加输出通道数的同时,输出的高和宽减半 \n此时的输出形状:{blk(X).shape}")

6.3. ResNet模型

ResNet的前两层跟之前介绍的GoogLeNet(含并行连结的网络)中的一样:

  • 在输出通道数为64、步幅为2的卷积层后,接步幅为2的的最大汇聚层(池化层)。

不同之处在于ResNet每个卷积层后增加了批量规范化层。

# 初始卷积块 b1:高分辨率 → 低分辨率(下采样)
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3), # 输入通道1(灰度图),输出通道64
                   nn.BatchNorm2d(64), nn.ReLU(), # 激活函数,批量归一化
                   nn.MaxPool2d(kernel_size=3, stride=2, padding=1)) # 最大池化(进一步下采样)
  • GoogLeNet 在后面接了4个由Inception块组成的模块。
  • ResNet 则使用4个由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块。
    • 第一个模块的通道数同输入通道数一致。由于之前已使用了步幅为2的最大汇聚层,所以无须减小高和宽。
    • 之后的每个模块在第一个残差块里将上一个模块的通道数翻倍,并将高和宽减半。

下面来实现这个模块。注意:这里对第一个模块做了特别处理:

'''
input_channels  :输入通道数
num_channels    :输出通道数
num_residuals   :当前阶段堆叠的残差块数量
first_block     :是否为网络的第一个残差阶段(避免首层下采样)
'''
def resnet_block(input_channels, num_channels, num_residuals,
                 first_block=False):
    blk = []
    for i in range(num_residuals):
        if i == 0 and not first_block:
            # 第一个块且非首层时,需调整维度(通道数+下采样)
            # 使用 use_1x1conv=True 和 strides=2 调整输入维度(通道数和尺寸),使其能与后续块相加
            blk.append(Residual(input_channels, num_channels,
                                use_1x1conv=True, strides=2))
        else:
            # 普通残差块:直接堆叠Residual(num_channels, num_channels),(通道数不变,无下采样)
            blk.append(Residual(num_channels, num_channels))
    return blk

接着在ResNet加入所有残差块,这里每个模块使用2个残差块:

# 残差块 b2-b5:堆叠多个残差块,逐步增加通道数并可能下采样
b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True))   # 64→64,2个残差块,无下采样
# b3-b5:
# 每个阶段的第一个残差块会通过 strides=2 下采样,并增加通道数(64→128→256→512)
# 后续残差块保持当前通道数和尺寸
b3 = nn.Sequential(*resnet_block(64, 128, 2))   # 64→128,2个残差块,第一个块会下采样
b4 = nn.Sequential(*resnet_block(128, 256, 2))  # 128→256,同上
b5 = nn.Sequential(*resnet_block(256, 512, 2))  # 256→512,同上

最后,与GoogLeNet一样,在ResNet中加入全局平均汇聚层,以及全连接层输出:

net = nn.Sequential(b1, b2, b3, b4, b5,  # 特征提取部分
                    # 全局平均池化 + 全连接层:将特征图转换为分类输出
                    nn.AdaptiveAvgPool2d((1,1)),  # 全局平均池化,输出尺寸为1x1(无论输入特征图尺寸如何,均池化为 1x1(适应不同输入尺寸))
                    nn.Flatten(),        # 展平为向量,将 1x1x512 的特征图展平为 512 维向量
                    nn.Linear(512, 10))   # 全连接层,输出10类 (10个类别的得分)
  • 每个模块有4个卷积层(不包括恒等映射的卷积层)。
  • 加上第一个卷积层和最后一个全连接层,共有18层。因此,这种模型通常被称为ResNet-18。
    • 初始卷积块b1有1个卷积层。
    • 每个残差块有2个卷积层,每一个模块含2给残差块,b2~b5共4个残差块,所以残差块部分层数=2*2*4=16。
    • 最后1个全连接层。
    • 加起来总共 1+16+1=18,共18层。
  • 通过配置不同的通道数和模块里的残差块数可以得到不同的ResNet模型,例如更深的含152层的ResNet-152。

虽然ResNet的主体架构跟GoogLeNet类似,但ResNet架构更简单,修改也更方便。这些因素都导致了ResNet迅速被广泛使用。 图7.6.4描述了完整的ResNet-18。

图7.6.4 ResNet-18 架构

在训练ResNet之前,先观察一下ResNet中不同模块的输入形状是如何变化的。在之前所有架构中,分辨率降低,通道数量增加,直到全局平均汇聚层聚集所有特征。

# 网络前向传播测试
X = torch.rand(size=(1, 1, 224, 224)) # 创建一个随机输入张量(模拟224x224的灰度图像)
for layer in net:
    X = layer(X) # 逐层通过网络
    print(layer.__class__.__name__,'output shape:\t', X.shape) # 每层的输出形状

  • 共4个残差阶段,没阶段各有2个残差块:
    • 除了首个残差阶段外,其余残差阶段都是首个残差块需降采样且用1×1卷积层)

各层输出形状分析:

  • 初始卷积块b1:卷积层将宽高减半,批量归一化和relu不变,最大汇聚(池化)再将高宽减半,所以 [1, 1, 224, 224]→[1, 64, 112, 112]→[1, 64, 56, 56]
  • 残差阶段b2:作为第一个残差阶段,其输入输出通道数相同,不改变输出形状,即 此阶段内所有残差块均无下采样(stride=1)。[1, 64, 56, 56]→[1, 64, 56, 56]
  • 残差阶段b3:
    • 第一个残差块:(下采样)
      • conv1 进行下采样(stride=2),尺寸减半。[1, 64, 56, 56]→[1, 128, 28, 28]
      • conv2 通道数和尺寸不变。[1, 128, 28, 28]→[1, 128, 28, 28]
      • conv3 (1*1卷积)调整原始输入X的通道数+尺寸(stride=2)。X[1, 64, 56, 56]→[1, 128, 28, 28] (以便让X能与Y相加)
      • 相加后形状:[1, 128, 28, 28]
    • 第二个残差块:(无下采样)
      • conv1 通道数和尺寸不变。[1, 128, 28, 28]→[1, 128, 28, 28]
      • conv2 通道数和尺寸不变。[1, 128, 28, 28]→[1, 128, 28, 28]
      • conv3 None
      • 相加后形状:[1, 128, 28, 28]
  • 残差阶段b4:同上。[1, 128, 28, 28]→[1, 256, 14, 14]
  • 残差阶段b5:同上。[1, 512, 14, 14]→[1, 512, 7, 7]
  • AdaptiveAvgPool2d((1,1))全局平均池化层:将任意尺寸的特征图池化为 1x1。[1, 512, 7, 7]→[1, 512, 1, 1]
  • 展平层:      [1, 512, 1, 1]→[1, 512],展平为向量。
  • 全连接层:[1, 512])→[1, 10],输出10类。

6.4. 训练模型

依旧在Fashion-MNIST数据集上训练ResNet:

lr, num_epochs, batch_size = 0.05, 10, 256 # 学习率,训练轮数,批量大小
train_iter, test_iter = common.load_data_fashion_mnist(batch_size, resize=96)
common.train_ch6(net, train_iter, test_iter, num_epochs, lr, common.try_gpu())

小结

  • 学习嵌套函数(nested function)是训练神经网络的理想情况。在深层神经网络中,学习另一层作为恒等映射(identity function)较容易(尽管这是一个极端情况)。

  • 残差映射可以更容易地学习同一函数,例如将权重层中的参数近似为零。

  • 利用残差块(residual blocks)可以训练出一个有效的深层神经网络:输入可以通过层间的残余连接更快地向前传播。

  • 残差网络(ResNet)对随后的深层神经网络设计产生了深远影响。

7. 稠密连接网络(DenseNet)

  • ResNet极大地改变了如何参数化深层网络中函数的观点。 
  • 稠密连接网络(DenseNet) (Huang et al., 2017) 在某种程度上是ResNet的逻辑扩展。

ResNet 的核心贡献:重新参数化深层函数

ResNet(He et al., 2015)的核心突破是提出了 残差连接(Residual Connection),通过引入恒等映射(Identity Shortcut)解决了深层网络训练中的梯度消失/爆炸问题。其核心思想是:

  • 重新定义函数:传统网络直接学习输入到输出的映射 H(x),而 ResNet 学习的是残差函数 F(x)=H(x)−x,即 H(x)=F(x)+x。
  • 优势:
    • 梯度可以通过捷径连接(Shortcut)直接回传,缓解了深层网络的优化难题。
    • 允许网络更高效地利用浅层特征(通过相加操作复用特征)。

本质:ResNet 通过 加法(Addition) 重新参数化了深层网络的函数,将“直接学习复杂映射”转化为“学习残差修正”。


DenseNet 的逻辑扩展:从加法到拼接的稠密连接

DenseNet(Huang et al., 2017)进一步思考:如果残差连接的加法操作可以保留特征,那么是否可以通过更激进的连接方式进一步利用特征? 于是提出了 稠密连接(Dense Connectivity):

  • 核心设计:
    • 每一层的输入是前面所有层输出的 拼接(Concatenation),而非加法。
    • 例如,第 l 层的输入是 [x0​,x1​,...,xl−1​],其中 xi​ 是第 i 层的输出。
  • 优势:
    • 特征复用:每一层可以直接访问所有历史层的特征,鼓励特征重用(Feature Reuse)。
    • 隐式深度监督:密集连接相当于为每一层提供了“多监督信号”,缓解了梯度消失问题。
    • 参数效率:通过拼接而非加法,避免了通道数的指数级增长(DenseNet 使用 瓶颈层(Bottleneck) 和 过渡层(Transition Layer) 控制维度)。

本质:DenseNet 通过 拼接(Concatenation) 扩展了 ResNet 的连接模式,将“残差修正”升级为“特征聚合”,形成了一种更极端的特征复用机制。


ResNet 与 DenseNet 的对比:(连接模式的对比)

特性ResNetDenseNet
连接方式加法(Element-wise Addition)拼接(Concatenation)
信息流浅层特征通过梯度回传间接影响深层浅层特征直接作为深层输入,复用更彻底
特征复用仅当前层与前一层的残差交互每一层与所有历史层直接交互
参数效率通道数可能爆炸(需通过1×1卷积降维)通过稠密块和过渡层控制维度增长
计算复杂度较低(加法操作轻量)较高(拼接后通道数线性增加)
哲学“修正”浅层特征“聚合”所有历史特征

7.1. 从ResNet到DenseNet

  • 从 ResNet 到 DenseNet 的演进,本质上是 网络连接模式的创新:
    • ResNet 通过残差连接(加法)重新定义了深层函数的参数化方式,
    • 而 DenseNet 通过稠密连接(拼接)进一步扩展了这一思想,实现了更彻底的特征复用。

回想一下任意函数的泰勒展开式(Taylor expansion),它把这个函数分解成越来越高阶的项 (泰勒展开 将函数分解为 不同阶数的 多项式项之和)。在接近0时,

f(x) = f(0) + f'(0) x + \frac{f''(0)}{2!} x^2 + \frac{f'''(0)}{3!} x^3 + \ldots.

    同样(ResNet 的类比),将函数分解为 线性项(恒等映射)和 非线性残差项 的加法:

    ResNet 将函数展开为 f(\mathbf{x}) = \mathbf{x} + g(\mathbf{x}).  (类似泰勒展开的一阶近似 + 高阶修正)

    ResNet 核心思想:通过加法保留浅层特征,缓解梯度消失。

    也就是说,ResNet将 f 分解为两部分:

    • 一个简单的线性项 和
    • 一个复杂的非线性项。

    那么再向前拓展一步,若想将 f 拓展成超过两部分的信息,一种方案便是DenseNet。

    DenseNet 的拓展:若泰勒展开允许更复杂的组合方式(如乘法、拼接),DenseNet 选择 拼接(Concatenation) 作为连接操作。

    核心思想:通过拼接聚合所有层的信息,最大化特征复用。

    图7.7.1 ResNet(左)与 DenseNet(右)在跨层连接上的主要区别:
    使用【相加】和 使用【连结】

    如 图7.7.1所示,ResNet和DenseNet的关键区别在于,

    • DenseNet 输出是连接(用图中的 [,] 表示)
    • 而不是如ResNet的简单相加。

    因此,在应用越来越复杂的函数序列后,我们执行从到其展开式的映射:

    \mathbf{x} \to \left[ \mathbf{x}, f_1(\mathbf{x}), f_2([\mathbf{x}, f_1(\mathbf{x})]), f_3([\mathbf{x}, f_1(\mathbf{x}), f_2([\mathbf{x}, f_1(\mathbf{x})])]), \ldots\right].

    最后,将这些展开式结合到多层感知机中,再次减少特征的数量。实现起来非常简单:

    • 不需要添加术语,而是将它们连接起来。

    DenseNet这个名字由变量之间的“稠密连接”而得来,最后一层与之前的所有层紧密相连。 稠密连接如 图7.7.2所示。

    图7.7.2 稠密连接

    稠密网络主要由2部分构成:稠密块(dense block)和 过渡层(transition layer)。

    • 稠密块:定义如何连接输入和输出,
    • 过渡层:控制通道数量,使其不会太复杂。

    7.2. 稠密块体(Dense Block)

    稠密块每一层都将所有前面的特征图作为输入
    • 定义:由多个 稠密层(Dense Layer) 组成,每层的输出通过拼接传递给后续所有层。

    • 稠密层结构:输出=BatchNorm→ReLU→Conv(3×3)

    • 输入:拼接所有历史层的输出(通道数累加)。
    • 输出:通过 3×3 卷积提取特征,保持空间分辨率。
    • 稠密连接:
      • 每一层的输入是之前所有层输出的拼接,实现特征复用。
      • 通道数随层数线性增长(增长率 k=num_channels)
    • 参数计算 示例:
      • 若稠密块有 L 层,
      • 每层输出 k 个通道(称为 增长率(Growth Rate)),
      • 则第 l 层的输入通道数为 k_{0}+k×(l-1),其中 k_{0}​ 是初始通道数。
    • 与 ResNet 的区别:
      • ResNet:H(x) = F(x) + x(加法,通道数不变)
      • DenseNet:H(x) = [x, F(x)](拼接,通道数累加)

    DenseNet使用了ResNet改良版的“批量规范化、激活和卷积”架构(参见 7.6节中的练习)。

    首先实现一下这个架构:

    import torch
    from torch import nn
    import common
    
    """
    稠密网络:
            1、稠密块:定义如果连接输入和输出
            2、过渡层:后者控制通道数
    """
    
    # 基础卷积块 (BN → ReLU → conv2d)
    def conv_block(input_channels, num_channels):
        return nn.Sequential(
            nn.BatchNorm2d(input_channels), nn.ReLU(), # 批量归一化、激活函数
            nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1)) # 3*3卷积

    一个稠密块:

    • 由多个卷积块组成,每个卷积块使用相同数量的输出通道。
    • 且在前向传播中,将每个卷积块的 输入和输出 在通道维度上 连结:
    '''
    # 包含num_convs个卷积块的 稠密块
    num_convs       :稠密块中的卷积层数量
    input_channels  :输入张量的通道数
    num_channels    :每个卷积层输出的通道数(即增长率 k)
    '''
    class DenseBlock(nn.Module):
        def __init__(self, num_convs, input_channels, num_channels):
            super(DenseBlock, self).__init__()
            layer = []
            # 随着层数增加,通道数线性增长(每层增加num_channels)
            for i in range(num_convs):
                layer.append(conv_block(
                    num_channels * i + input_channels, num_channels))
            self.net = nn.Sequential(*layer) # 所有卷积块按顺序存入self.net(nn.Sequential容器)
    
        # 逐层处理输入,并将每一层的输出与原始输入拼接
        def forward(self, X):
            for blk in self.net:
                Y = blk(X) # 通过当前卷积块
                # 连接通道维度上每个块的输入和输出
                # 特征拼接:将 当前层的输出Y 之前的所有特征X 在通道维度(dim=1)上 拼接
                X = torch.cat((X, Y), dim=1) # 拼接输入和输出(通道维度)
            return X
    • 增长率(growth rate):卷积块的通道数。其控制了 输出通道数相对于输入通道数 的增长。

    下例定义一个 有2个卷积层,且输出通道数为10的DenseBlock。 使用通道数为3的输入时,会得到通道数为 3+2*10=23 的输出。(3输入+2层卷积层*10卷积层输出 = 23输出通道)

    blk = DenseBlock(2, 3, 10)  # 2层卷积,输入通道3,增长率10
    X = torch.randn(4, 3, 8, 8) # 输入:batch_size=4, 通道=3, 高8, 宽8(4张3通道的8×8图像)
    Y = blk(X)
    print(f"输出形状:{Y.shape}")

    输出形状计算:

    • 第 1 层:
      • 输入通道:3(初始输入)
      • 输出 Y1 形状:(4, 10, 8, 8)
      • 拼接后 X 形状:(4, 3+10=13, 8, 8)(3输入+10卷积层输出 = 13输出通道)
    • 第 2 层:
      • 输入通道:13(前一次拼接后的结果)(第一层的输出)
      • 输出 Y2 形状:(4, 10, 8, 8)
      • 拼接后 X 形状:(4, 13+10=23, 8, 8)(13输入+10卷积层输出 = 23输出通道)
    • 最终输出:Y.shape = (4, 23, 8, 8)

    可视化流程:

    输入 X (3通道)
        │
        ├─→ 卷积块1 (输入3通道) → 输出10通道 → 拼接 → X (3+10=13通道)
        │
        └─→ 卷积块2 (输入13通道) → 输出10通道 → 拼接 → X (13+10=23通道)
        │
        输出 Y (23通道)

    7.3. 过渡层(Transition Layer)

    • 作用:控制稠密块输出的通道数,防止参数爆炸。

    • 结构:输出=BatchNorm→Conv(1×1)→AvgPool(2×2)

    • 1×1 卷积:降维(压缩通道数,通常压缩比例为 θ∈(0,1])。
    • 平均池化:降低空间分辨率,扩大感受野。

    由于每个稠密块都会带来通道数的增加,使用过多则会过于复杂化模型。

    • 过渡层则可以用来控制模型复杂度。
      • 它通过1*1卷积层来减小通道数,
      • 并使用步幅为2的平均汇聚层使高和宽减半,从而进一步降低模型复杂度。
    # 过渡层:再稠密块之间降低特征图的 通道数和空间尺寸 (高和宽减半)
    def transition_block(input_channels, num_channels):
        return nn.Sequential(
            nn.BatchNorm2d(input_channels), nn.ReLU(),              # 批量归一化、激活函数
            nn.Conv2d(input_channels, num_channels, kernel_size=1), # 1*1卷积层,减少通道数(input_channels → num_channels)
            nn.AvgPool2d(kernel_size=2, stride=2))                  # 平均汇聚(池化) 高宽减半(stride=2)

    对上一个例子中稠密块的输出使用通道数为10的过渡层。此时输出的通道数减为10,高和宽均减半:

    blk = transition_block(23, 10) # 输入(4,23,8,8) → 输出(4,10,4,4)
    print(f"输出的通道数减为10,高和宽均减半 \n此时的输出形状:{blk(Y).shape}")

    7.4. DenseNet模型

    网络整体构架:

    Input→Conv(7×7)→

    DenseBlock_1​→Transition_1

    →⋯→

    DenseBlock_n​→Transition_n​→

    GlobalAvgPool→FC

    • 关键参数:
      • 稠密块数量 n(通常为 3~4)
      • 增长率 k(通常为 12~32)
      • 过渡层压缩率 θ(通常为 0.5)

    现在开始构造DenseNet模型。

    1、首先使用同ResNet一样的单卷积层和最大汇聚层:

    # 构建DenseNet
    # 首先,使用和ResNet一样的卷积层和最大汇聚层
    # 初始卷积块:处理输入图像,提取初始特征
    b1 = nn.Sequential(
        nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3), # 输入1通道→输出64通道,空间尺寸减半(28×28 → 14×14)
        nn.BatchNorm2d(64), nn.ReLU(),
        nn.MaxPool2d(kernel_size=3, stride=2, padding=1)) # 3*3最大池化,空间尺寸再减半(14×14 → 7×7)

    2、DenseNet使用4个稠密块 (类似于ResNet使用的4个残差块)。与ResNet类似,可以设置每个稠密块使用多少个卷积层。

    • 这里设成4,从而与 (前面 6. 残差网络(ResNet)) 的ResNet-18保持一致。
    • 稠密块里的卷积层通道数(即增长率)设为32,所以每个稠密块将增加128个通道。

    在每个模块之间,

    • ResNet 通过步幅为2的残差块减小高和宽,
    • DenseNe t则使用过渡层来减半高和宽,并减半通道数。
    """
    DenseNet使用4个稠密块:
    1、稠密块里的卷积层通道数(即增长率)设为32,每个稠密块将增加4x32=128个通道
    2、每个模块之间,DenseNet使用过渡层减半高度和宽度,并减半通道数
    """
    # num_channels 为当前的通道数
    # growth_rate 为增长率,即输出通道相对于输入通道的增长程度,即每个卷积层输出的通道数
    num_channels, growth_rate = 64, 32 # 初始通道数64,增长率32
    # 4个稠密块,每个稠密块4个卷积层,每个稠密块增加4x32=128个通道
    num_convs_in_dense_blocks = [4, 4, 4, 4] # 每个稠密块包含4个卷积层
    
    # 构建稠密块和过渡层
    blks = []
    for i, num_convs in enumerate(num_convs_in_dense_blocks):
        blks.append(DenseBlock(num_convs, num_channels, growth_rate))
        # 上一个稠密块的输出通道数
        # 更新通道数(稠密块输出拼接后通道数增加):
        # 原通道数num_channels+新增通道数(4层卷积*每个产生32通道 即32增长率 即卷积层输出通道数32=新增128通道),
        num_channels += num_convs * growth_rate
        # 在稠密块之间添加一个转换层,使通道数量减半
        if i != len(num_convs_in_dense_blocks) - 1: # 如果不是最后一个稠密块,添加过渡层
            blks.append(transition_block(num_channels, num_channels // 2))
            num_channels = num_channels // 2  # 过渡层减半通道数

    3、最后接上全局汇聚层和全连接层来输出结果:(与ResNet类似)

    net = nn.Sequential(
        b1, *blks, # 初始卷积块 + 稠密块 + 过渡层
        nn.BatchNorm2d(num_channels), nn.ReLU(),    # 最终批归一化 + ReLU
        nn.AdaptiveAvgPool2d((1, 1)),               # 全局平均池化 →(batch_size,channels,1,1),将空间维度(H,W)压缩为(1,1),保留通道信息
        nn.Flatten(),                               # 展平 → (batch_size, channels)
        nn.Linear(num_channels, 10))     # 全连接层(10类分类)

    7.5. 训练模型

    由于这里使用了比较深的网络,本节里我们将输入高和宽从224降到96来简化计算。

    # 训练模型
    # 由于模型较深,这里将输入高度和宽度从224降为96
    lr, num_epochs, batch_size = 0.1, 10, 256
    train_iter, test_iter = common.load_data_fashion_mnist(batch_size, resize=96)
    common.train_ch6(net, train_iter, test_iter, num_epochs, lr, common.try_gpu())

    小结

    • 在跨层连接上,

      • ResNet残差网络 将输入与输出相加,

      • 稠密连接网络(DenseNet)在通道维上连结输入与输出。

    • DenseNet的主要构建模块:稠密块 和 过渡层。

    • 在构建DenseNet时,需要通过添加过渡层来控制网络的维数,从而再次减少通道的数量。

    Logo

    腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

    更多推荐