SSD详解:
结合代码https://github.com/amdegroot/ssd.pytorch进行讲解
1.网络结构:

整个网络可以分成三个部分:
2.1 Base网络:
主要用来提取特征,这里使用VGG16, 但是这里做了一些修改:
- conv4-1前面一层的maxpooling的ceil_mode=True,使得输出为 38x38;
- 将第5个pooling层和后面的fc层丢弃, 其中池化层pool5由原来的stride=2的2x2 变成stride=1 的3x3(猜想是不想reduce特征图大小)
- 3个fc层丢弃, 加上的conv6采用扩展卷积或带孔卷积(Dilation Conv),其在不增加参数与模型复杂度的条件下指数级扩大卷积的视野,采用 3x3 大小但dilation rate=6的扩展卷积。conv7采用1x1的卷积,
网络层次图:

base的部分(VGG16)实现:
# cfg = [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M' ,512, 512, 512]
def vgg(cfg, i, batch_norm=False):
layers = []
in_channels = i
for v in cfg:
if v == 'M':
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
elif v == 'C':
layers += [nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)] # maxpool3中使用了ceil_model=True,在pooling时向上取整,特征图由75->38
else:
conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
if batch_norm:
layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
else:
layers += [conv2d, nn.ReLU(inplace=True)]
in_channels = v
# max_pooling (3,3,1,1)
pool5 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
# 新添加的网络层 1024x3x3
conv6 = nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6)
# 新添加的网络层 1024x1x1
conv7 = nn.Conv2d(1024, 1024, kernel_size=1)
# 结合到整体网络中
layers += [pool5, conv6,
nn.ReLU(inplace=True), conv7, nn.ReLU(inplace=True)]
return layers
# 代码测试
if __name__ == "__main__":
base = {
'300': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M',
512, 512, 512],
'512': [],
}
vgg = nn.Sequential(*vgg(base['300'], 3))
x = torch.randn(1,3,300,300)
print(vgg(x).shape) #(1, 1024, 19, 19)
conv4_3层的特征图大小是38x38,该但是该层比较靠前,其norm较大,所以在其后面增加了一个L2 Normalization层(参见ParseNet),以保证和后面的检测层差异不是很大,这个和Batch Normalization层不太一样,其仅仅是对每个像素点在channle维度做归一化,而Batch Normalization层是在[batch_size, width, height]三个维度上做归一化。
class L2Norm(nn.Module):
def __init__(self,n_channels, scale):
super(L2Norm,self).__init__()
self.n_channels = n_channels
self.gamma = scale or None
self.eps = 1e-10
self.weight = nn.Parameter(torch.Tensor(self.n_channels))
self.reset_parameters()
def reset_parameters(self):
init.constant_(self.weight,self.gamma)
def forward(self, x):
norm = x.pow(2).sum(dim=1, keepdim=True).sqrt()+self.eps
#x /= norm
x = torch.div(x,norm)
out = self.weight.unsqueeze(0).unsqueeze(2).unsqueeze(3).expand_as(x) * x
return out
2.2 Extra Layers部分:
接连在base网络后面, 用来增加一些卷积层,以便增加一些尺度的特征图输入给,新增的卷积层有:Conv8_2,Conv9_2,Conv10_2,Conv11_2,他们的输出尺度分别为(10,10),(5,5),(3,3),(1,1)

网络层次:

实现:
# [256, 'S', 512, 128, 'S', 256, 128, 256, 128, 256]
def add_extras(cfg, i, batch_norm=False):
# Extra layers added to VGG for feature scaling
layers = []
in_channels = i
flag = False # False means 0, True means 1
for k, v in enumerate(cfg):
if in_channels != 'S':
if v == 'S':
layers += [nn.Conv2d(in_channels, cfg[k + 1],
kernel_size=(1, 3)[flag], stride=2, padding=1)]
else:
layers += [nn.Conv2d(in_channels, v, kernel_size=(1, 3)[flag])]
flag = not flag
in_channels = v
return layers
3.3 Multi-box Layers 部分:
由base部分和add_extras部分的介绍, 这个网络的输入分别是base部分的conv4_3 和conv_7,以及extras_layers 部分的Conv8_2,Conv9_2,Conv10_2,Conv11_2的输出的特征图,尺度分别为(38,38), (19,19),(10,10),(5,5),(3,3),(1,1), 每个特征图分别交给两个卷积层来处理,一个loc_layer, 输出位置信息,另一个是conf_layer, 来输出分类信息. 每个特征图上的每个像素都对应了不同个数的prio_box,这里取个数分别是[4, 6, 6, 6, 4, 4] . 这里取conv_4的特征图来举例说明:
特征38x38个像素,每个像素上生成4个prio_box. loc_layer和conf_layer都采用3x3, stride=1, padding=1的卷积方式,输出尺寸不变,但是channel发生了改变: 因为每个像素有4个prio_box, 每个prio_box有4个位置坐标值,所以loc_layer的输出channel=4x4. 同理每个prio_box有21种类别可能, conf_layer的输出channel=4 x 21.
对于其他输入给检测的特征图都用相同的处理方式, 所以loc_layers和conf_layers分别存储了处理各个特征图的loc_layer和conf_layer
网络层次:

