现代卷积神经网络6-残差网络(ResNet)

586 阅读9分钟

在深度学习的领域中,神经网络的层数越多,理论上网络的表达能力越强,能够学习更加复杂的模式。然而,当网络深度增加时,训练过程往往会遇到一些问题。最常见的问题就是 梯度消失 或者 梯度爆炸,这会导致网络无法有效地学习和优化。

残差网络(ResNet) 的提出,恰好解决了这个问题。ResNet的核心创新是 通过引入残差连接,让网络在深度增加的同时,训练变得更加高效且稳定。2015年,ResNet在ImageNet比赛中大放异彩,迅速成为深度学习领域的标杆。

接下来,我们将详细介绍ResNet的基本原理和实现方式。

一、残差网络的动机

首先,让我们想象一个简单的神经网络训练任务。假设我们的目标是学习一个函数g(x)g(x),该函数能够将输入xx映射到目标输出yy。理想情况下,我们希望网络能够直接学到这个函数g(x)g(x),但实际上,大多数时候我们无法直接训练到理想函数g(x)g(x)

为了更容易训练,我们可以将问题转化为学习一个残差映射。这个残差映射F(x)F(x)就是我们希望网络学到的内容,它通过对原始目标函数g(x)g(x)进行调整,帮助网络更容易地进行优化。换句话说,网络不再直接学习目标函数g(x)g(x),而是学习如何修正已有的函数h(x)h(x),使其更接近g(x)g(x)

数学表达为:𝐹(𝑥)=𝑔(𝑥)h(𝑥)𝐹(𝑥) = 𝑔(𝑥) − ℎ(𝑥)

其中,h(x)h(x)是我们当前网络学到的映射,g(x)g(x)是目标映射,F(x)F(x)是残差映射。

恒等映射的关键

最重要的一点是,残差网络(ResNet)让网络中的新增层可以选择不做任何改变,即它们可以“做恒等映射”——也就是说,输入和输出完全一样。想象一下,如果网络中某一层的功能就是保持不变(恒等映射),那么这层就没有增加额外的计算负担。这样,如果新加的层没有帮助,网络还是能够正常运行,不会变得更难训练。通过这种设计,ResNet避免了深层网络训练时常见的“越来越难优化”的问题。

二、残差块

为了实现残差映射,ResNet提出了 残差块(Residual Block) 的概念。一个残差块由两部分组成:

  • 常规卷积层:这些卷积层负责对输入数据进行处理。
  • 跳跃连接(Skip Connection):输入数据直接跳过卷积层,与卷积层的输出相加。

这样,输入数据和输出数据的差异(即残差)会被直接传递给后续层,使得梯度能够在训练过程中更容易传递,避免了梯度消失的问题。

下图展示了残差块的结构:

residual-block.svg

在上图中,左侧为传统的卷积神经网络结构,右侧为引入了跳跃连接的残差块。通过这种设计,残差块能够让网络更加容易地学习复杂的函数,同时防止了层数过多导致的问题。

代码实现

以下是一个简单的PyTorch实现:

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


class Residual(nn.Module):
    def __init__(self, input_channels, num_channels, use_1x1conv=False, strides=1):
        # 初始化Residual块
        # input_channels: 输入的通道数
        # num_channels: 输出的通道数
        # use_1x1conv: 是否使用1x1卷积来调整输入的形状
        # strides: 卷积步幅,默认是1
        super().__init__()
        # 第一个卷积层,进行特征提取,保持输入大小
        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卷积
        if use_1x1conv:
            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)

    def forward(self, X):
        """前向传播函数,定义数据如何通过该残差块流动"""

        # 经过第一个卷积层,批量归一化,ReLU激活
        Y = F.relu(self.bn1(self.conv1(X)))
        # 经过第二个卷积层和批量归一化
        Y = self.bn2(self.conv2(Y))
        if self.conv3:
            X = self.conv3(X)
        # 将输入与输出相加,实现残差连接
        Y += X
        return F.relu(Y)  # 最后通过ReLU激活函数

在这个Residual类中,我们通过两个卷积层(以及批量归一化和ReLU激活函数)来处理输入数据。最后,将输入数据XX与卷积后的输出YY进行相加,形成残差映射。

此代码生成两种类型的网络: 一种是当use_1x1conv=False时,应用ReLU非线性函数之前,将输入添加到输出。 另一种是当use_1x1conv=True时,添加通过 1×11 \times 1 卷积调整通道和分辨率。

resnet-block.svg

三、构建完整的ResNet模型

一个完整的ResNet模型由多个残差块组成,每个残差块都包含跳跃连接。ResNet模型的设计流程如下:

  • 卷积层:首先使用一个卷积层将输入图像的大小减半(步幅为2)。
  • 最大池化层:接着是一个最大池化层,用于进一步减小图像的空间维度。
  • 多个残差模块:然后将多个残差块堆叠起来,每个模块包含若干个残差块。每个模块的输出通道数逐渐加倍,同时空间分辨率逐渐减半。

