神经网络简述

神经元

神经网络通过模拟人神经元的活动,通过输入预测输出。

image-20240424091712887

神经网络中的一个神经元的树突接触到输入,然后经过计算之后从轴突上传出信号。

经过抽象之后,一个神经元就长这样:

image-20240430201657690

其接受3个输入,$x_1,x_2,x_3$,经过线性运算$w^Tx+b$,和激活函数$\sigma(z)$后,输出$a$​​。

$w$是一个$3\times1$的列向量,$x$是一个$3\times1$的列向量,$b$是一个实数

神经网络

多个神经元(cell)一起就组成了神经网络,一个神经网络可以表示为如下这样:

其中从左到右第一层称为输入层,第二、三层称为隐藏层,第四层成为输出层。

神经网络的工作方式

神经网络的每一层都是由多个神经元组成的,这里取这样的一层神经元:

那么:

  • 第一个神经元计算$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站的深度学习课程,这是相关的几节:

梯度下降法逻辑回归中的梯度下降法m个样本的梯度下降神经网络中的梯度下降直观理解反向传播

前期准备

采用Python编写程序,需要用到的库有:

  • numpy:用于矩阵计算
  • tensorboard(可选):用来记录训练数据

单层神经元

因为可以将神经网络拆分成一层层神经元的堆叠,所以只要写好一层,将这层复制多份,就可以构建一个神经网络。所以可以创建一个名叫Layer的类,代表一层神经元

而如刚才所述,每一层都进行前向传播和反向传播。而在实现前向传播和反向传播之前,先来考虑每一层中应该有什么元素。

Layer类中的变量

再回头看抽象出来的神经元以及前向传播和反向传播的过程:

可以看出,每一层需要有权重矩阵W,偏置矩阵b,还有选用的激活函数$\sigma$。

除此之外,构建权重矩阵W时还需要知道该层结点数目;在实现反向传播的时候还需要知道该层神经网络的输入$A^{[l-1]}$、该层神经网络的输出对损失函数的导数$dA^{[l]}$、学习率$\alpha$、$WX+b$的输出$Z$以及样本数$m$。

所以类的成员变量如下:

1
2
3
4
5
6
7
8
9
10
class Layer:
# 神经元参数
__W = None # 权重矩阵
__b = None # 偏置部分
__A_last_layer = None # 上层的输入
__Z = None # Wx+b得到的Z,之后会在反向传播中运用
__activation_function = None # 激活函数类型
__node_amount = None # 结点数目
__alpha = None # 学习率
__sample_amount = None # 样本数

再给出构造函数:

1
2
3
4
5
6
7
8
9
10
11
def __init__(self, last_layer_node_amount, node_amount=4, activation_function="relu", alpha=0.1):
# 初始化节点数量和激活函数类型
self.__node_amount = node_amount
self.__activation_function = activation_function

# 初始化权重矩阵
self.__W = np.random.randn(node_amount, last_layer_node_amount) * np.sqrt(2 / last_layer_node_amount)
# 初始化偏置矩阵
self.__b = np.random.randn(node_amount, 1)
# 设置学习率
self.__alpha = alpha

前向传播

之后,就可以开始实现最简单的前向传播算法,总共就只有两步:

  1. 计算$WX+b$得到$Z$​
  2. 根据激活函数的不同,选择不同的激活函数计算$Y=\sigma(Z)$
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def forward(self, X: np.ndarray):
"""
前向传播
:param X: 每一列是一个样本,每一行是一个特征
:return: 该层的输出,保持每一列是一个样本,每一行是一个特征
"""
# 设置样本数
self.__sample_amount = X.shape[1]

# 前向传播
self.__A_last_layer = X
self.__Z = np.dot(self.__W, X) + self.__b # 计算WX+b

if self.__activation_function == "relu":
return activation_function.relu(self.__Z)
elif self.__activation_function == "sigmoid":
return activation_function.sigmoid(self.__Z)
elif self.__activation_function == "linear":
return activation_function.linear(self.__Z)
elif self.__activation_function == "tanh":
return activation_function.tanh(self.__Z)
else:
raise "invalid activation function!"

这里以relu为例,给出激活函数的实现:

1
2
def relu(Z: np.ndarray):
return np.where(Z > 0, Z, 0)

反向传播

反向传播是整个代码中最复杂的部分,但只要掌握这四条公式,也是四个步骤,写下来也不难:

  1. 计算 $dZ^{[l]}$:$dZ^{[l]} = dA^{[l]} * g^{[l]’}(Z^{[l]})$
  2. 计算 $dW^{[l]}$:$dW^{[l]} = \frac{1}{m} dZ^{[l]} \cdot A^{[l-1]T}$

  3. 计算 $db^{[l]}$:$db^{[l]} = \frac{1}{m}np.sum(dZ^{[l]},axis=1,keepdims=True)$

  4. 计算 $dA^{[l-1]}$:$dA^{[l-1]} = W^{[l]T} \cdot dZ^{[l]}$

转换为对应代码,即为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def backward(self, dA):
dZ = None
# 计算dZ
if self.__activation_function == "relu":
dZ = dA * activation_function.d_relu(self.__Z)
elif self.__activation_function == "sigmoid":
dZ = dA * activation_function.d_sigmoid(self.__Z)
elif self.__activation_function == "linear":
dZ = dA * activation_function.d_linear(self.__Z)
elif self.__activation_function == "tanh":
dZ = dA * activation_function.d_tanh(self.__Z)
else:
raise "invalid activation function!"

