卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别

本文是本系列的第8篇文章,也是终结篇章。在本文中我们主要讲5层卷积神经网络参数更新和训练的代码实现,以及如何使用5层卷积神经网络来实现0~9的手写数字图像的识别。

首先还是列出本系列其它博文的超链接,方便读者跳转查阅:

1. 卷积神经网络原理及其C++/Opencv实现(1)

2. 卷积神经网络原理及其C++/Opencv实现(2)

3. 卷积神经网络原理及其C++/Opencv实现(3)

4. 卷积神经网络原理及其C++/Opencv实现(4)—误反向传播法

5. 卷积神经网络原理及其C++/Opencv实现(5)—参数更新

6. 卷积神经网络原理及其C++/Opencv实现(6)—前向传播代码实现

7. 卷积神经网络原理及其C++/Opencv实现(7)—误反向传播代码实现

下面我们还是分别讲5层网络其余部分的代码实现吧~

卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别_第1张图片

1. 训练过程中参数的更新

(1) O5层参数更新

本层需要更新的参数为192*10个权重值,以及10个偏置值。更新公式如下,其中α为学习率,Y为Softmax函数的输出,t为标签,x为Affine层的输入,0≤i<10,0≤j<192。

卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别_第2张图片

卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别_第3张图片

本层的参数更新代码实现如下:

void update_full_para(vector inputData, CNNOpts opts, OutLayer &O)
{
  int outSize_r = inputData[0].rows;
  int outSize_c = inputData[0].cols;
  Mat OinData(1, outSize_r*outSize_c*inputData.size(), CV_32FC1);
  for (int i = 0; i < inputData.size(); i++)   //12通道
  {
    for (int r = 0; r < outSize_r; r++)  //4
    {
      for (int c = 0; c < outSize_c; c++)   //4
      {
        //把本层输入的12个4*4图像展开成长度为192的一维向量
        OinData.ptr(0)[i*outSize_r*outSize_c + r*outSize_c + c] = inputData[i].ptr(r)[c];
      }
    }
  }


  for (int j = 0; j < O.outputNum; j++)  //10通道
  {
    for (int i = 0; i < O.inputNum; i++)  //192通道
    {
      //w = w - α。dE/dw
      O.wData.ptr(j)[i] = O.wData.ptr(j)[i] - opts.alpha*O.d.ptr(0)[j] * OinData.ptr(0)[i];
    }
    //b = b - α。dE/db
    O.basicData.ptr(0)[j] = O.basicData.ptr(0)[j] - opts.alpha*O.d.ptr(0)[j];
  }
}

(2) C3层参数更新

本层需要更新的参数为6*12个5*5卷积核,以及12个偏置值。更新公式如下,其中α为学习率,k为本层的卷积核,b为本层的偏置,YS2为S2层的输出,dC3为C3层的局部梯度,sum为求矩阵中所有元素和的操作,0≤i<12,0≤j<6。dC3的计算可参考上篇博文:

卷积神经网络原理及其C++/Opencv实现(7)—误反向传播代码实现

卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别_第4张图片

本层的参数更新代码实现如下:

void update_cov_para(vector inputData, CNNOpts opts, CovLayer &C)
{
  for (int i = 0; i < C.outChannels; i++)   //6通道
  {
    for (int j = 0; j < C.inChannels; j++)   //1通道
    {
      Mat Cdk = correlation(C.d[i], inputData[j], valid);  //计算YS2*dC3
      Cdk = Cdk*(-opts.alpha);   //矩阵乘以系数-α.dE/dk
      C.mapData[j][i] = C.mapData[j][i] + Cdk;   //计算k = k - α.dE/dk
    }


    float d_sum = (float)cv::sum(C.d[i])[0];   //计算sum(dC3),这里有6个24*24的d,6个偏置b,一个偏置b对应一个24*24矩阵d的所有元素和
    C.basicData.ptr(0)[i] = C.basicData.ptr(0)[i] - opts.alpha*d_sum;  //计算b = b - α.dE/db
  }
}

(3) C1层参数更新

本层需要更新的参数为6个5*5卷积核,以及6个偏置值。更新公式如下,其中α为学习率,k为本层的卷积核,b为本层的偏置,IC1为C1层的28*28输入图像(也即5层网络的一张28*28输入图像),dC1为C1层的局部梯度,sum为求矩阵中所有元素和的操作,0≤i<6。dC1的计算也可参考上篇博文。

卷积神经网络原理及其C++/Opencv实现(7)—误反向传播代码实现

卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别_第5张图片

卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别_第6张图片

由于本层的参数更新代码操作与C3层一样,只是输入、输出参数不一样而已,因此本层的参数更新也可以调用上述update_cov_para函数来实现。