实现过程:
def multibox(vgg, extra_layers, cfg, num_classes):
loc_layers = []
conf_layers = []
vgg_source = [21, -2]
for k, v in enumerate(vgg_source): #处理vgg部分特征图的layers
loc_layers += [nn.Conv2d(vgg[v].out_channels,
cfg[k] * 4, kernel_size=3, padding=1)]
conf_layers += [nn.Conv2d(vgg[v].out_channels,
cfg[k] * num_classes, kernel_size=3, padding=1)]
for k, v in enumerate(extra_layers[1::2], 2): # 处理extra_layers输出特征图的layers
loc_layers += [nn.Conv2d(v.out_channels, cfg[k]
* 4, kernel_size=3, padding=1)]
conf_layers += [nn.Conv2d(v.out_channels, cfg[k]
* num_classes, kernel_size=3, padding=1)]
return vgg, extra_layers, (loc_layers, conf_layers) #这里的输出包含了vgg和extra_layers网络
以上三个部分都是简单的网络层组合成的list,在ssd类中组合成具有forward功能的moudle类:,整个ssd的网络结构:

2. 先验框(prio_box)的生成
这里参照了https://zhuanlan.zhihu.com/p/33544892的描述:
每个特征图的每个单元为中心都设置一定数量的prio_box, 但是不同特征图设置的先验框数目不同(同一个特征图上每个单元设置的先验框是相同的,这里的数目指的是一个单元的先验框数目)。
先验框的设置,包括尺度(或者说大小)和长宽比两个方面。对于先验框的尺度,其遵守一个线性递增规则:随着特征图大小降低,先验框尺度线性增加:


class PriorBox(object):
"""Compute priorbox coordinates in center-offset form for each source
feature map.
cfg = {
'num_classes': 21,
'lr_steps': (80000, 100000, 120000),
'max_iter': 120000,
'feature_maps': [38, 19, 10, 5, 3, 1],
'min_dim': 300,
'steps': [8, 16, 32, 64, 100, 300], #下采样的倍数
'min_sizes': [30, 60, 111, 162, 213, 264],
'max_sizes': [60, 111, 162, 213, 264, 315],
'aspect_ratios': [[2], [2, 3], [2, 3], [2, 3], [2], [2]],
'variance': [0.1, 0.2],
'clip': True,
'name': 'VOC',
}
"""
def __init__(self, cfg):
super(PriorBox, self).__init__()
self.image_size = cfg['min_dim']
# number of priors for feature map location (either 4 or 6)
self.num_priors = len(cfg['aspect_ratios'])
self.variance = cfg['variance'] or [0.1]
self.feature_maps = cfg['feature_maps']
self.min_sizes = cfg['min_sizes']
self.max_sizes = cfg['max_sizes']
self.steps = cfg['steps']
self.aspect_ratios = cfg['aspect_ratios']
self.clip = cfg['clip']
self.version = cfg['name']
for v in self.variance:
if v <= 0:
raise ValueError('Variances must be greater than 0')
def forward(self):
mean = []
for k, f in enumerate(self.feature_maps):
for i, j in product(range(f), repeat=2):
f_k = self.image_size / self.steps[k]
# unit center x,y
cx = (j + 0.5) / f_k
cy = (i + 0.5) / f_k
# aspect_ratio: 1
# rel size: min_size
s_k = self.min_sizes[k]/self.image_size
mean += [cx, cy, s_k, s_k]
# aspect_ratio: 1
# rel size: sqrt(s_k * s_(k+1))
s_k_prime = sqrt(s_k * (self.max_sizes[k]/self.image_size))
mean += [cx, cy, s_k_prime, s_k_prime]
# rest of aspect ratios
for ar in self.aspect_ratios[k]:
mean += [cx, cy, s_k*sqrt(ar), s_k/sqrt(ar)]
mean += [cx, cy, s_k/sqrt(ar), s_k*sqrt(ar)]
# back to torch land
output = torch.Tensor(mean).view(-1, 4)
if self.clip:
output.clamp_(max=1, min=0)
return output
priorbox网络层中variance变量的作用:
在作者的回答参见:The meanings of parameter "variance" in PriorBox layer · Issue #75 · weiliu89/caffe
个人理解:除以variance是对预测box和真实box的误差进行放大,从而增加loss,增加梯度,加快收敛速度。
实现细节:在encodeBBox中除以相应的variance,在decodeBBox中就要乘以相应的variance.
3.前向推断过程:
1.1 读取图片:
这里最主要值得一提的操作是, cv2.imread读取的图片是BGR格式的,需要通过
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
转换到RGB格式,方便后面做通道减均值处理
然后经过如下处理:
x = cv2.resize(image, (300, 300)).astype(np.float32) # 缩放
x -= (104.0, 117.0, 123.0) # 减去通道均值,通过减去数据对应维度的统计平均值,来消除公共的部分,以凸显个体之间的特征和差异。
x = x.astype(np.float32)
x = x[:, :, ::-1].copy()#将三个通道的顺序又变换过来,成为最原始的顺序
x = torch.from_numpy(x).permute(2, 0, 1) #(300,300,3) -> (3,300,300)
xx = Variable(x.unsqueeze(0)) # wrap tensor in Variable
1.2 网络模型的推导:
读取的图片经过处理,导入网络模型(模型结构在后面会有详细的讲解,可以先看模型结构,才能理解模型的输出是什么),经过模型的forward过程,得到网络的输出值,主要包括两个部分: 位置信息loc 和 分类信息conf, 还有生成的priox_box送给检测层做解码用, 结合loc一起生成最终的预测框位置信息
ssd的forward过程如下,可以分为两个部分来看,一是网络结构的推断过程, 二是检测层对网络输出的处理
def forward(self, x):
sources = list()
loc = list()
conf = list()
# apply vgg up to conv4_3 relu
for k in range(23):
x = self.vgg[k](x)
s = self.L2Norm(x)
sources.append(s)
# apply vgg up to fc7
for k in range(23, len(self.vgg)):
x = self.vgg[k](x)
sources.append(x)
# apply extra layers and cache source layer outputs
for k, v in enumerate(self.extras):
x = F.relu(v(x), inplace=True)
if k % 2 == 1:
sources.append(x)
# apply multibox head to source layers
for (x, l, c) in zip(sources, self.loc, self.conf):
loc.append(l(x).permute(0, 2, 3, 1).contiguous())
conf.append(c(x).permute(0, 2, 3, 1).contiguous())
loc = torch.cat([o.view(o.size(0), -1) for o in loc], 1) #
conf = torch.cat([o.view(o.size(0), -1) for o in conf], 1)
-------- 以上是网络结构推导过程,以下是网络的输出送给检测层的处理过程-------------
if self.phase == "test":
output = self.detect(
loc.view(loc.size(0), -1, 4), # loc preds
self.softmax(conf.view(conf.size(0), -1,
self.num_classes)), # conf preds
self.priors.type(type(x.data)) # default boxes
)
else:
output = (
loc.view(loc.size(0), -1, 4),
conf.view(conf.size(0), -1, self.num_classes),
self.priors
)
return output
1.3 检测处理部分:
主要思路是这样: 现在拥有8732个预测框的位置信息(和原图的缩放比例值)(8732,4), 和分类信息(21,8732), 当然还有8732个先验框的坐标(8732,4).
每个类别都对应了8732个预测框, 先取一个很小的阈值( self.conf_thresh), 剔除掉此类别对应的框中置信度小于此阈值的框, 然后对剩下的框采用nms(非极大值抑制)算法,整个detection部分的实现可用下面的流程图来表示:

代码:
class Detect(Function):
def __init__(self, num_classes, top_k, conf_thresh, nms_thresh):
self.num_classes = num_classes
self.top_k = top_k
self.conf_thresh = conf_thresh
self.nms_thresh = nms_thresh
self.variance = cfg['variance']
def forward(self, loc_data, conf_data, prior_data):
'''
Args:
loc_data: 预测出的loc张量,shape[b,M,4], eg:[b, 8732, 4]
conf_data: 预测出的置信度,shape[b,M,num_classes], eg:[b, 8732, 21]
prior_data: 先验框,shape[M,4], eg:[8732, 4]
'''
batch = loc_data.size(0) # batch size
output = torch.zeros(batch, self.num_classes, self.top_k, 5) # 初始化输出
conf_preds = conf_data.transpose(2,1)
# 解码loc的信息,变为正常的bboxes
for i in range(batch):
# 解码loc
decode_boxes = decode(loc_data[i], prior_data, self.variance)
# 拷贝每个batch内的conf,用于nms
conf_scores = conf_preds[i].clone()
# 遍历每一个类别
for num in range(1, self.num_classes):
# 筛选掉 conf < conf_thresh 的conf
c_mask = conf_scores[num].gt(self.conf_thresh)
scores = conf_scores[num][c_mask]
# 如果都被筛掉了,则跳入下一类
if scores.size(0) == 0:
continue
# 筛选掉 conf < conf_thresh 的框
l_mask = c_mask.unsqueeze(1).expand_as(decode_boxes)
boxes = decode_boxes[l_mask].view(-1, 4)
# nms
ids, count = nms(boxes, scores, self.nms_thresh, self.top_k)
# nms 后得到的输出拼接
output[i, num, :count] = torch.cat((
scores[ids[:count]].unsqueeze(1),
boxes[ids[:count]]), 1)
return output
# 代码测试
if __name__ == "__main__":
detect = Detect(21, 200, 0.01, 0.5)
loc_data = torch.randn(1,8732,4)
conf_data = torch.randn(1,8732,21)
prior_data = torch.randn(8732, 4)
out = detect(loc_data, conf_data, prior_data)
print('Detect output shape:', out.shape)
输出:
Detect output shape: torch.Size([1, 21, 200, 5])
1.3.1 预测框解码
上图中所有预测框输出位置信息结合先验框的信息进行解码(对应过程是编码,这个过程在训练过程中实现,详细的编解码过程可参考训练过程的讲解),得到解码后的所有预测框在原图上的位置信息.解码过程如下:

def decode(loc, priors, variances):
"""Decode locations from predictions using priors to undo
the encoding we did for offset regression at train time.
Args:
loc (tensor): location predictions for loc layers,
Shape: [num_priors,4]
priors (tensor): Prior boxes in center-offset form.
Shape: [num_priors,4].
variances: (list[float]) Variances of priorboxes
Return:
decoded bounding box predictions
"""
boxes = torch.cat((
priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:],
priors[:, 2:] * torch.exp(loc[:, 2:] * variances[1])), 1)
boxes[:, :2] -= boxes[:, 2:] / 2
boxes[:, 2:] += boxes[:, :2]
return boxes
1.3.2 非极大值抑制算法(NMS)
对每个类别采用nms算法,NMS算法一般是为了去掉模型预测后的多余框,其一般设有一个nms_threshold=0.5,具体的实现思路如下:
- 选取这类box中scores最大的哪一个,它的index记为 i,并保留它;
- 计算
boxes[i]
与其余的boxes
的IOU
值; - 如果其
IOU>0.5
了,那么就舍弃这个box(由于可能这两个box表示同一目标,所以保留分数高的哪一个) - 从最后剩余的boxes中,再找出最大scores的哪一个,如此循环往复
def nms(boxes, scores, threshold=0.5, top_k=200):
'''
Args:
boxes: 预测出的box, shape[M,4]
scores: 预测出的置信度,shape[M]
threshold: 阈值
top_k: 要考虑的box的最大个数
Return:
keep: nms筛选后的box的新的index数组
count: 保留下来box的个数
'''
keep = scores.new(scores.size(0)).zero_().long()
x1 = boxes[:, 0]
y1 = boxes[:, 1]
x2 = boxes[:, 2]
y2 = boxes[:, 3]
area = (x2-x1)*(y2-y1) # 面积,shape[M]
_, idx = scores.sort(0, descending=True) # 降序排列scores的值大小
# 取前top_k个进行nms
idx = idx[:top_k]
count = 0
while idx.numel():
# 记录最大score值的index
i = idx[0]
# 保存到keep中
keep[count] = i
# keep 的序号
count += 1
if idx.size(0) == 1: # 保留框只剩一个
break
idx = idx[1:] # 移除已经保存的index
# 计算boxes[i]和其他boxes之间的iou
xx1 = x1[idx].clamp(min=x1[i])
yy1 = y1[idx].clamp(min=y1[i])
xx2 = x2[idx].clamp(max=x2[i])
yy2 = y2[idx].clamp(max=y2[i])
w = (xx2 - xx1).clamp(min=0)
h = (yy2 - yy1).clamp(min=0)
# 交集的面积
inter = w * h # shape[M-1]
iou = inter / (area[i] + area[idx] - inter)
# iou满足条件的idx
idx = idx[iou.le(threshold)] # Shape[M-1]
return keep, count
4 .训练过程:
总体思路, 从网络结构图中可以看出,整个网络的训练过程,在训练过程中的前向推断过程不包含检测处理部分,网络的输出就包含三个内容, loc_data([b, 8732, 4]), conf_data(32,8732,21), prio_box(8732,4), 然后根据这三个数据, 结合标签值来计算loss, 这里涉及到两个方面的内容,一是如何构造检测网络需要的标签, 而是如何设计损失函数.
4.1 .构造标签:
一个batch的图片和相应的annotation, 从数据集里经过读取,转换, 最后拿到的一个batch的target,其中每个target表示每张输入图片的目标信息, 一张图片可能包含多个目标的ground_truth_box和label. 现在网络需要用到prio_box,每个prio_box会与之匹配一个gt_box和label,也就是先验框的匹配. 最后的网络需要的标签包括两个方面, 一是每个proi_box和与之对应的gt_box的offset(这里用到了位置信息的编码,下面会讲解),还有就是每个proi_box根据和gt的匹配情况会给它分配一个label.
4.1.1 先验框的匹配
假设一张图片里有3个目标,也就有3个ground_truth, 会生成8732个先验框, SSD的先验框和ground truth匹配原则主要两点: 1. 对于图片中的每个gt,找到与其IOU最大的先验框,该先验框与其匹配,这样可以保证每个gt一定与某个prior匹配。 2. 对于剩余未匹配的priors,若某个gt的IOU大于某个阈值(一般0.5),那么该prior与这个gt匹配。
注意点:
1.通常称与gt匹配的prior为正样本,反之,若某一个prior没有与任何一个gt匹配,则为负样本。
2.某个gt可以和多个prior匹配,而每个prior只能和一个gt进行匹配。
3.如果多个gt和某一个prior的IOU均大于阈值,那么prior只与IOU最大的那个进行匹配
从代码中看匹配是如何被实现的:
- 先给每个gt找到IOU最大的那个prio_box, 一共有3个, 输出3个 best_prior_overlap,和best_prior_idx
- 然后给每个prio_box找到IOU最大的那个gt, 以及8732个best_truth_overlap和best_truth_idx
- 然后将那三个prio_box对应的最大的overlap的值设定为2,这是因为这三个prio_box对应的最大overlap的值可能小于所设定的阈值, 这样可以确保这三个prio_box不被设置成负样本,也就保证了每个gt一定与某个prior匹配
- 然后将这三个prixo_box匹配的gt的索引值调整为1中匹配的顺序, 因为那三个prio_box在2中匹配的gt不一定和1中匹配的顺序一样, 这时候应该以1中的匹配为准
- 然后提取出所有prio_box最后对应的gt_box的坐标(matches)
- 提取所有prio_box对应的gt的类别作为输出的类别
- 将所有prio_box与之对应gt的overlap小于阈值的prio_box的定位成负样本,也就是为0
- 所有prio_box对应了一个gt, 然后取两者之间的offset所为真实的位置标签
def match(threshold, truths, priors, variances, labels, loc_t, conf_t, idx):
'''
Target:
把和每个prior box 有最大的IOU的ground truth box进行匹配,
同时,编码包围框,返回匹配的索引,对应的置信度和位置
Args:
threshold: IOU阈值,小于阈值设为bg
truths: ground truth boxes, shape[N,4]
priors: 先验框, shape[M,4]
variances: prior的方差, list(float)
labels: 图片的所有类别,shape[num_obj]
loc_t: 用于填充encoded loc 目标张量
conf_t: 用于填充encoded conf 目标张量
idx: 现在的batch index
'''
overlaps = iou(truths, point_form(priors))
# [1,num_objects] 和每个ground truth box 交集最大的 prior box
best_prior_overlap, best_prior_idx = overlaps.max(1, keepdim=True)
# [1,num_priors] 和每个prior box 交集最大的 ground truth box
best_truth_overlap, best_truth_idx = overlaps.max(0, keepdim=True)
# squeeze shape
best_prior_idx.squeeze_(1) #(N)
best_prior_overlap.squeeze_(1) #(N)
best_truth_idx.squeeze_(0) #(M)
best_truth_overlap.squeeze_(0) #(M)
# 保证每个ground truth box 与某一个prior box 匹配,固定值为 2 > threshold,这样在
# 后面conf[best_truth_overlap < threshold] = 0的时候,能将每个gt对应最大的那三个先验框保留下来,即使这3个先验框对应最大的IOU小于阈值.因为每个ground truth必须要有一个先验框.
best_truth_overlap.index_fill_(0, best_prior_idx, 2) # ensure best prior
# 保证每一个ground truth 匹配它的都是具有最大IOU的prior
# 根据 best_prior_dix 锁定 best_truth_idx里面的最大IOU prior
for j in range(best_prior_idx.size(0)):
best_truth_idx[best_prior_idx[j]] = j
# 提取出所有匹配的ground truth box, Shape: [M,4]
matches = truths[best_truth_idx]
# 提取出所有GT框的类别, Shape:[M]
conf = labels[best_truth_idx] + 1
# 把 iou < threshold 的框类别设置为 bg,即为0
conf[best_truth_overlap < threshold] = 0
# 编码包围框
loc = encode(matches, priors, variances)
# 保存匹配好的loc和conf到loc_t和conf_t中
loc_t[idx] = loc # [M,4] encoded offsets to learn
conf_t[idx] = conf # [M] top class label for each prior
4.1.2 位置的编码


