sofic 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.
- sofic/__init__.py +185 -0
- sofic/automata/__init__.py +207 -0
- sofic/automata/_config_simulation.py +40 -0
- sofic/automata/active.py +611 -0
- sofic/automata/alergia.py +222 -0
- sofic/automata/algorithms.py +376 -0
- sofic/automata/atomaton.py +58 -0
- sofic/automata/base.py +161 -0
- sofic/automata/buchi.py +23 -0
- sofic/automata/buchi_simulation.py +67 -0
- sofic/automata/canonical_dual.py +18 -0
- sofic/automata/canonical_extraction.py +122 -0
- sofic/automata/dfa.py +85 -0
- sofic/automata/dfasat.py +195 -0
- sofic/automata/edsm.py +219 -0
- sofic/automata/enumeration.py +44 -0
- sofic/automata/icdfa.py +421 -0
- sofic/automata/idfa.py +363 -0
- sofic/automata/languages/__init__.py +39 -0
- sofic/automata/languages/_quotient_utils.py +64 -0
- sofic/automata/languages/atoms.py +31 -0
- sofic/automata/languages/automaton_ops.py +243 -0
- sofic/automata/languages/base.py +67 -0
- sofic/automata/languages/operations.py +78 -0
- sofic/automata/languages/quotients.py +66 -0
- sofic/automata/languages/residuals.py +25 -0
- sofic/automata/learning.py +79 -0
- sofic/automata/nfa.py +39 -0
- sofic/automata/nwa.py +343 -0
- sofic/automata/nwa_simulation.py +56 -0
- sofic/automata/observation.py +40 -0
- sofic/automata/papni.py +301 -0
- sofic/automata/regex.py +128 -0
- sofic/automata/rfsa.py +35 -0
- sofic/automata/rpni.py +193 -0
- sofic/automata/subsequential.py +201 -0
- sofic/automata/transducer_operations.py +350 -0
- sofic/automata/transducer_simulation.py +150 -0
- sofic/automata/transducers.py +365 -0
- sofic/automata/unifilar.py +107 -0
- sofic/automata/vpa.py +1373 -0
- sofic/automata/vpa_simulation.py +53 -0
- sofic/base.py +153 -0
- sofic/core.py +47 -0
- sofic/examples/__init__.py +86 -0
- sofic/examples/epsilon_machines.py +1089 -0
- sofic/examples/processes.py +1491 -0
- sofic/examples/shifts.py +144 -0
- sofic/exceptions.py +33 -0
- sofic/generators/__init__.py +115 -0
- sofic/generators/_word_measures.py +94 -0
- sofic/generators/alternative_complexity.py +104 -0
- sofic/generators/base.py +327 -0
- sofic/generators/bidirectional_construction.py +717 -0
- sofic/generators/bidirectional_epsilon_machine.py +689 -0
- sofic/generators/block_convergence.py +668 -0
- sofic/generators/block_entropy.py +578 -0
- sofic/generators/channel_measures.py +75 -0
- sofic/generators/conversions.py +182 -0
- sofic/generators/directional_flow.py +245 -0
- sofic/generators/edge_emissions.py +36 -0
- sofic/generators/edge_machine.py +178 -0
- sofic/generators/epsilon_construction.py +193 -0
- sofic/generators/epsilon_inference.py +703 -0
- sofic/generators/epsilon_machine.py +557 -0
- sofic/generators/epsilon_transducer.py +168 -0
- sofic/generators/epsilon_transducer_construction.py +185 -0
- sofic/generators/epsilon_transducer_inference.py +499 -0
- sofic/generators/hmm_inference.py +719 -0
- sofic/generators/information_diagram.py +428 -0
- sofic/generators/lumping.py +447 -0
- sofic/generators/markov.py +100 -0
- sofic/generators/mealy.py +156 -0
- sofic/generators/measures.py +257 -0
- sofic/generators/minimal_generative_model.py +821 -0
- sofic/generators/mixed_state.py +250 -0
- sofic/generators/mixed_state_construction.py +163 -0
- sofic/generators/moore.py +75 -0
- sofic/generators/nmachine.py +78 -0
- sofic/generators/nmachine_construction.py +70 -0
- sofic/generators/pfa.py +100 -0
- sofic/generators/prob.py +291 -0
- sofic/generators/process_equivalence.py +207 -0
- sofic/generators/quasi_inference.py +74 -0
- sofic/generators/quasi_realization.py +97 -0
- sofic/generators/reversal.py +66 -0
- sofic/generators/stack_hmm.py +426 -0
- sofic/generators/stack_inference.py +509 -0
- sofic/generators/stationary.py +134 -0
- sofic/generators/stochastic.py +65 -0
- sofic/generators/synchronization.py +407 -0
- sofic/generators/topological_epsilon_enumeration.py +349 -0
- sofic/generators/words.py +226 -0
- sofic/graph.py +135 -0
- sofic/indexing.py +31 -0
- sofic/inference/__init__.py +45 -0
- sofic/inference/bayesian/__init__.py +68 -0
- sofic/inference/bayesian/comparison.py +199 -0
- sofic/inference/bayesian/counts.py +219 -0
- sofic/inference/bayesian/diversity.py +254 -0
- sofic/inference/bayesian/epsilon.py +270 -0
- sofic/inference/bayesian/hdp_hmm.py +340 -0
- sofic/inference/bayesian/markov.py +294 -0
- sofic/inference/bayesian/pymc_backend.py +71 -0
- sofic/inference/bayesian/stack_hmm.py +215 -0
- sofic/inference/model_selection.py +365 -0
- sofic/inference/spectral.py +564 -0
- sofic/operations.py +16 -0
- sofic/properties.py +339 -0
- sofic/serialization.py +450 -0
- sofic/shifts/__init__.py +48 -0
- sofic/shifts/algorithms.py +84 -0
- sofic/shifts/base.py +49 -0
- sofic/shifts/cover_construction.py +76 -0
- sofic/shifts/covers.py +47 -0
- sofic/shifts/dyck_algorithms.py +100 -0
- sofic/shifts/dyck_enumeration.py +275 -0
- sofic/shifts/markov_dyck.py +172 -0
- sofic/shifts/parry_construction.py +82 -0
- sofic/shifts/sft.py +104 -0
- sofic/shifts/sft_construction.py +52 -0
- sofic/shifts/sliding_block_code.py +156 -0
- sofic/shifts/sofic.py +111 -0
- sofic/shifts/sofic_dyck.py +110 -0
- sofic/shifts/sofic_relation.py +64 -0
- sofic/shifts/textile.py +104 -0
- sofic/shifts/tmc.py +46 -0
- sofic/shifts/tmc_construction.py +58 -0
- sofic/shifts/topological_anatomy.py +150 -0
- sofic/states.py +27 -0
- sofic/testing/__init__.py +8 -0
- sofic/testing/strategies.py +154 -0
- sofic/viz/__init__.py +16 -0
- sofic/viz/_context.py +345 -0
- sofic/viz/_edge.py +216 -0
- sofic/viz/_format.py +89 -0
- sofic/viz/_labels.py +34 -0
- sofic/viz/_names.py +17 -0
- sofic/viz/_rational.py +20 -0
- sofic/viz/_tikz_compile.py +177 -0
- sofic/viz/_tikz_format.py +122 -0
- sofic/viz/_tikz_layout.py +218 -0
- sofic/viz/assets/vaucanson.tikz +71 -0
- sofic/viz/graphviz.py +158 -0
- sofic/viz/idiagram.py +350 -0
- sofic/viz/tikz.py +381 -0
- sofic-0.1.0.dist-info/METADATA +444 -0
- sofic-0.1.0.dist-info/RECORD +150 -0
- sofic-0.1.0.dist-info/WHEEL +4 -0
- sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
"""Inference for hidden Markov models.
|
|
2
|
+
|
|
3
|
+
Forward/backward/Viterbi decoding and sampling, plus the Cappe, Moulines &
|
|
4
|
+
Ryden (2005) toolbox: fixed-interval smoothing (one- and two-slice marginals),
|
|
5
|
+
Baum-Welch EM parameter re-estimation, and the score / observed information via
|
|
6
|
+
the Fisher and Louis identities.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Hashable, Iterable, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from sofic.generators.base import HiddenMarkovModel
|
|
18
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _as_mealy_hmm(hmm: HiddenMarkovModel) -> Any:
|
|
22
|
+
"""Return a Mealy-style representation through the HMM representation hook."""
|
|
23
|
+
return hmm.to_mealy()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _emission_transition_tensors_from_mealy(
|
|
27
|
+
hmm: Any,
|
|
28
|
+
) -> tuple[np.ndarray, dict[Any, np.ndarray]]:
|
|
29
|
+
"""Return initial vector ``pi`` and symbol -> joint transition matrices."""
|
|
30
|
+
from sofic.generators.prob import as_prob, has_symbolic, zeros
|
|
31
|
+
|
|
32
|
+
idx = hmm.reindex()
|
|
33
|
+
n = len(idx)
|
|
34
|
+
edge_probs = [transition.data.get(ATTR_PROB, 0.0) for transition in hmm.transitions()]
|
|
35
|
+
init_probs = list(hmm.initial_distribution.values())
|
|
36
|
+
symbolic = has_symbolic(edge_probs) or has_symbolic(init_probs)
|
|
37
|
+
|
|
38
|
+
pi = zeros((n,), symbolic=symbolic)
|
|
39
|
+
for state, mass in hmm.initial_distribution.items():
|
|
40
|
+
pi[idx.index(state)] = as_prob(mass)
|
|
41
|
+
|
|
42
|
+
symbols: set[Any] = set(hmm.observation_alphabet)
|
|
43
|
+
joint: dict[Any, np.ndarray] = {symbol: zeros((n, n), symbolic=symbolic) for symbol in symbols}
|
|
44
|
+
|
|
45
|
+
for transition in hmm.transitions():
|
|
46
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
47
|
+
if emission is None:
|
|
48
|
+
continue
|
|
49
|
+
i = idx.index(transition.source)
|
|
50
|
+
j = idx.index(transition.target)
|
|
51
|
+
joint[emission][i, j] = as_prob(joint[emission][i, j]) + as_prob(transition.data.get(ATTR_PROB, 0.0))
|
|
52
|
+
return pi, joint
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _emission_transition_tensors(
|
|
56
|
+
hmm: HiddenMarkovModel,
|
|
57
|
+
) -> tuple[np.ndarray, dict[Any, np.ndarray]]:
|
|
58
|
+
"""Return initial vector ``pi`` and symbol -> joint transition matrices."""
|
|
59
|
+
return _emission_transition_tensors_from_mealy(_as_mealy_hmm(hmm))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _limit_distribution_from_initial(pi_initial: np.ndarray, transition: np.ndarray) -> np.ndarray | None:
|
|
63
|
+
"""Return the limiting occupation law of ``pi_initial`` under ``transition``.
|
|
64
|
+
|
|
65
|
+
On reducible chains the left-eigenvector stationary law is not unique; the
|
|
66
|
+
process measure is the limit reached from the model's initial distribution.
|
|
67
|
+
"""
|
|
68
|
+
pi = np.asarray(pi_initial, dtype=float).copy()
|
|
69
|
+
total = float(pi.sum())
|
|
70
|
+
if total <= 0.0:
|
|
71
|
+
return None
|
|
72
|
+
pi /= total
|
|
73
|
+
matrix = np.asarray(transition, dtype=float)
|
|
74
|
+
n = len(pi)
|
|
75
|
+
for _ in range(max(100, 20 * n)):
|
|
76
|
+
nxt = pi @ matrix
|
|
77
|
+
mass = float(nxt.sum())
|
|
78
|
+
if mass <= 0.0:
|
|
79
|
+
return None
|
|
80
|
+
nxt /= mass
|
|
81
|
+
if np.allclose(nxt, pi, rtol=1e-12, atol=1e-14):
|
|
82
|
+
pi = nxt
|
|
83
|
+
break
|
|
84
|
+
pi = nxt
|
|
85
|
+
pi[np.isclose(pi, 0.0, atol=1e-15)] = 0.0
|
|
86
|
+
mass = float(pi.sum())
|
|
87
|
+
if mass <= 0.0:
|
|
88
|
+
return None
|
|
89
|
+
return pi / mass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _stationary_emission_tensors(
|
|
93
|
+
hmm: HiddenMarkovModel,
|
|
94
|
+
) -> tuple[np.ndarray, dict[Any, np.ndarray]]:
|
|
95
|
+
"""Return the stationary state law and symbol -> joint transition matrices.
|
|
96
|
+
|
|
97
|
+
Block/word statistics of a *stationary* process must weight the initial state
|
|
98
|
+
by the stationary distribution, not by the model's (possibly transient)
|
|
99
|
+
``initial_distribution``. The stationary vector is recovered directly from the
|
|
100
|
+
summed emission-transition matrices so it stays aligned with ``joint``'s state
|
|
101
|
+
indexing.
|
|
102
|
+
|
|
103
|
+
When the chain is reducible (multiple absorbing classes), the eigenvector
|
|
104
|
+
stationary law is not unique — prefer the limiting occupation reached from
|
|
105
|
+
``initial_distribution``. Fall back to the eigenvector solution, then to the
|
|
106
|
+
initial vector, only when the limit cannot be formed.
|
|
107
|
+
"""
|
|
108
|
+
from sofic.generators.prob import zeros
|
|
109
|
+
from sofic.generators.stationary import stationary_distribution_from_transition
|
|
110
|
+
|
|
111
|
+
pi_initial, joint = _emission_transition_tensors(hmm)
|
|
112
|
+
n = len(pi_initial)
|
|
113
|
+
if n == 0:
|
|
114
|
+
return pi_initial, joint
|
|
115
|
+
symbolic = pi_initial.dtype == object or any(matrix.dtype == object for matrix in joint.values())
|
|
116
|
+
transition = zeros((n, n), symbolic=symbolic)
|
|
117
|
+
for matrix in joint.values():
|
|
118
|
+
transition = transition + matrix
|
|
119
|
+
if not symbolic:
|
|
120
|
+
limited = _limit_distribution_from_initial(pi_initial, transition)
|
|
121
|
+
if limited is not None and np.allclose(limited @ transition, limited, rtol=1e-8, atol=1e-10):
|
|
122
|
+
return limited, joint
|
|
123
|
+
try:
|
|
124
|
+
pi = stationary_distribution_from_transition(transition)
|
|
125
|
+
except Exception:
|
|
126
|
+
pi = pi_initial
|
|
127
|
+
return pi, joint
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _forward_scaled(
|
|
131
|
+
pi: np.ndarray,
|
|
132
|
+
joint: dict[Any, np.ndarray],
|
|
133
|
+
obs: list[Any],
|
|
134
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
135
|
+
"""Return per-step-normalized forward messages and log scaling factors.
|
|
136
|
+
|
|
137
|
+
``alpha_hat[t]`` sums to one; ``log P(obs) = log_scales.sum()``. A ``-inf``
|
|
138
|
+
entry in ``log_scales`` marks an impossible step. Normalizing each step avoids
|
|
139
|
+
the underflow that makes the raw forward product vanish for long sequences.
|
|
140
|
+
"""
|
|
141
|
+
n = len(pi)
|
|
142
|
+
alpha_hat = np.zeros((len(obs) + 1, n), dtype=float)
|
|
143
|
+
log_scales = np.zeros(len(obs) + 1, dtype=float)
|
|
144
|
+
total0 = float(pi.sum())
|
|
145
|
+
if total0 <= 0.0:
|
|
146
|
+
log_scales[0] = -np.inf
|
|
147
|
+
return alpha_hat, log_scales
|
|
148
|
+
alpha_hat[0] = pi / total0
|
|
149
|
+
log_scales[0] = float(np.log(total0))
|
|
150
|
+
for t, symbol in enumerate(obs):
|
|
151
|
+
matrix = joint.get(symbol)
|
|
152
|
+
if matrix is None:
|
|
153
|
+
log_scales[t + 1] = -np.inf
|
|
154
|
+
continue
|
|
155
|
+
row = alpha_hat[t] @ matrix
|
|
156
|
+
scale = float(row.sum())
|
|
157
|
+
if scale <= 0.0:
|
|
158
|
+
log_scales[t + 1] = -np.inf
|
|
159
|
+
continue
|
|
160
|
+
alpha_hat[t + 1] = row / scale
|
|
161
|
+
log_scales[t + 1] = float(np.log(scale))
|
|
162
|
+
return alpha_hat, log_scales
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def forward(hmm: HiddenMarkovModel, observations: Sequence[Any], *, scaled: bool = False) -> np.ndarray:
|
|
166
|
+
"""Return forward messages ``alpha[t, s]`` for ``len(observations)+1`` rows.
|
|
167
|
+
|
|
168
|
+
With ``scaled=True`` each row is normalized to sum to one (the numerically
|
|
169
|
+
stable message used for posteriors); otherwise the raw messages are returned.
|
|
170
|
+
"""
|
|
171
|
+
pi, joint = _emission_transition_tensors(hmm)
|
|
172
|
+
obs = list(observations)
|
|
173
|
+
if scaled:
|
|
174
|
+
alpha_hat, _log_scales = _forward_scaled(pi, joint, obs)
|
|
175
|
+
return alpha_hat
|
|
176
|
+
n = len(pi)
|
|
177
|
+
alpha = np.zeros((len(obs) + 1, n), dtype=float)
|
|
178
|
+
alpha[0] = pi
|
|
179
|
+
for t, symbol in enumerate(obs):
|
|
180
|
+
matrix = joint.get(symbol)
|
|
181
|
+
if matrix is None:
|
|
182
|
+
alpha[t + 1] = 0.0
|
|
183
|
+
else:
|
|
184
|
+
alpha[t + 1] = alpha[t] @ matrix
|
|
185
|
+
return alpha
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def backward(hmm: HiddenMarkovModel, observations: Sequence[Any], *, scaled: bool = False) -> np.ndarray:
|
|
189
|
+
"""Return backward messages ``beta[t, s]`` for ``len(observations)+1`` rows.
|
|
190
|
+
|
|
191
|
+
With ``scaled=True`` each row is normalized to sum to one. The smoothed
|
|
192
|
+
posterior is then ``normalize(alpha_hat[t] * beta_hat[t])`` (the per-row
|
|
193
|
+
scaling constants cancel on renormalization).
|
|
194
|
+
"""
|
|
195
|
+
_, joint = _emission_transition_tensors(hmm)
|
|
196
|
+
n = next(iter(joint.values())).shape[0] if joint else len(_as_mealy_hmm(hmm).reindex())
|
|
197
|
+
obs = list(observations)
|
|
198
|
+
beta = np.zeros((len(obs) + 1, n), dtype=float)
|
|
199
|
+
beta[len(obs)] = 1.0
|
|
200
|
+
for t in range(len(obs) - 1, -1, -1):
|
|
201
|
+
matrix = joint.get(obs[t])
|
|
202
|
+
if matrix is None:
|
|
203
|
+
beta[t] = 0.0
|
|
204
|
+
else:
|
|
205
|
+
beta[t] = matrix @ beta[t + 1]
|
|
206
|
+
if scaled:
|
|
207
|
+
total = float(beta[t].sum())
|
|
208
|
+
if total > 0.0:
|
|
209
|
+
beta[t] = beta[t] / total
|
|
210
|
+
return beta
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _backward_scaled(joint: dict[Any, np.ndarray], obs: list[Any], n_states: int) -> np.ndarray:
|
|
214
|
+
"""Per-row-normalized backward messages from precomputed transition tensors.
|
|
215
|
+
|
|
216
|
+
``beta_hat[t]`` sums to one; the per-row scaling constants cancel against the
|
|
217
|
+
forward scaling when the smoothed posterior is renormalized. Shares tensors
|
|
218
|
+
with the forward pass so smoothing and EM avoid recomputing them.
|
|
219
|
+
"""
|
|
220
|
+
beta = np.zeros((len(obs) + 1, n_states), dtype=float)
|
|
221
|
+
beta[len(obs)] = 1.0
|
|
222
|
+
for t in range(len(obs) - 1, -1, -1):
|
|
223
|
+
matrix = joint.get(obs[t])
|
|
224
|
+
row = beta[t + 1] if matrix is None else matrix @ beta[t + 1]
|
|
225
|
+
beta[t] = 0.0 if matrix is None else row
|
|
226
|
+
total = float(beta[t].sum())
|
|
227
|
+
if total > 0.0:
|
|
228
|
+
beta[t] = beta[t] / total
|
|
229
|
+
return beta
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def log_likelihood(hmm: HiddenMarkovModel, observations: Sequence[Any]) -> float:
|
|
233
|
+
"""Natural-log likelihood ``log P(observations)``.
|
|
234
|
+
|
|
235
|
+
Uses the per-step-scaled forward recursion so the result stays finite for long
|
|
236
|
+
sequences instead of underflowing to ``-inf``.
|
|
237
|
+
"""
|
|
238
|
+
pi, joint = _emission_transition_tensors(hmm)
|
|
239
|
+
_alpha_hat, log_scales = _forward_scaled(pi, joint, list(observations))
|
|
240
|
+
if not np.all(np.isfinite(log_scales)):
|
|
241
|
+
return float("-inf")
|
|
242
|
+
return float(log_scales.sum())
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def smooth(hmm: HiddenMarkovModel, observations: Sequence[Any]) -> np.ndarray:
|
|
246
|
+
r"""Return fixed-interval smoothed marginals ``gamma[t, s]``.
|
|
247
|
+
|
|
248
|
+
``gamma[t, s] = P(X_t = s \mid Y_{0:n-1})`` for ``t = 0, ..., n`` (there are
|
|
249
|
+
``n + 1`` hidden states behind ``n`` edge emissions). Computed as the
|
|
250
|
+
per-row-renormalized product of the scaled forward and backward messages, the
|
|
251
|
+
forward-backward smoother of Cappe, Moulines & Ryden (2005, Section 3.2).
|
|
252
|
+
Rows for observation sequences of zero probability are returned as zeros.
|
|
253
|
+
"""
|
|
254
|
+
pi, joint = _emission_transition_tensors(hmm)
|
|
255
|
+
obs = list(observations)
|
|
256
|
+
n_states = len(pi)
|
|
257
|
+
alpha_hat, log_scales = _forward_scaled(pi, joint, obs)
|
|
258
|
+
if not np.all(np.isfinite(log_scales)):
|
|
259
|
+
return np.zeros((len(obs) + 1, n_states), dtype=float)
|
|
260
|
+
beta_hat = _backward_scaled(joint, obs, n_states)
|
|
261
|
+
gamma = alpha_hat * beta_hat
|
|
262
|
+
row_sums = gamma.sum(axis=1, keepdims=True)
|
|
263
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
264
|
+
gamma = np.where(row_sums > 0.0, gamma / row_sums, 0.0)
|
|
265
|
+
return gamma
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def two_slice_marginals(hmm: HiddenMarkovModel, observations: Sequence[Any]) -> np.ndarray:
|
|
269
|
+
r"""Return two-slice smoothed marginals ``xi[t, i, j]``.
|
|
270
|
+
|
|
271
|
+
``xi[t, i, j] = P(X_t = i, X_{t+1} = j \mid Y_{0:n-1})`` for ``t = 0, ..., n-1``,
|
|
272
|
+
where the transition at index ``t`` emits ``Y_t`` (Cappe, Moulines & Ryden,
|
|
273
|
+
2005, Section 3.2). Marginalizing over ``j`` recovers ``gamma[t]`` for
|
|
274
|
+
``t < n``. Returns an all-zero tensor for zero-probability sequences.
|
|
275
|
+
"""
|
|
276
|
+
pi, joint = _emission_transition_tensors(hmm)
|
|
277
|
+
obs = list(observations)
|
|
278
|
+
n_states = len(pi)
|
|
279
|
+
xi = np.zeros((len(obs), n_states, n_states), dtype=float)
|
|
280
|
+
alpha_hat, log_scales = _forward_scaled(pi, joint, obs)
|
|
281
|
+
if not np.all(np.isfinite(log_scales)):
|
|
282
|
+
return xi
|
|
283
|
+
beta_hat = _backward_scaled(joint, obs, n_states)
|
|
284
|
+
for t, symbol in enumerate(obs):
|
|
285
|
+
matrix = joint.get(symbol)
|
|
286
|
+
if matrix is None:
|
|
287
|
+
continue
|
|
288
|
+
block = alpha_hat[t][:, None] * matrix * beta_hat[t + 1][None, :]
|
|
289
|
+
total = float(block.sum())
|
|
290
|
+
if total > 0.0:
|
|
291
|
+
xi[t] = block / total
|
|
292
|
+
return xi
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _expected_edge_counts(
|
|
296
|
+
pi: np.ndarray,
|
|
297
|
+
joint: dict[Any, np.ndarray],
|
|
298
|
+
obs: list[Any],
|
|
299
|
+
) -> tuple[dict[tuple[int, Any, int], float], np.ndarray, np.ndarray, float]:
|
|
300
|
+
r"""Expected sufficient statistics for one observation sequence.
|
|
301
|
+
|
|
302
|
+
Returns ``(edge_counts, source_totals, gamma0, loglik)`` where
|
|
303
|
+
|
|
304
|
+
- ``edge_counts[(i, symbol, j)]`` is
|
|
305
|
+
:math:`\sum_t P(X_t = i, Y_t = symbol, X_{t+1} = j \mid Y)`, the expected
|
|
306
|
+
number of uses of edge ``i --symbol--> j``;
|
|
307
|
+
- ``source_totals[i] = \sum_{t=0}^{n-1} P(X_t = i \mid Y)`` is the expected
|
|
308
|
+
number of transitions out of state ``i`` (the Baum-Welch denominator);
|
|
309
|
+
- ``gamma0`` is the smoothed marginal of the initial state ``X_0``;
|
|
310
|
+
- ``loglik`` is the natural-log likelihood of the sequence.
|
|
311
|
+
|
|
312
|
+
Only edges present in ``joint`` (structural support) receive mass, so the
|
|
313
|
+
statistics preserve the model topology.
|
|
314
|
+
"""
|
|
315
|
+
n_states = len(pi)
|
|
316
|
+
alpha_hat, log_scales = _forward_scaled(pi, joint, obs)
|
|
317
|
+
if not np.all(np.isfinite(log_scales)):
|
|
318
|
+
return {}, np.zeros(n_states), np.zeros(n_states), float("-inf")
|
|
319
|
+
beta_hat = _backward_scaled(joint, obs, n_states)
|
|
320
|
+
edge_counts: dict[tuple[int, Any, int], float] = {}
|
|
321
|
+
source_totals = np.zeros(n_states, dtype=float)
|
|
322
|
+
for t, symbol in enumerate(obs):
|
|
323
|
+
matrix = joint.get(symbol)
|
|
324
|
+
if matrix is None:
|
|
325
|
+
continue
|
|
326
|
+
block = alpha_hat[t][:, None] * matrix * beta_hat[t + 1][None, :]
|
|
327
|
+
total = float(block.sum())
|
|
328
|
+
if total <= 0.0:
|
|
329
|
+
continue
|
|
330
|
+
block = block / total
|
|
331
|
+
source_totals += block.sum(axis=1)
|
|
332
|
+
for i, j in np.argwhere(block > 0.0):
|
|
333
|
+
key = (int(i), symbol, int(j))
|
|
334
|
+
edge_counts[key] = edge_counts.get(key, 0.0) + float(block[i, j])
|
|
335
|
+
g0 = alpha_hat[0] * beta_hat[0]
|
|
336
|
+
s0 = float(g0.sum())
|
|
337
|
+
gamma0 = g0 / s0 if s0 > 0.0 else np.zeros(n_states)
|
|
338
|
+
return edge_counts, source_totals, gamma0, float(log_scales.sum())
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _as_sequence_list(sequences: Iterable[Any]) -> list[list[Any]]:
|
|
342
|
+
"""Normalize ``sequences`` to a list of observation sequences.
|
|
343
|
+
|
|
344
|
+
Accepts either a single flat observation sequence (e.g. ``[0, 1, 0]``) or an
|
|
345
|
+
iterable of sequences (e.g. ``[[0, 1], [1, 0]]``). A single sequence is
|
|
346
|
+
detected when its first element is not itself a non-string sequence.
|
|
347
|
+
"""
|
|
348
|
+
seqs = list(sequences)
|
|
349
|
+
if not seqs:
|
|
350
|
+
return []
|
|
351
|
+
first = seqs[0]
|
|
352
|
+
if isinstance(first, (list, tuple)) and not isinstance(first, (str, bytes)):
|
|
353
|
+
return [list(seq) for seq in seqs]
|
|
354
|
+
return [seqs]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def baum_welch(
|
|
358
|
+
hmm: HiddenMarkovModel,
|
|
359
|
+
sequences: Iterable[Any],
|
|
360
|
+
*,
|
|
361
|
+
max_iter: int = 100,
|
|
362
|
+
tol: float = 1e-6,
|
|
363
|
+
estimate_initial: bool = True,
|
|
364
|
+
) -> tuple[Any, list[float]]:
|
|
365
|
+
r"""Fit HMM parameters by Baum-Welch (EM) expectation-maximization.
|
|
366
|
+
|
|
367
|
+
Re-estimates the Mealy joint edge law
|
|
368
|
+
:math:`A_o[i, j] = P(X_{t+1} = j, O = o \mid X_t = i)` and (optionally) the
|
|
369
|
+
initial distribution from data, holding the transition-graph topology fixed:
|
|
370
|
+
structurally absent edges receive zero expected count and stay absent, so the
|
|
371
|
+
fitted model generates the same sofic shift as ``hmm``. This is the EM
|
|
372
|
+
algorithm for probabilistic functions of finite Markov chains of Baum, Petrie,
|
|
373
|
+
Soules & Weiss and Cappe, Moulines & Ryden (2005, Chapter 10); see also
|
|
374
|
+
Rabiner (1989).
|
|
375
|
+
|
|
376
|
+
``sequences`` may be a single observation sequence or an iterable of
|
|
377
|
+
sequences (several sequences are needed to identify the initial distribution;
|
|
378
|
+
Cappe, Moulines & Ryden, 2005, Section 10.1). Unifilarity is *not* preserved,
|
|
379
|
+
so the fit is returned as a plain :class:`~sofic.generators.mealy.MealyHMM`.
|
|
380
|
+
|
|
381
|
+
Returns ``(fitted_model, loglik_trace)`` where ``loglik_trace`` is the
|
|
382
|
+
non-decreasing sequence of total natural-log likelihoods observed before each
|
|
383
|
+
parameter update.
|
|
384
|
+
"""
|
|
385
|
+
from sofic.generators.mealy import MealyHMM
|
|
386
|
+
|
|
387
|
+
mealy = hmm.to_mealy()
|
|
388
|
+
idx = mealy.reindex()
|
|
389
|
+
n_states = len(idx)
|
|
390
|
+
states = [idx.state(i) for i in range(n_states)]
|
|
391
|
+
alphabet = frozenset(mealy.observation_alphabet)
|
|
392
|
+
seqs = _as_sequence_list(sequences)
|
|
393
|
+
|
|
394
|
+
pi, joint = _emission_transition_tensors_from_mealy(mealy)
|
|
395
|
+
support = {
|
|
396
|
+
(i, symbol, j)
|
|
397
|
+
for symbol, matrix in joint.items()
|
|
398
|
+
for i in range(n_states)
|
|
399
|
+
for j in range(n_states)
|
|
400
|
+
if matrix[i, j] > 0.0
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
loglik_trace: list[float] = []
|
|
404
|
+
prev_ll: float | None = None
|
|
405
|
+
for _iteration in range(max_iter):
|
|
406
|
+
total_edge_counts: dict[tuple[int, Any, int], float] = defaultdict(float)
|
|
407
|
+
total_source = np.zeros(n_states, dtype=float)
|
|
408
|
+
gamma0_sum = np.zeros(n_states, dtype=float)
|
|
409
|
+
total_ll = 0.0
|
|
410
|
+
for obs in seqs:
|
|
411
|
+
edge_counts, source_totals, gamma0, loglik = _expected_edge_counts(pi, joint, obs)
|
|
412
|
+
if not np.isfinite(loglik):
|
|
413
|
+
continue
|
|
414
|
+
for key, value in edge_counts.items():
|
|
415
|
+
total_edge_counts[key] += value
|
|
416
|
+
total_source += source_totals
|
|
417
|
+
gamma0_sum += gamma0
|
|
418
|
+
total_ll += loglik
|
|
419
|
+
loglik_trace.append(total_ll)
|
|
420
|
+
if prev_ll is not None and abs(total_ll - prev_ll) < tol:
|
|
421
|
+
break
|
|
422
|
+
prev_ll = total_ll
|
|
423
|
+
|
|
424
|
+
new_joint = {symbol: np.zeros((n_states, n_states), dtype=float) for symbol in joint}
|
|
425
|
+
for (i, symbol, j), count in total_edge_counts.items():
|
|
426
|
+
if total_source[i] > 0.0:
|
|
427
|
+
new_joint[symbol][i, j] = count / total_source[i]
|
|
428
|
+
for i in range(n_states):
|
|
429
|
+
if total_source[i] <= 0.0:
|
|
430
|
+
for symbol in joint:
|
|
431
|
+
new_joint[symbol][i, :] = joint[symbol][i, :]
|
|
432
|
+
joint = new_joint
|
|
433
|
+
if estimate_initial:
|
|
434
|
+
mass = float(gamma0_sum.sum())
|
|
435
|
+
if mass > 0.0:
|
|
436
|
+
pi = gamma0_sum / mass
|
|
437
|
+
|
|
438
|
+
fitted = MealyHMM(
|
|
439
|
+
initial_distribution={states[i]: float(pi[i]) for i in range(n_states) if pi[i] > 0.0},
|
|
440
|
+
observation_alphabet=alphabet,
|
|
441
|
+
)
|
|
442
|
+
for state in states:
|
|
443
|
+
fitted.graph.add_state(state)
|
|
444
|
+
for i, symbol, j in sorted(support, key=lambda edge: (edge[0], str(edge[1]), edge[2])):
|
|
445
|
+
prob = float(joint[symbol][i, j])
|
|
446
|
+
if prob > 0.0:
|
|
447
|
+
fitted.add_transition(states[i], states[j], symbol, prob)
|
|
448
|
+
fitted.validate()
|
|
449
|
+
return fitted, loglik_trace
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def score(hmm: HiddenMarkovModel, observations: Sequence[Any]) -> dict[tuple[Hashable, Any, Hashable], float]:
|
|
453
|
+
r"""Return the score (gradient of the log-likelihood) via the Fisher identity.
|
|
454
|
+
|
|
455
|
+
For each edge ``i --o--> j``, returns
|
|
456
|
+
:math:`\partial \log P(Y) / \partial A_o[i, j] = E[N_{i,o,j} \mid Y] / A_o[i, j]`,
|
|
457
|
+
where ``N`` is the (unobserved) edge-use count. This is Fisher's identity,
|
|
458
|
+
``\nabla \log L(\theta) = E[\nabla \log f(X, Y; \theta) \mid Y]`` (Cappe,
|
|
459
|
+
Moulines & Ryden, 2005, Section 10.2.3), evaluated in the raw (unconstrained)
|
|
460
|
+
joint-edge parameters. Keys are ``(source, symbol, target)`` state labels.
|
|
461
|
+
"""
|
|
462
|
+
mealy = hmm.to_mealy()
|
|
463
|
+
idx = mealy.reindex()
|
|
464
|
+
pi, joint = _emission_transition_tensors_from_mealy(mealy)
|
|
465
|
+
edge_counts, _source_totals, _gamma0, loglik = _expected_edge_counts(pi, joint, list(observations))
|
|
466
|
+
if not np.isfinite(loglik):
|
|
467
|
+
raise ValueError("observations have zero probability under the model; score is undefined")
|
|
468
|
+
result: dict[tuple[Hashable, Any, Hashable], float] = {}
|
|
469
|
+
n_states = len(pi)
|
|
470
|
+
for symbol, matrix in joint.items():
|
|
471
|
+
for i in range(n_states):
|
|
472
|
+
for j in range(n_states):
|
|
473
|
+
prob = float(matrix[i, j])
|
|
474
|
+
if prob > 0.0:
|
|
475
|
+
count = edge_counts.get((i, symbol, j), 0.0)
|
|
476
|
+
result[(idx.state(i), symbol, idx.state(j))] = count / prob
|
|
477
|
+
return result
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _free_parameterization(
|
|
481
|
+
joint: dict[Any, np.ndarray],
|
|
482
|
+
n_states: int,
|
|
483
|
+
) -> tuple[list[tuple[int, Any, int]], list[tuple[int, Any, int]], list[int]]:
|
|
484
|
+
"""Build the free multinomial parameterization of the joint edge law.
|
|
485
|
+
|
|
486
|
+
Each source state whose outgoing edges number ``k >= 2`` contributes ``k - 1``
|
|
487
|
+
free parameters (its last edge in canonical order is the reference). Returns
|
|
488
|
+
``(free_edges, reference_by_param, source_by_param)``: the edge for each free
|
|
489
|
+
parameter, the reference edge of its source block, and the source-state index.
|
|
490
|
+
"""
|
|
491
|
+
free_edges: list[tuple[int, Any, int]] = []
|
|
492
|
+
reference_by_param: list[tuple[int, Any, int]] = []
|
|
493
|
+
source_by_param: list[int] = []
|
|
494
|
+
for i in range(n_states):
|
|
495
|
+
out_edges = sorted(
|
|
496
|
+
((i, symbol, j) for symbol, matrix in joint.items() for j in range(n_states) if matrix[i, j] > 0.0),
|
|
497
|
+
key=lambda edge: (str(edge[1]), edge[2]),
|
|
498
|
+
)
|
|
499
|
+
if len(out_edges) < 2:
|
|
500
|
+
continue
|
|
501
|
+
reference = out_edges[-1]
|
|
502
|
+
for edge in out_edges[:-1]:
|
|
503
|
+
free_edges.append(edge)
|
|
504
|
+
reference_by_param.append(reference)
|
|
505
|
+
source_by_param.append(i)
|
|
506
|
+
return free_edges, reference_by_param, source_by_param
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def observed_information(hmm: HiddenMarkovModel, observations: Sequence[Any]) -> np.ndarray:
|
|
510
|
+
r"""Return the observed information matrix via Louis' identity.
|
|
511
|
+
|
|
512
|
+
The observed information ``J = -\partial^2 \log L / \partial\theta^2`` for the
|
|
513
|
+
free multinomial parameters of the joint edge law is obtained from Louis'
|
|
514
|
+
(1982) identity,
|
|
515
|
+
|
|
516
|
+
.. math:: J = E[-\partial^2 \ell_c \mid Y] - \operatorname{Cov}(\partial \ell_c \mid Y),
|
|
517
|
+
|
|
518
|
+
where :math:`\ell_c` is the complete-data log-likelihood (Cappe, Moulines &
|
|
519
|
+
Ryden, 2005, Section 10.2.3). The complete-data information ``B`` follows from
|
|
520
|
+
the expected edge counts; the conditional covariance of the complete-data
|
|
521
|
+
score is computed exactly by a forward smoothing recursion for the first and
|
|
522
|
+
second moments of the additive score functional. The matrix is ordered by
|
|
523
|
+
:func:`free_parameter_labels`; an empty ``(0, 0)`` matrix is returned when the
|
|
524
|
+
model has no free parameters.
|
|
525
|
+
"""
|
|
526
|
+
mealy = hmm.to_mealy()
|
|
527
|
+
pi, joint = _emission_transition_tensors_from_mealy(mealy)
|
|
528
|
+
obs = list(observations)
|
|
529
|
+
n_states = len(pi)
|
|
530
|
+
|
|
531
|
+
free_edges, reference_by_param, source_by_param = _free_parameterization(joint, n_states)
|
|
532
|
+
d = len(free_edges)
|
|
533
|
+
if d == 0:
|
|
534
|
+
return np.zeros((0, 0), dtype=float)
|
|
535
|
+
|
|
536
|
+
edge_counts, _source_totals, _gamma0, loglik = _expected_edge_counts(pi, joint, obs)
|
|
537
|
+
if not np.isfinite(loglik):
|
|
538
|
+
raise ValueError("observations have zero probability under the model; information is undefined")
|
|
539
|
+
|
|
540
|
+
prob_of = {edge: float(joint[edge[1]][edge[0], edge[2]]) for edge in set(free_edges) | set(reference_by_param)}
|
|
541
|
+
|
|
542
|
+
# Complete-data information B = E[-d^2 l_c | Y], block-diagonal by source state.
|
|
543
|
+
complete_information = np.zeros((d, d), dtype=float)
|
|
544
|
+
for p in range(d):
|
|
545
|
+
ref_p = reference_by_param[p]
|
|
546
|
+
count_ref = edge_counts.get(ref_p, 0.0)
|
|
547
|
+
ref_term = count_ref / prob_of[ref_p] ** 2
|
|
548
|
+
for q in range(d):
|
|
549
|
+
if source_by_param[p] != source_by_param[q]:
|
|
550
|
+
continue
|
|
551
|
+
value = ref_term
|
|
552
|
+
if p == q:
|
|
553
|
+
edge_p = free_edges[p]
|
|
554
|
+
value += edge_counts.get(edge_p, 0.0) / prob_of[edge_p] ** 2
|
|
555
|
+
complete_information[p, q] = value
|
|
556
|
+
|
|
557
|
+
# Per-transition score contribution s(edge) as a d-vector (sparse per source block).
|
|
558
|
+
edge_score: dict[tuple[int, Any, int], np.ndarray] = {}
|
|
559
|
+
for p, edge in enumerate(free_edges):
|
|
560
|
+
edge_score.setdefault(edge, np.zeros(d))[p] += 1.0 / prob_of[edge]
|
|
561
|
+
for p, ref in enumerate(reference_by_param):
|
|
562
|
+
edge_score.setdefault(ref, np.zeros(d))[p] += -1.0 / prob_of[ref]
|
|
563
|
+
zero_d = np.zeros(d)
|
|
564
|
+
|
|
565
|
+
# Forward smoothing recursion for E[S | Y] and E[S S^T | Y] of the additive
|
|
566
|
+
# complete-data score functional S = sum_t s(edge_t).
|
|
567
|
+
alpha_hat, _log_scales = _forward_scaled(pi, joint, obs)
|
|
568
|
+
first = np.zeros((n_states, d), dtype=float)
|
|
569
|
+
second = np.zeros((n_states, d, d), dtype=float)
|
|
570
|
+
for t, symbol in enumerate(obs):
|
|
571
|
+
matrix = joint.get(symbol)
|
|
572
|
+
if matrix is None:
|
|
573
|
+
continue
|
|
574
|
+
weight = alpha_hat[t][:, None] * matrix # weight[i, k] = P(X_t=i, X_{t+1}=k, Y_t | Y_{0:t-1})
|
|
575
|
+
denom = weight.sum(axis=0)
|
|
576
|
+
new_first = np.zeros((n_states, d), dtype=float)
|
|
577
|
+
new_second = np.zeros((n_states, d, d), dtype=float)
|
|
578
|
+
for k in range(n_states):
|
|
579
|
+
if denom[k] <= 0.0:
|
|
580
|
+
continue
|
|
581
|
+
for i in range(n_states):
|
|
582
|
+
if weight[i, k] <= 0.0:
|
|
583
|
+
continue
|
|
584
|
+
retro = weight[i, k] / denom[k] # P(X_t=i | X_{t+1}=k, Y_{0:t})
|
|
585
|
+
s_vec = edge_score.get((i, symbol, k), zero_d)
|
|
586
|
+
first_i = first[i]
|
|
587
|
+
combined = first_i + s_vec
|
|
588
|
+
new_first[k] += retro * combined
|
|
589
|
+
cross = np.outer(first_i, s_vec)
|
|
590
|
+
new_second[k] += retro * (second[i] + cross + cross.T + np.outer(s_vec, s_vec))
|
|
591
|
+
first, second = new_first, new_second
|
|
592
|
+
|
|
593
|
+
phi_final = alpha_hat[len(obs)]
|
|
594
|
+
expected_score = phi_final @ first
|
|
595
|
+
expected_outer = np.einsum("k,kpq->pq", phi_final, second)
|
|
596
|
+
score_covariance = expected_outer - np.outer(expected_score, expected_score)
|
|
597
|
+
return complete_information - score_covariance
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def free_parameter_labels(hmm: HiddenMarkovModel) -> list[tuple[Hashable, Any, Hashable]]:
|
|
601
|
+
"""Return the ``(source, symbol, target)`` label for each free parameter.
|
|
602
|
+
|
|
603
|
+
The order matches the rows and columns of :func:`observed_information` and the
|
|
604
|
+
entries of :func:`standard_errors`.
|
|
605
|
+
"""
|
|
606
|
+
mealy = hmm.to_mealy()
|
|
607
|
+
idx = mealy.reindex()
|
|
608
|
+
_pi, joint = _emission_transition_tensors_from_mealy(mealy)
|
|
609
|
+
free_edges, _reference, _source = _free_parameterization(joint, len(idx))
|
|
610
|
+
return [(idx.state(i), symbol, idx.state(j)) for i, symbol, j in free_edges]
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def standard_errors(
|
|
614
|
+
hmm: HiddenMarkovModel,
|
|
615
|
+
observations: Sequence[Any],
|
|
616
|
+
) -> dict[tuple[Hashable, Any, Hashable], float]:
|
|
617
|
+
r"""Return asymptotic standard errors of the free edge parameters.
|
|
618
|
+
|
|
619
|
+
Standard errors are ``sqrt(diag(J^{-1}))`` where ``J`` is the
|
|
620
|
+
:func:`observed_information` matrix (Cappe, Moulines & Ryden, 2005,
|
|
621
|
+
Section 10.2.3). Uses the Moore-Penrose pseudoinverse when ``J`` is singular;
|
|
622
|
+
a non-positive variance estimate (numerically unidentified parameter) yields
|
|
623
|
+
``nan``. Keyed by the labels from :func:`free_parameter_labels`.
|
|
624
|
+
"""
|
|
625
|
+
labels = free_parameter_labels(hmm)
|
|
626
|
+
information = observed_information(hmm, observations)
|
|
627
|
+
if information.shape[0] == 0:
|
|
628
|
+
return {}
|
|
629
|
+
try:
|
|
630
|
+
covariance = np.linalg.inv(information)
|
|
631
|
+
except np.linalg.LinAlgError:
|
|
632
|
+
covariance = np.linalg.pinv(information)
|
|
633
|
+
variances = np.diag(covariance)
|
|
634
|
+
with np.errstate(invalid="ignore"):
|
|
635
|
+
errors = np.where(variances > 0.0, np.sqrt(variances), np.nan)
|
|
636
|
+
return dict(zip(labels, (float(value) for value in errors), strict=True))
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _log_probabilities(values: np.ndarray) -> np.ndarray:
|
|
640
|
+
log_values = np.full(values.shape, -np.inf, dtype=float)
|
|
641
|
+
positive = values > 0.0
|
|
642
|
+
log_values[positive] = np.log(values[positive])
|
|
643
|
+
return log_values
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def viterbi(hmm: HiddenMarkovModel, observations: Sequence[Any]) -> list[Hashable]:
|
|
647
|
+
mealy = _as_mealy_hmm(hmm)
|
|
648
|
+
idx = mealy.reindex()
|
|
649
|
+
pi, joint = _emission_transition_tensors_from_mealy(mealy)
|
|
650
|
+
n = len(idx)
|
|
651
|
+
obs = list(observations)
|
|
652
|
+
if n == 0:
|
|
653
|
+
return []
|
|
654
|
+
if not obs:
|
|
655
|
+
if not np.any(pi > 0.0):
|
|
656
|
+
return []
|
|
657
|
+
return [idx.state(int(np.argmax(pi)))]
|
|
658
|
+
|
|
659
|
+
log_pi = _log_probabilities(pi)
|
|
660
|
+
viterbi_log = np.full((len(obs), n), -np.inf, dtype=float)
|
|
661
|
+
backpointer = np.full((len(obs), n), -1, dtype=int)
|
|
662
|
+
|
|
663
|
+
matrix0 = joint.get(obs[0])
|
|
664
|
+
if matrix0 is not None:
|
|
665
|
+
log_matrix0 = _log_probabilities(matrix0)
|
|
666
|
+
for j in range(n):
|
|
667
|
+
best = log_pi + log_matrix0[:, j]
|
|
668
|
+
viterbi_log[0, j] = np.max(best)
|
|
669
|
+
backpointer[0, j] = int(np.argmax(best))
|
|
670
|
+
|
|
671
|
+
for t in range(1, len(obs)):
|
|
672
|
+
matrix = joint.get(obs[t])
|
|
673
|
+
if matrix is None:
|
|
674
|
+
continue
|
|
675
|
+
log_matrix = _log_probabilities(matrix)
|
|
676
|
+
for j in range(n):
|
|
677
|
+
scores = viterbi_log[t - 1] + log_matrix[:, j]
|
|
678
|
+
viterbi_log[t, j] = np.max(scores)
|
|
679
|
+
backpointer[t, j] = int(np.argmax(scores))
|
|
680
|
+
|
|
681
|
+
if not np.any(np.isfinite(viterbi_log[-1])):
|
|
682
|
+
return []
|
|
683
|
+
|
|
684
|
+
path = [0] * len(obs)
|
|
685
|
+
path[-1] = int(np.argmax(viterbi_log[-1]))
|
|
686
|
+
for t in range(len(obs) - 2, -1, -1):
|
|
687
|
+
path[t] = backpointer[t + 1, path[t + 1]]
|
|
688
|
+
return [idx.state(i) for i in path]
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def sample(
|
|
692
|
+
hmm: HiddenMarkovModel,
|
|
693
|
+
n: int,
|
|
694
|
+
rng: np.random.Generator | None = None,
|
|
695
|
+
) -> tuple[list[Any], list[Hashable]]:
|
|
696
|
+
generator = rng if rng is not None else np.random.default_rng()
|
|
697
|
+
mealy = _as_mealy_hmm(hmm)
|
|
698
|
+
idx = mealy.reindex()
|
|
699
|
+
pi, joint = _emission_transition_tensors_from_mealy(mealy)
|
|
700
|
+
state = int(generator.choice(len(idx), p=pi / pi.sum()))
|
|
701
|
+
|
|
702
|
+
observations: list[Any] = []
|
|
703
|
+
states: list[Hashable] = []
|
|
704
|
+
for _ in range(n):
|
|
705
|
+
states.append(idx.state(state))
|
|
706
|
+
row_sum = sum(matrix[state].sum() for matrix in joint.values())
|
|
707
|
+
if row_sum <= 0.0:
|
|
708
|
+
break
|
|
709
|
+
symbol_probs = np.array([joint[sym][state].sum() for sym in joint], dtype=float)
|
|
710
|
+
symbol_probs /= symbol_probs.sum()
|
|
711
|
+
symbol_index = int(generator.choice(len(joint), p=symbol_probs))
|
|
712
|
+
symbol = list(joint.keys())[symbol_index]
|
|
713
|
+
observations.append(symbol)
|
|
714
|
+
matrix = joint[symbol]
|
|
715
|
+
row = matrix[state]
|
|
716
|
+
if row.sum() <= 0.0:
|
|
717
|
+
break
|
|
718
|
+
state = int(generator.choice(len(idx), p=row / row.sum()))
|
|
719
|
+
return observations, states
|