darknet(YOLOV3/YOLOV1) 卷积实现代码阅读

整理来自知乎:https://zhuanlan.zhihu.com/p/359922317

darknet 是一个用 c 写成的小巧、灵活的深度神经网络框架。废话不多说快进到学习他的卷积部分代码实现。

void forward_convolutional_layer(convolutional_layer l, network net)

上面的函数就是 cpu 版本的卷积函数名称。他在实现上包括了以下几个部分。

  • 对运算后输出结果空间的初始化。
  • 判断是否需要做 XNOR 处理。
  • 将卷积转换为矩阵相乘。
  • 判断是否需要做 Batch Normlize 处理。
  • 激活函数对输入数据进行激活处理。

大概的函数框架如下

void forward_convolutional_layer(convolutional_layer l, network net)
{
	//1、对输出空间进行初始化
	//2、判断是否需要进行 XNOR 处理
	//3、将卷积转换为矩阵相乘操作
	//4、判断是否需要进行 Batch Normalize 操作
	//5、激活函数执行激活
}

代码中的具体实现如下

void forward_convolutional_layer(convolutional_layer l, network net)
{
    int i, j;

    fill_cpu(l.outputs*l.batch, 0, l.output, 1);

    if(l.xnor){
        binarize_weights(l.weights, l.n, l.c/l.groups*l.size*l.size, l.binary_weights);
        swap_binary(&l);
        binarize_cpu(net.input, l.c*l.h*l.w*l.batch, l.binary_input);
        net.input = l.binary_input;
    }

    int m = l.n/l.groups;
    int k = l.size*l.size*l.c/l.groups;
    int n = l.out_w*l.out_h;
    for(i = 0; i < l.batch; ++i){
        for(j = 0; j < l.groups; ++j){
            float *a = l.weights + j*l.nweights/l.groups;
            float *b = net.workspace;
            float *c = l.output + (i*l.groups + j)*n*m;
            float *im =  net.input + (i*l.groups + j)*l.c/l.groups*l.h*l.w;

            if (l.size == 1) {
                b = im;
            } else {
                im2col_cpu(im, l.c/l.groups, l.h, l.w, l.size, l.stride, l.pad, b);
            }
            gemm(0,0,m,n,k,1,a,k,b,n,1,c,n);
        }
    }

    if(l.batch_normalize){
        forward_batchnorm_layer(l, net);
    } else {
        add_bias(l.output, l.biases, l.batch, l.n, l.out_h*l.out_w);
    }

    activate_array(l.output, l.outputs*l.batch, l.activation);
    if(l.binary || l.xnor) swap_binary(&l);
}

空间初始化

void fill_cpu(int N, float ALPHA, float *X, int INCX)
{
    int i;
    for(i = 0; i < N; ++i) X[i*INCX] = ALPHA;
}

这个函数的实现比较简单就从0到N进行一次循环,并对输入的指针 X 对应的空间赋予值 ALPHA,这里的 INCX 就起到调整遍历 X 对应空间的步距的作用。

fill_cpu(l.outputs*l.batch,0, l.output,1);

解释下 fill_cpu 输入的参数,这里 l 标识 layer,是 darknet 中定义的“层类”的实例。l.out_h 表示这层输出特征的高, l.out_w 表示这层输出特征的宽,l.out_c 表示这层输出特征的通道数。

l.outputs = l.out_h * l.out_w * l.out_c;

这里 l.outputs 就表示本层输出的大小,这里的输出大小是按照输入一个样本来计算,但在实际中我们都是一批一批的输入,所以实际的输出大小表示为 l.outputs*l.batch 。

所以这里空间初始化函数的意思就是将 l.output 空间 l.outputsl.batch 个元素设置为0。在一个函数中,对输出空间先进行初始化是个很好的习惯,这里也可以用 c 自带的 memset(X, 0, N*sizeof(float)); 来进行初始化,但这个函数没 fill_cpu 通用。

XNOR 处理

当 l.xnor 被置1时,函数就会进行 XNOR 处理流程。 XNOR 目的是将输入和卷积核参数二值化为-1或者1的形式,这样可以加速模型运算速度。

    if(l.xnor){
        binarize_weights(l.weights, l.n, l.c/l.groups*l.size*l.size, l.binary_weights);
        swap_binary(&l);
        binarize_cpu(net.input, l.c*l.h*l.w*l.batch, l.binary_input);
        net.input = l.binary_input;
    }

先看看 binarize_weights 的参数

binarize_weights(l.weights, l.n, l.c/l.groups*l.size*l.size, l.binary_weights); 

l.weights 是存放卷积层的卷积核参数的地址、l.n 是这一层卷积核的个数、l.c/l.groups*l.size*l.size 是单个卷积核的参数数量。l.binary_weights 是存放二值化后输出的卷积核参数。

这里单独说下 l.groups 表示分组,也就是分组卷积,目标是将大的卷积核分成小分组分别进行卷积操作然后将卷积后的输出在深度方向拼接起来。

一般的卷积如下图,输入特征尺寸为 (𝑊𝑖,𝐻𝑖,𝐶𝑖) ,有4个卷积核,他的尺寸为 (𝑊𝑓,𝐻𝑓,𝐶𝑓) ,输出特征尺寸为 (𝑊𝑜,𝐻𝑜,𝐶𝑜) 。其中 𝐶𝑖=𝐶𝑓 , 因为有4个卷积核所以输出特征的𝐶𝑜=4 。

分组卷积如下,下面是分成两组。分组卷积在实施的时候会进行3次分组,第1次是对输入特征分组;第2次是对每个卷积核在深度方向进行分组,分组后每个卷积核参数需要的空间就从 ((𝑊𝑓,𝐻𝑓,𝐶𝑓) 减少到 (𝑊𝑓,𝐻𝑓,𝐶𝑓𝑔𝑟𝑜𝑢𝑝𝑠) ;第3次是对卷积核进行分组比如一共有4个卷积核分成两组则每组有2个卷积核。分组后我们需要的卷积核参数从 𝑁∗𝑊𝑓∗𝐻𝑓∗𝐶𝑓 减少为 𝑁∗𝑊𝑓∗𝐻𝑓∗𝐶𝑓/𝑔𝑟𝑜𝑢𝑝𝑠 。

还是接着看看下面 binarize_weights 函数的实现。

void binarize_weights(float *weights, int n, int size, float *binary)
{
    int i, f;
    for(f = 0; f < n; ++f){
        float mean = 0;
        for(i = 0; i < size; ++i){
            mean += fabs(weights[f*size + i]);
        }
        mean = mean / size;
        for(i = 0; i < size; ++i){
            binary[f*size + i] = (weights[f*size + i] > 0) ? mean : -mean;
        }
    }
}

在最外层依次遍历卷积核,内层先计算当前卷积核的参数均值,再通过比较卷积核参数判断正负,正数则给对应二值空间赋均值,负数则给对应二值空间赋均值相反数。对卷积参数的二值化利用如下公式:

𝐼∗𝑊≈(𝐼⊕𝐵)𝛼

其中 B 是 Sign(W) 后的结果也就是大于0取+1,等于或小于0取-1, 𝛼 是W的均值。

再看下面的 swap_binary 函数,他是将存放二值参数的地址和原参数地址交换,方便再后续卷积计算中使用。

void swap_binary(convolutional_layer *l)
{
    float *swap = l->weights;
    l->weights = l->binary_weights;
    l->binary_weights = swap;
}

接着看 binarize_cpu 函数,他在这里只是对输入进行了 Sign() 操作,这和论文中的描述不一致。XNOR-Net 论文中描述在 Sign() 后还应该乘以一个系数。

void binarize_cpu(float *input, int n, float *binary)
{
    int i;
    for(i = 0; i < n; ++i){
        binary[i] = (input[i] > 0) ? 1 : -1;
    }
}

通过 net.input = l.binary_input; 将输入替换为二值化的地址。

卷积转换为矩阵相乘

想想怎么实现卷积计算,一种常见思路就是按照滑动步长遍历输入特征。

int input_feature_size = h_i * w_i;
int filter_size = h_f * w_f;
for(int i=0; i < input_feature_size; i+=stride)
{
    for(int j=0; j < filter_size ;++j)
    {
       //
     }
}

这样实现计算效率很低,因为输入特征和卷积核在底层是在一个一维连续空间存放,但计算一个输出特征上的点所需要的输入特征点并没有存放在连续的空间上。

在 darknet 中是通过 im2col_cpu 和 gemm 函数,将卷积转换为矩阵相乘的形式。其中 im2col_cpu 负责重新排列输入特征以便进行矩阵相乘,gemm 负责执行执行矩阵相乘。im2col_cpu的思路是将按照顺序卷积核会依次遍历的区域一次性全部解析出来,并对他们重新排序。

按照上面的图来说,我们将对应和卷积核(0, 0)位置相乘的输入特征中的所有点排列在一个连续空间(红色表示),把和卷积核(0, 1)位置的输入特征中的所有点整理到一个连续空间。这样的连续空间个数和卷积核的元素个数一致为 𝐻𝑓∗𝑊𝑓 ,每个连续空间的长度和输出特征的大小一致为 𝐻𝑜∗𝑊𝑜 。这里是以单通道来举例,多通道的情况是一样的,按照上面的思路进行下去即可。

我看看 im2col_cpu 的参数。im 是待进行重新排序的输入特征地址,l.c/l.groups 分组后单个卷积核的通道数,l.h、l.w 输入特征的高宽,l.size 卷积核的尺寸(W, H)、l.stride 卷积的滑动步长、l.pad 卷积填充的大小。

im2col_cpu(im, l.c/l.groups, l.h, l.w, l.size, l.stride, l.pad, b);

再看看他的具体实现

void im2col_cpu(float* data_im,int channels,  int height,  int width,
                int ksize,  int stride, int pad, float* data_col) 
{
    int c,h,w;
    int height_col = (height + 2*pad - ksize) / stride + 1;
    int width_col = (width + 2*pad - ksize) / stride + 1;

    int channels_col = channels * ksize * ksize;
    for (c = 0; c < channels_col; ++c) {
        int w_offset = c % ksize;
        int h_offset = (c / ksize) % ksize;
        int c_im = c / ksize / ksize;
        for (h = 0; h < height_col; ++h) {
            for (w = 0; w < width_col; ++w) {
                int im_row = h_offset + h * stride;
                int im_col = w_offset + w * stride;
                int col_index = (c * height_col + h) * width_col + w;
                data_col[col_index] = im2col_get_pixel(data_im, height, width, channels,
                        im_row, im_col, c_im, pad);
            }
        }
    }
}

先计算输出特征的宽高

    int height_col = (height + 2*pad - ksize) / stride + 1;
    int width_col = (width + 2*pad - ksize) / stride + 1;

再计算出单个卷积核的参数数量

int channels_col = channels * ksize * ksize;

最外层循环就是在遍历单个卷积核的参数

for (c = 0; c < channels_col; ++c) {}

循环内部的目的就是将输入特征中会被对应卷积核参数相乘的元素找到,并放在连续空间去。

int w_offset = c % ksize;
int h_offset = (c / ksize) % ksize;

这里的 (w_offset, h_offset, c_im) 就是卷积核参数在卷积核上的位置(包括了通道位置)。

int im_row = h_offset + h * stride;
int im_col = w_offset + w * stride;

这里 (im_row , im_col , c_im) 就是在输入特征上会和卷积核对应参数相乘的一个位置。

int col_index =(c * height_col + h)* width_col + w;
int col_index = c * height_col * width_col  + h * width_col + w;//转换自上面的式子

将 col_index 做一个转换后可以看到,一个卷积核一个参数对应的col_index 变化范围是输出特征的参数数量。

float im2col_get_pixel(float *im, int height, int width, int channels,
                        int row, int col, int channel, int pad)
{
    row -= pad;
    col -= pad;

    if (row < 0 || col < 0 ||
        row >= height || col >= width) return 0;
    return im[col + width*(row + height*channel)];
}

im2col_get_pixel 函数用来获取输入特征上的点,输入的位置信息为(row, col, channel),其中将 row, col 消除填充 pad 的影响。后续的判断条件将超出输入特征范围的点默认返回0,等效为将填充区域默认返回0。

接下来看看 gemm 函数他主要负责进行矩阵乘法操作,可以看看下图,就是将卷积核的参数和对应输入特征相乘然后累加到输出特征上。

看看 gemm 的输入参数,TA和TB表示是否对矩阵A和矩阵B进行转置,M表示输出特征通道数、N表示输出特征的大小、K卷积核的大小、ALPHA 是个系数、A 是个矩阵、lda 表示矩阵 A 按列排布来解析时列长度是多少、B 是个矩阵、ldb 表示矩阵 B 按列排布来解析时列长度是多少、BETA 是个系数、C是输出矩阵、ldc 表示矩阵 C 按列排布来解析时列长度是多少。

这里的 gemm 函数在定义上和应用在 gpu 上的 cuda库 的 gemm函数保持了一致。

void gemm(int TA, int TB, int M, int N, int K, float ALPHA, 
        float *A, int lda, 
        float *B, int ldb,
        float BETA,
        float *C, int ldc)
{
    gemm_cpu( TA,  TB,  M, N, K, ALPHA,A,lda, B, ldb,BETA,C,ldc);
}

我们输入 gemm 的参数值为

0, 0即不对矩阵 a 和矩阵 b 转置,保持原有排列

分组后的卷积核个数 m = l.n/l.groups;卷积核的个数对应输出特征的通道数。

输出特征大小(不包括深度) n = l.out_w*l.out_h;

分组后单个卷积核的大小 k = l.size*l.size*l.c/l.groups;

ALPHA 为1;

卷积核参数地址 float *a = l.weights + j*l.nweights/l.groups;

lda 的输入值和 k 保持一致,也就是按列排列来解析矩阵a时,a每列有k这么长;

b 经过整理后顺序后的输入特征地址;

ldb的输入值和n保持一致,也就是按列排列来解析矩阵b时,b每列为n这么长;

BETA 为1;

输出地址 c = l.output + (i*l.groups + j)*n*m;

ldc的输入值和n保持一致,也就是按列排列来解析矩阵c时,b每列为n这么长;

gemm(0,0,m,n,k,1,a,k,b,n,1,c,n);

看看 gemm 的实现

void gemm_cpu(int TA, int TB, int M, int N, int K, float ALPHA, 
        float *A, int lda, 
        float *B, int ldb,
        float BETA,
        float *C, int ldc)
{
    int i, j;
    for(i = 0; i < M; ++i){
        for(j = 0; j < N; ++j){
            C[i*ldc + j] *= BETA;
        }
    }
    if(!TA && !TB)
        gemm_nn(M, N, K, ALPHA,A,lda, B, ldb,C,ldc);
    else if(TA && !TB)
        gemm_tn(M, N, K, ALPHA,A,lda, B, ldb,C,ldc);
    else if(!TA && TB)
        gemm_nt(M, N, K, ALPHA,A,lda, B, ldb,C,ldc);
    else
        gemm_tt(M, N, K, ALPHA,A,lda, B, ldb,C,ldc);
}

先是对数据矩阵的每个元素乘系数 BETA;然后根据我们选择的不对矩阵进行转置的选项进入

gemm_nn(M, N, K, ALPHA,A,lda, B, ldb,C,ldc);

我们继续看他的实现

void gemm_nn(int M, int N, int K, float ALPHA, 
        float *A, int lda, 
        float *B, int ldb,
        float *C, int ldc)
{
    int i,j,k;
    #pragma omp parallel for
    for(i = 0; i < M; ++i){
        for(k = 0; k < K; ++k){
            register float A_PART = ALPHA*A[i*lda+k];
            for(j = 0; j < N; ++j){
                C[i*ldc+j] += A_PART*B[k*ldb+j];
            }
        }
    }
}

这里的 #pragma omp parallel for 表示后续的for循环里面的内容并行执行,这样可以加速运算但也要求for循环里每次循环间是相互独立。

这里的 registerfloat A_PART = ALPHA*A[i*lda+k]; 告诉编译器将变量 A_PART 放到寄存器。

以上两个的目的都是加速计算。单独看看下面这部分,它意思是取出输入矩阵A的一个参数和ALPHA相乘得到 A_PART ,再将 A_PART 和输入矩阵 B 的一个ldb长度的元素依次相乘,结果再依次加到输入矩阵 C 对应位置上。

register float A_PART = ALPHA*A[i*lda+k];
for(j = 0; j < N; ++j){
   C[i*ldc+j] += A_PART*B[k*ldb+j];
}

这样就完成了我们示意图中的矩阵乘法操作。

最后回头看看这部分的实现

    int m = l.n/l.groups;
    int k = l.size*l.size*l.c/l.groups;
    int n = l.out_w*l.out_h;
    for(i = 0; i < l.batch; ++i){
        for(j = 0; j < l.groups; ++j){
            float *a = l.weights + j*l.nweights/l.groups;
            float *b = net.workspace;
            float *c = l.output + (i*l.groups + j)*n*m;
            float *im =  net.input + (i*l.groups + j)*l.c/l.groups*l.h*l.w;

            if (l.size == 1) {
                b = im;
            } else {
                im2col_cpu(im, l.c/l.groups, l.h, l.w, l.size, l.stride, l.pad, b);
            }
            gemm(0,0,m,n,k,1,a,k,b,n,1,c,n);
        }
    }

最外层的循环是遍历 BATCH 因为我们每次训练的输入是按照 BATCH 进行。还有一点当卷积核的尺寸是l.size == 1的时候不需要使用 im2col_cpu来调整输入特征的顺序。

Batch Normalize 处理

在卷积操作完成到输入激活函数前,我们可以选择是否进行 Batch Normalize 操作。这个操作的效果一方面是统一送入激活函数的数值的分布为正态分布这样降低网络学习的难度,网络不需要去适应不同的输入数据的分布,另外一方面 Batch Normalize 后值分布变成均值为0标准差为1,这样可以降低 tanh 和 sigmoid 这样的激活函数进入饱和区进而梯度消失难以训练的情况。如果不进行 Batch Normalize 操作则给卷积后的结果加上偏执 bias 即可,这里也说明进行 Batch Normalize 后就不需加偏执了。

    if(l.batch_normalize){
        forward_batchnorm_layer(l, net);
    } else {
        add_bias(l.output, l.biases, l.batch, l.n, l.out_h*l.out_w);
    }

我看看 forward_batchnorm_layer 的实现 ,注意 batch normalize 的操作对象整个 batch。还需要注意 batch normalize 的时候要区分现在是在进行训练还是推理,如果是预测那用于 normalize 的均值和方差是外部传入的,当然这个传入的均值和方差也是在训练过程计算得的。

void forward_batchnorm_layer(layer l, network net)
{
    if(l.type == BATCHNORM) copy_cpu(l.outputs*l.batch, net.input, 1, l.output, 1);
    copy_cpu(l.outputs*l.batch, l.output, 1, l.x, 1);
    if(net.train){
        mean_cpu(l.output, l.batch, l.out_c, l.out_h*l.out_w, l.mean);
        variance_cpu(l.output, l.mean, l.batch, l.out_c, l.out_h*l.out_w, l.variance);

        scal_cpu(l.out_c, .99, l.rolling_mean, 1);
        axpy_cpu(l.out_c, .01, l.mean, 1, l.rolling_mean, 1);
        scal_cpu(l.out_c, .99, l.rolling_variance, 1);
        axpy_cpu(l.out_c, .01, l.variance, 1, l.rolling_variance, 1);

        normalize_cpu(l.output, l.mean, l.variance, l.batch, l.out_c, l.out_h*l.out_w);   
        copy_cpu(l.outputs*l.batch, l.output, 1, l.x_norm, 1);
    } else {
        normalize_cpu(l.output, l.rolling_mean, l.rolling_variance, l.batch, l.out_c, l.out_h*l.out_w);
    }
    scale_bias(l.output, l.scales, l.batch, l.out_c, l.out_h*l.out_w);
    add_bias(l.output, l.biases, l.batch, l.out_c, l.out_h*l.out_w);
}

这里我有点困惑,看起来应该是当存在一个 BN 层的时候需要将他网络的输入拷贝到这层的输出,然后再把这层的输出拷贝到专门给 batch normalize 开辟的空间 l.x 中去。如果不是 BN 层,而是在卷积中集成的 batch normalize 操作就直接将这层的输出拷贝到专门给 batch normlize 开辟的空间 l.x 中去即可。

if(l.type == BATCHNORM) copy_cpu(l.outputs*l.batch, net.input, 1, l.output, 1);
copy_cpu(l.outputs*l.batch, l.output, 1, l.x, 1);

拷贝函数遍历 N 将 X 中的数值按照INCX的跨度拷贝 Y 中跨度为 INCY 的位置去。

void copy_cpu(int N, float *X, int INCX, float *Y, int INCY)
{
    int i;
    for(i = 0; i < N; ++i) Y[i*INCY] = X[i*INCX];
}

Batch normalize 操作原理如下

假设这次的 batch 有 m 个输入值,同时batch里面的输入 feature map 都是单通道的则

𝜇←1𝑚∑𝑖=1𝑚𝑥𝑖𝜎2←1𝑚∑𝑖=1𝑚(𝑥𝑖−𝜇)2𝑥𝑖^←𝑥𝑖−𝜇𝜎2+𝜖𝑦𝑖←𝛾𝑥𝑖^+𝛽

最后得到的 𝑦𝑖 就是经过 batch normalize 处理后的值,如果输入feature map 是多通道则对按通道完成 batch normalize 操作。看看具体代码实现,先判断当前网络处于训练模式下。

if(net.train){
    mean_cpu(l.output, l.batch, l.out_c, l.out_h*l.out_w, l.mean);
    variance_cpu(l.output, l.mean, l.batch, l.out_c, l.out_h*l.out_w, l.variance);
    //
}

看下 mean_cpu 的实现。

void mean_cpu(float *x, int batch, int filters, int spatial, float *mean)
{
    float scale = 1./(batch * spatial);
    int i,j,k;
    for(i = 0; i < filters; ++i){
        mean[i] = 0;
        for(j = 0; j < batch; ++j){
            for(k = 0; k < spatial; ++k){
                int index = j*filters*spatial + i*spatial + k;
                mean[i] += x[index];
            }
        }
        mean[i] *= scale;
    }
}

外出 for 循环按照 filters 也就是卷积核个数来遍历,外部输入的是 l.out_c 就是卷积操作后输出特征的通道数。内层循环将 batch 中每个特征的对应通道取出来求和然后计算对应均值。最后得到和通道数 l.out_c 个均值。

看下 variance_cpu 的实现。结构上和 mean_cpu 一致。

void variance_cpu(float *x, float *mean, int batch, int filters, int spatial, float *variance)
{
    float scale = 1./(batch * spatial - 1);
    int i,j,k;
    for(i = 0; i < filters; ++i){
        variance[i] = 0;
        for(j = 0; j < batch; ++j){
            for(k = 0; k < spatial; ++k){
                int index = j*filters*spatial + i*spatial + k;
                variance[i] += pow((x[index] - mean[i]), 2);
            }
        }
        variance[i] *= scale;
    }
}

接着的操作是计算后续在推理时会用到的均值和方差。方法如下

𝑟𝑜𝑙𝑙𝑖𝑛𝑔𝑚𝑒𝑎𝑛=0.99∗𝑟𝑜𝑙𝑙𝑖𝑛𝑔𝑚𝑒𝑎𝑛+0.01∗𝑚𝑒𝑎𝑛𝑟𝑜𝑙𝑙𝑖𝑛𝑔𝑣𝑎𝑟=0.99∗𝑟𝑜𝑙𝑙𝑖𝑛𝑔𝑣𝑎𝑟+0.01∗𝑣𝑎𝑟

实现如下

scal_cpu(l.out_c, .99, l.rolling_mean, 1);
axpy_cpu(l.out_c, .01, l.mean, 1, l.rolling_mean, 1);
scal_cpu(l.out_c, .99, l.rolling_variance, 1);
axpy_cpu(l.out_c, .01, l.variance, 1, l.rolling_variance, 1);

其中 scal_cpu 负责给输入变量乘以固定系数

void scal_cpu(int N, float ALPHA, float *X, int INCX)
{
    int i;
    for(i = 0; i < N; ++i) X[i*INCX] *= ALPHA;
}

其中 axpy_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];
}

