物体检测技术,通常是指在一张图像中检测出物体出现的位置及对应的类别。我们要求检测器输出5个量:物体类别、
xmin、ymin、xmax与ymax。当然,对于一个边框,检测器也可以输出中心点与宽高的形式,这两者是等价的。
深度神经网络大量的参数可以提取出鲁棒性和语义性更好的特征,并且分类器性能也更优越。2014年的RCNN(Regions with CNN features)算是使用深度学习实现物体检测的经典之作。在RCNN基础上,2015年的Fast RCNN实现了端到端的检测与卷积共享,Faster RCNN提出了锚框(Anchor)这一划时代的思想。在2016年,YOLO v1实现了无锚框(Anchor-Free)的一阶检测,SSD实现了多特征图的一阶检测。
在2017年,FPN利用特征金字塔实现了更优秀的特征提取网络,Mask RCNN则在实现了实例分割的同时,也提升了物体检测的性能。进入2018年后,物体检测的算法更为多样,如使用角点做检测的CornerNet、使用多个感受野分支的TridentNet、使用中心点做检测的CenterNet等。
在物体检测算法中,物体边框从无到有,边框变化的过程在一定程度上体现了检测是一阶的还是两阶的。
Anchor是一个划时代的思想,最早出现在Faster RCNN中,其本质上是一系列大小宽高不等的先验框,均匀地分布在特征图上,利用特征去预测这些Anchors的类别,以及与真实物体边框存在的偏移。Anchor相当于给物体检测提供了一个梯子,使得检测器不至于直接从无到有地预测物体,精度往往较高,常见算法有Faster RCNN和SSD等。
当然,还有一部分无锚框的算法,思路更为多样,有直接通过特征预测边框位置的方法,如YOLO v1等。最近也出现了众多依靠关键点来检测物体的算法,如CornerNet和CenterNet等。
对于具体的某个物体来讲,我们可以从预测框与真实框的贴合程度来判断检测的质量,通常使用IoU(Intersection of Union)来量化贴合程度。IoU使用两个边框的交集与并集的比值,就可以得到IoU。显而易见,IoU的取值区间是[0,1],IoU值越大,表明两个框重合越好。
对于IoU而言,我们通常会选取一个阈值,如0.5,来确定预测框是正确的还是错误的。当两个框的IoU大于0.5时,我们认为是一个有效的检测,否则属于无效的匹配。。
对于一个检测器,通常使用mAP(mean Average Precision)这一指标来评价一个模型的好坏,这里的AP指的是一个类别的检测精度,mAP则是多个类别的平均精度。我们首先将所有的预测框按照得分从高到低进行排序(因为得分越高的边框其对于真实物体的概率往往越大),然后从高到低遍历预测框。
for c in classes:
# 通过类别作为关键字,得到每个类别的预测、标签及总标签数
dects = det_boxes[c]
gt_class = gt_boxes[c]
npos = num_pos[c]
# 利用得分作为关键字,对预测框按照得分从高到低排序
dects = sorted(dects, key=lambda conf: conf[5], reverse=True)
# 设置两个与预测边框长度相同的列表,标记是True Positive还是False Positive
TP = np.zeros(len(dects))
FP = np.zeros(len(dects))
# 对某一个类别的预测框进行遍历
for d in range(len(dects)):
# 将IoU默认置为最低
iouMax = sys.float_info.min
# 遍历与预测框同一图像中的同一类别的标签,计算IoU
if dects[d][-1] in gt_class:
for j in range(len(gt_class[dects[d][-1]])):
iou = Evaluator.iou(dects[d][:4], gt_class[dects[d][-1]][j][:4])
if iou > iouMax:
iouMax = iou
jmax = j # 记录与预测有最大IoU的标签
# 如果最大IoU大于阈值,并且没有被匹配过,则赋予TP
if iouMax >= cfg['iouThreshold']:
if gt_class[dects[d][-1]][jmax][4] == 0:
TP[d] = 1
gt_class[dects[d][-1]][jmax][4] = 1 # 标记为匹配过
# 如果被匹配过,赋予FP
else:
FP[d] = 1
# 如果最大IoU没有超过阈值,赋予FP
else:
FP[d] = 1
# 如果对应图像中没有该类别的标签,赋予FP
else:
FP[d] = 1
# 利用NumPy的cumsum()函数,计算累计的FP与TP
acc_FP = np.cumsum(FP)
acc_TP = np.cumsum(TP)
rec = acc_TP / npos # 得到每个点的Recall
prec = np.divide(acc_TP, (acc_FP + acc_TP)) # 得到每个点的Precision
# 利用Recall与Precision进一步计算得到AP
[ap, mpre, mrec, ii] = Evaluator.CalculateAveragePrecision(rec, prec)
Python中的对象还可以进一步分为可变对象与不可变对象,这一点尤其要注意。
对于不可变对象,所有指向该对象的变量在内存中共用一个地址。
如果修改了不可变对象的变量的值,则原对象的其他变量不变;相比之下,如果修改了可变对象的变量,则相当于可变对象被修改了,其他变量也会发生变化。
注意:当对象的引用计数为0时,该对象对应的内存会被回收。
Python中的变量也存在深拷贝与浅拷贝的区别,不可变对象无论深/浅拷贝,其地址都是一样的,而可变对象则存在3种情况,下面以list为例。
Python的作用域从内而外,可以分为Local(局部)、Enclosed(嵌套)、Global(全局)及Built-in(内置)4种,如图1.19所示。变量的搜索遵循LEGB原则,如果一直搜索不到则会报错。
这4种作用域的含义如下:
·局部:在函数与类中,每当调用函数时都会创建一个局部作用域,局部变量域像一个栈,仅仅是暂时存在,依赖于创建该局部作用域的函数是否处于活动的状态。
·嵌套:一般出现在函数中嵌套了一个函数时,在外围函数中的作用域称为嵌套作用域,主要目的是为了实现闭包。
·全局:模型文件顶层声明的变量具有全局作用域,从外部看来,模块的全局变量就是一个模块对象的属性,全局作用域仅限于单个模块的文件中。
·内置:系统内解释器定义的变量。这种变量的作用域是解释器在则在,解释器亡则亡。
在编程语言中,高阶函数是指接受函数作为输入或者输出的函数。对于Python而言,函数是一等对象,即可以赋值给变量、添加到集合中、传参到函数中,也可以作为函数的返回值。下面介绍map()、reduce()、filter()和sorted()这4种常见的高阶函数。
map()函数可以将一个函数映射作用到可迭代的序列中,并返回函数输出的序列:
reduce()函数与map()函数不同,其输入的函数需要传入两个参数。reduce()的过程是先使用输入函数对序列中的前两个元素进行操作,得到的结果再和第三个元素进行运算,直到最后一个元素。
filter()函数的作用主要是通过输入函数对可迭代序列进行过滤,并返回满足过滤条件的可迭代序列。
sorted()函数可以完成对可迭代序列的排序。与列表本身自带的sort()函数不同,这里的sorted()函数返回的是一个新的列表。sorted()函数可以传入关键字key来指定排序的标准,参数reverse代表是否反向。
对于一些简单逻辑函数,可以使用lambda匿名表达式来取代函数的定义,这样可以节省函数名称的定义,以及简化代码的可读性等。
>>> map(lambda x: x+1, [1, 2, 3, 4, 5, 6, 7, 8, 9]) # lamda实现元素加1的操作
[2, 3, 4, 5, 6, 7, 8, 9, 10]
迭代器不要求事先准备好整个迭代过程中所有的元素,可以使用next()来访问元素。Python中的容器,如list、dict和set等,都属于可迭代对象,对于这些容器,我们可以使用iter()函数封装成迭代器。
实际上,任何实现了__iter__()和__next__()方法的对象都是迭代器,其中__iter__()方法返回迭代器本身,__next__()方法返回容器中的下一个值。for循环本质上也是一个迭代器的实现,作用于可迭代对象,在遍历时自动调用next()函数来获取下一个元素。
生成器是迭代器的一种,可以控制循环遍历的过程,实现一边循环一边计算,并使用yield来返回函数值,每次调用到yield会暂停。生成器迭代的序列可以不是完整的,从而可以节省出大量的内存空间。
有多种创建迭代器的方法,最简单的是使用生成器表达式,与list很相似,只不过使用()括号。
>>> a = (x for x in range(10)) # 利用()括号实现了一个简单的生成器
>>> next(a), next(a)
(0, 1)
最为常见的是使用yield关键字来创建一个生成器。例如下面代码中,第一次调用f()函数会返回1并保持住,第二次调用f()会继续执行,并返回2。
>>> def f():
... yield 1
... yield 2
... yield 3
>>> f1 = f()
>>> print([next(f1) for i in range(2)])
[1, 2]
2.1.1 Tensor数据类型
Tensor在使用时可以有不同的数据类型,如表2.1所示,官方给出了7种CPU Tensor类型与8种GPU Tensor类型,在使用时可以根据网络模型所需的精度与显存容量,合理地选取。16位半精度浮点是专为GPU上运行的模型设计的,以尽可能地节省GPU显存占用,但这种节省显存空间的方式也缩小了所能表达数据的大小。PyTorch中默认的数据类型是torch.FloatTensor,即torch.Tensor等同于torch.FloatTensor。
PyTorch可以通过set_default_tensor_type函数设置默认使用的Tensor类型,在局部使用完后如果需要其他类型,则还需要重新设置回所需的类型。
torch.set_default_tensor_type('torch.DoubleTensor')
对于Tensor之间的类型转换,可以通过type(new_type)、type_as()、int()等多种方式进行操作,尤其是type_as()函数,在后续的模型学习中可以看到,我们想保持Tensor之间的类型一致,只需要使用type_as()即可,并不需要明确具体是哪种类型。
Tensor有多种创建方法,如基础的构造函数Tensor(),还有多种与NumPy十分类似的方法,如ones()、eye()、zeros()和randn()等。
对于Tensor的维度,可使用Tensor.shape或者size()函数查看每一维的大小,两者等价。
查看Tensor中的元素总个数,可使用Tensor.numel()或者Tensor.nelement()函数,两者等价。
组合操作是指将不同的Tensor叠加起来,主要有torch.cat()和torch.stack()两个函数。cat是指沿着已有的数据的某一维度进行拼接,操作后数据的总维数不变,在进行拼接时,除了拼接的维度之外,其他维度必须相同。而torch.stack()函数指新增维度,并
按照指定的维度进行叠加。
分块则是与组合相反的操作,指将Tensor分割成不同的子Tensor,主要有torch.chunk()与torch.split()两个函数,前者需要指定分块的数量,而后者则需要指定每一块的大小,以整型或者list来表示。
索引操作与NumPy非常类似,主要包含下标索引、表达式索引、使用torch.where()与Tensor.clamp()的选择性索引。
变形操作则是指改变Tensor的维度,以适应在深度学习的计算中,数据维度经常变换的需求,是一种十分重要的操作。在PyTorch中主要有4类不同的变形方法。
view()、resize()和reshape()函数可以在不改变Tensor数据的前提下任意改变Tensor的形状,必须保证调整前后的元素总数相同,并且调整前后共享内存,三者的作用基本相同。
如果想要直接改变Tensor的尺寸,可以使用resize_()的原地操作函数。在resize_()函数中,如果超过了原Tensor的大小则重新分配内存,多出部分置0,如果小于原Tensor大小则剩余的部分仍然会隐藏保留。
transpose()函数可以将指定的两个维度的元素进行转置,而permute()函数则可以按照给定的维度进行维度变换。
在实际的应用中,经常需要增加或减少Tensor的维度,尤其是维度为1的情况,这时候可以使用squeeze()与unsqueeze()函数,前者用于去除size为1的维度,而后者则是将指定的维度的size变为1
4.expand()和expand_as()函数
有时需要采用复制元素的形式来扩展Tensor的维度,这时expand就派上用场了。expand()函数将size为1的维度复制扩展为指定大小,也可以使用expand_as()函数指定为示例Tensor的维度。
注意:在进行Tensor操作时,有些操作如transpose()、permute()等可能会把Tensor在内存中变得不连续,而有些操作如view()等是需要Tensor内存连续的,这种情况下需要使用contiguous()操作先将内存变为连续的。在PyTorch v0.4版本中增加了reshape()操作,可以看做是Tensor.contiguous().view()。
比较重要的是排序函数sort(),选择沿着指定维度进行排序,返回排序后的Tensor及对应的索引位置。max()与min()函数则是沿着指定维度选择最大与最小元素,返回该元素及对应的索引位置。
对于Tensor的单元素数学运算,如abs()、sqrt()、log()、pow()和三角函数等,都是逐元素操作(element-wise),输出的Tensor形状与原始Tensor形状一致。
对于类似求和、求均值、求方差、求距离等需要多个元素完成的操作,往往需要沿着某个维度进行计算,在Tensor中属于归并操作,输出形状小于输入形状。由于比较简单且与NumPy极为相似,在此就不详细展开。
自动广播语义,即不同形状的Tensor进行计算时,可自动扩展到较大的相同形状,再进行计算。广播机制的前提是任一个Tensor至少有一个维度,且从尾部遍历Tensor维度时,两者维度必须相等,其中一个要么是1要么不存在。
向量化操作是指可以在同一时间进行批量地并行计算,例如矩阵运算,以达到更好的计算效率的一种方式。在实际使用时,应尽量使用向量化直接对Tensor操作,避免低效率的for循环对元素逐个操作,尤其是在训练网络模型时,如果有大量的for循环,会极大地影响训练的速度。
为了实现高效计算,PyTorch提供了一些原地操作运算,即in-place operation,不经过复制,直接在原来的内存上进行计算。对于内存的共享,主要有如下3种情况,如图2.3所示。
直接通过Tensor来初始化另一个Tensor,或者通过Tensor的组合、分块、索引、变形操作来初始化另一个Tensor,则这两个Tensor共享内存。
2.原地操作符
PyTorch对于一些操作通过加后缀“_”实现了原地操作,如add_()和resize_()等,这种操作只要被执行,本身的Tensor则会被改变。
Tensor与NumPy可以高效地进行转换,并且转换前后的变量共享内存。在进行PyTorch不支持的操作时,甚至可以曲线救国,将Tensor转换为NumPy类型,操作后再转为Tensor。
e.g. a.numpy() torch.from_numpy(b), a.tolist()
自动求导机制记录了Tensor的操作,以便自动求导与反向传播。可以通过requires_grad参数来创建支持自动求导机制的Tensor。
Tensor有两个重要的属性,分别记录了该Tensor的梯度与经历的操作。
注意:建议使用Tensor.detach()函数来获取数据,因为.data属性在某些情况下不安全,原因在于对.data生成的数据进行修改不会被autograd追踪。Tensor.detach()函数生成的数据默认requires_grad为False。
Autograd的基本原理是随着每一步Tensor的计算操作,逐渐生成计算图,并将操作的function记录在Tensor的grad_fn中。在前向计算完后,只需对根节点进行backward函数操作,即可从当前根节点自动进行反向传播与梯度计算,从而得到每一个叶子节点的梯度,梯度计算遵循链式求导法则。
动态图特性:PyTorch建立的计算图是动态的,这也是PyTorch的一大特点。动态图是指程序运行时,每次前向传播时从头开始构建计算图,这样不同的前向传播就可以有不同的计算图,也可以在前向时插入各种Python的控制语句,不需要事先把所有的图都构建出来,并且可以很方便地查看中间过程变量。
backward()函数还有一个需要传入的参数grad_variabels,其代表了根节点的导数,也可以看做根节点各部分的权重系数。因为PyTorch不允许Tensor对Tensor求导,求导时都是标量对于Tensor进行求导,因此,如果根节点是向量,则应配以对应大小的权重,并求和得到标量,再反传。如果根节点的值是标量,则该参数可以省略,默认为1。当有多个输出需要同时进行梯度反传时,需要将retain_graph设置为True,从而保证在计算多个输出的梯度时互不影响。
nn.Module是PyTorch提供的神经网络类,并在类中实现了网络各层的定义及前向计算与反向传播机制。在实际使用时,如果想要实现某个神经网络,只需继承nn.Module,在初始化中定义模型结构与参数,在函数forward()中编写网络前向过程即可。
利用nn.Module搭建神经网络简单易实现,同时较为规范。在实际使用时,应注意如下5点。
在类的__init__()中需要定义网络学习的参数,在此使用nn.Parameter()函数定义了全连接中的ω和b,这是一种特殊的Tensor的构
造方法,默认需要求导,即requires_grad为True。
forward()函数用来进行网络的前向传播,并需要传入相应的Tensor,例如上例的perception(data)即是直接调用了forward()。在具体底层实现中,perception.__call__(data)将类的实例perception变成了可调用对象perception(data),而perception.__call__(data)中主要调用了forward()函数,具体可参考官方代码。nn.Module可以自动利用Autograd机制实现反向传播,不需要自己手动实现。
在Module的搭建时,可以嵌套包含子Module,上例的Perception中调用了Linear这个类,这样的代码分布可以使网络更加模块化,提升代码的复用性。在实际的应用中,PyTorch也提供了绝大多数的网络层,如全连接、卷积网络中的卷积、池化等,并自动实现前向与反向传播。在后面的章节中会对比较重要的层进行讲解。
在PyTorch中,还有一个库为nn.functional,同样也提供了很多网络层与函数功能,但与nn.Module不同的是,利用nn.functional定义的网络层不可自动学习参数,还需要使用nn.Parameter封装。nn.functional的设计初衷是对于一些不需要学习参数的层,如激活层、BN(Batch Normalization)层,可以使用nn.functional,这样这些层就不需要在nn.Module中定义了。
总体来看,对于需要学习参数的层,最好使用nn.Module,对于无参数学习的层,可以使用nn.functional,当然这两者间并没有严格的好坏之分。
当模型中只是简单的前馈网络时,即上一层的输出直接作为下一层的输入,这时可以采用nn.Sequential()模块来快速搭建模型,而不必手动在forward()函数中一层一层地前向传播。因此,如果想快速搭建模型而不考虑中间过程的话,推荐使用nn.Sequential()模块。
在PyTorch中,损失函数可以看做是网络的某一层而放到模型定义中,但在实际使用时更偏向于作为功能函数而放到前向传播过程中。PyTorch在torch.nn及torch.nn.functional中都提供了各种损失函数,通常来讲,由于损失函数不含有可学习的参数,因此这两者在功能上基本没有区别。
nn.optim中包含了各种常见的优化算法,包括随机梯度下降算法SGD(Stochastic Gradient Descent,随机梯度下降)、Adam(AdaptiveMoment Estimation)、Adagrad、RMSProp,这里仅对常用的SGD与Adam两种算法进行详细介绍。
在深度学习中,当前常用的是SGD算法,以一个小批次(Mini Batch)的数据为单位,计算一个批次的梯度,然后反向传播优化,并更新参数。
SGD优化算法的好处主要有两点:
当然,SGD也有其自身的缺点:
有效解决局部最优的通常做法是增加动量(momentum),其概念来自于物理学,在此是指更新的时候一定程度上保留之前更新的方向,同时利用当前批次的梯度进行微调,得到最终的梯度,可以增加优化的稳定性,降低陷入局部最优难以跳出的风险。
公式中的μ为动量因子,当此次梯度下降方向与上次相同时,梯度会变大,也就会加速收敛。当梯度方向不同时,梯度会变小,从而抑制梯度更新的震荡,增加稳定性。在训练的中后期,梯度会在局部极小值周围震荡,此时gt接近于0,但动量的存在使得梯度更新并不是0,从而有可能跳出局部最优解。
虽然SGD算法并不完美,但在当今的深度学习算法中仍然取得了大量的应用,使用SGD有时能够获得性能更佳的模型。
在SGD之外,Adam是另一个较为常见的优化算法。Adam利用了梯度的一阶矩与二阶矩动态地估计调整每一个参数的学习率,是一种学习率自适应算法。
Adam的优点在于,经过调整后,每一次迭代的学习率都在一个确定范围内,使得参数更新更加平稳。此外,Adam算法可以使模型更快收敛,尤其适用于一些深层网络,或者神经网络较为复杂的场景。
以VGG模型为例,在torchvision.models中,VGG模型的特征层与分类层分别用vgg.features与vgg.classifier来表示,每个部分是一个nn.Sequential结构,可以方便地使用与修改。
# VGG16的特征层包括13个卷积、13个激活函数ReLU、5个池化,一共31层
# VGG16的分类层包括3个全连接、2个ReLU、2个Dropout,一共7层
对于计算机视觉的任务,包括物体检测,我们通常很难拿到很大的数据集,在这种情况下重新训练一个新的模型是比较复杂的,并且不容易调整,因此,Fine-tune(微调)是一个常用的选择。所谓Fine-tune是指利用别人在一些数据集上训练好的预训练模型,在自己的数据集上训练自己的模型。
在具体使用时,通常有两种情况,第一种是直接利用torchvision.models中自带的预训练模型,只需要在使用时赋予pretrained
参数为True即可。
第二种是如果想要使用自己的本地预训练模型,或者之前训练过的模型,则可以通过model.load_state_dict()函数操作,
>>> state_dict = torch.load("your model path")
# 利用load_state_dict,遍历预训练模型的关键字,如果出现在了VGG中,则加载预训练参数
>>> vgg.load_state_dict({k:v for k, v in state_dict_items() if k in vgg.state_dict()})
通常来讲,对于不同的检测任务,卷积网络的前两三层的作用是非常类似的,都是提取图像的边缘信息等,因此为了保证模型训练中能够更加稳定,一般会固定预训练网络的前两三个卷积层而不进行参数的学习。例如VGG模型,可以设置前三个卷积模组不进行参数学习。
在PyTorch中,参数的保存通过torch.save()函数实现,可保存对象包括网络模型、优化器等,而这些对象的当前状态数据可以通过自身的state_dict()函数获取。
PyTorch将数据集的处理过程标准化,提供了Dataset基本的数据类,并在torchvision中提供了众多数据变换函数,数据加载的具体过程主要分为3步。
这3步的具体功能与实现如下:
对于数据集的处理,PyTorch提供了torch.utils.data.Dataset这个抽象类,在使用时只需要继承该类,并重写__len__()和__getitem()__函数,即可以方便地进行数据集的迭代。
torchvision.transforms工具包,可以方便地进行图像缩放、裁剪、随机翻转、填充及张量的归一化等操作,操作对象是PIL的Image或者Tensor。如果需要进行多个变换功能,可以利用transforms.Compose将多个变换整合起来,并且在实际使用时,通常会将变换操作集成到Dataset的继承类中。
经过前两步已经可以获取每一个变换后的样本,但是仍然无法进行批量处理、随机选取等操作,因此还需要torch.utils.data.Dataloader类进一步进行封装,使用方法如下例所示,该类需要4个参数,第1个参数是之前继承了Dataset的实例,第2个参数是批量batch的大小,第3个参数是是否打乱数据参数,第4个参数是使用几个线程来加载数据。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。