在深度学习的领域中,神经网络的层数越多,理论上网络的表达能力越强,能够学习更加复杂的模式。然而,当网络深度增加时,训练过程往往会遇到一些问题。最常见的问题就是 梯度消失 或者 梯度爆炸,这会导致网络无法有效地学习和优化。
残差网络(ResNet) 的提出,恰好解决了这个问题。ResNet的核心创新是 通过引入残差连接,让网络在深度增加的同时,训练变得更加高效且稳定。2015年,ResNet在ImageNet比赛中大放异彩,迅速成为深度学习领域的标杆。
接下来,我们将详细介绍ResNet的基本原理和实现方式。
一、残差网络的动机
首先,让我们想象一个简单的神经网络训练任务。假设我们的目标是学习一个函数,该函数能够将输入映射到目标输出。理想情况下,我们希望网络能够直接学到这个函数,但实际上,大多数时候我们无法直接训练到理想函数。
为了更容易训练,我们可以将问题转化为学习一个残差映射。这个残差映射就是我们希望网络学到的内容,它通过对原始目标函数进行调整,帮助网络更容易地进行优化。换句话说,网络不再直接学习目标函数,而是学习如何修正已有的函数,使其更接近。
数学表达为:
其中,是我们当前网络学到的映射,是目标映射,是残差映射。
恒等映射的关键
最重要的一点是,残差网络(ResNet)让网络中的新增层可以选择不做任何改变,即它们可以“做恒等映射”——也就是说,输入和输出完全一样。想象一下,如果网络中某一层的功能就是保持不变(恒等映射),那么这层就没有增加额外的计算负担。这样,如果新加的层没有帮助,网络还是能够正常运行,不会变得更难训练。通过这种设计,ResNet避免了深层网络训练时常见的“越来越难优化”的问题。
二、残差块
为了实现残差映射,ResNet提出了 残差块(Residual Block) 的概念。一个残差块由两部分组成:
- 常规卷积层:这些卷积层负责对输入数据进行处理。
- 跳跃连接(Skip Connection):输入数据直接跳过卷积层,与卷积层的输出相加。
这样,输入数据和输出数据的差异(即残差)会被直接传递给后续层,使得梯度能够在训练过程中更容易传递,避免了梯度消失的问题。
下图展示了残差块的结构:
在上图中,左侧为传统的卷积神经网络结构,右侧为引入了跳跃连接的残差块。通过这种设计,残差块能够让网络更加容易地学习复杂的函数,同时防止了层数过多导致的问题。
代码实现
以下是一个简单的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激活函数)来处理输入数据。最后,将输入数据与卷积后的输出进行相加,形成残差映射。
此代码生成两种类型的网络: 一种是当use_1x1conv=False时,应用ReLU非线性函数之前,将输入添加到输出。 另一种是当use_1x1conv=True时,添加通过 卷积调整通道和分辨率。
三、构建完整的ResNet模型
一个完整的ResNet模型由多个残差块组成,每个残差块都包含跳跃连接。ResNet模型的设计流程如下:
- 卷积层:首先使用一个卷积层将输入图像的大小减半(步幅为2)。
- 最大池化层:接着是一个最大池化层,用于进一步减小图像的空间维度。
- 多个残差模块:然后将多个残差块堆叠起来,每个模块包含若干个残差块。每个模块的输出通道数逐渐加倍,同时空间分辨率逐渐减半。
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())
五、小结
残差网络(ResNet)通过引入残差块(residual block),有效地解决了深层神经网络训练中的梯度消失问题。通过让每一层学习残差映射,ResNet不仅提高了训练的效率,还使得深层网络变得更加稳定。ResNet的成功为后来的深度学习模型提供了重要的设计思想,成为了现代深度学习框架中的基础组件。