Chapter 7 现代卷积神经网络

长文预警!!!(›´ω`‹ )

上一章我们了解了 CNN 的基本原理,那么在这一章自然考虑进行应用,章节开头就给出了这一章中我们预期实现的模型,包括:

  • AlexNet
  • VGG (这其实更类似于处理方法)
  • NiN(碟中谍网中网)
  • GoogLeNet:并行连结
  • ResNet:残差网络
  • DenseNet:稠密神经网络

虽然神经网络的概念非常简单,但不同的网络架构和超参数选择导致不同的网络性能各不相同。因此对经典网络的参数进行探究是合理且必要的,它有助于培养相关直觉,帮助我们开发自己的架构。

考虑到 ImageNet 数据集的内容过多,下述模型训练的数据集不做说明均为CIFAR10

AlexNet

总体来看, AlexNet 的设计理念和 LeNet5 的理念其实是差不多的,但 AlexNet 较之于 LeNet5 要深得多,它包括 5 个卷积层,2 个全连接隐藏层以及 1 个输出层.相较之下 LeNet 才 2 个卷积

另外 AlexNet 的激活函数用的是 ReLU 而非 Sigmoid,这有效降低了计算的难度,并且保证了输入在趋近于 $[0, 1]$ 时可能存在的的梯度消失问题。

另外,模型本身的加深可能导致模型最后输出时过拟合,为了缓解这一症状,在进行全连接时进行了暂退,通过丢弃神经元来保证模型的泛化能力。

所以实现思路就相当明确了:

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torchvision import transforms, datasets

class AlexNet(nn.Module):
    """ AlexNet 类 """
    def __init__(self, num_classes=1000):
        super().__init__()
        self.squeeze = nn.Sequential(
            nn.Conv2d(3, 96, kernel_size=3, stride=1, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(96, 256, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3,stride=2),
            nn.Conv2d(256, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        # 自适应池化层
        self.classifier = nn.Sequential(
            nn.AdaptiveAvgPool2d((1,1)),   
            nn.Flatten(),
            nn.Linear(256, 4096), nn.ReLU(inplace=True),
            nn.Dropout(0.5),        # 减轻过拟合
            nn.Linear(4096, 4096), nn.ReLU(inplace=True),
            nn.Dropout(0.5),        # 减轻过拟合
            nn.Linear(4096, num_classes)
        )

    def forward(self,X):
        X = self.squeeze(X)
        X = self.classifier(X)
        return X

VGG

VGG(Visual Geometry Group)网络是由牛津大学 Visual Geometry Group 在2014年提出(论文《Very Deep Convolutional Networks for Large-Scale Image Recognition》)。其核心思想包括:

  1. 更深的网络(Depth):用深度换质量,论文中提及,增加网络深度可以有效提升模型的特征学习能力,VGG 通过堆叠大量小型卷积核,构建起比 AlexNet 还要深得多的神经网络
  2. 小型卷积核:这是与 AlexNet 最大的差别,通过大批量、小规模的卷积核,模型能够保证在感受野基本一致的情况下保持更少的参数量,且通过大量附加的非线性激活函数来保证模型的非线性,使决策函数更有判别力
  3. 对块进行封装(Block-based Design):VGG 同时对块进行组织封装以实现卷积块,具体而言
    • 每个块包含: 连续数个(如2个、3个或4个)的 Conv2d (3x3) -> ReLU 层。
    • 块末尾: 跟随一个 MaxPool2d (2x2, stride=2) 层,用于对特征图进行空间下采样(尺寸减半,通道数翻倍)
    • 事实上,这种设计极大的提升了模型的可复现性、可读性和可扩展性,如果我们想进一步提升网络深度,只需要在块中加几层卷积就行
  4. 全连接分类器: 在卷积块提取特征后,VGG和AlexNet一样,使用几个全连接层(FC)来进行最终的分类

在实现上,我们先考虑封装一个 VGG 类内的构建层方法:

def make_layers(cfg, batch_norm=False):
    layers = []
    in_channels = 3
    for v in cfg:
        if v == 'M':
            layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
        else:
            conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
            if batch_norm:
                layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
            else:
                layers += [conv2d, nn.ReLU(inplace=True)]
            in_channels = v
    return nn.Sequential(*layers)

这使得我们可以通过配置一个 config 字典来迅速准确的构建对应的 VGG 网络:

cfg = {
    'VGG11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
    'VGG13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
    'VGG16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M'], 
    'VGG19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'],
}

之后就是常规的后全连接层、前向传播以及权重初始化的相关定义:

class VGG(nn.Module):
    def __init__(self, features, num_classes=10, init_weights=True):
        super(VGG, self).__init__()
        self.features = features
        # 对于CIFAR-10,特征图最终尺寸为1x1,因此自适应池化为(1, 1)
        self.adaptive_avg_pool = nn.AdaptiveAvgPool2d((1, 1))
        self.classifier = nn.Sequential(
            nn.Linear(512, 1024),  # 输入尺寸 512
            nn.ReLU(True),
            nn.Dropout(0.5),
            nn.Linear(1024, 1024),
            nn.ReLU(True),
            nn.Dropout(0.6),
            nn.Linear(1024, num_classes),
        )
        if init_weights:
            self._initialize_weights()

    def forward(self, x):
        x = self.features(x)                # 通过VGG网络提取特征
        x = self.adaptive_avg_pool(x)       # 自适应池化到1x1
        x = torch.flatten(x, 1)             # 展平为[batch_size, 512]
        x = self.classifier(x)              # 通过分类器
        return x

    def _initialize_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
                if m.bias is not None:
                    nn.init.constant_(m.bias, 0)
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.constant_(m.weight, 1)
                nn.init.constant_(m.bias, 0)
            elif isinstance(m, nn.Linear):
                nn.init.normal_(m.weight, 0, 0.01)
                nn.init.constant_(m.bias, 0)

但 VGG 原本的设计是针对 ImageNet 这种大输入图像集的神经网络。我们的测试数据集为 CIFAR10,原本的 VGG 网络可能过深,导致模型的最终训练效果其实并不如 AlexNet.比如 VGG11 在lr = 0.005, weight_decay = 5e-4 情况下的训练结果为:

VGG11-CFAIR-10训练loss & acc 图

我们可以很明显的发现在 Epoch 7 后模型学习到的特征是对泛化能力没有帮助的,此后模型表现出了强过拟合行为,即高 Training acc,高 Test loss 但 Test acc 却基本不动。


NiN

原始的NiN论文同样是在ImageNet数据集上进行设计的,输入为 224×224 的RGB图像。其架构主要由两种核心组件重复堆叠构成:

  • NiN 块
  • 最大池化层(MaxPoolingLayer)

并在最后以一个全局平均池化层(Global Average Layer)结束

NiN 块

这是 NiN 网络的核心组成,但和前述网络存在区别的是,它在最大池化层来提取特征之前,就通过两个 $1\times 1$ 的卷积层先进行操作,且每一层的后面都紧跟着一个 ReLU 激活函数。

我们为什么采用 $1\times 1$ 卷积?

其实在前文我们讨论过 $1\times 1$ 卷积层的相关作用,它能够:

  • 跨通道信息交互:它可以灵活地融合不同通道的特征图,实现降维或升维。
  • 增加非线性:每个1×1卷积后都接一个ReLU,使这个“微网络”能够拟合更复杂的函数,比单次卷积+ReLU的表达能力强的多

所以 NiN 整体架构就非常简单了,就是 NiN 块的交替堆叠和最大池化。当然论文结尾还有一个不得不提的神来之笔:全局平均池化(GAP)

全局平均池化层对最后一个NiN块输出的特征图的每个通道(Channel)求平均值,并直接展平送入预测。这完全移除了全连接层,极大降低了模型最终的参数量,有效控制了模型的过拟合。

实现

在 NiN 的实现上,我们先考虑 NiN Block 的构建:

class NiNBlock(nn.Module):
    """ NiN 块 """
    def __init__(self, in_channels, out_channels, kernel_size, stride, padding):
        super().__init__()
        self.block = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True),
            nn.Conv2d(out_channels, out_channels, kernel_size=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True),
            nn.Conv2d(out_channels, out_channels, kernel_size=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True)
        )

    def forward(self, x):
        return self.block(x)

以及 NiN 类的实现:

class NiN(nn.Module):
    """ NiN 网络 """
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            NiNBlock(3, 192, kernel_size=5, stride=1, padding=2),
            nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
            nn.Dropout2d(p=0.3),

            NiNBlock(192, 192, kernel_size=5, stride=1, padding=2),
            nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
            nn.Dropout2d(p=0.4),

            NiNBlock(192, 256, kernel_size=3, stride=1, padding=1),
            nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
            nn.Dropout2d(p=0.5),

            NiNBlock(256, num_classes, kernel_size=3, stride=1, padding=1)
        )
        self.classifier = nn.Sequential(
            nn.AdaptiveAvgPool2d((1, 1)),
            nn.Flatten()
        )

    def forward(self, x):
        x = self.features(x)
        x = self.classifier(x)
        return x

不过可能是我的实现方式有问题,在不考虑数据增强的情况下,我构建出的 NiN 在面对 CIFAR10 数据集上的表现较之于 VGG 低了 3% 左右,怎么说呢,很玄学的事(


GoogLeNet

与前面几个模型相比, GoogLeNet 最显著,也是最直观的特点是他引入了并行化的相关概念,通过并行地应用多种不同尺度的卷积操作(和池化操作),将它们的输出在通道维度上进行拼接(Concatenate)后作为下一层的输入,从而实现了在计算预算和内存限制内,让模型通过一种高效的“网络结构设计”来获得更强的表达能力

并行化

我们来梳理一前面三种的网络实现架构:

$$\begin{align}&\text{AlexNet / VGG}: \text{Conv} \rightarrow \text{ReLU} \rightarrow \text{Pool} \rightarrow \text{Conv} \rightarrow \text{ReLU} \rightarrow \text{Pool} \rightarrow … \rightarrow \text{FC}\\ &\text{NiN}: \text{MLPConv} \rightarrow \text{Pool} \rightarrow \text{MLPConv} \rightarrow \text{Pool} \rightarrow … \rightarrow \text{GAP}\end{align}$$

可以很明显的看到,之前的网络都是线性(串行) 结构,每一层的输出直接作为下一层的输入。

但 GoogLeNet 的架构为

$$\text{GoogLeNet}: \text{Inception Module} \rightarrow \text{Pool} \rightarrow \text{Inception Module} \rightarrow \text{Pool} \rightarrow …$$

我们可以看到一个很明显的趋势,相较于我们手动指定规模卷积核,为什么不能让网络”我全都要”()

因此,Inception模块并行地应用了多种不同尺度的卷积操作(和池化操作),并将它们的输出在通道维度上进行拼接(Concatenate)。

所以,一个 Naïve 的 Inception 块的构成包括:

  • 并行进行:
    1. 1×1 卷积
    2. 3×3 卷积
    3. 5×5 卷积
    4. 3×3 最大池化
  • 将四个分支的输出堆叠起来。

但上述Naïve版本有一个致命问题:计算量巨大,尤其是5×5卷积和池化后的通道拼接。GoogLeNet巧妙地吸收了NiN的1×1卷积,将其作为“瓶颈层(Bottleneck Layer)”,来降维,这极大减少了计算量。

所以实际的 Inception 块构成为:

在3×3和5×5卷积之前,以及池化之后,都添加了1×1卷积。

  • 作用1(对3×3/5×5分支): 降维。先通过 1×1 卷积减少通道数,再进行昂贵的 3×3 或 5×5 卷积,计算量骤降。
  • 作用2(对池化分支): 控制通道数。池化操作不改变通道数,直接拼接会导致通道数只增不减。1×1 卷积可以将其投影到新的、更少的通道空间。

而这就使得GoogLeNet在保持甚至提升模型表达能力的同时,参数量(5M)远小于VGG(138M)和AlexNet(60M),计算效率极高

辅助分类器(实现中没考虑)

为了解决深度网络带来的梯度消失问题,GoogLeNet 在网络的中部还引入了两个辅助分类器。他们的结构是完全相同的都是一个迷你版的卷积网络分类器,具体来说:

  • 输出层: 另一个全连接层,输出单元数与主分类器相同(例如ImageNet是1000个类),后面接一个Softmax函数,产生一个分类预测。
  • 平均池化层(Average Pooling): 使用5×5的滤波器,步幅为3。(例如,对于第一个辅助分类器,输入是14x14x512,输出为4x4x512)
  • 1×1卷积层: 用于降维和减少计算量。(例如,将512个通道减少到128个)
  • 全连接层: 将特征图展平后连接到一个1024个单元的隐藏层。
  • Dropout层: 防止过拟合。

当然在测试阶段,辅助分类器会被丢弃

此外 GoogLeNet 还丢弃了全连接层而是采用了全局平均池化层来进行替代,这样就进一步减少了参数量。

总的来说,GoogLeNet通过 Inception 模块实现了多尺度并行处理,让网络能够在同一层级捕获不同范围的上下文信息(通过设置卷积核大小);同时,为了减少计算的大量性能开销,它采用 $1 \times 1 $ 瓶颈层大幅降低了计算成本,使得模型构建更宽(更多通道)更深的网络成为可能。

实现

前面劈里啪啦说了这么多,只有实践才是最重要的。现在来看 GoogLeNet 的实现。

Inception 块

首先是 Inception 块的实现,这是 GoogLeNet 的核心之一。

我们在这考虑到数据集的变换,对 Inception 块做出了一些调整,但其核心还是不变的——不同感受野的同步提取:

class Inception(nn.Module):
    def __init__(self, in_channels, out_channels, **kwargs):
        super(Inception, self).__init__(**kwargs)
        # 分支1: 1x1卷积
        self.module_1 = nn.Sequential(
            nn.Conv2d(in_channels, out_channels[0], kernel_size=1),
            nn.BatchNorm2d(out_channels[0]),
            nn.ReLU(inplace=True)
        )
        # 分支2: 1x1卷积 -> 3x3卷积
        self.module_2 = nn.Sequential(
            nn.Conv2d(in_channels, 64, kernel_size=1),  # 减少维度
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.Conv2d(64, out_channels[1], kernel_size=3, padding=1),
            nn.BatchNorm2d(out_channels[1]),
            nn.ReLU(inplace=True)
        )
        # 分支3: 1x1卷积 -> 5x5卷积
        self.module_3 = nn.Sequential(
            nn.Conv2d(in_channels, 64, kernel_size=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.Conv2d(64, out_channels[2], kernel_size=5, padding=2),  # padding=2保持尺寸
            nn.BatchNorm2d(out_channels[2]),
            nn.ReLU(inplace=True)
        )
        # 分支4: 3x3最大池化 -> 1x1卷积
        self.module_4 = nn.Sequential(
            nn.MaxPool2d(kernel_size=3, stride=1, padding=1),  # stride=1保持尺寸
            nn.Conv2d(in_channels, out_channels[3], kernel_size=1),
            nn.BatchNorm2d(out_channels[3]),
            nn.ReLU(inplace=True)
        )
    
    def forward(self, x):
        x1 = self.module_1(x)
        x2 = self.module_2(x)
        x3 = self.module_3(x)
        x4 = self.module_4(x)
        return torch.cat([x1, x2, x3, x4], dim=1)

那么主体的实现就很简单了:

class GoogLeNet(nn.Module):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # 预处理层:输出尺寸16x16x192
        self.preLayer = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
            nn.Conv2d(64, 64, kernel_size=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.Conv2d(64, 192, kernel_size=3, padding=1),
            nn.BatchNorm2d(192),
            nn.ReLU(inplace=True),
        )
        # 下采样层:将尺寸从16x16下采样到8x8
        self.maxPoolingLayer = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        # Inception模块序列:所有模块保持8x8尺寸
        self.inception_1 = nn.Sequential(   
            Inception(192, [128, 64, 32, 64]),  # 输出通道288
            Inception(288, [128, 64, 32, 64]),  # 输出通道288
        )
        self.inception_2 = nn.Sequential(
            Inception(288, [128, 96, 48, 64]),  # 输出通道336
            Inception(336, [128, 96, 48, 64]),  # 输出通道336
            Inception(336, [128, 96, 48, 64]),  # 输出通道336
            Inception(336, [128, 96, 48, 64]),  # 输出通道336
            Inception(336, [128, 96, 48, 64]),  # 输出通道336
        )
        self.inception_3 = nn.Sequential(
            Inception(336, [192, 96, 48, 64]),  # 输出通道400
            Inception(400, [192, 96, 48, 64]),   # 输出通道400
        )
        # 全局平均池化和全连接层
        self.avgPoolingLayer = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(400, 10)  # 输入特征数为400,输出10类(CIFAR-10)
    
    def forward(self, x):
        x = self.preLayer(x) 
        x = self.maxPoolingLayer(x) 
        x = self.inception_1(x) 
        x = self.inception_2(x)  
        x = self.inception_3(x) 
        x = self.avgPoolingLayer(x)  
        x = x.view(x.size(0), -1) 
        x = self.fc(x) 
        return x

Batch Normalization – 批量归一化

在介绍 ResNet 之前,我们不妨将目光移回到我们已经构建出的相关模型上。在进行训练时,前面层参数会不断更新,导致后面每一层输入的分布也随之发生改变,在模型过深时就会导致每一层都要不停地去适应一个新的数据分布,使得训练变得困难,也就是所谓的“内部协变量偏移(ICS)”

虽然似乎我并没有遇上,我遇上的都是过拟合(头疼

如果输入分布稳定,我们可以通过精心初始化权重,让输入尽可能保持在激活函数的线性区域(梯度最大的区域附近),从而缓解梯度消失。但 ICS 的存在破坏了这种稳定性,使得精心设置的初始化可能很快失效。导致某一层的输入整体偏移到激活函数的饱和区。而 一旦输入进入饱和区,该层激活函数的梯度 $\frac{\partial a}{\partial z}$​ 就会变得极小。在反向传播时,这个极小的梯度值就会参与链式相乘,迅速导致传向更前面层的梯度消失于是模型的学习就完蛋了。

那么很自然就想到了,既然我们遇上的是偏移,那把偏移的部分给“揪”回来不就好了。这也是 BN 的核心思想,即通过对神经网络中每一层的输入进行重新中心化(Re-centering)和重新缩放(Re-scaling)从而显著加快训练速度,提升模型稳定性,并具有一定的正则化效果

具体步骤

Batch Normalization 在一个 mini-batch 数据上操作,其实现过程可以清晰地分为两步:标准化(Standardization) 和 缩放平移(Scale and Shift)。

假设我们有一个 mini-batch $\mathcal{B} = {x_1, x_2, …, x_m\\}$,其中 $m$ 是 batch size。BN 对 batch 中的每一个特征维度(每一个通道,对于CNN)独立地进行以下计算。

第一步:标准化(Standardization)

这一步的目的是将数据转换为均值为0,方差为1的标准正态分布。

计算 mini-batch 的均值:

$$\mu_{\mathcal{B}} \leftarrow \frac{1}{m} \sum_{i=1}^{m} x_i$$

计算 mini-batch 的方差:

$$\sigma_{\mathcal{B}}^2 \leftarrow \frac{1}{m} \sum_{i=1}^{m} (x_i – \mu_{\mathcal{B}})^2$$

对每个样本进行归一化:

$$\hat{x}_i \leftarrow \frac{x_i – \mu_{\mathcal{B}}}{\sqrt{\sigma_{\mathcal{B}}^2 + \epsilon}}$$

$\hat{x}_i$ 是标准化后的值。

$\epsilon$ 是一个极小的常数(例如 $10^{-5}$),是为了防止分母为零,增加数值稳定性。

第二步:缩放与平移(Scale and Shift)

第一步的强制标准化有可能会改变原始数据本身有价值的分布特征。为了解决这个问题,BN 引入了两个可学习的参数 $\gamma$(缩放因子)和 $\beta$(平移因子),让网络自己去决定归一化的程度。

仿射变换(缩放和平移):

$$y_i \leftarrow \gamma \hat{x}_i + \beta \equiv \text{BN}_{\gamma, \beta}(x_i)$$

$y_i$ 是 BatchNorm 层的最终输出。

如果网络认为标准正态分布是最好的,它可以通过学习将 $\gamma$ 设置为 $\sqrt{\sigma_{\mathcal{B}}^2}$,将 $\beta$ 设置为 $\mu_{\mathcal{B}}$,从而完美地恢复原始分布。这赋予了网络强大的灵活性。

所以整个 Batch Normalization 操作可以总结为一个公式:

$$y_i = \gamma \cdot \frac{x_i – \mu_{\mathcal{B}}}{\sqrt{\sigma_{\mathcal{B}}^2 + \epsilon}} + \beta$$

但需要注意的是,BN 在训练和推理时的表现是不同的,具体来说:

  • 训练阶段(Training):
  1. 使用当前 mini-batch 的数据来计算均值 $\mu_{\mathcal{B}}$ 和方差 $\sigma_{\mathcal{B}}^2$。
  2. 同时,算法会持续计算整个训练过程中 $\mu_{\mathcal{B}}$ 和 $\sigma_{\mathcal{B}}^2$ 的移动平均(Moving Average),为推理阶段做准备。
  3. 移动平均均值: $\mu_{\text{mov}} \leftarrow \lambda \mu_{\text{mov}} + (1 – \lambda) \mu_{\mathcal{B}}$
  4. 移动平均方差: $\sigma_{\text{mov}}^2 \leftarrow \lambda \sigma_{\text{mov}}^2 + (1 – \lambda) \sigma_{\mathcal{B}}^2$
    ($\lambda$ 是动量参数,通常取0.9或0.99)
  • 推理阶段(Inference/Testing):
  1. 此时我们通常没有 batch 的概念(可能一次只预测一个样本),或者 batch 的统计量不稳定。
  2. 因此,不再使用当前 batch 的统计量,而是直接使用训练阶段最终得到的移动平均 $\mu_{\text{mov}}$ 和 $\sigma_{\text{mov}}^2$。

此时推理时的计算变为:

$$y_i = \gamma \cdot \frac{x_i – \mu_{\text{mov}}}{\sqrt{\sigma_{\text{mov}}^2 + \epsilon}} + \beta$$

这样,通过训练和推理时的割裂,BN 保证了输出的稳定性和确定性。


ResNet

我觉得 ResNet 与前面我们提及的网络有着本质上的区别。如果说我们想要神经网络拟合的目标函数是 $H(x)$ ,那么 VGG 或者 GoogLeNet 的行为其实是让一层或者多层网络层直接学习我们期望的底层映射 $H(x)$ ,但 ResNet 不同,它的网络层的学习目标是残差映射(Residual Mapping) $ H(x)= F(x) + x $ 中的 $ F(x) $.

这就相当于什么呢,如果我们一顿饭要吃 $H(X)$ 的量才能吃饱,而我们当前碗里有 $x$ 量的饭,那么接下来我们 要做的就是打上剩余 $F(X)$ 量的饭来喂饱自己。

也就是说,我们可以将其视为是一种 “自调节机制”,具体来说,调节作用于两个方面:

  1. 对模型下一次输出的调节:在传统网络中,每一层都需要直接产出完整的、正确的 H(x),这是一个“绝对目标”。对于深层网络,这个目标可能过于困难。而在ResNet中,学习目标变成了 “相对于输入x,需要做出多少改变(F(x))”。这是一个“相对目标”或“增量目标”。
    • 如果当前的输出 x 已经是理想状态(即 H(x) = x),那么网络只需要将残差映射 F(x) 轻松地学习为0即可。F(x) = 0 -> H(x) = x + 0 = x
    • 如果需要对 x 进行修改才能得到理想输出,网络就学习一个非零的 F(x) 来施加这个必要的改变。
  2. 对训练过程的调节:跳跃连接(见下文)为梯度流动提供了一条“高速公路”或“捷径”,绕过了主路径上可能存在的梯度衰减层。因为梯度可以从损失函数直接、快速地流回浅层,确保所有层都能得到有效的训练信号。这就可以看作网络自动调节了反向传播的难度,确保了信息流的通畅,从而自我优化了训练过程。

在理论实现上,ResNet 的操作为:

  1. 前向传播
    • 输入 x 同时进入两个路径。
    • 主路径: x 经过一系列权重层(通常是两个3×3卷积层),得到输出 $F(x)$。
    • 快捷路径: x 本身直接传递(如果维度不变),或通过一个 $1\times 1$ 卷积进行投影以匹配维度(如果维度变化)。
    • 最终输出: 将两条路径的输出逐元素相加 $\text{out} = F(x) + x$。
    • 相加后的结果再通过一个ReLU激活函数。
  2. 反向传播:梯度通过快捷连接毫无衰减地直接传回更早的层。

这彻底解决了极深网络中的梯度消失问题,使得训练成百上千层的网络成为可能。

在模型构建中,我们主要依靠残差块进行实现,一个残差块的实现包括:

  • 保存输入: identity = x
  • 主路径计算: 通过2层(BasicBlock)或3层(Bottleneck)卷积层计算残差 F(x)。
  • 快捷路径计算: 判断是否需要1×1卷积来调整 x 的维度以匹配主路径输出。
  • 相加: out = F(x) + shortcut(x)
  • 激活: out = ReLU(out)

流程大概是这样:

                [输入 x]
                   │
    ┍───────────────────────────────────┐
    │                                   │
[1x1 Conv降维]                      [快捷路径]
[BatchNorm]                        [1x1 Conv]
  [ReLU]                                │
[3x3 Conv]                              │
[BatchNorm]                             │
  [ReLU]                                │
[1x1Conv升维]                            │
[BatchNorm]                             │
    │                                   │
    └─────────────[相加] ───────────────┘
                    │
                [ReLU激活]
                    │
                [输出 out]

可以观察到实现中有一些细节:

  1. Batch Normalization (BN)
    • 每个卷积层后都立即跟随一个BN层,这是ResNet原文的标准配置。BN极大地稳定了深度网络的训练过程。
    • 注意: ReLU激活函数是在BN之后使用的。
  2. 快捷路径的处理
    • 恒等映射: 当维度不变时,self.shortcut 是一个空序列,直接传递输入 x
    • 投影映射: 当需要改变维度或下采样时,self.shortcut 是一个1×1卷积层(+BN),用于将输入 x 投影到与主路径输出相同的维度,以便能进行加法操作。
  3. ReLU的位置
    • ReLU是在加法操作之后进行的。如果放在加法之前,会破坏残差学习的效果。
  4. 下采样
    • 通常在每个Stage的第一个残差块中进行。
    • 主路径: 通过3×3卷积的 stride=2 来实现下采样。
    • 快捷路径: 通过1×1卷积的 stride=2 来同时实现下采样和通道数调整。

实现

首先构建残差块。由论文的论述,对于残差块,有两种类型,分别针对前后通道数发生/不发生改变

  • 无通道拓展类,常用于ResNet-18/34
class BasicBlock(nn.Module):
    """用于ResNet-18/34的基础残差块,无通道拓展"""
    expansion = 1

    def __init__(self, in_channels, out_channels, stride=1):
        super(BasicBlock, self).__init__()
        # 主路径
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels * self.expansion)

        # 快捷路径
        self.shortcut = nn.Sequential()
        # 如果步长不为1(需要下采样)或者输入输出通道数不匹配,就需要用1x1卷积进行投影
        if stride != 1 or in_channels != out_channels * self.expansion:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels * self.expansion, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(out_channels * self.expansion)
            )

    def forward(self, x):
        identity = self.shortcut(x) # 先计算快捷路径,更清晰

        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out)) # 注意:这里主路径最后是BN,没有ReLU

        out += identity # 主路径输出 + 快捷路径输出
        out = F.relu(out) # 相加后再激活

        return out
  • 有通道拓展,针对ResNet-50/101/152的深层网络
class Bottleneck(nn.Module):
    """用于ResNet-50/101/152的瓶颈残差块"""
    expansion = 4

    def __init__(self, in_channels, out_channels, stride=1):
        super(Bottleneck, self).__init__()
        # 主路径
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        
        self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False)
        self.bn3 = nn.BatchNorm2d(out_channels * self.expansion)

        # 快捷路径
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels * self.expansion:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels * self.expansion, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(out_channels * self.expansion)
            )

    def forward(self, x):
        identity = self.shortcut(x)

        out = F.relu(self.bn1(self.conv1(x)))
        out = F.relu(self.bn2(self.conv2(out)))
        out = self.bn3(self.conv3(out)) # 注意:这里主路径最后是BN,没有ReLU

        out += identity
        out = F.relu(out)

        return out

而残差网络的构建,则基于多个残差块进行组合,此处考虑解耦,提供封装类函数 _make_layer:

    def _make_layer(self, block, out_channels, num_blocks, stride):
        """构建一个包含多个残差块的阶段"""
        strides = [stride] + [1] * (num_blocks - 1) # 第一个块可能下采样,后续块保持尺寸
        layers = []
        for stride in strides:
            layers.append(block(self.in_channels, out_channels, stride))
            self.in_channels = out_channels * block.expansion # 更新下一块的输入通道数
        return nn.Sequential(*layers)

所以 ResNet 整体的构建:

class ResNet(nn.Module):
    def __init__(self, block, num_blocks, num_classes=10):
        super(ResNet, self).__init__()
        self.in_channels = 64

        self.conv1 = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True)
        )
        # 构建四个残差阶段 (stage)
        # 每个阶段通过_make_layer函数创建,第一个块的stride可能为2(用于下采样)
        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)

        # 分类器
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) # 全局平均池化,输出尺寸为(1,1)
        self.fc = nn.Linear(512 * block.expansion, num_classes)

    def _make_layer(self, block, out_channels, num_blocks, stride):
        """构建一个包含多个残差块的阶段"""
        strides = [stride] + [1] * (num_blocks - 1) # 第一个块可能下采样,后续块保持尺寸
        layers = []
        for stride in strides:
            layers.append(block(self.in_channels, out_channels, stride))
            self.in_channels = out_channels * block.expansion # 更新下一块的输入通道数
        return nn.Sequential(*layers)

    def forward(self, x):
        out = self.conv1(x)

        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)

        out = self.avgpool(out)
        out = out.view(out.size(0), -1) # 展平
        out = self.fc(out)
        return out

DenseNet

最后一节!

我们先来考虑一个函数:

$$f(x + f(x + f(\dots)))$$

为什么提及这个呢,这其实是我突然想到的(划掉),我们来想想看这玩意和 ResNet 和我们将要提起的 DenseNet的结构上有没有啥相似之处。

ResNet 采用的是“跳跃连接”。第 L 层的输入来自第 L-1 层的输出,再加上更早的某一层(如第 L-2, L-3 层) 的输出。其信息流是:$x_L = F(x_{L-1}) + x_{L-3}$。这是一种跨层相加

更严谨一点:我们设设第 $l$ 层的输出为 $x_l$。那么 ResNet 的前向传播可以定义为:

$$x_l = x_{l-1} + F_l(x_{l-1})$$

展开后:

$$\begin{cases}x_1 = x_0 + F_1(x_0)\\x_2 = x_1 + F_2(x_1) = [x_0 + F_1(x_0)] + F_2(x_1)\\x_3 = x_2 + F_3(x_2) = [x_0 + F_1(x_0) + F_2(x_1)] + F_3(x_2)$

$…$

$x_L = x_0 + \sum_{i=1}^{L} F_i(x_{i-1})$

而对于我们将要实现的 DenseNet ,其采用的是“密集连接”。第 L 层的输入来自所有前面层(第 0, 1, 2, …, L-1 层) 输出的拼接。其信息流是:$x_L = F([x_0, x_1, x_2, …, x_{L-1}])$。[,]表示跨层拼接。

为什么要提这个呢,其实想表达的是,ResNet 和 DenseNet 的核心是“特征复用”思想

  • $x$: 代表最原始的输入特征。
  • 第一个 $f(…)$: 代表第一层网络,它从 x 中提取了特征 f(x)。
  • $x \text{op} f(x)$: 代表第二层网络的输入,它同时看到了原始特征 x 和第一层提取的特征 f(x)。
  • $f(x \text{op} f(x))$: 第二层基于这些信息提取了更深层次的特征。

做个类比,那就是:

特性ResNetDenseNet
连接方式跳跃连接(Sum)密集连接(Concatenation)
信息流动x_L = F(x_{L-1}) + x_{L-n}x_L = F([x_0, x_1, ..., x_{L-1}])
特征处理特征融合(加法)特征收集(拼接)
比喻“接力赛”:下一棒接过上一棒的成果,并可能从更早的某棒获得直接鼓励。“团队协作”:每个新成员(层)的工作都建立在所有前辈(前面所有层)的工作成果之上。
优势解决梯度消失,训练极深网络。极强的特征复用,大幅减少参数量,缓解梯度消失效果更好。

核心特征讲得差不多了,现在来考虑实现:

实现

DenseNet 的核心是 Dense Block 和 Transition Layer ,而在一个 Dense Block 内部,任意两层之间均有直接连接。 Transition Layer 则是在 Dense Block 之间进行特征降维以维持输入输出的平衡。

单层的 Dense Block 通常由 $BN Layer \rightarrow ReLU \rightarrow 1\times 1 Conv$ 组成,所以单层的封装类实现起来很简单:

class _DenseLayer(nn.Module):
    """ 单个 Dense 层 """
    def __init__(self, num_input_features, growth_rate):
        super().__init__()
        # 瓶颈层结构:BN - ReLU - 1x1 Conv
        self.bn1 = nn.BatchNorm2d(num_input_features)
        self.conv1 = nn.Conv2d(num_input_features, 4*growth_rate, kernel_size=1, bias=False)
        self.bn2 = nn.BatchNorm2d(4*growth_rate)
        self.conv2 = nn.Conv2d(4*growth_rate, growth_rate, kernel_size=3, padding=1, bias=False)

    def forward(self, pre_features):
        cat_features = torch.cat(pre_features, 1)

        out = self.conv1(F.relu(self.bn1(cat_features)))
        out = self.conv2(F.relu(self.bn2(out)))

        return out

那么在单层基础上,我们考虑 Dense 的完整实现, Dense 的实现需要考虑一个问题:

“我们如何将前层的结果接入到本层?”

这本质上是输入通道的拓展,我们在每一层的最后实际上要进行一个 contact 操作,所以我们考虑维护一个 grow_rate 来对各层将要拓展到的特征数进行扩展。

class DenseBlock(nn.Module):
    """ 完整的 Dense 块 """
    def __init__(self, num_layers, num_input_features, growth_rate):
        super().__init__()
        self.layers = nn.ModuleList()
        for i in range(num_layers):
            layer = _DenseLayer(
                num_input_features + i*growth_rate,     # 输入通道随层数增加
                growth_rate)
            self.layers.append(layer)

    def forward(self, init_features):
        features = [init_features]
        for layer in self.layers:
            new_features = layer(features)       # 逐层更新特征图
            features.append(new_features)
        return torch.cat(features, 1)       # 拼接特征图

另外是过渡层,由上述的阐述可知其实现可为:

class TransitionLayer(nn.Module):
    """ 过渡层 """
    def __init__(self, num_input_features):
        super().__init__()
        self.bn = nn.BatchNorm2d(num_input_features)
        self.conv = nn.Conv2d(num_input_features, num_input_features//2, kernel_size=1, bias=False)
        self.pool = nn.AvgPool2d(kernel_size=2, stride=2)    # 平均池化

    def forward(self, x):
        out = self.conv(F.relu(self.bn(x)))
        out = self.pool(out)
        return out

所以综合一下, DenseNet 类的实现:

class DenseNet(nn.Module):
    """ DenseNet 网络 """
    def __init__(self, num_init_features=24, growth_rate=12, block_config=[16, 16, 16], num_classes=10):
        super().__init__()
        # 此处修改了初始层:原论文卷积核尺寸过大,此处使用kernel_size=3, stride=1, padding=1,移除MaxPool,适应CIFAR-10的32x32输入
        self.features = nn.Sequential(
            nn.Conv2d(3, num_init_features, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(num_init_features),
            nn.ReLU(inplace=True)
        )

        num_features = num_init_features
        for i, num_layers in enumerate(block_config):
            block = DenseBlock(num_layers=num_layers, num_input_features=num_features, growth_rate=growth_rate)
            self.features.add_module('denseblock%d' % (i+1), block)
            num_features = num_features + num_layers * growth_rate
            # 在最后一个块之前添加过渡层
            if i != len(block_config)-1:
                trans = TransitionLayer(num_input_features=num_features)
                self.features.add_module('transition%d' % (i+1), trans)
                num_features = num_features // 2

        # 全局平均池化和分类器
        self.final_bn = nn.BatchNorm2d(num_features)  # 最终BatchNorm
        self.classifier = nn.Linear(num_features, num_classes)

    def forward(self, x):
        features = self.features(x)
        out = F.relu(self.final_bn(features), inplace=True)
        out = F.adaptive_avg_pool2d(out, (1, 1))
        out = torch.flatten(out, 1)
        out = self.classifier(out)
        return out

而在CFIAR – 10s-上的训练结果为:

完结撒花~

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注