2、Keras函数式API:Sequential模型回顾、函数式API入门、多输入多输出模型构建

2.1 Sequential模型回顾——简单场景的利器

我们先快速回顾一下Sequential模型。说白了,它就是一层叠一层的线性堆叠。你想想看,如果你的网络是一条直线,输入从一头进去,输出从另一头出来,中间没有分叉、没有合并,那Sequential就是最省事的选择。

我个人习惯在快速验证想法时先用Sequential。比如做一个简单的图像分类,几层卷积加全连接,代码写起来特别清爽:

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Conv2D, Flatten

model = Sequential([
    Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)),
    Flatten(),
    Dense(128, activation='relu'),
    Dense(10, activation='softmax')
])

嗯,这里要注意:Sequential模型要求每一层只有一个输入和一个输出。一旦你的网络结构出现分支,比如一个输入要同时送给两个不同的层,或者要把两个层的输出合并起来,Sequential就无能为力了。

小提示:我在项目中遇到过有人强行用Sequential实现残差连接,结果代码写得又臭又长。其实这时候就该换函数式API了。

2.2 函数式API入门——从线性到图结构

函数式API的核心思想是什么?就是把层当作函数来调用。你想想看,在Sequential里我们是把层像搭积木一样叠起来,而在函数式API里,我们是把层当作函数,输入张量经过层函数处理后得到输出张量。

这样做的好处很明显:你可以自由地定义张量的流向。一个张量可以流向多个层,多个张量可以合并,甚至可以形成环(比如循环神经网络)。

来看一个最简单的例子,用函数式API实现上面那个Sequential模型:

from tensorflow.keras.layers import Input, Dense, Conv2D, Flatten
from tensorflow.keras.models import Model

inputs = Input(shape=(28, 28, 1))
x = Conv2D(32, (3, 3), activation='relu')(inputs)
x = Flatten()(x)
x = Dense(128, activation='relu')(x)
outputs = Dense(10, activation='softmax')(x)

model = Model(inputs=inputs, outputs=outputs)

看到区别了吗?在函数式API中,我们显式地定义了输入张量 inputs,然后一层层传递下去,最后用 Model 类把输入和输出包装起来。这样做的好处是,你可以随时查看中间层的输出,也可以轻松地构建复杂的网络结构。

核心理解:函数式API把神经网络看作一个有向无环图(DAG)。每个层节点接收张量输入,产生张量输出。你可以自由地连接这些节点。

2.3 多输入多输出模型构建——实战中的刚需

为什么要多输入多输出?我在实际项目中遇到过这样一个场景:要预测一个商品的销量,输入数据包括商品图片、文字描述、历史价格走势。这三个输入的数据类型完全不同,图片用CNN处理,文字用RNN处理,价格用全连接层处理。最后要把三个分支的特征合并起来,输出两个结果:一个是销量预测值,一个是推荐指数。

这种场景用Sequential根本没法做,但函数式API处理起来就游刃有余。

来看一个具体的例子,假设我们要构建一个模型,输入两个向量,输出两个结果:

from tensorflow.keras.layers import Input, Dense, concatenate
from tensorflow.keras.models import Model

# 定义两个输入
input_a = Input(shape=(64,), name='input_a')
input_b = Input(shape=(128,), name='input_b')

# 第一个分支
x_a = Dense(32, activation='relu')(input_a)
x_a = Dense(16, activation='relu')(x_a)

# 第二个分支
x_b = Dense(64, activation='relu')(input_b)
x_b = Dense(32, activation='relu')(x_b)

# 合并两个分支
combined = concatenate([x_a, x_b])

# 输出1:分类结果
output_1 = Dense(10, activation='softmax', name='output_1')(combined)

# 输出2:回归结果
output_2 = Dense(1, activation='linear', name='output_2')(combined)

# 构建模型
model = Model(inputs=[input_a, input_b], outputs=[output_1, output_2])

# 编译时可以为每个输出指定不同的损失函数和权重
model.compile(
    optimizer='adam',
    loss={
        'output_1': 'sparse_categorical_crossentropy',
        'output_2': 'mse'
    },
    loss_weights={
        'output_1': 0.7,
        'output_2': 0.3
    },
    metrics={
        'output_1': 'accuracy',
        'output_2': 'mae'
    }
)

这里有几个关键点我要强调一下:

  • 输入命名:给每个Input层和输出层起个名字,这样在训练和预测时可以通过名字来传递数据,代码可读性会好很多。
  • 合并方式:常用的合并操作有 concatenate(拼接)、add(相加)、multiply(相乘)。选择哪种取决于你的业务逻辑。
  • 多损失函数:每个输出可以有自己的损失函数和权重。比如分类任务用交叉熵,回归任务用均方误差,通过 loss_weights 来平衡它们的重要性。
避坑指南:我曾经在训练多输出模型时,忘记给每个输出指定损失函数权重,结果模型只顾着优化数值大的那个损失,另一个输出完全没学到东西。所以一定要根据实际业务需求来设置 loss_weights

2.4 训练多输入多输出模型

训练的时候,数据也要对应地组织成字典或列表形式:

# 假设我们有训练数据
import numpy as np

# 生成模拟数据
x_a_train = np.random.randn(1000, 64)
x_b_train = np.random.randn(1000, 128)
y_1_train = np.random.randint(0, 10, size=(1000,))
y_2_train = np.random.randn(1000, 1)

# 方式一:用字典传递
history = model.fit(
    x={'input_a': x_a_train, 'input_b': x_b_train},
    y={'output_1': y_1_train, 'output_2': y_2_train},
    epochs=10,
    batch_size=32,
    validation_split=0.2
)

# 方式二:用列表传递(注意顺序要和定义时一致)
# history = model.fit(
#     [x_a_train, x_b_train],
#     [y_1_train, y_2_train],
#     epochs=10,
#     batch_size=32
# )

我个人更推荐用字典方式,因为顺序搞错了模型也不会报错,但结果会完全不对。用字典的话,名字对上了就万无一失。

2.5 函数式API的更多玩法

除了多输入多输出,函数式API还能做很多事情:

  • 共享层:同一个层可以被多个输入共享。比如在孪生网络中,两个输入共享同一个特征提取层。
  • 残差连接:可以把某一层的输出跳过几层直接加到后面的层上。
  • 条件分支:虽然Keras本身不支持动态条件,但你可以通过自定义层来实现类似的效果。

来看一个共享层的例子:

# 定义一个共享的Dense层
shared_dense = Dense(64, activation='relu')

input_1 = Input(shape=(32,))
input_2 = Input(shape=(32,))

# 同一个层处理两个不同的输入
output_1 = shared_dense(input_1)
output_2 = shared_dense(input_2)

# 合并
merged = concatenate([output_1, output_2])
final_output = Dense(1, activation='sigmoid')(merged)

model = Model(inputs=[input_1, input_2], outputs=final_output)

你想想看,这种共享层的设计在自然语言处理中特别常见。比如处理两个句子时,用同一个词嵌入层和LSTM层来提取特征,这样能保证两个句子被同等对待。

经验之谈:函数式API的学习曲线确实比Sequential陡一些,但一旦掌握了,你会发现它几乎能应对任何网络结构。我建议你从简单的多输入模型开始练手,慢慢过渡到复杂结构。

好了,这一章的内容就到这里。函数式API是Keras中最灵活的部分,也是构建复杂模型的基础。下一章我们会深入自定义层,到时候你会看到函数式API和自定义层结合起来能发挥多大的威力。