Andrew Kirillov 著
Conmajia 译
2019 年 1 月 15 日
原文发表于 CodeProject (2018 年 10 月 28 日) . 中文版有小幅修改 ,已获作者本人授权.
本文介绍了如何使用 ANNT 神经网络库生成卷积神经网络进行图像分类识别.
全文约 11,000 字 ,建议阅读时间 30 分钟. 本文数学内容较多 ,如果感到不适 ,可以放弃.
这个库最终命名为 ANNT (Artificial Neural Networks Technology) ,是 AForge.NET 科学计算库 AForge.Neuro
的组成部分 。AForge.NET 是 Andrew Kirillov 的杰作之一 ,主要用于计算机视觉 、人工智能 、机器学习 、图像处理 、机器人等领域 。
源码 491 KB
简介
本文继续上一篇 《前馈全连接神经网络》 ,讨论使用 ANNT 生成卷积神经网络 ,并应用到图像分类处理任务中. 在 《前馈》 中 ,我介绍了随机梯度下降 (SGD) 、误差反向传播 (EBP) 等算法 ,还引入了一个 MNIST 手写文字识别的简单例子. 例子虽然简单 ,但还是达到了 96.5% 的准确率. 这篇文章里 ,我打算介绍一个不同的人工神经网络架构: 卷积神经网络 (convolutional neural networks ,CNN) . 这是专为计算机视觉领域设计的架构 ,适宜处理诸如图像分类 、图像识别之类的任务. 文中附带的例子里 ,我把手写文字分类识别准确率提高到了 99%.
卷积神经网络由 Yann LeCun 在 1998 年提出. 然而那时候公众和业界对人工智能相关领域的关注度很低 ,他的研究在当时无人问津. 直到 14 年后 ,在 ImageNet 比赛中获胜团队使用了这一架构拔得头筹 ,这才引起了广泛的关注. 随后 CNN 一飞冲天 ,迅速流行起来 ,并应用到了大量计算机视觉领域研究中. 如今 ,最先进的卷积神经网络算法在进行图像识别时 ,甚至可以超过人类肉眼识别的准确率.
理论背景
前馈全连接人工神经网络的思路来源于对生物细胞的生理连接规律的研究. 类似的 ,卷积网络则是从动物大脑的学习方式获得灵感. 1950 年代至 1960 年代 ,Hubel 和 Wiesel 的研究揭示了猫与猴子的大脑皮层中负责视觉的部分包含了能响应极小视野的神经元. 如果眼睛不动 ,视觉刺激影响单个神经元放电的视觉空间区域称为感受野 (receptive field) . 相邻的细胞有相似和重叠的接收区. 感受野的大小和位置在整个大脑皮层上有系统的变化 ,从而形成完整的视觉空间图.
虽然汉语里野字有 field 的意思 ,但是 receptive field 翻译成感受野还真是***朗朗上口呢!
在 Hubel 等的论文中 ,他们描述了大脑中两种基本类型的视觉神经细胞 ,简单细胞和复杂细胞 ,每种的行为方式都不同. 例如 ,当识别到某个固定区域里呈某一角度的线条时 ,简单细胞就会激活. 复杂细胞的感受野更大 ,其输出对其中的特定位置不敏感. 这些细胞即便在视网膜的位置发生了变化也会继续对某种刺激作出反应.
1980 年 ,日本的福岛邦彦提出了种层次化的神经网络模型 ,命名为新认知机 (neocongnitron) . 这个模型受简单和复杂细胞的概念的启发 ,新认知者能够通过学习物体的形状来识别模式.
福岛邦彦老爷子奔 90 去的人了 ,对于技术发展还是很关注的. 感兴趣的读者可以通过 [〒电子邮件] 向他请教.
再后来 ,1998 年 ,Yann LeCun 等人引入了卷积神经网络. 第一版的 CNN 叫做 Lenet-5 ,能够分类手写数字.
卷积网络的架构
在开始构建卷积神经网络的细节之前 ,先来看神经网络的组成基础. 正如前一篇文章提到的 ,人工神经网络的许多概念可以作为单独的实体来实现 ,用于执行推理和训练阶段的计算. 由于核心结构已经在前面的文章中列出 ,这里我将直接在顶层添加模块 ,然后把它们粘在一起.
卷积层
卷积层是卷积神经网络的核心部分. 它假定输入是具有一定宽度 、高度和深度的三维形状. 对于第一个卷积层 ,它通常是一个图像 ,最常见的深度是 1 (灰度图像) 或 3 (带 RGB 通道的彩色图像) . 前一层生成一组特征映射 (这里的深度是输入特征映射的数量) 输入到后一层. 这里假设需要处理深度为 1 的输入 ,然后转换为二维结构.
所以 ,卷积层所做的 ,本质上是一个具有核的图像卷积 ,一种非常常见的图像处理操作. 例如 ,可以用来模糊化或者锐化图像. 但讨论卷积网络时并不关心这些. 根据使用的核 ,图像卷积可以用来寻找图像中的某些特征 ,如垂直 、水平边缘 ,角或圆等更复杂的特征. 想想我前面介绍的视觉皮层中简单细胞的概念?
数字图像处理里 ,两个矩阵相乘 ,如果其中一个保持不变 ,那么相当于用它代表的操作对另一个进行某种运算. 所以有时核也称为算子 (operator) .
现在来计算一下卷积. 假设有 n×m (高度 × 宽度) 、矩阵 K (核) 和 I (图像) ,那么卷积可以写成这些矩阵的点积:
K∗I=n∑i=1m∑j=1Kn−i+1,m−j+1∗Ii,j(1)
举个例子 ,对于 3×3 的矩阵 ,可以这么计算它们的卷积:
⎡⎢⎣abcdefghi⎤⎥⎦∗⎡⎢⎣123456789⎤⎥⎦=i∗1+h∗2+g∗3+f∗4+e∗5+d∗6+c∗7+b∗8+a∗9
式 (1) 的卷积定义是从信号处理领域借鉴过来的 ,核经过了垂直和水平翻转. 更直接的计算方法是 K 和 I 不进行翻转 ,直接进行正常点积. 这种操作称为互相关 ,定义如下:
K∗I=n∑i=1m∑j=1Ki,j∗Ii,j
在信号处理里 ,卷积和互相关具有不同的性质 ,并且用于不同的目的. 但是在图像处理和神经网络里 ,这些差异变得很细微 ,通常使用互相关来计算. 对于神经网络来说 , 这点差异并不重要. 稍后可以看到 ,这些“卷积”核实际上是神经网络需要学习的权重. 所以 ,由网络决定哪个核需要学习 , 翻转还是不翻转.
好了 ,现在知道了如何计算两个相同大小的矩阵的卷积. 但是实际图像处理中这种福利局很少有 ,一般通常是一个 3×3 、5×5 、7×7 等大小的正方形矩阵作为核 ,而图像可以是任意大小的. 那么怎么计算图像卷积呢? 为了计算图像卷积 ,在整个图像上移动核 ,并在每个可能位置计算加权和. 图像处理中 ,这个概念被称为滑动窗口 ,从图像的左上角开始 ,计算这一小区域 (大小和核相同) 的卷积. 然后将核右移一个像素 ,计算出另一个卷积. 不断重复 ,完成第一行每个位置的计算 ,然后从第二行开始 ,继续重复前面的计算. 这样 ,当整个图像处理后 ,就能得到一个特征图 ,其中包含了原图每个位置的卷积值.
图 1 说明了图像卷积的计算过程. 对于 8×8 的输入图像 (input image) 和 3×3 的核 (kernel) ,计算得到 6×6 的特征图 (feature map) .
图 1 图像卷积演示
注意卷积只在核完全匹配图像的位置计算 ,图形边缘无法计算卷积. 于是计算得到的特征图总是小于原图.
图 1 的 3×3 卷积核是设计来查找对象的左边缘的 (从滑动窗口的中心看 ,右侧有一条垂直直线) . 特征图中的高正值表示存在要查找的特征 ,零表示没有特征. 对于这个例子 ,负值表示存在“反转”特征 ,也就是对象的右边缘.
当计算卷积时 ,输出特征映射的大小比原图小. 使用的核越大 ,得到的特征图就越小. 对于 n×m 大小的核 ,输入图像的大小将丢失 (n−1)×(m−1). 因此 ,上面的例子如果用 5×5 的核 ,那特征图将只有 4×4. 多数情况下 ,需要特征图和原图等大 ,这时就要填充特征图 ,一般用 0 填充. 假设原图大小为 8×8 ,而核为 5×5 ,那么需要先把原图填充到 12×12 ,添加 4 个额外的行和列 ,每侧各 2 行/列.
现在 ,读者应该已经可以计算卷积了. 接下来要研究这些内容怎样运用到前面定义的卷积层中. 为了保持简单 ,继续使用图 1 的例子. 在这种情况下 ,输入层有 64 个节点 ,卷积层有 36 个神经元. 和全连接层不同的是 ,卷积层的神经元只与前一层的一小部分神经元相连. 卷积层中的每个神经元的连接数与它所实现的卷积核中的权重数相同 ,在上面的例子中是 9 个连接 (核大小 3×3) . 因为假定卷积层的输入具有二维形状 (一般是三维的 ,我这里简化一下 ,便于研究) ,所以这些连接是对先前神经元的矩形组进行的 ,该组神经元的形状与使用中的内核相同. 以前连接的神经元组对于卷积层的每个神经元是不同的 ,但是它确实与相邻的神经元重叠. 使用滑动窗口法计算图像卷积时 ,这些连接的方式与选择原图像素的方式相同.
忽略全连接层和卷积层的神经元与前一层的连接数不同 ,并且这些连接具有一定的结构这样的事实后 ,这两个层可以看作基本相同的: 计算输入的加权和以产生输出. 不过还有一个区别 ,就是卷积层的神经元共享权重. 因此 ,如果一个层做一个 3×3 的卷积 ,它只有一组权重 ,即 9. 每个神经元都共享这个权重 ,用于计算加权和. 而且 ,尽管没有提到 ,卷积层也为加权和增加了偏差值 , 这也是共享的. 表 1 总结了全连接层和卷积层之间的区别:
表 1 全连接层和卷积层对比
全连接层 | 卷积层 |
不假设输入结构 | 假设输入为 2D 形状 (通常是 3D) |
每个神经元都连接到前一层所有神经元 每神经元 64 个连接 | 每个神经元连接到前一层的矩形组 ,连接数等于卷积核的权重数 每神经元 9 个连接 |
每个神经元有自身的权重和偏差值 共 2304 权重 ,36 偏差值 | 共享权重和偏差值 共 9 权重 ,1 偏差值 |
前面的思考都基于卷积层的输入和输出都是二维这个假设. 但是实际上通常输入和输出都具有三维形状. 首先 ,从输出开始 ,每个卷积层计算不止一个卷积. 设计人工神经网络时 ,可以对它所能做的卷积数量进行配置 ,每个卷积使用自己的一组权重 (核) 和偏差值 ,从而生成不同的特征图. 前面提到过 ,不同的核可以用来寻找不同的特征直线 、曲线 、角等. 因此 ,通常会求得一些特征图 ,以突出不同特征. 这些图的计算方法很简单 ,只要在卷积层中添加额外的神经元群 ,这些神经元以单核的方式连接到输入端 ,就可以完成卷积的计算. 尽管这些神经元具有相同的连接模式 ,但它们共享不同的权重和偏差值. 还是用上面的例子 ,假设将卷积层配置为执行 5 个卷积 ,每个执行 3×3 ,这种情况下 ,输出数量 (神经元数量) 是 36×5=180. 5 组神经元组织成二维形状并重复相同的连接模式 ,每组都有自己的权重/偏差集 ,于是可得 45 个权重和 5 个偏差值.
来讨论一下输入的三维性质. 对于第一层卷积层 ,多半都是些图像 ,要么是灰度图 (2D) ,要么是 RGB 彩图 (3D) . 对于后续的卷积层 ,输入的深度等于前一层计算的特征图的数量 (卷积的数量) . 输入深度越大 ,与前一层连接的数量越多 ,卷积层中的神经元数量就越少. 此时使用的实际上是 3D 的卷积核 ,大小为 n×m×d ,d 是输入深度. 可以认为每个神经元都从各自的输入特征图增加了额外的连接. 2D 输入的情况下 ,每个神经元连接到输入特征图的 n×m 矩形区域. 3D 输入的情况下 ,每个神经元连接的是这些区域同样的位置 ,只是它们具有来自不同输入特征图的数字 d.
现在已经将卷积层推广到了三维上 ,也提到了偏差值 ,针对卷积核每个 (x,y) ,式 (1) 可以表示为:
K(f)∗Iy,x=d∑l=1n∑i=1m∑j=1[K(f)l,i,j∗Il,y+i−1,x+j−1+b(f)],f=1,2,⋯,z(2)
总结一下卷积层的参数. 在生成全连接层时 ,只用到输入神经元数量和输出神经元数量两个参数. 生成卷积层时 ,不需要指定输出的数量 ,只用指定输入的形状 ,h×w×d ,以及核的形状 n×m 和数量 z. 因此 ,有 6 个数字:
- w: 输入特征图的宽度
- h: 输入特征图的高度
- d: 输入深度 (特征图的数量)
- m: 卷积核宽度
- n: 卷积核高度
- z: 卷积核数量 (输出特征图的数量)
卷积核的实际大小取决于指定的输入 ,因此可以得到 z 个 n×m×d 大小的卷积核 ,假设没有填充输入 ,这时输出的大小应为 (h−n+1)×(w−m+1)×z.
上面是计算输出的概念性内容 ,接下来训练卷积层时 ,还会再次提到.
ReLU 激活函数
ReLU 激活函数也就是 rectifier 激活函数 ,对卷积神经网络来说 ,它不是什么新东西. 随着更深层次的神经网络的兴起 ,它得到了广泛的推广.
深度神经网络遇到的问题之一就是消失梯度问题. 当使用基于梯度的学习算法和反向传播算法训练人工神经网络时 ,每个神经网络的权重都与当前权重相关的误差函数偏导数成比例变化. 问题是在某些情况下 ,梯度值可能小到权重值不会改变. 这一问题的原因之一是使用传统的激活函数 ,如 sigmoid 和 tanh. 这些函数的梯度在 (0,1) 范围内 ,大部分的值接近于 0. 由于误差的偏导数是用链式法则计算出来的 ,对于一个 n 层网络 ,这些小数字会乘上 n 次 ,梯度将呈指数递减. 结果就是 ,深度神经网络在训练“前面的”层时非常缓慢.
ReLU 函数的定义为 f(x)=x+=max(0,x). 它最大的优点是 ,对于 x>0 的值 ,它的导数总是 1 ,所以它允许更好的梯度传播 ,从而加快深度人工神经网络的训练速度. 和 sigmoid 和 tanh 相比 ,它的计算效率更高 ,速度更快.
 |
 |
