前馈全连接神经网络和函数逼近、时间序列预测、手写数字识别

Andrew Kirillov 著
Conmajia 译
2019 年 1 月 12 日

原文发表于 CodeProject(2018 年 9 月 28 日). 中文版有小幅修改,已获作者本人授权.

本文介绍了如何使用 ANNT 神经网络库生成前馈全连接神经网络并应用到问题求解.

全文约 12,000 字,建议阅读时间 30 分钟. 本文数学内容较多,如果感到不适,可以放弃.

这个库最终命名为 ANNT(Artificial Neural Networks Technology),是 AForge.NET 科学计算库 AForge.Neuro 的组成部分。AForge.NET 是 Andrew Kirillov 的杰作之一,主要用于计算机视觉、人工智能、机器学习、图像处理、机器人等领域。

源码 447 KB

简介

最近这段时间,关于人工智能、机器学习、神经网络等等认知方面的话题很是火爆,引起了全民热烈讨论. 过去十年的发展,创造了许多新的应用和新的方法,也吸引了更多的人去了解这方面的研究成果,探索如何将它们应用到新的方面.

我对人工神经网络(artificial neural networks)一向很感兴趣,15 年前就开始玩了. 大学时候我做过一些研究工作,还为开源社区贡献了一些代码(即早期版本的 ANNT 神经网络计算库). 那时候,人们对神经网络的兴趣迅速增长,但学习环境相对纯净,没现在那么嘈杂1.

科技是日新月异的,在神经网络技术的发展过程中,出现了各种新的体系结构,创造了很多优秀的程序,产生了不少令人惊叹的想法. 所以我觉得我有必要花些时间更新一番我对神经网络的理解. 正如有人在一篇与人工神经网络相关的博客文章中提到的那样:“理解神经网络的最好方法就是实现它们2”. 我深以为然,因此我为一些常见的神经网络结构实现了一个小型的 C++ 库.

实际上现在到处都能找到各种优秀的程序库,不过大部分是面向 Python 开发人员的. 这些库功能可能确实很强大,但是不是我想要的编程语言. 其他的库有些相当复杂,不容易结合理论学习. 而那些针对特定的神经网络体系结构的小型库则种类繁杂,也不便于学习. 无论如何,我最终按照自己的方式实现了一个版本,也就是本文提到的 ANNT. 我为什么要用 C++?嗯,可以认为我是想用 SIMD 指令、并行化和未来的 GPU 计算等等技术吧.

本文是关于 ANNT 库的系列文章中的第一篇3,提供了一些常见的神经网络结构的实现,并将它们应用到不同的任务中. 第一部分是基础知识:前馈全连接网络反向传播学习算法. 了解这些内容将为以后的卷积递归网络打下基础. 我会在每篇文章中都附带源码和一些例子.

理论背景

神经网络这个课题并不新鲜,甚至算是老生常谈了. 有关人工神经网络的理论、不同的体系结构及训练方法,已经有大把研究资源可用. 本文不会太过深入地讨论理论细节,只作简要描述. 文中给出了一些参考资料链接,有兴趣的读者可以点击它们扩展阅读.

来自生物的灵感

现代人工神经网络的许多想法都是受到生物学现象的启发而产生的. 神经元neuron),也叫神经细胞,是神经系统尤其是大脑的核心组成部分. 它是一个电激发的细胞,通过电子和化学信号接收、处理和传输信息. 不同神经元之间通过称为突触的特殊连接产生和传输这些信息,互相连接形成神经回路. 人类的大脑平均有 1000 亿个神经元,而每个神经元又与多达 10000 个其他神经元相连,形成大约 1000 万亿个突触连接.

图 1 神经元结构

一枚典型的神经元由细胞体、树突和轴突组成,如图 1 所示. 树突是由细胞体产生的薄结构,有多个分叉,长度约数百微米(µm). 轴突是一种特殊的细胞延伸,起源于细胞体,传播长度可以超过一米. 其他生物例如长颈鹿体内的轴突甚至长达 5 米. 大多数神经元通过树突接收信号,然后沿着轴突发送信号. 因此,树突可以理解为神经元的输入,而轴突则是其输出.

人工神经元

人工神经元是表示生物神经元的数学模型. 人工神经元接收一个或多个输入(代表神经树突的电位),并对其进行求和以产生输出(也称为激活,代表神经元沿轴突传递的动作电位). 一般每个输入都是单独加权的,通过称为激活函数传递函数非线性函数传递.

图 2 人工神经元模型

用数学语言来描述,这个模型可以用以下公式表示:

\[ \tag{1} y=f\left(\sum_{j=1}^{m}\omega_j*x_j+b\right) \]

其中 \(x_j\) 是神经元的输入(input),\(\omega_j\) 是输入的权重(weight),\(b\)偏差值(bias),\(m\) 是输入的数量. 用向量 \(\mathbf{x}\)\(\mathbf{w}\)\(\mathbf{b}\) 表示,则式 \((1)\) 写为:

\[ \tag{2} y=f(\mathbf{w}^\mathrm{T}\mathbf{x}+\mathbf{b}) \]

1943 年,沃伦·麦卡洛奇4和沃尔特·皮茨5提出了阈值逻辑单元(threshold logic unit),这是历史上首个实用的人工神经元. 它用一个阈值函数作为传递函数. 最初,只考虑了具有二进制输入/输出的简单模型,对模型可能的权重也只作了一些简单限制. 然而从一开始,人们就已经注意到任何布尔函数都可以通过这些设备的网络实现. 这一点可以从实现一个(AND)、(OR)函数看出.

在 20 世纪 80 年代后期,随着神经网络的研究发展,科学家开始研究具有更连续形状的神经元,使用梯度下降(gradient decent)和其他优化算法来调整权重和偏差值.

和(AND)、或(OR)例子

