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
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