Press "Enter" to skip to content

读源码品Caffe – 结构流程篇

本站内容均来自兴趣收集,如不慎侵害的您的相关权益,请留言告知,我们将尽快删除.谢谢.

背景

本文主要介绍深度学习框架Caffe的工作原理和实现。时至今日,各种深度学习框架百花齐放,百家争鸣,从流行程度来说Caffe可能已经不同往日,那我们为什么还要来学习它的代码呢?尽管今天我们有了更多的选择,比如TensorFlow后来居上,凭借完整的生态、庞大的社区和积极的演进,快速成为了学界和工业界的流行框架。但相比而言,Caffe由于开源的时间早,因此在深度学习领域尤其是视觉方面有着非常深远的影响。很多的项目仍然是基于Caffe或其衍生品的。而且相比之下,它的实现比较轻巧,框架也很清晰,适合用作了解深度学习实现之用,也适合修改用于实验。编译安装过程可参见官方链接。注意如果用的CUDA是9.0,编译过程可能会遇到错误:

nvcc fatal : Unsupported gpu architecture 'compute_20'

只需要将Makefile.config中的带-gencode arch=compute_20的两行删去即可。

结构

Caffe的目录结构基本符合传统的开源项目习惯,几个主要目录看名字就知道做什么的。主要实现位于src/caffe目录下,它会编译出libcaffe.a和libcaffe.so两个库。Caffe中用到的很多结构化数据以Protocol Buffers
的格式存储,相关协议定义文件为src//proto/caffe.proto。python和matlab目录提供了相应的binding,scripts和tools目录下提供了一些脚本和工具,最重要的是可执行程序caffe.bin。几个核心功能都以它为入口。model目录提供了几种常用模型的网络结构描述文件和solver配置文件。更多模型可以上Caffe的model zoo。

Caffe中有四大基本组件:Blob,Layer,Net和Solver。前三者为神经网络的结构组件,分别对应数据块,层和网络(官方介绍)。Solver为求解器(官方介绍)。这几个组件之间的关系大约为下图:

Blob(blob.hpp)为数据载体,它是Layer, Net, Solver几个组件间交互的基本单元。无论是输入数据、层之间传递的数据(top & bottom blob)、还是层中的参数(param blob),都是以Blob形式封装的。Blob中的主要数据块有三个:data_为数据,diff_主要存储梯度,shape_为维度信息。由于计算可以通过CPU也可以通过GPU进行,数据需要在两者间进行同步,因此这些数据成员封装成SyncedMemory。它负责分配内存和管理CPU和GPU间的同步。成员count_为blob中当前的元素个数,capacity_为blob中当前分配内存的容量。当count_超过capacity_时意味着需要扩充内存。我们知道,存储格式上Caffe采用了NCHW(不像TensorFlow采用的NHWC),memory layout为row-major。Blob中的数据最高能到32维,但多数情况下是4维,即num, channel, height, width,分别对应shape的0到3维。
Layer(layer.hpp)为网络中的层。一般来说,除了特殊层,如data layer和loss layer,大多数层都会接收输入(bottom blob),通过某种类型的操作(如卷积,池化,全连接),给出输出(top blob)。Layer的分类详见官方介绍。这些layer们的继承关系组成了一个大的树结构。Layer中调用Forward()和Backward()进行前向和后向传播。他们根据当前的mode选择CPU实现(Forward_cpu()和Backward_cpu()函数)或者GPU实现(Forward_gpu()和Backward_gpu()函数)。其中Forward_cpu()和Backward_cpu()为纯虚函数,因此所有的layer中都需要实现。而Forward_gpu()和Backward_gpu()为虚函数,layer中可以实现自己的版本,否则的话就fallback到CPU版本。另一个需要所有layer实现的纯虚函数是Reshape(),因为reshape规则是layer实现相关的。
Net(net.hpp)为网络。它将layer连接在一起形成一张计算图。训练的过程会不断调用ForwardBackward()函数。该函数调用的两个函数-Forward()和Backward()分别是对网络进行一次前向传播和后向传播。另外,Update()函数负责layer中所有可学习参数的更新。
Solver(solver.hpp)为求解器。它主要负责组织和协调网络结构的构建与初始化,模型参数优化(训练),模型的验证测试等。Solver有很多继承类,每个继承类对应特定优化方法。由于现有的优化方法都是基于SGD的变种,因此结构上来看,SGDSolver为Solver的继承类,其它类型Solver都继承自SGDSolver。训练过程主要在Solve()函数中完成。该函数中调用Step()函数,继而不断通过Net的ForwardBackward()函数做前向和后向传播。梯度更新后,根据不同类型Solver进行参数的更新。该更新在ComputeUpdateValue()函数中完成。

