本文的重点是分析 darknet 是如何解析配置文件的. 和 Caffe 不同的是, darknet 并没有使用类似 protobuf 的配置文件, 而是自己定义了一套解析配置文件参数的方法.
解析配置文件的核心是 parse_network_cfg() 函数, 该函数定义在 darknet/src/parser.c 文件, 博文中的所有文件路径的根目录为 darknet 工程所在目录.
1. 初识配置文件 .cfg
如果你使用过 darknet 进行训练或测试一个模型, 那么在命令行需要用户指定你使用的网络配置文件, 配置文件一般以 .cfg 为后缀. 例如使用 darknet 训练网络时, 可以使用以下命令:
./darknet detector train cfg/coco.data cfg/yolov3.cfg darknet53.conv.74 -gpus 0,1,2,3
其中 cfg/yolov3.cfg 就是网络对应的配置文件, 其内容大概是下面这个样子:
[net]
# Testing
batch=1
subdivisions=1
width=416
height=416
channels=3
...
learning_rate=0.001
[convolutional]
batch_normalize=1
filters=32
size=3
stride=1
pad=1
activation=leaky
...
[shortcut]
from=-3
activation=linear
...
[yolo]
mask = 0,1,2
anchors = 10,13, 16,30, 33,23, 30,61, 62,45, 59,119, 116,90, 156,198, 373,326
classes=80
...
根据各个参数的作用可以将网络配置文件中的配置项分为两类:
- 与训练直接相关的项, 以 [net] 行开头的段. 其中包含的参数有: batch_size, width,height,channel,momentum,decay,angle,saturation, exposure,hue,learning_rate,burn_in,max_batches,policy,steps,scales;
- 不同类型的层的配置参数. 如 [convolutional], [short_cut], [yolo], [route], [upsample] 层等.
对于"与训练直接相关的项", 下面就几个重要的参数进行介绍; 而对于"不同类型的层的配置参数", 后面会有专门的文章介绍.
1. batch, subdivisions
在网络解析函数中, batch 是做过处理的:
net->batch /= net->subdivisions;
也就是说, batch_size 在 darknet 内部又被均分为 net->subdivisions 份, 成为更小的 batch_size. 但是这些小的 batch_size 最终又被汇总, 因此 darknet 中的 batch_size = net->batch / net->subdivisions * net->subdivisions. 至于为什么要这么做, 在后续的文章中会提到.
另外, 接下来计算训练图片数目的时候是这样的:
int imgs = net->batch * net->subdivisions * ngpus;
这样可以保证 imgs 可以被 subdivisions 整除, 因此, 通常建议将这个值设置为 8 的倍数.
从这里也可以看出另外一个事实: 在每次迭代过程中每个 GPU 或 CPU 都会训练 batch * subdivisions 张图片样本.
这里稍微有点跑题, 言归正传. 那么 darknet 中是如何解析并保存这些参数的?
2. 基本数据结构
darknet 使用 C 语言实现, 这就决定了其中大多数保存数据的结构都会选择链表这种简单高效的数据结构.
为了解析网络配置参数, darknet 中定义了三个关键的数据结构类型. list 类型变量保存所有的网络参数, section 类型变量保存的是网络中每一层的网络类型和参数, 其中的参数又是使用 list 类型来表示. kvp 键值对类型用来保存解析后的参数变量和参数值.
- list 类型定义在 darknet/include/darknet.h 文件中, 具体定义如下:
typedef struct node{
void *val;
struct node *next;
struct node *prev;
} node;
typedef struct list{
int size; // list 的所有节点个数
node *front; // list 的首节点
node *back; // list 的普通节点
} list;
- section 类型定义在 darknet/src/parser.c 文件中, 具体定义如下:
typedef struct{
char *type; // section 的类型
list *options; // section 的参数信息
}section;
- kvp - 键值对类型定义在 darknet/src/option_list.h 文件中, 具体定义如下:
typedef struct{
char *key;
char *val;
int used; // the parameter is not used in darknet
} kvp;
在 darknet 的网络配置文件( 以 .cfg 结尾) 中, 以 ‘[’ 开头的行被称为一个: section(段);
所有的网络配置参数保存在 list 类型变量中, list 中有很多的 sections 节点, 每个 section 中又有一个保存层参数的小 list, 整体上呈现出一种大链挂小链的结构: 大链中的节点为每个 section, 各个 section 包含的参数保存在小链中, 小链的节点值的数据类型为 kvp 键值对.
在数据结构中, 为了防止链表头丢失, 我们通常的做法是直接使用一个 node 指针来表示链表头. 但是在 darknet 中单独把 node 类型又进行了一次封装 - list 类型.
list 中的 front 表示链表首元素, front 成员只会在空链表插入第一个元素时进行赋值, 之后每次插入都只是操作 back 成员.
2. 解析并保存网络参数到链表中
读取配置文件由 read_cfg() 函数实现, 该函数的定义在 darknet/src/parser.c 文件中:
/**
* 读取神经网络结构配置文件(.cfg文件)中的配置数据, 将每个神经网络层参数读取到每个
* section 结构体 (每个 section 是 sections 的一个节点) 中, 而后全部插入到
* list 结构体 sections 中并返回
*
* \param: filename C 风格字符数组, 神经网络结构配置文件路径
*
* \return: list 结构体指针,包含从神经网络结构配置文件中读入的所有神经网络层的参数
*/
list *read_cfg(char *filename)
{
FILE *file = fopen(filename, "r");
if(file == 0) file_error(filename);
// 一个 section 表示配置文件中的一个字段,也就是网络结构中的一层
// 因此,一个 section 将读取并存储某一层的参数以及该层的 type
char *line;
int nu = 0; // 当前读取行记号
list *options = make_list(); // options 包含所有的神经网络层参数
section *current = 0; // 当前读取到的某一层
while((line= fgetl(file)) != 0){
++ nu;
strip(line); // 去除读入行中含有的空格符
switch(line[0]){
// 以 '[' 开头的行是一个新的 section , 其内容是层的 type
// 比如 [net], [maxpool], [convolutional] ...
case '[':
current = malloc(sizeof(section));
list_insert(options, current);
current->options = make_list();
current->type = line;
break;
case ', 然后第 30 行
': // 空行
case '#': // 注释
case ';': // 空行
free(line); // 对于上述三种情况直接释放内存即可
break;
// 剩下的才真正是网络结构的数据,调用 read_option() 函数读取
// 返回 0 说明文件中的数据格式有问题,将会提示错误
default:
if(!read_option(line, current->options)){
fprintf(stderr, "Config file error line %d, could \
parse: %s\n", nu, line);
free(line);
}
break;
}
}
fclose(file);
return options;
}
每个 section 的所在行的开头是 ‘[’ , ‘\0’ , ‘#’ 和 ‘;’ 符号开头的行为无效行, 除此之外的行为 section 对应的参数行. 每一行都是一个等式, 类似键值对的形式.
可以看到, 如果某一行开头是符号 ‘[’ , 说明读到了一个新的 section: currentread_option(line, current->options)
list_insert(options, current);` 将该新的 section 保存起来.
在读取到下一个开头符号为 ‘[’ 的行之前的所有行都是该 section 的参数, 在第 42 行 current
将读取到的参数保存在 options
变量的
3. 链表的插入操作
中. 注意, 这里保存在 options 节点中的数据为 kvp 键值对类型.当然对于 kvp 类型的参数, 需要先将每一行中对应的键和值(用 ‘=’ 分割) 分离出来, 然后再构造一个 kvp 类型的变量作为节点元素的数据.
这里保存读取到的参数使用的是 list_insert() 函数, 接下来分析这个函数.
/*
* \brief: 将 val 指针插入 list 结构体 l 中,这里相当于是用 C 实现了 C++ 中的
* list 的元素插入功能
*
* \prama: l 链表指针
* val 链表节点的元素值
*
* 流程: list 中保存的是 node 指针. 因此,需要用 node 结构体将 val 包裹起来后才可以
* 插入 list 指针 l 中
*
* 注意: 此函数类似 C++ 的 insert() 插入方式;
* 而 opion_insert() 函数类似 C++ map 的按值插入方式,比如 map[key]= value
*
* 两个函数操作对象都是 list 变量, 只是操作方式略有不同。
*/
void list_insert(list *l, void *val)
{
node *new = malloc(sizeof(node));
new->val = val;
new->next = 0;
// 如果 list 的 back 成员为空(初始化为 0), 说明 l 到目前为止,还没有存入数据
// 另外, 令 l 的 front 为 new (此后 front 将不会再变,除非删除)
if(!l->back){
l->front = new;
new->prev = 0;
}else{
l->back->next = new;
new->prev = l->back;
}
l->back = new;
++l->size;
}
保存 section 和每个参数组成的键值对时使用的是 list_insert() 函数, 前面提到了参数保存的结构其实是大链( 节点为 section )上边串着很多小链( 每个 section 节点的各个参数).
list_insert() 函数实现了链表插入操作. 该函数定义在 darknet/src/list.c 文件中:
4. 将链表中的网络参数保存到 network 结构体
可以看到, 插入的数据都会被重新包装在一个新的 node : 变量 new 中, 然后再将这个节点插入到链表 l 中.
网络结构解析到链表中后还不能直接使用, 如果仅仅想使用某一个参数而不得不每次都遍历整个链表, 这样就会导致程序效率变低, 最好的办法是将其保存到一个结构体变量中, 使用的时候按照成员进行访问.
注意区分"网络参数"和"训练得到的权值参数", 这是两个不同的概念.
“网络参数” 指的是描述网络结构的参数, 并没有构建实际的网络;
“训练得到的权值参数” 指的是已构建好的网络中各层的所有节点的权重值集合.
typedef struct network{
int n; // 网络的层数, 调用 make_network(int n) 时赋值
int batch; // 一批训练中的图片张数, 和 subdivisions 参数相关
size_t *seen; // 目前已经读入的图片张数(网络已经处理的图片张数)
int *t;
float epoch; // 到目前为止训练了整个数据集的次数
int subdivisions; // TODO
layer *layers; // 存储网络中的所有层
float *output;
learning_rate_policy policy;// 学习率下降策略: TODO
// 梯度下降法相关参数
float learning_rate;
float momentum;
float decay;
float gamma;
float scale;
float power;
int time_steps;
int step;
int max_batches;
float *scales;
int *steps;
int num_steps;
int burn_in;
int adam;
float B1;
float B2;
float eps;
int inputs; // 输入层节点个数
int outputs; // 输出层节点个数
int truths;
int notruth;
int h, w, c;
int max_crop;
int min_crop;
float max_ratio;
float min_ratio;
int center;
float angle;
float aspect;
float exposure;
float saturation;
float hue;
int random;
// darknet 为每个 GPU 维护一个相同的 network, 每个 network 以 gpu_index 区分
int gpu_index;
tree *hierarchy;
// 中间变量,用来暂存某层网络的输入(包含一个 batch 的输入,比如某层网络完成前向,将其输出赋给该变量,作为下一层的输入,可以参看 network.c 中的forward_network() 与 backward_network() 两个函数 )
float *input;
// 中间变量,与上面的 input 对应,用来暂存 input 数据对应的标签数据(真实数据)
float *truth;
// 中间变量,用来暂存某层网络的敏感度图(反向传播处理当前层时,用来存储上一层的敏感度图,因为当前层会计算部分上一层的敏感度图,可以参看 network.c 中的 backward_network() 函数)
float *delta;
// 网络的工作空间, 指的是所有层中占用运算空间最大的那个层的 workspace_size,
// 因为实际上在 GPU 或 CPU 中某个时刻只有一个层在做前向或反向运算
float *workspace;
// 网络是否处于训练阶段的标志参数,如果是则值为1. 这个参数一般用于训练与测试阶段有不
// 同操作的情况,比如 dropout 层,在训练阶段才需要进行 forward_dropout_layer()
// 函数, 测试阶段则不需要进入到该函数.
int train;
int index; // 标志参数,当前网络的活跃层
float *cost;
float clip;
#ifdef GPU
float *input_gpu;
float *truth_gpu;
float *delta_gpu;
float *output_gpu;
#endif
} network;
其中, 重要的成员变量都给出了注释.
network *make_network(int n)
{
network *net = calloc(1, sizeof(network));
net->n = n;
net->layers = calloc(net->n, sizeof(layer));
net->seen = calloc(1, sizeof(size_t));
net->t = calloc(1, sizeof(int));
net->cost = calloc(1, sizeof(float));
return net;
}
network *net = make_network(sections->size - 1);
从 net 变量开始, 依次为其中的指针变量分配内存. 由于第一个段 “[net]” 中存放的是和网络并不直接相关的配置参数, 因此网络中层的数目为 sections->size - 1, 即:
net->layers[count] = l; // save the layer to nerwork
第一节中提到: “根据各个参数的作用可以将网络配置文件中的配置项分为两类”, 因此需要对这两类配置参数分别处理.
配置文件的第一个段一定是"[net]" 段, 该段的参数解析由 parse_net_options() 函数完成, 函数定义在 darknet/src/parser.c 中.
之后的各段都是网络中的层, 比如, 用来完成特定的特征提取的卷积层, 用来降低训练误差的 shortcut 层和用来防止过拟合的 dropout 层等.
这些层都有特定的解析函数: 比如 parse_convolutional(), parse_shortcut() 和 parse_dropout() 等( 定义在 darknet/src/parser.c 中).. 每个解析函数返回一个填充好的层 l, 将这些层全部添加到 network 结构体的 layers 数组中. 即:
if (l.workspace_size > workspace_size)
workspace_size = l.workspace_size;
另外需要注意的是:
{
...
layer out = get_network_output_layer(net); // 通常情况下都是最后一层
net->outputs = out.outputs;
net->truths = out.outputs; // 默认值
if(net->layers[net->n-1].truths)
net->truths = net->layers[net->n-1].truths;
net->output = out.output;
net->input = calloc(net->inputs*net->batch, sizeof(float));
net->truth = calloc(net->truths*net->batch, sizeof(float));
...
}
workspace_size 表示网络的工作空间, 指的是所有层中占用运算空间最大的那个层的 workspace_size. 因为实际上在 GPU 或 CPU 中某个时刻只有一个层在做前向或反向运算.
注意: 填充层的参数时, 上一层的输入会作为下一层的输出.
最后的收尾工作: 输出层只能在网络搭建完毕之后才能确定, 输入层需要考虑 batch_size 的因素. truth 和输入层的维度相同, 也就是 label 信息.
{
...
#ifdef GPU
net->output_gpu = out.output_gpu;
net->input_gpu = cuda_make_array(net->input, net->inputs*net->batch);
net->truth_gpu = cuda_make_array(net->truth, net->truths*net->batch);
#endif
if(workspace_size){
#ifdef GPU
if(gpu_index >= 0){
net->workspace = cuda_make_array(0, (workspace_size-1)/sizeof(float)+1);
}else {
net->workspace = calloc(1, workspace_size);
}
#else
net->workspace = calloc(1, workspace_size);
#endif
...
}
上面提到的涉及到网络层的部分, 均会提供 GPU 版本, 其实最重要的也就下面这 4 个变量:
for(i = 0; i < ngpus; ++i){
free_network(nets[i]);
}
至此, 网络的解析结束, parse_network_cfg() 函数( 定义在 darknet/src/parser.c 中) 返回解析好的 network 类型的指针变量. 这个指针变量会伴随训练的整个过程, 因此一旦程序达到最大训练次数而需要退出训练时, 保存完最后一个模型后应该调用 free_network() 释放内存, 然后程序结束.
总结
但是 darknet 原生态代码中并没有显式调用 free_network() 释放内存.
可能有的人会问, 既然如此, 为什么不直接将配置文件读取并解析到 network 结构体变量中, 而要使用一个中间数据结构来缓存读取到的文件呢?
如果不使用中间数据结构来缓存. 将读取和解析流程串行进行的话, 如果配置文件较为复杂, 就会长时间使文件处于打开状态. 如果此时用户更改了配置文件中的一些条目, 就会导致读取和解析过程出现问题.
分开两步进行可以先快速读取文件信息到内存中组织好的结构中, 这时就可以关闭文件. 然后再慢慢的解析参数.
这种机制类似于操作系统中断的底半部机制, 先处理重要的中断信号, 然后在系统负荷较小时再处理中断信号中携带的任务.