ResNeXt

在前面的文章中,我们已经介绍了ResNet以及Inception的原理及其PyTorch实现。而今天要介绍的ResNeXt,正是在ResNet的基础上,结合Inception得到的。

在Inception中,其多个分支的结构是不同的,就像这样:
Alt text

ResNeXt的作者提议将每个分支的结构搞成一样的,以减少网络复杂性,便于扩展;再加上一个skip connection,就得到了ResNeXt block。

下面是论文中给出的一个ResNeXt block:

Alt text

它总共有32个分支,每个分支的结构都是完全相同的,且输入与输出之间做了跳连(skip connection)。

其实,上面的这个结构还是可以简化的,作者在论文中指出,以下3种结构是等价的:
Alt text

因此,为了方便,编码时我们就采用(c)结构。

现在把ResNeXt block的(c)结构单独拎出来:
Alt text

再把ResNet block拿过来:
Alt text

对比来看,两者在结构上的区别在于,前者的3x3卷积是分组卷积。在论文中,作者将分组数用Cardinality来表示,并且指出: increasing cardinality is more effective than going deeper or wider when we increase the capacity.

ResNet有许多版本,对应的ResNeXt也有许多不同版本。我们之前已经实现过ResNet-50,这里我们将实现ResNeXt-50。

ResNet50和ResNeXt-50的网络结构图如下:
Alt text

其中的C指的是Cardinality,也就是分组数。

可以看到,除了上面所提到的分组卷积外,ResNeXt-50 block的第一个1x1卷积和3x3卷积的输出特征图个数是ResNet-50对应的二倍。

鉴于此,我们可以很轻松的通过修改ResNet-50的代码来实现ResNeXt-50。

PyTorch 实现 ResNeXt

在实现之前,还有一点需要说明:

在上面的网络结构图中,出现了32x4d,这里的32指的是分组数,4指的是每个组内的卷积核个数。这两个参数的不同,ResNeXt-50的性能也会有所不同,作者经过实现发现,使用32x4d的搭配能够取得较好的效果,所以在这里,比”我们要实现ResNeXt-50”更准确的说法是”实现ResNeXt-50(32x4d)”。

首先实现ResNeXt block,也就是网络结构图中conv2到conv5都遵循的block:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class block(nn.Module):

#stride只针对第中间的3x3卷积
#1x1卷积的stride始终是1,1x1卷积只改变通道数,不改变特征图尺寸
def __init__(self,in_channels,out_channels,identity_downsample=None,stride=1,groups=1,width_per_group=64):
super().__init__()
#groups:分组数
#width_per_group:每个组内的卷积核个数
width=int(out_channels*(width_per_group/64))*groups#转换通道数
self.expansion=4
self.conv1=nn.Conv2d(in_channels,width,kernel_size=1,stride=1,padding=0)#不改变尺寸
self.bn1=nn.BatchNorm2d(width)
self.conv2=nn.Conv2d(width,width,kernel_size=3,stride=stride,padding=1,groups=groups)#stride=2,尺寸减半;stride=1,尺寸不变
self.bn2=nn.BatchNorm2d(width)
self.conv3=nn.Conv2d(width,out_channels*self.expansion,kernel_size=1,stride=1,padding=0)#不改变尺寸
self.bn3=nn.BatchNorm2d(out_channels*self.expansion)
self.relu=nn.ReLU()
self.identity_downsample=identity_downsample

def forward(self,x):
identity=x
x=self.conv1(x)
x=self.bn1(x)
x=self.relu(x)
x=self.conv2(x)
x=self.bn2(x)
x=self.relu(x)
x=self.conv3(x)
x=self.bn3(x)

if self.identity_downsample is not None:
identity=self.identity_downsample(identity)
#残差连接
x+=identity
x=self.relu(x)

return x

上面的代码是从我们之前发过的ResNet文章中复制并加以微小修改得到的。

具体地,添加了两个参数:groups,width_per_group。这两个参数就是上面提到的”分组数”和”每个组内的卷积核个数”。

width=int(out_channels(width_per_group/64))groups这句代码实现了将ResNet-50中第一个1x1卷积和3x3卷积的输出特征图个数增加一倍的操作,这样就得到了ResNeXt-50中相应的输出特征图个数。

当这两个参数采用默认值时,就是ResNet-50的block。

现在来实现完整的ResNeXt-50:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class ResNeXt(nn.Module):#每个残差block重复次数:[3,4,6,3]
def __init__(self,block,layers,image_channels,num_classes,groups=1,width_per_group=64):
super().__init__()

