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,340 @@
1
+ """Bayesian nonparametric HMM inference: the (sticky) HDP-HMM.
2
+
3
+ The hierarchical Dirichlet process HMM :cite:`Teh2006` and its sticky variant
4
+ :cite:`Fox2011` place a nonparametric prior over the number of hidden states, so
5
+ the state count is *inferred* rather than fixed. This module implements the
6
+ **weak-limit blocked Gibbs sampler** :cite:`Fox2011`: the countably-infinite HDP
7
+ prior is approximated by a symmetric Dirichlet over ``max_states`` components,
8
+ and each sweep
9
+
10
+ 1. samples the whole hidden-state sequence with forward-filter/backward-sample
11
+ (FFBS) given the current parameters,
12
+ 2. draws conjugate Dirichlet transition, initial, and (categorical /
13
+ Dirichlet-multinomial) emission rows given the state sequence, and
14
+ 3. updates the shared top-level weights ``beta`` from Antoniak table counts,
15
+ with the sticky self-transition override of :cite:`Fox2011`.
16
+
17
+ Emissions are **categorical (discrete)** throughout -- the Gaussian-emission
18
+ variant is deliberately excluded. Retained posterior samples are returned as
19
+ :class:`~sofic.generators.moore.MooreHMM` generators restricted to the states
20
+ occupied in that sweep, together with a posterior over the number of occupied
21
+ states.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Hashable, Sequence
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+
32
+ from sofic.generators.moore import MooreHMM
33
+ from sofic.inference.bayesian.counts import BayesianInferenceError
34
+
35
+ __all__ = ["HDPHMMPosterior", "infer_hdp_hmm"]
36
+
37
+
38
+ @dataclass
39
+ class HDPHMMPosterior:
40
+ """Posterior samples from the weak-limit (sticky) HDP-HMM sampler.
41
+
42
+ Attributes
43
+ ----------
44
+ samples
45
+ Retained posterior draws as :class:`~sofic.generators.moore.MooreHMM`
46
+ generators, each restricted to the states occupied in its sweep.
47
+ state_counts
48
+ Number of occupied states in each retained draw (``len == len(samples)``).
49
+ log_likelihoods
50
+ Data log-likelihood (natural log) of each retained draw.
51
+ alphabet
52
+ Sorted observation alphabet used by the sampler.
53
+ """
54
+
55
+ samples: list[MooreHMM] = field(default_factory=list)
56
+ state_counts: list[int] = field(default_factory=list)
57
+ log_likelihoods: list[float] = field(default_factory=list)
58
+ alphabet: tuple[Any, ...] = ()
59
+
60
+ def state_count_posterior(self) -> dict[int, float]:
61
+ """Return the posterior distribution over the number of occupied states."""
62
+ if not self.state_counts:
63
+ return {}
64
+ counts = np.asarray(self.state_counts)
65
+ values, freqs = np.unique(counts, return_counts=True)
66
+ total = float(freqs.sum())
67
+ return {int(value): float(freq) / total for value, freq in zip(values, freqs, strict=True)}
68
+
69
+ def map_state_count(self) -> int:
70
+ """Return the posterior modal number of occupied states."""
71
+ posterior = self.state_count_posterior()
72
+ if not posterior:
73
+ raise BayesianInferenceError("no retained samples")
74
+ return max(posterior, key=posterior.get)
75
+
76
+ def best_sample(self) -> MooreHMM:
77
+ """Return the retained draw with the highest data log-likelihood."""
78
+ if not self.samples:
79
+ raise BayesianInferenceError("no retained samples")
80
+ return self.samples[int(np.argmax(self.log_likelihoods))]
81
+
82
+
83
+ def _normalize_sequences(sequences: Sequence[Any]) -> list[list[Any]]:
84
+ if sequences is None:
85
+ raise BayesianInferenceError("sequences is required")
86
+ if isinstance(sequences, (str, bytes)):
87
+ raise BayesianInferenceError("pass a sequence of observations, not a string")
88
+ first = next(iter(sequences), None)
89
+ if first is None:
90
+ raise BayesianInferenceError("at least one non-empty sequence is required")
91
+ if not isinstance(first, (list, tuple)):
92
+ sequences = [sequences] # a single flat observation sequence
93
+ out = [list(seq) for seq in sequences if len(seq) > 0]
94
+ if not out:
95
+ raise BayesianInferenceError("at least one non-empty sequence is required")
96
+ return out
97
+
98
+
99
+ def _collect_alphabet(sequences: Sequence[Sequence[Any]]) -> tuple[Any, ...]:
100
+ symbols: set[Any] = set()
101
+ for seq in sequences:
102
+ symbols.update(seq)
103
+ return tuple(sorted(symbols, key=repr))
104
+
105
+
106
+ def _ffbs(
107
+ obs_idx: np.ndarray,
108
+ log_pi0: np.ndarray,
109
+ trans: np.ndarray,
110
+ emit: np.ndarray,
111
+ rng: np.random.Generator,
112
+ ) -> tuple[np.ndarray, float]:
113
+ """Forward-filter backward-sample one sequence; return states and log-likelihood."""
114
+ n_states = trans.shape[0]
115
+ length = obs_idx.shape[0]
116
+ alpha = np.empty((length, n_states))
117
+ loglik = 0.0
118
+
119
+ weights = np.exp(log_pi0) * emit[:, obs_idx[0]]
120
+ scale = weights.sum()
121
+ if scale <= 0.0:
122
+ weights = emit[:, obs_idx[0]].copy()
123
+ scale = weights.sum()
124
+ alpha[0] = weights / scale
125
+ loglik += np.log(scale)
126
+
127
+ for t in range(1, length):
128
+ predicted = alpha[t - 1] @ trans
129
+ weights = predicted * emit[:, obs_idx[t]]
130
+ scale = weights.sum()
131
+ if scale <= 0.0:
132
+ weights = emit[:, obs_idx[t]].copy()
133
+ scale = weights.sum()
134
+ alpha[t] = weights / scale
135
+ loglik += np.log(scale)
136
+
137
+ states = np.empty(length, dtype=int)
138
+ states[length - 1] = rng.choice(n_states, p=alpha[length - 1])
139
+ for t in range(length - 2, -1, -1):
140
+ probs = alpha[t] * trans[:, states[t + 1]]
141
+ total = probs.sum()
142
+ probs = probs / total if total > 0 else np.full(n_states, 1.0 / n_states)
143
+ states[t] = rng.choice(n_states, p=probs)
144
+ return states, float(loglik)
145
+
146
+
147
+ def _sample_dirichlet_rows(alpha_rows: np.ndarray, rng: np.random.Generator) -> np.ndarray:
148
+ """Draw one Dirichlet vector per row (rows with zero mass fall back to uniform)."""
149
+ out = np.empty_like(alpha_rows, dtype=float)
150
+ for i, row in enumerate(alpha_rows):
151
+ if row.sum() <= 0:
152
+ out[i] = np.full(row.shape[0], 1.0 / row.shape[0])
153
+ else:
154
+ out[i] = rng.dirichlet(row)
155
+ return out
156
+
157
+
158
+ def _antoniak_tables(customers: int, mass: float, rng: np.random.Generator) -> int:
159
+ """Sample the number of occupied CRP tables for ``customers`` at concentration ``mass``."""
160
+ if customers <= 0 or mass <= 0.0:
161
+ return 0
162
+ indices = np.arange(customers)
163
+ probs = mass / (mass + indices)
164
+ return int((rng.random(customers) < probs).sum())
165
+
166
+
167
+ def infer_hdp_hmm(
168
+ sequences: Sequence[Any],
169
+ *,
170
+ max_states: int = 20,
171
+ alpha: float = 1.0,
172
+ gamma: float = 1.0,
173
+ kappa: float = 0.0,
174
+ eta: float = 1.0,
175
+ iterations: int = 300,
176
+ burn_in: int = 150,
177
+ thin: int = 5,
178
+ rng: np.random.Generator | int | None = None,
179
+ ) -> HDPHMMPosterior:
180
+ """Infer a (sticky) HDP-HMM from discrete sequences by weak-limit blocked Gibbs.
181
+
182
+ Parameters
183
+ ----------
184
+ sequences
185
+ Either a single observation sequence (list/tuple of symbols) or a
186
+ collection of such sequences.
187
+ max_states
188
+ Weak-limit truncation ``L`` -- an upper bound on the number of states.
189
+ The *occupied* count is inferred and is typically far smaller.
190
+ alpha
191
+ Second-level (per-state transition) DP concentration.
192
+ gamma
193
+ Top-level DP concentration governing the shared weights ``beta``.
194
+ kappa
195
+ Sticky self-transition mass :cite:`Fox2011`. ``0`` recovers the plain
196
+ HDP-HMM; positive values bias toward state persistence.
197
+ eta
198
+ Symmetric Dirichlet concentration of the categorical emission prior.
199
+ iterations, burn_in, thin
200
+ Total Gibbs sweeps, discarded warm-up sweeps, and retention stride.
201
+ rng
202
+ ``numpy`` generator or seed.
203
+
204
+ Returns
205
+ -------
206
+ HDPHMMPosterior
207
+ Retained :class:`~sofic.generators.moore.MooreHMM` draws and a posterior
208
+ over the number of occupied states.
209
+ """
210
+ generator = rng if isinstance(rng, np.random.Generator) else np.random.default_rng(rng)
211
+
212
+ seqs = _normalize_sequences(sequences)
213
+ alphabet = _collect_alphabet(seqs)
214
+ if not alphabet:
215
+ raise BayesianInferenceError("sequences contain no observations")
216
+ if max_states < 1:
217
+ raise BayesianInferenceError("max_states must be >= 1")
218
+ for name, value in (("alpha", alpha), ("gamma", gamma), ("eta", eta)):
219
+ if value <= 0.0:
220
+ raise BayesianInferenceError(f"{name} must be positive")
221
+ if kappa < 0.0:
222
+ raise BayesianInferenceError("kappa must be non-negative")
223
+ if burn_in >= iterations:
224
+ raise BayesianInferenceError("burn_in must be smaller than iterations")
225
+
226
+ symbol_index = {symbol: i for i, symbol in enumerate(alphabet)}
227
+ obs = [np.array([symbol_index[o] for o in seq], dtype=int) for seq in seqs]
228
+ n_obs = len(alphabet)
229
+ length = max_states
230
+
231
+ # Warm start: label each observation by its symbol so state k initially emits
232
+ # symbol k. Random (near-uniform) initialization mixes very poorly for
233
+ # discrete emissions -- the symmetric prior leaves the state labels
234
+ # unidentified for many sweeps -- whereas a symbol-based labeling immediately
235
+ # breaks that symmetry with near-deterministic emissions.
236
+ beta = np.full(length, 1.0 / length)
237
+ rho = kappa / (alpha + kappa) if (alpha + kappa) > 0 else 0.0
238
+ init_trans = np.zeros((length, length))
239
+ init_emit = np.zeros((length, n_obs))
240
+ init_first = np.zeros(length)
241
+ for obs_idx in obs:
242
+ warm = obs_idx % length
243
+ init_first[warm[0]] += 1
244
+ for o_i, s in zip(obs_idx, warm, strict=True):
245
+ init_emit[s, o_i] += 1
246
+ for a, b in zip(warm[:-1], warm[1:], strict=True):
247
+ init_trans[a, b] += 1
248
+ trans = _sample_dirichlet_rows(alpha * beta[None, :] + kappa * np.eye(length) + init_trans, generator)
249
+ emit = _sample_dirichlet_rows(eta + init_emit, generator)
250
+ log_pi0 = np.log(np.clip(_sample_dirichlet_rows((alpha * beta + init_first)[None, :], generator)[0], 1e-300, None))
251
+
252
+ posterior = HDPHMMPosterior(alphabet=alphabet)
253
+
254
+ for sweep in range(iterations):
255
+ trans_counts = np.zeros((length, length))
256
+ init_counts = np.zeros(length)
257
+ emit_counts = np.zeros((length, n_obs))
258
+ sweep_states: list[np.ndarray] = []
259
+ total_loglik = 0.0
260
+
261
+ for obs_idx in obs:
262
+ states, loglik = _ffbs(obs_idx, log_pi0, trans, emit, generator)
263
+ sweep_states.append(states)
264
+ total_loglik += loglik
265
+ init_counts[states[0]] += 1
266
+ for o_i, s in zip(obs_idx, states, strict=True):
267
+ emit_counts[s, o_i] += 1
268
+ for a, b in zip(states[:-1], states[1:], strict=True):
269
+ trans_counts[a, b] += 1
270
+
271
+ sticky = kappa * np.eye(length)
272
+ trans = _sample_dirichlet_rows(alpha * beta[None, :] + sticky + trans_counts, generator)
273
+ emit = _sample_dirichlet_rows(eta + emit_counts, generator)
274
+ pi0 = _sample_dirichlet_rows((alpha * beta + init_counts)[None, :], generator)[0]
275
+ log_pi0 = np.log(np.clip(pi0, 1e-300, None))
276
+
277
+ # Antoniak table counts -> top-level weights beta (with sticky override).
278
+ bar_m = np.zeros(length)
279
+ rows = list(trans_counts) + [init_counts]
280
+ for j, row in enumerate(rows):
281
+ for k in range(length):
282
+ mass = alpha * beta[k] + (kappa if j == k else 0.0)
283
+ tables = _antoniak_tables(int(row[k]), mass, generator)
284
+ if j == k and kappa > 0.0 and tables > 0:
285
+ denom = rho + beta[k] * (1.0 - rho)
286
+ override_p = rho / denom if denom > 0 else 0.0
287
+ overrides = int(generator.binomial(tables, min(max(override_p, 0.0), 1.0)))
288
+ tables -= overrides
289
+ bar_m[k] += tables
290
+ beta = generator.dirichlet(gamma / length + bar_m)
291
+
292
+ if sweep >= burn_in and (sweep - burn_in) % thin == 0:
293
+ machine = _build_moore(sweep_states, trans, emit, pi0, alphabet)
294
+ posterior.samples.append(machine)
295
+ posterior.state_counts.append(len(list(machine.states())))
296
+ posterior.log_likelihoods.append(total_loglik)
297
+
298
+ if not posterior.samples:
299
+ raise BayesianInferenceError("no samples retained; increase iterations or lower burn_in/thin")
300
+ return posterior
301
+
302
+
303
+ def _build_moore(
304
+ sweep_states: Sequence[np.ndarray],
305
+ trans: np.ndarray,
306
+ emit: np.ndarray,
307
+ pi0: np.ndarray,
308
+ alphabet: Sequence[Any],
309
+ ) -> MooreHMM:
310
+ """Restrict the sampled parameters to occupied states and build a MooreHMM."""
311
+ occupied = sorted({int(s) for states in sweep_states for s in states})
312
+ index = {state: position for position, state in enumerate(occupied)}
313
+ names: dict[int, Hashable] = {state: f"q{position}" for position, state in enumerate(occupied)}
314
+
315
+ sub_trans = trans[np.ix_(occupied, occupied)]
316
+ row_sums = sub_trans.sum(axis=1, keepdims=True)
317
+ row_sums[row_sums == 0] = 1.0
318
+ sub_trans = sub_trans / row_sums
319
+
320
+ init = pi0[occupied]
321
+ init_total = init.sum()
322
+ init = init / init_total if init_total > 0 else np.full(len(occupied), 1.0 / len(occupied))
323
+
324
+ machine = MooreHMM(
325
+ observation_alphabet=frozenset(alphabet),
326
+ initial_distribution={names[state]: float(init[index[state]]) for state in occupied},
327
+ )
328
+ for state in occupied:
329
+ machine.graph.add_state(names[state])
330
+ for state in occupied:
331
+ machine.set_emission_distribution(
332
+ names[state],
333
+ {symbol: float(emit[state, j]) for j, symbol in enumerate(alphabet)},
334
+ )
335
+ for target in occupied:
336
+ prob = float(sub_trans[index[state], index[target]])
337
+ if prob > 0.0:
338
+ machine.add_transition(names[state], names[target], prob)
339
+ machine.validate()
340
+ return machine
@@ -0,0 +1,294 @@
1
+ """Conjugate Bayesian inference for finite-order Markov chains."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Iterable, Sequence
6
+ from itertools import product
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from scipy.special import polygamma
11
+
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
+ WordCountsMC,
17
+ dirichlet_multinomial_log_evidence,
18
+ pretty_symbol,
19
+ pretty_word,
20
+ split_word,
21
+ )
22
+
23
+
24
+ def words_iter(alphabet: Sequence[Any], length: int) -> Iterable[tuple[Any, ...]]:
25
+ return product(tuple(alphabet), repeat=length)
26
+
27
+
28
+ class DirichletPriorMC:
29
+ """Dirichlet row prior for an order-``k`` Markov chain."""
30
+
31
+ def __init__(self, alphabet: Sequence[Any], order: int, uniform: bool = True):
32
+ self.alphabet = tuple(alphabet)
33
+ self.order = int(order)
34
+ self.uniform = uniform
35
+ self.alphas: dict[tuple[tuple[Any, ...], Any], float] = {}
36
+
37
+ def __str__(self) -> str:
38
+ lines = []
39
+ for context in words_iter(self.alphabet, self.order):
40
+ lines.append(f"alpha({pretty_word(context)} -> *) = {self.get_alpha((*context, '*'))}")
41
+ for symbol in self.alphabet:
42
+ lines.append(
43
+ f"alpha({pretty_word(context)} -> {pretty_symbol(symbol)}) = {self.get_alpha((*context, symbol))}"
44
+ )
45
+ return "\n".join(lines) + "\n"
46
+
47
+ def create_random_prior(self, lower: int, upper: int, rng: np.random.Generator | None = None) -> None:
48
+ generator = rng if rng is not None else np.random.default_rng()
49
+ self.uniform = False
50
+ for word in words_iter(self.alphabet, self.order + 1):
51
+ self.set_alpha(word, int(generator.integers(lower, upper)))
52
+
53
+ def get_alpha(self, word: Sequence[Any]) -> float | None:
54
+ word = tuple(word)
55
+ if len(word) != self.order + 1:
56
+ return None
57
+ if self.uniform:
58
+ return float(len(self.alphabet) if word[-1] == "*" else 1.0)
59
+ return self.alphas.get(split_word(word))
60
+
61
+ def set_alpha(self, word: Sequence[Any], value: float) -> None:
62
+ self.uniform = False
63
+ context, symbol = split_word(word)
64
+ previous = self.alphas.get((context, symbol), 0.0)
65
+ self.alphas[(context, symbol)] = float(value)
66
+ self.alphas[(context, "*")] = self.alphas.get((context, "*"), 0.0) - previous + float(value)
67
+
68
+
69
+ class MarkovChainPosterior:
70
+ """Posterior over a finite-order Markov chain's transition rows."""
71
+
72
+ def __init__(self, alphabet: Sequence[Any], data: Sequence[Any], order: int, prior_type: str = "uniform"):
73
+ self.alphabet = tuple(alphabet)
74
+ self.order = int(order)
75
+ self.prior_type = prior_type
76
+ self.counts = WordCountsMC(data, self.order)
77
+ self.prior = DirichletPriorMC(self.alphabet, self.order)
78
+ if prior_type == "random":
79
+ self.prior.create_random_prior(1, 6)
80
+ elif prior_type != "uniform":
81
+ raise BayesianInferenceError("unknown Markov-chain prior type")
82
+
83
+ @property
84
+ def contexts(self) -> tuple[tuple[Any, ...], ...]:
85
+ return tuple(words_iter(self.alphabet, self.order))
86
+
87
+ def add_counts_from(self, data: Sequence[Any]) -> None:
88
+ self.counts.add_counts_from(data)
89
+
90
+ def transition_probability_mle(self, word: Sequence[Any], symbol: Any) -> tuple[float, float]:
91
+ context = tuple(word)
92
+ n = self.counts.get_word_count((*context, symbol))
93
+ N = self.counts.get_word_count((*context, "*"))
94
+ if N > 0:
95
+ prob = n / N
96
+ variance = n * (N - n) / N**3
97
+ else:
98
+ prob = variance = 0.0
99
+ return float(prob), float(variance)
100
+
101
+ def transition_probability_pme(self, word: Sequence[Any], symbol: Any) -> tuple[float, float]:
102
+ context = tuple(word)
103
+ n = self.counts.get_word_count((*context, symbol))
104
+ N = self.counts.get_word_count((*context, "*"))
105
+ a = self.prior.get_alpha((*context, symbol))
106
+ A = self.prior.get_alpha((*context, "*"))
107
+ if a is None or A is None:
108
+ raise BayesianInferenceError("missing prior alpha")
109
+ prob = (n + a) / (N + A)
110
+ variance = ((n + a) * (N + A - n - a)) / ((N + A + 1) * (N + A) ** 2)
111
+ return float(prob), float(variance)
112
+
113
+ def posterior_alpha_matrix(self) -> np.ndarray:
114
+ matrix = np.zeros((len(self.contexts), len(self.alphabet)), dtype=float)
115
+ for i, context in enumerate(self.contexts):
116
+ for j, symbol in enumerate(self.alphabet):
117
+ alpha = self.prior.get_alpha((*context, symbol))
118
+ if alpha is None:
119
+ raise BayesianInferenceError("missing prior alpha")
120
+ matrix[i, j] = alpha + self.counts.get_word_count((*context, symbol))
121
+ return matrix
122
+
123
+ def posterior_mean_matrix(self) -> np.ndarray:
124
+ alpha = self.posterior_alpha_matrix()
125
+ return alpha / alpha.sum(axis=1, keepdims=True)
126
+
127
+ def log_evidence(self) -> float:
128
+ evidence = 0.0
129
+ for context in self.contexts:
130
+ root = (*context, "*")
131
+ alpha_root = self.prior.get_alpha(root)
132
+ if alpha_root is None:
133
+ raise BayesianInferenceError("missing prior alpha")
134
+ cells: list[tuple[float, float]] = []
135
+ for symbol in self.alphabet:
136
+ word = (*context, symbol)
137
+ alpha = self.prior.get_alpha(word)
138
+ if alpha is None:
139
+ raise BayesianInferenceError("missing prior alpha")
140
+ cells.append((alpha, self.counts.get_word_count(word)))
141
+ evidence += dirichlet_multinomial_log_evidence(alpha_root, self.counts.get_word_count(root), cells)
142
+ return float(evidence)
143
+
144
+ def average_relative_entropy_plus_entropy_rate(self) -> float:
145
+ beta = 0.0
146
+ for context in self.contexts:
147
+ for symbol in self.alphabet:
148
+ beta += self.counts.get_word_count((*context, symbol)) + self.prior.get_alpha((*context, symbol))
149
+
150
+ result = 0.0
151
+ invlog2 = 1 / np.log(2)
152
+ for context in self.contexts:
153
+ root = (*context, "*")
154
+ n = self.counts.get_word_count(root)
155
+ alpha = self.prior.get_alpha(root)
156
+ prob = (n + alpha) / beta
157
+ result += invlog2 * prob * polygamma(0, n + alpha)
158
+ for symbol in self.alphabet:
159
+ cond, _variance = self.transition_probability_pme(context, symbol)
160
+ ws_count = self.counts.get_word_count((*context, symbol))
161
+ ws_alpha = self.prior.get_alpha((*context, symbol))
162
+ result -= invlog2 * prob * cond * polygamma(0, ws_count + ws_alpha)
163
+ return float(result)
164
+
165
+ def variance_relative_entropy_plus_entropy_rate(self) -> float:
166
+ beta = 0.0
167
+ for context in self.contexts:
168
+ for symbol in self.alphabet:
169
+ beta += self.counts.get_word_count((*context, symbol)) + self.prior.get_alpha((*context, symbol))
170
+
171
+ result = 0.0
172
+ invlog2 = 1 / np.log(2)
173
+ for context in self.contexts:
174
+ root = (*context, "*")
175
+ n = self.counts.get_word_count(root)
176
+ alpha = self.prior.get_alpha(root)
177
+ prob = (n + alpha) / beta
178
+ result -= invlog2 * prob**2 * polygamma(1, n + alpha)
179
+ for symbol in self.alphabet:
180
+ cond, _variance = self.transition_probability_pme(context, symbol)
181
+ ws_count = self.counts.get_word_count((*context, symbol))
182
+ ws_alpha = self.prior.get_alpha((*context, symbol))
183
+ result += invlog2 * prob**2 * cond**2 * polygamma(1, ws_count + ws_alpha)
184
+ return float(result)
185
+
186
+ def transition_probability_mle_iter(self) -> Iterable[tuple[str, str, float, float]]:
187
+ for context in self.contexts:
188
+ for symbol in self.alphabet:
189
+ prob, var = self.transition_probability_mle(context, symbol)
190
+ yield pretty_word(context), pretty_symbol(symbol), prob, var
191
+
192
+ def transition_probability_pme_iter(self) -> Iterable[tuple[str, str, float, float]]:
193
+ for context in self.contexts:
194
+ for symbol in self.alphabet:
195
+ prob, var = self.transition_probability_pme(context, symbol)
196
+ yield pretty_word(context), pretty_symbol(symbol), prob, var
197
+
198
+ def _state_for_context(self, context: tuple[Any, ...]) -> Hashable:
199
+ return context if self.order else "A"
200
+
201
+ def _target_for(self, context: tuple[Any, ...], symbol: Any) -> Hashable:
202
+ return (*context[1:], symbol) if self.order else "A"
203
+
204
+ def generate_mealy_hmm(self, method: str = "PME", threshold: float = 0.0, reduce: bool = True) -> MealyHMM:
205
+ del reduce
206
+ if not 0 <= threshold <= 1:
207
+ raise BayesianInferenceError("threshold must be between 0 and 1")
208
+ if method == "PME":
209
+ matrix = self.posterior_mean_matrix()
210
+ elif method == "MLE":
211
+ matrix = np.array(
212
+ [
213
+ [self.transition_probability_mle(context, symbol)[0] for symbol in self.alphabet]
214
+ for context in self.contexts
215
+ ],
216
+ dtype=float,
217
+ )
218
+ else:
219
+ raise BayesianInferenceError("unknown inference method")
220
+
221
+ hmm = MealyHMM(observation_alphabet=frozenset(self.alphabet))
222
+ hmm.name = f"Inferred order-{self.order} Markov chain, {method}"
223
+ states = [self._state_for_context(context) for context in self.contexts]
224
+ initial = {state: 1.0 / len(states) for state in states} if states else {}
225
+ hmm.initial_distribution = initial
226
+ for state in states:
227
+ hmm.graph.add_state(state)
228
+ for i, context in enumerate(self.contexts):
229
+ source = self._state_for_context(context)
230
+ for j, symbol in enumerate(self.alphabet):
231
+ prob = float(matrix[i, j])
232
+ if prob > threshold:
233
+ hmm.graph.add_transition(
234
+ source, self._target_for(context, symbol), **{ATTR_EMISSION: symbol, ATTR_PROB: prob}
235
+ )
236
+ hmm.validate()
237
+ return hmm
238
+
239
+ def sample_mealy_hmms(
240
+ self,
241
+ n: int = 1,
242
+ threshold: float = 0.0,
243
+ reduce: bool = True,
244
+ rng: np.random.Generator | None = None,
245
+ ) -> Iterable[MealyHMM]:
246
+ del reduce
247
+ if not 0 <= threshold <= 1:
248
+ raise BayesianInferenceError("threshold must be between 0 and 1")
249
+ generator = rng if rng is not None else np.random.default_rng()
250
+ alpha = self.posterior_alpha_matrix()
251
+ samples = np.stack([generator.dirichlet(row, size=n) for row in alpha], axis=1)
252
+ for sample in samples:
253
+ hmm = MealyHMM(observation_alphabet=frozenset(self.alphabet))
254
+ hmm.name = f"Sampled order-{self.order} Markov chain"
255
+ states = [self._state_for_context(context) for context in self.contexts]
256
+ hmm.initial_distribution = {state: 1.0 / len(states) for state in states}
257
+ for state in states:
258
+ hmm.graph.add_state(state)
259
+ for i, context in enumerate(self.contexts):
260
+ source = self._state_for_context(context)
261
+ for j, symbol in enumerate(self.alphabet):
262
+ prob = float(sample[i, j])
263
+ if prob > threshold:
264
+ hmm.graph.add_transition(
265
+ source, self._target_for(context, symbol), **{ATTR_EMISSION: symbol, ATTR_PROB: prob}
266
+ )
267
+ hmm.validate()
268
+ yield hmm
269
+
270
+ def as_pymc_model(self, *, observed_as_counts: bool = False) -> Any:
271
+ from sofic.inference.bayesian.pymc_backend import markov_chain_model
272
+
273
+ return markov_chain_model(self, observed_as_counts=observed_as_counts)
274
+
275
+ def counts_string(self) -> str:
276
+ return str(self.counts)
277
+
278
+ def prior_string(self) -> str:
279
+ return str(self.prior)
280
+
281
+ def transition_probability_mle_string(self) -> str:
282
+ return "".join(
283
+ f"Pr( {symbol} | {context} ) = {prob:.8f} , StdDev = {np.sqrt(var):.8f}\n"
284
+ for context, symbol, prob, var in self.transition_probability_mle_iter()
285
+ )
286
+
287
+ def transition_probability_pme_string(self) -> str:
288
+ return "".join(
289
+ f"Pr( {symbol} | {context} ) = {prob:.8f} , StdDev = {np.sqrt(var):.8f}\n"
290
+ for context, symbol, prob, var in self.transition_probability_pme_iter()
291
+ )
292
+
293
+
294
+ InferMC = MarkovChainPosterior