Keras 3 架构解析(四):训练系统——从损失函数到梯度优化与训练循环
Keras 3 架构解析(四):训练系统——从损失函数到梯度优化与训练循环
引言
在前三篇文章中,我们分别介绍了 Keras 3 的基础计算层、神经网络层与模型构建层。
基础计算层通过 Keras Ops 与多后端架构,为模型提供统一的张量运算能力。神经网络层通过 Layer 抽象,将数学计算、可训练参数与内部状态组织为可复用的计算组件。模型构建层则通过 Model 抽象,将多个网络层组合成完整的神经网络,并提供统一的模型管理接口。
然而,完成模型构建并不意味着模型已经具备解决实际问题的能力。
对于绝大多数神经网络而言,模型在初始化时只拥有一组尚未经过训练的参数。这些参数虽然能够参与前向计算,但通常无法产生具有实际预测意义的输出。
要使模型从数据中学习,就必须建立一套能够衡量预测误差、计算参数梯度并迭代更新权重的机制。
这一机制由 Keras 的训练系统(Training System)提供。
Keras 3 的训练系统主要由以下部分组成:
- 损失函数(Loss):衡量模型预测与训练目标之间的差异。
- 优化器(Optimizer):根据梯度计算参数更新。
- 评价指标(Metric):衡量模型在特定任务上的表现。
- 训练循环(Training Loop):组织数据读取、前向计算、反向传播和参数更新。
- 训练控制接口:通过
compile()、fit()、evaluate()和predict()统一管理训练、评估与推理过程。
与此前讨论的计算层类似,Keras 的训练系统并不重新实现所有底层计算机制。
自动微分、执行图编译与硬件计算仍主要由 JAX、TensorFlow 或 PyTorch 后端负责。Keras 则通过统一的训练抽象,将这些机制组织为相对一致的开发接口。
本文将从数学原理和软件架构两个角度出发,系统分析 Keras 3 的训练系统,并讨论其内置训练循环、自定义训练步骤与跨后端执行机制。
一、神经网络训练的基本过程
1.1 训练的数学目标
在监督学习中,假设给定训练数据集:
[
D={(x_i,y_i)}_{i=1}^{N}
]
其中,(x_i) 表示输入特征,(y_i) 表示对应的目标值。
神经网络是一个由参数 (\theta) 决定的函数:
[
\hat{y}i=f\theta(x_i)
]
训练的目标是找到一组参数,使模型预测结果尽可能符合实际目标。
为此,需要定义损失函数:
[
\ell(y_i,\hat{y}_i)
]
对于整个训练集,一个典型的经验风险最小化目标可以表示为:
[
\mathcal{L}(\theta)
\frac{1}{N}
\sum_{i=1}^{N}
\ell(y_i,f_\theta(x_i))
]
如果引入正则化,优化目标可以进一步写为:
[
\mathcal{J}(\theta)
\mathcal{L}(\theta)
+
\lambda R(\theta)
]
其中,(R(\theta)) 是正则化项,(\lambda) 控制其贡献。
神经网络训练的本质,就是通过优化算法不断调整 (\theta),使目标函数 (\mathcal{J}(\theta)) 逐渐减小。
需要指出,这里的目标函数通常只是训练目标,并不一定与模型最终业务目标完全一致。例如,分类模型可能优化交叉熵,但最终关注准确率、召回率或其他决策指标。
1.2 梯度与参数更新
对于可微的损失函数,可以通过自动微分计算梯度:
[
g_t=\nabla_\theta \mathcal{J}(\theta_t)
]
最简单的梯度下降更新规则为:
[
\theta_{t+1}
\theta_t-\eta g_t
]
其中:
- (\theta_t):第 (t) 次更新前的参数;
- (g_t):损失函数对参数的梯度;
- (\eta):学习率。
实际深度学习训练通常不会在每次更新时计算整个训练集,而是使用一个小批次(Mini-batch)估计梯度。
设当前批次为 (B_t),其包含 (m) 个样本,则:
[
\mathcal{L}_{B_t}(\theta)
\frac{1}{m}
\sum_{i\in B_t}
\ell(y_i,f_\theta(x_i))
]
优化器利用该批次计算出的梯度更新模型参数。
这种方式能够降低单次更新的计算开销,并允许模型在数据集上执行大量迭代。
1.3 一个训练步骤的组成
典型的训练步骤可以分解为以下过程:
输入一个 Batch
│
▼
模型前向计算
│
▼
生成预测值
│
▼
计算 Loss
│
▼
自动微分
│
▼
获得梯度
│
▼
Optimizer 更新
│
▼
更新评价指标
│
▼
返回训练日志
这一流程中,各组件承担不同的职责。
Model 负责生成预测值。
Loss 负责确定优化目标。
计算后端负责计算梯度。
Optimizer 负责参数更新。
Metric 负责统计模型表现。
Training Loop 则负责按照既定顺序调用这些组件。
Keras 训练系统的核心价值,就是将这一重复执行的计算流程封装起来,使开发者不必为每个常规模型重复编写完整训练循环。
二、compile():训练系统的配置入口
2.1 compile() 的基本作用
Keras Model 在完成构建后,可以通过 compile() 配置训练所需要的组件。
例如:
import keras
model.compile(
optimizer=keras.optimizers.Adam(
learning_rate=1e-3
),
loss=keras.losses.MeanSquaredError(),
metrics=[
keras.metrics.MeanAbsoluteError()
]
)
这段代码分别配置了:
- Adam 优化器;
- 均方误差损失函数;
- 平均绝对误差评价指标。
在调用 compile() 之后,模型便获得了执行相应内置训练流程所需要的配置。
但需要特别强调:
compile() 的主要职责是配置训练行为,而不是立即完成全部底层计算图编译。
虽然该接口也提供 jit_compile 等与编译有关的参数,但其核心意义是建立模型与损失函数、优化器及评价指标之间的关系。
具体的执行图追踪和 JIT 编译,可能在首次训练步骤等阶段才实际发生。
2.2 compile() 的主要参数
Keras 3 提供了以下主要配置项。
| 参数 | 主要职责 |
|---|---|
optimizer |
指定优化算法 |
loss |
指定训练目标 |
loss_weights |
配置多输出任务的损失权重 |
metrics |
指定普通评价指标 |
weighted_metrics |
指定应用样本权重的评价指标 |
run_eagerly |
控制是否使用即时执行方式 |
steps_per_execution |
控制单次编译执行包含的训练批次数 |
jit_compile |
控制是否启用后端编译优化 |
auto_scale_loss |
控制特定混合精度模式下的自动损失缩放 |
其中,最重要的是 optimizer、loss 和 metrics。
对于常规监督学习模型,通常只需要配置这三个部分。
例如,下面是一个二分类模型的训练配置:
model.compile(
optimizer=keras.optimizers.Adam(
learning_rate=0.001
),
loss=keras.losses.BinaryCrossentropy(
from_logits=True
),
metrics=[
keras.metrics.BinaryAccuracy(
threshold=0.0
),
keras.metrics.AUC(
from_logits=True
)
]
)
这里假设模型最后一层直接输出 Logits,而没有使用 Sigmoid 激活函数。
因此,交叉熵通过 from_logits=True 处理原始输出,BinaryAccuracy 则使用与 Logits 对应的阈值 0。
如果模型最后一层已经使用 Sigmoid 输出概率,那么损失函数应相应设置为 from_logits=False,BinaryAccuracy 通常使用概率阈值 0.5。
损失函数和指标的配置必须与模型输出语义保持一致,否则即使代码能够正常执行,也可能得到错误的训练目标或评价结果。
2.3 训练配置与模型结构的分离
Keras 将模型构建与训练配置分开处理。
例如,同一个回归模型可以先使用 Adam:
model.compile(
optimizer="adam",
loss="mse"
)
随后重新配置为 SGD:
model.compile(
optimizer=keras.optimizers.SGD(
learning_rate=0.01
),
loss="mse"
)
这种调整不会自动重新创建模型层,也不会自动将已有模型权重恢复为初始化状态。
因此,可以在保留模型当前参数的情况下更换训练配置。
但更换优化器时,原优化器的动量、二阶矩等内部状态并不会自动迁移到新优化器。
此外,Keras 会在 compile() 时确定参与训练的变量集合。
如果在编译后修改某个层的 trainable 状态,应当重新调用 compile(),以确保新的训练配置与模型参数状态一致。
这一点在迁移学习和分阶段训练中尤为重要。
三、Loss:损失函数系统
3.1 Loss 的职责
损失函数用于将模型预测结果与训练目标之间的差异转换为可优化的数值。
通常,一个损失函数接收两个输入:
[
\ell=\operatorname{Loss}(y_{\text{true}},y_{\text{pred}})
]
Keras 内置损失函数主要通过 keras.losses 提供。
需要区分的是,单个样本的损失、整个批次的损失以及用于反向传播的最终标量损失,并不一定是同一个对象。
Keras 的 Loss 类通常负责计算每个样本的损失,并根据其 reduction 配置执行相应的聚合。
此外,正则化损失及自定义层添加的附加损失,也可能参与最终优化目标。
3.2 回归损失
回归任务通常要求模型预测连续数值。
常见损失函数包括 MeanSquaredError、MeanAbsoluteError 和 Huber。
均方误差(Mean Squared Error,MSE)
[
\operatorname{MSE}
\frac{1}{N}
\sum_{i=1}^{N}
(y_i-\hat{y}_i)^2
]
MSE 对较大的误差施加平方惩罚,因此对异常值比较敏感。
Keras 实现:
loss = keras.losses.MeanSquaredError()
平均绝对误差(Mean Absolute Error,MAE)
[
\operatorname{MAE}
\frac{1}{N}
\sum_{i=1}^{N}
|y_i-\hat{y}_i|
]
MAE 对较大误差的惩罚呈线性增长,因此相较 MSE,通常对异常值更稳健。
loss = keras.losses.MeanAbsoluteError()
Huber 损失
Huber 损失结合了平方误差与绝对误差的部分特点。
[
L_\delta(e)=
\begin{cases}
\frac{1}{2}e^2,
& |e|\leq\delta\
\delta\left(|e|-\frac{1}{2}\delta\right),
& |e|>\delta
\end{cases}
]
其中:
[
e=y-\hat{y}
]
Keras 实现:
loss = keras.losses.Huber(
delta=1.0
)
对于不同回归任务,损失函数的选择可能直接影响模型最终学习到的预测行为。
例如,使用 MSE 训练的模型通常更接近条件均值意义下的预测,而使用 MAE 训练时,最优预测更接近条件中位数。
因此,损失函数并非单纯的训练技术参数,它还隐含了模型希望优化的统计目标。
3.3 分类损失
分类任务通常使用交叉熵损失。
二元交叉熵(Binary Crossentropy)
对于二分类问题:
[
L=
-\frac{1}{N}
\sum_{i=1}^{N}
\left[
y_i\log p_i+
(1-y_i)\log(1-p_i)
\right]
]
其中,(p_i) 表示模型预测为正类的概率。
Keras 实现:
loss = keras.losses.BinaryCrossentropy(
from_logits=False
)
当模型直接输出未经 Sigmoid 变换的 Logits 时,可以设置:
loss = keras.losses.BinaryCrossentropy(
from_logits=True
)
这种配置可以让框架使用适当的数值计算方式处理 Logits,通常比先手动计算概率再取对数更有利于数值稳定性。
多分类交叉熵
Keras 提供:
keras.losses.CategoricalCrossentropy()
以及:
keras.losses.SparseCategoricalCrossentropy()
两者的主要区别在于标签表示方式。
CategoricalCrossentropy 通常接收 One-Hot 编码或其他符合要求的类别分布标签。
SparseCategoricalCrossentropy 则接收整数类别编号。
例如,假设一个样本属于三个类别中的第二类:
整数类别标签:1
One-Hot 标签:[0, 1, 0]
对于整数标签,应优先考虑 SparseCategoricalCrossentropy,而不是为了使用 CategoricalCrossentropy 强制转换所有标签。
3.4 自定义损失函数
Keras 支持使用普通函数定义自定义损失。
例如,定义一种对正向预测误差施加更高惩罚的非对称均方误差:
from keras import ops
def asymmetric_mse(y_true, y_pred):
error = y_pred - y_true
weights = ops.where(
error > 0,
2.0,
1.0
)
weighted_error = (
weights * ops.square(error)
)
return ops.mean(
weighted_error,
axis=-1
)
然后传递给 compile():
model.compile(
optimizer="adam",
loss=asymmetric_mse
)
这里,预测值高于目标值的误差会受到更高惩罚。
由于使用了 keras.ops,该函数没有直接依赖特定计算后端。
需要注意,自定义损失函数的返回形状和聚合方式会影响训练目标。对于多维输出,通常应明确哪些维度属于样本内部的特征维度,哪些维度应保留给后续批次级归约。
如果需要配置化的损失函数,可以继承 keras.losses.Loss 并实现相应方法。
3.5 正则化损失
模型的最终训练目标可能不止包含任务损失。
例如,Dense 层可以配置 L2 正则化:
from keras import regularizers
layer = keras.layers.Dense(
64,
kernel_regularizer=regularizers.L2(
1e-4
)
)
此时,优化目标可以概括为:
[
L_{\text{total}}
L_{\text{task}}
+
L_{\text{regularization}}
]
其中,正则化损失通过模型或 Layer 的相关机制纳入训练目标。
因此,训练日志中显示的总 Loss 不一定与单独计算任务损失函数得到的数值完全相同。
四、Optimizer:参数优化系统
4.1 优化器的职责
Loss 定义了模型要优化什么,而 Optimizer 决定参数如何更新。
在标准训练过程中,优化器通常接收模型参数及相应梯度,并依据内部算法计算新的参数值。
Keras 通过 keras.optimizers 提供统一的优化器接口。
常见优化器包括:
| 优化器 | 主要机制 |
|---|---|
| SGD | 基于梯度更新参数,可加入动量 |
| RMSprop | 使用梯度平方的移动统计调整步长 |
| Adam | 同时利用梯度一阶矩与二阶矩估计 |
| AdamW | Adam 与解耦权重衰减 |
| Adagrad | 根据历史梯度信息调整参数学习率 |
| Adadelta | 使用自适应更新机制 |
不同优化器不仅影响参数更新方向,也影响内部状态的存储及训练过程中的数值行为。
4.2 SGD 与动量机制
普通 SGD 的参数更新为:
[
\theta_{t+1}
\theta_t-\eta g_t
]
如果加入动量,更新过程可以写为:
[
v_t=\mu v_{t-1}+g_t
]
[
\theta_{t+1}=
\theta_t-\eta v_t
]
其中,(\mu) 是动量系数。
Keras 实现:
optimizer = keras.optimizers.SGD(
learning_rate=0.01,
momentum=0.9
)
动量机制通过利用历史梯度信息,使参数更新不再仅依赖当前批次的梯度。
这可能改善部分优化问题中的震荡现象,并提高特定条件下的收敛效率。
4.3 Adam 与自适应学习率
Adam 在 SGD 动量的基础上,进一步维护梯度平方的移动统计。
其典型更新过程为:
[
m_t=
\beta_1m_{t-1}
+(1-\beta_1)g_t
]
[
v_t=
\beta_2v_{t-1}
+(1-\beta_2)g_t^2
]
对偏差进行修正后:
[
\hat{m}_t=
\frac{m_t}{1-\beta_1^t}
]
[
\hat{v}_t=
\frac{v_t}{1-\beta_2^t}
]
最终更新:
[
\theta_{t+1}
\theta_t-
\eta
\frac{\hat{m}_t}
{\sqrt{\hat{v}_t}+\epsilon}
]
Keras 实现:
optimizer = keras.optimizers.Adam(
learning_rate=1e-3,
beta_1=0.9,
beta_2=0.999,
epsilon=1e-7
)
Adam 的核心特点,是不同参数能够根据各自的历史梯度统计获得不同的有效更新尺度。
但这不意味着 Adam 在所有任务中都优于 SGD。
最终效果仍取决于任务结构、数据分布、学习率配置和其他训练条件。
4.4 AdamW 与权重衰减
AdamW 在 Adam 的基础上引入解耦权重衰减。
其重要区别在于,权重衰减与梯度更新被分开处理,而不是简单地将 L2 惩罚直接加入梯度后交给 Adam。
Keras 实现:
optimizer = keras.optimizers.AdamW(
learning_rate=1e-3,
weight_decay=1e-4
)
这种设计在现代深度学习模型中较为常见。
但需要注意,权重衰减是否应作用于所有参数,需要根据模型结构考虑。偏置、归一化层参数等变量在某些训练方案中可能采用不同的衰减策略。
因此,使用 AdamW 并不自动意味着已经采用了最合适的参数分组策略。
4.5 优化器状态
Optimizer 不一定是一个纯粹的数学更新函数。
以 Adam 为例,它需要保存:
- 一阶矩估计;
- 二阶矩估计;
- 更新步数;
- 其他配置相关状态。
这些状态与模型参数共同决定后续训练行为。
因此,模型权重与优化器状态是两类不同的状态。
如果只恢复模型权重而不恢复优化器状态,即使使用相同的优化器配置,后续训练轨迹也不一定与中断前完全一致。
这一点对于长时间训练、断点恢复和可复现实验具有重要意义。
4.6 梯度裁剪与梯度累积
Keras 优化器还提供部分常见的训练控制功能。
例如:
optimizer = keras.optimizers.AdamW(
learning_rate=1e-3,
global_clipnorm=1.0,
gradient_accumulation_steps=4
)
global_clipnorm 用于对整体梯度范数进行裁剪。
如果梯度向量 (g) 的范数超过阈值 (c),一种典型的裁剪方式为:
[
g’=
g\cdot
\min\left(
1,\frac{c}{|g|}
\right)
]
梯度裁剪可以限制异常大的更新幅度,但并不能从根本上消除所有训练不稳定问题。
gradient_accumulation_steps=4 则表示累积多个批次的梯度,再执行一次优化器参数更新。
假设每个批次包含 32 个样本,连续累积 4 次梯度,则在等权且归约方式一致等条件下,其效果可以近似对应使用 128 个样本计算一次更新。
但梯度累积并不保证与直接使用同等大小的大 Batch 完全等价。
例如,Batch Normalization 的批次统计、随机操作以及部分优化器调度逻辑都可能造成差异。
此外,梯度累积会改变参数更新频率,因此需要明确区分数据处理步数与优化器实际更新次数。
五、Metrics:评价指标系统
5.1 Loss 与 Metric 的区别
Loss 和 Metric 都可以用于衡量预测结果,但二者在训练系统中的角色不同。
Loss 是训练时需要最小化的目标。
Metric 则用于观察模型在某个评价标准下的表现。
例如:
model.compile(
optimizer="adam",
loss="mse",
metrics=["mae"]
)
这里模型优化的是 MSE,而不是 MAE。
MAE 的变化会被记录,但不会仅因为它被配置为 Metric,就自动参与梯度计算。
当然,一个数学指标也可以同时作为 Loss 和 Metric。区别不在于其计算公式,而在于它是否被纳入优化目标。
5.2 常见评价指标
对于回归任务,常见指标包括:
- MAE;
- MSE;
- RMSE;
- R² 等。
对于分类任务,常见指标包括:
- Accuracy;
- Precision;
- Recall;
- AUC;
- Top-K Accuracy 等。
例如:
model.compile(
optimizer="adam",
loss=keras.losses.BinaryCrossentropy(
from_logits=False
),
metrics=[
keras.metrics.BinaryAccuracy(),
keras.metrics.Precision(),
keras.metrics.Recall(),
keras.metrics.AUC()
]
)
这里假设模型输出的是概率。
需要注意,指标的适用性取决于任务本身。
例如,对于类别高度不平衡的分类任务,Accuracy 可能无法充分描述模型表现。
同样,AUC、Precision 和 Recall 描述的统计对象不同,不能仅因为某个指标数值更高,就断定模型在所有意义上都更好。
5.3 Metric 的状态管理
与普通 Loss 函数不同,Keras 的 Metric 通常具有内部状态。
它们需要在多个 Batch 之间累积统计量。
例如:
metric = keras.metrics.Mean()
metric.update_state([1.0, 2.0, 3.0])
metric.update_state([4.0, 5.0])
print(metric.result())
最终结果为:
[
\frac{1+2+3+4+5}{5}=3
]
这里,Metric 并不是简单地计算最后一个 Batch 的平均值。
它会通过内部状态记录累计数值和相应计数。
主要接口包括:
| 方法 | 职责 |
|---|---|
update_state() |
更新当前统计状态 |
result() |
返回当前累计结果 |
reset_state() |
清空内部统计状态 |
例如:
metric.reset_state()
该操作将指标恢复到初始状态。
在内置训练循环中,Keras 会在适当阶段重置相关 Metric 状态,使每个 Epoch 的指标能够独立统计。
5.4 为什么不能直接平均每个 Batch 的指标
并非所有指标都能够通过简单平均各批次结果得到全局结果。
例如,假设两个 Batch 的样本数量分别为 100 和 10。
如果只对两个 Batch 的准确率求普通算术平均,就会忽略它们样本数量不同的问题。
更复杂的指标还存在其他问题。
例如,整个数据集的 F1 分数,通常不等于各个 Batch 的 F1 分数的平均值。
因此,正确的 Metric 实现往往需要累计足够的统计量,再计算最终结果。
Keras 的 Metric 抽象正是为这种跨批次状态管理提供统一机制。
5.5 样本权重与指标权重
Keras 支持在训练时对不同样本施加不同权重。
例如:
model.fit(
x_train,
y_train,
sample_weight=weights
)
此时,样本权重会影响相应的训练损失计算。
但需要注意,通过 metrics 配置的普通评价指标默认不会因为训练使用了 sample_weight 就自动采用相同的权重。
如果需要计算加权评价指标,可以使用 weighted_metrics:
model.compile(
optimizer="adam",
loss="mse",
metrics=[
keras.metrics.MeanAbsoluteError(
name="mae"
)
],
weighted_metrics=[
keras.metrics.MeanAbsoluteError(
name="weighted_mae"
)
]
)
这样便可以同时观察普通 MAE 与考虑样本权重后的 MAE。
对于不平衡分类、不同样本重要性以及带有有效性掩码的训练任务,这一区分非常重要。
六、fit():内置训练循环
6.1 fit() 的职责
在完成 compile() 后,Keras 通过 fit() 执行模型训练。
例如:
history = model.fit(
x_train,
y_train,
batch_size=64,
epochs=20,
validation_data=(x_val, y_val)
)
从开发者视角看,只需要指定训练数据和主要训练参数。
但 Keras 内部需要完成一系列操作:
- 接收训练数据。
- 按照数据适配规则生成 Batch。
- 对每个 Batch 执行前向计算。
- 根据 Loss 计算训练目标。
- 使用后端自动微分计算梯度。
- 调用 Optimizer 更新模型参数。
- 更新 Metric 状态。
- 在需要时执行验证。
- 记录训练历史并触发相应回调。
这就是 Keras 内置训练循环的基本职责。
6.2 Epoch、Batch 与 Step
理解 fit(),首先需要区分三个概念。
Epoch
在常见的有限数据集训练中,一个 Epoch 表示完成一轮训练数据遍历。
Batch
Batch 是一次交给训练步骤处理的一组样本。
Step
在常规 fit() 中,一个 Step 通常对应处理一个 Batch。
例如,假设训练集包含 10,000 个样本,Batch Size 为 100。
如果不丢弃最后一个不完整批次:
[
\text{Steps per Epoch}
\left\lceil
\frac{10000}{100}
\right\rceil
=100
]
如果训练 20 个 Epoch,那么总共处理:
[
100\times20=2000
]
个训练批次。
在不使用梯度累积等特殊机制的情况下,通常也会执行 2,000 次参数更新。
但是,如果启用了梯度累积、显式设置 steps_per_epoch,或使用特殊的数据迭代机制,那么 Step、Epoch 与实际参数更新次数之间的关系可能发生变化。
因此,在分析训练日志或优化训练性能时,应当明确所讨论的是数据处理批次、优化器更新次数,还是编译函数的执行次数。
6.3 数据输入与批处理
Keras 的 fit() 不只支持 NumPy 数组。
它还能够接收多种数据输入形式,包括:
- NumPy 数组与后端原生张量;
tf.data.Dataset;- PyTorch DataLoader;
- Keras PyDataset;
- 符合接口要求的 Python 数据生成器。
对于 NumPy 数组,可以通过 batch_size 控制批次大小。
但如果输入本身已经是产生 Batch 的数据迭代器,通常不应该再向 fit() 指定 batch_size。
此时,批次划分应由数据加载系统负责。
这体现了训练循环与数据管道之间的职责分离。
训练循环负责消费 Batch,而数据系统负责以适当形式提供 Batch。
数据处理系统将在下一篇文章中进一步讨论。
6.4 训练集与验证集
模型训练通常需要同时观察训练集和验证集上的表现。
Keras 支持直接传入验证数据:
history = model.fit(
x_train,
y_train,
validation_data=(x_val, y_val),
epochs=20
)
在默认配置下,每个 Epoch 结束后,Keras 会在验证数据上计算相应的损失和评价指标。
验证阶段不会执行常规梯度更新。
对于 Dropout 等在训练与推理阶段行为不同的层,验证通常使用推理模式。
Keras 还支持:
validation_split=0.2
但必须注意,validation_split 会在训练数据被打乱之前,从传入数据的末尾划分指定比例作为验证集。
因此,它并不等价于随机分层抽样,也不会自动理解时间序列中的先后关系。
对于时间序列任务,应当根据预测时点、数据泄漏风险和实际验证目标显式划分训练集与验证集,而不应无条件依赖自动切分。
6.5 History:训练结果的记录
fit() 返回一个 History 对象。
例如:
history = model.fit(
x_train,
y_train,
epochs=10,
validation_data=(x_val, y_val)
)
可以通过:
history.history
访问训练历史。
通常包含如下字段:
loss
mae
val_loss
val_mae
其中:
loss:训练阶段的损失;mae:训练阶段的 MAE;val_loss:验证阶段的损失;val_mae:验证阶段的 MAE。
需要注意,训练与验证的 Loss 可能受到不同执行模式以及统计时点的影响。
例如,训练阶段的指标通常在参数不断更新的过程中累计,而验证阶段通常使用当前 Epoch 结束时的参数进行计算。
因此,两者并不总是处于完全相同的计算条件下。
不能仅凭单个 Epoch 中 val_loss 小于 loss,就直接判断存在训练异常。
七、evaluate() 与 predict():评估和推理
7.1 evaluate():模型评估
训练完成后,可以使用:
results = model.evaluate(
x_test,
y_test,
return_dict=True
)
evaluate() 会执行模型的前向计算,并根据配置计算相应的 Loss 与 Metrics。
与 fit() 不同,evaluate() 不执行常规训练参数更新。
它主要用于衡量模型在指定数据集上的表现。
例如,返回结果可能是:
{
"loss": 0.015,
"mae": 0.082
}
这里的数值仅用于说明返回结构,并不对应某次真实实验结果。
对于包含多个输出和多个指标的模型,推荐使用 return_dict=True。
这样可以通过指标名称访问结果,避免依赖返回列表中的位置。
7.2 predict():模型推理
如果只需要模型预测值,而不需要标签和评价指标,可以使用:
predictions = model.predict(
x_test,
batch_size=128
)
predict() 会分批处理输入,并返回预测结果。
通常,其返回值是 NumPy 数组,或者由多个数组组成的相应结构。
与 evaluate() 不同,predict() 不需要目标标签,也不会自动计算 Loss 或 Accuracy 等评价指标。
7.3 直接调用 Model 与 predict() 的区别
在前文中,我们已经知道 Model 继承自 Layer,因此可以直接执行:
outputs = model(
x,
training=False
)
这一方式与 predict() 的目的相近,但接口行为并不完全相同。
predict() 提供了批量数据适配、分批执行和结果收集等能力。
直接调用 model() 则更接近原始前向计算。
尤其在需要反复处理较小批次数据时,直接调用通常可以避免 predict() 的部分额外调度开销。
此外,直接调用可能返回后端原生张量,而 predict() 通常将结果转换为 NumPy 数据。
因此,对于需要继续在 GPU 或 TPU 上进行后续张量计算的场景,直接调用 Model 可能更加合适。
7.4 三种执行模式的区别
| 接口 | 前向计算 | 计算 Loss | 更新权重 | 典型用途 |
|---|---|---|---|---|
fit() |
是 | 是 | 是 | 模型训练 |
evaluate() |
是 | 是 | 否 | 模型评估 |
predict() |
是 | 否 | 否 | 批量推理 |
model(x) |
是 | 不自动计算 | 否 | 直接执行前向计算 |
这里的“不更新权重”是指不会执行常规优化器参数更新。某些特殊自定义层可能具有自己的状态更新行为,因此不能将所有模型内部状态都理解为绝对不变。
同时,对于存在训练与推理差异的层,直接调用 Model 时应明确使用适当的 training 参数。
八、完整实例:构建并训练一个回归模型
为了说明训练系统各组件如何协作,下面使用一个完整的回归示例。
这个示例使用人工生成的数据,不依赖外部数据集下载。
8.1 数据生成
import numpy as np
import keras
from keras import layers
rng = np.random.default_rng(42)
# 构建输入特征
x = rng.normal(
size=(2400, 12)
).astype("float32")
# 构建真实参数
true_w = rng.normal(
size=(12, 1)
).astype("float32")
# 生成回归目标
noise = rng.normal(
scale=0.1,
size=(2400, 1)
).astype("float32")
y = x @ true_w + noise
# 划分训练、验证与测试集
x_train = x[:1800]
y_train = y[:1800]
x_val = x[1800:2100]
y_val = y[1800:2100]
x_test = x[2100:]
y_test = y[2100:]
这里生成了 2,400 个样本,每个样本具有 12 个特征。
真实目标由线性变换与随机噪声构成。
这种数据结构较为简单,适合用于验证训练系统的基本工作流程。
8.2 模型构建
model = keras.Sequential([
keras.Input(shape=(12,)),
layers.Dense(
32,
activation="relu"
),
layers.Dense(
16,
activation="relu"
),
layers.Dense(1)
])
该模型包含两个隐藏层和一个输出层。
其整体数学结构可以表示为:
[
\hat{y}
W_3
\sigma\left(
W_2
\sigma(W_1x+b_1)+b_2
\right)+b_3
]
这里使用向量形式描述单个样本的前向计算,实际代码采用批量张量执行。
8.3 训练配置
model.compile(
optimizer=keras.optimizers.Adam(
learning_rate=1e-3
),
loss=keras.losses.MeanSquaredError(),
metrics=[
keras.metrics.MeanAbsoluteError(
name="mae"
)
]
)
在此配置下:
- 优化器使用 Adam;
- 优化目标为 MSE;
- 额外监控 MAE。
8.4 执行训练
history = model.fit(
x_train,
y_train,
validation_data=(x_val, y_val),
batch_size=64,
epochs=20,
shuffle=True,
verbose=2
)
训练过程中,Keras 会自动完成:
数据批次生成
│
▼
模型前向计算
│
▼
计算 MSE
│
▼
计算梯度
│
▼
Adam 更新参数
│
▼
更新 MAE
│
▼
下一 Batch
│
▼
Epoch 结束
│
▼
验证集评估
8.5 模型评估
results = model.evaluate(
x_test,
y_test,
return_dict=True,
verbose=0
)
print(results)
这里使用的是此前没有参与训练和验证的测试集。
测试集的主要作用,是在模型选择和训练配置确定后,提供相对独立的最终评估依据。
因此,不应反复根据测试集指标调整训练配置,否则可能使测试集逐渐参与模型选择过程,造成评估偏差。
8.6 模型推理
predictions = model.predict(
x_test[:8],
verbose=0
)
print(predictions)
此时,Keras 只执行模型的前向预测。
不会计算梯度,也不会通过优化器更新权重。
通过上述示例可以看到,开发者只需要定义网络结构、损失函数、优化器和训练数据,就能够完成常规监督学习的完整训练流程。
这种高层封装是 Keras 训练系统的重要特征。
九、Training Loop:内置训练循环的内部机制
9.1 训练循环的基本抽象
虽然 fit() 对训练过程进行了封装,但从架构上看,其核心仍然是一个反复执行的循环。
可以通过伪代码概括:
for epoch in range(num_epochs):
reset_metrics()
for batch in training_data:
x, y = batch
y_pred = model(
x,
training=True
)
loss = compute_loss(
y,
y_pred
)
gradients = compute_gradients(
loss,
model.trainable_weights
)
optimizer_update(
gradients,
model.trainable_weights
)
update_metrics(
y,
y_pred
)
evaluate_validation_data()
这段代码仅用于说明训练过程,并不是能够直接运行的 Keras 代码。
其中,compute_gradients() 和 optimizer_update() 是概念性操作。
不同后端会采用不同的自动微分与参数更新实现。
此外,实际的 Keras 训练循环还需要处理样本权重、正则化损失、数据适配、编译执行、回调和分布式状态等问题,因此其内部结构比上述伪代码复杂。
但无论采用何种后端,基本计算目标并没有改变。
9.2 train_step():单次训练步骤
Keras 将单个批次的训练行为抽象为 train_step()。
其主要职责包括:
- 获取当前批次的数据;
- 执行前向传播;
- 计算训练损失;
- 通过后端计算梯度;
- 使用优化器更新参数;
- 更新相应评价指标;
- 返回训练结果。
在默认情况下,Keras 使用其内置实现。
但开发者也可以覆盖 train_step(),以实现不同的训练算法。
这是 Keras 训练系统的重要扩展点。
9.3 自定义 train_step() 的适用场景
一般的分类和回归任务,通常不需要自定义训练步骤。
但以下情况可能需要扩展训练逻辑:
- 一个训练批次包含多个优化阶段;
- 生成器与判别器需要分别更新;
- 不同参数组需要特殊更新规则;
- 训练目标需要复杂的动态组合;
- 模型包含特殊的状态更新机制;
- 研究工作要求严格控制梯度计算和参数更新顺序。
例如,在生成对抗网络中,生成器和判别器通常具有不同的优化目标。
单个标准的 Loss 与 Optimizer 配置,未必能够清晰表达完整的训练算法。
此时,自定义训练步骤可能比强行使用普通 fit() 更合适。
十、跨后端训练步骤的实现差异
10.1 统一接口不意味着统一的底层梯度机制
Keras 3 的一个重要特点,是相同的模型和常规训练配置能够运行在不同计算后端上。
然而,当开发者需要自定义 train_step() 时,不同后端之间的实现差异就会重新显现。
这是因为自动微分和状态管理方式并没有被完全统一为单一的底层机制。
10.2 TensorFlow:GradientTape
TensorFlow 通常使用 tf.GradientTape 计算梯度。
下面是一个仅适用于 TensorFlow 后端的自定义训练步骤示例:
import tensorflow as tf
import keras
class CustomModel(keras.Model):
def train_step(self, data):
x, y, sample_weight = (
keras.utils.unpack_x_y_sample_weight(
data
)
)
with tf.GradientTape() as tape:
y_pred = self(
x,
training=True
)
loss = self.compute_loss(
x=x,
y=y,
y_pred=y_pred,
sample_weight=sample_weight
)
variables = self.trainable_variables
gradients = tape.gradient(
loss,
variables
)
self.optimizer.apply(
gradients,
variables
)
return self.compute_metrics(
x,
y,
y_pred,
sample_weight=sample_weight
)
这一示例展示了 TensorFlow 后端的基本训练过程。
具体的损失跟踪与日志返回行为还受到 Model 和 Metric 配置影响,开发者在构建完整自定义训练系统时,应一并检查这些状态的管理方式。
这里的主要特征是:
在 GradientTape 记录的计算过程中执行前向传播,再通过该记录计算梯度。
10.3 PyTorch:Autograd 与反向传播
PyTorch 通常使用 Autograd 机制。
其基本训练逻辑可以概括为:
# 清除之前的梯度
model.zero_grad()
# 前向计算
y_pred = model(x, training=True)
# 计算 Loss
loss = loss_fn(y, y_pred)
# 反向传播
loss.backward()
随后,开发者需要从相应变量中取得梯度,再执行优化器更新。
在 Keras 的 PyTorch 后端自定义训练步骤中,还需要正确处理 Keras Variable 与 PyTorch 张量之间的关系。
因此,虽然数学过程与 TensorFlow 相同,但梯度计算接口与状态管理方式不同。
10.4 JAX:函数式自动微分与显式状态
JAX 采用函数变换的方式实现自动微分。
典型接口包括:
jax.grad()
jax.value_and_grad()
JAX 的一个重要特点,是倾向于将计算表示为输入与输出明确的纯函数,并将可变状态显式传递。
因此,在 Keras 的 JAX 后端中,自定义 train_step() 通常使用不同于 TensorFlow 和 PyTorch 的函数签名。
其形式可以概括为:
def train_step(self, state, data):
...
return logs, new_state
其中,state 通常包含模型可训练变量、不可训练变量、优化器变量以及 Metric 变量等状态。
相应地,Keras 提供了无状态计算接口,例如:
stateless_call()
stateless_compute_loss()
以及优化器的:
stateless_apply()
这些机制使模型状态能够以显式数据的形式参与计算和函数变换。
对于 JAX 编译与函数式执行而言,这种设计具有重要意义。
但它也意味着,不能简单地将 TensorFlow 的自定义 train_step() 原样复制到 JAX 后端,并期待其正常运行。
10.5 不同后端的比较
| 机制 | TensorFlow | PyTorch | JAX |
|---|---|---|---|
| 自动微分 | GradientTape | Autograd | 函数变换 |
| 典型梯度接口 | tape.gradient() |
backward() |
jax.grad() |
| 状态管理 | 变量对象 | 张量与模块状态 | 显式函数状态 |
| 自定义训练步骤 | train_step(data) |
train_step(data) |
train_step(state, data) |
| 编译机制 | tf.function、XLA |
torch.compile |
JIT、XLA |
| 训练接口 | Keras Model API | Keras Model API | Keras Model API |
需要说明的是,这张表概括的是各后端的典型机制,而不是其全部能力。
各后端在自动微分、编译和状态管理方面可能还提供其他高级接口。
从架构上看,Keras 通过统一的 Model 与训练配置 API,屏蔽了常规训练中的大量底层差异;但对于高度自定义的训练算法,后端特性仍是必须考虑的设计因素。
十一、完全自定义训练循环
11.1 为什么需要自定义训练循环
覆盖 train_step() 可以修改单个批次的训练行为,同时继续使用 fit() 所提供的数据迭代、回调和训练历史等功能。
但某些研究任务需要控制整个训练过程。
例如:
- 不同训练阶段采用不同的数据分布;
- 一个训练轮次中执行多个相互关联的优化循环;
- 训练步骤需要与特殊仿真过程交互;
- 需要自定义跨设备状态同步;
- 对梯度累积、更新顺序或编译边界具有特殊要求。
对于这类任务,开发者可以完全自行编写训练循环。
11.2 自定义训练循环的成本
完全自定义训练循环意味着开发者需要承担更多职责。
除前向计算、Loss 和梯度更新外,还需要自行管理:
- Epoch 与 Step;
- 数据迭代;
- Metric 状态重置;
- 验证流程;
- 训练中断与恢复;
- 日志与检查点;
- 分布式计算及相关状态。
这些功能在 fit() 中通常已经由框架实现。
因此,完全自定义训练循环虽然提供最高的灵活性,但也增加了工程复杂度和维护成本。
Keras 的设计理念并不是要求所有高级模型都放弃 fit(),而是允许开发者根据实际需求逐步深入训练系统内部。
可以将其概括为三个层次:
第一层:标准训练
compile()
│
▼
fit()
│
▼
自动执行训练循环
第二层:自定义单步训练
compile()
│
▼
fit()
│
▼
自定义 train_step()
第三层:完全自定义训练
自行组织 Epoch / Batch
│
▼
自行执行 Forward
│
▼
自行计算 Loss / Gradient
│
▼
自行更新 Optimizer / Metrics
对于大多数监督学习任务,第一层通常已经足够。
第二层适合需要修改局部训练算法,但仍希望保留 Keras 训练基础设施的场景。
第三层则应当用于确实需要完全控制执行过程的任务。
十二、训练系统与计算性能
12.1 fit() 的性能不只取决于模型计算
训练性能是多个子系统共同作用的结果。
一个训练 Step 的耗时可能包含:
[
T_{\text{step}}
T_{\text{data}}
+
T_{\text{forward}}
+
T_{\text{backward}}
+
T_{\text{optimizer}}
+
T_{\text{overhead}}
]
这里的表达式是一种概念性分解。
实际执行中,数据读取、设备计算和数据传输可能发生重叠,因此各项耗时并不总能简单相加。
其中:
- (T_{\text{data}}):数据读取与预处理时间;
- (T_{\text{forward}}):前向传播时间;
- (T_{\text{backward}}):反向传播时间;
- (T_{\text{optimizer}}):优化器更新耗时;
- (T_{\text{overhead}}):框架调度及其他开销。
如果模型非常小,但 Batch 数量很大,那么 Python 调度与框架调用开销可能成为重要因素。
相反,对于大型神经网络,矩阵计算、内存访问及设备之间的通信可能占据主要开销。
因此,优化训练性能时不能只关注神经网络的 FLOPs 或参数数量。
12.2 jit_compile 与计算图编译
Keras 通过 compile() 的 jit_compile 参数控制相关编译行为。
例如:
model.compile(
optimizer="adam",
loss="mse",
jit_compile="auto"
)
对于 JAX 和 TensorFlow 后端,自动模式会在支持的情况下使用 XLA 编译。
对于 PyTorch 后端,自动模式默认使用即时执行;显式设置 jit_compile=True 时,可以使用基于 torch.compile 的编译路径。
编译优化可能通过算子融合、减少调度开销和改善计算组织,提高训练吞吐量。
但编译本身也存在成本。
尤其在 JAX 等编译型执行环境中,首次执行某个训练函数可能需要完成计算图追踪和编译,因此首次运行耗时不能直接代表稳定训练吞吐量。
此外,如果输入形状频繁变化,可能触发额外的编译工作。
12.3 steps_per_execution
Keras 提供:
model.compile(
optimizer="adam",
loss="mse",
steps_per_execution=16
)
其作用是允许一次编译函数执行多个训练 Batch。
这种方式可能降低频繁进入 Python 调度逻辑所产生的开销。
对于小模型、较短训练步骤或 TPU 训练,它可能具有实际价值。
但需要注意三个问题。
首先,这一参数不等于 Batch Size,也不直接改变模型每次梯度计算所使用的样本数量。
其次,设置较大的 steps_per_execution 可能改变部分批次级回调的触发频率。例如,某些 Batch 回调会在每次编译函数执行前后触发,而不是每个内部 Batch 都触发。
最后,根据 Keras 3 官方接口文档,steps_per_execution 不支持 PyTorch 后端。
因此,它是一种受后端实现影响的训练调度优化,而不是所有环境均适用的通用加速参数。
12.4 即时执行与调试
Keras 还提供:
model.compile(
optimizer="adam",
loss="mse",
run_eagerly=True
)
该配置适合需要检查运行时张量、排查自定义训练逻辑或调试异常的场景。
但即时执行通常会减少编译器可以利用的优化机会,因此不应直接将调试配置视为生产训练的性能最优配置。
在工程实践中,应区分调试环境与性能测试环境,并分别记录编译设置、Batch Size、数值精度与数据管道配置。
十三、训练系统中的常见设计问题
13.1 混淆 Loss 与 Metric
Loss 决定模型优化方向。
Metric 用于观察模型表现。
如果某个指标没有被纳入训练目标,仅仅在 metrics 中声明它,并不会使模型直接优化该指标。
因此,训练目标应当根据实际任务设计,而不能简单地将希望提升的所有指标都添加到 metrics 中。
13.2 将训练损失下降等同于泛化能力提升
训练损失下降只表示模型在当前训练目标下的表现有所改善。
它并不保证验证集或测试集上的表现同步提高。
模型可能出现过拟合,也可能在训练目标与真实评价目标之间存在偏差。
因此,训练过程需要同时关注训练指标和独立验证指标。
13.3 忽视梯度、参数与优化器状态
模型参数、梯度和优化器状态是不同的对象。
梯度通常对应当前训练步骤计算出的局部导数信息。
模型参数代表当前神经网络的权重。
优化器状态则可能记录历史梯度、更新步数等信息。
如果需要精确恢复训练过程,只考虑模型权重可能并不充分。
13.4 随意改变 Batch Size
Batch Size 不仅影响显存或内存占用,还可能影响梯度估计的方差和优化过程。
增大 Batch Size 可能提高硬件吞吐量,但也可能需要调整学习率或其他训练配置。
因此,Batch Size 不是纯粹的性能参数,它同时属于训练算法的一部分。
13.5 在不必要的情况下重写训练循环
自定义训练循环能够提供更高控制能力,但也会引入大量重复性工程代码。
如果所需功能已经能够通过标准 Loss、Optimizer、Metric 和 fit() 实现,那么完全重写训练循环通常没有必要。
如果只需要修改单个训练步骤,则优先考虑自定义 train_step()。
只有在标准训练生命周期本身不适用时,才需要完全自行控制训练流程。
这一原则有助于保持模型代码和训练基础设施之间的清晰边界。
十四、Keras 训练系统的架构意义
从整个 Keras 3 框架来看,训练系统建立在 Model 和基础计算能力之上。
其核心结构可以概括为:
fit()
│
▼
Training Loop
│
┌─────────┴─────────┐
│ │
▼ ▼
Data Batch Model State
│ │
└─────────┬─────────┘
▼
Forward
│
▼
Loss
│
▼
Autodiff
│
▼
Gradient
│
▼
Optimizer
│
▼
Update Weights
│
▼
Metrics
│
▼
History / Logs
这一架构反映了 Keras 对训练过程的分层设计。
Model 定义神经网络的计算结构与可训练参数。
Loss 定义需要优化的数学目标。
Optimizer 定义梯度如何转化为参数更新。
Metric 定义模型表现如何被统计和评价。
Training Loop 负责组织上述组件,并管理训练生命周期。
而在这些组件之下,自动微分、计算图编译和硬件执行仍由具体计算后端实现。
从软件工程角度看,这种分层结构具有三方面的重要意义。
第一,模型结构与训练算法相互分离。
同一个 Model 可以使用不同的 Loss 和 Optimizer 进行训练,而不需要重写网络结构。
第二,训练算法与执行机制相互分离。
在常规训练流程中,Keras 可以通过统一接口调用不同后端提供的自动微分和设备执行能力。
第三,训练系统具有逐级扩展能力。
开发者可以从内置 fit() 开始,逐步深入到自定义 train_step(),最终在必要时使用完全自定义的训练循环。
这种设计避免了两种极端:一方面,不必为了简单任务编写大量底层代码;另一方面,也不必因为高层接口无法覆盖全部需求,就放弃已有的模型与组件体系。
结语
Keras 3 的训练系统,主要解决神经网络如何根据数据迭代更新参数的问题。
其核心设计并不是某一种特定优化算法,而是将训练过程拆解为具有明确职责的组件,并通过统一的接口组织这些组件。
Loss 负责定义优化目标,Optimizer 负责执行参数更新,Metric 负责衡量训练效果,Training Loop 则负责协调整个计算过程。
在此基础上,compile() 提供训练配置入口,fit() 实现标准训练循环,evaluate() 提供模型评估能力,predict() 则负责批量推理。
对于常见的监督学习任务,这些接口通常已经能够覆盖完整的训练流程。
对于需要特殊优化规则的研究任务,Keras 还允许开发者通过自定义 train_step() 或完全自定义训练循环,逐步获得更细粒度的控制能力。
需要强调的是,Keras 的统一训练接口并没有消除不同计算后端之间的差异。
在涉及底层自动微分、状态管理、JIT 编译和特殊执行策略时,JAX、TensorFlow 与 PyTorch 仍然表现出不同的技术特征。
因此,理解 Keras 的训练系统,既需要掌握其统一的高层 API,也需要理解这些 API 与底层计算机制之间的边界。
至此,我们已经介绍了 Keras 3 的基础计算层、神经网络层、模型构建层与训练系统。
这四个部分共同构成了深度学习模型从数学定义到参数优化的完整基础。
但训练系统仍然依赖另一个关键环节:如何以合理的格式、速度和顺序向模型持续提供数据。
在下一篇文章中,我们将进一步讨论 Keras 的数据处理系统,分析数据加载、预处理、批处理、随机采样以及不同后端数据管道的集成机制,并讨论数据管道如何影响模型训练效率。
- 点赞
- 收藏
- 关注作者
评论(0)