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,201 @@
|
|
|
1
|
+
"""Deterministic and weighted finite-state transducers.
|
|
2
|
+
|
|
3
|
+
The finite-automata reading of the transducer stack (Mohri, *Weighted Automata
|
|
4
|
+
Algorithms*, in *Handbook of Weighted Automata*, 2009; Roche & Schabes, *Finite-
|
|
5
|
+
State Language Processing*, 1997):
|
|
6
|
+
|
|
7
|
+
* :class:`SubsequentialTransducer` -- an input-deterministic (sequential)
|
|
8
|
+
transducer augmented with a per-state final-output string, the classic
|
|
9
|
+
subsequential transducer of Schutzenberger/Mohri.
|
|
10
|
+
* :class:`WeightedFiniteStateTransducer` -- a transducer whose edges carry
|
|
11
|
+
weights in a semiring (probability or tropical), generalizing the stochastic
|
|
12
|
+
Mealy machine used by the ε-transducer.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from collections.abc import Hashable, Sequence
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from sofic.automata.transducers import MealyMachine
|
|
23
|
+
from sofic.exceptions import SoficValidationError
|
|
24
|
+
from sofic.graph import ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, EPSILON
|
|
25
|
+
|
|
26
|
+
SEMIRINGS = ("probability", "tropical", "log")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SubsequentialTransducer(MealyMachine):
|
|
30
|
+
"""Input-deterministic transducer with a per-state final output string.
|
|
31
|
+
|
|
32
|
+
A subsequential transducer reads its input left to right along the unique
|
|
33
|
+
matching path and, on reaching the end of input, appends the ``final_output``
|
|
34
|
+
string of the state it stops in.
|
|
35
|
+
|
|
36
|
+
Examples
|
|
37
|
+
--------
|
|
38
|
+
>>> from sofic.automata.subsequential import SubsequentialTransducer
|
|
39
|
+
>>> t = SubsequentialTransducer(
|
|
40
|
+
... input_alphabet=frozenset("ab"),
|
|
41
|
+
... output_alphabet=frozenset("xy"),
|
|
42
|
+
... initial_states=frozenset({"q0"}),
|
|
43
|
+
... final_output={"q0": ("y",)},
|
|
44
|
+
... )
|
|
45
|
+
>>> t.graph.add_state("q0")
|
|
46
|
+
>>> _ = t.add_transition("q0", "q0", "a", "x")
|
|
47
|
+
>>> sorted(t.transduce("aa"))
|
|
48
|
+
[('x', 'x', 'y')]
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
final_output: dict[Hashable, tuple[Any, ...]]
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
input_alphabet: frozenset[Any] | None = None,
|
|
56
|
+
output_alphabet: frozenset[Any] | None = None,
|
|
57
|
+
initial_states: frozenset[Hashable] | None = None,
|
|
58
|
+
final_output: dict[Hashable, Sequence[Any]] | None = None,
|
|
59
|
+
**kwargs: Any,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(
|
|
62
|
+
input_alphabet=input_alphabet,
|
|
63
|
+
output_alphabet=output_alphabet,
|
|
64
|
+
initial_states=initial_states,
|
|
65
|
+
**kwargs,
|
|
66
|
+
)
|
|
67
|
+
self.final_output = {state: tuple(word) for state, word in (final_output or {}).items()}
|
|
68
|
+
|
|
69
|
+
def validate(self) -> None:
|
|
70
|
+
super().validate()
|
|
71
|
+
from sofic.properties import is_sequential_transducer
|
|
72
|
+
|
|
73
|
+
self._require(is_sequential_transducer(self), "subsequential transducer must be input-deterministic")
|
|
74
|
+
for state, word in self.final_output.items():
|
|
75
|
+
self._require(self.graph.has_state(state), f"unknown final-output state {state!r}")
|
|
76
|
+
for symbol in word:
|
|
77
|
+
if symbol is EPSILON:
|
|
78
|
+
continue
|
|
79
|
+
self._require(symbol in self.output_alphabet, f"final output {symbol!r} not in output alphabet")
|
|
80
|
+
|
|
81
|
+
def is_subsequential(self) -> bool:
|
|
82
|
+
"""Return whether this is a valid subsequential transducer."""
|
|
83
|
+
from sofic.properties import is_subsequential_transducer
|
|
84
|
+
|
|
85
|
+
return is_subsequential_transducer(self)
|
|
86
|
+
|
|
87
|
+
def transduce(self, word: Sequence[Any]) -> set[tuple[Any, ...]]:
|
|
88
|
+
results: set[tuple[Any, ...]] = set()
|
|
89
|
+
for state, output in self._walk(word):
|
|
90
|
+
results.add(output + self.final_output.get(state, ()))
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
def _walk(self, word: Sequence[Any]) -> set[tuple[Hashable, tuple[Any, ...]]]:
|
|
94
|
+
configs: set[tuple[Hashable, tuple[Any, ...]]] = {(state, ()) for state in self.initial_states}
|
|
95
|
+
for symbol in word:
|
|
96
|
+
nxt: set[tuple[Hashable, tuple[Any, ...]]] = set()
|
|
97
|
+
for state, output in configs:
|
|
98
|
+
for transition in self.graph.out_transitions(state):
|
|
99
|
+
if transition.data.get(ATTR_SYMBOL) != symbol:
|
|
100
|
+
continue
|
|
101
|
+
emitted = transition.data.get(ATTR_OUTPUT)
|
|
102
|
+
extended = output + ((emitted,) if emitted is not None and emitted is not EPSILON else ())
|
|
103
|
+
nxt.add((transition.target, extended))
|
|
104
|
+
configs = nxt
|
|
105
|
+
return configs
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class WeightedFiniteStateTransducer(MealyMachine):
|
|
109
|
+
"""Finite-state transducer with edge weights in a semiring.
|
|
110
|
+
|
|
111
|
+
The weight of an ``(input, output)`` pair is the semiring sum over all
|
|
112
|
+
matching paths of the semiring product of their edge weights. The
|
|
113
|
+
``probability`` semiring uses ``(+, x, 0, 1)`` (edge weight = probability);
|
|
114
|
+
the ``tropical`` semiring uses ``(min, +, +inf, 0)`` (edge weight = cost),
|
|
115
|
+
yielding shortest-path / Viterbi weights.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
semiring: str
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
input_alphabet: frozenset[Any] | None = None,
|
|
123
|
+
output_alphabet: frozenset[Any] | None = None,
|
|
124
|
+
initial_states: frozenset[Hashable] | None = None,
|
|
125
|
+
semiring: str = "probability",
|
|
126
|
+
**kwargs: Any,
|
|
127
|
+
) -> None:
|
|
128
|
+
super().__init__(
|
|
129
|
+
input_alphabet=input_alphabet,
|
|
130
|
+
output_alphabet=output_alphabet,
|
|
131
|
+
initial_states=initial_states,
|
|
132
|
+
**kwargs,
|
|
133
|
+
)
|
|
134
|
+
self.semiring = semiring
|
|
135
|
+
|
|
136
|
+
def validate(self) -> None:
|
|
137
|
+
super().validate()
|
|
138
|
+
if self.semiring not in SEMIRINGS:
|
|
139
|
+
raise SoficValidationError(f"unknown semiring {self.semiring!r}; expected one of {SEMIRINGS}")
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_transducer(
|
|
143
|
+
cls,
|
|
144
|
+
transducer: MealyMachine,
|
|
145
|
+
*,
|
|
146
|
+
semiring: str = "probability",
|
|
147
|
+
) -> WeightedFiniteStateTransducer:
|
|
148
|
+
"""Build a WFST from a (probability-weighted) Mealy machine.
|
|
149
|
+
|
|
150
|
+
For the ``tropical`` and ``log`` semirings each edge probability ``p`` is
|
|
151
|
+
converted to the cost ``-log p``.
|
|
152
|
+
"""
|
|
153
|
+
graph = transducer.graph.copy()
|
|
154
|
+
if semiring in {"tropical", "log"}:
|
|
155
|
+
for _s, _t, data in graph.nx.edges(data=True):
|
|
156
|
+
prob = float(data.get(ATTR_PROB, 1.0))
|
|
157
|
+
data[ATTR_PROB] = math.inf if prob <= 0.0 else -math.log(prob)
|
|
158
|
+
result = cls(
|
|
159
|
+
input_alphabet=frozenset(transducer.input_alphabet),
|
|
160
|
+
output_alphabet=frozenset(transducer.output_alphabet),
|
|
161
|
+
initial_states=frozenset(transducer.initial_states),
|
|
162
|
+
semiring=semiring,
|
|
163
|
+
graph=graph,
|
|
164
|
+
)
|
|
165
|
+
result.validate()
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def weight(self, inputs: Sequence[Any], outputs: Sequence[Any]) -> float:
|
|
169
|
+
"""Return the total semiring weight of reading ``inputs`` emitting ``outputs``."""
|
|
170
|
+
if len(inputs) != len(outputs):
|
|
171
|
+
return self._zero()
|
|
172
|
+
frontier: dict[Hashable, float] = {state: self._one() for state in self.initial_states}
|
|
173
|
+
for input_symbol, output_symbol in zip(inputs, outputs, strict=True):
|
|
174
|
+
nxt: dict[Hashable, float] = defaultdict(self._zero)
|
|
175
|
+
for state, accumulated in frontier.items():
|
|
176
|
+
for transition in self.graph.out_transitions(state):
|
|
177
|
+
if transition.data.get(ATTR_SYMBOL) != input_symbol:
|
|
178
|
+
continue
|
|
179
|
+
if transition.data.get(ATTR_OUTPUT) != output_symbol:
|
|
180
|
+
continue
|
|
181
|
+
edge = float(transition.data.get(ATTR_PROB, self._one()))
|
|
182
|
+
nxt[transition.target] = self._plus(nxt[transition.target], self._times(accumulated, edge))
|
|
183
|
+
frontier = dict(nxt)
|
|
184
|
+
if not frontier:
|
|
185
|
+
return self._zero()
|
|
186
|
+
total = self._zero()
|
|
187
|
+
for value in frontier.values():
|
|
188
|
+
total = self._plus(total, value)
|
|
189
|
+
return total
|
|
190
|
+
|
|
191
|
+
def _one(self) -> float:
|
|
192
|
+
return 0.0 if self.semiring in {"tropical", "log"} else 1.0
|
|
193
|
+
|
|
194
|
+
def _zero(self) -> float:
|
|
195
|
+
return math.inf if self.semiring in {"tropical", "log"} else 0.0
|
|
196
|
+
|
|
197
|
+
def _times(self, left: float, right: float) -> float:
|
|
198
|
+
return left + right if self.semiring in {"tropical", "log"} else left * right
|
|
199
|
+
|
|
200
|
+
def _plus(self, left: float, right: float) -> float:
|
|
201
|
+
return min(left, right) if self.semiring in {"tropical", "log"} else left + right
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Composition and probability-aware helpers for finite-state transducers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Hashable, Iterable, Sequence
|
|
7
|
+
from itertools import product
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from sofic.automata.transducers import ERROR_STATE, ERROR_SYMBOL, MealyMachine
|
|
13
|
+
from sofic.generators.base import HiddenMarkovModel
|
|
14
|
+
from sofic.generators.mealy import MealyHMM
|
|
15
|
+
from sofic.graph import ATTR_EMISSION, ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, EPSILON
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cartesian_product_gg(
|
|
19
|
+
generators: Sequence[HiddenMarkovModel],
|
|
20
|
+
*,
|
|
21
|
+
create_using: type[MealyHMM] | None = None,
|
|
22
|
+
) -> MealyHMM:
|
|
23
|
+
"""Return the Cartesian product generator with tuple-valued emissions."""
|
|
24
|
+
if not generators:
|
|
25
|
+
raise ValueError("at least one generator is required")
|
|
26
|
+
mealy_generators = [generator.to_mealy() for generator in generators]
|
|
27
|
+
cls = create_using or MealyHMM
|
|
28
|
+
|
|
29
|
+
states_by_model = [tuple(generator.states()) for generator in mealy_generators]
|
|
30
|
+
initial: dict[tuple[Hashable, ...], float] = {}
|
|
31
|
+
for state_tuple in product(*states_by_model):
|
|
32
|
+
mass = 1.0
|
|
33
|
+
for generator, state in zip(mealy_generators, state_tuple, strict=True):
|
|
34
|
+
mass *= float(generator.initial_distribution.get(state, 0.0))
|
|
35
|
+
if mass:
|
|
36
|
+
initial[state_tuple] = mass
|
|
37
|
+
|
|
38
|
+
alphabets = [tuple(generator.observation_alphabet) for generator in mealy_generators]
|
|
39
|
+
observation_alphabet = frozenset(product(*alphabets)) if all(alphabets) else frozenset()
|
|
40
|
+
result = cls(initial_distribution=initial, observation_alphabet=observation_alphabet)
|
|
41
|
+
for state_tuple in product(*states_by_model):
|
|
42
|
+
result.graph.add_state(state_tuple)
|
|
43
|
+
|
|
44
|
+
edge_map: dict[tuple[tuple[Hashable, ...], tuple[Hashable, ...], tuple[Any, ...]], float] = defaultdict(float)
|
|
45
|
+
for source_tuple in product(*states_by_model):
|
|
46
|
+
outgoing_groups = [
|
|
47
|
+
list(generator.graph.out_transitions(state))
|
|
48
|
+
for generator, state in zip(mealy_generators, source_tuple, strict=True)
|
|
49
|
+
]
|
|
50
|
+
for edge_tuple in product(*outgoing_groups):
|
|
51
|
+
target = tuple(edge.target for edge in edge_tuple)
|
|
52
|
+
emission = tuple(edge.data.get(ATTR_EMISSION) for edge in edge_tuple)
|
|
53
|
+
prob = float(np.prod([_prob(edge.data) for edge in edge_tuple]))
|
|
54
|
+
edge_map[(source_tuple, target, emission)] += prob
|
|
55
|
+
|
|
56
|
+
for (source, target, emission), prob in edge_map.items():
|
|
57
|
+
if prob:
|
|
58
|
+
result.graph.add_transition(source, target, **{ATTR_EMISSION: emission, ATTR_PROB: prob})
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cartesian_product_tt(
|
|
63
|
+
transducers: Sequence[MealyMachine],
|
|
64
|
+
*,
|
|
65
|
+
create_using: type[MealyMachine] | None = None,
|
|
66
|
+
normalize: bool = True,
|
|
67
|
+
) -> MealyMachine:
|
|
68
|
+
"""Return the Cartesian product transducer with tuple-valued input/output symbols."""
|
|
69
|
+
if not transducers:
|
|
70
|
+
raise ValueError("at least one transducer is required")
|
|
71
|
+
cls = create_using or MealyMachine
|
|
72
|
+
states_by_model = [tuple(transducer.states()) for transducer in transducers]
|
|
73
|
+
initial_states = frozenset(product(*[tuple(t.initial_states) for t in transducers]))
|
|
74
|
+
input_alphabet = frozenset(product(*[tuple(t.alphabets()[0]) for t in transducers]))
|
|
75
|
+
output_alphabet = frozenset(product(*[tuple(t.alphabets()[1]) for t in transducers]))
|
|
76
|
+
|
|
77
|
+
edges: list[tuple[Hashable, Hashable, Any, Any, float]] = []
|
|
78
|
+
for source_tuple in product(*states_by_model):
|
|
79
|
+
outgoing_groups = [
|
|
80
|
+
list(transducer.graph.out_transitions(state))
|
|
81
|
+
for transducer, state in zip(transducers, source_tuple, strict=True)
|
|
82
|
+
]
|
|
83
|
+
for edge_tuple in product(*outgoing_groups):
|
|
84
|
+
target = tuple(edge.target for edge in edge_tuple)
|
|
85
|
+
input_symbol = tuple(_input(edge.data) for edge in edge_tuple)
|
|
86
|
+
output_symbol = tuple(_output(edge.data) for edge in edge_tuple)
|
|
87
|
+
prob = float(np.prod([_prob(edge.data) for edge in edge_tuple]))
|
|
88
|
+
edges.append((source_tuple, target, input_symbol, output_symbol, prob))
|
|
89
|
+
|
|
90
|
+
result = _build_transducer(
|
|
91
|
+
edges,
|
|
92
|
+
cls=cls,
|
|
93
|
+
states=product(*states_by_model),
|
|
94
|
+
initial_states=initial_states,
|
|
95
|
+
input_alphabet=input_alphabet,
|
|
96
|
+
output_alphabet=output_alphabet,
|
|
97
|
+
)
|
|
98
|
+
if normalize:
|
|
99
|
+
_normalize_transducer_rows(result)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def compose_tt(
|
|
104
|
+
transducers: Sequence[MealyMachine],
|
|
105
|
+
*,
|
|
106
|
+
complete: bool = True,
|
|
107
|
+
create_using: type[MealyMachine] | None = None,
|
|
108
|
+
normalize: bool = True,
|
|
109
|
+
) -> MealyMachine:
|
|
110
|
+
"""Serially compose transducers.
|
|
111
|
+
|
|
112
|
+
``compose_tt((t0, t1))`` returns the transducer that feeds ``t0``'s output
|
|
113
|
+
into ``t1``. State labels are tuples ordered like the input transducers.
|
|
114
|
+
"""
|
|
115
|
+
if not transducers:
|
|
116
|
+
raise ValueError("at least one transducer is required")
|
|
117
|
+
result = transducers[0].copy()
|
|
118
|
+
for transducer in transducers[1:]:
|
|
119
|
+
result = _compose_pair_tt(result, transducer, complete=complete, create_using=create_using, normalize=normalize)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def compose_tg(
|
|
124
|
+
transducer: MealyMachine,
|
|
125
|
+
generator: HiddenMarkovModel,
|
|
126
|
+
*,
|
|
127
|
+
complete: bool = True,
|
|
128
|
+
joint: bool = True,
|
|
129
|
+
normalize: bool = True,
|
|
130
|
+
create_using: type[MealyHMM] | None = None,
|
|
131
|
+
) -> MealyHMM:
|
|
132
|
+
"""Compose a transducer with a generator.
|
|
133
|
+
|
|
134
|
+
The result is a generator on joint ``(input, output)`` emissions by default.
|
|
135
|
+
Set ``joint=False`` to marginalize to output symbols only.
|
|
136
|
+
"""
|
|
137
|
+
gen = generator.to_mealy()
|
|
138
|
+
generator_alphabet = frozenset(gen.observation_alphabet)
|
|
139
|
+
transducer_inputs = transducer.alphabets()[0]
|
|
140
|
+
if generator_alphabet and transducer_inputs and not (generator_alphabet & transducer_inputs):
|
|
141
|
+
raise ValueError("generator outputs do not intersect transducer inputs")
|
|
142
|
+
|
|
143
|
+
work = transducer.complete(generator_alphabet, copy=True) if complete else transducer.copy()
|
|
144
|
+
cls = create_using or MealyHMM
|
|
145
|
+
t_initial = _transducer_initial_distribution(work)
|
|
146
|
+
initial: dict[tuple[Hashable, Hashable], float] = {}
|
|
147
|
+
for g_state, g_mass in gen.initial_distribution.items():
|
|
148
|
+
for t_state, t_mass in t_initial.items():
|
|
149
|
+
mass = float(g_mass) * float(t_mass)
|
|
150
|
+
if mass:
|
|
151
|
+
initial[(g_state, t_state)] = mass
|
|
152
|
+
|
|
153
|
+
states = [(g_state, t_state) for g_state in gen.states() for t_state in work.states()]
|
|
154
|
+
edge_map: dict[tuple[tuple[Hashable, Hashable], tuple[Hashable, Hashable], Any], float] = defaultdict(float)
|
|
155
|
+
for g_state, t_state in states:
|
|
156
|
+
source = (g_state, t_state)
|
|
157
|
+
for t_edge in work.graph.out_transitions(t_state):
|
|
158
|
+
if _input(t_edge.data) is EPSILON:
|
|
159
|
+
emission = _output(t_edge.data)
|
|
160
|
+
edge_map[(source, (g_state, t_edge.target), emission)] += _prob(t_edge.data)
|
|
161
|
+
for g_edge in gen.graph.out_transitions(g_state):
|
|
162
|
+
input_symbol = g_edge.data.get(ATTR_EMISSION)
|
|
163
|
+
if input_symbol is None:
|
|
164
|
+
continue
|
|
165
|
+
for t_edge in work.graph.out_transitions(t_state):
|
|
166
|
+
if _input(t_edge.data) != input_symbol:
|
|
167
|
+
continue
|
|
168
|
+
output_symbol = _output(t_edge.data)
|
|
169
|
+
emission = (input_symbol, output_symbol) if joint else output_symbol
|
|
170
|
+
prob = _prob(g_edge.data) * _prob(t_edge.data)
|
|
171
|
+
edge_map[(source, (g_edge.target, t_edge.target), emission)] += prob
|
|
172
|
+
|
|
173
|
+
observation_alphabet = frozenset(emission for _source, _target, emission in edge_map)
|
|
174
|
+
result = cls(initial_distribution=initial, observation_alphabet=observation_alphabet)
|
|
175
|
+
for state in states:
|
|
176
|
+
result.graph.add_state(state)
|
|
177
|
+
for (source, target, emission), prob in edge_map.items():
|
|
178
|
+
if prob:
|
|
179
|
+
result.graph.add_transition(source, target, **{ATTR_EMISSION: emission, ATTR_PROB: prob})
|
|
180
|
+
if normalize:
|
|
181
|
+
_normalize_hmm_rows(result)
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def transduce_generator(
|
|
186
|
+
transducer: MealyMachine,
|
|
187
|
+
generator: HiddenMarkovModel,
|
|
188
|
+
*,
|
|
189
|
+
complete: bool = True,
|
|
190
|
+
normalize: bool = True,
|
|
191
|
+
create_using: type[MealyHMM] | None = None,
|
|
192
|
+
) -> MealyHMM:
|
|
193
|
+
"""Return the output-only generator induced by driving ``transducer`` with ``generator``."""
|
|
194
|
+
return compose_tg(
|
|
195
|
+
transducer,
|
|
196
|
+
generator,
|
|
197
|
+
complete=complete,
|
|
198
|
+
joint=False,
|
|
199
|
+
normalize=normalize,
|
|
200
|
+
create_using=create_using,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _compose_pair_tt(
|
|
205
|
+
left: MealyMachine,
|
|
206
|
+
right: MealyMachine,
|
|
207
|
+
*,
|
|
208
|
+
complete: bool,
|
|
209
|
+
create_using: type[MealyMachine] | None,
|
|
210
|
+
normalize: bool,
|
|
211
|
+
) -> MealyMachine:
|
|
212
|
+
left_work = left.complete(copy=True) if complete else left.copy()
|
|
213
|
+
right_alphabet = right.alphabets()[0] | left_work.alphabets()[1]
|
|
214
|
+
right_work = right.complete(right_alphabet, copy=True) if complete else right.copy()
|
|
215
|
+
cls = create_using or MealyMachine
|
|
216
|
+
|
|
217
|
+
left_states = tuple(left_work.states())
|
|
218
|
+
right_states = tuple(right_work.states())
|
|
219
|
+
states = [(left_state, right_state) for left_state in left_states for right_state in right_states]
|
|
220
|
+
initial_states = frozenset(product(left_work.initial_states, right_work.initial_states))
|
|
221
|
+
input_alphabet = left_work.alphabets()[0]
|
|
222
|
+
output_alphabet = right_work.alphabets()[1]
|
|
223
|
+
|
|
224
|
+
edges: list[tuple[Hashable, Hashable, Any, Any, float]] = []
|
|
225
|
+
for left_state, right_state in states:
|
|
226
|
+
source = (left_state, right_state)
|
|
227
|
+
for right_edge in right_work.graph.out_transitions(right_state):
|
|
228
|
+
if _input(right_edge.data) is EPSILON:
|
|
229
|
+
edges.append(
|
|
230
|
+
(
|
|
231
|
+
source,
|
|
232
|
+
(left_state, right_edge.target),
|
|
233
|
+
EPSILON,
|
|
234
|
+
_output(right_edge.data),
|
|
235
|
+
_prob(right_edge.data),
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
for left_edge in left_work.graph.out_transitions(left_state):
|
|
239
|
+
left_input = _input(left_edge.data)
|
|
240
|
+
middle = _output(left_edge.data)
|
|
241
|
+
if middle is EPSILON:
|
|
242
|
+
edges.append((source, (left_edge.target, right_state), left_input, EPSILON, _prob(left_edge.data)))
|
|
243
|
+
continue
|
|
244
|
+
for right_edge in right_work.graph.out_transitions(right_state):
|
|
245
|
+
if _input(right_edge.data) != middle:
|
|
246
|
+
continue
|
|
247
|
+
edges.append(
|
|
248
|
+
(
|
|
249
|
+
source,
|
|
250
|
+
(left_edge.target, right_edge.target),
|
|
251
|
+
left_input,
|
|
252
|
+
_output(right_edge.data),
|
|
253
|
+
_prob(left_edge.data) * _prob(right_edge.data),
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
result = _build_transducer(
|
|
258
|
+
edges,
|
|
259
|
+
cls=cls,
|
|
260
|
+
states=states,
|
|
261
|
+
initial_states=initial_states,
|
|
262
|
+
input_alphabet=input_alphabet,
|
|
263
|
+
output_alphabet=output_alphabet,
|
|
264
|
+
)
|
|
265
|
+
if normalize:
|
|
266
|
+
_normalize_transducer_rows(result)
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _build_transducer(
|
|
271
|
+
edges: Iterable[tuple[Hashable, Hashable, Any, Any, float]],
|
|
272
|
+
*,
|
|
273
|
+
cls: type[MealyMachine],
|
|
274
|
+
states: Iterable[Hashable],
|
|
275
|
+
initial_states: frozenset[Hashable],
|
|
276
|
+
input_alphabet: frozenset[Any],
|
|
277
|
+
output_alphabet: frozenset[Any],
|
|
278
|
+
) -> MealyMachine:
|
|
279
|
+
merged: dict[tuple[Hashable, Hashable, Any, Any], float] = defaultdict(float)
|
|
280
|
+
all_states = list(dict.fromkeys(states))
|
|
281
|
+
for source, target, input_symbol, output_symbol, prob in edges:
|
|
282
|
+
all_states.extend([source, target])
|
|
283
|
+
merged[(source, target, input_symbol, output_symbol)] += float(prob)
|
|
284
|
+
|
|
285
|
+
result = cls(
|
|
286
|
+
input_alphabet=frozenset(symbol for symbol in input_alphabet if symbol is not EPSILON),
|
|
287
|
+
output_alphabet=frozenset(symbol for symbol in output_alphabet if symbol is not EPSILON),
|
|
288
|
+
initial_states=initial_states,
|
|
289
|
+
)
|
|
290
|
+
for state in dict.fromkeys(all_states):
|
|
291
|
+
result.graph.add_state(state)
|
|
292
|
+
for (source, target, input_symbol, output_symbol), prob in merged.items():
|
|
293
|
+
if prob:
|
|
294
|
+
result.add_transition(source, target, input_symbol, output_symbol, prob=prob)
|
|
295
|
+
if input_symbol is not EPSILON:
|
|
296
|
+
result.input_alphabet = result.input_alphabet | frozenset({input_symbol})
|
|
297
|
+
if output_symbol is not EPSILON:
|
|
298
|
+
result.output_alphabet = result.output_alphabet | frozenset({output_symbol})
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _normalize_transducer_rows(transducer: MealyMachine) -> None:
|
|
303
|
+
totals: dict[tuple[Hashable, Any], float] = defaultdict(float)
|
|
304
|
+
for transition in transducer.transitions():
|
|
305
|
+
totals[(transition.source, _input(transition.data))] += _prob(transition.data)
|
|
306
|
+
for source, target, key, data in transducer.graph.nx.edges(keys=True, data=True):
|
|
307
|
+
total = totals[(source, data.get(ATTR_SYMBOL, EPSILON))]
|
|
308
|
+
if total > 0.0:
|
|
309
|
+
transducer.graph.nx[source][target][key][ATTR_PROB] = float(data.get(ATTR_PROB, 1.0)) / total
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _normalize_hmm_rows(hmm: MealyHMM) -> None:
|
|
313
|
+
totals: dict[Hashable, float] = defaultdict(float)
|
|
314
|
+
for transition in hmm.transitions():
|
|
315
|
+
totals[transition.source] += _prob(transition.data)
|
|
316
|
+
for source, target, key, data in hmm.graph.nx.edges(keys=True, data=True):
|
|
317
|
+
total = totals[source]
|
|
318
|
+
if total > 0.0:
|
|
319
|
+
hmm.graph.nx[source][target][key][ATTR_PROB] = float(data.get(ATTR_PROB, 1.0)) / total
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _transducer_initial_distribution(transducer: MealyMachine) -> dict[Hashable, float]:
|
|
323
|
+
states = tuple(transducer.initial_states) or tuple(transducer.states())
|
|
324
|
+
if not states:
|
|
325
|
+
return {}
|
|
326
|
+
mass = 1.0 / len(states)
|
|
327
|
+
return dict.fromkeys(states, mass)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _input(data: dict[str, Any]) -> Any:
|
|
331
|
+
return data.get(ATTR_SYMBOL, EPSILON)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _output(data: dict[str, Any]) -> Any:
|
|
335
|
+
return data.get(ATTR_OUTPUT, EPSILON)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _prob(data: dict[str, Any]) -> float:
|
|
339
|
+
return float(data.get(ATTR_PROB, 1.0))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
__all__ = [
|
|
343
|
+
"ERROR_STATE",
|
|
344
|
+
"ERROR_SYMBOL",
|
|
345
|
+
"cartesian_product_gg",
|
|
346
|
+
"cartesian_product_tt",
|
|
347
|
+
"compose_tg",
|
|
348
|
+
"compose_tt",
|
|
349
|
+
"transduce_generator",
|
|
350
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Input/output simulation for Mealy and Moore transducers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Iterator, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import networkx as nx
|
|
9
|
+
|
|
10
|
+
from sofic.automata._config_simulation import simulate_configs
|
|
11
|
+
from sofic.automata.transducers import MealyMachine, MooreMachine, Transducer
|
|
12
|
+
from sofic.exceptions import InfiniteTransductionError
|
|
13
|
+
from sofic.graph import ATTR_OUTPUT, ATTR_SYMBOL, EPSILON
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def transduce_mealy(mealy: MealyMachine, word: Sequence[Any]) -> set[tuple[Any, ...]]:
|
|
17
|
+
"""Return possible output sequences for ``word`` on a Mealy machine.
|
|
18
|
+
|
|
19
|
+
Each transition appends its ``ATTR_OUTPUT`` after the corresponding input symbol.
|
|
20
|
+
"""
|
|
21
|
+
return _transduce(transducer=mealy, word=word, moore=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def transduce_moore(moore: MooreMachine, word: Sequence[Any]) -> set[tuple[Any, ...]]:
|
|
25
|
+
"""Return possible output sequences for ``word`` on a Moore machine.
|
|
26
|
+
|
|
27
|
+
Outputs follow the standard Moore convention: emit the initial state's output,
|
|
28
|
+
then after each input symbol emit the output of the entered state (length
|
|
29
|
+
``len(word) + 1`` when states carry outputs).
|
|
30
|
+
"""
|
|
31
|
+
return _transduce(transducer=moore, word=word, moore=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _state_output(transducer: Transducer, state: Hashable) -> tuple[Any, ...]:
|
|
35
|
+
out = transducer.graph.state_attrs(state).get(ATTR_OUTPUT)
|
|
36
|
+
return (out,) if out is not None and out is not EPSILON else ()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _transduce(transducer: Transducer, word: Sequence[Any], *, moore: bool) -> set[tuple[Any, ...]]:
|
|
40
|
+
if not transducer.initial_states:
|
|
41
|
+
return set()
|
|
42
|
+
|
|
43
|
+
initial: set[tuple[Hashable, tuple[Any, ...]]] = set()
|
|
44
|
+
for start in transducer.initial_states:
|
|
45
|
+
prefix = _state_output(transducer, start) if moore else ()
|
|
46
|
+
initial.add((start, prefix))
|
|
47
|
+
|
|
48
|
+
def step(config: tuple[Hashable, tuple[Any, ...]], symbol: Any) -> Iterator[tuple[Hashable, tuple[Any, ...]]]:
|
|
49
|
+
state, output_prefix = config
|
|
50
|
+
for transition in transducer.graph.out_transitions(state):
|
|
51
|
+
if transition.data.get(ATTR_SYMBOL) != symbol:
|
|
52
|
+
continue
|
|
53
|
+
if moore:
|
|
54
|
+
extended = output_prefix + _state_output(transducer, transition.target)
|
|
55
|
+
else:
|
|
56
|
+
out = transition.data.get(ATTR_OUTPUT)
|
|
57
|
+
extended = output_prefix + ((out,) if out is not None and out is not EPSILON else ())
|
|
58
|
+
yield (transition.target, extended)
|
|
59
|
+
|
|
60
|
+
stack = simulate_configs(
|
|
61
|
+
initial,
|
|
62
|
+
word,
|
|
63
|
+
step,
|
|
64
|
+
closure=lambda configs: _epsilon_closure(transducer, configs, moore=moore),
|
|
65
|
+
)
|
|
66
|
+
return {output_prefix for _, output_prefix in stack}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _epsilon_closure(
|
|
70
|
+
transducer: Transducer,
|
|
71
|
+
configs: set[tuple[Hashable, tuple[Any, ...]]],
|
|
72
|
+
*,
|
|
73
|
+
moore: bool,
|
|
74
|
+
) -> set[tuple[Hashable, tuple[Any, ...]]]:
|
|
75
|
+
if not configs:
|
|
76
|
+
return set()
|
|
77
|
+
starts = {state for state, _prefix in configs}
|
|
78
|
+
if _has_productive_epsilon_cycle(transducer, starts, moore=moore):
|
|
79
|
+
raise InfiniteTransductionError("productive epsilon-input cycle gives infinitely many outputs")
|
|
80
|
+
|
|
81
|
+
closure = set(configs)
|
|
82
|
+
stack = list(configs)
|
|
83
|
+
while stack:
|
|
84
|
+
state, output_prefix = stack.pop()
|
|
85
|
+
for transition in transducer.graph.out_transitions(state):
|
|
86
|
+
if transition.data.get(ATTR_SYMBOL, EPSILON) is not EPSILON:
|
|
87
|
+
continue
|
|
88
|
+
if moore:
|
|
89
|
+
extended = output_prefix + _state_output(transducer, transition.target)
|
|
90
|
+
else:
|
|
91
|
+
out = transition.data.get(ATTR_OUTPUT)
|
|
92
|
+
extended = output_prefix + ((out,) if out is not None and out is not EPSILON else ())
|
|
93
|
+
config = (transition.target, extended)
|
|
94
|
+
if config not in closure:
|
|
95
|
+
closure.add(config)
|
|
96
|
+
stack.append(config)
|
|
97
|
+
return closure
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _has_productive_epsilon_cycle(
|
|
101
|
+
transducer: Transducer,
|
|
102
|
+
starts: set[Hashable],
|
|
103
|
+
*,
|
|
104
|
+
moore: bool,
|
|
105
|
+
) -> bool:
|
|
106
|
+
eps_graph = nx.MultiDiGraph()
|
|
107
|
+
reachable: set[Hashable] = set(starts)
|
|
108
|
+
stack = list(starts)
|
|
109
|
+
while stack:
|
|
110
|
+
state = stack.pop()
|
|
111
|
+
eps_graph.add_node(state)
|
|
112
|
+
for transition in transducer.graph.out_transitions(state):
|
|
113
|
+
if transition.data.get(ATTR_SYMBOL, EPSILON) is not EPSILON:
|
|
114
|
+
continue
|
|
115
|
+
target = transition.target
|
|
116
|
+
eps_graph.add_edge(state, target, transition=transition)
|
|
117
|
+
if target not in reachable:
|
|
118
|
+
reachable.add(target)
|
|
119
|
+
stack.append(target)
|
|
120
|
+
|
|
121
|
+
for component in nx.strongly_connected_components(eps_graph):
|
|
122
|
+
if len(component) == 1:
|
|
123
|
+
state = next(iter(component))
|
|
124
|
+
has_cycle = eps_graph.has_edge(state, state)
|
|
125
|
+
else:
|
|
126
|
+
has_cycle = True
|
|
127
|
+
if not has_cycle:
|
|
128
|
+
continue
|
|
129
|
+
for source in component:
|
|
130
|
+
for target in eps_graph.successors(source):
|
|
131
|
+
if target not in component:
|
|
132
|
+
continue
|
|
133
|
+
for edge_data in eps_graph.get_edge_data(source, target).values():
|
|
134
|
+
transition = edge_data["transition"]
|
|
135
|
+
if _epsilon_edge_is_productive(transducer, transition.target, transition.data, moore=moore):
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _epsilon_edge_is_productive(
|
|
141
|
+
transducer: Transducer,
|
|
142
|
+
target: Hashable,
|
|
143
|
+
data: dict[str, Any],
|
|
144
|
+
*,
|
|
145
|
+
moore: bool,
|
|
146
|
+
) -> bool:
|
|
147
|
+
if moore:
|
|
148
|
+
return bool(_state_output(transducer, target))
|
|
149
|
+
output = data.get(ATTR_OUTPUT)
|
|
150
|
+
return output is not None and output is not EPSILON
|