☰
TensorFlow 2.0 Keras回调函数详解:从ModelCheckpoint到自定义训练控制
2026/10/15 12:16:11 网站建设 项目流程

直接上一段真实经历开场:有一个比较典型的图像分类任务,训练集接近八十万张图,单轮训练在单卡上要跑二十多分钟。前三天我基本什么回调都没配,一个人守着终端,loss曲线稍微抖动一下就开始焦虑,模型保存靠手动,学习率衰减靠肉眼判断,半夜醒来还要看两眼屏幕。后来我把训练脚本里的fit改成传入一组回调函数,基本就解放了——模型自动挑最好的版本存盘,loss 平台期自动降学习率,连续多个 epoch 没有提升就提前收工止损。这篇学习记录就是把 TensorFlow 2.0 下 Keras 训练相关的常用回调函数完整梳理一遍,包括它们各自解决什么问题、参数怎么配、源码层面是怎么触发的,以及我在真实项目里踩过的坑。

1. 先说清楚:回调函数到底在训练流程里扮演什么角色

1.1 没有回调函数的时候,训练是什么样的

很多初学者在接触回调函数之前,训练过程其实是这样的:用model.fit()把数据丢进去,然后死等。每跑完一个 epoch 看一次终端里打印的loss和acc,心里默默估算大概要跑多少轮。等到终于觉得"差不多行了",手动model.save()存一下;发现 loss 下降变慢,就手动改学习率,或者干脆在训练脚本外面再加一层循环,每训练完一个 epoch 就用验证集算一次分数,看分数不满意就自己调参数再训。

这种方式在小数据集上勉强能用,但一旦面对真正的任务——比如训练集很大、单轮耗时长、或者你同时在跑好几个实验——问题就全暴露了:要么你人在工位守着,要么就是在凌晨被一个跑崩的实验叫醒。更麻烦的是,你没法保证"手动保存的模型"刚好是验证指标最优的那一版,因为训练过程中模型权重变化是连续的,你某个时刻看到的指标好,不代表你此刻已落盘的就是最好的。

回调函数解决的就是这件事:它把你原本需要人工干预的那部分逻辑,全部搬到训练循环内部,让模型自己在合适的时机"做决定、做动作"。用我的话概括,它就是给训练过程挂上的一批定时器与触发器,你提前定义好"什么条件下干什么事",剩下的事就不用再盯着了。

1.2 回调函数是怎么被触发的:训练循环里的四个层级

在 Keras 的训练机制里,一次完整的训练按层级可以拆成训练开始/结束、epoch 开始/结束、batch 开始/结束这几类时机。每个回调函数内部都有一组可重写的方法,它们分别对应这些时机:

  • on_train_begin/on_train_end:整个fit调用开始时触发一次、结束时触发一次。
  • on_epoch_begin/on_epoch_end:每个 epoch 开始前、结束后触发。
  • on_batch_begin/on_batch_end:每个 batch 数据送入模型前、反向传播完成后触发。

model.fit()内部其实是这样一个循环结构:外层是 epoch 循环,内层是 batch 循环,每个循环的前后都有钩子去调用回调函数。所以回调函数里拿到的epoch或batch参数,都是当前循环所在的位置。你不需要修改任何网络结构或训练逻辑,只要把回调实例放进fit(..., callbacks=[...])的列表里,就会自动被调用。

有一点需要区分清楚:回调函数里能拿到self.model、self.params(比如 batch 大小、样本数、epoch 数)、self.history(历史指标字典),但这些是只读性质的上下文信息。你在回调里做的大部分操作是"读指标、做判断、执行副作用",比如保存文件、打印日志、修改学习率。我自己早期试用自定义回调时,总会试图在on_batch_end里直接去改model.loss,这种操作其实是无效甚至危险的,后面我会专门说。

2. 三板斧:ModelCheckpoint、EarlyStopping、ReduceLROnPlateau

这三个回调是默认情况下我最先塞进训练脚本的三件套。它们的组合能覆盖训练中最重要的三件事:保存最好的模型、决定什么时候停止、动态调整学习率。