*代码:
def encode(matched, priors, variances):
'''
将来至于priorbox的差异编码到ground truth box中
Args:
matched: 每个prior box 所匹配的ground truth,
Shape[M,4],坐标(xmin,ymin,xmax,ymax)
priors: 先验框box, shape[M,4],坐标(cx, cy, w, h)
variances: 方差,list(float)
'''
# 编码中心坐标cx, cy
g_cxcy = (matched[:, :2] + matched[:, 2:])/2 -priors[:, :2]
g_cxcy /= (priors[:, 2:] * variances[0]) #shape[M,2]
# 防止出现log出现负数,从而使loss为 nan
eps = 1e-5
# 编码宽高w, h
g_wh = (matched[:, 2:] - matched[:, :2]) / priors[:, 2:]
g_wh = torch.log(g_wh + eps) / variances[1] #shape[M,2]
return torch.cat([g_cxcy, g_wh], 1) #shape[M,4]
解码过程在前向推断过程中已经阐述了
4.2.设计损失函数:

1. 对于位置损失函数:
针对所有的正样本,采用 Smooth L1 Loss, 位置信息都是 encode
之后的位置信息。

SooothL1Loss其实是L2Loss和L1Loss的结合,它同时拥有L2 Loss和L1 Loss的部分优点。
1. 当预测值和ground truth差别较小的时候(绝对值差小于1),梯度不至于太大。(损失函数相较L1 Loss比较圆滑) 2. 当差别大的时候,梯度值足够小(较稳定,不容易梯度爆炸)。
2. 对于置信度损失函数:
首先需要使用 hard negative mining 将正负样本按照 1:3
的比例把负样本抽样出来,抽样的方法是:思想: 针对所有batch的confidence,按照置信度误差进行降序排列,取出前top_k
个负样本。