前面提到了,单个神经元可以实现类似于 AND、OR 以及 NAND 的功能. 要实现这些功能,可以将神经元的权重初始化为:

表 1 神经元权重初始化值
 $b$$\omega_1$$\omega_2$
OR-0.511
AND-1.511
NAND1.5-1-1

假设神经元使用阈值激活函数(\(u>0\) 为 1,否则为 0),把这些权重和偏差值代入神经元方程中,得到:

表 2 神经元求解
$x_1$$x_2$ $u_\rm{OR}$$y_\rm{OR}$ $u_\rm{AND}$$y_\rm{AND}$ $u_\rm{NAND}$$y_\rm{NAND}$
00 -0.50 -1.50 1.51
10 0.51 -0.50 0.51
01 0.51 -0.50 0.51
11 1.51 0.51 -0.50

那么,可以用单个神经元做点复杂的事情吗?例如用来实现异或(XOR)?答案是否定的. 这是因为,当一个神经元用于分类问题时,它只能用一条直线分隔数据点,而 XOR 输入不是线性可分离的. 图 3 显示了这三个函数的数据点:OR、AND 和 XOR. 对于 OR 和 AND 数据点,可以画一条直线将它们分隔成两类,但是没办法用直线分隔 XOR 的数据点.

图 3 神经元数据点分类

分隔线可以从权重和偏差值得到. 对于 OR 函数,当 \(b=-0.5\)\(\omega_1=1\)\(\omega_2=1\),下一个和是:\(1*x_1+1*x_2-0.5\). 转换成线性方程:\(x_2=0.5-x_1\),于是得到一条直线,这条线即可用来分割数据点.

单个神经元不行,可不可以用多个神经元来实现异或函数呢?当然可以. 在布尔代数里,XOR 可以使用 OR、AND 和 NAND 函数来实现:

\[ \mathrm{XOR}(x_1,x_2)=\mathrm{AND}(\mathrm{OR}(x_1,x_2),\mathrm{NAN}(x_1,x_2)) \]

这就意味着 3 个神经元加上 2 层网络即可完成.

人工神经网络

由于单个神经元无法完成太多的工作,所以实践中,总是把它们连接到网络中. 网络由神经层组成,每个神经层是多个神经元的集合. 人工神经网络有许多不同的结构,它们在神经元层间的连接方式和输入信号在网络中的传播方式上也各不相同. 本文将从其中最简单的体系结构开始:前馈全连接网络(feed forward fully connected networks).

图 4 前馈全连接神经网络

从图 4 看到,下一层的每个神经元都与前一层的所有神经元相连,而第一层的每个神经元与所有输入相连. 信号在网络中向一个方向传播:从输入到输出. 实践证明,这种类型的网络可以很好地完成不同的分类和回归任务.

网络的输入称为输入层,网络最后一层表示为输出层,所有其他层表示为隐藏层. 输入层是一种命名约定,它并不是网络中实际存在的实体. 本文讨论网络中的层数时,不计算输入层. 例如,有一个 3 层网络,那么这里说的是一个具有 2 个隐藏层和 1 个输出层的网络,输入层没有计入.

