现代卷积神经网络(GoogLeNet+批量归一化+ResNet)
神经网络架构
文章目录
- 神经网络架构
- 含并行连结的网络(GoogLeNet)
- Inception块
- 代码实现
- GoogLeNet模型
- Inception的变种
- 总结
- 批量归一化
- 核心思想
- 批量规范化层
- 全连接层
- 卷积层
- 预测过程中的批量规范化
- 代码实现
- 自行通过代码实现
- 通过pytorch框架中内部定义的BatchNorm操作
- 总结
- 残差网络(ResNet)
- 残差块(residual_block)
- 代码实现
- ResNet模型
- 总结
含并行连结的网络(GoogLeNet)
GoogLeNet吸收了NiN中串联网络中的思想,大量使用了 1 × 1 1 \times 1 1×1卷积,并在此基础上做了改进。
采用了全局平均池化来代替传统的全连接层,减少了参数数量,还能降低过拟合的风险;继续使用了模块化设计,引入了Inception模块,一种新的网络结构,在同一层中并行使用不同大小的卷积核来捕捉多尺度的特征;利用不同大小的卷积核组合来改进网络架构,来提高识别的精确度。
Inception块
Inception块由四条并行路径组成,前三条路径使用窗口大小为
1
×
1
1\times 1
1×1、
3
×
3
3\times 3
3×3和
5
×
5
5\times 5
5×5的卷积层,从不同空间大小中提取信息,中间的两条路径在输入上执行
1
×
1
1\times 1
1×1卷积,以减少通道数,从而降低模型的复杂性。第四条路径使用
3
×
3
3\times 3
3×3最大汇聚层,然后使用
1
×
1
1\times 1
1×1卷积层来改变通道数。
这四条路径都使用合适的填充来使输入与输出的高和宽一致,最后我们将每条线路的输出在通道维度上连结,将四条路输出的通道数合并在一起,并构成Inception块的输出。在Inception块中,通常调整的超参数是每层输出通道数。
图中标记为白色的卷积层可以认为是用来改变通道数的,要么改变输入要么改变输出;标记为蓝色的卷积层可以认为是用来抽取信息的。第1条路中标记为蓝色的卷积层不抽取空间信息,只抽取通道信息,第2、3条路中标记为蓝色的卷积层是用来抽取空间信息的,第4条路中标记为蓝色的最大池化层也是用来抽取空间信息的,增强鲁棒性。
代码实现
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
class Inception(nn.Module):
# c1--c4是每条路径的输出通道数
def __init__(self, in_channels, c1, c2, c3, c4, **kwargs):
super(Inception, self).__init__(**kwargs)
# 线路1,单1x1卷积层
self.p1_1 = nn.Conv2d(in_channels, c1, kernel_size=1)
# 线路2,1x1卷积层后接3x3卷积层
self.p2_1 = nn.Conv2d(in_channels, c2[0], kernel_size=1)
self.p2_2 = nn.Conv2d(c2[0], c2[1], kernel_size=3, padding=1)
# 线路3,1x1卷积层后接5x5卷积层
self.p3_1 = nn.Conv2d(in_channels, c3[0], kernel_size=1)
self.p3_2 = nn.Conv2d(c3[0], c3[1], kernel_size=5, padding=2)
# 线路4,3x3最大汇聚层后接1x1卷积层
self.p4_1 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
self.p4_2 = nn.Conv2d(in_channels, c4, kernel_size=1)
def forward(self, x):
p1 = F.relu(self.p1_1(x))
p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))
p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))
p4 = F.relu(self.p4_2(self.p4_1(x)))
# 在通道维度上连结输出
return torch.cat((p1, p2, p3, p4), dim=1)
GoogLeNet
有效的原因:滤波器(Filter)的组合,通过不同的滤波器尺寸来探索图像,意味着不同大小的滤波器可以有效地识别不同范围的图像细节;故可以为不同滤波器分配不同数量的参数。
参数个数比直接用卷积少,计算量大幅度减少;不仅增加了不同设置的卷积层,参数量和计算量都显著降低。
GoogLeNet模型
GoogLeNet
一共使用9个Inception块和全局平均汇聚层的堆叠来生成其估计值。Inception块之间的最大汇聚层可降低维度。
第一个模块类似于AlexNet
和LeNet
,Inception块的组合从VGG
继承,全局平均汇聚层避免了在最后使用全连接层。
Inception的变种
Inception-BN(V2):使用batch normalization
Inceprtion-V3:修改了Inception块,网络变得更复杂更深
- 替换5 * 5为多个3 * 3卷积层
- 替换5 * 5为多个1 * 7和7 * 1卷积层,区别先看横向区别再看纵向区别,代替了原来的5*5卷积
- 替换3 * 3为多个1 * 3和3 * 1卷积层
总结
- Inception块相当于一个有4条路径的子网络。它通过不同窗口形状的卷积层和最大池化层来并行抽取信息,并使用** 1 × 1 1×1 1×1卷积层减少每像素级别上的通道维数**从而降低模型复杂度。
GoogLeNet
将多个设计精细的Inception块与其他层(卷积层、全连接层)串联起来。其中Inception块的通道数分配之比是在ImageNet
数据集上通过大量的实验得来的。GoogLeNet
和它的后继者们一度是ImageNet
上最有效的模型之一:它以较低的计算复杂度提供了类似的测试精度。
Q:调参如何去调
A:ImageNet
的一个小子集上去调,样本减少一点,测一下观察性能,再放大子集上去调整。
批量归一化
随着模型层数的增加,越上层的梯度越大,越下层的梯度越小;即上层的模型很快就容易拟合成功,而下层的模型需要重新学习很多次,模型收敛变慢,不容易拟合。那么,我们要如何去学习底部层从而避免变化顶部层?我们可以通过批量归一化来解决层数过深而导致的模型训练过慢的问题。
核心思想
批量归一化应用于单个可选层(也可以应用到所有层),其原理如下:在每次训练迭代中,我们首先规范化输入,即通过减去其均值并除以其标准差,其中两者均基于当前小批量处理。接下来,我们应用比例系数和比例偏移。正是由于这个基于批量统计的标准化,才有了批量规范化的名称。
从形式上来说,用
x
∈
B
\mathbf{x} \in \mathcal{B}
x∈B表示一个来自==小批量
B
\mathcal{B}
B==的输入,批量规范化
B
N
\mathrm{BN}
BN根据以下表达式转换
x
\mathbf{x}
x:
B
N
(
x
)
=
γ
⊙
x
−
μ
^
B
σ
^
B
+
β
.
\mathrm{BN}(\mathbf{x}) = \boldsymbol{\gamma} \odot \frac{\mathbf{x} - \hat{\boldsymbol{\mu}}_\mathcal{B}}{\hat{\boldsymbol{\sigma}}_\mathcal{B}} + \boldsymbol{\beta}.
BN(x)=γ⊙σ^Bx−μ^B+β.
μ
^
B
\hat{\boldsymbol{\mu}}_\mathcal{B}
μ^B是小批量
B
\mathcal{B}
B的样本均值,
σ
^
B
\hat{\boldsymbol{\sigma}}_\mathcal{B}
σ^B是小批量
B
\mathcal{B}
B的样本标准差。应用标准化后,生成的小批量的平均值为0和单位方差为1。
由于单位方差(与其他一些魔法数)是一个主观的选择,因此我们通常包含拉伸参数(scale)
γ
\boldsymbol{\gamma}
γ和偏移参数(shift)
β
\boldsymbol{\beta}
β,它们的形状与
x
\mathbf{x}
x相同。请注意,
γ
\boldsymbol{\gamma}
γ和
β
\boldsymbol{\beta}
β是需要与其他模型参数一起学习的参数。
由于在训练过程中,中间层的变化幅度不能过于剧烈,而批量规范化将每一层主动居中,并将它们重新调整为给定的平均值和大小(通过 μ ^ B \hat{\boldsymbol{\mu}}_\mathcal{B} μ^B和 σ ^ B {\hat{\boldsymbol{\sigma}}_\mathcal{B}} σ^B)。
从形式上来看,
μ
^
B
\hat{\boldsymbol{\mu}}_\mathcal{B}
μ^B和
σ
^
B
{\hat{\boldsymbol{\sigma}}_\mathcal{B}}
σ^B,如下所示:
μ
^
B
=
1
∣
B
∣
∑
x
∈
B
x
,
σ
^
B
2
=
1
∣
B
∣
∑
x
∈
B
(
x
−
μ
^
B
)
2
+
ϵ
.
\begin{aligned} \hat{\boldsymbol{\mu}}_\mathcal{B} &= \frac{1}{|\mathcal{B}|} \sum_{\mathbf{x} \in \mathcal{B}} \mathbf{x},\\ \hat{\boldsymbol{\sigma}}_\mathcal{B}^2 &= \frac{1}{|\mathcal{B}|} \sum_{\mathbf{x} \in \mathcal{B}} (\mathbf{x} - \hat{\boldsymbol{\mu}}_{\mathcal{B}})^2 + \epsilon.\end{aligned}
μ^Bσ^B2=∣B∣1x∈B∑x,=∣B∣1x∈B∑(x−μ^B)2+ϵ.
请注意,我们在方差估计值中添加一个小的常量
ϵ
>
0
\epsilon > 0
ϵ>0,以确保我们永远不会尝试除以零,即使在经验方差估计值可能消失的情况下也是如此。估计值
μ
^
B
\hat{\boldsymbol{\mu}}_\mathcal{B}
μ^B和
σ
^
B
{\hat{\boldsymbol{\sigma}}_\mathcal{B}}
σ^B通过使用平均值和方差的噪声(noise)估计来抵消缩放问题。乍看起来,这种噪声是一个问题,而事实上它是有益的。
由于尚未在理论上明确的原因,优化中的各种噪声源通常会导致更快的训练和较少的过拟合:这种变化似乎是正则化的一种形式。
这些理论揭示了为什么批量规范化最适应
50
∼
100
50 \sim 100
50∼100范围中的中等批量大小的难题。
另外,批量规范化层在”训练模式“(通过小批量统计数据规范化)和“预测模式”(通过数据集统计规范化)中的功能不同。
在训练过程中,我们无法得知使用整个数据集来估计平均值和方差,所以只能根据每个小批次的平均值和方差不断训练模型。
而在预测模式下,可以根据整个数据集精确计算批量规范化所需的平均值和方差。
批量规范化层
回想一下,批量规范化和其他层之间的一个关键区别是,由于批量规范化在完整的小批量上运行,因此我们不能像以前在引入其他层时那样忽略批量大小。我们在下面讨论这两种情况:全连接层和卷积层,他们的批量规范化实现略有不同。
为什么BN
操作在激活函数之前?如果先进行激活函数,使得输出结果变为正数,变为非线性的数据,而又进行BN
操作使得数据变为线性数据。
全连接层
对于全连接层,行是样本,列是特征,规范化作用在全连接层的特征维。通常,将批量规范化层置于全连接层中的仿射变换和激活函数之间。设全连接层的输入为x,权重参数和偏置参数分别为
W
\mathbf{W}
W和
b
\mathbf{b}
b,激活函数为
ϕ
\phi
ϕ,批量规范化的运算符为
B
N
\mathrm{BN}
BN。
那么,使用批量规范化的全连接层的输出的计算详情如下:
h
=
ϕ
(
B
N
(
W
x
+
b
)
)
.
\mathbf{h} = \phi(\mathrm{BN}(\mathbf{W}\mathbf{x} + \mathbf{b}) ).
h=ϕ(BN(Wx+b)).
回想一下,均值和方差是在应用变换的"相同"小批量上计算的。
卷积层
而对于卷积层,行是一个通道的全部像素点,列是通道数,故规范化是作用在不同通道的同一像素点,作用在通道维。同样,对于卷积层,在卷积层之后和非线性激活函数之前应用批量规范化。
当卷积有多个输出通道时,我们需要对这些通道的“每个”输出执行批量规范化,每个通道都有自己的拉伸(scale)和偏移(shift)参数,这两个参数都是标量。
假设我们的小批量包含
m
m
m个样本,并且对于每个通道,卷积的输出具有高度
p
p
p和宽度
q
q
q。那么对于卷积层,我们在每个输出通道的
m
⋅
p
⋅
q
m \cdot p \cdot q
m⋅p⋅q个元素上同时执行每个批量规范化。因此,在计算平均值和方差时,我们会收集所有空间位置的值,然后在给定通道内应用相同的均值和方差,以便在每个空间位置对值进行规范化。
预测过程中的批量规范化
正如我们前面提到的,批量规范化在训练模式和预测模式下的行为通常不同。
首先,将训练好的模型用于预测时,不再需要样本均值中的噪声以及在微批次上估计每个小批次产生的样本方差了。其次,例如,我们可能需要使用我们的模型对逐个样本进行预测。**一种常用的方法是通过移动平均估算整个训练数据集的样本均值和方差,并在预测时使用它们得到确定的输出。**可见,和暂退法dropout
一样,批量规范化层在训练模式和预测模式下的计算结果也是不一样的。
最初的论文指出是用来减少内部协变量转移,后续论文指出通过在每个小批量里加入噪音来控制模型的复杂度。(有可能也不对,工程走在理论的前面)。批量归一化的功能与dropout
类似,不必一同使用。
代码实现
自行通过代码实现
# 有张量的批量规范化层
import torch
from torch import nn
from d2l import torch as d2l
def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum):
# moving_mean,moving_var:全局的均值和方差 eps:防止除零 momentum:更新mean和var
# 通过is_grad_enabled来判断当前模式是训练模式还是预测模式
if not torch.is_grad_enabled():
# 如果是在预测模式下,直接使用传入的移动平均所得的均值和方差
X_hat = (X - moving_mean) / torch.sqrt(moving_var + eps)
else:
assert len(X.shape) in (2, 4)
if len(X.shape) == 2:
# 使用全连接层的情况,计算特征维上的均值和方差
mean = X.mean(dim=0)
var = ((X - mean) ** 2).mean(dim=0)
else:
# 使用二维卷积层的情况,计算通道维上(axis=1)的均值和方差,针对通道维进行运算。
# 这里我们需要保持X的形状以便后面可以做广播运算
mean = X.mean(dim=(0, 2, 3), keepdim=True)
var = ((X - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
# 训练模式下,用当前的均值和方差做标准化
X_hat = (X - mean) / torch.sqrt(var + eps)
# 更新移动平均的均值和方差
moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
moving_var = momentum * moving_var + (1.0 - momentum) * var
Y = gamma * X_hat + beta # 缩放和移位
return Y, moving_mean.data, moving_var.data
# 将功能集成到一个自定义层中,处理数据移动到训练设备、分配和初始化任何必需的变量、跟踪移动平均线等问题
class BatchNorm(nn.Module):
# num_features:完全连接层的输出数量或卷积层的输出通道数。
# num_dims:2表示完全连接层,4表示卷积层
def __init__(self, num_features, num_dims):
super().__init__()
if num_dims == 2:
shape = (1, num_features)
else:
shape = (1, num_features, 1, 1)
# 参与求梯度和迭代的拉伸和偏移参数,分别初始化成1和0
self.gamma = nn.Parameter(torch.ones(shape))
self.beta = nn.Parameter(torch.zeros(shape))
# 非模型参数的变量初始化为0和1
self.moving_mean = torch.zeros(shape)
self.moving_var = torch.ones(shape)
def forward(self, X):
# 如果X不在内存上,将moving_mean和moving_var
# 复制到X所在显存上
if self.moving_mean.device != X.device:
self.moving_mean = self.moving_mean.to(X.device)
self.moving_var = self.moving_var.to(X.device)
# 保存更新过的moving_mean和moving_var
Y, self.moving_mean, self.moving_var = batch_norm(
X, self.gamma, self.beta, self.moving_mean,
self.moving_var, eps=1e-5, momentum=0.9)
return Y
# 将batch_normalization应用到LeNet中,构建LeNet神经网络
net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5), BatchNorm(6, num_dims=4), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), BatchNorm(16, num_dims=4), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
nn.Linear(16*4*4, 120), BatchNorm(120, num_dims=2), nn.Sigmoid(),
nn.Linear(120, 84), BatchNorm(84, num_dims=2), nn.Sigmoid(),
nn.Linear(84, 10))
# 训练网络
lr, num_epochs, batch_size = 1.0, 10, 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())
通过pytorch框架中内部定义的BatchNorm操作
net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5), nn.BatchNorm2d(6), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.BatchNorm2d(16), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
nn.Linear(256, 120), nn.BatchNorm1d(120), nn.Sigmoid(),
nn.Linear(120, 84), nn.BatchNorm1d(84), nn.Sigmoid(),
nn.Linear(84, 10))
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())
总结
- 批量归一化用来固定小批量中的均值和方差,然后来学习出合适的偏移和缩放
- 可以加速收敛速度,但一般不改变模型精度
残差网络(ResNet)
在非嵌套类函数中,较复杂的函数类并不总是向最佳值靠拢,并不一定容易靠近最佳值;但如果在嵌套类函数中,嵌套的函数类更容易找到所需的最佳值,避免不容易靠近最佳值。
如果当复杂的函数类包含较小的函数类时,我们可以先使得小函数靠近最佳值,然后不断利用包含的原始函数来找到更优解来拟合训练数据集。残差网络的核心思想:每个附加层都应该更容易地包含原始函数作为其元素之一。
残差块(residual_block)
左图中的虚线部分直接拟合出映射f(x),右图中的虚线部分拟合出残差映射f(x)-x。==残差映射在现实中更容易优化。==如果将右图中的虚线块中的权重和偏置参数设置为0,f(x)可以变为恒等映射。实际中,当理想映射 f ( x ) f(\mathbf{x}) f(x)极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。在残差块中,输入可通过跨层数据线路更快地向前传播。
代码实现
ResNet
沿用了VGG
完整的
3
×
3
3 \times 3
3×3卷积层设计。注意张量和conv2d
两者参数之间的差别。
fig_resnet_block
所示,此代码生成两种类型的网络:
一种是当use_1x1conv=False
时,应用ReLU非线性函数之前,将输入添加到输出。
另一种是当use_1x1conv=True
时,添加通过
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 d2l
class Residual(nn.Module): #@save
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)
self.conv2 = nn.Conv2d(num_channels, num_channels,
kernel_size=3, padding=1)
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):
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)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_1x1conv=False, 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)
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):
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)
# 输入与输出形状一致:
blk = Residual(3,3)
X = torch.rand(4, 3, 6, 6)
Y = blk(X)
Y.shape
----------------------------
torch.Size([4, 3, 6, 6])
# 增加输出通道,减半输出的高和宽
blk = Residual(3,6, use_1x1conv=True, strides=2)
blk(X).shape
---------------------------------------------------
torch.Size([4, 6, 3, 3])
ResNet模型
ResNet
的前两层跟之前介绍的GoogLeNet
中的一样:在输出通道数为64、步幅为2的
7
×
7
7 \times 7
7×7卷积层后,接步幅为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个由Inception块组成的模块。 ResNet
则使用4个由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块。 第一个模块的通道数同输入通道数一致。 由于之前已经使用了步幅为2的最大汇聚层,所以无须减小高和宽。 之后的每个模块在第一个残差块里将上一个模块的通道数翻倍,并将高和宽减半。
下面我们来实现这个模块。注意,我们对第一个模块做了特别处理。
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加入所有残差块,这里每个模块使用2个残差块。
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))
# 最后,与GoogLeNet一样,在ResNet中加入全局平均汇聚层,以及全连接层输出
net = nn.Sequential(b1, b2, b3, b4, b5,
nn.AdaptiveAvgPool2d((1,1)),
nn.Flatten(), nn.Linear(512, 10))
# 训练模型
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())
观察到如果按照正常运算,上层的梯度过小会影响到下层的梯度运算,如果利用残差块,不会因为上层的梯度大小而对下层的收敛造成影响。
总结
-
残差块的出现方便底层的网络更容易求损失,不会出现由于网络的深度过深而导致的损失很小,减轻了梯度消失的问题
-
残差块使得很深的网络更加容易训练,甚至可以训练一千层的网络;输入可以通过层间的残余连接更快地向前传播
-
残差网络对随后的深层神经网络设计产生了深远影响,无论是卷积类神经网络还是全连接类网络,帮助神经网络有效地求底层损失