原文:Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow
译者:飞龙
协议:CC BY-NC-SA 4.0
此清单可以指导您完成机器学习项目。有八个主要步骤:
构建问题并全局看问题。
获取数据。
探索数据以获得见解。
准备数据以更好地暴露底层数据模式给机器学习算法。
探索许多不同的模型并列出最佳模型。
微调您的模型并将它们组合成一个出色的解决方案。
展示您的解决方案。
启动,监控和维护您的系统。
显然,您应该随时根据自己的需求调整此清单。
用业务术语定义目标。
您的解决方案将如何使用?
当前的解决方案/变通方法是什么(如果有的话)?
应该如何框定这个问题(监督/无监督,在线/离线等)?
应如何衡量性能?
性能度量是否与业务目标一致?
达到业务目标所需的最低性能是多少?
有哪些可比较的问题?您能重复使用经验或工具吗?
是否有人类专业知识?
您如何手动解决问题?
列出到目前为止您(或其他人)已经做出的假设。
验证假设(如果可能)。
注意:尽可能自动化,以便您可以轻松获得新鲜数据。
列出您需要的数据以及需要多少数据。
找到并记录您可以获取数据的位置。
检查它将占用多少空间。
检查法律义务,并在必要时获得授权。
获取访问授权。
创建一个工作空间(具有足够的存储空间)。
获取数据。
将数据转换为您可以轻松操作的格式(而不更改数据本身)。
确保敏感信息被删除或受到保护(例如,匿名化)。
检查数据的大小和类型(时间序列,样本,地理等)。
对测试集进行抽样,将其放在一边,永远不要查看(不要窥探数据!)。
注意:尝试从领域专家那里获得这些步骤的见解。
为探索创建数据副本(如果需要,将其采样到可管理的大小)。
创建一个 Jupyter 笔记本来记录您的数据探索。
研究每个属性及其特征:
名称
类型(分类,整数/浮点数,有界/无界,文本,结构化等)
缺失值的百分比
噪声和噪声类型(随机的,异常值,舍入误差等)
任务的实用性
分布类型(高斯,均匀,对数等)
对于监督学习任务,识别目标属性。
可视化数据。
研究属性之间的相关性。
研究您如何手动解决问题。
识别您可能要应用的有前途的转换。
识别可能有用的额外数据(返回到“获取数据”步骤)。
记录您学到的东西。
注:
在数据的副本上工作(保持原始数据集完整)。
为您应用的所有数据转换编写函数,有五个原因:
这样您可以在下次获得新数据时轻松准备数据
这样您可以在未来项目中应用这些转换
清理和准备测试集。
在解决方案上线后,清理和准备新数据实例
使您的准备选择易于视为超参数
清理数据:
修复或删除异常值(可选)。
填补缺失值(例如,用零,平均值,中位数…)或删除它们的行(或列)。
执行特征选择(可选):
在适当的情况下进行特征工程:
离散化连续特征。
分解特征(例如,分类,日期/时间等)。
添加有前途的特征转换(例如,对数(* x ),平方根( x ), x *²等)。
将特征聚合成有前途的新特征。
执行特征缩放:
注:
如果数据很大,您可能希望对较小的训练集进行抽样,以便在合理的时间内训练许多不同的模型(请注意,这会惩罚复杂模型,如大型神经网络或随机森林)。
再次尝试尽可能自动化这些步骤。
使用标准参数从不同类别(例如线性、朴素贝叶斯、SVM、随机森林、神经网络等)训练许多快速而粗糙的模型。
测量并比较它们的性能:
分析每个算法的最重要变量。
分析模型所犯的错误类型:
进行一轮快速的特征选择和工程。
再进行一两次快速迭代,按照之前五个步骤。
列出前三到五个最有前途的模型,更喜欢产生不同类型错误的模型。
注意:
在这一步骤中,您将希望尽可能使用更多数据,特别是在朝着微调的最后阶段。
像往常一样,尽可能自动化。
使用交叉验证微调超参数:
将数据转换选择视为超参数,特别是在您不确定时(例如,如果您不确定是用零还是中位数替换缺失值,或者只是删除行)。
除非要探索的超参数值非常少,否则更喜欢随机搜索而不是网格搜索。如果训练时间很长,您可能更喜欢贝叶斯优化方法(例如,使用高斯过程先验,如Jasper Snoek 等人所述¹)。
尝试集成方法。将您最好的模型组合在一起通常会比单独运行它们产生更好的性能。
一旦您对最终模型有信心,请在测试集上测量其性能以估计泛化误差。
在测量泛化误差后不要调整模型:您只会开始过拟合测试集。
记录您所做的工作。
创建一个漂亮的演示文稿:
解释为什么您的解决方案实现了业务目标。
不要忘记呈现您沿途注意到的有趣点:
描述哪些工作了,哪些没有。
列出您的假设和系统的限制。
确保通过美丽的可视化或易于记忆的陈述传达您的关键发现(例如,“收入中位数是房价的头号预测因子”)。
准备好将您的解决方案投入生产(连接到生产数据输入,编写单元测试等)。
编写监控代码,定期检查系统的实时性能,并在性能下降时触发警报:
注意缓慢退化:随着数据的演变,模型往往会“腐烂”。
测量性能可能需要一个人类管道(例如,通过众包服务)。
还要监控输入的质量(例如,故障传感器发送随机值,或者另一个团队的输出变得陈旧)。这对在线学习系统尤为重要。
定期在新数据上重新训练您的模型(尽可能自动化)。
¹ Jasper Snoek 等人,“机器学习算法的实用贝叶斯优化”,《第 25 届国际神经信息处理系统会议论文集》2(2012):2951–2959。
本附录解释了 TensorFlow 的自动微分(autodiff)功能的工作原理,以及它与其他解决方案的比较。
假设您定义一个函数f(x, y) = x²y + y + 2,并且您需要其偏导数∂f/∂x和∂f/∂y,通常用于执行梯度下降(或其他优化算法)。您的主要选择是手动微分、有限差分逼近、前向自动微分和反向自动微分。TensorFlow 实现了反向自动微分,但要理解它,最好先看看其他选项。所以让我们逐个进行,从手动微分开始。
计算导数的第一种方法是拿起一支铅笔和一张纸,利用您的微积分知识推导出适当的方程。对于刚刚定义的函数f(x, y),这并不太难;您只需要使用五条规则:
常数的导数是 0。
λx的导数是λ(其中λ是一个常数)。
xλ的导数是*λx*(λ) ^– ¹,所以x²的导数是 2x。
函数和的导数是这些函数的导数之和。
λ倍函数的导数是λ乘以其导数。
从这些规则中,您可以推导出方程 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 函数不会泄漏内部结构。
² 在第十三章中讨论的一种流行的二进制格式。