ECCV2020 | SOD100K:超低参数量的高效显著性目标检测算法,广义OctConv和动态权重衰减

2020-07-28 11:44:07 浏览数 (1)

这篇文章收录于ECCV2020,是一篇超高效的显著性目标检测的算法,仅有100K的参数量。主要创新点有:对Octave降频卷积进行了改进使其支持多尺度特征输入;提出了动态权重衰减方法用于训练环节。这两点方法都可以应用于其他任务中,保持网络性能的同时减少参数量,值得学习。

论文地址:https://arxiv.org/pdf/2003.05643.pdf

代码地址:https://github.com/MCG-NKU/SOD100K

显著性目标检测模型通常需要大量的计算成本才能对每个像素进行精确的预测,从而使其几乎不适用于低功耗设备。本文旨在通过更高程度地提高网络效率来缓解计算成本与模型性能之间的矛盾。本文提出了一种灵活的卷积模块——广义Oct-Conv(gOctConv),可以有效利用现阶段和跨阶段的分阶段的多尺度特征,同时通过一种新型的动态权重衰减方案来减少特征表示的冗余性,有效的动态权重衰减方案可稳定地提高训练期间参数的稀疏性,支持gOctConv中每个scale的可学习通道数,从而使80%的参数减少而性能下降可忽略不计。同时,利用gOctConv建立了一个非常轻量级的模型CSNet,该模型在公开的显著性检测基准数据集上,仅使用大型模型约0.2%的参数量(100k),获得可比较的性能。

简介

显著性目标检测(SOD)是一项重要的计算机视觉任务,在图像检索、视觉跟踪和弱监督语义分割中具有多种应用。尽管基于卷积神经网络(CNN)的SOD方法取得了长足的进步,但这些方法大多数都集中在通过利用精细细节和全局语义来改善最新技术(SOTA)的性能。尽管性能出色,但这些模型通常非常耗费资源,因此几乎不适用于存储/计算能力有限的低功耗设备。如何构建具有SOTA性能的超轻量级SOD模型是一个重要但研究较少的领域。

SOD任务需要为每个图像像素生成准确的预测分数,因此既需要大尺度的高层次特征表示来正确定位突出的物体,也需要精细的低层次表示来精确细化边界。构建超轻量级的SOD模型有两个主要的挑战:首先,当高层次特征的低频性满足了显著性特征图的高输出分辨率时,可能会出现严重的重冗余。其次,SOTA SOD模型通常依靠ImageNet预训练的主干网络架构来提取特征,而这本身就很耗费资源。

最近,Chen等人(《Drop an octave: Reducing spatial redundancy in convolutional neural networks with octave convolution》)注意到了低频特征的空间冗余问题。为了替代标准卷积,他们设计了一个OctConv运算来处理特征图,这些特征图在较低的空间分辨率下在空间上变化较慢,从而降低了计算成本。但是,直接使用OctConv来减少SOD任务中的冗余问题仍然面临两个主要挑战。:1)仅利用OctConv中的低和高分辨率两个尺度不足以完全减少SOD任务中的冗余问题,而SOD任务比分类任务需要更强大的多尺度表示能力。2)在OctConv中,每个scale的通道数是手动选择的,因为SOD任务需要的类别信息较少,因此需要为调整显着性模型进行大量的调整。

本文通过对OctConv在以下几个方面的扩展,提出了一种广义的OctConv(gOctConv),用于建立极轻量级的SOD模型。1)可以灵活地从任意数量的尺度、从阶段内特征以及跨阶段特征中获取输入,使得多尺度表示的范围更大;2)提出了一种动态权重衰减方案,支持每个尺度的可学习通道数,允许80%的参数减少,而性能下降可以忽略不计。3)受益于gOctConv的灵活性和效率,提出了一个高轻量级的模型CSNet,它可以充分挖掘阶段内和跨阶段的多尺度特征。因为CSNet是极低参数的,可以直接从头开始训练,而无需ImageNet预训练,避免了识别任务中区分不同类别的不必要的特征表示。

OctConv

论文地址:https://export.arxiv.org/pdf/1904.05049

第三方复现结果:https://github.com/terrychenism/OctaveConv

在图像处理领域,图像可以分为描述基础背景结构的低频特征与描述快速变化细节的高频特征两部分,其中高频特征占据了主要的图像信息。与此类似,卷积特征也可以分为高频特征与低频特征两种,Octave卷积通过相邻位置的特征共享,减小了低频特征的尺寸,进而减小了特征的冗余与空间内存的占用。