这样每次训练我们都对滚动的改变最终在推理时会用到的均值和方差。

接着执行归一化操作

normalize_cpu(l.output, l.mean, l.variance, l.batch, l.out_c, l.out_h*l.out_w); 

实现如下。可以看到除sqrt(variance[f])的时候加上了 .000001f ,这个目的是避免出现除0。这里在循环中重复进行sqrt(variance[f])开方得到标准差的操作,感觉可以将这个操作放在循环外完成减少点计算量。

void normalize_cpu(float *x, float *mean, float *variance, int batch, int filters, int spatial)
{
    int b, f, i;
    for(b = 0; b < batch; ++b){
        for(f = 0; f < filters; ++f){
            for(i = 0; i < spatial; ++i){
                int index = b*filters*spatial + f*spatial + i;
                x[index] = (x[index] - mean[f])/(sqrt(variance[f]) + .000001f);
            }
        }
    }
}

外层循环按照 batch 里面包含的 feature map 来,然后每个通道计算归一化结果是选择自己通道对应的均值和方差(待开方得到标准差)。

接着将batch normalize 后的结果拷贝到 l.x_norm 中。

copy_cpu(l.outputs*l.batch, l.output, 1, l.x_norm, 1);

如果我们现在处于推理模式则利用 rolling_mean 和 rolling_variance 直接进行归一化即可。

