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.
Files changed (150) hide show
  1. sofic/__init__.py +185 -0
  2. sofic/automata/__init__.py +207 -0
  3. sofic/automata/_config_simulation.py +40 -0
  4. sofic/automata/active.py +611 -0
  5. sofic/automata/alergia.py +222 -0
  6. sofic/automata/algorithms.py +376 -0
  7. sofic/automata/atomaton.py +58 -0
  8. sofic/automata/base.py +161 -0
  9. sofic/automata/buchi.py +23 -0
  10. sofic/automata/buchi_simulation.py +67 -0
  11. sofic/automata/canonical_dual.py +18 -0
  12. sofic/automata/canonical_extraction.py +122 -0
  13. sofic/automata/dfa.py +85 -0
  14. sofic/automata/dfasat.py +195 -0
  15. sofic/automata/edsm.py +219 -0
  16. sofic/automata/enumeration.py +44 -0
  17. sofic/automata/icdfa.py +421 -0
  18. sofic/automata/idfa.py +363 -0
  19. sofic/automata/languages/__init__.py +39 -0
  20. sofic/automata/languages/_quotient_utils.py +64 -0
  21. sofic/automata/languages/atoms.py +31 -0
  22. sofic/automata/languages/automaton_ops.py +243 -0
  23. sofic/automata/languages/base.py +67 -0
  24. sofic/automata/languages/operations.py +78 -0
  25. sofic/automata/languages/quotients.py +66 -0
  26. sofic/automata/languages/residuals.py +25 -0
  27. sofic/automata/learning.py +79 -0
  28. sofic/automata/nfa.py +39 -0
  29. sofic/automata/nwa.py +343 -0
  30. sofic/automata/nwa_simulation.py +56 -0
  31. sofic/automata/observation.py +40 -0
  32. sofic/automata/papni.py +301 -0
  33. sofic/automata/regex.py +128 -0
  34. sofic/automata/rfsa.py +35 -0
  35. sofic/automata/rpni.py +193 -0
  36. sofic/automata/subsequential.py +201 -0
  37. sofic/automata/transducer_operations.py +350 -0
  38. sofic/automata/transducer_simulation.py +150 -0
  39. sofic/automata/transducers.py +365 -0
  40. sofic/automata/unifilar.py +107 -0
  41. sofic/automata/vpa.py +1373 -0
  42. sofic/automata/vpa_simulation.py +53 -0
  43. sofic/base.py +153 -0
  44. sofic/core.py +47 -0
  45. sofic/examples/__init__.py +86 -0
  46. sofic/examples/epsilon_machines.py +1089 -0
  47. sofic/examples/processes.py +1491 -0
  48. sofic/examples/shifts.py +144 -0
  49. sofic/exceptions.py +33 -0
  50. sofic/generators/__init__.py +115 -0
  51. sofic/generators/_word_measures.py +94 -0
  52. sofic/generators/alternative_complexity.py +104 -0
  53. sofic/generators/base.py +327 -0
  54. sofic/generators/bidirectional_construction.py +717 -0
  55. sofic/generators/bidirectional_epsilon_machine.py +689 -0
  56. sofic/generators/block_convergence.py +668 -0
  57. sofic/generators/block_entropy.py +578 -0
  58. sofic/generators/channel_measures.py +75 -0
  59. sofic/generators/conversions.py +182 -0
  60. sofic/generators/directional_flow.py +245 -0
  61. sofic/generators/edge_emissions.py +36 -0
  62. sofic/generators/edge_machine.py +178 -0
  63. sofic/generators/epsilon_construction.py +193 -0
  64. sofic/generators/epsilon_inference.py +703 -0
  65. sofic/generators/epsilon_machine.py +557 -0
  66. sofic/generators/epsilon_transducer.py +168 -0
  67. sofic/generators/epsilon_transducer_construction.py +185 -0
  68. sofic/generators/epsilon_transducer_inference.py +499 -0
  69. sofic/generators/hmm_inference.py +719 -0
  70. sofic/generators/information_diagram.py +428 -0
  71. sofic/generators/lumping.py +447 -0
  72. sofic/generators/markov.py +100 -0
  73. sofic/generators/mealy.py +156 -0
  74. sofic/generators/measures.py +257 -0
  75. sofic/generators/minimal_generative_model.py +821 -0
  76. sofic/generators/mixed_state.py +250 -0
  77. sofic/generators/mixed_state_construction.py +163 -0
  78. sofic/generators/moore.py +75 -0
  79. sofic/generators/nmachine.py +78 -0
  80. sofic/generators/nmachine_construction.py +70 -0
  81. sofic/generators/pfa.py +100 -0
  82. sofic/generators/prob.py +291 -0
  83. sofic/generators/process_equivalence.py +207 -0
  84. sofic/generators/quasi_inference.py +74 -0
  85. sofic/generators/quasi_realization.py +97 -0
  86. sofic/generators/reversal.py +66 -0
  87. sofic/generators/stack_hmm.py +426 -0
  88. sofic/generators/stack_inference.py +509 -0
  89. sofic/generators/stationary.py +134 -0
  90. sofic/generators/stochastic.py +65 -0
  91. sofic/generators/synchronization.py +407 -0
  92. sofic/generators/topological_epsilon_enumeration.py +349 -0
  93. sofic/generators/words.py +226 -0
  94. sofic/graph.py +135 -0
  95. sofic/indexing.py +31 -0
  96. sofic/inference/__init__.py +45 -0
  97. sofic/inference/bayesian/__init__.py +68 -0
  98. sofic/inference/bayesian/comparison.py +199 -0
  99. sofic/inference/bayesian/counts.py +219 -0
  100. sofic/inference/bayesian/diversity.py +254 -0
  101. sofic/inference/bayesian/epsilon.py +270 -0
  102. sofic/inference/bayesian/hdp_hmm.py +340 -0
  103. sofic/inference/bayesian/markov.py +294 -0
  104. sofic/inference/bayesian/pymc_backend.py +71 -0
  105. sofic/inference/bayesian/stack_hmm.py +215 -0
  106. sofic/inference/model_selection.py +365 -0
  107. sofic/inference/spectral.py +564 -0
  108. sofic/operations.py +16 -0
  109. sofic/properties.py +339 -0
  110. sofic/serialization.py +450 -0
  111. sofic/shifts/__init__.py +48 -0
  112. sofic/shifts/algorithms.py +84 -0
  113. sofic/shifts/base.py +49 -0
  114. sofic/shifts/cover_construction.py +76 -0
  115. sofic/shifts/covers.py +47 -0
  116. sofic/shifts/dyck_algorithms.py +100 -0
  117. sofic/shifts/dyck_enumeration.py +275 -0
  118. sofic/shifts/markov_dyck.py +172 -0
  119. sofic/shifts/parry_construction.py +82 -0
  120. sofic/shifts/sft.py +104 -0
  121. sofic/shifts/sft_construction.py +52 -0
  122. sofic/shifts/sliding_block_code.py +156 -0
  123. sofic/shifts/sofic.py +111 -0
  124. sofic/shifts/sofic_dyck.py +110 -0
  125. sofic/shifts/sofic_relation.py +64 -0
  126. sofic/shifts/textile.py +104 -0
  127. sofic/shifts/tmc.py +46 -0
  128. sofic/shifts/tmc_construction.py +58 -0
  129. sofic/shifts/topological_anatomy.py +150 -0
  130. sofic/states.py +27 -0
  131. sofic/testing/__init__.py +8 -0
  132. sofic/testing/strategies.py +154 -0
  133. sofic/viz/__init__.py +16 -0
  134. sofic/viz/_context.py +345 -0
  135. sofic/viz/_edge.py +216 -0
  136. sofic/viz/_format.py +89 -0
  137. sofic/viz/_labels.py +34 -0
  138. sofic/viz/_names.py +17 -0
  139. sofic/viz/_rational.py +20 -0
  140. sofic/viz/_tikz_compile.py +177 -0
  141. sofic/viz/_tikz_format.py +122 -0
  142. sofic/viz/_tikz_layout.py +218 -0
  143. sofic/viz/assets/vaucanson.tikz +71 -0
  144. sofic/viz/graphviz.py +158 -0
  145. sofic/viz/idiagram.py +350 -0
  146. sofic/viz/tikz.py +381 -0
  147. sofic-0.1.0.dist-info/METADATA +444 -0
  148. sofic-0.1.0.dist-info/RECORD +150 -0
  149. sofic-0.1.0.dist-info/WHEEL +4 -0
  150. 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