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,250 @@
|
|
|
1
|
+
"""Mixed-state presentations of hidden Markov models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from sofic.exceptions import StochasticValidationError
|
|
12
|
+
from sofic.generators.mealy import MealyHMM
|
|
13
|
+
from sofic.generators.prob import (
|
|
14
|
+
as_prob,
|
|
15
|
+
has_symbolic,
|
|
16
|
+
is_positive_mass,
|
|
17
|
+
is_symbolic,
|
|
18
|
+
is_zero,
|
|
19
|
+
simplify_prob,
|
|
20
|
+
sum_probs,
|
|
21
|
+
)
|
|
22
|
+
from sofic.graph import TransitionGraph
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class MixedState:
|
|
27
|
+
"""A normalized belief distribution over presentation basis states."""
|
|
28
|
+
|
|
29
|
+
belief: tuple[Any, ...]
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_vector(
|
|
33
|
+
cls,
|
|
34
|
+
vector: Sequence[Any] | np.ndarray,
|
|
35
|
+
*,
|
|
36
|
+
decimals: int = 12,
|
|
37
|
+
atol: float = 1e-12,
|
|
38
|
+
) -> MixedState | None:
|
|
39
|
+
"""Return a canonical mixed state, or ``None`` if the vector has no mass."""
|
|
40
|
+
flat = list(np.asarray(vector, dtype=object).ravel())
|
|
41
|
+
if has_symbolic(flat):
|
|
42
|
+
total = sum_probs(flat)
|
|
43
|
+
if is_zero(total, atol=atol):
|
|
44
|
+
return None
|
|
45
|
+
normalized = tuple(simplify_prob(as_prob(value) / total) for value in flat)
|
|
46
|
+
return cls(normalized)
|
|
47
|
+
|
|
48
|
+
array = np.asarray([float(value) for value in flat], dtype=float)
|
|
49
|
+
total = float(array.sum())
|
|
50
|
+
if total <= atol:
|
|
51
|
+
return None
|
|
52
|
+
normalized = array / total
|
|
53
|
+
rounded = tuple(round(float(value), decimals) for value in normalized)
|
|
54
|
+
total_rounded = sum(rounded)
|
|
55
|
+
if total_rounded <= atol:
|
|
56
|
+
return None
|
|
57
|
+
if not np.isclose(total_rounded, 1.0, atol=10 ** (-decimals + 2)):
|
|
58
|
+
rounded = tuple(round(value / total_rounded, decimals) for value in rounded)
|
|
59
|
+
return cls(rounded)
|
|
60
|
+
|
|
61
|
+
def as_array(self) -> np.ndarray:
|
|
62
|
+
if has_symbolic(self.belief):
|
|
63
|
+
array = np.empty(len(self.belief), dtype=object)
|
|
64
|
+
for i, value in enumerate(self.belief):
|
|
65
|
+
array[i] = value
|
|
66
|
+
return array
|
|
67
|
+
return np.asarray(self.belief, dtype=float)
|
|
68
|
+
|
|
69
|
+
def is_symbolic(self) -> bool:
|
|
70
|
+
return has_symbolic(self.belief)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_pure_mixed_state(
|
|
74
|
+
state: MixedState | Sequence[Any],
|
|
75
|
+
*,
|
|
76
|
+
atol: float = 1e-9,
|
|
77
|
+
) -> bool:
|
|
78
|
+
"""Return whether ``state`` is a vertex of the belief simplex."""
|
|
79
|
+
belief = state.belief if isinstance(state, MixedState) else tuple(state)
|
|
80
|
+
if has_symbolic(belief):
|
|
81
|
+
positives = [value for value in belief if is_positive_mass(value, atol=atol)]
|
|
82
|
+
from sofic.generators.prob import probs_equal
|
|
83
|
+
|
|
84
|
+
return len(positives) == 1 and probs_equal(sum_probs(belief), 1)
|
|
85
|
+
positives = [value for value in belief if value > atol]
|
|
86
|
+
return len(positives) == 1 and np.isclose(sum(belief), 1.0, atol=atol)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def pure_state_index(state: MixedState, *, atol: float = 1e-9) -> int | None:
|
|
90
|
+
"""Return the basis index for a pure mixed state, else ``None``."""
|
|
91
|
+
if not is_pure_mixed_state(state, atol=atol):
|
|
92
|
+
return None
|
|
93
|
+
for index, value in enumerate(state.belief):
|
|
94
|
+
if is_positive_mass(value, atol=atol):
|
|
95
|
+
return index
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def mixed_state_entropy(state: MixedState, *, atol: float = 1e-12) -> float:
|
|
100
|
+
"""Shannon entropy of a mixed state in bits."""
|
|
101
|
+
from sofic.generators.stochastic import shannon_entropy
|
|
102
|
+
|
|
103
|
+
if state.is_symbolic():
|
|
104
|
+
raise NotImplementedError("mixed_state_entropy is numeric-only; evaluate beliefs first")
|
|
105
|
+
return shannon_entropy(state.as_array(), atol=atol)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MixedStatePresentation(MealyHMM):
|
|
109
|
+
"""Unifilar presentation whose states are beliefs over a source HMM."""
|
|
110
|
+
|
|
111
|
+
basis_states: tuple[Hashable, ...]
|
|
112
|
+
initial_mixed_state: MixedState
|
|
113
|
+
pure_states: frozenset[MixedState]
|
|
114
|
+
recurrent_states: frozenset[MixedState]
|
|
115
|
+
transient_states: frozenset[MixedState]
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
basis_states: Sequence[Hashable],
|
|
121
|
+
initial_mixed_state: MixedState,
|
|
122
|
+
pure_states: frozenset[MixedState],
|
|
123
|
+
recurrent_states: frozenset[MixedState],
|
|
124
|
+
transient_states: frozenset[MixedState],
|
|
125
|
+
**kwargs: Any,
|
|
126
|
+
) -> None:
|
|
127
|
+
super().__init__(**kwargs)
|
|
128
|
+
self.basis_states = tuple(basis_states)
|
|
129
|
+
self.initial_mixed_state = initial_mixed_state
|
|
130
|
+
self.pure_states = pure_states
|
|
131
|
+
self.recurrent_states = recurrent_states
|
|
132
|
+
self.transient_states = transient_states
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_presentation(
|
|
136
|
+
cls,
|
|
137
|
+
hmm: MealyHMM,
|
|
138
|
+
*,
|
|
139
|
+
initial_mixed_state: MixedState | Mapping[Hashable, float] | Sequence[float] | None = None,
|
|
140
|
+
) -> MixedStatePresentation:
|
|
141
|
+
from sofic.generators.mixed_state_construction import build_mixed_state_presentation
|
|
142
|
+
|
|
143
|
+
return build_mixed_state_presentation(hmm, initial_mixed_state=initial_mixed_state)
|
|
144
|
+
|
|
145
|
+
def to_recurrent(self) -> MealyHMM:
|
|
146
|
+
"""Return the recurrent component with stationary initial weights.
|
|
147
|
+
|
|
148
|
+
Pure recurrent mixed states are relabeled by their basis states and returned
|
|
149
|
+
as an :class:`~sofic.generators.epsilon_machine.EpsilonMachine`. Otherwise
|
|
150
|
+
the recurrent component remains a unifilar :class:`~sofic.generators.mealy.MealyHMM`
|
|
151
|
+
over mixed states.
|
|
152
|
+
"""
|
|
153
|
+
keep = frozenset(self.recurrent_states)
|
|
154
|
+
if not keep:
|
|
155
|
+
raise StochasticValidationError("mixed-state presentation has no recurrent states")
|
|
156
|
+
|
|
157
|
+
state_map, is_epsilon_machine = self._recurrent_state_map(keep)
|
|
158
|
+
graph = TransitionGraph()
|
|
159
|
+
for state in keep:
|
|
160
|
+
graph.add_state(state_map[state], **self.graph.state_attrs(state))
|
|
161
|
+
for state in keep:
|
|
162
|
+
for transition in self.graph.out_transitions(state):
|
|
163
|
+
if transition.target in keep:
|
|
164
|
+
graph.add_transition(
|
|
165
|
+
state_map[state],
|
|
166
|
+
state_map[transition.target],
|
|
167
|
+
**transition.data,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
initial_distribution = self._recurrent_initial_distribution(keep, state_map)
|
|
171
|
+
constraints = getattr(self, "symbol_constraints", None)
|
|
172
|
+
if is_epsilon_machine:
|
|
173
|
+
from sofic.generators.epsilon_machine import EpsilonMachine
|
|
174
|
+
|
|
175
|
+
recurrent = EpsilonMachine(
|
|
176
|
+
graph=graph,
|
|
177
|
+
initial_distribution=initial_distribution,
|
|
178
|
+
observation_alphabet=self.observation_alphabet,
|
|
179
|
+
symbol_constraints=constraints,
|
|
180
|
+
)
|
|
181
|
+
recurrent.validate()
|
|
182
|
+
return recurrent
|
|
183
|
+
|
|
184
|
+
recurrent = MealyHMM(
|
|
185
|
+
graph=graph,
|
|
186
|
+
initial_distribution=initial_distribution,
|
|
187
|
+
observation_alphabet=self.observation_alphabet,
|
|
188
|
+
symbol_constraints=constraints,
|
|
189
|
+
)
|
|
190
|
+
recurrent.validate_stochastic()
|
|
191
|
+
recurrent._check_unifilar()
|
|
192
|
+
return recurrent
|
|
193
|
+
|
|
194
|
+
def _recurrent_state_map(
|
|
195
|
+
self,
|
|
196
|
+
keep: frozenset[MixedState],
|
|
197
|
+
) -> tuple[dict[MixedState, Hashable], bool]:
|
|
198
|
+
labels: dict[MixedState, Hashable] = {}
|
|
199
|
+
for state in keep:
|
|
200
|
+
label = self.causal_state(state)
|
|
201
|
+
if label is None:
|
|
202
|
+
return {mixed_state: mixed_state for mixed_state in keep}, False
|
|
203
|
+
labels[state] = label
|
|
204
|
+
return labels, True
|
|
205
|
+
|
|
206
|
+
def _recurrent_initial_distribution(
|
|
207
|
+
self,
|
|
208
|
+
keep: frozenset[MixedState],
|
|
209
|
+
state_map: Mapping[MixedState, Hashable],
|
|
210
|
+
) -> dict[Hashable, Any]:
|
|
211
|
+
from sofic.generators.prob import simplify_prob
|
|
212
|
+
|
|
213
|
+
idx = self.reindex()
|
|
214
|
+
stationary = self.stationary_distribution()
|
|
215
|
+
initial: dict[Hashable, Any] = {}
|
|
216
|
+
symbolic = stationary.dtype == object or has_symbolic(stationary.ravel())
|
|
217
|
+
for state in keep:
|
|
218
|
+
mass = stationary[idx.index(state)]
|
|
219
|
+
if not is_positive_mass(mass):
|
|
220
|
+
continue
|
|
221
|
+
label = state_map[state]
|
|
222
|
+
if label in initial:
|
|
223
|
+
initial[label] = simplify_prob(as_prob(initial[label]) + as_prob(mass))
|
|
224
|
+
else:
|
|
225
|
+
initial[label] = as_prob(mass)
|
|
226
|
+
if not initial:
|
|
227
|
+
raise StochasticValidationError("recurrent component has no positive stationary mass")
|
|
228
|
+
total = sum_probs(initial.values())
|
|
229
|
+
if symbolic or is_symbolic(total):
|
|
230
|
+
return {state: simplify_prob(as_prob(mass) / total) for state, mass in initial.items()}
|
|
231
|
+
total_f = float(total)
|
|
232
|
+
return {state: float(mass) / total_f for state, mass in initial.items()}
|
|
233
|
+
|
|
234
|
+
def belief(self, state: MixedState) -> tuple[Any, ...]:
|
|
235
|
+
return state.belief
|
|
236
|
+
|
|
237
|
+
def causal_state(self, state: MixedState) -> Hashable | None:
|
|
238
|
+
index = pure_state_index(state)
|
|
239
|
+
if index is None:
|
|
240
|
+
return None
|
|
241
|
+
return self.basis_states[index]
|
|
242
|
+
|
|
243
|
+
def is_pure(self, state: MixedState) -> bool:
|
|
244
|
+
return state in self.pure_states
|
|
245
|
+
|
|
246
|
+
def is_recurrent(self, state: MixedState) -> bool:
|
|
247
|
+
return state in self.recurrent_states
|
|
248
|
+
|
|
249
|
+
def is_transient(self, state: MixedState) -> bool:
|
|
250
|
+
return state in self.transient_states
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Build mixed-state presentations via belief-state enumeration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from sofic.exceptions import StochasticValidationError
|
|
12
|
+
from sofic.generators.base import HiddenMarkovModel
|
|
13
|
+
from sofic.generators.mealy import MealyHMM
|
|
14
|
+
from sofic.generators.mixed_state import (
|
|
15
|
+
MixedState,
|
|
16
|
+
MixedStatePresentation,
|
|
17
|
+
is_pure_mixed_state,
|
|
18
|
+
)
|
|
19
|
+
from sofic.generators.prob import (
|
|
20
|
+
as_prob,
|
|
21
|
+
has_symbolic,
|
|
22
|
+
is_positive_mass,
|
|
23
|
+
matvec,
|
|
24
|
+
simplify_prob,
|
|
25
|
+
sum_probs,
|
|
26
|
+
zeros,
|
|
27
|
+
)
|
|
28
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB, TransitionGraph
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _terminal_recurrent_states(graph: TransitionGraph) -> frozenset[Any]:
|
|
32
|
+
return graph.terminal_recurrent_states()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_initial_belief(
|
|
36
|
+
hmm: HiddenMarkovModel,
|
|
37
|
+
basis: tuple[Hashable, ...],
|
|
38
|
+
initial_mixed_state: MixedState | Mapping[Hashable, Any] | Sequence[Any] | None,
|
|
39
|
+
) -> MixedState:
|
|
40
|
+
if initial_mixed_state is None:
|
|
41
|
+
vector = hmm.stationary_distribution()
|
|
42
|
+
elif isinstance(initial_mixed_state, MixedState):
|
|
43
|
+
vector = initial_mixed_state.as_array()
|
|
44
|
+
elif isinstance(initial_mixed_state, Mapping):
|
|
45
|
+
index = {state: i for i, state in enumerate(basis)}
|
|
46
|
+
symbolic = has_symbolic(initial_mixed_state.values())
|
|
47
|
+
vector = zeros((len(basis),), symbolic=symbolic)
|
|
48
|
+
for state, mass in initial_mixed_state.items():
|
|
49
|
+
vector[index[state]] = as_prob(mass)
|
|
50
|
+
else:
|
|
51
|
+
vector = np.asarray(initial_mixed_state, dtype=object if has_symbolic(initial_mixed_state) else float)
|
|
52
|
+
|
|
53
|
+
if len(np.asarray(vector).ravel()) != len(basis):
|
|
54
|
+
raise ValueError(f"initial belief has length {len(np.asarray(vector).ravel())}, expected {len(basis)}")
|
|
55
|
+
mixed = MixedState.from_vector(vector)
|
|
56
|
+
if mixed is None:
|
|
57
|
+
raise ValueError("initial mixed state has zero total mass")
|
|
58
|
+
return mixed
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_mixed_state_presentation(
|
|
62
|
+
hmm: HiddenMarkovModel,
|
|
63
|
+
*,
|
|
64
|
+
initial_mixed_state: MixedState | Mapping[Hashable, Any] | Sequence[Any] | None = None,
|
|
65
|
+
max_states: int = 10_000,
|
|
66
|
+
) -> MixedStatePresentation:
|
|
67
|
+
"""Enumerate the mixed-state presentation of ``hmm``.
|
|
68
|
+
|
|
69
|
+
States are reachable belief distributions over the source presentation's hidden
|
|
70
|
+
states, updated by Bayes' rule on emitted symbols. The construction follows
|
|
71
|
+
Ellison, Mahoney & Crutchfield (J. Stat. Phys. 2009), Sec. VIII.2.
|
|
72
|
+
|
|
73
|
+
Any Mealy-style HMM (joint edge emissions) is accepted; unifilarity is not required.
|
|
74
|
+
Probabilities may be floats or exact sympy expressions.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
max_states
|
|
79
|
+
Safety cap on enumerated beliefs (symbolic machines can otherwise grow
|
|
80
|
+
without bound when successor beliefs fail to identify).
|
|
81
|
+
"""
|
|
82
|
+
if isinstance(hmm, MixedStatePresentation):
|
|
83
|
+
raise TypeError("cannot build a mixed-state presentation from another mixed-state presentation")
|
|
84
|
+
if not isinstance(hmm, MealyHMM):
|
|
85
|
+
raise TypeError(f"mixed-state presentation requires a MealyHMM, not {type(hmm)!r}")
|
|
86
|
+
|
|
87
|
+
from sofic.generators.hmm_inference import _emission_transition_tensors
|
|
88
|
+
|
|
89
|
+
constraints = getattr(hmm, "symbol_constraints", None)
|
|
90
|
+
|
|
91
|
+
idx = hmm.reindex()
|
|
92
|
+
basis = idx.states
|
|
93
|
+
_, joint = _emission_transition_tensors(hmm)
|
|
94
|
+
symbols = tuple(sorted(joint, key=str))
|
|
95
|
+
eta0 = _resolve_initial_belief(hmm, basis, initial_mixed_state)
|
|
96
|
+
|
|
97
|
+
graph = TransitionGraph()
|
|
98
|
+
discovered: dict[MixedState, MixedState] = {}
|
|
99
|
+
queue: deque[MixedState] = deque()
|
|
100
|
+
|
|
101
|
+
def register(state: MixedState) -> MixedState:
|
|
102
|
+
existing = discovered.get(state)
|
|
103
|
+
if existing is not None:
|
|
104
|
+
return existing
|
|
105
|
+
for known in discovered:
|
|
106
|
+
if _beliefs_equal(known, state, constraints=constraints):
|
|
107
|
+
discovered[state] = known
|
|
108
|
+
return known
|
|
109
|
+
if len(discovered) >= max_states:
|
|
110
|
+
raise StochasticValidationError(f"mixed-state presentation exceeded max_states={max_states}")
|
|
111
|
+
discovered[state] = state
|
|
112
|
+
graph.add_state(state)
|
|
113
|
+
queue.append(state)
|
|
114
|
+
return state
|
|
115
|
+
|
|
116
|
+
register(eta0)
|
|
117
|
+
|
|
118
|
+
while queue:
|
|
119
|
+
eta = queue.popleft()
|
|
120
|
+
row = eta.as_array()
|
|
121
|
+
for symbol in symbols:
|
|
122
|
+
matrix = joint[symbol]
|
|
123
|
+
mass = matvec(row, matrix)
|
|
124
|
+
probability = simplify_prob(sum_probs(mass.tolist()))
|
|
125
|
+
if not is_positive_mass(probability):
|
|
126
|
+
continue
|
|
127
|
+
successor = MixedState.from_vector(mass)
|
|
128
|
+
if successor is None:
|
|
129
|
+
continue
|
|
130
|
+
successor = register(successor)
|
|
131
|
+
graph.add_transition(
|
|
132
|
+
eta,
|
|
133
|
+
successor,
|
|
134
|
+
**{ATTR_PROB: as_prob(probability), ATTR_EMISSION: symbol},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
pure_states = frozenset(state for state in discovered.values() if is_pure_mixed_state(state))
|
|
138
|
+
unique_states = frozenset(discovered.values())
|
|
139
|
+
recurrent_states = _terminal_recurrent_states(graph) & unique_states
|
|
140
|
+
if not recurrent_states:
|
|
141
|
+
recurrent_states = _terminal_recurrent_states(graph)
|
|
142
|
+
reachable = frozenset(discovered.values())
|
|
143
|
+
transient_states = reachable - recurrent_states
|
|
144
|
+
|
|
145
|
+
return MixedStatePresentation(
|
|
146
|
+
graph=graph,
|
|
147
|
+
basis_states=basis,
|
|
148
|
+
initial_mixed_state=eta0,
|
|
149
|
+
pure_states=pure_states,
|
|
150
|
+
recurrent_states=frozenset(recurrent_states),
|
|
151
|
+
transient_states=frozenset(transient_states),
|
|
152
|
+
initial_distribution={eta0: as_prob(1)},
|
|
153
|
+
observation_alphabet=hmm.observation_alphabet,
|
|
154
|
+
symbol_constraints=constraints,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _beliefs_equal(left: MixedState, right: MixedState, *, constraints: Any = None) -> bool:
|
|
159
|
+
from sofic.generators.prob import probs_equal
|
|
160
|
+
|
|
161
|
+
if len(left.belief) != len(right.belief):
|
|
162
|
+
return False
|
|
163
|
+
return all(probs_equal(a, b, constraints=constraints) for a, b in zip(left.belief, right.belief, strict=True))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Moore-type hidden Markov models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from sofic.exceptions import StochasticValidationError
|
|
11
|
+
from sofic.generators.base import HiddenMarkovModel
|
|
12
|
+
from sofic.generators.mealy import MealyHMM
|
|
13
|
+
from sofic.graph import ATTR_EMISSION_DIST, ATTR_PROB
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from sofic.generators.lumping import LabelsLike, PartitionLike
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MooreHMM(HiddenMarkovModel):
|
|
20
|
+
"""HMM with P(o | q) on states and P(q' | q) on edges."""
|
|
21
|
+
|
|
22
|
+
def set_emission_distribution(self, state: Hashable, distribution: Mapping[Any, float]) -> None:
|
|
23
|
+
"""Set the state emission law ``P(observation | state)``."""
|
|
24
|
+
self.graph.nx.nodes[state][ATTR_EMISSION_DIST] = dict(distribution)
|
|
25
|
+
|
|
26
|
+
def add_transition(self, source: Hashable, target: Hashable, prob: float, **attrs: Any) -> int:
|
|
27
|
+
"""Add an edge carrying transition probability ``P(target | source)``."""
|
|
28
|
+
return self.graph.add_transition(source, target, **{ATTR_PROB: float(prob), **attrs})
|
|
29
|
+
|
|
30
|
+
def is_unifilar(self) -> bool:
|
|
31
|
+
"""Return whether the Mealy conversion is row-unifilar."""
|
|
32
|
+
return self.to_mealy().is_unifilar()
|
|
33
|
+
|
|
34
|
+
def to_mealy(self) -> MealyHMM:
|
|
35
|
+
from sofic.generators.conversions import moore_to_mealy
|
|
36
|
+
|
|
37
|
+
return moore_to_mealy(self)
|
|
38
|
+
|
|
39
|
+
def is_lumpable(self, partition: PartitionLike, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
|
|
40
|
+
"""Return whether ``partition`` is strongly lumpable for this HMM."""
|
|
41
|
+
from sofic.generators.lumping import is_lumpable
|
|
42
|
+
|
|
43
|
+
return is_lumpable(self, partition, rtol=rtol, atol=atol)
|
|
44
|
+
|
|
45
|
+
def lump(
|
|
46
|
+
self,
|
|
47
|
+
partition: PartitionLike,
|
|
48
|
+
*,
|
|
49
|
+
check: bool = True,
|
|
50
|
+
labels: LabelsLike | None = None,
|
|
51
|
+
rtol: float = 1e-8,
|
|
52
|
+
atol: float = 1e-10,
|
|
53
|
+
) -> MooreHMM:
|
|
54
|
+
"""Aggregate states into blocks, returning the lumped Moore HMM."""
|
|
55
|
+
from sofic.generators.lumping import lump
|
|
56
|
+
|
|
57
|
+
return lump(self, partition, check=check, labels=labels, rtol=rtol, atol=atol)
|
|
58
|
+
|
|
59
|
+
def validate_stochastic(self) -> None:
|
|
60
|
+
super().validate_stochastic()
|
|
61
|
+
for state in self.states():
|
|
62
|
+
attrs = self.graph.state_attrs(state)
|
|
63
|
+
emission_dist = attrs.get(ATTR_EMISSION_DIST)
|
|
64
|
+
if emission_dist is not None:
|
|
65
|
+
total = sum(emission_dist.values())
|
|
66
|
+
if not np.isclose(total, 1.0):
|
|
67
|
+
raise StochasticValidationError(f"emission distribution at {state!r} sums to {total}")
|
|
68
|
+
for symbol, prob in emission_dist.items():
|
|
69
|
+
if prob < 0:
|
|
70
|
+
raise StochasticValidationError(f"negative emission probability at {state!r}")
|
|
71
|
+
self._require(symbol in self.observation_alphabet, f"unknown emission {symbol!r}")
|
|
72
|
+
outgoing = list(self.graph.out_transitions(state))
|
|
73
|
+
trans_total = sum(t.data.get(ATTR_PROB, 0.0) for t in outgoing)
|
|
74
|
+
if outgoing and not np.isclose(trans_total, 1.0):
|
|
75
|
+
raise StochasticValidationError(f"transition probabilities from {state!r} sum to {trans_total}")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Negative machines (n-machines) with quasiprobabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from sofic.exceptions import QuasiStochasticValidationError
|
|
11
|
+
from sofic.generators.base import QuasiStochasticModel
|
|
12
|
+
from sofic.generators.epsilon_machine import EpsilonMachine
|
|
13
|
+
from sofic.graph import ATTR_EMISSION, ATTR_QUASIPROB
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from sofic.generators.quasi_realization import QuasiRealization
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NMachine(QuasiStochasticModel):
|
|
20
|
+
"""Mealy-type generator with signed joint quasiprobabilities on edges."""
|
|
21
|
+
|
|
22
|
+
observation_alphabet: frozenset[Any]
|
|
23
|
+
|
|
24
|
+
def __init__(self, observation_alphabet: frozenset[Any] | None = None, **kwargs: Any) -> None:
|
|
25
|
+
super().__init__(**kwargs)
|
|
26
|
+
self.observation_alphabet = observation_alphabet if observation_alphabet is not None else frozenset()
|
|
27
|
+
|
|
28
|
+
def validate_quasistochastic(self) -> None:
|
|
29
|
+
super().validate_quasistochastic()
|
|
30
|
+
for state in self.states():
|
|
31
|
+
outgoing = list(self.graph.out_transitions(state))
|
|
32
|
+
total = sum(t.data.get(ATTR_QUASIPROB, 0.0) for t in outgoing)
|
|
33
|
+
if outgoing and not np.isclose(total, 1.0):
|
|
34
|
+
raise QuasiStochasticValidationError(f"quasi masses from {state!r} sum to {total}")
|
|
35
|
+
marginals: dict[Any, float] = {}
|
|
36
|
+
for transition in outgoing:
|
|
37
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
38
|
+
if emission is None:
|
|
39
|
+
continue
|
|
40
|
+
marginals[emission] = marginals.get(emission, 0.0) + transition.data.get(ATTR_QUASIPROB, 0.0)
|
|
41
|
+
for emission, mass in marginals.items():
|
|
42
|
+
if mass < -1e-12:
|
|
43
|
+
raise QuasiStochasticValidationError(
|
|
44
|
+
f"negative marginal output probability P({emission!r}|{state!r})"
|
|
45
|
+
)
|
|
46
|
+
self._require(emission in self.observation_alphabet, f"unknown emission {emission!r}")
|
|
47
|
+
|
|
48
|
+
def is_unifilar(self) -> bool:
|
|
49
|
+
"""Return whether each state emits at most one edge per symbol."""
|
|
50
|
+
from sofic.properties import is_unifilar_emissions
|
|
51
|
+
|
|
52
|
+
return is_unifilar_emissions(self)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_epsilon_machine(
|
|
56
|
+
cls,
|
|
57
|
+
eps: EpsilonMachine,
|
|
58
|
+
splits: Mapping[Hashable, int] | None = None,
|
|
59
|
+
**kwargs: Any,
|
|
60
|
+
) -> NMachine:
|
|
61
|
+
from sofic.generators.nmachine_construction import build_nmachine
|
|
62
|
+
|
|
63
|
+
return build_nmachine(eps, splits)
|
|
64
|
+
|
|
65
|
+
def coarse_grained_distribution(self) -> dict[Hashable, float]:
|
|
66
|
+
from sofic.generators.nmachine_construction import coarse_grained_distribution
|
|
67
|
+
|
|
68
|
+
eps_states = tuple(
|
|
69
|
+
substate[0] for substate in self.states() if isinstance(substate, tuple) and len(substate) == 2
|
|
70
|
+
)
|
|
71
|
+
if not eps_states:
|
|
72
|
+
eps_states = tuple(self.states())
|
|
73
|
+
return coarse_grained_distribution(self, eps_states)
|
|
74
|
+
|
|
75
|
+
def to_quasi_realization(self) -> QuasiRealization:
|
|
76
|
+
from sofic.generators.quasi_realization import QuasiRealization
|
|
77
|
+
|
|
78
|
+
return QuasiRealization.from_nmachine(self)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""N-machine state-splitting construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Mapping
|
|
6
|
+
|
|
7
|
+
from sofic.generators.epsilon_machine import EpsilonMachine
|
|
8
|
+
from sofic.generators.nmachine import NMachine
|
|
9
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB, ATTR_QUASIPROB, TransitionGraph
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_nmachine(
|
|
13
|
+
eps: EpsilonMachine,
|
|
14
|
+
splits: Mapping[Hashable, int] | None = None,
|
|
15
|
+
) -> NMachine:
|
|
16
|
+
split_counts = dict(splits or {})
|
|
17
|
+
for state in eps.states():
|
|
18
|
+
split_counts.setdefault(state, 1)
|
|
19
|
+
|
|
20
|
+
graph = TransitionGraph()
|
|
21
|
+
coarse_map: dict[tuple[Hashable, int], Hashable] = {}
|
|
22
|
+
for state in eps.states():
|
|
23
|
+
count = split_counts[state]
|
|
24
|
+
for branch in range(count):
|
|
25
|
+
substate = (state, branch)
|
|
26
|
+
graph.add_state(substate)
|
|
27
|
+
coarse_map[substate] = state
|
|
28
|
+
|
|
29
|
+
pi = eps.stationary_distribution()
|
|
30
|
+
idx = eps.reindex()
|
|
31
|
+
initial: dict[tuple[Hashable, int], float] = {}
|
|
32
|
+
for state in eps.states():
|
|
33
|
+
count = split_counts[state]
|
|
34
|
+
mass = float(pi[idx.index(state)]) / count
|
|
35
|
+
for branch in range(count):
|
|
36
|
+
initial[(state, branch)] = mass
|
|
37
|
+
|
|
38
|
+
for state in eps.states():
|
|
39
|
+
count = split_counts[state]
|
|
40
|
+
outgoing = list(eps.graph.out_transitions(state))
|
|
41
|
+
for branch in range(count):
|
|
42
|
+
source = (state, branch)
|
|
43
|
+
for transition in outgoing:
|
|
44
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
45
|
+
target_state = transition.target
|
|
46
|
+
target_branch = branch % split_counts[target_state]
|
|
47
|
+
prob = float(transition.data.get(ATTR_PROB, 0.0))
|
|
48
|
+
graph.add_transition(
|
|
49
|
+
source,
|
|
50
|
+
(target_state, target_branch),
|
|
51
|
+
**{ATTR_QUASIPROB: prob, ATTR_EMISSION: emission},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return NMachine(
|
|
55
|
+
graph=graph,
|
|
56
|
+
initial_quasidistribution=initial,
|
|
57
|
+
observation_alphabet=eps.observation_alphabet,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def coarse_grained_distribution(nm: NMachine, eps_states: tuple[Hashable, ...]) -> dict[Hashable, float]:
|
|
62
|
+
pi = nm.stationary_quasidistribution()
|
|
63
|
+
idx = nm.reindex()
|
|
64
|
+
coarse: dict[Hashable, float] = dict.fromkeys(eps_states, 0.0)
|
|
65
|
+
for substate, mass in zip(idx.states, pi, strict=False):
|
|
66
|
+
if isinstance(substate, tuple) and len(substate) == 2:
|
|
67
|
+
coarse[substate[0]] = coarse.get(substate[0], 0.0) + float(mass)
|
|
68
|
+
else:
|
|
69
|
+
coarse[substate] = coarse.get(substate, 0.0) + float(mass)
|
|
70
|
+
return coarse
|