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,71 @@
1
+ """Optional PyMC model builders for Bayesian process inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+
11
+ def _safe_name(prefix: str, value: object) -> str:
12
+ text = repr(value)
13
+ return prefix + "_" + "".join(ch if ch.isalnum() else "_" for ch in text).strip("_")
14
+
15
+
16
+ def markov_chain_model(posterior: Any, *, observed_as_counts: bool = False) -> Any:
17
+ """Build a PyMC model for a :class:`MarkovChainPosterior`.
18
+
19
+ The default likelihood uses a ``Potential`` with the same ordered-sequence
20
+ evidence convention as cmpy. ``observed_as_counts=True`` switches to a
21
+ multinomial count likelihood with the same posterior but a different
22
+ marginal likelihood constant.
23
+ """
24
+ import pymc as pm
25
+
26
+ contexts = tuple(posterior.contexts)
27
+ symbols = tuple(posterior.alphabet)
28
+ counts = np.zeros((len(contexts), len(symbols)), dtype=int)
29
+ prior_alpha = np.zeros_like(counts, dtype=float)
30
+ for i, context in enumerate(contexts):
31
+ for j, symbol in enumerate(symbols):
32
+ counts[i, j] = int(posterior.counts.get_word_count((*context, symbol)))
33
+ prior_alpha[i, j] = posterior.prior.get_alpha((*context, symbol))
34
+
35
+ coords = {
36
+ "context": np.array([str(context) for context in contexts], dtype=object),
37
+ "symbol": np.array([str(symbol) for symbol in symbols], dtype=object),
38
+ }
39
+ with pm.Model(coords=coords) as model:
40
+ theta = pm.Dirichlet("theta", a=prior_alpha, dims=("context", "symbol"))
41
+ if observed_as_counts:
42
+ pm.Multinomial("counts", n=counts.sum(axis=1), p=theta, observed=counts, dims=("context", "symbol"))
43
+ else:
44
+ pm.Potential("sequence_loglik", (counts * pm.math.log(theta)).sum())
45
+ return model
46
+
47
+
48
+ def epsilon_machine_model(posterior: Any, *, start_node: Hashable | None = None) -> Any:
49
+ """Build a fixed-start PyMC model for an epsilon-machine posterior."""
50
+ import pymc as pm
51
+
52
+ dist = posterior.dirichlet
53
+ if start_node is None:
54
+ probs = posterior.start_node_probabilities()
55
+ if not probs:
56
+ raise ValueError("no viable start nodes")
57
+ start_node = max(probs, key=probs.get)
58
+ if start_node not in dist.get_possible_start_nodes():
59
+ raise ValueError(f"start node {start_node!r} is not viable")
60
+
61
+ rows: dict[Hashable, list[tuple[Hashable, Any]]] = {}
62
+ for edge in dist.valid_edges:
63
+ rows.setdefault(edge[0], []).append(edge)
64
+
65
+ with pm.Model() as model:
66
+ for source, edges in rows.items():
67
+ alpha = np.array([dist.get_edge_alpha(start_node, edge) for edge in edges], dtype=float)
68
+ counts = np.array([dist.get_edge_count(start_node, edge) or 0 for edge in edges], dtype=int)
69
+ theta = pm.Dirichlet(_safe_name("theta", source), a=alpha, shape=len(edges))
70
+ pm.Potential(_safe_name("sequence_loglik", source), (counts * pm.math.log(theta)).sum())
71
+ return model
@@ -0,0 +1,215 @@
1
+ """Conjugate Bayesian inference for fixed stack-HMM topologies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from sofic.generators.stack_hmm import Configuration, HiddenMarkovStackModel
12
+ from sofic.graph import ATTR_SYMBOL
13
+ from sofic.inference.bayesian.counts import (
14
+ BayesianInferenceError,
15
+ dirichlet_multinomial_log_evidence,
16
+ posterior_weights,
17
+ )
18
+ from sofic.shifts.sofic_dyck import TransitionRef, transition_ref
19
+
20
+
21
+ class PathCountStackHMM:
22
+ """Transition counts along a sample path under a fixed stack topology."""
23
+
24
+ def __init__(
25
+ self,
26
+ model: HiddenMarkovStackModel,
27
+ data: Sequence[Any],
28
+ *,
29
+ max_stack_depth: int = 16,
30
+ ) -> None:
31
+ self.model = model
32
+ self.max_stack_depth = int(max_stack_depth)
33
+ self.counts: dict[Configuration, dict[TransitionRef, int]] = defaultdict(lambda: defaultdict(int))
34
+ self.config_visits: dict[Configuration, int] = defaultdict(int)
35
+ self._add_counts_from(data)
36
+
37
+ def _add_counts_from(self, data: Sequence[Any]) -> None:
38
+ data = tuple(data)
39
+ if not data:
40
+ return
41
+ initial_states = [state for state, mass in self.model.initial_distribution.items() if mass > 0.0]
42
+ if not initial_states:
43
+ return
44
+ configs: dict[Configuration, float] = {(state, ()): 1.0 for state in initial_states}
45
+ for symbol in data:
46
+ if symbol not in self.model.symbol_alphabet:
47
+ return
48
+ next_configs: dict[Configuration, float] = defaultdict(float)
49
+ for config, mass in configs.items():
50
+ if mass <= 0.0:
51
+ continue
52
+ self.config_visits[config] += 1
53
+ successors = self.model._normalized_successors(config, max_stack_depth=self.max_stack_depth)
54
+ matching = [
55
+ (transition, prob, next_config)
56
+ for transition, prob, next_config in successors
57
+ if transition.data.get(ATTR_SYMBOL) == symbol
58
+ ]
59
+ if not matching:
60
+ return
61
+ transition, prob, next_config = max(matching, key=lambda item: item[1])
62
+ ref = transition_ref(transition)
63
+ self.counts[config][ref] += 1
64
+ next_configs[next_config] += mass * prob
65
+ configs = dict(next_configs)
66
+ if not configs:
67
+ return
68
+
69
+ def get_count(self, config: Configuration, edge: TransitionRef) -> int:
70
+ return self.counts.get(config, {}).get(edge, 0)
71
+
72
+ def get_config_count(self, config: Configuration) -> int:
73
+ return self.config_visits.get(config, 0)
74
+
75
+
76
+ class DirichletDistributionStackHMM:
77
+ """Product-of-Dirichlets posterior over enabled transitions per configuration."""
78
+
79
+ def __init__(
80
+ self,
81
+ model: HiddenMarkovStackModel,
82
+ data: Sequence[Any] | None = None,
83
+ *,
84
+ max_stack_depth: int = 16,
85
+ ) -> None:
86
+ self.model = model
87
+ self.max_stack_depth = max_stack_depth
88
+ self.configurations = model.reachable_configurations(max_stack_depth)
89
+ self.edges_by_config: dict[Configuration, list[TransitionRef]] = {}
90
+ self.alphas: dict[tuple[Configuration, TransitionRef], float] = {}
91
+ self.data: PathCountStackHMM | None = None
92
+ self._build_topology()
93
+ self._uniform_alphas()
94
+ if data is not None:
95
+ self.data = PathCountStackHMM(model, data, max_stack_depth=max_stack_depth)
96
+
97
+ def _build_topology(self) -> None:
98
+ for config in self.configurations:
99
+ enabled: list[TransitionRef] = []
100
+ for transition, _prob, _next_config in self.model._normalized_successors(
101
+ config,
102
+ max_stack_depth=self.max_stack_depth,
103
+ ):
104
+ enabled.append(transition_ref(transition))
105
+ if len(enabled) > 1:
106
+ self.edges_by_config[config] = enabled
107
+
108
+ def _uniform_alphas(self) -> None:
109
+ self.alphas.clear()
110
+ for config, edges in self.edges_by_config.items():
111
+ for edge in edges:
112
+ self.alphas[(config, edge)] = 1.0
113
+
114
+ def log_evidence(self) -> float:
115
+ if self.data is None:
116
+ raise BayesianInferenceError("data is required for log evidence")
117
+ evidence = 0.0
118
+ for config, edges in self.edges_by_config.items():
119
+ row_count = self.data.get_config_count(config)
120
+ if row_count == 0:
121
+ continue
122
+ row_alpha = sum(self.alphas[(config, edge)] for edge in edges)
123
+ cells = [(self.alphas[(config, edge)], self.data.get_count(config, edge)) for edge in edges]
124
+ evidence += dirichlet_multinomial_log_evidence(row_alpha, row_count, cells)
125
+ return float(evidence)
126
+
127
+ def posterior_mean_probabilities(self) -> dict[TransitionRef, float]:
128
+ if self.data is None:
129
+ raise BayesianInferenceError("data is required for posterior mean")
130
+ edge_totals: dict[TransitionRef, float] = defaultdict(float)
131
+ edge_counts: dict[TransitionRef, float] = defaultdict(float)
132
+ for config, edges in self.edges_by_config.items():
133
+ row_count = self.data.get_config_count(config)
134
+ if row_count == 0:
135
+ continue
136
+ row_alpha = sum(self.alphas[(config, edge)] for edge in edges)
137
+ for edge in edges:
138
+ alpha = self.alphas[(config, edge)]
139
+ count = self.data.get_count(config, edge)
140
+ mean = (count + alpha) / (row_count + row_alpha)
141
+ edge_totals[edge] += mean
142
+ edge_counts[edge] += 1.0
143
+ return {edge: edge_totals[edge] / edge_counts[edge] for edge in edge_totals if edge_counts[edge] > 0}
144
+
145
+ def posterior_mean_model(self) -> HiddenMarkovStackModel:
146
+ from sofic.shifts.sofic_dyck import SoficDyckShift
147
+
148
+ shift: SoficDyckShift = self.model.to_sofic_dyck_shift()
149
+ probabilities = self.posterior_mean_probabilities()
150
+ for transition in shift.transitions():
151
+ ref = transition_ref(transition)
152
+ if ref not in probabilities:
153
+ probabilities[ref] = 1.0
154
+ return HiddenMarkovStackModel.from_sofic_dyck_shift(
155
+ shift,
156
+ probabilities,
157
+ initial_distribution=self.model.initial_distribution,
158
+ allow_empty_stack_returns=self.model.allow_empty_stack_returns,
159
+ )
160
+
161
+
162
+ class StackHMMPosterior:
163
+ """Posterior over parameters for a fixed stack topology."""
164
+
165
+ def __init__(
166
+ self,
167
+ model: HiddenMarkovStackModel,
168
+ data: Sequence[Any] | None = None,
169
+ *,
170
+ max_stack_depth: int = 16,
171
+ ) -> None:
172
+ self.dirichlet = DirichletDistributionStackHMM(model, data, max_stack_depth=max_stack_depth)
173
+
174
+ def log_evidence(self) -> float:
175
+ return self.dirichlet.log_evidence()
176
+
177
+ def posterior_mean_model(self) -> HiddenMarkovStackModel:
178
+ return self.dirichlet.posterior_mean_model()
179
+
180
+
181
+ class ModelComparisonStackHMM:
182
+ """Compare enumerated stack topologies by conjugate marginal likelihood."""
183
+
184
+ def __init__(
185
+ self,
186
+ models: Sequence[HiddenMarkovStackModel],
187
+ data: Sequence[Any],
188
+ *,
189
+ max_stack_depth: int = 16,
190
+ topology_prior: str = "uniform",
191
+ ) -> None:
192
+ if not models:
193
+ raise BayesianInferenceError("models must be non-empty")
194
+ self.models = list(models)
195
+ self.data = tuple(data)
196
+ self.max_stack_depth = max_stack_depth
197
+ self.topology_prior = topology_prior
198
+ self.posteriors = [
199
+ StackHMMPosterior(model, self.data, max_stack_depth=max_stack_depth) for model in self.models
200
+ ]
201
+
202
+ def log_evidences(self) -> list[float]:
203
+ return [posterior.log_evidence() for posterior in self.posteriors]
204
+
205
+ def model_probabilities(self) -> dict[int, float]:
206
+ values = np.array(self.log_evidences(), dtype=float)
207
+ if self.topology_prior == "penalty":
208
+ values -= np.array([len(list(model.transitions())) for model in self.models], dtype=float)
209
+ weights, _ = posterior_weights(list(range(len(values))), values)
210
+ return weights
211
+
212
+ def most_probable_model(self) -> HiddenMarkovStackModel:
213
+ probs = self.model_probabilities()
214
+ index = max(probs, key=probs.get)
215
+ return self.posteriors[index].posterior_mean_model()
@@ -0,0 +1,365 @@
1
+ """Classical model-selection criteria for stochastic generators.
2
+
3
+ Point-estimate information criteria -- AIC :cite:`Akaike1974`, the
4
+ small-sample-corrected AICc :cite:`HurvichTsai1989`, BIC :cite:`Schwarz1978`,
5
+ and the two-part minimum description length :cite:`Rissanen1978` -- together
6
+ with cross-validated log-likelihood and the widely applicable information
7
+ criterion (WAIC) :cite:`Watanabe2010`. These complement the exact Bayesian
8
+ evidences of :mod:`sofic.inference.bayesian`: they score any fitted
9
+ :class:`~sofic.generators.base.HiddenMarkovModel` (ε-machine, Mealy HMM, Markov
10
+ chain) using the natural-log likelihood from
11
+ :func:`sofic.generators.hmm_inference.log_likelihood` and a free-parameter count
12
+ read off the transition graph, so they are likelihood-agnostic and apply
13
+ directly to discrete-emission models.
14
+
15
+ All information criteria follow the convention **lower is better**;
16
+ cross-validated and WAIC log scores follow **higher is better** for the raw log
17
+ score (WAIC itself is reported on the deviance scale, lower is better).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable, Iterable, Sequence
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+
28
+ from sofic.generators.base import HiddenMarkovModel
29
+ from sofic.generators.hmm_inference import free_parameter_labels, log_likelihood
30
+
31
+ __all__ = [
32
+ "ModelScores",
33
+ "WAICResult",
34
+ "count_free_parameters",
35
+ "cross_validated_log_likelihood",
36
+ "information_criterion",
37
+ "compare_information_criteria",
38
+ "posterior_pointwise_log_likelihoods",
39
+ "rank_topological_epsilon_machines",
40
+ "score_model",
41
+ "waic",
42
+ "waic_epsilon_machine",
43
+ ]
44
+
45
+ _CRITERIA = ("aic", "aicc", "bic", "mdl")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ModelScores:
50
+ """Information-criterion scores for one fitted model (lower is better)."""
51
+
52
+ log_likelihood: float
53
+ num_parameters: int
54
+ num_observations: int
55
+ aic: float
56
+ aicc: float
57
+ bic: float
58
+ mdl: float
59
+
60
+ def value(self, criterion: str) -> float:
61
+ """Return the score for ``criterion`` (one of ``aic``/``aicc``/``bic``/``mdl``)."""
62
+ key = criterion.lower()
63
+ if key not in _CRITERIA:
64
+ raise ValueError(f"unknown criterion {criterion!r}; choose from {_CRITERIA}")
65
+ return float(getattr(self, key))
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class WAICResult:
70
+ """Widely applicable information criterion decomposition."""
71
+
72
+ waic: float
73
+ lppd: float
74
+ p_waic: float
75
+ standard_error: float
76
+
77
+
78
+ def _normalize_sequences(sequences: Iterable[Any]) -> list[list[Any]]:
79
+ seqs = list(sequences)
80
+ if not seqs:
81
+ return []
82
+ first = seqs[0]
83
+ if isinstance(first, (list, tuple)) and not isinstance(first, (str, bytes)):
84
+ return [list(seq) for seq in seqs]
85
+ return [list(seqs)]
86
+
87
+
88
+ def count_free_parameters(model: HiddenMarkovModel, *, include_initial: bool = False) -> int:
89
+ """Return the number of free real parameters of ``model``.
90
+
91
+ Counts one free parameter per non-reference outgoing edge at each state (the
92
+ multinomial free-parameterization of the joint emission-transition law used
93
+ by :func:`sofic.generators.hmm_inference.observed_information`). With
94
+ ``include_initial`` the ``n - 1`` free parameters of the initial
95
+ distribution are added; for a stationary presentation the initial law is
96
+ determined by the dynamics, so this defaults to ``False``.
97
+ """
98
+ transition_params = len(free_parameter_labels(model))
99
+ if not include_initial:
100
+ return transition_params
101
+ support = sum(1 for mass in model.initial_distribution.values() if float(mass) > 0.0)
102
+ return transition_params + max(0, support - 1)
103
+
104
+
105
+ def _total_length(sequences: Sequence[Sequence[Any]]) -> int:
106
+ return int(sum(len(seq) for seq in sequences))
107
+
108
+
109
+ def _total_log_likelihood(model: HiddenMarkovModel, sequences: Sequence[Sequence[Any]]) -> float:
110
+ total = 0.0
111
+ for seq in sequences:
112
+ contribution = log_likelihood(model, seq)
113
+ if not np.isfinite(contribution):
114
+ return float("-inf")
115
+ total += contribution
116
+ return total
117
+
118
+
119
+ def score_model(
120
+ model: HiddenMarkovModel,
121
+ data: Iterable[Any],
122
+ *,
123
+ include_initial: bool = False,
124
+ ) -> ModelScores:
125
+ """Score ``model`` on ``data`` with AIC, AICc, BIC, and MDL.
126
+
127
+ ``data`` may be a single observation sequence or an iterable of sequences.
128
+ The scores use natural-log likelihoods; the number of observations is the
129
+ total symbol count. When the data has zero probability under the model the
130
+ likelihood is ``-inf`` and every criterion is ``+inf``.
131
+ """
132
+ sequences = _normalize_sequences(data)
133
+ n = _total_length(sequences)
134
+ k = count_free_parameters(model, include_initial=include_initial)
135
+ ll = _total_log_likelihood(model, sequences)
136
+
137
+ if not np.isfinite(ll):
138
+ inf = float("inf")
139
+ return ModelScores(float("-inf"), k, n, inf, inf, inf, inf)
140
+
141
+ aic = 2.0 * k - 2.0 * ll
142
+ denom = n - k - 1
143
+ aicc = aic + (2.0 * k * (k + 1)) / denom if denom > 0 else float("inf")
144
+ log_n = np.log(n) if n > 0 else 0.0
145
+ bic = k * log_n - 2.0 * ll
146
+ mdl = 0.5 * k * log_n - ll
147
+ return ModelScores(float(ll), k, n, float(aic), float(aicc), float(bic), float(mdl))
148
+
149
+
150
+ def information_criterion(
151
+ model: HiddenMarkovModel,
152
+ data: Iterable[Any],
153
+ *,
154
+ criterion: str = "bic",
155
+ include_initial: bool = False,
156
+ ) -> float:
157
+ """Return a single information-criterion value for ``model`` (lower is better)."""
158
+ return score_model(model, data, include_initial=include_initial).value(criterion)
159
+
160
+
161
+ def compare_information_criteria(
162
+ models: Iterable[HiddenMarkovModel],
163
+ data: Iterable[Any],
164
+ *,
165
+ criterion: str = "bic",
166
+ include_initial: bool = False,
167
+ ) -> dict[str, ModelScores]:
168
+ """Score several models on shared ``data``, keyed by each model's ``name``.
169
+
170
+ The data is materialized once and reused. Model keys fall back to
171
+ ``Model-<index>`` when a model has no ``name`` attribute.
172
+ """
173
+ sequences = _normalize_sequences(data)
174
+ _ = criterion # accepted for symmetry; callers pick the field via ModelScores.value
175
+ scores: dict[str, ModelScores] = {}
176
+ for index, model in enumerate(models):
177
+ name = str(getattr(model, "name", None) or f"Model-{index}")
178
+ scores[name] = score_model(model, sequences, include_initial=include_initial)
179
+ return scores
180
+
181
+
182
+ # --- Cross-validated log-likelihood ---------------------------------------
183
+
184
+
185
+ def _fold_indices(n_items: int, folds: int, rng: np.random.Generator) -> list[np.ndarray]:
186
+ order = rng.permutation(n_items)
187
+ return [np.sort(chunk) for chunk in np.array_split(order, folds)]
188
+
189
+
190
+ def cross_validated_log_likelihood(
191
+ fit: Callable[[list[Any]], HiddenMarkovModel],
192
+ data: Iterable[Any],
193
+ *,
194
+ folds: int = 5,
195
+ rng: np.random.Generator | int | None = None,
196
+ ) -> float:
197
+ """Return the total held-out natural-log likelihood under ``folds``-fold CV.
198
+
199
+ ``fit(train_sequences)`` must fit and return a model from a list of training
200
+ sequences. When ``data`` is a collection of sequences the folds partition the
201
+ sequences; a single long sequence is split into ``folds`` contiguous blocks.
202
+ Each held-out block is scored under a model trained on the remaining data and
203
+ the contributions are summed (higher is better). A fold whose held-out data
204
+ has zero probability contributes ``-inf``.
205
+ """
206
+ generator = rng if isinstance(rng, np.random.Generator) else np.random.default_rng(rng)
207
+ sequences = _normalize_sequences(data)
208
+ if folds < 2:
209
+ raise ValueError("folds must be at least 2")
210
+
211
+ if len(sequences) >= folds:
212
+ partition = _fold_indices(len(sequences), folds, generator)
213
+ blocks = [[sequences[i] for i in idx] for idx in partition]
214
+ else:
215
+ # Single (or few) long sequence(s): split the concatenation into contiguous blocks.
216
+ flat = [symbol for seq in sequences for symbol in seq]
217
+ if len(flat) < folds:
218
+ raise ValueError("not enough data for the requested number of folds")
219
+ blocks = [list(chunk) for chunk in np.array_split(np.array(flat, dtype=object), folds)]
220
+ blocks = [[list(block)] for block in blocks]
221
+
222
+ total = 0.0
223
+ for held_out_index in range(len(blocks)):
224
+ train: list[Any] = []
225
+ for index, block in enumerate(blocks):
226
+ if index == held_out_index:
227
+ continue
228
+ train.extend(block)
229
+ held_out = blocks[held_out_index]
230
+ model = fit(train)
231
+ total += _total_log_likelihood(model, _normalize_sequences(held_out))
232
+ return float(total)
233
+
234
+
235
+ # --- WAIC ------------------------------------------------------------------
236
+
237
+
238
+ def waic(pointwise_log_likelihoods: np.ndarray) -> WAICResult:
239
+ r"""Widely applicable information criterion from posterior samples.
240
+
241
+ ``pointwise_log_likelihoods`` has shape ``(n_samples, n_points)`` with entry
242
+ ``[s, i] = log p(y_i | theta_s)`` for posterior draw ``theta_s``. Returns the
243
+ WAIC on the deviance scale (lower is better),
244
+ ``WAIC = -2 (lppd - p_waic)`` with the log pointwise predictive density
245
+ ``lppd = sum_i log mean_s p(y_i | theta_s)`` and effective parameter count
246
+ ``p_waic = sum_i Var_s log p(y_i | theta_s)`` :cite:`Watanabe2010`.
247
+ """
248
+ from scipy.special import logsumexp
249
+
250
+ matrix = np.asarray(pointwise_log_likelihoods, dtype=float)
251
+ if matrix.ndim != 2 or matrix.size == 0:
252
+ raise ValueError("pointwise_log_likelihoods must be a non-empty (n_samples, n_points) array")
253
+ n_samples = matrix.shape[0]
254
+ lppd_pointwise = logsumexp(matrix, axis=0) - np.log(n_samples)
255
+ p_waic_pointwise = matrix.var(axis=0, ddof=1) if n_samples > 1 else np.zeros(matrix.shape[1])
256
+ elpd_pointwise = lppd_pointwise - p_waic_pointwise
257
+ waic_value = -2.0 * float(elpd_pointwise.sum())
258
+ n_points = matrix.shape[1]
259
+ standard_error = float(np.sqrt(n_points * np.var(-2.0 * elpd_pointwise, ddof=0))) if n_points > 1 else 0.0
260
+ return WAICResult(
261
+ waic=waic_value,
262
+ lppd=float(lppd_pointwise.sum()),
263
+ p_waic=float(p_waic_pointwise.sum()),
264
+ standard_error=standard_error,
265
+ )
266
+
267
+
268
+ def posterior_pointwise_log_likelihoods(
269
+ posterior: Any,
270
+ data: Iterable[Any],
271
+ *,
272
+ n_samples: int = 200,
273
+ rng: np.random.Generator | int | None = None,
274
+ ) -> np.ndarray:
275
+ """Sample per-sequence log-likelihoods from an ε-machine/Markov posterior.
276
+
277
+ ``posterior`` must expose ``generate_sample(rng=...) -> (start, model)`` (e.g.
278
+ :class:`~sofic.inference.bayesian.epsilon.EpsilonMachinePosterior`). Each data
279
+ sequence is one WAIC "point"; returns an array of shape
280
+ ``(n_samples, n_sequences)`` suitable for :func:`waic`.
281
+ """
282
+ generator = rng if isinstance(rng, np.random.Generator) else np.random.default_rng(rng)
283
+ sequences = _normalize_sequences(data)
284
+ matrix = np.empty((n_samples, len(sequences)), dtype=float)
285
+ for s in range(n_samples):
286
+ _start, model = posterior.generate_sample(rng=generator)
287
+ for i, seq in enumerate(sequences):
288
+ matrix[s, i] = log_likelihood(model, seq)
289
+ return matrix
290
+
291
+
292
+ def waic_epsilon_machine(
293
+ posterior: Any,
294
+ data: Iterable[Any],
295
+ *,
296
+ n_samples: int = 200,
297
+ rng: np.random.Generator | int | None = None,
298
+ ) -> WAICResult:
299
+ """Compute WAIC for a posterior over machines by sampling parameters."""
300
+ return waic(posterior_pointwise_log_likelihoods(posterior, data, n_samples=n_samples, rng=rng))
301
+
302
+
303
+ # --- Topological enumeration ranking --------------------------------------
304
+
305
+
306
+ @dataclass(frozen=True)
307
+ class TopologyScore:
308
+ """A candidate topology, its fitted realization, and its scores."""
309
+
310
+ machine: Any
311
+ scores: ModelScores
312
+ criterion_value: float
313
+
314
+
315
+ def _fit_topology(machine: Any, sequences: list[list[Any]], method: str) -> HiddenMarkovModel | None:
316
+ if method == "bayesian":
317
+ from sofic.inference.bayesian.epsilon import EpsilonMachinePosterior
318
+
319
+ flat = [symbol for seq in sequences for symbol in seq]
320
+ posterior = EpsilonMachinePosterior(machine, flat)
321
+ return posterior.posterior_mean_machine()
322
+ if method == "baum_welch":
323
+ fitted, _trace = machine.baum_welch(sequences)
324
+ return fitted
325
+ raise ValueError(f"unknown fit method {method!r}; choose 'bayesian' or 'baum_welch'")
326
+
327
+
328
+ def rank_topological_epsilon_machines(
329
+ data: Iterable[Any],
330
+ *,
331
+ alphabet: Sequence[Any],
332
+ num_states: int | Iterable[int],
333
+ criterion: str = "bic",
334
+ fit: str = "bayesian",
335
+ include_initial: bool = False,
336
+ check_minimal: bool = True,
337
+ ) -> list[TopologyScore]:
338
+ """Rank enumerated topological ε-machines by an information criterion.
339
+
340
+ Enumerates canonical topological ε-machines over ``alphabet`` for each
341
+ requested state count (:func:`~sofic.generators.topological_epsilon_enumeration.iter_topological_epsilon_machines`),
342
+ fits transition probabilities to ``data`` (Bayesian posterior mean by
343
+ default, or Baum-Welch), scores each fitted model, and returns the
344
+ candidates sorted best-first by ``criterion``. This is the frequentist
345
+ counterpart to the Bayesian topology comparison of
346
+ :class:`~sofic.inference.bayesian.comparison.ModelComparisonEM`.
347
+ """
348
+ from sofic.generators.topological_epsilon_enumeration import iter_topological_epsilon_machines
349
+
350
+ sequences = _normalize_sequences(data)
351
+ symbols = tuple(alphabet)
352
+ k = len(symbols)
353
+ counts = [num_states] if isinstance(num_states, int) else sorted({int(value) for value in num_states})
354
+
355
+ results: list[TopologyScore] = []
356
+ for n in counts:
357
+ for topology in iter_topological_epsilon_machines(k, n, alphabet=symbols, check_minimal=check_minimal):
358
+ fitted = _fit_topology(topology, sequences, fit)
359
+ if fitted is None:
360
+ continue
361
+ scores = score_model(fitted, sequences, include_initial=include_initial)
362
+ results.append(TopologyScore(fitted, scores, scores.value(criterion)))
363
+
364
+ results.sort(key=lambda item: item.criterion_value)
365
+ return results