因此,研究人员提出基于频率对混合特征图进行分解,并设计了一种新的 Octave 卷积(OctConv)操作,以存储和处理较低空间分辨率下空间变化「较慢」的特征图,从而降低内存和计算成本。

与现有多尺度方法不同,OctConv 是一种单一、通用和即插即用的卷积单元,可以直接代替(普通)卷积,而无需对网络架构进行任何调整。OctConv 与那些用于构建更优拓扑或者减少分组或深度卷积中通道冗余的方法是正交和互补的。

实验表明,通过用 OctConv 替代普通卷积,研究人员可以持续提高图像和视频识别任务的准确率,同时降低内存和计算成本。一个配备有 OctConv 的 ResNet-152 能够以仅仅 22.2 GFLOP 在 ImageNet 上达到 82.9% 的 top-1 分类准确率。

OctConv 的设计细节

OctConv 的卷积核

采用卷积降采样后的特征图,在进一步上采样后,将导致整体向右下方漂移,影响特征融合。

通过卷积降采样会导致特征图无法准确对齐。并推荐使用池化操作来进行降采样。

本文方法:Light-weighted Network with Generalized OctConv

1、Overview of Generalized OctConv

OctConv最初的设计仅是为替代传统卷积单元,它在一个阶段内引入高/低两个尺度进行卷积操作。但是,一个阶段只有两个尺度是不能引入SOD任务所需的足够的多尺度信息。因此,本文提出了一种广义的OctConv(gOctConv),它允许从阶段内和跨阶段的转换特征中进行任意数量的输入,具有可学习的通道数,如图2(b)所示。作为原始OctConv的通用版本,gOctConv主要从以下几个方面进行了改进:

  • 任意数量的输入和输出尺度可以支持更大范围的多尺度表示。
  • 除了级内的特征外,gOctConv还可以从特征提取器以任意比例处理跨级的特征。
  • gOctConv通过本文提出的动态权重衰减方案可以学习每个尺度的通道数。
  • 可以关闭跨尺度的特征交互,提升灵活性。