为了研究前馈全连接网络的数学模型,先定义一些术语和表达形式:

  • \(l\) 网络的层数
  • \(n(k)\)\(k\) 层神经元数量
  • \(n(0)\) 网络输入数量
  • \(m(k)\) 进入第 \(k\) 层的输入数量(\(m(k)=n(k-1)\)
  • \(y(k)\)\(k\) 层输出的列向量,长度 \(n(k)\)
  • \(y(0)\) 网络输入的列向量(向量 \(\mathbf{x}\)
  • \(b(k)\)\(k\) 层偏差值的列向量,长度 \(n(k)\)
  • \(\mathbf{w}(k)\)\(k\) 层的权重矩阵. 矩阵的第 \(i\) 行包含层的第 \(i\) 个神经元的权重, \(n(k)\times m(k)\)

对于上述所有定义,可以使用下面的简单公式(假设计算顺序从第一层到最后一层)计算前馈完全连接网络的输出:

\[ \tag{3} y_{i}^{(k)}=f\left(\sum_{j=1}^{m^{(k)}}\omega_{i,j}^{(k)}*y_j^{(k-1)}+b_j^{(k)}\right),k=1,2,\cdots,l,i=1,2,\cdots,n^{(k)} \]

或者用向量表示为:

\[ \tag{4} y_{i}^{(k)}=f\left(\mathbf{wy}^{(k-1)}+\mathbf{b}^{(k)}\right),k=1,2,\cdots,l \]

全是数学!然而光有这些公式什么也做不了,除非能够为要解决的问题正确初始化权重和偏差值,否则上面的公式毫无用处. 对于简单 AND/OR 函数,前面我已经人工设定了权重/偏差(表 1 和表 2). 但是对于更复杂的事情来说,要确定这些值不是一个简单的过程. 此时,就该轮到学习算法发挥作用了.

激活函数

开始研究学习算法之前,先来看看激活函数. 正如前面提到的,最早的人工神经元模型使用阈值函数从输入的加权和计算输出. 阈值函数虽然简单,但也有许多缺点. 首当其冲的就是它的导数. 阈值函数导数在 \(x=0\) 是不可导的,而在其他任何地方导数都为 0. 与此相反,用于神经网络训练的算法(例如梯度下降算法)要求激活函数是可导的,并且在定义域内具有非零梯度.

比较流行的激活函数之一是 sigmoid 函数,定义为:

\[ f(x)=\frac{1}{1+e^{-x}} \]

sigmoid 函数的形状类似于阶跃函数6,如图 5-a,但没有那么尖锐,它是光滑的,可导的,连续的,值域 \((0,1)\). 但它并不完美,它也有它的问题. 尽管如此,它对于使用前馈全连接网络完成的不同分类任务依然能很好地工作,因此简便起见,还是继续使用它来进行研究.

(a) sigmoid 函数
(b) tanh 函数
图 5 sigmoid 函数和 $\tanh$ 函数图像

常用的激活函数有:

  • 双曲正切,如图 5-b,形状类似于 sigmoid 函数,但值域是 \((-1,1)\)
  • SoftMax 函数,它将任意实值的向量压缩为实值的同一维向量,其中每个条目都在 \((0,1)\) 范围内,所有条目和为 1. 这有利于处理分类任务. 在分类任务中,神经网络的输出视为属于某个类的概率,概率之和恒为 1
  • rectifier7(整流器)是深度神经网络结构中一种常用的激活函数,它允许更好的梯度传播,具有较少的梯度消失(gradient vanishing)问题

为什么需要激活函数?可以不用吗?是的,在做回归的时候可以忽略输出层的激活函数,但是不能删除隐藏层中的. 隐藏层中的激活函数增加了非线性,使得神经网络能够学习非线性特征. 正是由于非线性,才能解决类似 XOR 这类线性不可分离的问题. 从隐藏层中去掉激活函数会破坏学习非线性特征的能力. 不含激活函数的多层网络会退化为单层网络. 是的,没有激活函数的多层网络完全可以由单层网络替换,失去其应有的功能和灵活性.

所以现在,神经网络的数学推理看起来已经完成了:调整了网络的权重/偏差后,计算网络的新数据输出. 还不够,需要找到一种训练神经网络的方法,让它可以做一些有用的事情.

训练人工神经网络

为了训练前馈全连接人工神经网络,需要引入监督学习算法. 这意味着将需要一个训练样本集,为可能的输入和目标输出提供样本. 学习算法的一个非常简单的概念是,从训练样本集中给未训练的神经网络(随机初始化)提供样本输入,并计算相应的输出. 随后,将网络产生的输出与需要产生的目标输出进行比较,计算出误差值. 基于计算的误差,更新网络的权重和偏差,以减小产生和目标输出之间的差异. 计算误差值并更新网络参数的过程称为一次训练的迭代世代8(epoch). 通过重复一定数量的世代,让误差变得足够小.

成本函数

为了计算误差,首先要做的是定义误差函数,或者称作成本函数(cost function). 简单起见,我选择均方误差(mean square error)函数,简称 MSE. 这个函数常用来完成回归计算. 假设有一个包含 \(m\) 个元素的样本集,用 \(x^{(j)}\) 个输入向量和 \(t^{(j)}\) 个目标输出向量表示(对于单个输出,仍然将它看作是向量). 对每个可能的输入,网络计算输出的相应 \(y^{(j)}\) 向量. 略去上标,用 \(y\)\(t\) 来表示任意网络的输出和相应的目标. 假设网络的输出层中有 \(n\) 个神经元,输出向量中的元素数量相同,那么单个训练示例的 MSE 成本函数可以这样定义:

\[ \mathrm{Cost}_1=\frac{1}{2n}\sum_{i=1}^{n}\left(y_i-t_i\right)^2 \]

对所有样本进行进行平均,得到整个训练集的成本函数:

\[ \tag{5} \begin{align*} \mathrm{Cost}&=\frac{1}{m}\sum_{j=1}^{m}\mathrm{Cost}_j\\ &=\frac{1}{2nm}\sum_{j=1}^{m}\sum_{i=1}^{n}\left(y_i^{(j)}-t_i^{(j)}\right)^2 \end{align*} \]

均方误差,应该除以 $n$,但是除以 $2n$ 对于结果影响不大,而且可以和 2 次方的导数相约,简化后续的计算.

现在,定义了成本函数后,就可以通过计算得到一个数值. 在训练一个神经网络时,通过监视这个值,观察它是否随着时间的推移而改进以及改进的速度,来评判训练样本集在神经网络上的表现.

随机梯度下降算法

有了成本函数,就可以进一步进行神经网络训练,更新权重/偏差从而使训练性能更好. 经典的优化问题中,往往需要找到使成本函数接近最小值(局部最小值)的网络参数. 为此,可以采用梯度下降优化算法. 该算法观察一个在点 \(\mathbf{a}\) 附近可导的多变量函数 \(\mathbf{F}(x)\),在沿着点 \(\mathbf{a}\)负梯度方向,即 \(-\nabla \mathbf{F}(a)\)\(\mathbf{F}(x)\) 的下降速度最快. 即是:

\[ \tag{6} \mathbf{a}^{(n+1)}=\mathbf{a}^{(n)}-\lambda\nabla \mathbf{F}\left(\mathbf{a}^{(n)}\right) \]

\(\lambda\) 足够小,\(\mathbf{F}\left(\mathbf{a}^{(n+1)}\right)\leqslant\mathbf{F}\left(\mathbf{a}^{(n)}\right)\). 在函数 \(\mathbf{F}\) 的某些假设下,可以保证收敛到局部极小值.

训练人工神经网络时,要尽量使样本集的成本函数最小. 考虑到样本集是固定的,输入样本和目标输出可以被视为常量. 于是成本函数可以视为权重的函数(偏差值视为一种特殊权重),通过优化,使成本最小化. 从随机初始化权重开始,采用梯度下降算法的神经网络的训练过程,式 \((6)\) 可以写成:

\[ \mathbf{\omega}^{(n+1)}=\mathbf{\omega}^{(n)}-\lambda\nabla\mathrm{Cost}\left(\mathbf{\omega}^{(n)}\right) \]

这里的 \(\lambda\) 参数称为学习率,它影响神经网络的训练速度(接近成本函数局部最小值的速度). 它的最佳值取决于神经网络的结构、训练设置等,因此需要根据经验和实验结果选取. 如果设置得太低,收敛到局部最小值可能会太慢,需要很长时间来训练网络. 另一方面,如果设定过高,成本函数可能会振荡发散.

研究权重更新和计算成本函数的梯度之前,先来看看梯度下降算法的问题是什么. 通常情况下,样本集可能会非常大:数万到数十万个样本,甚至数百万个样本. 计算整个系统的成本函数的代价太大,包括 CPU/GPU 和内存方面的. 另一种解决方案是使用随机梯度下降(stochastic gradient decent,SGD)算法,随机选取一个训练样本,只计算该样本的成本函数,然后根据该样本更新参数. 它对样本集中的所有样本重复这样的迭代,但顺序是随机的. 通过在一个世代内对模型进行多次小的改进,而不是像梯度下降算法那样每个世代只更新一次参数,SGD 算法可以实现非常快的训练速度. 当然这必须建立在样本集包含许多差异较小、相似的样本的基础上.

因此,根据 SGD 算法,神经网络的权重更新规则可以基于某个随机例子 \(j\)

\[ \tag{7} \mathbf{\omega}^{(n+1)}=\mathbf{\omega}^{(n)}-\lambda\nabla\mathrm{Cost}_j\left(\mathbf{\omega}^{(n)}\right) \]

分析随机梯度下降的收敛性,可以发现当学习率 \(\lambda\) 随适当速率减小,目标函数为凸函数时,SGD 几乎必然收敛到一个最小值,否则局部收敛到一个最小值.

小批量梯度下降mini-batch gradient decent,也叫批量梯度下降)是另一种介于上述两种算法之间的替代算法. 它类似于梯度下降,但更新参数时不计算整个样本集,而是计算指定大小的一部分样本,类似 SGD 算法.

尽管批量梯度下降是目前大多数应用的首选,但本文继续采用 SGD,便于阐述训练算法.

梯度和链式法则

前馈全连接神经网络最后一层的权重更新时,假设最后一层有 \(n\) 个神经元输出,每个都有 \(m\) 个输入;\(y_i\) 是第 \(i\) 个神经元的输出,\(u_i\) 是输入的加权和;\(t_i\) 是第 \(i\) 个神经元的目标输出;\(x_j\) 是第 \(j\) 个输入(来自上一层的对应神经元);\(\omega_{i,j}\) 是第 \(i\) 个神经元的权重. \(b_i\) 是第 \(i\) 个神经元的偏差值. 根据式 \((7)\),每个权重 \(\omega_{i,j}\) 的更新基于该权重的成本函数的偏导数,有:

\[ \tag{8} \omega_{i,j}^{(n+1)}=\omega_{i,j}^{(n)}-\lambda\frac{\partial\mathrm{Cost}_1}{\partial\omega_{i,j}^{(n)}} \]

成本函数是网络输出和目标输出的函数,其中网络输出是加权输入和的函数,最后加权和可以表示为网络权重的函数. 例如,假设有一个函数 \(f(x)\),其中 \(x\) 是另一个函数 \(x(t)\),最后 \(t\) 也是一个函数 \(t(a,b)\). 或者可以写为 \(f(x(t(a,b)))\). 需要找到 \(f\)\(a\) 的偏导数,此时需要应用计算偏导数的链式法则,即:

\[ \frac{\partial f}{\partial a}=\frac{\partial f}{\partial x}\frac{\partial x}{\partial t}\frac{\partial t}{\partial a} \]

将式 \((8)\) 代入上式,得到:

\[ \tag{9} \frac{\partial \mathrm{Cost}_1}{\partial \omega_{i,j}}=\frac{\partial \mathrm{Cost}_1}{\partial y_i}\frac{\partial y_i}{\partial u_i}\frac{\partial u_i}{\partial \omega_{i,j}} \]

现在来找出式 \((9)\) 里的每一个偏导数. 虽然假定的是平方均值误差函数,但在计算导数时使用更为常见. 考虑到这一点,成本函数相对于第 \(i\) 个神经元输出的偏导数为:

\[ \tag{10} \begin{align*} \frac{\partial \mathrm{Cost}_1}{\partial y_i}&=\frac{\partial}{\partial y_i}\left[\frac{1}{2}\sum_{i=1}^{n}(y_i-t_i)^2\right] \\ &=\frac{\partial}{\partial y_i}\left[\frac{1}{2}(y_i-t_i)^2\right] \\ &=y_i-t_i \end{align*} \]

可见 MSE 成本函数对网络输出的偏导数是实际输出与目标输出的差,可以用于预测误差. 在有多个输出神经元的情况下,可为每个单独的神经元计算这样的误差,而不考虑输出层中的神经元数量. 这就是为什么通常省略除以 \(n\) 的原因.

下一步是计算激活函数相对于其输入的导数. 输入的激活函数使用的是 sigmoid 函数,可得:

\[ \begin{align*} \frac{\partial y_i}{\partial u_i}&=\frac{\partial}{\partial u_i}\frac{1}{1+e^{-u_i}} \\ &=\frac{e^{-u_i}}{(1+e^{-u_i})^2}\\ &=y_i(1-y_i) \end{align*} \]

sigmoid 函数的导数可以用两种方式定义. 其中之一是基于 $u_i$. 但是在人工神经网络中鲜少如此. 用函数本身的值来计算 sigmoid 的导数要快得多.

最后,定义神经元加权和的偏导数 \(u_i\),关于它的权重 \(\omega_{i,j}\) 和偏差值 \(b_i\)

\[ \begin{align*} \frac{\partial u_i}{\partial \omega_{i,j}}&=\frac{\partial}{\partial \omega_{i,j}}\left(\sum_{j=1}^{m}\omega_{i,j}*x_j+b_i\right) \\ &=\frac{\partial}{\partial \omega_{i,j}}\left(\omega_{i,j}*x_j\right) \\ &=x_j \end{align*} \]

\[ \begin{align*} \frac{\partial u_i}{\partial b_i} &=\frac{\partial}{\partial b_i}\left(\sum_{j=1}^{m}\omega_{i,j}*x_j+b_i\right) \\ &=\frac{\partial b_i}{\partial b_i} \\ &=1 \end{align*} \]

综上,可得最后一层神经元权重和偏差值:

\[ \begin{align*} \omega_{i,j}^{(n+1)} &= \omega_{i,j}^{(n)}-\lambda\left[(y_i-t_i)y_i(1-y_i)x_j\right] \\ b_i^{(n+1)} &=b_i^{(n)}-\lambda\left[(y_i-t_i)y_i(1-y_i)\right] \end{align*} \]

上述公式仅适用于单层前馈全连接人工神经网络的训练. 然而,多数情况下需要多层网络来解决问题,于是引入误差反向传播算法.

误差反向传播

前文讲解了在输出层中计算成本函数的偏导数,现在定义输出层第 \(i\) 个神经元的误差项 \(E_i\).

\[ \tag{10'} E_i=\frac{\partial\mathrm{Cost}_1}{\partial y_i}=y_i-t_i \]

这实际上就是式 \((10)\). 接下来定义输出层前一层中第 \(j\) 神经元输出的成本函数偏导数 \(E'_j\). 这里再次使用了链式法则. 由于已经完全连接了人工神经网络,前一层的每个输出都连接到下一层的每个神经元. 反映到误差项中,有:

\[ \begin{align*} E'_j&=\frac{\partial\mathrm{Cost}_1}{\partial y_1}\frac{\partial y_1}{\partial u_1}\frac{\partial u_1}{\partial y'_j}+\frac{\partial\mathrm{Cost}_1}{\partial y_2}\frac{\partial y_2}{\partial u_2}\frac{\partial u_2}{\partial y'_j}+\cdots+\frac{\partial\mathrm{Cost}_1}{\partial y_n}\frac{\partial y_n}{\partial u_n}\frac{\partial u_n}{\partial y'_j} \\ &=\sum_{i=1}^{n}\frac{\partial\mathrm{Cost}_1}{\partial y_i}\frac{\partial y_i}{\partial u_i}\frac{\partial u_i}{\partial y'_j} \end{align*} \]

现在来做一些代换. 首先代入式 \((10')\),前一层第 \(j\) 项输出 \(y'_j\) 可以表示当前层输入 \(x_j\). 于是上式可写为:

\[ \tag{11} \begin{align*} E'_j&=\sum_{i=1}^{n}E_i\frac{\partial y_i}{\partial u_i}\frac{\partial u_i}{\partial x_j}\\ &=\sum_{i=1}^{n}E_iy_i(1-y_i)\omega_{i,j} \end{align*} \]

式子里的 \(E_i\) 是刻意保留的. 如果运用链式法则计算某个隐藏层的误差项,可以得到相同的公式. 也就是说,一旦用成本对网络输出的偏导数计算出输出层的误差项,就可以用式 \((11)\) 从下一层的误差项计算出所有前一层的误差项.

综合上面各式,现在可以为前馈全连接人工神经网络的所有层写下权重计算规则:

\[ \begin{align*} \omega_{i,j}^{(n+1)}&=\omega_{i,j}^{(n)}-\lambda\left[E_iy_i(1-y_i)x_j\right] \\ b_{i}^{(n+1)}&=b_i^{(n)}-\lambda\left[E_iy_i(1-y_i)\right] \end{align*} \]

这个算法就叫做误差反向传播. 一旦计算出输出层的误差,它就通过使用偏导数机制的神经网络向后传播. 所以,当涉及到人工神经网络时,通常会说向前和向后的传递. 正向指从输入到输出的信号流,反向指从输出到输入的误差值流.

如果不使用 MSE 或 sigmoid,那么上面的公式都要重新推导. 当然,需要改动的部分不多,只有相应的偏导数项不同.

好吧,现在理论就是这样. 显然,关于前馈全连接人工神经网络及训练还可以写很多. 但现在这些内容对于我这篇介绍应该足够了,而我在文中还提供了许多链接供额外阅读.

ANNT 库

我在设计 ANNT 库代码时,目标之一是让它更具灵活性,易于扩展和使用. 因此,从第一步开始就用到了面向对象的范式. 设计人工神经网络的层时,我决定将网络的层作为最小的建模实体,这样可以实现更好的性能(而不是像其他设计对单个神经元进行建模),并获得从不同类型的层构建不同神经网络架构的灵活性.

虽然理论部分表明激活函数是神经元的一部分,但我通过特殊的激活层类来实现. 另外我将不同的成本函数作为单独的类来实现,以便根据要解决的任务轻松地选择一个. 鉴于这种较大的设计粒度,源码中是找不到理论部分所示的权重更新规则的(式 \((7)-(8)\)). 相反,通过计算每个类所需的误差梯度项来实现其自身的反向传播算法部分.

ANNT 的类关系图如图 6:

图 6 ANNT 类关系图

例如,XMSECost 类只计算 \(y_i–t_i\) 部分. 跟着 XSigmoidActivation 类计算 \(y_i(1-y_i)\). 最后 XFullyConnectedLayer 计算权重的偏导数,以及传递到前一层的误差梯度. 这样设计,可以将不同的激活函数和成本函数插入神经网络模型,而无需对整个权重算法进行硬编码9.

梯度下降的计算也被移动到一个单独的类中. 如前所述,权重计算式为:\(\omega_{(t+1)}=\omega_{(t)}-\lambda*\Delta\omega_{(t)}\). 但是这并不是唯一可能的算法,而且不是最快的. 例如,有一种流行的算法叫做动量梯度下降(gradient decent with momentum),算法为:\(v_{(t)}=\mu*v_{(t-1)}+\lambda*\Delta\omega_{(t)};\omega_{(t+1)}=\omega_{(t)}-v_{(t)}\). 由于存在多种梯度下降算法,因此将它们作为单个类来实现是合乎逻辑的.

XNeuralNetwork 类表示实际的神经网络. 网络的体系结构取决于放入其中的层的类型. 本文只介绍了前馈完全连接的神经网络例子. 在下一篇文章中,我将探讨卷积神经网络和循环神经网络.

最后,还有两个附加类. XNetworkNeursion 用于计算网络输出,这是进行神经网络训练时需要的. 而 XNetworkTraining 类提供了进行神经网络实际训练的基础. 注意,只有在训练阶段才需要计算成本函数和参数.

另一件需要注意的事情是,ANNT 库使用了 SIMD 指令(SSE2 和 AVX 指令集)对计算进行矢量化,并使用 OpenMP10 对计算进行并行化. 运行时检查支持 SIMD,并使用可用的指令集. 如果需要禁用其中的任何内容,则可以编辑 config.hpp 文件.

编译源码

源码里附带 MSVC(2015版)文件和 GCC make 文件. 用 MSVC 非常简单,每个例子的解决方案文件都包括例子本身和库的项目,编译也只需点击一下按钮. 如果使用 GCC,则需要运行 make 来编译程序.

使用范例

为了验证人工神经网络在前馈全连接人工神经网络的不同应用,接下来将讲解与源码一起提供的 5 个例子.

这些例子唯一的目的是用来演示 ANNT 库的使用方法,并不代表用到的神经网络结构就是最适于它们的. 这些代码片段只是范例的一小部分,要查看示例的完整代码,你需要参阅本文提供的源码.

函数逼近

演示的第一个例子是函数逼近(回归). 对于这个任务,有一个样本集,其中包含一些函数的 \((x,y)\) 值,并在 \(y\) 值中添加了噪声. 然后,训练一个单输入单输出的神经网络,令它输出 \(y\) 的近似值. 下面是这个应用程序的两个演示样本集. 蓝线表示理想的函数(base function),而橙色点表示样本集(training set),在 \(y\) 值里添加噪声. 训练过程中,将带有噪声的 \((x,y)\) 输入神经网络,训练完成后,计算 \(y\) 值,观察结果近似值能有多接近理想值.

图 7 直线逼近
图 8 抛物线逼近

在直线样本集的情况下,网络可以像单个神经元一样简单,没有激活函数,即所谓的线性回归. 然而,在抛物线样本集的情况下,需要一个额外的隐藏层来处理样本集的非线性. 下面的代码可以创建一个简单的 2 层神经网络.

// 准备两层全连接人工神经网络
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );

net->AddLayer( make_shared<XFullyConnectedLayer>( 1, 10 ) ); // 1 输入, 10 神经元
net->AddLayer( make_shared<XSigmoidActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 10, 1 ) ); // 10 输入, 1 神经元

然后生成一个训练对象,给出成本函数和梯度下降算法.

// 用 Nesterov 优化器和 MSE 成本函数生成训练内容
XNetworkTraining netTraining( net,
                              make_shared<XNesterovMomentumOptimizer>( ),
                              make_shared<XMSECost>( ) );

最后,循环运行一定世代,每个世代开始时,打乱训练样本集顺序,确保随机抽取样本.

for ( size_t epoch = 1; epoch <= trainingParams.EpochsCount; epoch++ )
{
    // 打乱顺序
    for ( size_t i = 0; i < samplesCount / 2; i++ )
    {
        int swapIndex1 = rand( ) % samplesCount;
        int swapIndex2 = rand( ) % samplesCount;

        std::swap( ptrInputs[swapIndex1], ptrInputs[swapIndex2] );
        std::swap( ptrTargetOutputs[swapIndex1], ptrTargetOutputs[swapIndex2] );
    }

    auto cost = netTraining.TrainEpoch( ptrInputs, ptrTargetOutputs, trainingParams.BatchSize );
}

训练完成后,使用经过训练的神经网络来计算给定输入的函数输出,保存到 csv 文件中,以便进一步分析结果. 下面是几个逼近的例子,和上面一样,蓝线是理想的函数(供参考),橙色点是用于训练神经网络的带噪声样本集. 绿线是期望的从噪声输入中获得的函数的逼近结果(learnt function).

图 9 直线逼近结果
图 10 抛物线逼近结果
图 11 正弦函数逼近结果
图 12 递增的正弦函数逼近结果

时间序列预测

第二个例子演示了时间序列预测. 这里,样本集只有一些函数 \(F(t)\) 的值,而没有 \(t\). 函数的值是按 \(t\) 排序的,因此样本集表示一个按时间顺序生成的序列. 这个例子的任务是训练神经网络,根据过去的值预测函数的未来值.

下面是时间序列的例子,没有添加噪声,没有时间 \(t\) 值,只有函数的值 \(F(t)\).

图 13 时间预测序列

这个例子也可以作为函数逼近处理. 但并不是逼近 \(f(t)\),而是根据指定的 \(t\) 来查找函数的值. 相反,需要根据函数过去的值来查找函数未来的值. 假设将使用函数过去的 5 个值来预测下一个值. 在这种情况下,逼近这些函数:\(F(F(t-1),F(t-2),F(t-3),F(t-4),F(t-5))\),即根据函数的最后 5 个值查找函数的值.

例程第一件事是准备一个训练样本集. 要记住,这个例子与上面的逼近例子不同,这里只有函数的值. 因此需要创建一个样本集,其中包含神经网络和目标输出的样本输入. 假设原始数据文件包含函数 100 个值,这里保留最后的一些值,比如 5 个值,这样就可以检查训练神经网络的预测质量. 在其他 95 个值中,可以生成 90 个输入/输出训练对(因为使用 5 个过去的值来预测下一个).

一旦生成了训练集,用于创建和训练神经网络的其余代码与之前基本相同,唯一的区别是现在是一个 5 输入的神经网络.

// 准备 2 层人工神经网络,5 输入 1 输出 10 隐藏神经元
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );

net->AddLayer( make_shared<XFullyConnectedLayer>( 5, 10 ) );
net->AddLayer( make_shared<XTanhActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 10, 1 ) );

// 用 Nesterov 优化器和 MSE 成本函数生成训练内容
XNetworkTraining netTraining( net,
                              make_shared<XNesterovMomentumOptimizer>( ),
                              make_shared<XMSECost>( ) );

for ( size_t epoch = 1; epoch <= trainingParams.EpochsCount; epoch++ )
{
    // 打乱顺序
    for ( size_t i = 0; i < samplesCount / 2; i++ )
    {
        int swapIndex1 = rand( ) % samplesCount;
        int swapIndex2 = rand( ) % samplesCount;

        std::swap( ptrInputs[swapIndex1], ptrInputs[swapIndex2] );
        std::swap( ptrTargetOutputs[swapIndex1], ptrTargetOutputs[swapIndex2] );
    }

    auto cost = netTraining.TrainEpoch( ptrInputs, ptrTargetOutputs, trainingParams.BatchSize );
}

这个例程也会将结果输出到 csv 文件中,以便进一步分析. 同样,这里也有一些结果的例子. 蓝线是原始数据(original data),橙线是训练网络的输出(training result),用于从训练集获取输入,可以看到,橙线完全跟随蓝线. 绿线代表网络的预测(prediction),给出了未包含在训练集中的数据,并记录输出. 然后,利用刚刚产生的输出进行进一步的预测,然后再进行一次.

图 14 时间预测序列例 1
图 15 时间预测序列例 2
图 16 时间预测序列例 3

异或函数的二进制分类

这个例子相当于人工神经网络的“hello world”,一个非常简单的 2 层神经网络(共 3 个神经元),用来分类异或函数的输入. 前面分析过异或函数(XOR)的表示,现在对它进行分类,可以处理两个分类时常用的二进制交叉熵(binary cross entropy)作为成本函数.

// 准备 XOR 训练数据,输入编码为 -1、1,输出编码为 0、1
vector<fvector_t> inputs;
vector<fvector_t> targetOutputs;

inputs.push_back( { -1.0f, -1.0f } ); /* -> */ targetOutputs.push_back( { 0.0f } );
inputs.push_back( {  1.0f, -1.0f } ); /* -> */ targetOutputs.push_back( { 1.0f } );
inputs.push_back( { -1.0f,  1.0f } ); /* -> */ targetOutputs.push_back( { 1.0f } );
inputs.push_back( {  1.0f,  1.0f } ); /* -> */ targetOutputs.push_back( { 0.0f } );

// 准备 2 层人工神经网络
// 对 AND、OR 函数而言,单层就足够了,但是 XOR 需要两层,这点在前面讨论过
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );

net->AddLayer( make_shared<XFullyConnectedLayer>( 2, 2 ) );
net->AddLayer( make_shared<XTanhActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 2, 1 ) );
net->AddLayer( make_shared<XSigmoidActivation>( ) );

// 用 Nesterov 优化器和二进制交叉熵成本函数生成训练内容
XNetworkTraining netTraining( net,
                              make_shared<XMomentumOptimizer>( 0.1f ),
                              make_shared<XBinaryCrossEntropyCost>( ) );

// 训练神经网络
printf( "每个样本的成本: \n" );
for ( size_t i = 0; i < 80 * 2; i++ )
{
    size_t sample = rand( ) % inputs.size( );
    auto   cost   = netTraining.TrainSample( inputs[sample], targetOutputs[sample] );
}

尽管简单,但这个例子也可以尝试进行破坏性实验. 例如,可以注释掉第一个隐藏层,由此造成神经网络无法学习,无法对异或函数进行分类. 如果不注释隐藏层,而是注释它的激活函数,也会造成同样的结果. 在这种情况下,即使仍然有“两层”,但是因为破坏了非线性组件,于是网络变成了单层,也就无法完成任务了.

下面是例子的输出,显示了训练前后的分类结果,以及随时间推移成本函数值逐渐减少.

全连接人工神经网络 XOR 分类例程

训练前的网络输出:
{ -1.00 -1.00 } -> {  0.54 }
{  1.00 -1.00 } -> {  0.47 }
{ -1.00  1.00 } -> {  0.53 }
{  1.00  1.00 } -> {  0.46 }

每个样本的成本:
0.6262 0.5716 0.4806 1.0270 0.8960 0.8489 0.7270 0.9774
...
0.0260 0.0164 0.0251 0.0161 0.0198 0.0199 0.0191 0.0152

训练后的网络输出:
{ -1.00 -1.00 } -> {  0.02 }
{  1.00 -1.00 } -> {  0.98 }
{ -1.00  1.00 } -> {  0.98 }
{  1.00  1.00 } -> {  0.01 }

鸢尾花多类分类

另一个例子是对鸢尾花进行分类,这是一个非常常见的样本集,常用于测试不同分类算法的性能. 样本集包含 150 个属于 3 个类的样本(每个类 50 个样本). 每一朵鸢尾花都有 4 个特征:花被和花瓣的长度和宽度. 因此,神经网络有 4 个输入和 3 个输出. 正如上面看到的,XOR 的例子只使用了单个输出来区分两个类,编码为 0 和 1 足矣. 但是对于 3 个以上的类,需要使用所谓的独热码one hot encoding),每个类都被编码为零的向量,并且在对应于类号的索引处,只有一个元素被设置为 1. 因此,对于鸢尾花分类,神经网络的目标输出如下:1、0、0、0、1、0 和 0、0、1. 一旦训练完成并向网络提供新的样本,它的类就由输出神经元的索引决定,输出神经元的索引产生最大的值.

