Caffe訓練源碼基本流程

Caffe簡介

一般在介紹Caffe代碼結構的時候,大家都會說Caffe主要由Blob Layer Net 和 Solver這幾個部分組成。

Blob:::
主要用來表示網絡中的數據,包括訓練數據,網絡各層自身的參數(包括權值、偏置以及它們的梯度),網絡之間傳遞的數據都是通過 Blob 來實現的,同時 Blob 數據也支持在 CPU 與 GPU 上存儲,能夠在兩者之間做同步。

Layer:::
是對神經網絡中各種層的一個抽象,包括我們熟知的卷積層和下采樣層,還有全連接層和各種激活函數層等等。同時每種 Layer 都實現了前向傳播和反向傳播,並通過 Blob 來傳遞數據。

Net::: 是對整個網絡的表示,由各種 Layer 前後連接組合而成,也是我們所構建的網絡模型。

Solver :::定義了針對 Net 網絡模型的求解方法,記錄網絡的訓練過程,保存網絡模型參數,中斷並恢復網絡的訓練過程。自定義 Solver 能夠實現不同的網絡求解方式。

不過在剛開始準備閱讀Caffe代碼的時候,就算知道了代碼是由上面四部分組成還是感覺會無從下手,下面我們準備通過一個Caffe訓練LeNet的實例並結合代碼來解釋Caffe是如何初始化網絡,然後正向傳播、反向傳播開始訓練,最終得到訓練好的模型這一過程。

訓練LeNet

在Caffe提供的例子裏,訓練LeNet網絡的命令爲:

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

其中第一個參數build/tools/caffe是Caffe框架的主要框架,由tools/caffe.cpp文件編譯而來,第二個參數train表示是要訓練網絡,第三個參數是 solver的protobuf描述文件。在Caffe中,網絡模型的描述及其求解都是通過 protobuf 定義的,並不需要通過敲代碼來實現。同時,模型的參數也是通過 protobuf 實現加載和存儲,包括 CPU 與 GPU 之間的無縫切換,都是通過配置來實現的,不需要通過硬編碼的方式實現,有關
protobuf的具體內容可參考這篇博文:http://alanse7en.github.io/caffedai-ma-jie-xi-2/

網絡初始化

下面我們從caffe.cpp的main函數入口開始觀察Caffe是怎麼一步一步訓練網絡的。在caffe.cpp中main函數之外通過RegisterBrewFunction這個宏在每一個實現主要功能的函數之後將這個函數的名字和其對應的函數指針添加到了g_brew_map中,具體分別爲train(),test(),device_query(),time()這四個函數。

在運行的時候,根據傳入的參數在main函數中,通過GetBrewFunction得到了我們需要調用的那個函數的函數指針,並完成了調用。

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

在我們上面所說的訓練LeNet的例子中,傳入的第二個參數爲train,所以調用的函數爲caffe.cpp中的int train()函數,接下來主要看這個函數的內容。在train函數中有下面兩行代碼,下面的代碼定義了一個指向Solver的shared_ptr。其中主要是通過調用SolverRegistry這個類的靜態成員函數CreateSolver得到一個指向Solver的指針來構造shared_ptr類型的solver。而且由於C++多態的特性,儘管solver是一個指向基類Solver類型的指針,通過solver這個智能指針來調用各個成員函數會調用到各個子類(SGDSolver等)的函數。

// caffe.cpp
// 其中輸入參數solver_param就是上面所說的第三個參數:網絡的模型及求解文件
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, const Solver* root_solver)
    : net_(), callbacks_(), root_solver_(root_solver),requested_early_exit_(false)
{
  Init(param);
}

而在Init(paran)函數中,又主要是通過InitTrainNet()和InitTestNets()函數分別來搭建訓練網絡結構和測試網絡結構。

訓練網絡只能有一個,在InitTrainNet()函數中首先會設置一些基本參數,包括設置網絡的狀態爲TRAIN,確定訓練網絡只有一個等,然會會通過下面這條語句新建了一個Net對象。InitTestNets()函數和InitTrainNet()函數基本類似,不再贅述。

//solver.cpp
net_.reset(new Net<Dtype>(net_param));

上面語句新建了Net對象之後會調用Net類的構造函數,如下所示。可以看出構造函數是通過Init(param)函數來初始化網絡結構的。

//net.cpp
template <typename Dtype>
Net<Dtype>::Net(const NetParameter& param, const Net* root_net)
    : root_net_(root_net) {
  Init(param);
}

