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,564 @@
1
+ """Spectral (method-of-moments) learning of stochastic processes.
2
+
3
+ This module learns a weighted finite automaton (WFA) / observable-operator
4
+ model :cite:`Jaeger2000` for a stationary, discrete-time, discrete-alphabet
5
+ process from sampled sequences, using the Hankel-matrix singular value
6
+ decomposition of Balle, Carreras, Luque & Quattoni :cite:`Balle2014`. The
7
+ approach is the automata-theoretic twin of the spectral HMM algorithm of Hsu,
8
+ Kakade & Zhang :cite:`Hsu2012`; unlike Baum-Welch it is a consistent one-shot
9
+ estimator with no local optima, and the model order is read off from the
10
+ singular-value spectrum rather than fixed in advance.
11
+
12
+ The learned model is returned as a :class:`~sofic.generators.quasi_realization.QuasiRealization`
13
+ -- sofic's native matrix representation of an observable-operator model -- whose
14
+ ``word_probability`` implements the WFA recursion ``pi @ A_{x1} @ ... @ A_{xt} @ tau``
15
+ directly. Because the observable-operator representation is *signed*, this is
16
+ always well defined even when no non-negative (hidden Markov) realization of the
17
+ same rank exists; :func:`project_to_nmachine` and :func:`project_to_mealy`
18
+ provide a best-effort cleanup back to an :class:`~sofic.generators.nmachine.NMachine`
19
+ or a stochastic :class:`~sofic.generators.mealy.MealyHMM`.
20
+
21
+ Small-alphabet note
22
+ -------------------
23
+ The single-symbol spectral HMM of :cite:`Hsu2012` requires the observation
24
+ matrix to have full column rank, i.e. at least as many symbols as hidden states.
25
+ The Hankel formulation used here sidesteps that by indexing the moments with
26
+ multi-symbol *prefixes* and *suffixes*: increasing ``prefix_length`` /
27
+ ``suffix_length`` is the discrete analogue of observation stacking and lets the
28
+ method recover processes whose alphabet is smaller than the number of states
29
+ (golden-mean, even process, ...).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections import defaultdict
35
+ from collections.abc import Callable, Hashable, Iterable, Sequence
36
+ from typing import Any
37
+
38
+ import numpy as np
39
+
40
+ from sofic.generators.quasi_realization import QuasiRealization
41
+
42
+ __all__ = [
43
+ "SpectralInferenceError",
44
+ "hankel_matrices",
45
+ "learn_spectral_wfa",
46
+ "project_to_epsilon_machine",
47
+ "project_to_mealy",
48
+ "project_to_nmachine",
49
+ "spectral_singular_values",
50
+ ]
51
+
52
+ _BELIEF_DECIMALS = 6
53
+ _MASS_ATOL = 1e-12
54
+
55
+
56
+ class SpectralInferenceError(ValueError):
57
+ """Raised when spectral learning or projection cannot proceed."""
58
+
59
+
60
+ def _normalize_sequences(sequences: Iterable[Any]) -> list[tuple[Any, ...]]:
61
+ """Normalize ``sequences`` to a list of observation tuples.
62
+
63
+ Accepts either a single flat observation sequence (e.g. ``[0, 1, 0]``) or an
64
+ iterable of sequences (e.g. ``[[0, 1], [1, 0]]``), mirroring
65
+ :func:`sofic.generators.hmm_inference.baum_welch`.
66
+ """
67
+ seqs = list(sequences)
68
+ if not seqs:
69
+ return []
70
+ first = seqs[0]
71
+ if isinstance(first, (list, tuple)) and not isinstance(first, (str, bytes)):
72
+ return [tuple(seq) for seq in seqs]
73
+ return [tuple(seqs)]
74
+
75
+
76
+ def _sorted_alphabet(alphabet: Iterable[Any]) -> tuple[Any, ...]:
77
+ return tuple(sorted(set(alphabet), key=repr))
78
+
79
+
80
+ def _words_up_to(alphabet: Sequence[Any], max_length: int) -> list[tuple[Any, ...]]:
81
+ """Return every word over ``alphabet`` of length ``0 .. max_length``.
82
+
83
+ The empty word is first, so index ``0`` always addresses the ``epsilon``
84
+ row/column used for the initial and final WFA vectors.
85
+ """
86
+ words: list[tuple[Any, ...]] = [()]
87
+ frontier: list[tuple[Any, ...]] = [()]
88
+ for _ in range(max_length):
89
+ extended = [word + (symbol,) for word in frontier for symbol in alphabet]
90
+ words.extend(extended)
91
+ frontier = extended
92
+ return words
93
+
94
+
95
+ def _empirical_word_probability(
96
+ sequences: Sequence[tuple[Any, ...]],
97
+ max_length: int,
98
+ ) -> Callable[[Sequence[Any]], float]:
99
+ """Return a stationary block-probability estimator ``f(w)``.
100
+
101
+ ``f(w)`` is the fraction of length-``|w|`` sliding windows (across all
102
+ sequences) equal to ``w``, so ``sum_{|w|=L} f(w) = 1`` for each ``L`` and
103
+ ``f(epsilon) = 1``. This is the standard substring estimator used for
104
+ spectral learning of stochastic processes :cite:`Balle2014`.
105
+ """
106
+ counts: dict[tuple[Any, ...], int] = defaultdict(int)
107
+ windows: dict[int, int] = defaultdict(int)
108
+ for sequence in sequences:
109
+ n = len(sequence)
110
+ for length in range(1, max_length + 1):
111
+ count = n - length + 1
112
+ if count <= 0:
113
+ break
114
+ windows[length] += count
115
+ for start in range(count):
116
+ counts[sequence[start : start + length]] += 1
117
+
118
+ def f(word: Sequence[Any]) -> float:
119
+ word = tuple(word)
120
+ length = len(word)
121
+ if length == 0:
122
+ return 1.0
123
+ total = windows.get(length, 0)
124
+ if total == 0:
125
+ return 0.0
126
+ return counts.get(word, 0) / total
127
+
128
+ return f
129
+
130
+
131
+ def hankel_matrices(
132
+ word_probability: Callable[[Sequence[Any]], float],
133
+ alphabet: Sequence[Any],
134
+ *,
135
+ prefix_length: int,
136
+ suffix_length: int,
137
+ ) -> tuple[np.ndarray, dict[Any, np.ndarray], list[tuple[Any, ...]], list[tuple[Any, ...]]]:
138
+ """Build the Hankel matrix ``H`` and per-symbol shifted matrices ``H_sigma``.
139
+
140
+ ``H[u, v] = word_probability(u + v)`` over the prefix basis (words up to
141
+ ``prefix_length``) and suffix basis (words up to ``suffix_length``), and
142
+ ``H_sigma[u, v] = word_probability(u + (sigma,) + v)``.
143
+ """
144
+ prefixes = _words_up_to(alphabet, prefix_length)
145
+ suffixes = _words_up_to(alphabet, suffix_length)
146
+ n_prefixes, n_suffixes = len(prefixes), len(suffixes)
147
+
148
+ hankel = np.zeros((n_prefixes, n_suffixes), dtype=float)
149
+ for i, prefix in enumerate(prefixes):
150
+ for j, suffix in enumerate(suffixes):
151
+ hankel[i, j] = word_probability(prefix + suffix)
152
+
153
+ shifted: dict[Any, np.ndarray] = {}
154
+ for symbol in alphabet:
155
+ matrix = np.zeros((n_prefixes, n_suffixes), dtype=float)
156
+ for i, prefix in enumerate(prefixes):
157
+ for j, suffix in enumerate(suffixes):
158
+ matrix[i, j] = word_probability(prefix + (symbol,) + suffix)
159
+ shifted[symbol] = matrix
160
+ return hankel, shifted, prefixes, suffixes
161
+
162
+
163
+ def _select_rank(singular_values: np.ndarray, relative_threshold: float, min_singular_value: float) -> int:
164
+ if singular_values.size == 0:
165
+ return 1
166
+ cutoff = max(min_singular_value, relative_threshold * float(singular_values[0]))
167
+ keep = int(np.count_nonzero(singular_values > cutoff))
168
+ return max(1, keep)
169
+
170
+
171
+ def _consistency_gauge(
172
+ pi: np.ndarray,
173
+ tau: np.ndarray,
174
+ symbol_maps: dict[Any, np.ndarray],
175
+ ) -> tuple[np.ndarray, dict[Any, np.ndarray]]:
176
+ """Transform to the ``tau = 1`` gauge used by :class:`QuasiRealization`.
177
+
178
+ Applies an invertible similarity ``C`` with ``C @ 1 = tau`` (rank-one
179
+ Sherman-Morrison update) so that in the new basis the final vector is the
180
+ all-ones vector, the initial vector sums to ``pi @ tau = f(epsilon) = 1``,
181
+ and per-state outgoing masses sum to one. Word probabilities are preserved
182
+ exactly.
183
+ """
184
+ n = tau.shape[0]
185
+ ones = np.ones(n, dtype=float)
186
+ k = int(np.argmax(np.abs(tau)))
187
+ if abs(tau[k]) < 1e-12:
188
+ raise SpectralInferenceError("degenerate final vector; cannot normalize spectral model")
189
+ # C = I + (tau - 1) e_k^T => C @ 1 = tau ; C^{-1} = I - (tau - 1) e_k^T / tau_k
190
+ update = tau - ones
191
+ gauge = np.eye(n) + np.outer(update, np.eye(n)[k])
192
+ gauge_inv = np.eye(n) - np.outer(update, np.eye(n)[k]) / tau[k]
193
+
194
+ pi_gauged = pi @ gauge
195
+ maps_gauged = {symbol: gauge_inv @ matrix @ gauge for symbol, matrix in symbol_maps.items()}
196
+ return pi_gauged, maps_gauged
197
+
198
+
199
+ def learn_spectral_wfa(
200
+ sequences: Iterable[Any] | None = None,
201
+ *,
202
+ word_probability: Callable[[Sequence[Any]], float] | None = None,
203
+ alphabet: Iterable[Any] | None = None,
204
+ rank: int | None = None,
205
+ prefix_length: int = 2,
206
+ suffix_length: int | None = None,
207
+ singular_value_threshold: float = 1e-3,
208
+ min_singular_value: float = 1e-12,
209
+ ) -> QuasiRealization:
210
+ """Learn a WFA / observable-operator model by Hankel-matrix SVD.
211
+
212
+ Parameters
213
+ ----------
214
+ sequences
215
+ A single observed realization (e.g. ``[0, 1, 0, ...]``) or an iterable
216
+ of realizations. Ignored when ``word_probability`` is given.
217
+ word_probability
218
+ Optional exact block-probability function ``f(word) -> float`` (e.g.
219
+ :meth:`~sofic.generators.base.HiddenMarkovModel.word_probability`). When
220
+ supplied the Hankel matrix is built exactly instead of from samples;
221
+ ``alphabet`` is then required.
222
+ alphabet
223
+ Observation alphabet. Inferred from ``sequences`` when omitted.
224
+ rank
225
+ Number of latent states. When ``None`` the rank is chosen from the
226
+ singular-value spectrum (values exceeding
227
+ ``singular_value_threshold`` times the largest).
228
+ prefix_length, suffix_length
229
+ Maximum lengths of the prefix and suffix bases. ``suffix_length``
230
+ defaults to ``prefix_length``. Larger values are the small-alphabet
231
+ remedy (see module docstring).
232
+ singular_value_threshold
233
+ Relative cutoff for automatic rank selection.
234
+ min_singular_value
235
+ Absolute floor below which singular values are treated as zero.
236
+
237
+ Returns
238
+ -------
239
+ QuasiRealization
240
+ A consistent observable-operator model (``tau`` is the all-ones vector,
241
+ ``pi`` sums to one) whose ``word_probability`` reproduces the learned
242
+ statistics.
243
+ """
244
+ if suffix_length is None:
245
+ suffix_length = prefix_length
246
+ if prefix_length < 1 or suffix_length < 1:
247
+ raise SpectralInferenceError("prefix_length and suffix_length must be at least 1")
248
+
249
+ seqs: list[tuple[Any, ...]] = []
250
+ if word_probability is None:
251
+ if sequences is None:
252
+ raise SpectralInferenceError("provide either sequences or word_probability")
253
+ seqs = _normalize_sequences(sequences)
254
+ if not any(seqs):
255
+ raise SpectralInferenceError("sequences contain no symbols")
256
+
257
+ if alphabet is not None:
258
+ symbols = _sorted_alphabet(alphabet)
259
+ elif seqs:
260
+ symbols = _sorted_alphabet(symbol for sequence in seqs for symbol in sequence)
261
+ else:
262
+ raise SpectralInferenceError("alphabet is required when learning from word_probability")
263
+ if not symbols:
264
+ raise SpectralInferenceError("empty alphabet")
265
+
266
+ f = word_probability
267
+ if f is None:
268
+ f = _empirical_word_probability(seqs, prefix_length + suffix_length + 1)
269
+
270
+ hankel, shifted, prefixes, _suffixes = hankel_matrices(
271
+ f, symbols, prefix_length=prefix_length, suffix_length=suffix_length
272
+ )
273
+
274
+ u_full, s_full, vt_full = np.linalg.svd(hankel, full_matrices=False)
275
+ available = int(np.count_nonzero(s_full > min_singular_value))
276
+ if available == 0:
277
+ raise SpectralInferenceError("Hankel matrix is numerically zero; no signal to learn")
278
+ if rank is None:
279
+ rank = _select_rank(s_full, singular_value_threshold, min_singular_value)
280
+ rank = max(1, min(int(rank), available))
281
+
282
+ u_n = u_full[:, :rank]
283
+ s_n = s_full[:rank]
284
+ v_n = vt_full[:rank, :].T
285
+ inv_s = 1.0 / s_n
286
+
287
+ h_prefix = hankel[:, 0] # empty-suffix column: f(u)
288
+ h_suffix = hankel[0, :] # empty-prefix row: f(v)
289
+
290
+ pi = (h_suffix @ v_n) * inv_s
291
+ tau = u_n.T @ h_prefix
292
+ symbol_maps = {symbol: (u_n.T @ shifted[symbol] @ v_n) * inv_s[None, :] for symbol in symbols}
293
+
294
+ pi, symbol_maps = _consistency_gauge(pi, tau, symbol_maps)
295
+ tau = np.ones(rank, dtype=float)
296
+
297
+ total = float(pi.sum())
298
+ if abs(total) < 1e-12:
299
+ raise SpectralInferenceError("degenerate initial vector; cannot normalize spectral model")
300
+ pi = pi / total
301
+
302
+ return QuasiRealization(pi=pi, tau=tau, symbol_maps=symbol_maps)
303
+
304
+
305
+ def spectral_singular_values(
306
+ sequences: Iterable[Any] | None = None,
307
+ *,
308
+ word_probability: Callable[[Sequence[Any]], float] | None = None,
309
+ alphabet: Iterable[Any] | None = None,
310
+ prefix_length: int = 2,
311
+ suffix_length: int | None = None,
312
+ ) -> np.ndarray:
313
+ """Return the Hankel singular-value spectrum used for model-order selection.
314
+
315
+ A clear gap in the returned values indicates the effective number of latent
316
+ states; pass the resulting count as ``rank`` to :func:`learn_spectral_wfa`.
317
+ """
318
+ if suffix_length is None:
319
+ suffix_length = prefix_length
320
+ if alphabet is not None:
321
+ symbols = _sorted_alphabet(alphabet)
322
+ f = word_probability
323
+ if f is None:
324
+ if sequences is None:
325
+ raise SpectralInferenceError("provide sequences or word_probability")
326
+ seqs = _normalize_sequences(sequences)
327
+ f = _empirical_word_probability(seqs, prefix_length + suffix_length + 1)
328
+ else:
329
+ if sequences is None:
330
+ raise SpectralInferenceError("alphabet is required when learning from word_probability")
331
+ seqs = _normalize_sequences(sequences)
332
+ symbols = _sorted_alphabet(symbol for sequence in seqs for symbol in sequence)
333
+ f = _empirical_word_probability(seqs, prefix_length + suffix_length + 1)
334
+ hankel, _shifted, _prefixes, _suffixes = hankel_matrices(
335
+ f, symbols, prefix_length=prefix_length, suffix_length=suffix_length
336
+ )
337
+ return np.linalg.svd(hankel, compute_uv=False)
338
+
339
+
340
+ def _state_labels(n: int) -> list[Hashable]:
341
+ from sofic.states import sequential_labels
342
+
343
+ return list(sequential_labels(n))
344
+
345
+
346
+ def project_to_nmachine(qr: QuasiRealization, *, tol: float = 1e-9, validate: bool = True) -> Any:
347
+ """Project a learned :class:`QuasiRealization` onto an :class:`NMachine`.
348
+
349
+ Reads each operator entry ``A_sigma[i, j]`` as the signed joint
350
+ quasiprobability ``P(state_j, sigma | state_i)`` and renormalizes per-state
351
+ outgoing mass to one, producing an observable-operator generator with an
352
+ explicit transition graph. Small-magnitude edges (``|w| <= tol``) are
353
+ dropped. The result may carry signed weights (it is an n-machine, not
354
+ necessarily a hidden Markov model); use :func:`project_to_mealy` when a
355
+ non-negative realization is required.
356
+ """
357
+ from sofic.generators.nmachine import NMachine
358
+ from sofic.graph import ATTR_EMISSION, ATTR_QUASIPROB
359
+
360
+ symbol_maps = qr.symbol_maps
361
+ n = qr.pi.shape[0]
362
+ labels = _state_labels(n)
363
+ alphabet = sorted(symbol_maps, key=repr)
364
+
365
+ row_totals = np.zeros(n, dtype=float)
366
+ for matrix in symbol_maps.values():
367
+ row_totals += matrix.sum(axis=1)
368
+
369
+ machine = NMachine(observation_alphabet=frozenset(alphabet))
370
+ for label in labels:
371
+ machine.graph.add_state(label)
372
+ for i in range(n):
373
+ scale = row_totals[i]
374
+ if abs(scale) < 1e-12:
375
+ continue
376
+ for symbol in alphabet:
377
+ matrix = symbol_maps[symbol]
378
+ for j in range(n):
379
+ weight = matrix[i, j] / scale
380
+ if abs(weight) <= tol:
381
+ continue
382
+ machine.graph.add_transition(
383
+ labels[i], labels[j], **{ATTR_QUASIPROB: float(weight), ATTR_EMISSION: symbol}
384
+ )
385
+
386
+ pi = np.asarray(qr.pi, dtype=float)
387
+ pi_total = float(pi.sum())
388
+ if abs(pi_total) < 1e-12:
389
+ raise SpectralInferenceError("degenerate initial distribution; cannot build n-machine")
390
+ machine.initial_quasidistribution = {labels[i]: float(pi[i] / pi_total) for i in range(n) if abs(pi[i]) > tol}
391
+ if validate:
392
+ machine.validate()
393
+ return machine
394
+
395
+
396
+ def project_to_mealy(qr: QuasiRealization, *, tol: float = 1e-8, validate: bool = True) -> Any:
397
+ """Project a learned :class:`QuasiRealization` onto a stochastic ``MealyHMM``.
398
+
399
+ Succeeds only when the observable-operator model admits a non-negative
400
+ realization in the current basis: any operator entry below ``-tol`` raises
401
+ :class:`SpectralInferenceError`. Small negatives are clipped to zero and each
402
+ state's outgoing mass is renormalized to one. This is a best-effort cleanup;
403
+ a signed model should be kept as an :class:`~sofic.generators.nmachine.NMachine`
404
+ via :func:`project_to_nmachine`.
405
+ """
406
+ from sofic.generators.mealy import MealyHMM
407
+
408
+ symbol_maps = qr.symbol_maps
409
+ n = qr.pi.shape[0]
410
+ labels = _state_labels(n)
411
+ alphabet = sorted(symbol_maps, key=repr)
412
+
413
+ most_negative = min((float(matrix.min()) for matrix in symbol_maps.values()), default=0.0)
414
+ if most_negative < -tol:
415
+ raise SpectralInferenceError(
416
+ f"no non-negative realization in this basis (min operator entry {most_negative:.3g}); "
417
+ "use project_to_nmachine for the signed model"
418
+ )
419
+
420
+ clipped = {symbol: np.clip(matrix, 0.0, None) for symbol, matrix in symbol_maps.items()}
421
+ row_totals = np.zeros(n, dtype=float)
422
+ for matrix in clipped.values():
423
+ row_totals += matrix.sum(axis=1)
424
+
425
+ pi = np.clip(np.asarray(qr.pi, dtype=float), 0.0, None)
426
+ pi_total = float(pi.sum())
427
+ if pi_total < 1e-12:
428
+ raise SpectralInferenceError("degenerate initial distribution; cannot build Mealy HMM")
429
+
430
+ machine = MealyHMM(
431
+ initial_distribution={labels[i]: float(pi[i] / pi_total) for i in range(n) if pi[i] > tol},
432
+ observation_alphabet=frozenset(alphabet),
433
+ )
434
+ for label in labels:
435
+ machine.graph.add_state(label)
436
+ for i in range(n):
437
+ scale = row_totals[i]
438
+ if scale < 1e-12:
439
+ continue
440
+ for symbol in alphabet:
441
+ matrix = clipped[symbol]
442
+ for j in range(n):
443
+ prob = matrix[i, j] / scale
444
+ if prob <= tol:
445
+ continue
446
+ machine.add_transition(labels[i], labels[j], symbol, float(prob))
447
+ if validate:
448
+ machine.validate()
449
+ return machine
450
+
451
+
452
+ def project_to_epsilon_machine(
453
+ qr: QuasiRealization,
454
+ *,
455
+ tol: float = 1e-8,
456
+ max_states: int = 10_000,
457
+ ) -> Any:
458
+ """Extract an ε-machine from a learned spectral model.
459
+
460
+ When the observable operators admit a non-negative realization in the
461
+ learned basis, this is :func:`project_to_mealy` followed by
462
+ :meth:`~sofic.generators.epsilon_machine.EpsilonMachine.from_hmm`. Signed
463
+ operators are converted by enumerating mixed states of the observable
464
+ operators (belief updates ``b A_x / (b A_x τ)``) and merging
465
+ predictively equivalent recurrent states :cite:`Ellison2009`. This is the
466
+ computational-mechanics extraction, not a clustering heuristic.
467
+
468
+ Raises
469
+ ------
470
+ SpectralInferenceError
471
+ If mixed-state enumeration exceeds ``max_states`` or the initial
472
+ vector is degenerate.
473
+ """
474
+ from sofic.generators.epsilon_machine import EpsilonMachine
475
+
476
+ try:
477
+ mealy = project_to_mealy(qr, tol=tol, validate=True)
478
+ except SpectralInferenceError:
479
+ mealy = _mealy_from_operator_mixed_states(qr, max_states=max_states)
480
+ return EpsilonMachine.from_hmm(mealy)
481
+
482
+
483
+ def _mealy_from_operator_mixed_states(
484
+ qr: QuasiRealization,
485
+ *,
486
+ max_states: int = 10_000,
487
+ decimals: int = _BELIEF_DECIMALS,
488
+ ) -> Any:
489
+ """Build a unifilar Mealy HMM whose states are mixed states of ``qr``."""
490
+ from collections import deque
491
+
492
+ from sofic.generators.mealy import MealyHMM
493
+ from sofic.generators.mixed_state import MixedState
494
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB, TransitionGraph
495
+
496
+ maps = qr.symbol_maps
497
+ tau = np.asarray(qr.tau, dtype=float)
498
+ symbols = tuple(sorted(maps, key=repr))
499
+ eta0 = MixedState.from_vector(qr.pi, decimals=decimals)
500
+ if eta0 is None:
501
+ raise SpectralInferenceError("degenerate initial vector; cannot extract mixed states")
502
+
503
+ graph = TransitionGraph()
504
+ discovered: dict[MixedState, MixedState] = {}
505
+ queue: deque[MixedState] = deque()
506
+
507
+ def register(state: MixedState) -> MixedState:
508
+ existing = discovered.get(state)
509
+ if existing is not None:
510
+ return existing
511
+ atol = 10 ** (-decimals)
512
+ for known in discovered:
513
+ if all(np.isclose(a, b, rtol=0.0, atol=atol) for a, b in zip(known.belief, state.belief, strict=True)):
514
+ discovered[state] = known
515
+ return known
516
+ if len(discovered) >= max_states:
517
+ raise SpectralInferenceError(
518
+ f"mixed-state extraction exceeded max_states={max_states}; "
519
+ "use project_to_nmachine for the signed observable-operator model"
520
+ )
521
+ discovered[state] = state
522
+ graph.add_state(state)
523
+ queue.append(state)
524
+ return state
525
+
526
+ register(eta0)
527
+ while queue:
528
+ eta = queue.popleft()
529
+ row = eta.as_array()
530
+ emissions: list[tuple[Any, MixedState, float]] = []
531
+ for symbol in symbols:
532
+ nxt = row @ maps[symbol]
533
+ prob = float(nxt @ tau)
534
+ if prob <= _MASS_ATOL:
535
+ continue
536
+ successor = MixedState.from_vector(nxt, decimals=decimals)
537
+ if successor is None:
538
+ continue
539
+ emissions.append((symbol, register(successor), prob))
540
+ total = sum(prob for _symbol, _successor, prob in emissions)
541
+ if total <= _MASS_ATOL:
542
+ continue
543
+ for symbol, successor, prob in emissions:
544
+ graph.add_transition(
545
+ eta,
546
+ successor,
547
+ **{ATTR_PROB: float(prob / total), ATTR_EMISSION: symbol},
548
+ )
549
+
550
+ keep = graph.terminal_recurrent_states()
551
+ if not keep:
552
+ keep = frozenset(discovered.values())
553
+ recurrent = TransitionGraph()
554
+ for state in keep:
555
+ recurrent.add_state(state)
556
+ for transition in graph.out_transitions(state):
557
+ if transition.target in keep:
558
+ recurrent.add_transition(transition.source, transition.target, **dict(transition.data))
559
+ initial = {eta0: 1.0} if eta0 in keep else {}
560
+ return MealyHMM(
561
+ graph=recurrent,
562
+ initial_distribution=initial,
563
+ observation_alphabet=frozenset(symbols),
564
+ )
sofic/operations.py ADDED
@@ -0,0 +1,16 @@
1
+ """Cross-cutting operations on sofic state-machine models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sofic.base import StateMachine
6
+
7
+
8
+ def reverse(model: StateMachine) -> StateMachine:
9
+ """Reverse ``model`` using its concrete :meth:`~StateMachine.reverse` implementation.
10
+
11
+ This is the canonical top-level ``reverse`` for all :class:`~sofic.base.StateMachine`
12
+ subtypes (HMMs, ε-machines, automata, shifts). For finite automata, prefer
13
+ :meth:`~sofic.automata.nfa.NFA.reverse` or import
14
+ :func:`~sofic.automata.algorithms.reverse` directly.
15
+ """
16
+ return model.reverse()