【DL】第12章 使用 TensorFlow 进行自定义模型和训练

  🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎

📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃

🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝​

📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

 🖍foreword

✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

如果你对这个系列感兴趣的话,可以关注订阅哟👋

到目前为止,我们已经只使用了 TensorFlow 的高级 API tf.keras,但它已经让我们走得很远:我们使用各种技术构建了各种神经网络架构,包括回归和分类网络、Wide & Deep 网络和自归一化网络,例如批量标准化、辍学和学习率计划。事实上,你将遇到的 95% 的用例除了 tf.keras(和 tf.data;参见第 13 章)之外不需要任何东西。但现在是时候深入了解 TensorFlow 并看看它的底层Python API. 当您需要额外的控制来编写自定义损失函数、自定义指标、层、模型、初始化程序、正则化程序、权重约束等时,这将很有用。您甚至可能需要完全控制训练循环本身,例如对梯度应用特殊的变换或约束(不仅仅是裁剪它们)或对网络的不同部分使用多个优化器。我们将在本章中介绍所有这些案例,我们还将了解如何使用 TensorFlow 的自动图形生成功能来提升您的自定义模型和训练算法。但首先,让我们快速浏览一下 TensorFlow。

笔记

TensorFlow 2.0(测试版)是于 2019 年 6 月发布,使 TensorFlow 更易于使用。本书第一版使用TF 1,而本版使用TF 2。

TensorFlow 快速浏览

作为你知道,TensorFlow 是一个强大的数值计算库,特别适合大规模机器学习并对其进行了微调(但你可以将它用于需要大量计算的其他任何事情)。它由 Google Brain 团队开发,为 Google 的许多大型服务提供支持,例如 Google Cloud Speech、Google Photos 和 Google Search。它于 2015 年 11 月开源,现在是最受欢迎的深度学习库(在论文引用、公司采用、GitHub 上的明星等方面)。无数项目将 TensorFlow 用于各种机器学习任务,例如图像分类、自然语言处理、推荐系统和时间序列预测。

那么 TensorFlow 提供了什么?这是一个摘要:

  • 它的核心与 NumPy 非常相似,但支持 GPU。

  • 它支持分布式计算(跨多个设备和服务器)。

  • 包括一种即时 (JIT) 编译器,允许它优化计算速度和内存使用。它工作原理是从 Python 函数中提取计算图,然后对其进行优化(例如,通过修剪未使用的节点),最后高效地运行它(例如,通过自动并行运行独立操作)。

  • 计算图可以导出为可移植格式,因此您可以在一个环境中训练 TensorFlow 模型(例如,在 Linux 上使用 Python)并在另一个环境中运行它(例如,在 Android 设备上使用 Java)。

  • 它实现了 autodiff(参见第 10 章和附录D)并提供了一些出色的优化器,例如 RMSProp 和 Nadam(参见第 11 章),因此您可以轻松地最小化各种损失函数。

TensorFlow 在这些核心特性之上提供了更多特性:最重要的当然是tf.keras1,它也有数据加载和预处理操作(tf.datatf.io等)、图像处理操作(tf.image)、信号处理操作(tf.signal)和更多(有关 TensorFlow 的 Python API 的概述,请参见图 12-1 )。

小费

我们将介绍 TensorFlow API 的许多包和功能,但不可能全部涵盖,因此您应该花一些时间浏览 API;您会发现它非常丰富且有据可查。

图 12-1。TensorFlow 的 Python API

在最低级别,每个 TensorFlow 操作(简称op)都是使用高效的 C++ 代码实现的。2很多操作都有称为内核的多个实现:每个内核专用于特定的设备类型,例如 CPU、GPU 甚至TPU(张量处理单元)。您可能知道,GPU 可以通过将计算分成许多更小的块并在许多 GPU 线程上并行运行它们来显着加快计算速度。TPU 甚至更快:它们是专为深度学习操作3构建的定制 ASIC 芯片(我们将在第 19 章讨论如何将 TensorFlow 与 GPU 或 TPU 一起使用)。

TensorFlow 的架构如图 12-2所示。大多数情况下,您的代码将使用高级 API(尤其是 tf.keras 和 tf.data);但是当您需要更大的灵活性时,您将使用较低级别的 Python API,直接处理张量。请注意,其他语言的 API 也可用。在任何情况下,TensorFlow 的执行引擎都会负责高效地运行操作,即使您告诉它跨多个设备和机器也是如此。

图 12-2。TensorFlow 的架构

TensorFlow不仅可以在 Windows、Linux 和 macOS 上运行,还可以在移动设备(使用TensorFlow Lite)上运行,包括 iOS 和 Android(参见第 19 章)。如果您不想使用 Python API,可以使用 C++、Java、Go 和 Swift API。甚至还有一个名为TensorFlow.js的 JavaScript 实现,可以让您直接在浏览器中运行模型。

TensorFlow 比库更多。TensorFlow 处于广泛的图书馆生态系统的中心。首先,有用于可视化的 TensorBoard(参见第 10 章)。接下来是TensorFlow Extended (TFX),它是 Google 为生产 TensorFlow 项目而构建的一组库:它包括用于数据验证、预处理、模型分析和服务的工具(使用 TF Serving;参见第 19 章)。谷歌的 TensorFlow Hub提供了一种轻松下载和重用预训练神经网络的方法。您还可以在 TensorFlow 的模型花园中获得许多神经网络架构,其中一些是预训练的。查看TensorFlow 资源https://github.com/jtoy/awesome-tensorflow了解更多基于 TensorFlow 的项目。您会在 GitHub 上找到数百个 TensorFlow 项目,因此通常很容易为您尝试做的任何事情找到现有代码。

小费

更多的更多的机器学习论文连同它们的实现一起发布,有时甚至与预训练的模型一起发布。查看The latest in Machine Learning | Papers With Code以轻松找到它们。

最后的但同样重要的是,TensorFlow 拥有一支由热情和乐于助人的开发人员组成的专门团队,以及一个致力于改进它的大型社区。要提出技术问题,您应该使用Stack Overflow - Where Developers Learn, Share, & Build Careers并使用tensorflowpython标记您的问题。您可以通过GitHub提交错误和功能请求 。如需一般性讨论,请加入Google 群组

好的,是时候开始编码了!

像 NumPy 一样使用 TensorFlow

TensorFlow 的 API旋转围绕张量,从一个操作流向另一个操作——因此得名张量。张量与 NumPy 非常相似ndarray:它通常是一个多维数组,但它也可以保存一个标量(一个简单的值,例如42)。当我们创建自定义成本函数、自定义指标、自定义层等时,这些张量将很重要,所以让我们看看如何创建和操作它们。

张量和操作

可以创建一个张量tf.constant()。例如,这是一个张量,它表示一个具有两行和三列浮点数的矩阵:

>>> tf.constant([[1., 2., 3.], [4., 5., 6.]]) # matrix
<tf.Tensor: id=0, shape=(2, 3), dtype=float32, numpy=
array([[1., 2., 3.],
       [4., 5., 6.]], dtype=float32)>
>>> tf.constant(42) # scalar
<tf.Tensor: id=1, shape=(), dtype=int32, numpy=42>

