反向传播算法

本文要讲的就是可能最让我们头疼的反向传播(Backpropagation,常简称为 BP)算法了。事实上,如果不是要做理论研究而只是想快速应用神经网络来干活的话,了解如何使用 Tensorflow 等帮我们处理梯度的成熟的框架可能会比了解算法细节要更好一些(我们会把本章实现的模型的 Tensorflow 版本放在下一个系列中进行说明)。但即使如此,了解神经网络背后的原理总是有益的,在某种意义上它也能告诉我们应该选择怎样的神经网络结构来进行具体的训练

算法概述

顾名思义、BP 算法和前向传导算法的“方向”其实刚好相反:前向传导是由后往前(将激活值)一路传导,反向传播则是由前往后(将梯度)一路传播

注意:这里的“前”和“后”的定义是由 Layer 和输出层的相对位置给出的。具体而言,越靠近输出层的 Layer 我们称其越“前”、反之就称其越“后”

先从直观上理解一下 BP 算法的原理。总体上来说,BP 算法的目的是利用梯度来更新结构中的参数以使得损失函数最小化。这里面就涉及两个问题:

  • 如何获得(局部)梯度?
  • 如何使用梯度进行更新?

本节会简要介绍第一个问题应该如何解决、并说一种第二个问题的解决方案,对第二个问题的详细讨论会放在第 5 节中;正如前面提到的,BP 是在前向传导之后进行的、从前往后传播的算法,所以我们需要时刻记住这么一个要求——对于每个 Layer()而言、其(局部)梯度的计算除了能利用它自身的数据外、仅会利用到(假设包括输入、输出层在内一共有 m 个 Layer、符号约定与上述符号约定一致):

  • 上一层()传过来的激活值和下一层()传回来的(局部)梯度
  • 该层与下一层之间的线性变换矩阵(亦即权值矩阵)

其中出现的“局部梯度”的概念即为 BP 算法获得梯度的核心。其数学定义为:

一般而言我们会用其向量形式:

需要注意的是、此时数据样本数不可忽视,亦即其实都是的矩阵。

由名字不难想象、局部梯度仅在局部起作用且能在局部进行计算,事实上 BP 算法也正是通过将局部梯度进行传播来计算各个参数在全局的梯度、从而使参数的更新变得非常高效的。有关局部梯度的推导是相当繁复的工作、其中的细节我们会在相关数学理论中进行说明,这里就只叙述最终结果:

  • BP 算法的第一步为得到损失函数的梯度: 注意式中运算符“”两边都是维的矩阵(其中即为输出层所含神经元的个数)、运算符“”本身代表的则是 element wise 操作,亦即若 则有 同理若
  • BP 算法剩下的步骤即为局部梯度的反向传播过程: 这里列举出各个变量的维度以便理解:
    • 局部梯度、激活函数的导数:的维度为
    • 权值矩阵的转置:的维度为
    • 局部梯度:的维度为

如果不管推导的话、求局部梯度的过程本身其实是相当清晰简洁的;如果所用的编程语言(比如 Python)能够直接支持矩阵操作的话、求解局部梯度的过程完全可以用一行实现

损失函数的选择

我们在上一篇文章中说过、损失函数通常需要结合输出层的激活函数来讨论,这是因为在 BP 算法的第一步所计算的局部梯度正是由损失函数对模型输出的梯度和激活函数的导数通过 element
wise 操作“*”得到的。不难想象对于固定的损失函数而言、会有相对“适合它”的激活函数,而事实上、结合激活函数来选择损失函数确实是一个常见的做法。用得比较多的组合有以下四个:

  • Sigmoid 系以外的激活函数$+$距离损失函数(MSE)
    MSE 可谓是一个万金油,它不会出太大问题、同时也基本不能很好地解决问题。这里特地指出不能使用 Sigmoid 系激活函数(目前我们提到过的 Sigmoid 系函数只有 Sigmoid 函数本身和 Tanh 函数),是因为 Sigmoid 系激活函数在图像两端都非常平缓(可以结合之前的图来理解)、从而会引起梯度消失的现象。MSE 这个损失函数无法处理这种梯度消失、所以一般来说不会用 Sigmoid 系激活函数MSE 这个组合。具体而言,由于对 MSE 来说: 所以 结合 Sigmoid 的函数图像不难得知:若模型的输出但真值;此时虽然预测值和真值之间的误差几乎达到了极大值、不过由于 从而 亦即第一步算的局部梯度就趋近于 0 向量了;可以想象在此场景下模型参数的更新将会非常困难、收敛速度因为会变得很慢。前文提到若干次的梯度消失、正是这种由于激活函数在接近饱和时变化过于缓慢所引发的现象
  • SigmoidCross Entropy
    Sigmoid 激活函数之所以有梯度消失的现象是因为它的导函数形式为 想要解决梯度消失的话,比较自然的想法是定义一个损失函数、使得它导函数的分母上有这一项。而前文说过的 Cross Entropy 这个损失函数恰恰满足该条件、因为其导函数形式为 ,从而有 这就相当完美地解决了梯度消失问题
  • SoftmaxCross Entropy / log-likelihood
    这两个组合的核心都在于前面额外用了一个 Softmax。Softmax 比起一个激活函数来说更像是一个(针对向量的)变换,它具有相当好的直观:能把模型的输出向量通过指数函数归一化成一个概率向量。比如若输出是,经过 Softmax 之后就是。它的严格定义式也比较简洁(以代指 Softmax): 其中 从而 亦即 这和 Sigmoid 函数的导函数形式一模一样
    之所以要进行这一步变换,其实是因为 Cross Entropy 用概率向量来定义损失(要比用随便一个各位都在内的向量)更好、且 log-likelihood 更是只能使用概率向量来定义损失。由于 SigmoidCross Entropy 的求导已经介绍过且 Softmax 导函数与 Sigmoid 导函数一致、这里就只需给出 Softmaxlog-likelihood 的求导公式: 亦即 其中 将该式写成向量化的形式并不容易、但从实现的角度来说却也不算困难(以上公式的推导过程会放在相关数学理论中)。不过需要注意的是,像这样算出来的局部梯度会是一个非常稀疏的矩阵(亦即大部分元素都是 0)、从而很容易导致训练根本无法收敛,这也正是为何前文说 log-likelihood 的原始形式不尽合理。改进的方法很简单、只需将损失函数变为: 即可。不难发现这个改进后的损失函数和 Cross Entropy 从本质上来说是一样的、所以我们在后文不会实现 log-likelihood 对应的算法

以上我们对如何获取局部梯度作了比较充分的介绍,对于如何利用局部梯度更新参数的详细讲解会放在第5节、这里仅介绍一种最简单的做法:直接应用上一章说过的随机梯度下降(SGD)。由于可以推出(推导过程同样可参见相关数学理论):

从而只需

即可完成一步训练

相关实现

至此、神经网络中的 Layer 结构所需完成的所有工作就都已经介绍完毕,接下来就是归纳总结并着手实现的环节了。不难发现,每个 Layer 除了前向传导和反向传播算法核心以外,其余结构、功能等都完全一致;再加上这两大算法的核心只随激活函数的不同而不同、所以只需把激活函数留给具体的子类定义即可,其余的部分则都应该抽象成一个基类。由简入繁、我们可以先进行一个朴素的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import numpy as np
class Layer:
"""
初始化结构
self.shape:记录着上个Layer和该Layer所含神经元的个数,具体而言:
self.shape[0] = 上个Layer所含神经元的个数
self.shape[1] = 该Layer所含神经元的个数
"""
def __init__(self, shape):
self.shape = shape
def __str__(self):
return self.__class__.__name__
def __repr__(self):
return str(self)
@property
def name(self):
return str(self)