本来就是从负样本中进行抽样,对于负样本,我们应该认为他输出的背景置信度要很高才对,现在一个负样本,预测的背景置信度却很低,那就不对了,就应该要想方设法纠正过来!且这种负样本产生的交叉损失也是最大的!所以就需要专门挑出这种样本来训练!
完整loss代码
作者:JimmyHua
链接:https://zhuanlan.zhihu.com/p/79854543
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
import torch
import torch.nn as nn
import torch.nn.functional as F
from vgg_backbone import voc
from box_utils import match, log_sum_exp
class MultiBoxLoss(nn.Module):
def __init__(self, num_classes, overlap_thresh, neg_pos, use_gpu=False):
super(MultiBoxLoss, self).__init__()
self.use_gpu = use_gpu
self.num_classes = num_classes
self.threshold = overlap_thresh
self.negpos_ratio = neg_pos
self.variance = voc['variance']
def forward(self, pred, targets):
'''
Args:
pred: A tuple, 包含 loc(编码钱的位置信息), conf(类别), priors(先验框);
loc_data: shape[b,M,4];
conf_data: shape[b,M,num_classes];
priors: shape[M,4];
targets: 真实的boxes和labels,shape[b,num_objs,5];
'''
loc_data, conf_data, priors = pred
batch = loc_data.size(0) #batch
num_priors = priors[:loc_data.size(1), :].size(0) # 先验框个数
# 获取匹配每个prior box的 ground truth
# 创建 loc_t 和 conf_t 保存真实box的位置和类别
loc_t = torch.Tensor(batch, num_priors, 4)
conf_t = torch.LongTensor(batch, num_priors)
for idx in range(batch):
truths = targets[idx][:, :-1].detach() # ground truth box信息
labels = targets[idx][:, -1].detach() # ground truth conf信息
defaults = priors.detach() # priors的 box 信息
# 匹配 ground truth
match(self.threshold, truths, defaults,
self.variance, labels, loc_t, conf_t, idx)
# use gpu
if self.use_gpu:
loc_t = loc_t.cuda()
conf_t = conf_t.cuda()
pos = conf_t > 0 # 匹配中所有的正样本mask,shape[b,M]
# Localization Loss,使用 Smooth L1
# shape[b,M]-->shape[b,M,4]
pos_idx = pos.unsqueeze(2).expand_as(loc_data)
loc_p = loc_data[pos_idx].view(-1,4) # 预测的正样本box信息
loc_t = loc_t[pos_idx].view(-1,4) # 真实的正样本box信息
loss_l = F.smooth_l1_loss(loc_p, loc_t) # Smooth L1 损失
'''
Target;
下面进行hard negative mining
过程:
1、 针对所有batch的conf,按照置信度误差(预测背景的置信度越小,误差越大)进行降序排列;
2、 负样本的label全是背景,那么利用log softmax 计算出logP,
logP越大,则背景概率越低,误差越大;
3、 选取误差交大的top_k作为负样本,保证正负样本比例接近1:3;
'''
# shape[b*M,num_classes]
batch_conf = conf_data.view(-1, self.num_classes)
# 使用logsoftmax,计算置信度,shape[b*M, 1]
conf_logP = log_sum_exp(batch_conf) - batch_conf.gather(1, conf_t.view(-1, 1))
# hard Negative Mining
conf_logP = conf_logP.view(batch, -1) # shape[b, M]
conf_logP[pos] = 0 # 把正样本排除,剩下的就全是负样本,可以进行抽样
# 两次sort排序,能够得到每个元素在降序排列中的位置idx_rank
_, index = conf_logP.sort(1, descending=True)
_, idx_rank = index.sort(1)
# 抽取负样本
# 每个batch中正样本的数目,shape[b,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 # 抽取前top_k个负样本,shape[b, M]
# shape[b,M] --> shape[b,M,num_classes]
pos_idx = pos.unsqueeze(2).expand_as(conf_data)
neg_idx = neg.unsqueeze(2).expand_as(conf_data)
# 提取出所有筛选好的正负样本(预测的和真实的)
conf_p = conf_data[(pos_idx+neg_idx).gt(0)].view(-1, self.num_classes)
conf_target = conf_t[(pos+neg).gt(0)]
# 计算conf交叉熵
loss_c = F.cross_entropy(conf_p, conf_target)
# 正样本个数
N = num_pos.detach().sum().float()
loss_l /= N
loss_c /= N
return loss_l, loss_c
# 调试代码使用
if __name__ == "__main__":
loss = MultiBoxLoss(21, 0.5, 3)
p = (torch.randn(1,100,4), torch.randn(1,100,21), torch.randn(100,4))
t = torch.randn(1, 10, 4)
tt = torch.randint(20, (1,10,1))
t = torch.cat((t,tt.float()), dim=2)
l, c = loss(p, t)
# 随机randn,会导致g_wh出现负数,此时结果会变成 nan
print('loc loss:', l)
print('conf loss:', c)
输出:
loc loss: tensor(11.9424)
conf loss: tensor(2.0487)