Now you see me

Chao's Blog

理解HarDNet

一、初识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 […]

一、初识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 all Module 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_)

linkslayers_的增加方式比较明显,那(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