【转】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数据集?

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 酷酷鲨 设计师:CSDN官方博客 返回首页