深度神经网络实战: MNIST手写数字识别

此前,已经成功实现了只使用C++而不使用第三方库,来实现一个深度神经网络来解决多分类逻辑回归问题。可以见C++实现深度神经网络解决多分类逻辑回归问题

如今考虑一点更实战化的改进和应用尝试: 神经网络入门问题——手写数字识别。使用的数字当然是来自经典的MNIST数据集(Mixed National Institute of Standards and Technology database)了。


更多激活函数

下面是C++实现深度神经网络解决多分类逻辑回归问题里最终实现的深度神经网络的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// NN.h v1.0
#include <bits/stdc++.h>
using namespace std;
#define db double
#define range(x) (x).begin(),(x).end()
#define sigmoid(x) (1./(1.+exp(-(x))))
#define CE(a,b) ((a)*log(max(b,1e-12))+(1-(a))*log(max(1-(b),1e-12)))
typedef vector<db> Arr; mt19937 _rd(1061109589); auto real_rd = std::bind(std::normal_distribution<db>(0.,.01),mt19937(998244353));
struct NeuralNetwork{
struct Layer{
Arr h,x,d; vector<Arr> w; int n;
inline Layer(int _n, int c){
n = _n; x = h = d = Arr(n+1); x[0] = 1; w.clear();
for(int i = 0, j; i <= n; w[i][0] = 0.5, i++)
for(w.push_back(Arr(c+1)), j = 1; j <= c; w[i][j] = real_rd(), j++);
}
}; vector<Layer> L; Arr Out, Ans, In; int N, K; db eta; inline void AddLayer(int n){++N; L.emplace_back(n,K); K = n;}
inline NeuralNetwork(vector<int> Num, db _eta){L.clear(); eta = _eta; N = -1; K = 0; for(int&j:Num) AddLayer(j);}
inline void Run(){
for(int i = (copy(range(In),++L[0].x.begin()),1), j; i <= N; i++)
for(j = 1; j <= L[i].n; L[i].x[j]=sigmoid(L[i].h[j]=inner_product(range(L[i-1].x),L[i].w[j].begin(),0.)), j++);
Out.resize(K); for(int j = 0; j < K; Out[j] = exp(L[N].h[j+1]), j++); db S = accumulate(range(Out),0.); for(db&j:Out) j /= S;
}
inline int Judge(){return (int)(max_element(range(Out))-Out.begin());}
inline db CELoss(){db S = 0; for(int j = 0; j < K; S += CE(Ans[j],Out[j]), j++); return -S;}
inline void Adjust(){
for(int j = 1; j <= K; L[N].d[j] = -eta*(Out[j-1]-Ans[j-1]), j++);
for(int i = N-1, j, k; i; i--) for(fill(range(L[i].d),0.), j = 1; j <= L[i+1].n; j++)
for(k = 0; k <= L[i].n; L[i].d[k] += L[i+1].d[j]*L[i+1].w[j][k]*L[i].x[k]*(1-L[i].x[k]), k++);
for(int i = N, j, k; i; i--) for(j = 1; j <= L[i].n; j++)
for(k = 0; k <= L[i-1].n; L[i].w[j][k] += L[i].d[j]*L[i-1].x[k], k++);
}
};
#undef db
#undef range


首先要对之前1.0版本的深度神经网络核心代码做一个改进: 上述代码中只实现了以交叉熵为损失函数,$\text{sigmoid}$为激活函数的深度神经网络,现在加入$\tanh,\text{ReLU}$以及$\text{LeakyReLU}$函数。

会发现不同函数只有两部分要改: 一部分是正向传播的时候替换函数,另一部分是反向传播的时候求导函数。那么只需要求出每个函数的导函数即可。

因此可以使用函数指针匿名函数的语法特性来快速实现更换函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// NN.h v2.0
#include <bits/stdc++.h>
using namespace std;
#define db double
#define range(x) (x).begin(),(x).end()
#define CE(a,b) ((a)*log(max(b,1e-12))+(1-(a))*log(max(1-(b),1e-12)))
typedef vector<db> Arr; mt19937 _rd(1061109589); auto real_rd = std::bind(std::normal_distribution<db>(0.,.01),mt19937(998244353));
struct NeuralNetwork{
struct Layer{
Arr h,x,d; vector<Arr> w; int n;
inline Layer(int _n, int c){
n = _n; x = h = d = Arr(n+1); x[0] = 1; w.clear();
for(int i = 0, j; i <= n; w[i][0] = 0.5, i++)
for(w.push_back(Arr(c+1)), j = 1; j <= c; w[i][j] = real_rd(), j++);
}
}; vector<Layer> L; Arr Out, Ans, In; int N, K; db eta; db (*f)(db), (*df)(db); string F;
inline void AddLayer(int n){++N; L.emplace_back(n,K); K = n;}
inline NeuralNetwork(vector<int> Num, db _eta, string _="sigmoid"){
L.clear(); eta = _eta; N = -1; K = 0; for(int&j:Num) AddLayer(j); F = _; if(Num.size()) In = Arr(Num[0]), Ans = Out = Arr(K);
if(F=="sigmoid") f = [](db x){return 1./(1.+exp(-x));}, df = [](db y){return y*(1-y);};
else if(F=="tanh") f = [](db x){db t = exp(x); return (t-1./t)/(t+1./t);}, df = [](db y){return 1-y*y;};
else if(F=="ReLU") f = [](db x){return max(x,0.);}, df = [](db y){return y>0.?1:0.;};
else if(F=="LeakyReLU") f = [](db x){return x>0.?x:.01*x;}, df = [](db y){return y>0.?1:.01;};
else exit(-1);
}
inline void Run(){
for(int i = (copy(range(In),++L[0].x.begin()),1), j; i <= N; i++)
for(j = 1; j <= L[i].n; L[i].x[j]=f(L[i].h[j]=inner_product(range(L[i-1].x),L[i].w[j].begin(),0.)), j++);
Out.resize(K); for(int j = 0; j < K; Out[j] = exp(L[N].h[j+1]), j++); db S = accumulate(range(Out),0.); for(db&j:Out) j /= S;
}
inline int Judge(){return (int)(max_element(range(Out))-Out.begin());}
inline db CELoss(){db S = 0; for(int j = 0; j < K; S += CE(Ans[j],Out[j]), j++); return -S;}
inline void Adjust(){
for(int j = 1; j <= K; L[N].d[j] = -eta*(Out[j-1]-Ans[j-1]), j++);
for(int i = N-1, j, k; i; i--) for(fill(range(L[i].d),0.), j = 1; j <= L[i+1].n; j++)
for(k = 0; k <= L[i].n; L[i].d[k] += L[i+1].d[j]*L[i+1].w[j][k]*df(L[i].x[k]), k++);
for(int i = N, j, k; i; i--) for(j = 1; j <= L[i].n; j++)
for(k = 0; k <= L[i-1].n; L[i].w[j][k] += L[i].d[j]*L[i-1].x[k], k++);
}
};
#undef db
#undef range


实现的时候由于推式子方便以及常数优化,选择用df(y)来表示$y’$而不是df(x)

值得一提的是,在$\tanh(x) = \frac{e^x-e^{-x}}{e^x+e^{-x}}$中,如果$x$非常大会导致inf/inf = nan发生。可以选择分子分母同除$e^x$,$\tanh(x) = \frac{1-e^{-2x}}{1+e^{-2x}}$。当然,正常情况下都要保证输入层的节点$x \in [0,1]$,那么整个神经网络都满足$x \in [0,1]$,不会出现问题。


手写数字识别

问题: 给定一副$28 \times 28$的灰度像素图(每个像素有$0 \sim 255$的灰度),求这张图上写的是哪个个位数?


MNIST数据集

THE MNIST DATABASE of handwritten digits可以下载数据集。

下载得到的数据分为训练数据,训练答案,测试数据,测试答案。每个数据是一个二进制文件,下文以十六进制文件方式打开。

训练数据(train-images-idx3-ubyte.gz)以十六进制打开如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0000 0803 0000 ea60 0000 001c 0000 001c
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0312 1212 7e88 af1a
a6ff f77f 0000 0000 0000 0000 0000 0000
1e24 5e9a aafd fdfd fdfd e1ac fdf2 c340
0000 0000 0000 0000 0000 0031 eefd fdfd
...

前八位数字0000 0803没有用。然后八位数字0000 ea60是十六进制的60000,表示有n = 60000张图像。然后0000 001c是十六进制的28表示图像高度是28像素,下一个宽度同理。
接下来有$n \times 784$对两位数字,每两位表示一个像素,每连续$784$对表示一个图像。

训练答案(train-labels.idx1-ubyte)以十六进制打开如下:

1
2
3
4
5
6
0000 0801 0000 ea60 0500 0401 0902 0103
0104 0305 0306 0107 0208 0609 0400 0901
0102 0403 0207 0308 0609 0005 0600 0706
0108 0709 0309 0805 0903 0300 0704 0908
0009 0401 0404 0600 0405 0601 0000 0107
...

前八位数字0000 0801没有用。然后八位数字0000 ea60表示有n = 60000张图像。接下来$n$对两位数字,每两位都为0?形式,表示对应图像的答案。如05表示第一张图像是手写的数字5

测试数据和测试答案内容同理。

如何读取?

在C++中使用freopen打开文件后,可以直接用getchar()一次读取两位十六进制数返回一个$0 \sim 255$的数字。注意一定要用"rb"方式打开文件,不然会出现一些问题: 例如getchar()读入1a的时候,返回26,而26正好是Ctrl+Z的代码,表示一个流的结束,然后此后的getchar()就会永远返回-1表示EOF。而用"rb"方式打开文件就没有问题。


于是根据如上,用宏函数来实现一个读入一对数据(数据+答案)的方式:

1
2
3
4
5
6
7
8
9
#define gc getchar
struct img{unsigned char p[28][28]; int l;}I[60005]; int n;
#define Read(FILE_NAME)\
freopen((string(FILE_NAME)+string("-images.idx3-ubyte")).c_str(),"rb",stdin);\
gc();gc();gc();gc();gc();gc();n=gc()<<8;n+=gc();gc();gc();gc();gc();gc();gc();gc();gc();\
for(rint i = 1, x, y; i <= n; i++) for(x = 0; x < 28; x++) for(y = 0; y < 28; I[i].p[x][y] = gc(), y++); fclose(stdin);\
freopen((string(FILE_NAME)+string("-labels.idx1-ubyte")).c_str(),"rb",stdin);\
gc();gc();gc();gc();gc();gc();n=gc()<<8;n+=gc();\
for(rint i = 1; i <= n; I[i].l=gc(), i++); fclose(stdin);


训练神经网络

直接跑就好了。

1
2
3
4
5
6
7
8
9
10
11
void Run(NeuralNetwork &NN){
Read("train"); T0 = clock();
for(int i = 1, _, x, y; i <= n; fill(NN.Ans.begin(),NN.Ans.end(),0), NN.Ans[I[i].l] = 1, NN.Run(), NN.Adjust(), i++)
for(_ = x = 0; x < 28; x++) for(y = 0; y < 28; NN.In[_++] = I[i].p[x][y]/256., y++);
T = clock(); printf("Train Time = %.6lf\n",(db)(T-T0)/CLOCKS_PER_SEC);
Read("t10k"); T0 = clock(); Loss = 0; Score = 0;
for(int i = 1, _, x, y; i <= n; fill(NN.Ans.begin(),NN.Ans.end(),0), NN.Ans[I[i].l] = 1, NN.Run(), Loss += NN.CELoss(), Score += NN.Judge()==I[i].l, i++)
for(_ = x = 0; x < 28; x++) for(y = 0; y < 28; NN.In[_++] = I[i].p[x][y]/256., y++);
T = clock(); printf("Test Time = %.6lf\n",(db)(T-T0)/CLOCKS_PER_SEC);
printf("Average Loss = %.6lf Correct = %.2lf%%\n\n",Loss/n,Score/n*100);
}

这里采用四种激活函数分别运行尝试: 输入层784节点,隐层16节点,输出层10节点,然后枚举学习率调参。调参发现$\text{LeakyReLU}$在适宜参数下效果最好,于是最后多给了一点机会(隐层128节点):

1
2
3
4
5
6
7
int main(){
NeuralNetwork T1({784,16,10},.01,"sigmoid"); Run(T1);
NeuralNetwork T2({784,16,10},.01,"ReLU"); Run(T2);
NeuralNetwork T3({784,16,10},.01,"tanh"); Run(T3);
NeuralNetwork T4({784,128,10},.01,"LeakyReLU"); Run(T4);
return 0;
}

运行结果如图:

不过实践中,感谢dalao室友@core_exe提醒: 将每个测试点多跑几次,可以强化训练效果。

