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