batch normalization用于卷积层后的归一化。卷积神经网络每一层的参数更新后会导致数据分布的变化,使得网络学习更加困难,损失变化也更加振荡。通过归一化后,将每一层的输出数据归一化在均值为0方差为1的高斯分布中。
批次归一化的方法于z-score数据标准化的方法是一致的,计算方法如下:
设数据集,数据集的均值为,数据集的标准差为
对A中的所有数据进行z-score标准化,计算过程如下:
对于batch normalization也是如此,对于卷积神经网络,数据集A为每一层的输出数据(三维数据h*w*c),即对于该三维数据中的每一个元素都要进行z-score标准化。那么问题在于,对于卷积神经网络层输出的三维数据,他的均值,方差是什么。
我们先从均值和方差的存储空间的创建看起,在src/convolutional_layer.c中的make_convolutional_layer函数中可以看到存储空间的创建过程:
l.mean = calloc(n, sizeof(float));
l.variance = calloc(n, sizeof(float));
他们的大小都是n个float,而n代表该层的卷积核个数,也是该卷积层输出数据的通道数。那么可以猜想,是对输出数据的每一个通道计算一个均值。
在src/batchnorm_layer.c中的forward_batchnorm_layer函数可以看到,具体的实现过程,代码如下:
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);
}
第一句判断条件不用看,那是batch normalization作为单独一层时,而一般情况下是直接在卷积层中直接调用。
if(net.train)判断是否为训练过程,其中提到rolling_mean和rolling_variance,滚动平均值和滚动方差,这个稍后再说。
我们先来看均值和方差的计算过程,两个函数:
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);//计算方差
函数实现在src/blas.c中
我们先来看均值计算mean_cpu,我将调用语句作为注释写在最上面一行,方便对照:
//mean_cpu(l.output, l.batch, l.out_c, l.out_h*l.out_w, l.mean);
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;
}
}
scale=1/(batch*spatial),scale缩放因子即1除数据集元素个数,那么batch*spatial就表明了均值计算的范围,从调用过程中的参数输入可以知道spatial=l.out_h*l.out_w即输出数据一个通道的元素个数,所以现在可以明确,均值计算是针对输出数据的每一个通道,方差计算也是如此。而batch是网络训练的批次数量,即每次训练使用batch张图片,所以batch*spatial表明,均值计算针对的是batch个图像在该层输出数据的每一个通道计算一个均值。
我举个例子:
该卷基层有batch个输出,输出都是m*n*c的三维数据,那么均值有c个,即每个通道计算一个均值:
for i in range(c):
sum = 0
for i in range(batch):
sum += 对第i个batch输出数据的第c个通道求和
第i个通道对应的均值为sum/(batch*m*n)
上述伪代码可以更清晰的表明该过程,如果对卷积过程中批次的组织方式和实现过程有疑惑,可以参考darknet的卷积层源代码进行理解。
后续的循环求和过程就很好理解了,darknet中数据的组织方式是一维的数组,而循环求和过程就是寻找到元素的索引位置,求和而已。
均值计算过程已经非常明确了,即针对所有batch的某一个输出数据通道求平均,那么方差计算也是如此,代码如下:
//variance_cpu(l.output, l.mean, l.batch, l.out_c, l.out_h*l.out_w, l.variance);
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;
}
}
从循环控制的参数可以看出,针对的是所有batch输出的某一通道求方差,与均值计算是一致的,从z-score的计算方法来说,也必需是一致的。
在搞清楚均值和方差的计算后,回到forward_batchnormal_layer中:
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);
}
可以看到在训练过程中计算完均值和方差后,还计算了一个rolling_mean,rolling_variance但是在做归一化是并没有使用这两个值,而检测过程的归一化却使用的是rolling_mean,rolling_variance。
这两个值称为滚动平均和滚动方差,在讲解这两个数值的作用之前,我再次重申一遍,batch normalization是为了卷积后的数据分布一致,而我们使用了z-score的方法使用数据本身的均值和方差对数据进行标准化,这个均值是整体数据集的均值,这个方差也是整体数据集的方差,但是训练过程中我们计算的是一个batch的均值和方差,由于训练是以batch为单位更新参数的,所以使用一个batch的均值和方差是合理的,而在检测过程中所使用的就应当是整体数据的均值和方差。
虽然在训练过程中可以记录下所有的均值和方差,最后再求一个整体的平均,但是这样做占用太多的存储空间,时间效率也非常低下,所以采用了一种滚动求平均的方式。
设为m个数据的均值,为前m-1个数据的均值,则:
这就是滚动平均,每一次只用保存一个均值,计算一次平均。这种方法有一个很大的问题,m是从1开始递增的,随着m的增大1/m的值越来越小,那么的值也越来越小,也就是说后续数据对整体均值的影响越来越小,当m特别大时,均值几乎不变。这导致整体均值实际上只受到前期数据的影响。
这个问题是很严重的,所以darknet在实现时,并不是使用1-1/m和1/m,而是直接使用两个确定的数值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);
这解决了后期数据对均值的影响问题,但是这样计算出的还是数据集的均值?
他计算出的当然不再是数据集的均值,但是回到batch normalliza的意义上来,我们的目的是将卷积后的输出数据归一化到同一个数据分布中,我们这样去求均值,使得归一化后的数据不再是理想情况下的均值为0方差为1的分布,但是用这样的均值进行归一化数据的分布依然都是一样的,只不过不是均值为0方差为1的分布,而我们的目标就是希望他们分布一样,至于是什么样的分布还重要吗?当然是不重要的。
在训练过程结束后rolling_mean和rolling_variance会作为训练参数存入权重文件,供检测时使用。
当明确求取均值和方差的目标后,归一化就很清晰了,实现函数为src/blas.c 中的normalize_cpu函数,代码如下:
//normalize_cpu(l.output, l.mean, l.variance, l.batch, l.out_c, l.out_h*l.out_w);
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 normalization和z-score标准化是一样的,其实在实现中有改动。
由于卷积层后跟的是非线性激活函数,而通过归一化改变了数据的分布,可能使得原本工作在非线性激活区的数据跑到线性激活区了,为了解决该问题,darknet框架在z-score标准化的计算公式中加入了一个缩放因子和偏移量b
这两个都是超参数,即这两个参数是通过训练得到的。而darknet为了保证上式计算时,分母永不为0,添加了一个极小数0.000001,所以最终的计算式为:
代码完全时根据该计算式编写的。
这就是整个darknet中batch normalization的实现过程,而其他CNN网络的实现也会是大同小异。