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,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()
|