自定义模型和训练算法

自定义损失函数

当训练回归模型时,训练集有噪声,均方误差MSE可能会对大误差惩罚太多而导致模型不精确。平均绝对误差MAE不会对异常值惩罚太多,但是训练可能要一段时间才能收敛,并且训练后的模型可能不太精确。这就是使用Huber损失而不是旧的MSE损失的好时机。

\[L_\delta(y,f(x))==\left\{ \begin{aligned} \frac12(y-f(x))^2 \quad|y-f(x)|\le\delta\\ \delta|y-f(x)|-\frac12\delta^2\quad|y-f(x)|>\delta \\ \end{aligned} \right. \]

import tensorflow as tf
from tensorflow import keras
# 当delta=1时的huber损失定义


def huber_fn(y_true, y_pred):
    error = y_true-y_pred
    is_small_error = tf.abs(error) < 1
    square_loss = tf.square(error)/2
    linear_loss = tf.abs(error)-.5
    return tf.where(is_small_error, squared_loss, linear_loss)

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

model.compile(loss=huber_fn, optimizer='nadam')
model.fit(X_train, y_train)

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

保存包含自定义损失函数的模型效果很好。因为Keras会保存函数的名称。每次加载时都需要提供一个字典,将函数名称映射到实际函数。一般而言,当加载包含自定义对象的模型是需要将名称映射到对象:

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

对于Huber损失需要一个不同的delta值可以创建一个函数,该函数创建已配置的损失函数:

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.quare(error)/2
        linear_loss = threshold*tf.abs(error)-threshold**2/2
        return tf.where(is_small_error, squared_loss, linear_loss)
    return huber_fn

当保存模型时,阈值不会被保存。这意味着在加载模型时必须指定阈值。使用的名称是"huber_fn",这是为Keras命名的的函数的名称,而不是创建函数时的名称:

model = keras.models.load_model('my_model_with_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}
  • 构造函数接受**kwargs并把它们传递给父类构建函数,该父类构造函数处理标准超参数:超参数:损失的name和用于聚合单个实例损失的reduction算法。默认情况下,它是"sum_over_batch_size",这意味着损失将是实例损失的总和,由样本权重(如果有)加权,再除以批量大小(而不是权重之和,因此不是权重的平均)。其中可能的值为"sum"和"none"
  • call()方法获取标签和预测,计算所有实例损失,然后将其返回
  • get_config()方法返回一个字典,将每个超参数名称映射到其值。它首先调用父类的get_config()方法,然后将新的超参数添加到此字典中
# 可以在编译模型时使用此类的任何实例
model.compile(loss=HuberLoss(2.), optimizer='nadam')
# 当保存模型时,阈值会同时一起保存。在加载模型时,只需要将类名称映射到类本身即可:
model = keras.models.load_model(
    'my_model_with_a_custom_loss_class.h5', custom_objects={'HuberLoss': HuberLoss})

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

def my_softplus(z):  # 自定义softplus激活函数,相当于keras.activations.softplus()
    return tf.math.log(tf.exp(z)+1.0)


# 自定义Glorot初始化,相当于keras.initializers.glorot_normal()
def my_glorot_initializer(shape, dtype=tf.float32):
    # 计算方差,fan_{avg}=(fan_{in}+fan{out}/2,/sigma^2=1/fan_{avg}
    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):  # 确保权重为正的自定义约束,等同于keras.contraints.nonneg()
    # 权重为负则返回0,非负则不
    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层的输出,将结果传递到下一层。层的权重将使用初始化程序返回的值初始化。
在每个训练步骤中,权重将传递给正则化函数以计算正则化损失,并将其添加到主要损失中以得到用于训练的最终损失。
最后在每个步骤之后将调用约束函数,并将层的权重替换为约束权重。'''
'激活函数将应用于此Dense层的输出,将结果传递到下一层。层的权重将使用初始化程序返回的值初始化。\n在每个训练步骤中,权重将传递给正则化函数以计算正则化损失,并将其添加到主要损失中以得到用于训练的最终损失。\n最后在每个步骤之后将调用约束函数,并将层的权重替换为约束权重。'

如果函数具有需要和模型一起保存的超参数,需要继承适当的类,例如keras.regularizers.Regularizer,keras.constraints.Constraint,keras.initializers.Initializer或keras.layers.Layer(适用于任何层,包含激活函数)。就像为自定义损失一样,这是一个用于\(\ell_1\)正则化的简单类,它保存了其factor超参数(这次不用调用父类构造函数或get_config()方法,因为它不是由父类定义的):

class MylL1Regularizer(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损失函数用作指标。它可以很好地工作(持久性也可以以相同的方式工作,在这种情况下,仅保存函数名"huber_fn"):

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

对于训练期间的每一批次,Keras都会计算该指标并跟踪自轮次开始以来的均值。大多数时候,这是你想要的,但不总是!例如,考虑一个二分类器的精度。精度是真正的数量除以正预测的数量(包括真正和假正)。假设该模型在第一批次中做出了5个正预测,其中4个是正确的,即80%的精度。然后假设该模型在第二批次中做出了3个正预测,但它们都是不正确的,即第二批次的精度为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.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() # 所有预测值重置为0
class HuberMetrics(keras.metrics.Metric):
    def __init__(self, threshold=1.0, **kwargs):
        super().__init__(**kwargs)
        self.threshold = threshold
        self.huber_fn = create_huber(threshold)
        self.total = seld.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()方法。给定一个批次的标签和预测值(以及采样权重,但这个示例中忽略它们),它会更新变量
  • result()方法计算并返回最终结果,在这种情况下为所有实例的平均Huber度量。当使用度量作为函数时,首先调用update_state()方法,然后调用result()方法,并返回其输出
  • 这里还实现了get_config()方法来确保threshold与模型一起被保存
  • reset_states()方法的默认实现将所有变量重置为0.0(但是可以根据要求覆盖它)

自定义层

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

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

然后可以像使用顺序API、函数API或子类API等其他任何层一样使用此自定义层。也可以将其当作激活函数(或者可以使用activation=tf.exp、activation=keras.activations.exponential或仅使用activation='exponential')。当要预测的值具有非常不同的标度(例如0.01、10、1000)时,有时会在回归模型的输出层中使用指数层

要构建自定义的有状态层(即具有权重的层),需要创建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='bias', 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.activations.get()函数将激活函数转换为适当的激活函数(它接受函数、标准字符串(如"relu"或"selu",或None))
  • build()方法的作用是通过为每个权重调用add_weight()方法来创建层的变量。首次使用该层时,将调用build()方法,这对于创建某些权重通常是必需的。例如,需要知道上一层中神经元的数量,以便创建连接权重矩阵(即"kernel"):这对应输出的最后维度的大小。在build()方法的最后(并且在最后),必须调用父类的build()方法:这告诉Keras这一层被构建了(它只是设置了self.built=True)
  • call()方法执行所需操作。在这种情况下,计算输出X与层内核的矩阵乘积,添加偏执向量,并将结果应用于激活函数,从而获得层的输出
  • compute_output_shape()方法仅返回该层的输出形状。在这种情况下,它的形状与输入的形状相同,只是最后一个维度被替换为该层中的神经元的数量。在tf.keras中,形状是tf.TensorShape类的实例,可以通过as_list()将其转换为Python列表
  • get_config()方法就像之前的自定义类一样。通过调用keras.actiavtions.serialize()保存激活函数的完整配置

要创建一个具有多个输如(例如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]

如果层在训练期间和测试期间需要具有不用的行为(例如使用Dorpout或BatchNormalization层),则必须将训练参数添加到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(x, batch_input_shape):
        return batch_input_shape

自定义模型

定义一个输入经过第一个密集层,然后经过由两个密集层组成的残差快并执行加法运算,然后经过相同的残差块3次或者更多次,然后通过第二个残差块,最终结果经过密集输出层。要实现此模型,首先最好创建一个ResidualBolck层,因为将创建几个相同的块

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.hidden = 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()函数加载模型,则必须在两个ResidualBlock类和ResidualRegressor类中都实现get_config()方法。另外可以使用save_weights()和load_weights()方法保存和加载权重

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

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

有时候需要根据模型的其他部分来定义损失,例如权重或隐藏层的激活。这对于正则化或监视模型的某些内部方面可能很有用

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

class ReconstructingRegressor(keras.Model):
    def __init__(self, out_dim, **kwargs):
        super().__init__(**kwargs)
        self.hidden = [keras.layers.Dense(
            30, activation='elu', kernel_initialzer='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.recontruct = 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(rescontruction-inputs))
        self.add_loss(0.05*recon_loss)
        return self.out(Z)
  • 构建函数创建具有5个密集隐藏层和一个密集输出层的DNN
  • build()方法创建一个额外的密集层,该层用于重建模型的输入。必须在此处创建它,因为它的单元数必须等于输入数,并且在调用build()方法之前,此数是未知的
  • call()方法处理所有5个隐藏层的输入,然后将结果传递到重建层,从而产生重构
  • 然后call()方法计算重建损失(重建与输入之间的均方差),并使用add_loss()方法将其添加到模型的损失列表中。通过将其乘以0.05(可以调整的超参数)按比例缩小的重建。这确保了重建损失不会在主要损失中占大部分。
  • 最后,call()方法将隐藏层的输出传递到输出层并返回其输出

使用自动微分计算梯度

# 首先考虑一个简单的函数
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
print((f(w1+eps, w2)-f(w1, w2))/eps)
print((f(w1, w2+eps)-f(w1, w2))/eps)
36.000003007075065
10.000000003174137

这工作得很好并且易于实现,但这只是一个近似值,重要的是每个参数至少要调用一次f()(不是两次,因为只计算一次f(w1,w2))。每个参数至少需要调用f()一次,这种方法对于大型神经网络来说很棘手。因此,应该使用自动微分。TensorFlow使这个变得非常简单

import tensorflow as  tf
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>]

结果不仅是准确的(精度仅受浮点误差限制),而且无论有多少变量,gradient()方法都只经历以此计算(以相反的顺序),而且它非常有效。就像魔术一样

为了节省内存,仅将严格的最小值放入tf.GradientTape()块中。或者通过在tf.GradientTape()块中使用tape.stop_recording()块来暂停记录

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

with tf.GradientTape() as tape:
    z=f(w1,w2)
dz_w1=tape.gradient(z,w1)
dz_w2=tape.gradient(z,w2)
---------------------------------------------------------------------------

RuntimeError                              Traceback (most recent call last)

~\AppData\Local\Temp/ipykernel_4932/1289662103.py in <module>
      2     z=f(w1,w2)
      3 dz_w1=tape.gradient(z,w1)
----> 4 dz_w2=tape.gradient(z,w2)


C:\ProgramData\Miniconda3\envs\tf2\lib\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) # => tensor 36.0
dz_dw2=tape.gradient(z,w2) # => tensor 10.0
del tape

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

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

但是,可以强制tape观察你喜欢的任何张量,来记录设计它们的所有操作,然后可以针对这些张量计算梯度,就好像它们是变量一样

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

在某些情况下,这可能有用1,如果要实现正则化损失,从而在输出变化不大时惩罚那些变化很大的激活:损失将基于激活相对于输入的梯度而定。由于输入不是变量,因此你需要告诉tape观察它们

大多数情况下,一个梯度tape是用来计算单个值(通常是损失)相对于一组值(通常是模型参数)的梯度。这就是反向模式自动微分有用的地方,因为它只需要执行一次正向传播和一次反向传播即可获得所有梯度。如果尝试计算向量的梯度,例如包含多个损失的向量,那么Tensor Flow将计算向量和梯度。因此如果需要获取单独的梯度(例如每种损失相对于模型的梯度),则必须调用tape的jacobian()方法:它对向量中的每个损失执行一次反向模式自动微分(默认情况下全部并行)。它甚至可以计算二阶偏导数(Hessian,即偏导数的偏导数),但实际上很少需要

在某些情况下,可能希望阻止梯度在神经网络的某些部分反向传播。为此必须使用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)
gradients=tape.gradient(z,[w1,w2])
gradients
[<tf.Tensor: shape=(), dtype=float32, numpy=30.0>, None]

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

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

这是因为使用自动微分计算此函数的梯度值会导致一些数值上的困难:由于浮点精度误差,自动微分最终导致无法计算除以无穷(返回NaN)。幸运的是,可以分析发现softplus的导数为\(\frac{1}{1+\frac{1}{e^{x}}}\),在数值上是稳定的。接下来可以告诉TensorFlow在计算my_softplus()函数的梯度时使用@tf.custom_gradient来修饰它并使它既返回正常输出又返回计算导数的函数(它将接收到目前为止反向传播的梯度,直到softplus函数。根据链式法则,应该将它们乘以该函数的梯度):

@tf.custom_gradient
def my_better_softplus(z):
    exp=tf.exp(z)
    def my_softplus_gradient(grad):
        return grad/(1+1/exp)
    return tf.math.log(exp+1),my_softplus_gradients

现在计算my_better_softplus()函数的梯度时,即使对于较大的输入值,我们也可以获得正确的结果(但是由于指数的关系,主要输出仍然会爆炸。一种解决方法是使用tf.where()在输入较大时返回输入)

自定义训练循环

在极少数情况等下,fit()方法可能不够灵活而无法满足需要。例如在前面讨论的使用两种不同的优化器:一种用于宽路径,另一种用于深路径。由于fit()方法只是用一个优化器(编译模型时指定的优化器),因此实现该论文需要编写自定义循环

编写自定义训练循环会使得代码更长、更容易出错并且更难以维护

除非真的需要额外的灵活性,否则应该更倾向于使用fit()方法,而不是实现自己的训练循环,尤其是在团队合作中

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

l2_reg = keras.regularizers.l2(.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=32):
    idx = np.ranodm.randint(len(X), size=batch_size)
    return X[idx], y[idx]

定义一个函数,显示训练状态,包括步数、步总数、从轮次开始以来的平均损失(即使用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)

首先需要自定义一些超参数,然后选择优化器、损失函数和指标(这里使用MAE)

n_epochs=5
batch_size=32
n_steps=len(X_train)//batch_size
optimizer=keras.optimizers.Nadam(lr=.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)
        for metric in [mean_loss]+metrics:
            metric.reset_states()
  • 创建了两个嵌套循环:一个用于轮次,另一个用于轮次内的批处理
  • 然后从训练集中抽取一个随机批次
  • 在tf.GradientTape()块,对一个批次进行了预测(使用模型作为函数),并计算了损失:它等于主损失加其他损失(在此模型中,每层都有一个正则化损失)。由于mean_squared_error()函数每个实例返回一个损失,因此我们使用tf.reduce_mean()计算批次中的均值(如果要对每个实例应用不同的权重,则可以在这里操作)。正则化损失已经归约到单个标量,因此只需要求它们进行求和(使用tf.add_n()即可对具有相同形状和数据类型的多个张量求和)。
  • 接下来,用tape针对每个可训练变量计算损失的梯度,然后用优化器来执行梯度下降步骤
  • 然后,更新平均损失和指标(在当前轮次),并显示状态栏
  • 在每个轮次结束时,再次显示状态栏使其看起来完整并打印换行符,然后重置平均损失和指标的状态

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

最重要的时,此训练循环不会处理在训练期间和测试期间行为不同的层(例如BatchNormalization或Dropout)。需要处理这些问题,需要使用training=True调用模型,确保将其传播到需要它的层

posted @ 2021-10-16 23:19  里列昂遗失的记事本  阅读(383)  评论(0编辑  收藏  举报