Build train steps from objective functions - #669
Open
seanmor5 wants to merge 1 commit into
Open
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
polvalente
approved these changes
Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #595.
Axon.Loop.train_step/4andAxon.Loop.trainer/4only accepted a loss of the formloss(y_true, y_pred). Anything that needs more than one prediction and its targets, such as a contrastive objective that runs the model on an anchor and a positive, a penalty computed from the parameters, or any objective that wants to see the whole model state, had to re-implement the entire training step.notebooks/vision/metric-learning.livemddid exactly that: sixty lines ofbatch_step/initboilerplate to express a four-line objective with two forward passes.Both functions now accept an arity-4 objective function anywhere they accept a loss:
The objective receives the model's training-mode forward function, so users never build the model a second time or remember
mode: :train, the%Axon.ModelState{}being trained, and the batch. It returns the unscaled loss and the forward output to use as the prediction, which must be the%{prediction: _, state: _}map the forward returns in training mode. That keepsy_predavailable to metrics and letsAxon.ModelState.update/3merge the updated layer state (batch norm statistics, dropout keys) back as before. With several forward passes the user picks which output counts as the prediction. Dispatch is by arity: an arity-4 function is an objective, everything else goes through the existingbuild_loss_fn/1, which now wraps the loss in the default objective{loss_fn.(targets, prediction), forward_fn.(model_state, inputs)}. The step's return value is checked at trace time, so an objective that returns only the loss raises a clearArgumentErrorbefore anything is compiled.This also removes the "trainable parameters as grad" hack the issue calls out. The step now differentiates with respect to the whole
%Axon.ModelState{}(it is anNx.Containerwhoseparameters/state/frozen_parametersmetadata are kept fields) and selects the trainable subset of the resulting gradient struct withAxon.ModelState.trainable_parameters/1, so the optimizer sees exactly the parameters its state was initialised from. Gradients of frozen parameters and layer state are dead nodes and are eliminated by the compiler;Axon.ModelState.update/3still passes frozen leaves through untouched, so buffer donation behaves as before. This is what lets an objective readmodel_state.data[...]directly and have it differentiated. The privatetree_merge/3inAxon.Looponly existed for the hack and is gone.For loops built from an objective,
trainer/4reportslossfrom the running average the step keeps in its state instead of recomputing it fromy_true/y_pred, which is whatbuild_batch_fn/2already did for thelossmetric of a plain loss.Limitations
validate/4re-attaches training metrics to the evaluator with the default[:y_true, :y_pred]transform. An objective's loss cannot be recomputed that way, sovalidate/4now only carries over metrics given as an atom or an arity-2 function and a loop trained with an objective getsvalidation_<metric>for its other metrics but novalidation_loss. Previously any other metric arity crashed with aBadArityErrorat the end of the first epoch, so this is not a regression. Anevaluator/eval_stepthat accepts an objective is a natural follow-up.init_fnnow traces the objective rather than just the forward pass to learn the prediction shape, since with a user objective that is the only way to know it. Only shapes are kept, nothing is lowered, but it does mean the targets handed toinit_fnmust be ones the loss can consume. Two existing tests passed dummy targets of the wrong shape toinit_fndirectly and were updated;Axon.Loop.run/4always initialises from a real batch so this does not affect loops.Docs
train_step/4gains an "Objective functions" section with the contract and two examples,trainer/4a "Custom objective" example and thevalidate/4note,validate/4documents which metrics carry over, the custom-loss guide gains a "Using custom objectives in training loops" section, and the metric-learning notebook now usesAxon.Loop.trainer(model, &objective_fn/4, :adam)with an objective that returns the similarity logits as the prediction instead of its hand-written step.Tests
test/axon/loop_test.exs:train_step/3with an objective that runs two forward passes plus a parameter penalty: step state shape and a decreasing loss over five steps.train_step/3with an objective equivalent to:mean_squared_errorproduces the same parameters, loss and loss-scale state as the loss form, for both:identityand:dynamicloss scaling.ArgumentErrorat init.trainable_parameters/1on the gradient struct.trainer/3with an objective runs end to end with alossand a:mean_absolute_errormetric; an arity-3 function is rejected with a message pointing at the objective form.validate/4on an objective loop reportsvalidation_mean_absolute_errorand novalidation_loss.All nine fail against
main'slib/axon/loop.ex.mix test: 886 passed, 47 excluded.USE_EXLA=1 mix test test/axon/loop_test.exs: 70 passed, which covers the buffer-donation tests through the rewritten gradient path.🤖 Generated with Claude Code