(4) 所有参数的更新

综上,C1、C3、O5层的参数更新代码如下,其中inputdata为5层网络的单张28*28手写数字图像。

void cnnapplygrads(CNN &cnn, CNNOpts opts, Mat inputData) // 更新权重
{
  vector input_tmp;
  input_tmp.push_back(inputData);


  update_cov_para(input_tmp, opts, cnn.C1);


  update_cov_para(cnn.S2.y, opts, cnn.C3);


  update_full_para(cnn.S4.y, opts, cnn.O5);
}

2. 训练过程中参数的清零

由于训练是一个多轮迭代的过程,且训练时会有参数累加的操作,下一轮训练开始之前需要将参数清零,否则累加操作会出问题。

//清零卷积层的参数
void clear_cov_mid_para(CovLayer &C)
{
  int row = C.d[0].rows;
  int col = C.d[0].cols;
  for (int j = 0; j < C.outChannels; j++)
  {
    for (int r = 0; r < row; r++)
    {
      for (int c = 0; c < col; c++)
      {
        C.d[j].ptr(r)[c] = 0.0;
        C.v[j].ptr(r)[c] = 0.0;
        C.y[j].ptr(r)[c] = 0.0;
      }
    }
  }
}


//清零池化层的参数
void clear_pool_mid_para(PoolLayer &S)
{
  int row = S.d[0].rows;
  int col = S.d[0].cols;
  for (int j = 0; j < S.outChannels; j++)
  {
    for (int r = 0; r < row; r++)
    {
      for (int c = 0; c < col; c++)
      {
        S.d[j].ptr(r)[c] = 0.0;
        S.y[j].ptr(r)[c] = 0.0;
      }
    }
  }
}


//清零输出层的参数
void clear_out_mid_para(OutLayer &O)
{
  for (int j = 0; j < O.outputNum; j++)
  {
    O.d.ptr(0)[j] = 0.0;
    O.v.ptr(0)[j] = 0.0;
    O.y.ptr(0)[j] = 0.0;
  }
}


//调用上述函数实现5层网络的参数清零
void cnnclear(CNN &cnn)
{
  clear_cov_mid_para(cnn.C1);
  clear_pool_mid_para(cnn.S2);
  clear_cov_mid_para(cnn.C3);
  clear_pool_mid_para(cnn.S4);
  clear_out_mid_para(cnn.O5);
}

2. 手写数字图像的读取

从网上下载的手写数字图像,是gz压缩文件,需要将其解压:

解压gz文件之后得到以下4个对应文件,其中train-images.idx3-ubyte为训练数据文件,train-labels.idx1-ubyte为训练数据的标签文件,t10k-images.idx3-ubyte为测试数据文件,t10k-labels.idx1-ubyte为测试数据的标签文件。

(1) 训练数据文件与测试数据文件的格式如下图所示:

文件格式:该区域的4个字节数据组成一个int数据,如果该int数据为2051,表示该文件是图像文件,如果是2049表示该文件是文本文件。因此对于训练数据和测试数据文件,本区域的值为2051。

图像总数:该区域的4个字节数据组成一个int数据,该int数据为文件中包含的图像总数。

图像行数:该区域的4个字节数据组成一个int数据,该int数据为每张图像的行数。

图像列数:该区域的4个字节数据组成一个int数据,该int数据为每张图像的列数。

需要注意的是,如果运行程序的处理器为英特尔处理器,需要把读到的4个字节数据按相反顺序排序,再组成int数据,比如首先我们读取到的int数据由byte0、byte1、byte2、byte3这4个字节数据组成(<<为左移运算):

d=(byte3<< 24)) + (byte2<< 16) + (byte1<< 8) + byte0

那么需要把4个数据按照相反顺序排序,重新组成int数据,这个重组的int数据才是我们想要的数据:

d'=(byte0<< 24)) + (byte1<< 16) + (byte2<< 8) + byte3

根据上述格式,训练数据文件与测试数据文件的读取代码如下,我们将同一个文件中的多张图像都读成Opencv的Mat格式,然后将多个Mat格式图像保存进vector数组中:

//将int数据中的4个字节数据按相反顺序重新排列,重组成一个int数据
int ReverseInt(int i)   
{  
  unsigned char ch1, ch2, ch3, ch4;  
  ch1 = i & 0xff;  
  ch2 = (i >> 8) & 0xff;
  ch3 = (i >> 16) & 0xff;
  ch4 = (i >> 24) & 0xff;


  return ((int)(ch1 << 24)) + ((int)(ch2 << 16)) + ((int)(ch3 << 8)) + (int)ch4;  
}




