8.1 图像分类简述与 ResNet 源码分析
本章代码:https://github.com/zhangxiann/PyTorch_Practice/blob/master/lesson8/resnet_inference.py
这篇文章主要介绍了 图像分类的 inference,其中会着重介绍 ResNet。
模型概览
在torchvision.model中,有很多封装好的模型。
可以分类 3 类:
经典网络
alexnet
vgg
resnet
inception
densenet
googlenet
轻量化网络
squeezenet
mobilenet
shufflenetv2
自动神经结构搜索方法的网络
mnasnet
ResNet18 使用
以 ResNet 18 为例。
首先加载训练好的模型参数:
然后比较重要的是把模型放到 GPU 上,并且转换到eval模式:
在inference 时,主要流程如下:
代码要放在
with torch.no_grad():下。torch.no_grad()会关闭反向传播,可以减少内存、加快速度。根据路径读取图片,把图片转换为 tensor,然后使用
unsqueeze_(0)方法把形状扩大为$B \times C \times H \times W$,再把 tensor 放到 GPU 上 。模型的输出数据
outputs的形状是$1 \times 2$,表示batch_size为 1,分类数量为 2。torch.max(outputs,0)是返回outputs中每一列最大的元素和索引,torch.max(outputs,1)是返回outputs中每一行最大的元素和索引。这里使用
_, pred_int = torch.max(outputs.data, 1)返回最大元素的索引,然后根据索引获得 label:pred_str = classes[int(pred_int)]。
关键代码如下:
全部代码如下所示:
总结一下 inference 阶段需要注意的事项:
确保 model 处于 eval 状态,而非 trainning 状态
设置 torch.no_grad(),减少内存消耗,加快运算速度
数据预处理需要保持一致,比如 RGB 或者 rBGR
残差连接
以 ResNet 为例:
一个残差块有2条路径$F(x)$和$x$,$F(x)$路径拟合残差,不妨称之为残差路径;$x$路径为identity mapping恒等映射,称之为shortcut。图中的⊕为element-wise addition,要求参与运算的$F(x)$和$x$的尺寸要相同。
shortcut 路径大致可以分成2种,取决于残差路径是否改变了feature map数量和尺寸。
一种是将输入
x原封不动地输出。另一种则需要经过$1×1$卷积来升维或者降采样,主要作用是将输出与$F(x)$路径的输出保持
shape一致,对网络性能的提升并不明显。
两种结构如下图所示:
ResNet 中,使用了上面 2 种 shortcut。
网络结构
ResNet 有很多变种,包括 ResNet 18、ResNet 34、ResNet 50、ResNet 101、ResNet 152,网络结构对比如下:
ResNet 的各个变种,数据处理大致流程如下:
输入的图片形状是$3 \times 224 \times 224$。
图片经过
conv1层,输出图片大小为 $ 64 \times 112 \times 112$。图片经过
max pool层,输出图片大小为 $ 64 \times 56 \times 56 $。图片经过
conv2层,输出图片大小为 $ 64 \times 56 \times 56$。(注意,图片经过这个layer, 大小是不变的)图片经过
conv3层,输出图片大小为 $ 128 \times 28 \times 28$。图片经过
conv4层,输出图片大小为 $ 256 \times 14 \times 14$。图片经过
conv5层,输出图片大小为 $ 512 \times 7 \times 7$。图片经过
avg pool层,输出大小为 $ 512 \times 1 \times 1$。图片经过
fc层,输出维度为 $ num_classes$,表示每个分类的logits。
下面,我们称每个 conv 层为一个 layer(第一个 conv 层就是一个卷积层,因此第一个 conv 层除外)。
其中 ResNet 18、ResNet 34 的每个 layer 由多个 BasicBlock 组成,只是每个 layer 里堆叠的 BasicBlock 数量不一样。
而 ResNet 50、ResNet 101、ResNet 152 的每个 layer 由多个 Bottleneck 组成,只是每个 layer 里堆叠的 Bottleneck 数量不一样。
源码分析
我们来看看各个 ResNet 的源码,首先从构造函数开始。
构造函数
ResNet 18
resnet18 的构造函数如下。
[2, 2, 2, 2] 表示有 4 个 layer,每个 layer 中有 2 个 BasicBlock。
conv1为 1 层,conv2、conv3、conv4、conv5均为 4 层(每个 layer 有 2 个 BasicBlock,每个 BasicBlock 有 2 个卷积层),总共为 16 层,最后一层全连接层,$总层数 = 1+ 4 \times 4 + 1 = 18$,依此类推。
ResNet 34
resnet 34 的构造函数如下。
[3, 4, 6, 3] 表示有 4 个 layer,每个 layer 的 BasicBlock 数量分别为 3, 4, 6, 3。
ResNet 50
resnet 34 的构造函数如下。
[3, 4, 6, 3] 表示有 4 个 layer,每个 layer 的 Bottleneck 数量分别为 3, 4, 6, 3。
依此类推,ResNet 101 和 ResNet 152 也是由多个 layer 组成的。
_resnet()
上面所有的构造函数中,都调用了 _resnet() 方法来创建网络,下面来看看 _resnet() 方法。
可以看到,在 _resnet() 方法中,又调用了 ResNet() 方法创建模型,然后加载训练好的模型参数。
ResNet()
首先来看 ResNet() 方法的构造函数。
构造函数
构造函数的重要参数如下:
block:每个
layer里面使用的block,可以是BasicBlockBottleneck。num_classes:分类数量,用于构建最后的全连接层。
layers:一个 list,表示每个
layer中block的数量。
构造函数的主要流程如下:
判断是否传入
norm_layer,没有传入,则使用BatchNorm2d。判断是否传入孔洞卷积参数
replace_stride_with_dilation,如果不指定,则赋值为[False, False, False],表示不使用孔洞卷积。读取分组卷积的参数
groups,width_per_group。然后真正开始构造网络。
conv1层的结构是Conv2d -> norm_layer -> ReLU。conv2层的代码如下,对应于layer1,这个layer的参数没有指定stride,默认stride=1,因此这个layer不会改变图片大小:conv3层的代码如下,对应于layer2(注意这个layer指定stride=2,会降采样,详情看下面_make_layer的讲解):conv4层的代码如下,对应于layer3(注意这个layer指定stride=2,会降采样,详情看下面_make_layer的讲解):conv5层的代码如下,对应于layer4(注意这个layer指定stride=2,会降采样,详情看下面_make_layer的讲解):接着是
AdaptiveAvgPool2d层和fc层。最后是网络参数的初始:
卷积层采用
kaiming_normal_()初始化方法。bn层和GroupNorm层初始化为weight=1,bias=0。其中每个
BasicBlock和Bottleneck的最后一层bn的weight=0,可以提升准确率 0.2~0.3%。
完整的构造函数代码如下:
forward()
在 ResNet 中,网络经过层层封装,因此forward() 方法非常简洁。
数据变换大致流程如下:
输入的图片形状是$3 \times 224 \times 224$。
图片经过
conv1层,输出图片大小为 $ 64 \times 112 \times 112$。图片经过
max pool层,输出图片大小为 $ 64 \times 56 \times 56 $。对于
ResNet 18、ResNet 34(使用BasicBlock):图片经过
conv2层,对应于layer1,输出图片大小为 $ 64 \times 56 \times 56$。(注意,图片经过这个layer, 大小是不变的)图片经过
conv3层,对应于layer2,输出图片大小为 $ 128 \times 28 \times 28$。图片经过
conv4层,对应于layer3,输出图片大小为 $ 256 \times 14 \times 14$。图片经过
conv5层,对应于layer4,输出图片大小为 $ 512 \times 7 \times 7$。图片经过
avg pool层,输出大小为 $ 512 \times 1 \times 1$。
对于
ResNet 50、ResNet 101、ResNet 152(使用Bottleneck):图片经过
conv2层,对应于layer1,输出图片大小为 $ 256 \times 56 \times 56$。(注意,图片经过这个layer, 大小是不变的)图片经过
conv3层,对应于layer2,输出图片大小为 $ 512 \times 28 \times 28$。图片经过
conv4层,对应于layer3,输出图片大小为 $ 1024 \times 14 \times 14$。图片经过
conv5层,对应于layer4,输出图片大小为 $ 2048 \times 7 \times 7$。图片经过
avg pool层,输出大小为 $ 2048 \times 1 \times 1$。
图片经过
fc层,输出维度为 $ num_classes$,表示每个分类的logits。
在构造函数中可以看到,上面每个 layer 都是使用 _make_layer() 方法来创建层的,下面来看下 _make_layer() 方法。
_make_layer()
_make_layer()方法的参数如下:
block:每个
layer里面使用的block,可以是BasicBlock,Bottleneck。planes:输出的通道数
blocks:一个整数,表示该层
layer有多少个block。stride:第一个
block的卷积层的stride,默认为 1。注意,只有在每个layer的第一个block的第一个卷积层使用该参数。dilate:是否使用孔洞卷积。
主要流程如下:
判断孔洞卷积,计算
previous_dilation参数。判断
stride是否为 1,输入通道和输出通道是否相等。如果这两个条件都不成立,那么表明需要建立一个 1 X 1 的卷积层,来改变通道数和改变图片大小。具体是建立downsample层,包括conv1x1 -> norm_layer。建立第一个
block,把downsample传给block作为降采样的层,并且stride也使用传入的stride(stride=2)。后面我们会分析downsample层在BasicBlock和Bottleneck中,具体是怎么用的。改变通道数
self.inplanes = planes * block.expansion。在
BasicBlock里,expansion=1,因此这一步不会改变通道数。在
Bottleneck里,expansion=4,因此这一步会改变通道数。
图片经过第一个
block后,就会改变通道数和图片大小。接下来 for 循环添加剩下的block。从第 2 个block起,输入和输出通道数是相等的,因此就不用传入downsample和stride(那么block的stride默认使用 1,下面我们会分析BasicBlock和Bottleneck的源码)。
下面来看 BasicBlock 和 Bottleneck 的源码。
BasicBlock
构造函数
BasicBlock 构造函数的主要参数如下:
inplanes:输入通道数。
planes:输出通道数。
stride:第一个卷积层的
stride。downsample:从
layer中传入的downsample层。groups:分组卷积的分组数,使用 1
base_width:每组卷积的通道数,使用 64
dilation:孔洞卷积,为1,表示不使用 孔洞卷积
主要流程如下:
首先判断是否传入了
norm_layer层,如果没有,则使用BatchNorm2d。校验参数:
groups == 1,base_width == 64,dilation == 1。也就是说,在BasicBlock中,不使用孔洞卷积和分组卷积。定义第 1 组
conv3x3 -> norm_layer -> relu,这里使用传入的stride和inplanes。(如果是layer2,layer3,layer4里的第一个BasicBlock,那么stride=2,这里会降采样和改变通道数)。定义第 2 组
conv3x3 -> norm_layer -> relu,这里不使用传入的stride(默认为 1),输入通道数和输出通道数使用planes,也就是不需要降采样和改变通道数。
forward()
forward() 方法的主要流程如下:
x赋值给identity,用于后面的shortcut连接。x经过第 1 组conv3x3 -> norm_layer -> relu,如果是layer2,layer3,layer4里的第一个BasicBlock,那么stride=2,第一个卷积层会降采样。x经过第 1 组conv3x3 -> norm_layer,得到out。如果是
layer2,layer3,layer4里的第一个BasicBlock,那么downsample不为空,会经过downsample层,得到identity。最后将
identity和out相加,经过relu,得到输出。
注意,2 个卷积层都需要经过
relu层,但它们使用的是同一个relu层。
Bottleneck
构造函数
参数如下:
inplanes:输入通道数。
planes:输出通道数。
stride:第一个卷积层的
stride。downsample:从
layer中传入的downsample层。groups:分组卷积的分组数,使用 1
base_width:每组卷积的通道数,使用 64
dilation:孔洞卷积,为1,表示不使用 孔洞卷积
主要流程如下:
首先判断是否传入了
norm_layer层,如果没有,则使用BatchNorm2d。计算
width,等于传入的planes,用于中间的 $ 3 \times 3 $ 卷积。定义第 1 组
conv1x1 -> norm_layer,这里不使用传入的stride,使用width,作用是进行降维,减少通道数。定义第 2 组
conv3x3 -> norm_layer,这里使用传入的stride,输入通道数和输出通道数使用width。(如果是layer2,layer3,layer4里的第一个Bottleneck,那么stride=2,这里会降采样)。定义第 3 组
conv1x1 -> norm_layer,这里不使用传入的stride,使用planes * self.expansion,作用是进行升维,增加通道数。
forward()
forward() 方法的主要流程如下:
x赋值给identity,用于后面的shortcut连接。x经过第 1 组conv1x1 -> norm_layer -> relu,作用是进行降维,减少通道数。x经过第 2 组conv3x3 -> norm_layer -> relu。如果是layer2,layer3,layer4里的第一个Bottleneck,那么stride=2,第一个卷积层会降采样。x经过第 1 组conv1x1 -> norm_layer -> relu,作用是进行降维,减少通道数。如果是
layer2,layer3,layer4里的第一个Bottleneck,那么downsample不为空,会经过downsample层,得到identity。最后将
identity和out相加,经过relu,得到输出。
注意,3 个卷积层都需要经过
relu层,但它们使用的是同一个relu层。
总结
最后,总结一下。
BasicBlock中有 1 个 $3 \times 3 $卷积层,如果是layer的第一个BasicBlock,那么第一个卷积层的stride=2,作用是进行降采样。Bottleneck中有 2 个 $1 \times 1 $卷积层, 1 个 $3 \times 3 $ 卷积层。先经过第 1 个 $1 \times 1 $卷积层,进行降维,然后经过 $3 \times 3 $卷积层(如果是layer的第一个Bottleneck,那么 $3 \times 3 $ 卷积层的stride=2,作用是进行降采样),最后经过 $1 \times 1 $卷积层,进行升维 。
ResNet 18 图解
layer1
下面是 ResNet 18 ,使用的是 BasicBlock 的 layer1,特点是没有进行降采样,卷积层的 stride = 1,不会降采样。在进行 shortcut 连接时,也没有经过 downsample 层。

layer2,layer3,layer4
而 layer2,layer3,layer4 的结构图如下,每个 layer 包含 2 个 BasicBlock,但是第 1 个 BasicBlock 的第 1 个卷积层的 stride = 2,会进行降采样。在进行 shortcut 连接时,会经过 downsample 层,进行降采样和降维。

ResNet 50 图解
layer1
在 layer1 中,首先第一个 Bottleneck 只会进行升维,不会降采样。shortcut 连接前,会经过 downsample 层升维处理。第二个 Bottleneck 的 shortcut 连接不会经过 downsample 层。

layer2,layer3,layer4
而 layer2,layer3,layer4 的结构图如下,每个 layer 包含多个 Bottleneck,但是第 1 个 Bottleneck 的 $ 3 \times 3 $ 卷积层的 stride = 2,会进行降采样。在进行 shortcut 连接时,会经过 downsample 层,进行降采样和降维。

参考资料
如果你觉得这篇文章对你有帮助,不妨点个赞,让我有更多动力写出好文章。
我的文章会首发在公众号上,欢迎扫码关注我的公众号张贤同学。

最后更新于
这有帮助吗?