Keras 这东西,刚接触深度学习那会儿我把它当成"高级封装"随手用,直到有一次要复现一篇论文里的多输入融合结构,才发现自己对它到底有几种模型组织方式根本没搞清楚——Sequential 一把梭写惯了,遇到需要共享层、需要多输出、需要中途分叉的场景就卡壳。后来我把 Keras 的模型构建方式系统梳理了一遍,从最朴素的顺序堆叠到函数式图结构再到子类化,才算真正把"模型种类"这件事吃透。这篇就围绕 Keras 里几种主流的模型组织形态展开,讲清楚它们各自解决什么问题、底层是怎么组织的、什么场景该选哪种,以及我在实际项目里踩过的那些坑。不管你是刚学完 TensorFlow 基础想往上走,还是已经能跑通 Demo 但一遇到复杂结构就发怵,下面这些内容应该都能对上你的需求。
1. 先把"模型种类"这件事的边界划清楚
很多人一看到"Keras 模型种类"这几个字,第一反应是"不就是 Sequential 和 Functional 两种吗"。这个理解不算错,但太窄了。Keras 里所谓"模型种类",本质上是在回答一个问题:你用什么方式描述一张计算图。描述方式不同,能表达的拓扑结构就不同,调试体验、可复用性、导出能力也跟着变。
1.1 三种描述范式,而不是三个并列的类
从组织形态上看,Keras 构建模型有三条主线:
- 顺序式(Sequential):层像排队一样首尾相接,只有一个输入一个输出,中间不允许分叉。
- 函数式(Functional):把层当成函数调用,输入张量进、输出张量出,可以任意分叉、合并、共享,最终用
Model(inputs, outputs)把整张图包起来。 - 子类化(Subclassing):继承
tf.keras.Model,在__init__里定义层,在call里写前向逻辑,前向过程就是普通 Python 代码。
这三者不是互斥的"三个类",而是三种抽象层级。Sequential 其实是 Functional 的一个特例(单链图),Functional 是"声明式地画图",Subclassing 是"命令式地写图"。理解这个层级关系,比记住三个 API 名字重要得多。
1.2 为什么这个区分值得单独拎出来讲
我见过太多项目,一开始图省事用 Sequential,模型写到一半发现要做残差连接,只能推倒重来改成 Functional;也见过有人一上来就 Subclassing,结果模型结构没法用model.summary()直观看到,调结构调到怀疑人生。选错描述范式的代价,往往不是多写几行代码,而是整个调试和迭代节奏被打乱。
提示:判断该用哪种,先问自己两个问题——模型有没有分叉或合并?需不需要在训练中途访问中间层输出?前者决定 Sequential 够不够用,后者决定 Functional 和 Subclassing 的取舍。
下面这张表是我自己总结的快速对照,先给个全局印象,后面逐项展开。
| 维度 | Sequential | Functional | Subclassing |
|---|---|---|---|
| 拓扑表达能力 | 单链 | 任意有向无环图 | 任意(含动态控制流) |
| 结构可视化 | 好 | 好 | 差(需额外处理) |
| 多输入多输出 | 不支持 | 原生支持 | 原生支持 |
| 层共享 | 不方便 | 自然支持 | 自然支持 |
| 调试友好度 | 高 | 高 | 中 |
| 适合场景 | 快速原型、简单堆叠 | 大多数生产模型 | 研究型、动态结构 |
2. Sequential:最省心,但它的"省心"有明确代价
Sequential 是绝大多数人写的第一个 Keras 模型。它的 API 极简,model.add()一层层往上摞就行,读起来像一份清单。但正因为太顺手,很多人没意识到它到底限制了什么。
2.1 它到底限制在哪:单输入单输出 + 线性拓扑
Sequential 的本质是一条线性链:第 n 层的输出只能是第 n+1 层的输入,不允许某一层的输出同时喂给两个下游层,也不允许两个分支在后面汇合。这意味着残差连接(ResNet 那种x + f(x))、Inception 那种多分支并行、注意力机制里 query/key/value 的分头处理,统统写不了。
有人会问:那我能不能在 Sequential 里塞一个自定义层,把分叉逻辑藏在层内部?可以,但这等于你把图结构藏进了黑盒,summary()看不出来,可视化也画不出来,本质上已经放弃了 Sequential 的透明性优势,不如直接上 Functional。
2.2 一个能直接跑的 Sequential 骨架
import tensorflow as tf from tensorflow.keras import layers model = tf.keras.Sequential([ layers.Input(shape=(784,)), layers.Dense(256, activation='relu'), layers.Dropout(0.3), layers.Dense(128, activation='relu'), layers.Dense(10, activation='softmax') ], name='mlp_baseline') model.compile( optimizer=tf.keras.optimizers.Adam(1e-3), loss='sparse_categorical_crossentropy', metrics=['accuracy'] ) model.summary()这里有个细节值得说:第一层用layers.Input显式声明输入形状,而不是靠input_shape参数。前者是现在推荐的做法,因为它让模型在构建时就确定了完整的形状信息,summary()能直接列出每层的输出维度,也方便后续保存和加载。老代码里常见的Dense(256, input_shape=(784,))写法虽然还能用,但会在某些导出场景下带来形状推断的麻烦。
2.3 Sequential 真正好用的地方
别因为上面那些限制就小看它。做基线实验、跑通数据管道、验证一个想法是否值得深入,Sequential 的效率无可替代。我个人的习惯是:任何新任务,先用 Sequential 搭一个最朴素的 MLP 或小型 CNN,把数据加载、损失函数、评估指标这条链路跑通,确认没有数据层面的低级错误,再考虑换更复杂的结构。这个"先跑通再优化"的顺序,能帮你把"模型结构问题"和"数据管道问题"分开排查,省下大量时间。
3. Functional API:把模型当成一张可以任意连线的图
Functional 是我在真实项目里用得最多的一种。它的核心心智模型很简单:层是可调用的函数,张量是流动的数据,你把输入张量喂进去,拿到输出张量,最后声明"从这些输入到这些输出"就是我的模型。
3.1 为什么它比 Sequential 表达力强这么多
关键在于 Functional 里每一层的调用都会返回一个新的张量,你可以把这个张量存下来、复用、和别的张量做运算。这就打开了三扇门:
- 分叉:一个张量可以同时喂给多个层。
- 合并:多个张量的输出可以用
Concatenate、Add、Multiply等层合并。 - 共享:同一个层对象可以被调用多次,权重自动共享。
这三件事组合起来,几乎能表达所有常见的前馈结构。残差块、多尺度特征融合、双塔召回模型、多任务学习里的共享底层,全都是这套机制的排列组合。
3.2 手写一个带残差和多输出的结构
下面这个例子同时演示了分叉、合并和多输出,是我用来讲 Functional 的经典骨架:
from tensorflow.keras import layers, Model inputs = layers.Input(shape=(128,), name='features') # 主干 x = layers.Dense(64, activation='relu')(inputs) x = layers.Dense(64, activation='relu')(x) # 残差:把输入投影到同维度后相加 shortcut = layers.Dense(64)(inputs) x = layers.Add()([x, shortcut]) x = layers.Activation('relu')(x) # 分叉出两个任务头 cls_out = layers.Dense(1, activation='sigmoid', name='cls')(x) reg_out = layers.Dense(1, name='reg')(x) model = Model(inputs=inputs, outputs=[cls_out, reg_out], name='multi_task') model.compile( optimizer='adam', loss={'cls': 'binary_crossentropy', 'reg': 'mse'}, loss_weights={'cls': 1.0, 'reg': 0.5} )注意shortcut那一步:因为主干的输出维度是 64,输入是 128,直接相加维度对不上,所以要先过一个Dense(64)把维度对齐。这是写残差结构时最容易翻车的地方——Add要求所有输入张量形状完全一致,维度不匹配会直接报错。很多人第一次写残差就在这里卡住,以为是 API 用错了,其实是形状没对齐。
3.3 多输出模型的损失配置逻辑
上面compile里用了字典形式的 loss 和loss_weights,这里展开说一下为什么这么配。多输出模型训练时,每个输出头都有自己的损失,总损失是各头损失的加权和。loss_weights控制各任务的相对重要性,比如分类任务更重要就给它大一点的权重。如果不指定,默认各头等权。
实际调参时我的经验是:先让各头等权跑一轮,看各头 loss 的量级。如果某个头的 loss 天然就比另一个大一个数量级(比如回归的 MSE 和分类的交叉熵量纲不同),等权会导致梯度被大 loss 那头主导,小 loss 那头学不动。这时候要么调loss_weights,要么对各头的 loss 做归一化。这个坑在多任务学习里极其常见,光看总 loss 下降就以为训练正常,其实某个任务早就躺平了。
3.4 Functional 的调试优势:中间层随时可取
Functional 还有一个被低估的好处:因为整张图是显式声明的,你可以随时构造一个"截断模型"来拿中间层输出。比如想看第 3 层的特征,直接Model(inputs=model.input, outputs=model.layers[2].output)就行。这在做特征可视化、迁移学习、模型蒸馏时特别方便。Subclassing 就没这么轻松,因为它的前向逻辑是写在call里的 Python 代码,没有显式的图结构可供截取。
4. Subclassing:当前向逻辑复杂到图描述不够用时
Subclassing 是三种方式里最"自由"的,也是最容易被误用的。它的写法是继承tf.keras.Model,在__init__里把层建好,在call里写前向计算。前向过程就是普通 Python,可以有 if、for、甚至递归。
4.1 它解决的是"图描述不了"的问题
什么时候 Functional 不够用?典型场景是前向逻辑依赖运行时数据。比如:
- 根据输入长度动态决定循环次数(某些序列模型)。
- 前向过程中包含条件分支,不同样本走不同路径。
- 需要在前向里做复杂的张量操作,用现成的层拼不出来。
这些情况下,Functional 的"声明式画图"就力不从心了,因为图必须在构建时静态确定。Subclassing 把前向变成命令式代码,运行时才决定怎么算,灵活性拉满。
class GatedBlock(tf.keras.Model): def __init__(self, units): super().__init__() self.dense_a = layers.Dense(units, activation='relu') self.dense_b = layers.Dense(units, activation='sigmoid') self.proj = layers.Dense(units) def call(self, inputs, training=None): a = self.dense_a(inputs) gate = self.dense_b(inputs) return self.proj(a * gate)这个门控块用 Functional 也能写(Multiply层),但一旦逻辑再复杂一点,比如门控值要根据 batch 统计量动态调整,Functional 就难写了,Subclassing 则很自然。
4.2 Subclassing 的代价:你失去了什么
自由是有代价的,而且这个代价在项目后期会集中爆发:
summary()不完整:因为图没显式构建,summary()往往只能列出子层,看不到完整的形状流转,甚至有时直接报错。- 保存和加载变麻烦:Functional 模型可以只存结构不存权重,Subclassing 模型通常需要保存完整对象,跨版本加载容易出问题。
- 可视化困难:没有静态图,画不出结构图。
- 形状推断不友好:某些依赖静态形状的优化和检查会失效。
我的实际体会是:除非确实需要动态控制流,否则优先 Functional。Subclassing 更适合研究阶段快速验证一个非标准结构,等结构定型后,如果可能,再改写成 Functional 以便部署和维护。这个"先子类化验证、再函数式固化"的路径,在不少团队里是默认做法。
4.3 一个容易忽略的细节:call里的training参数
Subclassing 写call时,签名里最好带上training=None,并在用到 Dropout、BatchNorm 这类训练/推理行为不同的层时把它传下去。因为 Keras 在训练和推理时会通过这个参数控制层的行为,如果你在call里自己调用了这些层却没传training,它们可能一直按训练模式跑,导致推理结果异常。这个 bug 很隐蔽,因为训练时一切正常,只有推理或评估时才暴露。
5. 三种方式怎么选:一张决策路径加几条硬经验
讲完三种方式,回到最实际的问题:拿到一个新任务,怎么快速决定用哪种。我给一个自己常用的决策顺序,再补几条踩坑换来的硬经验。
5.1 决策顺序:从简到繁,够用就停
- 先问拓扑:模型是不是一条链?是,直接用 Sequential,别想太多。
- 有分叉/合并/多输入输出?是,上 Functional。绝大多数生产模型到这一步就够了。
- 前向逻辑依赖运行时数据、需要动态控制流?是,才考虑 Subclassing。
- 混合使用:Functional 里可以嵌 Subclassing 写的自定义层,Subclassing 里也可以用 Functional 构建的子模块。不必非此即彼。
第 4 点值得强调。实际项目里最常见的不是"纯 Functional"或"纯 Subclassing",而是用 Functional 搭主干,把复杂的自定义块用 Subclassing 封装成层。这样既保留了主图的可视化和可保存性,又在局部获得了灵活性。
5.2 几条用血换来的经验
经验一:形状对齐是 Functional 报错的头号来源。Add、Concatenate、Multiply这些合并层对输入形状有严格要求,Add要求完全一致,Concatenate要求除拼接轴外其他维度一致。写合并前先在纸上或注释里把各分支的输出形状标出来,能省掉大量试错。
经验二:给每个有意义的层和输出起名字。name='cls'、name='reg'这种命名,在多输出模型里直接决定你compile时字典的 key 怎么写,也决定保存后加载时能不能对上。默认名字(如dense_3)在模型一改结构就全乱,后期维护成本极高。
经验三:Sequential 转 Functional 的成本比想象中低。如果一开始用 Sequential,后来发现要加分叉,不用推倒重来。把 Sequential 里的层逐个拿出来,用函数式重新连一遍即可,层对象可以复用。我一般会保留原来的层定义,只改连接方式,这样权重初始化逻辑不变,对比实验更干净。
经验四:Subclassing 模型保存优先用SavedModel格式而非 H5。H5 对子类化模型的支持一直不太稳,尤其是带自定义层的时候。用model.save('path', save_format='tf')存成 SavedModel 目录,加载时用tf.keras.models.load_model并显式传入custom_objects,成功率最高。
经验五:多输出模型的评估指标要分头看。model.evaluate返回的是一个列表,顺序和你outputs列表一致。别只看第一个值就下结论,我见过有人把回归头的 MSE 当成分类准确率看了半天,纳闷为什么"准确率"是几百。
6. 把三种模型放进同一条训练链路里验证
光讲概念容易飘,我习惯用一个统一的小任务把三种写法都跑一遍,确认自己对它们的理解没有偏差。这个任务用同一个简单的表格数据,分别用 Sequential、Functional、Subclassing 实现结构等价的模型,对比参数量、训练曲线和保存加载行为。
6.1 结构等价的三份实现
# 方式一:Sequential seq_model = tf.keras.Sequential([ layers.Input(shape=(32,)), layers.Dense(64, activation='relu'), layers.Dense(1) ]) # 方式二:Functional inp = layers.Input(shape=(32,)) h = layers.Dense(64, activation='relu')(inp) out = layers.Dense(1)(h) func_model = Model(inp, out) # 方式三:Subclassing class SubModel(tf.keras.Model): def __init__(self): super().__init__() self.d1 = layers.Dense(64, activation='relu') self.d2 = layers.Dense(1) def call(self, x): return self.d2(self.d1(x)) sub_model = SubModel() sub_model.build((None, 32)) # 触发权重创建三份实现的参数量应该完全一致(32×64+64 加 64×1+1)。跑一遍count_params()对比,如果对不上,说明某处结构写错了。这个自检动作我强烈建议养成习惯,尤其是从论文复现结构时,参数量对不上往往意味着某层维度或连接方式搞错了。
6.2 保存加载行为的差异实测
这一步最能体现三种方式的区别。Sequential 和 Functional 可以直接model.save('m.keras')再load_model无损恢复;Subclassing 如果不调用build或没有跑过一次前向,直接保存可能因为权重未创建而失败。实测下来,Subclassing 模型在保存前务必先build或跑一个 dummy batch,否则加载回来的模型可能是空的。这个细节文档里提得不显眼,但实际踩到会浪费不少时间。
6.3 训练曲线对比的意义
结构等价的三份实现,在相同数据、相同优化器、相同随机种子下,训练曲线应该高度接近。如果某一份明显偏离,通常不是模型种类本身的问题,而是某处实现细节不一致——比如 Subclassing 里忘了传training导致 Dropout 行为不同,或者 Functional 里某层默认参数和 Sequential 不一致。用这种方式做交叉验证,能帮你把"模型种类"和"实现细节"这两类问题彻底分开。
7. 几个高频疑问的直给回答
最后整理几个我被问得最多、也是自己当初最困惑的问题,直接给结论。
Sequential 能不能加自定义层?能,只要这个层是单输入单输出的,model.add(MyLayer(...))就行。但如果自定义层内部有分叉,Sequential 的透明性就没了,建议换 Functional。
Functional 模型能不能像 Subclassing 一样写循环?不能。Functional 的图在构建时静态确定,循环必须在构建时展开(比如用 Python for 循环生成多个层再连起来),不能依赖运行时数据决定循环次数。需要运行时循环就上 Subclassing。
三种方式能不能互相转换?Sequential 和 Functional 之间转换很容易,层对象复用即可。Subclassing 转 Functional 需要把call里的逻辑手动改写成函数式连接,工作量取决于前向复杂度,动态控制流部分往往改不了,只能保留为自定义层。
生产部署选哪种?优先 Functional。它的静态图对导出、优化、跨平台推理最友好。Subclassing 模型在导出时经常需要额外处理,除非部署环境明确支持,否则不建议作为首选。
学习顺序建议?先把 Sequential 用熟,理解层、张量、编译训练这条主线;再啃 Functional,重点练分叉、合并、多输出;最后看 Subclassing,理解它解决的是哪类 Functional 解决不了的问题。这个顺序符合抽象层级递进,跳步容易夹生。
把这几块吃透之后,再回头看 TensorFlow 官方那些案例,你会发现它们无非是这三种范式的不同组合。真正决定你能不能复现一个复杂模型的,不是记住了多少 API,而是拿到结构图时能立刻判断"这该用哪种方式描述、哪里会形状不匹配、保存加载要注意什么"。这些判断力,只能靠把三种方式都亲手跑一遍、踩几个坑才能长出来。