当前位置:   article > 正文

完整教程---Python-手写-BP-实现神经网络_手写最简单的bp网络

手写最简单的bp网络

作者:chen_h
微信号 & QQ:862251340
微信公众号:coderpai


在这篇教程中,我会给大家介绍一些神经网络概念,实现代码和数据推导,可以帮助你更好的理解和构建一个神经网络。网上的一些教程要不侧重于数学推导,要不侧重于代码实现,这些都是非常不利于理解神经网络的。在这篇教程中,我会尽可能慢的帮助大家来理解数学推导和具体代码实现。代码我采用 Python 编写,所以如果你对 Python 有一个简单的认识,那么对阅读本文将会是非常有帮助的。当你学完这篇教程之后,你就可以自己手动编写完整的神经网络,并且在 MNIST 数据集上面可以取得很好的结果。

目录

  1. 什么是人工神经网络?
  2. 神经网络的结构
    2.1 神经元
    2.2 节点
    2.3 偏置项
    2.4 单元组合
    2.5 符号表示
  3. 前馈网络
    3.1

1. 什么是人工神经网络?

人工神经网络(ANNs)是一种模拟人脑设计的软件。我们不需要去理解复杂的生物大脑结构,我们只需要知道大脑是由一些神经元经过组织形成的,而这种组织是有逻辑输入输出关系的。这些关系是根据其微电流或者化学信号来记性调控的。人脑中的神经网络时一个非常巨大互联的神经元网络,其中一个神经元的输出可能是多个神经元的输入。学习是通过不断的去激活一些神经元,从而加强了一些神经元的连接。这些加强的连接可以更加使得我们输出期望的结果。这种反馈学习方式,可以使得神经网络越来越高效率,越来越准确。

人工神经网络尝试去模仿并且简化这种大脑行为,使得这种大脑学习方式可以被用来进行监督学习或者非监督学习。在监督学习中,数据集会提供标准的输入数据和输出数据,使得神经网络可以根据输入数据输出期望的输出。举个例子,垃圾邮件识别。训练集中的数据输入的是一封邮件的内容,其中可能包括很多的单词,输出数据是一个分类结果,说明这封邮件是垃圾邮件还是正常邮件。当我们使用大量的这类邮件数据去训练我们的神经网络时,那么下次神经网络对新的邮件数据输入时,就可以自动判断该邮件是否是垃圾邮件了。

在非监督学习中,我们尝试让神经网络去自己学习数据的结构,从而去分析数据。在本教程中,我们暂且不讨论非监督学习。

2. 神经网络的结构

2.1 神经元

我们设计的人工神经元通过激活函数来模拟生物神经元的放电过程。在分类任务中(比如,上面我们提到的垃圾邮件分类),此时激活函数就是一个分类器的开关——换句话说,一旦输入的数据是大于某一个特定值的,那么状态值就会发生改变,比如从0变成1,从-1变成1等等。这个过程就是模拟了生物神经细胞的开关状态。我们最常用的激活函数就是 sigmoid 函数,如下:

具体代码如下:

import matplotlib.pylab as plt
import numpy as np
x = np.arange(-8, 8, 0.1)
f = 1 / (1 + np.exp(-x))
plt.plot(x, f)
plt.xlabel('x')
plt.ylabel('f(x)')
plt.show()
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

如上图所示,这个函数的功能就是 “激活” 作用,即当我们输入的 x 值大于某一个特定的值时,该函数的值就从 0 变成了 1 。sigmoid 函数不是一个分段函数,它是一个连续函数,也就是说输出结果是一个连续变化,不会发生突变。也就是说,这是一个可导函数,这个性质对于训练算法非常重要。

2.2 节点

正如前面所提到的,生物神经元是按照分层来连接的,也就是说一些神经元的输出结果是另一些神经元的输入数据。我们可以把这个生物神经网络表示成一些连接的节点。每个节点都有很多的输入节点,并且这些节点是经过加权输入的,然后将激活函数应用到这个节点上面,并且产生节点的输出结果。这个解释不是很准确,但是比较直观,请参考下图:

上图中的圆圈就是代表一个节点。激活函数就是作用在这个节点上面的,这个节点将加权之后的数据输入,然后经过激活函数之后,得到输出结果,这个结果就是图中的 h 函数注意:这个节点,在一些别的资料中也称为感知器。

那么,这个权重又是怎么回事呢? 权重是一些实数值(不是二进制数据),他们与数据数据进行相乘,然后在节点中进行相加。我们也可以用下面的数学公式来表示:

其中,wi 是权重值(我们暂且不考虑 b)。那么这些权重是什么呢?这些就是我们需要在模型中学习的变量,这些权重和输入数据共同决定了节点的输出数据。b 是偏置项,该参数增加了节点的灵活性。

2.3 偏置项

让我们来看一个非常简单的节点,它只有一个输入和一个输出:

在上图中,输入到激活函数的数据是 x1*w1。那么在这个简单的神经网络中,权重 w1 是如何影响神经网络的输出呢?具体看如下代码:

w1 = 0.5
w2 = 1.0
w3 = 2.0
l1 = 'w = 0.5'
l2 = 'w = 1.0'
l3 = 'w = 2.0'
for w, l in [(w1, l1), (w2, l2), (w3, l3)]:
    f = 1 / (1 + np.exp(-x*w))
    plt.plot(x, f, label=l)
plt.xlabel('x')
plt.ylabel('h_w(x)')
plt.legend(loc=2)
plt.show()
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

从图中我们可以看到,权重改变了激活函数 sigmoid 函数的斜率变化,权重 w 越大,曲线越陡(斜率越大)。如果我们需要对输入数据与输出数据之间建立不同的连接关系,那么这种添加权重的方式是非常有用的。然而,如果我们只想让 x 大于 1 的时候才去改变输出结果,那么这就是偏置项的作用了。让我们在上述相同的网络中,添加偏置项试试,如下图:

w = 5.0
b1 = -8.0
b2 = 0.0
b3 = 8.0
l1 = 'b = -8.0'
l2 = 'b = 0.0'
l3 = 'b = 8.0'
for b, l in [(b1, l1), (b2, l2), (b3, l3)]:
    f = 1 / (1 + np.exp(-(x*w+b)))
    plt.plot(x, f, label=l)
plt.xlabel('x')
plt.ylabel('h_wb(x)')
plt.legend(loc=2)
plt.show()
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

在上图中,我们增加了 w1 的值,从而来增加曲线的陡峭。正如你看到的,我们改变偏置项 b 的值,从而来改变节点的激活值。因此,通过添加偏置项,我们可以让激活函数去模拟一个通用函数,比如我们要模拟一个函数,当 (x > z) 的时候,函数值为 1,否则函数值为 0 。如果没有这个偏置项,那么激活曲线都是经过原点的,也就是只能模拟一个函数,不能模拟任何的函数。所以显然添加偏置项是非常有用的一种方式。

2.4 单元组合

经过前面的解释,我希望你已经对神经网络中的各个单元结构(节点/神经元/感知器/偏置项)的运行方式有了一定了解。然而,你可能也意识到一点,在一个完整的神经网络中,不会只存在一个神经元,它会存在于多个互相连接的神经元。这些神经元的连接结构可能有很多的结构形式,但是最常见的就是输入层,隐藏层和输出层。比如下面的结构就是一个最简单的网络结构:

上图中是最简单的三层网络 —— Layer 1 表示的是输入层,也就是我们的输入数据层,Layer 2 表示的是隐藏层,请注意隐藏层可以有很多层,Layer 3 是网络的输出层。层与层之间,我们采用很多的线进行连接。从图中,我们可以看出,Layer 1 中的每个节点都会与 Layer 2 中的每个节点进行连接。同样,Layer 2 中的每个节点都会与 Layer 3 中的每个节点进行连接。而这些连接线就是我们上述介绍的连接权重。

2.5 符号表示

注意:因为简书对于数学公式不友好,我们采用 w21(1) 来表示如下公式,也就是说括号里面的数值表示上标:

接下来,我们会做一些数学推导,所以我们需要先来约定一些数学符号表示。我们这里使用的符号表示和 Stanford 的深度学习教程中的一样。权重我们采用如下来表示:

其中,i 表示第 l+1 层中的节点,j 表示第 l 层中的节点。请注意这个顺序,因为层与层之间有一个顺序关系。举个例子,第 1 层中存在节点 1,第 2 层中存在节点 2,那么这两个节点直接的连接权重就如下表示:

这个符号看起来有点奇怪,因为你可以希望 i 表示的是第 l 层的节点,j 表示的是第 l+1 层的节点,即沿着输入到输出方向,而不是相反操作。但是,当我们添加偏置项的时候,这种符号设计就非常有意义了。

正如你在上图中看到的,偏置项(+1)连接到层中的每一个节点。因此,第一层中的偏差项连接到第 2 层中的所有节点。因为偏置项是不具有真实节点的,所有它没有输入数据。偏置项我们采用如下符号表示:

其中,i 表示第 l+1 层中的节点数,与权重采用相同的表示方法。因此,第 1 层中的偏置跟第 2 层中第二个节点连接的偏置为:

最后,节点的输出结果为:

其中,j 表示第 l 层的第 j 个节点。从上面的三层网络中,第 2 层中的第 2 个节点的输出结果用如下符号表示:

现在,我们已经将所有需要用到的数学符号都介绍好了,接下来我们可以利用这些符号来组织计算一整个神经网络。给定输入数据到得出输出结果的过程称为前馈过程。

3. 前馈网络

为了说明神经网络是如何从输入到输出的,我们以上面的三层神经网络为例,可以得到如下的方程式:

在上述的式子中,f(∙) 表示神经元上的激活函数。在这里,我们使用的是 sigmoid 激活函数。在第一行中,h1(2) 表示的是第 2 层网络中第 1 个节点的输出结果,节点的输入数据是 w11(1)*x1,w12(1)*x2, w13(1)*x3 和 b1(1) 的相加和。之后通过激活函数,得到第一个节点的输出结果。这些输入数据也就是上图中的数学公式所展示的。同理,对于第二层的其他的两个节点,我们也可以得到这种结果。

神经网络的最后一层我们只有一个神经元,所以上述公式中我们就只有一个节点的输出。从公式中,我们可以观察到我们不是直接输入 (x1, x2, x3) ,而是将上一层的输出 (h1(2), h2(2), h3(2)) 作为第三层的输入。因此,也就是我们上述看到的公式。这也说明了一个问题,神经网络是由层次结构的。

3.1 前馈过程准备

现在,我们利用 Python 来简单介绍一个神经网络。首先,我们来定义第一层和第二层之间的连接权重,我们可以用如下矩阵来表示:

这个矩阵,我们可以利用 Python 中的 numpy 包来简单实现,如下:

import numpy as np
w1 = np.array([[0.2, 0.2, 0.2], [0.4, 0.4, 0.4], [0.6, 0.6, 0.6]])
  • 1
  • 2

我们可以按照相同的方式来实现第二层网络与输出层之间的连接矩阵,如下:

w2 = np.zeros((1, 3))
w2[0,:] = np.array([0.5, 0.5, 0.5])
  • 1
  • 2

我们可以采用相同的方法来构造我们的偏置项,如下:

b1 = np.array([0.8, 0.8, 0.8])
b2 = np.array([0.2])
  • 1
  • 2

最后,在编写我们的神经网络之前,我们还需要设计一个激活函数,如下:

def f(x):
    return 1 / (1 + np.exp(-x))
  • 1
  • 2
3.2 前馈过程尝试

下面我们使用 Python 来实现一个最简单的前馈网络计算过程,并且还会给出一个更加高校的计算方法:

def simple_looped_nn_calc(n_layers, x, w, b):
    for l in range(n_layers-1):
        #Setup the input array which the weights will be multiplied by for each layer
        #If it's the first layer, the input array will be the x input vector
        #If it's not the first layer, the input to the next layer will be the 
        #output of the previous layer
        if l == 0:
            node_in = x
        else:
            node_in = h
        #Setup the output array for the nodes in layer l + 1
        h = np.zeros((w[l].shape[0],))
        #loop through the rows of the weight array
        for i in range(w[l].shape[0]):
            #setup the sum inside the activation function
            f_sum = 0
            #loop through the columns of the weight array
            for j in range(w[l].shape[1]):
                f_sum += w[l][i][j] * node_in[j]
            #add the bias
            f_sum += b[l][i]
            #finally use the activation function to calculate the
            #i-th output i.e. h1, h2, h3
            h[i] = f(f_sum)
    return h
  • 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

这个函数将神经网络的层数,输入数据(以一个向量的形式输入),权重和偏置项(以元组或者列表的形式输入)作为输入,换句话说,我们的输入设置如下:

w = [w1, w2]
b = [b1, b2]
#a dummy x input vector
x = [1.5, 2.0, 3.0]
  • 1
  • 2
  • 3
  • 4

该函数首先会检查输入到每一层神经网络的数据。我们看第一层网络的输入就是我们的输入数据,第二层网络的输入就是我们第一层网络的数据乘以权重,然后加上偏置项,最近经过激活函数之后的数据。之后的每一层的输入数据都是前面一层的输出数据。最后,我们通过一个嵌套循环不断地计算每一个节点的数据,从而计算出一整个网络的结构。

具体调用如下:

simple_looped_nn_calc(3, x, w, b)
  • 1

这个函数最后输出的结果是 0.8354 。我们可以通过原始的数学公式推导来验证这个结果是否正确,如下:

3.3 一个更加高效的实现

正如前面所分析的,我们利用 Python 循环计算神经网络中每一个节点的数值不是最有效的计算方法。这是因为 Python 中的循环计算是非常慢的,下一节我们将讨论一种高效的替代方法,它是利用 Numpy 包来实现的。在讨论替代方法之前,我们需要先对这个方法做一点基准测试,基准测试我们可以通过 IPython 中的 %timeit 函数来实现。这个函数会将把该函数运行多次,然后返回该函数运行的平均时间。调用如下:

%timeit simple_looped_nn_calc(3, x, w, b)
  • 1

我们运行上面的代码,可以得到 40μs,也就是说每运行一次这个程序平均就需要 40 μs。数十微秒的结果看起来非常的快,那是因为我们的网络结构非常简单,如果我们把每层的节点数添加到 100 以上甚至更高,那么网络的运行速度也会越来越慢。特别是在我们训练网络的时候,这个速度会更加慢。举个例子,如果我们把上述的网络层添加到四层,那么运行时间就马上提高至 70 μs。

3.4 神经网络数值向量化

从数学计算上面来讲,我们还有更加优雅的书写方式,可以更加直观的表示神经网络的前馈过程。首先我们需要增加一个新的变量 zi(l),这个变量表示第 l 层第 i 个节点的总输入,包括偏置项的值。我们以第二层第一个节点为例子,可以得到如下的数学推导:

其中,n 表示第一层中的节点数。使用这种符号表示,我们可以把以前对每一个节点的计算(也就是要计算三次,因为有三个节点),压缩到一组数学计算:

注意,我们的大写 W 代表的是一层权重集合。我们需要注意的是,我们所有等式中的所有元素都是以矩阵或者向量的形式存储的。如果你对这些概念不是很了解,不要担心,我们会在下面讲解这些概念。上述方程是不是看起来还是有点繁琐,是不是还可以进一步简化?答案是肯定的,我们可以通过泛化用一个参数来表示一个层的权重值,如下所示:

这里我们看到了一般的前馈过程,其中第 l 层的输出结果作为了第 l+1 层的输入数据。h(1) 表示的是输入层 x 的值,h(nl) (nl 表示神经网络的层数)表示的是输出层的值。请注意,我们在上述的方程中去掉了节点编号 ij ,为什么我们能这样做呢?难道我们不需要去循环计算每一个节点直接的输入和输出吗?

答案当然是肯定的,我们可以使用矩阵乘法来更简单的实现这一点。这个过程就是我们的 “向量化”,它有两个好处。第一,它可以使得代码更加简单,正如你看到的数学推导一样。第二,我们可以在 Python 中使用快速的线性代数表示实现(比如,numpy 就可以很轻松的处理这一些),而不是采用循环表示,这将加快我们的程序运行速度。如果,你不是很熟悉矩阵运算,那么我们下一节将介绍。

3.5 矩阵乘法

我们以矩阵或者向量的形式可以得到如下的推导公式:

举个例子,我们以第二层为例,如下:

对于那些还不知道矩阵乘法是如何工作的,这里有一个比较好的网站可以帮助你学习,请点击这里。但是,为了快速学习这个算法,我们这里来简单说明一下矩阵乘法的运算过程。也就是说,左边矩阵的每一行与右边矩阵的每一列进行相乘,然后把相乘之后的结果进行相加,就是最后新矩阵的元素值。然后,我们就可以简单的将偏置项进行相加从而得到最终结果。

从上式你就可以观察到,最后矩阵运算结果的每一行都是对应于原始非矩阵运算的激活函数中的参数。如果激活函数能以一个元素的形式被应用,那么我就可以使用矩阵向量来进行计算,而不是通过循环计算。幸运的是,Numpy 包可以帮助我们来实现这一点,具体查看如下代码:

def matrix_feed_forward_calc(n_layers, x, w, b):
    for l in range(n_layers-1):
        if l == 0:
            node_in = x
        else:
            node_in = h
        z = w[l].dot(node_in) + b[l]
        h = f(z)
    return h
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

请注意上面的代码,我们核心的矩阵运算是发生在程序的第 7 行。如果你只是简单的使用 * 符号,那么只是执行的一个乘法操作,并不是我们需要的矩阵相乘操作。因此,在 numpy 中执行矩阵相乘时,我们需要使用的是 a.dot(b) 符号。

如果我们再次使用 %timeit 函数来执行这个函数,那么我们的运行时间能缩短到 24 μs。然而,如果我们把四层的神经网络的节点大小增加到 100 - 100 - 50 - 10,那么采用循环计算的方式,我们得到的执行时间是 41 ms,但是如果我们采用矢量化的方法进行试验,那么时间只需要 84 μs,这是多么大的改进啊,有没有很吃惊。我们居然提高了大约 500 倍!!!这是一个巨大的进步。当然我们还可以使用深度学习软件来提高矩阵运算的速度,比如 TensorFlow 或者 Theano,因为它们是利用 GPU 进行矩阵运算,而不是 CPU。这些软件的架构设计更加适合于矩阵运算。

接下来,我们将学习如何训练这个神经网络,使得这个神经网络可以进行分类训练。

4. 梯度下降和优化方法

正如我们在上面提到的,我们的连接权重是需要我们经过训练的。在监督学习中,我们的期望是减少模型的预期输出与真实输出之间的误差。比如,我们有一个神经网络,我们输入数据 x ,我们希望我们的神经网络的输出数据是 2,但是我们的神经网络的输出数据却是 5,那么一个最简单的误差表示就是 abs(2 - 5) = 3 。从数学上来讲,这个误差就是 L1 范数。

监督学习的想法是提供多对输入输出数据,然后根据这些数据来训练我们的神经网络,也就是不断的更新我们的权重和偏差项,使得我们的误差越来越小,也就是期望输出越来越接近实际样本输出。我们可以将这些输入输出数据对整理为 {(x(1),y(1)),…,(x(m),y(m))} ,其中 m 表示训练数据的样本数量。所以这些输入(输出)的数据都可以是一个向量,也就是每一个都不是表示一个单一的值。举个例子,比如我们的神经网络是去检测一个邮件是否是垃圾邮件,那我们的输入 x(1) 可能即使一系列的单词,比如:

在这种情况下,y(1) 可以是一个标量,1 或者是 0 表示邮件是否是垃圾邮件。或者,可以表示说一个 K 维度的向量。比如,我们输入的 x 是一张照片的灰度值的向量,输出的 y 是一个 26 维度的向量,即向量 (1, 0, …., 0) 表示的是字母 a,向量 (0, 1, 0, …, 0) 表示的是字母 b ,等等。所以,我们可以利用这个 26 维度的向量来进行字母分类。

在使用这些训练数据训练神经网络的时候,我们的目标是使得神经网络的预期输出与数据要求的相同。那么,达到这种效果是怎么实现的呢?答案是我们通过改变权值来实现这一点的。那么,怎么改变呢?这就是我们要讲的梯度下降的概念了,思考下面的一张图:

在这张图中,我们用蓝色的曲线画出了误差和权值 w 之间的变化曲线。最小误差就是我们用黑色的叉叉表示出来的地方,但是我们不知道哪个 w 是最好的结果。所以我们从一个随机值开始计算 w 值,也就是我们图中标记的 1 的位置。然后我们需要去改变这个 w 值,使得这个值不断的接近最低点,也就是刚刚所说的黑色叉叉。那么如何计算整个最低点呢?最常用的方法之一就是梯度下降。

为了进行这个方法,首先我们需要去计算点 1 处 w 的误差的梯度。所谓的梯度,就是该点的误差曲线在该点的斜率。在上图中,黑色箭头表示的方向就是梯度的反方向,斜率大小就是梯度值。如果 w 的值朝着梯度的方向发展,那么我们的误差就会越来越大。如果 w 的值朝着梯度的反方向发展,那么我们的误差就会越来越小。显然,这也是我们想要看到的结果。梯度的大小或者斜率的陡峭程度表示了该点误差变化的速度,如果梯度的幅度越大,那么该点的误差变化速度越快。

梯度下降法就是利用曲线的梯度来使得误差最小。当然我们无法一次就达到最小点,这里是一个不断迭代的过程,w 值根据如下公式进行更新:

在上式中,wnew 表示的是新的 w 的值,wold 表示的是旧的 w 的值。∇error 表示的是 wold 处的误差的梯度,α 表示的是步长(也就是学习率)。步长 α 将决定解最下误差的收敛速度。这个步长参数是非常重要的一个参数,如果步长太大,那么你可以想象一下上图曲线上的红点会在曲线两边不断反弹。这也将导致曲线不会收敛于最低点。如果步长太小,那么我们收敛的速度会非常慢。当权重 w 的值不断地靠近最佳值时,梯度值会越来越小。当梯度值减小到一定值时,我们认为权值 w 就是达到了最佳值。而这个一定程度通常称为精度。

4.1 一个简单的代码示例

下面是一个简单的利用 Python 实现的梯度下降例子,用于求解方程 f(x) = x^4-3x^3+2 的最小值,这个方程是来自于 Wikipedia。这个函数非常简单,它的梯度可以被分析计算出来,也就是说我们可以很容易的使用微机分进行计算,对这个函数求导可以得到方程 f′(x)=4x^3–9x^2 。这意味着,我们对于函数上任意一点都可以采用这个简单的方程来进行计算它的梯度,即导数。再次使用微机分,我们可以知道这个方程的最小值是当 x = 2.25 时。

x_old = 0 # The value does not matter as long as abs(x_new - x_old) > precision
x_new = 6 # The algorithm starts at x=6
gamma = 0.01 # step size
precision = 0.00001

def df(x):
    y = 4 * x**3 - 9 * x**2
    return y

while abs(x_new - x_old) > precision:
    x_old = x_new
    x_new += -gamma * df(x_old)

print("The local minimum occurs at %f" % x_new)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

这个函数最终会输出 “The local minimum occurs at 2.249965”,这与我们要求的精度是一直的。该代码实现了我在上面描述的权重调节算法,可以看出在给定的精度要求下,函数找到了最小值。这是一个非常简单的梯度应用的例子,我们在训练神经网络的时候可能会有一些不停。但是,主要思想还是一致的。我们找到神经网络的梯度,然后利用梯度一步一步的去调整权重和偏差项,尝试让我们的误差项越来越小,最后达到我们设定的精度要求。另一个与这个例子不同的是,神经网络中的计算过程都是多维度的, 因此我们必须在多维度空间中寻找函数的最小点。

我们上述讨论的这个利用梯度来寻找神经网络最优点的方法就是著名的反向传播方法(backpropagation method),接下来我们会简单的来讲解这个反向传播算法。在讲解之前,我们需要来仔细的学习一下损失函数。

4. 损失函数

前面我们已经讨论过了,利用梯度不断的去迭代修改神经网络的权重,使得最后神经网络的预期输出与真实结果越来越相近。然而,事实证明,有一种数学上更加普遍的方式来表达我们的预期输出与真实结果之间的误差,同时我们还可以通过这种表达来观察函数是否过拟合或者欠拟合,这种更加一般的优化形式就是所谓的最小化损失函数。假设,我们的神经网络中的训练数据对是 (x(z), y(z)),那么我们的损失函数可以设计如下:

上述公式展示了第 z 个训练样本的损失函数,其中 h(nl) 是神经网络最后一层的输出,也就是整个神经网络的输出结果。我将公式中的 h(nl) 值修改为了 ypred 值,这也是为了突出这个值是由于神经网络训练得到的预测值。整个误差损失值,我们采用的是 L2 范数,或者成为平方和误差(SSE)。SSE 是机器学习系统中衡量误差的最常见的一种方式。那么,为什么我们使用平方和误差,而不是我们上面提到的绝对误差 abs(ypred(xz) - yz) 呢?原因有很多,这里主要介绍两个原因:第一,从学习理论的角度来说,L2 范数可以防止过拟合,提升模型的泛化能力。第二,从优化计算的角度来说,L2 范数有助于处理 condition number 不好的情况下矩阵求逆很困难的问题。式子前面的系数 1/2 是我们人为添加上去的,这是因为我们后续对损失函数进行求导的时候,会产生一个系数 2,这里添加 1/2 是为了后续的计算简便。

请注意,上述我们描述的损失函数只是对应于一个训练样本 (x, y) 的。我们希望的是对于所有 m 个训练样本的损失函数值降到最低。因此,我们想要得到所有样本的训练误差的最小均方误差(MSE),如下:

那么,我们是如何利用上述的损失函数来训练我们网络的权重呢?答案是梯度下降法和反向传播算法。所以,接下来就让我们先来近距离的看一下梯度下降是如何应用在神经网络中的。

4.3 神经网络中的梯度下降

对于神经网络中的每一个权重 wij(l) 和偏差项 bi(l) ,梯度下降的公式推导如下:

基本上,上面的等式与我们之前描述的梯度下降算法非常类似:wnew = wold – α∗∇error 。虽然我们把等式中的 wnew 和 wold 参数给去掉了,但是现在等式的左边就相当于原来的 wnew 参数,等式的右边就相当于原来的 wold 参数。但是我们的迭代过程还是存在的,每一次程序的迭代都会去更新权重。

图中还有两个如下的符号:

这两个符号是对于权重和偏差项的单个样本损失函数的偏导数。这是什么意思呢?回想一下,我们上面提到的对于简单例子的梯度表示,每一步的梯度都是损失函数相对于权重的斜率。斜率或者梯度的另一个解释就是导数。普通导数的形式如下:

如果这个导数中的参数 x 是一个向量,那么这个导数的结果也就会是一个向量的形式,即对向量 x 中的每一个元素都会进行求导,而对每一个元素进行求导,也就是我们所说的偏导数。

4.4 二维梯度下降例子

接下来,让我们举一个标准的二维梯度下降问题的例子,下图就是二维平面的梯度下降迭代图:

上图中的蓝色线表示的就是不同取值时,对应的损失函数值,同一条蓝色线上面的损失值大致相同(其实和等高线原理差不多)。从图中我们可以看出,每一步(p1→p2→p3→p4)的变化都是沿着该点的梯度或者导数的垂直方向进行的,也就是说这是一个矢量。比如,在点 p1 处我们的梯度是

这个公式就表示点 p1 处对于 x1 和 x2 的方向是 2.1 和 0.7。但我们在求解的时候是需要一个一个参数进行求解,也就是说,我们先对 x1 进行求解。 在这种情况下,我们对 x1 进行偏导数求解如下:

那么,该值将是 2.1 。换句话说,我们在 x1 的搜索空间中,可以得到该点的梯度值是 2.1 。在神经网络中,我们一般都会有很多的参数,比如 [x1, x2, …, xn]。那么,在梯度下降中我们一般都会对每一个参数进行求偏导数,然后把所有的值再组合起来,形成一个新的向量,而这个向量就是一个完全的梯度。

在神经网络中,我们不会像上面的例子一样,很容易就可以计算神经网络损失函数的梯度。事实上,这个过程非常麻烦。虽然我们可以很容易的将神经网络的预期输出结果和真实结果之间对比,来生成损失函数,然后利用损失函数来计算输出层的梯度变化。那么,我们怎么来计算隐藏层的梯度变化呢?

来解决这个问题的答案就是著名的反向传播算法。这种方法是我们将损失函数“共享”给神经网络中的每一个权重和偏差项。换句话说,我们可以利用最后的损失函数来计算每一个参数的梯度。

4.5 反向传播算法数学推导

在这部分中,我们将来学习一些反向传播算法的数学推导。如果你不是很喜欢数学推导,那么可以直接跳过这一节。下一节我们会来学习如何利用 Python 来实现这一个反向传播算法。但是,如果你不是很反感数学,我还是建议你可以学习一下数学推导,这将帮助你更加深入的理解反向传播算法,从而更加深入的理解神经网络的训练过程。了解神经网络的核心部分是非常有必要的,这比那些只会调参的学习者会强很多。

首先,我们还是以最简单的三层神经网络为例来说明问题,如下:

这个神经网络的输出可以按照如下计算:

其中,

假设,我们现在想要求权重 w12(2) 对于损失函数 J 的梯度,也就是:

但是,我们无法直接去计算这个值。为了做到这一点,我们必须去使用链式法则来帮助我们:

如果你查看上式右边的式子,利用分子和分母可以相互约去的数学原理,比如:

那么,就可以很容易的得到等式左边的表示方法。

因此,当我们利用损失函数去计算权重的梯度的时候,我们可以巧妙的利用这一个链式规则,把式子拆成几个偏导数相乘的形式来计算梯度。但是,每一个偏导数的计算又是非常简单的,比如我们拿如下作为一个例子:

上式中,z1(2) 对于 w12(2) 的偏导数我们只关心其中的 w12(2)h2(2) 项,因为这有这一项才存在参数 w12(2),别的项我们都可以当做常数来看待。而对常数进行求导之后的结果是等于零,也就是可以直接舍去。因此,偏导数的最后结果就是 h2(2),也就是第二层中第二个节点的输出结果。

接下来,我们考虑导数第二个偏导数:

这个偏导数其实就是对于激活函数的导数。由于我们在反向传播算法中对激活函数求导,所以一般我们在神经网络中的激活函数都是可微分的。比如最常见的 sigmoid 函数的导数形式如下:

其中,f(z) 表示激活函数。至此,我们把这个偏导数也解决了。

接下来,我们需要去求解第一个偏导数:

请回忆一下,我们在这里定义的损失函数是利用的均方误差损失函数,它看起来的形式如下:

其中,y1 是样本的真实输出结果,我们继续使用链式规则来分解这个方程:

所以,我们现在就计算出来了损失函数 J 对于权重 w12(2) 的梯度,至少对于连接输出层的权重我们都可以计算出来了。在我们去分析隐藏层的权重如何计算梯度之前(也就是我们图中的第二层),我们先来介绍一些符号,可以帮助我们简化后续的数学表示,如下:

其中,i 表示输出层的节点编号。在我们上图的例子中,我们的输出层只有一个节点,所以这里 i 永远是等于 1 的。所以,我们现在可以将损失函数对于权重的梯度改写为:

其中,对于输出层而言,l = 2 并且 i 表示该层神经元编号。

4.6 反向传播到隐藏层

那么对于隐藏层(比如我们上图中的第二层)的权重我们是如何处理的呢?对于连接输出层的权重,下面的求导式子是非常有意义的:

因为可以通过将神经网络的输出结果与训练数据的真实结果来计算损失函数的导数。然而,对于神经网络中的隐藏层,就没有那么直接的计算方法。我们需要通过其他的权重来连接损失函数吗,然后计算每个节点的梯度。那么,我们该如何来处理呢?正如我们前面提到的,我们可以使用反向传播算法来做。

现在我们使用链式规则来处理这个棘手的问题,我们采用图形化的表示方法。我们需要神经网络反向传播的是 δi(nl) 项,因为这个项是神经网络跟损失函数直接连接的项。那么在隐藏层中的节点 j 我们又是如何计算的呢?具体我们可以参考如下的图:

从上图我们可以看到,输出层的 δ 通过与隐藏层连接的权重传送到隐藏节点。在只有一个输出层节点的情况下,我们可以将 δ 定义为:

其中,j 表示第 l 层的节点编号。那么,有多个输出节点的情况如何处理呢?在这种情况下,我们将所有传送的误差都进行相加计算,正如下图所展示的:

正如我们在上图中看到的,对每个来自输出层的 δ 值进行求和用来计算 δ1(2) 的值,但是每个 δ 值都被 wi1(2) 进行加权计算。换句话说,第二层中的第 1 个节点对三个输出节点的误差计算是有影响的,因此对于这个节点中关联的每个节点的误差都必须被“传回”到该节点的 δ 值。现在,我们可以为隐藏层中的节点 δ 设计一个一般的表达式:

其中,j 表示第 l 层的节点编号,i 表示第 l+1 层的节点编号(这种表示方法是我们从一开始就用的符号)。

所以,现在我们知道了我们是如何计算权重的偏导数的:

正如上图展示的,我们现在可以很容易的求得损失函数对于权重的偏导数,那么对于偏差项的偏导数又是如何计算的呢?


来源:heheda

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/94836
推荐阅读
相关标签
  

闽ICP备14008679号