Decorating a custom loss with tf.function changes the training results completely, both in keras model.fit method as well as custom training loop
ML System Design practice on Codemia
Design recommenders, ranking systems and training pipelines the way ML interviews actually ask for them, with worked solutions.
Introduction
If adding @tf.function to a custom loss changes training results dramatically, the problem is usually not the decorator itself. The real issue is that graph execution exposes hidden assumptions in the loss, such as Python side effects, NumPy calls, frozen control flow, or state that was only behaving by accident in eager mode.
What @tf.function Changes
Without @tf.function, TensorFlow executes operations eagerly, one line at a time. That feels like normal Python and is easy to debug. With @tf.function, TensorFlow traces the function and builds a graph. Later calls reuse that graph instead of rerunning Python directly.
That shift matters because Python behavior and TensorFlow behavior are not identical:
- Python values can be captured at trace time
- Python side effects may run only during tracing
- NumPy operations execute outside the TensorFlow graph
- control flow may be converted or frozen depending on what is traceable
Keras already compiles parts of training internally, so decorating the loss can introduce another graph boundary. If the loss is not purely tensor-based, the extra compilation step can expose bugs immediately.
A Common Source Of Wrong Results
The loss function should depend on tensors, not Python state that can silently change between batches. This example shows a bad pattern:
This surprises many people. The Python if can be fixed when the function is traced, so changing use_absolute_error later may not produce the behavior you expect. The graph keeps using the traced branch until retracing happens.
In eager mode, the same code appears to work because Python reevaluates the if each call.
Keep The Loss Pure And Tensor-Based
A safer custom loss uses only TensorFlow ops and its explicit inputs.
This version is graph-friendly because it does not depend on external Python state, lists, counters, or NumPy arrays created inside the loss. Keras can wrap it as needed during model.fit.
In most cases, start here: write a correct tensor-only loss first, and let Keras decide when to trace it.
@tf.function Belongs More Naturally On The Training Step
If you need graph speedups, place @tf.function on the training step rather than on a fragile loss implementation.
This pattern is easier to reason about. The loss stays a simple mathematical function, while the expensive repeated step gets compiled.
Other Reasons Results Can Drift
Even if the loss looks clean, results can still change if it includes behavior that is sensitive to tracing:
- random numbers generated with Python or NumPy instead of TensorFlow ops
- mutable Python containers updated inside the loss
- calls to
.numpy()inside graph-traced code - shape-dependent branches that retrace unpredictably
- mixed precision or dtype conversions that differ between code paths
For example, a NumPy random sample inside a traced function may be computed during tracing instead of every step. That turns a dynamic loss term into a constant, which can absolutely change optimization behavior.
If you need randomness in the graph, use TensorFlow randomness:
If you need metrics, logging, or counters, keep them outside the loss or implement them with TensorFlow primitives designed for graph execution.
How To Debug The Difference
When a loss behaves differently under @tf.function, reduce the problem:
- run the loss eagerly on fixed inputs
- run the same loss under
@tf.functionon the same inputs - compare outputs before involving the optimizer or model training
If the outputs already differ, the bug is inside the loss. If they match, the difference is probably elsewhere in the training step, such as data shuffling, randomness, metric updates, or optimizer state.
During debugging, forcing eager execution can help:
Use that only as a temporary diagnostic tool. It is helpful for isolating graph-related assumptions, not as a permanent performance setting.
Common Pitfalls
- Decorating the loss first instead of making it tensor-pure first.
- Using Python
ifstatements that depend on changing external values. - Calling NumPy or
.numpy()inside code expected to run as a graph. - Mixing bookkeeping side effects with the mathematical loss computation.
- Assuming
model.fitand a custom loop will behave identically if the loss contains hidden Python state.
Summary
- '
@tf.functionchanges execution from eager Python to a traced TensorFlow graph.' - Large result changes usually mean the loss relied on Python behavior that does not translate cleanly to graph execution.
- Write custom losses as pure tensor functions with no hidden external state.
- Put
@tf.functionon the training step when you want performance and clearer control. - Compare eager and traced outputs on fixed inputs to find the exact source of divergence.
Related reading
- Deep Learning model with Different data types in Keras
- Default Adam optimizer doesn't work in tf.keras but string adam does
- Deleting all but a few nodes in TensorFlow graph
- ''Dense'' object has no attribute ''op''
- Deep-Learning Nan loss reasons
- Deep-Learning Nan loss reasons
- ''Dense'' object has no attribute ''op''
- Deploy pre-trained Inception in TensorflowServing fails SavedModel has no variables
.png&w=3840&q=75)
Tackling System Design Interview Problems
A short course that equips you with the skills to approach system design interviews methodically.
Start the free courseTrack what you have practised
A free account saves your progress, solutions and study plan across every problem on Codemia.
ML System Design practice on Codemia
Design recommenders, ranking systems and training pipelines the way ML interviews actually ask for them, with worked solutions.