vector read_Img_to_Mat(const char* filename) 
{
  FILE  *fp = NULL;
  fp = fopen(filename, "rb");
  if (fp == NULL)
    printf("open file failed\n");
  assert(fp);


  int magic_number = 0;
  int number_of_images = 0;
  int n_rows = 0;
  int n_cols = 0;
  
  fread(&magic_number, sizeof(int), 1, fp);   //从文件中读取sizeof(int) 个字符到 &magic_number  
  magic_number = ReverseInt(magic_number);


  fread(&number_of_images, sizeof(int), 1, fp);   //获取训练或测试image的个数number_of_images 
  number_of_images = ReverseInt(number_of_images);
  
  fread(&n_rows, sizeof(int), 1, fp);   //获取训练或测试图像的高度Heigh  
  n_rows = ReverseInt(n_rows);
  
  fread(&n_cols, sizeof(int), 1, fp);   //获取训练或测试图像的宽度Width  
  n_cols = ReverseInt(n_cols);


 
  //获取第i幅图像,保存到vec中 
  int i, r, c;


  int img_size = n_rows*n_cols;
  vector img_list;
  for (i = 0; i < number_of_images; ++i)
  {
    Mat tmp(n_rows, n_cols, CV_8UC1);
    fread(tmp.data, sizeof(uchar), img_size, fp);  //读取一张图像
    tmp.convertTo(tmp, CV_32F);   //将图像转换为float数据
    tmp = tmp / 255.0;   //将数据转换成0~1的数据
    img_list.push_back(tmp.clone());
  }


  fclose(fp);
  return img_list;
}

(2) 标签文件的格式如下图所示:

卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别_第7张图片

文件格式:该区域的4个字节数据组成一个int数据,如果该int数据为2051,表示该文件是图像文件,如果是2049表示该文件是文本文件。标签文件属于文本文件,因此本区域的值为2049。

图像总数:该区域的4个字节数据组成一个int数据,该int数据为文件中包含的图像总数。

如果运行程序的处理器为英特尔处理器,同样需要把读到的4个字节数据按相反顺序排序,再重组成int数据。

每张图像表示的数字为0~9中的一个数字,因此图像标签就是0~9之中的一个数字,且该数字与图像表示的数字相对应。

由于卷积神经网络使用的是"one-hot"码,因此我们需要把0~9的标签数字转换为"one-hot"码:

0-->1 0 0 0 0 0 0 0 0 0

1-->0 1 0 0 0 0 0 0 0 0

2-->0 0 1 0 0 0 0 0 0 0

3-->0 0 0 1 0 0 0 0 0 0

4-->0 0 0 0 1 0 0 0 0 0

5-->0 0 0 0 0 1 0 0 0 0

6-->0 0 0 0 0 0 1 0 0 0

7-->0 0 0 0 0 0 0 1 0 0

8-->0 0 0 0 0 0 0 0 1 0

9-->0 0 0 0 0 0 0 0 0 1

根据上述格式,标签文件的读取代码如下,我们将同一个标签文件中的每个标签数字转换成"one-hot"码,然后再将"one-hot"码保存到一个1行10列的Mat结构当中,再将Mat保存到vector中:

vector read_Lable_to_Mat(const char* filename)
{
  FILE  *fp = NULL;
  fp = fopen(filename, "rb");
  if (fp == NULL)
    printf("open file failed\n");
  assert(fp);


  int magic_number = 0;
  int number_of_labels = 0;
  int label_long = 10;


  
  fread(&magic_number, sizeof(int), 1, fp);   //从文件中读取sizeof(magic_number) 个字符到 &magic_number  
  magic_number = ReverseInt(magic_number);


  fread(&number_of_labels, sizeof(int), 1, fp);   //获取训练或测试image的个数number_of_images 
  number_of_labels = ReverseInt(number_of_labels);


  int i, l;


  vector label_list;
  
  for (i = 0; i < number_of_labels; ++i)
  {
    
    Mat tmp = Mat::zeros(1, label_long, CV_32FC1);
    unsigned char temp = 0;
    fread(&temp, sizeof(unsigned char), 1, fp);
    tmp.ptr(0)[(int)temp] = 1.0;  //将0~9的数字转换成one-hot码
    label_list.push_back(tmp.clone());   
  }


  fclose(fp);
  return label_list;
}

3. 训练过程的实现代码

