TensorFlow-2-0-神经网络实用指南-全-
TensorFlow 2.0 神经网络实用指南(全)
原文:
annas-archive.org/md5/87c674ffae26bfd552462cfe4dde7475译者:飞龙
序言
科技领袖们正在采用神经网络来增强他们的产品,使其更智能,或者用营销术语来说,就是赋予 AI 能力。本书是一本实用的 TensorFlow 指南,涵盖了其内部结构、2.0 版本的新特性以及如何利用这些特性来创建基于神经网络的应用程序。读完本书,您将对 TensorFlow 架构及其新特性有深入了解,并能轻松使用神经网络的力量解决机器学习问题。
本书从机器学习和神经网络的理论概述开始,然后描述了 TensorFlow 库的 1.x 和 2.0 版本。在阅读本书时,您将通过易于理解的示例深入掌握理解神经网络工作原理所需的理论。接下来,您将学习如何掌握优化技术和算法,利用 TensorFlow 2.0 提供的新模块构建各种神经网络架构。此外,在分析完 TensorFlow 结构后,您将学会如何实现更复杂的神经网络架构,如用于分类的 CNN、语义分割网络、生成对抗网络等,以便在您的研究工作和项目中使用。
本书结束时,您将掌握 TensorFlow 结构,并能够利用这个机器学习框架的强大功能,轻松训练和使用各种复杂度的神经网络。
本书适用人群
本书面向数据科学家、机器学习开发人员、深度学习研究人员以及具有基本统计学背景的开发人员,适合那些希望使用神经网络并探索 TensorFlow 结构及其新特性的人。为了最大程度地从本书中受益,您需要具备一定的 Python 编程语言基础。
本书内容简介
第一章,什么是机器学习?,介绍机器学习的基本概念:监督学习、无监督学习和半监督学习的定义及其重要性。此外,您将开始了解如何创建数据管道,如何衡量算法性能,以及如何验证您的结果。
第二章,神经网络与深度学习,重点介绍神经网络。您将了解机器学习模型的优势,如何让网络进行学习,以及在实际中如何执行模型参数更新。阅读完本章后,您将理解反向传播和网络参数更新背后的直觉。此外,您还将了解为何深度神经网络架构对于解决具有挑战性的任务是必需的。
第三章,TensorFlow 图架构,介绍了 TensorFlow 的结构——这是 1.x 和 2.x 版本之间共享的结构。
第四章,TensorFlow 2.0 架构,展示了 TensorFlow 1.x 与 TensorFlow 2.x 之间的区别。你将开始使用这两个版本开发一些简单的机器学习模型,同时深入了解这两个版本的所有常见功能。
第五章,高效数据输入管道与估算器 API,展示了如何使用tf.data API 定义完整的数据输入管道,并结合tf.estimator API 定义实验。通过本章的学习,你将能够创建复杂且高效的输入管道,充分利用tf.data和tf.io.gfile API 的强大功能。
第六章,使用 TensorFlow Hub 进行图像分类,介绍了如何通过利用 TensorFlow Hub 与 Keras API 的紧密集成,轻松实现迁移学习与微调。
第七章,目标检测入门,展示了如何扩展你的分类器,将其转变为一个目标检测器,回归边界框的坐标,并为你介绍更复杂的目标检测架构。
第八章,语义分割与自定义数据集构建器,介绍了如何实现语义分割网络,如何为此类任务准备数据集,以及如何训练和衡量模型的性能。你将通过 U-Net 解决语义分割问题。
第九章,生成对抗网络,从理论和实践的角度介绍了 GAN。你将了解生成模型的结构,以及如何使用 TensorFlow 2.0 轻松实现对抗训练。
第十章,将模型投入生产,展示了如何将训练好的模型转化为一个完整的应用。本章还介绍了如何将训练好的模型导出为指定的表示形式(SavedModel)并在完整应用中使用。通过本章的学习,你将能够导出训练好的模型,并在 Python、TensorFlow.js 和使用 tfgo 库的 Go 语言中使用它。
为了最大限度地利用本书
你需要具备基本的神经网络知识,但这并非强制要求,因为这些内容将从理论和实践两个角度进行讲解。如果你具备基本的机器学习算法知识会更有帮助。此外,你需要具备良好的 Python 3 使用能力。
你应该已经知道如何使用 pip 安装包,如何设置工作环境以便与 TensorFlow 配合使用,以及如何启用(如果可用)GPU 加速。此外,还需要有一定的编程概念基础,例如命令式语言与描述性语言以及面向对象编程的知识。
环境设置将在第三章中介绍,TensorFlow 图架构将在机器学习和神经网络理论的前两章之后讲解。
下载示例代码文件。
你可以通过你的账户在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-Neural-Networks-with-TensorFlow-2.0。如果代码有更新,将会更新到现有的 GitHub 仓库。
我们还提供了其他代码包,来自我们丰富的书籍和视频目录,可以在github.com/PacktPublishing/找到。快去看看吧!
下载彩色图片。
我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图片。你可以在这里下载:static.packt-cdn.com/downloads/9781789615555_ColorImages.pdf。
使用的约定
本书中使用了许多文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。举个例子:“将下载的 WebStorm-10*.dmg 磁盘映像文件挂载为系统中的另一个磁盘。”
一块代码如下所示:
writer = tf.summary.FileWriter("log/two_graphs/g1", g1)
writer = tf.summary.FileWriter("log/two_graphs/g2", g2)
writer.close()
任何命令行输入或输出均按如下格式书写:
# create the virtualenv in the current folder (tf2)
pipenv --python 3.7
# run a new shell that uses the just created virtualenv
pipenv shell
# install, in the current virtualenv, tensorflow
pip install tensorflow==2.0
#or for GPU support: pip install tensorflow-gpu==2.0
粗体:表示一个新术语、一个重要的单词,或屏幕上出现的单词。例如,菜单或对话框中的单词会像这样出现在文本中。举个例子:“tf.Graph 结构的第二个特点是它的 图集合。”
警告或重要提示会以这种方式出现。
提示和技巧会以这种方式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com与我们联系。
勘误:虽然我们已经尽力确保内容的准确性,但错误仍然可能发生。如果你在本书中发现错误,我们将非常感谢你向我们报告。请访问 www.packt.com/submit-errata,选择你的书籍,点击“勘误提交表单”链接,并填写相关信息。
盗版:如果你在互联网上发现任何形式的我们作品的非法副本,我们将非常感谢你提供该位置地址或网站名称。请通过copyright@packt.com与我们联系,并提供相关链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且有兴趣写作或为书籍做贡献,请访问 authors.packtpub.com。
评论
请留下评论。在阅读并使用本书后,为什么不在你购买本书的网站上留下评论呢?潜在读者可以看到并参考你公正的意见来做出购买决策,我们 Packt 也能了解你对我们产品的看法,而我们的作者也可以看到你对他们书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packt.com。
第一部分:神经网络基础
本节提供了机器学习的基本介绍,以及神经网络和深度学习的重要概念。
本节包括以下章节:
-
第一章,什么是机器学习?
-
第二章,神经网络与深度学习
第一章:什么是机器学习?
机器学习(ML)是人工智能的一个分支,在这个领域,我们定义算法,目的是学习一个模型,该模型描述并提取数据中的有意义信息。
机器学习的激动人心的应用可以在许多领域找到,比如工业环境中的预测性维护、医学应用的图像分析、金融领域的时间序列预测、面部检测和身份识别用于安全目的、自动驾驶、文本理解、语音识别、推荐系统,以及机器学习的许多其他应用,这些应用无数,我们可能每天都在使用它们,甚至都没意识到!
想想你智能手机上的相机应用程序——当你打开应用并将相机对准一个人时,你会看到一个框框围绕着这个人的脸部。这是怎么做到的呢?对于计算机来说,一张图像只是一组三层叠加的矩阵。一个算法如何检测到这些像素的某个特定子集代表了人脸?
相机应用程序使用的算法(也称为模型)很可能已经经过训练,可以检测到这种模式。这项任务被称为人脸检测。这个人脸检测任务可以通过机器学习算法解决,这些算法可以归类为广泛的监督学习类别。
机器学习任务通常分为三大类,我们将在以下部分中分析这三类:
-
监督学习
-
无监督学习
-
半监督学习
每个类别都有其独特性和算法集,但它们都有一个共同的目标:从数据中学习。从数据中学习是每个机器学习算法的目标,特别是学习一个未知的函数,这个函数将数据映射到(预期的)响应上。
数据集可能是整个机器学习管道中最关键的部分;其质量、结构和大小是深度学习算法成功的关键,我们将在接下来的章节中看到这一点。
例如,上述的人脸检测任务可以通过训练一个模型来解决,让它查看成千上万的标注示例,以便算法学习到特定输入对应我们所称之为人脸的东西。
相同的算法在不同的人脸数据集上训练时,可能会表现出不同的性能,而我们拥有的高质量数据越多,算法的表现就会越好。
在本章中,我们将涵盖以下主题:
-
数据集的重要性
-
监督学习
-
无监督学习
-
半监督学习
数据集的重要性
由于数据集的概念在机器学习(ML)中至关重要,我们将详细探讨这一概念,重点介绍如何为构建完整且正确的机器学习管道创建所需的数据集划分。
数据集不过是数据的集合。正式来说,我们可以将数据集描述为一组对, ![],其中 ![] 是第 i 个示例,![] 是其标签,且具有有限的基数 ![]:

一个数据集包含有限数量的元素,我们的机器学习算法会多次遍历这个数据集,试图理解数据结构,直到它解决所要求的任务。正如在第二章《神经网络与深度学习》中所示,一些算法会一次性考虑所有数据,而其他算法则会在每次训练迭代时,逐步查看数据的一个小子集。
一个典型的监督学习任务是对数据集进行分类。我们在数据上训练一个模型,使其学会将从示例 中提取的一组特征与标签 ![] 对应起来。
在你踏入机器学习世界的初期,就有必要熟悉数据集、数据集拆分和训练轮次的概念,这样当我们在后续章节中讨论这些概念时,你就已经非常熟悉了。
现在,你已经在非常高的层次上了解了数据集是什么。但让我们深入探讨数据集拆分的基本概念。一个数据集包含了你所有可用的数据。正如我们之前提到的,机器学习算法需要多次遍历数据集并查看数据,以便学习如何解决任务(例如分类任务)。
如果我们使用相同的数据集来训练和测试我们的算法性能,那么如何确保算法即使在未见过的数据上也能表现良好呢?嗯,我们无法做到这一点。
最常见的做法是将数据集划分为三个部分:
-
训练集:用于训练模型的子集。
-
验证集:用于在训练过程中衡量模型性能,并执行超参数调整/搜索的子集。
-
测试集:在训练或验证阶段绝不触碰的子集。它仅用于进行最终的性能评估。
这三部分是数据集的互不相交的子集,如下图所示:

表示数据集如何划分的维恩图,其中要求训练集、验证集和测试集之间没有重叠。
训练集通常是较大的子集,因为它必须能有效地代表整个数据集。验证集和测试集较小,并且一般大小相同——当然,这只是一般情况;数据集的基数没有严格限制。事实上,唯一重要的是它们足够大,能够让算法进行训练并有代表性。
我们将让我们的模型从训练集学习,在训练过程中使用验证集评估其性能,并在测试集上进行最终的性能评估:这样可以让我们正确地定义和训练能够很好地泛化的监督学习算法,因此即使面对未见数据也能表现良好。
一个epoch是学习算法处理整个训练集的过程。因此,如果我们的训练集有 60,000 个样本,ML 算法用完所有样本进行学习后,一个 epoch 就算完成。
在机器学习领域,最著名的数据集之一就是 MNIST 数据集。MNIST 是一个标注数据对的数据集,其中每个样本是一个 28 x 28 的手写数字二值图像,标签则是图像中所表示的数字。
然而,出于几个原因,我们在本书中并不打算使用 MNIST 数据集:
-
MNIST 太简单了。无论是传统的还是现代的机器学习算法,几乎都能完美地分类数据集中的每一个数字(准确率>97%)。
-
MNIST 被过度使用了。我们不打算用相同的数据集做和别人一样的应用。
-
MNIST 无法代表现代计算机视觉任务。
上述原因源自一个新数据集的描述,名为fashion-MNIST,它是由 Zalando Research 的研究人员于 2017 年发布的。这是我们在本书中将要使用的数据集之一。
Fashion-MNIST 是 MNIST 数据集的直接替代品,这意味着它们的结构完全相同。因此,任何使用 MNIST 的数据集代码,只需更改数据集路径即可改为使用 fashion-MNIST。
它由 60,000 个样本的训练集和 10,000 个样本的测试集组成,和原始的 MNIST 数据集一样;甚至图像格式(28 x 28)也是相同的。主要的不同在于主题:这里没有手写数字的二值图像,而是衣物的灰度图像。由于它们是灰度图像而非二值图像,因此它们的复杂性更高(而二值图像只有背景为 0,前景为 255,而灰度图像是[0,255]的整个范围):

左边是从 fashion-MNIST 数据集抽取的图像,右边是从 MNIST 数据集抽取的图像。值得注意的是,MNIST 数据集较为简单,因为它是一个二值图像数据集,而 fashion-MNIST 数据集则更加复杂,原因在于灰度调色板和数据集元素的固有复杂性。
像 fashion-MNIST 这样的数据集是监督学习算法的完美候选者,因为这些算法需要带有注释的示例进行训练。
在描述不同类型的机器学习算法之前,了解n维空间的概念是非常重要的,因为它是每个机器学习从业者的日常基础。
n 维空间
- 维空间是一种建模数据集的方法,这些数据集的每个示例有
个属性。
数据集中的每个示例,
,完全由其
属性,
描述。

直观地,你可以将其想象为数据库表中的一行,其中属性是列。例如,像 fashion-MNIST 这样的图像数据集是一个元素数据集,每个元素都有 28 x 28 = 784 个属性——没有特定的列名,但该数据集的每一列可以被认为是图像中的一个像素位置。
维度的概念出现是因为我们开始将示例视为位于 n 维空间中的点,这些点通过它们的属性唯一标识。
当维度数小于或等于 3 且属性是数值型时,可视化这种表示方式很容易。为了理解这个概念,让我们来看一下数据挖掘领域最常见的数据集:鸢尾花数据集。
我们在这里要做的是探索性数据分析。探索性数据分析是当你开始处理一个新数据集时的良好实践:在考虑应用机器学习之前,始终先可视化并尝试理解数据。
该数据集包含三个类别,每个类别有 50 个实例,其中每个类别指代一种鸢尾花类型。所有属性都是连续的,除了标签/类别:
-
萼片长度(单位:厘米)
-
萼片宽度(单位:厘米)
-
花瓣长度(单位:厘米)
-
花瓣宽度(单位:厘米)
-
类别——Iris Setosa, Iris Versicolor, Iris Virginica
在这个小数据集中,我们有四个属性(加上类别信息),这意味着我们有四个维度,已经很难一次性将其可视化。我们可以做的就是选择特征对(萼片宽度,萼片长度)和(花瓣宽度,花瓣长度),并在二维平面中绘制它们,以便了解一个特征与另一个特征之间的关系(或没有关系),并可能发现数据中是否存在某些自然的分区。
使用诸如可视化两个特征之间关系的方法,只能帮助我们对数据集做一些初步的考虑;它无法帮助我们处理更复杂的场景,其中属性的数量远远更多,并且不总是数值型的。
在图表中,我们为每个类别分配不同的颜色,(Setosa, Versicolor, Virginica)=(蓝色,绿色,红色):

Iris 数据集的散点图;每个类别都有不同的颜色,表示的两个维度分别是萼片长度(x 轴)和萼片宽度(y 轴)。
正如我们在由属性(萼片宽度、萼片长度)标识的这个二维空间中看到的,蓝色点都靠得很近,而其他两个类别仍然混合在一起。通过观察这张图表,我们唯一能得出的结论是,可能存在萼片长度和宽度之间的正相关关系,但别无其他。让我们看看花瓣之间的关系:

Iris 数据集的散点图;每个类别都有不同的颜色,表示的两个维度分别是花瓣长度(x 轴)和花瓣宽度(y 轴)。
这个图表告诉我们,在这个数据集中有三个分区。为了找到它们,我们可以使用花瓣宽度和长度属性。
分类算法的目标是让它们学会识别哪些特征具有区分性,以便学习一个函数,使它们能够正确地分离不同类别的元素。神经网络已经证明是用来避免进行特征选择和大量数据预处理的正确工具:它们对噪声非常强大,几乎不需要数据清理。
警告:此方法仅适用于大数据集,在这些数据集中,噪声被正确数据所压倒—对于小数据集,最好通过绘图查看特征,并通过仅输入显著特征来帮助 ML 算法。
Iris 数据集是我们用来描述 n 维空间最直接的数据集。如果我们转向 fashion-MNIST 数据集,情况就会变得更加有趣。
单个例子具有 784 个特征:我们如何可视化一个 784 维空间?我们做不到!
我们唯一能做的就是进行降维技术,以减少可视化所需的维度数量,并更好地理解底层数据结构。
最简单的数据降维技术之一,通常在高维数据集上没有意义,是对数据中随机选择的维度进行可视化。我们在 Iris 数据集上做了这样的事情:我们只是从四个可用维度中随机选择了两个维度,并在二维平面上绘制了数据。当然,对于低维空间,这可能有所帮助,但对于诸如 fashion-MNIST 等数据集来说,这是完全浪费时间的。还有更好的降维技术,例如主成分分析(PCA)或t-分布随机近邻嵌入(t-SNE),我们不会在本书中详细介绍,因为我们即将在后续章节中使用的数据可视化工具,即 TensorBoard,已经为我们实现了这些算法。
此外,当我们在高维空间中工作时,存在一些几何属性无法按预期工作:这个现象被称为维度灾难。在接下来的部分中,我们将通过一个简单的几何示例来展示,随着维度的增加,欧几里得距离的计算方式如何发生变化。
维度灾难
让我们以一个超立方体单位
为例,其中心在 ![] 的位置,处于一个
-维空间中。
让我们再考虑一个
-维超球体,球心位于空间的原点
,
。直观地说,超立方体的中心
在球体内部。这对于所有的
值都成立吗?
我们可以通过测量超立方体中心和原点之间的欧几里得距离来验证这一点:

由于球体的半径在任何维度下都是 1,我们可以得出结论,对于 D 大于 4 的值,超立方体的中心位于超球体之外。
维度灾难是指那些只有在我们处理高维空间中的数据时才会出现的各种现象,这些现象在低维空间(如二维或三维空间)中是不存在的。
在实践中,随着维度的增加,一些违反直觉的现象开始发生;这就是维度灾难。
现在,应该更清楚地认识到,在高维空间中工作既不容易也不直观。深度神经网络的最大优势之一——也是其广泛应用的原因之一——就是它们使高维空间中的问题变得可处理,从而层层减少维度。
我们将描述的第一类机器学习算法是监督学习算法。此类算法是在我们希望在一个 n 维空间中找到一个能够区分不同类别元素的函数时,最合适的工具。
监督学习
监督学习算法通过从知识库(KB)中提取知识来工作,也就是说,算法从包含我们需要学习的概念标签实例的数据集中提取信息。
监督学习算法是两阶段的算法。给定一个监督学习问题——比如一个分类问题——算法在第一阶段(称为训练阶段)尝试解决该问题,并在第二阶段(称为测试阶段)测量其性能。
如前一部分所定义的,三个数据集划分(训练集、验证集和测试集)以及两阶段算法应当引起警觉:为什么我们需要一个两阶段算法和三个数据集划分?
因为第一阶段(在一个精心设计的流程中)使用了两个数据集。事实上,我们可以定义以下阶段:
-
训练和验证:算法分析数据集以生成对其所训练数据有效的理论,同时也能适用于它从未见过的项。
因此,该算法试图发现并概括一个概念,将具有相同标签的示例与示例本身联系起来。
直观地说,如果你有一个包含猫和狗标签的数据集,你希望算法能够区分它们,同时能够对具有相同标签的示例的变化具有鲁棒性(例如不同颜色、位置、背景的猫等)。
在每个训练周期结束时,应使用验证集上的度量标准进行性能评估,以选择在验证集上达到最佳性能的模型,并调优算法的超参数以获得最佳结果。
-
测试:将学习到的理论应用于训练和验证阶段从未见过的标记示例。这使得我们能够测试算法在从未用于训练或选择模型超参数的数据上的表现——这是真实场景。
监督学习算法是一个广泛的类别,所有这些算法都需要有标签的数据集。不要被标签的概念所误导:标签不一定是离散的值(如猫、狗、房子、马等);事实上,它也可以是一个连续值。关键是数据集中存在着示例与值之间的关联。更正式地说,示例是预测变量,而值是因变量、结果或目标变量。
根据期望结果的类型,监督学习算法可以分为两大类:

监督学习家族——目标变量定义了解决的问题
-
分类:当标签是离散的,目标是对示例进行分类并预测标签。分类算法的目标是学习分类边界。这些边界是将示例所在空间划分为不同区域的函数。
-
回归:当目标变量是连续的,目标是学会根据示例回归一个连续值。
我们将在接下来的章节中看到的回归问题是对人脸周围边界框角坐标的回归。人脸可以出现在输入图像的任何位置,算法已经学会回归边界框的八个坐标。
参数和非参数算法用于解决分类和回归问题;最常见的非参数算法是 k-NN 算法。它用于介绍距离和相似性的基本概念:这些概念是每个机器学习应用的基础。我们将在下一节中讲解 k-NN 算法。
距离和相似性 — k-NN 算法
k-NN 算法的目标是找到与给定元素相似的元素,通过相似度评分对它们进行排名,并返回找到的前 k 个相似元素(按相似度排序的前 k 个元素)。
为了做到这一点,你需要衡量一个函数所需的相似性,该函数为两个点分配一个数值评分:评分越高,元素之间的相似性应该越强。
由于我们将数据集建模为 n 维空间中的一组点,因此可以使用任何
范数,或者任何其他评分函数,即使它不是度量函数,来衡量两点之间的距离,并认为接近的元素是相似的,而远离的元素是不相似的。范数/距离函数的选择完全是任意的,它应该依赖于 n 维空间的拓扑结构(这就是为什么我们通常会减少输入数据的维度,并且尽量在低维空间中衡量距离——这样高维灾难给我们带来的困扰就少了)。
因此,如果我们想要衡量数据集中维度为 D 的元素之间的相似性,给定一个点 p,我们必须衡量并收集 p 到每个其他点 q 的距离:

前面的例子展示了计算连接 p 和 q 的距离向量的通用 p 范数的常见场景。在实践中,设置 p=1 会给我们带来曼哈顿距离,而设置 p=2 会给我们带来欧几里得距离。无论选择什么距离,算法的工作方式都是通过计算距离函数并按接近度排序来作为相似度的度量。
当 k-NN 应用于分类问题时,点 p 会根据其 k 个邻居的投票来进行分类,投票的依据是它们的类别。因此,一个被分类为特定类别的对象依赖于其周围元素的类别。
当 k-NN 应用于回归问题时,算法的输出是 k-NN 的值的平均值。
k-NN 只是多年来发展出来的各种非参数模型中的一个;然而,参数模型通常表现出更好的性能。我们将在下一节中讨论这些模型。
参数模型
本书中将要描述的机器学习模型都是参数化模型:这意味着可以使用一个函数来描述模型,其中输入和输出是已知的(在监督学习的情况下,这是明确的),目标是改变模型参数,以便给定一个特定的输入,模型能产生预期的输出。
给定一个输入样本,
,以及期望的结果,
,一个机器学习模型是一个参数化函数,
,其中
是训练过程中需要变化的模型参数,以便拟合数据(换句话说,生成一个假设)。
为了阐明模型参数的概念,我们可以给出最直观和最简单的例子——线性回归。
线性回归试图通过将线性方程拟合到观测数据中来建模两个变量之间的关系。
线性回归模型具有以下方程:

在这里,
是自变量,而
是因变量。参数
是比例因子、系数或斜率,而
是偏置系数或截距。
因此,在训练阶段必须变化的模型参数是
。
我们讨论的是训练集中一个单独的示例,但这条线应该是最能拟合训练集所有点的线。当然,我们对数据集做出了一个强假设:我们使用的是一个由于其本质而建模为一条线的模型。由于这一点,在尝试将线性模型拟合到数据之前,我们应该首先确定因变量和自变量之间是否存在线性关系(使用散点图通常很有帮助)。
拟合回归线的最常见方法是最小二乘法。该方法通过最小化每个数据点到拟合线的垂直偏差平方和,来计算观测数据的最佳拟合线(如果一个点恰好位于拟合线上的话,它的垂直偏差为 0)。我们称这种观测数据和预测数据之间的关系为损失函数,正如我们将在第二章《神经网络与深度学习》中看到的那样。
因此,监督学习算法的目标是遍历数据,并通过迭代调整
参数,以便
正确地建模观测现象。
然而,当使用更复杂的模型(如神经网络中具有大量可调参数的模型)时,调整参数可能会导致不希望的结果。
如果我们的模型仅由两个参数组成,并且我们试图建模一个线性现象,则不会有问题。但如果我们试图对 Iris 数据集进行分类,我们不能使用简单的线性模型,因为很容易看出,用来分隔不同类别的函数并不是一条简单的直线。
在这种情况下,我们可以使用具有更高数量可训练参数的模型,这些模型可以调整其变量以几乎完美地拟合数据集。这听起来完美,但实际上,这并不是我们期望的效果。事实上,模型只是调整其参数以适应训练数据,几乎是在记忆数据集,从而失去了所有的泛化能力。
这种病态现象被称为过拟合,它发生在我们使用一个过于复杂的模型来建模一个简单事件时。还有一种相反的情况,称为欠拟合,当我们的模型对于数据集过于简单,因此无法捕捉到数据的所有复杂性时,会发生这种情况。
每个机器学习模型的目标都是学习,并会调整其参数,以使其对噪声具有鲁棒性并能够泛化,这意味着找到一个合适的近似函数来表示预测变量与响应之间的关系:

虚线代表模型的预测。左侧的欠拟合是一个泛化性能很差的模型,因此无法学习到良好的数据集近似。中间的图像代表了一个可以很好地进行泛化的模型,而右侧的模型则记住了训练集,发生了过拟合。
多年来,已经开发出了多种监督学习算法。然而,本书将重点介绍一种已被证明更为通用,并可用于解决几乎所有监督、无监督和半监督学习任务的机器学习模型:神经网络。
在训练和验证阶段的解释中,我们讨论了两个尚未介绍的概念——超参数和指标:
-
超参数:当我们的算法(该算法需要完全定义)需要为一组参数分配值时,我们谈论的是超参数。我们将定义算法本身的参数称为超参数。例如,神经网络中的神经元数量就是一个超参数。
-
指标:给出模型预测的函数。期望的输出会产生一个数值评分,用来衡量模型的优劣。
指标是每个机器学习流程中的关键组成部分;它们如此有用且强大,以至于它们应当拥有自己的一节。
测量模型性能——指标
在评估和测试阶段评估监督学习算法是任何精心构建的机器学习管道中至关重要的一部分。
在我们描述可用的各种指标之前,还有一件值得注意的事:评估模型性能是我们在每个数据集拆分中都可以进行的操作。在训练阶段,通常在每个训练周期结束时,我们可以评估算法在训练集和验证集上的表现。通过绘制训练过程中曲线的变化,并分析验证曲线和训练曲线之间的关系,我们可以快速识别先前描述的机器学习模型病态条件——过拟合和欠拟合。
监督学习算法具有一个显著的优势,那就是数据集中包含了算法的期望输出,所有这里呈现的指标都利用标签信息来评估模型的“表现如何”。
有用于衡量分类器性能的指标,也有用于衡量回归器性能的指标;显然,即使分类器和回归器都属于监督学习算法家族,将它们视为相同的对象也是毫无意义的。
评估监督学习算法性能的第一个指标也是最常用的指标是准确率。
使用准确率
准确率是指正确预测的数量与所有预测数量之比。
准确率用于衡量多分类问题中的分类性能。
给定
作为标签,
作为预测,我们可以定义第i个样本的准确率如下:

因此,对于一个包含N个元素的完整数据集,所有样本的平均准确率如下:

使用此指标时,我们必须关注数据集D的结构:实际上,只有在每个类别的样本数量相等时,它才会工作得很好(我们需要使用一个平衡的数据集)。
在数据集不平衡的情况下,或者当预测错误类别的误差大于/小于预测其他类别时,准确率并不是最好的指标。为了理解这一点,考虑一个只有两个类别的数据集,其中 80%的样本是类 1,20%的样本是类 2。
如果分类器只预测类 1,在这个数据集中测得的准确率是 0.8,但显然这并不是一个好的分类器性能指标,因为无论输入是什么,它总是预测相同的类。如果在一个测试集上对同一模型进行测试,该测试集包含 40%的类 1 样本和剩下的类 2 样本,那么准确率会降到 0.4。
记住,度量标准可以在训练阶段用来衡量模型的表现,我们可以通过查看验证准确率和训练准确率来监控训练过程,检测模型是否出现过拟合或欠拟合。
如果模型能够拟合数据中的关系,训练准确率会提高;如果不能,模型过于简单,出现欠拟合。这时,我们需要使用具有更高学习能力(更多可训练参数)的复杂模型。
如果我们的训练准确率提高了,我们可以开始查看验证准确率(每个训练周期结束时):如果验证准确率停止增长甚至开始下降,说明模型正在过拟合训练数据,我们应该停止训练(这称为提前停止,是一种正则化技术)。
使用混淆矩阵
混淆矩阵是表示分类器性能的表格方式。它可以用来总结分类器在测试集上的表现,并且仅适用于多类分类问题。矩阵的每一行代表预测类别中的实例,每一列代表实际类别中的实例。例如,在一个二分类问题中,我们可以有以下内容:
| 样本: 320 | 实际: 是 | 实际: 否 |
|---|---|---|
| 预测: 是 | 98 | 120 |
| 预测: 否 | 150 | 128 |
值得注意的是,混淆矩阵不是一个度量标准;实际上,单独的矩阵并不能衡量模型的性能,而是计算若干有用度量的基础,这些度量都基于真正正例、真正负例、假正例和假负例的概念。
这些术语都指的是单一类别;这意味着在计算这些术语时,必须将多类分类问题视为二分类问题。给定一个多类分类问题,其中类别为 A、B、...、Z,我们例如可以得到以下内容:
-
(TP) A 的真正正例:所有被分类为 A 的 A 实例
-
(TN) A 的真正负例:所有未被分类为 A 的非 A 实例
-
(FP) A 的假正例:所有被分类为 A 的非 A 实例
-
(FN) A 的假负例:所有未被分类为 A 的 A 实例
当然,这可以应用于数据集中的每个类别,从而为每个类别得到这四个值。
我们可以计算的最重要的度量标准是精确度、召回率和 F1 分数,这些度量基于 TP、TN、FP 和 FN 的值。
精确度
精确度是正确正例结果的数量,除以预测的正例结果的数量:

该度量名称本身描述了我们在这里测量的内容:一个[0,1]范围内的数字,表示分类器预测的准确度:数值越高越好。然而,与准确率类似,单独依赖精度值可能具有误导性。高精度仅意味着在我们预测为正类时,我们能精确地检测到它。但这并不意味着当我们未检测到该类别时,我们也能准确。
另一个应始终衡量的度量指标是召回率,以便理解分类器的完整行为。
召回率
召回率是正确的正类结果的数量,除以所有相关样本的数量(例如,所有应当分类为正类的样本):

像精度一样,召回率是一个[0,1]范围内的数字,表示正确分类的样本在该类别所有样本中的百分比。召回率是一个重要的度量,特别是在图像中的物体检测等问题中。
测量二分类器的精度和召回率可以帮助你调节分类器的性能,使其按需表现。
有时,精度比召回率更重要,反之亦然。因此,值得为分类器的工作状态专门设立一个简短的部分。
分类器工作状态
有时,将分类器放入高召回率状态可能是值得的。这意味着我们更愿意接受更多的假阳性,同时确保真正的阳性被检测出来。
高召回率工作状态通常要求在计算机视觉工业应用中使用,因为生产线需要构建产品。在组装过程结束时,人工检查整个产品的质量是否达到了要求标准。
控制组装机器人操作的计算机视觉应用通常在高召回率状态下运行,因为生产线需要高吞吐量。如果将计算机视觉应用设置为高精度状态,生产线将经常停顿,减少整体吞吐量,并导致公司损失。
在实际场景中,改变分类器工作状态的能力至关重要,因为分类器作为生产工具被使用,必须能够根据商业决策进行自我适应。
还有其他一些情况需要高精度的工作状态。在工业场景中,一些由计算机视觉应用控制的过程非常关键,因此要求具有高准确度。
在发动机生产线中,分类器可以用于决定相机看到的零件是否是正确的部件,并将其挑选出来并安装到发动机中。在像这样的关键情况下,需要高精度的工作状态,而高召回率的工作状态则不推荐。
一个结合了精确率和召回率的度量是 F1 分数。
F1 分数
F1 分数是精度和召回率的调和平均数。这个值在 [0,1] 范围内,表示分类器的精度(precision)和它的健壮性(recall)。
F1 分数越大,模型的整体表现越好:

使用 ROC 曲线下的面积
接收操作特征(ROC)曲线下的面积是评估二元分类问题时最常用的指标之一。
大多数分类器生成的得分在 [0,1] 范围内,而不是直接作为分类标签。得分必须通过阈值来决定分类。一个自然的阈值是当得分大于 0.5 时分类为正,否则分类为负,但这并不总是我们的应用所需要的(例如,考虑到疾病患者的识别)。
改变阈值会改变分类器的性能,改变 TP、FP、TN 和 FN 的数量,从而影响整体分类表现。
阈值变化的结果可以通过绘制 ROC 曲线来考虑。ROC 曲线考虑了假阳性率(特异性)和真正率(敏感性):二元分类问题是这两个值之间的权衡。我们可以按如下方式描述这些值:
- 敏感性:真正率定义为被正确视为正类的正类数据点所占的比例,相对于所有正类数据点:

- 特异性:假阳性率定义为被认为是正类的负类数据点所占的比例,相对于所有负类数据点:

AUC 是 ROC 曲线下的面积,通过改变分类阈值获得:

通过改变分类阈值获得的 ROC 曲线。虚线表示随机猜测的预期。
很明显,TPR 和 FPR 的值都在 [0,1] 范围内,图形是通过变化分类器的分类阈值来绘制的,以便在每个阈值下获得不同的 TPR 和 FPR 配对。AUC 也在 [0,1] 范围内,值越大,模型越好。
如果我们对回归器的精度和召回率的表现感兴趣,并且从混淆矩阵中收集的所有数据都没有用处,那么我们必须使用其他指标来衡量回归误差。
平均绝对误差
平均绝对误差(MAE)是原始值和预测值之间绝对差的平均值。由于我们现在关注的是回归器的性能衡量,我们必须考虑到
和
的数值:

MAE 值没有上限,下限为 0。显然,我们希望 MAE 值尽可能接近 0。
MAE 为我们提供了预测值与实际输出之间距离的指示;这个指标容易解释,因为其值与原始响应值的量纲相同。
均方误差
均方误差(MSE)是原始值和预测值之间平方差的平均值:

和 MAE 一样,MSE 没有上限,其下限为 0。
相反,平方项的存在使得该指标不易解释。
一种良好的实践是同时考虑这两个指标,以便尽可能多地了解误差的分布。
该
关系成立,因此以下内容为真:
-
如果 MSE 接近 MAE,说明回归器的误差较小。
-
如果 MSE 接近 MAE²,说明回归器的误差较大。
指标可能是机器学习模型选择和性能衡量工具中最重要的部分:它们表示期望输出和模型输出之间的关系。这个关系是至关重要的,因为它是我们希望优化模型的目标,正如我们将在第二章《神经网络与深度学习》中看到的那样,届时我们将介绍损失函数的概念。
此外,由于本书中讨论的模型都是参数化模型,我们可以在训练过程中的任何时候/结束时衡量指标,并保存达到最佳验证/测试性能的模型参数(并根据定义,保存该模型)。
使用参数化模型使我们能够拥有这种灵活性——我们可以在模型达到所需性能时冻结其状态,然后继续进行训练,调整超参数,并尝试不同的配置/训练策略,同时确信我们已经保存了一个具有良好性能的模型。
拥有指标并能够在训练过程中进行度量,同时使用可以保存的参数化模型,使我们能够评估不同的模型,并仅保存最符合我们需求的模型。这个过程被称为模型选择,在每个设计良好的机器学习管道中都至关重要。
我们已经深入探讨了监督学习算法,但实际上,机器学习远不止如此(尽管在解决实际问题时,监督学习算法的表现最好)。
接下来我们将简要描述的算法来自无监督学习算法家族。
无监督学习
相较于监督学习,无监督学习在训练阶段不需要带标签的样本数据集——标签只在测试阶段需要,当我们想评估模型的表现时才需要。
无监督学习的目的是发现训练集中的自然分区。这是什么意思呢?想想 MNIST 数据集——它有 10 个类别,我们知道这一点是因为每个样本都有一个不同的标签,标签范围在[1,10]之间。无监督学习算法必须发现数据集中有 10 种不同的对象,并且通过查看样本而不依赖于标签的先验知识来实现这一点。
很明显,与监督学习算法相比,无监督学习算法更加具有挑战性,因为它们无法依赖标签的信息,必须自行发现特征并了解标签的概念。尽管具有挑战性,但它们的潜力巨大,因为它们能够发现数据中人类难以察觉的模式。无监督学习算法常常被需要从数据中提取意义的决策者所使用。
想想欺诈检测问题:你有一组交易,存在大量的资金交换,而你并不知道其中是否存在欺诈交易,因为现实世界中并没有标签!
在这种情况下,应用无监督学习算法可以帮助你发现正常交易的大自然分区,并帮助你识别异常值。
异常值是指位于数据中任何分区(也称为簇)外部,且通常远离这些分区的点,或者是具有某些特殊特征的分区,使其不同于正常的分区。
因此,无监督学习在异常检测任务中被广泛应用,并且涵盖了多个领域:不仅限于欺诈检测,还包括图像质量控制、视频流、来自生产环境传感器的数据流等。
无监督学习算法也是两阶段算法:
-
训练与验证:由于训练集内没有标签(如果有标签,则应予以丢弃),所以算法被训练来发现数据中的现有模式。如果有验证集,它应该包含标签;模型的表现可以在每个训练周期结束时进行评估。
-
测试:将一个带标签的数据集输入到算法中(如果有此类数据集),并将其结果与标签信息进行比较。在此阶段,我们使用标签信息来衡量算法的性能,以验证算法是否学会了从数据中提取人类也能检测到的模式。
仅仅基于这些例子,无监督学习算法并不是按照标签类型进行分类的(如同监督学习算法),而是根据它们的目标进行分类。
无监督学习算法可以分类如下:
-
聚类:目标是发现聚类,即数据的自然分区。
-
关联:在这种情况下,目标是发现描述数据及其之间关联的规则。这些规则通常用于提供推荐:

无监督学习家族——有两大类算法
关联学习算法是数据挖掘领域的强大工具:它们用于发现规则,例如“如果一个人购买了黄油和面包,他们可能还会购买牛奶”。学习这些规则可以在商业中带来巨大的竞争优势。通过回顾前面的例子,我们可以说,商店可以将黄油、面包和牛奶放在同一货架上,以最大化销售!
在聚类算法的训练阶段,我们关心的是衡量模型的性能,就像在监督学习中一样。在无监督学习算法的情况下,度量标准更为复杂,且依赖于任务。我们通常做的是利用数据集中存在的附加标签,但这些标签在训练过程中不会被使用,从而将问题重新转化为一个监督学习问题,并使用常规的度量标准。
与监督学习的情况一样,也有参数模型和非参数模型。
大多数非参数算法通过衡量数据点与数据集中其他每个数据点之间的距离来工作;然后,它们使用距离信息将数据空间划分为不同的区域。
与监督学习的情况类似,随着时间的推移,已经开发出许多算法,用于在无标签数据集中发现自然分区和/或规则。然而,神经网络已被应用于解决无监督学习任务,并取得了卓越的表现,且显示出极高的灵活性。这也是本书仅专注于神经网络的另一个原因。
无监督学习算法明确要求在训练阶段不使用任何标签信息。然而,由于数据集中可能存在标签,为什么不在仍使用机器学习算法发现数据中其他模式的同时,利用标签的存在呢?
半监督学习
半监督学习算法介于监督学习和无监督学习算法之间。
它们依赖于这样一个假设:我们可以利用有标签数据的信息来改进无监督学习算法的结果,反之亦然。
是否能够使用半监督学习算法取决于可用的数据:如果只有有标签的数据,可以使用监督学习;如果没有任何标签数据,则必须使用无监督学习方法。然而,假设我们有以下情况:
-
有标签和无标签的示例
-
所有标签为相同类别的示例
如果我们有这些数据,那么我们可以使用半监督方法来解决这个问题。
如果所有示例的标签都属于同一类别,这种情况看起来像是一个监督学习问题,但实际上并不是。
如果分类的目标是找到一个分割至少两个区域的边界,当我们只有一个区域时,如何定义区域之间的边界?
不能!
对于这类问题,无监督或半监督学习方法是最佳选择:算法将学习如何划分输入空间(希望能在一个单一的聚类中),其形状,以及数据在空间中的分布情况。
可以使用无监督学习方法来学习数据中是否存在单一的聚类。通过使用标签,并因此转向半监督学习方法,我们可以对空间施加一些额外的约束,从而使数据的表示更加合理。
一旦无监督/半监督学习算法学习到数据的表示,我们就可以测试一个新示例——一个在训练过程中从未见过的示例——是否落入聚类中。或者,我们可以计算一个数值分数,告诉我们这个新示例“有多大”符合已学习的表示。
与无监督学习算法一样,半监督算法也有两个阶段。
总结
在本章中,我们从一般和理论的角度讲解了机器学习算法的家族。了解机器学习是什么、算法如何分类、在特定任务下使用何种算法,以及如何熟悉机器学习从业者使用的所有概念和术语是非常重要的。
在下一章,第二章,神经网络与深度学习,我们将专注于神经网络。我们将理解机器学习模型的优点,如何让一个网络学习,以及在实践中如何执行模型参数更新。
练习
回答以下问题至关重要:你正在建立你的机器学习基础——不要跳过这个步骤!
-
给定一个包含 1000 个标签示例的数据集,如果你想在训练、验证和测试阶段使用准确率作为唯一指标来衡量监督学习算法的表现,应该怎么做?
-
监督学习和无监督学习有什么区别?
-
精确度和召回率有什么区别?
-
在高召回率模式下,模型产生的假阳性多还是少?与低召回率模式的模型相比,如何?
-
混淆矩阵只能用于二分类问题吗?如果不是,我们如何在多类分类问题中使用它?
-
一类分类是监督学习问题吗?如果是,为什么?如果不是,为什么?
-
如果一个二分类器的 AUC 为 0.5,你能从中得出什么结论?
-
写出精确度、召回率、F1 分数和准确率的公式。为什么 F1 分数很重要?准确率、精确度和召回率之间有关系吗?
-
真阳性率和假阳性率用于绘制 ROC 曲线。ROC 曲线的目的是什么?真阳性率/假阳性率与精确度/召回率之间有关系吗?提示:写出数学公式。
-
什么是维度灾难?
-
什么是过拟合和欠拟合?
-
模型的学习能力是什么?它是否与过拟合/欠拟合的情况相关?
-
写出Lp范数的公式——这是不是衡量点与点之间距离的唯一方式?
-
我们如何判断一个数据点与另一个数据点相似?
-
什么是模型选择?它为什么重要?
第二章:神经网络与深度学习
神经网络是本书中我们将深入探讨的主要机器学习模型。它们的应用领域无数,涵盖了从计算机视觉(例如,图像中的物体定位),金融(例如,神经网络用于检测欺诈行为),交易,到甚至艺术领域(神经网络与对抗训练过程结合,能够生成新颖且未见过的艺术作品,取得惊人的成果)。
本章可能是全书中最具理论性的部分,将展示如何定义神经网络及如何使其学习。首先,我们将介绍人工神经元的数学公式,并强调神经元必须具备哪些特征才能学习。接下来,将详细解释全连接和卷积神经网络拓扑结构,因为这些是几乎所有神经网络架构的构建模块。同时,我们将介绍深度学习和深度架构的概念。引入这一概念是必不可少的,因为正是深度架构让神经网络如今能够解决具有超人表现的挑战性问题。
最后,将展示训练一个参数化模型所需的优化过程,并结合一些正则化技术来提升模型的表现。梯度下降、链式法则和计算图的表示都将单独讲解,因为对任何机器学习实践者来说,了解在框架训练模型时发生的事情是至关重要的。
如果你已经熟悉本章所介绍的概念,可以直接跳到下一章,第三章,TensorFlow 图架构,本章专门讲解 TensorFlow 的图架构。
在本章中,我们将涵盖以下主题:
-
神经网络
-
优化
-
卷积神经网络
-
正则化
神经网络
神经网络的定义来自于最早期神经计算机之一的发明者罗伯特·赫希特-尼尔森博士,他在《神经网络入门——第一部分》中这样描述:
“一个由许多简单且高度互联的处理单元组成的计算系统,这些单元通过对外部输入的动态状态响应来处理信息。”
实际上,我们可以将人工神经网络视为一种计算模型,它基于大脑的工作原理。因此,数学模型受生物神经元的启发。
生物神经元
大脑的主要计算单元被称为神经元;在人类神经系统中,大约有 860 亿个神经元,它们通过突触互相连接。以下图示展示了生物神经元及其数学模型:

生物神经元的表示,左图为(a),右图为其数学模型(b)。来源:斯坦福 cs231n
生物神经元由以下部分组成:
-
树突:细小的纤维,以电信号的形式将信息从外部传递到细胞核。
-
突触:这些是神经元之间的连接点。神经元通过连接到树突的突触接收输入信号。
-
细胞核:接收来自树突的信号,对其进行处理,并产生响应(输出信号),然后将其发送到轴突。
-
轴突:神经元的输出通道。它可以连接到其他神经元的突触。
每个神经元从其树突接收输入信号,并将其传输到细胞核进行处理;树突对信号进行处理,从而整合(相加或合并)来自每个输入突触的兴奋与抑制。细胞核接收这些综合信号并将它们相加。如果最终的总和超过某个阈值,神经元就会发放,并将结果信息通过轴突传递到其他连接的神经元。
在神经元之间传递的信号量取决于连接的强度。正是神经元的排列和这些突触的强度决定了神经网络的功能。
生物神经元的学习阶段是基于细胞核生成的输出信号随时间的变化,作为某些类型输入信号的函数。神经元在其生命周期内会专门化,识别特定的刺激。
人工神经元
人工神经元基于生物神经元的结构,使用带有实数值的数学函数来模拟其行为。这种人工神经元被称为感知器,这一概念是由科学家弗兰克·罗森布拉特在 20 世纪 50 到 60 年代提出的。考虑到这一数学类比,我们可以这样描述生物神经元:
-
树突:神经元接收输入的数量。它也可以看作是输入数据的维度数,D。
-
突触:
与树突相关的权重。这些值在训练阶段会发生变化。在训练阶段结束时,我们说神经元已经专门化(它学会了从输入中提取特定的特征)。
如果
是一个 D 维输入向量,那么突触执行的操作是 
-
核心体(细胞体):这是一个将来自突触的值结合起来的函数,从而定义神经元的行为。为了模拟生物神经元的动作,即只有在输入中有特定刺激时才会激活(发射),核心体被建模为非线性函数。
如果
是一个非线性函数,那么神经元的输出,即考虑到所有输入刺激的结果,由以下方程给出:
.
- 在这里,
是偏置项,它具有基础性的重要性。它允许你学习一个不以 D 维空间原点为中心的决策边界。
如果我们暂时去除非线性(也称为激活)函数,我们可以很容易地看到,突触定义了一个具有以下方程的超平面:
![].
单个神经元只能执行二元分类,因为 D 维向量
只能位于它定义的超平面之上或之下。
如果—且仅当—样本是线性可分的,那么感知机能够在 D 维空间中正确分类样本。
核心体通过其非线性特性,将由树突定义的超平面映射到更一般的超曲面上,这就是学习到的决策边界。在最理想的情况下,非线性将超平面转化为一个超曲面,该超曲面能够正确分类 D 维空间中的点。然而,只有在这些点可以通过单一超曲面在两个区域之间分离时,它才会这样做。
这是我们需要多层神经网络的主要原因:如果输入数据无法通过单个超平面分离,添加另一层,通过将学习到的超平面转化为一个带有额外分类区域的新超平面,使其能够学习到复杂的分类边界,从而正确分离区域。
此外,值得注意的是,前馈神经网络,例如那些神经元之间存在连接但不形成循环的神经网络,是通用的函数近似器。这意味着,如果存在一种分离区域的方法,经过充分训练、具有足够容量的神经网络将能够学习并近似该函数。
- 轴突:这是神经元的输出值,其他神经元可以将其作为输入。
需要强调的是,这种生物神经元模型是非常粗略的:例如,神经元有许多不同的类型,每种类型都有不同的特性。生物神经元的树突执行复杂的非线性计算。突触不仅仅是一个单一的权重;它们是一个复杂的非线性动态系统。该模型还有许多其他简化之处,因为现实要比这个模型复杂得多,且更难以建模。因此,这种生物学上的灵感仅仅是一种理解神经网络的方式,但不要被这些相似之处所迷惑:人工神经网络仅仅是受到生物神经元的松散启发。
“我们为什么要使用神经网络而不是其他机器学习模型?”
传统的机器学习模型很强大,但通常不如神经网络那样灵活。神经网络可以以不同的拓扑结构排列,几何形状改变了神经网络所看到的内容(输入刺激)。此外,创建不同拓扑结构的神经网络层叠层非常简单,从而创建出深度模型。
神经网络的一个最大优点是其成为特征提取器的能力:其他机器学习模型需要对输入数据进行处理、提取有意义的特征,并且只能基于这些特征(手动定义的!)应用模型。
另一方面,神经网络可以通过自身从任何输入数据中提取有意义的特征(这取决于所使用层的拓扑结构)。
单个感知机展示了如何对不同类型的输入进行加权和求和,从而做出简单的决策;而感知机的复杂网络则可以做出相当微妙的决策。因此,神经网络架构由神经元组成,所有神经元通过突触(生物学上)相连,信息通过这些突触流动。在训练过程中,神经元会在学习到数据中的特定模式时触发。
这一触发率是通过激活函数来建模的。更准确地说,神经元是以无环图的形式连接的;
循环是不允许的,因为这会导致网络前向传递中的无限循环(这类网络被称为前馈神经网络)。神经网络模型通常将神经元组织成不同的层,而不是将神经元简单地连接成无规则的团块。最常见的层类型是全连接层。
全连接层
全连接配置是一种特定的网络拓扑,其中两个相邻层之间的神经元是完全成对连接的,但单层内的神经元之间没有任何连接。
将网络组织成层可以让我们创建由多个完全连接的层组成的堆叠,每层中神经元的数量不同。我们可以将多层神经网络看作一个包含可见层和隐藏层的模型。可见层仅指输入层和输出层;隐藏层是那些没有与外部连接的层:

这是一个典型的完全连接神经网络的表示,包含两个隐藏层。每一层都减少其输入的维度,目的是根据十个输入特征生成两个不同的输出。
隐藏层中神经元的数量是完全任意的,并且它改变网络的学习能力。输入层和输出层则有固定的维度,这是由我们要解决的任务决定的(例如,如果我们要解决一个n类分类问题,且输入维度为 D,那么我们需要一个具有 D 个输入的输入层和一个具有 n 个输出的输出层)。
从数学上讲,可以将完全连接层的输出定义为矩阵乘积的结果。假设我们有以下方程:

输出 O 由以下公式给出:

这里,M 是该层中神经元的任意数量。
虽然神经网络的输入层和输出层设计较为简单,但隐藏层的设计并不那么简单。没有固定的规则;神经网络研究者为隐藏层开发了许多设计启发式方法,帮助获得正确的行为(例如,当隐藏层数量与训练网络的时间之间存在权衡时)。
通常,增加每层中的神经元数量和/或神经网络中层的数量意味着需要增加网络的容量。这意味着神经网络可以表示更复杂的函数,并且可表示函数的空间增长;然而,这同时有好处也有坏处。好处是我们可以学习更复杂的函数,但坏处是更多的可训练参数会增加过拟合训练数据的风险。
通常,如果数据不复杂或我们使用的是小型数据集,应优先选择较小的神经网络。幸运的是,有不同的技术可以在使用大容量模型时防止过拟合数据。这些技术被称为正则化技术(对参数的 L2 惩罚、丢弃法、批量归一化、数据增强等)。我们将在接下来的章节中深入探讨这些技术。
激活函数是每个神经网络设计中另一个重要的部分。它应用于每个神经元:没有人强迫我们在每个神经元上使用相同的非线性函数,但约定俗成的是选择一种非线性形式,并在同一层的每个神经元上使用它。
如果我们正在构建一个分类器,我们关心的是评估网络的输出层,并能够解释输出值来理解网络的预测。假设我们对输出层的每个神经元应用了线性激活函数,每个神经元与一个特定类别相关联(看前面的图像,我们有一个 3 维输入和两个输出神经元,每个类别一个)——那么我们如何解释这些值呢?因为它们的值域是整个实数集,所以很难解释以这种方式表达的值。
最自然的方法是将输出值的总和限制在[0,1]范围内,这样我们就可以将输出值视为从预测类别的概率分布中采样,并可以将具有最高值的神经元视为预测的类别。或者,我们可以选择对这些值应用阈值操作,以模拟生物神经元的放电:如果神经元的输出大于某个阈值,我们可以输出值 1,否则输出 0。
另一种方法是将每个神经元的输出限制在[0,1]的范围内,例如在我们解决一个多类分类任务时,类之间不是互斥的。
很容易理解为什么输出层中的某种非线性非常重要——它可以改变网络的行为,因为我们如何解释网络的输出依赖于此。然而,要完全理解神经网络,理解每一层非线性的重要性是必须的。
激活函数
如我们所知,层中第i个神经元的输出值计算方式如下:
激活函数,
,因多种原因而重要:
-
如前一节所述,根据我们应用非线性的层次,它使我们能够解释神经网络的结果。
-
如果输入数据是不可线性分割的,其非线性特性使得你能够近似一个非线性函数,从而以非线性的方式分割数据(想一想超平面如何转化为一般的超曲面)。
-
如果相邻层之间没有非线性,那么多层神经网络等价于只有一个隐藏层的单层神经网络,因此它们只能分割输入数据的两个区域。事实上,给定:
![]
两个感知机堆叠:
![],
我们知道,第二个感知机的输出等同于单个感知机的输出:
![],
- 其中,![] 和 ![] 是权重矩阵,偏置向量等同于单个权重矩阵和偏置向量的乘积。
这意味着,当 ![] 是线性时,多层神经网络始终等同于单层神经网络(因此,它具有相同的学习能力)。如果不是这样,最后的方程就不成立。
- 非线性使得网络对噪声输入具有鲁棒性。如果输入数据包含噪声(即训练集中的值不完美——这种情况时常发生),非线性可以防止噪声传播到输出。以下是这一点的证明:
![]。
最常用的两种激活函数是 sigmoid(
)和双曲正切(
)。
第一个激活函数几乎在所有分类问题的输出层中使用,因为它将输出压缩到[0,1]范围内,使你可以将预测结果解释为一个概率:

双曲正切函数则作为几乎所有生成模型输出层的激活函数,通常用于生成图像的训练。即便如此,我们使用它的原因是为了正确地解释输出,并在输入图像和生成图像之间建立有意义的联系。我们习惯将输入值从[0,255]范围缩放到[-1,1],这是
函数的范围。
然而,使用像
和
这样的函数作为隐藏层的激活函数并不是最佳选择,这与反向传播训练过程相关(如我们将在后续章节中看到的,饱和非线性可能会成为问题)。为了克服饱和非线性带来的问题,许多其他激活函数被开发了出来。下图展示了最常见的非线性函数的简短视觉概览:

最常见激活函数的列表。来源:Stanford cs231n。
一旦网络结构定义完成,并且确定了要在隐藏层和输出层中使用的激活函数,就可以定义训练数据与网络输出之间的关系,从而能够训练网络并使其解决当前的任务。
在接下来的章节中,我们将讨论一个离散分类问题,该问题继承自第一章,什么是机器学习?我们在讨论的是,分类问题的所有结论也适用于连续变量,因为我们使用神经网络作为工具来解决监督学习问题。由于神经网络是一个参数模型,训练它意味着我们需要更新这些参数。
,以找到能够以最佳方式解决问题的配置。
如果我们希望定义输入数据和期望输出之间的关系,那么训练神经网络是必不可少的:这就是目标函数——或者称为损失函数,因为我们希望将损失最小化作为目标。
损失函数
在定义了网络架构后,模型必须进行训练。现在是时候定义模型输出和真实数据之间的关系了。为此,必须定义一个损失函数。
损失函数用于评估模型的拟合优度。
存在多种损失函数,每个损失函数都表达了网络输出与真实数据之间的关系,而它们的形式完全影响模型预测的质量。
对于一个在
个类别上的离散分类问题,我们可以定义一个神经网络模型,该模型接受一个 D 维的输入向量,
,并生成一个
维的预测向量,作为其参数的函数,
,如下所示:

模型生成一个 M 维的输出向量,包含模型分配给输入的概率,
,对于每一个可能的类别(如果我们对输出层应用了 sigmoid 激活函数,可以这样解读输出)。
很容易提取输出层中产生最大值的神经元的位置。预测类别的公式如下:

通过使用这个方法,我们可以找到产生最高分类分数的神经元的索引。由于我们知道与输入相关的标签,
,我们几乎已经准备好定义预测与标签之间的关系。我们面临的最后一个问题是标签的格式:标签是一个标量值,而网络输出是一个 M 维向量。虽然我们可以找到具有最高概率值的神经元位置,但我们关心的是整个输出层,因为我们希望增加正确类别的概率,并惩罚错误类别。
因此,标签必须转换为 M 维表示,以便我们可以在每个输出神经元和标签之间建立联系。
从标量值到 M 维表示的最自然转换称为独热编码。此编码由创建一个 M 维向量组成,该向量在标签的位置上值为 1,在其他位置上值为 0。因此,我们可以将独热编码的标签表示为如下:

现在,可以定义损失函数的一般公式,该公式适用于 i 第个训练集实例,作为一个实值函数,创建一个地面真实值(正确编码的标签)和预测值之间的联系:

应用于完整训练集的损失函数的一般公式,设训练集大小为 k,可以表示为对单个实例计算的损失的均值:

损失必须根据具体问题来选择(或定义)。对于分类问题(互斥类别),最简单和最直观的损失函数是标签的独热编码表示和网络输出之间的 L2 距离。目标是最小化网络输出与独热编码标签之间的距离,从而使网络预测一个类似于正确标签的 M 维向量:

损失函数的最小化是通过对模型参数值进行小的迭代调整来实现的。
参数初始化
初始模型参数值是训练阶段迭代优化问题的解:没有唯一的方式初始化网络参数,关于参数初始化的唯一有效建议如下:
-
不要将网络参数初始化为零:使用梯度下降法是无法找到新解的(如我们将在下一节中看到的),因为整个梯度为 0,因此没有更新方向的指示。
-
打破不同单元之间的对称性:如果两个使用相同激活函数的隐藏单元连接到相同输入,则这两个输入必须具有不同的初始参数值。这是必要的,因为几乎每个解决方案都要求为每个神经元分配一组不同的参数,以找到有意义的解。如果我们一开始将所有参数都设置为相同的值,那么每次更新步骤都会以相同的量更新所有网络参数,因为更新值取决于误差,而误差对于网络中的每个神经元来说都是相等的。因此,我们将无法找到有意义的解决方案。
通常,问题的初始解是通过具有零均值和单位方差的随机正态分布来采样的。这种分布确保了网络参数较小并且围绕零值均匀分布,同时它们之间有所不同,从而打破了对称性。
现在我们已经定义了网络架构,正确格式化了输入标签,并用损失函数定义了输入输出关系,那么我们如何最小化损失呢?我们如何迭代调整模型参数以最小化损失,从而解决问题?
这一切都是关于优化和优化算法的问题。
优化
运筹学为我们提供了高效的算法,可以通过寻找全局最优解(全局最小点)来解决优化问题,如果问题可以表达为具有明确定义特性的函数(例如,凸优化要求函数为凸函数)。
人工神经网络是通用的函数逼近器;因此,我们无法对神经网络所逼近的函数形状做出假设。此外,最常见的优化方法利用几何考虑,但正如我们在第一章中了解到的,什么是机器学习?,由于维度灾难,当维度很高时,几何学的表现非常不寻常。
基于这些原因,无法使用能够找到优化(最小化)问题全局最优解的运筹学方法。相反,我们必须使用一种迭代优化方法,该方法从初始解出发,尝试通过更新表示解的模型参数来细化解,以寻找一个好的局部最优解。
我们可以将模型参数,
,视为最小化问题的初始解。因此,我们可以从训练步骤 0 开始评估损失函数,
,以便了解它在当前初始参数配置下的值,
。现在,我们必须决定如何更新模型参数。为此,我们需要执行第一次更新步骤,方法是根据损失给出的信息进行操作。我们可以通过两种方式继续:
- 随机扰动:我们可以对当前的一组参数应用随机扰动,
,并计算获得的新参数集上的损失值,
。
如果训练步骤中的损失值,
,小于前一步的值,我们可以接受找到的解并继续进行新的随机扰动,应用到新一组参数上。否则,我们必须重复随机扰动,直到找到更好的解。
- 更新方向的估计:与其随机生成一组新参数,不如指导局部最优解搜索过程朝着函数的最大下降方向进行。
第二种方法是训练作为可微函数表示的参数化机器学习模型的 de facto 标准。
要正确理解这个梯度下降方法,我们必须将损失函数看作是在参数空间中定义的一个表面——我们的目标,即最小化损失,意味着我们需要找到这个表面上的最低点。
梯度下降
梯度下降是一种用于计算在寻找最小化/最大化问题解时,应该沿着哪个方向移动的方法。这种方法建议我们在更新模型参数时应遵循的方向:根据所使用的输入数据,找到的方向是损失面最陡峭下降的方向。所使用的数据极为重要,因为它决定了损失函数的评估方式,从而决定了用来评估更新方向的表面。
更新方向由损失函数的梯度给出。从微积分中知道,对于一个单变量可微函数,
,在点
的导数由以下公式给出:

该操作为我们提供了在
中的函数行为描述:它展示了函数在
变量附近一个无穷小区域内的变化。
对于 n 变量函数的导数操作的推广就是梯度,即偏导数的向量(函数对单一变量的导数向量,考虑其他变量为常数)。在我们的损失函数的情况下,它如下所示:

表示函数增长的方向。因此,由于我们的目标是找到最小值,我们必须沿着由反梯度指示的方向移动,如下所示:

在这里,反梯度表示执行参数更新时应该遵循的方向。现在,参数更新步骤如下所示:

参数是学习率,是梯度下降训练阶段的超参数。选择正确的学习率值更像是一门艺术而非科学,我们唯一能做的就是凭借直觉选择一个适合我们的模型和数据集的值。我们必须记住,反梯度只是告诉我们应该遵循的方向,它并没有给出当前解与最小点之间的距离信息。距离或更新强度由学习率来调节:
-
学习率过高可能会导致训练阶段不稳定,因为在局部最小值附近会出现跳跃,这会导致损失函数值的震荡。为了帮助记住这一点,我们可以把它想象成一个 U 型的曲面。如果学习率过高,我们在 U 的左右两侧之间跳跃,而在下一个更新步骤中则反向跳跃,永远无法下降到谷底(因为 U 的两个峰值之间的距离大于
)。 -
学习率过小可能会导致训练阶段的效果不理想,因为我们永远无法跳出一个不是全局最小值的谷底。因此,有被困在局部最小值中的风险。此外,学习率过小的另一个风险是永远找不到一个好的解——并不是因为我们被困在局部最小值中,而是因为我们朝着目标前进的速度太慢。由于这是一个迭代过程,研究可能需要花费太长时间。
为了应对选择学习率值的挑战,已经开发出多种策略,这些策略在训练阶段调整学习率的值,通常是逐渐减小,以便在使用较大学习率进行探索(寻找更广泛的解空间)和使用较小学习率进行精细调整(下降到更深的谷底)之间找到平衡。
到目前为止,我们已经讨论了通过使用完整数据集计算得到的损失函数来更新参数,这种方法称为批量梯度下降。实际上,这种方法无法应用于现实场景,因为现代神经网络应用处理的数据量巨大,通常无法完全存入计算机内存。
已经开发出了几种批量梯度下降的变体,以克服其局限性,同时也有不同的策略来更新模型参数,这些策略将帮助我们解决与梯度方法本身相关的挑战。
随机梯度下降
随机梯度下降会针对训练数据集中的每个元素进行模型参数更新——每个样本一次更新步骤:

如果数据集具有较高的方差,随机梯度下降会在训练阶段造成损失值的巨大波动。这既可以是优势也可以是劣势:
-
这可能是一个优势,因为由于损失的波动,我们进入了解决方案空间中的未探索区域,这些区域可能包含更好的最小值。
-
这是一种适用于在线训练的方法。这意味着在模型的整个生命周期中使用新数据进行训练(这意味着我们可以继续用新的数据训练模型,通常来自传感器)。
-
收敛较慢,找到一个好的最小值更困难,因为更新具有较高的方差。
训练神经网络的事实标准方法,旨在保持批量梯度下降和随机梯度下降的优点,称为小批量梯度下降。
小批量梯度下降
小批量梯度下降保留了批量梯度下降和随机梯度下降方法的优点。它使用训练集的一个子集(即小批量)更新模型参数,子集的基数为
:

这是最广泛使用的方法,原因如下:
-
使用小批量减少了参数更新的方差,因此在训练过程中加速了收敛速度。
-
使用具有卡尔达利数的小批量使你能够复用相同的方法进行在线训练
可以写出梯度下降在更新步骤 s 时的通用公式,如下所示:

-
对于
,该方法是随机梯度下降 -
对于
,该方法是批量梯度下降 -
对于
,该方法是小批量梯度下降
这里展示的三种方法以所谓的传统方式更新模型参数,该方式只考虑当前参数的值和通过应用定义计算出的反向梯度。它们都使用固定的学习率值。
其他参数优化算法也存在,所有这些算法的开发目标是找到更好的解决方案、更好地探索参数空间,并克服传统方法在寻找良好最小值时可能遇到的所有问题:
-
选择学习率:学习率可能是整个训练阶段最重要的超参数。这些原因在 梯度下降 部分的末尾已有解释。
-
常数学习率:传统更新策略在训练过程中不会改变学习率的值。此外,它使用相同的学习率来更新每一个参数。这总是可取的吗?可能不是,因为将与输入特征的不同出现频率相关的参数同等对待并不合理。从直观来看,我们希望以较小的步长更新与低频特征相关的参数,而以较大的步长更新其他参数。
-
鞍点和平台:用于训练神经网络的损失函数是大量参数的函数,因此是非凸函数。在优化过程中,可能会遇到鞍点(函数在某个维度上值增加,但在其他维度上值减少的点)或平台(损失面上的局部常数区域)。
在这些情况下,梯度几乎在每个维度上都接近零,因此由反梯度指引的方向几乎为零。这意味着我们已经被卡住了,优化过程无法继续。我们在多个训练步骤中被损失函数假定的常数值所迷惑;我们以为已经找到了一个好的最小值,但实际上,我们困在了解空间中的一个无意义区域。
梯度下降优化算法
已经开发了几种优化算法来提高传统优化方法的效率。在接下来的章节中,我们将回顾传统优化方法,并展示两种最常见的优化算法:动量法和 ADAM。我们讨论动量法是因为它展示了如何通过对损失面进行物理解释,从而取得成功的结果;而讨论 ADAM 则是因为它是最广泛应用的自适应优化方法。
传统方法
正如我们之前看到的,更新公式只需要估计方向,这个方向是通过使用反梯度和学习率来获得的:

动量
动量优化算法基于对损失面的一种物理解释。我们可以将损失面看作是一片混乱的景观,粒子在其中运动,目标是找到全局最小值。
传统算法通过计算反梯度找到的方向来更新粒子的位置,使得粒子在没有任何物理意义的情况下从一个位置跳到另一个位置。这可以看作是一个充满能量的不稳定系统。
动量算法引入的基本思想是通过考虑表面和粒子之间的相互作用来更新模型参数,就像你在物理系统中一样。
在现实世界中,系统无法在零时间内将粒子从一个点传送到另一个点并且不损失能量。系统的初始能量会因为外部力量的作用以及速度随时间的变化而丧失。
特别地,我们可以使用一个物体(粒子)在表面(损失表面)上滑动,并受到一个动摩擦力的类比,这个力随着时间推移减少其能量和速度。在机器学习中,我们将摩擦系数称为动量,但实际上,我们可以像在物理学中那样进行推理。因此,给定一个摩擦系数,
(一个取值范围在[0,1]之间,但通常在[0.9, 0.999]范围内的超参数),动量算法的更新规则由以下方程给出:

在这里,
是粒子的矢量速度(矢量的每个分量是特定维度上的速度)。速度的类比是自然的,因为在一维中,位置对时间的导数就是速度。
该方法考虑了粒子在上一步所达到的矢量速度,并且对于那些朝不同方向运动的分量进行减速,同时对朝相同方向运动的分量进行加速,以便进行后续更新。
通过这种方式,系统的总体能量得以减少,从而减少了振荡并加速了收敛过程,正如我们从下图中看到的那样,图中显示了香草算法(左侧)和动量算法(右侧)之间的差异:

这里是香草算法(左)和动量算法(右)的可视化表示。动量算法导致较少的损失振荡,并且更快地达到最小值。
ADAM
香草算法和动量算法将
参数视为常量:每个网络参数的更新强度(步长)都是相同的;没有区分与高频或低频特征相关的参数。为了解决这个问题并提高优化算法的效率,开发了一整套新的算法,称为自适应学习率优化方法。
这些算法背后的理念是为网络中的每个参数分配不同的学习率,从而使用适应神经元专门提取的特征类型(或者一般来说,适应神经元看到的不同输入特征)的学习率来更新它们:与高频特征相关联的小更新,反之则较大的更新。自适应矩估计(ADAM)并不是第一个被开发的自适应方法,但它是最常用的,因为它在许多不同的任务中超过了几乎所有其他自适应和非自适应算法:它提高了模型的泛化能力,同时加快了收敛速度。
作为一种自适应方法,它为模型中的每个参数创建一个学习率,如下所示:

算法的作者决定考虑梯度(平方)的变化以及它们的方差:

第一个项是梯度的指数移动平均(即一阶动量的估计),第二个项是梯度平方的指数移动平均(二阶动量的估计)。
和
都是具有
分量的向量,且都已初始化为 0。
和
是指数移动平均的衰减因子,并且是算法的超参数。
和
向量的零初始化使得它们的值接近 0,尤其是在衰减因子接近 1 时(因此衰减率较低)。
这是一个问题,因为我们正在估计接近零的值,而没有受到任何可能的更新规则的影响。为了解决这个问题,作者建议通过以下方式来修正一阶和二阶动量:

最后,他们建议了一种受其他自适应算法启发的更新规则(如 Adadelta 和 RMSProp,本书中未解释):

他们建议我们使用接近 1 的衰减率,并为 epsilon 参数设置一个非常小的值(它仅用于避免除以零的情况)。
为什么使用一阶和二阶矩估计以及这种更新规则来更新网络中的每个单独参数会改善模型的速度收敛性,并提高模型的泛化能力?
有效学习率,
,在训练过程中会根据每个参数进行自适应调整,并考虑每个神经元输入特征的出现频率。如果与当前参数相关的计算偏导数不为零,比如与该神经元相关的输入特征出现频繁时,分母会增加。出现频率越高,训练过程中更新步长就会越小。
如果偏导数几乎每次都接近零,更新步长将几乎保持不变,且在训练过程中从未改变大小。
迄今为止我们展示的每一种梯度下降优化算法,都需要我们计算损失函数的梯度。由于神经网络可以逼近任何函数且其拓扑结构可能非常复杂,那么我们如何有效地计算复杂函数的梯度呢?通过使用数据流图表示计算过程,并结合反向传播算法,便可以解决这个问题。
反向传播与自动微分
计算偏导数是训练神经网络时需要重复上千上万次的过程,因此,这个过程必须尽可能高效。
在之前的章节中,我们展示了如何通过使用损失函数,建立模型输出、输入和标签之间的联系。如果我们用图表示整个神经网络架构,就可以很容易地看到,对于一个输入实例,我们只是按顺序执行数学运算(输入乘以参数,将这些乘法结果相加,并将非线性函数应用于总和)。在这个图的输入端,我们有来自数据集的输入样本。图的输出节点是预测值;这个图可以看作是一组复合函数,类型如下:

一个具有两个输入的神经元的输出,
和
,使用 ReLU 激活函数的结果如下:

在前面的方程中使用的函数如下:
-
是输入与参数的乘积函数 -
是两个值的求和函数 -
是修正线性单元(ReLU)激活函数
因此,我们可以将输出神经元表示为这些函数的组合:

请记住,变量不是函数的输入值,而是模型参数
。我们感兴趣的是计算损失函数的偏导数,以便训练网络。我们使用梯度下降算法来完成这个过程。作为一个简单的例子,我们可以考虑一个简单的损失函数:

要计算关于变量的损失梯度 (
),可以应用链式法则(复合函数导数的法则):

使用莱布尼茨符号,可以更容易地看出如何应用链式法则来计算任何可微函数的偏导数,该函数表示为一个图(因此也可以表示为一组复合函数):

最终,这只是将操作表示为复合函数的问题,使用图表示是一种自然的方式。我们可以将图的节点与函数关联:它的输入是函数的输入;该节点执行函数计算并输出结果。此外,节点可以具有属性,例如在计算相对于其输入的偏导数时应用的公式。
此外,图可以在两个方向上遍历。我们可以沿着正向方向遍历(反向传播算法的正向传播),从而计算损失值。我们也可以沿着反向方向遍历,应用每个节点与输入相关的输出对输入的导数公式,并将来自前一个节点的值与当前节点的值相乘来计算偏导数。这就是链式法则的应用。
将计算表示为图形使我们能够通过计算复杂函数的梯度来执行自动微分。我们只考虑单个操作,并仅查看节点的输入和输出。
在图上应用链式法则有两种不同的方式——正向模式和反向模式。关于正向模式和反向模式下自动微分的详细解释超出了本书的范围;然而,在接下来的章节中,我们将看到 TensorFlow 如何在反向模式下实现自动微分,以及它是如何应用链式法则来计算损失值并反向遍历图的
次。与在正向模式下实现它相比,反向模式下的自动微分依赖于输入的基数,而不是网络的参数数量(现在可以很容易理解为什么 TensorFlow 在反向模式下实现自动微分;神经网络可能有数百万个参数)。
到目前为止,我们描述了可以应用于计算损失函数以使其适合训练数据的优化算法和策略。我们通过使用一个被我们的神经网络近似的通用函数来实现这一点。实际上,我们只引入了一个神经网络架构:全连接架构。然而,根据数据集类型,还有几种不同的神经网络架构可以用来解决不同的问题。
神经网络的一个优点是其能够执行不同的任务,这取决于所使用的神经元拓扑结构。
全连接配置是对输入的全局视图——每个神经元都看到了一切。然而,有些类型的数据不需要完整的视图就可以正确地被神经网络使用,或者在全连接配置下是计算不可行的。想象一下具有数百万像素的高分辨率图像;我们必须将每个神经元连接到每个像素,从而创建一个参数数量等于像素数乘以神经元数的网络:一个只有两个神经元的网络将导致 ![] 参数——这是完全无法处理的!
用于处理图像的架构,也许是过去几年中发展的最重要的神经层,是卷积神经网络。
卷积神经网络
卷积神经网络(CNNs)是现代计算机视觉、语音识别甚至自然语言处理应用的基本构建块。在本节中,我们将描述卷积算子,它在信号分析领域的使用以及在机器学习中的应用。
卷积算子
信号理论为我们提供了理解卷积操作所需的所有工具:为什么它在许多不同领域中被广泛使用以及为什么 CNNs 如此强大。当信号应用于其输入时,卷积操作用于研究某些物理系统的响应。不同的输入刺激可以使系统S产生不同的输出,并且可以使用卷积操作来建模系统的行为。
让我们从一维情况开始,引入线性时不变(LTI)系统的概念。
如果系统S接受一个输入信号并产生一个输出信号 ,则称该系统为 LTI 系统,如果以下属性成立:
-
线性性:
![]()
-
时间不变性😗*
**
是否可以通过分析 LTI 系统对 Dirac Delta 函数 δ(t) 的响应来分析其行为?δ(t) 是一个函数,其在其定义域内的每个点的值均为零,除了在
处。 在
处,它假设一个值,使其定义成立:

直观地说,将 δ(t) 应用到一个函数 φ(t) 上意味着在 0 处对 φ(t) 进行采样。因此,如果我们将 δ(t) 作为系统 S 的输入,则我们得到系统对单位冲击的响应,单位冲击以零为中心。当输入是 Dirac Delta 函数时,系统的输出称为系统的冲激响应,并通过以下方程表示:

系统的冲激响应具有重要的基础性意义,因为它允许我们计算 LTI 系统对任何输入的响应。
一个通用信号 x(t) 可以看作是它在每个瞬间 t 所取的值的总和。这可以通过将 δ(t) 应用到 x 域的每个点并进行平移来建模:

该公式是两个信号之间卷积的定义。
那么,为什么卷积操作对 LTI 系统的研究很重要呢?
给定 x(t) 作为通用输入信号,h(t) 作为 LTI 系统的冲激响应,我们得到以下结果:

卷积的结果表示 LTI 系统的行为,该系统通过其冲激响应 h(t) 进行建模,当 x(t) 是其输入时。这是一个重要的结果,因为它展示了冲激响应如何完全表征系统,并且卷积操作如何用于分析 LTI 系统在给定任何输入信号时的输出。
卷积操作是交换的,操作的结果是一个函数(一个信号)。
到目前为止,我们只考虑了连续情况,但在离散域上有一个自然的推广。如果
和
定义在
上,则卷积按如下方式计算:

2D 卷积
我们在二维情况下引入的 1D 卷积的推广是自然的。特别是,图像可以被看作是二维离散信号。在二维情况下,Dirac Delta 函数的对应物是 Kronecker Delta 函数,它可以独立于所使用空间的维度来表示。它被视为一个张量δ,其分量为:

图像可以被看作是 LTI 系统的二维版本。在这种情况下,我们讨论的是 线性空间不变 (LSI) 系统。
在二维离散情况下,卷积操作定义如下:

图像是有限维度的信号,具有明确的空间范围。这意味着之前介绍的公式变成了以下形式:

在这里,我们有以下内容:
-
是输入图像 -
是卷积滤波器(也称为卷积核)本身,而
是其边长 -
是输出像素,位于
位置
我们描述的操作是对输入图像的每一个(i,j)位置执行的,只有当卷积滤波器与该位置完全重叠时,它才会滑动在输入图像上:

输入图像(左侧)与卷积核之间的卷积操作产生右侧的特征图
如上图所示,不同的卷积滤波器从输入图像中提取不同的特征。事实上,在上图中,我们可以看到这个矩形滤波器(Sobel 滤波器)是如何提取输入图像的边缘的。用不同的卷积滤波器对图像进行卷积意味着必须提取不同的输入特征,卷积核能够捕捉到这些特征。在卷积神经网络出现之前,正如我们将在下一节看到的那样,我们必须手动设计能够提取所需特征的卷积核,以解决当前的任务。
有两个额外的参数没有在前面的公式中显示出来,它们控制卷积操作的执行方式。这些参数是水平和垂直步幅;它们告诉操作在水平和垂直方向上每次移动卷积核时需要跳过多少像素。通常,水平和垂直步幅是相等的,并用字母 S 表示。
如果输入图像的边长为
,那么通过与大小为 k 的卷积核进行卷积所产生的输出信号的分辨率可以按以下方式计算:

卷积在体积间的二维运算
到目前为止,我们只考虑了灰度图像的情况,即只有一个通道的图像。我们在现实生活中常见的图像都是 RGB 图像,也就是有三个颜色通道的图像。当输入图像有多个通道时,卷积操作同样有效;事实上,它的定义已经稍微变化,以便让卷积操作跨越每一个通道。
这个扩展版本要求卷积滤波器的通道数与输入图像相同;简而言之,如果输入图像有三个通道,卷积核也必须有三个通道。这样,我们就将图像视为二维信号的堆叠;我们称这些为体积(volumes)。
作为一个体积,每个图像(或卷积核)由三元组(W,H,D)来标识,其中 W、H 和 D 分别是宽度、高度和深度。
通过将图像和卷积核视为体积,我们可以将它们视为无序集合。事实上,通道的顺序(RGB,BGR)仅仅改变了软件对数据的解释方式,而内容保持不变:

这种推理使我们能够扩展之前的公式,从而使其考虑输入的深度:

这个卷积操作的结果叫做特征图。即使卷积是在体积之间进行的,输出也是一个具有单一深度的特征图,因为卷积操作将已生成的特征图相加,以考虑所有共享相同空间(x,y)位置的像素的信息。事实上,将得到的 D 个特征图相加是一种将一组二维卷积处理为单个二维卷积的方法。
这意味着结果激活图的每个位置,O,都包含了从相同输入位置通过其完整深度捕获的信息。这就是卷积操作背后的直观思想。
好的;现在我们已经掌握了在一维和二维空间维度中的卷积操作;我们还引入了卷积核高亮的概念,即通过定义卷积核值是一种手动操作,其中不同的卷积核可以从输入图像/体积中提取不同的特征。
卷积核的定义过程是纯粹的工程工作,定义它们并不容易:不同的任务可能需要不同的卷积核;其中一些卷积核以前从未定义过,而大多数卷积核的设计几乎是不可能的,因为某些特征只能通过处理过的信号来提取,这意味着我们必须在另一个卷积操作的结果上应用卷积操作(卷积操作的级联)。
卷积神经网络解决了这个问题:我们不再手动定义卷积核,而是可以定义由神经元构成的卷积核。
我们可以通过与多个滤波器体积进行卷积并将它们组合,来从输入体积中提取特征,同时考虑提取新输入的特征图,作为新的卷积层的输入。
网络越深,提取的特征越抽象。CNNs 的一个最大优点是能够将提取的特征进行组合,从由第一层卷积层提取的原始、基础特征,到最后一层提取的高级抽象特征,这些高级特征是由其他层提取的低级特征的组合学习而成:

CNNs 学习在第一层提取低级特征;随着网络变得更深,提取的特征的抽象层次也会增加。图片来自 Zeiler 和 Fergus,2013 年。
卷积层相对于全连接层的另一个优点是其局部视图性质。
要处理一张图像,全连接层必须将输入图像线性化,并从每个像素值到该层的每个神经元创建连接。这样内存需求非常大,并且让每个神经元看到整个输入并不是提取有意义特征的理想方式。
由于其特性,某些特征不像全连接层捕获的那样是全局的,而是局部的。例如,物体的边缘是特定输入区域的局部特征,而不是整个图像的特征。因此,CNNs 可以学习仅提取局部特征,并在后续层中将其组合。卷积架构的另一个优点是其较少的参数数量:它们不需要查看(从而创建连接)整个输入;它们只需要查看局部感受野。卷积操作需要更少的参数来提取有意义的特征图,这些特征图都捕捉了输入体积的局部特征。
CNNs 通常与另一层一起使用,称为池化层。在不深入探讨该操作细节的情况下(它在今天的架构中往往被避免),我们可以将其视为与卷积操作结构相同的操作(因此是一个在输入的水平和垂直方向上移动的窗口),但没有可学习的内核。在输入的每个区域中,应用一个不可学习的函数。该操作的目的是减少卷积操作生成的特征图的大小,从而减少网络的参数数量。
为了让我们了解常见的卷积神经网络架构是什么样子,以下图展示了 LeNet 5 架构,它使用了卷积层、最大池化(最大池化是一种池化操作,其中不可学习的函数是窗口内的最大值操作)以及全连接层,目的是对手写数字图像进行 10 类分类:

LeNet 5 架构——每个平面是一个特征图。来源:Gradient-Based Learning Applied to Document Recognition, Yann LeCun 等——1998 年
定义像 LeNet 5 这样的网络架构是一门艺术——没有关于可以使用的层数、要学习的卷积滤波器数量或全连接层中神经元数量的精确规则。此外,甚至选择隐藏层的正确激活函数也是另一个要搜索的超参数。复杂模型不仅在可学习参数方面丰富,而且在调整的超参数方面也很丰富,使得定义深度架构非常复杂和具有挑战性。
体积之间的卷积允许我们做一些花哨的事情,例如用 1 x 1 x D 卷积层替换每个全连接层,并在网络内部使用 1 x 1 x D 卷积来减少输入的维度。
1 x 1 x D 卷积
卷积是最先进模型的重要构建模块,因为它们可以用于不同的目标。
一个目标是将它们用作降维技术。我们通过一个例子来理解这一点。
如果将卷积操作应用于一个输入体积为
,并且与一组大小为
的滤波器进行卷积,每个滤波器的尺寸为
,则特征数从 512 降至
。输出体积现在的形状为
。
一个
卷积也等效于一个全连接层。主要区别在于卷积操作符的性质和全连接层的架构结构:后者要求输入具有固定大小,而前者接受每个具有大于或等于
空间范围的体积作为输入。因此,一个
卷积可以替代任何全连接层,因为它们是等效的。
此外,
卷积不仅将输入的特征减少到下一层,还引入了新的参数和新的非线性到网络中,这可能有助于提高模型的准确性。
当一个
卷积放置在分类网络的末尾时,它的作用就像一个全连接层,但是不要把它看作是降维技术,更直观地看待它作为一个输出形状为
的张量的层。输出张量的空间范围(由 W 和 H 标识)是动态的,并由网络分析的输入图像的位置确定。
如果网络定义时输入大小为 200 x 200 x 3,并且我们给定一个相同大小的图像作为输入,输出将是一个包含
和
的地图。然而,如果输入图像的空间范围大于
,则卷积网络将分析输入图像的不同位置(就像标准卷积一样,因为不可能将整个卷积架构视为具有自己内核大小和步幅参数的卷积操作),并生成一个包含
和
的张量。这是全连接层无法实现的,因为全连接层限制了网络只接受固定大小的输入并生成固定大小的输出。
卷积也是语义分割网络的基本构建模块,正如我们将在接下来的章节中看到的那样。
卷积层、池化层和全连接层是几乎所有神经网络架构的基本构建模块,这些架构如今被广泛用于解决计算机视觉任务,如图像分类、目标检测、语义分割、图像生成等许多问题!
我们将在接下来的章节中使用 TensorFlow 2.0 实现所有这些神经网络架构。
尽管卷积神经网络(CNN)有较少的参数,但即使是这种模型,在深度配置(堆叠的卷积层)中使用时,也可能会遇到过拟合问题。
因此,任何机器学习实践者都应该了解的另一个基本话题是正则化。
正则化
正则化是处理过拟合问题的一种方法:正则化的目标是修改学习算法或模型本身,使模型不仅能在训练数据上表现良好,而且能在新的输入数据上也表现良好。
解决过拟合问题的最广泛使用的解决方案之一——可能也是最容易理解和分析的——被称为 dropout(丢弃法)。
Dropout(丢弃法)
Dropout(丢弃法)的思想是训练一个神经网络集成,并对结果进行平均,而不是仅训练一个标准的网络。Dropout 通过以
的概率丢弃神经元,从标准神经网络开始,构建新的神经网络。
当一个神经元被丢弃时,它的输出会被设为零。如下图所示:

左侧是标准的全连接架构,右侧是通过丢弃神经元得到的网络架构,这意味着在训练阶段使用了丢弃法。来源:Dropout:一种防止神经网络过拟合的简单方法 - N. Srivastava—2014
被丢弃的神经元不参与训练阶段。由于神经元在每次新的训练迭代中都是随机丢弃的,使用丢弃法使得每次训练阶段都不同。事实上,使用丢弃法意味着每次训练步骤都是在一个新的网络上执行——更好的是,这个网络具有不同的拓扑结构。
N. Srivastava 等人在Dropout: A simple way to Prevent Neural Networks from Overfitting(介绍该正则化技术的论文)中很好地解释了这一概念:
在标准神经网络中,每个参数收到的导数会告诉它如何变化,以便减少最终的损失函数,考虑到所有其他单元的行为。因此,单元可能会以一种修正其他单元错误的方式变化。
这可能会导致复杂的共适应关系。反过来,这会导致过拟合,因为这些共适应关系无法推广到未见过的数据。我们假设,对于每个隐藏单元,丢弃法通过使其他隐藏单元的存在变得不可靠,从而防止共适应。
因此,隐藏单元不能依赖其他特定单元来纠正其错误。
丢弃法在实际中效果良好,因为它防止了在训练阶段神经元之间的共适应。在接下来的章节中,我们将分析丢弃法如何工作以及它是如何实现的。
丢弃法的工作原理
我们可以通过观察丢弃法在单个神经元上的应用来分析其工作原理。假设我们有以下内容:
-
作为线性神经元 -
作为激活函数
通过使用这些方法,我们可以将丢弃法的应用——仅在训练阶段——建模为激活函数的修改:

这里,

是一个
维伯努利随机变量的向量,
。
一个伯努利随机变量具有以下概率质量分布:

这里,
是可能的结果。伯努利随机变量正确地建模了丢弃法在神经元上的应用,因为神经元以
的概率被关闭,否则保持开启。在完全连接层的通用i号神经元上应用丢弃法的情况可能是有用的(但在卷积层的单个神经元上应用时也是一样的):

这里,
。
在训练过程中,一个神经元以概率
保持激活。因此,在测试阶段,我们需要模拟训练阶段使用的神经网络集群的行为。为此,我们需要将神经元的输出按
d 的因子进行缩放。
因此,我们得到以下结果:

反向 dropout
一种稍微不同的方法——几乎所有深度学习框架中使用的做法——是使用反向 dropout。此方法包括在训练阶段缩放激活,显而易见的优势是不需要在测试阶段改变网络架构。
缩放因子是保持概率的倒数,
,因此我们得到以下结果:

反向 dropout 是 dropout 在实践中的实现方式,因为它帮助我们定义模型,并仅通过更改一个参数(保持/丢弃概率)就可以在相同的模型上进行训练和测试。
直接 dropout,即在上一节中介绍的版本,迫使你在测试阶段修改网络,因为如果不乘以
,神经元会产生比后续神经元期望的值更高的值(从而使后续神经元可能饱和或爆炸)。这就是为什么反向 dropout 更为常见的实现方式。
Dropout 和 L2 正则化
Dropout 常常与 L2 归一化和其他参数约束技术一起使用,但并非总是如此。
归一化有助于保持模型参数值较低。这样,参数就不会增长过多。简而言之,L2 归一化是损失函数的附加项,其中
是一个名为正则化强度的超参数,
是模型,
是实际值
与预测值
之间的误差函数:

很容易理解,当我们通过梯度下降进行反向传播时,这个附加项减少了更新量。如果
是学习率,那么参数
的更新量如下:

仅使用 Dropout 在更新阶段并没有任何防止参数值过大的机制。
还有两种极其容易实现的解决方案,它们甚至不需要改变模型或在损失函数中加入额外的项。这些被称为数据增强和提前停止。
数据增强
数据增强是一种简单的方法,用于增加数据集的大小。通过对训练数据应用一系列变换来实现。它的目的是让模型意识到某些输入变化是可能的,从而在各种输入数据上表现得更好。
变换集高度依赖于数据集本身。通常,在处理图像数据集时,需要应用的变换如下:
-
随机左右翻转
-
随机上下翻转
-
向输入图像添加随机噪声
-
随机亮度变化
-
随机饱和度变化
然而,在对训练集应用任何这些变换之前,我们必须问自己:这个变换对于这种数据类型、我的数据集和当前任务来说有意义吗?
试想一下输入图像的随机左右翻转:如果我们的数据集是由标有方向的箭头图像组成,并且我们训练模型来预测箭头的方向,那么翻转图像只会破坏我们的训练集。
提前停止
正如我们在第一章中介绍的,什么是机器学习?,在训练阶段测量模型在验证集和训练集上的表现是一个好习惯。
这个好习惯可以帮助我们防止过拟合,并节省大量的训练时间,因为测量指标能告诉我们模型是否开始过拟合训练数据,从而判断是否是停止训练过程的时候。
我们来思考一个分类器——我们衡量验证准确率、训练准确率和损失值。
观察损失值时,我们可以看到,随着训练过程的进行,损失在下降。当然,这仅适用于健康的训练。训练是健康的,当损失趋势下降时。你也可能只是看到由于小批量梯度下降或使用随机正则化过程(dropout)引起的波动。
如果训练过程是健康的,并且损失趋势在下降,那么训练准确率将会提高。训练准确率衡量模型对训练集的学习效果——它并不能捕捉到模型的泛化能力。而验证准确率则是衡量模型在未见过的数据上的预测能力。
如果模型在学习,验证准确率会增加。如果模型发生过拟合,验证准确率会停止增加,甚至可能开始下降,而在训练集上的准确率则达到最大值。
如果在验证准确率(或任何被监控的指标)停止增长时立即停止训练模型,那么你就能轻松有效地解决过拟合问题。
数据增强和早期停止是减少过拟合的两种方法,且不需要改变模型的架构。
然而,类似于 dropout,还有另一种常见的正则化技术,称为批量归一化,它要求我们改变使用的模型架构。这有助于加速训练过程,并让我们获得更好的性能。
批量归一化
批量归一化不仅是一种正则化技术——它也是加速训练过程的好方法。为了提高学习过程的稳定性,从而减少损失函数的波动,批量归一化通过减去批次均值并除以批次标准差来规范化某一层的输出。
在这个归一化过程中(它不是一个学习过程),批量归一化添加了两个可训练的参数:标准差参数(gamma)和均值参数(beta)。
批量归一化不仅通过减少训练过程中的波动来加速收敛,而且还帮助减少过拟合,因为它在训练过程中引入了类似于 dropout 的随机性。不同之处在于,dropout 通过显式的方式添加噪声,而批量归一化通过计算批次的均值和方差来引入随机性。
下图来自原始论文《批量归一化——通过减少内部协方差偏移加速深度网络训练》(Ioffe 等,2015),展示了在训练过程中应用的算法:

批量归一化算法。来源:批量归一化——通过减少内部协方差偏移加速深度网络训练,Ioffe 等,2015
在训练过程结束时,要求应用在训练过程中学习到的相同仿射变换。然而,和计算输入批次的均值和方差不同,使用的是训练过程中积累的均值和方差。事实上,批量归一化就像 dropout 一样,在训练阶段和推理阶段的行为是不同的。在训练阶段,它计算当前输入批次的均值和方差,而在推理阶段,它使用训练过程中积累的均值和方差。
幸运的是,由于这是一个非常常见的操作,TensorFlow 已经提供了一个可以直接使用的 BatchNormalization 层,因此我们不必担心在训练过程中统计量的积累,以及在推理阶段需要改变层的行为。
摘要
这一章可能是整本书中最理论密集的一章;然而,你至少需要对神经网络的构建模块以及机器学习中使用的各种算法有一个直观的理解,才能开始理解其中发生的事情。
我们已经了解了什么是神经网络,训练神经网络意味着什么,以及如何使用一些最常见的更新策略进行参数更新。现在,你应该对如何应用链式法则以高效计算函数的梯度有了基本的理解。
我们没有明确讨论深度学习,但在实际应用中,这就是我们所做的;请记住,堆叠神经网络层就像堆叠不同的分类器,它们结合了各自的表达能力。我们用“深度学习”这个术语来表示这一点。实际上,我们可以说,深度神经网络(一个深度学习模型)就是具有多个隐藏层的神经网络。
本章后面,我们介绍了许多关于参数化模型训练、神经网络的起源以及它们的数学公式的重要概念。理解在定义全连接层(以及其他层)、定义损失函数并使用特定的优化策略来训练模型时发生的事情,至少需要有一个直观的概念,这是极其重要的,特别是在使用诸如 TensorFlow 这样的机器学习框架时。
TensorFlow 隐藏了我们在本章中描述的所有复杂性,但理解底层发生了什么,将使你能够通过观察模型的行为来调试它。你还将了解为什么在训练阶段会发生某些事情,并知道如何解决特定问题。例如,了解优化策略将帮助你理解为什么损失函数的值在训练阶段遵循某种趋势并假定某些值,并能帮助你选择正确的超参数。
在下一章,第三章,TensorFlow 图架构中,我们将看到如何使用计算的图表示将本章中介绍的所有理论概念在 TensorFlow 中有效实现。
练习
本章充满了各种理论概念需要理解,因此,和上一章一样,不要跳过练习:
-
人工神经元和生物神经元之间有哪些相似之处?
-
神经元的拓扑结构是否会改变神经网络的行为?
-
为什么神经元需要非线性激活函数?
-
如果激活函数是线性的,那么多层神经网络与单层神经网络是一样的,为什么?
-
神经网络如何处理输入数据中的错误?
-
写出一个通用神经元的数学公式。
-
写出一个全连接层的数学公式。
-
为什么多层配置能够解决非线性可分的解问题?
-
绘制 sigmoid、tanh 和 ReLu 激活函数的图像。
-
是否总是需要将训练集标签格式化为独热编码表示?如果任务是回归问题怎么办?
-
损失函数在期望输出与模型输出之间建立了联系:为什么损失函数需要是可微的,这一点很重要?
-
损失函数的梯度表示什么?反向梯度呢?
-
什么是参数更新规则?解释经典的更新规则。
-
写出小批量梯度下降算法并解释三种可能的情景。
-
随机扰动是一个好的更新策略吗?解释这种方法的优缺点。
-
非自适应优化算法与自适应优化算法有什么区别?
-
速度与动量更新的概念有什么关系?描述动量更新算法。
-
什么是 LTI 系统?它与卷积操作有什么关系?
-
什么是特征向量?
-
卷积神经网络(CNN)是特征提取器吗?如果是,能否使用全连接层对卷积层的输出进行分类?
-
模型参数初始化的准则是什么?将每个网络参数都赋值为常数 10 是一个好的初始化策略吗?
-
直接 dropout 与倒置 dropout 有何区别?为什么 TensorFlow 实现了倒置版本?
-
当使用 dropout 时,网络参数的 L2 正则化为什么有用?
-
写出卷积在体积(volumes)之间的公式:展示当卷积核为 1 x 1 x D 时它的行为。为什么全连接层与 1 x 1 x D 卷积是等价的?
-
如果在训练分类器时,验证精度停止提高,这意味着什么?如果已经存在 dropout 层,增加 dropout 或提高 drop 概率能让网络再次提高验证精度吗?为什么或为什么不?
第二部分:TensorFlow 基础
本节展示了 TensorFlow 2.0 的工作原理及与 1.x 版本的差异。本节还涵盖了如何定义一个完整的机器学习管道,从数据获取到模型定义,并说明 TensorFlow 1.x 中的图仍然存在于 TensorFlow 2.0 中。
本节包含以下章节:
-
第三章,TensorFlow 图架构
-
第四章,TensorFlow 2.0 架构
-
第五章,高效的数据输入管道和估计器 API
第三章:TensorFlow 图形架构
TensorFlow 的最简洁和完整的解释可以在项目主页上找到(www.tensorflow.org/),它突出了库的每个重要部分。TensorFlow 是一个用于高性能数值计算的开源软件库。其灵活的架构允许在多种平台(CPU、GPU 和 TPU)上轻松部署计算,从桌面到服务器集群,再到移动设备和边缘设备。最初由 Google AI 组织内的 Google Brain 团队的研究人员和工程师开发,TensorFlow 对机器学习和深度学习提供强大支持,其灵活的数值计算核心在许多其他科学领域中都有应用。
TensorFlow 的优势和最重要的特点可以总结为以下三点:
-
高性能数值计算库:TensorFlow 可以通过导入它在许多不同的应用中使用。它用 C++ 编写,并为多种语言提供绑定。最完整、高级和广泛使用的绑定是 Python 绑定。TensorFlow 是一个高性能的计算库,可以在多个领域(不仅仅是机器学习!)中高效执行数值计算。
-
灵活的架构:TensorFlow 被设计为可以在不同的硬件(GPU、CPU 和 TPU)和不同的网络架构上工作;其抽象级别非常高,几乎可以用相同的代码在单台计算机或数据中心的机群中训练模型。
-
面向生产:TensorFlow 由 Google Brain 团队开发,作为在规模上开发和提供机器学习模型的工具。它的设计理念是简化整个设计到生产流程;该库已经准备好在生产环境中使用几个 API。
因此,TensorFlow 是一个数值计算库,请牢记这一点。您可以使用它执行它提供的任何数学操作,利用您手头所有硬件的能力,而无需进行任何与 ML 相关的操作。
在本章中,您将学习关于 TensorFlow 架构的所有必要知识:TensorFlow 是什么,如何设置您的环境来测试 1.x 和 2.0 两个版本以查看差异,您将了解到如何构建计算图;在此过程中,您还将学习如何使用 TensorBoard 可视化图形。
在本章中,您(终于!)将开始阅读一些代码。请不要只是阅读代码和相关说明;请编写您阅读的所有代码并尝试执行它们。按照设置我们需要的两个虚拟环境的说明操作,并通过编码深入了解 TensorFlow 的基础知识,这对于每个 TensorFlow 版本都是适用的。
本章将涵盖以下主题:
-
环境设置
-
数据流图
-
模型定义与训练
-
使用 Python 与图形交互
环境设置
为了理解 TensorFlow 的结构,本章中展示的所有示例将使用最新的 TensorFlow 1.x 版本:1.15;然而,我们也将设置运行 TensorFlow 2.0 所需的所有内容,因为我们将在下一章第四章,TensorFlow 2.0 架构中使用它。
本书中展示的所有示例都指定了在运行时使用的 TensorFlow 版本。作为一个库,我们只需指定所需的版本来安装它。当然,在一台系统上安装两个不同版本的同一个库是错误的。为了能够在版本之间切换,我们将使用两个不同的Python 虚拟环境。
下面是关于虚拟环境(virtualenv)是什么以及为什么它完全符合我们需求的解释,来自虚拟环境的官方介绍(docs.Python.org/3/tutorial/venv.html#introduction):
Python 应用程序通常会使用一些不包含在标准库中的包和模块。应用程序有时需要某个库的特定版本,因为该应用程序可能需要修复特定的 bug,或者该应用程序可能是使用库的过时版本编写的。
这意味着一个 Python 安装可能无法满足每个应用程序的要求。如果应用程序 A 需要特定模块的版本 1.0,但应用程序 B 需要版本 2.0,那么这些需求就发生了冲突,安装版本 1.0 或 2.0 都会导致某个应用程序无法运行。
解决这个问题的方法是创建一个虚拟环境,这是一个自包含的目录树,包含特定版本 Python 的安装以及一些附加包。
不同的应用程序可以使用不同的虚拟环境。为了解决前面提到的冲突需求问题,应用程序 A 可以拥有自己的虚拟环境,安装版本 1.0,而应用程序 B 则有另一个虚拟环境,安装版本 2.0。如果应用程序 B 需要将某个库升级到版本 3.0,这将不会影响应用程序 A 的环境。
为了以最简单的方式创建虚拟环境,我们使用pipenv:用于创建和管理virtualenv的终极工具;请参阅github.com/pypa/pipenv上的安装指南。作为跨平台工具,使用 Windows、Mac 或 Linux 没有区别。安装了pipenv后,我们只需为两个不同的 TensorFlow 版本创建这两个独立的虚拟环境。
我们将安装不带 GPU 支持的 TensorFlow,因为 tensorflow-gpu 依赖于 CUDA 和最近的 NVIDIA GPU 才能使用 CUDA 包提供的 GPU 加速。如果你拥有最近的 NVIDIA GPU,可以安装 tensorflow-gpu 包,但必须确保安装了 TensorFlow 包所需的 CUDA 版本(TensorFlow 2.0 和 TensorFlow 1.15 需要 CUDA 10)。此外,你必须确保在 virtualenvs 中安装的 tensorflow-gpu 包依赖于相同的 CUDA 版本(CUDA 10);否则,其中一个安装将正常工作,另一个则不会。然而,如果你坚持使用 TensorFlow 的 2.0 和 1.15 版本,它们都编译了对 CUDA 10 的支持,因此,在系统上安装 CUDA 10 并在其 GPU 版本中安装它们应该可以完美工作。
TensorFlow 1.x 环境
创建一个名为 tf1 的文件夹,进入其中,并运行以下命令来创建一个环境,激活它,并使用 pip 安装 TensorFlow:
# create the virtualenv in the current folder (tf1)
pipenv --python 3.7
# run a new shell that uses the just created virtualenv
pipenv shell
# install, in the current virtualenv, tensorflow
pip install tensorflow==1.15
#or for GPU support: pip install tensorflow-gpu==1.15
使用 Python 3.7 并非强制要求;TensorFlow 支持 Python 3.5、3.6 和 3.7。因此,如果你使用的发行版/操作系统安装了较旧的 Python 版本,比如 Python 3.5,你只需在 pipenv 命令中更改 Python 版本。
目前为止,一切顺利。现在,你处于一个使用 Python 3.7 并安装了 tensorflow==1.15 的环境中。为了创建 TensorFlow 2.0 的新环境,我们必须首先退出当前正在使用的 pipenv shell。一般来说,要从一个 virtualenv 切换到另一个,我们使用 pipenv shell 激活它,并通过输入 exit 退出 shell 会话。
因此,在创建第二个虚拟环境之前,只需通过输入 exit 关闭当前运行的 shell。
TensorFlow 2.0 环境
与 TensorFlow 1.x 环境相同的方式,创建一个名为 tf2 的文件夹,进入其中,并运行以下命令:
# create the virtualenv in the current folder (tf2)
pipenv --python 3.7
# run a new shell that uses the just created virtualenv
pipenv shell
# install, in the current virtualenv, tensorflow
pip install tensorflow==2.0
#or for GPU support: pip install tensorflow-gpu==2.0
本书的其余部分指出了是否应该使用 TensorFlow 1.x 或 2.0 环境,代码前使用 (tf1) 或 (tf2) 符号表示。
现在我们可以开始深入分析 TensorFlow 结构,并描述在 TensorFlow 1.x 中显式显示但在 TensorFlow 2.0 中隐藏(但仍然存在的)数据流图。由于接下来的分析关注如何构建图以及如何使用各种低级操作来构建图的细节,几乎每个代码片段都使用 TensorFlow 1.x 环境。如果你仅因为已经了解并使用 TensorFlow 1.x 所以对版本 2.0 感兴趣,可以跳过此部分;但建议有经验的用户也阅读一下。
只使用tensorflow 2.0环境并替换对tensorflow包的每次调用,tf,可以使用 TensorFlow 2 中的兼容模块;因此,要拥有一个单一的(tf2)环境,必须将每个tf.替换为tf.compat.v1.,并通过在导入 TensorFlow 包之后添加tf.compat.v1.disable_eager_execution()行来禁用即时执行。
现在我们已经完成了环境设置,接下来让我们深入了解数据流图,学习如何开始编写一些实际的代码。
数据流图
为了成为一个高效、灵活且适合生产的库,TensorFlow 使用数据流图以操作之间的关系来表示计算。数据流是并行计算中广泛使用的一种编程模型,在数据流图中,节点表示计算单元,而边表示计算单元消耗或产生的数据。
如前一章所示,第二章,神经网络与深度学习,通过图表示计算的方式,具有能够通过梯度下降运行训练参数化机器学习模型所需的前向和后向传播的优点,应用链式法则将梯度计算作为一个局部过程应用到每个节点;然而,使用图的优势不仅仅是这个。
降低抽象层次,思考使用图表示计算的实现细节,带来了以下优点:
-
并行性:通过使用节点表示操作,边表示它们的依赖关系,TensorFlow 能够识别可以并行执行的操作。
-
计算优化:作为一个图,一个众所周知的数据结构,它可以被分析以优化执行速度。例如,可以检测到图中未使用的节点并将其删除,从而优化图的大小;还可以检测到冗余操作或次优图,并将其替换为最佳替代方案。
-
可移植性:图是一种与语言无关、与平台无关的计算表示。TensorFlow 使用协议缓冲区(Protobuf),它是一种简单的、与语言无关、与平台无关且可扩展的机制,用于序列化结构化数据以存储图。这实际上意味着,使用 Python 定义的 TensorFlow 模型可以以其语言无关的表示(Protobuf)保存,并且可以在另一个用其他语言编写的程序中使用。
-
分布式执行:每个图的节点可以放置在独立的设备上,甚至在不同的机器上。TensorFlow 将负责节点之间的通信,并确保图的执行正确无误。此外,TensorFlow 本身能够将图分配到多个设备上,知道某些操作在特定设备上表现更好。
让我们描述第一个数据流图,用于计算矩阵和向量之间的乘积与和,并保存图形表示,然后使用 TensorBoard 来可视化它:
(tf1)
import tensorflow as tf
# Build the graph
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32)
x = tf.constant([[0, 10], [0, 0.5]])
b = tf.constant([[1, -1]], dtype=tf.float32)
y = tf.add(tf.matmul(A, x), b, name="result") #y = Ax + b
writer = tf.summary.FileWriter("log/matmul", tf.get_default_graph())
writer.close()
在这几行代码中,包含了 TensorFlow 构建计算图的一些特性。该计算图表示常量张量A(由 Python 变量A标识)与常量张量x(由 Python 变量x标识)之间的矩阵乘法,以及与标识为b的张量相加的结果。
计算的结果由y Python 变量表示,也就是在计算图中名为result的tf.add节点的输出。
请注意 Python 变量与计算图中的节点之间的区别:我们仅使用 Python 来描述计算图;Python 变量的名称在计算图定义中并没有意义。
此外,我们创建了tf.summary.SummaryWriter来保存我们构建的计算图的图形表示。writer对象已经创建,指定了存储图形表示的路径(log/matmul)以及通过tf.get_default_graph函数调用获得的tf.Graph对象,该函数返回默认的计算图,因为在任何 TensorFlow 应用中,至少会有一个图存在。
现在,你可以使用 TensorFlow 自带的免费数据可视化工具 TensorBoard 来可视化计算图。TensorBoard 通过读取指定--logdir路径中的日志文件,并创建一个 Web 服务器,使我们能够通过浏览器可视化我们的计算图。
要执行 TensorBoard 并可视化计算图,只需输入以下命令,并在 TensorBoard 指定的地址打开网页浏览器:
tensorboard --logdir log/matmul
以下截图展示了在 TensorBoard 中看到的构建好的计算图,以及节点结果的详细信息。该截图有助于理解 TensorFlow 是如何表示节点以及每个节点具有哪些特征的:

这是描述操作 y = Ax + b 的计算图。结果节点用红色高亮显示,右侧栏展示了该节点的详细信息。
请注意,我们只是描述了计算图——对 TensorFlow API 的调用仅仅是添加操作(节点)和它们之间的连接(边);在这个阶段并没有执行计算。在 TensorFlow 1.x 中,必须遵循以下方法——静态图定义和执行,而在 2.0 版本中,这已经不再是强制要求。
由于计算图是框架的基本构建块(在每个版本中都是如此),因此必须深入理解它,因为即使在过渡到 2.0 版本后,了解底层发生的事情仍然至关重要(这对调试也大有帮助!)。
主要结构 – tf.Graph
如前一部分所述,Python 变量的名称与节点名称之间没有关系。始终记住,TensorFlow 是一个 C++ 库,我们使用 Python 来简化图的构建。Python 简化了图的描述阶段,因为它甚至可以在不显式定义图的情况下创建一个图;实际上,定义图有两种不同的方式:
-
隐式:只需使用
tf.*方法定义一个图。如果图没有显式定义,TensorFlow 总是定义一个默认的tf.Graph,可以通过调用tf.get_default_graph来访问。隐式定义限制了 TensorFlow 应用程序的表达能力,因为它只能使用一个图。 -
显式:可以显式定义计算图,从而每个应用程序可以有多个图。这个选项具有更强的表达能力,但通常不需要,因为需要多个图的应用程序并不常见。
为了显式定义一个图,TensorFlow 允许创建 tf.Graph 对象,通过 as_default 方法创建一个上下文管理器;在该上下文内定义的每个操作都将被放置在关联的图中。实际上,tf.Graph 对象为其包含的 tf.Operation 对象定义了一个命名空间。
tf.Graph 结构的第二个特点是其 图集合。每个 tf.Graph 使用集合机制来存储与图结构相关的元数据。一个集合通过键唯一标识,其内容是一个对象/操作的列表。用户通常不需要关心集合的存在,因为它们由 TensorFlow 本身使用,以正确地定义图。
例如,在定义一个参数化的机器学习模型时,图必须知道哪些 tf.Variable 对象是学习过程中需要更新的变量,以及哪些变量不是模型的一部分,而是其他的东西(例如在训练过程中计算的均值/方差——这些是变量,但不可训练)。在这种情况下,正如我们将在下一部分看到的,当创建一个 tf.Variable 时,它默认会被添加到两个集合中:全局变量集合和可训练变量集合。
图定义——从 tf.Operation 到 tf.Tensor
数据流图是计算的表示,其中节点表示计算单元,边表示计算消耗或生成的数据。
在 tf.Graph 的上下文中,每个 API 调用定义一个 tf.Operation(节点),它可以有多个输入和输出 tf.Tensor(边)。例如,参照我们的主要示例,当调用 tf.constant([[1, 2], [3, 4]], dtype=tf.float32) 时,一个名为 Const 的新节点(tf.Operation)会被添加到从上下文继承的默认 tf.Graph 中。这个节点返回一个名为 Const:0 的 tf.Tensor(边)。
由于图中的每个节点都是唯一的,如果图中已有一个名为 Const 的节点(这是默认赋给所有常量的名称),TensorFlow 会通过附加后缀 '_1'、'_2' 等来确保名称的唯一性。如果未提供名称,正如我们的例子中所示,TensorFlow 会为每个添加的操作提供一个默认名称,并同样添加后缀以保证它们的唯一性。
输出的 tf.Tensor 与关联的 tf.Operation 具有相同的名称,并附加了 :ID 后缀。ID 是一个递增的数字,表示操作生成的输出数量。对于 tf.constant,输出只有一个张量,因此 ID=0;但也可以有多个输出的操作,在这种情况下,后缀 :0、:1 等将被添加到操作生成的 tf.Tensor 名称中。
也可以为在上下文中创建的所有操作添加名称作用域前缀——上下文由 tf.name_scope 调用定义。默认的名称作用域前缀是一个由 / 分隔的活动 tf.name_scope 上下文管理器的名称列表。为了保证作用域内定义的操作和作用域本身的唯一性,tf.Operation 使用的相同后缀追加规则也适用。
以下代码片段展示了如何将我们的基准示例包装到一个独立的图中,如何在同一 Python 脚本中创建第二个独立的图,并展示了如何使用 tf.name_scope 更改节点名称并添加前缀。首先,我们导入 TensorFlow 库:
(tf1)
import tensorflow as tf
然后,我们定义了两个 tf.Graph 对象(作用域系统使得轻松使用多个图成为可能):
g1 = tf.Graph()
g2 = tf.Graph()
with g1.as_default():
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32)
x = tf.constant([[0, 10], [0, 0.5]])
b = tf.constant([[1, -1]], dtype=tf.float32)
y = tf.add(tf.matmul(A, x), b, name="result")
with g2.as_default():
with tf.name_scope("scope_a"):
x = tf.constant(1, name="x")
print(x)
with tf.name_scope("scope_b"):
x = tf.constant(10, name="x")
print(x)
y = tf.constant(12)
z = x * y
然后,我们定义两个摘要写入器。我们需要使用两个不同的 tf.summary.FileWriter 对象来记录两个独立的图。
writer = tf.summary.FileWriter("log/two_graphs/g1", g1)
writer = tf.summary.FileWriter("log/two_graphs/g2", g2)
writer.close()
运行示例并使用 TensorBoard 可视化这两个图,使用 TensorBoard 左侧的列切换不同的“运行”。
在示例中,具有相同名称 x 的节点可以共存于同一个图中,但必须位于不同的作用域下。实际上,位于不同作用域下使得这些节点完全独立,并且是完全不同的对象。节点名称实际上不仅仅是传递给操作定义的 name 参数,而是其完整路径,包括所有的前缀。
事实上,运行脚本时,输出如下:
Tensor("scope_a/x:0", shape=(), dtype=int32)
Tensor("scope_b/x:0", shape=(), dtype=int32)
如我们所见,完整的名称不同,我们还可以看到有关生成张量的其他信息。通常,每个张量都有一个名称、类型、秩和形状:
-
名称 唯一标识计算图中的张量。通过使用
tf.name_scope,我们可以为张量名称添加前缀,从而改变其完整路径。我们还可以通过每个tf.*API 调用的name属性来指定名称。 -
类型是张量的数据类型,例如
tf.float32、tf.int8等。 -
秩在 TensorFlow 中(这与严格的数学定义不同)仅指张量的维度数量;例如,标量的秩为 0,向量的秩为 1,矩阵的秩为 2,依此类推。
-
形状是每个维度中的元素数量;例如,标量的秩为 0,形状为
(),向量的秩为 1,形状为(D0),矩阵的秩为 2,形状为(D0, D1),依此类推。
有时,可能会看到一个维度为-1的形状。这是一种特殊语法,告诉 TensorFlow 从其他已定义的维度推断出应该放置在该位置的值。通常,负形状用于tf.reshape操作,它能够改变张量的形状,只要请求的形状与张量的元素数量兼容。
在定义张量时,可能会看到一个或多个维度的值为None。在这种情况下,完整的形状定义会推迟到执行阶段,因为使用None指示 TensorFlow 在运行时才会知道该位置的值。
作为一个 C++库,TensorFlow 是严格静态类型的。这意味着每个操作/张量的类型必须在图定义时就已知。而且,这也意味着不可能在不兼容的类型之间执行操作。
仔细观察基准示例,可以看到矩阵乘法和加法操作都是在具有相同类型tf.float32的张量上进行的。由 Python 变量A和b标识的张量已经定义,确保了操作定义时的类型明确,而张量x也具有相同的tf.float32类型;但在这种情况下,它是由 Python 绑定推断出来的,Python 绑定能够查看常量值并推断出在创建操作时应使用的类型。
Python 绑定的另一个特点是,它们简化了一些常见数学操作的定义,采用了操作符重载。最常见的数学操作都有对应的tf.Operation;因此,使用操作符重载来简化图定义是很自然的。
以下表格显示了 TensorFlow Python API 中重载的可用操作符:
| Python 操作符 | 操作名称 |
|---|---|
__neg__ |
一元 - |
__abs__ |
abs() |
__invert__ |
一元 ~ |
__add__ |
二进制 + |
__sub__ |
二进制 - |
__mul__ |
二进制逐元素 * |
__floordiv__ |
二进制 // |
__truediv__ |
二进制 / |
__mod__ |
二进制 % |
__pow__ |
二进制 ** |
__and__ |
二进制 & |
__or__ |
二进制 | |
__xor__ |
二进制 ^ |
__le__ |
二进制 < |
__lt__ |
二进制 <= |
__gt__ |
二进制 > |
__ge__ |
二进制 <= |
__matmul__ |
二进制 @ |
运算符重载允许更快速的图形定义,且与它们的 tf.* API 调用完全等效(例如,使用 __add__ 与使用 tf.add 函数是一样的)。只有在需要为操作指定名称时,使用 TensorFlow API 调用才有优势。通常,在定义图形时,我们只关心给输入和输出节点赋予有意义的名称,而其他节点可以由 TensorFlow 自动命名。
使用重载运算符时,我们无法指定节点名称,因此也无法指定输出张量的名称。事实上,在基准示例中,我们使用 tf.add 方法定义加法操作,是因为我们想给输出张量一个有意义的名称(result)。实际上,这两行代码是等效的:
# Original example, using only API calls
y = tf.add(tf.matmul(A, x), b, name="result")
# Using overloaded operators
y = A @ x + b
如本节开头所述,TensorFlow 本身可以将特定的节点放置在更适合执行操作的设备上。该框架非常灵活,允许用户只使用 tf.device 上下文管理器将操作手动放置在不同的本地和远程设备上。
图形放置 – tf.device
tf.device 创建一个上下文管理器,匹配一个设备。该函数允许用户请求在其创建的上下文中创建的所有操作都放置在相同的设备上。由 tf.device 标识的设备不仅仅是物理设备;事实上,它能够识别远程服务器、远程设备、远程工作者以及不同类型的物理设备(GPU、CPU 和 TPU)。必须遵循设备规格来正确指示框架使用所需设备。设备规格的形式如下:
/job:<JOB_NAME>/task:<TASK_INDEX>/device:<DEVICE_TYPE>:<DEVICE_INDEX>
详细说明如下:
-
<JOB_NAME>是一个字母数字字符串,且不能以数字开头 -
<DEVICE_TYPE>是已注册的设备类型(例如 GPU 或 CPU) -
<TASK_INDEX>是一个非负整数,表示名为<JOB_NAME>的任务索引 -
<DEVICE_NAME>是一个非负整数,表示设备的索引;例如,/GPU:0是第一个 GPU
不需要指定设备规格的每一部分。例如,当运行单机配置并且只有一张 GPU 时,你可以使用 tf.device 将某些操作固定到 CPU 和 GPU。
因此,我们可以扩展我们的基准示例,将操作放置在我们选择的设备上。因此,可以将矩阵乘法放置在 GPU 上,因为它对这种操作进行了硬件优化,同时将其他所有操作保持在 CPU 上。
请注意,由于这只是图形描述,因此无需实际拥有 GPU 或使用 tensorflow-gpu 包。首先,我们导入 TensorFlow 库:
(tf1)
import tensorflow as tf
现在,使用上下文管理器将操作放置在不同的设备上,首先是在本地机器的第一个 CPU 上:
with tf.device("/CPU:0"):
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32)
x = tf.constant([[0, 10], [0, 0.5]])
b = tf.constant([[1, -1]], dtype=tf.float32)
然后,在本地机器的第一个 GPU 上:
with tf.device("/GPU:0"):
mul = A @ x
当设备未通过作用域强制指定时,TensorFlow 会决定哪个设备更适合放置操作:
y = mul + b
然后,我们定义总结写入器:
writer = tf.summary.FileWriter("log/matmul_optimized", tf.get_default_graph())
writer.close()
如果我们查看生成的图,我们会看到它与基准示例生成的图完全相同,有两个主要的区别:
-
输出张量没有有意义的名称,而是使用默认名称
-
点击矩阵乘法节点后,可以在 TensorBoard 中看到该操作必须在本地机器的第一个 GPU 上执行。
matmul 节点被放置在本地机器的第一个 GPU 上,而任何其他操作则在 CPU 上执行。TensorFlow 会以透明的方式处理不同设备之间的通信:

请注意,尽管我们已定义产生常量张量的常量操作,但它们的值并未显示在节点的属性中,也未显示在输入/输出属性中。
在使用静态图和会话执行的模式下,执行与图的定义是完全分离的。然而在 eager 执行模式下,这种情况不再成立,但由于本章的重点是 TensorFlow 架构,因此也值得关注使用tf.Session的执行部分:在 TensorFlow 2.0 中,会话仍然存在,但被隐藏了,正如我们将在下一章第四章,TensorFlow 2.0 架构中看到的那样。
图执行 - tf.Session
tf.Session 是 TensorFlow 提供的一个类,用于表示 Python 程序和 C++ 运行时之间的连接。
tf.Session 对象是唯一能够直接与硬件(通过 C++ 运行时)进行通信的对象,它将操作放置在指定的设备上,使用本地和分布式的 TensorFlow 运行时,目的是具体构建定义好的图。tf.Session 对象经过高度优化,一旦正确构建,它会缓存 tf.Graph,以加速执行。
作为物理资源的所有者,tf.Session 对象必须像文件描述符一样使用,以执行以下操作:
-
通过创建
tf.Session获取资源(相当于操作系统的open调用) -
使用资源(相当于对文件描述符执行
read/write操作) -
使用
tf.Session.close释放资源(相当于close调用)
通常,我们通过上下文管理器使用会话,而不是手动定义会话并处理其创建和销毁,这样会话会在代码块退出时自动关闭。
tf.Session 的构造函数相当复杂且高度可定制,因为它用于配置和创建计算图的执行。
在最简单且最常见的场景中,我们只是希望使用当前本地硬件执行先前描述的计算图,具体如下:
(tf1)
# The context manager opens the session
with tf.Session() as sess:
# Use the session to execute operations
sess.run(...)
# Out of the context, the session is closed and the resources released
有一些更复杂的场景,在这些场景中,我们可能不希望使用本地执行引擎,而是使用远程的 TensorFlow 服务器,该服务器可以访问它控制的所有设备。通过仅使用服务器的 URL(grpc://),可以通过tf.Session的target参数来实现这一点。
(tf1)
# the IP and port of the TensorFlow server
ip = "192.168.1.90"
port = 9877
with tf.Session(f"grpc://{ip}:{port}") as sess:
sess.run(...)
默认情况下,tf.Session将捕获并使用默认的tf.Graph对象,但在使用多个图时,可以通过graph参数指定要使用的图。理解为什么使用多个图不常见是很容易的,因为即使是tf.Session对象也只能一次处理一个图。
tf.Session对象的第三个参数是通过config参数指定的硬件/网络配置。该配置通过tf.ConfigProto对象指定,tf.ConfigProto对象能够控制会话的行为。tf.ConfigProto对象相当复杂,选项丰富,其中最常见和广泛使用的两个选项如下(其他选项通常用于分布式和复杂的环境):
-
allow_soft_placement:如果设置为True,则启用软设备放置。并不是每个操作都能随意地放置在 CPU 和 GPU 上,因为某些操作在 GPU 上的实现可能缺失,使用此选项可以让 TensorFlow 忽略通过tf.device指定的设备,并在图定义时当指定了不支持的设备时,将操作放置在正确的设备上。 -
gpu_options.allow_growth:如果设置为True,则改变 TensorFlow 的 GPU 内存分配器;默认的分配器会在创建tf.Session时分配所有可用的 GPU 内存,而当allow_growth设置为True时,分配器会逐步增加分配的内存量。默认的分配器采用这种方式,因为在生产环境中,物理资源完全用于tf.Session的执行,而在标准研究环境中,资源通常是共享的(GPU 是可以被其他进程使用的资源,尽管 TensorFlow 的tf.Session正在执行)。
基础示例现在可以扩展,不仅定义一个图,还可以继续有效地构建并执行它:
import tensorflow as tf
import numpy as np
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32)
x = tf.constant([[0, 10], [0, 0.5]])
b = tf.constant([[1, -1]], dtype=tf.float32)
y = tf.add(tf.matmul(A, x), b, name="result")
writer = tf.summary.FileWriter("log/matmul", tf.get_default_graph())
writer.close()
with tf.Session() as sess:
A_value, x_value, b_value = sess.run([A, x, b])
y_value = sess.run(y)
# Overwrite
y_new = sess.run(y, feed_dict={b: np.zeros((1, 2))})
print(f"A: {A_value}\nx: {x_value}\nb: {b_value}\n\ny: {y_value}")
print(f"y_new: {y_new}")
第一个sess.run调用会评估三个tf.Tensor对象,A, x, b,并返回它们的值作为numpy数组。
第二次调用,sess.run(y),按照以下方式工作:
-
y是一个操作的输出节点:回溯到它的输入。 -
递归回溯每个节点,直到找到所有没有父节点的节点。
-
评估输入;在此情况下,为
A, x, b张量。 -
跟随依赖图:乘法操作必须在将其结果与
b相加之前执行。 -
执行矩阵乘法。
-
执行加法操作。
加法是图解析的入口点(Python 变量y),并且计算结束。
因此,第一个打印调用会产生以下输出:
A: [[1\. 2.]
[3\. 4.]]
x: [[ 0\. 10\. ]
[ 0\. 0.5]]
b: [[ 1\. -1.]]
y: [[ 1\. 10.]
[ 1\. 31.]]
第三个 sess.run 调用展示了如何将外部的值作为 numpy 数组注入到计算图中,从而覆盖节点。 feed_dict 参数允许你这样做:通常,输入是通过 feed_dict 参数传递给图的,并通过覆盖为此目的而创建的 tf.placeholder 操作。
tf.placeholder 只是一个占位符,目的是当外部值没有注入到图中时抛出错误。然而,feed_dict 参数不仅仅是传递占位符的方式。事实上,前面的示例展示了它如何被用来覆盖任何节点。通过将节点由 Python 变量 b 指定的变量通过 numpy 数组覆盖,且该数组在类型和形状上必须与被覆盖的变量兼容,得到的结果如下:
y_new: [[ 0\. 11.]
[ 0\. 32.]]
基准示例已经更新,以展示以下内容:
-
如何构建一个图
-
如何保存图的图形表示
-
如何创建一个会话并执行已定义的图
到目前为止,我们已经使用了具有常量值的图,并通过 sess.run 调用的 feed_dict 参数覆盖了节点参数。然而,由于 TensorFlow 被设计用来解决复杂问题,因此引入了 tf.Variable 的概念:每个参数化的机器学习模型都可以用 TensorFlow 来定义和训练。
静态图中的变量
变量是一个对象,它在多个 sess.run 调用中保持图的状态。通过构造 tf.Variable 类的实例,变量会被添加到 tf.Graph 中。
变量由(类型,形状)对完全定义,通过调用 tf.Variable 创建的变量可以作为图中其他节点的输入;实际上,tf.Tensor 和 tf.Variable 对象在构建图时可以以相同的方式使用。
变量相对于张量具有更多的属性:变量对象必须初始化,因此必须有其初始化器;变量默认会添加到全局变量和可训练变量的图集合中。如果将变量设置为 非可训练,它可以被图用来存储状态,但优化器在执行学习过程时会忽略它。
声明图中变量有两种方式:tf.Variable 和 tf.get_variable。使用 tf.Variable 更简单,但功能较弱——第二种方法使用起来更复杂,但具有更强的表达能力。
tf.Variable
通过调用 tf.Variable 创建变量将始终创建一个新的变量,并且始终需要指定初始值。以下几行展示了如何创建一个名为 W 的变量,形状为 (5, 5, size_in, size_out),以及一个名为 B 的变量,形状为 (size_out):
w = tf.Variable(tf.truncated_normal([5, 5, size_in, size_out], stddev=0.1), name="W")
b = tf.Variable(tf.constant(0.1, shape=[size_out]), name="B")
w的初始值通过tf.truncated_normal操作生成,该操作从均值为 0、标准差为 0.1 的正态分布中采样,生成初始化张量所需的5 x 5 x size_in x size_out(总数)值,而b则使用通过tf.constant操作生成的常数值 0.1 进行初始化。
由于每次调用tf.Variable都会在图中创建一个新的变量,它是创建层的完美候选者:每个层(例如卷积层/全连接层)定义都需要创建一个新的变量。例如,以下代码行展示了两个可以用来定义卷积神经网络和/或全连接神经网络的函数定义:
(tf1)
第一个函数创建了一个 2D 卷积层(使用 5 x 5 的卷积核),并随后进行最大池化操作,将输出的空间维度减半:
def conv2D(input, size_in, size_out, name="conv"):
"""Define a 2D convolutional layer + max pooling.
Args:
input: Input tensor: 4D.
size_in: it could be inferred by the input (input.shape[-1])
size_out: the number of convolutional kernel to learn
name: the name of the operation, using name_scope.
Returns:
The result of the convolution as specified + a max pool operation
that halves the spatial resolution.
"""
with tf.name_scope(name):
w = tf.Variable(tf.truncated_normal([5, 5, size_in, size_out], stddev=0.1), name="W")
b = tf.Variable(tf.constant(0.1, shape=[size_out]), name="B")
conv = tf.nn.conv2d(input, w, strides=[1, 1, 1, 1], padding="SAME")
act = tf.nn.relu(conv + b)
tf.summary.histogram("w", w)
tf.summary.histogram("b", b)
return tf.nn.max_pool(act, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding="SAME")
第二个函数定义了一个全连接层:
(tf1)
def fc(input, size_in, size_out, name="fc"):
"""Define a fully connected layer.
Args:
input: Input tensor: 2D.
size_in: it could be inferred by the input (input.shape[-1])
size_out: the number of output neurons kernel to learn
name: the name of the operation, using name_scope.
Returns:
The linear neurons output.
"""
两个函数还使用了tf.summary模块来记录权重、偏置和激活值的直方图,这些值在训练过程中可能会发生变化。
调用tf.summary方法会自动将摘要添加到一个全局集合中,该集合由tf.Saver和tf.SummaryWriter对象使用,以将每个摘要值记录到 TensorBoard 日志目录中:
(tf1)
with tf.name_scope(name):
w = tf.Variable(tf.truncated_normal([size_in, size_out], stddev=0.1), name="W")
b = tf.Variable(tf.constant(0.1, shape=[size_out]), name="B")
act = tf.matmul(input, w) + b
tf.summary.histogram("w", w)
tf.summary.histogram("b", b)
return act
以这种方式定义的层非常适合在用户希望定义一个由多个层堆叠组成的深度学习模型,并根据一个从第一层到最后一层的输入进行训练的最常见场景。
如果训练阶段不是标准的,而是需要在不同的输入之间共享变量的值,怎么办?
我们需要使用 TensorFlow 的变量共享功能,这在使用tf.Variable创建的层定义中是不可行的,因此我们必须改用最强大的方法——tf.get_variable。
tf.get_variable
与tf.Variable类似,tf.get_variable也允许定义和创建新变量。主要的区别在于,如果变量已经定义,它的行为会发生变化。
tf.get_variable总是与tf.variable_scope一起使用,因为它通过reuse参数启用tf.get_variable的变量共享功能。以下示例阐明了这一概念:
(tf1)
with tf.variable_scope("scope"):
a = tf.get_variable("v", [1]) # a.name == "scope/v:0"
with tf.variable_scope("scope"):
b = tf.get_variable("v", [1]) # ValueError: Variable scope/v:0 already exists
with tf.variable_scope("scope", reuse=True):
c = tf.get_variable("v", [1]) # c.name == "scope/v:0"
在上述示例中,Python 变量a和c指向相同的图变量,名为scope/v:0。因此,使用tf.get_variable定义变量的层可以与tf.variable_scope结合使用,以定义或重用该层的变量。当使用对抗训练训练生成模型时,这非常有用且强大,我们将在第九章中详细讨论生成对抗网络。
与 tf.Variable 不同,在这种情况下,我们不能直接传递初始值(将值直接作为输入传递给 call 方法);我们必须始终显式使用初始化器。之前定义的层可以通过 tf.get_variable 编写(这是定义变量的推荐方式),如下所示:
(tf1)
def conv2D(input, size_in, size_out):
w = tf.get_variable(
'W', [5, 5, size_in, size_out],
initializer=tf.truncated_normal_initializer(stddev=0.1))
b = tf.get_variable(
'B', [size_out], initializer=tf.constant_initializer(0.1))
conv = tf.nn.conv2d(input, w, strides=[1, 1, 1, 1], padding="SAME")
act = tf.nn.relu(conv + b)
tf.summary.histogram("w", w)
tf.summary.histogram("b", b)
return tf.nn.max_pool(
act, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding="SAME")
def fc(input, size_in, size_out):
w = tf.get_variable(
'W', [size_in, size_out],
initializer=tf.truncated_normal_initializer(stddev=0.1))
b = tf.get_variable(
'b', [size_out], initializer=tf.constant_initializer(0.1))
act = tf.matmul(input, w) + b
tf.summary.histogram("w", w)
tf.summary.histogram("b", b)
return act
调用 conv2D 或 fc 会定义当前作用域内所需的变量,因此,为了定义两个卷积层而不发生命名冲突,必须使用 tf.variable_scope:
input = tf.placeholder(tf.float32, (None, 28,28,1))
with tf.variable_scope("first)":
conv1 = conv2d(input, input.shape[-1].value, 10)
with tf.variable_scope("second"): #no conflict, variables under the second/ scope
conv2 = conv2d(conv1, conv1.shape[-1].value, 1)
# and so on...
手动定义层是一个很好的练习,了解 TensorFlow 提供了定义每个机器学习层所需的所有原语是每个机器学习从业者应该知道的事情。然而,手动定义每一层是乏味且重复的(我们几乎每个项目都需要全连接层、卷积层、dropout 层和批量归一化层),因此,TensorFlow 已经提供了一个名为 tf.layers 的模块,其中包含所有最常用且广泛使用的层,这些层在底层使用 tf.get_variable 定义,因此,可以与 tf.variable_scope 一起使用,以共享它们的变量。
模型定义与训练
免责声明:在 TensorFlow 2.0 中,层模块已被完全移除,使用 tf.keras.layers 定义层是新的标准;然而,tf.layers 的概述仍然值得阅读,因为它展示了按层逐步推理定义深度模型是自然的做法,并且也让我们了解了从 tf.layers 到 tf.keras.layers 迁移背后的原因。
使用 tf.layers 定义模型
如上一节所示,TensorFlow 提供了定义神经网络层所需的所有原始功能:用户在定义变量、操作节点、激活函数和日志记录时需要小心,并定义一个适当的接口来处理所有情况(例如,添加或不添加偏置项、为层参数添加正则化等)。
TensorFlow 1.x 中的 tf.layers 模块和 TensorFlow 2.0 中的 tf.keras.layers 模块提供了一个出色的 API,以便以方便且强大的方式定义机器学习模型。每个 tf.layers 中的层都使用 tf.get_variable 来定义变量,因此,用这种方式定义的每个层都可以使用 tf.variable_scope 提供的变量共享功能。
之前手动定义的 2D 卷积层和全连接层在 tf.layers 中得到了清晰的呈现,并且使用它们来定义类似 LeNet 的卷积神经网络非常容易,如所示。首先,我们定义一个用于分类的卷积神经网络:
(tf1)
def define_cnn(x, n_classes, reuse, is_training):
"""Defines a convolutional neural network for classification.
Args:
x: a batch of images: 4D tensor.
n_classes: the number of classes, hence, the number of output neurons.
reuse: the `tf.variable_scope` reuse parameter.
is_training: boolean variable that indicates if the model is in training.
Returns:
The output layer.
"""
with tf.variable_scope('cnn', reuse=reuse):
# Convolution Layer with 32 learneable filters 5x5 each
# followed by max-pool operation that halves the spatial extent.
conv1 = tf.layers.conv2d(x, 32, 5, activation=tf.nn.relu)
conv1 = tf.layers.max_pooling2d(conv1, 2, 2)
# Convolution Layer with 64 learneable filters 3x3 each.
# As above, max pooling to halve.
conv2 = tf.layers.conv2d(conv1, 64, 3, activation=tf.nn.relu)
conv2 = tf.layers.max_pooling2d(conv2, 2, 2)
然后,我们将数据展平为一个 1D 向量,以便使用全连接层。请注意新形状的计算方式,以及批量大小位置的负维度:
shape = (-1,conv2.shape[1].value * conv2.shape[2].value * conv2.shape[3].value)
fc1 = tf.reshape(conv2, shape)
# Fully connected layer
fc1 = tf.layers.dense(fc1, 1024)
# Apply (inverted) dropout when in training phase.
fc1 = tf.layers.dropout(fc1, rate=0.5, training=is_training)
# Prediction: linear neurons
out = tf.layers.dense(fc1, n_classes)
return out
input = tf.placeholder(tf.float32, (None, 28, 28, 1))
logits = define_cnn(input, 10, reuse=False, is_training=True)
由于这些是 TensorFlow 原始操作的高级封装,本书无需详细说明每一层的功能,因为从层的名称和文档中已经能清楚地了解。读者被邀请熟悉官方的 TensorFlow 文档,特别是尝试使用层来定义自己的分类模型:www.tensorflow.org/versions/r1.15/api_docs/python/tf/layers.
使用基线示例并将图替换为此 CNN 定义,使用 TensorFlow 可以看到每一层都有自己的作用域,各层之间是如何连接的,并且正如第二张图所示,通过双击某一层,可以查看其内容,了解其实现方式,而无需查看代码。
下图展示了定义的类似 LeNet 的 CNN 架构。整个架构位于cnn作用域下,输入节点是一个占位符。可以直观地看到各层如何连接,以及 TensorFlow 如何通过在相同名称的模块后添加_1后缀来避免命名冲突:

双击 conv2d 块可以分析各层定义的不同组件是如何相互连接的。请注意,与我们的层实现不同,TensorFlow 开发者使用了名为BiasAdd的操作来添加偏置,而不是直接使用原始的Add操作。虽然行为相同,但语义更为清晰:

作为练习,你可以尝试通过定义一个类似刚刚展示的 CNN 来扩展基线,进而可视化并理解层的结构。
我们必须始终记住,TensorFlow 1.x 遵循图定义和会话执行的方法。这意味着即使是训练阶段,也应该在同一个tf.Graph对象中描述,然后才能执行。
自动微分 – 损失函数和优化器
TensorFlow 使用自动微分——微分器是一个包含所有必要规则的对象,用于构建一个新的图,该图对每个经过的节点计算其导数。TensorFlow 1.x 中的 tf.train 模块包含最常用的微分器类型,这些微分器在这里被称为优化器。在该模块中,除了其他优化器,还可以找到 ADAM 优化器(tf.train.AdamOptimizer)和标准的梯度下降优化器(tf.train.GradientDescentOptimizer)。每个优化器都是实现了通用接口的对象。该接口标准化了如何使用优化器来训练模型。执行一个小批量梯度下降步骤仅仅是执行 Python 循环中的训练操作;也就是说,执行每个优化器的.minimize方法返回的操作。
正如你从前一章中呈现的理论中所了解的,第二章,神经网络与深度学习,要使用交叉熵损失训练分类器,必须对标签进行独热编码。TensorFlow 有一个模块,tf.losses,其中包含了最常用的损失函数,它们也能够自行执行标签的独热编码。此外,每个损失函数都期望接收 logits 张量作为输入;即模型的线性输出,未应用 softmax/sigmoid 激活函数。logits 张量的名称是 TensorFlow 的设计选择:即使没有对其应用 sigmoidal 转换,它仍然被这样称呼(一个更好的选择是将这个参数命名为 unscaled_logits)。
这种选择的原因是让用户专注于网络设计,而不必担心计算某些损失函数时可能出现的数值不稳定问题;实际上,tf.losses 模块中定义的每个损失函数在数值上都是稳定的。
为了完整理解该主题,并展示优化器只是构建一个与前一个图连接的图(实际上它只添加节点),可以将记录图的基准示例与定义网络、损失函数和优化器的示例结合起来。
因此,可以按如下方式修改前面的示例。为了定义标签的输入占位符,我们可以定义损失函数(tf.losses.sparse_softmax_cross_entropy),并实例化 ADAM 优化器以最小化它:
# Input placeholders: input is the cnn input, labels is the loss input.
input = tf.placeholder(tf.float32, (None, 28, 28, 1))
labels = tf.placeholder(tf.int32, (None,))
logits = define_cnn(input, 10, reuse=False, is_training=True)
# Numerically stable loss
loss = tf.losses.sparse_softmax_cross_entropy(labels, logits)
# Instantiate the Optimizer and get the operation to use to minimize the loss
train_op = tf.train.AdamOptimizer().minimize(loss)
# As in the baseline example, log the graph
writer = tf.summary.FileWriter("log/graph_loss", tf.get_default_graph())
writer.close()
TensorBoard 允许我们可视化构建的图,如下所示:

上图显示了在定义损失函数并调用 .minimize 方法时图的结构。
ADAM 优化器是一个单独的块,它只有输入——模型(cnn)、梯度,以及 ADAM 使用的不可训练的 beta1 和 beta2 参数(在此情况下,它们在构造函数中指定并保持默认值)。如你从理论中所知,梯度是相对于模型参数计算的,以最小化损失函数:左侧的图完全描述了这个构造。由 minimize 方法调用创建的梯度块是一个命名范围,因此,可以通过双击它来分析,就像分析 TensorBoard 中的任何其他块一样。
下图展示了扩展后的梯度块:它包含一个与用于前向模型的图的镜像结构。优化器用来优化参数的每个块都是梯度块的输入。梯度是优化器(ADAM)的输入:

继理论之后,区分器(优化器)创建了一个新的图形,它与原始图形相似;这个第二个图形在梯度块内部执行梯度计算。优化器利用生成的梯度来应用它定义的变量更新规则并实施学习过程。
关于静态图和会话执行,我们到目前为止所看到的内容的简要回顾如下:
-
使用占位符或其他优化方法定义模型输入,如后续章节所示
-
将模型定义为输入的函数
-
将损失函数定义为模型输出的函数
-
定义优化器并调用
.minimize方法以定义梯度计算图
这四个步骤使我们能够定义一个简单的训练循环并训练我们的模型。然而,我们跳过了一些重要部分:
-
在训练集和验证集上的模型性能评估
-
保存模型参数
-
模型选择
此外,由于输入是通过占位符定义的,我们必须处理与输入相关的所有事情:拆分数据集、创建小批量、跟踪训练周期等。
TensorFlow 2.0 与 tfds(TensorFlow 数据集)简化并标准化了输入管道的定义,正如我们将在后续章节中看到的那样;然而,清楚地了解底层发生的事情总是有优势的,因此,继续使用占位符的低级实现对读者来说是一个很好的练习,这有助于更好地理解 tfds 解决了哪些问题。
到目前为止,你应该清楚地了解在计算图中必须执行的操作,并且应该明白 Python 仅用于构建图形和执行与学习无关的操作(因此,并不是 Python 执行变量更新,而是表示训练操作的 Python 变量在会话中执行,从而触发所有必需的操作使得模型能够学习)。
在下一节中,扩展前面的 CNN 示例,增加在 Python 端执行模型选择所需的所有功能(保存模型、衡量性能、建立训练循环并喂入输入占位符)。
使用 Python 与图形交互
Python 是训练 TensorFlow 模型的首选语言;然而,在 Python 中定义了计算图之后,使用其他语言来执行已定义的学习操作并没有限制。
始终牢记,我们使用 Python 来定义一个图形,这个定义可以导出为便携式且与语言无关的表示(Protobuf)——该表示可以在任何其他语言中使用,用来创建具体的图形并在会话中使用。
TensorFlow 的 Python API 非常完整且易于使用。因此,我们可以扩展前面的示例来衡量准确度(在图中定义准确度度量操作),并使用该指标进行模型选择。
选择最佳模型意味着在每个训练周期结束时存储模型参数,并将产生最高指标值的参数移至不同的文件夹。为此,我们必须在 Python 中定义输入管道,并使用 Python 解释器与图进行交互。
提供占位符
如前一节所述,占位符是最容易使用的方式,但也是性能最差且最容易出错的构建数据输入管道的方式。在后续章节中,将介绍一种更好且高效的解决方案。这个高效的解决方案在图中完全定义了整个输入管道。然而,占位符解决方案不仅是最简单的,而且在某些场景下(例如,在训练强化学习代理时,使用占位符进行输入是首选解决方案)也是唯一可用的。
在第一章《什么是机器学习?》中,描述了 Fashion-MNIST 数据集,我们现在将把它作为模型的输入数据集——之前定义的 CNN 将用于分类时尚物品。
幸运的是,我们不需要担心数据集下载和处理部分,因为 TensorFlow 在其keras模块中已经有一个函数,能够下载和处理数据集,以便我们获得训练图像和测试图像及其标签,并且它们的形式符合预期(28 x 28 图像):
(tf1)
from tensorflow.keras.datasets import fashion_mnist
(train_x, train_y), (test_x, test_y) = fashion_mnist.load_data()
# Scale input in [-1, 1] range
train_x = train_x / 255\. * 2 - 1
test_x = test_x / 255\. * 2 - 1
# Add the last 1 dimension, so to have images 28x28x1
train_x = np.expand_dims(train_x, -1)
test_x = np.expand_dims(test_x, -1)
train_x和test_x包含整个数据集——使用包含完整数据集的单个批次来训练模型在标准计算机上不可行;因此,使用 Python 时,我们必须在划分数据集和构建小批量时小心,以便让训练过程变得可负担。
假设我们想要训练模型 10 个周期,每个周期使用 32 个元素的批次;计算训练一个周期所需的批次数量非常容易,然后运行一个训练循环,遍历这些批次:
(tf1)
epochs = 10
batch_size = 32
nr_batches_train = int(train_x.shape[0] / batch_size)
print(f"Batch size: {batch_size}")
print(f"Number of batches per epoch: {nr_batches_train}")
当然,由于我们需要进行模型选择,我们首先需要定义一个操作,该操作根据模型和输入计算准确度,然后使用tf.summary.SummaryWriter对象将训练和验证的准确度写入同一个图中。
编写摘要
基线示例已经使用了tf.summary.SummaryWriter对象,将图写入日志目录,并使其出现在 TensorBoard 的图形部分。然而,SummaryWriter不仅可以用来写入图,还可以写入直方图、标量值、分布、日志图像和许多其他数据类型。
tf.summary包包含了易于使用的方法来记录任何数据。例如,我们对记录损失值感兴趣;损失值是一个标量,因此tf.summary.scalar是我们要使用的方法。该包文档齐全,你应该花时间去探索它:www.tensorflow.org/versions/r1.15/api_docs/python/tf.
为了扩展前面的示例,我们可以将准确度操作定义为输入占位符的函数。通过这种方式,我们可以在需要时更改输入,运行相同的操作。例如,我们可能希望在每个训练周期结束时,测量训练准确度和验证准确度。
同样的推理也适用于损失值:将损失定义为模型的函数,模型又是占位符的函数,我们可以通过更改输入来衡量损失在训练和验证输入上的变化:
(tf1)
# Define the accuracy operation over a batch
predictions = tf.argmax(logits, 1)
# correct predictions: [BATCH_SIZE] tensor
correct_predictions = tf.equal(labels, predictions)
accuracy = tf.reduce_mean(
tf.cast(correct_predictions, tf.float32), name="accuracy")
# Define the scalar summarie operation that once executed produce
# an input for the tf.train.SummaryWriter.
accuracy_summary = tf.summary.scalar("accuracy", accuracy)
loss_summary = tf.summary.scalar("loss", loss)
单个tf.train.FileWriter对象与磁盘上的一个唯一路径相关联,称为run。一个 run 表示当前实验的不同配置。例如,默认的 run 通常是训练阶段。在这个阶段,因此在这个 run 中,附加的度量(损失、准确率、图像日志等)是在训练阶段,对训练集进行度量的。
可以通过创建一个新的tf.train.FileWriter,并与之关联一个不同的路径来创建一个不同的 run,但它与另一个(训练)FileWriter的根路径相同。通过这种方式,使用 TensorBoard,我们可以在同一图表上可视化不同的曲线;例如,在同一图表上可视化验证准确度和训练准确度。当分析实验行为时,或当你希望一目了然地比较不同实验时,这个特性非常重要。
因此,既然我们希望在同一个图表中可视化训练曲线和验证曲线,我们可以创建两个不同的写入器:
writer = tf.summary.FileWriter("log/graph_loss", tf.get_default_graph())
validation_summary_writer = tf.summary.FileWriter(
"log/graph_loss/validation")
第一个是训练阶段的写入器;第二个是验证阶段的写入器。
现在,理论上,我们可以通过运行accuracy张量来测量验证准确率和训练准确率,并相应地更改输入占位符的值;这意味着我们已经能够执行模型选择:选择验证准确率最高的模型。
要保存模型参数,需要一个tf.Saver对象。
保存模型参数和模型选择
保存模型参数很重要,因为这是在中断后继续训练模型的唯一方式,也是保存模型状态的唯一方法,原因可以是训练结束,或者模型达到了最佳验证性能。
tf.Saver 是 TensorFlow Python API 提供的对象,用于保存当前模型的变量。请注意,tf.Saver 对象只保存变量,而不保存图结构!
要保存图结构和变量,必须使用 SavedModel 对象;然而,由于 SavedModel 对象与将训练好的模型投入生产更为相关,它的定义和使用将涉及到专门介绍生产的段落。
tf.Saver 对象保存可训练变量列表以及其构造函数中指定的任何其他不可训练变量。创建后,该对象提供了 save 方法,接受用于存储变量的路径。单个 Saver 对象可以用来创建多个检查点,从而在不同路径中保存在验证指标上达到最佳性能的模型,以进行模型选择。
此外,Saver 对象提供了 restore 方法,可以用来填充先前定义图的变量,在开始训练之前,重新启动一个中断的训练阶段。最终,可以在恢复调用中指定要从检查点恢复的变量列表,从而可以使用预训练的层并对其进行微调。tf.Saver 是进行迁移学习和微调模型时的主要对象。
因此,前面的示例可以扩展以在 TensorBoard 中执行测量的训练/验证准确度的记录(在代码中,准确度是基于每个 epoch 结束时 128 个元素的批次进行测量的),训练/验证损失,并使用测量的验证准确度和新的保存器进行模型选择。
我们邀请你分析并运行完整的示例,深入了解每个展示对象如何详细工作。对于任何额外的测试,始终保持 TensorFlow API 参考和文档处于打开状态,并尝试所有内容:
(tf1)
def train():
input = tf.placeholder(tf.float32, (None, 28, 28, 1))
labels = tf.placeholder(tf.int64, (None,))
logits = define_cnn(input, 10, reuse=False, is_training=True)
loss = tf.losses.sparse_softmax_cross_entropy(labels, logits)
global_step = tf.train.get_or_create_global_step()
train_op = tf.train.AdamOptimizer().minimize(loss, global_step)
writer = tf.summary.FileWriter("log/graph_loss", tf.get_default_graph())
validation_summary_writer = tf.summary.FileWriter(
"log/graph_loss/validation")
init_op = tf.global_variables_initializer()
predictions = tf.argmax(logits, 1)
# correct predictions: [BATCH_SIZE] tensor
correct_predictions = tf.equal(labels, predictions)
accuracy = tf.reduce_mean(
tf.cast(correct_predictions, tf.float32), name="accuracy")
accuracy_summary = tf.summary.scalar("accuracy", accuracy)
loss_summary = tf.summary.scalar("loss", loss)
# Input preprocessing a Python stuff
(train_x, train_y), (test_x, test_y) = fashion_mnist.load_data()
# Scale input in [-1, 1] range
train_x = train_x / 255\. * 2 - 1
train_x = np.expand_dims(train_x, -1)
test_x = test_x / 255\. * 2 - 1
test_x = np.expand_dims(test_x, -1)
epochs = 10
batch_size = 32
nr_batches_train = int(train_x.shape[0] / batch_size)
print(f"Batch size: {batch_size}")
print(f"Number of batches per epoch: {nr_batches_train}")
validation_accuracy = 0
saver = tf.train.Saver()
with tf.Session() as sess:
sess.run(init_op)
for epoch in range(epochs):
for t in range(nr_batches_train):
start_from = t * batch_size
to = (t + 1) * batch_size
loss_value, _, step = sess.run(
[loss, train_op, global_step],
feed_dict={
input: train_x[start_from:to],
labels: train_y[start_from:to]
})
if t % 10 == 0:
print(f"{step}: {loss_value}")
print(
f"Epoch {epoch} terminated: measuring metrics and logging summaries"
)
saver.save(sess, "log/graph_loss/model")
start_from = 0
to = 128
train_accuracy_summary, train_loss_summary = sess.run(
[accuracy_summary, loss_summary],
feed_dict={
input: train_x[start_from:to],
labels: train_y[start_from:to]
})
validation_accuracy_summary, validation_accuracy_value, validation_loss_summary = sess.run(
[accuracy_summary, accuracy, loss_summary],
feed_dict={
input: test_x[start_from:to],
labels: test_y[start_from:to]
})
# save values in TensorBoard
writer.add_summary(train_accuracy_summary, step)
writer.add_summary(train_loss_summary, step)
validation_summary_writer.add_summary(validation_accuracy_summary,
step)
validation_summary_writer.add_summary(validation_loss_summary, step)
validation_summary_writer.flush()
writer.flush()
# model selection
if validation_accuracy_value > validation_accuracy:
validation_accuracy = validation_accuracy_value
saver.save(sess, "log/graph_loss/best_model/best")
writer.close()
如下两张截图所示,在 TensorBoard 中查看的结果。第一张展示了通过使用两个不同的写入器,可以在同一图上绘制两条不同的曲线;第二张展示了图表选项卡:

使用两个 SummaryWriter,可以在同一图表上绘制不同的曲线。顶部的图表是验证图;底部的是损失图。橙色表示训练运行,而蓝色表示验证。

结果图——请注意,如何正确使用变量作用域使得图表易于阅读和理解。
值得注意的是,即使只训练了几个 epoch,已定义的模型已经达到了显著的性能,尽管从准确度图中可以明显看出它存在过拟合问题。
总结
在本章中,我们分析了 TensorFlow 的底层工作原理——图定义阶段与会话中的执行之间的分离,如何使用 Python API 与图进行交互,以及如何定义模型并在训练期间度量指标。
值得注意的是,本章分析了 TensorFlow 在其静态图版本中的工作原理,但该版本不再是 TensorFlow 2.0 的默认版本;然而,图仍然存在,即使在使用急切执行模式时,每个 API 调用也会产生可以在图中执行的操作,从而加速执行。正如下一章所示,TensorFlow 2.0 仍允许在静态图模式下定义模型,特别是在使用 Estimator API 定义模型时。
了解图表示法至关重要,并且至少对表示计算的优势有一个直观的理解,能够让你清楚为什么 TensorFlow 在巨大复杂的环境中,如 Google 数据中心,能够如此高效地扩展。
练习部分非常重要——它要求你解决前面部分没有介绍的问题,因为这是熟悉 TensorFlow 文档和代码库的唯一途径。记录下每个练习你解决所花的时间,并尽量依靠 TensorFlow 文档和一些 Stack Overflow 问题自行找出解决方案!
在下一章,第四章,TensorFlow 2.0 架构,你将深入探索 TensorFlow 2.0 世界:急切执行模式;自动图转换;更好、更清晰的代码库;以及基于 Keras 的方法。
练习
-
为什么仅通过查看图就能评估模型是否存在过拟合?
-
扩展基线示例,将矩阵乘法操作放置在远程设备 IP 地址为 192.168.1.12 的设备上;并在 TensorBoard 上可视化结果。
-
是否需要远程设备来放置操作?
-
扩展
define_cnn方法中定义的 CNN 架构:在卷积层的输出与激活函数之间添加一个批量归一化层(来自tf.layers)。 -
尝试使用扩展的 CNN 架构训练模型:批量归一化层添加了两个更新操作,这些操作必须在运行训练操作之前执行。熟悉
tf.control_dependencies方法,以强制执行tf.GraphKeys.UPDATE_OPS集合中的操作在训练操作之前执行(查看tf.control_dependencies和tf.get_collection的文档!)。 -
在 TensorBoard 中记录训练和验证图像。
-
上一个示例中的模型选择是否正确执行了?可能没有。扩展 Python 脚本以在完整数据集上而不仅仅是一个批次上测量准确性。
-
用
tf.metrics包中提供的准确率操作替换手动执行的准确率测量。 -
处理 fashion-MNIST 数据集,并将其转换为二进制数据集:所有标签不为 0 的项现在都标记为 1。数据集现在是不平衡的。你应该使用什么度量标准来衡量模型性能并执行模型选择?请给出理由(参见第一章,什么是机器学习?),并手动实现该度量标准。
-
使用
tf.metrics包中定义的相同度量标准,替换手动实现的度量标准。
第四章:TensorFlow 2.0 架构
在第三章,TensorFlow 图架构中,我们介绍了 TensorFlow 图的定义和执行范式,尽管它功能强大且具有较高的表达能力,但也有一些缺点,如下所示:
-
陡峭的学习曲线
-
难以调试
-
某些操作时的反直觉语义
-
Python 仅用于构建图
学习如何处理计算图可能会很困难——定义计算,而不是像 Python 解释器遇到操作时那样执行操作,是与大多数程序不同的思维方式,尤其是那些只使用命令式语言的程序。
然而,仍然建议你深入理解数据流图(DataFlow graphs)以及 TensorFlow 1.x 如何强迫用户思考,因为这将帮助你理解 TensorFlow 2.0 架构的许多部分。
调试数据流图并不容易——TensorBoard 有助于可视化图形,但它不是调试工具。可视化图形只能确认图形是否已经按照 Python 中定义的方式构建,但一些特殊情况,比如无依赖操作的并行执行(还记得上一章末尾关于 tf.control_dependencies 的练习吗?),很难发现,并且在图形可视化中不会明确显示。
Python,作为事实上的数据科学和机器学习语言,仅用于定义图形;其他可能有助于解决问题的 Python 库不能在图定义期间使用,因为不能混合图定义和会话执行。混合图定义、执行和使用其他库处理图生成数据是困难的,这使得 Python 应用程序的设计变得非常丑陋,因为几乎不可能不依赖于全局变量、集合和多个文件中共有的对象。在使用这种图定义和执行范式时,用类和函数组织代码并不自然。
TensorFlow 2.0 的发布带来了框架的多项变化:从默认启用即时执行到完全清理了 API。事实上,整个 TensorFlow 包中充满了重复和废弃的 API,而在 TensorFlow 2.0 中,这些 API 最终被移除。此外,TensorFlow 开发者决定遵循 Keras API 规范,并移除了一些不符合此规范的模块:最重要的移除是 tf.layers(我们在第三章,TensorFlow 图架构中使用过该模块),改用 tf.keras.layers。
另一个广泛使用的模块,tf.contrib,已经被完全移除。tf.contrib模块包含了社区添加的、使用 TensorFlow 的层/软件。从软件工程的角度来看,拥有一个包含多个完全不相关的大型项目的模块是一个糟糕的想法。由于这个原因,它被从主包中移除,决定将被维护的大型模块移动到独立的代码库,同时移除不再使用和不再维护的模块。
默认启用急切执行并移除(隐藏)图定义和执行范式,TensorFlow 2.0 允许更好的软件设计,从而降低学习曲线的陡峭度,并简化调试阶段。当然,鉴于从静态图定义和执行范式过渡过来,你需要有不同的思维方式——这段过渡期的努力是值得的,因为 2.0 版本从长远来看带来的优势将大大回报这一初期的努力。
在本章中,我们将讨论以下主题:
-
重新学习 TensorFlow 框架
-
Keras 框架及其模型
-
急切执行和新特性
-
代码库迁移
重新学习框架
正如我们在 第三章中介绍的,TensorFlow 图架构,TensorFlow 的工作原理是首先构建计算图,然后执行它。在 TensorFlow 2.0 中,这个图的定义被隐藏并简化;执行和定义可以混合在一起,执行流程始终与源代码中的顺序一致——在 2.0 中不再需要担心执行顺序的问题。
在 2.0 发布之前,开发者必须遵循以下模式来设计图和源代码:
-
我如何定义图?我的图是否由多个逻辑上分离的层组成?如果是的话,我需要在不同的
tf.variable_scope中定义每个逻辑模块。 -
在训练或推理阶段,我是否需要在同一个执行步骤中多次使用图的某个部分?如果是的话,我需要通过将其包裹在
tf.variable_scope中来定义该部分,并确保reuse参数被正确使用。第一次我们这样做是为了定义这个模块,其他时间我们则是复用它。 -
图的定义完成了吗?如果是的话,我需要初始化所有全局和局部变量,从而定义
tf.global_variables_initializer()操作,并尽早执行它。 -
最后,你需要创建会话,加载图,并在你想执行的节点上运行
sess.run调用。
TensorFlow 2.0 发布后,这种思维方式完全改变了,变得更加直观和自然,尤其对于那些不习惯使用数据流图的开发者。事实上,在 TensorFlow 2.0 中,发生了以下变化:
-
不再使用全局变量。在 1.x 版本中,图是全局的;即使一个变量是在 Python 函数内部定义的,它也能被看到,并且与图的其他部分是分开的。
-
不再使用
tf.variable_scope。上下文管理器无法通过设置boolean标志(reuse)来改变函数的行为。在 TensorFlow 2.0 中,变量共享由模型本身来完成。每个模型是一个 Python 对象,每个对象都有自己的变量集,要共享这些变量,你只需使用相同的模型并传入不同的输入。 -
不再使用
tf.get_variable。正如我们在第三章中看到的,TensorFlow 图架构,tf.get_variable允许你通过tf.variable_scope声明可以共享的变量。由于每个变量现在都与 Python 变量一一对应,因此移除了声明全局变量的可能性。 -
不再使用
tf.layers。在tf.layers模块内声明的每个层都会使用tf.get_variable来定义自己的变量。请改用tf.keras.layers。 -
不再使用全局集合。每个变量都被添加到一个全局变量集合中,可以通过
tf.trainable_variables()进行访问——这与良好的软件设计原则相悖。现在,访问一个对象的变量的唯一方法是访问其trainable_variables属性,该属性返回该特定对象的可训练变量列表。 -
不需要手动调用初始化所有变量的操作。
-
API 清理并移除了
tf.contrib,现在通过创建多个小而组织良好的项目来代替。
所有这些变化都是为了简化 TensorFlow 的使用,更好地组织代码库,增强框架的表达能力,并标准化其结构。
立即执行(Eager Execution)以及 TensorFlow 遵循 Keras API 是 TensorFlow 2.0 版本发布时的最重要变化。
Keras 框架及其模型
与那些已经熟悉 Keras 的人通常认为的不同,Keras 不是一个机器学习框架(如 TensorFlow、CNTK 或 Theano)的高级封装;它是一个用于定义和训练机器学习模型的 API 规范。
TensorFlow 在其tf.keras模块中实现了这个规范。特别是,TensorFlow 2.0 本身就是该规范的一个实现,因此许多一级子模块实际上只是tf.keras子模块的别名;例如,tf.metrics = tf.keras.metrics和tf.optimizers = tf.keras.optimizers。
到目前为止,TensorFlow 2.0 拥有最完整的规范实现,使其成为绝大多数机器学习研究人员的首选框架。任何 Keras API 实现都允许你构建和训练深度学习模型。它因其层次化组织而用于快速解决方案的原型设计,也因其模块化和可扩展性、以及便于部署到生产环境中而用于高级研究。TensorFlow 中的 Keras 实现的主要优势如下:
-
易用性:Keras 接口是标准化的。每个模型定义必须遵循统一的接口;每个模型由层组成,而每一层必须实现一个明确定义的接口。
从模型定义到训练循环的每个部分都标准化,使得学习使用一个实现了该规范的框架变得简单且非常有用:任何其他实现了 Keras 规范的框架看起来都很相似。这是一个巨大的优势,因为它允许研究人员阅读其他框架中编写的代码,而无需学习框架的细节。
-
模块化和可扩展性:Keras 规范描述了一组构建块,可用于组合任何类型的机器学习模型。TensorFlow 实现允许你编写自定义构建块,例如新的层、损失函数和优化器,并将它们组合起来开发新思路。
-
内置:自从 TensorFlow 2.0 发布以来,使用 Keras 不再需要单独下载 Python 包。
tf.keras模块已经内置在tensorflow包中,并且它具有一些 TensorFlow 特定的增强功能。急切执行(Eager execution)是一个一流的功能,就像高性能输入管道模块
tf.data一样。导出一个使用 Keras 创建的模型比导出一个在纯 TensorFlow 中定义的模型还要简单。以语言无关的格式导出意味着它与任何生产环境的兼容性已经配置好了,因此可以确保与 TensorFlow 一起工作。
Keras 与急切执行(eager execution)结合,成为原型化新想法、更快设计可维护和良好组织的软件的完美工具。事实上,你不再需要思考图、全局集合以及如何定义模型以便跨不同的运行共享它们的参数;在 TensorFlow 2.0 中,真正重要的是以 Python 对象的方式思考,这些对象都有自己的变量。
TensorFlow 2.0 让你在设计整个机器学习管道时,只需关注对象和类,而不需要关注图和会话执行。
Keras 已经在 TensorFlow 1.x 中出现过,但当时默认未启用急切执行,这使得你可以通过组装层定义、训练和评估模型。在接下来的几个部分,我们将演示三种使用标准训练循环构建和训练模型的方法。
在 急切执行与新特性部分,您将学习如何创建一个自定义的训练循环。经验法则是:如果任务比较标准,就使用 Keras 构建模型并使用标准的训练循环;当 Keras 无法提供简单且现成可用的训练循环时,再编写自定义的训练循环。
顺序 API
最常见的模型类型是层的堆叠。tf.keras.Sequential 模型允许你通过堆叠 tf.keras.layers 来定义一个 Keras 模型。
我们在 第三章中定义的 CNN,TensorFlow 图形架构,可以使用 Keras 顺序模型在更少的行数和更优雅的方式中重新创建。由于我们正在训练一个分类器,我们可以使用 Keras 模型的 compile 和 fit 方法分别构建训练循环并执行它。在训练循环结束时,我们还可以使用 evaluate 方法评估模型在测试集上的表现——Keras 会处理所有的模板代码:
(tf2)
import tensorflow as tf
from tensorflow.keras.datasets import fashion_mnist
n_classes = 10
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(
32, (5, 5), activation=tf.nn.relu, input_shape=(28, 28, 1)),
tf.keras.layers.MaxPool2D((2, 2), (2, 2)),
tf.keras.layers.Conv2D(64, (3, 3), activation=tf.nn.relu),
tf.keras.layers.MaxPool2D((2, 2), (2, 2)),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(1024, activation=tf.nn.relu),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(n_classes)
])
model.summary()
(train_x, train_y), (test_x, test_y) = fashion_mnist.load_data()
# Scale input in [-1, 1] range
train_x = train_x / 255\. * 2 - 1
test_x = test_x / 255\. * 2 - 1
train_x = tf.expand_dims(train_x, -1).numpy()
test_x = tf.expand_dims(test_x, -1).numpy()
model.compile(
optimizer=tf.keras.optimizers.Adam(1e-5),
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
model.fit(train_x, train_y, epochs=10)
model.evaluate(test_x, test_y)
关于前面的代码,需要注意的一些事项如下:
-
tf.keras.Sequential通过堆叠 Keras 层构建tf.keras.Model对象。每个层都期望输入并生成输出,除了第一个层。第一个层使用额外的input_shape参数,这是正确构建模型并在输入真实数据前打印总结所必需的。Keras 允许你指定第一个层的输入形状,或者保持未定义。在前者的情况下,每个后续的层都知道其输入形状,并将其输出形状向前传播给下一个层,从而使得模型中每个层的输入和输出形状一旦tf.keras.Model对象被创建就已知。而在后者的情况下,形状是未定义的,并将在输入被馈送到模型后计算,这使得无法生成 总结。 -
model.summary()打印出模型的完整描述,如果你想检查模型是否已正确定义,从而检查模型定义中是否存在拼写错误,哪个层的权重最大(按参数数量),以及整个模型的参数量是多少,这非常有用。CNN 的总结在以下代码中展示。正如我们所看到的,绝大多数参数都在全连接层中:
Model: "sequential"
__________________________________________________
Layer (type) Output Shape Param #
==================================================
conv2d (Conv2D) (None, 24, 24, 32) 832
__________________________________________________
max_pooling2d (MaxPooling2D) (None, 12, 12, 32) 0
__________________________________________________
conv2d_1 (Conv2D) (None, 10, 10, 64) 18496
__________________________________________________
max_pooling2d_1 (MaxPooling2D) (None, 5, 5, 64) 0
__________________________________________________
flatten (Flatten) (None, 1600) 0
__________________________________________________
dense (Dense) (None, 1024) 1639424
__________________________________________________
dropout (Dropout) (None, 1024) 0
__________________________________________________
dense_1 (Dense) (None, 10) 10250
==================================================
Total params: 1,669,002
Trainable params: 1,669,002
Non-trainable params: 0
-
数据集预处理步骤是在没有使用 NumPy 的情况下进行的,而是采用了急切执行。
tf.expand_dims(data, -1).numpy()展示了 TensorFlow 如何替代 NumPy(具有 1:1 的 API 兼容性)。通过使用tf.expand_dims而非np.expand_dims,我们获得了相同的结果(在输入张量的末尾添加一个维度),但是创建了一个tf.Tensor对象而不是np.array对象。然而,compile方法要求输入为 NumPy 数组,因此我们需要使用numpy()方法。每个tf.Tensor对象都必须获取 Tensor 对象中包含的相应 NumPy 值。 -
在标准分类任务的情况下,Keras 允许你用一行代码通过
compile方法构建训练循环。为了配置训练循环,该方法只需要三个参数:优化器、损失函数和需要监控的度量。在前面的例子中,我们可以看到既可以使用字符串,也可以使用 Keras 对象作为参数来正确构建训练循环。 -
model.fit是你在训练循环构建完成后调用的方法,用于在传入的数据上开始训练阶段,训练次数由指定的 epoch 数确定,并在编译阶段指定的度量标准下进行评估。批量大小可以通过传递batch_size参数进行配置。在这种情况下,我们使用默认值 32。 -
在训练循环结束时,可以在一些未见过的数据上衡量模型的性能。在这种情况下,它是对 fashion-MNIST 数据集的测试集进行测试。
Keras 在训练模型时会为用户提供反馈,记录每个 epoch 的进度条,并在标准输出中实时显示损失和度量的值:
Epoch 1/10
60000/60000 [================] - 126s 2ms/sample - loss: 1.9142 - accuracy: 0.4545
Epoch 2/10
60000/60000 [================] - 125s 2ms/sample - loss: 1.3089 - accuracy: 0.6333
Epoch 3/10
60000/60000 [================] - 129s 2ms/sample - loss: 1.1676 - accuracy: 0.6824
[ ... ]
Epoch 10/10
60000/60000 [================] - 130s 2ms/sample - loss: 0.8645 - accuracy: 0.7618
10000/10000 [================] - 6s 644us/sample - loss: 0.7498 - accuracy: 0.7896
前面代码中的最后一行是evaluate调用的结果。
Functional API
Sequential API 是定义模型的最简单和最常见的方法。然而,它不能用来定义任意的模型。Functional API 允许你定义复杂的拓扑结构,而不受顺序层的限制。
Functional API 允许你定义多输入、多输出模型,轻松共享层,定义残差连接,并且一般来说能够定义具有任意复杂拓扑结构的模型。
一旦构建完成,Keras 层是一个可调用对象,它接受一个输入张量并生成一个输出张量。它知道可以将这些层当作函数来组合,并通过传递输入层和输出层来构建一个tf.keras.Model对象。
以下代码展示了如何使用功能接口定义 Keras 模型:该模型是一个全连接的神经网络,接受一个 100 维的输入并生成一个单一的数字作为输出(正如我们将在第九章 生成对抗网络中看到的,这将是我们的生成器架构):
(tf2)
import tensorflow as tf
input_shape = (100,)
inputs = tf.keras.layers.Input(input_shape)
net = tf.keras.layers.Dense(units=64, activation=tf.nn.elu, name="fc1")(inputs)
net = tf.keras.layers.Dense(units=64, activation=tf.nn.elu, name="fc2")(net)
net = tf.keras.layers.Dense(units=1, name="G")(net)
model = tf.keras.Model(inputs=inputs, outputs=net)
作为一个 Keras 模型,model可以像任何使用 Sequential API 定义的 Keras 模型一样进行编译和训练。
子类化方法
顺序 API 和功能 API 涵盖了几乎所有可能的场景。然而,Keras 提供了另一种定义模型的方式,它是面向对象的,更灵活,但容易出错且难以调试。实际上,可以通过在__init__中定义层并在call方法中定义前向传播来子类化任何tf.keras.Model:
(tf2)
import tensorflow as tf
class Generator(tf.keras.Model):
def __init__(self):
super(Generator, self).__init__()
self.dense_1 = tf.keras.layers.Dense(
units=64, activation=tf.nn.elu, name="fc1")
self.dense_2 = f.keras.layers.Dense(
units=64, activation=tf.nn.elu, name="fc2")
self.output = f.keras.layers.Dense(units=1, name="G")
def call(self, inputs):
# Build the model in functional style here
# and return the output tensor
net = self.dense_1(inputs)
net = self.dense_2(net)
net = self.output(net)
return net
不推荐使用子类化方法,因为它将层的定义与其使用分开,容易在重构代码时犯错。然而,使用这种模型定义来定义前向传播有时是唯一可行的方法,尤其是在处理循环神经网络时。
从tf.keras.Model子类化的Generator对象本身就是一个tf.keras.Model,因此,它可以像前面所示一样,使用compile和fit命令进行训练。
Keras 可用于训练和评估模型,但 TensorFlow 2.0 通过其急切执行功能,允许我们编写自定义训练循环,从而完全控制训练过程,并能够轻松调试。
急切执行和新特性
以下是急切执行官方文档中声明的内容(www.tensorflow.org/guide/eager):
TensorFlow 的急切执行是一个命令式编程环境,立即执行操作,而不是构建图形:操作返回具体值,而不是构建一个计算图以便稍后运行。这使得开始使用 TensorFlow 并调试模型变得更加容易,并且减少了样板代码。按照本指南操作时,请在交互式 Python 解释器中运行以下代码示例。
急切执行是一个灵活的机器学习平台,适用于研究和实验,提供以下功能:
-
直观的接口:自然地组织代码并使用 Python 数据结构。快速迭代小型模型和小型数据。
-
更简单的调试:直接调用操作来检查运行中的模型并测试更改。使用标准的 Python 调试工具进行即时错误报告。
-
自然控制流:使用 Python 控制流,而不是图形控制流,从而简化了动态模型的规格说明。
如顺序 API部分所示,急切执行使你能够(以及其他特性)将 TensorFlow 作为标准 Python 库,立即由 Python 解释器执行。
正如我们在第三章中解释的,TensorFlow 图形架构,图形定义和会话执行范式不再是默认模式。别担心!如果你希望精通 TensorFlow 2.0,上一章所学的内容极为重要,它将帮助你理解框架中某些部分为什么如此运作,尤其是在使用 AutoGraph 和 Estimator API 时,接下来我们将讨论这些内容。
让我们看看在启用即时执行时,上一章的基准示例如何工作。
基准示例
让我们回顾一下上一章的基准示例:
(tf1)
import tensorflow as tf
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32)
x = tf.constant([[0, 10], [0, 0.5]])
b = tf.constant([[1, -1]], dtype=tf.float32)
y = tf.add(tf.matmul(A, x), b, name="result") #y = Ax + b
with tf.Session() as sess:
print(sess.run(y))
会话的执行生成 NumPy 数组:
[[ 1\. 10.]
[ 1\. 31.]]
将基准示例转换为 TensorFlow 2.0 非常简单:
-
不用担心图
-
不用担心会话执行
-
只需写下你希望执行的内容,随时都能执行:
(tf2)
import tensorflow as tf
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32)
x = tf.constant([[0, 10], [0, 0.5]])
b = tf.constant([[1, -1]], dtype=tf.float32)
y = tf.add(tf.matmul(A, x), b, name="result")
print(y)
前面的代码与 1.x 版本相比会产生不同的输出:
tf.Tensor(
[[ 1\. 10.]
[ 1\. 31.]], shape=(2, 2), dtype=float32)
数值当然是相同的,但返回的对象不再是 NumPy 数组,而是 tf.Tensor 对象。
在 TensorFlow 1.x 中,tf.Tensor 对象只是 tf.Operation 输出的符号表示;而在 2.0 中,情况不再是这样。
由于操作会在 Python 解释器评估时立即执行,因此每个 tf.Tensor 对象不仅是 tf.Operation 输出的符号表示,而且是包含操作结果的具体 Python 对象。
请注意,tf.Tensor 对象仍然是 tf.Operation 输出的符号表示。这使得它能够支持和使用 1.x 特性,以便操作 tf.Tensor 对象,从而构建生成 tf.Tensor 的 tf.Operation 图。
图仍然存在,并且每个 TensorFlow 方法的结果都会返回 tf.Tensor 对象。
y Python 变量,作为 tf.Tensor 对象,可以作为任何其他 TensorFlow 操作的输入。如果我们希望提取 tf.Tensor 所包含的值,以便获得与 1.x 版本中 sess.run 调用相同的结果,我们只需调用 tf.Tensor.numpy 方法:
print(y.numpy())
TensorFlow 2.0 专注于即时执行,使得用户能够设计更好工程化的软件。在 1.x 版本中,TensorFlow 有着无处不在的全局变量、集合和会话概念。
变量和集合可以从源代码中的任何地方访问,因为默认图始终存在。
会话是组织完整项目结构所必需的,因为它知道只能存在一个会话。每当一个节点需要被评估时,必须实例化会话对象,并且在当前作用域中可以访问。
TensorFlow 2.0 改变了这些方面,提高了可以用它编写的代码的整体质量。实际上,在 2.0 之前,使用 TensorFlow 设计复杂的软件系统非常困难,许多用户最终放弃了,并定义了包含所有内容的巨大的单文件项目。现在,通过遵循所有软件工程的最佳实践,设计软件变得更加清晰和简洁。
函数,而不是会话
tf.Session对象已经从 TensorFlow API 中移除。通过专注于急切执行,你不再需要会话的概念,因为操作的执行是即时的——我们在执行计算之前不再构建计算图。
这开启了一个新的场景,其中源代码可以更好地组织。在 TensorFlow 1.x 中,按照面向对象编程原则设计软件非常困难,甚至很难创建使用 Python 函数的模块化代码。然而,在 TensorFlow 2.0 中,这是自然的,并且强烈推荐。
如前面的示例所示,基础示例可以轻松转换为其急切执行的对应版本。通过遵循一些 Python 最佳实践,可以进一步改进这段源代码:
(tf2)
import tensorflow as tf
def multiply(x, y):
"""Matrix multiplication.
Note: it requires the input shape of both input to match.
Args:
x: tf.Tensor a matrix
y: tf.Tensor a matrix
Returns:
The matrix multiplcation x @ y
"""
assert x.shape == y.shape
return tf.matmul(x, y)
def add(x, y):
"""Add two tensors.
Args:
x: the left hand operand.
y: the right hand operand. It should be compatible with x.
Returns:
x + y
"""
return x + y
def main():
"""Main program."""
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32)
x = tf.constant([[0, 10], [0, 0.5]])
b = tf.constant([[1, -1]], dtype=tf.float32)
z = multiply(A, x)
y = add(z, b)
print(y)
if __name__ == "__main__":
main()
那些可以通过调用sess.run单独执行的两个操作(矩阵乘法和求和)已经被移到独立的函数中。当然,基础示例很简单,但只要想一想机器学习模型的训练步骤——定义一个接受模型和输入数据的函数,然后执行训练步骤,便是轻而易举的。
让我们来看一下这个的几个优势:
-
更好的软件组织。
-
对程序执行流程几乎完全的控制。
-
不再需要在源代码中携带
tf.Session对象。 -
不再需要使用
tf.placeholder。为了给图输入数据,你只需要将数据传递给函数。 -
我们可以为代码编写文档!在 1.x 版本中,为了理解程序某部分发生了什么,我们必须阅读完整的源代码,理解它的组织方式,理解在
tf.Session中节点评估时执行了哪些操作,只有这样我们才会对发生的事情有所了解。使用函数,我们可以编写自包含且文档齐全的代码,完全按照文档说明执行。
急切执行带来的第二个也是最重要的优势是,不再需要全局图,因此,延伸开来,也不再需要其全局集合和变量。
不再有全局变量
全局变量是一个不良的软件工程实践——这是大家一致同意的。
在 TensorFlow 1.x 中,Python 变量和图变量的概念有着严格的区分。Python 变量是具有特定名称和类型的变量,遵循 Python 语言规则:它可以通过del删除,并且只在其作用域及层次结构中更低的作用域中可见。
另一方面,图变量是声明在计算图中的图,它存在于 Python 语言规则之外。我们可以通过将图赋值给 Python 变量来声明图变量,但这种绑定并不紧密:当 Python 变量超出作用域时,它会被销毁,而图变量仍然存在:它是一个全局且持久的对象。
为了理解这一变化带来的巨大优势,我们将看看 Python 变量被垃圾回收时,基准操作定义会发生什么:
(tf1)
import tensorflow as tf
def count_op():
"""Print the operations define in the default graph
and returns their number.
Returns:
number of operations in the graph
"""
ops = tf.get_default_graph().get_operations()
print([op.name for op in ops])
return len(ops)
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32, name="A")
x = tf.constant([[0, 10], [0, 0.5]], name="x")
b = tf.constant([[1, -1]], dtype=tf.float32, name="b")
assert count_op() == 3
del A
del x
del b
assert count_op() == 0 # FAIL!
程序在第二次断言时失败,且在调用 [A, x, b] 时 count_op 的输出保持不变。
删除 Python 变量完全没有用,因为图中定义的所有操作仍然存在,我们可以访问它们的输出张量,从而在需要时恢复 Python 变量或创建指向图节点的新 Python 变量。我们可以使用以下代码来实现这一点:
A = tf.get_default_graph().get_tensor_by_name("A:0")
x = tf.get_default_graph().get_tensor_by_name("x:0")
b = tf.get_default_graph().get_tensor_by_name("b:0")
为什么这种行为不好?请考虑以下情况:
-
一旦图中定义了操作,它们就一直存在。
-
如果图中的任何操作具有副作用(参见下面关于变量初始化的示例),删除相应的 Python 变量是无效的,副作用仍然会存在。
-
通常,即使我们在一个具有独立 Python 作用域的函数中声明了
A, x, b变量,我们也可以通过根据名称获取张量的方式在每个函数中访问它们,这打破了所有封装过程。
以下示例展示了没有全局图变量连接到 Python 变量时的一些副作用:
(tf1)
import tensorflow as tf
def get_y():
A = tf.constant([[1, 2], [3, 4]], dtype=tf.float32, name="A")
x = tf.constant([[0, 10], [0, 0.5]], name="x")
b = tf.constant([[1, -1]], dtype=tf.float32, name="b")
# I don't know what z is: if is a constant or a variable
z = tf.get_default_graph().get_tensor_by_name("z:0")
y = A @ x + b - z
return y
test = tf.Variable(10., name="z")
del test
test = tf.constant(10, name="z")
del test
y = get_y()
with tf.Session() as sess:
print(sess.run(y))
这段代码无法运行,并突出了全局变量方法的几个缺点,以及 TensorFlow 1.x 使用的命名系统的问题:
-
sess.run(y)会触发依赖于z:0张量的操作执行。 -
在通过名称获取张量时,我们无法知道生成它的操作是否有副作用。在我们的例子中,操作是
tf.Variable定义,这要求在z:0张量可以被评估之前必须先执行变量初始化操作;这就是为什么代码无法运行的原因。 -
Python 变量名对 TensorFlow 1.x 没有意义:
test首先包含一个名为z的图变量,随后test被销毁并替换为我们需要的图常量,即z。 -
不幸的是,调用
get_y找到一个名为z:0的张量,它指向tf.Variable操作(具有副作用),而不是常量节点z。为什么?即使我们在图变量中删除了test变量,z仍然存在。因此,当调用tf.constant时,我们有一个与图冲突的名称,TensorFlow 为我们解决了这个问题。它通过为输出张量添加_1后缀来解决这个问题。
在 TensorFlow 2.0 中,这些问题都不存在了——我们只需编写熟悉的 Python 代码。无需担心图、全局作用域、命名冲突、占位符、图依赖关系和副作用。甚至控制流也像 Python 一样,正如我们在下一节中将看到的那样。
控制流
在 TensorFlow 1.x 中,执行顺序操作并不是一件容易的事,尤其是在操作没有显式执行顺序约束的情况下。假设我们想要使用 TensorFlow 执行以下操作:
-
声明并初始化两个变量:
y和y。 -
将
y的值增加 1。 -
计算
x*y。 -
重复此操作五次。
在 TensorFlow 1.x 中,第一个不可用的尝试是仅仅通过以下步骤声明代码:
(tf1)
import tensorflow as tf
x = tf.Variable(1, dtype=tf.int32)
y = tf.Variable(2, dtype=tf.int32)
assign_op = tf.assign_add(y, 1)
out = x * y
init = tf.global_variables_initializer()
with tf.Session() as sess:
sess.run(init)
for _ in range(5):
print(sess.run(out))
那些完成了前一章提供的练习的人,应该已经注意到这段代码中的问题。
输出节点out对assign_op节点没有显式依赖关系,因此它在执行out时从不计算,从而使输出仅为 2 的序列。在 TensorFlow 1.x 中,我们必须显式地使用tf.control_dependencies来强制执行顺序,条件化赋值操作,以便它在执行out之前执行:
with tf.control_dependencies([assign_op]):
out = x * y
现在,输出是 3、4、5、6、7 的序列,这是我们想要的结果。
更复杂的示例,例如在图中直接声明并执行循环,其中可能会发生条件执行(使用tf.cond),是可能的,但要点是一样的——在 TensorFlow 1.x 中,我们必须担心操作的副作用,在编写 Python 代码时,必须考虑图的结构,甚至无法使用我们习惯的 Python 解释器。条件必须使用tf.cond而不是 Python 的if语句来表达,循环必须使用tf.while_loop而不是 Python 的for和while语句来定义。
TensorFlow 2.x 凭借其即时执行,使得可以使用 Python 解释器来控制执行流程:
(tf2)
import tensorflow as tf
x = tf.Variable(1, dtype=tf.int32)
y = tf.Variable(2, dtype=tf.int32)
for _ in range(5):
y.assign_add(1)
out = x * y
print(out)
之前的示例是使用即时执行开发的,更容易开发、调试和理解——毕竟它只是标准的 Python 代码!
通过简化控制流程,启用了即时执行,这是 TensorFlow 2.0 中引入的主要特性之一——现在,即便是没有任何数据流图或描述性编程语言经验的用户,也可以开始编写 TensorFlow 代码。即时执行减少了整个框架的复杂性,降低了入门门槛。
来自 TensorFlow 1.x 的用户可能会开始想知道我们如何训练机器学习模型,因为为了通过自动微分计算梯度,我们需要有一个执行操作的图。
TensorFlow 2.0 引入了 GradientTape 的概念,以高效解决这个问题。
GradientTape
tf.GradientTape()调用创建了一个上下文,在该上下文中记录自动微分的操作。每个在上下文管理器内执行的操作都会被记录在带状磁带上,前提是它们的至少一个输入是可监视的并且正在被监视。
当发生以下情况时,输入是可监视的:
-
这是一个可训练的变量,通过使用
tf.Variable创建。 -
它通过在
tf.Tensor对象上调用watch方法显式地被tape监视。
tape 记录了在该上下文中执行的每个操作,以便构建已执行的前向传递图;然后,tape 可以展开以使用反向模式自动微分计算梯度。它通过调用gradient方法来实现:
x = tf.constant(4.0)
with tf.GradientTape() as tape:
tape.watch(x)
y = tf.pow(x, 2)
# Will compute 8 = 2*x, x = 8
dy_dx = tape.gradient(y, x)
在前面的示例中,我们显式地要求tape监视一个常量值,而该常量值由于其本质不可被监视(因为它不是tf.Variable对象)。
一个tf.GradientTape对象,例如tape,在调用tf.GradientTape.gradient()方法后会释放它所持有的资源。这在大多数常见场景中是可取的,但在某些情况下,我们需要多次调用tf.GradientTape.gradient()。为了做到这一点,我们需要创建一个持久的梯度 tape,允许多次调用梯度方法而不释放资源。在这种情况下,由开发者在不再需要资源时负责释放这些资源。他们通过使用 Python 的del指令删除对 tape 的引用来做到这一点:
x = tf.Variable(4.0)
y = tf.Variable(2.0)
with tf.GradientTape(persistent=True) as tape:
z = x + y
w = tf.pow(x, 2)
dz_dy = tape.gradient(z, y)
dz_dx = tape.gradient(z, x)
dw_dx = tape.gradient(w, x)
print(dz_dy, dz_dx, dw_dx) # 1, 1, 8
# Release the resources
del tape
也可以在更高阶的导数中嵌套多个tf.GradientTape对象(现在你应该能轻松做到这一点,所以我将这部分留给你做练习)。
TensorFlow 2.0 提供了一种新的、简便的方式,通过 Keras 构建模型,并通过 tape 的概念提供了一种高度可定制和高效的计算梯度的方法。
我们在前面章节中提到的 Keras 模型已经提供了训练和评估它们的方法;然而,Keras 不能涵盖所有可能的训练和评估场景。因此,可以使用 TensorFlow 1.x 构建自定义训练循环,这样你就可以训练和评估模型,并完全控制发生的事情。这为你提供了实验的自由,可以控制训练的每一个部分。例如,如第九章所示,生成对抗网络,定义对抗训练过程的最佳方式是通过定义自定义训练循环。
自定义训练循环
tf.keras.Model对象通过其compile和fit方法,允许你训练大量机器学习模型,从分类器到生成模型。Keras 的训练方式可以加速定义最常见模型的训练阶段,但训练循环的自定义仍然有限。
有些模型、训练策略和问题需要不同类型的模型训练。例如,假设我们需要面对梯度爆炸问题。在使用梯度下降训练模型的过程中,可能会出现损失函数开始发散,直到它变成NaN,这通常是因为梯度更新的大小越来越大,直到溢出。
面对这个问题,你可以使用的一种常见策略是裁剪梯度或限制阈值:梯度更新的大小不能超过阈值。这可以防止网络发散,并通常帮助我们在最小化过程中找到更好的局部最小值。有几种梯度裁剪策略,但最常见的是 L2 范数梯度裁剪。
在这个策略中,梯度向量被归一化,使得 L2 范数小于或等于一个阈值。实际上,我们希望以这种方式更新梯度更新规则:
gradients = gradients * threshold / l2(gradients)
TensorFlow 有一个 API 用于此任务:tf.clip_by_norm。我们只需访问已计算的梯度,应用更新规则,并将其提供给选择的优化器。
为了使用tf.GradientTape创建自定义训练循环以计算梯度并进行后处理,我们需要将上一章末尾开发的图像分类器训练脚本迁移到 TensorFlow 2.0 版本。
请花时间仔细阅读源代码:查看新的模块化组织,并将先前的 1.x 代码与此新代码进行比较。
这些 API 之间存在几个区别:
-
优化器现在是 Keras 优化器。
-
损失现在是 Keras 损失。
-
精度可以通过 Keras 指标包轻松计算。
-
每个 TensorFlow 1.x 符号都有一个 TensorFlow 2.0 版本。
-
不再有全局集合。记录带有需要使用的变量列表以计算梯度,而
tf.keras.Model对象必须携带其自己的trainable_variables集合。
在 1.x 版本中存在方法调用,而在 2.0 版本中,存在一个返回可调用对象的 Keras 方法。几乎每个 Keras 对象的构造函数用于配置它,它们使用call方法来使用它。
首先,我们导入tensorflow库,然后定义make_model函数:
import tensorflow as tf
from tensorflow.keras.datasets import fashion_mnist
def make_model(n_classes):
return tf.keras.Sequential([
tf.keras.layers.Conv2D(
32, (5, 5), activation=tf.nn.relu, input_shape=(28, 28, 1)),
tf.keras.layers.MaxPool2D((2, 2), (2, 2)),
tf.keras.layers.Conv2D(64, (3, 3), activation=tf.nn.relu),
tf.keras.layers.MaxPool2D((2, 2), (2, 2)),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(1024, activation=tf.nn.relu),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(n_classes)
])
然后,我们定义load_data函数:
def load_data():
(train_x, train_y), (test_x, test_y) = fashion_mnist.load_data()
# Scale input in [-1, 1] range
train_x = tf.expand_dims(train_x, -1)
train_x = (tf.image.convert_image_dtype(train_x, tf.float32) - 0.5) * 2
train_y = tf.expand_dims(train_y, -1)
test_x = test_x / 255\. * 2 - 1
test_x = (tf.image.convert_image_dtype(test_x, tf.float32) - 0.5) * 2
test_y = tf.expand_dims(test_y, -1)
return (train_x, train_y), (test_x, test_y)
然后,我们定义train()函数,实例化模型、输入数据和训练参数:
def train():
# Define the model
n_classes = 10
model = make_model(n_classes)
# Input data
(train_x, train_y), (test_x, test_y) = load_data()
# Training parameters
loss = tf.losses.SparseCategoricalCrossentropy(from_logits=True)
step = tf.Variable(1, name="global_step")
optimizer = tf.optimizers.Adam(1e-3)
accuracy = tf.metrics.Accuracy()
最后,我们需要在train函数内定义train_step函数,并在训练循环中使用它:
# Train step function
def train_step(inputs, labels):
with tf.GradientTape() as tape:
logits = model(inputs)
loss_value = loss(labels, logits)
gradients = tape.gradient(loss_value, model.trainable_variables)
# TODO: apply gradient clipping here
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
step.assign_add(1)
accuracy_value = accuracy(labels, tf.argmax(logits, -1))
return loss_value, accuracy_value
epochs = 10
batch_size = 32
nr_batches_train = int(train_x.shape[0] / batch_size)
print(f"Batch size: {batch_size}")
print(f"Number of batches per epoch: {nr_batches_train}")
for epoch in range(epochs):
for t in range(nr_batches_train):
start_from = t * batch_size
to = (t + 1) * batch_size
features, labels = train_x[start_from:to], train_y[start_from:to]
loss_value, accuracy_value = train_step(features, labels)
if t % 10 == 0:
print(
f"{step.numpy()}: {loss_value} - accuracy: {accuracy_value}"
)
print(f"Epoch {epoch} terminated")
if __name__ == "__main__":
train()
上一个示例中并没有包括模型保存、模型选择和 TensorBoard 日志记录。此外,梯度裁剪部分留给你作为练习(参见前面代码的TODO部分)。
在本章末尾,所有缺失的功能将被包括进来;与此同时,请花些时间仔细阅读新版本,并与 1.x 版本进行比较。
下一节将重点介绍如何保存模型参数、重新启动训练过程和进行模型选择。
保存和恢复模型的状态。
TensorFlow 2.0 引入了检查点对象的概念:每个继承自tf.train.Checkpointable的对象都是可序列化的,这意味着可以将其保存在检查点中。与 1.x 版本相比,在 1.x 版本中只有变量是可检查点的,而在 2.0 版本中,整个 Keras 层/模型继承自tf.train.Checkpointable。因此,可以保存整个层/模型,而不必担心它们的变量;和往常一样,Keras 引入了一个额外的抽象层,使框架的使用更加简便。保存模型有两种方式:
-
使用检查点
-
使用 SavedModel
正如我们在第三章中解释的那样,TensorFlow 图架构,检查点并不包含模型本身的描述:它们只是存储模型参数的简便方法,并通过定义将检查点保存的变量映射到 Python 中的tf.Variable对象,或者在更高层次上,通过tf.train.Checkpointable对象来让开发者正确恢复它们。
另一方面,SavedModel 格式是计算的序列化描述,除了参数值之外。我们可以将这两个对象总结如下:
-
检查点:一种将变量存储到磁盘的简便方法
-
SavedModel:模型结构和检查点
SavedModel 是一种与语言无关的表示(Protobuf 序列化图),适合在其他语言中部署。本书的最后一章,第十章,将模型投入生产,专门讲解 SavedModel,因为它是将模型投入生产的正确方法。
在训练模型时,我们在 Python 中可以获得模型定义。由于这一点,我们有兴趣保存模型的状态,具体方法如下:
-
在失败的情况下重新启动训练过程,而不会浪费之前的所有计算。
-
在训练循环结束时保存模型参数,以便我们可以在测试集上测试训练好的模型。
-
将模型参数保存在不同位置,以便我们可以保存达到最佳验证性能的模型状态(模型选择)。
为了在 TensorFlow 2.0 中保存和恢复模型参数,我们可以使用两个对象:
-
tf.train.Checkpoint是基于对象的序列化/反序列化器。 -
tf.train.CheckpointManager是一个可以使用tf.train.Checkpoint实例来保存和管理检查点的对象。
与 TensorFlow 1.x 中的tf.train.Saver方法相比,Checkpoint.save和Checkpoint.restore方法是基于对象的检查点读写;前者只能读写基于variable.name的检查点。
与其保存变量,不如保存对象,因为它在进行 Python 程序更改时更为稳健,并且能够正确地与急切执行模式(eager execution)一起工作。在 TensorFlow 1.x 中,只保存variable.name就足够了,因为图在定义和执行后不会发生变化。而在 2.0 版本中,由于图是隐藏的且控制流可以使对象及其变量出现或消失,保存对象是唯一能够保留其状态的方式。
使用tf.train.Checkpoint非常简单——你想存储一个可检查点的对象吗?只需将其传递给构造函数,或者在对象生命周期中创建一个新的属性即可。
一旦你定义了检查点对象,使用它来构建一个tf.train.CheckpointManager对象,在该对象中你可以指定保存模型参数的位置以及保留多少个检查点。
因此,前一个模型训练的保存和恢复功能只需在模型和优化器定义后,添加以下几行即可:
(tf2)
ckpt = tf.train.Checkpoint(step=step, optimizer=optimizer, model=model)
manager = tf.train.CheckpointManager(ckpt, './checkpoints', max_to_keep=3)
ckpt.restore(manager.latest_checkpoint)
if manager.latest_checkpoint:
print(f"Restored from {manager.latest_checkpoint}")
else:
print("Initializing from scratch.")
可训练和不可训练的变量会自动添加到检查点变量中进行监控,这样你就可以在不引入不必要的损失函数波动的情况下恢复模型并重新启动训练循环。实际上,优化器对象通常携带自己的不可训练变量集(移动均值和方差),它是一个可检查点的对象,被添加到检查点中,使你能够在中断时恢复训练循环的状态。
当满足某个条件(例如i % 10 == 0,或当验证指标得到改善时),可以使用manager.save方法调用来检查点模型的状态:
(tf2)
save_path = manager.save()
print(f"Checkpoint saved: {save_path}")
管理器可以将模型参数保存到构造时指定的目录中;因此,为了执行模型选择,你需要创建第二个管理器对象,当满足模型选择条件时调用它。这个部分留给你自己完成。
摘要和指标
TensorBoard 仍然是 TensorFlow 默认且推荐的数据记录和可视化工具。tf.summary包包含所有必要的方法,用于保存标量值、图像、绘制直方图、分布等。
与tf.metrics包一起使用时,可以记录聚合数据。指标通常在小批量上进行度量,而不是在整个训练/验证/测试集上:在完整数据集划分上循环时聚合数据,使我们能够正确地度量指标。
tf.metrics包中的对象是有状态的,这意味着它们能够累积/聚合值,并在调用.result()时返回累积结果。
与 TensorFlow 1.x 相同,要将摘要保存到磁盘,你需要一个文件/摘要写入对象。你可以通过以下方式创建一个:
(tf2)
summary_writer = tf.summary.create_file_writer(path)
这个新对象不像 1.x 版本那样工作——它的使用现在更加简化且功能更强大。我们不再需要使用会话并执行(sess.run(summary))来获取写入摘要的行,新的tf.summary.*对象能够自动检测它们所在的上下文,一旦计算出摘要行,就能将正确的摘要记录到写入器中。
实际上,摘要写入器对象通过调用.as_default()定义了一个上下文管理器;在这个上下文中调用的每个tf.summary.*方法都会将其结果添加到默认的摘要写入器中。
将tf.summary与tf.metrics结合使用,使我们能够更加简单且正确地衡量和记录训练/验证/测试指标,相比于 TensorFlow 1.x 版本更加简便。事实上,如果我们决定每 10 步训练记录一次计算的度量值,那么我们需要可视化在这 10 步训练过程中计算出的均值,而不仅仅是最后一步的值。
因此,在每一步训练结束时,我们必须调用度量对象的.update_state方法来聚合并保存计算值到对象状态中,然后再调用.result()方法。
.result()方法负责正确计算聚合值上的度量。一旦计算完成,我们可以通过调用reset_states()来重置度量的内部状态。当然,所有在训练阶段计算的值都遵循相同的逻辑,因为损失是非常常见的:
mean_loss = tf.metrics.Mean(name='loss')
这定义了度量的Mean,即在训练阶段传入的输入的均值。在这种情况下,这是损失值,但同样的度量也可以用来计算每个标量值的均值。
tf.summary包还包含一些方法,用于记录图像(tf.summary.image),因此可以扩展之前的示例,在 TensorBoard 上非常简单地记录标量指标和图像批次。以下代码展示了如何扩展之前的示例,记录训练损失、准确率以及三张训练图像——请花时间分析结构,看看如何进行指标和日志记录,并尝试理解如何通过定义更多函数使代码结构更加模块化和易于维护:
def train():
# Define the model
n_classes = 10
model = make_model(n_classes)
# Input data
(train_x, train_y), (test_x, test_y) = load_data()
# Training parameters
loss = tf.losses.SparseCategoricalCrossentropy(from_logits=True)
step = tf.Variable(1, name="global_step")
optimizer = tf.optimizers.Adam(1e-3)
ckpt = tf.train.Checkpoint(step=step, optimizer=optimizer, model=model)
manager = tf.train.CheckpointManager(ckpt, './tf_ckpts', max_to_keep=3)
ckpt.restore(manager.latest_checkpoint)
if manager.latest_checkpoint:
print(f"Restored from {manager.latest_checkpoint}")
else:
print("Initializing from scratch.")
accuracy = tf.metrics.Accuracy()
mean_loss = tf.metrics.Mean(name='loss')
这里,我们定义了train_step函数:
# Train step function
def train_step(inputs, labels):
with tf.GradientTape() as tape:
logits = model(inputs)
loss_value = loss(labels, logits)
gradients = tape.gradient(loss_value, model.trainable_variables)
# TODO: apply gradient clipping here
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
step.assign_add(1)
accuracy.update_state(labels, tf.argmax(logits, -1))
return loss_value, accuracy.result()
epochs = 10
batch_size = 32
nr_batches_train = int(train_x.shape[0] / batch_size)
print(f"Batch size: {batch_size}")
print(f"Number of batches per epoch: {nr_batches_train}")
train_summary_writer = tf.summary.create_file_writer('./log/train')
with train_summary_writer.as_default():
for epoch in range(epochs):
for t in range(nr_batches_train):
start_from = t * batch_size
to = (t + 1) * batch_size
features, labels = train_x[start_from:to], train_y[start_from:to]
loss_value, accuracy_value = train_step(features, labels)
mean_loss.update_state(loss_value)
if t % 10 == 0:
print(f"{step.numpy()}: {loss_value} - accuracy: {accuracy_value}")
save_path = manager.save()
print(f"Checkpoint saved: {save_path}")
tf.summary.image(
'train_set', features, max_outputs=3, step=step.numpy())
tf.summary.scalar(
'accuracy', accuracy_value, step=step.numpy())
tf.summary.scalar(
'loss', mean_loss.result(), step=step.numpy())
accuracy.reset_states()
mean_loss.reset_states()
print(f"Epoch {epoch} terminated")
# Measuring accuracy on the whole training set at the end of the epoch
for t in range(nr_batches_train):
start_from = t * batch_size
to = (t + 1) * batch_size
features, labels = train_x[start_from:to], train_y[start_from:to]
logits = model(features)
accuracy.update_state(labels, tf.argmax(logits, -1))
print(f"Training accuracy: {accuracy.result()}")
accuracy.reset_states()
在 TensorBoard 上,在第一个 epoch 结束时,可以看到每 10 步测量的损失值:

每 10 步测量的损失值,如 TensorBoard 中所展示
我们还可以看到训练准确率,它与损失同时进行测量:

训练准确率,如 TensorBoard 中所展示
此外,我们还可以看到从训练集采样的图像:

来自训练集的三张图像样本——一件裙子、一双凉鞋和来自 fashion-MNIST 数据集的一件毛衣
急切执行允许你动态创建和执行模型,而无需显式地创建图。然而,在急切模式下工作并不意味着不能从 TensorFlow 代码中构建图。实际上,正如我们在前一节中所看到的,通过使用 tf.GradientTape,可以注册训练步骤中发生的事情,通过追踪执行的操作构建计算图,并使用这个图通过自动微分自动计算梯度。
追踪函数执行过程中发生的事情使我们能够分析在运行时执行了哪些操作。知道这些操作,它们的输入关系和输出关系使我们能够构建图形。
这非常重要,因为它可以利用一次执行函数、追踪其行为、将其主体转换为图形表示并回退到更高效的图形定义和会话执行,这会带来巨大的性能提升。所有这些都能自动完成:这就是 AutoGraph 的概念。
AutoGraph
自动将 Python 代码转换为其图形表示形式是通过使用 AutoGraph 完成的。在 TensorFlow 2.0 中,当函数被 @tf.function 装饰时,AutoGraph 会自动应用于该函数。这个装饰器将 Python 函数转换为可调用的图形。
一旦正确装饰,函数就会通过 tf.function 和 tf.autograph 模块进行处理,以便将其转换为图形表示形式。下图展示了装饰函数被调用时的示意图:

示意图表示当一个被 @tf.function 装饰的函数 f 被调用时,在首次调用和任何后续调用时会发生的事情:
在注解函数的首次调用时,发生以下情况:
-
函数被执行并追踪。在这种情况下,急切执行被禁用,因此每个
tf.*方法都会定义一个tf.Operation节点,产生一个tf.Tensor输出,正如在 TensorFlow 1.x 中一样。 -
tf.autograph模块用于检测可以转换为图形等价物的 Python 结构。图形表示是从函数追踪和 AutoGraph 信息中构建的。这样做是为了保留在 Python 中定义的执行顺序。 -
tf.Graph对象现在已经构建完成。 -
基于函数名和输入参数,创建一个唯一的 ID,并将其与图形关联。然后将图形缓存到映射中,以便在第二次调用时,如果 ID 匹配,则可以重用该图形。
将一个函数转换为其图形表示通常需要我们思考;在 TensorFlow 1.x 中,并非每个在急切模式下工作的函数都能毫无痛苦地转换为其图形版本。
例如,急切模式下的变量是一个遵循 Python 作用域规则的 Python 对象。在图模式下,正如我们在前一章中发现的,变量是一个持久化对象,即使其关联的 Python 变量超出作用域并被垃圾回收,它仍然存在。
因此,在软件设计中必须特别注意:如果一个函数必须进行图加速并且创建一个状态(使用tf.Variable及类似对象),则由开发者负责避免每次调用该函数时都重新创建变量。
由于这个原因,tf.function会多次解析函数体,寻找tf.Variable定义。如果在第二次调用时,它发现一个变量对象正在被重新创建,就会抛出异常:
ValueError: tf.function-decorated function tried to create variables on non-first call.
实际上,如果我们定义了一个执行简单操作的函数,并且该函数内部使用了tf.Variable,我们必须确保该对象只会被创建一次。
以下函数在急切模式下正常工作,但如果使用@tf.function装饰器进行装饰,则无法执行,并且会抛出前述异常:
(tf2)
def f():
a = tf.constant([[10,10],[11.,1.]])
x = tf.constant([[1.,0.],[0.,1.]])
b = tf.Variable(12.)
y = tf.matmul(a, x) + b
return y
处理创建状态的函数意味着我们必须重新思考图模式的使用。状态是一个持久化对象,例如变量,且该变量不能被重新声明多次。由于这一点,函数定义可以通过两种方式进行修改:
-
通过将变量作为输入参数传递
-
通过打破函数作用域并从外部作用域继承变量
第一个选择需要更改函数定义,使其能够:
(tf2)
@tf.function
def f(b):
a = tf.constant([[10,10],[11.,1.]])
x = tf.constant([[1.,0.],[0.,1.]])
y = tf.matmul(a, x) + b
return y
var = tf.Variable(12.)
f(var)
f(15)
f(tf.constant(1))
f现在接受一个 Python 输入变量b。这个变量可以是tf.Variable、tf.Tensor,也可以是一个 NumPy 对象或 Python 类型。每次输入类型发生变化时,都会创建一个新的图,以便为任何所需的输入类型创建一个加速版本的函数(这是因为 TensorFlow 图是静态类型的)。
另一方面,第二种选择需要分解函数作用域,使变量可以在函数作用域外部使用。在这种情况下,我们可以采取两条路径:
-
不推荐:使用全局变量
-
推荐:使用类似 Keras 的对象
第一种方法,即不推荐的方法,是将变量声明在函数体外,并在其中使用它,确保该变量只会声明一次:
(tf2)
b = None
@tf.function
def f():
a = tf.constant([[10, 10], [11., 1.]])
x = tf.constant([[1., 0.], [0., 1.]])
global b
if b is None:
b = tf.Variable(12.)
y = tf.matmul(a, x) + b
return y
f()
第二种方法,即推荐的方法,是使用面向对象的方法,并将变量声明为类的私有属性。然后,你需要通过将函数体放入__call__方法中,使实例化的对象可调用:
(tf2)
class F():
def __init__(self):
self._b = None
@tf.function
def __call__(self):
a = tf.constant([[10, 10], [11., 1.]])
x = tf.constant([[1., 0.], [0., 1.]])
if self._b is None:
self._b = tf.Variable(12.)
y = tf.matmul(a, x) + self._b
return y
f = F()
f()
AutoGraph 和图加速过程在优化训练过程时表现最佳。
实际上,训练过程中最具计算密集型的部分是前向传递(forward pass),接着是梯度计算和参数更新。在前面的例子中,遵循新结构,由于没有tf.Session,我们将训练步骤从训练循环中分离出来。训练步骤是一个无状态的函数,使用从外部作用域继承的变量。因此,它可以通过装饰器@tf.function转换为图形表示,并加速执行:
(tf2)
@tf.function
def train_step(inputs, labels):
# function body
你被邀请测量train_step函数图形转换所带来的加速效果。
性能提升不能保证,因为即使是即时执行(eager execution)也已经非常快,并且在一些简单的场景中,即时执行的速度和图形执行(graph execution)相当。然而,当模型变得更加复杂和深层时,性能提升是显而易见的。
AutoGraph 会自动将 Python 构造转换为其tf.*等效构造,但由于转换保留语义的源代码并不是一项容易的任务,因此在某些场景下,帮助 AutoGraph 进行源代码转换会更好。
事实上,已经有一些在即时执行中有效的构造,它们是 Python 构造的直接替代品。特别是,tf.range替代了range,tf.print替代了print,tf.assert替代了assert。
例如,AutoGraph 无法自动将print转换为tf.print以保持其语义。因此,如果我们希望一个图形加速的函数在图形模式下执行时打印内容,我们必须使用tf.print而不是print来编写函数。
你被邀请定义一些简单的函数,使用tf.range代替range,使用print代替tf.print,然后通过tf.autograph模块可视化源代码的转换过程。
例如,看看以下代码:
(tf2)
import tensorflow as tf
@tf.function
def f():
x = 0
for i in range(10):
print(i)
x += i
return x
f()
print(tf.autograph.to_code(f.python_function))
当调用f时,会生成0,1,2,..., 10——这是每次调用f时都会发生的吗,还是只会发生在第一次调用时?
你被邀请仔细阅读以下由 AutoGraph 生成的函数(这是机器生成的,因此难以阅读),以了解为什么f会以这种方式表现:
def tf__f():
try:
with ag__.function_scope('f'):
do_return = False
retval_ = None
x = 0
def loop_body(loop_vars, x_1):
with ag__.function_scope('loop_body'):
i = loop_vars
with ag__.utils.control_dependency_on_returns(ag__.print_(i)):
x, i_1 = ag__.utils.alias_tensors(x_1, i)
x += i_1
return x,
x, = ag__.for_stmt(ag__.range_(10), None, loop_body, (x,))
do_return = True
retval_ = x
return retval_
except:
ag__.rewrite_graph_construction_error(ag_source_map__)
将旧代码库从 TensorFlow 1.x 迁移到 2.0 可能是一个耗时的过程。这就是为什么 TensorFlow 作者创建了一个转换工具,允许我们自动迁移源代码(它甚至可以在 Python 笔记本中使用!)。
代码库迁移
正如我们已经看到的,TensorFlow 2.0 引入了许多破坏性的变化,这意味着我们必须重新学习如何使用这个框架。TensorFlow 1.x 是最广泛使用的机器学习框架,因此有大量现有的代码需要升级。
TensorFlow 工程师开发了一个转换工具,可以帮助进行转换过程:不幸的是,它依赖于tf.compat.v1模块,并且它不会移除图或会话执行。相反,它只是重写代码,使用tf.compat.v1进行前缀化,并应用一些源代码转换以修复一些简单的 API 更改。
然而,它是迁移整个代码库的一个良好起点。事实上,建议的迁移过程如下:
-
运行迁移脚本。
-
手动移除每个
tf.contrib符号,查找在contrib命名空间中使用的项目的新位置。 -
手动将模型切换到其 Keras 等效版本。移除会话。
-
在 eager 执行模式下定义训练循环。
-
使用
tf.function加速计算密集型部分。
迁移工具tf_upgrade_v2在通过pip安装 TensorFlow 2.0 时会自动安装。该升级脚本适用于单个 Python 文件、笔记本或完整的项目目录。
要迁移单个 Python 文件(或笔记本),请使用以下代码:
tf_upgrade_v2 --infile file.py --outfile file-migrated.py
要在目录树上运行,请使用以下代码:
tf_upgrade_v2 --intree project --outtree project-migrated
在这两种情况下,如果脚本无法找到输入代码的修复方法,它将打印错误。
此外,它始终会在report.txt文件中报告详细的变更列表,这有助于我们理解工具为何应用某些更改;例如:
Added keyword 'input' to reordered function 'tf.argmax'
Renamed keyword argument from 'dimension' to 'axis'
Old: tf.argmax([[1, 2, 2]], dimension=0))
~~~~~~~~~~
New: tf.argmax(input=[[1, 2, 2]], axis=0))
即使使用转换工具,迁移代码库也是一个耗时的过程,因为大部分工作仍然是手动的。将代码库转换为 TensorFlow 2.0 是值得的,因为它带来了许多优势,诸如以下几点:
-
轻松调试。
-
通过面向对象的方法提高代码质量。
-
维护的代码行数更少。
-
易于文档化。
-
面向未来——TensorFlow 2.0 遵循 Keras 标准,并且该标准经得起时间的考验。
总结
本章介绍了 TensorFlow 2.0 中引入的所有主要变化,包括框架在 Keras API 规范上的标准化、使用 Keras 定义模型的方式,以及如何使用自定义训练循环进行训练。我们还看到了 AutoGraph 引入的图加速,以及tf.function。
尤其是 AutoGraph,仍然要求我们了解 TensorFlow 图架构的工作原理,因为在 eager 模式中定义并使用的 Python 函数如果需要加速图计算,就必须重新设计。
新的 API 更加模块化、面向对象且标准化;这些突破性变化旨在使框架的使用更加简单和自然,尽管图架构中的微妙差异仍然存在,并且将始终存在。
对于那些有 TensorFlow 1.0 工作经验的人来说,改变思维方式到基于对象而不再基于图形和会话的方法可能会非常困难;然而,这是一场值得的斗争,因为它会提高编写软件的整体质量。
在下一章中,我们将学习高效的数据输入流水线和估计器 API。
练习
请仔细阅读以下练习,并仔细回答所有问题。通过练习、试错和大量挣扎,这是掌握框架并成为专家的唯一途径:
-
使用顺序、函数式和子类 API 定义一个分类器,以便对时尚-MNIST 数据集进行分类。
-
使用 Keras 模型的内置方法训练模型并测量预测准确率。
-
编写一个类,在其构造函数中接受一个 Keras 模型并进行训练和评估。
API 应该按照以下方式工作:
# Define your model
trainer = Trainer(model)
# Get features and labels as numpy arrays (explore the dataset available in the keras module)
trainer.train(features, labels)
# measure the accuracy
trainer.evaluate(test_features, test_labels)
-
使用
@tf.function注解加速训练方法。创建一个名为_train_step的私有方法,仅加速训练循环中最消耗计算资源的部分。运行训练并测量性能提升的毫秒数。
-
使用多个(2 个)输入和多个(2 个)输出定义一个 Keras 模型。
该模型必须接受灰度的 28 x 28 x 1 图像作为输入,以及一个与之相同尺寸的第二个灰度图像。第一层应该是这两个图像深度(28 x 28 x 1)的串联。
架构应该是类似自动编码器的卷积结构,将输入缩减到一个大小为 1 x 1 x 128 的向量,然后在其解码部分通过使用
tf.keras.layer.UpSampling2D层上采样层,直到恢复到 28 x 28 x D 的尺寸,其中 D 是您选择的深度。然后,在这个最后一层之上应该添加两个一元卷积层,每个都产生一个 28 x 28 x 1 的图像。
-
使用时尚-MNIST 数据集定义一个训练循环,生成
(image, condition)对,其中condition是一个完全白色的 28 x 28 x 1 图像,如果与image相关联的标签为 6;否则,它需要是一个黑色图像。在将图像输入网络之前,将其缩放到
[-1, 1]范围内。使用两个损失的总和来训练网络。第一个损失是网络的第一个输入和第一个输出之间的 L2 距离。第二个损失是
condition和第二个输出之间的 L1 距离。在训练过程中测量第一对的 L1 重构误差。当值小于 0.5 时停止训练。
-
使用 TensorFlow 转换工具转换所有脚本,以便解决在第三章中提出的练习,“TensorFlow 图形架构”。
-
分析转换的结果:是否使用了 Keras?如果没有,通过消除每一个
tf.compat.v1的引用来手动迁移模型。这总是可能的吗? -
选择你为前面练习之一编写的训练循环:在应用更新之前,可以操作梯度。约束条件应该是梯度的范数,并且在应用更新之前,范数应该处于[-1, 1]的范围内。使用 TensorFlow 的基本操作来实现:它应该与
@tf.function兼容。 -
如果以下函数被
@tf.function装饰,它会输出任何结果吗?描述其内部发生的情况:
def output():
for i in range(10):
tf.print(i)
- 如果以下函数被
@tf.function装饰,它会输出任何结果吗?描述其内部发生的情况:
def output():
for i in tf.range(10):
print(i)
- 如果以下函数被
@tf.function装饰,它会输出任何结果吗?描述其内部发生的情况:
def output():
for i in tf.range(10):
tf.print(f"{i}", i)
print(f"{i}", i)
-
给定
,使用tf.GradientTape在
和
中计算一阶和二阶偏导数。 -
移除在不再使用全局变量章节中未能执行的示例中的副作用,并使用常量代替变量。
-
扩展在自定义训练循环章节中定义的自定义训练循环,以便测量整个训练集、整个验证集的准确度,并在每个训练周期结束时进行评估。然后,使用两个
tf.train.CheckpointManager对象进行模型选择。如果验证准确度在 5 个周期内没有继续增加(最多变化±0.2),则停止训练。
-
在以下训练函数中,
step变量是否已转换为tf.Variable对象?如果没有,这会有什么不利之处?
@tf.function
def train(model, optimizer):
train_ds = mnist_dataset()
step = 0
loss = 0.0
accuracy = 0.0
for x, y in train_ds:
step += 1
loss = train_one_step(model, optimizer, x, y)
if tf.equal(step % 10, 0):
tf.print('Step', step, ': loss', loss, '; accuracy', compute_accuracy.result())
return step, loss, accuracy
在本书的所有练习中持续进行实践。
第五章:高效的数据输入管道和 Estimator API
在本章中,我们将重点介绍 TensorFlow API 中最常用的两个模块:tf.data 和 tf.estimator。
TensorFlow 1.x 的设计非常优秀,以至于在 TensorFlow 2.0 中几乎没有什么变化;实际上,tf.data 和 tf.estimator 是 TensorFlow 1.x 生命周期中最早引入的两个高级模块。
tf.data 模块是一个高级 API,允许你定义高效的输入管道,而无需担心线程、队列、同步和分布式文件系统。该 API 的设计考虑了简便性,旨在克服以前低级 API 的可用性问题。
tf.estimator API 旨在简化和标准化机器学习编程,允许训练、评估、运行推理并导出用于服务的参数化模型,让用户只关注模型和输入定义。
tf.data 和 tf.estimator APIs 完全兼容,并且强烈建议一起使用。此外,正如我们将在接下来的章节中看到的,每个 Keras 模型、整个即时执行(eager execution)甚至 AutoGraph 都与 tf.data.Dataset 对象完全兼容。这种兼容性通过在几行代码中定义和使用高效的数据输入管道,加速了训练和评估阶段。
在本章中,我们将涵盖以下主题:
-
高效的数据输入管道
-
tf.estimatorAPI
高效的数据输入管道
数据是每个机器学习管道中最关键的部分;模型从数据中学习,它的数量和质量是每个机器学习应用程序的游戏规则改变者。
将数据提供给 Keras 模型到目前为止看起来很自然:我们可以将数据集作为 NumPy 数组获取,创建批次,然后将批次传递给模型,通过小批量梯度下降进行训练。
然而,迄今为止展示的输入方式实际上是极其低效且容易出错,原因如下:
-
完整的数据集可能有数千 GB 大:没有任何单一的标准计算机,甚至是深度学习工作站,都有足够的内存来加载如此庞大的数据集。
-
手动创建输入批次意味着需要手动处理切片索引;这可能会出错。
-
数据增强,给每个输入样本应用随机扰动,会减慢模型训练过程,因为增强过程需要在将数据提供给模型之前完成。并行化这些操作意味着你需要关注线程之间的同步问题以及与并行计算相关的许多常见问题。此外,模板代码的复杂性也增加了。
-
将参数位于 GPU/TPU 上的模型从驻留在 CPU 上的主 Python 进程中提供数据,涉及加载/卸载数据,这是一个可能导致计算不理想的过程:硬件利用率可能低于 100%,这完全是浪费。
TensorFlow 对 Keras API 规范的实现,tf.keras,原生支持通过tf.data API 馈送模型,建议在使用急切执行(eager execution)、AutoGraph 和估算器 API 时使用它们。
定义输入管道是一个常见的做法,可以将其框架化为 ETL(提取、转换和加载)过程。
输入管道结构
定义训练输入管道是一个标准过程;可以将遵循的步骤框架化为提取、转换和加载(ETL)过程:即将数据从数据源复制到目标系统的过程,以便使用这些数据。
ETL 过程包括以下三个步骤,tf.data.Dataset对象可以轻松实现这些步骤:
-
提取:从数据源读取数据。数据源可以是本地的(持久存储,已加载到内存中)或远程的(云存储,远程文件系统)。
-
转换:对数据进行转换,以清理、增强(随机裁剪图像、翻转、颜色失真、添加噪声),使数据能被模型解释。通过对数据进行打乱和批处理,完成转换。
-
加载:将转换后的数据加载到更适合训练需求的设备(如 GPU 或 TPU)中,并执行训练。
这些 ETL 步骤不仅可以在训练阶段执行,还可以在推理阶段执行。
如果训练/推理的目标设备不是 CPU,而是其他设备,tf.data API 有效地利用了 CPU,将目标设备保留用于模型的推理/训练;事实上,GPU 或 TPU 等目标设备使得训练参数模型更快,而 CPU 则被大量用于输入数据的顺序处理。
然而,这个过程容易成为整个训练过程的瓶颈,因为目标设备可能以比 CPU 生产数据更快的速度消耗数据。
tf.data API 通过其tf.data.Dataset类,使我们能够轻松定义数据输入管道,这些管道透明地解决了之前的问题,同时增加了强大的高级特性,使其使用变得更加愉快。需要特别注意性能优化,因为仍然由开发者负责正确定义 ETL 过程,以确保目标设备的 100%使用率,手动去除任何瓶颈。
tf.data.Dataset 对象
tf.data.Dataset对象表示输入管道,作为一组元素,并附带对这些元素进行处理的有序转换集合。
每个元素包含一个或多个tf.Tensor对象。例如,对于一个图像分类问题,tf.data.Dataset的元素可能是单个训练样本,包含一对张量,分别表示图像及其相关标签。
创建数据集对象有几种方法,具体取决于数据源。
根据数据的位置和格式,tf.data.Dataset 类提供了许多静态方法,可以轻松创建数据集:
-
内存中的张量:
tf.data.Dataset.from_tensors或tf.data.Dataset.from_tensor_slices。在这种情况下,张量可以是 NumPy 数组或tf.Tensor对象。 -
来自 Python 生成器:
tf.data.Dataset.from_generator。 -
从匹配模式的文件列表中:
tf.data.Dataset.list_files。
此外,还有两个专门化的 tf.data.Dataset 对象,用于处理两种常用的文件格式:
-
tf.data.TFRecordDataset用于处理TFRecord文件。 -
tf.data.TextLineDataset用于处理文本文件,逐行读取文件。
TFRecord 文件格式的描述在随后的优化部分中给出。
一旦构建了数据集对象,就可以通过链式调用方法将其转换为一个新的 tf.data.Dataset 对象。tf.data API 广泛使用方法链来自然地表达应用于数据的转换序列。
在 TensorFlow 1.x 中,由于输入管道也是计算图的一个成员,因此需要创建一个迭代器节点。从 2.0 版本开始,tf.data.Dataset 对象是可迭代的,这意味着你可以通过 for 循环枚举其元素,或使用 iter 关键字创建一个 Python 迭代器。
请注意,可迭代并不意味着是一个 Python 迭代器。
你可以通过使用 for 循环 for value in dataset 来遍历数据集,但不能使用 next(dataset) 提取元素。
相反,可以在创建迭代器后,使用 Python 的 iter 关键字来使用 next(iterator):
iterator = iter(dataset) value = next(iterator)。
数据集对象是一个非常灵活的数据结构,它允许创建不仅仅是数字或数字元组的数据集,而是任何 Python 数据结构。如下一个代码片段所示,可以有效地将 Python 字典与 TensorFlow 生成的值混合:
(tf2)
dataset = tf.data.Dataset.from_tensor_slices({
"a": tf.random.uniform([4]),
"b": tf.random.uniform([4, 100], maxval=100, dtype=tf.int32)
})
for value in dataset:
# Do something with the dict value
print(value["a"])
tf.data.Dataset 对象通过其方法提供的转换集支持任何结构的数据集。
假设我们想要定义一个数据集,它生成无限数量的向量,每个向量有 100 个元素,包含随机值(我们将在专门讨论 GANs 的章节 第九章,生成对抗网络 中进行此操作);使用 tf.data.Dataset.from_generator,只需几行代码即可完成:
(tf2)
def noise():
while True:
yield tf.random.uniform((100,))
dataset = tf.data.Dataset.from_generator(noise, (tf.float32))
from_generator 方法的唯一特殊之处是需要将参数类型(此处为 tf.float32)作为第二个参数传递;这是必需的,因为在构建图时,我们需要提前知道参数的类型。
通过方法链,可以创建新的数据集对象,将刚刚构建的数据集转化为机器学习模型所期望的输入数据。例如,如果我们想在噪声向量的每个元素上加上 10,打乱数据集内容,并创建 32 个向量为一批的批次,只需调用三个方法即可:
(tf2)
buffer_size = 10
batch_size = 32
dataset = dataset.map(lambda x: x + 10).shuffle(buffer_size).batch(batch_size)
map方法是tf.data.Dataset对象中最常用的方法,因为它允许我们对输入数据集的每个元素应用一个函数,从而生成一个新的、转化后的数据集。
shuffle方法在每个训练管道中都使用,因为它通过一个固定大小的缓冲区随机打乱输入数据集;这意味着,打乱后的数据首先从输入中获取buffer_size个元素,然后对其进行打乱并生成输出。
batch方法从输入中收集batch_size个元素,并创建一个批次作为输出。此转换的唯一限制是批次中的所有元素必须具有相同的形状。
要训练模型,必须将所有训练集的元素输入模型多个周期。tf.data.Dataset类提供了repeat(num_epochs)方法来实现这一点。
因此,输入数据管道可以总结如下图所示:

该图展示了典型的数据输入管道:通过链式方法调用将原始数据转化为模型可用的数据。预取和缓存是优化建议,接下来的部分会详细讲解。
请注意,直到此时,仍未提及线程、同步或远程文件系统的概念。
所有这些操作都被tf.data API 隐藏起来:
-
输入路径(例如,当使用
tf.data.Dataset.list_files方法时)可以是远程的。TensorFlow 内部使用tf.io.gfile包,它是一个没有线程锁定的文件输入/输出封装器。该模块使得可以像读取本地文件系统一样读取远程文件系统。例如,可以通过gs://bucket/格式的地址从 Google Cloud Storage 存储桶读取,而无需担心认证、远程请求以及与远程文件系统交互所需的所有样板代码。 -
对数据应用的每个转换操作都高效地利用所有 CPU 资源——与数据集对象一起创建的线程数量等于 CPU 核心数,并在可能进行并行转换时,按顺序和并行方式处理数据。
-
这些线程之间的同步完全由
tf.dataAPI 管理。
所有通过方法链描述的转换操作都由tf.data.Dataset在 CPU 上实例化的线程执行,以自动执行可以并行的操作,从而大大提升性能。
此外,tf.data.Dataset 足够高层,以至于将所有线程的执行和同步隐藏起来,但这种自动化的解决方案可能不是最优的:目标设备可能没有完全被使用,用户需要消除瓶颈,以便实现目标设备的 100% 使用。
性能优化
到目前为止展示的 tf.data API 描述了一个顺序的数据输入管道,该管道通过应用转换将数据从原始格式转变为有用格式。
所有这些操作都在 CPU 上执行,而目标设备(CPU、TPU 或一般来说,消费者)则在等待数据。如果目标设备消耗数据的速度快于生产速度,那么目标设备将会有 0% 的利用率。
在并行编程中,这个问题已经通过预取解决。
预取
当消费者在工作时,生产者不应闲置,而应在后台工作,以便生成消费者在下一次迭代中所需的数据。
tf.data API 提供了 prefetch(n) 方法,通过该方法可以应用一种转换,使得生产者和消费者的工作能够重叠。最佳实践是在输入管道的末尾添加 prefetch(n),以便将 CPU 上执行的转换与目标设备上的计算重叠。
选择 n 非常简单:n 是训练步骤中消费的元素数量,由于绝大多数模型使用数据批次进行训练,每个训练步骤使用一个批次,因此 n=1。
从磁盘读取数据的过程,尤其是在读取大文件、从慢速 HDD 或使用远程文件系统时,可能非常耗时。通常使用缓存来减少这种开销。
缓存元素
cache 转换可以用来将数据缓存到内存中,完全消除对数据源的访问。当使用远程文件系统或读取过程较慢时,这可以带来巨大好处。仅当数据可以适配到内存中时,才有可能在第一次训练周期后缓存数据。
cache 方法在转换管道中起到了屏障的作用:cache 方法之前执行的所有操作只会执行一次,因此在管道中放置此转换可以带来巨大的好处。实际上,它可以在计算密集型转换之后或在任何慢速过程之后应用,以加速接下来的所有操作。
使用 TFRecords
读取数据是一个时间密集型的过程。通常,数据不能像它在线性存储在磁盘上一样被读取,而是文件必须经过处理和转换才能正确读取。
TFRecord 格式是一种二进制和语言无关的格式(使用 protobuf 定义),用于存储一系列二进制记录。TensorFlow 允许读取和写入由一系列 tf.Example 消息组成的 TFRecord 文件。
tf.Example 是一种灵活的消息类型,表示一个 {"key": value} 映射,其中 key 是特征名称,value 是其二进制表示。
例如,tf.Example 可以是字典(伪代码形式):
{
"height": image.height,
"width": image.widht,
"depth": image.depth,
"label": label,
"image": image.bytes()
}
数据集的一行(包括图像、标签及其他附加信息)被序列化为一个示例并存储在 TFRecord 文件中,尤其是图像没有采用压缩格式存储,而是直接使用其二进制表示形式。这使得图像可以线性读取,作为字节序列,无需应用任何图像解码算法,从而节省时间(但会占用更多磁盘空间)。
在引入 tfds(TensorFlow 数据集)之前,读取和写入 TFRecord 文件是一个重复且繁琐的过程,因为我们需要处理如何序列化和反序列化输入特征,以便与 TFRecord 二进制格式兼容。TensorFlow 数据集(即构建在 TFRecord 文件规范之上的高级 API)标准化了高效数据集创建的过程,强制要求创建任何数据集的 TFRecord 表示形式。此外,tfds 已经包含了许多正确存储在 TFRecord 格式中的现成数据集,其官方指南也完美解释了如何构建数据集,描述其特征,以创建准备使用的 TFRecord 表示形式。
由于 TFRecord 的描述和使用超出了本书的范围,接下来的章节将仅介绍 TensorFlow 数据集的使用。有关创建 TensorFlow 数据集构建器的完整指南,请参见 第八章,语义分割和自定义数据集构建器。如果您对 TFRecord 表示形式感兴趣,请参考官方文档:www.tensorflow.org/beta/tutorials/load_data/tf_records。
构建数据集
以下示例展示了如何使用 Fashion-MNIST 数据集构建一个 tf.data.Dataset 对象。这是一个完整的数据集示例,采用了之前描述的所有最佳实践;请花时间理解为何采用这种方法链式操作,并理解性能优化应用的位置。
在以下代码中,我们定义了 train_dataset 函数,该函数返回一个已准备好的 tf.data.Dataset 对象:
(tf2)
import tensorflow as tf
from tensorflow.keras.datasets import fashion_mnist
def train_dataset(batch_size=32, num_epochs=1):
(train_x, train_y), (test_x, test_y) = fashion_mnist.load_data()
input_x, input_y = train_x, train_y
def scale_fn(image, label):
return (tf.image.convert_image_dtype(image, tf.float32) - 0.5) * 2.0, label
dataset = tf.data.Dataset.from_tensor_slices(
(tf.expand_dims(input_x, -1), tf.expand_dims(input_y, -1))
).map(scale_fn)
dataset = dataset.cache().repeat(num_epochs)
dataset = dataset.shuffle(batch_size)
return dataset.batch(batch_size).prefetch(1)
然而,训练数据集应该包含增强的数据,以应对过拟合问题。使用 TensorFlow tf.image 包对图像数据进行数据增强非常直接。
数据增强
到目前为止定义的 ETL 过程仅转换原始数据,应用的转换不会改变图像内容。而数据增强则需要对原始数据应用有意义的转换,目的是创建更大的数据集,并因此训练一个对这些变化更为鲁棒的模型。
在处理图像时,可以使用 tf.image 包提供的完整 API 来增强数据集。增强步骤包括定义一个函数,并通过使用数据集的 map 方法将其应用于训练集。
有效的变换集合取决于数据集——例如,如果我们使用的是 MNIST 数据集,那么将输入图像上下翻转就不是一个好主意(没有人希望看到一个标记为 9 的数字 6 图像),但由于我们使用的是 fashion-MNIST 数据集,我们可以随意翻转或旋转输入图像(一条裤子即使被随机翻转或旋转,依然是条裤子)。
tf.image 包已经包含了具有随机行为的函数,专为数据增强设计。这些函数以 50%的概率对输入图像应用变换;这是我们期望的行为,因为我们希望向模型输入原始图像和增强图像。因此,可以按如下方式定义一个对输入数据应用有意义变换的函数:
(tf2)
def augment(image):
image = tf.image.random_flip_left_right(image)
image = tf.image.random_flip_up_down(image)
image = tf.image.random_brightness(image, max_delta=0.1)
return image
将此增强函数应用于数据集,并使用数据集的 map 方法,作为一个练习留给你完成。
尽管借助 tf.data API 很容易构建自己的数据集,以便在标准任务(分类、目标检测或语义分割)上对每个新算法进行基准测试,但这一过程可能是重复的,因而容易出错。TensorFlow 开发者与 TensorFlow 开发社区一起,标准化了 ETL 管道的 提取 和 转换 过程,开发了 TensorFlow Datasets。
TensorFlow 提供的数据增强函数有时不够用,尤其是在处理需要大量变换才能有用的小数据集时。有许多用 Python 编写的数据增强库,可以轻松地集成到数据集增强步骤中。以下是两个最常见的库:
imgaug: github.com/aleju/imgaug
albumentations: github.com/albu/albumentations
使用 tf.py_function 可以在 map 方法中执行 Python 代码,从而利用这些库生成丰富的变换集(这是 tf.image 包所没有提供的)。
TensorFlow Datasets – tfds
TensorFlow Datasets 是一个现成可用的数据集集合,它处理 ETL 过程中的下载和准备阶段,并构建 tf.data.Dataset 对象。
这个项目为机器学习从业者带来的显著优势是极大简化了最常用基准数据集的下载和准备工作。
TensorFlow 数据集(tfds)不仅下载并将数据集转换为标准格式,还会将数据集本地转换为其TFRecord表示形式,使得从磁盘读取非常高效,并为用户提供一个从TFRecord中读取并准备好使用的tf.data.Dataset对象。该 API 具有构建器的概念。每个构建器都是一个可用的数据集。
与tf.data API 不同,TensorFlow 数据集作为一个独立的包,需要单独安装。
安装
作为一个 Python 包,通过pip安装非常简单:
pip install tensorflow-datasets
就是这样。该包非常轻量,因为所有数据集仅在需要时下载。
使用
该包提供了两个主要方法:list_builders()和load():
-
list_builders()返回可用数据集的列表。 -
load(name, split)接受一个可用构建器的名称和所需的拆分。拆分值取决于构建器,因为每个构建器都有自己的信息。
使用tfds加载 MNIST 的训练和测试拆分,在可用构建器的列表中显示如下:
(tf2)
import tensorflow_datasets as tfds
# See available datasets
print(tfds.list_builders())
# Construct 2 tf.data.Dataset objects
# The training dataset and the test dataset
ds_train, ds_test = tfds.load(name="mnist", split=["train", "test"])
在一行代码中,我们下载、处理并将数据集转换为 TFRecord,并创建两个tf.data.Dataset对象来读取它们。
在这一行代码中,我们没有关于数据集本身的任何信息:没有关于返回对象的数据类型、图像和标签的形状等线索。
要获取整个数据集的完整描述,可以使用与数据集相关联的构建器并打印info属性;该属性包含所有工作所需的信息,从学术引用到数据格式:
(tf2)
builder = tfds.builder("mnist")
print(builder.info)
执行它后,我们得到如下结果:
tfds.core.DatasetInfo(
name='mnist',
version=1.0.0,
description='The MNIST database of handwritten digits.',
urls=['http://yann.lecun.com/exdb/mnist/'],
features=FeaturesDict({
'image': Image(shape=(28, 28, 1), dtype=tf.uint8),
'label': ClassLabel(shape=(), dtype=tf.int64, num_classes=10)
},
total_num_examples=70000,
splits={
'test': <tfds.core.SplitInfo num_examples=10000>,
'train': <tfds.core.SplitInfo num_examples=60000>
},
supervised_keys=('image', 'label'),
citation='"""
@article{lecun2010mnist,
title={MNIST handwritten digit database},
author={LeCun, Yann and Cortes, Corinna and Burges, CJ},
journal={ATT Labs [Online]. Available: http://yann. lecun. com/exdb/mnist},
volume={2},
year={2010}
}
"""',
)
这就是我们所需的一切。
强烈推荐使用tfds;此外,由于返回的是tf.data.Dataset对象,因此无需学习如何使用其他复杂的 API,因为tf.data API 是标准的,并且我们可以在 TensorFlow 2.0 中随处使用它。
Keras 集成
数据集对象原生支持 Keras 的tf.keras规范在 TensorFlow 中的实现。这意味着在训练/评估模型时,使用 NumPy 数组或使用tf.data.Dataset对象是一样的。使用tf.keras.Sequential API 定义的分类模型,在第四章,TensorFlow 2.0 架构中,使用之前定义的train_dataset函数创建的tf.data.Dataset对象训练会更快。
在以下代码中,我们只是使用标准的.compile和.fit方法调用,来编译(定义训练循环)和拟合数据集(这是一个tf.data.Dataset):
(tf2)
model.compile(
optimizer=tf.keras.optimizers.Adam(1e-5),
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
model.fit(train_dataset(num_epochs=10))
TensorFlow 2.0 默认是急切执行的,原生支持遍历tf.data.Dataset对象,以构建我们自己的自定义训练循环。
急切集成
tf.data.Dataset 对象是可迭代的,这意味着可以使用 for 循环枚举其元素,或者使用 iter 关键字创建一个 Python 迭代器。请注意,可迭代并不意味着是 Python 迭代器,正如本章开头所指出的那样。
遍历数据集对象是非常简单的:我们可以使用标准的 Python for 循环在每次迭代中提取一个批次。
通过使用数据集对象来配置输入管道,比当前使用的解决方案要更好。
手动通过计算索引从数据集中提取元素的过程容易出错且效率低,而 tf.data.Dataset 对象经过高度优化。此外,数据集对象与 tf.function 完全兼容,因此整个训练循环可以图转换并加速。
此外,代码行数大大减少,提高了可读性。以下代码块表示上一章的图加速(通过 @tf.function)自定义训练循环,参见 第四章,TensorFlow 2.0 架构;该循环使用了之前定义的 train_dataset 函数:
(tf2)
def train():
# Define the model
n_classes = 10
model = make_model(n_classes)
# Input data
dataset = train_dataset(num_epochs=10)
# Training parameters
loss = tf.losses.SparseCategoricalCrossentropy(from_logits=True)
step = tf.Variable(1, name="global_step")
optimizer = tf.optimizers.Adam(1e-3)
accuracy = tf.metrics.Accuracy()
# Train step function
@tf.function
def train_step(inputs, labels):
with tf.GradientTape() as tape:
logits = model(inputs)
loss_value = loss(labels, logits)
gradients = tape.gradient(loss_value, model.trainable_variables)
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
step.assign_add(1)
accuracy_value = accuracy(labels, tf.argmax(logits, -1))
return loss_value, accuracy_value
@tf.function
def loop():
for features, labels in dataset:
loss_value, accuracy_value = train_step(features, labels)
if tf.equal(tf.math.mod(step, 10), 0):
tf.print(step, ": ", loss_value, " - accuracy: ",
accuracy_value)
loop()
欢迎仔细阅读源代码,并与上一章的自定义训练循环进行对比,参见 第四章,TensorFlow 2.0 架构。
Estimator API
在前一节中,我们看到 tf.data API 如何简化并标准化输入管道的定义。我们还看到,tf.data API 完全整合到了 TensorFlow Keras 实现中,并且能够在自定义训练循环的急切或图加速版本中使用。
就像输入数据管道一样,整个机器学习编程中有很多重复的部分。特别是在定义了第一个版本的机器学习模型后,实践者会关注以下几个方面:
-
训练
-
评估
-
预测
在多次迭代这些步骤之后,将训练好的模型导出以供服务是自然的结果。
当然,定义训练循环、评估过程和预测过程在每个机器学习过程中是非常相似的。例如,对于一个预测模型,我们关心的是训练模型指定次数的 epochs,在过程结束时测量训练集和验证集的某个指标,并重复这个过程,调整超参数直到结果令人满意。
为了简化机器学习编程并帮助开发者专注于过程中的非重复部分,TensorFlow 引入了通过 tf.estimator API 的 Estimator 概念。
tf.estimator API 是一个高级 API,它封装了机器学习管道中的重复和标准化过程。有关 Estimator 的更多信息,请参见官方文档(www.tensorflow.org/guide/estimators)。以下是 Estimator 带来的主要优点:
-
你可以在本地主机或分布式多服务器环境中运行基于 Estimator 的模型,而无需更改模型。此外,你还可以在 CPU、GPU 或 TPU 上运行基于 Estimator 的模型,无需重新编写代码。
-
Estimator 简化了模型开发者之间实现的共享。
-
你可以使用高层次、直观的代码开发最先进的模型。简而言之,通常使用 Estimator 创建模型比使用低级 TensorFlow API 要容易得多。
-
Estimator 本身是建立在
tf.keras.layers上的,这简化了自定义。 -
Estimator 为你构建图。
-
Estimator 提供了一个安全分布式的训练循环,控制如何以及何时进行:
-
构建图
-
初始化变量
-
加载数据
-
处理异常
-
创建检查点文件并从故障中恢复
-
为 TensorBoard 保存摘要
-

Estimator API 建立在 TensorFlow 中层层次之上;特别地,Estimator 本身是使用 Keras 层构建的,以简化自定义。图片来源:tensorflow.org
机器学习管道的标准化过程通过一个描述它的类的定义进行:tf.estimator.Estimator。
要使用这个类,你需要使用一个由tf.estimator.Estimator对象的公共方法强制执行的、定义良好的编程模型,如下图所示:

Estimator 编程模型由 Estimator 对象的公共方法强制执行;API 本身处理检查点保存和重新加载;用户只需实现输入函数和模型本身;训练、评估和预测的标准过程由 API 实现。图片来源:tensorflow.org
使用 Estimator API 有两种不同的方式:构建自定义 Estimator 或使用预制 Estimator。
预制和自定义的 Estimator 遵循相同的编程模型;唯一的区别是,在自定义 Estimator 中,用户必须编写一个model_fn模型函数,而在预制 Estimator 中,模型定义是现成的(代价是灵活性较低)。
Estimator API 强制你使用的编程模型包括两个组件的实现:
-
数据输入管道的实现,实现
input_fn函数 -
(可选)模型的实现,处理训练、评估和预测情况,并实现
model_fn函数
请注意,文档中提到了图(graphs)。事实上,为了保证高性能,Estimator API 是建立在(隐藏的)图表示上的。即使 TensorFlow 2.0 默认使用急切执行(eager execution)范式,model_fn和input_fn也不会立即执行,Estimator 会在调用这些函数之前切换到图模式,这就是为什么代码必须与图模式执行兼容的原因。
实际上,Estimator API 是将数据与模型分离的良好实践的标准化。这一点通过tf.estimator.Estimator对象的构造函数得到了很好的体现,该对象是本章的主题:
__init__(
model_fn,
model_dir=None,
config=None,
params=None,
warm_start_from=None
)
值得注意的是,在构造函数中并未提到input_fn,这也是有道理的,因为输入可以在估算器的生命周期中发生变化,而模型则不能。
让我们来看一下input_fn函数应该如何实现。
数据输入管道
首先,让我们看看标准的 ETL 过程:
-
提取:从数据源读取数据。数据源可以是本地的(持久存储、已经加载到内存中)或远程的(云存储、远程文件系统)。
-
转换:对数据应用变换操作,以清洗数据、增强数据(随机裁剪图像、翻转、颜色扭曲、添加噪声),并使数据可以被模型理解。通过打乱数据并进行批处理来结束变换过程。
-
加载:将变换后的数据加载到最适合训练需求的设备(GPU 或 TPU)中,并执行训练。
tf.estimator.Estimator API 将前两阶段合并在input_fn函数的实现中,该函数被传递给train和evaluate方法。
input_fn函数是一个 Python 函数,它返回一个tf.data.Dataset对象,该对象生成模型消耗的features和labels对象,仅此而已。
正如在第一章中提出的理论所知,什么是机器学习?,使用数据集的正确方式是将其分为三个不重叠的部分:训练集、验证集和测试集。
为了正确实现这一点,建议定义一个输入函数,该函数接受一个输入参数,能够改变返回的tf.data.Dataset对象,并返回一个新的函数作为输入传递给 Estimator 对象。Estimator API 包含了模式(mode)的概念。
模型和数据集也可能处于不同的模式,这取决于我们处于管道的哪个阶段。模式通过enum类型tf.estimator.ModeKeys来实现,该类型包含三个标准键:
-
TRAIN:训练模式 -
EVAL:评估模式 -
PREDICT:推理模式
因此,可以使用tf.estimator.ModeKeys输入变量来改变返回的数据集(这一点 Estimator API 并不强制要求,反而很方便)。
假设我们有兴趣为 Fashion-MNIST 数据集的分类模型定义正确的输入管道,我们只需要获取数据,拆分数据集(由于没有提供评估集,我们将测试集分成两半),并构建我们需要的数据集对象。
输入函数的输入签名完全由开发者决定;这种自由度允许我们通过将每个数据集参数作为函数输入来参数化地定义数据集对象:
(tf2)
import tensorflow as tf
from tensorflow.keras.datasets import fashion_mnist
def get_input_fn(mode, batch_size=32, num_epochs=1):
(train_x, train_y), (test_x, test_y) = fashion_mnist.load_data()
half = test_x.shape[0] // 2
if mode == tf.estimator.ModeKeys.TRAIN:
input_x, input_y = train_x, train_y
train = True
elif mode == tf.estimator.ModeKeys.EVAL:
input_x, input_y = test_x[:half], test_y[:half]
train = False
elif mode == tf.estimator.ModeKeys.PREDICT:
input_x, input_y = test_x[half:-1], test_y[half:-1]
train = False
else:
raise ValueError("tf.estimator.ModeKeys required!")
def scale_fn(image, label):
return (
(tf.image.convert_image_dtype(image, tf.float32) - 0.5) * 2.0,
tf.cast(label, tf.int32),
)
def input_fn():
dataset = tf.data.Dataset.from_tensor_slices(
(tf.expand_dims(input_x, -1), tf.expand_dims(input_y, -1))
).map(scale_fn)
if train:
dataset = dataset.shuffle(10).repeat(num_epochs)
dataset = dataset.batch(batch_size).prefetch(1)
return dataset
return input_fn
在定义输入函数之后,Estimator API 引入的编程模型为我们提供了两种选择:通过手动定义要训练的模型来创建我们自己的自定义估算器,或者使用所谓的现成(预制)估算器。
自定义估算器
现成估算器和自定义估算器共享相同的架构:它们的目标是构建一个tf.estimator.EstimatorSpec对象,该对象完整定义了将由tf.estimator.Estimator执行的模型;因此,任何model_fn的返回值就是 Estimator 规格。
model_fn函数遵循以下签名:
model_fn(
features,
labels,
mode = None,
params = None,
config = None
)
函数参数如下:
-
features是从input_fn返回的第一个项目。 -
labels是从input_fn返回的第二个项目。 -
mode是一个tf.estimator.ModeKeys对象,指定模型的状态,是处于训练、评估还是预测阶段。 -
params是一个包含超参数的字典,可以用来轻松调优模型。 -
config是一个tf.estimator.RunConfig对象,它允许你配置与运行时执行相关的参数,例如模型参数目录和要使用的分布式节点数量。
请注意,features、labels 和 mode 是model_fn定义中最重要的部分,model_fn的签名必须使用这些参数名称;否则,会抛出ValueError异常。
要求输入签名与模型完全匹配,证明估算器必须在标准场景中使用,在这些场景中,整个机器学习管道可以从这种标准化中获得巨大的加速。
model_fn的目标是双重的:它必须使用 Keras 定义模型,并定义在不同mode下的行为。指定行为的方式是返回一个正确构建的tf.estimator.EstimatorSpec。
由于使用 Estimator API 编写模型函数非常简单,下面是使用 Estimator API 解决分类问题的完整实现。模型定义是纯 Keras 的,所使用的函数是之前定义的make_model(num_classes)。
我们邀请你仔细观察当mode参数变化时模型行为的变化:
重要:虽然 Estimator API 存在于 TensorFlow 2.0 中,但它仍然在图模式下工作。因此,model_fn 可以使用 Keras 来构建模型,但训练和摘要日志操作必须使用tf.compat.v1兼容模块来定义。
请参阅第三章,TensorFlow 图架构,以更好地理解图定义。(tf2)
def model_fn(features, labels, mode):
v1 = tf.compat.v1
model = make_model(10)
logits = model(features)
if mode == tf.estimator.ModeKeys.PREDICT:
# Extract the predictions
predictions = v1.argmax(logits, -1)
return tf.estimator.EstimatorSpec(mode, predictions=predictions)
loss = v1.reduce_mean(
v1.nn.sparse_softmax_cross_entropy_with_logits(
logits=logits, labels=v1.squeeze(labels)
)
)
global_step = v1.train.get_global_step()
# Compute evaluation metrics.
accuracy = v1.metrics.accuracy(
labels=labels, predictions=v1.argmax(logits, -1), name="accuracy"
)
# The metrics dictionary is used by the estimator during the evaluation
metrics = {"accuracy": accuracy}
if mode == tf.estimator.ModeKeys.EVAL:
return tf.estimator.EstimatorSpec(mode, loss=loss, eval_metric_ops=metrics)
if mode == tf.estimator.ModeKeys.TRAIN:
opt = v1.train.AdamOptimizer(1e-4)
train_op = opt.minimize(
loss, var_list=model.trainable_variables, global_step=global_step
)
return tf.estimator.EstimatorSpec(mode, loss=loss, train_op=train_op)
raise NotImplementedError(f"Unknown mode {mode}")
model_fn函数的工作方式与 TensorFlow 1.x 的标准图模型完全相同;整个模型的行为(三种可能的情况)都被编码在该函数中,函数返回的 Estimator 规格中。
训练和评估模型性能需要在每个训练周期结束时编写几行代码:
(tf2)
print("Every log is on TensorBoard, please run TensorBoard --logidr log")
estimator = tf.estimator.Estimator(model_fn, model_dir="log")
for epoch in range(50):
print(f"Training for the {epoch}-th epoch")
estimator.train(get_input_fn(tf.estimator.ModeKeys.TRAIN, num_epochs=1))
print("Evaluating...")
estimator.evaluate(get_input_fn(tf.estimator.ModeKeys.EVAL))
50 个训练周期的循环显示,估计器 API 会自动处理恢复模型参数并在每次.train调用结束时保存它们,无需用户干预,完全自动化。
通过运行TensorBoard --logdir log,可以查看损失和准确率的趋势。橙色表示训练过程,而蓝色表示验证过程:

TensorBoard 中显示的验证准确率以及训练和验证损失值
编写自定义估计器要求你思考 TensorFlow 图架构,并像在 1.x 版本中一样使用它们。
在 TensorFlow 2.0 中,像 1.x 版本一样,可以使用预制估计器自动定义计算图,这些估计器自动定义model_fn函数,而无需考虑图的方式。
预制估计器
TensorFlow 2.0 有两种不同类型的预制 Estimator:一种是自动从 Keras 模型定义中创建的,另一种是基于 TensorFlow 1.x API 构建的现成估计器。
使用 Keras 模型
在 TensorFlow 2.0 中构建 Estimator 对象的推荐方式是使用 Keras 模型本身。
tf.keras.estimator包提供了将tf.keras.Model对象自动转换为其 Estimator 对等体所需的所有工具。实际上,当 Keras 模型被编译时,整个训练和评估循环已经定义;因此,compile方法几乎定义了一个 Estimator-like 架构,tf.keras.estimator包可以使用该架构。
即使使用 Keras,你仍然必须始终定义tf.estimator.EstimatorSpec对象,这些对象定义了在训练和评估阶段使用的input_fn函数。
无需为这两种情况定义单独的EstimatorSpec对象,但可以并建议使用tf.estimator.TrainSpec和tf.estimator.EvalSpec分别定义模型的行为。
因此,给定通常的make_model(num_classes)函数,该函数创建一个 Keras 模型,实际上很容易定义规格并将模型转换为估计器:
(tf2)
# Define train & eval specs
train_spec = tf.estimator.TrainSpec(input_fn=get_input_fn(tf.estimator.ModeKeys.TRAIN, num_epochs=50))
eval_spec = tf.estimator.EvalSpec(input_fn=get_input_fn(tf.estimator.ModeKeys.EVAL, num_epochs=1))
# Get the Keras model
model = make_model(10)
# Compile it
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
# Convert it to estimator
estimator = tf.keras.estimator.model_to_estimator(
keras_model = model
)
# Train and evalution loop
tf.estimator.train_and_evaluate(estimator, train_spec, eval_spec)
使用现成的估计器
模型架构基本上是标准的:卷积神经网络由卷积层和池化层交替组成;全连接神经网络由堆叠的密集层组成,每一层有不同数量的隐藏单元,依此类推。
tf.estimator包包含了大量预制的模型,随时可以使用。完整的列表可以在文档中查看:www.tensorflow.org/versions/r2.0/api_docs/python/tf/estimator.
data2 = tf.data.Dataset.from_generator(lambda: range(100), (tf.int32))
def l1():
for v in data:
tf.print(v)
def l2():
for v in data2:
tf.print(v)
`l1`和`l2`函数可以使用`@tf.function`转换为其图形表示吗?使用`tf.autograph`模块分析生成的代码,以解释答案。
1. 何时应使用`tf.data.Dataset.cache`方法?
1. 使用`tf.io.gfile`包将未压缩的 fashion-MNIST 数据集副本本地存储。
1. 创建一个`tf.data.Dataset`对象,读取上一点中创建的文件;使用`tf.io.gfile`包。
1. 将上一章的完整示例转换为`tf.data`。
1. 将上一章的完整示例转换为`tf.Estimator`。
1. 使用`tfds`加载`"cat_vs_dog"`数据集。查看其构建器信息:这是一个单一分割数据集。使用`tf.data.Dataset.skip`和`tf.data.dataset.take`方法将其分为三个不重叠的部分:训练集、验证集和测试集。将每张图片调整为`32x32x3`,并交换标签。
1. 使用之前创建的三个数据集来定义`input_fn`,当`mode`变化时,选择正确的分割。
1. 使用简单的卷积神经网络定义一个自定义的`model_fn`函数,用于分类猫和狗(标签交换)。在 TensorBoard 上记录结果,并衡量准确率、损失值以及验证集上输出神经元的分布。
1. 使用预设的估算器解决问题 11。是否可以使用一个现成的 Estimator 复现使用自定义`model_fn`函数开发的相同解决方案?
1. 从自定义 Estimator 部分展示的准确率和验证损失曲线中,可以看出模型行为不正常;这种病态情况的名称是什么?如何缓解这种情况?
1. 通过调整`loss`值和/或更改模型架构,尽量减少模型的病态情况(参考前面的问题)。你的解决方案应该至少达到 0.96 的验证准确率。
# 第三章:神经网络的应用
本节将教你如何在各个领域实现各种神经网络应用,并展示神经网络的强大功能,尤其是在使用像 TensorFlow 这样的优秀框架时。在本节结束时,你将掌握不同神经网络架构的理论与实践知识,并且你将知道如何实现它们,以及如何使用 SavedModel 格式将模型投入生产。
本节包含以下章节:
+ 第六章,*使用 TensorFlow Hub 进行图像分类*
+ 第七章,*目标检测介绍*
+ 第八章,*语义分割与自定义数据集构建器*
+ 第九章,*生成对抗网络*
+ 第十章,*将模型投入生产*
# 第七章:使用 TensorFlow Hub 进行图像分类
在本书的前几章中,我们讨论了图像分类任务。我们已经看到如何通过堆叠多个卷积层来定义卷积神经网络,并学习如何使用 Keras 来训练它。我们还了解了 eager 执行,并发现使用 AutoGraph 非常直接。
到目前为止,使用的卷积架构一直是类似 LeNet 的架构,预期输入大小为 28 x 28,每次训练时从头到尾训练,以使网络学习如何提取正确的特征来解决 fashion-MNIST 分类任务。
从零开始构建分类器,逐层定义架构,是一个非常好的教学练习,能够让你实验不同的层配置如何影响网络的表现。然而,在现实场景中,用于训练分类器的数据量通常是有限的。收集干净且正确标注的数据是一个耗时的过程,收集包含成千上万样本的数据集非常困难。而且,即使数据集的大小足够(也就是我们处于大数据范畴),训练分类器的过程依然是一个缓慢的过程;训练可能需要数小时的 GPU 时间,因为比 LeNet 架构更复杂的架构是实现令人满意结果所必需的。多年来,已经开发出了不同的架构,所有这些架构都引入了一些新颖的设计,使得可以正确地分类分辨率高于 28 x 28 的彩色图像。
学术界和工业界每年都会发布新的分类架构,以提高现有技术水平。通过观察架构在像 ImageNet 这样的庞大数据集上训练和测试时的 top-1 准确率,可以衡量它们在图像分类任务中的表现。
ImageNet 是一个包含超过 1500 万张高分辨率图像的数据集,涵盖超过 22,000 个类别,所有图像都经过手动标注。**ImageNet 大规模视觉识别挑战赛**(**ILSVRC**)是一个年度的目标检测与分类挑战,使用 ImageNet 的一个子集,包含 1000 个类别的 1000 张图像。用于计算的数据集大致包括 120 万张训练图像、5 万张验证图像和 10 万张测试图像。
为了在图像分类任务中取得令人印象深刻的结果,研究人员发现需要使用深度架构。这种方法有一个缺点——网络越深,训练的参数数量就越多。但更多的参数意味着需要大量的计算能力(而计算能力是有成本的!)。既然学术界和工业界已经开发并训练了他们的模型,为什么我们不利用他们的工作来加速我们的开发,而不是每次都重新发明轮子呢?
在本章中,我们将讨论迁移学习和微调,展示它们如何加速开发过程。TensorFlow Hub 作为一种工具,用于快速获取所需模型并加速开发。
在本章结束时,您将了解如何使用 TensorFlow Hub 并通过其与 Keras 的集成,轻松地将模型中嵌入的知识迁移到新任务中。
在本章中,我们将讨论以下主题:
+ 获取数据
+ 迁移学习
+ 微调
# 获取数据
本章要解决的任务是一个关于花卉数据集的分类问题,该数据集可在**tensorflow-datasets**(**tfds**)中找到。该数据集名为`tf_flowers`,包含五种不同花卉物种的图像,分辨率各异。通过使用`tfds`,获取数据非常简单,我们可以通过查看`tfds.load`调用返回的`info`变量来获取数据集的信息,如下所示:
`(tf2)`
```py
import tensorflow_datasets as tfds
dataset, info = tfds.load("tf_flowers", with_info=True)
print(info)
前面的代码生成了以下的数据集描述:
tfds.core.DatasetInfo(
name='tf_flowers',
version=1.0.0,
description='A large set of images of flowers',
urls=['http://download.tensorflow.org/example_images/flower_photos.tgz'],
features=FeaturesDict({
'image': Image(shape=(None, None, 3), dtype=tf.uint8),
'label': ClassLabel(shape=(), dtype=tf.int64, num_classes=5)
},
total_num_examples=3670,
splits={
'train': <tfds.core.SplitInfo num_examples=3670>
},
supervised_keys=('image', 'label'),
citation='"""
@ONLINE {tfflowers,
author = "The TensorFlow Team",
title = "Flowers",
month = "jan",
year = "2019",
url = "http://download.tensorflow.org/example_images/flower_photos.tgz" }
"""',
redistribution_info=,
)
数据集有一个训练集划分,包含 3,670 张带标签的图像。由于Image形状特征的高度和宽度位置显示为None,图像分辨率不固定。数据集包含五个类别,正如我们预期的那样。查看数据集的download文件夹(默认为~/tensorflow_datasets/downloads/extracted),我们可以找到数据集的结构,并查看标签,具体如下:
-
雏菊
-
蒲公英
-
玫瑰
-
向日葵
-
郁金香
数据集中的每张图片都具有知识共享许可(Creative Commons by attribution)。从LICENSE.txt文件中我们可以看到,数据集是通过爬取 Flickr 网站收集的。以下是从数据集中随机选取的一张图片:

被标记为向日葵的图像。文件 sunflowers/2694860538_b95d60122c_m.jpg - 由 Ally Aubry 提供的 CC-BY 许可(www.flickr.com/photos/allyaubryphotography/2694860538/)。
数据集通常并不是由仅包含标注对象的图片组成的,这类数据集非常适合开发能够处理数据中噪声的强大算法。
数据集已经准备好,尽管它并未按指导原则正确划分。事实上,数据集只有一个划分,而推荐使用三个划分(训练集、验证集和测试集)。让我们通过创建三个独立的tf.data.Dataset对象来创建这三个不重叠的划分。我们将使用数据集对象的take和skip方法:
dataset = dataset["train"]
tot = 3670
train_set_size = tot // 2
validation_set_size = tot - train_set_size - train_set_size // 2
test_set_size = tot - train_set_size - validation_set_size
print("train set size: ", train_set_size)
print("validation set size: ", validation_set_size)
print("test set size: ", test_set_size)
train, test, validation = (
dataset.take(train_set_size),
dataset.skip(train_set_size).take(validation_set_size),
dataset.skip(train_set_size + validation_set_size).take(test_set_size),
)
好的。现在我们已经获得了所需的三种数据集划分,可以开始使用它们来训练、评估和测试我们的分类模型,该模型将通过重用别人基于不同数据集训练的模型来构建。
迁移学习
只有学术界和一些行业才具备训练整个卷积神经网络(CNN)从零开始所需的预算和计算能力,尤其是在像 ImageNet 这样的大型数据集上,从随机权重开始。
由于这项昂贵且耗时的工作已经完成,重用训练过的模型的部分来解决我们的分类问题是一个明智的做法。
事实上,可以将网络从一个数据集上学到的知识转移到一个新的数据集上,从而实现知识的迁移。
迁移学习是通过依赖先前学习的任务来学习新任务的过程:学习过程可以更快、更准确,并且需要更少的训练数据。
迁移学习的想法非常聪明,并且在使用卷积神经网络时可以成功应用。
事实上,所有用于分类的卷积架构都有一个固定结构,我们可以将它们的部分作为构建模块来为我们的应用提供支持。一般结构由三个元素组成:
-
输入层:该架构设计用于接受具有精确分辨率的图像。输入分辨率影响整个架构;如果输入层的分辨率较高,网络将会更深。
-
特征提取器:这是一组卷积层、池化层、归一化层以及位于输入层与第一个全连接层之间的所有其他层。该架构学习将输入图像中包含的所有信息总结为低维表示(在下面的图示中,大小为 227 x 227 x 3 的图像被投影到一个 9216 维的向量中)。
-
分类层:这些是一个由全连接层组成的堆叠——一个建立在分类器提取的低维输入表示之上的全连接分类器:

AlexNet 架构:第一款用于赢得 ImageNet 挑战的深度神经网络。像所有其他用于分类的卷积神经网络一样,它的结构是固定的。输入层由一个期望的输入图像组成,分辨率为 227 x 227 x 227。特征提取器是由一系列卷积层组成,后跟最大池化层以减少分辨率并向更深层推进;最后的特征图 6 x 6 x 256 被重塑为一个 6 * 6 * 256 = 9216 维的特征向量。分类层是传统的全连接架构,最终输出 1,000 个神经元,因为该网络是在 1,000 个类别上训练的。
将训练模型的知识转移到新模型中需要我们移除网络中任务特定的部分(即分类层),并将 CNN 保持为固定的特征提取器。
这种方法允许我们将预训练模型的特征提取器作为构建模块用于我们的新分类架构。在进行迁移学习时,预训练模型保持不变,而附加在特征向量上的新分类层是可训练的。
通过这种方式,我们可以通过重用在大规模数据集上学到的知识并将其嵌入到模型中来训练分类器。这带来了两个显著的优势:
-
它加速了训练过程,因为可训练的参数数量较少。
-
它有可能减轻过拟合问题,因为提取的特征来自不同的领域,训练过程无法改变它们。
到目前为止,一切都很好。迁移学习的思路很明亮,当数据集较小且资源受限时,它有助于解决一些现实问题。唯一缺失的部分,恰恰也是最重要的部分是:我们可以在哪里找到预训练模型?
正因如此,TensorFlow 团队创建了 TensorFlow Hub。
TensorFlow Hub
官方文档中对 TensorFlow Hub 的描述很好地阐明了 TensorFlow Hub 是什么以及它的作用:
TensorFlow Hub 是一个用于发布、发现和使用可重用机器学习模型组件的库。一个模块是 TensorFlow 图中的一个自包含部分,包含它的权重和资产,可以在不同任务中重复使用,这个过程被称为迁移学习。迁移学习可以:
-
使用较小的数据集训练模型
-
提升泛化能力,且
-
加速训练
因此,TensorFlow Hub 是一个我们可以浏览的库,可以在其中寻找最适合我们需求的预训练模型。TensorFlow Hub 既可以作为一个我们可以浏览的网站(tfhub.dev),也可以作为一个 Python 包使用。
安装 Python 包可以完美集成加载到 TensorFlow Hub 上的模块和 TensorFlow 2.0:
(tf2)
pip install tensorflow-hub>0.3
这就是我们所需的全部操作,就能访问与 TensorFlow 兼容且集成的完整预训练模型库。
TensorFlow 2.0 的集成非常棒——我们只需要 TensorFlow Hub 上模块的 URL,就能创建一个包含我们所需模型部分的 Keras 层!
在tfhub.dev浏览目录是直观的。接下来的截图展示了如何使用搜索引擎找到包含字符串tf2的任何模块(这是一种快速找到已上传的 TensorFlow 2.0 兼容且可用模块的方法):

TensorFlow Hub 官网(tfhub.dev):可以通过查询字符串(在这种情况下为 tf2)搜索模块,并通过左侧的过滤栏精细化搜索结果。
该库中有两种版本的模型:仅特征向量和分类模型,这意味着特征向量加上训练过的分类头。TensorFlow Hub 目录中已经包含了迁移学习所需的一切。在接下来的部分,我们将看到如何通过 Keras API 将 TensorFlow Hub 中的 Inception v3 模块轻松集成到 TensorFlow 2.0 源代码中。
使用 Inception v3 作为特征提取器
对 Inception v3 架构的完整分析超出了本书的范围;然而,值得注意的是该架构的一些特点,以便正确地在不同数据集上进行迁移学习。
Inception v3 是一个深度架构,包含 42 层,它在 2015 年赢得了ImageNet 大规模视觉识别挑战赛(ILSVRC)。其架构如下图所示:

Inception v3 架构。该模型架构复杂且非常深。网络接受一个 299 x 299 x 3 的图像作为输入,并生成一个 8 x 8 x 2,048 的特征图,这是最终部分的输入;即一个在 1,000 + 1 个 ImageNet 类别上训练的分类器。图像来源:cloud.google.com/tpu/docs/inception-v3-advanced。
网络期望输入的图像分辨率为 299 x 299 x 3,并生成一个 8 x 8 x 2,048 的特征图。它已在 ImageNet 数据集的 1,000 个类别上进行训练,输入图像已被缩放到[0,1]范围内。
所有这些信息都可以在模块页面找到,用户可以通过点击 TensorFlow Hub 网站的搜索结果来访问该页面。与前面显示的官方架构不同,在该页面上,我们可以找到关于提取特征向量的信息。文档说明它是一个 2,048 维的特征向量,这意味着所使用的特征向量并不是展平后的特征图(那将是一个 8 * 8 * 2048 维的向量),而是网络末端的一个全连接层。
了解期望的输入形状和特征向量大小对于正确地将调整大小后的图像输入到网络并附加最终层是至关重要的,知道特征向量与第一个全连接层之间会有多少连接。
更重要的是,需要了解网络是在哪个数据集上进行训练的,因为迁移学习效果良好是因为原始数据集与目标(新)数据集有一些相似的特征。以下截图展示了从 2015 年 ILSVRC 使用的数据集中收集的一些样本:

从 ILSVRC 2015 竞赛使用的数据集中收集的样本。高分辨率图像,复杂的场景和丰富的细节。
如你所见,这些图像是各种场景和主题的高分辨率图像,细节丰富。细节和主题的变化性很高。因此,我们期望学习到的特征提取器能够提取一个特征向量,作为这些特征的良好总结。这意味着,如果我们将一张与网络在训练中看到的图像具有相似特征的图像输入到预训练网络中,它将提取一个有意义的表示作为特征向量。相反,如果我们输入的图像没有类似的特征(例如,像 ImageNet 这样的简单几何形状图像,它缺乏丰富的细节),特征提取器就不太可能提取出一个好的表示。
Inception v3 的特征提取器肯定足够好,可以作为我们花卉分类器的构建模块。
将数据适配到模型中
模块页面上找到的信息还告诉我们,有必要向之前构建的数据集拆分中添加一个预处理步骤:tf_flower 图像是 tf.uint8 类型,这意味着它们的范围是 [0,255],而 Inception v3 是在 [0,1] 范围内的图像上训练的,因此它们是 tf.float32 类型:
(tf2)
def to_float_image(example):
example["image"] = tf.image.convert_image_dtype(example["image"], tf.float32)
return example
此外,Inception 架构要求固定的输入形状为 299 x 299 x 3。因此,我们必须确保所有图像都被正确调整为预期的输入大小:
(tf2)
def resize(example):
example["image"] = tf.image.resize(example["image"], (299, 299))
return example
所有必需的预处理操作已经定义好,因此我们可以准备将它们应用于 train、validation 和 test 数据集:
(tf2)
train = train.map(to_float_image).map(resize)
validation = validation.map(to_float_image).map(resize)
test = test.map(to_float_image).map(resize)
总结一下:目标数据集已经准备好;我们知道要使用哪个模型作为特征提取器;模块信息页面告诉我们需要进行一些预处理步骤,以使数据与模型兼容。
一切都已准备好设计一个使用 Inception v3 作为特征提取器的分类模型。在接下来的部分中,将展示 tensorflow-hub 模块的极易使用,得益于其与 Keras 的集成。
构建模型 - hub.KerasLayer
TensorFlow Hub Python 包已经安装好,这就是我们所需要做的全部:
-
下载模型参数和图形描述
-
恢复其图形中的参数
-
创建一个 Keras 层,包装图形并使我们能够像使用其他任何 Keras 层一样使用它
这三点操作是在 KerasLayer tensorflow-hub 函数的钩子下执行的:
import tensorflow_hub as hub
hub.KerasLayer(
"https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2",
output_shape=[2048],
trainable=False)
hub.KerasLayer 函数创建了 hub.keras_layer.KerasLayer,它是一个 tf.keras.layers.Layer 对象。因此,它可以像其他任何 Keras 层一样使用——这非常强大!
这种严格的集成使我们能够定义一个使用 Inception v3 作为特征提取器的模型,并且它具有两个全连接层作为分类层,这只需要非常少的代码行数:
(tf2)
num_classes = 5
model = tf.keras.Sequential(
[
hub.KerasLayer(
"https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2",
output_shape=[2048],
trainable=False,
),
tf.keras.layers.Dense(512),
tf.keras.layers.ReLU(),
tf.keras.layers.Dense(num_classes), # linear
]
)
由于有 Keras 集成,模型定义非常简单。所有的设置都已经完成,能够定义训练循环、衡量性能,并检查迁移学习方法是否能给出预期的分类结果。
不幸的是,从 TensorFlow Hub 下载预训练模型的过程,只有在高速互联网连接下才会快速。进度条显示下载进度默认未启用,因此,第一次构建模型时,可能需要较长时间(取决于网络速度)。
要启用进度条,hub.KerasLayer 需要使用 TFHUB_DOWNLOAD_PROGRESS 环境变量。因此,可以在脚本顶部添加以下代码片段,定义这个环境变量并将值设置为 1;这样,在第一次下载时,将会显示一个方便的进度条:
import os
os.environ["TFHUB_DOWNLOAD_PROGRESS"] = "1"
训练和评估
使用预训练的特征提取器可以加速训练,同时保持训练循环、损失函数和优化器不变,使用每个标准分类器训练的相同结构。
由于数据集标签是 tf.int64 标量,因此使用的损失函数是标准的稀疏分类交叉熵,并将 from_logits 参数设置为 True。如上一章所述,第五章,高效的数据输入管道与估算器 API,将此参数设置为 True 是一种良好的做法,因为这样损失函数本身会应用 softmax 激活函数,确保以数值稳定的方式计算,从而防止损失变为 NaN:
# Training utilities
loss = tf.losses.SparseCategoricalCrossentropy(from_logits=True)
step = tf.Variable(1, name="global_step", trainable=False)
optimizer = tf.optimizers.Adam(1e-3)
train_summary_writer = tf.summary.create_file_writer("./log/transfer/train")
validation_summary_writer = tf.summary.create_file_writer("./log/transfer/validation")
# Metrics
accuracy = tf.metrics.Accuracy()
mean_loss = tf.metrics.Mean(name="loss")
@tf.function
def train_step(inputs, labels):
with tf.GradientTape() as tape:
logits = model(inputs)
loss_value = loss(labels, logits)
gradients = tape.gradient(loss_value, model.trainable_variables)
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
step.assign_add(1)
accuracy.update_state(labels, tf.argmax(logits, -1))
return loss_value
# Configure the training set to use batches and prefetch
train = train.batch(32).prefetch(1)
validation = validation.batch(32).prefetch(1)
test = test.batch(32).prefetch(1)
num_epochs = 10
for epoch in range(num_epochs):
for example in train:
image, label = example["image"], example["label"]
loss_value = train_step(image, label)
mean_loss.update_state(loss_value)
if tf.equal(tf.math.mod(step, 10), 0):
tf.print(
step, " loss: ", mean_loss.result(), " acccuracy: ", accuracy.result()
)
mean_loss.reset_states()
accuracy.reset_states()
# Epoch ended, measure performance on validation set
tf.print("## VALIDATION - ", epoch)
accuracy.reset_states()
for example in validation:
image, label = example["image"], example["label"]
logits = model(image)
accuracy.update_state(label, tf.argmax(logits, -1))
tf.print("accuracy: ", accuracy.result())
accuracy.reset_states()
训练循环产生了以下输出(剪辑以突出显示仅重要部分):
10 loss: 1.15977693 acccuracy: 0.527777791
20 loss: 0.626715124 acccuracy: 0.75
30 loss: 0.538604617 acccuracy: 0.8125
40 loss: 0.450686693 acccuracy: 0.834375
50 loss: 0.56412369 acccuracy: 0.828125
## VALIDATION - 0
accuracy: 0.872410059
[...]
530 loss: 0.0310602095 acccuracy: 0.986607134
540 loss: 0.0334353112 acccuracy: 0.990625
550 loss: 0.029923955 acccuracy: 0.9875
560 loss: 0.0309863128 acccuracy: 1
570 loss: 0.0372043774 acccuracy: 0.984375
580 loss: 0.0412098244 acccuracy: 0.99375
## VALIDATION - 9
accuracy: 0.866957486
在一个训练周期后,我们得到了 0.87 的验证准确率,而训练准确率甚至更低(0.83)。但是到了第十个周期结束时,验证准确率甚至下降了(0.86),而模型开始对训练数据发生过拟合。
在 练习 部分,你将找到几个使用前述代码作为起点的练习;过拟合问题应从多个角度进行处理,寻找最佳解决方法。
在开始下一个主要部分之前,值得添加一个简单的性能测量,来衡量计算单个训练周期所需的时间。
训练速度
更快速的原型设计和训练是迁移学习方法的优势之一。迁移学习在工业界被广泛使用的原因之一是它能够节省资金,减少开发和训练时间。
要测量训练时间,可以使用 Python 的 time 包。time.time() 返回当前时间戳,可以让你测量(以毫秒为单位)完成一个训练周期所需的时间。
因此,可以通过添加时间模块导入和持续时间测量来扩展前一节的训练循环:
(tf2)
from time import time
# [...]
for epoch in range(num_epochs):
start = time()
for example in train:
image, label = example["image"], example["label"]
loss_value = train_step(image, label)
mean_loss.update_state(loss_value)
if tf.equal(tf.math.mod(step, 10), 0):
tf.print(
step, " loss: ", mean_loss.result(), " acccuracy: ", accuracy.result()
)
mean_loss.reset_states()
accuracy.reset_states()
end = time()
print("Time per epoch: ", end-start)
# remeaning code
平均而言,在配备 Nvidia k40 GPU 的 Colab 笔记本(colab.research.google.com)上运行训练循环,我们获得的执行速度如下:
Time per epoch: 16.206
如下一节所示,使用预训练模型作为特征提取器的迁移学习可以显著提高速度。
有时,仅将预训练模型作为特征提取器并不是将知识从一个领域迁移到另一个领域的最佳方法,通常是因为两个领域差异太大,且所学到的特征对于解决新任务并无帮助。
在这些情况下,实际上—并且建议—不使用固定的特征提取部分,而是让优化算法去改变它,从而端到端训练整个模型。
微调
微调是迁移学习的另一种方法。两者的目标相同,都是将针对特定任务在数据集上学到的知识迁移到不同的数据集和任务上。如前一节所示,迁移学习是重用预训练模型,并且不对其特征提取部分进行任何更改;实际上,它被认为是网络的不可训练部分。
相比之下,微调则是通过继续反向传播来微调预训练网络的权重。
何时进行微调
微调网络需要有正确的硬件;通过更深的网络反向传播梯度需要在内存中加载更多的信息。非常深的网络通常是在拥有数千个 GPU 的数据中心从零开始训练的。因此,准备根据可用内存的大小,将批量大小降至最低,例如降至 1。
除了硬件要求外,在考虑微调时还有其他需要注意的不同点:
-
数据集大小:微调网络意味着使用一个具有大量可训练参数的网络,正如我们在前几章中了解到的,拥有大量参数的网络容易发生过拟合。
如果目标数据集的大小较小,那么微调网络并不是一个好主意。将网络作为固定特征提取器使用,可能会带来更好的结果。
-
数据集相似性:如果数据集的大小很大(这里的大是指大小与预训练模型训练时使用的数据集相当)且与原始数据集相似,那么微调模型可能是一个好主意。稍微调整网络参数将帮助网络专注于提取特定于该数据集的特征,同时正确地重用来自先前相似数据集的知识。
如果数据集很大且与原始数据差异很大,微调网络可能会有所帮助。事实上,从预训练模型开始,优化问题的初始解很可能接近一个好的最小值,即使数据集有不同的特征需要学习(这是因为卷积神经网络的低层通常学习每个分类任务中常见的低级特征)。
如果新数据集满足相似性和大小的限制,微调模型是一个好主意。需要特别关注的一个重要参数是学习率。在微调一个预训练模型时,我们假设模型参数是好的(并且确实是,因为它们是实现了先进成果的模型参数),因此建议使用较小的学习率。
使用较高的学习率会过度改变网络参数,而我们不希望以这种方式改变它们。相反,使用较小的学习率,我们稍微调整参数,使其适应新的数据集,而不会过度扭曲它们,从而重新利用知识而不破坏它。
当然,如果选择微调方法,必须考虑硬件要求:降低批量大小可能是使用标准 GPU 微调非常深的模型的唯一方法。
TensorFlow Hub 集成
微调从 TensorFlow Hub 下载的模型可能听起来很困难;我们需要做以下几步:
-
下载模型参数和图
-
恢复图中的模型参数
-
恢复所有仅在训练期间执行的操作(激活丢弃层并启用批量归一化层计算的移动均值和方差)
-
将新层附加到特征向量上
-
端到端训练模型
实际上,TensorFlow Hub 和 Keras 模型的集成非常紧密,我们只需在通过hub.KerasLayer导入模型时将trainable布尔标志设置为True,就能实现这一切:
(tf2)
hub.KerasLayer(
"https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2",
output_shape=[2048],
trainable=True) # <- That's all!
训练并评估
如果我们构建与前一章第五章中相同的模型,高效的数据输入管道与估算器 API,并在tf_flower数据集上进行训练,微调权重,会发生什么情况?
所以,模型如下所示;请注意优化器的学习率已从1e-3降低到1e-5:
(tf2)
optimizer = tf.optimizers.Adam(1e-5)
# [ ... ]
model = tf.keras.Sequential(
[
hub.KerasLayer(
"https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2",
output_shape=[2048],
trainable=True, # <- enables fine tuning
),
tf.keras.layers.Dense(512),
tf.keras.layers.ReLU(),
tf.keras.layers.Dense(num_classes), # linear
]
)
# [ ... ]
# Same training loop
在以下框中,展示了第一次和最后一次训练时期的输出:
10 loss: 1.59038031 acccuracy: 0.288194448
20 loss: 1.25725865 acccuracy: 0.55625
30 loss: 0.932323813 acccuracy: 0.721875
40 loss: 0.63251847 acccuracy: 0.81875
50 loss: 0.498087496 acccuracy: 0.84375
## VALIDATION - 0
accuracy: 0.872410059
[...]
530 loss: 0.000400377758 acccuracy: 1
540 loss: 0.000466914673 acccuracy: 1
550 loss: 0.000909397728 acccuracy: 1
560 loss: 0.000376881275 acccuracy: 1
570 loss: 0.000533850689 acccuracy: 1
580 loss: 0.000438459858 acccuracy: 1
## VALIDATION - 9
accuracy: 0.925845146
正如预期的那样,测试准确率达到了常数值 1;因此我们对训练集进行了过拟合。这是预期的结果,因为tf_flower数据集比 ImageNet 小且简单。然而,要清楚地看到过拟合问题,我们必须等待更长时间,因为训练更多的参数使得整个学习过程变得非常缓慢,特别是与之前在预训练模型不可训练时的训练相比。
训练速度
通过像前一节那样添加时间测量,可以看到微调过程相对于迁移学习(使用模型作为不可训练的特征提取器)而言是非常缓慢的。
事实上,如果在前面的场景中,我们每个 epoch 的平均训练时间大约为 16.2 秒,那么现在我们平均需要等待 60.04 秒,这意味着训练速度下降了 370%!
此外,值得注意的是,在第一轮训练结束时,我们达到了与之前训练相同的验证准确率,并且尽管训练数据出现了过拟合,但在第十轮训练结束时获得的验证准确率仍高于之前的结果。
这个简单的实验展示了使用预训练模型作为特征提取器可能导致比微调模型更差的性能。这意味着,网络在 ImageNet 数据集上学到的特征与分类花卉数据集所需的特征差异太大。
是否使用预训练模型作为固定特征提取器,还是对其进行微调,是一个艰难的决定,涉及到许多权衡。了解预训练模型提取的特征是否适合新任务是复杂的;仅仅通过数据集的大小和相似性来作为参考是一个指导原则,但在实际操作中,这个决策需要进行多次测试。
当然,最好先将预训练模型作为特征提取器使用,并且如果新模型的表现已经令人满意,就无需浪费时间进行微调。如果结果不理想,值得尝试使用不同的预训练模型,最后的手段是尝试微调方法(因为这需要更多的计算资源,且成本较高)。
总结
本章介绍了迁移学习和微调的概念。从头开始训练一个非常深的卷积神经网络,且初始权重为随机值,需要合适的设备,这些设备只存在于学术界和一些大公司中。此外,这还是一个高成本的过程,因为找到在分类任务上达到最先进成果的架构需要设计和训练多个模型,并且每个模型都需要重复训练过程来寻找最佳的超参数配置。
因此,迁移学习是推荐的做法,尤其是在原型设计新解决方案时,它能够加速训练时间并降低训练成本。
TensorFlow Hub 是 TensorFlow 生态系统提供的在线库,包含一个在线目录,任何人都可以浏览并搜索预训练模型,这些模型可以直接使用。模型附带了所有必要的信息,从输入大小到特征向量大小,甚至包括训练模型时使用的数据集及其数据类型。所有这些信息可用于设计正确的数据输入管道,从而确保网络接收到正确形状和数据类型的数据。
TensorFlow Hub 附带的 Python 包与 TensorFlow 2.0 和 Keras 生态系统完美集成,使你仅需知道模型的 URL(可以在 Hub 网站上找到),就能下载并使用预训练模型。
hub.KerasLayer 函数不仅可以让你下载和加载预训练模型,还可以通过切换 trainable 标志来实现迁移学习和微调的功能。
在 迁移学习 和 微调 部分,我们开发了分类模型,并使用自定义训练循环进行了训练。TensorFlow Datasets 被用来轻松下载、处理,并获取 tf.data.Dataset 对象,这些对象通过定义高效的数据输入管道来充分利用处理硬件。
本章的最后部分是关于练习:本章的大部分代码故意未完成,以便让你动手实践,更有效地学习。
使用卷积架构构建的分类模型广泛应用于各个领域,从工业到智能手机应用。通过查看图像的整体内容来进行分类是有用的,但有时这种方法的使用范围有限(图像通常包含多个物体)。因此,已经开发出了其他架构,利用卷积神经网络作为构建模块。这些架构可以在每张图像中定位并分类多个物体,这些架构广泛应用于自动驾驶汽车和许多其他令人兴奋的应用中!
在下一章,第七章,目标检测简介,将分析物体定位和分类问题,并从零开始使用 TensorFlow 2.0 构建一个能够在图像中定位物体的模型。
练习
-
描述迁移学习的概念。
-
迁移学习过程何时能够带来良好的结果?
-
迁移学习和微调之间的区别是什么?
-
如果一个模型已经在一个小数据集上进行了训练,并且数据集的方差较低(示例相似),它是否是作为固定特征提取器用于迁移学习的理想选择?
-
在 迁移学习 部分构建的花卉分类器没有在测试数据集上进行性能评估:请为其添加评估功能。
-
扩展花卉分类器源代码,使其能够在 TensorBoard 上记录指标。使用已定义的 summary writers。
-
扩展花卉分类器,以使用检查点(及其检查点管理器)保存训练状态。
-
为达到了最高验证准确度的模型创建第二个检查点。
-
由于模型存在过拟合问题,可以通过减少分类层神经元的数量来进行测试;尝试一下,看看这是否能减少过拟合问题。
-
在第一个全连接层后添加一个丢弃层,并使用不同的丢弃保留概率进行多次运行,测量其性能。选择达到最高验证准确度的模型。
-
使用为花卉分类器定义的相同模型,创建一个新的训练脚本,使用 Keras 训练循环:不要编写自定义训练循环,而是使用 Keras。
-
将前面第 11 点创建的 Keras 模型转换为估算器(estimator)。训练并评估该模型。
-
使用 TensorFlow Hub 网站查找一个轻量级的预训练模型,用于图像分类,且该模型是基于一个高方差数据集训练的。使用特征提取器版本来构建一个 fashion-MNIST 分类器。
-
使用在复杂数据集上训练的模型作为 fashion-MNIST 分类器的特征提取器的想法是一个好主意吗?提取的特征有意义吗?
-
对之前构建的 fashion-MNIST 分类器进行微调。
-
将复杂数据集微调为简单数据集的过程是否帮助我们在迁移学习中获得了更好的结果?如果是,为什么?如果不是,为什么?
-
如果使用更高的学习率来微调模型,会发生什么?尝试一下。
第八章:物体检测简介
在图像中检测和分类物体是一个具有挑战性的问题。到目前为止,我们在简单层面上处理了图像分类的问题;但在现实场景中,我们不太可能只拥有包含一个物体的图像。在工业环境中,可以设置相机和机械支撑来捕捉单个物体的图像。然而,即使在像工业这样的受限环境中,也不总是能够拥有如此严格的设置。智能手机应用、自动化引导车辆,以及更一般的,任何在非受控环境中捕捉图像的现实应用,都需要在输入图像中同时进行多个物体的定位和分类。物体检测是通过预测包含物体的边界框的坐标来定位图像中的物体,同时正确分类它的过程。
解决物体检测问题的最先进方法基于卷积神经网络,正如我们在本章中将看到的,它不仅可以用于提取有意义的分类特征,还可以回归边界框的坐标。由于这是一个具有挑战性的问题,因此最好从基础开始。检测和分类多个物体比仅解决单一物体问题需要更复杂的卷积架构设计和训练。回归单个物体的边界框坐标并对内容进行分类的任务被称为定位和分类。解决此任务是开发更复杂架构以解决物体检测任务的起点。
在本章中,我们将研究这两个问题;我们从基础开始,完全开发一个回归网络,然后将其扩展为同时执行回归和分类。章节最后将介绍基于锚点的检测器,因为完整实现物体检测网络超出了本书的范围。
本章使用的数据集是 PASCAL Visual Object Classes Challenge 2007。
本章将涵盖以下主题:
-
获取数据
-
物体定位
-
分类与定位
获取数据
物体检测是一个监督学习问题,需要大量的数据才能达到良好的性能。通过在物体周围绘制边界框并为其分配正确标签,仔细注释图像的过程是一个费时的过程,需要几个小时的重复工作。
幸运的是,已经有几个现成可用的物体检测数据集。最著名的是 ImageNet 数据集,紧随其后的是 PASCAL VOC 2007 数据集。要能够使用 ImageNet,需要专门的硬件,因为它的大小和每张图片中标注的物体数量使得物体检测任务难以完成。
相比之下,PASCAL VOC 2007 只包含 9,963 张图像,每张图像中标注的物体数量不同,且属于 20 个选定的物体类别。20 个物体类别如下:
-
Person: 人物
-
Animal: 鸟、猫、牛、狗、马、羊
-
Vehicle: 飞机、自行车、船、公共汽车、汽车、摩托车、火车
-
Indoor: 瓶子、椅子、餐桌、盆栽植物、沙发、电视/显示器
如官方数据集页面所述(host.robots.ox.ac.uk/pascal/VOC/voc2007/),该数据集已经分为三个部分(训练、验证和测试)可供使用。数据已经被划分为 50%的训练/验证集和 50%的测试集。各类图像和物体的分布在训练/验证集和测试集之间大致相等。总共有 9,963 张图像,包含 24,640 个标注的物体。
TensorFlow 数据集允许我们通过一行代码下载整个数据集(约 869 MiB),并获取每个分割的tf.data.Dataset对象:
(tf2)
import tensorflow as tf
import tensorflow_datasets as tfds
# Train, test, and validation are datasets for object detection: multiple objects per image.
(train, test, validation), info = tfds.load(
"voc2007", split=["train", "test", "validation"], with_info=True
)
和往常一样,TensorFlow 数据集提供了很多关于数据集格式的有用信息。以下输出是print(info)的结果:
tfds.core.DatasetInfo(
name='voc2007',
version=1.0.0,
description='This dataset contains the data from the PASCAL Visual Object Classes Challenge
2007, a.k.a. VOC2007, corresponding to the Classification and Detection
competitions.
A total of 9,963 images are included in this dataset, where each image contains
a set of objects, out of 20 different classes, making a total of 24,640
annotated objects.
In the Classification competition, the goal is to predict the set of labels
contained in the image, while in the Detection competition the goal is to
predict the bounding box and label of each individual object.
',
urls=['http://host.robots.ox.ac.uk/pascal/VOC/voc2007/'],
features=FeaturesDict({
'image': Image(shape=(None, None, 3), dtype=tf.uint8),
'image/filename': Text(shape=(), dtype=tf.string, encoder=None),
'labels': Sequence(shape=(None,), dtype=tf.int64, feature=ClassLabel(shape=(), dtype=tf.int64, num_classes=20)),
'labels_no_difficult': Sequence(shape=(None,), dtype=tf.int64, feature=ClassLabel(shape=(), dtype=tf.int64, num_classes=20)),
'objects': SequenceDict({'label': ClassLabel(shape=(), dtype=tf.int64, num_classes=20), 'bbox': BBoxFeature(shape=(4,), dtype=tf.float32), 'pose': ClassLabel(shape=(), dtype=tf.int64, num_classes=5), 'is_truncated': Tensor(shape=(), dtype=tf.bool), 'is_difficult'
: Tensor(shape=(), dtype=tf.bool)})
},
total_num_examples=9963,
splits={
'test': <tfds.core.SplitInfo num_examples=4952>,
'train': <tfds.core.SplitInfo num_examples=2501>,
'validation': <tfds.core.SplitInfo num_examples=2510>
},
supervised_keys=None,
citation='"""
@misc{pascal-voc-2007,
author = "Everingham, M. and Van~Gool, L. and Williams, C. K. I. and Winn, J. and Zisserman, A.",
title = "The {PASCAL} {V}isual {O}bject {C}lasses {C}hallenge 2007 {(VOC2007)} {R}esults",
howpublished = "http://www.pascal-network.org/challenges/VOC/voc2007/workshop/index.html"}
"""',
redistribution_info=,
)
对于每张图像,都有一个SequenceDict对象,其中包含每个标注物体的信息。在处理任何数据相关项目时,查看数据非常方便。在这个案例中,特别是因为我们正在解决一个计算机视觉问题,查看图像和边界框可以帮助我们更好地理解网络在训练过程中应该面对的难题。
为了可视化标注图像,我们使用matplotlib.pyplot结合使用tf.image包;前者用于显示图像,后者用于绘制边界框并将其转换为tf.float32(从而将值缩放到[0,1]的范围内)。此外,演示了如何使用tfds.ClassLabel.int2str方法;这个方法非常方便,因为它允许我们从标签的数值表示中获取文本表示:
(tf2)
import matplotlib.pyplot as plt
从训练集获取五张图像,绘制边界框,然后打印类别:
with tf.device("/CPU:0"):
for row in train.take(5):
obj = row["objects"]
image = tf.image.convert_image_dtype(row["image"], tf.float32)
for idx in tf.range(tf.shape(obj["label"])[0]):
image = tf.squeeze(
tf.image.draw_bounding_boxes(
images=tf.expand_dims(image, axis=[0]),
boxes=tf.reshape(obj["bbox"][idx], (1, 1, 4)),
colors=tf.reshape(tf.constant((1.0, 1.0, 0, 0)), (1, 4)),
),
axis=[0],
)
print(
"label: ", info.features["objects"]["label"].int2str(obj["label"][idx])
)
然后,使用以下代码绘制图像:
plt.imshow(image)
plt.show()
以下图像是由代码片段生成的五张图像的拼贴画:

请注意,由于 TensorFlow 数据集在创建 TFRecords 时会对数据进行打乱,因此在不同机器上执行相同的操作时,不太可能产生相同的图像顺序。
还值得注意的是,部分物体被标注为完整物体;例如,左下角图像中的人类手被标记为一个人,图片右下角的摩托车后轮被标记为摩托车。
物体检测任务本质上具有挑战性,但通过查看数据,我们可以看到数据本身很难使用。事实上,打印到标准输出的右下角图像的标签是:
-
人物
-
鸟类
因此,数据集包含了完整的物体标注和标签(鸟类),以及部分物体标注并被标记为完整物体(例如,人类的手被标记为一个人)。这个简单的例子展示了物体检测的困难:网络应该能够根据属性(如手)或完整形状(如人)进行分类和定位,同时解决遮挡问题。
查看数据让我们更清楚问题的挑战性。然而,在面对物体检测的挑战之前,最好从基础开始,先解决定位和分类的问题。因此,我们必须过滤数据集中的物体,仅提取包含单个标注物体的图像。为此,可以定义并使用一个简单的函数,该函数接受tf.data.Dataset对象作为输入并对其进行过滤。通过过滤元素创建数据集的子集:我们感兴趣的是创建一个用于物体检测和分类的数据集,即一个包含单个标注物体的图像数据集:
(tf2)
def filter(dataset):
return dataset.filter(lambda row: tf.equal(tf.shape(row["objects"]["label"])[0], 1))
train, test, validation = filter(train), filter(test), filter(validation)
使用之前的代码片段,我们可以可视化一些图像,以检查是否一切如我们所预期:

我们可以看到从训练集抽取的、只包含单个物体的图像,使用之前的代码片段应用filter函数后绘制出来。filter函数返回一个新的数据集,该数据集仅包含输入数据集中包含单个边界框的元素,因此它们是训练单个网络进行分类和定位的完美候选。
物体定位
卷积神经网络(CNN)是极其灵活的对象——到目前为止,我们已经使用它们解决分类问题,让它们学习提取特定任务的特征。如在第六章《使用 TensorFlow Hub 进行图像分类》中所示,设计用于分类图像的 CNN 标准架构由两部分组成——特征提取器,它生成特征向量,以及一组全连接层,用于将特征向量分类到(希望是)正确的类别:

放置在特征向量顶部的分类器也可以看作是网络的头部
到目前为止,卷积神经网络(CNN)仅被用来解决分类问题,这一点不应误导我们。这些类型的网络非常强大,特别是在多层设置下,它们可以用来解决多种不同类型的问题,从视觉输入中提取信息。
因此,解决定位和分类问题的关键只是向网络中添加一个新的头,即定位头。
输入数据是一张包含单一物体以及边界框四个坐标的图像。因此,目标是利用这些信息通过将定位问题视为回归问题,来同时解决分类和定位问题。
将定位视为回归问题
暂时忽略分类问题,专注于定位部分,我们可以将定位问题视为回归输入图像中包含物体的边界框的四个坐标的问题。
实际上,训练 CNN 来解决分类任务或回归任务并没有太大区别:特征提取器的架构保持不变,而分类头则变成回归头。最终,这只是意味着将输出神经元的数量从类别数更改为 4,每个坐标一个神经元。
其理念是,当某些输入特征存在时,回归头应该学习输出正确的坐标。

使用 AlexNet 架构作为特征提取器,并将分类头替换为一个具有四个输出神经元的回归头
为了使网络学习回归物体边界框的坐标,我们必须使用损失函数来表达神经元和标签之间的输入/输出关系(即数据集中存在的边界框四个坐标)。
L2 距离可以有效地用作损失函数:目标是正确回归四个坐标,从而最小化预测值与真实值之间的距离,使其趋近于零:

第一个元组 ![] 是回归头输出,第二个元组 ![] 表示真实的边界框坐标。
在 TensorFlow 2.0 中实现回归网络是直接的。如 第六章 《使用 TensorFlow Hub 进行图像分类》所示,可以通过使用 TensorFlow Hub 下载并嵌入预训练的特征提取器来加速训练阶段。
值得指出的一个细节是 TensorFlow 用于表示边界框坐标(以及一般坐标)的方法—使用的格式是[ymin, xmin, ymax, xmax],并且坐标在[0,1]范围内进行归一化,以避免依赖于原始图像分辨率。
使用 TensorFlow 2.0 和 TensorFlow Hub,我们可以通过几行代码在 PASCAL VOC 2007 数据集上定义并训练坐标回归网络。
使用来自 TensorFlow Hub 的 Inception v3 网络作为坐标回归网络的骨干,定义回归模型是直接的。尽管该网络具有顺序结构,我们通过函数式 API 定义它,因为这将使我们能够轻松扩展模型,而无需重写:
(tf2)
import tensorflow_hub as hub
inputs = tf.keras.layers.Input(shape=(299,299,3))
net = hub.KerasLayer(
"https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2",
output_shape=[2048],
trainable=False,
)(inputs)
net = tf.keras.layers.Dense(512)(net)
net = tf.keras.layers.ReLU()(net)
coordinates = tf.keras.layers.Dense(4, use_bias=False)(net)
regressor = tf.keras.Model(inputs=inputs, outputs=coordinates)
此外,由于我们决定使用需要 299 x 299 输入图像分辨率且值在[0,1]范围内的 Inception 网络,我们需要在输入管道中增加额外的步骤来准备数据:
(tf2)
def prepare(dataset):
def _fn(row):
row["image"] = tf.image.convert_image_dtype(row["image"], tf.float32)
row["image"] = tf.image.resize(row["image"], (299, 299))
return row
return dataset.map(_fn)
train, test, validation = prepare(train), prepare(test), prepare(validation)
如前所述,使用的损失函数是标准的 L2 损失,TensorFlow 已经将其作为 Keras 损失实现,可以在tf.losses包中找到。然而,值得注意的是,我们自己定义损失函数,而不是使用tf.losses.MeanSquaredError,因为有一个细节需要强调。
如果我们决定使用已实现的均方误差(MSE)函数,我们必须考虑到,在底层使用了tf.subtract操作。该操作仅仅计算左侧操作数与右侧操作数的差值。这种行为是我们所期望的,但 TensorFlow 中的减法操作遵循 NumPy 的广播语义(几乎所有数学操作都遵循此语义)。这种语义将左侧张量的值广播到右侧张量,如果右侧张量的某个维度为 1,则会将左侧张量的值复制到该位置。
由于我们选择的图像中只有一个物体,因此在"bbox"属性中只有一个边界框。因此,如果我们选择批处理大小为 32,则包含边界框的张量将具有形状(32, 1, 4)。第二个位置的 1 可能会在损失计算中引起问题,并阻止模型收敛。
因此,我们有两个选择:
-
使用 Keras 定义损失函数,通过使用
tf.squeeze去除一维维度 -
手动定义损失函数
实际上,手动定义损失函数使我们能够在函数体内放置tf.print语句,这可以用于原始调试过程,且更重要的是,以标准方式定义训练循环,使得损失函数本身能够处理在需要时去除一维维度。
(tf2)
# First option -> this requires to call the loss l2, taking care of squeezing the input
# l2 = tf.losses.MeanSquaredError()
# Second option, it is the loss function iself that squeezes the input
def l2(y_true, y_pred):
return tf.reduce_mean(
tf.square(y_pred - tf.squeeze(y_true, axis=[1]))
)
训练循环很简单,可以通过两种不同的方式来实现:
-
编写自定义训练循环(因此使用
tf.GradientTape对象) -
使用 Keras 模型的
compile和fit方法,因为这是 Keras 为我们构建的标准训练循环。
然而,由于我们有兴趣在接下来的章节中扩展此解决方案,最好开始使用自定义训练循环,因为它提供了更多的自定义自由度。此外,我们有兴趣通过在 TensorBoard 上记录它们来可视化真实值和预测的边界框。
因此,在定义训练循环之前,值得定义一个draw函数,该函数接受数据集、模型和当前步骤,并利用它们来绘制真实框和预测框:
(tf2)
def draw(dataset, regressor, step):
with tf.device("/CPU:0"):
row = next(iter(dataset.take(3).batch(3)))
images = row["image"]
obj = row["objects"]
boxes = regressor(images)
tf.print(boxes)
images = tf.image.draw_bounding_boxes(
images=images, boxes=tf.reshape(boxes, (-1, 1, 4))
)
images = tf.image.draw_bounding_boxes(
images=images, boxes=tf.reshape(obj["bbox"], (-1, 1, 4))
)
tf.summary.image("images", images, step=step)
我们的坐标回归器的训练循环(它也可以被视为一个区域提议,因为它现在已经知道它正在图像中检测的物体的标签),同时在 TensorBoard 上记录训练损失值和来自训练集和验证集的三个样本图像的预测(使用draw函数),可以很容易地定义:
- 定义
global_step变量,用于跟踪训练迭代,然后定义文件写入器,用于记录训练和验证摘要:
optimizer = tf.optimizers.Adam()
epochs = 500
batch_size = 32
global_step = tf.Variable(0, trainable=False, dtype=tf.int64)
train_writer, validation_writer = (
tf.summary.create_file_writer("log/train"),
tf.summary.create_file_writer("log/validation"),
)
with validation_writer.as_default():
draw(validation, regressor, global_step)
- 根据 TensorFlow 2.0 的最佳实践,我们可以将训练步骤定义为一个函数,并使用
tf.function将其转换为图形表示:
@tf.function
def train_step(image, coordinates):
with tf.GradientTape() as tape:
loss = l2(coordinates, regressor(image))
gradients = tape.gradient(loss, regressor.trainable_variables)
optimizer.apply_gradients(zip(gradients, regressor.trainable_variables))
return loss
- 在每个批次上定义训练循环,并在每次迭代中调用
train_step函数:
train_batches = train.cache().batch(batch_size).prefetch(1)
with train_writer.as_default():
for _ in tf.range(epochs):
for batch in train_batches:
obj = batch["objects"]
coordinates = obj["bbox"]
loss = train_step(batch["image"], coordinates)
tf.summary.scalar("loss", loss, step=global_step)
global_step.assign_add(1)
if tf.equal(tf.mod(global_step, 10), 0):
tf.print("step ", global_step, " loss: ", loss)
with validation_writer.as_default():
draw(validation, regressor, global_step)
with train_writer.as_default():
draw(train, regressor, global_step)
尽管使用了 Inception 网络作为固定的特征提取器,但训练过程在 CPU 上可能需要几个小时,而在 GPU 上则几乎需要半个小时。
以下截图显示了训练过程中损失函数的可见趋势:

我们可以看到,从早期的训练步骤开始,损失值接近零,尽管在整个训练过程中会出现波动。
在训练过程中,在 TensorBoard 的图像标签中,我们可以可视化带有回归框和真实边界框的图像。由于我们创建了两个不同的日志记录器(一个用于训练日志,另一个用于验证日志),TensorFlow 为我们可视化了两个不同数据集的图像:

上述图像是来自训练集(第一行)和验证集(第二行)的样本,包含真实框和回归边界框。训练集中的回归边界框接近真实框,而验证集中的回归框则有所不同。
之前定义的训练循环存在各种问题:
-
唯一被测量的指标是 L2 损失。
-
验证集从未用于衡量任何数值分数。
-
没有进行过拟合检查。
-
完全缺乏一个衡量回归边界框质量的指标,既没有在训练集上,也没有在验证集上进行衡量。
因此,训练循环可以通过测量目标检测指标来改进;测量该指标还可以减少训练时间,因为我们可以提前停止训练。此外,从结果的可视化中可以明显看出,模型正在过拟合训练集,可以添加正则化层(如 dropout)来解决这个问题。回归边界框的问题可以视为一个二分类问题。事实上,只有两种可能的结果:真实边界框匹配或不匹配。
当然,达到完美匹配并非易事;因此,需要一个衡量检测到的边界框与真实值之间好坏的数值评分函数。最常用的用于衡量定位好坏的函数是交并比(IoU),我们将在下一节中详细探讨。
交并比(IoU)
交并比(IoU)定义为重叠区域与并集区域的比率。以下图像是 IoU 的图示:

版权归属:Jonathan Hui (medium.com/@jonathan_hui/map-mean-average-precision-for-object-detection-45c121a31173)
在实践中,IoU 衡量的是预测的边界框与真实边界框的重叠程度。由于 IoU 是一个使用物体区域的指标,因此可以很容易地将真实值和检测区域视为集合来表示。设 A 为提议物体像素的集合,B 为真实物体像素的集合;则 IoU 定义为:

IoU 值在[0,1]范围内,其中 0 表示无匹配(没有重叠),1 表示完美匹配。IoU 值用作重叠标准;通常,IoU 值大于 0.5 被认为是正匹配(真正例),而其他值被视为假匹配(假正例)。没有真正的负例。
在 TensorFlow 中实现 IoU 公式非常简单。唯一需要注意的细节是,需要对坐标进行反归一化,因为面积应该以像素为单位来计算。像素坐标的转换以及更友好的坐标交换表示是在_swap闭包中实现的:
(tf2)
def iou(pred_box, gt_box, h, w):
"""
Compute IoU between detect box and gt boxes
Args:
pred_box: shape (4,): y_min, x_min, y_max, x_max - predicted box
gt_boxes: shape (4,): y_min, x_min, y_max, x_max - ground truth
h: image height
w: image width
"""
将y_min、x_min、y_max和x_max的绝对坐标转换为x_min、y_min、x_max和y_max的像素坐标:
def _swap(box):
return tf.stack([box[1] * w, box[0] * h, box[3] * w, box[2] * h])
pred_box = _swap(pred_box)
gt_box = _swap(gt_box)
box_area = (pred_box[2] - pred_box[0]) * (pred_box[3] - pred_box[1])
area = (gt_box[2] - gt_box[0]) * (gt_box[3] - gt_box[1])
xx1 = tf.maximum(pred_box[0], gt_box[0])
yy1 = tf.maximum(pred_box[1], gt_box[1])
xx2 = tf.minimum(pred_box[2], gt_box[2])
yy2 = tf.minimum(pred_box[3], gt_box[3])
然后,计算边界框的宽度和高度:
w = tf.maximum(0, xx2 - xx1)
h = tf.maximum(0, yy2 - yy1)
inter = w * h
return inter / (box_area + area - inter)
平均精度
如果 IoU 值大于指定阈值(通常为 0.5),则可以将回归的边界框视为匹配。
在单类预测的情况下,计算真实正例(TP)和假正例(FP)的数量,能够使我们计算出平均精度,如下所示:

在目标检测挑战中,平均精度(AP)通常会在不同的 IoU 值下进行测量。最小要求是对 IoU 值为 0.5 时测量 AP,但在大多数实际场景中,单纯达到 0.5 的重叠并不足够。通常情况下,实际上,边界框预测需要至少匹配 IoU 值为 0.75 或 0.85 才能有用。
到目前为止,我们处理的是单类情况下的 AP,但值得讨论更一般的多类目标检测场景。
平均精度均值
在多类检测的情况下,每个回归的边界框可以包含可用类之一,评估目标检测器性能的标准指标是平均精度均值(mAP)。
计算它非常简单——mAP 是数据集中每个类别的平均精度:

了解用于目标检测的指标后,我们可以通过在每个训练周期结束时,在验证集上添加此测量,并每十步在一批训练数据上进行测量,从而改进训练脚本。由于目前定义的模型仅是一个没有类别的坐标回归器,因此测量的指标将是 AP。
在 TensorFlow 中实现 mAP 非常简单,因为tf.metrics包中已经有现成的实现可用。update_state方法的第一个参数是真实标签;第二个参数是预测标签。例如,对于二分类问题,一个可能的场景如下:
(tf2)
m = tf.metrics.Precision()
m.update_state([0, 1, 1, 1], [1, 0, 1, 1])
print('Final result: ', m.result().numpy()) # Final result: 0.66
还应注意,平均精度和 IoU 并不是目标检测专有的指标,但它们可以在执行任何定位任务时使用(IoU)并测量检测精度(mAP)。
在第八章中,语义分割与自定义数据集构建器专门讨论语义分割任务,使用相同的指标来衡量分割模型的性能。唯一的区别是,IoU 是以像素级别来衡量的,而不是使用边界框。训练循环可以改进;在下一节中,将展示一个改进后的训练脚本草案,但真正的改进将留作练习。
改进训练脚本
测量平均精度(针对单一类别)需要你为 IoU 测量设置阈值,并定义tf.metrics.Precision对象,该对象计算批次上的平均精度。
为了不改变整个代码结构,draw函数不仅用于绘制地面真值和回归框,还用于测量 IoU 并记录平均精度的总结:
(tf2)
# IoU threshold
threshold = 0.75
# Metric object
precision_metric = tf.metrics.Precision()
def draw(dataset, regressor, step):
with tf.device("/CPU:0"):
row = next(iter(dataset.take(3).batch(3)))
images = row["image"]
obj = row["objects"]
boxes = regressor(images)
images = tf.image.draw_bounding_boxes(
images=images, boxes=tf.reshape(boxes, (-1, 1, 4))
)
images = tf.image.draw_bounding_boxes(
images=images, boxes=tf.reshape(obj["bbox"], (-1, 1, 4))
)
tf.summary.image("images", images, step=step)
true_labels, predicted_labels = [], []
for idx, predicted_box in enumerate(boxes):
iou_value = iou(predicted_box, tf.squeeze(obj["bbox"][idx]), 299, 299)
true_labels.append(1)
predicted_labels.append(1 if iou_value >= threshold else 0)
precision_metric.update_state(true_labels, predicted_labels)
tf.summary.scalar("precision", precision_metric.result(), step=step)
作为一个练习(参见练习部分),你可以使用这段代码作为基线并重新组织结构,以便改善代码的组织方式。改善代码组织后,建议重新训练模型并分析精度图。
仅仅进行物体定位,而没有关于物体类别的信息,实用性有限,但在实践中,这是任何物体检测算法的基础。
分类与定位
目前定义的这种架构,没有关于它正在定位的物体类别的信息,称为区域提议。
使用单一神经网络进行物体检测和定位是可行的。事实上,完全可以在特征提取器的顶部添加第二个头,并训练它对图像进行分类,同时训练回归头来回归边界框坐标。
同时解决多个任务是多任务学习的目标。
多任务学习
Rich Caruna 在他的论文多任务学习(1997 年)中定义了多任务学习:
“多任务学习是一种归纳迁移方法,它通过利用相关任务训练信号中的领域信息作为归纳偏差来改善泛化能力。它通过并行学习任务,同时使用共享的表示;每个任务学到的内容可以帮助其他任务更好地学习。”
在实践中,多任务学习是机器学习的一个子领域,明确的目标是解决多个不同的任务,利用任务之间的共性和差异。经实验证明,使用相同的网络来解决多个任务,通常比使用同一网络分别训练解决每个任务的效果更好,能够提高学习效率和预测准确性。
多任务学习有助于解决过拟合问题,因为神经网络不太可能将其参数适应于解决一个特定任务,因此它必须学习如何提取对解决不同任务有用的有意义特征。
双头网络
在过去的几年里,已经开发了几种用于物体检测和分类的架构,采用两步过程。第一步是使用区域提议获取可能包含物体的输入图像区域。第二步是对提议的区域使用简单的分类器进行分类。
使用双头神经网络可以使推理时间更快,因为只需要进行一次单模型的前向传播,就可以实现更好的整体性能。
从架构的角度看,假设为了简单起见我们的特征提取器是 AlexNet(而实际上是更复杂的网络 Inception V3),向网络添加新头会改变模型架构,如下截图所示:

上面的截图展示了分类和定位网络的样子。特征提取部分应该能够提取足够通用的特征,以使两个头能够使用相同的共享特征解决这两种不同的任务。
从代码的角度来看,由于我们使用了 Keras 函数式模型定义,向模型添加额外输出非常简单。实际上,这仅仅是添加组成新头的所需层数,并将最终层添加到 Keras 模型定义的输出列表中。正如本书到目前为止所展示的,这第二个头必须以等于模型将训练的类别数量的神经元结束。在我们的例子中,PASCAL VOC 2007 数据集包含 20 个不同的类别。因此,我们只需要按如下方式定义模型:
(tf2)
- 首先,从输入层定义开始:
inputs = tf.keras.layers.Input(shape=(299, 299, 3))
- 然后,使用 TensorFlow Hub,我们定义固定的(不可训练的)特征提取器:
net = hub.KerasLayer(
"https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2",
output_shape=[2048],
trainable=False,
)(inputs)
- 然后,我们定义回归头,它只是一个由全连接层堆叠组成的部分,最后以四个线性神经元结束(每个边界框坐标一个):
regression_head = tf.keras.layers.Dense(512)(net)
regression_head = tf.keras.layers.ReLU()(regression_head)
coordinates = tf.keras.layers.Dense(4, use_bias=False)(regression_head)
- 接下来,我们定义分类头,它只是一个由全连接层堆叠组成的部分,经过训练用于分类由固定(不可训练)特征提取器提取的特征:
classification_head = tf.keras.layers.Dense(1024)(net)
classification_head = tf.keras.layers.ReLU()(classificatio_head)
classification_head = tf.keras.layers.Dense(128)(net)
classification_head = tf.keras.layers.ReLU()(classificatio_head)
num_classes = 20
classification_head = tf.keras.layers.Dense(num_classes, use_bias=False)(
classification_head
)
- 最后,我们可以定义将执行分类和定位的 Keras 模型。请注意,该模型有一个输入和两个输出:
model = tf.keras.Model(inputs=inputs, outputs=[coordinates, classification_head])
使用 TensorFlow 数据集,我们拥有执行分类和定位所需的所有信息,因为每一行都是一个字典,包含图像中每个边界框的标签。此外,由于我们已过滤数据集,确保其中仅包含单个对象的图像,因此我们可以像训练分类模型一样训练分类头,如第六章所示,使用 TensorFlow Hub 进行图像分类。
训练脚本的实现留作练习(见练习部分)。训练过程的唯一特点是要使用的损失函数。为了有效地训练网络同时执行不同任务,损失函数应包含每个任务的不同项。
通常,使用不同项的加权和作为损失函数。在我们的例子中,一项是分类损失,它通常是稀疏类别交叉熵损失,另一项是回归损失(之前定义的 L2 损失):

乘法因子 ![] 是超参数,用来赋予不同任务不同的重要性(梯度更新的强度)。
使用单一物体的图像进行分类并回归唯一存在的边界框坐标,只能在有限的实际场景中应用。相反,通常情况下,给定输入图像,要求同时定位和分类多个物体(即实际的物体检测问题)。
多年来,已经提出了多种物体检测模型,最近超越所有其他模型的都是基于锚框概念的模型。我们将在下一节探讨基于锚框的检测器。
基于锚框的检测器
基于锚框的检测器依赖锚框的概念,通过单一架构一次性检测图像中的物体。
基于锚框的检测器的直观思路是将输入图像划分为多个兴趣区域(锚框),并对每个区域应用定位和回归网络。其核心思想是让网络不仅学习回归边界框的坐标并分类其内容,还要使用同一网络在一次前向传播中查看图像的不同区域。
为了训练这些模型,不仅需要一个包含注释真实框的数据集,还需要为每张输入图像添加一组新的框,这些框与真实框有一定程度的重叠(具有所需的 IoU)。
锚框
锚框是输入图像在不同区域的离散化,也称为锚点或边界框先验。锚框概念背后的思路是,输入图像可以在不同的区域中离散化,每个区域有不同的外观。一个输入图像可能包含大物体和小物体,因此,离散化应该在不同的尺度下进行,以便同时检测不同分辨率下的物体。
在将输入离散化为锚框时,重要的参数如下:
-
网格大小:输入图像如何被均匀划分
-
框的尺度级别:给定父框,如何调整当前框的大小
-
长宽比级别:对于每个框,宽度与高度之间的比率
输入图像可以被划分为一个均匀尺寸的网格,例如 4 x 4 网格。这个网格的每个单元格可以用不同的尺度(0.5、1、2 等)进行调整,并且每个单元格具有不同的长宽比级别(0.5、1、2 等)。例如,以下图片展示了如何用锚框“覆盖”一张图像:

锚点框的生成影响着网络的性能——较小框的尺寸代表着网络能够检测到的小物体的尺寸。同样的推理也适用于较大框。
在过去几年中,基于锚点的检测器已经证明它们能够达到惊人的检测性能,不仅准确,而且速度更快。
最著名的基于锚点的检测器是You Only Look Once(YOLO),其次是Single Shot MultiBox Detector(SSD)。以下 YOLO 图像检测了图像中的多个物体,且在不同的尺度下,仅通过一次前向传播:

基于锚点的检测器的实现超出了本书的范围,因为理解这些概念需要相应的理论知识,并且这些模型也非常复杂。因此,仅介绍了使用这些模型时发生的直观想法。
总结
本章介绍了目标检测的问题,并提出了一些基本的解决方案。我们首先关注了所需的数据,并使用 TensorFlow 数据集获取了可以在几行代码中直接使用的 PASCAL VOC 2007 数据集。然后,讨论了如何使用神经网络回归边界框的坐标,展示了如何轻松地利用卷积神经网络从图像表示中生成边界框的四个坐标。通过这种方式,我们构建了区域建议(Region Proposal),即一个能够建议在输入图像中检测单个物体的位置的网络,而不产生其他关于检测物体的信息。
之后,介绍了多任务学习的概念,并展示了如何使用 Keras 函数式 API 将分类头与回归头结合。接着,我们简要介绍了基于锚点的检测器。这些检测器通过将输入划分为成千上万个区域(锚点)来解决目标检测的问题(即在单一图像中检测和分类多个物体)。
我们将 TensorFlow 2.0 和 TensorFlow Hub 结合使用,使我们能够通过将 Inception v3 模型作为固定特征提取器来加速训练过程。此外,得益于快速执行,结合纯 Python 和 TensorFlow 代码简化了整个训练过程的定义。
在下一章,我们将学习语义分割和数据集构建器。
练习
你可以回答所有理论问题,也许更重要的是,努力解决每个练习中包含的所有代码挑战:
-
在获取数据部分,我们对 PASCAL VOC 2007 数据集应用了过滤函数,仅选择了包含单一物体的图像。然而,过滤过程没有考虑类别平衡问题。
创建一个函数,给定三个过滤后的数据集,首先合并它们,然后创建三个平衡的拆分(如果不能完全平衡,可以接受适度的类别不平衡)。
-
使用前面提到的拆分方式,重新训练定位和分类网络。性能变化的原因是什么?
-
交并比(IoU)度量的是啥?
-
IoU 值为 0.4 代表什么?是好的匹配还是差的匹配?
-
什么是平均精度均值(mAP)?请解释这个概念并写出公式。
-
什么是多任务学习?
-
多任务学习是如何影响单任务模型的性能的?是提高了还是降低了?
-
在目标检测领域,什么是锚点?
-
描述一个基于锚点的检测器在训练和推理过程中如何查看输入图像。
-
mAP 和 IoU 仅是目标检测的度量标准吗?
-
为了改进目标检测和定位网络的代码,添加支持在每个训练轮次结束时将模型保存到检查点,并恢复模型(以及全局步骤变量)的状态以继续训练过程。
-
定位和回归网络的代码显式使用了一个
draw函数,它不仅绘制边界框,还测量 mAP。通过为每个不同的功能创建不同的函数来改进代码质量。 -
测量网络性能的代码仅使用了三个样本。这是错误的,你能解释原因吗?请修改代码,在训练过程中使用单个训练批次,并在每个训练轮次结束时使用完整的验证集。
-
为“多头网络和多任务学习”中定义的模型编写训练脚本:同时训练回归和分类头,并在每个训练轮次结束时测量训练和验证准确率。
-
筛选 PASCAL VOC 训练、验证和测试数据集,仅保留至少包含一个人(图片中可以有其他标注的物体)的图像。
-
替换训练过的定位和分类网络中的回归和分类头,使用两个新的头部。分类头现在应该只有一个神经元,表示图像包含人的概率。回归头应回归标注为人的物体的坐标。
-
应用迁移学习来训练之前定义的网络。当“人”类别的 mAP 停止增长(容忍范围为 +/- 0.2)并持续 50 步时,停止训练过程。
-
创建一个 Python 脚本,用于生成不同分辨率和尺度的锚框。
第九章:语义分割与自定义数据集构建器
本章中,我们将分析语义分割及其面临的挑战。语义分割是一个具有挑战性的问题,目标是为图像中的每个像素分配正确的语义标签。本章的第一部分介绍了这个问题本身,它为什么重要以及可能的应用。第一部分结束时,我们将讨论著名的 U-Net 语义分割架构,并将其作为一个 Keras 模型在纯 TensorFlow 2.0 风格中实现。模型实现之前,我们将介绍为成功实现语义分割网络所需的反卷积操作。
本章的第二部分从数据集创建开始——由于在撰写时没有tfds构建器支持语义分割,我们利用这一点来介绍 TensorFlow 数据集架构,并展示如何实现一个自定义的 DatasetBuilder。在获取数据后,我们将一步步执行 U-Net 的训练过程,展示使用 Keras 和 Keras 回调函数训练此模型是多么简便。本章以通常的练习部分结束,或许是整章中最关键的部分。理解一个概念的唯一途径就是亲自动手实践。
在本章中,我们将涵盖以下主题:
-
语义分割
-
创建一个 TensorFlow DatasetBuilder
-
模型训练与评估
语义分割
与目标检测不同,目标检测的目标是检测矩形区域中的物体,图像分类的目的是为整张图像分配一个标签,而语义分割是一个具有挑战性的计算机视觉任务,目标是为输入图像的每个像素分配正确的标签:

来自 CityScapes 数据集的语义标注图像示例。每个输入图像的像素都有相应的像素标签。(来源:www.cityscapes-dataset.com/examples/)
语义分割的应用有无数个,但也许最重要的应用领域是自动驾驶和医学影像。
自动引导车和自动驾驶汽车可以利用语义分割的结果,全面理解由安装在车辆上的摄像头捕捉到的整个场景。例如,拥有道路的像素级信息可以帮助驾驶软件更好地控制汽车的位置。通过边界框来定位道路的精度远不如拥有像素级分类,从而能够独立于视角定位道路像素。
在医学影像领域,由目标检测器预测的边界框有时是有用的,有时则没有。事实上,如果任务是检测特定类型的细胞,边界框可以提供足够的信息。但是如果任务是定位血管,单纯使用边界框是不够的。正如可以想象的那样,精细分类并不是一项容易的任务,理论和实践上都面临着许多挑战。
挑战
一个棘手的挑战是获取正确的数据。由于通过图像的主要内容对图像进行分类的过程相对较快,因此有几个庞大的标注图像数据集。一个专业的标注团队每天可以轻松标注数千张图片,因为这项任务仅仅是查看图片并选择一个标签。
也有很多物体检测数据集,其中多个物体已经被定位和分类。与单纯的分类相比,这个过程需要更多的标注时间,但由于它不要求极高的精度,因此是一个相对较快的过程。
语义分割数据集则需要专门的软件和非常耐心的标注员,他们在工作中非常精确。事实上,像素级精度标注的过程可能是所有标注类型中最耗时的。因此,语义分割数据集的数量较少,图像的数量也有限。正如我们将在下一部分中看到的,专门用于数据集创建的 PASCAL VOC 2007 数据集,包含 24,640 个用于图像分类和定位任务的标注物体,但只有大约 600 张标注图像。
语义分割带来的另一个挑战是技术性的。对图像的每一个像素进行分类需要以不同于目前所见的卷积架构方式来设计卷积架构。到目前为止,所有描述的架构都遵循了相同的结构:
-
一个输入层,用于定义网络期望的输入分辨率。
-
特征提取部分是由多个卷积操作堆叠而成,这些卷积操作具有不同的步幅,或者中间夹杂池化操作,逐层减少特征图的空间范围,直到它被压缩成一个向量。
-
分类部分,给定由特征提取器生成的特征向量,训练该部分将此低维表示分类为固定数量的类别。
-
可选地,回归头部,使用相同的特征生成一组四个坐标。
然而,语义分割任务不能遵循这种结构,因为如果特征提取器仅仅是逐层减少输入的分辨率,网络又如何为输入图像的每个像素生成分类呢?
提出的一个解决方案是反卷积操作。
反卷积 – 转置卷积
我们从这一节开始时,先说明“反卷积”这一术语具有误导性。实际上,在数学和工程学中,确实存在反卷积操作,但与深度学习从业者所指的反卷积并没有太多相似之处。
在这个领域中,反卷积操作是转置卷积操作,或甚至是图像调整大小,之后再执行标准卷积操作。是的,两个不同的实现使用了相同的名称。
深度学习中的反卷积操作只保证,如果特征图是输入图和具有特定大小与步幅的卷积核之间卷积的结果,则反卷积操作将生成具有与输入相同空间扩展的特征图,前提是应用相同的卷积核大小和步幅。
为了实现这一点,首先对预处理过的输入进行标准卷积操作,在边界处不仅添加零填充,还在特征图单元内进行填充。以下图示有助于澄清这一过程:

图像及说明来源:《深度学习卷积算术指南》——Vincent Dumoulin 和 Francesco Visin
TensorFlow 通过tf.keras.layers包,提供了一个现成可用的反卷积操作:tf.keras.layers.Conv2DTranspose。
执行反卷积的另一种可能方式是将输入调整为所需的分辨率,并通过在调整后的图像上添加标准的 2D 卷积(保持相同的填充)来使该操作可学习。
简而言之,在深度学习的背景下,真正重要的是创建一个可学习的层,能够重建原始空间分辨率并执行卷积操作。这并不是卷积操作的数学逆过程,但实践表明,这样做足以取得良好的结果。
使用反卷积操作并在医学图像分割任务中取得显著成果的语义分割架构之一是 U-Net 架构。
U-Net 架构
U-Net 是一种用于语义分割的卷积架构,由 Olaf Ronnerberg 等人在《用于生物医学图像分割的卷积网络》一文中提出,明确目标是分割生物医学图像。
该架构被证明足够通用,可以应用于所有语义分割任务,因为它在设计时并未对数据类型施加任何限制。
U-Net 架构遵循典型的编码器-解码器架构模式,并具有跳跃连接。采用这种设计架构的方式,在目标是生成与输入具有相同空间分辨率的输出时,已经证明非常有效,因为它允许梯度在输出层和输入层之间更好地传播:

U-Net 架构。蓝色框表示由模块产生的特征图,并标明了它们的形状。白色框表示复制并裁剪后的特征图。不同的箭头表示不同的操作。来源:Convolutional Networks for Biomedical Image Segmentation—Olaf Ronnerberg 等。
U-Net 架构的左侧是一个编码器,它逐层将输入大小从 572 x 572 缩小到最低分辨率下的 32 x 32。右侧包含架构的解码部分,它将从编码部分提取的信息与通过上卷积(反卷积)操作学到的信息进行混合。
原始的 U-Net 架构并不输出与输入相同分辨率的结果,但它设计为输出稍微低一些分辨率的结果。最终的 1 x 1 卷积被用作最后一层,将每个特征向量(深度为 64)映射到所需的类别数量。要全面评估原始架构,请仔细阅读 Olaf Ronnerberg 等人撰写的 Convolutional Networks for Biomedical Image Segmentation 中的原始 U-Net 论文。
我们将展示如何实现一个略微修改过的 U-Net,它输出的分辨率与输入相同,并且遵循相同的原始模块组织方式,而不是实现原始的 U-Net 架构。
从架构的截图中可以看到,主要有两个模块:
-
编码模块:有三个卷积操作,接着是一个下采样操作。
-
解码模块:这是一种反卷积操作,接着将其输出与对应的输入特征进行连接,并进行两次卷积操作。
使用 Keras 函数式 API 定义这个模型并连接这些逻辑模块是可能的,而且非常简单。我们将要实现的架构与原始架构略有不同,因为这是一个自定义的 U-Net 变体,它展示了 Keras 如何允许将模型作为层(或构建模块)来使用。
upsample 和 downsample 函数作为 Sequential 模型实现,该模型实际上是一个卷积或反卷积操作,步幅为 2,并随后使用一个激活函数:
(tf2)
import tensorflow as tf
import math
def downsample(depth):
return tf.keras.Sequential(
[
tf.keras.layers.Conv2D(
depth, 3, strides=2, padding="same", kernel_initializer="he_normal"
),
tf.keras.layers.LeakyReLU(),
]
)
def upsample(depth):
return tf.keras.Sequential(
[
tf.keras.layers.Conv2DTranspose(
depth, 3, strides=2, padding="same", kernel_initializer="he_normal"
),
tf.keras.layers.ReLU(),
]
)
模型定义函数假设最小输入分辨率为 256 x 256,并实现了架构中的编码、解码和连接(跳跃连接)模块:
(tf2)
def get_unet(input_size=(256, 256, 3), num_classes=21):
# Downsample from 256x256 to 4x4, while adding depth
# using powers of 2, startin from 2**5\. Cap to 512.
encoders = []
for i in range(2, int(math.log2(256))):
depth = 2 ** (i + 5)
if depth > 512:
depth = 512
encoders.append(downsample(depth=depth))
# Upsample from 4x4 to 256x256, reducing the depth
decoders = []
for i in reversed(range(2, int(math.log2(256)))):
depth = 2 ** (i + 5)
if depth < 32:
depth = 32
if depth > 512:
depth = 512
decoders.append(upsample(depth=depth))
# Build the model by invoking the encoder layers with the correct input
inputs = tf.keras.layers.Input(input_size)
concat = tf.keras.layers.Concatenate()
x = inputs
# Encoder: downsample loop
skips = []
for conv in encoders:
x = conv(x)
skips.append(x)
skips = reversed(skips[:-1])
# Decoder: input + skip connection
for deconv, skip in zip(decoders, skips):
x = deconv(x)
x = tf.keras.layers.Concatenate()([x, skip])
# Add the last layer on top and define the model
last = tf.keras.layers.Conv2DTranspose(
num_classes, 3, strides=2, padding="same", kernel_initializer="he_normal")
outputs = last(x)
return tf.keras.Model(inputs=inputs, outputs=outputs)
使用 Keras,不仅可以可视化模型的表格摘要(通过 Keras 模型的 summary() 方法),还可以获得所创建模型的图形表示,这在设计复杂架构时常常是一个福音:
(tf2)
from tensorflow.keras.utils import plot_model
model = get_unet()
plot_model(model, to_file="unet.png")
这三行代码生成了这个出色的图形表示:

定义的 U-Net 类似结构的图形表示。Keras 允许进行这种可视化,以帮助架构设计过程。
生成的图像看起来像是 U-net 架构的水平翻转版本,这也是我们将在本章中用来解决语义分割问题的架构。
现在我们已经理解了问题,并定义了深度架构,可以继续前进并收集所需的数据。
创建一个 TensorFlow DatasetBuilder
与任何其他机器学习问题一样,第一步是获取数据。由于语义分割是一个监督学习任务,我们需要一个图像及其相应标签的分类数据集。特殊之处在于,标签本身也是一张图像。
在撰写本文时,TensorFlow Datasets 中没有现成可用的语义数据集。因此,我们不仅在本节中创建需要的 tf.data.Dataset,还要了解开发 tfds DatasetBuilder 所需的过程。
由于在上一节的目标检测部分中,我们使用了 PASCAL VOC 2007 数据集,因此我们将重新使用下载的文件来创建 PASCAL VOC 2007 数据集的语义分割版本。以下截图展示了数据集的提供方式。每张图片都有一个对应的标签,其中像素颜色代表不同的类别:

从数据集中采样的一对(图像,标签)。上方的图像是原始图像,而下方的图像包含了已知物体的语义分割类别。每个未知类别都标记为背景(黑色),而物体则使用白色标出。
之前下载的数据集不仅包含了标注的边界框,还包括了许多图像的语义分割注释。TensorFlow Datasets 将原始数据下载到默认目录(~/tensorflow_datasets/downloads/),并将提取的归档文件放在 extracted 子文件夹中。因此,我们可以重新使用下载的数据来创建一个新的语义分割数据集。
在进行之前,值得先了解 TensorFlow 数据集的组织结构,以便明白我们需要做什么才能实现目标。
层次化组织
整个 TensorFlow Datasets API 设计时考虑了尽可能的扩展性。为了实现这一点,TensorFlow Datasets 的架构被组织成多个抽象层,将原始数据集数据转化为 tf.data.Dataset 对象。以下图表来自 TensorFlow Dataset 的 GitHub 页面(github.com/tensorflow/datasets/),展示了该项目的逻辑组织结构:

TensorFlow Datasets 项目的逻辑组织结构。原始数据经过多个抽象层的处理,这些层应用了转换和标准化操作,目的是定义 TFRecord 结构,并最终获取一个 tf.data.Dataset 对象。
通常,FeatureConnector和FileFormatAdapter类是现成的,而DatasetBuilder类必须正确实现,因为它是数据管道中与数据特定相关的部分。
每个数据集创建管道都从一个DatasetBuilder对象的子类开始,该子类必须实现以下方法:
-
_info用于构建描述数据集的DatasetInfo对象(并生成对人类友好的表示,这对全面理解数据非常有用)。 -
_download_and_prepare用于从远程位置下载数据(如果有的话)并进行一些基本预处理(如提取压缩档案)。此外,它还会创建序列化的(TFRecord)表示。 -
_as_dataset:这是最后一步,用于从序列化数据生成一个tf.data.Dataset对象。
直接子类化时,通常不需要DatasetBuilder类,因为GeneratorBasedBuilder是一个现成的DatasetBuilder子类,简化了数据集定义。通过子类化它需要实现的方法如下:
-
_info是DatasetBuilder的相同方法(参见上一条列表中的_info方法描述)。 -
_split_generators用于下载原始数据并进行一些基本预处理,但无需担心 TFRecord 的创建。 -
_generate_examples用于创建一个 Python 迭代器。该方法从原始数据中生成数据集中的示例,每个示例都会被自动序列化为 TFRecord 中的一行。
因此,通过子类化GeneratorBasedBuilder,只需要实现三个简单的方法,我们就可以开始实现它们了。
数据集类和 DatasetInfo
子类化一个模型并实现所需的方法是直接的。第一步是定义我们类的框架,然后按复杂度顺序开始实现方法。此外,既然我们的目标是创建一个用于语义分割的数据集,且使用的是相同的 PASCAL VOC 2007 数据集的下载文件,我们可以重写tfds.image.Voc2007的DatasetBuilder方法,以重用父类中已存在的所有信息:
(tf2)
import tensorflow as tf
import tensorflow_datasets as tfds
import os
class Voc2007Semantic(tfds.image.Voc2007):
"""Pasval VOC 2007 - semantic segmentation."""
VERSION = tfds.core.Version("0.1.0")
def _info(self):
# Specifies the tfds.core.DatasetInfo object
pass # TODO
def _split_generators(self, dl_manager):
# Downloads the data and defines the splits
# dl_manager is a tfds.download.DownloadManager that can be used to
# download and extract URLs
pass # TODO
def _generate_examples(self):
# Yields examples from the dataset
pass # TODO
最直接,但可能也是最重要的方法是实现_info,它包含了所有的数据集信息以及单个示例结构的定义。
由于我们正在扩展tfds.image.Voc2007数据集,因此可以重用某些公共信息。唯一需要注意的是,语义分割需要一个标签,这是一个单通道图像(而不是我们习惯看到的彩色图像)。
实现_info方法因此是直接的:
(tf2)
def _info(self):
parent_info = tfds.image.Voc2007().info
return tfds.core.DatasetInfo(
builder=self,
description=parent_info.description,
features=tfds.features.FeaturesDict(
{
"image": tfds.features.Image(shape=(None, None, 3)),
"image/filename": tfds.features.Text(),
"label": tfds.features.Image(shape=(None, None, 1)),
}
),
urls=parent_info.urls,
citation=parent_info.citation,
)
值得注意的是,TensorFlow Datasets 已经自带了一个预定义的特征连接器集合,这些连接器用于定义 FeatureDict。例如,定义具有固定深度(4 或 1)且高度和宽度未知的图像特征的正确方法是使用 tfds.features.Image(shape=(None, None, depth))。
description、urls 和 citation 字段已从父类继承,尽管这并不完全正确,因为父类的描述和引用字段涉及的是物体检测和分类挑战。
第二个需要实现的方法是 _split_generators。
创建数据集拆分
_split_generators 方法用于下载原始数据并进行一些基本的预处理,而无需担心 TFRecord 的创建。
由于我们是从 tfds.image.Voc2007 继承的,因此无需重新实现它,但需要查看父类的源代码:
(tf2)
def _split_generators(self, dl_manager):
trainval_path = dl_manager.download_and_extract(
os.path.join(_VOC2007_DATA_URL, "VOCtrainval_06-Nov-2007.tar"))
test_path = dl_manager.download_and_extract(
os.path.join(_VOC2007_DATA_URL, "VOCtest_06-Nov-2007.tar"))
return [
tfds.core.SplitGenerator(
name=tfds.Split.TEST,
num_shards=1,
gen_kwargs=dict(data_path=test_path, set_name="test")),
tfds.core.SplitGenerator(
name=tfds.Split.TRAIN,
num_shards=1,
gen_kwargs=dict(data_path=trainval_path, set_name="train")),
tfds.core.SplitGenerator(
name=tfds.Split.VALIDATION,
num_shards=1,
gen_kwargs=dict(data_path=trainval_path, set_name="val")),
]
源代码来自 github.com/tensorflow/datasets/blob/master/tensorflow_datasets/image/voc.py,并遵循 Apache License 2.0 授权协议。
如可以很容易看出,方法使用一个 dl_manager 对象来下载(并缓存)并从某个远程位置解压归档文件。数据集的拆分定义在 "train"、"test" 和 "val" 中的返回行执行。
每次调用 tfds.core.SplitGeneratro 最重要的部分是 gen_kwargs 参数。事实上,在这一行,我们正在指示如何调用 _generate_exaples 函数。
简而言之,这个函数通过调用 _generate_examples 函数,传入 data_path 参数设置为当前数据集路径(test_path 或 trainval_path),并将 set_name 设置为正确的数据集名称,从而创建三个拆分。
set_name 参数的值来自 PASCAL VOC 2007 目录和文件组织。正如我们将在下一节看到的那样,在 _generate_example 方法的实现中,了解数据集的结构和内容对于正确创建拆分是必要的。
生成示例
_generate_example 方法可以定义为任何签名。该方法仅由 _split_generators 方法调用,因此,由这个方法来正确地用正确的参数调用 _generate_example。
由于我们没有重写父类的 _split_generators 方法,因此我们必须使用父类要求的相同签名。因此,我们需要使用 data_path 和 set_name 参数,除此之外,还可以使用 PASCAL VOC 2007 文档中提供的其他所有信息。
_generate_examples 的目标是每次调用时返回一个示例(表现得像一个标准的 Python 迭代器)。
从数据集结构中,我们知道,在 VOCdevkit/VOC2007/ImageSets/Segmentation/ 目录下,有三个文本文件——每个拆分一个:"train","test" 和 "val"。每个文件都包含每个拆分中标记图像的名称。
因此,使用这些文件中包含的信息来创建三份数据集是直接的。我们只需逐行打开文件并读取,就可以知道要读取哪些图像。
TensorFlow Datasets 限制我们使用 Python 文件操作,但明确要求使用 tf.io.gfile 包。这个限制是必要的,因为有些数据集太大,无法在单台机器上处理,而 tf.io.gfile 可以方便地被 TensorFlow Datasets 用来读取和处理远程以及分布式的数据集。
从 PASCAL VOC 2007 文档中,我们还可以提取一个 查找表 (LUT),用来创建 RGB 值与标量标签之间的映射:
(tf2)
LUT = {
(0, 0, 0): 0, # background
(128, 0, 0): 1, # aeroplane
(0, 128, 0): 2, # bicycle
(128, 128, 0): 3, # bird
(0, 0, 128): 4, # boat
(128, 0, 128): 5, # bottle
(0, 128, 128): 6, # bus
(128, 128, 128): 7, # car
(64, 0, 0): 8, # cat
(192, 0, 0): 9, # chair
(64, 128, 0): 10, # cow
(192, 128, 0): 11, # diningtable
(64, 0, 128): 12, # dog
(192, 0, 128): 13, # horse
(64, 128, 128): 14, # motorbike
(192, 128, 128): 15, # person
(0, 64, 0): 16, # pottedplant
(128, 64, 0): 17, # sheep
(0, 192, 0): 18, # sofa
(128, 192, 0): 19, # train
(0, 64, 128): 20, # tvmonitor
(255, 255, 255): 21, # undefined / don't care
}
创建这个查找表后,我们可以仅使用 TensorFlow 操作来读取图像,检查其是否存在(因为无法保证原始数据是完美的,我们必须防止在数据集创建过程中出现故障),并创建包含与 RGB 颜色相关的数值的单通道图像。
仔细阅读源代码,因为第一次阅读时可能很难理解。特别是,查找 RGB 颜色与可用颜色之间对应关系的查找表循环,初看可能不容易理解。以下代码不仅使用 tf.Variable 创建与 RGB 颜色相关的数值的单通道图像,还检查 RGB 值是否正确:
(tf2)
def _generate_examples(self, data_path, set_name):
set_filepath = os.path.join(
data_path,
"VOCdevkit/VOC2007/ImageSets/Segmentation/{}.txt".format(set_name),
)
with tf.io.gfile.GFile(set_filepath, "r") as f:
for line in f:
image_id = line.strip()
image_filepath = os.path.join(
data_path, "VOCdevkit", "VOC2007", "JPEGImages", f"{image_id}.jpg"
)
label_filepath = os.path.join(
data_path,
"VOCdevkit",
"VOC2007",
"SegmentationClass",
f"{image_id}.png",
)
if not tf.io.gfile.exists(label_filepath):
continue
label_rgb = tf.image.decode_image(
tf.io.read_file(label_filepath), channels=3
)
label = tf.Variable(
tf.expand_dims(
tf.zeros(shape=tf.shape(label_rgb)[:-1], dtype=tf.uint8), -1
)
)
for color, label_id in LUT.items():
match = tf.reduce_all(tf.equal(label_rgb, color), axis=[2])
labeled = tf.expand_dims(tf.cast(match, tf.uint8), axis=-1)
label.assign_add(labeled * label_id)
colored = tf.not_equal(tf.reduce_sum(label), tf.constant(0, tf.uint8))
# Certain labels have wrong RGB values
if not colored.numpy():
tf.print("error parsing: ", label_filepath)
continue
yield image_id, {
# Declaring in _info "image" as a tfds.feature.Image
# we can use both an image or a string. If a string is detected
# it is supposed to be the image path and tfds take care of the
# reading process.
"image": image_filepath,
"image/filename": f"{image_id}.jpg",
"label": label.numpy(),
}
_generate_examples 方法不仅返回单个示例,它还必须返回一个元组,(id, example),其中 id —— 在本例中是 image_id —— 应该唯一标识该记录;此字段用于全局打乱数据集,并避免生成的数据集中出现重复元素。
实现了这个方法之后,一切都已经正确设置,我们可以使用全新的 Voc2007Semantic 加载器。
使用构建器
TensorFlow Datasets 可以自动检测当前作用域中是否存在 DatasetBuilder 对象。因此,通过继承现有的 DatasetBuilder 类实现的 "voc2007_semantic" 构建器已经可以直接使用:
dataset, info = tfds.load("voc2007_semantic", with_info=True)
在第一次执行时,会创建拆分,并且 _generate_examples 方法会被调用三次以创建示例的 TFRecord 表示。
通过检查 info 变量,我们可以看到一些数据集统计信息:
[...]
features=FeaturesDict({
'image': Image(shape=(None, None, 3), dtype=tf.uint8),
'image/filename': Text(shape=(), dtype=tf.string, encoder=None),
'label': Image(shape=(None, None, 1), dtype=tf.uint8)
},
total_num_examples=625,
splits={
'test': <tfds.core.SplitInfo num_examples=207>,
'train': <tfds.core.SplitInfo num_examples=207>,
'validation': <tfds.core.SplitInfo num_examples=211>
}
特征通过实现 _info 方法来描述,数据集的规模相对较小,每个训练集和测试集包含 207 张图像,验证集包含 211 张图像。
实现DatasetBuilder是一个相对直接的操作,每当你开始处理一个新的数据集时,都应该进行这项操作——这样,在训练和评估过程中可以使用高效的管道。
模型训练与评估
尽管网络架构并非图像分类器,并且标签不是标量,但语义分割仍然可以视为一个传统的分类问题,因此训练和评估过程是相同的。
出于这个原因,我们可以使用compile和fit Keras 模型来构建训练循环,并分别执行它,而不是编写自定义的训练循环。
数据准备
要使用 Keras 的fit模型,tf.data.Dataset对象应生成 (feature, label) 格式的元组,其中feature是输入图像,label是图像标签。
因此,值得定义一些可以应用于tf.data.Dataset生成的元素的函数,这些函数可以将数据从字典转换为元组,并且在此过程中,我们还可以为训练过程应用一些有用的预处理:
(tf2)
def resize_and_scale(row):
# Resize and convert to float, [0,1] range
row["image"] = tf.image.convert_image_dtype(
tf.image.resize(
row["image"],
(256,256),
method=tf.image.ResizeMethod.NEAREST_NEIGHBOR),
tf.float32)
# Resize, cast to int64 since it is a supported label type
row["label"] = tf.cast(
tf.image.resize(
row["label"],
(256,256),
method=tf.image.ResizeMethod.NEAREST_NEIGHBOR),
tf.int64)
return row
def to_pair(row):
return row["image"], row["label"]
现在很容易从通过tfds.load调用获得的dataset对象中获取验证集和训练集,并对其应用所需的转换:
(tf2)
batch_size= 32
train_set = dataset["train"].map(resize_and_scale).map(to_pair)
train_set = train_set.batch(batch_size).prefetch(1)
validation_set = dataset["validation"].map(resize_and_scale)
validation_set = validation_set.map(to_pair).batch(batch_size)
数据集已准备好用于fit方法,并且由于我们正在开发一个纯 Keras 解决方案,因此可以使用 Keras 回调函数配置隐藏的训练循环。
训练循环与 Keras 回调函数
compile方法用于配置训练循环。我们可以指定优化器、损失函数、评估指标以及一些有用的回调函数。
回调函数是在每个训练周期结束时执行的函数。Keras 提供了一个预定义回调函数的长列表,用户可以直接使用。在下一个代码片段中,将使用两个最常见的回调函数,ModelCheckpoint和TensorBoard回调函数。如其名称所示,前者在每个周期结束时保存检查点,而后者使用tf.summary记录指标。
由于语义分割可以视为一个分类问题,使用的损失函数是SparseCategoricalCrossentropy,并配置为在计算损失值时对网络的输出层应用 sigmoid(在深度维度上),如from_logits=True参数所示。这个配置是必须的,因为我们没有在自定义 U-Net 的最后一层添加激活函数:
(tf2)
# Define the model
model = get_unet()
# Choose the optimizer
optimizer = tf.optimizers.Adam()
# Configure and create the checkpoint callback
checkpoint_path = "ckpt/pb.ckpt"
cp_callback = tf.keras.callbacks.ModelCheckpoint(checkpoint_path,
save_weights_only=True,
verbose=1)
# Enable TensorBoard loggging
TensorBoard = tf.keras.callbacks.TensorBoard(write_images=True)
# Cofigure the training loop and log the accuracy
model.compile(optimizer=optimizer,
loss=tf.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])
数据集和回调函数被传递到fit方法,该方法执行所需轮数的有效训练循环:
(tf2)
num_epochs = 50
model.fit(train_set, validation_data=validation_set, epochs=num_epochs,
callbacks=[cp_callback, TensorBoard])
训练循环将训练模型 50 个周期,在训练过程中测量损失和准确度,并在每个周期结束时,测量验证集上的准确度和损失值。此外,经过两个回调后,我们在ckpt目录中有一个包含模型参数的检查点,并且不仅在标准输出(即 Keras 默认设置)上记录了度量,还在 TensorBoard 上进行了记录。
评估与推理
在训练过程中,我们可以打开 TensorBoard 并查看损失和度量的图表。在第 50 个周期结束时,我们会得到如下截图所示的图表:

训练集(橙色)和验证集(蓝色)上的准确度和损失值。Keras 将摘要的使用和配置隐藏给用户。
此外,由于我们已经在model变量中拥有了模型的所有参数,我们可以尝试向模型输入一张从互联网上下载的图像,看看分割是否按预期工作。
假设我们从互联网上下载了以下图像,并将其保存为"author.jpg":

问候!
我们期望模型生成这张图像中唯一已知类别的分割,即"person",同时在其他地方生成"background"标签。
一旦我们下载了图像,就将其转换为模型预期的相同格式(一个在【0,1】范围内的浮动值),并将其大小调整为512。由于模型处理的是一批图像,因此需要为sample变量添加一个单一的维度。现在,运行推理就像model(sample)一样简单。之后,我们在最后一个通道上使用tf.argmax函数提取每个像素位置的预测标签:
(tf2)
sample = tf.image.decode_jpeg(tf.io.read_file("author.jpg"))
sample = tf.expand_dims(tf.image.convert_image_dtype(sample, tf.float32), axis=[0])
sample = tf.image.resize(sample, (512,512))
pred_image = tf.squeeze(tf.argmax(model(sample), axis=-1), axis=[0])
在pred_image张量中,我们有稠密的预测,这些预测对于可视化几乎没有用处。实际上,这个张量的值在【0, 21】范围内,并且这些值一旦可视化后几乎无法区分(它们看起来都很黑)。
因此,我们可以使用为数据集创建的 LUT 应用从标签到颜色的逆映射。最后,我们可以使用 TensorFlow 的io包将图像转换为 JPEG 格式并将其存储在磁盘上,方便可视化:
(tf2)
REV_LUT = {value: key for key, value in LUT.items()}
color_image = tf.Variable(tf.zeros((512,512,3), dtype=tf.uint8))
pixels_per_label = []
for label, color in REV_LUT.items():
match = tf.equal(pred_image, label)
labeled = tf.expand_dims(tf.cast(match, tf.uint8), axis=-1)
pixels_per_label.append((label, tf.math.count_nonzero(labeled)))
labeled = tf.tile(labeled, [1,1,3])
color_image.assign_add(labeled * color)
# Save
tf.io.write_file("seg.jpg", tf.io.encode_jpeg(color_image))
这是在小数据集上仅训练 50 个周期后,简单模型的分割结果:

映射预测标签到相应颜色后的分割结果。
尽管结果较为粗糙,因为架构尚未优化,模型选择未进行,数据集规模较小,但分割结果已经看起来很有前景!
通过计算每个标签的匹配次数,可以检查预测的标签。在pixels_per_label列表中,我们保存了对(label,match_count)的配对,打印出来后,我们可以验证预测的类别是否为预期的"person"(id 15):
(tf2)
for label, count in pixels_per_label:
print(label, ": ", count.numpy())
这将产生以下结果:
0: 218871
1: 0
3: 383
[...]
15: 42285
[...]
这正是预期的。当然,仍然有改进的空间,这留给读者作为练习。
总结
本章介绍了语义分割问题并实现了 U-Net:一种用于解决此问题的深度编码器-解码器架构。简要介绍了该问题的可能应用场景和挑战,接着直观地介绍了用于构建架构解码器部分的反卷积(转置卷积)操作。由于在编写时,TensorFlow Datasets 中还没有准备好的语义分割数据集,我们利用这一点展示了 TensorFlow Datasets 的架构,并展示了如何实现自定义的 DatasetBuilder。实现它是直接的,推荐每个 TensorFlow 用户这样做,因为它是创建高效数据输入管道(tf.data.Dataset)的便捷方式。此外,通过实现 _generate_examples 方法,用户被迫“查看”数据,这是进行机器学习和数据科学时强烈推荐的做法。
之后,我们通过将此问题视为分类问题,学习了语义分割网络训练循环的实现。本章展示了如何使用 Keras 的 compile 和 fit 方法,并介绍了如何通过 Keras 回调函数自定义训练循环。本章以一个快速示例结束,演示了如何使用训练好的模型进行推理,并如何仅使用 TensorFlow 方法保存生成的图像。
在下一章,第九章,生成对抗网络,介绍了生成对抗网络(GANs)及其对抗性训练过程,显然,我们也解释了如何使用 TensorFlow 2.0 实现它们。
练习
以下练习具有基础性的重要性,邀请您回答每个理论问题并解决所有给定的代码挑战:
-
什么是语义分割?
-
为什么语义分割是一个困难的问题?
-
什么是反卷积?深度学习中的反卷积操作是一个真正的反卷积操作吗?
-
是否可以将 Keras 模型作为层使用?
-
是否可以使用单个 Keras
Sequential模型来实现具有跳跃连接的模型架构? -
描述原始 U-Net 架构:本章中展示的自定义实现与原始实现有什么不同?
-
使用 Keras 实现原始的 U-Net 架构。
-
什么是 DatasetBuilder?
-
描述 TensorFlow 数据集的层次结构。
-
_info方法包含数据集中每个示例的描述。这个描述与FeatureConnector对象有什么关系? -
描述
_generate_splits和_generate_examples方法。解释这两个方法是如何连接的,以及tfds.core.SplitGenerator的gen_kwargs参数的作用。 -
什么是 LUT?为什么它是创建语义分割数据集时有用的数据结构?
-
为什么在开发自定义 DatasetBuilder 时需要使用
tf.io.gfile? -
(加分项):为 TensorFlow Datasets 项目添加一个缺失的语义分割数据集!提交一个 Pull Request 到
github.com/tensorflow/datasets,并在消息中分享此练习部分和本书内容。 -
训练本章中展示的修改版 U-Net 架构。
-
更改损失函数并添加重建损失项,最小化过程的目标是同时最小化交叉熵,并使预测标签尽可能接近真实标签。
-
使用 Keras 回调函数测量平均交并比(Mean IOU)。Mean IOU 已在
tf.metrics包中实现。 -
尝试通过在编码器中添加 dropout 层来提高模型在验证集上的表现。
-
在训练过程中,开始时以 0.5 的概率丢弃神经元,并在每个 epoch 后将此值增加 0.1。当验证集的平均 IOU 停止增加时,停止训练。
-
使用训练好的模型对从互联网上下载的随机图像进行推断。对结果的分割进行后处理,以便检测出不同类别的不同元素的边界框。使用 TensorFlow 在输入图像上绘制边界框。
第十章:生成对抗网络
本章将介绍生成对抗网络(GANs)及对抗训练过程。在第一部分,我们将概述 GAN 框架的理论内容,同时强调对抗训练过程的优势以及使用神经网络作为创建 GAN 的模型所带来的灵活性。理论部分将为你提供一个直观的了解,帮助你理解在对抗训练过程中 GAN 的哪些部分被优化,并展示为什么应使用非饱和值函数,而不是原始的值函数。
接下来,我们将通过一步步实现 GAN 模型及其训练,并用视觉方式解释这个过程中发生的事情。通过观察模型的学习过程,你将熟悉目标分布和学习分布的概念。
本章第二部分将介绍 GAN 框架向条件版本的自然扩展,并展示如何创建条件图像生成器。本章与之前的章节一样,最后将会有一个练习部分,鼓励大家不要跳过。
本章将涵盖以下主题:
-
理解 GAN 及其应用
-
无条件 GAN
-
条件 GAN
理解 GAN 及其应用
由Ian Goodfellow 等人在 2014 年提出的论文《生成对抗网络》(Generative Adversarial Networks)中,GANs 彻底改变了生成模型的领域,为令人惊叹的应用铺平了道路。
GANs 是通过对抗过程估计生成模型的框架,其中两个模型,生成器和判别器,进行同时训练。
生成模型(生成器,Generator)的目标是捕捉训练集中包含的数据分布,而判别模型则充当二分类器。它的目标是估计一个样本来自训练数据的概率,而不是来自生成器。在下面的图示中,展示了对抗训练的总体架构:

对抗训练过程的图形化表示。生成器的目标是通过学习生成越来越像训练集的样本来欺骗判别器。(图像来源:www.freecodecamp.org/news/an-intuitive-introduction-to-generative-adversarial-networks-gans-7a2264a81394/—Thalles Silva)
这个想法是训练一个生成模型,而无需明确定义损失函数。相反,我们使用来自另一个网络的信号作为反馈。生成器的目标是愚弄判别器,而判别器的目标是正确分类输入样本是真实的还是虚假的。对抗训练的强大之处在于,生成器和判别器都可以是非线性、参数化的模型,例如神经网络。因此,可以使用梯度下降法来训练它们。
为了学习生成器在数据上的分布,生成器从先验噪声分布,
,到数据空间的映射,映射。
判别器,
,是一个函数(神经网络),其输出一个标量,表示
来自真实数据分布的概率,而不是来自
。
原始的 GAN 框架通过使用博弈论方法来表达问题,并将其作为一个最小-最大博弈,其中两个玩家——生成器和判别器——相互竞争。
价值函数
价值函数是以期望回报的形式表示玩家目标的数学方法。GAN 博弈通过以下价值函数来表示:

这个价值函数表示了两个玩家所进行的博弈,以及他们各自的长期目标。
判别器的目标是正确分类真实和虚假样本,这一目标通过最大化
和
两项来表示。前者代表正确分类来自真实数据分布的样本(因此,目标是得到
),而后者代表正确分类虚假样本(在这种情况下,目标是得到
)。
另一方面,生成器的目标是愚弄判别器,它的目标是最小化
。你可以通过生成与真实样本越来越相似的样本来最小化这一项,从而试图愚弄判别器。
值得注意的一点是,最小-最大博弈仅在价值函数的第二项中进行,因为在第一项中,只有判别器在参与。它通过学习正确分类来自真实数据分布的数据来实现这一点。
尽管这种公式清晰且相当容易理解,但它有一个实际的缺点。在训练的早期阶段,判别器可以通过最大化
轻松学会如何正确分类假数据,因为生成的样本与真实样本差异太大。由于从生成样本的质量学习较差,判别器可以以较高的置信度拒绝这些样本,因为它们与训练数据明显不同。这种拒绝表现为将生成样本的正确分类标为假(
),使得项
饱和。因此,之前的公式可能无法为G提供足够的梯度以良好地进行学习。解决这一实际问题的方法是定义一个不饱和的新价值函数。
非饱和价值函数
提出的解决方案是训练G以最大化
,而不是最小化
。直观地看,我们可以将提出的解决方案视为以不同方式进行相同的最小-最大游戏。
判别器的目标是最大化正确分类真实样本和假样本的概率,与之前的公式没有变化。另一方面,生成器的目标是最小化判别器正确分类生成样本为假的概率,但要通过使判别器将假样本分类为真实的方式显式地欺骗判别器。
同一游戏的价值函数,由两名玩家以不同方式进行游戏,可以表示如下:

如前所述,敌对训练框架的力量来自于G和D都可以是神经网络,并且它们都可以通过梯度下降进行训练。
模型定义和训练阶段
将生成器和判别器定义为神经网络,使我们能够利用多年来开发的所有神经网络架构来解决问题,每种架构都专门用于处理某种数据类型。
模型的定义没有约束;事实上,可以以完全任意的方式定义其架构。唯一的约束是由我们所处理的数据的结构决定的;架构取决于数据类型,所有类型如下:
-
图像:卷积神经网络
-
序列、文本:递归神经网络
-
数值、类别值:全连接网络
一旦我们根据数据类型定义了模型的架构,就可以使用它们来进行最小-最大游戏。
对抗训练包括交替执行训练步骤。每个训练步骤都是一个玩家动作,生成器和判别器交替进行对抗。游戏遵循以下规则:
- 判别器:判别器首先进行操作,可以将以下三个步骤重复执行 1 到k次,其中k是超参数(通常k等于 1):
-
-
从噪声分布中采样一个* m *噪声样本的迷你批量,
,来自噪声先验的![]()
-
从真实数据分布中采样一个* m *样本的迷你批量,
,来自真实数据分布的![]()
-
通过随机梯度上升训练判别器:
![]()
-
这里,
是判别器的参数
- 生成器:生成器始终在判别器操作之后进行,并且每次仅执行一次:
-
-
从噪声分布中采样一个* m *噪声样本的迷你批量,
,来自噪声先验的![]()
-
通过随机梯度上升训练生成器(这是一个最大化问题,因为游戏的目标是非饱和值函数):
![]()
-
这里,
是生成器的参数
就像任何通过梯度下降训练的神经网络一样,更新可以使用任何标准优化算法(Adam,SGD,带动量的 SGD 等)。游戏应该持续,直到判别器不再完全被生成器欺骗,也就是说,当判别器对每个输入样本的预测概率总是为 0.5 时。0.5 的值可能听起来很奇怪,但直观地说,这意味着生成器现在能够生成与真实样本相似的样本,而判别器只能做随机猜测。
GANs 的应用
乍一看,生成模型的效用有限。拥有一个生成与我们已有的(真实样本数据集)相似的模型有什么意义?
在实践中,从数据分布中学习在异常检测领域非常有用,并且在“仅限人类”的领域(如艺术、绘画和音乐生成)中也有重要应用。此外,GANs 在其条件形式中的应用令人惊讶,被广泛用于创造具有巨大市场价值的应用程序(更多信息请参阅本章的条件 GANs部分)。
使用 GANs,可以让机器从随机噪声开始生成极其逼真的面孔。以下图像展示了将 GAN 应用于面部生成问题。这些结果来源于论文《Progressive Growing of GANs for Improved Quality, Stability, and Variation》(T. Karras 等,2017,NVIDIA):

这些人并不存在。每一张图片,尽管超逼真,都是通过生成对抗网络(GAN)生成的。你可以亲自尝试,通过访问thispersondoesnotexist.com/来体验一下。(图片来源,论文标题为Progressive Growing of GANs for Improved Quality, Stability, and Variation)。
在 GAN 出现之前,另一个几乎不可能实现的惊人应用是领域转换,指的是你使用 GAN 从一个领域转换到另一个领域,例如,从素描转换到真实图像,或从鸟瞰图转换为地图。
下图来自论文Image-to-Image Translation with Conditional Adversarial Networks(Isola 等,2017),展示了条件 GAN 如何解决几年前被认为不可能完成的任务:

GAN 使得解决领域转换问题成为可能。现在,给黑白图像上色或仅通过素描生成照片变得可行。图片来源:Image-to-Image Translation with Conditional Adversarial Networks(Isola 等,2017)。
GAN 的应用令人惊叹,其实际应用也在不断被发现。从下一部分开始,我们将学习如何在纯 TensorFlow 2.0 中实现其中的一些应用。
无条件 GAN
看到 GAN 被提到为无条件的并不常见,因为这是默认的原始配置。然而,在本书中,我们决定强调原始 GAN 公式的这一特性,以便让你意识到 GAN 的两大主要分类:
-
无条件 GAN
-
条件 GAN
我们在上一部分描述的生成模型属于无条件 GAN 类别。该生成模型训练的目标是捕捉训练数据分布,并生成从捕捉的分布中随机抽样的样本。条件配置是该框架的略微修改版本,并将在下一部分介绍。
由于 TensorFlow 2.0 的默认即时执行风格,实施对抗训练变得非常简单。实际上,为了实现 Goodfellow 等人论文中描述的对抗训练循环(Generative Adversarial Networks),需要逐行按原样实现。当然,创建一个自定义训练循环,特别是需要交替训练两个不同模型的步骤时,最好的方法不是使用 Keras,而是手动实现。
就像任何其他机器学习问题一样,我们必须从数据开始。在这一部分,我们将定义一个生成模型,目的是学习关于以 10 为中心、标准差较小的随机正态数据分布。
准备数据
由于本节的目标是学习数据分布,我们将从基础开始,以便建立对对抗训练过程的直觉。最简单且最容易的方式是通过查看随机正态分布来可视化数据分布。因此,我们可以选择一个均值为 10,标准差为 0.1 的高斯(或正态)分布作为我们的目标数据分布:

由于即时执行过程,我们可以使用 TensorFlow 2.0 本身从目标分布中采样一个值。我们通过使用 tf.random.normal 函数来做到这一点。以下代码片段展示了一个函数,该函数从目标分布中采样(2000)个数据点:
(tf2)
import tensorflow as tf
def sample_dataset():
dataset_shape = (2000, 1)
return tf.random.normal(mean=10., shape=dataset_shape, stddev=0.1, dtype=tf.float32)
为了更好地理解 GAN 能学到什么,以及在对抗训练过程中发生了什么,我们使用 matplotlib 来将数据可视化成直方图:
(tf2)
import matplotlib.pyplot as plt
counts, bin, ignored = plt.hist(sample_dataset().numpy(), 100)
axes = plt.gca()
axes.set_xlim([-1,11])
axes.set_ylim([0, 60])
plt.show()
这显示了目标分布,如下图所示。如预期的那样,如果标准差较小,直方图将在均值处达到峰值:

目标分布的直方图——从一个均值为 10,标准差为 0.1 的高斯分布中采样的 5000 个数据点
现在我们已经定义了目标数据分布,并且有了一个从中采样的函数(sample_dataset),我们准备好定义生成器和判别器网络了。
正如我们在本章开头所述,对抗训练过程的力量在于生成器和判别器都可以是神经网络,并且模型可以使用梯度下降法进行训练。
定义生成器
生成器的目标是表现得像目标分布。因此,我们必须将其定义为一个具有单个神经元的网络。我们可以从目标分布中每次采样一个数字,生成器也应该能够做到这一点。
模型架构定义没有明确的指导原则或约束条件。唯一的限制来自于问题的性质,这些限制体现在输入和输出的维度。输出维度,如前所述,取决于目标分布,而输入维度是噪声先验的任意维度,通常设置为 100。
为了解决这个问题,我们将定义一个简单的三层神经网络,包含两个隐藏层,每个层有 64 个神经元:
(tf2)
def generator(input_shape):
"""Defines the generator keras.Model.
Args:
input_shape: the desired input shape (e.g.: (latent_space_size))
Returns:
G: The generator model
"""
inputs = tf.keras.layers.Input(input_shape)
net = tf.keras.layers.Dense(units=64, activation=tf.nn.elu, name="fc1")(inputs)
net = tf.keras.layers.Dense(units=64, activation=tf.nn.elu, name="fc2")(net)
net = tf.keras.layers.Dense(units=1, name="G")(net)
G = tf.keras.Model(inputs=inputs, outputs=net)
return G
generator 函数返回一个 Keras 模型。虽然只用一个 Sequential 模型也足够,但我们使用了 Keras 函数式 API 来定义该模型。
定义判别器
就像生成器一样,判别器的架构依赖于目标分布。目标是将样本分类为两类。因此,输入层依赖于从目标分布中采样的样本的大小;在我们的案例中,它是 1。输出层是一个单独的线性神经元,用于将样本分类为两类。
激活函数是线性的,因为 Keras 的损失函数应用了 sigmoid:
(tf2)
def disciminator(input_shape):
"""Defines the Discriminator keras.Model.
Args:
input_shape: the desired input shape (e.g.: (the generator output shape))
Returns:
D: the Discriminator model
"""
inputs = tf.keras.layers.Input(input_shape)
net = tf.keras.layers.Dense(units=32, activation=tf.nn.elu, name="fc1")(inputs)
net = tf.keras.layers.Dense(units=1, name="D")(net)
D = tf.keras.Model(inputs=inputs, outputs=net)
return D
定义生成器和判别器架构之后,我们只需通过指定正确的输入形状来实例化 Keras 模型:
(tf2)
# Define the real input shape
input_shape = (1,)
# Define the Discriminator model
D = disciminator(input_shape)
# Arbitrary set the shape of the noise prior
latent_space_shape = (100,)
# Define the input noise shape and define the generator
G = generator(latent_space_shape)
模型和目标数据分布已经定义;唯一缺少的就是表达它们之间的关系,这通过定义损失函数来完成。
定义损失函数
如前所述,判别器的输出是线性的,因为我们将要使用的 loss 函数为我们应用了非线性。为了按照原始公式实现对抗训练过程,使用的 loss 函数是二进制交叉熵:
(tf2)
bce = tf.keras.losses.BinaryCrossentropy(from_logits=True)
bce 对象用于计算两个分布之间的二进制交叉熵:
-
学到的分布,由判别器的输出表示,通过应用 sigmoid
函数将其压缩到 [0,1] 范围内,因为 from_logits参数被设置为True。如果判别器将输入分类为来自真实数据分布,则该值会接近 1。 -
条件经验分布在类别标签上,即一个离散的概率分布,其中真实样本的概率被标记为 1,其他情况下为 0。
数学上,条件经验分布与生成器输出(压缩到 [0,1])之间的二进制交叉熵表示如下:

我们希望训练判别器正确分类真实和伪造数据:正确分类真实数据可以看作是最大化
,而正确分类伪造数据是最大化
。
通过将期望值替换为批次中 m 个样本的经验均值,可以将正确分类一个样本的对数概率的最大化表示为两个 BCE 的和:

第一项是标签
在给定真实样本作为输入时的判别器输出之间的 BCE,而第二项是标签
在给定假样本作为输入时的判别器输出之间的 BCE。
在 TensorFlow 中实现这个损失函数非常简单:
(tf2)
def d_loss(d_real, d_fake):
"""The disciminator loss function."""
return bce(tf.ones_like(d_real), d_real) + bce(tf.zeros_like(d_fake), d_fake)
我们之前创建的同一 bce 对象在 d_loss 函数中使用,因为它是一个无状态对象,仅计算其输入之间的二元交叉熵。
请注意,在最大化它们的 bce 调用中不需要添加减号;二元交叉熵的数学公式已经包含减号。
生成器损失函数遵循这一理论。仅实施非饱和值函数包括 TensorFlow 实现以下公式:

该公式是生成图像的对数概率与真实图像的分布(标记为 1)之间的二元交叉熵。在实践中,我们希望最大化生成样本的对数概率,更新生成器参数以使判别器将其分类为真实(标签 1)。
TensorFlow 实现非常简单:
(tf2)
def g_loss(generated_output):
"""The Generator loss function."""
return bce(tf.ones_like(generated_output), generated_output)
一切都准备好实施对抗训练过程。
无条件 GAN 中的对抗训练过程
如我们在本章开头解释的那样,对抗训练过程是我们交替执行判别器和生成器的训练步骤的过程。生成器需要通过判别器计算的值来执行其参数更新,而判别器需要生成的样本(也称为假输入)和真实样本。
TensorFlow 允许我们轻松定义自定义训练循环。特别是 tf.GradientTape 对象非常有用,用于计算特定模型的梯度,即使存在两个相互作用的模型。实际上,由于每个 Keras 模型的 trainable_variables 属性,可以计算某个函数的梯度,但只针对这些变量。
训练过程与 GAN 论文描述的过程完全相同(生成对抗网络 - Ian Goodfellow 等人),由于急切模式。此外,由于这个训练过程可能计算密集(特别是在我们希望捕获的数据分布复杂的大型数据集上),值得使用 @tf.function 装饰训练步骤函数,以便通过将其转换为图形加快计算速度:
(tf2)
def train():
# Define the optimizers and the train operations
optimizer = tf.keras.optimizers.Adam(1e-5)
@tf.function
def train_step():
with tf.GradientTape(persistent=True) as tape:
real_data = sample_dataset()
noise_vector = tf.random.normal(
mean=0, stddev=1,
shape=(real_data.shape[0], latent_space_shape[0]))
# Sample from the Generator
fake_data = G(noise_vector)
# Compute the D loss
d_fake_data = D(fake_data)
d_real_data = D(real_data)
d_loss_value = d_loss(d_real_data, d_fake_data)
# Compute the G loss
g_loss_value = g_loss(d_fake_data)
# Now that we comptuted the losses we can compute the gradient
# and optimize the networks
d_gradients = tape.gradient(d_loss_value, D.trainable_variables)
g_gradients = tape.gradient(g_loss_value, G.trainable_variables)
# Deletng the tape, since we defined it as persistent
# (because we used it twice)
del tape
optimizer.apply_gradients(zip(d_gradients, D.trainable_variables))
optimizer.apply_gradients(zip(g_gradients, G.trainable_variables))
return real_data, fake_data, g_loss_value, d_loss_value
为了可视化生成器在训练过程中学习到的内容,我们绘制了从目标分布中采样的相同图形值(橙色),以及从生成器中采样的值(蓝色):
(tf2)
fig, ax = plt.subplots()
for step in range(40000):
real_data, fake_data,g_loss_value, d_loss_value = train_step()
if step % 200 == 0:
print("G loss: ", g_loss_value.numpy(), " D loss: ", d_loss_value.numpy(), " step: ", step)
# Sample 5000 values from the Generator and draw the histogram
ax.hist(fake_data.numpy(), 100)
ax.hist(real_data.numpy(), 100)
# these are matplotlib.patch.Patch properties
props = dict(boxstyle='round', facecolor='wheat', alpha=0.5)
# place a text box in upper left in axes coords
textstr = f"step={step}"
ax.text(0.05, 0.95, textstr, transform=ax.transAxes, fontsize=14,
verticalalignment='top', bbox=props)
axes = plt.gca()
axes.set_xlim([-1,11])
axes.set_ylim([0, 60])
display.display(pl.gcf())
display.clear_output(wait=True)
plt.gca().clear()
现在我们已经将整个训练循环定义为一个函数,可以通过调用 train() 来执行它。
train_step 函数是整个代码片段中最重要的部分,因为它包含了对抗训练的实现。值得强调的一个特点是,通过使用 trainable_variables,我们能够计算损失函数相对于我们感兴趣的模型参数的梯度,同时将其他所有因素保持不变。
第二个特点是使用了持久化的梯度带对象。使用持久化的带对象使我们能够在内存中分配一个单独的对象(即带对象),并且将其使用两次。如果带对象是非持久化创建的,我们就无法重用它,因为它会在第一次 .gradient 调用后自动销毁。
我们没有使用 TensorBoard 来可视化数据(这个留给你做练习),而是遵循了到目前为止使用的 matplotlib 方法,并且每 200 步训练从目标分布和学习到的分布中分别采样 5,000 个数据点,然后通过绘制相应的直方图进行可视化。
在训练的初期阶段,学习到的分布与目标分布不同,如下图所示:

在第 2,600 步训练时的数据可视化。目标分布是均值为 10,标准差为 0.1 的随机正态分布。从学习到的分布中采样的值正在慢慢向目标分布移动。
在训练阶段,我们可以看到生成器如何学习逼近目标分布:

在第 27,800 步训练时的数据可视化。学习到的分布正在接近均值 10,并且正在减少其方差。
在训练的后期阶段,两个分布几乎完全重合,训练过程可以停止:

在第 39,000 步训练时的数据可视化。目标分布和学习到的分布重叠。
多亏了 Keras 模型的表达能力和 TensorFlow eager 模式的易用性(加上通过 tf.function 进行图转换),定义两个模型并手动实现对抗训练过程几乎变得微不足道。
尽管看似微不足道,这实际上是我们在处理不同数据类型时使用的相同训练循环。事实上,同样的训练循环可以用于训练图像、文本甚至音频生成器,唯一的区别是我们在这些情况下使用不同的生成器和判别器架构。
稍微修改过的 GAN 框架允许你收集条件生成的样本;例如,当给定条件时,生成器被训练生成特定的样本。
条件生成对抗网络(Conditional GANs)
Mirza 等人在他们的论文条件生成对抗网络中,提出了一种条件版本的 GAN 框架。这个修改非常容易理解,并且是今天广泛应用的惊人 GAN 应用的基础。
一些最令人惊叹的 GAN 应用,例如通过语义标签生成街景,或者给定灰度输入对图像进行上色,作为条件 GAN 思想的专门版本,经过图像超分辨率处理。
条件生成对抗网络基于这样的思想:如果生成器(G)和判别器(D)都根据某些附加信息进行条件化,那么 GAN 就可以扩展为条件模型,y。这些附加信息可以是任何形式的附加数据,从类别标签到语义图,或者来自其他模态的数据。通过将附加信息作为额外的输入层同时馈入生成器和判别器,可以实现这种条件化。下面的图示来自条件生成对抗网络论文,清楚地展示了生成器和判别器模型如何扩展以支持条件化:

条件生成对抗网络。生成器和判别器有一个附加的输入,y,表示条件化模型的辅助信息(图片来源:条件生成对抗网络,Mirza 等,2014)。
生成器架构被扩展为将噪声的联合隐层表示与条件结合。没有关于如何将条件输入生成器网络的限制。你可以简单地将条件与噪声向量连接起来。或者,如果条件比较复杂,可以使用神经网络对其进行编码,并将其输出连接到生成器的某一层。判别器同样也可以采用相同的逻辑。
对模型进行条件化改变了值函数,因为我们从中采样的数据分布现在是条件化的:

在对抗训练过程中没有其他变化,关于非饱和值函数的考虑仍然适用。
在本节中,我们将实现一个条件的 Fashion-MNIST 生成器。
获取条件生成对抗网络(GAN)数据
通过使用 TensorFlow 数据集,获取数据非常直接。由于目标是创建一个 Fashion-MNIST 生成器,我们将使用类别标签作为条件。从tfds.load调用返回的数据是字典格式。因此,我们需要定义一个函数,将字典映射到一个只包含图像和对应标签的元组。在这个阶段,我们还可以准备整个数据输入管道:
(tf2)
import tensorflow as tf
import tensorflow_datasets as tfds
import matplotlib.pyplot as plt
dataset = tfds.load("fashion_mnist", split="train")
def convert(row):
image = tf.image.convert_image_dtype(row["image"], tf.float32)
label = tf.cast(row["label"], tf.float32)
return image, label
batch_size = 32
dataset = dataset.map(convert).batch(batch_size).prefetch(1)
定义条件生成对抗网络中的生成器
由于我们处理的是图像,天然的选择是使用卷积神经网络。特别是,使用我们在第八章中介绍的反卷积操作,语义分割和自定义数据集构建器,可以轻松定义一个类似解码器的网络,从潜在表示和条件开始生成图像:
(tf2)
def get_generator(latent_dimension):
# Condition subnetwork: encode the condition in a hidden representation
condition = tf.keras.layers.Input((1,))
net = tf.keras.layers.Dense(32, activation=tf.nn.elu)(condition)
net = tf.keras.layers.Dense(64, activation=tf.nn.elu)(net)
# Concatenate the hidden condition representation to noise and upsample
noise = tf.keras.layers.Input(latent_dimension)
inputs = tf.keras.layers.Concatenate()([noise, net])
# Convert inputs from (batch_size, latent_dimension + 1)
# To a 4-D tensor, that can be used with convolutions
inputs = tf.keras.layers.Reshape((1,1, inputs.shape[-1]))(inputs)
depth = 128
kernel_size= 5
net = tf.keras.layers.Conv2DTranspose(
depth, kernel_size,
padding="valid",
strides=1,
activation=tf.nn.relu)(inputs) # 5x5
net = tf.keras.layers.Conv2DTranspose(
depth//2, kernel_size,
padding="valid",
strides=2,
activation=tf.nn.relu)(net) #13x13
net = tf.keras.layers.Conv2DTranspose(
depth//4, kernel_size,
padding="valid",
strides=2,
activation=tf.nn.relu,
use_bias=False)(net) # 29x29
# Standard convolution with a 2x2 kernel to obtain a 28x28x1 out
# The output is a sigmoid, since the images are in the [0,1] range
net = tf.keras.layers.Conv2D(
1, 2,
padding="valid",
strides=1,
activation=tf.nn.sigmoid,
use_bias=False)(net)
model = tf.keras.Model(inputs=[noise, condition], outputs=net)
return model
在条件 GAN 中定义判别器
判别器架构很简单。条件化判别器的标准方法是将图像的编码表示与条件的编码表示连接在一起,并将条件放置在一个独特的向量中。这样做需要定义两个子网络——第一个子网络将图像编码为特征向量,第二个子网络将条件编码为另一个向量。以下代码阐明了这个概念:
(tf2)
def get_Discriminator():
# Encoder subnetwork: feature extactor to get a feature vector
image = tf.keras.layers.Input((28,28,1))
depth = 32
kernel_size=3
net = tf.keras.layers.Conv2D(
depth, kernel_size,
padding="same",
strides=2,
activation=tf.nn.relu)(image) #14x14x32
net = tf.keras.layers.Conv2D(
depth*2, kernel_size,
padding="same",
strides=2,
activation=tf.nn.relu)(net) #7x7x64
net = tf.keras.layers.Conv2D(
depth*3, kernel_size,
padding="same",
strides=2,
activation=tf.nn.relu)(net) #4x4x96
feature_vector = tf.keras.layers.Flatten()(net) # 4*4*96
在定义了将图像编码为特征向量的编码子网络后,我们准备创建条件的隐藏表示并将其与特征向量连接起来。这样做后,我们可以创建 Keras 模型并返回它:
(tf2)
# Create a hidden representation of the condition
condition = tf.keras.layers.Input((1,))
hidden = tf.keras.layers.Dense(32, activation=tf.nn.elu)(condition)
hidden = tf.keras.layers.Dense(64, activation=tf.nn.elu)(hidden)
# Concatenate the feature vector and the hidden label representation
out = tf.keras.layers.Concatenate()([feature_vector, hidden])
# Add the final classification layers with a single linear neuron
out = tf.keras.layers.Dense(128, activation=tf.nn.relu)(out)
out = tf.keras.layers.Dense(1)(out)
model = tf.keras.Model(inputs=[image, condition], outputs=out)
return model
对抗训练过程
对抗训练过程与我们为无条件 GAN 展示的过程相同。loss 函数完全相同:
(tf2)
bce = tf.keras.losses.BinaryCrossentropy(from_logits=True)
def d_loss(d_real, d_fake):
"""The disciminator loss function."""
return bce(tf.ones_like(d_real), d_real) + bce(tf.zeros_like(d_fake), d_fake)
def g_loss(generated_output):
"""The Generator loss function."""
return bce(tf.ones_like(generated_output), generated_output)
唯一的区别是我们的模型现在接受两个输入参数。
在决定噪声的先验维度并实例化 G 和 D 模型之后,定义训练函数需要对之前的训练循环做一些微小的修改。至于无条件 GAN 的训练循环定义,matplotlib 被用来记录图像。改进这个脚本的工作留给你去完成:
(tf2)
latent_dimension = 100
G = get_generator(latent_dimension)
D = get_Discriminator()
def train():
# Define the optimizers and the train operations
optimizer = tf.keras.optimizers.Adam(1e-5)
@tf.function
def train_step(image, label):
with tf.GradientTape(persistent=True) as tape:
noise_vector = tf.random.normal(
mean=0, stddev=1,
shape=(image.shape[0], latent_dimension))
# Sample from the Generator
fake_data = G([noise_vector, label])
# Compute the D loss
d_fake_data = D([fake_data, label])
d_real_data = D([image, label])
d_loss_value = d_loss(d_real_data, d_fake_data)
# Compute the G loss
g_loss_value = g_loss(d_fake_data)
# Now that we comptuted the losses we can compute the gradient
# and optimize the networks
d_gradients = tape.gradient(d_loss_value, D.trainable_variables)
g_gradients = tape.gradient(g_loss_value, G.trainable_variables)
# Deletng the tape, since we defined it as persistent
del tape
optimizer.apply_gradients(zip(d_gradients, D.trainable_variables))
optimizer.apply_gradients(zip(g_gradients, G.trainable_variables))
return g_loss_value, d_loss_value, fake_data[0], label[0]
epochs = 10
epochs = 10
for epoch in range(epochs):
for image, label in dataset:
g_loss_value, d_loss_value, generated, condition = train_step(image, label)
print("epoch ", epoch, "complete")
print("loss:", g_loss_value, "d_loss: ", d_loss_value)
print("condition ", info.features['label'].int2str(
tf.squeeze(tf.cast(condition, tf.int32)).numpy()))
plt.imshow(tf.squeeze(generated).numpy(), cmap='gray')
plt.show()
训练循环遍历训练集 10 个周期,并显示一个生成的 Fashion-MNIST 元素图像及其标签。在几个周期后,生成的图像变得越来越逼真,并开始与标签匹配,如下图所示:

生成的样本将输入随机噪声和条件 T 恤/上衣馈送给生成器。
总结
在本章中,我们研究了 GAN 和对抗训练过程。在第一部分中,介绍了对抗训练过程的理论解释,重点讨论了值函数,它用于将问题表述为一个最小-最大博弈。我们还展示了如何通过非饱和值函数,在实践中解决生成器如何解决饱和问题的学习。
我们接着看了如何在纯 TensorFlow 2.0 中实现用于创建无条件 GAN 的生成器和判别器模型。在这一部分,展示了 TensorFlow 2.0 的表达能力以及自定义训练循环的定义。事实上,展示了如何通过遵循 GAN 论文(生成对抗网络——Ian Goodfellow 等)中描述的步骤,轻松创建 Keras 模型并编写实现对抗训练过程的自定义训练循环。
Keras 函数式 API 也被广泛使用,其中实现了一个条件生成器,用于生成类似 Fashion-MNIST 的图像。该实现向我们展示了通过使用 Keras 函数式 API,如何将第二个输入(条件)传递给生成器和判别器,并轻松定义灵活的神经网络架构。
GAN 的宇宙在复杂的架构和巧妙的应用创意方面十分丰富。本章旨在解释 GAN 框架,并不声称涵盖所有内容;关于 GAN 的材料足够多,我甚至可以写一本完整的书。
本章以习题部分结束,其中包含一个挑战(问题 16 和 17):你能创建一个生成真实图像的条件 GAN 吗?从一个语义标签开始?
到目前为止,我们专注于如何训练各种模型,从简单的分类器到生成模型,而不考虑部署阶段。
在下一章,第十章,将模型投入生产,将展示每个实际机器学习应用的最后一步——学习模型的部署。
习题
尝试回答并解决以下习题,以扩展你从本章中获得的知识:
-
什么是对抗训练过程?
-
编写判别器和生成器所进行的极小极大博弈的价值函数。
-
解释为什么在训练的早期阶段,极小极大价值函数的公式可能会饱和。
-
编写并解释非饱和价值函数。
-
编写对抗训练过程的规则。
-
有没有关于如何向 GAN 传递条件的建议?
-
创建条件 GAN 意味着什么?
-
是否只能使用全连接神经网络来创建 GAN?
-
哪种神经网络架构更适合图像生成问题?
-
更新无条件 GAN 的代码:在 TensorBoard 上记录生成器和判别器的损失值,同时记录 matplotlib 图表。
-
无条件 GAN:在每个周期结束时保存模型参数到检查点。添加对模型恢复的支持,可以从最新的检查点重新开始。
-
通过将无条件 GAN 的代码扩展为条件 GAN,进行修改。给定条件 0 时,生成器必须表现得像正态分布,均值为 10,标准差为 0.1。给定条件 1 时,生成器必须产生一个从均值为 100、标准差为 1 的高斯分布中采样得到的值。
-
在 TensorBoard 中记录计算得出的梯度幅值,用于更新判别器和生成器。如果梯度幅值的绝对值大于 1,应用梯度裁剪。
-
对条件 GAN 重复执行第 1 和第 2 个练习。
-
条件 GAN:不要使用 matplotlib 绘制图像;使用
tf.summary.image和 TensorBoard。 -
使用我们在上一章中创建的数据集,第八章,语义分割和自定义数据集构建器,创建一个条件 GAN,执行领域转换,从语义标签到图像。
-
使用 TensorFlow Hub 下载一个预训练的特征提取器,并将其作为构建块,用于创建条件 GAN 的判别器,该 GAN 根据语义标签生成逼真的场景。
第十一章:将模型部署到生产环境
在本章中,将介绍任何现实机器学习应用的最终目标——训练模型的部署与推理。正如我们在前几章所看到的,TensorFlow 允许我们训练模型并将其参数保存在检查点文件中,这使得恢复模型状态并继续训练变得可能,同时也能够从 Python 执行推理。
然而,检查点文件在目标是使用经过训练的机器学习模型进行低延迟和低内存占用时并不是合适的文件格式。事实上,检查点文件只包含模型的参数值,而没有计算的描述;这迫使程序先定义模型结构,然后再恢复模型参数。此外,检查点文件包含的变量值仅在训练过程中有用,但在推理时(例如,优化器创建的所有变量)完全浪费资源。正确的表示方式是使用 SavedModel 序列化格式,接下来会进行详细介绍。在分析了 SavedModel 序列化格式,并查看如何将一个 tf.function 装饰的函数进行图转换和序列化后,我们将深入探讨 TensorFlow 部署生态系统,了解 TensorFlow 2.0 如何加速图在多个平台上的部署,并且如何为大规模服务而设计。
在本章中,我们将涵盖以下主题:
-
SavedModel 序列化格式
-
Python 部署
-
支持的部署平台
SavedModel 序列化格式
正如我们在第三章中解释的,TensorFlow 图计算架构,通过数据流图(DataFlow graphs)表示计算具有多个优势,特别是在模型可移植性方面,因为图是一种与语言无关的计算表示。
SavedModel 是 TensorFlow 模型的通用序列化格式,它通过创建一个语言无关的计算表示,扩展了 TensorFlow 标准图表示,使得该表示不仅可恢复且是封闭的。这种表示的设计不仅用于承载图的描述和值(像标准图一样),还提供了额外的特性,这些特性旨在简化在异构生产环境中使用训练过的模型。
TensorFlow 2.0 在设计时考虑了简洁性。这种设计选择在以下图示中可以明显看到,在这个图示中,可以看到 SavedModel 格式是研究和开发阶段(左侧)与部署阶段(右侧)之间的唯一桥梁:

TensorFlow 2.0 的训练和部署生态系统。图片来源:medium.com/tensorflow/whats-coming-in-tensorflow-2-0-d3663832e9b8——TensorFlow 团队
作为模型训练和部署之间的桥梁,SavedModel 格式必须提供广泛的特性,以满足可用的各种部署平台,从而为不同的软件和硬件平台提供出色的支持。
特性
SavedModel 包含一个完整的计算图,包括模型参数以及在创建过程中指定的所有其他内容。使用 TensorFlow 1.x API 创建的 SavedModel 对象仅包含计算的平面图表示;在 TensorFlow 2.0 中,SavedModel 包含一个序列化的tf.function对象表示。
创建 SavedModel 在使用 TensorFlow Python API 时非常简单(如下一节所示),但其配置要求你理解其主要特性,具体如下:
-
Graph tagging:在生产环境中,你经常需要将模型投入生产,同时在获得新数据后继续开发同一模型。另一个可能的场景是并行训练两个或多个相同的模型,这些模型使用不同的技术或不同的数据进行训练,并希望将它们全部投入生产,以测试哪个性能更好。
SavedModel 格式允许你在同一个文件中拥有多个图,这些图共享相同的变量和资产集。每个图都与一个或多个标签(用户定义的字符串)相关联,便于我们在加载操作时识别它。
-
SignatureDefs:在定义计算图时,我们需要了解模型的输入和输出;这被称为模型签名。SavedModel 序列化格式使用
SignatureDefs来允许对可能需要保存在graph.SignatureDefs中的签名提供通用支持。SignatureDefs仅仅是定义了哪些节点可以调用模型以及哪个是输出节点的命名模型签名集合,给定一个特定的输入。 -
Assets:为了允许模型依赖外部文件进行初始化,SavedModel 支持资产的概念。资产在 SavedModel 创建过程中被复制到 SavedModel 位置,并且可以被模型初始化过程安全地读取。
-
Device cleanup:我们在第三章中看到的计算图,TensorFlow 图架构,包含了计算必须执行的设备名称。为了生成可以在任何硬件平台上运行的通用图,SavedModel 支持在生成之前清理设备。
这些特性使您能够创建独立且自包含的硬件对象,指定如何调用模型、给定特定输入时的输出节点,以及在可用模型中使用哪一个特定模型(通过标签)。
从 Keras 模型创建 SavedModel
在 TensorFlow 1.x 中,创建 SavedModel 需要知道输入节点是什么,输出节点是什么,并且我们必须成功加载要保存的模型的图形表示到 tf.Session 函数中。
TensorFlow 2.0 大大简化了创建 SavedModel 的过程。由于 Keras 是唯一定义模型的方式,而且不再有会话,创建 SavedModel 的过程只需要一行代码:
(tf2)
具体结构如下:
# model is a tf.keras.Model model
path = "/tmp/model/1"
tf.saved_model.save(model, path)
path 变量遵循一种良好的实践,即在导出路径中直接添加模型的版本号 (/1)。与模型关联的唯一标签是默认的 tag: "serve"。
tf.saved_model.save 调用会在指定的 path 变量中创建以下目录结构。
assets/
variables/
variables.data-?????-of-?????
variables.index
saved_model.pb
目录包含以下内容:
-
assets包含辅助文件。这些文件在前一节中有描述。 -
variables包含模型变量。这些变量与检查点文件中的变量一样,是通过 TensorFlow Saver 对象创建的。 -
saved_model.pb是已编译的 Protobuf 文件。这是 Keras 模型描述的计算的二进制表示。
Keras 模型已经指定了模型的输入和输出;因此,无需担心哪个是输入哪个是输出。从 Keras 模型导出的 SignatureDef(值得提醒的是,它们只是描述如何调用模型的命名函数)是调用 Keras 模型的 call 方法(即前向传递),并且它在 serving_default 签名键下导出。
从 Keras 模型创建 SavedModel 非常简单,因为前向传递的描述包含在其 call 方法中。然后,TensorFlow 会使用 AutoGraph 自动将该函数转换为其图形等效表示。call 方法的输入参数成为图形的输入签名,而 Keras 模型的输出则成为输出。
然而,我们可能不想导出 Keras 模型。如果我们只想部署并提供一个通用的计算图怎么办?
从通用函数转换 SavedModel
在 TensorFlow 1.x 中,导出通用图和模型没有区别:选择输入和输出节点,创建会话,定义签名,然后保存。
在 TensorFlow 2.0 中,由于图形被隐藏,将通用的 TensorFlow 计算转换为 SavedModel(图形)需要一些额外的注意。
tf.saved_model.save(obj, export_dir, signatures=None) 函数的第一个参数描述清楚,obj 必须是一个可追踪的 对象。
可跟踪对象是从TrackableBase类派生的对象(私有,意味着它在tensorflow包中不可见)——几乎 TensorFlow 2.0 中的所有对象都派生自这个类。这些对象是可以存储在检查点文件中的对象,其中包括 Keras 模型、优化器等。
因此,像下面这样的函数无法导出,除非创建一个继承自TrackableBase对象的对象:
(tf2)
def pow(x, y):
return tf.math.pow(x, y)
TensorFlow API 中最通用的类是tf.Module类,一旦实例化,它会创建一个可跟踪对象。模块是一个命名容器,用于存放tf.Variable对象、其他模块和适用于用户输入的函数。继承tf.Module是创建可跟踪对象的直接方法,并满足tf.saved_model.save函数的要求:
(tf2)
class Wrapper(tf.Module):
def pow(self, x, y):
return tf.math.pow(x, y)
由于不是 Keras 模型,tf.saved_model.save不知道Wrapper类中的哪个方法适用于图形转换。我们可以通过两种不同的方式来指示save函数仅转换我们感兴趣的方法。它们如下:
-
指定签名:
save函数的第三个参数可以选择接受一个字典。字典必须包含要导出的函数名称和输入描述。通过使用tf.TensorSpec对象来实现。 -
使用
tf.function:当省略signature参数时,save模式会在obj中查找被@tf.function装饰的方法。如果找到了恰好一个方法,那么该方法将作为 SavedModel 的默认签名使用。并且在这种情况下,我们必须通过手动传递tf.TensorSpec对象来描述输入类型和形状,传递给tf.function的input_signature参数。
第二种方法是最方便的,它的优点还在于能够定义并将当前的 Python 程序转换为图形。使用时,这可以加速计算。
(tf2)
class Wrapper(tf.Module):
@tf.function(
input_signature=[
tf.TensorSpec(shape=None, dtype=tf.float32),
tf.TensorSpec(shape=None, dtype=tf.float32),
]
)
def pow(self, x, y):
return tf.math.pow(x, y)
obj = Wrapper()
tf.saved_model.save(obj, "/tmp/pow/1")
因此,将通用函数导出为其 SavedModel 表示的方式是将函数封装到一个可跟踪对象中,使用tf.function装饰器装饰方法,并指定转换过程中使用的输入签名。
这就是我们导出通用函数的全部步骤,也就是导出一个通用计算图或 Keras 模型到其自包含且与语言无关的表示形式,以便它可以在任何编程语言中使用。
使用 SavedModel 对象的最简单方法是使用 TensorFlow Python API,因为它是 TensorFlow 更完整的高级 API,并提供了方便的方法来加载和使用 SavedModel。
Python 部署
使用 Python,加载保存在 SavedModel 中的计算图并将其用作本地 Python 函数是非常简单的。这一切都要归功于 TensorFlow 的 Python API。tf.saved_model.load(path)方法会将位于path的 SavedModel 反序列化,并返回一个可跟踪的对象,该对象具有signatures属性,包含从签名键到已准备好使用的 Python 函数的映射。
load方法能够反序列化以下内容:
-
通用计算图,例如我们在上一节中创建的那些
-
Keras 模型
-
使用 TensorFlow 1.x 或 Estimator API 创建的 SavedModel
通用计算图
假设我们有兴趣加载在上一节中创建的pow函数的计算图,并在 Python 程序中使用它。在 TensorFlow 2.0 中,这非常简单。按照以下步骤进行操作:
- 导入模型:
(tf2)
path = "/tmp/pow/1"
imported = tf.saved_model.load(path)
imported对象具有一个signatures属性,我们可以检查它来查看可用的函数。在这种情况下,由于我们在导出模型时没有指定签名,因此我们预计只会找到默认签名"serving_default":
(tf2)
assert "serving_default" == list(imported.signatures)[0]
assert len(imported.signatures) == 1
可以通过访问imported.signatures["serving_default"]来获取幂函数的计算图。然后,它就可以准备使用了。
使用导入的计算图要求你对 TensorFlow 图结构有良好的理解,正如在第三章《TensorFlow 图架构》中解释的那样。事实上,imported.signatures["serving_default"]函数是一个静态图,因此,它需要一些额外的关注才能使用。
- 调用计算图并传入错误的输入类型会导致引发异常,因为静态图是严格静态类型的。此外,
tf.saved_model.load函数返回的对象强制要求只使用命名参数,而不能使用位置参数(这与pow函数的原始定义不同,后者只使用位置参数)。因此,一旦定义了正确形状和输入类型的输入,就可以轻松调用该函数:
(tf2)
pow = imported.signatures["serving_default"]
result = pow(x=tf.constant(2.0), y=tf.constant(5.0))
与你预期的不同,result变量并不包含一个值为32.0的tf.Tensor对象;它是一个字典。使用字典来返回计算结果是一个好的设计选择。事实上,这强制调用者(使用导入的计算图的 Python 程序)显式访问一个键,以指示所需的返回值。
- 在
pow函数的情况下,返回值是tf.Tensor而不是 Python 字典,返回的字典具有遵循命名约定的键——键名始终是"output_"字符串,后跟返回参数的位置(从零开始)。以下代码片段阐明了这个概念:
(tf2)
assert result["output_0"].numpy() == 32
如果 pow 函数更新如下,字典键将变为 "output_0", "output_1":
(tf2)
def pow(self, x, y):
return tf.math.pow(x, y), tf.math.pow(y, x)
当然,依赖默认的命名约定并不是一个好的或可维护的解决方案(output_0 代表什么?)。因此,在设计将要导出的函数时,最好使函数返回一个字典,这样导出的 SavedModel 在调用时将使用相同的字典作为返回值。因此,pow 函数的更好设计可能如下:
(tf2)
class Wrapper(tf.Module):
class Wrapper(tf.Module):
@tf.function(
input_signature=[
tf.TensorSpec(shape=None, dtype=tf.float32),
tf.TensorSpec(shape=None, dtype=tf.float32),
]
)
def pow(self, x, y):
return {"pow_x_y":tf.math.pow(x, y), "pow_y_x": tf.math.pow(y, x)}
obj = Wrapper()
tf.saved_model.save(obj, "/tmp/pow/1")
一旦导入并执行,以下代码将生成一个包含有意义名称的字典:
(tf2)
path = "/tmp/pow/1"
imported = tf.saved_model.load(path)
print(imported.signatures"serving_default",y=tf.constant(5.0)))
结果输出是以下字典:
{
'pow_x_y': <tf.Tensor: id=468, shape=(), dtype=float32, numpy=32.0>,
'pow_y_x': <tf.Tensor: id=469, shape=(), dtype=float32, numpy=25.0>
}
TensorFlow Python API 简化了不仅仅是通用计算图的加载,也简化了训练后的 Keras 模型的使用。
Keras 模型
作为官方 TensorFlow 2.0 定义机器学习模型的方式,Keras 模型在序列化时不仅包含序列化的 call 方法。由 load 函数返回的对象类似于恢复通用计算图时返回的对象,但具有更多的属性和特点:
-
.variables属性:附加在原始 Keras 模型上的不可训练变量已被序列化并存储在 SavedModel 中。 -
.trainable_variables属性:与.variables属性类似,模型的可训练变量也被序列化并存储在 SavedModel 中。 -
__call__方法:返回的对象暴露了一个__call__方法,接受的输入方式与原始 Keras 模型相同,而不是暴露一个具有单一键"serving_default"的signatures属性。
所有这些功能不仅允许将 SavedModel 作为独立的计算图使用,如下面的代码片段所示,还允许你完全恢复 Keras 模型并继续训练:
(tf2)
imported = tf.saved_model.load(path)
# inputs is a input compatible with the serialized model
outputs = imported(inputs)
正如我们之前提到的,所有这些附加特性(可训练和不可训练的变量,以及计算的序列化表示)允许从 SavedModel 完全恢复 Keras 模型对象,从而使它们可以作为检查点文件使用。Python API 提供了 tf.keras.models.load_model 函数来完成这一任务,并且在 TensorFlow 2.0 中,它非常方便:
(tf2)
model = tf.keras.models.load_model(path)
# models is now a tf.keras.Model object!
这里,path 是 SavedModel 或 h5py 文件的路径。由于 h5py 序列化格式是 Keras 的表示形式,并且与 SavedModel 序列化格式相比没有额外的优势,因此本书不考虑 h5py 格式。
Python API 还向后兼容 TensorFlow 1.x 的 SavedModel 格式,因此你可以恢复平坦图,而不是tf.function对象。
平坦图
由tf.estimator API 或使用 SavedModel 1.x API 创建的 SavedModel 对象包含计算的更原始表示,这种表示称为平坦图。
在这种表示形式中,平坦图不会继承来自tf.function对象的任何签名,以简化恢复过程。它只需直接获取计算图,以及其节点名称和变量(详情请见第三章,TensorFlow 图架构)。
这些 SavedModel 具有与其签名对应的函数(在序列化过程之前手动定义)存储在.signatures属性中,但更重要的是,恢复的 SavedModel 使用新的 TensorFlow 2.0 API 具有.prune方法,允许你仅通过知道输入和输出节点名称就能从任意子图中提取函数。
使用.prune方法相当于在默认图中恢复 SavedModel 并将其放入 TensorFlow 1.x 的 Session 中;然后,可以通过使用tf.Graph.get_tensor_by_name方法访问输入和输出节点。
通过.prune方法,TensorFlow 2.0 简化了这一过程,使其变得像下面的代码片段一样简单:
(tf2)
imported = tf.saved_model.load(v1savedmodel_path)
pruned = imported.prune("input_:0", "cnn/out/identity:0")
# inputs is an input compatible with the flat graph
out = pruned(inputs)
在这里,input_是任何可能输入节点的占位符,而"cnn/out/identity:0"是输出节点。
在 Python 程序中加载 SavedModel 后,可以将训练好的模型(或通用计算图)用作任何标准 Python 应用程序的构建块。例如,一旦你训练了一个人脸检测模型,就可以轻松使用 OpenCV(最著名的开源计算机视觉库)打开网络摄像头流,并将其输入到人脸检测模型中。训练模型的应用无数,你可以开发自己的 Python 应用程序,将训练好的机器学习模型作为构建块。
尽管 Python 是数据科学的主要语言,但它并不是在不同平台上部署机器学习模型的完美选择。有些编程语言是某些任务或环境的事实标准;例如,Javascript 用于客户端 Web 开发,C++和 Go 用于数据中心和云服务,等等。
作为一种语言无关的表示形式,理论上可以使用任何编程语言加载和执行(部署)SavedModel;这具有巨大的优势,因为有些情况下 Python 不可用,或者不是最佳选择。
TensorFlow 支持许多不同的部署平台:它提供了许多不同语言的工具和框架,以满足广泛的使用场景。
支持的部署平台
如本章开头的图示所示,SavedModel 是一个庞大部署平台生态系统的输入,每个平台的创建目标是满足不同的使用场景需求:
-
TensorFlow Serving:这是谷歌官方提供的机器学习模型服务解决方案。它支持模型版本控制,多个模型可以并行部署,并且通过完全支持硬件加速器(GPU 和 TPU),确保并发模型在低延迟下实现高吞吐量。TensorFlow Serving 不仅仅是一个部署平台,而是围绕 TensorFlow 构建的一个完整生态系统,且使用高效的 C++代码编写。目前,这是谷歌自己用来在 Google Cloud ML 平台上每秒处理数千万次推理的解决方案。
-
TensorFlow Lite:这是在移动设备和嵌入式设备上运行机器学习模型的首选部署平台。TensorFlow Lite 是一个全新的生态系统,拥有自己的训练和部署工具。它的设计目标是优化训练后的模型大小,从而生成一个针对快速推理和低功耗消耗优化的、原始模型的小型二进制表示。此外,TensorFlow Lite 框架还提供了构建新模型和重新训练现有模型的工具(因此它允许进行迁移学习/微调),这些操作可以直接在嵌入式设备或智能手机上完成。
TensorFlow Lite 附带一个 Python 工具链,用于将 SavedModel 转换为其优化表示,即
.tflite文件。 -
TensorFlow.js:这是一个类似于 TensorFlow Lite 的框架,但设计目的是在浏览器和 Node.js 中训练和部署 TensorFlow 模型。与 TensorFlow Lite 类似,该框架提供了一个 Python 工具链,可用于将 SavedModel 转换为 TensorFlow JavaScript 库可读的 JSON 格式。TensorFlow.js 可以用于微调或从零开始训练模型,利用来自浏览器或任何其他客户端的数据传感器。
-
其他语言绑定:TensorFlow 核心是用 C++编写的,且为许多不同的编程语言提供绑定,其中大多数是自动生成的。绑定的结构通常非常低级,类似于 TensorFlow 1.x Python API 和 TensorFlow C++ API 内部使用的 TensorFlow 图结构。
TensorFlow 支持许多不同的部署平台,准备在广泛的平台和设备上进行部署。在接下来的章节中,您将学习如何使用 TensorFlow.js 在浏览器中部署训练好的模型,并如何使用 Go 编程语言进行推理。
TensorFlow.js
TensorFlow.js (www.tensorflow.org/js/) 是一个用于开发和训练机器学习模型的 JavaScript 库,支持在浏览器或 Node.js 中部署这些模型。
要在 TensorFlow.js 中使用,训练好的模型必须转换为 TensorFlow.js 可加载的格式。目标格式是一个包含model.json文件和一组包含模型参数的二进制文件的目录。model.json文件包含图形描述和关于二进制文件的信息,以确保能够成功恢复训练好的模型。
尽管它与 TensorFlow 2.0 完全兼容,但为了最佳实践,建议为 TensorFlow.js 创建一个独立的环境,正如在第三章的环境设置部分中所解释的那样,TensorFlow 图架构。TensorFlow.js 专用环境从现在开始会使用(tfjs)符号,在代码片段之前显示。
开发 TensorFlow.js 应用程序的第一步是将 TensorFlow.js 安装到独立环境中。你需要这样做,以便可以通过 Python 使用所有提供的命令行工具和库本身:
(tfjs)
pip install tensorflowjs
TensorFlow.js 与 TensorFlow 2.0 紧密集成。实际上,使用 Python 直接将 Keras 模型转换为 TensorFlow.js 表示是可能的。此外,它还提供了一个命令行界面,用于将任何计算图的通用 SavedModel 转换为其支持的表示。
将 SavedModel 转换为 model.json 格式
由于无法直接在 TensorFlow.js 中使用 SavedModel,我们需要将其转换为兼容版本,然后在 TensorFlow.js 运行时加载。tensorflowjs_converter命令行工具使得转换过程变得简单明了。此工具不仅执行 SavedModel 和 TensorFlow.js 表示之间的转换,还会自动量化模型,在必要时减少其维度。
假设我们有兴趣将前一节中导出的计算图的 SavedModel 转换为 TensorFlow 格式,方法是通过序列化的pow函数。使用tensorflowjs_converter,我们只需指定输入和输出文件格式(在这种情况下,输入是 SavedModel,输出是 TensorFlow.js 图模型)及其位置,之后我们就可以开始操作了:
(tfjs)
tensorflowjs_converter \
--input_format "tf_saved_model" \
--output_format "tfjs_graph_model" \
/tmp/pow/1 \
exported_js
上述命令读取位于/tmp/pow/1的 SavedModel,并将转换结果放入当前目录exported_js中(如果该目录不存在则创建它)。由于 SavedModel 没有参数,在exported_js文件夹中,我们只会看到包含计算描述的model.json文件。
我们现在可以开始了——我们可以定义一个简单的网页或一个简单的 Node.js 应用程序,导入 TensorFlow.js 运行时,然后成功导入并使用已转换的 SavedModel。以下代码创建了一个包含表单的一页应用程序;通过使用 pow 按钮的点击事件,可以加载已导出的图并执行计算:
<html>
<head>
<title>Power</title>
<!-- Include the latest TensorFlow.js runtime -->
<script src="img/tfjs@latest"></script>
</head>
<body>
x: <input type="number" step="0.01" id="x"><br>
y: <input type="number" step="0.01" id="y"><br>
<button id="pow" name="pow">pow</button><br>
<div>
x<sup>y</sup>: <span id="x_to_y"></span>
</div>
<div>
y<sup>x</sup>: <span id="y_to_x"></span>
</div>
<script>
document.getElementById("pow").addEventListener("click", async function() {
// Load the model
const model = await tf.loadGraphModel("exported_js/model.json")
// Input Tensors
let x = tf.tensor1d([document.getElementById("x").value], dtype='float32')
let y = tf.tensor1d([document.getElementById("y").value], dtype='float32')
let results = model.execute({"x": x, "y": y})
let x_to_y = results[0].dataSync()
let y_to_x = results[1].dataSync()
document.getElementById("x_to_y").innerHTML = x_to_y
document.getElementById("y_to_x").innerHTML = y_to_x
});
</script>
</body>
</html>
TensorFlow.js 在如何使用加载的 SavedModel 方面遵循了不同的约定。如前面的代码片段所示,SavedModel 中定义的签名得以保留,并且通过传递命名参数 "x" 和 "y" 来调用该函数。相反,返回值的格式已被更改:pow_x_y 和 pow_y_x 键已被丢弃,返回值现在是位置参数;在第一个位置(results[0]),我们找到了 pow_x_y 键的值,而在第二个位置找到了 pow_y_x 键的值。
此外,由于 JavaScript 是一种对异步操作有强大支持的语言,TensorFlow.js API 在使用时也大量采用了异步操作——模型加载是异步的,并且定义在一个 async 函数内部。即使是从模型中获取结果,默认也是异步的。但在这种情况下,我们通过使用 dataSync 方法强制调用为同步。
使用 Python,我们现在可以启动一个简单的 HTTP 服务器,并在浏览器中查看该应用程序:
(tfjs)
python -m http.server
通过在浏览器中访问 http://localhost:8000/ 地址并打开包含先前编写代码的 HTML 页面,我们可以直接在浏览器中查看和使用已部署的图:

虽然 TensorFlow.js API 与 Python 版本相似,但它有所不同,并遵循不同的规则;对 TensorFlow.js 进行全面分析超出了本书的范围,因此你应该查看官方文档,以便更好地理解 TensorFlow.js API。
与前述过程相比,后者涉及使用 tensorflowjs_converter,Keras 模型的部署更加简化,并且可以直接将 Keras 模型转换为 model.json 文件的过程嵌入到用于训练模型的 TensorFlow 2.0 Python 脚本中。
将 Keras 模型转换为 model.json 格式
如本章开始时所示,Keras 模型可以导出为 SavedModel,因此,前面提到的将 SavedModel 转换为 model.json 文件的过程仍然可以使用。然而,由于 Keras 模型是 TensorFlow 2.0 框架中的特定对象,因此可以直接将部署过程嵌入到 TensorFlow.js 中,这一操作发生在训练流程的最后:
(tfjs)
import tensorflowjs as tfjs
from tensorflow import keras
model = keras.models.Sequential() # for example
# create the model by adding layers
# Standard Keras way of defining and executing the training loop
# (this can be replaced by a custom training loop)
model.compile(...)
model.fit(...)
# Convert the model to the model.json in the exported_js dir
tfjs_target_dir = "exported_js"
tfjs.converters.save_keras_model(model, tfjs_target_dir)
转换过程很简单,因为它只包含一行代码,tfjs.converters.save_keras_model(model, tfjs_target_dir)。因此,实际应用部分留给你作为练习(有关更多信息,请参见练习部分)。
在可用的部署平台中,有一长串编程语言支持 TensorFlow,这些语言的支持通常通过自动生成的绑定来实现。
支持不同编程语言是一个很大的优势,因为它允许开发者将使用 Python 开发和训练的机器学习模型嵌入到他们的应用程序中。例如,如果我们是 Go 开发者,想要在我们的应用程序中嵌入一个机器学习模型,我们可以使用 TensorFlow Go 绑定或其基础上构建的简化接口 tfgo。
Go 绑定和 tfgo
TensorFlow 对 Go 编程语言的绑定几乎完全是从 C++ API 自动生成的,因此它们只实现了基本操作。没有 Keras 模型、没有即时执行,也没有任何其他 TensorFlow 2.0 新特性;事实上,几乎没有对 Python API 进行任何更改。此外,Go API 不包含在 TensorFlow API 稳定性保证范围内,这意味着在小版本发布之间,所有内容都可能发生变化。不过,这个 API 对于加载使用 Python 创建的模型并在 Go 应用程序中运行它们特别有用。
设置
设置环境比 Python 更加复杂,因为需要下载并安装 TensorFlow C 库,并克隆整个 TensorFlow 仓库来创建正确版本的 Go TensorFlow 包。
以下 bash 脚本展示了如何下载、配置并安装没有 GPU 的 TensorFlow Go API,版本为 1.13:
#!/usr/bin/env bash
# variables
TF_VERSION_MAJOR=1
TF_VERSION_MINOR=13
TF_VERSION_PATCH=1
curl -L "https://storage.googleapis.com/tensorflow/libtensorflow/libtensorflow-cpu-linux-x86_64-""$TF_VERSION_MAJOR"."$TF_VERSION_MINOR"."$TF_VERSION_PATCH"".tar.gz" | sudo tar -C /usr/local -xz
sudo ldconfig
git clone https://github.com/tensorflow/tensorflow $GOPATH/src/github.com/tensorflow/tensorflow/
pushd $GOPATH/src/github.com/tensorflow/tensorflow/tensorflow/go
git checkout r"$TF_VERSION_MAJOR"."$TF_VERSION_MINOR"
go build
一旦安装完成,就可以构建并运行一个仅使用 Go 绑定的示例程序。
Go 绑定
请参考本节提供的示例程序,www.tensorflow.org/install/lang_go。
正如你从代码中看到的,Go 中使用 TensorFlow 与 Python 或甚至 JavaScript 的使用方式非常不同。特别是,提供的操作非常低级,而且仍然需要遵循图定义和会话执行模式。TensorFlow Go API 的详细解释超出了本书的范围;不过,你可以阅读 理解使用 Go 的 TensorFlow 文章(pgaleone.eu/tensorflow/go/2017/05/29/understanding-tensorflow-using-go/),该文解释了 Go API 的基础知识。
一个简化使用 Go 绑定的 Go 包是 tfgo。在接下来的部分,我们将使用它来恢复并执行先前导出的 SavedModel 中的 pow 操作的计算图。
使用 tfgo
安装 tfgo 非常简单;只需在安装 TensorFlow Go 包之后使用以下代码:
go get -u github.com/galeone/tfgo
由于目标是使用 Go 部署之前定义的 pow 函数的 SavedModel,我们将使用 tfgo 的 LoadModel 函数,该函数用于根据路径和所需标签加载 SavedModel。
TensorFlow 2.0 配备了 saved_model_cli 工具,可以用来检查 SavedModel 文件。该工具对于正确使用 Go 绑定或 tfgo 使用 SavedModel 至关重要。事实上,与 Python 或 TensorFlow.js 相反,Go API 需要输入和输出操作的名称,而不是在 SavedModel 创建时给出的高级名称。
通过使用saved_model_cli show,可以获得关于已检查 SavedModel 的所有信息,从而能够在 Go 中使用它们:
saved_model_cli show --all --dir /tmp/pow/1
这将生成以下信息列表:
MetaGraphDef with tag-set: 'serve' contains the following SignatureDefs:
signature_def['__saved_model_init_op']:
The given SavedModel SignatureDef contains the following input(s):
The given SavedModel SignatureDef contains the following output(s):
outputs['__saved_model_init_op'] tensor_info:
dtype: DT_INVALID
shape: unknown_rank
name: NoOp
Method name is:
signature_def['serving_default']:
The given SavedModel SignatureDef contains the following input(s):
inputs['x'] tensor_info:
dtype: DT_FLOAT
shape: unknown_rank
name: serving_default_x:0
inputs['y'] tensor_info:
dtype: DT_FLOAT
shape: unknown_rank
name: serving_default_y:0
The given SavedModel SignatureDef contains the following output(s):
outputs['pow_x_y'] tensor_info:
dtype: DT_FLOAT
shape: unknown_rank
name: PartitionedCall:0
outputs['pow_y_x'] tensor_info:
dtype: DT_FLOAT
shape: unknown_rank
name: PartitionedCall:1
Method name is: tensorflow/serving/predict
最重要的部分如下:
-
标签名称:
serve是该 SavedModel 对象中唯一的标签。 -
SignatureDefs:该 SavedModel 中有两个不同的 SignatureDefs:
__saved_model_init_op,在这种情况下不执行任何操作;以及serving_default,它包含有关导出计算图的输入和输出节点的所有必要信息。 -
输入和输出:每个 SignatureDef 部分都包含输入和输出的列表。如我们所见,对于每个节点,输出张量的 dtype、形状和生成该输出张量的操作名称都是可用的。
由于 Go 绑定支持扁平化的图结构,我们必须使用操作名称,而不是在 SavedModel 创建过程中所使用的名称,来访问输入/输出节点。
现在我们拥有了所有这些信息,使用tfgo加载和执行模型变得很简单。以下代码包含了有关如何加载模型及其使用的信息,以便它只执行计算输出节点的操作![]:
(go)
package main
import (
"fmt"
tg "github.com/galeone/tfgo"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
)
在以下代码片段中,你将从 SavedModel 标签 "serve" 中恢复模型。定义输入张量,即 x=2,y=5。然后,计算结果。输出是第一个节点,"PartitionedCall:0",它对应于 x_to_y。输入名称是 "serving_default_{x,y}",对应于 x 和 y。预测结果需要转换回正确的类型,这里是 float32:
func main() {
model := tg.LoadModel("/tmp/pow/1", []string{"serve"}, nil)
x, _ := tf.NewTensor(float32(2.0))
y, _ := tf.NewTensor(float32(5.0))
results := model.Exec([]tf.Output{
model.Op("PartitionedCall", 0),
}, map[tf.Output]*tf.Tensor{
model.Op("serving_default_x", 0): x,
model.Op("serving_default_y", 0): y,
})
predictions := results[0].Value().(float32)
fmt.Println(predictions)
}
如预期的那样,程序输出 32。
使用saved_model_cli检查 SavedModel 并在 Go 程序或任何其他支持的部署平台中使用它的过程始终是一样的,无论 SavedModel 的内容如何。这是使用标准化 SavedModel 序列化格式作为训练/图定义与部署之间唯一连接点的最大优势之一。
概要
在这一章中,我们研究了 SavedModel 序列化格式。这个标准化的序列化格式旨在简化在多个不同平台上部署机器学习模型的过程。
SavedModel 是一种与语言无关、自包含的计算表示,整个 TensorFlow 生态系统都支持它。由于基于 SavedModel 格式的转换工具或 TensorFlow 绑定提供的其他语言的本地支持,可以在嵌入式设备、智能手机、浏览器上部署训练过的机器学习模型,或者使用许多不同的语言。
部署模型的最简单方式是使用 Python,因为 TensorFlow 2.0 API 完全支持创建、恢复和操作 SavedModel 对象。此外,Python API 还提供了额外的功能和 Keras 模型与 SavedModel 对象之间的集成,使得可以将它们用作检查点。
我们看到,TensorFlow 生态系统支持的所有其他部署平台都基于 SavedModel 文件格式或其某些转换。我们使用 TensorFlow.js 在浏览器和 Node.js 中部署模型。我们了解到,我们需要额外的转换步骤,但由于 Python TensorFlow.js 包和 Keras 模型的本地支持,这一步骤很简单。自动生成的语言绑定接近于 C++ API,因此更低级且难以使用。我们还了解了 Go 绑定和 tfgo,这是 TensorFlow Go API 的简化接口。结合用于分析 SavedModel 对象的命令行工具,您已经了解到如何读取 SavedModel 中包含的信息,并将其用于在 Go 中部署 SavedModel。
我们已经完成了本书的阅读。通过回顾前几章,我们可以看到我们所取得的所有进展。你在神经网络世界的旅程不应该在这里结束;事实上,这应该是一个起点,让您可以在 TensorFlow 2.0 中创建自己的神经网络应用程序。在这段旅程中,我们学习了机器学习和深度学习的基础知识,同时强调了计算的图形表示。特别是,我们了解了以下内容:
-
机器学习基础知识,从数据集的重要性到最常见的机器学习算法家族(监督学习、无监督学习和半监督学习)。
-
最常见的神经网络架构,如何训练机器学习模型以及如何通过正则化解决过拟合问题。
-
TensorFlow 图形架构在 TensorFlow 1.x 中明确使用,并且仍然存在于 TensorFlow 2.0 中。在本章中,我们开始编写 TensorFlow 1.x 代码,在处理
tf.function时发现它非常有用。 -
TensorFlow 2.0 架构及其新的编程方式,TensorFlow 2.0 Keras 实现,急切执行以及许多其他新功能,这些内容在前几章节中也有详细解释。
-
如何创建高效的数据输入管道,并且如何使用新的TensorFlow 数据集(tfds)项目快速获取常见的基准数据集。此外,还介绍了 Estimator API,尽管它仍然使用旧的图表示。
-
如何使用 TensorFlow Hub 和 Keras 对预训练模型进行微调或进行迁移学习。通过这样做,我们学会了如何快速构建一个分类网络,从而通过重用技术巨头的工作来加速训练时间。
-
如何定义一个简单的分类和回归网络,目的是引入目标检测主题并展示如何利用 TensorFlow 即时执行轻松训练一个多头网络。
-
在目标检测之后,我们关注了更难的任务(但更易实现)——对图像进行语义分割,并开发了我们自己的 U-Net 版本来解决它。由于 TensorFlow 数据集(tfds)中没有语义分割数据集,我们还学习了如何添加自定义 DatasetBuilder 来添加新的数据集。
-
生成对抗网络(GANs)的理论以及如何使用 TensorFlow 2.0 实现对抗训练循环。此外,通过使用 fashion-MNIST 数据集,我们还学习了如何定义和训练条件 GAN。
-
最后,在这一章中,我们学会了如何通过利用 SavedModel 序列化格式和 TensorFlow 2.0 Serving 生态系统,将训练好的模型(或通用计算图)带入生产环境。
尽管这是最后一章,但仍然有练习需要做,像往常一样,你不应该跳过它们!
练习
以下练习是编程挑战,结合了 TensorFlow Python API 的表达能力和其他编程语言带来的优势:
-
什么是检查点文件?
-
什么是 SavedModel 文件?
-
检查点(checkpoint)和 SavedModel 有什么区别?
-
什么是 SignatureDef?
-
检查点可以有 SignatureDef 吗?
-
SavedModel 可以有多个 SignatureDef 吗?
-
导出一个计算批量矩阵乘法的计算图作为 SavedModel;返回的字典必须有一个有意义的键值。
-
将上一个练习中定义的 SavedModel 转换为 TensorFlow.js 表示。
-
使用我们在上一个练习中创建的
model.json文件,开发一个简单的网页,允许用户选择矩阵并计算其乘积。 -
恢复在第八章中定义的语义分割模型,语义分割与自定义数据集构建器,从其最新检查点恢复,并使用
tfjs.converters.save_keras_model将其转换为model.json文件。 -
使用我们在上一练习中导出的语义分割模型,开发一个简单的网页,给定一张图像,执行语义分割。使用
tf.fromPixels方法获取输入模型。TensorFlow.js API 的完整参考可以在js.tensorflow.org/api/latest/找到。 -
编写一个使用 TensorFlow Go 绑定的 Go 应用程序,计算一张图像与一个 3x3 卷积核之间的卷积。
-
使用 tfgo 重写你在上一练习中编写的 Go 应用程序。使用“image”包。有关更多信息,请阅读
github.com/galeone/tfgo上的文档。 -
恢复我们在第八章中定义的语义分割模型,语义分割与自定义数据集构建器,将其恢复到最新的检查点,并将其导出为 SavedModel 对象。
-
使用
tg.LoadModel将语义分割模型加载到 Go 程序中,并利用该模型为输入图像生成分割图,该图像的路径作为命令行参数传入。


与树突相关的权重。这些值在训练阶段会发生变化。在训练阶段结束时,我们说神经元已经专门化(它学会了从输入中提取特定的特征)。
是一个非线性函数,那么神经元的输出,即考虑到所有输入刺激的结果,由以下方程给出:
是偏置项,它具有基础性的重要性。它允许你学习一个不以 D 维空间原点为中心的决策边界。
,并计算获得的新参数集上的损失值,
。
,该方法是随机梯度下降
,该方法是批量梯度下降
,该方法是小批量梯度下降
是输入与参数的乘积函数
是两个值的求和函数
是修正线性单元(ReLU)激活函数
**
是输入图像
是卷积滤波器(也称为卷积核)本身,而
是其边长
是输出像素,位于
位置
作为线性神经元
作为激活函数
,使用
和
中计算一阶和二阶偏导数。
,来自噪声先验的
,来自真实数据分布的

,来自噪声先验的

函数将其压缩到 [0,1] 范围内,因为
浙公网安备 33010602011771号