Skip to content

Author custom models

Nonlinear Gaussian, particle, and tempered methods represent a model by the callbacks needed for one inference algorithm. You do not need to subclass an smcx model or wrap distributions in an smcx object. The boundary consists of arrays, PyTrees, and, for stochastic algorithms, explicit PRNG keys. Exact linear-Gaussian models instead use the dense-array interface shown in the quickstart.

Compose a nonlinear Gaussian filter

The extended and unscented Kalman filters share two ordinary mean callbacks. The extended filter additionally takes explicit Jacobians:

# EKF and UKF
transition_mean(state) -> state_mean
observation_mean(state) -> observation_mean

# EKF only
transition_jacobian(state) -> (state_dim, state_dim)
observation_jacobian(state) -> (observation_dim, state_dim)

Jacobians use output-by-input orientation. Each Jacobian can be analytic or created explicitly by the caller with jax.jacfwd; smcx does not select an automatic-differentiation policy. This example mixes both forms:

import jax
import jax.numpy as jnp

import smcx


def transition_mean(state):
    return jnp.array([
        0.9 * state[0] + 0.1 * jnp.sin(state[1]),
        0.8 * state[1],
    ])


def transition_jacobian(state):
    return jnp.array([
        [0.9, 0.1 * jnp.cos(state[1])],
        [0.0, 0.8],
    ])


def observation_mean(state):
    return jnp.array([state[0] + 0.05 * state[1] ** 2])


observation_jacobian = jax.jacfwd(observation_mean)

emissions = jnp.array([[0.2], [-0.1], [0.4]])
posterior = smcx.extended_kalman_filter(
    jnp.zeros(2),
    jnp.eye(2),
    transition_mean,
    transition_jacobian,
    0.1 * jnp.eye(2),
    observation_mean,
    observation_jacobian,
    jnp.array([[0.3]]),
    emissions,
)

The UKF reuses the two mean functions without Jacobians:

unscented = smcx.unscented_kalman_filter(
    jnp.zeros(2),
    jnp.eye(2),
    transition_mean,
    0.1 * jnp.eye(2),
    observation_mean,
    jnp.array([[0.3]]),
    emissions,
)

Rule defaults are alpha=1.0, beta=2.0, and kappa=0.0.

The transition covariance may have shape (state_dim, state_dim) or (ntime - 1, state_dim, state_dim). The observation covariance may have shape (observation_dim, observation_dim) or (ntime, observation_dim, observation_dim). All arrays and callback outputs share one float32 or float64 dtype.

With inputs=..., every supplied callback accepts (state, input_t). inputs[t] reaches the observation at t and the transition into t; inputs[0] does not transform the supplied prior. A rank-one input sequence is presented to callbacks as a length-one vector. When compiling a complete filter, close the callbacks over in a jax.jit wrapper rather than passing them as dynamic array arguments.

Use the EKF to supply a local linearization; use the UKF to apply the fixed scaled sigma-point rule. Shared means let research code compare them without a model hierarchy or general sigma-point plug-in layer.

Choose particle callbacks for the algorithm

Most particle callbacks act on one particle. smcx maps them over the particle cloud; only an initial sampler creates the whole cloud at once. This keeps model representation with your application while smcx owns resampling, schedules, and evidence accounting.

Each callback-driven algorithm asks only for behavior it can use:

Algorithm Sampling Densities or weights
Bootstrap Initial cloud and transition Observation
Auxiliary Initial cloud and transition Observation and look-ahead
Guided Initial cloud and proposal Proposal, transition, and observation
Tempered SMC Initial cloud Prior and likelihood

Choose the algorithm first, then supply the callbacks shown in the table. smcx does not inspect a model object to discover optional capabilities.

Bind a plain JAX model

The following user-owned classes are one possible model representation. The method names are a convention for this recipe; smcx never imports the classes or inspects the methods.

import math
from typing import NamedTuple

import jax
import jax.numpy as jnp
import jax.random as jr
import smcx


class AR1Params(NamedTuple):
    rho: jax.Array
    process_scale: jax.Array
    observation_scale: jax.Array


