caffe 训练过程源码层理解

一、前言

       本文主要部分转载自 https://buptldy.github.io/2016/10/09/2016-10-09-Caffe_Code/,并加入一些自己的见解

      本篇主要重点分析 caffe训练过程相关的主要代码。

二、caffe训练main函数入口

      这里以训练lenet模型(入门必备)为例, 训练 lenet 网络模型的基本命令为 ./build/tools/caffe  train  --solver=/opt/caffe-master/examples/mnist/lenet_solver.prototxt , main函数入口在 caffe-master/tools/caffe.cpp 。在main函数中,首先调用了

        //caffe.cpp
        return GetBrewFunction(caffe::string(argv[1]))();

       在函数GetBrewFunction中,调用了

       //caffe.cpp
        return g_brew_map[name];

        在main函数之外RegisterBrewFunction这个宏在每一个实现主要功能的函数之后将这个函数的名字和其对应的函数指针添加到了g_brew_map中,具体分别为train(),test(),device_query(),time()这四个函数。故这里g_brew_map的key值为 argv[1],也即是 “train”,则实际调用了

       //caffe.cpp
        int train() 

三、Solver对象初始化

       进入train() 函数,主要使用Solver类完成整个训练过程。Solver类中包含Net类对象,而Net类对象又包含了Layers类对象和Blob类对象

       首先需要从argv[2] 中的lenet_solver.prototxt 读取训练模型参数,以下2句初始化了读取参数的 SolverParameter对象 solver_param 

         //caffe.cpp
        caffe::SolverParameter solver_param;
        caffe::ReadSolverParamsFromTextFileOrDie(FLAGS_solver, &solver_param);

        接着,初始化了一个指向Solver<float>类型的shared_ptr智能指针solver ,通过调用SolverRegistry这个类的静态成员函数CreateSolver构造solver指针所指向的类对象空间。尽管solver是一个指向基类Solver类型对象的指针,但由于C++多态的特性,solver这个智能指针调用各个成员函数时会调用到各个子类(SGDSolver等)的函数。       

        //caffe.cpp
        shared_ptr<caffe::Solver<float> >
              solver(caffe::SolverRegistry<float>::CreateSolver(solver_param)); 

       因为在caffe.proto文件中默认的优化type为SGD,所以上面的代码会实例化一个SGDSolver的对象,SGDSolver 类继承于Solver类,在新建SGDSolver对象时会调用其构造函数如下所示:

         //sgd_solvers.hpp
         explicit SGDSolver(const SolverParameter& param)
              : Solver<Dtype>(param) { PreSolve(); }
       从上面代码可以看出,会先调用父类Solver的构造函数,如下所示。Solver类的构造函数通过Init(param)函数来初始化网络。

        //solver.cpp
        template <typename Dtype>
        Solver<Dtype>::Solver(const SolverParameter& param)
          : net_(), callbacks_(), requested_early_exit_(false) {
               Init(param);
        }
       而在Init(paran)函数中,又主要是通过InitTrainNet()和InitTestNets()函数分别来搭建训练网络结构和测试网络结构。  

       //solver.cpp
       InitTrainNet();  //即初始化Solver类的Net类型的成员变量net_
       InitTestNets(); //初始化测试网络

      这里重点关注InitTrainNet()函数所做的事,在InitTrainNet()函数中首先会设置一些基本参数,包括设置网络的状态为TRAIN,读取lenet_train_text.prototxt,保存至net_param 、确定训练网络只有一个等,然后通过下面这条语句新建了一个Net对象。

       //solver.cpp
       net_.reset(new Net<Dtype>(net_param));
      上面语句新建了Net对象之后会调用Net类的构造函数,如下所示。可以看出构造函数是通过Init(param)函数来初始化网络结构的。  

      //net.cpp
       template <typename Dtype>
       Net<Dtype>::Net(const NetParameter& param) {
            Init(param);
      }
      Init(param)函数首先是对bottom_vecs_、top_vecs_等成员变量开空间,之后从以下语句开始逐步初始化net对象的各个层       

      //net.cpp
      for (int layer_id = 0; layer_id < param.layer_size(); ++layer_id) {   //遍历着初始化每个层
     以下2句开始读取第 layer_id个层的参数,并初始化 layers_ [ layer_id ]。LayerRegistry<Dtype>::CreateLayer(layer_param)主要通过调用LayerRegistry这个类的静态成员函数CreateLayer得到一个指向Layer类的shared_ptr类型指针。并把指针存放在vector<shared_ptr<Layer<Dtype> > > layers_这个指针容器里。这里相当于根据每层的参数layer_param实例化了对应的各个子类层,比如conv_layer(卷积层)和pooling_layer(池化层)。实例化了各层就会调用每个层的构造函数,但每层的构造函数都没有做什么大的设置

       //net.cpp
       const LayerParameter& layer_param = param.layer(layer_id);
       ....
       layers_.push_back(LayerRegistry<Dtype>::CreateLayer(layer_param));
  接下来在Init()函数中主要由四部分组成:

<1> AppendBottom:设置每一层的输入数据

<2> AppendTop:设置每一层的输出数据

<3> layers_[layer_id]->SetUp:对上面设置的输入输出数据计算分配空间,并设置每层的可学习参数(权值和偏置),下面会详细降到这个函数

<4> AppendParam:对上面申请的可学习参数进行设置,主要包括学习率和正则率等  

       //net.cpp
       /*下面的两个for循环将此layer的bottom blob的指针和top blob的指针放入bottom_vecs_和top_vecs_,
       bottom blob和top blob的实例全都存放在blobs_中。相邻的两层,前一层的top blob是后一层的bottom blob,
       所以blobs_的同一个blob既可能是bottom blob,也可能使top blob。*/
       for (int bottom_id = 0; bottom_id < layer_param.bottom_size();  ++bottom_id) {
               const int blob_id = AppendBottom(param, layer_id, bottom_id,
                                       &available_blobs, &blob_name_to_idx);
        }
       .....
       for (int top_id = 0; top_id < num_top; ++top_id) {
            AppendTop(param, layer_id, top_id, &available_blobs, &blob_name_to_idx);
       }
        .....
       // 调用layer类的Setup函数进行初始化,输入参数:每个layer的输入blobs以及输出blobs,为每个blob设置大小
       layers_[layer_id]->SetUp(bottom_vecs_[layer_id], top_vecs_[layer_id]); 
       .....
       //接下来的工作是将每层的parameter的指针塞进params_,尤其是learnable_params_。
       for (int param_id = 0; param_id < num_param_blobs; ++param_id) {
            AppendParam(param, layer_id, param_id);
       }
      经过上面的过程,Net类的初始化工作基本就完成了,接着我们具体来看看上面所说的layers_[layer_id]->SetUp对每一具体的层结构进行设置,我们来看看Layer类的Setup()函数,对每一层的设置主要由下面三个函数组成:
LayerSetUp(bottom, top):由Layer类派生出的特定类都需要重写这个函数,主要功能是设置权值参数(包括偏置)的空间以及对权值参数经行随机初始化。
Reshape(bottom, top):根据输出blob和权值参数计算输出blob的维数,并申请空间。

      //layer.hpp
      // layer 初始化设置
      void SetUp(const vector<Blob<Dtype>*>& bottom,   
              const vector<Blob<Dtype>*>& top) {
           InitMutex();
           CheckBlobCounts(bottom, top);
           LayerSetUp(bottom, top);
           Reshape(bottom, top);
           SetLossWeights(top);
      }
      经过上述过程基本上就完成了初始化的工作,总体的流程大概就是新建一个Solver对象,然后调用Solver类的构造函数,然后在Solver的构造函数中又会新建Net类实例,在Net类的构造函数中又会新建各个Layer的实例,一直具体到设置每个Blob,当然里面还有很多具体的细节。

四、Solver对象迭代训练模型过程

     如第三节所示,调用完以下语句后,solver这一智能指针所指的Solver类型实例就被初始化完成,也即完成了整个网络的初始化。

          //caffe.cpp
         shared_ptr<caffe::Solver<float> >
              solver(caffe::SolverRegistry<float>::CreateSolver(solver_param)); 
      重新看回caffe.cpp,再执行完上面语句后,solver执行以下函数进行模型的优化

        //caffe.cpp 
        solver->Solve();   //默认参数为NULL
      进入Solve()函数如下,发现主要是调用了Step函数完成迭代    

        //solver.cpp
        template <typename Dtype>
        void Solver<Dtype>::Solve(const char* resume_file) {
              ......
              int start_iter = iter_;
              ......
              // 然后调用了'Step'函数,这个函数执行了实际的逐步的迭代过程
              Step(param_.max_iter() - iter_);
              ......
              LOG(INFO) << "Optimization Done.";
        }

       Step()的参数中param.max_iter()为lenet_solver.prototxt中的max_iter ,iter_ 在初始化Solver时被初始化为0。进入Step函数,在尽心迭代前先进行初始化,如下:

       //solver.cpp
       void Solver<Dtype>::Step(int iters) {
              const int start_iter = iter_;    //start_iter仅被用于while循环里的UpdateSmoothedLoss
              const int stop_iter = iter_ + iters;  //迭代的总次数,这里就跟solver.prototxt中的max_iter一致
              int average_loss = this->param_.average_loss();   //初始的平均损失值,为1
              losses_.clear();        //保存loss的vector容器,空间先清0
              smoothed_loss_ = 0;  //smooth后的loss,清0

              while (iter_ < stop_iter) {    //开始迭代
                   ......
               }
       }      
        初始化相关参数后,进入while循环,while循环大致可分为四部分。

        <1> 首先将网络的权值梯度清0,之后判断 iter_ %param_.test_interval()是否为0 ,然后调用一次TestAll(),实际上也即是 solver.prototxt上 每隔test_interval 次训练迭代就开始 test_iter次测试。测试验证集只涉及前向传播;

        <2> 接下来则是进行第 iter_ 次的训练迭代,本来直接读取batch_size张图片作前向和反向即可,但为了避免一次大的batch_size导致显存不足,代码中将     iter_size × batch_size作为实际的batch_size, 例如在训练网络时一个批次图片数量为128,可以在 train_text.prototxt 中设置128,但在solver.prototxt中设置iter_size为1(或者干脆不设置,则默认设为1);也可以在 train_text.prototxt 中设置了32,但在solver.prototxt中设置iter_size为 4 ,只要两者相乘为128 即可。最后把iter_size次计算得的loss作 loss/iter_size 作为一次前向的loss值。

        <3> 对loss作平滑,由于Caffe的训练方式是SGD,我们无法把所有的数据同时放入模型进行训练,那么部分数据产生的Loss就可能会和全样本的平均Loss不同,在必要时候将 loss 和历史过程中更新的 loss 求平均就可以减少Loss的震荡问题。

        <4> 最后调用了 ApplyUpdate() 函数, 执行梯度的更新,这个函数在基类 Solver 中没有实现,会调用每个子类自己的实现。

        相关代码如下:    

          //solver.cpp
          ......
          while (iter_ < stop_iter) {    //开始迭代

               net_->ClearParamDiffs();   // 将权值梯度清0
               if (param_.test_interval() && iter_ % param_.test_interval() == 0  // iter_为0,或test_interval的整数倍
                    && (iter_ > 0 || param_.test_initialization())) {
                    if (Caffe::root_solver()) {
                        TestAll();  // 函数内进行了 test_iter次测试
                   }
                  ......
                }
            ......
            Dtype loss = 0;
            for (int i = 0; i < param_.iter_size(); ++i) {   //作iter_size次前向和反向传播
                  loss += net_->ForwardBackward();
            }
            loss /= param_.iter_size();         //对loss取平均作前向的loss      
              ......
            UpdateSmoothedLoss(loss, start_iter, average_loss);   //平滑loss
            ......
            ApplyUpdate();    //使用反向传播计算得的梯度对网络模型的权值作更新
             ++iter_;    //迭代次数加1
            ......
       }
       在while循环中,这里重点关注第<2>、<4>部分。 
       <2> loss += net_->ForwardBackward();
       这行代码通过Net类的net_指针调用其成员函数ForwardBackward(),其代码如下所示,分别调用了成员函数Forward(&loss)和成员函数Backward()来进行前向传播和反向传播。

       // net.hpp
       // 进行一次正向传播,一次反向传播
       Dtype ForwardBackward() {
            Dtype loss;
            Forward(&loss);
            Backward();
            return loss;
        }
        前面的Forward(&loss)函数最终会执行到下面一段代码,Net类的Forward()函数会对网络中的每一层执行Layer类的成员函数Forward(),而具体的每一层Layer的派生类会重写Forward()函数来实现不同层的前向计算功能。上面的Backward()反向求导函数也和Forward()类似,调用不同层的Backward()函数来计算每层的梯度。  

        //net.cpp
       for (int i = start; i <= end; ++i) {
        // 对每一层进行前向计算,返回每层的loss,其实只有最后一层loss不为0
             Dtype layer_loss = layers_[i]->Forward(bottom_vecs_[i], top_vecs_[i]);
             loss += layer_loss;
            if (debug_info_) { ForwardDebugInfo(i); }
        }
        <4> ApplyUpdate();
       这个函数是Solver类的纯虚函数,需要派生类来实现,比如SGDSolver类实现的ApplyUpdate();函数如下,主要内容包括:设置参数的学习率;对梯度进行Normalize;对反向求导得到的梯度添加正则项的梯度;最后根据SGD算法计算最终的梯度;最后的最后把计算得到的最终梯度对权值进行更新。相关代码如下:     

         //sgd_solver.cpp
         template <typename Dtype>
         void SGDSolver<Dtype>::ApplyUpdate() {
             CHECK(Caffe::root_solver());

              // GetLearningRate根据设置的lr_policy来计算当前迭代的learning rate的值
             Dtype rate = GetLearningRate();

             // 判断是否需要输出当前的learning rate
            if (this->param_.display() && this->iter_ % this->param_.display() == 0) {
                  LOG(INFO) << "Iteration " << this->iter_ << ", lr = " << rate;
             }

             // 避免梯度爆炸,如果梯度的二范数超过了某个数值则进行scale操作,将梯度减小
             ClipGradients();

            // 对所有可更新的网络参数进行操作
            for (int param_id = 0; param_id < this->net_->learnable_params().size();
                    ++param_id) {
	          // 将第param_id个参数的梯度除以iter_size,
	          // 这一步的作用是保证实际的batch_size=iter_size*设置的batch_size
                  Normalize(param_id);

                  // 将正则化部分的梯度降入到每个参数的梯度中
                  Regularize(param_id);

                  // 计算SGD算法的梯度(momentum等)
                  ComputeUpdateValue(param_id, rate);
            }
         // 调用`Net::Update`更新所有的参数
            this->net_->Update();
       }
      以上则为caffe训练 lenet 模型权值的大致流程,当然具体各层的前向和反向计算、梯度计算和更新等等内容还需作更深入的分析。


猜你喜欢

转载自blog.csdn.net/AP1005834/article/details/78144990