这个例子使用了一个特殊的助手类,它封装了整个训练循环,使得神经网络训练代码更短.

// 准备 3 层人工神经网络
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );

net->AddLayer( make_shared<XFullyConnectedLayer>( 4, 10 ) );
net->AddLayer( make_shared<XTanhActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 10, 10 ) );
net->AddLayer( make_shared<XTanhActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 10, 3 ) );
net->AddLayer( make_shared<XSigmoidActivation>( ) );

// 用 Nesterov 优化器和交叉熵成本函数生成训练内容
shared_ptr<XNetworkTraining> netTraining = make_shared<XNetworkTraining>( net,
                                           make_shared<XNesterovMomentumOptimizer>( 0.01f ),
                                           make_shared<XCrossEntropyCost>( ) );

// 用助手类训练人工神经网络分类
XClassificationTrainingHelper trainingHelper( netTraining, argc, argv );
trainingHelper.SetTestSamples( testAttributes, encodedTestLabels, testLabels );

// 40 世代, 每批 10 样本
trainingHelper.RunTraining( 40, 10, trainAttributes, encodedTrainLabels, trainLabels );

助手类的好处在于,它不仅运行训练过程,如果提供了相应的样本集,它还可以运行验证和测试过程,并且提供了有用的进度日志,显示当前训练、验证、所用时间等.

