如何用C++编写不到200行的神经网络
目前已经有许多现成的深度学习框架,为什么我们还要用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 }