【轉】C++從零實現神經網絡

開發者技術前線

CVPy [感謝文章原作者,侵刪]

Table of Contents

一、Net類的設計與神經網絡初始化

神經網絡的要素

Net類的設計

成員變量與成員函數

神經網絡初始化

權值初始化

初始化測試

二、前向傳播與反向傳播

前言

前向過程

反向傳播過程

注意

三、神經網絡的訓練和測試

前言

完善後的Net類

訓練

測試

四、神經網絡的預測和輸入輸出解析

神經網絡的預測

輸出的組織方式和解析

讀取樣本和標籤

五、模型的保存和加載及實時畫出輸出曲線

模型的保存和加載

實時畫出輸出曲線

相關閱讀


一、Net類的設計與神經網絡初始化

閒言少敘,直接開始

既然是要用C++來實現,那麼我們自然而然的想到設計一個神經網絡類來表示神經網絡,這裏我稱之爲Net類。由於這個類名太過普遍,很有可能跟其他人寫的程序衝突,所以我的所有程序都包含在namespace liu中,由此不難想到我姓劉。在之前的博客反向傳播算法資源整理中,我列舉了幾個比較不錯的資源。對於理論不熟悉而且學習精神的同學可以出門左轉去看看這篇文章的資源。這裏假設讀者對於神經網絡的基本理論有一定的瞭解。

神經網絡的要素

在真正開始coding之前還是有必要交代一下神經網絡基礎,其實也就是設計類和寫程序的思路。簡而言之,神經網絡的包含幾大要素:

  • 神經元節點

  • 層(layer)

  • 權值(weights)

  • 偏置項(bias)

神經網絡的兩大計算過程分別是前向傳播和反向傳播過程。每層的前向傳播分別包含加權求和(卷積?)的線性運算和激活函數的非線性運算。反向傳播主要是用BP算法更新權值。 雖然裏面還有很多細節,但是對於作爲第一篇的本文來說,以上內容足夠了。

Net類的設計

Net類——基於Mat

神經網絡中的計算幾乎都可以用矩陣計算的形式表示,這也是我用OpenCV的Mat類的原因之一,它提供了非常完善的、充分優化過的各種矩陣運算方法;另一個原因是我最熟悉的庫就是OpenCV......有很多比較好的庫和框架在實現神經網絡的時候會用很多類來表示不同的部分。比如Blob類表示數據,Layer類表示各種層,Optimizer類來表示各種優化算法。但是這裏沒那麼複雜,主要還是能力有限,只用一個Net類表示神經網絡。

還是直接讓程序說話,Net類包含在Net.h中,大致如下。

#ifndef NET_H
#define NET_H
#endif // NET_H
#pragma once
#include <iostream>
#include<opencv2\core\core.hpp>
#include<opencv2\highgui\highgui.hpp>
//#include<iomanip>
#include"Function.h"
namespace liu
{
    class Net
    {
    public:
        std::vector<int> layer_neuron_num;
        std::vector<cv::Mat> layer;
        std::vector<cv::Mat> weights;
        std::vector<cv::Mat> bias;
    public:
        Net() {};
        ~Net() {};
        //Initialize net:genetate weights matrices、layer matrices and bias matrices
        // bias default all zero
        void initNet(std::vector<int> layer_neuron_num_);
        //Initialise the weights matrices.
        void initWeights(int type = 0, double a = 0., double b = 0.1);
        //Initialise the bias matrices.
        void initBias(cv::Scalar& bias);
        //Forward
        void forward();
        //Forward
        void backward();
    protected:
        //initialise the weight matrix.if type =0,Gaussian.else uniform.
        void initWeight(cv::Mat &dst, int type, double a, double b);
        //Activation function
        cv::Mat activationFunction(cv::Mat &x, std::string func_type);
        //Compute delta error
        void deltaError();
        //Update weights
        void updateWeights();
    };
}

說明

以上不是Net類的完整形態,只是對應於本文內容的一個簡化版,簡化之後看起來會更加清晰明瞭。

成員變量與成員函數

成員變量與成員函數

現在Net類只有四個成員變量,分別是:

  • 每一層神經元數目(layer_neuron_num)

  • 層(layer)

  • 權值矩陣(weights)

  • 偏置項(bias)

權值用矩陣表示就不用說了,需要說明的是,爲了計算方便,這裏每一層和偏置項也用Mat表示,每一層和偏置都用一個單列矩陣來表示。

Net類的成員函數除了默認的構造函數和析構函數,還有:

  • initNet():用來初始化神經網絡

  • initWeights():初始化權值矩陣,調用initWeight()函數

  • initBias():初始化偏置項

  • forward():執行前向運算,包括線性運算和非線性激活,同時計算誤差

  • backward():執行反向傳播,調用updateWeights()函數更新權值。

這些函數已經是神經網絡程序核心中的核心。剩下的內容就是慢慢實現了,實現的時候需要什麼添加什麼,逢山開路,遇河架橋。

神經網絡初始化

initNet()函數

先說一下initNet()函數,這個函數只接受一個參數——每一層神經元數目,然後藉此初始化神經網絡。這裏所謂初始化神經網絡的含義是:生成每一層的矩陣、每一個權值矩陣和每一個偏置矩陣。聽起來很簡單,其實也很簡單。

實現代碼在Net.cpp中。

這裏生成各種矩陣沒啥難點,唯一需要留心的是權值矩陣的行數和列數的確定。值得一提的是這裏把權值默認全設爲0。

   //Initialize net
    void Net::initNet(std::vector<int> layer_neuron_num_)
    {
        layer_neuron_num = layer_neuron_num_;
        //Generate every layer.
        layer.resize(layer_neuron_num.size());
        for (int i = 0; i < layer.size(); i++)
        {
            layer[i].create(layer_neuron_num[i], 1, CV_32FC1);
        }
        std::cout << "Generate layers, successfully!" << std::endl;
        //Generate every weights matrix and bias
        weights.resize(layer.size() - 1);
        bias.resize(layer.size() - 1);
        for (int i = 0; i < (layer.size() - 1); ++i)
        {
            weights[i].create(layer[i + 1].rows, layer[i].rows, CV_32FC1);
            //bias[i].create(layer[i + 1].rows, 1, CV_32FC1);
            bias[i] = cv::Mat::zeros(layer[i + 1].rows, 1, CV_32FC1);
        }
        std::cout << "Generate weights matrices and bias, successfully!" << std::endl;
        std::cout << "Initialise Net, done!" << std::endl;
    }

權值初始化

initWeight()函數

權值初始化函數initWeights()調用initWeight()函數,其實就是初始化一個和多個的區別。

偏置初始化是給所有的偏置賦相同的值。這裏用Scalar對象來給矩陣賦值。

   //initialise the weights matrix.if type =0,Gaussian.else uniform.
    void Net::initWeight(cv::Mat &dst, int type, double a, double b)
    {
        if (type == 0)
        {
            randn(dst, a, b);
        }
        else
        {
            randu(dst, a, b);
        }
    }
    //initialise the weights matrix.
    void Net::initWeights(int type, double a, double b)
    {
        //Initialise weights cv::Matrices and bias
        for (int i = 0; i < weights.size(); ++i)
        {
            initWeight(weights[i], 0, 0., 0.1);
        }
    }

偏置初始化是給所有的偏置賦相同的值。這裏用Scalar對象來給矩陣賦值。

   //Initialise the bias matrices.
    void Net::initBias(cv::Scalar& bias_)
    {
        for (int i = 0; i < bias.size(); i++)
        {
            bias[i] = bias_;
        }
    }

至此,神經網絡需要初始化的部分已經全部初始化完成了。

初始化測試

我們可以用下面的代碼來初始化一個神經網絡,雖然沒有什麼功能,但是至少可以測試下現在的代碼是否有BUG:

#include"../include/Net.h"
//<opencv2\opencv.hpp>
using namespace std;
using namespace cv;
using namespace liu;
int main(int argc, char *argv[])
{
    //Set neuron number of every layer
    vector<int> layer_neuron_num = { 784,100,10 };
    // Initialise Net and weights
    Net net;
    net.initNet(layer_neuron_num);
    net.initWeights(0, 0., 0.01);
    net.initBias(Scalar(0.05));
    getchar();
    return 0;
}

親測沒有問題。

本文先到這裏,前向傳播和反向傳播放在下一篇內容裏面。


二、前向傳播與反向傳播

前言

前一篇文章C++實現神經網絡之壹—Net類的設計和神經網絡的初始化中,大部分還是比較簡單的。因爲最重要事情就是生成各種矩陣並初始化。神經網絡中的重點和核心就是本文的內容——前向和反向傳播兩大計算過程。每層的前向傳播分別包含加權求和(卷積?)的線性運算和激活函數的非線性運算。反向傳播主要是用BP算法更新權值。本文也分爲兩部分介紹。

前向過程

前向過程簡介

如前所述,前向過程分爲線性運算和非線性運算兩部分。相對來說比較簡單。

線型運算可以用Y = WX+b來表示,其中X是輸入樣本,這裏即是第N層的單列矩陣,W是權值矩陣,Y是加權求和之後的結果矩陣,大小與N+1層的單列矩陣相同。b是偏置,默認初始化全部爲0。不難推知鬼知道我推了多久!,W的大小是(N+1).rows * N.rows。正如上一篇中生成weights矩陣的代碼實現一樣:

weights[i].create(layer[i + 1].rows, layer[i].rows, CV_32FC1); 

非線性運算可以用O=f(Y)來表示。Y就是上面得到的Y。O就是第N+1層的輸出。f就是我們一直說的激活函數。激活函數一般都是非線性函數。它存在的價值就是給神經網絡提供非線性建模能力。激活函數的種類有很多,比如sigmoid函數,tanh函數,ReLU函數等。各種函數的優缺點可以參考更爲專業的論文和其他更爲專業的資料。

我們可以先來看一下前向函數forward()的代碼:

   //Forward
   void Net::forward()
   {
       for (int i = 0; i < layer_neuron_num.size() - 1; ++i)
       {
           cv::Mat product = weights[i] * layer[i] + bias[i];
           layer[i + 1] = activationFunction(product, activation_function);
       }
   }

for循環裏面的兩句就分別是上面說的線型運算和激活函數的非線性運算。

激活函數activationFunction()裏面實現了不同種類的激活函數,可以通過第二個參數來選取用哪一種。代碼如下:

   //Activation function
    cv::Mat Net::activationFunction(cv::Mat &x, std::string func_type)
    {
        activation_function = func_type;
        cv::Mat fx;
        if (func_type == "sigmoid")
        {
            fx = sigmoid(x);
        }
        if (func_type == "tanh")
        {
            fx = tanh(x);
        }
        if (func_type == "ReLU")
        {
            fx = ReLU(x);
        }
        return fx;
    }

各個函數更爲細節的部分在Function.hFunction.cpp文件中。在此略去不表,感興趣的請君移步Github

需要再次提醒的是,上一篇博客中給出的Net類是精簡過的,下面可能會出現一些上一篇Net類裏沒有出現過的成員變量。完整的Net類的定義還是在Github裏。

反向傳播過程

反向傳播

反向傳播原理是鏈式求導法則,其實就是我們高數中學的複合函數求導法則。這只是在推導公式的時候用的到。具體的推導過程我推薦看看下面這一篇教程,用圖示的方法,把前向傳播和反向傳播表現的清晰明瞭,強烈推薦!

Principles of training multi-layer neural network using backpropagation。

一會將從這一篇文章中截取一張圖來說明權值更新的代碼。在此之前,還是先看一下反向傳播函數backward()的代碼是什麼樣的:

   //Forward
    void Net::backward()
    {
        calcLoss(layer[layer.size() - 1], target, output_error, loss);
        deltaError();
        updateWeights();
    }

可以看到主要是是三行代碼,也就是調用了三個函數:

  • 第一個函數calcLoss()計算輸出誤差和目標函數,所有輸出誤差平方和的均值作爲需要最小化的目標函數。

  • 第二個函數deltaError()計算delta誤差,也就是下圖中delta1*df()那部分。

  • 第三個函數updateWeights()更新權值,也就是用下圖中的公式更新權值。

下面是從前面強烈推薦的文章中截的一張圖:

詳情請見文章:https://blog.csdn.net/Rong_Toa/article/details/80346754

就看下updateWeights()函數的代碼:

   //Update weights
    void Net::updateWeights()
    {
        for (int i = 0; i < weights.size(); ++i)
        {
            cv::Mat delta_weights = learning_rate * (delta_err[i] * layer[i].t());
            weights[i] = weights[i] + delta_weights;
        }
    }

核心的兩行代碼應該還是能比較清晰反映上圖中的那個權值更新的公式的。圖中公式裏的eta常被稱作學習率。訓練神經網絡調參的時候經常要調節這貨。

計算輸出誤差和delta誤差的部分純粹是數學運算,乏善可陳。但是把代碼貼在下面吧。

calcLoss()函數在Function.cpp文件中:

   //Objective function
    void calcLoss(cv::Mat &output, cv::Mat &target, cv::Mat &output_error, float &loss)
    {
        if (target.empty())
        {
            std::cout << "Can't find the target cv::Matrix" << std::endl;
            return;
        }
        output_error = target - output;
        cv::Mat err_sqrare;
        pow(output_error, 2., err_sqrare);
        cv::Scalar err_sqr_sum = sum(err_sqrare);
        loss = err_sqr_sum[0] / (float)(output.rows);
    }

deltaError()Net.cpp中:

   //Compute delta error
    void Net::deltaError()
    {
        delta_err.resize(layer.size() - 1);
        for (int i = delta_err.size() - 1; i >= 0; i--)
        {
            delta_err[i].create(layer[i + 1].size(), layer[i + 1].type());
            //cv::Mat dx = layer[i+1].mul(1 - layer[i+1]);
            cv::Mat dx = derivativeFunction(layer[i + 1], activation_function);
            //Output layer delta error
            if (i == delta_err.size() - 1)
            {
                delta_err[i] = dx.mul(output_error);
            }
            else  //Hidden layer delta error
            {
                cv::Mat weight = weights[i];
                cv::Mat weight_t = weights[i].t();
                cv::Mat delta_err_1 = delta_err[i];
                delta_err[i] = dx.mul((weights[i + 1]).t() * delta_err[i + 1]);
            }
        }
    }

注意

需要注意的就是計算的時候輸出層和隱藏層的計算公式是不一樣的。

另一個需要注意的就是......難道大家沒覺得本系列文章的代碼看起來非常友好嗎

至此,神經網絡最核心的部分已經實現完畢。剩下的就是想想該如何訓練了。這個時候你如果願意的話仍然可以寫一個小程序進行幾次前向傳播和反向傳播。還是那句話,鬼知道我在能進行傳播之前到底花了多長時間調試!


三、神經網絡的訓練和測試

前言

在之前的文章中我們已經實現了Net類的設計和前向傳播和反向傳播的過程。可以說神經網絡的核心的部分已經完成。接下來就是應用層面了。

要想利用神經網絡解決實際的問題,比如說進行手寫數字的識別,需要用神經網絡對樣本進行迭代訓練,訓練完成之後,訓練得到的模型是好是壞,我們需要對之進行測試。這正是我們現在需要實現的部分的內容。

完善後的Net類

需要知道的是現在的Net類已經相對完善了,爲了實現接下來的功能,不論是成員變量還是成員函數都變得更加的豐富。現在的Net類看起來是下面的樣子:

   class Net
    {
    public:
        //Integer vector specifying the number of neurons in each layer including the input and output layers.
        std::vector<int> layer_neuron_num;
        std::string activation_function = "sigmoid";
        double learning_rate; 
        double accuracy = 0.;
        std::vector<double> loss_vec;
        float fine_tune_factor = 1.01;
    protected:
        std::vector<cv::Mat> layer;
        std::vector<cv::Mat> weights;
        std::vector<cv::Mat> bias;
        std::vector<cv::Mat> delta_err;

        cv::Mat output_error;
        cv::Mat target;
        float loss;

    public:
        Net() {};
        ~Net() {};

        //Initialize net:genetate weights matrices、layer matrices and bias matrices
        // bias default all zero
        void initNet(std::vector<int> layer_neuron_num_);

        //Initialise the weights matrices.
        void initWeights(int type = 0, double a = 0., double b = 0.1);

        //Initialise the bias matrices.
        void initBias(cv::Scalar& bias);

        //Forward
        void forward();

        //Forward
        void backward();

        //Train,use loss_threshold
        void train(cv::Mat input, cv::Mat target_, float loss_threshold, bool draw_loss_curve = false);        //Test
        void test(cv::Mat &input, cv::Mat &target_);

        //Predict,just one sample
        int predict_one(cv::Mat &input);

        //Predict,more  than one samples
        std::vector<int> predict(cv::Mat &input);

        //Save model;
        void save(std::string filename);

        //Load model;
        void load(std::string filename);

    protected:
        //initialise the weight matrix.if type =0,Gaussian.else uniform.
        void initWeight(cv::Mat &dst, int type, double a, double b);

        //Activation function
        cv::Mat activationFunction(cv::Mat &x, std::string func_type);

        //Compute delta error
        void deltaError();

        //Update weights
        void updateWeights();
    };

可以看到已經有了訓練的函數train()、測試的函數test(),還有實際應用訓練好的模型的predict()函數,以及保存和加載模型的函數save()和load()。大部分成員變量和成員函數應該還是能夠通過名字就能夠知道其功能的。

訓練

訓練函數train()

本文重點說的是訓練函數train()和測試函數test()。這兩個函數接受輸入(input)和標籤(或稱爲目標值target)作爲輸入參數。其中訓練函數還要接受一個閾值作爲迭代終止條件,最後一個函數可以暫時忽略不計,那是選擇要不要把loss值實時畫出來的標識。

訓練的過程如下:

  1. 接受一個樣本(即一個單列矩陣)作爲輸入,也即神經網絡的第一層;

  2. 進行前向傳播,也即forward()函數做的事情。然後計算loss;

  3. 如果loss值小於設定的閾值loss_threshold,則進行反向傳播更新閾值;

  4. 重複以上過程直到loss小於等於設定的閾值。

train函數的實現如下:

   //Train,use loss_threshold
    void Net::train(cv::Mat input, cv::Mat target_, float loss_threshold, bool draw_loss_curve)
    {
        if (input.empty())
        {
            std::cout << "Input is empty!" << std::endl;
            return;
        }

        std::cout << "Train,begain!" << std::endl;

        cv::Mat sample;
        if (input.rows == (layer[0].rows) && input.cols == 1)
        {
            target = target_;
            sample = input;
            layer[0] = sample;
            forward();
            //backward();
            int num_of_train = 0;
            while (loss > loss_threshold)
            {
                backward();
                forward();
                num_of_train++;
                if (num_of_train % 500 == 0)
                {
                    std::cout << "Train " << num_of_train << " times" << std::endl;
                    std::cout << "Loss: " << loss << std::endl;
                }
            }
            std::cout << std::endl << "Train " << num_of_train << " times" << std::endl;
            std::cout << "Loss: " << loss << std::endl;
            std::cout << "Train sucessfully!" << std::endl;
        }
        else if (input.rows == (layer[0].rows) && input.cols > 1)
        {
            double batch_loss = loss_threshold + 0.01;
            int epoch = 0;
            while (batch_loss > loss_threshold)
            {
                batch_loss = 0.;
                for (int i = 0; i < input.cols; ++i)
                {
                    target = target_.col(i);
                    sample = input.col(i);
                    layer[0] = sample;

                    farward();
                    backward();

                    batch_loss += loss;
                }

                loss_vec.push_back(batch_loss);

                if (loss_vec.size() >= 2 && draw_loss_curve)
                {
                    draw_curve(board, loss_vec);
                }
                epoch++;
                if (epoch % output_interval == 0)
                {
                    std::cout << "Number of epoch: " << epoch << std::endl;
                    std::cout << "Loss sum: " << batch_loss << std::endl;
                }
                if (epoch % 100 == 0)
                {
                    learning_rate *= fine_tune_factor;
                }
            }
            std::cout << std::endl << "Number of epoch: " << epoch << std::endl;
            std::cout << "Loss sum: " << batch_loss << std::endl;
            std::cout << "Train sucessfully!" << std::endl;
        }
        else
        {
            std::cout << "Rows of input don't cv::Match the number of input!" << std::endl;
        }
    }

這裏考慮到了用單個樣本和多個樣本迭代訓練兩種情況。而且還有另一種不用loss閾值作爲迭代終止條件,而是用正確率的train()函數,內容大致相同,此處略去不表。

在經過train()函數的訓練之後,就可以得到一個模型了。所謂模型,可以簡單的認爲就是權值矩陣。簡單的說,可以把神經網絡當成一個超級函數組合,我們姑且認爲這個超級函數就是y = f(x) = ax +b。那麼權值就是a和b。反向傳播的過程是把a和b當成自變量來處理的,不斷調整以得到最優值或逼近最優值。在完成反向傳播之後,訓練得到了參數a和b的最優值,是一個固定值了。這時自變量又變回了x。我們希望a、b最優值作爲已知參數的情況下,對於我們的輸入樣本x,通過神經網絡計算得到的結果y,與實際結果相符合是大概率事件。

測試

測試函數test()

test()函數的作用就是用一組訓練時沒用到的樣本,對訓練得到的模型進行測試,把通過這個模型得到的結果與實際想要的結果進行比較,看正確來說到底是多少,我們希望正確率越多越好。

test()的步驟大致如下幾步:

  1. 用一組樣本逐個輸入神經網絡;

  2. 通過前向傳播得到一個輸出值;

  3. 比較實際輸出與理想輸出,計算正確率。

test()函數的實現如下:

   //Test
    void Net::test(cv::Mat &input, cv::Mat &target_)
    {
        if (input.empty())
        {
            std::cout << "Input is empty!" << std::endl;
            return;
        }
        std::cout << std::endl << "Predict,begain!" << std::endl;

        if (input.rows == (layer[0].rows) && input.cols == 1)
        {
            int predict_number = predict_one(input);

            cv::Point target_maxLoc;
            minMaxLoc(target_, NULL, NULL, NULL, &target_maxLoc, cv::noArray());        
            int target_number = target_maxLoc.y;

            std::cout << "Predict: " << predict_number << std::endl;
            std::cout << "Target:  " << target_number << std::endl;
            std::cout << "Loss: " << loss << std::endl;
        }
        else if (input.rows == (layer[0].rows) && input.cols > 1)
        {
            double loss_sum = 0;
            int right_num = 0;
            cv::Mat sample;
            for (int i = 0; i < input.cols; ++i)
            {
                sample = input.col(i);
                int predict_number = predict_one(sample);
                loss_sum += loss;

                target = target_.col(i);
                cv::Point target_maxLoc;
                minMaxLoc(target, NULL, NULL, NULL, &target_maxLoc, cv::noArray());
                int target_number = target_maxLoc.y;

                std::cout << "Test sample: " << i << "   " << "Predict: " << predict_number << std::endl;
                std::cout << "Test sample: " << i << "   " << "Target:  " << target_number << std::endl << std::endl;
                if (predict_number == target_number)
                {
                    right_num++;
                }
            }
            accuracy = (double)right_num / input.cols;
            std::cout << "Loss sum: " << loss_sum << std::endl;
            std::cout << "accuracy: " << accuracy << std::endl;
        }
        else
        {
            std::cout << "Rows of input don't cv::Match the number of input!" << std::endl;
            return;
        }
    }

這裏在進行前向傳播的時候不是直接調用forward()函數,而是調用了predict_one()函數,predict函數的作用是給定一個輸入,給出想要的輸出值。其中包含了對forward()函數的調用。還有就是對於神經網絡的輸出進行解析,轉換成看起來比較方便的數值。

這一篇的內容已經夠多了,我決定把對於predict部分的解釋放到下一篇。


四、神經網絡的預測和輸入輸出解析

神經網絡的預測

預測函數predict()

在上一篇的結尾提到了神經網絡的預測函數predict(),說道predict調用了forward函數並進行了輸出的解析,輸出我們看起來比較方便的值。

predict()函數和predict_one()函數的區別相信很容易從名字看出來,那就是輸入一個樣本得到一個輸出和輸出一組樣本得到一組輸出的區別,顯然predict()應該是循環調用predict_one()實現的。所以我們先看一下predict_one()的代碼:

   int Net::predict_one(cv::Mat &input)
    {
        if (input.empty())
        {
            std::cout << "Input is empty!" << std::endl;
            return -1;
        }

        if (input.rows == (layer[0].rows) && input.cols == 1)
        {
            layer[0] = input;
            forward();

            cv::Mat layer_out = layer[layer.size() - 1];
            cv::Point predict_maxLoc;

            minMaxLoc(layer_out, NULL, NULL, NULL, &predict_maxLoc, cv::noArray());
            return predict_maxLoc.y;
        }
        else
        {
            std::cout << "Please give one sample alone and ensure input.rows = layer[0].rows" << std::endl;
            return -1;
        }
    }

可以在第二個if語句裏面看到最主要的內容就是兩行:分別是前面提到的前向傳播和輸出解析。

           forward();
            ...
            ...
            minMaxLoc(layer_out, NULL, NULL, NULL, &predict_maxLoc, cv::noArray());

前向傳播得到最後一層輸出層layer_out,然後從layer_out中提取最大值的位置,最後輸出位置的y座標。

輸出的組織方式和解析

輸出方式的組織和解析

之所以這麼做,就不得不提一下標籤或者叫目標值在這裏是以何種形式存在的。以激活函數是sigmoid函數爲例,sigmoid函數是把實數映射到[0,1]區間,所以顯然最後的輸出y:0<=y<=1。如果激活函數是tanh函數,則輸出區間是[-1,1]。如果是sigmoid,而且我們要進行手寫字體識別的話,需要識別的數字一共有十個:0-9。顯然我們的神經網絡沒有辦法輸出大於1的值,所以也就不能直觀的用0-9幾個數字來作爲神經網絡的實際目標值或者稱之爲標籤。

這裏採用的方案是,把輸出層設置爲一個單列十行的矩陣,標籤是幾就把第幾行的元素設置爲1,其餘都設爲0。由於編程中一般都是從0開始作爲第一位的,所以位置與0-9的數字正好一一對應。我們到時候只需要找到輸出最大值所在的位置,也就知道了輸出是幾。

當然上面說的是激活函數是sigmoid的情況。如果是tanh函數呢?那還是是幾就把第幾位設爲1,而其他位置全部設爲-1即可。

如果是ReLU函數呢?ReLU函數的至於是0到正無窮。所以我們可以標籤是幾就把第幾位設爲幾,其他爲全設爲0。最後都是找到最大值的位置即可。

這些都是需要根據激活函數來定。代碼中是調用opencv的minMaxLoc()函數來尋找矩陣中最大值的位置。

輸入的組織方式和讀取方法

輸入的組織方式和讀取方法

既然說到了輸出的組織方式,那就順便也提一下輸入的組織方式。生成神經網絡的時候,每一層都是用一個單列矩陣來表示的。顯然第一層輸入層就是一個單列矩陣。所以在對數據進行預處理的過程中,這裏就是把輸入樣本和標籤一列一列地排列起來,作爲矩陣存儲。標籤矩陣的第一列即是第一列樣本的標籤。以此類推。

值得一提的是,輸入的數值全部歸一化到0-1之間。

由於這裏的數值都是以float類型保存的,這種數值的矩陣Mat不能直接保存爲圖片格式,所以這裏我選擇了把預處理之後的樣本矩陣和標籤矩陣保存到xml文檔中。在源碼中可以找到把原始的csv文件轉換成xml文件的代碼。在csv2xml.cpp中。而我轉換完成的MNIST的部分數據保存在data文件夾中,可以在Github上找到。

在opencv中xml的讀寫非常方便,如下代碼是寫入數據:

   string filename = "input_label.xml";
    FileStorage fs(filename, FileStorage::WRITE);
    fs << "input" << input_normalized;
    fs << "target" << target_; // Write cv::Mat
    fs.release();

而讀取代碼的一樣簡單明瞭:

       cv::FileStorage fs;
        fs.open(filename, cv::FileStorage::READ);
        cv::Mat input_, target_;
        fs["input"] >> input_;
        fs["target"] >> target_;
        fs.release();

讀取樣本和標籤

我寫了一個函數get_input_label()從xml文件中從指定的列開始提取一定數目的樣本和標籤。默認從第0列開始讀取,只是上面函數的簡單封裝:

   //Get sample_number samples in XML file,from the start column. 
    void get_input_label(std::string filename, cv::Mat& input, cv::Mat& label, int sample_num, int start)
    {
        cv::FileStorage fs;
        fs.open(filename, cv::FileStorage::READ);
        cv::Mat input_, target_;
        fs["input"] >> input_;
        fs["target"] >> target_;
        fs.release();
        input = input_(cv::Rect(start, 0, sample_num, input_.rows));
        label = target_(cv::Rect(start, 0, sample_num, target_.rows));
    }

至此其實已經可以開始實踐,訓練神經網絡識別手寫數字了。只有一部分還沒有提到,那就是模型的保存和加載。下一篇將會講模型的save和load,然後就可以實際開始進行例子的訓練了。等不及的小夥伴可以直接去github下載完整的程序開始跑了。


五、模型的保存和加載及實時畫出輸出曲線

模型的保存和加載

模型的保存與加載

在我們完成對神經網絡的訓練之後,一般要把模型保存起來。不然每次使用模型之前都需要先訓練模型,對於data hungry的神經網絡來說,視數據多寡和精度要求高低,訓練一次的時間從幾分鐘到數百個小時不等,這是任何人都耗不起的。把訓練好的模型保存下來,當需要使用它的時候,只需要加載就行了。