MNIST 手写数字分类

最后一个例子,MNIST 手写数字分类(样本集需要单独下载). 这个例子和上面的鸢尾花分类例子差不多,只是神经网络更大,样本集更大,花的时间更多.

// 准备 3 层人工神经网络
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );

net->AddLayer( make_shared<XFullyConnectedLayer>( trainImages[0].size( ), 300 ) );
net->AddLayer( make_shared<XTanhActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 300, 100 ) );
net->AddLayer( make_shared<XTanhActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 100, 10 ) );
net->AddLayer( make_shared<XSoftMaxActivation>( ) );

// 用 Nesterov 优化器和交叉熵成本函数生成训练内容
shared_ptr<XNetworkTraining> netTraining = make_shared<XNetworkTraining>( net,
                                           make_shared<XAdamOptimizer>( 0.001f ),
                                           make_shared<XCrossEntropyCost>( ) );

// 用助手类训练人工神经网络分类
XClassificationTrainingHelper trainingHelper( netTraining, argc, argv );
trainingHelper.SetValidationSamples( validationImages, encodedValidationLabels, validationLabels );
trainingHelper.SetTestSamples( testImages, encodedTestLabels, testLabels );

// 20 世代, 每批 50 样本
trainingHelper.RunTraining( 20, 50, trainImages, encodedTrainLabels, trainLabels );

