Overriding train_step
Overriding train_step lets you rewrite what happens inside one training step while keeping fit, callbacks and progress bars exactly as they were.
- 8 min read
- 3 reading levels
- Published
Read these first
On this page 5
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
Overriding train_step means replacing the recipe Keras cooks on each batch, while keeping all the kitchen service around it.
Think of a tiffin service that cooks and delivers lunch daily. The delivery vans, the packing, the daily schedule — all of it works well. Only the recipe needs changing, because you want less oil. You do not buy vans and start a delivery company. You hand the same kitchen your own recipe card.
fit is the tiffin service: batching, progress bars, callbacks, metric logging. train_step is the recipe — what happens to one batch. Keras lets you swap the recipe and keep the service.
Why it exists
Standard training is always the same five moves: predict, measure wrongness, find corrections, apply them, update the scoreboard. fit does these for you.
But some training schemes bend the moves:
- Add noise to inputs during training only, so the model stops memorising exact values.
- Train two networks against each other, like the generator and detective in a GAN.
- Clip corrections that are too large before applying them.
Before this feature, bending one move meant abandoning fit and hand-writing the whole loop — losing callbacks, progress bars and logging in the process. Overriding train_step keeps all of that and changes only the middle.
How it works
fit ──batch──▶ train_step:
1. predict on the batch
2. measure wrongness (loss) ← your changes
3. compute corrections go in here
4. apply corrections
5. update the scoreboard
◀─scores── callbacks, progress bar: unchangedYou subclass the Keras model and rewrite that one method. Everything outside the box stays stock.
A real example you have seen
Every image generator that makes faces descends from GAN training: the filters that age you, the apps that swap styles. In GAN training, two models take turns learning from each other. Turn-taking cannot be expressed as a plain "predict, correct" loop. Custom train steps are how such schemes run inside ordinary tooling.
Remember this
train_step= what happens to one batch.fit= everything around it.- Override the step to change the recipe, keep callbacks and logging free.
- Reach for it only when compile-and-fit truly cannot express your idea.
What to learn next
- GradientTape — the recording device the custom step leans on.
- GAN — the architecture that made custom steps famous.
- Gradient clipping — the most common thing people add to a step.
Developer — Code and libraries.
Setup
pip install tensorflow numpyOutputs verified with TensorFlow 2.21 (Keras 3.12), CPU, seed 3. This is TensorFlow-backend code: inside the step we use tf.GradientTape — meet it properly in GradientTape.
A train step with built-in input noise
The model below augments its inputs with small random noise during training only — a regulariser baked into the step, invisible at prediction time.
import numpy as np
import tensorflow as tf
import keras
from keras import layers
keras.utils.set_random_seed(3)
class NoisyTrainer(keras.Model):
"""A model that adds small input noise during training only —
a data augmentation baked into the training step."""
def train_step(self, data):
x, y = data
x = x + tf.random.normal(tf.shape(x), stddev=0.05)
with tf.GradientTape() as tape:
y_pred = self(x, training=True)
loss = self.compute_loss(y=y, y_pred=y_pred)
grads = tape.gradient(loss, self.trainable_variables)
self.optimizer.apply_gradients(zip(grads, self.trainable_variables))
for metric in self.metrics:
if metric.name == "loss":
metric.update_state(loss)
else:
metric.update_state(y, y_pred)
return {m.name: m.result() for m in self.metrics}
inputs = keras.Input(shape=(2,))
outputs = layers.Dense(1, activation="sigmoid")(layers.Dense(16, activation="relu")(inputs))
model = NoisyTrainer(inputs, outputs)
x = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]], dtype="float32")
y = np.array([[0.], [1.], [1.], [0.]], dtype="float32") # XOR
model.compile(optimizer=keras.optimizers.Adam(0.05),
loss="binary_crossentropy", metrics=["accuracy"])
history = model.fit(x, y, epochs=400, batch_size=4, verbose=0)
print("loss at epoch 1: ", round(history.history["loss"][0], 4))
print("loss at epoch 400:", round(history.history["loss"][-1], 4))
print("predictions:", np.round(model.predict(x, verbose=0).ravel(), 2))loss at epoch 1: 0.7381 loss at epoch 400: 0.0006 predictions: [0. 1. 1. 0.]
The walkthrough
The five standard moves are all visible. Forward pass under the tape, compute_loss, tape.gradient, apply_gradients, metric updates. This is exactly what stock Keras runs when you do not override — the override is your chance to insert changes between the moves. Ours is one line: the noise addition before the tape.
self.compute_loss(y=y, y_pred=y_pred) asks the model to apply whatever loss you passed to compile. Using it, rather than hard-coding a loss, keeps the override reusable — compile still decides the objective.
self(x, training=True) matters. Layers like Dropout and BatchNorm behave differently in training; the flag is how they know. Forget it and those layers run in inference mode during training — the same trap as train and eval mode in PyTorch.
The metrics loop keeps the scoreboard honest: the loss metric tracks the loss value, every other metric compares truth against prediction. The returned dict is what callbacks and the progress bar display — same contract as stock Keras.
Noise made XOR slower, not impossible. Clean XOR trains in far fewer epochs. With noise, inputs like 0.03, 0.97 stand in for 0, 1, so the model must learn a tolerant boundary. That tolerance is the regularising point.
Common mistakes
Overriding when compile-and-fit would do. Sample weighting, class weighting, gradient clipping (clipnorm=1.0 on any optimizer), and learning-rate schedules all have stock support. The override is for schemes that genuinely change the step's structure.
Computing the forward pass outside the tape. Move y_pred = self(x) above with tf.GradientTape(): and gradients come back as None — the tape never saw the computation. Everything the gradient must flow through goes inside the with block.
Forgetting test_step. evaluate and validation_split use a separate method, test_step. Override only train_step and validation still runs the stock path — correct here (we want no noise at validation), but wrong if your change affects the loss itself. Override both when the loss logic changes.
Returning nothing. The returned dict feeds logging and callbacks. Return nothing and EarlyStopping has no numbers to watch.
Try it yourself
Add gradient clipping inside the step: grads = [tf.clip_by_norm(g, 1.0) for g in grads] before apply_gradients. Confirm training still converges. Then check the stock alternative — delete the line and pass clipnorm=1.0 to the Adam constructor instead. Same effect, no override; background in gradient clipping.
What to learn next
- GradientTape — the recording device the custom step leans on.
- GAN — the architecture that made custom steps famous.
- Gradient clipping — the most common thing people add to a step.
Researcher — Mathematics and papers.
Input noise as explicit regularisation
Training on $\tilde{x} = x + \varepsilon$, $\varepsilon \sim \mathcal{N}(0, \sigma^2 I)$, changes the objective in expectation. Second-order Taylor expansion of the loss around clean inputs gives:
$$ \mathbb{E}\varepsilon\big[\ell(f\theta(x+\varepsilon), y)\big] \approx \ell(f_\theta(x), y) + \frac{\sigma^2}{2}\,\mathrm{tr}!\left(H_x\right) $$
Symbols: $\varepsilon$ — the injected noise; $\sigma$ — its standard deviation (0.05 in the code); $H_x$ — the Hessian of the loss with respect to the inputs; $\mathrm{tr}$ — the trace. For linear models with squared loss the penalty term reduces exactly to $\sigma^2 \lVert w \rVert^2$ — Gaussian input noise is L2 regularisation (Bishop 1995, Training with noise is equivalent to Tikhonov regularization). For deep nets the equivalence is approximate but the smoothing intuition carries: the model is pushed toward functions whose output varies slowly around the data.
The train_step contract
Keras wraps train_step in a compiled graph (one tf.function around the whole step — see tf.function and retracing), so the override must be traceable: no Python side effects that need to run per batch, no .numpy() calls inside. run_eagerly=True in compile disables the compilation for debugging at a 10–100× step-time cost.
Architecturally this is the template-method pattern: fit is the invariant algorithm; train_step, test_step, predict_step are its replaceable hooks. The GAN reference implementation in the Keras documentation overrides train_step to alternate discriminator and generator updates within one step — the canonical example of a scheme inexpressible as a single loss (Goodfellow et al. 2014, Generative adversarial nets).
Keras 3 nuance: the code above is the TensorFlow-backend form. On JAX or PyTorch backends the override signature differs (JAX passes explicit state), so a portable custom step must be written per backend — the one place Keras 3's backend abstraction visibly leaks.
References
- Bishop (1995), Training with noise is equivalent to Tikhonov regularization, Neural Computation 7(1).
- Goodfellow et al. (2014), Generative adversarial nets — the archetypal custom step.
- Gamma et al. (1994), Design Patterns — template method, the pattern being used.
What to learn next
- GradientTape — the recording device the custom step leans on.
- GAN — the architecture that made custom steps famous.
- Gradient clipping — the most common thing people add to a step.