流程

下面看一个简单的用Caffe进行深度学习训练的例子 – 基于LeNet(详见论文《Gradient-Based Learning Applied to Document Recognition》)进行训练MNIST数据集。由于一方面MNIST数据集够简单小巧,另一方面LeNet结构简单但聚集了CNN的核心基本操作,因此,这几乎是每一个深度学习教程中的第一个例子,也算是深度学习界的”Hello World”了。操作过程详见官方链接。简单来说就是运行下面三个脚本:

# 下载MNIST数据集$ ./data/mnist/get_mnist.sh # 将MNIST原始数据存成lmdb格式。类似于TensorFlow中要先转成TFRecord提高效率。$ ./examples/mnist/create_mnist.sh # 开始训练$ ./examples/mnist/train_.sh

其中train_lenet.sh本质上是执行了下面命令:

$ ./build/tools/caffe train --solver=examples/mnist/lenet_solver.prototxt

可以看到,用Caffe训练几乎不用写代码(当然前提是所使用的layer都是Caffe官方支持的),求解器和网络结构都是通过配置文件来输入。lenet_solver.prototxt是求解器的配置文件(TensorFlow在物体检测API中也采用了类似的方式)。这些文件都是用protobuf描述的,具体的数据协议见src/caffe/proto/caffe.proto。lenet_solver.prototxt中主要包括了优化求解所需参数,迭代次数,snapshot(类似于TensorFlow中的checkpoint)路径及存储周期,是否使用GPU等。具体参数的含义可见注释及官方说明。其中第一个参数给出了网络的定义文件路径examples/mnist/lenet_train_test.prototxt。该文件说明可以见官方说明。

如果想要图形化网络结构,可以用draw_net.py脚本,比如图形化LeNet:

$ ./python/draw_net.py ./examples/mnist/lenet_train_test.prototxt ./examples/mnist/lenet_train_test.png

类似于tensorboard的Graph面板(但木有那么高级),输出下面结果:

还有逼格更高些的Netscope,它是一个神经网络结构可视化工具,支持caffe模型的格式。用法就像写markdown一样,左边写网络定义,右边就可以可视化出来。如LeNet:

回到上面的训练命令,该命令的输出可分成几个部分:Solver初始化,训练网络初始化,测试网络初始化,训练过程。其中在网络初始化的时候会打印出各个层构造的log,如第一个卷积层的构建过程:

183 I0331 16:51:26.953500 22735 layer_factory.hpp:77] Creating layer conv1184 I0331 16:51:26.953513 22735 net.cpp:84] Creating Layer conv1185 I0331 16:51:26.953518 22735 net.cpp:406] conv1 <- data186 I0331 16:51:26.953527 22735 net.cpp:380] conv1 -> conv1187 I0331 16:51:29.936190 22735 net.cpp:122] Setting up conv1188 I0331 16:51:29.936215 22735 net.cpp:129
] Top shape: 64 20 24 24 (737280)189 I0331 16:51:29.936219 22735 net.cpp:137] Memory required for data: 3150080

其中conv1为层名,conv1 <- data和conv1 -> conv1分别代表该层的输入和输出。然后是top blob的shape。因为网络的输入的某些维度是可以不固定的,因此构建网络时需要根据真实的输入shape依次计算出网络中每一层的确切shape。在这里,由于输入是28 x 28的图片,该卷积层kernel size为5 x 5,stride为1,根据公式(input_size−kernel_size+2∗padding)/stride+1

(input_size−kernel_size+2∗padding)/stride+1

(input_size−kernel_size+2∗padding)/stride+1

(input_size−kernel_size+2∗padding)/stride+1