(a) ReLU 函数 |
(b) sigmoid 函数 |
图 2 ReLU 函数和 sigmoid 函数
虽然 ReLU 函数存在一些潜在的问题 ,但到目前为止 ,它依然是深度神经网络中最成功最广泛的激活函数之一.
池化层
实践中经常会为卷积层生成一个池化层 (pooling layer) . 池化的目的是减少输入的空间尺寸 ,减少神经网络中的参数和计算量. 这也有助于控制过拟合 (over-fitting) .
最常见的池化技术是平均池化和最大池化. 以最大池化为例 ,使用 2×2 大小过滤器 ,跨距为 2 的 MAX
池化. 对于 n×m 的输入 ,通过将输入中的每个 2×2 区域替换为单个值 (该区域中 4 个值的最大值) ,得到 n2×m2 的结果. 通过设置与池化区域大小相等的跨距 ,可以保证这些区域相邻而不重叠. 图 3 演示了用于 6×6 输入图的过程.
图 3 池化
池化层的过滤器和跨距值. 例如一些应用程序使用具有 2 跨距的 3×3 大小过滤器这样存在部分重叠的池化. 一般来说跨距不会大于过滤器大小 ,图像里很多内容会完全丢失.
池化层使用二维特征图 ,但并且不影响输入深度. 如果输入包含由前一个卷积层生成的 10 个特征图 ,那么池化将分别应用于每个图. 所以通过池化 ,能生成相同数量的特征图 ,但尺寸更小.
建立卷积神经网络
多数情况下 ,卷积网络从卷积层开始 ,卷积层执行初始特征的提取 ,然后是全连接层 ,后者执行最终的分类.
以 LeNet-5 为例. 这是 Yann LeCun 提出的卷积神经网络结构 ,并应用于手写数字分类. 它输入 32×32 的灰度图像 ,产生 10 个值的向量 ,这些值代表数字属于某一类 (数字 0 到 9) 的概率. 表 2 总结了网络的结构 、输出的尺寸和可训练参数 (权重+偏差) 的数量.
表 2 LeNet-5 结构
层类型 |
可训练参数 |
输出大小 |
输入图像 |
|
32×32×1 |
卷积层 1 ,核大小 5×5 ,核数量 6 ReLU 激活 |
156 |
28×28×6 |
最大池化 1 |
|
14×14×6 |
卷积层 2 ,核大小 5×5 ,核数量 16 ReLU 激活函数 |
416 |
10×10×16 |
最大池化 2 |
|
5×5×16 |
卷积层 3 ,核大小 5×5 ,核数量 120 |
3120 |
1×1×120 |
全连接层 1 ,输入 120 ,输出 84 Sigmoid 激活函数 |
10164 |
84 |
全连接层 2 ,输入 84 ,输出 10 SoftMax 激活函数 |
850 |
10
|
这里只有 14706 个可训练参数 ,算是非常简单的卷积神经网络结构了. 业界实用的更复杂的深度神经网络 ,包含了超过几百万个训练参数.
训练卷积网络
到目前为止 ,本文还只局限于推导卷积神经网络 ,即计算给定输入的输出. 但是要从中得到有意义的东西 ,需要先对网络进行训练. 对于图像处理中的卷积算子 ,卷积核通常是人工设计的 ,具有特定的用途 ,比如查找物体边缘 ,锐化图像或是模糊图像等. 设计正确的卷积核来执行所需的任务是一个耗时的过程. 但是对于卷积神经网络 ,情况却完全不同. 在设计这种网络时 ,只用考虑层数 、完成的卷积的数量和大小等 ,而不会设置这些卷积核. 相反 ,网络将在训练阶段学习这些内容. 从本质上说 ,这些核只不过是权重.
卷积人工网络的训练使用与全连接网络训练完全相同的算法——随机梯度下降和反向传播. 正如 《前馈》 中写到的 ,为了计算神经网络误差的偏导数 ,可以使用链式法则. 这样可以为任何可训练层的权重变化定义完整的方程. 我将针对神经网络每个构建块 (building block) ,比如全连接和卷积层 、激活函数 、成本函数等 ,写一些小点的方程 ,而不是那种一个式子占半页纸的大玩意儿.
通过链式法则 ,可以发现神经网络的每个构建块都将其误差梯度计算为输出相对于输入的偏导数 ,并与后面块的误差梯度相乘. 要记住 ,信息流是向后移动的 ,所以计算要从最后一个块开始 ,然后流到前一个块 ,即第一个块. 训练阶段的最后一个块始终是一个成本函数 ,它将误差梯度作为成本 (其输出) 相对于神经网络输出 (成本函数的输入) 的导数进行计算. 这可以通过以下方式定义:
δ(net)i=∂Cost∂neti
所有其他构建块都从下一个块中获取误差梯度 ,并乘以其输出相对于输入的偏导数.
δ(k)i=∂out(k)∂in(k)iδ(k+1)
回忆一下全连接网络的导数. 首先 ,从 MSE 成本函数相对于网络输出的误差梯度开始 (yi 为网络产生的输出 ,ti 为目标输出) :
δ(net)i=yi−ti
当误差梯度通过 sigmoid 激活函数后移时 ,它会以这种方式重新计算 (这里的 oi 是 sigmoid 的输出) ,这是从下一块 (无所谓是什么 ,也可以是成本函数或多层网络中的另一层) 得到的梯度乘以 sigmoid 的导数:
δ(k)i=oi(1−oi)δ(k+1)i
或者 ,如果使用 tanh 作为激活函数 ,则:
δ(k)i=oi(1−o2i)δ(k+1)i
当需要通过一个全连接层向后传播误差梯度时 ,鉴于每个输入输出都各自相连 ,可以得到一个偏导数的和:
δ(k)i=m∑j=1ωi,j∗δ(k+1)j
其中 ,n 是全连接层中的神经元数 ,i 、j 分别表示第 i 个输出和第 j 个输入.
由于全连接层是一个可训练的层 ,它不仅需要将误差梯度向后传递给前一个层 ,还需要计算权重. 使用上述定义的命名约定 ,权重和偏差的计算规则可以写成 (经典 SGD) :
ωi,j(t+1)=ωi,j(t)−λ(δ(k+1)ixj)bi(t+1)=bi(t)−λδ(k+1)i
上面的方程实际上都是 《前馈》 中反向传播的内容. 为什么我要再写一遍? 首先是要提醒一下基础知识 ,其次 ,我用了不同的方式重写 ,其中每个构建块定义自己的误差梯度反向传播方程. 《前馈》 里给出的权重方程有助于理解基本知识以及链规则的工作原理 ,但是作为一个单一的方程 ,它没法通用. 如果成本函数不是 MSE 呢? 如果需要 tanh 或者 ReLU 激活函数而不是 sigmoid 呢? 本文介绍的方法更加灵活 ,允许以各种方式混合人工神经网络的构建块 ,并在不假设哪一层之后进行激活 ,使用哪一个成本函数的情况下进行培训. 此外 ,这样的写法和我实际的 C++ 代码实现类似 ,我把不同的构建块实现为单独的类 ,在训练过程中让它们各自计算前向传递和后向传递.
交叉熵成本函数
卷积神经网络最常用的用途之一是图像分类. 给定一个图像 ,网络需要把它分类到相互排斥的类里去. 比如手写数字分类 ,有 10 个可能的类对应于从 0 到 9 的数字. 或者可以训练一个网络来识别汽车 、卡车 、轮船 、飞机等交通工具. 这种分类的要点是 ,每个输入图像必须只属于一个类别.
在处理多类分类问题时 ,人工神经网络输出的类数应当与要区分的类数相同. 在训练阶段 ,目标输出是独热编码的 ,也就是用零向量表示 ,在与类对应的索引处 ,只有一个元素设置为值“1” 。例如 ,对于 4 类分类的任务 ,目标输出可能是: 第 2 类 \(\{0 、1 、0 、0\}\) 、第 4 类 \(\{0 、0 、0 、1\}\) 等. 任何目标输出都不允许将多个元素设置为“1”或其他非零值. 这可以看作是目标概率 ,即 \(\{0 、1 、0 、0\}\) 输出意味着输入属于第 2 类的概率为 100% ,以及属于其他类的概率为 0%.
上面说的是理想情况 ,实际训练中的神经网络输出不会是非黑即白这么极端 ,比如它可以输出 0.3 、0.35 、0.25 、0.1 之类的小数. 这些输出对应着不同的实际含义. 这表示神经网络没法十分清楚判断目标应该分到哪一类 ,它只能根据计算得到的概率分析 ,第 2 类的概率有 0.35 ,也就是 35% 的可能性 ,而且这是 4 个输出中最高的 ,那么它将猜测这很可能应该属于第 2 类.
所以说 ,需要一个成本函数来量化目标和实际输出之间的差异 ,并指导神经网络计算其参数. 在处理互斥类的概率模型时 ,通常需要处理预测概率和真实值 (ground-truth) 概率. 这种情况下 ,最常见的选择是交叉熵成本函数 (cross-entropy) . 交叉熵是信息论当中的概念. 通过最小化交叉熵 ,通过最小化额外的数据比特量 ,用估计的概率 yi 对出现概率分布 ti (目标或实际分布) 的某些事件进行编码. 为了最小化交叉熵 ,需要使估计概率与实际概率相同.
交叉熵成本函数定义如下:
Cost=−n∑i=1tilog(yi)
其中 ,ti 是目标输出 ,yi 是神经网络输出.
对上式求导 ,成本函数对神经网络输出的偏导数为:
δ(net)i=−tiyi
这就得到了可以代替 MSE 的交叉熵成本函数. 接下来可以开始处理其他构建块并观察误差梯度是如何反向传播的.
SoftMax 激活函数
《前馈》 中已经介绍过在分类问题中用到的神经网络最后一层使用 sigmoid 作为激活函数. 它的输出值域为 (0,1) ,可以理解为从 0% 到 100% 表示的概率. 如果神经网络输出层采用 sigmoid ,它的确可能得到接近于真实值的概率. 但是现在要处理的是互斥类 ,很多情况下 sigmoid 的输出是无意义的. 比如上面的 4 类分类例子: 一个输出向量是 ${0.6,0.55,0.1,0.1} ,这是用 sigmoid 可能得到的结果. 问题在哪? 乍一看 ,这表明应该是第 1 类 (60% 概率) ,但是第 2 类的可能性也很大 (55%) . 而且这个输出结果有一个很大的问题 ,它的各概率和达到了 1.35 ,也就是目标属于这 4 类之一的可能性是 135%. 这在物理上是毫无意义的!
这里要指出两个问题: 第一 ,各分类概率和应为 100% ,不能多 ,也不能少. 第二 ,对于难以识别的分类目标 ,如果目标既像第 1 类 ,又像第 2 类 ,那么怎么能确定 60% 这么高的概率一定是可信的?
为了解决这两个问题 ,需要用到另一个激活函数: SoftMax. SoftMax 类似 sigmoid ,值域也是 (0,1). 不同的是 ,它处理整个输入向量而不是其中的单个值 ,这就保证了输出向量 (概率) 的和恒为 1. SoftMax 定义为:
f(xi)=exi∑mj=1exj
将上面的例子改用 SoftMax 进行处理后 ,输出向量变得更合理了: {0.316,0.3,0.192,0.192}. 可以看到 ,向量中各概率的和等于 1 ,也就是 100%. 最可能的第 1 类 ,它的概率也不再高得离谱 ,只有 31.6%.
和其他激活函数一样 ,SoftMax 也需要定义它的梯度反向传播方程:
δ(k)i=m∑j=1(δ(k+1)j∗{j=i,oi(1−oj)j≠i,−oioj})
表 2 里可以看到 LeNet-5 神经网络架构中包含了全连接层和 sigmoid 激活函数. 这两者的方程也定义完毕 ,现在就可以继续讨论其他构建块了.
ReLU 激活函数
前面提到过 ,ReLU 激活函数在深度神经网络经常用到 ,它对于大于 0 的输入向量梯度恒为 1 ,所以能保证误差梯度在网络中更好地传播. 现在来定义它的梯度反向传播方程:
δ(k)i=δ(k+1)j∗{1,oi>00,oi⩽0}
池化层
为了尽量简洁地说明误差梯度如何通过池化层反向传播 ,假设使用的池化层卷积核大小 2×2 ,跨度 2 ,不填充输入 (只池化有效位置) . 这个假设意味着每个输出特征图的值都是基于 4 个值计算得到的.
尽管池化层假设输入向量是二维数据 ,但是下面的数学定义也可以处理输入输出是一维向量的情况. 首先定义 i2j(i) 函数 ,这个函数接受输入向量第 i 个值 (作为索引) ,并返回输出向量对应的第 j 个值 (作为索引) . 由于每个输出都是用 4 个输入值计算出来的 ,所以这意味着有 4 个 i 会让 i2j(i) 函数输出同一个 j.
先从最大池化开始 ,定义误差梯度反向传播方程之前 ,还有一件事要做. 在正向传递时 ,计算神经网络的输出也会用与输出向量长度相同的最大索引值 (max indices) 向量填充池化层. 如果输出向量包含对应输入值的最大值 ,则最大索引值向量包含最大值的索引. 综上所述 ,可以定义最大池化层的梯度反向传播方程:
δ(k)i=δ(k+1)i2j(i)∗{1,i=pi2j(i)0,i≠pi2j(i)}
其中 ,p 是最大索引值向量.
对平均池化来说 ,就更简单了:
δ(k)i=δ(k+1)i2j(i)q
其中 ,q 是卷积核大小 ,在这个例子里 ,q=4.
卷积层
最后来定义卷积层的反向传播过程. 牢记一点 ,它和全连接层的区别就在于共享权重和偏差值.
从卷积层的权重计算开始. 对于全连接层 ,误差对权重 ωi,j 的偏导数等于下一个块的误差梯度乘以相应的输入值 δ(k+1)ixj. 这是因为每个输入/输出连接都在全连接层中分配了自己的权重 ,而全连接层是不共享的. 但是卷积层和这不一样 ,图 4 显示了卷积核的每个权重都用于多个输入/输出连接. 图中的例子 ,突出显示的卷积核权重每个使用了 9 次 ,对应输入图像中的 9 个不同位置. 因此 ,与权重有关的误差的偏导数也需要有 9 个.
图 4 卷积层计算
和处理池化层时类似 ,这里忽略了卷积层处理的是二维/三维数据这一事实 ,而假设它们是普通的向量/数组 (就像 C++ 编程时用到的那样) . 对于上面的示例 ,第一个权重 (红线框出) 应用于输入 {1,2,3,5,6,7,9,10,11,13,14,15} ,而第四个权重应用于输入 {6,7,8,10,11,12,14,15,16}. 用 ri 表示每个权重使用的输入索引向量. 另外定义 i2o(i,j) 函数 ,它为第 i 个权重和第 j 个输入提供输出值索引. 上图中有几个例子 ,i2o(1,1)=1 、i2o(4,6)=1 、i2o(1,11)=9 、i2o(4,16)=9. 根据这些约定 ,可以定义卷积网络的权重:
ωi(t+1)=ωi(t)−λj∈ri∑jδ(k+1)i2o(i,j)xj(3)
上面的玩意儿有什么意义? 嗯 ,它的意义很丰富. 你想得越多 ,意义就越多. 这里的目标是为所有输出取误差梯度 (因为每个核的权重用于计算所有的输出) ,然后将它们乘以相应的输入. 尽管有多个核 ,但是它们都以相同的模式应用 ,所以即使需要计算不同核的权重 ,权重输入向量也保持不变. 然而 ,i2o(i,j) 是每个核特定的 ,它可以使用核的索引作为额外的参数进行扩展.
更新偏差值要简单得多. 由于每个核/偏差都用于计算输出值 ,所以只需为当前核生成的特性图的误差梯度求和即可:
b(t+1)=b(t)−λs∑jδ(k+1)j(4)
其中 ,s 是特性图.
式 (3) 、式 (4) 都是依据特征图/卷积核来完成的 ,权重和偏差值没有用核索引参数化.
现在来求卷积层误差梯度反向传播的最终方程. 这意味要计算与层输入相关的误差偏导数. 每个输入元素可以多次用于生成要素图的输出值 ,它的使用次数可以与卷积核中的元素数 (权重数) 相同. 但是 ,有些输入只能用于一个输出 ,比如二维特征图的四角. 还要记住 ,每个输入特征图都可以用不同的核进行多次处理 ,从而生成更多的输出图. 假设另一组名为 γi 的辅助向量 ,用于保存第 i 个输入所贡献的输出索引. 再定义 i2w(i,j) 函数 ,它返回连接第 i 个输入到第 j 个输出的权重. 还是以图 4 为例 ,有: i2w(1,1)=1 、i2w(6,1)=4 、i2w(16,9)=4. 利用这些定义 ,误差梯度通过卷积层后向传播的方程可以写为:
δ(k)i=j∈γi∑jωi2w(i,j)∗δ(k+1)j
数学分析到此结束 ,所有需要计算的内容都已经完成了.
ANNT 库
卷积人工神经网络很大程度上是基于 《前馈》 所述的全连接网络实现的设计集. 所有核心类都保持原样 ,只实现了新的构建块 ,允许将它们构建成卷积神经网络. 新的类关系图如下所示 ,跟原来的没有什么区别.
图 5 类关系图
与以前的设置方式类似 ,新的构建块负责计算正向传递上的输出和反向传递上传播误差梯度 (以及在可训练层的情况下计算初始权重) . 因此 ,所有的神经网络训练代码都可以原样照搬. 和其他代码一样 ,新的构建块尽可能使用了 SIMD 指令向量化计算 ,以及 OpenMP 并行计算.
编译源码
源码里附带 MSVC (2015 版) 文件和 GCC make 文件. 用 MSVC 非常简单 ,每个例子的解决方案文件都包括例子本身和库的项目 ,编译也只需点击一下按钮. 如果使用 GCC ,则需要运行 make 来编译程序.
使用例程
分析了那么久的原理和数学推导 ,是时候开始实践并实际生成一些用于图像分类任务的网络了 ,例如分类识别手写数字和汽车 、卡车 、轮船 、飞机之类不同的对象.
这些例子唯一的目的是用来演示 ANNT 库的使用方法 ,并不代表用到的神经网络结构就是最适于它们的. 这些代码片段只是范例的一小部分 ,要查看示例的完整代码 ,你需要参阅本文提供的源码.
MNIST 手写数字分类
第一个例子是对 MNIST 数据库里的手写数字进行分类. 这个数据库包含了 60000 个神经网络训练样本和 10000 个测试样本. 图 6 展示了其中的一部分.
图 6 MNIST 数据库 (部分)
例子使用的卷积神经网络的结构与 LeNet-5 网络非常相似 ,只是规模小得多. 它只有一个全连接网络:
Conv(32x32x1, 5x5x6 ) -> ReLU -> AvgPool(2x2)
Conv(14x14x6, 5x5x16 ) -> ReLU -> AvgPool(2x2)
Conv(5x5x16, 5x5x120) -> ReLU
FC(120, 10) -> SoftMax
上面设置了每个卷积层的输入大小以及它们执行的卷积的大小和数量 ,全连接层的输入/输出数量. 接下来生成卷积神经网络.
vector<bool> connectionTable( {
true, true, true, false, false, false,
false, true, true, true, false, false,
false, false, true, true, true, false,
false, false, false, true, true, true,
true, false, false, false, true, true,
true, true, false, false, false, true,
true, true, true, true, false, false,
false, true, true, true, true, false,
false, false, true, true, true, true,
true, false, false, true, true, true,
true, true, false, false, true, true,
true, true, true, false, false, true,
true, true, false, true, true, false,
false, true, true, false, true, true,
true, false, true, true, false, true,
true, true, true, true, true, true
} );
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );
net->AddLayer( make_shared<XConvolutionLayer>( 32, 32, 1, 5, 5, 6 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XAveragePooling>( 28, 28, 6, 2 ) );
net->AddLayer( make_shared<XConvolutionLayer>( 14, 14, 6, 5, 5, 16, connectionTable ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XAveragePooling>( 10, 10, 16, 2 ) );
net->AddLayer( make_shared<XConvolutionLayer>( 5, 5, 16, 5, 5, 120 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 120, 10 ) );
net->AddLayer( make_shared<XLogSoftMaxActivation>( ) );
从源码可以清楚看到上面的神经网络配置是如何转换成代码的 ,只是这个连接表是首次出现的. 这很容易理解 ,从网络结构和代码可以看出 ,第一层做 6 个卷积 ,因此生成 6 个特征图; 第二层做 16 个卷积. 在某些情况下 ,需要配置层的卷积只在输入特征映射的子集上操作. 如代码所示 ,第二层的前 6 个卷积使用第一层生成的 3 个特征图的不同模式 ,接下来的 9 个卷积使用 4 个特征图的不同模式. 最后一个卷积使用第一层的所有 6 个特征映射. 这样做是为了减少要训练的参数数量 ,并确保第二层的不同特征图不会基于相同的输入特征图.
当创建卷积网络时 ,可以像处理全连接网络一样进行操作: 创建一个训练内容 ,指定成本函数和权重的优化器 ,然后全部传递给一个助手类 ,由它运行训练/验证循环并测试.
shared_ptr<XNetworkTraining> netTraining = make_shared<XNetworkTraining>( net,
make_shared<XAdamOptimizer>( 0.002f ),
make_shared<XNegativeLogLikelihoodCost>( ) );
XClassificationTrainingHelper trainingHelper( netTraining, argc, argv );
trainingHelper.SetValidationSamples( validationImages, encodedValidationLabels, validationLabels );
trainingHelper.SetTestSamples( testImages, encodedTestLabels, testLabels );
trainingHelper.RunTraining( 20, 50, trainImages, encodedTrainLabels, trainLabels );
下面是输出 ,显示了训练进度和测试数据集的最终结果分类精度. 可以看到精度达到了 99.01% ,比起 《前馈》 中 96.55% 的精度更准确了.
MNIST handwritten digits classification example with Convolution ANN
Loaded 60000 training data samples
Loaded 10000 test data samples
Samples usage: training = 50000, validation = 10000, test = 10000
Learning rate: 0.0020, Epochs: 20, Batch Size: 50
Before training: accuracy = 5.00% (2500/50000), cost = 2.3175, 34.324s
Epoch 1 : [==================================================] 123.060s
Training accuracy = 97.07% (48536/50000), cost = 0.0878, 32.930s
Validation accuracy = 97.49% (9749/10000), cost = 0.0799, 6.825s
Epoch 2 : [==================================================] 145.140s
Training accuracy = 97.87% (48935/50000), cost = 0.0657, 36.821s
Validation accuracy = 97.94% (9794/10000), cost = 0.0669, 5.939s
...
Epoch 19 : [==================================================] 101.305s
Training accuracy = 99.75% (49877/50000), cost = 0.0077, 26.094s
Validation accuracy = 98.96% (9896/10000), cost = 0.0684, 6.345s
Epoch 20 : [==================================================] 104.519s
Training accuracy = 99.73% (49865/50000), cost = 0.0107, 28.545s
Validation accuracy = 99.02% (9902/10000), cost = 0.0718, 7.885s
Test accuracy = 99.01% (9901/10000), cost = 0.0542, 5.910s
Total time taken : 3187s (53.12min)
CIFAR10 图片分类
第二个示例对来自 CIFAR-10 数据集的 32×32 彩色图像进行分类. 这个数据集包含 60000 个图像 ,其中 50000 个用于训练 ,另外 10000 个用于测试. 图像分为 10 类: 飞机 、汽车 、鸟 、猫 、鹿 、狗 、青蛙 、马 、船和卡车. 图 7 展示了部分内容.
图 7 CIFAR10 数据集 (部分)
可以看到 ,CIFAR-10 数据集比 MNIST 手写数字复杂得多. 首先 ,图像是彩色的. 其次 ,它们不那么明显. 有些图如果不经提醒 ,我都认不出来. 网络的结构变得更大了 ,但并不是说它变得更深了 ,而是执行卷积和训练权重的数量在增加. 它的网络结构如下:
Conv(32x32x3, 5x5x32, BorderMode::Same) -> ReLU -> MaxPool -> BatchNorm
Conv(16x16x32, 5x5x32, BorderMode::Same) -> ReLU -> MaxPool -> BatchNorm
Conv(8x8x32, 5x5x64, BorderMode::Same) -> ReLU -> MaxPool -> BatchNorm
FC(1024, 64) -> ReLU -> BatchNorm
FC(64, 10) -> SoftMax
将上述神经网络结构转化为代码 ,得到以下结果:
用 ReLU(MaxPool)
和 MaxPool(ReLU)
计算结果相同 ,但计算量减少 75% ,所以这里选用前者.
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );
net->AddLayer( make_shared<XConvolutionLayer>( 32, 32, 3, 5, 5, 32, BorderMode::Same ) );
net->AddLayer( make_shared<XMaxPooling>( 32, 32, 32, 2 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XBatchNormalization>( 16, 16, 32 ) );
net->AddLayer( make_shared<XConvolutionLayer>( 16, 16, 32, 5, 5, 32, BorderMode::Same ) );
net->AddLayer( make_shared<XMaxPooling>( 16, 16, 32, 2 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XBatchNormalization>( 8, 8, 32 ) );
net->AddLayer( make_shared<XConvolutionLayer>( 8, 8, 32, 5, 5, 64, BorderMode::Same ) );
net->AddLayer( make_shared<XMaxPooling>( 8, 8, 64, 2 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XBatchNormalization>( 4, 4, 64 ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 4 * 4 * 64, 64 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XBatchNormalization>( 64, 1, 1 ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 64, 10 ) );
net->AddLayer( make_shared<XLogSoftMaxActivation>( ) );
剩下部分代码和前面的例子类似 ,也是生成训练内容 ,传递给助手类执行. 下面是这个例子的输出:
CIFAR-10 dataset classification example with Convolutional ANN
Loaded 50000 training data samples
Loaded 10000 test data samples
Samples usage: training = 43750, validation = 6250, test = 10000
Learning rate: 0.0010, Epochs: 20, Batch Size: 50
Before training: accuracy = 9.91% (4336/43750), cost = 2.3293, 844.825s
Epoch 1 : [==================================================] 1725.516s
Training accuracy = 48.25% (21110/43750), cost = 1.9622, 543.087s
Validation accuracy = 47.46% (2966/6250), cost = 2.0036, 77.284s
Epoch 2 : [==================================================] 1742.268s
Training accuracy = 54.38% (23793/43750), cost = 1.3972, 568.358s
Validation accuracy = 52.93% (3308/6250), cost = 1.4675, 76.287s
...
Epoch 19 : [==================================================] 1642.750s
Training accuracy = 90.34% (39522/43750), cost = 0.2750, 599.431s
Validation accuracy = 69.07% (4317/6250), cost = 1.2472, 81.053s
Epoch 20 : [==================================================] 1708.940s
Training accuracy = 91.27% (39931/43750), cost = 0.2484, 578.551s
Validation accuracy = 69.15% (4322/6250), cost = 1.2735, 81.037s
Test accuracy = 68.34% (6834/10000), cost = 1.3218, 122.455s
Total time taken : 48304s (805.07min)
前面提到了 ,CIFAR-10 数据集来得更复杂! 计算的结果远远达不到 MNIST 那样 99% 的准确度: 训练集的准确度约 91% ,测试/验证的准确度约 68-69%. 就是这样的精度 ,区区 20 个世代的计算就花了我 13 个小时! 这也说明了 ,对于卷积网络来说 , (如果不用分布式集群或者超级计算机) 普通 PC 仅仅使用 CPU 来计算显然不够看.
结论
本文中讨论了用 ANNT 库生成卷积神经网络. 在这一点上 ,它只能生成相对简单的网络 ,到目前为止 ,还不支持生产更高级 、更流行的架构. 但是正如 CIFAR-10 一例中看到的 ,一旦神经网络变大 ,就需要更多的计算能力来进行训练 ,所以仅仅使用 CPU 是不够的 (目前我只实现了用 CPU 计算网络) . 随着学习深入 ,这个弱点还会不断放大. 所以接下来我会优先研究如何实现 GPU 计算. 至于更复杂的神经网络架构 ,先往后放一放.
现在已经讨论了全连接和卷积的神经网络 ,在接下来的文章里 ,我将介绍递归神经网络 (recurrent neural networks) 架构.
如果想关注 ANNT 库的进展 ,或者挖掘更多的代码 ,可以在 Github 上找到这个项目.
许可
本文以及任何相关的源代码和文件都是根据 GNU 通用公共许可证 (GPLv3) 授权.
关于作者

Andrew Kirillov ,来自英国🇬🇧 ,目前就职于 IBM.
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?