[CLPR]BP神經網絡的C++實現

文章翻譯自: http://www.codeproject.com/Articles/16650/Neural-Network-for-Recognition-of-Handwritten-Digi

如何在C++中實現一個神經網絡類?

主要有四個不同的類需要我們來考慮:

  1. 層 - layers
  2. 層中的神經元 - neurons
  3. 神經元之間的連接 - connections
  4. 連接的權值 - weights

這四類都在下面的代碼中體現, 集中應用於第五個類 - 神經網絡(neural network)上. 它就像一個容器, 用於和外部交流的接口. 下面的代碼大量使用了STL的vector.

// simplified view: some members have been omitted,
// and some signatures have been altered

// helpful typedef's

typedef std::vector< NNLayer* >  VectorLayers;
typedef std::vector< NNWeight* >  VectorWeights;
typedef std::vector< NNNeuron* >  VectorNeurons;
typedef std::vector< NNConnection > VectorConnections;


// Neural Network class

class NeuralNetwork  
{
public:
    NeuralNetwork();
    virtual ~NeuralNetwork();
    
    void Calculate( double* inputVector, UINT iCount, 
        double* outputVector = NULL, UINT oCount = 0 );

    void Backpropagate( double *actualOutput, 
         double *desiredOutput, UINT count );

    VectorLayers m_Layers;
};


// Layer class

class NNLayer
{
public:
    NNLayer( LPCTSTR str, NNLayer* pPrev = NULL );
    virtual ~NNLayer();
    
    void Calculate();
    
    void Backpropagate( std::vector< double >& dErr_wrt_dXn /* in */, 
        std::vector< double >& dErr_wrt_dXnm1 /* out */, 
        double etaLearningRate );

    NNLayer* m_pPrevLayer;
    VectorNeurons m_Neurons;
    VectorWeights m_Weights;
};


// Neuron class

class NNNeuron
{
public:
    NNNeuron( LPCTSTR str );
    virtual ~NNNeuron();

    void AddConnection( UINT iNeuron, UINT iWeight );
    void AddConnection( NNConnection const & conn );

    double output;

    VectorConnections m_Connections;
};


// Connection class

class NNConnection
{
public: 
    NNConnection(UINT neuron = ULONG_MAX, UINT weight = ULONG_MAX);
    virtual ~NNConnection();

    UINT NeuronIndex;
    UINT WeightIndex;
};


// Weight class

class NNWeight
{
public:
    NNWeight( LPCTSTR str, double val = 0.0 );
    virtual ~NNWeight();

    double value;
};

類NeuralNetwork存儲的是一個指針數組, 這些指針指向NN中的每一層, 即NNLayer. 沒有專門的函數來增加層, 只需要使用std::vector::push_back()即可. NeuralNetwork類提供了兩個基本的接口, 一個用來得到輸出(Calculate), 一個用來訓練(Backpropagete).

每一個NNLayer都保存一個指向前一層的指針, 使用這個指針可以獲取上一層的輸出作爲輸入. 另外它還保存了一個指針向量, 每個指針指向本層的神經元, 即NNNeuron, 當然, 還有連接的權值NNWeight. 和NeuralNetwork相似, 神經元和權值的增加都是通過std::vector::push_back()方法來執行的. NNLayer層還包含了函數Calculate()來計算神經元的輸出, 以及Backpropagate()來訓練它們. 實際上, NeuralNetwork類只是簡單地調用每層的這些函數來實現上小節所說的2個同名方法.

每個NNNeuron保存了一個連接數組, 使用這個數組可以使得神經元能夠獲取輸入. 使用NNNeuron::AddConnection()來增加一個Connection, 輸入神經元的標號和權值的標號, 從而建立一個NNConnection對象, 並將它push_back()到神經元保存的連接數組中. 每個神經元同樣保存着它自己的輸出值(double). NNConnection和NNWeight類分別存儲了一些信息.

你可能疑惑, 爲何權值和連接要分開定義? 根據上述的原理, 每個連接都有一個權值, 爲何不直接將它們放在一個類裏?

原因是: 權值經常被連接共享.

實際上, 在卷積神經網絡中就是共享連接的權值的. 所以, 舉例來說, 就算一層可能有幾百個神經元, 權值卻可能只有幾十個. 通過分離這兩個概念, 這種共享可以很輕易地實現.


