经典神经网络(7)DenseNet及其在Fashion-MNIST数据集上的应用
1 DenseNet的简述
-
DenseNet
不是通过更深或者更宽的结构,而是通过特征重用来提升网络的学习能力。 -
ResNet
的思想是:创建从“靠近输入的层” 到 “靠近输出的层” 的直连。而DenseNet
做得更为彻底:将所有层以前馈的形式相连,这种网络因此称作DenseNet
。 -
DenseNet
具有以下的优点:- 缓解梯度消失的问题。因为每层都可以直接从损失函数中获取梯度、从原始输入中获取信息,从而易于训练。
- 密集连接还具有正则化的效应,缓解了小训练集任务的过拟合。
- 鼓励特征重用。网络将不同层学到的
feature map
进行组合。 - 大幅度减少参数数量。因为每层的卷积核尺寸都比较小,输出通道数较少 (由增长率决定)。
-
DenseNet
具有比传统卷积网络更少的参数,因为它不需要重新学习多余的feature map
。-
传统的前馈神经网络可以视作在层与层之间传递
状态
的算法,每一层接收前一层的状态
,然后将新的状态
传递给下一层。这会改变
状态
,但是也传递了需要保留的信息。 -
ResNet
通过恒等映射来直接传递需要保留的信息,因此层之间只需要传递状态的变化
。 -
DenseNet
会将所有层的状态
全部保存到集体知识
中,同时每一层增加很少数量的feture map
到网络的集体知识中
。
-
-
DenseNet
的层很窄(即:feature map
的通道数很小),如:每一层的输出只有 12 个通道。 -
在跨层连接上,不同于ResNet中将输⼊与输出相加,稠密连接网络(DenseNet)在通道维上
连结输⼊与输出
。DenseNet的主要构建模块是稠密块和过渡层
。在构建DenseNet时,我们需要通过添加过渡层来
控制网络的维数,从⽽再次减少通道的数量。 -
虽然
DenseNet
的计算效率较高、参数相对较少,但是DenseNet
对内存不友好。可以考虑通过共享内存,解决这个问题。 -
论文下载地址: https://arxiv.org/pdf/1608.06993.pdf
1.1 稠密块(dense block)
ResNet和DenseNet的关键区别在于,DenseNet输出是连接(下图中的[ , ] 表示),而不是如ResNet的简单相加。
DenseNet这个名字由变量之间的“稠密连接”⽽得来,最后⼀层与之前的所有层紧密相连。
注意
:当 feature map
的尺寸改变时,无法沿着通道方向进行拼接。此时将网络划分为多个DenseNet
块,每块内部的 feature map
尺寸相同,块之间的feature map
尺寸不同。
1.1.1 增长率
-
DenseNet
块中,每层的 H(即BN-ReLU-Conv) 输出的feature map
通道数都相同,都是k个。 k是一个重要的超参数,称作网络的增长率。第 l 层的输入【特征图】的通道数为: k 0 + k ( l − 1 ) 。其中 k 0 为输入层的通道数。 第l层的输入【特征图】 的通道数为:k_0 + k(l-1) 。其中k_0为输入层的通道数。 第l层的输入【特征图】的通道数为:k0+k(l−1)。其中k0为输入层的通道数。
-
DenseNet
不同于现有网络的一个重要地方是:DenseNet
的网络很窄,即输出的feature map
通道数较小,如:k = 12 。-
一个很小的增长率就能够获得不错的效果。一种解释是:
DenseNet
块的每层都可以访问块内的所有早前层输出的feature map
,这些feature map
可以视作DenseNet
块的全局状态。每层输出的feature map
都将被添加到块的这个全局状态中,该全局状态可以理解为网络块的【集体知识】,由块内所有层共享。增长率决定了新增特征占全局状态的比例。 -
因此
feature map
无需逐层复制(因为它是全局共享),这也是DenseNet
与传统网络结构不同的地方。这有助于整个网络的特征重用,并产生更紧凑的模型。
-
1.1.2 非线性变换
-
H可以是包含了 Batch Normalization(BN) 、ReLU 单元、池化或者卷积等操作的复合函数。
-
论文中的结构为:先执行BN ,再执行ReLU,最后接一个3 x 3 的卷积,即:BN-ReLU-Conv(3x3)
-
pytorch实现如下
import torch.nn as nn
import torch
'''
DenseNet使⽤了ResNet改良版的“批量规范化、激活和卷积”架构
卷积块:BN-ReLU-Conv
'''
def conv_block(input_channels, num_channels):
return nn.Sequential(
nn.BatchNorm2d(input_channels),
nn.ReLU(),
nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1)
)
1.1.3 bottleneck
1.1.4 pytorch实现稠密块
import torch.nn as nn
import torch
'''
DenseNet使⽤了ResNet改良版的“批量规范化、激活和卷积”架构
卷积块:BN-ReLU-Conv
'''
def conv_block(input_channels, num_channels):
return nn.Sequential(
nn.BatchNorm2d(input_channels),
nn.ReLU(),
nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1)
)
'''
⼀个稠密块由多个卷积块组成,每个卷积块使⽤相同数量的输出通道。
然⽽,在前向传播中,我们将每个卷积块的输⼊和输出在通道维上连结。
'''
class DenseBlock(nn.Module):
def __init__(self, num_convs, input_channels, num_channels):
super(DenseBlock, self).__init__()
layer = []
for i in range(num_convs):
layer.append(
conv_block(num_channels * i + input_channels, num_channels) # 一个稠密块由多个卷积块组成
)
self.net = nn.Sequential(*layer)
def forward(self, X):
for blk in self.net:
Y = blk(X)
# 连接通道维度上每个块的输⼊和输出
X = torch.cat((X, Y), dim=1)
return X
if __name__ == '__main__':
'''
1、稠密块 dense block
我们定义⼀个有2个输出通道数为10的DenseBlock。
使⽤通道数为3的输⼊时,我们会得到通道数为3 + 2 × 10 = 23的输出。
卷积块的通道数控制了输出通道数相对于输⼊通道数的增⻓,因此也被称为增⻓率(growth rate)。
'''
blk = DenseBlock(2, 3, 10)
# X经过第一个卷积块后变为(4, 10, 8, 8),然后和原始X(4, 3, 8, 8)进行在维度1进行拼接,X变成(4, 13, 8, 8)
# 然后输入到第二个卷积块,第二个卷积块将channels由(10+3)变为10,因此输出Y(4, 10, 8, 8)
# 然后X和Y在维度1进行拼接,得到最终输出(4, 23, 8, 8)
X = torch.randn(4, 3, 8, 8)
Y = blk(X)
print(Y.shape) # (4, 23, 8, 8)
1.2 过渡层(transition layer)
1.2.1 过渡层的介绍
-
一个
DenseNet
网络具有多个DenseNet
块,DenseNet
块之间由过渡层连接。DenseNet
块之间的层称为过渡层,其主要作用是连接不同的DenseNet
块。 -
过渡层可以包含卷积或池化操作,从而改变前一个
DenseNet
块的输出feature map
的大小(包括尺寸大小、通道数量)。- 论文中的过渡层由一个
BN
层、一个1x1
卷积层、一个2x2
平均池化层组成。其中1x1
卷积层用于减少DenseNet
块的输出通道数,提高模型的紧凑性。 - 如果不减少
DenseNet
块的输出通道数,则经过了 个DenseNet
块之后,网络的feature map
的通道数会变得很大(通道数计算如下图公式)
- 论文中的过渡层由一个
- 如果
Dense
块输出feature map
的通道数为 m,则可以使得过渡层输出feature map
的通道数为 theta ✖ m ,其中0< theta <=1 为压缩因子。- 当theta = 1时,经过过渡层的
feature map
通道数不变。 - 当theta < 1时,经过过渡层的
feature map
通道数减小。此时的DenseNet
称做DenseNet-C
。 - 结合了
DenseNet-C
和DenseNet-B
的改进的网络称作DenseNet-BC
- 当theta = 1时,经过过渡层的
1.2.2 过渡层的实现
'''
由于每个稠密块都会带来通道数的增加,使⽤过多则会过于复杂化模型。
⽽过渡层可以⽤来控制模型复杂度。它通过1 × 1卷积层来减⼩通道数,并使⽤步幅为2的平均汇聚层减半⾼和宽,从⽽进⼀步降低模型复杂度。
'''
def transition_block(input_channels, num_channels):
return nn.Sequential(
nn.BatchNorm2d(input_channels),
nn.ReLU(),
nn.Conv2d(input_channels, num_channels, kernel_size=1), # 1×1卷积层来减⼩通道数
nn.AvgPool2d(kernel_size=2, stride=2) # 步幅为2的平均汇聚层减半⾼和宽
)
if __name__ == '__main__':
'''
1、稠密块 dense block
我们定义⼀个有2个输出通道数为10的DenseBlock。
使⽤通道数为3的输⼊时,我们会得到通道数为3 + 2 × 10 = 23的输出。
卷积块的通道数控制了输出通道数相对于输⼊通道数的增⻓,因此也被称为增⻓率(growth rate)。
'''
blk = DenseBlock(2, 3, 10)
# X经过第一个卷积块后变为(4, 10, 8, 8),然后和原始X(4, 3, 8, 8)进行在维度1进行拼接,X变成(4, 13, 8, 8)
# 然后输入到第二个卷积块,第二个卷积块将channels由(10+3)变为10,因此输出Y(4, 10, 8, 8)
# 然后X和Y在维度1进行拼接,得到最终输出(4, 23, 8, 8)
X = torch.randn(4, 3, 8, 8)
Y = blk(X)
print(Y.shape) # (4, 23, 8, 8)
'''
2、过渡层 transition layer
'''
blk = transition_block(23, 10)
print(blk(Y).shape) # torch.Size([4, 10, 4, 4])
1.3 DenseNet网络性能
1.3.1 网络结构
网络结构:ImageNet
训练的DenseNet
网络结构,其中增长率k = 32 。
- 表中的
conv
代表的是BN-ReLU-Conv
的组合。如1x1 conv
表示:先执行BN
,再执行ReLU
,最后执行1x1
的卷积。 DenseNet-xx
表示DenseNet
块有xx
层。如:DenseNet-169
表示DenseNet
块有L=169 层 。- 所有的
DenseNet
使用的是DenseNet-BC
结构,输入图片尺寸为224x224
,初始卷积尺寸为7x7
、输出通道2k
、步长为2
,压缩因子 theta=0.5。 - 在所有
DenseNet
块的最后接一个全局平均池化层,该池化层的结果作为softmax
输出层的输入。
1.3.2 在ImageNet
验证集的错误率
下图是DenseNet
和ResNet
在ImageNet
验证集的错误率的比较(single-crop
)。左图为参数数量,右图为计算量。
从实验可见:DenseNet
的参数数量和计算量相对ResNet
明显减少。
- 具有
20M
个参数的DenseNet-201
与具有40M
个参数的ResNet-101
验证误差接近。 - 和
ResNet-101
验证误差接近的DenseNet-201
的计算量接近于ResNet-50
,几乎是ResNet-101
的一半。
1.3.3 一个简单版本DenseNet的实现
我们实现一个简单的版本的DenseNet,使用DenseNet,而非DenseNet-BC,以应用在Fashion-MNIST数据集上。
稠密块和过度层
import torch.nn as nn
import torch
'''
DenseNet使⽤了ResNet改良版的“批量规范化、激活和卷积”架构
卷积块:BN-ReLU-Conv
'''
def conv_block(input_channels, num_channels):
return nn.Sequential(
nn.BatchNorm2d(input_channels),
nn.ReLU(),
nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1)
)
'''
⼀个稠密块由多个卷积块组成,每个卷积块使⽤相同数量的输出通道。
然⽽,在前向传播中,我们将每个卷积块的输⼊和输出在通道维上连结。
'''
class DenseBlock(nn.Module):
def __init__(self, num_convs, input_channels, num_channels):
super(DenseBlock, self).__init__()
layer = []
for i in range(num_convs):
layer.append(
conv_block(num_channels * i + input_channels, num_channels) # 一个稠密块由多个卷积块组成
)
self.net = nn.Sequential(*layer)
def forward(self, X):
for blk in self.net:
Y = blk(X)
# 连接通道维度上每个块的输⼊和输出
X = torch.cat((X, Y), dim=1)
return X
'''
由于每个稠密块都会带来通道数的增加,使⽤过多则会过于复杂化模型。
⽽过渡层可以⽤来控制模型复杂度。它通过1 × 1卷积层来减⼩通道数,并使⽤步幅为2的平均汇聚层减半⾼和宽,从⽽进⼀步降低模型复杂度。
'''
def transition_block(input_channels, num_channels):
return nn.Sequential(
nn.BatchNorm2d(input_channels),
nn.ReLU(),
nn.Conv2d(input_channels, num_channels, kernel_size=1), # 1×1卷积层来减⼩通道数
nn.AvgPool2d(kernel_size=2, stride=2) # 步幅为2的平均汇聚层减半⾼和宽
)
if __name__ == '__main__':
'''
1、稠密块 dense block
我们定义⼀个有2个输出通道数为10的DenseBlock。
使⽤通道数为3的输⼊时,我们会得到通道数为3 + 2 × 10 = 23的输出。
卷积块的通道数控制了输出通道数相对于输⼊通道数的增⻓,因此也被称为增⻓率(growth rate)。
'''
blk = DenseBlock(2, 3, 10)
# X经过第一个卷积块后变为(4, 10, 8, 8),然后和原始X(4, 3, 8, 8)进行在维度1进行拼接,X变成(4, 13, 8, 8)
# 然后输入到第二个卷积块,第二个卷积块将channels由(10+3)变为10,因此输出Y(4, 10, 8, 8)
# 然后X和Y在维度1进行拼接,得到最终输出(4, 23, 8, 8)
X = torch.randn(4, 3, 8, 8)
Y = blk(X)
print(Y.shape) # (4, 23, 8, 8)
'''
2、过渡层 transition layer
'''
blk = transition_block(23, 10)
print(blk(Y).shape) # torch.Size([4, 10, 4, 4])
DenseNet
import torch.nn as nn
import torch
from _08_dense_block import DenseBlock,transition_block
class DenseNet(nn.Module):
def __init__(self):
super(DenseNet, self).__init__()
'''
1、DenseNet⾸先使⽤同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)
)
'''
2、接下来,类似于ResNet使⽤的4个残差块,DenseNet使⽤的是4个稠密块。
与ResNet类似,我们可以设置每个稠密块使⽤多少个卷积层。这⾥我们设成4,从⽽之前的ResNet-18保持⼀致。
稠密块⾥的卷积层通道数(即增⻓率)设为32,所以每个稠密块将增加128个通道。
3、在每个模块之间,ResNet通过步幅为2的残差块减⼩⾼和宽,DenseNet则使⽤过渡层来减半⾼和宽,并减半通道数。
'''
# num_channels为当前的通道数
num_channels, growth_rate = 64, 32
num_convs_in_dense_blocks = [4, 4, 4, 4]
blks = []
for i, num_convs in enumerate(num_convs_in_dense_blocks):
# 添加稠密块
blks.append(DenseBlock(num_convs, num_channels, growth_rate))
# 上⼀个稠密块的输出通道数
num_channels += num_convs * growth_rate
# 在稠密块之间添加⼀个转换层,使通道数量减半
if i != len(num_convs_in_dense_blocks) - 1:
blks.append(transition_block(num_channels, num_channels // 2))
num_channels = num_channels // 2
'''
4、与ResNet类似,最后接上全局汇聚层和全连接层来输出结果。
'''
self.model = nn.Sequential(
b1,
*blks,
nn.BatchNorm2d(num_channels),
nn.ReLU(),
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(num_channels, 10)
)
def forward(self, X):
return self.model(X)
if __name__ == '__main__':
net = DenseNet()
X = torch.rand(size=(1, 1, 224, 224), dtype=torch.float32)
for layer in net.model:
X = layer(X)
print(layer.__class__.__name__, 'output shape:', X.shape)
Sequential output shape: torch.Size([1, 64, 56, 56])
DenseBlock output shape: torch.Size([1, 192, 56, 56])
Sequential output shape: torch.Size([1, 96, 28, 28])
DenseBlock output shape: torch.Size([1, 224, 28, 28])
Sequential output shape: torch.Size([1, 112, 14, 14])
DenseBlock output shape: torch.Size([1, 240, 14, 14])
Sequential output shape: torch.Size([1, 120, 7, 7])
DenseBlock output shape: torch.Size([1, 248, 7, 7])
BatchNorm2d output shape: torch.Size([1, 248, 7, 7])
ReLU output shape: torch.Size([1, 248, 7, 7])
AdaptiveAvgPool2d output shape: torch.Size([1, 248, 1, 1])
Flatten output shape: torch.Size([1, 248])
Linear output shape: torch.Size([1, 10])
1.4 DenseNet的内存或显存消耗过多问题
虽然 DenseNet
的计算效率较高、参数相对较少,但是DenseNet
对内存不友好。考虑到GPU
显存大小的限制,因此无法训练较深的 DenseNet
。
1.4.1 内存的计算
假设DenseNet
块包含L层,则:
对于第
l
层,有
x
l
=
H
l
(
[
x
0
,
x
1
,
.
.
.
,
x
l
−
1
]
)
对于第l层,有x_l = H_l([x_0,x_1,...,x_{l-1}])
对于第l层,有xl=Hl([x0,x1,...,xl−1])
假设每层的输出feature map
尺寸均为W×H、通道数为k, 由BN-ReLU-Conv(3x3)
组成,则:
- 拼接
Concat
操作 :需要生成临时feature map
作为第l层的输入,内存消耗为 W×H×k×l 。 BN
操作:需要生成临时feature map
作为ReLU
的输入,内存消耗为 W×H×k×l 。ReLU
操作:可以执行原地修改,因此不需要额外的feature map
存放ReLU
的输出。Conv
操作:需要生成输出feature map
作为第l层的输出,它是必须的开销。
因此除了第1,2,…,L层的输出feature map
需要内存开销之外,第l层还需要2W×H×k×l 的内存开销来存放中间生成的临时feature map
。
整个
D
e
n
s
e
N
e
t
块需要
W
×
H
×
k
×
(
L
+
1
)
L
的内存开销来存放中间生成的临时特征图。
即
D
e
n
s
e
N
e
t
块的内存消耗为
O
(
L
2
)
,是网络深度的平方关系。
整个 DenseNet 块 需要W×H×k×(L+1)L 的内存开销来存放中间生成的临时特征图。\\ 即DenseNet 块 的内存消耗为O(L^2),是网络深度的平方关系。
整个DenseNet块需要W×H×k×(L+1)L的内存开销来存放中间生成的临时特征图。即DenseNet块的内存消耗为O(L2),是网络深度的平方关系。
1.4.2 拼接的必要性及内存消耗的原因
-
拼接
Concat
操作是必须的,因为当卷积的输入存放在连续的内存区域时,卷积操作的计算效率较高。而DenseNet Block
中,第l层的输入feature map
由前面各层的输出feature map
沿通道方向拼接而成。而这些输出feature map
并不在连续的内存区域。 -
DenseNet Block
的这种内存消耗并不是DenseNet Block
的结构引起的,而是由深度学习库引起的。因为Tensorflow/PyTorch
等库在实现神经网络时,会存放中间生成的临时节点(如BN
的输出节点),这是为了在反向传播阶段可以直接获取临时节点的值。 -
这是在时间代价和空间代价之间的折中:通过开辟更多的空间来存储临时值,从而在反向传播阶段节省计算。
1.4.3 网络的参数也会消耗内存
除了临时feature map
的内存消耗之外,网络的参数也会消耗内存。设 H由BN-ReLU-Conv(3x3)
组成,则第l层的网络参数数量为: 9×l×k^2(不考虑 BN
)。
整个
D
e
n
s
e
N
e
t
块的参数数量为
9
k
2
(
L
+
1
)
L
2
,
即
O
(
L
2
)
整个 DenseNet块的参数数量为\frac{9k^2(L+1)L}{2},即O(L^2)
整个DenseNet块的参数数量为29k2(L+1)L,即O(L2)
- 由于
DenseNet
参数数量与网络的深度呈平方关系,因此DenseNet
网络的参数更多、网络容量更大。这也是DenseNet
优于其它网络的一个重要因素。 - 通常情况下都有WH > (9×k/2) ,其中W,H为网络
feature map
的宽、高,k为网络的增长率。所以网络参数消耗的内存要远小于临时feature map
消耗的内存。
1.5 DenseNet内存优化_共享内存
其思想是利用时间代价和空间代价之间的折中,但是侧重于牺牲时间代价来换取空间代价。
其背后支撑的因素是:Concat
操作和BN
操作的计算代价很低,但是空间代价很高。因此这种做法在DenseNet
中非常有效。
1.5.1 传统做法
传统的DenseNet Block
的第 l 层。首先将 feature map
拷贝到连续的内存块,拷贝时完成拼接的操作。然后依次执行BN
、ReLU
、Conv
操作。
该层的临时feature map
需要消耗内存 2W×H×k×l,该层的输出feature map
需要消耗内存W×H×k 。
- 另外某些实现(如
LuaTorch
)还需要为反向传播过程的梯度分配内存,如左图下半部分所示。如:计算BN
层输出的梯度时,需要用到第 l 层输出层的梯度和BN
层的输出。存储这些梯度需要额外的 O(lk)的内存。 - 另外一些实现(如
PyTorch,MxNet
)会对梯度使用共享的内存区域来存放这些梯度,因此只需要O(k)的内存。
1.5.2 共享内存做法
右图为内存优化的DenseNet Block
的第 l 层。采用两组预分配的共享内存区Shared memory Storage location
来存concate
操作和BN
操作输出的临时feature map
。
对于第一组预分配的共享内存区:
第一组预分配的共享内存区:concat
操作共享区。第1,2,…,L 层的 concat
操作的输出都写入到该共享区,第(l+1) 层的写入会覆盖第 (l)层的结果。
-
对于整个
Dense Block
,这个共享区只需要分配 W×H×k×L(最大的feature map
)的内存,即内存消耗为O(kL) (对比传统
DenseNet的O(kL^2)
)。 -
后续的
BN
操作直接从这个共享区读取数据。 -
由于第 (l+1)层的写入会覆盖第(l) 层的结果,因此这里存放的数据是临时的、易丢失的。因此在反向传播阶段还需要重新计算第 (l)层的
Concat
操作的结果。因为
concat
操作的计算效率非常高,因此这种额外的计算代价很低。
对于第二组预分配的共享内存区
第二组预分配的共享内存区:BN
操作共享区。第1,2,…,L 层的 concat
操作的输出都写入到该共享区,第(l+1) 层的写入会覆盖第 (l)层的结果。
-
对于整个
Dense Block
,这个共享区也只需要分配W×H×k×L (最大的feature map
)的内存,即内存消耗为O(kL) (对比传统DenseNet的O(kL^2)
)。 -
后续的卷积操作直接从这个共享区读取数据。
-
与
concat
操作共享区同样的原因,在反向传播阶段还需要重新计算第(l)层的BN
操作的结果。BN
的计算效率也很高,只需要额外付出大约 5% 的计算代价。
由于BN
操作和concat
操作在神经网络中大量使用,因此这种预分配共享内存区的方法可以广泛应用。它们可以在增加少量的计算时间的情况下节省大量的内存消耗。
2 DenseNet在Fashion-MNIST数据集上的应用示例
2.1 创建DenseNet网络模型
如1.3.3所示。
2.2 读取Fashion-MNIST数据集
batch_size = 256
# 为了使Fashion-MNIST上的训练短⼩精悍,将输⼊的⾼和宽从224降到96,简化计算
train_iter,test_iter = get_mnist_data(batch_size,resize=96)
2.3 在GPU上进行模型训练
from _08_DenseNet import DenseNet
# 初始化模型
net = DenseNet()
lr, num_epochs = 0.1, 10
train_ch(net, train_iter, test_iter, num_epochs, lr, try_gpu())