smcx 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
smcx/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ # Copyright 2026 Michael Ellis
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Sequential Monte Carlo and particle filtering in JAX."""
5
+
6
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
7
+ from importlib.metadata import version as _version
8
+
9
+ from smcx.auxiliary import auxiliary_filter
10
+ from smcx.bootstrap import bootstrap_filter
11
+ from smcx.containers import (
12
+ LiuWestPosterior,
13
+ ParticleFilterPosterior,
14
+ ParticleFilterResult,
15
+ ParticleState,
16
+ SMC2Posterior,
17
+ TemperedPosterior,
18
+ )
19
+ from smcx.diagnostics import (
20
+ crps,
21
+ cumulative_log_score,
22
+ diagnose,
23
+ log_bayes_factor,
24
+ log_ml_increments,
25
+ param_weighted_mean,
26
+ param_weighted_quantile,
27
+ pareto_k_diagnostic,
28
+ particle_diversity,
29
+ posterior_predictive_sample,
30
+ replicated_log_ml,
31
+ tail_ess,
32
+ weighted_mean,
33
+ weighted_quantile,
34
+ weighted_variance,
35
+ )
36
+ from smcx.exceptions import DegenerateWeightsError
37
+ from smcx.guided import guided_filter
38
+ from smcx.liu_west import liu_west_filter
39
+ from smcx.resampling import (
40
+ multinomial,
41
+ residual,
42
+ stratified,
43
+ systematic,
44
+ )
45
+ from smcx.simulate import simulate
46
+ from smcx.smc2 import smc2
47
+ from smcx.tempering import temper
48
+ from smcx.weights import ess, log_ess, log_normalize, normalize
49
+
50
+ try:
51
+ __version__ = _version("smcx")
52
+ except _PackageNotFoundError:
53
+ __version__ = "1.0.0"
54
+
55
+ __all__ = [
56
+ "DegenerateWeightsError",
57
+ "LiuWestPosterior",
58
+ "ParticleFilterPosterior",
59
+ "ParticleFilterResult",
60
+ "ParticleState",
61
+ "SMC2Posterior",
62
+ "TemperedPosterior",
63
+ "__version__",
64
+ "auxiliary_filter",
65
+ "bootstrap_filter",
66
+ "crps",
67
+ "cumulative_log_score",
68
+ "diagnose",
69
+ "ess",
70
+ "guided_filter",
71
+ "liu_west_filter",
72
+ "log_bayes_factor",
73
+ "log_ess",
74
+ "log_ml_increments",
75
+ "log_normalize",
76
+ "multinomial",
77
+ "normalize",
78
+ "param_weighted_mean",
79
+ "param_weighted_quantile",
80
+ "pareto_k_diagnostic",
81
+ "particle_diversity",
82
+ "posterior_predictive_sample",
83
+ "replicated_log_ml",
84
+ "residual",
85
+ "simulate",
86
+ "smc2",
87
+ "stratified",
88
+ "systematic",
89
+ "tail_ess",
90
+ "temper",
91
+ "weighted_mean",
92
+ "weighted_quantile",
93
+ "weighted_variance",
94
+ ]
smcx/__main__.py ADDED
@@ -0,0 +1,15 @@
1
+ # Copyright 2026 Michael Ellis
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Allow running the package with ``python -m smcx``."""
5
+
6
+ from smcx import __version__
7
+
8
+
9
+ def main() -> None:
10
+ """Print package version."""
11
+ print(f"smcx {__version__}")
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main()
smcx/_utils.py ADDED
@@ -0,0 +1,172 @@
1
+ # Copyright 2026 Michael Ellis
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Shared private helpers for particle filters.
5
+
6
+ These utilities are extracted from the individual filter modules to
7
+ eliminate duplication. They are not part of the public API.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+
12
+ import jax.numpy as jnp
13
+ from jax import lax, vmap
14
+ from jaxtyping import Array, Float, Int
15
+
16
+ from smcx.containers import ParticleState
17
+ from smcx.types import PRNGKeyT
18
+ from smcx.weights import ess as compute_ess
19
+ from smcx.weights import log_normalize, normalize
20
+
21
+
22
+ def _prepend(first: Array, rest: Array) -> Array:
23
+ """Prepend a single leading slice to an array along axis 0.
24
+
25
+ Args:
26
+ first: Array of shape ``(...)``.
27
+ rest: Array of shape ``(T, ...)``.
28
+
29
+ Returns:
30
+ Concatenated array of shape ``(T+1, ...)``.
31
+ """
32
+ return jnp.concatenate([jnp.expand_dims(first, 0), rest], axis=0)
33
+
34
+
35
+ def _weighted_quantile_1d(
36
+ particles: Float[Array, " num_particles"],
37
+ weights: Float[Array, " num_particles"],
38
+ q: Float[Array, " num_quantiles"],
39
+ ) -> Float[Array, " num_quantiles"]:
40
+ """Compute weighted quantiles for a single 1-D vector.
41
+
42
+ Sorts particles, builds a midpoint CDF from the normalised
43
+ weights, and interpolates at the requested quantile levels.
44
+
45
+ Args:
46
+ particles: Particle values for one dimension.
47
+ weights: Normalised weights (sum to one).
48
+ q: Quantile levels in [0, 1].
49
+
50
+ Returns:
51
+ Interpolated quantile values.
52
+ """
53
+ sort_idx = jnp.argsort(particles)
54
+ p_sorted = particles[sort_idx]
55
+ w_sorted = weights[sort_idx]
56
+ cum_w = jnp.cumsum(w_sorted)
57
+ # Midpoint CDF: centre each particle's mass in its interval
58
+ # so that zero-weight particles don't create flat regions.
59
+ mid_cdf = (jnp.concatenate([jnp.zeros(1), cum_w[:-1]]) + cum_w) / 2
60
+ # Tiny tiebreaker ensures strict monotonicity for jnp.interp.
61
+ n = p_sorted.shape[0]
62
+ eps = jnp.arange(n, dtype=p_sorted.dtype) * 1e-12
63
+ return jnp.interp(q, mid_cdf + eps, p_sorted)
64
+
65
+
66
+ def _init_standard(
67
+ init_key: PRNGKeyT,
68
+ initial_sampler: Callable,
69
+ log_observation_fn: Callable,
70
+ first_emission: Array,
71
+ num_particles: int,
72
+ log_n: Array,
73
+ ) -> tuple[Array, Array, Array, Array, Array, ParticleState]:
74
+ """Initialise a standard (bootstrap/auxiliary) filter at t=0.
75
+
76
+ Samples from the prior, weights by the first observation, and
77
+ builds the initial :class:`ParticleState`.
78
+
79
+ Args:
80
+ init_key: PRNG key for initialisation.
81
+ initial_sampler: State prior sampler ``(key, N) -> particles``.
82
+ log_observation_fn: Observation log-density
83
+ ``(emission, state) -> log_prob``.
84
+ first_emission: First observation y_0.
85
+ num_particles: Number of particles N.
86
+ log_n: Precomputed ``log(N)`` as a scalar array in the
87
+ default float dtype.
88
+
89
+ Returns:
90
+ Tuple of ``(particles_0, log_w_0, log_ev_0, ess_0,
91
+ identity_ancestors, init_state)``.
92
+ """
93
+ particles_0 = initial_sampler(init_key, num_particles)
94
+ log_obs_0 = vmap(lambda z: log_observation_fn(first_emission, z))(
95
+ particles_0
96
+ )
97
+ log_w_0, log_sum_0 = log_normalize(log_obs_0)
98
+ log_ev_0 = log_sum_0 - log_n
99
+ ess_0: Array = jnp.asarray(compute_ess(log_w_0))
100
+ identity_ancestors = jnp.arange(num_particles, dtype=jnp.int32)
101
+
102
+ init_state = ParticleState(
103
+ particles=particles_0,
104
+ log_weights=log_w_0,
105
+ log_marginal_likelihood=log_ev_0,
106
+ )
107
+ return (
108
+ particles_0,
109
+ log_w_0,
110
+ log_ev_0,
111
+ ess_0,
112
+ identity_ancestors,
113
+ init_state,
114
+ )
115
+
116
+
117
+ def _conditional_resample(
118
+ key: PRNGKeyT,
119
+ log_weights: Float[Array, " num_particles"],
120
+ resampling_fn: Callable,
121
+ threshold: float,
122
+ num_particles: int,
123
+ identity: Int[Array, " num_particles"],
124
+ ) -> tuple[Array, Int[Array, " num_particles"]]:
125
+ """Conditionally resample particles based on ESS.
126
+
127
+ Computes the effective sample size of the given log weights
128
+ and resamples only when ESS falls below the threshold.
129
+
130
+ Args:
131
+ key: PRNG key for resampling.
132
+ log_weights: Normalised log weights (logsumexp = 0).
133
+ resampling_fn: Blackjax-compatible resampling function.
134
+ threshold: Absolute ESS threshold (e.g. ``0.5 * N``).
135
+ num_particles: Number of particles N.
136
+ identity: Identity ancestor indices ``arange(N)``.
137
+
138
+ Returns:
139
+ Tuple of ``(do_resample, ancestors)`` where *do_resample*
140
+ is a boolean scalar and *ancestors* are the resampled (or
141
+ identity) indices.
142
+ """
143
+ cur_ess = compute_ess(log_weights)
144
+ w_norm = normalize(log_weights)
145
+ do_resample: Array = jnp.asarray(cur_ess < threshold)
146
+ ancestors = lax.cond(
147
+ do_resample,
148
+ lambda: resampling_fn(key, w_norm, num_particles),
149
+ lambda: identity,
150
+ )
151
+ return do_resample, ancestors
152
+
153
+
154
+ def _raise_if_degenerate(marginal_loglik) -> None:
155
+ """Raise :class:`DegenerateWeightsError` on a collapsed filter.
156
+
157
+ Host-side check: fires only in eager execution. Under a user
158
+ ``jax.jit`` the value is a tracer and the check is skipped — the
159
+ ``-inf``/NaN marginal propagates instead (see the exception's
160
+ docstring).
161
+ """
162
+ from jax.core import Tracer
163
+
164
+ from smcx.exceptions import DegenerateWeightsError
165
+
166
+ if isinstance(marginal_loglik, Tracer):
167
+ return
168
+ value = float(marginal_loglik)
169
+ if value != value or value == float("-inf"):
170
+ raise DegenerateWeightsError(
171
+ f"all particle weights collapsed (marginal log-likelihood {value})"
172
+ )
smcx/auxiliary.py ADDED
@@ -0,0 +1,245 @@
1
+ # Copyright 2026 Michael Ellis
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ r"""Auxiliary particle filter (Pitt & Shephard, 1999).
5
+
6
+ The auxiliary particle filter (APF) improves on the bootstrap filter by
7
+ using a *look-ahead* step that biases resampling towards particles
8
+ likely to match the next observation **before** propagation.
9
+
10
+ At each time step the APF:
11
+
12
+ 1. **First-stage weights** — combines the current normalised weights
13
+ with the look-ahead log-density
14
+ :math:`\log g(y_{t+1} \mid x_t^i)` to form first-stage weights.
15
+ 2. **Resamples** (conditionally on ESS) using the first-stage weights.
16
+ 3. **Propagates** resampled particles through the transition prior.
17
+ 4. **Second-stage weights** — corrects for the look-ahead bias:
18
+ :math:`w_t^{(2)} = p(y_{t+1} \mid x_{t+1}^i) / g(y_{t+1} \mid x_t^{a_i})`.
19
+
20
+ When ``log_auxiliary_fn`` returns zero for all inputs, the APF
21
+ reduces to the bootstrap filter.
22
+
23
+ The implementation uses :func:`jax.lax.scan` so the full time-loop is
24
+ compiled into a single XLA program.
25
+ """
26
+
27
+ import math
28
+ from collections.abc import Callable
29
+
30
+ import jax.numpy as jnp
31
+ import jax.random as jr
32
+ from jax import lax, vmap
33
+ from jaxtyping import Array, Float
34
+
35
+ from smcx._utils import (
36
+ _conditional_resample,
37
+ _init_standard,
38
+ _prepend,
39
+ _raise_if_degenerate,
40
+ )
41
+ from smcx.containers import ParticleFilterPosterior, ParticleState
42
+ from smcx.resampling import systematic
43
+ from smcx.types import PRNGKeyT
44
+ from smcx.weights import ess as compute_ess
45
+ from smcx.weights import log_normalize
46
+
47
+
48
+ def auxiliary_filter(
49
+ key: PRNGKeyT,
50
+ initial_sampler: Callable,
51
+ transition_sampler: Callable,
52
+ log_observation_fn: Callable,
53
+ log_auxiliary_fn: Callable,
54
+ emissions: Float[Array, "ntime emission_dim"],
55
+ num_particles: int,
56
+ resampling_fn: Callable = systematic,
57
+ resampling_threshold: float = 0.5,
58
+ *,
59
+ store_history: bool = True,
60
+ ) -> ParticleFilterPosterior:
61
+ r"""Run an auxiliary particle filter (Pitt & Shephard, 1999).
62
+
63
+ Args:
64
+ key: JAX PRNG key.
65
+ initial_sampler: Function ``(key, num_particles) -> particles``
66
+ that draws from the initial state distribution
67
+ :math:`p(z_1)`.
68
+ transition_sampler: Function ``(key, state) -> state`` that
69
+ draws from the transition distribution
70
+ :math:`p(z_t \mid z_{t-1})`. Will be ``vmap``-ped over
71
+ the particle dimension internally.
72
+ log_observation_fn: Function
73
+ ``(emission, state) -> log_prob`` that evaluates the
74
+ observation log-density :math:`\log p(y_t \mid z_t)`.
75
+ Will be ``vmap``-ped over the particle dimension (second
76
+ argument) internally.
77
+ log_auxiliary_fn: Function
78
+ ``(emission, state) -> log_prob`` that evaluates the
79
+ look-ahead log-density
80
+ :math:`\log g(y_{t+1} \mid x_t)`.
81
+ Will be ``vmap``-ped over the particle dimension (second
82
+ argument) internally. When this returns zero for all
83
+ inputs the APF reduces to the bootstrap filter.
84
+ emissions: Observed emissions, shape ``(T, D)``.
85
+ num_particles: Number of particles :math:`N`.
86
+ resampling_fn: Resampling algorithm matching the Blackjax
87
+ signature ``(key, weights, num_samples) -> indices``.
88
+ Defaults to :func:`~smcx.resampling.systematic`.
89
+ resampling_threshold: Fraction of ``num_particles`` below
90
+ which resampling is triggered (e.g. 0.5 means resample
91
+ when ``ESS < 0.5 * N``).
92
+ store_history: When False (ADR-0011), the scan stacks no
93
+ per-step particle/weight/ancestor histories — the returned
94
+ arrays cover only the final step (time axis length 1)
95
+ while ``ess``/``log_evidence_increments`` stay full.
96
+
97
+ Returns:
98
+ :class:`~smcx.containers.ParticleFilterPosterior` containing
99
+ filtered particles, log weights, ancestor indices, the
100
+ marginal log-likelihood estimate, and ESS trace.
101
+ """
102
+ key, init_key = jr.split(key)
103
+ log_n = jnp.asarray(math.log(num_particles))
104
+
105
+ # --- Initialise at t=0 -------------------------------------------------
106
+ (
107
+ particles_0,
108
+ log_w_0,
109
+ log_ev_0,
110
+ ess_0,
111
+ identity_ancestors,
112
+ init_state,
113
+ ) = _init_standard(
114
+ init_key,
115
+ initial_sampler,
116
+ log_observation_fn,
117
+ emissions[0],
118
+ num_particles,
119
+ log_n,
120
+ )
121
+
122
+ # --- Scan body for t = 1, ..., T-1 -------------------------------------
123
+ def _step(
124
+ carry: tuple[ParticleState, Array],
125
+ args: tuple[PRNGKeyT, Float[Array, " emission_dim"]],
126
+ ):
127
+ (state, _prev_ancestors), (step_key, y_t) = carry, args
128
+ k1, k2 = jr.split(step_key)
129
+ # Invariant: state.log_weights are normalized (logsumexp = 0).
130
+
131
+ # 1. First-stage weights: combine current weights with
132
+ # look-ahead g(y_{t+1} | x_t)
133
+ log_aux = vmap(lambda z: log_auxiliary_fn(y_t, z))(state.particles)
134
+ log_first_stage = state.log_weights + log_aux
135
+
136
+ # Normalise first-stage weights for resampling
137
+ log_first_norm, log_first_sum = log_normalize(log_first_stage)
138
+
139
+ # 2. Conditionally resample using first-stage weights
140
+ threshold = resampling_threshold * num_particles
141
+ do_resample, ancestors = _conditional_resample(
142
+ k1,
143
+ log_first_norm,
144
+ resampling_fn,
145
+ threshold,
146
+ num_particles,
147
+ identity_ancestors,
148
+ )
149
+ resampled_particles = state.particles[ancestors]
150
+
151
+ # Store the look-ahead values for ancestors (needed for
152
+ # second-stage correction)
153
+ log_aux_ancestors = log_aux[ancestors]
154
+
155
+ # 3. Propagate through transition
156
+ keys = jr.split(k2, num_particles)
157
+ propagated = vmap(transition_sampler)(keys, resampled_particles)
158
+
159
+ # 4. Second-stage weights: observation / look-ahead adjustment
160
+ log_obs = vmap(lambda z: log_observation_fn(y_t, z))(propagated)
161
+ log_second_stage = log_obs - log_aux_ancestors
162
+
163
+ # Compute evidence increment and normalize.
164
+ # If resampled: first-stage weights were used for resampling,
165
+ # the evidence increment is the product of two factors:
166
+ # (a) E_W[g] = sum_i W_i * g_i (first-stage normaliser)
167
+ # (b) (1/N) sum_j w_j^(2) (mean second-stage weight)
168
+ # If not resampled: standard importance weighting,
169
+ # increment = logsumexp(log_w_old + log_obs)
170
+ log_w_unnorm = jnp.where(
171
+ do_resample,
172
+ log_second_stage,
173
+ state.log_weights + log_obs,
174
+ )
175
+ log_w_norm, log_sum = log_normalize(log_w_unnorm)
176
+
177
+ # log_first_sum = logsumexp(log_w_norm_old + log_aux)
178
+ # = log(sum W_i g_i) = log E_W[g] (no 1/N needed)
179
+ # log_sum for second stage = logsumexp(log_second_stage)
180
+ # so mean = log_sum - log_n
181
+ log_ev_inc_resample = log_first_sum + log_sum - log_n
182
+ log_ev_inc_no_resample = log_sum
183
+ log_ev_inc = jnp.where(
184
+ do_resample, log_ev_inc_resample, log_ev_inc_no_resample
185
+ )
186
+
187
+ new_state = ParticleState(
188
+ particles=propagated,
189
+ log_weights=log_w_norm,
190
+ log_marginal_likelihood=(
191
+ state.log_marginal_likelihood + log_ev_inc
192
+ ),
193
+ )
194
+ ess_t: Array = jnp.asarray(compute_ess(log_w_norm))
195
+ if store_history:
196
+ return (new_state, ancestors), (
197
+ propagated,
198
+ log_w_norm,
199
+ ancestors,
200
+ ess_t,
201
+ log_ev_inc,
202
+ )
203
+ # Final-only mode (ADR-0011): ancestors ride the carry (O(N));
204
+ # the scan stacks just the scalar traces.
205
+ return (new_state, ancestors), (ess_t, log_ev_inc)
206
+
207
+ # Run the scan over t = 1 ... T-1
208
+ step_keys = jr.split(key, emissions.shape[0] - 1)
209
+ init_carry = (init_state, identity_ancestors)
210
+ if store_history:
211
+ (
212
+ (final_state, _),
213
+ (
214
+ particles_rest,
215
+ log_w_rest,
216
+ ancestors_rest,
217
+ ess_rest,
218
+ log_ev_inc_rest,
219
+ ),
220
+ ) = lax.scan(_step, init_carry, (step_keys, emissions[1:]))
221
+ all_particles = _prepend(particles_0, particles_rest)
222
+ all_log_w = _prepend(log_w_0, log_w_rest)
223
+ all_ancestors = _prepend(identity_ancestors, ancestors_rest)
224
+ else:
225
+ (
226
+ (final_state, final_ancestors),
227
+ (ess_rest, log_ev_inc_rest),
228
+ ) = lax.scan(_step, init_carry, (step_keys, emissions[1:]))
229
+ all_particles = final_state.particles[None]
230
+ all_log_w = final_state.log_weights[None]
231
+ all_ancestors = final_ancestors[None]
232
+ ess_0_arr: Array = jnp.asarray(ess_0)
233
+ all_ess = _prepend(ess_0_arr, ess_rest)
234
+ all_log_ev_inc = _prepend(jnp.asarray(log_ev_0), log_ev_inc_rest)
235
+
236
+ _raise_if_degenerate(final_state.log_marginal_likelihood)
237
+
238
+ return ParticleFilterPosterior(
239
+ marginal_loglik=final_state.log_marginal_likelihood,
240
+ filtered_particles=all_particles,
241
+ filtered_log_weights=all_log_w,
242
+ ancestors=all_ancestors,
243
+ ess=all_ess,
244
+ log_evidence_increments=all_log_ev_inc,
245
+ )