下面是net.cpp文件裏Init()函數的主要內容(忽略具體細節),其中LayerRegistry::CreateLayer(layer_param)主要是通過調用LayerRegistry這個類的靜態成員函數CreateLayer得到一個指向Layer類的shared_ptr類型指針。並把每一層的指針存放在vector

//net.cpp Init()
for (int layer_id = 0; layer_id < param.layer_size(); ++layer_id) {//param是網絡參數,layer_size()返回網絡擁有的層數
    const LayerParameter& layer_param = param.layer(layer_id);//獲取當前layer的參數
    layers_.push_back(LayerRegistry<Dtype>::CreateLayer(layer_param));//根據參數實例化layer


//下面的兩個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_。
   const int num_param_blobs = layers_[layer_id]->blobs().size();
   for (int param_id = 0; param_id < num_param_blobs; ++param_id) {
       AppendParam(param, layer_id, param_id);
       //AppendParam負責具體的dirtywork
    }


    }

經過上面的過程,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,Net,Layer,Blob類的構造函數,完成整個網絡的初始化。

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

完成初始化之後,就可以開始對網絡經行訓練了,開始訓練的代碼如下所示,指向Solver類的指針solver開始調用Solver類的成員函數Solve(),名稱比較繞啊。

// 開始優化
solver->Solve();

接下來我們來看看Solver類的成員函數Solve(),Solve函數其實主要就是調用了Solver的另一個成員函數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()函數的主要代碼,首先是一個大循環設置了總的迭代次數,在每次迭代中訓練iter_size x batch_size個樣本,這個設置是爲了在GPU的顯存不夠的時候使用,比如我本來想把batch_size設置爲128,iter_size是默認爲1的,但是會out_of_memory,藉助這個方法,可以設置batch_size=32,iter_size=4,那實際上每次迭代還是處理了128個數據。

//solver.cpp
template <typename Dtype>
void Solver<Dtype>::Step(int iters) {
  ...
  //迭代
  while (iter_ < stop_iter) {
    ...
    // iter_size也是在solver.prototxt裏設置,實際上的batch_size=iter_size*網絡定義裏的batch_size,
    // 因此每一次迭代的loss是iter_size次迭代的和,再除以iter_size,這個loss是通過調用`Net::ForwardBackward`函數得到的
    // accumulate gradients over `iter_size` x `batch_size` instances
    for (int i = 0; i < param_.iter_size(); ++i) {
    /*
     * 調用了Net中的代碼,主要完成了前向後向的計算,
     * 前向用於計算模型的最終輸出和Loss,後向用於
     * 計算每一層網絡和參數的梯度。
     */
      loss += net_->ForwardBackward();
    }

    ...

    /*
     * 這個函數主要做Loss的平滑。由於Caffe的訓練方式是SGD,我們無法把所有的數據同時
     * 放入模型進行訓練,那麼部分數據產生的Loss就可能會和全樣本的平均Loss不同,在必要
     * 時候將Loss和歷史過程中更新的Loss求平均就可以減少Loss的震盪問題。
     */
    UpdateSmoothedLoss(loss, start_iter, average_loss);


    ...
    // 執行梯度的更新,這個函數在基類`Solver`中沒有實現,會調用每個子類自己的實現
    //,後面具體分析`SGDSolver`的實現
    ApplyUpdate();

    // 迭代次數加1
    ++iter_;
    ...

  }
}

上面Step()函數主要分爲三部分:

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); }
}

UpdateSmoothedLoss();

這個函數主要做Loss的平滑。由於Caffe的訓練方式是SGD,我們無法把所有的數據同時放入模型進行訓練,那麼部分數據產生的Loss就可能會和全樣本的平均Loss不同,在必要時候將Loss和歷史過程中更新的Loss求平均就可以減少Loss的震盪問題

ApplyUpdate();

這個函數是Solver類的純虛函數,需要派生類來實現,比如SGDSolver類實現的ApplyUpdate();函數如下,主要內容包括:設置參數的學習率;對梯度進行Normalize;對反向求導得到的梯度添加正則項的梯度;最後根據SGD算法計算最終的梯度;最後的最後把計算得到的最終梯度對權值進行更新。

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進行網絡訓練時網絡初始化以及前向傳播、反向傳播、梯度更新的過程,其中省略了大量的細節。上面還有很多東西都沒提到,比如說Caffe中Layer派生類的註冊及各個具體層前向反向的實現、Solver派生類的註冊、網絡結構的讀取、模型的保存等等大量內容。

轉自:::http://buptldy.github.io/2016/10/09/2016-10-09-Caffe_Code/

發佈了57 篇原創文章 · 獲贊 28 · 訪問量 24萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章