代码语言:javascript复制
class gOctaveConv(nn.Module):
   def __init__(self, in_channels, out_channels, kernel_size, alpha_in=[0.5,0.5], alpha_out=[0.5,0.5], stride=1, padding=1, dilation=1,
                groups=1, bias=False, up_kwargs = up_kwargs):
       super(gOctaveConv, self).__init__()
       
       self.stride = stride
       self.padding = padding
       self.dilation = dilation

       self.groups = groups
       self.weights = nn.Parameter(torch.Tensor(out_channels, round(in_channels/self.groups), kernel_size[0], kernel_size[1]))

       if bias:
           self.bias = nn.Parameter(torch.Tensor(out_channels))
       else:
           self.register_parameter('bias', None)

       self.up_kwargs = up_kwargs
       self.h2g_pool = nn.AvgPool2d(kernel_size=(2,2), stride=2)

       self.in_channels = in_channels
       self.out_channels = out_channels
       
       self.alpha_in = [0]
       tmpsum = 0
       for i in range(len(alpha_in)):
           tmpsum  = alpha_in[i]
           self.alpha_in.append(tmpsum)
       self.alpha_out = [0]
       tmpsum = 0
       for i in range(len(alpha_out)):
           tmpsum  = alpha_out[i]
           self.alpha_out.append(tmpsum)
       self.inbranch = len(alpha_in)
       self.outbranch = len(alpha_out)
       global USE_BALANCE
       self.use_balance = USE_BALANCE
       if self.use_balance:
           self.bals = nn.Parameter(torch.Tensor(self.outbranch, out_channels))
           init.normal_(self.bals, mean=1.0, std=0.05)
       self.reset_parameters()

   def reset_parameters(self):
       n = self.in_channels
       init.kaiming_uniform_(self.weights, a=math.sqrt(5))
       if self.bias is not None:
           fan_in, _ = init._calculate_fan_in_and_fan_out(self.weight)
           bound = 1 / math.sqrt(fan_in)
           init.uniform_(self.bias, -bound, bound)
   def forward(self, xset):
       yset = []
       ysets = []
       for j in range(self.outbranch):
           ysets.append([])
       if isinstance(xset,torch.Tensor):
           xset = [xset,]
       if USE_BALANCE:
           bals_norm = torch.abs(self.bals)/(torch.abs(self.bals).sum(dim=0) 1e-14)
       for i in range(self.inbranch):
           if xset[i] is None:
               continue
           if self.stride == 2:
               x = F.avg_pool2d(xset[i], (2,2), stride=2)
           else:
               x = xset[i]
           begin_x = int(round(self.in_channels*self.alpha_in[i]/self.groups))
           end_x = int(round(self.in_channels*self.alpha_in[i 1]/self.groups))
           if begin_x == end_x:
               continue
           for j in range(self.outbranch):
               begin_y = int(round(self.out_channels*self.alpha_out[j]))
               end_y = int(round(self.out_channels*self.alpha_out[j 1]))
               if begin_y == end_y:
                   continue
               scale_factor = 2**(i-j)
               this_output_shape = xset[j].shape[2:4]
               if self.bias is not None:
                   this_bias = self.bias[begin_y:end_y]
               else:
                   this_bias = None

               if self.use_balance:
                   this_weight = self.weights[begin_y:end_y, begin_x:end_x, :,:]
                   this_weight = this_weight*bals_norm[j,begin_y:end_y].view(this_weight.shape[0],1,1,1)
               else:
                   this_weight = self.weights[begin_y:end_y, begin_x:end_x, :,:]

               if scale_factor > 1:
                   y = F.conv2d(x, this_weight, this_bias, 1, self.padding, self.dilation, self.groups)
                   y = F.interpolate(y, size=this_output_shape, mode=up_kwargs['mode'])
               elif scale_factor < 1:
                   x_resize = F.interpolate(x, size=this_output_shape, mode=up_kwargs['mode'])
                   y = F.conv2d(x_resize, this_weight, this_bias, 1, self.padding, self.dilation, self.groups)
               else:
                   y = F.conv2d(x, this_weight, this_bias, 1, self.padding, self.dilation, self.groups)
               ysets[j].append(y)

       for j in range(self.outbranch):
           if len(ysets[j])!=0:
               yset.append(sum(ysets[j]))
           else:
               yset.append(None)
       del ysets
       return yset

class gOctaveCBR(nn.Module):
   def __init__(self,in_channels, out_channels, kernel_size=(3,3),alpha_in=[0.5,0.5], alpha_out=[0.5,0.5], stride=1, padding=1, dilation=1,
                groups=1, bias=False, up_kwargs = up_kwargs):
       super(gOctaveCBR, self).__init__()
       self.in_channels = in_channels
       self.out_channels = out_channels
       self.std_conv = False
       if len(alpha_in)==1 and len(alpha_out)==1:
           self.std_conv = True
           self.conv = Conv2dX100(in_channels,out_channels,kernel_size, stride, padding, dilation, groups, bias)
       else:
           self.conv = gOctaveConv(in_channels,out_channels,kernel_size, alpha_in,alpha_out, stride, padding, dilation, groups, bias, up_kwargs)
       
       self.bns = nn.ModuleList()
       self.prelus = nn.ModuleList()
       for i in range(len(alpha_out)):
           if int(round(out_channels*alpha_out[i]))!=0:
               self.bns.append(nn.GroupNorm(32, int(round(out_channels*alpha_out[i]))))
               self.prelus.append(nn.PReLU(int(round(out_channels*alpha_out[i]))))
           else:
               self.bns.append(None)
               self.prelus.append(None)
       self.outbranch = len(alpha_out)
       self.alpha_in = alpha_in
       self.alpha_out = alpha_out

   def forward(self, xset):
       if self.std_conv:
           if isinstance(xset,torch.Tensor):
               xset = [xset,]
           xset = self.conv(xset[0])
           xset = self.prelus[0](self.bns[0](xset))
       else:
           xset = self.conv(xset)
           for i in range(self.outbranch):
               if xset[i] is not None:
                   xset[i] = self.prelus[i](self.bns[i](xset[i]))
       return xset

2 Light-weighted Model Composed of gOctConvs

