Keras 下的自定义组件
导言
首先分享一下最近看的两本书:
- 《Deep Learning with Python》
- 《Hands-On Machine Learning with Scikit-Learn, Keras & TensorFlow》
两本书都到第三版了,阅读时请认准英文原版。
其中第一本书 DLWP 我已经基本读完,第二本 HOML 正在阅读。你可以理解为前者主要在讲 Keras,而后者可以深入到 TensorFlow。这样的阅读顺序我觉得挺好。
言归正传,这篇文章我想谈谈 Keras 的自定义组件,其主要思想来源于 HOML 的第 12 章。具体来讲,涵盖:
- 自定义 Initializer
- 自定义 Regularizer
- 自定义 Constraint
- 自定义 Activation Function
- 自定义 Loss
- 自定义 Metrics
- 自定义 Layer
- 自定义 Model
在这里面,有一些可以归纳出的点:
- 大多数情况下,可以只传递一个函数进去,Keras 会自动帮你处理这一切;
- 或者可以继承对应的类,每种组件都有其对应的类,一般要实现其
__call__或者call方法; - 在模型保存和加载的阶段,我们还要为每个组件实现
get_config方法; - Metrics、Layer、Model 这三者除了
call方法之外,还有其他的方法需要实现。
总之,这里面有许多东西需要处理和记忆,初看之下还会有些混乱。这篇文章就是在梳理这些内容。
由于篇幅有限,我只捡重要的讲。除了宣导之外,还能帮助我记忆。
正文
1. 支持函数作为自定义组件
在大多数情况下,我们可以直接使用函数作为自定义组件。例如,常见的 MSE Loss 我们可以直接实现为一个函数:
1 | def mse_loss(y_true, y_pred): |
最近被 TensorFlow 种草了,所以这里直接使用 TensorFlow 语法,没有使用 keras.ops.
支持函数作为自定义组件的有:
- 自定义 Initializer
- 自定义 Regularizer
- 自定义 Constraint
- 自定义 Activation Function
- 自定义 Loss
- 自定义 Metrics
除了 Layer、Model,其余的组件都支持传递函数作为自定义组件,Keras 会自动帮你处理好剩下的工作。
2. 继承特定的基类实现自定义组件
几乎所有的组件都支持继承某个基类实现自定义组件(而且这极有可能就是内部统一实现的方式)。
目的:当使用类作为自定义组件的时候,我们往往是希望通过这种方式声明自定义组件的内部状态。
例如 MSE 内部计算使用乘方的方式(即幂运算的指数为 2),我们希望定义一个指定指数的 Loss 函数,这时可通过继承基类的方式实现:
1 | class MPE(keras.losses.Loss): |
当然,基于 Python 语言的特性,你总是可以利用闭包完成一样的效果:
1 | def create_mpe_loss(exponent: int): |
因此,这绝不是继承基类的唯一能力。如果我们的自定义组件有内部状态,并且希望保存到模型,就只有继承基类的方式能够帮我们做到了。这时我们要在自定义类下实现 get_config 方法:
1 | class MPE(keras.losses.Loss): |
其内部的逻辑是,模型会在加载时,将 get_config 返回的值通过构造函数传递给 MPE 类,从而恢复原来的状态。
1 | keras.models.load_model("/path/to/model", custom_objects={"MPE": MPE}) |
注意:一定要将 MPE 类通过 custom_objects 参数传递进去。
基类继承的列表参考:
- 自定义 Initializer:
keras.Initializer - 自定义 Regularizer:
keras.Regularizer - 自定义 Constraint:
keras.Constraint - 自定义 Activation Function: 无
- 自定义 Loss:
keras.losses.Loss - 自定义 Metrics:
keras.metrics.Metric - 自定义 Layer:
keras.Layer - 自定义 Model:
keras.Model
注意:
- Activation Function 我标记为无,也就是说激活函数不应该再有内部状态了。
- 当我们想要用继承基类的方式自定义 Activation Function 的时候,需要继承自
keras.Layer,其本质是一个可训练的层。这是我查阅最新资料得到的回答。- 总之,自定义 Initializer、Regularizer、Constraint、Activation Function 这些有些特殊,它们是与 Dense 层深深绑定的。如果有机会,我去调研一下再讲。
3. 理解继承基类的工作流
在继承基类的时候,要实现不同的方法才能使其工作,我给它总结成几种情况:
- 直接实现
__call__方法:这种适用于内部没有特殊工作流的,直接作为纯函数使用的,包括 Initializer、Regularizer、Constraint. - 实现
call方法:基类已经实现了__call__方法,并有特殊流程,子类需要实现的是call方法。这类包括 Activation Function、Loss、Layer、Model. - Metris 方法有些特殊,子类需要实现
update_state、result、reset_state方法。
常规的我不想说了,只说两个特殊的点吧。
第一个是 Metrics. 我说过它有些特殊,子类不是实现 __call__ 或 call 方法,而是需要实现 update_state、result、reset_state 方法。(虽然 reset_state 不用实现,但我建议你在实践时还是一起实现它,毕竟有时候清晰比隐晦更友好)
这要从理解它的工作流讲起。当模型投入训练时,Metrics 即指标需要在内部维护它的状态,这就造成了它 “更新” - “获取结果” - “重置状态” 的工作模式:
- 在每一批次数据训练结束后,Metrics 接受训练结果更新内部的状态,这时调用的是
update_state方法; - 当一整代的数据训练结束后,Keras 调用 Metrics 的 result 方法获取这一代数据的指标;
- 然后 Keras 调用
reset_state方法以准备下一代的训练。
一个例子有利于理解上面的工作模式。现在我们实现一个自定义 Metrics,计算模型训练的准确率。
正确率(Accuracy)表示的是模型预测正确的样本数占总样本数的比例,其公式为 (预测正确的样本数) / (总样本数)。而我们这里所说的准确率与正确率不同。
相比于衡量样本整体的预测情况,我们有时更关心模型中正类样本的预测情况,这催生出两种衡量指标 “准确率” 和 “召回率”。
- 准确率(Precision)衡量的是模型预测为“正类”的样本中,有多少是真正的正类。计算公式:(模型预测为正中真实为正的样本数) / (模型预测为正的样本数)。
- 召回率(Recall)衡量的是模型找出所有真实正类样本的能力。计算公式:(真实为正的样本中预测为正的样本数) / (真实为正的样本数)。
实现“准确率”指标,要做的是在内部维护两个状态:模型预测为正的样本数、模型和真实都为正的样本数。
1 | class PrecisionMetrics(keras.Metric): |
第二个是自定义 Layer 和 Model,它们的特殊点在于,除了实现常规的 call 方法之外,最好要同步实现 build 方法。这要考虑到 Keras 在构建和运行模型时的工作流了。我将其分为 3 个步骤:
- 模型定义阶段;
- 模型构建阶段;
- 模型运行阶段。
模型定义阶段,我们只需要构建模型的连接图,例如首先是一个 Dense 层,再接着一个 Relu 激活函数,然后一个卷积层,再是一个 Relu 激活函数,如此等等。因为这个时候不知道上游输入数据的结构,此时模型各个层内部的参数还没有初始化。以 Dense 层为例:
1 | class Dense(keras.Layer): |
然后是模型构建阶段。针对模型内部的每一个层(或者是模型连接图的每一个结点),只有当已知上游传递的输入数据的形状时,才能真正地初始化内部参数。这时就是 build 方法工作的时机,以 Dense 层为例:
1 | class Dense(keras.Layer): |
在 build 中使用 self.add_weight() 创建的参数会被自动追踪,并包含在 self.trainable_weights 中。
最后是 call 方法,它在真正的计算过程中执行。由于已经在 build 方法中构建好了参数,此时直接执行计算逻辑即可。(请领悟一下 Keras 自定义 Layer 时这种逻辑分离的风格)
1 | class Dense(keras.Layer): |
总结:对于自定义 Layer 和 Model,请理解一下这种 “初始化” - “build” - “call” 的工作流。
4. 区分自定义 Layer 和 Model
做完这个议题我就想收尾了。
当我们谈到自定义 Layer 和 Model 的时候,毫无疑问就是在谈继承基类的方式。其实,Keras 是支持将纯函数作为自定义 Layer 的,参考
keras.layers.Lambda. 这里我们暂且忽视这一情况,只将基类继承作为它们俩的自定义方式。
首先,自定义是为了创造某种新的。例如前面的自定义 Loss,我们是希望构建出一种新的损失函数计算方式。同理包括 Initializer、Regularizer、Constraint、Activation Function、Metrics.
这个时候我们考虑用纯函数实现或者继承基类实现均可。考虑的逻辑是:如果没有内部状态并且实现相对简单,就用纯函数;如果需要保存内部状态或者实现相对复杂,用继承基类。
当谈到自定义 Layer 和 Model 的时候,情况就有所不同了。自定义 Layer 和 Model 绝对不是为了创造新的,或者你可以理解当我们构造 Layer 和 Model 的时候,一直都是在创造新的。因此,自定义 Layer 和 Model 和我们用 keras.Sequential 或者 函数式构建 的时候,作用其实是一样的,只是实现方式的不同而已。
所以,自定义 Layer 和 Model 的真实目的在于我们自己组织模型的方式,当我们需要一个整体并对外隐藏某些细节的时候,才是派上它们的用场。要知道,自定义 Layer 和 Model 的代码量明显增多,其要关注的细节也并另外两种模型构建的方式更多。
自定义 Layer 和 Model 的真实目的在于此,那么自定义 Layer 和自定义 Model 两者有什么区别呢?其实从目前的知识来说,自定义 Model 的目的没有那么明显。如果你认为自定义 Model 带来了更好的组织,那就去用它吧。但从目前的情况看,它并没有带来自定义 Layer 更多的能力。
模型可以查看、保存和加载,这是自定义 Layer 不具备的能力。但是其他的模型构建方式(Sequential 和 函数式)也有这些能力。
自定义 Model 最可能的用途(或者不得不为之的方式),是要实现自定义的训练过程。这里要重新实现 keras.Model 的 train_step 方法。我们遇到某些模型时(如 GAN 的对抗训练、特殊梯度更新),这是不得不这样做的方式。这种工作模式确实不那么常见而且有些复杂,如果有机会,我会在后面的文章里再说。
总结
本文系统梳理了在 Keras 中实现自定义组件的核心模式与思想。关键在于区分两种实现路径:对于无状态、简单的逻辑,直接传递函数是最优雅的选择;而对于需要内部状态、复杂逻辑或要求完整序列化的组件,则必须继承对应的基类,并实现其特定的方法链(如 call、update_state/result,或 build/call)。
自定义的终极目的并非为了炫技。对于 Loss、Metric 等,是为了定义新的评估维度;而对于 Layer 和 Model,实质是一种代码组织艺术,旨在封装复杂细节,构建清晰的抽象边界。其中,自定义 Model 的深层价值,往往在需要完全掌控训练循环(如重写 train_step)时才真正显现。
最后,我仍然要强调阅读原版书籍的重要性。本文的许多洞见直接源于《Hands-On Machine Learning》第12章的启发,而《Deep Learning with Python》则提供了更纯粹的Keras视角。只有深入阅读这些原始材料,你才能理解这些设计选择背后的深层逻辑,而不仅仅是记住几个API的用法。