前向傳遞

前向傳遞是指所有的神經元基於接收的輸入, 計算輸出的過程.

在代碼中, 這個過程通過調用NeuralNetwork::Calculate()來實現. NeuralNetwork::Calculate()直接設置輸入層的神經元的值, 隨後迭代剩下的層, 調用每一層的NNLayer::Calculate(). 這就是所謂的前向傳遞的串行實現方式. 串行計算並非是實現前向傳遞的唯一方法, 但它是最直接的. 下面是一個簡化後的代碼, 輸入一個代表輸入數據的C數組和一個代表輸出數據的C數組.

// simplified code

void NeuralNetwork::Calculate(double* inputVector, UINT iCount, 
               double* outputVector /* =NULL */, 
               UINT oCount /* =0 */)
                              
{
    VectorLayers::iterator lit = m_Layers.begin();
    VectorNeurons::iterator nit;
    
    // 第一層是輸入層: 
    // 直接設置所有的神經元輸出爲給定的輸入向量即可
    
    if ( lit < m_Layers.end() )  
    {
        nit = (*lit)->m_Neurons.begin();
        int count = 0;
        
        ASSERT( iCount == (*lit)->m_Neurons.size() );
        // 輸入和神經元個數應當一一對應
        
        while( ( nit < (*lit)->m_Neurons.end() ) && ( count < iCount ) )
        {
            (*nit)->output = inputVector[ count ];
            nit++;
            count++;
        }
    }
    
    // 調用Calculate()迭代剩餘層
    
    for( lit++; lit<m_Layers.end(); lit++ )
    {
        (*lit)->Calculate();
    }
    
    // 使用結果設置每層輸出
    
    if ( outputVector != NULL )
    {
        lit = m_Layers.end();
        lit--;
        
        nit = (*lit)->m_Neurons.begin();
        
        for ( int ii=0; ii<oCount; ++ii )
        {
            outputVector[ ii ] = (*nit)->output;
            nit++;
        }
    }
}

在層中的Calculate()函數中, 層會迭代其中的所有神經元, 對於每一個神經元, 它的輸出通過前饋公式給出: General feed-forward equation

這個公式通過迭代每個神經元的所有連接來實現, 獲取對應的權重和對應的前一層神經元的輸出. 如下:

// simplified code

void NNLayer::Calculate()
{
    ASSERT( m_pPrevLayer != NULL );
    
    VectorNeurons::iterator nit;
    VectorConnections::iterator cit;
    
    double dSum;
    
    for( nit=m_Neurons.begin(); nit<m_Neurons.end(); nit++ )
    {
        NNNeuron& n = *(*nit);  // 取引用
        
        cit = n.m_Connections.begin();
        
        ASSERT( (*cit).WeightIndex < m_Weights.size() );
        
        // 第一個權值是偏置
        // 需要忽略它的神經元下標

        dSum = m_Weights[ (*cit).WeightIndex ]->value;  
        
        for ( cit++ ; cit<n.m_Connections.end(); cit++ )
        {
            ASSERT( (*cit).WeightIndex < m_Weights.size() );
            ASSERT( (*cit).NeuronIndex < 
                     m_pPrevLayer->m_Neurons.size() );
            
            dSum += ( m_Weights[ (*cit).WeightIndex ]->value ) * 
                ( m_pPrevLayer->m_Neurons[ 
                   (*cit).NeuronIndex ]->output );
        }
        
        n.output = SIGMOID( dSum );
        
    }
    
}

SIGMOID是一個宏定義, 用於計算激勵函數.


 

反向傳播

BP是從最後一層向前移動的一個迭代過程. 假設在每一層我們都知道了它的輸出誤差. 如果我們知道輸出誤差, 那麼修正權值來減少這個誤差就不難. 問題是我們只能觀測到最後一層的誤差.

BP給出了一種通過當前層輸出計算前一層的輸出誤差的方法. 它是一種迭代的過程: 從最後一層開始, 計算最後一層權值的修正, 然後計算前一層的輸出誤差, 反覆.

BP的公式在下面. 代碼中就用到了這個公式. 距離來說, 第一個公式告訴了我們如何去計算誤差EP對於激勵值yi的第n層的偏導數. 代碼中, 這個變量名爲dErr_wrt_dYn[ ii ].