如图3所示,本文提出的轻量级网络由特征提取器和跨阶段融合部分组成,可以同时处理多个尺度的特征。特征提取器与提出的层级多尺度块(即ILBlock)堆叠在一起,并根据特征图的分辨率分为4个阶段,每个阶段分别具有3、4、6和4个ILBlock。由gOctConv组成的跨阶段融合部分处理来自特征提取器各阶段的特征,以获得高分辨率输出。

  • 层级多尺度块(In-layer Multi-scale Block) ILBlock增强了阶段性特征的多尺度表示,gOctConvs被用来引入ILBlock内的多尺度。原本的OctConv需要大约60%的FLOPs才能达到与标准卷积相似的性能,这对于我们设计一个高轻量级模型的目标来说是不够的。为了节省计算成本,在每一层中都没有必要使用不同尺度的交互特征。因此,本文方法应用gOctConv消除跨尺度操作,使每个输入通道对应于具有相同分辨率的输出通道。在每个尺度内利用深度运算来进一步节省计算成本。与原始的OctConv相比,gOctConv的这个实例只需要1/channel的FLOPs。ILBlock由一个原始的OctConv和两个3×3的gOctConv组成,如图3所示。原始的OctConv与两个尺度的特征进行交互,gOctConvs在每个尺度内提取特征。一个区块内的多尺度特征被单独处理,交替进行内交互。每一次卷积之后都要进行BatchNorm和PRelu。最初,随着分辨率的降低,我们将ILBlocks的通道大致翻倍,除了最后两个阶段的通道数相同。除非另有说明,ILBlocks中不同尺度的通道是均匀设置的。
  • 跨层级融合(Cross-stages Fusion)为了保持高输出分辨率,常规方法在特征提取器的高层级上保持高特征分辨率,不可避免地增加了计算冗余。相反,本文的方法仅使用gOctConvs从特征提取器的各个阶段融合多尺度特征,并生成高分辨率输出。作为效率和性能之间的折衷,使用了来自最后三个阶段的特征。gOctConv中1×1卷积将具有与每个阶段的最后一个卷积不同尺度的特征作为输入,并进行跨阶段卷积以输出具有不同尺度的特征。为了在粒度级别上提取多尺度特征,特征的每个尺度都由一组具有不同扩展率的并行卷积处理。然后将特征发送到另一个gOctConv 1×1卷积以生成最高分辨率的特征。另一个标准1×1卷积输出了显著性图的预测结果,还获得了gOctConvs的可学习通道。
代码语言:javascript复制
class PallMSBlock(nn.Module):
   def __init__(self,in_channels, out_channels, alpha=[0.5,0.5], bias=False):
       super(PallMSBlock, self).__init__()
       self.std_conv = False
       self.convs = nn.ModuleList()

       for i in range(len(alpha)):
           self.convs.append(MSBlock(int(round(in_channels*alpha[i])), int(round(out_channels*alpha[i]))))
       self.outbranch = len(alpha)

   def forward(self, xset):
       if isinstance(xset,torch.Tensor):
           xset = [xset,]
       yset = []
       for i in range(self.outbranch):
           yset.append(self.convs[i](xset[i]))
       return yset


class MSBlock(nn.Module):
   def __init__(self, in_channels, out_channels, dilations = [1,2,4,8,16]):
       super(MSBlock,self).__init__()
       self.dilations = dilations
       each_out_channels = out_channels//5
       self.msconv = nn.ModuleList()
       for i in range(len(dilations)):
           if i != len(dilations)-1:
               this_outc = each_out_channels
           else:
               this_outc = out_channels - each_out_channels*(len(dilations)-1)
           self.msconv.append(nn.Conv2d(in_channels, this_outc,3, padding=dilations[i], dilation=dilations[i], bias=False))
       self.bn = nn.GroupNorm(32, out_channels)
       self.prelu = nn.PReLU(out_channels)

   def forward(self, x):
       outs = []
       for i in range(len(self.dilations)):
           outs.append(self.msconv[i](x))
       out = torch.cat(outs, dim=1)
       del outs
       out = self.prelu(self.bn(out))
       return out


