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