Skip to content

Independent batches and normalization cost

Reaction callbacks enforce the declared template_shapes: a scalar state expects shape (), and an undeclared leading batch dimension raises an error with the reaction, state name, expected shape and received shape. Declare a categorical replicate axis for batched reactions. Passing arrays to a scalar specification does not implicitly add an independent replicate dimension.

For independent-clock stochastic engines, each reaction publishes independent_axes: declared factorize axes checked against that reaction's own propensity and coordinate mapping, before deterministic cancellation. An engine must also verify that all evolving state templates carry its chosen replicate axis and that no dynamics are omitted by reaction gaps or operators.

resolved_propensity_fn accepts current-time parameter slices in place of full time tables. Its time_varying_params lists consumed table names and full axes, so a consumer can select a different previous-table row for every replicate. This callback performs no interpolation or cache lookup. The ordinary propensity_fn still accepts full tables and scalar time. uses_time identifies explicit continuous-time dependence; declared table knots alone do not make such a propensity suitable for frozen-rate exact SSA.

A categorical axis can index independent parameter draws as well as model groups. Normalization expands the declared state cells and resolves their equations and parameter coordinates; it occurs before numerical evaluation or backend JIT compilation. The compiler caches lookup maps across these cells, so state membership, template selection, and coordinate lookup no longer repeat a scan over the entire axis for each reference.

For large independent batches, another useful option is to compile one block and map it over the batch. Declare factorize_axes only when no equation reduces, shifts, or mixes across the proposed batch axis. The compiler checks this condition and exposes block_pytree_eval_fn with that axis removed. Slice group-local parameters along with the state, and share global parameters:

import jax
import jax.numpy as jnp
from op_system import compile_spec

compiled = compile_spec({
    "kind": "expr",
    "axes": [{"name": "draw", "coords": ["first", "second"]}],
    "factorize_axes": ["draw"],
    "state": ["X[draw]"],
    "equations": {"X[draw]": "-shared * rate[draw] * X[draw]"},
})
block = compiled.block_pytree_eval_fn
assert block is not None


def evaluate_one(x, rate):
    return block(0.0, {"X": x}, shared=0.5, rate=rate)["X"]


values = jnp.array([1.0, 2.0, 3.0])
rates = jnp.array([0.2, 0.3, 0.4])
derivatives = jax.jit(jax.vmap(evaluate_one))(values, rates)

The numerical batch can have more draws than the small compilation fixture: the block evaluator contains no draw dimension. JAX mapping requires array parameters whose mapped dimensions match the state. CPU Torch can use the same pattern through torch.func.vmap, with Torch tensors and array operations. Qualify the operations in the particular model before assuming additional compiler or GPU support.

Mapping blocks changes compilation and evaluation costs, and does not speed up every workload. An equation such as sum_over(X[draw:d], draw=d) couples the draws and requires the monolithic evaluator. Removing that coupling to obtain a block evaluator would change the model.

Local rate aliases retain the same equations in full and block evaluation. For example, foi[loc] = beta * r0_loc[loc] * I[loc] uses a shared beta and a group-local r0_loc. Supply the selected local rate with the corresponding single-group state; expression and transition specifications should produce the same cell flows. Required CPU qualification checks this behavior in float32 and float64, including JAX compilation of the stripped evaluator.