ANNT:前馈全连接神经网络





5.00/5 (17投票s)
本文演示了 ANNT 库在创建全连接 ANN 和将其应用于不同任务方面的用法。
目录
引言
人工智能、机器学习、神经网络以及许多其他认知科学领域的话题最近非常热门。新思想和技术出现得如此之快,以至于几乎不可能全部跟上。过去十年这些领域取得的进步创造了许多新应用、解决已知问题的新方法,当然,也激发了人们学习更多关于它的知识以及如何将其应用于新事物的极大兴趣。
人工神经网络(ANNs,或者简单称为神经网络)这个话题对我来说已经很感兴趣很久了。我大概在 15 年前就开始接触它们,在大学时将其应用于一些工作中,并为开源社区贡献了一些神经网络代码。那时对神经网络的兴趣正在迅速增长,但仍然没有现在这么多的关注。
从那时起,发生了很大的变化——出现了新的神经网络架构,开发了许多出色的应用程序,并产生了惊人的想法。所以我感觉需要花些时间来更新我对这个话题的知识。正如有人在 ANN 相关博客文章中提到的:“理解神经网络内部机制的最好方法是实现它们。” 我决定采取这种方式。结果,我实现了一个小型 C++ 库,用于一些常见的神经网络架构。
当然,市面上有很多很棒的 ANN 库。其中许多面向 Python 开发者,这确实可能很强大,但这不是我选择的编程语言。其他库的代码库相当复杂,可能不容易与理论一起学习。而且还有各种各样针对特定神经网络架构的小型库等等。总之,由于我想了解其内部机制,所以我用自己的方式实现了它。为什么选择 C++?嗯,我想更接近底层——使用 SIMD 指令进行向量化、并行处理,未来还可以考虑 GPU。
本文是关于 ANNT 库系列文章的第一篇,该库实现了某些常见的神经网络架构并将其应用于不同任务。第一篇是关于众所周知的基本知识——前馈全连接网络和反向传播学习算法。它将为后续关于卷积和循环网络的文章奠定基础。每篇文章都将附带迄今为止可用的库源代码和一些工作示例。
理论背景
由于这个话题并不新,因此有许多关于人工神经网络理论、不同架构及其训练的资源。在这里,我们不会深入探讨理论细节,而是非常简要地描述它,并提供指向更全面涵盖该主题的其他材料的链接。
生物学上的启发
现代人工神经网络的许多思想都受到其生物学版本的启发。神经元或神经细胞是神经系统,特别是大脑的核心组成部分。它是一种电兴奋性细胞,通过电信号和化学信号接收、处理和传输信息。神经元之间的信号通过称为突触的特化连接发生。神经元可以相互连接形成神经网络。人脑平均拥有约1000 亿个神经元,它们可能与多达 10000 个其他神经元连接,形成约 1000 万亿个突触连接。
典型的神经元由细胞体(胞体)、树突和轴突组成。树突是起源于细胞体的细长结构,通常延伸数百微米并多次分支。轴突是起源于细胞体并向前延伸一段距离的特殊细胞延伸,在人类中可达一米,在其他物种中甚至更长。大多数神经元通过树突接收信号,并通过轴突发送信号。因此,树突可以被想象成神经元的输入,而轴突是其输出。
人工神经元
一个人工神经元是一个数学函数,代表生物神经元的模型。人工神经元接收一个或多个输入(代表神经树突的电位),并将它们相加以产生输出(或激活,代表沿着其轴突传输的神经元动作电位)。通常,每个输入都分别加权,然后将总和通过一个称为激活函数或传递函数的非线性函数。
用数学方程表示,一个简单的人工神经元由以下公式描述
其中 xj 是神经元的输入,wj 是输入的权重,b 是偏置值,m 是输入数量。为了使事情更紧凑,公式可以用向量表示法重写(这里 x 和 w 是表示为列向量的输入和权重)
第一个人工神经元是 Warren McCulloch 和 Walter Pitts 于 1943 年提出的阈值逻辑单元 (TLU)。作为传递函数,它使用了阈值函数。最初,只考虑了具有二元输入/输出和对权重有限制条件的简单模型。从一开始,就已经注意到任何布尔函数都可以通过此类设备的网络来实现,这很容易从可以实现 **AND** 和 **OR** 函数的事实中看出。
在 1980 年代后期,当神经网络的研究重新兴起时,开始考虑具有更连续形状的神经元。激活函数可微的可能性允许直接使用梯度下降和其他优化算法来调整权重和偏置值。
AND/OR 示例
如上所述,单个神经元可以实现 **OR**
、**AND**
或 **NAND**
等函数。要实现这些函数,神经元的权重可以初始化为以下值
b | w1 | w2 | |
或者 | -0.5 | 1 | 1 |
AND | -1.5 | 1 | 1 |
NAND | 1.5 | -1 | -1 |
将这些权重和偏置值代入神经元方程,并假设它使用阈值激活函数(当 u >= 0 时为 1,否则为 0),我们可以验证神经元确实在工作。
x1 | x2 | uor | yor | uand | yand | unand | ynand | |||
0 | 0 | -0.5 | 0 | -1.5 | 0 | 1.5 | 1 | |||
1 | 0 | 0.5 | 1 | -0.5 | 0 | 0.5 | 1 | |||
0 | 1 | 0.5 | 1 | -0.5 | 0 | 0.5 | 1 | |||
1 | 1 | 1.5 | 1 | 0.5 | 1 | -0.5 | 0 |
我们可以用单个神经元做更复杂的事情吗?例如 **XOR**
函数?不可以。原因是当单个神经元用于分类问题时,它只能用一条直线分离数据点。然而,**XOR**
输入不是线性可分的。下图显示了所有三个函数的三个数据点:**OR**
、**AND**
和 **XOR**
。对于 **OR**
和 **AND**
数据点,可以画一条直线将它们分成不同的类别,而对于 **XOR**
数据点则不行。
上面的分离线实际上是从权重和偏置值获得的。对于 **OR**
函数,我们使用了 b=-0.5,w1=1 和 w2=1。这将得到如下总和:1 * x1 + 1 * x2 - 0.5。将其转换为线性方程,我们得到:x2 = 0.5 - x1——这是分离 **OR**
数据点的直线。
使用不止一个神经元来实现 **XOR**
函数是否可行?当然。请记住,**XOR**
可以使用 **OR**
、**AND**
和 **NAND**
函数实现:**XOR(x1, x2) = AND(OR(x1, x2), NAND(x1, x2))**。这意味着 3 个神经元连接成一个 2 层网络就可以完成任务。
人工神经网络
由于单个神经元能做的事情不多,所以将它们组合成网络——人工神经网络。每个网络包含多个层,而每层又包含多个神经元。人工神经网络有许多不同的架构,它们在神经元如何跨层连接以及输入信号如何在网络中传输方面有所不同。在本文中,我们将从最简单的架构开始——前馈全连接网络。
在这种类型的人工神经网络中,下一层的每个神经元都连接到前一层的每个神经元(并且不连接其他神经元),而第一层的每个神经元连接到所有输入。在这些网络中,信号只朝一个方向传播——从输入到输出。这种类型的网络可以在不同的分类和回归任务中表现良好。
注意:将网络的输入节点表示为输入层,最后一层表示为输出层,所有其他层表示为隐藏层是非常常见的。由于输入层更多的是一种命名约定,并且它实际上并不代表网络本身中的一个实体,因此当我们谈论网络中的层数时,它不会被计入层数。所以,如果我们说有一个 3 层网络,那意味着它有一个网络,其中包含 2 个隐藏层和一个输出层。
为了提供前馈全连接网络的数学模型,我们先就一些变量命名和结构达成一致
- l - 网络中的层数;
- n(k) - 第 **k** 层中的神经元数量;
- n(0) - 进入网络的输入数量;
- m(k) - 进入第 **k** 层的输入数量(注意:**m(k)** = **n(k-1)**);
- y(k) - 第 **k** 层的输出列向量,长度为 **n(k)**;
- y(0) - 进入网络的输入列向量(向量 **x**);
- b(k) - 第 **k** 层的偏置值列向量,长度为 **n(k)**;
- W(k) - 第 **k** 层的权重矩阵。矩阵的第 **i** 行包含该层第 **i** 个神经元的权重。这意味着矩阵的大小是 **n(k)** x **m(k)**。
有了以上所有定义,就可以使用下面的简单公式计算前馈全连接网络的输出了(假设计算顺序从第一层到最后一层)
或者,为了紧凑,这里是用向量表示法表示的相同内容
这基本上就是前馈全连接网络数学的全部内容了!或者接近全部。问题是:仅凭这些公式能做什么?很少。除非我们正确初始化了权重和偏置值以解决我们想要解决的问题,否则使用上述公式实现的人工神经网络将毫无用处。对于上面简单的 OR
/AND
函数,我们手工调整了权重/偏置值来完成任务。但对于比这更复杂的事情,找到这些值并不是一个简单过程。这就是学习算法发挥作用的地方。
激活函数
为了完成神经网络推理所需的数学,我们需要更多地讨论激活函数。如前所述,最早的人工神经元模型使用阈值函数来计算其输出,该输出基于输入的加权总和。虽然简单,但阈值函数有一些缺点。主要缺点是它的导数在 x=0 处未定义,而在其他任何地方都等于零。正如我们将在后面看到的,用于神经网络训练的梯度下降算法要求激活函数是可微的,并且在广泛的输入值上具有非零梯度。
最流行的激活函数之一是Sigmoid 函数,其定义为
Sigmoid 函数的形状类似于阶跃函数(阈值)的形状,但不如阶跃函数那样陡峭。它是平滑的、可微的、非二元的,并且在 (0, 1) 范围内定义——看起来是一个不错的替代方案。但它并非完美,也有其问题。然而,它在用于前馈全连接网络的各种分类任务时被证明效果很好,所以我们现在暂时坚持使用它以保持简单。
![]() Sigmoid函数
| ![]() 双曲正切函数 - Tanh
|
还有一些其他流行的激活函数值得一提,例如
- 双曲正切函数,其形状与 Sigmoid 函数相似,但提供 (-1, 1) 范围内的输出。
- Softmax 函数,它将一个任意实数值的向量“压缩”到相同维度的实数值向量,其中每个元素都在 (0, 1) 范围内,并且所有元素加起来等于 1——这对于分类任务非常有用,在这些任务中,神经网络的输出可以被视为属于某个类别的概率。
- ReLU(整流器),它是深度神经网络架构中流行的激活函数,因为它允许更好的梯度传播,因此具有更少的梯度消失问题。
为什么我们需要激活函数?可以不用它吗?如果我们进行回归任务并且需要无界输出,那么我们可以从网络输出层中移除它。但是我们不能从隐藏层中移除它。隐藏层中的激活函数增加了非线性,因此网络可以学习非线性特征。这使得我们能够解决 XOR
问题之类的任务,例如,其中类别不是线性可分的。从隐藏层中移除激活函数将破坏学习非线性特征的能力,并且实际上会将任何多层网络变成单层网络。是的,没有激活函数的多个层可以被替换为一个层,它将完成相同的工作。或者最好说它完成不了,因为添加任何额外的层都没有意义。
现在,对于神经网络推理的数学来说,它已经完整了——在训练阶段完成后,即当我们调整好网络的权重/偏置值后,计算新数据的网络输出。然而,我们还没有它们。我们需要找到一种训练神经网络的方法,使其能够做一些有用的事情。
训练人工神经网络
对于前馈全连接人工神经网络的训练,我们将使用监督学习算法。这意味着我们将有一个训练数据集,其中提供可能的输入和目标输出的样本。学习算法的非常简要的想法是,给一个未经训练的神经网络(随机初始化的)提供训练数据集中的样本输入,然后它会计算出相应的输出。然后将网络产生的输出与它需要产生的目标输出进行比较,并计算出一些误差值。基于该误差值,然后更新网络的参数(权重和偏置),以减小此误差,即减小产生的输出与目标输出之间的差异。计算输出、误差值然后更新网络参数的一个周期称为一个训练 epoch。通常,训练算法会重复指定的 epoch 次数,或者直到误差值足够小。
成本函数
我们需要做的第一件事是定义误差函数,或者,正如它经常被称为的,成本函数。有许多流行的函数可供选择,它们更适合不同的任务。但是,为了简单起见,我们将从均方误差 (MSE) 函数开始,这是回归任务的常见选择。假设我们有一个包含 **m** 个样本的训练集,它们由输入的 **x(j)** 向量和目标输出的 **t(j)** 向量表示(尽管大多数回归任务假设单个输出,但为了使数学通用,我们将认为它是一个向量)。对于每个可能的输入,网络都会计算出相应的输出向量 **y(j)**。现在,如果我们去掉上标,我们也可以使用 **y** 和 **t** 来表示任意网络的输出及其对应的目标。假设网络在其输出层中有 **n** 个神经元,因此输出向量的元素数量相同,对于单个训练样本,MSE 成本函数可以定义为
如果我们想计算整个训练数据集的成本函数值,那么我们可以对其进行平均
注意:正如成本函数名称所示,它应该是均方误差的平均值。逻辑上表明,均方误差的总和应除以 **n**。然而,将其除以 **2n** 并不会过多改变这个想法,但可以简化后续的导数计算。
现在,我们已经定义了成本函数,我们可以得到一个单一的数值,用于判断人工神经网络在训练数据集上的表现如何。在训练神经网络时,监控此值以查看它是否随时间改进以及改进的速度如何非常有用。
随机梯度下降
定义了成本函数后,我们现在可以进一步进行神经网络训练和更新其权重/偏置,使其表现更好。我们面临的是一个经典的优化问题——我们需要找到这样的网络参数,使得成本函数趋近于其最小值(局部最小值)。为此,我们可以采用梯度下降优化算法。该算法基于一个观察:如果一个多变量函数 **F(x)** 在点 **a** 的邻域内是可定义和可微的,那么如果从 **a** 沿函数在该点的负梯度方向,即 -∇**F(a)** 前进,**F(x)** 会下降得最快。因此,梯度下降算法的参数更新规则定义如下
对于足够小的参数 **λ** 值,**F(an+1)** ≤ **F(an)**。对于函数 **F** 的某些假设,可以保证收敛到局部最小值。
在训练人工神经网络的情况下,我们需要最小化训练集上的成本函数。考虑到训练集是固定的,输入样本和目标输出可以视为常数。因此,成本函数仅成为我们希望优化以最小化成本的网络权重(为简单起见,暂时将偏置值视为特殊权重)的函数。使用随机初始化的权重开始,具有梯度下降算法的神经网络的训练过程是通过以下公式迭代更新权重来完成的
参数 **λ** 被称为学习率,它影响神经网络的训练速度(接近成本函数局部最小值的速度)。参数的最优值取决于神经网络的架构、训练设置等,因此基于经验和实验来选择。如果设置得太低,收敛到局部最小值可能会非常慢,需要很长时间来训练网络。另一方面,如果设置得太高,成本函数可能会振荡甚至发散。
在进一步进行权重更新和计算成本函数梯度之前,让我们看看梯度下降算法存在什么问题。训练集通常会变得非常大——数万到数十万个样本甚至数百万个。计算整个集合上的成本函数可能会非常昂贵,包括 CPU/GPU 和内存方面。一种替代解决方案是使用随机梯度下降 (SGD) 算法,它随机选择一个训练样本(或在训练 epoch 开始时对训练集进行混洗),仅针对该单个样本计算成本函数,然后基于此单个样本进行参数更新。它对训练集中的所有样本重复这种更新迭代,但顺序是随机的。在实践中,随机梯度下降通常会导致更快的训练,因为模型在一个 epoch 内多次得到小的改进,而不是像真正的梯度下降那样每个 epoch 进行一次参数更新。这是因为,很多时候训练集包含许多相似的样本,它们彼此之间的差异很小。因此,处理某些样本的更新,通常会改进未来样本的结果。
因此,根据 SGD 算法,我们的神经网络权重更新规则仅基于单个随机示例 **j**
随机梯度下降的收敛性已经得到分析,并且观察到当学习率 **λ** 以适当的速率减小,并且目标函数是凸函数时,SGD 几乎肯定会收敛到全局最小值,否则几乎肯定会收敛到局部最小值。
小批量梯度下降(或简称为批量梯度下降)是另一种替代算法——介于上述两者之间。它类似于梯度下降,但它不是在整个训练集上计算参数更新,而是在指定大小的批量上进行。与随机梯度下降类似,样本是随机选择到每个批次中(或提前进行混洗)。
尽管批量梯度下降是如今大多数应用的首选设置,但我们现在将坚持使用随机梯度下降来简化其余的训练算法。
链式法则与梯度
现在是时候详细阐述神经网络的权重更新规则了。我们先来看看前馈全连接神经网络最后一层的权重更新过程。假设最后一层有 **n** 个神经元/输出,每个神经元有 **m** 个输入;**yi** 是第 **i** 个神经元的输出,**ui** 是其输入的加权总和(输入到激活函数);**ti** 是第 **i** 个神经元的目标输出;**xj** 是第 **j** 个输入(来自前一层的相应神经元);**wi,j** 是第 **i** 个神经元对于第 **j** 个输入的权重;**bi** 是第 **i** 个神经元的偏置值。根据 SGD 算法,每个权重 **wi,j** 的更新基于成本函数相对于该权重的偏导数,可以这样写
为了计算成本函数的偏导数,我们需要使用所谓的链式法则。原因是成本函数不是网络权重的简单函数。相反,它是网络输出和目标输出的函数,其中网络输出是加权输入总和的函数,而加权总和又可以表示为网络权重的函数。例如,假设有一个函数 **f(x)**,其中 **x** 是另一个函数 **x(t)**,最后 **t** 也是一个函数 **t(a, b)**。或者可以写成 **f(x(t(a, b)))**。假设我们需要找到 **f** 相对于 **a** 的偏导数。使用链式法则可以这样做
将相同的思想应用于成本函数的偏导数,我们可以得到以下公式
我们来逐步找到链中的每个偏导数。虽然我们目前使用的 MSE 成本函数假定是均方误差的**平均值**,但在计算其导数时,使用总和更常见。考虑到这一点,成本函数相对于第 **i** 个神经元输出的偏导数可以这样写
因此,MSE 成本函数相对于网络输出的偏导数就是实际输出与目标输出之间的差值,这可以视为预测误差。如果我们有多个输出神经元,最好为每个单独的神经元计算这样的误差,而不考虑输出层中的神经元数量。因此,通常省略除以 **n**。
下一步是计算激活函数相对于其输入的导数。由于我们使用的是 Sigmoid 激活函数,我们得到以下导数
请注意,Sigmoid 函数的导数有两种定义方式。第一种是基于函数的参数,即 **ui**。然而,在人工神经网络中,没有人真正这样做。通过使用函数本身的值来计算 Sigmoid 的导数要快得多,考虑到它在计算网络输出时已经计算过。
最后,我们可以定义神经元加权总和 **ui** 相对于其权重 **wi,j** 和偏置值 **bi** 的偏导数
将所有这些放在一起,我们得到最后一层神经元的权重和偏置值的以下更新规则
上面的公式可用于仅训练单层的前馈全连接人工神经网络。然而,大多数应用需要多层网络。这就是误差反向传播算法的作用。
误差反向传播
为了获得隐藏层的权重更新规则,我们可以使用与之前相同的链式法则技术。我们已经看到了如何找到成本函数相对于输出层中神经元输出的偏导数。让我们将其表示为 **Ei**——输出层第 i 个神经元的误差项。
现在我们来定义 **E'j** 的公式——成本函数相对于前一层(输出层之前的层)第 j 个神经元输出的偏导数。我们将再次使用链式法则,但我们需要牢记一个重要事项。由于我们有全连接的人工神经网络,前一层的每个输出都连接到下一层的每个神经元。这反映在误差项的计算中。
现在让我们做一些替换。首先,我们将 **Ei** 项放入公式中。然后我们回忆一下,前一层的第 **j** 个输出 **y'j** 可以表示为当前层的输入 **xj**。然后我们可以将上面的公式重写为更通用的形式
上面公式中的 **Ei** 项被故意保留。如果我们进一步应用链式法则来查找另一个隐藏层的误差项,我们将再次得到相同的公式。这意味着一旦使用成本相对于网络输出的偏导数计算出输出层的误差项,那么所有先前层的误差项就可以使用上述公式从下一层的误差项计算得出。
有了上述泛化,我们现在可以写出前馈全连接人工神经网络所有层的权重更新规则。
上面描述的算法称为误差反向传播。一旦计算出输出层的误差,它就会通过神经网络向后传播,使用偏导数机制。因此,在谈论人工神经网络时,通常会提到前向传播和后向传播。前向传播是计算网络输出——信号从输入流向输出。后向传播是计算网络参数的更新——误差值从输出流向输入。
请记住,以上所有内容都适用于我们使用 MSE 作为成本函数和 Sigmoid 作为激活函数的情况。如果使用其他成本函数或激活函数,上述公式将发生变化。但变化不大——只有相应的偏导数项会不同。
好了,理论部分暂时就到这里。显然,关于前馈全连接人工神经网络及其训练还有很多可以说的。但这应该足以作为介绍,而提供的链接则作为额外的资源。
ANNT 库
在实现 ANNT 库的代码时,目标是使其灵活、易于扩展和使用。因此,从一开始就采用了面向对象的范式。在设计人工神经网络的类层次结构时,决定将网络层作为最小的建模实体。这样,就可以获得更好的性能(与一些实现将模型细化到单个神经元相比),并获得构建不同类型神经网络的灵活性。
尽管理论部分表明激活函数是神经元的一部分,但它们的实现被分离成特殊的激活层类。不同的成本函数也作为单独的类实现,以便可以根据正在解决的任务轻松选择一个。由于这种粒度,代码中不会找到理论部分所示的权重更新规则。相反,每个类都通过计算误差梯度的所需项来实现其在反向传播算法中的部分。
例如,XMSECost
类仅计算 **yi – ti** 部分。然后 XSigmoidActivation
类在此基础上添加 **yi(1-yi)** 部分。最后,XFullyConnectedLayer
负责计算相对于权重的偏导数以及将误差梯度传递给前一层。这样,就可以在不硬编码整个权重更新算法的情况下,将不同的激活函数和成本函数插入到神经网络模型中。
梯度下降更新规则也已移至单独的类。如前所述,算法的权重更新公式如下:**w(t+1) = w(t) – λ * Δw(t)**。但这并非唯一的可能算法,而且通常也不是能更快训练的算法。例如,另一种流行的算法称为带动量的梯度下降,其更新规则如下:**v(t) = μ * v(t-1) + λ * Δw(t); w(t+1) = w(t) - v(t)**。由于存在许多不同的梯度下降算法变体,因此将它们实现为独立的类是合乎逻辑的。
XNeuralNetwork
类代表实际的神经网络。网络的架构实际上取决于放入其中的层类型。在本文中,我们将只看到前馈全连接 ANN 的示例。但是,在接下来的文章中,我们将探索卷积和循环神经网络。
最后,还有两个附加类。XNetworkInference
用于仅计算网络的输出,这是我们不再需要训练时所需要的。而 XNetworkTraining
类提供了进行神经网络实际训练所需的基础设施,请注意,成本函数和参数更新算法(优化器)仅在训练阶段需要。
另一个需要注意的地方是,ANNT 库利用 SIMD 指令(SSE2 和 AVX)进行计算的向量化,以及 OpenMP 进行并行化。SIMD 支持在运行时进行检查,并使用可用的指令集。但是,如果出于任何原因需要禁用其中任何一个,可以编辑 Config.hpp 文件。
代码构建
代码附带 MSVC(2015 版本)解决方案文件和 GCC make 文件。使用 MSVC 解决方案非常简单——每个示例的解决方案文件都包含示例本身和库的项目。因此,MSVC 选项就像打开所需示例的解决方案文件并按构建按钮一样简单。如果使用 GCC,则需要先构建库,然后通过运行 **make**
来构建所需的示例应用程序。
使用示例
为了演示 ANNT 库如何在各种前馈全连接人工神经网络应用中使用,我们将探讨代码提供的五个示例。注意:这些示例中的任何一个都不能声称演示的神经网络架构是针对其任务的最佳架构。事实上,这些示例中的任何一个甚至都没有说明人工神经网络是可行的方法。相反,它们唯一的目的是演示库的使用。
注意:下面的代码片段仅是示例应用程序的一小部分。要查看示例的完整代码,请参阅文章随附的源代码包。
函数逼近
第一个演示的示例是函数逼近(回归)。对于此任务,我们有一个数据集,其中包含某个函数的 X/Y 值,并在 Y 值中添加了噪声。任务是训练一个单输入单输出的神经网络,该网络将为给定的输入 X 输出函数的逼近值 Y。例如,下面是此应用程序的两个样本数据集。蓝线显示基本函数,而橙色点表示添加了噪声的 Y 值的这些数据点。然后将在训练期间向神经网络提供有噪声的 X/Y 对。训练完成后,将使用该网络仅从 X 值计算 Y 值,以便我们可以看到逼近的接近程度。
对于线性数据集,网络可以简单地是一个没有激活函数的单个神经元。这称为线性回归。但是,对于抛物线数据集,我们需要一个额外的隐藏层来处理非线性。可以使用下面的代码创建一个简单的 2 层神经网络。
// prepare fully connected ANN with two layers
// the output layer is not followed by activation function,
// so that we have unbounded output
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );
net->AddLayer( make_shared<XFullyConnectedLayer>( 1, 10 ) ); // 1 input, 10 neurons
net->AddLayer( make_shared<XSigmoidActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 10, 1 ) ); // 10 inputs, 1 neuron
然后创建一个网络的训练对象,该对象接受我们选择的成本函数和使用的梯度下降算法变体。
// create training context with Nesterov optimizer and MSE cost function
XNetworkTraining netTraining( net,
make_shared<XNesterovMomentumOptimizer>( ),
make_shared<XMSECost>( ) );
最后,定义了一个训练循环,该循环运行一定数量的 epoch。在每个 epoch 开始时,训练数据集将被混洗,以确保样本按随机顺序选取。
for ( size_t epoch = 1; epoch <= trainingParams.EpochsCount; epoch++ )
{
// shuffle data
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 文件中,以便可以进一步分析结果。下面是逼近结果的一些示例。与之前一样,蓝线是基本函数(用于参考),橙色点是用于训练神经网络的噪声数据集。绿色线是我们感兴趣的——从有噪声输入获得的函数逼近。
时间序列预测
第二个示例演示时间序列预测。在这里,我们的数据集只有某个函数的 F(t) 值,而 t 值缺失。函数的值按 t 排序,因此数据集代表一个时间序列——值按生成时间排序。我们的任务是训练神经网络,根据过去的值来预测函数的未来值。
下面是样本中使用的时间序列示例。没有添加噪声,没有时间值,只有函数值 F(t)。
此示例也可以视为函数逼近。但是,我们不是逼近 F(t)(即根据指定值 t 查找函数值)。相反,我们需要根据其过去的值的数量来查找函数值。假设我们将使用该函数的五个过去值来预测下一个值。在这种情况下,我们将逼近以下函数:F(F(t-1), F(t-2), F(t-3), F(t-4), F(t-5)),即根据最后五个值查找函数的值。
样本应用程序做的第一件事是准备一个训练集。请记住,与上面演示的逼近示例不同,这里我们只有函数的值。因此,我们需要创建一个训练集,其中包含神经网络的样本输入和目标输出。假设原始数据文件包含某个函数的 100 个值。我们将保留最后一些值,例如 5 个值,以便我们可以检查训练好的神经网络的预测质量。在其他 95 个值中,我们可以生成 90 个输入/输出训练对,因为我们使用 5 个过去的值来预测下一个值。
生成训练集后,创建和训练神经网络的其余代码与我们之前看到的相同。唯一的变化是现在我们有了五个输入的神经网络。
// prepare fully connected ANN with two layers - 5 input, 1 output, 10 hidden neurons
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 ) );
// create training context with Nesterov optimizer and MSE cost function
XNetworkTraining netTraining( net,
make_shared<XNesterovMomentumOptimizer>( ),
make_shared<XMSECost>( ) );
for ( size_t epoch = 1; epoch <= trainingParams.EpochsCount; epoch++ )
{
// shuffle data
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 文件,以便可以进一步分析。同样,这里是一些结果示例。蓝线是我们收到的原始数据。橙色线是训练好的网络对从训练集中获取的输入的输出。橙色线与蓝色线非常吻合,这并不奇怪,因为它是网络训练的数据。然而,绿色线代表了网络的预测。它得到了未包含在训练集中的数据,并记录了输出。然后,使用刚刚产生的输出来进行进一步预测,然后再进行一次。
XOR 函数的二元分类
这个例子可以被看作是人工神经网络的“Hello World”应用程序。一个非常简单的 2 层神经网络(总共 3 个神经元)被训练来对 XOR 函数的输入进行分类。由于我们现在转向分类,我们在本例中使用了新的成本函数,即二元交叉熵——这是处理仅两个类别时的常见选择。
// prepare XOR training data, inputs encoded as -1 and 1, while outputs as 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 } );
// Prepare 2 layer ANN.
// A single layer/neuron is enough for AND or OR functions, but XOR needs two layers.
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>( ) );
// create training context with Nesterov optimizer and Binary Cross Entropy cost function
XNetworkTraining netTraining( net,
make_shared<XMomentumOptimizer>( 0.1f ),
make_shared<XBinaryCrossEntropyCost>( ) );
// train the neural network
printf( "Cost of each sample: \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 函数进行分类。如果注释掉的不是隐藏层本身,而是它的激活函数,也会发生同样的情况。在这种情况下,即使我们仍然有“两层”,我们也破坏了非线性组件,这实际上将我们的网络变成了一个单层。
下面是此应用程序的样本输出,显示了训练之前和之后的分类结果,以及随时间推移而降低的成本函数值。
XOR example with Fully Connected ANN
Network output before training:
{ -1.00 -1.00 } -> { 0.54 }
{ 1.00 -1.00 } -> { 0.47 }
{ -1.00 1.00 } -> { 0.53 }
{ 1.00 1.00 } -> { 0.46 }
Cost of each sample:
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
Network output after training:
{ -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 个或更多类别,我们需要使用所谓的独热编码,其中每个类别都编码为带有单个元素设置为 1 的零向量,该元素索引对应于类别编号。因此,对于鸢尾花分类,神经网络的目标输出将如下所示:{1, 0, 0}、{0, 1, 0} 和 {0, 0, 1}。训练完成后,将新的样本提供给网络,其类别由产生最大值的输出神经元的索引确定。
此示例使用一个特殊的辅助类,该类封装了整个训练循环,从而使神经网络训练代码更短。
// prepare a 3 layer ANN
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>( ) );
// create training context with Nesterov optimizer and Cross Entropy cost function
shared_ptr<XNetworkTraining> netTraining = make_shared<XNetworkTraining>( net,
make_shared<XNesterovMomentumOptimizer>( 0.01f ),
make_shared<XCrossEntropyCost>( ) );
// using the helper for training ANN to do classification
XClassificationTrainingHelper trainingHelper( netTraining, argc, argv );
trainingHelper.SetTestSamples( testAttributes, encodedTestLabels, testLabels );
// 40 epochs, 10 samples in batch
trainingHelper.RunTraining( 40, 10, trainAttributes, encodedTrainLabels, trainLabels );
这个辅助类的优点是它不仅运行训练阶段,而且在提供相应的训练集时,还运行验证和测试阶段。它提供了有用的进度日志,显示当前的训练准确度、验证准确度、所用时间等。
Iris classification example with Fully Connected ANN
Loaded 150 data samples
Using 120 samples for training and 30 samples for test
Learning rate: 0.0100, Epochs: 40, Batch Size: 10
Before training: accuracy = 33.33% (40/120), cost = 0.5627, 0.000s
Epoch 1 : [==================================================] 0.005s
Training accuracy = 33.33% (40/120), cost = 0.3154, 0.000s
Epoch 2 : [==================================================] 0.003s
Training accuracy = 86.67% (104/120), cost = 0.1649, 0.000s
...
Epoch 40 : [==================================================] 0.006s
Training accuracy = 93.33% (112/120), cost = 0.0064, 0.000s
Test accuracy = 96.67% (29/30), cost = 0.0064, 0.000s
Total time taken : 0s (0.00min)
MNIST 手写数字分类
最后,前馈全连接人工神经网络的最后一个示例是MNIST 手写数字的分类(数据集需要单独下载)。这个例子与上面的鸢尾花分类例子差别不大——只是一个更大的神经网络,训练集更大,结果是需要更长的时间来训练神经网络。
// prepare a 3 layer ANN
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>( ) );
// create training context with Adam optimizer and Cross Entropy cost function
shared_ptr<XNetworkTraining> netTraining = make_shared<XNetworkTraining>( net,
make_shared<XAdamOptimizer>( 0.001f ),
make_shared<XCrossEntropyCost>( ) );
// using the helper for training ANN to do classification
XClassificationTrainingHelper trainingHelper( netTraining, argc, argv );
trainingHelper.SetValidationSamples
( validationImages, encodedValidationLabels, validationLabels );
trainingHelper.SetTestSamples( testImages, encodedTestLabels, testLabels );
// 20 epochs, 50 samples in batch
trainingHelper.RunTraining( 20, 50, trainImages, encodedTrainLabels, trainLabels );
对于这个例子,我们使用了一个 3 层神经网络——第一隐藏层有 300 个神经元,第二隐藏层有 100 个神经元,输出层有 10 个神经元。尽管该神经网络的架构相当简单,但它在测试数据集(未用于训练的数据集)上达到了 96% 以上的准确率。在下一篇关于卷积网络的文章中,我们将把这个数字提高到 99% 左右。
MNIST handwritten digits classification example with Fully Connected ANN
Loaded 60000 training data samples
Loaded 10000 test data samples
Samples usage: training = 50000, validation = 10000, test = 10000
Learning rate: 0.0010, Epochs: 20, Batch Size: 50
Before training: accuracy = 10.17% (5087/50000), cost = 2.4892, 2.377s
Epoch 1 : [==================================================] 59.215s
Training accuracy = 92.83% (46414/50000), cost = 0.2349, 3.654s
Validation accuracy = 93.15% (9315/10000), cost = 0.2283, 0.636s
Epoch 2 : [==================================================] 61.675s
Training accuracy = 94.92% (47459/50000), cost = 0.1619, 2.685s
Validation accuracy = 94.91% (9491/10000), cost = 0.1693, 0.622s
...
Epoch 19 : [==================================================] 59.822s
Training accuracy = 96.81% (48404/50000), cost = 0.0978, 2.976s
Validation accuracy = 95.88% (9588/10000), cost = 0.1491, 0.527s
Epoch 20 : [==================================================] 87.108s
Training accuracy = 97.77% (48883/50000), cost = 0.0688, 2.823s
Validation accuracy = 96.60% (9660/10000), cost = 0.1242, 0.658s
Test accuracy = 96.55% (9655/10000), cost = 0.1146, 0.762s
Total time taken : 1067s (17.78min)
结论
以上是关于前馈全连接人工神经网络及其在 ANNT 库中的实现。正如前面已经提到的,库将继续发展。届时将会有新的文章,描述卷积和循环人工神经网络。对于每种架构,都将提供新样本。有些将是全新的,而有些示例将解决与之前完全相同的任务,例如 MNIST 数字分类,以便可以比较不同神经网络的性能。
目前,该库仅使用 CPU,没有 GPU 支持。但是,它确实利用 SIMD 指令进行向量化,并使用 OpenMP 进行并行化。GPU 支持以及许多其他功能都在待开发的功能列表中,希望这些功能在某个时候能够实现。
如果有人想关注 ANNT 库的进展或深入研究比文章中提供的代码更多的代码,可以在 GitHub 上找到该项目,该项目已经超越了前馈全连接 ANN,并得到了进一步发展。
链接
- 生物神经元
- 神经元和突触
- 人工神经元
- 人工神经网络
- 神经网络中的 XOR 问题
- 线性可分性
- 激活函数
- 理解神经网络中的激活函数
- 均方误差
- 梯度下降
- 随机梯度下降
- 迷你批量梯度下降的温和介绍
- 多元链式法则,简单版
- 反向传播
- 梯度下降优化算法概述
- 独热编码
- 鸢尾花数据集
- MNIST手写数字数据库
历史
- 2018 年 9 月 28 日:初始版本