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,182 @@
|
|
|
1
|
+
"""Generator presentation conversions."""
|
|
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
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sofic.generators.synchronization import TopologicalUnifilarGraph
|
|
12
|
+
|
|
13
|
+
from sofic.automata.dfa import DFA
|
|
14
|
+
from sofic.automata.nfa import NFA
|
|
15
|
+
from sofic.generators.base import HiddenMarkovModel
|
|
16
|
+
from sofic.generators.mealy import MealyHMM
|
|
17
|
+
from sofic.generators.moore import MooreHMM
|
|
18
|
+
from sofic.generators.nmachine import NMachine
|
|
19
|
+
from sofic.generators.pfa import ProbabilisticFiniteAutomaton
|
|
20
|
+
from sofic.generators.quasi_realization import QuasiRealization
|
|
21
|
+
from sofic.graph import (
|
|
22
|
+
ATTR_EMISSION,
|
|
23
|
+
ATTR_EMISSION_DIST,
|
|
24
|
+
ATTR_PROB,
|
|
25
|
+
ATTR_QUASIPROB,
|
|
26
|
+
ATTR_SYMBOL,
|
|
27
|
+
EPSILON,
|
|
28
|
+
TransitionGraph,
|
|
29
|
+
)
|
|
30
|
+
from sofic.shifts.sofic import SoficShift
|
|
31
|
+
from sofic.states import sequential_labels
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def moore_to_mealy(moore: MooreHMM) -> MealyHMM:
|
|
35
|
+
"""Convert Moore HMM to Mealy with joint P(q', o | q) on departure state."""
|
|
36
|
+
graph = TransitionGraph()
|
|
37
|
+
for state in moore.states():
|
|
38
|
+
graph.add_state(state)
|
|
39
|
+
for transition in moore.transitions():
|
|
40
|
+
source = transition.source
|
|
41
|
+
target = transition.target
|
|
42
|
+
trans_prob = float(transition.data.get(ATTR_PROB, 0.0))
|
|
43
|
+
emission_dist = moore.graph.state_attrs(source).get(ATTR_EMISSION_DIST, {})
|
|
44
|
+
for emission, emit_prob in emission_dist.items():
|
|
45
|
+
joint = trans_prob * float(emit_prob)
|
|
46
|
+
if joint > 0.0:
|
|
47
|
+
graph.add_transition(
|
|
48
|
+
source,
|
|
49
|
+
target,
|
|
50
|
+
**{ATTR_PROB: joint, ATTR_EMISSION: emission},
|
|
51
|
+
)
|
|
52
|
+
return MealyHMM(
|
|
53
|
+
graph=graph,
|
|
54
|
+
initial_distribution=moore.initial_distribution,
|
|
55
|
+
observation_alphabet=moore.observation_alphabet,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pfa_to_mealy(pfa: ProbabilisticFiniteAutomaton) -> MealyHMM:
|
|
60
|
+
graph = pfa.graph.copy()
|
|
61
|
+
return MealyHMM(
|
|
62
|
+
graph=graph,
|
|
63
|
+
initial_distribution=pfa.initial_distribution,
|
|
64
|
+
observation_alphabet=frozenset(pfa.output_alphabet),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def hmm_to_sofic_shift(hmm: HiddenMarkovModel) -> SoficShift:
|
|
69
|
+
"""Strip probabilities from an HMM and keep its labeled support."""
|
|
70
|
+
support = _mealy_support(hmm)
|
|
71
|
+
graph = _support_graph(support, edge_attr=ATTR_SYMBOL)
|
|
72
|
+
return SoficShift(graph=graph, symbol_alphabet=support.observation_alphabet)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def hmm_to_support_nfa(hmm: HiddenMarkovModel) -> NFA:
|
|
76
|
+
"""Build an NFA whose language is the finite-word support of an HMM."""
|
|
77
|
+
support = _mealy_support(hmm)
|
|
78
|
+
graph = _support_graph(support, edge_attr=ATTR_SYMBOL)
|
|
79
|
+
states = frozenset(support.states())
|
|
80
|
+
start = _fresh_start_state(states)
|
|
81
|
+
graph.add_state(start)
|
|
82
|
+
for state in states:
|
|
83
|
+
graph.add_transition(start, state, **{ATTR_SYMBOL: EPSILON})
|
|
84
|
+
return NFA(
|
|
85
|
+
graph=graph,
|
|
86
|
+
input_alphabet=support.observation_alphabet,
|
|
87
|
+
initial_states=frozenset({start}),
|
|
88
|
+
accepting_states=states,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def hmm_to_support_dfa(hmm: HiddenMarkovModel) -> DFA:
|
|
93
|
+
"""Determinize the HMM support NFA from the all-states subset."""
|
|
94
|
+
support = _mealy_support(hmm)
|
|
95
|
+
states = frozenset(support.states())
|
|
96
|
+
nfa = hmm_to_support_nfa(support)
|
|
97
|
+
nfa.initial_states = states
|
|
98
|
+
|
|
99
|
+
dfa = nfa.determinize(alphabet=support.observation_alphabet)
|
|
100
|
+
empty_subset = frozenset()
|
|
101
|
+
if dfa.graph.has_state(empty_subset):
|
|
102
|
+
dfa.graph.nx.remove_node(empty_subset)
|
|
103
|
+
dfa.accepting_states = dfa.graph.terminal_recurrent_states()
|
|
104
|
+
return dfa
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _mealy_support(hmm: HiddenMarkovModel) -> MealyHMM:
|
|
108
|
+
return hmm.to_mealy()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _support_graph(hmm: MealyHMM, *, edge_attr: str) -> TransitionGraph:
|
|
112
|
+
graph = TransitionGraph()
|
|
113
|
+
for state in hmm.states():
|
|
114
|
+
graph.add_state(state)
|
|
115
|
+
for transition in hmm.transitions():
|
|
116
|
+
prob = float(transition.data.get(ATTR_PROB, 0.0))
|
|
117
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
118
|
+
if prob <= 0.0 or emission is None:
|
|
119
|
+
continue
|
|
120
|
+
graph.add_transition(transition.source, transition.target, **{edge_attr: emission})
|
|
121
|
+
return graph
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fresh_start_state(states: frozenset[Hashable]) -> Hashable:
|
|
125
|
+
start: Hashable = ("__sofic_hmm_start__",)
|
|
126
|
+
suffix = 0
|
|
127
|
+
while start in states:
|
|
128
|
+
suffix += 1
|
|
129
|
+
start = ("__sofic_hmm_start__", suffix)
|
|
130
|
+
return start
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def quasi_realization_from_nmachine(nm: NMachine) -> QuasiRealization:
|
|
134
|
+
idx = nm.reindex()
|
|
135
|
+
pi = np.array([nm.initial_quasidistribution.get(s, 0.0) for s in idx.states], dtype=float)
|
|
136
|
+
tau = np.ones(len(idx), dtype=float)
|
|
137
|
+
symbol_maps: dict[Any, np.ndarray] = {}
|
|
138
|
+
for transition in nm.transitions():
|
|
139
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
140
|
+
if emission is None:
|
|
141
|
+
continue
|
|
142
|
+
matrix = symbol_maps.setdefault(emission, np.zeros((len(idx), len(idx)), dtype=float))
|
|
143
|
+
i = idx.index(transition.source)
|
|
144
|
+
j = idx.index(transition.target)
|
|
145
|
+
matrix[i, j] += float(transition.data.get(ATTR_QUASIPROB, 0.0))
|
|
146
|
+
return QuasiRealization(pi=pi, tau=tau, symbol_maps=symbol_maps)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def nmachine_from_quasi_realization(
|
|
150
|
+
qr: QuasiRealization, observation_alphabet: frozenset[Any] | None = None
|
|
151
|
+
) -> NMachine:
|
|
152
|
+
states = sequential_labels(len(qr.pi))
|
|
153
|
+
graph = TransitionGraph()
|
|
154
|
+
for state in states:
|
|
155
|
+
graph.add_state(state)
|
|
156
|
+
alphabet = observation_alphabet if observation_alphabet is not None else frozenset(qr.symbol_maps)
|
|
157
|
+
for symbol, matrix in qr.symbol_maps.items():
|
|
158
|
+
for i in range(matrix.shape[0]):
|
|
159
|
+
for j in range(matrix.shape[1]):
|
|
160
|
+
weight = float(matrix[i, j])
|
|
161
|
+
if weight != 0.0:
|
|
162
|
+
graph.add_transition(states[i], states[j], **{ATTR_QUASIPROB: weight, ATTR_EMISSION: symbol})
|
|
163
|
+
initial = {states[i]: float(qr.pi[i]) for i in range(len(qr.pi))}
|
|
164
|
+
return NMachine(
|
|
165
|
+
graph=graph,
|
|
166
|
+
initial_quasidistribution=initial,
|
|
167
|
+
observation_alphabet=alphabet,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def hmm_to_edge_machine(hmm: MealyHMM | MooreHMM, iterations: int = 1, style: int = 0) -> MealyHMM:
|
|
172
|
+
"""Convert an HMM to its edge (generator) presentation."""
|
|
173
|
+
from sofic.generators.edge_machine import hmm_to_edge_machine as _build
|
|
174
|
+
|
|
175
|
+
return _build(hmm, iterations=iterations, style=style)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def epsilon_machine_to_unifilar_graph(eps: MealyHMM) -> TopologicalUnifilarGraph:
|
|
179
|
+
"""Strip emission-labeled transitions to a topological unifilar graph."""
|
|
180
|
+
from sofic.generators.synchronization import graph_from_epsilon_machine
|
|
181
|
+
|
|
182
|
+
return graph_from_epsilon_machine(eps)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Directional information flow on known stochastic generators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from sofic.generators.base import HiddenMarkovModel, StochasticModel
|
|
11
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _require_dit():
|
|
15
|
+
from sofic.generators.measures import require_dit
|
|
16
|
+
|
|
17
|
+
return require_dit("directional flow")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _pair_block_distribution(generator: HiddenMarkovModel, *, history: int) -> Any:
|
|
21
|
+
"""Joint law over flattened ``(x0, y0, x1, y1, ...)`` windows."""
|
|
22
|
+
from sofic.generators.hmm_inference import _stationary_emission_tensors
|
|
23
|
+
|
|
24
|
+
dit = _require_dit()
|
|
25
|
+
# Directional-flow statistics describe the stationary joint process, so weight
|
|
26
|
+
# the initial state by the stationary law rather than ``initial_distribution``.
|
|
27
|
+
pi, joint = _stationary_emission_tensors(generator)
|
|
28
|
+
|
|
29
|
+
block_length = history + 1
|
|
30
|
+
ones = np.ones(len(pi), dtype=float)
|
|
31
|
+
outcomes: list[tuple[Any, ...]] = []
|
|
32
|
+
probs: list[float] = []
|
|
33
|
+
|
|
34
|
+
def walk(
|
|
35
|
+
mass: np.ndarray,
|
|
36
|
+
prefix: tuple[Any, ...],
|
|
37
|
+
steps_remaining: int,
|
|
38
|
+
) -> None:
|
|
39
|
+
if steps_remaining == 0:
|
|
40
|
+
outcomes.append(prefix)
|
|
41
|
+
probs.append(float(mass @ ones))
|
|
42
|
+
return
|
|
43
|
+
for symbol, matrix in joint.items():
|
|
44
|
+
if not isinstance(symbol, tuple) or len(symbol) != 2:
|
|
45
|
+
raise TypeError("generator must emit length-2 tuple symbols")
|
|
46
|
+
next_mass = mass @ matrix
|
|
47
|
+
if next_mass.sum() <= 0.0:
|
|
48
|
+
continue
|
|
49
|
+
walk(next_mass, prefix + (symbol[0], symbol[1]), steps_remaining - 1)
|
|
50
|
+
|
|
51
|
+
walk(pi.copy(), (), block_length)
|
|
52
|
+
total = sum(probs)
|
|
53
|
+
if total > 0.0:
|
|
54
|
+
probs = [p / total for p in probs]
|
|
55
|
+
return dit.Distribution(outcomes, probs)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _window_distribution(
|
|
59
|
+
generator: HiddenMarkovModel,
|
|
60
|
+
*,
|
|
61
|
+
history: int,
|
|
62
|
+
) -> Any:
|
|
63
|
+
if history < 0:
|
|
64
|
+
raise ValueError("history must be nonnegative")
|
|
65
|
+
return _pair_block_distribution(generator, history=history)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _rv_indices(history: int) -> dict[str, list[int]]:
|
|
69
|
+
"""Map window variables to flattened distribution indices."""
|
|
70
|
+
names: dict[str, list[int]] = {}
|
|
71
|
+
for t in range(history + 1):
|
|
72
|
+
names[f"x_{t}"] = [2 * t]
|
|
73
|
+
names[f"y_{t}"] = [2 * t + 1]
|
|
74
|
+
names["x_past"] = [idx for t in range(history) for idx in names[f"x_{t}"]]
|
|
75
|
+
names["y_past"] = [idx for t in range(history) for idx in names[f"y_{t}"]]
|
|
76
|
+
names["x_pres"] = names[f"x_{history}"]
|
|
77
|
+
names["y_pres"] = names[f"y_{history}"]
|
|
78
|
+
return names
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def transfer_entropy(
|
|
82
|
+
generator: HiddenMarkovModel,
|
|
83
|
+
*,
|
|
84
|
+
source: str = "x",
|
|
85
|
+
target: str = "y",
|
|
86
|
+
history: int = 1,
|
|
87
|
+
) -> float:
|
|
88
|
+
"""One-step transfer entropy ``I(Y_t; X_{past} | Y_{past})`` on tuple emissions.
|
|
89
|
+
|
|
90
|
+
The generator must emit ``(x, y)`` pairs at each step. ``source`` and
|
|
91
|
+
``target`` must be ``'x'`` or ``'y'``.
|
|
92
|
+
"""
|
|
93
|
+
if source not in {"x", "y"} or target not in {"x", "y"}:
|
|
94
|
+
raise ValueError("source and target must be 'x' or 'y'")
|
|
95
|
+
_require_dit()
|
|
96
|
+
from dit.multivariate import total_correlation as I
|
|
97
|
+
|
|
98
|
+
dist = _window_distribution(generator, history=history)
|
|
99
|
+
idx = _rv_indices(history)
|
|
100
|
+
source_past = idx[f"{source}_past"]
|
|
101
|
+
target_past = idx[f"{target}_past"]
|
|
102
|
+
target_pres = idx[f"{target}_pres"]
|
|
103
|
+
return float(I(dist, [target_pres, source_past], target_past))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def directed_information(
|
|
107
|
+
generator: HiddenMarkovModel,
|
|
108
|
+
*,
|
|
109
|
+
source: str = "x",
|
|
110
|
+
target: str = "y",
|
|
111
|
+
length: int = 1,
|
|
112
|
+
) -> float:
|
|
113
|
+
"""Finite-length directed information ``I(X^{n} -> Y^{n})`` on tuple emissions."""
|
|
114
|
+
if length < 1:
|
|
115
|
+
raise ValueError("length must be positive")
|
|
116
|
+
_require_dit()
|
|
117
|
+
from dit.multivariate import total_correlation as I
|
|
118
|
+
|
|
119
|
+
total = 0.0
|
|
120
|
+
for t in range(length):
|
|
121
|
+
dist = _window_distribution(generator, history=t)
|
|
122
|
+
idx = _rv_indices(t)
|
|
123
|
+
source_block = [index for k in range(t + 1) for index in idx[f"{source}_{k}"]]
|
|
124
|
+
target_pres = idx[f"{target}_{t}"]
|
|
125
|
+
target_past = idx[f"{target}_past"] if t > 0 else []
|
|
126
|
+
total += float(I(dist, [source_block, target_pres], target_past))
|
|
127
|
+
return total
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def intrinsic_information_flow(
|
|
131
|
+
generator: HiddenMarkovModel,
|
|
132
|
+
*,
|
|
133
|
+
source: str = "x",
|
|
134
|
+
target: str = "y",
|
|
135
|
+
history: int = 1,
|
|
136
|
+
) -> float:
|
|
137
|
+
"""Intrinsic information flow estimate at finite window length."""
|
|
138
|
+
_require_dit()
|
|
139
|
+
from dit.multivariate import intrinsic_total_correlation as IMI
|
|
140
|
+
|
|
141
|
+
dist = _window_distribution(generator, history=history)
|
|
142
|
+
idx = _rv_indices(history)
|
|
143
|
+
source_past = idx[f"{source}_past"]
|
|
144
|
+
target_past = idx[f"{target}_past"]
|
|
145
|
+
target_pres = idx[f"{target}_pres"]
|
|
146
|
+
return float(IMI(dist, [target_pres, source_past], target_past))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def shared_information_flow(
|
|
150
|
+
generator: HiddenMarkovModel,
|
|
151
|
+
*,
|
|
152
|
+
source: str = "x",
|
|
153
|
+
target: str = "y",
|
|
154
|
+
history: int = 1,
|
|
155
|
+
) -> float:
|
|
156
|
+
"""Shared flow: time-delayed mutual information minus intrinsic flow."""
|
|
157
|
+
_require_dit()
|
|
158
|
+
from dit.multivariate import total_correlation as I
|
|
159
|
+
|
|
160
|
+
dist = _window_distribution(generator, history=history)
|
|
161
|
+
idx = _rv_indices(history)
|
|
162
|
+
source_past = idx[f"{source}_past"]
|
|
163
|
+
target_pres = idx[f"{target}_pres"]
|
|
164
|
+
tdmi = float(I(dist, [source_past, target_pres]))
|
|
165
|
+
intrinsic = intrinsic_information_flow(
|
|
166
|
+
generator,
|
|
167
|
+
source=source,
|
|
168
|
+
target=target,
|
|
169
|
+
history=history,
|
|
170
|
+
)
|
|
171
|
+
return tdmi - intrinsic
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def synergistic_information_flow(
|
|
175
|
+
generator: HiddenMarkovModel,
|
|
176
|
+
*,
|
|
177
|
+
source: str = "x",
|
|
178
|
+
target: str = "y",
|
|
179
|
+
history: int = 1,
|
|
180
|
+
) -> float:
|
|
181
|
+
"""Synergistic flow: transfer entropy minus intrinsic flow."""
|
|
182
|
+
te = transfer_entropy(generator, source=source, target=target, history=history)
|
|
183
|
+
intrinsic = intrinsic_information_flow(
|
|
184
|
+
generator,
|
|
185
|
+
source=source,
|
|
186
|
+
target=target,
|
|
187
|
+
history=history,
|
|
188
|
+
)
|
|
189
|
+
return te - intrinsic
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def independent_pair_generator(
|
|
193
|
+
left: StochasticModel,
|
|
194
|
+
right: StochasticModel,
|
|
195
|
+
) -> HiddenMarkovModel:
|
|
196
|
+
"""Build an independent pair generator emitting ``(x, y)`` tuple symbols."""
|
|
197
|
+
from sofic.generators.mealy import MealyHMM
|
|
198
|
+
from sofic.graph import TransitionGraph
|
|
199
|
+
|
|
200
|
+
left_states = tuple(left.states())
|
|
201
|
+
right_states = tuple(right.states())
|
|
202
|
+
if not left_states or not right_states:
|
|
203
|
+
raise ValueError("both generators must have at least one state")
|
|
204
|
+
|
|
205
|
+
graph = TransitionGraph()
|
|
206
|
+
for left_state in left_states:
|
|
207
|
+
for right_state in right_states:
|
|
208
|
+
graph.add_state((left_state, right_state))
|
|
209
|
+
|
|
210
|
+
left_idx = {state: i for i, state in enumerate(left_states)}
|
|
211
|
+
right_idx = {state: i for i, state in enumerate(right_states)}
|
|
212
|
+
left_pi = left.stationary_distribution()
|
|
213
|
+
right_pi = right.stationary_distribution()
|
|
214
|
+
initial: dict[tuple[Hashable, Hashable], float] = {}
|
|
215
|
+
for left_state in left_states:
|
|
216
|
+
for right_state in right_states:
|
|
217
|
+
mass = float(left_pi[left_idx[left_state]] * right_pi[right_idx[right_state]])
|
|
218
|
+
if mass > 0.0:
|
|
219
|
+
initial[(left_state, right_state)] = mass
|
|
220
|
+
|
|
221
|
+
alphabet: set[tuple[Any, Any]] = set()
|
|
222
|
+
for left_state in left_states:
|
|
223
|
+
for transition in left.graph.out_transitions(left_state):
|
|
224
|
+
x = transition.data.get(ATTR_EMISSION)
|
|
225
|
+
prob_left = float(transition.data.get(ATTR_PROB, 0.0))
|
|
226
|
+
if prob_left <= 0.0 or x is None:
|
|
227
|
+
continue
|
|
228
|
+
for right_state in right_states:
|
|
229
|
+
for transition_r in right.graph.out_transitions(right_state):
|
|
230
|
+
y = transition_r.data.get(ATTR_EMISSION)
|
|
231
|
+
prob_right = float(transition_r.data.get(ATTR_PROB, 0.0))
|
|
232
|
+
if prob_right <= 0.0 or y is None:
|
|
233
|
+
continue
|
|
234
|
+
alphabet.add((x, y))
|
|
235
|
+
graph.add_transition(
|
|
236
|
+
(left_state, right_state),
|
|
237
|
+
(transition.target, transition_r.target),
|
|
238
|
+
**{ATTR_PROB: prob_left * prob_right, ATTR_EMISSION: (x, y)},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return MealyHMM(
|
|
242
|
+
graph=graph,
|
|
243
|
+
initial_distribution=initial,
|
|
244
|
+
observation_alphabet=frozenset(alphabet),
|
|
245
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Shared helpers for edge-labeled stochastic generators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sofic.exceptions import StochasticValidationError
|
|
8
|
+
from sofic.generators.prob import is_symbolic, row_sums_to_one
|
|
9
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_stochastic_edge_emissions(
|
|
13
|
+
model: Any,
|
|
14
|
+
*,
|
|
15
|
+
alphabet: frozenset[Any],
|
|
16
|
+
alphabet_name: str,
|
|
17
|
+
row_mass_label: str,
|
|
18
|
+
negative_probability_label: str,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Validate row-stochastic edge probabilities and emission alphabet membership."""
|
|
21
|
+
for state in model.states():
|
|
22
|
+
outgoing = list(model.graph.out_transitions(state))
|
|
23
|
+
probs = [t.data.get(ATTR_PROB, 0.0) for t in outgoing]
|
|
24
|
+
if outgoing and not row_sums_to_one(probs):
|
|
25
|
+
total = sum(probs)
|
|
26
|
+
raise StochasticValidationError(f"{row_mass_label} from {state!r} sum to {total}")
|
|
27
|
+
for transition in outgoing:
|
|
28
|
+
prob = transition.data.get(ATTR_PROB, 0.0)
|
|
29
|
+
if is_symbolic(prob):
|
|
30
|
+
if getattr(prob, "is_negative", None) is True:
|
|
31
|
+
raise StochasticValidationError(f"{negative_probability_label} on {transition}")
|
|
32
|
+
elif prob < 0:
|
|
33
|
+
raise StochasticValidationError(f"{negative_probability_label} on {transition}")
|
|
34
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
35
|
+
if emission is not None:
|
|
36
|
+
model._require(emission in alphabet, f"emission {emission!r} not in {alphabet_name} alphabet")
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Edge-machine (generator) presentation of an HMM.
|
|
2
|
+
|
|
3
|
+
States index labeled transition paths of the source HMM. By default, the
|
|
4
|
+
emitted symbol on a step is pulled from the destination edge state, matching
|
|
5
|
+
cmpy's edge-machine construction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sofic.generators.base import HiddenMarkovModel
|
|
14
|
+
from sofic.generators.mealy import MealyHMM
|
|
15
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB, TransitionGraph
|
|
16
|
+
|
|
17
|
+
EdgeState = tuple[Any, ...]
|
|
18
|
+
|
|
19
|
+
ATTR_EDGE_SOURCE = "edge_source"
|
|
20
|
+
ATTR_EDGE_TARGET = "edge_target"
|
|
21
|
+
_ATTR_EDGE_SOURCE = ATTR_EDGE_SOURCE
|
|
22
|
+
_ATTR_EDGE_TARGET = ATTR_EDGE_TARGET
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def edge_state_label(edge: EdgeState) -> str:
|
|
26
|
+
"""Stable string label for an edge-state tuple (dit-friendly state name)."""
|
|
27
|
+
return "\x1e".join(repr(part) for part in edge)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_edge_state_label(label: str) -> EdgeState:
|
|
31
|
+
"""Recover an edge-state tuple from :func:`edge_state_label`."""
|
|
32
|
+
parts = label.split("\x1e")
|
|
33
|
+
if not label or not parts:
|
|
34
|
+
raise ValueError(f"not an edge state label: {label!r}")
|
|
35
|
+
return tuple(eval(part) for part in parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def hmm_to_edge_machine(hmm: HiddenMarkovModel, iterations: int = 1, style: int = 0) -> MealyHMM:
|
|
39
|
+
"""Build the edge machine whose states are source-HMM transition paths.
|
|
40
|
+
|
|
41
|
+
With ``iterations=1``, each state is ``(source, emission, target)``. More
|
|
42
|
+
iterations extend states by sliding-path suffixes, as in cmpy. ``style=0``
|
|
43
|
+
uses the default PULL labels from destination states; ``style=1`` rewrites
|
|
44
|
+
final transition emissions to PUSH labels from source states.
|
|
45
|
+
"""
|
|
46
|
+
hmm = hmm.to_mealy()
|
|
47
|
+
iterations = int(iterations)
|
|
48
|
+
if iterations == 0:
|
|
49
|
+
return hmm
|
|
50
|
+
if iterations < 0:
|
|
51
|
+
raise ValueError("iterations must be nonnegative")
|
|
52
|
+
|
|
53
|
+
edge_machine = _edge_machine_iteration(hmm, edge_machine=False)
|
|
54
|
+
for _iteration in range(iterations - 1):
|
|
55
|
+
edge_machine = _edge_machine_iteration(edge_machine, edge_machine=True)
|
|
56
|
+
|
|
57
|
+
if style == 1:
|
|
58
|
+
_push_transition_labels(edge_machine)
|
|
59
|
+
return edge_machine
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _edge_machine_iteration(machine: MealyHMM, *, edge_machine: bool) -> MealyHMM:
|
|
63
|
+
edge_states, edge_probs, states_by_source = _edge_states(machine, edge_machine=edge_machine)
|
|
64
|
+
if not edge_states:
|
|
65
|
+
raise ValueError("HMM has no labeled transitions")
|
|
66
|
+
|
|
67
|
+
graph = _edge_transition_graph(edge_states, edge_probs, states_by_source, edge_machine=edge_machine)
|
|
68
|
+
initial = _edge_initial_distribution(machine, edge_states, edge_probs, edge_machine=edge_machine)
|
|
69
|
+
return MealyHMM(
|
|
70
|
+
graph=graph,
|
|
71
|
+
initial_distribution=initial,
|
|
72
|
+
observation_alphabet=machine.observation_alphabet,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _edge_states(
|
|
77
|
+
machine: MealyHMM,
|
|
78
|
+
*,
|
|
79
|
+
edge_machine: bool,
|
|
80
|
+
) -> tuple[list[EdgeState], dict[EdgeState, float], dict[Any, list[EdgeState]]]:
|
|
81
|
+
edge_states: list[EdgeState] = []
|
|
82
|
+
edge_probs: dict[EdgeState, float] = defaultdict(float)
|
|
83
|
+
states_by_source: dict[Any, list[EdgeState]] = defaultdict(list)
|
|
84
|
+
seen: set[EdgeState] = set()
|
|
85
|
+
|
|
86
|
+
for transition in machine.transitions():
|
|
87
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
88
|
+
if emission is None:
|
|
89
|
+
continue
|
|
90
|
+
state = _state_for_transition(
|
|
91
|
+
transition.source,
|
|
92
|
+
transition.target,
|
|
93
|
+
emission,
|
|
94
|
+
edge_machine=edge_machine,
|
|
95
|
+
)
|
|
96
|
+
if state not in seen:
|
|
97
|
+
seen.add(state)
|
|
98
|
+
edge_states.append(state)
|
|
99
|
+
states_by_source[_state_source(state, edge_machine=edge_machine)].append(state)
|
|
100
|
+
edge_probs[state] += float(transition.data.get(ATTR_PROB, 0.0))
|
|
101
|
+
|
|
102
|
+
return edge_states, dict(edge_probs), dict(states_by_source)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _state_for_transition(source: Any, target: Any, emission: Any, *, edge_machine: bool) -> EdgeState:
|
|
106
|
+
if edge_machine:
|
|
107
|
+
if not isinstance(source, tuple) or not isinstance(target, tuple):
|
|
108
|
+
raise TypeError("iterated edge-machine states must be tuples")
|
|
109
|
+
return source + target[-2:]
|
|
110
|
+
return (source, emission, target)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _state_source(state: EdgeState, *, edge_machine: bool) -> Any:
|
|
114
|
+
return state[:-2] if edge_machine else state[0]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _state_target(state: EdgeState, *, edge_machine: bool) -> Any:
|
|
118
|
+
return state[2:] if edge_machine else state[2]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _edge_transition_graph(
|
|
122
|
+
edge_states: list[EdgeState],
|
|
123
|
+
edge_probs: dict[EdgeState, float],
|
|
124
|
+
states_by_source: dict[Any, list[EdgeState]],
|
|
125
|
+
*,
|
|
126
|
+
edge_machine: bool,
|
|
127
|
+
) -> TransitionGraph:
|
|
128
|
+
graph = TransitionGraph()
|
|
129
|
+
for edge in edge_states:
|
|
130
|
+
graph.add_state(
|
|
131
|
+
edge,
|
|
132
|
+
**_edge_state_attrs(edge),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
for edge_from in edge_states:
|
|
136
|
+
target_state = _state_target(edge_from, edge_machine=edge_machine)
|
|
137
|
+
for edge_to in states_by_source.get(target_state, []):
|
|
138
|
+
graph.add_transition(
|
|
139
|
+
edge_from,
|
|
140
|
+
edge_to,
|
|
141
|
+
**{
|
|
142
|
+
ATTR_PROB: edge_probs[edge_to],
|
|
143
|
+
ATTR_EMISSION: edge_to[-2],
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
return graph
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _edge_state_attrs(edge: EdgeState) -> dict[str, Any]:
|
|
150
|
+
if len(edge) != 3:
|
|
151
|
+
return {}
|
|
152
|
+
return {_ATTR_EDGE_SOURCE: edge[0], ATTR_EMISSION: edge[1], _ATTR_EDGE_TARGET: edge[2]}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _edge_initial_distribution(
|
|
156
|
+
machine: MealyHMM,
|
|
157
|
+
edge_states: list[EdgeState],
|
|
158
|
+
edge_probs: dict[EdgeState, float],
|
|
159
|
+
*,
|
|
160
|
+
edge_machine: bool,
|
|
161
|
+
) -> dict[EdgeState, float]:
|
|
162
|
+
idx = machine.reindex()
|
|
163
|
+
pi = machine.stationary_distribution()
|
|
164
|
+
initial: dict[EdgeState, float] = defaultdict(float)
|
|
165
|
+
for edge in edge_states:
|
|
166
|
+
source = _state_source(edge, edge_machine=edge_machine)
|
|
167
|
+
if source not in idx:
|
|
168
|
+
continue
|
|
169
|
+
initial[edge] += float(pi[idx.index(source)] * edge_probs[edge])
|
|
170
|
+
total = sum(initial.values())
|
|
171
|
+
if total <= 0.0:
|
|
172
|
+
raise ValueError("edge machine initial distribution is empty")
|
|
173
|
+
return {edge: mass / total for edge, mass in initial.items() if mass > 0.0}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _push_transition_labels(machine: MealyHMM) -> None:
|
|
177
|
+
for source, _target, _key, data in machine.graph.nx.edges(keys=True, data=True):
|
|
178
|
+
data[ATTR_EMISSION] = source[-2]
|