void cnntrain(CNN &cnn, vector inputData, vector outputData, CNNOpts opts, int trainNum)
{
  // 学习训练误差曲线,记录交叉熵误差函数的值
  cnn.L = Mat(1, trainNum, CV_32FC1).clone();
  for (int e = 0;  e < opts.numepochs; e++)   //opts.numepochs表示需要训练次数
  {
    for (int n = 0; n < trainNum; n++)   //trainNum表示由多少张图片,训练完这些图片相当于完成一次训练
    {
      //学习率递减0.03~0.001
      opts.alpha = 0.03 - 0.029*n / (trainNum - 1);    
                     
      cnnff(cnn, inputData[n]);   // 前向传播 
      cnnbp(cnn, outputData[n]); // 后向传播
      cnnapplygrads(cnn, opts, inputData[n]); // 更新参数
      
      // 计算交叉熵误差函数的值
      float l = 0.0;
      for (int i = 0; i < cnn.O5.outputNum; i++)
      {
        l = l - outputData[n].ptr(0)[i] * log(cnn.O5.y.ptr(0)[i]);
      }
      cnn.L.ptr(0)[n] = l;
      
      cnnclear(cnn);   //清零参数


      printf("n=%d, f=%f, α=%f\n", n, cnn.L.ptr(0)[n], opts.alpha);
    }
  }
}

4. 对手写数字图像分类的实现代码

//1行n列的向量
int vecmaxIndex(Mat vec)  //返回向量最大数的序号
{
  int veclength = vec.cols;
  float maxnum = -1.0;
  int maxIndex = 0;


  float *p = vec.ptr(0);
  for(int i=0; i < veclength; i++)
  {
    if(maxnum < p[i])
    {
      maxnum = p[i];
      maxIndex = i;
    }
  }
  return maxIndex;
}


//测试函数
float cnntest(CNN cnn, vector inputData, vector outputData)
{
  int incorrectnum = 0;  //错误预测的数目
  for (int i = 0; i < inputData.size(); i++)  //inputData.size()为测试图像的总数
  {
    cnnff(cnn, inputData[i]);   //前向传播
    //检查神经网络输出的最大概率的序号是否等于标签中1值的序号,如果等于则表示分类成功
    if (vecmaxIndex(cnn.O5.y) != vecmaxIndex(outputData[i]))
    {
      incorrectnum++;
      printf("i = %d, 识别失败\n", i);
    }
    else
    {
      printf("i = %d, 识别成功\n", i);
    }
    cnnclear(cnn);
  }
  printf("incorrectnum=%d\n", incorrectnum);
  printf("inputData.size()=%d\n", inputData.size());
  return (float)incorrectnum / (float)inputData.size();
}

5. 总体测试的实现代码

以下函数就是5层网络的测试代码,在mian函数中调用。

void minst_cnn_test(void)
{
  vector traindata_list;
  vector traindata_label;
  vector testdata_list;
  vector testdata_label;
  
  //读取训练数据标签
  traindata_label = read_Lable_to_Mat("Minst/train-labels.idx1-ubyte");
  //读取训练数据
  traindata_list = read_Img_to_Mat("Minst/train-images.idx3-ubyte");
  //读取测试数据标签
  testdata_label = read_Lable_to_Mat("Minst/t10k-labels.idx1-ubyte");
  //读取测试数据
  testdata_list = read_Img_to_Mat("Minst/t10k-images.idx3-ubyte");
  
  int train_num = traindata_list.size();
  int test_num = testdata_list.size();
  int outSize = testdata_label[0].cols;


  int row = traindata_list[0].rows;
  int col = traindata_list[0].cols;


  CNNOpts opts;
  opts.numepochs = 1;
  opts.alpha = 0.03;   //学习率初始值
  int trainNum = 60000;


  CNN cnn;
  cnnsetup(cnn, row, col, outSize);   //cnn初始化
  cnntrain(cnn, traindata_list, traindata_label, opts, train_num);  //训练


  float success = cnntest(cnn, testdata_list, testdata_label);   //分类
  printf("success=%f\n", 1 - success);   //打印分类的成功率
}

运行以上函数对5层网络进行手写数字图像的训练和分类测试,得到的结果如下,对10000张图像进行分类,分类失败170张,准确率达到98.3%,还是相当高的。

本系列的基于VS2015与Opencv3.4.1的完整代码工程,读者可在以下网址下载:

https://download.csdn.net/download/shandianfengfan/16392246

好了,本系列的文章就更新到这里啦,有人可能会说我重复造轮子没有意义,我倒不这么认为,因为这是一个学习的过程,自己去实现一遍会加深自己的理解。在深度理解之后,再去使用别人现成的深度学习框架,也会顺手得多。接下来的文章我们就不自己实现网络了,而是使用别人现成的深度学习框架,我们把主要精力放在网络的构建与训练模型的构建上面。

欢迎扫码关注以下微信公众号,接下来会不定时更新更加精彩的内容噢~

卷积神经网络原理及其C++/Opencv实现(8)—手写数字图像识别_第8张图片

你可能感兴趣的:(深度学习,神经网络,深度学习,计算机视觉,opencv,人工智能)