TensorFlow 2.0 神经网络实用指南(二)
原文:
annas-archive.org/md5/87c674ffae26bfd552462cfe4dde7475译者:飞龙
第四章: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 分类器进行微调。
-
将复杂数据集微调为简单数据集的过程是否帮助我们在迁移学习中获得了更好的结果?如果是,为什么?如果不是,为什么?
-
如果使用更高的学习率来微调模型,会发生什么?尝试一下。