本文主要是介绍内涵:目标检测之DarkNet-DarkNet源码解读<二>训练篇,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
1. 引言
本篇文章是介绍DarkNet的第三篇文章。第一篇文章主要是介绍DarkNet的使用,重点在于熟悉DarkNet训练集的数据标签形式和相关的使用指令。第二篇文章主要是介绍DarkNet的Test线的源码:包括存储cfg文件的List数据结构;实现网络多链结构的route层的;实现调用不同layer层forward函数的回调机制和检测的前向后处理。本文是DarkNet系列的第三篇文章。按照自己的设计,DarkNet就会以这三篇文章作为主脉络,后续若再有相关的关于DarkNet的文章,也仅仅是对这三篇主干文章的一个补充。
作为DarkNet的最后一条主线文章,本篇文章依旧延续上一篇文章的风格:从入口函数出发,通过不断的分支跟进,从而归纳出一条“线”来。对于这条"线"上的代码,在本篇博文里面不做详细的复制粘贴和注释,这样太影响博客的布局,突出不了重点,具体可以参看我的Github尚未上传。在本篇文章中只对有意思的点加以阐述和研读。
在阅读代码的过程中,认为train线上的以下点有必要和大家分享、交流一下:
- 训练集坐标标签的使用;
- cuda数据变量内容的查看;
- Yolo loss到底是什么样的;
2. 训练数据标签的使用
2.1 多线程加速数据读取速度
在读取训练数据和标签文件的时候,DarkNet采用了多线程的方式来加速,具体的,作者在代码内部写死了为64个线程。相关的代码如下:
void train_detector({...args.threads = 64;//64;//线程64,gdb调试的时候,最好修改为单线程pthread_t load_thread = load_data(args);double time;int count = 0;//while(i*imgs < N*120){while(get_current_batch(net) < net->max_batches){//终止match...}
如上图所示,load_data()函数会经历层层封装调用,最终我们所期待看到的load图片,resize操作,数据增强操作,以及标签文件的读取操作位于load_data_detection()函数中。
在中间层主要是在完成以下操作:循环生成args.thread个线程(官方在代码中内部写死的是64个),然后每个线程去调用我们的load_data_detection()函数。当所有线程调用完毕后,会有一个concat_data()操作对数据进行汇总。为了保证concat之前所有线程已经结束,会循环对每个线程调用pthread_join()函数,“以阻塞的方式等待thread指定的线程结束”。
filename : data.c
void *load_threads(void *ptr)
{...for(i = 0; i < args.threads; ++i){args.d = buffers + i;args.n = (i+1) * total/args.threads - i * total/args.threads;threads[i] = load_data_in_thread(args);}...
如上面的代码片段所示,args.n为分配给每一个进程的读取图片数量。自始至终,都是一个args结构体变量来贯穿始终(或者其影分身),读取的数据(包括待送入网络的训练数据和对应的标签)就存放在args.d中。
ok,中间环节不再赘述,直奔最终的load_data_detection()函数,这里面的内容值得细读其中的代码。由于darknet源码缺少良好的注释以及官方技术文档,在阅读源码的时候,我采用的是gdb的方式来进行调试,此时我把args.threads = 1;这样就避免了gdb调试多线程程序。
gdb调试的指令是:
make
gdb darknet
set args detector train cfg/voc.data cfg/yolov3-voc.cfg darknet53.conv.74
filename: data.cdata load_data_detection(int n, char **paths, int m, int w, int h, int boxes, int classes, float jitter, float hue, float saturation, float exposure)
{ char **random_paths = get_random_paths(paths, n, m);//从数量为m的paths字符串数组中随即抽取n个图片路径;其中m为训练图片的总数量,n为每个线程分得的
"任务"数量int i;data d = {0};d.shallow = 0;d.X.rows = n;//每个线程分得的图片数量d.X.vals = calloc(d.X.rows, sizeof(float*));d.X.cols = h*w*3;//416*416*3 网络输入的宽和高d.y = make_matrix(n, 5*boxes);//5个坐标,boxes为每张图的最大目标数量90for(i = 0; i < n; ++i){image orig = load_image_color(random_paths[i], 0, 0);//用opencv或者c原生代码load图片image sized = make_image(w, h, orig.c);//先做一个空的image,此时像素值初始化为0fill_image(sized, .5);//将空的image里面的像素设置为.5float dw = jitter * orig.w;//jitter扰动系数,官方网络给的是0.3float dh = jitter * orig.h;float new_ar = (orig.w + rand_uniform(-dw, dw)) / (orig.h + rand_uniform(-dh, dh));//float scale = rand_uniform(.25, 2);float scale = 1;//new_ar = 5;float nw, nh;if(new_ar < 1){nh = scale * h;nw = nh * new_ar;} else {nw = scale * w;nh = nw / new_ar;}float dx = rand_uniform(0, w - nw);float dy = rand_uniform(0, h - nh);place_image(orig, nw, nh, dx, dy, sized);//怎么放,是一种随即的坐标,并不是放到最中间。这点很有意思可以写一写//save_image(orig, "orig");//char sized_name[128] = "";//sprintf(sized_name, "nw-%f-nh-%f-dx-%f-dy-%f-sized", nw, nh, dx, dy);//save_image(sized,sized_name);random_distort_image(sized, hue, saturation, exposure);//随即做一些扰动,来作为数据扩增int flip = rand()%2;//flip = 0;if(flip) flip_image(sized);//随机做反转操作d.X.vals[i] = sized.data;//w和h是网络的输入宽和高,特定的此处为416,416//知道-dx/w, -dy/h, nw/w, nh/h是怎么来的,有什么物理意义是核心;//fill_truth_detection(random_paths[i], boxes, d.y.vals[i], classes, flip, -dx/w, -dy/h, nw/w, nh/h);fill_truth_detection_custom(random_paths[i], boxes, d.y.vals[i], classes, flip, -dx/w, -dy/h, nw/w, nh/h, orig, sized);free_image(orig);}free(random_paths);return d;
}
上述代码,先看这部分代码:
float dw = jitter * orig.w;//jitter扰动系数,官方网络给的是0.3float dh = jitter * orig.h;float new_ar = (orig.w + rand_uniform(-dw, dw)) / (orig.h + rand_uniform(-dh, dh));//float scale = rand_uniform(.25, 2);float scale = 1;//new_ar = 5;float nw, nh;if(new_ar < 1){nh = scale * h;nw = nh * new_ar;} else {nw = scale * w;nh = nw / new_ar;}float dx = rand_uniform(0, w - nw);float dy = rand_uniform(0, h - nh);place_image(orig, nw, nh, dx, dy, sized);//怎么放,是一种随即的坐标,并不是放到最中间。这点很有意思可以写一写
这部分代码主要做了两个操作"保持原尺寸缩放+随机扰动的形变"。假若jitter设置为0,或者random_uniform(-dw, dw)和random_uniform(-dh, dh)都碰巧取到0,则该部分代码退化为"等比例缩放"。
等比例缩放可以总结为“以长边为主”:
若 r a t i o = w s r c / h s r c > w n e t / h n e t ratio = w_{src}/h_{src} > w_{net} / h_{net} ratio=wsrc/hsrc>wnet/hnet, 则 w r e s i z e = w n e t w_{resize} = w_{net} wresize=wnet, h r e s i z e = w r e s i z e / r a t i o h_{resize} = w_{resize} /ratio hresize=wresize/ratio;
若 r a t i o = w s r c / h s r c < w n e t / h n e t ratio = w_{src}/h_{src} < w_{net} / h_{net} ratio=wsrc/hsrc<wnet/hnet, 则 h r e s i z e = h n e t , h_{resize} = h_{net}, hresize=hnet, w r e s i z e = h n e t ∗ r a t i o w_{resize} = h_{net} * ratio wresize=hnet∗ratio;
其中DarkNet源码中跟"1"比较,是因为DarkNet官方cfg中网络的宽和高都是相等的,所以net_w / net_h=1,但若自己设计cfg有更随意的比例范围时,源码中的此处记得要更改。但网络统一固定为
如果该图片仅仅在训练中出现一次的话,保持原尺寸等比例缩放是一种最不失真的缩放方式,是我们想要的。但神经网络的训练迭代次数是冗余的,在迭代次数冗余的情况下,失真对于我们来讲也是一种数据扩增手段。
float dw = jitter * orig.w;
float dh = jitter * orig.h;
而且如下面代码所示,resize之后的图片并不是顶着左上部放置,也不是放到中间,而是一种随即offset的一种放置。
float dx = rand_uniform(0, w - nw);
float dy = rand_uniform(0, h - nh);
place_image(orig, nw, nh, dx, dy, sized);
我这里把jitter数值调大到5,来放大一下失真和offset的效果。就可以看到实际的DarkNet是一种含有长宽比失真,且随机offset的resize方式。我这里设置为5仅仅是体现失真的效果,实际使用时,为了数据扩增可以温和的设置为0.3左右即可。
当然除了这种暴力resize之外,DarkNet还提供了丰富的数据增强手段:
数据增强手段 | 设置 | 含义 |
---|---|---|
hue | cfg的net层 | 0.1,色度 |
saturation | 同上 | 1.5,饱和度 |
exposure | 同上 | 1.5, 曝光 |
flip | 源码内部随机 | 随机翻转 |
random_distort_image(sized, hue, saturation, exposure);//随即做一些扰动,来作为数据扩增
int flip = rand()%2;
//flip = 0;
if(flip) flip_image(sized);//随机做反转操作
d.X.vals[i] = sized.data;
这部分没有什么太多要讲的,在这一系列操作之后,就可以获得网络所需要的数据输入了,传递给d.X.vals[i]。但对于坐标标签,由于不规则resize操作以及可能随即生效的flip操作,因此标签坐标还需要通过如下代码做一部分转换。
boxes[i].left = boxes[i].left * sx - dx;//此处是核心,弄明白sx和dx是怎么来的
boxes[i].right = boxes[i].right * sx - dx;
boxes[i].top = boxes[i].top * sy - dy;
boxes[i].bottom = boxes[i].bottom* sy - dy;
if(flip){//如果原图做了翻转操作,则真值坐标也做翻转操作float swap = boxes[i].left;boxes[i].left = 1. - boxes[i].right;boxes[i].right = 1. - swap;
}
理论上,boxes的right,left,top,bottom是归一化坐标,因此resize并不会影响其前后归一化坐标。做转换,是因为
- 如上面展示的图片所示,resize之后平铺net的宽和高构造的image之后,会存在offset,此时就会出现dx,dy的偏移;
- resize尺寸与net尺寸存在一个缩放系数;
sx = resize_w / net_w
sy = resize_h/ net_h
2.2 多尺度训练
filename : detector.cif(l.random && count++%10 == 0){//l.random在yolo层设置死的为0,所以每隔10次resize一次。printf("Resizing\n");int dim = (rand() % 10 + 10) * 32;//320到640之间if (get_current_batch(net)+200 > net->max_batches) dim = 608;//最后200个batch,固定为608//int dim = (rand() % 4 + 16) * 32;printf("%d\n", dim);args.w = dim;args.h = dim;
如上述代码所述,如果l.random参数打开,每10次迭代,会随机在320到460之间随机挑选一个dim,来作为新的网络的宽和高。random参数对应于cfg文件中的yolo层的random参数。
但这里不得不吐槽一下,这里的32是一个magic number。
3. 如何查看cuda中的变量
filename : network.c
void forward_network_gpu(network *netp)
{network net = *netp;cuda_set_device(net.gpu_index);cuda_push_array(net.input_gpu, net.input, net.inputs*net.batch);if(net.truth){cuda_push_array(net.truth_gpu, net.truth, net.truths*net.batch);}...
}
左屏vscode,不断的跳转。右屏gdb终端窗口,时不时p一下自己疑惑的变量名称,印证一下自己想法。遵循着这样的套路,跳转到上面这段代码时,失效了。这部分应该是cuda编程的代码,不是特别熟悉。
cuda_push_array(),应该是将net.input指向的数据移动至net.input_gpu上。如果依旧在pdb上,打印的话,会发现即使push完之后net.input_gpu的数据依旧为0,与自己的预期不符合。
(gdb) p *net.input_gpu
$7 = 0
自己对cuda编程并不熟悉。猜测是,net.input是指向内存上的一段数据;而input_gpu是指向“显存”上的数据。而gdb无法p出来显存上的变量。那么如何知道自己net.input_gpu上的是否有数据,以及是什么样的数据:可以将gpu上的数据再移动到一个内存块上,然后打印内存块上的数据,就可以得到上述两个疑惑的答案。
filename : network.c
void forward_network_gpu(network *netp)
{network net = *netp;cuda_set_device(net.gpu_index);cuda_push_array(net.input_gpu, net.input, net.inputs*net.batch);if(net.truth){cuda_push_array(net.truth_gpu, net.truth, net.truths*net.batch);}#if 1float * array_cpu_debug = (float *)calloc(net.inputs*net.batch, 1); cudaError_t status = cudaMemcpy(array_cpu_debug, net.input_gpu, net.inputs*net.batch, cudaMemcpyDeviceToHost);check_error(status);#endif...
}
此时就可以间接的看到显存里面的内容了。
(gdb) p *net.input
$1 = 0.701874614
(gdb) p *net.input_gpu
$2 = 0
(gdb) p *array_cpu_debug
$3 = 0.701874614
(gdb)
当然这样调试感觉比较简陋,有机会可以搞一下CUDA编程,相信应该会有专门的显存变量调试方法。本篇文章,就先这么凑合着用吧。
4. Yolo loss到底是什么样的
网上关于Yolo Loss的正确形式有一定的争议,其中j不少优秀的博主关于Yolov3的损失函数都做了一定的阐述。两位博主的阐述都十分优秀,具体可见参考文献链接。但我想从另外一个角度再来阐述一下这个问题:yolo loss的形式应该是什么,以及为什么是这样?
4.1 基础知识回顾回归
YOLO的Loss虽然复杂,但其无非就是三种Loss的混合叠加:box loss、obj loss和 class loss,而涉及到的也不外乎回归任务(box属性)和分类任务(obj属性 和 class属性)。我相信yolo作者在设计loss的时候,用的肯定是最为自然直接能想到的loss。而我们最熟悉的Loss或者说听得最多的,无非交叉熵loss(Cross Entroy Loss)和均方差loss(Mean Squar Loss)。其他如果太高端或者太新的Loss,肯定会被论文作者当作亮点放在显眼处,显然实际情况并没有。
4.1.1 分类–交叉熵误差
在信息论中,信息是用来消除随机不确定性的东西。如果你提出的一句话里面描述的事件发生的概率越小,你的话蕴含的信息量越大。形象的来讲:
张三:我感断定抛一枚筛子,点数不会超过6。
李四:(不耐烦的反白眼)偶~
张三: 我感断定股票XX(一直跌了3年的股票),明天要涨30个点
李四:(赶紧凑过来)嗯?
也即信息量与事件发生的概率成反比。信息论中用如下的公式来定量描述信息量与事件概率的关系:
I ( x ) = − l o g ( P ( x ) ) − − − − − − − 《 1 》 I(x)=−log(P(x))-------《1》 I(x)=−log(P(x))−−−−−−−《1》
信息量的期望被称作(信息)熵
H ( X ) = − ∑ i = 1 n P ( x i ) l o g ( P ( x i ) ) ) ( X = x 1 , x 2 , x 3 . . . , x n ) H(X)=− \sum_{i=1}^{n} P(x_{i})log(P(x_{i})))(X=x_{1},x_{2},x_{3}...,x_{n}) H(X)=−i=1∑nP(xi)log(P(xi)))(X=x1,x2,x3...,xn)
对于0-1分布,上述(信息)熵退化为:
H ( X ) = − ∑ i = 1 n P ( x i ) l o g ( P ( x i ) ) ) = − P ( x ) l o g ( P ( x ) ) − ( 1 − P ( x ) ) l o g ( 1 − P ( x ) ) H(X)=− \sum_{i=1}^{n} P(x_{i})log(P(x_{i})))=−P(x)log(P(x))−(1−P(x))log(1−P(x)) H(X)=−i=1∑nP(xi)log(P(xi)))=−P(x)log(P(x))−(1−P(x))log(1−P(x))
其中 P ( x ) P(x) P(x)表示事件 x x x发生的概率。
在这篇介绍KL散度的文章中,作者举了一个蚜虫的例子。在这个例子中,需要通过一个便于压缩的分布来模拟来自外太空的真实分布,传输回地球。人类期望使用模拟分布得到的信息量也尽量与真实分布相同,这是我们最想看到的。于是就用信息量的差值的期望来描述两个分布之间的差异,也即KL散度。
D k l ( p / q ) = ∑ i = 1 n P ( x i ) ( l o g ( P ( x i ) ) − l o g ( Q ( x i ) ) ) = − ∑ i = 1 n P ( x i ) l o g ( P ( x i ) ) + ∑ i = 1 n P ( x i ) l o g ( Q ( x i ) ) D_{kl}(p/q) = \sum_{i=1}^{n}P(x_{i})(log(P(x_{i}))-log(Q(x_{i}))) =-\sum_{i=1}^{n}P(x_{i})log(P(x_{i}))+\sum_{i=1}^{n}P(x_{i})log(Q(x_{i})) Dkl(p/q)=i=1∑nP(xi)(log(P(xi))−log(Q(xi)))=−i=1∑nP(xi)log(P(xi))+i=1∑nP(xi)log(Q(xi))
也就是说该 k l 散度=-信息熵 + 交叉熵 kl散度=-信息熵+交叉熵 kl散度=-信息熵+交叉熵
k l kl kl散度用来度量在逼近一个分布时的信息损失量,而且 k l kl kl散度具有两个很好的属性:
- k l ≥ 0 kl \ge 0 kl≥0
- k l = 0 ; w h i l e P ( X ) = = Q ( X ) kl = 0; while P(X)==Q(X) kl=0;whileP(X)==Q(X)
因此 k l kl kl散度被用作神经网络中的损失函数来衡量模型分布与实际分布的差异。信息熵是一个常量(对于one-hot来讲为0,算是常量的一个特例),因此 k l kl kl散度等于0等同于交叉熵最小,这就是交叉熵损失函数的由来。
c e = − ∑ i = 1 n P ( x i ) l o g ( Q ( x i ) ) ce= -\sum_{i=1}^{n}P(x_{i})log(Q(x_{i})) ce=−i=1∑nP(xi)log(Q(xi))
记住这里的 P ( x i ) P(x_i) P(xi)是概率,因为网上好多人会直接写成 y i l o g y i ∗ y_{i}logy_{i}^* yilogyi∗,然后就把 y i y_i yi作为标签值来理解,并使用。但如果 y i y_{i} yi是一个(0~1)范围的数字,你这样用也行,因为它满足了概率的范围,此时理解有误,但使用没问题。
交叉熵是没有单独使用的其前面一定会接softmax(比如caffe中的softmaxloss是最常用的Loss)或者sigmoid激活函数。这是因为 Q i Q_{i} Qi处也是一个概率的物理意义,所以使用ce必须将其归一化,然后才符合ce loss的出处。但可能大部分人都没有思考过这个问题,为什么softmax loss的出处。
为什么讲一定是softmax或者sigmoid函数(为了强调,我可能说的有点绝对了,但据我有限的工作生涯,见到的还就这两个),这是因为这两个函数和ce配合不但能满足其归一化,不违背相应处概率的物理意义,另一方面还有就是它们和ce配合起来,获得的loss梯度具有很好的形式。这里证明就不再讲了,直接说结论:
∂ c e ∂ z i = a i − y i \frac{\partial{ce}}{\partial{z_{i}}}=a_{i}-y_{i} ∂zi∂ce=ai−yi
说一下这个公式的三种用法:
- 最常用的hard label 分类&& BCE: a i − 1 a_i-1 ai−1或者 a i − 0 a_i-0 ai−0
- soft label 分类(eg label smooth): a i − y i a_i-y_i ai−yi
- 回归soft CE: a i − y i a_i-y_i ai−yi
务必记住, a i a_i ai和 y i y_i yi都是概率值不是标签值,其范围一定要位于(0~1)范围内。
4.1.2 回归–均方差误差
引用周志立老师在机器学习西瓜书2.3节性能度量中的一句话。
回归任务最常用的性能度量是“均方误差”(mean squared error)
E ( f ; D ) =1 / m ∑ i = 1 m ( f ( x i ) − y i ) 2 E(f;D)=1/m\sum_{i=1}^m(f(x_{i})-y_i)^2 E(f;D)=1/mi=1∑m(f(xi)−yi)2
此时
∂ e ∂ z = 2 m ∗ ( a − y ) ∗ ∂ a ∂ z \frac{\partial{e}}{\partial{z}}=\frac{2}{m}*(a-y)*\frac{\partial{a}}{{\partial{z}}} ∂z∂e=m2∗(a−y)∗∂z∂a
可以看到梯度的前半部分是一个很简洁的一阶多项式。因此我们一般不会再画蛇添足去添加激活函数,此时 a = z a=z a=z。
这里要讲两点很关键的点,
- 在Darknet,预测一个box有四个坐标x,y,w,h,但作者对x,做了sigmoid函数激活,但对w,h没做,因此虽然都是坐标,但前者用的是ce 具体来讲是soft ce(这其实是一种非常规做法,但这是作者有意为之的,后面会讲到原因),w,h则符合预期的使用mse。
- 但ce和mse的梯度形式几乎一样,如果忽略不重要的前面的系数因子的话,但其实本质是完全不一样的,因为ce之所以有这么漂亮的梯度形式,是因为它必须要有激活函数。
其实通过4.1节,想表达的是这样一个思想:从概念的推导过程来讲,交叉熵先天性的适用于离散的分类任务;而在学术界的惯例,回归任务使用均方误差则更加符合直觉。
4.2 YoloV3的Loss到底是什么样
4.2.1 Yolov3论文中的表述
if bounding box prior没有对应的真值框:它的loss仅仅有obj的Loss。
如果bounding box prior有对应的真值框,它的loss则包含了三者box, obj和class。
class 预测的loss 为binary cross-entropy loss
坐标的预测为sum of squared error loss。
obj的预测loss作者没提及,因为它没什么特殊之处和yolv2一样是一个BCE。
4.2.2 DarkNet源码中的实际实现
首先要讲的是,DarkNet源码中并没有Yolov3的loss代码。有的只是Yolo层loss的梯度代码。所以只能通过梯度来反向验证自己的loss的理解,这种间接的验证导致了loss的具体形式的理解,稍有不慎就会出现一定的偏差。
来看DarkNet源码中是如何实现的。
filename : yolo_layer.c
int box_index = entry_index(l, b, mask_n*l.w*l.h + j*l.w + i, 0);
float iou = delta_yolo_box(truth, l.output, l.biases, best_n, box_index, i, j, l.w, l.h, net.w, net.h, l.delta, (2-truth.w*truth.h), l.w*l.h);int obj_index = entry_index(l, b, mask_n*l.w*l.h + j*l.w + i, 4);
avg_obj += l.output[obj_index];
l.delta[obj_index] = 1 - l.output[obj_index];//已经搞定int class = net.truth[t*(4 + 1) + b*l.truths + 4];
if (l.map) class = l.map[class];
int class_index = entry_index(l, b, mask_n*l.w*l.h + j*l.w + i, 4 + 1);
delta_yolo_class(l.output, l.delta, class_index, class, l.classes, l.w*l.h, &avg_cat);
首先来看坐标的梯度计算方式:delta_yolo_box()
float delta_yolo_box(box truth, float *x, float *biases, int n, int index, int i, int j, int lw, int lh, int w, int h, float *delta, float scale, int stride)
{box pred = get_yolo_box(x, biases, n, index, i, j, lw, lh, w, h, stride);float iou = box_iou(pred, truth);float tx = (truth.x*lw - i);float ty = (truth.y*lh - j);float tw = log(truth.w*w / biases[2*n]);float th = log(truth.h*h / biases[2*n + 1]);delta[index + 0*stride] = scale * (tx - x[index + 0*stride]);delta[index + 1*stride] = scale * (ty - x[index + 1*stride]);delta[index + 2*stride] = scale * (tw - x[index + 2*stride]);delta[index + 3*stride] = scale * (th - x[index + 3*stride]);return iou;
}
这里要注意的是,我们先看一看yolo层到底做了那些操作。
void forward_yolo_layer(const layer l, network net)
{int i,j,b,t,n;memcpy(l.output, net.input, l.outputs*l.batch*sizeof(float));#ifndef GPU//将x,y和1+c的预测值进行归一化操作,具体的归一化操作的函数为log回归for (b = 0; b < l.batch; ++b){for(n = 0; n < l.n; ++n){int index = entry_index(l, b, n*l.w*l.h, 0);//注意这里是n不是l.n,darknet的代码风格真的是一言难尽activate_array(l.output + index, 2*l.w*l.h, LOGISTIC);index = entry_index(l, b, n*l.w*l.h, 4);activate_array(l.output + index, (1+l.classes)*l.w*l.h, LOGISTIC);}}
#endifmemset(l.delta, 0, l.outputs * l.batch * sizeof(float));if(!net.train) return;//到这边结束,后面涉及到的就是train阶段的梯度计算内容。
x | y | w | h | obj | c 0 c_{0} c0 | c 1 c_{1} c1 | c 2 c_{2} c2 | . . . ... ... | c n − 1 c_{n-1} cn−1 |
---|---|---|---|---|---|---|---|---|---|
σ ( x ) \sigma(x) σ(x) | σ ( y ) \sigma(y) σ(y) | w | h | σ ( o b j ) \sigma(obj) σ(obj) | σ ( c 0 ) \sigma(c_{0}) σ(c0) | σ ( c 1 ) \sigma(c_{1}) σ(c1) | σ ( c 2 ) \sigma(c_{2}) σ(c2) | σ ( c . . . ) \sigma(c_{...}) σ(c...) | σ ( c n − 1 ) \sigma(c_{n-1}) σ(cn−1) |
其实如果单说前向的话,yolo.c就是这么简单,操作就这么多。这里的 σ \sigma σ函数使用的是logistic函数,记住我们一切的loss都是以前向操作来的,这点极其极其重要。我们知道 σ \sigma σ除了是一种激活函数外,还具有归一化的作用,任意 x x x经 s i g m o i d sigmoid sigmoid函数压缩至[0,1],满足概率意义。对于obj没什么好讲的,我们本身就是需要这样的概率值。对于 c 0 c_{0} c0, c 1 c_{1} c1,… c n − 1 c_{n-1} cn−1,可能我们习惯于softmax然后接cross entroy。但是如下图所示:作者特意强调,使用简单的sigmoid并不会影响性能。而且逻辑上,而且softmax带有类间的排斥性,因此在v3中作者换做使用了sigmoid,我们前面讲过sigmoid的梯度形式和softmax的梯度形式一样,所以对代码无影响。 | |||||||||
再有就是注意到,作者对x,y,w,h中的x和y也进行了 σ \sigma σ操作(这里一定要注意仅仅对x,y进行了这样的操作,w和h并没有),坐标为什么也要进行 s i g m o i d sigmoid sigmoid操作:原因是因为我们希望无论tx和ty是什么值, σ ( t x ) \sigma(t_{x}) σ(tx)和 σ ( t y ) \sigma(t_{y}) σ(ty)的范围都是[0~1],这样即使在训练初始阶段,box的中心点预测值也位于当前cell内部,保证学习初期不至于过于偏,训练更加平稳。而对于w和h就没有这样的要求了。因为这一点的关系导致x,y,w,h使用的loss并不一样,前者为soft CE loss,后者为MSE loss。 | |||||||||
嗯,讲了这么多,让我们再回到delta_yolo_box函数,注意,这里的代码显示的x,y,w,h的梯度形式很像。但确实一个完全不同的意义。 |
delta[index + 0*stride] = scale * (tx - x[index + 0*stride]);
delta[index + 1*stride] = scale * (ty - x[index + 1*stride]);
delta[index + 2*stride] = scale * (tw - x[index + 2*stride]);
delta[index + 3*stride] = scale * (th - x[index + 3*stride]);
首先来讲tx和ty,它是ce函数的梯度注意这里的x是x=sigmoid(x)过的,其梯度通式为:
∂ d ∂ z i = a i − y i \frac{\partial{d}}{\partial{z_{i}}}=a_{i}-y_{i} ∂zi∂d=ai−yi
显然对于BCE loss来讲,当 y i = 1 y_{i}=1 yi=1,则 d e l t a = a i − 1 delta=a_{i}-1 delta=ai−1;当 y i = 0 , 则 y_{i}=0,则 yi=0,则delta=a_{i}-0$。对于obj和class都是这样的,因此这边先提前把这两大部分loss讲了,见如下代码:
obj相关delta代码
int obj_index = entry_index(l, b, n*l.w*l.h + j*l.w + i, 4);avg_anyobj += l.output[obj_index];l.delta[obj_index] = 0 - l.output[obj_index];
if(mask_n >= 0){...int obj_index = entry_index(l, b, mask_n*l.w*l.h + j*l.w + i, 4);avg_obj += l.output[obj_index];l.delta[obj_index] = 1 - l.output[obj_index];//已经搞定
class相关代码:
void delta_yolo_class(float *output, float *delta, int index, int class, int classes, int stride, float *avg_cat)
{int n;if (delta[index]){delta[index + stride*class] = 1 - output[index + stride*class];if(avg_cat) *avg_cat += output[index + stride*class];return;}for(n = 0; n < classes; ++n){delta[index + stride*n] = ((n == class)?1 : 0) - output[index + stride*n];if(n == class && avg_cat) *avg_cat += output[index + stride*n];}
}
讲完这两个相对比较简单的,再回到我们重点关注对象x,y。依旧看 y i − a i y_i-a_{i} yi−ai,这里要说明此公式最初的起源是KL散度,因此 y i y_i yi一定要把他理解为标签的概率值,网上有很多人把她称之为标签值,我感觉是有问题的,因为不符合在KL公式中的含义。
那么,这个标签的概率,应该恒为1,为什么这里是 t x t_x tx和 t y t_y ty呢?这是因为在回归任务中使用的是CE的变体,网上将其称之为soft cross entroy。也就是说对标签的概率值,我并不是完全100%的确定。这个可能有点抽象,可以类比于分类中的soft label(label smooth)之类的。比如,一只即像老鼠又像仓鼠的动物,我们可能给他的标签是老鼠,但可以将它的概率值是0.9。那么现在也就是讲我们可以接受, t x t_x tx不一定是1,那么你可能会问, t x t_x tx既然是概率,她的范围应该是(0~1),嗯,是的。作者对坐标真值也做了退化处理,保证其在(0~ 1)范围内。你可能还有疑问,就是虽然可以接受 t x t_x tx, t y t_y ty的范围是(0-1),但这个概率的物理意义是什么呢?答案是不知道,其实从整个神经网络不断叠加不断叠加,最后经过softmax或者sigmoid,我们就认可它是一个概率,那么,why不能接受,这样一个同样值阈为[0~1]的一个函数是分布函数呢。
float tx = (truth.x*lw - i);
float ty = (truth.y*lh - j);delta[index + 0*stride] = scale * (tx - x[index + 0*stride]);
delta[index + 1*stride] = scale * (ty - x[index + 1*stride]);
delta[index + 2*stride] = scale * (tw - x[index + 2*stride]);
delta[index + 3*stride] = scale * (th - x[index + 3*stride]);
再来看看w和h,看起来w,h和x,y的梯度形式是一样的,为什么讲前者是sce(soft cross entroy)而后者确实mse呢?因为前者经历了一个sigmoid函数,而后者没有。
x | y | w | h |
---|---|---|---|
σ ( x ) \sigma(x) σ(x) | σ ( y ) \sigma(y) σ(y) | w | h |
for (b = 0; b < l.batch; ++b){for(n = 0; n < l.n; ++n){int index = entry_index(l, b, n*l.w*l.h, 0);//注意这里是n不是l.n,darknet的代码风格真的是一言难尽activate_array(l.output + index, 2*l.w*l.h, LOGISTIC);index = entry_index(l, b, n*l.w*l.h, 4);activate_array(l.output + index, (1+l.classes)*l.w*l.h, LOGISTIC);}}
因此根据4.1.2节的mse公式,形式上看起来就和x,y的一摸一样(这里忽略系数),但其本质却大不相同。
5. 结束语
DarkNet的第三篇文章结束了。接下来会暂时告一段落,转向其他主题。另一方面,自己也会讲添加注释版的DarkNet上传至github,再有就是文章写的有点仓促,自己也会逐步完善再完善下。DarkNet其实还有一些点,自己也想写,但感觉后面放到番外篇吧。
结束于 2020 11 28 浦东
6. 参考文献
- 北溟客的关于Yolov3源码的解读
- Cigar关于交叉熵的解读
- just_sort关于Yolov3损失函数的解读
- 行云关于Yolov3的损失函数总结
- KL散度范围的证明
这篇关于内涵:目标检测之DarkNet-DarkNet源码解读<二>训练篇的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!