marmo 0.1.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.
markovlib/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """markovlib — Markov-like processes as one recursion parameterized by a belief algebra + semiring.
2
+
3
+ Public surface (vertical slice): the model (:class:`DiscreteChain`), the engine
4
+ (:class:`ExactChain`), the semirings (:class:`SumProduct` / :class:`MaxPlus`), the decidable dispatch
5
+ (:func:`resolve_engine` → :class:`Exact` / :class:`Approximate` / :class:`Intractable`), and the uniform
6
+ queries (:func:`smooth` / :func:`decode` / :func:`loglik`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from markovlib.belief import Belief, Categorical, GaussianBelief
12
+ from markovlib.dispatch import resolve_engine
13
+ from markovlib.duration import DurationModel, NegBinomDuration
14
+ from markovlib.engines.exact_chain import ExactChain, ExpectedStats, SmoothResult
15
+ from markovlib.engines.gaussian import FilterResult, GaussianChain, GaussianSmoothResult
16
+ from markovlib.engines.particle import ParticleFilter, ParticleResult
17
+ from markovlib.engines.segmental import SegmentalChain
18
+ from markovlib.learn import FitResult, fit
19
+ from markovlib.model import DiscreteChain, LinearGaussian, SemiMarkovChain, StateSpaceModel
20
+ from markovlib.query import decode, filter, loglik, particle_filter, smooth
21
+ from markovlib.resolution import Approximate, EngineResolution, Exact, Intractable
22
+ from markovlib.semiring import MaxPlus, Semiring, SumProduct
23
+
24
+ __all__ = [
25
+ "Belief",
26
+ "Categorical",
27
+ "GaussianBelief",
28
+ "DiscreteChain",
29
+ "SemiMarkovChain",
30
+ "LinearGaussian",
31
+ "StateSpaceModel",
32
+ "DurationModel",
33
+ "NegBinomDuration",
34
+ "ExactChain",
35
+ "SegmentalChain",
36
+ "GaussianChain",
37
+ "ParticleFilter",
38
+ "SmoothResult",
39
+ "ExpectedStats",
40
+ "FilterResult",
41
+ "GaussianSmoothResult",
42
+ "ParticleResult",
43
+ "FitResult",
44
+ "fit",
45
+ "Semiring",
46
+ "SumProduct",
47
+ "MaxPlus",
48
+ "Exact",
49
+ "Approximate",
50
+ "Intractable",
51
+ "EngineResolution",
52
+ "resolve_engine",
53
+ "smooth",
54
+ "decode",
55
+ "loglik",
56
+ "filter",
57
+ "particle_filter",
58
+ ]
markovlib/belief.py ADDED
@@ -0,0 +1,109 @@
1
+ """The belief algebra — the message representation the one recursion is generic over.
2
+
3
+ A :class:`Belief` is a distribution-shaped message. The forward recursion asks only two things of it:
4
+ ``combine`` (``⊗`` — fold in a local factor / emission) and ``log_mass`` (for the likelihood and for
5
+ normalization). The *push-forward* through the dynamics is supplied to the recursion separately as a
6
+ ``predict`` callable, so the belief type stays ignorant of the transition. Swapping the belief —
7
+ :class:`Categorical` (log-vector) now; Gaussian (moment / information form) and particle (samples +
8
+ weights) later — turns the *same* recursion into forward–backward, Kalman/RTS, or a particle filter,
9
+ exactly as swapping the semiring turns posteriors into MAP. Belief × semiring are the two orthogonal
10
+ axes the whole library rides on.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Protocol, Self
17
+
18
+ import numpy as np
19
+ import numpy.typing as npt
20
+ from scipy.special import logsumexp
21
+
22
+ Float = npt.NDArray[np.float64]
23
+
24
+
25
+ class Belief(Protocol):
26
+ """A distribution-shaped message: fold in a like factor, normalize, and report its log-mass."""
27
+
28
+ def combine(self, factor: Self, /) -> Self:
29
+ """``⊗`` — fold in another belief / local factor (log-domain for :class:`Categorical`)."""
30
+ ...
31
+
32
+ def log_mass(self) -> float:
33
+ """The log of the total (unnormalized) mass — this step's contribution to the likelihood."""
34
+ ...
35
+
36
+ def normalized(self) -> Self:
37
+ """This belief rescaled to unit mass."""
38
+ ...
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Categorical:
43
+ """A categorical belief: an (unnormalized) log-probability vector over ``S`` states."""
44
+
45
+ log_p: Float
46
+
47
+ def combine(self, factor: Categorical, /) -> Categorical:
48
+ """Pointwise ``⊗`` — add the log-vectors (multiply the unnormalized densities)."""
49
+ return Categorical(self.log_p + factor.log_p)
50
+
51
+ def log_mass(self) -> float:
52
+ """``logsumexp`` of the log-vector — the total unnormalized mass."""
53
+ return float(logsumexp(self.log_p))
54
+
55
+ def normalized(self) -> Categorical:
56
+ """The belief rescaled to a proper distribution (subtract the log-mass)."""
57
+ return Categorical(self.log_p - self.log_mass())
58
+
59
+ def probs(self) -> Float:
60
+ """The normalized probability vector."""
61
+ return np.exp(self.normalized().log_p)
62
+
63
+ def mode(self) -> int:
64
+ """The most probable state index (MAP marginal)."""
65
+ return int(np.argmax(self.log_p))
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class GaussianBelief:
70
+ """A Gaussian message in information (canonical) form ``φ(x) = exp(-½ xᵀΛx + ηᵀx + g)``.
71
+
72
+ ``eta`` is the information vector ``η``, ``precision`` is ``Λ`` (symmetric positive-definite),
73
+ ``log_scale`` is ``g``. The product of two canonical potentials just *adds* ``(η, Λ, g)`` — the
74
+ Gaussian analog of :class:`Categorical`'s log-add — so :meth:`combine` is elementwise addition. And
75
+ ``log_mass`` is ``log ∫ φ``: under the filtering recursion the running message is
76
+ ``φ_t(x_t) = p(x_t, y_{0:t})``, so ``log_mass`` is ``log p(y_{0:t})`` and the final message's
77
+ ``log_mass`` is the data log-likelihood — exactly parallel to Categorical.
78
+ """
79
+
80
+ eta: Float
81
+ precision: Float
82
+ log_scale: float = 0.0
83
+
84
+ def combine(self, factor: GaussianBelief, /) -> GaussianBelief:
85
+ """``⊗`` — multiply the canonical potentials (add information vectors, precisions, and scales)."""
86
+ return GaussianBelief(
87
+ self.eta + factor.eta, self.precision + factor.precision, self.log_scale + factor.log_scale
88
+ )
89
+
90
+ def log_mass(self) -> float:
91
+ """``log ∫ φ(x) dx = g + d/2·log 2π − ½·log|Λ| + ½·ηᵀΛ⁻¹η``."""
92
+ dim = int(self.eta.shape[0])
93
+ _, log_det = np.linalg.slogdet(self.precision)
94
+ quad = float(self.eta @ np.linalg.solve(self.precision, self.eta))
95
+ return float(self.log_scale + 0.5 * dim * np.log(2.0 * np.pi) - 0.5 * float(log_det) + 0.5 * quad)
96
+
97
+ def normalized(self) -> GaussianBelief:
98
+ """The belief rescaled to unit mass (a proper density: ``log_mass == 0``)."""
99
+ return GaussianBelief(self.eta, self.precision, self.log_scale - self.log_mass())
100
+
101
+ def mean(self) -> Float:
102
+ """The distribution mean ``μ = Λ⁻¹η``."""
103
+ mean: Float = np.linalg.solve(self.precision, self.eta)
104
+ return mean
105
+
106
+ def covariance(self) -> Float:
107
+ """The distribution covariance ``Σ = Λ⁻¹``."""
108
+ cov: Float = np.linalg.inv(self.precision)
109
+ return cov
markovlib/dispatch.py ADDED
@@ -0,0 +1,33 @@
1
+ """``resolve_engine`` — markovlib's ``decide()``: which engine resolves a query, and how exactly.
2
+
3
+ The deliberate analog of fungeom's decidable dispatch: given a model and a query, return *evidence*
4
+ (:data:`~markovlib.resolution.EngineResolution`) naming the engine and its exactness, never a silent
5
+ best-effort. A :class:`~markovlib.model.DiscreteChain` resolves ``smooth`` / ``decode`` / ``loglik``
6
+ exactly; a :class:`~markovlib.model.SemiMarkovChain` resolves ``decode`` exactly; a
7
+ :class:`~markovlib.model.LinearGaussian` resolves ``filter`` exactly (the Kalman filter). New engines
8
+ register here, each declaring ``Exact`` or ``Approximate`` for the queries they resolve.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from markovlib.engines.exact_chain import ExactChain
14
+ from markovlib.engines.gaussian import GaussianChain
15
+ from markovlib.engines.particle import ParticleFilter
16
+ from markovlib.engines.segmental import SegmentalChain
17
+ from markovlib.model import DiscreteChain, LinearGaussian, SemiMarkovChain, StateSpaceModel
18
+ from markovlib.resolution import Approximate, EngineResolution, Exact, Intractable
19
+
20
+ _CHAIN_QUERIES = frozenset({"smooth", "decode", "loglik"})
21
+
22
+
23
+ def resolve_engine(model: object, query: str) -> EngineResolution:
24
+ """Return evidence for which engine resolves ``query`` on ``model``, and whether exactly."""
25
+ if isinstance(model, DiscreteChain) and query in _CHAIN_QUERIES:
26
+ return Exact(ExactChain())
27
+ if isinstance(model, SemiMarkovChain) and query in {"decode", "smooth"}:
28
+ return Exact(SegmentalChain())
29
+ if isinstance(model, LinearGaussian) and query in {"filter", "smooth"}:
30
+ return Exact(GaussianChain())
31
+ if isinstance(model, StateSpaceModel) and query == "filter":
32
+ return Approximate(ParticleFilter(), "bootstrap particle filter", "O(1/sqrt(N)) Monte Carlo")
33
+ return Intractable(f"no engine for {type(model).__name__} / query={query!r}")
markovlib/duration.py ADDED
@@ -0,0 +1,69 @@
1
+ """Duration models for semi-Markov chains — explicit dwell-time distributions.
2
+
3
+ In a plain Markov chain the time spent in a state is *implicitly* geometric (set by the self-transition
4
+ probability). A semi-Markov chain instead gives each state an **explicit** dwell distribution and forbids
5
+ self-transitions — persistence is owned by the duration, not the transition. :class:`NegBinomDuration`
6
+ is the shifted negative-binomial dwell (a mean and a dispersion); its ``concentration → 1`` limit is the
7
+ geometric dwell, so a semi-Markov chain with geometric durations degrades exactly to a plain HMM.
8
+
9
+ Both the point mass (:meth:`~NegBinomDuration.log_pmf`) and the right tail
10
+ (:meth:`~NegBinomDuration.log_survival`, ``log P(dwell ≥ d)``) are exposed: the segmental decoder scores
11
+ a naturally-ended segment by the pmf and a segment that hits the duration cap by the survival (the
12
+ standard explicit-duration **right-censoring**, which lets a state persist past the cap).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Protocol
19
+
20
+ from scipy.stats import nbinom
21
+
22
+
23
+ class DurationModel(Protocol):
24
+ """A dwell-time distribution over integer durations ``d ≥ 1``."""
25
+
26
+ def log_pmf(self, d: int) -> float:
27
+ """``log P(dwell = d)`` for ``d ≥ 1``."""
28
+ ...
29
+
30
+ def log_survival(self, d: int) -> float:
31
+ """``log P(dwell ≥ d)`` — the right-censored mass a capped segment scores."""
32
+ ...
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class NegBinomDuration:
37
+ """Shifted negative-binomial dwell over ``d ≥ 1`` with given ``mean`` (> 1) and ``concentration`` (> 0).
38
+
39
+ Parameterized so ``E[d] = mean`` exactly: with ``k = d - 1 ~ NB(r = concentration, p)`` and
40
+ ``p = r / (r + mean - 1)``. The ``concentration → 1`` limit is the geometric dwell
41
+ ``P(d) = (1 - q)^{d-1} q`` with ``q = 1 / mean`` (a plain HMM's implicit dwell); larger
42
+ ``concentration`` concentrates the mass around the mean. Backed by :data:`scipy.stats.nbinom`.
43
+ """
44
+
45
+ mean: float
46
+ concentration: float
47
+
48
+ def __post_init__(self) -> None:
49
+ if self.mean <= 1.0:
50
+ raise ValueError(f"mean dwell must be > 1 (got {self.mean})")
51
+ if self.concentration <= 0.0:
52
+ raise ValueError(f"concentration must be > 0 (got {self.concentration})")
53
+
54
+ def _params(self) -> tuple[float, float]:
55
+ r = self.concentration
56
+ p = r / (r + self.mean - 1.0)
57
+ return r, p
58
+
59
+ def log_pmf(self, d: int) -> float:
60
+ """``log P(dwell = d)`` — the shifted negative-binomial log-pmf at integer ``d ≥ 1``."""
61
+ if d < 1:
62
+ raise ValueError(f"duration must be >= 1 (got {d})")
63
+ r, p = self._params()
64
+ return float(nbinom.logpmf(d - 1, r, p))
65
+
66
+ def log_survival(self, d: int) -> float:
67
+ """``log P(dwell ≥ d)``. Since ``d = k + 1``, ``P(d ≥ D) = P(k > D - 2) = nbinom.sf(D - 2)``."""
68
+ r, p = self._params()
69
+ return float(nbinom.logsf(d - 2, r, p))
@@ -0,0 +1 @@
1
+ """Inference engines — each a concrete resolver of one or more queries over a model."""
@@ -0,0 +1,96 @@
1
+ """``ExactChain`` — exact inference for a finite discrete chain.
2
+
3
+ The reference engine: for a finite-state chain every query (filter / smooth / decode / loglik) and the
4
+ EM E-step (:meth:`ExactChain.expected_stats`) are *exact*, computed by the belief-generic forward
5
+ recursion plus the categorical second passes in :mod:`markovlib.engines.recursion`. Being exact, it is
6
+ also the **reference** future fast/approximate engines are tested against (validated against brute-force
7
+ path enumeration in the tests).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+ from scipy.special import logsumexp
17
+
18
+ from markovlib.belief import Categorical
19
+ from markovlib.engines.recursion import backward_messages, categorical_predict, forward, viterbi
20
+ from markovlib.model import DiscreteChain
21
+ from markovlib.semiring import SumProduct
22
+
23
+ Float = npt.NDArray[np.float64]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SmoothResult:
28
+ """Posterior marginals ``gamma`` ``(T, S)`` (rows sum to 1) and the data log-likelihood."""
29
+
30
+ gamma: Float
31
+ loglik: float
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ExpectedStats:
36
+ """The EM E-step's expected sufficient statistics.
37
+
38
+ ``gamma`` ``(T, S)`` are the state marginals ``P(state_t = s | obs)``; ``xi`` ``(T-1, S, S)`` are the
39
+ pairwise marginals ``P(state_t = i, state_{t+1} = j | obs)`` — together they are everything the
40
+ initial/transition M-step needs. ``loglik`` is the observed-data log-likelihood at these parameters.
41
+ """
42
+
43
+ gamma: Float
44
+ xi: Float
45
+ loglik: float
46
+
47
+
48
+ class ExactChain:
49
+ """Exact filter / smooth / decode / loglik + EM E-step for a :class:`~markovlib.model.DiscreteChain`."""
50
+
51
+ def _forward(self, model: DiscreteChain, log_emissions: Float) -> list[Categorical]:
52
+ """The sum-product forward beliefs ``α`` — the belief-generic recursion at categorical instance."""
53
+ factors = [Categorical(row) for row in log_emissions]
54
+ predict = categorical_predict(model.log_trans, SumProduct())
55
+ return forward(Categorical(model.log_init), predict, factors)
56
+
57
+ def _alpha_beta(self, model: DiscreteChain, log_emissions: Float) -> tuple[Float, Float, float]:
58
+ """The log forward ``α``, the log backward ``β``, and the data log-likelihood."""
59
+ alpha = self._forward(model, log_emissions)
60
+ log_alpha = np.stack([belief.log_p for belief in alpha])
61
+ beta = backward_messages(model.log_trans, log_emissions)
62
+ loglik = float(logsumexp(log_alpha[-1]))
63
+ return log_alpha, beta, loglik
64
+
65
+ @staticmethod
66
+ def _gamma(log_alpha: Float, beta: Float) -> Float:
67
+ """Posterior state marginals ``γ[t] = normalize(α[t] + β[t])``."""
68
+ unnorm = log_alpha + beta
69
+ gamma: Float = np.exp(unnorm - logsumexp(unnorm, axis=1, keepdims=True))
70
+ return gamma
71
+
72
+ def smooth(self, model: DiscreteChain, log_emissions: Float) -> SmoothResult:
73
+ """Forward–backward: posterior marginals + log-likelihood (sum-product)."""
74
+ log_alpha, beta, loglik = self._alpha_beta(model, log_emissions)
75
+ return SmoothResult(gamma=self._gamma(log_alpha, beta), loglik=loglik)
76
+
77
+ def loglik(self, model: DiscreteChain, log_emissions: Float) -> float:
78
+ """The data log-likelihood ``log p(observations)`` (forward pass only)."""
79
+ return self._forward(model, log_emissions)[-1].log_mass()
80
+
81
+ def expected_stats(self, model: DiscreteChain, log_emissions: Float) -> ExpectedStats:
82
+ """The EM E-step: state marginals ``γ``, pairwise marginals ``ξ``, and the log-likelihood.
83
+
84
+ ``ξ[t, i, j] = normalize( α[t, i] + log A[i, j] + log b_{t+1}(j) + β[t+1, j] )`` (normalized by the
85
+ log-likelihood). It marginalizes to ``γ`` (``Σ_j ξ[t, i, j] = γ[t, i]``) and sums to 1 per ``t``.
86
+ """
87
+ log_alpha, beta, loglik = self._alpha_beta(model, log_emissions)
88
+ gamma = self._gamma(log_alpha, beta)
89
+ transition = model.log_trans[None, :, :] if model.log_trans.ndim == 2 else model.log_trans
90
+ log_xi = log_alpha[:-1, :, None] + transition + (log_emissions[1:] + beta[1:])[:, None, :] - loglik
91
+ return ExpectedStats(gamma=gamma, xi=np.exp(log_xi), loglik=loglik)
92
+
93
+ def decode(self, model: DiscreteChain, log_emissions: Float) -> npt.NDArray[np.intp]:
94
+ """The single most likely state path (Viterbi / MAP, max-plus)."""
95
+ path, _ = viterbi(model.log_init, model.log_trans, log_emissions)
96
+ return path
@@ -0,0 +1,114 @@
1
+ """``GaussianChain`` — the Kalman filter, as the *same* forward recursion with a Gaussian belief.
2
+
3
+ This is the payoff of the belief abstraction: :func:`~markovlib.engines.recursion.forward` is reused
4
+ verbatim — only the belief type changes from :class:`~markovlib.belief.Categorical` to
5
+ :class:`~markovlib.belief.GaussianBelief`. The observation at each step enters as a fixed Gaussian
6
+ *factor* (the likelihood ``N(y_t | H x, R)`` written as a canonical potential in ``x``), ``combine`` is
7
+ the canonical-form product, and the transition push-forward is the linear-Gaussian prediction. The
8
+ running message is the unnormalized filtering density ``p(x_t, y_{0:t})``; its mean/covariance are the
9
+ Kalman filtered estimate and its ``log_mass`` accumulates the data log-likelihood — so the categorical
10
+ HMM and the Kalman filter are literally two instantiations of one recursion.
11
+
12
+ The RTS smoother (the Gaussian backward pass) and a particle filter (a sibling engine needing reified
13
+ randomness) are deliberate next steps.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+
21
+ import numpy as np
22
+ import numpy.typing as npt
23
+
24
+ from markovlib.belief import GaussianBelief
25
+ from markovlib.engines.recursion import forward
26
+ from markovlib.model import LinearGaussian
27
+
28
+ Float = npt.NDArray[np.float64]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class FilterResult:
33
+ """Kalman filtered estimates: per-step ``means`` ``(T, D)``, ``covariances`` ``(T, D, D)``, and loglik."""
34
+
35
+ means: Float
36
+ covariances: Float
37
+ loglik: float
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class GaussianSmoothResult:
42
+ """RTS-smoothed estimates ``p(x_t | y_{0:T-1})``: per-step ``means``, ``covariances``, and the data loglik."""
43
+
44
+ means: Float
45
+ covariances: Float
46
+ loglik: float
47
+
48
+
49
+ def _observation_factor(model: LinearGaussian, y: Float) -> GaussianBelief:
50
+ """The likelihood ``N(y | H x, R)`` as a canonical potential in ``x`` (a fixed per-step factor)."""
51
+ obs_precision = np.linalg.inv(model.obs_noise)
52
+ transposed = model.observation.T @ obs_precision
53
+ precision = transposed @ model.observation
54
+ eta = transposed @ y
55
+ dim_obs = int(y.shape[0])
56
+ _, log_det_r = np.linalg.slogdet(model.obs_noise)
57
+ log_scale = -0.5 * (dim_obs * np.log(2.0 * np.pi) + float(log_det_r)) - 0.5 * float(y @ obs_precision @ y)
58
+ return GaussianBelief(eta, precision, log_scale)
59
+
60
+
61
+ def _prior_factor(model: LinearGaussian) -> GaussianBelief:
62
+ """The initial prior ``N(m0, P0)`` as a normalized canonical potential."""
63
+ precision = np.linalg.inv(model.init_cov)
64
+ eta = precision @ model.init_mean
65
+ return GaussianBelief(eta, precision, 0.0).normalized()
66
+
67
+
68
+ def gaussian_predict(transition: Float, process_noise: Float) -> Callable[[GaussianBelief, int], GaussianBelief]:
69
+ """The linear-Gaussian push-forward ``μ ↦ Aμ``, ``Σ ↦ AΣAᵀ + Q`` (mass-preserving)."""
70
+
71
+ def predict(belief: GaussianBelief, step: int) -> GaussianBelief:
72
+ log_mass = belief.log_mass() # prediction is a convolution — it preserves the total mass
73
+ mean = transition @ belief.mean()
74
+ cov = transition @ belief.covariance() @ transition.T + process_noise
75
+ precision = np.linalg.inv(cov)
76
+ eta = precision @ mean
77
+ unscaled = GaussianBelief(eta, precision, 0.0)
78
+ return GaussianBelief(eta, precision, log_mass - unscaled.log_mass())
79
+
80
+ return predict
81
+
82
+
83
+ class GaussianChain:
84
+ """Exact Kalman filtering and RTS smoothing for a :class:`~markovlib.model.LinearGaussian` model."""
85
+
86
+ def _filtered_beliefs(self, model: LinearGaussian, observations: Float) -> list[GaussianBelief]:
87
+ """The filtered messages — the shared forward recursion with a Gaussian belief."""
88
+ prior = _prior_factor(model)
89
+ factors = [_observation_factor(model, y) for y in observations]
90
+ predict = gaussian_predict(model.transition, model.process_noise)
91
+ return forward(prior, predict, factors)
92
+
93
+ def filter(self, model: LinearGaussian, observations: Float) -> FilterResult:
94
+ """The filtered ``p(x_t | y_{0:t})`` mean/covariance per step + the data log-likelihood."""
95
+ beliefs = self._filtered_beliefs(model, observations)
96
+ means = np.stack([belief.mean() for belief in beliefs])
97
+ covariances = np.stack([belief.covariance() for belief in beliefs])
98
+ return FilterResult(means=means, covariances=covariances, loglik=beliefs[-1].log_mass())
99
+
100
+ def smooth(self, model: LinearGaussian, observations: Float) -> GaussianSmoothResult:
101
+ """The RTS-smoothed ``p(x_t | y_{0:T-1})`` mean/covariance per step (filter forward + RTS backward)."""
102
+ beliefs = self._filtered_beliefs(model, observations)
103
+ filtered_means = [belief.mean() for belief in beliefs]
104
+ filtered_covs = [belief.covariance() for belief in beliefs]
105
+ transition, process_noise = model.transition, model.process_noise
106
+ smoothed_means = list(filtered_means) # the last step is already smoothed (= filtered)
107
+ smoothed_covs = list(filtered_covs)
108
+ for t in range(len(beliefs) - 2, -1, -1):
109
+ predicted_mean = transition @ filtered_means[t]
110
+ predicted_cov = transition @ filtered_covs[t] @ transition.T + process_noise
111
+ gain = filtered_covs[t] @ transition.T @ np.linalg.inv(predicted_cov)
112
+ smoothed_means[t] = filtered_means[t] + gain @ (smoothed_means[t + 1] - predicted_mean)
113
+ smoothed_covs[t] = filtered_covs[t] + gain @ (smoothed_covs[t + 1] - predicted_cov) @ gain.T
114
+ return GaussianSmoothResult(np.stack(smoothed_means), np.stack(smoothed_covs), beliefs[-1].log_mass())
@@ -0,0 +1,84 @@
1
+ """``ParticleFilter`` — a bootstrap (sequential-importance-resampling) filter, a *sibling* engine.
2
+
3
+ Unlike the categorical/Gaussian filters, this does **not** use the shared
4
+ :func:`~markovlib.engines.recursion.forward`: the per-step evidence is a *state-dependent* reweighting
5
+ (the likelihood evaluated at the particle locations), not a fixed factor to ``combine``, and the
6
+ prediction step *samples* the transition. So it stands alongside the segmental DP as an engine that does
7
+ not fit the two-sweep mold — and it is the library's first **approximate** engine (Monte Carlo,
8
+ ``O(1/√N)``), which is why :func:`~markovlib.dispatch.resolve_engine` reports it as ``Approximate``.
9
+
10
+ Randomness is **reified**: the whole filter is a deterministic function of its ``seed`` (and ``model``,
11
+ ``observations``, ``n_particles``) — the same "reify the seed as an explicit input" discipline that keeps
12
+ the engine referentially transparent. Resampling (systematic, triggered when the effective sample size
13
+ falls below ``resample_threshold · N``) and the marginal-likelihood estimate are the standard SIR forms.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ import numpy as np
21
+ import numpy.typing as npt
22
+ from scipy.special import logsumexp
23
+
24
+ from markovlib.model import StateSpaceModel
25
+
26
+ Float = npt.NDArray[np.float64]
27
+ Int = npt.NDArray[np.intp]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ParticleResult:
32
+ """Filtered (weighted particle) ``means`` ``(T, D)``, the marginal-likelihood estimate, and per-step ESS."""
33
+
34
+ means: Float
35
+ loglik: float
36
+ ess: Float
37
+
38
+
39
+ def _systematic_resample(weights: Float, rng: np.random.Generator) -> Int:
40
+ """Systematic resampling: one uniform draw fixes ``N`` evenly-spaced positions on the CDF."""
41
+ n = weights.shape[0]
42
+ positions = (float(rng.random()) + np.arange(n)) / n
43
+ cumulative = np.cumsum(weights)
44
+ cumulative[-1] = 1.0 # guard against floating-point drift past the last bin
45
+ indices: Int = np.searchsorted(cumulative, positions).astype(np.intp)
46
+ return indices
47
+
48
+
49
+ class ParticleFilter:
50
+ """A bootstrap particle filter for a :class:`~markovlib.model.StateSpaceModel`."""
51
+
52
+ def filter(
53
+ self,
54
+ model: StateSpaceModel,
55
+ observations: Float,
56
+ *,
57
+ n_particles: int,
58
+ seed: int,
59
+ resample_threshold: float = 0.5,
60
+ ) -> ParticleResult:
61
+ """Filter ``observations`` with ``n_particles`` particles; deterministic given ``seed``."""
62
+ rng = np.random.default_rng(seed)
63
+ particles = np.asarray(model.sample_prior(rng, n_particles), dtype=np.float64)
64
+ log_weights = np.full(n_particles, -np.log(n_particles))
65
+ means: list[Float] = []
66
+ ess_per_step: list[float] = []
67
+ loglik = 0.0
68
+
69
+ for step, y in enumerate(observations):
70
+ if step > 0:
71
+ particles = np.asarray(model.propagate(rng, particles), dtype=np.float64)
72
+ log_weights = log_weights + np.asarray(model.log_likelihood(y, particles), dtype=np.float64)
73
+ increment = float(logsumexp(log_weights)) # log Σ Wᵢ·p(yₜ|xᵢ) — the marginal-likelihood step
74
+ loglik += increment
75
+ log_weights = log_weights - increment # renormalize (Σ exp = 1)
76
+ weights = np.exp(log_weights)
77
+ ess = float(1.0 / np.sum(weights**2))
78
+ means.append(weights @ particles)
79
+ ess_per_step.append(ess)
80
+ if ess < resample_threshold * n_particles:
81
+ particles = particles[_systematic_resample(weights, rng)]
82
+ log_weights = np.full(n_particles, -np.log(n_particles))
83
+
84
+ return ParticleResult(means=np.array(means), loglik=loglik, ess=np.array(ess_per_step))
@@ -0,0 +1,94 @@
1
+ """The one forward recursion (belief-generic), plus the categorical second passes.
2
+
3
+ :func:`forward` is *the* load-bearing primitive: a single recursion generic over the belief type and a
4
+ per-step ``predict`` push-forward, touching the belief only through :meth:`~markovlib.belief.Belief.combine`.
5
+ The categorical chain supplies :func:`categorical_predict` (a semiring matrix-vector push): with
6
+ :class:`~markovlib.semiring.SumProduct` the messages are the forward ``α`` of forward–backward; with
7
+ :class:`~markovlib.semiring.MaxPlus` they are the Viterbi score table ``δ``. The sum-product smoother adds
8
+ :func:`backward_messages`; the max-plus decoder adds argmax backpointers + a backtrace (:func:`viterbi`).
9
+
10
+ Transitions may be **homogeneous** (a single ``(S, S)`` ``log_trans``) or **time-varying** (a
11
+ ``(T-1, S, S)`` tensor, where ``log_trans[k]`` is the step-``k`` → step-``k+1`` transition). Every pass
12
+ dispatches on ``log_trans.ndim``, so the gated/inhomogeneous case (a contact-style per-frame transition)
13
+ runs the same code as the plain chain.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable, Sequence
19
+
20
+ import numpy as np
21
+ import numpy.typing as npt
22
+ from scipy.special import logsumexp
23
+
24
+ from markovlib.belief import Belief, Categorical
25
+ from markovlib.semiring import Semiring
26
+
27
+ Float = npt.NDArray[np.float64]
28
+ Int = npt.NDArray[np.intp]
29
+
30
+
31
+ def forward[B: Belief](initial: B, predict: Callable[[B, int], B], factors: Sequence[B]) -> list[B]:
32
+ """The forward recursion ``msg[t] = predict(msg[t-1], t) ⊗ factor[t]`` (``msg[0] = initial ⊗ factor[0]``).
33
+
34
+ Generic over the belief type ``B``: it touches the belief only through ``combine`` and the supplied
35
+ ``predict`` push-forward (given the previous belief and the target step ``t``), so categorical /
36
+ Gaussian / particle beliefs — homogeneous or time-varying — all run this one loop.
37
+ """
38
+ messages = [initial.combine(factors[0])]
39
+ for t in range(1, len(factors)):
40
+ messages.append(predict(messages[t - 1], t).combine(factors[t]))
41
+ return messages
42
+
43
+
44
+ def categorical_predict(log_trans: Float, semiring: Semiring) -> Callable[[Categorical, int], Categorical]:
45
+ """The chain push-forward for categorical beliefs: ``b'[j] = ⊕_i ( b[i] ⊗ trans[i, j] )``.
46
+
47
+ ``⊗`` is ``+`` (log domain); ``⊕`` is ``semiring.reduce``. ``log_trans`` is ``(S, S)`` (homogeneous)
48
+ or ``(T-1, S, S)`` (time-varying); entering step ``t`` uses the step-``(t-1)`` → step-``t`` transition.
49
+ """
50
+ homogeneous = log_trans.ndim == 2
51
+
52
+ def predict(belief: Categorical, step: int) -> Categorical:
53
+ transition = log_trans if homogeneous else log_trans[step - 1]
54
+ return Categorical(semiring.reduce(belief.log_p[:, None] + transition, axis=0))
55
+
56
+ return predict
57
+
58
+
59
+ def backward_messages(log_trans: Float, log_em: Float) -> Float:
60
+ """The sum-product backward pass ``β[t, i] = logsumexp_j ( trans_t[i, j] + em[t+1, j] + β[t+1, j] )``.
61
+
62
+ ``trans_t`` is the step-``t`` → step-``t+1`` transition (``log_trans`` itself if homogeneous, else
63
+ ``log_trans[t]``).
64
+ """
65
+ n_steps, n_states = log_em.shape
66
+ homogeneous = log_trans.ndim == 2
67
+ beta = np.zeros((n_steps, n_states), dtype=np.float64)
68
+ for t in range(n_steps - 2, -1, -1):
69
+ transition = log_trans if homogeneous else log_trans[t]
70
+ beta[t] = logsumexp(transition + (log_em[t + 1] + beta[t + 1])[None, :], axis=1)
71
+ return beta
72
+
73
+
74
+ def viterbi(log_init: Float, log_trans: Float, log_em: Float) -> tuple[Int, float]:
75
+ """Max-plus forward with argmax backpointers + backtrace → ``(map_path, best_logscore)``.
76
+
77
+ Shares the recursion *shape* of :func:`forward` (semiring = MaxPlus) but additionally records, at each
78
+ step, which predecessor achieved the max. Supports homogeneous and time-varying ``log_trans``.
79
+ """
80
+ n_steps, n_states = log_em.shape
81
+ homogeneous = log_trans.ndim == 2
82
+ delta = np.empty((n_steps, n_states), dtype=np.float64)
83
+ psi = np.empty((n_steps, n_states), dtype=np.intp)
84
+ delta[0] = log_init + log_em[0]
85
+ for t in range(1, n_steps):
86
+ transition = log_trans if homogeneous else log_trans[t - 1]
87
+ scores = delta[t - 1][:, None] + transition
88
+ psi[t] = np.argmax(scores, axis=0)
89
+ delta[t] = log_em[t] + np.max(scores, axis=0)
90
+ path = np.empty(n_steps, dtype=np.intp)
91
+ path[-1] = np.intp(np.argmax(delta[-1]))
92
+ for t in range(n_steps - 2, -1, -1):
93
+ path[t] = psi[t + 1, path[t + 1]]
94
+ return path, float(delta[-1].max())