相關源碼可參考最新的實現:https://github.com/ronnyyoung/EasyML ,中的neural_network模塊,后持續更新,包括加入CNN的結構。
一、引言
在前一篇關於神經網絡的文章中,給出了神經網絡中單個神經元的結構和作用原理,並且用梯度下降的方法推導了單個SIMGOID單元的權值更新法則。在文章的最后給了一個例子,我們以一個4維的單位向量作為特征,映射到一維的[0,1]的空間中,我們采用了一個感知器單元,實驗結果發現經過15000次(實際應該在5000次左右已經收斂了)的訓練后,對於給出的特征向量,感知器單元總是能夠得到很接近我們預期的結果了。然而在實際應用過程中,單個神經元不能擬合太復雜的映射關系,我們需要構建更復雜的網絡來逼近那些更復雜的目標函數,本文的最后,我們會用多層網絡處理前一篇文章中的例子,經過300-500次的訓練,就可以很好的收斂。
本篇文章為神經網絡這個主題的第二篇文章,主要介紹多層網絡的結構及用反向傳播算法對權值進行更新,最后我們會一步一步用C++對整個結構進行實現。
二、多層網絡結構
多層網絡,顧名思義就是由多個層結構組成的網絡系統,它的每一層都是由若干個神經元結點構成,該層的任意一個結點都與上一層的每一個結點相聯,由它們來提供輸入,經過計算產生該結點的輸出並作為下一層結點的輸入。
值得注意的是任何多層的網絡結構必須有一輸入層、一個輸出層。下面的圖結構是更形象的表示:
我們從圖像中來再次說明多層網絡的結構:上圖是一個3層的網絡結構,它由一個輸入層、一個輸出層和一個隱藏層構成,當然隱藏層的層數可以更多。圖像隱藏層的結點$i$與輸入層的每一個結點相連,也就是說它接收了一組向量$input=[x_1,x_2,x_3,\cdots,x_n]$作為輸入,同時與它相連的n條線代表了n個輸入的權值。特別要注意的是圖像隱藏層結點與輸出結點還有一個紅色的連線,它們代表了偏置,即$w_0$。
那么結合上篇文章的內容,我們知道圖像的i結點,它將輸入與對應的權值進行線性加權求和,然后經過$sigmoid函數$計算,把得到的結果作為該個結點的輸出。
$$net_i=w_0+x_1w_1+x_2w_2+x_3w_3+x_nw_n=\sum_{i=0}^{N=n}x_iw_i.(其中x_0=1)$$
$$o_i=\frac{1}{1+e^{-net_i}}$$
整個多層網絡就是由一組輸入開始,然后按每條連結線的權重,進行一直的向前計算。這里我們進一步對上面這個網絡結構進行量化,以便后面實現:首先它是一個三層的網絡結構,第一層是輸入層,它本身沒有接收輸入,也沒有連線進來;第二層有3個結點,並且有3*(4+1)根連結線,注意每個結點有一個偏置線;最后一層是輸出層,它有4個結點,並且有4*(3+1)根連結線。所以說整個網絡結構為$[layer1,layer2,layer3]$,而每一層都是這樣的結構:$layer=[nodes,weights]$。
三、反向傳播算法
我們已經在上篇文章中討論了單個結點用梯度下降的方法,可以去更新權值向量。而對於多層網絡結構,我們也可以用類似的方法也推導整個網絡的權值更新法則,我們把這種方法叫作反向傳播算法,因為它是從輸出層開始向前逐層更新權值的。
那么我們先從輸出層考慮,還是先考慮整個輸出誤差是多少?不同與單個感知器單元(一個輸出),多層網絡結構具有多個輸出,那么它的誤差計算公式可以用LSM法則表示如下:
$$E(w)=\frac{1}{2}\sum_{d \in D}\sum_{k \in outputs}(t_{kd}-o_{kd})^2$$
其中,$outputs$是網絡輸出單元的集合,$D$還是代表所有訓練樣本空間,$t_{kd}$和$o_{kd}$是與訓練樣例$d$和第$k$個輸出單元相關的預期值與輸出值。
我們的目標是搜索一個巨大的假設空間,這個假設空間由網絡結構中所有可能的權值構成。如果用幾何的定義來思考,那么這個巨大的搜索空間構成了一個誤差曲面,我們需要找到這個曲面上的最小值點。顯然,梯度下降是我們的一個方法,我們通過計算曲面任何點的梯度方向,然后沿着反方向去改變權值,就會使誤差變小。
回憶我們一篇文章講到的隨機梯度下降法則,我們將它應用在多層網絡的反向傳播算法中。我們每次只處理一個樣本實例,然后更新各個權值,通過大量的樣本實例逐漸的調整權值。那么對於每一次的訓練樣例$d$來說,它的輸出誤差為:
$$E_d(w)=\frac{1}{2}\sum_{k \in outputs}(t_{kd}-o_{kd})^2$$
對於輸出層的結點上的連線權值,很明顯它們可以直接影響到最終的誤差,而隱藏層結點上的連結線權值只能間接的影響最后的結果,所以我們分兩種情況來推導反向傳播算法。
情況1:對於輸出單元的權值訓練法則:
我們知道每個結點前的所有連結線只能通過影響net(net的定義在上面的公式中)的結果來影響誤差E,所以有:
$$\frac{\partial E_d}{\partial w_i}=\frac{\partial E_d}{\partial net}\frac{\partial net}{\partial w_i}=\frac{\partial E_d}{\partial net}x_i$$
所以我們只用推導出$\frac{\partial E_d}{\partial net}$即可。
$$\frac{\partial E_d}{\partial net}=\frac{\partial E_d}{\partial o}\frac{\partial o}{\partial net}=-(t-o)o(1-o)$$
將上面的兩個公式合並,我們就得到了更新權值的法則,如下:
$$w_i \gets w_i+\Delta w \\\Delta w=-\eta \frac{\partial E_d}{\partial w_i}x_i=\eta (t-o)o(1-o)x_i$$
我們把其中的$(t-o)o(1-o)$看成與該個結點相關的誤差項,並用符號$\delta$表示。
情況2:隱藏單元的權值訓練法則
隱藏層中的任意結點上的連結線權值都是通過影響以它的輸出作為輸入的下一層(downstream)的結點而最終影響誤差的,所以隱藏層的推導如下:
$$\frac{\partial E_d}{\partial net_i}=\sum_{k \in ds(i)}\frac{\partial E_d}{\partial net_k}\frac{\partial net_k}{net_i}
= \sum_{k \in ds(i)}-\delta_k \frac{\partial net_k}{net_i}=-o_i(1-o_i)\sum_{k \in ds(i)}\delta_k w_{ki}$$
所以隱藏層單元權值更新法則為:
$$w_i \gets w_i+\Delta w_i \\\Delta w_i=-\eta \delta_i x_i \\\delta_i=o(1-o)\sum_{k \in ds(i)}\delta_kw_{ki}$$
OK,上面內容就是反向傳播算法,上面公式中有些中間推導步驟省略了,無非是一些鏈式法則求導的內容。不過就算沒弄清楚整個推導過程也沒有關系,只要按下面的算法來更新你的所有權值即可。
1,對於訓練樣例training_examples中的每個$<\vec{x},\vec{t}>$,把輸入沿網絡傳播,計算出網絡中每個單元$u$的輸出$o_u$;
2,對於網絡中的每個輸出單元$k$,計算它的誤差項$\delta_k$
$$\delta_k \gets o_k(1-o_k)(t_k-o_k)$$
3,對於網絡中的每個隱藏的單元$h$,計算它的誤差項$\delta_h$
$$\delta_h \gets o_h(1-o_h)\sum_{k \in outputs}w_{kh}\delta_k$$
4,更新每個網絡權值$w_{ji}$
$$w_{ji} \gets w_{ji}+\Delta w_{ji},其中,\Delta w_{ji}=\eta \delta_j x_{ji}$$
四、深入討論
收斂性與局部最小值
正如前面所說的反向傳播算法實現了一種對可能的網絡權值空間的梯度下降搜索,它不斷迭代從而減小訓練樣例目標值與網絡輸出之間的誤差。但因為多層網絡,誤差曲面可能含有多個不同的局部極小值,我們的梯度下降可以收斂在這些極小值中。因此,對於多層網絡,反向傳播算法僅能保證收斂到誤差E的某個局部極小值,不一定收斂到全局最小誤差。
盡管缺乏對收斂到全局最小誤差的保證,反向傳播算法在實踐中仍是非常有效的函數逼近算法。對很多實際中的應用,人們發現局部最小值的問題沒有想像的那么嚴重。因為局部極小值往往是對於某個權值而言,些時其他權值未必也是極小值。事實上網絡的權越多,誤差曲面維數越多,也就越可能為梯度下降提供更多的“逃逸路線”讓梯度下降離開相對該單個權值的局部極小值。
另外一個觀點是,我們開始給權值初始化的值都非常小,接近於0,在這樣小權值的情況下,sigoid函數可以近似的看為線性的,所以在權值變化的初期是不存在局部極小值問題的,而到了后期整個網絡到了高度非線性的時候,可能這里的極小值點已經很接近全局最小值了。
多層網絡的處理能力
很多人都會在這里發出疑問,什么類型的函數可以使用多層網絡來表示呢?或者說什么樣的分類問題可以用多層網絡來表示呢?答案是:任意函數。任意函數可以被一個有三層單元的網絡以任意精度逼近(Cybenko 1988)。但是值得注意的是,我們使用的梯度下降算法並沒有搜索整個權值空間,所以我們很可能會漏掉那個最合適的權值集合。
歸納偏置
什么是歸納偏置?舉個例子,假如我們有兩個樣本$x_1=[1,0,0,0]$和$x_2=[0.8,0,0,0]$並且我們認為它們屬於同一類別,即如果把它們作為神經網絡的輸入,我們希望它們得到同樣的輸出。訓練樣本中只有這兩個實例,但是如果我們需要得到$x_3=[0.9,0,0,0]$的輸出時,它的結果會和$x_1$,$x_2$的輸出一樣。神經網絡的這種能力,我們稱它為歸納偏置的能力,實際網絡是在數據點之間平滑插值。
過度擬合
因為我們收集到的樣本中有些樣本可能由於我們分類錯誤等原因,造成了一個錯誤的樣本用例,實際上神經網絡對這種帶有噪點的樣本的適應性很強。但是在上面我們介紹的原理中,我們並沒有規定權值迭代更新的終止條件,往往我們是設置了一個迭代次數來控制,也就有可能造成,在訓練的后期那些權值是過度擬合那些噪點樣本。這個問題沒有統一的解決方案,現在比較常用的方法就是通過交叉驗證,即在訓練的同時,用一組校驗校本進行測試,找出分類率回降的一個點,從而終於訓練過程。
五、ANN的實現
我們首先來定義幾個類,用它們來分別表示神經網絡結構中一些基本組件:整個網絡(NeuralNetwork)、單層網絡(NNlayer)、神經元結點(NNneural)、連接線(NNconnection)。
首先整個網絡包含了一些參數,如層數、每層的結點數、迭代次數、每次實例的輸出等,同時一個網絡結構應該有的功能:設置參數、初始化網絡、網絡向前傳播、反向傳播、樣本輸入、訓練等。
1 class NNlayer; 2 class NNneural; 3 class NNconnection; 4 5 class NeuralNetwork 6 { 7 private: 8 unsigned nLayer; // 網絡層數 9 vector<unsigned> nodes; // 每層的結點數 10 vector<double> actualOutput; // 每次迭代的輸出結果 11 double etaLearningRate; // 權值學習率 12 unsigned iterNum; // 迭代次數 13 public: 14 vector<NNlayer*> m_layers; // 整個網絡層 15 void create(unsigned num_layers,unsigned * ar_nodes); // 創建網絡 16 void initializeNetwork(); // 初始化網絡,包括設置權值等 17 void forwardCalculate(vector<double>& invect,vector<double>& outvect); // 向前計算 18 void backPropagate(vector<double>& tVect,vector<double>& oVect); //反向傳播 19 20 void train(vector<vector<double>>& inputVect,vector<vector<double>>& outputVect); //訓練 21 void classifer(vector<double>& inVect,vector<double>& outVect); // 分類 22 };
然后設計單層網絡結構,我們需要一個指針成員來說明層與層之間的連接關系,同時每層網絡是由大量的神經元結點構成,同時我們將權值向量作為了每一層的成員,為什么沒有將權值與每個結點上的連接線捆在一起呢?那是因為到后面介紹到卷積神經網絡的時候,你會發現很多結點可以共有一個權值。
1 class NNlayer 2 { 3 public: 4 NNlayer(){ preLayer = NULL; } 5 NNlayer *preLayer; 6 vector<NNneural> m_neurals; 7 vector<double> m_weights; 8 void addNeurals(unsigned num, unsigned preNumNeurals); 9 void backPropagate(vector<double>& dErrWrtDxn, vector<double>& dErrWrtDxnm, double eta); 10 };
然后就是每個結點類和結點上的連接線,每個結點包含了一個輸出和若干個連接線。這里連接線里保存是兩個索引值,它表明條連接線的權重在整個權重向量中的索引與它連接的前面一層結點的索引。
1 class NNneural 2 { 3 public: 4 double output; 5 vector<NNconnection> m_connection; 6 7 }; 8 class NNconnection 9 { 10 public: 11 unsigned weightIdx; 12 unsigned neuralIdx; 13 };
上面是基本的數據結構,而我們整個算法的核心就在於向前計算與反向傳播來更新閾值,也就是函數forwardCalculate()和backPropagate()。
向前傳播函數其實比較簡單,注意第一層是輸入層,它不接受來自其他層的輸入,我們只需將它所有結點的輸出設置為訓練樣本的特征即可。而反向傳播函數,將最后一層與隱藏層區分開來,因為它們更新權值的法則不同,並且這個工作由每一層的backPropagate()函數來完成。
下面是整個代碼中比較重要的幾個函數,需要完整代碼的可以聯系我。

1 void NeuralNetwork::initializeNetwork() 2 { 3 // 初始化網絡,主要是創建各層和各層的結點,並給權重向量賦初值 4 for (vector<NNlayer*>::size_type i = 0; i != nLayer; i++) 5 { 6 NNlayer* ptrLayer = new NNlayer; 7 if (i == 0) 8 { 9 ptrLayer->addNeurals(nodes[i],0); 10 } 11 else 12 { 13 ptrLayer->preLayer = m_layers[i - 1]; 14 ptrLayer->addNeurals(nodes[i],nodes[i-1]); 15 unsigned num_weights = nodes[i] * (nodes[i-1]+1); // 有一個是bias 16 for (vector<double>::size_type k = 0; k != num_weights; k++) 17 { 18 // 初始化權重在0~0.05 19 ptrLayer->m_weights.push_back(0.05*rand()/RAND_MAX); 20 } 21 } 22 m_layers.push_back(ptrLayer); 23 } 24 }

1 void NNlayer::addNeurals(unsigned num, unsigned preNumNeural) 2 { 3 for (vector<NNneural>::size_type i = 0; i != num; i++) 4 { 5 NNneural sneural; 6 sneural.output = 0; 7 for (vector<NNconnection>::size_type k = 0; k != preNumNeural+1; k++) 8 { 9 NNconnection sconnection; 10 sconnection.weightIdx = i*(preNumNeural + 1) + k; // 設置權重索引 11 sconnection.neuralIdx = k; // 設置前層結點索引 12 sneural.m_connection.push_back(sconnection); 13 } 14 m_neurals.push_back(sneural); 15 } 16 }

1 void NeuralNetwork::forwardCalculate(vector<double>& invect, vector<double>& outvect) 2 { 3 actualOutput.clear(); 4 vector<NNlayer*>::iterator layerIt = m_layers.begin(); 5 while (layerIt != m_layers.end()) 6 { 7 if (layerIt == m_layers.begin()) 8 { 9 // 第一層 10 for (vector<NNneural>::size_type k = 0; k != (*layerIt)->m_neurals.size(); k++) 11 { 12 (*layerIt)->m_neurals[k].output = invect[k]; 13 } 14 } 15 else 16 { 17 vector<NNneural>::iterator neuralIt = (*layerIt)->m_neurals.begin(); 18 int neuralIdx = 0; 19 while (neuralIt != (*layerIt)->m_neurals.end()) 20 { 21 vector<NNconnection>::size_type num_connection = (*neuralIt).m_connection.size(); 22 double dsum = (*layerIt)->m_weights[num_connection*(neuralIdx + 1) - 1]; // 先將偏置加上 23 for (vector<NNconnection>::size_type i = 0; i != num_connection - 1; i++) 24 { 25 // sum=sum of xi*wi 26 unsigned wgtIndex = (*neuralIt).m_connection[i].weightIdx; 27 unsigned neuIndex = (*neuralIt).m_connection[i].neuralIdx; 28 dsum += ((*layerIt)->preLayer->m_neurals[neuIndex].output*(*layerIt)->m_weights[wgtIndex]); 29 } 30 neuralIt->output = SIGMOID(dsum); 31 neuralIdx++; 32 neuralIt++; 33 } 34 } 35 ++layerIt; 36 } 37 // 將最后一層的結果傳遞給輸出 38 NNlayer* lastLayer = m_layers[m_layers.size() - 1]; 39 vector<NNneural>::iterator neuralIt = lastLayer->m_neurals.begin(); 40 while (neuralIt != lastLayer->m_neurals.end()) 41 { 42 outvect.push_back(neuralIt->output); 43 ++neuralIt; 44 } 45 }

1 void NeuralNetwork::backPropagate(vector<double>& tVect, vector<double>& oVect) 2 { 3 // lit是最后一層的迭代器 4 vector<NNlayer*>::iterator lit = m_layers.end() - 1; 5 // dErrWrtDxLast是最后一層所有結點的誤差 6 vector<double> dErrWrtDxLast((*lit)->m_neurals.size()); 7 // 所有層的誤差 8 vector<vector<double>> diffVect(nLayer); 9 for (vector<NNneural>::size_type i = 0; i != (*lit)->m_neurals.size();i++) 10 { 11 dErrWrtDxLast[i] = oVect[i] - tVect[i]; 12 } 13 diffVect[nLayer - 1] = dErrWrtDxLast; 14 // 先將其他層的誤差都設為0 15 for (unsigned i = 0; i < nLayer - 1; i++) 16 { 17 diffVect[i].resize(m_layers[i]->m_neurals.size(),0.0); 18 } 19 20 vector<NNlayer*>::size_type i = m_layers.size()-1; 21 for (lit; lit>m_layers.begin(); lit--) 22 { 23 (*lit)->backPropagate(diffVect[i],diffVect[i-1],etaLearningRate); 24 --i; 25 } 26 diffVect.clear(); 27 } 28 void NNlayer::backPropagate(vector<double>& dErrWrtDxn,vector<double>& dErrWrtDxnm,double eta) 29 { 30 double output; 31 vector<double> dErrWrtDyn(dErrWrtDxn.size()); 32 for (vector<NNneural>::size_type i = 0; i != m_neurals.size(); i++) 33 { 34 output = m_neurals[i].output; 35 dErrWrtDyn[i] = DSIGMOID(output)*dErrWrtDxn[i]; 36 } 37 unsigned ii(0); 38 vector<NNneural>::iterator nit = m_neurals.begin(); 39 vector<double> dErrWrtDwn(m_weights.size(),0); 40 while(nit != m_neurals.end()) 41 { 42 for (vector<NNconnection>::size_type k = 0; k != (*nit).m_connection.size(); k++) 43 { 44 if (k == (*nit).m_connection.size() - 1) 45 output = 1; 46 else 47 output = preLayer->m_neurals[(*nit).m_connection[k].neuralIdx].output; 48 dErrWrtDwn[(*nit).m_connection[k].weightIdx] += output*dErrWrtDyn[ii]; 49 } 50 51 ++nit; 52 ++ii; 53 } 54 unsigned j(0); 55 nit = m_neurals.begin(); 56 while (nit != m_neurals.end()) 57 { 58 for (vector<NNconnection>::size_type k = 0; k != (*nit).m_connection.size()-1; k++) 59 { 60 dErrWrtDxnm[(*nit).m_connection[k].neuralIdx] += dErrWrtDyn[j] * m_weights[(*nit).m_connection[k].weightIdx]; 61 } 62 ++j; 63 ++nit; 64 } 65 for (vector<double>::size_type i = 0; i != m_weights.size(); i++) 66 { 67 m_weights[i] -= eta*dErrWrtDwn[i]; 68 } 69 }
這里我們想起上篇文章中的一個例子,這里我們用多層的網絡來再次去擬合那個目標函數,我們用了3層的網絡結構,隱藏層設置了20層,而這里我們沒有將結果設置為1維,而是用一個4維向量組成也就是與輸入向量一致。下面是經過500迭代后產生的結果,並組我們最后以一組[0.01,0.99,0.001,-0.05]這樣的輸入來測試,我們預期的結果是[0,1,0,0],而程序得到的結果是[0.027,0.999,0.011,-0.046]。
六、結束語
到這里,神經網絡的基本結構和最常見的訓練法則已經介紹完了,但是神經網絡的發展已經有幾十年的歷史了,期間出現了各種的變種,讓神經網絡發展為了不同的種類,但它的基本思路都是不變的,正如下一篇文章即將介紹的卷積神經網絡。
本文原理部分主要參考Tom M.Mitchell的《機器學習》,而實現部分主要參考了CodeProject中的Neural Network for recognition of handwriting digits這篇文章。感謝你的閱讀。