sofic 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sofic/__init__.py +185 -0
- sofic/automata/__init__.py +207 -0
- sofic/automata/_config_simulation.py +40 -0
- sofic/automata/active.py +611 -0
- sofic/automata/alergia.py +222 -0
- sofic/automata/algorithms.py +376 -0
- sofic/automata/atomaton.py +58 -0
- sofic/automata/base.py +161 -0
- sofic/automata/buchi.py +23 -0
- sofic/automata/buchi_simulation.py +67 -0
- sofic/automata/canonical_dual.py +18 -0
- sofic/automata/canonical_extraction.py +122 -0
- sofic/automata/dfa.py +85 -0
- sofic/automata/dfasat.py +195 -0
- sofic/automata/edsm.py +219 -0
- sofic/automata/enumeration.py +44 -0
- sofic/automata/icdfa.py +421 -0
- sofic/automata/idfa.py +363 -0
- sofic/automata/languages/__init__.py +39 -0
- sofic/automata/languages/_quotient_utils.py +64 -0
- sofic/automata/languages/atoms.py +31 -0
- sofic/automata/languages/automaton_ops.py +243 -0
- sofic/automata/languages/base.py +67 -0
- sofic/automata/languages/operations.py +78 -0
- sofic/automata/languages/quotients.py +66 -0
- sofic/automata/languages/residuals.py +25 -0
- sofic/automata/learning.py +79 -0
- sofic/automata/nfa.py +39 -0
- sofic/automata/nwa.py +343 -0
- sofic/automata/nwa_simulation.py +56 -0
- sofic/automata/observation.py +40 -0
- sofic/automata/papni.py +301 -0
- sofic/automata/regex.py +128 -0
- sofic/automata/rfsa.py +35 -0
- sofic/automata/rpni.py +193 -0
- sofic/automata/subsequential.py +201 -0
- sofic/automata/transducer_operations.py +350 -0
- sofic/automata/transducer_simulation.py +150 -0
- sofic/automata/transducers.py +365 -0
- sofic/automata/unifilar.py +107 -0
- sofic/automata/vpa.py +1373 -0
- sofic/automata/vpa_simulation.py +53 -0
- sofic/base.py +153 -0
- sofic/core.py +47 -0
- sofic/examples/__init__.py +86 -0
- sofic/examples/epsilon_machines.py +1089 -0
- sofic/examples/processes.py +1491 -0
- sofic/examples/shifts.py +144 -0
- sofic/exceptions.py +33 -0
- sofic/generators/__init__.py +115 -0
- sofic/generators/_word_measures.py +94 -0
- sofic/generators/alternative_complexity.py +104 -0
- sofic/generators/base.py +327 -0
- sofic/generators/bidirectional_construction.py +717 -0
- sofic/generators/bidirectional_epsilon_machine.py +689 -0
- sofic/generators/block_convergence.py +668 -0
- sofic/generators/block_entropy.py +578 -0
- sofic/generators/channel_measures.py +75 -0
- sofic/generators/conversions.py +182 -0
- sofic/generators/directional_flow.py +245 -0
- sofic/generators/edge_emissions.py +36 -0
- sofic/generators/edge_machine.py +178 -0
- sofic/generators/epsilon_construction.py +193 -0
- sofic/generators/epsilon_inference.py +703 -0
- sofic/generators/epsilon_machine.py +557 -0
- sofic/generators/epsilon_transducer.py +168 -0
- sofic/generators/epsilon_transducer_construction.py +185 -0
- sofic/generators/epsilon_transducer_inference.py +499 -0
- sofic/generators/hmm_inference.py +719 -0
- sofic/generators/information_diagram.py +428 -0
- sofic/generators/lumping.py +447 -0
- sofic/generators/markov.py +100 -0
- sofic/generators/mealy.py +156 -0
- sofic/generators/measures.py +257 -0
- sofic/generators/minimal_generative_model.py +821 -0
- sofic/generators/mixed_state.py +250 -0
- sofic/generators/mixed_state_construction.py +163 -0
- sofic/generators/moore.py +75 -0
- sofic/generators/nmachine.py +78 -0
- sofic/generators/nmachine_construction.py +70 -0
- sofic/generators/pfa.py +100 -0
- sofic/generators/prob.py +291 -0
- sofic/generators/process_equivalence.py +207 -0
- sofic/generators/quasi_inference.py +74 -0
- sofic/generators/quasi_realization.py +97 -0
- sofic/generators/reversal.py +66 -0
- sofic/generators/stack_hmm.py +426 -0
- sofic/generators/stack_inference.py +509 -0
- sofic/generators/stationary.py +134 -0
- sofic/generators/stochastic.py +65 -0
- sofic/generators/synchronization.py +407 -0
- sofic/generators/topological_epsilon_enumeration.py +349 -0
- sofic/generators/words.py +226 -0
- sofic/graph.py +135 -0
- sofic/indexing.py +31 -0
- sofic/inference/__init__.py +45 -0
- sofic/inference/bayesian/__init__.py +68 -0
- sofic/inference/bayesian/comparison.py +199 -0
- sofic/inference/bayesian/counts.py +219 -0
- sofic/inference/bayesian/diversity.py +254 -0
- sofic/inference/bayesian/epsilon.py +270 -0
- sofic/inference/bayesian/hdp_hmm.py +340 -0
- sofic/inference/bayesian/markov.py +294 -0
- sofic/inference/bayesian/pymc_backend.py +71 -0
- sofic/inference/bayesian/stack_hmm.py +215 -0
- sofic/inference/model_selection.py +365 -0
- sofic/inference/spectral.py +564 -0
- sofic/operations.py +16 -0
- sofic/properties.py +339 -0
- sofic/serialization.py +450 -0
- sofic/shifts/__init__.py +48 -0
- sofic/shifts/algorithms.py +84 -0
- sofic/shifts/base.py +49 -0
- sofic/shifts/cover_construction.py +76 -0
- sofic/shifts/covers.py +47 -0
- sofic/shifts/dyck_algorithms.py +100 -0
- sofic/shifts/dyck_enumeration.py +275 -0
- sofic/shifts/markov_dyck.py +172 -0
- sofic/shifts/parry_construction.py +82 -0
- sofic/shifts/sft.py +104 -0
- sofic/shifts/sft_construction.py +52 -0
- sofic/shifts/sliding_block_code.py +156 -0
- sofic/shifts/sofic.py +111 -0
- sofic/shifts/sofic_dyck.py +110 -0
- sofic/shifts/sofic_relation.py +64 -0
- sofic/shifts/textile.py +104 -0
- sofic/shifts/tmc.py +46 -0
- sofic/shifts/tmc_construction.py +58 -0
- sofic/shifts/topological_anatomy.py +150 -0
- sofic/states.py +27 -0
- sofic/testing/__init__.py +8 -0
- sofic/testing/strategies.py +154 -0
- sofic/viz/__init__.py +16 -0
- sofic/viz/_context.py +345 -0
- sofic/viz/_edge.py +216 -0
- sofic/viz/_format.py +89 -0
- sofic/viz/_labels.py +34 -0
- sofic/viz/_names.py +17 -0
- sofic/viz/_rational.py +20 -0
- sofic/viz/_tikz_compile.py +177 -0
- sofic/viz/_tikz_format.py +122 -0
- sofic/viz/_tikz_layout.py +218 -0
- sofic/viz/assets/vaucanson.tikz +71 -0
- sofic/viz/graphviz.py +158 -0
- sofic/viz/idiagram.py +350 -0
- sofic/viz/tikz.py +381 -0
- sofic-0.1.0.dist-info/METADATA +444 -0
- sofic-0.1.0.dist-info/RECORD +150 -0
- sofic-0.1.0.dist-info/WHEEL +4 -0
- sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
|
@@ -0,0 +1,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
|