神经网络的分层设计原理

在深度学习中,为了便于组合任意结构、任意层次的神经网络,通常会对神经网络进行分层设计,这也是一种模块化的设计思想。

我们知道,神经网络的核心功能是 推理学习,也称为 正向传播(Forward Propagation) 和 反向传播(Backward Propagation)。为了实现结构化的分层设计,在局部层面,每个分层都必须同样具备对应的能力。

计算图

为了理解神经网络各个分层的工作原理,我们将使用 计算图(Computational Graphic)的方式来分析推理和学习的过程。

下面,我们尝试计算图使用来分析一个简单的问题:

张三在超市买了 2 个西瓜,其中西瓜每个 100 元,消费税为 10%。对此,请计算两个问题: 1. 本次消费的总金额是多少? 2. 西瓜价格的上涨会在多大程度上影响最终的支付金额?即“支付金额关于西瓜价格的导数”是什么?

正向传播

对于第一个问题,我们可以得到如下所示的计算图的正向传播路径。其中,每个节点是一个数学函数,对其输入进行计算,得到对应的输出,并正向传播至下一个节点。

计算图可以将一个复杂的整体运算拆分成多个简单的局部运算。同时,将各个局部运算的结果不断地传递至其他计算节点,进而可以得到整体结果。最终可得:本次消费的总金额为 220 元。

反向传播

对于第二个问题,我们可以进一步得到计算图的反向传播路径。这里从右向左依次传递导数,1,1.1,2.2。最终可得:支付金额关于西瓜的价格的导数为 2.2,即西瓜价格每上涨 1 元,最终支付价格会增加 2.2 元。

这里为什么使用反向传播计算导数呢?因为神经网络的学习过程就是基于损失函数的导数(更准确地说,是梯度)来进行的,所以列举了一个类似的问题。

损失函数

神经网络的反向传播是围绕 损失函数(Loss Function,或称误差函数)完成的,其表示神经网络的性能,即当前神经网络的推理结果对比监督数据的正确值在多大程度上不拟合。

神经网络的学习过程的核心思想就是 通过损失函数计算其梯度,并结合梯度下降法,根据梯度的正负值来更新权重。如果梯度值为负,通过调整权重参数向正方向改变,可以减小损失函数的值;如果梯度值为正,通过调整权重参数向负方向改变,可以减小损失函数的值。

下面,我们来介绍两个最常见的损失函数。

均方误差

均方误差(Mean Squared Error)的计算公式如下所示:

\[\begin{aligned} E = \frac{1}{2} \sum_k (y_k - t_k)^2 \end{aligned}\]

其中,\(y_k\) 表示神经网络的输出,\(t_k\) 表示监督数据的正确值,\(k\) 表示数据的维度。

均方误差会计算神经网络的输出和监督数据的各个元素之差的平方,再求总和。我们在 《初识人工神经网络(1)——基本原理》 中介绍的损失函数就是均方误差。

根据定义,我们可以完成均方误差的代码实现,如下所示。

1
2
def mean_squared_error(y, t): 
return 0.5 * np.sum((y-t)**2)

交叉熵误差

交叉熵误差(Cross Entropy Error)的计算公式如下所示:

\[\begin{aligned} E = - \sum_k t_k log y_k \end{aligned}\]

其中,\(log\) 表示以 \(e\) 为底数的自然对数 \(log_e\)\(y_k\) 表示神经网络的输出,\(t_k\) 为监督数据的正确值。

这里采用 one-hot 表示法,即 \(t_k\) 中只有正确值的位置的值为 1,其他均为 0。因此,交叉熵误差实际上只计算对应正确值标签的输出的自然对数。比如:当正确值标签的索引是 2,如果对应的神经网络的输出是 0.6,那么交叉熵误差为 \(-log0.6 = 0.51\);如果对应的神经网络的输出是 0.1,那么交叉熵误差为 \(-log0.1 = 2.30\)。由此可以看出,在正确值的位置的输出值越接近 1,则误差越小。

根据定义,我们可以完成交叉熵误差的代码实现,如下所示。

1
2
3
def cross_entropy_error(y, t): 
delta = 1e-7
return -np.sum(t * np.log(y + delta))

链式法则

神经网络的学习过程是基于损失函数的导数(梯度)来完成的,由于反向传播的路径中包含了多个分层,以及大量的权重参数,因此我们需要考虑如何对损失函数的导数进行拆分,以便在计算图中的路径中进行传播。

很幸运,链式法则(Chain Rule)可以完美地解决这个问题。什么是链式法则?链式法则是关于复合函数的导数的性质,其定义如下:

如果某个函数由复合函数表示,则该复合函数的导数可以用构成复合函数的各个函数的导数的乘积表示。

举个例子,有一个函数定义为 \(z = (x + y)^2\),此时我们可以将它拆解成两个函数,分别是:

\[\begin{aligned} z =& t^2 \\ t =& x + y \end{aligned}\]

对此,我们可以基于链式法则对复合函数 \(z\) 关于 \(x\) 到导数 \(\frac{\partial z}{\partial x}\) 进行求解,如下所示。

\[\begin{aligned} \frac{\partial z}{\partial x} = & \frac{\partial z}{\partial t} \frac{\partial t}{\partial x} \\ = & 2t \cdot 1 \\ = & 2(x + y) \end{aligned}\]

使用计算图进行拆解,可以得到如下所示的反向传播的计算路径。

运算节点

在计算图中,节点是决定正向传播和反向传播的关键。下面,我们来看几种典型的运算节点。

加法节点

加法节点用于处理加法运算,比如:\(z = x + y\)\(z\) 关于 \(x\)\(y\) 的导数都是常量 \(1\),其正向传播和反向传播的计算图如下所示。反向传播的导数仍然保持“上游传来的梯度”不变。

乘法节点

乘法节点用于处理乘法运算,比如:\(z = x \times y\)。此时,我们可以分别求出 \(z\) 关于 \(x\)\(y\) 的导数,分别是:

\[\begin{aligned} \frac{\partial z}{\partial x} = y \\ \frac{\partial z}{\partial y} = x \end{aligned}\]

乘法节点的正向传播和反向传播如下所示,其中反向传播会将“上游传来的梯度”乘以“将正向传播时的输入替换后的值”。

分支节点

下图所示,分支节点是有分支的节点,本质上就是相同的值被复制并分叉,其反向传播是上游传来的梯度之和。

Repeat 节点

分支节点有两个分支,Repeat 节点则有 N 个分支。与分支节点类似,其反向传播也是通过 N 个梯度的总和求出。

Sum 节点

Sum 节点是通用的加法节点。Sum 节点的反向传播将上游传来的梯度复制并分配至所有分支。我们可以发现,Sum 节点和 Repeat 存在一种逆向关系,即 Sum 节点的正向传播相当于 Repeat 节点的反向传播;Sum 节点的反向传播相当于 Repeat 节点的正向传播。

MatMul 节点

MatMul 节点,即矩阵乘积(Matrix Multiply)节点。我们考虑一个矩阵乘法的例子 \(y = xW\)。其中,\(x\)\(W\)\(y\) 的形状分别是 \(1 \times D\)\(D \times H\)\(1 \times H\)

此时,我们可以通过如下方式求解关于 \(x\) 的第 \(i\) 个元素的导数 \(\frac{\partial L}{\partial x_i}\)\(\frac{\partial L}{\partial x_i}\) 表示变化程度,当 \(x_i\) 发生微小变化时,\(L\) 会有多大程度的变化。如果此时改变 \(x_i\),则向量 \(y\) 的所有元素都会发生变化。由于 \(y\) 的各个元素发生变化,最终 \(L\) 也会发生变化。因此,\(x_i\)\(L\) 的链式法则路径存在多个,它们的和是 \(\frac{\partial L}{\partial x_i}\)