就像 a 一样ndarray,atf.Tensor有一个形状和一个数据类型 ( dtype):

>>> t = tf.constant([[1., 2., 3.], [4., 5., 6.]])
>>> t.shape
TensorShape([2, 3])
>>> t.dtype
tf.float32

索引的工作方式很像 NumPy:

>>> t[:, 1:]
<tf.Tensor: id=5, shape=(2, 2), dtype=float32, numpy=
array([[2., 3.],
       [5., 6.]], dtype=float32)>
>>> t[..., 1, tf.newaxis]
<tf.Tensor: id=15, shape=(2, 1), dtype=float32, numpy=
array([[2.],
       [5.]], dtype=float32)>

最重要的是,可以使用各种张量操作:

>>> t + 10
<tf.Tensor: id=18, shape=(2, 3), dtype=float32, numpy=
array([[11., 12., 13.],
       [14., 15., 16.]], dtype=float32)>
>>> tf.square(t)
<tf.Tensor: id=20, shape=(2, 3), dtype=float32, numpy=
array([[ 1.,  4.,  9.],
       [16., 25., 36.]], dtype=float32)>
>>> t @ tf.transpose(t)
<tf.Tensor: id=24, 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.multiply()tf.square()tf.exp()tf.sqrt()等)以及您可以在 NumPy 中找到的大多数运算(例如 、、tf.reshape())。有些函数的名称与 NumPy 中的不同;例如,、、和等价于、和。当名称不同时,通常有一个很好的理由。例如,在 TensorFlow 中,您必须编写; 你不能像在 NumPy 中那样写。原因是该函数与 NumPy 的属性做的事情并不完全相同:在 TensorFlow 中,使用自己的转置数据副本创建一个新张量,而在 NumPy 中,tf.squeeze()tf.tile()tf.reduce_mean()tf.reduce_sum()tf.reduce_max()tf.math.log()np.mean()np.sum()np.max()np.log()tf.transpose(t)t.Ttf.transpose()Tt.T只是对相同数据的转置视图。类似地,该tf.reduce_sum()操作之所以这样命名,是因为它的 GPU 内核(即 GPU 实现)使用了一种 reduce 算法,该算法不能保证元素添加的顺序:因为 32 位浮点数的精度有限,所以结果可能会不断变化每次调用此操作时略。也是如此tf.reduce_mean()(但当然tf.reduce_max()是确定性的)。

笔记

许多函数和类都有别名。比如tf.add()tf.math.add()都是一样的功能。这允许 TensorFlow 为最常见的操作4提供简洁的名称,同时保留组织良好的包。

硬'低级 API

Keras API 有自己的低级 API,位于keras.backend. 它包括square()exp()和等函数sqrt()。在 tf.keras 中,这些函数一般只是调用相应的 TensorFlow 操作。如果您想编写可移植到其他 Keras 实现的代码,您应该使用这些 Keras 函数。然而,它们只涵盖了 TensorFlow 中所有可用函数的一个子集,因此在本书中我们将直接使用 TensorFlow 操作。以下是使用 的简单示例keras.backend,通常K简称为:

>>> from tensorflow import keras
>>> K = keras.backend
>>> K.square(K.transpose(t)) + 10
<tf.Tensor: id=39, shape=(3, 2), dtype=float32, numpy=
array([[11., 26.],
       [14., 35.],
       [19., 46.]], dtype=float32)>

张量和 NumPy

张量使用 NumPy 玩得很好:您可以从 NumPy 数组创建张量,反之亦然。您甚至可以将 TensorFlow 操作应用于 NumPy 数组并将 NumPy 操作应用于张量:

>>> 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 位精度对于神经网络来说通常绰绰有余,而且它运行速度更快并且使用的 RAM 更少。因此,当您从 NumPy 数组创建张量时,请确保设置dtype=tf.float32.

类型转换

类型转换会严重影响性能,并且当它们自动完成时很容易被忽视。为避免这种情况,TensorFlow 不会自动执行任何类型转换:如果您尝试对类型不兼容的张量执行操作,它只会引发异常。例如,不能添加浮点张量和整数张量,甚至不能添加 32 位浮点数和 64 位浮点数:

>>> tf.constant(2.) + tf.constant(40)
Traceback[...]InvalidArgumentError[...]expected to be a float[...]
>>> tf.constant(2.) + tf.constant(40., dtype=tf.float64)
Traceback[...]InvalidArgumentError[...]expected to be a double[...]

起初这可能有点烦人,但请记住,这是有原因的!当然,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)>

A 的tf.Variable行为很像 a tf.Tensor:您可以用它执行相同的操作,它也可以很好地与 NumPy 配合使用,而且它对类型也很挑剔。但也可以使用assign()方法(或assign_add()assign_sub(),将变量递增或递减给定值)就地修改它。您还可以修改单个单元格(或切片),通过使用单元格(或切片)的assign()方法(直接项分配将不起作用)或使用scatter_update()orscatter_nd_update()方法:

v.assign(2 * v)           # => [[2., 4., 6.], [8., 10., 12.]]
v[0, 1].assign(42)        # => [[2., 42., 6.], [8., 10., 12.]]
v[:, 2].assign([0., 1.])  # => [[2., 42., 0.], [8., 10., 1.]]
v.scatter_nd_update(indices=[[0, 0], [1, 2]], updates=[100., 200.])
                          # => [[100., 42., 0.], [8., 10., 200.]]

笔记

实际上,您很少需要手动创建变量,因为 Keras 提供了一种add_weight()方法来为您处理它,正如我们将看到的那样。此外,模型参数通常会由优化器直接更新,因此您很少需要手动更新变量。

其他数据结构

TensorFlow支持其他几种数据结构,包括以下(请参阅笔记本中的“张量和操作”部分或附录 F了解更多详细信息):

稀疏张量 ( tf.SparseTensor)

有效率的表示主要包含零的张量. 该tf.sparse包包含稀疏张量的操作。

张量数组 ( tf.TensorArray)

张量列表。默认情况下,它们具有固定大小,但可以选择动态(即可变)。它们包含的所有张量必须具有相同的形状和数据类型。

不规则张量 ( tf.RaggedTensor)

代表张量列表列表,其中每个张量具有相同的形状和数据类型。该tf.ragged包包含不规则张量的操作。

String tensors

类型的正则张量tf.string。这些代表字节字符串,而不是 Unicode 字符串,因此如果您使用 Unicode 字符串(例如,常规 Python 3 字符串,如 )创建字符串张量"café",那么它将自动编码为 UTF-8(例如,b"caf\xc3\xa9")。或者,您可以使用 类型的张量表示 Unicode 字符串tf.int32,其中每个项目表示一个 Unicode 代码点(例如,[99, 97, 102, 233])。该tf.strings包(带有s)包含字节字符串和 Unicode 字符串的操作(并将一个转换为另一个)。重要的是要注意 atf.string是原子的,这意味着它的长度不会出现在张量的形状中。一旦将其转换为 Unicode 张量(即,tf.int32保存 Unicode 代码点的类型的张量),长度就会出现在形状中。