得到输出的size,也就是最后两个维度为24 x 24;因为网络定义中num_output为20,即输出有20个feature map,因此第三维为20;网络结构描述文件中输入batch指定为64(每次向前传播时输入的sample个数),所以这里第一维为64。最后很温馨地给出了所需的memory。

训练过程中的log中会持续输出loss, learning rate等信息,由于前面的solver描述文件中指定每过100次迭代输出,所以每当迭代次数为100整数就会输出类似下面信息:

914 I0331 16:51:57.116389 22735 solver.cpp:239] Iteration 9900 (371.101 iter/s, 0.269468s/100 iters), loss = 0.        00668194915 I0331 16:51:57.116416 22735 solver.cpp:258]     Train net output #0: loss = 0.0066819 (* 1 = 0.0066819 loss)916 I0331 16:51:57.116421 22735 sgd_solver.cpp:112] Iteration 9900, lr = 0.00596843

solver描述文件中指定每过5000次输出snapshot,总迭代次数10000次。因此在5000和10000次时会输出snapshot,主要包括两个文件:caffemodel文件包含网络中的参数;solverstate文件除了参数外,还有迭代次数,learning rate等信息。

917 I0331 16:51:57.381996 22735 solver.cpp:468] Snapshotting to binary proto file examples/mnist/lenet_iter_10000.     caffemodel918 I0331 16:51:57.386615 22735 sgd_solver.cpp:280] Snapshotting solver state to binary proto file examples/mnist/     lenet_iter_10000.solverstate919 I0331 16:51:57.388149 22735 solver.cpp:331] Iteration 10000, loss = 0.00307433920 I0331 16:51:57.388164 22735 solver.cpp:351] Iteration 10000, Testing net (#0)

类似地,根据solver中的设定,每500次迭代要在测试集上做测试,输出loss和accuracy。这样我们就可以容易地找出什么时候发生overfit。下面是最后一次的结果。由于MNIST非常简单,即使是在笔者这种穷人配置上,训练过程耗时约33.5秒,准确率轻松达到99%+。

921 I0331 16:51:57.465945 22750 data_layer.cpp:73] Restarting data prefetching from start.922 I0331 16:51:57.468549 22735 solver.cpp:418]     Test net output #0: accuracy = 0.9917923 I0331 16:51:57.468567 22735 solver.cpp:418]     Test net output #1: loss = 0.0299201 (* 1 = 0.0299201 loss)

我们知道,深度学习的训练过程往往需要观察训练过程,像TensorFlow中提供了tensorboard作为图形化工具。Caffe则简陋一些,但也可以通过解析log来生成。如解析上面训练LeNet时的log:

$ python tools/extra/parse_log.py ./train_lenet.log  ./log/

它生成lenet_train.log.train和lenet_train.log.test两个csv文件。然后用pandas读取,最后将之用matplotlib绘制出来:

凑合着看吧。。。

初始化

这里可执行程序caffe的源代码入口在tools/caffe.cpp。这个binary包含了四大功能:设备查询,训练,测试和测量执行时间。它们的函数名分别为device_query(), train(), test()和time()。相应的函数通过RegisterBrewFunction这个宏来注册到g_brew_map这个查找表中。当执行时,通过GetBrewFunction()根据参数选择相应的函数执行。比如上面训练时参数为train,那就会进到train()函数。我们以上面的LeNet训练MNIST为例先来过下Caffe中的具体实现。这个过程大体流程如下图:

其中完成工作主要有以下几步:
一、通过ReadSolverParamsFromTextFileOrDie()函数从solver配置文件中把数据读入并填入SolverParameter结构。
二、处理命令行传入的level和stage参数(如有),它们主要用于all-in-one network。同一个网络可能在不同阶段或者不同用处时结构是有所不同的,常见的例子比如对于训练和测试阶段输入的batch size是不同的,又比如有些层是只有训练时才需要的(比如dropout),有些只有测试时才需要的(如accuracy)。使用这种网络结构的”if-else”机制可以避免写多个网络结构描述文件,实现all-in-one network。
三、配置GPU,是否使用GPU以及使用哪些GPU可以通过solver配置文件或者caffe命令行参数指定。根据实际情况调用Caffe::set_mode(Caffe::CPU)或者Caffe::set_mode(Caffe::GPU)。
四、配置signal,在训练过程中我们可以通过signal来与训练程序通信,比如默认SIGINT让其停止(这也保证了在训练过程中我们Ctrl-C也可以优雅地退出主循环),SIGHUP让其产生Snapshot。通过Solver的SetActionFunction()函数设置动作请求函数。后面的训练过程每一步都会调用它,该函数根据当前实际接收signal情况返回需要执行的动作信息。
五、如果不是从头开始训练的话,要么通过snapshot参数传入Snapshot状态文件(可以是h5或者是protobuf binary格式),要么传入存储权重参数的caffemodel文件,两者取其一。如果指定了Snapshot状态文件,这里会通过Solver的Restore()函数进行恢复;如果指定了权重参数文件,则加入到Solver参数结构中,后面在初始化网络时会通过LoadNetWeights()函数来恢复这些权重参数值。
六、通过SolverRegistry::CreateSolver()根据solver配置文件中指定的优化类型创建Solver实例。这一步中完成了Solver,Net,Layer, Blob等很多创建与初始化工作。
七、调用Solver的Solve()开始训练过程。
其它步都比较trivial,不细说,重点是第6和第7步,它们分别对应网络的初始化和训练,先看下初始化过程。Caffe中预定义了多种Solver,对应几种流行的优化方法:SGD,AdaDelta,AdaGrad,Adam,Nesterov,RMSProp。它们都会通过REGISTER_SOLVER_CLASS宏进行注册。以SGD为例,REGISTER_SOLVER_CLASS(SGD)会生成Creator_SGDSolver()函数,该函数负责创建SGDSolver对象。接下来创建对应的SolverRegisterer对象,该对象的唯一作用就是在构造函数中调用SolverReg
istry::AddCreator()注册该类型的Solver,即将”SGD”与相应的创建函数Creator_SGDSolver()的映射存入注册表(类型为CreatorRegistry的g_registry)。

接下来看下Solver的构造函数。Solver构造函数有两个版本,分别接收文件形式和SolverParameter结构形式的Solver参数。不过最后都是调用到Solver::Init()来初始化。一开始做一些细碎的初始化(检测snapshot的写权限和设定随机种子等)后,就通过InitTrainNet()和InitTestNets()两个函数来分别初始化训练和测试网络。大体流程如下图:

InitTrainNet()和InitTestNets()这两个函数实现是类似的,因此我们主要看InitTrainNet()函数,主要区别是InitTestNets()中可能(如有指定)会创建多个测试网络。InitTrainNet()函数一进去就是一顿参数检查,再就是根据情况载入网络结构描述文件。由于上面的例子中,solver的配置文件中只有net参数,这里调用ReadNetParamsFromTextFileOrDie()函数来载入指定的网络结构描述文件,读入到NetParameter结构中。然后设置NetState(包括phase, level, stage信息),依次从NetParameter和SolverParameter的train_state属性中读取,后面的会把前面(如有)成员内容覆盖(也就是说优先级更高),最后写入到NetParameter结构中。继而通过它来初始化Net对象,并将其智能指针赋给成员net_。最后通过从SolverParameter中指定的权重参数caffemodel文件(如有)加载参数值。其中创建和初始化这块流程较长,我们重点看下。Net的初始化主要在Net::Init()函数中完成,这个函数大体工作流程如下:
一、 函数FilterNet()中处理网络结构描述文件中的过虑规则。根据前面设定的信息,对网络结构进行过滤处理。
二、 函数InsertSplits()将那些共享的bottom blob用SplitLayer(一路输入,多路输出)替换。比如在上面LeNet例子中,输入层的label和ip2层即要输出到loss层,也要到accuracy层。
三、 从底层到高层逐层构建网络。对于网络结构中的每层,执行以下操作:

  1. 一坨杂事,比如继承Net的phase属性(如果layer本来没设的话),检查propagate_down设置是否正确(必须为0或者和bottom blob的size一致)。
  2. 通过LayerRegistry::CreateLayer()根据传入的LayerParameter创建相应的Layer对象,并将指针放入layers_这个数组中。LayerRegistry是一个工厂类,其中包含一个字符串到Layer创建函数(类型为Creator)指针的map结构,可以根据LayerParameter中的layer类型名找到相应的layer创建函数。这种用工厂模式创建的方式和上面创建Solver的方式很像,事实上实现基本就是差不多的。每种layer都会通过REGISTER_LAYER_CREATOR宏来进行注册,比如REGISTER_LAYER_CREATOR(Convolution, GetConvolutionLayer)会为Convolution这种类型的layer在注册表中映射到GetConvolutionLayer()这个函数。这样,当网络结构描述文件中有layer的type属性为Convolution时,就会调用GetConvlutionLayer()函数来创建相应的Layer对象。对于有些类型的layer,创建时不需要复杂的判断,就可以用REGISTER_LAYER_CLASS宏,它会生成一个简单的创建函数Creator_XXXLayer()。另外,我们知道layer中的操作可以用GPU加速。Layer基类中有Forward_cpu()和Forward_gpu(),Backward_cpu()和Backward_gpu(),分别对应CPU和GPU版本的实现。以InnerProductLayer为例,inner_product_layer.cpp中为CPU实现,inner_product_layer.cu中为GPU实现。如果木有CUDA,编译时加了CPU_ONLY宏,则定义相应的stub函数,当跑到时会出错。
  3. 对于该层的所有bottom blob,调用AppendBottom()进行进行处理。首先根据bottom blob名得到blob_id(通过blob_name_to_idx,在处理上一层的top时已经插入,因上一层的top即是这一层的bottom),然后更新bottom_vecs_和bottom_id_vecs_分别加入该blob的指针和blob_id。接着,将该blob从available_blobs_集合中去除,这个集合代表当前还没被作为bottom消费过的top blob,也就是整个网络逻辑上的output结点。最后计算该bottom blob是否参于back-propagation(BP),从blob_need_backward_取出该bottom blob对应的值,它代表该blob在上一层处理作为top blob时是否被标定为要参于BP(因为该信息需要从网络底层向高层传播,比如第n层的bottom blob要参于BP,意味着n+1, n+2, …也都需要参于BP,否则梯度传不到第n层),如果有定义propagate_down属性(网络结构描述文件中可以通过propagate_down来指定相应的bottom blob是否参于BP)则会直接覆盖前面的值。最后将该bottom blob是否参于BP(need_backward)记录到bottom_need_backward_数组当中。AppendBottom()返回后,只要当前bottom blob需要参与BP,则暂时将need_backward置为true。
  4. 对于该层所有的top blob,调用AppendTop()函数进行处理。这个函数里先判断是否为in-place计算(如激活层relu等),它们在网络结构描述文件中bottom和top名是一样的。如果是这种情况更新成员top_vecs_和top_id_vecs_,它们分别是网络中按层存放的所有top blob的指针,和相应的top blob的id(这个id就是在blobs_这个数组中的位置)。如果不是in-place的正常top blob,则新创建Blob对象,生成id,将blob名称放入blob_names_,设置blob_need_backward_默认为false(后面会更新)。在blob_name_to_idx插入相应元素,然后和in-place情况下一样更新top_id_vecs_和top_vecs_。最后在available_blobs集合中插入该blob名称(在前面提到的AppendBottom()函数中会在该blob作为bottom时将之清除)。如果当前是layer类型为Input,将top blob的指针和index记录在net_input_blobs_和net_input_blob_indices_中。另外,为了方便性和兼容性,对于loss layer,如果它没有指定top blob的话,也会自动给它创建。
  5. 调用当前层的SetUp()函数。该函数依次调用以下函数:
    (a)CheckBlobCounts():每种层可能都会对bottom和top blob的个数有特定要求,这里检测是否满足。
    (b)LayerSetUp():每种层如果有自定义的初始化操作,一般放到LayerSetUp()这个虚函数中,这里会调用它来初始化。
    (c)Reshape():根据该layer中bottom blob的shape调整param blob以及top blob的大小,因为后两者的维度是取决于前者的。
    (d)SetLossWeights():有时候,总的loss function中包含了几个layer的top blob输出,它们的权重可以通过在网络结构描述文件中loss_weight参数指定。这里会将相应top blob中的diff所有元素全填成loss_weight。
  6. SetUp()函数完成后,接下来根据上面SetLossWeights()函数中得到的信息填blob_loss_weights_数组。它代表的是所有top blob是否为loss function做“贡献”以及做多少“贡献”。在上面LeNet的例子中就最后的loss层值该值为1,其它层该值都为0。也就是说只有loss层的top是最终loss function的唯一成员。接下来会打印出该层所有top blob的维度信息,以及loss weight(如有)。之后估算所需memory,放于memory_used_成员中。本质上就是所有top blob(因下一层的top就是上一层的bottom,所以只要考虑top)的元素size累加。貌似没有将param blob所占内存累加在内,可能觉得相比feature map它的size是小头吧。
  7. 一些杂事,如检查网络定义中指定的param个数是否不大于layer中的param blob个数。比如网络结构描述文件中conv1层中定义了两个param,则该层中的param blob也为两个(weight和bias)。接下来设置param blob是否需要参与BP,如果网络结构描述文件中有相应param属性指定learning rate multiplier(由属性lr_mul
    t指定。比如说LeNet中cvon1层定义中有两个param,lr_mult分别为1和2)为非0的,则说明需要参与BP,设置need_backward为真,且通过set_param_propagate_down()标记相应param blob。
  8. 对于该层中所有的param blob(如conv1中的weight和bias),调用AppendParam()函数来处理。param_display_names_记录了参数的名字。params_和param_id_vecs_分别记录该blob的指针以及它的index。param_layer_indices_中记录了每一个layer和param对,它在后面可以用来通过param的id来找到该信息。然后就分两种情况处理:
    (a)第一种是非共享的param blob。这种参数要不就是匿名的,要不就是给了一个独一无二的名字的(通过ParamSpec中的name属性)。它的param_owners_对应位置填-1代表是非共享的。param_names_index_为通过参数名查找id的map,后面通过重名判断是否为共享blob时需要。接着设置learnable_params_和learnable_param_ids_,它们记录了可学习param blob指针及在learnable_params_数组中的id。has_params_lr_和has_params_decay_记录了该参数是否有learning rate multiplier和weight decay multiplier。params_lr_和params_weight_decay_分别记录了相应的值。这两个值之后在训练过程中的参数更新时会到用。
    (b)第二种是共享的param blob。这种param blob与之前的param blob是重名的(称之前已有该名的param blob为owner),因此可通过之前的param_names_index_先查到第一个叫这个名字的param blob(也是该共享param blob的owner)的id(owner_net_param_id),param_owners_对应位置放的就是这个id。然后通过之前param_layer_indices_得到owner的layer id和param id,这样就可以检测owner blob和该共享blob的shape啥的是否一致。最后,和第一种情况时类似,要设 learnable_param_ids_,has_params_lr_, params_lr_, has_params_decay_, params_weight_decay_这些数组的相应元素。只是这种情况下learnable_param_ids_中对应元素为owner param blob的learanable_param_id。对于其它几个成员,如果owner param blob已经有了相应元素,就不覆盖了;如果没有,那就会设置该共享param blob的相应属性。
    下图是一个简单的共享param blob时的例子中各关键数据结构的关系(假定红色param blob共享绿色param blob):
  9. layer_need_backward_数组记录了每一层是否需要参于BP,将之前得到的当前层的need_backward放入其中。如果需要,设置blob_need_backward_中该层的所有top blob为true,这样下一层处理bottom时就会能这个信息往上层传,因为只有上层参与BP了,下层才能参与。

