Keras:使用TensorFlow自定义模型和训练

当我们需要额外的控制来编写自定义损失函数、自定义指标、层、模型、初始化程序、正则化函数、权重约束等,这时我们需要研究TensorFlow并了解底层Python API了。

1 TensorFlow快速浏览

TensorFlow是一个强大的用于数值计算的库,特别适合大规模机器学习并对其进行微调。TensorFlow能提供什么呢?总结如下:

  • 它的核心与NumPy非常相似,但具有GPU支持。
  • 它支持分布式计算(跨多个设备和服务器)
  • 它包含一种即时(JIT)编译器,可使其对速度和内存使用情况来优化计算。它的工作方式是从Python函数中提取计算图,然后进行优化,最后有效地运行它。
  • 计算图可以导出为可移植格式。如我们可以在Linux中训练TensorFlow模型,然后在Android环境中运行TensorFlow模型。
  • 它实现了自动微分,提供了一些优秀的优化器,如RMSProp和Nadam。

TensorFlow在这些核心功能的基础上提供了更多功能,除tf.keras,还具有数据加载和预处理操作(tf.data, tf.io)、图像处理操作(tf.image)、信号处理操作(tf.signal)。另外,TensorFlow不仅仅是函数库,更是广泛的生态系统的核心。首先,TensorBoard可以进行可视化,接下来TensorFlow Extended,它是为TensorFlow项目进行生产环境而构建的一组库,包括数据验证、预处理、模型分析和服务工具。TensorFlow Hub提供了一种轻松下载和重用预训练的神经网络的方法,我们还可以在TensorFlow模型花园中获得许多神经网络架构,其中一些已经过预先训练。

2 TensorFlow的数据结构

2.1 像numpy一样使用TensorFlow

TensorFlow的API一切围绕张量,张量从一个操作流向另一个操作,张量非常类似于numpy的ndarray,它通常是一个多维度数组,但它也可以保存标量。

import tensorflow as tf
# 使用tf.constant()创建张量
t = tf.constant([[1., 2., 3.], [4., 5., 6.]])
复制代码
2022-03-01 20:42:41.082775: I tensorflow/core/platform/cpu_feature_guard.cc:151] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations:  AVX2 FMA
To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.
复制代码
# 查看形状
print(t.shape)
# 查看数据类型
print(t.dtype)
复制代码
(2, 3)
<dtype: '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   # add 10, 等价于 tf.add(t, 10)
tf.square(t)  # 平方
t @ tf.transpose(t) # 矩阵相乘 A(2, 3)*B(3, 2)=C(2, 2), 矩阵乘法等价于tf.matmul()函数
复制代码
<tf.Tensor: shape=(2, 2), dtype=float32, numpy=
array([[14., 32.],
       [32., 77.]], dtype=float32)>
复制代码

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。在TensorFlow中,使用自己的转置数据副本创建一个新的张量,而在numpy中,t.T只是相同数据的转置视图。

另外,许多函数和类都有别名,如:tf.add()和tf.math.add()都是同一函数。但有个例外,tf.math.log()没有tf.log()的别名。

Keras的底层API

Keras API在keras.backend中有自己的底层API。它包含诸如square()、exp()和sqrt()等函数。我们使用Keras函数的一个示例:

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

2.2 张量和Numpy的配合使用

利用张量创建 numpy数组,利用numpy数组创建张量

import numpy as np
a = np.array([2., 4., 5.])
tf.constant(a)   # numpy数组转张量
复制代码
<tf.Tensor: shape=(3,), dtype=float64, numpy=array([2., 4., 5.])>
复制代码
t.numpy() # 张量转 numpy数组
复制代码
array([[1., 2., 3.],
       [4., 5., 6.]], dtype=float32)
复制代码
tf.square(a)  # 用TensorFlow操作应用于numpy数组
复制代码
<tf.Tensor: shape=(3,), dtype=float64, numpy=array([ 4., 16., 25.])>
复制代码
np.square(t)  # 用numpy操作应用于张量
复制代码
array([[ 1.,  4.,  9.],
       [16., 25., 36.]], dtype=float32)
复制代码

注意:默认情况,numpy使用64位精度,而TensorFlow使用32位精度。因此从numpy数组创建张量,确保设置dtype=tf.float32

2.3 类型转换

类型转换严重影响性能,TensorFlow不会自动执行任何类型转换,如果对不兼容类型的张量执行操作,会引发异常。例如,不能把浮点张量和整数张量相加,甚至不能相加32位浮点和64位浮点。

tf.constant(2.) + tf.constant(40)   # 异常报错
tf.constant(2.) + tf.constant(40, dtype=tf.float64) # 异常报错
复制代码
---------------------------------------------------------------------------

InvalidArgumentError                      Traceback (most recent call last)

/var/folders/bn/0lpcbtqn6w7159rr0gp940z80000gn/T/ipykernel_7365/1443138918.py in <module>
----> 1 tf.constant(2.) + tf.constant(40)   # 异常报错
      2 tf.constant(2.) + tf.constant(40, dtype=tf.float64) # 异常报错


/opt/anaconda3/envs/keras/lib/python3.9/site-packages/tensorflow/python/util/traceback_utils.py in error_handler(*args, **kwargs)
    151     except Exception as e:
    152       filtered_tb = _process_traceback_frames(e.__traceback__)
--> 153       raise e.with_traceback(filtered_tb) from None
    154     finally:
    155       del filtered_tb


/opt/anaconda3/envs/keras/lib/python3.9/site-packages/tensorflow/python/framework/ops.py in raise_from_not_ok_status(e, name)
   7105 def raise_from_not_ok_status(e, name):
   7106   e.message += (" name: " + name if name is not None else "")
-> 7107   raise core._status_to_exception(e) from None  # pylint: disable=protected-access
   7108 
   7109 


InvalidArgumentError: cannot compute AddV2 as input #1(zero-based) was expected to be a float tensor but is a int32 tensor [Op:AddV2]
复制代码
# 如若确实需要转换类型,可以使用tf.cast()
t2 = tf.constant(40, dtype=tf.float64)
tf.constant(2.0) + tf.cast(t2, tf.float32)
复制代码
<tf.Tensor: shape=(), dtype=float32, numpy=42.0>
复制代码

2.4 变量

我们看到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)>
复制代码

给变量增加、减少或者修改的方法

v.assign(2*v)
v
复制代码
<tf.Variable 'Variable:0' shape=(2, 3) dtype=float32, numpy=
array([[ 2.,  4.,  6.],
       [ 8., 10., 12.]], dtype=float32)>
复制代码
v[0, 1].assign(42)
复制代码
<tf.Variable 'UnreadVariable' shape=(2, 3) dtype=float32, numpy=
array([[ 2., 42.,  6.],
       [ 8., 10., 12.]], dtype=float32)>
复制代码
v.scatter_nd_update(indices=[[0,0], [1,2]], updates=[100., 200.])
复制代码
<tf.Variable 'UnreadVariable' shape=(2, 3) dtype=float32, numpy=
array([[100.,  42.,   6.],
       [  8.,  10., 200.]], dtype=float32)>
复制代码

2.5 其他数据结构

  • 稀疏张量(tf.SparseTensor)
  • 张量数组(tf.TensorArray)
  • 不规则张量(tf.RaggedTensor)
  • 字符串张量(tf.string)
  • 集合(tf.sets)
  • 队列(tf.queue)

3 定制模型 和训练算法

3.1 自定义损失函数

我们想用Huber损失,目前Huber损失不是官方Keras API的一部分,但可以在tf.keras中使用(keras.losses.Huber实例)。但是,我们假装它不存在,从而自定义该损失函数。

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)
y_true = tf.constant([[1.], [0]], dtype="float32")
y_pred = tf.constant([[0.8], [0.2]], dtype="float32")
huber_fn(y_true, y_pred)
复制代码
<tf.Tensor: shape=(2, 1), dtype=float32, numpy=
array([[0.02],
       [0.02]], dtype=float32)>
复制代码

可以看到,返回了每个实例的损失的张量,而不是返回实例的平均损失,这样,Keras可以根据要求使用类别权重或样本权重。 现在,我们编译Keras模型时使用此损失,然后训练模型。

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

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

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

