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,447 @@
|
|
|
1
|
+
"""Lumping (state aggregation) of Markov chains and hidden Markov models.
|
|
2
|
+
|
|
3
|
+
Strong lumpability in the sense of Kemeny & Snell (:cite:`KemenySnell1976`,
|
|
4
|
+
Ch. 6): a partition of the state space is *strongly lumpable* when, for every
|
|
5
|
+
block, the total probability of moving into each target block is identical for
|
|
6
|
+
every state in the source block. That common value defines the transition law of
|
|
7
|
+
the coarser lumped chain, and -- because the condition constrains only the
|
|
8
|
+
transition matrix -- it holds for every initial distribution.
|
|
9
|
+
|
|
10
|
+
Hidden Markov models extend the condition to each emitted symbol so that the
|
|
11
|
+
lumped model generates the same observed process:
|
|
12
|
+
|
|
13
|
+
* :class:`~sofic.generators.mealy.MealyHMM` -- the joint block-and-symbol mass
|
|
14
|
+
``sum_{t in B_j} P(t, o | s)`` must be constant across ``s`` in a block.
|
|
15
|
+
* :class:`~sofic.generators.moore.MooreHMM` -- additionally the state emission
|
|
16
|
+
law ``P(o | s)`` must be identical across a block.
|
|
17
|
+
|
|
18
|
+
The public entry points are :func:`is_lumpable` (predicate) and :func:`lump`
|
|
19
|
+
(constructor). ``lump`` raises :class:`~sofic.exceptions.LumpabilityError` when
|
|
20
|
+
the partition is not strongly lumpable unless ``check=False``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable, Hashable, Iterable, Mapping
|
|
26
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from sofic.exceptions import LumpabilityError
|
|
31
|
+
from sofic.graph import ATTR_EMISSION, ATTR_EMISSION_DIST, ATTR_PROB, TransitionGraph
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from sofic.base import StateMachine
|
|
35
|
+
from sofic.generators.markov import MarkovChain
|
|
36
|
+
from sofic.generators.mealy import MealyHMM
|
|
37
|
+
from sofic.generators.moore import MooreHMM
|
|
38
|
+
|
|
39
|
+
PartitionLike = Iterable[Iterable[Hashable]] | Mapping[Hashable, Hashable]
|
|
40
|
+
LabelsLike = Mapping[frozenset[Hashable], Hashable] | Callable[[frozenset[Hashable]], Hashable]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_partition(model: StateMachine, partition: PartitionLike) -> list[frozenset[Hashable]]:
|
|
44
|
+
"""Return ``partition`` as an ordered list of disjoint, covering blocks.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
model
|
|
49
|
+
The model whose states the partition must cover exactly.
|
|
50
|
+
partition
|
|
51
|
+
Either an iterable of blocks (each an iterable of states) or a mapping
|
|
52
|
+
from each state to a block key.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
list of frozenset
|
|
57
|
+
Blocks ordered by the position of their first member in ``model.states()``.
|
|
58
|
+
|
|
59
|
+
Raises
|
|
60
|
+
------
|
|
61
|
+
ValueError
|
|
62
|
+
If the blocks overlap, reference unknown states, or fail to cover every
|
|
63
|
+
state of ``model``.
|
|
64
|
+
"""
|
|
65
|
+
order = {state: index for index, state in enumerate(model.states())}
|
|
66
|
+
state_set = set(order)
|
|
67
|
+
|
|
68
|
+
if isinstance(partition, Mapping):
|
|
69
|
+
grouped: dict[Hashable, set[Hashable]] = {}
|
|
70
|
+
for state, key in partition.items():
|
|
71
|
+
grouped.setdefault(key, set()).add(state)
|
|
72
|
+
raw_blocks: Iterable[Iterable[Hashable]] = grouped.values()
|
|
73
|
+
else:
|
|
74
|
+
raw_blocks = partition
|
|
75
|
+
|
|
76
|
+
seen: set[Hashable] = set()
|
|
77
|
+
blocks: list[frozenset[Hashable]] = []
|
|
78
|
+
for raw in raw_blocks:
|
|
79
|
+
block = frozenset(raw)
|
|
80
|
+
if not block:
|
|
81
|
+
continue
|
|
82
|
+
unknown = block - state_set
|
|
83
|
+
if unknown:
|
|
84
|
+
raise ValueError(f"partition references unknown states {sorted(map(str, unknown))}")
|
|
85
|
+
overlap = block & seen
|
|
86
|
+
if overlap:
|
|
87
|
+
raise ValueError(f"partition blocks overlap on states {sorted(map(str, overlap))}")
|
|
88
|
+
seen |= block
|
|
89
|
+
blocks.append(block)
|
|
90
|
+
|
|
91
|
+
missing = state_set - seen
|
|
92
|
+
if missing:
|
|
93
|
+
raise ValueError(f"partition does not cover states {sorted(map(str, missing))}")
|
|
94
|
+
|
|
95
|
+
blocks.sort(key=lambda block: min(order[state] for state in block))
|
|
96
|
+
return blocks
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _block_of(blocks: list[frozenset[Hashable]]) -> dict[Hashable, int]:
|
|
100
|
+
return {state: index for index, block in enumerate(blocks) for state in block}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _representatives(model: StateMachine, blocks: list[frozenset[Hashable]]) -> list[Hashable]:
|
|
104
|
+
order = {state: index for index, state in enumerate(model.states())}
|
|
105
|
+
return [min(block, key=lambda state: order[state]) for block in blocks]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _default_label(block: frozenset[Hashable]) -> Hashable:
|
|
109
|
+
if len(block) == 1:
|
|
110
|
+
return next(iter(block))
|
|
111
|
+
return "+".join(sorted(str(state) for state in block))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_labels(blocks: list[frozenset[Hashable]], labels: LabelsLike | None) -> list[Hashable]:
|
|
115
|
+
if labels is None:
|
|
116
|
+
resolved = [_default_label(block) for block in blocks]
|
|
117
|
+
elif isinstance(labels, Mapping):
|
|
118
|
+
resolved = [labels.get(block, _default_label(block)) for block in blocks]
|
|
119
|
+
else:
|
|
120
|
+
resolved = [labels(block) for block in blocks]
|
|
121
|
+
if len(set(resolved)) != len(resolved):
|
|
122
|
+
raise ValueError(f"block labels are not distinct: {resolved}")
|
|
123
|
+
return resolved
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _markov_signature(
|
|
127
|
+
model: StateMachine, state: Hashable, block_of: dict[Hashable, int], num_blocks: int
|
|
128
|
+
) -> np.ndarray:
|
|
129
|
+
signature = np.zeros(num_blocks, dtype=float)
|
|
130
|
+
for transition in model.graph.out_transitions(state):
|
|
131
|
+
target_block = block_of.get(transition.target)
|
|
132
|
+
if target_block is None:
|
|
133
|
+
continue
|
|
134
|
+
signature[target_block] += float(transition.data.get(ATTR_PROB, 0.0))
|
|
135
|
+
return signature
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _mealy_signature(
|
|
139
|
+
model: StateMachine, state: Hashable, block_of: dict[Hashable, int]
|
|
140
|
+
) -> dict[tuple[int, Any], float]:
|
|
141
|
+
signature: dict[tuple[int, Any], float] = {}
|
|
142
|
+
for transition in model.graph.out_transitions(state):
|
|
143
|
+
target_block = block_of.get(transition.target)
|
|
144
|
+
if target_block is None:
|
|
145
|
+
continue
|
|
146
|
+
key = (target_block, transition.data.get(ATTR_EMISSION))
|
|
147
|
+
signature[key] = signature.get(key, 0.0) + float(transition.data.get(ATTR_PROB, 0.0))
|
|
148
|
+
return signature
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _emission_dist(model: StateMachine, state: Hashable) -> dict[Any, float]:
|
|
152
|
+
return dict(model.graph.state_attrs(state).get(ATTR_EMISSION_DIST) or {})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _dicts_close(left: Mapping[Any, float], right: Mapping[Any, float], *, rtol: float, atol: float) -> bool:
|
|
156
|
+
for key in set(left) | set(right):
|
|
157
|
+
if not np.isclose(left.get(key, 0.0), right.get(key, 0.0), rtol=rtol, atol=atol):
|
|
158
|
+
return False
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_lumpable_markov(model: MarkovChain, blocks: list[frozenset[Hashable]], *, rtol: float, atol: float) -> bool:
|
|
163
|
+
block_of = _block_of(blocks)
|
|
164
|
+
num_blocks = len(blocks)
|
|
165
|
+
for block in blocks:
|
|
166
|
+
members = iter(block)
|
|
167
|
+
reference = _markov_signature(model, next(members), block_of, num_blocks)
|
|
168
|
+
for state in members:
|
|
169
|
+
if not np.allclose(_markov_signature(model, state, block_of, num_blocks), reference, rtol=rtol, atol=atol):
|
|
170
|
+
return False
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _is_lumpable_mealy(model: MealyHMM, blocks: list[frozenset[Hashable]], *, rtol: float, atol: float) -> bool:
|
|
175
|
+
block_of = _block_of(blocks)
|
|
176
|
+
for block in blocks:
|
|
177
|
+
members = iter(block)
|
|
178
|
+
reference = _mealy_signature(model, next(members), block_of)
|
|
179
|
+
for state in members:
|
|
180
|
+
if not _dicts_close(_mealy_signature(model, state, block_of), reference, rtol=rtol, atol=atol):
|
|
181
|
+
return False
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _is_lumpable_moore(model: MooreHMM, blocks: list[frozenset[Hashable]], *, rtol: float, atol: float) -> bool:
|
|
186
|
+
block_of = _block_of(blocks)
|
|
187
|
+
num_blocks = len(blocks)
|
|
188
|
+
for block in blocks:
|
|
189
|
+
members = iter(block)
|
|
190
|
+
first = next(members)
|
|
191
|
+
reference_emit = _emission_dist(model, first)
|
|
192
|
+
reference_trans = _markov_signature(model, first, block_of, num_blocks)
|
|
193
|
+
for state in members:
|
|
194
|
+
if not _dicts_close(_emission_dist(model, state), reference_emit, rtol=rtol, atol=atol):
|
|
195
|
+
return False
|
|
196
|
+
if not np.allclose(
|
|
197
|
+
_markov_signature(model, state, block_of, num_blocks), reference_trans, rtol=rtol, atol=atol
|
|
198
|
+
):
|
|
199
|
+
return False
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _lumped_initial(
|
|
204
|
+
model: StateMachine, block_of: dict[Hashable, int], labels: list[Hashable]
|
|
205
|
+
) -> dict[Hashable, float]:
|
|
206
|
+
initial: dict[Hashable, float] = {}
|
|
207
|
+
for state, mass in getattr(model, "initial_distribution", {}).items():
|
|
208
|
+
label = labels[block_of[state]]
|
|
209
|
+
initial[label] = initial.get(label, 0.0) + float(mass)
|
|
210
|
+
return initial
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _lump_markov(model: MarkovChain, blocks: list[frozenset[Hashable]], labels: LabelsLike | None) -> MarkovChain:
|
|
214
|
+
from sofic.generators.markov import MarkovChain
|
|
215
|
+
|
|
216
|
+
resolved = _resolve_labels(blocks, labels)
|
|
217
|
+
block_of = _block_of(blocks)
|
|
218
|
+
representatives = _representatives(model, blocks)
|
|
219
|
+
|
|
220
|
+
graph = TransitionGraph()
|
|
221
|
+
for label in resolved:
|
|
222
|
+
graph.add_state(label)
|
|
223
|
+
result = MarkovChain(graph=graph)
|
|
224
|
+
|
|
225
|
+
for index, representative in enumerate(representatives):
|
|
226
|
+
merged: dict[int, float] = {}
|
|
227
|
+
for transition in model.graph.out_transitions(representative):
|
|
228
|
+
target_block = block_of.get(transition.target)
|
|
229
|
+
if target_block is None:
|
|
230
|
+
continue
|
|
231
|
+
merged[target_block] = merged.get(target_block, 0.0) + float(transition.data.get(ATTR_PROB, 0.0))
|
|
232
|
+
for target_block, prob in merged.items():
|
|
233
|
+
result.add_transition(resolved[index], resolved[target_block], prob)
|
|
234
|
+
|
|
235
|
+
result.initial_distribution = _lumped_initial(model, block_of, resolved)
|
|
236
|
+
result.validate()
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _lump_mealy(model: MealyHMM, blocks: list[frozenset[Hashable]], labels: LabelsLike | None) -> MealyHMM:
|
|
241
|
+
from sofic.generators.mealy import MealyHMM
|
|
242
|
+
|
|
243
|
+
resolved = _resolve_labels(blocks, labels)
|
|
244
|
+
block_of = _block_of(blocks)
|
|
245
|
+
representatives = _representatives(model, blocks)
|
|
246
|
+
|
|
247
|
+
graph = TransitionGraph()
|
|
248
|
+
for label in resolved:
|
|
249
|
+
graph.add_state(label)
|
|
250
|
+
result = MealyHMM(graph=graph, observation_alphabet=model.observation_alphabet)
|
|
251
|
+
|
|
252
|
+
for index, representative in enumerate(representatives):
|
|
253
|
+
merged: dict[tuple[int, Any], float] = {}
|
|
254
|
+
for transition in model.graph.out_transitions(representative):
|
|
255
|
+
target_block = block_of.get(transition.target)
|
|
256
|
+
if target_block is None:
|
|
257
|
+
continue
|
|
258
|
+
key = (target_block, transition.data.get(ATTR_EMISSION))
|
|
259
|
+
merged[key] = merged.get(key, 0.0) + float(transition.data.get(ATTR_PROB, 0.0))
|
|
260
|
+
for (target_block, emission), prob in merged.items():
|
|
261
|
+
result.add_transition(resolved[index], resolved[target_block], emission, prob)
|
|
262
|
+
|
|
263
|
+
result.initial_distribution = _lumped_initial(model, block_of, resolved)
|
|
264
|
+
result.validate()
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _lump_moore(model: MooreHMM, blocks: list[frozenset[Hashable]], labels: LabelsLike | None) -> MooreHMM:
|
|
269
|
+
from sofic.generators.moore import MooreHMM
|
|
270
|
+
|
|
271
|
+
resolved = _resolve_labels(blocks, labels)
|
|
272
|
+
block_of = _block_of(blocks)
|
|
273
|
+
representatives = _representatives(model, blocks)
|
|
274
|
+
|
|
275
|
+
graph = TransitionGraph()
|
|
276
|
+
for label in resolved:
|
|
277
|
+
graph.add_state(label)
|
|
278
|
+
result = MooreHMM(graph=graph, observation_alphabet=model.observation_alphabet)
|
|
279
|
+
|
|
280
|
+
for index, representative in enumerate(representatives):
|
|
281
|
+
emission = _emission_dist(model, representative)
|
|
282
|
+
if emission:
|
|
283
|
+
result.set_emission_distribution(resolved[index], emission)
|
|
284
|
+
merged: dict[int, float] = {}
|
|
285
|
+
for transition in model.graph.out_transitions(representative):
|
|
286
|
+
target_block = block_of.get(transition.target)
|
|
287
|
+
if target_block is None:
|
|
288
|
+
continue
|
|
289
|
+
merged[target_block] = merged.get(target_block, 0.0) + float(transition.data.get(ATTR_PROB, 0.0))
|
|
290
|
+
for target_block, prob in merged.items():
|
|
291
|
+
result.add_transition(resolved[index], resolved[target_block], prob)
|
|
292
|
+
|
|
293
|
+
result.initial_distribution = _lumped_initial(model, block_of, resolved)
|
|
294
|
+
result.validate()
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _dispatch(model: StateMachine) -> tuple[Callable[..., bool], Callable[..., Any]]:
|
|
299
|
+
from sofic.generators.markov import MarkovChain
|
|
300
|
+
from sofic.generators.mealy import MealyHMM
|
|
301
|
+
from sofic.generators.moore import MooreHMM
|
|
302
|
+
|
|
303
|
+
if isinstance(model, MarkovChain):
|
|
304
|
+
return _is_lumpable_markov, _lump_markov
|
|
305
|
+
if isinstance(model, MooreHMM):
|
|
306
|
+
return _is_lumpable_moore, _lump_moore
|
|
307
|
+
if isinstance(model, MealyHMM):
|
|
308
|
+
return _is_lumpable_mealy, _lump_mealy
|
|
309
|
+
raise TypeError(f"lumping is not supported for {type(model).__name__}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def is_lumpable(model: StateMachine, partition: PartitionLike, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
|
|
313
|
+
"""Return whether ``partition`` is strongly lumpable for ``model``.
|
|
314
|
+
|
|
315
|
+
Strong lumpability follows Kemeny & Snell (:cite:`KemenySnell1976`): the
|
|
316
|
+
aggregated mass into each block must not depend on which state of a block the
|
|
317
|
+
chain occupies. For hidden Markov models the condition is imposed per emitted
|
|
318
|
+
symbol (and, for Moore presentations, additionally on the state emission law).
|
|
319
|
+
|
|
320
|
+
Parameters
|
|
321
|
+
----------
|
|
322
|
+
model
|
|
323
|
+
A :class:`~sofic.generators.markov.MarkovChain`,
|
|
324
|
+
:class:`~sofic.generators.mealy.MealyHMM` (including
|
|
325
|
+
:class:`~sofic.generators.epsilon_machine.EpsilonMachine`), or
|
|
326
|
+
:class:`~sofic.generators.moore.MooreHMM`.
|
|
327
|
+
partition
|
|
328
|
+
Blocks (iterable of iterables) or a state-to-block mapping; must cover
|
|
329
|
+
every state exactly.
|
|
330
|
+
rtol, atol
|
|
331
|
+
Tolerances forwarded to :func:`numpy.isclose`.
|
|
332
|
+
|
|
333
|
+
Raises
|
|
334
|
+
------
|
|
335
|
+
TypeError
|
|
336
|
+
If ``model`` is not a supported generator type.
|
|
337
|
+
ValueError
|
|
338
|
+
If ``partition`` is not a valid partition of ``model``'s states.
|
|
339
|
+
"""
|
|
340
|
+
checker, _builder = _dispatch(model)
|
|
341
|
+
blocks = normalize_partition(model, partition)
|
|
342
|
+
return checker(model, blocks, rtol=rtol, atol=atol)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@overload
|
|
346
|
+
def lump(
|
|
347
|
+
model: MarkovChain,
|
|
348
|
+
partition: PartitionLike,
|
|
349
|
+
*,
|
|
350
|
+
check: bool = ...,
|
|
351
|
+
labels: LabelsLike | None = ...,
|
|
352
|
+
rtol: float = ...,
|
|
353
|
+
atol: float = ...,
|
|
354
|
+
) -> MarkovChain: ...
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@overload
|
|
358
|
+
def lump(
|
|
359
|
+
model: MooreHMM,
|
|
360
|
+
partition: PartitionLike,
|
|
361
|
+
*,
|
|
362
|
+
check: bool = ...,
|
|
363
|
+
labels: LabelsLike | None = ...,
|
|
364
|
+
rtol: float = ...,
|
|
365
|
+
atol: float = ...,
|
|
366
|
+
) -> MooreHMM: ...
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@overload
|
|
370
|
+
def lump(
|
|
371
|
+
model: MealyHMM,
|
|
372
|
+
partition: PartitionLike,
|
|
373
|
+
*,
|
|
374
|
+
check: bool = ...,
|
|
375
|
+
labels: LabelsLike | None = ...,
|
|
376
|
+
rtol: float = ...,
|
|
377
|
+
atol: float = ...,
|
|
378
|
+
) -> MealyHMM: ...
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@overload
|
|
382
|
+
def lump(
|
|
383
|
+
model: StateMachine,
|
|
384
|
+
partition: PartitionLike,
|
|
385
|
+
*,
|
|
386
|
+
check: bool = ...,
|
|
387
|
+
labels: LabelsLike | None = ...,
|
|
388
|
+
rtol: float = ...,
|
|
389
|
+
atol: float = ...,
|
|
390
|
+
) -> StateMachine: ...
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def lump(
|
|
394
|
+
model: StateMachine,
|
|
395
|
+
partition: PartitionLike,
|
|
396
|
+
*,
|
|
397
|
+
check: bool = True,
|
|
398
|
+
labels: LabelsLike | None = None,
|
|
399
|
+
rtol: float = 1e-8,
|
|
400
|
+
atol: float = 1e-10,
|
|
401
|
+
) -> StateMachine:
|
|
402
|
+
"""Aggregate the states of ``model`` according to ``partition``.
|
|
403
|
+
|
|
404
|
+
Builds the coarser lumped model (Kemeny & Snell, :cite:`KemenySnell1976`):
|
|
405
|
+
block-to-block transition masses are read from a block representative, and
|
|
406
|
+
initial masses are summed within blocks. A
|
|
407
|
+
:class:`~sofic.generators.markov.MarkovChain` lumps to a ``MarkovChain``, a
|
|
408
|
+
:class:`~sofic.generators.moore.MooreHMM` to a ``MooreHMM``, and any
|
|
409
|
+
:class:`~sofic.generators.mealy.MealyHMM` (including an
|
|
410
|
+
:class:`~sofic.generators.epsilon_machine.EpsilonMachine`) to a plain
|
|
411
|
+
``MealyHMM`` -- lumping may break unifilarity, so the stricter subtype is not
|
|
412
|
+
preserved.
|
|
413
|
+
|
|
414
|
+
Parameters
|
|
415
|
+
----------
|
|
416
|
+
model
|
|
417
|
+
The generator to lump.
|
|
418
|
+
partition
|
|
419
|
+
Blocks (iterable of iterables) or a state-to-block mapping; must cover
|
|
420
|
+
every state exactly.
|
|
421
|
+
check
|
|
422
|
+
When ``True`` (default), raise :class:`~sofic.exceptions.LumpabilityError`
|
|
423
|
+
if ``partition`` is not strongly lumpable. When ``False``, build the model
|
|
424
|
+
anyway from each block's representative row (the result is exact only when
|
|
425
|
+
the partition is in fact lumpable).
|
|
426
|
+
labels
|
|
427
|
+
Optional mapping from a block (as a ``frozenset``) to its lumped-state
|
|
428
|
+
label, or a callable taking a block and returning a label. By default a
|
|
429
|
+
singleton block keeps its original state label and a merged block becomes
|
|
430
|
+
the ``"+"``-joined string of its members' labels.
|
|
431
|
+
rtol, atol
|
|
432
|
+
Tolerances forwarded to :func:`numpy.isclose` for the lumpability check.
|
|
433
|
+
|
|
434
|
+
Raises
|
|
435
|
+
------
|
|
436
|
+
LumpabilityError
|
|
437
|
+
If ``check`` and ``partition`` is not strongly lumpable.
|
|
438
|
+
TypeError
|
|
439
|
+
If ``model`` is not a supported generator type.
|
|
440
|
+
ValueError
|
|
441
|
+
If ``partition`` is invalid or the resolved block labels collide.
|
|
442
|
+
"""
|
|
443
|
+
checker, builder = _dispatch(model)
|
|
444
|
+
blocks = normalize_partition(model, partition)
|
|
445
|
+
if check and not checker(model, blocks, rtol=rtol, atol=atol):
|
|
446
|
+
raise LumpabilityError("partition is not strongly lumpable for this model")
|
|
447
|
+
return builder(model, blocks, labels)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Markov chains (visible-state generators)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable
|
|
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 StochasticModel
|
|
12
|
+
from sofic.graph import ATTR_PROB
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from sofic.generators.lumping import LabelsLike, PartitionLike
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MarkovChain(StochasticModel):
|
|
19
|
+
"""Visible-state Markov process without an observation layer."""
|
|
20
|
+
|
|
21
|
+
def add_transition(self, source: Hashable, target: Hashable, prob: float, **attrs: Any) -> int:
|
|
22
|
+
"""Add an edge carrying transition probability ``P(target | source)``."""
|
|
23
|
+
return self.graph.add_transition(source, target, **{ATTR_PROB: float(prob), **attrs})
|
|
24
|
+
|
|
25
|
+
def is_deterministic(self) -> bool:
|
|
26
|
+
"""Return whether each state has a single successor with probability 1."""
|
|
27
|
+
from sofic.properties import is_deterministic_markov
|
|
28
|
+
|
|
29
|
+
return is_deterministic_markov(self)
|
|
30
|
+
|
|
31
|
+
def is_lumpable(self, partition: PartitionLike, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
|
|
32
|
+
"""Return whether ``partition`` is strongly lumpable for this chain."""
|
|
33
|
+
from sofic.generators.lumping import is_lumpable
|
|
34
|
+
|
|
35
|
+
return is_lumpable(self, partition, rtol=rtol, atol=atol)
|
|
36
|
+
|
|
37
|
+
def lump(
|
|
38
|
+
self,
|
|
39
|
+
partition: PartitionLike,
|
|
40
|
+
*,
|
|
41
|
+
check: bool = True,
|
|
42
|
+
labels: LabelsLike | None = None,
|
|
43
|
+
rtol: float = 1e-8,
|
|
44
|
+
atol: float = 1e-10,
|
|
45
|
+
) -> MarkovChain:
|
|
46
|
+
"""Aggregate states into blocks, returning the lumped chain."""
|
|
47
|
+
from sofic.generators.lumping import lump
|
|
48
|
+
|
|
49
|
+
return lump(self, partition, check=check, labels=labels, rtol=rtol, atol=atol)
|
|
50
|
+
|
|
51
|
+
def validate_stochastic(self) -> None:
|
|
52
|
+
super().validate_stochastic()
|
|
53
|
+
for state in self.states():
|
|
54
|
+
outgoing = list(self.graph.out_transitions(state))
|
|
55
|
+
total = sum(t.data.get(ATTR_PROB, 0.0) for t in outgoing)
|
|
56
|
+
if outgoing and not np.isclose(total, 1.0):
|
|
57
|
+
raise StochasticValidationError(f"transition probabilities from {state!r} sum to {total}")
|
|
58
|
+
for transition in outgoing:
|
|
59
|
+
prob = transition.data.get(ATTR_PROB, 0.0)
|
|
60
|
+
if prob < 0:
|
|
61
|
+
raise StochasticValidationError(f"negative transition probability on {transition}")
|
|
62
|
+
|
|
63
|
+
def stationary_distribution(self) -> np.ndarray:
|
|
64
|
+
from sofic.generators.stationary import stationary_distribution_from_transition
|
|
65
|
+
from sofic.properties import transition_matrix
|
|
66
|
+
|
|
67
|
+
idx = self.reindex()
|
|
68
|
+
if len(idx) == 0:
|
|
69
|
+
return np.array([], dtype=float)
|
|
70
|
+
transition, _states = transition_matrix(self, attr=ATTR_PROB, states=idx.states)
|
|
71
|
+
return stationary_distribution_from_transition(transition)
|
|
72
|
+
|
|
73
|
+
def entropy_rate(self) -> float:
|
|
74
|
+
from sofic.generators.measures import entropy_rate_markov
|
|
75
|
+
|
|
76
|
+
return entropy_rate_markov(self)
|
|
77
|
+
|
|
78
|
+
def words_of_length(self, length: int) -> dict[tuple[Hashable, ...], float]:
|
|
79
|
+
"""Return visible state paths of ``length`` and their probabilities."""
|
|
80
|
+
from sofic.generators.words import markov_words_of_length
|
|
81
|
+
|
|
82
|
+
return markov_words_of_length(self, length)
|
|
83
|
+
|
|
84
|
+
def sample_path(self, n: int, rng: np.random.Generator | None = None) -> list[Hashable]:
|
|
85
|
+
generator = rng if rng is not None else np.random.default_rng()
|
|
86
|
+
idx = self.reindex()
|
|
87
|
+
pi = self.stationary_distribution()
|
|
88
|
+
state = int(generator.choice(len(idx), p=pi))
|
|
89
|
+
|
|
90
|
+
path: list[Hashable] = []
|
|
91
|
+
for _ in range(n):
|
|
92
|
+
path.append(idx.state(state))
|
|
93
|
+
outgoing = list(self.graph.out_transitions(idx.state(state)))
|
|
94
|
+
probs = np.array([float(t.data.get(ATTR_PROB, 0.0)) for t in outgoing], dtype=float)
|
|
95
|
+
if probs.sum() <= 0.0:
|
|
96
|
+
break
|
|
97
|
+
probs /= probs.sum()
|
|
98
|
+
choice = int(generator.choice(len(outgoing), p=probs))
|
|
99
|
+
state = idx.index(outgoing[choice].target)
|
|
100
|
+
return path
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Mealy-type hidden Markov models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from sofic.exceptions import UnifilarityError
|
|
9
|
+
from sofic.generators.base import HiddenMarkovModel
|
|
10
|
+
from sofic.generators.edge_emissions import validate_stochastic_edge_emissions
|
|
11
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from sofic.generators.lumping import LabelsLike, PartitionLike
|
|
15
|
+
from sofic.generators.mixed_state import MixedState, MixedStatePresentation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MealyHMM(HiddenMarkovModel):
|
|
19
|
+
"""HMM with joint transition-emission law P(q', o | q) on edges.
|
|
20
|
+
|
|
21
|
+
Each outgoing edge carries an emission symbol and a probability. Row sums at
|
|
22
|
+
every state must equal 1. Use :meth:`mixed_state_presentation` to obtain
|
|
23
|
+
belief-state dynamics, or :meth:`~sofic.generators.epsilon_machine.EpsilonMachine.from_hmm`
|
|
24
|
+
for the causal ε-machine presentation.
|
|
25
|
+
|
|
26
|
+
Examples
|
|
27
|
+
--------
|
|
28
|
+
>>> from sofic.examples import golden_mean
|
|
29
|
+
>>> eps = golden_mean(0.5)
|
|
30
|
+
>>> eps.entropy_rate() > 0
|
|
31
|
+
True
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def add_transition(self, source: Hashable, target: Hashable, symbol: Any, prob: float, **attrs: Any) -> int:
|
|
35
|
+
"""Add an edge carrying joint emission probability ``P(target, symbol | source)``.
|
|
36
|
+
|
|
37
|
+
``prob`` may be a Python float or an exact sympy expression (see
|
|
38
|
+
:mod:`sofic.generators.prob`).
|
|
39
|
+
"""
|
|
40
|
+
from sofic.generators.prob import as_prob
|
|
41
|
+
|
|
42
|
+
return self.graph.add_transition(
|
|
43
|
+
source,
|
|
44
|
+
target,
|
|
45
|
+
**{ATTR_EMISSION: symbol, ATTR_PROB: as_prob(prob), **attrs},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def validate_stochastic(self) -> None:
|
|
49
|
+
super().validate_stochastic()
|
|
50
|
+
validate_stochastic_edge_emissions(
|
|
51
|
+
self,
|
|
52
|
+
alphabet=self.observation_alphabet,
|
|
53
|
+
alphabet_name="observation",
|
|
54
|
+
row_mass_label="joint masses",
|
|
55
|
+
negative_probability_label="negative joint probability",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def to_mealy(self) -> MealyHMM:
|
|
59
|
+
"""Return this already-Mealy presentation."""
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def _check_unifilar(self) -> None:
|
|
63
|
+
if self.is_unifilar():
|
|
64
|
+
return
|
|
65
|
+
seen: set[tuple[Hashable, Any]] = set()
|
|
66
|
+
for transition in self.transitions():
|
|
67
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
68
|
+
if emission is None:
|
|
69
|
+
continue
|
|
70
|
+
key = (transition.source, emission)
|
|
71
|
+
if key in seen:
|
|
72
|
+
raise UnifilarityError(f"duplicate emission {emission!r} from state {transition.source!r}")
|
|
73
|
+
seen.add(key)
|
|
74
|
+
|
|
75
|
+
def is_unifilar(self) -> bool:
|
|
76
|
+
"""Return whether each state emits at most one edge per symbol."""
|
|
77
|
+
from sofic.properties import is_unifilar_emissions
|
|
78
|
+
|
|
79
|
+
return is_unifilar_emissions(self)
|
|
80
|
+
|
|
81
|
+
def is_counifilar(self) -> bool:
|
|
82
|
+
"""Return whether each ``(target, emission)`` identifies a unique source."""
|
|
83
|
+
from sofic.properties import is_counifilar_emissions
|
|
84
|
+
|
|
85
|
+
return is_counifilar_emissions(self)
|
|
86
|
+
|
|
87
|
+
def is_lumpable(self, partition: PartitionLike, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
|
|
88
|
+
"""Return whether ``partition`` is strongly lumpable for this HMM."""
|
|
89
|
+
from sofic.generators.lumping import is_lumpable
|
|
90
|
+
|
|
91
|
+
return is_lumpable(self, partition, rtol=rtol, atol=atol)
|
|
92
|
+
|
|
93
|
+
def lump(
|
|
94
|
+
self,
|
|
95
|
+
partition: PartitionLike,
|
|
96
|
+
*,
|
|
97
|
+
check: bool = True,
|
|
98
|
+
labels: LabelsLike | None = None,
|
|
99
|
+
rtol: float = 1e-8,
|
|
100
|
+
atol: float = 1e-10,
|
|
101
|
+
) -> MealyHMM:
|
|
102
|
+
"""Aggregate states into blocks, returning the lumped Mealy HMM."""
|
|
103
|
+
from sofic.generators.lumping import lump
|
|
104
|
+
|
|
105
|
+
return lump(self, partition, check=check, labels=labels, rtol=rtol, atol=atol)
|
|
106
|
+
|
|
107
|
+
def is_irreducible(self) -> bool:
|
|
108
|
+
"""Return whether the internal state graph is strongly connected."""
|
|
109
|
+
from sofic.properties import is_irreducible
|
|
110
|
+
|
|
111
|
+
return is_irreducible(self)
|
|
112
|
+
|
|
113
|
+
def is_ergodic(self, *, weak: bool = True) -> bool:
|
|
114
|
+
"""Return weak/strong ergodicity of the internal finite-state dynamics."""
|
|
115
|
+
from sofic.properties import is_ergodic
|
|
116
|
+
|
|
117
|
+
return is_ergodic(self, weak=weak)
|
|
118
|
+
|
|
119
|
+
def is_stationary(self, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
|
|
120
|
+
"""Return whether the initial distribution is internally stationary."""
|
|
121
|
+
from sofic.properties import is_stationary
|
|
122
|
+
|
|
123
|
+
return is_stationary(self, rtol=rtol, atol=atol)
|
|
124
|
+
|
|
125
|
+
def is_detailed_balance(self, *, rtol: float = 1e-8, atol: float = 1e-10) -> bool:
|
|
126
|
+
"""Return whether stationary labeled flows satisfy detailed balance."""
|
|
127
|
+
from sofic.properties import is_detailed_balance
|
|
128
|
+
|
|
129
|
+
return is_detailed_balance(self, rtol=rtol, atol=atol)
|
|
130
|
+
|
|
131
|
+
def is_periodic(self) -> bool:
|
|
132
|
+
"""Return whether terminal internal components have graph period greater than one."""
|
|
133
|
+
from sofic.properties import is_periodic
|
|
134
|
+
|
|
135
|
+
return is_periodic(self)
|
|
136
|
+
|
|
137
|
+
def is_strictly_sofic(self) -> bool:
|
|
138
|
+
"""Return whether this generator's support is strictly sofic."""
|
|
139
|
+
from sofic.properties import is_strictly_sofic
|
|
140
|
+
|
|
141
|
+
return is_strictly_sofic(self)
|
|
142
|
+
|
|
143
|
+
def mixed_state_presentation(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
initial_mixed_state: MixedState | Mapping[Hashable, float] | Sequence[float] | None = None,
|
|
147
|
+
) -> MixedStatePresentation:
|
|
148
|
+
"""Build the mixed-state presentation (observer belief dynamics)."""
|
|
149
|
+
from sofic.generators.mixed_state import MixedStatePresentation
|
|
150
|
+
|
|
151
|
+
return MixedStatePresentation.from_presentation(self, initial_mixed_state=initial_mixed_state)
|
|
152
|
+
|
|
153
|
+
def to_edge_machine(self, iterations: int = 1, style: int = 0) -> MealyHMM:
|
|
154
|
+
from sofic.generators.edge_machine import hmm_to_edge_machine
|
|
155
|
+
|
|
156
|
+
return hmm_to_edge_machine(self, iterations=iterations, style=style)
|