class GaussianAR1:
    def sample_initial(self, key, num_particles, params):
        scale = params.process_scale / jnp.sqrt(1.0 - params.rho**2)
        return scale * jr.normal(key, (num_particles, 1))

    def sample_transition(self, key, state, params):
        noise = params.process_scale * jr.normal(key, state.shape)
        return params.rho * state + noise

    def log_observation(self, emission, state, params):
        scale = params.observation_scale
        residual = (emission[0] - state[0]) / scale
        return (
            -0.5 * residual**2 - jnp.log(scale) - 0.5 * math.log(2.0 * math.pi)
        )

A small factory binds the model representation and its parameters into the three callback signatures expected by smcx.bootstrap_filter:

def make_bootstrap_callbacks(model, params):
    def initial(key, num_particles):
        return model.sample_initial(key, num_particles, params)

    def transition(key, state):
        return model.sample_transition(key, state, params)

    def log_observation(emission, state):
        return model.log_observation(emission, state, params)

    return initial, transition, log_observation


model = GaussianAR1()
params = AR1Params(
    rho=jnp.asarray(0.95),
    process_scale=jnp.asarray(0.3),
    observation_scale=jnp.asarray(0.7),
)
initial, transition, log_observation = make_bootstrap_callbacks(model, params)

emissions = jnp.asarray([[0.2], [-0.1], [0.4]])
posterior = smcx.bootstrap_filter(
    jr.key(0),
    initial,
    transition,
    log_observation,
    emissions,
    num_particles=4_096,
)

The factory belongs to the application. An auxiliary or guided factory can return the extra callbacks required by that algorithm.

Choose when to resample

The four state-space particle filters accept either an ESS fraction or a caller-owned resampling criterion in resampling_threshold. The callback receives normalized log weights, the corresponding absolute ESS, and the zero-based emission index:

def every_fifth_step(log_weights, current_ess, time_index):
    del log_weights, current_ess
    return time_index % 5 == 0


posterior = smcx.bootstrap_filter(
    jr.key(0),
    initial,
    transition,
    log_observation,
    emissions,
    num_particles=4_096,
    resampling_threshold=every_fifth_step,
)

The result must be a Python Boolean or scalar JAX Boolean. The callback runs for time indices 1 through T - 1 and can be traced as part of the filter. Bootstrap and guided filters supply their carried weights and ESS. Auxiliary and Liu–West filters instead supply the normalized first-stage weights and their ESS, because those are the quantities governing ancestor selection. The numeric default 0.5 retains the strict rule ESS < 0.5 * N.

Compose a particle-filter kernel

Use smcx.run_particle_filter when a built-in filter does not provide the algorithmic pieces you want to combine. The runner accepts these callback contracts when there are no time-varying inputs:

initialize(time_index, emission_t, key_t) -> (carry, record)
step(carry, time_index, emission_t, key_t) -> (carry, record)

An input-aware kernel inserts input_t before key_t in both callbacks:

initialize(time_index, emission_t, input_t, key_t) -> (carry, record)
step(carry, time_index, emission_t, input_t, key_t) -> (carry, record)

The carry may be any JAX PyTree of arrays. Its structure, leaf shapes, and dtypes must remain fixed across steps because the runner uses jax.lax.scan. It is private execution state and is not included in the returned posterior. Each callback also returns the public standard record:

smcx.ParticleFilterRecord(
    particles,  # PyTree leaves: (num_particles, ...)
    normalized_log_weights,  # (num_particles,)
    ancestor_indices,  # (num_particles,), integer
    log_evidence_increment,  # scalar
)

The record describes the current time. Ancestor indices refer to the previous cloud; an identity map is conventional at time zero. The runner aligns emissions, optional inputs, and fresh keys; computes ESS; accumulates the evidence increments; and assembles smcx.ParticleFilterPosterior. The callbacks retain control of resampling, propagation, weighting, and the increment calculation. Weight normalization and ancestor-index bounds are callback preconditions.