normalize_cpu(l.output, l.rolling_mean, l.rolling_variance, l.batch, l.out_c, l.out_h*l.out_w);

根据前面提到的 batch normalize 的原理还需要对结果做如下操作

𝑦𝑖←𝛾𝑥𝑖^+𝛽

这里的 𝛾 和 𝛽 是额外增加给网络学习的参数,目的是让网络自己调节先前归一化的程度,它甚至可以将先去的归一化回退掉。具体如下

scale_bias(l.output, l.scales, l.batch, l.out_c, l.out_h*l.out_w);
add_bias(l.output, l.biases, l.batch, l.out_c, l.out_h*l.out_w);

其中 scale_bias 按照通道给 batch 中的每个参数乘系数

void scale_bias(float *output, float *scales, int batch, int n, int size)
{
    int i,j,b;
    for(b = 0; b < batch; ++b){
        for(i = 0; i < n; ++i){
            for(j = 0; j < size; ++j){
                output[(b*n + i)*size + j] *= scales[i];
            }
        }
    }
}

其中 add_bias 按照通道给 batch 中的每个参数加上偏执

void add_bias(float *output, float *biases, int batch, int n, int size)
{
    int i,j,b;
    for(b = 0; b < batch; ++b){
        for(i = 0; i < n; ++i){
            for(j = 0; j < size; ++j){
                output[(b*n + i)*size + j] += biases[i];
            }
        }
    }
}

