首页 深度学习入门 - 1.手搓BP神经网络
文章
取消

深度学习入门 - 1.手搓BP神经网络

深度学习入门1 - 手搓BP神经网络

1. BP神经网络简介

简介

BP(Back Propagation)神经网络是1986年由 RumelhartMcClelland 为首的科学家提出的概念,是一种按照误差逆向传播算法训练的多层前馈神经网络,是目前应用最广泛的神经网络模型之一。1

1989Robert Hecht-Nielsen证明了对于任何闭区间内的一个连续函数都可以用一个隐含层的BP网络来逼近,这就是万能逼近定理2

根据该定理,一个三层的BP神经网络即可完成 m->n 的映射。据此设计出来的BP神经网络的结构如下:

BP神经网络结构

图中橙色部分是输入层,紫色部分是隐藏层,蓝色部分是输出层。

输入层的维度和数据的输入维度相关;隐藏层的自定义程度比较高,有多少层、每一层的维度是多少均可以自定义;输出层的维度则和任务目标相关,若是分类任务,输出层的维度等于分类数;若是回归任务,其维度等于输入维度。

隐藏层节点数设定

隐藏层节点数越多,其拥有的参数越多,能构建出来的线性模型越复杂,其解决问题的能力就越强。但是,参数越多意味着计算量越大,所需的计算资源就越大。如果我们用一个拥有100个隐藏层节点的BP神经网络去拟合一个简单 $y = x²$ 函数,虽然最终的拟合程度很高,但同时也浪费了很多的计算资源,就好像杀鸡用牛刀一样。因此,隐藏层节点数的设定非常重要。

有一个经验公式可以确定隐藏层的节点数目:

\[h = \sqrt{m+n}+a\]

其中 $h$ 为隐藏层节点数,$m$ 为输入层节点(维度)数,$n$ 为输出层节点(维度)数, $a$ 为 1~10 之间的调节常数。

正向传播过程

我们把输入数据看作是向量 $x$,每一层隐藏层相当于线性函数 $f(x)=wx+b$ ,其中 $w$ 是权重, $b$ 是偏置。为了使线性函数拥有拟合非线性函数的能力,在经过每一层节点后还需要通过一层激活函数。常用的激活函数介绍在下面。

将正向传播过程写成数学公式大致如下:

\[z_{i+1} = f_{i}(x_i) = w_ix_i+b_i\] \[a_{i+1} = f_{activate}(z_{i+1})\\\]

其中, $z$ 是 $x$ 经过隐藏层后的输出, $a$ 是 $z$ 经过激活函数后的输出。在输入层中, $x$ 是输入的原始数据,后续的隐藏层和输出层 $x$ 均为上一层的输出 $a$ 。

常用激活函数

  • Sigmoid函数

    Sigmoid是最常用的激活函数,其能将数据映射到(0, 1)之间,一般用于分类器。公式和图像如下:

    \[f(x) = \frac 1 {1+e^x}\]

    sigmoid

  • tanh函数

    解决了Sigmoid函数中心不为0的缺点,但同Sigmoid一样存在梯度消失的问题。公式和图像如下:

    \[f(x) = \frac{e^x-e^{-x}}{e^x+e^{-x}}\]

    tanh

  • ReLU函数

    通用的激活函数,针对Sigmoid函数和tanh的缺点进行改进的,目前在大多数情况下使用。公式和图像如下:

    \[f(x) = max(0, x)\]

    relu

  • LeakyReLU函数

    为了解决ReLU函数在 $x < 0$ 时梯度为0的问题进行改进的,其能使得在 $x < 0$ 时能能保留微小的梯度 $α$ (一般取值为0.01)。公式和图像如下:

    \[f(x)=max(-\alpha x, x)\]

    leakyrelu

反向传播过程

训练网络的过程中,输入 $x$ 经过前向传播得到输出结果 $y$ 后,我们需要和真实值 $y_t$ 进行比较,比较的方法通常是计算损失函数的值,常用损失函数将在下一部分介绍。然后,我们利用这个值去更新前一层的参数。然而,由于隐藏层的预期输出没有在训练样本中给出,因此我们无法直接计算得到隐藏层节点的输出误差。为了解决这个问题,引入了反向传播算法。其核心思想是将误差由输出层向前层反向传播,利用后一层的误差来估计前一层的误差。