class CSFNet(nn.Module):
   def __init__(self, num_classes=1):
       super(CSFNet, self).__init__()
       self.base = Res2Net(Bottle2neck, [3, 4, 6, 3], baseWidth = 26, scale = 4)
       # self.base.load_state_dict(model_zoo.load_url(model_urls['res2net50_v1b_26w_4s']))

       fuse_in_channel = 256 512 1024 2048
       fuse_in_split = [1/15,2/15,4/15,8/15]
       fuse_out_channel = 128 256 512 512
       fuse_out_split = [1/11,2/11,4/11,4/11]

       self.fuse = gOctaveCBR(fuse_in_channel, fuse_out_channel, kernel_size=(1,1), padding=0, 
                               alpha_in = fuse_in_split, alpha_out = fuse_out_split, stride = 1)
       self.ms = PallMSBlock(fuse_out_channel, fuse_out_channel, alpha = fuse_out_split)
       self.fuse1x1 = gOctaveCBR(fuse_out_channel, fuse_out_channel, kernel_size=(1, 1), padding=0, 
                               alpha_in = fuse_out_split, alpha_out = [1,], stride = 1)
       self.cls_layer = nn.Conv2d(fuse_out_channel, num_classes, kernel_size=1)

   def forward(self, x):
       features = self.base(x)
       fuse = self.fuse(features)
       fuse = self.ms(fuse)
       fuse = self.fuse1x1(fuse)
       output = self.cls_layer(fuse[0])
       output = F.interpolate(output, x.size()[2:], mode='bilinear', align_corners=False)

       return output

def build_model():
   return CSFNet()

def weights_init(m):
   if isinstance(m, nn.Conv2d):
       m.weight.data.normal_(0, 0.01)
       if m.bias is not None:
           m.bias.data.zero_()

3、Learnable Channels for gOctConv

本文建议在训练过程中利用本文提出的动态权重衰减方法,为gOctConv中的每个尺度获取可学习的通道数。动态权重衰减可在通道之间保持稳定的权重分布,同时引入稀疏性,从而有助于修剪算法消除性能下降幅度很小的冗余通道。

Dynamic Weight Decay

常用的正则化技巧权重衰减使CNN具有更好的泛化性能。通过权重衰减进行训练使得CNN中不重要的权重值接近于零。因此,权重衰减已被广泛用于修剪算法中以引入稀疏性。权重衰减的常见实现方式是在损失函数中添加L2正则化:

注意力机制已被广泛用于重新校准多样化的输出,并有额外的块和计算成本。因此,作者建议在推理期间不增加额外成本地缓解通道之间的多样化输出。多样化输出主要是由对权重的衰减项的不加区分的抑制造成的。因此,可以根据某些通道的特定特征来调整权重衰减。具体来说,在反向传播期间,衰减项会根据某些通道的特征动态变化。动态权重衰减的权重更新写为:

其中λ是动态权重衰减的权重,xi表示由wi计算的特征,而S(xi)是特征的度量,根据任务可以具有多个定义。在本文中,目标是根据稳定通道之间的特征进行权重分配。因此,可以仅使用全局平均池(GAP)作为特定通道的指标:

具体的算法过程如下:

实验与结果

数据集:训练数据集是DUST-TR,并在几个常见的测试集上进行评价,包括ECSSD,PASCAL-S,DUT-O,HKU-IS,SOD和DUTS-TE。

实验细节:在没有ImageNet预训练的情况下,CSNet仍可以达到与基于预训练主干的大模型相当的性能。最初将学习率设置为1e-4,然后在200个epoch和250个epoch时衰减10倍。本文仅使用了随机翻转和裁剪的数据增强。gOctConvs之后的BatchNorms权重衰减,作者建议使用动态的权重衰减替代,默认权重为3,而其他权重的权重衰减默认设置为5e-3。

实验结果

消融实验和对比实验

特征提取器仅由ILBlocks组成。使用固定的参数,并仅调整ILBlocks的gOctConvs中高分辨率/低分辨率特征的通道数分配比例。如表格1所示,就F-measure而言,CSNet-5 / 5的F值比特征提取器3/1高出1.4%。即使在极端情况下,仅具有低分辨率功能的CSNet-0 / 1的性能也比具有所有高分辨率功能的提取器1/0多44%的FLOPs。但是,手动调整具有不同分辨率的特征通道数的分割比例可能在性能和计算成本之间仅能达到次优。

如表格2所示。ResNet CSF仅使用53%的参数和21%的FLOP达到与ResNet PoolNet相似的性能。gOctConvs可以在主干的不同阶段获得高分辨率和低分辨率的特征,从而获得高分辨率输出,同时节省了大量计算资源。

动态权重衰减对比实验

实时性比较

更多细节可参考论文原文。

参考:

[1] 干货!仅有 100k 参数的高效显著性检测方法

[2] 即插即用新卷积OctConv:提升CNN性能、速度翻倍

0 人点赞