反向传播存在于训练过程中,入口函数为detector.c的loss = train_network(net, train);下面是传播流程:
步骤 | 函数 | 源码文件 | 功能 |
---|---|---|---|
1 | train_detector(datacfg, cfg, weights, gpus, ngpus, clear); | detector.c | 训练数据集,参数有数据路径文件、网络配置文件、权重文件、gpu地址及个数、是否清除已训练图片数量标记 |
2 | loss = train_network(net, train); | network.c | 在train_detector函数中,参数有网络数据结构network *net和data train。train通过线程函数循环按batch读入。 |
3 | float err = train_network_datum(net); | network.c | 在train_network函数中,参数net中包含net->input, net->truth等数据信息,net->input为resize后的三通道数据。 |
4 | backward_network(net); | network.c | 在train_network_datum函数中,参数net中更新了net->seen信息,就是每个批次训练过的图片数量 |
5 | l.backward(l, net); | darknet.h | 在backward_network函数中,按照层进行倒序循环调用,这个函数功能在make_yolo_layer、make_convolutional_layer中进行定义 |
6 | 通过l.backward进行每个层具体的反向传播计算 | l为当前层的layer,net中的input和delta为前一层的input和delta;更新的l.weight | |
7 | update_network(net); | network.c | 次步骤在backward_network(net)执行之后,对weight和biases进行更新 |
8 | save_weights(net, buff); | parse.c | 在完成一个batch的训练之后,在network结构体中更新一次weight,每训练100个batch,保存一次weights到一个新的weight文件,这里保存的除了卷积核还有每个层的偏置吧biases,这时候就算完成更新了w和b |
在之前《YOLOv3反向传播原理 之 公式推导》中推导反向传播公式时,都是针对一张图片的数据处理,但是在实际的软件实现中,每一次训练都是按照一个batch的图片进行训练,在上表第3步的train_network_datum函数中(源码如下),通过紧邻的forward_network(net)和backward_network(net)实现前向传播和反向传播。
float train_network_datum(network *net)
{
*net->seen += net->batch;
net->train = 1;
forward_network(net);//前向传播
backward_network(net);//反向传播
float error = *net->cost;
/*利用update_network(net)-->l.update(在不同层定义不同,
如在convolutional_layer.c中就是update_convolutional_layer)更新weight
*/
//训练图片达到一个整批就可以更新一次网络参数
if(((*net->seen)/net->batch)%net->subdivisions == 0) update_network(net);
return error;
}
在forward_network(net)中,r如果l.delta!=0通过,对整个批次的l.delta赋值为0,源码如下,:
//前向网络操作的核心代码
void forward_network(network *netp)
{
......
//#pragma omp parallel for
for(i = 0; i < net.n; ++i){
//每一个网络层循环一次
net.index = i;
layer l = net.layers[i];//这一步隐藏玄机,在一开始解析网络的时候,就决定了l会对应那个前向函数
if(l.delta){
fill_cpu(l.outputs * l.batch, 0, l.delta, 1);//输出×批尺寸,统统填0
}
......
}
}
......
}
反向传播计算梯度的第一步是计算LOSS函数,LOSS函数中,是将一个batch的图片所有处理结果的误差(delta)进行累加计算的,所以在计算每个权重(weight)的梯度时,也需要在一个批次内进行累加。
前向传播的过程中,l.delta变量中存储了每个yolo层对一个批次内每个图片计算的误差,但是这些信息在这个阶段并没有累加,而是存储于内存,再最终计算weight的时候进行累加。
重点需要关注的几个输入输出变量:
network *net;
typedef struct network{
int n; // 网络总层数(make_network()时赋值),但是直接下载的现成的weight文件有现成的
int batch; // parse_net_options()中赋值:一个batch含有的图片张数,批处理的图片个数,一般再训练中的batch=cfg文件中batch值/subdivision
size_t *seen; // 目前已经读入的图片张数(网络已经处理的图片张数)(在make_network()中动态分配内存)
layer *layers; //存储网络所有的层的信息,在make_network()中动态分配内存
int inputs; //在实际操作过程中,input作为network的一部分,在network和layer两个结构体中有inputs和outputs
//注意,都是int型,表示一张输入图片的元素个数
//如果网络配置文件中未指定,则默认等于net->h * net->w * net->c,在parse_net_options()中赋值
int outputs; //一张输入图片对应的输出元素个数,对于一些网络,可由输入图片的尺寸及相关参数计算出
//比如卷积层,可以通过输入尺寸以及跨度、核大小计算出;
//对于另一些尺寸,则需要通过网络配置文件指定,如未指定,取默认值1,比如全连接层
float *delta; //存储一个临时梯度,一般是当前层前一层输出结果的梯度
float *workspace; //用于存储临时计算结果
float *input; // 中间变量,用来暂存某层网络的输入(包含一个batch的输入,比如某层网络完成前向,
// 将其输出赋给该变量,作为下一层的输入,可以参看network.c中的forward_network()与backward_network()两个函数),
// 当然,也是网络接受最原始输入数据(即第一层网络接收的输入)的变量
//(比如在图像检测训练中,最早在train_detector()->train_network()->get_next_batch()函数中赋值)
} network;
layer l,prev;
struct layer{
float * delta; //存储针对当前层输出结果的梯度
float * output; //当前层的输出feature map的集合
int w,h,c; //当前层输出的feature map宽度,高度,每组卷积核(滤波器)的个数,c的个数为前一层输出中feature map的个数,有时候也说是输出的维度
float * biases; //更新的偏置
float * bias_updates; //偏置的梯度
float * weights; //权重的梯度
float * weight_updates; //更新的权重,不知道是什么原因,作者编程时把权重和梯度形式上反着写
float * scales; //标准化中的尺度变换系数
float * scale_updates; //标准化中的尺度变换系数的梯度
int out_h, out_w, out_c; //当前层输出结果的高,宽,通道数
int n; //卷积核的组数,决定了当前层输出的feature map的个数
float * mean; //feature map均值,用于标准化层
float * variance; //feature map均方值,用于标准化层
float * mean_delta; //梯度均值,用于标准化层
float * variance_delta; //梯度均方值,用于标准化层
float * rolling_mean; //weight文件中的初始均值,用于标准化层
float * rolling_variance; //weight文件中的初始均方值,用于标准化层
int batch_normalize; //该层中是否有标准化
int batch; //一个层包含的处理的一个批次图片数量,主要为input,output,delta,weight等保存空间和确定计算次数,一般具体为多少看batch是否除以subdivisions,当说完成一个batch图片训练时候,需要除,当更新权重时候又会乘回来
int inputs; //一个层输入值的尺寸
int outputs; //一个层输出值的尺寸
int nweights; //一个层权重值的尺寸
int nbiases; //一个层偏置值的尺寸
};
下面我们就分析前面表格中的第6步中的内容:
YOLO层反向传播只有一个直接传播的步骤。l.delta存储的就是LOSS针对YOLO层前一层输出l.output的梯度,这个梯度求解过程中,使用平方差LOSS函数和交叉熵LOSS函数的效果都是一样的,这个推导过程在前一篇文章《YOLO中LOSS函数的计算》中有说明。YOLO反向传播的主函数(yolo_layer.c)如下:
//const layer l, network net分别对应表格第5步l.backward(l, net)中的l和net。
void backward_yolo_layer(const layer l, network net)
{
//axpy_cpu在blas.c中,主要用于将l.delta传递到前一层的net.delta中。
//这里参考make_yolo_layer函数,可知l.inputs = l.outputs = h*w*n*(classes + 4 + 1);
axpy_cpu(l.batch*l.inputs, 1, l.delta, 1, net.delta, 1);
}
axpy_cpu函数可以完成将X中元素乘以权值ALPHA,和Y中对应位置元素相乘。那么不是说好的直接传递吗?根据1.1节初始化的分析,前一层的delta统统赋值为0,所以累加等于赋值。说实话,本人感觉直接使用blas.h中的copy_cpu函数也是一样的效果。
void axpy_cpu(int N, float ALPHA, float *X, int INCX, float *Y, int INCY)
{
int i;
for(i = 0; i < N; ++i) Y[i*INCY] += ALPHA*X[i*INCX];
}
好了,把YOLO层的梯度求完了,按照cfg文件,就该到卷积层了。
convolutional_layer类型实际上就是layer,在convolutional_layer.h中有声明:
typedef layer convolutional_layer;
接着看convolutional_layer.c中源码和分析:
//反向传播,反向的关键,反向传播的各种数据通过两个参数传递,一个是当前层(l层)layer,一个是前一层(l-1层)network
//convolutional_layer 的类型还是layer
//所有的*input,*output,*delta,*truth都是在network中的,因为这些信息需要连贯性
//每一层feature map的宽、高、每组卷积核中卷积核的个数,滤波器组数等信息都是在layer中。
//反向传播的计算和前向传播一样也是要用到重排和矩阵乘法计算
// net.delta = l.delta×激活函数对权重的误差,卷积误差传递
void backward_convolutional_layer(convolutional_layer l, network net)
{
//*************尺寸参数定义和初始化*****************//
int i, j;
int m = l.n/l.groups;//滤波器核函数的个数×组个数,这里group都是1,在parse.c中设置
int n = l.size*l.size*l.c/l.groups;//当前层卷积核总尺寸,size是卷积核的边长,l.c为卷积核(即滤波器)个数,当前层的卷积核用于处理l-1层输出
int k = l.out_w*l.out_h;//当前层一个feature map的尺寸,l.delta的维度为[l.n, l.out_w*l.out_h],l.delta参考l.output
//*************计算一个CNN模块中经过激活层后的梯度*****************//
//gradient_array在activation.c中,求当前层经过激活函数的梯度,根据配置文件这里都是ReLU函数,l.output>0,则求导为结果为1×l.output,否则为0×l.output,这里的功能是经过激活层进行梯度传递,也可认为是传递误差
//l.delta为激活层计算得到的梯度,后续和当前层权重累乘相加完成梯度(误差)传递,l.activation为激活函数类型
gradient_array(l.output, l.outputs*l.batch, l.activation, l.delta);
//*************计算一个CNN模块中经过batch_normalize层后的梯度*****************//
//如果有batch_normalize层,则计算batch_normalize层传递结果
//如果没有,直接通过backward_bias完成计算不同图片输出结果的梯度累加
if(l.batch_normalize){
backward_batchnorm_layer(l, net);
} else {
//k为feature map尺寸,l.n为输出维度或feature map个数k×l.n=一个图片output输出的尺寸
backward_bias(l.bias_updates, l.delta, l.batch, l.n, k);
}
//*************计算一个CNN模块中经过CNN层后的梯度*****************//
//按批次图像循环,在计算权重的梯度的时候将针对前一层(l-1层)不同图片处理结果误差计算结果进行累加
//注意,前面不是已经把不同批次的delta都计算了一遍了吗,这里怎么又搞了一遍?前面计算的是第l层的梯度,累加后用于针对前一层每张图输出结果梯度的计算。这里累加是针对每张图计算结果的累加。
//其实我们很容易想到还有一种方法,就是前面先不累加,从一开始就逐图计算权重梯度,再最终累加。这种计算方法首先要占用很大的内存,另外还可能存在梯度消失
for(i = 0; i < l.batch; ++i){
for(j = 0; j < l.groups; ++j){
//l.delta为指针传递参数
//a是delta的矩阵,表示一个批次中第i个图处理结果的位置
float *a = l.delta + (i*l.groups + j)*m*k;
//创建一个空的workspace指针,用于缓存信息
float *b = net.workspace;
//当前层中已经更新的权重指针,l.nweights为所有卷积核中每个组的总权重尺寸,这里l.groups==1用不到
//当前层权重的尺寸为l.nweights,make_convolutional_layer中初始化了l.nweights = c/groups*n*size*size
float *c = l.weight_updates + j*l.nweights/l.groups;//更新的权重
//net.input就是前一层的输出,在公式中就是y(l-1)
float *im = net.input + (i*l.groups + j)*l.c/l.groups*l.h*l.w;
//net.delta就是前一层的梯度,即LOSS对前一层输出的梯度
float *imd = net.delta + (i*l.groups + j)*l.c/l.groups*l.h*l.w;//图像误差
//如果当前层卷积核尺寸为1,b = im,前一层的输出不用重排
if(l.size == 1){
b = im;
//如果当前层卷积核尺寸>1,前一层的输出需要重排,便于计算对前一层的梯度时候后一层的权重与之相乘累加
} else {
im2col_cpu(im, l.c/l.groups, l.h, l.w,
l.size, l.stride, l.pad, b);
}
//*************计算当前CNN层权重的的梯度*****************//
//*****a不转置,b转置,beta = 1,则c=ab+c,表示将针对不同图片权重的梯度计算结果累加*****//
//更新了l.weight_updates + j*l.nweights/l.groups
//注意,这里a不转置,重排的b转置了,每一个卷积核对应的输出(每个输出平面)的尺寸l.out_w*l.out_h=重排后的列数,转置后变成行数
//然后得到每个feature map,即每个卷积核的weight的调整梯度,就是每个batch中的几张图片的导数要合并计算
//c = delta×图像,当前层delta×前一层输出y(即net.input),叠加更新,求出来的是weight的导数
gemm(0,1,m,n,k,1,a,k,b,k,1,c,n);
//这一层用于求LOSS对前一层(l-1层)输出的梯度,原理就是将当前层的delta乘以计算当前层的权重,累加后进行误差传递
//如果net.delta ≠ 0,则进行下一步计算
//注意delta有network的也有layer的,net.delta是前一层的,l.delta是这一层的
if (net.delta) {
//当前层的权重指针
a = l.weights + j*l.nweights/l.groups;
//当前层的梯度指针l.delta
b = l.delta + (i*l.groups + j)*m*k;
c = net.workspace;
//如果卷积核尺寸为1,c不变,仍旧为前一层的梯度,此时卷积核的作用只有一个变换维度
if (l.size == 1) {
c = imd;
}
//*************计算针对前一层输出的的梯度*****************//
//*****a转置,b不转置,beta = 0,计算c = a×b,LOSS针对前一层输出的梯度不累加,放到下一层计算时候累加*****//
//b每一行行数就是l.n/groups,列数为l.w*l.h
//a是当前层权重,也是给前一层计算当前层的权重,很小的一行,一个卷积核排起来构成矩阵,行数为卷积核数,转置后成为列数,b就是delta,列数就是l.outputs×卷积核数
//转置后:a和c的行数为n(l.size*l.size*l.c/l.groups),列数为l.n,b和c的列数为k(l.out_w*l.out_h)
//转置后:a的列数和b的行数为l.n,a的步长为n,b的步长为k.
//a每个卷积核的参数行和b每一列相乘并相加,将不同通道相同位置的值加权再加在一起,这个很清楚:因为一个前一层的输出对应到不同的通道
//c就是乘得结果,存入workspace,作为中间量,求完存起来用于下个批次求梯度时候使用
gemm(1,0,n,k,m,1,a,n,b,k,0,c,k);
if (l.size != 1) {
//最后,再将net.workspace中的值,也就是c中的值转换成imd,存入前一层(l-1层)delta中,这是个指针变量传递,所以能够保存到layer[l-1]中