2.1.2. 前馈神经网络的工作机制#

前面我们已经认识了单个神经元模型和多层神经网络,接下来需要探索神经网络的工作机制。我们在入手最基础的前馈神经网络前,我们需要思考以下几个问题:如何将给定的数据转化成神经网络的输入,应该怎样设计神经网络的架构,信号在神经网络中是如何计算并传播的,神经网络是如何学习并不断优化的?读者可以带着这些问题进行这一节的学习。

  1. 向量和矩阵

首先需要明确的是,在神经网络中,输入、输出数据以及网络的特征和权重都以矩阵的形式表示和存储,神经网络中的计算本质上都是对矩阵的运算。因此,了解矩阵的运算法则及意义,能够帮助我们理解深度学习中的一些基本概念和计算过程,例如前向传播和反向传播。在本节中,我们将会对向量和矩阵进行介绍,建立初步的概念,这也是后面神经网络学习的基础。

向量(Vector) ,通常用加粗的斜体小写字母或小写字母顶部加箭头来表示。只有一行元素构成的向量,称为行向量。只有一列元素构成的向量,则称为列向量。例如,一个3维列向量α可表示为:

\[\begin{split} α = \begin{bmatrix} a_1\\ a_2 \\ a_3 \end{bmatrix} \end{split}\]

矩阵(Matrix) ,由m×n个数排成的m行n列数表,通常用一个加粗的斜体大写字母来表示。一个m×n矩阵,可视为由m个行向量组成,每个行向量为n维;也可视为由个n列向量构成,每个列向量为m维。特别地,当m = n时,该矩阵叫做n阶方阵。例如,一个3×2阶矩阵A可表示为:

\[\begin{split} A = \begin{bmatrix} 2 & -4\\ 3 & 1 \\ 0 & 6 \end{bmatrix} \end{split}\]

接着,我们将介绍在深度学习中最常用的矩阵运算法则。

矩阵加减法:两个矩阵的加减法是将矩阵中对应的元素相加减,运算的前提是:两个矩阵需要具有相同的行和列数。下式分别举例了二阶方阵的加法和减法。

\[\begin{split} \begin{bmatrix} 1 & 2\\ 3 & 4 \end{bmatrix} + \begin{bmatrix} 1 & 2\\ 3 & 4 \end{bmatrix} =\begin{bmatrix} 1+1 & 2+2\\ 3+3 & 4+4 \end{bmatrix} = \begin{bmatrix} 2 & 4\\ 6 & 8 \end{bmatrix} \end{split}\]
\[\begin{split} \begin{bmatrix} 1 & 2\\ 3 & 4 \end{bmatrix} - \begin{bmatrix} 1 & 2\\ 3 & 4 \end{bmatrix} =\begin{bmatrix} 1-1 & 2-2\\ 3-3 & 4-4 \end{bmatrix} = \begin{bmatrix} 0 & 0\\ 0 & 0 \end{bmatrix} \end{split}\]

在Python中,可以使用NumPy.array()方法生成向量或者矩阵。上述运算可转化为代码:

chapter_2_1_2_01.py#
 1import numpy
 2
 3# 定义两个矩阵
 4A = numpy.array([[1, 2], [3, 4]])
 5B = numpy.array([[1, 2], [3, 4]])
 6# 矩阵加减法
 7C = A + B
 8print(C)
 9D = A - B
10print(D)

矩阵转置:将矩阵A的行换成同序数的列得到的新矩阵称为A 的转置矩阵,记作\(A^T\)。例如矩阵:

\[\begin{split} A = \begin{bmatrix} 1 & 2 & 0\\ 3 & 4 & 2 \end{bmatrix} \end{split}\]

其转置矩阵为:

\[\begin{split} A^T = \begin{bmatrix} 1 & 3\\ 2 & 4\\ 0 & 2 \end{bmatrix} \end{split}\]

在Python中,上述运算可转化为代码:

chapter_2_1_2_02.py#
1import numpy
2
3A = numpy.array([[1, 2, 0], [3, 4, 2]])
4# T 表示转置操作 即矩阵B是矩阵A的转置矩阵
5B = A.T
6print(B)

矩阵乘法:设A = (\(a_{ij}\))是一个m×s矩阵,B = (\(b_{ij}\) ) 是一个s×n矩阵,那么规定矩阵A与矩阵B的乘积是一个m×n矩阵C = (\(c_{ij}\)) ,其中

\[\begin{split} \begin{align} \begin{array} c_{ij} = (a_{i1})(b_{1j}) + a_{i2}b_{2j} + ··· + a_{is}b_{sj} = \sum\limits_ {k=1}^sa_{ik}b \\ (i=1,2,···,m;j=1,2,···,n) \\ \end{array} \ \tag {2-1} \end{align} \end{split}\]

并把此乘积记作 C=AB。从式(2-1)可得矩阵C的第i行第j列的元素等于矩阵A 的第i行的元素与矩阵B的第j列对应元素乘积之和。例如,

\[\begin{split} C=AB=\begin{bmatrix} 1 & 2 & 3 \end{bmatrix} · \begin{bmatrix} 4\\ 5\\ 6 \end{bmatrix}=\begin{bmatrix} 32 \end{bmatrix} \end{split}\]

我们称矩阵的乘法为点积(Dot Product)。在Python中,我们采用numpy.dot() 函数完成上述运算,将运算过程转化为代码如下:

chapter_2_1_2_03.py#
1import numpy
2
3A = numpy.array([[1, 2, 3]])
4B = numpy.array([[4], [5], [6]])
5# dot()表示矩阵乘法
6C = numpy.dot(A, B)
7print(C)

必须注意:矩阵乘法是不可交换的(即AB≠BA )。而且只有当第一个矩阵的列数和第二个矩阵的行数相同时,两个矩阵才能相乘。虽然矩阵乘法是人为的规则,但它大大简化了计算的表达,可以将巨大的计算量很简洁地表达出来,这一点对机器学习算法的开发和使用有重要的作用。与此同时,GPU 的设计就是源于矩阵计算处理的基本概念,GPU 会并行地操作整个矩阵里的元素,而不是一个一个地串行处理,提高了计算速度。

上面介绍的矩阵乘法又被称作矩阵的内积(Inner Product),还有另一种矩阵乘法运算——外积(Outer Product),用符号\(\bigotimes\)表示。假设矩阵A = (\(a_{ij}\))、B = (\(b_{ij}\)) 分别为两个2×2的矩阵,则的A\(\bigotimes\)B计算可以表示为:

\[\begin{split} \begin{align} A \bigotimes B=\begin{bmatrix} a_{11}B & a_{12}B \\ a_{21}B & a_{22}B \end{bmatrix}=\begin{bmatrix} \begin{matrix} a_{11}b_{11} & a_{11}b_{12} \\ a_{11}b_{21} & a_{11}b_{22} \\ \end{matrix}& \begin{matrix} a_{12}b_{11} & a_{12}b_{12} \\ a_{12}b_{21} & a_{12}b_{22} \\ \end{matrix} \\ \begin{matrix} a_{21}b_{11} & a_{21}b_{12} \\ a_{21}b_{21} & a_{21}b_{22} \\ \end{matrix} & \begin{matrix} a_{22}b_{11} & a_{22}b_{12} \\ a_{22}b_{21} & a_{22}b_{22} \\ \end{matrix}\\ \end{bmatrix} \ \tag {2-2} \end{align} \end{split}\]

除此之外,Hadamard 积也是我们常用的一种矩阵乘法运算,用符号\(\bigodot\)表示。Hadamard积要求参与运算的两个矩阵形状相同,然后对两个矩阵对应位置上的元素相乘,并产生相同维度的第三个矩阵。

假设矩阵A = (\(a_{ij}\))、B = (\(b_{ij}\)) 分别为两个3×2矩阵,则A与B的Hadamard积结果C也是一个3×2矩阵,可表示为:

\[\begin{split} \begin{align} A \bigodot B = \begin{bmatrix} a_{11} & a_{12}\\ a_{21} & a_{22}\\ a_{31} & a_{32}\\ \end{bmatrix} \bigodot \begin{bmatrix} b_{11} & b_{12}\\ b_{21} & b_{22}\\ b_{31} & b_{32}\\ \end{bmatrix}=\begin{bmatrix} a_{11}b_{11} & a_{12}b_{12} \\ a_{21}b_{21} & a_{22}b_{22} \\ a_{31}b_{31} & a_{32}b_{32} \\ \end{bmatrix} \ \tag {2-3} \end{align} \end{split}\]

矩阵在神经网络中的应用十分广泛。例如,神经网络中的神经元,需要接收来自其他神经元的信号。这些信号来源不同,对神经元的影响也不同。需要给每个输入信号分配一个权重,神经元将对接收到的信号进行加权求和作为其输入。整个过程如果用数学公式一一表示将会非常复杂,在后续的学习中,我们会更加深入地体会到矩阵在简化神经网络表达中的重要作用。

  1. 激活函数

从上一小节中,我们知道了前馈神经网络中每个神经元对于输入数据x的运算可以由下式表示:

\[o=A(wx+b) \tag {2-4}\]

其中,A是神经元使用的激活函数,w为神经元的权重(Weights),b是偏置(Bias),它们是神经网络中可通过不断训练而学习到的参数。

很显然,如果不使用激活函数,那么每一个神经元中都只是对输入数据进行了简单的线性变换,每一层输出都是上一层输入的线性函数,这样,即使网络的深度再深,输出值都只是输入值的线性组合。而激活函数则为神经元引入了非线性因素,这样神经网络就具有了更强大的表达能力和拟合能力。

为了更好地理解什么是激活函数,我们用一个简单的阶跃函数来说明。

图 2-4 阶跃函数

图 2-4 阶跃函数#

如上图所示,在输入值小于阈值时,函数的输出为0。一旦输入达到设定的阈值,输出就一跃而起。具有这种行为的人工神经元就像一个真正的生物神经元,只有当输入信号达到了阈值,这个神经元才会激活,处于兴奋状态;否则这个神经元将被抑制。但是阶跃函数的缺点也是非常明显的。首先,它是不连续且不光滑的,这就导致在反向传播时这一层很难学习。其次,阶跃函数“非黑即白”的特性虽然可能最符合生物神经网络,但是实际中有时我们需要表达“20%被激活”这种概念,即我们需要一个模拟的激活值而非简单的“0”或“1”。

因此,我们需要改善激活函数使其具备如下性质:

  • 激活函数应是连续并可导(允许少数点上不可导)的非线性函数。

  • 激活函数及其导函数要尽可能的简单,这有利于提高网络的计算效率。

  • 激活函数的导函数的值域要在一个合适的区间内,不能太大也不能太小,否则会影响训练的效率和稳定性。

总而言之,激活函数对神经网络而言非常重要,它通过为神经元设定阈值,使得网络中各层次的输入数据始终存在于某一范围之内,很大程度上影响着模型的学习效果及效率。下面介绍几种神经网络中常用的激活函数:

(1)Sigmoid函数

Sigmoid函数也叫Logistic函数,在逻辑回归、人工神经网络中有着广泛的应用。Sigmoid激活函数和导函数的表达式为:

\[ Sigmoid(x) = \dfrac {1} {1+e^{-x}} \tag {2-5} \]
\[ Sigmoid^{'}(x) = \dfrac {e^{-x}} {(1+e^{-x})^2} \tag {2-6} \]

从下图可知,Sigmoid函数和阶跃函数非常相似,但是解决了阶跃函数不光滑和不连续的问题,同时它还成功引入了非线性。Sigmoid函数能够把输入的连续实值变换为0和1之间的输出,且在0.5处为中心对称,并且越靠近x = 0的取值斜率越大。由于Sigmoid函数值域处在(0,1) ,所以往往被用到二分类任务的输出层,可以将它的输出值看作数据归于某类的概率,从而达到预测类别的目的。

Sigmoid的局限之处在于,其梯度范围在0-0.25之间,当输入值大于4或者小于-4时,它的梯度就非常接近0了。在深层网络中,这非常容易造成“梯度消失”,使网络难以训练。

(2)tanh双曲正切函数

tanh双曲正切函数是由双曲正弦和双曲余弦这两个基本双曲函数推导而来,其关于原点中心对称。tanh函数和导函数的表达式为:

\[ tanh(x) = \dfrac {e^x - e^{-x}} { e^x + e^{-x} } \tag{2-7} \]
\[ tanh^{'}(x)=1-(tanh(x))^2=\dfrac {4e^{-2x}} {(1+e^{-2x})^2} \tag{2-8} \]

从图2-5可以看出,与Sigmoid函数相比,tanh的梯度范围更广(0-1),能一定程度上缓解梯度消失的问题的发生。除此之外,tanh的输出均值是0,这对于神经网络的学习而言意义重大,在后面的反向传播部分我们将对此进行解释。但是tanh仍不能完全避免梯度消失的问题。

图 2-5 Sigmoid 函数,tanh函数及其导函数图像对比

图 2-5 Sigmoid 函数,tanh函数及其导函数图像对比#

(3)ReLU 函数

ReLU(Rectified Linear Unit)函数是深度学习中较为流行的一种激活函数,尤其是在卷积神经网络和层次较深的神经网络中。ReLU函数和导函数的表达式为:

\[\begin{split} \begin{align} ReLU(x)=\begin{cases} x,x \geq 0\\ x,x<0\\ \end{cases} \ \tag {2-9} \end{align} \end{split}\]
\[\begin{split} \begin{align} ReLU^{'}(x)=\begin{cases} 1,x \geq 0\\ 0,x<0\\ \end{cases} \ \tag {2-10} \end{align} \end{split}\]

ReLU函数和导函数的图像为如图2-6所示。

图 2-6 ReLU 函数和导函数图像

图 2-6 ReLU 函数和导函数图像#

相对于Sigmoid和Tanh函数,对ReLU函数求梯度则非常简单,可以大程度地提升梯度下降的收敛速度。此外,不难发现,当输入为正时,ReLU不会造成梯度消失的问题。但是,对于小于0的输入数据,ReLU将它们“一刀切”地映射为0。如果参数在一次不恰当的更新后,某个ReLU神经元在所有的训练数据上都不能被激活,那么这个神经元自身参数的梯度将一直保持为0,ReLU神经元在训练时便会“死亡”。但这一特征也使ReLU函数能够生成稀疏性更好的特征数据,即将数据转化为只有最大数值,其他都为0的特征。这种变换可以更好地突出输入特征,用大多数元素为0的稀疏矩阵来实现,这也是ReLU函数的主要优势。

(4)Leaky ReLU函数

Leaky ReLU函数是基于ReLU而提出的。它的函数和导函数的表达式为:

\[\begin{split} \begin{align} LeakyReLU(x)=\Big\{\begin{matrix} x,x \geq 0\\ \gamma x,x<0 \end{matrix} \ \tag{2-11} \end{align} \end{split}\]
\[\begin{split} \begin{align} LeakyReLU^{'}(x)=\Big\{\begin{matrix} 1,x \geq 0\\ \gamma,x<0 \end{matrix} \ \tag{2-12} \end{align} \end{split}\]

其中,γ为leak系数,是一个很小的常数,比如 0.01。当γ为0.1时,Leaky ReLU函数和导函数的图像如图2-7所示。

图 2-7 Leaky ReLU 函数和导函数图像

图 2-7 Leaky ReLU 函数和导函数图像#

不同于ReLU函数在输入小于0时梯度为0,Leaky ReLU函数在输入小于0时会保持一个很小的梯度γ,这样,当神经元在非激活状态时也能有一个非零的梯度用来更新参数。Leaky ReLU函数解决了ReLU函数的神经元“死亡”问题,即使在负区域也具有小的正斜率,因此对于负输入值,它仍可以进行反向传播。

上面我们介绍的四种激活函数各有优劣,然而,目前并没有一个各种场景通用的最优激活函数,不同的激活函数可能在不同类型的数据上达到较好的效果,我们可以结合算法目标和输入数据的特点,根据各个函数的优缺点来配置神经网络。

  1. 前向传播

在我们了解了向量和矩阵、激活函数的概念后,接下来请读者思考如下问题:给定一个神经网络,输入信号是如何经过一层层的网络层将信息向前传播的呢?

简单来说,这个过程就是将输入信号在各层神经元中进行计算,并将输出作为下一层神经元的输入,如此逐层进行计算一直到输出层。假设我们输入一张手写数字图像,希望通过神经网络输出其代表的数字,向前传播的整个过程可以用图2-8做一个清晰的说明。

图 2-8 神经网络前向传播过程

图 2-8 神经网络前向传播过程#

首先,我们输入一张手写数字“7”图像,图像的大小为28×28,可转化为28×28=784的列向量作为输入信号。在信号经过层层的网络层向前传播的过程中,神经网络会根据权重矩阵和上一层神经元的输出计算出当前神经元的信号组合值,然后由激活函数进行处理,如果达到了阈值,则该神经元被激活,如图中的绿色神经元所示,继而信号作为下一层的输入传递下去。如此以来,神经网络就可以通过逐层的信息传递,得到网络最后的输出,也就是我们图中最后标记出来的“7”,代表着神经网络预测的结果。

为了更加详细的说明问题,我们以一个简单的神经网络为例,说明向前传播的整个计算过程。对于图2-9所示的神经网络,输入层有三个节点,隐藏层有四个节点,输出层有两个节点。其中,xi为输入层的第i个神经元的输入,设\(w^{l}_ {jk}\)为l -1层第k个神经元到第l层第 j个神经元的权重, \(b^l_j\)为第l层第j个神经元的偏置,\(a^l_j\)为第l层第 j个神经元的输出,f()为激活函数。

图 2-9 一个简单的神经网络

图 2-9 一个简单的神经网络#

对于隐藏层的输出\(a_1^{(2)}\)\(a_2^{(2)}\)\(a_3^{(2)}\)\(a_4^{(2)}\)有:

\[\begin{split} \begin{align} \begin{array}{lr} a_1^{(2)}=f(Z_1^{(2)})=f(w_{11}^{(2)}x_1+w_{12}^{(2)}x_2+w_{13}^{(2)}x_3+b_1^{( 2)}) \\ a_2^{(2)}=f(Z_2^{(2)})=f(w_{21}^{(2)}x_1+w_{22}^{(2)}x_2+w_{23}^{(2)}x_3+b_2^{( 2)}) \\ a_3^{(2)}=f(Z_3^{(2)})=f(w_{31}^{(2)}x_1+w_{32}^{(2)}x_2+w_{33}^{(2)}x_3+b_3^{( 2)}) \\ a_4^{(2)}=f(Z_4^{(2)})=f(w_{41}^{(2)}x_1+w_{42}^{(2)}x_2+w_{43}^{(2)}x_3+b_4^{( 2)}) \\ \end{array} \ \tag{2-13} \\ \end{align} \end{split}\]

对于输出层的输出\(a_1^{(3)}\)\(a_2^{(3)}\)有:

\[\begin{split} \begin{align} \begin{array}{lr} a_1^3=f(Z_1^{(3)})=f(w_{11}^{(3)}d_1^{(2)} + w_{12}^{(3)}d_2^{(2)} + w_{13}^{( 3)}d_3^{(2)} + w_{14}^{(3)}d_4^{(2)} + b_1^{(3)}) \\ a_2^3=f(Z_2^{(3)})=f(w_{21}^{(3)}d_1^{(2)} + w_{22}^{(3)}d_2^{(2)} + w_{23}^{( 3)}d_3^{(2)} + w_{24}^{(3)}d_4^{(2)} + b_2^{(3)}) \\ \end{array} \tag{2-14} \end{align} \end{split}\]

从上面的公式可以看出,即使在神经元数量很少的情况下,不同层之间信息的传播使用单纯的代数法表示已经相当麻烦了。如果神经网络中有大量的神经元,我们需要对每一层的每一个神经元进行重复的计算,计算组合信号,其工作量巨大。

幸好我们之前已经学习过矩阵这个强大的数学工具。因此,我们可以将输入转化为矩阵传入到神经网络中,将不同层网络之间的连接组成一个权重矩阵,并将信号传播的过程用矩阵乘法的形式表达。 那么,神经网络将通过下面公式不断地迭代来完成信息的向前传播过程:

\[ z^{(l)} = W^{(l)}a^{(l-1)}+ b^{(l)} \tag{2-15} \]
\[ a^{(l)}=A_l(z^{(l)}) \tag{2-16} \]

其中,z(l)为第l层神经元的净输入,W(l)为第l-1层到第l层的权重矩阵,a(l) 为第l层神经元的输出,b(l)为第l-1层到第l层的偏置,Al为第l层神经元的激活函数。

采用矩阵乘法后,上述通过神经网络进行手写数字分类的向前传播计算过程也可以用很简单的矩阵形式进行表达,如图2-10所示。

图 2-10 向前传播计算过程示意图

图 2-10 向前传播计算过程示意图#

可见,使用矩阵乘法来代替大量的代数运算,既可以清晰表达信号的传播过程,又避免了使用大量的下标,简洁地帮助我们完成了前馈神经网络前向传播的过程的表示。

  1. 反向传播

在前向传播过程结束后,神经网络将输出最终的预测值。如何让神经网络从这个结果中进行学习,从而使预测值越来越接近期望的真实值、实现从输入到输出的映射?这便是反向传播将要解决的问题。神经网络的权重和偏差是神经网络中的未知参数。我们希望找到最优的权重和偏差值,完成在特定数据集上从输入到输出的正确映射,因此我们可以将神经网络的学习过程看作一个参数的最优化过程。

损失函数(Loss Function)是用来估量预测值y和真实输出值之间的不一致程度的非负函数。我们希望神经网络输出的预测值尽可能地接近真实值,也就是说,我们希望损失函数的值尽可能小。进而,神经网络参数最优化的目标可以转换成损失函数值的最小化问题,我们希望能找到最优的W和b,使得损失函数值最小:

\[ \underset {W,b} {argmin} L(y,\hat{y}) \]

此时,我们也许会想到,是不是可以通过对参数求偏导数然后令其为0,直接解出最优解呢?不幸的是,在实际情况中,出于以下两个原因,这是行不通的:

  • 偏导为0的点不一定是局部极值点。 神经网络本质上是一个多元函数,偏导为0是该点为极值点的必要不充分条件,所以偏导为0的点不一定是极值。当损失函数为凸函数时,偏导等于0的位置即对应全局极值点,即最优解,但这种情况只占极少数。

  • 解析解计算过于复杂。 对于多元函数,仅使用一阶导是无法判别极值点的。此时,需要引入高阶导。多元函数的高阶偏导计算十分复杂,高维导数的计算相当耗时,且高阶偏导的组合数随着参数量和求导阶数的增长呈指数级增长。

虽然无法一次性求得最优的参数值,但我们可以通过一次次迭代来寻找最优参数值,逐步逼近最小损失值。其中,最广泛应用的方法是梯度下降(Gradient Descent)。

梯度下降的基本过程就和下山的场景很类似。如图2-11所示,假设我们身处山上的某一处位置,而梯度下降法,就是帮助我们快速到达山底的方法。我们可以通过查看最陡峭的下坡路寻找到最快到达山底的路,每走一段距离,我们将重新计算山坡的梯度。通过迭代的方式直到到达山底,显然这就是下山的最短路径。在机器学习中我们可以将初始位置当作是输入到预测函数的初始值,同时也对应损失函数上的某个起始点。山体最陡峭的方向则是梯度,对应于损失函数的偏导数。山底则对应损失函数的最小值。梯度下降法的目的是找到损失函数的最小值。

图 2-11 梯度下降法

图 2-11 梯度下降法#

给定损失函数L(θ),梯度下降法的数学公式为:

\[ \theta_{n+1}=\theta_n-\eta\nabla_{\theta}L(\theta) \tag{2-17} \]

其中\(\theta_{n+1}\)为第n+1次迭代时的参数值,\(\nabla_{\theta}L(\theta)\)为函数在 θ位置的梯度,η在梯度下降法中被称为学习率或者步长。

通过η来控制每一步走的距离。学习率η不能设置太大,不然容易在最优解附近“震荡”,但始终无法更接近最优解。同时,η也不能设置太小,网络会训练得很慢,训练时间过长。网络迭代终止的条件是函数的梯度值为0(实际实现时是接近于0),此时认为已经达到极值点。

在了解了利用梯度下降法来更新参数、逐步逼近最小损失函数值的过程后,让我们学习一下神经网络反向传播的过程。

第一步,我们需要定义损失函数,其中y为目标值,为输入数据经过神经元计算后输出的真实结果。我们需要计算第l层的权重和偏置梯度,即计算损失函数相对于权重的偏导数以及相对于偏置的偏导数,前者从计算入手。接下来,根据求导的链式法则,我们可以展开上述两项得到:

\[ \frac{\partial L(y,f(x))} {\partial w_{ij}^{(l)}}=\frac{\partial z^{(l) }}{\partial w_{ij}^{(l)}} \frac {\partial L(y,f(x))} {\partial z^{(l)}} \tag{2-18} \]
\[ \frac{\partial L(y,f(x))} {\partial b^{(l)}}=\frac{\partial z^{(l)}}{\partial b^{(l)}} \frac {\partial L(y,f(x))} {\partial z^{(l)}} \tag{2-19} \]

其中,\(z^{(l)}=W^{(l)}a^{(l-1)} + b^{(l)}\)\(a^{(l-1)}\)为经过上一层激活函数的输入,因此可得:

\[ \begin{align} \frac {\partial z^{(l)}} {\partial w_{ij}^{(l)}} = \begin{bmatrix} \frac {\partial z^{(l)}} {\partial w_{ij}^{(l)}}, ..., \frac {\partial z^{(l)}} {\partial w_{ij}^{(l)}}, ..., \frac {\partial z^{(l)}} {\partial w_{ij}^{(l)}}, \end{bmatrix}=\begin{bmatrix} 0, ..., \frac {\partial (w_i^{(l)} a^{(l-1)} + b_i^{(l)} )} {\partial w_{ij}^{(l)}}, ..., 0 \end{bmatrix} = \begin{bmatrix} 0, ..., a_j^{(l-1)}, ..., 0 \end{bmatrix} \ \tag{2-20} \end{align} \]
\[ \frac {\partial z^{(l)} } { \partial b^{(l)}} = I_M \tag {2-21} \]

其中,M为输入的维度,IM为M×M的单位矩阵。

由以上公式我们已经得到了\(\frac {\partial z^{(l)}} {\partial w_{ij}^{(l) }}\)以及\(\frac {\partial z^{(l)} } { \partial b^{(l)}}\),为了得到\(\frac {\partial L}{\partial w_{ij}^{(l)}}\)以及\(\frac {\partial L}{\partial b^{(l) }}\),我们还需要计算\(\frac {\partial L(y,f(x))}{\partial z^{(l)}}\)

偏导数\(\frac {\partial L(y,f(x))}{\partial z^{(l)}}\)表示第 l 层神经元对最终损失的影响,也反映了最终损失对第 l 层神经元的敏感程度,因此\(\frac {\partial L(y,f(x))}{\partial z^{(l)}}\)一般称为第 l 层神经元的误差项,用\(\delta^{(l)}\)来表示:

\[ \delta^{(l)}=\frac {\partial L(y,f(x))}{\partial z^{(l)}} \tag {2-22} \]

根据 \(z^{(l+1)}=W^{(l+1)}a^{(l)}+b^{(l+1)}\),有:

\[ \frac {\partial z{(l+1)}}{\partial a^{(l)}} = (W^{(l+1)})^{\gamma} \tag {2-23} \]

又因为\(a^{(l)}=A_i(z^{(l)})\),其中\(A_i\)为第l层使用的激活函数,我们可以得到:

\[ \frac {\partial a^{(l)}}{\partial z^{(l)}} = \frac {\partial A_l(z^{(l)}) }{\partial z^{(l)}}=A_l^{'}(z^{(l)}) \tag {2-24} \]

其中,\(A^{'}\)为激活函数的一阶导数。

进一步,根据链式法则,将\(\delta^{(l)}\)展开:

\[\delta^{(l)}=\frac {\partial L(y,f(x))}{\partial z^{(l)}}= \frac {\partial a^{(l)}}{\partial z^{(l)}} · \frac {\partial z^{(l+1)}} {\partial a^{(l)}} · \frac {\partial L(y,f(x))}{\partial z^{(l+1)}} = A_l^{'}(z^{(l)})·(W^{(l+1)})^{\gamma}·\delta^{(l+1)} \tag {2-25}\]

从上式中可以观察出,第l层的误差项\(\delta^{(l)}\)可以通过第l+1层的误差项\(\delta{( l+1)}\)计算得到,这就是误差的反向传播。

因此,反向传播算法的含义是:第 l 层的一个神经元的误差项是所有与该神经元相连的第 l+1 层的神经元的误差项的权重和,再乘上该神经元激活函数的一阶导数。

在计算出上面的三个偏导数之后,有:

\[ \frac {\partial L(y,f(x))} {\partial w_{ij}^{(l)}} = \delta^{(l)}a_j^{(l-1)} \tag {2-26} \]

其中,\(\delta^{(l)}a_j^{(l-1)}\)相当于向量\(\delta^{(l)}\)和向量\(a^{l-1}\)的外积的第i,j个元素。

上式可以进一步写成:

\[ \begin{align} \begin{bmatrix} \frac {\partial L(y,f(x))} {\partial W^{(l)}} \end{bmatrix}_{ij}=\begin{bmatrix} \delta^{(l)}(a^{(l-1)})^{\gamma} \end{bmatrix}_{ij} \ \tag {2-27} \end{align} \]

因此,\(L(y,f(x))\)关于第l层权重\(W^{(l)}\)的梯度为:

\[ \frac {\partial L(y,f(x))} {\partial W^{(l)}} = \delta^{(l)}(a^{(l-1)})^{T} \tag {2-28} \]

同理,\(L(y,f(x))\) 关于第l层偏置\(b^{(l)}\)的梯度为:

\[ \frac {\partial L(y,f(x))}{\partial b^{(l)}} = \delta^{(l)} \tag {2-29} \]

到这里,我们可以更好地理解激活函数对于神经网络的重要性了。如果激活函数的导数值太小,我们的误差项\(\delta^{( l) }\)将同样会非常小,这个很小的误差项将会不断地反向传播,越靠近输入层,误差越小,这样计算出来的损失函数对相应层次的权重W和偏置b的梯度也会非常小,甚至趋于零,会使得权重和偏差参数无法被更新,神经网络无法被优化,训练难以收敛到较好的解决方案。相对地,如果导数值太大,误差项在层层传播中累积相乘,如果网络层之间的梯度值大于 1.0,那么重复相乘会导致梯度呈指数级增长,梯度变得非常大,导致网络权重的大幅更新,使网络变得很不稳定。

同样地,这也是我们期望神经网络中经过激活函数后的数据其均值为0的原因。由于激活函数的导函数值都是非负的,因此权重梯度的正负取决于神经元由激活函数处理后的输入值a。当a全为正或者全为负时,梯度也全为正或全为负,这意味着梯度只会沿着一个方向发生变化,可能会使得权重无法收敛。均值为0的时候,激活函数的输出值有正有负,使得梯度可以在相反的方向上进行尝试并更新,有利于快速达到收敛。

得到了梯度的表达式(2-25),我们将它代入梯度下降公式(2-28)和(2-29),更新网络的权重和偏置,一次反向传播的计算就此完成。我们在本小节中学习的通过前向传播和反向传播来计算每一层的误差项、对神经网络参数的过程又被称作反向传播算法(Backpropagation, BP),这是一种最常用的模型训练方法,我们后续学习的神经网络都将采用它来进行参数的更新。

  1. 损失函数

损失函数(Loss Function)在深度学习中非常重要,因为训练模型的过程实际就是优化损失函数的过程。损失函数用来衡量模型的好坏,无论什么样的网络结构,如果使用的损失函数不正确,最终将难以训练出正确的模型。

任何能够衡量模型预测值与真实值之间的误差的函数都可以叫做损失函数。该误差在反向传播过程中,对每个参数的偏导数就是梯度下降中提到的梯度。损失函数越小说明模型和参数越符合训练样本。损失函数的计算方式有多种,在模型开发过程中,会根据不同的网络结构、不同的任务去构造不同的损失函数。下面介绍三种常见的损失函数,具体如下。

(1)L1损失函数

L1损失函数,即平均绝对误差(Mean Absolute Error, MAE),它衡量的是预测值与真实值之间距离的平均误差幅度,范围为0到正无穷,其数学公式为:

\[MAELoss=\frac {1}{n} \sum_{i=1}^{n}|y_i - f_i(x)| \tag {2-30}\]

MAELoss一个比较大问题是,它的梯度在训练过程中不发生改变,求解效率较低;但对异常值更具有鲁棒性,一般用于回归问题。

MAELoss在PyTorch中对应的计算函数为L1Loss,函数以类的形式封装,需要对其先进行实例化再使用,具体代码如下:

# MAELoss 在 pytorch 中对应的计算函数为 L1Loss
criterion = torch.nn.L1Loss()
loss = criterion(output, target)

备注

ouput 为网络输出,target 为数据集标签

(2)L2损失函数

L2损失函数也称为均方差(Mean Square Error, MSE)损失函数,它是预测值与真实值之间距离的平方的平均值,范围同为0到正无穷,其数学公式为:

\[ MSELoss= \frac {1} {n} \sum_{i=1}^{n}(y_i-f_i(x))^2 \tag {2-31} \]

MSELoss收敛速度快,能够对梯度给予合适的惩罚权重,而不是“一视同仁”,使梯度更新的方向可以更加精确;但对异常值十分敏感,梯度更新的方向很容易受离群点所主导,鲁棒性较差,一般用于回归问题。

MSELoss函数以类的形式封装,需要对其先进行实例化再使用,具体代码如下:

criterion = torch.nn.MSELoss()
loss = criterion(output, target)

(3)交叉熵损失函数

交叉熵损失(Cross-Entropy Loss)又称对数似然损失(Log-likelihood Loss)、对数损失;二分类时还可称之为逻辑回归损失(Logistic Loss)。

交叉熵反应两个概率分布的距离,而不是欧氏距离,一般用于分类问题。由于神经网络输出的是向量,而不是概率分布的形式。因此,在多分类任务中,经常采用softmax激活函数+交叉熵损失函数,将一个向量进行“归一化”成概率分布的形式,这会使得向量中所有元素之和为1,然后再采用交叉熵损失函数计算损失。下面为交叉熵损失的计算公式,yi表示目标数据,p( yi)表示神经网络输出的数据的概率分布:

\[ CELoss=-\frac{1}{n}\sum_i^n(y_i·log \ p(y_i)+ (1-y_i)·log(1-p(y_i))) \tag {2-32} \]

CELoss在PyTorch中对应的计算函数为CrossEntropyLoss,函数以类的形式封装,需要对其先进行实例化再使用,具体代码如下:


criterion = torch.nn.CrossEntropyLoss()
loss = criterion(output, target)

PyTorch 中还封装了其他的损失函数。这些损失函数不如本书中介绍的几种常用,但作知识扩展,可做简单了解。

  • SmoothL1Loss:平滑版的L1损失函数。此损失函数对于异常点的敏感性不如MSELoss。在某些情况下(如Fast R-CNN模型中),它可以防止梯度“爆炸”。

  • NLLLoss:负对数似然损失函数,在分类任务中经常使用。

  • KLDivLoss:计算KL散度损失函数。

  • BCELoss:计算真实标签与预测值之间的二进制交叉熵。

  • BCEWithLogitsLoss:带有Sigmoid激活函数层的BCELoss。

  • HingeEmbeddingLoss:用来测量两个输入是否相似,使用L1距离。

  • MultiLabelMarginLoss:计算多标签分类的基于间隔的损失函数(Hinge Loss)。

  • SoftMarginLoss:用来优化二分类的逻辑损失。

  • MultiLabelSoftMarginLoss:用来优化多标签分类(One-versus-all)的损失。

  • CosineEmbeddingLoss:使用余弦距离测量两个输入是否相似,一般用于学习非线性embedding或者半监督学习。

  • MultiMarginLoss:用来计算多分类任务的Hinge Loss。

值得注意的是,在神经网络计算时,预测值要与真实值控制在同样的数据分布内。假设将预测值输入Sigmoid激活函数后得到的取值范围在0到1之间,那么真实值也应归一化至0到1之间。这样在进行损失函数计算时才会有较好的效果。一般可以根据问题类型选择按照表2-1对损失函数进行初步筛选。

表2-1 损失函数问题类型初筛#

问题类型

最后一层激活函数

损失函数

二分类,单标签 添加Sigmoid nn.BCELoss
不添加Sigmoid nn.BCEWithLogitsLoss
二分类,多标签 nn.SoftMarginLoss(target 为1或者-1)
多分类,单标签 不添加Sigmoid nn.CrossEntropyLoss(target的类型为torch.LongTensor的 one-hot)
添加Sigmoid nn.NLLLoss
多分类,多标签 nn.MultiLabelSoftMarginLoss(target为0或1)
回归 nn.MSELoss
识别 nn.TripleMarginLoss nn.CosineEmbeddingLoss(margin在[-1,1]之间)
  1. 优化器

值得一提的是,在编程实现神经网络的过程中,我们并不需要按照反向传播的步骤逐行地用代码复现这个过程,而是可以直接利用优化器(Optimizer)实现参数的优化。优化器封装了利用梯度下降更新参数的优化算法,很大程度上简化了我们的代码。下面介绍几种常用的优化器。

(1)BGD

我们在训练神经网络时,为了提升效率,通常以批量(Batch)为单位将数据输入神经网络进行训练,这样,我们对批量中的每个样本计算梯度、进行梯度下降更新参数的方法也被称为批量梯度下降(Batch Gradient Descent, BGD)。

通过分析,我们可以发现批量梯度下降的不足之处:

  • 计算耗时较长: 在数据量过大时,每次训练对批量中的每个样本数据进行梯度计算会耗费大量的时间。而且,当数据集中存在相似数据时,对他们的计算将会是重复冗余的,从而进一步消耗不必要的计算时间。

  • 超参数敏感: 对于批量梯度下降算法,如果学习率设置太小,会导致收敛速度过慢,如果过大,可能会导致算法发散,梯度值越来越大,如图2-12所示。

  • 局部最小值和Plateau问题:图2-13描述了两个梯度下降的主要挑战——局部最小值和Plateau问题。如果随机初始化使得算法从左侧起步,则最终损失会下降到一个梯度值同样为0的局部最小值,而不是全局最小值;如果随机初始化使得算法从右侧起步,几次迭代后到达一个平坦区域(Plateau),梯度接近于0,则算法会因此误判,认为已经到达了极值点。

图 2-12 不同学习率对梯度下降的影响

图 2-12 不同学习率对梯度下降的影响#

图 2-13 局部最小值和Plateau 问题

图 2-13 局部最小值和Plateau 问题#

(2)SGD

随机梯度下降(Stochastic Gradient Descent, SGD)是对先前所介绍的批量梯度下降的一种改进,只在批量中随机选取一个样本进行梯度下降,如下式所示:

\[ \theta_{t-1}=\theta_t-\eta\nabla_\theta L(\theta; x^{(i)};y^{(i)}) \tag {2-33} \]

虽然SGD通过减少每次计算梯度的样本数量来加快梯度更新的速度,但由于其随机性,每次迭代的变化方向可能会非常大,导致最后难以收敛于一个精确的极小值。那么有没有一种既能满足在多个方向上进行迭代,又能保证计算耗时不会太长的方法呢?有!那就是小批量梯度下降法(Mini-Batch Gradient Descent, MBGD),它是BGD和MBGD的折中,每次随机选择若干样本进行梯度下降计算。读者可以自行查阅资料了解更详细的计算过程。

在PyTorch中,SGD优化器以类的形式封装,需要对其先进行实例化再使用。通常,我们在训练用PyTorch框架搭建的神经网络时,会采用如下的代码声明优化器、利用优化器进行反向传播优化参数。其中,loss.backward() 将反向传播损失的梯度、计算出每个参数的梯度值,而在这之前我们需要执行optimizer.zero_grad() 。这么做的原因是PyTorch会将每次计算得到的张量的梯度进行累计,因此我们需要通过zero_grad() 将模型参数的梯度归零,再进行梯度的反向传播。最后,我们执行optimizer.step() 根据梯度更新参数。

# 伪代码
# 通常设置SGD的学习率lr为0.001 ~ 0.01
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
optimizer.zero_grad()
loss.backward()
optimizer.step()

(3)Momentum

动量(Momentum)优化算法模拟了物理里动量的概念,把惯性的思想运用到梯度下降的计算中。下图直观地描述了动量优化算法的思路。我们用\(v_t\)表示梯度下降向量,它同时描述了梯度下降的方向和长度。采用动量优化算法进行梯度下降点的计算时,如图2-14所示,当前时间步的梯度下降方向不仅与计算出的梯度有关,还受先前时间步梯度下降向量\(v_ {t-1}\)的“惯性”的影响,我们用参数\(\gamma\)控制这个“惯性”的影响程度,称之为动量参数。将两者相加,即可得到第t时间步的梯度下降向量\(v_t\)

图 2-14 采用动量优化算法进行梯度下降计算

图 2-14 采用动量优化算法进行梯度下降计算#

动量优化的过程可以用数学公式描述如下:

\[ v_t=\gamma v_{t-1} + \eta\nabla_\theta L(\theta) \tag {2-34} \]
\[ \theta_t=\theta_{t-1}- v_t \tag {2-35} \]

在PyTorch中,动量可以作为一个参数在实例化时传给优化器类,例如下面的代码,我们可以为SGD设置momentum参数,即我们刚刚介绍的动量参数γ。


# momentum即为动量参数,通常设置为0.9或0.8
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.8)

(4)AdaGrad

传统梯度下降算法对学习率非常敏感,为了更好地驾驭这个超参数,多种自适应优化算法应运而生,AdaGrad便是其中一种。AdaGrad全称Adaptive Gradient,即自适应梯度,它是一种具有自适应学习率的梯度下降优化方法。它能够独立地自动调整学习率,在不同参数上使用不同的学习率,对不频繁的参数执行较大的更新,对频繁的参数执行较小的更新。AdaGrad优化器通过如下的公式对参数\(\theta_t\)进行更新:

\[ \theta_{t+1,i} = \theta_{t,i} - \frac {\eta} {\sqrt{G_{t,ii}+\varepsilon}} · g_ {t,i} \tag {2-36} \]

其中,\(g_{t,i}=\nabla_{\theta_{t,i}}L(\theta_{t,i}) \)为损失函数在时间步t时关于\(\theta_i\)的梯度,\(G_t\in R^{d\times d}\)是一个对角矩阵, 对角线上的元素\(G_{t,ii}\)是t步之前累积的参数梯度的平方和,即:

\[ G_{t,ii}=\sum_{k=0}^t g_{t,i}^2 \tag {2-37} \]

可以看出,如果参数\(\theta_i\)频繁更新,那么它在大多数时间步中的梯度必定不为0,平方和也必然大于0,这样就会导致式(2-36)中的分母变大, 从而调小了\(\theta_i\)的学习率。反之,不频繁更新的参数的学习率则会稍大,实现了自适应的效果。AdaGrad总体的参数更新公式可表示如下:

\[ \theta_{i+1}=\theta_i -\frac {\eta}{\sqrt{G_t+\varepsilon}} \bigodot g_t \tag {2-38} \]

我们在PyTorch中通过下列代码实例化相应的类来获得AdaGrad优化器:


# 默认学习率lr=0.01,可根据需要修改
optimizer = torch.optim.Adagrad(model.parameters())

(5)RMSProp

AdaGrad成功地实现了学习率的自适应,但有一个很明显的缺点:随着步数增加,容易累计增大,\(G_ {t,ii}\)导致学习率过早变小,参数的优化过程变得缓慢。为解决这个问题,研究者提出了RMSProp。RMSProp全称为Root Mean Square Propagation(均方根传播),它通过指数加权平均代替梯度平方和,并引入一个新的超参数——衰减速率α,可以表示对历史信息的记忆程度,用来控制移动平均长度范围。RMSProp的参数更新公式如下:

\[ \theta_{i+1}=\theta_t - \frac {\eta}{\sqrt{E[g^2]_t+\varepsilon}} \cdot g_t \tag {2-39} \]

其中,\(E[G^2]_t\)为时间步t的指数加权平均,可表示为:\(E[g^2]_t=\alpha E[g^2]_{t-1}+( 1-\gamma)g_t^2\)

通常,在使用RMSProp时,衰减速率α被设为0.99,学习率η被设为0.01。RMSProp旨在加速优化过程,减少达到最优值所需的迭代次数,提高优化算法的能力,获得更优越的最终结果。在实践中,RMSProp被证明是一种有效的深度神经网络优化算法,得到了广泛的应用。

在PyTorch中,我们通过下列代码实例化相应的类来获得RMSprop优化器:

# 默认学习率lr=0.01,衰减速率alpha=0.99,可根据需要修改
optimizer = torch.optim.RMSprop(model.parameters())

(6)Adam

Adam(Adaptive Moment Estimation)本质上是带有动量项的RMSProp,它同时存储了历史梯度的一阶指数加权平均和二阶指数加权平均,前者来源于动量梯度下降优化算法,后者来自RMSProp的思想,分别用mt和vt表示:

\[ m_t=\beta_1m_{t-1}+(1-\beta_1)g_t \tag {2-40} \]
\[ v_t = \beta_2v_{t-1}+(1-\beta_2)g_t^2 \tag {2-41} \]

mt和vt也被称为梯度的一阶矩估计和二阶矩估计。由上式可知,如果mt和vt被初始化为0向量,那么两个公式中的第一项总是会偏向于0,因此我们需要对它们进行偏差修正:

\[ \hat m_t = \frac {m_t}{1-\beta_1^t} \tag {2-42} \]
\[ \hat{v}_t=\frac {v_t} {1-\beta_2^t} \tag {2-43} \]

最后权重的更新公式如下:

\[ \theta_{t+1}=\theta_t - \frac {\eta}{\sqrt{\hat{v}_{t-1}}+\varepsilon} \cdot \hat{m}_{t-1} \tag {2-44} \]

在使用Adam时,我们一般设置\(\beta_1\)=0.9,\(\beta_2\)=0.999。Adam的优点主要在于,在经过偏差矫正后,每一次迭代学习率都有一个确定的范围,参数会比较平稳。我们可以通过下面的代码在程序中实例化一个Adam优化器。

# 默认学习率lr=0.001, betas=(0.9, 0.999) ,可根据需要修改
optimizer = torch.optim.Adam(model.parameters())

上面我们简单地介绍了几种常见优化器的原理,以及在程序中使用它们的方法。优化器没有优劣之分,我们需要根据实际情况来确定优化器的选择,例如在模型设计实验过程中,要快速验证新模型的效果,可以先用Adam进行快速实验优化;但对于需要上线或者发布的模型,我们可以用精调的SGD作为优化器。我们还可以考虑不同优化算法的组合,例如先用Adam进行快速下降,而后再换到SGD进行充分的调优。此外,我们在实例化代码中只给出了部分重要参数,更多的参数和它们涉及的原理,等待读者的深度挖掘!