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,499 @@
|
|
|
1
|
+
"""transCSSR: sample-based epsilon-transducer reconstruction.
|
|
2
|
+
|
|
3
|
+
Generalizes Causal-State Splitting Reconstruction (Shalizi, Shalizi &
|
|
4
|
+
Crutchfield, arXiv:cs/0210025) from a single process to an input-output channel,
|
|
5
|
+
following the ε-transducer of Barnett & Crutchfield (J. Stat. Phys. 161:2
|
|
6
|
+
(2015)) and the transCSSR algorithm (Darmon & Rapp, ``ddarmon/transCSSR``).
|
|
7
|
+
|
|
8
|
+
Causal states are equivalence classes of joint ``(input, output)`` pasts that
|
|
9
|
+
induce the same conditional next-output law ``P(y | history, x)`` for every input
|
|
10
|
+
symbol ``x``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import Counter, defaultdict
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from scipy import stats
|
|
22
|
+
|
|
23
|
+
from sofic.exceptions import StochasticValidationError
|
|
24
|
+
from sofic.generators.epsilon_transducer import EpsilonTransducer
|
|
25
|
+
from sofic.graph import ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, TransitionGraph
|
|
26
|
+
|
|
27
|
+
JointHistory = tuple[tuple[Any, Any], ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class JointSuffixCounts:
|
|
32
|
+
"""Empirical counts of joint pasts and following input-conditioned outputs."""
|
|
33
|
+
|
|
34
|
+
input_alphabet: tuple[Any, ...]
|
|
35
|
+
output_alphabet: tuple[Any, ...]
|
|
36
|
+
history_counts: Counter[JointHistory] = field(default_factory=Counter)
|
|
37
|
+
#: ``next_counts[history][input]`` is a Counter over following output symbols.
|
|
38
|
+
next_counts: dict[JointHistory, dict[Any, Counter[Any]]] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_sequences(
|
|
42
|
+
cls,
|
|
43
|
+
inputs: Sequence[Any],
|
|
44
|
+
outputs: Sequence[Any],
|
|
45
|
+
*,
|
|
46
|
+
input_alphabet: Sequence[Any] | None = None,
|
|
47
|
+
output_alphabet: Sequence[Any] | None = None,
|
|
48
|
+
max_length: int,
|
|
49
|
+
) -> JointSuffixCounts:
|
|
50
|
+
xs = tuple(inputs)
|
|
51
|
+
ys = tuple(outputs)
|
|
52
|
+
if len(xs) != len(ys):
|
|
53
|
+
raise ValueError("inputs and outputs must have equal length")
|
|
54
|
+
if not xs:
|
|
55
|
+
raise ValueError("sequences must be non-empty")
|
|
56
|
+
in_alpha = tuple(sorted(set(xs), key=repr)) if input_alphabet is None else tuple(input_alphabet)
|
|
57
|
+
out_alpha = tuple(sorted(set(ys), key=repr)) if output_alphabet is None else tuple(output_alphabet)
|
|
58
|
+
counts = cls(input_alphabet=in_alpha, output_alphabet=out_alpha)
|
|
59
|
+
pairs = tuple(zip(xs, ys, strict=True))
|
|
60
|
+
n = len(pairs)
|
|
61
|
+
for t in range(n):
|
|
62
|
+
for length in range(0, min(t, max_length) + 1):
|
|
63
|
+
history = pairs[t - length : t]
|
|
64
|
+
counts.history_counts[history] += 1
|
|
65
|
+
by_input = counts.next_counts.setdefault(history, {})
|
|
66
|
+
by_input.setdefault(xs[t], Counter())[ys[t]] += 1
|
|
67
|
+
return counts
|
|
68
|
+
|
|
69
|
+
def output_counts(self, histories: set[JointHistory], input_symbol: Any) -> Counter[Any]:
|
|
70
|
+
observed: Counter[Any] = Counter()
|
|
71
|
+
for history in histories:
|
|
72
|
+
by_input = self.next_counts.get(history)
|
|
73
|
+
if by_input is None:
|
|
74
|
+
continue
|
|
75
|
+
counter = by_input.get(input_symbol)
|
|
76
|
+
if counter is not None:
|
|
77
|
+
observed.update(counter)
|
|
78
|
+
return observed
|
|
79
|
+
|
|
80
|
+
def state_morph(self, histories: set[JointHistory], input_symbol: Any) -> dict[Any, float]:
|
|
81
|
+
"""Return ``P(output | histories, input_symbol)``."""
|
|
82
|
+
observed = self.output_counts(histories, input_symbol)
|
|
83
|
+
total = sum(observed.values())
|
|
84
|
+
if total == 0:
|
|
85
|
+
return {}
|
|
86
|
+
return {symbol: observed.get(symbol, 0) / total for symbol in self.output_alphabet}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _output_contingency(left: Counter[Any], right: Counter[Any], alphabet: tuple[Any, ...]) -> np.ndarray | None:
|
|
90
|
+
active = [symbol for symbol in alphabet if left.get(symbol, 0) + right.get(symbol, 0) > 0]
|
|
91
|
+
if not active:
|
|
92
|
+
return None
|
|
93
|
+
table = np.array(
|
|
94
|
+
[[left.get(symbol, 0) for symbol in active], [right.get(symbol, 0) for symbol in active]],
|
|
95
|
+
dtype=float,
|
|
96
|
+
)
|
|
97
|
+
if np.allclose(table[0], table[1]):
|
|
98
|
+
return None
|
|
99
|
+
if table.shape[1] < 2:
|
|
100
|
+
left_total = table[0].sum()
|
|
101
|
+
right_total = table[1].sum()
|
|
102
|
+
if left_total == 0.0 or right_total == 0.0:
|
|
103
|
+
return None
|
|
104
|
+
if np.isclose(table[0, 0] / left_total, table[1, 0] / right_total):
|
|
105
|
+
return None
|
|
106
|
+
return table
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#: Aggregated output counts of a state: ``agg[input_symbol]`` is a Counter over outputs.
|
|
110
|
+
StateAggregate = dict[Any, Counter[Any]]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _history_aggregate(counts: JointSuffixCounts, history: JointHistory) -> StateAggregate:
|
|
114
|
+
return {input_symbol: Counter(counter) for input_symbol, counter in counts.next_counts.get(history, {}).items()}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _merge_aggregate(target: StateAggregate, source: StateAggregate) -> None:
|
|
118
|
+
for input_symbol, counter in source.items():
|
|
119
|
+
target.setdefault(input_symbol, Counter()).update(counter)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def aggregates_differ(
|
|
123
|
+
left: StateAggregate,
|
|
124
|
+
right: StateAggregate,
|
|
125
|
+
*,
|
|
126
|
+
input_alphabet: tuple[Any, ...],
|
|
127
|
+
output_alphabet: tuple[Any, ...],
|
|
128
|
+
alpha: float,
|
|
129
|
+
test: Literal["g", "chi2"] = "g",
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""Return whether two aggregated morphs differ on ``P(output | ., input)`` for some input."""
|
|
132
|
+
for input_symbol in input_alphabet:
|
|
133
|
+
table = _output_contingency(
|
|
134
|
+
left.get(input_symbol, Counter()),
|
|
135
|
+
right.get(input_symbol, Counter()),
|
|
136
|
+
output_alphabet,
|
|
137
|
+
)
|
|
138
|
+
if table is None:
|
|
139
|
+
continue
|
|
140
|
+
if _table_significant(table, alpha=alpha, test=test):
|
|
141
|
+
return True
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _aggregate_score(
|
|
146
|
+
left: StateAggregate,
|
|
147
|
+
right: StateAggregate,
|
|
148
|
+
*,
|
|
149
|
+
input_alphabet: tuple[Any, ...],
|
|
150
|
+
output_alphabet: tuple[Any, ...],
|
|
151
|
+
) -> float:
|
|
152
|
+
total = 0.0
|
|
153
|
+
for input_symbol in input_alphabet:
|
|
154
|
+
table = _output_contingency(
|
|
155
|
+
left.get(input_symbol, Counter()),
|
|
156
|
+
right.get(input_symbol, Counter()),
|
|
157
|
+
output_alphabet,
|
|
158
|
+
)
|
|
159
|
+
if table is None:
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
163
|
+
statistic, _p, _dof, _expected = stats.chi2_contingency(table, lambda_="log-likelihood")
|
|
164
|
+
if np.isfinite(statistic):
|
|
165
|
+
total += float(statistic)
|
|
166
|
+
except ValueError:
|
|
167
|
+
continue
|
|
168
|
+
return total
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _table_significant(table: np.ndarray, *, alpha: float, test: Literal["g", "chi2"]) -> bool:
|
|
172
|
+
try:
|
|
173
|
+
if test == "g":
|
|
174
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
175
|
+
statistic, _p, _dof, expected = stats.chi2_contingency(table, lambda_="log-likelihood")
|
|
176
|
+
if not np.isfinite(statistic) or np.any(expected == 0):
|
|
177
|
+
return False
|
|
178
|
+
dof = max(1, table.shape[1] - 1)
|
|
179
|
+
return float(statistic) > float(stats.chi2.ppf(1.0 - alpha, dof))
|
|
180
|
+
statistic, p_value, _dof, expected = stats.chi2_contingency(table)
|
|
181
|
+
except ValueError:
|
|
182
|
+
return False
|
|
183
|
+
if np.any(expected == 0):
|
|
184
|
+
return False
|
|
185
|
+
return float(p_value) < alpha
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _homogenize(
|
|
189
|
+
counts: JointSuffixCounts,
|
|
190
|
+
*,
|
|
191
|
+
Lmax: int,
|
|
192
|
+
alpha: float,
|
|
193
|
+
test: Literal["g", "chi2"],
|
|
194
|
+
min_count: int,
|
|
195
|
+
) -> tuple[dict[int, set[JointHistory]], dict[JointHistory, int]]:
|
|
196
|
+
in_alpha = counts.input_alphabet
|
|
197
|
+
out_alpha = counts.output_alphabet
|
|
198
|
+
states: dict[int, set[JointHistory]] = {0: {()}}
|
|
199
|
+
state_agg: dict[int, StateAggregate] = {0: _history_aggregate(counts, ())}
|
|
200
|
+
history_to_state: dict[JointHistory, int] = {(): 0}
|
|
201
|
+
next_state_id = 1
|
|
202
|
+
|
|
203
|
+
for _length in range(Lmax + 1):
|
|
204
|
+
for state_id in sorted(states):
|
|
205
|
+
for history in list(states[state_id]):
|
|
206
|
+
for pair in _observed_pairs(counts, history):
|
|
207
|
+
child = (*history, pair)
|
|
208
|
+
if child in history_to_state or counts.history_counts.get(child, 0) == 0:
|
|
209
|
+
continue
|
|
210
|
+
child_agg = _history_aggregate(counts, child)
|
|
211
|
+
if counts.history_counts.get(child, 0) < min_count:
|
|
212
|
+
# Too rare to split reliably; inherit the parent's causal state.
|
|
213
|
+
states[state_id].add(child)
|
|
214
|
+
history_to_state[child] = state_id
|
|
215
|
+
_merge_aggregate(state_agg[state_id], child_agg)
|
|
216
|
+
continue
|
|
217
|
+
if aggregates_differ(
|
|
218
|
+
state_agg[state_id],
|
|
219
|
+
child_agg,
|
|
220
|
+
input_alphabet=in_alpha,
|
|
221
|
+
output_alphabet=out_alpha,
|
|
222
|
+
alpha=alpha,
|
|
223
|
+
test=test,
|
|
224
|
+
):
|
|
225
|
+
best_state: int | None = None
|
|
226
|
+
best_score = float("inf")
|
|
227
|
+
for candidate_id, candidate_agg in state_agg.items():
|
|
228
|
+
if aggregates_differ(
|
|
229
|
+
candidate_agg,
|
|
230
|
+
child_agg,
|
|
231
|
+
input_alphabet=in_alpha,
|
|
232
|
+
output_alphabet=out_alpha,
|
|
233
|
+
alpha=alpha,
|
|
234
|
+
test=test,
|
|
235
|
+
):
|
|
236
|
+
continue
|
|
237
|
+
score = _aggregate_score(
|
|
238
|
+
candidate_agg,
|
|
239
|
+
child_agg,
|
|
240
|
+
input_alphabet=in_alpha,
|
|
241
|
+
output_alphabet=out_alpha,
|
|
242
|
+
)
|
|
243
|
+
if score < best_score:
|
|
244
|
+
best_score = score
|
|
245
|
+
best_state = candidate_id
|
|
246
|
+
if best_state is None:
|
|
247
|
+
best_state = next_state_id
|
|
248
|
+
states[next_state_id] = set()
|
|
249
|
+
state_agg[next_state_id] = {}
|
|
250
|
+
next_state_id += 1
|
|
251
|
+
states[best_state].add(child)
|
|
252
|
+
history_to_state[child] = best_state
|
|
253
|
+
_merge_aggregate(state_agg[best_state], child_agg)
|
|
254
|
+
else:
|
|
255
|
+
states[state_id].add(child)
|
|
256
|
+
history_to_state[child] = state_id
|
|
257
|
+
_merge_aggregate(state_agg[state_id], child_agg)
|
|
258
|
+
return states, history_to_state
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _observed_pairs(counts: JointSuffixCounts, history: JointHistory) -> list[tuple[Any, Any]]:
|
|
262
|
+
by_input = counts.next_counts.get(history)
|
|
263
|
+
if by_input is None:
|
|
264
|
+
return []
|
|
265
|
+
pairs: list[tuple[Any, Any]] = []
|
|
266
|
+
for input_symbol, counter in by_input.items():
|
|
267
|
+
for output_symbol in counter:
|
|
268
|
+
pairs.append((input_symbol, output_symbol))
|
|
269
|
+
return pairs
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _determinize(
|
|
273
|
+
states: dict[int, set[JointHistory]],
|
|
274
|
+
history_to_state: dict[JointHistory, int],
|
|
275
|
+
counts: JointSuffixCounts,
|
|
276
|
+
) -> dict[int, set[JointHistory]]:
|
|
277
|
+
current = {state_id: set(histories) for state_id, histories in states.items()}
|
|
278
|
+
next_state_id = (max(current) + 1) if current else 0
|
|
279
|
+
changed = True
|
|
280
|
+
while changed:
|
|
281
|
+
changed = False
|
|
282
|
+
for state_id in sorted(current):
|
|
283
|
+
histories = current[state_id]
|
|
284
|
+
if len(histories) <= 1:
|
|
285
|
+
continue
|
|
286
|
+
for pair in _pairs_from(counts):
|
|
287
|
+
buckets: dict[int, set[JointHistory]] = defaultdict(set)
|
|
288
|
+
for history in histories:
|
|
289
|
+
if not _history_emits(counts, history, pair):
|
|
290
|
+
continue
|
|
291
|
+
child = (*history, pair)
|
|
292
|
+
target = history_to_state.get(child)
|
|
293
|
+
if target is None:
|
|
294
|
+
continue
|
|
295
|
+
buckets[target].add(history)
|
|
296
|
+
if len(buckets) <= 1:
|
|
297
|
+
continue
|
|
298
|
+
ordered = sorted(buckets.items(), key=lambda item: (-len(item[1]), repr(min(item[1], key=repr))))
|
|
299
|
+
_keep_target, keep_histories = ordered[0]
|
|
300
|
+
current[state_id] = keep_histories
|
|
301
|
+
for _target, split_histories in ordered[1:]:
|
|
302
|
+
current[next_state_id] = split_histories
|
|
303
|
+
for history in split_histories:
|
|
304
|
+
history_to_state[history] = next_state_id
|
|
305
|
+
next_state_id += 1
|
|
306
|
+
changed = True
|
|
307
|
+
break
|
|
308
|
+
if changed:
|
|
309
|
+
break
|
|
310
|
+
return current
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _pairs_from(counts: JointSuffixCounts) -> list[tuple[Any, Any]]:
|
|
314
|
+
return [(x, y) for x in counts.input_alphabet for y in counts.output_alphabet]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _history_emits(counts: JointSuffixCounts, history: JointHistory, pair: tuple[Any, Any]) -> bool:
|
|
318
|
+
by_input = counts.next_counts.get(history)
|
|
319
|
+
if by_input is None:
|
|
320
|
+
return False
|
|
321
|
+
counter = by_input.get(pair[0])
|
|
322
|
+
return bool(counter) and counter.get(pair[1], 0) > 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _drop_transient(
|
|
326
|
+
states: dict[int, set[JointHistory]],
|
|
327
|
+
history_to_state: dict[JointHistory, int],
|
|
328
|
+
counts: JointSuffixCounts,
|
|
329
|
+
) -> dict[int, set[JointHistory]]:
|
|
330
|
+
import networkx as nx
|
|
331
|
+
|
|
332
|
+
graph = nx.DiGraph()
|
|
333
|
+
graph.add_nodes_from(states)
|
|
334
|
+
for state_id, histories in states.items():
|
|
335
|
+
for history in histories:
|
|
336
|
+
for pair in _pairs_from(counts):
|
|
337
|
+
if not _history_emits(counts, history, pair):
|
|
338
|
+
continue
|
|
339
|
+
target = history_to_state.get((*history, pair))
|
|
340
|
+
if target is not None:
|
|
341
|
+
graph.add_edge(state_id, target)
|
|
342
|
+
if graph.number_of_edges() == 0:
|
|
343
|
+
return states
|
|
344
|
+
|
|
345
|
+
recurrent: set[int] = set()
|
|
346
|
+
for component in nx.strongly_connected_components(graph):
|
|
347
|
+
subgraph = graph.subgraph(component)
|
|
348
|
+
has_cycle = subgraph.number_of_edges() > 0 and (
|
|
349
|
+
len(component) > 1 or any(subgraph.has_edge(node, node) for node in component)
|
|
350
|
+
)
|
|
351
|
+
if not has_cycle:
|
|
352
|
+
continue
|
|
353
|
+
if not any(graph.has_edge(v, w) for v in component for w in graph.nodes if w not in component):
|
|
354
|
+
recurrent.update(component)
|
|
355
|
+
if not recurrent:
|
|
356
|
+
return states
|
|
357
|
+
return {state_id: histories for state_id, histories in states.items() if state_id in recurrent}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _state_visits(
|
|
361
|
+
inputs: Sequence[Any],
|
|
362
|
+
outputs: Sequence[Any],
|
|
363
|
+
history_to_state: dict[JointHistory, int],
|
|
364
|
+
*,
|
|
365
|
+
length: int,
|
|
366
|
+
) -> Counter[int]:
|
|
367
|
+
visits: Counter[int] = Counter()
|
|
368
|
+
pairs = tuple(zip(inputs, outputs, strict=True))
|
|
369
|
+
for t in range(len(pairs)):
|
|
370
|
+
for hist_len in range(min(t, length), -1, -1):
|
|
371
|
+
history = pairs[t - hist_len : t]
|
|
372
|
+
state = history_to_state.get(history)
|
|
373
|
+
if state is not None:
|
|
374
|
+
visits[state] += 1
|
|
375
|
+
break
|
|
376
|
+
return visits
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _build_transducer(
|
|
380
|
+
states: dict[int, set[JointHistory]],
|
|
381
|
+
counts: JointSuffixCounts,
|
|
382
|
+
history_to_state: dict[JointHistory, int],
|
|
383
|
+
inputs: Sequence[Any],
|
|
384
|
+
outputs: Sequence[Any],
|
|
385
|
+
*,
|
|
386
|
+
length: int,
|
|
387
|
+
) -> EpsilonTransducer:
|
|
388
|
+
visits = _state_visits(inputs, outputs, history_to_state, length=length)
|
|
389
|
+
if not visits:
|
|
390
|
+
raise StochasticValidationError("no empirical causal-state visits")
|
|
391
|
+
|
|
392
|
+
graph = TransitionGraph()
|
|
393
|
+
labels = {state_id: f"s{state_id}" for state_id in states}
|
|
394
|
+
for label in labels.values():
|
|
395
|
+
graph.add_state(label)
|
|
396
|
+
|
|
397
|
+
used_inputs: set[Any] = set()
|
|
398
|
+
used_outputs: set[Any] = set()
|
|
399
|
+
for state_id, histories in states.items():
|
|
400
|
+
source = labels[state_id]
|
|
401
|
+
for input_symbol in counts.input_alphabet:
|
|
402
|
+
morph = counts.state_morph(histories, input_symbol)
|
|
403
|
+
if not morph:
|
|
404
|
+
continue
|
|
405
|
+
row: list[tuple[str, Any, float]] = []
|
|
406
|
+
for output_symbol, prob in morph.items():
|
|
407
|
+
if prob <= 0.0:
|
|
408
|
+
continue
|
|
409
|
+
emitting = [
|
|
410
|
+
history for history in histories if _history_emits(counts, history, (input_symbol, output_symbol))
|
|
411
|
+
]
|
|
412
|
+
targets = {history_to_state.get((*history, (input_symbol, output_symbol))) for history in emitting}
|
|
413
|
+
targets.discard(None)
|
|
414
|
+
if len(targets) != 1:
|
|
415
|
+
continue
|
|
416
|
+
target_id = next(iter(targets))
|
|
417
|
+
if target_id not in labels:
|
|
418
|
+
continue
|
|
419
|
+
row.append((labels[target_id], output_symbol, prob))
|
|
420
|
+
total = sum(prob for _label, _out, prob in row)
|
|
421
|
+
if total <= 0.0:
|
|
422
|
+
continue
|
|
423
|
+
for target_label, output_symbol, prob in row:
|
|
424
|
+
graph.add_transition(
|
|
425
|
+
source,
|
|
426
|
+
target_label,
|
|
427
|
+
**{ATTR_SYMBOL: input_symbol, ATTR_OUTPUT: output_symbol, ATTR_PROB: prob / total},
|
|
428
|
+
)
|
|
429
|
+
used_inputs.add(input_symbol)
|
|
430
|
+
used_outputs.add(output_symbol)
|
|
431
|
+
|
|
432
|
+
total_visits = float(sum(visits.values()))
|
|
433
|
+
initial = {labels[state_id]: visits[state_id] / total_visits for state_id in states if visits.get(state_id, 0) > 0}
|
|
434
|
+
if not initial:
|
|
435
|
+
initial = {labels[next(iter(states))]: 1.0}
|
|
436
|
+
|
|
437
|
+
result = EpsilonTransducer(
|
|
438
|
+
input_alphabet=frozenset(used_inputs),
|
|
439
|
+
output_alphabet=frozenset(used_outputs),
|
|
440
|
+
initial_states=frozenset(initial),
|
|
441
|
+
initial_distribution=initial,
|
|
442
|
+
graph=graph,
|
|
443
|
+
)
|
|
444
|
+
result.validate()
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _default_lmax(n: int, alphabet_size: int, min_count: int) -> int:
|
|
449
|
+
if alphabet_size <= 0:
|
|
450
|
+
return 1
|
|
451
|
+
# The joint (input, output) history space grows as ``alphabet_size ** L``, so
|
|
452
|
+
# keep the default depth modest relative to the single-process CSSR bound.
|
|
453
|
+
return max(1, min(5, n // max(1, alphabet_size * min_count)))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def transcssr(
|
|
457
|
+
inputs: Sequence[Any],
|
|
458
|
+
outputs: Sequence[Any],
|
|
459
|
+
*,
|
|
460
|
+
input_alphabet: Sequence[Any] | None = None,
|
|
461
|
+
output_alphabet: Sequence[Any] | None = None,
|
|
462
|
+
Lmax: int | None = None,
|
|
463
|
+
alpha: float = 0.001,
|
|
464
|
+
test: Literal["g", "chi2"] = "g",
|
|
465
|
+
min_count: int = 5,
|
|
466
|
+
) -> EpsilonTransducer:
|
|
467
|
+
"""Reconstruct an ε-transducer from paired input/output sequences (transCSSR).
|
|
468
|
+
|
|
469
|
+
``alpha`` is the per-test significance level for the causal-state split
|
|
470
|
+
decision; the transCSSR/CSSR default of ``0.001`` favors fewer, more robust
|
|
471
|
+
states. ``Lmax`` bounds the joint-history depth and ``min_count`` the minimum
|
|
472
|
+
occurrences before a history is eligible to seed a new state.
|
|
473
|
+
"""
|
|
474
|
+
xs = tuple(inputs)
|
|
475
|
+
ys = tuple(outputs)
|
|
476
|
+
if len(xs) != len(ys):
|
|
477
|
+
raise ValueError("inputs and outputs must have equal length")
|
|
478
|
+
if len(xs) < 2:
|
|
479
|
+
raise ValueError("sequences must contain at least two symbols")
|
|
480
|
+
joint_alphabet_size = (
|
|
481
|
+
len(set(xs)) * len(set(ys))
|
|
482
|
+
if input_alphabet is None or output_alphabet is None
|
|
483
|
+
else len(tuple(input_alphabet)) * len(tuple(output_alphabet))
|
|
484
|
+
)
|
|
485
|
+
max_length = Lmax if Lmax is not None else _default_lmax(len(xs), joint_alphabet_size, min_count)
|
|
486
|
+
counts = JointSuffixCounts.from_sequences(
|
|
487
|
+
xs,
|
|
488
|
+
ys,
|
|
489
|
+
input_alphabet=input_alphabet,
|
|
490
|
+
output_alphabet=output_alphabet,
|
|
491
|
+
max_length=max_length + 1,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
states, history_to_state = _homogenize(counts, Lmax=max_length, alpha=alpha, test=test, min_count=min_count)
|
|
495
|
+
states = _determinize(states, history_to_state, counts)
|
|
496
|
+
history_to_state = {history: state_id for state_id, histories in states.items() for history in histories}
|
|
497
|
+
states = _drop_transient(states, history_to_state, counts)
|
|
498
|
+
history_to_state = {history: state_id for state_id, histories in states.items() for history in histories}
|
|
499
|
+
return _build_transducer(states, counts, history_to_state, xs, ys, length=max_length)
|