随着我们设计越来越深的网络,深刻理解“新添加的层如何提升神经网络的性能”变得至关重要。更重要的是设计网络的能力,在这种网络中,添加层会使网络更具表现力, 为了取得质的突破,我们需要一些数学基础知识。
残差网络源自于一个思想——我们要不断地去加深我的神经网络,但加深一定会给你带来好处吗?不一定。
红色点为最优值,虽然 f 6 f_{6} f6更复杂了,但有可能它学偏了!
但是,如果我增加模型复杂度,但每一次,更复杂的模型是包含前面的小模型,所以我的新模型就能严格的比前面更大,所以模型效果至少不会变差。ResNet想的就是,我加更多的层,让你至少不至于变差,通常来说是变好的。
函数类
残差网络的核心思想:每个附加层都应该更容易地包含原始函数作为其元素之一。
残差块
假设我的 g ( x ) g(x) g(x)什么都不干,没有学到任何东西,那我的 f ( x ) f(x) f(x)至少还是可以学到原来的值 x x x的。 g ( x ) g(x) g(x)上任何能够发挥一点点作用的东西都能够把我们的整体模型变大一点。
+ x +x +x意思就是我的复杂模型是包含了我前面的小模型的。
ResNet沿用了VGG完整的 3 × 3 3\times 3 3×3卷积层设计。 残差块里首先有 2 2 2个有相同输出通道数的 3 × 3 3\times 3 3×3卷积层。 每个卷积层后接一个批量规范化层和ReLU激活函数。 然后我们通过跨层数据通路,跳过这 2 2 2个卷积运算,将输入直接加在最后的ReLU激活函数前。
这样的设计要求 2 2 2个卷积层的输出与输入形状一样,从而使它们可以相加。 如果想改变通道数,就需要引入一个额外的 1 × 1 1\times 1 1×1卷积层来将输入变换成需要的形状后再做相加运算。
残差块的实现如下:
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2lclass Residual(nn.Module): # 这是类的构造函数,负责初始化。它接收四个参数:# input_channels:输入的通道数。# num_channels:输出的通道数。# use_1x1conv:一个布尔值,决定是否使用一个1x1的卷积来改变维度或步长,默认为 False。# strides:卷积操作的步长,默认为1。def __init__(self, input_channels, num_channels,use_1x1conv=False, strides=1):super().__init__()self.conv1 = nn.Conv2d(input_channels, num_channels,kernel_size=3, padding=1, stride=strides)# 定义第一个卷积层 conv1,它是一个2D卷积层,接受 input_channels,输出 num_channels,# 卷积核大小为3x3,填充为1(为了保持数据的空间维度),步长为 strides。self.conv2 = nn.Conv2d(num_channels, num_channels,kernel_size=3, padding=1)# 定义第二个卷积层 conv2,配置类似于 conv1 但步长为1,用于进一步处理 conv1 的输出。if use_1x1conv:self.conv3 = nn.Conv2d(input_channels, num_channels,kernel_size=1, stride=strides)else:self.conv3 = None# 根据 use_1x1conv 的值决定是否创建第三个卷积层 conv3。# 这个层使用1x1的卷积核,可以改变输入的维度和/或调整步长,# 如果 use_1x1conv 为 False,则不创建这个层。self.bn1 = nn.BatchNorm2d(num_channels)self.bn2 = nn.BatchNorm2d(num_channels)# 定义两个批量归一化层,bn1 和 bn2,用于稳定和加速训练过程。def forward(self, X): # 定义网络的前向传播函数,X 是输入数据。Y = F.relu(self.bn1(self.conv1(X)))# 应用第一个卷积层 conv1,接着是批量归一化 bn1,最后是ReLU激活函数。结果存储在 Y 中。Y = self.bn2(self.conv2(Y))# Y 经过第二个卷积层 conv2 和第二个批量归一化层 bn2。if self.conv3:X = self.conv3(X)# 如果 conv3 存在,则对输入 X 应用这个层,可能用于维度匹配。Y += X # 将变换后的输入 X 加到 Y 上,实现残差连接。return F.relu(Y)# 最后应用ReLU激活函数并返回结果。# 这一步确保了网络的输出非负,同时加入了非线性,有助于处理更复杂的模式。
如 图7.6.3所示,此代码生成两种类型的网络:
- 一种是当
use_1x1conv=False
时,应用ReLU非线性函数之前,将输入添加到输出。 - 另一种是当
use_1x1conv=True
时,添加通过 1 × 1 1\times 1 1×1卷积调整通道和分辨率。
下面我们来查看输入和输出形状一致的情况。
blk = Residual(3,3)
# 创建了一个 Residual 块的实例,名为 blk。
# 这个残差块的输入和输出通道数都是3。
# 由于没有指定 use_1x1conv 和 strides 参数,它们默认为 False 和 1,
# 分别意味着不使用1x1卷积来改变维度或步长,且卷积操作的步长为1。
X = torch.rand(4, 3, 6, 6)
# 这行代码使用 torch.rand 函数生成一个随机张量 X,其形状为 (4, 3, 6, 6)。这个张量的维度意味着:
# 第一维度为4,表示批量大小(即有4个独立的数据样本)。
# 第二维度为3,表示每个样本有3个通道(例如RGB图像)。
# 第三和第四维度都是6,表示每个通道的空间维度(6x6像素)。
Y = blk(X)
# 通过在残差块 blk 中传递张量 X 来计算其输出 Y。
# 在残差块内部,数据 X 将通过两个卷积层、两个批量归一化层和ReLU激活函数进行处理,如果有需要,还可能通过一个1x1卷积来调整维度。最后,通过残差连接将输入加到输出上,然后再次应用ReLU激活函数。
Y.shape
我们也可以在增加输出通道数的同时,减半输出的高和宽。
blk = Residual(3,6, use_1x1conv=True, strides=2)
# 创建了一个 Residual 类的实例,名为 blk。与之前的示例相比,此处有以下不同的参数设置:
# input_channels=3:输入通道数为3。
# num_channels=6:输出通道数设置为6,即该残差块会将输出的通道数从3增加到6。
# use_1x1conv=True:这意味着将使用一个1x1的卷积层 conv3。这个卷积层不仅用于调整通道数量(从3到6),同时也会使用 strides 参数指定的步长。
# strides=2:在主卷积层 conv1 和1x1卷积层 conv3 中使用步长2,这将导致输出的空间尺寸(高度和宽度)减半。
blk(X).shape
ResNet模型
ResNet的前两层跟之前介绍的GoogLeNet中的一样: 在输出通道数为 64 64 64、步幅为 2 2 2的 7 × 7 7\times 7 7×7卷积层后,接步幅为 2 2 2的 3 × 3 3\times 3 3×3的最大池化层。 不同之处在于ResNet每个卷积层后增加了批量规范化层。
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))
GoogLeNet在后面接了 4 4 4个由Inception块组成的模块。 ResNet则使用 4 4 4个由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块。 第一个模块的通道数同输入通道数一致。 由于之前已经使用了步幅为 2 2 2的最大池化层,所以无须减小高和宽。 之后的每个模块在第一个残差块里将上一个模块的通道数翻倍,并将高和宽减半。
下面我们来实现这个模块。注意,我们对第一个模块做了特别处理。
# 这段代码定义了一个名为 resnet_block 的函数,用于创建一个残差块(或多个残差块)的列表,这是在构建深度残差网络(ResNet)时的典型用法
def resnet_block(input_channels, num_channels, num_residuals, first_block=False):
# 这是函数的定义行,其中 resnet_block 接受四个参数:
# input_channels:输入通道数。
# num_channels:每个残差块输出的通道数。
# num_residuals:在这个块中要创建的残差单元的数量。
# first_block:一个布尔值,默认为 False,用来指示这是否是网络的第一个块。这很重要,因为第一个块的处理通常与其他块有所不同,以适应网络输入特性。blk = []# 初始化一个空列表 blk,用于存储接下来创建的所有残差单元。for i in range(num_residuals):# 开始一个循环,迭代次数由 num_residuals 决定,即这个块中将创建的残差单元数量。if i == 0 and not first_block:# 在每次迭代中,首先检查当前是否是第一个残差单元 (i == 0) 并且这不是整个网络的第一个块 (not first_block)。
# 如果这两个条件同时满足,意味着需要对输入进行下采样,通常是通过改变通道数和减小空间维度来实现。blk.append(Residual(input_channels, num_channels,use_1x1conv=True, strides=2))# 如果满足上述条件,这行代码将创建一个新的 Residual 对象,这个对象使用1x1的卷积(use_1x1conv=True)和步长为2(strides=2),这样可以在第一个单元中改变通道数并减半空间维度,添加到列表 blk 中。else:blk.append(Residual(num_channels, num_channels))# 对于块中的其余残差单元,或者如果这是第一个块,这行代码创建一个标准的残差单元,其中输入和输出通道数相同,且不改变空间维度。这些单元也被添加到列表 blk 中。return blk# 函数返回列表 blk,其中包含了所有创建的残差单元。# 这个列表可以被整合到更大的网络结构中,作为构建复杂ResNet架构的一部分。# 总体上,resnet_block 函数允许灵活地创建具有或不具有下采样的残差块,这对于构建深度残差网络中不同的层级结构是非常有用的。
接着在ResNet加入所有残差块,这里每个模块使用2个残差块。
# 这几行代码演示了如何使用前面定义的 resnet_block 函数来构建深度残差网络(ResNet)的几个不同的块
# 这些块是按顺序连接起来的,每个块包含多个残差单元。让我们逐行解析:
b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True))
# resnet_block(64, 64, 2, first_block=True) 调用 resnet_block 函数,生成两个残差单元,用于处理64个输入通道和64个输出通道的数据。因为这是第一个块,first_block 设置为 True,这意味着即使这是第一个块,也不会进行下采样,步长和通道数都保持不变。
# * 操作符用于解包函数返回的列表,使其成为 nn.Sequential 构造函数的参数。
# b2 是一个 nn.Sequential 容器,按顺序包含了这些残差单元。
b3 = nn.Sequential(*resnet_block(64, 128, 2))
# resnet_block(64, 128, 2) 生成两个残差单元,其中第一个单元将使用1x1卷积和步长为2进行下采样,输入通道从64增加到128,输出也是128通道。
# 这里,第一个残差单元特别重要,因为它处理了通道数的增加和尺寸的减半,这对于逐渐增加网络的深度和学习更复杂的特征至关重要。
b4 = nn.Sequential(*resnet_block(128, 256, 2))
# resnet_block(128, 256, 2) 生成两个残差单元,首个单元将通道数从128增加到256,并且通过步长为2的1x1卷积下采样,减小空间尺寸。
b5 = nn.Sequential(*resnet_block(256, 512, 2))
# resnet_block(256, 512, 2) 在这个函数调用中,首个残差单元将输入通道从256增加到512,同时步长为2的1x1卷积再次下采样。
# b5 继续这一趋势,为最终层级的特征提供足够的抽象能力。# 这一系列的残差块构建是为了逐步深化网络,每个块通过增加通道数和减少尺寸来增加其能力处理更抽象的概念
# 这样的结构使得ResNet能够在处理深度神经网络时保持较好的训练效果,防止梯度消失问题。
最后,与GoogLeNet一样,在ResNet中加入全局平均池化层,以及全连接层输出。
net = nn.Sequential(b1, b2, b3, b4, b5,nn.AdaptiveAvgPool2d((1,1)),nn.Flatten(), nn.Linear(512, 10))
每个模块有4个卷积层(不包括恒等映射的 1 × 1 1\times 1 1×1卷积层)。 加上第一个 7 × 7 7\times 7 7×7卷积层和最后一个全连接层,共有18层。 因此,这种模型通常被称为ResNet-18。 通过配置不同的通道数和模块里的残差块数可以得到不同的ResNet模型,例如更深的含152层的ResNet-152。 虽然ResNet的主体架构跟GoogLeNet类似,但ResNet架构更简单,修改也更方便。这些因素都导致了ResNet迅速被广泛使用。 图7.6.4描述了完整的ResNet-18。
在训练ResNet之前,让我们观察一下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)
通道数加倍,高宽减半。
训练模型
同之前一样,我们在Fashion-MNIST数据集上训练ResNet。
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())
小结
-
学习嵌套函数(nested function)是训练神经网络的理想情况。在深层神经网络中,学习另一层作为恒等映射(identity function)较容易(尽管这是一个极端情况)。
-
残差映射可以更容易地学习同一函数,例如将权重层中的参数近似为零。
-
利用残差块(residual blocks)可以训练出一个有效的深层神经网络:输入可以通过层间的残余连接更快地向前传播。
-
残差网络(ResNet)对随后的深层神经网络设计产生了深远影响。残差连接,不仅在ResNet被使用,几乎现在所有新的网络都在使用残差连接。
为什么可以训练很深的网络?
因为我使用残差连接的思想,加深层数,它总是能包含我之前的网络。
“残差”的概念体现在什么地方呢?
f ( x ) = x + g ( x ) f(x)=x+g(x) f(x)=x+g(x)
我在fit这个函数的时候,假设这个 x x x是一个小模型的输出,那么首先说我会fit x x x里面的那个小模型,如果这个小模型fit差不多之后(底层train的差不多了),然后把 g ( x ) g(x) g(x)能够给我improve的那些东西,那些残差。
假设我要训练这样的曲线
我的一个做法是我先去训练一个平滑的东西(蓝色线),剩下的那些误差,layer2在layer1的基础上叠加做出来的效果。
ResNet先会训练一些基础的比较下层的。
《在线胡讲深度残差网络ResNet》笔记
对神经网络来说,隐藏层越多,模型越深,它应该效果更好才对?
理论上来说,堆叠神经网络的层数应该可以提升模型的精度,但是现实中真的是这样吗?
实验数据证明,一开始随着模型层数的增加,模型的精度会达到饱和,如果再增加网络的层数的话,它就会开始退化。
从实验数据可以看到,在训练轮次相同的情况下,56层的网络误差居然比20层的网络误差还要高。这个现象是由于深层网络训练难度太高导致的,我们给这个现象起名叫退化,这个现象经常被和过拟合搞混淆,但是过拟合其实是会让训练误差变得越来越小,而测试误差变高,退化则是让训练误差和测试误差都变高。
与此同时,深度神经网络还有一个难题,我们以一个最简单的神经网络为例:
在反向传播的过程中,我们可以推导出每一层的误差项,都依赖于它后面一层的误差项,在层数很多的情况下,我们很难保证每一层的权值和梯度的大小,举一个最经典的例子,如果我们使用sigmoid函数作为激活函数,它的导数的最大值只有0.25,梯度在传播的过程中越来越趋近于0,误差就没有办法传播到底层的参数了,这就是梯度消失。虽然批量规范化和层规范化可以缓解梯度消失的问题,但是我们有没有什么办法,既可以解决退化的问题,又能顺便给梯度开个后门呢?
我们先来想一想,为什么深层神经网络会出现退化的问题呢?
假设我们的神经网络在层数为 l l l的时候达到了最优的效果,这个时候我们把这个网络构建的更深,那么第 l l l层之后的每一层,理论上来说应该是个恒等映射,但是呢拟合一个恒等映射是很难的,所以我们可不可以换个思路?
如果我们用 H ( x ) H(x) H(x)来表示我们想让这个神经网络学到的映射,用 x x x来表示我们已经学到的内容,那么现在我们可不可以让我们的神经网络去拟合 H ( x ) H(x) H(x)和 x x x之间的残差呢?
也就是说,如果我们选择优化的不是 H ( x ) H(x) H(x),而是把 H ( x ) H(x) H(x)拆分为 x x x和 H ( x ) − x H(x)-x H(x)−x两个部分,我们选择去优化 H ( x ) − x H(x)-x H(x)−x,我们给这个残差取名叫 F ( x ) F(x) F(x), F ( x ) F(x) F(x)通常包含着卷积和激活之类的操作,我们把 F ( x ) F(x) F(x)和 x x x相加之后,仍然能得到我们想要的 H ( x ) H(x) H(x),我们把这样从输入额外连一条线到输出来表示,将输入输出相加的操作叫做skip connection,如果让 F ( x ) F(x) F(x)趋近于0,那么就相当于我们构造了一个恒等映射。
那为什么这种方法可以有效解决退化和梯度消失的问题呢?
我们假设第 l l l层的输入是 X l X_{l} Xl,那它这一层的输出就是 F X l + X l F_{X_{l}}+X_{l} FXl+Xl,同时,它也是第 l + 1 l+1 l+1层的输入 X l + 1 X_{l+1} Xl+1,那我们现在可以根据这个规律,去推导一下第 l + 2 l+2 l+2层的输入,到了这一步我们是不是就不难发现,我们可以得到任意一个更深的层数 L L L和一个更浅的层数 l l l之间的关系的表达式。首先,是任意一层的输入 x L x_{L} xL,可以写成比他更浅的任意一层的输入 x l x_{l} xl和两层之间的所有残差的和,我们这样是不是可以初步推测出,和普通的神经网络相比,残差网络在前向传播时候,可以让任意低层的信息更容易传播到高层,根据这个式子,我们也可以推导出损失函数关于 X L X_{L} XL的梯度,我们从这里可以发现,损失函数关于 X L X_{L} XL的梯度,可以直接传播到任意一个更浅的层,后面的这一坨不可能一直等于 − 1 -1 −1,也就是说,残差网络中不会出现梯度消失的问题。作者何恺明的观点是这样的属性让残差网络无论是正向传播还是反向传播,都可以将信号直接传播到任意一层。
在没有skip connection的情况下,网络越深,损失函数的非凸性就越强,在求梯度的时候,我们还是更喜欢凸函数,函数的非凸性越强,更难找到全局最优解。