我是从高三开始入门的,一直是用C++来做神经网络。从造轮子开始,到实现模型,到封装模型,再到真正用seq2vec,seq2seq模型训练成功一些小玩意,所有的东西都是自己写的,但是资料都是从网上搜,学习也遇到好多坑。我将会在这里具体说说造轮子的过程。
造轮子之前,必须要广泛查阅资料,自己推导前向传播和反向传播的所有过程,这个过程需要的知识点是偏导数以及链式法则,高中生其实是可以理解的(将偏导数理解为对多元函数中其中一个变量求导,把其他所有量都看作常数即可),不过稍微有点困难,这直接导致了我高三其实学的一知半解。
首先入门实现要从最简单的BP开始,公式等内容不再一一赘述,网上有非常多的资料,个人草稿纸上推导的内容也早就丢失。确认理解后,就可以着手用C++开始写BP的轮子了。
首先是各种激励函数。一开始你需要了解的激励函数可能只有sigmoid,但是随着学习深入,了解其他的激励函数很有必要。
在激励函数方面需要写的是这个函数本身,以及它的导函数。sigmoid和tanh,elu等激励函数需要用到exp()函数,这个函数需要cmath头文件。IDE自选吧,怎么方便怎么来,我高中参加NOIP用Dev,一直到现在我才改用VScode(这个只能算工作台)。sigmoidtanh
tanh其实在cmath库里就有写好的函数,可以直接调用的。ReLU
relu用三目运算符就很舒服。elu和leaky relu也可以这么玩儿。不过我比较推荐leaky relu吧,因为relu很容易出现神经元死亡的情况(神经元无论接受什么样的数据,其总和都是负值,那么这个神经元没有任何输出,在反向过程中也无法进行权值更新,具体看自己推导)。
以上是激励函数的一些例子。下面是写神经元的例子。
写神经元的话,你可以单纯只用二维数组来写,直接做矩阵运算,但是当时我没有接触线性代数,所以用了个非常直观但是后期效率低下的方法。一般用struct
首先定义输入神经元个数,隐藏层神经元个数,输出神经元个数,分别对应InputNum,HideNum,OutputNum。如果你觉得这个写法有点长,可以缩成INUM,HNUM,ONUM等等,怎么舒服怎么写。
然后用struct来分别定义隐藏层神经元,输出层神经元。显然这个用C++的class有点大炮打蚊子的感觉,当然还是那一句,怎么舒服怎么写。如果你觉得我这么写有点重复的内容,可以使用template。用template灵活编写,还可复用
然后bp必须要有的一些变/常量有:
常量const double learningrate,大小自己定
input[INUM]用于存储输入数据(单个batch),expect[ONUM]用于存储期望数据(单个batch)
error,sigma_error,分别用于记录单个batch的误差,以及对所有batch的误差求和,记得初始化为一个很大的值,这是进入训练循环的必要条件。
hidden[HNUM],output[ONUM]这两个是结构体数组,之后在写前向和反向过程中是必要的。
以上所有变量都是全局变量。最基本的需要的函数
TxtCheck()用于检查神经网络的数据是否存在,不存在的话进行INIT()并且输出数据保存为一个文件。INIT()即为初始化函数。INIT函数,第一句先确定随机数种子
INIT()函数首先要先开始生成随机数,srand()加上unsigned (time(NULL))很不错,需要头文件ctime。在下面的语句里,对每个神经元的weight和bia进行初始化,具体内容按照需求来,这个自己可以灵活编辑,我这里只给个大框架。
Datain和Dataout不多说了,显而易见是进行数据的输出和读取用的,用于在几个epoch后保存数据文件,避免下一次打开的时候重复训练。
用ifstream和ofstream进行读取和输出,需要头文件fstream。上图双引号内填文件名(文件在该文件夹下)或者绝对地址(文件在其他地方)。
Mainwork()函数用于读取训练集,首先sigma_error=0,接着依次循环对训练集中每个batch进行处理,读入input和expect中,然后调用Calc()函数进入前向传播阶段,再调用ErrorCalc()进入本次误差计算阶段,error在本次计算中被赋值。然后进行Training()反向传播,接着sigma_error+=error。Mainwork预览
Calc()里面进行前向传播,基本上都是循环,不用我多说了吧?
ErrorCalc()也是如此,同理Training()也是,所以写的这些东西里面,占了绝大多数的语句都是循环语句。*0.5比/2快不少哦,尤其是需要很多步骤的时候
把整个过程都写下来啦。。。这是我个人喜好的写法,要是觉得看不明白或者觉得效率很低,也可以自己写的,反正能实现功能是关键!
(有个小trick上图没体现出来,一般bia的增量是2*learningrate*diff,亲测效果不错)
main里面基本上写一些调用的内容
然后在C++里面,如果数据里出现了Inf,很有可能下面会出现NaN,然后循环会被动停止,给你输出含有一堆NaN的垃圾数据,为了避免这个,C++其实是有一个宏可以检测Inf和NaN的。
isnan()和isinf()是cmath/math.h库里的宏,可以直接调用来判断
到这里,我已经把写简单BP的诀窍说完了,如果你想写深度的,框架其实也差不多。以后我可能会更新的内容里面也基本上都是建立在这个框架体系之上的,希望能有所帮助。即使你可能不太能接受我这种不用矩阵运算的写法,但这也是一个用C++造轮子从零开始的例子,希望能给予你鼓励。
下面贴个代码,当然不能直接复制了用,要自己修改的哦
#include#include#include#define INUM 2#define HNUM 5#define ONUM 2using namespace std;
template
struct neuron
{
double w[NUM],bia,diff;
double in,out;
};
neuron hide[HNUM];
neuron output[ONUM];
const double learningrate=0.1;
double input[INUM];
double expect[ONUM];
double sigma_error=1e8;
double error=1e8;
double sigmoid(double x)
{
return 1.0/(1.0+exp(-x));
}
double diffsigmoid(double x)
{
x=1.0/(1.0+exp(-x));
return x*(1-x);
}
double tanh(double x)
{
return (exp(x)-exp(-x))/(exp(x)+exp(-x));
}
double difftanh(double x)
{
x=tanh(x);
return 1-x*x;
}
double relu(double x)
{
return x>0? x:0;
}
double diffrelu(double x)
{
return x>0? 1:0;
}
void TxtCheck();
void INIT();
void Datain();
void Dataout();
void Mainwork();
void Calc();
void ErrorCalc();
void Training();
int main()
{
int epoch=0;
TxtCheck();
while(sigma_error>0.001)
{
epoch++;
Mainwork();
if(epoch%(一个数)==0)
Dataout();
//也可以写其他操作}
Dataout();
return 0;
}
void INIT()
{
srand(unsigned(time(NULL)));
/*statement*/
return;
}
void Datain()
{
ifstream fin(" ");
fin>>...
fin.close();
}
void Dataout()
{
ofstream fout(" ");
fout<<...>
fout.close();
}
void Mainwork()
{
ifstream fin("数据集");
sigma_error=0;
for(int b=0;b
{
/*处理batch数据,读入input和expect*/
Calc();
ErrorCalc();
Training();
sigma_error+=error;
}
fin.close();
return;
}
void Calc()
{
for(int i=0;i
{
hide[i].in=0;
hide[i].in+=hide[i].bia;
for(int j=0;j
hide[i].in+=input[j]*hide[i].w[j];
hide[i].out=sigmoid(hide[i].in);
}
/*other statements*/
}
void ErrorCalc()
{
double trans;
error=0;
for(int i=0;i
{
trans=output[i].out-expect[i];
error+=trans*trans;
}
error*=0.5;
}
void Training()
{
for(int i=0;i
output[i].diff=(expect[i]-output[i].out)*diffsigmoid(output[i].in);
//负号直接舍弃,因为整个传递过程这里的负号不带来影响//而且在最后更新数据的时候也不需要再*(-1)for(int i=0;i
{
hide[i].diff=0;
for(int j=0;j
hide[i].diff+=output[j].diff*output[j].w[i];
hide[i].diff*=diffsigmoid(hide[i].in);
}
for(int i=0;i
{
output[i].bia+=learningrate*output[i].diff;
for(int j=0;j
output[i].w[j]+=learningrate*output[i].diff*hide[j].out;
}
for(int i=0;i
{
hide[i].bia+=learningrate*hide[i].diff;
for(int j=0;j
hide[i].w[j]+=learningrate*hide[i].diff*input[j];
}
return;
}
2019/3/14 21:59更新AutoEncoder
最近进军深度学习,少不了自动编码器,于是在LSTM的seq2seq模型上加入了AutoEncoder部分,由于初期的架构,循环很多,代码量很大,不过可以从以前的代码里复制,然后微微修改,再粘贴下来,等到有空之后,我会把自己RNN和LSTM的东西也分享分享的。
2019/3/15更新
功能函数的大体结构都如之前写的那样,现在讲述的都是其他一些神经元单元的设计和使用。我是做NLP自然语言处理的,自然语言处理必然少不了RNN,LSTM,GRU这些基本单元,那么按照上面的思路,RNN和LSTM的写法应该不难得出,不过变成了下面这样:
#define MAXTIME 100
template
struct rnn_neuron
{
double wi[InputNum],wh[HideNum];
double bia,diff[Maxtime];
double in[Maxtime],out[Maxtime];
};
template
struct nor_neuron
{
double w[InputNum],bia,diff[Maxtime];
double in[Maxtime],out[Maxtime];
};
const double learningrate=0.1;
rnn_neuron hide[HNUM];
nor_neuron output[ONUM];
double input[INUM][MAXTIME];
double expect[ONUM][MAXTIME];
double sigma_error=1e8;
double error=1e8;
可以看出来出现了MAXTIME这个东西,这个辅助量是用于记录时间序列中每个时间刻的数据的,因为每个数据在最后BPTT的过程中都是必需的。rnn中的wi是对输入端的权重,wh是对前一时间刻隐藏层输出的权重。
但是这样写还有个缺陷。struct中diff是记录这个单元在t时刻的训练增量的,显然如果直接遍历所有时间,把增量依次赋给数据是不太行的。因为每个时间刻内,增量可能数量级很小很小,甚至有可能到1e-8以及更小(在非常长的时间序列下,可以到1e-20的级别),直接赋给数据,就相当于给数据加上了0,丢失了精度。
举个例子:double x=0.1,y=1e-10;
x+y后,得出的结果仍然是0.1,显然是丢失了精度。
那么为了避免出现这个问题,我们还需要再加上一个sigmadiff用于把所有时间刻的diff累加起来一起赋给数据。不过这样做的话,就要对每个时间下的每个数据(包括权重)做sigmadiff了,因为一开始求的diff是对bia的偏导数,如果直接全部加起来,获得的sigmadiff仅仅是对bia的sigmadiff。
于是
template
struct rnn_neuron
{
double wi[InputNum],wh[HideNum],sigmawi[InputNum],sigmawh[HideNum];
double bia,diff[Maxtime],sigmabia;
double in[Maxtime],out[Maxtime];
};
template
struct nor_neuron
{
double w[InputNum],bia,diff[Maxtime],sigmaw[InputNum],sigmabia;
double in[Maxtime],out[Maxtime];
};
就变成了这样。
那么同理,lstm是一样的思路,不过数据更加多,而且随着数据量增加,训练速度也明显会变得非常慢(真的非常显著的变化!)
template
struct LSTM_neuron
{
double cell[Maxtime];
double out[Maxtime];
double fog_in[Maxtime],fog_out[Maxtime],fog_bia,fog_wi[InputNum],fog_wh[HideNum],fog_diff[Maxtime];
double sig_in[Maxtime],sig_out[Maxtime],sig_bia,sig_wi[InputNum],sig_wh[HideNum],sig_diff[Maxtime];
double tan_in[Maxtime],tan_out[Maxtime],tan_bia,tan_wi[InputNum],tan_wh[HideNum],tan_diff[Maxtime];
double out_in[Maxtime],out_out[Maxtime],out_bia,out_wi[InputNum],out_wh[HideNum],out_diff[Maxtime];
double fog_transbia,fog_transwi[InputNum],fog_transwh[HideNum];
double sig_transbia,sig_transwi[InputNum],sig_transwh[HideNum];
double tan_transbia,tan_transwi[InputNum],tan_transwh[HideNum];
double out_transbia,out_transwi[InputNum],out_transwh[HideNum];
};
那么针对rnn和lstm的Calc()和Training()函数都要重新编写哦!
接着就是利用这些单元来写一些模型,然后对测试好的模型进行封装。
先拿一开始的BP做个例子吧。思想其实是很简单的,BP的神经元我们已经有个一个struct来定义了。那么我们用这个struct做一个class,把一些函数也包含进去。bp.h用于放template和class
/*bp.h header file by ValK*/
/* 2019/3/15 15:25 */
#ifndef __BP_H__#define __BP_H__#include #include #include #include #include #include using namespace std;
template
struct neuron
{
double w[NUM],bia,diff;
double in,out;
};
class ActivateFunction
{
public:
double sigmoid(double x)
{
return 1.0/(1.0+exp(-x));
}
double diffsigmoid(double x)
{
x=1.0/(1.0+exp(-x));
return x*(1-x);
}
double tanh(double x)
{
return (exp(x)-exp(-x))/(exp(x)+exp(-x));
}
double difftanh(double x)
{
x=tanh(x);
return 1-x*x;
}
double relu(double x)
{
return x>0? x:0;
}
double diffrelu(double x)
{
return x>0? 1:0;
}
};
ActivateFunction fun;
template
class bp_neural_network
{
private:
neuron hide[HNUM];
neuron output[ONUM];
double learningrate;
double input[INUM];
double expect[ONUM];
int batch_size;
double sigma_error;
double error;
public:
int epoch;
void TxtCheck()
{
if(!fopen("data.ai","r"))
{
INIT();
Dataout();
}
if(!fopen("trainingdata.txt","r"))
{
cout<
cout<
exit(0);
}
}
bp_neural_network()
{
epoch=0;
sigma_error=1e8;
error=1e8;
batch_size=1;
learningrate=0.01;
TxtCheck();
}
void SetBatch(int Batch)
{
batch_size=Batch;
return;
}
void INIT()
{
srand(unsigned(time(NULL)));
/*statement*/
return;
}
void Datain()
{
ifstream fin("data.ai");
/*statement*/
fin.close();
}
void Dataout()
{
ofstream fout("data.ai");
/*statement*/
fout.close();
}
void Mainwork()
{
ifstream fin("trainingdata.txt");
sigma_error=0;
for(int b=0;b
{
/*处理batch数据,读入input和expect*/
Calc();
ErrorCalc();
Training();
sigma_error+=error;
}
fin.close();
return;
}
void Calc()
{
for(int i=0;i
{
hide[i].in=hide[i].bia;
for(int j=0;j
hide[i].in+=input[j]*hide[i].w[j];
hide[i].out=fun.sigmoid(hide[i].in);
}
for(int i=0;i
{
output[i].in=output[i].bia;
for(int j=0;j
output[i].in+=hide[j].out*output[i].w[j];
output[i].out=fun.sigmoid(output[i].in);
}
}
void ErrorCalc()
{
double trans;
error=0;
for(int i=0;i
{
trans=output[i].out-expect[i];
error+=trans*trans;
}
error*=0.5;
}
void Training()
{
for(int i=0;i
output[i].diff=(expect[i]-output[i].out)*fun.diffsigmoid(output[i].in);
//负号直接舍弃,因为整个传递过程这里的负号不带来影响//而且在最后更新数据的时候也不需要再*(-1)for(int i=0;i
{
hide[i].diff=0;
for(int j=0;j
hide[i].diff+=output[j].diff*output[j].w[i];
hide[i].diff*=fun.diffsigmoid(hide[i].in);
}
for(int i=0;i
{
output[i].bia+=learningrate*output[i].diff;
for(int j=0;j
output[i].w[j]+=learningrate*output[i].diff*hide[j].out;
}
for(int i=0;i
{
hide[i].bia+=learningrate*hide[i].diff;
for(int j=0;j
hide[i].w[j]+=learningrate*hide[i].diff*input[j];
}
return;
}
};
#endif
bpneuralnetwork这个template初始三个传参便是建立一个网络必须要的参数,这种思想在写其他template封装时很重要。
neuron是struct单元,包括了基本bp神经元需要的数据,ActivateFunction类包括了一些需要使用的激励函数。
省时间,一些函数的内容就不多写了。设计构造函数的时候可以自己创新,想怎么写怎么写,我这里构造函数先初始化了epoch,sigmerror,error,batch_size,还有learningrate。(直接把函数内容写class里面是被template逼的……教授要是看到了会骂死我)
Mainwork函数一般推荐你不要封装进去。。因为bp可能会被用来处理各种各样的问题,为了保证灵活性,Mainwork还是自己在外面写吧,要什么功能再加进去就是了。
写个小bug(误)来看看是否运行正常:
没有问题,因为我没有训练集,所以在构造函数里判断出来了,直接退出了程序。
更新内容基本结束~
2019.5.14更新
这次课设就写了相关的代码,不过和答案里提供的方法不太一样,这个头文件库里面所有的网络建立都是通过constructor传参+内存分配完成的,没有使用template。https://github.com/ValKmjolnir/easyNLP