2.1 ModelCheckpoint——把最好的一版模型留住

ModelCheckpoint 的职责很单纯:周期性把模型保存到磁盘。但光"保存"还不够,真正有价值的是"判断哪些版本值得保存"。默认情况下它每个 epoch 保存一次,你如果不加干预,训练二十轮会留下二十个模型文件,既占空间又不好管理。实际操作中我几乎总会设置save_best_only=True,它会让回调跟踪某个监控指标,只有当本轮指标优于历史最佳时才会覆盖保存。

参数上有一个需要自己理解清楚的概念:monitor和mode。

  • monitor指定要跟踪的指标名,常见的是val_loss或val_accuracy。在 TensorFlow 2.x 里要特别注意,验证集上面的准确率指标名通常是val_accuracy,但很多老教程写的是val_acc,直接填老名字训练时会报错。
  • mode告诉回调"指标变大是好还是变小是好"。auto模式会依据监控指标名称里是否含acc来判断,如果名称有歧义,最好手动指定。监控val_loss时用mode='min',监控val_accuracy时用mode='max'。

实际使用时我的配置一般长这样:

import tensorflow as tf checkpoint_cb = tf.keras.callbacks.ModelCheckpoint( filepath='best_model.h5', # 保存路径 monitor='val_accuracy', # 监控验证集准确率 mode='max', # 越大越好 save_best_only=True, # 只保存最优版本 save_weights_only=False, # 保存完整模型,便于直接加载 verbose=1 # 保存时打印通知 )

这里有个值得展开的点:save_weights_only应该怎么选。如果是原型快速验证,save_weights_only=True可以显著减少存储占用,一个 ResNet50 的权重文件大约 90MB,而完整模型往往会翻倍甚至更多,因为要额外保存网络结构、优化器状态、编译参数。但如果你后续需要直接load_model恢复并继续训练,那最好存完整模型,尤其是你用了像 Adam 这类带动量状态量的优化器时,保存权重方案会丢掉动量信息,恢复训练后前几个 epoch 的收敛速度会有明显波动。

文件路径前缀还有一个坑:filepath里可以包含占位符,比如model_{epoch:02d}_{val_accuracy:.4f}.h5。这种写法在save_best_only=True时会产生很多不同名字的文件,因为每次保存都认为"这是目前最优的一版",文件名不同导致旧版不会覆盖,磁盘会越来越多。我一般只在save_best_only=False且确实需要保留每一轮快照时,才用这种带变量名的路径。

2.2 EarlyStopping——给训练踩刹车

EarlyStopping 解决的核心问题是:你永远不知道训练到第几轮最合适。理论上只要一直训,训练集 loss 会持续下降,但验证集指标大概率会先升后降,形成典型的过拟合曲线。EarlyStopping 做的事情是持续监控某个指标,一旦发现指标在连续若干个 epoch 里没有改善,就提前终止训练。

它最关键的参数是patience,也就是"允许指标多少个 epoch 不进步"。patience=1会非常敏感,任何一轮正常波动都可能触发停止;patience=10则更宽容,适合训练本身噪声比较大的场景。我常用的设定是patience=5或patience=8,具体取决于验证集大小和每轮训练时长。验证集越小,指标波动越大,patience 就要相应调大,否则经常在真正收敛前就误停了。

另一个容易被忽略的配置是restore_best_weights。如果不设置它,EarlyStopping 触发后返回的模型权重是"最后一次 epoch 结束时的权重",而不是"监控指标最优那一轮"的权重。换句话说,你的模型可能是从最好点又过拟合跑了 5 个 epoch 才被叫停的。设置restore_best_weights=True后,回调会在停止训练前把权重恢复到历史最优状态。这一点在多次实验中帮我把最终效果稳定提升了好几个百分点,属于性价比极高的一个参数。

有时候你会发现 EarlyStopping 和 ModelCheckpoint 同时存在时,最终加载的模型到底是谁说不清楚。我的习惯是:如果训练脚本里有 EarlyStopping,且设了restore_best_weights=True,那么后续评估直接用model.evaluate即可,不需要额外去读盘;如果没设restore_best_weights,就必须从 ModelCheckpoint 保存的文件加载,否则你评估的模型并不是最优版。

2.3 ReduceLROnPlateau——学习率该降就降

训练早期 loss 快速下降,到中后期逐渐进入平台期,这是非常普遍的现象。这时候手动把学习率调小一档,通常 loss 又能继续往下走一段。ReduceLROnPlateau 就是把这个操作自动化的回调:它监控某个指标,当指标连续patience轮没有改善,就把学习率乘以factor。

一个比较标准的配置:

reduce_lr_cb = tf.keras.callbacks.ReduceLROnPlateau( monitor='val_loss', factor=0.5, # 每次降为原来的一半 patience=3, # 连续3个epoch没改善才降 cooldown=1, # 降完学习率后休息1个epoch,不监控 min_lr=1e-6, # 学习率下界 verbose=1 )

factor=0.5表示新学习率 = 旧学习率 × 0.5,这是比较温和的衰减。如果任务收敛比较快,也可以设factor=0.2这种更激进的。cooldown的作用是降学习率后先等几个 epoch,让模型在新学习率下充分适应,不会因为刚降完就立刻继续触发判断。min_lr是保护性下界,防止学习率在反复平台期里被降到几乎没有变化,模型进入"死水"状态。

很多人会把 ReduceLROnPlateau 和 LearningRateScheduler 搞混。后者是严格按照你定义好的 epoch 次数表调整学习率,比如"第 10 轮乘 0.1、第 20 轮乘 0.1",它不关心指标表现;前者则是动态判断,该降才降,不该降就不动。对于大多数实验项目,我推荐优先用 ReduceLROnPlateau,因为你并不知道学习率应该在哪个 epoch 下降,硬编码的衰减表往往和实际收敛节奏对不上。

3. 日志与可视化:TensorBoard、CSVLogger 和 LambdaCallback

训练指标不能只看终端里刷的那几行。当实验数量多、对比维度复杂时,没有记录、没有曲线,根本记不清哪个配置跑出过什么结果。这一组回调就是负责把训练过程"留痕"的。

3.1 TensorBoard——把训练曲线搬进浏览器

TensorBoard 从 TensorFlow 1.x 时代就是最常用的可视化工具。在 2.0 的 Keras 版本里使用变得非常简单,只需要一个回调:

tensorboard_cb = tf.keras.callbacks.TensorBoard( log_dir='./logs/exp001', histogram_freq=1, # 每1个epoch记录一次权重直方图 write_graph=True, # 记录计算图 update_freq='epoch' # 也可设为'batch' )

训练结束后,在终端执行tensorboard --logdir=./logs,浏览器打开http://localhost:6006就能看到曲线。我在实际项目里主要看三块内容:SCALARS面板里的 loss 和 accuracy 曲线,用来判断收敛趋势;GRAPHS面板查看网络结构是否和预期一致;HISTOGRAMS面板看权重分布有没有异常膨胀或消失,这对排查梯度爆炸很有用。

有几个细节需要提醒。histogram_freq=1会显著增大日志体积,因为你把每层的权重分布都记下来了,如果数据集大、网络深,训练完可能产生几个 GB 的 events 文件。单纯只想看曲线的话,可以不写这个参数或者写0。update_freq='batch'会让曲线更细密,但同样增加开销,大多数场景'epoch'足够。另外,如果你同时跑多个实验且使用fit时传了validation_data,你会发现 TensorBoard 里的val_loss等指标是自动带上来的,不需要额外处理。

3.2 CSVLogger——留一个能塞进表格处理的文本档案

TensorBoard 适合人眼观察,CSVLogger 适合程序化分析。这个回调会把每个 epoch 结束时候的所有指标,按行写入一个 CSV 文件。我有段时间同时跑十组对比实验,每组配不同数据增强策略,最后就靠这些 CSV 文件汇总到一个大表格里做统计,比手动复制终端文本靠谱太多。

基本写法:

csv_cb = tf.keras.callbacks.CSVLogger( filename='training_log.csv', separator=',', append=False )

这里有一个非常容易踩的坑:append参数。第一次训练时,回调会创建一个新文件并把表头写进去;如果你要在同一进程里进行第二次独立训练,并且想续写同一个文件,必须把append=True,否则表头会被重复写入,而且旧数据会被覆盖。更保险的做法是每次新实验用独立文件名,比如在文件名里加上时间戳,避免任何误覆盖的可能。

CSVLogger 的内容其实和history.history是一致的,区别在于前者落到磁盘、进程结束后依然可查。我一般还会利用文件名把关键超参数编码进去,比如resnet50_lr0.001_bs64.csv,后续检索时扫一眼文件名就知道是哪个实验。

3.3 LambdaCallback——不想写完整回调类时的快速通道

LambdaCallback 允许你直接用匿名函数指定在某个训练时机执行的动作,适合写一些很简短的逻辑,没必要专门定义一个完整回调类。

举个例子,我想在每个 epoch 结束时打印当前学习率,只需要:

lambda_cb = tf.keras.callbacks.LambdaCallback( on_epoch_end=lambda epoch, logs: print( f"Epoch {epoch}: lr = {tf.keras.backend.get_value(model.optimizer.lr):.6f}" ) )

LambdaCallback 支持on_epoch_begin、on_epoch_end、on_batch_begin、on_batch_end、on_train_begin、on_train_end这些参数,可以传任意多个。它的优点是轻量,缺点是结构化程度低。如果逻辑超过三五行,或者涉及状态保存(比如统计连续几个 epoch 的指标差),我会建议直接写一个类,不然 lambda 里堆了一堆状态变量,代码可读性会变差,调试也更麻烦。

我实际用到的一个典型场景是在训练启动时自动打印模型参数量:

def print_params(train_begin, logs=None): total = int(tf.keras.backend.count_params(model.trainable_variables)) print(f"Trainable params: {total}") lambda_cb = tf.keras.callbacks.LambdaCallback( on_train_begin=print_params )

4. 自己写回调:继承 Callback 类实现自定义逻辑

内置回调覆盖了大多数常用场景,但真正让回调机制强大的地方,是你完全可以写自己的回调类。尤其当你的实验"想做点内置回调没有的事情"时,这个能力往往是唯一的解法。

4.1 回调的生命周期方法:方法名与触发时机

自定义回调的基础是继承tf.keras.callbacks.Callback,然后重写对应的方法。可用的方法包括前面提到过的on_train_begin、on_train_end、on_epoch_begin、on_epoch_end、on_batch_begin、on_batch_end。它们的签名几乎都是(self, epoch, logs=None)或(self, batch, logs=None)。

回调实例里自带几个重要属性:

  • self.model:当前训练中的模型,可以读取model.optimizer.lr、model.layers、调用model.evaluate等。
  • self.params:fit传入的训练参数,比如batch_size、epochs、verbose。
  • self.history:历史指标记录对象,本质上是一本字典。

一个最经典的自定义回调范例是"在每轮结束额外打印验证集上的 F1 分数"。Keras 内置指标里没有 F1,但你可以在on_epoch_end里直接调用model.evaluate或者对验证集做预测并计算。需要注意每次 evaluate 都会占用额外时间,如果一个 epoch 原本 10 分钟,你在里面再 evaluate 一次,开销会明显上升,所以更推荐用model.predict配合内存中的验证数据计算,而不是频繁调用完整评估流程。

以下是一个简化的 F1 打印回调:

import numpy as np from sklearn.metrics import f1_score class F1Monitor(tf.keras.callbacks.Callback): def __init__(self, validation_data): super().__init__() self.val_x, self.val_y = validation_data def on_epoch_end(self, epoch, logs=None): pred = tf.argmax(self.model.predict(self.val_x), axis=1) true = tf.argmax(self.val_y, axis=1) if len(self.val_y.shape) > 1 else self.val_y score = f1_score(true.numpy(), pred.numpy(), average='macro') print(f"Epoch {epoch + 1}: macro F1 = {score:.4f}")

这个类里最关键的一行是拿到模型后进行predict,并利用真实标签计算分数。这么做的注意点是验证集要能整体塞进内存,如果验证集太大,建议改为随机采样一个小批次来计算近似分数。

4.2 两个更贴近实战的自定义回调例子

第一个例子:给训练中"疑似过拟合"的阶段加上预警。内置 EarlyStopping 直接在达标时停训练,但有些场景我不想立刻停,而是想观察到连续多少个 epoch 里训练指标还在下降、验证指标却停滞或上升时,额外打印警告并顺手调低学习率。这种逻辑没有现成回调,只能自己写。

我当时的实现大致是:维护一个队列,保存最近几个 epoch 的val_loss;每轮检查一次,如果val_loss连续 N 轮没有低于历史最低值、但loss仍在下降,就打印一行"WARNING: possible overfitting, val_loss stuck"。后续是否调整学习率,可以再结合ReduceLROnPlateau的机制,也可以自己在回调里手动乘factor。

第二个例子是:"动态调整丢弃率"。具体场景是一个偏小样本任务,训练初期希望丢弃率高一点,防过拟合,但训练后期模型容量不足,希望逐步降低丢弃率让模型充分拟合。由于丢弃层不是可训练变量,它的值可以通过回调在每轮结束后获取并修改:

class DropoutScheduler(tf.keras.callbacks.Callback): def __init__(self, layer_name, start_rate=0.5, end_rate=0.1, total_epochs=50): super().__init__() self.layer_name = layer_name self.start_rate = start_rate self.end_rate = end_rate self.total_epochs = total_epochs def on_epoch_begin(self, epoch, logs=None): progress = min(epoch / self.total_epochs, 1.0) rate = self.start_rate + (self.end_rate - self.start_rate) * progress for layer in self.model.layers: if layer.name == self.layer_name and hasattr(layer, 'rate'): layer.rate = rate

这里唯一的坑在于:如果你修改的是 dropout 层的rate,必须确认这个属性在预测和训练时都会被读取,``trainable配置不受影响。同时这种修改只对当前进程内的模型生效,如果你保存了模型再加载,需要自己同步这个策略。

从这两个例子可以看出,自定义回调本质上就是"在训练事件的钩子里写普通 Python 代码"。你不需要理解 Keras 底层的自动微分或图执行机制,只需要正确使用模型暴露出来的接口,自由度非常高。

5. 组合使用时的整体策略与几个我亲自踩过的坑

单独使用每个回调都不难,麻烦的是它们组合在一起时可能产生相互作用,或者因为一些细节问题导致实验效果不理想甚至直接报错。这一部分讲的是我反复踩过之后沉淀下来的配置套路。

5.1 回调列表的顺序会决定执行先后

model.fit接收的callbacks是一个列表,多个回调在同一个时机上是按下标顺序依次执行的。执行顺序在大多数情况下不会影响最终结果,但有一个典型例外:如果你把ReduceLROnPlateau放在了EarlyStopping后面,那么在同一个 epoch 结束时,可能是先触发早停判断、再触发学习率调整判断。如果这一轮恰好满足早停条件,训练直接终止,学习率调整还没执行,这通常没问题;但反过来,如果你希望"先降学习率再看效果,而不是直接停",顺序就很重要。

我的默认顺序习惯是:TensorBoard或CSVLogger放最前面来记录状态,之后是ReduceLROnPlateau,再是ModelCheckpoint,最后是EarlyStopping。这样每轮指标产生后,先记录、再判断学习率、再决定是否存模型、最后判断是否早停。一套流程下来逻辑上更顺畅。

5.2 不同任务场景下的回调配置模板

我把长期使用的几个基础配置整理成下面这张表,不一定适合所有情况,但作为起点很实用。

场景推荐回调组合说明
图像分类,数据集中等规模ModelCheckpoint(val_accuracy) + EarlyStopping(patience=5) + ReduceLROnPlateau(val_loss)兼顾最优模型保存和自动收敛,是最稳妥的组合
大规模数据,资源充足ModelCheckpoint(每N轮存一次) + TensorBoard早停不实用,因为单轮很慢,更看重周期快照
小样本/高噪声数据EarlyStopping(restore_best_weights=True, patience=10) + ReduceLROnPlateau + CSVLoggerpatience 要调大,避免噪声导致误停
序列生成类任务ModelCheckpoint(只存权重) + CSVLogger + 自定义F1回调序列任务评估成本高,不建议每轮都 evaluate
需要在不同阶段采用不同学习率LearningRateScheduler(分段函数) 或 自定义LR回调比 ReduceLROnPlateau 更容易实现固定计划

5.3 踩坑记录:从监控指标拼写到恢复最优权重

第一个常见的坑:监控指标名称不匹配。TensorFlow 2.3 之前,训练和验证集上的准确率指标名分别是accuracy和val_accuracy;但有些模型通过metrics=['acc']指定,日志里又会显示acc和val_acc。回调的monitor参数必须严格匹配日志里存在的键名,否则训练一开始就会抛找不到键的错误。排查方法很简单,在第一个 epoch 结束后打印logs.keys(),看实际有哪些指标名。

第二个坑是 EarlyStopping 配合restore_best_weights时的文件覆盖问题。如果同时使用了 ModelCheckpoint 且 save_best_only=True,两个回调都在监听同一个指标,早停恢复权重时会把模型变回某轮最优状态,而保存下来的文件也是在某一轮写的。两者内容基本一致,但如果你在训练中途手动加载过检查点文件,并且训练脚本后续又依赖于内存中的模型,要特别留意到底哪个才是"最优"版本。最好的做法是最终以 ModelCheckpoint 保存的文件为准,因为它是磁盘上真实存在的、可重复加载的产物。

第三个坑是保存路径的目录不存在。ModelCheckpoint 在保存文件时如果发现父目录不存在,并不会自动帮你创建目录,而是直接抛FileNotFoundError。我在脚本里写文件路径时习惯先加一句:

os.makedirs('./checkpoints', exist_ok=True)

再设置filepath='./checkpoints/best_model.h5',这样就不会被这种低级问题卡住。

第四个坑是自定义回调里调用model.evaluate导致指标污染。在on_epoch_end里调用model.evaluate本身没有错,但它会跑一次完整的前向计算,这会显著拉长每个 epoch 的时间。更关键的是,如果设置了TensorBoard(update_freq='epoch'),它记录的是当前日志里的logs值,而你在on_epoch_end之后追加的东西不会自动写入。如果你确实需要比较"训练日志指标"和"自评估指标",建议把自定义回调放在列表靠后的位置,并且自行在回调里记录结果,不要依赖原有的logs。

第五个坑实际上不是回调本身的,而是对回调保存的学习率状态理解不足。使用ReduceLROnPlateau时,如果中途保存模型再加载继续训练,学习率是可能被重置的。原因是模型的优化器状态被序列化时可能包含当前学习率,但从加载的文件来看,不同版本和保存方式的行为并不一致。我的解决方法是:在自定义回调里显式记录每次学习率变化到 CSV 里,下次继续训练时手动把优化器的学习率设置回记录的最终值。这看起来多此一举,但确实帮我在中断恢复实验时省了不少排查时间。

讲到最后,实际操作中我还有一个习惯:把回调函数的配置统一抽到一个字典里,和模型结构、训练超参数一起存成一份实验配置文件。这样同一个训练脚本可以随时通过修改配置来切换回调组合,复现实验时也只需要找回那一份配置,再配合 CSVLogger 和 TensorBoard 的日志,就能完整还原当时的训练环境。毕竟在一个项目里跑几十上百组实验是常态,把实验管理做得规范一点,后面分析结果时会感激自己当初多花的几分钟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询