Equation (1): Error due to a single pattern

對於最後一層神經元的輸出, 計算一個單輸入圖像模式的誤差偏導的方法如下:

Equation (1): Error due to a single pattern(equation 1)

其中, Error due to a single pattern P at the last layer n是對於模式P再第n層的誤差, Target output at the last layer (i.e., the desired output at the last layer)是最後一層的期望輸出, Actual value of the output at the last layer是最後一層的實際輸出.

給定上式, 我們可以得到偏導表達式:

Equation (2): Partial derivative of the output error for one pattern with respect to the neuron output values(equation 2)

式2給出了BP過程的起始值. 我們使用這個數值作爲式2的右值從而計算偏導的值. 使用偏導的值, 我們可以計算權值的修正量, 通過應用下式:

Equation (3): Partial derivative of the output error for one pattern with respect to the activation value of each neuron(equation 3), 其中Derivative of the activation function是激勵函數的導數.

Equation (4): Partial derivative of the output error for one pattern with respect to each weight feeding the neuron(equation 4)

使用式2和式3, 我們可以計算前一層的誤差, 使用下式5: 

Equation (5): Partial derivative of the error for the previous layer(equation 5)

從式5中獲取的值又可以立刻用作前一層的起始值. 這是BP的核心所在.

式4中獲取的值告訴我們該如何去修正權值, 按照下式:

Equation (6): Updating the weights(equation 6)

其中eta是學習速率, 常用值是0.0005, 並隨着訓練減小.

本代碼中, 上述等式在NeuralNetwork::Backpropagate()中實現. 輸入實際上是神經網絡的實際輸出和期望輸出. 使用這兩個輸入, NeuralNetwork::Backpropagate()計算式2的值並迭代所有的層, 從最後一層一直迭代到第一層. 對於每層, 都調用了NNLayer::Backpropagate(). 輸入是梯度值, 輸出則是式5.

這些梯度都保存在一個兩維數組differentials中.

本層的輸出則作爲前一層的輸入.

// simplified code
void NeuralNetwork::Backpropagate(double *actualOutput, 
     double *desiredOutput, UINT count)
{
    // 神經網絡的BP過程,
    // 從最後一層迭代向前處理到第一層爲止.
    // 首先, 單獨計算最後一層,
    // 因爲它提供了前一層所需的梯度信息
    // (i.e., dErr_wrt_dXnm1)
    
    // 變量含義:
    //
    // Err - 整個NN的輸出誤差
    // Xn - 第n層的輸出向量
    // Xnm1 - 前一層的輸出向量
    // Wn - 第n層的權值向量
    // Yn - 第n層的激勵函數輸入值
    // 即, 在應用壓縮函數(squashing function)前的權值和// F - 擠壓函數: Xn = F(Yn)
    // F' - 壓縮函數(squashing function)的梯度
    //   比如, 令 F = tanh,
    //   則 F'(Yn) = 1 - Xn^2, 梯度可以通過輸出來計算, 不需要輸入信息
    
    
    VectorLayers::iterator lit = m_Layers.end() - 1; // 取最後一層
    
    std::vector< double > dErr_wrt_dXlast( (*lit)->m_Neurons.size() ); // 記錄後層神經元誤差對輸入的梯度
    std::vector< std::vector< double > > differentials; //記錄每一層輸出對輸入的梯度
    
    int iSize = m_Layers.size(); // 層數
    
    differentials.resize( iSize ); 
    
    int ii;
    
    // 計算最後一層的 dErr_wrt_dXn 來開始整個迭代.
    // 對於標準的MSE方程
    // (比如, 0.5*sumof( (actual-target)^2 ),
    // 梯度表達式就僅僅是期望和實際的差: Xn - Tn
    
    for ( ii=0; ii<(*lit)->m_Neurons.size(); ++ii )
    {
        dErr_wrt_dXlast[ ii ] = 
            actualOutput[ ii ] - desiredOutput[ ii ];
    }
    
    
    // 保存 Xlast 並分配內存存儲剩餘的梯度
    
    differentials[ iSize-1 ] = dErr_wrt_dXlast;  // 最後一層的梯度
    
    for ( ii=0; ii<iSize-1; ++ii )
    {
        differentials[ ii ].resize( 
             m_Layers[ii]->m_Neurons.size(), 0.0 );
    }
    
    // 迭代每個層, 包括最後一層但不包括第一層
    // 同時求得每層的BP誤差並矯正權值// 返回梯度dErr_wrt_dXnm1用於下一次迭代
    
    ii = iSize - 1;
    for ( lit; lit>m_Layers.begin(); lit--)
    {
        (*lit)->Backpropagate( differentials[ ii ], 
              differentials[ ii - 1 ], m_etaLearningRate ); // 調用每一層的BP接口
        --ii;
    }
    
    differentials.clear();
}

 在NNLayer::Backpropagate()中, 層實現了式3~5, 計算出了梯度. 實現了式6來更新本層的權重. 在下面的代碼中, 激勵函數的梯度被定義爲 DSIGMOID.

