两万字长文详解:如何用C++从零实现神经网络

2022 年 7 月 24 日 极市平台
↑ 点击 蓝字  关注极市平台

作者丨冰不语
来源丨CVPy
编辑丨极市平台

极市导读

 

本文通过介绍神经网络基础和分步解释代码详细讲述了如何用C++编写神经网络,并且在最后给出实战手写数字识别的全过程指导。 >>加入极市CV技术交流群,走在计算机视觉的最前沿

一、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 =  0double 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],  00.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( 00.0.01);
    net.initBias(Scalar( 0.05));
    getchar();
     return  0;
}

二、前向传播与反向传播

前言

前一章节中,大部分还是比较简单的。因为最重要事情就是生成各种矩阵并初始化。神经网络中的重点和核心就是本文的内容——前向和反向传播两大计算过程。每层的前向传播分别包含加权求和(卷积?)的线性运算和激活函数的非线性运算。反向传播主要是用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()的代码是什么样的:
  
  
    
     //Backward
     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]);
            }
        }
    }

注意

需要注意的就是计算的时候输出层和隐藏层的计算公式是不一样的。另一个需要注意的就是......难道大家没觉得本系列文章的代码看起来非常友好吗至此,神经网络最核心的部分已经实现完毕。剩下的就是想想该如何训练了。这个时候你如果愿意的话仍然可以写一个小程序进行几次前向传播和反向传播。还是那句话,鬼知道我在能进行传播之前到底花了多长时间调试!

三、神经网络的训练和测试

前言

在之前的章节中我们已经实现了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 = 0double 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<intpredict(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_,  NULLNULLNULL, &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,  NULLNULLNULL, &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调用了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,  NULLNULLNULL, &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;
        }
    }            forward();
            ...
            ...
            minMaxLoc(layer_out,  NULLNULLNULL, &predict_maxLoc, cv::noArray());            forward();
            ...
            ...
            minMaxLoc(layer_out,  NULLNULLNULL, &predict_maxLoc, cv::noArray());
可以在第二个if语句里面看到最主要的内容就是两行:分别是前面提到的前向传播和输出解析。
  
  
    
            forward();
            ...
            ...
            minMaxLoc(layer_out,  NULLNULLNULL, &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_(6201000, CV_8UC3, cv::Scalar::all(200));
        board = board_;
        cv::line(board, cv::Point( 0550), cv::Point( 1000550), cv::Scalar( 000),  2);
        cv::line(board, cv::Point( 500), cv::Point( 501000), cv::Scalar( 000),  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( 00255),  2);
             if (i >=  1000)
            {
                 return;
            }
        }
        cv::imshow( "Loss", board);
        cv::waitKey( 10);
    }
