分类 TensorFlow & Keras 下的文章

自定义损失函数

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

$$ L_\delta(y,f(x)) = \left\{ \begin{aligned} \frac{1}{2}(y-f(x))^2, \quad &|y-f(x)|\le\delta\\ \delta|y-f(x)|-\frac{1}{2}\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调用模型,确保将其传播到需要它的层

张量和操作

可以使用tf.constant()创建张量

from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity='all'
import tensorflow as tf
tf.constant([[1., 2., 3.], [4., 5., 6.]])
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[1., 2., 3.],
       [4., 5., 6.]], dtype=float32)>

像 ndarray 一样,tf.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: shape=(2, 2), dtype=float32, numpy=
array([[2., 3.],
       [5., 6.]], dtype=float32)>
t[..., 1]
<tf.Tensor: shape=(2,), dtype=float32, numpy=array([2., 5.], dtype=float32)>
t[..., 1, tf.newaxis]  # 提取所有行,第一列的数据并增加一个维度 所以形状是(2,1),上面没有增加新的维度形状就是(2,)
<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.matmul()函数
<tf.Tensor: shape=(2, 2), dtype=float32, numpy=
array([[14., 32.],
       [32., 77.]], dtype=float32)>

张量和 NumPy

张量可以与NumPy配合使用:可以使用NumPy数组创建张量,也可以将TensorFlow操作应用于NumPy数组,将NumPy操作应用于张量:

import numpy as np
a=np.array([2.,4.,5.])
tf.constant(a)
<tf.Tensor: shape=(3,), dtype=float64, numpy=array([2., 4., 5.])>
t.numpy()
array([[1., 2., 3.],
       [4., 5., 6.]], dtype=float32)
tf.square(a)
<tf.Tensor: shape=(3,), dtype=float64, numpy=array([ 4., 16., 25.])>
np.square(a)
array([ 4., 16., 25.])

类型转换

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

tf.constant(2.0)+tf.constant(40)
---------------------------------------------------------------------------

InvalidArgumentError                      Traceback (most recent call last)

~\AppData\Local\Temp/ipykernel_12252/3002758296.py in <module>
----> 1 tf.constant(2.0)+tf.constant(40)


InvalidArgumentError: cannot compute AddV2 as input #1(zero-based) was expected to be a float tensor but is a int32 tensor [Op:AddV2]


tf.constant(2.0)+tf.constant(40,dtype=tf.float64)
---------------------------------------------------------------------------

InvalidArgumentError                      Traceback (most recent call last)

~\AppData\Local\Temp/ipykernel_12252/2939420824.py in <module>
----> 1 tf.constant(2.0)+tf.constant(40,dtype=tf.float64)
InvalidArgumentError: cannot compute AddV2 as input #1(zero-based) was expected to be a float tensor but is a double tensor [Op:AddV2]

需要转换类型时,可以使用tf.cast():

t2=tf.constant(40.,dtype=tf.float64)
tf.constant(2.)+tf.cast(t2,tf.float32)
<tf.Tensor: 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[0,1].assign(42)
v[:,2].assign([0.,1.])
v.scatter_nd_update(indices=[[0,0],[1,2]],updates=[100.,200.])
<tf.Variable 'UnreadVariable' shape=(2, 3) dtype=float32, numpy=
array([[ 2.,  4.,  6.],
       [ 8., 10., 12.]], dtype=float32)>
<tf.Variable 'UnreadVariable' shape=(2, 3) dtype=float32, numpy=
array([[ 2., 42.,  6.],
       [ 8., 10., 12.]], dtype=float32)>
<tf.Variable 'UnreadVariable' shape=(2, 3) dtype=float32, numpy=
array([[ 2., 42.,  0.],
       [ 8., 10.,  1.]], dtype=float32)>
<tf.Variable 'UnreadVariable' shape=(2, 3) dtype=float32, numpy=
array([[100.,  42.,   0.],
       [  8.,  10., 200.]], dtype=float32)>

其他数据结构

  • 稀疏张量(tf.SparseTensor)
    有效地表示主要包含零的张量。tf.sparse程序包包含稀疏张量的操作
  • 张量数组(tf.TensorArray)
    张量的列表。默认情况下,它们的大小是固定的,但可以选择动态设置。它们包含的所有张量必须具有相同的形状和数据类型
  • 不规则张量(tf.RaggedTensor)
    表示张量列表的静态列表,其中每个张量具有相同的形状和数据类型。tf.ragged程序包包含用于不规则的张量的操作
  • 字符串张量
    tf.string类型的常规张量。它表示字节字符串,而不是Unicode字符串,因此如果使用Unicode字符串创建字符串张量,则它将被自动编码为UTF-8。或者,可以使用类型为tf.int32的张量来表示Unicode字符串,其中每个项都表示一个Unicode代码点。tf.strings包包含用于字节字符串和Unicode字符串的操作(并将转换为另一个)。重要的是要注意,tf.string是原子级的,这意味着它的长度不会出现在张量的形状当中。一旦将其转换为Unicode张量(即包含Unicode代码点的tf.int32l类型的张量)后,长度就会显示在形状中
  • 集合
    表示为常见张量(或稀疏张量)。例如,tf.constant([[1,2.],[3.4]])代表两个集合{1,2}和{3,4}。通常,每个集合由张量的最后一个轴上的向量表示。可以使用tf.sets包中的操作来操作集。
  • 队列
    跨多个步骤存储的张量。TenorFlow提供了各种队列:简单的先进先出(FIFO)队列(FIFOQueue),可以区分某些元素优先级的队列(Priority Queue),将其元素(RandomShuffleQueue)随机排列,通过填充(PaddingFIFOQueue)批量处理具有不同形状的元素。这些类都在tf.queue包中。

梯度下降

线性模型

$\hat{y}=x*w$来拟合学习时间$x$与考试分数$y$

$$ loss=(\hat{y}-y)^2=(x*w-y)^2\to cost=\frac1N\sum^N_{n=1}(\hat{y_n}-y_n)^2 $$

由图可知损失函数在$w=2.0$时,取得最小值。记损失函数在$w^*$处取得最小值

$$ w^*=\arg_w min\ cost(w) $$

假定w的初始位置在Initial Guess处,在这点处的梯度为 $\frac{\partial cost}{\partial w}$,此时梯度为正数,沿着梯度方向$cost$变化最快,可以通过沿梯度方向变化最快方向来更新$w$,通过公式:
$$w = w - \alpha \frac{\partial \text{cost}}{\partial w} $$
来更新 $w$。$\alpha$ 为学习率,也可以理解成沿梯度下降方向迈出步伐的大小。
$$ \frac{\partial \text{cost}(\omega)}{\partial\omega} = \frac{\partial}{\partial\omega}\frac{1}{N}\sum^N_{n=1}(x_n\omega-y_n)^2 $$

$$ = \frac{1}{N}\sum^N_{n=1}\frac{\partial}{\partial\omega}(x_n\omega-y_n)^2 $$

$$ = \frac{1}{N}\sum^N_{n=1}2(x_n\omega-y_n)\frac{\partial(x_n\omega-y_n)}{\partial\omega} $$

$$ = \frac{1}{N}\sum^N_{n=1}2x_n(x_n\omega-y_n) $$

x_data = [1.0, 2.0, 3.0] # 准备数据
y_data = [2.0, 4.0, 6.0]
 
w = 1.0 # 初始化权重w为1.0


def forward(x):
    return x*w # 定义线性模型y=x*w


def cost(xs, ys): # 定义损失函数
    cost = 0
    for x, y in zip(xs, ys):
        y_pred = forward(x)
        cost += (y_pred-y)**2 # 损失等于每个数据的和
    return cost/len(xs) # 除以数据个数来获得均方误差差


def gradient(xs, ys): # 定义梯度函数
    grad = 0
    for x, y in zip(xs, ys):
        grad += 2*x*(x*w-y) # 根据前面的公式计算在该点的梯度
    return grad/len(xs)


print('Predict (before training', 4, forward(4)) # 输出训练之前的x=4和y_pred
for epoch in range(100): # 训练100伦
    cost_val = cost(x_data, y_data) # 计算损失函数
    grad_val = gradient(x_data, y_data) # 计算梯度
    w -= 0.01*grad_val # 通过梯度和学习率来调整权重
    print('Epoch', epoch, 'w=', w, 'loss=', cost_val)
print('Predict (after training)', 4, forward(4)) # 输出训练之后的x=4和y_pred
Epoch 90 w= 1.9998658050763347 loss= 1.0223124683409346e-07
Epoch 91 w= 1.9998783299358769 loss= 8.403862850836479e-08
Epoch 92 w= 1.9998896858085284 loss= 6.908348768398496e-08
Epoch 93 w= 1.9998999817997325 loss= 5.678969725349543e-08
Epoch 94 w= 1.9999093168317574 loss= 4.66836551287917e-08
Epoch 95 w= 1.9999177805941268 loss= 3.8376039345125727e-08
Epoch 96 w= 1.9999254544053418 loss= 3.154680994333735e-08
Epoch 97 w= 1.9999324119941766 loss= 2.593287985380858e-08
Epoch 98 w= 1.9999387202080534 loss= 2.131797981222471e-08
Epoch 99 w= 1.9999444396553017 loss= 1.752432687141379e-08
Predict (after training) 4 7.999777758621207

随机梯度下降(SGD)Stochastic Gradient Descent

$$ w=w-\alpha\frac{\partial cost}{\partial w}\to w=w-\alpha\frac{\partial loss}{\partial w} $$

$$ \frac{\partial cost}{\partial w}=\frac1N\sum_{n=1}^N2\cdot x_n\cdot(x_n\cdot w-y_n)\to\frac{\partial loss_n}{\partial w}=2\cdot x_n\cdot(x_n\cdot w-y_n) $$

def loss(x,y):
    y_pred=forward(x)
    return (y_pred-y)**2

$loss=(\hat{y}-y)^2=(x*w-y)^2$

def graident(x,y):
    return 2*x*(x*w-y)

$\frac{\partial loss_n}{\partial w}=2\cdot x_n\cdot(x_n\cdot w-y_n)$

for epoch in range(100):
    for x,y in zip(x_data,y_data):
        grad=gradient(x,y)
        w=-=0.01*grad
        print('\tgrad:',x,y,grad)
        l=loss(x,y)

更新权重使用训练集每个样本的梯度,而不是梯度的平均

动量优化

想象一下,一个保龄球在光滑的表面上沿着平缓的坡度滚动:它开始速度很慢,在很快会获得动量,直到最终达到终极速度(如果有摩擦或空气阻力)。相比之下,常规的梯度下降法只是在斜坡上采取小的、常规的步骤,因此算法将花费更多时间到达底部

回想一下梯度下降通过直接减去权重的成本函数$J(\theta)$的梯度乘以学习率$\eta(\nabla_\theta J(\theta))$来更新权重$\theta$。等式是:$\theta\gets\theta-\eta\nabla_\theta J(\theta)$。它不关心较早的梯度是什么。如果局部梯度很小,则它会走得非常缓慢。
动量优化非常关心先前的梯度是什么:在每次迭代时,它都会从动量向量$m$(乘以学习率$\eta$)中减去局部梯度,并通过添加该动量向量来更新权重。换句话说,梯度是用于加速度而不是速度。为了模拟某种摩擦机制并防止动量变得过大,该算法引入了一个新的超参数$\beta$,称为动量,必须将其设置为0(高摩擦)和1(无摩擦)之间。典型的动量值为0.9。
动量算法:

  1. $m\gets\beta m-\eta\nabla_\theta J(\theta)$
  2. $\theta\gets\theta+m$

你可以轻松地验证,如果梯度保持恒定,则最终速度(即权重更新的最大大小)等于该梯度乘以学习率$\eta$在乘以$1/(1+\beta)$(忽略符号)。例如,如果$\beta=0.9$,则最终速度等于梯度乘以学习率的10倍!这使得动量优化比梯度下降要更快地从平台逃脱。梯度下降相当快地沿着陡峭的斜坡下降,但是沿着山谷下降需要很长时间。相反,动量优化将沿着山谷滚动得越来越快,直到达到谷底(最优解)。在不使用批量归一化的深层神经网络中,较高的层通常会得到比率不同的输入,因此使用动量优化会有所帮助。它还可以帮助绕过局部优化问题。

由于这种动量势头,优化器可能会稍微过调,然后又回来,再次过调,在稳定于最小点之前会多次振荡。这是在系统中有一些摩擦力的原因之一:它消除了这些振荡,从而加快了收敛速度。

在Keras中实现动量优化,只需要使用SGD优化器并设置其超参数momentum:

optimizer=keras.optimizers.SGD(lr=1e-4,momentum=.9)

动量优化的一个缺点是它增加了另一个超参数来调整。但是,动量0.9通常在实践中效果很好,几乎总是比常规的“梯度下降”更快。

Nesterov加速度

Nesterov加速梯度(Nesterov Accelerated Gradient,NAG)方法也被称为 Nesterov 动量优化,它不是在局部位置$\theta$,而是在$\theta+\beta m$处沿动量方向稍微提前处测量成本函数的梯度

Nesterov 加速梯度算法:

  1. $m\gets\beta m-\eta\nabla_\theta J(\theta+\beta m)$
  2. $\theta\gets\theta+m$

这种小的调整有效是因为通常动量向量会指向正确的方向(即朝向最优解),因为使用在该方向上测得更远的梯度而不是原始位置上的梯度会稍微准确些。

Nesterov 更新会最终稍微接近最优解。一段时间后,这些小的改进累加起来,NAG 就比常规的动量优化要快得多。此外,当动量推动权重跨越谷底时,$\nabla_1$继续推动越过谷底,而$\nabla_2$推回谷底。这有助于减少振荡,因为 NAG 收敛更快。

NAG 通常比常规动量更快,要使用它,只需要在创建 SGD 优化器时设置nesterov=True 即可:

optimizer=keras.optimizers.SGD(lr=1e-4,momentum=.9,nesterov=True)

常规与 Nesterov 动量优化:前者使用在动量步骤之前计算梯度,而后者使用在动量步骤后计算的梯度

AdaGrad

再次考虑拉长的碗状问题:梯度下降从快速沿最陡的坡度下降开始,该坡度没有直接指向全局最优解,然后非常缓慢地下沉到谷底。如果算法可以纠正其方向,使它更多地指向全局最优解,那将是很好的。AdaGrad 算法通过沿最陡峭的维度按比例缩小梯度向量

AdaGrad 算法:

  1. $s\gets s+\nabla_\theta J(\theta)\otimes\nabla_\theta J(\theta)$
  2. $\theta\gets\theta-\eta\nabla_\theta J(\theta)\oslash\sqrt{s+\epsilon}$

第一步将梯度的平方累加到向量${s}$中($\otimes$符号表示逐元素相乘)。此向量化形式等效于针对向量${s}$中的每个元素$s_i$计算$s_i\gets s_i+(\partial J(\theta)/\partial\theta_i)^2$。换句话说,每个$s_i$累加关于参数$\theta_i$的成本函数偏导数的平方。如果成本函数沿第$i$个维度陡峭,则$s_i$将在每次迭代中变得越来越大。

第二步几乎与“梯度下降”相同,但有一个很大的区别:梯度向量按比例因子$\sqrt{{s}+\epsilon}$缩小了($\oslash$符号代表逐元素相除,而$\epsilon$是避免被除以零的平滑项,通常设置为$10^{-10}$)。此向量化形式等效于对所有参数$\theta_i$同时计算$\theta_i\gets\theta_i-\eta\ \partial J(\theta)/\partial\theta_i\ /\ \sqrt{{s_i}+\epsilon}$ 。

简而言之,该算法会降低学习率,但是对于陡峭的维度,它的执行速度要比对缓慢下降的维度的执行速度更快。这称为自适应学习率。它有助于将结果更新更直接的指向全局最优解。另一个好处是,它几乎不需要调整学习率超参数$\eta$。
AdaGrad与梯度下降法:前者可以较早地修正其方向,使之指向最优解
对于简单的二次问题,AdaGrad经常表现良好,但是在训练神经网络时,它往往停止得太早。学习率按比例缩小,以至于算法在最终达到全局最优解之前完全停止了。因此,即使KerasAdaGrad优化器。

RMSProp

AdaGrad有下降太快,永远不会收敛到全局最优解的风险。RMSProp算法通过只是累加最近迭代中的梯度(而不是自训练开始以来的所有梯度)来解决这个问题。它通过在第一步中使用指数衰减。
RMSProp算法:

  1. $s\gets\beta s\ \nabla_\theta J(\theta)\otimes\nabla_\theta J(\theta)$
  2. $\theta\gets\theta-\eta\ \nabla_\theta J(\theta)\oslash\sqrt{s+\epsilon}$
    衰减率$\beta$通常设置为0.9。默认值通常效果很好。
    Keras有RMSProp优化器:

    optimizer=keras.optimizers.RMSProp(lr=1e-4,rho=.9)

    rho参数对应于公式中的$\beta$,除了非常简单的问题外,该优化器几乎总是比AdaGrad表现更好。实际上,直到Adam优化出现之前,它一直是许多研究人员首选的优化算法。

    Adam 和 Nadam 优化

    Adam代表自适应矩估计,结合了动量优化和RMSProp的思想:就像动量优化一样,它跟踪过去梯度的指数衰减平均值。就像RMSProp一样,它跟踪过去平方梯度的指数衰减平均值。
    Adma算法:

  3. $m\gets\beta_1m-(1-\beta_1)\ \nabla_\theta J(\theta)$
  4. $s\gets\beta_2s+(1-\beta_2)\ \nabla_\theta J(\theta)\otimes\nabla_\theta J(\theta)$
  5. $\hat{m}\gets\frac{m}{1-\beta_1^t}$
  6. $\hat{s}\gets\frac{s}{1-\beta_2^t}$
  7. $\theta\gets\theta-\eta\hat{m}\oslash\sqrt{\hat{s}+\epsilon}$

在此等式中,$t$表示迭代次数(从1开始)。
如果只看步骤1、2和5,你会发现Adam与动量优化和RMSProp非常相似。唯一的区别是步骤1计算的是指数衰减的平均值,而不是指数衰减的总和,但除了常数因子(衰减平均值是衰减总和的$1-\beta_1$倍)外,它们实际上是等效的。第三步和第四步在技术上有些细节:由于ms初始化为0,因此在训练开始时它们会偏向0,这两个步骤将有助于在训练开始时提高ms

动量衰减超参数$\beta_1$通常初始化为0.9,而缩放衰减超参数$\beta_2$通常被初始化为0.999。平滑项$\epsilon$通常会初始化为一个很小的数字。这些是Adam类的默认值(准确说,epsilon的默认值为None,它告诉Keras使用keras.backend.espilon(),默认值为$10^{-10}$。可以使用keras.backend.set_epsilon()来改变)。这是使用Keras来创建Adam优化器的方法:

optimizer=keras.optimizers.Adam(lr=1e-4,beta_1=.9,beta_2=.999)

由于Adam是一种自适应学习率算法(如AdaGrad和RMSProp),因此对学习率超参数$\eta$需要较少的调整。通常可以使用默认值$\eta=.001$,这使得Adam比梯度下降更易于使用。

Adam的两个变体

AdaMax

在Adam算法的步骤2中,Adma累加了s中的梯度平方(对于最近的梯度,权重更大)。在第5步中,如果忽略$\epsilon$和第三步和第四步,Adma将以s的平凡根按比例缩小参数更新。简而言之,Adam按时间衰减梯度的$\ell_2$范数按比例缩小参数更新($\ell_2$范数是平方和的平方根)。与Adam在用一篇论文中介绍的AdaMax将$\ell_2$范数替换为$\ell_\infty$(一种表达最大值的新颖方法)。具体来说,它用$s\gets max(\beta_2s,\nabla_\theta J(\theta))$替换公式中的步骤2,它删除第4步,在第5步中,将梯度更新按比例s缩小,这是时间衰减梯度的最大值。实际上,这可以使AdaMax比Adam更稳定,但这确实取决于数据集,通常Adam的表现更好。因此,用Adam在某些任务上遇到问题,这时可以尝试的另一种优化器。