到这里,整个网络基本初始化完毕。初始化过程中涉及到很多数据结构,文字描述略显枯燥。让我们以conv1和pool1层为例,简单列下几个主要数据结构之间的关系。

四、计算网络中的各blob是否需要参与BP。该信息由三个类似的结构记录:bottom_need_backward_,blob_need_backward_和layer_need_backward_。其实bottom_need_backward_和blob_need_backward_中记录的信息类似,都记录层间blob是否需要参与BP,但形式略有不同。前者是二级结构,即用于查询特定layer的特定bottom,它会在后面训练中做后向传播时用于指导是否需要对相应bottom blob做BP;后者为一级结构,只能通过blob_id进行查询。layer_need_backward_可以查询特定layer是否参与BP,它会在后向传播时用于判断是否需要调用特定layer的Backward()函数。每个层是否需要参与BP会在初始化时打印出来,如:

252 I0404 21:39:29.620026 12810 net.cpp:198] loss needs backward computation. 253 I0404 21:39:29.620033 12810 net.cpp:198] ip2 needs backward computation. 254 I0404 21:39:29.620038 12810 net.cpp:198] relu1 needs backward computation. 255 I0404 21:39:29.620040 12810 net.cpp:198] ip1 needs backward computation. 256 I0404 21:39:29.620044 12810 net.cpp:198] pool2 needs backward computation. 257 I0404 21:39:29.620059 12810 net.cpp:198] conv2 needs backward computation. 258 I0404 21:39:29.620062 12810 net.cpp:198] pool1 needs backward computation. 259 I0404 21:39:29.620067 12810 net.cpp:198] conv1 needs backward computation. 260 I0404 21:39:29.620072 12810 net.cpp:200] mnist does not need backward computation. 261 I0404 21:39:29.620075 12810 net.cpp:242] This network produces output loss

