SSD的损失函数与region proposal方法有个明显的区别,在于SSD并不是针对所有的检测器计算loss。SSD会用一种匹配策略给每个检测器分配一个真实标签(背景为0,前景为对应的物体类别)。指定的标签为前景的预测器称为正样本(正样本全部计算loss)。标签为背景的预测器是负样本,并不是所有的负样本都用来计算loss(原因是每张图片中负样本的数量远远多于正样本,如果全部计算loss,则负样本的loss会主导整个loss)。此时按照预设正负样本比例(论文中为0.3),挑选出一定数量的负样本.
对于负样本的挑选,论文中称之为"困难样本挖掘",其实就是对负样本按照loss大小排序,选择前n个loss大的负样本进行梯度更新。
综上,ssd的最终loss就是挑选的正负样本的总loss。
这部分代码在utils.py的match函数中
论文中的说法是分为两步:
总体目标损失函数是位置损失(loc)和置信损失(conf)的加权和:
其中N是匹配的默认框的数量(就是正负样本的数量之和),位置损失是预测框(l)和真实标签值框(g)参数之间的smooth-L1损失。 类似于Faster R-CNN .置信损失是softmax对多类别的损失。α设置为置信损失和位置损失的权重。
位置损失仅计算正样本的loss(负样本也没得算啊),分别是中心点坐标和长宽的smooth-L1差异。置信度损失对挑选的正负样本都计算。
def match(threshold, truths, priors, variances, labels, loc_t, conf_t, idx):
"""
overlaps Shape为[truths.shape[0],priors.shape[0]],每一行对应truths中的一个框和priors中
所有框的iou
"""
overlaps = jaccard(
truths,
point_form(priors)
)
#每个truth求匹配最好的default_box
best_prior_overlap, best_prior_idx = overlaps.max(1, keepdim=True)
#每个default_box求匹配最好的truth
best_truth_overlap, best_truth_idx = overlaps.max(0, keepdim=True)
best_truth_idx.squeeze_(0)
best_truth_overlap.squeeze_(0)
best_prior_idx.squeeze_(1)
best_prior_overlap.squeeze_(1)
"""
index_fill_(dim, index, val) → Tensor
按参数index中的索引数确定的顺序,将原tensor用参数 val值填充。
确保匹配的框不会因为阈值太低被过滤掉
best_truth是每一个priors box匹配一个最好的ground truth ,形状为4096
"""
best_truth_overlap.index_fill_(0, best_prior_idx, 2) # ensure best prior
"""
本步骤确保每一个ground truth都能匹配到一个priors box
下面的代码逻辑上可能有问题,因为best_prior_idx内部可能有重复的数字,这样修改会被后面的数覆盖
前面的
无法解决两个物体匹配到同一个default box的问题
"""
for j in range(best_prior_idx.size(0)):
best_truth_idx[best_prior_idx[j]] = j
matches = truths[best_truth_idx] # Shape: [num_priors,4]
conf = labels[best_truth_idx] + 1 # Shape: [num_priors]
conf[best_truth_overlap < threshold] = 0 # label as background
loc = encode(matches, priors, variances)
loc_t[idx] = loc # [num_priors,4] encoded offsets to learn
conf_t[idx] = conf # [num_priors] top class label for each prior
输入参数:
首先我们看到该函数没有返回值,要理解这并不代表这个函数什么都不做。这是pytorch中Tesnor有一个特性,在子函数中修改Tensor的值,主函数中的Tensor也会跟着变化,因此我们可以把conf_t和loc_t当做该函数的返回值来看,这两个Tensor也就是这个函数的目标。
jacard函数用来求输入两组box(要求为左上角,右下角的形式)之间的iou。jacard(A,B)的返回值形状为[A.shape[0],B.shape[0]],每一行对应A中的一个框和B中 所有框的iou。
通过对jacard函数返回值在行和列上求最大值可以找到每个truth匹配最好的default_box,和每个default_box匹配最好的truth(有点绕口,但是这两个使用起来是不同的)。
我们最终的目标是给每个default_box匹配一个标签。因此在每个default_box匹配最好的truth的基础上进行修改,将其中truth匹配到最好的default_box项的修改为该truth(而不是按照与该default_box的iou最大的truth)。
然后将iou低于阈值的项设为背景。编码之后得到loc和conf,就是当前图片的最终标签。
# Hard Negative Mining
"""
先将正样本loss置为0,然后对loss排序(每张图片内部挑选)之后,取前self.negpos_ratio*num_pos个负样本的loss
"""
loss_c[pos] = 0 # filter out pos boxes for now
"""
下一步loss_c shape转变为[batch,num_priors]
下面这种挑选前n个数的操作值得大赞
"""
loss_c = loss_c.view(num, -1)
_, loss_idx = loss_c.sort(1, descending=True)
_, idx_rank = loss_idx.sort(1)
num_pos = pos.long().sum(1, keepdim=True)
num_neg = torch.clamp(self.negpos_ratio*num_pos, max=pos.size(1)-1)
neg = idx_rank < num_neg.expand_as(idx_rank)
# Confidence Loss Including Positive and Negative Examples
"""
上面几步的操作就是为获得pos_idx和neg_idx
conf_data 的shape为[batch,num_priors,num_classes]
"""
pos_idx = pos.unsqueeze(2).expand_as(conf_data)
neg_idx = neg.unsqueeze(2).expand_as(conf_data)
"""
(pos_idx+neg_idx).gt(0)的原因个人猜测可能是因为挑选的正样本和负样本可能会重复,因此将大于1的数变成1.。。。。
但是经过实验Tensor[mask]中对于mask大于1的数也是可以的
"""
conf_p = conf_data[(pos_idx+neg_idx).gt(0)].view(-1, self.num_classes)
targets_weighted = conf_t[(pos+neg).gt(0)]
负样本的选取本质上就是将每个负样本的loss按照从大到小的顺序进行排序之后选择取前n个(负样本是在每个片中选择,而不是minibatch中)。这份代码的两点在于用两个sort排序,在每张图片中挑选出了loss大的前n个样本(难点在于每个图片的你、n并不相同,无法直接排序后用切片操作选取样本)。关于两个sort排序求矩阵的升序或降序元素的位置,请参考我这篇博客。
首先明确match函数中构建了最终形似的标签,根据网络输出和match构建的最终标签我们已经可以来计算位置损失,直接待用pytorch的smooth_l1_loss()
loss_l = F.smooth_l1_loss(loc_p, loc_t, size_average=False)
然后经过负样本挑选,计算正负样本的置信损失,直接调用pytroch的F.cross_entropy()
loss_c = F.cross_entropy(conf_p, targets_weighted, size_average=False)
整个代码的难点在于正负样本的挑选,以及最终标签的构造。
附:
代码的完整注意请参考我的github