\[\begin{aligned} \frac{\partial L}{\partial x_i} = & \sum_j \frac{\partial L}{\partial y_j} \frac{\partial y_j}{\partial x_i} \\ = & \sum_j \frac{\partial L}{\partial y_j} W_{ij} \end{aligned}\]

由上式可知,\(\frac{\partial L}{\partial x_i}\) 由向量 \(\frac{\partial L}{\partial y}\)\(W\) 的第 \(i\) 行向量的内积求得,进而推导得到:

\[\begin{aligned} \frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} W^T \end{aligned}\]

\(\frac{\partial L}{\partial x}\) 可由矩阵乘积一次求得,其中 \(W^T\) 表示矩阵 \(W\) 的转置矩阵。

当我们考虑 mini-batch 处理的情况,即 \(x\) 中保存了 \(N\) 份数据。此时,\(x\)\(W\)\(y\) 的形状分别是 \(N \times D\)\(D \times H\)\(N \times H\),其计算图如下所示。

\(\frac{\partial L}{\partial x}\) 的关系式,我们发现矩阵乘积的反向传播与乘法的反向传播类似,同样可以总结出“上游传来的梯度”乘以“将正向传播时的输入替换后的值”。最后,我们进一步通过确认矩阵的形状,可以推导出矩阵乘法的反向传播的数学式,如下所示。

分层设计

《初识人工神经网络(1)——基本原理》 中我们介绍了一个数字识别的神经网络,其结构如下所示,隐藏层的节点具备两个处理函数,分别是求和函数、激活函数。对此,为了实现分层设计,我们将进一步拆分成仿射层、激活函数层。

Affine

求和函数本质上对各个输入进行加权求和,通过矩阵点乘实现。此时,我们再引入一个偏置,用于控制神经元被激活的容易程度。由于神经网络的加权求和运算与加偏置运算,正好对应仿射变换的一次线性变换和一次平移,因此将其称为仿射层,或 Affine 层。如下所示是 Affine 计算图的正向传播路径。其中,\(X\) 表示输入矩阵,\(W\) 表示权重矩阵,\(B\) 表示偏置矩阵。

计算图

通过上述计算图,我们可以发现 Affine 层是由一个 MatMul 节点和一个加法节点组成。由此,我们可以得到其计算图的反向传播路径,如下所示。

代码实现

根据计算图,我们可以很容易得到 Affine 层的代码实现,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Affine:
def __init__(self, W, b):
self.W = W
self.b = b
self.x = None
self.dW = None
self.db = None

def forward(self, x):
self.x = x
out = np.dot(x, self.W) + self.b
return out

def backward(self, dout):
dx = np.dot(dout, self.W.T)
self.dW = np.dot(self.x.T, dout)
self.db = np.sum(dout, axis=0)
return dx

Sigmoid

Sigmoid 是激活函数的一种,其数学定义如下所示。

\[\begin{aligned} y = & \frac{1}{1 + exp(-x)} \\ = & \frac{1}{1 + e^{-x}} \end{aligned}\]

根据其数学定义,我们对其运算步骤进行节点拆解,可以得到如下所示计算图的正向传播路径。

计算图

在 sigmoid 计算图中,我们注意到有两个没有介绍过的节点,分别是:\(exp\) 节和除法节点。

对于 \(exp\) 节点,其数学表示为 \(y = exp(x)\),其导数由下式表示。

\[\begin{aligned} \frac{\partial y}{\partial x} =& log_e e \cdot e^x \\ =& e^x \\ =& exp(x) \end{aligned}\]

对于除法节点,其数学表示为 \(y = \frac{1}{x}\),其导数由下式表示。

\[\begin{aligned} \frac{\partial y}{\partial x} =& -\frac{1}{x^2} \\ =& -y^2 \end{aligned}\]

