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,717 @@
|
|
|
1
|
+
"""Bidirectional ε-machine construction (Ellison et al., arXiv:1107.2168, Sec. VII)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Hashable, Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from sofic.exceptions import StochasticValidationError
|
|
12
|
+
from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
|
|
13
|
+
from sofic.generators.epsilon_machine import EpsilonMachine
|
|
14
|
+
from sofic.generators.prob import (
|
|
15
|
+
as_prob,
|
|
16
|
+
has_symbolic,
|
|
17
|
+
is_positive_mass,
|
|
18
|
+
is_symbolic,
|
|
19
|
+
is_zero,
|
|
20
|
+
probs_equal,
|
|
21
|
+
row_sums_to_one,
|
|
22
|
+
simplify_prob,
|
|
23
|
+
sum_probs,
|
|
24
|
+
zeros,
|
|
25
|
+
)
|
|
26
|
+
from sofic.generators.reversal import time_reverse_stochastic
|
|
27
|
+
from sofic.generators.stationary import (
|
|
28
|
+
stationary_distribution_from_transition,
|
|
29
|
+
stationary_distribution_hmm,
|
|
30
|
+
)
|
|
31
|
+
from sofic.graph import ATTR_EMISSION, ATTR_FUTURE_SYMBOL, ATTR_PROB, TransitionGraph
|
|
32
|
+
from sofic.states import next_sequential_label_index, sequential_labels
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_bidirectional_epsilon_machine(
|
|
36
|
+
forward: EpsilonMachine,
|
|
37
|
+
reverse: EpsilonMachine,
|
|
38
|
+
) -> BidirectionalEpsilonMachine:
|
|
39
|
+
"""Build M± from forward and reverse ε-machines via Eq. (15)."""
|
|
40
|
+
if forward.observation_alphabet != reverse.observation_alphabet:
|
|
41
|
+
raise StochasticValidationError("forward and reverse observation alphabets must match")
|
|
42
|
+
|
|
43
|
+
reverse = _relabel_collision_free(reverse, forward)
|
|
44
|
+
rev_time = time_reverse_stochastic(reverse)
|
|
45
|
+
compatible = _compatible_pairs(forward, reverse)
|
|
46
|
+
graph = _build_eq15_graph(forward, rev_time, sources=compatible)
|
|
47
|
+
graph, initial = _prune_to_stationary_support(graph, forward, reverse)
|
|
48
|
+
if not initial:
|
|
49
|
+
raise StochasticValidationError("bidirectional machine has empty joint support")
|
|
50
|
+
|
|
51
|
+
bidir = BidirectionalEpsilonMachine(
|
|
52
|
+
graph=graph,
|
|
53
|
+
initial_distribution=initial,
|
|
54
|
+
observation_alphabet=forward.observation_alphabet,
|
|
55
|
+
forward_machine=forward,
|
|
56
|
+
reverse_machine=reverse,
|
|
57
|
+
)
|
|
58
|
+
bidir._joint_pi = dict(initial)
|
|
59
|
+
bidir.validate()
|
|
60
|
+
_validate_bidirectional_anatomy(bidir)
|
|
61
|
+
return bidir
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_eq15_graph(
|
|
65
|
+
forward: EpsilonMachine,
|
|
66
|
+
rev_time: EpsilonMachine,
|
|
67
|
+
*,
|
|
68
|
+
sources: set[tuple[Hashable, Hashable]] | None = None,
|
|
69
|
+
) -> TransitionGraph:
|
|
70
|
+
"""Build the provisional Eq. (15) graph before pruning transient joint states."""
|
|
71
|
+
reverse_states = sorted(rev_time.states(), key=repr)
|
|
72
|
+
raw: dict[tuple[Hashable, Hashable], list[tuple[tuple[Hashable, Hashable], Any, Any]]] = defaultdict(list)
|
|
73
|
+
|
|
74
|
+
for alpha in sorted(forward.states(), key=repr):
|
|
75
|
+
for gamma in reverse_states:
|
|
76
|
+
source = (alpha, gamma)
|
|
77
|
+
if sources is not None and source not in sources:
|
|
78
|
+
continue
|
|
79
|
+
for transition in forward.graph.out_transitions(alpha):
|
|
80
|
+
symbol = transition.data.get(ATTR_EMISSION)
|
|
81
|
+
prob_forward = as_prob(transition.data.get(ATTR_PROB, 0.0))
|
|
82
|
+
if symbol is None or not is_positive_mass(prob_forward):
|
|
83
|
+
continue
|
|
84
|
+
beta = transition.target
|
|
85
|
+
for delta in reverse_states:
|
|
86
|
+
tex = _reverse_tex_probability(rev_time, gamma, delta, symbol)
|
|
87
|
+
if not is_positive_mass(tex):
|
|
88
|
+
continue
|
|
89
|
+
raw[source].append(((beta, delta), symbol, tex))
|
|
90
|
+
|
|
91
|
+
return _graph_from_raw(raw)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _graph_from_raw(
|
|
95
|
+
raw: dict[tuple[Hashable, Hashable], list[tuple[tuple[Hashable, Hashable], Any, Any]]],
|
|
96
|
+
) -> TransitionGraph:
|
|
97
|
+
graph = TransitionGraph()
|
|
98
|
+
registry: dict[tuple[Hashable, Hashable], tuple[Hashable, Hashable]] = {}
|
|
99
|
+
|
|
100
|
+
def _intern(pair: tuple[Hashable, Hashable]) -> tuple[Hashable, Hashable]:
|
|
101
|
+
existing = registry.get(pair)
|
|
102
|
+
if existing is not None:
|
|
103
|
+
return existing
|
|
104
|
+
registry[pair] = pair
|
|
105
|
+
return pair
|
|
106
|
+
|
|
107
|
+
for source, entries in raw.items():
|
|
108
|
+
if not entries:
|
|
109
|
+
continue
|
|
110
|
+
source = _intern(source)
|
|
111
|
+
graph.add_state(source)
|
|
112
|
+
aggregated: dict[tuple[tuple[Hashable, Hashable], Any], Any] = {}
|
|
113
|
+
for target, symbol, weight in entries:
|
|
114
|
+
key = (_intern(target), symbol)
|
|
115
|
+
if key in aggregated:
|
|
116
|
+
aggregated[key] = sum_probs([aggregated[key], weight])
|
|
117
|
+
else:
|
|
118
|
+
aggregated[key] = as_prob(weight)
|
|
119
|
+
total = sum_probs(aggregated.values())
|
|
120
|
+
if not is_positive_mass(total):
|
|
121
|
+
continue
|
|
122
|
+
symbolic = is_symbolic(total) or has_symbolic(aggregated.values())
|
|
123
|
+
for (target, symbol), weight in aggregated.items():
|
|
124
|
+
if symbolic:
|
|
125
|
+
prob = _clean_probability(simplify_prob(as_prob(weight) / as_prob(total)))
|
|
126
|
+
else:
|
|
127
|
+
prob = _clean_probability(float(weight) / float(total))
|
|
128
|
+
graph.add_transition(
|
|
129
|
+
source,
|
|
130
|
+
target,
|
|
131
|
+
**{ATTR_PROB: prob, ATTR_EMISSION: symbol},
|
|
132
|
+
)
|
|
133
|
+
return graph
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _clean_probability(probability: Any) -> Any:
|
|
137
|
+
if is_symbolic(probability):
|
|
138
|
+
return simplify_prob(probability)
|
|
139
|
+
rounded = round(float(probability), 15)
|
|
140
|
+
if np.isclose(probability, rounded, rtol=0.0, atol=1e-15):
|
|
141
|
+
return rounded
|
|
142
|
+
return float(probability)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _reverse_tex_probability(
|
|
146
|
+
rev_time: EpsilonMachine,
|
|
147
|
+
gamma: Hashable,
|
|
148
|
+
delta: Hashable,
|
|
149
|
+
symbol: Any,
|
|
150
|
+
) -> Any:
|
|
151
|
+
"""T̃_x(γ, δ) from the time-reversed reverse ε-machine (Eq. 18)."""
|
|
152
|
+
masses: list[Any] = []
|
|
153
|
+
for transition in rev_time.graph.out_transitions(gamma):
|
|
154
|
+
if transition.data.get(ATTR_EMISSION) != symbol:
|
|
155
|
+
continue
|
|
156
|
+
if transition.target != delta:
|
|
157
|
+
continue
|
|
158
|
+
masses.append(as_prob(transition.data.get(ATTR_PROB, 0.0)))
|
|
159
|
+
if not masses:
|
|
160
|
+
return 0.0
|
|
161
|
+
return sum_probs(masses)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _prune_to_stationary_support(
|
|
165
|
+
graph: TransitionGraph,
|
|
166
|
+
forward: EpsilonMachine,
|
|
167
|
+
reverse: EpsilonMachine,
|
|
168
|
+
*,
|
|
169
|
+
tol: float = 1e-12,
|
|
170
|
+
) -> tuple[TransitionGraph, dict[tuple[Hashable, Hashable], Any]]:
|
|
171
|
+
"""Drop transient joint states and return a margin-matching stationary joint π."""
|
|
172
|
+
states = list(graph.states())
|
|
173
|
+
if not states:
|
|
174
|
+
return graph, {}
|
|
175
|
+
|
|
176
|
+
keep = _recurrent_support(graph) or set(states)
|
|
177
|
+
trimmed = _restrict_graph(graph, keep)
|
|
178
|
+
initial = _joint_pi_minimum_support(trimmed, forward, reverse, tol=tol)
|
|
179
|
+
if not initial:
|
|
180
|
+
return trimmed, {}
|
|
181
|
+
|
|
182
|
+
trimmed = _restrict_graph(trimmed, set(initial.keys()))
|
|
183
|
+
keep = _recurrent_support(trimmed) or set(initial.keys())
|
|
184
|
+
trimmed = _restrict_graph(trimmed, keep)
|
|
185
|
+
initial = _joint_pi_minimum_support(trimmed, forward, reverse, tol=tol)
|
|
186
|
+
if initial:
|
|
187
|
+
trimmed = _restrict_graph(trimmed, set(initial.keys()))
|
|
188
|
+
return trimmed, initial
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _compatible_pairs(
|
|
192
|
+
forward: EpsilonMachine,
|
|
193
|
+
reverse: EpsilonMachine,
|
|
194
|
+
) -> set[tuple[Hashable, Hashable]]:
|
|
195
|
+
"""Joint states (α, γ) with positive measure in the bidirectional presentation.
|
|
196
|
+
|
|
197
|
+
Eq. (15) is evaluated on every forward/reverse pair that passes the reverse
|
|
198
|
+
future-symbol filter. When multiple undirected components admit a stationary
|
|
199
|
+
joint π, :func:`_joint_pi_minimum_support` selects the one whose marginals
|
|
200
|
+
match the forward/reverse causal-state stationary distributions.
|
|
201
|
+
"""
|
|
202
|
+
future_symbol = _infer_future_symbols(reverse)
|
|
203
|
+
pairs: set[tuple[Hashable, Hashable]] = set()
|
|
204
|
+
for alpha in forward.states():
|
|
205
|
+
for gamma in reverse.states():
|
|
206
|
+
if gamma in future_symbol:
|
|
207
|
+
required = future_symbol[gamma]
|
|
208
|
+
if required not in forward.observation_alphabet:
|
|
209
|
+
continue
|
|
210
|
+
if not _forward_emits(forward, alpha, required):
|
|
211
|
+
continue
|
|
212
|
+
pairs.add((alpha, gamma))
|
|
213
|
+
return pairs
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _forward_emits(forward: EpsilonMachine, state: Hashable, symbol: Any) -> bool:
|
|
217
|
+
return any(
|
|
218
|
+
transition.data.get(ATTR_EMISSION) == symbol and is_positive_mass(as_prob(transition.data.get(ATTR_PROB, 0.0)))
|
|
219
|
+
for transition in forward.graph.out_transitions(state)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _infer_future_symbols(reverse: EpsilonMachine) -> dict[Hashable, Any]:
|
|
224
|
+
"""Map reverse causal states to the future symbol X₀ they carry when annotated."""
|
|
225
|
+
mapping: dict[Hashable, Any] = {}
|
|
226
|
+
alphabet = reverse.observation_alphabet
|
|
227
|
+
for state in reverse.states():
|
|
228
|
+
attrs = reverse.graph.state_attrs(state)
|
|
229
|
+
if ATTR_FUTURE_SYMBOL in attrs:
|
|
230
|
+
mapping[state] = attrs[ATTR_FUTURE_SYMBOL]
|
|
231
|
+
elif state in alphabet:
|
|
232
|
+
mapping[state] = state
|
|
233
|
+
return mapping
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _joint_pi_on_pair_subset(
|
|
237
|
+
graph: TransitionGraph,
|
|
238
|
+
pairs: list[tuple[Hashable, Hashable]],
|
|
239
|
+
*,
|
|
240
|
+
tol: float = 1e-12,
|
|
241
|
+
) -> dict[tuple[Hashable, Hashable], Any] | None:
|
|
242
|
+
"""Stationary joint π over a closed joint-state component, or ``None`` if unavailable.
|
|
243
|
+
|
|
244
|
+
The joint stationary distribution is the normalized left eigenvector (eigenvalue
|
|
245
|
+
one) of the component's row-stochastic transition matrix — the general HMM
|
|
246
|
+
stationary distribution. A component that is not row-stochastic (i.e. leaks
|
|
247
|
+
probability outside ``pairs``) is not a closed recurrent class and is rejected.
|
|
248
|
+
"""
|
|
249
|
+
if not pairs:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
states = sorted(pairs, key=repr)
|
|
253
|
+
index = {state: i for i, state in enumerate(states)}
|
|
254
|
+
n = len(states)
|
|
255
|
+
edge_probs = [
|
|
256
|
+
as_prob(transition.data.get(ATTR_PROB, 0.0)) for state in states for transition in graph.out_transitions(state)
|
|
257
|
+
]
|
|
258
|
+
symbolic = has_symbolic(edge_probs)
|
|
259
|
+
matrix = zeros((n, n), symbolic=symbolic)
|
|
260
|
+
for state in states:
|
|
261
|
+
row = index[state]
|
|
262
|
+
for transition in graph.out_transitions(state):
|
|
263
|
+
column = index.get(transition.target)
|
|
264
|
+
if column is None:
|
|
265
|
+
continue
|
|
266
|
+
matrix[row, column] = as_prob(matrix[row, column]) + as_prob(transition.data.get(ATTR_PROB, 0.0))
|
|
267
|
+
|
|
268
|
+
if symbolic:
|
|
269
|
+
for row in range(n):
|
|
270
|
+
if not row_sums_to_one([as_prob(matrix[row, j]) for j in range(n)]):
|
|
271
|
+
return None
|
|
272
|
+
elif not np.allclose(matrix.sum(axis=1), 1.0, atol=1e-9):
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
pi = stationary_distribution_from_transition(matrix)
|
|
277
|
+
except (StochasticValidationError, ValueError):
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
if symbolic:
|
|
281
|
+
joint = {states[i]: simplify_prob(as_prob(pi[i])) for i in range(n) if is_positive_mass(pi[i])}
|
|
282
|
+
else:
|
|
283
|
+
joint = {states[i]: float(pi[i]) for i in range(n) if float(pi[i]) > tol}
|
|
284
|
+
return joint or None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _undirected_components(
|
|
288
|
+
graph: TransitionGraph,
|
|
289
|
+
) -> list[set[tuple[Hashable, Hashable]]]:
|
|
290
|
+
import networkx as nx
|
|
291
|
+
|
|
292
|
+
undirected = graph.nx.to_undirected()
|
|
293
|
+
return [set(component) for component in nx.connected_components(undirected)]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _anatomy_gap_for_joint(
|
|
297
|
+
graph: TransitionGraph,
|
|
298
|
+
joint: dict[tuple[Hashable, Hashable], Any],
|
|
299
|
+
forward: EpsilonMachine,
|
|
300
|
+
reverse: EpsilonMachine,
|
|
301
|
+
) -> float:
|
|
302
|
+
"""Return |b_μ + r_μ − h_μ| for a candidate joint support, or inf if unavailable."""
|
|
303
|
+
try:
|
|
304
|
+
provisional = BidirectionalEpsilonMachine(
|
|
305
|
+
graph=_restrict_graph(graph, set(joint.keys())),
|
|
306
|
+
initial_distribution=joint,
|
|
307
|
+
observation_alphabet=forward.observation_alphabet,
|
|
308
|
+
forward_machine=forward,
|
|
309
|
+
reverse_machine=reverse,
|
|
310
|
+
)
|
|
311
|
+
provisional._joint_pi = dict(joint)
|
|
312
|
+
h_mu = provisional.entropy_rate()
|
|
313
|
+
b_mu = provisional.bound_information()
|
|
314
|
+
r_mu = provisional.ephemeral_information()
|
|
315
|
+
except (ImportError, StochasticValidationError, ValueError):
|
|
316
|
+
return float("inf")
|
|
317
|
+
|
|
318
|
+
if is_symbolic(h_mu) or is_symbolic(b_mu) or is_symbolic(r_mu):
|
|
319
|
+
import sympy as sp
|
|
320
|
+
|
|
321
|
+
gap = sp.simplify(sp.sympify(b_mu) + sp.sympify(r_mu) - sp.sympify(h_mu))
|
|
322
|
+
return 0.0 if gap == 0 else float("inf")
|
|
323
|
+
return abs(float(b_mu) + float(r_mu) - float(h_mu))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _stationary_state_probabilities(machine: EpsilonMachine) -> dict[Hashable, Any]:
|
|
327
|
+
"""Stationary causal-state distribution of ``machine`` keyed by state label."""
|
|
328
|
+
index = machine.reindex()
|
|
329
|
+
pi = machine.stationary_distribution()
|
|
330
|
+
symbolic = pi.dtype == object or has_symbolic(pi.ravel())
|
|
331
|
+
if symbolic:
|
|
332
|
+
return {state: simplify_prob(as_prob(pi[i])) for i, state in enumerate(index.states)}
|
|
333
|
+
return {state: float(pi[i]) for i, state in enumerate(index.states)}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _abs_marginal_error(left: Any, right: Any) -> float:
|
|
337
|
+
"""Absolute marginal deviation (exact sympy simplify, else numeric)."""
|
|
338
|
+
if probs_equal(left, right):
|
|
339
|
+
return 0.0
|
|
340
|
+
if is_symbolic(left) or is_symbolic(right):
|
|
341
|
+
import sympy as sp
|
|
342
|
+
|
|
343
|
+
simplified = sp.simplify(sp.Abs(sp.sympify(left) - sp.sympify(right)))
|
|
344
|
+
if getattr(simplified, "free_symbols", None):
|
|
345
|
+
return float("inf")
|
|
346
|
+
try:
|
|
347
|
+
return abs(float(simplified))
|
|
348
|
+
except (TypeError, ValueError):
|
|
349
|
+
return float("inf")
|
|
350
|
+
return abs(float(left) - float(right))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _joint_marginal_mismatch(
|
|
354
|
+
joint: dict[tuple[Hashable, Hashable], Any],
|
|
355
|
+
pi_plus: dict[Hashable, Any],
|
|
356
|
+
pi_minus: dict[Hashable, Any],
|
|
357
|
+
) -> float:
|
|
358
|
+
"""Max abs deviation of ``joint``'s marginals from the target stationary marginals.
|
|
359
|
+
|
|
360
|
+
A valid bidirectional presentation is a closed recurrent joint class whose
|
|
361
|
+
forward/reverse marginals equal the forward/reverse causal-state stationary
|
|
362
|
+
distributions. Spurious closed sub-cycles (e.g. the all-``0`` period-3 cycle
|
|
363
|
+
of the Nemo process) violate this and are rejected by the selector below.
|
|
364
|
+
"""
|
|
365
|
+
forward_marginal: dict[Hashable, Any] = {}
|
|
366
|
+
reverse_marginal: dict[Hashable, Any] = {}
|
|
367
|
+
for (alpha, gamma), mass in joint.items():
|
|
368
|
+
forward_marginal[alpha] = sum_probs([forward_marginal.get(alpha, 0), mass])
|
|
369
|
+
reverse_marginal[gamma] = sum_probs([reverse_marginal.get(gamma, 0), mass])
|
|
370
|
+
|
|
371
|
+
error = 0.0
|
|
372
|
+
for state in set(pi_plus) | set(forward_marginal):
|
|
373
|
+
error = max(
|
|
374
|
+
error,
|
|
375
|
+
_abs_marginal_error(forward_marginal.get(state, 0), pi_plus.get(state, 0)),
|
|
376
|
+
)
|
|
377
|
+
for state in set(pi_minus) | set(reverse_marginal):
|
|
378
|
+
error = max(
|
|
379
|
+
error,
|
|
380
|
+
_abs_marginal_error(reverse_marginal.get(state, 0), pi_minus.get(state, 0)),
|
|
381
|
+
)
|
|
382
|
+
return error
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _joint_pi_minimum_support(
|
|
386
|
+
graph: TransitionGraph,
|
|
387
|
+
forward: EpsilonMachine,
|
|
388
|
+
reverse: EpsilonMachine,
|
|
389
|
+
*,
|
|
390
|
+
tol: float = 1e-12,
|
|
391
|
+
marginal_tol: float = 1e-6,
|
|
392
|
+
) -> dict[tuple[Hashable, Hashable], Any]:
|
|
393
|
+
"""Pick the closed undirected component that is the true bidirectional class.
|
|
394
|
+
|
|
395
|
+
The joint π on each component is the general HMM stationary distribution (left
|
|
396
|
+
eigenvector for eigenvalue one). When the Eq. (15) graph has more than one
|
|
397
|
+
closed recurrent component (e.g. the Nemo process, which admits a spurious
|
|
398
|
+
all-``0`` period-3 cycle alongside the genuine 6-state machine), the correct
|
|
399
|
+
class is the one whose forward/reverse marginals equal the forward/reverse
|
|
400
|
+
causal-state stationary distributions. Among marginal-matching components the
|
|
401
|
+
information-anatomy identity ``h_μ = b_μ + r_μ`` and then the support size break
|
|
402
|
+
remaining ties; if none match we fall back to the smallest-mismatch component.
|
|
403
|
+
"""
|
|
404
|
+
pairs = [state for state in graph.states() if isinstance(state, tuple) and len(state) == 2]
|
|
405
|
+
if not pairs:
|
|
406
|
+
return {}
|
|
407
|
+
|
|
408
|
+
pi_plus = _stationary_state_probabilities(forward)
|
|
409
|
+
pi_minus = _stationary_state_probabilities(reverse)
|
|
410
|
+
|
|
411
|
+
components = sorted(
|
|
412
|
+
_undirected_components(graph),
|
|
413
|
+
key=lambda component: (len(component), sorted(component, key=repr)),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
candidates: list[tuple[float, float, int, dict[tuple[Hashable, Hashable], Any]]] = []
|
|
417
|
+
for component in components:
|
|
418
|
+
component_pairs = [pair for pair in pairs if pair in component]
|
|
419
|
+
joint = _joint_pi_on_pair_subset(graph, component_pairs, tol=tol)
|
|
420
|
+
if joint is None:
|
|
421
|
+
continue
|
|
422
|
+
marginal_error = _joint_marginal_mismatch(joint, pi_plus, pi_minus)
|
|
423
|
+
anatomy_gap = _anatomy_gap_for_joint(graph, joint, forward, reverse)
|
|
424
|
+
candidates.append((marginal_error, anatomy_gap, len(joint), joint))
|
|
425
|
+
|
|
426
|
+
if not candidates:
|
|
427
|
+
return {}
|
|
428
|
+
|
|
429
|
+
all_symbolic = all(has_symbolic(candidate[3].values()) for candidate in candidates)
|
|
430
|
+
if all_symbolic:
|
|
431
|
+
matching = [candidate for candidate in candidates if candidate[0] == 0.0]
|
|
432
|
+
else:
|
|
433
|
+
matching = [candidate for candidate in candidates if candidate[0] <= marginal_tol]
|
|
434
|
+
if matching:
|
|
435
|
+
best = min(matching, key=lambda candidate: (candidate[1], candidate[2]))
|
|
436
|
+
else:
|
|
437
|
+
best = min(candidates, key=lambda candidate: (candidate[0], candidate[1], candidate[2]))
|
|
438
|
+
return best[3]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _canonical_joint_state(
|
|
442
|
+
state: tuple[Hashable, Hashable],
|
|
443
|
+
keep: set[tuple[Hashable, Hashable]],
|
|
444
|
+
) -> tuple[Hashable, Hashable] | None:
|
|
445
|
+
if state in keep:
|
|
446
|
+
return state
|
|
447
|
+
for key in keep:
|
|
448
|
+
if key == state:
|
|
449
|
+
return key
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _restrict_graph(graph: TransitionGraph, keep: set[tuple[Hashable, Hashable]]) -> TransitionGraph:
|
|
454
|
+
trimmed = TransitionGraph()
|
|
455
|
+
for state in keep:
|
|
456
|
+
trimmed.add_state(state)
|
|
457
|
+
for transition in graph.transitions():
|
|
458
|
+
source = _canonical_joint_state(transition.source, keep)
|
|
459
|
+
target = _canonical_joint_state(transition.target, keep)
|
|
460
|
+
if source is None or target is None:
|
|
461
|
+
continue
|
|
462
|
+
trimmed.add_transition(source, target, **transition.data)
|
|
463
|
+
return trimmed
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _relabel_epsilon_machine(
|
|
467
|
+
machine: EpsilonMachine,
|
|
468
|
+
mapping: Mapping[Hashable, Hashable],
|
|
469
|
+
) -> EpsilonMachine:
|
|
470
|
+
from sofic.generators.stochastic import normalize_row_weights
|
|
471
|
+
|
|
472
|
+
graph = TransitionGraph()
|
|
473
|
+
for state in machine.states():
|
|
474
|
+
attrs = machine.graph.state_attrs(state)
|
|
475
|
+
graph.add_state(mapping[state], **attrs)
|
|
476
|
+
for state in machine.states():
|
|
477
|
+
outgoing = list(machine.graph.out_transitions(state))
|
|
478
|
+
merged: dict[tuple[Hashable, Any], Any] = {}
|
|
479
|
+
for transition in outgoing:
|
|
480
|
+
prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
|
|
481
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
482
|
+
key = (mapping[transition.target], emission)
|
|
483
|
+
if key in merged:
|
|
484
|
+
merged[key] = sum_probs([merged[key], prob])
|
|
485
|
+
else:
|
|
486
|
+
merged[key] = prob
|
|
487
|
+
merged = normalize_row_weights(merged)
|
|
488
|
+
source = mapping[state]
|
|
489
|
+
for (target, emission), prob in merged.items():
|
|
490
|
+
attrs = {ATTR_PROB: as_prob(prob)}
|
|
491
|
+
if emission is not None:
|
|
492
|
+
attrs[ATTR_EMISSION] = emission
|
|
493
|
+
graph.add_transition(source, target, **attrs)
|
|
494
|
+
initial = {mapping[state]: as_prob(prob) for state, prob in machine.initial_distribution.items()}
|
|
495
|
+
eps = EpsilonMachine(
|
|
496
|
+
graph=graph,
|
|
497
|
+
initial_distribution=initial,
|
|
498
|
+
observation_alphabet=machine.observation_alphabet,
|
|
499
|
+
)
|
|
500
|
+
eps.validate()
|
|
501
|
+
return eps
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _relabel_collision_free(
|
|
505
|
+
reverse: EpsilonMachine,
|
|
506
|
+
forward: EpsilonMachine,
|
|
507
|
+
) -> EpsilonMachine:
|
|
508
|
+
"""Relabel ``reverse`` to sequential capital letters when names collide with ``forward``."""
|
|
509
|
+
if not set(reverse.states()) & set(forward.states()):
|
|
510
|
+
return reverse
|
|
511
|
+
states = sorted(reverse.states(), key=repr)
|
|
512
|
+
start = next_sequential_label_index(forward.states())
|
|
513
|
+
labels = sequential_labels(len(states), start=start)
|
|
514
|
+
mapping = dict(zip(states, labels, strict=True))
|
|
515
|
+
return _relabel_epsilon_machine(reverse, mapping)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _validate_bidirectional_anatomy(bidir: BidirectionalEpsilonMachine, *, tol: float = 1e-9) -> None:
|
|
519
|
+
"""Ensure positive-support joint states form one weakly connected component."""
|
|
520
|
+
import networkx as nx
|
|
521
|
+
|
|
522
|
+
graph = bidir.to_networkx()
|
|
523
|
+
positive = set(bidir._joint_pi or bidir.joint_distribution())
|
|
524
|
+
if positive:
|
|
525
|
+
subgraph = graph.subgraph(positive).copy()
|
|
526
|
+
if subgraph.number_of_nodes() > 0:
|
|
527
|
+
components = list(nx.weakly_connected_components(subgraph))
|
|
528
|
+
if len(components) != 1:
|
|
529
|
+
raise StochasticValidationError(
|
|
530
|
+
f"bidirectional machine has {len(components)} weak components on positive support"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def infer_reverse_epsilon_machine(forward: EpsilonMachine) -> EpsilonMachine:
|
|
535
|
+
"""Infer a reverse ε-machine presentation for bidirectional construction.
|
|
536
|
+
|
|
537
|
+
Time-reverses ``forward``, then builds the generator ε-machine via MSP and
|
|
538
|
+
probabilistic state merging (:meth:`EpsilonMachine.from_hmm`).
|
|
539
|
+
"""
|
|
540
|
+
rev_hmm = time_reverse_stochastic(forward)
|
|
541
|
+
reverse = EpsilonMachine.from_hmm(rev_hmm)
|
|
542
|
+
return _relabel_collision_free(reverse, forward)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _recurrent_support(graph: TransitionGraph, *, tol: float = 1e-12) -> set[tuple[Hashable, Hashable]]:
|
|
546
|
+
"""Keep joint states in the recurrent class with stochastic outgoing edges."""
|
|
547
|
+
keep = set(graph.states())
|
|
548
|
+
changed = True
|
|
549
|
+
while changed:
|
|
550
|
+
changed = False
|
|
551
|
+
next_keep: set[tuple[Hashable, Hashable]] = set()
|
|
552
|
+
for state in keep:
|
|
553
|
+
outgoing = [transition for transition in graph.out_transitions(state) if transition.target in keep]
|
|
554
|
+
if not outgoing:
|
|
555
|
+
changed = True
|
|
556
|
+
continue
|
|
557
|
+
probs = [as_prob(t.data.get(ATTR_PROB, 0.0)) for t in outgoing]
|
|
558
|
+
if not row_sums_to_one(probs):
|
|
559
|
+
changed = True
|
|
560
|
+
continue
|
|
561
|
+
next_keep.add(state)
|
|
562
|
+
keep = next_keep
|
|
563
|
+
return keep
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def joint_distribution(bidir: BidirectionalEpsilonMachine) -> dict[tuple[Hashable, Hashable], Any]:
|
|
567
|
+
"""Return π(α, γ) = P(S⁺ = α, S⁻ = γ) under the bidirectional stationary distribution."""
|
|
568
|
+
if bidir._joint_pi is not None:
|
|
569
|
+
return dict(bidir._joint_pi)
|
|
570
|
+
|
|
571
|
+
idx = bidir.reindex()
|
|
572
|
+
pi = stationary_distribution_hmm(bidir)
|
|
573
|
+
symbolic = pi.dtype == object or has_symbolic(pi.ravel())
|
|
574
|
+
joint: dict[tuple[Hashable, Hashable], Any] = {}
|
|
575
|
+
for index, state in enumerate(idx.states):
|
|
576
|
+
alpha, gamma = state
|
|
577
|
+
mass = as_prob(pi[index])
|
|
578
|
+
if symbolic:
|
|
579
|
+
if is_positive_mass(mass):
|
|
580
|
+
joint[(alpha, gamma)] = simplify_prob(mass)
|
|
581
|
+
elif float(mass) > 0.0:
|
|
582
|
+
joint[(alpha, gamma)] = float(mass)
|
|
583
|
+
return joint
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def bidirectional_step_distribution(bidir: BidirectionalEpsilonMachine) -> Any:
|
|
587
|
+
"""Stationary joint ``Pr(S⁺₀, S⁻₀, X₀, S⁺₁, S⁻₁)`` as a 5-RV dit Distribution.
|
|
588
|
+
|
|
589
|
+
Random-variable indices (dit ``X0`` … ``X4``):
|
|
590
|
+
|
|
591
|
+
* ``0`` — forward causal state ``S⁺₀``
|
|
592
|
+
* ``1`` — reverse causal state ``S⁻₀``
|
|
593
|
+
* ``2`` — present emission ``X₀``
|
|
594
|
+
* ``3`` — forward causal state ``S⁺₁``
|
|
595
|
+
* ``4`` — reverse causal state ``S⁻₁``
|
|
596
|
+
"""
|
|
597
|
+
dit = _require_dit_for_step()
|
|
598
|
+
|
|
599
|
+
joint = joint_distribution(bidir)
|
|
600
|
+
outcomes: list[tuple[Any, ...]] = []
|
|
601
|
+
probs: list[Any] = []
|
|
602
|
+
for (alpha, gamma), mass in joint.items():
|
|
603
|
+
if not is_positive_mass(mass):
|
|
604
|
+
continue
|
|
605
|
+
for transition in bidir.graph.out_transitions((alpha, gamma)):
|
|
606
|
+
symbol = transition.data.get(ATTR_EMISSION)
|
|
607
|
+
prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
|
|
608
|
+
if symbol is None or not is_positive_mass(prob):
|
|
609
|
+
continue
|
|
610
|
+
beta, delta = transition.target
|
|
611
|
+
if has_symbolic([mass, prob]):
|
|
612
|
+
weight = simplify_prob(as_prob(mass) * as_prob(prob))
|
|
613
|
+
else:
|
|
614
|
+
weight = float(mass) * float(prob)
|
|
615
|
+
if not is_positive_mass(weight):
|
|
616
|
+
continue
|
|
617
|
+
outcomes.append((alpha, gamma, symbol, beta, delta))
|
|
618
|
+
probs.append(weight)
|
|
619
|
+
|
|
620
|
+
if not probs:
|
|
621
|
+
raise StochasticValidationError("bidirectional step distribution is empty")
|
|
622
|
+
|
|
623
|
+
total = sum_probs(probs)
|
|
624
|
+
if has_symbolic(probs) or is_symbolic(total):
|
|
625
|
+
from dit.symbolic import symbolic_distribution
|
|
626
|
+
|
|
627
|
+
return symbolic_distribution(
|
|
628
|
+
outcomes,
|
|
629
|
+
[simplify_prob(as_prob(p) / as_prob(total)) for p in probs],
|
|
630
|
+
)
|
|
631
|
+
return dit.Distribution(outcomes, [float(p) / float(total) for p in probs])
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _require_dit_for_step():
|
|
635
|
+
from sofic.generators.measures import require_dit
|
|
636
|
+
|
|
637
|
+
return require_dit("bidirectional step distributions")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def forward_epsilon_machine(bidir: BidirectionalEpsilonMachine) -> EpsilonMachine:
|
|
641
|
+
"""Marginalize M± to recover M⁺ (paper Eq. after 2454)."""
|
|
642
|
+
return _marginalize_to_epsilon(bidir, project_forward=True)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def reverse_epsilon_machine(bidir: BidirectionalEpsilonMachine) -> EpsilonMachine:
|
|
646
|
+
"""Marginalize M± to recover M⁻ (paper Eq. after 2464)."""
|
|
647
|
+
return _marginalize_to_epsilon(bidir, project_forward=False)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _marginalize_to_epsilon(
|
|
651
|
+
bidir: BidirectionalEpsilonMachine,
|
|
652
|
+
*,
|
|
653
|
+
project_forward: bool,
|
|
654
|
+
) -> EpsilonMachine:
|
|
655
|
+
joint = joint_distribution(bidir)
|
|
656
|
+
side = bidir.forward_machine if project_forward else bidir.reverse_machine
|
|
657
|
+
coord = 0 if project_forward else 1
|
|
658
|
+
symbolic = has_symbolic(joint.values())
|
|
659
|
+
|
|
660
|
+
pi_marginal: dict[Hashable, Any] = {}
|
|
661
|
+
for pair, mass in joint.items():
|
|
662
|
+
state = pair[coord]
|
|
663
|
+
if state in pi_marginal:
|
|
664
|
+
pi_marginal[state] = sum_probs([pi_marginal[state], mass])
|
|
665
|
+
else:
|
|
666
|
+
pi_marginal[state] = as_prob(mass)
|
|
667
|
+
|
|
668
|
+
graph = TransitionGraph()
|
|
669
|
+
for state in side.states():
|
|
670
|
+
if project_forward:
|
|
671
|
+
graph.add_state(state)
|
|
672
|
+
else:
|
|
673
|
+
attrs = side.graph.state_attrs(state)
|
|
674
|
+
if ATTR_FUTURE_SYMBOL in attrs:
|
|
675
|
+
graph.add_state(state, **{ATTR_FUTURE_SYMBOL: attrs[ATTR_FUTURE_SYMBOL]})
|
|
676
|
+
else:
|
|
677
|
+
graph.add_state(state)
|
|
678
|
+
|
|
679
|
+
weights: dict[tuple[Hashable, Hashable, Any], Any] = {}
|
|
680
|
+
for pair, mass in joint.items():
|
|
681
|
+
source = pair[coord]
|
|
682
|
+
pi_source = pi_marginal.get(source, 0)
|
|
683
|
+
if not is_positive_mass(mass) or is_zero(pi_source):
|
|
684
|
+
continue
|
|
685
|
+
for transition in side.graph.out_transitions(source):
|
|
686
|
+
symbol = transition.data.get(ATTR_EMISSION)
|
|
687
|
+
prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
|
|
688
|
+
if symbol is None or not is_positive_mass(prob):
|
|
689
|
+
continue
|
|
690
|
+
key = (source, transition.target, symbol)
|
|
691
|
+
contribution = (
|
|
692
|
+
simplify_prob(as_prob(mass) * as_prob(prob) / as_prob(pi_source))
|
|
693
|
+
if symbolic or has_symbolic([mass, prob, pi_source])
|
|
694
|
+
else float(mass) * float(prob) / float(pi_source)
|
|
695
|
+
)
|
|
696
|
+
if key in weights:
|
|
697
|
+
weights[key] = sum_probs([weights[key], contribution])
|
|
698
|
+
else:
|
|
699
|
+
weights[key] = contribution
|
|
700
|
+
|
|
701
|
+
for (source, target, symbol), prob in weights.items():
|
|
702
|
+
if not is_positive_mass(prob):
|
|
703
|
+
continue
|
|
704
|
+
existing = [
|
|
705
|
+
t for t in graph.out_transitions(source) if t.data.get(ATTR_EMISSION) == symbol and t.target == target
|
|
706
|
+
]
|
|
707
|
+
if existing:
|
|
708
|
+
continue
|
|
709
|
+
graph.add_transition(source, target, **{ATTR_PROB: as_prob(prob), ATTR_EMISSION: symbol})
|
|
710
|
+
|
|
711
|
+
eps = EpsilonMachine(
|
|
712
|
+
graph=graph,
|
|
713
|
+
initial_distribution=pi_marginal,
|
|
714
|
+
observation_alphabet=bidir.observation_alphabet,
|
|
715
|
+
)
|
|
716
|
+
eps.validate()
|
|
717
|
+
return eps
|