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 +58 -0
- markovlib/belief.py +109 -0
- markovlib/dispatch.py +33 -0
- markovlib/duration.py +69 -0
- markovlib/engines/__init__.py +1 -0
- markovlib/engines/exact_chain.py +96 -0
- markovlib/engines/gaussian.py +114 -0
- markovlib/engines/particle.py +84 -0
- markovlib/engines/recursion.py +94 -0
- markovlib/engines/segmental.py +241 -0
- markovlib/fungeom.py +41 -0
- markovlib/learn.py +63 -0
- markovlib/model.py +98 -0
- markovlib/py.typed +0 -0
- markovlib/query.py +101 -0
- markovlib/resolution.py +47 -0
- markovlib/semiring.py +55 -0
- marmo-0.1.0.dist-info/METADATA +173 -0
- marmo-0.1.0.dist-info/RECORD +21 -0
- marmo-0.1.0.dist-info/WHEEL +4 -0
- marmo-0.1.0.dist-info/licenses/LICENSE +201 -0
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())
|