在 CV 中,我们常见的任务就是对输入的图像进行分类。在分类层常见的形式是 backbone 特征提取网络之后接一个或多个全连接层来得到标签分数。我们可以用卷积层来代替全连接层。接下来,我们看下如何在下面两种场景下使用卷积层作为输出层:
- 图像分类使用卷积层
- 目标检测使用卷积层
1. 图像分类使用卷积层
在使用卷积神经网络进行图像分类任务时,我们经常需要在网络的最后接一个线性层,作用就是为了得到图像的多标签的预测结果。例如,我们得到的形状为 (2, 3, 4, 4),为了得到其 10 个标签分数,我们会将其变成 (2, 48) 并送入到线性层得到 (2, 10) 的预测结果。
import torch.nn as nn import torch def test01(): inputs = torch.randn(2, 3, 4, 4) # flattern 特征图 inputs = inputs.reshape(inputs.size(0), -1) # 送入全连接得到预测 out = nn.Linear(48, 10)(inputs) # 输出形状: (2, 10) print(out.shape) if __name__ == '__main__': test01()
那么,如何将线性层换成卷积层呢?特征图大小为 4×4,我们就使用 10 个大小为 4×4 的卷积核,就会得到 10 个预测标签。
import torch.nn as nn import torch def test02(): inputs = torch.randn(2, 3, 4, 4) # 使用卷积层代替全连接层得到 10 个标签输出 out = nn.Conv2d(in_channels=3, out_channels=10, kernel_size=4, stride=1)(inputs) # 输出形状: (2, 10, 1, 1) print(out.shape) if __name__ == '__main__': test02()
我们了解了如何将全连接层替换成卷积层。假设输入一副图像我们得到了 7x7x512 的特征图,接下来用于识别输入图像的标签。
如果使用全连接层的话,我们会将输出的特征图展开成 7x7x512 的向量,该向量中就包含了 backbone 网络从输入图像中提取到的特征。线性层则就学习该特征的分布,一般会使用多层线性层+非线性激活函数用于加强该部分的网络对特征分布的学习。
在这个过程中,如果训练图像中存在 “狗”,但是狗在不同的输入图像中位置不一样,生成的 7x7x512 的向量是不同的。也就是说,线性层的学习会受到特征位置的影响。
有时,我们就想,管它特征在哪,只要具备了 “狗” 的特征不就行了么?此时,我们就可以将线性层替换为卷积层。例如上面的例子中,得到的输入图像的特征图为 7x7x512,我们直接用 10 个 7x7x512 的卷积核,其思想就有点像(假设标签有大象、狗、狮子等等):
- 我们用 10 个卷积核中的第 1 个卷积核,直接对得到的 7x7x512 特征图进行卷积,得到一个数字,如果该数字较大,则说明图像中可能包含 “大象” 这个标签的特征;
- 我们用 10 个卷积核中的第 2 个卷积核,直接对得到的 7x7x512 特征图进行卷积,得到一个数字,该数字越大,越说明图像中可能包含 “狗” 这个标签的特征;
- … 以此类推
在上面的提到的 10 个卷积核,每一个卷积核可以理解为它被训练出来专门用于感知不同动物特征的工具,最终得到一个值,用于表示感知到的强度,值越大,说明它认为某个类别的标签的可能性就越大。
从这里也可以看到,使用卷积层代替线性层能够忽略特征位置的影响,也能够提升模型的泛化能力。
2. 目标检测使用卷积层
目标检测的一种思路是将图像分成一块一块的,逐个块去检测是否有物体、有啥物体、以及物体的边框。例如:输入图像经过 backbone 网络得到了 7x7x512 的输出,该数据可以理解为将原始输入图像分成了 7×7=49 个小块,512 为每一个小块提取到的特征。我们现在想去预测这 49 个块的属于各个标签分数。
我们可以使用全连接层,思路是将 7x7x512 展平经过一个或者多个全连接层,得到 490 维度的输出,最后将其 reshape (7x7x10),得到每个块的 10 个类别的输出分数,如下代码所示:
def test01(): # 输出特征图 outputs = torch.randn(2, 512, 7, 7) # 全连接层计算每个位置分数 linear = nn.Linear(in_features=512 * 7 * 7, out_features=490) outputs3 = outputs.reshape(outputs.size(0), -1) # torch.Size([2, 490]) output3 = linear(outputs3) output3 = output3.reshape(2, 10, 7, 7) print(output3.shape)
程序输出结果:
torch.Size([2, 10, 7, 7])
这种做法,可以理解为从整个输入图像的角度,让线性层去学习特征的分布。当学习到了满足那些分布的块属于那些类别时,就可以得到每个块的类别输出分数。这种方式的不足主要有两点:
- 参数量较大
- 如果输入图像大小发生变化,那么将无法输入到全连接层
我们仍然使用全连接层,更准确的理解应该是局部连接层,因为我们只输入一个块内的特征到线性层。但是换个思路。我们逐个将每个块的 512 个特征送入到一个全连接层,逐个进行预测。这个思路才有点像将输入图像分成多个不同的块,让全连接层去学习这些不同的块。如下代码所示:
def test02(): # 输出特征图 outputs = torch.randn(2, 512, 7, 7) # 全连接层计算每个位置分数 linear = nn.Linear(in_features=512, out_features=10) # 取出 7x7=49 个图像位置向量 inputs = [outputs[:, :, i, j] for i in range(outputs.size(2)) for j in range(outputs.size(3))] # 对每一个位置进行分类评分 results = [linear(input) for input in inputs] # 将 [shape(2, 10), shape(2, 10) ...] 处理成 torch.Size([2, 10, 7, 7]) # torch.Size([49, 2, 10]) outputs2 = torch.stack(results, dim=0) # torch.Size([2, 10, 49]) outputs2 = outputs2.permute(1, 2, 0) # size: torch.Size([2, 10, 7, 7]) size = outputs2.size()[:2] + outputs.size()[2:] # torch.Size([2, 10, 7, 7]) outputs2 = outputs2.reshape(size) print(outputs2.shape)
输出结果和 test01 函数一样 torch.Size([2, 10, 7, 7])。但是其参数量减少很多,并且当输入图像形状发生变化,得到的结果为 (2, 512, H, W),虽然 H、W 不确定但是上面代码依然能够处理。不足的地方在于:
- 写起来麻烦一点(不算啥问题)
- 需要多次将不同的块送入到全连接层做预测
另外一种思路是将全连接层直接替换为卷积层,写起来也简便,参数量和 test02 一样,也能适应不同形状的图像输入。示例代码:
# 使用卷积核计算每个位置的分数 def test03(): # 输出特征图 outputs = torch.randn(2, 512, 7, 7) # 对每个位置特征图计算类别分数 conv2d = nn.Conv2d(in_channels=512, out_channels=10, kernel_size=1) outputs = conv2d(outputs) print(outputs.shape)
程序输出结果和前两个函数一样。我们通过使用 10个 1x1x512 的卷积核在图像上进行滑动,就可以得到每一个块的 10 个类别分数。每个卷积核被学习用来识别对应的 512 个特征包含某个标签的概率。其实这里的 1×1 卷积核本质上和 test02 一样,也可以理解为一个线性层。