DarkNet 框架介绍

DarkNet 框架介绍

最近在看 YoLo 源码的时候,发现原作者使用的是一个叫 DarkNet 的神经网络库,地址如下: https://github.com/pjreddie/darknet

Darknet is an open source neural network framework written in C and CUDA. It is fast, easy to install, and supports CPU and GPU computation

我在 Github 搜索时,发现 star 第二多的是一个中国人,仓库地址: hgpvision/darknet ,他对 DarkNet 的源码做了非常详细的注释。

由于我之前在学习 deeplearning. ai 的课程时,曾经跟着课后作业写过一个居于 Numpy 的 Python 神经网络库,同时也总结了一下流程。

于是就想对 DarkNet 的源码也做一番分析,这里写下自己的心得体会

如果对神经网络算法实现感兴趣的,本文会有一些帮助。但这此之前,必须搞懂神经网络的理论原理,否则会有一些困难。

1. 模型导入

1.1 模型定义文本

不同框架模型导入机制不同。DarkNet 和 Caffe 都是基于 C/C++ 的,因此 DarkNet 也借鉴了 Caffe 的模型定义

DarkNet 的 模型定义文件 后缀为 .cfgtxt 文本类型

  • 每一层网络都采用 [name] 标记该层类型,比如 [convolutional] 表示卷积层, [maxpool] 表示 池化层 , [connected] 表示全连接层

  • 每一层网络的参数 (hyperparameter) 采用 key = value 格式定义,
    比如下面是 AlexNet 的第一层卷积层:

    [convolutional]
    filters=96        # 卷积核个数
    size=11           # 卷积核尺寸 (长宽相同)
    stride=4      # 移动步长
    pad=0         # 是否在边缘补 0
    activation=relu    # Relu 激活函数
  • 模型文本的第一层网络定义的是全局通用参数,该层类型为 [net]
    常见参数如下:

    batch         # 每次处理的批个数
    height / width   # 输入图片高度/宽度
    channel           # 输入图片通道,一般为 3 (RGB)
    decay         # 正则项系数,也称衰减系数
    momentum      # 动量,x <- momentum * x - alpha * dx 
    learning_rate  # 学习率

1.2 利用 链表 表征读取的模型文本

DarkNet 采用 双向链表 将 .cfg 文本定义的模型读取到内存中

listnode 结构体定义在 list.hlist.c 文件,node 存储的数据的类型为 void * 类型

在具体应用中,每个节点 (node) 存储的数据类型为 section 结构体,定义在 parser.c 文件

读取文本到链表的函数

函数实现的具体功能如下图所示,可供参考

2. 模型建立

2.1 networklayer 结构体

network 结构体表示整个网络结构,包括所有全局参数和运行时指针传递的功能,这里只介绍几个重要的变量,具体课参考上面提到的博客

layer 结构体表示每一层网络结构,定义在 layer.h 文件

两个结构体的具体变量含义,在后面训练模型的时候就看出来了,这里大致可以理解就 OK 了。这也符合软件开发的流程,我们在大致搭好框架后,每次迭代开发,需要添加新功能时,会在类里面添加新的变量,所有并不是所有的变量是开始就存在的。

所以放心往下继续看吧。

2. 建立模型

DarkNet 通过上面的已经获得的双向链表 生成对应的 network 和 layer 结构体,并填充参数。具体函数均在 parser.c 文件实现

注意: size_params 结构体定义在 parser.c 中,这里必须得提一下,用过 Pytorch 的应该都知道,Pytorch 会根据我们指定的神经网络结构自动计算权重参数的个数,背后的原理就是根据输入数据尺寸和输出数据尺寸以及网络层类型自动计算需要的权重参数。

DarkNet 怎么干的呢 ?

parse_connected 创建全连接层为例,它主要调用了 make_connected_layer 函数创建 一个新的 layer 结构体 l,其中 l.weights = calloc(outputs*inputs, sizeof(float)) 分配的权重参数个数需要 inputsoutputs 变量,而 inputs 变量则是由 pramas 传递进来的,后面会详细说明

函数 parse_network_cfg 的流程实现

  1. list *sections = read_cfg(filename) 获得 链表结构体

  2. node * n = sections -> front 获得链表头节点,同时获得 node 的 value 变量,它是一个 section 结构体,在进一步获得 section 结构体的 options 变量,这就是我们想要的
    parse_net_options(options, &net) 解析 [net] 层的参数链表结构体 ,并赋值给 network 结构体变量

  3. 新建一个 size_params params 变量,将 network 结构体的一些参数赋值进来

  4. while 循环,终止条件为 节点 n 不存在,即到达尾节点

    • s = (section *)n->val; options = s->options; 获得单层参数链表结构体
    • string_to_layer_type(s->type) 获得 网络层的类别
    • 根据获得的类别,使用对应的 parse_xxx 函数返回对应的 layer 结构体,比如 l = parse_convolutional(options, params) 实现卷积层解析,其中的 params 变量就是上面的建立的结构体
    • 设置一些特殊的参数变量,比如 l.truth, l.onlyforward, l.stopbackward
    • net.layers[count] = llayer 结构体传递到 network 结构体
    • 根据 l.workspace_size 设置 workspace 临时变量
    • n = n -> next 转到下一个节点
    • ++count 更新层数值
    • 根据 结构体 l 的 输出数据尺寸重新设置 params 结构体,用于传递下一层 layer 的输入数据尺寸结构
  5. net.inputnet.truth 分配整个神经网络的输入数据、输出数据所需的内存,用 calloc 函数,该函数会初始化清零

  6. net.workspace 临时空间结构体分配内存,内存大小就是临时变量 workspace_size 的值

