C++從零實現神經網絡(收藏版:兩萬字長文)

infinitycoder 發佈 2024-05-09T20:27:36.858025+00:00

元旦期間,CVPy連載神經網絡系列的同時舉行送書活動,不料大家對於書籍的熱情高漲,以至於大部分人只顧參加送書活動而忽略了文章的主內容。

文章來源:公眾號CVPy

原文連結:https://mp.weixin.qq.com/s/ZiDlILntJaAqQCpXkDP0rg


前言

元旦期間,CVPy連載神經網絡系列的同時舉行送書活動,不料大家對於書籍的熱情高漲,以至於大部分人只顧參加送書活動而忽略了文章的主內容。而且考慮到元旦假期期間的分散發布,可能導致一部分人未能看到全部內容。故而把系列六篇整合之後發布,方便感興趣的人查閱收藏。


長文預警:共22727字

建議:收藏後找合適時間閱讀。

一、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;
}

親測沒有問題。

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

源碼


源碼連結

所有的代碼都已經託管在Github上面,感興趣的可以去下載查看。歡迎提意見。

公眾號後台回復「神經網絡」可得源碼連結地址。


二、前向傳播與反向傳播


前言

前一篇文章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()更新權值,也就是用下圖中的公式更新權值。

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

就看下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]);
            }
        }
    }


注意

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

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

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


源碼連結

所有的代碼都已經託管在Github上面,感興趣的可以去下載查看。歡迎提意見。

公眾號後台回復「神經網絡」可得源碼連結地址。


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


前言

在之前的文章中我們已經實現了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部分的解釋放到下一篇。


源碼連結

所有的代碼都已經託管在Github上面,感興趣的可以去下載查看。歡迎提意見。

公眾號後台回復「神經網絡」可得源碼連結地址。

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

神經網絡的預測


預測函數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下載完整的程序開始跑了。


源碼連結

所有的代碼都已經託管在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數據訓練神經網絡。


源碼連結

所有的代碼都已經託管在Github上面,感興趣的可以去下載查看。歡迎提意見。

公眾號後台回復「神經網絡」可得源碼連結地址。

六、實戰手寫數字識別

之前的五篇博客講述的內容應該覆蓋了如何編寫神經網絡的大部分內容,在經過之前的一系列努力之後,終於可以開始實戰了。試試寫出來的神經網絡怎麼樣吧。

數據準備


MNIST數據集

有人說MNIST手寫數字識別是機器學習領域的Hello World,所以我這一次也是從手寫字體識別開始。我是從Kaggle找的手寫數字識別的數據集。數據已經被保存為csv格式,相對比較方便讀取。

數據集包含了數字0-9是個數字的灰度圖。但是這個灰度圖是展開過的。展開之前都是28x28的圖像,展開後成為1x784的一行。csv文件中,每一行有785個元素,第一個元素是數字標籤,後面的784個元素分別排列著展開後的184個像素。看起來像下面這樣:

也許你已經看到了第一列0-9的標籤,但是會疑惑為啥像素值全是0,那是因為這裡能顯示出來的,甚至不足28x28圖像的一行。而數字一般應該在圖像中心位置,所以邊緣位置當然是啥也沒有,往後滑動就能看到非零像素值了。像下面這樣:

這裡需要注意到的是,像素值的範圍是0-255。一般在數據預處理階段都會歸一化,全部除以255,把值轉換到0-1之間。

csv文件中包含42000個樣本,這麼多樣本,對於我七年前買的4000元級別的破筆記本來說,單單是讀取一次都得半天,更不要提拿這麼多樣本去疊代訓練了,簡直是噩夢(兼論一個苦逼的學生幾年能掙到換電腦的錢!)。所以我只是提取了前1000個樣本,然後把歸一化後的樣本和標籤都保存到一個xml文件中。在前面的一篇博客中已經提到了輸入輸出的組織形式,偷懶直接複製了。

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

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

這裡只是重複一下,這一部分的代碼在csv2xml.cpp中:

#include<opencv2\opencv.hpp>
#include<iostream>
using namespace std;
using namespace cv;


