ResNet 残差网络 (乘法→加法的思想 - 残差连接是所有前沿模型的标配) + 代码实现 ——笔记2.16《动手学深度学习》
目录
前言
0. 乘法变加法的思想
1. 函数类
2. 残差块 (讲解+代码)
QA: 残差这个概念的体现?
3. ResNet模型 (代码+讲解)
补充:更多版本的ResNet
4. 训练模型
5. 小结
6. ResNet的两大卖点
6.1 加深模型可以退化为浅层模型
6.2 用加法解决梯度消失问题
前言
- 课程全部代码(pytorch版)已上传到附件
- 本章节为原书第7章(现代卷积),共分为7节,本篇是第6节:残差网络(ResNet)
- 本节的代码位置为:chapter_convolutional-modern/resnet.ipynb
- 本节的视频链接:29 残差网络 ResNet【动手学深度学习v2】_哔哩哔哩_bilibili
0. 乘法变加法的思想
- 现在所有新的网络,不管是Bert还是Transformer,Residual connection(残差连接)算是标配了,得到了广泛应用
- Residual connection 在一定程度上体现了“乘法变加法”的思想
- 比如Transformer在多头注意力机制之后,会将输入与注意力层的输出相加
- “让乘法变加法”
- 使用 “让乘法变加法” 来训练的模型,包括ResNet, LSTM, CNN
- 原先是用乘法进行线性变换:在深度神经网络中,每一层的输出是该层里的权重参数与输入的元素逐个相乘,然后求和
- 乘法容易导致梯度消失/爆炸(指数效应)
- ResNet的核心:层数很多的时候,使用加法而不是乘法 (来传递信号)
- LSTM:时序就是句子长度,例如把句子按照单词 (一个单词一个时序) 划分成一个一个的时序 (输入)
- 原始的时序神经网络是对每一个时序做乘法,句子太长就会梯度消失/爆炸
- LSTM将乘法变成加法
- 加法出问题的概率远低于乘法(关于为什么,可参考本文6. ResNet的两大卖点)
随着我们设计越来越深的网络,深刻理解“新添加的层如何提升神经网络的性能”变得至关重要。更重要的是设计网络的能力,在这种网络中,添加层会使网络更具表现力, 为了取得质的突破,我们需要一些数学基础知识。
1. 函数类
Non-nested function classes (非嵌套函数类)?; Nested function classes (嵌套函数类)√
:label:fig_functionclasses
因此,只有当较复杂的函数类包含较小的函数类时,我们才能确保提高它们的性能。 对于深度神经网络,如果我们能将新添加的层训练成恒等映射(identity function)𝑓(𝐱)=𝐱,新模型和原模型将同样有效。 同时,由于新模型可能得出更优的解来拟合训练数据集,因此添加层似乎更容易降低训练误差。
针对这一问题,何恺明等人提出了残差网络(ResNet) :cite:He.Zhang.Ren.ea.2016
。 它在2015年的ImageNet图像识别挑战赛夺魁,并深刻影响了后来的深度神经网络的设计。 残差网络的核心思想是:每个附加层都应该更容易地包含原始函数作为其元素之一。 于是,残差块(residual blocks)便诞生了,这个设计对如何建立深层神经网络产生了深远的影响。 凭借它,ResNet赢得了2015年ImageNet大规模视觉识别挑战赛。
2. 残差块 (讲解+代码)
让我们聚焦于神经网络局部:如图 :numref:fig_residual_block
所示,假设我们的原始输入为𝑥,而希望学出的理想映射为𝑓(𝐱)(作为 :numref:fig_residual_block
上方激活函数的输入)。 :numref:fig_residual_block
左图虚线框中的部分需要直接拟合出该映射𝑓(𝐱),而右图虚线框中的部分则需要拟合出残差映射𝑓(𝐱)−𝐱。 残差映射在现实中往往更容易优化。
- 拟合:就好比一个学生通过大量的练习题(数据)来摸索和总结出解题的方法和规律(拟合出的函数或模型),使用规律用输入得出正确的输出
- 映射𝑓(𝐱):在这里可理解为模型本身,用输入x得出正确(预测)的输出𝑓(𝐱),就是一个映射
QA: 残差这个概念的体现?
- 为什么是叫残差网络:
- 粗浅理解:因为训练损失的时候是块的输出+块的输入x = f(x)这个映射(模型),因此块的输出 = f(x) - 块的输入x,f(x) - x就是残差啦
- 深入理解:
问题18: 残差这个概念体现在什么地方? 就是因为 f(x) = x + g(x), 所以g(x)可以视为f(x)的残差吗?
李沐:因为x来自于上个块的输入,在底层(靠近数据),会先训练x(靠近底层),如图所示:
把模型类比为 f(x) = x (layer1) + g(x) (layer2),两个要训练的层的叠加
蓝色线:是一开始的模型 x (layer1),假设有152层,先训练前面18层,先学出个简单的模型
红色线:训练的后期再不断加入残差让模型更复杂,即在 x (layer1) 基础上叠加 g(x) (layer2) ,比如训练完152层,红色线和蓝色线的距离就是残差
以本节开头提到的恒等映射作为我们希望学出的理想映射𝑓(𝐱),我们只需将 :numref:fig_residual_block
中右图虚线框内上方的加权运算(如仿射)的权重和偏置参数设成0,那么𝑓(𝐱)即为恒等映射。 实际中,当理想映射𝑓(𝐱)极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。 :numref:fig_residual_block
右图是ResNet的基础架构--残差块(residual block)。 在残差块中,输入可通过跨层数据线路更快地向前传播。
:label:fig_residual_block
ResNet沿用了VGG完整的3×3卷积层设计。 残差块里首先有2个有相同输出通道数的3×3卷积层。 每个卷积层后接一个批量规范化层和ReLU激活函数。 然后我们通过跨层数据通路,跳过这2个卷积运算,将输入直接加在最后的ReLU激活函数前。 这样的设计要求2个卷积层的输出与输入形状一样,从而使它们可以相加。 如果想改变通道数,就需要引入一个额外的1×1卷积层来将输入变换成需要的形状后再做相加运算。 残差块的实现如下:
In [1]:
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
# 定义的是小残差块
class Residual(nn.Module): #@save # ※ 残差块的核心就在这儿 ※
def __init__(self, input_channels, num_channels, # use_1x1convzhi指要不要用1×1的卷积层
use_1x1conv=False, strides=1): # 如果想改变通道数,就用1×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)
if use_1x1conv: # 如果用上了1×1卷积
self.conv3 = nn.Conv2d(input_channels, num_channels,
kernel_size=1, stride=strides)
else: # 不改变通道数的情况
self.conv3 = None
self.bn1 = nn.BatchNorm2d(num_channels) # 残差块里的两个BN
self.bn2 = nn.BatchNorm2d(num_channels) # 都是2D卷积,输入输出的形状是(批量, 通道数, 高, 宽)
def forward(self, X):
Y = F.relu(self.bn1(self.conv1(X))) # 对着下方架构图看就很好理解
Y = self.bn2(self.conv2(Y))
if self.conv3: # 判断有没有1×1卷积改变通道数
X = self.conv3(X) # 如果有, 用1×1卷积改变一下输入X的通道数
# ※ 这里是残差连接(Residual Connection)的核心体现 ※
Y += X # 不管通道有没有改变, 都得践行一下残差连接的思想: 输出的残差 + X = f(X) ※
return F.relu(Y) # 再做一下relu
如 :numref:fig_resnet_block
所示,此代码生成两种类型的网络: 一种是当use_1x1conv=False
时,应用ReLU非线性函数之前,将输入添加到输出。 另一种是当use_1x1conv=True
时,添加通过1×1卷积调整通道和分辨率。
:label:fig_resnet_block
下面我们来查看[输入和输出形状一致]的情况。
In [2]:
blk = Residual(3,3) # 输入和输出形状一致=3的残差块
X = torch.rand(4, 3, 6, 6) # (批量, 通道数, 高, 宽)
Y = blk(X) # 输出的f(x)
Y.shape
Out[2]:
torch.Size([4, 3, 6, 6])
我们也可以在[增加输出通道数的同时,减半输出的高和宽]。
In [3]:
blk = Residual(3,6, use_1x1conv=True, strides=2) # strides=2 会让高宽减半
blk(X).shape
Out[3]:
torch.Size([4, 6, 3, 3])
3. ResNet模型 (代码+讲解)
ResNet的前两层跟之前介绍的GoogLeNet中的一样: 在输出通道数为64、步幅为2的7×7卷积层后,接步幅为2的3×3的最大汇聚层。 不同之处在于ResNet每个卷积层后增加了批量规范化层。
In [4]:
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3), # 输入通道维1是因为咱使用的
nn.BatchNorm2d(64), nn.ReLU(), # stride=2 高宽减半 # Fashion-MNIST数据集是单通道的
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)) # 高宽再减半
GoogLeNet在后面接了4个由Inception块组成的模块。 ResNet则使用4个由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块。 第一个模块的通道数同输入通道数一致。 由于之前已经使用了步幅为2的最大汇聚层,所以无须减小高和宽。 之后的每个模块在第一个残差块里将上一个模块的通道数翻倍,并将高和宽减半。
下面我们来实现这个模块。注意,我们对第一个模块做了特别处理。
In [5]:
def resnet_block(input_channels, num_channels, num_residuals,
first_block=False):
blk = [] # 存放块
for i in range(num_residuals): # num_residuals:块的数量
if i == 0 and not first_block:
blk.append(Residual(input_channels, num_channels,
use_1x1conv=True, strides=2)) # 看架构图,第1个块一般是高宽减半, 通道翻倍
else: # 看架构图,第1个块之后的块一般不改变高宽和通道数
blk.append(Residual(num_channels, num_channels))
return blk # 返回这些块用于nn.Sequential定义网络
接着在ResNet加入所有残差块,这里每个模块(stage/残差块组)使用2个残差块。
In [6]:
# 每个模块都是两个残差块的组合(stage/残差块组)
b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True)) # num_residuals=2时
b3 = nn.Sequential(*resnet_block(64, 128, 2)) # 一次循环时:i = 0;第二次循环时:i = 1,就循环两次
b4 = nn.Sequential(*resnet_block(128, 256, 2)) # 通道数不断加倍
b5 = nn.Sequential(*resnet_block(256, 512, 2))
# “*”指的是把block从list形式展开,展成nn.Sequential的可用传参
最后,与GoogLeNet一样,在ResNet中加入全局平均汇聚层,以及全连接层输出。
In [7]:
net = nn.Sequential(b1, b2, b3, b4, b5,
nn.AdaptiveAvgPool2d((1,1)),
nn.Flatten(), nn.Linear(512, 10)) # nn.Flatten()展平
每个模块有4个卷积层(不包括恒等映射的1×1卷积层)。 加上第一个7×7卷积层和最后一个全连接层,共有18层。 因此,这种模型通常被称为ResNet-18。 通过配置不同的通道数和模块里的残差块数可以得到不同的ResNet模型,例如更深的含152层的ResNet-152。 虽然ResNet的主体架构跟GoogLeNet类似,但ResNet架构更简单,修改也更方便。这些因素都导致了ResNet迅速被广泛使用。 :numref:fig_resnet18
描述了完整的ResNet-18。
:label:fig_resnet18
补充:更多版本的ResNet
- 横坐标是计算速度,纵坐标是准确率
- 圆点的面积大小,指的是内存占用的相对大小(圆点越大,越占内存)
- ResNet经过多年改进,有了超多版本,一般使用预训练的resnet50就够啦
- 可以看到resnet的效果非常好,而且有很多版本,比如resnet18,常用的是resnet50
在训练ResNet之前,让我们[观察一下ResNet中不同模块的输入形状是如何变化的]。 在之前所有架构中,分辨率降低,通道数量增加,直到全局平均汇聚层聚集所有特征。
In [8]:
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])
4. 训练模型
同之前一样,我们在Fashion-MNIST数据集上训练ResNet。
In [9]:
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())
5. 小结
- 残差块使得很深的网络更加容易训练;
- 甚至可以训练一千层的网络;
- 残差网络的 Residual connection(残差连接)对随后的深层神经网络设计产生了深远影响,无论是卷积类网络还是全连接类网络;
- 学习嵌套函数(nested function)是训练神经网络的理想情况。在深层神经网络中,学习另一层作为恒等映射(identity function)较容易(尽管这是一个极端情况)。
- 残差网络(ResNet)对随后的深层神经网络设计产生了深远影响。
6. ResNet的两大卖点
6.1 加深模型可以退化为浅层模型
- 使得深层网络(x (layer1) + g(x) (layer2))在性能不好的时候能够退化为浅层网络(x (layer1))
- 模型加深,性能至少不会下降
6.2 用加法解决梯度消失问题
从梯度大小的角度来解释,residual connection 使得靠近数据的层的权重 w 也能够获得比较大的梯度;因此,不管网络有多深,下面的层都是可以拿到足够大的梯度,使得网络能够比较高效地更新
- 乘法导致梯度消失:训练很深的神经网络时,由于(反向传播)计算梯度时的链式法则中的乘法,导致容易在靠近输入的层出现梯度消失的问题:
-
- 由于这个计算过程中包含了多个乘法运算,如果在传播过程中每一项的梯度值都 小于 1,那么随着层数的增加,这些小于 1 的数不断相乘,就会导致梯度越来越小,靠近输入层的梯度可能会趋近于零,这就是梯度消失问题。
- 使用加法的残差连接:(不会导致梯度消失)
- 残差连接的结构
- 加法不会导致梯度消失的原因
- 从而缓解了梯度消失问题