self.in_channels=64

#conv1
self.conv1=nn.Conv2d(image_channels,64,kernel_size=7,stride=2,padding=3)
self.bn1=nn.BatchNorm2d(64)
self.relu=nn.ReLU()

self.maxpool=nn.MaxPool2d(kernel_size=3,stride=2,padding=1)

#ResNet layers: conv2_x,conv3_x,conv4_x,conv5_x
self.layer1=self._make_layer(block,layers[0],out_channels=64,stride=1,groups=groups,width_per_group=width_per_group)#stride=1? True ;in_channels=out_channels*4? False
self.layer2=self._make_layer(block,layers[1],out_channels=128,stride=2,groups=groups,width_per_group=width_per_group)#stride=1? False ;in_channels=out_channels*4? False
self.layer3=self._make_layer(block,layers[2],out_channels=256,stride=2,groups=groups,width_per_group=width_per_group)#stride=1? False ;in_channels=out_channels*4? False
self.layer4=self._make_layer(block,layers[3],out_channels=512,stride=2,groups=groups,width_per_group=width_per_group)#stride=1? False ;in_channels=out_channels*4? False

self.avgpool=nn.AdaptiveAvgPool2d((1,1))
self.fc=nn.Linear(512*4,num_classes)

def forward(self,x):
# 输入x的shape: [4,3,224,224]

x=self.conv1(x)
x=self.bn1(x)
x=self.relu(x)
#print(x.shape)#torch.Size([4, 64, 112, 112]),经过conv1,尺寸减半

x=self.maxpool(x)
#print(x.shape)#torch.Size([4, 64, 56, 56]),经过池化,尺寸减半(严格来说,这个池化层属于conv2_i)
x=self.layer1(x)
#print(x.shape)#torch.Size([4, 256, 56, 56])#经过conv2_x,由于stride=1,尺寸不变
x=self.layer2(x)
#print(x.shape)#torch.Size([4, 512, 28, 28])#经过conv3_x,由于stride=2,尺寸减半
x=self.layer3(x)
#print(x.shape)#torch.Size([4, 1024, 14, 14])#经过conv4_x,由于stride=2,尺寸减半
x=self.layer4(x)
#print(x.shape)#torch.Size([4, 2048, 7, 7])#经过conv5_x,由于stride=2,尺寸减半


x=self.avgpool(x)
x=x.reshape(x.shape[0],-1)
x=self.fc(x)

return x

#每个layer(conv2_i,conv3_i,conv4_i,conv5_i)都有几个重复块,只需要对第一个重复块做downsample就能做跳连了,其余重复块的尺寸和通道数都不会变,因此直接跳连即可
def _make_layer(self,block,num_residual_blocks,out_channels,stride,groups,width_per_group):
identity_downsample=None
layers=[]
#只有conv2_x的stride=1,其余都为2
#原始输入需要做些改变,才能做残差连接
if stride !=1 or self.in_channels!=out_channels*4:
#print('stride=1?',stride==1,';in_channels=out_channels*4? ',self.in_channels==out_channels*4)
identity_downsample=nn.Sequential(nn.Conv2d(self.in_channels,out_channels*4,kernel_size=1,stride=stride),#stride=2时,尺寸减半,通道数变了,做downsample才能做跳连
nn.BatchNorm2d(out_channels*4))#stride=1时,尺寸不变,但通道数变了,此时也需要做downsample,这样才能做跳连
layers.append(block(self.in_channels,out_channels,identity_downsample,stride,groups,width_per_group))#stride=2,尺寸减半;或者stride=1,尺寸不变,但输出通道数变了。这也就是需要downsample的原因。
self.in_channels=out_channels*4

#其余重复块的stride采用默认值1,不改变尺寸
for i in range(num_residual_blocks-1):
layers.append(block(self.in_channels,out_channels,groups=groups,width_per_group=width_per_group))

return nn.Sequential(*layers)

这段代码同样是复制于ResNet-50,并做了一点修改。具体地,添加groups和width_per_group这两个参数,并在_make_layer方法中调用block类的地方传入这两个参数。

看,我们只是在ResNet-50的基础上做了一点点修改,就得到了ResNeXt-50。

如果你对上述代码中某些细节有困惑,请阅读我们之前推送的关于ResNet的文章。

最后,还是老规矩,来测试一下:
Alt text

参考: