目前已經有許多現成的深度學習框架,為什么我們還要用C++來編寫一個神經網絡?一個理由是我們需要了解學習框架內部的運行原理,當分析問題的時候能夠很快的定位原因;另一個理由是,我們需要為專有設備編寫一個推理引擎,它可能運行在手機端,或者移動設備上。這篇文章實現了一個最簡單的神經網絡框架,適合大家入門學習。
參考代碼鏈接:https://github.com/webbery/MiniEngine/tree/demo
一個最簡單的神經網絡包含輸入節點、一個線性層、一個激活函數和一個損失函數。為了減少編碼,我們采用Eigen代替numpy,來實現相關的矩陣操作。
首先定義一個Node節點類作為基類,它包含一個輸入節點的隊列和一個輸出節點的隊列;以及當前節點的值,與它連接的反向傳播的梯度值,前向傳播接口和反向傳播接口。另外為了調試方便,我們給Node增加了一個name成員變量,來標識數據對應到哪個節點。
1 class Node { 2 public: 3 virtual void forward() = 0; 4 virtual void backward() = 0; 5 protected: 6 Eigen::MatrixXf _value; 7 std::vector<Node*> _inputs; 8 std::vector<Node*> _outputs; 9 std::map<Node*, Eigen::MatrixXf> _gradients; 10 std::string _name; 11 };
輸入節點Input繼承Node類,代表輸入變量,這些變量將被數據賦值。對應於tensorflow的Variable類。這里我們在構造函數里初始化了它的大小row和col
1 class Input : public Node { 2 public: 3 Input(const char* name,size_t rows=0,size_t cols=0); 4 };
Linear節點,代表全連接層,前向傳播接口的實現方式為WX+bias,其中bias跟WX計算出來的矩陣形式是不相同的,需要對bias做一個廣播操作;反向傳播需要計算輸出節點對應的W、X、bias梯度值
1 class Linear : public Node { 2 public: 3 Linear(Node* nodes, Node* weights, Node* bias); 4 5 virtual void forward(){_value = (_nodes->getValue() * _weights->getValue()).rowwise() + Eigen::VectorXf(_bias->getValue()).transpose();} 6 7 virtual void backward(){ 8 for (auto node : _outputs){ 9 auto grad = node->getGradient(this); 10 _gradients[_weights] = _nodes->getValue().transpose() * grad; 11 _gradients[_bias] = grad.colwise().sum().transpose(); 12 _gradients[_nodes] = grad * _weights->getValue().transpose(); 13 } 14 } 15 16 private: 17 Node* _nodes = nullptr; 18 Node* _weights = nullptr; 19 Node* _bias = nullptr; 20 };
Sigmoid節點,代表激活函數,前向傳播計算sigmoid函數結果,反向傳播計算sigmoid導函數
1 class Sigmoid : public Node { 2 public: 3 Sigmoid(Node* node); 4 virtual void forward(){_value = _impl(_node->getValue());} 5 virtual void backward(){ 6 auto y = _value; 7 auto y2 = y.cwiseProduct(y); 8 _partial = y-y2; 9 10 for (auto node : _outputs) { 11 auto grad = node->getGradient(this); 12 _gradients[_node] = grad.cwiseProduct(_partial); 13 } 14 } 15 private: 16 Eigen::MatrixXf _impl(const Eigen::MatrixXf& x){return (-x.array().exp() + 1).inverse();} 17 private: 18 Node* _node = nullptr; 19 //sigmoid的偏導 20 Eigen::MatrixXf _partial; 21 };
MSE節點,代表Loss function,前向傳播計算估計值與真實值的方差,反向傳播需要計算方差的一階導數結果
1 class MSE : public Node { 2 public: 3 MSE(Node* y, Node* y_hat); 4 virtual void forward(){ 5 _diff = _y->getValue() - _y_hat->getValue(); 6 auto diff2= _diff.cwiseProduct(_diff); 7 auto v = Eigen::MatrixXf(1, 1); 8 v << (diff2).mean(); 9 _value = v; 10 } 11 virtual void backward(){ 12 auto r = _y_hat->getValue().rows(); 13 _gradients[_y] = _diff * (2.f / r); 14 _gradients[_y_hat] = _diff * (-2.f / r); 15 } 16 private: 17 Node* _y; 18 Node* _y_hat; 19 Eigen::MatrixXf _diff; 20 };
這幾個節點是我們將要構建的框架的基本元素。然后我們還需要實現一個圖的拓撲排序,對排序后的節點進行前向迭代,計算預測結果;然后再反向迭代,計算每個連接的梯度。
1 std::vector<Node*> topological_sort(Node* input_nodes){ 2 //根據傳入的數據初始化圖結構 3 Node* pNode = nullptr; 4 //pair第一個為輸入,第二個為輸出 5 std::map < Node*, std::pair<std::set<Node*>, std::set<Node*> > > g; 6 //待遍歷的周圍節點 7 std::list<Node*> vNodes; 8 vNodes.emplace_back(input_nodes); 9 //廣度遍歷,先遍歷輸出節點,再遍歷輸入節點 10 //已經遍歷過的節點 11 std::set<Node*> sVisited; 12 while (vNodes.size() && (pNode = vNodes.front())) { 13 if (sVisited.find(pNode) != sVisited.end()) vNodes.pop_front(); 14 const auto& outputs = pNode->getOutputs(); 15 for (auto item: outputs){ 16 g[pNode].second.insert(item); //添加item為pnode的輸出節點 17 g[item].first.insert(pNode); //添加pnode為item的輸入節點 18 if(sVisited.find(item)==sVisited.end()) vNodes.emplace_back(item); //把沒有訪問過的節點添加到待訪問隊列中 19 } 20 const auto& inputs = pNode->getInputs(); 21 for (auto item: inputs){ 22 g[pNode].first.insert(item); //添加item為pnode的輸入節點 23 g[item].second.insert(pNode); //添加pnode為item的輸出節點 24 if (sVisited.find(item) == sVisited.end()) vNodes.emplace_back(item); 25 } 26 sVisited.emplace(pNode); 27 vNodes.pop_front(); 28 } 29 30 //根據圖結構進行拓撲排序 31 std::vector<Node*> vSorted; 32 while (g.size()) { 33 for (auto itr=g.begin();itr!=g.end();++itr) 34 { 35 //沒有輸入節點 36 auto& f = g[itr->first]; 37 if (f.first.size() == 0) { 38 vSorted.push_back(itr->first); 39 //找到圖中這個節點的輸出節點,然后將輸出節點對應的這個父節點移除 40 auto outputs = f.second;//f['out'] 41 for (auto& output: outputs) g[output].first.erase(itr->first); 42 //然后將這個節點從圖中移除 43 g.erase(itr->first); 44 break; 45 } 46 } 47 } 48 return vSorted; 49 }
測試程序中,我們定義了每個節點,並構造了節點之間的連接關系;之后把輸入節點傳給了topological_sort。該函數從輸入節點開始,進行廣度優先遍歷,構建一個圖結構;然后根據拓撲排序算法,將這個圖結構排序成一個隊列返回;這個隊列在tensorflow里被稱為“圖”。
然后,測試程序運行train_one_batch前向遍歷一次得到預測值,然后再反向遍歷一次,得到每個節點連接的梯度變化;
1 void train_one_batch(std::vector<Node*>& graph){ 2 for (auto node:graph){ 3 node->forward(); 4 } 5 for (int idx = graph.size() - 1; idx >= 0;--idx) { 6 graph[idx]->backward(); 7 } 8 }
接着根據計算出來的梯度值,更新權重節點W,b,完成一次訓練
1 void sgd_update(std::vector<Node*> update_nodes, float learning_rate){ 2 for (auto node: update_nodes){ 3 Eigen::MatrixXf delta = -1 * learning_rate * node->getGradient(node); 4 node->setValue(node->getValue() + delta); 5 } 6 }