基于 BP 神经网络的手写体数字识别 - 反向传播算法
(基于 BP 神经网络的识别手写体数字, Part 3)
前言
上一篇文章使用随机梯度下降法实现神经网络自学习的过程。但仍然留有一个问题,如何快速计算代价函数的梯度?这一篇文章将介绍反向传播算法将之解决。
反向传播算法最初是由 David Rumelhart, Geoffrey Hinton, 和 Ronald Williams 在其 1986 的论文 中提出的。在该文中,作者将反向传播算法运用到多种神经网络中,其自学习过程都大大加快,并推动了神经网络算法走向实用。今天,反向传播算法可以说是神经网络算法中的“老黄牛”了。
其实反向传播不仅仅是一个快速算法那么简单,其还揭示了权重和偏移是如何影响网络的行为。
热身:利用矩阵快速计算神经网络的输出
在讨论反向传播之前,首先来看一下如何快速计算神经网络的输出。虽然上一篇文章中已经介绍了 feedforward 函数,但还是有必要熟悉反向传播算法中使用的数学符号。
首先是明确的权重定义。使用 $w^l_{jk}$ 表示权重,代表连接 $(l-1)^{\rm th}$ 层第 $k^{\rm th}$ 个神经元 和 $l^{\rm th}$ 层第 $j^{\rm th}$ 个神经元的权重。举例来说,第二层第四个神经元和第三层第二个神经元之间的权重连接为
对于网络的偏移和激励我们将使用类似的符号。即 $b^l_j$ 是 $l^{\rm th}$ 层第 $j^{\rm th}$ 个神经元的偏移,$a^l_j$ 是 $l^{\rm th}$ 层第 $j^{\rm th}$ 个神经元的激励。如图所示:
有了这些记号,第 $l^{\rm th}$ 层第 $j^{\rm th}$ 个神经元的激励 $a^l_j$ 可以由第 $(l-1)^{\rm th}$ 层的所有激励求得:
\begin{eqnarray} a^{l}j = \sigma\left( \sum_k w^{l}{jk} a^{l-1}_k + b^l_j \right), \label{23} \end{eqnarray}
其中,求和是对第 $(l-1)^{\rm th}$ 层所有的神经元求和。为了将表达式转换成矩阵的形式,这里为每一层 $l$ 定义一个权重矩阵 $w^l$。该矩阵中第 $j^{\rm th}$ 行第 $k^{\rm th}$ 列的元素为 $w^l_{jk}$。类似的,为每一层定义了一个偏移向量 $b^l$,每一个神经元的偏移为 $b^l_j$。最后,我们定义第 $k$ 层所有的激励 $a^l_j$ 的集合为激励向量 $a^l$。
对于公式 (\ref{23}),我们还需要向量化函数 $\sigma$。因为我们不是将整个矩阵作为一个参数传递给函数,而是对矩阵的每一个元素应用相同的函数,即 $\sigma(v)$ 的元素为 $\sigma(v)_j = \sigma(v_j)$。举例来说,假如我们有一个函数 $f(x)=x^2$,那么函数 $f$ 的向量化是:
\begin{eqnarray} f\left(\left[ \begin{array}{c} 2 \ 3 \end{array} \right] \right) = \left[ \begin{array}{c} f(2) \ f(3) \end{array} \right] = \left[ \begin{array}{c} 4 \ 9 \end{array} \right], \label{24} \end{eqnarray}
有了这些,我们将公式 (\ref{23})重写成下列更紧凑的形式:
\begin{eqnarray} a^{l} = \sigma(w^l a^{l-1}+b^l). \label{25} \end{eqnarray}
这个表达式给了我们更全局的视角:本层的激励是如何与上一层的激励相关联的。计算 $a^l$ 时,会计算出中间结构 $z^l \equiv w^l a^{l-1}+b^l$。这一项我们称之为第 $l$ 层的加权输入。$z^l$ 的每一个元素为 $z^l_j= \sum_k w^l_{jk} a^{l-1}_k+b^l_j$,即 $z^l_j$ 是第 $l$ 层第 $j$ 个神经元激励函数的加权输入。公式 (\ref{25}) 有时也会写作 $a^l =\sigma(z^l)$。
代价函数的两个前提假设
反向传播算法是为了计算代价函数 $C$ 的两个偏导数 $\partial C / \partial w$ 和 $\partial C / \partial b$。 首先看我们的二次代价函数
\begin{eqnarray} C = \frac{1}{2n} \sum_x |y(x)-a^L(x)|^2, \label{26} \end{eqnarray}
其中 $n$ 是训练数据的总数,求和是对每一个数据求和,$y=y(x)$ 是相应的期望输出,$L$ 是网络的总层数,$a^L = a^L(x)$ 是实际的网络输出。
第一个假设是代价函数可以写作时单个训练数据误差 $C_x =\frac{1}{2} |y-a^L |^2$ 的平均值 $C = \frac{1}{n} \sum_x C_x$。这个假设对于后续介绍的其他代价函数也是成立的。
之所以这么假设是因为反向传播算法只能够计算单个训练数据的偏导数 $\partial C_x / \partial w$ 和 $\partial C_x / \partial b$。最后对所有数据求平均值得 $\partial C / \partial w$ 和 $\partial C / \partial b$。
第二个假设是误差可以写作神经网络输出的函数:
例如,二次代价函数就满足这一要求,单个训练数据 $x$ 的二次误差可以写成:
\begin{eqnarray} C = \frac{1}{2} |y-a^L|^2 = \frac{1}{2} \sum_j (y_j-a^L_j)^2, \label{27} \end{eqnarray}
其中,对单个样本来说,$x$ 是定值,期望输出 $y$ 也是定值,唯一的变量就是网络输出 $a^L$,即满足第二条假设。
反向传播的四个基本公式
反向传播算法基于最基本的线性代数操作,比如向量相加、向量与矩阵相乘等。其中有一个比较少见,假设 $s$ 和 $t$ 是两个同维的向量,我们定义 $s \odot t$ 为两个向量对应分量的乘积。那么其每个元素为 $(s \odot t)_j = s_jt_j$。例如,
\begin{eqnarray} \left[\begin{array}{c} 1 \\ 2 \end{array}\right] \odot \left[\begin{array}{c} 3 \\ 4\end{array} \right] = \left[ \begin{array}{c} 1 \times 3 \\ 2 \times 4 \end{array} \right] = \left[ \begin{array}{c} 3 \\ 8 \end{array} \right]. \label{28} \end{eqnarray}
这种元素对元素的乘积也称作 Hadamard 积或者是 Schur 积。Numpy 对这种运算有着良好的优化。
然后我们继续引入一个中间变量 $\delta^l_j$,我们称作第 $l$ 层第 $j$ 个神经元的误差。为了形象地理解误差到底是什么,假设在我们的神经网络中有一个小妖精:
这个妖精坐在 $l$ 层的第 $j$ 个神经元上。当输入到来时,它故意扰乱神经元的正常工作。它在神经元的加权输入中加入了一个小的变化 $\Delta z^l_j$,这样输出变成了 $\sigma(z^l_j+\Delta z^l_j)$。这个变化向后层神经网络传递,最终到输出层导致误差相应地改变为 $\frac{\partial C}{\partial z^l_j} \Delta z^l_j$。
现在,我们钦定了这个妖精是个好妖精,在试图帮助我们减小误差。当 $\frac{\partial C}{\partial z^l_j}$ 是个很大的正数(负数)时,通过选择 $\Delta z^l_j$ 为负值(正)即可减小误差。但如果 $\frac{\partial C}{\partial z^l_j}$ 接近于零,那么这时这个好妖精无论做什么都对误差结果影响不大,这时可以认为网络接近最优值。
以上说明 $\frac{\partial C}{\partial z^l_j}$ 是一个可以用来量化神经元误差 的量。所以我们定义
\begin{eqnarray} \delta^l_j \equiv \frac{\partial C}{\partial z^l_j}. \label{29} \end{eqnarray}
你可能会奇怪为什么要引入一个新的中间量--加权输入 $z^l_j$,而不是直接使用激励 $a^l_j$?纯粹是因为这个中间量让最后的反向传播表达式更简洁。
输出层的误差,$\delta^L$,该误差分量由下式给出
\begin{eqnarray} \delta^L_j = \frac{\partial C}{\partial a^L_j} \sigma'(z^L_j). \tag{BP1}\label{BP1} \end{eqnarray}
这是一个非常自然的表达式,等式右边第一项为 $\partial C / \partial a^L_j$,反映了误差在第 $j$ 个输出激励影响下变化的速度。若某个特定的输出神经元对误差 $C$ 影响不大,则 $\delta^L_j$ 很小。右边第二项为 $\sigma'(z^L_j)$,反应了 $z^L_j$ 对激励函数 $\delta$ 的影响。
公式 (\ref{BP1}) 非常容易计算。加权输入在 feedforward 正向传播时即可顺便计算,而 sigmoid 激励函数和二次代价函数的偏导也易可得。鉴于 (\ref{BP1}) 是以每个元素的形式给出的,有必要改写成矩阵的形式:
\begin{eqnarray} \delta^L = \nabla_a C \odot \sigma'(z^L). \tag{BP1a}\label{BP1a} \end{eqnarray}
这里,$\nabla_a C$ 是偏导数 $\partial C / \partial a^L_j$ 组成的向量。你可以认为 $\nabla_a C$ 反映了误差相对于输出激励的变化。由二次代价函数 $C = \frac{1}{2} \sum_j (y_j-a_j)^2$ 的偏导数 $\partial C / \partial a^L_j = (a_j-y_j)$ 可得:$\nabla_a C =(a^L-y)$。最终公式 (\ref{BP1}) 变成
\begin{eqnarray} \delta^L = (a^L-y) \odot \sigma'(z^L). \label{30}\end{eqnarray}
通过下一层误差可以求本层误差,即
\begin{eqnarray} \delta^l = ((w^{l+1})^T \delta^{l+1}) \odot \sigma'(z^l), \tag{BP2}\label{BP2} \end{eqnarray}
初看上去这个公式有点复杂,但其意义显而易见。$l+1$ 层的误差 $\delta^{l+1}$,通过权重矩阵的转置 $(w^{l+1})^T$ 反向转播到第 $l$ 层,然后与 $\sigma'(z^l)$ 做 Hadamard 积即可求得 $l$ 层的误差。
通过公式 (\ref{BP1}) 和公式 (\ref{BP2}) 的组合,每一层的误差都能求得。
误差相对于偏移的变化率,即偏导数为
\begin{eqnarray} \frac{\partial C}{\partial b^l_j} = \delta^l_j. \tag{BP3}\label{BP3} \end{eqnarray}
这意味着其偏导数就是误差的值本身,我们也可以简写为
\begin{eqnarray} \frac{\partial C}{\partial b} = \delta, \label{31} \end{eqnarray}
误差相对于权重的变化率,即偏导数为
\begin{eqnarray} \frac{\partial C}{\partial w^l_{jk}} = a^{l-1}_k \delta^l_j. \tag{BP4}\label{BP4} \end{eqnarray}
这意味着我们可以通过上一层的激励和本层的误差来求取偏导数,我们也可以简写为
\begin{eqnarray}
\frac{\partial C}{\partial w} = a_{\rm in} \delta_{\rm out},
\label{32}
\end{eqnarray}
上一层的激励如果接近于零,$a_{\rm in} \approx 0$,那么 $\partial C / \partial w$ 也会非常小,这种情况下,权重的自学习速度减慢了。
反向传播算法
由公式 (\ref{BP1})-(\ref{BP4}) 即可得反向传播算法的步骤如下:
首先,输入一个 mini-batch 量的训练数据。
然后,对每一个训练数据 $x$ 施加以下步骤:
- 正向传播:对每一层 $l=2,3,...,L$ 计算 $z^{x,l} = w^l a^{x,l-1}+b^l$ 和 $a^{x,l} = \sigma(z^{x,l})$。
- 计算最后一层误差 $\delta^{x,L}$:计算向量 $\delta^{x,L} = \nabla_a C_x \odot \sigma'(z^{x,L})$。
- 反向传播误差:对每一层 $l=L-1,L-2,...,2$ 计算 $\delta^{x,l} = ((w^{l+1})^T \delta^{x,l+1})\odot \sigma'(z^{x,l})$。
**最后,为这个 mini-batch 中的训练数据计算下降的梯度:**对于每一层 $l=L,L-1,...,2$ 更新权重和偏移 $w^l \rightarrow w^l-\frac{\eta}{m} \sum_x \delta^{x,l} (a^{x,l-1})^T, b^l \rightarrow b^l-\frac{\eta}{m}\sum_x \delta^{x,l}$。
写成代码即为
def backprop(self, x, y):
"""Return a tuple "(nabla_b, nabla_w)" representing the
gradient for the cost function C_x. "nabla_b" and
"nabla_w" are layer-by-layer lists of numpy arrays, similar
to "self.biases" and "self.weights"."""
nabla_b = [np.zeros(b.shape) for b in self.biases]
nabla_w = [np.zeros(w.shape) for w in self.weights]
# feedforward
activation = x
activations = [x] # list to store all the activations, layer by layer
zs = [] # list to store all the z vectors, layer by layer
for b, w in zip(self.biases, self.weights):
z = np.dot(w, activation)+b
zs.append(z)
activation = sigmoid(z)
activations.append(activation)
# backward pass
delta = self.cost_derivative(activations[-1], y) * \
sigmoid_prime(zs[-1])
nabla_b[-1] = delta
nabla_w[-1] = np.dot(delta, activations[-2].transpose())
# Note that the variable l in the loop below is used a little
# differently to the notation in Chapter 2 of the book. Here,
# l = 1 means the last layer of neurons, l = 2 is the
# second-last layer, and so on. It's a renumbering of the
# scheme in the book, used here to take advantage of the fact
# that Python can use negative indices in lists.
for l in xrange(2, self.num_layers):
z = zs[-l]
sp = sigmoid_prime(z)
delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
nabla_b[-l] = delta
nabla_w[-l] = np.dot(delta, activations[-l-1].transpose())
return (nabla_b, nabla_w)