parse_connected 为例,介绍 全连接层网络建立

  1. 根据 options 链表 获得参数 ouput – 输出神经元个数

  2. 获得激活函数类型

  3. 是否 batch normalization

  4. make_connected_layer 创建 layer 结构体,注意 connected_layer 只是 layer 结构体的别名,该函数在 connected_layer.c 实现,流程如下:

    • 分配内存,包括数组 l.output, l.delta, l.weight_updates, l.bias_updates, l.weights, l.biases ,具体分配内存的长度都是根据输入、输出神经元个数 (参数: inputs, outputs)和 batch 大小确定的

    • 实现 l.forward, l.backward, l.update 3个函数
      比如: l.forward = forward_connected_layer 说明全连接层的前向传播函数为 forward_connected_layer

    • 由于前面分配内存采用 calloc 函数,为了打破对称性,我们必须将 权重参数 l.weights 随机化赋值
      这里 缩放因子采用 sqrt(2./inputs) ,一般适用于 relu 激活函数,原因在后面这篇博客提到了。 类似的初始化方法还有 Xavier initialization ,这里推荐一篇博客,Xavier 初探
      目的是保证输入和输出数据分布 (均值和方差) 一致,加快收敛

    • l.biases 全部初始化为 0

    • 如果 batch_normalize, 则分配变量 l.scales, l.scale_updates, l.mean, l.mean_delta, l.variance, l.variance_delta

3. 模型训练

3.1 数据 是以 一维数组的形式存储的

如果使用基于 Python 的神经网络框架,我们都习惯了用 tensor (张量) 表征 输入,输出数据。其实 tensor 说白了就是多维数组,Python 科学计算包对 多维数组的支持 隐藏了底层实现,对使用者非常方便。

但 DarkNet 是基于 C 语言的,C 虽然可以用 指针嵌套实现多维数组,但内存释放非常麻烦 , 相信内存泄漏的 Bug 是所有 C/C++ 程序员的噩梦。其实 DarNet 中也有相当一部分代码是 关于内存释放 的,但这篇博客就不提了。如果你感兴趣,可以自己查阅源码。

回到原话题,DarkNet 框架将所有数据都是用一维数组存放的,理解这一点非常重要。

每一个 layer 结构中的 h, w, c 以及 batch 参数用于指定一维数组的大小,如下图所示,

数据存放的顺序为 layer.w -> layer.h -> layer.c -> layer.batch

理解这一点,对后面 1维数组的矩阵计算源码分析非常有帮助

3.2 train_network_datum

这个函数很清楚的表现了一次 batch 处理的流程,在 network.c 中实现的

3.3 前向传播 forward_network

注意:

  1. 每次 input -> output, 通常是拷贝,如果在 卷积层,还要对数据重排,然后再拷贝,后面会写一篇博客单独讲 CNN 层的具体实现。
    前向传播计算结束,我们要将这一层的输出传递给下一层的输入,通过指针传递实现,就是上面代码的 net.input = l.output
    最后的输出层,将 net.truth 设为 l.output ,用于后续的损失函数计算 calc_network_cost

  2. 清空 layer.delta ,这一步在 Pytorch 中也有相应的操作 net.zero_grad() , zeroes the gradient buffers of all parameters, in Pytorch, gradient is accumulated to existing gradient.

  3. 这里留一个问题,如果 layer.delta 都清空了,反向传播如何逐层相乘呢 ? 因为无论怎么相乘,结果都是 0
    可以先自己想一下,我在后面会解释的。

3.3 反向传播 backward_network

流程如下:

  1. 先备份 network 结构体
    注意: 在 C 语言中,进行结构体拷贝操作,结构体的指针变量进行拷贝操作,只会简单的拷贝指针,并不会进行值拷贝。
    也就是说,这里虽然备份了 network 结构体,但后续对 network 结构体中的 layer *layers 指针操作,并不会被覆盖掉,因为两个结构体的指针指向同一块内存。

  2. for 循环,循环次数为 n ,结束时,恢复原 network 结构体,但对 layers 指针的操作保留
    获得下一层的 输出指针 ouput 和 梯度累积指针 delta
    通过 netinput , delta 传递上面两个指针
    运行 layer.backward 函数,参数为 当前层的结构体和 network 结构体,它实现的功能如下:

    • 根据上一层的前向输入 (通过 net.input 传递进来)计算梯度,并得到weight_updates, bias_updates
    • 更新上一层的 delta 数据 (梯度累加),通过 net.delta 传递出去

具体流程可以如下图所示:

回答前面提到过这个问题:

前向传播 每层 layer 结构体清空 delta 数组的情况下,反向传播过程中,最开始的那一层,也就是输出层,它的 delta 是不是为 0,如果为 0,怎么进行后续计算呢 ?

DarkNet 在这里有一个小技巧,因为 DarkNet 规定所有网络结构最后一层均为 [cost] 类型

所以如果你看了 cost_layer.c 关于 该层网络的 前向传播和反向传播实现,你就可以回答上面的问题了。

上面的 l2_cpu 用于计算 误差平方和,它将误差传递给 l.output ,但同时它还干了一件事,计算 l.delta ,也就是说,最后一层的 delta 是在前向传播函数中就直接完成的

所以它的反向传播函数非常简单,直接将该层的 delta 值拷贝给前一层的 delta (指针通过 net.delta 传递)

4. 数据导入

//TODO

发表评论

电子邮件地址不会被公开。 必填项已用*标注

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d 博主赞过: