Keras Custom Callbacks
Learn to create custom callbacks in Keras for better training control. This Applied AI engineering tutorial covers how to implement callbacks to monitor metrics, adjust learning rates, and more, with a hands-on exercise to solidify your skills.
Focus: create custom callbacks in keras
You've trained models with EarlyStopping and ModelCheckpoint before, but what happens when you need to pause training to refill your GPU's memory, or send a Slack alert when validation loss plateaus? The built-in Keras callbacks cover the common cases, but they can't know about your training loop's unique demands. That's the pain: you hit a wall where the training behavior you need simply isn't bundled with tf.keras. The solution is to create custom callbacks in Keras — a skill that turns a generic training script into a purpose-built, production-grade pipeline. In this Applied AI engineering lesson, you'll learn the exact mechanism Keras uses to hook into every stage of training, and you'll walk away able to write callbacks that monitor, mutate, and even halt training on your own terms.
The problem this lesson solves
Training a neural network is rarely a fire-and-forget operation. In real projects, you need to:
- React to metrics as they emerge — e.g., reduce the learning rate when validation loss stops improving.
- Save the best model only when it truly is the best (not just every epoch).
- Log custom information to your own dashboard, CSV, or messaging system.
- Implement business rules — like stopping training early if a custom fairness metric crosses a threshold, or if GPU memory usage exceeds a safety limit.
- Interact with the outside world — send a notification, trigger a data pipeline step, or adjust a hyperparameter mid-run.
The built-in callbacks (EarlyStopping, ModelCheckpoint, ReduceLROnPlateau, TensorBoard) cover generic scenarios, but they are black boxes. You can set parameters, but you can't inject your own logic at precise moments. When you hit this ceiling, you have two choices: hack together a loop that manually checks conditions between epochs (ugly and error-prone), or write a custom callback that integrates seamlessly with the Keras training engine. The latter is cleaner, more testable, and far more maintainable — and that's exactly what this lesson teaches.
Core concept / mental model
Think of a Keras callback as a set of hooks — functions that Keras promises to call at specific moments during training. Picture a robot that trains your model. At every checkpoint (start of training, end of each epoch, end of each batch, etc.), the robot stops, looks at its list of observers, and says: "Hey, does anyone want to do something right now?" Your custom callback is one such observer, raising its hand at the moments you care about.
Formally, every callback inherits from tf.keras.callbacks.Callback. When you pass a list of callbacks to model.fit(), Keras invokes the corresponding methods on each callback at the right time. The most important methods are:
| Method | Called when | What you can do |
|---|---|---|
on_epoch_begin(epoch, logs) |
Start of each epoch | Modify the model, adjust internal state |
on_epoch_end(epoch, logs) |
End of each epoch | Read metrics, save weights, stop training |
on_batch_begin(batch, logs) |
Start of each batch | Change batch-level behavior |
on_batch_end(batch, logs) |
End of each batch | Monitor batch-level metrics |
on_train_begin(logs) |
Start of training | Set up resources, counters |
on_train_end(logs) |
End of training | Clean up, send final notification |
logs is a dictionary that Keras populates with the current metrics — e.g., logs['loss'], logs['accuracy'], logs['val_loss']. The key mental shift: you are not writing code that runs inside the training loop; you are writing code that Keras calls at the boundaries. This separation keeps your architecture clean and lets you mix multiple callbacks without entangling their effects.
How it works step by step
To create a custom callback, follow this systematic path:
- Subclass
tf.keras.callbacks.Callback— your class inherits all the methods, and you override the ones you need. - Define
__init__— store configuration values (like a threshold, a file path, or a log name). - Override the lifecycle methods — choose the hook points that matter for your goal.
- Access the model and logs — inside the callback,
self.modelgives you the current model;logsgives you the metrics. - Use
self.model.stop_training = True— this is the magic flag that halts training from inside a callback. - Pass an instance to
model.fit()— along with any built-in callbacks, in thecallbackslist.
Pro tip: The
logsdictionary is only populated with metrics that are actually computed. If you wantval_accuracyto appear, make sure you pass a validation set tofit().
A simple example
Here's the minimal custom callback that prints the training loss after every epoch:
import tensorflow as tf
class LossLogger(tf.keras.callbacks.Callback):
def on_epoch_end(self, epoch, logs=None):
loss = logs.get("loss")
print(f"Epoch {epoch + 1}: loss = {loss:.4f}")
# Train a mini model to see the callback in action
model = tf.keras.Sequential([tf.keras.layers.Dense(1, input_shape=(2,))])
model.compile(optimizer="sgd", loss="mse")
# Dummy data
import numpy as np
x = np.random.rand(100, 2)
y = np.random.rand(100, 1)
model.fit(x, y, epochs=3, callbacks=[LossLogger()])
Expected output (abbreviated):
Epoch 1: loss = 0.1234
Epoch 2: loss = 0.1187
Epoch 3: loss = 0.1123
Hands-on walkthrough
Now let's build something genuinely useful: a custom callback that saves the best model based on a custom metric and stops training if a metric plateaus — but with your own twist. You'll also see how to modify the learning rate dynamically.
Example 1: Custom ModelCheckpoint with a Twist
Keras's built-in ModelCheckpoint saves based on a monitored quantity, but let's say you want to keep the last N best models, not just the single best. Here's a custom callback:
import tensorflow as tf
import numpy as np
import os
class KeepLastNBest(tf.keras.callbacks.Callback):
def __init__(self, monitor="val_loss", n=3, save_path="checkpoints"):
super().__init__()
self.monitor = monitor
self.n = n
self.save_path = save_path
self.history = [] # list of (metric_value, filename)
os.makedirs(save_path, exist_ok=True)
def on_epoch_end(self, epoch, logs=None):
logs = logs or {}
current = logs.get(self.monitor)
if current is None:
return
# Save the model with epoch number
filename = f"{self.save_path}/model_epoch_{epoch + 1}.keras"
self.model.save(filename)
self.history.append((current, filename))
# Sort by metric (ascending for loss, descending for accuracy)
self.history.sort(key=lambda x: x[0], reverse=("acc" in self.monitor))
# Remove extras
while len(self.history) > self.n:
_, old_file = self.history.pop()
if os.path.exists(old_file):
os.remove(old_file)
print(f"Saved {filename} (val_loss: {current:.4f})")
# Usage
model = tf.keras.Sequential([tf.keras.layers.Dense(1, input_shape=(2,))])
model.compile(optimizer="adam", loss="mse")
x = np.random.rand(100, 2)
y = np.random.rand(100, 1)
val_x = np.random.rand(20, 2)
val_y = np.random.rand(20, 1)
model.fit(x, y, validation_data=(val_x, val_y), epochs=5,
callbacks=[KeepLastNBest(n=2)])
Expected behavior: After training, only the 2 best models remain in the checkpoints/ folder.
Example 2: Learning Rate Scheduler with Custom Logic
You can implement your own learning rate scheduler that reduces LR after a plateau, but with a minimum limit and a cooldown period:
class CustomLRScheduler(tf.keras.callbacks.Callback):
def __init__(self, factor=0.5, patience=2, min_lr=1e-6, cooldown=1):
super().__init__()
self.factor = factor
self.patience = patience
self.min_lr = min_lr
self.cooldown = cooldown
self.wait = 0
self.cooldown_counter = 0
self.best = float("inf")
def on_epoch_end(self, epoch, logs=None):
logs = logs or {}
val_loss = logs.get("val_loss")
if val_loss is None:
return
if self.cooldown_counter > 0:
self.cooldown_counter -= 1
return
if val_loss < self.best:
self.best = val_loss
self.wait = 0
else:
self.wait += 1
if self.wait >= self.patience:
lr = float(tf.keras.backend.get_value(self.model.optimizer.lr))
new_lr = max(lr * self.factor, self.min_lr)
tf.keras.backend.set_value(self.model.optimizer.lr, new_lr)
print(f"Reducing LR to {new_lr:.2e}")
self.wait = 0
self.cooldown_counter = self.cooldown
# Usage
model = tf.keras.Sequential([tf.keras.layers.Dense(1, input_shape=(2,))])
model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.01), loss="mse")
model.fit(x, y, validation_data=(val_x, val_y), epochs=10,
callbacks=[CustomLRScheduler()])
Example 3: Early Stopping with a Custom Condition
Let's stop training not based on patience, but when a custom metric (like a business KPI) crosses a threshold:
class ThresholdStopper(tf.keras.callbacks.Callback):
def __init__(self, threshold=0.95, monitor="accuracy"):
super().__init__()
self.threshold = threshold
self.monitor = monitor
def on_epoch_end(self, epoch, logs=None):
logs = logs or {}
current = logs.get(self.monitor)
if current is not None and current >= self.threshold:
print(f"Reached {self.monitor} = {current:.4f} >= {self.threshold}. Stopping.")
self.model.stop_training = True
# Usage with a small classification model
inputs = tf.keras.Input(shape=(4,))
x = tf.keras.layers.Dense(8, activation="relu")(inputs)
outputs = tf.keras.layers.Dense(1, activation="sigmoid")(x)
model = tf.keras.Model(inputs, outputs)
model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"])
x_cls = np.random.rand(100, 4)
y_cls = np.random.randint(0, 2, size=(100,))
model.fit(x_cls, y_cls, epochs=50, callbacks=[ThresholdStopper(threshold=0.8)])
Compare options / when to choose what
When should you write a custom callback instead of using built-ins? Here's a comparison table:
| Situation | Built-in Callback | Custom Callback |
|---|---|---|
| Stop when val_loss plateaus | EarlyStopping |
Overkill — use built-in |
| Save best model only | ModelCheckpoint with save_best_only |
Use built-in |
| Reduce LR on plateau | ReduceLROnPlateau |
Use built-in |
| Save last N best models | Not available | Custom — needed |
| Log to a custom dashboard | Not possible | Custom |
| Trigger an alert when metric crosses threshold | Not available | Custom |
| Modify model architecture mid-training | Not possible | Custom (though rare) |
| Log batch-level statistics | Not in built-ins | Custom with on_batch_end |
| Implement custom normalization based on batch stats | Not available | Custom |
General rule of thumb: If a built-in does exactly what you need, use it. But if you find yourself combining multiple built-ins in a hacky way or trying to make one behave like another, a custom callback is the cleaner path.
Variations
- TensorFlow 1.x style: Older
tf.compat.v1had aCallbackclass with similar methods. If you're working on legacy code, the patterns are almost identical, but you won't have thelogsdict populated the same way. - Keras 2.x vs 3.x: The
Callbackbase class is the same API. In Keras 3 (multi-backend),tf.kerasis one implementation; you can also usekerasdirectly with PyTorch or JAX backends, and the callback API remains the same. - Custom training loops: If you use
tf.GradientTapeinstead ofmodel.fit(), you have full control and may not need callbacks — but you lose the convenience of metric tracking. Use callbacks when you want a standard training loop with injected logic.
Troubleshooting & edge cases
logsis empty or missing keys: This happens when the metric isn't computed. For validation metrics, you must pass.fit(validation_data=...). For custom metrics, ensure they're included inmodel.compile(metrics=[...]).- Fix: Always check
logs.get("val_loss") or logs["loss"]as a fallback. self.modelisNonein__init__: The model isn't attached until beforeon_train_begin. Don't accessself.modelin__init__; do it inon_train_beginor later.- Setting
stop_trainingdoesn't stop: Make sure you setself.model.stop_training = Truein a method that actually runs during training (e.g.,on_epoch_end). Setting it in__init__has no effect. - Modifying the model's loss or state inside a callback can cause unexpected behavior: If you change weights or add layers, do it in
on_epoch_beginoron_train_begin, and be aware that changing the architecture after compilation may break optimizers. - Memory leak when saving models every batch: Use
on_epoch_endfor heavy operations unless you know what you're doing. Batch-level callbacks run thousands of times — optimize accordingly. - Concurrency issues with shared state: If you use multiple callbacks that share state (e.g., a counter), keep the state inside each callback instance to avoid side effects.
What you learned & what's next
You now have the core skill of creating custom callbacks in Keras. You learned the mental model of hooks, the key lifecycle methods (on_epoch_begin, on_epoch_end, on_batch_end, etc.), how to access logs and self.model, and how to stop training or modify the learning rate. You also compared when to use built-ins vs. custom, and you walked through three concrete examples: a rolling-best model saver, a custom LR scheduler, and a threshold-based early stopper. This lesson directly fulfills the objectives: you can explain the core idea and you've completed practical exercises.
The next lesson in this track will likely cover advanced training loops — perhaps custom metrics or callbacks that interact with tf.data pipelines. With custom callbacks, you're now ready to debug, monitor, and control long training runs in production.
Practice recap
As a hands-on exercise, extend the KeepLastNBest callback to also log the model's file size, and write a small test that verifies the callback correctly deletes old checkpoints. Then create a custom callback that sends a simulated Slack message (print to console) whenever training reaches 50% of the total epochs. These mini-projects will reinforce the hook lifecycle and the logs mechanics you've just learned.
Common mistakes
- Accessing
self.modelin__init__— the model isn't attached until training begins. Useon_train_begininstead. - Assuming
logsalways hasval_loss— you must passvalidation_datatofit()for validation metrics to appear. - Forgetting that setting
self.model.stop_training = Trueonly works from within a callback method that runs during training (e.g.,on_epoch_end). - Overriding
__init__without callingsuper().__init__()— this can break the callback framework in some Keras versions. - Saving the entire model or heavy artifacts on
on_batch_end— thousands of calls per epoch will slow training drastically.
Variations
- Instead of subclassing
tf.keras.callbacks.Callback, you can use thekeras.callbacks.Callbackfrom the standalone Keras library (Keras 3) — the API is the same across backends. - For pure inference-time monitoring (e.g., during
model.evaluate()), callbacks also work but are less common; you can useon_test_batch_endto capture per-batch results. - If you're building a custom training loop with
tf.GradientTape, you might skip callbacks entirely and write your own logging — but you lose the convenience ofmodel.fit's metric tracking.
Real-world use cases
- A production MLOps team logs model performance metrics to an internal dashboard after every epoch using a custom callback that posts to a REST API.
- An autonomous vehicle team implements a safety-metric callback that stops training if a false-negative rate exceeds a regulatory threshold, saving compute and preventing deployment of unsafe models.
- A NLP startup builds a custom callback that dynamically lowers the learning rate based on a domain-specific perplexity metric, improving fine-tuning stability of transformer models.
Key takeaways
- Custom callbacks are subclassed from
tf.keras.callbacks.Callbackand let you hook into every phase of training. - The
logsdictionary gives you access to current metrics — uselogs.get('val_loss')to avoid KeyErrors. - You can halt training entirely by setting
self.model.stop_training = Trueinside a lifecycle method. - Prefer built-in callbacks when they fit; write custom ones when you need bespoke logic like saving N best models or custom alerts.
- Always test your callback with a tiny model and dummy data to confirm your hooks fire correctly.
- Optimize batch-level callbacks — they run thousands of times per epoch, so keep them lightweight.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.