反向传播算法由 Henry J. Kelley 在1960 和 Arthur E. Bryson 在1961分别提出。

在反向传播中,关键在于梯度下降链式求导法则。梯度下降使得误差能够反向传播,其思想是在权值空间中朝着误差减小最快的方向搜索,从而找到局部最小值(或者说局部最优)。其更新的参数的公式是:

\[\Delta w = -\alpha\nabla Loss(w) = -\alpha\frac{\partial Loss}{\partial w}\]

反向传播的梯度下降中,为了求得任意一层的 $\Delta w_k$ ,我们需要利用链式法则去求。例如求 $\frac{\partial Loss}{\partial w_1}$ :

\[\frac{\partial Loss}{\partial w_1}=\frac{\partial Loss}{\partial f_3}\cdot\frac{\partial f_3}{\partial f_2}\cdot\frac{\partial f_2}{\partial f_1}\cdot\frac{\partial f_1}{\partial w_1}\]

通过这种方式,误差能够反向传播并用于更新每一个链接权值,使神经网络整体上逼近损失函数的局部最优值,从而达到训练目的。

常用损失函数

  • 平均平方误差 MSE(Mean Square Error)

    \[E = \sum_n[y^{(n)}-(w^Tx^{(n)}+b)]^2\]
  • 交叉熵 Cross Entropy

    常用于多分类问题。

    \[l^{CE}=-\log\frac{e^{oc}}{\sum^C_{j=c}e^{oj}} = -\log(\mathrm{softmax}(o))\]

参数更新相关方法 3

本章节参考Fange的blog。

在反向传播章节中,我们提到了梯度下降法用于更新参数。最原始的参数更新方式是:

\[w_i=w_i-l\times\Delta w_i\] \[b_i=b_i-l\times\Delta b_i\\\]

其中 w 和 b 是待优化参数,l 是学习率。但这种方法存在震荡的问题,即上一次更新时梯度为负,本次更新时梯度为正的情况,这会导致模型函数难以收敛。因此,机器学习(或着说深度学习)中会更常用自适应的梯度更新方法,下面简要介绍几种常用的梯度更新方法:

  • Momentum

    Momentum即为动量。顾名思义,Momentum方法参考了物理中动量的概念进行设计。我们知道,物体具有运动的惯性。当我们骑车刹车时,不会立马停下来,而是会获得一个加速度,然后再缓慢停下来。Momentum方法也是类似的。在同一方向上步长会逐渐变大,改变方向时步长会减小。其公式如下:

    \[x_{n+1}=x_n-v_n\] \[v_n=mv_{n-1}+t\nabla f(x_n)\] \[v_0=t\nabla f(x_n)\]

    其中 $t$ 为固定部长, $m$ 为动量系数(用于模拟运动中的阻力),常设置为0.9. 这种算法的优点是稳定性高,且可以摆脱局部最小值。

  • AdaGrad

    Ada即为Adaptive(自适应),这种算法采取了步长衰减的技巧,会依照梯度去调整步长。其公式如下:

    \(x_{n+1}=x_n - \frac t{\sqrt{\omega+\epsilon}}\nabla f(x_n)\) \(\omega=\sum^n_{r=1}||\nabla f(x_n)||^2\)

    其中 $t$ 为固定步长, $\frac1{\sqrt{\omega+\epsilon}}$ 是更新项, $\epsilon$ 是为了让分母不为零的补偿项,一般设置为 $1\times10^{-8}$ 。$\omega$ 为前面所有迭代的梯度值的平方和,前期梯度较小的时候 $n$ 较小,步长能被放大,后期梯度越来越大,能够控制步长变小。后期可能为出现分母过于接近零使得训练结束,所以另有通过均方根来代替平方和的RMSprop算法:

    \[x_{n+1}=x_n-\frac{t}{\sqrt{\omega _n}+\epsilon}\nabla f(x_n)\] \[\omega _n=\phi\omega _{n-1}+(1-\phi)||\nabla f(x_n)||^2\]

    在实际应用中一般取 $\phi=0.9$。

  • Adam

    Adam(Adaptive Moment Estimation),其结合了Momentum和AdaGrad两种算法的特点,其公式如下:

    \[m_n={\beta}_1m_{n-1}+(1-{\beta}_1)\nabla f(x_n)\\\] \[v_n={\beta}_2v_{n-1}+(1-{\beta}_2)||\nabla f(x_n)||^2\]

    Adam的作者发现算法偏移量容易趋近零,所以提出了修正方程以消除偏移量:

    \[\bar{m}_n=\frac{m_n}{1-{\beta}_1^n}\] \[\bar{v}_n=\frac{v_n}{1-{\beta}_2^n}\]

    更新主公式则为:

    \[x_{n+1}=x_n-\frac{t}{\sqrt{\bar{v}_n}+\epsilon}\bar{m}_n\]

    Adam是当前应用最广泛的优化算法。

2.代码实现

了解了BP神经网络及其相关优化算法后,下面我们开始实践编写简单的BP神经网络。首先在上一节配置好的项目中,新建一个名为 MyBPNet 的Python文件,然后导入以下包:

1
2
3
4
5
import numpy as np
import matplotlib.pyplot as plt
import math
import random
from tqdm import tqdm, trange

下面将分块编写代码。

神经网络主体

为便于构建神经网络,这里将其封装为一个名为 MyBPNet 的类。根据BP神经网络的结构,有 输入层 - 隐藏层 - 输出层 三大层,因此在初始化时需要知道这“三”层的尺寸。还需要为每一层隐藏层设计权重 $w$ 和偏置 $b$​ ,并对其随机初始化。根据结构图,该层的输入尺寸 = 上一层的输出尺寸。

初始化

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
def __init__(self, input_size, hidden_size, output_size):
    """
    :param input_size: 输入的特征维度
    :param hidden_size: 隐藏层列表
    :param output_size: 输出的特征维度
    """
    self.ws = []  # 神经网络的每一层隐藏层的参数w
    self.bias = []  # 神经网络的每一层隐藏层的参数b
    last = input_size
    # 输入层和隐藏层
    for i in hidden_size:
        self.ws.append(np.random.rand(last, i))
        self.bias.append(np.random.rand(i))
        last = i
    self.w_out = np.random.rand(last, output_size)  # 输出层的参数w
    self.bias_out = np.random.rand(output_size)  # 输出层的参数b
    
    self.diffs = [np.zeros_like(self.ws[i]) for i in range(len(self.ws))]  # 每一层的参数w的梯度
    self.diffs_b = [np.zeros_like(self.bias[i]) for i in range(len(self.bias))]  # 每一层的参数b的梯度
    # 输出层的参数w, b的梯度
    self.diff_out = np.zeros_like(self.w_out)
    self.diff_out_b = np.zeros_like(self.bias_out)
    
    self.z_list = []  # 每一层矩阵计算后的输出
    self.a_list = []  # 每一层激活后的输出

前向传播

根据上述的步骤进行前向传播。每一轮前向传播前都需要清空当前的输出列表,避免和上一轮前向传播的输出列表搞混。

注意:在隐藏层中,每一步都需要使用激活函数,而输出层不需要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def forward(self, x):
    """
    前向传播函数
    :param x: 输入的数据
    :return: 返回前向传播后的数据
    """
    self.a_list.clear()
    self.z_list.clear()
    for i in range(len(self.ws)):
        x = x @ self.ws[i] + self.bias[i]  # @表示矩阵乘法
        self.z_list.append(x)
        x = act_fn(x)
        self.a_list.append(x)
    x = x @ self.w_out + self.bias_out
    self.a_out = x
    return x

我们上面介绍过他很多种激活函数,因此我们可以对我们的代码加以修改,使之能够使用不同的激活函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def forward(self, x, act_fn: str = 'sigmoid'):
    """
    前向传播函数
    :param act_fn: 激活函数
    :param x: 输入的数据
    :return: 返回前向传播后的数据
    """
    self.a_list.clear()
    self.z_list.clear()
    for i in range(len(self.ws)):
        x = x @ self.ws[i] + self.bias[i]  # @表示矩阵乘法
        self.z_list.append(x)
        if act_fn == 'sigmoid':
            x = sigmoid(x)
        elif act_fn == 'ReLU':
            x = ReLU(x)
        elif act_fn == 'LeakyReLU':
            x = LeakyReLU(x)
        else:
            raise ValueError("act_fn must be 'sigmoid', 'ReLU' or 'LeakyReLU'")
        self.a_list.append(x)
    x = x @ self.w_out + self.bias_out
    self.a_out = x
    return x

最后,还有一个问题。由于反向传播最后一项是对输入 $x$ 进行求导,所以我们还需要在 z_list 中加入 输入 $x$ 以便于处理(当然你也可以不这么干,设计反向传播函数时将输入的 $x$ 作为函数参数传入也行)。

最终我们前向传播代码修改为:

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
def forward(self, x, act_fn: str = 'sigmoid'):
    """
    前向传播函数
    :param act_fn: 激活函数
    :param x: 输入的数据
    :return: 返回前向传播后的数据
    """
    self.a_list.clear()
    self.z_list.clear()
    self.a_list.append(x)
    for i in range(len(self.ws)):
        x = x @ self.ws[i] + self.bias[i]  # @表示矩阵乘法
        self.z_list.append(x)
        if act_fn == 'sigmoid':
            x = sigmoid(x)
        elif act_fn == 'ReLU':
            x = ReLU(x)
        elif act_fn == 'LeakyReLU':
            x = LeakyReLU(x)
        else:
            raise ValueError("act_fn must be 'sigmoid', 'ReLU' or 'LeakyReLU'")
        self.a_list.append(x)
    x = x @ self.w_out + self.bias_out
    self.a_out = x
    return x

反向传播

反向传播这边比较复杂,我们需要对其进行链式求导。

首先是单独对输出层进行求导。因为输出层没有经过激活函数,因此直接利用 $\frac{\part Loss}{\part a_{out}}$ 进行求导,然后使用动量法更新参数。注意,偏置 $b$ 需要沿着横轴做累加,确保其为一维向量。

1
2
3
4
5
6
7
d = loss_MSE_derivative(self.a_out, label)  # dl/da_out
self.diff_out = self.diff_out * alpha + (1 - alpha) * (self.a_list[-1].T @ d)  # da_out/dw_out
self.diff_out_b = self.diff_out_b * alpha + (1 - alpha) * (np.sum(d, axis=0))
d = d @ self.w_out.T
# 更新参数
self.w_out -= learning_rate * self.diff_out
self.bias_out -= learning_rate * self.diff_out_b

接下来就是更新隐藏层的参数,这里先一次性链式求导完,最后再更新参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
for i in range(len(self.ws) - 1, -1, -1):
    if act_fn == 'sigmoid':
        d = d * sigmoid_derivative(self.z_list[i])  # da_4/dz_4
    elif act_fn == 'ReLU':
        d = d * ReLU_derivative(self.z_list[i])
    elif act_fn == 'LeakyReLU':
        d = d * LeakyReLU_derivative(self.z_list[i])
    else:
        raise ValueError("act_fn must be 'sigmoid', 'ReLU' or 'LeakyReLU'")
    # 使用动量法更新参数
    self.diffs[i] = self.diffs[i] * alpha + (1 - alpha) * (self.a_list[i].T @ d)  # dz_4/dw_4
    self.diffs_b[i] = self.diffs_b[i] * alpha + (1 - alpha) * (np.sum(d, axis=0))
    d = d @ self.ws[i].T

完整代码如下:

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
def backward(self, label, learning_rate=0.001, alpha=0.1, act_fn: str = 'sigmoid'):
    """
    反向传播函数
    :param label: 真实值
    :param learning_rate: 学习率
    :param alpha: 动量α的参数
    :param act_fn: 激活函数
    """
    d = loss_MSE_derivative(self.a_out, label)  # dl/da_out
    self.diff_out = self.diff_out * alpha + (1 - alpha) * (self.a_list[-1].T @ d)  # da_4/dw_out
    self.diff_out_b = self.diff_out_b * alpha + (1 - alpha) * (np.sum(d, axis=0))
    d = d @ self.w_out.T
    for i in range(len(self.ws) - 1, -1, -1):
        if act_fn == 'sigmoid':
            d = d * sigmoid_derivative(self.z_list[i])  # da_4/dz_4
        elif act_fn == 'ReLU':
            d = d * ReLU_derivative(self.z_list[i])
        elif act_fn == 'LeakyReLU':
            d = d * LeakyReLU_derivative(self.z_list[i])
        else:
            raise ValueError("act_fn must be 'sigmoid', 'ReLU' or 'LeakyReLU'")
        # 使用动量法更新参数
        self.diffs[i] = self.diffs[i] * alpha + (1 - alpha) * (self.a_list[i].T @ d)  # dz_4/dw_4
        self.diffs_b[i] = self.diffs_b[i] * alpha + (1 - alpha) * (np.sum(d, axis=0))
        d = d @ self.ws[i].T
    # 更新参数
    self.w_out -= learning_rate * self.diff_out
    self.bias_out -= learning_rate * self.diff_out_b
    for i in range(len(self.ws)):
        self.ws[i] -= learning_rate * self.diffs[i]
        self.bias[i] -= learning_rate * self.diffs_b[i]

训练函数

简单起见,不考虑分 batch 的情况。训练时先前向传播,再反向传播更新参数。

1
2
3
4
5
for i in range(epoch):
    out = self.forward(x, act_fn)
    loss = loss_MSE(out, y)
    print(f"Epoch: {i}, Loss: {loss}")
    self.backward(y, learning_rate, alpha, act_fn)

为了绘制损失函数的图像,我们可以将每次迭代得到的 loss 值记录下来,使用 plt 进行画图。

1
2
3
4
5
6
7
8
9
10
losses = []
for i in range(epoch):
    out = self.forward(x, act_fn)
    loss = loss_MSE(out, y)
    losses.append(loss)
    print(f"Epoch: {i}, Loss: {loss}")
    self.backward(y, learning_rate, alpha, act_fn)
    
plt.plot(losses)
plt.show()

对其使用 tqdm 封装,使之能实时查看训练进度。完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def train(self, x, y, learning_rate=0.001, epoch=1000, alpha=0.1, act_fn: str = 'sigmoid', plot=True):
    """
    :param plot: 画loss图
    :param alpha: 动量的参数
    :param act_fn: 激活函数
    :param x: 输入的数据
    :param y: 输出的数据
    :param learning_rate: 学习率
    :param epoch: 迭代次数
    """
    losses = []
    with trange(epoch) as t:
        for i in t:
            out = self.forward(x, act_fn)
            loss = loss_MSE(out, y)
            losses.append(loss)
            t.set_description(f"Epoch: {i}")
            t.set_postfix(loss=loss)
            self.backward(y, learning_rate, alpha, act_fn)
    
    # 画图
    if plot:
        plt.plot(losses)
        plt.show()

测试函数

接受一个输入 $x$ 和 预期输出 $y$ ,从而可以看出模型的学习效果。测试时只需要进行前向传播,无需反向传播更新参数。

1
2
3
4
5
6
7
8
9
10
11
def test(self, x, y, act_fn: str = 'sigmoid'):
    """
    :param x: 输入的数据
    :param y: 真实输出的数据
    :param act_fn: 激活函数
    """
    out = self.forward(x, act_fn)
    print("Input: \n", x)
    print("Test Result: \n", out)
    print("Real Result: \n", y)
    print(f"Test Loss: {loss_MSE(out, y)}")

激活函数

作为样例,我们选择sigmoid,ReLU和LeakyReLU三种激活函数进行编写。由于反向传播中需要用到他们的导函数,我们还需要编写他们的导函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def sigmoid(x: np.matrix):
    return 1 / (1 + np.exp(-x))


def sigmoid_derivative(x: np.matrix):
    return sigmoid(x) * (1 - sigmoid(x))


def ReLU(x):
    return np.maximum(0, x)


def ReLU_derivative(x):
    return np.where(x > 0, 1, 0)


def LeakyReLU(x, alpha=0.01):
    return np.where(x > 0, x, alpha * x)


def LeakyReLU_derivative(x, alpha=0.01):
    return np.where(x > 0, 1, alpha)

损失函数

同样的,按照公式编写即可,并且需要导数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def loss_MSE(pred, label):
    """
    :param pred: 预测值
    :param label: 真实值
    :return: 预测值和真实值之间的MSE
    """
    return np.mean((pred - label) ** 2)


def loss_MSE_derivative(pred, label):
    """
    :param pred: 预测值
    :param label: 真实值
    :return: 预测值和真实值之间的MSE的导数
    """
    return 2 * (pred - label) / pred.size

总结

最后,呈上完整代码。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
import numpy as np
import matplotlib.pyplot as plt
import math
import random
from tqdm import tqdm, trange

np.random.seed(42)
random.seed(42)


def sigmoid(x: np.matrix):
    return 1 / (1 + np.exp(-x))


def sigmoid_derivative(x: np.matrix):
    return sigmoid(x) * (1 - sigmoid(x))


def ReLU(x):
    return np.maximum(0, x)


def ReLU_derivative(x):
    return np.where(x > 0, 1, 0)


def LeakyReLU(x, alpha=0.01):
    return np.where(x > 0, x, alpha * x)


def LeakyReLU_derivative(x, alpha=0.01):
    return np.where(x > 0, 1, alpha)


def loss_MSE(pred, label):
    """
    :param pred: 预测值
    :param label: 真实值
    :return: 预测值和真实值之间的MSE
    """
    return np.mean((pred - label) ** 2)


def loss_MSE_derivative(pred, label):
    """
    :param pred: 预测值
    :param label: 真实值
    :return: 预测值和真实值之间的MSE的导数
    """
    return 2 * (pred - label) / pred.size


def loss_SoftMax(pred, label):
    """
    :param pred: 预测值
    :param label: 真实值
    :return: 预测值和真实值之间的交叉熵
    """
    return -np.mean(label * np.log(pred))


class MyBPNet:
    def __init__(self, input_size, hidden_size, output_size):
        """
        :param input_size: 输入的特征维度
        :param hidden_size: 隐藏层列表
        :param output_size: 输出的特征维度
        """
        self.ws = []  # 神经网络的每一层的参数w
        self.bias = []  # 神经网络的每一层的参数b
        last = input_size
        # 输入层和隐藏层
        for i in hidden_size:
            self.ws.append(np.random.rand(last, i))
            self.bias.append(np.random.rand(i))
            last = i
        self.a_list = []  # 每一层激活后的输出
        self.z_list = []  # 每一层矩阵计算后的输出
        self.w_out = np.random.rand(last, output_size)  # 输出层的参数w
        self.bias_out = np.random.rand(output_size)  # 输出层的参数b
        self.diffs = [np.zeros_like(self.ws[i]) for i in range(len(self.ws))]  # 每一层的参数w的梯度
        self.diffs_b = [np.zeros_like(self.bias[i]) for i in range(len(self.bias))]  # 每一层的参数b的梯度
        # 输出层的参数w, b的梯度
        self.diff_out = np.zeros_like(self.w_out)
        self.diff_out_b = np.zeros_like(self.bias_out)

    def forward(self, x, act_fn: str = 'sigmoid'):
        """
        前向传播函数
        :param act_fn: 激活函数
        :param x: 输入的数据
        :return: 返回前向传播后的数据
        """
        self.a_list.clear()
        self.z_list.clear()
        self.a_list.append(x)
        for i in range(len(self.ws)):
            x = x @ self.ws[i] + self.bias[i]
            self.z_list.append(x)
            if act_fn == 'sigmoid':
                x = sigmoid(x)
            elif act_fn == 'ReLU':
                x = ReLU(x)
            elif act_fn == 'LeakyReLU':
                x = LeakyReLU(x)
            else:
                raise ValueError("act_fn must be 'sigmoid', 'ReLU' or 'LeakyReLU'")
            self.a_list.append(x)
        x = x @ self.w_out + self.bias_out
        self.a_out = x
        return x

    def backward(self, label, learning_rate=0.001, alpha=0.1, act_fn: str = 'sigmoid'):
        """
        反向传播函数
        :param label: 真实值
        :param learning_rate: 学习率
        :param alpha: 动量α的参数
        :param act_fn: 激活函数
        """
        d = loss_MSE_derivative(self.a_out, label)  # dl/da_out
        self.diff_out = self.diff_out * alpha + (1 - alpha) * (self.a_list[-1].T @ d)  # da_out/dw_out
        self.diff_out_b = self.diff_out_b * alpha + (1 - alpha) * (np.sum(d, axis=0))
        d = d @ self.w_out.T
        for i in range(len(self.ws) - 1, -1, -1):
            if act_fn == 'sigmoid':
                d = d * sigmoid_derivative(self.z_list[i])  # da_4/dz_4
            elif act_fn == 'ReLU':
                d = d * ReLU_derivative(self.z_list[i])
            elif act_fn == 'LeakyReLU':
                d = d * LeakyReLU_derivative(self.z_list[i])
            else:
                raise ValueError("act_fn must be 'sigmoid', 'ReLU' or 'LeakyReLU'")
            # 使用动量法更新参数
            self.diffs[i] = self.diffs[i] * alpha + (1 - alpha) * (self.a_list[i].T @ d)  # dz_4/dw_4
            self.diffs_b[i] = self.diffs_b[i] * alpha + (1 - alpha) * (np.sum(d, axis=0))
            d = d @ self.ws[i].T
        # 更新参数
        self.w_out -= learning_rate * self.diff_out
        self.bias_out -= learning_rate * self.diff_out_b
        for i in range(len(self.ws)):
            self.ws[i] -= learning_rate * self.diffs[i]
            self.bias[i] -= learning_rate * self.diffs_b[i]

    def train(self, x, y, learning_rate=0.001, epoch=1000, alpha=0.1, act_fn: str = 'sigmoid', plot=True):
        """
        :param plot: 画loss图
        :param alpha: 动量的参数
        :param act_fn: 激活函数
        :param x: 输入的数据
        :param y: 输出的数据
        :param learning_rate: 学习率
        :param epoch: 迭代次数
        """
        losses = []
        with trange(epoch) as t:
            for i in t:
                out = self.forward(x, act_fn)
                loss = loss_MSE(out, y)
                losses.append(loss)
                t.set_description(f"Epoch: {i}")
                t.set_postfix(loss=loss)
                self.backward(y, learning_rate, alpha, act_fn)

        # 画图
        if plot:
            plt.plot(losses)
            plt.show()

    def test(self, x, y, act_fn: str = 'sigmoid'):
        """
        :param x: 输入的数据
        :param y: 真实输出的数据
        :param act_fn: 激活函数
        """
        out = self.forward(x, act_fn)
        print("Input: \n", x)
        print("Test Result: \n", out)
        print("Real Result: \n", y)
        print(f"Test Loss: {loss_MSE(out, y)}")


if __name__ == '__main__':
    # 生成数据
    x = np.random.rand(100, 1)
    y = x ** 2
    x_t = np.random.rand(2, 1)
    y_t = x_t ** 2
    # 创建网络
    net = MyBPNet(1, [3, 3, 5, 4], 1)
    # 训练
    net.train(x, y, learning_rate=0.00001, epoch=100000, alpha=0.3, act_fn='LeakyReLU', plot=False)
    # 测试
    net.test(x_t, y_t, act_fn='LeakyReLU')

输出结果:

1
2
3
4
5
6
7
8
9
10
11
Epoch: 99999: 100%|██████████| 100000/100000 [00:26<00:00, 3810.56it/s, loss=0.0823]
Input: 
 [[0.03142919]
 [0.63641041]]
Test Result: 
 [[0.32406103]
 [0.350623  ]]
Real Result: 
 [[0.00098779]
 [0.40501821]]
Test Loss: 0.05366757866790473

可以看到,对于 $y=x^2$ 这个函数,模型拟合较好。上面的100000个epoch只是为了能看到 tqdm 的效果,正常训练不用也不应该设置如此大的epoch数。


参考资料

  1. https://baike.baidu.com/item/BP%E7%A5%9E%E7%BB%8F%E7%BD%91%E7%BB%9C/4581827 

  2. https://blog.csdn.net/qq_39521554/article/details/80007223 

  3. https://fange12306.github.io/posts/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E4%B8%AD%E5%87%A0%E7%A7%8D%E5%B8%B8%E8%A7%81%E7%9A%84%E6%A2%AF%E5%BA%A6%E4%B8%8B%E9%99%8D%E7%AE%97%E6%B3%95/ 

本文由作者按照 CC BY 4.0 进行授权