然后,我们根据链式法则可以推导出如下所示的反向传播路径。

接下来,我们对 sigmoid 反向输出的导数进行简化,推导如下。

\[\begin{aligned} \frac{\partial L}{\partial y} y^2 exp(-x) =& \frac{\partial L}{\partial y} \frac{1}{(1 + exp(-x))^2} exp(-x) \\ =& \frac{\partial L}{\partial y} \frac{1}{1 + exp(-x)} \frac{exp(-x)}{1 + exp(-x)} \\ =& \frac{\partial L}{\partial y} y (1 - y) \end{aligned}\]

然后,我们再隐藏计算图过程中节点,合并成一个 sigmoid 节点,可以得到如下所示的计算图。

代码实现

根据计算图,我们可以很容易得到 Sigmoid 层的代码实现,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
class Sigmoid:
def __init__(self):
self.out = None

def forward(self, x):
out = 1 / (1 + np.exp(-x))
self.out = out
return out

def backward(self, dout):
dx = dout * (1.0 - self.out) * self.out
return dx

ReLU

ReLU 是另一种常用的激活函数,其数学表达式非常简单,如下所示。

\[\begin{aligned} y = \begin{cases} x & (x > 0) \\ 0 & (x \leq 0) \end{cases} \end{aligned}\]

计算图

根据 ReLU 的数学定义,我们可以求解 \(y\) 关于 \(x\) 的导数,如下所示。

\[\begin{aligned} \frac{\partial y}{\partial x} = \begin{cases} 1 & (x > 0) \\ 0 & (x \leq 0) \end{cases} \end{aligned}\]

下图所示为 ReLU 的计算图。当正向传播时的输入 \(x\) 大于 0,则反向传播会将上游的值直接传递至下游;当正向传播时的输入 \(x\) 小于等于 0,则导数传递停止。

代码实现

根据计算图,我们可以得到 ReLU 层的代码实现,如下所示。其中,变量 mask 是由 True/False 构成的 NumPy 数 组,它会把正向传播时的输入 x 的元素中小于等于 0 的地方保存为 True,其他地方(大于 0 的元素)保存为 False。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Relu:
def __init__(self):
self.mask = None

def forward(self, x):
self.mask = (x <= 0)
out = x.copy()
out[self.mask] = 0
return out

def backward(self, dout):
dout[self.mask] = 0
dx = dout
return dx

Softmax-with-Loss

对于分类问题,尤其是多元分类问题,一般使用 Softmax 函数作为计算输出。Softmax 函数的数学定义如下所示。

\[\begin{aligned} y_k = & \frac{exp(a_k)}{\sum\limits_{i=1}\limits^n exp(a_i)} \end{aligned}\]

其中,\(exp(x)\) 表示 \(e^x\) 的指数函数。如果输出层有 \(n\) 个神经元,\(y_k\) 表示第 \(k\) 个神经元的输出值。Softmax 函数中的分子是输入信号 \(a_k\) 的指数函数,分母是所有输入信号的指数函数之和。Softmax 函数的输出是 0.0 到 1.0 之间的实数,且 Softmax 函数的输出值总和为 1。因此,我们将 Softmax 函数的输出解释为“概率”。

神经网络包含 推理学习 两个阶段。在推理阶段通常不使用 Softmax 层,因为 Softmax 只是对前一层的数据进行了归一化处理。推理阶段只需要用最后的 Affine 层输出的最大值即可。不过,在学习阶段则需要 Softmax 层。为了配合监督数据的正确值进行学习,通常会使用 Softmax 结合交叉熵误差(Cross Entropy Error)损失函数,构成一个 Softmax-with-Loss 层。

上文,我们介绍过交叉熵误差,这里我们再简单回顾一下它的数学定义,如下所示。

\[\begin{aligned} L = - \sum\limits_k t_k log y_k \end{aligned}\]

计算图

