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
sofic/graph.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""NetworkX-backed transition graph wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import networkx as nx
|
|
10
|
+
|
|
11
|
+
# Sentinel for NFA epsilon transitions.
|
|
12
|
+
EPSILON = object()
|
|
13
|
+
|
|
14
|
+
# Node attribute keys.
|
|
15
|
+
ATTR_EMISSION = "emission"
|
|
16
|
+
ATTR_EMISSION_DIST = "emission_dist"
|
|
17
|
+
ATTR_FUTURE_SYMBOL = "future_symbol"
|
|
18
|
+
ATTR_OUTPUT = "output"
|
|
19
|
+
|
|
20
|
+
# Edge attribute keys.
|
|
21
|
+
ATTR_SYMBOL = "symbol"
|
|
22
|
+
ATTR_PROB = "prob"
|
|
23
|
+
ATTR_QUASIPROB = "quasiprob"
|
|
24
|
+
ATTR_EMISSION_EDGE = "emission"
|
|
25
|
+
ATTR_KIND = "kind"
|
|
26
|
+
ATTR_STACK_SYMBOL = "stack_symbol"
|
|
27
|
+
ATTR_HIER_STATE = "hier_state"
|
|
28
|
+
ATTR_MULTIPLICITY = "multiplicity"
|
|
29
|
+
|
|
30
|
+
# VPA edge kinds.
|
|
31
|
+
KIND_CALL = "call"
|
|
32
|
+
KIND_RETURN = "return"
|
|
33
|
+
KIND_INTERNAL = "internal"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Transition:
|
|
38
|
+
"""One directed transition in a :class:`TransitionGraph`."""
|
|
39
|
+
|
|
40
|
+
source: Hashable
|
|
41
|
+
target: Hashable
|
|
42
|
+
key: int
|
|
43
|
+
data: dict[str, Any]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TransitionGraph:
|
|
47
|
+
"""NetworkX-backed directed multigraph for sofic models.
|
|
48
|
+
|
|
49
|
+
States are nodes; transitions are edges with attribute dictionaries keyed by
|
|
50
|
+
module constants such as :data:`ATTR_PROB` and :data:`ATTR_EMISSION`.
|
|
51
|
+
|
|
52
|
+
Examples
|
|
53
|
+
--------
|
|
54
|
+
>>> from sofic.graph import TransitionGraph, ATTR_PROB
|
|
55
|
+
>>> g = TransitionGraph()
|
|
56
|
+
>>> g.add_state("A")
|
|
57
|
+
>>> g.add_transition("A", "A", **{ATTR_PROB: 1.0})
|
|
58
|
+
>>> len(list(g.transitions()))
|
|
59
|
+
1
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_g",)
|
|
63
|
+
|
|
64
|
+
def __init__(self, graph: nx.MultiDiGraph | None = None) -> None:
|
|
65
|
+
self._g = graph if graph is not None else nx.MultiDiGraph()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def nx(self) -> nx.MultiDiGraph:
|
|
69
|
+
return self._g
|
|
70
|
+
|
|
71
|
+
def add_state(self, state: Hashable, **attrs: Any) -> None:
|
|
72
|
+
self._g.add_node(state, **attrs)
|
|
73
|
+
|
|
74
|
+
def add_transition(self, source: Hashable, target: Hashable, **attrs: Any) -> int:
|
|
75
|
+
return self._g.add_edge(source, target, **attrs)
|
|
76
|
+
|
|
77
|
+
def states(self) -> Iterator[Hashable]:
|
|
78
|
+
yield from self._g.nodes
|
|
79
|
+
|
|
80
|
+
def transitions(self) -> Iterator[Transition]:
|
|
81
|
+
for source, target, key, data in self._g.edges(keys=True, data=True):
|
|
82
|
+
yield Transition(source=source, target=target, key=key, data=dict(data))
|
|
83
|
+
|
|
84
|
+
def copy(self) -> TransitionGraph:
|
|
85
|
+
return TransitionGraph(self._g.copy())
|
|
86
|
+
|
|
87
|
+
def has_state(self, state: Hashable) -> bool:
|
|
88
|
+
return self._g.has_node(state)
|
|
89
|
+
|
|
90
|
+
def state_attrs(self, state: Hashable) -> dict[str, Any]:
|
|
91
|
+
return dict(self._g.nodes[state])
|
|
92
|
+
|
|
93
|
+
def out_transitions(self, source: Hashable) -> Iterator[Transition]:
|
|
94
|
+
for _target in self._g.successors(source):
|
|
95
|
+
for key, data in self._g.get_edge_data(source, _target).items():
|
|
96
|
+
yield Transition(source=source, target=_target, key=key, data=dict(data))
|
|
97
|
+
|
|
98
|
+
def reverse(self) -> TransitionGraph:
|
|
99
|
+
"""Return a graph with the same nodes/attrs and all edges transposed."""
|
|
100
|
+
reversed_graph = nx.MultiDiGraph()
|
|
101
|
+
for state, attrs in self._g.nodes(data=True):
|
|
102
|
+
reversed_graph.add_node(state, **dict(attrs))
|
|
103
|
+
for source, target, _key, data in self._g.edges(keys=True, data=True):
|
|
104
|
+
reversed_graph.add_edge(target, source, **dict(data))
|
|
105
|
+
return TransitionGraph(reversed_graph)
|
|
106
|
+
|
|
107
|
+
def forward_reachable(self, sources: set[Hashable] | frozenset[Hashable]) -> frozenset[Hashable]:
|
|
108
|
+
"""States reachable along directed edges from ``sources``."""
|
|
109
|
+
from collections import deque
|
|
110
|
+
|
|
111
|
+
if not sources:
|
|
112
|
+
return frozenset()
|
|
113
|
+
seen = set(sources)
|
|
114
|
+
queue = deque(sources)
|
|
115
|
+
while queue:
|
|
116
|
+
state = queue.popleft()
|
|
117
|
+
for transition in self.out_transitions(state):
|
|
118
|
+
target = transition.target
|
|
119
|
+
if target not in seen:
|
|
120
|
+
seen.add(target)
|
|
121
|
+
queue.append(target)
|
|
122
|
+
return frozenset(seen)
|
|
123
|
+
|
|
124
|
+
def terminal_recurrent_states(self) -> frozenset[Hashable]:
|
|
125
|
+
"""States in terminal strongly connected components (no exit to outside)."""
|
|
126
|
+
recurrent: set[Hashable] = set()
|
|
127
|
+
for component in nx.strongly_connected_components(self._g):
|
|
128
|
+
if not component:
|
|
129
|
+
continue
|
|
130
|
+
exits_component = any(
|
|
131
|
+
target not in component for source in component for _, target in self._g.out_edges(source)
|
|
132
|
+
)
|
|
133
|
+
if not exits_component:
|
|
134
|
+
recurrent.update(component)
|
|
135
|
+
return frozenset(recurrent)
|
sofic/indexing.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Dense state indexing for NumPy-backed algorithms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StateIndex:
|
|
9
|
+
"""Bidirectional mapping between hashable states and ``0..n-1`` indices."""
|
|
10
|
+
|
|
11
|
+
__slots__ = ("_states", "_index")
|
|
12
|
+
|
|
13
|
+
def __init__(self, states: Iterable[Hashable]) -> None:
|
|
14
|
+
self._states = tuple(dict.fromkeys(states))
|
|
15
|
+
self._index = {state: i for i, state in enumerate(self._states)}
|
|
16
|
+
|
|
17
|
+
def index(self, state: Hashable) -> int:
|
|
18
|
+
return self._index[state]
|
|
19
|
+
|
|
20
|
+
def state(self, i: int) -> Hashable:
|
|
21
|
+
return self._states[i]
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def states(self) -> tuple[Hashable, ...]:
|
|
25
|
+
return self._states
|
|
26
|
+
|
|
27
|
+
def __len__(self) -> int:
|
|
28
|
+
return len(self._states)
|
|
29
|
+
|
|
30
|
+
def __contains__(self, state: Hashable) -> bool:
|
|
31
|
+
return state in self._index
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Inference algorithms for stochastic generators."""
|
|
2
|
+
|
|
3
|
+
from sofic.inference import bayesian
|
|
4
|
+
from sofic.inference.model_selection import (
|
|
5
|
+
ModelScores,
|
|
6
|
+
WAICResult,
|
|
7
|
+
compare_information_criteria,
|
|
8
|
+
count_free_parameters,
|
|
9
|
+
cross_validated_log_likelihood,
|
|
10
|
+
information_criterion,
|
|
11
|
+
rank_topological_epsilon_machines,
|
|
12
|
+
score_model,
|
|
13
|
+
waic,
|
|
14
|
+
waic_epsilon_machine,
|
|
15
|
+
)
|
|
16
|
+
from sofic.inference.spectral import (
|
|
17
|
+
SpectralInferenceError,
|
|
18
|
+
hankel_matrices,
|
|
19
|
+
learn_spectral_wfa,
|
|
20
|
+
project_to_epsilon_machine,
|
|
21
|
+
project_to_mealy,
|
|
22
|
+
project_to_nmachine,
|
|
23
|
+
spectral_singular_values,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"bayesian",
|
|
28
|
+
"ModelScores",
|
|
29
|
+
"WAICResult",
|
|
30
|
+
"compare_information_criteria",
|
|
31
|
+
"count_free_parameters",
|
|
32
|
+
"cross_validated_log_likelihood",
|
|
33
|
+
"information_criterion",
|
|
34
|
+
"rank_topological_epsilon_machines",
|
|
35
|
+
"score_model",
|
|
36
|
+
"waic",
|
|
37
|
+
"waic_epsilon_machine",
|
|
38
|
+
"SpectralInferenceError",
|
|
39
|
+
"hankel_matrices",
|
|
40
|
+
"learn_spectral_wfa",
|
|
41
|
+
"project_to_epsilon_machine",
|
|
42
|
+
"project_to_mealy",
|
|
43
|
+
"project_to_nmachine",
|
|
44
|
+
"spectral_singular_values",
|
|
45
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Bayesian inference for computational mechanics models.
|
|
2
|
+
|
|
3
|
+
The default implementations use exact conjugate Dirichlet calculations. PyMC
|
|
4
|
+
support is available through ``as_pymc_model()`` methods and is imported only
|
|
5
|
+
when requested.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from sofic.inference.bayesian.comparison import ModelComparisonEM, ModelComparisonMC, ModelComparisonMC2
|
|
9
|
+
from sofic.inference.bayesian.counts import (
|
|
10
|
+
BayesianInferenceError,
|
|
11
|
+
PathCountEM,
|
|
12
|
+
WordCountsMC,
|
|
13
|
+
pretty_symbol,
|
|
14
|
+
pretty_word,
|
|
15
|
+
split_word,
|
|
16
|
+
)
|
|
17
|
+
from sofic.inference.bayesian.diversity import (
|
|
18
|
+
PosteriorDiversityResult,
|
|
19
|
+
machine_diversity,
|
|
20
|
+
posterior_mean_word_distribution,
|
|
21
|
+
posterior_process_diversity,
|
|
22
|
+
process_identification_word_length,
|
|
23
|
+
word_distribution_to_pmf,
|
|
24
|
+
)
|
|
25
|
+
from sofic.inference.bayesian.epsilon import DirichletDistributionEM, EpsilonMachinePosterior, InferEM
|
|
26
|
+
from sofic.inference.bayesian.hdp_hmm import HDPHMMPosterior, infer_hdp_hmm
|
|
27
|
+
from sofic.inference.bayesian.markov import DirichletPriorMC, InferMC, MarkovChainPosterior
|
|
28
|
+
from sofic.inference.bayesian.stack_hmm import (
|
|
29
|
+
DirichletDistributionStackHMM,
|
|
30
|
+
ModelComparisonStackHMM,
|
|
31
|
+
PathCountStackHMM,
|
|
32
|
+
StackHMMPosterior,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
BayesianMCException = BayesianInferenceError
|
|
36
|
+
BayesianEMException = BayesianInferenceError
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BayesianEMException",
|
|
40
|
+
"BayesianMCException",
|
|
41
|
+
"BayesianInferenceError",
|
|
42
|
+
"DirichletDistributionEM",
|
|
43
|
+
"DirichletDistributionStackHMM",
|
|
44
|
+
"DirichletPriorMC",
|
|
45
|
+
"EpsilonMachinePosterior",
|
|
46
|
+
"HDPHMMPosterior",
|
|
47
|
+
"infer_hdp_hmm",
|
|
48
|
+
"InferEM",
|
|
49
|
+
"InferMC",
|
|
50
|
+
"MarkovChainPosterior",
|
|
51
|
+
"ModelComparisonEM",
|
|
52
|
+
"ModelComparisonMC",
|
|
53
|
+
"ModelComparisonMC2",
|
|
54
|
+
"PosteriorDiversityResult",
|
|
55
|
+
"machine_diversity",
|
|
56
|
+
"posterior_mean_word_distribution",
|
|
57
|
+
"posterior_process_diversity",
|
|
58
|
+
"process_identification_word_length",
|
|
59
|
+
"word_distribution_to_pmf",
|
|
60
|
+
"ModelComparisonStackHMM",
|
|
61
|
+
"PathCountEM",
|
|
62
|
+
"PathCountStackHMM",
|
|
63
|
+
"StackHMMPosterior",
|
|
64
|
+
"WordCountsMC",
|
|
65
|
+
"pretty_symbol",
|
|
66
|
+
"pretty_word",
|
|
67
|
+
"split_word",
|
|
68
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Bayesian model comparison utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from sofic.generators.mealy import MealyHMM
|
|
11
|
+
from sofic.inference.bayesian.counts import BayesianInferenceError, posterior_weights
|
|
12
|
+
from sofic.inference.bayesian.markov import MarkovChainPosterior
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from sofic.inference.bayesian.diversity import PosteriorDiversityResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModelComparisonMC:
|
|
19
|
+
"""Compare Markov-chain orders using exact conjugate evidences."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
alphabet: Sequence[Any],
|
|
24
|
+
data: Sequence[Any],
|
|
25
|
+
min_order: int,
|
|
26
|
+
max_order: int,
|
|
27
|
+
mc_prior: str = "uniform",
|
|
28
|
+
mo_prior: str = "uniform",
|
|
29
|
+
):
|
|
30
|
+
if min_order < 0 or max_order < min_order:
|
|
31
|
+
raise BayesianInferenceError("invalid min/max Markov orders")
|
|
32
|
+
self.alphabet = tuple(alphabet)
|
|
33
|
+
self.min_order = int(min_order)
|
|
34
|
+
self.max_order = int(max_order)
|
|
35
|
+
self.markov_chain_prior = mc_prior
|
|
36
|
+
self.model_order_prior = mo_prior
|
|
37
|
+
self.mc_dict = {
|
|
38
|
+
order: MarkovChainPosterior(self.alphabet, data, order, prior_type=mc_prior)
|
|
39
|
+
for order in range(self.min_order, self.max_order + 1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def log_evidence(self) -> dict[int, float]:
|
|
43
|
+
return {order: posterior.log_evidence() for order, posterior in sorted(self.mc_dict.items())}
|
|
44
|
+
|
|
45
|
+
def _log_prior_penalty(self, order: int) -> float:
|
|
46
|
+
if self.model_order_prior == "uniform":
|
|
47
|
+
return 0.0
|
|
48
|
+
if self.model_order_prior == "penalty":
|
|
49
|
+
a = len(self.alphabet)
|
|
50
|
+
return -float((a**order) * (a - 1))
|
|
51
|
+
raise BayesianInferenceError("unknown model-order prior")
|
|
52
|
+
|
|
53
|
+
def model_probabilities(self) -> dict[int, float]:
|
|
54
|
+
orders = sorted(self.mc_dict)
|
|
55
|
+
log_evidences = [self.mc_dict[order].log_evidence() + self._log_prior_penalty(order) for order in orders]
|
|
56
|
+
weights, _ = posterior_weights(orders, log_evidences)
|
|
57
|
+
return weights
|
|
58
|
+
|
|
59
|
+
def most_probable_model(self) -> MealyHMM:
|
|
60
|
+
probs = self.model_probabilities()
|
|
61
|
+
order = max(probs, key=probs.get)
|
|
62
|
+
return self.mc_dict[order].generate_mealy_hmm(method="PME")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ModelComparisonMC2(ModelComparisonMC):
|
|
66
|
+
"""Compare an explicit list of Markov-chain orders."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
alphabet: Sequence[Any],
|
|
71
|
+
data: Sequence[Any],
|
|
72
|
+
orders: int | Sequence[int],
|
|
73
|
+
mc_prior: str = "uniform",
|
|
74
|
+
mo_prior: str = "uniform",
|
|
75
|
+
):
|
|
76
|
+
order_list = list(range(orders + 1)) if isinstance(orders, int) else sorted(int(order) for order in orders)
|
|
77
|
+
if not order_list or min(order_list) < 0:
|
|
78
|
+
raise BayesianInferenceError("invalid Markov orders")
|
|
79
|
+
self.alphabet = tuple(alphabet)
|
|
80
|
+
self.min_order = min(order_list)
|
|
81
|
+
self.max_order = max(order_list)
|
|
82
|
+
self.orders = order_list
|
|
83
|
+
self.markov_chain_prior = mc_prior
|
|
84
|
+
self.model_order_prior = mo_prior
|
|
85
|
+
self.mc_dict = {
|
|
86
|
+
order: MarkovChainPosterior(self.alphabet, data, order, prior_type=mc_prior) for order in order_list
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ModelComparisonEM:
|
|
91
|
+
"""Compare candidate unifilar topologies using epsilon-machine evidences."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
machines: Iterable[MealyHMM],
|
|
96
|
+
data: Sequence[Any] | None = None,
|
|
97
|
+
beta: float = 0.0,
|
|
98
|
+
state_path: bool = False,
|
|
99
|
+
):
|
|
100
|
+
from sofic.inference.bayesian.epsilon import EpsilonMachinePosterior
|
|
101
|
+
|
|
102
|
+
self.beta = float(beta)
|
|
103
|
+
self.data = data
|
|
104
|
+
self.em_dict: dict[str, EpsilonMachinePosterior] = {}
|
|
105
|
+
self.numMachines = 0
|
|
106
|
+
self.possMachines = 0
|
|
107
|
+
for index, machine in enumerate(machines):
|
|
108
|
+
self.numMachines += 1
|
|
109
|
+
posterior = EpsilonMachinePosterior(machine, data, state_path=state_path)
|
|
110
|
+
if posterior.log_evidence() > -np.inf:
|
|
111
|
+
self.possMachines += 1
|
|
112
|
+
name = getattr(machine, "name", None) or f"Machine-{index}"
|
|
113
|
+
self.em_dict[str(name)] = posterior
|
|
114
|
+
self.evidence_dictionary: dict[str, float] = {}
|
|
115
|
+
self.probs: dict[str, float] = {}
|
|
116
|
+
self.set_evidence = -np.inf
|
|
117
|
+
|
|
118
|
+
def log_evidence(self) -> dict[str, float]:
|
|
119
|
+
if not self.evidence_dictionary:
|
|
120
|
+
self.evidence_dictionary = {name: posterior.log_evidence() for name, posterior in self.em_dict.items()}
|
|
121
|
+
return dict(self.evidence_dictionary)
|
|
122
|
+
|
|
123
|
+
def model_probabilities(self) -> dict[str, float]:
|
|
124
|
+
if self.probs:
|
|
125
|
+
return dict(self.probs)
|
|
126
|
+
evidence = self.log_evidence()
|
|
127
|
+
names = list(evidence)
|
|
128
|
+
log_evidences = [evidence[name] - self.beta * len(self.em_dict[name].dirichlet.nodes) for name in names]
|
|
129
|
+
self.probs, self.set_evidence = posterior_weights(names, log_evidences)
|
|
130
|
+
return dict(self.probs)
|
|
131
|
+
|
|
132
|
+
def generate_sample(self, rng: np.random.Generator | None = None) -> tuple[Any, MealyHMM]:
|
|
133
|
+
generator = rng if rng is not None else np.random.default_rng()
|
|
134
|
+
probs = self.model_probabilities()
|
|
135
|
+
names = tuple(probs)
|
|
136
|
+
weights = np.array([probs[name] for name in names], dtype=float)
|
|
137
|
+
choice = names[int(generator.choice(len(names), p=weights))]
|
|
138
|
+
return self.em_dict[choice].generate_sample(rng=generator)
|
|
139
|
+
|
|
140
|
+
def information_criteria(self, *, include_initial: bool = False) -> dict[str, Any]:
|
|
141
|
+
"""Score every candidate topology with classical information criteria.
|
|
142
|
+
|
|
143
|
+
Fits each topology's posterior-mean parameters and scores it against the
|
|
144
|
+
stored data with :func:`sofic.inference.model_selection.score_model`,
|
|
145
|
+
returning a mapping ``name -> ModelScores``. This is a frequentist
|
|
146
|
+
alternative to the Bayesian :meth:`model_probabilities` (AIC/AICc/BIC/MDL,
|
|
147
|
+
lower is better). Requires ``data`` to have been supplied.
|
|
148
|
+
"""
|
|
149
|
+
from sofic.inference.model_selection import score_model
|
|
150
|
+
|
|
151
|
+
if self.data is None:
|
|
152
|
+
raise BayesianInferenceError("information criteria require data")
|
|
153
|
+
scores: dict[str, Any] = {}
|
|
154
|
+
for name, posterior in self.em_dict.items():
|
|
155
|
+
machine = posterior.posterior_mean_machine()
|
|
156
|
+
if machine is None:
|
|
157
|
+
continue
|
|
158
|
+
scores[name] = score_model(machine, self.data, include_initial=include_initial)
|
|
159
|
+
return scores
|
|
160
|
+
|
|
161
|
+
def best_by_information_criterion(self, criterion: str = "bic", *, include_initial: bool = False) -> str | None:
|
|
162
|
+
"""Return the topology name minimizing ``criterion`` (``aic``/``aicc``/``bic``/``mdl``)."""
|
|
163
|
+
scores = self.information_criteria(include_initial=include_initial)
|
|
164
|
+
if not scores:
|
|
165
|
+
return None
|
|
166
|
+
return min(scores, key=lambda name: scores[name].value(criterion))
|
|
167
|
+
|
|
168
|
+
def machine_diversity(self) -> float:
|
|
169
|
+
"""Shannon entropy (bits) of the topology posterior weights.
|
|
170
|
+
|
|
171
|
+
See :func:`~sofic.inference.bayesian.diversity.machine_diversity`.
|
|
172
|
+
"""
|
|
173
|
+
from sofic.inference.bayesian.diversity import machine_diversity
|
|
174
|
+
|
|
175
|
+
return machine_diversity(self)
|
|
176
|
+
|
|
177
|
+
def process_diversity(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
method: str = "posterior_mean",
|
|
181
|
+
n_samples: int = 500,
|
|
182
|
+
rng: np.random.Generator | None = None,
|
|
183
|
+
convention: str = "paz",
|
|
184
|
+
word_length: int | None = None,
|
|
185
|
+
) -> PosteriorDiversityResult:
|
|
186
|
+
"""Weighted JSD over length-:math:`L` word distributions in the posterior.
|
|
187
|
+
|
|
188
|
+
See :func:`~sofic.inference.bayesian.diversity.posterior_process_diversity`.
|
|
189
|
+
"""
|
|
190
|
+
from sofic.inference.bayesian.diversity import posterior_process_diversity
|
|
191
|
+
|
|
192
|
+
return posterior_process_diversity(
|
|
193
|
+
self,
|
|
194
|
+
method=method,
|
|
195
|
+
n_samples=n_samples,
|
|
196
|
+
rng=rng,
|
|
197
|
+
convention=convention,
|
|
198
|
+
word_length=word_length,
|
|
199
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Counting utilities for conjugate Bayesian process inference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Hashable, Iterable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.special import gammaln
|
|
12
|
+
|
|
13
|
+
from sofic.generators.mealy import MealyHMM
|
|
14
|
+
from sofic.graph import ATTR_EMISSION
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BayesianInferenceError(ValueError):
|
|
18
|
+
"""Raised when Bayesian inference inputs are inconsistent."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dirichlet_multinomial_log_evidence(
|
|
22
|
+
row_alpha: float,
|
|
23
|
+
row_count: float,
|
|
24
|
+
cells: Iterable[tuple[float, float]],
|
|
25
|
+
) -> float:
|
|
26
|
+
"""Log marginal likelihood contribution of one Dirichlet-multinomial row.
|
|
27
|
+
|
|
28
|
+
Computes ``lnΓ(A) - lnΓ(A + N) + Σ_i [lnΓ(α_i + n_i) - lnΓ(α_i)]`` for a row
|
|
29
|
+
with concentration ``row_alpha`` (``A``), observed total ``row_count`` (``N``)
|
|
30
|
+
and per-cell ``(alpha_i, count_i)`` pairs.
|
|
31
|
+
"""
|
|
32
|
+
evidence = gammaln(row_alpha) - gammaln(row_alpha + row_count)
|
|
33
|
+
for alpha, count in cells:
|
|
34
|
+
evidence += gammaln(alpha + count) - gammaln(alpha)
|
|
35
|
+
return float(evidence)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def posterior_weights(
|
|
39
|
+
keys: Sequence[Any],
|
|
40
|
+
log_evidences: Sequence[float],
|
|
41
|
+
) -> tuple[dict[Any, float], float]:
|
|
42
|
+
"""Normalize log-evidences into posterior weights via a softmax.
|
|
43
|
+
|
|
44
|
+
Returns ``(weights, log_norm)`` where ``weights[key]`` is the posterior
|
|
45
|
+
probability of each key and ``log_norm`` is the log-sum-exp normalizer
|
|
46
|
+
(``-inf`` when there are no keys). ``log_evidences`` should already include
|
|
47
|
+
any log-prior penalty terms.
|
|
48
|
+
"""
|
|
49
|
+
from scipy.special import logsumexp
|
|
50
|
+
|
|
51
|
+
values = np.asarray(log_evidences, dtype=float)
|
|
52
|
+
if values.size == 0:
|
|
53
|
+
return {}, float("-inf")
|
|
54
|
+
log_norm = float(logsumexp(values))
|
|
55
|
+
weights = {key: float(np.exp(value - log_norm)) for key, value in zip(keys, values, strict=True)}
|
|
56
|
+
return weights, log_norm
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pretty_symbol(symbol: Any) -> str:
|
|
60
|
+
"""Format a symbol like cmpy's Bayesian inference utilities."""
|
|
61
|
+
if isinstance(symbol, str):
|
|
62
|
+
return symbol
|
|
63
|
+
try:
|
|
64
|
+
iterator = iter(symbol)
|
|
65
|
+
except TypeError:
|
|
66
|
+
return str(symbol)
|
|
67
|
+
return ":".join(map(str, iterator))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def pretty_word(word: Sequence[Any]) -> str:
|
|
71
|
+
"""Format a word as comma-separated symbols."""
|
|
72
|
+
return ",".join(pretty_symbol(symbol) for symbol in word)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def split_word(word: Sequence[Any]) -> tuple[tuple[Any, ...], Any]:
|
|
76
|
+
"""Split a word into ``(history, next_symbol)``."""
|
|
77
|
+
word = tuple(word)
|
|
78
|
+
return tuple(word[:-1]), word[-1]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WordCountsMC:
|
|
82
|
+
"""Counts of length-``order`` contexts and following symbols."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, data: Sequence[Any], order: int):
|
|
85
|
+
if order < 0:
|
|
86
|
+
raise ValueError("order must be nonnegative")
|
|
87
|
+
self.counts: defaultdict[tuple[tuple[Any, ...], Any], float] = defaultdict(float)
|
|
88
|
+
self.order = int(order)
|
|
89
|
+
self.add_counts_from(data)
|
|
90
|
+
|
|
91
|
+
def __str__(self) -> str:
|
|
92
|
+
if not self.counts:
|
|
93
|
+
return "No counts."
|
|
94
|
+
formatted = {
|
|
95
|
+
f"{pretty_word(context)} -> {pretty_symbol(symbol)}": (context, symbol) for context, symbol in self.counts
|
|
96
|
+
}
|
|
97
|
+
integer_counts = all(float(value).is_integer() for value in self.counts.values())
|
|
98
|
+
lines = []
|
|
99
|
+
for label in sorted(formatted):
|
|
100
|
+
value = self.counts[formatted[label]]
|
|
101
|
+
value_repr = str(int(value)) if integer_counts else str(value)
|
|
102
|
+
lines.append(f"n({label}) = {value_repr}")
|
|
103
|
+
return "\n".join(lines) + "\n"
|
|
104
|
+
|
|
105
|
+
def add_counts_from(self, data: Sequence[Any]) -> None:
|
|
106
|
+
data = tuple(data)
|
|
107
|
+
for index in range(0, max(0, len(data) - self.order)):
|
|
108
|
+
context = data[index : index + self.order]
|
|
109
|
+
symbol = data[index + self.order]
|
|
110
|
+
self.counts[(context, symbol)] += 1
|
|
111
|
+
self.counts[(context, "*")] += 1
|
|
112
|
+
|
|
113
|
+
def clear_word_counts(self) -> None:
|
|
114
|
+
self.counts = defaultdict(float)
|
|
115
|
+
|
|
116
|
+
def get_word_count(self, word: Sequence[Any]) -> float:
|
|
117
|
+
return self.counts.get(split_word(word), 0.0)
|
|
118
|
+
|
|
119
|
+
def set_word_count(self, word: Sequence[Any], value: float) -> None:
|
|
120
|
+
context, symbol = split_word(word)
|
|
121
|
+
previous = self.counts.get((context, symbol), 0.0)
|
|
122
|
+
self.counts[(context, symbol)] = float(value)
|
|
123
|
+
self.counts[(context, "*")] = self.counts.get((context, "*"), 0.0) - previous + float(value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def scan_unifilar_topology(
|
|
127
|
+
machine: MealyHMM,
|
|
128
|
+
) -> tuple[dict[tuple[Hashable, Any], Hashable], list[tuple[Hashable, Any]]]:
|
|
129
|
+
"""Scan ``machine`` transitions into a ``(trace, sorted_edges)`` topology.
|
|
130
|
+
|
|
131
|
+
``trace`` maps each ``(source, emission)`` edge to its target; ``edges`` is the
|
|
132
|
+
sorted list of distinct edge keys. Raises :class:`BayesianInferenceError` if
|
|
133
|
+
two transitions share a ``(source, emission)`` key (non-unifilar topology).
|
|
134
|
+
"""
|
|
135
|
+
trace: dict[tuple[Hashable, Any], Hashable] = {}
|
|
136
|
+
edges: list[tuple[Hashable, Any]] = []
|
|
137
|
+
for transition in machine.transitions():
|
|
138
|
+
symbol = transition.data.get(ATTR_EMISSION)
|
|
139
|
+
key = (transition.source, symbol)
|
|
140
|
+
if key in trace:
|
|
141
|
+
raise BayesianInferenceError("non-unifilar topology is not allowed")
|
|
142
|
+
trace[key] = transition.target
|
|
143
|
+
edges.append(key)
|
|
144
|
+
edges.sort(key=repr)
|
|
145
|
+
return trace, edges
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class PathTrace:
|
|
150
|
+
"""Counts and terminal state for one assumed start state."""
|
|
151
|
+
|
|
152
|
+
counts: dict[Hashable | tuple[Hashable, Any], int]
|
|
153
|
+
last_state: Hashable | None
|
|
154
|
+
state_path: tuple[Hashable, ...] = ()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PathCountEM:
|
|
158
|
+
"""State and edge counts for a unifilar candidate topology."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, machine: MealyHMM, data: Sequence[Any] | None, state_path: bool = False):
|
|
161
|
+
self.machine = machine
|
|
162
|
+
self.collect_state_path = state_path
|
|
163
|
+
self.edges: list[tuple[Hashable, Any]] = []
|
|
164
|
+
self.nodes: list[Hashable] = list(machine.states())
|
|
165
|
+
self.trace: dict[tuple[Hashable, Any], Hashable] = {}
|
|
166
|
+
self.counts: dict[Hashable, PathTrace] = {}
|
|
167
|
+
self.possible_start_nodes: list[Hashable] = []
|
|
168
|
+
self._process_machine()
|
|
169
|
+
self._generate_counts(tuple(data or ()))
|
|
170
|
+
|
|
171
|
+
def _process_machine(self) -> None:
|
|
172
|
+
self.trace, self.edges = scan_unifilar_topology(self.machine)
|
|
173
|
+
|
|
174
|
+
def _generate_counts(self, data: tuple[Any, ...]) -> None:
|
|
175
|
+
for start in self.nodes:
|
|
176
|
+
state = start
|
|
177
|
+
counts: dict[Hashable | tuple[Hashable, Any], int] = {}
|
|
178
|
+
path = [state]
|
|
179
|
+
valid = True
|
|
180
|
+
for symbol in data:
|
|
181
|
+
counts[state] = counts.get(state, 0) + 1
|
|
182
|
+
edge = (state, symbol)
|
|
183
|
+
counts[edge] = counts.get(edge, 0) + 1
|
|
184
|
+
if edge not in self.trace:
|
|
185
|
+
valid = False
|
|
186
|
+
state = None
|
|
187
|
+
path = []
|
|
188
|
+
counts = {}
|
|
189
|
+
break
|
|
190
|
+
state = self.trace[edge]
|
|
191
|
+
path.append(state)
|
|
192
|
+
if valid:
|
|
193
|
+
self.possible_start_nodes.append(start)
|
|
194
|
+
self.counts[start] = PathTrace(
|
|
195
|
+
counts=counts,
|
|
196
|
+
last_state=state,
|
|
197
|
+
state_path=tuple(path) if self.collect_state_path else (),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def get_edges(self) -> list[tuple[Hashable, Any]]:
|
|
201
|
+
return list(self.edges)
|
|
202
|
+
|
|
203
|
+
def get_nodes(self) -> list[Hashable]:
|
|
204
|
+
return list(self.nodes)
|
|
205
|
+
|
|
206
|
+
def get_edge_count(self, start_node: Hashable, edge: tuple[Hashable, Any]) -> int | None:
|
|
207
|
+
return self.counts[start_node].counts.get(edge)
|
|
208
|
+
|
|
209
|
+
def get_node_count(self, start_node: Hashable, node: Hashable) -> int | None:
|
|
210
|
+
return self.counts[start_node].counts.get(node)
|
|
211
|
+
|
|
212
|
+
def get_possible_start_nodes(self) -> list[Hashable]:
|
|
213
|
+
return list(self.possible_start_nodes)
|
|
214
|
+
|
|
215
|
+
def get_last_node(self, start_node: Hashable) -> Hashable | None:
|
|
216
|
+
return self.counts[start_node].last_state
|
|
217
|
+
|
|
218
|
+
def get_state_path(self, start_node: Hashable) -> tuple[Hashable, ...]:
|
|
219
|
+
return self.counts[start_node].state_path
|