Skip to content

ODE and SDE integration

Both solvers take a state array or pytree and return a trajectory with a leading axis for the requested times. Pass collect_trace=False for just the terminal state. Parameters go through positional *args; use a closure or partial for configuration. Keep method names and controller configuration static under JIT.

Choosing an ODE grid

Fixed-step methods such as rk4 step on the supplied grid. Adaptive methods such as dopri5 choose internal steps and return values at the requested times. StepSizeAdaptor controls tolerances for adaptive methods; fixed-step methods ignore it.

import jax
import jax.numpy as jnp
from probjax.utils import odeint
from probjax.utils.odeutil import StepSizeAdaptor

def decay(t, y, rate):
    return -rate * y

ts = jnp.linspace(0.0, 1.0, 17)
y0 = jnp.array([1.0, 2.0])
trajectory = odeint(
    decay, y0, ts, 0.5, solver="dopri5",
    step_size_adaptor=StepSizeAdaptor(rtol=1e-4, atol=1e-5),
)
assert trajectory.shape == (17, 2)
assert jnp.allclose(trajectory[-1], y0 * jnp.exp(-0.5), rtol=1e-3)
terminal = odeint(decay, y0, ts, 0.5, collect_trace=False)
assert terminal.shape == y0.shape

solver= takes a registered name, a solver class, or a configured solver: calling a solver class with only options configures it. Implicit solvers accept Newton options, for example a matrix-free Jacobian with an iterative linear solve (any linear_solver of probjax.utils.linear_operator.solve, including lineax solvers). Configured solvers compare by value, so they are safe static arguments under jit.

from probjax.utils.odeutil import implicit_euler

stiff_solver = implicit_euler(jacobian="matrix_free", linear_solver="gmres")
stiff_trajectory = odeint(decay, y0, ts, 50.0, solver=stiff_solver)
assert jnp.all(jnp.abs(stiff_trajectory[-1]) <= jnp.abs(y0))

The default computation dtype is float32. For float64, enable JAX x64 before creating arrays and pass dtype=jnp.float64. Tight tolerances alone cannot recover precision unavailable in the chosen dtype. Compare against a finer grid or tighter tolerances for the quantity you actually need.

Linear drifts

Wrap linear dynamics dy/dt = A y + b(t) in linear_drift to use the exact solvers linear_exact (ODE) and linear_exact_sde (SDE). A can be an array (0-d or 1-d for a diagonal, 2-d for a dense matrix), a structured LinearOperator, or a function of time returning either. The structure decides the cost: a Diagonal drift exponentiates elementwise, a Dense drift uses a matrix exponential, and a matrix-free Function must be densified with as_dense() first. Both solvers are exact for constant A and freeze time-varying A at each step start; dense drifts need vector states (calling the drift itself still acts by matmul on shaped states).

import jax.numpy as jnp
from probjax.utils import odeint
from probjax.utils.functions import linear_drift
from probjax.utils.linear_operator import Dense, Diagonal

grid = jnp.linspace(0.0, 1.0, 5)
start = jnp.array([1.0, 2.0])
diagonal_decay = linear_drift(A=Diagonal(jnp.array([-1.0, -0.5])))
trajectory = odeint(diagonal_decay, start, grid, method="linear_exact")
assert jnp.allclose(trajectory[-1], start * jnp.exp(jnp.array([-1.0, -0.5])))

rotation = linear_drift(A=Dense(jnp.array([[0.0, 1.0], [-1.0, 0.0]])))
terminal = odeint(rotation, start, grid, method="linear_exact", collect_trace=False)
assert jnp.allclose(jnp.linalg.norm(terminal), jnp.linalg.norm(start), rtol=1e-5)

Gradients

Fixed-grid integration differentiates the discrete stepping computation and supports the check_points option for checkpointed grid scans when collecting the trajectory. Its entries are nested scan lengths whose product equals the number of grid intervals, not checkpoint indices. Adaptive ODE integration currently uses a custom reverse-mode adjoint, whose backward path is unreliable (controller residuals and filtered-state handling). Treat adaptive gradients as unsupported; use a converged fixed-grid solve for differentiable examples below. There is no public adjoint= or gradient-mode selector, and forward-mode AD through that custom VJP is unsupported. Any repaired adjoint also needs gradient convergence checks separate from the forward solution.