# 计算上一层的dA
dA_last_layer = np.dot(self.__W.T, dZ)
# 更新权重矩阵
self.__W -= self.__alpha * (1 / self.__sample_amount) * np.dot(dZ, self.__A_last_layer.T)
# 更新偏置量
self.__b -= (1 / self.__sample_amount) * np.sum(dZ, axis=1, keepdims=True)

return dA_last_layer

这里以relu为例,给出激活函数导数的实现:

1
2
def d_relu(Z: np.ndarray):
return np.where(Z > 0, 1, 0)

神经网络的封装

写好单层之后,将单层神经元进行堆叠就可以得到完整的神经网络。

定义一个Network类,用于保存神经网络。以下是其的成员变量和构造函数:

1
2
3
4
class Network:
__layers = [] # 保存隐藏层和输出层
__X_train, __Y_train, __X_test, __Y_test = None, None, None, None # 训练集和测试集
__loss_function = None # 损失函数
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
43
44
45
def __init__(self, nodes_amount: list, X_train, Y_train, X_test, Y_test, learning_speed,
output_layer_function="sigmoid",
hidden_layer_function="relu",
loss_function="cross entropy"): # 通过字符串来选择隐藏层,输出层的激活函数,还有损失函数
"""
初始化神经网络
:param nodes_amount: 每层的结点数
:param X_train: 训练数据,每行是不同特征,每列是不同样本
:param Y_train: 训练标签,每行使不同特征,每列是不同样本
:param X_test: 测试数据,每行是不同特征,每列是不同样本
:param Y_test: 测试标签,每行是不同特征,每列是不同样本
:param learning_speed: 学习速率
:param output_layer_function: 输出层激活函数
:param hidden_layer_function: 隐藏层激活函数
:param loss_function: 损失函数
"""
# 判断维度是否正确
if X_train.shape[0] != X_test.shape[0] or Y_train.shape[0] != Y_test.shape[0] or X_train.shape[1:] != \
Y_train.shape[1:] or X_test.shape[1:] != Y_test.shape[1:]:
raise "dimension error"

# 保存损失函数
if loss_function == "cross entropy":
self.__loss_function = loss_function
else:
raise "loss function name error"

# 保存训练集、测试集
self.__X_train = X_train
self.__X_test = X_test
self.__Y_train = Y_train
self.__Y_test = Y_test

# 初始化神经网络
for index in range(len(nodes_amount)):
if index == 0:
self.__layers.append(
layer.Layer(X_train.shape[0], nodes_amount[index], hidden_layer_function, learning_speed))
elif index < len(nodes_amount) - 1:
self.__layers.append(
layer.Layer(nodes_amount[index - 1], nodes_amount[index], hidden_layer_function, learning_speed))
elif index == len(nodes_amount) - 1:
self.__layers.append(
layer.Layer(nodes_amount[index - 1], nodes_amount[index], output_layer_function, learning_speed))

神经网络的训练

因为每一层的前向传播和反向传播都是已经写好的。所以在整个神经网络的训练过程中,只需要逐层调用前向传播和反向传播即可。

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
def train(self, times):
# 增加计数器
count = increment_counter()

#tensorboard记录
writer = SummaryWriter("logs")
for iteration in range(times):
# 正向传播
layer_input = self.__X_train
for index in range(len(self.__layers)):
layer = self.__layers[index]
layer_input = layer.forward(layer_input)

dA = None
if self.__loss_function == "cross entropy":
# 计算误差。最后一层对下一层的输入就是最终输出
loss = loss_function.cross_entropy(layer_input, self.__Y_train)
#记录数据
print(f'iteration:{iteration},loss:{loss}')
writer.add_scalar(f'loss{count}', loss, iteration)
# 计算最终输出对损失函数的偏导数
dA = loss_function.d_cross_entropy(layer_input, self.__Y_train)

#反向传播
for index in range(len(self.__layers) - 1, -1, -1):
layer = self.__layers[index]
dA = layer.backward(dA)
writer.close()

因为在反向传播的时候需要给出该层神经元输出对损失函数的偏导数,所以需要计算最终输出对损失函数的偏导数。

1
dA = loss_function.d_cross_entropy(layer_input, self.__Y_train)

这里的损失函数采取的是交叉熵函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def cross_entropy(A: np.ndarray, Y: np.ndarray):
if A.shape != Y.shape:
raise "dimension is not match"
m = A.shape[1]
loss = Y * np.log(A) + (1 - Y) * np.log(1 - A)
loss = (-1 / m) * np.sum(loss, axis=1, keepdims=True)
return loss


def d_cross_entropy(A: np.ndarray, Y: np.ndarray):
if A.shape != Y.shape:
raise "dimension is not match"
dA = -(Y * (1 / A) + (Y - 1) * (1 / (1 - A)))
return dA

至此,整个训练过程的代码就写完了

神经网络的预测

神经网络的预测实际上就是对特征矩阵做一次前向传播,神经网络的输出就是预测结果

1
2
3
4
5
6
7
def predict(self, X):
layer_input = X
for index in range(len(self.__layers)):
layer = self.__layers[index]
layer_input = layer.forward(layer_input)

return layer_input

结语

至此,整个神经网络的框架就搭建好了。主要的两个类是单层神经元Layer以及神经网络类NetworkLayer类需要实现前向传播和反向传播,Network类需要实现训练的过程。

其他的例如激活函数的改变、损失函数的改变,只需要在原有的框架下写一些条件判断即可。