Nadam

Nadam优化是Adam加上 Nesterov 技巧,因此其收敛速度通常比Adam稍快。研究人员Timothy Dozat在各种任务上对许多不同的优化器进行了比较,发现Nadam总体上胜过Adam,但有时不如RMSProp。

自适应优化方法(包括RMSProp、Adam和Nadam优化)通常很棒,可以快速收敛到一个好的解决方案,因此,当对模型的性能感到失望时,尝试改用Nesterov 加速梯度:你的数据可能对自适应梯度过敏。

到目前为止讨论的所有优化技术都仅仅依赖于一阶片导数(Jacobians)。一些优化文献还包含了基于二阶片导数(Hessian)的一些算法。这些算法很难应用于深度神经网络,因为每个输出有$n^2$的Hessian(其中n是参数的数量),而不是每个输出只有n个Jacobians。由于DNN通常具有成千上万的参数,因此二阶优化算法甚至不适合储存在内存中,即使可以,计算Hessian也太慢了

训练稀疏模型

所有的优化算法都产生了密集模型,这意味着大多数参数都是非零的。如果运行时需要一个非常快的模型,或者需要占用更少的内存,就更需要一个稀疏模型。

实现这一点的一个简单方法是像往常一样训练模型,然后去掉很小的权重(将它们设置为零)。但这通常不会导致非常稀疏的模型,而且可能降低模型的性能。

一个更好的选择是在训练时使用强$\ell_1$正则化,因为它会迫使优化器产生尽可能的为零的权重

学习率调度

找到一个好的学习率很重要。如果设置得太高,训练可能会发散。如果设置地太低,训练最终会收敛到最优解,但是这将花费很长时间。如果它设置得稍微有点高,它一开始会很快,但是最终会围绕最优解振荡,不会真正稳定下来。如果算力有限,则可能必须先终端训练,然后才能正确收敛,从而产程次优解。

与恒定学习率相比,对学习率进行调度可以更快地找到一个最优解

幂调度

将学习率设置为迭代次数$t$的函数:$\eta(t)=\eta_0/(1+t/s)^c$。初始学习率$\eta_0$、幂$c$(通常设置为1)和步骤$s$是超参数。学习率在每一步都会下降。在$s$个步骤之后,它下降到$\eta_0/2$。再在$s$个步骤之后,它下降到$\eta_0/3$,以此类推,此调度开始下降,然后越来越慢。幂调度需要调整$\eta_0$和$s$可能还有$c$。

指数调度

将学习率设置为$\eta(t)=\eta_0\ 0.1^{\frac{t}{s}}$。学习率每s步将逐渐下降10倍。幂调度越来越缓慢地降低学习率,而指数调度则使学习率每s步降低10倍。

分段恒定调度

对一些轮次使用恒定的学习率(例如对5个轮次,$\eta_0=0.1$),对于另外一些轮次使用较小的学习率(例如,对于50轮次,$\eta_0=0.001$)。以此类推。

性能调度

每N步测量一次验证误差(就像提前停止一样),并且当误差停止下降时,将学习率降低$\lambda$倍

1周期调度1

与其他方法相反,1周期调度从提高初始学习率$\eta_0$开始,在训练过程中增长至$\eta_1$。然后,它在训练的后半部分将学习率再次线性降低到$\eta_0$,通过将学习率降低几个数量级(仍然是线性的)来完成最后几个轮次。使用与找到最优学习率相同的方法来选择最大学习率$\eta_1$,而初始学习率$\eta_0$大约要低10倍。

使用动量优化来训练深度神经网络进行语音识别时一些最受欢迎的学习率调度的性能。在这种情况下,性能调度和指数调度都表现良好。更倾向于指数调度,因为它易于微调

在Keras中实现幂调度是最简单的选择:只需在创建优化器时设置超参数decay即可:

optimizer=keras.optimizers.SGD(lr=0.01,decay=1e-4)

decay是s(学习率除以多个数字单位所需要的步数)的倒数,Keras假定c=1.
指数调度和分段调度也非常简单,需要定义一个函数,该函数采用当前轮次并返回学习率。

指数调度:

def exponential_decay_fn(epoch):
    return 0.01*0.1**(epoch/20)

如果不想对$\eta_0$和$s$进行硬编码,则可以创建一个返回配置函数的函数:

def exponential_decay(lr0,s):
    def exponential_decay_fn(epoch):
        return lr0*0.1**(epoch/s)
    return exponential_decay_fn

接下来,创建一个LearningRateScheduler回调函数,为其提供调度函数,然后将此回调函数传递给fit()方法:

lr_scheduler=keras.callbacks.LearningRateScheduler(exponential_decay_fn)
history=model.fit(X_train_scaled,y_train,callbacks=[lr_scheduler])

LearningRateScheduler将在每个轮次开始时更新优化器的learning_rate属性。通常每轮次更新一次学习率

调度函数可以选择将当前学习率作为第二个参数。例如,以下调度调度函数将以前的学习率乘以$0.1^{1/20}$,这将导致相同的指数衰减(但现在衰减从轮次0开始而不是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回调函数。例如,如果连续5个轮次的最好验证损失都没有改善时,它将使学习率乘以0.5

lr_scheduler=keras.callbacks.ReduceLROnPlateau(factor=.5,patiences=5)

最后,tf.keras提供了另一种实现学习率调度的方法:使用keras.optimizers.schedule中可以使用的调度之一来定义学习率,然后将该学习率传递给任意优化器。这种方法在每个步骤更新学习率而不是每个轮次。例如,以下是实现与之前定义的exponential_decay_fn()函数相同的指数调度的方法:

s=20*len(X_train)//32 #batch_size=32,每轮X_train//32步,共20轮
learning_rate=keras.optimizers.schedules.ExponentialDecay(0.01,s,0.1)
optimizer=kersa.optimizers.SGD(leraning_rate)

保存模型时,学习率及其调度(包括其状态)也将被保存。

用Keras进行迁移学习

假如Fashion MNIST数据集包含了8个类别,例如,除凉鞋和衬衫之外的所有类别。有人在该数据集上建立并训练的Keras模型,并获得了相当不错的性能(精度>90%)。将此模型称为模型A。现在要处理另一项任务:有凉鞋和衬衫的图像,想要训练一个二元分类器(正=衬衫,负=凉鞋)。数据集非常小,只有200张带标签的图像。当使用与模型A相同的架构训练一个新模型(称为模型B)时,它的的性能相当好(97.2%)

首先,需要加载模型A并基于该模型的层创建一个新模型。来重用输出层之外的所有层:

import tensorflow as tf
from tensorflow import keras

model_A=keras.models.load_model('my_model_A.h5')
model_B_on_A=keras.models.Sequential(model_A.layers[:-1])
model_B_on_A.add(keras.layers.Dense(1,activation='sigmoid'))

model_A和model_B_on_A现在共享一些层。当训练model_B_on_A时,也会影响model_A。如果想避免这种情况,需要在重用model_A的层之前对其进行克隆。为此,需要使用clone_model()来克隆模型A的架构,然后复制其权重(因为clone_model()不会克隆权重):

model_A_clone=kersa.models.clone_model(model_A)
model_A_clone.set_weights(model_A.get_weights())

现在开始可以为任务B训练model_B_on_A,但是由于新的输出层是随机初始化的,它会产生较大的错误(至少在前几个轮次内),因为将存在较大的错误梯度,这可能会破坏重用的权重。为了避免这种情况,一种方法是在前几个轮次冻结重用的层,给新层一些时间来学习合理的权重。为此,可以将每一层的可训练属性设置为False并编译模型

for layer in model_B_on_A.layers[:-1]:
    layers.trainable=False

model_B_on_A.compile(loss='binary_crossentropy',optimizer='sgd',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=keras.optimizers.SGD(lr=1e-4)
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))