保存包含自定义损失函数的模型,对于Keras来说很方便。因为Keras会保存函数的名称,所以每次加载时,都需要提供一个字典,将函数名称映射到实际函数即可。

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

如果想要一个不同的阈值怎么办?一种解决方案是创建一个函数,该函数创建已配置的损失函数:

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) - 0.5*threshold**2
        return tf.where(is_small_error, squared_loss, linear_loss)
    return huber_fn

model.compile(loss=create_huber(2.0), optimizer="nadam")
复制代码

但不幸的是,当保存模型时,阈值不会被保存,这意味着加载模型时必须指定阈值,同时,注意:使用的名称是“huber_fn”,这个是Keras命名的函数的名称,而不是创建函数时的名称create_huber。

model = keras.models.load_model("my_model_with_a_custom_loss.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) < threshold
        squared_loss = tf.square(error) / 2
        linear_loss = threshold*tf.abs(error) - 0.5*threshold**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算法。默认情况下,它是“sum_over_batch_size”,这意味着损失将是实例损失的总和,由样本权重(如果有)加权,再除以批量大小。
  • call()方法获取标签和预测,计算所有实例损失,然后将其返回。
  • get_config()方法返回一个字典,将每个超参数名称映射到其值。它首先调用父类的get_config()方法,然后将新的超参数添加到此字典中

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

model.compile(loss=HuberLoss(2.0), optimizer="nadam")
复制代码

当保存模型时,阈值会同时一起保存,在加载模型时,只需要将类名映射到类本身即可:

model = keras.models.load_model("my_model_with_a_custom_loss.h5",
                                custom_objects={"HuberLoss":HuberLoss}
                               )
复制代码

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

大多数Keras功能,例如损失、正则化、约束、初始化、度量、激活函数、层甚至完整模型,都可以以几乎相同的方式自定义。在大多数情况下,只需要编写带有适当输入和输出的简单函数即可。

def my_softplus(z):
    # 自定义激活函数,等价于 tf.nn.softplus()或 keras.activations.softplus()
    return tf.math.log(tf.exp(z)+1.0)
def my_glorot_initializer(shape, dtype=tf.float32):
    # 自定义Glorot初始化,等价于 keras.initializers.glorot_normal()
    stddev = tf.sqrt(2. /(shape[0] + shape[1]))
    return tf.random.normal(shape, stddev=stddev, dtype=dtype)
def my_l1_regularizer(weights):
    # 自定义l1正则化,等价于keras.regularizers.l1(0.01)
    return tf.reduce_sum(tf.abs(0.01 * weights))
def my_positive_weights(weights):
    # 确保权重均为正的自定义约束,等价于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.Regularizer, keras.constraints.Constraint, keras.initializers.Initializer,keras.layers.Layer。如:

# 保存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__()方法。

3.4 自定义指标

损失和指标在概念上不是一回事:损失(如:交叉熵)被梯度下降来训练模型,因此它们必须是可微的,并且梯度在任何地方都不应为0。如果人类不容易解释它们也没有问题。相反,指标(如:准确率)用于评估模型,它们必须更容易被解释。大多数情况下,定义一个自定义指标函数与定义一个自定义损失函数完全相同。实际上,之前我们创建的Huber损失函数用作指标,如:

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

对于训练期间的每一个批次,keras都会计算该指标并跟踪自轮次开始以来的均值。例如:考虑一个二元分类器的精度,假设该模型在第一批次中做出5个正预测,其中4个正确,则80%的精度。假设该模型在第二批次找那个做出3个正预测,0个是正确的,那么第二批次的精度是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: 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_stats()方法重置这些变量:

precision.result()
复制代码
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>
复制代码
precision.variables
复制代码
[<tf.Variable 'true_positives:0' shape=(1,) dtype=float32, numpy=array([4.], dtype=float32)>,
 <tf.Variable 'false_positives:0' shape=(1,) dtype=float32, numpy=array([4.], dtype=float32)>]
复制代码
precision.reset_states() # variables都被重置为0;
复制代码
precision.result()
复制代码
<tf.Tensor: shape=(), dtype=float32, numpy=0.0>
复制代码

如果要创建这样的流式变量,创建keras.metrics.Metric类的子类。下面是跟踪Huber总损失的简单示例以及到目前为止看到的实例数量。当要求得到结果时,它返回比率,这就是平均Huber损失。

class HuberMetric(keras.metrics.Metric):
    def __init__(self, threshold=1.0, **kwargs):
        super().__init__(**kwargs)
        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}
