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,254 @@
1
+ """Posterior diversity diagnostics for Bayesian epsilon-machine model comparison.
2
+
3
+ Two complementary notions of posterior spread are tracked:
4
+
5
+ * **Machine diversity** — Shannon entropy of the topology weights returned by
6
+ :meth:`~sofic.inference.bayesian.comparison.ModelComparisonEM.model_probabilities`.
7
+ This measures uncertainty over *presentations* (topologies), not processes.
8
+
9
+ * **Process diversity** — weighted Jensen–Shannon divergence (JSD) over length-:math:`L`
10
+ word distributions, one per posterior component. When many high-weight machines
11
+ generate nearly the same stochastic process, process diversity can be much smaller
12
+ than machine diversity.
13
+
14
+ Word length conventions
15
+ -----------------------
16
+ For an :math:`n`-state presentation, classical HMM identification (Paz 1971;
17
+ Finesso 1991) uses the full length-:math:`(2n-1)` word distribution. Some
18
+ minimal-realization algorithms use a conservative window of :math:`2n+1`.
19
+ Upper's rank-growing history/future word lists (see
20
+ :func:`~sofic.generators.process_equivalence.is_equal_process`) provide a
21
+ data-driven alternative. All topologies in a comparison share the same :math:`L`,
22
+ chosen from the largest state count in the posterior.
23
+
24
+ JSD is computed in bits via ``dit``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Sequence
30
+ from dataclasses import dataclass
31
+ from itertools import product
32
+ from typing import TYPE_CHECKING, Any, Literal
33
+
34
+ import numpy as np
35
+
36
+ from sofic.generators.base import HiddenMarkovModel
37
+ from sofic.generators.process_equivalence import _HistoryFutureWordList
38
+ from sofic.generators.words import hmm_words_of_length
39
+ from sofic.inference.bayesian.counts import BayesianInferenceError
40
+ from sofic.inference.bayesian.epsilon import EpsilonMachinePosterior
41
+
42
+ if TYPE_CHECKING:
43
+ from sofic.inference.bayesian.comparison import ModelComparisonEM
44
+
45
+ _TOL = 1e-15
46
+ WordLengthConvention = Literal["paz", "conservative", "upper_list"]
47
+ ProcessDiversityMethod = Literal["posterior_mean", "monte_carlo"]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class PosteriorDiversityResult:
52
+ """Posterior machine and process diversity diagnostics."""
53
+
54
+ process_diversity: float
55
+ machine_diversity: float
56
+ word_length: int
57
+ method: ProcessDiversityMethod
58
+ n_components: int
59
+
60
+
61
+ def _require_dit():
62
+ try:
63
+ from dit.divergences.jensen_shannon_divergence import jensen_shannon_divergence_pmf
64
+ except ImportError as exc:
65
+ raise ImportError("dit is required for posterior process diversity; install with `pip install dit`") from exc
66
+ return jensen_shannon_divergence_pmf
67
+
68
+
69
+ def _comparison_alphabet(comparison: ModelComparisonEM) -> tuple[Any, ...]:
70
+ alphabets: set[Any] = set()
71
+ for posterior in comparison.em_dict.values():
72
+ alphabets.update(posterior.machine.observation_alphabet)
73
+ if not alphabets:
74
+ raise BayesianInferenceError("posterior has no viable machines")
75
+ return tuple(sorted(alphabets, key=repr))
76
+
77
+
78
+ def _max_state_count(comparison: ModelComparisonEM) -> int:
79
+ if not comparison.em_dict:
80
+ raise BayesianInferenceError("posterior has no viable machines")
81
+ return max(len(posterior.dirichlet.nodes) for posterior in comparison.em_dict.values())
82
+
83
+
84
+ def _upper_list_word_length(machine: HiddenMarkovModel) -> int:
85
+ hf = _HistoryFutureWordList.from_hmm(machine)
86
+ future_words = hf.future_word_list()
87
+ history_words = hf.history_word_list()
88
+ candidates = [len(word) for word in (*future_words, *history_words)]
89
+ return max(candidates) if candidates else 0
90
+
91
+
92
+ def process_identification_word_length(
93
+ comparison: ModelComparisonEM,
94
+ *,
95
+ convention: WordLengthConvention | str = "paz",
96
+ word_length: int | None = None,
97
+ ) -> int:
98
+ """Return the word length used to compare processes in a model comparison."""
99
+ if word_length is not None:
100
+ length = int(word_length)
101
+ if length < 0:
102
+ raise BayesianInferenceError("word_length must be nonnegative")
103
+ return length
104
+
105
+ n_max = _max_state_count(comparison)
106
+ if convention == "paz":
107
+ return max(0, 2 * n_max - 1)
108
+ if convention == "conservative":
109
+ return 2 * n_max + 1
110
+ if convention == "upper_list":
111
+ lengths = []
112
+ for posterior in comparison.em_dict.values():
113
+ start_probs = posterior.start_node_probabilities()
114
+ if not start_probs:
115
+ continue
116
+ start_node = max(start_probs, key=start_probs.get)
117
+ mean_machine = posterior.posterior_mean_machine(start_node)
118
+ if mean_machine is not None:
119
+ lengths.append(_upper_list_word_length(mean_machine))
120
+ if not lengths:
121
+ return max(0, 2 * n_max - 1)
122
+ return max(lengths)
123
+ raise BayesianInferenceError(f"unknown word-length convention: {convention!r}")
124
+
125
+
126
+ def word_distribution_to_pmf(
127
+ distribution: dict[tuple[Any, ...], float],
128
+ alphabet: Sequence[Any],
129
+ length: int,
130
+ ) -> np.ndarray:
131
+ """Align a sparse word distribution to a dense PMF over ``alphabet**length``."""
132
+ symbols = tuple(alphabet)
133
+ if length == 0:
134
+ total = sum(float(prob) for prob in distribution.values())
135
+ return np.array([total if total > _TOL else 0.0], dtype=float)
136
+
137
+ outcomes = list(product(symbols, repeat=length))
138
+ pmf = np.zeros(len(outcomes), dtype=float)
139
+ for index, word in enumerate(outcomes):
140
+ prob = distribution.get(word, 0.0)
141
+ if prob > _TOL:
142
+ pmf[index] = float(prob)
143
+
144
+ total = float(pmf.sum())
145
+ if total > _TOL and abs(total - 1.0) > _TOL:
146
+ pmf /= total
147
+ return pmf
148
+
149
+
150
+ def machine_diversity(comparison: ModelComparisonEM) -> float:
151
+ """Shannon entropy (bits) of the topology posterior weights."""
152
+ probs = np.array(list(comparison.model_probabilities().values()), dtype=float)
153
+ if probs.size == 0:
154
+ raise BayesianInferenceError("posterior has no viable machines")
155
+ positive = probs[probs > 0.0]
156
+ if positive.size <= 1:
157
+ return 0.0
158
+ return float(-np.sum(positive * np.log2(positive)))
159
+
160
+
161
+ def posterior_mean_word_distribution(
162
+ posterior: EpsilonMachinePosterior,
163
+ length: int,
164
+ ) -> dict[tuple[Any, ...], float]:
165
+ """Start-marginalized word distribution from posterior-mean transition probabilities."""
166
+ if length < 0:
167
+ raise BayesianInferenceError("length must be nonnegative")
168
+
169
+ start_probs = posterior.start_node_probabilities()
170
+ if not start_probs:
171
+ return {}
172
+
173
+ distribution: dict[tuple[Any, ...], float] = {}
174
+ for start_node, start_weight in start_probs.items():
175
+ if start_weight <= _TOL:
176
+ continue
177
+ machine = posterior.posterior_mean_machine(start_node)
178
+ if machine is None:
179
+ continue
180
+ words = hmm_words_of_length(machine, length)
181
+ for word, prob in words.items():
182
+ distribution[word] = distribution.get(word, 0.0) + start_weight * float(prob)
183
+ return distribution
184
+
185
+
186
+ def _jsd_from_word_distributions(
187
+ distributions: Sequence[dict[tuple[Any, ...], float]],
188
+ weights: Sequence[float],
189
+ alphabet: Sequence[Any],
190
+ length: int,
191
+ ) -> float:
192
+ jensen_shannon_divergence_pmf = _require_dit()
193
+ if not distributions:
194
+ raise BayesianInferenceError("no word distributions supplied")
195
+ if len(distributions) != len(weights):
196
+ raise BayesianInferenceError("number of weights must match number of distributions")
197
+
198
+ pmfs = np.vstack([word_distribution_to_pmf(dist, alphabet, length) for dist in distributions])
199
+ weight_array = np.asarray(weights, dtype=float)
200
+ if weight_array.sum() <= _TOL:
201
+ raise BayesianInferenceError("posterior weights must sum to a positive value")
202
+ return float(jensen_shannon_divergence_pmf(pmfs, weight_array))
203
+
204
+
205
+ def posterior_process_diversity(
206
+ comparison: ModelComparisonEM,
207
+ *,
208
+ method: ProcessDiversityMethod = "posterior_mean",
209
+ n_samples: int = 500,
210
+ rng: np.random.Generator | None = None,
211
+ convention: WordLengthConvention | str = "paz",
212
+ word_length: int | None = None,
213
+ ) -> PosteriorDiversityResult:
214
+ """Compute weighted JSD over posterior word distributions."""
215
+ if not comparison.em_dict:
216
+ raise BayesianInferenceError("posterior has no viable machines")
217
+
218
+ length = process_identification_word_length(comparison, convention=convention, word_length=word_length)
219
+ machine_div = machine_diversity(comparison)
220
+ alphabet = _comparison_alphabet(comparison)
221
+
222
+ if method == "posterior_mean":
223
+ model_probs = comparison.model_probabilities()
224
+ names = list(model_probs)
225
+ distributions = [posterior_mean_word_distribution(comparison.em_dict[name], length) for name in names]
226
+ weights = [model_probs[name] for name in names]
227
+ process_div = _jsd_from_word_distributions(distributions, weights, alphabet, length)
228
+ return PosteriorDiversityResult(
229
+ process_diversity=process_div,
230
+ machine_diversity=machine_div,
231
+ word_length=length,
232
+ method="posterior_mean",
233
+ n_components=len(names),
234
+ )
235
+
236
+ if method == "monte_carlo":
237
+ if n_samples <= 0:
238
+ raise BayesianInferenceError("n_samples must be positive")
239
+ generator = rng if rng is not None else np.random.default_rng()
240
+ distributions = []
241
+ for _ in range(n_samples):
242
+ _start, machine = comparison.generate_sample(rng=generator)
243
+ distributions.append(hmm_words_of_length(machine, length))
244
+ weights = [1.0 / n_samples] * n_samples
245
+ process_div = _jsd_from_word_distributions(distributions, weights, alphabet, length)
246
+ return PosteriorDiversityResult(
247
+ process_diversity=process_div,
248
+ machine_diversity=machine_div,
249
+ word_length=length,
250
+ method="monte_carlo",
251
+ n_components=n_samples,
252
+ )
253
+
254
+ raise BayesianInferenceError(f"unknown process diversity method: {method!r}")
@@ -0,0 +1,270 @@
1
+ """Conjugate Bayesian inference for fixed unifilar generator topologies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Mapping, Sequence
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ from scipy.special import logsumexp
10
+
11
+ from sofic.generators.epsilon_machine import EpsilonMachine
12
+ from sofic.generators.mealy import MealyHMM
13
+ from sofic.graph import ATTR_EMISSION, ATTR_PROB
14
+ from sofic.inference.bayesian.counts import (
15
+ BayesianInferenceError,
16
+ PathCountEM,
17
+ dirichlet_multinomial_log_evidence,
18
+ scan_unifilar_topology,
19
+ )
20
+
21
+
22
+ class DirichletDistributionEM:
23
+ """Product-of-Dirichlets prior/posterior for a fixed unifilar topology."""
24
+
25
+ def __init__(self, machine: MealyHMM, data: Sequence[Any] | None = None, state_path: bool = False):
26
+ self.machine = machine
27
+ self.alphas: dict[Hashable | tuple[Hashable, Any], float] = {}
28
+ self.edges: list[tuple[Hashable, Any]] = []
29
+ self.valid_edges: list[tuple[Hashable, Any]] = []
30
+ self.nodes: list[Hashable] = list(machine.states())
31
+ self.trace: dict[tuple[Hashable, Any], Hashable] = {}
32
+ self.data: PathCountEM | None = None
33
+ self.deterministic = False
34
+ self._process_machine_topology()
35
+ self.valid_startnodes = list(self.nodes)
36
+ self._generate_uniform_alphas()
37
+ if data is not None:
38
+ self.data = PathCountEM(machine, data, state_path=state_path)
39
+ self.valid_startnodes = self.data.get_possible_start_nodes()
40
+
41
+ def _process_machine_topology(self) -> None:
42
+ self.trace, self.edges = scan_unifilar_topology(self.machine)
43
+ outgoing: dict[Hashable, list[tuple[Hashable, Any]]] = {}
44
+ for edge in self.edges:
45
+ outgoing.setdefault(edge[0], []).append(edge)
46
+ for _source, edges in outgoing.items():
47
+ if len(edges) > 1:
48
+ self.valid_edges.extend(edges)
49
+ else:
50
+ edge = edges[0]
51
+ prob = self._topology_probability(edge)
52
+ if not np.isclose(prob, 1.0):
53
+ self.valid_edges.append(edge)
54
+ self.valid_edges.sort(key=repr)
55
+ self.deterministic = not self.valid_edges
56
+
57
+ def _topology_probability(self, edge: tuple[Hashable, Any]) -> float:
58
+ source, symbol = edge
59
+ for transition in self.machine.graph.out_transitions(source):
60
+ if transition.data.get(ATTR_EMISSION) == symbol:
61
+ return float(transition.data.get(ATTR_PROB, 0.0))
62
+ return 0.0
63
+
64
+ def _generate_uniform_alphas(self) -> None:
65
+ self.alphas.clear()
66
+ for edge in self.valid_edges:
67
+ source, _symbol = edge
68
+ self.alphas[edge] = 1.0
69
+ self.alphas[source] = self.alphas.get(source, 0.0) + 1.0
70
+
71
+ def get_edges(self) -> list[tuple[Hashable, Any]]:
72
+ return list(self.edges)
73
+
74
+ def get_nodes(self) -> list[Hashable]:
75
+ return list(self.nodes)
76
+
77
+ def get_possible_start_nodes(self) -> list[Hashable]:
78
+ return list(self.valid_startnodes)
79
+
80
+ def get_edge_alpha(self, start_node: Hashable, edge: tuple[Hashable, Any]) -> float | None:
81
+ del start_node
82
+ return self.alphas.get(edge)
83
+
84
+ def get_node_alpha(self, start_node: Hashable, node: Hashable) -> float | None:
85
+ del start_node
86
+ return self.alphas.get(node)
87
+
88
+ def get_edge_count(self, start_node: Hashable, edge: tuple[Hashable, Any]) -> int | None:
89
+ return None if self.data is None else self.data.get_edge_count(start_node, edge)
90
+
91
+ def get_node_count(self, start_node: Hashable, node: Hashable) -> int | None:
92
+ return None if self.data is None else self.data.get_node_count(start_node, node)
93
+
94
+ def get_last_node(self, start_node: Hashable) -> Hashable | None:
95
+ return None if self.data is None else self.data.get_last_node(start_node)
96
+
97
+ def get_state_path(self, start_node: Hashable) -> tuple[Hashable, ...]:
98
+ return () if self.data is None else self.data.get_state_path(start_node)
99
+
100
+ def log_evidence_start_node(self, start_node: Hashable) -> float:
101
+ if start_node not in self.valid_startnodes:
102
+ return -np.inf
103
+ rows: dict[Hashable, list[tuple[float, float]]] = {}
104
+ for edge in self.valid_edges:
105
+ source, _symbol = edge
106
+ alpha = self.get_edge_alpha(start_node, edge)
107
+ alpha_row = self.get_node_alpha(start_node, source)
108
+ if alpha is None or alpha_row is None:
109
+ raise BayesianInferenceError("missing Dirichlet alpha")
110
+ count = self.get_edge_count(start_node, edge) or 0
111
+ rows.setdefault(source, []).append((alpha, count))
112
+ evidence = 0.0
113
+ for source, cells in rows.items():
114
+ alpha_row = self.get_node_alpha(start_node, source)
115
+ count_row = self.get_node_count(start_node, source) or 0
116
+ evidence += dirichlet_multinomial_log_evidence(alpha_row, count_row, cells)
117
+ return float(evidence)
118
+
119
+ def mean_edge_probability(self, start_node: Hashable, edge: tuple[Hashable, Any]) -> float | None:
120
+ if start_node not in self.valid_startnodes:
121
+ return None
122
+ if edge in self.edges and edge not in self.valid_edges:
123
+ return 1.0
124
+ count = self.get_edge_count(start_node, edge) or 0
125
+ row_count = self.get_node_count(start_node, edge[0]) or 0
126
+ alpha = self.get_edge_alpha(start_node, edge)
127
+ row_alpha = self.get_node_alpha(start_node, edge[0])
128
+ if alpha is None or row_alpha is None:
129
+ return None
130
+ return float((count + alpha) / (row_count + row_alpha))
131
+
132
+ def set_edge_alpha(self, edge: tuple[Hashable, Any], value: float) -> None:
133
+ if edge not in self.valid_edges:
134
+ raise BayesianInferenceError("cannot set alpha for deterministic edge")
135
+ self.alphas[edge] = float(value)
136
+ for node in self.nodes:
137
+ self.alphas[node] = 0.0
138
+ for valid_edge in self.valid_edges:
139
+ self.alphas[valid_edge[0]] += self.alphas[valid_edge]
140
+
141
+ def _machine_from_probabilities(
142
+ self, start_node: Hashable, probabilities: Mapping[tuple[Hashable, Any], float], name: str
143
+ ) -> MealyHMM:
144
+ machine = MealyHMM(observation_alphabet=getattr(self.machine, "observation_alphabet", frozenset()))
145
+ machine.name = name
146
+ initial_state = self.get_last_node(start_node) if self.data is not None else start_node
147
+ machine.initial_distribution = {initial_state: 1.0} if initial_state is not None else {}
148
+ for node in self.nodes:
149
+ machine.graph.add_state(node)
150
+ for edge in self.edges:
151
+ source, symbol = edge
152
+ prob = probabilities[edge]
153
+ machine.graph.add_transition(source, self.trace[edge], **{ATTR_EMISSION: symbol, ATTR_PROB: prob})
154
+ machine.validate()
155
+ if machine.is_unifilar():
156
+ eps = EpsilonMachine.from_networkx(
157
+ machine.to_networkx(),
158
+ initial_distribution=machine.initial_distribution,
159
+ observation_alphabet=machine.observation_alphabet,
160
+ )
161
+ eps.name = name
162
+ eps.validate()
163
+ return eps
164
+ return machine
165
+
166
+ def posterior_mean_machine(self, start_node: Hashable) -> MealyHMM | None:
167
+ if start_node not in self.valid_startnodes:
168
+ return None
169
+ probabilities = {}
170
+ for edge in self.edges:
171
+ prob = self.mean_edge_probability(start_node, edge)
172
+ if prob is None:
173
+ raise BayesianInferenceError(f"missing probability for edge {edge!r}")
174
+ probabilities[edge] = prob
175
+ return self._machine_from_probabilities(
176
+ start_node, probabilities, f"Posterior Mean Machine, Start Node: {start_node}"
177
+ )
178
+
179
+ def generate_sample(self, start_node: Hashable, rng: np.random.Generator | None = None) -> MealyHMM | None:
180
+ if start_node not in self.valid_startnodes:
181
+ return None
182
+ generator = rng if rng is not None else np.random.default_rng()
183
+ probabilities: dict[tuple[Hashable, Any], float] = {}
184
+ grouped: dict[Hashable, list[tuple[Hashable, Any]]] = {}
185
+ for edge in self.edges:
186
+ if edge in self.valid_edges:
187
+ grouped.setdefault(edge[0], []).append(edge)
188
+ else:
189
+ probabilities[edge] = 1.0
190
+ for _source, edges in grouped.items():
191
+ alpha = []
192
+ for edge in edges:
193
+ a = self.get_edge_alpha(start_node, edge)
194
+ if a is None:
195
+ raise BayesianInferenceError("missing Dirichlet alpha")
196
+ alpha.append(a + (self.get_edge_count(start_node, edge) or 0))
197
+ sample = generator.dirichlet(np.asarray(alpha, dtype=float))
198
+ for edge, prob in zip(edges, sample, strict=True):
199
+ probabilities[edge] = float(prob)
200
+ return self._machine_from_probabilities(start_node, probabilities, f"Sampled Machine, Start Node: {start_node}")
201
+
202
+
203
+ class EpsilonMachinePosterior:
204
+ """Posterior over parameters and unknown start state for a topology."""
205
+
206
+ def __init__(
207
+ self,
208
+ machine: MealyHMM,
209
+ data: Sequence[Any] | None = None,
210
+ start_dist: Mapping[Hashable, float] | None = None,
211
+ state_path: bool = False,
212
+ ):
213
+ self.machine = machine
214
+ self.dirichlet = DirichletDistributionEM(machine, data, state_path=state_path)
215
+ if start_dist is None:
216
+ nodes = self.dirichlet.get_nodes()
217
+ self.start_dist = {node: 1.0 / len(nodes) for node in nodes} if nodes else {}
218
+ else:
219
+ self.start_dist = dict(start_dist)
220
+
221
+ def log_evidence(self) -> float:
222
+ terms = []
223
+ for node in self.dirichlet.get_possible_start_nodes():
224
+ prior = self.start_dist.get(node, 0.0)
225
+ if prior > 0:
226
+ terms.append(np.log(prior) + self.dirichlet.log_evidence_start_node(node))
227
+ return float(logsumexp(terms)) if terms else -np.inf
228
+
229
+ def probability_start_node(self, start_node: Hashable) -> float:
230
+ if start_node not in self.dirichlet.get_possible_start_nodes():
231
+ return 0.0
232
+ log_norm = self.log_evidence()
233
+ prior = self.start_dist.get(start_node, 0.0)
234
+ if prior <= 0:
235
+ return 0.0
236
+ return float(np.exp(np.log(prior) + self.dirichlet.log_evidence_start_node(start_node) - log_norm))
237
+
238
+ def start_node_probabilities(self) -> dict[Hashable, float]:
239
+ return {node: self.probability_start_node(node) for node in self.dirichlet.get_possible_start_nodes()}
240
+
241
+ def sample_start_node(self, rng: np.random.Generator | None = None) -> Hashable:
242
+ generator = rng if rng is not None else np.random.default_rng()
243
+ probs = self.start_node_probabilities()
244
+ nodes = tuple(probs)
245
+ weights = np.array([probs[node] for node in nodes], dtype=float)
246
+ return nodes[int(generator.choice(len(nodes), p=weights))]
247
+
248
+ def generate_sample(self, rng: np.random.Generator | None = None) -> tuple[Hashable, MealyHMM]:
249
+ generator = rng if rng is not None else np.random.default_rng()
250
+ start = self.sample_start_node(generator)
251
+ machine = self.dirichlet.generate_sample(start, rng=generator)
252
+ if machine is None:
253
+ raise BayesianInferenceError("sampled impossible start node")
254
+ return start, machine
255
+
256
+ def posterior_mean_machine(self, start_node: Hashable | None = None) -> MealyHMM | None:
257
+ if start_node is None:
258
+ probs = self.start_node_probabilities()
259
+ if not probs:
260
+ return None
261
+ start_node = max(probs, key=probs.get)
262
+ return self.dirichlet.posterior_mean_machine(start_node)
263
+
264
+ def as_pymc_model(self, start_node: Hashable | None = None) -> Any:
265
+ from sofic.inference.bayesian.pymc_backend import epsilon_machine_model
266
+
267
+ return epsilon_machine_model(self, start_node=start_node)
268
+
269
+
270
+ InferEM = EpsilonMachinePosterior