下图所示是 Softmax-with-Loss 层的计算图的整体示意图。计算图内部可以分为 Softmax 和 Cross Entropy Error 两个层,其各自则是由多个基本运算节点构成。注意,在计算图中,我们将指数之和简写为 \(S\),最终的输出计为 \((y_1, y_2, y_3)\)

这里,我们重点看一下反向传播。

首先是 Cross Entropy Error 层的反向传播,如下图所示。

其主要注意以下几个要点:

  • 反向传播初始值为 1。因为 \(\frac{\partial L}{\partial L} = 1\)
  • 乘法节点的反向传播是将正向传播时的输入值进行翻转,乘以上游传来的导数后,传递至下游。
  • 加法节点的反向传播是将上游传来的导数继续进行传递。
  • 对数节点中对数的导数是 \(\frac{\partial y}{\partial x} = \frac{1}{x}\),其反向传播则根据链式法则,使用上游传来的导数乘以自身的导数,并将结果传递至下游。

其次,我们来看 Softmax 层的反向传播,如下图所示。

对于乘法节点,其反向传播将正向传播时的输入值进行翻转,乘以上游传来的导数。其包含两个反向传播分支,具体计算如下所示。

\[\begin{aligned} - \frac{t_1}{y_1} exp(a_1) =& - t_1 \frac{S}{exp(a_1)} exp(a_1) = - t_1 S \\ - \frac{t_1}{y_1} \frac{1}{S} =& -\frac{t_1}{exp(a_1)} \end{aligned}\]

接下来是一个 Repeat 节点和除法节点的组合。对于 Repeat 节点,其反向传播时会将上游节点的导数进行求和,得到 \(-S(t_1 + t_2 + t_3)\);对于除法节点,其反向传播可以进一步得到 \(\frac{1}{S}(t_1 + t_2 + t_3)\)。由于 \((t_1, t_2, t_3)\) 采用 one-hot 表示法,其和为 1,因此进一步得到反向传播的值为 \(\frac{1}{S}\)

然后是加法节点,其反向传播将上游传来的导数继续进行传递。

最后是 exp 节点,从计算图看,它其实包含了一个 exp 节点和一个 Repeat 节点。对于 Repeat 节点,其反向传播会将上游节点的导数进行求和;对于 exp 节点,其导数为 \(\frac{\partial y}{\partial x} = exp(x)\)。反向传播推导如下:

\[\begin{aligned} \frac{\partial y}{\partial y} = (\frac{1}{S} - \frac{t_1}{exp(a_1)}) exp(a_1) = y_1 - t_1 \end{aligned}\]

代码实现

最后,我们根据计算图进行代码实现,结果如下所示。

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

def cross_entropy_error(y, t):
delta = 1e-7
return -np.sum(t * np.log(y + delta))

def softmax(a):
c = np.max(a)
exp_a = np.exp(a - c) # 溢出对策 sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y

class SoftmaxWithLoss:
def __init__(self):
self.loss = None # 损失
self.y = None # softmax的输出
self.t = None # 监督数据(one-hot vector)

def forward(self, x, t): self.t = t
self.y = softmax(x)
self.loss = cross_entropy_error(self.y, self.t)
return self.loss

def backward(self, dout=1):
batch_size = self.t.shape[0]
dx = (self.y - self.t) / batch_size
return dx

总结

在实践中,分层设计有利于快速调整和实现各种各样的神经网络。我们经常听说的深度学习,本质上就是一个神经网络层级比较多的结构。

为了介绍神经网络中的分层设计原理,本文以计算图作为出发点,作为分析每个分层正向传播和反向传播的依据。这里,我们还介绍了一个用于反向传播的关键法则——链式法则。最后,我们介绍了神经网络中几种常见的分层,如:Affine、Sigmoid、ReLU、Softmax-with-Loss 等。

本文,我们大致了解了神经网络的分层设计,这种思想是研究复杂神经网络的基础。后续有时间,我们将继续探索神经网络的各种应用,敬请期待吧!