原始文档:https://www.yuque.com/lart/papers/frxyq3#FVXRR
这是一篇改进卷积操作的论文,实际上是一种仍然是一种卷积参数与特征相关的动态卷积网络(Dynamic Convolution Networks)。
由于卷积参数动态生成自特征,而且也不再是标准卷积那种局部处理的策略,所以在实现起来需要有些额外的技巧。
本文的实现中,作者提供了两种手段,一种是基于pytorch自身的unfold方法(https://github.com/d-li14/involution/blob/main/cls/mmcls/models/utils/involution_naive.py)和相乘求和,而另一种是直接使用cuda手动编写特征与生成的卷积核之间的整合过程(https://github.com/d-li14/involution/blob/main/cls/mmcls/models/utils/involution_cuda.py)。
而基于unfold的实现方法实际上理解起来非常简单,这里直接从代码入手,先看看这份工作到底怎么做的:
import torch.nn as nn
from mmcv.cnn import ConvModule
class involution(nn.Module):
def __init__(self, channels, kernel_size, stride):
super(involution, self).__init__()
self.kernel_size = kernel_size
self.stride = stride
self.channels = channels
reduction_ratio = 4
self.group_channels = 16
self.groups = self.channels // self.group_channels
self.conv1 = ConvModule(in_channels=channels,
out_channels=channels // reduction_ratio, kernel_size=1,
conv_cfg=None, norm_cfg=dict(type="BN"), act_cfg=dict(type="ReLU"))
self.conv2 = ConvModule(in_channels=channels // reduction_ratio,
out_channels=kernel_size ** 2 * self.groups,
kernel_size=1, stride=1, conv_cfg=None, norm_cfg=None, act_cfg=None)
if stride > 1:
self.avgpool = nn.AvgPool2d(stride, stride)
self.unfold = nn.Unfold(kernel_size, 1, (kernel_size - 1) // 2, stride)
def forward(self, x):
weight = self.conv2(self.conv1(x if self.stride == 1 else self.avgpool(x)))
b, c, h, w = weight.shape
weight = weight.view(b, self.groups, self.kernel_size ** 2, h, w).unsqueeze(2)
out = self.unfold(x).view(b, self.groups, self.group_channels, self.kernel_size ** 2, h, w) # 组内共享卷积核
out = (weight * out).sum(dim=3).view(b, self.channels, h, w)
return out
这里的实现中依赖了pytorch的一个非常重要的方法,就是 unfold
,该方法的主要功能就是实现了卷积的滑窗操作,每一次窗口中的数据会被收集到并堆叠到通道维度上,即dim=1上。
实际上的它的主要参数和卷积也基本一致,就是
kernel_size, stride, padding, dilation
这些。关于unfold
的简单介绍可见后文。
这里可以看到,对于生成卷积权重的过程使用了一个简单的 Conv-BN-ReLU-Conv
的结构,生成权重tensor后对其进行reshape操作,按照Line26和Line27的内容可以理解,这里实际上构造了一种动态的分组卷积。针对输入特征x,在 unfold
收集滑窗数据并堆叠到通道维度上后(就是 b, self.groups, self.group_channels, self.kernel_size ** 2, h, w
这里的 self.groups, self.group_channels, self.kernel_size ** 2
这部分所指代的数据),这里堆叠数据进行了拆分,将原始的通道中的数据进行分组,对于组里每一通道的滑窗数据共享同一个卷积核(即weight在 self.group_channels
对应的维度上是使用相同的广播得到的数据)。
对权重和调整后的x进行乘法(这里用的是元素乘法后累和,和后面介绍 unfold
中提到的矩阵乘法在思想上是一致的,都是在进行卷积中的加权求和)。
总体而言,这里的involution操作可以被称为动态的(空间不共享)、分组(组内共享)的卷积。(感觉这里应该整理下不同方法的差异了,其实已经有一些论文出现了类似的构造,例如CARAFE中实际上是一种动态的深度分离的卷积)。
既然已经知道了核心操作的过程,那么接下来我们需要了解的是,这么一个概念,作者是怎么思考或者展示的。
从传统图像滤波方法中可以了解到,卷积核具有两个引人注目的特性,这些特性有助于其吸引力(magnetism)和流行性(popularity),即,与空间无关(spatial-agnostic)和特定于通道(channel-specific)。
自开创性的VGG问世以来,神经网络通过将卷积核的空间跨度限制为不超过3x3的区域,从而追求卷积核的紧凑性。但是这也带来了一些问题:
为了克服以上局限,这里提出了involution,这种实现正好是相对于卷积的两种属性的各自的反面,即实现了spatial-specific和channel-agnostic。具体而言,involution核对于空间各个位置是不同的,但是在通道上确是共享的。
综合以上两个因素,involution操作的计算复杂度随特征通道数量而线性放缩,基于此,动态参数化的involution kernel在空间维度上具有广泛的覆盖范围(更广的感受野)。
由于这种反转的设计策略,提出的involution相较于convolution有着两个好处:
类似地,最近的方法开始尝试使用自注意力来替换卷积操作,以捕获远程依赖关系[Stand-alone self-attention in vision models, Exploring self-attention for image recognition]。在这些作品中,纯粹的自注意力机制可以被用来构建具有良好性能的独立模型。有趣的是(intriguingly),文章揭示了自注意力通过涉及内核构造的复杂公式化来具体化了我们一般化定义的involution操作。相比之下,这项工作中采用的involution kernel根据单个像素而不是依据相邻像素的关系生成的。更进一步,在实验中证明,即使使用令人尴尬(embarrassingly)的简单版本,involution也可以实现相较于self-attention在accuracy-cost的权衡。充分意识到在self-attention中,通过将查询与每个键进行比较而获得的亲和度矩阵(affinity matrix)也是involution kernel一种实例,在这里作者们开始质疑组合query和key特征以生成这样一个kernel的必要性,因为作者们简化了的involution kernel可以在避免key内容的冗余使用的同时还可以获得不错的性能。至于self-attention中的专用的位置编码就更不用说了(可能更不是有必要的了)。
提出的involution运算通过以相当轻量级的方式将可扩展(extendable)和可切换(switchable)的空间模型嵌入到表示学习范式中,轻松地促进了视觉识别。
在重新设计的视觉原语(visual primitive)的基础上,建立了一个被称为RedNet的主干架构,该架构可以实现优于基于卷积的ResNet和基于自注意力的图像分类模型的性能。在包括检测和分割在内的下游任务上,我们全面进行了逐步研究,以检验involution对检测器和分割器的不同组件(例如其backbone和neck)的有效性。 事实证明,对每个考虑的组件而言,involution都是有帮助的,并且将它们组合在一起可带来最高的效率。
说了这么多,看看作者是如何总结贡献的:
最直接是按照他们的计算方式来进行表示。
假设对于仅包含卷积操作的单一卷积层,输入特征为 X ∈ R C i × H × W X \in \mathbb{R}^{C_i \times H \times W} X∈RCi×H×W,输出特征为 Y ∈ R C o × H × W Y \in \mathbb{R}^{C_{o} \times H \times W} Y∈RCo×H×W。
为了通过渐进式构建整个网络,我们通过堆叠残差快来模仿ResNet的设计,因为ResNet的优雅架构使其易于尝试新思想并进行比较。我们对ResNet的stem中(使用3x3或7x7 involution进行分类或密集预测)和trunk(对所有任务使用7x7 involution)位置中的所有bottleneck位置的3x3卷积进行替换,但保留所有1x1卷积用于通道投影和融合。这些经过精心设计的实体联合起来,形成了一种称为RedNet的新型高效backbone。
一旦空间和通道信息交织在一起,神经网络内部就会出现大量的冗余。 但是,信息交互在RedNet中巧妙地解耦,朝着有利的精度与效率的权衡的方向发展。具体而言,在一个像素的通道维度中编码的信息隐式分散在其空间中 核生成步骤中的邻近区域,此后,由于具有庞大且动态的involution kernel,因此可以收集到丰富的感受野中的信息。必不可少的是,线性变换(通过1x1卷积实现)用于信道信息交换。综上所述,channel-spatial,spatial-alone和channel-alone的交互,交替且独立地作用于信息传播流,在确保表征能力的同时,协同促进了网络体系结构的小型化。
>>> a = torch.randn(1, 1, 3, 3)
>>> unfold = nn.Unfold(kernel_size=(2, 3), stride=(1, 1), padding=(1, 1), dilation=1)
>>> unfold(a).shape
torch.Size([1, 6, 12])
>>> unfold(a)
tensor([[[ 0.0000, 0.0000, 0.0000, 0.0000, 0.0286, 0.6680, 0.0000, -0.8924, -0.8030, 0.0000, -2.2951, -0.0457],
[ 0.0000, 0.0000, 0.0000, 0.0286, 0.6680, -0.2763, -0.8924, -0.8030, 1.0248, -2.2951, -0.0457, -0.4175],
[ 0.0000, 0.0000, 0.0000, 0.6680, -0.2763, 0.0000, -0.8030, 1.0248, 0.0000, -0.0457, -0.4175, 0.0000],
[ 0.0000, 0.0286, 0.6680, 0.0000, -0.8924, -0.8030, 0.0000, -2.2951, -0.0457, 0.0000, 0.0000, 0.0000],
[ 0.0286, 0.6680, -0.2763, -0.8924, -0.8030, 1.0248, -2.2951, -0.0457, -0.4175, 0.0000, 0.0000, 0.0000],
[ 0.6680, -0.2763, 0.0000, -0.8030, 1.0248, 0.0000, -0.0457, -0.4175, 0.0000, 0.0000, 0.0000, 0.0000]]])
>>> a
tensor([[[[ 0.0286, 0.6680, -0.2763],
[-0.8924, -0.8030, 1.0248],
[-2.2951, -0.0457, -0.4175]]]])
这里做的就是对tensor a沿着h和w滑动滑窗,滑窗的h=2,w=3,沿着两个方向的移动的步长都为1,对tensor a的padding也是沿着h方向对上下各补1行0和沿着w方向左右各补1列0。
tensor([[[[ 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
[ 0.0000, 0.0286, 0.6680, -0.2763, 0.0000],
[ 0.0000, -0.8924, -0.8030, 1.0248, 0.0000],
[ 0.0000, -2.2951, -0.0457, -0.4175, 0.0000],
[ 0.0000, 0.0000, 0.0000, 0.0000, 0.0000]]]])
例如对于第一个窗口,即对上面这个已经padding之后的tensor处理时,窗口数据为 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0286, 0.6680
,则它们会被收集起来堆叠到通道维度。也就是 unfold(a)
中的一列(因为unfold会将滑窗处理后的h和w维度以类似flatten的规则,按照从左至右从上到下、从低维度到高维度的过程,堆叠到一起)。
不过这里展示的将窗口中的数据堆叠到c上的过程是基于单通的tensor进行的演示,如果是多通道会如何?
a = torch.randn(1, 2, 3, 3)
a
tensor([[[[-1.4588, -0.7465, 1.4516],
[-1.2399, -0.9225, -2.0265],
[-1.1223, -0.4368, -0.4471]],
[[ 0.0395, -0.8731, -1.0842],
[-0.0681, -0.8138, -0.3849],
[ 0.9828, -0.0866, -0.7849]]]])
unfold(a)
tensor([[[ 0.0000, 0.0000, 0.0000, 0.0000, -1.4588, -0.7465, 0.0000, -1.2399, -0.9225, 0.0000, -1.1223, -0.4368],
[ 0.0000, 0.0000, 0.0000, -1.4588, -0.7465, 1.4516, -1.2399, -0.9225, -2.0265, -1.1223, -0.4368, -0.4471],
[ 0.0000, 0.0000, 0.0000, -0.7465, 1.4516, 0.0000, -0.9225, -2.0265, 0.0000, -0.4368, -0.4471, 0.0000],
[ 0.0000, -1.4588, -0.7465, 0.0000, -1.2399, -0.9225, 0.0000, -1.1223, -0.4368, 0.0000, 0.0000, 0.0000],
[-1.4588, -0.7465, 1.4516, -1.2399, -0.9225, -2.0265, -1.1223, -0.4368, -0.4471, 0.0000, 0.0000, 0.0000],
[-0.7465, 1.4516, 0.0000, -0.9225, -2.0265, 0.0000, -0.4368, -0.4471, 0.0000, 0.0000, 0.0000, 0.0000],
[ 0.0000, 0.0000, 0.0000, 0.0000, 0.0395, -0.8731, 0.0000, -0.0681, -0.8138, 0.0000, 0.9828, -0.0866],
[ 0.0000, 0.0000, 0.0000, 0.0395, -0.8731, -1.0842, -0.0681, -0.8138, -0.3849, 0.9828, -0.0866, -0.7849],
[ 0.0000, 0.0000, 0.0000, -0.8731, -1.0842, 0.0000, -0.8138, -0.3849, 0.0000, -0.0866, -0.7849, 0.0000],
[ 0.0000, 0.0395, -0.8731, 0.0000, -0.0681, -0.8138, 0.0000, 0.9828, -0.0866, 0.0000, 0.0000, 0.0000],
[ 0.0395, -0.8731, -1.0842, -0.0681, -0.8138, -0.3849, 0.9828, -0.0866, -0.7849, 0.0000, 0.0000, 0.0000],
[-0.8731, -1.0842, 0.0000, -0.8138, -0.3849, 0.0000, -0.0866, -0.7849, 0.0000, 0.0000, 0.0000, 0.0000]]])
从上面的例子来看, unfold
操作实际上也是按照着从低维到高维的过程进行的处理,即对c、h、w三个维度,按照w、h、c的先后顺序将滑窗内(要注意,这里的“滑窗要想象成三维的block,它通道数和输入特征一致)的数据收集起来,进而放到c维度上。
unfold
操作实现了卷积中最基本的滑窗收集数据的操作,可以用来构造更复杂的卷积。
通过前面的那个多通道tensor的例子,可以知道,实际上对于卷积运算而言,如果我们已经得到了 unfold(a)
这样的将每个卷积滑窗内的数据收集到整个通道上的中间tensor,那么接下来只需要将卷积核变形,直接对这个中间tensor的通道维度进行矩阵相乘即可。而动态与标准卷积最大的差异就在于这里的各个通道是否共享这个卷积核。
在 nn.Unfold
的文档中给出了一个构造标准卷积的例子:
>>> # Convolution is equivalent with Unfold + Matrix Multiplication + Fold (or view to output shape)
>>> inp = torch.randn(1, 3, 10, 12)
>>> w = torch.randn(2, 3, 4, 5) # 这可以来自模型的特征,即动态卷积,亦或是一个parameter,即标准卷积
>>> inp_unf = torch.nn.functional.unfold(inp, (4, 5)) # 这里用的是nn.Unfold的函数形式
>>> inp_unf.shape
torch.Size([1, 60, 56])
>>> reshaped_w = w.view(w.size(0), -1).t()
>>> reshaped_w.shape
torch.Size([60, 2])
>>> out_unf = inp_unf.transpose(1, 2).matmul(reshaped_w).transpose(1, 2)
# 60 -> 2 这个过程既有卷积窗口的整合亦有通道的调整
>>> out_unf.shape
torch.Size([1, 2, 56])
>>> out = torch.nn.functional.fold(out_unf, (7, 8), (1, 1)) # fold是和unfold相反的操作,是将堆叠起来的数据展开成窗口
>>> # or equivalently (and avoiding a copy),
>>> # out = out_unf.view(1, 2, 7, 8)
>>> (torch.nn.functional.conv2d(inp, w) - out).abs().max() # 使用unfold算出的out和使用conv2d算出的结果基本一致
tensor(1.9073e-06)
但是需要注意到的一点是,一般而言,使用 unfold
构造的卷积速度会慢不少。我个人感觉,这主要还是因为 unfold
构造卷积时,中间的形状转化造成了不必要的时间消耗。