进可暴力提性能,退可无损做压缩:结构重参数化技术综述
极市导读
结构重参数化技术是一种很巧妙的让模型同时具备"简单"的架构和"优秀"的参数的方法。本文介绍了两篇来自清华大学丁贵广老师团队和旷视科技的关于模型参数化技术的工作。 >>加入极市CV技术交流群,走在计算机视觉的最前沿
本文介绍的2篇论文都来自清华大学丁贵广老师团队和旷视科技。
模型参数主要指的是学得的参数(learnable params) 和其他在训练过程中得到的参数,如batch norm (BN) 累积得到的均值和标准差。则:一组参数和一个结构是一一对应的。举个最简单的例子,两个全连接层甲和乙之间如果没有非线性的话就可以转换为一个全连接层丙。设这两个全连接层的参数为矩阵A和B,输入为x,则输出为y=B(Ax)。我们可以构造C=BA,则有y=B(Ax)=Cx。那么C就是我们得到的全连接层的参数。则参数AB就对应结构甲乙,参数C就对应结构丙。
结构重参数的意思其实是说:结构重参数化(structural re-parameterization)指的是首先构造一系列结构 (一般用于训练),并将其参数等价转换为另一组参数 (一般用于推理或部署),从而将这一系列结构等价转换为另一系列结构。在现实场景中,训练资源一般是相对丰富的,我们更在意推理时的开销和性能,因此我们想要训练时的结构较大,具备好的某种性质(更高的精度或其他有用的性质,如稀疏性),转换得到的推理时结构较小且保留这种性质(相同的精度或其他有用的性质)。这样一来,训练时的结构对应一组参数,推理时我们想要的结构对应另一组参数;只要能把前者的参数等价转换为后者,就可以将前者的结构等价转换为后者。 结构A对应一组参数X,结构B对应一组参数Y,如果我们能将X等价转换为Y,就能将结构A等价转换为B。
The meaning of structural reparameterization is that the training-time model has a set of parameters while the inference-time model has another set, and we parameterize the latter with the parameters transformed from the former.
目录:
1 RepVGG: Making VGG-style ConvNets Great Again (CVPR 2021)
1.1 RepVGG原理分析
1.1.1 什么是VGG式模型?
1.1.2 从多分支架构到结构重参数
1.2 RepVGG代码解读2 RepMLP: Re-parameterizing Convolutions into Fully-connected Layers for Image Recognition (Arxiv)
2.1 RepMLP原理分析
2.1.1 深度学习模型的几个性质
2.1.2 RepMLP模块
2.1.3 如何将卷积等效成FC层?
1 RepVGG: Making VGG-style ConvNets Great Again (CVPR 2021)
论文地址:
https://arxiv.org/abs/2101.03697
开源预训练模型和代码 (PyTorch版):
https://github.com/DingXiaoH/RepVGG
本小节参考论文作者的博客:
丁霄汉:RepVGG:极简架构,SOTA性能,让VGG式模型再次伟大(CVPR-2021)
https://zhuanlan.zhihu.com/p/344324470
1.1 RepVGG原理分析:
1.1.1 什么是VGG式模型?
“VGG式”模型指的是:
1. 没有任何分支结构。即通常所说的plain或feed-forward架构。
2. 仅使用3x3卷积。
3. 仅使用ReLU作为激活函数。
下面用一句话介绍RepVGG模型的基本架构:将20多层3x3卷积堆起来,分成5个stage,每个stage的第一层是stride=2的降采样,每个卷积层用ReLU作为激活函数。
再用一句话介绍RepVGG模型的详细结构:RepVGG-A的5个stage分别有[1, 2, 4, 14, 1]层,RepVGG-B的5个stage分别有[1, 4, 6, 16, 1]层,宽度是[64, 128, 256, 512]的若干倍。这里的倍数是随意指定的诸如1.5,2.5这样的“工整”的数字,没有经过细调。
我们一般用TFLOPS (Tera FLoating-point Operations Per-second) 来描述某种操作的计算密度,下图1是不同大小卷积核卷积的运算量,时间和计算密度。可以发现3×3卷积的计算密度最大,可达1x1和5x5卷积的4倍。
作者认为,VGG式极简模型至少还有5大现实的优势:
1. 计算密度高: 3x3卷积非常快。在GPU上,3x3卷积的计算密度(理论运算量除以所用时间)可达1x1和5x5卷积的四倍。
2. 运算速度快: 单路架构非常快,因为并行度高。同样的计算量,“大而整”的运算效率远超“小而碎”的运算,尽管VGG模型看起来参数量大一点。比如VGG-16的计算量是EfficientNet-B3的8.4倍,但是在1080Ti的运算速度确是EfficientNet-B3的1.8倍!对GPU这类强并行运算设备友好。运行时间除了与FLOPs有关以外,还受其他2个因素的影响:Memory access cost (MAC):内存使用量,运行这个操作占用多少内存。比如多分支操作Inception,ResNet的MAC很大。Degree of parallelism:高并行度的模型在相同的FLOPs时比低并行度的模型速度快。
省内存: 单路架构省内存。例如,ResNet的shortcut虽然不占计算量,却增加了一倍的显存占用,因为多路架构的memory access cost (MAC)很大。
4. 灵活性: 单路架构灵活性更好,容易改变各层的宽度 (如剪枝) 。
5. 专一性: RepVGG主体部分只有一种算子:3x3卷积接ReLU。在设计专用芯片时,给定芯片尺寸或造价,我们可以集成海量的3x3卷积-ReLU计算单元来达到很高的效率,从图12中看出。别忘了,单路架构省内存的特性也可以帮我们少做存储单元。
1.1.2 从多分支架构到结构重参数
多分支架构(如ResNet,Inception,DenseNet,各种NAS架构)和单分支结构 (VGG式模型) 相比各有什么优劣呢?
多分支架构训练更加稳定且容易,比如ResNet的残差连接就能够很好地解决gradient vanishing的问题。但是推理速度慢,占用的内存大。 单分支结构推理速度快,节省内存。但是训练比较困难,训练的性能较低。
既然多分支架构是对训练有益的,而作者想要部署的模型是单路架构,作者提出解耦训练时和推理时架构。我们通常使用模型的方式是:
1. 训练一个模型
2. 部署这个模型
但在这里,作者提出一个新的做法:
1. 训练一个多分支模型
2. 将多分支模型等价转换为单路模型
3. 部署单路模型
这样就可以同时利用多分支模型训练时的优势(性能高) 和单路模型推理时的好处(速度快、省内存)。这里的关键显然在于这种多分支模型的构造形式和转换的方式。
如果能把一种结构的参数等价转换成另一种结构的参数,那么这个多分支模型就可以等价转换成单路模型。
作者的实现方式是在训练时,为每一个3x3卷积层添加平行的1x1卷积分支和恒等映射分支,构成一个RepVGG Block。这种设计是借鉴ResNet的做法,区别在于ResNet是每隔两层或三层加一分支,而作者是每层都加,如下图2所示。
图2:为每一个3x3卷积层添加平行的1x1卷积分支和恒等映射分支
训练完成后,我们对模型做等价转换,得到部署模型。根据卷积的线性(具体来说是可加性),设三个3x3卷积核分别是 ,有
。怎样利用这一原理将一个RepVGG Block转换为一个卷积呢?
其实非常简单,因为RepVGG Block中的:
所以我们只需要:
1. 把Identity转换为1x1卷积,只要构造出一个以单位矩阵为卷积核的1x1卷积即可。
2. 把1x1卷积等价转换为3x3卷积,只要用0填充即可。
下图3描述了这一转换过程。在这一示例中,输入和输出通道都是2,故3x3卷积的参数是4个3x3矩阵,1x1卷积的参数是一个2x2矩阵。注意三个分支都有BN层,其参数包括累积得到的均值及标准差和学得的缩放因子及bias。这并不会妨碍转换的可行性,因为推理时的卷积层和其后的BN层可以等价转换为一个带bias的卷积层(也就是通常所谓的“吸BN”)。比如卷积核分别是 , 。3种不同操作的BN参数分别是:
则输出特征是:
而BN操作可以等价为:
把BN的参数合并到卷积中的过程为:
合并之后卷积就成为了:
所以推理时的卷积层和其后的BN层可以等价转换为一个带bias的卷积层。对三分支分别“吸BN”之后(注意恒等映射可以看成一个“卷积层”,其参数是一个2x2单位矩阵!),将得到的1x1卷积核用0给pad成3x3。最后,三分支得到的卷积核和bias分别相加即可。这样,每个RepVGG Block转换前后的输出完全相同,因而训练好的模型可以等价转换为只有3x3卷积的单路模型。
从这一转换过程中,我们看到了 “结构重参数化”的实质:训练时的结构对应一组参数,推理时我们想要的结构对应另一组参数;只要能把前者的参数等价转换为后者,就可以将前者的结构等价转换为后者。
RepVGG模型的架构如下图4所示。大量使用3×卷积但是不使用Max-Pooling结构。RepVGG-A的5个stage分别有[1, 2, 4, 14, 1]层,RepVGG-B的5个stage分别有[1, 4, 6, 16, 1]层,宽度 width 是[64, 128, 256, 512]的若干倍。这里的倍数是随意指定的诸如1.5,2.5这样的“工整”的数字,没有经过细调。分类头和MLP-Mixer一致,为Global Average Pooling+FC层。
第1个stage分辨率最高,所以只使用1层。 最后1个stage的channel数最多,所以为了节约参数也只使用1层。
在不同阶段的通道数方面,作者采用了经典的配置64-128-256-512。与此同时,作者采用因子控制前四个阶段的通道,因子控制最后一个阶段的通道,通常 a" data-formula-type="inline-equation" style="">(我们期望最后一层具有更丰富的特征)。为避免大尺寸特征的高计算量,对于第一阶段的输出通道做了约束。基于此得到的不同大小的RepVGG模型见下图5。
RepVGG实验:
在1080Ti上测试,RepVGG模型的速度-精度相当出色。下图6给出了RepVGG与不同计算量的ResNet及其变种在精度、速度、参数量等方面的对比。可以看到:RepVGG表现出了更好的精度-速度均衡,比如
RepVGG-A0比ResNet18 Accuracy 高1.25%,推理速度快33%; RepVGG-A1比Resnet34 Accuracy 高0.29%,推理速度快64%; RepVGG-A2比ResNet50 Accuracy 高0.17%,推理速度快83%; RepVGG-B1g4比ResNet101 Accuracy 度高0.37%,推理速度快101%; RepVGG-B1g2比ResNet152 Accuracy 相当,推理速度快2.66倍。
注意,RepVGG取得超过EfficientNet和RegNet的性能并没有使用任何的NAS或繁重的人工迭代设计。
下图7是ImageNet上训练200 epochs的实验结果。RepVGG模型可以在200 epoch的时候达到80%的accuracy,这是plain模型首次在精度上达到SOTA指标。
RepVGG-A2比EfficientNet-B0精度高1.37%,推理速度快59%; RepVGG-B1比RegNetX-3.2GF精度高0.39%,推理速度稍快; RepVGG-B3比RegNetX-12GF精度相当,推理速快31%。
这也说明,在不同的架构之间用FLOPs来衡量其真实速度是欠妥的。例如,RepVGG-B2的FLOPs是EfficientNet-B3的10倍,但1080Ti上的速度是后者的2倍,这说明前者的计算密度是后者的20余倍。
另外一系列ablation studies和对比实验表明,结构重参数化是RepVGG模型性能出色的关键 (详见论文) 。
最后需要注明的是,RepVGG是为GPU和专用硬件设计的高效模型,追求高速度、省内存,较少关注参数量和理论计算量。在低算力设备上,可能不如MobileNet和ShuffleNet系列适用。
所以结构重参数的意思其实是说:训练时的结构对应一组参数,推理时我们想要的结构对应另一组参数;只要能把前者的参数等价转换为后者,就可以将前者的结构等价转换为后者。
1.2 RepVGG代码解读
代码链接:
https://github.com/DingXiaoH/RepVGG
测试模型:
python test.py [imagenet-folder with train and val folders] train [path to weights file] -a [model name]
支持模型类别:
RepVGG-A0, RepVGG-A1, RepVGG-A2, RepVGG-B0, RepVGG-B1, RepVGG-B1g2, RepVGG-B1g4, RepVGG-B2, RepVGG-B2g2, RepVGG-B2g4, RepVGG-B3, RepVGG-B3g2, RepVGG-B3g4
比如:
python test.py [imagenet-folder with train and val folders] train RepVGG-B2-train.pth -a RepVGG-B2
测试320分辨率输入:
python test.py [imagenet-folder with train and val folders] train RepVGG-D2se-200epochs-train.pth -a RepVGG-D2se -r 320
注意此时的超参数是train。
根据结构化参数,把训练时的模型转化成测试时的模型:
python convert.py [weights file of the training-time model to load] [path to save] -a [model name]
比如:
python convert.py RepVGG-B2-train.pth RepVGG-B2-deploy.pth -a RepVGG-B2
测试deploy版本模型:
python test.py [imagenet-folder with train and val folders] deploy RepVGG-B2-deploy.pth -a RepVGG-B2
注意此时的超参数是deploy。
训练:
python train.py -a RepVGG-A0 --dist-url 'tcp://127.0.0.1:23333' --dist-backend 'nccl' --multiprocessing-distributed --world-size 1 --rank 0 --workers 32 [imagenet-folder with train and val folders]
python test.py [imagenet-folder with train and val folders] train model_best.pth.tar -a RepVGG-A0
使用训练 (train) 模式的模型:
from repvgg import repvgg_model_convert, create_RepVGG_A0
train_model = create_RepVGG_A0(deploy=False)
train_model.load_state_dict(torch.load('RepVGG-A0-train.pth')) # or train from scratch
# do whatever you want with train_model
deploy_model = repvgg_model_convert(train_model, save_path='RepVGG-A0-deploy.pth')
# do whatever you want with deploy_model
使用部署 (deploy) 模式的模型:
deploy_model = create_RepVGG_A0(deploy=True)
deploy_model.load_state_dict(torch.load('RepVGG-A0-deploy.pth'))
# do whatever you want with deploy_model
常见问题:
1. 部署的模型是训练模型结构化参数之后的单路模型,那么结构化参数前后的2个模型输出一样吗?或者,训练模式的模型和部署模式的模型输出结果一致吗?
答:一致。
import torch
train_model = create_RepVGG_A0(deploy=False)
train_model.eval() # Don't forget to call this before inference.
deploy_model = repvgg_model_convert(train_model)
x = torch.randn(1, 3, 224, 224)
train_y = train_model(x)
deploy_y = deploy_model(x)
print(((train_y - deploy_y) ** 2).sum()) # Will be around 1e-10
2. 如何在其他任务上使用预训练好的RepVGG模型?
答:
第1步:在自己的数据集上finetune training-time RepVGG 模型。比如想要做一个分割模型,那要首先build一个PSPNet,导入预训练的RepVGG的权重,再在segmentation数据集上Fine-tune这个模型。
第2步:把这个Fine-tune好的模型转化成单路结构的部署模式的模型。伪代码如下:
# train_backbone = create_RepVGG_B2(deploy=False)
# train_backbone.load_state_dict(torch.load('RepVGG-B2-train.pth'))
# train_pspnet = build_pspnet(backbone=train_backbone)
# segmentation_train(train_pspnet)
# deploy_pspnet = repvgg_model_convert(train_pspnet)
# segmentation_test(deploy_pspnet)
RepVGG代码:
SE模块:
class SEBlock(nn.Module):
def __init__(self, input_channels, internal_neurons):
super(SEBlock, self).__init__()
self.down = nn.Conv2d(in_channels=input_channels, out_channels=internal_neurons, kernel_size=1, stride=1, bias=True)
self.up = nn.Conv2d(in_channels=internal_neurons, out_channels=input_channels, kernel_size=1, stride=1, bias=True)
self.input_channels = input_channels
def forward(self, inputs):
x = F.avg_pool2d(inputs, kernel_size=inputs.size(3))
x = self.down(x)
x = F.relu(x)
x = self.up(x)
x = torch.sigmoid(x)
x = x.view(-1, self.input_channels, 1, 1)
return inputs * x
RepVGG Block:
class RepVGGBlock(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size,
stride=1, padding=0, dilation=1, groups=1, padding_mode='zeros', deploy=False, use_se=False):
super(RepVGGBlock, self).__init__()
self.deploy = deploy
self.groups = groups
self.in_channels = in_channels
assert kernel_size == 3
assert padding == 1
padding_11 = padding - kernel_size // 2
self.nonlinearity = nn.ReLU()
if use_se:
self.se = SEBlock(out_channels, internal_neurons=out_channels // 16)
else:
self.se = nn.Identity()
if deploy:
self.rbr_reparam = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size, stride=stride,
padding=padding, dilation=dilation, groups=groups, bias=True, padding_mode=padding_mode)
else:
self.rbr_identity = nn.BatchNorm2d(num_features=in_channels) if out_channels == in_channels and stride == 1 else None
self.rbr_dense = conv_bn(in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size, stride=stride, padding=padding, groups=groups)
self.rbr_1x1 = conv_bn(in_channels=in_channels, out_channels=out_channels, kernel_size=1, stride=stride, padding=padding_11, groups=groups)
print('RepVGG Block, identity = ', self.rbr_identity)
def forward(self, inputs):
if hasattr(self, 'rbr_reparam'):
return self.nonlinearity(self.se(self.rbr_reparam(inputs)))
if self.rbr_identity is None:
id_out = 0
else:
id_out = self.rbr_identity(inputs)
return self.nonlinearity(self.se(self.rbr_dense(inputs) + self.rbr_1x1(inputs) + id_out))
如果是deploy模式,则使用单路结构计算,使用self.nonlinearity(self.se(self.rbr_reparam(inputs))),就是直接过一个3×3卷积输出。
如果是train模式,则使用多分支结构,计算3×3卷积,1×1卷积和Identity分支之和作为输出。
值得注意的是3×3卷积都配了SE Block。
下面这个函数就是按照论文的方法把1×1卷积和Identity操作转化成3×3卷积的:
def get_equivalent_kernel_bias(self):
kernel3x3, bias3x3 = self._fuse_bn_tensor(self.rbr_dense)
kernel1x1, bias1x1 = self._fuse_bn_tensor(self.rbr_1x1)
kernelid, biasid = self._fuse_bn_tensor(self.rbr_identity)
return kernel3x3 + self._pad_1x1_to_3x3_tensor(kernel1x1) + kernelid, bias3x3 + bias1x1 + biasid
def _pad_1x1_to_3x3_tensor(self, kernel1x1):
if kernel1x1 is None:
return 0
else:
return torch.nn.functional.pad(kernel1x1, [1,1,1,1])
最后返回的是2个量:kernel3x3 + self._pad_1x1_to_3x3_tensor(kernel1x1) + kernelid和bias3x3 + bias1x1 + biasid。分别代表这个等价的3×3卷积的权重和偏置。
其中,_fuse_bn_tensor这个函数的作用是“吸BN”,方法如上式2.3所示,为方便阅读将它重写如下:
最后返回的是kernel * t, beta - running_mean * gamma / std,就是上式的计算结果。值得注意的是:kernel是四维张量,而t是个1维向量,所以会t = (gamma / std).reshape(-1, 1, 1, 1)使其维度和kernel匹配上。
def _fuse_bn_tensor(self, branch):
if branch is None:
return 0, 0
if isinstance(branch, nn.Sequential):
kernel = branch.conv.weight
running_mean = branch.bn.running_mean
running_var = branch.bn.running_var
gamma = branch.bn.weight
beta = branch.bn.bias
eps = branch.bn.eps
else:
assert isinstance(branch, nn.BatchNorm2d)
if not hasattr(self, 'id_tensor'):
input_dim = self.in_channels // self.groups
kernel_value = np.zeros((self.in_channels, input_dim, 3, 3), dtype=np.float32)
for i in range(self.in_channels):
kernel_value[i, i % input_dim, 1, 1] = 1
self.id_tensor = torch.from_numpy(kernel_value).to(branch.weight.device)
kernel = self.id_tensor
running_mean = branch.running_mean
running_var = branch.running_var
gamma = branch.weight
beta = branch.bias
eps = branch.eps
std = (running_var + eps).sqrt()
t = (gamma / std).reshape(-1, 1, 1, 1)
return kernel * t, beta - running_mean * gamma / std
定义VGG模型:
class RepVGG(nn.Module):
def __init__(self, num_blocks, num_classes=1000, width_multiplier=None, override_groups_map=None, deploy=False, use_se=False):
super(RepVGG, self).__init__()
assert len(width_multiplier) == 4
self.deploy = deploy
self.override_groups_map = override_groups_map or dict()
self.use_se = use_se
assert 0 not in self.override_groups_map
self.in_planes = min(64, int(64 * width_multiplier[0]))
self.stage0 = RepVGGBlock(in_channels=3, out_channels=self.in_planes, kernel_size=3, stride=2, padding=1, deploy=self.deploy, use_se=self.use_se)
self.cur_layer_idx = 1
self.stage1 = self._make_stage(int(64 * width_multiplier[0]), num_blocks[0], stride=2)
self.stage2 = self._make_stage(int(128 * width_multiplier[1]), num_blocks[1], stride=2)
self.stage3 = self._make_stage(int(256 * width_multiplier[2]), num_blocks[2], stride=2)
self.stage4 = self._make_stage(int(512 * width_multiplier[3]), num_blocks[3], stride=2)
self.gap = nn.AdaptiveAvgPool2d(output_size=1)
self.linear = nn.Linear(int(512 * width_multiplier[3]), num_classes)
def _make_stage(self, planes, num_blocks, stride):
strides = [stride] + [1]*(num_blocks-1)
blocks = []
for stride in strides:
cur_groups = self.override_groups_map.get(self.cur_layer_idx, 1)
blocks.append(RepVGGBlock(in_channels=self.in_planes, out_channels=planes, kernel_size=3,
stride=stride, padding=1, groups=cur_groups, deploy=self.deploy, use_se=self.use_se))
self.in_planes = planes
self.cur_layer_idx += 1
return nn.Sequential(*blocks)
def forward(self, x):
out = self.stage0(x)
out = self.stage1(out)
out = self.stage2(out)
out = self.stage3(out)
out = self.stage4(out)
out = self.gap(out)
out = out.view(out.size(0), -1)
out = self.linear(out)
return out
optional_groupwise_layers = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26]
g2_map = {l: 2 for l in optional_groupwise_layers}
g4_map = {l: 4 for l in optional_groupwise_layers}
def create_RepVGG_A0(deploy=False):
return RepVGG(num_blocks=[2, 4, 14, 1], num_classes=1000,
width_multiplier=[0.75, 0.75, 0.75, 2.5], override_groups_map=None, deploy=deploy)
def create_RepVGG_A1(deploy=False):
return RepVGG(num_blocks=[2, 4, 14, 1], num_classes=1000,
width_multiplier=[1, 1, 1, 2.5], override_groups_map=None, deploy=deploy)
def create_RepVGG_A2(deploy=False):
return RepVGG(num_blocks=[2, 4, 14, 1], num_classes=1000,
width_multiplier=[1.5, 1.5, 1.5, 2.75], override_groups_map=None, deploy=deploy)
def create_RepVGG_B0(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[1, 1, 1, 2.5], override_groups_map=None, deploy=deploy)
def create_RepVGG_B1(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[2, 2, 2, 4], override_groups_map=None, deploy=deploy)
def create_RepVGG_B1g2(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[2, 2, 2, 4], override_groups_map=g2_map, deploy=deploy)
def create_RepVGG_B1g4(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[2, 2, 2, 4], override_groups_map=g4_map, deploy=deploy)
def create_RepVGG_B2(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[2.5, 2.5, 2.5, 5], override_groups_map=None, deploy=deploy)
def create_RepVGG_B2g2(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[2.5, 2.5, 2.5, 5], override_groups_map=g2_map, deploy=deploy)
def create_RepVGG_B2g4(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[2.5, 2.5, 2.5, 5], override_groups_map=g4_map, deploy=deploy)
def create_RepVGG_B3(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[3, 3, 3, 5], override_groups_map=None, deploy=deploy)
def create_RepVGG_B3g2(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[3, 3, 3, 5], override_groups_map=g2_map, deploy=deploy)
def create_RepVGG_B3g4(deploy=False):
return RepVGG(num_blocks=[4, 6, 16, 1], num_classes=1000,
width_multiplier=[3, 3, 3, 5], override_groups_map=g4_map, deploy=deploy)
def create_RepVGG_D2se(deploy=False):
return RepVGG(num_blocks=[8, 14, 24, 1], num_classes=1000,
width_multiplier=[2.5, 2.5, 2.5, 5], override_groups_map=None, deploy=deploy, use_se=True)
func_dict = {
'RepVGG-A0': create_RepVGG_A0,
'RepVGG-A1': create_RepVGG_A1,
'RepVGG-A2': create_RepVGG_A2,
'RepVGG-B0': create_RepVGG_B0,
'RepVGG-B1': create_RepVGG_B1,
'RepVGG-B1g2': create_RepVGG_B1g2,
'RepVGG-B1g4': create_RepVGG_B1g4,
'RepVGG-B2': create_RepVGG_B2,
'RepVGG-B2g2': create_RepVGG_B2g2,
'RepVGG-B2g4': create_RepVGG_B2g4,
'RepVGG-B3': create_RepVGG_B3,
'RepVGG-B3g2': create_RepVGG_B3g2,
'RepVGG-B3g4': create_RepVGG_B3g4,
'RepVGG-D2se': create_RepVGG_D2se, # Updated at April 25, 2021. This is not reported in the CVPR paper.
}
def get_RepVGG_func_by_name(name):
return func_dict[name]
只需要使用get_RepVGG_func_by_name这个函数然后传入对应的模型名称即可。
把模型转换成deploy模式:
def repvgg_model_convert(model:torch.nn.Module, save_path=None, do_copy=True):
if do_copy:
model = copy.deepcopy(model)
for module in model.modules():
if hasattr(module, 'switch_to_deploy'):
module.switch_to_deploy()
if save_path is not None:
torch.save(model.state_dict(), save_path)
return model
其中的switch_to_deploy函数会再调用get_equivalent_kernel_bias函数得到等效的3×3卷积的权重和偏置。
def switch_to_deploy(self):
if hasattr(self, 'rbr_reparam'):
return
kernel, bias = self.get_equivalent_kernel_bias()
self.rbr_reparam = nn.Conv2d(in_channels=self.rbr_dense.conv.in_channels, out_channels=self.rbr_dense.conv.out_channels,
kernel_size=self.rbr_dense.conv.kernel_size, stride=self.rbr_dense.conv.stride,
padding=self.rbr_dense.conv.padding, dilation=self.rbr_dense.conv.dilation, groups=self.rbr_dense.conv.groups, bias=True)
self.rbr_reparam.weight.data = kernel
self.rbr_reparam.bias.data = bias
for para in self.parameters():
para.detach_()
self.__delattr__('rbr_dense')
self.__delattr__('rbr_1x1')
if hasattr(self, 'rbr_identity'):
self.__delattr__('rbr_identity')
小结:
RepVGG借助结构重参数技术,不用NAS,不用attention,不用各种新颖的激活函数,甚至不用分支结构,只用3x3卷积和ReLU使得模型的推理速度提升不少,节约大量内存。是一种GPU端模型压缩的不错的选择。结构A对应一组参数X,结构B对应一组参数Y,如果我们能将X等价转换为Y,就能将结构A等价转换为B。
2 RepMLP: Re-parameterizing Convolutions into Fully-connected Layers for Image Recognition (Arxiv)
RepMLP论文地址:
https://arxiv.org/abs/2105.01883
开源预训练模型和代码 (PyTorch版):
DingXiaoH/RepMLP
https://github.com/DingXiaoH/RepMLP
2.1 RepMLP原理分析:
本文是MLP-Mixer后一天挂在Arxiv上的工作,也是使用纯MLP模型完成图像分类任务的工作。它借助重参数技术,将卷积与FC巧妙地融合,加强分类网络的性能。在ImageNet分类、人脸识别以及语义分割等任务(无论是否具有平移不变性)上均能涨点。结合FC层的全局表征能力和位置先验性质以及卷积层的局部先验性质,可以在大幅增加参数的同时不会造成推理速度的显著降低。
2.1.1 深度学习模型的几个性质
局部先验性质
卷积的局部先验性质的意思是: 卷积操作默认图片中的某个像素与周围的像素联系更加密切,而与距它较远的像素联系更少。所以卷积操作只处理某个局部邻域的信息。这一性质也称为归纳偏置 (inductive bias) 或者局部先验性质 (local prior)。FC层不具备这个能力。
全局建模能力
与其同时,捕获长距离依赖 (long-range dependency) 的能力,这一性质也称为全局建模能力 (global capacity) 。传统卷积拥有长距离依赖的能力的方法是使用更大的感受野,堆叠更多的卷积层。但是这也势必会导致计算十分低效,且造成优化困难。Transformer模型被用来解决这个问题,其中的self-attention层专门用来捕获长距离依赖,但是缺少了局部先验性质。FC层也具备这个能力,这很容易理解,因为FC层的输出与每个输入都有连接。
位置先验性质
位置先验性质的意思是:比如人通过人脸识别来解锁手机,脸部的照片一般情况下都在屏幕的中心,眼睛一般在图像上方,鼻子一般在中部。这一性质就是位置先验,也叫做positional perception,而一层卷积是不具备这个性质的。FC层具备这个能力,因为FC层的参数是位置相关的,而不像卷积操作,不同位置的参数是共享的。
综上,作者看上了全连接层具备的全局建模能力和位置先验性质,使用FC层取代卷积操作。此外,FC层的计算更高效,对于应用场景更友好。
但是,FC层却不具备局部先验性质,作者解决这个问题的办法是使用结构重参数技术 (Structural Re-parameterization Technique)。
2.1.2 RepMLP模块
所谓结构重参数的意思已经在RepVGG里面有讲到,就是:训练时的结构对应一组参数,推理时我们想要的结构对应另一组参数;只要能把前者的参数等价转换为后者,就可以将前者的结构等价转换为后者, 官方描述为:
The meaning of structural reparameterization is that the training-time model has a set of parameters while the inference-time model has another set, and we parameterize the latter with the parameters transformed from the former.
结构A对应一组参数X,结构B对应一组参数Y,如果我们能将X等价转换为Y,就能将结构A等价转换为B。注意结构重参数技术 整个过程只转换模型一次,就是训完以后把训练模型等效为部署模型的那个时候,转换完成后,训练的模型变成了全新的部署模型,训练的模型可以丢弃。
问:RepVGG是怎么使用结构重参数的?
答: 在训练模型时,为每一个3x3卷积层添加平行的1x1卷积分支和恒等映射分支,构成一个RepVGG Block。在部署 (deploy) 时,把1×1卷积分支和恒等映射分等效为3×3分支,权重weights和偏置bias 叠加在之前训练的3×3分支上面。
问:RepMLP是怎么使用结构重参数的?
答:在训练模型时,为每一个MLP层添加平行的卷积+BN分支,构成一个RepMLP Block。在部署(deploy) 时,把平行的卷积分支等效为MLP分支,使deploy模型只有MLP操作。
与RepVGG的一卷到底相比,RepMLP运行速度更快,并且具备全局建模能力和位置先验性质。
在CIFAR数据集上,简简单单的MLP模型即可取得与CNN非常接近的性能。通过将RepMLP插入到现有CNN中,我们在ImageNet数据集上提升ResNets模型精度达1.8%,在人脸识别任务上提升2.9%,在Cityscapes提升2.3%mIoU精度且具有更低的FLOPs。
本文的贡献是使得MLP层同时具备局部先验性质,全局建模能力和位置先验性质,使其适用于图像识别任务且不造成任何推理耗时增加。
RepMLP的架构如上图8所示。训练阶段的RepMLP由3部分构成:Global Perceptron、Partition Perceptron、Local Perceptron。
对于卷积层,假设特征图表示为,用字母 和 分别代表卷积和全连接层的权值。本文采用了PyTorch风格的数据排布与伪代码风格,比如卷积处理的数据流表示如下:
其中表示输出特征, 表示输出通道数, 表示pad的像素数量,表示卷积核 (暂时假设groups=1) 。为简单起见,我们还假设。
对于全连接层,假设P和Q为输入与输出维度,分别表示输入与输出,表示全连接层核,计算公式如下:
假设FC不会改变输入输出分辨率,即。我们采用RS (reshape)来表示仅仅改变张量形变而不会改变内存数据顺序的操作,它是一种cost-free的操作。输入首先会被展平 (Flatten) 为 个长度为的向量,即,然后乘以核得到输出,最后再RS为。为更好的阅读,在歧义的情况下我们忽略RS而简写为:
这种形式的全连接层无法充分利用图像的局部信息,并忽视了位置信息。
而且这种形式的全连接层所需要的参数也很大,假设,此时FC层的参数量高达10G,这样巨大的参数量是难以接受的,而且如上文所述,无法充分利用图像的局部信息。
所以作者提出了3种Global Perceptron 和 Partition Perceptron来节约参数量。
Global Perceptron
Global Perceptron是把特征图分成一样大小的部分来节约参数量,比如,的特征图可以被拆分为,我们将每个视作一个部分。假设表示期望部分的高和宽,则特征图的变化过程如下图9所示:
我们看到经过了Global Perceptron,维度为 的特征图最终通过PyTorch里面的reshape和permute操作变成了 。通过这种方式,参数量可以从下降到。
但是,强行把特征图分成不同的部分会打破不同部分之间的相关性。各个部分会被分开对待而忽视了它们原本的位置上的相关性。为对每个分区添加相关性,Global Perceptron采用了以下几个操作:
对每个分区采用均值池化得到1个像素。 将上述所得送入到BN+两层的MLP。 将上述所得进行reshape并与每个部分的特征相加。 得到的结果传入Partition Perceptron 和 Local Perceptron层。
则特征图维度的完整的变化过程如下图10所示,具体过程如图8所示。
Partition Perceptron
Partition Perceptron 包含FC与BN层,并以每个部分的特征作为输入。Global Perceptron的输出 将通过reshape、re-arrange、reshape等操作变为。作者进一步采用组卷及降低FC3的参数量,定义如下:
类似的,组形式的FC核 ,此时参数量可以减少g倍。尽管这种形式的FC不被某些框架(如Pytorch)支持,但我们可以采用卷积代替实现。它的实现包含三个步骤:
将输入特征 Reshape 为spatial size = 的特征; 采用groups = g 的卷积; 将上述所得特征 Reshape 为。
整个过程定义如下:
则特征图维度的完整的变化过程如下图11所示,具体过程如图8所示。
Local Perceptron
Local Perceptron将每个部分的特征经由几个卷积进行处理。所有卷积分支的输出与Partition Perceptron的输出相加作为最终的输出。特征图维度的完整的变化过程如上图12所示,具体过程如图8所示。
2.1.3 如何将卷积等效成FC层?
假设FC核为:,卷积核 ,我们期望构建满足:
注意到:对任意与同形状的核,MMUL的加法特征满足:
因此,只要可以构建与同形状的,我们就可以将F合并到并满足:
很明显,一定存在 (因为卷积可以视为稀疏版FC层)。
所以接下来要做的事情是如何把这个卷积操作 给等效成全连接操作 ,换句话讲,如何找到 。这个 的神奇之处就在于:给某一个张量乘以 ,就相当于是给这个张量的Reshape成的四维形式做卷积,结果再Reshape成二维形式。。
作者给出了一种方法,这也是本文我认为最妙的地方:
现在全连接操作是咋算的呢?计算形式是:
给上式插入一个Identity Matrix ,这个式子就变成了:
因为这里面都是矩阵乘法,多乘一个单位矩阵没啥关系。
前面说过:这个 的神奇之处就在于:给某一个张量乘以 ,就相当于是给这个张量的Reshape成的四维形式做卷积,结果再Reshape成二维形式。
那么2.10式是给 这个张量乘以 ,就相当于是给 这个张量的四维形式 做卷积,这里:
所以我们把矩阵乘法变成卷积,就有:
但是有一点不同, 的结果是2维的,而卷积的结果是4维度的 ,所以需要再结果Reshape成二维形式,所以
结合上式2.12,2.10就变为了:
这个绿到发光的部分正是我们要找的 。
所以:
2.14式就是我们要找的等效全连接操作 ,算法就是卷积核的等效FC核可以通过对恒等矩阵进行卷积并添加合适reshape得到。这个转换既便捷又可导。
PyTorch格式的伪代码如下:
Algorithm 1 PyTorch code for converting groupwsie conv into FC.
Input: C, h, w, g, O, conv kernel, conv bias
I = torch.eye(C * h * w // g).repeat(1, g).reshape(C * h * w // g, C, h, w)
fc kernel = F.conv2d(I, conv kernel, padding=conv kernel.size(2)//2, groups=g)
fc kernel = fc kernel.reshape(O * h * w // g, C * h * w).t() # Note the transpose
fc bias = conv bias.repeat interleave(h * w)
return: fc kernel, fc bias
下一步,按照上式1.3的做法,把BN的参数合并到卷积中:
这是因为:
式1.3是2D BN的融合方法,那么1D BN也是同理,融合进 里面。所以说按照2.14式的做法我们就可以把所有的卷积操作等效成FC了。
Experiment:
数据集:CIFAR10,ImageNet
超参数:
参数 | 值 |
---|---|
数据增广策略 | padding, RandomCrop, RandomFlip |
bs | 128 |
学习率变化 | cosine learning rate annealing |
CIFAR10实验:
这里作者构建了2个模型,PureMLP和ConvNet,如下图13所示:
图13:PureMLP和ConvNet
PureMLP由FC层 (Conv1×1)和RepMLP构成,图中的16×16,32×32代表分辨率。左右的改变其实就是左边的RepMLP层对标右面的3×3卷积。所有的RepMLP层都使用 。为了使左右2侧的计算量尽量接近,左侧3个stage的channel分别是16,32,64,而左侧3个stage的channel分别是32,64,128。结果如下图14所示:
可以看到:
纯MLP模型能够以52.8M FLPs达到91.11%的精度,不过该结果弱于Wide ConvNet;
作者随后进行了一些对照实验,转换前的计算量达到118.9M,高于包含卷积与BN,其计算量非常大,这说明了结构重参数的重要性。
移除Local Perceptron,模型精度下降8.5%,说明了局部先验的重要性。 移除Global Perceptron,模型精度下降1.5%,说明了全局建模的重要性。 替换FC3为卷积 (K=9, p=4),尽管其感受野更大,但仍造成精度下降3.5.%,说明了FC要比卷积更有力。
ImageNet实验:
超参数:
参数 | 值 |
---|---|
数据增广策略 | mixup, AutoAugment, Random Crop, RandomFlip |
bs | 256 |
学习率变化 | cosine learning rate annealing |
weight decay | e-4 |
采用ResNet作为基线模型并用于评估RepMLP作为传统ConvNet构建模型的性能。模块定义如下图19所示。假设ResNet的几个stage的特征依次是 。
只对stride=1的层替换了RepMLP,仅仅对 进行替换时,结果如下图16所示。 时RepMLP-Res50具有比ResNet50更少的参数量,更快的推理速度(快10%)。
前两行说明:当前深度学习框架对于组形式卷积支持程度并不够好,参数量提升59%,但推理速度仅下降了0.7%;更进一步的组形式优化有可能使得RepMLP更高效。
下图17是对不同层的特征进行替换时的性能变化,超参数设置为 。
采用RepMLP模块替换ResNet中的模块会导致轻微的速度下降,但精度出现了显著提升。比如,仅仅采用RepMLP替换 即可带来0.94%精度提升,参数量仅增加5M; 的替换可以取得最佳的均衡。
下图18为更高输入分辨率下不同模型的性能对比。
在320×320的高输入分辨率下,超参数设置为 。 意思是对于 使用group=8,而对 使用group=16。
得到的结论是:
相比同参数量的传统ConvNet,RepMLP-Res50的计算量更低、推理速度更快。比如,相比224×224输入的ResNet101,RepMLP-Res50仅需50%FLOPs,更少的参数量,推理速度快50%,也可以取得同等精度;当输入分辨率为320×320时,RepMLP-Res50在精度、速度以及FLOPs方面的优势更大。
提升RepMLP的参数量不会导致推理速度的大幅下降。比如,从RepMLP-Res50-g8/16到RepMLP-Res50-g4/8,参数量提升47%,但FLOPs仅提升3.6%,推理速度仅从311下降到305。这对于高吞吐量的大型服务器推理是非常有用的。
相比于EfficientNet (在端测设备效率较高而在GPU端是低效的),RepMLP-Res50在速度于精度方面表现更优。
除了前述模块改进外,作者还提供了一种更轻量化的高速版本如下图19所示,性能见下图20。
轻量版RepMLP取得了与原始ResNet50相当的性能(77.14 vs 77.19),但FLOPs降低30%,推理速度快55%,达到1074 examples per second。
小结:
RepMLP借助结构重参数技术,将MLP与卷积巧妙结合,不仅保留了MLP的全局建模能力和位置先验性质,还能融入卷积的局部先验性质。在ImageNet分类、人脸识别以及语义分割等任务(无论是否具有平移不变性)上均能涨点。作为模型压缩的方法,它可以在GPU端实现模型推理速度的提升。
总结:
为什么结构重参数化技术会work呢? 原作者给出了解释:
(1) 推理时的等价性不代表训练时的等价性。以RepVGG为例,3x3-BN + 1x1-BN + BN最后得到的结构就是一个3x3卷积。也就是说,最终得到那一组参数是OxCx3x3,直接训一个3x3卷积最终得到的也是OxCx3x3。但这两组参数只是形状相同而已,并不代表后者的性能跟前者一样。再举个最简单的例子,一个带BN的卷积就可以等价转换为一个带bias的卷积(即工业界常说的“吸BN”),总不能说训练时前者也等价于后者嘛。至于为什么前者比后者更好,这就是一个本质上非常复杂的训练动力学(training dynamics)问题了。这个问题的解决恐怕要依赖更多“打开深度学习的黑箱”之类的工作。
(2) 大就是猛,多就是好,大力出奇迹,越多越work。一般来说,加参数总是有好处的。但是既然不能不考虑推理开销,我们就希望加一些能在推理阶段去掉的参数,事实证明这样也是有好处的。
(3) 构造的结构提供了模型本身所缺乏的某种性质,为某些花式操作提供了空间。例如,VGG式直筒模型缺乏分支结构和短的路径,我们就构造shortcut,给它加上分支和短的路径,只不过这些结构只在训练时存在而已;通道剪枝问题中“记忆”(保持模型精度不降低)和“遗忘”(制造稀疏性) 耦合在一起 (同一组参数同时参与记忆和遗忘有关的损失函数),我们就引入额外的1x1卷积,让这些额外的结构去“遗忘”,避免这种耦合。
(4) 构造的结构增加了“多样化的链接”和更多的“训练时非线性”。在Diverse Branch Block的实验中,作者报告了一些有意思的发现。按理说1x1卷积的表征能力弱于3x3卷积,因为前者可以看作一个有很多参数为0的3x3卷积,但是1x1 + 3x3的性能却明显好于3x3 + 3x3,也就是说一个强结构加一个弱结构好于两个强结构相加;BN虽然推理时是线性的,但训练时是非线性的,在DBB的各个分支里去掉BN则效果减弱很多。
丁霄汉:结构重参数化:利用参数转换解耦训练和推理结构
https://zhuanlan.zhihu.com/p/361090497
结构重参数化技术是一种很巧妙的让模型同时具备"简单"的架构和"优秀"的参数的方法。要想训练出"优秀"的参数就需要构建复杂的多路架构,牺牲了简单架构的推理速度快的优势。而要保留简单架构的优点又会牺牲训练的参数。所以结构重参数化技术就是一种巧妙的使二者兼具的方法。
本文亮点总结
4.RepMLP借助结构重参数技术,将MLP与卷积巧妙结合,不仅保留了MLP的全局建模能力和位置先验性质,还能融入卷积的局部先验性质。
如果觉得有用,就请分享到朋友圈吧!