复制代码

以上代码解释:

  • 构造函数使用add_weight()方法创建用于跟踪多个批次的度量状态所需的变量。在这种情况下,该变量包括所有Huber损失的总和,以及目前为止看到的实例数。如果愿意,可以手动创建变量。Keras会跟踪任何设置为属性的tf.Variable。
  • 当你使用此类的实例作为函数时,将调用update_state()方法。给定一个批次的标签和预测值。给定一个批次的标签和预测值,它会更新变量,如Precision对象所做的那样。
  • result()方法计算并返回最终结果,在这种情况下所有实例的平均Huber度量,当你使用度量作为函数时,首先调用updata_state()方法,然后调用result()方法,并返回其输出。
  • 我们还实现了get_config()方法来确保threshold与模型一起被保存。
  • reset_states()方法的默认实现将所有变量重置为0.0。
huber = HuberMetric()
y_true = tf.constant([[1.], [0]], dtype="float32")
y_pred = tf.constant([[0.8], [0.2]], dtype="float32")
huber(y_true, y_pred)
复制代码
<tf.Tensor: shape=(), dtype=float32, numpy=0.02>
复制代码
y_true = tf.constant([[1.], [0]], dtype="float32")
y_pred = tf.constant([[0.6], [0.2]], dtype="float32")
huber(y_true, y_pred)   # (0.02+0.02+0.08+0.02)/4
复制代码
<tf.Tensor: shape=(), dtype=float32, numpy=0.034999996>
复制代码
huber.result()
复制代码
<tf.Tensor: shape=(), dtype=float32, numpy=0.034999996>
复制代码

3.5 自定义层

如果想要构建一个包含独特层的架构,而TensorFlow没有为其提供默认实现。我们需要创建一个自定义层,或者想构建一个重复的架构,其中包含重复多次的相同的层块。

首先,某些层没有权重,例如:keras.layers.Flatten或者keras.layers.ReLU()。如果要创建不带任何权重的自定义层,最简单的选择是编写一个函数并将其包装在keras.layers.Lambda层中。例如:以下层将对它的输入应用指数函数:

exponential_layer = keras.layers.Lambda(lambda x: tf.exp(x))
复制代码

然后,可以像使用顺序API、函数式API或子类API等其他任何层一样使用此自定义层,也可以将其用作激活函数,有时会在回归模型的输出层使用指数层。

如果要构建有状态层(具有权重的层),需要创建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)  # 这个必须放在后面这里
        
    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)}   
复制代码

以上代码解释:

  • 构造函数将所有超参数用作参数,如:units、activation,重要的是它还接受**kwargs参数.它调用父类构造函数,并将传递给kwargs:主要处理标准参数,例如input_shape、trainable和name,然后将超参数保存为属性。
  • 使用keras.activation.get()函数将激活参数转换为适当的激活函数,它接受函数或标准字符串(如:"relu"、"selu").
  • build()方法的作用是通过为每个权重调用add_weight()方法来创建层的变量。首次使用该层时,将调用build()方法。在这一点上,Keras知道该层的输入形状,并将其传递给build()方法,这对于创建某些权重通常是必须的。另外,在build()方法的最后,并且仅在最后,必须要调用父类的build()方法:这告诉Keras这一层被构建了(它只是设置了self.build=True)。
  • call()方法执行所需的操作。计算输入X与层内核的矩阵乘积,添加偏置向量,并对结果应用激活函数,从而获得层的输出。
  • compute_output_shape()方法仅返回该层输出的形状。
  • get_config()方法就像以前的自定义类中一样。请注意调用keras.activations.serialize()保存激活函数的完整配置。

现在,我们就可以像其他任何层一样使用MyDense层。

注意:compute_output_shape()方法其实可以省略,因为tf.keras会自动推断出输出形状,除非层时动态的,此方法是必需的。

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

class 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]    # 遵循从广播规则
复制代码

现在可以像其他的任何层一样使用此层,但是只能使用函数式和子类API,不能用顺序API(它仅接受具有一个输入和一个输出的层)。

如果我们的层在训练期间和测试期间具有不同的行为,如:Dropout,则必须将训练参数添加到call()方法并使用此参数来决定要做什么。我们来创建一个在训练期间添加高斯噪声,但在测试期间不执行任何操作的层。

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
复制代码

3.6 自定义模型

其实前面已经讨论过自定义模型类的创建,在讨论子类API时。但当要自定义ResidualBlock层(包含跳过连接)的任意模型,该如何构造呢? 如图所示:

image.png

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透明的处理,它会自动检测到隐藏属性,该属性包含可跟踪的对象,因此它们的变量会自动添加到该层的变量列表中。接下来,我们使用子类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)
复制代码

如果希望使用save()方法保存模型并使用keras.models.load_model()函数加载模型。则必须在两个ResidualBlock类和ResidualRegressor类中都实现get_config()方法。另外可以使用save_weights()和load_weights()方法保存和加载权重。

Model类是Layer类的子类,因此可以像定义层一样定义和使用模型,但是模型具有一些额外的功能,包括其compile()、fit()、evaluate()和predict()方法以及get_layers()方法(可以按名称或按索引返回任何模型的层)、save()方法。

下面我们继续思考一些事情:首先基于模型内部定义损失或指标;其次,如何构建自定义循环。

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

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

要基于模型内部自定义损失,根据所需模型的任何部分进行计算,然后将结果传递给add_loss()方法。

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(reconstruct - inputs))
        self.add_loss(0.05 * recon_loss)
        return self.out(Z)
复制代码

对以上代码解释:

  • 构造函数创建5个密集隐藏层和一个密集输出层DNN
  • build()方法创建一个额外的密集层,该层用于重建模型的输入。必须在此处创建它,因为它的单元数必须等于输入数,并且在调用build()方法之前,此数是未知的。
  • call()方法处理所有5个隐藏层输入,然后将结果传递到重建层,从而产生重构。
  • 然后call()方法计算重建损失(重建与输入之间的均方差),并使用add_loss()方法将其加入到模型的损失列表中。请注意,乘以0.05按比例缩小了重建,这确保了重建损失不会在主要损失中占大部分。

同样,我们可以通过所需的任何方式计算来添加基于模型内部的自定义指标,只要结果是指标对象的输出即可。如:我们可以在构造函数中创建keras.metrics.Mean对象,然后在call()方法中调用它,将recon_loss传递它,最后通过调用模型add_metric()方法将其添加到模型中。这样当你训练模型时,Keras会同时显示每个轮次的平均损失和每个轮次的平均重建误差。

3.8 使用自动微分计算梯度

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

w1, w2 = tf.Variable(5.), tf.Variable(3.)
with tf.GradientTape() as tape:
    z = f(w1, w2)
gradients = tape.gradient(z, [w1, w2])
gradients
复制代码
[<tf.Tensor: shape=(), dtype=float32, numpy=36.0>,
 <tf.Tensor: shape=(), dtype=float32, numpy=10.0>]
复制代码

调用tape的gradient()方法后,tape会立即被自动擦除,因此如果尝试两次调用gradient(),则会出现异常:

tape.gradient(z, [w1, w2])
复制代码
---------------------------------------------------------------------------

RuntimeError                              Traceback (most recent call last)

/var/folders/bn/0lpcbtqn6w7159rr0gp940z80000gn/T/ipykernel_7365/2306421709.py in <module>
----> 1 tape.gradient(z, [w1, w2])


/opt/anaconda3/envs/keras/lib/python3.9/site-packages/tensorflow/python/eager/backprop.py in gradient(self, target, sources, output_gradients, unconnected_gradients)
   1030     """
   1031     if self._tape is None:
-> 1032       raise RuntimeError("A non-persistent GradientTape can only be used to "
   1033                          "compute one set of gradients (or jacobians)")
   1034     if self._recording:


RuntimeError: A non-persistent GradientTape can only be used to compute one set of gradients (or jacobians)
复制代码

如果需要多次调用gradient(),则必须使该tape具有持久性,并在每次使用完该tape后将其删除以释放资源。

with tf.GradientTape(persistent=True) as tape:
    z = f(w1, w2)
dz_dw1 = tape.gradient(z, w1)
dz_dw2 = tape.gradient(z, w2)
del tape
复制代码

默认情况下,tape仅跟踪涉及变量的操作,如果尝试针对变量以外的任何其他变量计算z的梯度,则结果将为None。

但是,可以强制tape观察你喜欢的任何张量,来记录涉及它们的所有操作,然后针对这些张量计算梯度。

c1, c2 = tf.constant(5.), tf.constant(3.)
with tf.GradientTape() as tape:
    z = f(c1, c2)
print(tape.gradient(z, [c1, c2]))

#####
with tf.GradientTape() as tape:
    tape.watch(c1)
    tape.watch(c2)
    z = f(c1, c2)
print(tape.gradient(z, [c1, c2]))
复制代码
[None, None]
[<tf.Tensor: shape=(), dtype=float32, numpy=36.0>, <tf.Tensor: shape=(), dtype=float32, numpy=10.0>]
复制代码

某些情况下,我们希望组织梯度在神经网络的某些部分反向传播。为此必须使用tf.stop_gradient()函数。该函数在前向传递过程中返回其输入,但在反向传播期间不让梯度通过。

def f(w1, w2):
    return 3*w1**2 + tf.stop_gradient(2*w1*w2)
with tf.GradientTape() as tape:
    z = f(w1, w2)  # 前向传播一样,不管有没有stop_gradient()
gradients = tape.gradient(z, [w1, w2]) # 反向传播,将忽略stop_gradient()指定的计算
gradients
复制代码
[<tf.Tensor: shape=(), dtype=float32, numpy=30.0>, None]
复制代码

3.9 自定义训练循环

极少数情况下,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)
])
复制代码

创建一个小函数,从训练集中随机采样一批实例:

def random_batch(X, y, batch_size=3):
    idx = np.random.randint(len(X), size=batch_size)
    return X[idx], y[idx]
复制代码

定义一个函数,显示 训练状态,包括步数、步总数、从轮次开始以来的平均损失,和其他指标:

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)
复制代码

解释一下:\r(回车)和end=""确保状态栏始终打印再同一行上。print_status_bar()函数包括一个进度条,当然也可以使用tqdm.

我们需要定义一些超参数,然后选择优化器、损失函数和指标。

fashion_mnist = keras.datasets.fashion_mnist
(X_train_full, y_train_full), (X_test, y_test) = fashion_mnist.load_data()
X_valid, X_train = X_train_full[:5000]/255.0, X_train_full[5000:] /255.0
y_valid, y_train = y_train_full[:5000], y_train_full[5000:]

n_epochs = 5
batch_size = 32
n_steps = len(X_train) // batch_size
optimizer = keras.optimizers.Nadam(learning_rate=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, 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)
        for metric in [mean_loss] + metrics:
            metric.reset_states()
复制代码
Epoch 1/5
54976/55000 - mean: 11.125340 - mean_absolute_error: 3.246988Epoch 2/5
54976/55000 - mean: 6.180694 - mean_absolute_error: 1.975620Epoch 3/5
54976/55000 - mean: 18.690468 - mean_absolute_error: 4.290856Epoch 4/5
54976/55000 - mean: 8.105114 - mean_absolute_error: 2.175005Epoch 5/5
54976/55000 - mean: 4.059292 - mean_absolute_error: 1.9687572
复制代码

如果设置优化器的超参数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))
复制代码

最重要的是,此训练循环不会处理在训练期间和测试期间行为不同的层,如:Dropout。要处理这些问题,需要使用trining=True调用模型,确保将其传播到需要它的每个层。

猜你喜欢

转载自juejin.im/post/7071914983306559518