Sets

表示为常规张量(或稀疏张量)。例如,tf.constant([[1, 2], [3, 4]])表示两个集合 {1, 2} 和​​ {3, 4}。更一般地说,每个集合都由张量最后一个轴上的向量表示。您可以使用包中的操作来操作集合tf.sets

Queues

跨多个步骤存储张量。TensorFlow 提供了多种队列:简单的先进先出(FIFO)队列(FIFOQueue),可以优先考虑一些项目(PriorityQueue),打乱他们的项目(RandomShuffleQueue),并通过填充( )批量处理不同形状的项目PaddingFIFOQueue。这些类都在tf.queue包中。

有了张量、操作、变量和各种数据结构供您使用,您现在可以自定义模型和训练算法了!

自定义模型和训练算法

让我们首先创建一个自定义损失函数,这是一个简单而常见的用例。

自定义损失函数

认为你想训练一个回归模型,但你的训练集有点吵。当然,您首先尝试通过删除或修复异常值来清理数据集,但这还不够;数据集仍然嘈杂。你应该使用哪个损失函数?这均方误差可能会过多地惩罚大错误并导致您的模型不精确。平均绝对误差不会过多地惩罚异常值,但训练可能需要一段时间才能收敛,而且训练后的模型可能不是很精确。这可能是使用 Huber 损失(在第 10 章中介绍)而不是旧的 MSE 的好时机。这Huber loss 目前不是官方 Keras API 的一部分,但它在 tf.keras 中可用(只需使用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 可以在请求时应用类权重或样本权重(参见第 10 章)。

现在您可以在编译 Keras 模型时使用此损失,然后训练您的模型:

model.compile(loss=huber_fn, optimizer="nadam")
model.fit(X_train, y_train, [...])

就是这样!对于训练期间的每个批次,Keras 将调用该huber_fn()函数来计算损失并使用它来执行梯度下降步骤。此外,它将跟踪自 epoch 开始以来的总损失,并显示平均损失。

但是当你保存模型时,这个自定义损失会发生什么?

保存和加载包含自定义组件的模型

保存包含自定义损失函数的模型可以正常工作,因为 Keras 保存了函数的名称。无论何时加载它,您都需要提供一个将函数名称映射到实际函数的字典。更一般地,当您加载包含自定义对象的模型时,您需要将名称映射到对象:

model = keras.models.load_model("my_model_with_a_custom_loss.h5",
                                custom_objects={"huber_fn": huber_fn})

在当前的实现中,–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 = keras.models.load_model("my_model_with_a_custom_loss_threshold_2.h5",
                                custom_objects={"huber_fn": create_huber(2.0)})

您可以通过创建该类的子keras.losses.Loss类,然后实现其get_config()方法来解决此问题:

class HuberLoss(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}

警告

Keras API 目前仅指定如何使用子类化来定义层、模型、回调和正则化器。如果您使用子类化构建其他组件(例如损失、度量、初始化器或约束),它们可能无法移植到其他 Keras 实现。Keras API 很可能会更新以指定所有这些组件的子类化。

让我们看一下这段代码:

  • 构造函数接受**kwargs它们并将它们传递给处理标准超参数的父构造函数:name损失和reduction用于聚合单个实例损失的算法。默认情况下,它是"sum_over_batch_size",这意味着损失将是实例损失的总和,由样本权重加权(如果有)除以批量大小(而不是权重总和,因此这不是加权平均值)。5其他可能的值为"sum""none"

  • call()方法获取标签和预测,计算所有实例损失,然后返回它们。

  • get_config()方法返回一个字典,将每个超参数名称映射到其值。它首先调用父类的get_config()方法,然后将新的超参数添加到这个字典中(注意方便的语法是在 Python 3.5 中添加的)。{**x}

然后,您可以在编译模型时使用此类的任何实例:

model.compile(loss=HuberLoss(2.), optimizer="nadam")

保存模型时,阈值将随之保存;当你加载模型时,你只需要将类名映射到类本身:

model = keras.models.load_model("my_model_with_a_custom_loss_class.h5",
                                custom_objects={"HuberLoss": HuberLoss})

当您保存模型时,Keras 会调用损失实例的get_config()方法并将配置作为 JSON 保存在 HDF5 文件中。当您加载模型时,它会调用from_config()类上的HuberLoss类方法:此方法由基类 ( Loss) 实现并创建该类的实例,并传递**config给构造函数。

这就是损失!那不是太难,是吗?同样简单的是自定义激活函数、初始化器、正则化器和约束。现在让我们看看这些。

自定义激活函数、初始化器、正则化器和约束

最多Keras 的功能,例如损失、正则化器、约束、初始化器、度量、激活函数、层,甚至完整模型,都可以以非常相同的方式进行定制。大多数时候,您只需要编写一个带有适当输入和输出的简单函数。以下是自定义激活函数(相当于keras.activations.softplus()or tf.nn.softplus())、自定义 Glorot 初始化器(相当于keras.initializers.glorot_normal())、自定义 ℓ 1正则化器(相当于keras.regularizers.l1(0.01))和确保权重均为正的自定义约束(相当于keras.constraints.nonneg()or )的示例tf.nn.relu()

def my_softplus(z): # note: tf.nn.softplus(z) better handles large inputs
    return tf.math.log(tf.exp(z) + 1.0)

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 = keras.layers.Dense(30, activation=my_softplus,
                           kernel_initializer=my_glorot_initializer,
                           kernel_regularizer=my_l1_regularizer,
                           kernel_constraint=my_positive_weights)

激活函数将应用于这Dense一层的输出,其结果将传递到下一层。层的权重将使用初始化器返回的值进行初始化。在每个训练步骤,权重将传递给正则化函数以计算正则化损失,该损失将添加到主要损失中以获得用于训练的最终损失。最后,每一步训练后都会调用约束函数,将层的权重替换为约束的权重。

如果函数具有需要与模型一起保存的超参数,那么您将需要对适当的类进行子类化,例如keras.regularizers.Regularizerkeras.constraints.Constraintkeras.initializers.Initializerkeras.layers.Layer(对于任何层,包括激活函数)。就像我们对自定义损失所做的一样,这里有一个简单的 ℓ 1正则化类,它保存了它的factor超参数(这次我们不需要调用父构造函数或get_config()方法,因为它们不是由父类定义的):

class MyL1Regularizer(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__()方法,或者实现正则化器、初始化器和约束的方法。正如我们现在所看到的,对于指标,情况有些不同。

自定义指标

损失和指标在概念上不是一回事:梯度下降使用损失(例如,交叉熵)来训练模型,因此它们必须是可微的(至少在评估它们的地方),并且它们的梯度不应处处为 0。另外,如果它们不容易被人类解释,那也没关系。相比之下,指标(例如准确度)用于评估模型:它们必须更易于解释,并且它们可以是不可微分的或处处具有 0 梯度。

也就是说,在大多数情况下,定义自定义度量函数与定义自定义损失函数完全相同。事实上,我们甚至可以使用我们之前创建的 Huber 损失函数作为度量;6它可以正常工作(并且持久性也可以以相同的方式工作,在这种情况下只保存函数的名称,"huber_fn"):

model.compile(loss="mse", optimizer="nadam", metrics=[create_huber(2.0)])

对于训练期间的每个批次,Keras 将计算此指标并跟踪其自 epoch 开始以来的平均值。大多数时候,这正是您想要的。但不总是!例如,考虑二元分类器的精度。正如我们在第 3 章中看到的,精度是真阳性的数量除以阳性预测的数量(包括真阳性和假阳性)。假设该模型在第一批中做出了五个正面预测,其中四个是正确的:那就是 80% 的准确率。然后假设模型在第二批中做出了三个积极的预测,但它们都是不正确的:第二批的精度为 0%。如果你只计算这两个精度的平均值,你会得到 40%。但是等一下——那不是这两个批次的模型精度!实际上,在 8 个正预测 (5 + 3) 中,总共有 4 个真正例 (4 + 0),因此总体精度是 50%,而不是 40%。我们需要的是一个对象,它可以跟踪真阳性的数量和假阳性的数量,并且可以在请求时计算它们的比率。这正是keras.metrics.Precision该类所做的:

>>> precision = keras.metrics.Precision()
>>> precision([0, 1, 1, 1, 0, 1, 0, 1], [1, 1, 0, 1, 0, 1, 0, 1])
<tf.Tensor: id=581729, 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: id=581780, shape=(), dtype=float32, numpy=0.5>

在这个例子中,我们创建了一个Precision对象,然后我们像函数一样使用它,将第一批的标签和预测传递给它,然后是第二批(注意我们也可以传递样本权重)。我们使用了与刚刚讨论的示例相同数量的真假阳性。第一批之后,返回80%的精度;然后在第二批之后,它返回 50%(这是迄今为止的整体精度,而不是第二批的精度)。这个被称为流式度量(或有状态度量),因为它是逐批逐步更新的。

在任何时候,我们都可以调用该result()方法来获取指标的当前值。我们还可以通过使用属性查看它的变量(跟踪真假阳性的数量)variables,我们可以使用方法重置这些变量reset_states()

>>> precision.result()
<tf.Tensor: id=581794, 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

如果您需要创建这样的流式指标,请创建该类的子keras.metrics.Metric类。这是一个简单的例子,它记录了 Huber 的总损失和到目前为止看到的实例数量。当询问结果时,它返回比率,这只是平均 Huber 损失:

class HuberMetric(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):
        metric = self.huber_fn(y_true, y_pred)
        self.total.assign_add(tf.reduce_sum(metric))
        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}

让我们看一下这段代码:7

  • 构造函数使用该add_weight()方法创建跟踪多个批次的指标状态所需的变量——在本例中,是所有 Huber 损失的总和 ( total) 和到目前为止看到的实例数 ( count)。如果您愿意,您可以手动创建变量。Keras 跟踪任何tf.Variable设置为属性的内容(更一般地说,任何“可跟踪”对象,例如图层或模型)。

  • update_state()当您将此类的实例用作函数时,将调用该方法(就像我们对Precision对象所做的那样)。它更新变量,给定一批的标签和预测(和样本权重,但在这种情况下我们忽略它们)。

  • result()方法计算并返回最终结果,在这种情况下是所有实例的平均 Huber 度量。当您将度量用作函数时,update_state()首先调用该方法,然后调用该result()方法,并返回其输出。

  • 我们还实现了该get_config()方法以确保threshold与模型一起保存。

  • reset_states()方法的默认实现将所有变量重置为 0.0(但如果需要,您可以覆盖它)。

笔记

Keras 将无缝处理变量持久性;无需采取任何行动。

当你使用一个简单的函数定义一个指标时,Keras 会自动为每个批次调用它,它会跟踪每个时期的平均值,就像我们手动做的一样。所以我们HuberMetric班的唯一好处就是threshold意志得救了。但是,当然,某些指标,例如精度,不能简单地在批次上平均:在这些情况下,除了实施流式指标之外别无选择。

现在我们已经构建了一个流式度量,构建一个自定义层看起来就像在公园里散步!

自定义图层

可能偶尔想要构建一个包含 TensorFlow 不提供默认实现的奇异层的架构。在这种情况下,您将需要创建一个自定义层。或者您可能只是想构建一个非常重复的架构,包含重复多次的相同层块,并且将每个层块视为单个层会很方便。例如,如果模型是层 A、B、C、A、B、C、A、B、C 的序列,那么您可能想要定义包含层 A、B、C 的自定义层 D,因此您的模型然后将只是 D,D,D。让我们看看如何构建自定义层。

首先,有些层没有权重,例如keras.layers.Flattenkeras.layers.ReLU。如果你想创建一个没有任何权重的自定义层,最简单的选择是编写一个函数并将其包装在一个keras.layers.Lambda层中。例如,以下层将指数函数应用于其输入:

exponential_layer = keras.layers.Lambda(lambda x: tf.exp(x))

然后可以像任何其他层一样使用此自定义层,使用 Sequential API、Functional API 或 Subclassing API。您也可以将其用作激活函数(或者您可以使用activation=tf.expactivation=keras.activations.exponential或简单地使用activation="exponential")。当要预测的值具有非常不同的尺度(例如,0.001、10.、1,000.)时,指数层有时用于回归模型的输出层。

正如您现在可能已经猜到的那样,要构建一个自定义的有状态层(即,具有权重的层),您需要创建该类的一个子keras.layers.Layer类。例如,以下类实现了Dense层的简化版本:

class MyDense(keras.layers.Layer):
    def __init__(self, units, activation=None, **kwargs):
        super().__init__(**kwargs)
        self.units = units
        self.activation = 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")
        super().build(batch_input_shape) # must be at the end

    def call(self, X):
        return self.activation(X @ self.kernel + self.bias)

    def compute_output_shape(self, batch_input_shape):
        return tf.TensorShape(batch_input_shape.as_list()[:-1] + [self.units])

    def get_config(self):
        base_config = super().get_config()
        return {**base_config, "units": self.units,
                "activation": keras.activations.serialize(self.activation)}

让我们看一下这段代码:

  • 构造函数将所有超参数作为参数(在本例中为unitsactivation),重要的是它还接受一个**kwargs参数。它调用父构造函数,将kwargs: 传递给它,它负责处理标准参数,例如input_shapetrainablename。然后它将超参数保存为属性,activation使用函数将参数转换为适当的激活函数keras.activations.get()(它接受函数、标准字符串,如"relu"or"selu"或简单地None)。8

  • 该方法的作用是通过为每个权重build()调用该方法来创建图层的变量。add_weight()第一次使用该层时调用该build()方法。那时,Keras 将知道这一层输入的形状,并将其传递给build()方法9,这通常是创建某些权重所必需的。例如,为了创建连接权重矩阵(即"kernel"),我们需要知道前一层的神经元数量:这对应于输入的最后一个维度的大小。在build()方法结束时(并且仅在结束时),您必须调用父级的build()方法:这告诉 Keras 该层已构建(它只是设置self.built=True)。

  • call()方法执行所需的操作。在这种情况下,我们计算输入X和层内核的矩阵乘法,我们添加偏置向量,并将激活函数应用于结果,这给了我们层的输出。

  • compute_output_shape()方法只返回该层输出的形状。在这种情况下,它与输入的形状相同,除了最后一个维度被替换为层中的神经元数量。请注意,在 tf.keras 中,形状是tf.TensorShape类的实例,您可以使用as_list().

  • get_config()方法就像在以前的自定义类中一样。请注意,我们通过调用保存激活函数的完整配置keras.activations.serialize()

您现在可以MyDense像使用任何其他图层一样使用图层!

笔记

您通常可以省略该compute_output_shape()方法,因为 tf.keras 会自动推断输出形状,除非图层是动态的(我们将很快看到)。在其他 Keras 实现中,此方法要么是必需的,要么其默认实现假定输出形状与输入形状相同。

要创建具有多个输入(例如Concatenate)的层,该call()方法的参数应该是一个包含所有输入的元组,类似地,该compute_output_shape()方法的参数应该是一个包含每个输入的批处理形状的元组。要创建具有多个输出的层,该call()方法应返回输出列表,并compute_output_shape()应返回批量输出形状列表(每个输出一个)。例如,以下玩具层接受两个输入并返回三个输出:

MyMultiLayer(keras.layers.Layer):
    def call(self, X):
        X1, X2 = X
        return [X1 + X2, X1 * X2, X1 / X2]

    def compute_output_shape(self, batch_input_shape):
        b1, b2 = batch_input_shape
        return [b1, b1, b1] # should probably handle broadcasting rules

该层现在可以像任何其他层一样使用,但当然只使用功能和子类 API,而不是顺序 API(它只接受具有一个输入和一个输出的层)。

如果您的层在训练和测试期间需要具有不同的行为(例如,如果它使用DropoutBatchNormalization层),那么您必须training在方法中添加一个参数call()并使用该参数来决定要做什么。例如,让我们创建一个在训练期间添加高斯噪声(用于正则化)但在测试期间不做任何事情的层(Keras 有一个做同样事情的层,keras.layers.GaussianNoise):

class MyGaussianNoise(keras.layers.Layer):
    def __init__(self, stddev, **kwargs):
        super().__init__(**kwargs)
        self.stddev = stddev

    def call(self, X, training=None):
        if training:
            noise = tf.random.normal(tf.shape(X), stddev=self.stddev)
            return X + noise
        else:
            return X

    def compute_output_shape(self, batch_input_shape):
        return batch_input_shape

有了它,您现在可以构建您需要的任何自定义层!现在让我们创建自定义模型。

定制模型

我们在第 10 章中,我们讨论了 Subclassing API 时,已经看过创建自定义模型类。10很简单:继承keras.Model类,在构造函数中创建层和变量,然后实现call()方法来做任何你想让模型做的事情。假设您要构建如图 12-3 所示的模型。

        图 12-3。自定义模型示例:具有包含跳过连接的自定义 ResidualBlock 层的任意模型

输入通过第一个密集层,然后通过一个由两个密集层和一个加法运算组成的残差块(正如我们将在第 14 章中看到的,一个残差块将其输入添加到其输出),然后通过同一个残差块,再经过三个次,然后通过第二个残差块,最终结果通过密集输出层。请注意,此模型没有多大意义;这只是一个示例,说明您可以轻松构建所需的任何类型的模型,甚至是包含循环和跳过连接的模型。要实现这个模型,最好先创建一个ResidualBlock层,因为我们将创建几个相同的块(我们可能希望在另一个模型中重用它):

class ResidualBlock(keras.layers.Layer):
    def __init__(self, n_layers, n_neurons, **kwargs):
        super().__init__(**kwargs)
        self.hidden = [keras.layers.Dense(n_neurons, activation="elu",
                                          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属性包含可跟踪对象(在本例中为层),因此它们的变量会自动添加到该层的变量列表中。这个类的其余部分是不言自明的。接下来,让我们使用 Subclassing API 来定义模型本身:

class ResidualRegressor(keras.Model):
    def __init__(self, output_dim, **kwargs):
        super().__init__(**kwargs)
        self.hidden1 = keras.layers.Dense(30, activation="elu",
                                          kernel_initializer="he_normal")
        self.block1 = ResidualBlock(2, 30)
        self.block2 = ResidualBlock(2, 30)
        self.out = 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()加载它,则必须在类和类中实现该方法(如我们之前所做的那样)。或者,您可以使用和方法保存和加载权重。keras.models.load_model()get_config()ResidualBlockResidualRegressorsave_weights()load_weights()

该类Model是该类的子Layer类,因此可以像层一样定义和使用模型。但是模型有一些额外的功能,当然包括它的compile()fit()evaluate()predict()方法(以及一些变体),以及get_layers()方法(可以按名称或索引返回模型的任何层)和save()方法(并支持keras.models.load_model()keras.models.clone_model())。

小费

如果模型提供的功能比层多,为什么不将每一层都定义为模型呢?好吧,从技术上讲你可以,但是将模型的内部组件(即层或可重复使用的层块)与模型本身(即你将训练的对象)区分开来通常更清晰。前者应该是类的子Layer类,而后者应该是类的子Model类。

有了它,您可以使用 Sequential API、Functional API、Subclassing API 甚至这些的混合,自然而简洁地构建您在论文中找到的几乎任何模型。“几乎”任何型号?是的,我们仍然需要关注一些事情:首先,如何根据模型内部定义损失或指标,其次,如何构建自定义训练循环。

基于模型内部的损失和指标

我们之前定义的自定义损失和指标都是基于标签和预测(以及可选的样本权重)。有时您希望根据模型的其他部分定义损失,例如其隐藏层的权重或激活。这对于正则化或监视模型的某些内部方面可能很有用。

要基于模型内部定义自定义损失,请根据您想要的模型的任何部分计算它,然后将结果传递给add_loss()方法。例如,让我们构建一个自定义回归 MLP 模型,该模型由五个隐藏层的堆栈加上一个输出层。此自定义模型还将在上隐藏层的顶部有一个辅助输出。这与这个辅助输出相关的损失将被称为重建损失(见第 17 章):它是重建和输入之间的均方差。通过将这种重建损失添加到主要损失中,我们将鼓励模型通过隐藏层保留尽可能多的信息——即使是对回归任务本身没有直接用处的信息。在实践中,这种损失有时会提高泛化能力(它是正则化损失)。这是此自定义模型的代码,具有自定义重建损失:

class ReconstructingRegressor(keras.Model):
    def __init__(self, output_dim, **kwargs):
        super().__init__(**kwargs)
        self.hidden = [keras.layers.Dense(30, activation="selu",
                                          kernel_initializer="lecun_normal")
                       for _ in range(5)]
        self.out = keras.layers.Dense(output_dim)

    def build(self, batch_input_shape):
        n_inputs = batch_input_shape[-1]
        self.reconstruct = keras.layers.Dense(n_inputs)
        super().build(batch_input_shape)

    def call(self, inputs):
        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)
        return self.out(Z)

让我们看一下这段代码:

  • 构造函数创建具有五个密集隐藏层和一个密集输出层的 DNN。

  • build()方法创建了一个额外的密集层,该层将用于重建模型的输入。它必须在这里创建,因为它的单元数必须等于输入数,并且在build()调用方法之前这个数是未知的。

  • call()方法通过所有五个隐藏层处理输入,然后将结果通过重建层,从而产生重建。

  • 然后该call()方法计算重建损失(重建和输入之间的均方差),并使用该add_loss()方法将其添加到模型的损失列表中。11请注意,我们通过将重建损失乘以 0.05 来缩小重建损失(这是一个您可以调整的超参数)。这确保了重建损失不会主导主要损失。

  • 最后,该call()方法将隐藏层的输出传递给输出层并返回其输出。

同样,您可以通过以任何您想要的方式计算基于模型内部的自定义指标,只要结果是指标对象的输出即可。例如,你可以keras.metrics.Mean在构造函数中创建一个对象,然后在call()方法中调用它,传递给它recon_loss,最后通过调用模型的add_metric()方法将它添加到模型中。这样,当您训练模型时,Keras 将显示每个时期的平均损失(损失是主要损失加上重建损失的 0.05 倍的总和)和每个时期的平均重建误差。两者都会在训练期间下降:

Epoch 1/5
11610/11610 [==============] [...] 损失:4.3092 - 重建错误:1.7360
Epoch 2/5
11610/11610 [==============] [...] 损失:1.1232 - 重建错误:0.8964
[...]

在超过 99% 的情况下,我们到目前为止讨论的所有内容都足以实现您想要构建的任何模型,即使具有复杂的架构、损失和指标。但是,在极少数情况下,您可能需要自定义训练循环本身。在我们到达那里之前,我们需要看看如何在 TensorFlow 中自动计算梯度。

使用 Autodiff 计算梯度

了解如何使用 autodiff(参见第 10 章和附录D)自动计算梯度,让我们考虑一个简单的玩具函数:

def f(w1, w2):
    return 3 * w1 ** 2 + 2 * w1 * w2

如果你知道微积分,你可以分析地发现这个函数关于 的偏导数w16 * 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()需要每个参数至少调用一次,这使得这种方法对于大型神经网络来说难以处理。因此,我们应该使用 autodiff。TensorFlow 使这变得非常简单:

w1, w2 = tf.Variable(5.), tf.Variable(3.)
with tf.GradientTape() as tape:
    z = f(w1, w2)

gradients = tape.gradient(z, [w1, w2])

我们首先定义两个变量w1w2,然后我们创建一个tf.GradientTape上下文,该上下文将自动记录涉及变量的每个操作,最后我们要求这个磁带计算结果z关于这两个变量的梯度[w1, w2]。让我们看一下 TensorFlow 计算的梯度:

>>> gradients
[<tf.Tensor: id=828234, shape=(), dtype=float32, numpy=36.0>,
 <tf.Tensor: id=828229, shape=(), dtype=float32, numpy=10.0>]

完美的!不仅结果准确(精度仅受浮点误差限制),而且gradient()无论有多少变量,该方法仅对记录的计算进行一次(以相反的顺序),因此非常高效。就像魔术一样!

小费

为了节省内存,只将严格的最小值放在tf.GradientTape()块内。with tape.stop_recording()或者,通过在块内创建一个块来暂停录制tf.GradientTape()

调用它的方法后磁带会立即自动擦除gradient(),所以如果你尝试调用gradient()两次,你会得到一个异常:

with tf.GradientTape() as tape:
    z = f(w1, w2)

dz_dw1 = tape.gradient(z, w1) # => tensor 36.0
dz_dw2 = tape.gradient(z, w2) # RuntimeError!

如果您需要gradient()多次调用,则必须使磁带持久化并在每次完成时将其删除以释放资源:12

with tf.GradientTape(persistent=True) as tape:
    z = f(w1, w2)

dz_dw1 = tape.gradient(z, w1) # => tensor 36.0
dz_dw2 = tape.gradient(z, w2) # => 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.]

这在某些情况下可能很有用,例如,如果您想实现一个正则化损失,该损失会在输入变化不大时惩罚变化很大的激活:损失将基于与输入相关的激活的梯度。由于输入不是变量,因此您需要告诉磁带观看它们。

大多数情况下,梯度带用于计算单个值(通常是损失)相对于一组值(通常是模型参数)的梯度。这就是反向模式 autodiff 大放异彩的地方,因为它只需要进行一次正向传递和一次反向传递即可一次获得所有渐变。如果您尝试计算向量的梯度,例如包含多个损失的向量,则 TensorFlow 将计算向量和的梯度。因此,如果您需要获得单独的梯度(例如,每个损失相对于模型参数的梯度),您必须调用磁带的jacobian()方法:它将对向量中的每个损失执行一次反向模式自动差异(默认情况下全部并行)。甚至可以计算二阶偏导数(Hessians,即偏导数的偏导数),但这在实践中很少需要(参见笔记本的“使用 Autodiff 计算梯度”部分的示例) .

在某些情况下,您可能希望阻止梯度通过神经网络的某些部分进行反向传播。为此,您必须使用该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) # same result as without stop_gradient()

gradients = tape.gradient(z, [w1, w2]) # => returns [tensor 30., None]

最后,在计算梯度时,您可能偶尔会遇到一些数值问题。例如,如果您计算my_softplus()大输入的函数梯度,则结果将为 NaN:

>>> x = tf.Variable([100.])
>>> with tf.GradientTape() as tape:
...     z = my_softplus(x)
...
>>> tape.gradient(z, [x])
<tf.Tensor: [...] numpy=array([nan], dtype=float32)>

这是因为使用 autodiff 计算此函数的梯度会导致一些数值困难:由于浮点精度误差,autodiff 最终会计算无穷大除以无穷大(返回 NaN)。幸运的是,我们可以分析发现,softplus 函数的导数只有 1 / (1 + 1 / exp( x )),在数值上是稳定的。接下来,我们可以告诉 TensorFlow 在计算函数的梯度时使用这个稳定函数,方法是my_softplus()用它装饰它@tf.custom_gradient并使其返回其正常输出和计算导数的函数(请注意,它将接收反向传播的梯度作为输入到目前为止,一直到 softplus 函数;根据链式法则,我们应该将它们与这个函数的梯度相乘):

>>> x = tf.Variable([100.])
>>> with tf.GradientTape() as tape:
...     z = my_softplus(x)
...
>>> tape.gradient(z, [x])
<tf.Tensor: [...] numpy=array([nan], dtype=float32)>

现在,当我们计算函数的梯度时my_better_softplus(),我们得到了正确的结果,即使对于较大的输入值(但是,由于指数,主输出仍然会爆炸;一种解决方法是tf.where()在输入很大时使用它来返回)。

恭喜!您现在可以计算任何函数的梯度(假设它在您计算它的点是可微的),甚至在需要时阻止反向传播,并编写您自己的梯度函数!这可能比您需要的灵活性更大,即使您构建自己的自定义训练循环,正如我们现在将看到的那样。

自定义训练循环

在极少数情况下,该fit()方法可能对您需要做的事情不够灵活。例如,我们在第 10 章讨论的Wide & Deep 论文使用了两种不同的优化器:一种用于宽路径,另一种用于深度路径。由于该方法只使用一个优化器(我们在编译模型时指定的那个),因此实现本文需要编写您自己的自定义循环。fit()

您可能还想编写自定义训练循环,只是为了让他们更有信心,他们会准确地按照您的意愿去做(也许您不确定该fit()方法的某些细节)。有时将所有内容都明确化会让人感觉更安全。但是,请记住,编写自定义训练循环会使您的代码更长、更容易出错且更难维护。

小费

除非您真的需要额外的灵活性,否则您应该更喜欢使用该fit()方法而不是实现自己的训练循环,特别是如果您在团队中工作。

首先,让我们建立一个简单的模型。无需编译,因为我们将手动处理训练循环:

l2_reg = keras.regularizers.l2(0.05)
model = keras.models.Sequential([
    keras.layers.Dense(30, activation="elu", kernel_initializer="he_normal",
                       kernel_regularizer=l2_reg),
    keras.layers.Dense(1, kernel_regularizer=l2_reg)
])

接下来,让我们创建一个小函数,从训练集中随机抽取一批实例(在第 13 章中,我们将讨论 Data API,它提供了更好的选择):

def random_batch(X, y, batch_size=32):
    idx = np.random.randint(len(X), size=batch_size)
    return X[idx], y[idx]

让我们还定义一个显示训练状态的函数,包括步数、总步数、自 epoch 开始以来的平均损失(即,我们将使用Mean度量来计算它)和其他度量:

def print_status_bar(iteration, total, loss, metrics=None):
    metrics = " - ".join(["{}: {:.4f}".format(m.name, m.result())
                         for m in [loss] + (metrics or [])])
    end = "" if iteration < total else "\n"
    print("\r{}/{} - ".format(iteration, total) + metrics,
          end=end)

这段代码是不言自明的,除非您不熟悉 Python 字符串格式:{:.4f}将格式化一个小数点后四位数字的浮点数,并使用\r(回车)end=""确保状态栏始终打印在同一行上。在笔记本中,该print_status_bar()功能包括一个进度条,但您可以使用方便的tqdm库来代替。

有了这个,让我们开始吧!首先,我们需要定义一些超参数并选择优化器、损失函数和指标(在这个例子中只是 MAE):

n_epochs = 5
batch_size = 32
n_steps = len(X_train) // batch_size
optimizer = keras.optimizers.Nadam(lr=0.01)
loss_fn = keras.losses.mean_squared_error
mean_loss = keras.metrics.Mean()
metrics = [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 * batch_size, len(y_train), mean_loss, metrics)
    print_status_bar(len(y_train), len(y_train), mean_loss, metrics)
    for metric in [mean_loss] + metrics:
        metric.reset_states()

这段代码中有很多内容,让我们来看看它:

  • 我们创建了两个嵌套循环:一个用于时期,另一个用于时期内的批次。

  • 然后我们从训练集中随机抽取一批。

  • tf.GradientTape()块内,我们对一批进行预测(使用模型作为函数),我们计算损失:它等于主要损失加上其他损失(在这个模型中,每层有一个正则化损失) . 由于该mean_squared_error()函数每个实例返回一个损失,因此我们使用计算批次的平均值tf.reduce_mean()(如果您想对每个实例应用不同的权重,这就是您要做的地方)。正则化损失已经减少到每个单一的标量,所以我们只需要对它们求和(使用tf.add_n(),它对相同形状和数据类型的多个张量求和)。

  • 接下来,我们要求磁带计算每个可训练变量(不是所有变量!)的损失梯度,并将它们应用于优化器以执行梯度下降步骤。

  • 然后我们更新平均损失和指标(在当前时期),并显示状态栏。

  • 在每个 epoch 结束时,我们再次显示状态栏以使其看起来完整13并打印换行符,我们重置平均损失和指标的状态。

如果你想应用梯度裁剪(参见第 11 章),只需设置优化器clipnormclipvalue超参数。如果您想对渐变应用任何其他转换,只需在调用该apply_gradients()方法之前执行此操作。

如果您向模型添加权重约束(例如,通过设置kernel_constraintbias_constraint在创建层时),您应该更新训练循环以在之后应用这些约束apply_gradients()

for variable in model.variables:
    if variable.constraint is not None:
        variable.assign(variable.constraint(variable))

最重要的是,这个训练循环不处理在训练和测试期间表现不同的层(例如,BatchNormalizationDropout)。要处理这些,您需要调用模型training=True并确保它将其传播到需要它的每一层。

如您所见,您需要做很多事情,而且很容易出错。但从好的方面来说,你可以完全控制,所以这是你的决定。

既然您知道如何自定义模型的任何部分14和训练算法,让我们看看如何使用 TensorFlow 的自动图形生成功能:它可以显着加快您的自定义代码,并且还可以使其移植到任何支持的平台TensorFlow(见第 19 章)。

TensorFlow 函数和图表

在 TensorFlow 1 中,图表是不可避免的(就像它们带来的复杂性一样),因为它们是 TensorFlow API 的核心部分。在 TensorFlow 2 中,它们仍然存在,但不那么重要,而且它们使用起来更加(非常!)简单。为了展示它有多简单,让我们从一个计算其输入立方的普通函数开始:

def cube(x):
    return x ** 3

我们显然可以用 Python 值调用这个函数,例如 int 或 float,或者我们可以用张量调用它:

>>> cube(2)
8
>>> cube(tf.constant(2.0))
<tf.Tensor: id=18634148, shape=(), dtype=float32, numpy=8.0>

现在,让我们tf.function()将这个 Python 函数转换为TensorFlow 函数

>>> tf_cube = tf.function(cube)
>>> tf_cube
<tensorflow.python.eager.def_function.Function at 0x1546fc080>

然后可以像原来的 Python 函数一样使用这个 TF 函数,它会返回相同的结果(但作为张量):

>>> tf_cube(2)
<tf.Tensor: id=18634201, shape=(), dtype=int32, numpy=8>
>>> tf_cube(tf.constant(2.0))
<tf.Tensor: id=18634211, 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 函数快得多,尤其是在它执行复杂计算的情况下。15大多数时候,您实际上不需要知道更多:当您想要提升 Python 函数时,只需将其转换为 TF 函数即可。就这样!

此外,当您编写自定义损失函数、自定义度量、自定义层或任何其他自定义函数并在 Keras 模型中使用它时(正如我们在本章中所做的那样),Keras 会自动将您的函数转换为 TF 函数——无需使用tf.function(). 所以大多数时候,所有这些魔法都是 100% 透明的。

小费

您可以通过在创建自定义层或自定义模型时设置来告诉 Keras不要将您的 Python 函数转换为 TF 函数。dynamic=True或者,您可以run_eagerly=True在调用模型的compile()方法时进行设置。

默认情况下,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语句,以及breakcontinuereturn语句。这第一步称为AutoGraph。TensorFlow 必须分析源代码的原因是 Python 没有提供任何其他方法来捕获控制流语句:它提供了像 and 这样的魔术方法__add__()__mul__()捕获像+and这样的运算符*,但是没有__while__()or__if__()魔术方法。在分析函数的代码后,AutoGraph 输出该函数的升级版本,其中所有控制流语句都被适当的 TensorFlow 操作替换,例如tf.while_loop()for 循环和tf.cond()forif语句。例如,在图 12-4中,AutoGraph 分析sum_squares()Python函数的源代码,并生成tf__sum_squares()函数。在这个函数中,for循环被loop_body()函数的定义(包含原始for循环的主体)替换,然后是对该for_stmt()函数的调用。此调用将tf.while_loop()在计算图中构建适当的操作。

                图 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 函数时都会发生这些副作用,因为它们只会在跟踪函数时发生。

    • 您可以将任意 Python 代码包装在一个tf.py_function()操作中,但这样做会影响性能,因为 TensorFlow 将无法对此代码进行任何图形优化。它还会降低可移植性,因为该图只能在 Python 可用的平台上运行(并且安装了正确的库)。

  • 您可以调用其他 Python 函数或 TF 函数,但它们应遵循相同的规则,因为 TensorFlow 将在计算图中捕获它们的操作。请注意,这些其他功能不需要用@tf.function.

  • 如果该函数创建一个 TensorFlow 变量(或任何其他有状态的 TensorFlow 对象,例如数据集或队列),它必须在第一次调用时这样做,而且只有在那时,否则你会得到一个异常。通常最好在 TF 函数之外创建变量build()(例如,在自定义层的方法中)。如果要为变量分配新值,请确保调用其assign()方法,而不是使用=运算符。

  • TensorFlow 应该可以使用 Python 函数的源代码。如果源代码不可用(例如,如果您在 Python shell 中定义您的函数,该函数无法访问源代码,或者如果您仅将已编译的*.pyc Python 文件部署到生产环境),则生成图进程将失败或功能有限。

  • TensorFlow 只会捕获for迭代张量或数据集的循环。因此,请确保使用而不是,否则将不会在图中捕获循环。相反,它将在跟踪期间运行。(如果循环旨在构建图形,这可能是您想要的,例如在神经网络中创建每一层。)for i in tf.range(x)for i in range(x)for

  • 与往常一样,出于性能原因,您应该尽可能选择矢量化实现,而不是使用循环。

是时候总结一下了!在本章中,我们从 TensorFlow 的简要概述开始,然后我们研究了 TensorFlow 的低级 API,包括张量、操作、变量和特殊数据结构。然后,我们使用这些工具来自定义 tf.keras 中的几乎每个组件。最后,我们研究了 TF 函数如何提高性能,如何使用 AutoGraph 和跟踪生成图形,以及编写 TF 函数时应遵循的规则(如果您想进一步打开黑匣子,例如探索生成的图表,您将在附录 G中找到技术细节)。

在下一章中,我们将了解如何使用 TensorFlow 高效地加载和预处理数据。

练习

  1. 你会如何用一句话描述 TensorFlow?它的主要特点是什么?你能说出其他流行的深度学习库吗?

  2. TensorFlow 是 NumPy 的替代品吗?两者的主要区别是什么?

  3. 你用tf.range(10)and得到同样的结果tf.constant(np.arange(10))吗?

  4. 除了常规张量,你能说出 TensorFlow 中可用的其他六种数据结构吗?

  5. 可以通过编写函数或子keras.losses.Loss类来定义自定义损失函数。你什么时候使用每个选项?

  6. 类似地,可以在函数或子类中定义自定义指标keras.metrics.Metric。你什么时候使用每个选项?

  7. 什么时候应该创建自定义层而不是自定义模型?

  8. 有哪些需要编写自己的自定义训练循环的用例?

  9. 自定义 Keras 组件可以包含任意 Python 代码,还是必须可以转换为 TF 函数?

  10. 如果您希望函数可转换为 TF 函数,应遵守哪些主要规则?

  11. 什么时候需要创建动态 Keras 模型?你是怎样做的?为什么不让你的所有模型都动态化呢?

  12. 实现一个执行层规范化的自定义层(我们将在第 15 章中使用这种类型的层):

    1. build()方法应定义两个可训练的权重αβ,包括形状input_shape[-1:]和数据类型tf.float32α应该用1s初始化,β用0s初始化。

    2. call()方法应计算每个实例特征的均值μ和标准差σ。为此,您可以使用tf.nn.moments(inputs, axes=-1, keepdims=True),它返回所有实例的均值μ和方差σ 2(计算方差的平方根以获得标准偏差)。然后该函数应计算并返回α ⊗( X - μ )/( σ + ε ) + β,其中 ⊗ 表示逐项乘法 ( *),而ε是平滑项(避免除以零的小常数,例如 0.001)。

    3. 确保您的自定义层产生与层相同(或几乎相同)的输出keras.layers.LayerNormalization

  13. 使用自定义训练循环训练模型以处理 Fashion MNIST 数据集(参见第 10 章)。

    1. 显示每个 epoch 的 epoch、迭代、平均训练损失和平均准确度(在每次迭代中更新),以及每个 epoch 结束时的验证损失和准确度。

    2. 尝试对上层和下层使用具有不同学习率的不同优化器。

附录 A中提供了这些练习的解决方案。

1TensorFlow 包含另一个称为Estimators API的深度学习 API ,但 TensorFlow 团队建议tf.keras改用它。

2如果您需要(但您可能不会),您可以使用 C++ API 编写自己的操作。

3要了解有关 TPU 及其工作原理的更多信息,请查看https://homl.info/tpus

4一个值得注意的例外是tf.math.log(),它很常用,但没有tf.log()别名(因为它可能与日志记录混淆)。

5使用加权平均值不是一个好主意:如果你这样做了,那么具有相同权重但在不同批次中的两个实例将对训练产生不同的影响,具体取决于每个批次的总权重。

6但是,Huber 损失很少用作度量(首选 MAE 或 MSE)。

7此类仅用于说明目的。一个更简单更好的实现将只是子类化keras.metrics.Mean该类;有关示例,请参阅笔记本的“流媒体指标”部分。

8此功能特定于 tf.keras。你可以keras.layers.Activation改用。

9Keras API 调用这个参数input_shape,但由于它还包括批处理维度,所以我更喜欢调用它batch_input_shape。对compute_output_shape().

10“子类化 API”这个名称通常仅指通过子类化创建自定义模型,尽管正如我们在本章中看到的,通过子类化可以创建许多其他东西。

11您还可以调用add_loss()模型内的任何层,因为模型会递归地从其所有层收集损失。

12如果磁带超出范围,例如当使用它的函数返回时,Python 的垃圾收集器将为您删除它。

13事实上,我们并没有处理训练集中的每一个实例,因为我们随机采样了实例:一些被处理了不止一次,而另一些则根本没有处理。同样,如果训练集大小不是批大小的倍数,我们将错过一些实例。在实践中这很好。

14除了优化器,很少有人定制这些;有关示例,请参见笔记本中的“自定义优化器”部分。

15然而,在这个简单的例子中,计算图非常小,根本没有什么可以优化的,所以tf_cube()实际上运行速度比cube().

  • 5
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sonhhxg_柒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值