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
sofic/generators/pfa.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Probabilistic finite automata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from sofic.generators.base import StochasticModel
|
|
11
|
+
from sofic.generators.edge_emissions import validate_stochastic_edge_emissions
|
|
12
|
+
from sofic.generators.mealy import MealyHMM
|
|
13
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProbabilisticFiniteAutomaton(StochasticModel):
|
|
17
|
+
"""String generator with joint symbol+probability on edges."""
|
|
18
|
+
|
|
19
|
+
output_alphabet: frozenset[Any]
|
|
20
|
+
|
|
21
|
+
def __init__(self, output_alphabet: frozenset[Any] | None = None, **kwargs: Any) -> None:
|
|
22
|
+
super().__init__(**kwargs)
|
|
23
|
+
self.output_alphabet = output_alphabet if output_alphabet is not None else frozenset()
|
|
24
|
+
|
|
25
|
+
def add_transition(self, source: Hashable, target: Hashable, symbol: Any, prob: float, **attrs: Any) -> int:
|
|
26
|
+
"""Add an edge carrying joint output probability ``P(target, symbol | source)``."""
|
|
27
|
+
return self.graph.add_transition(
|
|
28
|
+
source,
|
|
29
|
+
target,
|
|
30
|
+
**{ATTR_EMISSION: symbol, ATTR_PROB: float(prob), **attrs},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def validate_stochastic(self) -> None:
|
|
34
|
+
super().validate_stochastic()
|
|
35
|
+
validate_stochastic_edge_emissions(
|
|
36
|
+
self,
|
|
37
|
+
alphabet=self.output_alphabet,
|
|
38
|
+
alphabet_name="output",
|
|
39
|
+
row_mass_label="outgoing masses",
|
|
40
|
+
negative_probability_label="negative probability",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def is_unifilar(self) -> bool:
|
|
44
|
+
"""Return whether each state emits at most one edge per symbol."""
|
|
45
|
+
from sofic.properties import is_unifilar_emissions
|
|
46
|
+
|
|
47
|
+
return is_unifilar_emissions(self)
|
|
48
|
+
|
|
49
|
+
def to_mealy(self) -> MealyHMM:
|
|
50
|
+
from sofic.generators.conversions import pfa_to_mealy
|
|
51
|
+
|
|
52
|
+
return pfa_to_mealy(self)
|
|
53
|
+
|
|
54
|
+
def string_probability(self, word: Sequence[Any]) -> float:
|
|
55
|
+
if not word:
|
|
56
|
+
return sum(self.initial_distribution.values())
|
|
57
|
+
idx = self.reindex()
|
|
58
|
+
n = len(idx)
|
|
59
|
+
mass = np.zeros(n, dtype=float)
|
|
60
|
+
for state, prob in self.initial_distribution.items():
|
|
61
|
+
mass[idx.index(state)] = float(prob)
|
|
62
|
+
for symbol in word:
|
|
63
|
+
updated = np.zeros(n, dtype=float)
|
|
64
|
+
for transition in self.transitions():
|
|
65
|
+
if transition.data.get(ATTR_EMISSION) != symbol:
|
|
66
|
+
continue
|
|
67
|
+
i = idx.index(transition.source)
|
|
68
|
+
j = idx.index(transition.target)
|
|
69
|
+
updated[j] += mass[i] * float(transition.data.get(ATTR_PROB, 0.0))
|
|
70
|
+
mass = updated
|
|
71
|
+
return float(mass.sum())
|
|
72
|
+
|
|
73
|
+
def words_of_length(self, length: int) -> dict[tuple[Any, ...], float]:
|
|
74
|
+
"""Return output words of ``length`` and their probabilities."""
|
|
75
|
+
from sofic.generators.words import pfa_words_of_length
|
|
76
|
+
|
|
77
|
+
return pfa_words_of_length(self, length)
|
|
78
|
+
|
|
79
|
+
def sample(self, n: int, rng: np.random.Generator | None = None) -> list[Any]:
|
|
80
|
+
generator = rng if rng is not None else np.random.default_rng()
|
|
81
|
+
idx = self.reindex()
|
|
82
|
+
probs = np.array([self.initial_distribution.get(s, 0.0) for s in idx.states], dtype=float)
|
|
83
|
+
if probs.sum() <= 0.0:
|
|
84
|
+
return []
|
|
85
|
+
state = int(generator.choice(len(idx), p=probs / probs.sum()))
|
|
86
|
+
output: list[Any] = []
|
|
87
|
+
for _ in range(n):
|
|
88
|
+
outgoing = list(self.graph.out_transitions(idx.state(state)))
|
|
89
|
+
if not outgoing:
|
|
90
|
+
break
|
|
91
|
+
edge_probs = np.array([float(t.data.get(ATTR_PROB, 0.0)) for t in outgoing], dtype=float)
|
|
92
|
+
if edge_probs.sum() <= 0.0:
|
|
93
|
+
break
|
|
94
|
+
edge_probs /= edge_probs.sum()
|
|
95
|
+
edge = outgoing[int(generator.choice(len(outgoing), p=edge_probs))]
|
|
96
|
+
emission = edge.data.get(ATTR_EMISSION)
|
|
97
|
+
if emission is not None:
|
|
98
|
+
output.append(emission)
|
|
99
|
+
state = idx.index(edge.target)
|
|
100
|
+
return output
|
sofic/generators/prob.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Probability scalars: floats or sympy expressions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
Prob = Any # float | sympy.Expr
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sympy():
|
|
14
|
+
try:
|
|
15
|
+
import sympy
|
|
16
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
17
|
+
raise ImportError("Symbolic probabilities require sympy. Install with: pip install sofic[symbolic]") from exc
|
|
18
|
+
return sympy
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_symbolic(value: Any) -> bool:
|
|
22
|
+
"""Return whether ``value`` is an exact sympy expression (not a Python float).
|
|
23
|
+
|
|
24
|
+
Exact rationals / integers and expressions with free symbols are symbolic.
|
|
25
|
+
``sympy.Float`` is treated as numeric and will be coerced to ``float``.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
import sympy
|
|
29
|
+
except ImportError:
|
|
30
|
+
return False
|
|
31
|
+
if not isinstance(value, sympy.Expr):
|
|
32
|
+
return False
|
|
33
|
+
return not isinstance(value, sympy.Float)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def has_symbolic(values: Iterable[Any]) -> bool:
|
|
37
|
+
"""Return whether any entry in ``values`` is symbolic."""
|
|
38
|
+
return any(is_symbolic(value) for value in values)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def as_prob(value: Any) -> Prob:
|
|
42
|
+
"""Coerce ``value`` to a stored probability: sympy Expr pass-through, else float.
|
|
43
|
+
|
|
44
|
+
Plain Python ``int`` values are left unchanged so they compose with sympy
|
|
45
|
+
without introducing ``Float`` coefficients (``0 + a`` stays exact).
|
|
46
|
+
"""
|
|
47
|
+
if is_symbolic(value):
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, bool):
|
|
50
|
+
return float(value)
|
|
51
|
+
if isinstance(value, int):
|
|
52
|
+
return value
|
|
53
|
+
try:
|
|
54
|
+
import sympy
|
|
55
|
+
|
|
56
|
+
if isinstance(value, sympy.Basic):
|
|
57
|
+
return float(value)
|
|
58
|
+
except ImportError:
|
|
59
|
+
pass
|
|
60
|
+
return float(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def simplify_prob(value: Prob) -> Prob:
|
|
64
|
+
"""Simplify a symbolic probability; return floats/ints unchanged."""
|
|
65
|
+
if not is_symbolic(value):
|
|
66
|
+
return value
|
|
67
|
+
sp = _sympy()
|
|
68
|
+
return sp.simplify(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_zero(value: Prob, *, atol: float = 1e-15) -> bool:
|
|
72
|
+
"""Return whether ``value`` is (numerically or symbolically) zero."""
|
|
73
|
+
if is_symbolic(value):
|
|
74
|
+
sp = _sympy()
|
|
75
|
+
return sp.simplify(value) == 0
|
|
76
|
+
if isinstance(value, int):
|
|
77
|
+
return value == 0
|
|
78
|
+
try:
|
|
79
|
+
return abs(float(value)) <= atol
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_positive_mass(value: Prob, *, atol: float = 1e-15) -> bool:
|
|
85
|
+
"""Return whether ``value`` carries positive probability mass.
|
|
86
|
+
|
|
87
|
+
Symbolic expressions with free symbols are treated as positive unless they
|
|
88
|
+
simplify to a non-positive number (matching dit's free-symbol convention).
|
|
89
|
+
"""
|
|
90
|
+
if is_zero(value, atol=atol):
|
|
91
|
+
return False
|
|
92
|
+
if is_symbolic(value):
|
|
93
|
+
sp = _sympy()
|
|
94
|
+
simplified = sp.simplify(value)
|
|
95
|
+
if simplified.free_symbols:
|
|
96
|
+
return simplified.is_nonpositive is not True
|
|
97
|
+
try:
|
|
98
|
+
return float(simplified) > atol
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
return simplified != 0
|
|
101
|
+
return float(value) > atol
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def probs_equal(
|
|
105
|
+
left: Prob,
|
|
106
|
+
right: Prob,
|
|
107
|
+
*,
|
|
108
|
+
rtol: float = 1e-9,
|
|
109
|
+
atol: float = 1e-12,
|
|
110
|
+
constraints: SymbolConstraints | None = None,
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Equality for probability scalars (exact sympy simplify, else numeric close).
|
|
113
|
+
|
|
114
|
+
When ``constraints`` are supplied and either operand is symbolic, equality is
|
|
115
|
+
tested modulo the constraint ideal (see :class:`SymbolConstraints`), so
|
|
116
|
+
expressions that coincide only under a parameter's minimal polynomial are
|
|
117
|
+
recognized as equal.
|
|
118
|
+
"""
|
|
119
|
+
if is_symbolic(left) or is_symbolic(right):
|
|
120
|
+
sp = _sympy()
|
|
121
|
+
if constraints is not None:
|
|
122
|
+
return constraints.equal(left, right)
|
|
123
|
+
return sp.simplify(sp.sympify(left) - sp.sympify(right)) == 0
|
|
124
|
+
return bool(np.isclose(float(left), float(right), rtol=rtol, atol=atol))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sum_probs(values: Iterable[Prob]) -> Prob:
|
|
128
|
+
"""Sum probability scalars, preserving sympy when any term is symbolic."""
|
|
129
|
+
items = list(values)
|
|
130
|
+
if not items:
|
|
131
|
+
return 0
|
|
132
|
+
if has_symbolic(items):
|
|
133
|
+
sp = _sympy()
|
|
134
|
+
total = sp.Integer(0)
|
|
135
|
+
for value in items:
|
|
136
|
+
total += sp.sympify(value)
|
|
137
|
+
return simplify_prob(total)
|
|
138
|
+
return float(sum(float(value) for value in items))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def row_sums_to_one(probs: Sequence[Prob], *, atol: float = 1e-9) -> bool:
|
|
142
|
+
"""Return whether ``probs`` sum to one (exact sympy or numeric close)."""
|
|
143
|
+
if not probs:
|
|
144
|
+
return True
|
|
145
|
+
total = sum_probs(probs)
|
|
146
|
+
if is_symbolic(total) or has_symbolic(probs):
|
|
147
|
+
sp = _sympy()
|
|
148
|
+
# Free symbols: accept rows that are identically 1, else skip (dit-style).
|
|
149
|
+
if getattr(total, "free_symbols", None):
|
|
150
|
+
return True
|
|
151
|
+
return sp.simplify(sp.sympify(total) - 1) == 0
|
|
152
|
+
return bool(np.isclose(float(total), 1.0, atol=atol))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def zeros(shape: tuple[int, ...], *, symbolic: bool = False) -> np.ndarray:
|
|
156
|
+
"""Allocate a probability array (object dtype when ``symbolic``)."""
|
|
157
|
+
if symbolic:
|
|
158
|
+
sp = _sympy()
|
|
159
|
+
array = np.empty(shape, dtype=object)
|
|
160
|
+
array.fill(sp.Integer(0))
|
|
161
|
+
return array
|
|
162
|
+
return np.zeros(shape, dtype=float)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def array_sum(array: np.ndarray) -> Prob:
|
|
166
|
+
"""Sum an array, using Python reduction for object (sympy) arrays."""
|
|
167
|
+
flat = np.asarray(array).ravel()
|
|
168
|
+
if flat.dtype == object or has_symbolic(flat):
|
|
169
|
+
return sum_probs(flat.tolist())
|
|
170
|
+
return float(flat.sum())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def matvec(row: np.ndarray, matrix: np.ndarray) -> np.ndarray:
|
|
174
|
+
"""Row-vector times matrix, supporting object-dtype sympy entries."""
|
|
175
|
+
if row.dtype == object or matrix.dtype == object or has_symbolic(row) or has_symbolic(matrix.ravel()):
|
|
176
|
+
sp = _sympy()
|
|
177
|
+
n = matrix.shape[1]
|
|
178
|
+
out = np.empty(n, dtype=object)
|
|
179
|
+
for j in range(n):
|
|
180
|
+
total = sp.Integer(0)
|
|
181
|
+
for i in range(matrix.shape[0]):
|
|
182
|
+
total += sp.sympify(row[i]) * sp.sympify(matrix[i, j])
|
|
183
|
+
out[j] = simplify_prob(total)
|
|
184
|
+
return out
|
|
185
|
+
return row @ matrix
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def canonical_prob_key(value: Prob, constraints: SymbolConstraints | None = None) -> Any:
|
|
189
|
+
"""Hash-stable key for partition signatures (simplified sympy or float).
|
|
190
|
+
|
|
191
|
+
With ``constraints`` the key is taken in the constraint residue field, so
|
|
192
|
+
two probabilities that are equal modulo the constraints share a key.
|
|
193
|
+
"""
|
|
194
|
+
if constraints is not None:
|
|
195
|
+
return constraints.key(value)
|
|
196
|
+
if is_symbolic(value):
|
|
197
|
+
sp = _sympy()
|
|
198
|
+
return sp.srepr(sp.simplify(value))
|
|
199
|
+
return float(value)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SymbolConstraints:
|
|
203
|
+
"""Algebraic side-relations (each ``expr == 0``) over probability symbols.
|
|
204
|
+
|
|
205
|
+
Used to compare symbolic probabilities modulo the ideal they generate, so
|
|
206
|
+
that states which coincide only under a parameter's minimal polynomial (for
|
|
207
|
+
example the Misiurewicz root ``a**3 - 2*a - 2 == 0``) are recognized as
|
|
208
|
+
equal when de-duplicating beliefs or merging causal states.
|
|
209
|
+
|
|
210
|
+
When the relations reduce to a single univariate polynomial in one symbol,
|
|
211
|
+
equality and keys are computed exactly in the residue field
|
|
212
|
+
``Q[s] / (minpoly)`` via ``p * q^{-1} mod minpoly``. Otherwise a
|
|
213
|
+
Groebner-basis ideal-membership test (with a plain ``simplify`` fallback) is
|
|
214
|
+
used for equality, and the simplified ``srepr`` for keys.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, relations: Iterable[Any]) -> None:
|
|
218
|
+
sp = _sympy()
|
|
219
|
+
rels = tuple(sp.sympify(relation) for relation in relations)
|
|
220
|
+
self.relations = rels
|
|
221
|
+
symbols: set[Any] = set()
|
|
222
|
+
for relation in rels:
|
|
223
|
+
symbols |= relation.free_symbols
|
|
224
|
+
self._symbols = tuple(sorted(symbols, key=str))
|
|
225
|
+
self._var: Any | None = None
|
|
226
|
+
self._minpoly: Any | None = None
|
|
227
|
+
if len(rels) == 1 and len(self._symbols) == 1:
|
|
228
|
+
var = self._symbols[0]
|
|
229
|
+
try:
|
|
230
|
+
self._minpoly = sp.Poly(rels[0], var, domain=sp.QQ)
|
|
231
|
+
self._var = var
|
|
232
|
+
except sp.PolynomialError:
|
|
233
|
+
self._minpoly = None
|
|
234
|
+
self._var = None
|
|
235
|
+
|
|
236
|
+
def __repr__(self) -> str:
|
|
237
|
+
return f"SymbolConstraints({list(self.relations)!r})"
|
|
238
|
+
|
|
239
|
+
def __eq__(self, other: Any) -> bool:
|
|
240
|
+
return isinstance(other, SymbolConstraints) and self.relations == other.relations
|
|
241
|
+
|
|
242
|
+
def __hash__(self) -> int:
|
|
243
|
+
return hash(self.relations)
|
|
244
|
+
|
|
245
|
+
def _residue_key(self, value: Prob) -> tuple[Any, ...] | None:
|
|
246
|
+
"""Residue-field representative coefficients, or ``None`` if unavailable."""
|
|
247
|
+
if self._minpoly is None or self._var is None:
|
|
248
|
+
return None
|
|
249
|
+
if value.free_symbols - {self._var}:
|
|
250
|
+
return None
|
|
251
|
+
sp = _sympy()
|
|
252
|
+
from sympy.polys.polyerrors import NotInvertible
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
num, den = sp.fraction(sp.together(value))
|
|
256
|
+
num_poly = sp.Poly(num, self._var, domain=sp.QQ)
|
|
257
|
+
den_poly = sp.Poly(den, self._var, domain=sp.QQ)
|
|
258
|
+
den_inv = sp.invert(den_poly, self._minpoly)
|
|
259
|
+
rep = sp.rem((num_poly * den_inv).as_expr(), self._minpoly.as_expr(), self._var)
|
|
260
|
+
coeffs = sp.Poly(rep, self._var, domain=sp.QQ).all_coeffs()
|
|
261
|
+
except (sp.PolynomialError, NotInvertible, ValueError, ZeroDivisionError):
|
|
262
|
+
return None
|
|
263
|
+
return tuple(coeffs)
|
|
264
|
+
|
|
265
|
+
def key(self, value: Prob) -> Any:
|
|
266
|
+
"""Canonical, hashable key equal for probabilities equal modulo the ideal."""
|
|
267
|
+
if not is_symbolic(value):
|
|
268
|
+
return float(value)
|
|
269
|
+
residue = self._residue_key(value)
|
|
270
|
+
if residue is not None:
|
|
271
|
+
return ("mod", residue)
|
|
272
|
+
return canonical_prob_key(value)
|
|
273
|
+
|
|
274
|
+
def equal(self, left: Prob, right: Prob) -> bool:
|
|
275
|
+
"""Return whether ``left`` and ``right`` are equal modulo the constraints."""
|
|
276
|
+
sp = _sympy()
|
|
277
|
+
left_residue = self._residue_key(sp.sympify(left)) if is_symbolic(left) else None
|
|
278
|
+
right_residue = self._residue_key(sp.sympify(right)) if is_symbolic(right) else None
|
|
279
|
+
if left_residue is not None and right_residue is not None:
|
|
280
|
+
return left_residue == right_residue
|
|
281
|
+
diff = sp.sympify(left) - sp.sympify(right)
|
|
282
|
+
if self.relations:
|
|
283
|
+
try:
|
|
284
|
+
num = sp.numer(sp.together(diff))
|
|
285
|
+
basis = sp.groebner(self.relations, *self._symbols, order="lex")
|
|
286
|
+
_, remainder = sp.reduced(sp.expand(num), basis, *self._symbols)
|
|
287
|
+
if sp.simplify(remainder) == 0:
|
|
288
|
+
return True
|
|
289
|
+
except (sp.PolynomialError, ValueError, ZeroDivisionError):
|
|
290
|
+
pass
|
|
291
|
+
return sp.simplify(diff) == 0
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Finite-state process equivalence for HMM presentations.
|
|
2
|
+
|
|
3
|
+
The algorithm follows the sufficient history/future word-list construction used
|
|
4
|
+
by CMPy, based on Dan Upper's finite-dimensional process-equivalence test.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from sofic.generators.base import HiddenMarkovModel
|
|
17
|
+
from sofic.generators.hmm_inference import _emission_transition_tensors_from_mealy
|
|
18
|
+
from sofic.generators.words import _start_vector
|
|
19
|
+
|
|
20
|
+
_DEFAULT_RTOL = 1e-9
|
|
21
|
+
_DEFAULT_ATOL = 1e-12
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_equal_process(
|
|
25
|
+
g1: HiddenMarkovModel,
|
|
26
|
+
g2: HiddenMarkovModel,
|
|
27
|
+
*,
|
|
28
|
+
start1: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
|
|
29
|
+
start2: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
|
|
30
|
+
rtol: float | None = None,
|
|
31
|
+
atol: float | None = None,
|
|
32
|
+
) -> bool:
|
|
33
|
+
"""Return whether two finite HMMs generate the same word process.
|
|
34
|
+
|
|
35
|
+
The test compares finite bases for the history and future spaces rather
|
|
36
|
+
than brute-force enumerating all words up to a fixed cutoff.
|
|
37
|
+
"""
|
|
38
|
+
if set(g1.observation_alphabet) != set(g2.observation_alphabet):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
rtol = _DEFAULT_RTOL if rtol is None else float(rtol)
|
|
42
|
+
atol = _DEFAULT_ATOL if atol is None else float(atol)
|
|
43
|
+
|
|
44
|
+
hf1 = _HistoryFutureWordList.from_hmm(g1, start=start1)
|
|
45
|
+
hf2 = _HistoryFutureWordList.from_hmm(g2, start=start2)
|
|
46
|
+
|
|
47
|
+
history_words = _sorted_words(set(hf1.history_word_list()) | set(hf2.history_word_list()))
|
|
48
|
+
future_words = _sorted_words(set(hf1.future_word_list()) | set(hf2.future_word_list()))
|
|
49
|
+
|
|
50
|
+
if not _compare_future_probabilities(hf1, hf2, future_words, rtol=rtol, atol=atol):
|
|
51
|
+
return False
|
|
52
|
+
if not _compare_conditional_tables(hf1, hf2, history_words, future_words, rtol=rtol, atol=atol):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
one_step_future_words = set(future_words)
|
|
56
|
+
for word in future_words:
|
|
57
|
+
for symbol in hf1.alphabet:
|
|
58
|
+
one_step_future_words.add((symbol,) + word)
|
|
59
|
+
extended_future_words = _sorted_words(one_step_future_words)
|
|
60
|
+
return _compare_conditional_tables(hf1, hf2, history_words, extended_future_words, rtol=rtol, atol=atol)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class _HistoryFutureWordList:
|
|
65
|
+
alphabet: tuple[Any, ...]
|
|
66
|
+
matrices: dict[Any, np.ndarray]
|
|
67
|
+
start: np.ndarray
|
|
68
|
+
_word_matrices: dict[tuple[Any, ...], np.ndarray] = field(default_factory=dict)
|
|
69
|
+
_future_words: list[tuple[Any, ...]] | None = None
|
|
70
|
+
_history_words: list[tuple[Any, ...]] | None = None
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_hmm(
|
|
74
|
+
cls,
|
|
75
|
+
hmm: HiddenMarkovModel,
|
|
76
|
+
*,
|
|
77
|
+
start: Hashable | Mapping[Hashable, float] | Sequence[float] | np.ndarray | None = None,
|
|
78
|
+
) -> _HistoryFutureWordList:
|
|
79
|
+
mealy = hmm.to_mealy()
|
|
80
|
+
pi, matrices = _emission_transition_tensors_from_mealy(mealy)
|
|
81
|
+
return cls(
|
|
82
|
+
alphabet=tuple(sorted(mealy.observation_alphabet, key=repr)),
|
|
83
|
+
matrices=matrices,
|
|
84
|
+
start=_start_vector(mealy, pi, start),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def dimension(self) -> int:
|
|
89
|
+
return len(self.start)
|
|
90
|
+
|
|
91
|
+
def word_matrix(self, word: tuple[Any, ...]) -> np.ndarray:
|
|
92
|
+
if not self._word_matrices:
|
|
93
|
+
self._word_matrices[()] = np.eye(self.dimension, dtype=float)
|
|
94
|
+
cached = self._word_matrices.get(word)
|
|
95
|
+
if cached is not None:
|
|
96
|
+
return cached
|
|
97
|
+
if not word:
|
|
98
|
+
return self._word_matrices[()]
|
|
99
|
+
|
|
100
|
+
prefix = word[:-1]
|
|
101
|
+
prefix_matrix = self.word_matrix(prefix)
|
|
102
|
+
symbol_matrix = self.matrices.get(word[-1])
|
|
103
|
+
if symbol_matrix is None:
|
|
104
|
+
matrix = np.zeros((self.dimension, self.dimension), dtype=float)
|
|
105
|
+
else:
|
|
106
|
+
matrix = prefix_matrix @ symbol_matrix
|
|
107
|
+
self._word_matrices[word] = matrix
|
|
108
|
+
return matrix
|
|
109
|
+
|
|
110
|
+
def future_vector(self, word: tuple[Any, ...]) -> np.ndarray:
|
|
111
|
+
return self.word_matrix(word) @ np.ones(self.dimension, dtype=float)
|
|
112
|
+
|
|
113
|
+
def history_vector(self, word: tuple[Any, ...]) -> np.ndarray:
|
|
114
|
+
vector = self.start @ self.word_matrix(word)
|
|
115
|
+
total = float(vector.sum())
|
|
116
|
+
if total != 0.0:
|
|
117
|
+
vector = vector / total
|
|
118
|
+
return vector
|
|
119
|
+
|
|
120
|
+
def future_word_list(self) -> list[tuple[Any, ...]]:
|
|
121
|
+
if self._future_words is not None:
|
|
122
|
+
return list(self._future_words)
|
|
123
|
+
|
|
124
|
+
queue: deque[tuple[Any, ...]] = deque([()])
|
|
125
|
+
words: list[tuple[Any, ...]] = []
|
|
126
|
+
basis: list[np.ndarray] = []
|
|
127
|
+
rank = 0
|
|
128
|
+
while queue:
|
|
129
|
+
word = queue.popleft()
|
|
130
|
+
candidate = self.future_vector(word)
|
|
131
|
+
matrix = np.vstack([*basis, candidate]) if basis else np.asarray([candidate])
|
|
132
|
+
new_rank = np.linalg.matrix_rank(matrix)
|
|
133
|
+
if new_rank <= rank:
|
|
134
|
+
continue
|
|
135
|
+
rank = int(new_rank)
|
|
136
|
+
basis.append(candidate)
|
|
137
|
+
words.append(word)
|
|
138
|
+
for symbol in self.alphabet:
|
|
139
|
+
queue.append((symbol,) + word)
|
|
140
|
+
|
|
141
|
+
self._future_words = _sorted_words(words)
|
|
142
|
+
return list(self._future_words)
|
|
143
|
+
|
|
144
|
+
def history_word_list(self) -> list[tuple[Any, ...]]:
|
|
145
|
+
if self._history_words is not None:
|
|
146
|
+
return list(self._history_words)
|
|
147
|
+
|
|
148
|
+
queue: deque[tuple[Any, ...]] = deque([()])
|
|
149
|
+
words: list[tuple[Any, ...]] = []
|
|
150
|
+
basis: list[np.ndarray] = []
|
|
151
|
+
rank = 0
|
|
152
|
+
while queue:
|
|
153
|
+
word = queue.popleft()
|
|
154
|
+
candidate = self.history_vector(word)
|
|
155
|
+
matrix = np.vstack([*basis, candidate]) if basis else np.asarray([candidate])
|
|
156
|
+
new_rank = np.linalg.matrix_rank(matrix)
|
|
157
|
+
if new_rank <= rank:
|
|
158
|
+
continue
|
|
159
|
+
rank = int(new_rank)
|
|
160
|
+
basis.append(candidate)
|
|
161
|
+
words.append(word)
|
|
162
|
+
for symbol in self.alphabet:
|
|
163
|
+
queue.append(word + (symbol,))
|
|
164
|
+
|
|
165
|
+
self._history_words = _sorted_words(words)
|
|
166
|
+
return list(self._history_words)
|
|
167
|
+
|
|
168
|
+
def future_matrix(self, words: Sequence[tuple[Any, ...]]) -> np.ndarray:
|
|
169
|
+
if not words:
|
|
170
|
+
return np.zeros((self.dimension, 0), dtype=float)
|
|
171
|
+
return np.column_stack([self.future_vector(word) for word in words])
|
|
172
|
+
|
|
173
|
+
def history_matrix(self, words: Sequence[tuple[Any, ...]]) -> np.ndarray:
|
|
174
|
+
if not words:
|
|
175
|
+
return np.zeros((0, self.dimension), dtype=float)
|
|
176
|
+
return np.vstack([self.history_vector(word) for word in words])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _compare_future_probabilities(
|
|
180
|
+
hf1: _HistoryFutureWordList,
|
|
181
|
+
hf2: _HistoryFutureWordList,
|
|
182
|
+
future_words: Sequence[tuple[Any, ...]],
|
|
183
|
+
*,
|
|
184
|
+
rtol: float,
|
|
185
|
+
atol: float,
|
|
186
|
+
) -> bool:
|
|
187
|
+
probs1 = hf1.start @ hf1.future_matrix(future_words)
|
|
188
|
+
probs2 = hf2.start @ hf2.future_matrix(future_words)
|
|
189
|
+
return bool(np.allclose(probs1, probs2, rtol=rtol, atol=atol))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _compare_conditional_tables(
|
|
193
|
+
hf1: _HistoryFutureWordList,
|
|
194
|
+
hf2: _HistoryFutureWordList,
|
|
195
|
+
history_words: Sequence[tuple[Any, ...]],
|
|
196
|
+
future_words: Sequence[tuple[Any, ...]],
|
|
197
|
+
*,
|
|
198
|
+
rtol: float,
|
|
199
|
+
atol: float,
|
|
200
|
+
) -> bool:
|
|
201
|
+
table1 = hf1.history_matrix(history_words) @ hf1.future_matrix(future_words)
|
|
202
|
+
table2 = hf2.history_matrix(history_words) @ hf2.future_matrix(future_words)
|
|
203
|
+
return bool(np.allclose(table1, table2, rtol=rtol, atol=atol))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _sorted_words(words: Sequence[tuple[Any, ...]] | set[tuple[Any, ...]]) -> list[tuple[Any, ...]]:
|
|
207
|
+
return sorted(words, key=lambda word: (len(word), tuple(repr(symbol) for symbol in word)))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Matrix operations for quasi-stochastic generators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from sofic.exceptions import QuasiStochasticValidationError
|
|
11
|
+
from sofic.generators.base import QuasiStochasticModel
|
|
12
|
+
from sofic.graph import ATTR_EMISSION, ATTR_QUASIPROB
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def transition_matrices(model: QuasiStochasticModel) -> dict[Any, np.ndarray]:
|
|
16
|
+
idx = model.reindex()
|
|
17
|
+
n = len(idx)
|
|
18
|
+
matrices: dict[Any, np.ndarray] = {}
|
|
19
|
+
for transition in model.transitions():
|
|
20
|
+
emission = transition.data.get(ATTR_EMISSION)
|
|
21
|
+
if emission is None:
|
|
22
|
+
continue
|
|
23
|
+
matrix = matrices.setdefault(emission, np.zeros((n, n), dtype=float))
|
|
24
|
+
i = idx.index(transition.source)
|
|
25
|
+
j = idx.index(transition.target)
|
|
26
|
+
matrix[i, j] += float(transition.data.get(ATTR_QUASIPROB, 0.0))
|
|
27
|
+
return matrices
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stationary_quasidistribution(model: QuasiStochasticModel) -> np.ndarray:
|
|
31
|
+
idx = model.reindex()
|
|
32
|
+
n = len(idx)
|
|
33
|
+
if n == 0:
|
|
34
|
+
return np.array([], dtype=float)
|
|
35
|
+
|
|
36
|
+
combined = np.zeros((n, n), dtype=float)
|
|
37
|
+
for matrix in transition_matrices(model).values():
|
|
38
|
+
combined += matrix
|
|
39
|
+
|
|
40
|
+
distribution = np.zeros(n, dtype=float)
|
|
41
|
+
for state, mass in model.initial_quasidistribution.items():
|
|
42
|
+
distribution[idx.index(state)] = float(mass)
|
|
43
|
+
|
|
44
|
+
if distribution.sum() <= 0.0:
|
|
45
|
+
distribution = np.full(n, 1.0 / n, dtype=float)
|
|
46
|
+
|
|
47
|
+
for _ in range(10_000):
|
|
48
|
+
updated = distribution @ combined
|
|
49
|
+
if np.allclose(updated, distribution, rtol=1e-10, atol=1e-12):
|
|
50
|
+
distribution = updated
|
|
51
|
+
break
|
|
52
|
+
distribution = updated
|
|
53
|
+
|
|
54
|
+
total = distribution.sum()
|
|
55
|
+
if abs(total) <= 0.0:
|
|
56
|
+
raise QuasiStochasticValidationError("failed to compute stationary quasidistribution")
|
|
57
|
+
return distribution / total
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def word_probability(model: QuasiStochasticModel, word: Sequence[Any]) -> float:
|
|
61
|
+
idx = model.reindex()
|
|
62
|
+
n = len(idx)
|
|
63
|
+
pi = np.zeros(n, dtype=float)
|
|
64
|
+
for state, mass in model.initial_quasidistribution.items():
|
|
65
|
+
pi[idx.index(state)] = float(mass)
|
|
66
|
+
ones = np.ones(n, dtype=float)
|
|
67
|
+
matrices = transition_matrices(model)
|
|
68
|
+
result = pi
|
|
69
|
+
for symbol in word:
|
|
70
|
+
matrix = matrices.get(symbol)
|
|
71
|
+
if matrix is None:
|
|
72
|
+
return 0.0
|
|
73
|
+
result = result @ matrix
|
|
74
|
+
return float(result @ ones)
|