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