A tour of structure-agnostic and structure-aware tooling
State-space models, everywhere:
Inference software: scattered across ecosystems, philosophies, levels of abstraction.
Goal: a map of what exists, and our efforts to form a real probabilistic programming ecosystem for SSMs in Python.
cuthbert and dynestyxcuthbert in the wild: predicting the World CupA latent Markov process \(x_t\), observed indirectly through \(y_t\):
\[ x_t \mid x_{t-1} \sim p(x_t \mid x_{t-1}) \qquad y_t \mid x_t \sim p(y_t \mid x_t) \]
Three inference targets, recurring throughout:
| Ecosystem | Character |
|---|---|
| R | Mature, classical/Bayesian SSM tooling: KFAS, dlm, bsts |
| Julia | General PPLs (Turing.jl), growing SSM-specific support |
| Python | Richest ecosystem, most fragmented: general PPLs and SSM-specific libraries side by side |
Rest of the talk: Python.
cuthbert, dynestyx, dynamax)Agnostic (Stan): the recursion, written by hand.
Aware, general-graph (TensorFlow Probability): the SSM is a first-class Distribution without any hand-rolled recursion.
Same split shows up elsewhere: R’s KFAS is declarative like TFP here; PyMC’s pytensor.scan is hand-rolled like Stan, though its newer pymc-extras.statespace layer grows an SSM-native shortcut on top, but more focused on the model definition (classical time series superposition).
Time-decomposition often allows for algorithms that run in \(O(T)\) rather than \(O(T^3)\), or \(O(\log T)\) rather than \(O(T)\), both in memory and in compute (depending on the hardware). The SSM factorization is a strong inductive bias: it allows for inference methods that are computationally optimal.
Karjalainen, Lee, Singh & Vihola, “On the Forgetting of Particle Filters” (arXiv:2309.08517): the SSM / Feynman–Kac mixing structure gives particle filters a provably optimal \(O(\log N)\) forgetting of initialisation. Generic MCMC/VI on an unstructured joint posterior has no analogue of this guarantee.
And it’s not just theory: Rao–Blackwellization, finite/discrete truncations, sigma-points methods, and particle methods all pre-date today’s SSM-agnostic software, born out of application-driven approximation rather than a “one algorithm fits any density” philosophy.
JAX library, structure-aware, SSM-specific. Model classes, with inference methods attached to each:
import jax.random as jr
from dynamax.hidden_markov_model import GaussianHMM
key1, key2, key3 = jr.split(jr.PRNGKey(0), 3)
num_states, emission_dim, num_timesteps = 3, 2, 1000
hmm = GaussianHMM(num_states, emission_dim)
true_params, _ = hmm.initialize(key1)
true_states, emissions = hmm.sample(true_params, key2, num_timesteps)
params, props = hmm.initialize(key3, method="kmeans", emissions=emissions)
params, lls = hmm.fit_em(params, props, emissions, num_iters=20)
post = hmm.smoother(params, emissions)Model classes: Gaussian and non-Gaussian HMMs, linear and nonlinear Gaussian SSMs. Inference and learning: methods on the model instance (.sample, .fit_em, .smoother).
dynamax’s OO layer ties each model class to its own inference methods: GaussianHMM(...).smoother(...), LinearGaussianSSM(...).filter(...). Swapping inference algorithm means switching model class, or extending the hierarchy.
dynestyx’s docs discuss the coupling of tools:
One drawback of this suite of methods [
dynamax,cd-dynamax,PFJax] is a varied set of APIs, with model code that is tightly coupled with the resulting inference method. Indynestyx, we offer a large variety of different inference methods under the same roof in a unified, abstract API.
BlackJAX: deliberately structure-agnostic by design. Only ever sees a plain log-density function:
BlackJAX on composability: “Sampling algorithms are too often integrated into PPLs and not decoupled from the rest of the framework […]. Their implementation is most of the time monolithic and it is impossible to reuse parts of the algorithm to build custom kernels.”
cuthbert’ stems from the same philosophy: decoupling model and inference. But unlike BlackJAX, cuthbert is structure-aware: it knows it’s dealing with a state-space model, and exploits that structure to run inference efficiently.
cuthbertA JAX library for SSM inference: filtering, smoothing, static parameter estimation.
Explicitly not a PPL: a stated non-goal.
cuthbertis not a probabilistic programming language (PPL). But can easily compose withdynamax,distrax,numpyroandpymcin a similar way to howblackjaxdoes.
Functional API:
The only classes in
cuthbertareNamedTuples andProtocols. All functions are pure, and work seamlessly withjax.grad,jax.jit,jax.vmap.
BlackJAX: inference-only and structure-agnostic. cuthbert: inference-only and SSM-structure-aware.
cuthbert: building a filterfrom jax import numpy as jnp
from cuthbert.gaussian import kalman
def build_car_tracking_filter(m0, chol_P0, F, c, chol_Q, H, d, chol_R, ys):
def get_init_params(model_inputs):
return m0, chol_P0
def get_dynamics_params(model_inputs):
return F, c, chol_Q
def get_observation_params(model_inputs):
return H, d, chol_R, ys[model_inputs - 1]
filter_obj = kalman.build_filter(
get_init_params, get_dynamics_params, get_observation_params
)
model_inputs = jnp.arange(len(ys) + 1)
return filter_obj, model_inputsThe pattern: get_dynamics_params / get_observation_params, pure functions of a model_inputs pytree. Same functions, reusable across Kalman, EKF/UKF, or particle-filter backends.
Decouples model and inference method.
cuthbert: running the filterparallel=True: \(O(\log T)\) temporal-parallel filtering.
cuthbert: what’s under the hoodOne unified interface (init_prepare / filter_prepare / filter_combine) behind every backend:
| Model class | Backend |
|---|---|
| Linear-Gaussian | Kalman/RTS (√-form, parallel-in-time) |
| Nonlinear-Gaussian | EKF / UKF / CKF / GHKF |
| Discrete / finite-state | Forward-backward, Baum–Welch |
| General nonlinear | Particle filter (SMC) + FFBSi smoothing |
| High-dimensional | Ensemble Kalman Filter + localization |
| Factorized dynamics | Factorial SSMs (Duffield et al. 2024) |
Swapping inference method: swapping the build_filter(...) call. Everything downstream (cuthbert.filter, cuthbert.smoother): unchanged.
cuthbert: predict/update in \(O(\log T)\)Classical recursion: two named steps, called sequentially. The parallel-in-time reformulation (Särkkä & García-Fernández, 2021) repackages both into one associative element:
Filter only exposes filter_prepare (element construction) plus filter_combine (this operator). Feed filter_combine to jax.lax.associative_scan (or just a standard scan): compute drops from \(O(T)\) to \(O(\log T)\) given enough parallel workers.
Scope currently: Discrete/Gaussian filters/smoothers only; particle filters/ensemble Kalman stay sequential (for now).
dynestyx: cuthbert’s model-authoring layerAn extension of NumPyro, same decoupling philosophy one level up: dynestyx uses cuthbert as one of its three inference backends (alongside BlackJAX and cd-dynamax). dsx.sample(...) dispatches differently depending on the active handler: Simulator() / Filter() / LatentPathBuilder().
def continuous_time_lti_gaussian_model(rho=None, obs_times=None, obs_values=None):
rho = numpyro.sample("rho", dist.Uniform(0.0, 5.0), obs=rho)
A = jnp.array([[-1.0, 0.0], [rho, -1.0]])
dynamics = DynamicalModel(
initial_condition=dist.MultivariateNormal(
loc=jnp.zeros(2), covariance_matrix=1.0**2 * jnp.eye(2)),
state_evolution=ContinuousTimeStateEvolution(
drift=lambda x, u, t: A @ x,
diffusion=ScalarDiffusion(1.0, bm_dim=2)),
observation_model=LinearGaussianObservation(
H=jnp.array([[0.0, 1.0]]), R=jnp.array([[0.15**2]])),
)
return dsx.sample("f", dynamics, obs_times=obs_times, obs_values=obs_values)dsx.sample ties together ordinary MCMC sampling for parameters (rho) and a DynamicalModel plus chosen backend for the dynamics.
cuthbert as the inference engine, from dynestyxBackend choice: explicit, per algorithm, via a filter_source field on the config dataclass, not automatic structural dispatch. EnKF and particle filtering: cuthbert-only. Kalman/EKF: either backend, and parallel-in-time Kalman filter specifically requires filter_source="cuthbert".
Under the hood: an adapter translates the DynamicalModel into the get_init_params / get_dynamics_params / get_observation_params closures of cuthbert’s kalman.build_filter.
cuthbert |
dynestyx |
|
|---|---|---|
| Role | Inference engine | Model authoring + inference selection |
| Built on | Plain JAX | NumPyro + effectful |
| API style | Pure functions only | Effect handlers over DynamicalModel |
| Model classes | Gaussian → discrete → particle → ensemble → factorial | Discrete- & continuous-time, (non)linear, (non)Gaussian |
| Inference selection | Pick a backend module | Config objects (filter_source, …) |
| Relationship | One of dynestyx’s backends |
Depends on cuthbert directly |
cuthberto-carlos: the live sitestate-space-models.github.io/cuthberto-carlos.
cuthberto-carlos: predicting the World Cupcuthbert’s factorial support, in the wild: predicting the 2026 FIFA World Cup, live, at state-space-models.github.io/cuthberto-carlos.
Follows Duffield, Power & Rimella, “A state-space perspective on modelling and inference for online skill rating” (JRSS-C, 2024). One attack/defence pair per team, evolving over time (Ornstein-Uhlenbeck dynamics), joined per fixture into a single factorial state-space model with a bivariate Poisson observation:
\[ p(x^i_k \mid x^i_{k-1}) = N\left(x^i_k \mid \mu_0 + \phi_k(x^i_{k-1}-\mu_0),\ Q_k\right) \]
\[ p(y_k \mid x^i_k, x^j_k) = \mathrm{BivPoisson}(y_k \mid x^i_k, x^j_k, \alpha, \beta) \]
Built with the same get_*_params pattern from earlier, wrapped in cuthbert.factorial: one 2-D state per team, joined per match, filtered with the moments (EKF-style) backend.
cuthberto-carlos: results vs. Polymarket

Filtered team strengths, full score grid, win/draw/loss probabilities. Checked live against Polymarket odds: \(R^2 = 0.88\), Pearson \(r = 0.94\), fit slope \(1.10\).
cuthbert: open and active.
Open an issue if you have a question, suggestion, or spotted a bug. Start or join a discussion about design/methods etc. Open a pull request. Join us on Discord.
dynestyx: concrete, documented gaps to fill.
control_model (exogenous/control inputs): not yet implementedpredict_times ≥ max(obs_times) today)cuthbert backend: not yet wired upConcrete “where to start” points, for anyone who wants to get involved.
github.com/state-space-models/cuthbertgithub.com/BasisResearch/dynestyxcuthbert maintainers:



dynestyx people: Dan Waxman, Dmitry Batenkov, John Feser, Andy Zane, Eli Bingham, Youssef Marzouk, Matthew E. Levine