【AI入门】C++构建BP神经网络,并实现手写数字识别

BP神经网络的基本原理

参考资料:机器学习(西瓜书) - 周志华

如图所示,一个简单的BP网络包含输入层,隐藏层和输出层。

给定输入值\(x_1,x_2,...,x_n\),隐藏层和输出层的输出分别值为。

\[b_i=f(\sum\limits_{j}v_{ji}x_{j}-\gamma_i)\\ \]

\[y_i=f(\sum\limits_{j}w_{ij}b_j-\theta_i) \]

\(v,w\)为连接权,\(\gamma,\theta\)为阈值。\(f\)​为激活函数,一般取

\[f(x)=sigmoid(x)=\frac{1}{1+e^{-x}} \]

它有很好的性质,为求解梯度提供了便利

\[f'(x)=f(x)(1-f(x)) \]

对训练例\((x_1,y_1)...(x_n,y_n)\),假定神经网络的输出为\(\hat{y_1}...\hat{y_n}\),则定义网络的均方误差为

\[E=\frac{1}{2}\sum\limits_j(\hat{y_j}-y_j)^2 \]

我们可以根据网络的均方误差,采取梯度下降的方法,调整连接权与阈值的参数值,例如

\[v_{ij}\leftarrow v_{ij}+\Delta v \]

为尽量减小均方误差,\(\Delta v\)应取均方误差关于\(v_{ij}\)的负梯度方向。

我们令

\[\Delta v=-\eta\frac{\partial E}{\partial v_{ij}} \]

其中\(\eta\in(0,1)\)称为学习率,控制着算法每一轮迭代中的更新步长,若太大容易震荡,太小收敛速度又会过慢。

这里略去使用链式求导法则求解梯度的具体过程,仅给出结果。

\[g_i=\hat{y_i}(1-\hat{y_i})(y_i-\hat{y_i}) \]

\[e_i=b_i(1-b_i)\sum\limits_{j}w_{ij}g_j \]

\[\Delta w_{ij}=\eta g_jb_i \]

\[\Delta \theta_i=-\eta g_i \]

\[\Delta v_{ij}=\eta e_jx_i \]

\[\Delta\gamma_i = -\eta e_i \]

从输出数据计算输出数据和误差的过程称为前向传播。而调整权值和阈值则从输出到输入的方向进行,称为反向传播(back propagation)。

BP神经网络的C++实现

算法的流程图如下

根据西瓜书上的推导与流程,实现了BP神经网络的基本框架

const int NX = 784, NB = 500, NY = 10;//输入层X,隐藏层B,输出层Y节点数
const double eta = 0.1;//学习率

struct Node {
    double val{};
    double bias{};
    vector<double> weight;
} x[NX], b[NB], y[NY];//输入层X,隐藏层B,输出层Y
double g[NY], e[NB];//用于反向传播
double trainx[NX], trainy[NY];//训练数据



double sigmoid(double x) { return 1.0 / (1.0 + exp(-x)); }

double get_rand_weight() { return rand() % 10 / 5.0 - 1; } //生成(-1,1)随机数
double get_rand_bias() { return rand() % 10 / 500.0 - 0.01; } //生成(-0.01,0.01)随机数


//网络初始化
void init() {
    for (int i = 0; i < NX; i++) {
        //x[i].bias = get_rand_bias();
        for (int j = 0; j < NB; j++) {
            x[i].weight.push_back(get_rand_weight());
        }
    }
    for (int i = 0; i < NB; i++) {
        b[i].bias = get_rand_bias();
        for (int j = 0; j < NY; j++) {
            b[i].weight.push_back(get_rand_weight());
        }
    }
    for (int i = 0; i < NY; i++) {
        y[i].bias = get_rand_bias();
    }
};

//前向传播
void forward() {
    //首先需要清空隐藏层和输出层原有的非参数数据!!!
    for (int i = 0; i < NB; i++) b[i].val = 0;
    for (int i = 0; i < NY; i++) y[i].val = 0;
    //输入层读取数据
    for (int i = 0; i < NX; i++) x[i].val = trainx[i];
    //输入层->隐藏层
    for (int i = 0; i < NX; i++) {
        for (int j = 0; j < NB; j++) {
            b[j].val += x[i].val * x[i].weight[j];
        }
    }
    //隐藏层求值
    for (int i = 0; i < NB; i++) {
        b[i].val = sigmoid(b[i].val - b[i].bias);
    }
    //隐藏层->输出层
    for (int i = 0; i < NB; i++) {
        for (int j = 0; j < NY; j++) {
            y[j].val += b[i].val * b[i].weight[j];
        }
    }
    //输出层求值
    for (int i = 0; i < NY; i++) {
        y[i].val = sigmoid(y[i].val - y[i].bias);
    }
}

//反向传播
void back() {
    //计算g和e
    for (int i = 0; i < NY; i++) {
        g[i] = y[i].val * (1 - y[i].val) * (trainy[i] - y[i].val);
    }

    for (int i = 0; i < NB; i++) {
        double res = 0;
        for (int j = 0; j < NY; j++) {
            res += b[i].weight[j] * g[j];
        }
        e[i] = b[i].val * (1 - b[i].val) * res;
    }

    //更新参数w, theta, v, gamma
    for (int i = 0; i < NB; i++)
        for (int j = 0; j < NY; j++)
            b[i].weight[j] += eta * b[i].val * g[j];
    for (int i = 0; i < NY; i++)
        y[i].bias -= eta * g[i];
    for (int i = 0; i < NX; i++)
        for (int j = 0; j < NB; j++)
            x[i].weight[j] += eta * x[i].val * e[j];
    for (int i = 0; i < NB; i++)
        b[i].bias -= eta * e[i];
}

将BP神经网络应用于手写数字识别

数据处理过程参考了这篇博客https://www.cnblogs.com/alphainf/p/16395313.html

使用Minst数据集,可以从官网http://yann.lecun.com/exdb/mnist/获取。

训练集包含60000组28*28的手写数字灰度图像,以及每个图像对应的正确数字0~9。

我们可以将28*28=784个像素的灰度值标准化为(0,1)的实数,作为输入层的数据。

输出层的节点数设为10,\(y_0\)\(y_9\)分别表示输入图像为0~9的概率。

隐藏层节点数量可以自行设点,这里取500。

FILE *fImg, *fAns;
fImg=fopen("train-images.idx3-ubyte","rb");
fseek(fImg, 16, SEEK_SET);
fAns=fopen("train-labels.idx1-ubyte","rb");
fseek(fAns, 8, SEEK_SET);

//读入一张新的图片
//除了前16字节,接下来的信息都是一张一张的图片
//每张图片大小为28*28 = 784 = NX,每个char表示该像素对应的灰度,范围为0至255
unsigned char img[NX], ans;
fread(img, 1, NX, fImg);
for (int i = 0; i < NX; i++) trainx[i] = (double)img[i] / 255.0;
//读入该图片对应的答案
//除了前8字节,第k个字节对应第k张图片的正确答案
fread(&ans,1,1,fAns);
for(int i = 0; i < NY; i++) trainy[i] = (i == ans) ? 1 : 0;

下面这段代码,可以粗略地将图像和训练过程可视化。

for (int i = 0; i < 28; i++) {
        for (int j = 0; j < 28; j++) {
            if (trainx[i * 28 + j] != 0) cout << 'X';
            else cout << ' ';
        }
        cout << endl;
    }
    cout << "Test Case #" << Case <<", result is " << res << ", answer is " << (int)ans << endl;

完整代码如下

#include <iostream>
#include <cstdlib>
#include <cmath>
#include <vector>
using namespace std;


const int NX = 784, NB = 500, NY = 10;//输入层X,隐藏层B,输出层Y节点数
const double eta = 0.1;//学习率

struct Node {
    double val{};
    double bias{};
    vector<double> weight;
} x[NX], b[NB], y[NY];//输入层X,隐藏层B,输出层Y
double g[NY], e[NB];//用于反向传播
double trainx[NX], trainy[NY];//训练数据



double sigmoid(double x) { return 1.0 / (1.0 + exp(-x)); }

double get_rand_weight() { return rand() % 10 / 5.0 - 1; } //生成(-1,1)随机数
double get_rand_bias() { return rand() % 10 / 500.0 - 0.01; } //生成(-0.01,0.01)随机数


//网络初始化
void init() {
    for (int i = 0; i < NX; i++) {
        //x[i].bias = get_rand_bias();
        for (int j = 0; j < NB; j++) {
            x[i].weight.push_back(get_rand_weight());
        }
    }
    for (int i = 0; i < NB; i++) {
        b[i].bias = get_rand_bias();
        for (int j = 0; j < NY; j++) {
            b[i].weight.push_back(get_rand_weight());
        }
    }
    for (int i = 0; i < NY; i++) {
        y[i].bias = get_rand_bias();
    }
};

//前向传播
void forward() {
    //首先需要清空隐藏层和输出层原有的非参数数据!!!
    for (int i = 0; i < NB; i++) b[i].val = 0;
    for (int i = 0; i < NY; i++) y[i].val = 0;
    //输入层读取数据
    for (int i = 0; i < NX; i++) x[i].val = trainx[i];
    //输入层->隐藏层
    for (int i = 0; i < NX; i++) {
        for (int j = 0; j < NB; j++) {
            b[j].val += x[i].val * x[i].weight[j];
        }
    }
    //隐藏层求值
    for (int i = 0; i < NB; i++) {
        b[i].val = sigmoid(b[i].val - b[i].bias);
    }
    //隐藏层->输出层
    for (int i = 0; i < NB; i++) {
        for (int j = 0; j < NY; j++) {
            y[j].val += b[i].val * b[i].weight[j];
        }
    }
    //输出层求值
    for (int i = 0; i < NY; i++) {
        y[i].val = sigmoid(y[i].val - y[i].bias);
    }
}

//反向传播
void back() {

    //计算g和e
    for (int i = 0; i < NY; i++) {
        g[i] = y[i].val * (1 - y[i].val) * (trainy[i] - y[i].val);
    }

    for (int i = 0; i < NB; i++) {
        double res = 0;
        for (int j = 0; j < NY; j++) {
            res += b[i].weight[j] * g[j];
        }
        e[i] = b[i].val * (1 - b[i].val) * res;
    }

    //更新w, theta, v, gamma
    for (int i = 0; i < NB; i++)
        for (int j = 0; j < NY; j++)
            b[i].weight[j] += eta * b[i].val * g[j];
    for (int i = 0; i < NY; i++)
        y[i].bias -= eta * g[i];
    for (int i = 0; i < NX; i++)
        for (int j = 0; j < NB; j++)
            x[i].weight[j] += eta * x[i].val * e[j];
    for (int i = 0; i < NB; i++)
        b[i].bias -= eta * e[i];
}


FILE *fImg, *fAns;
int result[1000000] = {0}; //每次训练的结果,正确为1,错误为0
void train(int Case) {
    //读入一张新的图片
    //除了前16字节,接下来的信息都是一张一张的图片
    //每张图片大小为28*28 = 784 = NX,每个char表示该像素对应的灰度,范围为0至255
    unsigned char img[NX], ans;
    fread(img, 1, NX, fImg);
    for (int i = 0; i < NX; i++) trainx[i] = (double)img[i] / 255.0;
    //读入该图片对应的答案
    //除了前8字节,第k个字节对应第k张图片的正确答案
    fread(&ans,1,1,fAns);
    for(int i = 0; i < NY; i++) trainy[i] = (i == ans) ? 1 : 0;

    //前向传播,计算答案是否正确
    forward();
    int res = 0;
    for (int i = 0; i <= 9; i++)
        if (y[i].val > y[res].val)
            res = i;
    result[Case] = (res == ans) ? 1 : 0;
/*
    for (int i = 0; i < 28; i++) {
        for (int j = 0; j < 28; j++) {
            if (trainx[i * 28 + j] != 0) cout << 'X';
            else cout << ' ';
        }
        cout << endl;
    }
    cout << "Test Case #" << Case <<", result is " << res << ", answer is " << (int)ans << endl;

*/
    //反向传播
    back();

    //输出最近100局的正确率
    int P = 100, cnt = 0;
    if(Case % P == 0) {
        for(int i = 0; i < P; i++)
            cnt += result[Case - i];
        cout << Case << " " << cnt << endl;
    }
}

int main() {
    fImg=fopen("train-images.idx3-ubyte","rb");
    fseek(fImg, 16, SEEK_SET);
    fAns=fopen("train-labels.idx1-ubyte","rb");
    fseek(fAns, 8, SEEK_SET);
    freopen("result.txt", "w", stdout);
    init();
    for (int Case = 1; Case <= 60000; Case++) {
        train(Case);
    }
    return 0;
}

程序每训练100次,会输出过去一百次测试的正确率,在60000次测试后,平均正确率达到85%左右

训练次数-正确率曲线如图所示

坑点

前向传播之前要把节点原有的输入值清零。

存在的疑惑

一开始将阈值和连接权用同样的(-1,1)随机数生成器,发现网络会失效,只会输出同一个答案。尝试将阈值调小后才提升了准确率。

代码中对于阈值的反向传播处理可能存在问题。

posted @ 2023-02-17 17:22  _vv123  阅读(758)  评论(0编辑  收藏  举报