1
2
3
4
5
6
7
8
9
10
11
12
void Run(NeuralNetwork &NN){
Read("train"); T0 = clock();
for(int j = 0; j < 10; j++)//!!!!!!!!!!
for(int i = 1, _, x, y; i <= n; fill(NN.Ans.begin(),NN.Ans.end(),0), NN.Ans[I[i].l] = 1, NN.Run(), NN.Adjust(), i++)
for(_ = x = 0; x < 28; x++) for(y = 0; y < 28; NN.In[_++] = I[i].p[x][y]/256., y++);
T = clock(); printf("Train Time = %.6lf\n",(db)(T-T0)/CLOCKS_PER_SEC);
Read("t10k"); T0 = clock(); Loss = 0; Score = 0;
for(int i = 1, _, x, y; i <= n; fill(NN.Ans.begin(),NN.Ans.end(),0), NN.Ans[I[i].l] = 1, NN.Run(), Loss += NN.CELoss(), Score += NN.Judge()==I[i].l, i++)
for(_ = x = 0; x < 28; x++) for(y = 0; y < 28; NN.In[_++] = I[i].p[x][y]/256., y++);
T = clock(); printf("Test Time = %.6lf\n",(db)(T-T0)/CLOCKS_PER_SEC);
printf("Average Loss = %.6lf Correct = %.2lf%%\n\n",Loss/n,Score/n*100);
}

注意是多跑几次而不是每次将一个点反复跑,这样可以避免最后几个点的训练效果占比重过大。

同样运行结果如图:

当然这里调参非常粗略,如果精细调参可以得到最优结果,具体结果也受到随机影响。目前得到的最高正确率是$97.37\%$,已经相当令人满意了。


完整训练代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <bits/stdc++.h>
#include "NN.h"
using namespace std;
#define db double
#define gc getchar
#define rint register int
#define Read(FILE_NAME)\
freopen((string(FILE_NAME)+string("-images.idx3-ubyte")).c_str(),"rb",stdin);\
gc();gc();gc();gc();gc();gc();n=gc()<<8;n+=gc();gc();gc();gc();gc();gc();gc();gc();gc();\
for(rint i = 1, x, y; i <= n; i++) for(x = 0; x < 28; x++) for(y = 0; y < 28; I[i].p[x][y] = gc(), y++); fclose(stdin);\
freopen((string(FILE_NAME)+string("-labels.idx1-ubyte")).c_str(),"rb",stdin);\
gc();gc();gc();gc();gc();gc();n=gc()<<8;n+=gc();\
for(rint i = 1; i <= n; I[i].l=gc(), i++); fclose(stdin);
struct img{unsigned char p[28][28]; int l;}I[60005]; int n, T0, T; db Loss, Score;
void Run(NeuralNetwork &NN){
Read("train"); T0 = clock();
for(int j = 0; j < 10; j++)
for(int i = 1, _, x, y; i <= n; fill(NN.Ans.begin(),NN.Ans.end(),0), NN.Ans[I[i].l] = 1, NN.Run(), NN.Adjust(), i++)
for(_ = x = 0; x < 28; x++) for(y = 0; y < 28; NN.In[_++] = I[i].p[x][y]/256., y++);
T = clock(); printf("Train Time = %.6lf\n",(db)(T-T0)/CLOCKS_PER_SEC);
Read("t10k"); T0 = clock(); Loss = 0; Score = 0;
for(int i = 1, _, x, y; i <= n; fill(NN.Ans.begin(),NN.Ans.end(),0), NN.Ans[I[i].l] = 1, NN.Run(), Loss += NN.CELoss(), Score += NN.Judge()==I[i].l, i++)
for(_ = x = 0; x < 28; x++) for(y = 0; y < 28; NN.In[_++] = I[i].p[x][y]/256., y++);
T = clock(); printf("Test Time = %.6lf\n",(db)(T-T0)/CLOCKS_PER_SEC);
printf("Average Loss = %.6lf Correct = %.2lf%%\n\n",Loss/n,Score/n*100);
}
int main(){
NeuralNetwork T1({784,16,10},.01,"sigmoid"); Run(T1);
NeuralNetwork T2({784,16,10},.01,"ReLU"); Run(T2);
NeuralNetwork T3({784,16,10},.01,"tanh"); Run(T3);
NeuralNetwork T4({784,128,10},.01,"LeakyReLU"); Run(T4);
return 0;
}


扫描二维码即可在手机上查看这篇文章,或者转发二维码来分享这篇文章:


文章作者: Magolor
文章链接: https://magolor.cn/2019/08/29/2019-08-29-blog-01/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Magolor
扫描二维码在手机上查看或转发二维码以分享Magolor的博客