跳到主要内容

残差网络(ResNet)

残差网络(ResNet)是一种深度卷积神经网络结构,由微软研究院的Kaiming He等人在2015年提出。ResNet通过引入“残差块”(residual block)来解决深度神经网络中的梯度消失和梯度爆炸问题,从而使网络能够被有效地训练。

首先先明确一下几个深度学习方面的问题?

网络的深度为什么重要?

我们知道,在 CNN 网络中,我们输入的是图片的矩阵,也是最基本的特征,整个 CNN 网络就是一个信息提取的过程,从底层的特征逐渐抽取到高度抽象的特征,网络的层数越多也就意味这能够提取到的不同级别的抽象特征更加丰富,并且越深的网络提取的特征越抽象,就越具有语义信息。

为什么不能简单的增加网络层数?

对于传统的 CNN 网络,简单的增加网络的深度,容易导致梯度消失和爆炸。针对梯度消失和爆炸的解决方法一般是正则初始化(normalized initialization)和中间的正则化层(intermediate normalization layers),但是这会导致另一个问题,退化问题,随着网络层数的增加,在训练集上的准确率却饱和甚至下降了。这个和过拟合不一样,因为过拟合在训练集上的表现会更加出色。

按照常理更深层的网络结构的解空间是包括浅层的网络结构的解空间的,也就是说深层的网络结构能够得到更优的解,性能会比浅层网络更佳。但是实际上并非如此,深层网络无论从训练误差或是测试误差来看,都有可能比浅层误差更差,这也证明了并非是由于过拟合的原因。导致这个原因可能是因为随机梯度下降的策略,往往解到的并不是全局最优解,而是局部最优解,由于深层网络的结构更加复杂,所以梯度下降算法得到局部最优解的可能性就会更大。

提示

SGD 在每次迭代时只使用一个或一小批样本来估计梯度,而不是使用整个数据集。这意味着它可能有多个局部最小值、鞍点和其他复杂的结构。在这种情况下,任何基于梯度的优化算法(包括SGD和GD)都可能会收敛到局部最小值,而不是全局最小值。同时由于 SGD 在每次迭代时只使用一小批样本来估计梯度,这可能会导致梯度估计带有噪声。这种噪声梯度也可能使其陷入其他局部最小值。

如何解决退化问题

这里提供了一种想法:既然深层网络相比于浅层网络具有退化问题,那么是否可以保留深层网络的深度,又可以有浅层网络的优势去避免退化问题呢?

为了解决这个问题,ResNet提出了一种新的网络结构,即残差块。

残差块的核心思想是在网络中引入跳跃连接(skip connection 或 shortcut connection)。这些连接允许输入直接“跳过”一层或多层,并与更深层的输出相加。这种设计鼓励网络学习恒等映射,从而避免了梯度消失和梯度爆炸问题。

如下图,把某层输入跳跃连接到下一层乃至更深层的激活层之前,同本层输出一起经过激活函数输出。

普通网络(Plain Network)

普通网络是指没有跳跃连接的网络,即每一层的输入只连接到下一层的输入,如下图所示:

当所训练的网络足够深时训练误差将不再减少反而会增加。这是因为随着网络深度的增加,网络的训练误差会越来越大,这就是退化问题。

残差网络(Residual Network)

在深度网络中,梯度可能会在反向传播过程中变得非常小,导致网络难以训练。跳跃连接为梯度提供了一个直接的路径,使其可以更容易地流回网络的早期层。这样可以避免因为梯度消失而导致的网络退化问题。

提示

下图的残差网络由五个残差块组成,该残差网络只跳跃了一层,还可跳跃多层。

上面这种直接跳过某层的连接方式,称为恒等映射,恒等映射是一个简单的概念,指的是一个函数将其输入直接映射到相同的输出。换句话说,对于所有的输入,输出都是完全相同的。

提示

数学上,恒等映射可以表示为:

f(x)=xf(x) = x

其中,ff 是恒等函数,对于所有的 xxf(x)f(x) 的值都等于 xx

那要怎么实现恒等映射呢?下面就引出了残差块的概念,残差块的定义是:

H(x)=F(x)+x=>F(x)=H(x)xH(x) = F(x) + x => F(x) = H(x) - x

所以可以发现只要 F(x)=0F(x) = 0 就构成了一个恒等映射 H(x)=xH(x) = x ,这里 F(x)F(x) 为残差。

PyTorch 中的实现

以下是一个简单的残差块(Residual Block)的实现:

import torch.nn as nn

class ResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super(ResidualBlock, self).__init__()

# 主要路径(Main Path)
# 这是残差块的主要部分,它包含两个卷积层,每个卷积层后面都有一个批量归一化层。
self.main_path = nn.Sequential(
# 第一个卷积层
nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False),
# 批量归一化层
nn.BatchNorm2d(out_channels),
# ReLU激活函数
nn.ReLU(inplace=True),
# 第二个卷积层
nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False),
# 另一个批量归一化层
nn.BatchNorm2d(out_channels)
)

# 跳跃连接(Shortcut Connection)
# 这是残差块的跳跃连接部分。它的目的是将输入直接传递到输出,以便与主要路径的输出相加。
self.shortcut = nn.Sequential()
# 如果步长不为1或输入和输出的通道数不同,我们需要调整输入的维度以匹配主要路径的输出。
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
# 使用1x1的卷积来调整输入的维度
nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False),
# 批量归一化层
nn.BatchNorm2d(out_channels)
)

def forward(self, x):
# 主要路径的输出
out_main = self.main_path(x)
# 跳跃连接的输出
out_shortcut = self.shortcut(x)
# 将主要路径和跳跃连接的输出相加
out = out_main + out_shortcut
# 使用ReLU激活函数
out = nn.ReLU(inplace=True)(out)
return out

这个 ResidualBlock 类定义了一个残差块。主要路径(main_path)包含两个卷积层,每个卷积层后面都有一个批量归一化层(BatchNorm2d)。跳跃连接(shortcut)是一个恒等映射

提示

如果输入和输出的维度不匹配(由于步长不为1或通道数不同),则会添加一个 1x1 的卷积层来调整维度。

forward 方法中,主要路径的输出和跳跃连接的输出被相加,然后通过ReLU激活函数。

这就是一个典型的残差块的实现。在实际的 ResNet 中,会有多个这样的残差块堆叠在一起,形成一个深度神经网络。

ResNet 模型

ResNet(Residual Network)是由Kaiming He等人在2016年提出的,它通过引入残差块来解决深度网络中的退化问题。ResNet模型有多种版本,其中ResNet-18、ResNet-34、ResNet-50、ResNet-101和ResNet-152是最常见的几种。

ResNet-18是其中最浅的版本,具有18层(不包括池化和全连接层)。

以下是一个PyTorch中的ResNet-18模型定义:

import torch.nn as nn

class BasicBlock(nn.Module):
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, kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels) # 批量归一化

# 跳跃连接
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != self.expansion * out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, self.expansion * out_channels, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(self.expansion * out_channels)
)

def forward(self, x):
out = nn.ReLU()(self.bn1(self.conv1(x))) # 第一个卷积层后的激活
out = self.bn2(self.conv2(out)) # 第二个卷积层
out += self.shortcut(x) # 添加跳跃连接的输出
out = nn.ReLU()(out) # 激活函数
return out

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

# 初始卷积层
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
self.bn1 = nn.BatchNorm2d(64)

# 构建残差块
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.avg_pool = nn.AdaptiveAvgPool2d((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 = nn.ReLU()(self.bn1(self.conv1(x))) # 初始卷积层后的激活
out = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)(out) # 最大池化
out = self.layer1(out) # 第一组残差块
out = self.layer2(out) # 第二组残差块
out = self.layer3(out) # 第三组残差块
out = self.layer4(out) # 第四组残差块
out = self.avg_pool(out) # 平均池化
out = out.view(out.size(0), -1) # 展平
out = self.fc(out) # 全连接层
return out

def ResNet18():
# 返回一个ResNet-18模型实例
return ResNet(BasicBlock, [2, 2, 2, 2])

这里,BasicBlock定义了ResNet-18中使用的基本残差块。ResNet类是整个网络的定义,其中_make_layer函数用于创建连续的残差块。最后,ResNet18函数返回一个ResNet-18模型实例。

References