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,349 @@
|
|
|
1
|
+
"""Enumeration of canonical topological ε-machines.
|
|
2
|
+
|
|
3
|
+
Implements Algorithm 2 of Johnson, Crutchfield, Ellison & McTague (2010),
|
|
4
|
+
*Enumerating Finitary Processes* (arXiv:1011.0036), filtering incomplete
|
|
5
|
+
accessible DFA strings to strongly connected, minimal, canonical representatives.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Hashable, Iterator, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from sofic.automata.idfa import (
|
|
15
|
+
MISSING_TRANSITION,
|
|
16
|
+
IDFAEnumerationError,
|
|
17
|
+
_delta_table,
|
|
18
|
+
idfa_string_to_topological_graph,
|
|
19
|
+
iter_idfa_strings,
|
|
20
|
+
rank_idfa_string,
|
|
21
|
+
reroot_idfa_string,
|
|
22
|
+
transition_count,
|
|
23
|
+
validate_idfa_string,
|
|
24
|
+
)
|
|
25
|
+
from sofic.exceptions import SoficValidationError
|
|
26
|
+
from sofic.generators.epsilon_machine import EpsilonMachine
|
|
27
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"TopologicalEpsilonEnumerationError",
|
|
31
|
+
"count_topological_epsilon_machines",
|
|
32
|
+
"epsilon_machine_to_idfa_string",
|
|
33
|
+
"idfa_string_to_epsilon_machine",
|
|
34
|
+
"idfa_string_to_topological_graph",
|
|
35
|
+
"is_canonical_topological_epsilon",
|
|
36
|
+
"is_minimal_idfa",
|
|
37
|
+
"is_topological_epsilon_string",
|
|
38
|
+
"iter_topological_epsilon_machines",
|
|
39
|
+
"iter_topological_epsilon_strings",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TopologicalEpsilonEnumerationError(SoficValidationError):
|
|
44
|
+
"""Raised when topological ε-machine enumeration fails."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _uniform_stationary_distribution(
|
|
48
|
+
transitions: Sequence[int],
|
|
49
|
+
*,
|
|
50
|
+
n: int,
|
|
51
|
+
k: int,
|
|
52
|
+
) -> dict[int, float]:
|
|
53
|
+
table = _delta_table(transitions, n=n, k=k)
|
|
54
|
+
outdegree = [sum(1 for symbol in range(k) if table[state][symbol] is not None) for state in range(n)]
|
|
55
|
+
matrix = np.zeros((n, n), dtype=float)
|
|
56
|
+
for state in range(n):
|
|
57
|
+
if outdegree[state] == 0:
|
|
58
|
+
continue
|
|
59
|
+
weight = 1.0 / outdegree[state]
|
|
60
|
+
for symbol in range(k):
|
|
61
|
+
target = table[state][symbol]
|
|
62
|
+
if target is not None:
|
|
63
|
+
matrix[state, target] += weight
|
|
64
|
+
eigenvalues, vectors = np.linalg.eig(matrix.T)
|
|
65
|
+
index = int(np.argmin(np.abs(eigenvalues - 1.0)))
|
|
66
|
+
distribution = np.real(vectors[:, index])
|
|
67
|
+
if distribution.sum() < 0.0:
|
|
68
|
+
distribution = -distribution
|
|
69
|
+
distribution = np.maximum(distribution, 0.0)
|
|
70
|
+
total = float(distribution.sum())
|
|
71
|
+
if total <= 0.0:
|
|
72
|
+
raise TopologicalEpsilonEnumerationError("failed to compute stationary distribution")
|
|
73
|
+
distribution /= total
|
|
74
|
+
return {state: float(distribution[state]) for state in range(n)}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def idfa_string_to_epsilon_machine(
|
|
78
|
+
transitions: Sequence[int],
|
|
79
|
+
*,
|
|
80
|
+
n: int,
|
|
81
|
+
k: int,
|
|
82
|
+
alphabet: Sequence[object] | None = None,
|
|
83
|
+
) -> EpsilonMachine:
|
|
84
|
+
"""Decode an IDFA string into an :class:`~sofic.generators.epsilon_machine.EpsilonMachine`.
|
|
85
|
+
|
|
86
|
+
Outgoing edges from each state receive uniform probability ``1 / outdegree``,
|
|
87
|
+
matching the topological ε-machine convention of Johnson et al. (2010).
|
|
88
|
+
"""
|
|
89
|
+
validate_idfa_string(transitions, n=n, k=k)
|
|
90
|
+
if alphabet is None:
|
|
91
|
+
symbols = tuple(range(k))
|
|
92
|
+
else:
|
|
93
|
+
if len(alphabet) != k:
|
|
94
|
+
raise TopologicalEpsilonEnumerationError("alphabet length must equal k")
|
|
95
|
+
symbols = tuple(alphabet)
|
|
96
|
+
|
|
97
|
+
table = _delta_table(transitions, n=n, k=k)
|
|
98
|
+
outdegree = [sum(1 for symbol in range(k) if table[state][symbol] is not None) for state in range(n)]
|
|
99
|
+
eps = EpsilonMachine(
|
|
100
|
+
initial_distribution=_uniform_stationary_distribution(transitions, n=n, k=k),
|
|
101
|
+
observation_alphabet=frozenset(symbols),
|
|
102
|
+
)
|
|
103
|
+
for state in range(n):
|
|
104
|
+
eps.graph.add_state(state)
|
|
105
|
+
for state in range(n):
|
|
106
|
+
if outdegree[state] == 0:
|
|
107
|
+
continue
|
|
108
|
+
probability = 1.0 / outdegree[state]
|
|
109
|
+
for symbol_index, symbol in enumerate(symbols):
|
|
110
|
+
target = table[state][symbol_index]
|
|
111
|
+
if target is None:
|
|
112
|
+
continue
|
|
113
|
+
eps.graph.add_transition(
|
|
114
|
+
state,
|
|
115
|
+
target,
|
|
116
|
+
**{ATTR_PROB: probability, ATTR_EMISSION: symbol},
|
|
117
|
+
)
|
|
118
|
+
eps.validate()
|
|
119
|
+
return eps
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def epsilon_machine_to_idfa_string(
|
|
123
|
+
eps: EpsilonMachine,
|
|
124
|
+
*,
|
|
125
|
+
symbol_order: Sequence[object] | None = None,
|
|
126
|
+
canonical: bool = True,
|
|
127
|
+
) -> tuple[int, ...]:
|
|
128
|
+
"""Encode an ε-machine as an incomplete accessible DFA transition string.
|
|
129
|
+
|
|
130
|
+
Probabilities are ignored. Missing symbol transitions are encoded with
|
|
131
|
+
:data:`sofic.automata.idfa.MISSING_TRANSITION`. If ``canonical`` is true,
|
|
132
|
+
all states are tried as roots and the rank-minimal IDFA string is returned.
|
|
133
|
+
Otherwise, the first state in deterministic label order is used as the root.
|
|
134
|
+
"""
|
|
135
|
+
from sofic.generators.synchronization import graph_from_epsilon_machine
|
|
136
|
+
|
|
137
|
+
graph = graph_from_epsilon_machine(eps)
|
|
138
|
+
states = tuple(graph.states)
|
|
139
|
+
if not states:
|
|
140
|
+
raise TopologicalEpsilonEnumerationError("epsilon machine must have at least one state")
|
|
141
|
+
|
|
142
|
+
symbols = _validated_symbol_order(graph.alphabet, symbol_order)
|
|
143
|
+
if not symbols:
|
|
144
|
+
raise TopologicalEpsilonEnumerationError("alphabet must be non-empty")
|
|
145
|
+
|
|
146
|
+
if not canonical:
|
|
147
|
+
root = _sorted_by_repr(states)[0]
|
|
148
|
+
return _encode_topological_graph_from_root(graph.states, graph.transitions, symbols, root)
|
|
149
|
+
|
|
150
|
+
candidates: list[tuple[int, tuple[int, ...]]] = []
|
|
151
|
+
for root in states:
|
|
152
|
+
transitions = _encode_topological_graph_from_root(graph.states, graph.transitions, symbols, root)
|
|
153
|
+
try:
|
|
154
|
+
rank = rank_idfa_string(transitions, n=len(states), k=len(symbols))
|
|
155
|
+
except IDFAEnumerationError:
|
|
156
|
+
continue
|
|
157
|
+
candidates.append((rank, transitions))
|
|
158
|
+
if not candidates:
|
|
159
|
+
raise TopologicalEpsilonEnumerationError("encoded graph is not an accessible IDFA string")
|
|
160
|
+
return min(candidates, key=lambda candidate: (candidate[0], candidate[1]))[1]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _validated_symbol_order(
|
|
164
|
+
alphabet: frozenset[object],
|
|
165
|
+
symbol_order: Sequence[object] | None,
|
|
166
|
+
) -> tuple[object, ...]:
|
|
167
|
+
if symbol_order is None:
|
|
168
|
+
return tuple(_sorted_by_repr(alphabet))
|
|
169
|
+
|
|
170
|
+
symbols = tuple(symbol_order)
|
|
171
|
+
if len(frozenset(symbols)) != len(symbols):
|
|
172
|
+
raise TopologicalEpsilonEnumerationError("symbol_order must contain unique symbols")
|
|
173
|
+
if frozenset(symbols) != alphabet:
|
|
174
|
+
raise TopologicalEpsilonEnumerationError("symbol_order must match the epsilon machine alphabet")
|
|
175
|
+
return symbols
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _sorted_by_repr(values: Sequence[object] | frozenset[object]) -> tuple[object, ...]:
|
|
179
|
+
return tuple(sorted(values, key=lambda value: (type(value).__module__, type(value).__qualname__, repr(value))))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _encode_topological_graph_from_root(
|
|
183
|
+
states: frozenset[Hashable],
|
|
184
|
+
edges: Mapping[tuple[Hashable, object], Hashable],
|
|
185
|
+
symbols: Sequence[object],
|
|
186
|
+
root: Hashable,
|
|
187
|
+
) -> tuple[int, ...]:
|
|
188
|
+
edge_map = dict(edges)
|
|
189
|
+
if root not in states:
|
|
190
|
+
raise TopologicalEpsilonEnumerationError(f"unknown root state {root!r}")
|
|
191
|
+
|
|
192
|
+
state_to_index: dict[Hashable, int] = {root: 0}
|
|
193
|
+
index_to_state: list[Hashable] = [root]
|
|
194
|
+
transitions: list[int] = []
|
|
195
|
+
state_index = 0
|
|
196
|
+
|
|
197
|
+
while state_index < len(index_to_state):
|
|
198
|
+
state = index_to_state[state_index]
|
|
199
|
+
for symbol in symbols:
|
|
200
|
+
target = edge_map.get((state, symbol))
|
|
201
|
+
if target is None:
|
|
202
|
+
transitions.append(MISSING_TRANSITION)
|
|
203
|
+
continue
|
|
204
|
+
if target not in states:
|
|
205
|
+
raise TopologicalEpsilonEnumerationError(f"transition target {target!r} is not a graph state")
|
|
206
|
+
if target not in state_to_index:
|
|
207
|
+
state_to_index[target] = len(index_to_state)
|
|
208
|
+
index_to_state.append(target)
|
|
209
|
+
transitions.append(state_to_index[target])
|
|
210
|
+
state_index += 1
|
|
211
|
+
|
|
212
|
+
if len(index_to_state) != len(states):
|
|
213
|
+
raise TopologicalEpsilonEnumerationError(
|
|
214
|
+
"epsilon machine graph must be initially connected from the selected root"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
validate_idfa_string(transitions, n=len(states), k=len(symbols))
|
|
218
|
+
return tuple(transitions)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _reachable_states(transitions: Sequence[int], *, n: int, k: int, start: int) -> set[int]:
|
|
222
|
+
table = _delta_table(transitions, n=n, k=k)
|
|
223
|
+
seen = {start}
|
|
224
|
+
stack = [start]
|
|
225
|
+
while stack:
|
|
226
|
+
state = stack.pop()
|
|
227
|
+
for symbol in range(k):
|
|
228
|
+
target = table[state][symbol]
|
|
229
|
+
if target is not None and target not in seen:
|
|
230
|
+
seen.add(target)
|
|
231
|
+
stack.append(target)
|
|
232
|
+
return seen
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def is_strongly_connected_idfa(transitions: Sequence[int], *, n: int, k: int) -> bool:
|
|
236
|
+
"""Return whether every state can reach every other state."""
|
|
237
|
+
return all(len(_reachable_states(transitions, n=n, k=k, start=source)) == n for source in range(n))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def is_minimal_idfa(transitions: Sequence[int], *, n: int, k: int) -> bool:
|
|
241
|
+
"""Return whether the incomplete accessible DFA is minimal (Algorithm 2 step 4)."""
|
|
242
|
+
validate_idfa_string(transitions, n=n, k=k)
|
|
243
|
+
if n == 1:
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
table = _delta_table(transitions, n=n, k=k)
|
|
247
|
+
partition: list[set[int]] = [set(range(n))]
|
|
248
|
+
|
|
249
|
+
changed = True
|
|
250
|
+
while changed:
|
|
251
|
+
changed = False
|
|
252
|
+
block_index = {state: index for index, block in enumerate(partition) for state in block}
|
|
253
|
+
new_partition: list[set[int]] = []
|
|
254
|
+
for block in partition:
|
|
255
|
+
groups: dict[tuple[object, ...], set[int]] = {}
|
|
256
|
+
for state in block:
|
|
257
|
+
signature = tuple(
|
|
258
|
+
None if table[state][symbol] is None else block_index[table[state][symbol]] for symbol in range(k)
|
|
259
|
+
)
|
|
260
|
+
groups.setdefault(signature, set()).add(state)
|
|
261
|
+
if len(groups) > 1:
|
|
262
|
+
changed = True
|
|
263
|
+
new_partition.extend(groups.values())
|
|
264
|
+
partition = new_partition
|
|
265
|
+
return all(len(block) == 1 for block in partition)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def is_topological_epsilon_string(
|
|
269
|
+
transitions: Sequence[int],
|
|
270
|
+
*,
|
|
271
|
+
n: int,
|
|
272
|
+
k: int,
|
|
273
|
+
check_minimal: bool = True,
|
|
274
|
+
) -> bool:
|
|
275
|
+
"""Return whether ``transitions`` passes the structural ε-machine tests."""
|
|
276
|
+
validate_idfa_string(transitions, n=n, k=k)
|
|
277
|
+
defined = transition_count(transitions)
|
|
278
|
+
if defined < n:
|
|
279
|
+
return False
|
|
280
|
+
if n > 1 and defined >= n * k:
|
|
281
|
+
return False
|
|
282
|
+
if not is_strongly_connected_idfa(transitions, n=n, k=k):
|
|
283
|
+
return False
|
|
284
|
+
return not (check_minimal and not is_minimal_idfa(transitions, n=n, k=k))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def is_canonical_topological_epsilon(
|
|
288
|
+
transitions: Sequence[int],
|
|
289
|
+
*,
|
|
290
|
+
n: int,
|
|
291
|
+
k: int,
|
|
292
|
+
check_minimal: bool = True,
|
|
293
|
+
) -> bool:
|
|
294
|
+
"""Return whether ``transitions`` is a canonical topological ε-machine (Algorithm 2)."""
|
|
295
|
+
if not is_topological_epsilon_string(transitions, n=n, k=k, check_minimal=check_minimal):
|
|
296
|
+
return False
|
|
297
|
+
if n == 1:
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
rank = rank_idfa_string(transitions, n=n, k=k)
|
|
302
|
+
except IDFAEnumerationError:
|
|
303
|
+
return False
|
|
304
|
+
for root in range(1, n):
|
|
305
|
+
rotated = reroot_idfa_string(transitions, new_root=root, n=n, k=k)
|
|
306
|
+
if rotated is None:
|
|
307
|
+
return False
|
|
308
|
+
try:
|
|
309
|
+
rotated_rank = rank_idfa_string(rotated, n=n, k=k)
|
|
310
|
+
except IDFAEnumerationError:
|
|
311
|
+
continue
|
|
312
|
+
if rotated_rank < rank:
|
|
313
|
+
return False
|
|
314
|
+
if rotated_rank == rank and rotated < transitions:
|
|
315
|
+
return False
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def iter_topological_epsilon_strings(
|
|
320
|
+
k: int,
|
|
321
|
+
n: int,
|
|
322
|
+
*,
|
|
323
|
+
check_minimal: bool = True,
|
|
324
|
+
) -> Iterator[tuple[int, ...]]:
|
|
325
|
+
"""Yield canonical topological ε-machine transition strings.
|
|
326
|
+
|
|
327
|
+
By default, strings whose states are not minimal causal states are filtered
|
|
328
|
+
out. Pass ``check_minimal=False`` to enumerate the broader structural class.
|
|
329
|
+
"""
|
|
330
|
+
for transitions in iter_idfa_strings(k, n):
|
|
331
|
+
if is_canonical_topological_epsilon(transitions, n=n, k=k, check_minimal=check_minimal):
|
|
332
|
+
yield transitions
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def iter_topological_epsilon_machines(
|
|
336
|
+
k: int,
|
|
337
|
+
n: int,
|
|
338
|
+
*,
|
|
339
|
+
alphabet: Sequence[object] | None = None,
|
|
340
|
+
check_minimal: bool = True,
|
|
341
|
+
) -> Iterator[EpsilonMachine]:
|
|
342
|
+
"""Yield uniform-probability :class:`~sofic.generators.epsilon_machine.EpsilonMachine` objects."""
|
|
343
|
+
for transitions in iter_topological_epsilon_strings(k, n, check_minimal=check_minimal):
|
|
344
|
+
yield idfa_string_to_epsilon_machine(transitions, n=n, k=k, alphabet=alphabet)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def count_topological_epsilon_machines(k: int, n: int) -> int:
|
|
348
|
+
"""Return ``E_{n,k}``, the number of canonical topological ε-machines."""
|
|
349
|
+
return sum(1 for _ in iter_topological_epsilon_strings(k, n))
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Finite-word distributions for stochastic generators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
6
|
+
from itertools import product
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from sofic.generators.base import HiddenMarkovModel, QuasiStochasticModel
|
|
12
|
+
from sofic.generators.hmm_inference import _emission_transition_tensors
|
|
13
|
+
from sofic.generators.markov import MarkovChain
|
|
14
|
+
from sofic.generators.pfa import ProbabilisticFiniteAutomaton
|
|
15
|
+
from sofic.graph import ATTR_PROB
|
|
16
|
+
|
|
17
|
+
_TOL = 1e-15
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def hmm_words_of_length(hmm: HiddenMarkovModel, length: int) -> dict[tuple[Any, ...], float]:
|
|
21
|
+
"""Return observed words of ``length`` and their probabilities."""
|
|
22
|
+
if length < 0:
|
|
23
|
+
raise ValueError("length must be nonnegative")
|
|
24
|
+
pi, joint = _emission_transition_tensors(hmm)
|
|
25
|
+
alphabet = sorted(hmm.observation_alphabet, key=repr)
|
|
26
|
+
if length == 0:
|
|
27
|
+
return {(): float(pi.sum())} if pi.sum() > _TOL else {}
|
|
28
|
+
if not alphabet:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
terminal = np.ones(len(pi), dtype=float)
|
|
32
|
+
zero = np.zeros((len(pi), len(pi)), dtype=float)
|
|
33
|
+
distribution: dict[tuple[Any, ...], float] = {}
|
|
34
|
+
for word in product(alphabet, repeat=length):
|
|
35
|
+
mass = pi.copy()
|
|
36
|
+
for symbol in word:
|
|
37
|
+
mass = mass @ joint.get(symbol, zero)
|
|
38
|
+
probability = float(mass @ terminal)
|
|
39
|
+
if abs(probability) > _TOL:
|
|
40
|
+
distribution[word] = probability
|
|
41
|
+
return distribution
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def hmm_word_probability(
|
|
45
|
+
hmm: HiddenMarkovModel,
|
|
46
|
+
word: Sequence[Any],
|
|
47
|
+
*,
|
|
48
|
+
start: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
|
|
49
|
+
) -> float:
|
|
50
|
+
"""Return the probability of an observed ``word`` from ``start``.
|
|
51
|
+
|
|
52
|
+
``start`` may be ``None`` (use the model's initial distribution), a state,
|
|
53
|
+
a state-probability mapping, or a dense vector in the model's state order.
|
|
54
|
+
"""
|
|
55
|
+
mealy = hmm.to_mealy()
|
|
56
|
+
pi, joint = _emission_transition_tensors(mealy)
|
|
57
|
+
mass = _start_vector(mealy, pi, start)
|
|
58
|
+
if len(word) == 0:
|
|
59
|
+
return float(mass.sum())
|
|
60
|
+
n = len(mass)
|
|
61
|
+
zero = np.zeros((n, n), dtype=float)
|
|
62
|
+
for symbol in word:
|
|
63
|
+
matrix = joint.get(symbol, zero)
|
|
64
|
+
mass = mass @ matrix
|
|
65
|
+
if not np.any(np.abs(mass) > _TOL):
|
|
66
|
+
return 0.0
|
|
67
|
+
return float(mass.sum())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def hmm_log_word_probability(
|
|
71
|
+
hmm: HiddenMarkovModel,
|
|
72
|
+
word: Sequence[Any],
|
|
73
|
+
*,
|
|
74
|
+
start: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
|
|
75
|
+
) -> float:
|
|
76
|
+
"""Return ``log2(P(word))`` or ``-inf`` for forbidden words."""
|
|
77
|
+
probability = hmm_word_probability(hmm, word, start=start)
|
|
78
|
+
if probability <= 0.0:
|
|
79
|
+
return float("-inf")
|
|
80
|
+
return float(np.log2(probability))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def hmm_word_probabilities(
|
|
84
|
+
hmm: HiddenMarkovModel,
|
|
85
|
+
lengths: int | Sequence[int],
|
|
86
|
+
*,
|
|
87
|
+
start: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
|
|
88
|
+
sparse: bool = True,
|
|
89
|
+
) -> dict[tuple[Any, ...], float]:
|
|
90
|
+
"""Return probabilities for all observed words at the requested lengths."""
|
|
91
|
+
requested = (lengths,) if isinstance(lengths, int) else tuple(lengths)
|
|
92
|
+
if any(length < 0 for length in requested):
|
|
93
|
+
raise ValueError("lengths must be nonnegative")
|
|
94
|
+
|
|
95
|
+
mealy = hmm.to_mealy()
|
|
96
|
+
alphabet = sorted(mealy.observation_alphabet, key=repr)
|
|
97
|
+
distribution: dict[tuple[Any, ...], float] = {}
|
|
98
|
+
for length in requested:
|
|
99
|
+
if length == 0:
|
|
100
|
+
probability = hmm_word_probability(mealy, (), start=start)
|
|
101
|
+
if not sparse or abs(probability) > _TOL:
|
|
102
|
+
distribution[()] = probability
|
|
103
|
+
continue
|
|
104
|
+
for word in product(alphabet, repeat=length):
|
|
105
|
+
probability = hmm_word_probability(mealy, word, start=start)
|
|
106
|
+
if not sparse or abs(probability) > _TOL:
|
|
107
|
+
distribution[word] = probability
|
|
108
|
+
return distribution
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def hmm_conditional_word_probability(
|
|
112
|
+
hmm: HiddenMarkovModel,
|
|
113
|
+
word: Sequence[Any],
|
|
114
|
+
condition: Sequence[Any],
|
|
115
|
+
*,
|
|
116
|
+
start: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
|
|
117
|
+
) -> float:
|
|
118
|
+
"""Return ``P(word | condition)`` from the requested start distribution."""
|
|
119
|
+
condition_probability = hmm_word_probability(hmm, condition, start=start)
|
|
120
|
+
if condition_probability <= _TOL:
|
|
121
|
+
raise ZeroDivisionError("condition has zero probability")
|
|
122
|
+
joint_word = tuple(condition) + tuple(word)
|
|
123
|
+
return hmm_word_probability(hmm, joint_word, start=start) / condition_probability
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def pfa_words_of_length(pfa: ProbabilisticFiniteAutomaton, length: int) -> dict[tuple[Any, ...], float]:
|
|
127
|
+
"""Return output words of ``length`` and their probabilities."""
|
|
128
|
+
if length < 0:
|
|
129
|
+
raise ValueError("length must be nonnegative")
|
|
130
|
+
alphabet = sorted(pfa.output_alphabet, key=repr)
|
|
131
|
+
if length == 0:
|
|
132
|
+
probability = pfa.string_probability(())
|
|
133
|
+
return {(): probability} if probability > _TOL else {}
|
|
134
|
+
if not alphabet:
|
|
135
|
+
return {}
|
|
136
|
+
distribution: dict[tuple[Any, ...], float] = {}
|
|
137
|
+
for word in product(alphabet, repeat=length):
|
|
138
|
+
probability = pfa.string_probability(word)
|
|
139
|
+
if probability > _TOL:
|
|
140
|
+
distribution[word] = probability
|
|
141
|
+
return distribution
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def quasi_words_of_length(model: QuasiStochasticModel, length: int) -> dict[tuple[Any, ...], float]:
|
|
145
|
+
"""Return words of ``length`` and their signed quasiprobabilities."""
|
|
146
|
+
if length < 0:
|
|
147
|
+
raise ValueError("length must be nonnegative")
|
|
148
|
+
alphabet = _quasi_alphabet(model)
|
|
149
|
+
if length == 0:
|
|
150
|
+
probability = float(model.word_probability(()))
|
|
151
|
+
return {(): probability} if abs(probability) > _TOL else {}
|
|
152
|
+
if not alphabet:
|
|
153
|
+
return {}
|
|
154
|
+
distribution: dict[tuple[Any, ...], float] = {}
|
|
155
|
+
for word in product(sorted(alphabet, key=repr), repeat=length):
|
|
156
|
+
probability = float(model.word_probability(word))
|
|
157
|
+
if abs(probability) > _TOL:
|
|
158
|
+
distribution[word] = probability
|
|
159
|
+
return distribution
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def markov_words_of_length(chain: MarkovChain, length: int) -> dict[tuple[Hashable, ...], float]:
|
|
163
|
+
"""Return visible state paths of ``length`` and their probabilities."""
|
|
164
|
+
if length < 0:
|
|
165
|
+
raise ValueError("length must be nonnegative")
|
|
166
|
+
states = tuple(chain.states())
|
|
167
|
+
if length == 0:
|
|
168
|
+
return {(): 1.0}
|
|
169
|
+
distribution: dict[tuple[Hashable, ...], float] = {}
|
|
170
|
+
for word in product(states, repeat=length):
|
|
171
|
+
probability = _markov_path_probability(chain, word)
|
|
172
|
+
if probability > _TOL:
|
|
173
|
+
distribution[word] = probability
|
|
174
|
+
return distribution
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _quasi_alphabet(model: QuasiStochasticModel) -> tuple[Any, ...]:
|
|
178
|
+
for name in ("observation_alphabet", "output_alphabet"):
|
|
179
|
+
alphabet = getattr(model, name, None)
|
|
180
|
+
if alphabet:
|
|
181
|
+
return tuple(alphabet)
|
|
182
|
+
return tuple(model.transition_matrices())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _start_vector(
|
|
186
|
+
hmm: HiddenMarkovModel,
|
|
187
|
+
default: np.ndarray,
|
|
188
|
+
start: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None,
|
|
189
|
+
) -> np.ndarray:
|
|
190
|
+
if start is None:
|
|
191
|
+
return np.array(default, dtype=float)
|
|
192
|
+
|
|
193
|
+
idx = hmm.reindex()
|
|
194
|
+
n = len(idx)
|
|
195
|
+
if isinstance(start, Mapping):
|
|
196
|
+
vector = np.zeros(n, dtype=float)
|
|
197
|
+
for state, mass in start.items():
|
|
198
|
+
if not hmm.graph.has_state(state):
|
|
199
|
+
raise ValueError(f"unknown start state {state!r}")
|
|
200
|
+
vector[idx.index(state)] = float(mass)
|
|
201
|
+
return vector
|
|
202
|
+
|
|
203
|
+
if hmm.graph.has_state(start):
|
|
204
|
+
vector = np.zeros(n, dtype=float)
|
|
205
|
+
vector[idx.index(start)] = 1.0
|
|
206
|
+
return vector
|
|
207
|
+
|
|
208
|
+
vector = np.asarray(start, dtype=float)
|
|
209
|
+
if vector.shape != (n,):
|
|
210
|
+
raise ValueError(f"start vector must have shape {(n,)}, got {vector.shape}")
|
|
211
|
+
return vector.copy()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _markov_path_probability(chain: MarkovChain, path: tuple[Hashable, ...]) -> float:
|
|
215
|
+
if not path:
|
|
216
|
+
return 1.0
|
|
217
|
+
probability = float(chain.initial_distribution.get(path[0], 0.0))
|
|
218
|
+
for source, target in zip(path, path[1:], strict=False):
|
|
219
|
+
edge_probability = 0.0
|
|
220
|
+
for transition in chain.graph.out_transitions(source):
|
|
221
|
+
if transition.target == target:
|
|
222
|
+
edge_probability += float(transition.data.get(ATTR_PROB, 0.0))
|
|
223
|
+
probability *= edge_probability
|
|
224
|
+
if probability <= _TOL:
|
|
225
|
+
return 0.0
|
|
226
|
+
return probability
|