The sweet spot of customization
Overriding train_step() is the first rung you should reach for: you replace exactly one method — the per-batch forward/backward logic — and inherit everything else. The progress bar, callbacks, validation pass, and distribution strategy all keep working because fit() still drives the loop; it just calls your step instead of the built-in one.
The four moves inside the step
Every train_step() does the same four things: unpack the batch, run a forward pass to get y_pred, compute the loss with self.compute_loss(), then compute and apply gradients. You finish by updating the metric objects and returning a name → value dict — that dict is exactly what fit() prints in the progress bar. The code below shows the TensorFlow-backend version; the gradient mechanics are where the backend leaks through (see the warning).