以上是对结构的抽象。由于我们实现的是一个比较朴素的版本、所以这个框架里也没有太多东西;如果要考虑上特殊的结构(比如后文会介绍的 Dropout、Normalize 等“附加层”)的话、就需要再往这个框架中添加若干属性

接下来就是对两大算法(前向传导、反向传播)的抽象(不妨设当前 Layer 为):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def _activate(self, x):
pass
# 将激活函数的导函数的定义留给子类定义
# 需要特别指出的是、这里的参数y其实是
# 这样设置参数y的原因会马上在后文叙述、这里暂时按下不表
def derivative(self, y):
pass
# 前向传导算法的封装
def activate(self, x, w, bias):
return self._activate(x.dot(w) + bias)
# 反向传播算法的封装,主要是利用上面定义的导函数derivative来完成局部梯度的计算
# 其中:、、prev_delta;
def bp(self, y, w, prev_delta):
return prev_delta.dot(w.T) * self.derivative(y)

出于优化的考虑、我们在上述实现的bp方法中留了一些“余地”。具体而言,考虑到神经网络最后两层通常都是前文提到的 4 种组合之一、所以针对它们进行算法的优化是合理的;而为了具有针对性、CostLayer 的 BP 算法就无法包含在这个相对而言抽象程度比较高的方法里面。具体细节会在后文进行介绍、这里只说一下 CostLayer 自带的 BP 算法的大致思路:它会根据需要将相应的额外变换(比如 Softmax 变换)和损失函数整合在一起并算出一个整合后的梯度

以上便完成了 Layer 结构基类的定义,接下来就说明一下为何在定义derivative这个计算激活函数导函数的方法时、传进去的参数是该 Layer 的输出值。其实理由相当平凡:很多常用的激活函数的导函数使用函数值来定义会比使用自变量来定义要更好(所谓更好是指形式上更简单、从而计算开销会更小)。接下来就罗列一下上文提到过的、6 种激活函数的导函数的形式:

  • 逻辑函数(Sigmoid)
  • 正切函数(Tanh)
  • 线性整流函数(Rectified Linear Unit,常简称为 ReLU)
  • ELU 函数(Exponential Linear Unit)
  • Softplus 函数
  • 恒同映射(Identity)

可以看出,用来表示确实基本都比用来表示要简单、高效不少,所以在传参时将激活函数值传给计算导函数值的方法是合理的

接下来就是实现具体要用在神经网络中的 Layer 了;由前文讨论可知、它们只需定义相应的激活函数及(用激活函数值表示的)导函数即可。以经典的 Sigmoid 激活函数所对应的 Layer 为例:

1
2
3
4
5
6
class Sigmoid(Layer):
def _activate(self, x):
return 1 / (1 + np.exp(-x))
def derivative(self, y):
return y * (1 - y)

其余 5 个激活函数对应 Layer 的实现是类似的、观众老爷们可以尝试对照着公式进行实现,我个人实现的版本则可以参见这里

最后我们要实现的就是那有些特殊的 CostLayer 了。总结一下前文所说的诸多内容、可知实现 CostLayer 时需要注意如下两点:

  • 没有激活函数、但可能会有特殊的变换函数(比如说 Softmax),同时还需要定义某个损失函数
  • 定义导函数时,需要考虑到自身特殊的变换函数并计算相应的、整合后的梯度

