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 +94 -0
- smcx/__main__.py +15 -0
- smcx/_utils.py +172 -0
- smcx/auxiliary.py +245 -0
- smcx/bootstrap.py +209 -0
- smcx/containers.py +188 -0
- smcx/diagnostics.py +765 -0
- smcx/exceptions.py +19 -0
- smcx/guided.py +205 -0
- smcx/liu_west.py +303 -0
- smcx/py.typed +0 -0
- smcx/resampling.py +148 -0
- smcx/simulate.py +80 -0
- smcx/smc2.py +312 -0
- smcx/tempering.py +241 -0
- smcx/types.py +15 -0
- smcx/weights.py +74 -0
- smcx-1.0.0.dist-info/METADATA +181 -0
- smcx-1.0.0.dist-info/RECORD +20 -0
- smcx-1.0.0.dist-info/WHEEL +4 -0
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
|
+
)
|