在这个例子中,我使用了一个 3 层的神经网络,第一个隐藏层有 300 个神经元,第二个隐藏层有 100 个神经元,输出层有 10 个神经元. 虽然神经网络的结构非常简单,但它在测试样本集(不用于训练的样本集)上的精确度却达到了 96% 以上. 我下一篇关于卷积网络的文章中,我会把这个数字提高到 99% 左右.

结论

这就是目前人工神经网络的前馈全连接及它在人工神经网络库中的实现. 正如前面提到的,这个库将进一步发展,我也回写新文章阐述卷积神经网络和循环神经网络. 对于每个结构,我都会提供新的例子. 有些是全新的,有些将解决与以前完全相同的任务,例如 MNIST 数字分类,以便比较不同神经网络的性能.

到此为止,ANNT 库只使用了 CPU 进行计算,还不支持 GPU. 但是,我确实利用了用于矢量化的 SIMD 指令和用于并行化的 OpenMP 指令来设计 ANNT. GPU 支持,以及其他许多东西,都列在我的待开发列表中,希望能在以后完成.

如果有人想关注 ANNT 库的进展,或者挖掘出比本文提供的更多的代码,可以在 Github 上找到这个项目.

许可

本文以及任何相关的源代码和文件都是根据 GNU通用公共许可证(GPLv3)授权.