// simplified code

void NNLayer::Backpropagate( std::vector< double >& dErr_wrt_dXn /* in */, 
                            std::vector< double >& dErr_wrt_dXnm1 /* out */, 
                            double etaLearningRate )
{
    double output;

    // 計算式 (3): dErr_wrt_dYn = F'(Yn) * dErr_wrt_Xn
    
    for ( ii=0; ii<m_Neurons.size(); ++ii ) // 遍歷所有神經元
    {
        output = m_Neurons[ ii ]->output; // 神經元輸出
    
        dErr_wrt_dYn[ ii ] = DSIGMOID( output ) * dErr_wrt_dXn[ ii ]; // 誤差對輸入的梯度
    }
    
    // 計算式 (4): dErr_wrt_Wn = Xnm1 * dErr_wrt_Yn
    // 對於本層的每個神經元, 遍歷前一層的連接
    // 更新對應權值的梯度
    
    ii = 0;
    for ( nit=m_Neurons.begin(); nit<m_Neurons.end(); nit++ ) // 迭代本層所有神經元
    {
        NNNeuron& n = *(*nit);  // 取引用
        
        for ( cit=n.m_Connections.begin(); cit<n.m_Connections.end(); cit++ ) // 遍歷每個神經元的後向連接
        {
            kk = (*cit).NeuronIndex; // 連接的前一層神經元標號
            if ( kk == ULONG_MAX ) // 偏置的標號固定爲最大整形量
            {
                output = 1.0;  // 偏置
            }
            else // 其他情況下 神經元輸出等於前一層對應神經元的輸出 Xn-1
            {
                output = m_pPrevLayer->m_Neurons[ kk ]->output; 
            }
            // 誤差對權值的梯度
           // 每次使用對應神經元的誤差對輸入的梯度
            dErr_wrt_dWn[ (*cit).WeightIndex ] += dErr_wrt_dYn[ ii ] * output;
        }
        
        ii++;
    }
    
    
    // 計算式 (5): dErr_wrt_Xnm1 = Wn * dErr_wrt_dYn,// 需要dErr_wrt_Xn的值來進行前一層的BP
    
    ii = 0;
    for ( nit=m_Neurons.begin(); nit<m_Neurons.end(); nit++ ) // 迭代所有神經元
    {
        NNNeuron& n = *(*nit);  // 取引用
        
        for ( cit=n.m_Connections.begin(); 
              cit<n.m_Connections.end(); cit++ ) // 遍歷每個神經元所有連接
        {
            kk=(*cit).NeuronIndex;
            if ( kk != ULONG_MAX )
            {
                // 排除了ULONG_MAX, 提高了偏置神經元的重要性// 因爲我們不能夠訓練偏置神經元
                
                nIndex = kk;
                
                dErr_wrt_dXnm1[ nIndex ] += dErr_wrt_dYn[ ii ] * 
                       m_Weights[ (*cit).WeightIndex ]->value;
            }
            
        }
        
        ii++;  // ii 跟蹤神經元下標
        
    }
    
    
    // 計算式 (6): 更新權值
    // 在本層使用 dErr_wrt_dW (式4)
    // 以及訓練速率eta

    for ( jj=0; jj<m_Weights.size(); ++jj )
    {
        oldValue = m_Weights[ jj ]->value;
        newValue = oldValue.dd - etaLearningRate * dErr_wrt_dWn[ jj ];
        m_Weights[ jj ]->value = newValue;
    }
}

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章