resnet18.svg

3.1 构建ResNet的第一层

ResNet的前两层与之前我们提到的GoogLeNet类似:首先是一个卷积层,然后接一个池化层。在ResNet中,所有的卷积层后面都加上了批量归一化层(BatchNorm)。

b1 = nn.Sequential(
    nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),  # 卷积层
    nn.BatchNorm2d(64),  # 批量归一化
    nn.ReLU(),  # 激活函数
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1)  # 最大池化层
)

3.2 ResNet的残差模块

接下来,我们要使用残差模块来构建深层网络。每个模块都由多个残差块(Residual 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:  # 第一个块需要调整输入通道数
            blk.append(Residual(input_channels, num_channels, use_1x1conv=True, strides=2))
        else:
            blk.append(Residual(num_channels, num_channels))  # 之后的块不需要调整通道数
    return blk
  • resnet_block:这是一个辅助函数,用来创建一个残差模块。num_residuals表示该模块中的残差块的数量,first_block用于决定是否是第一个模块,若是第一个模块,则需要调整通道数。

  • if i == 0 and not first_block:

    • 如果是当前块的第一个残差块i == 0),并且不是第一个模块not first_block),则通过 1x1卷积 来调整输入的通道数,并且步幅为 2(strides=2),以减小特征图的尺寸。

    • 否则,对于后续的残差块,只需保持通道数一致,直接使用普通的残差块。

3.3 构建多个残差模块

接下来,我们将使用多个模块来构建ResNet。每个模块都会使用resnet_block来添加残差块。

b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True))  # 第一个模块
b3 = nn.Sequential(*resnet_block(64, 128, 2))  # 第二个模块
b4 = nn.Sequential(*resnet_block(128, 256, 2))  # 第三个模块
b5 = nn.Sequential(*resnet_block(256, 512, 2))  # 第四个模块

每个模块都由若干个残差块组成,通道数会随着模块的深入而增加。每个模块内部,第一层残差块使用1x1卷积进行通道数的变换,并且步幅为2,减小图像尺寸。之后的每个残差块都只进行通道数的变换,而步幅保持为1。

3.4 最后几层:全局平均池化和全连接层

在ResNet的最后,我们会用全局平均池化来将输出的特征图变成一个单一的向量,然后接一个全连接层输出分类结果。

net = nn.Sequential(
    b1, b2, b3, b4, b5,  # 前面几个模块
    nn.AdaptiveAvgPool2d((1, 1)),  # 全局平均池化,输出一个1x1的特征图
    nn.Flatten(),  # 将特征图展平为一维向量
    nn.Linear(512, 10)  # 全连接层,输出10个类别
)
  • 全局平均池化(AdaptiveAvgPool2d((1, 1))):将每个特征图的空间维度(高度和宽度)缩小为1,保留每个通道的平均值。这种做法有助于减少计算量。
  • Flatten:将输出的特征图展平成一维向量。
  • 全连接层:最后一层是一个全连接层,将展平的特征向量映射到10个类别上(比如用于分类任务)。

3.5 模型架构总结

通过组合这些模块,我们得到了一个完整的ResNet模型。我们可以通过下列代码查看模型的输出形状,来确保网络的每一层正确工作。

X = torch.rand(size=(1, 1, 224, 224))
for layer in net:
    X = layer(X)
    print(layer.__class__.__name__, 'output shape:\t', X.shape)

输出将显示每一层的输出形状,帮助我们理解图像如何通过网络流动并逐步改变尺寸。

Sequential output shape:	 torch.Size([1, 64, 56, 56])
Sequential output shape:	 torch.Size([1, 64, 56, 56])
Sequential output shape:	 torch.Size([1, 128, 28, 28])
Sequential output shape:	 torch.Size([1, 256, 14, 14])
Sequential output shape:	 torch.Size([1, 512, 7, 7])
AdaptiveAvgPool2d output shape:	 torch.Size([1, 512, 1, 1])
Flatten output shape:	 torch.Size([1, 512])
Linear output shape:	 torch.Size([1, 10])

四、训练ResNet模型

和之前一样,我们可以在Fashion-MNIST数据集上训练这个ResNet模型。以下是训练ResNet模型的代码:

import d2l

lr, num_epochs, batch_size = 0.05, 10, 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

屏幕截图 2025-01-26 121525.png 屏幕截图 2025-01-26 121645.png

五、小结

残差网络(ResNet)通过引入残差块(residual block),有效地解决了深层神经网络训练中的梯度消失问题。通过让每一层学习残差映射,ResNet不仅提高了训练的效率,还使得深层网络变得更加稳定。ResNet的成功为后来的深度学习模型提供了重要的设计思想,成为了现代深度学习框架中的基础组件。