def terminal_sum(rate):
    return jnp.sum(odeint(decay, y0, ts, rate, collect_trace=False))

gradient = jax.jit(jax.grad(terminal_sum))(jnp.asarray(0.5))
assert jnp.allclose(gradient, -jnp.sum(y0) * jnp.exp(-0.5), rtol=1e-3)

SDEs and adaptive noise

For fixed-step integration, select a method such as euler_maruyama, milstein or the stochastic Runge–Kutta methods sri1/sri2 (srk is an alias of sri1). Diffusion outputs determine noise layout: scalar/vector outputs represent diagonal noise; matrices can represent full, rectangular noise.

sde_type="stratonovich" is implemented by milstein. For state-dependent noise the other solvers (and adaptive stepping) raise NotImplementedError instead of silently solving the Itô equation; for additive noise (additive_diffusion, const_diffusion) both conventions agree and every solver works.

Supplying an SDE adaptor enables adaptive Euler–Maruyama step doubling, regardless of method. This adaptive path currently supports diagonal noise only.

Steps are never rejected on their own noise: the error estimate of a step depends on that step's Brownian increment, so keeping only low-error steps biases the path's quadratic variation and loses part of the Itô correction. Every step is accepted and its error only sets the next step size.

Controller Brownian increments Use
StrongStepSizeAdaptor One keyed virtual Brownian tree Pathwise comparisons and sample-wise objectives
WeakStepSizeAdaptor Fresh draws from the key Distributional experiments (moments, terminal densities)
from probjax.utils import sdeint
from probjax.utils.sdeutil import StrongStepSizeAdaptor

def noise(t, y, rate):
    return jnp.full_like(y, 0.1)

paths = sdeint(
    jax.random.key(0), decay, noise, y0, ts, 0.5,
    step_size_adaptor=StrongStepSizeAdaptor(rtol=1e-2, atol=1e-3),
)
assert paths.shape == (17, 2)
assert jnp.all(jnp.isfinite(paths))

The adaptive SDE inner loop is bounded by max_inner_steps and supports reverse AD through its scan. A larger budget costs more work, even after an interval finishes. Budget or step-size boundary hits can force completion outside the requested tolerance. Inspect eager warnings and test convergence; host warnings are skipped while tracing under jit/vmap. The strong controller never steps below 64 leaf widths of its Brownian tree (tree_depth, default 20), since the path inside a leaf is interpolated smoothly and carries no noise.

Use return_brownian=True with collect_trace=True to also return the Brownian trace. Low-level Brownian paths and iterated stochastic integrals live in probjax.utils.sdeutil.brownian; their layout and Itô/Stratonovich conventions must agree with the solver.

Inverse log-determinants

inverse_and_logabsdet can invert a terminal ODE map by integrating backward. The inverse log-determinant uses the integrated drift divergence. This is a numerical continuous-flow estimate, not an exact Jacobian determinant of the discrete stepping map. Inversion is useful only when the flow and its backward integration are well behaved.

trace_estimator="exact" computes full Jacobians. "hutchinson" uses JVPs with fixed random probes along the trajectory, trading computation for variance. Pass logdet_rng and increase num_samples to reduce variance. These options affect inverse log-determinant computation, not an ordinary forward solve.

from probjax.core import inverse_and_logabsdet

def flow(x):
    return odeint(
        decay, x, ts, 0.5, collect_trace=False,
        trace_estimator="hutchinson", logdet_rng=jax.random.key(1),
        num_samples=2,
    )

recovered, inverse_logdet = inverse_and_logabsdet(flow)(flow(y0))
assert jnp.allclose(recovered, y0, rtol=1e-3)
assert jnp.allclose(inverse_logdet, 1.0, atol=1e-3)

A callable trace_estimator(drift_flat, t, x_flat, args) can exploit an analytic or structured divergence. There is no public Jacobian-evaluation stride option; skipping evaluations is not a supported accuracy/performance control.