Interpretable and Accurate Fine-grained Recognition via Region Grouping
通过区域分组实现可解释且准确的细粒度识别。根据,特征图获得每个语义部分的分配图,再计算出每个语义的特征向量,形成特征矩阵,后续变换和分类。
文章目录
摘要
本文方法核心:在深度神经网络中集成基于区域的部分发现和属性。通过对象部分的分割以及识别它们对分类的贡献来解释结果。为了便于在没有直接监督的情况下学习对象部分,探索了出现对象部分的简单先验。当与基于区域的部分发现和归因相结合时,可产生保持较高准确性的可解释模型。
1 引言
尽管模型的解释可以在多个方面进行,但解释模型的至少一种方法是分割对象部分的有意义区域,并进一步确定其对决策的贡献。如何设计一个可解释的深层模型,能学着发现对象部分并评估其对视觉识别的重要性。
部分发现,即在没有显式监督信息的情况下学习目标部分,本身就很难。来自卷积网络的特征可用于将像素分组为一组视觉上相干的区域,从中可以选择辨别的子集进行识别。 希望仅以对象标签为指导,希望分组有助于找到视觉上截然不同的部分,并且选择过程将确定它们对分类的贡献。
基于区域的部分发现的主要挑战是,没有明确的监督信号来定义部分区域。 必须结合有关对象部分的先验知识以促进学习。 本文的核心创新是探索关于对象部分的简单先验:给定单个图像,部分的出现遵循Beta分布,例如大多数鸟类图像中很可能会出现鸟头。 这种简单的先验知识与基于区域的部分发现相结合,可以识别出有意义的对象部分,且结果可解释的深度模型仍然非常准确。
本文模型学习了对象部分的字典,可以将2D特征图分组为部分片段。这是通过将像素特征与学习字典中的部分表示进行比较完成的。从结果片段中合并基于区域的特征,然后通过注意力机制选择片段的子集进行分类。
在训练过程中,针对每个部分的出现强制执行先前的Beta分布,保证每个批处理中都是二分类的。 这是通过最大程度地减小部分在先发生与经验发生之间的Earth Mover距离来完成的。训练期间,模型仅受带正则化项的对象标签的监督。 在测试过程中,模型输出目标部分的分段,分段部分的重要性和预测的标签。 模型的解释是通过部分分割和其对分类的贡献来进行的。
在三个细粒度数据集上进行实验,获取可解释性和准确性:
- 可解释性,将模型中的输出区域片段与带注释的对象部分进行了比较。在较小数据集上,局部定位误差很小。
- 准确性,用于细粒度分类的标准度量
2 相关研究
深度网络的探索
许多方法专注于开发激活图和/或滤波器权重的可视化工具。其他工作试图在输入图像中识别区分区域。量化基准,将网络单元的激活与人工注释的概念蒙版进行比较。、
深度模型的可解释性
许多研究开发了可通过其设计解释的深层模型。 或者,可以为可移植模型设计新的网络体系结构。
本文模型试图明确编码对象部分的概念,和以往工作的不同:
- 采用感知分组以提供基于图像段的解释
- 学习是通过对象部分出现之前的强先验来进行正则化的
部分发现
bounding box、annotations、弱监督/无监督、注意力。
本文研究试图找到部分并确定它们对细粒度分类的重要性,但考虑了对象部分出现的显式正则化
弱监督语义分割
探索了新颖的正则化方法来学习分割对象部分。考虑了细粒度分类的背景下弱监督的零件分割。还探索了一种分厂不同的部分出现的先验。
基于区域的识别
模型将分割和分类组合成一个深层模型,从而链接到基于区域的识别或更广泛的组成学习的努力。
本文模型对CNN特征进行分组。以前的工作都没有关注分组的质量,因此不能直接用于解释。
3 方法
关键假设是,可通过对一组图像特征 X 1 : N X_{1:N} X1:N中每个部分 d k d_k dk发生强制执行先验分布来规范学习:给定 X 1 : N X_{1:N} X1:N,令 p ( d k ∣ X 1 : N ) p(d_k|X_{1:N}) p(dk∣X1:N)为部分 d k d_k dk在集合 X 1 : N X_{1:N} X1:N中出现的条件概率。假设 p ( d k ∣ X 1 : N ) p(d_k|X_{1:N}) p(dk∣X1:N)遵循一个U形分布,其作用类似于概率二进制开关,可以控制“开”和“关”的概率。(不知道什么意思)
3.1 部分分割和正则化
部分分配
相似度投影单元: ( X , D ) → Q (X,D)\to Q (X,D)→Q
每个通道用来生成分配到某个语义的可能性
发现部分
平滑处理更有效,平滑操作有助于消除特征图上的异常值。
部分检测器定义为 t k = max i j G ∗ Q k t_k=\max_{ij}G∗Q^k tk=maxijG∗Qk,其中G是2D高斯核,∗是卷积运算。 t k t_k tk在 ( 0 , 1 ) (0,1) (0,1)的范围内。 此外,将k个部分检测器的输出串联到所有部分的出现向量 τ = [ t 1 , t 2 , . . . , t K ] T ∈ ( 0 , 1 ) K τ= [t_1,t_2,...,t_K] ^T∈(0,1)^K τ=[t1,t2,...,tK]T∈(0,1)K上。
正则
关键思想是规范每个部分的出现,通过强制部分出现的经验分布和先验U形Beta分布对齐来完成的。
给定 N N N个样本,串联所有向量 τ n τ_n τn来估计经验分布 p ( d k ∣ X 1 : N ) p(d_k|X_{1:N}) p(dk∣X1:N)形成矩阵 T = [ τ 1 , τ 2 , . . . , τ N ] ∈ ( 0 , 1 ) K × N T = [τ_1,τ_2,...,τ_N]\in(0,1)^{K×N} T=[τ1,τ2,...,τN]∈(0,1)K×N。假设 p ^ ( d k ∣ X 1 : N ) \hat p(d_k|X_{1:N}) p^(dk∣X1:N)已知(比如,服从Beta分布),根据以下公式计算Earth Mover距离:
在小批量训练中,可以通过将积分替换为小批量内样本的总和来计算该距离,从而得出 F − 1 F^{-1} F−1和 F ^ − 1 \hat F^{-1} F^−1之间的L1距离。 在实践中发现,使用对数函数重新调整CDF的逆很有帮助,从而提高了训练的稳定性。
其中 τ k ∗ τ^∗_k τk∗是 T T T(大小N)的第k行向量的排序版本(升序),[τ∗ k] i是τ∗ k的第i个元素。 是为数值稳定性添加的一个小值。使用对数重定标度可以克服由等式1中的softmax函数引入的梯度消失问题。即使部分 d k d^{k} dk远离当前批处理中的所有特征向量,即等式1中的 q i j k q^k_{ij} qijk值也很小,由于重新缩放, d k d_k dk仍然可以接收非零梯度。
3.2 区域特征提取与归因
z
k
z_k
zk是来自分配给部件
d
k
d_k
dk的像素的区域特征。将
z
k
z_k
zk组合在一起,获得区域特征集
Z
=
[
z
1
,
z
2
,
.
.
.
,
z
K
]
∈
R
D
×
K
Z = [z_1,z_2,...,z_K]\in R^{D×K}
Z=[z1,z2,...,zK]∈RD×K。 使用具有几个残差块的子网
f
z
f_z
fz进一步变换
Z
Z
Z,变换后的特征为
f
z
(
Z
)
f_z(Z)
fz(Z)。
此外,在
Z
Z
Z上方附加了注意模块,以预测每个区域的重要性。 这是通过子网络
f
f
f实现的,该子网络由
a
=
s
o
f
t
m
a
x
(
f
a
(
Z
T
)
)
a = softmax(f_a(Z^T))
a=softmax(fa(ZT))给出,结果将进一步用于分类。
3.3 基于注意力的分类
a a a为区域特征 Z Z Z的调制器, a a a中的较大值表示一个更重要的分类区域
根据 a a a可以回溯特征图上每个像素的贡献,,通过使用 Q T a Q^Ta QTa来完成, Q ∈ R K × H W Q\in R^{K×HW} Q∈RK×HW。
4 实验
cub上效果才87.3%,对细粒度分类而言一般般
局限性和讨论
模型在iNaturalist上失败了。模型可能无法将像素分组为部分区域,有时会产生不正确的显着性贴图。在iNaturalist上使用超过5K的细粒度类别时,U形分布可能无法描述零件的出现。模型不对部分之间的相互作用进行建模,而是需要中等到较大的批量来估计部分出现的经验分布。 因此,未来方向是探索对象部分的更好先验。
5 结论
提出了一种可解释的深度模型。模型利用了对象部分出现的先验,并将基于区域的零件发现和归因集成到了深度网络中。 经过图像级别标签的训练,可以预测对象部分的分配图,部分区域和对象标签的注意图,证明了在对象分类和对象零件定位方面的出色结果。
6 代码
代码中居然没有预训练部分,模型直接修改了resnet的结构。主要是下面的:
# import libs
import math
import numpy as np
import torch
import torch.nn as nn
class GroupingUnit(nn.Module):
def __init__(self, in_channels, num_parts):
super(GroupingUnit, self).__init__()
self.num_parts = num_parts
self.in_channels = in_channels
# params
self.weight = nn.Parameter(torch.FloatTensor(num_parts, in_channels, 1, 1))
self.smooth_factor = nn.Parameter(torch.FloatTensor(num_parts))
def reset_parameters(self, init_weight=None, init_smooth_factor=None):
if init_weight is None:
# msra init
nn.init.kaiming_normal_(self.weight)
self.weight.data.clamp_(min=1e-5)
else:
# init weight based on clustering
assert init_weight.shape == (self.num_parts, self.in_channels)
with torch.no_grad():
self.weight.copy_(init_weight.unsqueeze(2).unsqueeze(3))
# set smooth factor to 0 (before sigmoid)
if init_smooth_factor is None:
nn.init.constant_(self.smooth_factor, 0)
else:
# init smooth factor based on clustering
assert init_smooth_factor.shape == (self.num_parts,)
with torch.no_grad():
self.smooth_factor.copy_(init_smooth_factor)
def forward(self, inputs):
assert inputs.dim() == 4
# 0. store input size
batch_size = inputs.size(0)
in_channels = inputs.size(1)
input_h = inputs.size(2)
input_w = inputs.size(3)
assert in_channels == self.in_channels
# 1. generate the grouping centers
grouping_centers = self.weight.contiguous().view(1, self.num_parts, self.in_channels).expand(batch_size,
self.num_parts,
self.in_channels)
# 2. compute assignment matrix
# - d = -\|X - C\|_2 = - X^2 - C^2 + 2 * C^T X
# C^T X (N * K * H * W)
inputs_cx = inputs.contiguous().view(batch_size, self.in_channels, input_h * input_w)
cx_ = torch.bmm(grouping_centers, inputs_cx)
cx = cx_.contiguous().view(batch_size, self.num_parts, input_h, input_w)
# X^2 (N * C * H * W) -> (N * 1 * H * W) -> (N * K * H * W)
x_sq = inputs.pow(2).sum(1, keepdim=True)
x_sq = x_sq.expand(-1, self.num_parts, -1, -1)
# C^2 (K * C * 1 * 1) -> 1 * K * 1 * 1
c_sq = grouping_centers.pow(2).sum(2).unsqueeze(2).unsqueeze(3)
c_sq = c_sq.expand(-1, -1, input_h, input_w)
# expand the smooth term
beta = torch.sigmoid(self.smooth_factor)
beta_batch = beta.unsqueeze(0).unsqueeze(2).unsqueeze(3)
beta_batch = beta_batch.expand(batch_size, -1, input_h, input_w)
# assignment = softmax(-d/s) (-d must be negative)
assign = (2 * cx - x_sq - c_sq).clamp(max=0.0) / beta_batch
assign = nn.functional.softmax(assign, dim=1) # default dim = 1
# 3. compute residual coding
# NCHW -> N * C * HW
x = inputs.contiguous().view(batch_size, self.in_channels, -1)
# permute the inputs -> N * HW * C
x = x.permute(0, 2, 1)
# compute weighted feats N * K * C
assign = assign.contiguous().view(batch_size, self.num_parts, -1)
qx = torch.bmm(assign, x)
# repeat the graph_weights (K * C) -> (N * K * C)
c = grouping_centers
# sum of assignment (N * K * 1) -> (N * K * K)
sum_ass = torch.sum(assign, dim=2, keepdim=True)
# residual coding N * K * C
sum_ass = sum_ass.expand(-1, -1, self.in_channels).clamp(min=1e-5)
sigma = (beta / 2).sqrt()
out = ((qx / sum_ass) - c) / sigma.unsqueeze(0).unsqueeze(2)
# 4. prepare outputs
# we need to memorize the assignment (N * K * H * W)
assign = assign.contiguous().view(
batch_size, self.num_parts, input_h, input_w)
# output features has the size of N * K * C
outputs = nn.functional.normalize(out, dim=2)
outputs_t = outputs.permute(0, 2, 1)
# generate assignment map for basis for visualization
return outputs_t, assign
# name
def __repr__(self):
return self.__class__.__name__ + ' (' \
+ str(self.in_channels) + ' -> ' \
+ str(self.num_parts) + ')'
模型:
class ResNet(nn.Module):
def __init__(self, block, layers, num_classes=1000, num_parts=32):
super(ResNet, self).__init__()
# model params
self.inplanes = 64
self.n_parts = num_parts
self.num_classes = num_classes
# modules in original resnet as the feature extractor
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.layer1 = self._make_layer(block, 64, layers[0])
self.layer2 = self._make_layer(block, 128, layers[1], stride=2)
self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
# the grouping module
self.grouping = GroupingUnit(256 * block.expansion, num_parts)
self.grouping.reset_parameters(init_weight=None, init_smooth_factor=None)
# post-processing bottleneck block for the region features
self.post_block = nn.Sequential(
Bottleneck1x1(1024, 512, stride=1, downsample=nn.Sequential(
nn.Conv2d(1024, 2048, kernel_size=1, stride=1, bias=False),
nn.BatchNorm2d(2048))),
Bottleneck1x1(2048, 512, stride=1),
Bottleneck1x1(2048, 512, stride=1),
Bottleneck1x1(2048, 512, stride=1),
)
# an attention for each classification head
self.attconv = nn.Sequential(
Bottleneck1x1(1024, 256, stride=1),
Bottleneck1x1(1024, 256, stride=1),
nn.Conv2d(1024, 1, kernel_size=1, stride=1, padding=0, bias=True),
nn.BatchNorm2d(1),
nn.ReLU(),
)
# the final batchnorm
self.groupingbn = nn.BatchNorm2d(2048)
# linear classifier for each attribute
self.mylinear = nn.Linear(2048, num_classes)
# initialize convolutional layers with kaiming_normal_, BatchNorm with weight 1, bias 0
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
elif isinstance(m, nn.BatchNorm2d):
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)
# initialize the last bn in residual blocks with weight zero
for m in self.modules():
if isinstance(m, Bottleneck) or isinstance(m, Bottleneck1x1):
nn.init.constant_(m.bn3.weight, 0)
elif isinstance(m, BasicBlock):
nn.init.constant_(m.bn2.weight, 0)
# layer generation for resnet backbone
def _make_layer(self, block, planes, blocks, stride=1):
downsample = None
if stride != 1 or self.inplanes != planes * block.expansion:
downsample = nn.Sequential(
nn.Conv2d(self.inplanes, planes * block.expansion,
kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(planes * block.expansion),
)
layers = []
layers.append(block(self.inplanes, planes, stride, downsample))
self.inplanes = planes * block.expansion
for i in range(1, blocks):
layers.append(block(self.inplanes, planes))
return nn.Sequential(*layers)
def forward(self, x):
# the resnet backbone
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
# grouping module upon the feature maps outputed by the backbone
region_feature, assign = self.grouping(x)
region_feature = region_feature.contiguous().unsqueeze(3)
# generate attention
att = self.attconv(region_feature)
att = F.softmax(att, dim=2)
# non-linear layers over the region features
region_feature = self.post_block(region_feature)
# attention-based classification
# apply the attention on the features
out = region_feature * att
out = out.contiguous().squeeze(3)
# average all region features into one vector based on the attention
out = F.avg_pool1d(out, self.n_parts) * self.n_parts
out = out.contiguous().unsqueeze(3)
# final bn
out = self.groupingbn(out)
# linear classifier
out = out.contiguous().view(out.size(0), -1)
out = self.mylinear(out)
return out, att, assign