一、初识HarDNet
在一部分,我首先简单展示一下HarDNet的效果, 它使用了经过ImageNet预训练的权重。在这里采用的是PyTorch框架,在Google Colab上测试运行。
1.1 介绍HarDNet
HarDNet指的是Harmonic DenseNet: A low memory traffic network,其突出的特点就是低内存占用率。过去几年,随着更强的计算能力和更大的数据集,我们能够训练更加复杂的网络。对于实时应用,我们面临的问题是如何在提高计算效率的同时,降低功耗。在这种情况下,作者们提出了HarDNet在两者之间寻求最佳平衡。
1.2 HarDNet的架构
HarDNet可以用于图像分割和目标检测,其架构是基于Densely Connected Network。在HarDNet中,作者提出了Harmonic Dense Bocks的概念。如图1所示,可以看到该网络就像多个谐波。HarDNet的全称就是Harmonic Densely Connected Network。
图1. Illustrations for HarDNet
1.3 HarDNet的可能应用
我们有许多图像分类算法,但是与其他分类算法相比,HarDNet可以降低功耗并达到类似的精度。
在目标检测中,SSD使用HarDNet-68作为最先进的骨干网络;在图像分割中,可以使用HarDNet对图像进行下采样。
二、再看HarDNet
2.1 背景
在神经网络的推理阶段,如何增加计算效率,并减小功耗,这是一个关键问题。
过去是怎么做的呢?首先就是减小模型的尺寸(模型的参数量和权重),这就意味着更小的MACs(number of multiply-accumulate operations or floating point operations)和更少的动态随机访问存储器(DRAM),这些主要用来读写模型参数和特征图。主要的工作有:Residual Networks 、SqueezeNets、Densely Connected Networks等。也可以通过pruning和quantization来减小模型尺寸和功耗。
对于语义分割任务,中间特征图的总尺寸是模型尺寸的几百倍,这样就会使DRAM过度存取特征图,导致推理速度变慢。
目前减小特征图尺寸的方法基本都是有损的(比如,subsampling),这样会使准确率下降。本文作者设计了一种CNN结构,减小了特征图的DRAM存取量,同时不损害准确率。
2.2 评价指标
Nvidia profiler:DRAM读/写的字节数。
ARM Scale Sim:每个CNN框架的流量数据和推理次数。
Convolutional Input/Output (CIO):每个卷积层的输入和输出尺寸之和。COI是DRAM流量的近似处理。
C I O=\sum_{l}\left(c_{i n}^{(l)} \times w_{i n}^{(l)} \times h_{i n}^{(l)}+c_{o u t}^{(l)} \times w_{o u t}^{(l)} \times h_{o u t}^{(l)}\right)
MoC:MACs/COO。在MoC低于某个值时,某个层的延时对应于某个固定的时间。
2.3 基本方法
文中,作者对每一层的MoC施加一个软约束,以设计一个低CIO网络模型,并合理增加MACs。如图2所示,避免使用MoC非常低的层,例如具有非常大输入/输出通道比的Conv1x1层。受Densely Connected Networks的启发,作者提出了Harmonic Densely Connected Network (HarD- Net) 。首先减少来自DenseNet的大部分层连接,以降低级联损耗。然后,通过增加层的通道宽度来平衡输入/输出通道比率。
图2. Concept of MoC constraint
我们想要的是:较高的MoC;较小的CIO;适中的MACs。
2.4 结论
HarDNets reduces DRAM traffic by 40% compared with DenseNets.
Compared to DenseNet and ResNet, HarDNet achieves the same accuracy with 30%∼50% less CIO, and accordingly, 30%∼40% less inference time.
三、深入HarDNet
3.1 稀疏化和权重化
文中采用的稀疏连接方式:当$k$能被$2^n$整除,让$k$层和$k-2^n$层相连,其中$n$为非负整数;并且还需满足$k-2^{n} \ge 0$。
如果k=0,则0层就是输入层。
如果k=1,则1层可以与0层(n=0)相连;
如果k=2,则2层可以与1层(n=0)、0层(n=1)相连;
如果k=3,则3层可以与2层(n=0)相连;
如果k=4,则4层可以与3层(n=0)、2层(n=1)、0层(n=2)相连;
如果k=5,则5层可以与4层(n=0)相连;
如果k=6,,则6层可以与5层(n=0)、4层(n=1)相连;
3.2 Transition and Bottleneck Layers
将上面提到的各种层组合起来,就构成了一个Harmonic Dense Block (HDB)。在HDB后连接一个1×1 conv层,作为trainsition。此外,作者设置HDB的深度为L=2^n
,这样一个HDB的最后一层就有最大的通道数,梯度最多能传输\text{log}L
层。为了缓解这种梯度消失,作者将一个HDB的输出设置为第L层和它前面所有奇数层的级联。当完成HDB以后,就可以丢弃从2至L-2的所有偶数层。当m=1.6-1.9时,这些偶数层的内存占用是奇数层的2至3倍。
3.3 详细设计
如下图所示,作者提出了6个版本的HarDNet,在transition layer中设置reduction rate为0.85,因为已经对growth rate multiplier设置了一个低维的压缩因子。为了使深度具有多样性,作者还将一个block分成多个blocks,一共有16层(当考虑bottleneck layers时则为20层)。
图3. Detailed implementation parameters. A “3×3, 64” stands for a Conv3x3 layer with 64 output channels, and the leading numbers below Stride 2 stand for an HDB with how many layers, followed by its growth rate k and a transitional Conv1x1 with t output channels.
进一步,作者提出了HarDNet-68。去除了全局稠密连接;使用了MaxPool进行下采样;采用Conv-BN-ReLU的顺序;每个HDB中的growth rate k 能够增强CIO的效率。因为一个较深的HDB具有更大的输入通道数,所以一个更大的growth rate能够促进某一层输入和输出之间的通道比,从而达到MoC的限制。
此外,作者还提出了HarDNet-39DS,采用了完全的深度可分离卷积(除了第一个卷积层)。如图4b所示,作者将一个3×3 Conv层分解为一个逐点卷积和一个逐深度卷积。需要注意的是,这里逐点和逐深度卷积的顺序不能改变。因为一个HDB中每层均有一个宽输入和一个窄输出,若改变卷积顺序的话,会极大增加COI。
图4. (a) Inverted transition down module, (b)Depthwise-separable convolution for HarDNet.
四、模型遍历
在阅读文章时,可能依旧有诸多疑点,因此这里通过代码来进一步理解网络的内涵。
你可以在这里找到模型源文件。
首先,我自己增设了一个main函数,以调用整个网络模型。代码如下:
if __name__ == "__main__":
model = hardnet(n_classes=19)
image = torch.randn(1, 3, 512, 512)
with torch.no_grad():
output = model.forward(image)
print(model)
print(output.size())
接下来我将通过代码的运行顺序来遍历,但会保留缩进。
在model = hardnet(n_classes=19)
处设置断点,接着跳入hardnet()
类,开始遍历__init__()
,代码如下:
class hardnet(nn.Module):
def __init__(self, n_classes=19):
super(hardnet, self).__init__()
first_ch = [16,24,32,48]
ch_list = [ 64, 96, 160, 224, 320]
grmul = 1.7
gr = [ 10,16,18,24,32]
n_layers = [ 4, 4, 8, 8, 8]
考虑:为什么line5-9要这么设置呢?
first_ch设置的是前4个ConvLayer的输出通道数。
blks = len(n_layers)
self.shortcut_layers = []
blks: 5
self.base = nn.ModuleList([])
self.base.append (
ConvLayer(in_channels=3, out_channels=first_ch[0], kernel=3, stride=2)
)
CLASS
torch.nn.ModuleList(modules: Optional[Iterable[torch.nn.modules.module.Module]] = None)Holds submodules in a list.
ModuleList
can be indexed like a regular Python list, but modules it contains are properly registered, and will be visible by allModule
methods.
下一步即将进入ConvLayer()
类,所以我先跳出hardnet()
类,跳到ConvLayer()
类,快速浏览一下,代码如下:
class ConvLayer(nn.Sequential):
def __init__(self, in_channels, out_channels, kernel=3, stride=1, dropout=0.1):
super().__init__()
self.add_module('conv', nn.Conv2d(in_channels, out_channels, kernel_size=kernel,
stride=stride, padding=kernel//2, bias = False))
self.add_module('norm', nn.BatchNorm2d(out_channels))
self.add_module('relu', nn.ReLU(inplace=True))
add_module
(name: str, module: torch.nn.modules.module.Module) Adds a child module to the current module.
根据调用ConvLayer()时的输入参数,
in_channels: 3
out_channels: first_ch[0], which is 16 here.
kernel: 3
stride: 2
现在从ConvLayer()
类回到hardnet()
类中,代码位置如下:
self.base.append (
ConvLayer(in_channels=3, out_channels=first_ch[0], kernel=3, stride=2)
)
self.base.append ( ConvLayer(first_ch[0], first_ch[1], kernel=3) )
self.base.append ( ConvLayer(first_ch[1], first_ch[2], kernel=3, stride=2) )
self.base.append ( ConvLayer(first_ch[2], first_ch[3], kernel=3) )
接下来的第2-4个ConvLayer的[in_channels, out_channels]
通道数依次为[2, 16]、[16, 24]、[24, 32]、[32, 48]。
需要注意的是:第1个和第3个ConvLayer的stride=2
,第2个和第4个ConvLayer的stride=1
。
接着往下
skip_connection_channel_counts = []
思考:这里的跳跃连接怎么使用?
ch = first_ch[3]
这里ch为48。
for i in range(blks):
blk = HarDBlock(ch, gr[i], grmul, n_layers[i])
这里我遇到了HarDBlock()
类,这应该就是原文的精华所在,需要仔细分析。
回忆一下:
ch = 48
gr = [ 10,16,18,24,32] # 就是growth rate
grmul = 1.7 # 就是文章中所谓的growth rate multiplier
n_layers = [ 4, 4, 8, 8, 8]
blks = len(n_layers)
现在跳出hardnet()
类,跳入HarDBlock()
类的__init__()
函数,代码如下:
def __init__(self, in_channels, growth_rate, grmul, n_layers, keepBase=False, residual_out=False):
super().__init__()
self.in_channels = in_channels
self.growth_rate = growth_rate
self.grmul = grmul
self.n_layers = n_layers
self.keepBase = keepBase
这里的一些关键参数:
self.in_channels: ch, 这个数值是在
HarDBlock()
类外部会被更新的self.growth_rat: gr[0], which is 10 here.
self.grmul: 1.7
self.n_layers: n_layers[0], which is 4 here.
self.links = []
layers_ = []
self.out_channels = 0
for i in range(n_layers):
outch, inch, link = self.get_link(i+1, in_channels, growth_rate, grmul)
self.links.append(link)
use_relu = residual_out
layers_.append(ConvLayer(inch, outch))
if (i % 2 == 0) or (i == n_layers - 1):
self.out_channels += outch
#print("Blk out =",self.out_channels)
self.layers = nn.ModuleList(layers_)
这里的第5行调用了HarDBlock()
类里的另一个函数get_link()
,调用参数为:get_link(1, 48, 10, 1.7)
我跳出__init__()
,进入get_link()
,这是作者观点的核心部分。代码如下:
def get_link(self, layer, base_ch, growth_rate, grmul):
if layer == 0:
return base_ch, 0, []
out_channels = growth_rate # 10
link = []
for i in range(10): # 思考:为什么是10?因为2^9=512,默认layer不会大于512.
dv = 2 ** i
if layer % dv == 0:
k = layer - dv
link.append(k)
if i > 0:
out_channels *= grmul
out_channels = int(int(out_channels + 1) / 2) * 2
in_channels = 0
for i in link:
ch,_,_ = self.get_link(i, base_ch, growth_rate, grmul)
in_channels += ch
return out_channels, in_channels, link
如何理解这一过程呢?
首先是__init__()
函数,也就是HarDBlock()
类的输入:
in_channels = ch = 48,
growth_rate = gr[0] = 10,
grmul = 1.7,
n_layers = n_layers[0] = 4.
然后是get_link()
函数的输入:
layer = i+1,
base_ch = in_channels = 48,
growth_rate = 10,
grmul = 10.
接下来通过表格的形式,列出get_link()
函数的内外循环过程:
outside get_link() | in get_link() | ||||||||
---|---|---|---|---|---|---|---|---|---|
links | outch | inch | self.out_channels | layer | out_channels | link | in_channels | i | |
1 | [] | – | – | 0 | 1 | 10 | [] | – | – |
2 | [] | – | – | 0 | 1 | 10 | [0] | – | – |
3 | [] | – | – | 0 | 1 | 10 | [0] | 0 | 0 |
4 | [] | – | – | 0 | 1 | 10 | [0] | 48 | 0 |
5 | [0] | 10 | 48 | 10 | 1 | 10 | [0] | – | – |
6 | [0] | 10 | 48 | 10 | 2 | 10 | [] | – | – |
7 | [0] | 10 | 48 | 10 | 2 | 40 | [1] | – | – |
8 | [0] | 10 | 48 | 10 | 2 | 17 | [1, 0] | – | – |
9 | [0] | 10 | 48 | 10 | 2 | 18 | [1, 0] | 0 | 1 |
接下来,难理解的可能就是对link = [1, 0]进行循环,而循环的内容就是get.link()函数,这就构成了递归。具体来说,它会再次运行第1-5行的内容(这里面包括了link=1和link=0时所需的运行内容)。
layer = 1,运行的内容编号为1-1,1-0
layer = 2,运行的内容编号为2-1,1-1,1-0, 2-0
layer = 3,运行的内容编号为3-2, 2-1,1-1,1-0, 2-0; 3-1, 1-1,1-0; 3-0
这就是递归的过程了。
搞清楚了这个关系,我们就直接看一下get_link()
函数在layer为1,2,3,4时的输出结果:
layer | outch | inch | link |
---|---|---|---|
1 | 10 | 48 | [0] |
2 | 18 | 58 | [1, 0] |
3 | 10 | 18 | [2] |
4 | 28 | 76 | [3, 2, 0] |
可以看到这和[3.1 稀疏化和权重化](##3.1 稀疏化和权重化)中,我计算得到的结果完全相同!即:
如果k=0,则0层就是输入层。
如果k=1,则1层可以与0层(n=0)相连;
如果k=2,则2层可以与1层(n=0)、0层(n=1)相连;
如果k=3,则3层可以与2层(n=0)相连;
如果k=4,则4层可以与3层(n=0)、2层(n=1)、0层(n=2)相连。
接下来,我要跳出get_link()
函数,回到__init__()
函数,
self.links = []
layers_ = []
self.out_channels = 0
for i in range(n_layers):
outch, inch, link = self.get_link(i+1, in_channels, growth_rate, grmul)
self.links.append(link)
use_relu = residual_out
layers_.append(ConvLayer(inch, outch))
if (i % 2 == 0) or (i == n_layers - 1):
self.out_channels += outch
#print("Blk out =",self.out_channels)
self.layers = nn.ModuleList(layers_)
links
和layers_
的增加方式比较明显,那(i % 2 == 0) or (i == n_layers - 1)
的判断目的又是什么呢?
根据作者的解释,这样判断是为了能让输出通道数转化为输入通道数,有待之后验证。
最后将各个layers_进行组合,作为HarDBlock()
的返回值,回到hardnet()
中。
从HarDBlock()
回到hardnet()
中,
for i in range(blks):
blk = HarDBlock(ch, gr[i], grmul, n_layers[i])
ch = blk.get_out_ch()
这里的blk有几个比较关键的参数(此时i = 0, blks = 5):
blk.grmul: 1.7
blk.growth_rate: 10
blk.in_channels: 48
blk.links: [[0], [1, 0], [2], [3, 2, 0]]
blk.n_layers: 4
blk.out_channels: 48
要获取ch
,我跳出hardnet()
,快速跳到HarDBlock()
中的get_out_ch()
,代码如下:
def get_out_ch(self):
return self.out_channels
从get_out_ch()
回到hardnet()
,ch的值就是blk.out_channels的值,也就是48。
Reference
[1]. HarDNet: A Low Memory Traffic Network
[2]. Building your own Object Recognition in Pytorch – A Guide to Implement HarDNet in PyTorch