具体的代码也是非常直观的,先来看看其基本架构:

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
class CostLayer(Layer):
"""
初始化结构
self._available_cost_functions:记录所有损失函数的字典
self._available_transform_functions:记录所有特殊变换函数的字典
self._cost_function、self._cost_function_name:记录损失函数及其名字的两个属性
self._transform_function 、self._transform:记录特殊变换函数及其名字的两个属性
"""
def __init__(self, shape, cost_function="MSE"):
super(CostLayer, self).__init__(shape)
self._available_cost_functions = {
"MSE": CostLayer._mse,
"SVM": CostLayer._svm,
"CrossEntropy": CostLayer._cross_entropy
}
self._available_transform_functions = {
"Softmax": CostLayer._softmax,
"Sigmoid": CostLayer._sigmoid
}
self._cost_function_name = cost_function
self._cost_function = self._available_cost_functions[cost_function]
if transform is None and cost_function == "CrossEntropy":
self._transform = "Softmax"
self._transform_function = CostLayer._softmax
else:
self._transform = transform
self._transform_function = self._available_transform_functions.get(
transform, None)
def __str__(self):
return self._cost_function_name
def _activate(self, x, predict):
# 如果不使用特殊的变换函数的话、直接返回输入值即可
if self._transform_function is None:
return x
# 否则、调用相应的变换函数以获得结果
return self._transform_function(x)
# 由于CostLayer有自己特殊的BP算法,所以这个方法不会被调用、自然也无需定义
def _derivative(self, y, delta=None):
pass

接下来就要定义相应的变换函数了。由前文对四种损失函数组合的讨论及上述代码都可以看出、我们需要定义 Softmax 和 Sigmoid 这两种变换函数及相应导函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@staticmethod
def safe_exp(x):
return np.exp(x - np.max(x, axis=1, keepdims=True))
@staticmethod
def _softmax(y, diff=False):
if diff:
return y * (1 - y)
exp_y = CostLayer.safe_exp(y)
return exp_y / np.sum(exp_y, axis=1, keepdims=True)
@staticmethod
def _sigmoid(y, diff=False):
if diff:
return y * (1 - y)
return 1 / (1 + np.exp(-y))

其中前三行代码实现的safe_exp方法主要利用了如下恒等式:

其中是任意一个常数;如果此时我们取

这样的话分母、分子中所有幂次都不大于 0,从而不会出现由于某个很大而导致对应的很大、并因而导致数据溢出的情况,从而在一定程度上保证了数值稳定性

接下来要实现的就是各种损失函数以及能够根据损失函数计算整合梯度的方法了;考虑到可拓展性,我们不仅要优化特定的组合对应的整合算法、同时也要考虑一般性的情况。因此在实现损失函数的同时、实现损失函数的导函数是有必要的:

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
# 定义计算整合梯度的方法,注意这里返回的是负梯度
def bp_first(self, y, y_pred):
# 如果是Sigmoid / Softmax和Cross Entropy的组合、就用进行优化
# 注意返回时需要返回负梯度,下同
if self._cost_function_name == "CrossEntropy" and (
self._transform == "Softmax" or self._transform == "Sigmoid"):
return y - y_pred
# 否则、就只能用普适性公式进行计算:
# (没有特殊变换函数)
# (有特殊变换函数)
dy = -self._cost_function(y, y_pred)
if self._transform_function is None:
return dy
return dy * self._transform_function(y_pred, diff=True)
# 定义计算损失的方法
@property
def calculate(self):
return lambda y, y_pred: self._cost_function(y, y_pred, False)
# 定义距离损失函数及其导函数
@staticmethod
def _mse(y, y_pred, diff=True):
if diff:
return -y + y_pred
return 0.5 * np.average((y - y_pred) ** 2)
# 定义Cross Entropy损失函数及其导函数
@staticmethod
def _cross_entropy(y, y_pred, diff=True, eps=1e-8):
if diff:
return -y / (y_pred + eps) + (1 - y) / (1 - y_pred + eps)
return np.average(-y * np.log(y_pred + eps) - (1 - y) * np.log(1 - y_pred + eps))

至此、我们打算实现的朴素神经网络模型中的所有 Layer 结构就都实现完毕了。下一节我们会介绍一些特殊的 Layer 结构,它们不会整合在我们的朴素神经网络结构中;但是如果想在实际任务中应用神经网络的话、了解它们是有必要的

观众老爷们能赏个脸么 ( σ'ω')σ