Training & Adjoints¶
Training a learned solver means differentiating through an inner optimisation loop, and how you do that determines memory cost and gradient quality. The adjoint strategies here make that choice explicit and swappable — see Adjoint Methods in the Mathematical Reference for the trade-offs. Around them sit the loss functions and train/eval steps for 4DVarNet and the amortized posteriors, and the ConvLSTM gradient modulators that 4DVarNet learns in place of a hand-tuned inner optimiser.
Adjoint strategies¶
Implementations of vardax.adjoints (Decision D15): full
backpropagation with checkpointed memory (RecursiveCheckpointAdjoint),
truncated one-step gradients (OneStepAdjoint, pairing with the
one_step_solve_* solver functions), and
implicit differentiation at a fixed point (ImplicitAdjoint, pairing with
solve_4dvarnet_1d_fixedpoint). All three are also accessible via the
vardax.adjoints submodule namespace. Use
assert_adjoint_calibrated to verify a cheap adjoint against
the exact one before trusting it.
RecursiveCheckpointAdjoint and ImplicitAdjoint are re-exported from
optimistix for one-stop import;
see the optimistix documentation for their full signatures:
vardax.RecursiveCheckpointAdjoint—optimistix.RecursiveCheckpointAdjoint, exact reverse-mode backpropagation through the unrolled inner loop with binomial checkpointing (the default).vardax.ImplicitAdjoint—optimistix.ImplicitAdjoint, implicit-function-theorem differentiation at a fixed point; pair withsolve_4dvarnet_1d_fixedpoint.
KStepAdjoint(k) is vardax's own truncated adjoint — warmup under
stop_gradient, then k differentiable steps; OneStepAdjoint is the
k=1 alias (Bolte, Pauwels &
Vaiter, NeurIPS 2023):
vardax — Modular variational data assimilation with learned components.
All public symbols are re-exported from the private _src subpackage so
that user code imports from the top-level namespace:
KStepAdjoint
¶
Bases: AbstractAdjoint
K-step differentiation: warmup under stop_gradient, then k live steps.
For a solver iteration \(z_{j+1} = T(z_j)\) run \(n\) times, the gradient flows only through the last \(k\) steps,
where \(\bar z_{n-k}\) is the iterate after the warmup, held fixed.
Algorithm
- Run the first \(n - k\) solver iterations under
jax.lax.stop_gradient. - Run the last \(k\) iterations differentiably.
- Backpropagate through those \(k\) iterations only, at memory cost \(O(k)\).
Use as the solver_adjoint argument to
FourDVarNet1D /
FourDVarNet2D:
from vardax.adjoints import KStepAdjoint
model = FourDVarNet1D(
state_dim=N, n_time=T, ...,
solver_adjoint=KStepAdjoint(k=3),
key=key,
)
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
If |
Attributes:
| Name | Type | Description |
|---|---|---|
k |
int
|
Number of trailing solver iterations that propagate
gradients. Must be at least 1; values larger than
|
Examples:
k=1 is the one-step estimator, and OneStepAdjoint is a
subclass.
A non-positive k is rejected.
>>> vardax.KStepAdjoint(k=0)
Traceback (most recent call last):
...
ConfigurationError: KStepAdjoint needs k >= 1; got k=0.
References
Bolte, J., Pauwels, E. & Vaiter, S. (2023). One-step differentiation of iterative algorithms. NeurIPS 36. arXiv:2305.13768.
Source code in src/vardax/_src/adjoints/k_step.py
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | |
apply
¶
apply(
primal_fn: Callable,
rewrite_fn: Callable,
inputs: PyTree,
tags: frozenset[object],
) -> PyTree[Array]
Not used by vardax's custom learned solver — dispatch happens
in vardax._src.solver via isinstance on the adjoint type.
Implementing this method for upstream optimistix.minimise
compatibility is tracked under the planned upstream
contribution (Decision D6).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
primal_fn
|
Callable
|
The primal solve (unused). |
required |
rewrite_fn
|
Callable
|
The fixed-point rewrite (unused). |
required |
inputs
|
PyTree
|
The solve inputs (unused). |
required |
tags
|
frozenset[object]
|
Optimistix tags (unused). |
required |
Raises:
| Type | Description |
|---|---|
NotImplementedError
|
Always; select the adjoint through
|
Source code in src/vardax/_src/adjoints/k_step.py
OneStepAdjoint
¶
Bases: KStepAdjoint
One-step differentiation (Bolte et al., 2023).
Run K - 1 warmup iterations of the inner solver with
jax.lax.stop_gradient, then one differentiable step. Gives
O(1) memory and is exact at the fixed point of the inner
iteration.
where \(\bar z_{K-1}\) is the iterate after the warmup, held fixed.
Algorithm
- Run \(K - 1\) solver iterations under
jax.lax.stop_gradient. - Run the last iteration differentiably.
- Backpropagate through that single step.
Use as the solver_adjoint argument to
FourDVarNet1D /
FourDVarNet2D:
from vardax.adjoints import OneStepAdjoint
model = FourDVarNet1D(
state_dim=N, n_time=T, ...,
solver_adjoint=OneStepAdjoint(),
key=key,
)
Examples:
>>> import vardax
>>> adjoint = vardax.OneStepAdjoint()
>>> adjoint
OneStepAdjoint()
>>> adjoint.k
1
It is KStepAdjoint with k=1.
References
Bolte, J., Pauwels, E. & Vaiter, S. (2023). One-step differentiation of iterative algorithms. NeurIPS 36. arXiv:2305.13768.
Source code in src/vardax/_src/adjoints/one_step.py
to_optimistix_adjoint
¶
Map an adjoint spec onto an optimistix.AbstractAdjoint.
Algorithm
- Return
specunchanged if it already is anoptimistix.AbstractAdjoint. - Otherwise match it by class name against the mapping below.
- Raise for names that have no inner-solve counterpart.
Mapping:
ImplicitAdjoint()→optx.ImplicitAdjoint()— exact at a converged fixed point, O(1) memory, one Hessian linear solve.RecursiveCheckpointAdjoint(checkpoints)→optx.RecursiveCheckpointAdjoint(checkpoints)— exact, recomputing.TruncatedAdjoint(k)→KStepAdjoint(k)— warmup understop_gradient, thenkdifferentiable steps (k=1isOneStepAdjoint).DirectAdjoint/BacksolveAdjoint→UnsupportedInputError: the first is the plain unrolled default (passoptx.RecursiveCheckpointAdjoint()or nothing), the second only exists at the dynamics layer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
Any
|
A spec from |
required |
Returns:
| Type | Description |
|---|---|
AbstractAdjoint
|
The corresponding optimistix adjoint instance. |
Raises:
| Type | Description |
|---|---|
UnsupportedInputError
|
For layer-inappropriate or unrecognised specs. |
Examples:
>>> import optimistix as optx
>>> from pipekit_cycle.adjoints import TruncatedAdjoint
>>> from vardax.adjoints import to_optimistix_adjoint
>>> to_optimistix_adjoint(TruncatedAdjoint(k=3))
KStepAdjoint(k=3)
>>> isinstance(
... to_optimistix_adjoint(optx.ImplicitAdjoint()), optx.ImplicitAdjoint
... )
True
A pipekit TruncatedAdjoint(k=1) is the one-step adjoint.
>>> import vardax
>>> adjoint = to_optimistix_adjoint(TruncatedAdjoint(k=1))
>>> isinstance(adjoint, vardax.OneStepAdjoint)
False
>>> isinstance(adjoint, vardax.KStepAdjoint)
True
A spec with no inner-solve meaning is rejected.
>>> from pipekit_cycle.adjoints import DirectAdjoint
>>> to_optimistix_adjoint(DirectAdjoint())
Traceback (most recent call last):
...
UnsupportedInputError: DirectAdjoint does not apply at the inner-solve layer
Source code in src/vardax/_src/adjoints/mapping.py
Gradient modulators¶
The learned components of 4DVarNet: ConvLSTM cells that map the raw
variational-cost gradient to a descent update, satisfying the
GradModulator Protocol. Their recurrent state is carried
in the LSTMState1D / LSTMState2D containers.
vardax — Modular variational data assimilation with learned components.
All public symbols are re-exported from the private _src subpackage so
that user code imports from the top-level namespace:
ConvLSTMGradMod1D
¶
Bases: Module
1-D ConvLSTM-based gradient modulator.
Accepts the concatenation of the current state and its gradient as input and produces a modulated gradient update (and updated LSTM state). With \(g = \nabla J(u)\) and previous memory \((h, c)\) the gates are
and the update is \(W_o h'\).
Algorithm
- Stack the gradient over the state along the channel axis and apply the input convolution; add the convolution of the hidden state.
- Split into the input, forget, candidate and output gates.
- Update the cell state \(c'\) and the hidden state \(h'\).
- Map \(h'\) back to the state channels with the output convolution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state_channels
|
int
|
Number of channels in the state / gradient ( |
required |
hidden_dim
|
int
|
Number of hidden channels in the LSTM. |
required |
kernel_size
|
int
|
1-D convolution kernel size. |
3
|
key
|
PRNGKeyArray
|
PRNG key for the convolution weights. |
required |
Examples:
The update has the shape of the gradient, the memory the shape of the hidden state:
>>> import jax
>>> import jax.numpy as jnp
>>> import vardax as vdx
>>> mod = vdx.ConvLSTMGradMod1D(
... state_channels=3, hidden_dim=5, key=jax.random.PRNGKey(0)
... )
>>> grad = jnp.ones((2, 3, 4))
>>> update, lstm = mod(grad, grad, vdx.LSTMState1D.zeros(2, 5, 4))
>>> update.shape, lstm.hidden.shape
((2, 3, 4), (2, 5, 4))
The memory is carried across solver steps, so a second call with the returned state gives a different update:
Source code in src/vardax/_src/grad_mod.py
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | |
ConvLSTMGradMod2D
¶
Bases: Module
2-D ConvLSTM-based gradient modulator.
Same recurrence as ConvLSTMGradMod1D, with 2-D
convolutions: with \(g = \nabla J(u)\) and memory \((h, c)\),
and the update is \(W_o h'\).
Algorithm
- Stack the gradient over the state along the channel axis and apply the input convolution; add the convolution of the hidden state.
- Split into the input, forget, candidate and output gates.
- Update the cell state \(c'\) and the hidden state \(h'\).
- Map \(h'\) back to the state channels with the output convolution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state_channels
|
int
|
Number of time channels in the state / gradient. |
required |
hidden_dim
|
int
|
Number of hidden channels in the LSTM. |
required |
kernel_size
|
int
|
2-D convolution kernel size. |
3
|
key
|
PRNGKeyArray
|
PRNG key for the convolution weights. |
required |
Examples:
The update has the shape of the gradient:
>>> import jax
>>> import jax.numpy as jnp
>>> import vardax as vdx
>>> mod = vdx.ConvLSTMGradMod2D(
... state_channels=3, hidden_dim=2, key=jax.random.PRNGKey(0)
... )
>>> grad = jnp.ones((1, 3, 4, 5))
>>> update, lstm = mod(grad, grad, vdx.LSTMState2D.zeros(1, 2, 4, 5))
>>> update.shape, lstm.cell.shape
((1, 3, 4, 5), (1, 2, 4, 5))
A zero gradient and zero state still yield a finite update:
>>> zeros = jnp.zeros((1, 3, 4, 5))
>>> out, _ = mod(zeros, zeros, vdx.LSTMState2D.zeros(1, 2, 4, 5))
>>> bool(jnp.all(jnp.isfinite(out)))
True
Source code in src/vardax/_src/grad_mod.py
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 | |
Losses & steps¶
Outer-loop training: reconstruction-based losses for 4DVarNet, the
negative-log-likelihood loss for amortized posteriors, and the
optax-driven train_step / amortized_train_step / eval_step that
consume them.
vardax — Modular variational data assimilation with learned components.
All public symbols are re-exported from the private _src subpackage so
that user code imports from the top-level namespace:
reconstruction_loss
¶
Mean-squared reconstruction loss.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pred
|
Float[Array, ...]
|
Model predictions \(\hat x\). |
required |
target
|
Float[Array, ...]
|
Ground-truth targets \(x\), broadcastable against |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar mean-squared error. |
Examples:
>>> import jax.numpy as jnp
>>> import vardax as vdx
>>> float(vdx.reconstruction_loss(jnp.array([1.0, 3.0]), jnp.array([1.0, 1.0])))
2.0
A perfect prediction has zero loss, whatever the shape:
Source code in src/vardax/_src/training.py
train_loss_fn
¶
Compute the training loss for a single batch.
Algorithm
- Run the model on the batch to obtain the prediction.
- Compare it with
batch.targetusingreconstruction_loss.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
Equinox module implementing |
required |
batch
|
Batch1D | Batch2D
|
Training batch (must have a non- |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar reconstruction loss. |
Raises:
| Type | Description |
|---|---|
MissingInputError
|
If |
Examples:
>>> import jax
>>> import jax.numpy as jnp
>>> import optax
>>> import vardax as vdx
>>> model = vdx.FourDVarNet1D(
... state_dim=4,
... n_time=3,
... latent_dim=2,
... hidden_dim=3,
... n_solver_steps=1,
... key=jax.random.PRNGKey(0),
... )
>>> ones = jnp.ones((2, 3, 4))
>>> batch = vdx.Batch1D(input=ones, mask=ones, target=jnp.zeros((2, 3, 4)))
>>> vdx.train_loss_fn(model, batch).shape
()
It differentiates with respect to the model, as train_step does:
>>> import equinox as eqx
>>> loss, grads = eqx.filter_value_and_grad(vdx.train_loss_fn)(model, batch)
>>> bool(jnp.isfinite(loss))
True
A batch without a target is rejected:
>>> unlabelled = vdx.Batch1D(input=ones, mask=ones)
>>> vdx.train_loss_fn(model, unlabelled)
Traceback (most recent call last):
...
MissingInputError: train_loss_fn requires batch.target to be set.
Source code in src/vardax/_src/training.py
train_step
¶
train_step(
model: Any,
batch: Batch1D | Batch2D,
optimizer: GradientTransformation,
opt_state: OptState,
) -> tuple[Any, OptState, Float[Array, ""]]
Perform a single training step (forward + backward + update).
This is the correctness-critical primitive: gradients flow through
the FourDVarNet inner solver according to whichever differentiation
strategy (the model's solver_adjoint) the model is configured with.
Users should compose this primitive into their training loop
(notebook-level or pipekit_train.TrainingLoop) rather than
reimplementing it.
Algorithm
- Evaluate
train_loss_fnand its gradient with respect to the model parameters. - Turn the gradient into an update with the optax optimiser.
- Apply the update to the model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
Equinox module to optimise. |
required |
batch
|
Batch1D | Batch2D
|
Training batch. |
required |
optimizer
|
GradientTransformation
|
Optax gradient transformation (e.g. |
required |
opt_state
|
OptState
|
Current optimiser state. |
required |
Returns:
| Type | Description |
|---|---|
Any
|
The updated model, the updated optimiser state and the scalar loss |
OptState
|
(evaluated before the update). |
Raises:
| Type | Description |
|---|---|
MissingInputError
|
If |
Examples:
>>> import jax
>>> import jax.numpy as jnp
>>> import equinox as eqx
>>> import optax
>>> import vardax as vdx
>>> model = vdx.FourDVarNet1D(
... state_dim=4,
... n_time=3,
... latent_dim=2,
... hidden_dim=3,
... n_solver_steps=1,
... key=jax.random.PRNGKey(0),
... )
>>> ones = jnp.ones((2, 3, 4))
>>> batch = vdx.Batch1D(input=ones, mask=ones, target=jnp.zeros((2, 3, 4)))
>>> optimizer = optax.adam(1e-2)
>>> opt_state = optimizer.init(eqx.filter(model, eqx.is_array))
>>> new_model, opt_state, loss = vdx.train_step(
... model, batch, optimizer, opt_state
... )
>>> loss.shape
()
The step returns the pre-update loss, so it equals eval_step on the
old model, and the parameters have moved:
>>> bool(jnp.allclose(loss, vdx.eval_step(model, batch)))
True
>>> bool(eqx.tree_equal(new_model, model))
False
Source code in src/vardax/_src/training.py
eval_step
¶
Compute the evaluation loss for a single batch (no gradient).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
Equinox module implementing |
required |
batch
|
Batch1D | Batch2D
|
Evaluation batch (must have a non- |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar reconstruction loss. |
Raises:
| Type | Description |
|---|---|
MissingInputError
|
If |
Examples:
>>> import jax
>>> import jax.numpy as jnp
>>> import optax
>>> import vardax as vdx
>>> model = vdx.FourDVarNet1D(
... state_dim=4,
... n_time=3,
... latent_dim=2,
... hidden_dim=3,
... n_solver_steps=1,
... key=jax.random.PRNGKey(0),
... )
>>> ones = jnp.ones((2, 3, 4))
>>> batch = vdx.Batch1D(input=ones, mask=ones, target=jnp.zeros((2, 3, 4)))
>>> vdx.eval_step(model, batch).shape
()
It agrees with the training loss on the same batch:
>>> bool(
... jnp.allclose(
... vdx.eval_step(model, batch), vdx.train_loss_fn(model, batch)
... )
... )
True
A batch without a target is rejected:
>>> vdx.eval_step(
... model, vdx.Batch1D(input=ones, mask=ones)
... )
Traceback (most recent call last):
...
MissingInputError: eval_step requires batch.target to be set.
Source code in src/vardax/_src/training.py
amortized_nll_loss_fn
¶
Negative log-likelihood for amortized inference (Epic 8).
For AmortizedPosterior with flow / regression heads the maximum-
likelihood objective on simulated pairs is
Algorithm
- Evaluate \(\log q_\phi(x \mid y)\) for each sample with
model.log_prob(batch.target, batch). - Return the negative batch mean.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
|
required |
batch
|
Batch1D | Batch2D
|
Training batch with |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar NLL averaged over the batch. |
Raises:
| Type | Description |
|---|---|
MissingInputError
|
If |
Examples:
>>> import jax
>>> import jax.numpy as jnp
>>> import optax
>>> import vardax as vdx
>>> key = jax.random.PRNGKey(0)
>>> posterior = vdx.AmortizedPosterior(
... encoder=vdx.MLPObsEncoder(input_size=12, context_dim=6, key=key),
... head=vdx.RegressionHead(context_dim=6, state_shape=(3, 4), key=key),
... config=vdx.AmortizedConfig(),
... )
>>> ones = jnp.ones((2, 3, 4))
>>> batch = vdx.Batch1D(input=ones, mask=ones, target=jnp.zeros((2, 3, 4)))
>>> vdx.amortized_nll_loss_fn(posterior, batch).shape
()
It is the negative mean of the per-sample log-density:
>>> expected = -jnp.mean(posterior.log_prob(batch.target, batch))
>>> bool(jnp.allclose(vdx.amortized_nll_loss_fn(posterior, batch), expected))
True
The true state is required:
>>> blind = vdx.Batch1D(input=ones, mask=ones)
>>> vdx.amortized_nll_loss_fn(
... posterior, blind
... )
Traceback (most recent call last):
...
MissingInputError: amortized_nll_loss_fn requires batch.target ...
Source code in src/vardax/_src/training.py
amortized_train_step
¶
amortized_train_step(
model: Any,
batch: Batch1D | Batch2D,
optimizer: GradientTransformation,
opt_state: OptState,
) -> tuple[Any, OptState, Float[Array, ""]]
Single training step for AmortizedPosterior.
Same shape as train_step but uses
amortized_nll_loss_fn instead of the MSE
reconstruction loss. Use this for simulation-based training of amortized
variants; use train_step for FourDVarNet and classical models that
reconstruct fields.
Algorithm
- Evaluate the NLL and its gradient with respect to the parameters.
- Turn the gradient into an update with the optax optimiser.
- Apply the update to the model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
|
required |
batch
|
Batch1D | Batch2D
|
Training batch with |
required |
optimizer
|
GradientTransformation
|
Optax gradient transformation. |
required |
opt_state
|
OptState
|
Current optimiser state. |
required |
Returns:
| Type | Description |
|---|---|
Any
|
The updated model, the updated optimiser state and the scalar NLL |
OptState
|
(evaluated before the update). |
Raises:
| Type | Description |
|---|---|
MissingInputError
|
If |
Examples:
>>> import jax
>>> import jax.numpy as jnp
>>> import optax
>>> import vardax as vdx
>>> key = jax.random.PRNGKey(0)
>>> posterior = vdx.AmortizedPosterior(
... encoder=vdx.MLPObsEncoder(input_size=12, context_dim=6, key=key),
... head=vdx.RegressionHead(context_dim=6, state_shape=(3, 4), key=key),
... config=vdx.AmortizedConfig(),
... )
>>> ones = jnp.ones((2, 3, 4))
>>> batch = vdx.Batch1D(input=ones, mask=ones, target=jnp.zeros((2, 3, 4)))
>>> import equinox as eqx
>>> optimizer = optax.adam(1e-2)
>>> opt_state = optimizer.init(eqx.filter(posterior, eqx.is_array))
>>> new, opt_state, loss = vdx.amortized_train_step(
... posterior, batch, optimizer, opt_state
... )
>>> loss.shape
()
A few steps reduce the loss on a fixed batch:
>>> first = loss
>>> for _ in range(5):
... new, opt_state, loss = vdx.amortized_train_step(
... new, batch, optimizer, opt_state
... )
>>> bool(loss < first)
True