3.2 三种建模方式:Sequential、函数式与子类化 本节摘要:Keras 提供三种建模方式:Sequential 适合直线堆叠,函数式适合分支汇合的有向图,子类化适合需要 Python 全权控制前向逻辑的场景。三者产出的模型在训练与保存上能力一致,差别在"前向结构能表达什么"与"调试负担"上。本节用加州房价与一个双分支结构分别演示三种写法,并给出选型决策依据与子类化的三个坑。 本节能力清单 阅读完本节,你应当能够: 用三种方式分别实现同一个回归模型,确认训练行为一致; 用函数式 API 搭建双分支输入再汇合的结构; 写出规范的子类化模型,避开变量创建时机与 build 的坑; 按"结构形态、调试需求、保存方式"三维做建模选型。
本节摘要:Keras 提供三种建模方式:Sequential 适合直线堆叠,函数式适合分支汇合的有向图,子类化适合需要 Python 全权控制前向逻辑的场景。三者产出的模型在训练与保存上能力一致,差别在"前向结构能表达什么"与"调试负担"上。本节用加州房价与一个双分支结构分别演示三种写法,并给出选型决策依据与子类化的三个坑。
阅读完本节,你应当能够:
Sequential 把层按顺序串成一条直线,数据从第 0 层流到最后一层。加州房价回归的最小实现:
import tensorflow as tf model = tf.keras.Sequential([ tf.keras.layers.Input(shape=(8,)), tf.keras.layers.Dense(64, activation="relu", name="hidden"), tf.keras.layers.Dense(1, name="output"), ]) model.summary() # 输出(摘要): # Model: "sequential" # Layer (type) Output Shape Param # # hidden (Dense) (None, 64) 576 # output (Dense) (None, 1) 65 # 参数量手算:8 乘 64 加 64 = 576;64 乘 1 加 1 = 65
summary 的 Param 列是检验理解的抓手:Dense 的参数就是权重矩阵加偏置,None 占位代表批次维度待定。Sequential 的边界同样明确:它不能表达分叉——任何"一个张量喂给两条路再汇合"的结构都会卡住。
函数式把层当作可调用的变换,张量在层之间像变量一样传递,天然表达有向无环图。加州房价的双分支变体:数值特征走深支,简单特征走浅支,再拼接汇合:
inputs = tf.keras.Input(shape=(8,), name="features") # 分支一:深处理 deep = tf.keras.layers.Dense(64, activation="relu")(inputs) deep = tf.keras.layers.Dense(32, activation="relu")(deep) # 分支二:浅处理(直连) shallow = tf.keras.layers.Dense(8, activation="relu")(inputs) # 汇合与输出 merged = tf.keras.layers.Concatenate()([deep, shallow]) outputs = tf.keras.layers.Dense(1, name="prediction")(merged) func_model = tf.keras.Model(inputs=inputs, outputs=outputs, name="dual_branch") func_model.summary() # 输出(摘要行): # dual_branch (Functional) 输出 (None, 1) # 总参数量:576 + 32 乘 41 加 72 加 9 + 41 = 深支 2144、浅支 72、汇合层 41
函数式的核心优势是多输入多输出:Input 可声明多个,Model 的 outputs 可列多个,fit 时喂字典即可。结构越接近"数据流图"的模型,函数式写得越自然——注意力结构、残差连接都是它的主场(第 4 章大量使用)。
子类化直接继承 Model,在 init 里声明层、在 call 里写前向逻辑。call 是普通 Python 函数,if、循环、中间打印都随便写:
class ResidualBlock(tf.keras.Model): def __init__(self, units): super().__init__() self.dense1 = tf.keras.layers.Dense(units, activation="relu") self.dense2 = tf.keras.layers.Dense(units) self.proj = tf.keras.layers.Dense(units) # 维度对齐用投影 def call(self, x, training=False): h = self.dense1(x) h = self.dense2(h) return tf.nn.relu(h + self.proj(x)) # 残差相加,Python 随意写 class HouseNet(tf.keras.Model): def __init__(self): super().__init__() self.block1 = ResidualBlock(64) self.out = tf.keras.layers.Dense(1) def call(self, x, training=False): return self.out(self.block1(x)) sub_model = HouseNet() _ = sub_model(tf.zeros([2, 8])) # 关键一步:先跑一次让变量被创建 sub_model.summary() # 输出: # HouseNet (Functional 之外的子类化结构),可训练参数 4817 # 不先调用一次就 summary 或 save,会因变量尚未创建而报错
子类化的灵活性有代价:模型结构只有在第一次 call 时才被登记,所以必须先喂一个样例张量(或 build)让变量诞生,才能 summary、save 或 fit。这源于 1.6 节的规矩——变量只能在真实执行中创建,子类化把这层封装暴露给了你。
| 维度 | Sequential | 函数式 | 子类化 |
|---|---|---|---|
| 结构形态 | 直线堆叠 | 任意有向无环图 | 任意,含动态逻辑 |
| 多输入输出 | 不支持 | 直接支持 | call 签名自定 |
| 可视化与检查 | 最容易 | 容易 | 需先 build |
| 保存通用性 | 好 | 好 | SavedModel 路线 |
| 适用场景 | 表格数据基线、CNN 主干 | 分支、残差、注意力 | 研究、动态结构 |
决策口诀:直线用 Sequential,有分叉用函数式,前向里必须跑 Python 逻辑才用子类化。三条纪律保障三者可互换:第一,给层与模型起 name,日志与存档都受益;第二,激活函数写在层参数里而不是 call 里手工调,存档时才不丢;第三,子类化模型的 forward 逻辑不要依赖 call 之外的可变 Python 状态,否则图模式下行为(1.5 节的副作用陷阱)不可预期。
# 三者训练行为一致性验证 func_model.compile(optimizer="adam", loss="mse", run_eagerly=True) sub_model.compile(optimizer="adam", loss="mse", run_eagerly=True) import numpy as np Xs = np.random.rand(128, 8).astype("float32") ys = np.random.rand(128).astype("float32") h1 = func_model.fit(Xs, ys, epochs=2, verbose=0).history["loss"] h2 = sub_model.fit(Xs, ys, epochs=2, verbose=0).history["loss"] print([round(v, 2) for v in h1]) print([round(v, 2) for v in h2]) # 两行输出都在 1.0 附近逐轮下降(随机数据,数值不定) # 三种建模方式对 fit 完全透明——装配方式不影响训练调度
⚠️ 常见坑:子类化模型在 fit 里报"变量未创建",根因是第一层写在 call 里且从未被调用。规范是所有层在 init 里实例化,call 只调用不创建。
💡 关键直觉:三种方式是同一张前向图的三种"登记笔迹"。Sequential 是填表,函数式是画流程图,子类化是自由作文——作文写得越自由,机器越难替你检查,这就是调试负担的来源。
下节进入全章枢纽:compile 与 fit 的内部调度。