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,509 @@
1
+ """Sample-based inference for hidden Markov stack models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter, defaultdict
6
+ from collections.abc import Callable, Hashable, Sequence
7
+ from typing import Any, ClassVar, Literal
8
+
9
+ from sofic.automata.papni import DyckAlphabet, is_well_matched, learn_sofic_dyck_shift_papni
10
+ from sofic.exceptions import StochasticValidationError
11
+ from sofic.generators.epsilon_inference import (
12
+ History,
13
+ SuffixCounts,
14
+ _cluster_histories_by_morph,
15
+ _cssr_determinize,
16
+ _cssr_homogenize,
17
+ _default_lmax,
18
+ _drop_transient_states,
19
+ _merge_similar_states,
20
+ )
21
+ from sofic.generators.stack_hmm import HiddenMarkovStackModel
22
+ from sofic.graph import ATTR_SYMBOL
23
+ from sofic.shifts.sofic_dyck import SoficDyckShift, TransitionRef, transition_ref
24
+
25
+ __all__ = [
26
+ "ConfigurationHistory",
27
+ "StackSuffixCounts",
28
+ "fit_stack_hmm_mle",
29
+ "learn_stack_hmm_papni",
30
+ "stack_cssr",
31
+ "stack_subtree_merge",
32
+ ]
33
+
34
+ ConfigurationHistory = tuple[tuple[Any, ...], tuple[Any, ...]]
35
+
36
+
37
+ class StackSuffixCounts(SuffixCounts):
38
+ """Empirical counts of (suffix, stack) histories and following symbols.
39
+
40
+ Shares the morph / comparison machinery of :class:`SuffixCounts`; only the
41
+ empty-history key and the sequence-scanning constructor differ.
42
+ """
43
+
44
+ empty_history: ClassVar[History] = ((), ())
45
+
46
+ def __init__(
47
+ self,
48
+ alphabet: tuple[Any, ...],
49
+ history_counts: Counter[ConfigurationHistory] | None = None,
50
+ next_counts: dict[ConfigurationHistory, Counter[Any]] | None = None,
51
+ ) -> None:
52
+ super().__init__(
53
+ alphabet=alphabet,
54
+ history_counts=history_counts if history_counts is not None else Counter(),
55
+ next_counts=next_counts if next_counts is not None else defaultdict(Counter),
56
+ )
57
+
58
+ @classmethod
59
+ def from_sequence( # type: ignore[override]
60
+ cls,
61
+ sequence: Sequence[Any],
62
+ *,
63
+ alphabet: DyckAlphabet,
64
+ max_length: int | None = None,
65
+ max_stack_depth: int = 8,
66
+ ) -> StackSuffixCounts:
67
+ seq = tuple(sequence)
68
+ if not seq:
69
+ raise ValueError("sequence must be non-empty")
70
+ visible_alphabet = tuple(sorted(alphabet.symbol_alphabet, key=repr))
71
+ max_len = max_length if max_length is not None else len(seq)
72
+ counts = cls(alphabet=visible_alphabet)
73
+ stack: list[Any] = []
74
+ for t, symbol in enumerate(seq):
75
+ if symbol not in alphabet.symbol_alphabet:
76
+ raise ValueError(f"symbol {symbol!r} not in Dyck alphabet")
77
+ for length in range(0, min(t, max_len) + 1):
78
+ suffix = seq[t - length : t]
79
+ history = (suffix, tuple(stack))
80
+ counts.history_counts[history] += 1
81
+ counts.next_counts[history][symbol] += 1
82
+ if symbol in alphabet.call_alphabet:
83
+ if len(stack) >= max_stack_depth:
84
+ stack = stack[1:]
85
+ stack.append(symbol)
86
+ elif symbol in alphabet.return_alphabet:
87
+ if stack:
88
+ stack.pop()
89
+ return counts
90
+
91
+
92
+ def _successor_history(
93
+ history: ConfigurationHistory,
94
+ symbol: Any,
95
+ *,
96
+ alphabet: DyckAlphabet,
97
+ length: int,
98
+ max_stack_depth: int,
99
+ ) -> ConfigurationHistory:
100
+ suffix, stack = history
101
+ extended = suffix + (symbol,)
102
+ if length <= 0:
103
+ new_suffix: tuple[Any, ...] = ()
104
+ elif len(extended) <= length:
105
+ new_suffix = extended
106
+ else:
107
+ new_suffix = extended[-length:]
108
+
109
+ stack_list = list(stack)
110
+ if symbol in alphabet.call_alphabet:
111
+ if len(stack_list) >= max_stack_depth:
112
+ stack_list = stack_list[1:]
113
+ stack_list.append(symbol)
114
+ elif symbol in alphabet.return_alphabet and stack_list:
115
+ stack_list.pop()
116
+ return new_suffix, tuple(stack_list)
117
+
118
+
119
+ def _stack_successor_fn(
120
+ *,
121
+ alphabet: DyckAlphabet,
122
+ length: int,
123
+ max_stack_depth: int,
124
+ ) -> Callable[[ConfigurationHistory, Any], ConfigurationHistory]:
125
+ """Bind the stack-lifted successor into the ``(history, symbol)`` shape shared CSSR expects."""
126
+
127
+ def successor(history: ConfigurationHistory, symbol: Any) -> ConfigurationHistory:
128
+ return _successor_history(
129
+ history,
130
+ symbol,
131
+ alphabet=alphabet,
132
+ length=length,
133
+ max_stack_depth=max_stack_depth,
134
+ )
135
+
136
+ return successor
137
+
138
+
139
+ def _stack_homogenize(
140
+ counts: StackSuffixCounts,
141
+ *,
142
+ alphabet: DyckAlphabet,
143
+ Lmax: int,
144
+ alpha: float,
145
+ test: Literal["g", "chi2", "tv"],
146
+ max_stack_depth: int,
147
+ ) -> tuple[dict[int, set[ConfigurationHistory]], dict[ConfigurationHistory, int]]:
148
+ return _cssr_homogenize(
149
+ counts,
150
+ Lmax=Lmax,
151
+ alpha=alpha,
152
+ test=test,
153
+ successor_fn=_stack_successor_fn(alphabet=alphabet, length=Lmax, max_stack_depth=max_stack_depth),
154
+ )
155
+
156
+
157
+ def _stack_determinize(
158
+ states: dict[int, set[ConfigurationHistory]],
159
+ history_to_state: dict[ConfigurationHistory, int],
160
+ counts: StackSuffixCounts,
161
+ *,
162
+ length: int,
163
+ alphabet: DyckAlphabet,
164
+ max_stack_depth: int,
165
+ ) -> dict[int, set[ConfigurationHistory]]:
166
+ """Split homogeneous states until stack-lifted transitions are unifilar."""
167
+ return _cssr_determinize(
168
+ states,
169
+ history_to_state,
170
+ counts,
171
+ length=length,
172
+ successor_fn=_stack_successor_fn(alphabet=alphabet, length=length, max_stack_depth=max_stack_depth),
173
+ )
174
+
175
+
176
+ def _stack_merge(
177
+ states: dict[int, set[ConfigurationHistory]],
178
+ history_to_state: dict[ConfigurationHistory, int],
179
+ counts: StackSuffixCounts,
180
+ *,
181
+ alpha: float,
182
+ test: Literal["g", "chi2", "tv"],
183
+ ) -> dict[int, set[ConfigurationHistory]]:
184
+ proxy = counts.restricted_to(set(history_to_state))
185
+ return _merge_similar_states(states, history_to_state, proxy, alpha=alpha, test=test)
186
+
187
+
188
+ def _stack_drop_transient(
189
+ states: dict[int, set[ConfigurationHistory]],
190
+ history_to_state: dict[ConfigurationHistory, int],
191
+ counts: StackSuffixCounts,
192
+ *,
193
+ length: int,
194
+ alphabet: DyckAlphabet,
195
+ max_stack_depth: int,
196
+ ) -> dict[int, set[ConfigurationHistory]]:
197
+ proxy = counts.restricted_to(set(history_to_state))
198
+ return _drop_transient_states(states, history_to_state, proxy, length=length)
199
+
200
+
201
+ def _representative_stack(histories: set[ConfigurationHistory]) -> tuple[Any, ...]:
202
+ stacks = [stack for _suffix, stack in histories if stack]
203
+ if not stacks:
204
+ return ()
205
+ return max(stacks, key=len)
206
+
207
+
208
+ def _counts_to_stack_hmm(
209
+ states: dict[int, set[ConfigurationHistory]],
210
+ counts: StackSuffixCounts,
211
+ history_to_state: dict[ConfigurationHistory, int],
212
+ sequence: Sequence[Any],
213
+ *,
214
+ alphabet: DyckAlphabet,
215
+ length: int,
216
+ ) -> HiddenMarkovStackModel:
217
+ visits: Counter[int] = Counter()
218
+ seq = tuple(sequence)
219
+ stack: list[Any] = []
220
+ for t in range(len(seq)):
221
+ for hist_len in range(0, min(t, length) + 1):
222
+ suffix = seq[t - hist_len : t]
223
+ state = history_to_state.get((suffix, tuple(stack)))
224
+ if state is not None:
225
+ visits[state] += 1
226
+ symbol = seq[t]
227
+ if symbol in alphabet.call_alphabet:
228
+ stack.append(symbol)
229
+ elif symbol in alphabet.return_alphabet and stack:
230
+ stack.pop()
231
+
232
+ if not visits:
233
+ raise StochasticValidationError("no empirical configuration visits")
234
+
235
+ model = HiddenMarkovStackModel(
236
+ call_alphabet=alphabet.call_alphabet,
237
+ return_alphabet=alphabet.return_alphabet,
238
+ internal_alphabet=alphabet.internal_alphabet,
239
+ )
240
+ state_labels = {state_id: f"s{state_id}" for state_id in states}
241
+ for label in state_labels.values():
242
+ model.graph.add_state(label)
243
+
244
+ call_refs: dict[tuple[Hashable, Any], TransitionRef] = {}
245
+ return_refs: dict[tuple[Hashable, Any, Any], TransitionRef] = {}
246
+
247
+ for state_id, histories in states.items():
248
+ source = state_labels[state_id]
249
+ stack_repr = _representative_stack(histories)
250
+ stack_tops = {stack[-1] for _suffix, stack in histories if stack}
251
+ morph = counts.state_morph(histories)
252
+ for symbol in counts.alphabet:
253
+ prob = morph[symbol]
254
+ if prob <= 0.0:
255
+ continue
256
+ emitting = [
257
+ history for history in histories if counts.next_counts.get(history, Counter()).get(symbol, 0) > 0
258
+ ]
259
+ if not emitting:
260
+ continue
261
+ child_histories = {
262
+ _successor_history(
263
+ history,
264
+ symbol,
265
+ alphabet=alphabet,
266
+ length=length,
267
+ max_stack_depth=max(len(stack_repr), 1),
268
+ )
269
+ for history in emitting
270
+ }
271
+ targets = {history_to_state.get(child) for child in child_histories}
272
+ targets.discard(None)
273
+ if not targets:
274
+ continue
275
+ if len(targets) > 1:
276
+ target_counts: Counter[int] = Counter()
277
+ for history in emitting:
278
+ child = _successor_history(
279
+ history,
280
+ symbol,
281
+ alphabet=alphabet,
282
+ length=length,
283
+ max_stack_depth=max(len(stack_repr), 1),
284
+ )
285
+ target_id = history_to_state.get(child)
286
+ if target_id is not None:
287
+ target_counts[target_id] += counts.history_counts.get(history, 0)
288
+ target_id = target_counts.most_common(1)[0][0]
289
+ else:
290
+ target_id = next(iter(targets))
291
+ target = state_labels[target_id]
292
+
293
+ if symbol in alphabet.call_alphabet:
294
+ key = (source, symbol, target)
295
+ if key not in call_refs:
296
+ call_refs[key] = model.add_call_transition(source, target, symbol, prob)
297
+ elif symbol in alphabet.return_alphabet:
298
+ call_candidates = stack_tops or frozenset(alphabet.call_alphabet)
299
+ for matched_call in call_candidates:
300
+ key = (source, symbol, matched_call)
301
+ if key not in return_refs:
302
+ return_refs[key] = model.add_return_transition(source, target, symbol, prob)
303
+ else:
304
+ model.add_internal_transition(source, target, symbol, prob)
305
+
306
+ for (_src, call_symbol, _target), call_ref in call_refs.items():
307
+ for (_ret_source, _return_symbol, matched_call), return_ref in return_refs.items():
308
+ if matched_call == call_symbol:
309
+ model.add_matched_pair(call_ref, return_ref)
310
+
311
+ for state_id, histories in states.items():
312
+ source = state_labels[state_id]
313
+ for history in histories:
314
+ _suffix, stack = history
315
+ if not stack:
316
+ continue
317
+ for symbol in alphabet.return_alphabet:
318
+ if counts.next_counts.get(history, Counter()).get(symbol, 0) <= 0:
319
+ continue
320
+ matched_call = stack[-1]
321
+ for (src, sym, _tgt), call_ref in call_refs.items():
322
+ if src != source or sym != matched_call:
323
+ continue
324
+ for (rsrc, rsym, mc), return_ref in return_refs.items():
325
+ if rsrc == source and rsym == symbol and mc == matched_call:
326
+ model.add_matched_pair(call_ref, return_ref)
327
+
328
+ total_visits = float(sum(visits.values()))
329
+ initial = {state_labels[state_id]: visits[state_id] / total_visits for state_id in states if visits[state_id] > 0}
330
+ if not initial:
331
+ initial = {state_labels[next(iter(states))]: 1.0}
332
+ model.initial_distribution = initial
333
+ model.validate()
334
+ return model
335
+
336
+
337
+ def stack_cssr(
338
+ sequence: Sequence[Any],
339
+ *,
340
+ alphabet: DyckAlphabet,
341
+ Lmax: int | None = None,
342
+ max_stack_depth: int = 8,
343
+ alpha: float = 0.05,
344
+ test: Literal["g", "chi2", "tv"] = "g",
345
+ min_count: int = 5,
346
+ ) -> HiddenMarkovStackModel:
347
+ """Reconstruct a stack HMM via configuration-lifted CSSR."""
348
+ seq = tuple(sequence)
349
+ if len(seq) < 2:
350
+ raise ValueError("sequence must contain at least two symbols")
351
+ max_length = Lmax if Lmax is not None else _default_lmax(len(seq), len(alphabet.symbol_alphabet), min_count)
352
+ counts = StackSuffixCounts.from_sequence(
353
+ seq,
354
+ alphabet=alphabet,
355
+ max_length=max_length + 1,
356
+ max_stack_depth=max_stack_depth,
357
+ )
358
+ states, history_to_state = _stack_homogenize(
359
+ counts,
360
+ alphabet=alphabet,
361
+ Lmax=max_length,
362
+ alpha=alpha,
363
+ test=test,
364
+ max_stack_depth=max_stack_depth,
365
+ )
366
+ states = _stack_determinize(
367
+ states,
368
+ history_to_state,
369
+ counts,
370
+ length=max_length,
371
+ alphabet=alphabet,
372
+ max_stack_depth=max_stack_depth,
373
+ )
374
+ states = _stack_merge(states, history_to_state, counts, alpha=alpha, test=test)
375
+ states = _stack_drop_transient(
376
+ states, history_to_state, counts, length=max_length, alphabet=alphabet, max_stack_depth=max_stack_depth
377
+ )
378
+ history_to_state = {history: state_id for state_id, histories in states.items() for history in histories}
379
+ return _counts_to_stack_hmm(
380
+ states,
381
+ counts,
382
+ history_to_state,
383
+ seq,
384
+ alphabet=alphabet,
385
+ length=max_length,
386
+ )
387
+
388
+
389
+ def stack_subtree_merge(
390
+ sequence: Sequence[Any],
391
+ *,
392
+ alphabet: DyckAlphabet,
393
+ L: int,
394
+ max_stack_depth: int = 8,
395
+ delta: float = 0.0,
396
+ ) -> HiddenMarkovStackModel:
397
+ """Reconstruct a stack HMM by merging depth-``L`` configuration subtrees."""
398
+ if L < 0:
399
+ raise ValueError("L must be non-negative")
400
+ seq = tuple(sequence)
401
+ if len(seq) < 2:
402
+ raise ValueError("sequence must contain at least two symbols")
403
+ counts = StackSuffixCounts.from_sequence(
404
+ seq,
405
+ alphabet=alphabet,
406
+ max_length=L + 1,
407
+ max_stack_depth=max_stack_depth,
408
+ )
409
+ histories = {history for history in counts.history_counts if len(history[0]) <= L}
410
+ histories.add(((), ()))
411
+ proxy = counts.restricted_to(histories)
412
+ states = _cluster_histories_by_morph(proxy, histories, delta=delta)
413
+ history_to_state = {history: state_id for state_id, members in states.items() for history in members}
414
+ states = _stack_determinize(
415
+ states,
416
+ history_to_state,
417
+ counts,
418
+ length=L,
419
+ alphabet=alphabet,
420
+ max_stack_depth=max_stack_depth,
421
+ )
422
+ history_to_state = {
423
+ history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
424
+ }
425
+ states = _stack_merge(states, history_to_state, counts, alpha=0.05, test="tv")
426
+ history_to_state = {
427
+ history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
428
+ }
429
+ states = _stack_drop_transient(
430
+ states, history_to_state, counts, length=L, alphabet=alphabet, max_stack_depth=max_stack_depth
431
+ )
432
+ history_to_state = {
433
+ history: state_id for state_id, histories_in_state in states.items() for history in histories_in_state
434
+ }
435
+ return _counts_to_stack_hmm(
436
+ states,
437
+ counts,
438
+ history_to_state,
439
+ seq,
440
+ alphabet=alphabet,
441
+ length=L,
442
+ )
443
+
444
+
445
+ def fit_stack_hmm_mle(
446
+ shift: SoficDyckShift,
447
+ sequence: Sequence[Any],
448
+ *,
449
+ smoothing: float = 1e-6,
450
+ ) -> HiddenMarkovStackModel:
451
+ """Assign MLE edge probabilities to a fixed Dyck topology from one sample."""
452
+ from sofic.shifts.dyck_algorithms import _successors
453
+
454
+ seq = tuple(sequence)
455
+ edge_counts: Counter[TransitionRef] = Counter()
456
+ states = tuple(shift.states())
457
+ if not states:
458
+ raise ValueError("shift has no states")
459
+ state = states[0]
460
+ stack: tuple[TransitionRef, ...] = ()
461
+
462
+ for symbol in seq:
463
+ matched = False
464
+ for transition in shift.graph.out_transitions(state):
465
+ if transition.data.get(ATTR_SYMBOL) != symbol:
466
+ continue
467
+ for target, next_stack in _successors(shift, transition, stack):
468
+ edge_counts[transition_ref(transition)] += 1
469
+ state = target
470
+ stack = next_stack
471
+ matched = True
472
+ break
473
+ if matched:
474
+ break
475
+ if not matched:
476
+ break
477
+
478
+ probabilities: dict[TransitionRef, float] = {}
479
+ outgoing: dict[Hashable, list[TransitionRef]] = defaultdict(list)
480
+ for transition in shift.transitions():
481
+ ref = transition_ref(transition)
482
+ outgoing[transition.source].append(ref)
483
+
484
+ for refs in outgoing.values():
485
+ total = sum(edge_counts.get(ref, 0.0) for ref in refs) + smoothing * len(refs)
486
+ for ref in refs:
487
+ count = edge_counts.get(ref, 0.0) + smoothing
488
+ probabilities[ref] = count / total if total > 0 else 1.0 / len(refs)
489
+
490
+ return HiddenMarkovStackModel.from_sofic_dyck_shift(shift, probabilities)
491
+
492
+
493
+ def learn_stack_hmm_papni(
494
+ positive: Sequence[Sequence[Any]],
495
+ negative: Sequence[Sequence[Any]] | None = None,
496
+ *,
497
+ alphabet: DyckAlphabet,
498
+ sequence: Sequence[Any] | None = None,
499
+ ) -> HiddenMarkovStackModel:
500
+ """Learn stack topology via PAPNI and fit edge probabilities from ``sequence`` or positives."""
501
+ shift = learn_sofic_dyck_shift_papni(positive, negative, alphabet=alphabet)
502
+ fit_source: Sequence[Any]
503
+ if sequence is not None:
504
+ fit_source = sequence
505
+ else:
506
+ fit_source = max((tuple(word) for word in positive if is_well_matched(word, alphabet)), key=len, default=())
507
+ if not fit_source:
508
+ raise ValueError("no sequence available for parameter fitting")
509
+ return fit_stack_hmm_mle(shift, fit_source)
@@ -0,0 +1,134 @@
1
+ """Stationary distribution for hidden Markov models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from sofic.exceptions import StochasticValidationError
8
+ from sofic.generators.base import HiddenMarkovModel
9
+ from sofic.generators.prob import (
10
+ has_symbolic,
11
+ simplify_prob,
12
+ zeros,
13
+ )
14
+ from sofic.graph import ATTR_PROB
15
+
16
+
17
+ def stationary_distribution_hmm(hmm: HiddenMarkovModel) -> np.ndarray:
18
+ from sofic.properties import transition_matrix
19
+
20
+ idx = hmm.reindex()
21
+ if len(idx) == 0:
22
+ return np.array([], dtype=float)
23
+ transition, _states = transition_matrix(hmm, attr=ATTR_PROB, states=idx.states)
24
+ return stationary_distribution_from_transition(transition)
25
+
26
+
27
+ def stationary_distribution_from_transition(transition: np.ndarray) -> np.ndarray:
28
+ """Return a normalized left eigenvector of ``transition`` for eigenvalue one.
29
+
30
+ When ``transition`` contains sympy expressions, solve ``π P = π`` and
31
+ ``sum(π) = 1`` exactly via sympy linear algebra.
32
+ """
33
+ matrix = np.asarray(transition)
34
+ if matrix.dtype == object or has_symbolic(matrix.ravel()):
35
+ return _stationary_distribution_symbolic(matrix)
36
+ return _stationary_distribution_numeric(np.asarray(matrix, dtype=float))
37
+
38
+
39
+ def _stationary_distribution_numeric(matrix: np.ndarray) -> np.ndarray:
40
+ """Return a normalized left eigenvector of ``transition`` for eigenvalue one."""
41
+ n = matrix.shape[0]
42
+ if matrix.shape != (n, n):
43
+ raise ValueError("transition matrix must be square")
44
+ if n == 0:
45
+ return np.array([], dtype=float)
46
+
47
+ eigenvalues, eigenvectors = np.linalg.eig(matrix.T)
48
+ candidates = sorted(range(n), key=lambda i: abs(eigenvalues[i] - 1.0))
49
+ for index in candidates:
50
+ if not np.isclose(eigenvalues[index], 1.0, rtol=1e-9, atol=1e-10):
51
+ continue
52
+ vector = np.real_if_close(eigenvectors[:, index], tol=1000)
53
+ if np.iscomplexobj(vector):
54
+ continue
55
+ pi = np.asarray(vector, dtype=float)
56
+ if pi.sum() < 0.0:
57
+ pi = -pi
58
+ pi[np.isclose(pi, 0.0, atol=1e-12)] = 0.0
59
+ if np.any(pi < -1e-10):
60
+ continue
61
+ pi = np.maximum(pi, 0.0)
62
+ total = float(pi.sum())
63
+ if total <= 0.0:
64
+ continue
65
+ pi = _clean_stationary_distribution(pi / total)
66
+ if np.allclose(pi @ matrix, pi, rtol=1e-8, atol=1e-10):
67
+ return pi
68
+
69
+ augmented = np.vstack([matrix.T - np.eye(n), np.ones(n)])
70
+ target = np.zeros(n + 1, dtype=float)
71
+ target[-1] = 1.0
72
+ solution, *_ = np.linalg.lstsq(augmented, target, rcond=None)
73
+ solution[np.isclose(solution, 0.0, atol=1e-12)] = 0.0
74
+ solution = np.maximum(solution, 0.0)
75
+ total = float(solution.sum())
76
+ if total <= 0.0:
77
+ raise StochasticValidationError("failed to compute a positive stationary distribution")
78
+ pi = _clean_stationary_distribution(solution / total)
79
+ if not np.allclose(pi @ matrix, pi, rtol=1e-8, atol=1e-10):
80
+ raise StochasticValidationError("failed to compute an invariant stationary distribution")
81
+ return pi
82
+
83
+
84
+ def _stationary_distribution_symbolic(matrix: np.ndarray) -> np.ndarray:
85
+ """Solve π P = π, sum π = 1 over a sympy-valued transition matrix."""
86
+ import sympy as sp
87
+
88
+ n = matrix.shape[0]
89
+ if matrix.shape != (n, n):
90
+ raise ValueError("transition matrix must be square")
91
+ if n == 0:
92
+ return zeros((0,), symbolic=True)
93
+
94
+ symbols = sp.symbols(f"pi0:{n}", real=True, nonnegative=True)
95
+ eqs = []
96
+ for j in range(n):
97
+ # (π P)_j = π_j
98
+ lhs = sum(symbols[i] * sp.sympify(matrix[i, j]) for i in range(n))
99
+ eqs.append(sp.Eq(sp.simplify(lhs - symbols[j]), 0))
100
+ eqs.append(sp.Eq(sum(symbols), 1))
101
+
102
+ solution = sp.solve(eqs, symbols, dict=True)
103
+ if not solution:
104
+ # Fall back to nullspace of (P^T - I) with normalization.
105
+ p = sp.Matrix([[sp.sympify(matrix[i, j]) for j in range(n)] for i in range(n)])
106
+ null = (p.T - sp.eye(n)).nullspace()
107
+ if not null:
108
+ raise StochasticValidationError("failed to compute a symbolic stationary distribution")
109
+ vec = null[0]
110
+ total = sum(vec)
111
+ if total == 0:
112
+ raise StochasticValidationError("failed to compute a symbolic stationary distribution")
113
+ pi = zeros((n,), symbolic=True)
114
+ for i in range(n):
115
+ pi[i] = simplify_prob(vec[i] / total)
116
+ return pi
117
+
118
+ best = solution[0]
119
+ pi = zeros((n,), symbolic=True)
120
+ for i, symbol in enumerate(symbols):
121
+ pi[i] = simplify_prob(best[symbol])
122
+ return pi
123
+
124
+
125
+ def _clean_stationary_distribution(distribution: np.ndarray) -> np.ndarray:
126
+ n = len(distribution)
127
+ if n == 0:
128
+ return distribution
129
+ uniform = np.full(n, 1.0 / n, dtype=float)
130
+ if np.allclose(distribution, uniform, rtol=1e-12, atol=1e-12):
131
+ return uniform
132
+ cleaned = distribution.copy()
133
+ cleaned[np.isclose(cleaned, 0.0, atol=1e-15)] = 0.0
134
+ return cleaned / cleaned.sum()
@@ -0,0 +1,65 @@
1
+ """Shared stochastic row/column validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from sofic.exceptions import StochasticValidationError
11
+
12
+
13
+ def shannon_entropy(values: Iterable[float], *, normalize: bool = False, atol: float = 0.0) -> float:
14
+ """Shannon entropy (in bits) of ``values``.
15
+
16
+ Values at or below ``atol`` are dropped. When ``normalize`` is true the
17
+ retained values are rescaled to sum to one before the entropy is taken;
18
+ otherwise they are assumed to already form a distribution.
19
+ """
20
+ probs = np.asarray(list(values), dtype=float)
21
+ probs = probs[probs > atol]
22
+ if probs.size == 0:
23
+ return 0.0
24
+ if normalize:
25
+ total = float(probs.sum())
26
+ if total <= 0.0:
27
+ return 0.0
28
+ probs = probs / total
29
+ return float(-np.sum(probs * np.log2(probs)))
30
+
31
+
32
+ def normalize_row_weights(weights: dict[tuple, Any], *, atol: float = 1e-9) -> dict[tuple, Any]:
33
+ """Return ``weights`` scaled to sum to 1 when the total is positive."""
34
+ from sofic.generators.prob import (
35
+ as_prob,
36
+ has_symbolic,
37
+ is_zero,
38
+ probs_equal,
39
+ simplify_prob,
40
+ sum_probs,
41
+ )
42
+
43
+ if not weights:
44
+ return {}
45
+ total = sum_probs(weights.values())
46
+ if is_zero(total):
47
+ return {}
48
+ if has_symbolic(weights.values()) or has_symbolic([total]):
49
+ if probs_equal(total, 1):
50
+ return {key: as_prob(value) for key, value in weights.items()}
51
+ return {key: simplify_prob(as_prob(value) / total) for key, value in weights.items()}
52
+ total_f = float(total)
53
+ if total_f <= 0.0:
54
+ return {}
55
+ if np.isclose(total_f, 1.0, atol=atol):
56
+ return dict(weights)
57
+ return {key: float(value) / total_f for key, value in weights.items()}
58
+
59
+
60
+ def assert_stochastic_rows(matrix: np.ndarray, *, atol: float = 1e-9) -> None:
61
+ """Raise if any row of ``matrix`` does not sum to 1."""
62
+ row_sums = matrix.sum(axis=1)
63
+ if not np.allclose(row_sums, 1.0, atol=atol):
64
+ bad = np.where(~np.isclose(row_sums, 1.0, atol=atol))[0]
65
+ raise StochasticValidationError(f"rows {bad.tolist()} do not sum to 1")