利用Darknet在YOLOv4中添加注意力机制模块
在论文《YOLOv4: Optimal Speed and Accuracy of Object Detectio》中,有一个重要的trick,就是注意力机制模块。而且在Darknet框架中还增加了相关的层的设计,主要包括sam_layer层和scale_channels_layer层,分别用于处理空间注意力机制和通道注意力机制。
搜索了大量的文章,基本没找到如何在Darknet在YOLOv4中添加注意力机制模块的方法,这里进行了探索。按照基本原理实现了模块的添加,但是实现效果还需要进一步调试,这里抛砖引玉,有好的理解的小伙伴可以和我交流。
添加注意力机制模块分成添加SE模块、添加SAM模块和添加CBAM模块三篇,组成一个小系列。本篇为总体介绍和添加SE模块。
基本概念
为了便于大家理解,这里对注意力机制的基本概念进行梳理。
注意力机制(Attention Mechanism)是机器学习中的一种数据处理方法,源于NLP的学习任务,最初用于处理输入不规则的语音信息或者文本信息。由于RNN网络具有很强的遗忘性,诞生了LSTM,但是LSTM运算量过大,后来将状态加权原理进一步延伸,诞生了注意力机制模型,用于对前期输入的信息加权,突出重要内容,提升训练的准确性。基于注意力机制,还诞生了Transformer模型,计算机视觉中也引入Transformer模型,形成ViT模型,最近也比较热门。浙江大学还提出了YOLOS模型,和本文讨论的YOLO是完全不同的架构,这里说到此不再延伸。
YOLOv4中引入注意力机制,就是希望网络能够自动学出来图片需要注意的地方。比如人眼在看一幅画的时候,不会将注意力平等地分配给画中的所有像素,而是将更多注意力分配给人们关注的地方。从实现的角度来讲,注意力机制就是通过神经网络的操作生成一个掩码mask,mask上的值一个打分,重点评价当前需要关注的点。
注意力机制可以分为:
- 通道注意力机制:对通道生成掩码mask,进行打分,代表是senet, Channel Attention Module。
- 空间注意力机制:对空间进行掩码的生成,进行打分,代表是Spatial Attention Module 。
- 混合域注意力机制:同时对通道注意力和空间注意力进行评价打分,代表的有BAM, CBAM。
通道注意力机制使用SE模块,在Darknet中,新添加的scale_channels_layer 层就是用于SE模块,该层在darknet.h中的定义为scale_channels.
SE模块思想简单,易于实现,并且很容易可以加载到现有的网络模型框架中。SENet主要是学习了channel之间的相关性,筛选出了针对通道的注意力,稍微增加了一点计算量,但是效果比较好。原理图如下:
通过上图可以理解他的实现过程,通过对卷积的到的feature map进行处理,得到一个和通道数一样的一维向量作为每个通道的评价分数,然后将改分数分别施加到对应的通道上。
配置实现
在Backbone后面进行添加。上面的原理图中,SE模块是对残差模型的改造,在Darknet中只需要通过配置文件的修改即可完成。本人开始在实验中,一开始没有使用残差模块,导致训练好的模型根本无法识别图像中的目标。
添加SE模块需要在配置文件中增加如下内容:
####Backbone#######
####Res###
[convolutional]
batch_normalize=1
filters=1024
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=1024
size=3
stride=1
pad=1
activation=leaky
####se###
[avgpool]
[convolutional]
batch_normalize=1
filters=64
size=1
stride=1
pad=1
activation=relu
[convolutional]
batch_normalize=1
filters=1024
size=1
stride=1
pad=1
activation=logistic
[scale_channels]
from = -4
activation= linear
###########
[shortcut]
from=-7
scale_wh = 1
activation=linear
###########
上面配置描述中的Global pooling选择Global average pooling,在Darknet中,avgpooling层是对每个channel进行平均,有多少个channel,就计算出含多少个元素的平均值。
FC为全连接层,在Darknet中,全连接层无法修改channels的数量,由于是一维向量,卷积层和全连接层是一样的,可以修改channels的数量(通过filters参数),还可以增加激活函数,所以FC+ReLU和FC+Sigmoid可以使用两个卷基模块代替。
Scale的实现在Darknet中通过scale_channels_layer实现,这里使用了linear激活函数,表示对scale处理后的数据不再进行激活处理。
源码分析
这里主要使用scale_channels_layer和avgpool_layer,这里对涉及到的相关源码进行简要注释:
- avgpool_layer:
//parse_avgpool可以看出在Darknet框架中cfg文件中avgpool配置需要哪些参数,可见没有size和stride,这说明在配置文件中[avgpool]不需要设置参数,这个平均池化层就是个全局平均,这个通过forward_avgpool_layer也可以看出。
avgpool_layer parse_avgpool(list *options, size_params params)
{
int batch,w,h,c;
w = params.w; //参数:feature map的宽度
h = params.h; //参数:feature map的高度
c = params.c; //参数:feature map的通道数
batch=params.batch; //参数:batch的数量
if(!(h && w && c)) error("Layer before avgpool layer must output image.");
avgpool_layer layer = make_avgpool_layer(batch,w,h,c);
return layer;
}
//平均池化层的前向传播函数
void forward_avgpool_layer(const avgpool_layer l, network_state state)
{
int b,i,k;
for(b = 0; b < l.batch; ++b){
for(k = 0; k < l.c; ++k){
int out_index = k + b*l.c;
l.output[out_index] = 0;
for(i = 0; i < l.h*l.w; ++i){
int in_index = i + l.h*l.w*(k + b*l.c);
l.output[out_index] += state.input[in_index];
}
//每个层输出一个1*1的feature map,为每个feature map的全局平均
l.output[out_index] /= l.h*l.w;
}
}
}
//平均池化层的反向传播函数
void backward_avgpool_layer(const avgpool_layer l, network_state state)
{
int b,i,k;
for(b = 0; b < l.batch; ++b){
for(k = 0; k < l.c; ++k){
int out_index = k + b*l.c;
for(i = 0; i < l.h*l.w; ++i){
int in_index = i + l.h*l.w*(k + b*l.c);
//对后一层传过来的微分进行全局平均,分配给feature map每个元素
state.delta[in_index] += l.delta[out_index] / (l.h*l.w);
}
}
}
}
- scale_channels_layer
//通过解析函数可以看出,用于cfg文件的包括:from,scale_wh,activation三个参数
//from表示与之相乘的feature map在cfg文件描述的层数,本文选用-4,表示倒数4层
//scale_wh表示相乘时,每个batch不同的图像对应的feature map是否使用相同的参数
layer parse_scale_channels(list *options, size_params params, network net)
{
char *l = option_find(options, "from");
int index = atoi(l);
if (index < 0) index = params.index + index;
int scale_wh = option_find_int_quiet(options, "scale_wh", 0);
int batch = params.batch;
layer from = net.layers[index];
layer s = make_scale_channels_layer(batch, index, params.w, params.h, params.c, from.out_w, from.out_h, from.out_c, scale_wh);
char *activation_s = option_find_str_quiet(options, "activation", "linear");
ACTIVATION activation = get_activation(activation_s);
s.activation = activation;
if (activation == SWISH || activation == MISH) {
printf(" [scale_channels] layer doesn't support SWISH or MISH activations \n");
}
return s;
}
//scale_channels_layer的前向传播函数
void forward_scale_channels_layer(const layer l, network_state state)
{
int size = l.batch * l.out_c * l.out_w * l.out_h;
int channel_size = l.out_w * l.out_h;
int batch_size = l.out_c * l.out_w * l.out_h;
float *from_output = state.net.layers[l.index].output;
//设置scale_wh=1时,考虑一个batch中不同的图像分别进行scale系数相乘
if (l.scale_wh) {
int i;
#pragma omp parallel for
for (i = 0; i < size; ++i) {
int input_index = i % channel_size + (i / batch_size)*channel_size;
l.output[i] = state.input[input_index] * from_output[i];
}
}
//设置scale_wh=0或不设置时,考虑一个batch中不同的图像都使用相同的scale系数相乘
else {
int i;
#pragma omp parallel for
for (i = 0; i < size; ++i) {
l.output[i] = state.input[i / channel_size] * from_output[i];
}
}
activate_array(l.output, l.outputs*l.batch, l.activation);
}
//scale_channels_layer的反向传播函数,区分也是结合scale_wh设置,对后一层的微分对输出的feature map进行系数相乘,等于微分直接传播,如果系数scale为1,则等于微分直接原封不动传递给前一层
void backward_scale_channels_layer(const layer l, network_state state)
{
gradient_array(l.output, l.outputs*l.batch, l.activation, l.delta);
//axpy_cpu(l.outputs*l.batch, 1, l.delta, 1, state.delta, 1);
//scale_cpu(l.batch, l.out_w, l.out_h, l.out_c, l.delta, l.w, l.h, l.c, state.net.layers[l.index].delta);
int size = l.batch * l.out_c * l.out_w * l.out_h;
int channel_size = l.out_w * l.out_h;
int batch_size = l.out_c * l.out_w * l.out_h;
float *from_output = state.net.layers[l.index].output;
float *from_delta = state.net.layers[l.index].delta;
if (l.scale_wh) {
int i;
#pragma omp parallel for
for (i = 0; i < size; ++i) {
int input_index = i % channel_size + (i / batch_size)*channel_size;
state.delta[input_index] += l.delta[i] * from_output[i];// / l.out_c; // l.delta * from (should be divided by l.out_c?)
from_delta[i] += state.input[input_index] * l.delta[i]; // input * l.delta
}
}
else {
int i;
#pragma omp parallel for
for (i = 0; i < size; ++i) {
state.delta[i / channel_size] += l.delta[i] * from_output[i];// / channel_size; // l.delta * from (should be divided by channel_size?)
from_delta[i] += state.input[i / channel_size] * l.delta[i]; // input * l.delta
}
}
}
小结
在实验过程中,[scale_channels]一开始没有设置scale_wh = 1效果并不好,后来增加了scale_channels =1,才能正常识别。另外,SE模块放置的位置如何调整才能达到最佳效果,也需要大量实验验证。