//int csv2xml()
int main()
{
    CvMLData mlData;
    mlData.read_csv("train.csv");//讀取csv文件
    Mat data = cv::Mat(mlData.get_values(), true);
    cout << "Data have been read successfully!" << endl;
    //Mat double_data;
    //data.convertTo(double_data, CV_64F);

    Mat input_ = data(Rect(1, 1, 784, data.rows - 1)).t();
    Mat label_ = data(Rect(0, 1, 1, data.rows - 1));
    Mat target_(10, input_.cols, CV_32F, Scalar::all(0.));

    Mat digit(28, 28, CV_32FC1);
    Mat col_0 = input_.col(3);
    float label0 = label_.at<float>(3, 0);
    cout << label0;
    for (int i = 0; i < 28; i++)
    {
        for (int j = 0; j < 28; j++)
        {
            digit.at<float>(i, j) = col_0.at<float>(i * 28 + j);
        }
    }

    for (int i = 0; i < label_.rows; ++i)
    {
        float label_num = label_.at<float>(i, 0);
        //target_.at<float>(label_num, i) = 1.;
        target_.at<float>(label_num, i) = label_num;
    }

    Mat input_normalized(input_.size(), input_.type());
    for (int i = 0; i < input_.rows; ++i)
    {
        for (int j = 0; j < input_.cols; ++j)
        {
            //if (input_.at<double>(i, j) >= 1.)
            //{
            input_normalized.at<float>(i, j) = input_.at<float>(i, j) / 255.;
            //}
        }
    }

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


    Mat input_1000 = input_normalized(Rect(0, 0, 10000, input_normalized.rows));
    Mat target_1000 = target_(Rect(0, 0, 10000, target_.rows));

    string filename2 = "input_label_0-9_10000.xml";
    FileStorage fs2(filename2, FileStorage::WRITE);

    fs2 << "input" << input_1000;
    fs2 << "target" << target_1000; // Write cv::Mat
    fs2.release();

    return 0;
}

這是我最近用ReLU的時候的代碼,標籤是幾就把第幾位設為幾,其他為全設為0。最後都是找到最大值的位置即可。

在代碼中Mat digit的作用是,檢驗下轉換後的矩陣和標籤是否對應正確這裡是把col(3),也就是第四個樣本從一行重新變成28x28的圖像,看上面的第一張圖的第一列可以看到,第四個樣本的標籤是4。那麼它轉換回來的圖像時什麼樣呢?是下面這樣:

這裡也證明了為啥第一張圖看起來像素全是0。邊緣全黑能不是0嗎?

然後在使用的時候用前面提到過的get_input_label()獲取一定數目的樣本和標籤。

實戰數字識別


實戰

沒想到前面數據處理說了那麼多。。。。

廢話少說,直接說訓練的過程:

  1. 給定每層的神經元數目,初始化神經網絡和權值矩陣
  2. 從inputlabel1000.xml文件中取前800個樣本作為訓練樣本,後200作為測試樣本。
  3. 這是神經網絡的一些參數:訓練時候的終止條件,學習率,激活函數類型
  4. 前800樣本訓練神經網絡,直到滿足loss小於閾值loss_threshold,停止。
  5. 後200樣本測試神經網絡,輸出正確率。
  6. 保存訓練得到的模型。

以sigmoid為激活函數的訓練代碼如下:

#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.5));

    //Get test samples and test samples
    Mat input, label, test_input, test_label;
    int sample_number = 800;
    get_input_label("data/input_label_1000.xml", input, label, sample_number);
    get_input_label("data/input_label_1000.xml", test_input, test_label, 200, 800);

    //Set loss threshold,learning rate and activation function
    float loss_threshold = 0.5;
    net.learning_rate = 0.3;
    net.output_interval = 2;
    net.activation_function = "sigmoid";

    //Train,and draw the loss curve(cause the last parameter is ture) and test the trained net
    net.train(input, label, loss_threshold, true);
    net.test(test_input, test_label);

    //Save the model
    net.save("models/model_sigmoid_800_200.xml");

    getchar();
    return 0;

}

對比前面說的六個過程,代碼應該是很清晰的了。參數output_interval是間隔幾次疊代輸出一次,這設置為疊代兩次輸出一次。

如果按照上面的參數來訓練,正確率是0.855:

在只有800個樣本的情況下,這個正確率我認為還是可以接受的。

如果要直接使用訓練好的樣本,那就更加簡單了:

    //Get test samples and the label is 0--1
    Mat test_input, test_label;
    int sample_number = 200;
    int start_position = 800;
    get_input_label("data/input_label_1000.xml", test_input, test_label, sample_number, start_position);

    //Load the trained net and test.
    Net net;
    net.load("models/model_sigmoid_800_200.xml");
    net.test(test_input, test_label);

    getchar();
    return 0;


如果激活函數是tanh函數,由於tanh函數的值域是[-1,1],所以在訓練的時候要把標籤矩陣稍作改動,需要改動的地方如下:

    //Set loss threshold,learning rate and activation function
    float loss_threshold = 0.2;
    net.learning_rate = 0.02;
    net.output_interval = 2;
    net.activation_function = "tanh";

    //convert label from 0---1 to -1---1,cause tanh function range is [-1,1]
    label = 2 * label - 1;
    test_label = 2 * test_label - 1;


這裡不光改了標籤,還有幾個參數也是需要改以下的,學習率比sigmoid的時候要小一個量級,效果會比較好。這樣訓練出來的正確率大概在0.88左右,也是可以接受的。

關鍵字: