原文:Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow 译者:飞龙 协议:CC BY-NC-SA 4.0
此清单可以指导您完成机器学习项目。有八个主要步骤:
显然,您应该随时根据自己的需求调整此清单。
注意:尽可能自动化,以便您可以轻松获得新鲜数据。
注意:尝试从领域专家那里获得这些步骤的见解。
注:
注:
注意:
在测量泛化误差后不要调整模型:您只会开始过拟合测试集。
¹ Jasper Snoek 等人,“机器学习算法的实用贝叶斯优化”,《第 25 届国际神经信息处理系统会议论文集》2(2012):2951–2959。
本附录解释了 TensorFlow 的自动微分(autodiff)功能的工作原理,以及它与其他解决方案的比较。
假设您定义一个函数f(x, y) = x²y + y + 2,并且您需要其偏导数∂f/∂x和∂f/∂y,通常用于执行梯度下降(或其他优化算法)。您的主要选择是手动微分、有限差分逼近、前向自动微分和反向自动微分。TensorFlow 实现了反向自动微分,但要理解它,最好先看看其他选项。所以让我们逐个进行,从手动微分开始。
计算导数的第一种方法是拿起一支铅笔和一张纸,利用您的微积分知识推导出适当的方程。对于刚刚定义的函数f(x, y),这并不太难;您只需要使用五条规则:
从这些规则中,您可以推导出方程 B-1。
∂f ∂x = ∂(x 2 y) ∂x + ∂y ∂x + ∂2 ∂x = y ∂(x 2 ) ∂x + 0 + 0 = 2 x y ∂f ∂y = ∂(x 2 y) ∂y + ∂y ∂y + ∂2 ∂y = x 2 + 1 + 0 = x 2 + 1
对于更复杂的函数,这种方法可能变得非常繁琐,您可能会犯错。幸运的是,还有其他选择。现在让我们看看有限差分逼近。
回想一下函数h(x)在点x[0]处的导数h′(x[0])是该点处函数的斜率。更准确地说,导数被定义为通过该点x[0]和函数上另一点x的直线的斜率的极限,当x无限接近x[0]时(参见方程 B-2)。
h ' ( x 0 ) = lim x→x 0 h(x)-h(x 0 ) x-x 0 = lim ε→0 h(x 0 +ε)-h(x 0 ) ε
因此,如果我们想计算f(x, y)关于x在x = 3 和y = 4 处的偏导数,我们可以计算f(3 + ε, 4) - f(3, 4),然后将结果除以ε,使用一个非常小的ε值。这种数值逼近导数的方法称为有限差分逼近,这个特定的方程称为牛顿的差商。以下代码正是这样做的:
def f(x, y):
return x**2*y + y + 2
def derivative(f, x, y, x_eps, y_eps):
return (f(x + x_eps, y + y_eps) - f(x, y)) / (x_eps + y_eps)
df_dx = derivative(f, 3, 4, 0.00001, 0)
df_dy = derivative(f, 3, 4, 0, 0.00001)
不幸的是,结果不够精确(对于更复杂的函数来说情况会更糟)。正确的结果分别是 24 和 10,但实际上我们得到了:
>>> df_dx
24.000039999805264
>>> df_dy
10.000000000331966
注意,要计算两个偏导数,我们至少要调用f()
三次(在前面的代码中我们调用了四次,但可以进行优化)。如果有 1,000 个参数,我们至少需要调用f()
1,001 次。当处理大型神经网络时,这使得有限差分逼近方法过于低效。
然而,这种方法实现起来非常简单,是检查其他方法是否正确实现的好工具。例如,如果它与您手动推导的函数不一致,那么您的函数可能存在错误。
到目前为止,我们已经考虑了两种计算梯度的方法:手动微分和有限差分逼近。不幸的是,这两种方法都对训练大规模神经网络有致命缺陷。因此,让我们转向自动微分,从正向模式开始。
图 B-1 展示了正向模式自动微分在一个更简单的函数g(x, y) = 5 + xy 上的工作原理。该函数的图在左侧表示。经过正向模式自动微分后,我们得到右侧的图,表示偏导数∂g/∂x = 0 + (0 × x + y × 1) = y(我们可以类似地得到关于y的偏导数)。
该算法将从输入到输出遍历计算图(因此称为“正向模式”)。它从叶节点获取偏导数开始。常数节点(5)返回常数 0,因为常数的导数始终为 0。变量x返回常数 1,因为∂x/∂x = 1,变量y返回常数 0,因为∂y/∂x = 0(如果我们要找关于y的偏导数,结果将相反)。
现在我们有了所有需要的内容,可以向上移动到函数g中的乘法节点。微积分告诉我们,两个函数u和v的乘积的导数是∂(u × v)/∂x = ∂v/∂x × u + v × ∂u/∂x。因此,我们可以构建右侧的图的大部分,表示为 0 × x + y × 1。
最后,我们可以到达函数g中的加法节点。如前所述,函数和的导数是这些函数的导数之和,因此我们只需要创建一个加法节点并将其连接到我们已经计算过的图的部分。我们得到了正确的偏导数:∂g/∂x = 0 + (0 × x + y × 1)。
然而,这个方程可以被简化(很多)。通过对计算图应用一些修剪步骤,摆脱所有不必要的操作,我们得到一个只有一个节点的更小的图:∂g/∂x = y。在这种情况下,简化相当容易,但对于更复杂的函数,正向模式自动微分可能会产生一个庞大的图,可能难以简化,并导致性能不佳。
请注意,我们从一个计算图开始,正向模式自动微分产生另一个计算图。这称为符号微分,它有两个好处:首先,一旦导数的计算图被生成,我们可以使用它任意次数来计算给定函数的导数,无论x和y的值是多少;其次,如果需要的话,我们可以再次在结果图上运行正向模式自动微分,以获得二阶导数(即导数的导数)。我们甚至可以计算三阶导数,依此类推。
但也可以在不构建图形的情况下运行正向模式自动微分(即数值上,而不是符号上),只需在运行时计算中间结果。其中一种方法是使用双数,它们是形式为a + bε的奇怪但迷人的数字,其中a和b是实数,ε是一个无穷小数,使得ε² = 0(但ε ≠ 0)。您可以将双数 42 + 24ε看作类似于 42.0000⋯000024,其中有无限多个 0(但当然这只是简化,只是为了让您对双数有一些概念)。双数在内存中表示为一对浮点数。例如,42 + 24ε由一对(42.0, 24.0)表示。
双数可以相加、相乘等,如 Equation B-3 所示。
λ ( a + b ε ) = λ a + λ b ε ( a + b ε ) + ( c + d ε ) = ( a + c ) + ( b + d ) ε ( a + b ε ) × ( c + d ε ) = a c + ( a d + b c ) ε + ( b d ) ε 2 = a c + ( a d + b c ) ε
最重要的是,可以证明h(a + bε) = h(a) + b × h′(a)ε,因此计算h(a + ε)可以一次性得到h(a)和导数h′(a)。图 B-2 显示了使用双重数计算f(x, y)对x在x = 3 和y = 4 时的偏导数(我将写为∂f/∂x (3, 4))。我们只需要计算f(3 + ε, 4);这将输出一个双重数,其第一个分量等于f(3, 4),第二个分量等于∂f/∂x (3, 4)。
要计算∂f/∂y (3, 4),我们需要再次通过图进行计算,但这次是在x = 3 和y = 4 + ε的情况下。
因此,正向模式自动微分比有限差分逼近更准确,但至少在输入较多而输出较少时存在相同的主要缺陷(例如在处理神经网络时):如果有 1,000 个参数,将需要通过图进行 1,000 次传递来计算所有偏导数。这就是逆向模式自动微分的优势所在:它可以在通过图进行两次传递中计算出所有偏导数。让我们看看如何做到的。
逆向模式自动微分是 TensorFlow 实现的解决方案。它首先沿着图的正向方向(即从输入到输出)进行第一次传递,计算每个节点的值。然后进行第二次传递,这次是在反向方向(即从输出到输入)进行,计算所有偏导数。名称“逆向模式”来自于这个对图的第二次传递,在这个传递中,梯度以相反方向流动。图 B-3 代表了第二次传递。在第一次传递中,所有节点值都是从x = 3 和y = 4 开始计算的。您可以在每个节点的右下角看到这些值(例如,x × x = 9)。为了清晰起见,节点标记为n[1]到n[7]。输出节点是n[7]:f(3, 4) = n[7] = 42。
这个想法是逐渐沿着图向下走,计算f(x, y)对每个连续节点的偏导数,直到达到变量节点。为此,逆向模式自动微分在方程 B-4 中大量依赖于链式法则。
∂f ∂x = ∂f ∂n i × ∂n i ∂x
由于n[7]是输出节点,f = n[7],所以∂f / ∂n[7] = 1。
让我们继续沿着图向下走到n[5]:当n[5]变化时,f会变化多少?答案是∂f / ∂n[5] = ∂f / ∂n[7] × ∂n[7] / ∂n[5]。我们已经知道∂f / ∂n[7] = 1,所以我们只需要∂n[7] / ∂n[5]。由于n[7]只是执行n[5] + n[6]的求和,我们发现∂n[7] / ∂n[5] = 1,所以∂f / ∂n[5] = 1 × 1 = 1。
现在我们可以继续到节点n[4]:当n[4]变化时,f会变化多少?答案是∂f / ∂n[4] = ∂f / ∂n[5] × ∂n[5] / ∂n[4]。由于n[5] = n[4] × n[2],我们发现∂n[5] / ∂n[4] = n[2],所以∂f / ∂n[4] = 1 × n[2] = 4。
这个过程一直持续到我们到达图的底部。在那一点上,我们将计算出f(x, y)在x = 3 和y = 4 时的所有偏导数。在这个例子中,我们发现∂f / ∂x = 24 和∂f / ∂y = 10。听起来没错!
反向模式自动微分是一种非常强大和准确的技术,特别是当输入很多而输出很少时,因为它只需要一个前向传递加上一个反向传递来计算所有输出相对于所有输入的所有偏导数。在训练神经网络时,我们通常希望最小化损失,因此只有一个输出(损失),因此只需要通过图两次来计算梯度。反向模式自动微分还可以处理不完全可微的函数,只要您要求它在可微分的点计算偏导数。
在图 B-3 中,数值结果是在每个节点上实时计算的。然而,这并不完全是 TensorFlow 的做法:相反,它创建了一个新的计算图。换句话说,它实现了符号反向模式自动微分。这样,只需要生成一次计算图来计算神经网络中所有参数相对于损失的梯度,然后每当优化器需要计算梯度时,就可以一遍又一遍地执行它。此外,这使得在需要时可以计算高阶导数。
如果您想在 C++中实现一种新类型的低级 TensorFlow 操作,并且希望使其与自动微分兼容,那么您需要提供一个函数,该函数返回函数输出相对于其输入的偏导数。例如,假设您实现了一个计算其输入平方的函数:f(x) = x²。在这种情况下,您需要提供相应的导数函数:f′(x) = 2x。
在本附录中,我们将快速查看 TensorFlow 支持的数据结构,超出了常规的浮点或整数张量。这包括字符串、不规则张量、稀疏张量、张量数组、集合和队列。
张量可以保存字节字符串,这在自然语言处理中特别有用(请参阅第十六章):
>>> tf.constant(b"hello world")
<tf.Tensor: shape=(), dtype=string, numpy=b'hello world'>
如果尝试构建一个包含 Unicode 字符串的张量,TensorFlow 会自动将其编码为 UTF-8:
>>> tf.constant("café")
<tf.Tensor: shape=(), dtype=string, numpy=b'caf\xc3\xa9'>
还可以创建表示 Unicode 字符串的张量。只需创建一个 32 位整数数组,每个整数代表一个单个 Unicode 码点:¹
>>> u = tf.constant([ord(c) for c in "café"])
>>> u
<tf.Tensor: shape=(4,), [...], numpy=array([ 99, 97, 102, 233], dtype=int32)>
在类型为tf.string
的张量中,字符串长度不是张量形状的一部分。换句话说,字符串被视为原子值。但是,在 Unicode 字符串张量(即 int32 张量)中,字符串的长度是张量形状的一部分。
tf.strings
包含几个函数来操作字符串张量,例如length()
用于计算字节字符串中的字节数(或者如果设置unit="UTF8_CHAR"
,则计算代码点的数量),unicode_encode()
用于将 Unicode 字符串张量(即 int32 张量)转换为字节字符串张量,unicode_decode()
用于执行相反操作:
>>> b = tf.strings.unicode_encode(u, "UTF-8")
>>> b
<tf.Tensor: shape=(), dtype=string, numpy=b'caf\xc3\xa9'>
>>> tf.strings.length(b, unit="UTF8_CHAR")
<tf.Tensor: shape=(), dtype=int32, numpy=4>
>>> tf.strings.unicode_decode(b, "UTF-8")
<tf.Tensor: shape=(4,), [...], numpy=array([ 99, 97, 102, 233], dtype=int32)>
您还可以操作包含多个字符串的张量:
>>> p = tf.constant(["Café", "Coffee", "caffè", "咖啡"])
>>> tf.strings.length(p, unit="UTF8_CHAR")
<tf.Tensor: shape=(4,), dtype=int32, numpy=array([4, 6, 5, 2], dtype=int32)>
>>> r = tf.strings.unicode_decode(p, "UTF8")
>>> r
<tf.RaggedTensor [[67, 97, 102, 233], [67, 111, 102, 102, 101, 101], [99, 97,
102, 102, 232], [21654, 21857]]>
请注意,解码的字符串存储在RaggedTensor
中。那是什么?
不规则张量是一种特殊类型的张量,表示不同大小数组的列表。更一般地说,它是一个具有一个或多个不规则维度的张量,意味着切片可能具有不同长度的维度。在不规则张量r
中,第二个维度是一个不规则维度。在所有不规则张量中,第一个维度始终是一个常规维度(也称为均匀维度)。
不规则张量r
的所有元素都是常规张量。例如,让我们看看不规则张量的第二个元素:
>>> r[1]
<tf.Tensor: [...], numpy=array([ 67, 111, 102, 102, 101, 101], dtype=int32)>
tf.ragged
包含几个函数来创建和操作不规则张量。让我们使用tf.ragged.constant()
创建第二个不规则张量,并沿着轴 0 连接它与第一个不规则张量:
>>> r2 = tf.ragged.constant([[65, 66], [], [67]])
>>> tf.concat([r, r2], axis=0)
<tf.RaggedTensor [[67, 97, 102, 233], [67, 111, 102, 102, 101, 101], [99, 97,
102, 102, 232], [21654, 21857], [65, 66], [], [67]]>
结果并不太令人惊讶:r2
中的张量是沿着轴 0 在r
中的张量之后附加的。但是如果我们沿着轴 1 连接r
和另一个不规则张量呢?
>>> r3 = tf.ragged.constant([[68, 69, 70], [71], [], [72, 73]])
>>> print(tf.concat([r, r3], axis=1))
<tf.RaggedTensor [[67, 97, 102, 233, 68, 69, 70], [67, 111, 102, 102, 101, 101,
71], [99, 97, 102, 102, 232], [21654, 21857, 72, 73]]>
这次,请注意r
中的第i个张量和r3
中的第i个张量被连接。现在这更不寻常,因为所有这些张量都可以具有不同的长度。
如果调用to_tensor()
方法,不规则张量将转换为常规张量,用零填充较短的张量以获得相等长度的张量(您可以通过设置default_value
参数更改默认值):
>>> r.to_tensor()
<tf.Tensor: shape=(4, 6), dtype=int32, numpy=
array([[ 67, 97, 102, 233, 0, 0],
[ 67, 111, 102, 102, 101, 101],
[ 99, 97, 102, 102, 232, 0],
[21654, 21857, 0, 0, 0, 0]], dtype=int32)>
许多 TF 操作支持不规则张量。有关完整列表,请参阅tf.RaggedTensor
类的文档。
TensorFlow 还可以高效地表示稀疏张量(即包含大多数零的张量)。只需创建一个tf.SparseTensor
,指定非零元素的索引和值以及张量的形状。索引必须按“读取顺序”(从左到右,从上到下)列出。如果不确定,只需使用tf.sparse.reorder()
。您可以使用tf.sparse.to_dense()
将稀疏张量转换为密集张量(即常规张量):
>>> s = tf.SparseTensor(indices=[[0, 1], [1, 0], [2, 3]],
... values=[1., 2., 3.],
... dense_shape=[3, 4])
...
>>> tf.sparse.to_dense(s)
<tf.Tensor: shape=(3, 4), dtype=float32, numpy=
array([[0., 1., 0., 0.],
[2., 0., 0., 0.],
[0., 0., 0., 3.]], dtype=float32)>
请注意,稀疏张量不支持与密集张量一样多的操作。例如,您可以将稀疏张量乘以任何标量值,得到一个新的稀疏张量,但是您不能将标量值添加到稀疏张量中,因为这不会返回一个稀疏张量:
>>> s * 42.0
<tensorflow.python.framework.sparse_tensor.SparseTensor at 0x7f84a6749f10>
>>> s + 42.0
[...] TypeError: unsupported operand type(s) for +: 'SparseTensor' and 'float'
tf.TensorArray
表示一个张量列表。这在包含循环的动态模型中可能很方便,用于累积结果并稍后计算一些统计数据。您可以在数组中的任何位置读取或写入张量:
array = tf.TensorArray(dtype=tf.float32, size=3)
array = array.write(0, tf.constant([1., 2.]))
array = array.write(1, tf.constant([3., 10.]))
array = array.write(2, tf.constant([5., 7.]))
tensor1 = array.read(1) # => returns (and zeros out!) tf.constant([3., 10.])
默认情况下,读取一个项目也会用相同形状但全是零的张量替换它。如果不想要这样,可以将clear_after_read
设置为False
。
当您向数组写入时,必须将输出分配回数组,就像这个代码示例中所示。如果不这样做,尽管您的代码在急切模式下可以正常工作,但在图模式下会出错(这些模式在第十二章中讨论)。
默认情况下,TensorArray
具有在创建时设置的固定大小。或者,您可以设置size=0
和dynamic_size=True
,以便在需要时自动增长数组。但是,这会影响性能,因此如果您事先知道size
,最好使用固定大小数组。您还必须指定dtype
,并且所有元素必须与写入数组的第一个元素具有相同的形状。
您可以通过调用stack()
方法将所有项目堆叠到常规张量中:
>>> array.stack()
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[1., 2.],
[0., 0.],
[5., 7.]], dtype=float32)>
TensorFlow 支持整数或字符串的集合(但不支持浮点数)。它使用常规张量表示集合。例如,集合{1, 5, 9}
只是表示为张量[[1, 5, 9]]
。请注意,张量必须至少有两个维度,并且集合必须在最后一个维度中。例如,[[1, 5, 9], [2, 5, 11]]
是一个包含两个独立集合的张量:{1, 5, 9}
和{2, 5, 11}
。
tf.sets
包含几个用于操作集合的函数。例如,让我们创建两个集合并计算它们的并集(结果是一个稀疏张量,因此我们调用to_dense()
来显示它):
>>> a = tf.constant([[1, 5, 9]])
>>> b = tf.constant([[5, 6, 9, 11]])
>>> u = tf.sets.union(a, b)
>>> u
<tensorflow.python.framework.sparse_tensor.SparseTensor at 0x132b60d30>
>>> tf.sparse.to_dense(u)
<tf.Tensor: [...], numpy=array([[ 1, 5, 6, 9, 11]], dtype=int32)>
还可以同时计算多对集合的并集。如果某些集合比其他集合短,必须用填充值(例如 0)填充它们:
>>> a = tf.constant([[1, 5, 9], [10, 0, 0]])
>>> b = tf.constant([[5, 6, 9, 11], [13, 0, 0, 0]])
>>> u = tf.sets.union(a, b)
>>> tf.sparse.to_dense(u)
<tf.Tensor: [...] numpy=array([[ 1, 5, 6, 9, 11],
[ 0, 10, 13, 0, 0]], dtype=int32)>
如果您想使用不同的填充值,比如-1,那么在调用to_dense()
时必须设置default_value=-1
(或您喜欢的值)。
默认的default_value
是 0,所以在处理字符串集合时,必须设置这个参数(例如,设置为空字符串)。
tf.sets
中还有其他可用的函数,包括difference()
、intersection()
和size()
,它们都是不言自明的。如果要检查一个集合是否包含某些给定值,可以计算该集合和值的交集。如果要向集合添加一些值,可以计算集合和值的并集。
队列是一种数据结构,您可以将数据记录推送到其中,然后再将它们取出。TensorFlow 在tf.queue
包中实现了几种类型的队列。在实现高效的数据加载和预处理流水线时,它们曾经非常重要,但是 tf.data API 基本上使它们变得无用(也许在一些罕见情况下除外),因为使用起来更简单,并提供了构建高效流水线所需的所有工具。为了完整起见,让我们快速看一下它们。
最简单的队列是先进先出(FIFO)队列。要构建它,您需要指定它可以包含的记录的最大数量。此外,每个记录都是张量的元组,因此您必须指定每个张量的类型,以及可选的形状。例如,以下代码示例创建了一个最多包含三条记录的 FIFO 队列,每条记录包含一个 32 位整数和一个字符串的元组。然后将两条记录推送到队列中,查看大小(此时为 2),并取出一条记录:
>>> q = tf.queue.FIFOQueue(3, [tf.int32, tf.string], shapes=[(), ()])
>>> q.enqueue([10, b"windy"])
>>> q.enqueue([15, b"sunny"])
>>> q.size()
<tf.Tensor: shape=(), dtype=int32, numpy=2>
>>> q.dequeue()
[<tf.Tensor: shape=(), dtype=int32, numpy=10>,
<tf.Tensor: shape=(), dtype=string, numpy=b'windy'>]
还可以使用enqueue_many()
和dequeue_many()
一次入队和出队多个记录(要使用dequeue_many()
,必须在创建队列时指定shapes
参数,就像我们之前做的那样):
>>> q.enqueue_many([[13, 16], [b'cloudy', b'rainy']])
>>> q.dequeue_many(3)
[<tf.Tensor: [...], numpy=array([15, 13, 16], dtype=int32)>,
<tf.Tensor: [...], numpy=array([b'sunny', b'cloudy', b'rainy'], dtype=object)>]
其他队列类型包括:
PaddingFIFOQueue
与FIFOQueue
相同,但其dequeue_many()
方法支持出队不同形状的多个记录。它会自动填充最短的记录,以确保批次中的所有记录具有相同的形状。
PriorityQueue
一个按优先级顺序出队记录的队列。优先级必须作为每个记录的第一个元素包含在其中,是一个 64 位整数。令人惊讶的是,优先级较低的记录将首先出队。具有相同优先级的记录将按照 FIFO 顺序出队。
RandomShuffleQueue
一个记录以随机顺序出队的队列。在 tf.data 出现之前,这对实现洗牌缓冲区很有用。
如果队列已满并且您尝试入队另一个记录,则enqueue*()
方法将冻结,直到另一个线程出队一条记录。同样,如果队列为空并且您尝试出队一条记录,则dequeue*()
方法将冻结,直到另一个线程将记录推送到队列中。
如果您不熟悉 Unicode 代码点,请查看https://homl.info/unicode。
在本附录中,我们将探索由 TF 函数生成的图形(请参阅第十二章)。
TF 函数是多态的,意味着它们支持不同类型(和形状)的输入。例如,考虑以下tf_cube()
函数:
@tf.function
def tf_cube(x):
return x ** 3
每次您调用一个 TF 函数并使用新的输入类型或形状组合时,它会生成一个新的具体函数,具有为这种特定组合专门优化的图形。这样的参数类型和形状组合被称为输入签名。如果您使用它之前已经见过的输入签名调用 TF 函数,它将重用之前生成的具体函数。例如,如果您调用tf_cube(tf.constant(3.0))
,TF 函数将重用用于tf_cube(tf.constant(2.0))
(对于 float32 标量张量)的相同具体函数。但是,如果您调用tf_cube(tf.constant([2.0]))
或tf_cube(tf.constant([3.0]))
(对于形状为[1]的 float32 张量),它将生成一个新的具体函数,对于tf_cube(tf.constant([[1.0, 2.0], [3.0, 4.0]]))
(对于形状为[2, 2]的 float32 张量),它将生成另一个新的具体函数。您可以通过调用 TF 函数的get_concrete_function()
方法来获取特定输入组合的具体函数。然后可以像普通函数一样调用它,但它只支持一个输入签名(在此示例中为 float32 标量张量):
>>> concrete_function = tf_cube.get_concrete_function(tf.constant(2.0))
>>> concrete_function
<ConcreteFunction tf_cube(x) at 0x7F84411F4250>
>>> concrete_function(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>
图 D-1 显示了tf_cube()
TF 函数,在我们调用tf_cube(2)
和tf_cube(tf.constant(2.0))
之后:生成了两个具体函数,每个签名一个,每个具有自己优化的函数图(FuncGraph
)和自己的函数定义(FunctionDef
)。函数定义指向与函数的输入和输出对应的图的部分。在每个FuncGraph
中,节点(椭圆形)表示操作(例如,幂运算,常量,或用于参数的占位符如x
),而边(操作之间的实箭头)表示将在图中流动的张量。左侧的具体函数专门用于x=2
,因此 TensorFlow 成功将其简化为始终输出 8(请注意,函数定义甚至没有输入)。右侧的具体函数专门用于 float32 标量张量,无法简化。如果我们调用tf_cube(tf.constant(5.0))
,将调用第二个具体函数,x
的占位符操作将输出 5.0,然后幂运算将计算5.0 ** 3
,因此输出将为 125.0。
tf_cube()
TF 函数,及其ConcreteFunction
和它们的FuncGraph
这些图中的张量是符号张量,意味着它们没有实际值,只有数据类型、形状和名称。它们代表将在实际值被馈送到占位符x
并执行图形后流经图形的未来张量。符号张量使得可以预先指定如何连接操作,并且还允许 TensorFlow 递归推断所有张量的数据类型和形状,鉴于它们的输入的数据类型和形状。
现在让我们继续窥探底层,并看看如何访问函数定义和函数图,以及如何探索图的操作和张量。
您可以使用graph
属性访问具体函数的计算图,并通过调用图的get_operations()
方法获取其操作列表:
>>> concrete_function.graph
<tensorflow.python.framework.func_graph.FuncGraph at 0x7f84411f4790>
>>> ops = concrete_function.graph.get_operations()
>>> ops
[<tf.Operation 'x' type=Placeholder>,
<tf.Operation 'pow/y' type=Const>,
<tf.Operation 'pow' type=Pow>,
<tf.Operation 'Identity' type=Identity>]
在这个例子中,第一个操作代表输入参数 x
(它被称为 占位符),第二个“操作”代表常数 3
,第三个操作代表幂运算(**
),最后一个操作代表这个函数的输出(它是一个恒等操作,意味着它不会做任何比幂运算输出的更多的事情^(1))。每个操作都有一个输入和输出张量的列表,您可以通过操作的 inputs
和 outputs
属性轻松访问。例如,让我们获取幂运算的输入和输出列表:
>>> pow_op = ops[2]
>>> list(pow_op.inputs)
[<tf.Tensor 'x:0' shape=() dtype=float32>,
<tf.Tensor 'pow/y:0' shape=() dtype=float32>]
>>> pow_op.outputs
[<tf.Tensor 'pow:0' shape=() dtype=float32>]
这个计算图在 图 D-2 中表示。
请注意每个操作都有一个名称。它默认为操作的名称(例如,"pow"
),但当调用操作时您可以手动定义它(例如,tf.pow(x, 3, name="other_name")
)。如果名称已经存在,TensorFlow 会自动添加一个唯一的索引(例如,"pow_1"
,"pow_2"
等)。每个张量也有一个唯一的名称:它总是输出该张量的操作的名称,如果它是操作的第一个输出,则为 :0
,如果它是第二个输出,则为 :1
,依此类推。您可以使用图的 get_operation_by_name()
或 get_tensor_by_name()
方法按名称获取操作或张量:
>>> concrete_function.graph.get_operation_by_name('x')
<tf.Operation 'x' type=Placeholder>
>>> concrete_function.graph.get_tensor_by_name('Identity:0')
<tf.Tensor 'Identity:0' shape=() dtype=float32>
具体函数还包含函数定义(表示为协议缓冲区^(2)),其中包括函数的签名。这个签名允许具体函数知道要用输入值填充哪些占位符,以及要返回哪些张量:
>>> concrete_function.function_def.signature
name: "__inference_tf_cube_3515903"
input_arg {
name: "x"
type: DT_FLOAT
}
output_arg {
name: "identity"
type: DT_FLOAT
}
现在让我们更仔细地看一下跟踪。
让我们调整 tf_cube()
函数以打印其输入:
@tf.function
def tf_cube(x):
print(f"x = {x}")
return x ** 3
现在让我们调用它:
>>> result = tf_cube(tf.constant(2.0))
x = Tensor("x:0", shape=(), dtype=float32)
>>> result
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>
result
看起来不错,但看看打印出来的内容:x
是一个符号张量!它有一个形状和数据类型,但没有值。而且它有一个名称("x:0"
)。这是因为 print()
函数不是一个 TensorFlow 操作,所以它只会在 Python 函数被跟踪时运行,这发生在图模式下,参数被替换为符号张量(相同类型和形状,但没有值)。由于 print()
函数没有被捕获到图中,所以下一次我们用 float32 标量张量调用 tf_cube()
时,什么也不会被打印:
>>> result = tf_cube(tf.constant(3.0))
>>> result = tf_cube(tf.constant(4.0))
但是,如果我们用不同类型或形状的张量,或者用一个新的 Python 值调用 tf_cube()
,函数将再次被跟踪,因此 print()
函数将被调用:
>>> result = tf_cube(2) # new Python value: trace!
x = 2
>>> result = tf_cube(3) # new Python value: trace!
x = 3
>>> result = tf_cube(tf.constant([[1., 2.]])) # new shape: trace!
x = Tensor("x:0", shape=(1, 2), dtype=float32)
>>> result = tf_cube(tf.constant([[3., 4.], [5., 6.]])) # new shape: trace!
x = Tensor("x:0", shape=(None, 2), dtype=float32)
>>> result = tf_cube(tf.constant([[7., 8.], [9., 10.]])) # same shape: no trace
如果您的函数具有 Python 副作用(例如,将一些日志保存到磁盘),请注意此代码只会在函数被跟踪时运行(即每次用新的输入签名调用 TF 函数时)。最好假设函数可能在调用 TF 函数时随时被跟踪(或不被跟踪)。
在某些情况下,您可能希望将 TF 函数限制为特定的输入签名。例如,假设您知道您只会用 28 × 28 像素图像的批次调用 TF 函数,但是批次的大小会有很大的不同。您可能不希望 TensorFlow 为每个批次大小生成不同的具体函数,或者依赖它自行决定何时使用 None
。在这种情况下,您可以像这样指定输入签名:
@tf.function(input_signature=[tf.TensorSpec([None, 28, 28], tf.float32)])
def shrink(images):
return images[:, ::2, ::2] # drop half the rows and columns
这个 TF 函数将接受任何形状为 [*, 28, 28] 的 float32 张量,并且每次都会重用相同的具体函数:
img_batch_1 = tf.random.uniform(shape=[100, 28, 28])
img_batch_2 = tf.random.uniform(shape=[50, 28, 28])
preprocessed_images = shrink(img_batch_1) # works fine, traces the function
preprocessed_images = shrink(img_batch_2) # works fine, same concrete function
然而,如果您尝试用 Python 值调用这个 TF 函数,或者用意外的数据类型或形状的张量调用它,您将会得到一个异常:
img_batch_3 = tf.random.uniform(shape=[2, 2, 2])
preprocessed_images = shrink(img_batch_3) # ValueError! Incompatible inputs
如果您的函数包含一个简单的 for
循环,您期望会发生什么?例如,让我们编写一个函数,通过连续添加 1 来将 10 添加到其输入中:
@tf.function
def add_10(x):
for i in range(10):
x += 1
return x
它运行正常,但当我们查看它的图时,我们发现它不包含循环:它只包含 10 个加法操作!
>>> add_10(tf.constant(0))
<tf.Tensor: shape=(), dtype=int32, numpy=15>
>>> add_10.get_concrete_function(tf.constant(0)).graph.get_operations()
[<tf.Operation 'x' type=Placeholder>, [...],
<tf.Operation 'add' type=AddV2>, [...],
<tf.Operation 'add_1' type=AddV2>, [...],
<tf.Operation 'add_2' type=AddV2>, [...],
[...]
<tf.Operation 'add_9' type=AddV2>, [...],
<tf.Operation 'Identity' type=Identity>]
实际上这是有道理的:当函数被跟踪时,循环运行了 10 次,因此x += 1
操作运行了 10 次,并且由于它处于图模式下,它在图中记录了这个操作 10 次。您可以将这个for
循环看作是一个在创建图表时被展开的“静态”循环。
如果您希望图表包含一个“动态”循环(即在执行图表时运行的循环),您可以手动使用tf.while_loop()
操作创建一个,但这并不直观(请参见第十二章笔记本的“使用 AutoGraph 捕获控制流”部分以获取示例)。相反,使用 TensorFlow 的AutoGraph功能要简单得多,详见第十二章。AutoGraph 实际上是默认激活的(如果您需要关闭它,可以在tf.function()
中传递autograph=False
)。因此,如果它是开启的,为什么它没有捕获add_10()
函数中的for
循环呢?它只捕获对tf.data.Dataset
对象的张量进行迭代的for
循环,因此您应该使用tf.range()
而不是range()
。这是为了给您选择:
range()
,for
循环将是静态的,这意味着仅在跟踪函数时才会执行。循环将被“展开”为每次迭代的一组操作,正如我们所见。
tf.range()
,循环将是动态的,这意味着它将包含在图表本身中(但在跟踪期间不会运行)。
让我们看看如果在add_10()
函数中将range()
替换为tf.range()
时生成的图表:
>>> add_10.get_concrete_function(tf.constant(0)).graph.get_operations()
[<tf.Operation 'x' type=Placeholder>, [...],
<tf.Operation 'while' type=StatelessWhile>, [...]]
如您所见,图现在包含一个While
循环操作,就好像我们调用了tf.while_loop()
函数一样。
在 TensorFlow 中,变量和其他有状态对象,如队列或数据集,被称为资源。TF 函数对它们进行特殊处理:任何读取或更新资源的操作都被视为有状态的,并且 TF 函数确保有状态的操作按照它们出现的顺序执行(与无状态操作相反,后者可能并行运行,因此它们的执行顺序不被保证)。此外,当您将资源作为参数传递给 TF 函数时,它会通过引用传递,因此函数可能会对其进行修改。例如:
counter = tf.Variable(0)
@tf.function
def increment(counter, c=1):
return counter.assign_add(c)
increment(counter) # counter is now equal to 1
increment(counter) # counter is now equal to 2
如果查看函数定义,第一个参数被标记为资源:
>>> function_def = increment.get_concrete_function(counter).function_def
>>> function_def.signature.input_arg[0]
name: "counter"
type: DT_RESOURCE
还可以在函数外部使用定义的tf.Variable
,而无需显式将其作为参数传递:
counter = tf.Variable(0)
@tf.function
def increment(c=1):
return counter.assign_add(c)
TF 函数将将其视为隐式的第一个参数,因此实际上最终会具有相同的签名(除了参数的名称)。但是,使用全局变量可能会很快变得混乱,因此通常应该将变量(和其他资源)封装在类中。好消息是@tf.function
也可以很好地与方法一起使用:
class Counter:
def __init__(self):
self.counter = tf.Variable(0)
@tf.function
def increment(self, c=1):
return self.counter.assign_add(c)
不要使用=
、+=
、-=
或任何其他 Python 赋值运算符与 TF 变量。相反,您必须使用assign()
、assign_add()
或assign_sub()
方法。如果尝试使用 Python 赋值运算符,当调用该方法时将会出现异常。
这种面向对象的方法的一个很好的例子当然是 Keras。让我们看看如何在 Keras 中使用 TF 函数。
默认情况下,您在 Keras 中使用的任何自定义函数、层或模型都将自动转换为 TF 函数;您无需做任何事情!但是,在某些情况下,您可能希望停用此自动转换——例如,如果您的自定义代码无法转换为 TF 函数,或者如果您只想调试代码(在急切模式下更容易)。为此,您只需在创建模型或其任何层时传递dynamic=True
:
model = MyModel(dynamic=True)
如果您的自定义模型或层将始终是动态的,可以使用dynamic=True
调用基类的构造函数:
class MyDense(tf.keras.layers.Layer):
def __init__(self, units, **kwargs):
super().__init__(dynamic=True, **kwargs)
[...]
或者,在调用compile()
方法时传递run_eagerly=True
:
model.compile(loss=my_mse, optimizer="nadam", metrics=[my_mae],
run_eagerly=True)
现在你知道了 TF 函数如何处理多态性(具有多个具体函数),如何使用 AutoGraph 和追踪自动生成图形,图形的样子,如何探索它们的符号操作和张量,如何处理变量和资源,以及如何在 Keras 中使用 TF 函数。
¹ 你可以安全地忽略它 - 它只是为了技术原因而在这里,以确保 TF 函数不会泄漏内部结构。
² 在第十三章中讨论的一种流行的二进制格式。