在计算这些信息时主要考虑几方面信息:1. param blob是否需要训练,是的话它的各级上层也需要参与BP;2. 是否有设定skip_propagate_down属性,如有设定则意味着该bottom blob下面的层都不需要参与BP;3. 是否对loss function有贡献,如果不对loss产生影响的话那就不必要参与BP了;4. 是否设定全局的force_bakcward属性。计算过程大体分两步:

  1. 第一步遍历网络判断是否需要参与BP。因为一般来说,当上层不需要参与BP,下层就不需要参与BP,这个信息是从上往下传的。因此,这里的循环也是从高层到底层(和第一个大循环不同),对于每一层确定它的两个基本属性:其一是layer_contributes_loss,它代表该层是否对最终的loss有贡献。比如loss layer必须有,另外某层对loss有贡献,则它的bottom blob会被列入blobs_under_loss集合,这些bottom所连接的下一层(相对下一层即为top)也会被认为对loss有贡献。那些没有贡献的自然不需要参与BP。另一个是layer_skip_propagate_down,它代表该层是否不需要参与BP。当某层的top blob被判定为不需要参与BP,则该值为true,否则为false。另外,网络结构描述文件中如果设置了bottom blob的propagate_down属性,其值代表对应bottom blob是否需要参与BP。如果没有指定,那会自动推断。综合以上这些信息,判断各bottom blob是否需要参与BP(记录在bottom_need_backward_中)。如果为否,即而会被加入blobs_skip_backp集合中,用与指导下层忽略BP。layer_need_backward_代表该layer最终是否参与BP,它会根据前面两个属性做出判断。比如当layer不对loss做贡献,或上层不需要参与BP,则本层不需要参与BP。例如LeNet这个例子中,除了输入层其它层都要参与BP,它们都对loss有直接或间接影响,且输入层的上层conv1就有可学习参数。而输入层本身是网络的源头,没有前置输入,做BP没有意义。输入层没有bottom blob,因此它在第一个大循环计算中need_backward为false,该值会插入到layer_need_backward_数组中指示输入层不需要参与BP。
  2. 考虑人为设定的force_backward,如果设置了该属性,则强制把所有层都设成要参与BP,所有layer中的param blob设置param_propagate_down标志。

五、确定output blob,经过上面处理后所有剩余在available_blobs中的都为output blob,指针和index分别存入net_output_blobs_和net_output_blob_indeces_。接下去,设置blob_names_index_和layer_names_index_。它们用与根据blob和layer名称查找对应id。
六、调用ShareWeights()函数处理共享的param blob。之前提到已经通过数据结构记录这些信息,现在调用Blob的ShareData()和ShareDiff()函数让它们底层的数据指向同一个,实现事实上的数据共享。

训练

训练过程所需的参数也是通过protobuf文件来配置的(如上例中的lenet_solver.prototxt)。该文件各字段详细含义可以参见官方说明。训练过程主要实现在Solver::Solve()函数中。我们先来鸟瞰下它的大体流程:

主要工作流程如下:
一、如果提供Snapshot文件则先通过Restore()函数恢复。
二、调用Step()函数进行训练,参数为需要迭代的次数。该函数为训练的主循环。循环的index从iter_到iter_+iters。循环次数由参数iters指定。

  1. 进入循环后,先调用Net的ClearParamDiffs()函数初始化参数的梯度信息。
  2. 如果满足条件(test_internal指定间隔),调用TestAll()函数在测试网络上基于测试集做前身传播。接下来根据requested_early_exit_标志判断测试过程中是否收到中断请求,如有则退出循环。
  3. 调用on_start()回调函数,和下面的on_gradients_ready()类似,主要用于多卡训练,和我们穷人关系不大,这里不展开。
  4. 通过Net的ForwardBackward()函数做iter_size次前向和后向传播。这里就是深度神经网络训练的主体,前向时计算loss,后向时更新梯度。最后记录平均loss,并用UpdateSmoothedLoss()函数做平滑。
  5. 如果指定了display属性,这时会输出一坨信息。如迭代次数和loss等,便于我们就可以在训练过程监视学习情况。
  6. 通过ApplyUpdate()函数应用更新。ApplyUpdate()函数是Solver的纯虚函数,实现在SGDSolver类中:
    (a)通过GetLearningRate()函数根据Solver配置文件中指定的策略(fixed, step, exp, inv, multistep, poly, sigmoid)得到learning rate。learning rate决定了参数更新的速度,它很大程度上会影响训练的收敛性。
    (b)通过ClipGradients()函数来clip梯度,由于在深度神经网络中会有梯度爆炸的问题。比如在RNN中会有同一结点参数反复相乘,导致梯度非常大,参数更新跳变巨大导致无法收敛。比较有效的方法就是对其进行clip。具体方法是计算所有可学习参数的梯度的L2范数,当其大与clip_gradients时,对其进行scale(scale因子为参数clip_gradients除以可学习参数的梯度的L2范数)。
    (c)对于每个可学习参数,分别调用Normalize(),Regularize()和ComputeUpdateValue()三个函数。Normalize()函数对可学习参数在iter_size(Step()函数中每轮循环做前后向传播的次数)间做normalization;Regularize()函数考虑weight decay(权值衰减,防止overfit),其值由Solver的weight_decay属性和Net的params_weight_decay属性指定(最终为两者的乘积),类型由regularization_type指定,常用的有L1和L2正则项,L1可以帮助产生稀疏权重。ComputeUpdateValue()函数在前面计算出的基本梯度上考虑momentum(通过使前面几次的梯度也参与计算来加速学习和抑制震荡,尤其是在高曲率,梯度小而一致的情况,或是梯度存在噪声的情况下)等信息。不同的优化方法有不同的使用策略,因此可以看到在像NesterovSolver和AdamSolver等Solver类中都有其自己的实现。
    (d)调用Net的Update()函数应用上面计算出的修正后梯度进行网络中参数的更新。该函数调用所有可学习参数对应blob的Update()函数,最终用caffe_axpy(CPU)和caffe_gpu_axpy(GPU)完成参数的更新(即从data_中减去diff_,相当与让参数沿着梯度的负方向前进,也就是让loss function减小的方向)。
  7. 通过之前注册的函数检测signal。如果需要(迭代次数为参数snapshot整数倍,或者接收到产生Snapshot的signal),通过Snapshot()函数产生Snapshot文件。如果接收到停止的signal,则标记requested_early_exit_,下一轮迭代中就会退出。

三、如果指定snapshot_after_train属性,则此时产生Snapshot。接下来,如果之前接收到SIGINT(如Ctrl+C),则退出训练主循环。
四、如果满足条件(满足display属性指定的显示信息输出间隔),对于训练网络做一次前身传播,得到其loss,通过UpdateSmoothedLoss()函数对其做平滑(类似于TensorFlow中的ExponentialMovingAverage,减小基于SGD优化方法带来的variance)后输出。
五、如果满足条件(满足test_internal属性指定的间隔),对测试网络做前身传播。输出测试网络在测试集上的accuracy以及loss。

小结

Caffe作为经典的深度学习框架,今天看来仍是我们学习深度学习具体实现的良好读物。本文以最简单的LeNet训练MNIST数据集为引子小撸了下大体框架和流程。当然,这只是开篇,Caffe中还有许多的细节,如各种层的高效实现都值得细究,这些我们留待以后慢慢细读。

Be First to Comment

发表评论

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