关于作者


Andrew Kirillov,来自英国🇬🇧,目前就职于 IBM.


  1. 过去研究内容相对较少,学习可以集中在课题本身. 现在各种衍生变化让人眼花撩乱,加之太多乌合之众也参与到“科技前沿”中来,鱼目混珠,难免令人困惑.

  2. 出自 Michael J. Garbade 博士 How to Create a Simple Neural Network in Python.

  3. 实际上这是第二篇,第一篇是介绍 ANNT 库结构和基本功能的文章. 感兴趣的读者可以参考这篇译文.

  4. Warren Sturgis McCulloch(1898-1969),美国神经生理学家和控制论学家.

  5. Walter Harry Pitts Jr.(1923-1969),美国逻辑学家.

  6. 最简单的阶跃函数也叫开关函数,表达式为:
    \[ f(x)=\left\{ \begin{aligned} 0 & , & x<0 \\ 1 & , & x\geqslant{0} \end{aligned} \right. \]
    函数图像是这样的:

  7. \(f(x)=x^+=max(0,x)\),即输出为输入的正数部分.

  8. 我认为使用作为 epoch 的翻译更贴切. 相比之下,迭代(iteration)这个词太肤浅,没有体现出经由某一过程进化改变的精髓. 即便如此,很多人还是用迭代用得乐此不疲,也是无所谓,开心就好.

  9. 这是一个典型的策略模式(strategy pattern)设计.

  10. 一种高度优化的多线程并行编译处理方案.

posted @ 2019-01-15 11:25  Conmajia  阅读(...)  评论(...编辑  收藏