至此,神经网络已经实现完成了。完整的代码可以在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( 11784, data.rows -  1)).t();
    Mat label_ = data(Rect( 011, data.rows -  1));
     Mat target_(10, input_.cols, CV_32F, Scalar::all(0.));

     Mat digit(2828, CV_32FC1);
    Mat col_0 = input_.col( 3);
     float label0 = label_.at< float>( 30);
     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( 0010000, input_normalized.rows));
    Mat target_1000 = target_(Rect( 0010000, 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. 从input_label_1000.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( 00.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,  200800);

     //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左右,也是可以接受的。

完整的代码可以在Github上找到,源码链接如下:

https://github.com/LiuXiaolong19920720/simple_net



公众号后台回复“ECCV2022”获取论文分类资源下载~

△点击卡片关注极市平台,获取 最新CV干货

极市干货
算法项目: CV工业项目落地实战大航海 | 附极力值攻略 极市打榜|目标检测算法上新!(年均分成5万)
实操教程 Pytorch - 弹性训练原理分析《CUDA C 编程指南》导读
极视角动态: 极视角作为重点项目入选「2022青岛十大资本青睐企业」榜单! 极视角发布EQP激励计划,招募优质算法团队展开多维度生态合作!


点击阅读原文进入CV社区

收获更多技术干货

登录查看更多
0

相关内容

【2022新书】Python数据分析第三版,579页pdf
专知会员服务
244+阅读 · 2022年8月31日
【干货书】深度学习数学:理解神经网络,347页pdf
专知会员服务
262+阅读 · 2022年7月3日
《深度学习》圣经花书的数学推导、原理与Python代码实现
算法与数据结构Python,369页pdf
专知会员服务
162+阅读 · 2020年3月4日
TensorFlow Lite指南实战《TensorFlow Lite A primer》,附48页PPT
专知会员服务
69+阅读 · 2020年1月17日
【新书】傻瓜式入门深度学习,371页pdf
专知会员服务
188+阅读 · 2019年12月28日
实操教程|Pytorch转ONNX详解
极市平台
3+阅读 · 2022年4月4日
实践教程|卷积神经网络 C++ 从零开始实现
极市平台
2+阅读 · 2022年2月24日
PyTorch 深度剖析:如何保存和加载PyTorch模型?
极市平台
0+阅读 · 2021年11月28日
详解PyTorch编译并调用自定义CUDA算子的三种方式
极市平台
0+阅读 · 2021年11月6日
keras系列︱深度学习五款常用的已训练模型
数据挖掘入门与实战
10+阅读 · 2018年3月27日
tensorflow系列笔记:流程,概念和代码解析
北京思腾合力科技有限公司
30+阅读 · 2017年11月11日
国家自然科学基金
0+阅读 · 2014年12月31日
国家自然科学基金
5+阅读 · 2014年12月31日
国家自然科学基金
0+阅读 · 2013年12月31日
国家自然科学基金
7+阅读 · 2012年12月31日
国家自然科学基金
2+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2009年12月31日
国家自然科学基金
2+阅读 · 2009年12月31日
国家自然科学基金
0+阅读 · 2009年12月31日
Arxiv
0+阅读 · 2022年9月19日
Arxiv
0+阅读 · 2022年9月17日
Learning with Local Gradients at the Edge
Arxiv
0+阅读 · 2022年9月16日
Arxiv
0+阅读 · 2022年9月15日
A Modern Introduction to Online Learning
Arxiv
20+阅读 · 2019年12月31日
VIP会员
相关VIP内容
【2022新书】Python数据分析第三版,579页pdf
专知会员服务
244+阅读 · 2022年8月31日
【干货书】深度学习数学:理解神经网络,347页pdf
专知会员服务
262+阅读 · 2022年7月3日
《深度学习》圣经花书的数学推导、原理与Python代码实现
算法与数据结构Python,369页pdf
专知会员服务
162+阅读 · 2020年3月4日
TensorFlow Lite指南实战《TensorFlow Lite A primer》,附48页PPT
专知会员服务
69+阅读 · 2020年1月17日
【新书】傻瓜式入门深度学习,371页pdf
专知会员服务
188+阅读 · 2019年12月28日
相关资讯
实操教程|Pytorch转ONNX详解
极市平台
3+阅读 · 2022年4月4日
实践教程|卷积神经网络 C++ 从零开始实现
极市平台
2+阅读 · 2022年2月24日
PyTorch 深度剖析:如何保存和加载PyTorch模型?
极市平台
0+阅读 · 2021年11月28日
详解PyTorch编译并调用自定义CUDA算子的三种方式
极市平台
0+阅读 · 2021年11月6日
keras系列︱深度学习五款常用的已训练模型
数据挖掘入门与实战
10+阅读 · 2018年3月27日
tensorflow系列笔记:流程,概念和代码解析
北京思腾合力科技有限公司
30+阅读 · 2017年11月11日
相关基金
国家自然科学基金
0+阅读 · 2014年12月31日
国家自然科学基金
5+阅读 · 2014年12月31日
国家自然科学基金
0+阅读 · 2013年12月31日
国家自然科学基金
7+阅读 · 2012年12月31日
国家自然科学基金
2+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2009年12月31日
国家自然科学基金
2+阅读 · 2009年12月31日
国家自然科学基金
0+阅读 · 2009年12月31日
Top
微信扫码咨询专知VIP会员