原文:Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow
译者:飞龙
第十一章:训练深度神经网络
在第十章中,您构建、训练和微调了您的第一个人工神经网络。但它们是浅层网络,只有几个隐藏层。如果您需要解决一个复杂的问题,比如在高分辨率图像中检测数百种对象,您可能需要训练一个更深的人工神经网络,也许有 10 层或更多层,每一层包含数百个神经元,通过数十万个连接相连。训练深度神经网络并不是一件轻松的事情。以下是您可能遇到的一些问题:
-
在训练过程中,当反向传播通过 DNN 向后流动时,您可能会面临梯度变得越来越小或越来越大的问题。这两个问题都会使得较低层非常难以训练。
-
您可能没有足够的训练数据来训练这样一个庞大的网络,或者标记成本太高。
-
训练可能会非常缓慢。
-
一个拥有数百万参数的模型会严重增加过拟合训练集的风险,特别是如果训练实例不足或者太嘈杂。
在本章中,我们将逐个讨论这些问题,并提出解决方法。我们将首先探讨梯度消失和梯度爆炸问题以及它们最流行的解决方案。接下来,我们将看看迁移学习和无监督预训练,这可以帮助您解决复杂任务,即使您只有很少的标记数据。然后,我们将讨论各种优化器,可以极大地加快训练大型模型。最后,我们将介绍一些用于大型神经网络的流行正则化技术。
有了这些工具,您将能够训练非常深的网络。欢迎来到深度学习!
梯度消失/爆炸问题
正如在第十章中讨论的那样,反向传播算法的第二阶段是从输出层到输入层,沿途传播错误梯度。一旦算法计算出网络中每个参数相对于成本函数的梯度,它就会使用这些梯度来更新每个参数,进行梯度下降步骤。
不幸的是,随着算法向下进行到更低的层,梯度通常会变得越来越小。结果是,梯度下降更新几乎不会改变较低层的连接权重,训练永远不会收敛到一个好的解决方案。这被称为梯度消失问题。在某些情况下,相反的情况可能发生:梯度会变得越来越大,直到层的权重更新变得非常大,算法发散。这是梯度爆炸问题,最常出现在递归神经网络中(参见第十五章)。更一般地说,深度神经网络受到不稳定梯度的困扰;不同层可能以非常不同的速度学习。
或者在-r 和+r 之间的均匀分布,r = sqrt(3 / fan_avg)
在他们的论文中,Glorot 和 Bengio 提出了一种显著减轻不稳定梯度问题的方法。他们指出,我们需要信号在两个方向上正确地流动:在前向方向进行预测时,以及在反向方向进行反向传播梯度时。我们不希望信号消失,也不希望它爆炸和饱和。为了使信号正确地流动,作者认为每一层的输出方差应该等于其输入方差,并且在反向方向通过一层之后,梯度在前后具有相等的方差(如果您对数学细节感兴趣,请查看论文)。实际上,除非层具有相等数量的输入和输出(这些数字称为层的fan-in和fan-out),否则不可能保证两者都相等,但 Glorot 和 Bengio 提出了一个在实践中被证明非常有效的良好折衷方案:每层的连接权重必须随机初始化,如方程 11-1 所述,其中fan[avg] = (fan[in] + fan[out]) / 2。这种初始化策略称为Xavier 初始化或Glorot 初始化,以论文的第一作者命名。
观察 Sigmoid 激活函数(参见图 11-1),您会发现当输入变大(负或正)时,函数在 0 或 1 处饱和,导数非常接近 0(即曲线在两个极端处平坦)。因此,当反向传播开始时,几乎没有梯度可以通过网络向后传播,存在的微小梯度会随着反向传播通过顶层逐渐稀释,因此对于较低层几乎没有剩余的梯度。
图 11-1。Sigmoid 激活函数饱和
Glorot 和 He 初始化
这种不幸的行为早在很久以前就被经验性地观察到,这也是深度神经网络在 2000 年代初大多被放弃的原因之一。当训练 DNN 时,梯度不稳定的原因并不清楚,但在 2010 年的一篇论文中,Xavier Glorot 和 Yoshua Bengio 揭示了一些端倪。作者发现了一些嫌疑人,包括当时最流行的 Sigmoid(逻辑)激活函数和权重初始化技术的组合(即均值为 0,标准差为 1 的正态分布)。简而言之,他们表明,使用这种激活函数和初始化方案,每一层的输出方差远大于其输入方差。在网络中前进,每一层的方差在每一层之后都会增加,直到激活函数在顶层饱和。实际上,这种饱和现象被 sigmoid 函数的均值为 0.5 而不是 0 所加剧(双曲正切函数的均值为 0,在深度网络中的表现略好于 sigmoid 函数)。
方程 11-1。Glorot 初始化(使用 Sigmoid 激活函数时)
正态分布,均值为 0,方差为σ² = 1 / fan_avg
如果您在方程式 11-1 中用fan[in]替换fan[avg],您将得到 Yann LeCun 在 1990 年代提出的初始化策略。他称之为LeCun 初始化。Genevieve Orr 和 Klaus-Robert Müller 甚至在他们 1998 年的书Neural Networks: Tricks of the Trade(Springer)中推荐了这种方法。当fan[in] = fan[out]时,LeCun 初始化等同于 Glorot 初始化。研究人员花了十多年的时间才意识到这个技巧有多重要。使用 Glorot 初始化可以显著加快训练速度,这是深度学习成功的实践之一。
一些论文提供了不同激活函数的类似策略。这些策略仅在方差的规模和它们是否使用fan[avg]或fan[in]上有所不同,如表 11-1 所示(对于均匀分布,只需使用r=3σ2)。为 ReLU 激活函数及其变体提出的初始化策略称为He 初始化或Kaiming 初始化,以论文的第一作者命名。对于 SELU,最好使用 Yann LeCun 的初始化方法,最好使用正态分布。我们将很快介绍所有这些激活函数。
表 11-1。每种激活函数的初始化参数
| 初始化 | 激活函数 | σ²(正态) |
|---|---|---|
| Glorot | 无,tanh,sigmoid,softmax | 1 / fan[avg] |
| He | ReLU,Leaky ReLU,ELU,GELU,Swish,Mish | 2 / fan[in] |
| LeCun | SELU | 1 / fan[in] |
默认情况下,Keras 使用均匀分布的 Glorot 初始化。当您创建一个层时,您可以通过设置kernel_initializer="he_uniform"或kernel_initializer="he_normal"来切换到 He 初始化。
import tensorflow as tf
dense = tf.keras.layers.Dense(50, activation="relu",
kernel_initializer="he_normal")
或者,您可以使用VarianceScaling初始化器获得表 11-1 中列出的任何初始化方法,甚至更多。例如,如果您想要使用均匀分布并基于fan[avg](而不是fan[in])进行 He 初始化,您可以使用以下代码:
he_avg_init = tf.keras.initializers.VarianceScaling(scale=2., mode="fan_avg",
distribution="uniform")
dense = tf.keras.layers.Dense(50, activation="sigmoid",
kernel_initializer=he_avg_init)
更好的激活函数
2010 年 Glorot 和 Bengio 的一篇论文中的一个见解是,不稳定梯度的问题在一定程度上是由于激活函数的选择不当。直到那时,大多数人都认为,如果自然界选择在生物神经元中使用大致为 S 形的激活函数,那么它们一定是一个很好的选择。但事实证明,其他激活函数在深度神经网络中表现得更好,特别是 ReLU 激活函数,主要是因为它对于正值不会饱和,而且计算速度非常快。
不幸的是,ReLU 激活函数并不完美。它存在一个称为dying ReLUs的问题:在训练过程中,一些神经元实际上“死亡”,意味着它们停止输出除 0 以外的任何值。在某些情况下,您可能会发现您网络的一半神经元已经死亡,尤其是如果您使用了较大的学习率。当神经元的权重被微调得使得 ReLU 函数的输入(即神经元输入的加权和加上偏置项)在训练集中的所有实例中都为负时,神经元就会死亡。当这种情况发生时,它只会继续输出零,并且梯度下降不再影响它,因为当其输入为负时,ReLU 函数的梯度为零。
为了解决这个问题,您可能希望使用 ReLU 函数的变体,比如leaky ReLU。
Leaky ReLU
leaky ReLU 激活函数定义为 LeakyReLUα = max(αz, z)(参见图 11-2)。超参数α定义了函数“泄漏”的程度:它是z < 0 时函数的斜率。对于z < 0,具有斜率的 leaky ReLU 永远不会死亡;它们可能会陷入长时间的昏迷,但最终有机会苏醒。Bing Xu 等人在 2015 年的一篇论文比较了几种 ReLU 激活函数的变体,其中一个结论是,泄漏变体总是优于严格的 ReLU 激活函数。事实上,设置α=0.2(一个巨大的泄漏)似乎比α=0.01(一个小泄漏)表现更好。该论文还评估了随机泄漏 ReLU(RReLU),其中α在训练期间在给定范围内随机选择,并在测试期间固定为平均值。RReLU 表现也相当不错,并似乎作为正则化器,减少了过拟合训练集的风险。最后,该论文评估了参数泄漏 ReLU(PReLU),其中α在训练期间被授权学习:它不再是一个超参数,而是一个可以像其他参数一样通过反向传播修改的参数。据报道,PReLU 在大型图像数据集上明显优于 ReLU,但在较小的数据集上存在过拟合训练集的风险。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1102.png
图 11-2. Leaky ReLU:类似于 ReLU,但对负值有一个小的斜率
Keras 在tf.keras.layers包中包含了LeakyReLU和PReLU类。就像其他 ReLU 变体一样,您应该使用 He 初始化。例如:
leaky_relu = tf.keras.layers.LeakyReLU(alpha=0.2) # defaults to alpha=0.3
dense = tf.keras.layers.Dense(50, activation=leaky_relu,
kernel_initializer="he_normal")
如果您愿意,您也可以在模型中将LeakyReLU作为一个单独的层来使用;对于训练和预测没有任何影响:
model = tf.keras.models.Sequential([
[...] # more layers
tf.keras.layers.Dense(50, kernel_initializer="he_normal"), # no activation
tf.keras.layers.LeakyReLU(alpha=0.2), # activation as a separate layer
[...] # more layers
])
对于 PReLU,将LeakyReLU替换为PReLU。目前在 Keras 中没有官方实现 RReLU,但您可以相当容易地实现自己的(要了解如何做到这一点,请参见第十二章末尾的练习)。
ReLU、leaky ReLU 和 PReLU 都存在一个问题,即它们不是平滑函数:它们的导数在z=0 处突然变化。正如我们在第四章中讨论 lasso 时看到的那样,这种不连续性会导致梯度下降在最优点周围反弹,并减慢收敛速度。因此,现在我们将看一些 ReLU 激活函数的平滑变体,从 ELU 和 SELU 开始。
ELU 和 SELU
2015 年,Djork-Arné Clevert 等人提出了一篇论文,提出了一种新的激活函数,称为指数线性单元(ELU),在作者的实验中表现优于所有 ReLU 变体:训练时间缩短,神经网络在测试集上表现更好。方程式 11-2 展示了这个激活函数的定义。
方程式 11-2. ELU 激活函数
ELU α ( z ) = α ( exp ( z ) - 1 ) if z < 0 z if z ≥ 0
ELU 激活函数看起来很像 ReLU 函数(参见图 11-3),但有一些主要区别:
-
当z < 0 时,它会取负值,这使得单元的平均输出更接近于 0,并有助于缓解梯度消失问题。超参数α定义了当z是一个较大的负数时 ELU 函数接近的值的相反数。通常设置为 1,但您可以像调整其他超参数一样进行调整。
-
在z < 0 时具有非零梯度,避免了死神经元问题。
-
如果α等于 1,则该函数在任何地方都是平滑的,包括在z = 0 附近,这有助于加快梯度下降的速度,因为它在z = 0 的左右两侧不会反弹太多。
在 Keras 中使用 ELU 就像设置activation="elu"一样简单,与其他 ReLU 变体一样,应该使用 He 初始化。ELU 激活函数的主要缺点是它的计算速度比 ReLU 函数及其变体慢(由于使用了指数函数)。在训练期间更快的收敛速度可能会弥补这种缓慢的计算,但是在测试时,ELU 网络将比 ReLU 网络慢一点。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1103.png
图 11-3. ELU 和 SELU 激活函数
不久之后,Günter Klambauer 等人在2017 年的一篇论文中介绍了缩放 ELU(SELU)激活函数:正如其名称所示,它是 ELU 激活函数的缩放变体(大约是 ELU 的 1.05 倍,使用α ≈ 1.67)。作者们表明,如果构建一个仅由一堆稠密层(即 MLP)组成的神经网络,并且所有隐藏层使用 SELU 激活函数,那么网络将自标准化:每一层的输出在训练过程中倾向于保持均值为 0,标准差为 1,从而解决了梯度消失/爆炸的问题。因此,SELU 激活函数可能在 MLP 中胜过其他激活函数,尤其是深层网络。要在 Keras 中使用它,只需设置activation="selu"。然而,自标准化发生的条件有一些(请参阅论文进行数学证明):
-
输入特征必须标准化:均值为 0,标准差为 1。
-
每个隐藏层的权重必须使用 LeCun 正态初始化。在 Keras 中,这意味着设置
kernel_initializer="lecun_normal"。 -
只有在普通 MLP 中才能保证自标准化属性。如果尝试在其他架构中使用 SELU,如循环网络(参见第十五章)或具有跳跃连接(即跳过层的连接,例如在 Wide & Deep 网络中),它可能不会胜过 ELU。
-
您不能使用正则化技术,如ℓ[1]或ℓ[2]正则化、最大范数、批量归一化或常规的 dropout(这些将在本章后面讨论)。
这些是重要的限制条件,因此尽管 SELU 有所承诺,但并没有获得很大的关注。此外,另外三种激活函数似乎在大多数任务上表现出色:GELU、Swish 和 Mish。
GELU、Swish 和 Mish
GELU是由 Dan Hendrycks 和 Kevin Gimpel 在2016 年的一篇论文中引入的。再次,您可以将其视为 ReLU 激活函数的平滑变体。其定义在方程 11-3 中给出,其中Φ是标准高斯累积分布函数(CDF):Φ(z)对应于从均值为 0、方差为 1 的正态分布中随机抽取的值低于z的概率。
方程 11-3. GELU 激活函数
GELU(z)=zΦ(z)
如您在图 11-4 中所见,GELU 类似于 ReLU:当其输入z非常负时,它接近 0,当z非常正时,它接近z。然而,到目前为止我们讨论的所有激活函数都是凸函数且单调递增的,而 GELU 激活函数则不是:从左到右,它开始直线上升,然后下降,达到大约-0.17 的低点(接近 z≈-0.75),最后反弹上升并最终向右上方直线前进。这种相当复杂的形状以及它在每个点上都有曲率的事实可能解释了为什么它效果如此好,尤其是对于复杂任务:梯度下降可能更容易拟合复杂模式。在实践中,它通常优于迄今讨论的任何其他激活函数。然而,它的计算成本稍高,提供的性能提升并不总是足以证明额外成本的必要性。尽管如此,可以证明它大致等于zσ(1.702 z),其中σ是 sigmoid 函数:使用这个近似也非常有效,并且计算速度更快。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1104.png
图 11-4. GELU、Swish、参数化 Swish 和 Mish 激活函数
GELU 论文还介绍了sigmoid linear unit(SiLU)激活函数,它等于zσ(z),但在作者的测试中被 GELU 表现得更好。有趣的是,Prajit Ramachandran 等人在2017 年的一篇论文中重新发现了 SiLU 函数,通过自动搜索好的激活函数。作者将其命名为Swish,这个名字很受欢迎。在他们的论文中,Swish 表现优于其他所有函数,包括 GELU。Ramachandran 等人后来通过添加额外的超参数β来推广 Swish,用于缩放 sigmoid 函数的输入。推广后的 Swish 函数为 Swishβ = zσ(βz),因此 GELU 大致等于使用β = 1.702 的推广 Swish 函数。您可以像调整其他超参数一样调整β。另外,也可以将β设置为可训练的,让梯度下降来优化它:这样可以使您的模型更加强大,但也会有过拟合数据的风险。
另一个相当相似的激活函数是Mish,它是由 Diganta Misra 在2019 年的一篇论文中引入的。它被定义为 mish(z) = ztanh(softplus(z)),其中 softplus(z) = log(1 + exp(z))。就像 GELU 和 Swish 一样,它是 ReLU 的平滑、非凸、非单调变体,作者再次进行了许多实验,并发现 Mish 通常优于其他激活函数,甚至比 Swish 和 GELU 稍微好一点。图 11-4 展示了 GELU、Swish(默认β = 1 和β = 0.6)、最后是 Mish。如您所见,当z为负时,Mish 几乎完全重叠于 Swish,当z为正时,几乎完全重叠于 GELU。
提示
那么,对于深度神经网络的隐藏层,你应该使用哪种激活函数?对于简单任务,ReLU 仍然是一个很好的默认选择:它通常和更复杂的激活函数一样好,而且计算速度非常快,许多库和硬件加速器提供了 ReLU 特定的优化。然而,对于更复杂的任务,Swish 可能是更好的默认选择,甚至可以尝试带有可学习β参数的参数化 Swish 来处理最复杂的任务。Mish 可能会给出稍微更好的结果,但需要更多的计算。如果你非常关心运行时延迟,那么你可能更喜欢 leaky ReLU,或者对于更复杂的任务,可以使用参数化 leaky ReLU。对于深度 MLP,可以尝试使用 SELU,但一定要遵守之前列出的约束条件。如果你有多余的时间和计算能力,也可以使用交叉验证来评估其他激活函数。
Keras 支持 GELU 和 Swish,只需使用activation="gelu"或activation="swish"。然而,它目前不支持 Mish 或广义 Swish 激活函数(但请参阅第十二章了解如何实现自己的激活函数和层)。
激活函数就介绍到这里!现在,让我们看一种完全不同的解决不稳定梯度问题的方法:批量归一化。
批量归一化
尽管使用 He 初始化与 ReLU(或其任何变体)可以显著减少训练开始时梯度消失/爆炸问题的危险,但并不能保证它们在训练过程中不会再次出现。
在一篇2015 年的论文中,Sergey Ioffe 和 Christian Szegedy 提出了一种称为批量归一化(BN)的技术,解决了这些问题。该技术包括在模型中在每个隐藏层的激活函数之前或之后添加一个操作。这个操作简单地将每个输入零中心化和归一化,然后使用每层两个新的参数向量进行缩放和移位:一个用于缩放,另一个用于移位。换句话说,该操作让模型学习每个层输入的最佳缩放和均值。在许多情况下,如果将 BN 层作为神经网络的第一层,您就不需要标准化训练集。也就是说,不需要StandardScaler或Normalization;BN 层会为您完成(大致上,因为它一次只看一个批次,并且还可以重新缩放和移位每个输入特征)。
为了将输入零中心化和归一化,算法需要估计每个输入的均值和标准差。它通过评估当前小批量输入的均值和标准差来实现这一点(因此称为“批量归一化”)。整个操作在方程式 11-4 中逐步总结。
方程式 11-4. 批量归一化算法
1 . μ B = 1 m B ∑ i=1 m B x (i) 2 . σ B 2 = 1 m B ∑ i=1 m B (x (i) -μ B ) 2 3 . x ^ (i) = x (i) -μ B σ B 2 +ε 4 . z (i) = γ ⊗ x ^ (i) + β
在这个算法中:
-
μ[B] 是在整个小批量B上评估的输入均值向量(它包含每个输入的一个均值)。
-
m[B] 是小批量中实例的数量。
-
σ[B] 是输入标准差的向量,也是在整个小批量上评估的(它包含每个输入的一个标准差)。
-
x ^ ^((i)) 是实例i的零中心化和归一化输入向量。
-
ε 是一个微小的数字,避免了除以零,并确保梯度不会增长太大(通常为 10^(–5))。这被称为平滑项。
-
γ 是该层的输出比例参数向量(它包含每个输入的一个比例参数)。
-
⊗ 表示逐元素乘法(每个输入都会乘以其对应的输出比例参数)。
-
β 是该层的输出偏移参数向量(它包含每个输入的一个偏移参数)。每个输入都会被其对应的偏移参数偏移。
-
z^((i)) 是 BN 操作的输出。它是输入的重新缩放和偏移版本。
因此,在训练期间,BN 会标准化其输入,然后重新缩放和偏移它们。很好!那么,在测试时呢?嗯,事情并不那么简单。实际上,我们可能需要为单个实例而不是一批实例进行预测:在这种情况下,我们将无法计算每个输入的均值和标准差。此外,即使我们有一批实例,它可能太小,或者实例可能不是独立且同分布的,因此在批次实例上计算统计数据将是不可靠的。一个解决方案可能是等到训练结束,然后通过神经网络运行整个训练集,并计算 BN 层每个输入的均值和标准差。这些“最终”输入均值和标准差可以在进行预测时代替批次输入均值和标准差。然而,大多数批次归一化的实现在训练期间通过使用该层输入均值和标准差的指数移动平均值来估计这些最终统计数据。这就是当您使用BatchNormalization层时 Keras 自动执行的操作。总之,在每个批次归一化的层中学习了四个参数向量:γ(输出缩放向量)和β(输出偏移向量)通过常规反向传播学习,而μ(最终输入均值向量)和σ(最终输入标准差向量)则使用指数移动平均值进行估计。请注意,μ和σ是在训练期间估计的,但仅在训练后使用(以替换公式 11-4 中的批次输入均值和标准差)。
Ioffe 和 Szegedy 证明了批次归一化显著改善了他们进行实验的所有深度神经网络,从而在 ImageNet 分类任务中取得了巨大的改进(ImageNet 是一个大型图像数据库,被分类为许多类别,通常用于评估计算机视觉系统)。梯度消失问题得到了很大程度的减轻,以至于他们可以使用饱和激活函数,如 tanh 甚至 sigmoid 激活函数。网络对权重初始化也不那么敏感。作者能够使用更大的学习率,显著加快学习过程。具体来说,他们指出:
应用于最先进的图像分类模型,批次归一化在 14 倍更少的训练步骤下实现了相同的准确性,并且以显著的优势击败了原始模型。[…] 使用一组批次归一化的网络,我们在 ImageNet 分类上取得了最佳发布结果:达到 4.9%的前 5 验证错误率(和 4.8%的测试错误率),超过了人类评分者的准确性。
最后,就像一份源源不断的礼物,批次归一化就像一个正则化器,减少了对其他正则化技术(如本章后面描述的 dropout)的需求。
然而,批量归一化确实给模型增加了一些复杂性(尽管它可以消除对输入数据进行归一化的需要,如前面讨论的)。此外,还存在运行时惩罚:由于每一层需要额外的计算,神经网络的预测速度变慢。幸运的是,通常可以在训练后将 BN 层与前一层融合在一起,从而避免运行时惩罚。这是通过更新前一层的权重和偏置,使其直接产生适当规模和偏移的输出来实现的。例如,如果前一层计算XW + b,那么 BN 层将计算γ ⊗ (XW + b - μ) / σ + β(忽略分母中的平滑项ε)。如果我们定义W′ = γ⊗W / σ和b′ = γ ⊗ (b - μ) / σ + β,则方程简化为XW′ + b′。因此,如果我们用更新后的权重和偏置(W′和b′)替换前一层的权重和偏置(W和b),我们可以摆脱 BN 层(TFLite 的转换器会自动执行此操作;请参阅第十九章)。
注意
您可能会发现训练速度相当慢,因为使用批量归一化时,每个时期需要更多的时间。通常,这通常会被 BN 的收敛速度更快所抵消,因此需要更少的时期才能达到相同的性能。总的来说,墙上的时间通常会更短(这是您墙上时钟上测量的时间)。
使用 Keras 实现批量归一化
与 Keras 的大多数事物一样,实现批量归一化是简单直观的。只需在每个隐藏层的激活函数之前或之后添加一个BatchNormalization层。您还可以将 BN 层添加为模型中的第一层,但通常在此位置使用普通的Normalization层效果一样好(它的唯一缺点是您必须首先调用其adapt()方法)。例如,这个模型在每个隐藏层后应用 BN,并将其作为模型中的第一层(在展平输入图像之后):
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dense(300, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dense(100, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dense(10, activation="softmax")
])
就这样!在这个只有两个隐藏层的微小示例中,批量归一化不太可能产生很大的影响,但对于更深的网络,它可能产生巨大的差异。
让我们显示模型摘要:
>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
flatten (Flatten) (None, 784) 0
_________________________________________________________________
batch_normalization (BatchNo (None, 784) 3136
_________________________________________________________________
dense (Dense) (None, 300) 235500
_________________________________________________________________
batch_normalization_1 (Batch (None, 300) 1200
_________________________________________________________________
dense_1 (Dense) (None, 100) 30100
_________________________________________________________________
batch_normalization_2 (Batch (None, 100) 400
_________________________________________________________________
dense_2 (Dense) (None, 10) 1010
=================================================================
Total params: 271,346
Trainable params: 268,978
Non-trainable params: 2,368
_________________________________________________________________
正如您所看到的,每个 BN 层都会为每个输入添加四个参数:γ、β、μ和σ(例如,第一个 BN 层会添加 3,136 个参数,即 4×784)。最后两个参数,μ和σ,是移动平均值;它们不受反向传播的影响,因此 Keras 将它们称为“不可训练”¹³(如果您计算 BN 参数的总数,3,136 + 1,200 + 400,然后除以 2,您将得到 2,368,这是该模型中不可训练参数的总数)。
让我们看看第一个 BN 层的参数。其中两个是可训练的(通过反向传播),另外两个不是:
>>> [(var.name, var.trainable) for var in model.layers[1].variables]
[('batch_normalization/gamma:0', True),
('batch_normalization/beta:0', True),
('batch_normalization/moving_mean:0', False),
('batch_normalization/moving_variance:0', False)]
BN 论文的作者主张在激活函数之前而不是之后添加 BN 层(就像我们刚刚做的那样)。关于这一点存在一些争论,因为哪种方式更可取似乎取决于任务-您也可以尝试这个来看看哪个选项在您的数据集上效果最好。要在激活函数之前添加 BN 层,您必须从隐藏层中删除激活函数,并在 BN 层之后作为单独的层添加它们。此外,由于批量归一化层包含每个输入的一个偏移参数,您可以在创建时通过传递use_bias=False来删除前一层的偏置项。最后,通常可以删除第一个 BN 层,以避免将第一个隐藏层夹在两个 BN 层之间。更新后的代码如下:
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
tf.keras.layers.Dense(300, kernel_initializer="he_normal", use_bias=False),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Activation("relu"),
tf.keras.layers.Dense(100, kernel_initializer="he_normal", use_bias=False),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Activation("relu"),
tf.keras.layers.Dense(10, activation="softmax")
])
BatchNormalization类有很多可以调整的超参数。默认值通常是可以的,但偶尔您可能需要调整momentum。当BatchNormalization层更新指数移动平均值时,该超参数将被使用;给定一个新值v(即,在当前批次上计算的新的输入均值或标准差向量),该层使用以下方程更新运行平均值v^:
v ^ ← v ^ × momentum + v × ( 1 - momentum )
一个良好的动量值通常接近于 1;例如,0.9,0.99 或 0.999。对于更大的数据集和更小的小批量,您希望有更多的 9。
另一个重要的超参数是axis:它确定应该对哪个轴进行归一化。默认为-1,这意味着默认情况下将归一化最后一个轴(使用在其他轴上计算的均值和标准差)。当输入批次为 2D(即,批次形状为[批次大小,特征])时,这意味着每个输入特征将基于在批次中所有实例上计算的均值和标准差进行归一化。例如,前面代码示例中的第一个 BN 层将独立地归一化(和重新缩放和移位)784 个输入特征中的每一个。如果我们将第一个 BN 层移到Flatten层之前,那么输入批次将是 3D,形状为[批次大小,高度,宽度];因此,BN 层将计算 28 个均值和 28 个标准差(每个像素列一个,跨批次中的所有实例和列中的所有行计算),并且将使用相同的均值和标准差归一化给定列中的所有像素。还将有 28 个比例参数和 28 个移位参数。如果您仍希望独立处理 784 个像素中的每一个,则应将axis=[1, 2]。
批量归一化已经成为深度神经网络中最常用的层之一,特别是在深度卷积神经网络中讨论的(第十四章),以至于在架构图中通常被省略:假定在每一层之后都添加了 BN。现在让我们看看最后一种稳定梯度的技术:梯度裁剪。
梯度裁剪
另一种缓解梯度爆炸问题的技术是在反向传播过程中裁剪梯度,使其永远不超过某个阈值。这被称为梯度裁剪。¹⁴ 这种技术通常用于循环神经网络中,其中使用批量归一化是棘手的(正如您将在第十五章中看到的)。
在 Keras 中,实现梯度裁剪只需要在创建优化器时设置clipvalue或clipnorm参数,就像这样:
optimizer = tf.keras.optimizers.SGD(clipvalue=1.0)
model.compile([...], optimizer=optimizer)
这个优化器将梯度向量的每个分量剪切到-1.0 和 1.0 之间的值。这意味着损失的所有偏导数(对每个可训练参数)将在-1.0 和 1.0 之间被剪切。阈值是您可以调整的超参数。请注意,这可能会改变梯度向量的方向。例如,如果原始梯度向量是[0.9, 100.0],它主要指向第二轴的方向;但是一旦您按值剪切它,您会得到[0.9, 1.0],它大致指向两个轴之间的对角线。在实践中,这种方法效果很好。如果您希望确保梯度剪切不改变梯度向量的方向,您应该通过设置clipnorm而不是clipvalue来按范数剪切。如果其ℓ[2]范数大于您选择的阈值,则会剪切整个梯度。例如,如果设置clipnorm=1.0,那么向量[0.9, 100.0]将被剪切为[0.00899964, 0.9999595],保持其方向但几乎消除第一个分量。如果您观察到梯度在训练过程中爆炸(您可以使用 TensorBoard 跟踪梯度的大小),您可能希望尝试按值剪切或按范数剪切,使用不同的阈值,看看哪个选项在验证集上表现最好。
重用预训练层
通常不建议从头开始训练一个非常大的 DNN,而不是先尝试找到一个现有的神经网络,完成与您尝试解决的任务类似的任务(我将在第十四章中讨论如何找到它们)。如果找到这样的神经网络,那么通常可以重用大部分层,除了顶部的层。这种技术称为迁移学习。它不仅会显著加快训练速度,而且需要的训练数据明显较少。
假设您可以访问一个经过训练的 DNN,用于将图片分类为 100 个不同的类别,包括动物、植物、车辆和日常物品,现在您想要训练一个 DNN 来分类特定类型的车辆。这些任务非常相似,甚至部分重叠,因此您应该尝试重用第一个网络的部分(参见图 11-5)。
注意
如果您新任务的输入图片与原始任务中使用的图片大小不同,通常需要添加一个预处理步骤,将它们调整为原始模型期望的大小。更一般地说,当输入具有相似的低级特征时,迁移学习效果最好。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1105.png
图 11-5。重用预训练层
通常应该替换原始模型的输出层,因为它很可能对新任务没有用处,而且可能不会有正确数量的输出。
同样,原始模型的上层隐藏层不太可能像下层那样有用,因为对于新任务最有用的高级特征可能与对原始任务最有用的特征有很大不同。您需要找到要重用的正确层数。
提示
任务越相似,您将希望重用的层次就越多(从较低层次开始)。对于非常相似的任务,尝试保留所有隐藏层,只替换输出层。
首先尝试冻结所有重用的层(即使它们的权重不可训练,以便梯度下降不会修改它们并保持固定),然后训练您的模型并查看其表现。然后尝试解冻顶部一两个隐藏层,让反向传播调整它们,看看性能是否提高。您拥有的训练数据越多,您可以解冻的层次就越多。解冻重用层时降低学习率也很有用:这将避免破坏它们微调的权重。
如果您仍然无法获得良好的性能,并且训练数据很少,尝试删除顶部隐藏层并再次冻结所有剩余的隐藏层。您可以迭代直到找到要重用的正确层数。如果您有大量训练数据,您可以尝试替换顶部隐藏层而不是删除它们,甚至添加更多隐藏层。
使用 Keras 进行迁移学习
让我们看一个例子。假设时尚 MNIST 数据集仅包含八个类别,例如除凉鞋和衬衫之外的所有类别。有人在该数据集上构建并训练了一个 Keras 模型,并获得了相当不错的性能(>90%的准确率)。我们将这个模型称为 A。现在您想要解决一个不同的任务:您有 T 恤和套头衫的图像,并且想要训练一个二元分类器:对于 T 恤(和上衣)为正,对于凉鞋为负。您的数据集非常小;您只有 200 张带标签的图像。当您为这个任务训练一个新模型(我们称之为模型 B),其架构与模型 A 相同时,您获得了 91.85%的测试准确率。在喝早晨咖啡时,您意识到您的任务与任务 A 非常相似,因此也许迁移学习可以帮助?让我们找出来!
首先,您需要加载模型 A 并基于该模型的层创建一个新模型。您决定重用除输出层以外的所有层:
[...] # Assuming model A was already trained and saved to "my_model_A"
model_A = tf.keras.models.load_model("my_model_A")
model_B_on_A = tf.keras.Sequential(model_A.layers[:-1])
model_B_on_A.add(tf.keras.layers.Dense(1, activation="sigmoid"))
请注意,model_A和model_B_on_A现在共享一些层。当您训练model_B_on_A时,它也会影响model_A。如果您想避免这种情况,您需要在重用其层之前克隆model_A。为此,您可以使用clone_model()克隆模型 A 的架构,然后复制其权重:
model_A_clone = tf.keras.models.clone_model(model_A)
model_A_clone.set_weights(model_A.get_weights())
警告
tf.keras.models.clone_model()仅克隆架构,而不是权重。如果您不使用set_weights()手动复制它们,那么当首次使用克隆模型时,它们将被随机初始化。
现在您可以为任务 B 训练model_B_on_A,但由于新的输出层是随机初始化的,它将产生大误差(至少在最初的几个时期),因此会产生大误差梯度,可能会破坏重用的权重。为了避免这种情况,一种方法是在最初的几个时期内冻结重用的层,让新层有时间学习合理的权重。为此,将每个层的trainable属性设置为False并编译模型:
for layer in model_B_on_A.layers[:-1]:
layer.trainable = False
optimizer = tf.keras.optimizers.SGD(learning_rate=0.001)
model_B_on_A.compile(loss="binary_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
注意
在冻结或解冻层之后,您必须始终编译您的模型。
现在您可以为模型训练几个时期,然后解冻重用的层(这需要重新编译模型)并继续训练以微调任务 B 的重用层。在解冻重用的层之后,通常最好降低学习率,再次避免损坏重用的权重。
history = model_B_on_A.fit(X_train_B, y_train_B, epochs=4,
validation_data=(X_valid_B, y_valid_B))
for layer in model_B_on_A.layers[:-1]:
layer.trainable = True
optimizer = tf.keras.optimizers.SGD(learning_rate=0.001)
model_B_on_A.compile(loss="binary_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
history = model_B_on_A.fit(X_train_B, y_train_B, epochs=16,
validation_data=(X_valid_B, y_valid_B))
那么,最终的结论是什么?好吧,这个模型的测试准确率为 93.85%,比 91.85%高出两个百分点!这意味着迁移学习将错误率减少了近 25%:
>>> model_B_on_A.evaluate(X_test_B, y_test_B)
[0.2546142041683197, 0.9384999871253967]
您相信了吗?您不应该相信:我作弊了!我尝试了许多配置,直到找到一个表现出强烈改进的配置。如果您尝试更改类别或随机种子,您会发现改进通常会下降,甚至消失或反转。我所做的被称为“折磨数据直到它招认”。当一篇论文看起来过于积极时,您应该持怀疑态度:也许这种花哨的新技术实际上并没有太大帮助(事实上,它甚至可能降低性能),但作者尝试了许多变体并仅报告了最佳结果(这可能仅仅是纯粹的运气),而没有提及他们在过程中遇到了多少失败。大多数情况下,这并不是恶意的,但这是科学中许多结果永远无法重现的原因之一。
为什么我作弊了?事实证明,迁移学习在小型密集网络上效果不佳,可能是因为小型网络学习的模式较少,而密集网络学习的是非常具体的模式,这些模式不太可能在其他任务中有用。迁移学习最适用于深度卷积神经网络,这些网络倾向于学习更通用的特征检测器(特别是在较低层)。我们将在第十四章中重新讨论迁移学习,使用我们刚讨论的技术(这次不会作弊,我保证!)。
无监督预训练
假设您想要解决一个复杂的任务,但您没有太多标记的训练数据,而不幸的是,您找不到一个类似任务训练的模型。不要失去希望!首先,您应该尝试收集更多标记的训练数据,但如果您无法做到,您仍然可以执行无监督预训练(见图 11-6)。事实上,收集未标记的训练示例通常很便宜,但标记它们却很昂贵。如果您可以收集大量未标记的训练数据,您可以尝试使用它们来训练一个无监督模型,例如自编码器或生成对抗网络(GAN;见第十七章)。然后,您可以重复使用自编码器的较低层或 GAN 的鉴别器的较低层,添加顶部的输出层,然后使用监督学习(即使用标记的训练示例)微调最终网络。
正是这种技术在 2006 年由 Geoffrey Hinton 及其团队使用,导致了神经网络的复兴和深度学习的成功。直到 2010 年,无监督预训练(通常使用受限玻尔兹曼机(RBMs;请参阅https://homl.info/extra-anns中的笔记本))是深度网络的标准,只有在消失梯度问题得到缓解后,纯粹使用监督学习训练 DNN 才变得更加普遍。无监督预训练(今天通常使用自编码器或 GAN,而不是 RBMs)仍然是一个很好的选择,当您有一个复杂的任务需要解决,没有类似的可重用模型,但有大量未标记的训练数据时。
请注意,在深度学习的早期阶段,训练深度模型是困难的,因此人们会使用一种称为贪婪逐层预训练的技术(在图 11-6 中描述)。他们首先使用单层训练一个无监督模型,通常是一个 RBM,然后冻结该层并在其顶部添加另一层,然后再次训练模型(实际上只是训练新层),然后冻结新层并在其顶部添加另一层,再次训练模型,依此类推。如今,事情简单得多:人们通常一次性训练完整的无监督模型,并使用自编码器或 GAN,而不是 RBMs。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1106.png
图 11-6。在无监督训练中,模型使用无监督学习技术在所有数据上进行训练,包括未标记的数据,然后使用监督学习技术仅在标记的数据上对最终任务进行微调;无监督部分可以像这里所示一次训练一层,也可以直接训练整个模型
辅助任务上的预训练
如果您没有太多标记的训练数据,最后一个选择是在一个辅助任务上训练第一个神经网络,您可以轻松获取或生成标记的训练数据,然后重复使用该网络的较低层来执行实际任务。第一个神经网络的较低层将学习特征检测器,很可能可以被第二个神经网络重复使用。
例如,如果您想构建一个识别人脸的系统,您可能只有每个个体的少量图片,显然不足以训练一个良好的分类器。收集每个人数百张照片是不现实的。但是,您可以在网络上收集大量随机人的照片,并训练第一个神经网络来检测两张不同图片是否展示了同一个人。这样的网络将学习良好的人脸特征检测器,因此重用其较低层将允许您训练一个使用很少训练数据的良好人脸分类器。
对于自然语言处理(NLP)应用,您可以下载数百万个文本文档的语料库,并从中自动生成标记数据。例如,您可以随机屏蔽一些单词并训练模型来预测缺失的单词是什么(例如,它应该预测句子“What ___ you saying?”中缺失的单词可能是“are”或“were”)。如果您可以训练模型在这个任务上达到良好的性能,那么它将已经对语言有相当多的了解,您肯定可以在实际任务中重复使用它,并在标记数据上进行微调(我们将在第十五章中讨论更多的预训练任务)。
注意
自监督学习是指从数据本身自动生成标签,例如文本屏蔽示例,然后使用监督学习技术在生成的“标记”数据集上训练模型。
更快的优化器
训练一个非常庞大的深度神经网络可能会非常缓慢。到目前为止,我们已经看到了四种加速训练(并达到更好解决方案)的方法:应用良好的连接权重初始化策略,使用良好的激活函数,使用批量归一化,并重用预训练网络的部分(可能是为辅助任务构建的或使用无监督学习)。另一个巨大的加速来自使用比常规梯度下降优化器更快的优化器。在本节中,我们将介绍最流行的优化算法:动量、Nesterov 加速梯度、AdaGrad、RMSProp,最后是 Adam 及其变体。
动量
想象一颗保龄球在光滑表面上缓坡滚动:它会从慢慢开始,但很快会积累动量,直到最终达到终端速度(如果有一些摩擦或空气阻力)。这就是动量优化的核心思想,由鲍里斯·波利亚克在 1964 年提出。与此相反,常规梯度下降在坡度平缓时会采取小步骤,在坡度陡峭时会采取大步骤,但它永远不会加速。因此,与动量优化相比,常规梯度下降通常要慢得多才能达到最小值。
请记住,梯度下降通过直接减去成本函数J(θ)相对于权重的梯度(∇[θ]J(θ))乘以学习率η来更新权重θ。方程式为θ ← θ - η∇[θ]J(θ)。它不关心先前的梯度是什么。如果局部梯度很小,它会走得很慢。
动量优化非常关注先前梯度是什么:在每次迭代中,它从动量向量 m(乘以学习率η)中减去局部梯度,然后通过添加这个动量向量来更新权重(参见方程 11-5)。换句话说,梯度被用作加速度,而不是速度。为了模拟某种摩擦机制并防止动量增长过大,该算法引入了一个新的超参数β,称为动量,必须设置在 0(高摩擦)和 1(无摩擦)之间。典型的动量值为 0.9。
方程 11-5. 动量算法
1 . m ← β m - η ∇ θ J ( θ ) 2 . θ ← θ + m
您可以验证,如果梯度保持不变,则终端速度(即权重更新的最大大小)等于该梯度乘以学习率η乘以 1 / (1 - β)(忽略符号)。例如,如果β = 0.9,则终端速度等于梯度乘以学习率的 10 倍,因此动量优化的速度比梯度下降快 10 倍!这使得动量优化比梯度下降更快地摆脱高原。我们在第四章中看到,当输入具有非常不同的比例时,成本函数看起来像一个拉长的碗(参见图 4-7)。梯度下降很快下降陡峭的斜坡,但然后需要很长时间才能下降到山谷。相比之下,动量优化将会越来越快地滚动到山谷,直到达到底部(最优解)。在不使用批量归一化的深度神经网络中,上层通常会出现具有非常不同比例的输入,因此使用动量优化会有很大帮助。它还可以帮助跳过局部最优解。
注意
由于动量的原因,优化器可能会稍微超调,然后返回,再次超调,并在稳定在最小值之前多次振荡。这是有摩擦力的好处之一:它消除了这些振荡,从而加快了收敛速度。
在 Keras 中实现动量优化是一件轻而易举的事情:只需使用SGD优化器并设置其momentum超参数,然后躺下来赚钱!
optimizer = tf.keras.optimizers.SGD(learning_rate=0.001, momentum=0.9)
动量优化的一个缺点是它增加了另一个需要调整的超参数。然而,在实践中,动量值 0.9 通常效果很好,几乎总是比常规梯度下降更快。
Nesterov 加速梯度
动量优化的一个小变体,由Yurii Nesterov 于 1983 年提出,¹⁶几乎总是比常规动量优化更快。Nesterov 加速梯度(NAG)方法,也称为Nesterov 动量优化,测量成本函数的梯度不是在本地位置θ处,而是稍微向前在动量方向,即θ + βm(参见方程 11-6)。
第 11-6 方程。Nesterov 加速梯度算法
1 . m ← β m - η ∇ θ J ( θ + β m ) 2 . θ ← θ + m
这个小调整有效是因为通常动量向量将指向正确的方向(即朝向最优解),因此使用稍微更准确的梯度测量更有利于使用稍微更远处的梯度,而不是原始位置处的梯度,如您在图 11-7 中所见(其中∇[1]表示在起始点θ处测量的成本函数的梯度,而∇[2]表示在位于θ + βm的点处测量的梯度)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1107.png
图 11-7。常规与 Nesterov 动量优化:前者应用动量步骤之前计算的梯度,而后者应用动量步骤之后计算的梯度
如您所见,Nesterov 更新最终更接近最优解。随着时间的推移,这些小的改进累积起来,NAG 最终比常规动量优化快得多。此外,请注意,当动量将权重推过山谷时,∇[1]继续推动更远,而∇[2]则向山谷底部推回。这有助于减少振荡,因此 NAG 收敛更快。
要使用 NAG,只需在创建SGD优化器时设置nesterov=True:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.001, momentum=0.9,
nesterov=True)
AdaGrad
考虑再次延长碗问题:梯度下降首先快速沿着最陡的斜坡下降,这并不直指全局最优解,然后它非常缓慢地下降到山谷底部。如果算法能够更早地纠正方向,使其更多地指向全局最优解,那将是很好的。AdaGrad算法通过沿着最陡的维度缩小梯度向量来实现这种校正(参见方程 11-7)。
方程 11-7。AdaGrad 算法
1 . s ← s + ∇ θ J ( θ ) ⊗ ∇ θ J ( θ ) 2 . θ ← θ - η ∇ θ J ( θ ) ⊘ s + ε
第一步将梯度的平方累积到向量s中(请记住,⊗符号表示逐元素乘法)。这种向量化形式等同于计算s[i] ← s[i] + (∂J(θ)/∂θ[i])²,对于向量s的每个元素s[i]来说,换句话说,每个s[i]累积了成本函数对参数θ[i]的偏导数的平方。如果成本函数沿第i维陡峭,那么在每次迭代中s[i]将变得越来越大。
第二步几乎与梯度下降完全相同,但有一个重大区别:梯度向量被一个因子s+ε缩小(⊘符号表示逐元素除法,ε是一个平滑项,用于避免除以零,通常设置为 10^(–10))。这个向量化形式等价于同时计算所有参数θ[i]的θi←θi-η∂J(θ)/∂θi/si+ε。
简而言之,这个算法会衰减学习率,但对于陡峭的维度比对于坡度较缓的维度衰减得更快。这被称为自适应学习率。它有助于更直接地指向全局最优(参见图 11-8)。另一个好处是它需要更少的调整学习率超参数η。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1108.png
图 11-8. AdaGrad 与梯度下降的比较:前者可以更早地纠正方向指向最优点
在简单的二次问题上,AdaGrad 通常表现良好,但在训练神经网络时经常会过早停止:学习率被缩小得太多,以至于算法最终在达到全局最优之前完全停止。因此,即使 Keras 有一个Adagrad优化器,你也不应该用它来训练深度神经网络(尽管对于简单任务如线性回归可能是有效的)。不过,理解 AdaGrad 有助于理解其他自适应学习率优化器。
RMSProp
正如我们所见,AdaGrad 有减速得太快并且永远无法收敛到全局最优的风险。RMSProp算法¹⁸通过仅累积最近迭代的梯度来修复这个问题,而不是自训练开始以来的所有梯度。它通过在第一步中使用指数衰减来实现这一点(参见方程 11-8)。
方程 11-8. RMSProp 算法
1 . s ← ρ s + ( 1 - ρ ) ∇ θ J ( θ ) ⊗ ∇ θ J ( θ ) 2 . θ ← θ - η ∇ θ J ( θ ) ⊘ s + ε
衰减率ρ通常设置为 0.9。¹⁹ 是的,这又是一个新的超参数,但这个默认值通常效果很好,所以你可能根本不需要调整它。
正如你所期望的,Keras 有一个RMSprop优化器:
optimizer = tf.keras.optimizers.RMSprop(learning_rate=0.001, rho=0.9)
除了在非常简单的问题上,这个优化器几乎总是比 AdaGrad 表现得更好。事实上,直到 Adam 优化算法出现之前,它一直是许多研究人员首选的优化算法。
亚当
Adam,代表自适应矩估计,结合了动量优化和 RMSProp 的思想:就像动量优化一样,它跟踪过去梯度的指数衰减平均值;就像 RMSProp 一样,它跟踪过去梯度的平方的指数衰减平均值(见 Equation 11-9)。这些是梯度的均值和(未居中)方差的估计。均值通常称为第一时刻,而方差通常称为第二时刻,因此算法的名称。
方程 11-9. Adam 算法
1 . m ← β 1 m - ( 1 - β 1 ) ∇ θ J ( θ ) 2 . s ← β 2 s + ( 1 - β 2 ) ∇ θ J ( θ ) ⊗ ∇ θ J ( θ ) 3 . m^ ← m 1 - β 1 t 4 . s^ ← s 1-β 2 t 5 . θ ← θ + η m^ ⊘ s^ + ε
在这个方程中,t代表迭代次数(从 1 开始)。
如果只看步骤 1、2 和 5,你会注意到 Adam 与动量优化和 RMSProp 的相似之处:β[1]对应于动量优化中的β,β[2]对应于 RMSProp 中的ρ。唯一的区别是步骤 1 计算的是指数衰减平均值而不是指数衰减和,但实际上这些是等价的,除了一个常数因子(衰减平均值只是衰减和的 1 - β[1]倍)。步骤 3 和 4 有点技术细节:由于m和s初始化为 0,在训练开始时它们会偏向于 0,因此这两个步骤将有助于在训练开始时提升m和s。
动量衰减超参数β[1]通常初始化为 0.9,而缩放衰减超参数β[2]通常初始化为 0.999。与之前一样,平滑项ε通常初始化为一个非常小的数字,如 10^(–7)。这些是Adam类的默认值。以下是如何在 Keras 中创建 Adam 优化器的方法:
optimizer = tf.keras.optimizers.Adam(learning_rate=0.001, beta_1=0.9,
beta_2=0.999)
由于 Adam 是一种自适应学习率算法,类似于 AdaGrad 和 RMSProp,它需要较少调整学习率超参数η。您通常可以使用默认值η=0.001,使得 Adam 比梯度下降更容易使用。
提示
如果您开始感到对所有这些不同技术感到不知所措,并想知道如何为您的任务选择合适的技术,不用担心:本章末尾提供了一些实用指南。
最后,值得一提的是 Adam 的三个变体:AdaMax、Nadam 和 AdamW。
AdaMax
Adam 论文还介绍了 AdaMax。请注意,在方程式 11-9 的第 2 步中,Adam 在s中累积梯度的平方(对于最近的梯度有更大的权重)。在第 5 步中,如果我们忽略ε和步骤 3 和 4(这些都是技术细节),Adam 通过s的平方根缩小参数更新。简而言之,Adam 通过时间衰减梯度的ℓ[2]范数缩小参数更新(回想一下,ℓ[2]范数是平方和的平方根)。
AdaMax 用ℓ[∞]范数(一种说法是最大值)替换了ℓ[2]范数。具体来说,它用s←max(β2s, abs(∇θJ(θ)))替换了方程式 11-9 的第 2 步,删除了第 4 步,在第 5 步中,它通过s的因子缩小梯度更新,s是时间衰减梯度的绝对值的最大值。
实际上,这使得 AdaMax 比 Adam 更稳定,但这确实取决于数据集,总体上 Adam 表现更好。因此,如果您在某些任务上遇到 Adam 的问题,这只是另一个您可以尝试的优化器。
Nadam
Nadam 优化是 Adam 优化加上 Nesterov 技巧,因此它通常会比 Adam 收敛速度稍快。在介绍这种技术的研究报告中,研究员 Timothy Dozat 比较了许多不同的优化器在各种任务上的表现,发现 Nadam 通常优于 Adam,但有时会被 RMSProp 超越。
AdamW
AdamW是 Adam 的一个变体,它集成了一种称为权重衰减的正则化技术。权重衰减通过将模型的权重在每次训练迭代中乘以一个衰减因子,如 0.99,来减小权重的大小。这可能让您想起ℓ[2]正则化(在第四章介绍),它也旨在保持权重较小,事实上,可以在数学上证明,当使用 SGD 时,ℓ[2]正则化等效于权重衰减。然而,当使用 Adam 或其变体时,ℓ[2]正则化和权重衰减不等效:实际上,将 Adam 与ℓ[2]正则化结合使用会导致模型通常不如 SGD 产生的模型泛化能力好。AdamW 通过正确地将 Adam 与权重衰减结合来解决这个问题。
警告
自适应优化方法(包括 RMSProp、Adam、AdaMax、Nadam 和 AdamW 优化)通常很好,快速收敛到一个好的解决方案。然而,阿希亚·C·威尔逊等人在一篇2017 年的论文中表明,它们可能导致在某些数据集上泛化能力较差的解决方案。因此,当您对模型的性能感到失望时,请尝试使用 NAG:您的数据集可能只是对自适应梯度过敏。还要关注最新的研究,因为它发展迅速。
要在 Keras 中使用 Nadam、AdaMax 或 AdamW,请将tf.keras.optimizers.Adam替换为tf.keras.optimizers.Nadam、tf.keras.optimizers.Adamax或tf.keras.optimizers.experimental.AdamW。对于 AdamW,您可能需要调整weight_decay超参数。
到目前为止讨论的所有优化技术只依赖于一阶偏导数(雅可比)。优化文献中还包含基于二阶偏导数(海森,即雅可比的偏导数)的惊人算法。不幸的是,这些算法很难应用于深度神经网络,因为每个输出有n²个海森(其中n是参数的数量),而不是每个输出只有n个雅可比。由于 DNN 通常具有成千上万个参数甚至更多,第二阶优化算法通常甚至无法适应内存,即使能够适应,计算海森也太慢。
表 11-2 比较了到目前为止我们讨论过的所有优化器(*是不好的,**是平均的,***是好的)。
表 11-2。优化器比较
| 类 | 收敛速度 | 收敛质量 |
|---|---|---|
SGD | * | *** |
SGD(momentum=...) | ** | *** |
SGD(momentum=..., nesterov=True) | ** | *** |
Adagrad | *** | *(过早停止) |
RMSprop | *** | ** or *** |
Adam | *** | ** or *** |
AdaMax | *** | ** or *** |
Nadam | *** | ** or *** |
AdamW | *** | ** or *** |
学习率调度
找到一个好的学习率非常重要。如果设置得太高,训练可能会发散(如“梯度下降”中讨论的)。如果设置得太低,训练最终会收敛到最优解,但需要很长时间。如果设置得稍微偏高,它会在一开始就非常快地取得进展,但最终会围绕最优解打转,从未真正稳定下来。如果你的计算预算有限,你可能需要在训练收敛之前中断训练,得到一个次优解(参见图 11-9)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1109.png
图 11-9。不同学习率η的学习曲线
如第十章中讨论的,您可以通过训练模型几百次,将学习率从一个非常小的值指数增加到一个非常大的值,然后查看学习曲线并选择一个略低于学习曲线开始迅速上升的学习率来找到一个好的学习率。然后,您可以重新初始化您的模型,并使用该学习率进行训练。
但是你可以比恒定学习率做得更好:如果你从一个较大的学习率开始,然后在训练停止快速取得进展时降低它,你可以比使用最佳恒定学习率更快地达到一个好的解。有许多不同的策略可以在训练过程中降低学习率。从一个低学习率开始,增加它,然后再次降低它也可能是有益的。这些策略被称为学习计划(我在第四章中简要介绍了这个概念)。这些是最常用的学习计划:
幂调度
将学习率设置为迭代次数t的函数:η(t) = η[0] / (1 + t/s)^(c)。初始学习率η[0],幂c(通常设置为 1)和步长s是超参数。学习率在每一步下降。经过s步,学习率降至η[0]的一半。再经过s步,它降至η[0]的 1/3,然后降至η[0]的 1/4,然后η[0]的 1/5,依此类推。正如您所看到的,这个调度首先快速下降,然后变得越来越慢。当然,幂调度需要调整η[0]和s(可能还有c)。
指数调度
将学习率设置为η(t) = η[0] 0.1^(t/s)。学习率将每s步逐渐降低 10 倍。虽然幂调度使学习率降低得越来越慢,指数调度则每s步将其降低 10 倍。
分段常数调度
在一些时期内使用恒定的学习率(例如,η[0] = 0.1,持续 5 个时期),然后在另一些时期内使用较小的学习率(例如,η[1] = 0.001,持续 50 个时期),依此类推。尽管这种解决方案可能效果很好,但需要调整以找出正确的学习率序列以及每个学习率使用的时间长度。
性能调度
每N步测量验证错误(就像提前停止一样),当错误停止下降时,将学习率降低λ倍。
1cycle 调度
1cycle 是由 Leslie Smith 在2018 年的一篇论文中提出的。与其他方法相反,它从增加初始学习率η[0]开始,线性增长到训练中途的η[1]。然后在训练的第二半部分线性降低学习率至η[0],最后几个时期通过几个数量级的降低率(仍然是线性)来完成。最大学习率η[1]是使用我们用来找到最佳学习率的相同方法选择的,初始学习率η[0]通常低 10 倍。当使用动量时,我们首先使用高动量(例如 0.95),然后在训练的前半部分将其降低到较低的动量(例如 0.85,线性),然后在训练的后半部分将其提高到最大值(例如 0.95),最后几个时期使用该最大值。Smith 进行了许多实验,表明这种方法通常能够显著加快训练速度并达到更好的性能。例如,在流行的 CIFAR10 图像数据集上,这种方法仅在 100 个时期内达到了 91.9%的验证准确率,而通过标准方法(使用相同的神经网络架构)在 800 个时期内仅达到了 90.3%的准确率。这一壮举被称为超级收敛。
Andrew Senior 等人在2013 年的一篇论文中比较了使用动量优化训练深度神经网络进行语音识别时一些最流行的学习调度的性能。作者得出结论,在这种情况下,性能调度和指数调度表现良好。他们更青睐指数调度,因为它易于调整,并且收敛到最佳解稍快。他们还提到,它比性能调度更容易实现,但在 Keras 中,这两个选项都很容易。也就是说,1cycle 方法似乎表现得更好。
在 Keras 中实现幂调度是最简单的选择——只需在创建优化器时设置衰减超参数:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01, decay=1e-4)
衰减是s的倒数(将学习率除以一个单位所需的步数),Keras 假设c等于 1。
指数调度和分段调度也很简单。您首先需要定义一个函数,该函数接受当前 epoch 并返回学习率。例如,让我们实现指数调度:
def exponential_decay_fn(epoch):
return 0.01 * 0.1 ** (epoch / 20)
如果您不想硬编码 η[0] 和 s,您可以创建一个返回配置函数的函数:
def exponential_decay(lr0, s):
def exponential_decay_fn(epoch):
return lr0 * 0.1 ** (epoch / s)
return exponential_decay_fn
exponential_decay_fn = exponential_decay(lr0=0.01, s=20)
接下来,创建一个 LearningRateScheduler 回调,将调度函数传递给它,并将此回调传递给 fit() 方法:
lr_scheduler = tf.keras.callbacks.LearningRateScheduler(exponential_decay_fn)
history = model.fit(X_train, y_train, [...], callbacks=[lr_scheduler])
LearningRateScheduler 将在每个 epoch 开始时更新优化器的 learning_rate 属性。通常每个 epoch 更新一次学习率就足够了,但是如果您希望更频繁地更新它,例如在每一步,您可以随时编写自己的回调(请参阅本章笔记本中“指数调度”部分的示例)。在每一步更新学习率可能有助于处理每个 epoch 中的许多步骤。或者,您可以使用 tf.keras.optimizers.schedules 方法,稍后会进行描述。
提示
训练后,history.history["lr"] 可以让您访问训练过程中使用的学习率列表。
调度函数可以选择将当前学习率作为第二个参数。例如,以下调度函数将前一个学习率乘以 0.1^(1/20),这将导致相同的指数衰减(除了衰减现在从第 0 个 epoch 开始而不是第 1 个):
def exponential_decay_fn(epoch, lr):
return lr * 0.1 ** (1 / 20)
这个实现依赖于优化器的初始学习率(与之前的实现相反),所以请确保适当设置它。
当您保存一个模型时,优化器及其学习率也会被保存。这意味着使用这个新的调度函数,您可以加载一个训练好的模型,并继续在离开的地方继续训练,没有问题。然而,如果您的调度函数使用 epoch 参数,情况就不那么简单了:epoch 不会被保存,并且每次调用 fit() 方法时都会被重置为 0。如果您要继续训练一个模型,这可能会导致一个非常大的学习率,这可能会损坏模型的权重。一个解决方案是手动设置 fit() 方法的 initial_epoch 参数,使 epoch 从正确的值开始。
对于分段常数调度,您可以使用以下类似的调度函数(与之前一样,如果您愿意,您可以定义一个更通用的函数;请参阅笔记本中“分段常数调度”部分的示例),然后创建一个带有此函数的 LearningRateScheduler 回调,并将其传递给 fit() 方法,就像对指数调度一样:
def piecewise_constant_fn(epoch):
if epoch < 5:
return 0.01
elif epoch < 15:
return 0.005
else:
return 0.001
对于性能调度,请使用 ReduceLROnPlateau 回调。例如,如果您将以下回调传递给 fit() 方法,每当最佳验证损失连续五个 epoch 没有改善时,它将把学习率乘以 0.5(还有其他选项可用;请查看文档以获取更多详细信息):
lr_scheduler = tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=5)
history = model.fit(X_train, y_train, [...], callbacks=[lr_scheduler])
最后,Keras 提供了另一种实现学习率调度的方法:您可以使用 tf.keras.optimizers.schedules 中可用的类之一定义一个调度学习率,然后将其传递给任何优化器。这种方法在每一步而不是每个 epoch 更新学习率。例如,以下是如何实现与我们之前定义的 exponential_decay_fn() 函数相同的指数调度:
import math
batch_size = 32
n_epochs = 25
n_steps = n_epochs * math.ceil(len(X_train) / batch_size)
scheduled_learning_rate = tf.keras.optimizers.schedules.ExponentialDecay(
initial_learning_rate=0.01, decay_steps=n_steps, decay_rate=0.1)
optimizer = tf.keras.optimizers.SGD(learning_rate=scheduled_learning_rate)
这很简单明了,而且当您保存模型时,学习率及其调度(包括其状态)也会被保存。
至于 1cycle,Keras 不支持它,但是可以通过创建一个自定义回调,在每次迭代时修改学习率来实现它,代码不到 30 行。要从回调的 on_batch_begin() 方法中更新优化器的学习率,您需要调用 tf.keras.backend.set_value(self.model.optimizer.learning_rate, new_learning_rate)。请参阅笔记本中的“1Cycle Scheduling”部分以获取示例。
总之,指数衰减、性能调度和 1cycle 可以显著加快收敛速度,所以试一试吧!
通过正则化避免过拟合
有了四个参数,我可以拟合一只大象,有了五个我可以让它摇动它的鼻子。
约翰·冯·诺伊曼,引用自恩里科·费米在《自然》427 中
拥有成千上万个参数,你可以拟合整个动物园。深度神经网络通常有数万个参数,有时甚至有数百万个。这给予它们极大的自由度,意味着它们可以拟合各种复杂的数据集。但这种极大的灵活性也使得网络容易过拟合训练集。通常需要正则化来防止这种情况发生。
我们已经在第十章中实现了最好的正则化技术之一:提前停止。此外,即使批量归一化是为了解决不稳定梯度问题而设计的,它也像一个相当不错的正则化器。在本节中,我们将研究神经网络的其他流行正则化技术:ℓ[1] 和 ℓ[2] 正则化、dropout 和最大范数正则化。
ℓ[1] 和 ℓ[2] 正则化
就像你在第四章中为简单线性模型所做的那样,你可以使用 ℓ[2] 正则化来约束神经网络的连接权重,和/或者使用 ℓ[1] 正则化如果你想要一个稀疏模型(其中许多权重等于 0)。以下是如何将 ℓ[2] 正则化应用于 Keras 层的连接权重,使用正则化因子为 0.01:
layer = tf.keras.layers.Dense(100, activation="relu",
kernel_initializer="he_normal",
kernel_regularizer=tf.keras.regularizers.l2(0.01))
l2() 函数返回一个正则化器,在训练过程中的每一步都会调用它来计算正则化损失。然后将其添加到最终损失中。正如你所期望的那样,如果你想要 ℓ[1] 正则化,你可以简单地使用tf.keras.regularizers.l1();如果你想要同时使用 ℓ[1] 和 ℓ[2] 正则化,可以使用tf.keras.regularizers.l1_l2()(指定两个正则化因子)。
由于通常希望在网络的所有层中应用相同的正则化器,以及在所有隐藏层中使用相同的激活函数和相同的初始化策略,你可能会发现自己重复相同的参数。这会使代码变得丑陋且容易出错。为了避免这种情况,你可以尝试重构代码以使用循环。另一个选择是使用 Python 的functools.partial()函数,它允许你为任何可调用对象创建一个薄包装器,并设置一些默认参数值:
from functools import partial
RegularizedDense = partial(tf.keras.layers.Dense,
activation="relu",
kernel_initializer="he_normal",
kernel_regularizer=tf.keras.regularizers.l2(0.01))
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
RegularizedDense(100),
RegularizedDense(100),
RegularizedDense(10, activation="softmax")
])
警告
正如我们之前看到的,当使用 SGD、动量优化和 Nesterov 动量优化时,ℓ[2] 正则化是可以的,但在使用 Adam 及其变种时不行。如果你想要在使用 Adam 时进行权重衰减,那么不要使用 ℓ[2] 正则化:使用 AdamW 替代。
Dropout
Dropout 是深度神经网络中最流行的正则化技术之一。它是由 Geoffrey Hinton 等人在 2012 年的一篇论文中提出的,并在 2014 年由 Nitish Srivastava 等人进一步详细阐述,已被证明非常成功:许多最先进的神经网络使用了 dropout,因为它使它们的准确率提高了 1%–2%。这听起来可能不多,但当一个模型已经有 95%的准确率时,获得 2%的准确率提升意味着将错误率减少了近 40%(从 5%的错误率降至大约 3%)。
这是一个相当简单的算法:在每个训练步骤中,每个神经元(包括输入神经元,但始终不包括输出神经元)都有一个概率p在训练期间暂时“被丢弃”,这意味着在这个训练步骤中它将被完全忽略,但在下一个步骤中可能会活跃(参见图 11-10)。超参数p称为dropout 率,通常设置在 10%到 50%之间:在循环神经网络中更接近 20%-30%(参见第十五章),在卷积神经网络中更接近 40%-50%(参见第十四章)。训练后,神经元不再被丢弃。这就是全部(除了我们将立即讨论的一个技术细节)。
最初令人惊讶的是,这种破坏性技术居然有效。如果一家公司告诉员工每天早上抛硬币决定是否去上班,公司会表现得更好吗?谁知道呢;也许会!公司将被迫调整其组织;它不能依赖任何一个人来操作咖啡机或执行其他关键任务,因此这种专业知识必须分散到几个人身上。员工必须学会与许多同事合作,而不仅仅是少数几个人。公司将变得更具弹性。如果有人离职,这不会有太大影响。目前尚不清楚这种想法是否适用于公司,但对于神经网络来说,它确实有效。使用 dropout 训练的神经元无法与其相邻的神经元共同适应;它们必须尽可能独立地发挥作用。它们也不能过度依赖少数输入神经元;它们必须关注每个输入神经元。它们最终对输入的轻微变化不太敏感。最终,您将获得一个更健壮的网络,具有更好的泛化能力。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1110.png
图 11-10。使用 dropout 正则化,每次训练迭代中,一个或多个层中的所有神经元的随机子集(除了输出层)会“被丢弃”;这些神经元在这次迭代中输出为 0(由虚线箭头表示)
理解 dropout 的另一种方法是意识到在每个训练步骤中生成了一个独特的神经网络。由于每个神经元可以存在或不存在,因此存在 2^(N)个可能的网络(其中N是可丢弃神经元的总数)。这是一个如此巨大的数字,以至于同一个神经网络被重复抽样几乎是不可能的。一旦您运行了 10,000 个训练步骤,您实际上已经训练了 10,000 个不同的神经网络,每个神经网络只有一个训练实例。这些神经网络显然不是独立的,因为它们共享许多权重,但它们仍然是不同的。最终的神经网络可以看作是所有这些较小神经网络的平均集合。
提示
在实践中,通常只能将 dropout 应用于顶部一到三层的神经元(不包括输出层)。
有一个小但重要的技术细节。假设p=75%:平均每次训练步骤中只有 25%的神经元是活跃的。这意味着在训练后,神经元将连接到四倍于训练期间的输入神经元。为了补偿这一事实,我们需要在训练期间将每个神经元的输入连接权重乘以四。如果不这样做,神经网络在训练期间和训练后将看到不同的数据,表现不佳。更一般地,在训练期间,我们需要将连接权重除以“保留概率”(1-p)。
使用 Keras 实现 dropout,可以使用tf.keras.layers.Dropout层。在训练期间,它会随机丢弃一些输入(将它们设置为 0),并将剩余的输入除以保留概率。训练结束后,它什么也不做;它只是将输入传递给下一层。以下代码在每个密集层之前应用了 dropout 正则化,使用了 0.2 的 dropout 率:
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=[28, 28]),
tf.keras.layers.Dropout(rate=0.2),
tf.keras.layers.Dense(100, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.Dropout(rate=0.2),
tf.keras.layers.Dense(100, activation="relu",
kernel_initializer="he_normal"),
tf.keras.layers.Dropout(rate=0.2),
tf.keras.layers.Dense(10, activation="softmax")
])
[...] # compile and train the model
警告
由于 dropout 只在训练期间激活,比较训练损失和验证损失可能会产生误导。特别是,模型可能会过度拟合训练集,但训练和验证损失却相似。因此,请确保在没有 dropout 的情况下评估训练损失(例如,在训练后)。
如果观察到模型过拟合,可以增加 dropout 率。相反,如果模型对训练集拟合不足,可以尝试减少 dropout 率。对于大型层,增加 dropout 率,对于小型层,减少 dropout 率也有帮助。此外,许多最先进的架构仅在最后一个隐藏层之后使用 dropout,因此如果全局 dropout 太强,您可能想尝试这样做。
Dropout 确实会显著减慢收敛速度,但在适当调整后通常会得到更好的模型。因此,额外的时间和精力通常是值得的,特别是对于大型模型。
提示
如果要对基于 SELU 激活函数的自正则化网络进行正则化(如前面讨论的),应该使用alpha dropout:这是一种保留其输入均值和标准差的 dropout 变体。它是在与 SELU 一起引入的同一篇论文中提出的,因为常规 dropout 会破坏自正则化。
蒙特卡洛(MC)Dropout
2016 年,Yarin Gal 和 Zoubin Ghahramani 的一篇论文建立了使用 dropout 的更多好理由:
-
首先,该论文建立了 dropout 网络(即包含
Dropout层的神经网络)与近似贝叶斯推断之间的深刻联系,为 dropout 提供了坚实的数学理论基础。 -
其次,作者引入了一种强大的技术称为MC dropout,它可以提升任何经过训练的 dropout 模型的性能,而无需重新训练它甚至修改它。它还提供了模型不确定性的更好度量,并且可以在几行代码中实现。
如果这一切听起来像某种“奇怪的技巧”点击诱饵,那么看看以下代码。这是 MC dropout 的完整实现,增强了我们之前训练的 dropout 模型而无需重新训练它:
import numpy as np
y_probas = np.stack([model(X_test, training=True)
for sample in range(100)])
y_proba = y_probas.mean(axis=0)
请注意,model(X)类似于model.predict(X),只是它返回一个张量而不是 NumPy 数组,并支持training参数。在这个代码示例中,设置training=True确保Dropout层保持活动状态,因此所有预测都会有些不同。我们只对测试集进行 100 次预测,并计算它们的平均值。更具体地说,每次调用模型都会返回一个矩阵,每个实例一行,每个类别一列。因为测试集中有 10,000 个实例和 10 个类别,所以这是一个形状为[10000, 10]的矩阵。我们堆叠了 100 个这样的矩阵,所以y_probas是一个形状为[100, 10000, 10]的 3D 数组。一旦我们在第一个维度上取平均值(axis=0),我们得到y_proba,一个形状为[10000, 10]的数组,就像我们在单次预测中得到的一样。就是这样!在打开 dropout 的情况下对多次预测取平均值会给我们一个通常比关闭 dropout 的单次预测结果更可靠的蒙特卡洛估计。例如,让我们看看模型对 Fashion MNIST 测试集中第一个实例的预测,关闭 dropout:
>>> model.predict(X_test[:1]).round(3)
array([[0\. , 0\. , 0\. , 0\. , 0\. , 0.024, 0\. , 0.132, 0\. ,
0.844]], dtype=float32)
模型相当自信(84.4%)这张图片属于第 9 类(踝靴)。与 MC dropout 预测进行比较:
>>> y_proba[0].round(3)
array([0\. , 0\. , 0\. , 0\. , 0\. , 0.067, 0\. , 0.209, 0.001,
0.723], dtype=float32)
模型似乎仍然更喜欢类别 9,但其置信度降至 72.3%,类别 5(凉鞋)和 7(运动鞋)的估计概率增加,这是有道理的,因为它们也是鞋类。
MC dropout 倾向于提高模型概率估计的可靠性。这意味着它不太可能自信但错误,这可能是危险的:想象一下一个自动驾驶汽车自信地忽略一个停车标志。了解哪些其他类别最有可能也很有用。此外,您可以查看概率估计的标准差:
>>> y_std = y_probas.std(axis=0)
>>> y_std[0].round(3)
array([0\. , 0\. , 0\. , 0.001, 0\. , 0.096, 0\. , 0.162, 0.001,
0.183], dtype=float32)
显然,类别 9 的概率估计存在相当大的方差:标准差为 0.183,应与估计的概率 0.723 进行比较:如果您正在构建一个风险敏感的系统(例如医疗或金融系统),您可能会对这种不确定的预测极为谨慎。您绝对不会将其视为 84.4%的自信预测。模型的准确性也从 87.0%略微提高到 87.2%:
>>> y_pred = y_proba.argmax(axis=1)
>>> accuracy = (y_pred == y_test).sum() / len(y_test)
>>> accuracy
0.8717
注意
您使用的蒙特卡洛样本数量(在此示例中为 100)是一个可以调整的超参数。它越高,预测和不确定性估计就越准确。但是,如果您将其加倍,推断时间也将加倍。此外,在一定数量的样本之上,您将注意到改进很小。您的任务是根据您的应用程序找到延迟和准确性之间的正确权衡。
如果您的模型包含在训练期间以特殊方式行为的其他层(例如BatchNormalization层),那么您不应该像我们刚刚做的那样强制训练模式。相反,您应该用以下MCDropout类替换Dropout层:³⁰
class MCDropout(tf.keras.layers.Dropout):
def call(self, inputs, training=False):
return super().call(inputs, training=True)
在这里,我们只是子类化Dropout层,并覆盖call()方法以强制其training参数为True(请参阅第十二章)。类似地,您可以通过子类化AlphaDropout来定义一个MCAlphaDropout类。如果您从头开始创建一个模型,只需使用MCDropout而不是Dropout。但是,如果您已经使用Dropout训练了一个模型,您需要创建一个与现有模型相同但使用Dropout而不是MCDropout的新模型,然后将现有模型的权重复制到新模型中。
简而言之,MC dropout 是一种很棒的技术,可以提升 dropout 模型并提供更好的不确定性估计。当然,由于在训练期间只是常规的 dropout,因此它也起到了正则化的作用。
最大范数正则化
神经网络的另一种流行的正则化技术称为最大范数正则化:对于每个神经元,它约束传入连接的权重w,使得∥ w ∥[2] ≤ r,其中r是最大范数超参数,∥ · ∥[2]是ℓ[2]范数。
最大范数正则化不会向整体损失函数添加正则化损失项。相反,通常是在每个训练步骤之后计算∥ w ∥[2],并在需要时重新缩放w(w ← w r / ∥ w ∥[2])。
减小r会增加正则化的程度,并有助于减少过拟合。最大范数正则化还可以帮助缓解不稳定的梯度问题(如果您没有使用批量归一化)。
在 Keras 中实现最大范数正则化,将每个隐藏层的kernel_constraint参数设置为具有适当最大值的max_norm()约束,如下所示:
dense = tf.keras.layers.Dense(
100, activation="relu", kernel_initializer="he_normal",
kernel_constraint=tf.keras.constraints.max_norm(1.))
在每次训练迭代之后,模型的fit()方法将调用max_norm()返回的对象,将该层的权重传递给它,并得到重新缩放的权重,然后替换该层的权重。正如您将在第十二章中看到的,如果需要,您可以定义自己的自定义约束函数,并将其用作kernel_constraint。您还可以通过设置bias_constraint参数来约束偏置项。
max_norm()函数有一个默认为0的axis参数。一个Dense层通常具有形状为[输入数量,神经元数量]的权重,因此使用axis=0意味着最大范数约束将独立应用于每个神经元的权重向量。如果您想在卷积层中使用最大范数(参见第十四章),请确保适当设置max_norm()约束的axis参数(通常为axis=[0, 1, 2])。
总结和实用指南
在本章中,我们涵盖了各种技术,您可能想知道应该使用哪些技术。这取决于任务,目前还没有明确的共识,但我发现表 11-3 中的配置在大多数情况下都能很好地工作,而不需要太多的超参数调整。尽管如此,请不要将这些默认值视为硬性规则!
表 11-3. 默认 DNN 配置
| 超参数 | 默认值 |
|---|---|
| 内核初始化器 | He 初始化 |
| 激活函数 | 如果是浅层则为 ReLU;如果是深层则为 Swish |
| 归一化 | 如果是浅层则为无;如果是深层则为批量归一化 |
| 正则化 | 提前停止;如果需要则使用权重衰减 |
| 优化器 | Nesterov 加速梯度或 AdamW |
| 学习率调度 | 性能调度或 1cycle |
如果网络是简单的密集层堆叠,则它可以自我归一化,您应该使用表 11-4 中的配置。
表 11-4. 自我归一化网络的 DNN 配置
| 超参数 | 默认值 |
|---|---|
| 内核初始化器 | LeCun 初始化 |
| 激活函数 | SELU |
| 归一化 | 无(自我归一化) |
| 正则化 | 如果需要则使用 Alpha dropout |
| 优化器 | Nesterov 加速梯度 |
| 学习率调度 | 性能调度或 1cycle |
不要忘记对输入特征进行归一化!您还应尝试重用预训练神经网络的部分,如果您可以找到一个解决类似问题的模型,或者如果您有大量未标记数据,则使用无监督预训练,或者如果您有大量类似任务的标记数据,则使用辅助任务的预训练。
虽然前面的指南应该涵盖了大多数情况,但这里有一些例外情况:
-
如果您需要一个稀疏模型,您可以使用ℓ[1]正则化(并在训练后选择性地将微小权重归零)。如果您需要一个更稀疏的模型,您可以使用 TensorFlow 模型优化工具包。这将破坏自我归一化,因此在这种情况下应使用默认配置。
-
如果您需要一个低延迟模型(执行闪电般快速预测的模型),您可能需要使用更少的层,使用快速激活函数(如 ReLU 或 leaky ReLU),并在训练后将批量归一化层折叠到前面的层中。拥有一个稀疏模型也会有所帮助。最后,您可能希望将浮点精度从 32 位减少到 16 位甚至 8 位(参见“将模型部署到移动设备或嵌入式设备”)。再次,查看 TF-MOT。
-
如果您正在构建一个风险敏感的应用程序,或者推断延迟在您的应用程序中并不是非常重要,您可以使用 MC dropout 来提高性能,并获得更可靠的概率估计,以及不确定性估计。
有了这些指导,您现在已经准备好训练非常深的网络了!我希望您现在相信,只使用方便的 Keras API 就可以走很长一段路。然而,可能会有一天,当您需要更多控制时,例如编写自定义损失函数或调整训练算法时。对于这种情况,您将需要使用 TensorFlow 的较低级别 API,您将在下一章中看到。
练习
-
Glorot 初始化和 He 初始化旨在解决什么问题?
-
只要使用 He 初始化随机选择的值将所有权重初始化为相同值,这样做可以吗?
-
将偏置项初始化为 0 可以吗?
-
在本章讨论的每种激活函数中,您希望在哪些情况下使用?
-
当使用
SGD优化器时,如果将momentum超参数设置得太接近 1(例如 0.99999),可能会发生什么? -
列出三种可以生成稀疏模型的方法。
-
Dropout 会减慢训练速度吗?它会减慢推断速度(即对新实例进行预测)吗?MC dropout 呢?
-
练习在 CIFAR10 图像数据集上训练深度神经网络:
-
构建一个具有 20 个每层 100 个神经元的隐藏层的 DNN(这太多了,但这是这个练习的重点)。使用 He 初始化和 Swish 激活函数。
-
使用 Nadam 优化和提前停止,在 CIFAR10 数据集上训练网络。您可以使用
tf.keras.datasets.cifar10.load_data()加载数据集。该数据集由 60,000 个 32×32 像素的彩色图像组成(50,000 个用于训练,10,000 个用于测试),具有 10 个类别,因此您需要一个具有 10 个神经元的 softmax 输出层。记得每次更改模型架构或超参数时都要搜索正确的学习率。 -
现在尝试添加批量归一化并比较学习曲线:它是否比以前收敛得更快?它是否产生更好的模型?它如何影响训练速度?
-
尝试用 SELU 替换批量归一化,并进行必要的调整以确保网络自我归一化(即标准化输入特征,使用 LeCun 正态初始化,确保 DNN 仅包含一系列密集层等)。
-
尝试使用 alpha dropout 对模型进行正则化。然后,在不重新训练模型的情况下,看看是否可以通过 MC dropout 获得更好的准确性。
-
使用 1cycle 调度重新训练您的模型,看看它是否提高了训练速度和模型准确性。
-
这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ Xavier Glorot 和 Yoshua Bengio,“理解训练深度前馈神经网络的困难”,第 13 届人工智能和统计国际会议论文集(2010):249-256。
² 这里有一个类比:如果将麦克风放大器的旋钮调得太接近零,人们就听不到您的声音,但如果将其调得太接近最大值,您的声音将被饱和,人们将听不懂您在说什么。现在想象一下这样一系列放大器:它们都需要适当设置,以便您的声音在链的末端响亮清晰地传出。您的声音必须以与进入时相同的幅度从每个放大器中传出。
³ 例如,Kaiming He 等人,“深入研究整流器:在 ImageNet 分类上超越人类水平表现”,2015 年 IEEE 国际计算机视觉大会论文集(2015):1026-1034。
⁴ 如果神经元下面的层中的输入随时间演变并最终返回到 ReLU 激活函数再次获得正输入的范围内,死神经元可能会复活。例如,如果梯度下降调整了死神经元下面的神经元,这种情况可能会发生。
⁵ Bing Xu 等人,“卷积网络中修正激活的实证评估”,arXiv 预印本 arXiv:1505.00853(2015)。
⁶ Djork-Arné Clevert 等人,“指数线性单元(ELUs)快速准确的深度网络学习”,国际学习表示会议论文集,arXiv 预印本(2015 年)。
⁷ Günter Klambauer 等人,“自正则化神经网络”,第 31 届国际神经信息处理系统会议论文集(2017):972–981。
⁸ Dan Hendrycks 和 Kevin Gimpel,“高斯误差线性单元(GELUs)”,arXiv 预印本 arXiv:1606.08415(2016)。
⁹ 如果曲线上任意两点之间的线段永远不会低于曲线,则函数是凸的。单调函数只增加或只减少。
¹⁰ Prajit Ramachandran 等人,“寻找激活函数”,arXiv 预印本 arXiv:1710.05941(2017)。
¹¹ Diganta Misra,“Mish:一种自正则化的非单调激活函数”,arXiv 预印本 arXiv:1908.08681(2019)。
¹² Sergey Ioffe 和 Christian Szegedy,“批量归一化:通过减少内部协变量转移加速深度网络训练”,第 32 届国际机器学习会议论文集(2015):448–456。
¹³ 然而,它们是根据训练数据在训练期间估计的,因此可以说它们是可训练的。在 Keras 中,“不可训练”实际上意味着“不受反向传播影响”。
¹⁴ Razvan Pascanu 等人,“关于训练递归神经网络的困难”,第 30 届国际机器学习会议论文集(2013):1310–1318。
¹⁵ Boris T. Polyak,“加速迭代方法收敛的一些方法”,苏联计算数学和数学物理杂志 4,第 5 期(1964):1–17。
¹⁶ Yurii Nesterov,“一种具有收敛速率O(1/k²)的无约束凸最小化问题方法”,苏联科学院学报 269(1983):543–547。
¹⁷ John Duchi 等人,“用于在线学习和随机优化的自适应次梯度方法”,机器学习研究杂志 12(2011):2121–2159。
¹⁸ 该算法由 Geoffrey Hinton 和 Tijmen Tieleman 于 2012 年创建,并由 Geoffrey Hinton 在他关于神经网络的 Coursera 课程中介绍(幻灯片:https://homl.info/57;视频:https://homl.info/58)。有趣的是,由于作者没有撰写描述该算法的论文,研究人员经常在其论文中引用“第 6e 讲座的第 29 张幻灯片”。
¹⁹ ρ是希腊字母 rho。
²⁰ Diederik P. Kingma 和 Jimmy Ba,“Adam:一种随机优化方法”,arXiv 预印本 arXiv:1412.6980(2014)。
²¹ Timothy Dozat,“将 Nesterov 动量合并到 Adam 中”(2016)。
²² Ilya Loshchilov 和 Frank Hutter,“解耦权重衰减正则化”,arXiv 预印本 arXiv:1711.05101(2017)。
²³ Ashia C. Wilson 等人,“机器学习中自适应梯度方法的边际价值”,神经信息处理系统进展 30(2017):4148–4158。
Leslie N. Smith,“神经网络超参数的纪律性方法:第 1 部分—学习率、批量大小、动量和权重衰减”,arXiv 预印本 arXiv:1803.09820(2018)。
Andrew Senior 等人,“深度神经网络在语音识别中的学习率的实证研究”,IEEE 国际会议论文集(2013):6724–6728。
Geoffrey E. Hinton 等人,“通过防止特征探测器的共适应来改进神经网络”,arXiv 预印本 arXiv:1207.0580(2012)。
Nitish Srivastava 等人,“Dropout:防止神经网络过拟合的简单方法”,机器学习研究杂志 15(2014):1929–1958。
Yarin Gal 和 Zoubin Ghahramani,“Dropout 作为贝叶斯近似:在深度学习中表示模型不确定性”,第 33 届国际机器学习会议论文集(2016):1050–1059。
具体来说,他们表明训练一个 dropout 网络在数学上等同于在一种特定类型的概率模型中进行近似贝叶斯推断,这种模型被称为深高斯过程。
这个MCDropout类将与所有 Keras API 一起工作,包括顺序 API。如果您只关心功能 API 或子类 API,您不必创建一个MCDropout类;您可以创建一个常规的Dropout层,并使用training=True调用它。
第十二章:使用 TensorFlow 进行自定义模型和训练
到目前为止,我们只使用了 TensorFlow 的高级 API,Keras,但它已经让我们走得很远:我们构建了各种神经网络架构,包括回归和分类网络,Wide & Deep 网络,自正则化网络,使用各种技术,如批量归一化,dropout 和学习率调度。事实上,您将遇到的 95%用例不需要除了 Keras(和 tf.data)之外的任何东西(请参见第十三章)。但现在是时候深入研究 TensorFlow,看看它的低级Python API。当您需要额外控制以编写自定义损失函数,自定义指标,层,模型,初始化程序,正则化器,权重约束等时,这将非常有用。您甚至可能需要完全控制训练循环本身;例如,应用特殊的转换或约束到梯度(超出仅仅剪切它们)或为网络的不同部分使用多个优化器。我们将在本章中涵盖所有这些情况,并且还将看看如何使用 TensorFlow 的自动生成图功能来提升您的自定义模型和训练算法。但首先,让我们快速浏览一下 TensorFlow。
TensorFlow 的快速浏览
正如您所知,TensorFlow 是一个强大的用于数值计算的库,特别适用于大规模机器学习(但您也可以用它来进行需要大量计算的任何其他任务)。它由 Google Brain 团队开发,驱动了谷歌许多大规模服务,如 Google Cloud Speech,Google Photos 和 Google Search。它于 2015 年 11 月开源,现在是业界最广泛使用的深度学习库:无数项目使用 TensorFlow 进行各种机器学习任务,如图像分类,自然语言处理,推荐系统和时间序列预测。
那么 TensorFlow 提供了什么?以下是一个摘要:
-
它的核心与 NumPy 非常相似,但支持 GPU。
-
它支持分布式计算(跨多个设备和服务器)。
-
它包括一种即时(JIT)编译器,允许它优化计算以提高速度和内存使用。它通过从 Python 函数中提取计算图,优化它(例如通过修剪未使用的节点),并有效地运行它(例如通过自动并行运行独立操作)来工作。
-
计算图可以导出为可移植格式,因此您可以在一个环境中训练 TensorFlow 模型(例如在 Linux 上使用 Python),并在另一个环境中运行它(例如在 Android 设备上使用 Java)。
-
它实现了反向模式自动微分(请参见第十章和附录 B)并提供了一些优秀的优化器,如 RMSProp 和 Nadam(请参见第十一章),因此您可以轻松最小化各种损失函数。
TensorFlow 提供了许多建立在这些核心功能之上的功能:最重要的当然是 Keras,但它还有数据加载和预处理操作(tf.data,tf.io 等),图像处理操作(tf.image),信号处理操作(tf.signal)等等(请参见图 12-1 以获取 TensorFlow 的 Python API 概述)。
提示
我们将涵盖 TensorFlow API 的许多包和函数,但不可能覆盖所有内容,因此您应该花些时间浏览 API;您会发现它非常丰富且有很好的文档。
在最低级别上,每个 TensorFlow 操作(简称 op)都是使用高效的 C++代码实现的。许多操作有多个称为内核的实现:每个内核专门用于特定设备类型,如 CPU、GPU,甚至 TPU(张量处理单元)。正如您可能知道的,GPU 可以通过将计算分成许多较小的块并在许多 GPU 线程上并行运行来显着加快计算速度。TPU 速度更快:它们是专门用于深度学习操作的定制 ASIC 芯片(我们将在第十九章讨论如何使用 GPU 或 TPU 与 TensorFlow)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1201.png
图 12-1. TensorFlow 的 Python API
TensorFlow 的架构如图 12-2 所示。大部分时间,您的代码将使用高级 API(特别是 Keras 和 tf.data),但当您需要更灵活性时,您将使用较低级别的 Python API,直接处理张量。无论如何,TensorFlow 的执行引擎将有效地运行操作,即使跨多个设备和机器,如果您告诉它的话。
TensorFlow 不仅可以在 Windows、Linux 和 macOS 上运行,还可以在移动设备上运行(使用 TensorFlow Lite),包括 iOS 和 Android(请参阅第十九章)。请注意,如果您不想使用 Python API,还可以使用其他语言的 API:有 C++、Java 和 Swift 的 API。甚至还有一个名为 TensorFlow.js 的 JavaScript 实现,可以直接在浏览器中运行您的模型。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1202.png
图 12-2. TensorFlow 的架构
TensorFlow 不仅仅是一个库。TensorFlow 是一个庞大生态系统中心。首先,有用于可视化的 TensorBoard(请参阅第十章)。接下来,有由 Google 构建的用于将 TensorFlow 项目投入生产的一套库,称为TensorFlow Extended (TFX):它包括用于数据验证、预处理、模型分析和服务的工具(使用 TF Serving;请参阅第十九章)。Google 的 TensorFlow Hub 提供了一种轻松下载和重复使用预训练神经网络的方式。您还可以在 TensorFlow 的model garden中获得许多神经网络架构,其中一些是预训练的。查看TensorFlow 资源和https://github.com/jtoy/awesome-tensorflow以获取更多基于 TensorFlow 的项目。您可以在 GitHub 上找到数百个 TensorFlow 项目,因此通常很容易找到您正在尝试做的任何事情的现有代码。
提示
越来越多的机器学习论文随着它们的实现发布,有时甚至附带预训练模型。请查看https://paperswithcode.com以轻松找到它们。
最后但并非最不重要的是,TensorFlow 拥有一支充满激情和乐于助人的开发团队,以及一个庞大的社区为其改进做出贡献。要提出技术问题,您应该使用https://stackoverflow.com,并在问题中标记tensorflow和python。您可以通过GitHub提交错误和功能请求。要进行一般讨论,请加入TensorFlow 论坛。
好了,现在是开始编码的时候了!
像 NumPy 一样使用 TensorFlow
TensorFlow 的 API 围绕着张量展开,这些张量从操作流向操作,因此得名 TensorFlow。张量与 NumPy 的ndarray非常相似:通常是一个多维数组,但也可以保存标量(例如42)。当我们创建自定义成本函数、自定义指标、自定义层等时,这些张量将非常重要,让我们看看如何创建和操作它们。
张量和操作
您可以使用tf.constant()创建一个张量。例如,这里是一个表示具有两行三列浮点数的矩阵的张量:
>>> import tensorflow as tf
>>> t = tf.constant([[1., 2., 3.], [4., 5., 6.]]) # matrix
>>> t
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[1., 2., 3.],
[4., 5., 6.]], dtype=float32)>
就像ndarray一样,tf.Tensor有一个形状和一个数据类型(dtype):
>>> t.shape
TensorShape([2, 3])
>>> t.dtype
tf.float32
索引工作方式与 NumPy 类似:
>>> t[:, 1:]
<tf.Tensor: shape=(2, 2), dtype=float32, numpy=
array([[2., 3.],
[5., 6.]], dtype=float32)>
>>> t[..., 1, tf.newaxis]
<tf.Tensor: shape=(2, 1), dtype=float32, numpy=
array([[2.],
[5.]], dtype=float32)>
最重要的是,各种张量操作都是可用的:
>>> t + 10
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[11., 12., 13.],
[14., 15., 16.]], dtype=float32)>
>>> tf.square(t)
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[ 1., 4., 9.],
[16., 25., 36.]], dtype=float32)>
>>> t @ tf.transpose(t)
<tf.Tensor: shape=(2, 2), dtype=float32, numpy=
array([[14., 32.],
[32., 77.]], dtype=float32)>
请注意,编写t + 10等同于调用tf.add(t, 10)(实际上,Python 调用了魔术方法t.__add__(10),它只是调用了tf.add(t, 10))。其他运算符,如-和*,也受支持。@运算符在 Python 3.5 中添加,用于矩阵乘法:它等同于调用tf.matmul()函数。
注意
许多函数和类都有别名。例如,tf.add()和tf.math.add()是相同的函数。这使得 TensorFlow 可以为最常见的操作保留简洁的名称,同时保持良好组织的包。
张量也可以保存标量值。在这种情况下,形状为空:
>>> tf.constant(42)
<tf.Tensor: shape=(), dtype=int32, numpy=42>
注意
Keras API 有自己的低级 API,位于tf.keras.backend中。这个包通常被导入为K,以简洁为主。它曾经包括函数如K.square()、K.exp()和K.sqrt(),您可能在现有代码中遇到:这在 Keras 支持多个后端时编写可移植代码很有用,但现在 Keras 只支持 TensorFlow,您应该直接调用 TensorFlow 的低级 API(例如,使用tf.square()而不是K.square())。从技术上讲,K.square()及其相关函数仍然存在以保持向后兼容性,但tf.keras.backend包的文档只列出了一些实用函数,例如clear_session()(在第十章中提到)。
您将找到所有您需要的基本数学运算(tf.add()、tf.multiply()、tf.square()、tf.exp()、tf.sqrt()等)以及大多数您可以在 NumPy 中找到的操作(例如tf.reshape()、tf.squeeze()、tf.tile())。一些函数的名称与 NumPy 中的名称不同;例如,tf.reduce_mean()、tf.reduce_sum()、tf.reduce_max()和tf.math.log()相当于np.mean()、np.sum()、np.max()和np.log()。当名称不同时,通常有很好的理由。例如,在 TensorFlow 中,您必须编写tf.transpose(t);您不能像在 NumPy 中那样只写t.T。原因是tf.transpose()函数与 NumPy 的T属性并不完全相同:在 TensorFlow 中,将创建一个具有其自己的转置数据副本的新张量,而在 NumPy 中,t.T只是相同数据的一个转置视图。同样,tf.reduce_sum()操作之所以被命名为这样,是因为其 GPU 核心(即 GPU 实现)使用的减少算法不保证元素添加的顺序:因为 32 位浮点数的精度有限,每次调用此操作时结果可能会发生微小变化。tf.reduce_mean()也是如此(当然tf.reduce_max()是确定性的)。
张量和 NumPy
张量与 NumPy 兼容:您可以从 NumPy 数组创建张量,反之亦然。您甚至可以将 TensorFlow 操作应用于 NumPy 数组,将 NumPy 操作应用于张量:
>>> import numpy as np
>>> a = np.array([2., 4., 5.])
>>> tf.constant(a)
<tf.Tensor: id=111, shape=(3,), dtype=float64, numpy=array([2., 4., 5.])>
>>> t.numpy() # or np.array(t)
array([[1., 2., 3.],
[4., 5., 6.]], dtype=float32)
>>> tf.square(a)
<tf.Tensor: id=116, shape=(3,), dtype=float64, numpy=array([4., 16., 25.])>
>>> np.square(t)
array([[ 1., 4., 9.],
[16., 25., 36.]], dtype=float32)
警告
请注意,NumPy 默认使用 64 位精度,而 TensorFlow 使用 32 位。这是因为 32 位精度通常对神经网络来说足够了,而且运行速度更快,使用的内存更少。因此,当您从 NumPy 数组创建张量时,请确保设置dtype=tf.float32。
类型转换
类型转换可能会严重影响性能,并且当它们自动完成时很容易被忽略。为了避免这种情况,TensorFlow 不会自动执行任何类型转换:如果您尝试在具有不兼容类型的张量上执行操作,它只会引发异常。例如,您不能将浮点张量和整数张量相加,甚至不能将 32 位浮点数和 64 位浮点数相加:
>>> tf.constant(2.) + tf.constant(40)
[...] InvalidArgumentError: [...] expected to be a float tensor [...]
>>> tf.constant(2.) + tf.constant(40., dtype=tf.float64)
[...] InvalidArgumentError: [...] expected to be a float tensor [...]
这可能一开始有点烦人,但请记住这是为了一个好的目的!当然,当您真正需要转换类型时,您可以使用tf.cast():
>>> t2 = tf.constant(40., dtype=tf.float64)
>>> tf.constant(2.0) + tf.cast(t2, tf.float32)
<tf.Tensor: id=136, shape=(), dtype=float32, numpy=42.0>
变量
到目前为止,我们看到的tf.Tensor值是不可变的:我们无法修改它们。这意味着我们不能使用常规张量来实现神经网络中的权重,因为它们需要通过反向传播进行调整。此外,其他参数可能也需要随时间变化(例如,动量优化器会跟踪过去的梯度)。我们需要的是tf.Variable:
>>> v = tf.Variable([[1., 2., 3.], [4., 5., 6.]])
>>> v
<tf.Variable 'Variable:0' shape=(2, 3) dtype=float32, numpy=
array([[1., 2., 3.],
[4., 5., 6.]], dtype=float32)>
tf.Variable的行为很像tf.Tensor:您可以执行相同的操作,它与 NumPy 很好地配合,对类型也一样挑剔。但是它也可以使用assign()方法(或assign_add()或assign_sub(),它们会增加或减少给定值来就地修改变量)。您还可以使用单个单元格(或切片)的assign()方法或使用scatter_update()或scatter_nd_update()方法来修改单个单元格(或切片):
v.assign(2 * v) # v now equals [[2., 4., 6.], [8., 10., 12.]]
v[0, 1].assign(42) # v now equals [[2., 42., 6.], [8., 10., 12.]]
v[:, 2].assign([0., 1.]) # v now equals [[2., 42., 0.], [8., 10., 1.]]
v.scatter_nd_update( # v now equals [[100., 42., 0.], [8., 10., 200.]]
indices=[[0, 0], [1, 2]], updates=[100., 200.])
直接赋值不起作用:
>>> v[1] = [7., 8., 9.]
[...] TypeError: 'ResourceVariable' object does not support item assignment
注意
在实践中,您很少需要手动创建变量;Keras 提供了一个add_weight()方法,它会为您处理,您将看到。此外,模型参数通常会直接由优化器更新,因此您很少需要手动更新变量。
其他数据结构
TensorFlow 支持几种其他数据结构,包括以下内容(请参阅本章笔记本中的“其他数据结构”部分或附录 C 了解更多详细信息):
稀疏张量(tf.SparseTensor)
高效地表示大部分为零的张量。tf.sparse包含了稀疏张量的操作。
张量数组(tf.TensorArray)
是张量列表。它们默认具有固定长度,但可以选择性地扩展。它们包含的所有张量必须具有相同的形状和数据类型。
不规则张量(tf.RaggedTensor)
表示张量列表,所有张量的秩和数据类型相同,但大小不同。张量大小变化的维度称为不规则维度。tf.ragged包含了不规则张量的操作。
字符串张量
是类型为tf.string的常规张量。这些表示字节字符串,而不是 Unicode 字符串,因此如果您使用 Unicode 字符串(例如,像"café"这样的常规 Python 3 字符串)创建字符串张量,那么它将自编码为 UTF-8(例如,b"caf\xc3\xa9")。或者,您可以使用类型为tf.int32的张量来表示 Unicode 字符串,其中每个项目表示一个 Unicode 代码点(例如,[99, 97, 102, 233])。tf.strings包(带有s)包含用于字节字符串和 Unicode 字符串的操作(以及将一个转换为另一个的操作)。重要的是要注意tf.string是原子的,这意味着其长度不会出现在张量的形状中。一旦您将其转换为 Unicode 张量(即,一个包含 Unicode 代码点的tf.int32类型的张量),长度将出现在形状中。
集合
表示为常规张量(或稀疏张量)。例如,tf.constant([[1, 2], [3, 4]])表示两个集合{1, 2}和{3, 4}。更一般地,每个集合由张量的最后一个轴中的向量表示。您可以使用tf.sets包中的操作来操作集合。
队列
在多个步骤中存储张量。TensorFlow 提供各种类型的队列:基本的先进先出(FIFO)队列(FIFOQueue),以及可以优先处理某些项目的队列(PriorityQueue),对其项目进行洗牌的队列(RandomShuffleQueue),以及通过填充来批处理不同形状的项目的队列(PaddingFIFOQueue)。这些类都在tf.queue包中。
有了张量、操作、变量和各种数据结构,你现在可以定制你的模型和训练算法了!
自定义模型和训练算法
你将首先创建一个自定义损失函数,这是一个简单而常见的用例。
自定义损失函数
假设你想训练一个回归模型,但你的训练集有点嘈杂。当然,你首先尝试通过删除或修复异常值来清理数据集,但结果还不够好;数据集仍然很嘈杂。你应该使用哪种损失函数?均方误差可能会过分惩罚大误差,导致模型不够精确。平均绝对误差不会像惩罚异常值那样严重,但训练可能需要一段时间才能收敛,训练出的模型可能不够精确。这可能是使用 Huber 损失的好时机(在第十章介绍)。Huber 损失在 Keras 中是可用的(只需使用tf.keras.losses.Huber类的实例),但让我们假装它不存在。要实现它,只需创建一个函数,该函数将标签和模型预测作为参数,并使用 TensorFlow 操作来计算包含所有损失的张量(每个样本一个):
def huber_fn(y_true, y_pred):
error = y_true - y_pred
is_small_error = tf.abs(error) < 1
squared_loss = tf.square(error) / 2
linear_loss = tf.abs(error) - 0.5
return tf.where(is_small_error, squared_loss, linear_loss)
警告
为了获得更好的性能,你应该使用矢量化的实现,就像这个例子一样。此外,如果你想要从 TensorFlow 的图优化功能中受益,你应该只使用 TensorFlow 操作。
也可以返回平均损失而不是单个样本损失,但这不推荐,因为这样做会使在需要时无法使用类权重或样本权重(参见第十章)。
现在你可以在编译 Keras 模型时使用这个 Huber 损失函数,然后像往常一样训练你的模型:
model.compile(loss=huber_fn, optimizer="nadam")
model.fit(X_train, y_train, [...])
就是这样!在训练期间的每个批次中,Keras 将调用huber_fn()函数来计算损失,然后使用反向模式自动微分来计算损失相对于所有模型参数的梯度,最后执行梯度下降步骤(在这个例子中使用 Nadam 优化器)。此外,它将跟踪自从 epoch 开始以来的总损失,并显示平均损失。
但是当你保存模型时,这个自定义损失会发生什么?
保存和加载包含自定义组件的模型
保存包含自定义损失函数的模型可以正常工作,但是当你加载它时,你需要提供一个将函数名称映射到实际函数的字典。更一般地,当你加载包含自定义对象的模型时,你需要将名称映射到对象:
model = tf.keras.models.load_model("my_model_with_a_custom_loss",
custom_objects={"huber_fn": huber_fn})
提示
如果你用@keras.utils.register_keras_serializable()装饰huber_fn()函数,它将自动可用于load_model()函数:不需要将其包含在custom_objects字典中。
使用当前的实现,任何在-1 和 1 之间的错误都被认为是“小”。但是如果你想要一个不同的阈值呢?一个解决方案是创建一个函数来创建一个配置好的损失函数:
def create_huber(threshold=1.0):
def huber_fn(y_true, y_pred):
error = y_true - y_pred
is_small_error = tf.abs(error) < threshold
squared_loss = tf.square(error) / 2
linear_loss = threshold * tf.abs(error) - threshold ** 2 / 2
return tf.where(is_small_error, squared_loss, linear_loss)
return huber_fn
model.compile(loss=create_huber(2.0), optimizer="nadam")
不幸的是,当你保存模型时,threshold不会被保存。这意味着在加载模型时你将需要指定threshold的值(注意要使用的名称是"huber_fn",这是你给 Keras 的函数的名称,而不是创建它的函数的名称):
model = tf.keras.models.load_model(
"my_model_with_a_custom_loss_threshold_2",
custom_objects={"huber_fn": create_huber(2.0)}
)
你可以通过创建tf.keras.losses.Loss类的子类,然后实现它的get_config()方法来解决这个问题:
class HuberLoss(tf.keras.losses.Loss):
def __init__(self, threshold=1.0, **kwargs):
self.threshold = threshold
super().__init__(**kwargs)
def call(self, y_true, y_pred):
error = y_true - y_pred
is_small_error = tf.abs(error) < self.threshold
squared_loss = tf.square(error) / 2
linear_loss = self.threshold * tf.abs(error) - self.threshold**2 / 2
return tf.where(is_small_error, squared_loss, linear_loss)
def get_config(self):
base_config = super().get_config()
return {**base_config, "threshold": self.threshold}
让我们来看看这段代码:
-
构造函数接受
**kwargs并将它们传递给父构造函数,父构造函数处理标准超参数:损失的name和用于聚合单个实例损失的reduction算法。默认情况下,这是"AUTO",等同于"SUM_OVER_BATCH_SIZE":损失将是实例损失的总和,加权后再除以批量大小(而不是加权平均)。其他可能的值是"SUM"和"NONE"。 -
call()方法接受标签和预测值,计算所有实例损失,并返回它们。 -
get_config()方法返回一个字典,将每个超参数名称映射到其值。它首先调用父类的get_config()方法,然后将新的超参数添加到此字典中。
然后您可以在编译模型时使用此类的任何实例:
model.compile(loss=HuberLoss(2.), optimizer="nadam")
当您保存模型时,阈值将与模型一起保存;当您加载模型时,您只需要将类名映射到类本身:
model = tf.keras.models.load_model("my_model_with_a_custom_loss_class",
custom_objects={"HuberLoss": HuberLoss})
当您保存模型时,Keras 会调用损失实例的get_config()方法,并以 SavedModel 格式保存配置。当您加载模型时,它会在HuberLoss类上调用from_config()类方法:这个方法由基类(Loss)实现,并创建一个类的实例,将**config传递给构造函数。
损失就是这样了!正如您现在将看到的,自定义激活函数、初始化器、正则化器和约束并没有太大不同。
自定义激活函数、初始化器、正则化器和约束
大多数 Keras 功能,如损失、正则化器、约束、初始化器、指标、激活函数、层,甚至完整模型,都可以以类似的方式进行自定义。大多数情况下,您只需要编写一个带有适当输入和输出的简单函数。这里有一个自定义激活函数的示例(相当于tf.keras.activations.softplus()或tf.nn.softplus())、一个自定义 Glorot 初始化器的示例(相当于tf.keras.initializers.glorot_normal())、一个自定义ℓ[1]正则化器的示例(相当于tf.keras.regularizers.l1(0.01))以及一个确保权重都为正的自定义约束的示例(相当于tf.keras.constraints.nonneg()或tf.nn.relu()):
def my_softplus(z):
return tf.math.log(1.0 + tf.exp(z))
def my_glorot_initializer(shape, dtype=tf.float32):
stddev = tf.sqrt(2. / (shape[0] + shape[1]))
return tf.random.normal(shape, stddev=stddev, dtype=dtype)
def my_l1_regularizer(weights):
return tf.reduce_sum(tf.abs(0.01 * weights))
def my_positive_weights(weights): # return value is just tf.nn.relu(weights)
return tf.where(weights < 0., tf.zeros_like(weights), weights)
正如您所看到的,参数取决于自定义函数的类型。然后可以像这里展示的那样正常使用这些自定义函数:
layer = tf.keras.layers.Dense(1, activation=my_softplus,
kernel_initializer=my_glorot_initializer,
kernel_regularizer=my_l1_regularizer,
kernel_constraint=my_positive_weights)
激活函数将应用于此Dense层的输出,并将其结果传递给下一层。层的权重将使用初始化器返回的值进行初始化。在每个训练步骤中,权重将传递给正则化函数以计算正则化损失,然后将其添加到主损失中以获得用于训练的最终损失。最后,在每个训练步骤之后,将调用约束函数,并将层的权重替换为受约束的权重。
如果一个函数有需要与模型一起保存的超参数,那么您将希望子类化适当的类,比如tf.keras.regularizers.Regularizer、tf.keras.constraints.Constraint、tf.keras.initializers.Initializer或tf.keras.layers.Layer(适用于任何层,包括激活函数)。就像您为自定义损失所做的那样,这里是一个简单的ℓ[1]正则化类,它保存了其factor超参数(这次您不需要调用父构造函数或get_config()方法,因为它们不是由父类定义的):
class MyL1Regularizer(tf.keras.regularizers.Regularizer):
def __init__(self, factor):
self.factor = factor
def __call__(self, weights):
return tf.reduce_sum(tf.abs(self.factor * weights))
def get_config(self):
return {"factor": self.factor}
请注意,您必须为损失、层(包括激活函数)和模型实现call()方法,或者为正则化器、初始化器和约束实现__call__()方法。对于指标,情况有些不同,您将立即看到。
自定义指标
损失和指标在概念上并不相同:损失(例如,交叉熵)被梯度下降用来训练模型,因此它们必须是可微的(至少在评估它们的点上),它们的梯度不应该在任何地方都为零。此外,如果它们不容易被人类解释也是可以的。相反,指标(例如,准确率)用于评估模型:它们必须更容易被解释,可以是不可微的或者在任何地方梯度为零。
也就是说,在大多数情况下,定义一个自定义指标函数与定义一个自定义损失函数完全相同。实际上,我们甚至可以使用我们之前创建的 Huber 损失函数作为指标;它会工作得很好(在这种情况下,持久性也会以相同的方式工作,只保存函数的名称"huber_fn",而不是阈值):
model.compile(loss="mse", optimizer="nadam", metrics=[create_huber(2.0)])
在训练期间的每个批次,Keras 将计算这个指标并跟踪自开始时的平均值。大多数情况下,这正是你想要的。但并非总是如此!例如,考虑一个二元分类器的精度。正如你在第三章中看到的,精度是真正例的数量除以正例的预测数量(包括真正例和假正例)。假设模型在第一个批次中做出了五个正面预测,其中四个是正确的:这是 80%的精度。然后假设模型在第二个批次中做出了三个正面预测,但它们全部是错误的:这是第二个批次的 0%精度。如果你只计算这两个精度的平均值,你会得到 40%。但等一下——这不是这两个批次的模型精度!事实上,总共有四个真正例(4 + 0)中的八个正面预测(5 + 3),所以总体精度是 50%,而不是 40%。我们需要的是一个对象,它可以跟踪真正例的数量和假正例的数量,并且可以在需要时基于这些数字计算精度。这正是tf.keras.metrics.Precision类所做的:
>>> precision = tf.keras.metrics.Precision()
>>> precision([0, 1, 1, 1, 0, 1, 0, 1], [1, 1, 0, 1, 0, 1, 0, 1])
<tf.Tensor: shape=(), dtype=float32, numpy=0.8>
>>> precision([0, 1, 0, 0, 1, 0, 1, 1], [1, 0, 1, 1, 0, 0, 0, 0])
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>
在这个例子中,我们创建了一个Precision对象,然后像一个函数一样使用它,为第一个批次传递标签和预测,然后为第二个批次(如果需要,还可以传递样本权重)。我们使用了与刚才讨论的示例中相同数量的真正例和假正例。在第一个批次之后,它返回 80%的精度;然后在第二个批次之后,它返回 50%(这是到目前为止的总体精度,而不是第二个批次的精度)。这被称为流式指标(或有状态指标),因为它逐渐更新,批次之后。
在任何时候,我们可以调用result()方法来获取指标的当前值。我们还可以通过使用variables属性查看其变量(跟踪真正例和假正例的数量),并可以使用reset_states()方法重置这些变量:
>>> precision.result()
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>
>>> precision.variables
[<tf.Variable 'true_positives:0' [...], numpy=array([4.], dtype=float32)>,
<tf.Variable 'false_positives:0' [...], numpy=array([4.], dtype=float32)>]
>>> precision.reset_states() # both variables get reset to 0.0
如果需要定义自己的自定义流式指标,创建tf.keras.metrics.Metric类的子类。这里是一个基本示例,它跟踪总 Huber 损失和迄今为止看到的实例数量。当要求结果时,它返回比率,这只是平均 Huber 损失:
class HuberMetric(tf.keras.metrics.Metric):
def __init__(self, threshold=1.0, **kwargs):
super().__init__(**kwargs) # handles base args (e.g., dtype)
self.threshold = threshold
self.huber_fn = create_huber(threshold)
self.total = self.add_weight("total", initializer="zeros")
self.count = self.add_weight("count", initializer="zeros")
def update_state(self, y_true, y_pred, sample_weight=None):
sample_metrics = self.huber_fn(y_true, y_pred)
self.total.assign_add(tf.reduce_sum(sample_metrics))
self.count.assign_add(tf.cast(tf.size(y_true), tf.float32))
def result(self):
return self.total / self.count
def get_config(self):
base_config = super().get_config()
return {**base_config, "threshold": self.threshold}
让我们走一遍这段代码:
-
构造函数使用
add_weight()方法创建需要在多个批次中跟踪指标状态的变量——在这种情况下,所有 Huber 损失的总和(total)和迄今为止看到的实例数量(count)。如果愿意,你也可以手动创建变量。Keras 跟踪任何设置为属性的tf.Variable(更一般地,任何“可跟踪”的对象,如层或模型)。 -
当你将这个类的实例用作函数时(就像我们用
Precision对象做的那样),update_state()方法会被调用。它根据一个批次的标签和预测更新变量(以及样本权重,但在这种情况下我们忽略它们)。 -
result()方法计算并返回最终结果,在这种情况下是所有实例上的平均 Huber 指标。当你将指标用作函数时,首先调用update_state()方法,然后调用result()方法,并返回其输出。 -
我们还实现了
get_config()方法,以确保threshold与模型一起保存。 -
reset_states()方法的默认实现将所有变量重置为 0.0(但如果需要,你可以覆盖它)。
注意
Keras 会无缝处理变量持久性;不需要任何操作。
当你使用简单函数定义指标时,Keras 会自动为每个批次调用它,并在每个时期期间跟踪平均值,就像我们手动做的那样。因此,我们的HuberMetric类的唯一好处是threshold将被保存。但当然,有些指标,比如精度,不能简单地在批次上进行平均:在这些情况下,除了实现流式指标之外别无选择。
现在你已经构建了一个流式指标,构建一个自定义层将会变得轻而易举!
自定义层
有时候你可能想要构建一个包含一种 TensorFlow 没有提供默认实现的奇特层的架构。或者你可能只是想要构建一个非常重复的架构,在这种架构中,一个特定的层块被重复多次,将每个块视为单个层会很方便。对于这些情况,你会想要构建一个自定义层。
有一些没有权重的层,比如tf.keras.layers.Flatten或tf.keras.layers.ReLU。如果你想创建一个没有任何权重的自定义层,最简单的方法是编写一个函数并将其包装在tf.keras.layers.Lambda层中。例如,以下层将对其输入应用指数函数:
exponential_layer = tf.keras.layers.Lambda(lambda x: tf.exp(x))
然后,这个自定义层可以像任何其他层一样使用,使用序贯 API、函数式 API 或子类 API。你也可以将它用作激活函数,或者你可以使用activation=tf.exp。指数层有时用于回归模型的输出层,当要预测的值具有非常不同的规模时(例如,0.001、10.、1,000.)。事实上,指数函数是 Keras 中的标准激活函数之一,所以你可以简单地使用activation="exponential"。
你可能会猜到,要构建一个自定义的有状态层(即带有权重的层),你需要创建tf.keras.layers.Layer类的子类。例如,以下类实现了Dense层的简化版本:
class MyDense(tf.keras.layers.Layer):
def __init__(self, units, activation=None, **kwargs):
super().__init__(**kwargs)
self.units = units
self.activation = tf.keras.activations.get(activation)
def build(self, batch_input_shape):
self.kernel = self.add_weight(
name="kernel", shape=[batch_input_shape[-1], self.units],
initializer="glorot_normal")
self.bias = self.add_weight(
name="bias", shape=[self.units], initializer="zeros")
def call(self, X):
return self.activation(X @ self.kernel + self.bias)
def get_config(self):
base_config = super().get_config()
return {**base_config, "units": self.units,
"activation": tf.keras.activations.serialize(self.activation)}
让我们来看看这段代码:
-
构造函数将所有超参数作为参数(在这个例子中是
units和activation),并且重要的是它还接受一个**kwargs参数。它调用父构造函数,将kwargs传递给它:这会处理标准参数,如input_shape、trainable和name。然后它将超参数保存为属性,使用tf.keras.activations.get()函数将activation参数转换为适当的激活函数(它接受函数、标准字符串如"relu"或"swish",或者简单地None)。 -
build()方法的作用是通过为每个权重调用add_weight()方法来创建层的变量。build()方法在第一次使用该层时被调用。在那时,Keras 将知道该层输入的形状,并将其传递给build()方法,这通常是创建一些权重所必需的。例如,我们需要知道前一层中的神经元数量以创建连接权重矩阵(即"kernel"):这对应于输入的最后一个维度的大小。在build()方法的最后(仅在最后),您必须调用父类的build()方法:这告诉 Keras 该层已构建(它只是设置self.built = True)。 -
call()方法执行所需的操作。在这种情况下,我们计算输入X和层的内核的矩阵乘法,添加偏置向量,并将激活函数应用于结果,这给出了层的输出。 -
get_config()方法与以前的自定义类中的方法一样。请注意,通过调用tf.keras.activations.serialize()保存激活函数的完整配置。
现在您可以像使用任何其他层一样使用MyDense层!
注意
Keras 会自动推断输出形状,除非该层是动态的(稍后将看到)。在这种(罕见)情况下,您需要实现compute_output_shape()方法,该方法必须返回一个TensorShape对象。
要创建具有多个输入的层(例如,Concatenate),call()方法的参数应该是一个包含所有输入的元组。要创建具有多个输出的层,call()方法应该返回输出的列表。例如,以下示例玩具层接受两个输入并返回三个输出:
class MyMultiLayer(tf.keras.layers.Layer):
def call(self, X):
X1, X2 = X
return X1 + X2, X1 * X2, X1 / X2
这个层现在可以像任何其他层一样使用,但当然只能使用功能 API 和子类 API,而不能使用顺序 API(顺序 API 只接受具有一个输入和一个输出的层)。
如果您的层在训练和测试期间需要具有不同的行为(例如,如果它使用Dropout或BatchNormalization层),那么您必须在call()方法中添加一个training参数,并使用此参数来决定要执行什么操作。例如,让我们创建一个在训练期间添加高斯噪声(用于正则化)但在测试期间不执行任何操作的层(Keras 有一个执行相同操作的层,tf.keras.layers.GaussianNoise):
class MyGaussianNoise(tf.keras.layers.Layer):
def __init__(self, stddev, **kwargs):
super().__init__(**kwargs)
self.stddev = stddev
def call(self, X, training=False):
if training:
noise = tf.random.normal(tf.shape(X), stddev=self.stddev)
return X + noise
else:
return X
有了这个,您现在可以构建任何您需要的自定义层!现在让我们看看如何创建自定义模型。
自定义模型
我们已经在第十章中讨论了使用子类 API 创建自定义模型类。这很简单:子类化tf.keras.Model类,在构造函数中创建层和变量,并实现call()方法以执行您希望模型执行的操作。例如,假设我们想要构建图 12-3 中表示的模型。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1203.png
图 12-3。自定义模型示例:一个包含跳过连接的自定义ResidualBlock层的任意模型
输入首先经过一个密集层,然后通过由两个密集层和一个加法操作组成的残差块(如您将在第十四章中看到的,残差块将其输入添加到其输出中),然后通过这个相同的残差块再进行三次,然后通过第二个残差块,最终结果通过一个密集输出层。如果这个模型看起来没有太多意义,不要担心;这只是一个示例,说明您可以轻松构建任何您想要的模型,甚至包含循环和跳过连接的模型。要实现这个模型,最好首先创建一个ResidualBlock层,因为我们将创建一对相同的块(并且可能希望在另一个模型中重用它):
class ResidualBlock(tf.keras.layers.Layer):
def __init__(self, n_layers, n_neurons, **kwargs):
super().__init__(**kwargs)
self.hidden = [tf.keras.layers.Dense(n_neurons, activation="relu",
kernel_initializer="he_normal")
for _ in range(n_layers)]
def call(self, inputs):
Z = inputs
for layer in self.hidden:
Z = layer(Z)
return inputs + Z
这个层有点特殊,因为它包含其他层。Keras 会自动处理这一点:它会自动检测hidden属性包含可跟踪对象(在这种情况下是层),因此它们的变量会自动添加到此层的变量列表中。这个类的其余部分是不言自明的。接下来,让我们使用子类 API 来定义模型本身:
class ResidualRegressor(tf.keras.Model):
def __init__(self, output_dim, **kwargs):
super().__init__(**kwargs)
self.hidden1 = tf.keras.layers.Dense(30, activation="relu",
kernel_initializer="he_normal")
self.block1 = ResidualBlock(2, 30)
self.block2 = ResidualBlock(2, 30)
self.out = tf.keras.layers.Dense(output_dim)
def call(self, inputs):
Z = self.hidden1(inputs)
for _ in range(1 + 3):
Z = self.block1(Z)
Z = self.block2(Z)
return self.out(Z)
我们在构造函数中创建层,并在call()方法中使用它们。然后可以像任何其他模型一样使用此模型(编译、拟合、评估和使用它进行预测)。如果您还希望能够使用save()方法保存模型,并使用tf.keras.models.load_model()函数加载模型,则必须在ResidualBlock类和ResidualRegressor类中实现get_config()方法(就像我们之前做的那样)。或者,您可以使用save_weights()和load_weights()方法保存和加载权重。
Model类是Layer类的子类,因此模型可以像层一样定义和使用。但是模型具有一些额外的功能,包括当然包括compile()、fit()、evaluate()和predict()方法(以及一些变体),还有get_layer()方法(可以通过名称或索引返回模型的任何层)和save()方法(以及对tf.keras.models.load_model()和tf.keras.models.clone_model()的支持)。
提示
如果模型提供的功能比层更多,为什么不将每个层都定义为模型呢?技术上您可以这样做,但通常更清晰的做法是区分模型的内部组件(即层或可重用的层块)和模型本身(即您将训练的对象)。前者应该是Layer类的子类,而后者应该是Model类的子类。
有了这些,您可以自然而简洁地构建几乎任何您在论文中找到的模型,使用顺序 API、函数 API、子类 API,甚至这些的混合。“几乎”任何模型?是的,还有一些事情我们需要看一下:首先是如何基于模型内部定义损失或指标,其次是如何构建自定义训练循环。
基于模型内部的损失和指标
我们之前定义的自定义损失和指标都是基于标签和预测(以及可选的样本权重)。有时您可能希望基于模型的其他部分(例如其隐藏层的权重或激活)定义损失。这可能对正则化目的或监视模型的某些内部方面很有用。
要基于模型内部定义自定义损失,可以根据模型的任何部分计算损失,然后将结果传递给add_loss()方法。例如,让我们构建一个由五个隐藏层堆叠加一个输出层组成的自定义回归 MLP 模型。这个自定义模型还将在最上面的隐藏层之上具有一个辅助输出。与这个辅助输出相关联的损失将被称为重建损失(参见第十七章):它是重建和输入之间的均方差差异。通过将这个重建损失添加到主要损失中,我们将鼓励模型通过隐藏层尽可能保留更多信息,即使这些信息对于回归任务本身并不直接有用。在实践中,这种损失有时会改善泛化能力(它是一种正则化损失)。还可以使用模型的add_metric()方法添加自定义指标。以下是具有自定义重建损失和相应指标的自定义模型的代码:
class ReconstructingRegressor(tf.keras.Model):
def __init__(self, output_dim, **kwargs):
super().__init__(**kwargs)
self.hidden = [tf.keras.layers.Dense(30, activation="relu",
kernel_initializer="he_normal")
for _ in range(5)]
self.out = tf.keras.layers.Dense(output_dim)
self.reconstruction_mean = tf.keras.metrics.Mean(
name="reconstruction_error")
def build(self, batch_input_shape):
n_inputs = batch_input_shape[-1]
self.reconstruct = tf.keras.layers.Dense(n_inputs)
def call(self, inputs, training=False):
Z = inputs
for layer in self.hidden:
Z = layer(Z)
reconstruction = self.reconstruct(Z)
recon_loss = tf.reduce_mean(tf.square(reconstruction - inputs))
self.add_loss(0.05 * recon_loss)
if training:
result = self.reconstruction_mean(recon_loss)
self.add_metric(result)
return self.out(Z)
让我们来看一下这段代码:
-
构造函数创建了一个具有五个密集隐藏层和一个密集输出层的 DNN。我们还创建了一个
Mean流式指标,用于在训练过程中跟踪重建误差。 -
build()方法创建一个额外的密集层,用于重构模型的输入。它必须在这里创建,因为其单元数必须等于输入的数量,在调用build()方法之前这个数量是未知的。 -
call()方法通过所有五个隐藏层处理输入,然后将结果传递给重构层,该层生成重构。 -
然后
call()方法计算重构损失(重构和输入之间的均方差),并使用add_loss()方法将其添加到模型的损失列表中。请注意,我们通过将重构损失乘以 0.05 来缩小重构损失(这是一个可以调整的超参数)。这确保了重构损失不会主导主要损失。 -
接下来,在训练过程中,
call()方法更新重构度量并将其添加到模型中以便显示。这段代码示例实际上可以通过调用self.add_metric(recon_loss)来简化:Keras 将自动为您跟踪均值。 -
最后,
call()方法将隐藏层的输出传递给输出层,并返回其输出。
在训练过程中,总损失和重构损失都会下降:
Epoch 1/5
363/363 [========] - 1s 820us/step - loss: 0.7640 - reconstruction_error: 1.2728
Epoch 2/5
363/363 [========] - 0s 809us/step - loss: 0.4584 - reconstruction_error: 0.6340
[...]
在大多数情况下,到目前为止我们讨论的一切将足以实现您想构建的任何模型,即使是具有复杂架构、损失和指标。然而,对于一些架构,如 GANs(参见第十七章),您将不得不自定义训练循环本身。在我们到达那里之前,我们必须看看如何在 TensorFlow 中自动计算梯度。
使用自动微分计算梯度
要了解如何使用自动微分(参见第十章和附录 B)自动计算梯度,让我们考虑一个简单的玩具函数:
def f(w1, w2):
return 3 * w1 ** 2 + 2 * w1 * w2
如果你懂微积分,你可以分析地找到这个函数相对于w1的偏导数是6 * w1 + 2 * w2。你也可以找到它相对于w2的偏导数是2 * w1。例如,在点(w1, w2) = (5, 3),这些偏导数分别等于 36 和 10,因此在这一点的梯度向量是(36,10)。但如果这是一个神经网络,这个函数会复杂得多,通常有数万个参数,通过手工分析找到偏导数将是一个几乎不可能的任务。一个解决方案是通过测量当你微调相应参数一点点时函数的输出如何变化来计算每个偏导数的近似值:
>>> w1, w2 = 5, 3
>>> eps = 1e-6
>>> (f(w1 + eps, w2) - f(w1, w2)) / eps
36.000003007075065
>>> (f(w1, w2 + eps) - f(w1, w2)) / eps
10.000000003174137
看起来不错!这个方法运行得相当好,而且易于实现,但它只是一个近似值,重要的是你需要至少针对每个参数调用一次f()(不是两次,因为我们可以只计算一次f(w1, w2))。每个参数至少调用一次f()使得这种方法在大型神经网络中变得难以处理。因此,我们应该使用反向模式自动微分。TensorFlow 使这变得非常简单:
w1, w2 = tf.Variable(5.), tf.Variable(3.)
with tf.GradientTape() as tape:
z = f(w1, w2)
gradients = tape.gradient(z, [w1, w2])
首先我们定义两个变量w1和w2,然后我们创建一个tf.GradientTape上下文,它将自动记录涉及变量的每个操作,最后我们要求这个磁带计算结果z相对于两个变量[w1, w2]的梯度。让我们看看 TensorFlow 计算的梯度:
>>> gradients
[<tf.Tensor: shape=(), dtype=float32, numpy=36.0>,
<tf.Tensor: shape=(), dtype=float32, numpy=10.0>]
太棒了!结果不仅准确(精度仅受浮点误差限制),而且gradient()方法只需通过记录的计算一次(按相反顺序),无论有多少变量,因此非常高效。就像魔术一样!
提示
为了节省内存,在tf.GradientTape()块中只放入严格的最小值。或者,通过在tf.GradientTape()块内创建一个with tape.stop_recording()块来暂停记录。
在调用其gradient()方法后,磁带会立即被擦除,因此如果尝试两次调用gradient(),将会收到异常:
with tf.GradientTape() as tape:
z = f(w1, w2)
dz_dw1 = tape.gradient(z, w1) # returns tensor 36.0
dz_dw2 = tape.gradient(z, w2) # raises a RuntimeError!
如果您需要多次调用gradient(),您必须使磁带持久化,并在每次完成后删除它以释放资源:
with tf.GradientTape(persistent=True) as tape:
z = f(w1, w2)
dz_dw1 = tape.gradient(z, w1) # returns tensor 36.0
dz_dw2 = tape.gradient(z, w2) # returns tensor 10.0, works fine now!
del tape
默认情况下,磁带只会跟踪涉及变量的操作,因此,如果您尝试计算z相对于除变量以外的任何东西的梯度,结果将是None:
c1, c2 = tf.constant(5.), tf.constant(3.)
with tf.GradientTape() as tape:
z = f(c1, c2)
gradients = tape.gradient(z, [c1, c2]) # returns [None, None]
但是,您可以强制磁带监视任何您喜欢的张量,记录涉及它们的每个操作。然后,您可以计算相对于这些张量的梯度,就像它们是变量一样:
with tf.GradientTape() as tape:
tape.watch(c1)
tape.watch(c2)
z = f(c1, c2)
gradients = tape.gradient(z, [c1, c2]) # returns [tensor 36., tensor 10.]
在某些情况下,这可能很有用,比如如果您想要实现一个正则化损失,惩罚激活在输入变化很小时变化很大的情况:损失将基于激活相对于输入的梯度。由于输入不是变量,您需要告诉磁带监视它们。
大多数情况下,梯度磁带用于计算单个值(通常是损失)相对于一组值(通常是模型参数)的梯度。这就是反向模式自动微分的优势所在,因为它只需要进行一次前向传递和一次反向传递就可以一次性获得所有梯度。如果尝试计算向量的梯度,例如包含多个损失的向量,那么 TensorFlow 将计算向量总和的梯度。因此,如果您需要获取各个梯度(例如,每个损失相对于模型参数的梯度),您必须调用磁带的jacobian()方法:它将为向量中的每个损失执行一次反向模式自动微分(默认情况下全部并行)。甚至可以计算二阶偏导数(Hessians,即偏导数的偏导数),但在实践中很少需要(请参阅本章笔记本的“使用自动微分计算梯度”部分以获取示例)。
在某些情况下,您可能希望阻止梯度通过神经网络的某些部分进行反向传播。为此,您必须使用tf.stop_gradient()函数。该函数在前向传递期间返回其输入(类似于tf.identity()),但在反向传播期间不允许梯度通过(它的作用类似于常数):
def f(w1, w2):
return 3 * w1 ** 2 + tf.stop_gradient(2 * w1 * w2)
with tf.GradientTape() as tape:
z = f(w1, w2) # the forward pass is not affected by stop_gradient()
gradients = tape.gradient(z, [w1, w2]) # returns [tensor 30., None]
最后,当计算梯度时,您可能偶尔会遇到一些数值问题。例如,如果在x=10^(-50)处计算平方根函数的梯度,结果将是无穷大。实际上,该点的斜率并不是无穷大,但它超过了 32 位浮点数的处理能力:
>>> x = tf.Variable(1e-50)
>>> with tf.GradientTape() as tape:
... z = tf.sqrt(x)
...
>>> tape.gradient(z, [x])
[<tf.Tensor: shape=(), dtype=float32, numpy=inf>]
为了解决这个问题,在计算平方根时,通常建议向x(例如 10^(-6))添加一个微小值。
指数函数也经常引起头痛,因为它增长非常快。例如,之前定义的my_softplus()的方式在数值上不稳定。如果计算my_softplus(100.0),您将得到无穷大而不是正确的结果(约为 100)。但是可以重写该函数以使其在数值上稳定:softplus 函数被定义为 log(1 + exp(z)),这也等于 log(1 + exp(–|z|)) + max(z, 0)(请参阅数学证明的笔记本),第二种形式的优势在于指数项不会爆炸。因此,这是my_softplus()函数的更好实现:
def my_softplus(z):
return tf.math.log(1 + tf.exp(-tf.abs(z))) + tf.maximum(0., z)
在一些罕见的情况下,一个数值稳定的函数可能仍然具有数值不稳定的梯度。在这种情况下,你将不得不告诉 TensorFlow 使用哪个方程来计算梯度,而不是让它使用自动微分。为此,你必须在定义函数时使用@tf.custom_gradient装饰器,并返回函数的通常结果以及计算梯度的函数。例如,让我们更新my_softplus()函数,使其也返回一个数值稳定的梯度函数:
@tf.custom_gradient
def my_softplus(z):
def my_softplus_gradients(grads): # grads = backprop'ed from upper layers
return grads * (1 - 1 / (1 + tf.exp(z))) # stable grads of softplus
result = tf.math.log(1 + tf.exp(-tf.abs(z))) + tf.maximum(0., z)
return result, my_softplus_gradients
如果你懂微积分(参见关于这个主题的教程笔记本),你会发现 log(1 + exp(z))的导数是 exp(z) / (1 + exp(z))。但这种形式是不稳定的:对于较大的z值,它最终会计算出无穷大除以无穷大,返回 NaN。然而,通过一点代数操作,你可以证明它也等于 1 - 1 / (1 + exp(z)),这是稳定的。my_softplus_gradients()函数使用这个方程来计算梯度。请注意,这个函数将接收到目前为止反向传播的梯度,一直到my_softplus()函数,并根据链式法则,我们必须将它们与这个函数的梯度相乘。
现在当我们计算my_softplus()函数的梯度时,即使对于较大的输入值,我们也会得到正确的结果。
恭喜!现在你可以计算任何函数的梯度(只要在计算时它是可微的),甚至在需要时阻止反向传播,并编写自己的梯度函数!这可能比你需要的灵活性更多,即使你构建自己的自定义训练循环。接下来你将看到如何做到这一点。
自定义训练循环
在某些情况下,fit()方法可能不够灵活以满足你的需求。例如,我们在第十章中讨论的Wide & Deep 论文使用了两种不同的优化器:一种用于宽路径,另一种用于深路径。由于fit()方法只使用一个优化器(在编译模型时指定的那个),实现这篇论文需要编写自己的自定义循环。
你可能也喜欢编写自定义训练循环,只是为了更有信心地确保它们确实按照你的意图执行(也许你对fit()方法的一些细节不确定)。有时候,让一切都显式化可能会感觉更安全。然而,请记住,编写自定义训练循环会使你的代码变得更长、更容易出错,并且更难维护。
提示
除非你在学习或确实需要额外的灵活性,否则应该优先使用fit()方法而不是实现自己的训练循环,特别是如果你在团队中工作。
首先,让我们构建一个简单的模型。不需要编译它,因为我们将手动处理训练循环:
l2_reg = tf.keras.regularizers.l2(0.05)
model = tf.keras.models.Sequential([
tf.keras.layers.Dense(30, activation="relu", kernel_initializer="he_normal",
kernel_regularizer=l2_reg),
tf.keras.layers.Dense(1, kernel_regularizer=l2_reg)
])
接下来,让我们创建一个小函数,从训练集中随机抽取一个批次的实例(在第十三章中,我们将讨论 tf.data API,它提供了一个更好的替代方案):
def random_batch(X, y, batch_size=32):
idx = np.random.randint(len(X), size=batch_size)
return X[idx], y[idx]
让我们还定义一个函数,用于显示训练状态,包括步数、总步数、自开始时的平均损失(我们将使用Mean指标来计算),以及其他指标:
def print_status_bar(step, total, loss, metrics=None):
metrics = " - ".join([f"{m.name}: {m.result():.4f}"
for m in [loss] + (metrics or [])])
end = "" if step < total else "\n"
print(f"\r{step}/{total} - " + metrics, end=end)
这段代码很容易理解,除非你不熟悉 Python 的字符串格式化:{m.result():.4f}将指标的结果格式化为小数点后四位的浮点数,使用\r(回车)和end=""确保状态栏始终打印在同一行上。
有了这个,让我们开始吧!首先,我们需要定义一些超参数,并选择优化器、损失函数和指标(在这个例子中只有 MAE):
n_epochs = 5
batch_size = 32
n_steps = len(X_train) // batch_size
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01)
loss_fn = tf.keras.losses.mean_squared_error
mean_loss = tf.keras.metrics.Mean(name="mean_loss")
metrics = [tf.keras.metrics.MeanAbsoluteError()]
现在我们准备构建自定义循环了!
for epoch in range(1, n_epochs + 1):
print("Epoch {}/{}".format(epoch, n_epochs))
for step in range(1, n_steps + 1):
X_batch, y_batch = random_batch(X_train_scaled, y_train)
with tf.GradientTape() as tape:
y_pred = model(X_batch, training=True)
main_loss = tf.reduce_mean(loss_fn(y_batch, y_pred))
loss = tf.add_n([main_loss] + model.losses)
gradients = tape.gradient(loss, model.trainable_variables)
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
mean_loss(loss)
for metric in metrics:
metric(y_batch, y_pred)
print_status_bar(step, n_steps, mean_loss, metrics)
for metric in [mean_loss] + metrics:
metric.reset_states()
这段代码中有很多内容,让我们来逐步解释一下:
-
我们创建两个嵌套循环:一个用于时期,另一个用于时期内的批次。
-
然后我们从训练集中抽取一个随机批次。
-
在
tf.GradientTape()块内,我们对一个批次进行预测,使用模型作为一个函数,并计算损失:它等于主要损失加上其他损失(在这个模型中,每层有一个正则化损失)。由于mean_squared_error()函数返回每个实例的一个损失,我们使用tf.reduce_mean()计算批次的平均值(如果您想对每个实例应用不同的权重,这就是您应该做的地方)。正则化损失已经被减少为每个单一标量,所以我们只需要对它们求和(使用tf.add_n(),它对相同形状和数据类型的多个张量求和)。 -
接下来,我们要求磁带计算损失相对于每个可训练变量的梯度——不是所有变量!——并将它们应用于优化器以执行梯度下降步骤。
-
然后我们更新平均损失和指标(在当前时期内),并显示状态栏。
-
在每个时期结束时,我们重置平均损失和指标的状态。
如果您想应用梯度裁剪(参见第十一章),请设置优化器的 clipnorm 或 clipvalue 超参数。如果您想对梯度应用任何其他转换,只需在调用 apply_gradients() 方法之前这样做。如果您想向模型添加权重约束(例如,在创建层时设置 kernel_constraint 或 bias_constraint),您应该更新训练循环以在 apply_gradients() 之后应用这些约束,就像这样:
for variable in model.variables:
if variable.constraint is not None:
variable.assign(variable.constraint(variable))
警告
在训练循环中调用模型时不要忘记设置 training=True,特别是如果您的模型在训练和测试期间表现不同(例如,如果它使用 BatchNormalization 或 Dropout)。如果是自定义模型,请确保将 training 参数传播到您的模型调用的层。
正如您所看到的,有很多事情需要做对,很容易出错。但好的一面是,您可以完全控制。
现在您知道如何自定义模型的任何部分¹⁵和训练算法,让我们看看如何使用 TensorFlow 的自动生成图形功能:它可以显著加快您的自定义代码,并且还将其移植到 TensorFlow 支持的任何平台(参见第十九章)。
TensorFlow 函数和图形
回到 TensorFlow 1,图形是不可避免的(伴随着复杂性),因为它们是 TensorFlow API 的核心部分。自从 TensorFlow 2(2019 年发布)以来,图形仍然存在,但不再是核心部分,而且使用起来简单得多(多得多!)。为了展示它们有多简单,让我们从一个计算其输入的立方的微不足道的函数开始:
def cube(x):
return x ** 3
我们显然可以使用 Python 值(如整数或浮点数)调用此函数,或者我们可以使用张量调用它:
>>> cube(2)
8
>>> cube(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>
现在,让我们使用 tf.function() 将这个 Python 函数转换为 TensorFlow 函数:
>>> tf_cube = tf.function(cube)
>>> tf_cube
<tensorflow.python.eager.def_function.Function at 0x7fbfe0c54d50>
然后,这个 TF 函数可以像原始的 Python 函数一样使用,并且将返回相同的结果(但始终作为张量):
>>> tf_cube(2)
<tf.Tensor: shape=(), dtype=int32, numpy=8>
>>> tf_cube(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>
在幕后,tf.function() 分析了 cube() 函数执行的计算,并生成了一个等效的计算图!正如您所看到的,这是相当轻松的(我们很快会看到这是如何工作的)。或者,我们也可以将 tf.function 用作装饰器;这实际上更常见:
@tf.function
def tf_cube(x):
return x ** 3
原始的 Python 函数仍然可以通过 TF 函数的 python_function 属性访问,以防您需要它:
>>> tf_cube.python_function(2)
8
TensorFlow 优化计算图,修剪未使用的节点,简化表达式(例如,1 + 2 将被替换为 3)等。一旦优化的图准备就绪,TF 函数将有效地执行图中的操作,按适当的顺序(并在可能时并行执行)。因此,TF 函数通常比原始 Python 函数运行得快得多,特别是如果它执行复杂计算。大多数情况下,您实际上不需要知道更多:当您想要提升 Python 函数时,只需将其转换为 TF 函数。就这样!
此外,如果在调用tf.function()时设置jit_compile=True,那么 TensorFlow 将使用加速线性代数(XLA)为您的图编译专用内核,通常融合多个操作。例如,如果您的 TF 函数调用tf.reduce_sum(a * b + c),那么没有 XLA,函数首先需要计算a * b并将结果存储在临时变量中,然后将c添加到该变量中,最后调用tf.reduce_sum()处理结果。使用 XLA,整个计算将编译为单个内核,该内核将一次性计算tf.reduce_sum(a * b + c),而不使用任何大型临时变量。这不仅速度更快,而且使用的 RAM 大大减少。
当您编写自定义损失函数、自定义指标、自定义层或任何其他自定义函数,并在 Keras 模型中使用它(就像我们在本章中一直做的那样),Keras 会自动将您的函数转换为 TF 函数——无需使用tf.function()。因此,大多数情况下,这种魔术是 100%透明的。如果您希望 Keras 使用 XLA,只需在调用compile()方法时设置jit_compile=True。简单!
提示
您可以通过在创建自定义层或自定义模型时设置dynamic=True来告诉 Keras不将您的 Python 函数转换为 TF 函数。或者,您可以在调用模型的compile()方法时设置run_eagerly=True。
默认情况下,TF 函数为每个唯一的输入形状和数据类型生成一个新图,并将其缓存以供后续调用。例如,如果您调用tf_cube(tf.constant(10)),将为形状为[]的 int32 张量生成一个图。然后,如果您调用tf_cube(tf.constant(20)),将重用相同的图。但是,如果您随后调用tf_cube(tf.constant([10, 20])),将为形状为[2]的 int32 张量生成一个新图。这就是 TF 函数处理多态性(即不同的参数类型和形状)的方式。但是,这仅适用于张量参数:如果将数值 Python 值传递给 TF 函数,则将为每个不同的值生成一个新图:例如,调用tf_cube(10)和tf_cube(20)将生成两个图。
警告
如果您多次使用不同的数值 Python 值调用 TF 函数,则将生成许多图,减慢程序速度并使用大量 RAM(您必须删除 TF 函数才能释放它)。Python 值应保留用于将具有少量唯一值的参数,例如每层神经元的数量之类的超参数。这样可以使 TensorFlow 更好地优化模型的每个变体。
AutoGraph 和跟踪
那么 TensorFlow 如何生成图呢?它首先通过分析 Python 函数的源代码来捕获所有控制流语句,比如for循环、while循环和if语句,以及break、continue和return语句。这第一步被称为AutoGraph。TensorFlow 必须分析源代码的原因是 Python 没有提供其他捕获控制流语句的方法:它提供了像__add__()和__mul__()这样的魔术方法来捕获+和*等运算符,但没有__while__()或__if__()这样的魔术方法。在分析函数代码之后,AutoGraph 会输出一个升级版本的函数,其中所有控制流语句都被适当的 TensorFlow 操作替换,比如tf.while_loop()用于循环,tf.cond()用于if语句。例如,在图 12-4 中,AutoGraph 分析了sum_squares() Python 函数的源代码,并生成了tf__sum_squares()函数。在这个函数中,for循环被替换为loop_body()函数的定义(包含原始for循环的主体),然后调用for_stmt()函数。这个调用将在计算图中构建适当的tf.while_loop()操作。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1204.png
图 12-4. TensorFlow 如何使用 AutoGraph 和跟踪生成图
接下来,TensorFlow 调用这个“升级”函数,但不是传递参数,而是传递一个符号张量—一个没有实际值的张量,只有一个名称、一个数据类型和一个形状。例如,如果您调用sum_squares(tf.constant(10)),那么tf__sum_squares()函数将被调用,传递一个类型为 int32、形状为[]的符号张量。该函数将在图模式下运行,这意味着每个 TensorFlow 操作都会在图中添加一个节点来表示自己和其输出张量(与常规模式相反,称为急切执行或急切模式)。在图模式下,TF 操作不执行任何计算。图模式是 TensorFlow 1 中的默认模式。在图 12-4 中,您可以看到tf__sum_squares()函数被调用,其参数是一个符号张量(在这种情况下,一个形状为[]的 int32 张量),以及在跟踪期间生成的最终图。节点表示操作,箭头表示张量(生成的函数和图都被简化了)。
提示
为了查看生成的函数源代码,您可以调用tf.autograph.to_code(sum_squares.python_function)。代码并不一定要漂亮,但有时可以帮助调试。
TF 函数规则
大多数情况下,将执行 TensorFlow 操作的 Python 函数转换为 TF 函数是微不足道的:用@tf.function装饰它,或者让 Keras 为您处理。但是,有一些规则需要遵守:
-
如果调用任何外部库,包括 NumPy 甚至标准库,这个调用只会在跟踪期间运行;它不会成为图的一部分。实际上,TensorFlow 图只能包括 TensorFlow 构造(张量、操作、变量、数据集等)。因此,请确保使用
tf.reduce_sum()而不是np.sum(),tf.sort()而不是内置的sorted()函数,等等(除非您真的希望代码只在跟踪期间运行)。这还有一些额外的影响:-
如果您定义了一个 TF 函数
f(*x*),它只返回np.random.rand(),那么只有在跟踪函数时才会生成一个随机数,因此f(tf.constant(2.))和f(tf.constant(3.))将返回相同的随机数,但f(tf.constant([2., 3.]))将返回一个不同的随机数。如果将np.random.rand()替换为tf.random.uniform([]),那么每次调用都会生成一个新的随机数,因为该操作将成为图的一部分。 -
如果您的非 TensorFlow 代码具有副作用(例如记录某些内容或更新 Python 计数器),那么您不应该期望每次调用 TF 函数时都会发生这些副作用,因为它们只会在函数被跟踪时发生。
-
您可以在
tf.py_function()操作中包装任意的 Python 代码,但这样做会影响性能,因为 TensorFlow 将无法对此代码进行任何图优化。这也会降低可移植性,因为图仅在安装了正确库的平台上运行 Python 可用(和 Python 可用的平台)。
-
-
您可以调用其他 Python 函数或 TF 函数,但它们应该遵循相同的规则,因为 TensorFlow 将捕获它们的操作在计算图中。请注意,这些其他函数不需要用
@tf.function装饰。 -
如果函数创建了 TensorFlow 变量(或任何其他有状态的 TensorFlow 对象,例如数据集或队列),它必须在第一次调用时才能这样做,否则您将收到异常。通常最好在 TF 函数之外创建变量(例如,在自定义层的
build()方法中)。如果要为变量分配新值,请确保调用其assign()方法,而不是使用=运算符。 -
您的 Python 函数的源代码应该对 TensorFlow 可用。如果源代码不可用(例如,如果您在 Python shell 中定义函数,无法访问源代码,或者如果您仅将编译后的 *.pyc Python 文件部署到生产环境),则图生成过程将失败或功能有限。
-
TensorFlow 仅会捕获对张量或
tf.data.Dataset进行迭代的for循环(请参见第十三章)。因此,请确保使用for i in tf.range(*x*)而不是for i in range(*x*),否则循环将不会在图中被捕获。相反,它将在跟踪期间运行。(如果for循环旨在构建图,例如在神经网络中创建每个层,那么这可能是您想要的。) -
一如既往,出于性能原因,您应该尽可能使用矢量化实现,而不是使用循环。
是时候总结了!在本章中,我们从 TensorFlow 的简要概述开始,然后看了 TensorFlow 的低级 API,包括张量、操作、变量和特殊数据结构。然后我们使用这些工具来自定义 Keras API 中的几乎每个组件。最后,我们看了 TF 函数如何提升性能,如何使用 AutoGraph 和跟踪生成图形,以及编写 TF 函数时应遵循的规则(如果您想进一步打开黑匣子并探索生成的图形,您将在附录 D 中找到技术细节)。
在下一章中,我们将学习如何使用 TensorFlow 高效加载和预处理数据。
练习
-
您如何用简短的句子描述 TensorFlow?它的主要特点是什么?您能否列出其他流行的深度学习库?
-
TensorFlow 是否可以替代 NumPy?它们之间的主要区别是什么?
-
tf.range(10)和tf.constant(np.arange(10))会得到相同的结果吗? -
您能否列出 TensorFlow 中除了常规张量之外的其他六种数据结构?
-
您可以通过编写函数或子类化
tf.keras.losses.Loss类来定义自定义损失函数。您会在什么时候使用每个选项? -
同样,您可以在函数中定义自定义指标,也可以作为
tf.keras.metrics.Metric的子类。您会在什么时候使用每个选项? -
何时应该创建自定义层而不是自定义模型?
-
有哪些需要编写自定义训练循环的用例?
-
自定义 Keras 组件可以包含任意的 Python 代码吗,还是必须可转换为 TF 函数?
-
如果您希望函数可转换为 TF 函数,主要需要遵守哪些规则?
-
何时需要创建一个动态的 Keras 模型?如何做到这一点?为什么不将所有模型都设置为动态的呢?
-
实现一个执行层归一化的自定义层(我们将在第十五章中使用这种类型的层):
-
build()方法应该定义两个可训练的权重α和β,形状都是input_shape[-1:],数据类型为tf.float32。α应该初始化为 1,β初始化为 0。 -
call()方法应该计算每个实例特征的平均值μ和标准差σ。为此,您可以使用tf.nn.moments(inputs, axes=-1, keepdims=True),它返回所有实例的平均值μ和方差σ²(计算方差的平方根以获得标准差)。然后函数应该计算并返回α ⊗ (X - μ)/(σ + ε) + β,其中 ⊗ 表示逐元素乘法(*),ε是一个平滑项(一个小常数,避免除以零,例如 0.001)。 -
确保您的自定义层产生与
tf.keras.layers.LayerNormalization层相同(或非常接近)的输出。
-
-
使用自定义训练循环训练一个模型,以处理 Fashion MNIST 数据集(参见第十章):
-
显示每个时代、迭代、平均训练损失和每个时代的平均准确率(在每次迭代更新),以及每个时代结束时的验证损失和准确率。
-
尝试使用不同的优化器以及不同的学习率来处理上层和下层。
-
这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3。
然而,Facebook 的 PyTorch 库目前在学术界更受欢迎:比起 TensorFlow 或 Keras,更多的论文引用 PyTorch。此外,Google 的 JAX 库正在获得动力,尤其是在学术界。
TensorFlow 包括另一个名为estimators API的深度学习 API,但现在已经不推荐使用。
如果您有需要(但您可能不会),您可以使用 C++ API 编写自己的操作。
要了解更多关于 TPU 以及它们如何工作的信息,请查看https://homl.info/tpus。
tf.math.log()是一个值得注意的例外,它通常被使用,但没有tf.log()的别名,因为这可能会与日志记录混淆。
使用加权平均值不是一个好主意:如果这样做,那么具有相同权重但在不同批次中的两个实例将对训练产生不同的影响,这取决于每个批次的总权重。
{**x, [...]}语法是在 Python 3.5 中添加的,用于将字典x中的所有键/值对合并到另一个字典中。自 Python 3.9 起,您可以使用更好的x | y语法(其中x和y是两个字典)。
然而,Huber 损失很少用作度量标准——通常更喜欢使用 MAE 或 MSE。
这个类仅用于说明目的。一个更简单和更好的实现方法是只需子类化tf.keras.metrics.Mean类;请参阅本章笔记本的“流式指标”部分以获取示例。
Keras API 将此参数称为input_shape,但由于它还包括批量维度,我更喜欢将其称为batch_input_shape。
在 Keras 中,“子类 API”通常只指通过子类化创建自定义模型,尽管在本章中您已经看到,许多其他东西也可以通过子类化创建。
由于 TensorFlow 问题#46858,这种情况下调用super().build()可能会失败,除非在您阅读此内容时已修复该问题。如果没有,请将此行替换为self.built = True。
您还可以在模型内的任何层上调用add_loss(),因为模型会递归地从所有层中收集损失。
如果磁带超出范围,例如当使用它的函数返回时,Python 的垃圾收集器会为您删除它。
除了优化器之外,很少有人会自定义这些;请参阅笔记本中的“自定义优化器”部分以获取示例。
然而,在这个简单的例子中,计算图非常小,几乎没有任何优化的空间,所以tf_cube()实际上比cube()运行得慢得多。
第十三章:使用 TensorFlow 加载和预处理数据
在第二章中,您看到加载和预处理数据是任何机器学习项目的重要部分。您使用 Pandas 加载和探索(修改后的)加利福尼亚房屋数据集——该数据集存储在 CSV 文件中——并应用 Scikit-Learn 的转换器进行预处理。这些工具非常方便,您可能会经常使用它们,特别是在探索和实验数据时。
然而,在大型数据集上训练 TensorFlow 模型时,您可能更喜欢使用 TensorFlow 自己的数据加载和预处理 API,称为tf.data。它能够非常高效地加载和预处理数据,使用多线程和排队从多个文件中并行读取数据,对样本进行洗牌和分批处理等。此外,它可以实时执行所有这些操作——在 GPU 或 TPU 正在训练当前批次数据时,它会在多个 CPU 核心上加载和预处理下一批数据。
tf.data API 允许您处理无法放入内存的数据集,并充分利用硬件资源,从而加快训练速度。tf.data API 可以直接从文本文件(如 CSV 文件)、具有固定大小记录的二进制文件以及使用 TensorFlow 的 TFRecord 格式的二进制文件中读取数据。
TFRecord 是一种灵活高效的二进制格式,通常包含协议缓冲区(一种开源二进制格式)。tf.data API 还支持从 SQL 数据库中读取数据。此外,许多开源扩展可用于从各种数据源中读取数据,例如 Google 的 BigQuery 服务(请参阅https://tensorflow.org/io)。
Keras 还提供了强大而易于使用的预处理层,可以嵌入到您的模型中:这样,当您将模型部署到生产环境时,它将能够直接摄取原始数据,而无需您添加任何额外的预处理代码。这消除了训练期间使用的预处理代码与生产中使用的预处理代码之间不匹配的风险,这可能会导致训练/服务偏差。如果您将模型部署在使用不同编程语言编写的多个应用程序中,您不必多次重新实现相同的预处理代码,这也减少了不匹配的风险。
正如您将看到的,这两个 API 可以联合使用——例如,从 tf.data 提供的高效数据加载和 Keras 预处理层的便利性中受益。
在本章中,我们将首先介绍 tf.data API 和 TFRecord 格式。然后我们将探索 Keras 预处理层以及如何将它们与 tf.data API 一起使用。最后,我们将快速查看一些相关的库,您可能会发现它们在加载和预处理数据时很有用,例如 TensorFlow Datasets 和 TensorFlow Hub。所以,让我们开始吧!
tf.data API
整个 tf.data API 围绕着 tf.data.Dataset 的概念展开:这代表了一系列数据项。通常,您会使用逐渐从磁盘读取数据的数据集,但为了简单起见,让我们使用 tf.data.Dataset.from_tensor_slices() 从一个简单的数据张量创建数据集:
>>> import tensorflow as tf
>>> X = tf.range(10) # any data tensor
>>> dataset = tf.data.Dataset.from_tensor_slices(X)
>>> dataset
<TensorSliceDataset shapes: (), types: tf.int32>
from_tensor_slices() 函数接受一个张量,并创建一个 tf.data.Dataset,其中的元素是沿着第一维度的所有 X 的切片,因此这个数据集包含 10 个项目:张量 0、1、2、…、9。在这种情况下,如果我们使用 tf.data.Dataset.range(10),我们将获得相同的数据集(除了元素将是 64 位整数而不是 32 位整数)。
您可以简单地迭代数据集的项目,如下所示:
>>> for item in dataset:
... print(item)
...
tf.Tensor(0, shape=(), dtype=int32)
tf.Tensor(1, shape=(), dtype=int32)
[...]
tf.Tensor(9, shape=(), dtype=int32)
注意
tf.data API 是一个流式 API:您可以非常高效地迭代数据集的项目,但该 API 不适用于索引或切片。
数据集还可以包含张量的元组,或名称/张量对的字典,甚至是张量的嵌套元组和字典。在对元组、字典或嵌套结构进行切片时,数据集将仅切片它包含的张量,同时保留元组/字典结构。例如:
>>> X_nested = {"a": ([1, 2, 3], [4, 5, 6]), "b": [7, 8, 9]}
>>> dataset = tf.data.Dataset.from_tensor_slices(X_nested)
>>> for item in dataset:
... print(item)
...
{'a': (<tf.Tensor: [...]=1>, <tf.Tensor: [...]=4>), 'b': <tf.Tensor: [...]=7>}
{'a': (<tf.Tensor: [...]=2>, <tf.Tensor: [...]=5>), 'b': <tf.Tensor: [...]=8>}
{'a': (<tf.Tensor: [...]=3>, <tf.Tensor: [...]=6>), 'b': <tf.Tensor: [...]=9>}
链接转换
一旦您有了数据集,您可以通过调用其转换方法对其应用各种转换。每个方法都会返回一个新的数据集,因此您可以像这样链接转换(此链在图 13-1 中有示例):
>>> dataset = tf.data.Dataset.from_tensor_slices(tf.range(10))
>>> dataset = dataset.repeat(3).batch(7)
>>> for item in dataset:
... print(item)
...
tf.Tensor([0 1 2 3 4 5 6], shape=(7,), dtype=int32)
tf.Tensor([7 8 9 0 1 2 3], shape=(7,), dtype=int32)
tf.Tensor([4 5 6 7 8 9 0], shape=(7,), dtype=int32)
tf.Tensor([1 2 3 4 5 6 7], shape=(7,), dtype=int32)
tf.Tensor([8 9], shape=(2,), dtype=int32)
在这个例子中,我们首先在原始数据集上调用repeat()方法,它返回一个将原始数据集的项目重复三次的新数据集。当然,这不会将所有数据在内存中复制三次!如果您调用此方法而没有参数,新数据集将永远重复源数据集,因此迭代数据集的代码将不得不决定何时停止。
然后我们在这个新数据集上调用batch()方法,再次创建一个新数据集。这个新数据集将把前一个数据集的项目分组成七个项目一组的批次。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1301.png
图 13-1. 链接数据集转换
最后,我们迭代这个最终数据集的项目。batch()方法必须输出一个大小为两而不是七的最终批次,但是如果您希望删除这个最终批次,使所有批次具有完全相同的大小,可以调用batch()并使用drop_remainder=True。
警告
数据集方法不会修改数据集,它们会创建新的数据集。因此,请确保保留对这些新数据集的引用(例如,使用dataset = ...),否则什么也不会发生。
您还可以通过调用map()方法来转换项目。例如,这将创建一个所有批次乘以二的新数据集:
>>> dataset = dataset.map(lambda x: x * 2) # x is a batch
>>> for item in dataset:
... print(item)
...
tf.Tensor([ 0 2 4 6 8 10 12], shape=(7,), dtype=int32)
tf.Tensor([14 16 18 0 2 4 6], shape=(7,), dtype=int32)
[...]
这个map()方法是您将调用的方法,用于对数据进行任何预处理。有时这将包括一些可能相当密集的计算,比如重塑或旋转图像,因此您通常会希望启动多个线程以加快速度。这可以通过将num_parallel_calls参数设置为要运行的线程数,或者设置为tf.data.AUTOTUNE来完成。请注意,您传递给map()方法的函数必须可以转换为 TF 函数(请参阅第十二章)。
还可以使用filter()方法简单地过滤数据集。例如,此代码创建一个仅包含总和大于 50 的批次的数据集:
>>> dataset = dataset.filter(lambda x: tf.reduce_sum(x) > 50)
>>> for item in dataset:
... print(item)
...
tf.Tensor([14 16 18 0 2 4 6], shape=(7,), dtype=int32)
tf.Tensor([ 8 10 12 14 16 18 0], shape=(7,), dtype=int32)
tf.Tensor([ 2 4 6 8 10 12 14], shape=(7,), dtype=int32)
您经常会想查看数据集中的一些项目。您可以使用take()方法来实现:
>>> for item in dataset.take(2):
... print(item)
...
tf.Tensor([14 16 18 0 2 4 6], shape=(7,), dtype=int32)
tf.Tensor([ 8 10 12 14 16 18 0], shape=(7,), dtype=int32)
数据洗牌
正如我们在第四章中讨论的,梯度下降在训练集中的实例是独立且同分布(IID)时效果最好。确保这一点的一个简单方法是对实例进行洗牌,使用shuffle()方法。它将创建一个新数据集,首先用源数据集的前几个项目填充缓冲区。然后,每当需要一个项目时,它将从缓冲区随机取出一个项目,并用源数据集中的新项目替换它,直到完全迭代源数据集。在这一点上,它将继续从缓冲区随机取出项目,直到缓冲区为空。您必须指定缓冲区大小,并且很重要的是要足够大,否则洗牌效果不会很好。¹ 只是不要超出您拥有的 RAM 量,尽管即使您有很多 RAM,也没有必要超出数据集的大小。如果您希望每次运行程序时都获得相同的随机顺序,可以提供一个随机种子。例如,以下代码创建并显示一个包含 0 到 9 的整数,重复两次,使用大小为 4 的缓冲区和随机种子 42 进行洗牌,并使用批次大小为 7 进行批处理的数据集:
>>> dataset = tf.data.Dataset.range(10).repeat(2)
>>> dataset = dataset.shuffle(buffer_size=4, seed=42).batch(7)
>>> for item in dataset:
... print(item)
...
tf.Tensor([3 0 1 6 2 5 7], shape=(7,), dtype=int64)
tf.Tensor([8 4 1 9 4 2 3], shape=(7,), dtype=int64)
tf.Tensor([7 5 0 8 9 6], shape=(6,), dtype=int64)
提示
如果在打乱的数据集上调用repeat(),默认情况下它将在每次迭代时生成一个新的顺序。这通常是个好主意,但是如果您希望在每次迭代中重复使用相同的顺序(例如,用于测试或调试),可以在调用shuffle()时设置reshuffle_each_iteration=False。
对于一个无法放入内存的大型数据集,这种简单的打乱缓冲区方法可能不够,因为缓冲区相对于数据集来说很小。一个解决方案是对源数据本身进行打乱(例如,在 Linux 上可以使用shuf命令对文本文件进行打乱)。这将显著改善打乱效果!即使源数据已经被打乱,通常也会希望再次打乱,否则每个时期将重复相同的顺序,模型可能会出现偏差(例如,由于源数据顺序中偶然存在的一些虚假模式)。为了进一步打乱实例,一个常见的方法是将源数据拆分为多个文件,然后在训练过程中以随机顺序读取它们。然而,位于同一文件中的实例仍然会相互靠近。为了避免这种情况,您可以随机选择多个文件并同时读取它们,交错它们的记录。然后在此基础上使用shuffle()方法添加一个打乱缓冲区。如果这听起来很费力,不用担心:tf.data API 可以在几行代码中实现所有这些。让我们看看您可以如何做到这一点。
从多个文件中交错行
首先,假设您已经加载了加利福尼亚房屋数据集,对其进行了打乱(除非已经打乱),并将其分为训练集、验证集和测试集。然后将每个集合分成许多 CSV 文件,每个文件看起来像这样(每行包含八个输入特征加上目标中位房价):
MedInc,HouseAge,AveRooms,AveBedrms,Popul…,AveOccup,Lat…,Long…,MedianHouseValue
3.5214,15.0,3.050,1.107,1447.0,1.606,37.63,-122.43,1.442
5.3275,5.0,6.490,0.991,3464.0,3.443,33.69,-117.39,1.687
3.1,29.0,7.542,1.592,1328.0,2.251,38.44,-122.98,1.621
[...]
假设train_filepaths包含训练文件路径列表(您还有valid_filepaths和test_filepaths):
>>> train_filepaths
['datasets/housing/my_train_00.csv', 'datasets/housing/my_train_01.csv', ...]
或者,您可以使用文件模式;例如,train_filepaths = "datasets/housing/my_train_*.csv"。现在让我们创建一个仅包含这些文件路径的数据集:
filepath_dataset = tf.data.Dataset.list_files(train_filepaths, seed=42)
默认情况下,list_files()函数返回一个打乱文件路径的数据集。一般来说这是件好事,但是如果出于某种原因不想要这样,可以设置shuffle=False。
接下来,您可以调用interleave()方法一次从五个文件中读取并交错它们的行。您还可以使用skip()方法跳过每个文件的第一行(即标题行):
n_readers = 5
dataset = filepath_dataset.interleave(
lambda filepath: tf.data.TextLineDataset(filepath).skip(1),
cycle_length=n_readers)
interleave()方法将创建一个数据集,从filepath_dataset中提取五个文件路径,对于每个文件路径,它将调用您提供的函数(在本例中是 lambda 函数)来创建一个新的数据集(在本例中是TextLineDataset)。清楚地说,在这个阶段总共会有七个数据集:文件路径数据集、交错数据集以及交错数据集内部创建的五个TextLineDataset。当您迭代交错数据集时,它将循环遍历这五个TextLineDataset,从每个数据集中逐行读取,直到所有数据集都用完。然后它将从filepath_dataset中获取下一个五个文件路径,并以相同的方式交错它们,依此类推,直到文件路径用完。为了使交错效果最佳,最好拥有相同长度的文件;否则最长文件的末尾将不会被交错。
默认情况下,interleave()不使用并行处理;它只是顺序地从每个文件中一次读取一行。如果您希望实际并行读取文件,可以将interleave()方法的num_parallel_calls参数设置为您想要的线程数(请记住,map()方法也有这个参数)。甚至可以将其设置为tf.data.AUTOTUNE,让 TensorFlow 根据可用的 CPU 动态选择正确的线程数。现在让我们看看数据集现在包含什么:
>>> for line in dataset.take(5):
... print(line)
...
tf.Tensor(b'4.5909,16.0,[...],33.63,-117.71,2.418', shape=(), dtype=string)
tf.Tensor(b'2.4792,24.0,[...],34.18,-118.38,2.0', shape=(), dtype=string)
tf.Tensor(b'4.2708,45.0,[...],37.48,-122.19,2.67', shape=(), dtype=string)
tf.Tensor(b'2.1856,41.0,[...],32.76,-117.12,1.205', shape=(), dtype=string)
tf.Tensor(b'4.1812,52.0,[...],33.73,-118.31,3.215', shape=(), dtype=string)
这些是随机选择的五个 CSV 文件的第一行(忽略标题行)。看起来不错!
注意
可以将文件路径列表传递给 TextLineDataset 构造函数:它将按顺序遍历每个文件的每一行。如果还将 num_parallel_reads 参数设置为大于一的数字,那么数据集将并行读取该数量的文件,并交错它们的行(无需调用 interleave() 方法)。但是,它不会对文件进行洗牌,也不会跳过标题行。
数据预处理
现在我们有一个返回每个实例的住房数据集,其中包含一个字节字符串的张量,我们需要进行一些预处理,包括解析字符串和缩放数据。让我们实现一些自定义函数来执行这些预处理:
X_mean, X_std = [...] # mean and scale of each feature in the training set
n_inputs = 8
def parse_csv_line(line):
defs = [0.] * n_inputs + [tf.constant([], dtype=tf.float32)]
fields = tf.io.decode_csv(line, record_defaults=defs)
return tf.stack(fields[:-1]), tf.stack(fields[-1:])
def preprocess(line):
x, y = parse_csv_line(line)
return (x - X_mean) / X_std, y
让我们逐步解释这段代码:
-
首先,代码假设我们已经预先计算了训练集中每个特征的均值和标准差。
X_mean和X_std只是包含八个浮点数的 1D 张量(或 NumPy 数组),每个输入特征一个。可以使用 Scikit-Learn 的StandardScaler在数据集的足够大的随机样本上完成这个操作。在本章的后面,我们将使用 Keras 预处理层来代替。 -
parse_csv_line()函数接受一个 CSV 行并对其进行解析。为了帮助实现这一点,它使用tf.io.decode_csv()函数,该函数接受两个参数:第一个是要解析的行,第二个是包含 CSV 文件中每列的默认值的数组。这个数组(defs)告诉 TensorFlow 不仅每列的默认值是什么,还告诉它列的数量和类型。在这个例子中,我们告诉它所有特征列都是浮点数,缺失值应默认为零,但我们为最后一列(目标)提供了一个空的tf.float32类型的默认值数组:该数组告诉 TensorFlow 这一列包含浮点数,但没有默认值,因此如果遇到缺失值,它将引发异常。 -
tf.io.decode_csv()函数返回一个标量张量列表(每列一个),但我们需要返回一个 1D 张量数组。因此,我们对除最后一个(目标)之外的所有张量调用tf.stack():这将这些张量堆叠成一个 1D 数组。然后我们对目标值做同样的操作:这将使其成为一个包含单个值的 1D 张量数组,而不是标量张量。tf.io.decode_csv()函数完成后,它将返回输入特征和目标。 -
最后,自定义的
preprocess()函数只调用parse_csv_line()函数,通过减去特征均值然后除以特征标准差来缩放输入特征,并返回一个包含缩放特征和目标的元组。
让我们测试这个预处理函数:
>>> preprocess(b'4.2083,44.0,5.3232,0.9171,846.0,2.3370,37.47,-122.2,2.782')
(<tf.Tensor: shape=(8,), dtype=float32, numpy=
array([ 0.16579159, 1.216324 , -0.05204564, -0.39215982, -0.5277444 ,
-0.2633488 , 0.8543046 , -1.3072058 ], dtype=float32)>,
<tf.Tensor: shape=(1,), dtype=float32, numpy=array([2.782], dtype=float32)>)
看起来不错!preprocess() 函数可以将一个实例从字节字符串转换为一个漂亮的缩放张量,带有相应的标签。我们现在可以使用数据集的 map() 方法将 preprocess() 函数应用于数据集中的每个样本。
将所有内容放在一起
为了使代码更具重用性,让我们将迄今为止讨论的所有内容放在另一个辅助函数中;它将创建并返回一个数据集,该数据集将高效地从多个 CSV 文件中加载加利福尼亚房屋数据,对其进行预处理、洗牌和分批处理(参见图 13-2):
def csv_reader_dataset(filepaths, n_readers=5, n_read_threads=None,
n_parse_threads=5, shuffle_buffer_size=10_000, seed=42,
batch_size=32):
dataset = tf.data.Dataset.list_files(filepaths, seed=seed)
dataset = dataset.interleave(
lambda filepath: tf.data.TextLineDataset(filepath).skip(1),
cycle_length=n_readers, num_parallel_calls=n_read_threads)
dataset = dataset.map(preprocess, num_parallel_calls=n_parse_threads)
dataset = dataset.shuffle(shuffle_buffer_size, seed=seed)
return dataset.batch(batch_size).prefetch(1)
请注意,我们在最后一行使用了 prefetch() 方法。这对性能很重要,你现在会看到。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1302.png
图 13-2. 从多个 CSV 文件加载和预处理数据
预取
通过在自定义csv_reader_dataset()函数末尾调用prefetch(1),我们正在创建一个数据集,该数据集将尽力始终领先一个批次。换句话说,当我们的训练算法在处理一个批次时,数据集将已经在并行工作,准备好获取下一个批次(例如,从磁盘读取数据并对其进行预处理)。这可以显著提高性能,如图 13-3 所示。
如果我们还确保加载和预处理是多线程的(通过在调用interleave()和map()时设置num_parallel_calls),我们可以利用多个 CPU 核心,希望准备一个数据批次的时间比在 GPU 上运行训练步骤要短:这样 GPU 将几乎 100%利用(除了从 CPU 到 GPU 的数据传输时间)[3],训练将运行得更快。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1303.png
图 13-3。通过预取,CPU 和 GPU 并行工作:当 GPU 处理一个批次时,CPU 处理下一个批次
提示
如果您计划购买 GPU 卡,其处理能力和内存大小当然非常重要(特别是对于大型计算机视觉或自然语言处理模型,大量的 RAM 至关重要)。对于良好性能同样重要的是 GPU 的内存带宽;这是它每秒可以将多少千兆字节的数据进出其 RAM。
如果数据集足够小,可以放入内存,您可以通过使用数据集的cache()方法将其内容缓存到 RAM 来显着加快训练速度。通常应在加载和预处理数据之后,但在洗牌、重复、批处理和预取之前执行此操作。这样,每个实例只会被读取和预处理一次(而不是每个时期一次),但数据仍然会在每个时期以不同的方式洗牌,下一批数据仍然会提前准备好。
您现在已经学会了如何构建高效的输入管道,从多个文本文件加载和预处理数据。我们已经讨论了最常见的数据集方法,但还有一些您可能想看看的方法,例如concatenate()、zip()、window()、reduce()、shard()、flat_map()、apply()、unbatch()和padded_batch()。还有一些更多的类方法,例如from_generator()和from_tensors(),它们分别从 Python 生成器或张量列表创建新数据集。请查看 API 文档以获取更多详细信息。还请注意,tf.data.experimental中提供了一些实验性功能,其中许多功能可能会在未来的版本中成为核心 API 的一部分(例如,请查看CsvDataset类,以及make_csv_dataset()方法,该方法负责推断每列的类型)。
使用数据集与 Keras
现在,我们可以使用我们之前编写的自定义csv_reader_dataset()函数为训练集、验证集和测试集创建数据集。训练集将在每个时期进行洗牌(请注意,验证集和测试集也将进行洗牌,尽管我们实际上并不需要):
train_set = csv_reader_dataset(train_filepaths)
valid_set = csv_reader_dataset(valid_filepaths)
test_set = csv_reader_dataset(test_filepaths)
现在,您可以简单地使用这些数据集构建和训练 Keras 模型。当您调用模型的fit()方法时,您传递train_set而不是X_train, y_train,并传递validation_data=valid_set而不是validation_data=(X_valid, y_valid)。fit()方法将负责每个时期重复训练数据集,每个时期使用不同的随机顺序:
model = tf.keras.Sequential([...])
model.compile(loss="mse", optimizer="sgd")
model.fit(train_set, validation_data=valid_set, epochs=5)
同样,您可以将数据集传递给evaluate()和predict()方法:
test_mse = model.evaluate(test_set)
new_set = test_set.take(3) # pretend we have 3 new samples
y_pred = model.predict(new_set) # or you could just pass a NumPy array
与其他数据集不同,new_set通常不包含标签。如果包含标签,就像这里一样,Keras 会忽略它们。请注意,在所有这些情况下,您仍然可以使用 NumPy 数组而不是数据集(但当然它们需要先加载和预处理)。
如果您想构建自己的自定义训练循环(如第十二章中讨论的),您可以很自然地遍历训练集:
n_epochs = 5
for epoch in range(n_epochs):
for X_batch, y_batch in train_set:
[...] # perform one gradient descent step
实际上,甚至可以创建一个 TF 函数(参见第十二章),用于整个时期训练模型。这可以真正加快训练速度:
@tf.function
def train_one_epoch(model, optimizer, loss_fn, train_set):
for X_batch, y_batch in train_set:
with tf.GradientTape() as tape:
y_pred = model(X_batch)
main_loss = tf.reduce_mean(loss_fn(y_batch, y_pred))
loss = tf.add_n([main_loss] + model.losses)
gradients = tape.gradient(loss, model.trainable_variables)
optimizer.apply_gradients(zip(gradients, model.trainable_variables))
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01)
loss_fn = tf.keras.losses.mean_squared_error
for epoch in range(n_epochs):
print("\rEpoch {}/{}".format(epoch + 1, n_epochs), end="")
train_one_epoch(model, optimizer, loss_fn, train_set)
在 Keras 中,compile()方法的steps_per_execution参数允许您定义fit()方法在每次调用用于训练的tf.function时将处理的批次数。默认值只是 1,因此如果将其设置为 50,您通常会看到显着的性能改进。但是,Keras 回调的on_batch_*()方法只会在每 50 批次时调用一次。
恭喜,您现在知道如何使用 tf.data API 构建强大的输入管道!然而,到目前为止,我们一直在使用常见、简单和方便但不是真正高效的 CSV 文件,并且不太支持大型或复杂的数据结构(如图像或音频)。因此,让我们看看如何改用 TFRecords。
提示
如果您对 CSV 文件(或者您正在使用的其他格式)感到满意,您不一定必须使用 TFRecords。俗话说,如果它没有坏,就不要修理!当训练过程中的瓶颈是加载和解析数据时,TFRecords 非常有用。
TFRecord 格式
TFRecord 格式是 TensorFlow 存储大量数据并高效读取的首选格式。它是一个非常简单的二进制格式,只包含一系列大小不同的二进制记录(每个记录由长度、用于检查长度是否损坏的 CRC 校验和、实际数据,最后是数据的 CRC 校验和组成)。您可以使用tf.io.TFRecordWriter类轻松创建 TFRecord 文件:
with tf.io.TFRecordWriter("my_data.tfrecord") as f:
f.write(b"This is the first record")
f.write(b"And this is the second record")
然后,您可以使用tf.data.TFRecordDataset来读取一个或多个 TFRecord 文件:
filepaths = ["my_data.tfrecord"]
dataset = tf.data.TFRecordDataset(filepaths)
for item in dataset:
print(item)
这将输出:
tf.Tensor(b'This is the first record', shape=(), dtype=string)
tf.Tensor(b'And this is the second record', shape=(), dtype=string)
提示
默认情况下,TFRecordDataset将逐个读取文件,但您可以使其并行读取多个文件,并通过传递文件路径列表给构造函数并将num_parallel_reads设置为大于 1 的数字来交错它们的记录。或者,您可以通过使用list_files()和interleave()来获得与我们之前读取多个 CSV 文件相同的结果。
压缩的 TFRecord 文件
有时将 TFRecord 文件压缩可能很有用,特别是如果它们需要通过网络连接加载。您可以通过设置options参数创建一个压缩的 TFRecord 文件:
options = tf.io.TFRecordOptions(compression_type="GZIP")
with tf.io.TFRecordWriter("my_compressed.tfrecord", options) as f:
f.write(b"Compress, compress, compress!")
在读取压缩的 TFRecord 文件时,您需要指定压缩类型:
dataset = tf.data.TFRecordDataset(["my_compressed.tfrecord"],
compression_type="GZIP")
协议缓冲区简介
尽管每个记录可以使用您想要的任何二进制格式,但 TFRecord 文件通常包含序列化的协议缓冲区(也称为protobufs)。这是一个在 2001 年由谷歌开发的便携式、可扩展和高效的二进制格式,并于 2008 年开源;protobufs 现在被广泛使用,特别是在grpc中,谷歌的远程过程调用系统。它们使用一个看起来像这样的简单语言进行定义:
syntax = "proto3";
message Person {
string name = 1;
int32 id = 2;
repeated string email = 3;
}
这个 protobuf 定义表示我们正在使用 protobuf 格式的第 3 版,并且指定每个Person对象(可选)可能具有一个字符串类型的name、一个 int32 类型的id,以及零个或多个字符串类型的email字段。数字1、2和3是字段标识符:它们将在每个记录的二进制表示中使用。一旦你在*.proto文件中有了一个定义,你就可以编译它。这需要使用 protobuf 编译器protoc在 Python(或其他语言)中生成访问类。请注意,你通常在 TensorFlow 中使用的 protobuf 定义已经为你编译好了,并且它们的 Python 类是 TensorFlow 库的一部分,因此你不需要使用protoc。你只需要知道如何在 Python 中使用*protobuf 访问类。为了说明基础知识,让我们看一个简单的示例,使用为Personprotobuf 生成的访问类(代码在注释中有解释):
>>> from person_pb2 import Person # import the generated access class
>>> person = Person(name="Al", id=123, email=["a@b.com"]) # create a Person
>>> print(person) # display the Person
name: "Al"
id: 123
email: "a@b.com"
>>> person.name # read a field
'Al'
>>> person.name = "Alice" # modify a field
>>> person.email[0] # repeated fields can be accessed like arrays
'a@b.com'
>>> person.email.append("c@d.com") # add an email address
>>> serialized = person.SerializeToString() # serialize person to a byte string
>>> serialized
b'\n\x05Alice\x10{\x1a\x07a@b.com\x1a\x07c@d.com'
>>> person2 = Person() # create a new Person
>>> person2.ParseFromString(serialized) # parse the byte string (27 bytes long)
27
>>> person == person2 # now they are equal
True
简而言之,我们导入由protoc生成的Person类,创建一个实例并对其进行操作,可视化它并读取和写入一些字段,然后使用SerializeToString()方法对其进行序列化。这是准备保存或通过网络传输的二进制数据。当读取或接收这些二进制数据时,我们可以使用ParseFromString()方法进行解析,并获得被序列化的对象的副本。
你可以将序列化的Person对象保存到 TFRecord 文件中,然后加载和解析它:一切都会正常工作。然而,ParseFromString()不是一个 TensorFlow 操作,所以你不能在 tf.data 管道中的预处理函数中使用它(除非将其包装在tf.py_function()操作中,这会使代码变慢且不太可移植,正如你在第十二章中看到的)。然而,你可以使用tf.io.decode_proto()函数,它可以解析任何你想要的 protobuf,只要你提供 protobuf 定义(请参考笔记本中的示例)。也就是说,在实践中,你通常会希望使用 TensorFlow 提供的专用解析操作的预定义 protobuf。现在让我们来看看这些预定义的 protobuf。
TensorFlow Protobufs
TFRecord 文件中通常使用的主要 protobuf 是Exampleprotobuf,它表示数据集中的一个实例。它包含一个命名特征列表,其中每个特征可以是一个字节字符串列表、一个浮点数列表或一个整数列表。以下是 protobuf 定义(来自 TensorFlow 源代码):
syntax = "proto3";
message BytesList { repeated bytes value = 1; }
message FloatList { repeated float value = 1 [packed = true]; }
message Int64List { repeated int64 value = 1 [packed = true]; }
message Feature {
oneof kind {
BytesList bytes_list = 1;
FloatList float_list = 2;
Int64List int64_list = 3;
}
};
message Features { map<string, Feature> feature = 1; };
message Example { Features features = 1; };
BytesList、FloatList和Int64List的定义足够简单明了。请注意,对于重复的数值字段,使用[packed = true]进行更有效的编码。Feature包含一个BytesList、一个FloatList或一个Int64List。一个Features(带有s)包含一个将特征名称映射到相应特征值的字典。最后,一个Example只包含一个Features对象。
注意
为什么会定义Example,因为它只包含一个Features对象?嗯,TensorFlow 的开发人员可能有一天决定向其中添加更多字段。只要新的Example定义仍然包含相同 ID 的features字段,它就是向后兼容的。这种可扩展性是 protobuf 的一个伟大特性。
这是你如何创建一个代表同一个人的tf.train.Example:
from tensorflow.train import BytesList, FloatList, Int64List
from tensorflow.train import Feature, Features, Example
person_example = Example(
features=Features(
feature={
"name": Feature(bytes_list=BytesList(value=[b"Alice"])),
"id": Feature(int64_list=Int64List(value=[123])),
"emails": Feature(bytes_list=BytesList(value=[b"a@b.com",
b"c@d.com"]))
}))
这段代码有点冗长和重复,但你可以很容易地将其包装在一个小的辅助函数中。现在我们有了一个Example protobuf,我们可以通过调用其SerializeToString()方法将其序列化,然后将生成的数据写入 TFRecord 文件。让我们假装写入五次,以假装我们有几个联系人:
with tf.io.TFRecordWriter("my_contacts.tfrecord") as f:
for _ in range(5):
f.write(person_example.SerializeToString())
通常,您会写比五个Example更多的内容!通常情况下,您会创建一个转换脚本,从当前格式(比如 CSV 文件)读取数据,为每个实例创建一个Example protobuf,将它们序列化,并保存到几个 TFRecord 文件中,最好在此过程中对它们进行洗牌。这需要一些工作,所以再次确保这确实是必要的(也许您的流水线使用 CSV 文件运行良好)。
现在我们有一个包含多个序列化Example的漂亮 TFRecord 文件,让我们尝试加载它。
加载和解析示例
为了加载序列化的Example protobufs,我们将再次使用tf.data.TFRecordDataset,并使用tf.io.parse_single_example()解析每个Example。它至少需要两个参数:包含序列化数据的字符串标量张量,以及每个特征的描述。描述是一个字典,将每个特征名称映射到tf.io.FixedLenFeature描述符,指示特征的形状、类型和默认值,或者tf.io.VarLenFeature描述符,仅指示特征列表的长度可能变化的类型(例如"emails"特征)。
以下代码定义了一个描述字典,然后创建了一个TFRecordDataset,并对其应用了一个自定义预处理函数,以解析该数据集包含的每个序列化Example protobuf:
feature_description = {
"name": tf.io.FixedLenFeature([], tf.string, default_value=""),
"id": tf.io.FixedLenFeature([], tf.int64, default_value=0),
"emails": tf.io.VarLenFeature(tf.string),
}
def parse(serialized_example):
return tf.io.parse_single_example(serialized_example, feature_description)
dataset = tf.data.TFRecordDataset(["my_contacts.tfrecord"]).map(parse)
for parsed_example in dataset:
print(parsed_example)
固定长度的特征被解析为常规张量,但变长特征被解析为稀疏张量。您可以使用tf.sparse.to_dense()将稀疏张量转换为密集张量,但在这种情况下,更简单的方法是直接访问其值:
>>> tf.sparse.to_dense(parsed_example["emails"], default_value=b"")
<tf.Tensor: [...] dtype=string, numpy=array([b'a@b.com', b'c@d.com'], [...])>
>>> parsed_example["emails"].values
<tf.Tensor: [...] dtype=string, numpy=array([b'a@b.com', b'c@d.com'], [...])>
您可以使用tf.io.parse_example()批量解析示例,而不是使用tf.io.parse_single_example()逐个解析它们:
def parse(serialized_examples):
return tf.io.parse_example(serialized_examples, feature_description)
dataset = tf.data.TFRecordDataset(["my_contacts.tfrecord"]).batch(2).map(parse)
for parsed_examples in dataset:
print(parsed_examples) # two examples at a time
最后,BytesList可以包含您想要的任何二进制数据,包括任何序列化对象。例如,您可以使用tf.io.encode_jpeg()使用 JPEG 格式对图像进行编码,并将这些二进制数据放入BytesList中。稍后,当您的代码读取 TFRecord 时,它将从解析Example开始,然后需要调用tf.io.decode_jpeg()来解析数据并获取原始图像(或者您可以使用tf.io.decode_image(),它可以解码任何 BMP、GIF、JPEG 或 PNG 图像)。您还可以通过使用tf.io.serialize_tensor()对张量进行序列化,然后将生成的字节字符串放入BytesList特征中,将任何您想要的张量存储在BytesList中。稍后,当您解析 TFRecord 时,您可以使用tf.io.parse_tensor()解析这些数据。请参阅本章的笔记本https://homl.info/colab3 ,了解在 TFRecord 文件中存储图像和张量的示例。
正如您所看到的,Example protobuf 非常灵活,因此对于大多数用例来说可能已经足够了。但是,当您处理列表列表时,可能会有些繁琐。例如,假设您想对文本文档进行分类。每个文档可以表示为一个句子列表,其中每个句子表示为一个单词列表。也许每个文档还有一个评论列表,其中每个评论表示为一个单词列表。还可能有一些上下文数据,比如文档的作者、标题和发布日期。TensorFlow 的SequenceExample protobuf 就是为这种用例而设计的。
使用 SequenceExample Protobuf 处理列表列表
这是SequenceExample protobuf 的定义:
message FeatureList { repeated Feature feature = 1; };
message FeatureLists { map<string, FeatureList> feature_list = 1; };
message SequenceExample {
Features context = 1;
FeatureLists feature_lists = 2;
};
SequenceExample包含一个Features对象用于上下文数据和一个包含一个或多个命名FeatureList对象(例如,一个名为"content"的FeatureList和另一个名为"comments"的FeatureList)的FeatureLists对象。每个FeatureList包含一个Feature对象列表,每个Feature对象可能是字节字符串列表、64 位整数列表或浮点数列表(在此示例中,每个Feature可能代表一个句子或评论,可能以单词标识符列表的形式)。构建SequenceExample、序列化它并解析它类似于构建、序列化和解析Example,但您必须使用tf.io.parse_single_sequence_example()来解析单个SequenceExample或tf.io.parse_sequence_example()来解析批处理。这两个函数返回一个包含上下文特征(作为字典)和特征列表(也作为字典)的元组。如果特征列表包含不同大小的序列(如前面的示例),您可能希望使用tf.RaggedTensor.from_sparse()将它们转换为不规则张量(请参阅完整代码的笔记本):
parsed_context, parsed_feature_lists = tf.io.parse_single_sequence_example(
serialized_sequence_example, context_feature_descriptions,
sequence_feature_descriptions)
parsed_content = tf.RaggedTensor.from_sparse(parsed_feature_lists["content"])
现在您已经知道如何使用 tf.data API、TFRecords 和 protobufs 高效存储、加载、解析和预处理数据,是时候将注意力转向 Keras 预处理层了。
Keras 预处理层
为神经网络准备数据通常需要对数值特征进行归一化、对分类特征和文本进行编码、裁剪和调整图像等。有几种选项:
-
预处理可以提前在准备训练数据文件时完成,使用您喜欢的任何工具,如 NumPy、Pandas 或 Scikit-Learn。您需要在生产中应用完全相同的预处理步骤,以确保您的生产模型接收到与训练时相似的预处理输入。
-
或者,您可以在加载数据时使用 tf.data 进行即时预处理,通过使用该数据集的
map()方法对数据集的每个元素应用预处理函数,就像本章前面所做的那样。同样,您需要在生产中应用相同的预处理步骤。 -
最后一种方法是直接在模型内部包含预处理层,这样它可以在训练期间即时预处理所有输入数据,然后在生产中使用相同的预处理层。本章的其余部分将讨论这种最后一种方法。
Keras 提供了许多预处理层,您可以将其包含在模型中:它们可以应用于数值特征、分类特征、图像和文本。我们将在接下来的部分中讨论数值和分类特征,以及基本文本预处理,我们将在第十四章中涵盖图像预处理,以及在第十六章中涵盖更高级的文本预处理。
归一化层
正如我们在第十章中看到的,Keras 提供了一个Normalization层,我们可以用来标准化输入特征。我们可以在创建层时指定每个特征的均值和方差,或者更简单地在拟合模型之前将训练集传递给该层的adapt()方法,以便该层可以在训练之前自行测量特征的均值和方差:
norm_layer = tf.keras.layers.Normalization()
model = tf.keras.models.Sequential([
norm_layer,
tf.keras.layers.Dense(1)
])
model.compile(loss="mse", optimizer=tf.keras.optimizers.SGD(learning_rate=2e-3))
norm_layer.adapt(X_train) # computes the mean and variance of every feature
model.fit(X_train, y_train, validation_data=(X_valid, y_valid), epochs=5)
提示
传递给adapt()方法的数据样本必须足够大,以代表您的数据集,但不必是完整的训练集:对于Normalization层,从训练集中随机抽取的几百个实例通常足以获得特征均值和方差的良好估计。
由于我们在模型中包含了Normalization层,现在我们可以将这个模型部署到生产环境中,而不必再担心归一化的问题:模型会自动处理(参见图 13-4)。太棒了!这种方法完全消除了预处理不匹配的风险,当人们尝试为训练和生产维护不同的预处理代码,但更新其中一个并忘记更新另一个时,就会发生这种情况。生产模型最终会接收到以其不期望的方式预处理的数据。如果他们幸运的话,会得到一个明显的错误。如果不幸的话,模型的准确性会悄悄下降。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1304.png
图 13-4。在模型中包含预处理层
直接在模型中包含预处理层很简单明了,但会减慢训练速度(在Normalization层的情况下只会稍微减慢):实际上,由于预处理是在训练过程中实时进行的,每个时期只会发生一次。我们可以通过在训练之前仅对整个训练集进行一次归一化来做得更好。为此,我们可以像使用 Scikit-Learn 的StandardScaler一样单独使用Normalization层:
norm_layer = tf.keras.layers.Normalization()
norm_layer.adapt(X_train)
X_train_scaled = norm_layer(X_train)
X_valid_scaled = norm_layer(X_valid)
现在我们可以在经过缩放的数据上训练模型,这次不需要Normalization层:
model = tf.keras.models.Sequential([tf.keras.layers.Dense(1)])
model.compile(loss="mse", optimizer=tf.keras.optimizers.SGD(learning_rate=2e-3))
model.fit(X_train_scaled, y_train, epochs=5,
validation_data=(X_valid_scaled, y_valid))
很好!这应该会加快训练速度。但是现在当我们将模型部署到生产环境时,模型不会对其输入进行预处理。为了解决这个问题,我们只需要创建一个新模型,将适应的Normalization层和刚刚训练的模型包装在一起。然后我们可以将这个最终模型部署到生产环境中,它将负责对其输入进行预处理和进行预测(参见图 13-5):
final_model = tf.keras.Sequential([norm_layer, model])
X_new = X_test[:3] # pretend we have a few new instances (unscaled)
y_pred = final_model(X_new) # preprocesses the data and makes predictions
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1305.png
图 13-5。在训练之前仅对数据进行一次预处理,然后将这些层部署到最终模型中
现在我们拥有了最佳的两种方式:训练很快,因为我们只在训练开始前对数据进行一次预处理,而最终模型可以在运行时对其输入进行预处理,而不会有任何预处理不匹配的风险。
此外,Keras 预处理层与 tf.data API 很好地配合。例如,可以将tf.data.Dataset传递给预处理层的adapt()方法。还可以使用数据集的map()方法将 Keras 预处理层应用于tf.data.Dataset。例如,以下是如何将适应的Normalization层应用于数据集中每个批次的输入特征的方法:
dataset = dataset.map(lambda X, y: (norm_layer(X), y))
最后,如果您需要比 Keras 预处理层提供的更多特性,您可以随时编写自己的 Keras 层,就像我们在第十二章中讨论的那样。例如,如果Normalization层不存在,您可以使用以下自定义层获得类似的结果:
import numpy as np
class MyNormalization(tf.keras.layers.Layer):
def adapt(self, X):
self.mean_ = np.mean(X, axis=0, keepdims=True)
self.std_ = np.std(X, axis=0, keepdims=True)
def call(self, inputs):
eps = tf.keras.backend.epsilon() # a small smoothing term
return (inputs - self.mean_) / (self.std_ + eps)
接下来,让我们看看另一个用于数值特征的 Keras 预处理层:Discretization层。
Discretization 层
Discretization层的目标是通过将值范围(称为箱)映射到类别,将数值特征转换为分类特征。这对于具有多峰分布的特征或与目标具有高度非线性关系的特征有时是有用的。例如,以下代码将数值age特征映射到三个类别,小于 18 岁,18 到 50 岁(不包括),50 岁或以上:
>>> age = tf.constant([[10.], [93.], [57.], [18.], [37.], [5.]])
>>> discretize_layer = tf.keras.layers.Discretization(bin_boundaries=[18., 50.])
>>> age_categories = discretize_layer(age)
>>> age_categories
<tf.Tensor: shape=(6, 1), dtype=int64, numpy=array([[0],[2],[2],[1],[1],[0]])>
在这个例子中,我们提供了期望的分箱边界。如果你愿意,你可以提供你想要的箱数,然后调用层的adapt()方法,让它根据值的百分位数找到合适的箱边界。例如,如果我们设置num_bins=3,那么箱边界将位于第 33 和第 66 百分位数之下的值(在这个例子中,值为 10 和 37):
>>> discretize_layer = tf.keras.layers.Discretization(num_bins=3)
>>> discretize_layer.adapt(age)
>>> age_categories = discretize_layer(age)
>>> age_categories
<tf.Tensor: shape=(6, 1), dtype=int64, numpy=array([[1],[2],[2],[1],[2],[0]])>
通常不应将诸如此类的类别标识符直接传递给神经网络,因为它们的值无法有意义地进行比较。相反,它们应该被编码,例如使用独热编码。现在让我们看看如何做到这一点。
CategoryEncoding 层
当只有少量类别(例如,少于十几个或二十个)时,独热编码通常是一个不错的选择(如第二章中讨论的)。为此,Keras 提供了CategoryEncoding层。例如,让我们对刚刚创建的age_categories特征进行独热编码:
>>> onehot_layer = tf.keras.layers.CategoryEncoding(num_tokens=3)
>>> onehot_layer(age_categories)
<tf.Tensor: shape=(6, 3), dtype=float32, numpy=
array([[0., 1., 0.],
[0., 0., 1.],
[0., 0., 1.],
[0., 1., 0.],
[0., 0., 1.],
[1., 0., 0.]], dtype=float32)>
如果尝试一次对多个分类特征进行编码(只有当它们都使用相同的类别时才有意义),CategoryEncoding类将默认执行多热编码:输出张量将包含每个输入特征中存在的每个类别的 1。例如:
>>> two_age_categories = np.array([[1, 0], [2, 2], [2, 0]])
>>> onehot_layer(two_age_categories)
<tf.Tensor: shape=(3, 3), dtype=float32, numpy=
array([[1., 1., 0.],
[0., 0., 1.],
[1., 0., 1.]], dtype=float32)>
如果您认为知道每个类别出现的次数是有用的,可以在创建CategoryEncoding层时设置output_mode="count",在这种情况下,输出张量将包含每个类别的出现次数。在前面的示例中,输出将与之前相同,只是第二行将变为[0., 0., 2.]。
请注意,多热编码和计数编码都会丢失信息,因为无法知道每个活动类别来自哪个特征。例如,[0, 1]和[1, 0]都被编码为[1., 1., 0.]。如果要避免这种情况,那么您需要分别对每个特征进行独热编码,然后连接输出。这样,[0, 1]将被编码为[1., 0., 0., 0., 1., 0.],[1, 0]将被编码为[0., 1., 0., 1., 0., 0.]。您可以通过调整类别标识符来获得相同的结果,以便它们不重叠。例如:
>>> onehot_layer = tf.keras.layers.CategoryEncoding(num_tokens=3 + 3)
>>> onehot_layer(two_age_categories + [0, 3]) # adds 3 to the second feature
<tf.Tensor: shape=(3, 6), dtype=float32, numpy=
array([[0., 1., 0., 1., 0., 0.],
[0., 0., 1., 0., 0., 1.],
[0., 0., 1., 1., 0., 0.]], dtype=float32)>
在此输出中,前三列对应于第一个特征,最后三列对应于第二个特征。这使模型能够区分这两个特征。但是,这也增加了馈送到模型的特征数量,因此需要更多的模型参数。很难事先知道单个多热编码还是每个特征的独热编码哪个效果最好:这取决于任务,您可能需要测试两种选项。
现在您可以使用独热编码或多热编码对分类整数特征进行编码。但是对于分类文本特征呢?为此,您可以使用StringLookup层。
StringLookup 层
让我们使用 Keras 的StringLookup层对cities特征进行独热编码:
>>> cities = ["Auckland", "Paris", "Paris", "San Francisco"]
>>> str_lookup_layer = tf.keras.layers.StringLookup()
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[1], [3], [3], [0]])>
我们首先创建一个StringLookup层,然后将其适应到数据:它发现有三个不同的类别。然后我们使用该层对一些城市进行编码。默认情况下,它们被编码为整数。未知类别被映射为 0,就像在这个例子中的“Montreal”一样。已知类别从最常见的类别开始编号,从最常见到最不常见。
方便的是,当创建StringLookup层时设置output_mode="one_hot",它将为每个类别输出一个独热向量,而不是一个整数:
>>> str_lookup_layer = tf.keras.layers.StringLookup(output_mode="one_hot")
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 4), dtype=float32, numpy=
array([[0., 1., 0., 0.],
[0., 0., 0., 1.],
[0., 0., 0., 1.],
[1., 0., 0., 0.]], dtype=float32)>
提示
Keras 还包括一个IntegerLookup层,其功能类似于StringLookup层,但输入为整数,而不是字符串。
如果训练集非常大,可能会方便地将层适应于训练集的随机子集。在这种情况下,层的adapt()方法可能会错过一些较少见的类别。默认情况下,它会将它们全部映射到类别 0,使它们在模型中无法区分。为了减少这种风险(同时仅在训练集的子集上调整层),您可以将num_oov_indices设置为大于 1 的整数。这是要使用的未知词汇(OOV)桶的数量:每个未知类别将使用哈希函数对 OOV 桶的数量取模,伪随机地映射到其中一个 OOV 桶。这将使模型能够区分至少一些罕见的类别。例如:
>>> str_lookup_layer = tf.keras.layers.StringLookup(num_oov_indices=5)
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Foo"], ["Bar"], ["Baz"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[5], [7], [4], [3], [4]])>
由于有五个 OOV 桶,第一个已知类别的 ID 现在是 5(“巴黎”)。但是,"Foo"、"Bar"和"Baz"是未知的,因此它们各自被映射到 OOV 桶中的一个。 "Bar"有自己的专用桶(ID 为 3),但不幸的是,"Foo"和"Baz"被映射到相同的桶中(ID 为 4),因此它们在模型中保持不可区分。这被称为哈希碰撞。减少碰撞风险的唯一方法是增加 OOV 桶的数量。但是,这也会增加总类别数,这将需要更多的 RAM 和额外的模型参数,一旦类别被独热编码。因此,不要将该数字增加得太多。
将类别伪随机映射到桶中的这种想法称为哈希技巧。Keras 提供了一个专用的层,就是Hashing层。
哈希层
对于每个类别,Keras 的Hashing层计算一个哈希值,取模于桶(或“bin”)的数量。映射完全是伪随机的,但在运行和平台之间是稳定的(即,只要桶的数量不变,相同的类别将始终被映射到相同的整数)。例如,让我们使用Hashing层来编码一些城市:
>>> hashing_layer = tf.keras.layers.Hashing(num_bins=10)
>>> hashing_layer([["Paris"], ["Tokyo"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[0], [1], [9], [1]])>
这个层的好处是它根本不需要适应,这有时可能很有用,特别是在核外设置中(当数据集太大而无法放入内存时)。然而,我们再次遇到了哈希碰撞:“东京”和“蒙特利尔”被映射到相同的 ID,使它们在模型中无法区分。因此,通常最好坚持使用StringLookup层。
现在让我们看另一种编码类别的方法:可训练的嵌入。
使用嵌入编码分类特征
嵌入是一种高维数据(例如类别或词汇中的单词)的密集表示。如果有 50,000 个可能的类别,那么独热编码将产生一个 50,000 维的稀疏向量(即,大部分为零)。相比之下,嵌入将是一个相对较小的密集向量;例如,只有 100 个维度。
在深度学习中,嵌入通常是随机初始化的,然后通过梯度下降与其他模型参数一起训练。例如,在加利福尼亚住房数据集中,"NEAR BAY"类别最初可以由一个随机向量表示,例如[0.131, 0.890],而"NEAR OCEAN"类别可能由另一个随机向量表示,例如[0.631, 0.791]。在这个例子中,我们使用了 2D 嵌入,但维度的数量是一个可以调整的超参数。
由于这些嵌入是可训练的,它们在训练过程中会逐渐改进;由于它们在这种情况下代表的是相当相似的类别,梯度下降肯定会使它们彼此更接近,同时也会使它们远离"INLAND"类别的嵌入(参见图 13-6)。实际上,表示得越好,神经网络就越容易做出准确的预测,因此训练倾向于使嵌入成为类别的有用表示。这被称为表示学习(您将在第十七章中看到其他类型的表示学习)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/hsn-ml-3e/img/mls3_1306.png
图 13-6。嵌入将在训练过程中逐渐改进
Keras 提供了一个Embedding层,它包装了一个嵌入矩阵:这个矩阵每行对应一个类别,每列对应一个嵌入维度。默认情况下,它是随机初始化的。要将类别 ID 转换为嵌入,Embedding层只需查找并返回对应于该类别的行。就是这样!例如,让我们用五行和 2D 嵌入初始化一个Embedding层,并用它来编码一些类别:
>>> tf.random.set_seed(42)
>>> embedding_layer = tf.keras.layers.Embedding(input_dim=5, output_dim=2)
>>> embedding_layer(np.array([2, 4, 2]))
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[-0.04663396, 0.01846724],
[-0.02736737, -0.02768031],
[-0.04663396, 0.01846724]], dtype=float32)>
正如您所看到的,类别 2 被编码(两次)为 2D 向量[-0.04663396, 0.01846724],而类别 4 被编码为[-0.02736737, -0.02768031]。由于该层尚未训练,这些编码只是随机的。
警告
Embedding层是随机初始化的,因此除非使用预训练权重初始化,否则在模型之外作为独立的预处理层使用它是没有意义的。
如果要嵌入一个分类文本属性,您可以简单地将StringLookup层和Embedding层连接起来,就像这样:
>>> tf.random.set_seed(42)
>>> ocean_prox = ["<1H OCEAN", "INLAND", "NEAR OCEAN", "NEAR BAY", "ISLAND"]
>>> str_lookup_layer = tf.keras.layers.StringLookup()
>>> str_lookup_layer.adapt(ocean_prox)
>>> lookup_and_embed = tf.keras.Sequential([
... str_lookup_layer,
... tf.keras.layers.Embedding(input_dim=str_lookup_layer.vocabulary_size(),
... output_dim=2)
... ])
...
>>> lookup_and_embed(np.array([["<1H OCEAN"], ["ISLAND"], ["<1H OCEAN"]]))
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[-0.01896119, 0.02223358],
[ 0.02401174, 0.03724445],
[-0.01896119, 0.02223358]], dtype=float32)>
请注意,嵌入矩阵中的行数需要等于词汇量的大小:这是总类别数,包括已知类别和 OOV 桶(默认只有一个)。StringLookup类的vocabulary_size()方法方便地返回这个数字。
提示
在这个例子中,我们使用了 2D 嵌入,但一般来说,嵌入通常有 10 到 300 个维度,取决于任务、词汇量和训练集的大小。您将需要调整这个超参数。
将所有内容放在一起,现在我们可以创建一个 Keras 模型,可以处理分类文本特征以及常规数值特征,并为每个类别(以及每个 OOV 桶)学习一个嵌入:
X_train_num, X_train_cat, y_train = [...] # load the training set
X_valid_num, X_valid_cat, y_valid = [...] # and the validation set
num_input = tf.keras.layers.Input(shape=[8], name="num")
cat_input = tf.keras.layers.Input(shape=[], dtype=tf.string, name="cat")
cat_embeddings = lookup_and_embed(cat_input)
encoded_inputs = tf.keras.layers.concatenate([num_input, cat_embeddings])
outputs = tf.keras.layers.Dense(1)(encoded_inputs)
model = tf.keras.models.Model(inputs=[num_input, cat_input], outputs=[outputs])
model.compile(loss="mse", optimizer="sgd")
history = model.fit((X_train_num, X_train_cat), y_train, epochs=5,
validation_data=((X_valid_num, X_valid_cat), y_valid))
这个模型有两个输入:num_input,每个实例包含八个数值特征,以及cat_input,每个实例包含一个分类文本输入。该模型使用我们之前创建的lookup_and_embed模型来将每个海洋接近类别编码为相应的可训练嵌入。接下来,它使用concatenate()函数将数值输入和嵌入连接起来,生成完整的编码输入,准备输入神经网络。在这一点上,我们可以添加任何类型的神经网络,但为了简单起见,我们只添加一个单一的密集输出层,然后我们创建 KerasModel,使用我们刚刚定义的输入和输出。接下来,我们编译模型并训练它,传递数值和分类输入。
正如您在第十章中看到的,由于Input层的名称是"num"和"cat",我们也可以将训练数据传递给fit()方法,使用字典而不是元组:{"num": X_train_num, "cat": X_train_cat}。或者,我们可以传递一个包含批次的tf.data.Dataset,每个批次表示为((X_batch_num, X_batch_cat), y_batch)或者({"num": X_batch_num, "cat": X_batch_cat}, y_batch)。当然,验证数据也是一样的。
注意
先进行独热编码,然后通过一个没有激活函数和偏置的Dense层等同于一个Embedding层。然而,Embedding层使用的计算量要少得多,因为它避免了许多零乘法——当嵌入矩阵的大小增长时,性能差异变得明显。Dense层的权重矩阵起到了嵌入矩阵的作用。例如,使用大小为 20 的独热向量和一个具有 10 个单元的Dense层等同于使用一个input_dim=20和output_dim=10的Embedding层。因此,在Embedding层后面的层中使用的嵌入维度不应该超过单元数。
好了,现在您已经学会了如何对分类特征进行编码,是时候将注意力转向文本预处理了。
文本预处理
Keras 为基本文本预处理提供了一个TextVectorization层。与StringLookup层类似,您必须在创建时传递一个词汇表,或者使用adapt()方法从一些训练数据中学习词汇表。让我们看一个例子:
>>> train_data = ["To be", "!(to be)", "That's the question", "Be, be, be."]
>>> text_vec_layer = tf.keras.layers.TextVectorization()
>>> text_vec_layer.adapt(train_data)
>>> text_vec_layer(["Be good!", "Question: be or be?"])
<tf.Tensor: shape=(2, 4), dtype=int64, numpy=
array([[2, 1, 0, 0],
[6, 2, 1, 2]])>
两个句子“Be good!”和“Question: be or be?”分别被编码为[2, 1, 0, 0]和[6, 2, 1, 2]。词汇表是从训练数据中的四个句子中学习的:“be” = 2,“to” = 3,等等。为构建词汇表,adapt()方法首先将训练句子转换为小写并去除标点,这就是为什么“Be”、“be”和“be?”都被编码为“be” = 2。接下来,句子被按空格拆分,生成的单词按降序频率排序,产生最终的词汇表。在编码句子时,未知单词被编码为 1。最后,由于第一个句子比第二个句子短,因此用 0 进行了填充。
提示
TextVectorization层有许多选项。例如,您可以通过设置standardize=None来保留大小写和标点,或者您可以将任何标准化函数作为standardize参数传递。您可以通过设置split=None来防止拆分,或者您可以传递自己的拆分函数。您可以设置output_sequence_length参数以确保输出序列都被裁剪或填充到所需的长度,或者您可以设置ragged=True以获得一个不规则张量而不是常规张量。请查看文档以获取更多选项。
单词 ID 必须进行编码,通常使用Embedding层:我们将在第十六章中进行这样做。或者,您可以将TextVectorization层的output_mode参数设置为"multi_hot"或"count"以获得相应的编码。然而,简单地计算单词通常不是理想的:像“to”和“the”这样的单词非常频繁,几乎没有影响,而“basketball”等更稀有的单词则更具信息量。因此,通常最好将output_mode设置为"tf_idf",它代表词频 × 逆文档频率(TF-IDF)。这类似于计数编码,但在训练数据中频繁出现的单词被降权,反之,稀有单词被升权。例如:
>>> text_vec_layer = tf.keras.layers.TextVectorization(output_mode="tf_idf")
>>> text_vec_layer.adapt(train_data)
>>> text_vec_layer(["Be good!", "Question: be or be?"])
<tf.Tensor: shape=(2, 6), dtype=float32, numpy=
array([[0.96725637, 0.6931472 , 0\. , 0\. , 0\. , 0\. ],
[0.96725637, 1.3862944 , 0\. , 0\. , 0\. , 1.0986123 ]], dtype=float32)>
TF-IDF 的变体有很多种,但TextVectorization层实现的方式是将每个单词的计数乘以一个权重,该权重等于 log(1 + d / (f + 1)),其中d是训练数据中的句子总数(也称为文档),f表示这些训练句子中包含给定单词的数量。例如,在这种情况下,训练数据中有d = 4 个句子,单词“be”出现在f = 3 个句子中。由于单词“be”在句子“Question: be or be?”中出现了两次,它被编码为 2 × log(1 + 4 / (1 + 3)) ≈ 1.3862944。单词“question”只出现一次,但由于它是一个不太常见的单词,它的编码几乎一样高:1 × log(1 + 4 / (1 + 1)) ≈ 1.0986123。请注意,对于未知单词,使用平均权重。
这种文本编码方法易于使用,并且对于基本的自然语言处理任务可以得到相当不错的结果,但它有几个重要的局限性:它只适用于用空格分隔单词的语言,它不区分同音异义词(例如“to bear”与“teddy bear”),它不提示您的模型单词“evolution”和“evolutionary”之间的关系等。如果使用多热编码、计数或 TF-IDF 编码,则单词的顺序会丢失。那么还有哪些其他选项呢?
一种选择是使用TensorFlow Text 库,它提供比TextVectorization层更高级的文本预处理功能。例如,它包括几种子词标记器,能够将文本分割成比单词更小的标记,这使得模型更容易检测到“evolution”和“evolutionary”之间有一些共同之处(有关子词标记化的更多信息,请参阅第十六章)。
另一个选择是使用预训练的语言模型组件。现在让我们来看看这个。
使用预训练语言模型组件
TensorFlow Hub 库使得在您自己的模型中重用预训练模型组件变得容易,用于文本、图像、音频等。这些模型组件称为模块。只需浏览TF Hub 存储库,找到您需要的模块,将代码示例复制到您的项目中,模块将自动下载并捆绑到一个 Keras 层中,您可以直接包含在您的模型中。模块通常包含预处理代码和预训练权重,并且通常不需要额外的训练(但当然,您的模型的其余部分肯定需要训练)。
例如,一些强大的预训练语言模型是可用的。最强大的模型非常庞大(几个千兆字节),因此为了快速示例,让我们使用nnlm-en-dim50模块,版本 2,这是一个相当基本的模块,它将原始文本作为输入并输出 50 维句子嵌入。我们将导入 TensorFlow Hub 并使用它来加载模块,然后使用该模块将两个句子编码为向量:
>>> import tensorflow_hub as hub
>>> hub_layer = hub.KerasLayer("https://tfhub.dev/google/nnlm-en-dim50/2")
>>> sentence_embeddings = hub_layer(tf.constant(["To be", "Not to be"]))
>>> sentence_embeddings.numpy().round(2)
array([[-0.25, 0.28, 0.01, 0.1 , [...] , 0.05, 0.31],
[-0.2 , 0.2 , -0.08, 0.02, [...] , -0.04, 0.15]], dtype=float32)
hub.KerasLayer层从给定的 URL 下载模块。这个特定的模块是一个句子编码器:它将字符串作为输入,并将每个字符串编码为单个向量(在本例中是一个 50 维向量)。在内部,它解析字符串(在空格上拆分单词)并使用在一个巨大的语料库上预训练的嵌入矩阵嵌入每个单词:Google News 7B 语料库(七十亿字长!)。然后计算所有单词嵌入的平均值,结果就是句子嵌入。
您只需要在您的模型中包含这个hub_layer,然后就可以开始了。请注意,这个特定的语言模型是在英语上训练的,但许多其他语言也可用,以及多语言模型。
最后,由 Hugging Face 提供的优秀开源Transformers 库也使得在您自己的模型中包含强大的语言模型组件变得容易。您可以浏览Hugging Face Hub,选择您想要的模型,并使用提供的代码示例开始。它以前只包含语言模型,但现在已扩展到包括图像模型等。
我们将在第十六章中更深入地讨论自然语言处理。现在让我们看一下 Keras 的图像预处理层。
图像预处理层
Keras 预处理 API 包括三个图像预处理层:
-
tf.keras.layers.Resizing将输入图像调整为所需大小。例如,Resizing(height=100, width=200)将每个图像调整为 100×200,可能会扭曲图像。如果设置crop_to_aspect_ratio=True,则图像将被裁剪到目标图像比例,以避免扭曲。 -
tf.keras.layers.Rescaling重新缩放像素值。例如,Rescaling(scale=2/255, offset=-1)将值从 0 → 255 缩放到-1 → 1。 -
tf.keras.layers.CenterCrop裁剪图像,保留所需高度和宽度的中心区域。
例如,让我们加载一些示例图像并对它们进行中心裁剪。为此,我们将使用 Scikit-Learn 的load_sample_images()函数;这将加载两个彩色图像,一个是中国寺庙的图像,另一个是花朵的图像(这需要 Pillow 库,如果您正在使用 Colab 或者按照安装说明进行操作,应该已经安装):
from sklearn.datasets import load_sample_images
images = load_sample_images()["images"]
crop_image_layer = tf.keras.layers.CenterCrop(height=100, width=100)
cropped_images = crop_image_layer(images)
Keras 还包括几个用于数据增强的层,如RandomCrop、RandomFlip、RandomTranslation、RandomRotation、RandomZoom、RandomHeight、RandomWidth和RandomContrast。这些层仅在训练期间激活,并随机对输入图像应用一些转换(它们的名称是不言自明的)。数据增强将人为增加训练集的大小,通常会导致性能提升,只要转换后的图像看起来像真实的(非增强的)图像。我们将在下一章更详细地介绍图像处理。
注意
在幕后,Keras 预处理层基于 TensorFlow 的低级 API。例如,Normalization层使用tf.nn.moments()来计算均值和方差,Discretization层使用tf.raw_ops.Bucketize(),CategoricalEncoding使用tf.math.bincount(),IntegerLookup和StringLookup使用tf.lookup包,Hashing和TextVectorization使用tf.strings包中的几个操作,Embedding使用tf.nn.embedding_lookup(),图像预处理层使用tf.image包中的操作。如果 Keras 预处理 API 不满足您的需求,您可能偶尔需要直接使用 TensorFlow 的低级 API。
现在让我们看看在 TensorFlow 中另一种轻松高效地加载数据的方法。
TensorFlow 数据集项目
TensorFlow 数据集(TFDS)项目使加载常见数据集变得非常容易,从小型数据集如 MNIST 或 Fashion MNIST 到像 ImageNet 这样的大型数据集(您将需要相当大的磁盘空间!)。列表包括图像数据集、文本数据集(包括翻译数据集)、音频和视频数据集、时间序列等等。您可以访问https://homl.info/tfds查看完整列表,以及每个数据集的描述。您还可以查看了解您的数据,这是一个用于探索和理解 TFDS 提供的许多数据集的工具。
TFDS 并未与 TensorFlow 捆绑在一起,但如果您在 Colab 上运行或者按照https://homl.info/install的安装说明进行安装,那么它已经安装好了。然后您可以导入tensorflow_datasets,通常为tfds,然后调用tfds.load()函数,它将下载您想要的数据(除非之前已经下载过),并将数据作为数据集字典返回(通常一个用于训练,一个用于测试,但这取决于您选择的数据集)。例如,让我们下载 MNIST:
import tensorflow_datasets as tfds
datasets = tfds.load(name="mnist")
mnist_train, mnist_test = datasets["train"], datasets["test"]
然后您可以应用任何您想要的转换(通常是洗牌、批处理和预取),然后准备训练您的模型。这里是一个简单的示例:
for batch in mnist_train.shuffle(10_000, seed=42).batch(32).prefetch(1):
images = batch["image"]
labels = batch["label"]
# [...] do something with the images and labels
提示
load()函数可以对其下载的文件进行洗牌:只需设置shuffle_files=True。但是这可能不够,最好对训练数据进行更多的洗牌。
请注意,数据集中的每个项目都是一个包含特征和标签的字典。但是 Keras 期望每个项目是一个包含两个元素的元组(再次,特征和标签)。您可以使用map()方法转换数据集,就像这样:
mnist_train = mnist_train.shuffle(buffer_size=10_000, seed=42).batch(32)
mnist_train = mnist_train.map(lambda items: (items["image"], items["label"]))
mnist_train = mnist_train.prefetch(1)
但是通过设置as_supervised=True,让load()函数为您执行此操作会更简单(显然,这仅适用于带标签的数据集)。
最后,TFDS 提供了一种方便的方法来使用split参数拆分数据。例如,如果您想要使用训练集的前 90%进行训练,剩余的 10%进行验证,整个测试集进行测试,那么您可以设置split=["train[:90%]", "train[90%:]", "test"]。load()函数将返回所有三个集合。这里是一个完整的示例,使用 TFDS 加载和拆分 MNIST 数据集,然后使用这些集合来训练和评估一个简单的 Keras 模型:
train_set, valid_set, test_set = tfds.load(
name="mnist",
split=["train[:90%]", "train[90%:]", "test"],
as_supervised=True
)
train_set = train_set.shuffle(buffer_size=10_000, seed=42).batch(32).prefetch(1)
valid_set = valid_set.batch(32).cache()
test_set = test_set.batch(32).cache()
tf.random.set_seed(42)
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28)),
tf.keras.layers.Dense(10, activation="softmax")
])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=5)
test_loss, test_accuracy = model.evaluate(test_set)
恭喜,您已经到达了这个相当技术性的章节的结尾!您可能会觉得它与神经网络的抽象美有些远,但事实是深度学习通常涉及大量数据,知道如何高效加载、解析和预处理数据是一项至关重要的技能。在下一章中,我们将看一下卷积神经网络,这是图像处理和许多其他应用中最成功的神经网络架构之一。
练习
-
为什么要使用 tf.data API?
-
将大型数据集拆分为多个文件的好处是什么?
-
在训练过程中,如何判断您的输入管道是瓶颈?您可以做些什么来解决它?
-
您可以将任何二进制数据保存到 TFRecord 文件中吗,还是只能序列化协议缓冲区?
-
为什么要费心将所有数据转换为
Example协议缓冲区格式?为什么不使用自己的协议缓冲区定义? -
在使用 TFRecords 时,何时应该激活压缩?为什么不系统地这样做?
-
数据可以在编写数据文件时直接进行预处理,或者在 tf.data 管道中进行,或者在模型内的预处理层中进行。您能列出每个选项的一些优缺点吗?
-
列举一些常见的编码分类整数特征的方法。文本呢?
-
加载时尚 MNIST 数据集(在第十章中介绍);将其分为训练集、验证集和测试集;对训练集进行洗牌;并将每个数据集保存到多个 TFRecord 文件中。每个记录应该是一个序列化的
Example协议缓冲区,具有两个特征:序列化图像(使用tf.io.serialize_tensor()来序列化每个图像),和标签。然后使用 tf.data 为每个集创建一个高效的数据集。最后,使用 Keras 模型来训练这些数据集,包括一个预处理层来标准化每个输入特征。尝试使输入管道尽可能高效,使用 TensorBoard 来可视化分析数据。 -
在这个练习中,您将下载一个数据集,将其拆分,创建一个
tf.data.Dataset来高效加载和预处理数据,然后构建和训练一个包含Embedding层的二元分类模型:-
下载大型电影评论数据集,其中包含来自互联网电影数据库(IMDb)的 50,000 条电影评论。数据组织在两个目录中,train和test,每个目录包含一个pos子目录,其中包含 12,500 条正面评论,以及一个neg子目录,其中包含 12,500 条负面评论。每个评论存储在单独的文本文件中。还有其他文件和文件夹(包括预处理的词袋版本),但在这个练习中我们将忽略它们。
-
将测试集分为验证集(15,000)和测试集(10,000)。
-
使用 tf.data 为每个集创建一个高效的数据集。
-
创建一个二元分类模型,使用
TextVectorization层来预处理每个评论。 -
添加一个
Embedding层,并计算每个评论的平均嵌入,乘以单词数量的平方根(参见第十六章)。然后将这个重新缩放的平均嵌入传递给您模型的其余部分。 -
训练模型并查看您获得的准确性。尝试优化您的管道,使训练尽可能快。
-
使用 TFDS 更轻松地加载相同的数据集:
tfds.load("imdb_reviews")。
-
这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ 想象一副排好序的扑克牌在您的左边:假设您只拿出前三张牌并洗牌,然后随机选取一张放在右边,将另外两张留在手中。再从左边拿一张牌,在手中的三张牌中洗牌,随机选取一张放在右边。当您像这样处理完所有的牌后,您的右边将有一副扑克牌:您认为它会被完美洗牌吗?
² 一般来说,只预取一个批次就可以了,但在某些情况下,您可能需要预取更多。或者,您可以通过将tf.data.AUTOTUNE传递给prefetch(),让 TensorFlow 自动决定。
³ 但是请查看实验性的tf.data.experimental.prefetch_to_device()函数,它可以直接将数据预取到 GPU。任何带有experimental的 TensorFlow 函数或类的名称可能会在未来版本中发生更改而没有警告。如果实验性函数失败,请尝试删除experimental一词:它可能已经移至核心 API。如果没有,请查看笔记本,我会确保其中包含最新的代码。
⁴ 由于 protobuf 对象旨在被序列化和传输,它们被称为消息。
⁵ 本章包含了您使用 TFRecords 所需了解的最基本知识。要了解更多关于 protobufs 的信息,请访问https://homl.info/protobuf。
⁶ Tomáš Mikolov 等人,“单词和短语的分布式表示及其组合性”,第 26 届国际神经信息处理系统会议论文集 2(2013):3111–3119。
⁷ Malvina Nissim 等人,“公平比耸人听闻更好:男人对医生,女人对医生”,arXiv 预印本 arXiv:1905.09866(2019)。
⁸ TensorFlow Hub 没有与 TensorFlow 捆绑在一起,但如果您在 Colab 上运行或者按照https://homl.info/install的安装说明进行安装,那么它已经安装好了。
⁹ 要精确,句子嵌入等于句子中单词嵌入的平均值乘以句子中单词数的平方根。这是为了弥补随着n增长,n个随机向量的平均值会变短的事实。
¹⁰ 对于大图像,您可以使用tf.io.encode_jpeg()。这将节省大量空间,但会损失一些图像质量。
1093

被折叠的 条评论
为什么被折叠?



