从零开始搭建一个简易神经网络
神经网络简述
神经元
神经网络通过模拟人神经元的活动,通过输入预测输出。
神经网络中的一个神经元的树突接触到输入,然后经过计算之后从轴突上传出信号。
经过抽象之后,一个神经元就长这样:
其接受3个输入,$x_1,x_2,x_3$,经过线性运算$w^Tx+b$,和激活函数$\sigma(z)$后,输出$a$。
$w$是一个$3\times1$的列向量,$x$是一个$3\times1$的列向量,$b$是一个实数
神经网络
多个神经元(cell)一起就组成了神经网络,一个神经网络可以表示为如下这样:
%%{ init : { 'flowchart' : { 'curve' : 'basis' }}}%% flowchart LR age((年龄)) salary((薪水)) age-.->x1((cell)) & x2((cell)) & x3((cell)) salary-.->x1 & x2 & x3 x1 & x2 & x3-.->x4((cell)) & x5((cell)) & x6((cell)) -.->output((sigmoid))-->y((结果))
其中从左到右第一层称为输入层,第二、三层称为隐藏层,第四层成为输出层。
神经网络的工作方式
神经网络的每一层都是由多个神经元组成的,这里取这样的一层神经元:
%%{ init : { 'flowchart' : { 'curve' : 'basis' }}}%% flowchart LR age((x1)) salary((x2)) age-.->x1((cell)) & x2((cell)) & x3((cell)) salary-.->x1 & x2 & x3 x1-->y1((y1)) x2-->y2((y2)) x3-->y3((y3))
那么:
- 第一个神经元计算$a_1=w_1^Tx+b_1$与$y_1=\sigma(a_1)$。
- 第二个神经元计算$a_2=w_2^Tx+b_2$与$y_2=\sigma(a_2)$
- 第三个神经元计算$a_3=w_3^Tx+b_1$与$y_3=\sigma(a_3)$
这里,$w_i$是一个$2\times1$的列向量,$x$是一个$2\times1$的列向量,$b$是一个实数
因为每个神经元执行的操作类似,所以可以将$w_i$和$b$堆叠起来,以表示输入通过这一层神经元得到的输出。
我们将所有的 $w^T_i$ 按行堆叠成一个矩阵$W$,其中每一行对应一个神经元的权重向量的转置 $w^T_i$。
将所有的偏置 $b_i$ 按行堆叠成一个列向量,其中每个元素对应一个神经元的偏置 $b_i$。因此,堆叠后的 $b$ 将是一个 $3 \times 1$ 的列向量:
这里,每个 $b_i$ 是一个实数。
现在,我们可以用堆叠后的 $W$ 和 $b$ 来表示所有神经元的输出:
其中,$Y$ 是一个 $3 \times 1$ 的列向量,包含了所有神经元的输出;$X$ 是一个 $2 \times 1$ 的列向量,表示输入特征。
多样本
到目前为止,神经网络的输入还是单样本,但在实际的问题中,遇到的都是多样本,如果一个个样本输入则太慢了。
又考虑到每个样本$x_i$在神经网络中所经过的计算是相似的。所以,同样的想法,可以将样本$x_1,x_2,\cdots,x_n$按列的方式堆叠成一个矩阵$X$。
展开来就是:
$m$个特征,$n$个样本
而每层的计算方式并没有因为样本的堆叠发生改变,仍然是:
这里意思是每一列都加上$b$
只不过矩阵的维度发生了变化:
- $X$维度变为$(m,n)$
- $Y$的维度变为$(m’,n)$
$W$矩阵的维度是$(m’,m)$维
更新参数
刚才所述统都属于前向传播的过程,而到这里还有一个关键的问题没有解决,就是神经网络的参数$W$和$b$要怎么更新。如果不更新,神经网络就无法学习样本中的特征。所以就需要引入反向传播算法,用于更新这两个参数。
而反向传播算法中常用的是梯度下降。
梯度下降
考虑一个函数$z=f(x,y)$,在高数中学过,其在$x_0,y_0$增长最快的方向就是函数$f(x,y)$在这一点的梯度向量的方向。而$f(x,y)$在$(x_0,y_0)$处的梯度向量是$(\frac{\partial f}{\partial x}|_{x_0,y_0},\frac{\partial f}{\partial y}|_{x_0,y_0})$。所以$(x_0+\frac{\partial f}{\partial x}|_{x_0,y_0},y_0+\frac{\partial f}{\partial y}|_{x_0,y_0})$可以使得$f(x,y)$增长最快。
那么相反的(如果一个函数是凸的),函数的减小最快的方向就是梯度向量的反方向,即$(x_0-\frac{\partial f}{\partial x}|_{x_0,y_0},y_0-\frac{\partial f}{\partial y}|_{x_0,y_0})$
神经网络中的梯度下降
在神经网络中也是类似的,损失函数$f$(也就是神经网络预测的$\hat y$和实际的$y$的偏差)是各层权重矩阵$W$和偏置向量$b$的函数。
所以也可以用同样的方法去更新$W$和$b$:
这里的$\alpha$是学习率
下面直接给出神经网络中单层的梯度下降公式:
计算 $dZ^{[l]}$:$dZ^{[l]} = dA^{[l]} * g^{[l]’}(Z^{[l]})$
计算 $dW^{[l]}$:$dW^{[l]} = \frac{1}{m} dZ^{[l]} \cdot A^{[l-1]T}$
计算 $db^{[l]}$:$db^{[l]} = \frac{1}{m}np.sum(dZ^{[l]},axis=1,keepdims=True)$
计算 $dA^{[l-1]}$:$dA^{[l-1]} = W^{[l]T} \cdot dZ^{[l]}$
其中,$dA^{[l]}$ 是损失函数对 $A^{[l]}$ 的梯度,$dZ^{[l]}$ 是损失函数对 $Z^{[l]}$ 的梯度。上标$[l]$指的是第$l$层。以此类推。
[!NOTE]
如果想看详细的证明,推荐吴恩达在B站的深度学习课程,这是相关的几节:
前期准备
采用Python
编写程序,需要用到的库有:
numpy
:用于矩阵计算tensorboard
(可选):用来记录训练数据
单层神经元
因为可以将神经网络拆分成一层层神经元的堆叠,所以只要写好一层,将这层复制多份,就可以构建一个神经网络。所以可以创建一个名叫Layer
的类,代表一层神经元
flowchart LR subgraph 神经网络 layer1[layer] layer2[layer] end input-->layer1-->layer2-->output
而如刚才所述,每一层都进行前向传播和反向传播。而在实现前向传播和反向传播之前,先来考虑每一层中应该有什么元素。
Layer类中的变量
再回头看抽象出来的神经元以及前向传播和反向传播的过程:
可以看出,每一层需要有权重矩阵W
,偏置矩阵b
,还有选用的激活函数$\sigma$。
除此之外,构建权重矩阵W
时还需要知道该层结点数目;在实现反向传播的时候还需要知道该层神经网络的输入$A^{[l-1]}$、该层神经网络的输出对损失函数的导数$dA^{[l]}$、学习率$\alpha$、$WX+b$的输出$Z$以及样本数$m$。
所以类的成员变量如下:
1 | class Layer: |
再给出构造函数:
1 | def __init__(self, last_layer_node_amount, node_amount=4, activation_function="relu", alpha=0.1): |
前向传播
之后,就可以开始实现最简单的前向传播算法,总共就只有两步:
- 计算$WX+b$得到$Z$
- 根据激活函数的不同,选择不同的激活函数计算$Y=\sigma(Z)$
1 | def forward(self, X: np.ndarray): |
这里以relu
为例,给出激活函数的实现:
1 | def relu(Z: np.ndarray): |
反向传播
反向传播是整个代码中最复杂的部分,但只要掌握这四条公式,也是四个步骤,写下来也不难:
- 计算 $dZ^{[l]}$:$dZ^{[l]} = dA^{[l]} * g^{[l]’}(Z^{[l]})$
计算 $dW^{[l]}$:$dW^{[l]} = \frac{1}{m} dZ^{[l]} \cdot A^{[l-1]T}$
计算 $db^{[l]}$:$db^{[l]} = \frac{1}{m}np.sum(dZ^{[l]},axis=1,keepdims=True)$
计算 $dA^{[l-1]}$:$dA^{[l-1]} = W^{[l]T} \cdot dZ^{[l]}$
转换为对应代码,即为:
1 | def backward(self, dA): |
这里以relu
为例,给出激活函数导数的实现:
1 | def d_relu(Z: np.ndarray): |
神经网络的封装
写好单层之后,将单层神经元进行堆叠就可以得到完整的神经网络。
定义一个Network类,用于保存神经网络。以下是其的成员变量和构造函数:
1 | class Network: |
1 | def __init__(self, nodes_amount: list, X_train, Y_train, X_test, Y_test, learning_speed, |
神经网络的训练
因为每一层的前向传播和反向传播都是已经写好的。所以在整个神经网络的训练过程中,只需要逐层调用前向传播和反向传播即可。
1 | def train(self, times): |
因为在反向传播的时候需要给出该层神经元输出对损失函数的偏导数,所以需要计算最终输出对损失函数的偏导数。
1 | dA = loss_function.d_cross_entropy(layer_input, self.__Y_train) |
这里的损失函数采取的是交叉熵函数:
1 | def cross_entropy(A: np.ndarray, Y: np.ndarray): |
至此,整个训练过程的代码就写完了
神经网络的预测
神经网络的预测实际上就是对特征矩阵做一次前向传播,神经网络的输出就是预测结果
1 | def predict(self, X): |
结语
至此,整个神经网络的框架就搭建好了。主要的两个类是单层神经元Layer
以及神经网络类Network
:Layer
类需要实现前向传播和反向传播,Network
类需要实现训练的过程。
其他的例如激活函数的改变、损失函数的改变,只需要在原有的框架下写一些条件判断即可。