这样就完成了 batch normalize 操作,开始有提到当使用 BN 的时候外部就不需要进行加偏执操作,原因是在 BN 中包括减去均值的操作,如果外部增加了加偏执操作在后面进行 BN 时也会被减去。而且在 BN 内部也有加偏执的操作所以在 BN 后加偏执也没必要。

执行激活

最后进行到激活步骤,就是让激活函数作用到每个输出特征上。现在的激活函数一般选择 leaky relu,他的形式如下

𝑦𝑖={𝑥𝑖𝑖𝑓𝑥𝑖≥0𝑥𝑖𝑎𝑖,𝑎𝑖∈{1,+∞}𝑖𝑓𝑥𝑖<0

图形如下,他激活区宽阔不会饱和。

activate_array(l.output, l.outputs*l.batch, l.activation);

内部实现如下

void activate_array(float *x, const int n, const ACTIVATION a)
{
    int i;
    for(i = 0; i < n; ++i){
        x[i] = activate(x[i], a);
    }
}

这里的 activate 函数里面只是一个 switch case 结构

float activate(float x, ACTIVATION a)
{
    switch(a){
        case LINEAR:
            return linear_activate(x);
        case LOGISTIC:
            return logistic_activate(x);
        case LOGGY:
            return loggy_activate(x);
        case RELU:
            return relu_activate(x);
        case ELU:
            return elu_activate(x);
        case SELU:
            return selu_activate(x);
        case RELIE:
            return relie_activate(x);
        case RAMP:
            return ramp_activate(x);
        case LEAKY:
            return leaky_activate(x);
        case TANH:
            return tanh_activate(x);
        case PLSE:
            return plse_activate(x);
        case STAIR:
            return stair_activate(x);
        case HARDTAN:
            return hardtan_activate(x);
        case LHTAN:
            return lhtan_activate(x);
    }
    return 0;
}

最后在完成激活后需要将使用了二值化操作的参数重新交换回来,这个目的是后续更新参数的时候是基于原始参数来更新。

if(l.binary || l.xnor) swap_binary(&l);

总结

以上代码是 yolov3 对应的 darknet 中卷积的前向传播部分cpu实现部分,对应还有gpu的实现部分,这部分后续再阅读。从这次阅读可以看到 darknet 的一个前向卷积操作不是单纯的卷积还包括了二值化、批归一化等可选操作。同时 darknet 的批操作是放在了单纯的卷积和激活操作之间。了解到了在实现层面通过 im2col 和 gemm 来加速卷积运算的操作。

也有疑问就是二值化中对输入特征的处理没有乘系数,只做了 sign() 操作。还有 batch normalize 前和后的结果都放到了 l.x 和 l.x_nrom 中,暂时在 前向卷积中没看到他们的用处。

参考

[1] Group Convolution分组卷积,以及Depthwise Convolution和Global Depthwise Convolution

[2] XNOR-Net算法详解

[3] 什么是批标准化 (Batch Normalization)

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值