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,703 @@
|
|
|
1
|
+
"""Sample-based ε-machine reconstruction (CSSR, subtree merging, and spectral).
|
|
2
|
+
|
|
3
|
+
CSSR follows Shalizi, Shalizi & Crutchfield (arXiv:cs/0210025). Subtree merging
|
|
4
|
+
follows Crutchfield & Young (PRL 1989; PRE 1994). Spectral reconstruction learns
|
|
5
|
+
a weighted finite automaton by Hankel SVD :cite:`Balle2014,Hsu2012` and extracts
|
|
6
|
+
causal states as mixed states of the learned operators :cite:`Ellison2009`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, ClassVar, Literal
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy import stats
|
|
18
|
+
|
|
19
|
+
from sofic.exceptions import StochasticValidationError
|
|
20
|
+
from sofic.generators.epsilon_machine import EpsilonMachine
|
|
21
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB, TransitionGraph
|
|
22
|
+
|
|
23
|
+
History = tuple[Any, ...]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SuffixCounts:
|
|
28
|
+
"""Empirical counts of histories and following symbols in a sequence."""
|
|
29
|
+
|
|
30
|
+
alphabet: tuple[Any, ...]
|
|
31
|
+
history_counts: Counter[History] = field(default_factory=Counter)
|
|
32
|
+
next_counts: dict[History, Counter[Any]] = field(default_factory=lambda: defaultdict(Counter))
|
|
33
|
+
|
|
34
|
+
#: History key used as the fallback for an empty history set (overridden by stack counts).
|
|
35
|
+
empty_history: ClassVar[History] = ()
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_sequence(
|
|
39
|
+
cls,
|
|
40
|
+
sequence: Sequence[Any],
|
|
41
|
+
*,
|
|
42
|
+
alphabet: Sequence[Any] | None = None,
|
|
43
|
+
max_length: int | None = None,
|
|
44
|
+
) -> SuffixCounts:
|
|
45
|
+
seq = tuple(sequence)
|
|
46
|
+
if not seq:
|
|
47
|
+
raise ValueError("sequence must be non-empty")
|
|
48
|
+
alphabet = tuple(sorted(set(seq), key=repr)) if alphabet is None else tuple(alphabet)
|
|
49
|
+
unknown = set(seq) - set(alphabet)
|
|
50
|
+
if unknown:
|
|
51
|
+
raise ValueError(f"symbols {unknown!r} not in alphabet")
|
|
52
|
+
max_len = max_length if max_length is not None else len(seq)
|
|
53
|
+
counts = cls(alphabet=alphabet)
|
|
54
|
+
n = len(seq)
|
|
55
|
+
for t in range(n):
|
|
56
|
+
for length in range(0, min(t, max_len) + 1):
|
|
57
|
+
history = seq[t - length : t]
|
|
58
|
+
counts.history_counts[history] += 1
|
|
59
|
+
nxt = seq[t]
|
|
60
|
+
counts.next_counts[history][nxt] += 1
|
|
61
|
+
return counts
|
|
62
|
+
|
|
63
|
+
def morph(self, history: History, *, smoothing: float = 0.0) -> dict[Any, float]:
|
|
64
|
+
"""MLE (optional additive smoothing) of P(next symbol | history)."""
|
|
65
|
+
counts = self.next_counts.get(history, Counter())
|
|
66
|
+
total = sum(counts.values())
|
|
67
|
+
if total == 0:
|
|
68
|
+
uniform = 1.0 / len(self.alphabet)
|
|
69
|
+
return dict.fromkeys(self.alphabet, uniform)
|
|
70
|
+
denom = total + smoothing * len(self.alphabet)
|
|
71
|
+
return {symbol: (counts.get(symbol, 0) + smoothing) / denom for symbol in self.alphabet}
|
|
72
|
+
|
|
73
|
+
def state_morph(self, histories: set[History], *, smoothing: float = 0.0) -> dict[Any, float]:
|
|
74
|
+
"""Weighted average of history morphs with weights from occurrence counts."""
|
|
75
|
+
weights = {history: float(self.history_counts.get(history, 0)) for history in histories}
|
|
76
|
+
total_weight = sum(weights.values())
|
|
77
|
+
if total_weight <= 0.0:
|
|
78
|
+
return self.morph(self.empty_history, smoothing=smoothing)
|
|
79
|
+
result = dict.fromkeys(self.alphabet, 0.0)
|
|
80
|
+
for history, weight in weights.items():
|
|
81
|
+
morph = self.morph(history, smoothing=smoothing)
|
|
82
|
+
for symbol in self.alphabet:
|
|
83
|
+
result[symbol] += weight * morph[symbol]
|
|
84
|
+
return {symbol: prob / total_weight for symbol, prob in result.items()}
|
|
85
|
+
|
|
86
|
+
def marginal_morph(self) -> dict[Any, float]:
|
|
87
|
+
"""Global next-symbol distribution (IID morph at L=0)."""
|
|
88
|
+
counts = Counter()
|
|
89
|
+
for _history, counter in self.next_counts.items():
|
|
90
|
+
counts.update(counter)
|
|
91
|
+
grand = sum(counts.values())
|
|
92
|
+
if grand == 0:
|
|
93
|
+
uniform = 1.0 / len(self.alphabet)
|
|
94
|
+
return dict.fromkeys(self.alphabet, uniform)
|
|
95
|
+
return {symbol: counts.get(symbol, 0) / grand for symbol in self.alphabet}
|
|
96
|
+
|
|
97
|
+
def restricted_to(self, histories: set[History]) -> SuffixCounts:
|
|
98
|
+
"""Return a plain :class:`SuffixCounts` proxy limited to ``histories``.
|
|
99
|
+
|
|
100
|
+
The morph/comparison helpers only read the history sets handed to them, so
|
|
101
|
+
stack inference can reuse them by projecting its configuration counts onto a
|
|
102
|
+
flat proxy without changing any results.
|
|
103
|
+
"""
|
|
104
|
+
proxy = SuffixCounts(alphabet=self.alphabet)
|
|
105
|
+
proxy.history_counts = Counter({h: self.history_counts.get(h, 0) for h in histories})
|
|
106
|
+
proxy.next_counts = defaultdict(Counter)
|
|
107
|
+
for history in histories:
|
|
108
|
+
proxy.next_counts[history] = self.next_counts.get(history, Counter())
|
|
109
|
+
return proxy
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _observed_counts_for_morph(
|
|
113
|
+
counts: SuffixCounts,
|
|
114
|
+
histories: set[History],
|
|
115
|
+
) -> Counter[Any]:
|
|
116
|
+
observed = Counter()
|
|
117
|
+
for history in histories:
|
|
118
|
+
observed.update(counts.next_counts.get(history, Counter()))
|
|
119
|
+
return observed
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _contingency_rows(
|
|
123
|
+
counts: SuffixCounts,
|
|
124
|
+
left_histories: set[History],
|
|
125
|
+
right_histories: set[History],
|
|
126
|
+
) -> np.ndarray | None:
|
|
127
|
+
left_obs = _observed_counts_for_morph(counts, left_histories)
|
|
128
|
+
right_obs = _observed_counts_for_morph(counts, right_histories)
|
|
129
|
+
active = [symbol for symbol in counts.alphabet if left_obs.get(symbol, 0) + right_obs.get(symbol, 0) > 0]
|
|
130
|
+
if not active:
|
|
131
|
+
return None
|
|
132
|
+
table = np.array(
|
|
133
|
+
[
|
|
134
|
+
[left_obs.get(symbol, 0) for symbol in active],
|
|
135
|
+
[right_obs.get(symbol, 0) for symbol in active],
|
|
136
|
+
],
|
|
137
|
+
dtype=float,
|
|
138
|
+
)
|
|
139
|
+
if np.allclose(table[0], table[1]):
|
|
140
|
+
return None
|
|
141
|
+
if table.shape[1] < 2:
|
|
142
|
+
left_total = table[0].sum()
|
|
143
|
+
right_total = table[1].sum()
|
|
144
|
+
if left_total == 0.0 or right_total == 0.0:
|
|
145
|
+
return None
|
|
146
|
+
left_prob = table[0, 0] / left_total
|
|
147
|
+
right_prob = table[1, 0] / right_total
|
|
148
|
+
if np.isclose(left_prob, right_prob):
|
|
149
|
+
return None
|
|
150
|
+
return table
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def morphs_differ(
|
|
154
|
+
counts: SuffixCounts,
|
|
155
|
+
left_histories: set[History],
|
|
156
|
+
right_histories: set[History],
|
|
157
|
+
*,
|
|
158
|
+
alpha: float = 0.05,
|
|
159
|
+
test: Literal["g", "chi2", "tv"] = "g",
|
|
160
|
+
delta: float = 0.0,
|
|
161
|
+
) -> bool:
|
|
162
|
+
"""Return whether two history sets have significantly different morphs."""
|
|
163
|
+
if test == "tv":
|
|
164
|
+
left = counts.state_morph(left_histories)
|
|
165
|
+
right = counts.state_morph(right_histories)
|
|
166
|
+
distance = 0.5 * sum(abs(left[s] - right[s]) for s in counts.alphabet)
|
|
167
|
+
return distance > delta
|
|
168
|
+
|
|
169
|
+
table = _contingency_rows(counts, left_histories, right_histories)
|
|
170
|
+
if table is None:
|
|
171
|
+
return False
|
|
172
|
+
if test == "g":
|
|
173
|
+
try:
|
|
174
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
175
|
+
statistic, _p_value, _dof, expected = stats.chi2_contingency(table, lambda_="log-likelihood")
|
|
176
|
+
except ValueError:
|
|
177
|
+
return False
|
|
178
|
+
if not np.isfinite(statistic) or np.any(expected == 0):
|
|
179
|
+
return False
|
|
180
|
+
dof = max(1, table.shape[1] - 1)
|
|
181
|
+
critical = float(stats.chi2.ppf(1.0 - alpha, dof))
|
|
182
|
+
return float(statistic) > critical
|
|
183
|
+
try:
|
|
184
|
+
statistic, p_value, _dof, expected = stats.chi2_contingency(table)
|
|
185
|
+
except ValueError:
|
|
186
|
+
return False
|
|
187
|
+
if np.any(expected == 0):
|
|
188
|
+
return False
|
|
189
|
+
return float(p_value) < alpha
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def morph_test_score(
|
|
193
|
+
counts: SuffixCounts,
|
|
194
|
+
left_histories: set[History],
|
|
195
|
+
right_histories: set[History],
|
|
196
|
+
*,
|
|
197
|
+
test: Literal["g", "chi2", "tv"] = "g",
|
|
198
|
+
) -> float:
|
|
199
|
+
"""Score for matching morphs (lower is more similar)."""
|
|
200
|
+
if test == "tv":
|
|
201
|
+
left = counts.state_morph(left_histories)
|
|
202
|
+
right = counts.state_morph(right_histories)
|
|
203
|
+
return 0.5 * sum(abs(left[s] - right[s]) for s in counts.alphabet)
|
|
204
|
+
table = _contingency_rows(counts, left_histories, right_histories)
|
|
205
|
+
if table is None:
|
|
206
|
+
return 0.0
|
|
207
|
+
try:
|
|
208
|
+
if test == "g":
|
|
209
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
210
|
+
statistic, _p, _dof, _expected = stats.chi2_contingency(table, lambda_="log-likelihood")
|
|
211
|
+
return float(statistic) if np.isfinite(statistic) else 0.0
|
|
212
|
+
statistic, _p, _dof, _expected = stats.chi2_contingency(table)
|
|
213
|
+
return float(statistic)
|
|
214
|
+
except ValueError:
|
|
215
|
+
return 0.0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _default_lmax(n: int, alphabet_size: int, min_count: int) -> int:
|
|
219
|
+
if alphabet_size <= 0:
|
|
220
|
+
return 1
|
|
221
|
+
return max(1, min(15, n // max(1, alphabet_size * min_count)))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _grow_history(history: History, symbol: Any) -> History:
|
|
225
|
+
"""Default successor: append the symbol without truncation (flat ε-machine CSSR)."""
|
|
226
|
+
return history + (symbol,)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _cssr_homogenize(
|
|
230
|
+
counts: SuffixCounts,
|
|
231
|
+
*,
|
|
232
|
+
Lmax: int,
|
|
233
|
+
alpha: float,
|
|
234
|
+
test: Literal["g", "chi2", "tv"],
|
|
235
|
+
successor_fn: Callable[[History, Any], History] = _grow_history,
|
|
236
|
+
) -> tuple[dict[int, set[History]], dict[History, int]]:
|
|
237
|
+
"""Return state id -> histories and history -> state id."""
|
|
238
|
+
states: dict[int, set[History]] = {0: {counts.empty_history}}
|
|
239
|
+
history_to_state: dict[History, int] = {counts.empty_history: 0}
|
|
240
|
+
next_state_id = 1
|
|
241
|
+
|
|
242
|
+
for _length in range(Lmax + 1):
|
|
243
|
+
for state_id in sorted(states):
|
|
244
|
+
histories = set(states[state_id])
|
|
245
|
+
for history in list(histories):
|
|
246
|
+
for symbol in counts.alphabet:
|
|
247
|
+
child = successor_fn(history, symbol)
|
|
248
|
+
if child in history_to_state:
|
|
249
|
+
continue
|
|
250
|
+
if counts.history_counts.get(child, 0) == 0:
|
|
251
|
+
continue
|
|
252
|
+
child_histories = {child}
|
|
253
|
+
if morphs_differ(
|
|
254
|
+
counts,
|
|
255
|
+
histories,
|
|
256
|
+
child_histories,
|
|
257
|
+
alpha=alpha,
|
|
258
|
+
test=test,
|
|
259
|
+
):
|
|
260
|
+
best_state: int | None = None
|
|
261
|
+
best_score = float("inf")
|
|
262
|
+
for candidate_id, candidate_histories in states.items():
|
|
263
|
+
if morphs_differ(
|
|
264
|
+
counts,
|
|
265
|
+
candidate_histories,
|
|
266
|
+
child_histories,
|
|
267
|
+
alpha=alpha,
|
|
268
|
+
test=test,
|
|
269
|
+
):
|
|
270
|
+
continue
|
|
271
|
+
score = morph_test_score(
|
|
272
|
+
counts,
|
|
273
|
+
candidate_histories,
|
|
274
|
+
child_histories,
|
|
275
|
+
test=test,
|
|
276
|
+
)
|
|
277
|
+
if score < best_score:
|
|
278
|
+
best_score = score
|
|
279
|
+
best_state = candidate_id
|
|
280
|
+
if best_state is None:
|
|
281
|
+
best_state = next_state_id
|
|
282
|
+
states[next_state_id] = set()
|
|
283
|
+
next_state_id += 1
|
|
284
|
+
states[best_state].add(child)
|
|
285
|
+
history_to_state[child] = best_state
|
|
286
|
+
else:
|
|
287
|
+
states[state_id].add(child)
|
|
288
|
+
history_to_state[child] = state_id
|
|
289
|
+
return states, history_to_state
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _cssr_determinize(
|
|
293
|
+
states: dict[int, set[History]],
|
|
294
|
+
history_to_state: dict[History, int],
|
|
295
|
+
counts: SuffixCounts,
|
|
296
|
+
*,
|
|
297
|
+
length: int,
|
|
298
|
+
successor_fn: Callable[[History, Any], History] = _grow_history,
|
|
299
|
+
) -> dict[int, set[History]]:
|
|
300
|
+
"""Split homogeneous states until transitions are unifilar."""
|
|
301
|
+
current = {state_id: set(histories) for state_id, histories in states.items()}
|
|
302
|
+
changed = True
|
|
303
|
+
next_state_id = max(current) + 1 if current else 0
|
|
304
|
+
|
|
305
|
+
while changed:
|
|
306
|
+
changed = False
|
|
307
|
+
for state_id in sorted(current):
|
|
308
|
+
histories = current[state_id]
|
|
309
|
+
if len(histories) <= 1:
|
|
310
|
+
continue
|
|
311
|
+
for symbol in counts.alphabet:
|
|
312
|
+
buckets: dict[int, set[History]] = defaultdict(set)
|
|
313
|
+
for history in histories:
|
|
314
|
+
if counts.next_counts.get(history, Counter()).get(symbol, 0) == 0:
|
|
315
|
+
continue
|
|
316
|
+
child = successor_fn(history, symbol)
|
|
317
|
+
target = history_to_state.get(child)
|
|
318
|
+
if target is None:
|
|
319
|
+
continue
|
|
320
|
+
buckets[target].add(history)
|
|
321
|
+
if len(buckets) <= 1:
|
|
322
|
+
continue
|
|
323
|
+
# Keep the largest bucket in the original state; split others.
|
|
324
|
+
ordered = sorted(buckets.items(), key=lambda item: (-len(item[1]), min(item[1])))
|
|
325
|
+
keep_target, keep_histories = ordered[0]
|
|
326
|
+
current[state_id] = keep_histories
|
|
327
|
+
for _target, split_histories in ordered[1:]:
|
|
328
|
+
new_id = next_state_id
|
|
329
|
+
next_state_id += 1
|
|
330
|
+
current[new_id] = split_histories
|
|
331
|
+
for history in split_histories:
|
|
332
|
+
history_to_state[history] = new_id
|
|
333
|
+
changed = True
|
|
334
|
+
break
|
|
335
|
+
if changed:
|
|
336
|
+
break
|
|
337
|
+
return current
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _merge_similar_states(
|
|
341
|
+
states: dict[int, set[History]],
|
|
342
|
+
history_to_state: dict[History, int],
|
|
343
|
+
counts: SuffixCounts,
|
|
344
|
+
*,
|
|
345
|
+
alpha: float,
|
|
346
|
+
test: Literal["g", "chi2", "tv"],
|
|
347
|
+
) -> dict[int, set[History]]:
|
|
348
|
+
"""Merge inferred states whose pooled morphs are statistically indistinguishable.
|
|
349
|
+
|
|
350
|
+
Merging on the morph alone can fuse states with incompatible ``symbol ->
|
|
351
|
+
successor`` maps, yielding a non-unifilar partition. Callers must re-run
|
|
352
|
+
:func:`_cssr_determinize` afterwards to restore unifilarity.
|
|
353
|
+
"""
|
|
354
|
+
current = {state_id: set(histories) for state_id, histories in states.items()}
|
|
355
|
+
changed = True
|
|
356
|
+
while changed:
|
|
357
|
+
changed = False
|
|
358
|
+
state_ids = sorted(current)
|
|
359
|
+
for index, left_id in enumerate(state_ids):
|
|
360
|
+
if left_id not in current:
|
|
361
|
+
continue
|
|
362
|
+
for right_id in state_ids[index + 1 :]:
|
|
363
|
+
if right_id not in current:
|
|
364
|
+
continue
|
|
365
|
+
if morphs_differ(
|
|
366
|
+
counts,
|
|
367
|
+
current[left_id],
|
|
368
|
+
current[right_id],
|
|
369
|
+
alpha=alpha,
|
|
370
|
+
test=test,
|
|
371
|
+
):
|
|
372
|
+
continue
|
|
373
|
+
current[left_id].update(current.pop(right_id))
|
|
374
|
+
for history in current[left_id]:
|
|
375
|
+
history_to_state[history] = left_id
|
|
376
|
+
changed = True
|
|
377
|
+
break
|
|
378
|
+
if changed:
|
|
379
|
+
break
|
|
380
|
+
return current
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _drop_transient_states(
|
|
384
|
+
states: dict[int, set[History]],
|
|
385
|
+
history_to_state: dict[History, int],
|
|
386
|
+
counts: SuffixCounts,
|
|
387
|
+
*,
|
|
388
|
+
length: int,
|
|
389
|
+
) -> dict[int, set[History]]:
|
|
390
|
+
"""Keep only states in bottom strongly connected components."""
|
|
391
|
+
import networkx as nx
|
|
392
|
+
|
|
393
|
+
successors: dict[int, dict[Any, set[int]]] = defaultdict(lambda: defaultdict(set))
|
|
394
|
+
for state_id, histories in states.items():
|
|
395
|
+
for history in histories:
|
|
396
|
+
for symbol in counts.alphabet:
|
|
397
|
+
if counts.next_counts.get(history, Counter()).get(symbol, 0) == 0:
|
|
398
|
+
continue
|
|
399
|
+
child = history + (symbol,)
|
|
400
|
+
target = history_to_state.get(child)
|
|
401
|
+
if target is None:
|
|
402
|
+
continue
|
|
403
|
+
successors[state_id][symbol].add(target)
|
|
404
|
+
|
|
405
|
+
graph = nx.DiGraph()
|
|
406
|
+
for state_id in states:
|
|
407
|
+
graph.add_node(state_id)
|
|
408
|
+
for state_id, by_symbol in successors.items():
|
|
409
|
+
for targets in by_symbol.values():
|
|
410
|
+
for target in targets:
|
|
411
|
+
graph.add_edge(state_id, target)
|
|
412
|
+
|
|
413
|
+
if graph.number_of_edges() == 0:
|
|
414
|
+
return states
|
|
415
|
+
|
|
416
|
+
recurrent: set[int] = set()
|
|
417
|
+
for component in nx.strongly_connected_components(graph):
|
|
418
|
+
if not component:
|
|
419
|
+
continue
|
|
420
|
+
subgraph = graph.subgraph(component)
|
|
421
|
+
has_cycle = subgraph.number_of_edges() > 0 and (
|
|
422
|
+
len(component) > 1 or any(subgraph.has_edge(node, node) for node in component)
|
|
423
|
+
)
|
|
424
|
+
if not has_cycle:
|
|
425
|
+
continue
|
|
426
|
+
outgoing = any(graph.has_edge(v, w) for v in component for w in graph.nodes if w not in component)
|
|
427
|
+
if not outgoing:
|
|
428
|
+
recurrent.update(component)
|
|
429
|
+
|
|
430
|
+
if not recurrent:
|
|
431
|
+
return states
|
|
432
|
+
return {state_id: histories for state_id, histories in states.items() if state_id in recurrent}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _empirical_state_visits(
|
|
436
|
+
sequence: Sequence[Any],
|
|
437
|
+
history_to_state: dict[History, int],
|
|
438
|
+
*,
|
|
439
|
+
length: int,
|
|
440
|
+
) -> Counter[int]:
|
|
441
|
+
"""Count how often each causal state is occupied along ``sequence``.
|
|
442
|
+
|
|
443
|
+
Every time step belongs to exactly one causal state — the one keyed by the
|
|
444
|
+
*longest* available suffix (up to ``length``). Counting each nested suffix
|
|
445
|
+
(as an earlier version did) over-weights short-history states and skews the
|
|
446
|
+
reconstructed ``initial_distribution`` away from the occupation/stationary law.
|
|
447
|
+
"""
|
|
448
|
+
visits: Counter[int] = Counter()
|
|
449
|
+
seq = tuple(sequence)
|
|
450
|
+
for t in range(len(seq)):
|
|
451
|
+
for hist_len in range(min(t, length), -1, -1):
|
|
452
|
+
history = seq[t - hist_len : t]
|
|
453
|
+
state = history_to_state.get(history)
|
|
454
|
+
if state is not None:
|
|
455
|
+
visits[state] += 1
|
|
456
|
+
break
|
|
457
|
+
return visits
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _counts_to_mealy(
|
|
461
|
+
states: dict[int, set[History]],
|
|
462
|
+
counts: SuffixCounts,
|
|
463
|
+
history_to_state: dict[History, int],
|
|
464
|
+
sequence: Sequence[Any],
|
|
465
|
+
*,
|
|
466
|
+
length: int,
|
|
467
|
+
) -> EpsilonMachine:
|
|
468
|
+
visits = _empirical_state_visits(sequence, history_to_state, length=length)
|
|
469
|
+
if not visits:
|
|
470
|
+
raise StochasticValidationError("no empirical state visits")
|
|
471
|
+
|
|
472
|
+
graph = TransitionGraph()
|
|
473
|
+
state_labels = {state_id: f"s{state_id}" for state_id in states}
|
|
474
|
+
for label in state_labels.values():
|
|
475
|
+
graph.add_state(label)
|
|
476
|
+
|
|
477
|
+
for state_id, histories in states.items():
|
|
478
|
+
label = state_labels[state_id]
|
|
479
|
+
morph = counts.state_morph(histories)
|
|
480
|
+
for symbol in counts.alphabet:
|
|
481
|
+
prob = morph[symbol]
|
|
482
|
+
if prob <= 0.0:
|
|
483
|
+
continue
|
|
484
|
+
emitting = [
|
|
485
|
+
history for history in histories if counts.next_counts.get(history, Counter()).get(symbol, 0) > 0
|
|
486
|
+
]
|
|
487
|
+
if not emitting:
|
|
488
|
+
continue
|
|
489
|
+
child_histories = {history + (symbol,) for history in emitting}
|
|
490
|
+
targets = {history_to_state.get(child) for child in child_histories}
|
|
491
|
+
targets.discard(None)
|
|
492
|
+
if not targets:
|
|
493
|
+
continue
|
|
494
|
+
if len(targets) > 1:
|
|
495
|
+
raise StochasticValidationError(f"non-unifilar inferred transition from {label!r} on {symbol!r}")
|
|
496
|
+
target_label = state_labels[next(iter(targets))]
|
|
497
|
+
graph.add_transition(label, target_label, **{ATTR_PROB: prob, ATTR_EMISSION: symbol})
|
|
498
|
+
|
|
499
|
+
total_visits = float(sum(visits.values()))
|
|
500
|
+
initial = {state_labels[state_id]: visits[state_id] / total_visits for state_id in states if visits[state_id] > 0}
|
|
501
|
+
if not initial:
|
|
502
|
+
initial = {state_labels[next(iter(states))]: 1.0}
|
|
503
|
+
|
|
504
|
+
machine = EpsilonMachine(
|
|
505
|
+
graph=graph,
|
|
506
|
+
initial_distribution=initial,
|
|
507
|
+
observation_alphabet=frozenset(counts.alphabet),
|
|
508
|
+
)
|
|
509
|
+
machine.validate()
|
|
510
|
+
return machine
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def cssr(
|
|
514
|
+
sequence: Sequence[Any],
|
|
515
|
+
*,
|
|
516
|
+
alphabet: Sequence[Any] | None = None,
|
|
517
|
+
Lmax: int | None = None,
|
|
518
|
+
alpha: float = 0.05,
|
|
519
|
+
test: Literal["g", "chi2", "tv"] = "g",
|
|
520
|
+
min_count: int = 5,
|
|
521
|
+
) -> EpsilonMachine:
|
|
522
|
+
"""Reconstruct an ε-machine by Causal-State Splitting Reconstruction (CSSR)."""
|
|
523
|
+
seq = tuple(sequence)
|
|
524
|
+
if len(seq) < 2:
|
|
525
|
+
raise ValueError("sequence must contain at least two symbols")
|
|
526
|
+
alphabet_size = len(set(seq)) if alphabet is None else len(tuple(alphabet))
|
|
527
|
+
max_length = Lmax if Lmax is not None else _default_lmax(len(seq), alphabet_size, min_count)
|
|
528
|
+
counts = SuffixCounts.from_sequence(seq, alphabet=alphabet, max_length=max_length + 1)
|
|
529
|
+
|
|
530
|
+
states, history_to_state = _cssr_homogenize(
|
|
531
|
+
counts,
|
|
532
|
+
Lmax=max_length,
|
|
533
|
+
alpha=alpha,
|
|
534
|
+
test=test,
|
|
535
|
+
)
|
|
536
|
+
states = _cssr_determinize(states, history_to_state, counts, length=max_length)
|
|
537
|
+
states = _merge_similar_states(states, history_to_state, counts, alpha=alpha, test=test)
|
|
538
|
+
# Re-determinize: morph-only merging can fuse states with incompatible
|
|
539
|
+
# successors, so restore unifilarity before building the machine.
|
|
540
|
+
states = _cssr_determinize(states, history_to_state, counts, length=max_length)
|
|
541
|
+
states = _drop_transient_states(states, history_to_state, counts, length=max_length)
|
|
542
|
+
history_to_state = {history: state_id for state_id, histories in states.items() for history in histories}
|
|
543
|
+
return _counts_to_mealy(states, counts, history_to_state, seq, length=max_length)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _morph_distance(
|
|
547
|
+
counts: SuffixCounts,
|
|
548
|
+
left: History,
|
|
549
|
+
right: History,
|
|
550
|
+
*,
|
|
551
|
+
delta: float,
|
|
552
|
+
) -> float:
|
|
553
|
+
left_morph = counts.morph(left)
|
|
554
|
+
right_morph = counts.morph(right)
|
|
555
|
+
return 0.5 * sum(abs(left_morph[s] - right_morph[s]) for s in counts.alphabet)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _morphs_equivalent(
|
|
559
|
+
counts: SuffixCounts,
|
|
560
|
+
left: History,
|
|
561
|
+
right: History,
|
|
562
|
+
*,
|
|
563
|
+
delta: float,
|
|
564
|
+
) -> bool:
|
|
565
|
+
left_morph = counts.morph(left)
|
|
566
|
+
right_morph = counts.morph(right)
|
|
567
|
+
if delta > 0.0:
|
|
568
|
+
return _morph_distance(counts, left, right, delta=delta) <= delta
|
|
569
|
+
return all(np.isclose(left_morph[symbol], right_morph[symbol], rtol=0.0, atol=1e-3) for symbol in counts.alphabet)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _cluster_histories_by_morph(
|
|
573
|
+
counts: SuffixCounts,
|
|
574
|
+
histories: set[History],
|
|
575
|
+
*,
|
|
576
|
+
delta: float,
|
|
577
|
+
) -> dict[int, set[History]]:
|
|
578
|
+
parent: dict[History, History] = {history: history for history in histories}
|
|
579
|
+
|
|
580
|
+
def find(history: History) -> History:
|
|
581
|
+
root = history
|
|
582
|
+
while parent[root] != root:
|
|
583
|
+
parent[root] = parent[parent[root]]
|
|
584
|
+
root = parent[root]
|
|
585
|
+
return root
|
|
586
|
+
|
|
587
|
+
def union(left: History, right: History) -> None:
|
|
588
|
+
left_root = find(left)
|
|
589
|
+
right_root = find(right)
|
|
590
|
+
if left_root != right_root:
|
|
591
|
+
parent[right_root] = left_root
|
|
592
|
+
|
|
593
|
+
history_list = sorted(histories)
|
|
594
|
+
for index, left in enumerate(history_list):
|
|
595
|
+
for right in history_list[index + 1 :]:
|
|
596
|
+
if _morphs_equivalent(counts, left, right, delta=delta):
|
|
597
|
+
union(left, right)
|
|
598
|
+
|
|
599
|
+
clusters: dict[History, set[History]] = defaultdict(set)
|
|
600
|
+
for history in histories:
|
|
601
|
+
clusters[find(history)].add(history)
|
|
602
|
+
|
|
603
|
+
states: dict[int, set[History]] = {}
|
|
604
|
+
for state_id, (_root, members) in enumerate(clusters.items()):
|
|
605
|
+
states[state_id] = set(members)
|
|
606
|
+
return states
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def subtree_merge(
|
|
610
|
+
sequence: Sequence[Any],
|
|
611
|
+
*,
|
|
612
|
+
L: int,
|
|
613
|
+
delta: float = 0.0,
|
|
614
|
+
alphabet: Sequence[Any] | None = None,
|
|
615
|
+
) -> EpsilonMachine:
|
|
616
|
+
"""Reconstruct an ε-machine by merging depth-``L`` subtrees (Crutchfield--Young)."""
|
|
617
|
+
if L < 0:
|
|
618
|
+
raise ValueError("L must be non-negative")
|
|
619
|
+
seq = tuple(sequence)
|
|
620
|
+
if len(seq) < 2:
|
|
621
|
+
raise ValueError("sequence must contain at least two symbols")
|
|
622
|
+
counts = SuffixCounts.from_sequence(seq, alphabet=alphabet, max_length=L + 1)
|
|
623
|
+
|
|
624
|
+
histories = {history for history in counts.history_counts if len(history) <= L}
|
|
625
|
+
histories.add(())
|
|
626
|
+
|
|
627
|
+
states = _cluster_histories_by_morph(counts, histories, delta=delta)
|
|
628
|
+
history_to_state = {history: state_id for state_id, members in states.items() for history in members}
|
|
629
|
+
|
|
630
|
+
states = _cssr_determinize(states, history_to_state, counts, length=L)
|
|
631
|
+
history_to_state = {
|
|
632
|
+
history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
|
|
633
|
+
}
|
|
634
|
+
states = _merge_similar_states(states, history_to_state, counts, alpha=0.05, test="tv")
|
|
635
|
+
history_to_state = {
|
|
636
|
+
history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
|
|
637
|
+
}
|
|
638
|
+
# Re-determinize: morph-only merging can fuse states with incompatible
|
|
639
|
+
# successors, so restore unifilarity before building the machine.
|
|
640
|
+
states = _cssr_determinize(states, history_to_state, counts, length=L)
|
|
641
|
+
states = _drop_transient_states(states, history_to_state, counts, length=L)
|
|
642
|
+
history_to_state = {
|
|
643
|
+
history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
|
|
644
|
+
}
|
|
645
|
+
return _counts_to_mealy(states, counts, history_to_state, seq, length=L)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def spectral(
|
|
649
|
+
sequences: Iterable[Any] | None = None,
|
|
650
|
+
*,
|
|
651
|
+
word_probability: Callable[[Sequence[Any]], float] | None = None,
|
|
652
|
+
alphabet: Sequence[Any] | None = None,
|
|
653
|
+
rank: int | None = None,
|
|
654
|
+
prefix_length: int = 3,
|
|
655
|
+
suffix_length: int | None = None,
|
|
656
|
+
singular_value_threshold: float = 1e-3,
|
|
657
|
+
min_singular_value: float = 1e-12,
|
|
658
|
+
max_states: int = 10_000,
|
|
659
|
+
) -> EpsilonMachine:
|
|
660
|
+
"""Reconstruct an ε-machine by spectral learning then mixed-state extraction.
|
|
661
|
+
|
|
662
|
+
Learns a weighted finite automaton / observable-operator model from block
|
|
663
|
+
statistics :cite:`Balle2014,Hsu2012`, then extracts causal states as the
|
|
664
|
+
mixed states of those operators :cite:`Ellison2009`. When the learned
|
|
665
|
+
operators are non-negative this is a Mealy projection followed by
|
|
666
|
+
:meth:`~sofic.generators.epsilon_machine.EpsilonMachine.from_hmm`; signed
|
|
667
|
+
operators use mixed-state enumeration rather than a clustering heuristic.
|
|
668
|
+
|
|
669
|
+
Parameters
|
|
670
|
+
----------
|
|
671
|
+
sequences
|
|
672
|
+
A single observed realization or an iterable of realizations. Ignored
|
|
673
|
+
when ``word_probability`` is given.
|
|
674
|
+
word_probability
|
|
675
|
+
Optional exact block-probability function ``f(word) -> float``.
|
|
676
|
+
``alphabet`` is then required.
|
|
677
|
+
alphabet
|
|
678
|
+
Observation alphabet. Inferred from ``sequences`` when omitted.
|
|
679
|
+
rank
|
|
680
|
+
Number of latent states. When ``None`` the rank is chosen from the
|
|
681
|
+
Hankel singular-value spectrum.
|
|
682
|
+
prefix_length, suffix_length
|
|
683
|
+
Maximum lengths of the prefix and suffix bases. ``suffix_length``
|
|
684
|
+
defaults to ``prefix_length``.
|
|
685
|
+
singular_value_threshold, min_singular_value
|
|
686
|
+
Cutoffs for automatic rank selection; see
|
|
687
|
+
:func:`~sofic.inference.spectral.learn_spectral_wfa`.
|
|
688
|
+
max_states
|
|
689
|
+
Safety cap on enumerated mixed states.
|
|
690
|
+
"""
|
|
691
|
+
from sofic.inference.spectral import learn_spectral_wfa, project_to_epsilon_machine
|
|
692
|
+
|
|
693
|
+
model = learn_spectral_wfa(
|
|
694
|
+
sequences,
|
|
695
|
+
word_probability=word_probability,
|
|
696
|
+
alphabet=alphabet,
|
|
697
|
+
rank=rank,
|
|
698
|
+
prefix_length=prefix_length,
|
|
699
|
+
suffix_length=suffix_length,
|
|
700
|
+
singular_value_threshold=singular_value_threshold,
|
|
701
|
+
min_singular_value=min_singular_value,
|
|
702
|
+
)
|
|
703
|
+
return project_to_epsilon_machine(model, max_states=max_states)
|