【AlexeyAB DarkNet框架解析】二,数据结构解析

按照前面的思路,这一节进入到DarkNet的数据结构解析。Darknet是一个C语言实现的神经网络框架,这就决定了其中大多数保存数据的数据结构都会使用链表这种简单高效的数据结构。

基础数据结构

为了解析网络配置参数,DarkNet 中定义了三个关键的数据结构类型。list类型变量保存所有的网络参数, section类型变量保存的是网络中每一层的网络类型和参数, 其中的参数又是使用list类型来表示。kvp键值对类型用来保存解析后的参数变量和参数值。

  • list类型定义在src/list.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 类型定义在src/parser.c文件中,代码如下:
// 定义section
typedef struct{
    char *type;
    list *options;
}section;
  • kvp 键值对类型定义在src/option_list.h文件中,具体定义如下:
// kvp 键值对
typedef struct{
    char *key;
    char *val;
    int used;
} kvp;

在Darknet的网络配置文件(.cfg结尾)中,以[开头的行被称为一个段(section)。所有的网络配置参数保存在list类型变量中,list中有很多的section节点,每个section中又有一个保存层参数的小list,整体上出现了一种大链挂小链的结构。大链的每个节点为section,每个section中包含的参数保存在小链中,小链的节点值的数据类型为kvp键值对,这里有个图片可以解释这种结构。

我们来大概解释下该参数网,首先创建一个list,取名sections,记录一共有多少个section(一个section存储了某一网络层所需参数);然后创建一个node,该nodevoid类型的指针指向一个新创建的section;该sectionchar类型指针指向.cfg文件中的某一行(line),然后将该sectionlist指针指向一个新创建的node,该nodevoid指针指向一个kvp结构体,kvp结构体中的key就是.cfg文件中的关键字(如:batch,subdivisions等),val就是对应的值;如此循环就形成了上述的参数网络图。

解析并保存网络参数到链表中

读取配置文件由src/parser.c中的read_cfg()函数实现:

/*
 * 读取神经网络结构配置文件(.cfg文件)中的配置数据, 将每个神经网络层参数读取到每个
 * section 结构体 (每个 section 是 sections 的一个节点) 中, 而后全部插入到
 * list 结构体 sections 中并返回
 *
 * \param: filename    C 风格字符数组, 神经网络结构配置文件路径
 *
 * \return: list 结构体指针,包含从神经网络结构配置文件中读入的所有神经网络层的参数
 * 每个 section 的所在行的开头是 ‘[’ , ‘\0’ , ‘#’ 和 ‘;’ 符号开头的行为无效行, 除此
 *之外的行为 section 对应的参数行. 每一行都是一个等式, 类似键值对的形式.

 *可以看到, 如果某一行开头是符号 ‘[’ , 说明读到了一个新的 section: current, 然后第1508行
 *list_insert(options, current);` 将该新的 section 保存起来.

 *在读取到下一个开头符号为 ‘[’ 的行之前的所有行都是该 section 的参数, 在第 1518 行
 *read_option(line, current->options) 将读取到的参数保存在 current 变量的 options 中.
 *注意, 这里保存在 options 节点中的数据为 kvp 键值对类型.

 *当然对于 kvp 类型的参数, 需要先将每一行中对应的键和值(用 ‘=’ 分割) 分离出来, 然后再
 *构造一个 kvp 类型的变量作为节点元素的数据.
 */
list *read_cfg(char *filename)
{
    FILE *file = fopen(filename, "r");
	//一个section表示配置文件中的一个字段,也就是网络结构中的一层
    //因此,一个section将读取并存储某一层的参数以及该层的type
    if(file == 0) file_error(filename);
    char *line;
    int nu = 0; //当前读取行号
    list *sections = make_list(); //sections包含所有的神经网络层参数
    section *current = 0;//当前读取到某一层
    while((line=fgetl(file)) != 0){
        ++ nu;
        strip(line); //去除读入行中含有的空格符
        switch(line[0]){
			 // 以 '[' 开头的行是一个新的 section , 其内容是层的 type
            // 比如 [net], [maxpool], [convolutional] ...
            case '[':
                current = (section*)xmalloc(sizeof(section));
                list_insert(sections, current);
                current->options = make_list();
                current->type = line;
                break;
            case '\0': //空行
            case '#': //注释
            case ';': //空行
                free(line); // 对于上述三种情况直接释放内存即可
                break;
            default:
			    // 剩下的才真正是网络结构的数据,调用 read_option() 函数读取
                // 返回 0 说明文件中的数据格式有问题,将会提示错误
                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 sections;
}

链表的插入操作

保存section和每个参数组成的键值对时使用的是list_insert()函数, 前面提到了参数保存的结构其实是大链( 节点为section)上边挂着很多小链( 每个section节点的各个参数)。list_insert()函数实现了链表插入操作,该函数定义在src/list.c文件中:

/*
 * 简介: 将 val 指针插入 list 结构体 l 中,这里相当于是用 C 实现了 C++ 中的
 *         list 的元素插入功能
 *
 * 参数: 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* newnode = (node*)xmalloc(sizeof(node));
    newnode->val = val;
    newnode->next = 0;
    // 如果 list 的 back 成员为空(初始化为 0), 说明 l 到目前为止,还没有存入数据
    // 另外, 令 l 的 front 为 new (此后 front 将不会再变,除非删除)
    if(!l->back){
        l->front = newnode;
        newnode->prev = 0;
    }else{
        l->back->next = newnode;
        newnode->prev = l->back;
    }
    l->back = newnode;
    ++l->size;
}

可以看到, 插入的数据都会被重新包装在一个新的node : 变量new中,然后再将这个节点插入到链表中。网络结构解析到链表中后还不能直接使用, 因为想使用任意一个参数都不得不每次去遍历整个链表, 这样就会导致程序效率变低, 所以最好的办法是将其保存到一个结构体变量中, 使用的时候按照成员进行访问。复杂度从。

将链表中的网络结构保存到network结构体

  • 首先来看看network结构体的定义,在include/darknet.h中:
// 定义network结构体
typedef struct network {
    int n; //网络的层数,调用make_network(int n)时赋值
    int batch; //一批训练中的图片参数,和subdivsions参数相关
    uint64_t *seen; //目前已经读入的图片张数(网络已经处理的图片张数)
    int *t;
    float epoch; //到目前为止训练了整个数据集的次数
    int subdivisions;
    layer *layers; //存储网络中的所有层
    float *output;
    learning_rate_policy policy; // 学习率下降策略
    int benchmark_layers;
    // 梯度下降法相关参数
    float learning_rate; //学习率
    float learning_rate_min; //学习率最小值
    float learning_rate_max;  //学习率最大值
    int batches_per_cycle; //
    int batches_cycle_mult;
    float momentum;
    float decay;
    float gamma;
    float scale;
    float power;
    int time_steps;
    int step;
    int max_batches;
    int num_boxes;
    int train_images_num;
    float *seq_scales;
    float *scales;
    int   *steps;
    int num_steps;
    int burn_in;
    int cudnn_half;
    // ADAM优化方法相关策略
    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;
    int flip; // horizontal flip 50% probability augmentaiont for classifier training (default = 1)
    int blur;
    int mixup;
    float label_smooth_eps;
    int resize_step;
    int letter_box;
    float angle;
    float aspect;
    float exposure;
    float saturation;
    float hue;
    int random;
    int track;
    int augment_speed;
    int sequential_subdivisions;
    int init_sequential_subdivisions;
    int current_subdivision;
    int try_fix_nan;
    //darknet 为每个 GPU 维护一个相同的 network, 每个 network 以 gpu_index 区分
    int gpu_index;
    tree *hierarchy;

	//中间变量,用来暂存某层网络的输入(包含一个 batch 的输入,比如某层网络完成前向,
    //将其输出赋给该变量,作为下一层的输入,可以参看 network.c 中的forward_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;
	//每一层的损失,只有[yolo]层有值
    float *cost;
    float clip;

#ifdef GPU
    //float *input_gpu;
    //float *truth_gpu;
    float *delta_gpu;
    float *output_gpu;

    float *input_state_gpu;
    float *input_pinned_cpu;
    int input_pinned_cpu_flag;

    float **input_gpu;
    float **truth_gpu;
    float **input16_gpu;
    float **output16_gpu;
    size_t *max_input16_size;
    size_t *max_output16_size;
    int wait_stream;

    float *global_delta_gpu;
    float *state_delta_gpu;
    size_t max_delta_gpu_size;
#endif
    int optimized_memory;
    size_t workspace_size_limit;
} network;
  • network结构体分配内存空间,函数定义在src/network.c文件中,代码如下:
//为network结构体分配内存空间
network make_network(int n)
{
    network net = {0};
    net.n = n;
    net.layers = (layer*)xcalloc(net.n, sizeof(layer));
    net.seen = (uint64_t*)xcalloc(1, sizeof(uint64_t));
#ifdef GPU
    net.input_gpu = (float**)xcalloc(1, sizeof(float*));
    net.truth_gpu = (float**)xcalloc(1, sizeof(float*));

    net.input16_gpu = (float**)xcalloc(1, sizeof(float*));
    net.output16_gpu = (float**)xcalloc(1, sizeof(float*));
    net.max_input16_size = (size_t*)xcalloc(1, sizeof(size_t));
    net.max_output16_size = (size_t*)xcalloc(1, sizeof(size_t));
#endif
    return net;
}

src/parser.c中的parse_network_cfg()函数中,从net变量开始,依次为其中的指针变量分配内存。由于第一个段[net]中存放的是和网络并不直接相关的配置参数, 因此网络层的数目为sections->size - 1,即:network *net = make_network(sections->size - 1);

  • 将链表中的网络参数解析后保存到network结构体,配置文件的第一个段一定是[net]段,该段的参数解析由parse_net_options()函数完成,函数定义在src/parser.c中。之后的各段都是网络中的层。比如完成特定特征提取的卷积层,用来降低训练误差的shortcur层和防止过拟合的dropout层等。这些层都有特定的解析函数:比如parse_convolutional(), parse_shortcut()parse_dropout()。每个解析函数返回一个填充好的层l,将这些层全部添加到network结构体的layers数组中。即是:net->layers[count] = l;另外需要注意的是这行代码:if (l.workspace_size > workspace_size) workspace_size = l.workspace_size;,其中workspace代表网络的工作空间,指的是所有层中占用运算空间最大那个层的workspace。因为在CPU或GPU中某个时刻只有一个层在做前向或反向传播。输出层只能在网络搭建完毕之后才可以确定,输入层需要考虑batch_size的因素,truth是输入标签,同样需要考虑batch_size的因素。具体层的参数解析后面专门写一篇推文来帮助理解。
  • 到这里,网络的宏观解析结束。parse_network_cfg()(src/parser.c中)函数返回解析好的network类型的指针变量。

为啥需要中间数据结构缓存?

这里可能有个疑问,为什么不将配置文件读取并解析到network结构体变量中, 而要使用一个中间数据结构来缓存读取到的文件呢?因为,如果不使用中间数据结构来缓存. 将读取和解析流程串行进行的话, 如果配置文件较为复杂, 就会长时间使文件处于打开状态。如果此时用户更改了配置文件中的一些条目, 就会导致读取和解析过程出现问题。分开两步进行可以先快速读取文件信息到内存中组织好的结构中, 这时就可以关闭文件. 然后再慢慢的解析参数。这种机制类似于操作系统中断的底半部机制, 先处理重要的中断信号, 然后在系统负荷较小时再处理中断信号中携带的任务。

后记

今天讲了DarkNet中底层数据结构是如何组织的,到现在为止我们可以获得一个存储了所有网络配置信息的network结构体,接下来就是加载数据进行训练以及测试了。由于篇幅原因今天就先讲到这里,谢谢大家观看。

欢迎关注GiantPandaCV, 在这里你将看到独家的深度学习分享,坚持原创,每天分享我们学习到的新鲜知识。( • ̀ω•́ )✧

下一篇
举报

扫码关注云+社区

领取腾讯云代金券