現在需要考慮的一個問題是,保存模型的時候,我們到底要保存哪些東西?

之前有提到,可以簡單的認爲權值矩陣就是所謂模型。所以權值矩陣一定要保存。除此之外呢?不能忘記的一點是,我們保存模型是爲了加載後能使用模型。顯然要求加載模型之後,輸入一個或一組樣本就能開始前向運算和反向傳播。這也就是說,之前實現的時候,forward()之前需要的,這裏也都需要,只是權值不是隨意初始化了,而是用訓練好的權值矩陣代替。基於以上考慮,最終決定要保存的內容如下4個:

  1. layer_neuron_num,各層神經元數目,這是生成神經網絡需要的唯一參數。

  2. weights,神經網絡初始化之後需要用訓練好的權值矩陣去初始化權值。

  3. activation_function,使用神經網絡的過程其實就是前向計算的過程,顯然需要知道激活函數是什麼。

  4. learning_rate,如果要在現有模型的基礎上繼續訓練以得到更好的模型,更新權值的時候需要用到這個函數。

再決定了需要保存的內容之後,接下來就是實現了,仍然是保存爲xml格式,上一篇已經提到了保存和加載xml是多麼的方便:

   //Save model;
    void Net::save(std::string filename)
    {
        cv::FileStorage model(filename, cv::FileStorage::WRITE);
        model << "layer_neuron_num" << layer_neuron_num;
        model << "learning_rate" << learning_rate;
        model << "activation_function" << activation_function;

        for (int i = 0; i < weights.size(); i++)
        {
            std::string weight_name = "weight_" + std::to_string(i);
            model << weight_name << weights[i];
        }
        model.release();
    }

    //Load model;
    void Net::load(std::string filename)
    {
        cv::FileStorage fs;
        fs.open(filename, cv::FileStorage::READ);
        cv::Mat input_, target_;

        fs["layer_neuron_num"] >> layer_neuron_num;
        initNet(layer_neuron_num);

        for (int i = 0; i < weights.size(); i++)
        {
            std::string weight_name = "weight_" + std::to_string(i);
            fs[weight_name] >> weights[i];
        }

        fs["learning_rate"] >> learning_rate;
        fs["activation_function"] >> activation_function;

        fs.release();
    }

實時畫出輸出曲線

實時畫曲線

有時候我們爲了有一個直觀的觀察,我們希望能夠是實時的用一個曲線來表示輸出誤差。但是沒有找到滿意的程序可用,於是自己就寫了一個非常簡單的函數,用來實時輸出訓練時的loss。理想的輸出大概像下面這樣:

爲什麼說是理想的輸出呢,因爲一般來說誤差很小,可能曲線直接就是從左下角開始的,上面一大片都沒有用到。不過已經能夠看出loss的大致走向了。

這個函數的實現其實就是先畫倆個作爲座標用的直線,然後把相鄰點用直線連接起來:

   //Draw loss curve
    void draw_curve(cv::Mat& board, std::vector<double> points)
    {
        cv::Mat board_(620, 1000, CV_8UC3, cv::Scalar::all(200));
        board = board_;
        cv::line(board, cv::Point(0, 550), cv::Point(1000, 550), cv::Scalar(0, 0, 0), 2);
        cv::line(board, cv::Point(50, 0), cv::Point(50, 1000), cv::Scalar(0, 0, 0), 2);

        for (size_t i = 0; i < points.size() - 1; i++)
        {
            cv::Point pt1(50 + i * 2, (int)(548 - points[i]));
            cv::Point pt2(50 + i * 2 + 1, (int)(548 - points[i + 1]));
            cv::line(board, pt1, pt2, cv::Scalar(0, 0, 255), 2);
            if (i >= 1000)
            {
                return;
            }
        }
        cv::imshow("Loss", board);
        cv::waitKey(10);
    }

至此,神經網絡已經實現完成了。完整的代碼可以在Github上找到。

下一步,就是要用編寫的神經網絡,用實際樣本開始訓練了。下一篇,用MNIST數據訓練神經網絡。

相關閱讀

【BP神經網絡】使用反向傳播訓練多層神經網絡的原則+“常見問題”

深度學習與神經網絡概述

MNIST手寫數字數據集格式,如何讀取MNIST數據集?

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