TensorFlow2-计算机视觉实用指南-全-
TensorFlow2 计算机视觉实用指南(全)
原文:
annas-archive.org/md5/2ce047cb64d76f6ec2fe1fc950bc3846译者:飞龙
前言
由于深度学习方法如卷积神经网络(CNN)的应用,计算机视觉在健康、汽车、社交媒体和机器人等领域达到了新的高度。无论是自动化复杂任务、辅助专家工作,还是帮助艺术家进行创作,越来越多的公司正在整合计算机视觉解决方案。
本书将探索 TensorFlow 2,这是谷歌开源机器学习框架的全新版本。我们将介绍其主要功能以及最先进的解决方案,并演示如何高效地构建、训练和部署 CNN,用于多种实际任务。
本书读者对象
本书适合具有一定 Python 编程和图像处理背景的读者(例如,知道如何读取和写入图像文件,以及如何编辑其像素值)。本书有一个逐步深入的学习曲线,既适合深度学习的初学者,也适合对 TensorFlow 2 新特性感兴趣的专家。
虽然一些理论解释需要一定的代数和微积分知识,但为关注实际应用的学习者提供了具体的例子。你将一步一步解决现实中的任务,例如自动驾驶汽车和智能手机应用的视觉识别。
本书内容简介
第一章,计算机视觉与神经网络,为你介绍计算机视觉和深度学习,提供一些理论背景,并教你如何从零开始实现并训练一个用于视觉识别的神经网络。
第二章,TensorFlow 基础与模型训练,讲解了与计算机视觉相关的 TensorFlow 2 概念,以及一些更高级的概念。它介绍了 Keras——现在是 TensorFlow 的一个子模块——并描述了如何使用这些框架训练一个简单的识别方法。
第三章,现代神经网络,介绍了卷积神经网络(CNN)并解释了它们如何革新计算机视觉。本章还介绍了正则化工具和现代优化算法,这些工具和算法可用于训练更强大的识别系统。
第四章,影响力分类工具,提供了理论细节和实用代码,帮助你熟练地应用最先进的解决方案——如 Inception 和 ResNet——进行图像分类。本章还解释了什么是迁移学习,为什么它是机器学习中的关键概念,并讲解了如何使用 TensorFlow 2 执行迁移学习。
第五章,物体检测模型,介绍了两种方法的架构,用于检测图像中的特定物体——以速度著称的 You Only Look Once 和以精度著称的 Faster R-CNN。
第六章,增强与图像分割,介绍了自编码器以及如何应用 U-Net 和 FCN 等网络进行图像去噪、语义分割等任务。
第七章,在复杂和稀缺数据集上训练,专注于高效收集和预处理数据集以应用于深度学习。书中介绍了构建优化数据管道的 TensorFlow 工具,以及多种补偿数据稀缺性的解决方案(图像渲染、领域适应、生成网络如 VAE 和 GAN)。
第八章,视频和递归神经网络,介绍了递归神经网络,并展示了更先进的版本——长短时记忆(LSTM)架构。它提供了实际代码,应用 LSTM 进行视频中的动作识别。
第九章,优化模型并在移动设备上部署,详细介绍了模型优化方面的内容,包括速度、磁盘空间和计算性能。书中通过一个实际例子讲解了如何在移动设备和浏览器上部署 TensorFlow 解决方案。
附录,从 TensorFlow 1 到 TensorFlow 2 的迁移,提供了一些关于 TensorFlow 1 的信息,重点介绍了 TensorFlow 2 引入的关键变化。同时也提供了将旧项目迁移到最新版本的指南。最后,为了那些想要深入了解的读者,每章参考文献也一并列出。
为了最大化利用本书
以下部分包含一些信息和建议,旨在帮助读者更好地阅读本书,并充分利用附加材料。
下载并运行示例代码文件
练习成就完美。因此,本书不仅提供了对 TensorFlow 2 和最先进计算机视觉方法的深入解释,还附带了每章的许多实际示例和完整实现。
下载代码文件
您可以从您的 www.packt.com 账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packtpub.com/support 并注册,以便直接通过电子邮件获取文件。
您可以通过以下步骤下载代码文件:
-
在 www.packt.com 登录或注册。
-
选择“支持”标签。
-
点击“代码下载”。
-
在搜索框中输入书名,并按照屏幕上的指示操作。
下载文件后,请确保使用以下最新版本的工具解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2。如果代码有更新,将会在现有的 GitHub 仓库中进行更新。
我们还提供了其他代码包,来自我们丰富的书籍和视频目录,均可在 github.com/PacktPublishing 查找。赶快去看看吧!
学习并运行实验
Jupyter Notebook(jupyter.org)是一个开源的 Web 应用,用于创建和分享 Python 脚本,结合文本信息、视觉结果、公式等更多内容。我们将 Jupyter 笔记本 称为本书中提供的文档,包含详细的代码、预期结果以及补充说明。每个 Jupyter 笔记本都专注于一个具体的计算机视觉任务。例如,一个笔记本解释了如何训练 CNN 来检测图像中的动物,另一个详细描述了构建自动驾驶汽车识别系统的所有步骤,等等。
正如我们将在本节中看到的,这些文档可以直接学习,也可以作为代码示例运行,重现书中展示的实验。
在线学习 Jupyter 笔记本
如果你只是想浏览提供的代码和结果,可以直接在线访问本书的 GitHub 仓库中的内容。事实上,GitHub 可以渲染 Jupyter 笔记本并将其显示为静态网页。
然而,GitHub 查看器会忽略一些样式格式和交互内容。为了获得最佳的在线查看体验,我们推荐使用 Jupyter nbviewer(nbviewer.jupyter.org),这是一个官方的在线平台,你可以用它来读取上传到网上的 Jupyter 笔记本。该网站可以查询并渲染存储在 GitHub 仓库中的笔记本。因此,提供的 Jupyter 笔记本也可以通过以下地址进行阅读:nbviewer.jupyter.org/github/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2。
在你的机器上运行 Jupyter 笔记本
要在你的机器上阅读或运行这些文档,你应该首先安装 Jupyter Notebook。对于已经使用 Anaconda(www.anaconda.com)来管理和部署 Python 环境的用户(如我们在本书中推荐的那样),Jupyter Notebook 应该已经可以直接使用(因为它与 Anaconda 一起安装)。对于使用其他 Python 发行版的用户,或者不熟悉 Jupyter Notebook 的用户,我们推荐查看文档,它提供了安装说明和教程(jupyter.org/documentation)。
一旦 Jupyter Notebook 安装在您的机器上,导航到包含书籍代码文件的目录,打开终端并执行以下命令:
$ jupyter notebook
网页界面应在您的默认浏览器中打开。此时,您应该能够浏览目录并打开提供的 Jupyter 笔记本,进行阅读、执行或编辑。
一些文档包含高级实验,这些实验可能需要大量计算资源(例如,使用大型数据集训练识别算法)。没有适当的加速硬件(即没有兼容的 NVIDIA GPU,如第二章《TensorFlow 基础与模型训练》所述),这些脚本可能需要数小时甚至数天的时间(即使有兼容的 GPU,最先进的示例仍然可能需要很长时间)。
在 Google Colab 中运行 Jupyter 笔记本
对于那些希望自行运行 Jupyter 笔记本——或进行新的实验——但没有足够强大机器的用户,我们推荐使用Google Colab,也叫做Colaboratory (colab.research.google.com)。它是 Google 提供的基于云的 Jupyter 环境,用户可以在强大的机器上运行计算密集型脚本。有关此服务的更多详情,请查阅 GitHub 仓库。
下载彩色图像
我们还提供了一个 PDF 文件,其中包含书中使用的截图/图表的彩色图像。您可以在此下载:www.packtpub.com/sites/default/files/downloads/9781788830645_ColorImages.pdf。
使用的约定
本书中使用了多种文本约定。
CodeInText:表示文本中的代码词、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 以及用户输入。例如:“Model对象的.fit()方法启动训练过程。”
代码块的设置如下:
import tensorflow as tf
x1 = tf.constant([[0, 1], [2, 3]])
x2 = tf.constant(10)
x = x1 * x2
当我们希望您注意代码块中的特定部分时,相关的行或项会用粗体显示:
neural_network = tf.keras.Sequential(
[tf.keras.layers.Dense(64),
tf.keras.layers.Dense(10, activation="softmax")])
任何命令行输入或输出如下所示:
$ tensorboard --logdir ./logs
粗体:表示新术语、重要词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的词汇会以这种方式显示。这里是一个例子:“您可以在 TensorBoard 的 Scalars 页面上观察解决方案的表现。”
警告或重要提示如下所示。
提示和技巧如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中注明书名,并通过customercare@packtpub.com联系我们。
勘误:尽管我们已经尽力确保内容的准确性,但错误还是难免。如果您在本书中发现了错误,我们将不胜感激,您可以将错误报告给我们。请访问 www.packtpub.com/support/errata,选择您的书籍,点击“勘误提交表单”链接,并填写相关细节。
盗版:如果您在互联网上发现任何我们作品的非法复制品,请您提供该内容的地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 联系我们,并提供该材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专长,并且有兴趣写作或为书籍贡献内容,请访问 authors.packtpub.com。
评价
请留下评价。在您阅读并使用了本书后,为什么不在您购买书籍的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决策,我们 Packt 可以了解您对我们产品的看法,我们的作者也能看到您对他们书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packt.com。
第一部分:TensorFlow 2 与深度学习在计算机视觉中的应用
本节介绍计算机视觉和深度学习的基础知识,并通过具体的 TensorFlow 示例进行讲解。首先介绍这些技术领域,然后第一章将带你深入了解神经网络的内部工作原理。本节继续介绍 TensorFlow 2 和 Keras 的重要功能,它们的关键概念和生态系统。最后,描述计算机视觉专家采用的机器学习技术。
本节将涵盖以下章节:
-
第一章,计算机视觉与神经网络
-
第二章,TensorFlow 基础与模型训练
-
第三章,现代神经网络
第一章:计算机视觉与神经网络
近年来,计算机视觉已经成长为创新的一个关键领域,越来越多的应用正在重新塑造企业和生活方式。我们将从简要介绍该领域及其历史开始,以便为读者提供一些背景信息。接着,我们将介绍人工神经网络,并解释它们如何革新计算机视觉。因为我们相信通过实践学习,因此在本章结束时,我们甚至会从零开始实现我们自己的神经网络!
本章将涉及以下主题:
-
计算机视觉及其作为一个迷人的当代领域的原因
-
我们是如何走到这一步的——从本地手工制作的描述符到深度神经网络
-
神经网络,它们究竟是什么,以及如何为一个基础的识别任务实现我们自己的神经网络
技术要求
在本书中,我们将使用 Python 3.5(或更高版本)。作为一种通用编程语言,Python 凭借其有用的内建特性和著名的库,已成为数据科学家们的主要工具。
对于本介绍性章节,我们将只使用两个基础库——NumPy 和 Matplotlib。它们可以在www.numpy.org和matplotlib.org上找到并进行安装。然而,我们建议使用 Anaconda(www.anaconda.com),这是一个免费的 Python 发行版,使得包管理和部署变得简单。
完整的安装说明以及本章展示的所有代码,可以在 GitHub 上的github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow2/tree/master/Chapter01找到。
我们假设读者已经具备一些 Python 的知识,并且对图像表示(像素、通道等)和矩阵操作(形状、乘积等)有基本的理解。
计算机视觉的实际应用
计算机视觉如今无处不在,以至于它的定义在不同专家之间可能有很大的差异。在本节介绍中,我们将勾画计算机视觉的整体图景,重点介绍它的应用领域和面临的挑战。
介绍计算机视觉
计算机视觉很难定义,因为它位于多个研究和开发领域的交汇点,例如计算机科学(算法、数据处理和图形学)、物理学(光学和传感器)、数学(微积分和信息论)和生物学(视觉刺激和神经处理)。从本质上讲,计算机视觉可以总结为从 数字图像中自动提取信息。
我们的大脑在视觉方面的表现令人惊叹。我们能够解读眼睛不断捕捉到的视觉刺激,瞬间区分不同物体,并识别出我们只见过一次的人脸,这一切都不可思议。对于计算机来说,图像只是像素块,是由红绿蓝值组成的矩阵,毫无其他意义。
计算机视觉的目标是教会计算机如何像人类(及其他生物)一样理解这些像素,甚至做得更好。事实上,计算机视觉已经取得了长足进展,尤其是在深度学习的兴起后,它在一些任务中已经达到了超越人类的表现,例如人脸验证和手写文字识别。
随着由全球最大 IT 公司推动的研究社区的高度活跃,以及数据和视觉传感器日益普及,越来越多的雄心勃勃的问题正在被解决:基于视觉的自动驾驶导航、基于内容的图像和视频检索,以及自动标注和增强等。这无疑是专家和新手们都充满期待的时代。
主要任务及其应用
基于计算机视觉的新产品每天都在出现(例如,工业控制系统、互动智能手机应用和监控系统),它们涵盖了广泛的任务。在本节中,我们将介绍其中的主要应用,并详细说明它们如何解决实际问题。
内容识别
计算机视觉的核心目标是理解图像,即从像素中提取有意义的、语义层面的信息(例如图像中出现的物体、它们的位置和数量)。这个通用问题可以分为多个子领域。以下是一个非详尽的列表。
物体分类
物体分类(或称图像分类)是将图像分配到预定义类别中的任务,示意图如下:

图 1.1:对人和车标签的分类器应用示例,应用于图像集
物体分类因 2012 年深度卷积神经网络首次成功应用于计算机视觉而成名(这一点将在本章后面介绍)。自那时以来,这一领域的进展非常迅速,以至于现在在各种应用场景中都达到了超越人类的表现(一个著名的例子是狗品种的分类;深度学习方法已经变得极其高效,能够识别出人类最好的朋友的区分特征)。
常见应用包括文本数字化(使用字符识别)和图像数据库的自动标注。
在第四章《有影响力的分类工具》中,我们将介绍先进的分类方法及其对计算机视觉的整体影响。
物体识别
而物体分类方法从预定义的集合中分配标签,物体识别(或实例分类)方法则学习识别类别的特定实例。
例如,一个物体分类工具可以配置为返回包含面部的图像,而识别方法则会专注于面部特征,以识别该人并在其他图像中识别他们(识别每一张面孔,如下图所示):

图 1.2:应用于肖像的标识符示例
因此,物体识别可以看作是一个聚类数据集的过程,通常应用一些数据集分析概念(将在第六章,增强与图像分割中介绍)。
物体检测与定位
另一个任务是图像中特定元素的检测。它通常应用于监控应用中的人脸检测,甚至高级相机应用,医学中癌细胞的检测,工业工厂中损坏组件的检测等。
检测通常是进一步计算的前置步骤,它提供了图像的较小区域,以便分别进行分析(例如,裁剪某人的面部用于人脸识别,或提供围绕物体的边界框以评估其在增强现实应用中的姿态),如下图所示:

图 1.3:汽车检测器示例,返回候选物体的边界框
最先进的解决方案将在第五章,物体检测模型中详细介绍。
物体与实例分割
分割可以看作是更高级的检测类型。与仅提供识别元素的边界框不同,分割方法返回标记所有像素的掩码,这些像素属于特定类别或特定实例(请参见下图图 1.4)。这使得任务变得更加复杂,实际上是计算机视觉中少数几个仍远不及人类表现的任务之一(我们的脑袋确实在绘制视觉元素的精确边界/轮廓方面异常高效)。物体分割和实例分割在下图中进行了说明:

图 1.4:比较汽车的物体分割方法和实例分割方法的结果
在图 1.4中,虽然物体分割算法为所有属于汽车类的像素返回一个单一的掩膜,但实例分割算法则为每个被识别的汽车实例返回不同的掩膜。这对于机器人和智能汽车来说是一个关键任务,目的是让它们理解周围环境(例如,识别车辆前方的所有元素),但在医学影像中也有应用。精准分割医学扫描中的不同组织可以加速诊断并简化可视化(例如,为每个器官上色,或去除视图中的杂物)。这将在第六章《图像增强与分割》中进行展示,并针对自动驾驶应用提供具体实验。
姿态估计
姿态估计的含义可能根据目标任务不同而有所不同。对于刚性物体,它通常意味着估计物体在三维空间中相对于相机的位置和方向。这对于机器人尤其有用,使它们能够与环境互动(物体拾取、碰撞避免等)。它也常常应用于增强现实,通过叠加三维信息到物体上。
对于非刚性元素,姿态估计也可以意味着估计其子部件之间的位置关系。更具体地说,当将人类视为非刚性目标时,典型应用包括人类姿态的识别(站立、坐着、跑步等)或手语的理解。这些不同的情况在下图中有所示例:

图 1.5:刚性与非刚性姿态估计示例
在这两种情况下——无论是针对完整还是部分元素——算法的任务都是基于其在图像中的二维表示,评估它们在三维世界中相对于相机的实际位置和方向。
视频分析
计算机视觉不仅适用于单张图像,还适用于视频。如果视频流有时逐帧分析,则有些任务要求你将图像序列整体考虑,以便考虑时间一致性(这将是第八章《视频与递归神经网络》中的一个主题)。
实例跟踪
一些与视频流相关的任务可以通过简单地逐帧分析来完成(无记忆),但更高效的方法要么考虑图像间的差异,以指导处理到新帧,要么将完整的图像序列作为输入进行预测。跟踪,即在视频流中定位特定元素,就是这样一个任务的典型示例。
跟踪可以通过对每一帧应用检测和识别方法逐帧完成。然而,使用之前的结果来建模实例的运动,以便部分预测它们在未来帧中的位置要高效得多。因此,运动连续性在这里是一个关键前提,尽管它并不总是成立(例如,对于快速移动的物体)。
动作识别
另一方面,动作识别属于只能通过图像序列来执行的任务之一。就像我们不能仅凭单独且无序的词语理解一个句子一样,我们无法在不研究连续图像序列的情况下识别一个动作(参见图 1.6)。
识别一个动作意味着从预定义的一组中识别特定的运动(例如,针对人类动作——跳舞、游泳、画正方形,或画圆)。应用范围从监控(例如,检测异常或可疑行为)到人机交互(例如,手势控制设备):

图 1.6:巴拉克·奥巴马是在挥手、指向某人、拍打蚊子,还是做其他事情?
只有完整的帧序列才能帮助标注这一动作。
由于物体识别可以分为物体分类、检测、分割等,动作识别也可以如此(动作分类、检测等)。
运动估计
一些方法侧重于估计实际速度/轨迹,而不是试图识别运动中的元素,这些方法在视频中被捕捉到。评估相对于表示场景的相机自身的运动(自运动)也是常见的做法。这在娱乐行业中尤为重要,例如,捕捉运动以便应用视觉效果,或在体育转播等电视直播中叠加 3D 信息。
内容感知图像编辑
除了分析其内容,计算机视觉方法还可以应用于改善图像本身。越来越多的基础图像处理工具(例如,图像去噪的低通滤波器)正被更智能的方法所取代,这些方法能够利用图像内容的先验知识来改善其视觉质量。例如,如果一个方法学习到鸟类的典型样子,它可以运用这些知识,将鸟类图片中的噪点像素替换为连贯的像素。这个概念适用于任何类型的图像修复,无论是去噪、去模糊,还是分辨率增强(超分辨率,如下面的图示所示):

图 1.7:传统方法与深度学习方法在图像超分辨率上的比较。注意第二张图像中的细节更加清晰。
内容感知算法也被应用于一些摄影或艺术应用中,比如智能手机上的智能人像或美颜模式,旨在增强某些模型的特征,或者智能去除/编辑工具,能够去除不需要的元素并用连贯的背景替代。
在第六章《图像增强与分割》和第七章《复杂稀缺数据集的训练》中,我们将展示如何构建并实现这些生成性方法。
场景重建
最后,尽管我们在本书中不会深入讨论,场景重建是指在给定一张或多张图像的情况下,恢复场景的 3D 几何结构。一个简单的基于人类视觉的例子是立体匹配。这个过程是从不同视角对同一场景的两张图像进行匹配,以推算出每个可视化元素的距离。更先进的方法会使用多张图像,并将它们的内容匹配在一起,从而获取目标场景的 3D 模型。这种技术可以应用于物体、人类、建筑等的 3D 扫描。
计算机视觉简史
“如果你想定义未来,就要研究过去。”
——孔子
为了更好地理解计算机视觉的现状及其当前面临的挑战,我们建议快速回顾一下它的起源以及过去几十年的演变过程。
从初步成功到第一步
科学家们长期以来一直梦想着开发人工智能,包括视觉智能。计算机视觉的最初进展正是源自这一想法。
低估感知任务
计算机视觉作为一个领域,最早起步于 60 年代,源于人工智能(AI)研究社区。尽管当时深受符号主义哲学的影响,该哲学认为下棋和其他纯粹的智力活动是人类智慧的巅峰,这些研究人员低估了低级动物功能(如感知)的复杂性。1966 年,这些研究人员曾相信他们能够通过一个夏季项目重现人类感知,这成为计算机视觉领域的一个著名轶事。
马文·明斯基是最早提出基于感知构建 AI 系统方法的人之一(在《人工智能的步伐》一文中,发表在 1961 年的 IRE 会议录上)。他认为,利用模式识别、学习、规划和归纳等低级功能,有可能构建能够解决各种问题的机器。然而,这一理论直到 80 年代才得到深入研究。在 1984 年的《运动、视觉与智能》一书中,汉斯·莫拉维克指出,我们的神经系统通过进化过程已经发展出应对感知任务的能力(我们的大脑中超过 30%的区域专门负责视觉!)。
正如他所指出的,尽管计算机在算术运算方面表现得相当不错,但它们无法与我们感知能力相抗衡。从这个意义上说,编程让计算机解决纯粹的智力任务(例如,下棋)并不一定有助于开发在一般意义上或相对人类智能的智能系统。
手工制作局部特征
受人类感知的启发,计算机视觉的基本机制是简单直接的,并且自早期以来没有太大演变——其思想是首先从原始像素中提取有意义的特征,然后将这些特征与已知的标记特征进行匹配,以实现识别。
在计算机视觉中,特征是从与当前任务相关的数据中提取的信息片段(通常数学上表示为一维或二维向量)。特征包括图像中的一些关键点、特定的边缘、区分性补丁等。它们应该容易从新图像中获取,并包含进一步识别所需的必要信息。
研究人员曾提出越来越复杂的特征。最初考虑提取边缘和线条用于场景的基本几何理解或字符识别;接着,纹理和光照信息也被纳入考虑,促使了早期的物体分类器的发展。
在 90 年代,基于统计分析的特征,如主成分分析(PCA),首次成功应用于面部分类等复杂识别问题。一个经典的例子是由 Matthew Turk 和 Alex Pentland 提出的特征脸方法(《Eigenfaces for Recognition》,MIT 出版社,1991 年)。给定一个面部图像数据库,通过 PCA 计算出平均图像和特征向量/图像(也称为特征向量/图像)。这组小型的特征图像理论上可以线性组合,重建原始数据集中的任何面孔,甚至超出该数据集。换句话说,每张面部图像可以通过特征图像的加权和来逼近(参见图 1.8)。这意味着,一个特定的面孔仅通过每个特征图像的重建权重列表即可定义。因此,分类一个新面孔只需要将其分解为特征图像,获得其权重向量,然后与已知面孔的向量进行比较:

图 1.8:将肖像图像分解为平均图像和特征图像的加权和。这些平均图像和特征图像是在更大的面部数据集上计算得出的
另一种在 90 年代末出现并彻底改变了该领域的方法叫做尺度不变特征变换(SIFT)。顾名思义,这种方法由 David Lowe 提出(在《来自尺度不变关键点的独特图像特征》一文中,Elsevier),它通过一组对尺度和方向变化具有鲁棒性的特征来表示视觉对象。简而言之,这种方法在图像中寻找一些关键点(通过寻找其梯度中的不连续性),提取每个关键点周围的图像块,并为每个关键点计算一个特征向量(例如,图像块中或其梯度中的值的直方图)。然后,图像的局部特征及其相应的关键点可以用来在其他图像中匹配相似的视觉元素。在下图中,SIFT 方法应用于一张图片,使用了 OpenCV(docs.opencv.org/3.1.0/da/df5/tutorial_py_sift_intro.html)。对于每个局部化的关键点,圆圈的半径代表考虑用于特征计算的图像块的大小,线条表示特征方向(即邻域梯度的主要方向):

图 1.9:表示从图像中提取的 SIFT 关键点(使用 OpenCV)
随着时间的推移,出现了更先进的方法——这些方法提供了更鲁棒的方式来提取关键点、计算和结合区分性特征——但它们遵循了相同的总体流程(从一张图像中提取特征,并将其与其他图像的特征进行比较)。
在此基础上加入一些机器学习
然而,很快就清楚了,提取鲁棒的、区分度高的特征只是完成识别任务的一半工作。例如,同一类中的不同元素可能看起来完全不同(比如不同品种的狗),因此它们共享的共同特征可能仅限于少数几个。因此,与图像匹配任务不同,更高层次的问题,如语义分类,不能仅仅通过比较查询图像中的像素特征与标签图片中的像素特征来解决(如果必须与大型标签数据集中的每张图片进行比较,这种过程在处理时间上也可能变得不理想)。
这时,机器学习发挥了作用。随着越来越多的研究人员在 90 年代尝试解决图像分类问题,基于特征来区分图像的统计方法逐渐出现。支持向量机(SVMs),由 Vladimir Vapnik 和 Corinna Cortes 标准化(《支持向量网络》,Springer,1995),在很长一段时间内,是从复杂结构(如图像)到简单标签(如类别)进行映射学习的默认解决方案。
给定一组图像特征及其二进制标签(例如,猫 或 非猫,如 图 1.10 所示),可以优化 SVM 来学习分离一个类别与另一个类别的函数,基于提取的特征。一旦获得该函数,就只需将其应用于未知图像的特征向量,便能将其映射到两个类别中的一个(后来的 SVM 还可以扩展到更多类别)。在下面的示意图中,SVM 被训练回归一个线性函数,通过从图像中提取的特征来分离两个类别(在这个例子中,特征作为只有两个值的向量):

图 1.10:一个由 SVM 回归的线性函数的示意图。请注意,使用一种叫做核技巧的概念,SVM 也可以找到非线性解法来分离不同的类别。
多年来,计算机视觉领域也借用了其他机器学习算法,比如随机森林、词袋模型、贝叶斯 模型,显然还有神经网络。
深度学习的崛起
那么,神经网络是如何接管计算机视觉,并成为我们今天所称之为深度学习的呢?本节提供了一些答案,详细介绍了这一强大工具的技术发展。
早期的尝试和失败
可能会让人感到惊讶的是,人工神经网络的出现甚至早于现代计算机视觉的诞生。它们的发展是典型的“发明出现得太早”的故事。
感知机的兴起与衰落
在 50 年代,Frank Rosenblatt 提出了感知机,这是一种受神经元启发的机器学习算法,也是第一个神经网络的基础构成元素(《感知机:一种用于信息存储和大脑组织的概率模型》,美国心理学会,1958)。在适当的学习过程中,这种方法已经能够识别字符。然而,这种热潮是短暂的。Marvin Minsky(人工智能的奠基人之一)和 Seymor Papert 很快证明感知机无法学习像 XOR 这样的简单函数(XOR,异或函数,对于两个二进制输入值,当且仅当一个输入为 1 时,输出为 1,否则输出 0)。今天看来这很有道理——因为当时的感知机是基于线性函数建模的,而 XOR 是一个非线性函数——但在那个时候,这让研究进展停滞了多年。
过于庞大,难以扩展
直到 70 年代末到 80 年代初,神经网络才重新获得一些关注。几篇研究论文介绍了如何通过将多个感知器层堆叠在一起,使用一种相当简单的方案——反向传播,来训练神经网络。正如我们在下一部分中将详细介绍的,这一训练过程通过计算网络的误差并将其反向传播通过感知器层,从而使用导数更新它们的参数。很快,第一个卷积神经网络(CNN),即当前识别方法的先驱,被开发出来并成功应用于手写字符的识别。
可惜的是,这些方法计算量巨大,根本无法扩展到更大的问题上。因此,研究人员采用了更轻量的机器学习方法,如支持向量机(SVM),神经网络的应用又停滞了十年。那么,是什么使它们复兴,带来了我们今天所知的深度学习时代呢?
复兴的原因
这种复兴的原因是双重的,根源在于互联网的爆炸性发展和硬件效率的提升。
互联网——数据科学的新埃尔多拉多
互联网不仅仅是通信的革命;它也深刻地改变了数据科学。科学家们通过将图片和内容上传到网上,变得更加容易分享,进而催生了用于实验和基准测试的公共数据集。此外,不仅仅是研究人员,很快全世界的每个人都开始在线发布新内容,以指数级的速度分享图片、视频等。这开启了大数据和数据科学的黄金时代,互联网成为了新的埃尔多拉多。
通过简单地索引不断发布的内容,图像和视频数据集的规模达到了前所未有的大小,从Caltech-101(10,000 张图像,由 Li Fei-Fei 等人于 2003 年发布,Elsevier)到ImageNet(1400 万+张图像,由 Jia Deng 等人于 2009 年发布,IEEE)或Youtube-8M(800 万+视频,由 Sami Abu-El-Haija 等人于 2016 年发布,包括 Google)。甚至企业和政府也很快意识到收集和发布数据集在特定领域推动创新的诸多优势(例如,英国政府发布的视频监控数据集 i-LIDS,Facebook 和微软资助的图像标注数据集 COCO 等)。
随着大量数据的涌现,涵盖了各种使用场景,新的大门被打开了(数据饥渴算法,也就是需要大量训练样本才能收敛的方法终于可以成功应用),同时也提出了新的挑战(例如,如何高效处理这些信息)。
比以往更强大的力量
幸运的是,随着互联网的蓬勃发展,计算能力也在提升。硬件变得越来越便宜,同时也越来越快,似乎遵循了摩尔定律(该定律表明处理器速度每两年应翻倍——这一规律已经保持了近四十年,尽管现在观察到有所放缓)。随着计算机变得更快,它们也变得更适合计算机视觉。对此,我们要感谢视频游戏。
图形处理单元(GPU)是一种计算机组件,即专门设计用于处理运行 3D 游戏所需操作的芯片。因此,GPU 被优化用于生成或操作图像,能够并行化这些繁重的矩阵运算。尽管第一款 GPU 在 80 年代就已经构思出来,但它们直到新千年到来时才变得价格合理并流行开来。
2007 年,NVIDIA(主要设计 GPU 的公司之一)发布了CUDA的第一个版本,这是一种编程语言,允许开发者直接为兼容的 GPU 编程。类似的语言OpenCL随后也出现了。有了这些新工具,人们开始利用 GPU 的强大性能来处理新的任务,如机器学习和计算机视觉。
深度学习或人工神经网络的重新品牌化
终于,数据需求量大、计算密集型的算法有了展现的机会。随着大数据和云计算的兴起,深度学习突然无处不在。
什么让学习变得更深刻?
实际上,深度学习这个术语早在 80 年代就已经被提出,当时神经网络首次开始堆叠两层或三层神经元。与早期更简单的解决方案不同,深度学习重新组合了更深的神经网络,也就是拥有多个隐藏层的网络——这些额外的层设置在输入层和输出层之间。每一层处理它的输入,并将结果传递给下一层,所有层都经过训练以提取越来越抽象的信息。例如,神经网络的第一层将学习如何对图像中的基本特征作出反应,如边缘、线条或颜色渐变;下一层将学习如何利用这些线索提取更高级的特征;如此继续,直到最后一层,它推断出所需的输出(例如预测的类别或检测结果)。
然而,深度学习直到 2006 年才真正开始应用,那时 Geoff Hinton 和他的同事提出了一种有效的方法,可以一层一层地训练这些更深的模型,直到达到所需的深度(《深度信念网络的快速学习算法》,麻省理工学院出版社,2006 年)。
深度学习时代
随着神经网络研究重新回到正轨,深度学习开始快速发展,直到 2012 年发生了一次重大突破,最终让深度学习在当代声名鹊起。自从 ImageNet 发布以来,每年都会组织一次比赛(ImageNet 大规模视觉识别挑战赛(ILSVRC)—image-net.org/challenges/LSVRC),供研究人员提交他们最新的分类算法,并与其他参赛者在 ImageNet 上的表现进行对比。2010 年和 2011 年的获胜解决方案分别有 28%和 26%的分类错误率,并采用了传统的概念,如 SIFT 特征和 SVM。然后,2012 年版出现了,一个新的研究团队将识别错误率降至令人震惊的 16%,远远甩开了其他参赛者。
在描述这一成就的论文中(使用深度卷积神经网络进行 ImageNet 分类,NIPS,2012),Alex Krizhevsky、Ilya Sutskever 和 Geoff Hinton 提出了现代识别方法的基础。他们设计了一个 8 层神经网络,后来被命名为AlexNet,该网络包含几个卷积层以及其他现代组件,如dropout和修正线性激活单元(ReLUs),这些将在第三章中详细介绍,现代神经网络,因为它们已成为计算机视觉的核心。更重要的是,他们使用 CUDA 实现了这一方法,使得它可以在 GPU 上运行,从而终于使得在合理的时间内训练深度神经网络成为可能,并能够迭代处理像 ImageNet 这样庞大的数据集。
同年,Google 展示了如何将云计算的进步应用于计算机视觉。他们使用从 YouTube 视频中提取的 1000 万张随机图像的数据集,训练神经网络识别包含猫的图像,并将训练过程并行化到 16,000 台机器上,最终使得准确率相比以前的方法翻倍。
就这样,深度学习时代开始了,我们正身处其中。每个人都加入了这一领域,提出了越来越深的模型、更先进的训练方案和适用于便携设备的轻量化解决方案。这是一个激动人心的时期,随着深度学习解决方案的效率不断提高,越来越多的人尝试将其应用于新的应用和领域。通过本书,我们希望传达一些当前的热情,并为您提供现代方法的概览以及如何开发解决方案。
入门神经网络
到现在为止,我们知道神经网络构成了深度学习的核心,并且是现代计算机视觉的强大工具。但它们究竟是什么呢?它们是如何工作的?在接下来的部分,我们不仅会解决它们效率背后的理论解释,还将直接将这些知识应用于简单网络在识别任务中的实现和应用。
构建神经网络
人工神经网络(ANNs),或简称神经网络(NNs),是强大的机器学习工具,擅长处理信息、识别常见模式或检测新模式,并逼近复杂过程。它们之所以如此强大,归功于它们的结构,接下来我们将探讨这一点。
模拟神经元
众所周知,神经元是我们思维和反应的基本支撑。可能不那么显而易见的是它们是如何工作的,以及它们如何被模拟。
生物学启发
人工神经网络(ANNs)大致受动物大脑工作的启发。我们的脑袋是一个复杂的神经元网络,每个神经元相互传递信息并将感官输入(作为电信号和化学信号)转化为思维和行动。每个神经元通过其树突接收电输入,树突是细胞纤维,将电信号从突触(与前一个神经元的连接处)传播到胞体(神经元的主要部分)。如果积累的电刺激超过特定阈值,细胞会被激活,电冲动会通过细胞的轴突(神经元的输出电缆,末端有多个突触连接到其他神经元)向下一个神经元传播。因此,每个神经元都可以看作一个非常简单的信号处理单元,一旦堆叠在一起,就能实现我们现在正在进行的思维过程。
数学模型
受其生物学对照物(见图 1.11)的启发,人工神经元接收多个输入(每个都是一个数字),将它们加总在一起,最后应用激活函数以获得输出信号,输出信号可以传递给网络中的下一个神经元(这可以视为一个有向图):

图 1.11:左侧是一个简化的生物神经元,右侧是其人工对照物
输入的求和通常是加权进行的。每个输入会根据一个特定于此输入的权重进行放大或缩小。这些权重是网络训练阶段调整的参数,以便神经元对正确的特征做出反应。通常,还会训练并使用另一个参数来进行此求和过程——神经元的偏置。其值简单地添加到加权和中,作为偏移。
让我们迅速将这个过程数学化。假设我们有一个神经元,接受两个输入值,x[0] 和 x[1]。每个值都会分别乘以一个权重因子,w[0] 和 w[1],然后将它们加总在一起,再加上一个可选的偏置,b。为了简化,我们可以将输入值表示为水平向量,x,将权重表示为垂直向量,w:

使用这种公式,整个操作可以简单地表达如下:

这个步骤很简单,对吧?两个向量之间的点积处理了加权求和:

现在,输入已经被缩放并汇总为结果 z,我们必须对其应用 激活函数 以获得神经元的输出。如果我们回到生物神经元的类比,它的激活函数将是一个二值函数,例如如果 y 超过阈值 t,则返回 1 的电信号,否则返回 0(通常 t = 0)。如果我们将其形式化,激活函数 y = f(z*) 可以表示为:

阶跃函数是原始感知器的关键组成部分,但自那时以来,引入了更先进的激活函数,它们具有更多的优势特性,例如非线性(用于建模更复杂的行为)和连续可微性(对训练过程至关重要,我们稍后将解释)。最常见的激活函数如下:
-
Sigmoid 函数,
(其中 𝑒 为指数函数) -
双曲正切函数,
![]()
-
修正线性单元(ReLU),
![]()
上述常见激活函数的图示如下:

图 1.12:绘制常见激活函数
无论如何,就这样!我们已经建模了一个简单的人工神经元。它能够接收信号、处理它并输出一个可以转发(这是机器学习中常用的术语)到其他神经元的值,从而构建网络。
如果没有非线性激活函数,链式神经元将等同于只有一个神经元。例如,如果我们有一个带参数 w[A] 和 b[A] 的线性神经元,后接一个带参数 w[B] 和 b[B] 的线性神经元,那么
,其中 w = w[A]**^(
)] w[B],并且 b = b[A] + b[B*]。因此,如果我们想创建复杂的模型,非线性激活函数是必要的。
实现
这样的模型可以在 Python 中非常容易地实现(使用 NumPy 进行向量和矩阵操作):
import numpy as np
class Neuron(object):
"""A simple feed-forward artificial neuron.
Args:
num_inputs (int): The input vector size / number of input values.
activation_fn (callable): The activation function.
Attributes:
W (ndarray): The weight values for each input.
b (float): The bias value, added to the weighted sum.
activation_fn (callable): The activation function.
"""
def __init__(self, num_inputs, activation_fn):
super().__init__()
# Randomly initializing the weight vector and bias value:
self.W = np.random.rand(num_inputs)
self.b = np.random.rand(1)
self.activation_fn = activation_fn
def forward(self, x):
"""Forward the input signal through the neuron."""
z = np.dot(x, self.W) + self.b
return self.activation_function(z)
正如我们所看到的,这直接是我们之前定义的数学模型的适应。使用这个人工神经元同样简单。让我们实例化一个感知器(一个使用阶跃函数作为激活方法的神经元),并将一个随机输入通过它转发:
# Fixing the random number generator's seed, for reproducible results:
np.random.seed(42)
# Random input column array of 3 values (shape = `(1, 3)`)
x = np.random.rand(3).reshape(1, 3)
# > [[0.37454012 0.95071431 0.73199394]]
# Instantiating a Perceptron (simple neuron with step function):
step_fn = lambda y: 0 if y <= 0 else 1
perceptron = Neuron(num_inputs=x.size, activation_fn=step_fn)
# > perceptron.weights = [0.59865848 0.15601864 0.15599452]
# > perceptron.bias = [0.05808361]
out = perceptron.forward(x)
# > 1
我们建议在下一节中放大它们的维度之前,花些时间实验不同的输入和神经元参数。
将神经元层叠在一起
通常,神经网络被组织成层,也就是一组通常接收相同输入并应用相同操作的神经元(例如,应用相同的激活函数,尽管每个神经元首先会用自己的特定权重对输入进行加权求和)。
数学模型
在网络中,信息从输入层流向输出层,中间有一个或多个隐藏层。在图 1.13中,三个神经元A、B和C属于输入层,神经元H属于输出或激活层,神经元D、E、F和G属于隐藏层。第一层有一个大小为 2 的输入x,第二层(隐藏层)将前一层的三个激活值作为输入,依此类推。这样的层,每个神经元都与前一层的所有值相连,被称为全连接或密集层:

图 1.13:一个 3 层神经网络,包含两个输入值和一个最终输出
再次,我们可以通过用向量和矩阵表示这些元素来简化计算。以下操作由第一层完成:

这可以表达如下:

为了得到前面的方程,我们必须按如下方式定义变量:

因此,第一层的激活可以写成一个向量,
,该向量可以直接作为输入向量传递到下一层,依此类推,直到最后一层。
实现
像单个神经元一样,这个模型可以在 Python 中实现。实际上,我们甚至不需要对Neuron类做太多编辑:
import numpy as np
class FullyConnectedLayer(object):
"""A simple fully-connected NN layer.
Args:
num_inputs (int): The input vector size/number of input values.
layer_size (int): The output vector size/number of neurons.
activation_fn (callable): The activation function for this layer.
Attributes:
W (ndarray): The weight values for each input.
b (ndarray): The bias value, added to the weighted sum.
size (int): The layer size/number of neurons.
activation_fn (callable): The neurons' activation function.
"""
def __init__(self, num_inputs, layer_size, activation_fn):
super().__init__()
# Randomly initializing the parameters (using a normal distribution this time):
self.W = np.random.standard_normal((num_inputs, layer_size))
self.b = np.random.standard_normal(layer_size)
self.size = layer_size
self.activation_fn = activation_fn
def forward(self, x):
"""Forward the input signal through the layer."""
z = np.dot(x, self.W) + self.b
return self.activation_fn(z)
我们只需要改变一些变量的维度,以反映每层中神经元的多样性。通过这种实现,我们的层甚至可以一次处理多个输入!传递一个单列向量x(形状为 1 × s,其中x包含s个值)或一堆列向量(形状为n × s,其中n为样本数)对于我们的矩阵计算没有任何影响,我们的层将正确输出堆叠的结果(假设b被加到每一行):
np.random.seed(42)
# Random input column-vectors of 2 values (shape = `(1, 2)`):
x1 = np.random.uniform(-1, 1, 2).reshape(1, 2)
# > [[-0.25091976 0.90142861]]
x2 = np.random.uniform(-1, 1, 2).reshape(1, 2)
# > [[0.46398788 0.19731697]]
relu_fn = lambda y: np.maximum(y, 0) # Defining our activation function
layer = FullyConnectedLayer(2, 3, relu_fn)
# Our layer can process x1 and x2 separately...
out1 = layer.forward(x1)
# > [[0.28712364 0\. 0.33478571]]
out2 = layer.forward(x2)
# > [[0\. 0\. 1.08175419]]
# ... or together:
x12 = np.concatenate((x1, x2)) # stack of input vectors, of shape `(2, 2)`
out12 = layer.forward(x12)
# > [[0.28712364 0\. 0.33478571]
# [0\. 0\. 1.08175419]]
一堆输入数据通常被称为批次。
通过这个实现,现在只是将全连接层串联起来,构建简单的神经网络。
将我们的网络应用于分类
我们知道如何定义层,但尚未初始化并将它们连接成计算机视觉的网络。为了演示如何做到这一点,我们将处理一个著名的识别任务。
设置任务
对手写数字图像进行分类(即识别图像中是否包含0、1等数字)是计算机视觉中的一个历史性问题。修改版国家标准与技术研究院(MNIST)数据集(yann.lecun.com/exdb/mnist/),其中包含了 70,000 张手写数字的灰度图像(28 × 28 像素),多年来一直作为该识别任务的参考,以便人们可以测试他们的方法(此数据集的所有版权归 Yann LeCun 和 Corinna Cortes 所有,见下图):

图 1.14:MNIST 数据集中每个数字的十个样本
对于数字分类,我们需要的是一个网络,它接收这些图像中的一张作为输入,并返回一个输出向量,表示网络认为该图像对应于每个类别的程度。输入向量包含28 × 28 = 784个值,而输出向量包含 10 个值(对应 10 个不同的数字,从0到9)。在这些之间,定义隐藏层的数量和大小是我们的任务。为了预测图像的类别,只需将图像向量通过网络前馈,收集输出,然后返回得分最高的类别。
这些信念分数通常会转换为概率,以简化进一步的计算或解释。例如,假设一个分类网络对狗类给出了 9 的分数,对其他类猫给出了 1 的分数。这相当于说,根据这个网络,图像显示狗的概率为 9/10,显示猫的概率为 1/10。
在我们实现解决方案之前,先通过加载 MNIST 数据来准备训练和测试方法。为了简便起见,我们将使用mnist Python 模块(github.com/datapythonista/mnist),该模块由 Marc Garcia 开发(采用 BSD 3-Clause New或Revised许可,并且已安装在本章的源代码目录中):
import numpy as np
import mnist
np.random.seed(42)
# Loading the training and testing data:
X_train, y_train = mnist.train_images(), mnist.train_labels()
X_test, y_test = mnist.test_images(), mnist.test_labels()
num_classes = 10 # classes are the digits from 0 to 9
# We transform the images into column vectors (as inputs for our NN):
X_train, X_test = X_train.reshape(-1, 28*28), X_test.reshape(-1, 28*28)
# We "one-hot" the labels (as targets for our NN), for instance, transform label `4` into vector `[0, 0, 0, 0, 1, 0, 0, 0, 0, 0]`:
y_train = np.eye(num_classes)[y_train]
有关数据集预处理和可视化的更详细操作,请参见本章的源代码。
实现网络
对于神经网络本身,我们需要将各个层组合在一起,并添加一些方法,供网络前向传播和根据输出向量预测类别使用。层的实现完成后,以下代码应该不言自明:
import numpy as np
from layer import FullyConnectedLayer
def sigmoid(x): # Apply the sigmoid function to the elements of x.
return 1 / (1 + np.exp(-x)) # y
class SimpleNetwork(object):
"""A simple fully-connected NN.
Args:
num_inputs (int): The input vector size / number of input values.
num_outputs (int): The output vector size.
hidden_layers_sizes (list): A list of sizes for each hidden layer to be added to the network
Attributes:
layers (list): The list of layers forming this simple network.
"""
def __init__(self, num_inputs, num_outputs, hidden_layers_sizes=(64, 32)):
super().__init__()
# We build the list of layers composing the network:
sizes = [num_inputs, *hidden_layers_sizes, num_outputs]
self.layers = [
FullyConnectedLayer(sizes[i], sizes[i + 1], sigmoid)
for i in range(len(sizes) - 1)]
def forward(self, x):
"""Forward the input vector `x` through the layers."""
for layer in self.layers: # from the input layer to the output one
x = layer.forward(x)
return x
def predict(self, x):
"""Compute the output corresponding to `x`, and return the index of the largest output value"""
estimations = self.forward(x)
best_class = np.argmax(estimations)
return best_class
def evaluate_accuracy(self, X_val, y_val):
"""Evaluate the network's accuracy on a validation dataset."""
num_corrects = 0
for i in range(len(X_val)):
if self.predict(X_val[i]) == y_val[i]:
num_corrects += 1
return num_corrects / len(X_val)
我们刚刚实现了一个前馈神经网络,可以用于分类!现在是时候将其应用到我们的任务中了:
# Network for MNIST images, with 2 hidden layers of size 64 and 32:
mnist_classifier = SimpleNetwork(X_train.shape[1], num_classes, [64, 32])
# ... and we evaluate its accuracy on the MNIST test set:
accuracy = mnist_classifier.evaluate_accuracy(X_test, y_test)
print("accuracy = {:.2f}%".format(accuracy * 100))
# > accuracy = 12.06%
我们的准确率只有~12.06%。这可能看起来令人失望,因为这个准确率几乎与随机猜测相当。但这也是可以理解的——目前我们的网络是由随机参数定义的。我们需要根据我们的使用案例来训练它,这一任务将在下一节中处理。
训练神经网络
神经网络是一种特殊的算法,因为它们需要进行训练,也就是说,它们的参数需要通过从可用数据中学习来优化,以完成特定任务。一旦网络优化到能够在训练数据集上表现良好,它们就可以在新的、相似的数据上使用,以提供令人满意的结果(前提是训练得当)。
在解决我们的 MNIST 任务之前,我们将提供一些理论背景,涵盖不同的学习策略,并展示训练是如何实际进行的。然后,我们将直接将其中的一些概念应用到我们的例子中,以便我们的简单网络最终学会如何解决识别任务!
学习策略
在教授神经网络时,根据任务和训练数据的可用性,有三种主要的学习范式。
有监督学习
有监督学习 可能是最常见的学习范式,它也确实是最容易理解的。当我们想要教神经网络进行两种模态之间的映射时(例如,将图像映射到它们的类别标签或语义掩膜),就会应用这种方法。它需要访问一个包含图像和它们的真实标签(如每张图像的类别信息或语义掩膜)的训练数据集。
基于此,训练过程变得直接明了:
-
将图像输入网络并收集其结果(即预测标签)。
-
评估网络的损失,即在将网络预测与真实标签对比时,预测的错误程度。
-
相应地调整网络参数,以减少损失。
-
重复直到网络收敛,即直到它在这个训练数据上无法再进一步改善。
因此,这种策略值得称为有监督的——一个实体(我们)通过为每个预测提供反馈(从真实标签计算出的损失)来监督网络的训练,这样方法可以通过反复练习来学习(正确/错误;再试一次)。
无监督学习
然而,当我们没有任何真实标签信息时,如何训练一个网络呢?无监督学习是一个答案。这里的想法是构建一个函数,仅基于输入和对应输出来计算网络的损失。
这种策略非常适用于聚类(将具有相似属性的图像分组)或压缩(在保持某些属性的同时减少内容大小)等应用。对于聚类,损失函数可以衡量一个簇中的图像与其他簇中的图像的相似度。对于压缩,损失函数可以衡量压缩数据中重要属性与原始数据中重要属性的保持情况。
无监督学习因此需要一些专业知识,以便我们能够提出有意义的损失函数。
强化学习
强化学习是一种互动策略。一个智能体在环境中导航(例如,一个机器人在房间内移动,或者一个视频游戏角色通过关卡)。智能体有一组预定义的动作列表(走、转弯、跳跃等),每次动作后,它都会进入一个新的状态。一些状态可能会带来奖励,这些奖励可以是即时的或延迟的,也可以是正向的或负向的(例如,视频游戏角色触碰到一个奖励物品时会得到正向奖励,或被敌人击中时会受到负向奖励)。
在每个时刻,神经网络只会接收到来自环境的观察(例如,机器人的视觉信息或视频游戏屏幕)和奖励反馈(胡萝卜与大棒)。根据这些,它需要学习什么能带来更高的奖励,并相应地估计智能体的最佳短期或长期策略。换句话说,它需要估计一系列动作,这些动作将最大化最终的奖励。
强化学习是一种强大的范式,但它在计算机视觉领域的应用较少。在这里我们将不再进一步介绍,但我们鼓励机器学习爱好者深入了解。
教学时间
无论采用什么学习策略,整体的训练步骤都是相同的。给定一些训练数据,网络会做出预测并获得反馈(例如损失函数的结果),然后用这些反馈来更新网络的参数。这些步骤会不断重复,直到网络无法进一步优化为止。在本节中,我们将详细说明并实现这一过程,从损失计算到权重优化。
评估损失
损失函数的目标是评估网络在当前权重下的表现。更正式地说,这个函数表示预测质量与网络参数(如权重和偏置)之间的关系。损失越小,参数就越适合所选任务。
由于损失函数代表了网络的目标(返回正确的标签,在保持内容的同时压缩图像,等等),所以任务越多,损失函数的种类就越多。然而,有些损失函数比其他的更常用。这就是平方和函数,也叫L2 损失(基于 L2 范数),它在监督学习中无处不在。这个函数简单地计算输出向量y(我们的网络估计的每类概率)与真实值向量y^(true)(每个类别值为零,除了正确的类别)的每个元素之间的平方差:

还有许多其他具有不同特性的损失函数,例如L1 损失,它计算向量之间的绝对差,或者二元交叉熵(BCE)损失,它在将预测的概率与期望值比较之前,会将其转换为对数尺度:

对数运算将概率从[0, 1]转换为-![,0]。因此,通过将结果乘以-1,损失值从+
移动到 0,因为神经网络学习如何正确预测。请注意,交叉熵函数也可以应用于多类别问题(不仅仅是二元问题)。
人们通常会将损失除以向量中的元素数量,也就是说,计算均值而不是总和。均方误差(MSE)是 L2 损失的平均版本,而均绝对误差(MAE)是 L1 损失的平均版本。
目前,我们将以 L2 损失为例。我们将使用它进行后续的理论解释,并用它来训练我们的 MNIST 分类器。
反向传播损失
我们如何更新网络参数以使它们最小化损失呢?对于每个参数,我们需要知道稍微改变它的值会如何影响损失。如果我们知道哪些改变会稍微减少损失,那么只需要应用这些改变,并重复这一过程直到达到最小值。这正是损失函数的梯度所表达的内容,也是梯度下降过程的本质。
在每次训练迭代中,都会计算损失对网络每个参数的导数。这些导数表示需要对参数进行哪些小的调整(由于梯度表示的是函数增加的方向,因此需要乘以-1,因为我们希望最小化损失)。可以将其视为沿着损失函数相对于每个参数的slope逐步下降,因此这个迭代过程被称为梯度下降(参见下图):

图 1.15:说明梯度下降如何优化神经网络的参数P
现在的问题是,我们如何计算所有这些导数(作为每个参数的斜率值)?这时,链式法则为我们提供了帮助。链式法则告诉我们,关于某一层的参数(k)的导数可以通过该层的输入和输出值(x[k],y[k])以及下一层(k + 1)的导数简单地计算出来。更正式地,对于该层的权重W[k],我们有以下公式:

在这里,l'[k+1]是计算出的关于层k + 1 相对于其输入* x[k+1] = y[k]的导数,其中f'[k]是该层激活函数的导数,
是x的转置。注意,z[k]表示层k执行的加权求和结果(即,在输入层激活函数之前的结果),如在层叠神经元部分中定义。最后,
符号表示两个向量/矩阵之间的逐元素相乘,也称为Hadamard 积*。如以下方程所示,它基本上是成对地相乘元素:

回到链式法则,关于偏置的导数可以通过类似的方式计算,如下所示:

最后,为了全面性,我们得到以下方程:

这些计算看起来可能很复杂,但我们只需要理解它们表示什么——我们可以递归地计算每个参数如何影响损失,逐层向后进行(使用某一层的导数来计算前一层的导数)。这个概念也可以通过将神经网络表示为计算图来说明,即将数学操作链接在一起的图(第一层的加权求和操作执行,然后其结果传递给第一个激活函数,接着它的输出传递给第二层的操作,以此类推)。因此,计算整个神经网络相对于某些输入的结果,实际上就是将数据通过这个计算图进行前向传递,而获取相对于每个参数的导数,实际上是通过反向传播结果损失来实现的,因此这个过程被称为反向传播。
为了从输出层开始这个过程,需要计算损失相对于输出值的导数(参见前面的公式)。因此,损失函数需要容易求导。例如,L2 损失的导数就是以下内容:

如前所述,一旦我们知道每个参数的损失导数,接下来的任务就是相应地更新它们:

正如我们所见,导数通常会乘以一个因子
(epsilon),然后用于更新参数。这个因子被称为学习率。它有助于控制每次迭代时每个参数的更新强度。较大的学习率可能让网络学习得更快,但也有可能步伐过大,导致网络 错过 损失的最小值。因此,学习率的值需要小心设置。现在,让我们总结一下完整的训练过程:
-
选择接下来的 n 张训练图像,并将它们输入到网络中。
-
计算并反向传播损失,使用链式法则计算相对于各层参数的导数。
-
使用相应导数的值(按学习率缩放)来更新参数。
-
重复步骤 1 到 3,遍历整个训练集。
-
重复步骤 1 到 4,直到收敛或达到固定的迭代次数。
对整个训练集进行一次迭代(步骤 1到4)被称为一个周期(epoch)。如果 n = 1 且训练样本是从剩余图像中随机选择的,那么这个过程被称为随机梯度下降(SGD),这种方法易于实现和可视化,但速度较慢(需要更多更新)且 更嘈杂。人们更倾向于使用 小批量随机梯度下降。这意味着使用较大的 n 值(受到计算机性能的限制),这样梯度会在每个 mini-batch(或更简单地称为 batch)的 n 个随机训练样本上平均,从而减少了噪声。
现在,术语 SGD 被广泛使用,不论 n 的值是多少。
本节中,我们已经讲解了神经网络的训练过程。现在是时候将其付诸实践了!
教会我们的网络进行分类
到目前为止,我们仅实现了网络及其层的前向传播功能。首先,让我们更新 FullyConnectedLayer 类,以便我们可以为反向传播和优化添加方法:
class FullyConnectedLayer(object):
# [...] (code unchanged)
def __init__(self, num_inputs, layer_size, activation_fn, d_activation_fn):
# [...] (code unchanged)
self.d_activation_fn = d_activation_fn # Deriv. activation function
self.x, self.y, self.dL_dW, self.dL_db = 0, 0, 0, 0 # Storage attr.
def forward(self, x):
z = np.dot(x, self.W) + self.b
self.y = self.activation_fn(z)
self.x = x # we store values for back-propagation
return self.y
def backward(self, dL_dy):
"""Back-propagate the loss."""
dy_dz = self.d_activation_fn(self.y) # = f'
dL_dz = (dL_dy * dy_dz) # dL/dz = dL/dy * dy/dz = l'_{k+1} * f'
dz_dw = self.x.T
dz_dx = self.W.T
dz_db = np.ones(dL_dy.shape[0]) # dz/db = "ones"-vector
# Computing and storing dL w.r.t. the layer's parameters:
self.dL_dW = np.dot(dz_dw, dL_dz)
self.dL_db = np.dot(dz_db, dL_dz)
# Computing the derivative w.r.t. x for the previous layers:
dL_dx = np.dot(dL_dz, dz_dx)
return dL_dx
def optimize(self, epsilon):
"""Optimize the layer's parameters w.r.t. the derivative values."""
self.W -= epsilon * self.dL_dW
self.b -= epsilon * self.dL_db
本节中展示的代码经过简化并删除了注释,以保持合理的长度。完整的源代码可以在本书的 GitHub 仓库中找到,并附有一个将所有内容连接起来的 Jupyter 笔记本。
现在,我们需要通过逐层反向传播和优化的方法,更新 SimpleNetwork 类,并添加一个最终的方法来涵盖整个训练过程:
def derivated_sigmoid(y): # sigmoid derivative function
return y * (1 - y)
def loss_L2(pred, target): # L2 loss function
return np.sum(np.square(pred - target)) / pred.shape[0] # opt. for results not depending on the batch size (pred.shape[0]), we divide the loss by it
def derivated_loss_L2(pred, target): # L2 derivative function
return 2 * (pred - target) # we could add the batch size division here too, but it wouldn't really affect the training (just scaling down the derivatives).
class SimpleNetwork(object):
# [...] (code unchanged)
def __init__(self, num_inputs, num_outputs, hidden_layers_sizes=(64, 32), loss_fn=loss_L2, d_loss_fn=derivated_loss_L2):
# [...] (code unchanged, except for FC layers new params.)
self.loss_fn, self.d_loss_fn = loss_fn, d_loss_fn
# [...] (code unchanged)
def backward(self, dL_dy):
"""Back-propagate the loss derivative from last to 1st layer."""
for layer in reversed(self.layers):
dL_dy = layer.backward(dL_dy)
return dL_dy
def optimize(self, epsilon):
"""Optimize the parameters according to the stored gradients."""
for layer in self.layers:
layer.optimize(epsilon)
def train(self, X_train, y_train, X_val, y_val, batch_size=32, num_epochs=5, learning_rate=5e-3):
"""Train (and evaluate) the network on the provided dataset."""
num_batches_per_epoch = len(X_train) // batch_size
loss, accuracy = [], []
for i in range(num_epochs): # for each training epoch
epoch_loss = 0
for b in range(num_batches_per_epoch): # for each batch
# Get batch:
b_idx = b * batch_size
b_idx_e = b_idx + batch_size
x, y_true = X_train[b_idx:b_idx_e], y_train[b_idx:b_idx_e]
# Optimize on batch:
y = self.forward(x) # forward pass
epoch_loss += self.loss_fn(y, y_true) # loss
dL_dy = self.d_loss_fn(y, y_true) # loss derivation
self.backward(dL_dy) # back-propagation pass
self.optimize(learning_rate) # optimization
loss.append(epoch_loss / num_batches_per_epoch)
# After each epoch, we "validate" our network, i.e., we measure its accuracy over the test/validation set:
accuracy.append(self.evaluate_accuracy(X_val, y_val))
print("Epoch {:4d}: training loss = {:.6f} | val accuracy = {:.2f}%".format(i, loss[i], accuracy[i] * 100))
一切都准备好了!我们可以训练我们的模型,看看它的表现:
losses, accuracies = mnist_classifier.train(
X_train, y_train, X_test, y_test, batch_size=30, num_epochs=500)
# > Epoch 0: training loss = 1.096978 | val accuracy = 19.10%
# > Epoch 1: training loss = 0.886127 | val accuracy = 32.17%
# > Epoch 2: training loss = 0.785361 | val accuracy = 44.06%
# [...]
# > Epoch 498: training loss = 0.046022 | val accuracy = 94.83%
# > Epoch 499: training loss = 0.045963 | val accuracy = 94.83%
恭喜!如果你的机器足够强大,可以完成这个训练(这个简单的实现并未利用 GPU),我们刚刚获得了我们自己的神经网络,它能够以大约 94.8%的准确率对手写数字进行分类!
训练考虑因素 – 欠拟合与过拟合
我们邀请你尝试我们刚刚实现的框架,尝试不同的超参数(层大小、学习率、批量大小等)。选择合适的拓扑结构(以及其他超参数)可能需要大量的调整和测试。虽然输入和输出层的大小由使用案例决定(例如,对于分类任务,输入大小是图像中的像素值数量,输出大小是要预测的类别数量),但隐藏层应谨慎设计。
例如,如果网络层数过少,或层太小,准确率可能会停滞不前。这意味着网络欠拟合,即它没有足够的参数来应对任务的复杂性。在这种情况下,唯一的解决方案是采用一种更适合应用的新架构。
另一方面,如果网络过于复杂和/或训练数据集过小,网络可能会开始过拟合训练数据。这意味着网络会很好地拟合训练分布(即它的特定噪声、细节等),但无法对新样本进行泛化(因为这些新图像可能会有稍微不同的噪声,例如)。下面的图表突出了这两种问题之间的区别。最左边的回归方法没有足够的参数来建模数据的变化,而最右边的方法参数过多,这意味着它将难以泛化:

图 1.16:过拟合和欠拟合的常见示意图
虽然收集更大、更具多样性的训练数据集似乎是解决过拟合的逻辑方案,但在实践中并不总是可行的(例如,由于访问目标对象的限制)。另一种解决方案是调整网络或其训练,以约束网络学习的细节量。这些方法将在第三章《现代神经网络》中详细介绍,以及其他高级神经网络解决方案。
总结
在第一章中,我们覆盖了很多内容。我们介绍了计算机视觉,相关的挑战,以及一些历史方法,如 SIFT 和 SVM。我们熟悉了神经网络,并了解了它们是如何构建、训练和应用的。在从零开始实现我们自己的分类器网络后,我们现在可以更好地理解和欣赏机器学习框架的工作原理。
有了这些知识,我们现在已经完全准备好在下一章开始使用 TensorFlow 了。
问题
-
以下哪个任务不属于计算机视觉范畴?
-
根据查询搜索相似的图像
-
从图像序列中重建 3D 场景
-
视频角色的动画
-
-
原始感知机使用了哪种激活函数?
-
假设我们想训练一种方法来检测手写数字是否为 4。我们应该如何调整我们在本章中实现的网络来完成这个任务?
进一步阅读
-
Python 图像处理实践(
www.packtpub.com/big-data-and-business-intelligence/hands-image-processing-python),作者:Sandipan Dey:一本很好的书,帮助你深入了解图像处理本身,以及如何使用 Python 来处理视觉数据 -
使用 Python 进行 OpenCV 3.x 实践 – 第二版(
www.packtpub.com/application-development/opencv-3x-python-example-second-edition),作者:Gabriel Garrido 和 Prateek Joshi:另一本最近出版的书,介绍了著名的计算机视觉库OpenCV,这个库已经存在多年(它实现了我们在本章中介绍的一些传统方法,如边缘检测器、SIFT 和 SVM)
第二章:TensorFlow 基础与模型训练
TensorFlow是一个用于数值处理的库,供研究人员和机器学习从业者使用。虽然你可以使用 TensorFlow 执行任何数值运算,但它主要用于训练和运行深度神经网络。本章将介绍 TensorFlow 2 的核心概念,并带你通过一个简单的示例。
本章将涵盖以下主题:
-
使用 TensorFlow 2 和 Keras 入门
-
创建并训练一个简单的计算机视觉模型
-
TensorFlow 和 Keras 核心概念
-
TensorFlow 生态系统
技术要求
本书中将使用 TensorFlow 2。你可以在www.tensorflow.org/install上找到适用于不同平台的详细安装说明。
如果你计划使用机器的 GPU,请确保安装相应的版本,tensorflow-gpu。它必须与 CUDA 工具包一起安装,CUDA 工具包是 NVIDIA 提供的一个库(developer.nvidia.com/cuda-zone)。
安装说明也可以在 GitHub 的 README 中找到,链接为github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2/tree/master/Chapter02。
使用 TensorFlow 2 和 Keras 入门
在详细介绍 TensorFlow 的核心概念之前,我们将简要介绍该框架,并提供一个基本示例。
介绍 TensorFlow
TensorFlow 最初由 Google 开发,用于让研究人员和开发人员进行机器学习研究。它最初被定义为表达机器学习算法的接口,以及执行这些算法的实现。
TensorFlow 主要用于简化在各种平台上部署机器学习解决方案——计算机 CPU、计算机 GPU、移动设备,以及最近的浏览器平台。此外,TensorFlow 还提供了许多有用的功能,用于创建机器学习模型并进行大规模运行。2019 年,TensorFlow 2 发布,重点是易用性,同时保持良好的性能。
关于 TensorFlow 1.0 概念的介绍可以在本书的附录中的《从 TensorFlow 1 迁移到 TensorFlow 2》找到。
该库于 2015 年 11 月开源。从那时起,它得到了不断的改进,并被全球用户广泛使用。它被认为是研究中最受欢迎的平台之一。它也是 GitHub 活动中最活跃的深度学习框架之一。
TensorFlow 既适合初学者,也适合专家使用。TensorFlow API 具有不同的复杂度级别,允许新手从简单的 API 开始,而专家则可以同时创建非常复杂的模型。让我们来探索这些不同的级别。
TensorFlow 的主要架构
TensorFlow 的架构具有多个抽象层次。我们首先介绍最底层,然后逐层走向最顶层:

图 2.1:TensorFlow 架构图
大多数深度学习计算是用 C++编写的。为了在 GPU 上运行操作,TensorFlow 使用了 NVIDIA 开发的一个库,叫做CUDA。这也是你需要安装 CUDA,如果希望利用 GPU 功能的原因,同时也解释了为何不能使用来自其他硬件制造商的 GPU。
Python低级 API然后对 C++源代码进行了封装。当你在 TensorFlow 中调用 Python 方法时,通常会在后台调用 C++代码。这个封装层让用户能够更快地工作,因为 Python 被认为比 C++更容易使用且不需要编译。这个 Python 封装使得执行一些非常基础的操作(如矩阵乘法和加法)成为可能。
在最顶层是高级 API,由两个组件组成——Keras 和 Estimator API。Keras是一个用户友好、模块化、可扩展的 TensorFlow 封装器。我们将在下一节介绍它。Estimator API包含若干预先构建的组件,允许你轻松构建机器学习模型。你可以将它们视为构建块或模板。
在深度学习中,模型通常指的是一个在数据上训练过的神经网络。一个模型由架构、矩阵权重和参数组成。
介绍 Keras
Keras 于 2015 年首次发布,旨在作为一个接口,便于快速进行神经网络实验。因此,它依赖 TensorFlow 或Theano(另一种已废弃的深度学习框架)来执行深度学习操作。Keras 以其用户友好性著称,是初学者的首选库。
自 2017 年起,TensorFlow 已完全集成 Keras,这意味着你只需安装 TensorFlow 即可使用它,无需额外安装其他内容。在本书中,我们将依赖tf.keras而不是 Keras 的独立版本。两者之间有一些小的差异,比如与 TensorFlow 其他模块的兼容性以及模型保存方式。因此,读者必须确保使用正确的版本,具体如下:
-
在你的代码中,导入
tf.keras而不是keras。 -
阅读 TensorFlow 网站上的
tf.keras文档,而不是keras.io文档。 -
使用外部 Keras 库时,确保它们与
tf.keras兼容。 -
一些保存的模型可能不兼容不同版本的 Keras。
这两个版本将会在可预见的未来继续共存,并且tf.keras将会越来越多地与 TensorFlow 集成。为了展示 Keras 的强大和简洁,我们现在将用它实现一个简单的神经网络。
使用 Keras 构建的简单计算机视觉模型
在我们深入探讨 TensorFlow 的核心概念之前,让我们从一个经典的计算机视觉例子——数字识别,使用美国国家标准与技术研究所(MNIST)数据集开始。该数据集在第一章中介绍,计算机视觉与神经网络。
准备数据
首先,我们导入数据。数据由 60,000 张图像组成作为训练集,10,000 张图像作为测试集:
import tensorflow as tf
num_classes = 10
img_rows, img_cols = 28, 28
num_channels = 1
input_shape = (img_rows, img_cols, num_channels)
(x_train, y_train),(x_test, y_test) = tf.keras.datasets.mnist.load_data()
x_train, x_test = x_train / 255.0, x_test / 255.0
在 TensorFlow 中,通常使用别名tf进行导入,以便更快速地读取和输入。通常也会用x表示输入数据,用y表示标签。
tf.keras.datasets模块提供了快速访问、下载并实例化多个经典数据集的方法。在使用load_data导入数据后,注意我们将数组除以255.0,以将数据范围缩小到[0, 1]而非[0, 255]。通常我们会对数据进行归一化处理,范围可以是[0, 1]或[-1, 1]。
构建模型
现在我们可以开始构建实际的模型了。我们将使用一个非常简单的架构,由两个全连接(也叫密集)层组成。在我们探索架构之前,先来看看代码。正如您所看到的,Keras 代码非常简洁:
model = tf.keras.models.Sequential()
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(128, activation='relu'))
model.add(tf.keras.layers.Dense(num_classes, activation='softmax'))
由于我们的模型是一个线性堆叠的层次结构,我们从调用Sequential函数开始。然后逐个添加每一层。我们的模型由两个全连接层组成,我们逐层构建它:
-
Flatten:这个操作将代表图像像素的 2D 矩阵转换为 1D 数组。我们需要在添加全连接层之前进行此操作。28 × 28 的图像被转换为一个大小为 784 的向量。
-
Dense大小为128:这个层将784个像素值转化为 128 个激活值,使用一个大小为128 × 784的权重矩阵和一个大小为128的偏置矩阵。总的来说,这意味着100,480个参数。
-
Dense大小为10:这个层将128个激活值转化为我们的最终预测。请注意,由于我们希望概率的总和为1,我们将使用
softmax激活函数。
softmax函数接收一个层的输出,并返回使其总和为1的概率。这是分类模型最后一层的首选激活函数。
请注意,您可以通过model.summary()获取模型的描述、输出及其权重。以下是输出:
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
flatten_1 (Flatten) (None, 784) 0
_________________________________________________________________
dense_1 (Dense) (None, 128) 100480
_________________________________________________________________
dense_2 (Dense) (None, 10) 1290
=================================================================
Total params: 101,770
Trainable params: 101,770
Non-trainable params: 0
现在,模型已设置架构并初始化权重,可以开始训练以完成选择的任务。
训练模型
Keras 使得训练变得异常简单:
model.compile(optimizer='sgd',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
model.fit(x_train, y_train, epochs=5, verbose=1, validation_data=(x_test, y_test))
调用我们刚创建的模型的.compile()是一个必须的步骤。必须指定几个参数:
-
optimizer:这是执行梯度下降的组件。 -
loss:这是我们要优化的度量。在我们的例子中,我们选择交叉熵,就像在上一章中一样。 -
metrics:这些是训练过程中评估的附加指标函数,用于进一步展示模型的性能(与loss不同,它们不用于优化过程)。
Keras 中名为loss的sparse_categorical_crossentropy执行与categorical_crossentropy相同的交叉熵操作,但前者直接将真实标签作为输入,而后者要求真实标签事先进行one-hot编码。因此,使用sparse_...损失函数可以避免我们手动转换标签。
将 'sgd' 传递给 Keras 相当于传递 tf.keras.optimizers.SGD()。前者更易读,而后者则可以指定诸如自定义学习率等参数。损失函数、指标以及大多数传递给 Keras 方法的参数也是如此。
然后,我们调用 .fit() 方法。它与 scikit-learn 这一流行的机器学习库使用的接口非常相似。我们将训练五个周期,这意味着我们会在整个训练数据集上迭代五次。
注意,我们将verbose设置为1。这将允许我们看到包含先前选择的指标、损失值以及预计到达时间(ETA)的进度条。ETA 是对当前周期结束前剩余时间的估算。以下是进度条的显示效果:

图 2.2:Keras 在 verbose 模式下显示的进度条截图
模型性能
如第一章《计算机视觉与神经网络》所述,你会注意到我们的模型发生了过拟合——训练准确率高于测试准确率。如果我们训练模型五个周期,测试集上的准确率会达到 97%。这比上一章的 95%提高了大约 2%。最先进的算法可以达到 99.79%的准确率。
我们遵循了三个主要步骤:
-
加载数据:在这种情况下,数据集已经准备好了。在未来的项目中,可能需要额外的步骤来收集和清理数据。
-
创建模型:通过使用 Keras,这一步变得非常简单——我们通过添加顺序层来定义模型的架构。然后,我们选择了损失函数、优化器和监控指标。
-
训练模型:我们的模型第一次运行得相当好。在更复杂的数据集上,通常需要在训练过程中对参数进行微调。
由于有了 Keras 这个 TensorFlow 的高级 API,整个过程变得非常简单。在这个简单的 API 后面,库隐藏了很多复杂性。
TensorFlow 2 和 Keras 详解
我们已经介绍了 TensorFlow 的总体架构,并使用 Keras 训练了我们的第一个模型。现在,让我们逐步介绍 TensorFlow 2 的主要概念。我们将解释本书中涉及的 TensorFlow 核心概念,并随后介绍一些高级概念。虽然在本书的后续部分我们可能不会使用所有这些概念,但理解它们可能对你了解一些在 GitHub 上可用的开源模型或深入理解该库有所帮助。
核心概念
新版本的框架于 2019 年春季发布,重点是简化和易用性。在这一部分中,我们将介绍 TensorFlow 依赖的概念,并讲解它们从版本 1 到版本 2 的演变。
介绍张量
TensorFlow 得名于一种数学对象 tensor。你可以将张量想象为 N 维数组。一个张量可以是标量、向量、3D 矩阵或 N 维矩阵。
TensorFlow 的一个基础组件,Tensor 对象用于存储数学值。它可以包含固定值(使用 tf.constant 创建)或变化值(使用 tf.Variable 创建)。
在本书中,tensor 指代数学概念,而 Tensor(大写 T)则对应 TensorFlow 对象。
每个 Tensor 对象具有以下内容:
-
类型:
string、float32、float16或int8等。 -
形状:数据的维度。例如,标量的形状为
(),大小为 n 的向量的形状为(n),大小为 n × m 的 2D 矩阵的形状为(n, m)。 -
秩:维度的数量,0 表示标量,
1表示向量,2 表示 2D 矩阵。
一些张量可能具有部分未知的形状。例如,一个接受可变大小图像的模型,其输入形状可能是 (None, None, 3)。由于图像的高度和宽度事先未知,因此前两个维度被设置为 None。然而,通道数(3,对应红色、蓝色和绿色)是已知的,因此被设置为固定值。
TensorFlow 图
TensorFlow 使用张量作为输入和输出。将输入转化为输出的组件称为 操作。因此,一个计算机视觉模型是由多个操作组成的。
TensorFlow 使用 有向无环图(DAC)表示这些操作,也称为 图。在 TensorFlow 2 中,图操作已被隐藏,以便使框架更易于使用。然而,图的概念仍然是理解 TensorFlow 工作原理的重要部分。
在使用 Keras 构建之前的示例时,TensorFlow 实际上构建了一个图:

图 2.3:对应我们模型的简化图。在实践中,每个节点由更小的操作(如矩阵乘法和加法)组成。
虽然非常简单,这个图以操作的形式表示了我们模型的不同层。依赖图有许多优势,使 TensorFlow 能够执行以下操作:
-
在 CPU 上运行一部分操作,在 GPU 上运行另一部分操作
-
在分布式模型的情况下,在不同的机器上运行图的不同部分
-
优化图以避免不必要的操作,从而提高计算性能
此外,图的概念使得 TensorFlow 模型具有可移植性。一个图的定义可以在任何设备上运行。
在 TensorFlow 2 中,图的创建不再由用户处理。虽然在 TensorFlow 1 中管理图是一个复杂的任务,但新版本极大地提高了可用性,同时仍保持性能。在下一部分,我们将窥探 TensorFlow 的内部工作原理,简要探索图的创建过程。
比较延迟执行和急切执行
TensorFlow 2 的主要变化是急切执行。历史上,TensorFlow 1 默认总是使用延迟执行。它被称为延迟,因为在框架没有被明确要求之前,操作不会被执行。
让我们从一个非常简单的示例开始,来说明延迟执行和急切执行的区别,求和两个向量的值:
import tensorflow as tf
a = tf.constant([1, 2, 3])
b = tf.constant([0, 0, 1])
c = tf.add(a, b)
print(c)
请注意,tf.add(a, b)可以被a + b替代,因为 TensorFlow 重载了许多 Python 运算符。
上述代码的输出取决于 TensorFlow 的版本。在 TensorFlow 1 中(默认模式为延迟执行),输出将是这样的:
Tensor("Add:0", shape=(3,), dtype=int32)
然而,在 TensorFlow 2 中(急切执行是默认模式),你将获得以下输出:
tf.Tensor([1 2 4], shape=(3,), dtype=int32)
在两种情况下,输出都是一个 Tensor。在第二种情况下,操作已经被急切执行,我们可以直接观察到 Tensor 包含了结果([1 2 4])。而在第一种情况下,Tensor 包含了关于加法操作的信息(Add:0),但没有操作的结果。
在急切模式下,你可以通过调用.numpy()方法来获取 Tensor 的值。在我们的示例中,调用c.numpy()会返回[1 2 4](作为 NumPy 数组)。
在 TensorFlow 1 中,计算结果需要更多的代码,这使得开发过程更加复杂。急切执行使得代码更易于调试(因为开发者可以随时查看 Tensor 的值)并且更易于开发。在下一部分,我们将详细介绍 TensorFlow 的内部工作原理,并研究它是如何构建图的。
在 TensorFlow 2 中创建图
我们将从一个简单的示例开始,来说明图的创建和优化:
def compute(a, b, c):
d = a * b + c
e = a * b * c
return d, e
假设a、b和c是 Tensor 矩阵,这段代码计算两个新值:d和e。使用急切执行,TensorFlow 将先计算d的值,然后计算e的值。
使用延迟执行,TensorFlow 会创建一个操作图。在运行图以获得结果之前,会运行一个图形优化器。为了避免重复计算 a * b,优化器会缓存结果,并在需要时重用它。对于更复杂的操作,优化器还可以启用并行性,以加快计算速度。这两种技术在运行大型和复杂模型时非常重要。
正如我们所看到的,运行在 eager 模式下意味着每个操作在定义时都会执行。因此,无法应用这样的优化。幸运的是,TensorFlow 包括一个模块来绕过这一点——TensorFlow AutoGraph。
介绍 TensorFlow AutoGraph 和 tf.function
TensorFlow AutoGraph 模块使得将 eager 代码转换为图形变得简单,从而实现自动优化。为了做到这一点,最简单的方法是在函数上方添加 tf.function 装饰器:
@tf.function
def compute(a, b, c):
d = a * b + c
e = a * b * c
return d, e
Python 装饰器是一种概念,它允许函数被包装,添加功能或修改其行为。装饰器以@符号("at"符号)开始。
当我们第一次调用 compute 函数时,TensorFlow 会透明地创建以下图形:

图 2.4:第一次调用 compute 函数时 TensorFlow 自动生成的图形
TensorFlow AutoGraph 可以转换大多数 Python 语句,如 for 循环、while 循环、if 语句和迭代。由于图形优化,图形执行有时可能比 eager 代码更快。更一般而言,AutoGraph 应该在以下场景中使用:
-
当模型需要导出到其他设备时
-
当性能至关重要且图形优化能够带来速度提升时
图形的另一个优势是其自动求导。知道所有操作的完整列表后,TensorFlow 可以轻松地计算每个变量的梯度。
请注意,为了计算梯度,操作必须是可微分的。某些操作,如 tf.math.argmax,则不是。在 loss 函数中使用它们可能会导致自动求导失败。用户需要确保损失函数是可微分的。
然而,由于在 eager 模式下,每个操作都是相互独立的,因此默认情况下无法进行自动求导。幸运的是,TensorFlow 2 提供了一种方法,在仍使用 eager 模式的情况下执行自动求导——梯度带。
使用梯度带回传误差
梯度带允许在 eager 模式下轻松进行反向传播。为了说明这一点,我们将使用一个简单的示例。假设我们想解方程 A × X = B,其中 A 和 B 是常数。我们想找到 X 的值以解方程。为此,我们将尝试最小化一个简单的损失函数,abs(A × X - B)。
在代码中,这转换为以下内容:
A, B = tf.constant(3.0), tf.constant(6.0)
X = tf.Variable(20.0) # In practice, we would start with a random value
loss = tf.math.abs(A * X - B)
现在,为了更新X的值,我们希望计算损失函数关于X的梯度。然而,当打印损失的内容时,我们得到如下结果:
<tf.Tensor: id=18525, shape=(), dtype=float32, numpy=54.0>
在 eager 模式下,TensorFlow 计算了操作的结果,而不是存储操作!没有操作及其输入的信息,将无法自动微分loss操作。
这时,梯度带就派上用场了。通过在tf.GradientTape上下文中运行我们的损失计算,TensorFlow 将自动记录所有操作,并允许我们在之后回放它们:
def train_step():
with tf.GradientTape() as tape:
loss = tf.math.abs(A * X - B)
dX = tape.gradient(loss, X)
print('X = {:.2f}, dX = {:2f}'.format(X.numpy(), dX))
X.assign(X - dX)
for i in range(7):
train_step()
上述代码定义了一个训练步骤。每次调用train_step时,损失都会在梯度带的上下文中计算。然后,使用该上下文来计算梯度。X变量随后会被更新。事实上,我们可以看到X逐渐逼近解决方程的值:
X = 20.00, dX = 3.000000
X = 17.00, dX = 3.000000
X = 14.00, dX = 3.000000
X = 11.00, dX = 3.000000
X = 8.00, dX = 3.000000
X = 5.00, dX = 3.000000
X = 2.00, dX = 0.000000
你会注意到,在本章的第一个示例中,我们并没有使用梯度带。这是因为 Keras 模型将训练封装在.fit()函数中——不需要手动更新变量。然而,对于创新性模型或实验时,梯度带是一个强大的工具,它允许我们在几乎不费力的情况下进行自动微分。读者可以在第三章的正则化笔记本中找到梯度带的更实际应用,现代神经网络。
Keras 模型和层
在本章的第一部分,我们构建了一个简单的 Keras Sequential 模型。生成的Model对象包含了许多有用的方法和属性:
-
.inputs和.outputs:提供对模型输入和输出的访问。 -
.layers:列出模型的所有层以及它们的形状。 -
.summary():打印模型的架构。 -
.save():保存模型、其架构和当前的训练状态。对于稍后恢复训练非常有用。可以使用tf.keras.models.load_model()从文件中实例化模型。 -
.save_weights():仅保存模型的权重。
虽然 Keras 模型对象只有一种类型,但可以通过多种方式构建它们。
Sequential 和函数式 API
你可以使用函数式 API 来代替本章开始时使用的 Sequential API:
model_input = tf.keras.layers.Input(shape=input_shape)
output = tf.keras.layers.Flatten()(model_input)
output = tf.keras.layers.Dense(128, activation='relu')(output)
output = tf.keras.layers.Dense(num_classes, activation='softmax')(output)
model = tf.keras.Model(model_input, output)
注意到代码比之前稍长了一些。然而,函数式 API 比 Sequential API 更加灵活和富有表现力。前者允许构建分支模型(即构建具有多个并行层的架构),而后者只能用于线性模型。为了更大的灵活性,Keras 还提供了子类化Model类的可能性,如第三章中所述,现代神经网络。
无论Model对象是如何构建的,它都是由层组成的。一个层可以看作是一个节点,接受一个或多个输入并返回一个或多个输出,类似于 TensorFlow 操作。它的权重可以通过.get_weights()访问,并通过.set_weights()设置。Keras 提供了用于最常见深度学习操作的预制层。对于更具创新性或复杂的模型,tf.keras.layers.Layer也可以被子类化。
回调函数
Keras 回调函数是一些实用函数,可以传递给 Keras 模型的.fit()方法,以增加其默认行为的功能。可以定义多个回调函数,Keras 会在每个批次迭代、每个 epoch 或整个训练过程中,在回调函数之前或之后调用它们。预定义的 Keras 回调函数包括以下内容:
-
CSVLogger:将训练信息记录到 CSV 文件中。 -
EarlyStopping:如果损失或某个度量停止改善,它会停止训练。它可以在避免过拟合方面发挥作用。 -
LearningRateScheduler:根据计划在每个 epoch 改变学习率。 -
ReduceLROnPlateau:当损失或某个度量停止改善时,自动降低学习率。
也可以通过子类化tf.keras.callbacks.Callback来创建自定义回调函数,如后续章节和代码示例中所示。
高级概念
总结来说,AutoGraph 模块、tf.function装饰器和梯度带上下文使得图的创建和管理变得非常简单——如果不是不可见的话。然而,很多复杂性对用户来说是隐藏的。在这一部分中,我们将探索这些模块的内部工作原理。
本节介绍了一些高级概念,这些概念在全书中并不需要,但它们可能有助于你理解更复杂的 TensorFlow 代码。更急于学习的读者可以跳过这一部分,稍后再回来查看。
tf.function 的工作原理
如前所述,当第一次调用用tf.function装饰的函数时,TensorFlow 将创建一个对应函数操作的图。TensorFlow 会缓存这个图,以便下次调用该函数时无需重新创建图。
为了说明这一点,让我们创建一个简单的identity函数:
@tf.function
def identity(x):
print('Creating graph !')
return x
这个函数将在 TensorFlow 每次创建与其操作对应的图时打印一条消息。在这种情况下,由于 TensorFlow 缓存了图,它只会在第一次运行时打印一些信息:
x1 = tf.random.uniform((10, 10))
x2 = tf.random.uniform((10, 10))
result1 = identity(x1) # Prints 'Creating graph !'
result2 = identity(x2) # Nothing is printed
但是,请注意,如果我们更改输入类型,TensorFlow 将重新创建图:
x3 = tf.random.uniform((10, 10), dtype=tf.float16)
result3 = identity(x3) # Prints 'Creating graph !'
这一行为的解释在于,TensorFlow 图是通过它们的操作以及它们接收的输入张量的形状和类型来定义的。因此,当输入类型发生变化时,需要创建一个新的图。在 TensorFlow 术语中,当tf.function函数定义了输入类型时,它就变成了具体函数。
总结来说,每次一个装饰过的函数首次运行时,TensorFlow 会缓存与输入类型和输入形状对应的图。如果函数使用不同类型的输入运行,TensorFlow 将创建一个新的图并进行缓存。
然而,每次执行具体函数时记录信息可能会很有用,而不仅仅是第一次执行时。为此,可以使用tf.print:
@tf.function
def identity(x):
tf.print("Running identity")
return x
这个函数将不会仅仅在第一次打印信息,而是每次运行时都会打印Running identity。
TensorFlow 2 中的变量
TensorFlow 使用Variable实例来存储模型权重。在我们的 Keras 示例中,我们可以通过访问model.variables列出模型的内容。它将返回模型中包含的所有变量的列表:
print([variable.name for variable in model.variables])
# Prints ['sequential/dense/kernel:0', 'sequential/dense/bias:0', 'sequential/dense_1/kernel:0', 'sequential/dense_1/bias:0']
在我们的示例中,变量管理(包括命名)完全由 Keras 处理。如前所述,我们也可以创建自己的变量:
a = tf.Variable(3, name='my_var')
print(a) # Prints <tf.Variable 'my_var:0' shape=() dtype=int32, numpy=3>
请注意,对于大型项目,建议为变量命名以明确代码的含义并简化调试。要更改变量的值,可以使用Variable.assign方法:
a.assign(a + 1)
print(a.numpy()) # Prints 4
如果不使用.assign()方法,将会创建一个新的Tensor方法:
b = a + 1
print(b) # Prints <tf.Tensor: id=21231, shape=(), dtype=int32, numpy=4>
最后,删除 Python 对变量的引用将会把该对象从活动内存中移除,为其他变量创建腾出空间。
分布式策略
我们在一个非常小的数据集上训练了一个简单的模型。在使用更大的模型和数据集时,需要更多的计算能力——这通常意味着需要多个服务器。tf.distribute.Strategy API 定义了多个机器如何协同工作以高效训练模型。
TensorFlow 定义的一些策略如下:
-
MirroredStrategy:用于在单台机器上训练多个 GPU。模型的权重会在各个设备之间保持同步。 -
MultiWorkerMirroredStrategy:类似于MirroredStrategy,但用于在多台机器上训练。 -
ParameterServerStrategy:用于在多台机器上训练。不同于在每个设备上同步权重,权重会保存在参数服务器上。 -
TPUStrategy:用于在 Google 的张量处理单元(TPU)芯片上进行训练。
TPU 是 Google 定制的芯片,类似于 GPU,专门设计用于运行神经网络计算。它可以通过 Google Cloud 访问。
要使用分布式策略,在其作用域内创建并编译模型:
mirrored_strategy = tf.distribute.MirroredStrategy()
with mirrored_strategy.scope():
model = make_model() # create your model here
model.compile([...])
请注意,你可能需要增加批处理大小,因为每个设备现在只会接收每个批次的小部分数据。根据你的模型,你可能还需要调整学习率。
使用 Estimator API
我们在本章的第一部分看到,Estimator API 是 Keras API 的高级替代方法。Estimator 简化了训练、评估、预测和服务过程。
估计器有两种类型。预制估计器是由 TensorFlow 提供的非常简单的模型,允许你快速尝试机器学习架构。第二种类型是自定义估计器,可以使用任何模型架构创建。
估计器处理模型生命周期的所有小细节——数据队列、异常处理、从失败中恢复、周期性检查点等。在 TensorFlow 1 中,使用估计器被认为是最佳实践,而在版本 2 中,建议使用 Keras API。
可用的预制估计器
在撰写本文时,现有的预制估计器有DNNClassifier、DNNRegressor、LinearClassifier和LinearRegressor。其中,DNN 代表深度神经网络。还提供了基于两种架构的组合估计器——DNNLinearCombinedClassifier和DNNLinearCombinedRegressor。
在机器学习中,分类是预测离散类别的过程,而回归是预测连续数字的过程。
组合估计器,也称为深度宽度模型,利用线性模型(用于记忆)和深度模型(用于泛化)。它们主要用于推荐或排序模型。
预制估计器适用于一些机器学习问题。然而,它们不适用于计算机视觉问题,因为没有带有卷积的预制估计器,卷积是一种强大的层类型,将在下一章中描述。
训练自定义估计器
创建估计器的最简单方法是转换 Keras 模型。在模型编译后,调用tf.keras.estimator.model_to_estimator():
estimator = tf.keras.estimator.model_to_estimator(model, model_dir='./estimator_dir')
model_dir参数允许你指定一个位置,在该位置保存模型的检查点。如前所述,估计器将自动保存我们的模型检查点。
训练估计器需要使用输入函数——一个返回特定格式数据的函数。接受的格式之一是 TensorFlow 数据集。数据集 API 在第七章中有详细描述,复杂和稀缺数据集的训练。现在,我们将定义以下函数,该函数以正确的格式批量返回本章第一部分定义的数据集,每批包含32个样本:
BATCH_SIZE = 32
def train_input_fn():
train_dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train))
train_dataset = train_dataset.batch(BATCH_SIZE).repeat()
return train_dataset
一旦定义了这个函数,我们可以启动训练与估计器:
estimator.train(train_input_fn, steps=len(x_train)//BATCH_SIZE)
就像 Keras 一样,训练部分非常简单,因为估计器处理了繁重的工作。
TensorFlow 生态系统
除了主要库外,TensorFlow 还提供了许多对机器学习有用的工具。虽然其中一些工具随 TensorFlow 一起提供,但其他工具被归类在TensorFlow 扩展(TFX)和TensorFlow 附加组件(TensorFlow Addons)下。我们将介绍最常用的工具。
TensorBoard
虽然我们在本章第一个示例中使用的进度条提供了有用的信息,但我们可能希望访问更详细的图表。TensorFlow 提供了一个强大的监控工具——TensorBoard。它在安装 TensorFlow 时默认包含,并且与 Keras 的回调函数结合使用时非常简单:
callbacks = [tf.keras.callbacks.TensorBoard('./logs_keras')]
model.fit(x_train, y_train, epochs=5, verbose=1, validation_data=(x_test, y_test), callbacks=callbacks)
在这段更新后的代码中,我们将 TensorBoard 回调传递给 model.fit() 方法。默认情况下,TensorFlow 会自动将损失值和指标写入我们指定的文件夹中。然后,我们可以从命令行启动 TensorBoard:
$ tensorboard --logdir ./logs_keras
这个命令会输出一个 URL,我们可以打开它来显示 TensorBoard 界面。在 Scalars 选项卡中,我们可以找到显示损失值和准确率的图表:

图 2.5:训练期间由 TensorBoard 显示的两个图表
正如你在本书中将看到的,训练一个深度学习模型需要大量的微调。因此,监控模型的表现至关重要。TensorBoard 让你可以精确地完成这项任务。最常见的使用场景是监控模型损失的变化过程。但你还可以执行以下操作:
-
绘制任何指标(如准确率)
-
显示输入和输出图像
-
显示执行时间
-
绘制模型的图形表示
TensorBoard 非常灵活,有许多使用方法。每条信息都存储在 tf.summary 中——这可以是标量、图像、直方图或文本。例如,要记录标量,你可以先创建一个摘要写入器,然后使用以下方法记录信息:
writer = tf.summary.create_file_writer('./model_logs')
with writer.as_default():
tf.summary.scalar('custom_log', 10, step=3)
在前面的代码中,我们指定了步骤——它可以是 epoch 数、batch 数量或自定义信息。它将对应于 TensorBoard 图表中的 x 轴。TensorFlow 还提供了生成汇总的工具。为了手动记录准确率,你可以使用以下方法:
accuracy = tf.keras.metrics.Accuracy()
ground_truth, predictions = [1, 0, 1], [1, 0, 0] # in practice this would come from the model
accuracy.update_state(ground_truth, predictions)
tf.summary.scalar('accuracy', accuracy.result(), step=4)
其他可用的指标,例如 Mean、Recall 和 TruePositives。虽然在 TensorBoard 中设置指标的日志记录可能看起来有些复杂且耗时,但它是 TensorFlow 工具包中的一个重要部分。它将节省你无数小时的调试和手动日志记录工作。
TensorFlow 附加组件和 TensorFlow 扩展
TensorFlow 附加组件是一个将额外功能收集到单一库中的集合 (github.com/tensorflow/addons)。它包含一些较新的深度学习进展,这些进展由于不够稳定或使用人数不足,无法被加入到 TensorFlow 主库中。它也作为 tf.contrib 的替代品,后者已从 TensorFlow 1 中移除。
TensorFlow 扩展是一个为 TensorFlow 提供端到端机器学习平台的工具。它提供了几个有用的工具:
-
TensorFlow 数据验证:用于探索和验证机器学习数据的库。你可以在构建模型之前使用它。
-
TensorFlow Transform:一个用于数据预处理的库。它确保训练数据和评估数据的处理方式一致。
-
TensorFlow 模型分析:用于评估 TensorFlow 模型的库。
-
TensorFlow Serving:一个用于机器学习模型的服务系统。服务是通过 REST API 从模型提供预测的过程:

图 2.6:创建和使用深度学习模型的端到端过程
如图 2.6所示,这些工具实现了端到端的目标,涵盖了构建和使用深度学习模型的每个步骤。
TensorFlow Lite 和 TensorFlow.js
TensorFlow 的主要版本设计用于 Windows、Linux 和 Mac 计算机。要在其他设备上运行,则需要不同版本的 TensorFlow。TensorFlow Lite旨在在手机和嵌入式设备上运行模型预测(推断)。它包括一个转换器,将 TensorFlow 模型转换为所需的.tflite格式,并包含一个可以安装在移动设备上进行推断的解释器。
最近,开发了TensorFlow.js(也称为tfjs),它使几乎任何 Web 浏览器都能进行深度学习。它不需要用户安装,且有时可以利用设备的 GPU 加速。我们在第九章《优化模型并在移动设备上部署》中详细介绍了 TensorFlow Lite 和 TensorFlow.js 的使用,优化模型并部署到移动设备上。
模型运行的位置
由于计算机视觉模型处理大量数据,因此训练需要较长时间。因此,在本地计算机上训练可能会花费相当多的时间。你还会发现,创建高效的模型需要很多次迭代。这两个观点将影响你决定在哪里训练和运行模型。在本节中,我们将比较不同的训练和使用模型的选项。
在本地机器上
在你的计算机上编码模型通常是开始的最快方式。由于你可以使用熟悉的环境,你可以根据需要轻松修改代码。然而,个人计算机,尤其是笔记本电脑,缺乏训练计算机视觉模型的计算能力。使用 GPU 进行训练的速度可能比使用 CPU 快 10 到 100 倍。这就是为什么建议使用 GPU 的原因。
即使你的计算机拥有 GPU,也只有非常特定的模型可以运行 TensorFlow。你的 GPU 必须与 CUDA 兼容,CUDA 是 NVIDIA 的计算库。撰写本文时,TensorFlow 的最新版本要求 CUDA 计算能力为 3.5 或更高。
一些笔记本电脑支持外接 GPU 机箱,但这违背了便携电脑的初衷。一个更实际的方法是将你的模型运行在一台远程计算机上,该计算机拥有 GPU。
在远程机器上
如今,你可以按小时租用带 GPU 的强大计算机。定价因 GPU 性能和供应商而异。单个 GPU 机器的费用通常为每小时约 $1,且价格每日有所下降。如果你承诺租用整个月份的机器,每月大约 $100 就可以获得良好的计算性能。考虑到你在训练模型时节省的时间,租用远程机器通常从经济角度来说是明智的选择。
另一个选择是搭建你自己的深度学习服务器。请注意,这需要投资和组装,并且 GPU 消耗大量电力。
一旦你获得了远程机器的访问权限,你有两个选择:
-
在远程服务器上运行 Jupyter Notebook。然后,你可以通过浏览器在全球任何地方访问 Jupyter Lab 或 Jupyter Notebook。这是一种非常方便的深度学习执行方式。
-
同步本地开发文件夹并远程运行代码。大多数 IDE 都有将本地代码与远程服务器同步的功能。这使得你可以在自己喜欢的 IDE 中编程,同时仍然享受强大的计算机性能。
基于 Jupyter 笔记本的 Google Colab 允许你在云端运行笔记本,免费使用。你甚至可以启用 GPU 模式。Colab 有存储空间限制,并且连续运行时间限制为 8 小时。虽然它是一个非常适合入门或实验的工具,但对于更大的模型来说并不方便。
在 Google Cloud 上
要在远程机器上运行 TensorFlow,你需要自己管理——安装正确的软件,确保其更新,并开启和关闭服务器。虽然对于一台机器来说仍然可以这样做,有时你还需要将训练分配到多个 GPU 上,但使用 Google Cloud ML 来运行 TensorFlow 可以让你专注于模型而不是操作。
你会发现 Google Cloud ML 对以下内容非常有用:
-
通过云中的弹性资源快速训练模型
-
使用并行化方法在最短时间内找到最佳模型参数
-
一旦模型准备好,你可以在无需自己运行预测服务器的情况下提供预测
有关打包、发送和运行模型的所有细节都可以在 Google Cloud ML 文档中找到(cloud.google.com/ml-engine/docs/)。
总结
在本章中,我们首先使用 Keras API 训练了一个基本的计算机视觉模型。我们介绍了 TensorFlow 2 背后的主要概念——张量、图、AutoGraph、即时执行和梯度带。我们还详细介绍了框架中的一些更高级的概念。我们通过库周围的主要工具进行了讲解,从用于监控的 TensorBoard 到用于预处理和模型分析的 TFX。最后,我们讨论了根据需求选择运行模型的位置。
拥有这些强大工具后,你现在准备好在下一章探索现代计算机视觉模型了。
问题
-
Keras 在 TensorFlow 中的作用是什么,它的目的是什么?
-
为什么 TensorFlow 使用图形?如何手动创建图形?
-
急切执行模式和懒惰执行模式有什么区别?
-
如何在 TensorBoard 中记录信息,并如何显示它?
-
TensorFlow 1 和 TensorFlow 2 之间的主要区别是什么?
第三章:现代神经网络
在第一章《计算机视觉与神经网络》中,我们介绍了近年来更适合图像处理的神经网络是如何超越过去十年计算机视觉方法的。然而,由于受限于我们从头开始重新实现的能力,我们只涵盖了基本架构。现在,随着 TensorFlow 强大的 API 触手可得,是时候探索什么是卷积神经网络(CNNs),以及这些现代方法是如何被训练以进一步提高它们的鲁棒性。
本章将涵盖以下主题:
-
CNN 及其与计算机视觉的相关性
-
使用 TensorFlow 和 Keras 实现这些现代网络
-
高级优化器和如何高效训练 CNN
-
正则化方法和如何避免过拟合
技术要求
本章的主要资源是用 TensorFlow 实现的。同时,Matplotlib 包(matplotlib.org)和 scikit-image 包(scikit-image.org)也有使用,尽管它们仅用于显示一些结果或加载示例图像。
如同前几章一样,展示本章概念的 Jupyter 笔记本可以在以下 GitHub 文件夹中找到:github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2/tree/master/Chapter03。
探索卷积神经网络
本章的第一部分,我们将介绍 CNN,也称为卷积网络(ConvNets),并解释为什么它们在视觉任务中变得无处不在。
多维数据的神经网络
CNNs 的引入是为了解决原始神经网络的一些缺陷。在本节中,我们将讨论这些问题,并展示 CNN 是如何处理它们的。
完全连接网络的问题
通过我们在第一章《计算机视觉与神经网络》和第二章《TensorFlow 基础与训练模型》的入门实验,我们已经突出了基本网络在处理图像时的以下两个主要缺点:
-
爆炸性的参数数量
-
空间推理的缺乏
让我们在这里讨论一下这些内容。
爆炸性的参数数量
图像是具有大量值的复杂结构(即,H × W × D 个值,其中H表示图像的高度,W表示宽度,D表示深度/通道数,例如 RGB 图像的D = 3)。即使是我们在前两章中使用的单通道小图像,也代表了大小为28 × 28 × 1 = 784的输入向量。对于我们实现的基础神经网络的第一层,这意味着一个形状为(784, 64)的权重矩阵。这意味着仅仅这个变量就需要优化 50,176(784 × 64)个参数值!
当我们考虑更大的 RGB 图像或更深的网络时,参数的数量会呈指数级增长。
缺乏空间推理
因为它们的神经元接收来自上一层的所有值且没有任何区分(它们是全连接的),这些神经网络没有距离/空间性的概念。数据中的空间关系丧失了。多维数据,如图像,也可以是从列向量到密集层的任何形式,因为它们的操作没有考虑数据的维度性和输入值的位置。更准确地说,这意味着全连接(FC)层丧失了像素间的接近概念,因为所有像素值在层内被组合时并不考虑它们的原始位置。
由于它不会改变全连接层的行为,为了简化计算和参数表示,通常会在将多维输入传递到这些层之前,先对其进行展平(即将其重塑为列向量)。
直观地看,如果神经网络能够考虑空间信息,即某些输入值属于同一个像素(通道值)或属于同一区域(相邻像素),那么神经层将更加智能。
引入卷积神经网络(CNN)
CNN 为这些不足提供了简单的解决方案。尽管它们与我们之前介绍的网络(如前馈网络和反向传播网络)工作原理相同,但它们的架构做了一些巧妙的改进。
首先,CNN 能够处理多维数据。对于图像,CNN 输入的是三维数据(高度 × 宽度 × 深度),并且它的神经元排列方式也类似于一个体积(参见图 3.1)。这导致了 CNN 的第二个创新——与全连接网络不同,在 CNN 中,每个神经元只访问上一层中相邻区域的一些元素,而不是连接到上一层的所有元素。这个区域(通常是正方形并跨越所有通道)被称为神经元的感受野(或过滤器大小):

图 3.1:CNN 表示,展示了从第一层到最后一层的左上角神经元的感受野(更多解释可在以下小节中找到)
通过只将神经元与上一层中相邻的神经元相连,CNN 不仅大幅减少了需要训练的参数数量,还保留了图像特征的定位。
卷积神经网络操作
在这种架构范式下,还引入了几种新的层类型,充分利用了多维性和局部连接性。
卷积层
CNN 之所以得名,是因为它们的核心架构包含了卷积层。在这些层中,通过在所有连接到同一输出通道的神经元之间共享相同的权重和偏置,进一步减少了参数的数量。
概念
这些具有共享权重和偏置的特定神经元也可以被认为是一个在整个输入矩阵上滑动的单一神经元,具有空间有限的连接性。在每一步,这个神经元只与当前滑动的输入体积(H × W × D)中的局部区域进行空间连接。考虑到这种有限维度的输入,k[H] × k[W] × D,对于具有过滤器大小(k[H],k[W])的神经元,该神经元的工作方式与我们第一章中建模的神经元类似——它在线性组合输入值(k[H] × k[W] × D 个值)之后,再应用激活函数对和进行处理(线性或非线性函数)。从数学角度来看,当神经元接受从位置 (i, j) 开始的输入块时,它的响应 z[i,j] 可以表示为:

是神经元的权重(即形状为 k[H] × k[W] × D 的二维矩阵),
是神经元的偏置,
是激活函数(例如,sigmoid)。对于神经元可以在输入数据上滑动的每个位置,重复此操作后,我们得到其完整的响应矩阵 𝑧,尺寸为 H[o] × W[o],其中 H[o] 和 W[o] 分别是神经元可以在输入张量上垂直和水平滑动的次数。
实际上,大多数情况下使用的是方形过滤器,这意味着它们的大小是 (k, k),其中 k = k[H] = k[W]。在本章的其余部分,为了简化解释,我们将只考虑方形过滤器,尽管值得记住的是,它们的高度和宽度可能会有所变化。
由于卷积层仍然可以有 N 组不同的神经元(即,具有共享参数的 N 组神经元),它们的响应图被堆叠在一起,形成形状为 H[o] × W[o] × N 的输出张量。
就像我们在全连接层中应用矩阵乘法一样,卷积操作也可以在这里用来一次性计算所有响应图(因此这些层的名称)。熟悉这种操作的人,可能在我们提到在输入矩阵上滑动过滤器时就认出了它。对于那些不熟悉这种操作的人,卷积的结果确实是通过将一个过滤器 w 滑动到输入矩阵 x 上,并在每个位置计算过滤器与从当前起始位置开始的 x 块的点积来获得的。此操作在图 3.2中得到了说明(使用单通道输入张量,以便使图示易于理解):

图 3.2:卷积示意图
在图 3.2中,请注意输入x已经被填充为零,这在卷积层中是常见的操作,例如当我们希望输出与原始输入(例如本例中的 3 × 3 大小)相同尺寸时。填充的概念在本章后面进一步发展。
这种操作的正确数学术语实际上是交叉相关,尽管在机器学习社区中通常使用卷积。矩阵x与滤波器w的交叉相关定义为
:

请注意我们用于z的方程式的对应关系。另一方面,矩阵x与滤波器w的实际数学卷积对所有有效位置(i, j)定义为:

如我们所见,在这种设置中,这两种操作非常相似,通过简单地在执行之前翻转滤波器,可以从交叉相关操作中获得卷积结果。
属性
一个具有N组不同神经元的卷积层由形状为D × k × k(当滤波器为正方形时)的N个权重矩阵(也称为滤波器或核心)和N个偏置值定义。因此,这一层只需训练N × (D**k² + 1)个值。与具有相似输入和输出维度的全连接层不同,后者需要(H × W × D) × (H[o] × W[o] × N)个参数。正如我们之前所示,全连接层的参数数量受数据维度的影响,而这并不影响卷积层的参数数量。
正是这一特性使得卷积层在计算机视觉中成为强大的工具,原因有两点。首先,正如前文所示,这意味着我们可以训练适用于更大输入图像的网络,而不影响需要调整的参数数量。其次,这也意味着卷积层可以应用于任何尺寸的图像!与具有全连接层的网络不同,纯卷积层不需要为不同尺寸的输入进行适应和重新训练。
当将 CNN 应用于各种大小的图像时,对输入批次进行采样仍然需要注意。事实上,只有所有图像具有相同尺寸时,才能将图像的子集堆叠到普通的批次张量中。因此,在实践中,在批处理之前应对图像进行排序(主要在训练阶段)或简单地分别处理每个图像(通常在测试阶段)。然而,为了简化数据处理和网络任务,人们通常会预处理图像,使它们的大小都相同(通过缩放和/或裁剪)。
除了那些计算优化外,卷积层还有一些与图像处理相关的有趣特性。经过训练,卷积层的滤波器能够非常擅长对特定的局部特征做出反应(一个拥有N个滤波器的层意味着能够对N种不同的特征做出反应)。例如,CNN 中第一层卷积层的每个卷积核将学习对特定的低级特征做出响应,比如特定的线条方向或颜色渐变。接下来,较深的层将使用这些结果来定位更加抽象或高级的特征,比如人脸的形状或特定物体的轮廓。此外,每个滤波器(即每组共享的神经元)都会对图像中的特定特征作出反应,无论该特征在图像中的位置如何。更正式地说,卷积层对图像坐标空间中的平移是不变的。
滤波器对输入图像的响应图可以描述为一个表示滤波器对目标特征响应位置的地图。因此,这些中间结果在 CNN 中通常被称为特征图。因此,一个拥有N个滤波器的层将返回N个特征图,每个特征图对应于输入张量中特定特征的检测。由层返回的N个特征图的堆叠通常被称为特征体积(形状为H[o] × W[o] × N)。
超参数
卷积层首先由其滤波器的数量N、输入深度D(即输入通道的数量)和滤波器/卷积核的大小(k[H],k[W])来定义。由于常用的是方形滤波器,大小通常仅由k来定义(尽管如前所述,有时也会考虑非方形滤波器)。
然而,正如前面提到的,卷积层实际上与同名的数学运算有所不同。输入与滤波器之间的操作可以有几个额外的超参数,影响滤波器在图像上滑动的方式。
首先,我们可以应用不同的步幅,即滤波器滑动的步伐。步幅超参数定义了当滑动时,图像块与滤波器之间的点积是否应在每个位置计算(stride = 1),还是在每s个位置计算(stride = s)。步幅越大,得到的特征图就越稀疏。
图像在进行卷积之前也可以进行零填充;即通过在原始内容周围添加零的行和列,合成地增加图像的尺寸。如图 3.2所示,这种填充增加了滤波器可以覆盖的图像位置数量。我们因此可以指定要应用的填充值(即要在输入的每一侧添加的空行和空列的数量)。
字母k通常用于表示滤波器/核大小(k代表kernel)。类似地,s通常用于表示步幅,p用于表示填充。请注意,与滤波器大小一样,通常会对水平和垂直步幅使用相同的值(s = s[H] = s[W]),并且水平和垂直填充也通常使用相同的值;不过,在某些特定的使用案例中,它们可能会有不同的值。
所有这些参数(核的数量N;核的大小k;步幅s;以及填充p)不仅影响层的操作,还会影响其输出形状。到目前为止,我们定义的输出形状为(H[o],W[o],N),其中H[o]和W[o]是神经元在输入上垂直和水平滑动的次数。那么,H[o]和W[o]到底是什么呢?从形式上来看,它们可以按以下方式计算:

虽然我们邀请你选择一些具体的例子来更好地理解这些公式,但我们可以直观地理解它们背后的逻辑。大小为𝑘的滤波器在大小为H × W的图像中,可以占据最多H - k + 1个不同的垂直位置和W - k + 1个水平位置。此外,如果这些图像在每一边都进行了p的填充,那么这些位置的数量将增加到H - k + 2p + 1(关于W - k + 2p + 1)。最后,增加步幅s,基本上意味着只考虑s个位置中的一个,这就解释了除法(注意这是整数除法)。
有了这些超参数,我们可以轻松控制层的输出尺寸。这在物体分割等应用中尤其方便;也就是说,当我们希望输出的分割掩码与输入图像的大小相同。
TensorFlow/Keras 方法
在低级 API 中可用,tf.nn.conv2d()(请参考文档:www.tensorflow.org/api_docs/python/tf/nn/conv2d)是进行图像卷积的默认选择。其主要参数如下:
-
input:输入图像的批次,形状为(B, H, W, D),其中B是批次大小。 -
filter:堆叠成形状为(k[H], k[W], D, N)的N个滤波器。 -
strides:表示批量输入每个维度步幅的四个整数的列表。通常,你会使用[1, s[H], s[W], 1](即,仅对图像的两个空间维度应用自定义步幅)。 -
padding:一个4 × 2整数列表,表示每个批量输入维度的前后填充,或者一个字符串,定义要使用的预定义填充案例;即,VALID或SAME(接下来的解释)。 -
name:用于标识该操作的名称(有助于创建清晰、易读的图形)。
请注意,tf.nn.conv2d()接受一些更高级的参数,我们暂时不会介绍(请参考文档)。图 3.3和3.4展示了两种不同参数的卷积操作效果:

图 3.3:使用 TensorFlow 对图像进行卷积的示例。这里的卷积核是一个著名的卷积核,常用于对图像应用高斯模糊。
在以下截图中,应用了计算机视觉领域中一个知名的卷积核:

图 3.4:另一个 TensorFlow 卷积的示例,具有更大的步幅。这个特定的卷积核通常用于提取图像中的边缘/轮廓。
关于填充,TensorFlow 开发者选择提供两种不同的预实现模式,以便用户无需自己去搞清楚在常规情况下需要使用哪个值,p。VALID 表示图像不会被填充(p = 0),滤波器只会在默认的有效位置上滑动。而选择 SAME 时,TensorFlow 会计算 p 的值,以确保卷积输出的高度和宽度与输入在步幅为 1 时相同(也就是说,根据前面章节中给出的方程,暂时将 s 设置为 1,从而解得 H[o] = H[o] 和 W[o] = W)。
有时候,你可能需要使用比零更复杂的填充。在这种情况下,建议使用 tf.pad() 方法(请参考文档:www.tensorflow.org/api_docs/python/tf/pad),然后简单地实例化一个使用 VALID 填充的卷积操作。
TensorFlow 还提供了其他几个低级别的卷积方法,例如 tf.nn.conv1d()(请参考文档:www.tensorflow.org/api_docs/python/tf/nn/conv1d)和 tf.nn.conv3d()(请参考文档:www.tensorflow.org/api_docs/python/tf/nn/conv3d),分别用于一维和三维数据,或者使用 tf.nn.depthwise_conv2d()(请参考文档:www.tensorflow.org/api_docs/python/tf/nn/depthwise_conv2d)对图像的每个通道进行不同滤波器的卷积等。
到目前为止,我们只展示了使用固定滤波器的卷积。对于卷积神经网络(CNN),我们必须使滤波器可训练。卷积层还会在将结果传递给激活函数之前应用一个学习到的偏置。因此,这一系列操作可以如下实现:
# Initializing the trainable variables (for instance, the filters with values from a Glorot distribution, and the bias with zeros):
kernels_shape = [k, k, D, N]
glorot_uni_initializer = tf.initializers.GlorotUniform()
# ^ this object is defined to generate values following the Glorot distribution (note that other famous parameter more or less random initializers exist, also covered by TensorFlow)
kernels = tf.Variable(glorot_uni_initializer(kernels_shape),
trainable=True, name="filters")
bias = tf.Variable(tf.zeros(shape=[N]), trainable=True, name="bias")
# Defining our convolutional layer as a compiled function:
@tf.function
def conv_layer(x, kernels, bias, s):
z = tf.nn.conv2d(x, kernels, strides=[1,s,s,1], padding='VALID')
# Finally, applying the bias and activation function (for instance, ReLU):
return tf.nn.relu(z + bias)
这个前馈函数可以进一步封装成一个Layer对象,类似于我们在第一章中实现的全连接层,计算机视觉与神经网络,是围绕矩阵操作构建的。通过 Keras API,TensorFlow 2 提供了自己的tf.keras.layers.Layer类,我们可以对其进行扩展(参见www.tensorflow.org/api_docs/python/tf/keras/layers/Layer的文档)。以下代码块演示了如何基于此构建一个简单的卷积层:
class SimpleConvolutionLayer(tf.keras.layers.Layer):
def __init__(self, num_kernels=32, kernel_size=(3, 3), stride=1):
""" Initialize the layer.
:param num_kernels: Number of kernels for the convolution
:param kernel_size: Kernel size (H x W)
:param stride: Vertical/horizontal stride
"""
super().__init__()
self.num_kernels = num_kernels
self.kernel_size = kernel_size
self.stride = stride
def build(self, input_shape):
""" Build the layer, initializing its parameters/variables.
This will be internally called the 1st time the layer is used.
:param input_shape: Input shape for the layer (for instance, BxHxWxC)
"""
num_input_ch = input_shape[-1] # assuming shape format BHWC
# Now we know the shape of the kernel tensor we need:
kernels_shape = (*self.kernel_size, num_input_ch, self.num_kernels)
# We initialize the filter values fior instance, from a Glorot distribution:
glorot_init = tf.initializers.GlorotUniform()
self.kernels = self.add_weight( # method to add Variables to layer
name='kernels', shape=kernels_shape, initializer=glorot_init,
trainable=True) # and we make it trainable.
# Same for the bias variable (for instance, from a normal distribution):
self.bias = self.add_weight(
name='bias', shape=(self.num_kernels,),
initializer='random_normal', trainable=True)
def call(self, inputs):
""" Call the layer, apply its operations to the input tensor."""
return conv_layer(inputs, self.kernels, self.bias, self.stride)
TensorFlow 的大多数数学操作(例如tf.math和tf.nn中的操作)已经由框架定义了它们的导数。因此,只要一个层由这些操作组成,我们就不需要手动定义它的反向传播,节省了大量的精力!
尽管这个实现的优点是显式的,但 Keras API 也封装了常见层的初始化(如第二章中介绍的,TensorFlow 基础与模型训练),从而加速了开发过程。通过tf.keras.layers模块,我们可以通过一次调用实例化一个类似的卷积层,如下所示:
conv = tf.keras.layers.Conv2D(filters=N, kernel_size=(k, k), strides=s,
padding='valid', activation='relu')
tf.keras.layers.Conv2D()(参见www.tensorflow.org/api_docs/python/tf/keras/layers/Conv2D的文档)有一个长长的附加参数列表,封装了多个概念,比如权重正则化(将在本章稍后介绍)。因此,建议在构建高级 CNN 时使用此方法,而不是花时间重新实现这些概念。
池化层
另一个与 CNN 一起使用的常见层类别是池化类型。
概念和超参数
这些池化层有点特别,因为它们没有任何可训练的参数。每个神经元仅仅取其窗口(感受野)中的值,并返回一个单一的输出,该输出是通过预定义函数计算得出的。最常见的两种池化方法是最大池化和平均池化。最大池化层仅返回池化区域每个深度的最大值(参考图 3.5),而平均池化层计算池化区域每个深度的平均值(参考图 3.6)。
池化层通常与 步幅 值等于其 窗口/核大小 一起使用,以便对不重叠的区域应用池化函数。它们的目的是 减少数据的空间维度,从而减少网络中所需的参数总数以及计算时间。例如,具有 2 × 2 窗口大小和步幅为 2 的池化层(即 k = 2 和 s = 2)将取每个深度的四个值,并返回一个单一的数字。这样,它将把特征的高度和宽度除以 2;也就是说,减少接下来层的计算次数 2 × 2 = 4。最后,注意,和卷积层一样,你可以在应用操作之前对张量进行填充(如 图 3.5 所示):

图 3.5:展示了一个窗口大小为 3 × 3、填充为 1、步幅为 2 的单通道输入的最大池化操作
通过填充(padding)和步幅(stride)参数,可以控制生成张量的维度。图 3.6 提供了另一个示例:

图 3.6:展示了一个窗口大小为 2 × 2、填充为 0、步幅为 2 的单通道输入的平均池化操作
由于池化层的超参数与卷积层类似,除了没有可训练的卷积核,因此池化层是易于使用且轻量级的解决方案,适用于控制数据的维度。
TensorFlow/Keras 方法
也可以从 tf.nn 包中获得,tf.nn.max_pool()(参考文档 www.tensorflow.org/api_docs/python/tf/nn/max_pool)和 tf.nn.avg_pool()(参考文档 www.tensorflow.org/api_docs/python/tf/nn/avg_pool)的签名与 tf.nn.conv2d() 非常相似,如下所示:
-
value:形状为 (B, H, W, D) 的输入图像批次,其中 B 是批次大小 -
ksize:一个包含四个整数的列表,表示每个维度的窗口大小;通常使用 [1, k, k, 1] -
strides:一个包含四个整数的列表,表示批处理输入每个维度的步幅,类似于tf.nn.conv2d() -
padding:一个字符串,定义要使用的填充算法(VALID或SAME) -
name:用于标识此操作的名称(对于创建清晰、可读的图形非常有用)
图 3.7 展示了应用于图像的平均池化操作:

图 3.7:使用 TensorFlow 对图像进行平均池化的示例
在 图 3.8 中,对相同的图像应用了最大池化函数:

图 3.8:另一个最大池化操作的示例,窗口大小与步幅相比过大(仅用于演示目的)
在这里,我们仍然可以使用更高级的 API,使得实例化过程更加简洁:
avg_pool = tf.keras.layers.AvgPool2D(pool_size=k, strides=[s, s], padding='valid')
max_pool = tf.keras.layers.MaxPool2D(pool_size=k, strides=[s, s], padding='valid')
由于池化层没有可训练的权重,因此池化操作与 TensorFlow 中对应的层之间没有实际区别。这使得这些操作不仅轻量级,而且易于实例化。
全连接层
值得一提的是,全连接层也用于 CNN,就像在常规网络中一样。接下来的段落中,我们将介绍何时考虑使用它们,以及如何将它们包含在 CNN 中。
在 CNN 中的应用
虽然全连接层可以添加到处理多维数据的 CNN 中,但这意味着传递给这些层的输入张量必须首先被重塑为批处理的列向量——就像我们在第一章《计算机视觉与神经网络》和第二章《TensorFlow 基础与模型训练》中所做的那样(即将高度、宽度和深度维度展平为一个单一的向量)。
全连接层也常被称为密集连接层,或简洁地称为密集层(与其他连接性较为有限的 CNN 层相对)。
虽然在某些情况下,神经元访问完整的输入图(例如,结合空间上远离的特征)可能是有利的,但全连接层有几个缺点,如本章开头所述(例如,空间信息丢失和参数数量庞大)。此外,与其他 CNN 层不同,全连接层是由其输入和输出的大小来定义的。特定的全连接层不能处理形状与其配置时不同的输入。因此,在神经网络中使用全连接层通常意味着失去将其应用于不同尺寸图像的可能性。
尽管存在这些缺点,这些层仍然在 CNN 中广泛使用。它们通常位于网络的最后几层,用于将多维特征转换为 1D 分类向量。
TensorFlow/Keras 方法
尽管我们在上一章中已经使用了 TensorFlow 的全连接层,但我们没有停下来关注它们的参数和属性。再次提醒,tf.keras.layers.Dense()的签名(请参考www.tensorflow.org/api_docs/python/tf/keras/layers/Dense的文档)与之前介绍的层类似,不同之处在于它们不接受任何strides或padding参数,而是使用units来表示神经元/输出大小,具体如下:
fc = tf.keras.layers.Dense(units=output_size, activation='relu')
然而,请记住,在将数据传递给密集层之前,你应当小心展平多维张量。tf.keras.layers.Flatten()(参考文档 www.tensorflow.org/api_docs/python/tf/keras/layers/Flatten)可以作为一个中间层来实现这一目的。
有效感受野
正如我们在本节中将详细说明的那样,神经网络的有效感受野 (ERF) 是深度学习中的一个重要概念,因为它可能影响网络跨引用并结合输入图像中远距离元素的能力。
定义
虽然感受野表示神经元与前一层连接的局部区域,但 ERF 定义了输入图像的区域(而不仅仅是前一层的区域),该区域影响给定层神经元的激活,如图 3.9所示:

图 3.9:具有两个卷积层简单网络的层的感受野示意图
请注意,通常会将感受野 (RF) 用作 ERF 的替代术语,因为 RF 可以简单地指代层的过滤器大小或窗口大小。一些人还使用 RF 或 ERF 来特指影响输出层每个单元的输入区域(而不仅仅是网络的任何中间层)。
更加令人困惑的是,一些研究人员开始将 ERF 称为实际影响神经元的输入区域子集。这一观点由 Wenjie Luo 等人在他们的论文《Understanding the Effective Receptive Field in Deep Convolutional Neural Networks》中提出,该论文发表在《Advances in Neural Information Processing Systems (2016)》上。他们的观点是,并非所有被神经元“看到”的像素都对其响应有相等的贡献。我们可以直观地接受,例如,感受野中心的像素对神经元的响应权重会大于外围像素。这些中心像素携带的信息可以通过网络的中间层沿多条路径传播到达某个神经元,而感受野外围的像素则通过单一路径连接到该神经元。因此,Luo 等人定义的 ERF 遵循伪高斯分布,而传统 ERF 是均匀分布的。
作者将这种感受野的表示与人类的中央黄斑做了有趣的类比,中央黄斑是眼睛中负责清晰中央视觉的区域。视力的这一细节部分是许多人类活动的基础。尽管其相对较小,但一半的视神经与黄斑相连,就像有效感受野中的中央像素连接到更多的人工神经元一样。
公式
无论其像素实际扮演什么角色,卷积神经网络第i层的有效感受野(此处称为R[i])可以通过递归方式计算如下:

在这个方程中,k[i] 是该层的滤波器大小,s[i] 是它的步长(因此,方程的最后一部分表示所有前面层的步长的乘积)。例如,我们可以将此公式应用于图 3.9 中展示的极简二层卷积神经网络(CNN),以定量评估第二层的有效感受野(ERF),计算方法如下:

该公式确认了网络的有效感受野(ERF)直接受到中间层数量、滤波器大小和步长的影响。子采样层(例如池化层或具有较大步长的层)会大大增加有效感受野,但会以牺牲特征分辨率为代价。
由于卷积神经网络(CNN)的局部连接性,在定义网络架构时,你应当牢记层与其超参数如何影响视觉信息在网络中的流动。
使用 TensorFlow 实现卷积神经网络
大多数最先进的计算机视觉算法都基于卷积神经网络(CNN),这些网络使用我们刚刚介绍的三种不同类型的层(即卷积层、池化层和全连接层),并进行一些调整和技巧,这些内容我们将在本书中介绍。在这一部分,我们将实现第一个卷积神经网络,并将其应用于数字识别任务。
实现我们的第一个卷积神经网络(CNN)
对于我们的第一个卷积神经网络,我们将实现LeNet-5。该网络最早由 Yann Le Cun 于 1995 年提出(在Learning algorithms for classification: A comparison on handwritten digit recognition, World Scientific Singapore一书中),并应用于 MNIST 数据集。LeNet-5 可能不是一个新的网络,但它仍然是介绍卷积神经网络的常用模型。实际上,凭借其七个层次,这个网络非常容易实现,同时也能得到有趣的结果。
LeNet-5 架构
如图 3.10所示,LeNet-5 首先由两个模块组成,每个模块包含一个卷积层(卷积核大小k = 5,步长s = 1),后接一个最大池化层(池化核k = 2,步长s = 2)。在第一个模块中,输入图像在进行卷积前在每一边填充 2 个像素(即,p = 2,因此实际输入大小为32 × 32),卷积层有六个不同的滤波器(N = 6)。第二个卷积层前没有填充(p = 0),它的滤波器数量设置为 16(N = 16)。经过这两个模块后,三个全连接层将特征合并并最终输出分类结果(10 个数字类别)。在第一个全连接层之前,5 × 5 × 16 的特征体积被展平成一个 400 个值的向量。完整的网络架构如下图所示:

图 3.10:LeNet-5 架构(使用 NN-SVG 工具渲染,由 Alexander Lenail 提供—http://alexlenail.me/NN-SVG)
在原始实现中,除了最后一层外,每个卷积层和全连接层都使用 tanh 作为激活函数。然而,ReLU 现在比 tanh 更为常用,已在大多数 LeNet-5 实现中取代了 tanh。对于最后一层,应用 softmax 函数。该函数接受一个 N 值的向量,并返回一个相同大小的向量,y,其值被归一化为概率分布。换句话说,softmax 会归一化一个向量,使其所有值都在 0 和 1 之间,并且它们的和恰好等于 1。因此,这个函数通常应用于分类任务的神经网络末尾,将网络的预测值转换为每个类别的概率值,如 第一章,计算机视觉与神经网络 中所述(即,给定输出张量,y = [y[0], ..., y[i], ..., y[N]],y[i] 表示样本属于类别 i 的可能性)。
网络的原始预测(即,未经过归一化的预测值)通常被称为 logits。这些无界的值通常通过 softmax 函数转换为概率值。这个归一化过程使得预测更加 易读(每个值代表网络对对应类别的置信度;参见 第一章,计算机视觉与神经网络),并简化了训练损失的计算(即,分类任务中的类别交叉熵)。
TensorFlow 和 Keras 实现
我们手头有了实现此网络的所有工具。在查看 TensorFlow 和 Keras 提供的实现之前,建议你自己尝试实现。使用 第二章,TensorFlow 基础与模型训练 中的符号和变量,使用 Keras Sequential API 实现的 LeNet-5 网络如下:
from tensorflow.keras.model import Model, Sequential
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense
model = Sequential() # `Sequential` inherits from tf.keras.Model
# 1st block:
model.add(Conv2D(6, kernel_size=(5, 5), padding='same', activation='relu',
input_shape=(img_height, img_width, img_channels))
model.add(MaxPooling2D(pool_size=(2, 2)))
# 2nd block:
model.add(Conv2D(16, kernel_size=(5, 5), activation='relu')
model.add(MaxPooling2D(pool_size=(2, 2)))
# Dense layers:
model.add(Flatten())
model.add(Dense(120, activation='relu'))
model.add(Dense(84, activation='relu'))
model.add(Dense(num_classes, activation='softmax'))
该模型通过逐一实例化并添加层,按顺序创建。如同在 第二章,TensorFlow 基础与模型训练 中提到的,Keras 还提供了 功能性 API。该 API 使得用更面向对象的方式定义模型成为可能(如以下代码所示),尽管也可以直接通过层操作实例化 tf.keras.Model(如在我们的某些 Jupyter 笔记本中所示):
from tensorflow.keras import Model
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense
class LeNet5(Model): # `Model` has the same API as `Layer` + extends it
def __init__(self, num_classes): # Create the model and its layers
super(LeNet5, self).__init__()
self.conv1 = Conv2D(6, kernel_size=(5, 5), padding='same',
activation='relu')
self.conv2 = Conv2D(16, kernel_size=(5, 5), activation='relu')
self.max_pool = MaxPooling2D(pool_size=(2, 2))
self.flatten = Flatten()
self.dense1 = Dense(120, activation='relu')
self.dense2 = Dense(84, activation='relu')
self.dense3 = Dense(num_classes, activation='softmax')
def call(self, x): # Apply the layers in order to process the inputs
x = self.max_pool(self.conv1(x)) # 1st block
x = self.max_pool(self.conv2(x)) # 2nd block
x = self.flatten(x)
x = self.dense3(self.dense2(self.dense1(x))) # dense layers
return x
Keras 层确实可以像函数一样,对输入数据进行处理并进行链式操作,直到获得所需的输出。功能性 API 允许你构建更复杂的神经网络;例如,当某一特定层在网络中被多次复用,或者当层具有多个输入或输出时。
对于已经尝试过 PyTorch(pytorch.org)的用户来说,这种面向对象的神经网络构建方法可能很熟悉,因为它在 PyTorch 中被广泛使用。
应用到 MNIST
我们现在可以编译并训练我们的数字分类模型。通过 Keras API(并重用上一章中准备的 MNIST 数据变量),我们实例化优化器(一个简单的随机梯度下降(SGD)优化器),并在启动训练之前定义损失函数(类别交叉熵),如下所示:
model.compile(optimizer='sgd', loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
# We also instantiate some Keras callbacks, that is, utility functions automatically called at some points during training to monitor it:
callbacks = [
# To interrupt the training if `val_loss` stops improving for over 3 epochs:
tf.keras.callbacks.EarlyStopping(patience=3, monitor='val_loss'),
# To log the graph/metrics into TensorBoard (saving files in `./logs`):
tf.keras.callbacks.TensorBoard(log_dir='./logs', histogram_freq=1)]
# Finally, we launch the training:
model.fit(x_train, y_train, batch_size=32, epochs=80,
validation_data=(x_test, y_test), callbacks=callbacks)
请注意使用 sparse_categorical_crossentropy,而非 categorical_crossentropy,以避免对标签进行独热编码。这个损失函数在第二章中有描述,TensorFlow 基础与模型训练。
经过大约 60 个 epoch 后,我们观察到网络在验证数据上的准确率达到了超过 98.5%!与我们之前使用非卷积网络的尝试相比,相对误差降低了2倍(从约 3.0%的误差降至约 1.5%),这是一个显著的改进(考虑到已经非常高的准确率)。
在接下来的章节中,我们将充分理解卷积神经网络(CNN)的分析能力,并将其应用于越来越复杂的视觉任务。
精炼训练过程
网络架构不仅在这些年中得到了改进,网络的训练方式也在不断发展,提升了网络收敛的可靠性与速度。在本节中,我们将讨论在第一章中介绍的梯度下降算法的一些不足,并探讨避免过拟合的一些方法。
现代网络优化器
优化多维函数(如神经网络)是一项复杂的任务。我们在第一章中介绍的梯度下降解法是一个优雅的解决方案,但它有一些局限性,接下来的部分将重点说明这些局限性。幸运的是,研究人员已经开发出新一代的优化算法,我们也将讨论这些算法。
梯度下降的挑战
我们之前展示了神经网络的参数 P(即所有层的权重和偏置参数)如何在训练过程中通过反向传播梯度,逐步更新以最小化损失 L。如果把这个梯度下降过程用一个公式来总结,应该是下面这个样子:

是学习率超参数,它强调或减弱网络参数在每次训练迭代时,根据损失函数的梯度更新的方式。虽然我们提到学习率值应谨慎设置,但并未解释为何及如何设置。对此需要谨慎的原因有三。
训练速度与权衡
我们之前部分地讨论过这一点。虽然设置较高的学习率可能会让训练后的网络更快地收敛(即,在较少的迭代中,由于每次迭代时参数更新幅度较大),但它也可能会阻止网络找到合适的损失最小值。图 3.11 是一个著名的插图,展示了优化时过于谨慎与急于求成之间的权衡:

图 3.11:学习率权衡的插图
从图 3.11中,我们可以观察到,过低的学习率会减慢收敛速度(左侧的图表 A),而过高的学习率可能会导致其超过局部最小值(右侧的图表 B)。
直观地说,应该有比反复试验更好的方法来找到合适的学习率。例如,一种常见的解决方案是在训练过程中动态调整学习率,从较大的值开始(以便初期更快速地探索损失域),然后在每个周期后将其减小(以便在接近最小值时进行更谨慎的更新)。这一过程称为学习率衰减。手动衰减仍然可以在许多实现中找到,不过,现在 TensorFlow 提供了更先进的学习率调度器和具有自适应学习率的优化器。
次优局部最小值
在优化复杂(即,非凸)方法时,一个常见的问题是陷入次优局部最小值。事实上,梯度下降法可能会将我们带到一个无法逃脱的局部最小值,即使更好的最小值就在旁边,如图 3.12所示:

图 3.12:梯度下降最终陷入次优局部最小值的示例
由于训练样本的随机采样(导致每个小批次的梯度常常有所不同),第一章中介绍的 SGD(随机梯度下降),计算机视觉与神经网络,已经能够跳出浅层局部最小值。
请注意,梯度下降过程不能确保收敛到全局最小值(即,收敛到所有可能组合中的最佳参数集)。这将意味着扫描完整的损失域,以确保给定的最小值确实是最好的(这将意味着,例如,计算所有可能参数组合的损失)。考虑到视觉任务的复杂性和解决这些任务所需的庞大参数量,数据科学家通常更愿意找到一个令人满意的局部最小值。
一个适用于异质参数的超参数
最后,在传统的梯度下降法中,相同的学习率用于更新网络中的所有参数。然而,并不是所有这些变量对变化的敏感度相同,也不是它们在每次迭代中都会对损失产生相同的影响。
可能会觉得使用不同的学习率(例如,针对参数的子集)更新关键参数,以便更加精细地更新这些参数,同时大胆更新那些对网络预测贡献不足的参数,这样做是有利的。
高级优化器
我们在前述段落中提出的一些直觉已经被研究人员进行了适当的研究和形式化,从而导致了基于 SGD 的新优化算法。现在我们将列出这些优化器中最常见的几种,详细介绍它们的贡献以及如何在 TensorFlow 中使用它们。
动量算法
动量算法最早由 Boris Polyak 提出(见某些加速迭代方法收敛的方法,Elsevier,1964 年),该算法基于 SGD 并受到物理学中动量概念的启发——只要一个物体在下坡,它的速度将在每一步中加速。应用于梯度下降时,理念是考虑到之前的参数更新,v[i-1],并将其加入到新的更新项中,v[i],如下所示:

其中,
(mu)是动量权重(介于 0 和 1 之间的值),定义了应用前次更新的比例。如果当前步骤和前一步的方向相同,它们的幅度将会叠加,导致 SGD 在该方向上加速。如果方向不同,动量将抑制这些振荡。
在tf.optimizers(也可以通过tf.keras.optimizers访问)中,动量被定义为 SGD 的一个可选参数(参见www.tensorflow.org/api_docs/python/tf/keras/optimizers/SGD)如下所示:
optimizer = tf.optimizers.SGD(lr=0.01, momentum=0.9, # `momentum` = "mu"
decay=0.0, nesterov=False)
该优化器接受一个decay参数,用于修正每次更新时的学习率衰减(参见前述段落)。
然后,可以直接将此优化器实例作为参数传递给model.fit(),通过 Keras API 启动训练。对于更复杂的训练场景(例如,当训练互依网络时),优化器也可以被调用,并提供损失梯度和模型的可训练参数。以下是一个简单的训练步骤示例,手动实现:
@tf.function
def train_step(batch_images, batch_gts): # typical training step
with tf.GradientTape() as grad_tape: # Tell TF to tape the gradients
batch_preds = model(batch_images, training=True) # forward
loss = tf.losses.MSE(batch_gts, batch_preds) # compute loss
# Get the loss gradients w.r.t trainable parameters and back-propagate:
grads = grad_tape.gradient(loss, model.trainable_variables)
optimizer.apply_gradients(zip(grads, model.trainable_variables))
tf.optimizers.SGD有一个有趣的布尔参数——用于将常见的动量方法切换到 Nesterov 算法。实际上,前一种方法的一个主要问题是,当网络非常接近损失最小值时,积累的动量通常会非常高,这可能导致方法错过或在目标最小值周围振荡。
Nesterov 加速梯度(NAG 或 Nesterov 动量)为这个问题提供了解决方案(相关课程为 凸编程基础讲座第一卷:基础课程,由 Yurii Nesterov 编写,Springer Science and Business Media 出版)。早在 1980 年代,Yurii Nesterov 的想法是让优化器有机会提前查看坡度,以便 知道 如果坡度开始上升,它应该放慢速度。更正式地说,Nesterov 建议直接重用过去的项 v[i-1] 来估算如果我们继续沿此方向前进,参数 P[i+1] 会取什么值。然后,梯度将基于这些近似的未来参数进行评估,并最终用于计算实际的更新,公式如下:

这种动量优化器版本(其中损失是基于参数的值计算的,更新值根据之前的步骤进行调整)对梯度变化更具适应性,可以显著加快梯度下降的过程。
Ada 家族
Adagrad、Adadelta 和 Adam 是围绕根据每个神经元的敏感性和/或激活频率调整学习率的思路的几个迭代和变体。
Adagrad 优化器(用于 自适应梯度)最初由 John Duchi 等人在《在线学习和随机优化的自适应子梯度方法》(《机器学习研究杂志》,2011)中提出,使用一个精巧的公式(我们在这里不展开,欢迎你自行查阅)来自动更快地减少与常见特征相关的参数的学习率,对于不常见的特征则减小得较慢。换句话说,正如 Keras 文档中所描述,一个参数接受的更新次数越多,更新的幅度越小(可以参考 keras.io/optimizers/ 的文档)。这种优化算法不仅消除了手动调整/衰减学习率的需求,还使得 SGD 过程更加稳定,特别是在具有稀疏表示的数据集上。
2013 年,Matthew D. Zeiler 等人在《ADADELTA: 一种自适应学习率方法》(arXiv 预印本)中介绍了 Adadelta,为 Adagrad 固有的问题提供了解决方案。由于 Adagrad 在每次迭代中都会衰减学习率,因此到某个时刻,学习率变得过小,网络将无法继续学习(除了可能一些不常见的参数)。Adadelta 通过控制每个参数用于除以学习率的因子,避免了这个问题。
RMSprop 由 Geoffrey Hinton 提出,是另一个著名的优化器(在他的 Coursera 课程中介绍,Lecture 6.5-rmsprop: 将梯度除以其近期幅度的滑动平均值
)。与 Adadelta 密切相关且相似,RMSprop 也被开发用来修正 Adagrad 的缺陷。
Adam(自适应矩估计法)是 Diederik P. Kingma 等人提出的另一种优化方法(见Adam: A method for stochastic optimization,ICLR,2015)。除了存储先前的更新项 v[i] 来调整每个参数的学习率外,Adam 还会跟踪过去的动量值。因此,它通常被认为是Adadelta和momentum的混合体。同样,Nadam是继承自Adadelta和NAG的优化器。
所有这些不同的优化器都可以在tf.optimizers包中找到(请参考www.tensorflow.org/api_docs/python/tf/train/中的文档)。需要注意的是,目前没有共识认为哪个优化器是最好的。然而,Adam 被许多计算机视觉专业人士所青睐,因为它在数据稀缺的情况下表现有效。RMSprop 也常被认为是递归神经网络的一个不错选择(如在第八章,视频和递归神经网络中介绍的)。
一个展示如何使用这些不同优化器的 Jupyter notebook 已提供在 Git 仓库中。每个优化器还应用于我们用于 MNIST 分类的LeNet-5的训练,以便比较它们的收敛情况。
正则化方法
然而,仅仅高效地训练神经网络,使其在训练数据上最小化损失,还不够。我们还希望这些网络在应用于新图像时表现良好。我们不希望它们过拟合训练集(如在第一章,计算机视觉与神经网络中提到的那样)。为了让我们的网络具备良好的泛化能力,我们提到过,丰富的训练集(足够的变异性来覆盖可能的测试场景)和明确定义的架构(既不浅以避免欠拟合,也不复杂以防止过拟合)是关键。然而,随着时间的推移,已经开发了其他正则化方法;例如,优化阶段的精细调整过程,用以避免过拟合。
早期停止
当神经网络在同一小批训练样本上迭代过多时,它们就会开始过拟合。因此,防止这种问题的一个简单解决方案是确定模型需要的训练周期数。这个数字应该足够低,以便在网络开始过拟合之前停止,但又足够高,让网络从这个训练集学到它能学到的一切。
交叉验证 在这里是评估何时停止训练的关键。通过为优化器提供验证数据集,优化器可以测量模型在网络未直接优化过的图像上的表现。通过对网络进行验证,例如在每个训练周期后,我们可以判断训练是否应继续(即,当验证准确率仍在上升时)或应停止(即,当验证准确率停滞或下降时)。后一种情况称为早停。
实际上,我们通常会监控并绘制验证损失和指标与训练迭代次数的关系,并在最优点恢复保存的权重(因此,在训练过程中定期保存网络非常重要)。这种监控、早停和最优权重恢复可以通过一个可选的 Keras 回调函数(tf.keras.callbacks.EarlyStopping)自动完成,正如我们之前的训练中展示的那样。
L1 和 L2 正则化
另一种防止过拟合的方法是修改损失函数,将正则化作为训练目标之一。L1 和 L2 正则化就是这种方法的典型例子。
原则
在机器学习中,可以在训练前将一个计算出来的正则化项 R(P)(它是方法 f 的参数 P)添加到损失函数 L 中进行优化(例如,一个神经网络),如下所示:

在这里,
是控制正则化强度的因子(通常,用来缩小正则化项相对于主损失的幅度),而 y = f(x, P) 是该方法的输出,f,通过输入数据 x 的参数 P 来参数化。通过将这个项 R(P) 添加到损失中,我们迫使网络不仅优化其任务,而且在约束其参数可能取值的同时进行优化。
L1 和 L2 正则化的相应项如下:

L2 正则化(也称为岭正则化)因此迫使网络最小化其参数值的平方和。虽然这种正则化导致所有参数值在优化过程中逐渐衰减,但由于平方项的存在,它对大参数的惩罚更为强烈。因此,L2 正则化鼓励网络保持其参数值较低,从而更加均匀地分布。它防止网络发展出一组具有大值的参数,这些大值会影响其预测(因为这可能会阻碍网络的泛化能力)。
另一方面,L1 正则化器(也叫LASSO(最小绝对收缩和选择算子)正则化器,最早由Fadil Santosa 和 William Symes在 《带限反射地震图的线性反演》 中提出,SIAM,1986)迫使网络最小化其参数值的绝对值之和。乍一看,L1 正则化和 L2 正则化的区别可能显得微不足道,但它们的特性实际上是非常不同的。由于较大的权重不会因平方而受到惩罚,L1 正则化会迫使网络将与不重要特征相关联的参数缩小到零。因此,它通过强制网络忽略较不重要的特征(例如与数据集噪声相关的特征)来防止过拟合。换句话说,L1 正则化强迫网络采用稀疏参数,即依赖于一小部分非零参数。如果网络的内存占用需要最小化(例如移动应用程序),这一点尤其有优势。
TensorFlow 和 Keras 实现
要实现这些技术,我们应该定义正则化损失并将此函数附加到每个目标层。在每次训练迭代中,这些附加损失应该基于层的参数计算,并与主要任务特定的损失(例如网络预测的交叉熵)求和,以便它们可以通过优化器一起反向传播。幸运的是,TensorFlow 2 提供了多个工具来简化这一过程。
可以通过tf.keras.layers.Layer和tf.keras.Model实例的.add_loss(losses, ...)方法将附加的损失添加到网络中,其中losses是返回损失值的张量或无参可调用对象。一旦正确地添加到层(参见以下代码),这些损失将在每次调用层/模型时计算。附加到Layer或Model实例的所有损失,以及附加到其子层的损失,将会计算,并且在调用.losses属性时返回损失值列表。为了更好地理解这一概念,我们将扩展之前实现的简单卷积层,向其参数添加可选的正则化:
from functools import partial
def l2_reg(coef=1e-2): # reimplementation of tf.keras.regularizers.l2()
return lambda x: tf.reduce_sum(x ** 2) * coef
class ConvWithRegularizers(SimpleConvolutionLayer):
def __init__(self, num_kernels=32, kernel_size=(3, 3), stride=1,
kernel_regularizer=l2_reg(), bias_regularizer=None):
super().__init__(num_kernels, kernel_size, stride)
self.kernel_regularizer = kernel_regularizer
self.bias_regularizer = bias_regularizer
def build(self, input_shape):
super().build(input_shape)
# Attaching the regularization losses to the variables.
if self.kernel_regularizer is not None:
# for instance, we tell TF to compute and save
# `tf.nn.l1_loss(self.kernels)` at each call (that is iteration):
self.add_loss(partial(self.kernel_regularizer, self.kernels))
if self.bias_regularizer is not None:
self.add_loss(partial(self.bias_regularizer, self.bias))
正则化损失应引导模型学习更强健的特征。它们不应优先于主要的训练损失,后者是为了让模型适应其任务。因此,我们应该小心不要过度强调正则化损失。正则化损失的值通常会通过一个介于 0 和 1 之间的系数进行衰减(参见我们l2_reg()损失函数中的coef)。这种加权尤其重要,例如,当主要损失是平均值时(例如,MSE 和 MAE)。为了使正则化损失不至于过大,我们应该确保它们也在参数维度上平均,或者进一步减小它们的系数。
在每次训练迭代中,对于由这些层组成的网络,正则化损失可以被计算、列出并加入到主损失中,具体如下:
# We create a NN containing layers with regularization/additional losses:
model = Sequential()
model.add(ConvWithRegularizers(6, (5, 5), kernel_regularizer=l2_reg())
model.add(...) # adding more layers
model.add(Dense(num_classes, activation='softmax'))
# We train it (c.f. function `training_step()` defined before):
for epoch in range(epochs):
for (batch_images, batch_gts) in dataset:
with tf.GradientTape() as grad_tape:
loss = tf.losses.sparse_categorical_crossentropy(
batch_gts, model(batch_images)) # main loss
loss += sum(model.losses) # list of addit. losses
# Get the gradients of combined losses and back-propagate:
grads = grad_tape.gradient(loss, model.trainable_variables)
optimizer.apply_gradients(zip(grads, model.trainable_variables))
我们引入了.add_loss()方法,因为该方法可以极大地简化将特定层损失添加到自定义网络中的过程。然而,当涉及到添加正则化损失时,TensorFlow 提供了一个更直接的解决方案。我们只需将正则化损失函数作为.add_weight()方法(也称为.add_variable())的参数,该方法用于创建并附加变量到Layer实例。例如,卷积核的变量可以通过以下方式直接创建,并附加正则化损失:self.kernels = self.add_weight(..., regularizer=self.kernel_regularizer)。在每次训练迭代中,得到的正则化损失值仍然可以通过该层或模型的.losses属性获得。
在使用预定义的 Keras 层时,我们无需扩展类来添加正则化项。这些层可以通过参数接收正则化器。Keras 甚至在其tf.keras.regularizers模块中显式定义了一些正则化器可调用函数。最后,在使用 Keras 训练操作(如model.fit(...))时,Keras 会自动考虑额外的model.losses(即正则化项和其他可能的特定层损失),如下所示:
# We instantiate a regularizer (L1 for example):
l1_reg = tf.keras.regularizers.l1(0.01)
# We can then pass it as a parameter to the target model's layers:
model = Sequential()
model.add(Conv2D(6, kernel_size=(5, 5), padding='same', activation='relu',
input_shape=input_shape, kernel_regularizer=l1_reg))
model.add(...) # adding more layers
model.fit(...) # training automatically taking into account the reg. terms.
Dropout
到目前为止,我们介绍的正则化方法主要影响网络的训练方式。其他一些解决方案则影响网络的架构。Dropout(丢弃法)就是其中一种方法,也是最流行的正则化技巧之一。
定义
在Dropout: A Simple Way to Prevent Neural Networks from Overfitting(JMLR, 2014)中,Hinton 及其团队(他们为深度学习做出了许多贡献)首次引入了dropout方法,dropout通过在每次训练迭代中随机断开目标层的一些神经元(即“丢弃”)来实现。这种方法因此需要一个超参数比率,
,该比率表示每次训练步骤中神经元被关闭的概率(通常设定在 0.1 到 0.5 之间)。该概念在图 3.13中有所展示:

图 3.13:在简单神经网络中表示的 Dropout(注意,每次迭代时,丢弃的层神经元是随机选择的)
通过人为且随机地削弱网络,这种方法迫使网络学习到更加鲁棒且并行的特征。例如,由于 dropout 可能会停用负责某个关键特征的神经元,网络必须找到其他重要特征来达到相同的预测结果。这样就能促使网络发展出冗余的特征表示,用于预测。
丢弃法也常被解释为一种廉价的解决方案,可以同时训练多个模型(原始网络的随机失效版本)。在测试阶段,丢弃法不会应用于网络,因此网络的预测可以看作是各个部分模型结果的结合。因此,这种信息平均可以防止网络过拟合。
TensorFlow 和 Keras 方法
丢弃法可以通过函数tf.nn.dropout(x, rate, ...)调用(请参阅www.tensorflow.org/api_docs/python/tf/nn/dropout)直接获得一个值随机丢弃的张量,或者通过tf.keras.layers.Dropout()作为层调用(请参阅www.tensorflow.org/api_docs/python/tf/layers/dropout),可以将其添加到神经网络模型中。默认情况下,tf.keras.layers.Dropout()仅在训练时应用(当层/模型被调用时,带有training=True参数),否则会被禁用(转发未经修改的值)。
丢弃层应该直接添加到我们希望防止过拟合的层后面(因为丢弃层会随机丢弃前一层返回的值,迫使其进行适应)。例如,你可以在 Keras 中对全连接层应用丢弃法(例如,使用一个比率,
),如下面的代码块所示:
model = Sequential([ # ...
Dense(120, activation='relu'),
Dropout(0.2), # ...
])
批量归一化
尽管我们的列表并不详尽,但我们将介绍一种常见的正则化方法,这种方法也被直接集成到网络的架构中。
定义
类似于丢弃法,批量归一化(由 Sergey Ioffe 和 Christian Szegedy 在《批量归一化:通过减少内部协方差偏移加速深度网络训练》一文中提出,JMLR, 2015)是一种可以插入神经网络并影响其训练的操作。该操作获取前一层的批量结果并进行归一化处理;即,减去批量均值并除以批量标准差。
由于在 SGD 中批次是随机采样的(因此很少有两个批次完全相同),这意味着数据几乎永远不会以相同的方式进行归一化。因此,网络必须学习如何处理这些数据波动,使其变得更加健壮和通用。此外,这一步归一化同时改善了梯度在网络中的流动方式,促进了 SGD 过程。
批量归一化层的行为实际上比我们简洁地呈现的要复杂。这些层有一些可训练的参数,用于去归一化操作,以便下一层不会仅仅试图学习如何撤销批量归一化。
TensorFlow 和 Keras 方法
类似于 dropout,批量归一化在 TensorFlow 中既可以作为函数 tf.nn.batch_normalization() 使用(请参阅www.tensorflow.org/api_docs/python/tf/nn/batch_normalization中的文档),也可以作为层 tf.keras.layers.BatchNormalization() 使用(请参阅www.tensorflow.org/api_docs/python/tf/keras/layers/BatchNormalization中的文档),这使得将这一正则化工具轻松地集成到网络中变得更加简单。
所有这些不同的优化技术都是深度学习中的宝贵工具,特别是在处理不平衡或稀缺数据集时训练 CNN 时,这种情况在定制应用中经常发生(如第七章《在复杂和稀缺数据集上的训练》中详细阐述)。
与优化器学习的 Jupyter 笔记本类似,我们提供了另一个笔记本,展示了如何应用这些正则化方法,以及它们如何影响我们简单 CNN 的性能。
总结
在 TensorFlow 和 Keras 的帮助下,我们赶上了深度学习领域多年的研究进展。由于 CNN 已成为现代计算机视觉(以及机器学习一般)的核心,了解它们的性能以及它们由哪些层组成是至关重要的。正如本章所展示的,TensorFlow 和 Keras 提供了清晰的接口,可以高效地构建这样的网络。它们还实现了多种先进的优化和正则化技术(例如各种优化器、L1/L2 正则化、dropout 和批量归一化),以提高训练模型的性能和鲁棒性,这对于任何应用都是非常重要的。
我们现在拥有了最终应对更具挑战性的计算机视觉任务的工具。
在下一章中,我们将介绍几种应用于大规模图像数据集分类任务的 CNN 架构。
问题
-
为什么卷积层的输出宽度和高度比输入小,除非进行了填充?
-
一个大小为 (2, 2),步幅为 2 的最大池化层,作用于图 3.6中的输入矩阵,输出会是什么?
-
如何使用 Keras 函数式 API 以非面向对象的方式实现 LeNet-5?
-
L1/L2 正则化如何影响网络?
进一步阅读
-
关于深度学习中初始化和动量的重要性(
proceedings.mlr.press/v28/sutskever13.pdf),Ilya Sutskever 等人撰写。该篇经常被引用的会议论文于 2013 年发布,提出并比较了动量和 NAG 算法。 -
Dropout: 防止神经网络过拟合的简单方法 (
www.jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf),作者:Nitish Srivastava 等人。该篇 2014 年发布的会议论文介绍了 dropout 方法。对于那些想深入了解这一方法并看到其在多个著名计算机视觉数据集中的应用的读者来说,这是一篇值得一读的好文章。
第二部分:经典识别问题的前沿解决方案
在本节中,您将发现并应用现代方法来解决各种问题。分类作为经典的机器学习任务,将作为一个很好的示例来介绍最新的神经网络架构(如Inception和ResNet)以及迁移学习。目标检测对于自动驾驶汽车和其他机器人非常有用,将通过比较两种广泛使用的算法——YOLO和Faster R-CNN,来说明速度与精度之间的权衡。最后,基于前两章的内容,本节的最后一章将深入介绍应用于图像去噪和语义分割的编码器-解码器网络。
本节将涵盖以下章节:
-
第四章,有影响力的分类工具
-
第五章,目标检测模型
-
第六章,图像增强与分割
第四章:影响力的分类工具
在 2012 年深度学习突破之后,基于卷积神经网络(CNNs)的更精细的分类系统研究获得了动力。如今创新的速度越来越快,因为越来越多的公司在开发智能产品。在多年来为物体分类开发的众多解决方案中,有一些因为其对计算机视觉的贡献而变得非常著名。它们被衍生和改编用于如此多的不同应用,已达到必须了解的地位,因此值得拥有自己的一章。
与这些解决方案所引入的先进网络架构并行,其他方法也被探索用来更好地为 CNN 做好特定任务的准备。因此,在本章的第二部分,我们将探讨如何将网络在特定使用案例中获得的知识转移到新的应用中,以提高性能。
本章将涵盖以下主题:
-
VGG、Inception 和 ResNet 等重要架构对计算机视觉带来了什么
-
这些解决方案如何能够重新实现或直接用于分类任务
-
什么是迁移学习,如何高效地重新利用已训练的网络
技术要求
解释本章概念的 Jupyter notebook 可以在 GitHub 文件夹中找到,地址为 github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2/tree/master/Chapter04。
本章唯一新增的包是 tensorflow-hub。安装说明可以在 www.tensorflow.org/hub/installation 找到(它是一个通过 pip 安装的单行命令:pip install tensorflow-hub)。
理解先进的 CNN 架构
计算机视觉的研究一直在通过渐进式的贡献和重大的创新飞跃不断向前发展。由研究人员和公司组织的挑战赛,邀请专家提交新的解决方案,以便最佳地解决预定任务,这些挑战赛在推动这些关键贡献方面起到了重要作用。ImageNet 大规模视觉识别挑战赛 (ILSVRC); 参见 第一章,计算机视觉与神经网络) 就是一个典型的例子。尽管 2012 年 AlexNet 获得了具有重大意义的胜利,但它依然代表着一个巨大的挑战,挑战的图像库包含数百万张图片,分为 1,000 个细粒度的类别,依然是勇敢的研究者们面临的巨大挑战。
在本节中,我们将介绍一些经典的深度学习方法,这些方法在 AlexNet 之后,针对 ILSVRC 持续进行改进,涵盖了导致这些方法发展的原因以及它们做出的贡献。
VGG – 一种标准的 CNN 架构
我们将介绍的第一个网络架构是VGG(或VGGNet),由牛津大学的视觉几何组(Visual Geometry Group)开发。尽管该小组在 2014 年 ILSVRC 分类任务中仅获得第二名,但他们的方法影响了许多后来的架构。
VGG 架构概述
了解 VGG 作者的动机,再看他们的贡献,我们将展示 VGG 架构如何通过更少的参数实现更高的准确率。
动机
AlexNet 是一次革命性的突破,它是第一个成功训练的 CNN,用于如此复杂的识别任务,并作出了多个至今仍然有效的贡献,诸如以下几点:
-
使用修正线性单元(ReLU)作为激活函数,这有效避免了梯度消失问题(将在本章后面解释),从而提高了训练效果(与使用 sigmoid 或 tanh 相比)
-
在 CNN 中应用dropout(详见第三章,现代神经网络,其中覆盖了所有相关好处)
-
典型的 CNN 架构结合了卷积层和池化层的块,最后通过全连接层进行最终预测
-
对图像进行随机变换(图像平移、水平翻转等),以合成性地扩展数据集(即通过随机编辑原始样本来增加不同的训练图像数量——更多细节请参见第七章,在复杂且稀缺的数据集上训练)
尽管如此,即便在当时,很明显这个原型架构仍有改进的空间。许多研究者的主要动力是尝试让网络更深(也就是构建一个由更多层堆叠组成的网络),尽管这会带来一些挑战。事实上,更多的层通常意味着需要训练更多的参数,这使得学习过程变得更加复杂。然而,正如我们将在下一段中描述的那样,牛津大学 VGG 小组的 Karen Simonyan 和 Andrew Zisserman 成功地解决了这个挑战。他们提交给 ILSVRC 2014 的方法达到了 7.3%的 Top-5 错误率,比 AlexNet 的 16.4%错误率低了超过一半!
Top-5 准确率是 ILSVRC 的主要分类指标之一。如果正确类别在前五个猜测之内,方法就被认为预测正确。事实上,对于许多应用程序来说,能够将大量类别候选缩小到较少的候选类别的方法是可以接受的(例如,可以将剩下的选择留给专家用户)。Top-5 指标是更通用的 top-k 指标的特例。
架构
在他们的论文(《用于大规模图像识别的非常深的卷积网络》, ArXiv, 2014)中,Simonyan 和 Zisserman 展示了他们如何将网络设计得比大多数以前的网络更深。实际上,他们介绍了六种不同的 CNN 架构,从 11 层到 25 层不等。每个网络由五个块组成,每个块包含几个连续的卷积层,后接最大池化层和三个最终的全连接层(训练时采用 dropout)。所有的卷积层和最大池化层都使用SAME作为填充方式。卷积的步长是s = 1,并且使用ReLU函数作为激活函数。总体来说,一个典型的 VGG 网络在下图中表示:

图 4.1:VGG-16 架构
两个最具表现力的架构,时至今日仍被广泛使用,它们被称为VGG-16和VGG-19。这些数字(16 和 19)代表了这些 CNN 架构的深度;即,堆叠在一起的可训练层数。例如,如图 4.1所示,VGG-16 包含 13 个卷积层和 3 个全连接层,因此深度为 16(不包括非可训练操作;即 5 个最大池化层和 2 个 dropout 层)。VGG-19 也是如此,包含额外的三个卷积层。VGG-16 大约有 1.38 亿个参数,而 VGG-19 有 1.44 亿个参数。这些数字相当高,尽管正如我们将在接下来的部分中展示的,VGG 研究人员采取了一种新的方法来保持这些值的控制,尽管其架构深度较大。
贡献 – 标准化 CNN 架构
在接下来的段落中,我们将总结这些研究人员介绍的最重要的贡献,并进一步详细说明他们的架构。
用多个较小的卷积代替较大的卷积
作者从一个简单的观察开始——一堆两个3 × 3核的卷积与一个5 × 5核的卷积具有相同的感受野(请参考第三章, 现代神经网络,了解有效感受野(ERF)公式)。
同样,三个连续的3 × 3卷积会得到7 × 7的感受野,而五个3 × 3操作会得到11 × 11的感受野。因此,尽管 AlexNet 使用了较大的滤波器(最大11 × 11),VGG 网络却包含更多数量但较小的卷积,以获得更大的 ERF。这一变化的好处有两个:
-
它减少了参数的数量:实际上,11 × 11卷积层的N个滤波器意味着需要训练11 × 11 × D × N = 121D**N个值(其中D为输入的深度),而五个3 × 3卷积层则需要1 × (3 × 3 × D × N**) + 4 × (3 × 3 × N × N**) = 9DN + 36**N²个权重(用于它们的滤波器)。只要N < 3.6**D,这意味着参数会更少。例如,当N = 2**D时,参数的数量从242**D²降到153**D²(参考前面的公式)。这使得网络更容易优化,而且更加轻量(我们邀请你查看7 × 7和5 × 5卷积替换后的下降情况)。
-
它增加了非线性:拥有更多的卷积层——每一层后面跟着一个非线性激活函数,如ReLU——增加了网络学习复杂特征的能力(即,通过结合更多的非线性操作)。
总的来说,用小的连续卷积替代更大的卷积,使得 VGG 的作者能够有效地加深网络。
增加特征图的深度
基于另一种直觉,VGG 的作者将每个卷积块的特征图深度加倍(从第一次卷积后的 64 增加到 512)。由于每一组后面都有一个2 × 2的最大池化层,并且步幅为 2,深度加倍,而空间维度则减半。
这使得空间信息能够编码为越来越复杂且具有区分度的特征,用于分类。
使用尺度抖动进行数据增强
Simonyan 和 Zisserman 还引入了一种他们称之为数据增强的机制,名为尺度抖动。在每次训练迭代中,他们随机缩放批处理图像(将其较小的一边从 256 像素缩放到 512 像素),然后将其裁剪到适当的输入尺寸(他们的 ILSVRC 提交使用224 × 224)。通过这种随机变换,网络将面临不同尺度的样本,并学习尽管存在尺度抖动,仍然正确分类这些样本(参见图 4.2)。因此,网络变得更加稳健,因为它是在涵盖更多现实变换范围的图像上进行训练的。
数据增强是通过对图像应用随机变换,从而合成地增加训练数据集大小的过程,以创建不同版本的图像。详细信息和具体示例可参见第七章,复杂和稀缺数据集的训练。
作者们还建议在测试时应用随机缩放和裁剪。这个想法是通过这种方式生成查询图像的多个版本,并将它们全部输入网络,直觉是这样可以增加将内容以网络特别适应的尺度输入的机会。最终的预测是通过对每个版本的结果进行平均得到的。
在他们的论文中,他们展示了这一过程如何有助于提高准确性:

图 4.2:尺度抖动的示例。注意,通常不会保持内容的纵横比,以进一步转换图像。
这一原则之前被 AlexNet 的作者使用。在训练和测试过程中,他们为每张图像生成了多个版本,通过不同的裁剪和翻转变换的组合。
用卷积替代全连接层
虽然经典的 VGG 架构以多个全连接(FC)层(如 AlexNet)结束,但作者建议了一种替代版本。在这个版本中,全连接层被卷积层所替代。
第一组使用较大卷积核(7 × 7 和 3 × 3)的卷积操作将特征图的空间大小减少到 1 × 1(之前没有应用填充),并将其深度增加到 4,096。最后,使用一个 1 × 1 的卷积,其滤波器数量等于要预测的类别数(即,N = 1,000 用于 ImageNet)。最终的 1 × 1 × N 向量通过 softmax 函数进行归一化,然后将其展开成最终的类别预测(向量的每个值表示预测的类别概率)。
1 × 1 卷积通常用于改变输入体积的深度,而不影响其空间结构。对于每个空间位置,新值是从该位置所有深度值的插值计算出来的。
没有任何全连接层的网络被称为全卷积网络(FCN)。正如在第三章《现代神经网络》中提到的,并且正如 VGG 作者所强调的那样,FCN 可以应用于不同大小的图像,而无需提前裁剪。
有趣的是,为了在 ILSVRC 中获得最佳准确率,作者同时训练并使用了两种版本(普通版和 FCN),再次通过平均它们的结果来获得最终的预测。这种技术被称为模型平均,在生产中经常使用。
TensorFlow 和 Keras 的实现
由于作者在创建清晰架构方面的努力,VGG-16 和 VGG-19 是最容易重新实现的分类器之一。示例代码可以在本章的 GitHub 文件夹中找到,供教学用途。然而,在计算机视觉领域,像许多其他领域一样,通常建议不要重新发明轮子,而是重用现有的工具。以下段落展示了不同的预实现 VGG 解决方案,您可以直接调整并重用。
TensorFlow 模型
虽然 TensorFlow 本身没有提供 VGG 架构的官方实现,但在tensorflow/models GitHub 库中可以找到精心实现的 VGG-16 和 VGG-19 网络(github.com/tensorflow/models)。这个由 TensorFlow 贡献者维护的库包含了许多精心策划的先进模型或实验性模型。通常建议在寻找特定网络时,应该搜索这个库。
我们邀请读者查看那里的 VGG 代码(目前可在github.com/tensorflow/tensorflow/blob/master/tensorflow/contrib/slim/python/slim/nets/vgg.py找到),它重新实现了我们之前描述的 FCN 版本。
Keras 模型
Keras API 提供了这些架构的官方实现,可以通过其tf.keras.applications包访问(请参见文档:www.tensorflow.org/api_docs/python/tf/keras/applications)。该包包含了其他几个著名的模型,并为每个模型提供了预训练参数(即从先前在特定数据集上训练中保存的参数)。例如,你可以使用以下命令实例化一个 VGG 网络:
vgg_net = tf.keras.applications.VGG16(
include_top=True, weights='imagenet', input_tensor=None,
input_shape=None, pooling=None, classes=1000)
使用这些默认参数,Keras 会实例化 VGG-16 网络并加载在 ImageNet 上经过完整训练周期后保存的参数值。通过这一条命令,我们得到了一个准备好将图像分类为 1,000 个 ImageNet 类别的网络。如果我们想要重新从头训练网络,我们应该将weights=None,Keras 将随机设置权重。
在 Keras 术语中,top层对应于最后一层的连续全连接层。因此,如果我们设置include_top=False,VGG 的全连接层将被排除,网络的输出将是最后一个卷积/最大池化块的特征图。如果我们想要重用预训练的 VGG 网络来提取有意义的特征(这些特征可以应用于更高级的任务),而不仅仅是用于分类,这时可以使用pooling函数参数(即当include_top=False时)来指定在返回特征图之前对其进行的可选操作(pooling='avg'或pooling='max',用于应用全局平均池化或最大池化)。
GoogLeNet 和 inception 模块
由谷歌研究人员开发的架构,我们现在将要介绍的这一架构也被应用于 ILSVRC 2014,并在分类任务中超越了 VGGNet,获得了第一名。GoogLeNet(源自Google和LeNet,向这一开创性网络致敬)在结构上与其线性对手有很大不同,引入了inception blocks的概念(该网络也通常被称为inception network)。
GoogLeNet 架构概述
正如我们将在接下来的章节中看到的,GoogLeNet 的作者 Christian Szegedy 等人,从一个与 VGG 研究人员截然不同的角度出发,构思了一个更高效的 CNN(深入卷积网络,CVPR IEEE 会议论文,2014)。
动机
虽然 VGG 的作者基于 AlexNet 并致力于标准化和优化其结构,以获得更清晰和更深的架构,谷歌的研究人员则采取了不同的方法。正如论文中提到的,他们的第一个考虑因素是优化 CNN 的计算开销。
实际上,尽管经过精心设计(参见 VGG),CNN 越深,训练参数的数量和每次预测的计算量就越大(在内存和时间上都很昂贵)。例如,VGG-16 大约占 93 MB(参数存储方面),而 VGG 在 ILSVRC 的提交需要两到三周的时间,在四个 GPU 上进行训练。GoogLeNet 拥有大约 500 万个参数,比 AlexNet 轻 12 倍,比 VGG-16 轻 21 倍,而且该网络在一周内就能训练完成。因此,GoogLeNet——以及更近期的 inception 网络——甚至可以在较为普通的机器(如智能手机)上运行,这也促进了它们的长期流行。
我们必须记住,尽管在参数和操作数量上大幅减少,GoogLeNet 仍然在 2014 年的分类挑战中获胜,具有 6.7% 的 top-5 错误率(而 VGG 为 7.3%)。这一表现是 Szegedy 等人第二个目标的结果——构建一个不仅更深而且更大的网络,具有并行层块用于 多尺度处理。虽然我们将在本章后面详细介绍这个解决方案,但其直觉其实很简单。构建一个 CNN 是一项复杂的迭代任务。我们如何知道应该在堆叠中添加哪个层(如卷积层或池化层)以提高准确性?我们如何知道哪个卷积核尺寸最适合某一层?毕竟,不同尺寸的卷积核对不同尺度的特征反应不同。我们如何避免这种取舍?根据作者的说法,一个解决方案是使用他们开发的 * inception 模块*,由多个不同的层并行工作组成。
架构
如图 4.3所示,GoogLeNet 架构不像我们之前学习的架构那样简单,尽管可以按区域逐一分析。输入图像首先通过经典的卷积层和最大池化层系列进行处理。然后,信息经过九个 Inception 模块的堆叠。这些模块(通常称为子网络;详见图 4.4)是垂直和水平方向堆叠的层块。对于每个模块,输入特征图会传递到由一个或两个不同层(具有不同卷积核大小的卷积和最大池化)组成的四个并行子块。
这四个并行操作的结果会沿深度维度被串联在一起,形成一个单一的特征体积:

图 4.3:GoogLeNet 架构。Inception 模块详见图 4.4
在前面的图中,所有的卷积层和最大池化层都使用了SAME填充。卷积层如果没有特别说明,s = 1(步幅为 1),并使用ReLU函数作为激活函数。
该网络由几个层块组成,这些层块共享相似的结构,并具有并行层——即 Inception 模块。例如,第一 Inception 模块,如图 4.3所示,接收一个大小为28 × 28 × 192的特征体积作为输入。它的第一个并行子块,由一个1 × 1卷积输出组成(N = 64,s = 1),因此生成一个28 × 28 × 64的张量。同样,第二个子模块,由两个卷积组成,输出一个28 × 28 × 128的张量;剩下的两个子模块分别输出一个28 × 28 × 32和28 × 28 × 32的特征体积。因此,通过将这四个结果沿最后一个维度堆叠,第一 Inception 模块输出一个28 × 28 × 256的张量,然后将其传递到第二模块,以此类推。在下面的图示中,左侧表示的是朴素解法,右侧显示的是 GoogLeNet 中使用的模块(即 Inception 模块 v1)(请注意,在 GoogLeNet 中,随着模块的深度增加,滤波器数量N也会增加):

图 4.4:Inception 模块:朴素解法与实际解法
最后一个模块的特征通过平均池化从7 × 7 × 1,024池化到1 × 1 × 1,024,并最终密集地转换为预测向量。如图 4.3所示,网络进一步由两个辅助分支组成,这些分支也指向预测。它们的目的将在下一节中详细说明。
总的来说,GoogLeNet 是一个 22 层深的架构(仅计算可训练的层),总共有超过 60 个卷积层和全连接层。尽管如此,这个更大的网络参数量比 AlexNet 少了 12 倍。
贡献 – 推广更大块和瓶颈结构
低数量的参数以及网络的性能是 GoogLeNet 作者实现的多个概念的结果。我们将在本节中介绍这些主要概念。
本节中,我们将仅介绍与之前介绍的网络不同的关键概念。请注意,GoogLeNet 的作者重新应用了我们已覆盖的几种其他技术,例如对每个输入图像进行多个裁剪的预测以及在训练期间使用其他图像变换。
使用 inception 模块捕获各种细节
由 Min Lin 等人在其具有影响力的 Network in Network (NIN) 论文中于 2013 年提出,构建由子网络模块组成的 CNN 的理念被 Google 团队采用并充分利用。如前所述并在图 4.4中展示,他们开发的基本 inception 模块由四个并行层组成——三个卷积层,滤波器大小分别为 1 × 1、3 × 3 和 5 × 5,以及一个步长为 1 的最大池化层。此并行处理的优势,在结果拼接后,显而易见。
正如在动机小节中所解释的,这种架构允许对数据进行多尺度处理。每个 inception 模块的结果结合了不同尺度的特征,捕获了更广泛的信息。我们不需要选择哪个卷积核大小可能是最好的(这样的选择需要进行多次训练和测试迭代),即网络会自己学习在每个模块中依赖哪些卷积。
此外,虽然我们展示了如何通过垂直堆叠具有非线性激活函数的层来积极影响网络性能,但对于水平组合,情况也是如此。来自不同层的特征拼接进一步增强了卷积神经网络(CNN)的非线性特性。
使用 1 × 1 卷积作为瓶颈
尽管这本身不是一种贡献,Szegedy 等人通过高效地将其应用于他们的网络,使得以下技术广为人知。
如在用卷积替代全连接层部分中提到的,1 × 1 卷积层(步长为 1)常用于在不影响输入空间结构的情况下改变输入体积的整体深度。这样的层具有 N 个滤波器,输入形状为 H × W × D,并返回一个插值后的 H × W × N 张量。对于输入图像中的每个像素,其 D 通道的值将通过该层(根据其滤波器权重)被插值为 N 个通道值。
这一特性可以应用于通过预先压缩特征深度(使用N < D)来减少更大卷积所需的参数数量。该技术基本上使用1 × 1的卷积作为瓶颈(即作为中间层,降低维度,从而减少参数数量)。由于神经网络中的激活通常是冗余的或未被使用,这样的瓶颈通常几乎不影响性能(只要它们不会大幅度减少深度)。此外,GoogLeNet 有并行层来补偿深度的减少。实际上,在 Inception 网络中,每个模块中都有瓶颈,出现在所有较大卷积之前和最大池化操作之后,如图 4.4所示。
以第一个 Inception 模块中的5 × 5卷积为例(输入为28 × 28 × 192的体积),其过滤器的张量在传统版本中将是5 × 5 × 192 × 32的维度。仅这个卷积就代表了 153,600 个参数。在 Inception 模块的第一个版本(即带瓶颈的版本)中,在 5 × 5 卷积之前引入了一个1 × 1的卷积,其中N = 16。结果,这两个卷积的总参数为1 × 1 × 192 × 16 + 5 × 5 × 16 × 32 = 15,872个可训练的核值。这比之前版本(仅对于这个单独的5 × 5层)少了 10 倍的参数,且输出大小相同!此外,正如前面所提到的,添加具有非线性激活函数(ReLU)的层进一步提高了网络抓取复杂概念的能力。
本章展示的是 GoogLeNet 提交给 ILSVRC 2014 的版本。更常被称为Inception V1,该架构自那时以来已被作者进一步改进。Inception V2和Inception V3包含了几个改进,例如将5 × 5和7 × 7卷积替换为更小的卷积(如 VGG 中所做的),改进瓶颈的超参数以减少信息损失,并添加了BatchNorm层。
使用池化代替全连接
为了减少参数的数量,Inception 的作者们采用了一个解决方案,即在最后一个卷积块之后使用平均池化层,而不是全连接层。通过使用7 × 7的窗口大小和步长为 1,该层将特征体积从7 × 7 × 1,024减少到1 × 1 × 1,024,而无需训练任何参数。如果使用全连接层,则会增加(7 × 7 × 1,024)× 1,024 = 51,380,224个参数。尽管使用这个替代方法网络的表达能力有所降低,但计算上的提升是巨大的(并且网络已经包含了足够的非线性操作来捕捉最终预测所需的信息)。
GoogLeNet 中的最后一层全连接层(FC 层)具有1,024 × 1,000 = 1,024,000个参数,占网络总参数的五分之一!
通过中间损失来应对梯度消失问题
如在介绍架构时简要提到的,GoogLeNet 在训练时有两个辅助分支(训练后移除),也用于生成预测。
它们的目的是在训练过程中改善损失在网络中的传播。事实上,较深的 CNN 经常会遇到梯度消失问题。许多 CNN 操作(例如,sigmoid)的导数幅度较小(小于一)。因此,层数越高,反向传播时导数的乘积就会变得越小(因为更多小于一的值相乘,结果会更接近零)。通常,梯度在到达第一层时会消失或缩小为零。由于梯度值直接用于更新参数,如果梯度太小,这些层将无法有效学习。
另一个相反的现象——梯度爆炸问题——也可能发生在更深的网络中。当使用导数可能具有更大幅度的操作时,它们在反向传播时的乘积可能会变得非常大,以至于导致训练不稳定(权重更新剧烈且不稳定),甚至有时会溢出(NaN值)。
这里实现的解决方案非常务实且有效:通过在不同的网络深度引入额外的分类损失,减少第一层与预测之间的距离。如果最终损失的梯度无法正常流向第一层,那么这些层仍然会通过更靠近的中间损失进行训练,从而有助于分类。顺便提一下,这个解决方案还略微提高了受多个损失影响的层的鲁棒性,因为它们必须学习提取既对主网络有用,又对较短分支有用的判别特征。
在 TensorFlow 和 Keras 中的实现
尽管 Inception 架构乍一看可能显得复杂,但我们已经拥有大部分实现它的工具。此外,TensorFlow 和 Keras 也提供了几个预训练版本。
使用 Keras 功能 API 的 Inception 模块
到目前为止,我们实现的网络完全是顺序结构,从输入到预测只有一条路径。与此不同,Inception 模型有多个并行的层和分支。这使得我们有机会展示,这种操作图形在现有的 API 下并没有比其他网络更难实例化。在接下来的部分,我们将使用 Keras 功能 API 编写一个 Inception 模块(参见文档:keras.io/getting-started/sequential-model-guide/)。
到目前为止,我们主要使用的是 Keras Sequential API,而它并不太适合多路径架构(正如其名字所暗示的那样)。Keras Functional API 更接近 TensorFlow 的范式,通过将每一层的 Python 变量作为参数传递给下一层来构建图形。以下代码展示了使用这两种 API 实现的一个简化模型:
from keras.models import Sequential, Model
from keras.layers import Dense, Conv2D, MaxPooling2D, Flatten, Input
# Sequential version:
model = Sequential()
model.add(Conv2D(32, kernel_size=(5, 5), input_shape=input_shape))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Flatten())
model.add(Dense(10, activation='softmax'))
# Functional version:
inputs = Input(shape=input_shape)
conv1 = Conv2D(32, kernel_size=(5, 5))(inputs)
maxpool1 = MaxPooling2D(pool_size=(2, 2))(conv1)
predictions = Dense(10, activation='softmax')(Flatten()(maxpool1))
model = Model(inputs=inputs, outputs=predictions)
使用功能性 API,可以轻松地将一个层传递给多个其他层,这正是我们需要用于 Inception 模块的并行块。它们的结果可以通过 concatenate 层合并在一起(参见文档 keras.io/layers/merge/#concatenate_1)。因此,图 4.4 中展示的简单 Inception 块可以按如下方式实现:
from keras.layers import Conv2D, MaxPooling2D, concatenate
def naive_inception_block(previous_layer, filters=[64, 128, 32]):
conv1x1 = Conv2D(filters[0], kernel_size=(1, 1), padding='same',
activation='relu')(previous_layer)
conv3x3 = Conv2D(filters[1], kernel_size=(3, 3), padding='same',
activation='relu')(previous_layer)
conv5x5 = Conv2D(filters[2], kernel_size=(5, 5), padding='same',
activation='relu')(previous_layer)
max_pool = MaxPooling2D((3, 3), strides=(1, 1),
padding='same')(previous_layer)
return concatenate([conv1x1, conv3x3, conv5x5, max_pool], axis=-1)
我们将留给你自己来调整这段代码,以通过添加瓶颈层来实现 Inception V1 的正确模块。
TensorFlow 模型和 TensorFlow Hub
Google 提供了几种脚本和教程,解释如何直接使用其 Inception 网络,或者如何对其进行重新训练以适应新的应用。tensorflow/models Git 仓库中专门针对这一架构的目录(github.com/tensorflow/models/tree/master/research/inception)也非常丰富且文档详尽。此外,Inception V3 的预训练版本已在 TensorFlow Hub 上提供,这为我们介绍这一平台提供了机会。
TensorFlow Hub 是一个预训练模型的仓库。类似于 Docker 允许人们轻松共享和重用软件包,避免重新配置分发,TensorFlow Hub 提供了访问预训练模型的功能,让人们无需花时间和资源去重新实现和重新训练模型。它结合了一个网站(tfhub.dev),用户可以在其中搜索特定的模型(例如,基于目标识别任务),以及一个 Python 包来轻松下载和开始使用这些模型。例如,我们可以按如下方式获取并设置 Inception V3 网络:
import tensorflow as tf
import tensorflow_hub as hub
url = "https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2"
hub_feature_extractor = hub.KerasLayer( # TF-Hub model as Layer
url, # URL of the TF-Hub model (here, an InceptionV3 extractor)
trainable=False, # Flag to set the layers as trainable or not
input_shape=(299, 299, 3), # Expected input shape (found on tfhub.dev)
output_shape=(2048,), # Output shape (same, found on the model's page)
dtype=tf.float32) # Expected dtype
inception_model = Sequential(
[hub_feature_extractor, Dense(num_classes, activation='softmax')],
name="inception_tf_hub")
尽管这段代码相当简洁,但实际上发生了很多事情。初步步骤是浏览 tfhub.dev 网站,并在其中选择一个模型。在展示所选模型的页面上(tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2; 存储在 model_url 中),我们可以看到所选的 Inception 模型被定义为 图像特征向量,它期望接收 299 × 299 × 3 的输入,此外还有其他一些细节。要使用 TensorFlow Hub 模型,我们需要知道如何与其交互。
图像特征向量类型告诉我们该网络返回的是提取的特征;也就是说,它返回的是在密集操作之前,最后一个卷积块的输出。使用这样的模型,我们可以自行添加最终层(例如,确保输出大小与考虑的类别数量相符)。
TensorFlow Hub 接口的最新版本与 Keras 完美兼容,并且可以通过 tensorflow_hub.KerasLayer(model_url, trainable, ...) 将完整的预训练 TensorFlow Hub 模型提取并实例化为 Keras 层。像任何 Keras 层一样,它可以在更大的 Keras 模型或 TensorFlow 估算器中使用。
尽管这可能看起来不像使用 Keras Applications API 那么直接,TensorFlow Hub 拥有一个异国情调的模型目录,并且预计随着时间的推移会不断增加。
Git 仓库中提供的一个 Jupyter notebook 专门介绍了 TensorFlow Hub 及其使用方法。
Keras 模型
与 VGG 一样,Keras 提供了 Inception V3 的实现,并且可以选择使用在 ImageNet 上预训练的权重。tf.keras.applications.InceptionV3()(参见文档:keras.io/applications/#inceptionv3)的函数签名与 VGG 的一致。
我们提到了 AlexNet,这是 2012 年 ILSVRC 的获胜解决方案,以及 VGGNet 和 GoogLeNet,它们在 2014 年的比赛中脱颖而出。你可能会想知道 2013 年是谁赢得了比赛。那一年的挑战赛被 ZFNet 架构主导(以其创建者 Matthew Zeiler 和 Rob Fergus 为名,二人来自纽约大学)。如果本章没有介绍 ZFNet,那是因为其架构并不特别创新,并且此后并未广泛使用。
然而,Zeiler 和 Fergus 的重要贡献在于他们在其他方面的工作——他们开发并应用了多个卷积神经网络(CNN)可视化操作(如反池化和转置卷积,也称为反卷积,这两者在第六章《增强与图像分割》中有详细介绍)。事实上,神经网络的一个常见批评是它们像黑盒子一样运行,没人真正理解它们为什么以及如何如此有效。Zeiler 和 Fergus 的工作是揭示 CNN 内部过程的重要第一步(例如,它们是如何对特定特征作出反应的,以及它们如何随着层次加深学习更加抽象的概念)。通过可视化每一层网络对特定图像的反应及其对最终预测的贡献,作者能够优化超参数,从而提高模型性能(《卷积神经网络的可视化与理解》,Springer,2014)。
对神经网络的理解研究仍在进行中(例如,最近的许多工作捕捉并分析了网络对特定元素的注意力),并且已经极大地帮助改进了当前的系统。
ResNet —— 残差网络
本章中我们将讨论的最后一个架构,在 2015 年的 ILSVRC 中获胜。它由一种新型模块——残差模块组成,ResNet(残差网络)提供了一种高效的方法来构建非常深的网络,在性能上超越了更大的模型,如 Inception。
ResNet 架构概述
由微软研究员 Kaiming He 等人开发的 ResNet 架构,是一个有趣的解决方案,旨在解决影响 CNN 的学习问题。按照前述章节的结构,我们将首先明确作者的目标,并介绍他们的新颖架构(参见深度残差学习用于图像识别,CVPR IEEE 会议论文集,2016)。
动机
Inception 网络证明,增加网络规模是图像分类以及其他识别任务中的一种有效策略。然而,专家们仍然不断尝试通过增加网络的深度来解决越来越复杂的任务。然而,He 等人在论文前言中提出的那个问题“学习更好的网络就像堆叠更多的层一样简单吗?”是有道理的。
我们已经知道,网络越深,训练就越困难。但除了梯度消失/爆炸问题(已有其他解决方案处理过),He 等人指出了深度卷积神经网络(CNN)面临的另一个问题——性能退化。这一切源于一个简单的观察——CNN 的准确率并不会随着新层的增加而线性提高。随着网络深度的增加,出现了退化问题。准确率开始饱和甚至下降。即使是训练损失,在不加注意地堆叠过多层时,也开始下降,证明问题并不是由于过拟合引起的。例如,作者将 18 层深的 CNN 和 34 层深的 CNN 进行了比较,结果显示后者在训练过程中以及训练后表现都不如较浅的版本。在他们的论文中,He 等人提出了解决方案,以构建非常深且具有良好性能的网络。
通过模型平均(应用不同深度的 ResNet 模型)和预测平均(对每个输入图像的多个裁剪结果进行预测平均),ResNet 作者在 ILSVRC 挑战中达到了历史最低的 3.6% 的 top-5 错误率。这是首次有算法在该数据集上超过了人类。挑战组织者曾测量人类的表现,最佳人类候选者的错误率为 5.1%(参见ImageNet 大规模视觉识别挑战,Springer,2015)。在这样的任务中实现超越人类的表现,是深度学习的一个巨大里程碑。然而,我们仍需牢记,尽管算法能够熟练地解决特定任务,但它们仍不具备将这些知识扩展到其他任务,或者理解所处理数据的上下文的能力。
架构
与 Inception 类似,ResNet 也经历了多次架构的迭代改进,例如添加了瓶颈卷积或使用了更小的卷积核。与 VGG 类似,ResNet 也有多个伪标准化版本,特点是其深度:ResNet-18、ResNet-50、ResNet-101、ResNet-152 等。事实上,2015 年 ILSVRC 的获胜 ResNet 网络堆叠了 152 个可训练层(总共有 6000 万个参数),这是当时一个令人印象深刻的成就:

图 4.5: 示例 ResNet 架构
在前面的图示中,所有卷积层和最大池化层的填充方式都是SAME,并且步幅未指定时为s = 1。每个3 × 3卷积后都应用批量归一化(在残差路径上,灰色部分),而1 × 1卷积(在映射路径上,黑色部分)没有激活函数(为恒等映射)。
如我们在图 4.5中看到的,ResNet 架构比 Inception 架构更简洁,尽管它也由具有并行操作的层块组成。与 Inception 不同的是,每个并行层会非线性地处理输入信息,而 ResNet 的模块由一个非线性路径和一个恒等路径组成。前者(由图 4.5中较细的灰色箭头表示)对输入特征图进行若干次卷积、批量归一化和ReLU激活。后者(由较粗的黑色箭头表示)则简单地转发特征而不进行任何转换。
最后一条陈述并不总是正确的。如图 4.5所示,当特征的深度通过非线性分支并行增加时,使用1 × 1卷积来调整特征的深度。在这种情况下,为了避免参数数量的急剧增加,空间维度在两侧也使用步幅s = 2 进行减小。
与 Inception 模块一样,来自每个分支的特征图(即转换后的特征和原始特征)在传递到下一个模块之前会被合并在一起。然而,与 Inception 模块不同的是,这种合并并不是通过深度连接来实现的,而是通过逐元素加法(这是一种简单的操作,不需要额外的参数)。我们将在接下来的部分中探讨这些残差模块的优势。
请注意,在大多数实现中,每个残差块的最后一个3 × 3卷积后并不会直接跟随ReLU激活。相反,非线性函数是在与恒等路径合并之后才应用的。
最后,来自最后一个模块的特征经过平均池化并密集地转换成预测结果,如同在 GoogLeNet 中一样。
贡献 – 更深入地传递信息
残差模块对机器学习和计算机视觉作出了重要贡献。在接下来的部分,我们将探讨这一点的原因。
估计残差函数而非映射
正如 ResNet 的作者所指出的,如果层能够轻松地学习恒等映射(也就是说,如果一组层能够学习权重,使得它们的一系列操作最终返回与输入层相同的张量),则不会发生退化现象。
事实上,作者们认为,当在 CNN 上添加一些层时,如果这些额外的层能够收敛到恒等函数,我们应该至少获得相同的训练/验证误差。它们至少会学习将原始网络的结果传递下去,而不会使其退化。由于我们经常可以观察到退化现象,这意味着 CNN 层并不容易学习到恒等映射。
这促使了引入残差块的想法,残差块有两条路径:
-
一条路径进一步处理数据,增加了一些卷积层
-
一条路径执行恒等映射(也就是,直接转发数据,不做任何更改)
我们可以直观地理解这如何解决退化问题。当在 CNN 上添加一个残差块时,至少可以通过将处理分支的权重设置为零来保持原有的性能,从而只留下预定义的恒等映射。只有当处理路径有助于最小化损失时,它才会被考虑。
数据转发路径通常被称为跳跃路径或快捷方式。处理路径通常被称为残差****路径,因为其操作的输出会被加到原始输入上,当恒等映射接近最优时,处理后的张量的大小比输入的张量小得多(因此使用了残差这一术语)。总体来说,这条残差路径仅对输入数据引入微小的变化,使得它能够将模式传递到更深的层。
在他们的论文中,何等人展示了他们的架构不仅解决了退化问题,而且他们的 ResNet 模型在相同层数下比传统模型的准确率更高。
极深网络
还值得注意的是,残差块比传统块不包含更多的参数,因为跳跃和加法操作不需要额外的参数。因此,它们可以高效地作为超深网络的构建块。
除了应用于 ImageNet 挑战的 152 层网络外,作者们还通过训练一个令人印象深刻的 1,202 层网络来展示他们的贡献。他们报告说,训练这样一个庞大的卷积神经网络(CNN)没有遇到困难(尽管其验证准确率略低于 152 层网络,可能是因为过拟合)。
更近的研究开始探索利用残差计算来构建更深更高效的网络,例如高速公路网络(带有可训练的开关值来决定每个残差块应使用哪个路径)或DenseNet模型(在块之间添加更多的跳跃连接)。
TensorFlow 和 Keras 的实现
与之前的架构一样,我们已经具备了重新实现 ResNet 所需的工具,并且可以直接访问预实现/预训练版本。
使用 Keras 函数式 API 实现残差块
作为练习,让我们自己实现一个基本的残差块。如图 4.5所示,残差路径由两个卷积层组成,每个卷积层后跟一个批量归一化层。ReLU 激活函数直接应用于第一个卷积层之后。对于第二个卷积层,激活函数仅在与另一条路径合并之后应用。使用 Keras 函数式 API,残差路径可以通过五六行代码轻松实现,具体代码如下所示。
快捷路径甚至更简单。它要么没有任何层,要么只有一个 1 × 1 的卷积层,用于在残差路径改变输入张量的维度时(例如,当使用更大的步幅时)进行重塑。
最后,将两条路径的结果相加,并对和应用ReLU函数。总的来说,一个基本的残差块可以如下实现:
from tf.keras.layers import Activation, Conv2D, BatchNormalization, add
def residual_block_basic(x, filters, kernel_size=3, strides=1):
# Residual Path:
conv_1 = Conv2D(filters=filters, kernel_size=kernel_size,
padding='same', strides=strides)(x)
bn_1 = BatchNormalization(axis=-1)(conv_1)
act_1 = Activation('relu')(bn_1)
conv_2 = Conv2D(filters=filters, kernel_size=kernel_size,
padding='same', strides=strides)(act_1)
residual = BatchNormalization(axis=-1)(conv_2)
# Shortcut Path:
shortcut = x if strides == 1 else Conv2D(
filters, kernel_size=1, padding='valid', strides=strides)(x)
# Merge and return :
return Activation('relu')(add([shortcut, residual]))
在一个 Jupyter notebook 中展示了一个更优雅的函数。该 notebook 还包含了 ResNet 架构的完整实现和一个分类问题的简要演示。
TensorFlow 模型与 TensorFlow Hub
和 Inception 网络一样,ResNet 网络也有自己的官方实现,提供在 tensorflow/models Git 仓库中,并且有自己的预训练 TensorFlow Hub 模块。
我们邀请你查看官方的 tensorflow/models 实现,因为它提供了多个来自最新研究的残差块类型。
Keras 模型
最后,Keras 再次提供了自己的 ResNet 实现——例如,tf.keras.applications.ResNet50()(请参考 keras.io/applications/#resnet50 中的文档)——并可以加载在 ImageNet 上预训练的参数。这些方法的签名与之前介绍的 Keras 应用程序相同。
这个 Keras 应用程序的完整代码也已提供在 Git 仓库中。
本章中展示的 CNN 架构列表并不声称是详尽无遗的。它经过精心挑选,涵盖了计算机视觉领域中的重要解决方案和具有教学价值的内容。
随着视觉识别领域的研究不断快速进展,越来越多的先进架构应运而生,这些架构是在之前的解决方案(例如 Highway 和 DenseNet 方法对 ResNet 的改进)基础上提出的,或通过合并它们(如 Inception-ResNet 解决方案)而成,或为特定的使用场景进行了优化(如专为手机运行的轻量级 MobileNet)。因此,在尝试重新发明轮子之前,查看一下当前的技术前沿总是个不错的主意(例如,在官方仓库或研究期刊中)。
利用迁移学习
这种重新利用他人提供的知识的理念不仅在计算机科学中非常重要。人类技术的发展几千年来,正是源于我们将知识从一代传递到另一代、从一个领域传递到另一个领域的能力。许多研究人员认为,将这一理念应用于机器学习,可能是开发更高效系统的关键之一,使其能够解决新任务,而无需从头开始重新学习一切。
因此,本节将介绍迁移学习对人工神经网络的意义,以及它如何应用于我们的模型。
概述
我们将首先介绍什么是迁移学习,以及根据不同的应用场景,它是如何在深度学习中实现的。
定义
在本章的第一部分,我们介绍了几种为 ImageNet 分类挑战赛开发的著名 CNN。我们提到这些模型通常被重新利用到更广泛的应用中。在接下来的章节中,我们将最终详细说明这种重新调适的原因及其实施方法。
人类启示
就像许多机器学习的发展一样,迁移学习的灵感来源于我们人类处理复杂任务和获取知识的方式。
如本节引言中所提到的,第一个灵感来源是我们作为一个物种将知识从一个个体转移到另一个个体的能力。专家可以通过口头或书面教学,迅速将他们多年来积累的宝贵知识传授给大量学生。通过利用一代又一代积累并提炼的知识,人类文明能够不断精炼并扩展其技术能力。我们祖先花费千年才理解的现象——如人类生物学、太阳系等——已成为常识。
此外,作为个体,我们也具备将某些专长从一项任务转移到另一项任务的能力。例如,掌握一门外语的人更容易学习类似的语言。类似地,已经有一段时间驾驶汽车的人,已经掌握了交通规则和一些相关的反应技能,这些对他们学习驾驶其他车辆非常有帮助。
这些通过利用已有知识来掌握复杂任务的能力,以及将获得的技能转用于相似活动的能力,是人类智慧的核心。机器学习领域的研究人员梦寐以求能够复制这些能力。
动机
与人类不同,迄今为止,大多数机器学习系统都是为单一特定任务设计的。直接将训练好的模型应用到不同的数据集上通常会得到较差的结果,尤其是当数据样本不共享相同的语义内容(例如,MNIST 手写数字图像与 ImageNet 照片)或相同的图像质量/分布(例如,智能手机图片数据集与高质量图片数据集)时。由于 CNN 被训练来提取和解释特定特征,因此如果特征分布发生变化,它们的性能将会受到影响。因此,必须进行一些转换才能将网络应用于新任务。
解决方案已经被研究了几十年。在 1998 年,Sebastian Thrun 和 Lorien Pratt 编辑了Learning to Learn,一本汇编了该主题流行研究观点的书籍。最近,在他们的Deep Learning书中(www.deeplearningbook.org/contents/representation.html,MIT 出版社,第 534 页),Ian Goodfellow、Yoshua Bengio 和 Aaron Courville 将迁移学习定义如下:
[...] 这种情况是指在一个设置中学到的知识(例如,分布 p[1])被用来提高另一个设置中的泛化能力(例如,分布 p[2])。
对研究人员来说,假设例如 CNN 提取的一些特征可以部分重用于分类手写文本是合理的,尤其是手写数字分类。类似地,一个学习了检测人脸的网络可以部分用于评估面部表情。事实上,尽管输入(用于人脸检测的完整图像与新任务的裁剪图像)和输出(检测结果与分类值)不同,但该网络的一些层已经被训练来提取面部特征,这对这两项任务都有帮助。
在机器学习中,任务由提供的输入(例如,智能手机的图片)和期望的输出(例如,特定类别集的预测结果)定义。例如,ImageNet 上的分类和检测是两个不同的任务,尽管它们使用相同的输入图像,但输出却不同。
在某些情况下,算法可以针对相似的任务(例如,行人检测),但使用不同的数据集(例如,不同位置的 CCTV 图像,或不同质量的摄像头图像)。因此,这些方法是在不同的领域(即,数据分布)上进行训练的。
迁移学习的目标是将知识从一个任务应用到另一个任务,或者从一个领域应用到另一个领域。后一种类型的迁移学习被称为领域适应,将在第七章中更具体地讨论,在复杂和稀缺数据集上的训练。
迁移学习在数据不足以充分学习新任务时尤为有趣(即,缺乏足够的图像样本来估计分布)。事实上,深度学习方法非常依赖数据;它们需要大量的数据集进行训练。这些数据集——特别是用于监督学习的标注数据集——通常非常繁琐,甚至不可能收集。例如,专家们在为工业自动化建立识别系统时,不能去每个工厂拍摄每个新制造的产品及其组件的数百张照片。他们通常不得不处理较小的数据集,而这些数据集对于 CNN 来说不足以令人满意地收敛。这些限制解释了为何要将已经在充分记录的视觉任务上获得的知识重用于其他任务的努力。
ImageNet 及最近的 COCO 是特别丰富的数据集,包含了来自大量类别的数百万张标注图像。假设在这些数据集上训练的 CNN 已获得了相当的视觉识别专业知识,因此,Keras 和 TensorFlow Hub 提供了在这些数据集上已训练的标准模型(如 Inception、ResNet-50 等)。人们常常使用这些模型来进行知识迁移。
迁移 CNN 知识
那么,如何将某些知识从一个模型转移到另一个模型呢?人工神经网络相对于人脑有一个有利之处,那就是它们可以轻松存储和复制。CNN 的专业知识无非是其参数在训练后得到的值——这些值可以很容易地恢复并转移到相似的网络中。
CNN 的迁移学习主要是通过重用在丰富数据集上训练的高性能网络的完整或部分架构和权重,来实例化一个用于不同任务的新模型。通过这种条件化的实例化,新模型可以进行微调;也就是说,它可以在可用数据上进一步训练,以适应新的任务或领域。
正如我们在前几章中强调的那样,网络的第一层通常提取低级特征(如线条、边缘或颜色渐变),而最终的卷积层则反应更为复杂的概念(如特定的形状和模式)。对于分类任务,最终的池化层和/或全连接层处理这些高级特征图(通常称为瓶颈特征),从而做出类别预测。
这一典型的设置和相关观察促成了各种迁移学习策略的提出。去掉最终预测层的预训练 CNN,开始作为高效的特征提取器使用。当新任务与这些提取器训练的任务足够相似时,它们可以直接用来输出相关的特征(例如,TensorFlow Hub 上的图像特征向量模型正是为了这个目的而存在)。然后,这些特征可以通过一两个新的全连接层进行处理,这些层经过训练后可以输出与任务相关的预测。为了保持提取特征的质量,特征提取器的层通常会在训练阶段被冻结;即,它们的参数在梯度下降过程中不会被更新。在其他情况下,当任务/领域不太相似时,特征提取器的最后几层——或者所有层——会被微调;也就是说,这些层与新的预测层一起在任务数据上进行训练。这些不同的策略将在接下来的段落中进一步解释。
使用场景
实际上,我们应该重用哪个预训练模型?哪些层应该被冻结或微调?这些问题的答案取决于目标任务与模型已训练任务之间的相似性,以及新应用的训练样本的丰富性。
相似任务,拥有有限的训练数据
迁移学习尤其有用,当你想解决一个特定任务且没有足够的训练样本来有效训练一个高性能模型时,但你却能够访问到一个更大且相似的训练数据集。
该模型可以在更大的数据集上进行预训练,直到收敛(或者,如果可用且相关,我们可以获取一个已经预训练的模型)。然后,当目标任务不同(即其输出与预训练任务不同)时,应移除其最终层,并用适应目标任务的层进行替换。例如,假设我们想训练一个模型来区分蜜蜂和黄蜂的图片。ImageNet 中包含这两个类别的图片,可以用作训练数据集,但它们的数量不足以让一个高效的 CNN 在不发生过拟合的情况下进行学习。然而,我们可以首先在完整的 ImageNet 数据集上训练该网络,以便从 1,000 个类别中进行分类,从而发展更广泛的专业知识。在这个预训练之后,其最终的全连接层可以被移除,并替换为配置成输出我们两个目标类别预测的层。
如我们之前提到的,新模型最终可以通过冻结预训练的层,并仅训练其上方的全连接层来为其任务做准备。实际上,由于目标训练数据集过小,如果我们不冻结其特征提取组件,模型最终会过拟合。通过固定这些参数,我们确保网络保持它在更丰富数据集上所发展出的表达能力。
相似任务,拥有丰富的训练数据
可用于目标任务的训练数据集越大,完全重新训练网络时网络过拟合的可能性就越小。因此,在这种情况下,通常会解冻特征提取器的最新层。换句话说,目标数据集越大,越多的层可以安全地进行微调。这使得网络能够提取与新任务相关性更高的特征,从而更好地学习如何执行该任务。
模型已经在一个相似的数据集上经历了第一次训练阶段,并且可能已经接近收敛。因此,在微调阶段使用较小的学习率是常见做法。
具有丰富训练数据的不相似任务
如果我们有足够丰富的训练集来进行应用,是否还有必要使用预训练模型?如果原始任务与目标任务之间的相似性太低,这个问题是合理的。预训练模型,甚至下载预训练权重,可能会很昂贵。然而,研究人员通过各种实验表明,在大多数情况下,用预训练权重(即使来自不相似的使用案例)初始化网络,通常比用随机权重初始化更为有效。
迁移学习在任务或其领域至少具有某些基本相似性时才有意义。例如,图像和音频文件都可以作为二维张量存储,并且卷积神经网络(如 ResNet)通常应用于两者。然而,模型在视觉和音频识别中依赖的是完全不同的特征。通常情况下,视觉识别模型从训练音频相关任务的网络中获取权重并不会带来好处。
具有有限训练数据的不相似任务
最后,如果目标任务非常具体,以至于几乎没有训练样本可用,而使用预训练权重没有太大意义该怎么办?首先,需要重新考虑是否应用或重新利用深度模型。在小数据集上训练这样的模型会导致过拟合,而深度预训练提取器返回的特征对于特定任务来说可能过于无关。然而,如果我们记住卷积神经网络(CNN)的前几层会响应低层次的特征,仍然可以从迁移学习中受益。我们不仅可以去掉预训练模型的最终预测层,还可以去掉一些过于特定于任务的最后几个卷积块。然后,可以在剩余层之上添加一个浅层分类器,最终对新模型进行微调。
使用 TensorFlow 和 Keras 进行迁移学习
为了总结本章内容,我们将简要介绍如何使用 TensorFlow 和 Keras 执行迁移学习。我们邀请读者并行阅读相关的 Jupyter 笔记本,通过分类任务演示迁移学习的过程。
模型手术
间接地,我们已经展示了如何通过 TensorFlow Hub 和 Keras 应用提供的标准预训练模型,轻松地将其提取为新任务的特征提取器。然而,重新使用非标准网络也很常见;例如,由专家提供的更具体的最新 CNNs,或者已经针对某些先前任务进行过训练的自定义模型。我们将演示如何编辑任何模型以进行迁移学习。
删除层
第一项任务是删除预训练模型的最终层,将其转换为特征提取器。像往常一样,Keras 使这个操作非常简单。对于Sequential模型,层的列表可以通过model.layers属性访问。这个结构有一个pop()方法,可以删除模型的最后一层。因此,如果我们知道需要删除的最终层的数量以将网络转换为特定的特征提取器(例如,标准 ResNet 模型的两层),可以像下面这样做:
for i in range(num_layers_to_remove):
model.layers.pop()
在纯 TensorFlow 中,编辑支持模型的操作图既不简单也不推荐。然而,我们必须记住,在运行时未使用的图操作不会被执行。因此,即使在编译图中保留旧层,也不会影响新模型的计算性能,只要它们不再被调用。因此,我们只需确定我们想保留的先前模型的最后一层/操作,而不是删除层。如果我们不知道其对应的 Python 对象,但知道其名称(例如,通过 TensorBoard 检查图表),则可以通过循环遍历模型的层并检查它们的名称来恢复其代表张量:
for layer in model.layers:
if layer.name == name_of_last_layer_to_keep:
bottleneck_feats = layer.output
break
然而,Keras 提供了额外的方法来简化这个过程。知道要保留的最后一层的名称(例如,在使用model.summary()打印名称后),可以在几行代码中构建一个特征提取器模型:
bottleneck_feats = model.get_layer(last_layer_name).output
feature_extractor = Model(inputs=model.input, outputs=bottleneck_feats)
将其权重与原始模型共享,这个特征提取模型已经准备好使用。
嫁接层
在特征提取器之上添加新的预测层相对简单(与以前的 TensorFlow Hub 示例相比),因为只需在相应模型的顶部添加新层。例如,可以使用 Keras API 如下完成这项工作:
dense1 = Dense(...)(feature_extractor.output) # ...
new_model = Model(model.input, dense1)
正如我们所见,通过 Keras,TensorFlow 2 使缩短、扩展或组合模型变得简单!
选择性训练
迁移学习使训练阶段变得更加复杂,因为我们首先应该恢复预训练层,并定义哪些层应该被冻结。幸运的是,有几个工具可用来简化这些操作。
恢复预训练参数
TensorFlow 有一些实用函数可以为估算器进行热启动;即初始化其中一些层的预训练权重。以下代码片段告诉 TensorFlow 使用预训练估算器的保存参数来为具有相同名称的层的新估算器初始化:
def model_function():
# ... define new model, reusing pretrained one as feature extractor.
ckpt_path = '/path/to/pretrained/estimator/model.ckpt'
ws = tf.estimator.WarmStartSettings(ckpt_path)
estimator = tf.estimator.Estimator(model_fn, warm_start_from=ws)
WarmStartSettings 初始化器接受一个可选的 vars_to_warm_start 参数,该参数还可以用于提供您希望从检查点文件中恢复的特定变量的名称(作为列表或正则表达式)(有关更多详细信息,请参阅www.tensorflow.org/api_docs/python/tf/estimator/WarmStartSettings 的文档)。
使用 Keras,我们可以在为新任务进行转换之前简单地恢复预训练模型:
# Assuming the pretrained model was saved with `model.save()`:
model = tf.keras.models.load_model('/path/to/pretrained/model.h5')
# ... then pop/add layers to obtain the new model.
尽管在删除一些层之前完全恢复完整模型并不完全理想,但这种解决方案的优点在于简洁性。
冻结层
在 TensorFlow 中,冻结层的最通用方法是从传递给优化器的变量列表中删除它们的 tf.Variable 属性:
# For instance, we want to freeze the model's layers with "conv" in their name:
vars_to_train = model.trainable_variables
vars_to_train = [v for v in vars_to_train if "conv" in v.name]
# Applying the optimizer to the remaining model's variables:
optimizer.apply_gradients(zip(gradient, vars_to_train))
在 Keras 中,层具有 .trainable 属性,只需将其设置为 False 即可冻结它们:
for layer in feature_extractor_model.layers:
layer.trainable = False # freezing the complete extractor
再次,为了完整的迁移学习示例,我们邀请您查阅 Jupyter 笔记本。
总结
诸如 ILSVRC 等分类挑战是研究人员的良好试验场,导致了更先进的深度学习解决方案的发展。在这一章中详细介绍的每个架构都以其独特的方式成为计算机视觉中的重要组成部分,并仍然应用于越来越复杂的应用程序。正如我们将在接下来的章节中看到的那样,它们的技术贡献激发了其他方法,适用于广泛的视觉任务。
此外,我们不仅学会了重用最先进的解决方案,还发现了算法本身如何从先前任务获取的知识中受益。通过迁移学习,CNN 的性能可以在特定应用中得到极大的改善。这对于诸如目标检测之类的任务尤为重要,这也将是我们下一章的主题。对于目标检测的数据集注释比图像级别识别更为繁琐,因此方法通常只能访问较小的训练数据集。因此,牢记迁移学习作为获取高效模型的解决方案非常重要。
问题
-
哪个 TensorFlow Hub 模块可以用于实例化 ImageNet 的 Inception 分类器?
-
如何冻结 Keras 应用程序中 ResNet-50 模型的前三个残差宏块?
-
何时不建议使用迁移学习?
进一步阅读
- 使用 Python 进行实践迁移学习 (
www.packtpub.com/big-data-and-business-intelligence/hands-transfer-learning-python),作者:Dipanjan Sarkar、Raghav Bali 和 Tamoghna Ghosh:本书更详细地介绍了迁移学习,同时将深度学习应用于计算机视觉以外的领域。
第五章:目标检测模型
从自动驾驶汽车到内容审查,检测图像中的物体及其位置是计算机视觉中的经典任务。本章将介绍用于目标检测的技术。我们将详细说明当前最主流的两种模型架构——You Only Look Once(YOLO)和Regions with Convolutional Neural Networks(R-CNN)。
本章将涵盖以下主题:
-
目标检测技术的发展历史
-
主要的目标检测方法
-
使用 YOLO 架构实现快速目标检测
-
使用 Faster R-CNN 架构提高目标检测效果
-
使用 Faster R-CNN 与 TensorFlow 目标检测 API
技术要求
本章的代码以笔记本的形式可以在github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2/tree/master/Chapter05获取。
介绍目标检测
目标检测在第一章《计算机视觉与神经网络》中做了简要介绍。在本节中,我们将介绍其历史以及核心技术概念。
背景
目标检测,也称为目标定位,是检测图像中的物体及其边界框的过程。边界框是能够完全包含物体的图像中最小的矩形。
目标检测算法的常见输入是图像。常见的输出是边界框和物体类别的列表。对于每个边界框,模型会输出对应的预测类别及其置信度。
应用
目标检测的应用广泛,涵盖了许多行业。例如,目标检测可用于以下目的:
-
在自动驾驶汽车中,用于定位其他车辆和行人
-
用于内容审查,定位禁止物体及其大小
-
在医疗领域,通过放射线影像定位肿瘤或危险组织
-
在制造业中,用于装配机器人组装或修理产品
-
在安全行业,用于检测威胁或统计人数
-
在野生动物保护中,用于监控动物种群
这些只是其中的一些例子——随着目标定位技术越来越强大,越来越多的应用每天都在被发现。
简要历史
历史上,目标检测依赖于经典的计算机视觉技术:图像描述符。为了检测一个物体,例如一辆自行车,你需要从多张该物体的照片中开始。然后从图像中提取与自行车相关的描述符。这些描述符表示自行车的特定部分。在寻找该物体时,算法会尝试在目标图像中再次找到这些描述符。
在图像中定位自行车时,最常用的技术是浮动窗口。图像的多个小矩形区域依次进行检查,匹配描述符最多的部分将被认为包含物体。随着时间的推移,使用了许多变种。
这种技术具有一些优点:它对旋转和颜色变化具有鲁棒性,不需要大量的训练数据,并且适用于大多数物体。然而,准确度水平并不令人满意。
虽然神经网络在 1990 年代初期就已经被用来检测图像中的面部、手部或文本,但它们在 2010 年代初期开始在 ImageNet 挑战赛中大幅超越描述符技术。
从那时起,性能稳步提高。性能指的是算法在以下方面的表现:
-
边界框精度:提供正确的边界框(既不太大也不太小)
-
召回率:找到所有物体(没有遗漏任何物体)
-
类别精度:为每个物体输出正确的类别(避免将猫误认为狗)
性能改进还意味着模型在计算结果时变得越来越快(针对特定的输入图像尺寸和计算能力)。虽然早期的模型需要相当长的时间(超过几秒钟)才能检测到物体,但现在它们可以实时使用。在计算机视觉的背景下,实时通常意味着每秒检测超过五次。
评估模型性能
要比较不同的物体检测模型,我们需要统一的评估指标。对于给定的测试集,我们运行每个模型并收集其预测结果。我们使用预测结果和真实值计算评估指标。在本节中,我们将看看用于评估物体检测模型的指标。
精确度和召回率
尽管它们通常不用于评估物体检测模型,精确度和召回率是计算其他指标的基础。因此,理解精确度和召回率非常重要。
要衡量精确度和召回率,我们首先需要为每张图像计算以下内容:
-
真阳性的数量:真阳性(TP)决定了有多少个预测与同类的真实框匹配。
-
假阳性的数量:假阳性(FP)决定了有多少个预测与同类的真实框不匹配。
-
假阳性的数量:假阳性(FN)决定了有多少个真实值没有匹配的预测。
然后,精确度和召回率的定义如下:

请注意,如果预测完全匹配所有的实际标签,则不会出现假阳性或假阴性。因此,准确率和召回率都将等于 1,这是一个完美的分数。如果模型基于不稳健的特征频繁预测物体的存在,准确率将下降,因为会有很多假阳性。相反,如果模型过于严格,只有在满足精确条件时才会认为物体被检测到,召回率将下降,因为会有很多假阴性。
精确召回曲线
精确召回曲线在许多机器学习问题中都有应用。其基本思想是可视化模型在每个置信度阈值下的准确率和召回率。对于每个边界框,我们的模型会输出一个置信度——这是一个介于 0 和 1 之间的数字,表示模型对预测正确性的信心。
因为我们不希望保留那些信心较低的预测,通常会移除低于某个阈值的预测,𝑇。例如,如果𝑇 = 0.4,我们将不考虑任何置信度低于此数值的预测。
移动阈值会对准确率和召回率产生影响:
-
如果 T 接近 1:准确率会很高,但召回率会很低。由于我们筛除了很多对象,错过了很多对象——召回率下降。由于我们只保留有信心的预测,因此没有太多假阳性——准确率上升。
-
如果 T 接近 0:准确率会很低,但召回率会很高。由于我们保留了大部分预测,因此不会有假阴性——召回率上升。由于模型对其预测的信心较低,我们会有很多假阳性——准确率下降。
通过计算在 0 到 1 之间每个阈值下的准确率和召回率,我们可以获得精确召回曲线,如下所示:

图 5.1:精确召回曲线
选择一个阈值是在准确率和召回率之间的权衡。如果模型检测行人,我们会选择一个较高的召回率,以确保不会错过任何路人,即使这意味着有时车辆会因没有有效理由而停下。如果模型检测投资机会,我们会选择较高的准确率,以避免选择错误的机会,即使这意味着错过一些机会。
平均准确率和均值平均准确率
虽然精确召回曲线能告诉我们很多关于模型的信息,但通常更方便的是拥有一个单一的数字。平均准确率(AP)对应于曲线下的面积。由于它始终包含在一个 1×1 的矩形内,因此 AP 的值始终介于 0 和 1 之间。
平均准确率提供了模型在单个类别上的性能信息。为了获得全局评分,我们使用均值平均准确率(mAP)。这对应于每个类别的平均准确率的平均值。如果数据集有 10 个类别,我们将计算每个类别的平均准确率,并取这些数值的平均值。
平均精度(mAP)至少在两个目标检测挑战中使用——PASCAL Visual Object Classes(通常称为Pascal VOC)和Common Objects in Context(通常称为COCO)。后者规模更大,包含的类别更多;因此,通常得到的分数比前者低。
平均精度阈值
我们之前提到过,真阳性和假阳性是通过预测与真实框是否匹配来定义的。然而,如何决定预测和真实框是否匹配呢?一个常见的指标是Jaccard 指数,它衡量两个集合重叠的程度(在我们的例子中,就是由框表示的像素集合)。它也被称为交集与并集的比率(IoU),定义如下:

|𝐴| 和 |𝐵| 是每个集合的基数;即它们各自包含的元素数量。𝐴 ⋂ 𝐵 是两个集合的交集,因此分子 |𝐴 ⋂ 𝐵| 代表它们共有的元素数量。类似地,𝐴 ⋃ 𝐵 是集合的并集(如下面的图示所示),因此分母 |𝐴 ⋃ 𝐵| 代表两个集合总共覆盖的元素数量:

图 5.2:框的交集与并集示意图
为什么要计算这样的比率,而不是直接使用交集呢?虽然交集可以很好地指示两个集合/框的重叠程度,但这个值是绝对的,而非相对的。因此,两个大框可能会比两个小框重叠更多的像素。这就是为什么要使用这个比率——它的值总是介于 0(如果两个框没有重叠)和 1(如果两个框完全重叠)之间。
计算平均精度时,我们说两个框重叠,当它们的 IoU 超过某个阈值时。通常选择的阈值是0.5。
对于 Pascal VOC 挑战,0.5 也被使用——我们说使用的是 mAP@0.5(读作mAP at 0.5)。对于 COCO 挑战,使用略有不同的指标——mAP@[0.5:0.95]。这意味着我们计算 mAP@0.5,mAP@0.55,...,mAP@0.95,并取其平均值。对 IoU 进行平均会奖励定位更精确的模型。
一个快速的目标检测算法——YOLO
虽然这个缩写可能会让你会心一笑,但 YOLO 是目前最快的目标检测算法之一。最新版本 YOLOv3 在现代 GPU 上,对于256×256的图像大小,可以以每秒超过 170 帧的速度运行(FPS)。在这一节中,我们将介绍其架构背后的理论概念。
介绍 YOLO
YOLO 首次发布于 2015 年,在速度和精度上超越了几乎所有其他目标检测架构。此后,该架构已被多次改进。在本章中,我们将借鉴以下三篇论文的内容:
-
You Only Look Once: 统一的实时目标检测(2015),Joseph Redmon、Santosh Divvala、Ross Girshick 和 Ali Farhadi
-
YOLO9000:更好、更快、更强(2016),Joseph Redmon 和 Ali Farhadi
-
YOLOv3:一个渐进的改进(2018),Joseph Redmon 和 Ali Farhadi
为了清晰简洁,我们不会描述所有细节,来阐述 YOLO 如何达到其最大性能。相反,我们将专注于网络的总体架构。我们将提供 YOLO 的实现,以便你可以将我们的架构与代码进行比较。它可以在本章的代码库中找到。
该实现已被设计为易于阅读和理解。我们邀请那些希望深入理解架构的读者,先阅读本章内容,然后参考原始论文和实现。
YOLO 论文的主要作者维护了一个深度学习框架,叫做 Darknet(github.com/pjreddie/darknet)。这个框架包含了 YOLO 的官方实现,并且可以用于复现论文中的结果。它是用 C++ 编写的,并且没有基于 TensorFlow。
YOLO 的优缺点
YOLO 以其速度而闻名。然而,最近在准确性方面被 Faster R-CNN(将在本章后面介绍)所超越。此外,由于 YOLO 检测物体的方式,它在处理小物体时表现不佳。例如,它可能很难从一群鸟中检测出单只鸟。与大多数深度学习模型一样,它也很难正确检测出与训练集偏差较大的物体(例如不寻常的长宽比或外观)。尽管如此,架构在不断演进,相关问题正在得到解决。
YOLO 的主要概念
YOLO 的核心思想是:将目标检测重新框定为一个单一的回归问题。这是什么意思?与其使用滑动窗口或其他复杂技术,我们将把输入划分为一个 w × h 的网格,如下图所示:

图 5.3:一个涉及飞机起飞的示例。这里,w = 5,h = 5,B = 2,这意味着总共有 5 × 5 × 2 = 50 个潜在的框,但图像中仅显示了 2 个框
对于网格的每一部分,我们将定义B个边界框。然后,我们的唯一任务就是预测每个边界框的以下内容:
-
盒子的中心
-
盒子的宽度和高度
-
这个框包含了一个物体的概率
-
该物体的类别
由于所有这些预测都是数值,我们因此将目标检测问题转化为回归问题。
很重要的一点是要区分网格单元(将图像分成等份的部分,准确来说是 w × h 部分)与定位物体的边界框。每个网格单元包含 B 个边界框。因此,最终会有 w × h × B 个可能的边界框。
在实际应用中,YOLO 使用的概念比这个稍微复杂。假设网格的一部分有多个物体怎么办?如果一个物体跨越多个网格部分怎么办?更重要的是,如何选择一个损失函数来训练我们的模型?接下来,我们将深入了解 YOLO 架构。
使用 YOLO 进行推理
由于模型的架构可能很难一次性理解,我们将把模型分为两个部分——推理和训练。推理是将图像输入并计算结果的过程。训练是学习模型权重的过程。在从头实现模型时,推理在模型训练之前无法使用。但为了简化,我们将从推理开始。
YOLO 主干模型
像大多数图像检测模型一样,YOLO 基于主干模型。该模型的作用是从图像中提取有意义的特征,供最终的层使用。这也是为什么主干模型被称为特征提取器,这一概念在第四章《有影响力的分类工具》中介绍。YOLO 的总体架构如下图所示:

图 5.4:YOLO 架构总结。请注意,主干模型是可交换的,其架构可能有所不同。
虽然可以选择任何架构作为特征提取器,但 YOLO 论文使用了一个自定义架构。最终模型的性能在很大程度上取决于特征提取器架构的选择。
主干模型的最终层输出一个大小为w × h × D的特征体积,其中w × h是网格的大小,D是特征体积的深度。例如,对于 VGG-16,D = 512。
网格的大小,w × h,取决于两个因素:
-
完整特征提取器的步幅:对于 VGG-16,步幅为 16,意味着特征体积输出将是输入图像的 1/16 大小。
-
输入图像的大小:由于特征体积的大小与图像大小成正比,因此输入图像越小,网格也越小。
YOLO 的最终层接受特征体积作为输入。它由大小为1 × 1的卷积滤波器组成。如在第四章《有影响力的分类工具》中所示,1 × 1的卷积层可以用于改变特征体积的深度,而不影响其空间结构。
YOLO 的层输出
YOLO 的最终输出是一个w × h × M矩阵,其中w × h是网格的大小,M对应于公式B × (C + 5),其中适用以下内容:
-
B是每个网格单元的边界框数量。
-
C是类别的数量(在我们的例子中,我们将使用 20 个类别)。
请注意,我们在类别数上加了5。这是因为对于每个边界框,我们需要预测(C + 5)个数字:
-
t[x]和t[y]将用来计算边界框中心的坐标。
-
t[w]和t[h]将用来计算边界框的宽度和高度。
-
c是物体位于边界框中的置信度。
-
p1、p2、...和pC是边界框包含物体属于类别1、2、...、C的概率(在我们的示例中,C = 20)。
该图总结了输出矩阵的显示方式:

图 5.5:YOLO 的最终矩阵输出。在这个示例中,B = 5,C = 20,w = 13,h = 13。大小为 13 × 13 × 125
在我们解释如何使用该矩阵计算最终的边界框之前,我们需要介绍一个重要概念——锚框。
引入锚框
我们提到过,t[x]、t[y]、t[w]和t[h]用来计算边界框的坐标。为什么不直接让网络输出坐标(x、y、w、h)呢?实际上,这正是 YOLO v1 中的做法。不幸的是,这会导致很多误差,因为物体的大小各异。
确实,如果训练数据集中的大多数物体较大,网络将倾向于预测宽度(w)和高度(h)非常大。当使用训练好的模型来检测小物体时,它通常会失败。为了解决这个问题,YOLO v2 引入了锚框。
锚框(也叫先验框)是一组在训练网络之前就决定好的边界框大小。例如,当训练神经网络来检测行人时,会选择高而窄的锚框。如下所示:

图 5.6:左侧是用于检测行人的三种边界框大小。右侧是我们如何调整其中一个边界框以匹配行人。
一组锚框通常较小——在实践中通常包含 3 到 25 种不同的尺寸。由于这些框不能完全匹配所有物体,网络会用于细化最接近的锚框。在我们的示例中,我们将行人图像中的物体与最接近的锚框匹配,并使用神经网络来修正锚框的高度。这就是t[x]、t[y]、t[w]和t[h]对应的内容——锚框的修正。
当锚框首次在文献中提出时,它们是手动选择的。通常使用九种框大小:
-
三个正方形(小号、中号、大号)
-
三个水平矩形(小号、中号、大号)
-
三个垂直矩形(小号、中号、大号)
然而,在 YOLOv2 的论文中,作者意识到锚框的大小因数据集而异。因此,在训练模型之前,他们建议对数据进行分析,以选择合适的锚框大小。比如,在检测行人时,如前所述,使用垂直矩形框;在检测苹果时,则使用正方形的锚框。
YOLO 如何细化锚框
在实践中,YOLOv2 使用以下公式计算每个最终边界框的坐标:

前面公式中的各项可以按如下方式解释:
-
t[x] , t[y] , t[w] , 和 t[h] 是最后一层的输出。
-
b[x] , b[y] , b[w] , 和 b[h] 分别表示预测边界框的位置和大小。
-
p[w] 和 p[h] 代表锚框的原始尺寸。
-
c[x] 和 c[y] 是当前网格单元的坐标(对于左上框,它们将是 (0,0),对于右上框,它们将是 (w - 1,0),对于左下框,它们将是 (0, h - 1))。
-
exp 是指数函数。
-
sigmoid 是 sigmoid 函数,描述见第一章,计算机视觉与神经网络。
尽管这个公式看起来复杂,但这个示意图可能有助于澄清问题:

图 5.7:YOLO 如何精炼并定位锚框
在前面的示意图中,我们看到左侧实线为锚框,虚线为精炼后的边界框。右侧的点是边界框的中心。
神经网络的输出是一个包含原始数值的矩阵,需要转换为边界框列表。简化版的代码如下所示:
boxes = []
for row in range(grid_height):
for col in range(grid_width):
for b in range(num_box):
tx, ty, tw, th = network_output[row, col, b, :4]
box_confidence = network_output[row, col, b, 4]
classes_scores = network_output[row, col, b, 5:]
bx = sigmoid(tx) + col
by = sigmoid(ty) + row
# anchor_boxes is a list of dictionaries containing the size of each anchor
bw = anchor_boxes[b]['w'] * np.exp(tw)
bh = anchors_boxes[b]['h'] * np.exp(th)
boxes.append((bx, by, bw, bh, box_confidence, classes_scores))
这段代码需要在每次推理时运行,以便为图像计算边界框。在我们显示框之前,还需要进行一次后处理操作。
后处理框
我们得到了预测边界框的坐标和大小,以及置信度和类概率。现在我们只需将置信度乘以类概率,并进行阈值处理,只保留高概率:
# Confidence is a float, classes is an array of size NUM_CLASSES
final_scores = box_confidence * classes_scores
OBJECT_THRESHOLD = 0.3
# filter will be an array of booleans, True if the number is above threshold
filter = classes_scores >= OBJECT_THRESHOLD
filtered_scores = class_scores * filter
这是使用简单示例的该操作示例,阈值为 0.3,该框的置信度(对于此特定框)为 0.5:
| CLASS_LABELS | dog | airplane | bird | elephant |
|---|---|---|---|---|
| classes_scores | 0.7 | 0.8 | 0.001 | 0.1 |
| final_scores | 0.35 | 0.4 | 0.0005 | 0.05 |
| filtered_scores | 0.35 | 0.4 | 0 | 0 |
然后,如果filtered_scores包含非空值,这意味着我们至少有一个类的分数超过了阈值。我们保留得分最高的类:
class_id = np.argmax(filtered_scores)
class_label = CLASS_LABELS[class_id]
在我们的示例中,class_label 将是 airplane。
一旦我们对网格中的所有边界框应用了这个过滤操作,我们就会得到绘制预测所需的所有信息。以下照片显示了这样做后的结果:

图 5.8:原始边界框输出在图像上的绘制示例
许多边界框重叠。由于平面覆盖了多个网格单元,因此它被多次检测到。为了解决这个问题,我们需要在后处理管道中进行最后一步——非极大值抑制 (NMS)。
NMS
NMS 的思路是去除与概率最高框重叠的框。因此,我们移除非最大框。为此,我们根据概率对所有框进行排序,先选取概率最高的框。然后,对于每个框,我们计算它与其他所有框的 IoU。
计算一个框与其他框的 IoU 后,我们去除那些 IoU 超过某个阈值的框(该阈值通常在 0.5 到 0.9 之间)。
使用伪代码,NMS 的实现如下所示:
sorted_boxes = sort_boxes_by_confidence(boxes)
ids_to_suppress = []
for maximum_box in sorted_boxes:
for idx, box in enumerate(boxes):
iou = compute_iou(maximum_box, box)
if iou > iou_threshold:
ids_to_suppress.append(idx)
processed_boxes = np.delete(boxes, ids_to_suppress)
实际上,TensorFlow 提供了自己的 NMS 实现,tf.image.non_max_suppression(boxes, ...)(请参阅文档:www.tensorflow.org/api_docs/python/tf/image/non_max_suppression),我们建议使用它(它已经过优化并提供了有用的选项)。还要注意,NMS 被大多数物体检测模型的后处理管道所使用。
在执行 NMS 后,我们得到了一个更好的结果,只有一个边界框,如下图所示:

图 5.9:NMS 后在图像上绘制的边界框示例
YOLO 推理总结
将所有步骤整合起来,YOLO 推理包括若干个小步骤。YOLO 的架构示意图如下所示:

图 5.10:YOLO 架构示意图。在这个示例中,我们对每个网格单元使用两个边界框。
YOLO 推理过程可以总结如下:
-
接受输入图像,并使用 CNN 骨干网计算特征体积。
-
使用卷积层计算锚框修正、物体存在分数和类别概率。
-
使用该输出计算边界框的坐标。
-
筛选掉低阈值的框,并使用 NMS 对剩余框进行后处理。
在这个过程的最后,我们得到了最终的预测结果。
由于整个过程由卷积和过滤操作组成,网络可以接受任何大小和任何比例的图像。因此,它具有很高的灵活性。
训练 YOLO
我们已经概述了 YOLO 的推理过程。利用在线提供的预训练权重,可以直接实例化模型并生成预测结果。然而,您可能希望在特定数据集上训练一个模型。在本节中,我们将讲解 YOLO 的训练过程。
YOLO 骨干网的训练方式
正如我们之前提到的,YOLO 模型由两个主要部分组成——骨干网和 YOLO 头。骨干网可以使用许多架构。在训练完整模型之前,骨干网会在传统的分类任务中,通过使用 ImageNet 并采用第四章中详细描述的迁移学习技术进行训练。虽然我们可以从头开始训练 YOLO,但这样做需要花费更多的时间。
Keras 使得在我们的网络中使用预训练的主干网络变得非常简单:
input_image = Input(shape=(IMAGE_H, IMAGE_W, 3))
true_boxes = Input(shape=(1, 1, 1, TRUE_BOX_BUFFER , 4))
inception = InceptionV3(input_shape=(IMAGE_H, IMAGE_W,3), weights='imagenet', include_top=False)
features = inception(input_image)
GRID_H, GRID_W = inception.get_output_shape_at(-1)[1:3]
# print(grid_h, grid_w)
output = Conv2D(BOX * (4 + 1 + CLASS),
(1, 1), strides=(1,1),
padding='same',
name='DetectionLayer',
kernel_initializer='lecun_normal')(features)
output = Reshape((GRID_H, GRID_W, BOX, 4 + 1 + CLASS))(output)
在我们的实现中,我们将采用 YOLO 论文中提出的架构,因为它能提供最佳的结果。然而,如果你要在手机上运行你的模型,你可能会想要使用一个更小的模型。
YOLO 损失
由于最后一层的输出相当不寻常,因此相应的损失也是如此。实际上,YOLO 损失因其复杂性而著称。为了说明它,我们将把损失分解为多个部分,每个部分对应于最后一层返回的某种输出。网络预测多种信息:
-
边界框的坐标和大小
-
物体出现在边界框中的置信度
-
类别的得分
损失的一般思路是,当误差较大时,我们希望损失也较大。损失会惩罚不正确的值。然而,我们只希望在有意义的情况下这样做——如果一个边界框不包含任何物体,我们就不希望惩罚它的坐标,因为它们反正不会被使用。
神经网络的实现细节通常在原始论文中没有提供。因此,它们在不同的实现之间会有所不同。我们在这里概述的是一种实现建议,而不是绝对参考。我们建议阅读现有实现中的代码,以了解损失是如何计算的。
边界框损失
损失函数的第一部分帮助网络学习预测边界框坐标和大小的权重:

尽管这个方程式一开始看起来可能很吓人,但这一部分其实相对简单。让我们分解它:
-
λ(lambda)是损失的权重——它反映了在训练过程中,我们希望给予边界框坐标多大重要性。
-
∑(大写希腊字母 sigma)表示我们要对它后面的内容求和。在这个例子中,我们对网格的每个部分(从 i = 0 到i = S²)以及该部分网格中的每个框(从 0 到 B)求和。
-
1^(obj)(物体指示函数)是一个函数,当网格的第 i 部分和第 j 个边界框负责某个物体时,其值为 1。我们将在下一段解释“负责”是什么意思。
-
x[i]、y[i]、w[i] 和 h[i] 对应于边界框的大小和坐标。我们取预测值(网络输出)与目标值(也称为真实值)之间的差异。这里,预测值带有上标(
ˆ)。 -
我们将差异平方化,以确保其为正数。
-
注意,我们取了 w[i]和 h[i]的平方根。这样做是为了确保小的边界框错误受到比大的边界框更重的惩罚。
这个损失的关键部分是指示函数。只有当边界框负责检测物体时,坐标才会是正确的。对于图像中的每个物体,难点是确定哪个边界框负责它。对于 YOLOv2,具有与检测到物体最高 IoU 的锚框被认为是负责的。这里的基本思想是让每个锚框专注于一种物体类型。
物体置信度损失
损失的第二部分教会网络学习预测边界框是否包含物体的权重:

我们已经涵盖了该函数中的大部分符号。剩下的符号如下:
-
C[ij]: 第i网格部分的第j边界框包含物体(任意类型)的置信度
-
1^(noobj)(无物体的指示函数):当第i网格部分和第j边界框不负责某物体时,函数值为 1
计算1^(noobj)的一个简单方法是(1 - 1(obj))*。然而,如果我们这样做,它可能会在训练过程中引发一些问题。实际上,我们的网格上有很多边界框。在确定某个边界框负责特定物体时,可能会有其他适合该物体的候选框。我们不希望惩罚那些适合物体的其他优秀候选框的物体性得分。因此,*1(noobj)的定义如下:

在实践中,对于位置(i, j)上的每个边界框,都会计算它与每个地面真实框的 IoU。如果 IoU 超过某个阈值(通常为 0.6),则将 1^(noobj)设为 0。这个想法的依据是避免惩罚那些包含物体但并不负责该物体的边界框。
分类损失
损失的最后一部分——分类损失,确保网络学习为每个边界框预测正确的类别:

这个损失与第一章中介绍的计算机视觉与神经网络的损失非常相似。需要注意的是,虽然 YOLO 论文中展示的损失是 L2 损失,但许多实现使用的是交叉熵损失。这个损失部分确保正确的物体类别被预测出来。
完整 YOLO 损失
完整 YOLO 损失是前面提到的三个损失的总和。通过组合这三个项,损失惩罚边界框坐标的精细调整、物体性得分和类别预测的错误。通过反向传播错误,我们能够训练 YOLO 网络预测正确的边界框。
在本书的 GitHub 仓库中,读者将找到 YOLO 网络的简化实现。特别地,代码中包含了大量注释的损失函数。
训练技巧
一旦损失函数被正确定义,YOLO 就可以通过反向传播进行训练。然而,为了确保损失不会发散,并且获得良好的性能,我们将详细介绍一些训练技巧:
-
数据增强(在第七章中解释,在复杂和稀缺数据集上的训练)和丢弃法(在第三章中解释,现代神经网络)被用来防止网络过拟合训练数据,从而使其能够更好地泛化。
-
另一种技术是多尺度训练。每经过n个批次,网络的输入会被更改为不同的尺寸。这迫使网络学习在各种输入维度下进行精确预测。
-
与大多数检测网络一样,YOLO 在图像分类任务上进行预训练。
-
尽管论文中没有提到,官方 YOLO 实现使用了预热——在训练开始时降低学习率,以避免损失爆炸。
Faster R-CNN – 一个强大的目标检测模型
YOLO 的主要优点是其速度。尽管它可以取得非常好的结果,但现在已被更复杂的网络超越。更快的区域卷积神经网络(Faster R-CNN)被认为是在写作时的最先进技术。它也相当快速,在现代 GPU 上达到 4-5 帧每秒。在这一部分,我们将探讨其架构。
Faster R-CNN 架构经过多年的研究逐步设计完成。更准确地说,它是从两个架构——R-CNN 和 Fast R-CNN——逐步构建而成的。在这一部分,我们将重点介绍最新的架构,Faster R-CNN:
- Faster R-CNN:通过区域提议网络实现实时目标检测(2015),Shaoqing Ren, Kaiming He, Ross Girshick 和 Jian Sun
本文借鉴了之前两个设计中的大量知识。因此,部分架构细节可以在以下论文中找到:
-
精确目标检测和语义分割的丰富特征层次(2013),Ross Girshick, Jeff Donahue, Trevor Darrell 和 Jitendra Mali
-
Fast R-CNN(2015),Ross Girshick
就像 YOLO 架构一样,我们建议先阅读本章,然后查看这些论文以获得更深的理解。在本章中,我们将使用与论文中相同的符号。
Faster R-CNN 的通用架构
YOLO 被认为是一个单次检测器——正如其名称所示,每个图像像素只分析一次。这就是它速度非常快的原因。为了获得更精确的结果,Faster R-CNN 分为两个阶段工作:
-
第一阶段是提取兴趣区域(RoI,复数形式为 RoIs)。RoI 是输入图像中可能包含物体的区域。对于每张图像,第一步生成大约 2,000 个 RoI。
-
第二阶段是 分类步骤(有时也称为 检测步骤)。我们将每个 2,000 个 RoI 调整为正方形,以适应卷积网络的输入。然后我们使用 CNN 对 RoI 进行分类。
在 R-CNN 和 Fast R-CNN 中,兴趣区域是使用一种叫做 选择性搜索 的技术生成的。这里不再详细介绍,因为它在 Faster R-CNN 论文中被删除了,原因是其速度较慢。此外,选择性搜索并不涉及任何深度学习技术。
由于 Faster R-CNN 的两个部分是独立的,我们将分别介绍每一部分。然后我们将介绍完整模型的训练细节。
第一阶段 – 区域提议
使用 区域提议网络(RPN)生成兴趣区域。为了生成 RoI,RPN 使用卷积层。因此,它可以在 GPU 上实现,并且速度非常快。
RPN 架构与 YOLO 的架构共享许多特征:
-
它还使用锚框——在 Faster R-CNN 论文中,使用了九种锚框尺寸(三种竖直矩形、三种水平矩形和三种正方形)。
-
它可以使用任何骨干网络来生成特征体积。
-
它使用一个网格,网格的大小取决于特征体积的大小。
-
它的最后一层输出数字,允许锚框被精炼成一个合适的边界框以适应物体。
然而,该架构与 YOLO 的架构并不完全相同。RPN 接受图像作为输入并输出兴趣区域。每个兴趣区域由一个边界框和一个物体存在概率组成。为了生成这些数字,使用 CNN 提取特征体积。然后使用该特征体积生成区域、坐标和概率。RPN 的架构在下图中进行了说明:

图 5.11:RPN 架构概述
图 5.11 中表示的逐步过程如下:
-
网络接受图像作为输入,并应用多个卷积层。
-
它输出一个特征体积。一个卷积滤波器应用于特征体积。其大小为 3 × 3 × D,其中 D 是特征体积的深度(在我们的示例中,D = 512)。
-
在特征体积的每个位置,滤波器生成一个中间的 1 × D 向量。
-
两个兄弟 1 × 1 的卷积层计算物体存在性分数和边界框坐标。每个 k 个边界框有两个物体存在性分数。此外,还有四个浮动值,用于优化锚框的坐标。
在后处理之后,最终输出是 RoI 的列表。在这一步骤中,不会生成有关物体类别的信息,只会生成它的位置。在下一步分类中,我们将对物体进行分类,并优化边界框。
第二阶段 – 分类
Faster R-CNN 的第二部分是分类。它输出最终的边界框,并接受两个输入——来自前一步(RPN)的 RoI 列表,以及从输入图像计算出的特征体积。
由于大部分分类阶段的架构来自之前的论文《Fast R-CNN》,有时也称其为相同的名字。因此,Faster R-CNN 可以视为 RPN 和 Fast R-CNN 的结合。
分类部分可以与任何对应于输入图像的特征体积一起工作。然而,由于特征图已在前一个区域提议步骤中计算,因此在这里它们只是被重复使用。这种技术有两个好处:
-
共享权重:如果我们使用不同的 CNN,我们将不得不为两个骨干网络存储权重——一个用于 RPN,另一个用于分类。
-
共享计算:对于一个输入图像,我们只计算一个特征体积,而不是两个。由于这一操作是整个网络中最昂贵的部分,避免重复计算可以显著提高计算性能。
Faster R-CNN 架构
Faster R-CNN 的第二阶段接受第一阶段的特征图,以及 RoI 列表。对于每个 RoI,应用卷积层以获得类别预测和边界框精细调整信息。操作流程如下:

图 5.12:Faster R-CNN 的架构总结
逐步过程如下:
-
接受来自 RPN 步骤的特征图和 RoI。原始图像坐标系统中生成的 RoI 被转换为特征图坐标系统。在我们的示例中,CNN 的步幅为 16。因此,它们的坐标会被除以 16。
-
调整每个 RoI 的大小,使其适应全连接层的输入。
-
应用全连接层。它与任何卷积网络的最终层非常相似。我们得到一个特征向量。
-
应用两层不同的卷积层。一层处理分类(称为cls),另一层处理 RoI 的精细调整(称为rgs)。
最终结果是类别分数和边界框精细调整浮动,我们可以对其进行后处理以生成模型的最终输出。
特征体积的大小取决于输入的大小和 CNN 的架构。例如,对于 VGG-16,特征体积的大小是w × h × 512,其中w = input_width/16,h = input_height/16。我们说 VGG-16 的步幅为 16,因为特征图中的一个像素等于输入图像中的 16 个像素。
虽然卷积网络可以接受任何大小的输入(因为它们在图像上使用滑动窗口),但最终的全连接层(在步骤 2 和步骤 3 之间)只能接受固定大小的特征体积作为输入。由于区域提议的大小不同(例如,人的垂直矩形、苹果的正方形……),这使得最终层无法直接使用。
为了绕过这个问题,在 Fast R-CNN 中引入了一种技术——兴趣区域池化(RoI 池化)。它将特征图的可变大小区域转换为固定大小的区域。调整大小后的特征区域可以传递给最终的分类层。
RoI 池化
RoI 池化层的目标很简单——将一个大小可变的激活图部分转换为固定大小。输入激活图的子窗口大小为h × w,目标激活图的大小为H × W。RoI 池化通过将输入划分为一个网格来工作,每个单元格的大小为h/H × w/W。
让我们通过一个例子来说明。如果输入的大小是h × w = 5 × 4,目标激活图的大小是H × W = 2 × 2,那么每个单元格的大小应该是2.5 × 2。由于我们只能使用整数,因此我们将使某些单元格的大小为3 × 2,其他单元格的大小为2 × 2。然后,我们将取每个单元格的最大值:

图 5.13:RoI 池化示例,其中 RoI 的大小为 5 × 4(从 B3 到 E7),输出大小为 2 × 2(从 J4 到 K5)
RoI 池化层非常类似于最大池化层。不同之处在于,RoI 池化能够处理大小可变的输入,而最大池化只能处理固定大小的输入。RoI 池化有时被称为RoI 最大池化。
在原始的 R-CNN 论文中,RoI 池化尚未引入。因此,每个 RoI 是从原始图像中提取的,经过调整大小后直接传递给卷积网络。由于大约有 2,000 个 RoI,这样做非常慢。Fast R-CNN 中的Fast来源于 RoI 池化层带来的巨大加速。
训练 Faster R-CNN
在我们解释如何训练网络之前,先来看看 Faster R-CNN 的完整架构:

图 5.14:Faster R-CNN 的完整架构。注意,它可以处理任何输入大小
由于其独特的架构,Faster R-CNN 不能像普通的 CNN 一样进行训练。如果将网络的两个部分分别训练,两个部分的特征提取器将无法共享相同的权重。在接下来的部分中,我们将解释如何训练每个部分,以及如何使这两个部分共享卷积权重。
训练 RPN
RPN 的输入是图像,输出是一个 RoI 列表。正如我们之前看到的,对于每张图像,有H × W × k个提议(其中H和W代表特征图的大小,k是锚点的数量)。在这一步,物体的类别尚未被考虑。
一次性训练所有候选框会很困难——由于图像大多由背景组成,大多数候选框将被训练为预测背景。因此,网络将学习始终预测背景。相反,更倾向于使用采样技术。
构建了 256 个实际锚点的小批量;其中 128 个是正样本(包含物体),另外 128 个是负样本(仅包含背景)。如果图像中正样本少于 128 个,则使用所有可用的正样本,并用负样本填充批次。
RPN 损失
RPN 损失比 YOLO 的损失更简单。它由两部分组成:

前面公式中的各项可以解释如下:
-
i是训练批次中一个锚点的索引。
-
p[i]是锚点为物体的概率。*p[i]**是实际值——如果锚点是“正样本”,则为 1;否则为 0。
-
t[i]是表示坐标修正的向量;*t[i]**是实际值。
-
N[cls]是训练小批量中实际锚点的数量。
-
N[reg]是可能的锚点位置数量。
-
L[cls]是两个类别(物体和背景)的对数损失。
-
λ是一个平衡参数,用于平衡损失的两个部分。
最后,损失由 Lreg = R(t[i] - t[i])* 组成,其中 R 是平滑L1 损失函数,定义如下:

平滑[L1]函数被引入,作为替代先前使用的 L2 损失。当误差过大时,L2 损失会变得过大,导致训练不稳定。
正如 YOLO 一样,回归损失仅用于包含物体的锚点框,这要归功于p[i]**项。两部分通过N[cls]和N[reg]*进行划分。这两个值被称为归一化项——如果我们改变小批量的大小,损失不会失去平衡。
最后,lambda 是一个平衡参数。在论文配置中,N[cls] ~= 256 和 N[reg] ~= 2,400。作者将λ设置为 10,以便两个项具有相同的总权重。
总结来说,类似于 YOLO,损失惩罚以下内容:
-
第一个项中物体分类的误差
-
第二项中边界框修正的误差
然而,与 YOLO 的损失相反,它并不处理物体类别,因为 RPN 只预测 RoI。除了损失和小批量构建方式外,RPN 的训练方式与其他网络相同,使用反向传播。
Fast R-CNN 损失
如前所述,Faster R-CNN 的第二阶段也被称为 Fast R-CNN。因此,其损失通常被称为 Fast R-CNN 损失。尽管 Fast R-CNN 损失的公式与 RPN 损失不同,但在本质上非常相似:

前述方程中的术语可以解释如下:
-
Lcls 是实际类别 u 与类别概率 p 之间的对数损失。
-
Lloc 与 RPN 损失中的 L[reg] 相同。
-
λ[u ≥ 1] 在 u ≥ 1 时等于 1,否则为 0。
在 Fast R-CNN 训练过程中,我们始终使用背景类别,id = 0。事实上,RoI 可能包含背景区域,且将其分类为背景是非常重要的。术语 λ[u ≥ 1] 用于避免惩罚背景框的边界框误差。对于所有其他类别,由于 u 将大于 0,我们将惩罚误差。
训练方案
如前所述,在网络的两部分之间共享权重可以使模型更快(因为 CNN 只应用一次)且更轻。在 Faster R-CNN 论文中,推荐的训练过程被称为 四步交替训练。该过程的简化版本如下:
-
训练 RPN,使其预测可接受的 RoI。
-
使用训练好的 RPN 输出训练分类部分。在训练结束时,由于 RPN 和分类部分分别训练,它们的卷积权重不同。
-
用分类部分的 CNN 替换 RPN 的 CNN,使它们共享卷积权重。冻结共享的 CNN 权重。重新训练 RPN 的最后几层。
-
再次使用 RPN 的输出训练分类部分的最后一层。
该过程结束后,我们得到一个训练好的网络,其中两部分共享卷积权重。
TensorFlow 物体检测 API
由于 Faster R-CNN 始终在改进,我们没有提供本书的参考实现。相反,我们建议使用 TensorFlow 物体检测 API。它提供了一个由贡献者和 TensorFlow 团队维护的 Faster R-CNN 实现,提供了预训练模型和训练自己模型的代码。
物体检测 API 不属于核心 TensorFlow 库的一部分,而是作为一个单独的仓库提供,详细介绍见 第四章,影响力分类工具:github.com/tensorflow/models/tree/master/research/object_detection。
使用预训练模型
物体检测 API 提供了多个在 COCO 数据集上训练的预训练模型。这些模型在架构上有所不同——虽然它们都基于 Faster R-CNN,但使用了不同的参数和骨干网络。这对推理速度和性能产生了影响。一个经验法则是,推理时间随着平均精度的提高而增加。
在自定义数据集上训练
也可以训练一个模型来检测不在 COCO 数据集中的物体。为此,需要大量的数据。一般来说,建议每个物体类别至少有 1,000 个样本。为了生成训练集,需要手动标注训练图像,方法是画出物体的边界框。
使用物体检测 API 不涉及编写 Python 代码。相反,架构是通过配置文件来定义的。我们建议从现有的配置文件开始,并在此基础上进行调整以获得良好的性能。本章的代码库中提供了一个完整的示例。
总结
我们介绍了两种物体检测模型的架构。第一个是 YOLO,以推理速度快著称。我们讲解了其整体架构以及推理的工作原理,此外还讲解了训练过程。我们还详细说明了用于训练模型的损失函数。第二个是 Faster R-CNN,以其最先进的性能著称。我们分析了网络的两个阶段以及如何训练它们。我们还描述了如何通过 TensorFlow 物体检测 API 使用 Faster R-CNN。
在下一章,我们将通过学习如何将图像分割成有意义的部分,以及如何转换和增强它们,进一步扩展物体检测。
问题
-
边界框、锚框和真实框有什么区别?
-
特征提取器的作用是什么?
-
应该偏向使用哪种模型,YOLO 还是 Faster R-CNN?
-
使用锚框意味着什么?
深入阅读
-
精通 OpenCV 4 (
www.packtpub.com/application-development/mastering-opencv-4-third-edition),由 Roy Shilkrot 和 David Millán Escrivá 编写,包含了实际的计算机视觉项目,包括高级物体检测技术。 -
OpenCV 4 计算机视觉应用编程实战 (
www.packtpub.com/application-development/opencv-4-computer-vision-application-programming-cookbook-fourth-edition),由 David Millán Escrivá 和 Robert Laganiere 编写,涵盖了经典的物体描述符以及物体检测概念。
第六章:图像增强与分割
我们刚刚学习了如何创建神经网络,输出比单一类别更复杂的预测。在本章中,我们将进一步拓展这一概念,介绍编码器-解码器,这些模型用于编辑或生成完整图像。我们将展示编码器-解码器网络如何应用于从图像去噪到物体和实例分割的广泛应用。本章将提供几个具体示例,例如编码器-解码器在自动驾驶汽车语义分割中的应用。
本章将涵盖以下主题:
-
编码器-解码器是什么,以及如何训练它们进行像素级预测
-
它们使用了哪些新颖的层来输出高维数据(反池化、转置卷积和空洞卷积)
-
FCN 和 U-Net 架构如何解决语义分割问题
-
我们迄今为止所讲解的模型如何扩展以处理实例分割
技术要求
展示本章概念的 Jupyter 笔记本可以在以下 Git 文件夹中找到:github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2/tree/master/Chapter06.
本章稍后我们将介绍pydensecrf库,以提高分割结果。根据其 GitHub 页面的详细说明(请参阅文档:github.com/lucasb-eyer/pydensecrf#installation),此 Python 模块可以通过pip安装(pip install git+https://github.com/lucasb-eyer/pydensecrf.git),并需要安装最新版本的 Cython(pip install -U cython)。
使用编码器-解码器转换图像
如第一章《计算机视觉与神经网络》中所述,计算机视觉与神经网络,多个典型的计算机视觉任务要求像素级结果。例如,语义分割方法对图像的每个像素进行分类,智能编辑工具返回的图像会对某些像素进行修改(例如,删除不需要的元素)。在本节中,我们将介绍编码器-解码器,以及如何将遵循这一范式的卷积神经网络(CNN)应用于此类任务。
编码器-解码器简介
在处理复杂应用之前,让我们首先介绍什么是编码器-解码器及其所实现的功能。
编码与解码
编码器-解码器架构是一个非常通用的框架,广泛应用于通信、密码学、电子学等领域。根据该框架,编码器是一个将输入样本映射到潜在空间的函数,也就是由编码器定义的一组隐藏的结构化值。解码器是与之互补的函数,它将潜在空间中的元素映射到预定义的目标域。例如,可以构建一个编码器来解析媒体文件(其内容在潜在空间中表示为元素),并可以与一个解码器配对,后者将媒体内容输出为不同的文件格式。我们现在常用的图像和音频压缩格式就是这种类型的例子。JPEG 工具对我们的媒体进行编码,将其压缩为更小的二进制文件;然后它们解码这些文件以在显示时恢复像素值。
在机器学习中,编码器-解码器网络已经被使用了很长时间(例如,用于文本翻译)。一个编码器网络会将源语言的句子作为输入(例如,法语句子),并学习将它们投影到潜在空间中,在这个空间中,句子的意义会作为特征向量进行编码。解码器网络会与编码器一起训练,将编码后的向量转换为目标语言的句子(例如,英语)。
编码器-解码器模型中的潜在空间向量通常称为代码。
请注意,编码器-解码器的一个常见特性是其潜在空间比输入和目标潜在空间要小,如图 6-1所示:

图 6-1:在 MNIST 数据集上训练的自编码器示例(版权归 Yann LeCun 和 Corinna Cortes 所有)
在图 6-1中,编码器被训练将28 × 28的图像转换为32个值的向量(代码),而解码器被训练来恢复这些图像。这些代码可以与它们的类别标签一起绘制,以突出数据集中的相似性/结构(32维的向量通过t-SNE方法投影到二维平面上,该方法由 Laurens van der Maatens 和 Geoffrey Hinton 开发,并在笔记本中详细介绍)。
编码器被设计或训练来提取/压缩样本中包含的语义信息(例如,法语句子的意思,而不考虑该语言的语法特点)。然后,解码器根据其对目标领域的知识,解压/补充信息(例如,将编码后的信息转换为正确的英语句子)。
自编码
自编码器(AEs)是一种特殊类型的编码器-解码器。如图 6-1所示,它们的输入和目标领域是相同的,因此它们的目标是正确编码并解码图像,而不影响图像质量,尽管它们有一个瓶颈(即其较低维度的潜在空间)。输入被压缩为一种压缩表示(作为特征向量)。如果后来需要原始输入,它可以通过解码器从压缩表示中重构出来。
因此,JPEG 工具可以被称为自编码器(AEs),因为它们的目标是编码图像,然后将其解码回去而不失去太多质量。输入和输出数据之间的距离是自编码算法需要最小化的典型损失。对于图像,这个距离可以简单地通过交叉熵损失来计算,或者通过输入图像与结果图像之间的 L1/L2 损失(分别是曼哈顿距离和欧几里得距离)(如在第三章,现代神经网络中所示)。
在机器学习中,自编码网络非常容易训练,不仅因为其损失函数表达直接,如我们刚才所描述的那样,还因为其训练不需要任何标签。输入图像是用来计算损失的目标。
在机器学习专家中,对于自编码器存在分歧。有些人认为这些模型是无监督的,因为它们的训练不需要任何额外的标签。另一些人则认为,与纯粹的无监督方法(通常使用复杂的损失函数来发现无标签数据集中的模式)不同,自编码器有明确的目标(即,它们的输入图像)。因此,这些模型也常被称为自监督(即,它们的目标可以直接从输入中推导出来)。
由于自编码器的潜在空间较小,它们的编码子网络必须学习如何正确地压缩数据,而解码器必须学习如何正确地映射并解压数据。
如果没有瓶颈条件,对于具有快捷路径的网络(例如 ResNet),这种恒等映射将是直接的(参考第四章,影响力分类工具)。它们可以简单地将完整的输入信息从编码器传递到解码器。由于存在较低维度的潜在空间(瓶颈),它们被迫学习一种正确的压缩表示。
目的
关于更通用的编码器-解码器,它们的应用非常广泛。它们用于转换图像,将它们从一个域或模态映射到另一个域。例如,这些模型经常被应用于深度回归,即估计相机与图像内容(深度)之间的距离,对于每个像素。例如,这对增强现实应用非常重要,因为它们可以建立环境的 3D 表示,从而更好地与环境进行交互。
类似地,编码器-解码器常用于语义分割(参见第一章,计算机视觉和神经网络,以获取其定义)。在这种情况下,网络被训练不是返回深度,而是为每个像素估计的类别(参见图 6-2-c)。这个重要的应用将在本章的第二部分详细讨论。最后,编码器-解码器也因其更具艺术性的用例而闻名,例如将涂鸦艺术转换为伪现实图像或估算在夜间拍摄的照片的白天等效图像:

图 6-2:编码器-解码器的应用示例。这三个应用在本章的 Jupyter 笔记本中有详细说明和实现细节。
城市场景图像及其语义分割标签在图 6-2、图 6-10和图 6-11中来自Cityscapes数据集(www.cityscapes-dataset.com)。Cityscapes是一个很棒的数据集,也是应用于自动驾驶识别算法的基准。该数据集的研究者 Marius Cordts 等很慷慨地授权我们使用其中的一些图像来说明本书内容,并在本章后面的 Jupyter 笔记本中演示一些算法。
现在让我们考虑自动编码器(AE)。为什么一个网络应该被训练来返回其输入图像呢?答案再次在于 AE 的瓶颈特性。尽管编码和解码组件作为一个整体进行训练,但根据使用情况它们是分开应用的。
由于瓶颈的存在,编码器必须在尽可能保留信息的同时压缩数据。因此,如果训练数据集具有重复的模式,网络将尝试揭示这些相关性以改善编码。因此,AE 的编码器部分可以用来从其训练的域中获取图像的低维表示。它们提供的低维表示通常能够有效地保持图像之间的内容相似性,例如。因此,它们有时用于数据集可视化,以突出集群和模式(参见图 6-1)。
自动编码器(AEs)并不像 JPEG 等算法那样适合通用的图像压缩。事实上,AEs 是数据特定的;也就是说,它们只能有效地压缩它们所知道的领域中的图像(例如,训练在自然风景图像上的 AE 在处理肖像时效果较差,因为视觉特征差异太大)。然而,与传统压缩方法不同,AEs 对它们所训练的图像有更好的理解,了解其重复出现的特征、语义信息等等。
在某些情况下,自动编码器(AEs)会针对其解码器进行训练,解码器可以用于生成任务。实际上,如果潜在空间在训练过程中得到了恰当的结构化,那么从这个空间中随机挑选的任何向量都可以通过解码器转化为一张图像!正如我们将在本章后面简要解释的,以及在第七章《复杂和稀缺数据集的训练》中提到的,在复杂和稀缺数据集上的训练,训练一个用于生成新图像的解码器实际上并不像想象的那么简单,这需要一些精心的工程设计,才能使生成的图像看起来真实(这对于生成对抗网络(GANs)的训练尤其如此,我们将在下一章中详细说明)。
然而,去噪自动编码器(denoising AEs)是实践中最常见的 AE 实例。这些模型有一个特点,即它们的输入图像在传递给网络之前会经历一个有损的转换。由于这些模型仍然是被训练来恢复原始图像(即转换前的图像),它们将学会取消这个有损操作并恢复一些缺失的信息(参见图 6-2-a)。典型的模型会被训练来去除白噪声或高斯噪声,或者恢复丢失的内容(例如,遮挡或删除的图像区域)。此类 AEs 也被用于智能图像放大,也称为图像超分辨率。事实上,这些网络可以学习部分去除传统放大算法(如双线性插值)产生的伪影(即噪声)(参见图 6-2-b)。
基本示例 – 图像去噪
我们将通过一个简单的示例来说明 AEs 的有用性——去噪损坏的 MNIST 图像。
简单的全连接 AE
为了演示这些模型是如何简单又高效,我们将选择一个浅层的、全连接的架构,并使用 Keras 实现:
inputs = Input(shape=[img_height * img_width])
# Encoding layers:
enc_1 = Dense(128, activation='relu')(inputs)
code = Dense(64, activation='relu')(enc_1)
# Decoding layers:
dec_1 = Dense(64, activation='relu')(code)
preds = Dense(128, activation='sigmoid')(dec_1)
autoencoder = Model(inputs, preds)
# Training:
autoencoder.compile(loss='binary_crossentropy')
autoencoder.fit(x_train, x_train) # x_train as inputs and targets
我们在此强调了常见的对称编码器-解码器架构,及其低维瓶颈。为了训练我们的 AE,我们使用图像(x_train)作为输入和目标。训练完成后,这个简单的模型可以用来嵌入数据集,如图 6-1所示。
我们选择了sigmoid作为最后的激活函数,以便将输出值限制在 0 和 1 之间,像输入值一样。
应用于图像去噪
训练我们之前的图像去噪模型其实非常简单,只需创建一份带噪声的训练图像副本,并将其作为输入传递给我们的网络即可:
x_noisy = x_train + np.random.normal(loc=.0, scale=.5, size=x_train.shape)
autoencoder.fit(x_noisy, x_train)
本章的前两本笔记本详细介绍了训练过程,提供了插图和额外的提示(例如,用于可视化训练过程中预测的图像)。
卷积编码器-解码器
与其他基于神经网络(NN)的系统一样,编码器-解码器从卷积层和池化层的引入中受益匪浅。深度自编码器(DAEs)和其他架构很快就被广泛应用于越来越复杂的任务。
在本节中,我们将首先介绍为卷积编码器-解码器开发的新层。然后,我们将展示基于这些操作的一些重要架构。
反池化、转置和膨胀
正如我们在前几章中所看到的,例如在第三章,《现代神经网络》和第四章,《影响力分类工具》中,CNNs 是出色的特征提取器。它们的卷积层将输入张量转换为越来越高层次的特征图,而池化层则逐渐对数据进行下采样,从而生成紧凑且语义丰富的特征。因此,CNNs 非常适合作为高效的编码器。
然而,如何逆转这一过程,将这些低维特征解码为完整的图像呢?正如我们在接下来的段落中将要展示的那样,就像卷积和池化操作替代了密集层用于图像的编码一样,逆操作——如转置卷积(也称为反卷积)、膨胀卷积和反池化——也被开发出来,以更好地解码特征。
转置卷积(反卷积)
在第三章,《现代神经网络》中,我们介绍了卷积层、它们执行的操作,以及它们的超参数(卷积核大小k、输入深度D、卷积核数量N、填充p和步幅s)如何影响输出的维度(图 6-3可作为提醒)。对于形状为(H, W, D)的输入张量,我们提出了以下方程来评估输出形状(H[o], W[o], N):

现在,假设我们想要开发一个层来逆转卷积的空间变换。换句话说,给定形状为(H[o], W[o], N)的特征图和相同的超参数,k、D、N、p和s,我们希望一个类似卷积的操作能恢复一个形状为(H, W, D)的张量。在前面的方程式中隔离H和W,因此我们希望这个操作满足以下特性:

这就是反卷积的定义。如我们在第四章,影响力分类工具中简要提到的那样,这种新类型的层是由 Zeiler 和 Fergus 提出的,他们是 ZFNet 背后的研究人员,ZFNet 是 2013 年 ILSVRC 竞赛的获胜方法(可视化与理解卷积网络,Springer,2014)。
使用 k × k × D × N 的卷积核堆栈,这些层将 H[o] × W[o] × N 的张量卷积成 H × W × D 的映射。为了实现这一点,输入张量首先会经过膨胀操作。膨胀操作由一个速率 d 定义,包括在输入张量的每对行和列之间插入 d – 1 行和列的零值,如图 6-4所示。在反卷积中,膨胀率设置为 s(即它所反转的标准卷积所用的步幅)。经过这种重采样后,张量将使用 p' = k – p – 1 进行填充。膨胀和填充参数这样定义是为了恢复原始形状(H,W,D)。然后,张量最终使用步幅 s' = 1 与层的滤波器进行卷积,最终得到 H × W × D 的映射。标准卷积和反卷积的比较见图 6-3和图 6-4。
以下是标准卷积操作:

图 6-3:卷积层执行的操作提醒(这里定义为一个 3 × 3 的卷积核 w,填充 p = 1,步幅 s = 2)
请注意,在图 6-3中,补丁与卷积核之间的数学操作实际上是互相关(请参见第三章,现代神经网络)。
以下是反卷积操作:

图 6-4:反卷积层执行的操作,用于逆转标准卷积的空间变换(这里定义为 3 × 3 卷积核 w,填充 p = 1,膨胀 d = 2,如图 6-3 所示)
请注意,这次在图 6-4中,补丁与卷积核之间的操作是数学卷积。
如果这个过程看起来有些抽象,那么只需要记住,转置卷积层通常用于镜像标准卷积,以增加特征图的空间维度,同时与可训练的过滤器对其内容进行卷积。这使得这些层非常适合用于解码器架构。它们可以通过 tf.layers.conv2d_transpose()(参见www.tensorflow.org/api_docs/python/tf/layers/conv2d_transpose)和 tf.keras.layers.Conv2DTranspose()(参见www.tensorflow.org/api_docs/python/tf/keras/layers/Conv2DTranspose)进行实例化,这些方法的签名与标准的 conv2d 相同。
标准卷积和转置卷积之间还有一个微妙的区别,虽然在实际应用中没有什么重大影响,但知道这一点仍然是有益的。回到第三章,《现代神经网络》,我们提到 CNN 中的卷积层实际上执行的是交叉相关(cross-correlation)。如图 6-4所示,转置卷积层实际上使用的是数学卷积,翻转了卷积核的索引。
转置卷积也常被错误地称为反卷积。虽然确实存在一种名为反卷积的数学操作,但其执行方式与转置卷积不同。反卷积实际上完全恢复卷积,返回原始张量。而转置卷积仅仅是近似这个过程,返回形状相同的张量。如图 6-3和6-4所示,原始张量和最终张量的形状匹配,但它们的值并不相同。
转置卷积(Transposed convolutions)有时也被称为分数步幅卷积。实际上,输入张量的扩张(dilation)可以在某种程度上看作是使用分数步幅进行卷积的等效操作。
反池化(Unpooling)
尽管步幅卷积(strided convolutions)常用于 CNN 架构中,但在减少图像空间维度时,平均池化(average-pooling)和最大池化(max-pooling)是最常见的操作。因此,Zeiler 和 Fergus 还提出了一种最大反池化操作(通常简称为反池化),用于伪逆最大池化。他们在一个名为deconvnet的网络中使用了这一操作,用于解码和可视化其卷积网络(即 CNN)的特征。在描述他们的解决方案的论文中(在赢得 ILSVRC 2013 后发表,见《可视化和理解卷积网络》,Springer, 2014),他们解释道,尽管最大池化不是可逆的(也就是说,我们无法通过数学方式恢复池化操作丢弃的所有非最大值),但至少在空间采样方面,定义一个近似其反转的操作是可能的。
为了实现这个伪逆操作,他们首先修改了每个最大池化层,使其除了输出结果张量外,还输出池化掩码。换句话说,这个掩码表示所选最大值的原始位置。最大反池化操作的输入为池化后的张量(它可能在操作之间经过了其他保持形状的操作)和池化掩码。它利用池化掩码将输入值散布到一个上采样到池化前形状的张量中。图片胜过千言万语,图 6-5可能会帮助你理解该操作:

图 6-5:最大反池化操作示例,跟随一个最大池化层,并且该池化层也输出其池化掩码
注意,像池化层一样,反池化操作是固定/不可训练的操作。
上采样与调整大小
类似地,开发了平均反池化操作以模拟平均池化。后者操作将一个k × k 元素的池化区域进行平均,得到一个单一的值。因此,平均反池化层会将张量中的每个值复制到一个k × k 的区域,如图 6-6所示:

图 6-6:平均反池化操作示例(也称为上采样)
目前,这种操作比最大反池化操作使用得更为频繁,更常被称为上采样。例如,这个操作可以通过tf.keras.layers.UpSampling2D()来实例化(请参考www.tensorflow.org/api_docs/python/tf/keras/layers/UpSampling2D中的文档)。该方法本质上只是tf.image.resize()的包装器(请参考www.tensorflow.org/api_docs/python/tf/image/resize中的文档),当使用method=tf.image.ResizeMethod.NEAREST_NEIGHBOR参数调用时,用于通过最近邻插值来调整图像大小(正如其名称所示)。最后,注意双线性插值有时也用于放大特征图而无需添加任何训练参数,例如通过使用interpolation="bilinear"参数来实例化tf.keras.layers.UpSampling2D()(而不是默认的"nearest"值),这等同于使用默认的method=tf.image.ResizeMethod.BILINEAR属性来调用tf.image.resize()。
在解码器架构中,每次最近邻或双线性上采样通常后跟一个步长s=1 和填充"SAME"的卷积(以保持新的形状)。这些预定义的上采样和卷积操作的组合与组成编码器的卷积和池化层相对应,并允许解码器学习自己的特征,以更好地恢复目标信号。
一些研究人员,如 Augustus Odena,更喜欢这些操作而不是转置卷积,特别是在像图像超分辨率这样的任务中。事实上,当内核大小不是步长的倍数时,转置卷积往往会导致一些棋盘状伪影(由于特征重叠),从而影响输出质量(反卷积和棋盘伪影,Distill,2016)。
扩张/孔卷积
我们将在本章介绍的最后一个操作与前面的操作略有不同,因为它并非旨在对提供的特征图进行上采样。相反,它被提出是为了在不进一步牺牲数据的空间维度的情况下人为地增加卷积的感受野。为了实现这一点,在这里也应用了扩张(请参阅转置卷积(反卷积)部分),尽管方法有所不同。
确实,扩张卷积类似于标准卷积,但具有额外的超参数d,定义其内核所应用的扩张。图 6-7说明了这个过程如何人为地增加了层的感受野:

图 6-7:由扩张卷积层执行的操作(此处由 2 × 2 内核 w 定义,填充 p = 1,步长 s = 1,扩张 d = 2)
这些层也被称为孔卷积,来自法语表达à trous(带孔)。确实,虽然内核扩张增加了感受野,但却是通过在其中切割孔来实现的。
凭借这些特性,这种操作经常用于现代编码器-解码器中,将图像从一个域映射到另一个域。在 TensorFlow 和 Keras 中,实例化扩张卷积只需为tf.layers.conv2d()和tf.keras.layers.Conv2D()的dilation_rate参数提供高于默认值 1 的值。
开发这些各种操作旨在保留或增加特征图的空间性质,从而导致了多种 CNN 架构用于像素级密集预测和数据生成。
示例架构 – FCN 和 U-Net
大多数卷积编码器-解码器遵循与其全连接对应物相同的模板,但利用其局部连接层的空间性质以获得更高质量的结果。一个典型的卷积 AE 在 Jupyter 笔记本中展示。在本小节中,我们将介绍从这个基本模板衍生出的两个更先进的架构。发布于 2015 年的 FCN 和 U-Net 模型仍然很受欢迎,并且通常用作更复杂系统(如语义分割,域自适应等)的组成部分。
完全卷积网络
如在 第四章,影响力分类工具 中简要介绍的那样,全卷积网络(FCNs)基于 VGG-16 架构,最终的全连接层被 1 × 1 的卷积层替代。我们没有提到的是,这些网络通常会扩展为带有上采样模块的结构,并作为编码器-解码器使用。由加利福尼亚大学伯克利分校的 Jonathan Long、Evan Shelhamer 和 Trevor Darrell 提出的 FCN 架构完美地展示了前一小节中发展出的概念:
-
如何将用于特征提取的 CNNs 用作高效的编码器
-
然后,他们的特征图如何通过我们刚才介绍的操作有效地进行上采样和解码
事实上,Jonathan Long 等人建议重用预训练的 VGG-16 作为特征提取器(参见 第四章,影响力分类工具)。VGG-16 通过其五个卷积块有效地将图像转换为特征图,尽管在每个块后空间维度都会减半。为了从最后一个块解码特征图(例如,转换为语义掩码),用于分类的全连接层被卷积层替代。然后应用最终层——一个转置卷积,将数据上采样回输入形状(即,步幅为 s = 32,因为空间维度在 VGG 中被除以 32)。
然而,Long 等人很快注意到,这种名为 FCN-32s 的架构产生了过于 粗糙 的结果。正如他们在论文中解释的那样(《用于语义分割的全卷积网络》,《IEEE CVPR 会议论文集》,2015),最后一层的大步幅确实限制了细节的尺度。尽管来自最后一个 VGG 块的特征包含丰富的上下文信息,但它们的空间定义已经丢失了太多。因此,作者们想到将最后一个块的特征图与来自前面块的更大特征图进行融合。
在 FCN-16s 中,FCN-32s 的最后一层被一个步幅为 s = 2 的转置层替代,这样得到的张量与第四个块的特征图具有相同的维度。通过跳跃连接,将两个张量的特征进行合并(逐元素加法)。最终结果通过另一个转置卷积(步幅 s = 16)缩放回输入形状。在 FCN-8s 中,重复相同的过程,但使用来自第三个块的特征,最终再进行一个步幅 s = 8 的转置卷积。为了清晰起见,完整的架构在 图 6-8 中展示,下一示例中提供了一个 Keras 实现:

图 6-8:FCN-8s 架构。每个模块后的数据维度显示在图中,假设输入为 H × W。D[o] 表示所需的输出通道数。
图 6-8展示了 VGG-16 如何作为特征提取器/编码器,以及如何使用转置卷积进行解码。该图还强调了 FCN-32s 和 FCN-16s 是更简单、更轻量的架构,仅有一个或完全没有跳跃连接。
由于其使用迁移学习并融合了多尺度特征图,FCN-8s 能够输出具有细节的图像。此外,由于其完全卷积的特性,它可以应用于编码/解码不同大小的图像。FCN-8s 表现出色且具有多功能性,仍然广泛应用于许多领域,并且启发了其他多种架构。
U-Net
在受 FCN 启发的解决方案中,U-Net 架构不仅是最早的之一,而且可能是最受欢迎的(由 Olaf Ronneberger、Philipp Fischer 和 Thomas Brox 在一篇名为U-Net: Convolutional networks for biomedical image segmentation的论文中提出,该论文由 Springer 出版)。
该模型也用于语义分割(应用于医学影像),它与 FCN 有许多相似之处。它由一个多块的收缩型编码器组成,通过增加特征的深度而减少其空间维度,以及一个扩展型解码器,恢复图像的分辨率。此外,像 FCN 一样,跳跃连接用于将编码块与其解码对接块连接。因此,解码块既能接收来自前一块的上下文信息,也能接收来自编码路径的位置数据。
U-Net 与 FCN 在两个主要方面有所不同。与 FCN-8s 不同,U-Net 是对称的,采用传统的 U 形编码器-解码器结构(因此得名)。此外,通过连接(沿通道轴)而非加法来合并来自跳跃连接的特征图。U-Net 架构如图 6-9所示。至于 FCN,一个 Jupyter Notebook 专门用于从头实现 FCN:

图 6-9:U-Net 架构
还需要注意的是,虽然原始解码块具有升采样时 s = 2的转置卷积,但常见的实现使用最近邻插值缩放(请参阅前一小节的讨论)。由于其流行性,U-Net 已经有了许多变体,并且仍然启发了多种架构(例如,用残差块替代其原有块,并且增强块内和块外的连接性)。
中介示例——图像超分辨率
让我们简要地将这些模型应用到一个新问题——图像超分辨率(完整的实现和附加提示可以在相关的 Notebook 中找到)。
FCN 实现
记住我们刚刚介绍的架构,可以按照以下方式实现简化版的 FCN-8s(请注意,实际模型在每个转置卷积之前还有额外的卷积层):
inputs = Input(shape=(224, 224, 3))
# Building a pretrained VGG-16 feature extractor as encoder:
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=inputs)
# We recover the feature maps returned by each of the 3 final blocks:
f3 = vgg16.get_layer('block3_pool').output # shape: (28, 28, 256)
f4 = vgg16.get_layer('block4_pool').output # shape: (14, 14, 512)
f5 = vgg16.get_layer('block5_pool').output # shape: ( 7, 7, 512)
# We replace the VGG dense layers by convs, adding the "decoding" layers instead after the conv/pooling blocks:
f3 = Conv2D(filters=out_ch, kernel_size=1, padding='same')(f3)
f4 = Conv2D(filters=out_ch, kernel_size=1, padding='same')(f4)
f5 = Conv2D(filters=out_ch, kernel_size=1, padding='same')(f5)
# We upscale `f5` to a 14x14 map so it can be merged with `f4`:
f5x2 = Conv2DTranspose(filters=out_chh, kernel_size=4,strides=2,
padding='same', activation='relu')(f5)
# We merge the 2 feature maps with an element-wise addition:
m1 = add([f4, f5x2])
# We repeat the operation to merge `m1` and `f3` into a 28x28 map:
m1x2 = Conv2DTranspose(filters=out_ch, kernel_size=4, strides=2,
padding='same', activation='relu')(m1)
m2 = add([f3, m1x2])
# Finally, we use a transp-conv to recover the original shape:
outputs = Conv2DTranspose(filters=out_ch, kernel_size=16, strides=8,
padding='same', activation='sigmoid')(m2)
fcn_8s = Model(inputs, outputs)
通过重用 VGG 的 Keras 实现和功能性 API,可以轻松创建一个 FCN-8s 模型。
应用于图像放大
训练超分辨率网络的一个简单技巧是使用传统的放大方法(如双线性插值)将图像放大到目标尺寸,然后再输入模型。通过这种方式,网络可以作为去噪自编码器进行训练,任务是清除上采样伪影并恢复丢失的细节:
x_noisy = bilinear_upscale(bilinear_downscale(x_train)) # pseudo-code
fcn_8s.fit(x_noisy, x_train)
适当的代码和完整的图像演示可以在笔记本中找到。
如前所述,我们刚刚介绍的架构通常应用于多种任务,如从彩色图像中估计深度、下一帧预测(即预测下一帧图像的内容,输入为一系列视频帧)和图像分割。在本章的第二部分,我们将深入探讨后一项任务,这在许多现实应用中至关重要。
理解语义分割
语义分割是将图像划分为有意义部分的任务的更广泛术语。它包括对象分割和实例分割,这两者在第一章《计算机视觉与神经网络》中介绍。与前几章中涉及的图像分类和目标检测不同,分割任务要求方法返回像素级的密集预测,即为输入图像中的每个像素分配一个标签。
在更详细地解释为什么编码器-解码器如此擅长对象分割,以及如何进一步优化其结果之后,我们将介绍一些针对更复杂任务——实例分割的解决方案。
使用编码器-解码器进行对象分割
正如我们在本章第一部分所看到的,编码-解码网络被训练来将数据样本从一个领域映射到另一个领域(例如,从噪声到无噪声,或从彩色到深度)。对象分割可以看作是其中的一种操作——将图像从颜色域映射到类别域。根据其价值和上下文,我们希望为图片中的每个像素分配一个目标类别,从而返回具有相同高度和宽度的标签图。
教授编码器-解码器将图像转换为标签图仍然需要一些考虑,我们现在将讨论这一点。
概述
在接下来的段落中,我们将展示如何使用 U-Net 等网络进行对象分割,以及如何进一步处理其输出以生成精细的标签图。
解码为标签图
构建直接输出标签图的编码器-解码器——其中每个像素值表示一个类别(例如,1表示狗,2表示猫)——会产生较差的结果。与分类器一样,我们需要一种更好的方法来输出类别值。
为了将图像分类为 N 类别,我们学习了如何构建网络,使最终层输出 N 个 logits,表示每个类别的预测得分。我们还学习了如何使用 softmax 操作将这些得分转换为概率,并通过选择最大值(例如,使用 argmax)返回最有可能的类别。相同的机制可以应用于语义分割,在像素级别而不是图像级别。我们的网络被构建为返回一个 H × W × N 的张量,其中包含每个像素的得分(参见 图 6-10):

图 6-10:给定一个尺寸为 H × W 的输入图像,网络返回一个 H × W × N 的概率图,其中 N 是类别数。使用 argmax 可以获得预测的标签图。
对于本章中展示的架构,获得这样的输出张量只是设置 D**[o] = N 的问题,也就是在构建模型时将输出通道数设置为类别数(参见 图 6-8 和 图 6-9)。然后可以将它们作为分类器进行训练。交叉熵损失用于将 softmax 值与 one-hot 编码的真实标签图进行比较(尽管被比较的张量在分类时具有更多的维度,但这不会影响计算)。此外,H × W × N 的预测结果也可以通过选择沿通道轴的最大值索引(即,沿通道轴的 argmax)类似地转换为每个像素的标签。例如,前面展示的 FCN-8s 代码可以调整为训练一个用于物体分割的模型,如下所示:
inputs = Input(shape=(224, 224, 3))
out_ch = num_classes = 19 # e.g., for object segmentation over Cityscapes
# [...] building e.g. a FCN-8s architecture, c.f. previous snippet.
outputs = Conv2DTranspose(filters=out_ch, kernel_size=16, strides=8,
padding='same', activation=None)(m2)
seg_fcn = Model(inputs, outputs)
seg_fcn.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
# [...] training the network. Then we use it to predict label maps:
label_map = np.argmax(seg_fcn.predict(image), axis=-1)
Git 仓库包含一个完整的 FCN-8s 模型示例,该模型已构建并训练用于语义分割,同时还包含一个 U-Net 模型。
使用分割损失和指标进行训练
使用先进的架构,如 FCN-8s 和 U-Net,是构建高性能语义分割系统的关键。然而,最先进的模型仍然需要一个合适的损失函数才能最优化收敛。虽然交叉熵是训练模型进行粗分类和密集分类的默认损失,但在后者的情况下应采取适当的预防措施。
对于图像级和像素级分类任务,类别不平衡是一个常见问题。假设我们训练一个模型,数据集包含 990 张猫的图片和 10 张狗的图片。如果模型学会总是输出猫,那么它将在训练集上达到 99%的准确率,但在实际应用中并没有什么用处。对于图像分类,可以通过添加或删除图片来避免此问题,从而使所有类别以相同的比例出现。对于像素级分类问题,这个问题更为棘手。一些类别可能出现在每张图像中,但仅占很少的像素,而其他类别可能覆盖大部分图像(例如,对于自动驾驶汽车应用中的交通标志与道路类别)。数据集无法通过编辑来弥补这种不平衡。
为了防止分割模型对大类别产生偏倚,它们的损失函数应该进行调整。例如,通常的做法是加权每个类别对交叉熵损失的贡献。如我们在自动驾驶汽车语义分割笔记本中展示的以及图 6-11所示,类别在训练图像中出现得越少,它在损失中的权重就应越大。这样,如果网络开始忽略较小的类别,就会受到严重惩罚:

图 6-11:语义分割中像素加权策略示例(像素越亮,其在损失中的权重越大)
权重图通常是根据地面真值标签图计算得出的。需要注意的是,如图 6-11所示,应用于每个像素的权重不仅可以根据类别设置,还可以根据像素相对于其他元素的位置进行设置,等等。
另一种解决方案是用另一种不受类别比例影响的代价函数来替代交叉熵。毕竟,交叉熵是一个代理准确性函数,由于其良好的可微性而被采用。然而,这个函数并未真正表达我们模型的实际目标——正确分割不同的类别,无论它们的面积如何。因此,研究人员提出了几种针对语义分割的损失函数和度量指标,以更明确地捕捉这一目标。
交集并集比(IoU),在第五章中介绍,目标检测模型,是常见的评估指标之一。Sørensen–Dice 系数(通常简称为Dice 系数)是另一个。像 IoU 一样,它衡量两个集合的重叠程度:

在这里,|**A|和|B|表示每个集合的基数(参考上一章中的解释),而
表示它们的交集中的元素数量(交集的基数)。IoU 和 Dice 有几个共同特性,实际上其中一个可以帮助计算另一个:

在语义分割中,Dice 因此用来衡量每个类的预测掩码与实际掩码的重叠程度。对于一个类,分子表示正确分类的像素数,分母表示该类在预测和实际掩码中所有像素的总数。作为一种度量,Dice 系数因此不依赖于一个类在图像中所占的像素相对数量。在多类任务中,科学家通常计算每个类的 Dice 系数(比较每一对预测和实际掩码),然后取其平均值。
从公式中可以看到,Dice 系数的定义范围在 0 和 1 之间——如果 A 和 B 完全不重叠,则其值为 0;如果它们完全重叠,则值为 1。因此,为了将其作为一个网络应当最小化的损失函数,我们需要反转这个评分。总的来说,对于应用于 N 类的语义分割,Dice 损失通常定义如下:

让我们再澄清一下这个公式。如果 a 和 b 是两个一热编码张量,那么 Dice 分子(即它们的交集)可以通过对它们进行元素级的相乘来近似(参见 第一章,计算机视觉与神经网络),然后将得到的张量中的所有值相加。分母是通过将 a 和 b 中的所有元素相加得到的。最后,如果张量为空,通常会在分母中添加一个小值,
(例如,低于 1e-6),并在分子中也添加该小值,以避免除零错误,并平滑结果。
请注意,在实际应用中,与实际的 ground truth 一热编码张量不同,预测值并不包含二进制值。它们由连续从 0 到 1 的 softmax 概率组成。因此,这种损失函数通常被称为 soft Dice。
在 TensorFlow 中,损失函数可以如下实现:
def dice_loss(labels, logits, num_classes, eps=1e-6, spatial_axes=[1, 2]):
# Transform logits in probabilities, and one-hot the ground truth:
pred_proba = tf.nn.softmax(logits, axis=-1)
gt_onehot = tf.one_hot(labels, num_classes, dtype=tf.float32)
# Compute Dice numerator and denominator:
num_perclass = 2 * tf.reduce_sum(pred_proba * gt_onehot, axis=spatial_axes)
den_perclass = tf.reduce_sum(pred_proba + gt_onehot, axis=spatial_axes)
# Compute Dice and average over batch and classes:
dice = tf.reduce_mean((num_perclass + eps) / (den_perclass + eps))
return 1 - dice
Dice 和 IoU 是分割任务中重要的工具,它们的实用性在相关的 Jupyter notebook 中得到了进一步的展示。
使用条件随机场进行后处理
正确标记每个像素是一个复杂的任务,通常会得到预测的标签图,其中轮廓较差并且存在小的错误区域。幸运的是,有一些方法可以对结果进行后处理,修正一些明显的缺陷。在这些方法中,条件随机场(CRFs)方法因其整体效率而最为流行。
这背后的理论超出了本书的范围,但 CRFs 能够通过考虑每个像素在原始图像中的上下文来提高像素级的预测。如果两个相邻像素之间的颜色梯度较小(即颜色没有急剧变化),那么它们很可能属于同一类别。考虑到这个基于空间和颜色的模型,以及预测器提供的概率图(在我们的案例中,是 CNN 的 softmax 张量),CRF 方法返回的是经过精细化的标签图,它们在视觉轮廓方面更为准确。
有多个现成的实现可用,例如由 Lucas Beyer 开发的pydensecrf(github.com/lucasb-eyer/pydensecrf),这是一个 Python 包装器,用于实现 Philipp Krähenbühl 和 Vladlen Koltun 提出的带高斯边缘潜力的稠密 CRF(参见Efficient inference in fully connected CRFs with gaussian edge potentials,Advances in neural information processing systems,2011)。在本章的最后一个笔记本中,我们将解释如何使用这个框架。
高级示例 – 自驾车的图像分割
如本章开头所建议的,我们将把这些新知识应用于一个复杂的实际用例——自驾车的交通图像分割。
任务展示
像人类驾驶员一样,自驾车需要理解其环境,并意识到周围的元素。将语义分割应用于前置摄像头的视频图像可以让系统知道是否有其他汽车在周围,知道是否有行人或自行车正在穿越道路,是否在跟随交通线和标志等。
因此,这是一个关键过程,研究人员正在投入大量精力来优化模型。因此,有多个相关数据集和基准可供使用。我们为演示选择的Cityscapes数据集(www.cityscapes-dataset.com)是最著名的之一。由 Marius Cordts 等人分享(参见The Cityscapes Dataset for Semantic Urban Scene Understanding,IEEE CVPR Conference Proceedings),它包含来自多个城市的视频序列,并为超过 19 个类别(如道路、汽车、植物等)提供语义标签。专门有一个笔记本用于帮助入门该基准。
示例解决方案
在本章的两个最终 Jupyter 笔记本中,使用 FCN 和 U-Net 模型训练来处理这个任务,运用了本节中介绍的多个技巧。我们展示了如何在计算损失时正确地对每个类别加权,如何对标签图进行后处理,等等。
由于整个解决方案比较长,且笔记本更适合展示当前代码,我们邀请你如果对这个用例感兴趣,可以继续阅读笔记本。这样,我们可以将本章的其余部分专注于另一个引人入胜的问题——实例分割。
实例分割的更难情况
使用为对象分割训练的模型时,softmax 输出表示每个像素属于 N 个类别之一的概率。然而,它并不表示两个像素或像素块是否属于同一类别的实例。例如,给定如 图 6-10 所示的预测标签图,我们无法统计出 树 或 建筑物 实例的数量。
在接下来的子章节中,我们将介绍通过扩展已解决的两个相关任务——对象分割和对象检测——的解决方案来实现实例分割的两种不同方法。
从对象分割到实例分割
首先,我们将介绍一些工具,帮助我们从刚才提到的分割模型中获得实例掩模。U-Net 的作者普及了调整编码器-解码器的概念,使其输出可用于实例分割。这个想法被 Alexander Buslaev、Victor Durnov 和 Selim Seferbekov 推得更远,他们因在 2018 年 Kaggle 数据科学大赛(www.kaggle.com/c/data-science-bowl-2018)中获胜而广为人知,这场赞助比赛旨在推进医学应用中的实例分割技术。
尊重边界
如果语义掩模捕获的元素是良好分隔/不重叠的,那么将掩模拆分以区分每个实例并不是一项复杂的任务。现在有很多算法可以用来估计二值矩阵中不同块的轮廓,和/或为每个块提供一个单独的掩模。对于多类别实例分割,这个过程可以针对对象分割方法返回的每个类别掩模重复进行,进一步将它们拆分成实例。
但是,首先应该获得精确的语义掩模,否则相互过于接近的元素可能会被当作一个整体返回。那么,我们如何确保分割模型在生成具有精确轮廓的掩模时能够给予足够的关注,至少对于不重叠的元素?我们已经知道答案——教网络做某件具体的事情的唯一方法是相应地调整它们的训练损失。
U-Net 是为生物医学应用而开发的,用于分割显微镜图像中的神经结构。为了教会网络正确分离相邻细胞,作者决定对损失函数进行加权,以更重地惩罚位于多个实例边界的错误分类像素。正如在图 6-11中所示,这种策略与我们在前一小节中介绍的逐类损失加权非常相似,尽管这里的加权是针对每个像素具体计算的。U-Net 的作者提出了一个公式来基于地面真实类掩膜计算这些权重图。对于每个像素和每个类,该公式考虑了像素到最近两个类实例的距离。两个距离越小,权重越大。权重图可以预先计算并与地面真实掩膜一起存储,以便在训练时共同使用。
请注意,这种逐像素的加权可以与多类场景中的逐类加权相结合。对图像中某些区域给予更高惩罚的思想也可以适应于其他应用(例如,更好地分割制造物体的关键部件)。
我们提到了 2018 年 Kaggle 数据科学挑战赛的获胜者,他们对这个思想做出了值得注意的改进。对于每个类,他们的定制 U-Net 输出了两个掩膜:一个是预测逐像素类别概率的常规掩膜,另一个是捕捉类别边界的掩膜。地面真实的边界掩膜是根据类别掩膜预先计算的。在适当训练后,来自两个预测掩膜的信息可以用于获得每个类的良好分离元素。
后处理成实例掩膜
正如前一小节所讨论的,一旦获得精确的掩膜,就可以通过应用适当的算法从中识别出不重叠的实例。这个后处理通常使用形态学函数,如掩膜****腐蚀和膨胀来完成。
分水岭变换是另一类常见的算法,可以将类别掩膜进一步分割成实例。这些算法将单通道张量视为一个地形表面,其中每个值代表一个高程。通过我们不会详细讨论的各种方法,它们提取出代表实例边界的山脊顶部。这些变换的多种实现可用,其中一些是基于 CNN 的,例如 Min Bai 和 Raquel Urtasun(来自多伦多大学)所提出的Deep watershed transform for instance segmentation(IEEE CVPR 会议论文集,2017)。受到 FCN 架构的启发,他们的网络将预测的语义掩膜和原始 RGB 图像作为输入,输出一个能量图,能够用于识别山脊。得益于 RGB 信息,这种解决方案甚至能够准确地分离重叠的实例。
从目标检测到实例分割 – Mask R-CNN
处理实例分割的第二种方法是从物体检测的角度出发。在第五章,物体检测模型中,我们介绍了如何返回图像中出现的物体实例的边界框。在接下来的段落中,我们将展示如何将这些结果转化为更精细的实例掩码。更准确地说,我们将介绍Mask R-CNN,它是一个扩展Faster R-CNN的网络。
将语义分割应用于边界框
当我们在第一章中介绍物体检测时,计算机视觉与神经网络部分,我们解释了这个过程通常作为一个初步步骤,提供包含单个实例的图像块以供进一步分析。考虑到这一点,实例分割就变成了两步操作:
-
使用物体检测模型返回每个目标类别实例的边界框
-
将每个图像块输入到语义分割模型中以获得实例掩码
如果预测的边界框准确(每个边界框捕捉到一个完整的、单一的元素),那么分割网络的任务就很简单——就是分类哪些像素属于捕捉到的类别,哪些像素属于背景/属于其他类别。
这种解决实例分割的方法是有优势的,因为我们已经拥有了实现它所需的所有工具(物体检测和语义分割模型)!
使用 Faster-RCNN 构建实例分割模型
虽然我们可以简单地使用预训练的检测网络,再接上预训练的分割网络,但如果这两个网络被串联在一起并以端到端的方式训练,整个管道的效果肯定会更好。通过公共层反向传播分割损失将更好地确保提取的特征对于检测和分割任务都具有意义。这几乎就是Facebook AI Research(FAIR)的 Kaiming He 等人在 2017 年提出的Mask R-CNN的原始思想(Mask R-CNN,IEEE CVPR 会议论文)。
如果这个名字让你有些印象,Kaiming He 也是 ResNet 和 Faster R-CNN 的主要作者之一。
Mask R-CNN 主要基于 Faster R-CNN。与 Faster R-CNN 一样,Mask R-CNN 由一个区域提议网络组成,后面跟着两个分支,分别预测每个提议区域的类别和框偏移量(参见第五章,目标检测模型)。然而,作者在此基础上扩展了该模型,增加了一个第三并行分支,为每个区域中的元素输出二进制掩码(如图 6-12所示)。需要注意的是,这个附加分支仅由几个标准卷积和转置卷积组成。正如作者在论文中强调的那样,这种并行处理遵循 Faster R-CNN 的精神,与其他实例分割方法形成对比,后者通常是顺序进行的:

图 6-12:基于 Faster R-CNN 的 Mask R-CNN 架构
得益于这种并行处理,He 等人能够解耦分类和分割。虽然分割分支被定义为输出N个二进制掩码(每个类别一个,就像任何常见的语义分割模型一样),但只有与另一分支预测的类别对应的掩码会被考虑用于最终预测和训练损失。换句话说,只有实例类别的掩码才会对应用于分割分支的交叉熵损失产生贡献。正如作者所解释的那样,这使得分割分支能够在没有类别之间竞争的情况下预测标签图,从而简化了其任务。
Mask R-CNN 作者的另一个著名贡献是RoI align 层,取代了 Faster R-CNN 中的RoI 池化。二者之间的差异实际上相当微妙,但提供了显著的准确度提升。RoI 池化会引入量化误差,例如通过离散化子窗口单元的坐标(参见第五章,目标检测模型,以及图 5-13)。虽然这对分类分支的预测影响不大(它对这些微小的错位具有鲁棒性),但会影响分割分支的像素级预测质量。为了避免这种情况,He 等人简单地去除了离散化,而是使用了双线性插值来获取单元格的内容。
Mask R-CNN 在 COCO 2017 挑战中脱颖而出,现如今被广泛使用。多个实现可以在线找到,例如,在专门用于目标检测和实例分割的tensorflow/models库的文件夹中(github.com/tensorflow/models/tree/master/research/object_detection)。
总结
本章中,我们介绍了几种用于像素精确应用的范式。我们介绍了编码器-解码器及一些特定架构,并将其应用于从图像去噪到语义分割的多个任务。我们还展示了如何结合不同的解决方案来应对更复杂的问题,例如实例分割。
随着我们处理越来越复杂的任务,新的挑战也随之而来。例如,在语义分割中,精确标注图像以训练模型是一项耗时的工作。因此,现有的数据集通常稀缺,应该采取具体措施避免过拟合。此外,由于训练图像及其真实标注较大,需构建良好的数据管道以实现高效训练。
在接下来的章节中,我们将深入介绍如何有效地使用 TensorFlow 来增强和服务训练批次。
问题
-
AEs 有什么特殊性?
-
FCNs 基于哪种分类架构?
-
如何训练语义分割模型,使其不忽视小类别?
进一步阅读
Mask R-CNN (openaccess.thecvf.com/content_iccv_2017/html/He_Mask_R-CNN_ICCV_2017_paper.html) 由 Kaiming He、Georgia Gkioxari、Piotr Dollar 和 Ross Girshick 提出:本章中提到的这篇精心撰写的会议论文介绍了 Mask R-CNN,提供了更多的插图和细节,可能有助于您理解该模型。
第三部分:计算机视觉的高级概念与新前沿
本节讨论了我们领域中的几个当代挑战,并为那些希望将计算机视觉应用于新兴用例的人提供了必要的技术。首先,介绍了设计用于高效处理大量数据的 TensorFlow 工具。在处理数据过于稀缺的相反场景中,还将介绍领域适应技术,以及使用计算机图形学、生成对抗网络(GANs)和变分自编码器(VAEs)进行图像生成的技术。为了学习如何从视频中提取信息,本章专门介绍了递归神经网络、它们的理论及一些应用实例。最后,本书以讨论与设备端计算机视觉相关的挑战作为结尾,教你如何在手机和网页浏览器上部署解决方案。
本节将涵盖以下章节:
-
第七章,复杂和稀缺数据集的训练
-
第八章,视频与递归神经网络
-
第九章,优化模型并在移动设备上部署
第七章:在复杂且稀缺的数据集上进行训练
数据是深度学习应用的命脉。因此,训练数据应该能够顺畅地流入网络,并且应包含所有对准备方法任务至关重要的有意义信息。然而,数据集往往有复杂的结构,或者存储在异构设备上,这使得将其内容高效地提供给模型的过程变得复杂。在其他情况下,相关的训练图像或注释可能不可用,从而剥夺了模型学习所需的信息。
幸运的是,在前述情况中,TensorFlow 提供了一个丰富的框架来建立优化的数据管道——tf.data。在后者的情况下,当相关的训练数据稀缺时,研究人员提出了多种替代方法——数据增强、合成数据集生成、域适应等。这些替代方法还将为我们提供机会,详细阐述生成模型,如 变分自编码器(VAEs)和 生成对抗网络(GANs)。
本章将涵盖以下主题:
-
如何使用
tf.data构建高效的输入管道,提取和处理各种样本 -
如何增强和渲染图像,以弥补训练数据的稀缺性
-
域适应方法是什么,它们如何帮助训练更强大的模型
-
如何使用生成模型(如 VAE 和 GAN)创建新颖的图像
技术要求
再次提醒,多个 Jupyter 笔记本和相关源文件用于说明本章内容,可以在专门为本书创建的 Git 仓库中找到:github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2/tree/master/Chapter07。
笔记本需要一些额外的 Python 包,演示如何从 3D 模型中渲染合成图像,如 vispy (vispy.org) 和 plyfile (github.com/dranjan/python-plyfile)。安装说明已在笔记本中提供。
高效的数据提供
精确的输入管道不仅可以大大减少训练模型所需的时间,还可以更好地预处理训练样本,引导网络朝向更高效的配置。在本节中,我们将演示如何构建这些优化管道,深入探讨 TensorFlow tf.data API。
介绍 TensorFlow 数据 API
尽管 tf.data 在 Jupyter 笔记本中已经出现过多次,但我们还没有正式介绍这个 API 及其多个方面。
TensorFlow 数据 API 背后的直觉
在介绍 tf.data 之前,我们将提供一些背景,以证明它与深度学习模型训练的相关性。
为快速且数据需求量大的模型提供输入
神经网络(NNs)是数据饥渴型的模型。它们在训练过程中能够迭代的训练数据集越大,神经网络的准确性和鲁棒性就会越强。正如我们在实验中已经注意到的那样,训练一个网络是一项繁重的任务,可能需要数小时,甚至数天的时间。
随着 GPU/TPU 硬件架构的不断提升,随着这些设备逐渐变得更加高效,每次训练迭代中前向传播和反向传播所需的时间不断减少(对于能够负担这些设备的人来说)。如今的速度已经快到神经网络倾向于消耗训练批次的速度超过典型输入管道生产它们的速度。这在计算机视觉中尤为真实。图像数据集通常太庞大,无法完全预处理,并且实时读取/解码图像文件可能会导致显著的延迟(尤其是在每次训练中反复执行时)。
懒加载结构的启示
更普遍地说,随着大数据的崛起,近年来出现了大量的文献、框架、最佳实践等,为各种应用提供了处理和服务大量数据的新解决方案。tf.data API 是 TensorFlow 开发人员在这些框架和实践的基础上构建的,旨在提供一个清晰且高效的框架来为神经网络提供数据。更准确地说,该 API 的目标是定义输入管道,使其能够在当前步骤完成之前为下一步提供数据(参考官方 API 指南,www.tensorflow.org/guide/performance/datasets)。
正如 Derek Murray(谷歌的一位 TensorFlow 专家)在几次线上演示中所解释的那样(他的一次演示被录制成视频,并可以在www.youtube.com/watch?v=uIcqeP7MFH0观看),通过tf.data API 构建的管道与功能语言中的懒加载列表类似。它们可以以按需调用的方式批量处理巨大的或无限的数据集(例如,当新数据样本实时生成时就是无限的)。它们提供如map()、reduce()、filter()和repeat()等操作来处理数据并控制其流动。它们可以与 Python 生成器进行比较,但具有更先进的接口,更重要的是,它们具有 C++的计算性能支撑。尽管你可以手动实现一个多线程的 Python 生成器来与主训练循环并行处理和提供批次数据,但tf.data能够开箱即用地完成所有这些工作(并且很可能以更优化的方式完成)。
TensorFlow 数据管道的结构
正如前面几段所指出的,数据科学家们已经积累了大量关于大型数据集处理和管道化的专业知识,而tf.data管道的结构直接遵循了这些最佳实践。
提取、转换、加载
API 指南还将训练数据管道与提取、转换、加载(ETL)过程进行了类比。ETL 是计算机科学中常见的数据处理范式。在计算机视觉中,负责为模型提供训练数据的 ETL 管道通常是这样的:

图 7-1:提供计算机视觉模型训练数据的典型 ETL 管道
提取步骤包括选择数据源并提取其内容。这些源可以由文档显式列出(例如,包含所有图像文件名的 CSV 文件),也可以隐式列出(例如,数据集中的所有图像已存储在特定文件夹中)。这些源可能存储在不同设备上(本地或远程),提取器的任务还包括列出这些不同的源并提取其内容。例如,在计算机视觉中,数据集通常非常庞大,需要将其存储在多个硬盘上。为了以有监督的方式训练神经网络,我们还需要提取图像的标注/真实值(例如,存储在 CSV 文件中的类别标签,以及存储在另一个文件夹中的真实分割掩码)。
获取的数据样本接下来应进行转换。最常见的转换之一是将提取的数据样本解析为统一格式。例如,这意味着将从图像文件中读取的字节解析为矩阵表示(例如,将 JPEG 或 PNG 字节解码为图像张量)。在此步骤中,还可以应用其他较重的转换,例如将图像裁剪/缩放为相同的尺寸,或使用各种随机操作对图像进行增强。同样,这也适用于有监督学习的标注。它们也应该被解析,例如,解析为张量,以便稍后传递给损失函数。
一旦准备好,数据将被加载到目标结构中。对于机器学习方法的训练,这意味着将批次样本发送到负责运行模型的设备,例如所选的 GPU。处理后的数据集还可以缓存/保存到某个地方以备后用。
例如,这个 ETL 过程已经在 Jupyter notebook 中观察到,在第六章中设置了Cityscapes输入管道,增强和分割图像。输入管道遍历提供的输入/真实值文件名,并解析和增强它们的内容,然后将结果作为批次传递给我们的训练过程。
API 接口
tf.data.Dataset是tf.data API 提供的核心类(参考文档:www.tensorflow.org/api_docs/python/tf/data/Dataset)。该类的实例(通常称为数据集)代表数据源,遵循我们刚刚介绍的懒加载列表范式。
数据集可以通过多种方式初始化,这取决于它们的内容最初是如何存储的(例如文件、NumPy 数组、张量等)。例如,数据集可以基于一个图像文件的列表,如下所示:
dataset = tf.data.Dataset.list_files("/path/to/dataset/*.png")
数据集还拥有许多可以应用于自身的方法,以提供一个变换后的数据集。例如,以下函数返回一个新的数据集实例,将文件的内容正确转换(即解析)为统一大小的图像张量:
def parse_fn(filename):
img_bytes = tf.io.read_file(filename)
img = tf.io.decode_png(img_bytes, channels=3)
img = tf.image.resize(img, [64, 64])
return img # or for instance, `{'image': img}` if we want to name this input
dataset = dataset.map(map_func=parse_fn)
传递给.map()的函数将在遍历数据集时应用于每个样本。事实上,一旦所有必要的转换应用完成,数据集可以像任何懒加载的列表/生成器一样使用,如下所示:
print(dataset.output_types) # > "tf.uint8"
print(dataset.output_shapes) # > "(64, 64, 3)"
for image in dataset:
# do something with the image
所有的数据样本已经作为Tensor返回,并可以轻松加载到负责训练的设备上。为了更加简便,tf.estimator.Estimator和tf.keras.Model实例可以直接接收tf.data.Dataset对象作为输入进行训练(对于估算器,数据集操作必须包装成一个返回数据集的函数),如下所示:
keras_model.fit(dataset, ...) # to train a Keras model on the data
def input_fn():
# ... build dataset
return dataset
tf_estimator.train(input_fn, ...) # ... or to train a TF estimator
通过将估算器和模型与tf.data API 紧密集成,TensorFlow 2 使得数据预处理和数据加载变得更加模块化且清晰。
设置输入管道
牢记 ETL 过程,我们将开发tf.data提供的一些最常见和最重要的方法,至少是针对计算机视觉应用的。对于完整的列表,我们邀请读者参考文档(www.tensorflow.org/api_docs/python/tf/data)。
提取(来自张量、文本文件、TFRecord 文件等)
数据集通常是为特定需求而构建的(如公司收集图像以训练更智能的算法、研究人员设置基准测试等),因此很少能找到结构和格式相同的两个数据集。幸运的是,TensorFlow 的开发者对此非常清楚,并提供了大量工具来列出和提取数据。
从 NumPy 和 TensorFlow 数据
首先,如果数据样本已经以某种方式被程序加载(例如,作为 NumPy 或 TensorFlow 结构),则可以通过.from_tensors()或.from_tensor_slices()静态方法直接传递给tf.data。
两者都接受嵌套的数组/张量结构,但后者会沿第一个轴切片数据,将其拆分为样本,如下所示:
x, y = np.array([1, 2, 3, 4]), np.array([5, 6, 7, 8])
d = tf.data.Dataset.from_tensors((x,y))
print(d.output_shapes) # > (TensorShape([4]), TensorShape([4]))
d_sliced = tf.data.Dataset.from_tensor_slices((x,y))
print(d_sliced.output_shapes) # > (TensorShape([]), TensorShape([]))
如我们所观察到的,第二个数据集d_sliced最终包含四对样本,每对仅包含一个值。
从文件中
如前面的示例所示,数据集可以使用.list_files()静态方法遍历文件。此方法创建一个字符串张量的数据集,每个张量包含一个列出文件的路径。然后可以通过例如tf.io.read_file()来打开每个文件(tf.io包含与文件相关的操作)。
tf.data API 还提供了一些特定的数据集,用于遍历二进制或文本文件。tf.data.TextLineDataset()可以按行读取文档(这对于某些公开数据集很有用,它们将图像文件和/或标签列出在文本文件中);tf.data.experimental.CsvDataset()也可以解析 CSV 文件,并按行返回其内容。
tf.data.experimental并不保证与其他模块的向后兼容性。当本书到达读者手中时,一些方法可能已经被移到tf.data.Dataset中,或者已经被删除(对于一些是 TensorFlow 限制的临时解决方案的函数)。我们邀请读者查看文档。
从其他输入(生成器、SQL 数据库、范围等)
虽然我们不会列举所有的情况,但需要记住的是,tf.data.Dataset可以从多种输入源进行定义。例如,简单遍历数字的数据集可以通过.range()静态方法初始化。数据集也可以基于 Python 生成器使用.from_generator()构建。最后,即使元素存储在 SQL 数据库中,TensorFlow 也提供了一些(实验性的)工具来查询数据库,包括以下内容:
dataset = tf.data.experimental.SqlDataset(
"sqlite", "/path/to/my_db.sqlite3",
"SELECT img_filename, label FROM images", (tf.string, tf.int32))
对于更具体的数据集实例化器,我们邀请读者查看tf.data文档。
转换样本(解析、增强等)
ETL 管道的第二步是转换。转换可以分为两类——那些单独影响数据样本的,和那些影响整个数据集的。在接下来的段落中,我们将介绍前者的转换,并解释如何对我们的样本进行预处理。
解析图像和标签
在我们之前章节中为dataset.map()编写的parse_fn()方法中,调用了tf.io.read_file()来读取数据集中列出的每个文件名对应的文件,然后tf.io.decode_png()将字节转换为图像张量。
tf.io 还包含 decode_jpeg()、decode_gif()等方法。它还提供了更通用的decode_image(),能够自动推断使用哪种图像格式(请参考文档:www.tensorflow.org/api_docs/python/tf/io)。
此外,有许多方法可以应用于解析计算机视觉标签。显然,如果标签也是图像(例如,用于图像分割或编辑),我们刚才列出的那些方法仍然可以重复使用。如果标签存储在文本文件中,可以使用TextLineDataset或FixedLengthRecordDataset(参见www.tensorflow.org/api_docs/python/tf/data中的文档)进行迭代处理,并且像tf.strings这样的模块可以帮助解析行/记录。例如,假设我们有一个训练数据集,其中包含一个文本文件,每行列出了图像文件名及其类标识符,两者之间由逗号分隔。每对图像/标签可以通过这种方式进行解析:
def parse_fn(line):
img_filename, img_label = tf.strings.split(line, sep=',')
img = tf.io.decode_image(tf.io.read_file(img_filename))[0]
return {'image': img, 'label': tf.strings.to_number(img_label)}
dataset = tf.data.TextLineDataset('/path/to/file.txt').map(parse_fn)
正如我们所观察到的,TensorFlow 提供了多个辅助函数来处理和转换字符串、读取二进制文件、解码 PNG 或 JPEG 字节为图像等。有了这些函数,处理异构数据的管道可以以最小的努力搭建。
解析 TFRecord 文件
虽然列出所有图像文件,然后迭代打开和解析它们是一种直接的管道解决方案,但它可能并不高效。逐个加载和解析图像文件会消耗大量资源。将大量图像存储到一个二进制文件中,可以使从磁盘读取操作(或者远程文件的流操作)变得更加高效。因此,TensorFlow 用户通常被建议使用 TFRecord 文件格式,它基于 Google 的协议缓冲区(Protocol Buffers),一种语言中立、平台中立的可扩展机制,用于序列化结构化数据(参见developers.google.com/protocol-buffers中的文档)。
TFRecord 文件是聚合数据样本(如图像、标签和元数据)的二进制文件。一个 TFRecord 文件包含序列化的tf.train.Example实例,基本上是一个字典,命名每个数据元素(根据此 API 称为特征)来组成样本(例如,{'img': image_sample1, 'label': label_sample1, ...})。每个样本包含的每个元素/特征都是tf.train.Feature或其子类的实例。这些对象将数据内容存储为字节、浮动数值或整数的列表(参见www.tensorflow.org//api_docs/python/tf/train中的文档)。
由于它是专为 TensorFlow 开发的,这种文件格式得到了 tf.data 的很好支持。为了将 TFRecord 文件作为输入管道的数据源,TensorFlow 用户可以将文件传递给 tf.data.TFRecordDataset(filenames)(请参考文档 www.tensorflow.org/api_docs/python/tf/data/TFRecordDataset),该函数可以遍历其中包含的序列化 tf.train.Example 元素。要解析其内容,应执行以下操作:
dataset = tf.data.TFRecordDataset(['file1.tfrecords','file2.tfrecords'])
# Dictionary describing the features/tf.trainExample structure:
feat_dic = {'img': tf.io.FixedLenFeature([], tf.string), # image's bytes
'label': tf.io.FixedLenFeature([1], tf.int64)} # class label
def parse_fn(example_proto): # Parse a serialized tf.train.Example
sample = tf.parse_single_example(example_proto, feat_dic)
return tf.io.decode_image(sample['img])[0], sample['label']
dataset = dataset.map(parse_fn)
tf.io.FixedLenFeature(shape, dtype, default_value) 让管道知道预期从序列化样本中获得什么类型的数据,然后可以通过一个命令进行解析。
在其中一个 Jupyter 笔记本中,我们更详细地讲解了 TFRecord,逐步解释如何预处理数据并将其存储为 TFRecord 文件,以及如何将这些文件作为 tf.data 管道的数据源使用。
编辑样本
.map() 方法是 tf.data 管道的核心。除了解析样本外,它还可用于进一步编辑它们。例如,在计算机视觉中,一些应用通常需要将输入图像裁剪/调整大小为相同的尺寸(例如,应用 tf.image.resize())或将目标标签转换为独热编码(tf.one_hot())。
正如我们将在本章后面详细说明的那样,建议将可选的数据增强操作封装为传递给 .map() 的函数。
转换数据集(洗牌、打包、并行化等)
该 API 还提供了众多函数,用于将一个数据集转换成另一个数据集,调整其结构,或者将其与其他数据源合并。
结构化数据集
在数据科学和机器学习中,过滤数据、洗牌样本和将样本堆叠成批次等操作非常常见。tf.data API 为大多数这些操作提供了简单的解决方案(请参考文档 www.tensorflow.org/api_docs/python/tf/data/Dataset)。例如,以下是一些最常用的数据集方法:
-
.batch(batch_size, ...),它返回一个新的数据集,数据样本按批次处理(tf.data.experimental.unbatch()执行相反的操作)。请注意,如果在.batch()后调用.map(),则映射函数将接收到批处理数据作为输入。 -
.repeat(count=None),它将数据重复count次(如果count = None,则无限次重复)。 -
.shuffle(buffer_size, seed, ...),它在填充缓冲区后对元素进行洗牌(例如,如果buffer_size = 10,数据集将被虚拟地划分为 10 个元素的小子集,并随机排列每个子集中的元素,然后逐个返回)。缓冲区大小越大,洗牌的随机性就越强,但过程也越重。 -
.filter(predicate),该方法根据提供的predicate函数的布尔输出来保留/移除元素。例如,如果我们想过滤一个数据集,移除存储在在线的数据,我们可以如下使用该方法:
url_regex = "(?i)([a-z][a-z0-9]*)://([^ /]+)(/[^ ]*)?|([^ @]+)@([^ @]+)"
def is_not_url(filename): #NB: the regex isn't 100% sure/covering all cases
return ~(tf.strings.regex_full_match(filename, url_regex))
dataset = dataset.filter(is_not_url)
-
**.**take(count),该方法返回一个包含最多count个元素的数据集。 -
**.**skip(count),该方法返回一个去除了前count个元素的数据集。这两个方法都可以用来拆分数据集,例如,按如下方式将数据集拆分为训练集和验证集:
num_training_samples, num_epochs = 10000, 100
dataset_train = dataset.take(num_training_samples)
dataset_train = dataset_train.repeat(num_epochs)
dataset_val = dataset.skip(num_training_samples)
还有许多其他方法可用于结构化数据或控制数据流,通常这些方法受到其他数据处理框架的启发(如 .unique()、.reduce() 和 .group_by_reducer())。
合并数据集
一些方法也可以用于合并数据集。最简单的两种方法是 .concatenate(dataset) 和静态方法 .zip(datasets)(请参考文档 www.tensorflow.org/api_docs/python/tf/data/Dataset)。前者连接提供的数据集样本与当前数据集样本,而后者则组合数据集的元素成元组(类似于 Python 中的 zip()),如下所示:
d1 = tf.data.Dataset.range(3)
d2 = tf.data.Dataset.from_tensor_slices([[4, 5], [6, 7], [8, 9]])
d = tf.data.Dataset.zip((d1, d2))
# d will return [0, [4, 5]], [1, [6, 7]], and [2, [8, 9]]
另一种常用于合并来自不同来源的数据的方法是 .interleave(map_func, cycle_length, block_length, ...)(请参考文档 www.tensorflow.org/api_docs/python/tf/data/Dataset#interleave)。该方法将 map_func 函数应用于数据集的元素,并对结果进行交错。现在让我们回到 解析图像和标签 部分中展示的示例,图像文件和类名列在一个文本文件中。如果我们有多个这样的文本文件,并希望将它们的所有图像合并成一个数据集,可以按如下方式使用 .interleave():
filenames = ['/path/to/file1.txt', '/path/to/file2.txt', ...]
d = tf.data.Dataset.from_tensor_slices(filenames)
d = d.interleave(lambda f: tf.data.TextLineDataset(f).map(parse_fn),
cycle_length=2, block_length=5)
cycle_length 参数固定了并行处理的元素数量。在我们之前的示例中,cycle_length = 2 意味着函数将并行遍历前两个文件的行,然后再遍历第三和第四个文件的行,以此类推。block_length 参数控制每个元素返回的连续样本数量。在这里,block_length = 5 意味着该方法将在遍历另一个文件之前,从一个文件中返回最多 5 行连续的样本。
利用所有这些方法以及更多可用的工具,可以轻松地设置复杂的数据提取和转换流程,正如之前的一些笔记本中所示(例如,CIFAR 和 Cityscapes 数据集)。
加载
tf.data 的另一个优点是它的所有操作都已注册到 TensorFlow 操作图中,提取和处理的样本会作为 Tensor 实例返回。因此,我们在 ETL 的最后一步,即 加载,不需要做太多的工作。与任何其他 TensorFlow 操作或张量一样,库会负责将它们加载到目标设备上——除非我们希望自行选择设备(例如,使用 tf.device() 包装数据集的创建)。当我们开始遍历 tf.data 数据集时,生成的样本可以直接传递给模型。
优化和监控输入管道
虽然这个 API 简化了高效输入管道的设置,但为了充分发挥其功能,应该遵循一些最佳实践。除了分享来自 TensorFlow 创建者的一些建议外,我们还将介绍如何监控和重用管道。
遵循优化的最佳实践
该 API 提供了几种方法和选项来优化数据处理和流动,我们将详细介绍这些内容。
并行化和预取
默认情况下,大多数数据集方法都是逐个处理样本,没有并行性。然而,这种行为可以很容易地改变,例如,利用多个 CPU 核心。例如,.interleave() 和 .map() 方法都有一个 num_parallel_calls 参数,用于指定它们可以创建的线程数(请参阅文档 www.tensorflow.org/api_docs/python/tf/data/Dataset)。并行化图像的提取和转换可以大大减少生成训练批次所需的时间,因此,始终正确设置 num_parallel_calls 是非常重要的(例如,设置为处理机器的 CPU 核心数)。
TensorFlow 还提供了 tf.data.experimental.parallel_interleave()(请参阅文档 www.tensorflow.org/versions/r2.0/api_docs/python/tf/data/experimental/parallel_interleave),这是 .interleave() 的并行化版本,带有一些额外的选项。例如,它有一个 sloppy 参数,如果设置为 True,允许每个线程在其输出准备好后立即返回。这样一方面意味着数据将不再按确定的顺序返回,另一方面,这可以进一步提高管道性能。
tf.data 的另一个与性能相关的特性是能够 预取 数据样本。通过数据集的 .prefetch(buffer_size) 方法应用时,该特性允许输入管道在当前样本被消费的同时开始准备下一个样本,而不是等待下一个数据集调用。具体而言,这使得 TensorFlow 能够在当前批次被 GPU 上的模型使用时,在 CPU 上开始准备下一个训练批次。
预取基本上实现了数据准备和训练操作的 并行化,以 生产者-消费者 的方式进行。因此,通过启用并行调用和预取,可以通过少量更改大大减少训练时间,如下所示:
dataset = tf.data.TextLineDataset('/path/to/file.txt')
dataset = dataset.map(parse_fn, num_threads).batch(batch_size).prefetch(1)
受 TensorFlow 官方指南的启发(www.tensorflow.org/guide/performance/datasets),图 7-2 展示了这些最佳实践所带来的性能提升:

图 7-2:展示并行化和预取操作带来的性能提升
通过结合这些不同的优化方法,CPU/GPU 的空闲时间可以进一步减少。在预处理时间方面的性能提升可能会非常显著,正如本章中的一个 Jupyter Notebook 所展示的那样。
融合操作
还需要了解的是,tf.data 提供了一些函数,将一些关键操作进行组合,以提高性能或获得更可靠的结果。
例如,tf.data.experimental.shuffle_and_repeat(buffer_size, count, seed) 将打乱和重复操作融合在一起,使得每个训练周期(epoch)都能以不同的方式打乱数据集(详细信息请参见文档 www.tensorflow.org/versions/r2.0/api_docs/python/tf/data/experimental/shuffle_and_repeat)。
回到优化问题,tf.data.experimental.map_and_batch(map_func, batch_size, num_parallel_batches, ...)(详细信息请参见文档 www.tensorflow.org/versions/r2.0/api_docs/python/tf/data/experimental/map_and_batch)首先应用map_func函数,然后将结果批处理在一起。通过融合这两项操作,该方案避免了一些计算开销,因此应该优先采用。
map_and_batch() 将被淘汰,因为 TensorFlow 2 正在实现多个工具来自动优化 tf.data 操作,例如将多个 .map() 调用组合在一起、对 .map() 操作进行向量化并与 .batch() 直接融合、融合 .map() 和 .filter() 等。一旦这种自动优化被完全实现并经过 TensorFlow 社区验证,就不再需要 map_and_batch()(再强调一次,到你阅读这一章节时,这可能已经是事实)。
传递选项以确保全局属性
在 TensorFlow 2 中,还可以通过设置全局选项来配置数据集,这将影响它们的所有操作。tf.data.Options 是一种可以通过 .with_options(options) 方法传递给数据集的结构,它有多个属性用于参数化数据集(请参阅文档 www.tensorflow.org/api_docs/python/tf/data/Options)。
例如,如果将 .experimental_autotune 布尔属性设置为 True,TensorFlow 将根据目标机器的容量自动调整所有数据集操作的 num_parallel_calls 值。
当前名为 .experimental_optimization 的属性包含一组与数据集操作自动优化相关的子选项(请参阅前面的信息框)。例如, .map_and_batch_fusion 属性可以设置为 True,以使 TensorFlow 自动融合 .map() 和 .batch() 调用;.map_parallelization 可以设置为 True,使 TensorFlow 自动并行化某些映射函数,等等,具体如下:
options = tf.data.Options()
options.experimental_optimization.map_and_batch_fusion = True
dataset = dataset.with_options(options)
还有许多其他选项可供选择(可能还会有更多)。我们邀请读者查看文档,特别是当输入管道的性能对他们来说至关重要时。
监控和重用数据集
我们介绍了多个优化 tf.data 管道的工具,但我们如何确保它们能对性能产生积极影响呢?是否还有其他工具可以帮助我们找出可能导致数据流缓慢的操作?在接下来的段落中,我们将通过演示如何监控输入管道以及如何缓存和恢复它们以供后续使用来回答这些问题。
聚合性能统计
TensorFlow 2 的一个新特性是能够聚合有关 tf.data 管道的一些统计信息,例如它们的延迟(整个过程的延迟和/或每个操作的延迟)或每个元素产生的字节数。
可以通过 TensorFlow 的全局选项来通知收集这些数据集的指标值(参见前面的段落)。tf.data.Options实例具有.experimental_stats字段,该字段来自tf.data.experimental.StatsOption类(请参考文档:www.tensorflow.org/versions/r2.0/api_docs/python/tf/data/experimental/StatsOptions)。此类定义了与上述数据集指标相关的几个选项(例如,将.latency_all_edges设置为True以测量延迟)。它还具有.aggregator属性,可以接收tf.data.experimental.StatsAggregator的实例(请参考文档:www.tensorflow.org/versions/r2.0/api_docs/python/tf/data/experimental/StatsAggregator)。顾名思义,该对象将附加到数据集并汇总请求的统计数据,提供可以记录并在 TensorBoard 中可视化的摘要,如以下代码示例所示。
在编写本书时,这些功能仍然处于高度实验阶段,尚未完全实现。例如,目前没有简单的方法来记录包含聚合统计信息的摘要。鉴于监控工具的重要性,我们仍然涵盖了这些功能,认为它们很快会完全可用。
因此,数据集的统计信息可以聚合并保存(例如,供 TensorBoard 使用),如下所示:
# Use utility function to tell TF to gather latency stats for this dataset:
dataset = dataset.apply(tf.data.experimental.latency_stats("data_latency"))
# Link stats aggregator to dataset through the global options:
stats_aggregator = tf.data.experimental.StatsAggregator()
options = tf.data.Options()
options.experimental_stats.aggregator = stats_aggregator
dataset = dataset.with_options(options)
# Later, aggregated stats can be obtained as summary, for instance, to log them:
summary_writer = tf.summary.create_file_writer('/path/to/summaries/folder')
with summary_writer.as_default():
stats_summary = stats_aggregator.get_summary()
# ... log summary with `summary_writer` for Tensorboard (TF2 support coming soon)
请注意,不仅可以获取整个输入管道的统计信息,还可以获取其每个内部操作的统计数据。
缓存和重用数据集
最后,TensorFlow 提供了多个函数来缓存生成的样本或保存tf.data管道的状态。
可以通过调用数据集的.cache(filename)方法来缓存样本。如果已缓存,数据在下一次迭代时将无需经过相同的转换(即,在接下来的时期)。请注意,缓存数据的内容将取决于该方法应用的时机。请看以下示例:
dataset = tf.data.TextLineDataset('/path/to/file.txt')
dataset_v1 = dataset.cache('cached_textlines.temp').map(parse_fn)
dataset_v2 = dataset.map(parse_fn).cache('cached_images.temp')
第一个数据集将缓存TextLineDataset返回的样本,即文本行(缓存的数据存储在指定的文件cached_textlines.temp中)。parse_fn所做的转换(例如,为每个文本行打开并解码相应的图像文件)必须在每个时期重复进行。另一方面,第二个数据集则缓存了parse_fn返回的样本,即图像。虽然这可以为下一轮的训练节省宝贵的计算时间,但这也意味着需要缓存所有生成的图像,这可能会导致内存效率低下。因此,缓存需要仔细考虑。
最后,保存数据集的状态也是可能的,例如,当训练被意外中断时,可以在不重新迭代先前输入批次的情况下恢复训练。如文档中所提到的,这个功能对在少量不同批次上训练的模型(因此存在过拟合风险)有积极影响。对于估算器,一个保存数据集迭代器状态的解决方案是设置以下钩子——tf.data.experimental.CheckpointInputPipelineHook(请参考文档 www.tensorflow.org/api_docs/python/tf/data/experimental/CheckpointInputPipelineHook)。
由于深知可配置和优化的数据流对机器学习应用的重要性,TensorFlow 开发者持续提供新特性来完善 tf.data API。正如在上一部分中提到并在相关的 Jupyter Notebook 中展示的那样,利用这些特性——即使是实验性的——可以大大减少实现开销和训练时间。
如何应对数据稀缺
能够高效地提取和转化数据以训练复杂的应用程序至关重要,但这假设首先有足够的数据可供此类任务使用。毕竟,神经网络是数据饥渴型方法,即使我们身处大数据时代,足够大的数据集仍然很难收集,且更难以标注。标注一张图像可能需要几分钟(例如,为语义分割模型创建地面真值标签图),某些标注可能还需要专家验证/修正(例如,在标注医学图片时)。在某些情况下,图像本身可能不容易获得。例如,在为工业厂房构建自动化模型时,拍摄每个制造物品及其组件的照片既耗时又费钱。
因此,数据稀缺是计算机视觉中的一个常见问题,尽管缺乏训练图像或严格标注,仍有很多努力尝试训练出鲁棒的模型。在本节中,我们将介绍多年来提出的几种解决方案,并展示它们在不同任务中的优缺点。
增强数据集
我们从第四章《影响力分类工具》开始就提到了这一第一种方法,并且我们在之前的笔记本中已将其应用于某些应用程序。这终于是我们正确介绍数据增强的机会,并展示如何在 TensorFlow 2 中应用它。
概述
正如之前所指出的,增广 数据集意味着对它们的内容应用随机转换,以获取每个内容的不同版本。我们将介绍这一过程的好处,以及一些相关的最佳实践。
为什么要增广数据集?
数据增广可能是处理过小的训练集最常见和简单的方法。它可以通过提供每个图像的不同外观版本来几乎使它们的图像数量倍增。这些不同的版本是通过应用随机转换的组合获得的,例如尺度抖动、随机翻转、旋转和色彩偏移。数据增广可以意外地帮助防止过拟合,这在用小样本集训练大模型时通常会发生。
但即使有足够的训练图像可用,仍应考虑这一过程。事实上,数据增广还有其他好处。即使是大型数据集也可能存在偏差,而数据增广可以部分补偿其中的一些偏差。我们将通过一个例子说明这个概念。假设我们想要为画笔与钢笔图片建立一个分类器。然而,每个类别的图片是由两个不同的团队收集的,事先并未就准确的获取协议达成一致(例如,选择哪种相机型号或照明条件)。结果,画笔 训练图片明显比钢笔 图片更暗更嘈杂。由于神经网络训练以正确预测任何视觉线索,这些模型在这样的数据集上学习时可能最终依赖于这些明显的光照/噪声差异来分类对象,而不是纯粹专注于对象的表征(如它们的形状和纹理)。一旦投入生产,这些模型表现会很差,不再能依赖这些偏差。这个例子在 图 7-3 中有所说明:

图 7-3:一个在偏差数据集上训练的分类器示例,无法将其知识应用到目标数据上
随机向图片添加一些噪声或随机调整它们的亮度,将阻止网络依赖这些线索。这些增广将部分补偿数据集的偏差,并使这些视觉差异变得太不可预测,无法被网络使用(即防止模型过度拟合偏差数据集)。
数据增强还可以用于提高数据集的覆盖范围。训练数据集无法涵盖所有图像变化(否则我们就不需要构建机器学习模型来处理新的不同图像)。例如,如果某个数据集中的所有图像都是在相同的光照下拍摄的,那么在不同光照条件下拍摄的图像,训练出来的识别模型效果会非常差。这些模型本质上没有学习到光照条件是一个因素,它们应该学会忽略光照条件,专注于实际的图像内容。因此,在将训练图像传递给网络之前,随机调整图像亮度可以帮助模型学习这一视觉特性。通过更好地为目标图像的变化性做好准备,数据增强有助于训练出更强大的解决方案。
考虑事项
数据增强可以采取多种形式,在执行这一过程时应考虑多个选项。首先,数据增强可以是离线进行的,也可以是在线进行的。离线增强意味着在训练开始之前转换所有图像,并将各种版本保存以备后用。在线增强则意味着在训练输入管道中生成每个新批次时应用转换。
由于增强操作可能计算开销较大,因此提前应用这些操作并存储结果,在输入管道的延迟方面可能具有优势。然而,这意味着需要足够的内存空间来存储增强后的数据集,这通常会限制生成的不同版本的数量。通过在实时处理时随机转换图像,在线解决方案可以为每个训练周期提供不同的版本。尽管计算开销更大,但这意味着网络将接收到更多的变化。因此,离线增强和在线增强之间的选择受限于可用设备的内存/处理能力,以及所需的变化性。
变化性本身由要应用的转换类型决定。例如,如果仅应用随机水平和垂直翻转操作,那么每张图像最多会有四个不同版本。根据原始数据集的大小,您可以考虑离线应用转换,并存储四倍大小的数据集。另一方面,如果考虑应用随机裁剪和随机色彩偏移等操作,则可能的变化数量几乎是无限的。
因此,在设置数据增强时,首先要做的是筛选出相关的转换操作(以及在适用时它们的参数)。可能的操作列表非常庞大,但并非所有操作都适用于目标数据和使用案例。例如,垂直翻转仅应在图像内容自然会出现在倒立状态下时考虑(如大系统的特写图像或鸟瞰图/卫星图像)。如果垂直翻转城市景观图像(例如 Cityscapes 图像),对于模型没有任何帮助,因为它们(希望)永远不会遇到这种倒立的图像。
同样,你应该小心地正确参数化一些转换操作,例如裁剪或亮度调整。如果图像变得过暗或过亮,以至于其内容无法再被识别,或者如果关键元素被裁剪掉,那么模型将无法从这些编辑后的图片中学习任何东西(如果过多的图像经过不恰当的增强处理,甚至可能会混淆模型)。因此,重要的是要筛选和参数化那些能为数据集(针对目标使用案例)增加有意义变化的转换,同时保持其语义内容。
图 7-4 提供了一些对于自动驾驶应用来说,哪些增强操作是有效的,哪些是无效的示例:

图 7-4:自动驾驶应用中的有效/无效增强操作
同样,值得记住的是,数据增强无法完全弥补数据稀缺问题。如果我们希望模型能够识别猫,但仅有波斯猫的训练图像,那么任何简单的图像变换都无法帮助模型识别其他猫品种(例如斯芬克斯猫)。
一些先进的数据增强解决方案包括应用计算机图形学或编码器-解码器方法来改变图像。例如,可以使用计算机图形学算法添加虚假的太阳光晕或运动模糊,CNN 可以训练将白天的图像转换为夜间图像。我们将在本章后面介绍这些技术。
最后,当适用时,你不应忘记相应地转换标签,特别是当进行几何转换时,涉及检测和分割标签时。如果图像被调整大小或旋转,相关的标签图或边界框也应该进行相同的操作,以保持对齐(参见第六章的 Cityscapes 实验,图像增强与分割)。
使用 TensorFlow 进行图像增强
在阐明了为什么和何时应该进行图像增强之后,接下来就是详细解释如何进行图像增强了。我们将介绍 TensorFlow 提供的一些有用工具来转换图像,并分享一些具体的示例。
TensorFlow 图像模块
Python 提供了各种各样的框架来处理和转换图像。除了 OpenCV(opencv.org)和 Python Imaging Library(PIL—effbot.org/zone/pil-index.htm)等通用框架外,还有一些包专门提供用于机器学习系统的数据增强方法。在这些包中,Alexander Jung 提供的 imgaug(github.com/aleju/imgaug)和 Marcus D. Bloice 提供的 Augmentor(github.com/mdbloice/Augmentor)可能是最广泛使用的,它们都提供了丰富的操作和简洁的接口。即使是 Keras 也提供了用于预处理和增强图像数据集的函数。ImageDataGenerator(keras.io/preprocessing/image)可以用来实例化图像批处理生成器,涵盖数据增强(如图像旋转、缩放或通道偏移)。
然而,TensorFlow 有自己专门的图像处理模块,可以与 tf.data 数据管道无缝集成——tf.image(请参阅www.tensorflow.org/api_docs/python/tf/image中的文档)。该模块包含各种函数。其中一些实现了常见的图像相关度量(例如,tf.image.psnr() 和 tf.image.ssim()),还有一些可以用于将图像从一种格式转换为另一种格式(例如,tf.image.rgb_to_grayscale())。但最重要的是,tf.image 实现了多种图像变换。这些函数大多成对出现——一个函数实现了操作的固定版本(例如,tf.image.central_crop()、tf.image.flip_left_right() 和 tf.image.adjust_jpeg_quality()),另一个则是随机版本(例如,tf.image.random_crop()、tf.image.random_flip_left_right() 和 tf.image.random_jpeg_quality())。随机化函数通常接受一个值的范围作为参数,从中随机抽取变换的属性(例如,tf.image.random_jpeg_quality() 的 min_jpeg_quality 和 max_jpeg_quality 参数)。
tf.image 函数直接应用于图像张量(无论是单个还是批量),在 tf.data 数据管道中推荐用于在线增强(将操作分组为传递给 .map() 的函数)。
示例 – 为我们的自动驾驶应用增强图像
在前一章中,我们介绍了一些最先进的语义分割模型,并将它们应用于城市场景,以指导自动驾驶汽车。在相关的 Jupyter 笔记本中,我们提供了一个传递给 dataset.map() 的 _augmentation_fn(img, gt_img) 函数,用于增强图像及其地面真值标签图。虽然当时我们没有提供详细解释,但这个增强函数很好地展示了 tf.image 如何增强复杂数据。
例如,它为解决同时变换输入图像和其密集标签的问题提供了一个简单的解决方案。假设我们想让一些样本随机水平翻转。如果我们分别对输入图像和真实标签图调用tf.image.random_flip_left_right(),那么两张图像会经历相同变换的概率只有一半。
确保对图像对应用相同几何变换的一个解决方案如下:
img_dim, img_ch = tf.shape(img)[-3:-1], tf.shape(img)[-1]
# Stack/concatenate the image pairs along the channel axis:
stacked_imgs = tf.concat([img, tf.cast(gt_img, img.dtype)], -1)
# Apply the random operations, for instance, horizontal flipping:
stacked_imgs = tf.image.random_flip_left_right(stacked_imgs)
# ... or random cropping (for instance, keeping from 80 to 100% of the images):
rand_factor = tf.random.uniform([], minval=0.8, maxval=1.)
crop_shape = tf.cast(tf.cast(img_dim, tf.float32) * rand_factor, tf.int32)
crop_shape = tf.concat([crop_shape, tf.shape(stacked_imgs)[-1]], axis=0)
stacked_imgs = tf.image.random_crop(stacked_imgs, crop_shape)
# [...] (apply additional geometrical transformations)
# Unstack to recover the 2 augmented tensors:
img = stacked_imgs[..., :img_ch]
gt_img = tf.cast(stacked_imgs[..., img_ch:], gt_img.dtype)
# Apply other transformations in the pixel domain, for instance:
img = tf.image.random_brightness(image, max_delta=0.15)
由于大多数tf.image几何函数对图像的通道数没有任何限制,因此提前沿通道轴将图像拼接在一起是一个简单的技巧,可以确保它们经历相同的几何操作。
上述示例还说明了一些操作如何通过从随机分布中抽取一些参数来进一步随机化。tf.image.random_crop(images, size)返回固定大小的裁剪,裁剪位置随机选自图像中的位置。通过使用tf.random.uniform()选择一个尺寸因子,我们获得的裁剪不仅在原图像中随机定位,而且尺寸也是随机的。
最后,这个例子也提醒我们,并非所有的变换都应该应用于输入图像及其标签图。尝试调整标签图的亮度或饱和度是没有意义的(并且在某些情况下会引发异常)。
我们将通过强调此过程应始终考虑来结束这一小节关于数据增强的讨论。即使在大数据集上进行训练,增强其图像也能使模型更加稳健——只要随机变换是小心选择并应用的。
渲染合成数据集
然而,如果我们根本没有图像进行训练怎么办?计算机视觉中一种常见的解决方案是使用合成数据集。在接下来的小节中,我们将解释什么是合成图像,它们如何生成以及它们的局限性。
概述
首先让我们澄清什么是合成图像,以及为什么它们在计算机视觉中如此常见。
3D 数据库的崛起
正如本节介绍数据匮乏时所提到的,完全缺乏训练图像的情况在工业界并不少见。收集每个新元素的几百张图像是昂贵的,有时甚至是完全不切实际的(例如,当目标物体尚未生产出来,或者仅在某个遥远地点可用时)。
然而,对于工业应用和其他场景,越来越常见的是能够获取目标物体或场景的 3D 模型(例如 3D 计算机辅助设计(CAD)蓝图或使用深度传感器捕捉的 3D 场景)。大规模的 3D 模型数据集甚至在网络上成倍增加。随着计算机图形学的发展,越来越多的专家开始使用这些 3D 数据库来渲染合成图像,用于训练他们的识别模型。
合成数据的优势
合成图像是通过计算机图形库从 3D 模型生成的图像。得益于盈利丰厚的娱乐产业,计算机图形技术确实取得了长足进展,现在的渲染引擎能够从 3D 模型中生成高度逼真的图像(例如用于视频游戏、3D 动画电影和特效)。科学家们很快就看到了计算机视觉的潜力。
给定一些目标物体/场景的详细 3D 模型,使用现代 3D 引擎可以渲染出大量伪现实的图像数据集。通过适当的脚本,举例来说,你可以从每个角度、不同的距离、不同的光照条件或背景等渲染目标物体的图像。使用各种渲染方法,甚至可以模拟不同类型的相机和传感器(例如,深度传感器,如Microsoft Kinect或Occipital Structure传感器)。
拥有对场景/图像内容的完全控制,你还可以轻松获得每张合成图像的各种地面真值标签(例如渲染模型的精确 3D 位置或物体掩码)。例如,针对驾驶场景,巴塞罗那自治大学的一个研究团队构建了城市环境的虚拟副本,并利用这些副本渲染了多个城市场景数据集,名为SYNTHIA(synthia-dataset.net)。这个数据集类似于Cityscapes(www.cityscapes-dataset.com),但规模更大。
来自达姆施塔特工业大学和英特尔实验室的另一支团队成功演示了使用现实感十足的电子游戏Grand Theft Auto V (GTA 5)(download.visinf.tu-darmstadt.de/data/from_games)中的图像训练的自动驾驶模型。
这三个数据集展示在图 7-5中:

图 7-5:Cityscapes、SYNTHIA 和 Playing for Data 数据集的样本(数据集链接在本节中提供)。图像及其类别标签已叠加。
除了生成静态数据集,3D 模型和游戏引擎还可以用于创建互动仿真环境。毕竟,基于仿真的学习通常用于教导人类复杂的技能,例如当在真实环境中学习过于危险或复杂时(例如,模拟零重力环境来教宇航员如何在太空中执行某些任务,或构建基于游戏的平台帮助外科医生在虚拟病人上学习)。如果这对人类有效,为什么不对机器有效呢?公司和研究实验室一直在开发多种仿真框架,涵盖了各种应用(如机器人学、自动驾驶、监控等)。
在这些虚拟环境中,人们可以训练和测试他们的模型。在每个时间步骤,模型会从环境中接收一些视觉输入,模型可以利用这些输入采取进一步的行动,进而影响仿真过程,依此类推(这种互动式训练实际上是强化学习的核心内容,如第一章《计算机视觉与神经网络》中所提到的)。
合成数据集和虚拟环境用于弥补缺乏真实训练数据的问题,或者避免将不成熟的解决方案直接应用到复杂或危险的情境中。
从 3D 模型生成合成图像
计算机图形学本身是一个广泛且迷人的领域。在接下来的段落中,我们将简单介绍一些实用的工具和现成的框架,供那些需要为其应用渲染数据的人使用。
从 3D 模型进行渲染
从 3D 模型生成图像是一个复杂的多步骤过程。大多数 3D 模型由网格表示,网格是一组由顶点(即 3D 空间中的点)限定的小面(通常是三角形)组成,表示模型的表面。一些模型还包含一些纹理或颜色信息,指示每个顶点或小表面应该具有的颜色。最后,模型可以被放置到一个更大的 3D 场景中(进行平移/旋转)。给定一个由内参(如焦距和主点)定义的虚拟相机及其在 3D 场景中的姿态,任务是渲染出相机在场景中所看到的内容。该过程在下方的图 7-6中以简化的方式展示:

图 7-6:简化的 3D 渲染管线表示(3D 模型来自 LineMOD 数据集——http://campar.in.tum.de/Main/StefanHinterstoisser)
将 3D 场景转换为 2D 图像意味着需要进行多个变换,首先将每个模型的面从 3D 坐标(相对于物体)投影到全局场景坐标(世界坐标),然后再投影到相机坐标系(相机坐标),最后将其投影到图像空间中的 2D 坐标(图像坐标)。所有这些投影可以通过直接的矩阵乘法来表示,但它们(遗憾的是)只是渲染过程的一小部分。表面颜色也应正确插值,可见性应得到尊重(被其他物体遮挡的元素不应绘制),还应应用现实的光照效果(例如,光照、反射和折射)等等。
操作繁多且计算量大。幸运的是,GPU 最初是为了高效地执行这些操作而设计的,像OpenGL(www.opengl.org)这样的框架已经开发出来,帮助与 GPU 进行接口连接,以实现计算机图形学(例如,将顶点/面加载到 GPU 作为缓冲区,或定义名为着色器的程序来指定如何投影和着色场景),并简化一些过程。
大多数现代计算机语言提供了基于OpenGL的库,如PyOpenGL(pyopengl.sourceforge.net)或面向对象的vispy(vispy.org)库(适用于 Python)。像Blender(www.blender.org)这样的应用程序提供了图形界面来构建和渲染 3D 场景。尽管掌握所有这些工具需要一些努力,但它们极为灵活,可以极大地帮助渲染任何类型的合成数据。
然而,值得记住的是,正如我们之前提到的,实验室和公司已经共享了许多高级框架,用于专门为机器学习应用渲染合成数据集。例如,来自萨尔茨堡大学的 Michael Gschwandtner 和 Roland Kwitt 开发了BlenSor(www.blensor.org),这是一个基于 Blender 的应用程序,用于模拟各种传感器(BlenSor: Blender 传感器模拟工具箱,Springer,2011 年);最近,Simon Brodeur 和来自不同领域的研究人员分享了HoME-Platform,该平台模拟了多种室内环境,用于智能系统(HoME: 家庭多模态环境,ArXiv,2017 年)。
在手动设置完整的渲染管线或使用特定的仿真系统时,无论是哪种情况,最终目标都是渲染大量带有真实数据和足够变化(视角、光照条件、纹理等)的训练数据。
为了更好地说明这些概念,专门有一个完整的笔记本来渲染来自 3D 模型的合成数据集,简要介绍了3D 网格、着色器和视图矩阵等概念。使用vispy实现了一个简单的渲染器。
合成图像的后期处理
虽然目标对象的 3D 模型在工业环境中通常是可获得的,但很少能够获得它们所处环境的 3D 表示(例如,工业厂房的 3D 模型)。因此,3D 对象/场景往往显得孤立,没有适当的背景。但是,就像任何其他视觉内容一样,如果模型没有经过训练去处理背景/杂乱物体,它们在面对真实图像时就无法正常工作。因此,研究人员通常会对合成图像进行后期处理,例如将它们与相关的背景图片合成(用相关环境的图像像素值替换空白背景)。
虽然某些增强操作可以通过渲染管道来处理(例如亮度变化或运动模糊),但其他 2D 变换在训练期间仍然常常应用于合成数据。这种额外的后期处理再次是为了减少过拟合的风险,并提高模型的鲁棒性。
2019 年 5 月,TensorFlow Graphics发布了。这个模块提供了一个计算机图形学管道,可以从 3D 模型生成图像。由于这个渲染管道由新颖的可微分操作组成,它可以与神经网络(NNs)紧密结合——或者集成到其中——(这些图形操作是可微分的,因此训练误差可以通过它们进行反向传播,就像其他任何神经网络层一样)。随着越来越多的功能被添加到 TensorFlow Graphics 中(例如 TensorBoard 的 3D 可视化插件和额外的渲染选项),它无疑将成为解决 3D 应用或依赖合成训练数据的应用方案的核心组件。更多信息以及详细教程可以在相关的 GitHub 仓库中找到(github.com/tensorflow/graphics)。
问题——真实感差距
尽管合成图像的渲染已经使得各种计算机视觉应用成为可能,但它仍然不是数据稀缺的完美解决方案(或者至少目前还不是)。虽然如今计算机图形学框架可以渲染出超真实的图像,但它们需要详细的 3D 模型(具有精确的表面和高质量的纹理信息)。收集数据以构建这样的模型与直接构建目标对象的真实图像数据集的成本一样昂贵——甚至可能更贵。
由于 3D 模型有时具有简化的几何形状或缺乏与纹理相关的信息,因此真实感合成数据集并不常见。这种渲染训练数据与真实目标图像之间的真实感差距会损害模型的表现。它们在合成数据上训练时学到的视觉线索可能在真实图像中并不存在(这些图像可能具有不同的饱和度颜色、更复杂的纹理或表面等)。
即使 3D 模型能够正确描绘原始物体,物体的外观随着时间的推移(例如,由于磨损)也常常发生变化。
目前,许多努力正致力于解决计算机视觉中的现实差距。虽然一些专家正在致力于构建更逼真的 3D 数据库或开发更先进的仿真工具,其他专家则提出了新的机器学习模型,这些模型能够将它们在合成环境中获得的知识转移到真实场景中。后者的方法将是本章最后小节的主题。
利用领域适应和生成模型(VAE 和 GAN)
领域适应方法在第四章中简要提到过,属于迁移学习策略中的一部分。它们的目标是将模型在一个源领域(即一个数据分布)中获得的知识转移到另一个目标领域。因此,得到的模型应该能够正确识别来自新分布的样本,即使它们没有在该分布上进行直接训练。这适用于当目标领域的训练样本不可用时,但可以考虑使用其他相关数据集作为训练替代品的场景。
假设我们希望训练一个模型,在真实场景中对家用工具进行分类,但我们只能获得制造商提供的整洁的产品图片。如果没有领域适应,基于这些广告图片训练的模型在面对目标图像时——例如有杂乱物品、光照不佳等问题时——将无法正常工作。
在合成数据上训练识别模型,使其能够应用于真实图像,已经成为领域适应方法的一种常见应用。实际上,具有相同语义内容的合成图像和真实照片可以被看作是两个不同的数据分布,也就是说,它们是具有不同细节、噪声等特征的两个领域。
本节中,我们将考虑以下两种不同的方法:
-
领域适应方法旨在训练模型,使其在源领域和目标领域上表现相同
-
适应训练图像使其与目标图像更相似的方法
训练模型以适应领域变化的鲁棒性
域适应的第一种方法是鼓励模型关注在源领域和目标领域中都能找到的鲁棒特征。基于这种方法,已经提出了多种解决方案,具体取决于训练过程中是否有目标数据可用。
监督式领域适应
有时,你可能幸运地能够获得来自目标领域的一些图像及相关注释,除此之外还有一个较大的源数据集(例如,合成图像)。这种情况通常出现在工业界,在那里公司必须在收集足够的目标图像以训练识别模型的高昂成本与如果模型仅在合成数据上进行训练时所遭遇的性能下降之间找到折衷。
幸运的是,多个研究表明,将少量目标样本添加到训练集中可以提升算法的最终表现。通常提出的两个主要原因是:
-
即使目标数据稀缺,它也为模型提供了目标领域的一些信息。为了最小化对所有样本的训练损失,网络必须学会如何处理这一小部分添加的图像(这甚至可以通过对这些图像加重损失来进一步强调)。
-
由于源分布和目标分布本质上是不同的,混合数据集展示了更大的视觉变异性。正如前面所解释的,模型需要学习更强大的特征,这在仅应用于目标图像时是有益的(例如,模型变得更好地准备处理多样化的数据,从而更好地应对目标图像分布)。
也可以直接将其与我们在第四章中探讨的迁移学习方法相类比,影响力分类工具(先在大型源数据集上训练模型,然后在较小的目标训练集上进行微调)。正如当时所提到的,源数据与目标领域越接近,这样的训练方案就越有效——反之亦然(在 Jupyter Notebook 中,我们强调了这些限制,尝试在过于偏离目标分布的合成图像上训练自驾车的分割模型)。
无监督领域适应
在准备训练数据集时,收集图像通常不是主要问题。但正确地为这些图像进行注释才是,因为这是一个繁琐且因此成本高昂的过程。因此,许多领域适应方法专注于这些仅有源图像、其对应注释和目标图像的场景。在没有真实标签的情况下,这些目标样本无法像通常的监督方式那样直接用于训练模型。相反,研究人员一直在探索无监督方案,以利用这些图像仍能提供的目标领域的视觉信息。
例如,像《通过深度适应网络学习可转移特征》这样的工作,作者是来自清华大学的 Mingsheng Long 等人,他们在模型的某些层中添加了约束,使得无论输入图像属于哪个领域,它们生成的特征图都有相同的分布。这种方法提出的训练方案可以简化为以下几种步骤:
-
在多个迭代中,以监督方式在源批次上训练模型。
-
偶尔,将训练集输入到模型中,并计算我们希望适应的层生成的特征图的分布(例如,均值和方差)。
-
类似地,将目标图像集输入到模型中,并计算生成的特征图的分布。
-
优化每一层,以减少两个分布之间的距离。
-
重复整个过程,直到达到收敛。
在没有目标标签的情况下,这些解决方案迫使网络学习可以在两个领域之间迁移的特征,而网络则在源数据上进行训练(约束通常添加到负责特征提取的最后卷积层,因为前面的层通常已经足够通用)。
其他方法则考虑到在这些训练场景中始终存在的隐式标签——每个图像所属的领域(即源或目标)。这个信息可以用来训练一个监督式二分类器——给定一张图像或特征图,其任务是预测它来自源领域还是目标领域。这个次级模型可以与主模型一起训练,指导其提取可能属于任一领域的特征。
例如,在他们的领域对抗神经网络(DANN)论文中(发表于 JMLR,2016),Hana Ajakan、Yaroslav Ganin 等人(来自 Skoltech)提出在模型中添加一个次级分支(紧接着特征提取层)进行训练,任务是识别输入数据的领域(即二分类)。然后,训练过程如下(再次简化):
-
生成一批源图像及其任务相关的真实标签,以训练主网络(通过主分支进行常规的前馈和反向传播)。
-
生成一批混合了源图像和目标图像的批次,并带有其领域标签,然后通过特征提取器和次级分支进行前馈,次级分支尝试预测每个输入的正确领域(源或目标)。
-
正常地通过次分支的层进行领域分类损失的反向传播,但在通过特征提取器反向传播之前,反转梯度。
-
重复整个过程直到收敛,即直到主网络能够按预期执行任务,而领域分类分支不再正确地预测领域。
这个训练过程如图 7-7所示:

图 7-7:应用于分类器训练的 DANN 概念
通过适当控制数据流或主损失的加权,三个步骤可以在一次迭代中同时执行。这在我们为该方法专门编写的 Jupyter Notebook 中有演示。
这个框架因其巧妙而受到了广泛关注。通过反转来自领域分类损失的梯度(即,将其乘以 -1)再通过特征提取器传播,特征提取器的各层将学习最大化这个损失,而不是最小化它。这个方法被称为对抗性,因为次级头会不断尝试正确预测领域,而上游特征提取器将学习混淆它。具体来说,这使得特征提取器学习到无法用于区分输入图像领域的特征,但对网络的主要任务是有用的(因为主头的正常训练是并行进行的)。训练完成后,领域分类头可以被简单地丢弃。
请注意,在 TensorFlow 2 中,操控特定操作的梯度是非常直接的。可以通过应用 @tf.custom_gradient 装饰器(参考文档 www.tensorflow.org/api_docs/python/tf/custom_gradient)到函数,并提供自定义的梯度操作来完成此任务。通过这种方式,我们可以为 DANN 实现以下操作,操作将在特征提取器后、领域分类层之前调用,以便在反向传播时反转该点的梯度:
# This decorator specifies the method has a custom gradient. Along with its normal output, the method should return the function to compute its gradient:
@tf.custom_gradient
def reverse_gradient(x): # Flip the gradient's sign.
y = tf.identity(x) # the value of the tensor itself isn't changed
return y, lambda dy: tf.math.negative(dy) # output + gradient method
自DANN之后,发布了许多其他领域适应方法(例如,ADDA 和 CyCaDa),它们遵循类似的对抗性框架。
在某些情况下,目标图像的注释是可用的,但密度不符合要求(例如,当目标任务是像素级语义分割时,只有图像级类别标签)。自动标注方法已经被提出用于这种情况。例如,在稀疏标签的指导下,训练于源数据的模型用于预测目标训练图像的更密集标签。然后,这些源标签被添加到训练集中,以细化模型。这个过程会反复进行,直到目标标签看起来足够正确,并且基于混合数据训练的模型已经收敛。
领域随机化
最后,可能出现完全没有目标数据可用于训练的情况(没有图像,没有注释)。此时,模型的性能完全依赖于源数据集的相关性(例如,渲染的合成图像在外观上多么逼真,并且与任务的相关性有多大)。
将数据增强概念推向极限,领域随机化也是一种可行的方式。这个方法主要由工业专家探索,基本思路是在大范围的数据变异下训练模型(如在《通过领域随机化将深度神经网络从模拟迁移到现实世界》,IEEE, 2017 中所述)。举个例子,如果我们只能访问到目标物体的 3D 模型,但不知道这些物体可能会出现在什么样的场景中,我们可以使用 3D 仿真引擎生成带有大量随机背景、光照、场景布局等的图像。其理论是,通过足够多的变异,仿真数据对模型而言可能就像是另一种变体,只要目标领域与随机化的训练领域在某种程度上重叠,网络在训练后就不会完全没有方向。
显然,我们不能指望这样的神经网络(NNs)能表现得像那些在目标样本上训练过的网络一样好,但领域随机化是应对困境的一种合理解决方案。
使用 VAEs 和 GANs 生成更大或更现实的数据集
本章将介绍的第二种主要的领域适应方法将为我们提供一个机会,介绍近年来机器学习中被称为最有趣的发展之一——生成模型,尤其是变分自编码器(VAEs)和生成对抗网络(GANs)。自从这些模型被提出以来,它们就非常流行,已被应用到多种不同的解决方案中。因此,我们将在这里进行一个通用的介绍,然后再呈现这些模型如何应用于数据集生成和领域适应。
判别模型与生成模型
到目前为止,我们研究的大多数模型都是判别模型。给定一个输入x,它们通过学习合适的参数W,来返回/区分出正确的标签y(例如,x可能是输入图像,y可能是该图像的类别标签)。判别模型可以被解释为函数 f(x ; W) = y。它们也可以被解释为尝试学习条件概率分布,p(y|x)(意思是给定 x 的情况下 y 的概率;例如,给定一张特定的图片x,它的标签是猫的图片的概率是多少?)。
还有一个我们尚未介绍的模型类别——生成模型。给定一些样本,x,这些样本来自未知的概率分布p(x),生成模型的目标是建模这个分布。例如,给定一些代表猫的图像x,生成模型将试图推测数据分布(这些猫的图像是如何通过所有可能的像素组合形成的),从而生成新的猫图像,这些图像可能属于与x相同的集合。
换句话说,判别模型通过特定特征学习识别图像(例如,它可能是一张猫的图片,因为它描绘了胡须、爪子和尾巴)。生成模型则学习从输入域中抽取新的图像,重现其典型特征(例如,这是一张合理的新猫的图片,通过生成并组合典型的猫特征得到)。
作为函数,生成性 CNN 需要一个输入,可以将其处理成一张新图片。通常,它们是通过噪声向量进行条件化的,也就是说,z是从随机分布中抽样得到的张量(例如
,意味着z是从均值为
和标准差为
的正态分布中随机抽样得到的)。对于每一个接收到的随机输入,模型都会提供一个从它们学会建模的分布中生成的新图像。当标签可用时,生成性网络还可以通过标签进行条件化,y。在这种情况下,它们需要建模条件分布,p(x|y)(例如,考虑标签y = "cat",那么抽取特定图像x的概率是多少?)
根据大多数专家的观点,生成模型是机器学习下一阶段的关键。为了能够生成大量且多样的新数据,尽管其参数数量有限,网络必须提炼数据集,以揭示其结构和关键特征。它们必须理解数据。
VAE
虽然自编码器也可以学习数据分布的某些方面,但它们的目标仅仅是重建编码后的样本,也就是说,通过编码后的特征辨别出原始图像,而不是从所有可能的像素组合中重建它们。标准自编码器并不旨在生成新的样本。如果我们随机从它们的潜在空间中抽取一个编码向量,那么从它们的解码器中得到一张无意义的图像的可能性是非常高的。这是因为它们的潜在空间没有约束,通常不是连续的(也就是说,潜在空间中通常有很大一部分区域并不对应任何有效图像)。
变分自编码器(VAE)是一种特别的自编码器,旨在具有连续的潜在空间,因此它们被用作生成模型。VAE 的编码器不是直接提取与图像x对应的编码,而是提供潜在空间中图像所属于分布的简化估计。
通常,编码器被构建为返回两个向量,分别表示多元正态分布的均值
和标准差
(针对n维潜在空间)。形象地说,均值表示图像在潜在空间中的最可能位置,而标准差控制围绕该位置的圆形区域的大小,该区域也是图像可能存在的地方。从编码器定义的这个分布中,选取一个随机编码z并传递给解码器。解码器的任务是基于z恢复图像x。由于相同图像的z可能会有所变化,解码器必须学会处理这些变化,以返回输入图像。
为了说明它们之间的区别,自编码器和 VAE 并排显示在图 7-8中:

图 7-8:标准自编码器与变分自编码器的比较
梯度无法通过随机采样操作反向传播。为了能够在尽管有采样 z 的情况下通过编码器反向传播损失,采用了重参数化技巧。该操作通过
来近似,而不是直接采样
,并且有
。通过这种方式,z 可以通过可导操作获得,将
视为一个随机向量,作为额外的输入传递给模型。
在训练过程中,一个损失—通常是均方误差(MSE)—衡量输出图像与输入图像的相似度,就像我们在标准自编码器中做的一样。然而,VAE 模型中还添加了另一个损失,以确保其编码器估计的分布是明确的。如果没有这个约束,VAE 可能会表现得像普通的自编码器,返回
空值和
作为图像的编码。这个第二个损失基于Kullback–Leibler 散度(以其创始人的名字命名,通常缩写为KL 散度)。KL 散度度量两个概率分布之间的差异。它被转化为一个损失,以确保编码器定义的分布足够接近标准正态分布
。

通过这种重参数化技巧和 KL 散度,自编码器变成了强大的生成模型。一旦模型训练完成,它们的编码器可以被丢弃,解码器可以直接用来生成新的图像,只需将随机向量作为输入!。例如,图 7-9 显示了一个简单卷积变分自编码器(VAE)生成的图像网格,该 VAE 的潜在空间维度为n = 2,经过训练以生成类似 MNIST 的图像(更多细节和源代码可以在 Jupyter Notebook 中找到):

图 7-9:由简单 VAE 生成的图像网格,训练以生成类似 MNIST 的结果
为了生成这个网格,不同的向量z不是随机选取的,而是通过采样均匀覆盖 2D 潜在空间的一部分,因此生成的网格图展示了z从(-1.5, -1.5)到(1.5, 1.5)变化时的输出图像。我们因此可以观察到潜在空间的连续性,生成图像的内容从一个数字到另一个数字有所变化。
GANs
生成对抗网络(GANs)最早由蒙特利尔大学的 Ian Goodfellow 等人于 2014 年提出,毫无疑问,它们是生成任务中最受欢迎的解决方案。
正如其名字所示,GANs 使用对抗性方案,因此它们可以以无监督的方式进行训练(这个方案启发了本章前面介绍的DANN方法)。在仅有一组图像x的情况下,我们希望训练一个生成器网络来建模p(x),也就是说,生成新的有效图像。因此,我们没有适当的真实标签数据可以用来直接与新图像进行比较(因为它们是新的)。无法使用典型的损失函数时,我们将生成器与另一个网络——判别器对抗。
判别器的任务是评估一张图像是否来自原始数据集(真实图像),还是由另一个网络生成(伪造图像)。类似于DANN中的领域判别头,判别器作为二分类器以监督方式进行训练,使用隐式图像标签(真实与伪造)进行训练。在与判别器对抗的过程中,生成器试图欺骗判别器,通过噪声向量z生成新的图像,使判别器认为它们是真实图像(即,从p(x)中采样的图像)。
当判别器预测生成图像的二分类时,其结果会通过反向传播传递回生成器。生成器因此从判别器的反馈中学习。例如,如果判别器学会检查图像中是否有胡须来标记其为真实(如果我们想要创建猫的图像),那么生成器就会从反向传播中接收到这一反馈,并学会画胡须(即使只有判别器接收到实际猫图像!)。图 7-10 通过生成手写数字图像来说明 GAN 的概念:

图 7-10:GAN 表示
GANs 的灵感来源于 博弈论,它们的训练可以解释为一个 二人零和极小极大博弈。博弈的每一阶段(即每次训练迭代)如下进行:
-
生成器 G 接收 N 个噪声向量 z,并输出相同数量的图像 x[G][.]
-
这些 𝑁 个 虚假的 图像与 N 个从训练集中挑选出来的 真实的 图像 x 混合在一起。
-
判别器 D 在这个混合批次上进行训练,试图估计哪些图像是 真实的,哪些是 虚假的。
-
生成器 G 在另一个 N 个噪声向量的批次上进行训练,试图生成图像,使得 D 假设它们是真实的。
因此,在每次迭代中,判别器 D(由 P[D] 参数化)试图最大化博弈奖励 V(G, D),而生成器 G(由 P[G] 参数化)试图最小化它:

注意,这个方程假设标签 真实 为 1,标签 虚假 为 0。V(G, D) 的第一项代表判别器 D 对图像 x 是 真实的 的平均对数概率估计(D 应该返回 1 对每一个)。第二项代表 D 对生成器输出是 虚假的 的平均对数概率估计(D 应该返回 0 对每一个)。因此,这个奖励 V(G, D) 被用来训练判别器 D,作为一个分类度量,D 必须最大化这个度量(尽管在实践中,人们更习惯于训练网络最小化 - V(G, D),以减少损失)。
从理论上讲,V(G, D) 也应该用于训练生成器 G,作为一个值,目的是最小化这个值。然而,如果 D 变得过于自信,它的第二项的梯度将会 消失,趋近于 0(因为第一项相对于 P[G] 的导数始终为零,因为 P[G] 在其中不起作用)。这种消失梯度可以通过一个小的数学变化避免,使用以下损失来训练 G:

根据博弈论,这个 极小极大 博弈 的结果是 G 和 D 之间的 均衡(称为 纳什均衡,以数学家约翰·福布斯·纳什 Jr. 的名字命名,他定义了这个概念)。虽然在 GANs 中很难实现,但训练应该最终使 D 无法区分 真实 和 虚假(即 D(x) = ¹/[2] 和 D(G(z)) = ¹/[2] 对所有样本成立),同时使 G 模拟目标分布 p(x)。
尽管训练困难,GANs 可以产生非常真实的结果,因此常用于生成新的数据样本(GANs 可应用于任何数据模态:图像、视频、语音、文本等)。
虽然变分自编码器(VAE)较容易训练,但生成对抗网络(GANs)通常返回更清晰的结果。使用均方误差(MSE)评估生成的图像时,VAE 的结果可能略显模糊,因为模型倾向于返回平均图像以最小化该损失。而 GANs 中的生成器无法采用这种方式作弊,因为判别器可以轻松识别模糊的图像为 虚假。VAE 和 GANs 都可以用于生成更大的训练数据集,以进行图像级别的识别(例如,准备一个 GAN 生成新的 狗 图像,另一个生成新的 猫 图像,用于在更大的数据集上训练 狗 对 猫 分类器)。
VAE 和 GANs 都在提供的 Jupyter Notebooks 中得到了实现。
使用条件生成对抗网络(GANs)扩增数据集
生成对抗网络(GANs)的另一个重要优势是它们可以通过任何类型的数据进行条件化。条件生成对抗网络(cGANs)可以被训练来建模 条件分布 p(x|y),即生成根据一组输入值 y 条件化的图像(参见生成模型的介绍)。条件输入 y 可以是图像、类别或连续标签、噪声向量等,或者它们的任何组合。
在条件生成对抗网络(GANs)中,判别器被修改为接收图像 x(真实或虚假)及其对应的条件变量 y,作为配对输入(即 D(x, y))。尽管其输出仍然是介于 0 和 1 之间的值,用于衡量输入看起来有多“真实”,其任务略有变化。为了被视为真实,图像不仅需要看起来像是从训练数据集中绘制的,还应该与其配对变量相对应。
举个例子,假设我们想要训练一个生成器 G,用来生成手写数字的图像。如果这个生成器能够根据请求生成特定数字的图像,那么它会更加有用,而不是生成随机数字的图像(即,生成一个图像,其 y = 3,其中 y 是类别数字标签)。如果判别器没有给定 y,生成器将学习生成真实的图像,但不能确定这些图像是否展示了所需的数字(例如,我们可能从 G 得到一个真实的 5 图像,而不是 3)。将条件信息提供给 D 后,网络将立即发现不对应 y 的虚假图像,迫使 G 有效地建模 p(x|y)。
由 Phillip Isola 等人(来自伯克利 AI 研究中心)提出的Pix2Pix模型是一种著名的图像到图像条件 GAN(即,y是图像),它在多个任务中得到了演示,例如将手绘草图转换为图片、将语义标签转换为实际图片等(Image-to-image translation with conditional adversarial networks,IEEE,2017)。虽然Pix2Pix在监督学习的背景下表现最好,当目标图像可用并能为 GAN 目标添加 MSE 损失时,更新的解决方案移除了这一约束。例如,CycleGAN(由 Jun-Yan Zhu 等人从伯克利 AI 研究中心提出,2017 年 IEEE 发表,与Pix2Pix作者合作)或PixelDA(由 Konstantinos Bousmalis 和 Google Brain 的同事提出,Unsupervised pixel-level domain adaptation with generative adversarial networks,IEEE,2017)便是这样的例子。
像其他近期的条件 GAN 一样,PixelDA可以作为一种领域适应方法,用于将源领域的训练图像映射到目标领域。例如,PixelDA生成器可以应用于生成逼真的合成图像版本,从一小组未标注的真实图像中进行学习。因此,它可以用于增强合成数据集,以使在其上训练的模型不那么受限于现实差距。
尽管主要以艺术应用而闻名(GAN 生成的肖像已经在许多艺术画廊展出),生成模型是强大的工具,从长远来看,它们可能会成为理解复杂数据集的核心。但如今,尽管训练数据稀缺,它们已经被公司用来训练更强大的识别模型。
总结
尽管计算能力的指数级增长和更大数据集的可用性促成了深度学习时代的到来,但这并不意味着数据科学的最佳实践应该被忽视,也不意味着所有应用都能轻松获得相关数据集。
在本章中,我们深入研究了tf.data API,学习了如何优化数据流。接着,我们介绍了几种不同但兼容的解决方案来解决数据稀缺的问题:数据增强、合成数据生成和领域适应。后者的解决方案使我们有机会介绍了 VAEs 和 GANs 这两种强大的生成模型。
在下一章中,我们将强调定义良好的输入管道的重要性,因为我们将应用神经网络(NNs)于更高维度的数据:图像序列和视频。
问题
-
给定一个张量,
a = [1, 2, 3],和另一个张量,b = [4, 5, 6],你如何构建一个tf.data管道,使其能够单独输出每个值,从1到6? -
根据
tf.data.Options的文档,如何确保数据集每次运行时都能以相同的顺序返回样本? -
我们介绍的哪些领域适应方法可以在没有目标注释的情况下用于训练?
-
判别器在生成对抗网络(GANs)中起什么作用?
进一步阅读
-
学习 OpenGL (
www.packtpub.com/game-development/learn-opengl),作者:Frahaan Hussain:对于有兴趣了解计算机图形学并渴望学习如何使用 OpenCV 的读者,这本书是一个很好的起点。 -
初学者的人工智能实践 (
www.packtpub.com/big-data-and-business-intelligence/hands-artificial-intelligence-beginners),作者:Patrick D. Smith:尽管这本书是为 TensorFlow 1 编写的,但它为生成网络专门分配了完整的一章。
第八章:视频与递归神经网络
到目前为止,本书只讨论了静态图像。然而,在这一章中,我们将介绍应用于视频分析的技术。从自动驾驶汽车到视频流网站,计算机视觉技术已经发展出来,用以处理图像序列。
我们将介绍一种新的神经网络类型——递归神经网络(RNNs),它们专门为处理视频等序列输入而设计。作为一个实际应用,我们将它们与卷积神经网络(CNNs)结合,用来检测短视频片段中的动作。
本章将覆盖以下主题:
-
递归神经网络简介
-
长短时记忆网络的内部工作原理
-
计算机视觉模型在视频中的应用
技术要求
本书的 GitHub 仓库中提供了以 Jupyter notebooks 形式的注释代码,网址为github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2/tree/master/Chapter08。
介绍 RNNs
RNNs 是一种适用于序列(或递归)数据的神经网络。序列数据的例子包括句子(词语序列)、时间序列(例如股票价格序列)或视频(帧序列)。它们属于递归数据,因为每个时间步都与前面的步骤相关。
虽然 RNNs 最初是为时间序列分析和自然语言处理任务而开发的,但现在它们已被应用于各种计算机视觉任务。
我们将首先介绍 RNNs 的基本概念,然后尝试对它们的工作原理进行一般性的理解。接着,我们将描述它们的权重如何学习。
基本形式
为了介绍 RNNs,我们将以视频识别为例。一个视频由 N 帧组成。分类一个视频的简单方法是对每一帧应用 CNN,然后对输出结果取平均值。
尽管这种方法能提供不错的结果,但它并未反映出视频中的某些部分比其他部分更为重要。而且,重要部分并不总是需要比无意义的部分更多的帧。将输出进行平均的风险在于可能会丢失重要信息。
为了避免这个问题,RNN 会将视频的所有帧依次处理,从第一帧到最后一帧。RNN 的主要特点是能够有效地结合所有帧的特征,从而生成有意义的结果。
我们并不会直接将 RNN 应用于帧的原始像素。如本章稍后所述,我们首先使用 CNN 生成一个特征体积(一个特征图的堆叠)。特征体积的概念在第三章,现代神经网络中有详细介绍。提醒一下,特征体积是 CNN 的输出,通常表示的是具有较小维度的输入。
为了实现这一点,RNN 引入了一个新的概念,叫做状态。状态可以看作是 RNN 的记忆。在实践中,状态是一个浮动矩阵。状态最初是一个零矩阵,并在每一帧视频中进行更新。过程结束时,最终状态被用来生成 RNN 的输出。
RNN 的主要组件是 RNN 单元,我们将对每一帧应用该单元。一个单元接受的输入包括 当前帧 和 上一个状态。对于由 N 帧组成的视频,简单递归网络的展开表示如图 8-1 所示:

图 8-1:基本 RNN 单元
具体来说,我们从一个空状态 (h^(<0>) ) 开始。第一步,单元将当前状态 (h^(<0>) ) 与当前帧 (frame[1]) 结合生成新的状态 (h^(<1>) )。然后,相同的过程会应用于接下来的帧。过程结束时,我们会得到最终状态 (h^(
请注意这里的术语——RNN 指的是接收图像并返回最终输出的组件。RNN 单元 指的是将一帧和当前状态结合起来,并返回下一个状态的子组件。
在实践中,单元将当前状态和帧结合起来生成新的状态。这个组合是根据以下公式进行的:

在公式中,以下内容适用:
-
b 是偏置。
-
W[rec] 是递归权重矩阵,W[input] 是权重矩阵。
-
x^(
) 是输入。 -
h(*<t-1>*)> 是当前状态,h^(
)* 是新状态。
隐藏状态不是直接使用的。会使用一个权重矩阵 V 来计算最终的预测:

在本章中,我们将使用尖括号 (< >) 来表示时间信息。其他来源可能使用不同的约定。然而,请注意,带有帽子的 y(
)通常表示神经网络的预测,而 y 则表示真实值。
在应用于视频时,RNN 可以用于对整个视频或每一帧进行分类。在前一种情况下,例如,在预测视频是否暴力时,只有最终的预测
会被使用。在后一种情况下,例如,为了检测哪些帧可能包含裸露内容,每个时间步的预测都会被使用。
RNN 的一般理解
在详细说明网络如何学习 W[input]、W[rec] 和 V 的权重之前,我们先尝试大致了解基本的 RNN 是如何工作的。一般来说,W[input] 会影响结果,如果输入中的某些特征进入了隐藏状态,W[rec] 会影响结果,如果某些特征停留在隐藏状态中。
让我们用具体的例子来说明——分类暴力视频和舞蹈视频。
由于枪声通常是非常突然而短暂的,它只会出现在视频中的少数几帧中。理想情况下,网络将学习 W[input],以便当 x^(
然而,为了对舞蹈视频进行分类,我们需要采用另一种行为。理想情况下,网络应该学习 W[input],因此,例如,当 x^(

图 8-2:隐藏状态应如何根据视频内容演变的简化表示
确实,如果输入的是一段体育视频,我们不希望某一帧被错误地分类为 跳舞的人,进而改变我们的状态为 跳舞。由于舞蹈视频大多由包含跳舞人物的帧组成,通过逐步递增状态,我们可以避免误分类。
此外,W[rec] 必须被学习,以使 舞蹈 概念逐渐从状态中消失。这样,如果视频的开头是关于舞蹈的,但整个视频并不是舞蹈视频,它就不会被分类为舞蹈视频。
学习 RNN 权重
实际上,网络的状态比前面例子中仅包含每个类别的权重的向量要复杂得多。W[input]、W[rec] 和 V 的权重无法手工设计。幸运的是,它们可以通过 反向传播 进行学习。该技术在第一章,计算机视觉与神经网络 中有详细介绍。一般的思路是通过根据网络所犯的错误来修正权重,从而学习这些权重。
通过时间的反向传播
然而,对于 RNN,我们不仅通过网络的深度进行反向传播误差,还需要通过时间进行反向传播。首先,我们通过对所有时间步的个体损失(L)求和来计算总损失:

这意味着我们可以单独计算每个时间步的梯度。为了大大简化计算,我们将假设 tanh = identity(也就是说,我们假设没有激活函数)。例如,在 t = 4 时,我们将通过应用链式法则来计算梯度:

在这里,我们遇到了一种复杂性——方程右侧的第三项(加粗部分)无法轻易求导。实际上,要对 h^(<4>) 关于 W[rec] 求导,所有其他项不能依赖于 W[rec]。然而,h^(<4>) 也依赖于 h^(<3>),而 h^(<3>) 又依赖于 W[rec],因为 h^(<3>) = tanh (W**[rec] h^(<2>) + W**[input] x^(<3>) + b),以此类推,直到我们到达 h^(<0>),它完全由零组成。
为了正确地推导这个项,我们在这个偏导数上应用全微分公式:

乍一看,似乎有些奇怪:一个项等于它自己加上其他(非零)项。然而,由于我们正在对一个偏导数进行全微分,我们需要考虑所有项,以生成梯度。
通过注意到其他所有项保持不变,我们可以得到以下方程:

因此,之前呈现的偏导数可以表示为如下:

总结来说,我们注意到梯度将依赖于所有之前的状态以及 W[rec]。这个概念被称为 时间反向传播(BPTT)。由于最新的状态依赖于它之前的所有状态,因此考虑这些状态来计算误差是有意义的。当我们将每个时间步的梯度相加以计算总梯度时,并且由于对于每个时间步,我们必须回到第一个时间步来计算梯度,因此涉及大量的计算。因此,RNN 在训练时往往非常慢。
此外,我们可以将之前的公式推广,表明
依赖于 W[rec] 的 t-2 次方。当 t 较大时,这非常棘手。事实上,如果 W[rec] 的项小于 1,随着指数的增大,它们变得非常小。更糟糕的是,如果这些项大于 1,梯度将趋向于无穷大。这些现象分别被称为 梯度消失 和 梯度爆炸(它们在第四章中有描述,影响力分类工具)。幸运的是,存在一些解决方法可以避免这个问题。
截断反向传播
为了避免长时间的训练过程,可以选择每 k[1] 个时间步计算一次梯度,而不是每一步计算一次。这将梯度计算的次数除以 k[1],使得网络的训练更加迅速。
我们可以将反向传播限制在 k[2] 步之前,而不是遍历所有时间步。这有效地限制了梯度消失,因为梯度最多只会依赖于 W^(k[2])。这也限制了计算梯度所需的计算量。然而,网络将不太可能学习长期的时间关系。
这两种技术的结合被称为 截断反向传播,其两个参数通常被称为 k[1] 和 k[2]。它们必须进行调整,以确保在训练速度和模型性能之间达到良好的平衡。
这个技术——尽管强大——仍然是解决一个基本 RNN 问题的权宜之计。在接下来的章节中,我们将介绍一种架构的改变,可以用来彻底解决这个问题。
长短期记忆单元
正如我们之前看到的,常规 RNN 存在梯度爆炸问题。因此,有时候它们很难学习数据序列中的长期关系。此外,它们将信息存储在单一的状态矩阵中。例如,如果枪声发生在一个非常长的视频的开始部分,到视频结束时,RNN 的隐藏状态很可能已经被噪声覆盖。这个视频可能不会被分类为暴力内容。
为了解决这两个问题,Sepp Hochreiter 和 Jürgen Schmidhuber 在他们的论文(Long Short-Term Memory,Neural Computation,1997)中提出了基本 RNN 的一种变种——长短期记忆(LSTM)单元。多年来,这一方法有了显著的改进,并引入了许多变体。在本节中,我们将概述其内部工作原理,并展示为什么梯度消失问题不再那么严重。
LSTM 的基本原理
在详细介绍 LSTM 单元背后的数学原理之前,我们先尝试理解它是如何工作的。为此,我们将以一个应用于奥林匹克运动会的实时分类系统为例。该系统必须检测每一帧中正在进行的运动项目。
如果网络看到人们站成一排,它能推测是什么运动吗?是足球运动员在唱国歌,还是运动员准备跑 100 米比赛?如果没有关于前面几帧发生了什么的信息,预测就不会准确。我们之前介绍的基本 RNN 架构能够将这些信息存储在隐藏状态中。然而,如果运动项目是一个接一个交替出现的,那么这将变得更加困难。事实上,状态被用来生成当前的预测。基本 RNN 无法存储那些它不会立即使用的信息。
LSTM 架构通过存储一个被称为单元状态的记忆矩阵来解决这个问题,它被表示为 C(<t>)*。在每个时间步,*C(
注意,LSTM 的单元状态与简单 RNN 的状态不同,如下方程所示。LSTM 的单元状态在转化为最终状态之前会经过过滤。
门是 LSTM 单元的核心思想。一个门是一个矩阵,将与 LSTM 中的另一个元素逐项相乘。如果门的所有值为0,则其他元素的信息将无法通过。另一方面,如果门的值接近1,则所有其他元素的信息都将通过。
提醒一下,逐项相乘的示例(也称为元素级乘法或哈达玛积)可以如下表示:

在每个时间步长,利用当前输入和前一输出计算三个门矩阵:
-
输入门:应用于输入,以决定哪些信息能通过。在我们的示例中,如果视频显示的是观众成员,我们可能不希望使用此输入来生成预测。此时,门的值大多为零。
-
遗忘门:应用于单元状态,以决定忘记哪些信息。在我们的示例中,如果视频展示的是讲解员在讲话,我们可能希望忘记当前的运动项目,因为接下来可能会展示新的运动项目。
-
输出门:将与单元状态相乘,以决定哪些信息被输出。我们可能希望在单元状态中保留先前的体育项目为足球这一事实,但这对于当前帧并没有用处。输出此信息可能会扰乱接下来的时间步。通过将门的值设为接近零,我们可以有效地将此信息保留到后面。
在下一部分,我们将介绍如何计算这些门和候选状态,并展示为什么 LSTM 在梯度消失问题上影响较小。
LSTM 内部工作原理
首先,让我们详细说明门是如何计算的:

如前面方程所述,三个门是通过相同的原理计算的——将权重矩阵 (W) 与前一输出 (h^(
候选状态(
)以类似方式计算。然而,所使用的激活函数是双曲正切函数,而非 sigmoid 函数:

请注意,这个公式与在基本 RNN 架构中用于计算h(<t>)*的公式完全相同。然而,*h(

最后,LSTM 隐藏状态(输出)将从单元状态计算如下:

LSTM 单元的简化表示如图 8-3所示:

图 8-3:LSTM 单元的简化表示。门控计算已省略
LSTM 权重同样通过时间反向传播进行计算。由于 LSTM 单元中存在众多信息路径,梯度计算变得更加复杂。然而,我们可以观察到,如果遗忘门的项f^(

因此,通过将遗忘门偏置初始化为全为 1 的向量,我们可以确保信息能够通过多个时间步进行反向传播。这样,LSTM 在梯度消失问题上表现得较少。
这标志着我们对 RNN 的介绍结束;我们现在可以开始进行视频的实际分类。
视频分类
从电视到网络流媒体,视频格式越来越受欢迎。自计算机视觉诞生以来,研究人员一直尝试将计算机视觉应用于多张图像的处理。尽管最初受到计算能力的限制,但最近他们已经开发出了强大的视频分析技术。在本节中,我们将介绍与视频相关的任务,并详细介绍其中之一——视频分类。
将计算机视觉应用于视频
在每秒 30 帧的情况下,处理每一帧视频意味着每分钟需要分析30 × 60 = 180 帧。这一问题在计算机视觉早期阶段就已经出现,在深度学习兴起之前。当时,开发出了高效的视频分析技术。
最明显的技术是采样。我们可以每秒分析一到两帧,而不是分析所有帧。虽然这样更高效,但如果一个重要场景短暂出现,例如之前提到的枪声,我们可能会失去信息。
更高级的技术是场景提取。这对于分析电影特别流行。算法检测视频何时从一个场景切换到另一个场景。例如,如果镜头从特写镜头切换到广角镜头,我们将分析每个镜头中的一帧。即使特写镜头非常短,而广角镜头跨越多个帧,我们也只会从每个镜头中提取一帧。场景提取可以通过使用快速高效的算法来完成。它们处理图像的像素,并评估两个连续帧之间的变化。大的变化意味着场景切换。
此外,所有在第一章中描述的与图像相关的任务,计算机视觉与神经网络,也适用于视频。例如,超分辨率、分割和风格迁移通常针对视频进行。但视频的时间维度创造了新的应用形式,具体体现在以下特定于视频的任务中:
-
动作检测:这是视频分类的一个变种,目标是分类一个人正在完成的动作。动作从跑步到踢足球不等,甚至可以精确到表演的舞蹈类型或演奏的乐器。
-
下一帧预测:给定N连续帧,预测第N+1帧的样子。
-
超慢动作:这也叫做帧插值。模型需要生成中间帧,以使慢动作看起来不那么卡顿。
-
物体跟踪:这通常使用传统的计算机视觉技术,如描述符来执行。然而,现在已经采用深度学习来跟踪视频中的物体。
在这些特定于视频的任务中,我们将重点介绍动作检测。在下一节中,我们将介绍一个动作视频数据集,并介绍如何将 LSTM 单元应用于视频。
使用 LSTM 对视频进行分类
我们将使用UCF101数据集(www.crcv.ucf.edu/data/UCF101.php),该数据集由 K. Soomro 等人编制(请参阅UCF101:来自野外视频的 101 个人类动作类别数据集,CRCV-TR-12-01,2012)。以下是数据集中的一些示例:

图 8-4:UCF101 数据集中的示例图像
该数据集包含 13,320 个视频片段。每个片段包含一个人执行 101 种可能动作中的一种。
为了对视频进行分类,我们将采用两步过程。事实上,递归网络并不会直接输入原始的像素图像。虽然理论上可以直接输入完整图像,但在此之前使用 CNN 特征提取器来减少维度,并减少 LSTM 的计算量。因此,我们的网络架构可以通过图 8-5表示:

图 8-5:结合 CNN 和 RNN 进行视频分类。在这个简化的示例中,序列长度为 3
如前所述,通过 RNN 反向传播错误是困难的。虽然我们可以从头开始训练 CNN,但这将花费大量时间并得到不理想的结果。因此,我们使用预训练网络,应用第四章《有影响力的分类工具》中介绍的迁移学习技术。
出于同样的原因,通常不对 CNN 进行微调,并保持其权重不变,因为这样不会带来性能提升。由于 CNN 在整个训练周期中保持不变,因此特定帧始终返回相同的特征向量。这使我们能够缓存特征向量。由于 CNN 步骤是最耗时的,缓存结果意味着只需计算一次特征向量,而不是每个周期都计算,从而节省大量训练时间。
因此,我们将视频分为两个步骤进行分类。首先,我们将提取特征并缓存它们。一旦完成此操作,我们将在提取的特征上训练 LSTM。
从视频中提取特征
为了生成特征向量,我们将使用在 ImageNet 数据集上训练过的预训练 Inception 网络来对图像进行分类。
我们将移除最后一层(全连接层),仅保留最大池化操作后生成的特征向量。
另一个选择是保留平均池化层之前的输出,即高维特征图。然而,在我们的示例中,我们不需要空间信息——无论动作发生在画面中央还是角落,预测结果都会相同。因此,我们将使用二维最大池化层的输出。这将加速训练,因为 LSTM 的输入将比原来小 64 倍(64 = 8 × 8 = 输入图像大小为 299 × 299 的特征图大小)。
TensorFlow 允许我们通过一行代码访问预训练模型,如第四章《有影响力的分类工具》中所描述:
inception_v3 = tf.keras.applications.InceptionV3(include_top=False, weights='imagenet')
我们添加最大池化操作,将8 × 8 × 2,048 的特征图转换为1 × 2,048 的向量:
x = inception_v3.output
pooling_output = tf.keras.layers.GlobalAveragePooling2D()(x)
feature_extraction_model = tf.keras.Model(inception_v3.input, pooling_output)
我们将使用tf.data API 从视频中加载帧。一个初步问题是——所有视频的长度不同。以下是帧数的分布情况:

图 8-6:UCF101 数据集中每个视频的帧数分布
在使用数据之前,最好进行快速分析。手动检查数据并绘制分布图可以节省大量实验时间。
在 TensorFlow 中,与大多数深度学习框架一样,批次中的所有示例必须具有相同的长度。为满足此要求,最常见的解决方案是padding(填充)——我们用实际数据填充前几个时间步,将最后几个时间步填充为零。
在我们的案例中,我们不会使用视频中的所有帧。每秒 25 帧的情况下,大多数帧看起来相似。通过只使用一部分帧,我们可以减少输入的大小,从而加快训练过程。为了选择这个子集,我们可以使用以下任意一种选项:
-
每秒提取N帧。
-
从所有帧中采样N帧。
-
将视频分段为场景并从每个场景中提取N帧,如下图所示:

图 8-7:两种采样技术的比较。虚线矩形表示零填充
由于视频长度的巨大变化,每秒提取N帧也会导致输入长度的巨大变化。虽然可以通过填充解决这个问题,但我们最终会得到一些几乎全部由零组成的输入——这可能导致训练性能不佳。因此,我们将从每个视频中采样N张图像。
我们将使用 TensorFlow 数据集 API 将输入馈送到我们的特征提取网络:
dataset = tf.data.Dataset.from_generator(frame_generator,
output_types=(tf.float32, tf.string),
output_shapes=((299, 299, 3), ())
在之前的代码中,我们指定了输入类型和输入形状。我们的生成器将返回形状为299 × 299的图像,并且具有三个通道,还会返回一个表示文件名的字符串。文件名稍后将用于根据视频对帧进行分组。
frame_generator的作用是选择将由网络处理的帧。我们使用 OpenCV 库从视频文件中读取数据。对于每个视频,我们每隔N帧采样一张图像,其中N等于num_frames / SEQUENCE_LENGTH,而SEQUENCE_LENGTH是 LSTM 输入序列的大小。该生成器的简化版本如下所示:
def frame_generator():
video_paths = tf.io.gfile.glob(VIDEOS_PATH)
for video_path in video_paths:
capture = cv2.VideoCapture(video_path)
num_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
sample_every_frame = max(1, num_frames // SEQUENCE_LENGTH)
current_frame = 0
label = os.path.basename(os.path.dirname(video_path))
while True:
success, frame = capture.read()
if not success:
break
if current_frame % sample_every_frame == 0:
img = preprocess_frame(frame)
yield img, video_path
current_frame += 1
我们遍历视频的帧,只处理其中的一部分。在视频结束时,OpenCV 库将返回success为False,循环将终止。
注意,与任何 Python 生成器一样,我们不是使用return关键字,而是使用yield关键字。这样我们可以在循环结束之前就开始返回帧。这样,网络可以在不等待所有帧预处理完成的情况下开始训练。
最后,我们遍历数据集以生成视频特征:
dataset = dataset.batch(16).prefetch(tf.data.experimental.AUTOTUNE)
current_path = None
all_features = []
for img, batch_paths in tqdm.tqdm(dataset):
batch_features = feature_extraction_model(img)
for features, path in zip(batch_features.numpy(), batch_paths.numpy()):
if path != current_path and current_path is not None:
output_path = current_path.decode().replace('.avi', '')
np.save(output_path, all_features)
all_features = []
current_path = path
all_features.append(features)
在之前的代码中,注意我们迭代批次输出并比较视频文件名。我们这样做是因为批次大小不一定与N(我们每个视频采样的帧数)相同。因此,一个批次可能包含多个连续序列的帧:

图 8-8:批次大小为四,每个视频采样三帧的输入表示
我们读取网络的输出,当遇到不同的文件名时,我们将视频特征保存到文件中。请注意,这种技术只有在帧的顺序正确时才有效。如果数据集被打乱,它将无法正常工作。视频特征会保存到与视频相同的位置,但扩展名不同(.npy而不是.avi)。
这一步会迭代数据集中 13,320 个视频,并为每个视频生成特征。每个视频采样 40 帧,使用现代 GPU 大约需要一个小时。
训练 LSTM
现在视频特征已生成,我们可以用它们来训练 LSTM。这个步骤与本书前面描述的训练步骤非常相似——我们定义模型和输入管道,并启动训练。
定义模型
我们的模型是一个简单的顺序模型,使用 Keras 层定义:
model = tf.keras.Sequential([
tf.keras.layers.Masking(mask_value=0.),
tf.keras.layers.LSTM(512, dropout=0.5, recurrent_dropout=0.5),
tf.keras.layers.Dense(256, activation='relu'),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(len(LABELS), activation='softmax')
])
我们应用了一个 dropout,这是在第三章中介绍的概念,现代神经网络。LSTM 的dropout参数控制输入权重矩阵上应用的 dropout 量。recurrent_dropout参数控制对前一个状态应用的 dropout 量。与 mask 类似,recurrent_dropout会随机忽略部分前一状态的激活值,以避免过拟合。
我们模型的第一层是一个Masking层。由于我们将图像序列填充了空帧以便批处理,因此我们的 LSTM 单元会不必要地迭代这些添加的帧。添加Masking层可以确保 LSTM 层在遇到零矩阵之前停止在实际的序列末尾。
该模型将把视频分为 101 个类别,比如kayaking、rafting或fencing。然而,它只会预测一个表示预测的向量。我们需要一种方法将这 101 个类别转换成向量形式。我们将使用一种叫做独热编码的技术,详细说明见第一章,计算机视觉与神经网络。由于我们有 101 个不同的标签,我们将返回一个大小为 101 的向量。对于kayaking,该向量将除了第一个元素外全为零,第一个元素设置为1。对于rafting,除了第二个元素外其余全为0,第二个元素设置为1,其他类别以此类推。
加载数据
我们将使用生成器加载生成帧特征时产生的.npy文件。代码确保所有输入序列具有相同的长度,必要时会用零进行填充:
def make_generator(file_list):
def generator():
np.random.shuffle(file_list)
for path in file_list:
full_path = os.path.join(BASE_PATH, path)
full_path = full_path.replace('.avi', '.npy')
label = os.path.basename(os.path.dirname(path))
features = np.load(full_path)
padded_sequence = np.zeros((SEQUENCE_LENGTH, 2048))
padded_sequence[0:len(features)] = np.array(features)
transformed_label = encoder.transform([label])
yield padded_sequence, transformed_label[0]
return generator
在前面的代码中,我们定义了一个 Python 闭包函数——一个返回另一个函数的函数。这个技术使我们能够通过一个生成器函数创建train_dataset(返回训练数据)和validation_dataset(返回验证数据):
train_dataset = tf.data.Dataset.from_generator(make_generator(train_list),
output_types=(tf.float32, tf.int16),
output_shapes=((SEQUENCE_LENGTH, 2048), (len(LABELS))))
train_dataset = train_dataset.batch(16)
train_dataset = train_dataset.prefetch(tf.data.experimental.AUTOTUNE)
valid_dataset = tf.data.Dataset.from_generator(make_generator(test_list),
output_types=(tf.float32, tf.int16),
output_shapes=((SEQUENCE_LENGTH, 2048), (len(LABELS))))
valid_dataset = valid_dataset.batch(16)
valid_dataset = valid_dataset.prefetch(tf.data.experimental.AUTOTUNE)
我们还根据第七章中描述的最佳实践,对数据进行批处理和预取,在复杂和稀缺数据集上训练。
训练模型
训练过程与书中先前描述的非常相似,我们邀请读者参考本章附带的笔记本。使用之前描述的模型,我们在验证集上达到了 72%的精度。
这个结果可以与使用更先进技术时获得的 94%的最先进精度水平进行比较。我们的简单模型可以通过改善帧采样、使用数据增强、采用不同的序列长度,或通过优化层的大小来增强。
总结
我们通过描述 RNN 的基本原理扩展了对神经网络的理解。在介绍了基本 RNN 的内部工作原理后,我们将反向传播扩展到递归网络的应用。正如本章所介绍的,当应用于 RNN 时,BPTT 会遭遇梯度消失问题。可以通过使用截断反向传播,或采用不同类型的架构——LSTM 网络来解决这个问题。
我们将这些理论原则应用于一个实际问题——视频中的动作识别。通过结合 CNN 和 LSTM,我们成功地训练了一个网络,将视频分类为 101 个类别,并引入了视频特有的技术,如帧采样和填充。
在下一章中,我们将通过介绍新平台——移动设备和网页浏览器,扩展我们对神经网络应用的了解。
问题
-
LSTM 相较于简单 RNN 架构的主要优势是什么?
-
当 CNN 应用于 LSTM 之前,它的用途是什么?
-
什么是梯度消失,为什么会发生?它为什么是个问题?
-
梯度消失的一些解决方法有哪些?
进一步阅读
-
Python 快速入门指南中的 RNN (
www.packtpub.com/big-data-and-business-intelligence/recurrent-neural-networks-python-quick-start-guide),作者:Simeon Kostadinov:本书详细介绍了 RNN 架构,并通过使用 TensorFlow 1 的示例进行应用。 -
RNN 在序列学习中的批判性回顾 (
arxiv.org/abs/1506.00019),作者:Zachary C. Lipton 等人:本文综述并综合了三十年的 RNN 架构。 -
门控 RNN 在序列建模中的经验评估 (
arxiv.org/abs/1412.3555),作者:Junyoung Chung 等人:本文比较了不同 RNN 架构的性能。
第九章:优化模型并部署到移动设备
计算机视觉应用种类繁多、涉及面广。虽然大多数训练步骤发生在服务器或计算机上,但深度学习模型也广泛应用于各种前端设备,如手机、自动驾驶汽车和物联网(IoT)设备。在有限的计算能力下,性能优化变得尤为重要。
在本章中,我们将介绍一些技术,帮助你限制模型大小并提高推理速度,同时保持良好的预测质量。作为一个实际示例,我们将创建一个简单的移动应用,识别 iOS 和 Android 设备上的面部表情,以及在浏览器中运行。
本章将涵盖以下主题:
-
如何减少模型大小并提升速度,同时保持准确性
-
深入分析模型计算性能
-
在移动设备(iOS 和 Android)上运行模型
-
介绍 TensorFlow.js 在浏览器中运行模型
技术要求
本章的代码可以从 github.com/PacktPublishing/Hands-On-Computer-Vision-with-TensorFlow-2/tree/master/Chapter09 获取。
在为移动设备开发应用时,你需要掌握 Swift(用于 iOS)或 Java(用于 Android)。如果在浏览器中进行计算机视觉开发,你需要了解 JavaScript。本章中的示例简单且有详细解释,即便你更熟悉 Python,理解起来也非常容易。
此外,要运行示例 iOS 应用,你需要一台兼容的设备以及安装了 Xcode 的 Mac 电脑。要运行 Android 应用,你需要一台 Android 设备。
优化计算和磁盘占用
使用计算机视觉模型时,有些特性至关重要。优化模型的速度可能使它实现实时运行,开启许多新的应用场景。即使是提高模型的准确性几个百分点,也可能决定一个模型是玩具级别还是能够实际应用的。
另一个重要特征是大小,它影响模型所需的存储空间以及下载时间。对于一些平台,如手机或网页浏览器,模型的大小对最终用户来说至关重要。
本节将介绍提升模型推理速度的技术,并讨论如何减少模型的大小。
测量推理速度
推理描述了使用深度学习模型进行预测的过程。推理速度通常以每秒图像数或每张图像的秒数来衡量。模型必须在每秒处理 5 到 30 张图像之间,才能算作实时处理。在提高推理速度之前,我们需要正确地测量它。
如果一个模型每秒能处理i张图像,我们可以同时运行N个推理管道来提高性能——这样,模型将能够每秒处理N × i张图像。虽然并行化对许多应用有益,但它并不适用于实时应用。
在实时场景中,例如自动驾驶汽车,无论能并行处理多少图像,真正重要的是延迟——为单张图像计算预测所需的时间。因此,对于实时应用,我们只测量模型的延迟——处理单张图像所需的时间。
对于非实时应用,您可以并行运行任意多的推理进程。例如,对于一个视频,您可以并行分析N段视频,并在过程结束时将预测结果连接起来。唯一的影响是财务成本,因为您需要更多的硬件来并行处理这些帧。
测量延迟
如前所述,要衡量模型的性能,我们希望计算处理单张图像所需的时间。然而,为了最小化测量误差,我们实际上会测量多个图像的处理时间。然后,我们将获得的时间除以图像的数量。
我们并不测量单张图像的计算时间,原因有几个。首先,我们希望消除测量误差。第一次运行推理时,机器可能正在忙碌,GPU 可能尚未初始化,或者其他许多技术因素可能导致性能下降。多次运行可以帮助我们减少这种误差。
第二个原因是 TensorFlow 和 CUDA 的预热。当第一次运行某个操作时,深度学习框架通常会比较慢——它们需要初始化变量、分配内存、移动数据等。此外,在执行重复操作时,它们通常会自动优化。
基于以上原因,建议使用多个图像来测量推理时间,以模拟实际环境。
在测量推理时间时,包含数据加载、数据预处理和后处理时间也非常重要,因为这些可能占有相当大的比例。
使用跟踪工具来理解计算性能
虽然测量模型的总推理时间可以告诉您应用的可行性,但有时您可能需要更详细的性能报告。为此,TensorFlow 提供了多个工具。在本节中,我们将讨论跟踪工具,它是 TensorFlow 摘要包的一部分。
在第七章,《复杂和稀缺数据集上的训练》中,我们描述了如何分析输入管道的性能。请参考本章以监控预处理和数据摄取的性能。
要使用它,调用trace_on并将profiler设置为True。然后,您可以运行 TensorFlow 或 Keras 操作,并将跟踪信息导出到一个文件夹:
logdir = './logs/model'
writer = tf.summary.create_file_writer(logdir)
tf.summary.trace_on(profiler=True)
model.predict(train_images)
with writer.as_default():
tf.summary.trace_export('trace-model', profiler_outdir=logdir)
忽略调用 create_file_writer 和 with writer.as_default() 仍然会生成操作的追踪信息。然而,模型图表示将不会写入磁盘。
一旦模型开始运行并启用追踪,我们可以通过在命令行执行以下命令来将 TensorBoard 指向该文件夹:
$ tensorboard --logdir logs
在浏览器中打开 TensorBoard 并点击 Profile 标签后,我们可以查看操作信息:

图 9-1:在多个数据批次上进行简单全连接模型的操作追踪
如前所述,模型由许多小的操作组成。通过点击某个操作,我们可以获取它的名称及持续时间。例如,以下是一个密集矩阵乘法(全连接层)的详细信息:

图 9-2:矩阵乘法操作的细节
TensorFlow 追踪可能会占用大量磁盘空间。因此,我们建议仅在少量数据批次上运行你希望追踪的操作。
在 TPU 上,TensorBoard 提供了一个专用的 Capture Profile 按钮。需要指定 TPU 名称、IP 地址以及追踪记录的时间。
实际上,追踪工具通常用于更大的模型,以确定以下信息:
-
哪些层占用了最多的计算时间。
-
为什么在修改架构后模型需要更多时间。
-
判断 TensorFlow 是否始终在计算数字,或者是否在等待数据。这可能是因为数据预处理时间过长,或者 CPU 之间存在大量的数据交换。
我们鼓励你追踪所使用的模型,以更好地理解计算性能。
提高模型推理速度
现在我们知道如何正确衡量模型推理速度,接下来可以使用几种方法来提高推理速度。有些方法涉及更换硬件,而其他方法则需要改变模型架构本身。
针对硬件进行优化
正如我们之前看到的,推理使用的硬件对速度至关重要。从最慢的选项到最快的选项,推荐使用以下硬件:
-
CPU:虽然较慢,但通常是最便宜的选择。
-
GPU:速度较快,但价格较贵。许多智能手机都配备了集成 GPU,可用于实时应用。
-
专用硬件:例如,Google 的 TPU(用于服务器)、Apple 的 Neural Engine(用于移动设备)或 NVIDIA Jetson(用于便携式硬件)。这些都是专门为运行深度学习操作设计的芯片。
如果速度对你的应用至关重要,那么使用最快的硬件并调整你的代码是很重要的。
在 CPU 上优化
现代英特尔 CPU 通过特殊指令可以更快速地计算矩阵运算。这是通过深度神经网络数学核心库(MKL-DNN)实现的。TensorFlow 默认并未利用这些指令。使用这些指令需要重新编译 TensorFlow 并启用正确的选项,或安装一个名为 tensorflow-mkl 的特殊版本。
如何使用 MKL-DNN 构建 TensorFlow 的相关信息可以在www.tensorflow.org/上找到。请注意,该工具包目前仅适用于 Linux。
优化 GPU 上的操作
要在 NVIDIA GPU 上运行模型,两个库是必需的——CUDA 和 cuDNN。TensorFlow 天生就能利用这些库提供的加速。
为了正确在 GPU 上运行操作,必须安装 tensorflow-gpu 包。此外,tensorflow-gpu 的 CUDA 版本必须与计算机上安装的版本匹配。
一些现代 GPU 提供16 位浮点(FP16)指令。其理念是使用较低精度的浮点数(16 位而不是常用的 32 位),以加速推理,而不会对输出质量产生太大影响。并非所有 GPU 都支持 FP16。
在专用硬件上优化
由于每个芯片都不同,确保更快推理的技术在不同的制造商之间有所不同。运行模型所需的步骤由制造商进行了详细文档化。
一个经验法则是避免使用特殊操作。如果某一层运行的操作包含条件判断或分支,那么该芯片很可能不支持它。操作将不得不在 CPU 上运行,从而使整个过程变慢。因此,建议仅使用标准操作——卷积、池化和全连接层。
优化输入
计算机视觉模型的推理速度与输入图像的大小成正比。此外,将图像的维度除以二意味着模型需要处理的像素减少四倍。因此,使用较小的图像可以提高推理速度。
使用较小的图像时,模型可以获取的信息较少,细节也更少。这通常会影响结果的质量。需要通过实验图像大小来找到一个良好的速度与准确性之间的平衡。
优化后处理
正如我们在本书前面看到的,大多数模型需要后处理操作。如果使用不合适的工具实现,后处理可能会耗费大量时间。虽然大多数后处理发生在 CPU 上,但有时也可以将部分操作放在 GPU 上执行。
使用追踪工具,我们可以分析后处理所需的时间,从而对其进行优化。非极大值抑制(NMS)是一项操作,如果实现不当,可能会消耗大量时间(请参见第五章,目标检测模型):

图 9-3:NMS 计算时间与框数的关系
注意前面的图表,慢速实现采用线性计算时间,而快速实现则几乎是常数。虽然四毫秒看起来非常短,但请记住,一些模型可能返回更多的框,从而导致后处理时间。
当模型仍然太慢时
一旦模型在速度上得到了优化,它有时仍然太慢,无法满足实时应用的要求。有几种技术可以绕过这种慢速问题,同时保持用户的实时体验。
插值与跟踪
目标检测模型通常计算量非常大。在每一帧视频上运行有时是不切实际的。常用的技术是只在每几帧运行一次模型。在中间帧之间,使用线性插值来跟踪目标物体。
虽然这种技术不适用于实时应用,但另一种常用的技术是目标跟踪。一旦通过深度学习模型检测到目标,就使用一个更简单的模型来跟踪物体的边界。
目标跟踪几乎适用于任何类型的物体,只要它能与背景清晰区分,并且其形状不会发生过度变化。有许多目标跟踪算法(其中一些可以通过 OpenCV 的 tracker 模块获得,文档可在此查看 docs.opencv.org/master/d9/df8/group__tracking.html);其中许多算法也适用于移动应用。
模型蒸馏
当其他技术都不起作用时,最后的选择是模型蒸馏。其基本思路是训练一个小模型,让它学习更大模型的输出。我们不是让小模型学习原始标签(我们可以用数据来做到这一点),而是让它学习更大模型的输出。
让我们看一个例子——我们训练了一个非常大的网络,通过图片预测动物的品种。输出如下:

图 9-4:我们网络所做的预测示例
由于我们的模型太大,无法在移动设备上运行,因此我们决定训练一个较小的模型。我们没有直接用已有的标签来训练它,而是决定将大网络的知识进行蒸馏。为此,我们将使用大网络的输出作为目标。
对于第一张图片,我们不再用* [1, 0, 0] 作为新模型的目标,而是使用大网络的输出,目标为 [0.9, 0.7, 0.1] 。这个新目标被称为软目标。通过这种方式,小网络将被教导,尽管第一张图片中的动物不是哈士奇,但根据更先进的模型,它看起来与哈士奇相似,因为这张图片在哈士奇类别中的得分为0.7*。
更大的网络能够直接从原始标签中学习(例如我们示例中的 [1, 0, 0]),因为它拥有更多的计算和内存能力。在训练过程中,它能够推测出不同品种的狗虽然长得相似,但属于不同的类别。一个较小的模型自己可能无法学习到这样的抽象关系,但可以通过其他网络进行引导。按照上述过程,第一个模型推断出的知识将传递给新的模型,这就是知识蒸馏的名称由来。
减少模型大小
在浏览器或移动设备上使用深度学习模型时,模型需要下载到设备上。为了以下原因,模型必须尽可能轻量:
-
用户通常使用手机在移动网络下连接,且这个网络有时是计量的。
-
连接也可能较慢。
-
模型可能需要频繁更新。
-
便携设备的磁盘空间有时是有限的。
深度学习模型拥有数亿个参数,这使得它们非常占用磁盘空间。幸运的是,有一些技术可以减少它们的大小。
量化
最常见的技术是减少参数的精度。我们可以将参数存储为 16 位或 8 位浮点数,而不是 32 位浮点数。已经有实验使用二进制参数,仅用 1 位存储。
量化通常在训练结束时进行,当模型转换为在设备上使用时进行。这一转换会影响模型的准确性。因此,在量化后评估模型非常重要。
在所有的压缩技术中,量化通常对模型大小的影响最大,而对性能的影响最小。它也非常容易实现。
通道剪枝和权重稀疏化
还有一些技术存在,但实现起来可能更困难。因为这些技术主要依赖于反复试验,所以没有直接的应用方法。
第一种方法,通道剪枝,包括去除一些卷积滤波器或通道。卷积层通常有 16 到 512 个不同的滤波器。在训练阶段结束时,通常会发现其中一些滤波器是无用的。我们可以去除这些滤波器,以避免存储那些对模型性能无帮助的权重。
第二种方法叫做权重稀疏化。我们可以只存储那些被认为重要或远离零值的权重,而不是存储整个矩阵的权重。
例如,我们可以存储一个权重向量,如[0.1, 0.9, 0.05, 0.01, 0.7, 0.001],而不是存储所有权重。我们可以保留那些远离零值的权重。最终结果是一个元组列表,形式为(位置, 值)。在我们的例子中,它会是[(1, 0.9), (4, 0.7)]。如果向量中的许多值接近零,我们可以预期存储的权重大幅减少。
设备端机器学习
由于深度学习算法对计算资源的高要求,它们通常在强大的服务器上运行。这些计算机是专门为此任务设计的。出于延迟、隐私或成本的原因,有时在客户的设备上运行推理更具吸引力:智能手机、连接设备、汽车或微型计算机。
所有这些设备的共同特点是较低的计算能力和低功耗要求。由于它们处于数据生命周期的末端,设备端机器学习也被称为边缘计算或边缘机器学习。
使用常规机器学习时,计算通常发生在数据中心。例如,当你将照片上传到 Facebook 时,一个深度学习模型会在 Facebook 的数据中心运行,以检测你朋友的面孔并帮助你标记他们。
使用设备端机器学习时,推理发生在你的设备上。一个常见的例子是 Snapchat 面部滤镜——检测你面部位置的模型直接在设备上运行。然而,模型训练仍然发生在数据中心——设备使用的是从服务器获取的训练模型:

图 9-5: 比较设备端机器学习与传统机器学习的示意图
大多数设备端机器学习都是用于推理。模型的训练仍然主要在专用服务器上进行。
设备端机器学习的考虑因素
使用设备端机器学习(on-device ML)通常是由多种原因推动的,但也有其局限性。
设备端机器学习的好处
以下段落列出了直接在用户设备上运行机器学习算法的主要好处。
延迟
最常见的动机是延迟。因为将数据发送到服务器进行处理需要时间,实时应用使得使用传统机器学习变得不可能。最引人注目的例子是自动驾驶汽车。为了快速反应环境,汽车必须拥有尽可能低的延迟。因此,在汽车内运行模型至关重要。此外,一些设备被使用在没有互联网连接的地方。
隐私
随着消费者对隐私的关注越来越高,企业正在设计技术,以在尊重这一需求的同时运行深度学习模型。
让我们来看一个来自 Apple 的大规模示例。当你在 iOS 设备上浏览照片时,你可能会注意到可以搜索对象或事物——cat、bottle、car 会返回相应的图片。即使这些图片没有发送到云端,也能实现这一点。对于 Apple 来说,在尊重用户隐私的同时提供这个功能非常重要。如果没有用户的同意,发送照片进行处理是不可能的。
因此,苹果决定使用设备端机器学习。每晚,当手机在充电时,iPhone 上会运行计算机视觉模型,检测图像中的物体并启用此功能。
成本
除了尊重用户隐私外,这项功能还帮助苹果公司降低了成本,因为公司无需支付服务器费用来处理其客户生成的数亿张图片。
在更小的规模上,现在已经可以在浏览器中运行一些深度学习模型。这对于演示特别有用——通过在用户的计算机上运行模型,您可以避免为大规模推理支付高昂的 GPU 服务器费用。此外,不会出现过载问题,因为页面访问的用户越多,所能使用的计算能力也越多。
设备端机器学习的局限性
尽管它有许多好处,但这一概念也存在一些局限性。首先,设备的计算能力有限,意味着一些最强大的模型无法被考虑。
此外,许多设备端深度学习框架与最创新或最复杂的层不兼容。例如,TensorFlow Lite 不兼容自定义 LSTM 层,这使得使用该框架在移动设备上移植高级循环神经网络变得困难。
最后,使模型在设备上可用意味着要与用户共享权重和架构。尽管存在加密和混淆方法,但这增加了反向工程或模型盗窃的风险。
实际的设备端计算机视觉
在讨论设备端计算机视觉的实际应用之前,我们先来看一下在移动设备上运行深度学习模型的一般考虑因素。
设备端计算机视觉的特点
在移动设备上运行计算机视觉模型时,重点从原始性能指标转向用户体验。在手机上,这意味着要尽量减少电池和磁盘的使用:我们不希望在几分钟内耗尽手机电池或填满设备的所有可用空间。在移动设备上运行时,建议使用较小的模型。由于它们包含更少的参数,因此占用的磁盘空间更少。此外,由于所需的操作更少,这也减少了电池的使用。
手机的另一个特点是方向。在训练数据集中,大多数图片都是正确方向的。尽管我们有时在数据增强过程中会改变方向,但图像很少会完全倒置或侧向。然而,手机的持握方式多种多样。因此,我们必须监控设备的方向,以确保我们向模型输入的图像方向正确。
生成 SavedModel
正如我们之前提到的,设备端机器学习通常用于推理。因此,前提是需要有一个训练好的模型。希望本书能让你对如何实现和准备网络有一个好的了解。我们现在需要将模型转换为中间文件格式。然后,它将通过一个库转换为移动端使用。
在 TensorFlow 2 中,首选的中间格式是SavedModel。一个 SavedModel 包含了模型架构(图)和权重。
大多数 TensorFlow 对象都可以导出为 SavedModel。例如,以下代码导出一个训练过的 Keras 模型:
tf.saved_model.save(model, export_dir='./saved_model')
生成冻结图
在引入SavedModel API 之前,TensorFlow 主要使用冻结图格式。实际上,SavedModel 是一个冻结图的封装。前者包含更多的元数据,并且可以包含服务模型所需的预处理函数。虽然 SavedModel 越来越受欢迎,但一些库仍然要求使用冻结模型。
要将 SavedModel 转换为冻结图,可以使用以下代码:
from tensorflow.python.tools import freeze_graph
output_node_names = ['dense/Softmax']
input_saved_model_dir = './saved_model_dir'
input_binary = False
input_saver_def_path = False
restore_op_name = None
filename_tensor_name = None
clear_devices = True
input_meta_graph = False
checkpoint_path = None
input_graph_filename = None
saved_model_tags = tag_constants.SERVING
freeze_graph.freeze_graph(input_graph_filename, input_saver_def_path,
input_binary, checkpoint_path, output_node_names,
restore_op_name, filename_tensor_name,
'frozen_model.pb', clear_devices, "", "", "",
input_meta_graph, input_saved_model_dir,
saved_model_tags)
除了指定输入和输出之外,我们还需要指定output_node_names。事实上,模型的推理输出并不总是很清楚。例如,图像检测模型有多个输出——框坐标、分数和类别。我们需要指定使用哪个(些)输出。
请注意,许多参数的值是False或None,因为这个函数可以接受许多不同的格式,而 SavedModel 只是其中之一。
预处理的重要性
正如在第三章中所解释的,现代神经网络,输入图像必须进行预处理。最常见的预处理方法是将每个通道的值除以127.5(127.5 = 255/2 = 图像像素的中间值),并减去 1。这样,我们将图像的值表示为-1 到 1 之间的数值:

图 9-6: 单通道3 x 3图像的预处理示例
然而,表示图像的方法有很多种,具体取决于以下几个方面:
-
通道的顺序:RGB 或 BGR
-
图像的范围是0到1,* -1到1,还是0到255*?
-
维度的顺序:[W, H, C] 或 [C, W, H]
-
图像的方向
在移植模型时,至关重要的是在设备上使用与训练时完全相同的预处理。如果没有做到这一点,模型的推理效果会很差,有时甚至会完全失败,因为输入数据与训练数据之间的差异太大。
所有的移动深度学习框架都提供了一些选项来指定预处理设置。由你来设置正确的参数。
现在我们已经获得了SavedModel,并且了解了预处理的重要性,我们可以在不同的设备上使用我们的模型了。
示例应用 – 识别面部表情
为了直接应用本章介绍的概念,我们将开发一个使用轻量级计算机视觉模型的应用程序,并将其部署到各种平台。
我们将构建一个分类面部表情的应用程序。当指向一个人的脸时,它会输出该人的表情——高兴、悲伤、惊讶、厌恶、生气或中立。我们将在面部表情识别(FER)数据集上训练我们的模型,该数据集可在www.kaggle.com/c/challenges-in-representation-learning-facial-expression-recognition-challenge上获得,由 Pierre-Luc Carrier 和 Aaron Courville 整理。它由 28,709 张灰度图像组成,大小为48 × 48:

图 9-7:从 FER 数据集中采样的图像
在应用程序中,最简单的方法是使用相机捕捉图像,然后将其直接输入到训练好的模型中。然而,这样会导致结果不佳,因为环境中的物体会影响预测的质量。我们需要在将图像输入给用户之前,先裁剪出用户的面部。

图 9-8:我们的面部表情分类应用的两步流程
尽管我们可以为第一步(人脸检测)构建自己的模型,但使用现成的 API 要方便得多。它们在 iOS 上原生支持,在 Android 和浏览器中通过库提供支持。第二步,表情分类,将使用我们的自定义模型进行。
介绍 MobileNet
我们将用于分类的架构被命名为MobileNet。它是一个为移动设备设计的卷积模型。该模型在 2017 年由 Andrew G Howard 等人提出,论文名为《MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications》,它使用一种特殊类型的卷积来减少生成预测所需的参数数量和计算量。
MobileNet 使用深度可分离卷积。实际上,这意味着该架构由两种类型的卷积交替组成:
-
逐点 卷积:这些与常规卷积类似,但使用的是1 × 1 卷积核。逐点卷积的目的是结合输入的不同通道。应用于 RGB 图像时,它们将计算所有通道的加权和。
-
深度 卷积:这些卷积与常规卷积类似,但不合并通道。深度卷积的作用是过滤输入的内容(检测线条或模式)。应用于 RGB 图像时,它们将为每个通道计算一个特征图。
这两种类型的卷积结合使用时,表现与常规卷积相似。然而,由于它们的卷积核较小,所需的参数和计算量较少,因此这种架构非常适合移动设备。
在设备上部署模型
为了说明设备端的机器学习,我们将把一个模型移植到 iOS 和 Android 设备,以及网页浏览器上。我们还将描述其他可用的设备类型。
在 iOS 设备上使用 Core ML 运行
随着最新设备的发布,苹果公司将重点放在了机器学习上。他们设计了一款定制的芯片——神经引擎。该芯片可以实现快速的深度学习操作,同时保持低功耗。为了充分利用这款芯片,开发者必须使用一组官方 API,称为Core ML(请参阅developer.apple.com/documentation/coreml中的文档)。
要在 Core ML 中使用现有模型,开发者需要将其转换为.mlmodel格式。幸运的是,苹果提供了 Python 工具,用于将 Keras 或 TensorFlow 模型转换为该格式。
除了速度和能效,Core ML 的一个优势是它与其他 iOS API 的集成。存在强大的本地方法,用于增强现实、面部检测、物体追踪等多个功能。
尽管 TensorFlow Lite 支持 iOS,但目前我们仍然推荐使用 Core ML。它能够提供更快的推理时间和更广泛的功能兼容性。
从 TensorFlow 或 Keras 转换
要将我们的模型从 Keras 或 TensorFlow 转换,另一个工具是必需的——tf-coreml (github.com/tf-coreml/tf-coreml)。
在撰写本文时,tf-coreml与 TensorFlow 2 不兼容。我们提供了一个修改版本,直到该库的开发者更新它。请参阅本章的笔记本,以获取最新的安装说明。
我们可以将模型转换为.mlmodel格式:
import tfcoreml as tf_converter
tf_converter.convert('frozen_model.pb',
'mobilenet.mlmodel',
class_labels=EMOTIONS,
image_input_names=['input_0:0'],
output_feature_names=[output_node_name + ':0'],
red_bias=-1,
green_bias=-1,
blue_bias=-1,
image_scale=1/127.5,
is_bgr=False)
一些参数是重要的:
-
class_labels:标签列表。如果没有这个,我们最终得到的将是类 ID,而不是可读的文本。 -
input_names:输入层的名称。 -
image_input_names:这是用于指定 Core ML 框架我们的输入是图像。这个设置稍后会很有用,因为库将为我们处理所有的预处理工作。 -
output_feature_names:与冻结模型转换一样,我们需要指定模型中要目标的输出。在这种情况下,它们不是操作,而是输出。因此,必须在名称后附加:0。 -
image_scale:用于预处理的缩放比例。 -
bias:每种颜色的预处理偏差。 -
is_bgr:如果通道是 BGR 顺序,则必须为True,如果是 RGB 顺序,则为False。
如前所述,scale、bias和is_bgr必须与训练时使用的设置相匹配。
将模型转换为.mlmodel文件后,可以在 Xcode 中打开它:

图 9-9:Xcode 截屏,显示模型的详细信息
注意,由于我们指定了image_input_names,输入被识别为Image。因此,Core ML 将能够为我们处理图像的预处理工作。
加载模型
完整的应用程序可以在章节仓库中找到。构建和运行它需要一台 Mac 计算机和一台 iOS 设备。让我们简要介绍一下如何从模型中获取预测的步骤。请注意,以下代码是用 Swift 编写的,它与 Python 语法类似:
private lazy var model: VNCoreMLModel = try! VNCoreMLModel(for: mobilenet().model)
private lazy var classificationRequest: VNCoreMLRequest = {
let request = VNCoreMLRequest(model: model, completionHandler: { [weak self] request, error in
self?.processClassifications(for: request, error: error)
})
request.imageCropAndScaleOption = .centerCrop
return request
}()
代码由三个主要步骤组成:
-
加载模型。有关模型的所有信息都包含在
.mlmodel文件中。 -
设置自定义回调。在我们的情况下,图像分类完成后,我们将调用
processClassifications。 -
设置
imageCropAndScaleOption。我们的模型设计为接受正方形图像,但输入图像的比例通常不同。因此,我们配置 Core ML 通过将其设置为centerCrop来裁剪图像的中心部分。
我们还使用本机的 VNDetectFaceRectanglesRequest 和 VNSequenceRequestHandler 函数加载用于面部检测的模型:
private let faceDetectionRequest = VNDetectFaceRectanglesRequest()
private let faceDetectionHandler = VNSequenceRequestHandler()
使用模型
作为输入,我们访问 pixelBuffer,它包含设备摄像头的视频流中的像素。我们运行面部检测模型并获得 faceObservations。这将包含检测结果。如果该变量为空,则表示未检测到面部,我们将不会进一步处理该函数:
try faceDetectionHandler.perform([faceDetectionRequest], on: pixelBuffer, orientation: exifOrientation)
guard let faceObservations = faceDetectionRequest.results as? [VNFaceObservation], faceObservations.isEmpty == false else {
return
}
然后,对于每个 faceObservation 在 faceObservations 中,我们将对包含面部的区域进行分类:
let classificationHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right, options: [:])
let box = faceObservation.boundingBox
let region = CGRect(x: box.minY, y: 1 - box.maxX, width: box.height, height:box.width)
self.classificationRequest.regionOfInterest = region
try classificationHandler.perform([self.classificationRequest])
为此,我们指定请求的 regionOfInterest。这会通知 Core ML 框架,输入是图像的这个特定区域。这非常方便,因为我们不需要裁剪和调整图像的大小——框架会为我们处理这些。最后,我们调用 classificationHandler.perform 本机方法。
请注意,我们需要更改坐标系统。面部坐标是以图像的左上角为原点返回的,而 regionOfInterest 必须以左下角为原点来指定。
一旦生成预测,我们的自定义回调 processClassifications 将会被调用并传入结果。然后,我们将能够将结果显示给用户。这部分内容在本书的 GitHub 仓库中的完整应用程序中有介绍。
在 Android 上使用 TensorFlow Lite 运行
TensorFlow Lite 是一个移动框架,可以在移动设备和嵌入式设备上运行 TensorFlow 模型。它支持 Android、iOS 和 Raspberry Pi。与 iOS 设备上的 Core ML 不同,它不是一个本地库,而是一个必须添加到应用中的外部依赖项。
尽管 Core ML 已为 iOS 设备硬件进行了优化,但 TensorFlow Lite 的性能可能因设备而异。在某些 Android 设备上,它可以使用 GPU 来提高推理速度。
为了在我们的示例应用中使用 TensorFlow Lite,我们首先需要使用 TensorFlow Lite 转换器将模型转换为该库的格式。
从 TensorFlow 或 Keras 转换模型
TensorFlow 集成了一个功能,用于将 SavedModel 模型转换为 TF Lite 格式。为此,我们首先创建一个 TensorFlow Lite 转换器对象:
# From a Keras model
converter = tf.lite.TFLiteConverter.from_keras_model(model)
## Or from a SavedModel
converter = tf.lite.TFLiteConverter('./saved_model')
然后,将模型保存到磁盘:
tflite_model = converter.convert()
open("result.tflite", "wb").write(tflite_model)
你会注意到,TensorFlow Lite 的功能提供的选项比 Apple Core ML 少。确实,TensorFlow Lite 不自动处理图像的预处理和调整大小。这些需要在 Android 应用中由开发者手动处理。
加载模型
在将模型转换为.tflite格式后,我们可以将其添加到 Android 应用的 assets 文件夹中。然后,我们可以使用辅助函数loadModelFile加载模型:
tfliteModel = loadModelFile(activity);
由于我们的模型在应用的 assets 文件夹中,我们需要传递当前活动。如果你不熟悉 Android 应用开发,可以将活动理解为应用中的一个特定屏幕。
然后,我们可以创建Interpreter。在 TensorFlow Lite 中,解释器用于运行模型并返回预测结果。在我们的示例中,我们传递默认的Options构造函数。Options构造函数可以用来改变线程数或模型的精度:
Interpreter.Options tfliteOptions = new Interpreter.Options();
tflite = new Interpreter(tfliteModel, tfliteOptions);
最后,我们将创建ByteBuffer。这是一种包含输入图像数据的数据结构:
imgData =
ByteBuffer.allocateDirect(
DIM_BATCH_SIZE
* getImageSizeX()
* getImageSizeY()
* DIM_PIXEL_SIZE
* getNumBytesPerChannel());
ByteBuffer是一个数组,将包含图像的像素数据。其大小取决于以下因素:
-
批次大小——在我们的例子中为 1。
-
输入图像的维度。
-
通道数(
DIM_PIXEL_SIZE)——RGB 为 3,灰度为 1。 -
最后,每个通道的字节数。因为1 字节 = 8 位,所以一个 32 位的输入需要 4 个字节。如果使用量化,8 位输入需要 1 个字节。
为了处理预测,我们稍后将填充这个imgData缓冲区并传递给解释器。我们的面部表情检测模型已经准备好使用。在开始使用我们的完整流程之前,我们只需要实例化人脸检测器:
faceDetector = new FaceDetector.Builder(this.getContext())
.setMode(FaceDetector.FAST_MODE)
.setTrackingEnabled(false)
.setLandmarkType(FaceDetector.NO_LANDMARKS)
.build();
请注意,这个FaceDetector类来自 Google Vision 框架,与 TensorFlow Lite 无关。
使用该模型
对于我们的示例应用,我们将处理位图图像。你可以将位图视为一个原始像素矩阵。它们与 Android 上的大多数图像库兼容。我们从显示相机视频流的视图textureView获取这个位图:
Bitmap bitmap = textureView.getBitmap(previewSize.getHeight() / 4, previewSize.getWidth() / 4)
我们并没有以全分辨率捕获位图。相反,我们将其尺寸除以4(这个数字是通过反复试验选出来的)。选择过大的尺寸会导致人脸检测非常慢,从而增加我们流程的推理时间。
接下来,我们将从位图创建vision.Frame。这个步骤是必要的,以便将图像传递给faceDetector:
Frame frame = new Frame.Builder().setBitmap(bitmap).build();
faces = faceDetector.detect(frame);
然后,对于faces中的每个face,我们可以在位图中裁剪出用户的面部。在 GitHub 仓库中提供的cropFaceInBitmap辅助函数正是执行此操作——它接受面部坐标并裁剪位图中的相应区域:
Bitmap faceBitmap = cropFaceInBitmap(face, bitmap);
Bitmap resized = Bitmap.createScaledBitmap(faceBitmap,
classifier.getImageSizeX(), classifier.getImageSizeY(), true)
在调整位图大小以适应模型输入后,我们填充imgData,即ByteBuffer,它是Interpreter接受的格式:
imgData.rewind();
resized.getPixels(intValues, 0, resized.getWidth(), 0, 0, resized.getWidth(), resized.getHeight());
int pixel = 0;
for (int i = 0; i < getImageSizeX(); ++i) {
for (int j = 0; j < getImageSizeY(); ++j) {
final int val = intValues[pixel++];
addPixelValue(val);
}
}
如您所见,我们遍历位图的像素并将其添加到imgData中。为此,我们使用addPixelValue。这个函数处理每个像素的预处理。它会根据模型的特性有所不同。在我们的案例中,模型使用的是灰度图像。因此,我们必须将每个像素从彩色转换为灰度:
protected void addPixelValue(int pixelValue) {
float mean = (((pixelValue >> 16) & 0xFF) + ((pixelValue >> 8) & 0xFF) + (pixelValue & 0xFF)) / 3.0f;
imgData.putFloat(mean / 127.5f - 1.0f);
}
在这个函数中,我们使用位运算来计算每个像素的三种颜色的平均值。然后将其除以127.5并减去1,这是我们模型的预处理步骤。
在此过程的最后,imgData包含了正确格式的输入信息。最后,我们可以运行推理:
float[][] labelProbArray = new float[1][getNumLabels()];
tflite.run(imgData, labelProbArray);
预测结果将存放在labelProbArray中。然后我们可以处理并显示它们。
在浏览器中使用 TensorFlow.js 运行
随着每年浏览器功能的不断增加,能够运行深度学习模型只是时间问题。在浏览器中运行模型有许多优势:
-
用户无需安装任何东西。
-
计算在用户的机器上进行(无论是手机还是电脑)。
-
该模型有时可以使用设备的 GPU。
在浏览器中运行的库称为 TensorFlow.js(请参考文档:github.com/tensorflow/tfjs)。我们将使用它来实现我们的面部表情分类应用。
虽然 TensorFlow 不能利用非 NVIDIA 的 GPU,但 TensorFlow.js 可以在几乎任何设备上使用 GPU。浏览器中的 GPU 支持最初是为了通过 WebGL(一个用于 Web 应用程序的计算机图形 API,基于 OpenGL)显示图形动画而实现的。由于它涉及矩阵运算,后来被重新用于运行深度学习操作。
将模型转换为 TensorFlow.js 格式
要使用 TensorFlow.js,必须先通过tfjs-converter将模型转换为正确的格式。它可以转换 Keras 模型、冻结的模型和 SavedModels。安装说明可在 GitHub 仓库中找到。
然后,转换模型的过程与 TensorFlow Lite 的过程非常相似。不同之处在于,它不是在 Python 中完成,而是通过命令行完成:
$ tensorflowjs_converter ./saved_model --input_format=tf_saved_model my-tfjs --output_format tfjs_graph_model
类似于 TensorFlow Lite,我们需要指定输出节点的名称。
输出由多个文件组成:
-
optimized_model.pb:包含模型图 -
weights_manifest.json:包含权重列表的信息 -
group1-shard1of5,group1-shard2of5,...,group1-shard5of5:包含模型的权重,分成多个文件
模型被分割成多个文件,因为并行下载通常更快。
使用该模型
在我们的 JavaScript 应用程序中,导入 TensorFlow.js 后,我们可以加载模型。请注意,以下代码是用 JavaScript 编写的。它的语法类似于 Python:
import * as tf from '@tensorflow/tfjs';
const model = await tf.loadModel(MOBILENET_MODEL_PATH);
我们还将使用一个名为face-api.js的库来提取人脸:
import * as faceapi from 'face-api.js';
await faceapi.loadTinyFaceDetectorModel(DETECTION_MODEL_PATH)
一旦两个模型加载完毕,我们就可以开始处理用户的图像:
const video = document.getElementById('video');
const detection = await faceapi.detectSingleFace(video, new faceapi.TinyFaceDetectorOptions())
if (detection) {
const faceCanvases = await faceapi.extractFaces(video, [detection])
const values = await predict(faceCanvases[0])
}
在这里,我们从video元素中抓取一帧显示用户网络摄像头的图像。face-api.js库将尝试检测该帧中的人脸。如果它检测到人脸,则提取图像中包含人脸的部分,并将其输入到我们的模型中。
predict函数处理图像的预处理和分类。它的具体操作如下:
async function predict(imgElement) {
let img = await tf.browser.fromPixels(imgElement, 3).toFloat();
const logits = tf.tidy(() => {
// tf.fromPixels() returns a Tensor from an image element.
img = tf.image.resizeBilinear(img, [IMAGE_SIZE, IMAGE_SIZE]);
img = img.mean(2);
const offset = tf.scalar(127.5);
// Normalize the image from [0, 255] to [-1, 1].
const normalized = img.sub(offset).div(offset);
const batched = normalized.reshape([1, IMAGE_SIZE, IMAGE_SIZE, 1]);
return mobilenet.predict(batched);
});
return logits
}
我们首先使用resizeBilinear调整图像大小,并通过mean将其从彩色转换为灰度。然后,我们对像素进行预处理,将其标准化到-1到1之间。最后,我们将数据传递给model.predict进行预测。在这个流程的末端,我们得到了可以展示给用户的预测结果。
注意使用tf.tidy。这一点非常重要,因为 TensorFlow.js 会创建一些中间张量,而这些张量可能永远不会从内存中清除。将我们的操作包装在tf.tidy中可以自动清除内存中的中间元素。
近年来,技术进步使得浏览器中的新应用成为可能——图像分类、文本生成、风格迁移和姿势估计现在对任何人都可以使用,无需安装任何软件。
在其他设备上运行
我们已经涵盖了如何将模型转换为浏览器、iOS 和 Android 设备上运行的内容。TensorFlow Lite 也可以在运行 Linux 的袖珍计算机——树莓派上运行。
此外,专门为运行深度学习模型设计的设备也在这些年中陆续出现。以下是一些例子:
-
英伟达 Jetson TX2:大小相当于手掌,常用于机器人应用。
-
谷歌 Edge TPU:谷歌为物联网应用设计的一款芯片,大小类似于指甲,并提供开发者工具包。
-
英特尔神经计算棒:大小类似于 USB 闪存驱动器;可以连接到任何计算机(包括树莓派),以提高其机器学习能力。
这些设备都专注于最大化计算能力,同时最小化功耗。随着每一代设备的不断增强,设备端机器学习领域发展迅速,每年都在开启新的应用。
概要
在本章中,我们讨论了多个性能相关的话题。首先,我们学习了如何正确地测量模型的推理速度,然后我们介绍了减少推理时间的技巧:选择合适的硬件和库、优化输入大小以及优化后处理。我们还介绍了使较慢的模型看起来像是实时处理的技巧,并减少了模型的大小。
接着,我们介绍了设备端机器学习(on-device ML),以及其优缺点。我们学习了如何将 TensorFlow 和 Keras 模型转换为与设备端深度学习框架兼容的格式。通过 iOS、Android 和浏览器的实例,我们涵盖了广泛的设备。我们还介绍了一些现有的嵌入式设备。
在本书中,我们详细介绍了 TensorFlow 2,并将其应用于多个计算机视觉任务。我们涵盖了多种先进的解决方案,提供了理论背景和一些实际实现。最后一章涉及模型的部署,现在由你来利用 TensorFlow 2 的强大功能,开发适用于你选择的用例的计算机视觉应用!
问题
-
在衡量模型推理速度时,是应该使用单张图像还是多张图像来进行测量?
-
权重为float32的模型比权重为float16的模型更小还是更大?
-
在 iOS 设备上,应该偏向使用 Core ML 还是 TensorFlow Lite?那在 Android 设备上呢?
-
在浏览器中运行模型有哪些好处和局限性?
-
嵌入式设备运行深度学习算法的最重要要求是什么?
第十章:从 TensorFlow 1 迁移到 TensorFlow 2
由于 TensorFlow 2 最近才发布,因此大多数在线项目仍然是为 TensorFlow 1 构建的。尽管第一版已经具备了许多有用的功能,如 AutoGraph 和 Keras API,但建议你迁移到最新版本的 TensorFlow,以避免技术债务。幸运的是,TensorFlow 2 提供了一个自动迁移工具,能够将大多数项目转换为其最新版本。该工具几乎不需要额外的努力,并且会输出功能正常的代码。然而,要将代码迁移到符合 TensorFlow 2 规范的版本,需要一些细心和对两个版本的了解。在本节中,我们将介绍迁移工具,并将 TensorFlow 1 的概念与其 TensorFlow 2 对应概念进行比较。
自动迁移
安装 TensorFlow 2 后,可以通过命令行使用迁移工具。要转换项目目录,请运行以下命令:
$ tf_upgrade_v2 --intree ./project_directory --outtree ./project_directory_updated
以下是示例项目中命令日志的样本:
INFO line 1111:10: Renamed 'tf.placeholder' to 'tf.compat.v1.placeholder'
INFO line 1112:10: Renamed 'tf.layers.dense' to 'tf.compat.v1.layers.dense'
TensorFlow 2.0 Upgrade Script
-----------------------------
Converted 21 files
Detected 1 issues that require attention
----------------------------------------------------------------------
----------------------------------------------------------------------
File: project_directory/test_tf_converter.py
----------------------------------------------------------------------
project_directory/test_tf_converter.py:806:10: WARNING: tf.image.resize_bilinear called with align_corners argument requires manual check: align_corners is not supported by tf.image.resize, the new default transformation is close to what v1 provided. If you require exactly the same transformation as before, use compat.v1.image.resize_bilinear.
Make sure to read the detailed log 'report.txt'
转换工具会详细列出它对文件所做的所有更改。在极少数情况下,当它检测到需要手动处理的代码行时,会输出带有更新说明的警告。
大多数过时的调用都已移至 tf.compat.v1。事实上,尽管许多概念已经废弃,TensorFlow 2 仍然通过此模块提供对旧 API 的访问。然而,请注意,调用 tf.contrib 会导致转换工具失败并生成错误:
ERROR: Using member tf.contrib.copy_graph.copy_op_to_graph in deprecated module tf.contrib. tf.contrib.copy_graph.copy_op_to_graph cannot be converted automatically. tf.contrib will not be distributed with TensorFlow 2.0, please consider an alternative in non-contrib TensorFlow, a community-maintained repository, or fork the required code.
迁移 TensorFlow 1 代码
如果工具运行没有任何错误,代码可以按原样使用。然而,迁移工具使用的 tf.compat.v1 模块被认为已废弃。调用此模块时会输出废弃警告,并且该模块的内容将不再由社区更新。因此,建议重构代码,使其更加符合 TensorFlow 2 的规范。在接下来的部分中,我们将介绍 TensorFlow 1 的概念,并解释如何将它们迁移到 TensorFlow 2。在以下示例中,将使用 tf1 来代替 tf,表示使用 TensorFlow 1.13。
会话
由于 TensorFlow 1 默认不使用即时执行(eager execution),因此操作的结果不会直接显示。例如,当对两个常量求和时,输出对象是一个操作:
import tensorflow as tf1 # TensorFlow 1.13
a = tf1.constant([1,2,3])
b = tf1.constant([1,2,3])
c = a + b
print(c) # Prints <tf.Tensor 'add:0' shape=(3,) dtype=int32
为了计算结果,你需要手动创建 tf1.Session。会话负责以下任务:
-
管理内存
-
在 CPU 或 GPU 上运行操作
-
如有必要,跨多台机器运行
使用会话最常见的方法是通过 Python 中的 with 语句。与其他不受管理的资源一样,with 语句确保我们使用完会话后会正确关闭。如果会话没有关闭,可能会继续占用内存。因此,TensorFlow 1 中的会话通常是这样实例化和使用的:
with tf1.Session() as sess:
result = sess.run(c)
print(result) # Prints array([2, 4, 6], dtype=int32)
你也可以显式关闭会话,但不推荐这么做:
sess = tf1.Session()
result = sess.run(c)
sess.close()
在 TensorFlow 2 中,会话管理发生在幕后。由于新版本使用了急切执行,因此不需要这段冗余代码来计算结果。因此,可以删除对 tf1.Session() 的调用。
Placeholders
在之前的示例中,我们计算了两个向量的和。然而,我们在创建图时定义了这些向量的值。如果我们想使用变量代替,我们本可以使用 tf1.placeholder:
a = tf1.placeholder(dtype=tf.int32, shape=(None,))
b = tf1.placeholder(dtype=tf.int32, shape=(None,))
c = a + b
with tf1.Session() as sess:
result = sess.run(c, feed_dict={
a: [1, 2, 3],
b: [1, 1, 1]
})
在 TensorFlow 1 中,placeholders 主要用于提供输入数据。它们的类型和形状必须定义。在我们的示例中,形状是 (None,),因为我们可能希望在任意大小的向量上运行操作。在运行图时,我们必须为 placeholders 提供具体的值。这就是我们在 sess.run 中使用 feed_dict 参数的原因,将变量的内容作为字典传递,placeholders 作为键。如果未为所有 placeholders 提供值,将会引发异常。
在 TensorFlow 2 之前,placeholders 被用来提供输入数据和层的参数。前者可以通过 tf.keras.Input 来替代,而后者可以通过 tf.keras.layers.Layer 参数来处理。
变量管理
在 TensorFlow 1 中,变量是全局创建的。每个变量都有一个唯一的名称,创建变量的最佳实践是使用 tf1.get_variable():
weights = tf1.get_variable(name='W', initializer=[3])
在这里,我们创建了一个名为 W 的全局变量。删除 Python 中的 weights 变量(例如使用 Python 的 del weights 命令)不会影响 TensorFlow 内存。事实上,如果我们尝试再次创建相同的变量,我们将会遇到错误:
Variable W already exists, disallowed. Did you mean to set reuse=True or reuse=tf.AUTO_REUSE in VarScope?
虽然 tf1.get_variable() 允许你重用变量,但它的默认行为是在选择的变量名已经存在时抛出错误,以防止你不小心覆盖变量。为了避免这个错误,我们可以更新调用 tf1.variable_scope(...) 并使用 reuse 参数:
with tf1.variable_scope("conv1", reuse=True):
weights = tf1.get_variable(name='W', initializer=[3])
variable_scope 上下文管理器用于管理变量的创建。除了处理变量重用外,它还通过为变量名称附加前缀来方便地将变量分组。在之前的示例中,变量会被命名为 conv1/W。
在这种情况下,将 reuse 设置为 True 意味着,如果 TensorFlow 遇到名为 conv1/W 的变量,它不会像之前那样抛出错误。相反,它会重用现有的变量及其内容。然而,如果你尝试调用之前的代码,而名为 conv1/W 的变量不存在,你将遇到以下错误:
Variable conv1/W does not exist
实际上,reuse=True 只能在重用现有变量时指定。如果你想在变量不存在时创建一个变量,并在它存在时重用,可以传递 reuse=tf.AUTO_REUSE。
在 TensorFlow 2 中,行为有所不同。虽然变量作用域依然存在以便于命名和调试,但变量不再是全局的。它们在 Python 层级上进行管理。只要你能够访问 Python 引用(在我们的例子中是 weights 变量),就可以修改该变量。要删除变量,你需要删除其引用,例如通过运行以下命令:
del weights
以前,变量可以全局访问和修改,并且可能会被其他代码覆盖。全局变量的弃用使得 TensorFlow 代码更加易读且更不容易出错。
层与模型
TensorFlow 模型最初是通过 tf1.layers 定义的。由于该模块在 TensorFlow 2 中已被弃用,推荐使用 tf.keras.layers 作为替代。要使用 TensorFlow 1 训练模型,需要使用优化器和损失函数定义一个训练操作。例如,如果 y 是全连接层的输出,我们可以使用以下命令定义训练操作:
cross_entropy = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(labels=output, logits=y))
train_step = tf.train.AdamOptimizer(1e-3).minimize(cross_entropy)
每次调用此操作时,一批图像将被送入网络并执行一次反向传播步骤。然后我们运行一个循环来计算多个训练步骤:
num_steps = 10**7
with tf1.Session() as sess:
sess.run(tf1.global_variables_initializer())
for i in range(num_steps):
batch_x, batch_y = next(batch_generator)
sess.run(train_step, feed_dict={x: batch_x, y: batch_y})
在打开会话时,需要调用 tf1.global_variables_initializer() 以确保层被正确初始化。如果没有这样做,将抛出异常。在 TensorFlow 2 中,变量的初始化是自动处理的。
其他概念
我们详细介绍了在新版本中被弃用的 TensorFlow 1 中最常见的概念。许多较小的模块和范式在 TensorFlow 2 中也进行了重新设计。在迁移项目时,我们建议详细查看两个版本的文档。为了确保迁移顺利,并且 TensorFlow 2 版本按预期工作,我们建议你记录推理指标(如延迟、准确率或平均精度)和训练指标(如收敛前的迭代次数),并比较旧版和新版的值。
由于 TensorFlow 是开源的,并且得到了活跃社区的支持,它不断发展——集成新特性、优化其他功能、改善开发者体验等等。尽管这有时需要额外的工作,但尽早升级到最新版本将为你提供最佳的环境,以开发性能更高的识别应用程序。
参考文献
本节列出了本书中提到的科学论文和其他网络资源。
第一章:计算机视觉与神经网络
-
Angeli, A., Filliat, D., Doncieux, S., Meyer, J.-A., 2008. 一种快速且增量的方法,用于基于视觉词袋的回环检测。IEEE 机器人学报 1027–1037。
-
Bradski, G., Kaehler, A., 2000. OpenCV。Dr. Dobb’s 软件工具期刊 3。
-
Cortes, C., Vapnik, V., 1995. 支持向量网络。 机器学习 20,273–297。
-
Drucker, H., Burges, C.J., Kaufman, L., Smola, A.J., Vapnik, V., 1997. 支持向量回归机。见:神经信息处理系统进展,pp. 155–161。
-
Krizhevsky, A., Sutskever, I., Hinton, G.E., 2012. ImageNet 分类与深度卷积神经网络。见:神经信息处理系统进展,pp. 1097–1105。
-
Lawrence, S., Giles, C.L., Tsoi, A.C., Back, A.D., 1997. 面部识别:卷积神经网络方法。IEEE 神经网络交易 8, 98–113。
-
LeCun, Y., Boser, B.E., Denker, J.S., Henderson, D., Howard, R.E., Hubbard, W.E., Jackel, L.D., 1990. 使用反向传播网络进行手写数字识别。见:神经信息处理系统进展,pp. 396–404。
-
LeCun, Y., Cortes, C., Burges, C., 2010. MNIST 手写数字数据库。AT&T Labs [在线]。可在
yann.lecun.com/exdb/mnist查阅 2, 18。 -
Lowe, D.G., 2004. 从尺度不变关键点提取独特图像特征。国际计算机视觉杂志 60, 91–110。
-
Minsky, M., 1961. 迈向人工智能的步骤。IRE 会议记录 49, 8–30。
-
Minsky, M., Papert, S.A., 2017. 感知器:计算几何学入门。MIT 出版社。
-
Moravec, H., 1984. 运动、视觉与智能。
-
Papert, S.A., 1966. 夏季视觉项目。
-
Plaut, D.C., 等人,1986. 反向传播学习实验。
-
Rosenblatt, F., 1958. 感知器:大脑中信息存储与组织的概率模型。心理学评论 65, 386。
-
Turk, M., Pentland, A., 1991. 用于识别的特征脸。认知神经科学杂志 3, 71–86。
-
Wold, S., Esbensen, K., Geladi, P., 1987. 主成分分析。化学计量学与智能实验室系统 2, 37–52。
第二章:TensorFlow 基础与模型训练
-
Abadi, M., Agarwal, A., Barham, P., Brevdo, E., Chen, Z., Citro, C., Corrado, G.S., Davis, A., Dean, 等人。TensorFlow: 大规模机器学习在异构分布式系统上的应用 19。
-
API 文档 [WWW 文档],无日期。TensorFlow。网址:
www.tensorflow.org/api_docs/(访问于 2018 年 12 月 14 日)。 -
Chollet, F., 2018. TensorFlow 是研究界深度学习的首选平台。过去三个月在 arXiv 上有提到深度学习框架,pic.twitter.com/v6ZEi63hzP. @fchollet。
-
Goldsborough, P., 2016. TensorFlow 介绍。arXiv:1610.01178 [cs]。
第三章:现代神经网络
-
Abadi, M., Barham, P., Chen, J., Chen, Z., Davis, A., Dean, J., Devin, M., Ghemawat, S., Irving, G., Isard, M., 等人,2016. Tensorflow: 大规模机器学习的系统。见:OSDI,pp. 265–283。
-
API 文档,网址:
www.tensorflow.org/api_docs/(访问于 2018 年 12 月 14 日)。 -
Bottou, L., 2010. 使用随机梯度下降进行大规模机器学习. 载于:COMPSTAT'2010 会议论文集. Springer,第 177–186 页。
-
Bottou, L., Curtis, F.E., Nocedal, J., 2018. 大规模机器学习的优化方法. SIAM 综述 60, 223–311。
-
Dozat, T., 2016. 将 Nesterov 动量融入 Adam。
-
Duchi, J., Hazan, E., Singer, Y., 2011. 在线学习与随机优化的自适应子梯度方法. 机器学习研究杂志 12, 2121–2159。
-
Gardner, W.A., 1984. 随机梯度下降算法的学习特性:一项综合研究、分析与批评. 信号处理 6, 113–133。
-
Girosi, F., Jones, M., Poggio, T., 1995. 正则化理论与神经网络架构. 神经计算 7, 219–269。
-
Ioffe, S., Szegedy, C., 2015. 批量归一化:通过减少内部协方差偏移加速深度网络训练. arXiv 预印本 arXiv:1502.03167。
-
Karpathy, A., n.d. 斯坦福大学 CS231n:用于视觉识别的卷积神经网络 [WWW 文档]. URL:
cs231n.stanford.edu/(访问日期:2018 年 12 月 14 日)。 -
Kingma, D.P., Ba, J., 2014. Adam:一种随机优化方法. arXiv 预印本 arXiv:1412.6980。
-
Krizhevsky, A., Sutskever, I., Hinton, G.E., 2012. 使用深度卷积神经网络进行图像分类. 载于:神经信息处理系统进展,第 1097–1105 页。
-
Lawrence, S., Giles, C.L., Tsoi, A.C., Back, A.D., 1997. 人脸识别:一种卷积神经网络方法. IEEE 神经网络学报 8, 98–113。
-
Le 和 Borji – 2017 – 卷积神经网络中神经元的感受野、有效感受野和投影场是什么? pdf, n.d。
-
Le, H., Borji, A., 2017. 卷积神经网络中神经元的感受野、有效感受野和投影场是什么? arXiv:1705.07049 [cs]。
-
LeCun, Y., Cortes, C., Burges, C., 2010. MNIST 手写数字数据库. AT&T 实验室 [在线]. 可从
yann.lecun.com/exdb/mnist获取 2。 -
LeCun, Y., 等, 2015. LeNet-5, 卷积神经网络. URL:
yann.lecun.com/exdb/lenet20。 -
Lenail, A., n.d. NN SVG [WWW 文档]. URL:
alexlenail.me/NN-SVG/(访问日期:2018 年 12 月 14 日)。 -
Luo, W., Li, Y., Urtasun, R., Zemel, R., n.d. 理解深度卷积神经网络中的有效感受野 9。
-
Nesterov, Y., 1998. 凸编程导论 第一卷:基础课程. 讲义。
-
Perkins, E.S., Davson, H., n.d. 人眼 | 定义、结构与功能 [WWW 文档]. 大英百科全书. URL:
www.britannica.com/science/human-eye(访问日期:2018 年 12 月 14 日)。 -
Perone, C.S., n.d. 卷积神经网络中的有效感受野 | Terra Incognita. Terra Incognita。
-
Polyak, B.T., 1964. 加速迭代方法收敛的一些方法. 苏联计算数学与数学物理 4, 1–17.
-
Raj, D., 2018. 梯度下降优化算法简短说明. Medium.
-
Simard, P.Y., Steinkraus, D., Platt, J.C., 2003. 卷积神经网络在视觉文档分析中的最佳实践. 见:Null,第 958 页.
-
Srivastava, N., Hinton, G., Krizhevsky, A., Sutskever, I., Salakhutdinov, R., 2014. Dropout:一种防止神经网络过拟合的简单方法. 机器学习研究期刊 15, 1929–1958.
-
Sutskever, I., Martens, J., Dahl, G., Hinton, G., 2013. 在深度学习中初始化与动量的重要性. 见:国际机器学习大会,1139–1147 页.
-
Tieleman, T., Hinton, G., 2012. 讲座 6.5-rmsprop: 通过最近的梯度幅度的滑动平均值来划分梯度. COURSERA: 神经网络与机器学习 4, 26–31.
-
Walia, A.S., 2017. 神经网络中使用的优化算法类型及优化梯度下降的方法 [WWW 文档]. Towards Data Science. URL:
towardsdatascience.com/types-of-optimization-algorithms-used-in-neural-networks-and-ways-to-optimize-gradient-95ae5d39529f(访问时间:2018 年 12 月 14 日)。 -
Zeiler, M.D., 2012. ADADELTA:一种自适应学习率方法. arXiv 预印本 arXiv:1212.5701.
-
Zhang, T., 2004. 使用随机梯度下降算法解决大规模线性预测问题. 见:第二十一届国际机器学习会议论文集,第 116 页.
第四章:影响力的分类工具
-
API 文档 [WWW 文档], n.d. TensorFlow. URL:
www.tensorflow.org/api_docs/(访问时间:2018 年 12 月 14 日)。 -
Goodfellow, I., Bengio, Y., Courville, A., 2016. 深度学习. MIT 出版社.
-
He, K., Zhang, X., Ren, S., Sun, J., 2015. 用于图像识别的深度残差学习. arXiv:1512.03385 [cs].
-
Howard, A.G., Zhu, M., Chen, B., Kalenichenko, D., Wang, W., Weyand, T., Andreetto, M., Adam, H., 2017. MobileNets:面向移动视觉应用的高效卷积神经网络. arXiv:1704.04861 [cs].
-
Huang, G., Liu, Z., van der Maaten, L., Weinberger, K.Q., 2016. 密集连接卷积网络. arXiv:1608.06993 [cs].
-
Karpathy, A., n.d. 斯坦福大学 CS231n:视觉识别的卷积神经网络 [WWW 文档]. URL:
cs231n.stanford.edu/(访问时间:2018 年 12 月 14 日)。 -
Karpathy, A. 我从与 ConvNet 在 ImageNet 上竞争中学到的东西 [WWW 文档], 未注明日期. URL:
karpathy.github.io/2014/09/02/what-i-learned-from-competing-against-a-convnet-on-imagenet/(访问日期:2019 年 1 月 4 日)。 -
Lin, M., Chen, Q., Yan, S., 2013. 网络中的网络. arXiv:1312.4400 [cs].
-
Pan, S.J., Yang, Q., 2010. 迁移学习调查. IEEE 知识与数据工程学报 22, 1345–1359.
-
Russakovsky, O., Deng, J., Su, H., Krause, J., Satheesh, S., Ma, S., Huang, Z., Karpathy, A., Khosla, A., Bernstein, M., Berg, A.C., Fei-Fei, L., 2014. ImageNet 大规模视觉识别挑战. arXiv:1409.0575 [cs].
-
Sarkar, D. (DJ), 2018. 迁移学习的全面实用指南:深度学习中的真实世界应用 [WWW 文档]. Towards Data Science. URL:
towardsdatascience.com/a-comprehensive-hands-on-guide-to-transfer-learning-with-real-world-applications-in-deep-learning-212bf3b2f27a(访问日期:2019 年 1 月 15 日)。 -
shu-yusa, 2018. 使用 TensorFlow Hub 的 Inception-v3 进行迁移学习. Medium.
-
Simonyan, K., Zisserman, A., 2014. 用于大规模图像识别的非常深的卷积网络. arXiv:1409.1556 [cs].
-
Srivastava, R.K., Greff, K., Schmidhuber, J., 2015. 高速公路网络. arXiv:1505.00387 [cs].
-
Szegedy, C., Ioffe, S., Vanhoucke, V., Alemi, A., 2016. Inception-v4, Inception-ResNet 以及残差连接对学习的影响. arXiv:1602.07261 [cs].
-
Szegedy, C., Liu, W., Jia, Y., Sermanet, P., Reed, S., Anguelov, D., Erhan, D., Vanhoucke, V., Rabinovich, A., 2014. 深入卷积神经网络的研究. arXiv:1409.4842 [cs].
-
Szegedy, C., Vanhoucke, V., Ioffe, S., Shlens, J., Wojna, Z., 2015. 重新思考 Inception 架构在计算机视觉中的应用. arXiv:1512.00567 [cs].
-
Thrun, S., Pratt, L., 1998. 学习如何学习.
-
Zeiler, Matthew D., Fergus, R., 2014. 卷积网络的可视化与理解. 见:Fleet, D., Pajdla, T., Schiele, B., Tuytelaars, T. (编辑), 计算机视觉 – ECCV 2014. Springer 国际出版公司, Cham, 页码 818–833.
-
Zeiler, Matthew D., Fergus, R., 2014. 卷积网络的可视化与理解. 见:欧洲计算机视觉会议, 页码 818–833.
第五章:目标检测模型
-
Everingham, M., Eslami, S.M.A., Van Gool, L., Williams, C.K.I., Winn, J., Zisserman, A., 2015. Pascal 视觉目标类别挑战:回顾. 国际计算机视觉杂志 111, 98–136.
-
Girshick, R., 2015. Fast R-CNN. arXiv:1504.08083 [cs].
-
Girshick, R., Donahue, J., Darrell, T., Malik, J., 2013. 准确的目标检测和语义分割的丰富特征层次. arXiv:1311.2524 [cs].
-
Redmon, J., Divvala, S., Girshick, R., Farhadi, A., 2015. You Only Look Once: 统一的实时目标检测。arXiv:1506.02640 [cs]。
-
Redmon, J., Farhadi, A., 2016. YOLO9000: 更好、更快、更强。arXiv:1612.08242 [cs]。
-
Redmon, J., Farhadi, A., 2018. YOLOv3: 渐进性改进。arXiv:1804.02767 [cs]。
-
Ren, S., He, K., Girshick, R., Sun, J., 2015. Faster R-CNN: 基于区域提议网络的实时目标检测。arXiv:1506.01497 [cs]。
第六章:图像增强与分割
-
Bai, M., Urtasun, R., 2016. 用于实例分割的深度分水岭变换。arXiv:1611.08303 [cs]。
-
Beyer, L., 2019. Python 包装器用于 Philipp Krähenbühl 的稠密(全连接)条件随机场,带有高斯边缘潜力:lucasb-eyer/pydensecr**f。
-
在 Keras 中构建自编码器 [WWW Document], n.d. URL:
blog.keras.io/building-autoencoders-in-keras.html(accessed January 18, 2019). -
Cordts, M., Omran, M., Ramos, S., Rehfeld, T., Enzweiler, M., Benenson, R., Franke, U., Roth, S., Schiele, B., 2016. 用于语义城市场景理解的 Cityscapes 数据集。发表于 2016 IEEE 计算机视觉与模式识别大会(CVPR)。在 2016 IEEE 计算机视觉与模式识别大会(CVPR)上展示,IEEE, 拉斯维加斯, NV, USA, 第 3213-3223 页。
-
Dice, L.R., 1945. 物种间生态关联量度。生态学 26, 297–302。
-
Drozdzal, M., Vorontsov, E., Chartrand, G., Kadoury, S., Pal, C., 2016. 跳跃连接在生物医学图像分割中的重要性。arXiv:1608.04117 [cs]。
-
Dumoulin, V., Visin, F., 2016. 深度学习卷积算术指南。arXiv:1603.07285 [cs, stat]。
-
Guan, S., Khan, A., Sikdar, S., Chitnis, P.V., n.d. 用于 2D 稀疏光声断层成像伪影去除的完全稠密 UNet 8。
-
He, K., Gkioxari, G., Dollár, P., Girshick, R., 2017. Mask R-CNN. arXiv:1703.06870 [cs].
-
Kaggle. 2018 数据科学碗 [WWW Document], n.d. URL:
kaggle.com/c/data-science-bowl-2018(accessed February 8, 2019). -
Krähenbühl, P., Koltun, V., n.d. 带有高斯边缘潜力的完全连接条件随机场高效推理 9。
-
Lan, T., Li, Y., Murugi, J.K., Ding, Y., Qin, Z., 2018. RUN:用于计算机辅助肺结节检测的残差 U-Net,无需候选选择。arXiv:1805.11856 [cs]。
-
Li, X., Chen, H., Qi, X., Dou, Q., Fu, C.-W., Heng, P.A., 2017. H-DenseUNet: 用于肝脏和肿瘤分割的混合稠密连接 UNet,从 CT 体积中提取。arXiv:1709.07330 [cs]。
-
Lin, T.-Y., Goyal, P., Girshick, R., He, K., Dollár, P., 2017. Focal Loss for Dense Object Detection. arXiv:1708.02002 [cs].
-
Milletari, F., Navab, N., Ahmadi, S.-A., 2016. V-Net:用于体积医学图像分割的完全卷积神经网络。在:2016 年第四届国际三维视觉会议(3DV)。在 2016 年第四届国际三维视觉会议(3DV)上展示,IEEE,斯坦福,美国加利福尼亚州,页码 565–571。
-
Noh, H., Hong, S., Han, B., 2015. 用于语义分割的去卷积网络学习。在:2015 IEEE 国际计算机视觉会议(ICCV)。2015 年 ICCV 会议上展示,IEEE,智利圣地亚哥,页码 1520–1528。
-
Odena, A., Dumoulin, V., Olah, C., 2016. 去卷积与棋盘伪影。Distill 1, e3。
-
Ronneberger, O., Fischer, P., Brox, T., 2015. U-Net:用于生物医学图像分割的卷积网络。arXiv:1505.04597 [cs]。
-
Shelhamer, E., Long, J., Darrell, T., 2017. 完全卷积网络用于语义分割。IEEE 模式分析与机器智能学报 39, 640–651。
-
Sørensen, T., 1948. 基于物种相似性在植物社会学中建立相等幅度群体的方法及其在丹麦公共草地植被分析中的应用。Biol. Skr. 5, 1–34。
-
无监督特征学习与深度学习教程 [WWW 文档],无日期。URL:
ufldl.stanford.edu/tutorial/unsupervised/Autoencoders/(访问日期:2019 年 1 月 17 日)。 -
Zeiler, M.D., Fergus, R., 2013. 可视化与理解卷积网络。arXiv:1311.2901 [cs]。
-
Zhang, Z., Liu, Q., Wang, Y., 2018. 基于深度残差 U-Net 的道路提取。IEEE 地球科学与遥感快报 15, 749–753。
第七章:在复杂和稀缺数据集上的训练
-
Bousmalis, K., Silberman, N., Dohan, D., Erhan, D., Krishnan, D., 2017a. 基于生成对抗网络的无监督像素级领域适应。在:2017 年 IEEE 计算机视觉与模式识别会议(CVPR)上展示。2017 年 IEEE 计算机视觉与模式识别会议(CVPR),IEEE,夏威夷檀香山,页码 95–104。
-
Bousmalis, K., Silberman, N., Dohan, D., Erhan, D., Krishnan, D., 2017b. 基于生成对抗网络的无监督像素级领域适应。 在:IEEE 计算机视觉与模式识别会议论文集,页码 3722–3731。
-
Brodeur, S., Perez, E., Anand, A., Golemo, F., Celotti, L., Strub, F., Rouat, J., Larochelle, H., Courville, A., 2017. HoME:家庭多模态环境。arXiv:1711.11017 [cs, eess]。
-
Chang, A.X., Funkhouser, T., Guibas, L., Hanrahan, P., Huang, Q., Li, Z., Savarese, S., Savva, M., Song, S., Su, H., Xiao, J., Yi, L., Yu, F., 2015. ShapeNet: 一个信息丰富的 3D 模型库(编号 arXiv:1512.03012 [cs.GR])。斯坦福大学 – 普林斯顿大学 – 芝加哥丰田技术研究院。
-
Chen, Y., Li, W., Sakaridis, C., Dai, D., Van Gool, L., 2018. 面向野外目标检测的领域自适应 Faster R-CNN. 见:2018 年 IEEE/CVF 计算机视觉与模式识别大会。在 2018 年 IEEE/CVF 计算机视觉与模式识别大会(CVPR)上发表,IEEE,美国犹他州盐湖城,第 3339–3348 页。
-
Cordts, M., Omran, M., Ramos, S., Rehfeld, T., Enzweiler, M., Benenson, R., Franke, U., Roth, S., Schiele, B., 2016. Cityscapes 数据集:语义城市场景理解. 见:IEEE 计算机视觉与模式识别会议论文集,第 3213–3223 页。
-
Ganin, Y., Ustinova, E., Ajakan, H., Germain, P., Larochelle, H., Laviolette, F., Marchand, M., Lempitsky, V., 2017. 神经网络的领域对抗训练. 见:Csurka, G.(编),《计算机视觉应用中的领域适应》,Springer 国际出版社,Cham,第 189–209 页。
-
Goodfellow, I., Pouget-Abadie, J., Mirza, M., Xu, B., Warde-Farley, D., Ozair, S., Courville, A., Bengio, Y., 2014. 生成对抗网络. 见:《神经信息处理系统进展》,第 2672–2680 页。
-
Gschwandtner, M., Kwitt, R., Uhl, A., Pree, W., 2011. BlenSor:Blender 传感器仿真工具箱. 见:国际视觉计算研讨会,第 199–208 页。
-
Hernandez-Juarez, D., Schneider, L., Espinosa, A., Vázquez, D., López, A.M., Franke, U., Pollefeys, M., Moure, J.C., 2017. 倾斜的 Stixels:表示旧金山最陡峭的街道. arXiv:1707.05397 [cs]。
-
Hoffman, J., Tzeng, E., Park, T., Zhu, J.-Y., Isola, P., Saenko, K., Efros, A.A., Darrell, T., 2017. CyCADA:循环一致性对抗领域适应. arXiv:1711.03213 [cs]。
-
Isola, P., Zhu, J.-Y., Zhou, T., Efros, A.A., 2017. 基于条件对抗网络的图像到图像翻译. 见:IEEE 计算机视觉与模式识别会议论文集,第 1125–1134 页。
-
Kingma, D.P., Welling, M., 2013. 自编码变分贝叶斯. arXiv 预印本 arXiv:1312.6114。
-
Long, M., Cao, Y., Wang, J., Jordan, M.I., 无日期。 使用深度适应网络学习可迁移特征 9。
-
Planche, B., Wu, Z., Ma, K., Sun, S., Kluckner, S., Lehmann, O., Chen, T., Hutter, A., Zakharov, S., Kosch, H., 等人,2017. Depthsynth:来自 CAD 模型的实时逼真合成数据生成,用于 2.5D 识别. 见:2017 年国际三维视觉会议(3DV),第 1–10 页。
-
Planche, B., Zakharov, S., Wu, Z., Hutter, A., Kosch, H., Ilic, S., 2018. 超越外观——将真实图像映射到几何领域以进行无监督 CAD 识别. arXiv 预印本 arXiv:1810.04158。
-
协议缓冲区 [WWW 文档],无日期。Google 开发者. URL:
developers.google.com/protocol-buffers/(访问日期:2019 年 2 月 23 日)。 -
Radford, A., Metz, L., Chintala, S., 2015. 无监督表示学习与深度卷积生成对抗网络. arXiv:1511.06434 [cs]。
-
Richter, S.R., Vineet, V., Roth, S., Koltun, V., 2016. 为数据而玩:来自电脑游戏的真实数据。见:欧洲计算机视觉会议,页 102–118。
-
Ros, G., Sellart, L., Materzynska, J., Vazquez, D., Lopez, A.M., 2016. SYNTHIA 数据集:用于城市场景语义分割的大规模合成图像集合。见:2016 IEEE 计算机视觉与模式识别会议(CVPR)。在 2016 IEEE 计算机视觉与模式识别会议(CVPR)上展示,IEEE,拉斯维加斯,美国,页 3234–3243。
-
Rozantsev, A., Lepetit, V., Fua, P., 2015. 为训练物体检测器渲染合成图像。计算机视觉与图像理解 137, 24–37。
-
Tremblay, J., Prakash, A., Acuna, D., Brophy, M., Jampani, V., Anil, C., To, T., Cameracci, E., Boochoon, S., Birchfield, S., 2018. 使用合成数据训练深度网络:通过领域随机化弥合现实差距。见:2018 IEEE/CVF 计算机视觉与模式识别研讨会(CVPRW)。在 2018 IEEE/CVF CVPRW 上展示,IEEE,盐湖城,美国,页 1082–10828。
-
Tzeng, E., Hoffman, J., Saenko, K., Darrell, T., 2017. 对抗性辨别领域适应。见:2017 IEEE 计算机视觉与模式识别会议(CVPR)。在 2017 IEEE CVPR 上展示,IEEE,檀香山,美国,页 2962–2971。
-
Zhu, J.-Y., Park, T., Isola, P., Efros, A.A., 2017. 使用循环一致的对抗网络进行非配对图像到图像的转换。见:IEEE 国际计算机视觉会议论文集,页 2223–2232。
第八章:视频与递归神经网络
-
Britz, D., 2015. 递归神经网络教程,第三部分 – 时间反向传播与梯度消失。WildML。
-
Brown, C., 2019. 用于学习神经网络及相关资料的仓库:go2carter/nn-learn。
-
Chung, J., Gulcehre, C., Cho, K., Bengio, Y., 2014. 门控递归神经网络在序列建模中的实证评估。arXiv:1412.3555 [cs]。
-
Hochreiter, S., Schmidhuber, J., 1997. 长短期记忆。神经计算 9, 1735–1780。
-
Lipton, Z.C., Berkowitz, J., Elkan, C., 2015. 递归神经网络在序列学习中的关键回顾。arXiv:1506.00019 [cs]。
-
Soomro, K., Zamir, A.R., Shah, M., 2012. UCF101:来自野外视频的 101 个人类动作类别数据集。arXiv:1212.0402 [cs]。
第九章:优化模型并部署到移动设备
-
Goodfellow, I.J., Erhan, D., Carrier, P.L., Courville, A., Mirza, M., Hamner, B., Cukierski, W., Tang, Y., Thaler, D., Lee, D.-H., Zhou, Y., Ramaiah, C., Feng, F., Li, R., Wang, X., Athanasakis, D., Shawe-Taylor, J., Milakov, M., Park, J., Ionescu, R., Popescu, M., Grozea, C., Bergstra, J., Xie, J., Romaszko, L., Xu, B., Chuang, Z., Bengio, Y., 2013. 表示学习中的挑战:三项机器学习竞赛报告。arXiv:1307.0414 [cs, stat]。
-
Hinton, G., Vinyals, O., Dean, J.,2015 年。从神经网络中提取知识。arXiv:1503.02531 [cs, stat]。
-
Hoff, T.,未注明日期。苹果照片背后的技术以及深度学习与隐私的未来——高可扩展性。
-
腾讯,未注明日期。腾讯/PocketFlow:一个用于开发更小、更快的 AI 应用的自动模型压缩(AutoMC)框架。
第十一章:评估
答案
每章末尾的评估问题的答案将在以下部分中共享。
第一章
- 以下哪个任务不属于计算机视觉:基于查询图像进行相似图片的网络搜索、从图像序列中重建 3D 场景,还是动画视频角色?
后者,它属于 计算机图形学 的领域。然而,值得注意的是,越来越多的计算机视觉算法正在帮助艺术家更高效地生成或动画化内容(例如,动作捕捉 方法,记录演员执行某些动作并将这些动作转移到虚拟角色身上)。
- 原始感知机使用了哪种激活函数?
step 函数。
- 假设我们想训练一种方法来检测手写数字是否为 4。我们应该如何调整本章中实现的网络以完成这个任务?
在本章中,我们训练了一个分类网络来识别数字图片,范围从 0 到 9。因此,网络必须在 10 个类别中预测出正确的类别,因此,输出向量包含 10 个值(每个类别一个得分/概率)。
在这个问题中,我们定义了一个不同的分类任务。我们希望网络识别图像中是否包含 4 或 不是 4。这是一个 二分类任务,因此,网络应进行编辑,仅输出两个值。
第二章
- Keras 与 TensorFlow 有什么区别?它的目的是什么?
Keras 被设计为其他深度学习库的封装器,以便简化开发。TensorFlow 现在通过 tf.keras 与 Keras 完全集成。在 TensorFlow 2 中,最佳实践是使用该模块创建模型。
- 为什么 TensorFlow 使用图?如何手动创建图?
TensorFlow 依赖于图来确保模型的性能和可移植性。在 TensorFlow 2 中,手动创建图的最佳方式是使用 tf.function 装饰器。
- 急切执行模式和延迟执行模式有什么区别?
在延迟执行模式下,直到用户特别请求结果时才会执行计算。而在急切执行模式下,每个操作在定义时都会执行。虽然前者由于图优化可能更快,但后者更易于使用和调试。在 TensorFlow 2 中,延迟执行模式已被弃用,转而支持急切执行模式。
- 如何在 TensorBoard 中记录信息,如何显示它?
要在 TensorBoard 中记录信息,您可以使用 tf.keras.callbacks.TensorBoard 回调函数,并将其传递给 .fit 方法以训练模型。要手动记录信息,您可以使用 tf.summary 模块。要显示信息,请启动以下命令:
$ tensorboard --logdir ./model_logs
在这里,model_logs 是存储 TensorBoard 日志的目录。此命令将输出一个 URL。导航到该 URL 以监控训练过程。
- TensorFlow 1 和 2 之间的主要区别是什么?
TensorFlow 2 通过将图管理从用户手中移除,专注于简化操作。它默认使用即时执行(eager execution),使得模型更容易调试。然而,它依然通过 AutoGraph 和 tf.function 保持了高性能。同时,它与 Keras 深度集成,使得模型创建比以往任何时候都更简单。
第三章
- 为什么卷积层的输出宽度和高度会比输入小,除非进行了填充?
卷积层输出的空间维度表示内核在垂直和水平方向上滑动时能够取到的有效位置数量。由于内核跨越 k × k 像素(如果是正方形),它们可以在输入图像上取的位置数量只能等于(如果 k = 1)或小于图像的维度。
这是通过本章中提出的方程来表达的,方程用于基于层的超参数计算输出维度。
- 在图 3-6 中的输入矩阵上,具有(2,2)感受野和步幅为 2 的最大池化层输出会是什么?

- 如何通过 Keras Functional API 以非面向对象的方式实现 LeNet-5?
代码如下:
from tensorflow.keras import Model
from tensorflow.keras.layers import Inputs, Conv2D, MaxPooling2D, Flatten, Dense
# "Layer" representing the network's inputs:
inputs = Input(shape=input_shape)
# First block (conv + max-pool):
conv1 = Conv2D(6, kernel_size=5, padding='same', activation='relu')(inputs)
max_pool1 = MaxPooling2D(pool_size=(2, 2))(conv1)
# 2nd block:
conv2 = Conv2D(16, kernel_size=5, activation='relu')(max_pool1)
max_pool2 = MaxPooling2D(pool_size=(2, 2))(conv2)
# Dense layers:
flatten = Flatten()(max_pool2)
dense1 = Dense(120, activation='relu')(flatten)
dense2 = Dense(84, activation='relu')(dense1)
dense3 = Dense(num_classes, activation='softmax')(dense2)
lenet5_model = Model(inputs=inputs, outputs=dense3)
- L1/L2 正则化如何影响网络?
L1 正则化 强制应用它的层将与不重要特征相关联的参数值拉向零;也就是说,忽略那些不重要的特征(如与数据集噪声相关的特征)。
L2 正则化 强制层将其变量保持在较低的水平,从而使它们更加均匀分布。它防止网络形成一小组具有大值的参数,这些参数会过度影响模型的预测。
第四章
- 哪个 TensorFlow Hub 模块可以用来实例化一个用于 ImageNet 的 Inception 分类器?
位于 tfhub.dev/google/tf2-preview/inception_v3/classification/2 的模型可以直接用于分类类似 ImageNet 的图像,因为该分类模型是在该数据集上预训练的。
- 如何冻结 Keras Applications 中 ResNet-50 模型的前三个残差宏块?
代码如下:
freeze_num = 3
# Looking at `resnet50.summary()`, we could observe that the 1st layer of the 4th macro-block is named "res5[...]":
break_layer_name = 'res{}'.format(freeze_num + 2)
for layer in resnet50_finetune.layers:
if break_layer_name in layer.name:
break
if isinstance(layer, tf.keras.layers.Conv2D):
# If the layer is a convolution, and isn't after
# the 1st layer not to train:
layer.trainable = False
- 何时不建议使用迁移学习?
当 领域 差异过大且目标数据的结构与源数据结构完全不同时,迁移学习可能并不会带来好处。正如本章所述,尽管 CNN 可以应用于图像、文本和音频文件,但将针对一种模态训练的权重迁移到另一种模态是不推荐的。
第五章
- 边界框、锚框和真实框之间有什么区别?
边界框是包围物体的最小矩形。锚框是具有特定大小的边界框。对于图像网格中的每个位置,通常会有几个具有不同纵横比的锚框——正方形、竖直矩形和横向矩形。通过调整锚框的大小和位置,目标检测模型可以生成预测结果。地面实况框是与训练集中某个特定物体对应的边界框。如果一个模型训练得很好,它会生成非常接近地面实况框的预测结果。
- 特征提取器的作用是什么?
特征提取器是一个 CNN,它将图像转换为特征体积。特征体积的维度通常比输入图像小,并且包含可以传递给网络其他部分的有意义特征,从而生成预测结果。
- 你应该选择以下哪种模型:YOLO 还是 Faster R-CNN?
如果速度是优先考虑的因素,你应该选择 YOLO,因为它是最快的架构。如果准确性至关重要,你应该选择 Faster R-CNN,因为它生成最好的预测结果。
- 何时使用锚框?
在锚框出现之前,框的预测维度是通过网络的输出生成的。由于物体的大小不同(一个人通常适合竖直矩形,而一辆车适合横向矩形),因此引入了锚框。使用这种技术,每个锚框可以专门化为一个物体比例,从而生成更精确的预测。
第六章
- 自编码器的特性是什么?
自编码器是输入和目标相同的编码器-解码器。它们的目标是正确编码并解码图像,而不影响图像的质量,尽管它们有瓶颈(即其低维度的潜在空间)。
- 完全卷积网络(FCNs)基于哪种分类架构?
FCNs 使用 VGG-16 作为特征提取器。
- 如何训练语义分割模型,以便它不忽略小类别?
类别加权可以应用于交叉熵损失,从而对较小类别的重像素进行更多的惩罚,这些像素被误分类。也可以使用不受类别比例影响的损失函数,例如Dice。
第七章
- 给定一个
a = [1, 2, 3]的张量和一个b = [4, 5, 6]的张量,如何构建一个tf.data管道,以便将每个值从1到6单独输出?
代码如下:
dataset_a = tf.data.Dataset.from_tensor_slices(a)
dataset_b = tf.data.Dataset.from_tensor_slices(b)
dataset_ab = dataset_a.concatenate(dataset_b)
for element in dataset_ab:
print(element) # will print 1, then 2, ... until 6
- 根据
tf.data.Options的文档,如何确保数据集在每次运行时始终以相同的顺序返回样本?
tf.data.Options 的 .experimental_deterministic 属性应该在传递给数据集之前设置为 True。
- 在没有目标注释用于训练时,我们介绍的哪些领域自适应方法可以使用?
应该考虑使用无监督领域适应方法,例如通过深度适应网络学习可转移特征,该方法由中国清华大学的龙铭生等人提出,或者是领域对抗神经网络(DANN),由 Skoltech 的 Yaroslav Ganin 等人提出。
- 判别器在 GAN 中扮演什么角色?
它与生成器对抗,试图区分假图像和真实图像。判别器可以看作是一个可训练的损失函数,用于引导生成器——生成器试图最小化判别器的正确程度,随着训练的进行,两个网络在各自的任务上会变得越来越好。
第八章
- LSTM 相对于简单 RNN 架构的主要优点是什么?
LSTM 在梯度消失方面的影响较小,且能够更好地存储递归数据中的长期关系。尽管它们需要更多的计算能力,但这通常会导致更好的预测结果。
- 当 CNN 应用在 LSTM 之前时,它的作用是什么?
CNN 作为特征提取器,减少了输入数据的维度。通过应用预训练的 CNN,我们可以从输入图像中提取有意义的特征。LSTM 训练得更快,因为这些特征的维度比输入图像小得多。
- 什么是梯度消失,为什么会发生?为什么这是一个问题?
在 RNN 中反向传播误差时,我们还需要回溯时间步。如果时间步数很多,由于梯度计算的方式,信息会逐渐消失。这是一个问题,因为它使得网络更难学习如何生成好的预测结果。
- 解决梯度消失问题有哪些方法?
一种解决方法是使用截断反向传播,这是一种在本章中描述的技术。另一种选择是使用 LSTM 而不是简单的 RNN,因为 LSTM 在梯度消失方面的影响较小。
第九章
- 在测量模型推理速度时,应该使用单张图片还是多张图片?
应该使用多张图片,以避免测量偏差。
- 具有
float32权重的模型比具有float16权重的模型大还是小?
Float16权重使用的空间大约是float32权重的一半。在兼容设备上,它们也可以更快。
- 在 iOS 设备上,应该使用 Core ML 还是 TensorFlow Lite?安卓设备呢?
在 iOS 设备上,我们建议尽可能使用 Core ML,因为它原生支持并且与硬件紧密集成。在 Android 设备上,应该使用 TensorFlow Lite,因为没有其他替代方案。
- 在浏览器中运行模型有哪些好处和局限性?
它不需要在用户端安装任何东西,也不需要在服务器端占用计算能力,使得应用几乎可以无限扩展。
- 嵌入式设备运行深度学习算法的最重要要求是什么?
除了计算能力外,最重要的需求是功耗,因为大多数嵌入式设备依赖电池供电。


(其中 𝑒 为指数函数)

浙公网安备 33010602011771号