前言
本文主要介绍最基础的神经网络,包括其结构,学习方法, C++ \texttt{C++} C++ 的实现代码。 Python \texttt{Python} Python 的代码可以搜索互联网得到。
前排提示:本人涉及一丁点数学知识。
神经网络的结构
神经网络包括多个层次(Layer)。一般来说神经网络包括三个部分:输入层,隐藏层,输出层 。输入层和输出层都只有一个,而隐藏层可以有多个。顺序为 输入层,隐藏层,输出层。
每一个层次会有若干的神经元。我们称第 L l L_l Ll 层为输出层,也就是最后一层。对于第 L L L 层(从 0 0 0 开始计数),我们用 n L n_L nL 表示其神经元的数量。
对于第 L L L 层的第 i i i 个神经元(从 0 0 0 开始计数),我们用 a i L a^L_i aiL 表示其输出到下一层神经元或者作为结果的值。对于生物体的神经元, a i L a_i^L aiL 的取值只有 0 , 1 0,1 0,1 ,但是对于本文而言,计算机的神经元有 a i L ∈ [ 0 , 1 ] a_i^L\in[0,1] aiL∈[0,1] 。同时,每个神经元还有一个偏移值 b i L b^L_i biL 。
除输入层外,每一个神经元都与上一层的所有神经元都有一个连接,对于第 L ( 1 < L ) L(1<L) L(1<L) 的第 i i i 个神经元,它与 L − 1 L-1 L−1 层的所有神经元都有连接,其中它与第 j j j 个神经元的连接有权重 w i , j L w^L_{i,j} wi,jL 。
下图展示了一个简单的神经网络的构成:
图中有 n 0 = 3 , n 1 = n 2 = 2 n_0=3,n_1=n_2=2 n0=3,n1=n2=2 ,且第 0 0 0 层为输入层,第 2 2 2 层为输出层。
PS \texttt{PS} PS: 此图没有展示神经元的偏移值。
输出值的计算
对于输入层而言,其输出值 a i 0 ( i ∈ [ 0 , n 0 ) ∩ Z ) a^0_i (i\in[0,n_0)\cap \mathbb{Z}) ai0(i∈[0,n0)∩Z) ,其输出值与神经网络的输入相同。
对于隐藏层和输出层,其计算方式如下:
a i L = f ( z i L ) z i L = b i L + ∑ j = 0 n L − 1 w i , j L a j L − 1 \begin{aligned} a^L_i&=f\left(z^L_i\right)\\ z^L_i&=b^L_i + \sum_{j=0}^{n_{L-1}} w^L_{i,j}a^{L-1}_j \end{aligned} aiLziL=f(ziL)=biL+j=0∑nL−1wi,jLajL−1
其中, f ( x ) f(x) f(x) 函数一般为 S i g m o i d \mathrm{Sigmoid} Sigmoid 函数,常见于生物种群数量的自然增长。其定义和导数如下:
f ( x ) = 1 1 + e − x f ′ ( x ) = f ( x ) ( 1 − f ( x ) ) \begin{aligned} f(x)&=\frac{1}{1+e^{-x}}\\ f'(x)&=f(x)(1-f(x)) \end{aligned} f(x)f′(x)=1+e−x1=f(x)(1−f(x))
这个函数可以让我们的输出值取值范围固定在 [ 0 , 1 ] [0,1] [0,1] 之间。
实际上,神经网络使用的 f ( x ) f(x) f(x) 不一定需要让输出值固定在 [ 0 , 1 ] [0,1] [0,1] 之间,比如卷积神经网络(CNN)中有一个 f ( x ) f(x) f(x) 是 R e L U \mathrm{ReLU} ReLU 函数,其定义为:
f ( x ) = { x x > 0 0 o t h e r w i s e f(x)=\begin{cases} x & x>0\\ 0 & \mathrm{otherwise} \end{cases} f(x)={x0x>0otherwise
请留意 z i L z^L_i ziL 这个值,我们暂且将其称为成为中间值。
神经网络的学习方法
整个神经网络的计算过程,可以认为是一个庞大的函数 F F F ,其自变量的个数是输入层神经元的个数,而因变量的个数是输出层神经元的个数。而我们需要的让这个函数去拟合一个标准函数 G G G 。
而神经网络的学习过程,实际上是一个用提供的若干个输入输出(我们称这些输入输出为训练集)不断调整权重 w w w 以及偏移值 b b b ,使得 F F F 拟合 G G G 的过程。
过拟合
实际上在神经网络的学习过程中,因为参数过多(或者其他原因),容易出现过拟合(overfitting)的现象。表现为在对于训练集中的数据,神经网络可以给出优秀的结果,但是对于训练集以外的数据,则出现很多错误。
一个形象的理解方法就是:当我们用给定的一些三次函数 G G G 上的点,用一个 6 6 6 次函数 F F F 去拟合这个 3 3 3 次函数 G G G 的时候,对于给定的点, F F F 都和 G G G 的输出一致,但是本应该是 0 0 0 的 4 , 5 , 6 4,5,6 4,5,6 次项不全是 0 0 0 ,导致 F F F 会出现一些弯曲,这时候就出现了过拟合。下图是一个过拟合的示意图:
其中绿色的点是训练集, G ( x ) G(x) G(x) 是我们需要的函数, F ( x ) F(x) F(x) 为神经网络拟合出的函数。
一般来说,我们会使用另一套输入输出(我们称之为测试集)来评判神经网络的学习效果。
在当前状态下,我们并不考虑过拟合问题。
反向传播方法(Backprop,简称BP)
反向传播方法是神经网络学习的核心算法。
对于训练集中的一个输入输出 k k k ,我们用 O i O_i Oi 表示理想状态下,神经网络的输出层的第 i i i 个神经元应该有的输出值。 这时候,我们可以计算出神经网络当前的函数 F F F 和 G G G 的误差,这里我们用类似方差的表达式表示其误差:
C k = ∑ i = 0 N L l ( a i L l − O i ) 2 C_k=\sum_{i=0}^{N_{L_l}} (a^{L_l}_i-O_i)^2 Ck=i=0∑NLl(aiLl−Oi)2
实际上有其他表示误差的方式,本人不是很了解。
显然的,我们需要让 C k C_k Ck 越小越好,这时候,我们需要知道 a i L l a_i^{L_l} aiLl 应该如何变化才能使得 C k C_k Ck 变小,可以想到运用导数计算出 a a a 应该有的变化,也就是:
∂ C k ∂ a i L l = 2 ( a i L l − O i ) \frac{\partial C_k}{\partial a^{L_l}_i} =2\left(a^{L_l}_i - O_i\right) ∂aiLl∂Ck=2(aiLl−Oi)
想让 C k C_k Ck 变小,就需要让 a i L l a_i^{L_l} aiLl 向着 − ∂ C k a i L l -\frac{\partial C_k}{a^{L_l}_i} −aiLl∂Ck 方向减小,至于如何做到这一点,我们可以调整 b i L l , w i , ∗ L l , a ∗ L l − 1 b^{L_l}_i,w^{L_l}_{i,*}, a^{L_{l-1}}_* biLl,wi,∗Ll,a∗Ll−1。而调整这些参数,也可以使用求导的方式 ( 0 ≤ j < n l − 1 0\leq j<n_{l-1} 0≤j<nl−1):
∂ C k ∂ b i L l = ∂ z i L l ∂ b i L l ∂ a i L l ∂ z i L l ∂ C k ∂ a i L l = 1 ⋅ f ′ ( z i L l ) ∂ C k ∂ a i L l ∂ C k ∂ w i , j L l = ∂ z i L l ∂ w i , j L l ∂ a i L l ∂ z i L l ∂ C k ∂ a i L l = a j L l − 1 f ′ ( z i L l ) ∂ C k ∂ a i L l ∂ C k ∂ a j L l − 1 = ∑ i = 0 n L l ∂ z i L l ∂ a j L l − 1 ∂ a i L l ∂ z i L l ∂ C k ∂ a i L l = ∑ i = 0 n L l − 1 w i , j L l f ′ ( z i L l ) ∂ C k ∂ a i L l \begin{aligned} \frac{\partial C_k}{\partial b^{L_l}_i} &= \frac{\partial z^{L_l}_i}{\partial b^{L_l}_i} \frac{\partial a^{L_l}_i}{\partial z^{L_l}_i} \frac{\partial C_k}{\partial a^{L_l}_i} = 1\cdot f'\left(z^{L_l}_i\right)\frac{\partial C_k}{\partial a^{L_l}_i}\\ \frac{\partial C_k}{\partial w^{L_l}_{i,j}} &= \frac{\partial z^{L_l}_i}{\partial w^{L_l}_{i,j}} \frac{\partial a^{L_l}_i}{\partial z^{L_l}_i} \frac{\partial C_k}{\partial a^{L_l}_i} = a^{L_{l}-1}_j f'\left(z^{L_l}_i\right)\frac{\partial C_k}{\partial a^{L_l}_i}\\ \frac{\partial C_k}{\partial a^{L_{l}-1}_j} &= \sum_{i=0}^{n_{L_l}}\frac{\partial z^{L_l}_i}{\partial a^{L_{l}-1}_j} \frac{\partial a^{L_l}_i}{\partial z^{L_l}_i} \frac{\partial C_k}{\partial a^{L_l}_i} = \sum_{i=0}^{n_{L_l}-1}w^{L_l}_{i,j}f'\left(z^{L_l}_i\right)\frac{\partial C_k}{\partial a^{L_l}_i} \end{aligned} ∂biLl∂Ck∂wi,jLl∂Ck∂ajLl−1∂Ck=∂biLl∂ziLl∂ziLl∂aiLl∂aiLl∂Ck=1⋅f′(ziLl)∂aiLl∂Ck=∂wi,jLl∂ziLl∂ziLl∂aiLl∂aiLl∂Ck=ajLl−1f′(ziLl)∂aiLl∂Ck=i=0∑nLl∂ajLl−1∂ziLl∂ziLl∂aiLl∂aiLl∂Ck=i=0∑nLl−1wi,jLlf′(ziLl)∂aiLl∂Ck
要调整这些参数,只需要向着导数的反方向增减这些变量即可,至于 a j L l − 1 a^{L_l-1}_j ajLl−1 的调整,需要调整 b j L l − 1 b^{L_l-1}_j bjLl−1 以及 w j , ∗ L l − 1 w^{L_l-1}_{j,*} wj,∗Ll−1 ,其调整方式也是求导,且结构和上述的式子大致相同:
∀ L ∈ [ 1 , L l − 1 ] ∩ Z ∂ C k ∂ b i L = ∂ z i L ∂ b i L ∂ a i L ∂ z i L ∂ C k ∂ a i L = 1 ⋅ f ′ ( z i L ) ∂ C k ∂ a i L ∂ C k ∂ w i , j L = ∂ z i L ∂ w i , j L ∂ a i L ∂ z i L ∂ C k ∂ a i L = a j L − 1 f ′ ( z i L ) ∂ C k ∂ a i L ∂ C k ∂ a j L = ∑ i = 0 n L + 1 − 1 ∂ z i L + 1 ∂ a j L ∂ a i L + 1 ∂ z i L + 1 ∂ C k ∂ a i L + 1 = ∑ i = 0 n L + 1 − 1 w i , j L + 1 f ′ ( z i L + 1 ) ∂ C k ∂ a i L + 1 \begin{aligned} &\forall L\in [1,L_l-1] \cap \mathbb{Z}\\ &\frac{\partial C_k}{\partial b^{L}_i} = \frac{\partial z^{L}_i}{\partial b^{L}_i} \frac{\partial a^{L}_i}{\partial z^{L}_i} \frac{\partial C_k}{\partial a^{L}_i} = 1\cdot f'\left(z^{L}_i\right)\frac{\partial C_k}{\partial a^{L}_i}\\ &\frac{\partial C_k}{\partial w^{L}_{i,j}} = \frac{\partial z^{L}_i}{\partial w^{L}_{i,j}} \frac{\partial a^{L}_i}{\partial z^{L}_i} \frac{\partial C_k}{\partial a^{L}_i} = a^{L-1}_j f'\left(z^{L}_i\right)\frac{\partial C_k}{\partial a^{L}_i}\\ &\frac{\partial C_k}{\partial a^{L}_j} = \sum_{i=0}^{n_{L+1}-1}\frac{\partial z^{L+1}_i}{\partial a^{L}_j} \frac{\partial a^{L+1}_i}{\partial z^{L+1}_i} \frac{\partial C_k}{\partial a^{L+1}_i} = \sum_{i=0}^{n_{L+1}-1}w^{L+1}_{i,j}f'\left(z^{L+1}_i\right)\frac{\partial C_k}{\partial a^{L+1}_i} \end{aligned} ∀L∈[1,Ll−1]∩Z∂biL∂Ck=∂biL∂ziL∂ziL∂aiL∂aiL∂Ck=1⋅f′(ziL)∂aiL∂Ck∂wi,jL∂Ck=∂wi,jL∂ziL∂ziL∂aiL∂aiL∂Ck=ajL−1f′(ziL)∂aiL∂Ck∂ajL∂Ck=i=0∑nL+1−1∂ajL∂ziL+1∂ziL+1∂aiL+1∂aiL+1∂Ck=i=0∑nL+1−1wi,jL+1f′(ziL+1)∂aiL+1∂Ck
至此,我们得到了为了拟合数据 k k k 而需要调整的数据的内容,以及调整之前的网络的信息,我们用两个数组(或者认为是矩阵,向量)将其整合一下:
∇ C k = [ ∂ C k ∂ b 0 0 ∂ C k ∂ b 1 0 ⋮ ∂ C k ∂ b n L l − 1 L l ∂ C k ∂ w 0 , 0 1 ∂ C k ∂ w 0 , 1 1 ⋮ ∂ C k ∂ w n L l − 1 , n L l − 1 − 1 L l ] , D = [ b 0 0 b 1 0 ⋮ b n L l − 1 L l w 0 , 0 1 w 0 , 1 1 ⋮ w n L l − 1 , n L l − 1 − 1 L l ] \nabla C_k=\begin{bmatrix} \frac{\partial C_k}{\partial b^{0}_0}\\ \frac{\partial C_k}{\partial b^{0}_1}\\ \vdots\\ \frac{\partial C_k}{\partial b^{L_l}_{n_{L_l}-1}}\\ \frac{\partial C_k}{\partial w^{1}_{0,0}}\\ \frac{\partial C_k}{\partial w^{1}_{0,1}}\\ \vdots\\ \frac{\partial C_k}{\partial w^{L_l}_{n_{L_l}-1,n_{L_l-1}-1}} \end{bmatrix} , D=\begin{bmatrix} b^{0}_0\\ b^{0}_1\\ \vdots\\ b^{L_l}_{n_{L_l}-1}\\ w^{1}_{0,0}\\ w^{1}_{0,1}\\ \vdots\\ w^{L_l}_{n_{L_l}-1,n_{L_l-1}-1} \end{bmatrix} ∇Ck= ∂b00∂Ck∂b10∂Ck⋮∂bnLl−1Ll∂Ck∂w0,01∂Ck∂w0,11∂Ck⋮∂wnLl−1,nLl−1−1Ll∂Ck ,D= b00b10⋮bnLl−1Llw0,01w0,11⋮wnLl−1,nLl−1−1Ll
当我们调整网络的时候,可以使用一个参数 η \eta η 调整这个数据对学习的影响,而调整方式,可以用 D ′ = D − η ⋅ ∇ C k D'=D-\eta \cdot \nabla C_k D′=D−η⋅∇Ck 表示对这个数据的学习。可想而知 η \eta η 越大,这个数据对网络的影响越强。而 η \eta η 的调节,是一个需要讨论的问题,但是本人不太懂,所以本文中的 η = 1 \eta =1 η=1 。
Mini-Batch
如果对于每个数据,都进行一次调整 ,那么效率会变得很低,为此,我们可以使用一个小技巧,也就是 Mini-Batch \texttt{Mini-Batch} Mini-Batch 。具体操作方式如下:
首先设定一个大小 S S S ,本文中,我们设其为 10 10 10 。接着,将数据集打乱,然后每 S S S 个数据组成一个 B a t c h \mathrm{Batch} Batch 。对于每一个 B a t c h \mathrm{Batch} Batch ,设其中的数据编号为 1 , 2 , 3 , … , S 1,2,3,\dots, S 1,2,3,…,S ,对于每一个数据 i i i ,都用 BP \texttt{BP} BP 计算出一个 ∇ C i \nabla C_i ∇Ci ,接着将 B a t c h \mathrm{Batch} Batch 中的调整信息整合,这里我们用平均值进行整合: ∇ C = 1 S ∑ i = 1 S ∇ C i \nabla C=\frac{1}{S}\sum_{i=1}^S \nabla C_i ∇C=S1∑i=1S∇Ci ,接着用这个 ∇ C \nabla C ∇C 调整神经网络。
代码
NN . h \texttt{NN}.h NN.h:
#pragma once#include <vector>
#include <cstdarg>
#include "gloconst.h"class Network {
private:double **a, **b, **da, **db, **z;double ***w, ***dw;std::vector<int> network_size;/// <summary>/// 进行逆向传播/// </summary>/// <param name="output">学习的输出</param>void Backprop(double *output);
public:Network(std::vector<int> &network_size);~Network();/// <summary>/// 计算一个数据/// </summary>/// <param name="input">输入</param>void Calculate(double *input);/// <summary>/// 对模型进行训练/// </summary>/// <param name="data">数据 data[i][0] 表示第i个输入,data[i][1] 表示第i个输出</param>/// <param name="size">数据的数量</param>/// <param name="batch_size">mini-batch的大小</param>void Train(double ***data, int size, int batch_size = 100);/// <summary>/// 得到输出值a最大的节点的id/// </summary>/// <returns></returns>int GetMaxOutputNode();
};
NN.cpp \texttt{NN.cpp} NN.cpp:
#include "NN.h"
#include <algorithm>
#include <ctime>
#include <cassert>
#include <random>unsigned seed = time(NULL);
std::default_random_engine gen(seed);
std::normal_distribution<double> dis(0, 1);//生成正态分布的随机数 mu=0,sigma=1
inline double randn() {return dis(gen);
}
Network::Network(std::vector<int> &network_size) {this->network_size = network_size;a = new double *[network_size.size()];b = new double *[network_size.size()];da = new double *[network_size.size()];db = new double *[network_size.size()];z = new double *[network_size.size()];w = new double **[network_size.size() - 1];dw = new double **[network_size.size() - 1];for (int i = 0; i < network_size.size(); i++) { int size_i = network_size[i];b[i] = new double[size_i], a[i] = new double[size_i], da[i] = new double[size_i], db[i] = new double[size_i], z[i] = new double[size_i];for (int j = 0; j < size_i; j++) b[i][j] = randn();if (i) {w[i] = new double *[size_i];dw[i] = new double *[size_i];for (int j = 0; j < size_i; j++) {int size2 = network_size[i - 1];w[i][j] = new double[size2];dw[i][j] = new double[size2];for (int k = 0; k < size2; k++)w[i][j][k] = randn();}}}
}Network::~Network() {for (int i = 0; i < network_size.size(); i++) {int size_i = network_size[i];delete[] a[i], delete[] b[i], delete[] da[i], delete[] db[i], delete[] z[i];if (i) for (int j = 0; j < size_i; j++)delete[] w[i][j], delete[] dw[i][j];}
}void Network::Calculate(double *input) {memcpy(a[0], input, sizeof(double) * network_size[0]);for (int i = 1; i < network_size.size(); i++) {int sizei = network_size[i], size_lst = network_size[i - 1];for (int j = 0; j < sizei; j++) {z[i][j] = b[i][j];for (int k = 0; k < size_lst; k++) z[i][j] += w[i][j][k] * a[i - 1][k];a[i][j] = Sigmoid(z[i][j]);}}
}void Network::Backprop(double *output) {int lstid = network_size.size() - 1, sizei = network_size[network_size.size() - 1];for (int i = 0; i < sizei; i++) {da[lstid][i] = (a[lstid][i] - output[i]),db[lstid][i] = DSigmoid(z[lstid][i]) * da[lstid][i];for (int j = 0; j < network_size[lstid - 1]; j++)dw[lstid][i][j] = a[lstid - 1][j] * db[lstid][i];}for (int i = lstid - 1; i > 0; i--) {for (int j = 0; j < network_size[i]; j++) {da[i][j] = 0;for (int k = 0; k < network_size[i + 1]; k++)da[i][j] += w[i + 1][k][j] * db[i + 1][k];db[i][j] = DSigmoid(z[i][j]) * da[i][j];for (int k = 0; k < network_size[i - 1]; k++)dw[i][j][k] = a[i - 1][k] * db[i][j];}}
}void array_add(double *dst, double *src, int sz) {for (int i = 0; i < sz; i++) dst[i] += src[i];
}
void array_mul(double *dst, double x, int sz) {for (int i = 0; i < sz; i++) dst[i] *= x;
}void Network::Train(double ***data, int size, int batch_size) {batch_size = std::min(batch_size, size);std::random_shuffle(data, data + size);double **avr_db, ***avr_dw;avr_db = new double *[network_size.size()];avr_dw = new double **[network_size.size()];for (int i = 0; i < network_size.size(); i++) {avr_db[i] = new double[network_size[i]];avr_dw[i] = new double*[network_size[i]];if (i) for (int j = 0; j < network_size[i]; j++)avr_dw[i][j] = new double[network_size[i - 1]];}const double eta = 1;for (int st = 0; st < size; st += batch_size) {//clean the avr arrayfor (int i = 0; i < network_size.size(); i++) {memset(avr_db[i], 0, sizeof(double) * network_size[i]);if (i) for (int j = 0; j < network_size[i]; j++)memset(avr_dw[i][j], 0, sizeof(double) * network_size[i - 1]);}for (int x = st; x < st + batch_size; x++) {Calculate(data[x][0]);Backprop(data[x][1]);for (int i = 0; i < network_size.size(); i++) {array_add(avr_db[i], db[i], network_size[i]);if (i) for (int j = 0; j < network_size[i]; j++)array_add(avr_dw[i][j], dw[i][j], network_size[i - 1]);}}for (int i = 1; i < network_size.size(); i++) {array_mul(avr_db[i], -eta / batch_size, network_size[i]);array_add(b[i], avr_db[i], network_size[i]);if (i) for (int j = 0; j < network_size[i]; j++) {array_mul(avr_dw[i][j], -eta / batch_size, network_size[i - 1]),array_add(w[i][j], avr_dw[i][j], network_size[i - 1]);}}}for (int i = 0; i < network_size.size(); i++) {delete[] avr_db[i];if (i) {for (int j = 0; j < network_size[i]; j++) delete[] avr_dw[i][j];delete[] avr_dw[i];}}delete[] avr_db, delete[] avr_dw;
}int Network::GetMaxOutputNode() {int res = 0;double *lyr = a[network_size.size() - 1];int size = network_size[network_size.size() - 1];for (int i = 1; i < size; i++)res = (lyr[res] > lyr[i] ? res : i);return res;
}