This always-resampling bootstrap kernel composes only public smcx operations with the initial, transition, and log_observation callbacks defined above:

num_particles = 4_096


def weighted_record(particles, emission_t, ancestors):
    log_scores = jax.vmap(log_observation, in_axes=(None, 0))(
        emission_t, particles
    )
    log_weights, log_total = smcx.log_normalize(log_scores)
    increment = log_total - jnp.log(jnp.asarray(num_particles))
    record = smcx.ParticleFilterRecord(
        particles,
        log_weights,
        ancestors,
        increment,
    )
    return log_weights, record


def initialize_kernel(time_index, emission_t, key_t):
    del time_index
    particles = initial(key_t, num_particles)
    ancestors = jnp.arange(num_particles, dtype=jnp.int32)
    log_weights, record = weighted_record(
        particles,
        emission_t,
        ancestors,
    )
    return (particles, log_weights), record


def step_kernel(carry, time_index, emission_t, key_t):
    del time_index
    previous_particles, previous_log_weights = carry
    resample_key, transition_key = jr.split(key_t)
    ancestors = smcx.systematic(
        resample_key,
        smcx.normalize(previous_log_weights),
        num_particles,
    )
    selected = jax.tree.map(
        lambda leaf: leaf[ancestors],
        previous_particles,
    )
    particle_keys = jr.split(transition_key, num_particles)
    particles = jax.vmap(transition)(particle_keys, selected)
    log_weights, record = weighted_record(
        particles,
        emission_t,
        ancestors,
    )
    return (particles, log_weights), record


custom_posterior = smcx.run_particle_filter(
    jr.key(0),
    initialize_kernel,
    step_kernel,
    emissions,
)

Initialization receives time zero and the first emission. The step callback then receives times one through ntime - 1. With store_history=True, the posterior stores every particle record. With store_history=False, its particle, weight, and ancestor histories contain only the final record; ESS and evidence increments remain available for every time step.

Combine auxiliary selection with a guided proposal

The same runner can combine an auxiliary look-ahead log_m with a proposal q that sees the current emission. Keep normalized carried log weights W and use this core inside the step callback:

log_first, first_total = smcx.log_normalize(W + log_m)
do_resample = smcx.ess(log_first) < threshold * num_particles
ancestors = jax.lax.cond(
    do_resample,
    lambda: resampling_fn(
        resample_key, smcx.normalize(log_first), num_particles
    ),
    lambda: jnp.arange(num_particles, dtype=jnp.int32),
)
parents = jax.tree.map(lambda leaf: leaf[ancestors], previous_particles)
particles = jax.vmap(lambda key_i, parent: proposal(key_i, parent, emission_t))(
    particle_keys, parents
)
log_g = jax.vmap(lambda state: log_observation(emission_t, state))(particles)
log_f = jax.vmap(log_transition)(particles, parents)
log_q = jax.vmap(lambda state, parent: log_proposal(emission_t, state, parent))(
    particles, parents
)
log_step = log_g + log_f - log_q
log_scores = jnp.where(do_resample, log_step - log_m[ancestors], W + log_step)
log_weights, second_total = smcx.log_normalize(log_scores)
increment = jnp.where(
    do_resample,
    first_total + second_total - jnp.log(num_particles),
    second_total,
)

Return particles, log_weights, ancestors, and increment in a ParticleFilterRecord. The look-ahead correction appears only after first-stage resampling; without resampling, the ordinary guided score is W + log(g) + log(f) - log(q). In an input-aware step, pass the aligned input_t to the look-ahead, proposal, and all three density callbacks.

Write input-aware callbacks explicitly

Time-varying inputs use distinct callback signatures. At time zero, input_0 reaches the initial sampler and observation callback. At later times, input_t reaches the transition into that time and its observation. The smcx-facing input is always the final callback argument.

Use a separate factory for an input-aware model:

def make_input_aware_bootstrap_callbacks(model, params):
    def initial(key, num_particles, input_0):
        return model.sample_initial(key, num_particles, input_0, params)

    def transition(key, state, input_t):
        return model.sample_transition(key, state, input_t, params)

    def log_observation(emission, state, input_t):
        return model.log_observation(emission, state, input_t, params)

    return initial, transition, log_observation

Choose make_bootstrap_callbacks or make_input_aware_bootstrap_callbacks when configuring the run. Keeping both forms visible prevents an ambiguous runtime dispatch and makes input alignment part of the model code.

Keep the two PyTree roles separate

There are two useful, different PyTrees at this boundary.

The latent-state PyTree is one particle's evolving state. Bootstrap, auxiliary, and guided filters accept a nonempty PyTree of arrays. The initial sampler adds a leading particle axis to every leaf, and each transition preserves the tree structure, leaf shapes, and dtypes. smcx carries and resamples this tree as inference state.

The model or parameter PyTree belongs to the application. Fixed model values can be closed over by the callbacks, as params is above. Algorithms whose mutation kernels require Euclidean geometry still take dense parameter vectors; a user-owned codec can decode those vectors inside conditioned callbacks.

Close over a fixed model rather than copying it into every latent particle. Replacing closed-over array values can make JAX retrace or recompile the filter, so pass frequently changing values through an explicit argument.

Replace the tempering mutation

smcx.temper accepts one caller-owned invariant mutation through a paired structural callback boundary:

mutation_init(position, tempered_logdensity_fn) -> state
mutation_step(key, state, tempered_logdensity_fn) -> (state, info)

State is a JAX PyTree with a dense vector position; info is a JAX PyTree with a scalar floating acceptance_rate. NamedTuples are a convenient representation, and either object may carry extra fields. The target passed to both callbacks is the current stage density log_prior + phi * log_likelihood.

posterior = smcx.temper(
    jr.key(0),
    initial,
    log_prior,
    log_likelihood,
    num_particles=4_096,
    mutation_init_fn=mutation_init,
    mutation_step_fn=mutation_step,
)

smcx batches independent states across particles and compiles the fixed-count sweep; temper itself remains host-driven. Mutation state is reinitialized after each resampling stage. The caller is responsible for making each step invariant for the supplied target. Omitting both callbacks selects the existing cloud-adaptive random-walk Metropolis mutation. Pass ordinary callbacks rather than pre-jitting a function that accepts the target callable.

Optional Equinox representation

If an application already uses Equinox, a callable module can be captured by the same closures. This example targets equinox==0.13.8; Equinox is not an smcx dependency. See the Equinox Module documentation for its PyTree behavior.

import math

import equinox as eqx
import jax
import jax.numpy as jnp
import jax.random as jr
import smcx


class LinearGaussianTransition(eqx.Module):
    rho: jax.Array
    process_scale: jax.Array

    def __call__(self, key, state):
        noise = self.process_scale * jr.normal(key, state.shape)
        return self.rho * state + noise


def make_equinox_bootstrap_callbacks(
    transition_model,
    initial_scale,
    observation_scale,
):
    def initial(key, num_particles):
        return initial_scale * jr.normal(key, (num_particles, 1))

    def transition(key, state):
        return transition_model(key, state)

    def log_observation(emission, state):
        residual = (emission[0] - state[0]) / observation_scale
        return (
            -0.5 * residual**2
            - jnp.log(observation_scale)
            - 0.5 * math.log(2.0 * math.pi)
        )

    return initial, transition, log_observation


transition_model = LinearGaussianTransition(
    rho=jnp.asarray(0.95),
    process_scale=jnp.asarray(0.3),
)
initial, transition, log_observation = make_equinox_bootstrap_callbacks(
    transition_model,
    initial_scale=jnp.asarray(1.0),
    observation_scale=jnp.asarray(0.7),
)
emissions = jnp.asarray([[0.2], [-0.1], [0.4]])
posterior = smcx.bootstrap_filter(
    jr.key(0),
    initial,
    transition,
    log_observation,
    emissions,
    num_particles=4_096,
)

The factory exposes the same three callback signatures as the plain JAX version. No Equinox-specific adapter is needed.