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.
Files changed (150) hide show
  1. sofic/__init__.py +185 -0
  2. sofic/automata/__init__.py +207 -0
  3. sofic/automata/_config_simulation.py +40 -0
  4. sofic/automata/active.py +611 -0
  5. sofic/automata/alergia.py +222 -0
  6. sofic/automata/algorithms.py +376 -0
  7. sofic/automata/atomaton.py +58 -0
  8. sofic/automata/base.py +161 -0
  9. sofic/automata/buchi.py +23 -0
  10. sofic/automata/buchi_simulation.py +67 -0
  11. sofic/automata/canonical_dual.py +18 -0
  12. sofic/automata/canonical_extraction.py +122 -0
  13. sofic/automata/dfa.py +85 -0
  14. sofic/automata/dfasat.py +195 -0
  15. sofic/automata/edsm.py +219 -0
  16. sofic/automata/enumeration.py +44 -0
  17. sofic/automata/icdfa.py +421 -0
  18. sofic/automata/idfa.py +363 -0
  19. sofic/automata/languages/__init__.py +39 -0
  20. sofic/automata/languages/_quotient_utils.py +64 -0
  21. sofic/automata/languages/atoms.py +31 -0
  22. sofic/automata/languages/automaton_ops.py +243 -0
  23. sofic/automata/languages/base.py +67 -0
  24. sofic/automata/languages/operations.py +78 -0
  25. sofic/automata/languages/quotients.py +66 -0
  26. sofic/automata/languages/residuals.py +25 -0
  27. sofic/automata/learning.py +79 -0
  28. sofic/automata/nfa.py +39 -0
  29. sofic/automata/nwa.py +343 -0
  30. sofic/automata/nwa_simulation.py +56 -0
  31. sofic/automata/observation.py +40 -0
  32. sofic/automata/papni.py +301 -0
  33. sofic/automata/regex.py +128 -0
  34. sofic/automata/rfsa.py +35 -0
  35. sofic/automata/rpni.py +193 -0
  36. sofic/automata/subsequential.py +201 -0
  37. sofic/automata/transducer_operations.py +350 -0
  38. sofic/automata/transducer_simulation.py +150 -0
  39. sofic/automata/transducers.py +365 -0
  40. sofic/automata/unifilar.py +107 -0
  41. sofic/automata/vpa.py +1373 -0
  42. sofic/automata/vpa_simulation.py +53 -0
  43. sofic/base.py +153 -0
  44. sofic/core.py +47 -0
  45. sofic/examples/__init__.py +86 -0
  46. sofic/examples/epsilon_machines.py +1089 -0
  47. sofic/examples/processes.py +1491 -0
  48. sofic/examples/shifts.py +144 -0
  49. sofic/exceptions.py +33 -0
  50. sofic/generators/__init__.py +115 -0
  51. sofic/generators/_word_measures.py +94 -0
  52. sofic/generators/alternative_complexity.py +104 -0
  53. sofic/generators/base.py +327 -0
  54. sofic/generators/bidirectional_construction.py +717 -0
  55. sofic/generators/bidirectional_epsilon_machine.py +689 -0
  56. sofic/generators/block_convergence.py +668 -0
  57. sofic/generators/block_entropy.py +578 -0
  58. sofic/generators/channel_measures.py +75 -0
  59. sofic/generators/conversions.py +182 -0
  60. sofic/generators/directional_flow.py +245 -0
  61. sofic/generators/edge_emissions.py +36 -0
  62. sofic/generators/edge_machine.py +178 -0
  63. sofic/generators/epsilon_construction.py +193 -0
  64. sofic/generators/epsilon_inference.py +703 -0
  65. sofic/generators/epsilon_machine.py +557 -0
  66. sofic/generators/epsilon_transducer.py +168 -0
  67. sofic/generators/epsilon_transducer_construction.py +185 -0
  68. sofic/generators/epsilon_transducer_inference.py +499 -0
  69. sofic/generators/hmm_inference.py +719 -0
  70. sofic/generators/information_diagram.py +428 -0
  71. sofic/generators/lumping.py +447 -0
  72. sofic/generators/markov.py +100 -0
  73. sofic/generators/mealy.py +156 -0
  74. sofic/generators/measures.py +257 -0
  75. sofic/generators/minimal_generative_model.py +821 -0
  76. sofic/generators/mixed_state.py +250 -0
  77. sofic/generators/mixed_state_construction.py +163 -0
  78. sofic/generators/moore.py +75 -0
  79. sofic/generators/nmachine.py +78 -0
  80. sofic/generators/nmachine_construction.py +70 -0
  81. sofic/generators/pfa.py +100 -0
  82. sofic/generators/prob.py +291 -0
  83. sofic/generators/process_equivalence.py +207 -0
  84. sofic/generators/quasi_inference.py +74 -0
  85. sofic/generators/quasi_realization.py +97 -0
  86. sofic/generators/reversal.py +66 -0
  87. sofic/generators/stack_hmm.py +426 -0
  88. sofic/generators/stack_inference.py +509 -0
  89. sofic/generators/stationary.py +134 -0
  90. sofic/generators/stochastic.py +65 -0
  91. sofic/generators/synchronization.py +407 -0
  92. sofic/generators/topological_epsilon_enumeration.py +349 -0
  93. sofic/generators/words.py +226 -0
  94. sofic/graph.py +135 -0
  95. sofic/indexing.py +31 -0
  96. sofic/inference/__init__.py +45 -0
  97. sofic/inference/bayesian/__init__.py +68 -0
  98. sofic/inference/bayesian/comparison.py +199 -0
  99. sofic/inference/bayesian/counts.py +219 -0
  100. sofic/inference/bayesian/diversity.py +254 -0
  101. sofic/inference/bayesian/epsilon.py +270 -0
  102. sofic/inference/bayesian/hdp_hmm.py +340 -0
  103. sofic/inference/bayesian/markov.py +294 -0
  104. sofic/inference/bayesian/pymc_backend.py +71 -0
  105. sofic/inference/bayesian/stack_hmm.py +215 -0
  106. sofic/inference/model_selection.py +365 -0
  107. sofic/inference/spectral.py +564 -0
  108. sofic/operations.py +16 -0
  109. sofic/properties.py +339 -0
  110. sofic/serialization.py +450 -0
  111. sofic/shifts/__init__.py +48 -0
  112. sofic/shifts/algorithms.py +84 -0
  113. sofic/shifts/base.py +49 -0
  114. sofic/shifts/cover_construction.py +76 -0
  115. sofic/shifts/covers.py +47 -0
  116. sofic/shifts/dyck_algorithms.py +100 -0
  117. sofic/shifts/dyck_enumeration.py +275 -0
  118. sofic/shifts/markov_dyck.py +172 -0
  119. sofic/shifts/parry_construction.py +82 -0
  120. sofic/shifts/sft.py +104 -0
  121. sofic/shifts/sft_construction.py +52 -0
  122. sofic/shifts/sliding_block_code.py +156 -0
  123. sofic/shifts/sofic.py +111 -0
  124. sofic/shifts/sofic_dyck.py +110 -0
  125. sofic/shifts/sofic_relation.py +64 -0
  126. sofic/shifts/textile.py +104 -0
  127. sofic/shifts/tmc.py +46 -0
  128. sofic/shifts/tmc_construction.py +58 -0
  129. sofic/shifts/topological_anatomy.py +150 -0
  130. sofic/states.py +27 -0
  131. sofic/testing/__init__.py +8 -0
  132. sofic/testing/strategies.py +154 -0
  133. sofic/viz/__init__.py +16 -0
  134. sofic/viz/_context.py +345 -0
  135. sofic/viz/_edge.py +216 -0
  136. sofic/viz/_format.py +89 -0
  137. sofic/viz/_labels.py +34 -0
  138. sofic/viz/_names.py +17 -0
  139. sofic/viz/_rational.py +20 -0
  140. sofic/viz/_tikz_compile.py +177 -0
  141. sofic/viz/_tikz_format.py +122 -0
  142. sofic/viz/_tikz_layout.py +218 -0
  143. sofic/viz/assets/vaucanson.tikz +71 -0
  144. sofic/viz/graphviz.py +158 -0
  145. sofic/viz/idiagram.py +350 -0
  146. sofic/viz/tikz.py +381 -0
  147. sofic-0.1.0.dist-info/METADATA +444 -0
  148. sofic-0.1.0.dist-info/RECORD +150 -0
  149. sofic-0.1.0.dist-info/WHEEL +4 -0
  150. sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
@@ -0,0 +1,578 @@
1
+ """Block entropy convergence diagrams for epsilon-machines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import numpy as np
11
+
12
+ if TYPE_CHECKING:
13
+ from sofic.generators.epsilon_machine import EpsilonMachine
14
+
15
+ _TOL = 1e-15
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class BlockEntropyDiagram:
20
+ """Finite-block entropy curves and computational-mechanics annotations.
21
+
22
+ The curves follow the entropy-convergence diagrams used for finite-state
23
+ epsilon-machines: block entropy ``H[X_0:L]``, state-block entropy
24
+ ``H[S_0, X_0:L]``, block-state entropy ``H[X_0:L, S_L]``, the linear
25
+ asymptote ``E + h_mu L``, and the finite-length crypticity estimate
26
+ ``chi(L)``.
27
+ """
28
+
29
+ lengths: np.ndarray
30
+ block_entropy: np.ndarray
31
+ state_block_entropy: np.ndarray
32
+ block_state_entropy: np.ndarray
33
+ entropy_asymptote: np.ndarray
34
+ crypticity_estimate: np.ndarray
35
+ entropy_rate_estimate: np.ndarray
36
+ entropy_rate: float
37
+ excess_entropy: float
38
+ statistical_complexity: float
39
+ crypticity: float
40
+ markov_order: int | float
41
+ cryptic_order: int | float
42
+
43
+ @property
44
+ def transient_information(self) -> float:
45
+ """Transient information ``T`` (Crutchfield & Feldman, 2003).
46
+
47
+ Equal to the accumulated area between the block-entropy curve and its
48
+ linear asymptote over the plotted block lengths,
49
+ ``sum_L (E + h_mu L - H[X_{0:L}])``.
50
+ """
51
+ return float(np.sum(self.entropy_asymptote - self.block_entropy))
52
+
53
+ def plot(
54
+ self,
55
+ ax: Any | None = None,
56
+ *,
57
+ show_block_entropy: bool = True,
58
+ show_state_block_entropy: bool = False,
59
+ show_block_state_entropy: bool = True,
60
+ show_asymptote: bool = True,
61
+ show_transient_information: bool = True,
62
+ show_markov_order: bool = True,
63
+ show_cryptic_order: bool = True,
64
+ show_crypticity: bool = False,
65
+ show_excess_entropy: bool = False,
66
+ show_statistical_complexity: bool = False,
67
+ show_entropy_rate_estimate: bool = False,
68
+ show_grid: bool = True,
69
+ show_legend: bool = True,
70
+ title: str | None = None,
71
+ marker: str = "o",
72
+ ) -> Any:
73
+ """Plot selected block entropy diagram features on ``ax``.
74
+
75
+ ``matplotlib`` is imported only when this method is called. The return
76
+ value is the axes object used for plotting.
77
+ """
78
+ if ax is None:
79
+ import matplotlib.pyplot as plt
80
+
81
+ _, ax = plt.subplots()
82
+
83
+ if show_transient_information and show_block_entropy and show_asymptote:
84
+ # Shade the transient information: the area between H[X_{0:L}] and the
85
+ # E + h_mu L asymptote. Drawn first (low zorder) so lines stay on top.
86
+ ax.fill_between(
87
+ self.lengths,
88
+ self.block_entropy,
89
+ self.entropy_asymptote,
90
+ color="tab:blue",
91
+ alpha=0.15,
92
+ zorder=0,
93
+ label=rf"$\mathbf{{T}} = {self.transient_information:.3f}$",
94
+ )
95
+
96
+ if show_block_entropy:
97
+ ax.plot(self.lengths, self.block_entropy, marker=marker, label=r"$H[X_{0:L}]$")
98
+ if show_state_block_entropy:
99
+ ax.plot(
100
+ self.lengths,
101
+ self.state_block_entropy,
102
+ marker=marker,
103
+ label=r"$H[S_0, X_{0:L}]$",
104
+ )
105
+ if show_block_state_entropy:
106
+ ax.plot(
107
+ self.lengths,
108
+ self.block_state_entropy,
109
+ marker=marker,
110
+ label=r"$H[X_{0:L}, S_L]$",
111
+ )
112
+ if show_asymptote:
113
+ ax.plot(
114
+ self.lengths,
115
+ self.entropy_asymptote,
116
+ linestyle="--",
117
+ color="black",
118
+ label=r"$E + h_\mu L$",
119
+ )
120
+ if show_crypticity:
121
+ ax.plot(
122
+ self.lengths,
123
+ self.crypticity_estimate,
124
+ marker=marker,
125
+ linestyle="-.",
126
+ label=r"$\chi(L)$",
127
+ )
128
+ ax.axhline(
129
+ self.crypticity,
130
+ linestyle=":",
131
+ color="tab:purple",
132
+ label=r"$\chi$",
133
+ )
134
+ if show_excess_entropy:
135
+ ax.axhline(self.excess_entropy, linestyle=":", color="tab:green", label=r"$E$")
136
+ if show_statistical_complexity:
137
+ ax.axhline(
138
+ self.statistical_complexity,
139
+ linestyle=":",
140
+ color="tab:brown",
141
+ label=r"$C_\mu$",
142
+ )
143
+ if show_entropy_rate_estimate:
144
+ ax.plot(
145
+ self.lengths,
146
+ self.entropy_rate_estimate,
147
+ marker=marker,
148
+ linestyle=":",
149
+ label=r"$h_\mu(L)$",
150
+ )
151
+ ax.axhline(self.entropy_rate, linestyle="--", color="tab:gray", label=r"$h_\mu$")
152
+
153
+ if show_markov_order:
154
+ self._plot_order_line(ax, self.markov_order, "tab:red", r"$R$")
155
+ if show_cryptic_order:
156
+ self._plot_order_line(ax, self.cryptic_order, "tab:orange", r"$k_\chi$")
157
+
158
+ if show_grid:
159
+ ax.set_axisbelow(True)
160
+ ax.grid(True, color="0.85", linewidth=0.8)
161
+ else:
162
+ ax.grid(False)
163
+ ax.set_xlabel("Block length L")
164
+ ax.set_ylabel("Information (bits)")
165
+ if title is not None:
166
+ ax.set_title(title)
167
+ if show_legend:
168
+ ax.legend()
169
+ return ax
170
+
171
+ def _plot_order_line(self, ax: Any, order: int | float, color: str, symbol: str) -> None:
172
+ if not _is_finite_order(order):
173
+ return
174
+ if float(order) < float(self.lengths[0]) or float(order) > float(self.lengths[-1]):
175
+ return
176
+ label = f"{symbol} = {int(order)}"
177
+ ax.axvline(float(order), linestyle="--", color=color, alpha=0.75, label=label)
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class CMExtensionEstimates:
182
+ """Ellison/Mahoney CM-extension curves shared by block estimate diagrams.
183
+
184
+ These nine finite-block curves are computed identically for
185
+ :class:`BlockEntropyEstimates` and
186
+ :class:`~sofic.generators.block_convergence.BlockConvergenceEstimates`
187
+ (see :func:`_cm_extension_curves`).
188
+ """
189
+
190
+ excess_entropy_lower: np.ndarray
191
+ excess_entropy_upper: np.ndarray
192
+ excess_entropy_estimate: np.ndarray
193
+ synchronization_estimate: np.ndarray
194
+ reverse_synchronization_estimate: np.ndarray
195
+ transient_information_estimate: np.ndarray
196
+ predictability_gain_estimate: np.ndarray
197
+ oracular_information_estimate: np.ndarray
198
+ gauge_information_estimate: np.ndarray
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class BlockEntropyEstimates(CMExtensionEstimates):
203
+ """Finite-block estimates for computational-mechanics quantities."""
204
+
205
+ lengths: np.ndarray
206
+ block_entropy: np.ndarray
207
+ state_block_entropy: np.ndarray
208
+ block_state_entropy: np.ndarray
209
+ entropy_asymptote: np.ndarray
210
+ entropy_rate_estimate: np.ndarray
211
+ residual_entropy: np.ndarray
212
+ residual_entropy_rate_estimate: np.ndarray
213
+ crypticity_estimate: np.ndarray
214
+ entropy_rate: float
215
+ excess_entropy: float
216
+ statistical_complexity: float
217
+ crypticity: float
218
+ predicted_information: float
219
+ bound_information: float
220
+ ephemeral_information: float
221
+
222
+ @property
223
+ def h_mu(self) -> float:
224
+ return self.entropy_rate
225
+
226
+ @property
227
+ def E(self) -> float:
228
+ return self.excess_entropy
229
+
230
+ @property
231
+ def r_mu(self) -> float:
232
+ return self.ephemeral_information
233
+
234
+ @property
235
+ def b_mu(self) -> float:
236
+ return self.bound_information
237
+
238
+ def information_anatomy(self) -> dict[str, float]:
239
+ """Return finite-block estimates using the exact anatomy key names."""
240
+ return {
241
+ "rho_mu": self.predicted_information,
242
+ "bound_mu": self.bound_information,
243
+ "ephemeral_mu": self.ephemeral_information,
244
+ "entropy_rate": self.entropy_rate,
245
+ "excess_entropy": self.excess_entropy,
246
+ "crypticity": self.crypticity,
247
+ }
248
+
249
+
250
+ def block_entropy_diagram(machine: EpsilonMachine, max_length: int) -> BlockEntropyDiagram:
251
+ """Compute finite-block entropy convergence curves for an epsilon-machine."""
252
+ if max_length < 0:
253
+ raise ValueError("max_length must be nonnegative")
254
+
255
+ lengths, block_entropy, state_block_entropy, block_state_entropy, pi, symbol_matrices = _block_entropy_curves(
256
+ machine,
257
+ max_length,
258
+ )
259
+
260
+ entropy_rate = _entropy_rate(pi, symbol_matrices)
261
+ statistical_complexity = _entropy(pi)
262
+ excess_entropy = _excess_entropy(machine, entropy_rate=entropy_rate, block_entropy=block_entropy)
263
+ crypticity = statistical_complexity - excess_entropy
264
+ entropy_asymptote = excess_entropy + entropy_rate * lengths
265
+ crypticity_estimate = state_block_entropy - block_state_entropy
266
+ entropy_rate_estimate = np.empty(max_length + 1, dtype=float)
267
+ entropy_rate_estimate[0] = math.nan
268
+ if max_length > 0:
269
+ entropy_rate_estimate[1:] = np.diff(block_entropy)
270
+
271
+ return BlockEntropyDiagram(
272
+ lengths=lengths,
273
+ block_entropy=block_entropy,
274
+ state_block_entropy=state_block_entropy,
275
+ block_state_entropy=block_state_entropy,
276
+ entropy_asymptote=entropy_asymptote,
277
+ crypticity_estimate=crypticity_estimate,
278
+ entropy_rate_estimate=entropy_rate_estimate,
279
+ entropy_rate=entropy_rate,
280
+ excess_entropy=excess_entropy,
281
+ statistical_complexity=statistical_complexity,
282
+ crypticity=crypticity,
283
+ markov_order=machine.markov_order(),
284
+ cryptic_order=machine.cryptic_order(),
285
+ )
286
+
287
+
288
+ def block_entropy_estimates(
289
+ machine: EpsilonMachine,
290
+ max_length: int,
291
+ *,
292
+ entropy_rate: float | None = None,
293
+ use_exact: bool = True,
294
+ ) -> BlockEntropyEstimates:
295
+ """Approximate information quantities from finite block entropies.
296
+
297
+ When ``use_exact`` is true (the default) the asymptotic ``h_mu`` and excess
298
+ entropy come from the exact closed-form / bidirectional computation, matching
299
+ the diagram and block-convergence paths; when false they are promoted from the
300
+ finite-block curves (the last entropy-rate difference) for genuinely
301
+ finite-length estimates.
302
+ """
303
+ if max_length < 0:
304
+ raise ValueError("max_length must be nonnegative")
305
+ if entropy_rate is None and max_length < 1:
306
+ raise ValueError("max_length must be positive when entropy_rate is not supplied")
307
+
308
+ lengths, block_entropy, state_block_entropy, block_state_entropy, pi, symbol_matrices = _block_entropy_curves(
309
+ machine,
310
+ max_length,
311
+ )
312
+
313
+ entropy_rate_estimate = _entropy_rate_estimates(block_entropy)
314
+ if entropy_rate is not None:
315
+ h_mu = float(entropy_rate)
316
+ elif use_exact:
317
+ # Match the diagram / convergence paths: the closed-form entropy rate is
318
+ # exact for unifilar presentations and does not depend on ``max_length``.
319
+ h_mu = _entropy_rate(pi, symbol_matrices)
320
+ else:
321
+ h_mu = float(entropy_rate_estimate[-1])
322
+ statistical_complexity = _entropy(pi)
323
+
324
+ h_mu_l = h_mu * lengths
325
+ excess_entropy_estimate = 0.5 * (block_entropy + block_state_entropy) - h_mu_l
326
+ excess_entropy = _estimated_excess_entropy(machine, excess_entropy_estimate, use_exact=use_exact)
327
+
328
+ entropy_asymptote = excess_entropy + h_mu_l
329
+ crypticity_estimate = state_block_entropy - block_state_entropy
330
+ crypticity = float(crypticity_estimate[-1]) if crypticity_estimate.size else 0.0
331
+
332
+ cm = _cm_extension_curves(
333
+ lengths,
334
+ block_entropy,
335
+ state_block_entropy,
336
+ block_state_entropy,
337
+ h_mu=h_mu,
338
+ statistical_complexity=statistical_complexity,
339
+ excess_entropy=excess_entropy,
340
+ entropy_asymptote=entropy_asymptote,
341
+ crypticity_estimate=crypticity_estimate,
342
+ )
343
+
344
+ residual_entropy = _residual_entropy_curve(machine, max_length)
345
+ residual_entropy_rate_estimate = _entropy_rate_estimates(residual_entropy)
346
+ r_mu = float(residual_entropy_rate_estimate[-1]) if max_length > 0 else math.nan
347
+ b_mu = h_mu - r_mu
348
+ rho_mu = float(block_entropy[1] - h_mu) if max_length >= 1 else math.nan
349
+
350
+ return BlockEntropyEstimates(
351
+ lengths=lengths,
352
+ block_entropy=block_entropy,
353
+ state_block_entropy=state_block_entropy,
354
+ block_state_entropy=block_state_entropy,
355
+ entropy_asymptote=entropy_asymptote,
356
+ entropy_rate_estimate=entropy_rate_estimate,
357
+ residual_entropy=residual_entropy,
358
+ residual_entropy_rate_estimate=residual_entropy_rate_estimate,
359
+ crypticity_estimate=crypticity_estimate,
360
+ entropy_rate=h_mu,
361
+ excess_entropy=excess_entropy,
362
+ statistical_complexity=statistical_complexity,
363
+ crypticity=crypticity,
364
+ predicted_information=rho_mu,
365
+ bound_information=b_mu,
366
+ ephemeral_information=r_mu,
367
+ excess_entropy_lower=cm.excess_entropy_lower,
368
+ excess_entropy_upper=cm.excess_entropy_upper,
369
+ excess_entropy_estimate=cm.excess_entropy_estimate,
370
+ synchronization_estimate=cm.synchronization_estimate,
371
+ reverse_synchronization_estimate=cm.reverse_synchronization_estimate,
372
+ transient_information_estimate=cm.transient_information_estimate,
373
+ predictability_gain_estimate=cm.predictability_gain_estimate,
374
+ oracular_information_estimate=cm.oracular_information_estimate,
375
+ gauge_information_estimate=cm.gauge_information_estimate,
376
+ )
377
+
378
+
379
+ def plot_block_entropy_diagram(
380
+ machine: EpsilonMachine,
381
+ max_length: int,
382
+ ax: Any | None = None,
383
+ **kwargs: Any,
384
+ ) -> Any:
385
+ """Compute and plot a block entropy diagram for ``machine``."""
386
+ return block_entropy_diagram(machine, max_length).plot(ax=ax, **kwargs)
387
+
388
+
389
+ def _block_entropy_curves(
390
+ machine: EpsilonMachine,
391
+ max_length: int,
392
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, dict[Any, np.ndarray]]:
393
+ pi, symbol_matrices = _stationary_symbol_matrices(machine)
394
+ lengths = np.arange(max_length + 1, dtype=int)
395
+ block_entropy = np.zeros(max_length + 1, dtype=float)
396
+ state_block_entropy = np.zeros(max_length + 1, dtype=float)
397
+ block_state_entropy = np.zeros(max_length + 1, dtype=float)
398
+
399
+ word_matrices = [np.eye(len(pi), dtype=float)]
400
+ terminal = np.ones(len(pi), dtype=float)
401
+ alphabet = tuple(sorted(symbol_matrices, key=repr))
402
+
403
+ for length in range(max_length + 1):
404
+ block_probs: list[float] = []
405
+ state_block_probs: list[float] = []
406
+ block_state_probs: list[float] = []
407
+
408
+ for matrix in word_matrices:
409
+ end_mass = pi @ matrix
410
+ start_conditioned = matrix @ terminal
411
+ block_probs.append(float(end_mass.sum()))
412
+ state_block_probs.extend(float(prob) for prob in pi * start_conditioned)
413
+ block_state_probs.extend(float(prob) for prob in end_mass)
414
+
415
+ block_entropy[length] = _entropy(block_probs)
416
+ state_block_entropy[length] = _entropy(state_block_probs)
417
+ block_state_entropy[length] = _entropy(block_state_probs)
418
+
419
+ if length < max_length:
420
+ word_matrices = [matrix @ symbol_matrices[symbol] for matrix in word_matrices for symbol in alphabet]
421
+
422
+ return lengths, block_entropy, state_block_entropy, block_state_entropy, pi, symbol_matrices
423
+
424
+
425
+ def _stationary_symbol_matrices(machine: EpsilonMachine) -> tuple[np.ndarray, dict[Any, np.ndarray]]:
426
+ from sofic.generators.hmm_inference import _emission_transition_tensors
427
+
428
+ pi = machine.stationary_distribution()
429
+ _, raw_matrices = _emission_transition_tensors(machine)
430
+ n = len(pi)
431
+ zero = np.zeros((n, n), dtype=float)
432
+ matrices = {
433
+ symbol: np.array(raw_matrices.get(symbol, zero), dtype=float) for symbol in machine.observation_alphabet
434
+ }
435
+ return pi, matrices
436
+
437
+
438
+ def _entropy_rate(pi: np.ndarray, symbol_matrices: dict[Any, np.ndarray]) -> float:
439
+ rate = 0.0
440
+ for state_index, state_probability in enumerate(pi):
441
+ if state_probability <= _TOL:
442
+ continue
443
+ symbol_probs = [float(matrix[state_index].sum()) for matrix in symbol_matrices.values()]
444
+ rate += float(state_probability) * _entropy(symbol_probs)
445
+ return rate
446
+
447
+
448
+ def _excess_entropy(
449
+ machine: EpsilonMachine,
450
+ *,
451
+ entropy_rate: float | None = None,
452
+ block_entropy: np.ndarray | None = None,
453
+ ) -> float:
454
+ try:
455
+ return float(machine.to_bidirectional().excess_entropy())
456
+ except Exception:
457
+ return _block_entropy_excess_entropy(machine, entropy_rate=entropy_rate, block_entropy=block_entropy)
458
+
459
+
460
+ def _block_entropy_excess_entropy(
461
+ machine: EpsilonMachine,
462
+ *,
463
+ entropy_rate: float | None = None,
464
+ block_entropy: np.ndarray | None = None,
465
+ ) -> float:
466
+ order = machine.markov_order()
467
+ if _is_finite_order(order):
468
+ markov_order = int(order)
469
+ h_mu = entropy_rate
470
+ if h_mu is None:
471
+ curves = _block_entropy_curves(machine, markov_order)
472
+ _block_entropy = curves[1]
473
+ pi = curves[4]
474
+ symbol_matrices = curves[5]
475
+ h_mu = _entropy_rate(pi, symbol_matrices)
476
+ block_entropy = _block_entropy
477
+ if block_entropy is not None and markov_order < len(block_entropy):
478
+ H_R = float(block_entropy[markov_order])
479
+ else:
480
+ curves = _block_entropy_curves(machine, markov_order)
481
+ H_R = float(curves[1][markov_order])
482
+ return float(H_R - markov_order * h_mu)
483
+
484
+ if block_entropy is None or len(block_entropy) == 0:
485
+ raise RuntimeError("cannot estimate excess entropy without finite block entropies")
486
+ h_mu = entropy_rate
487
+ if h_mu is None:
488
+ _lengths, _block_entropy, _state_block_entropy, _block_state_entropy, pi, symbol_matrices = (
489
+ _block_entropy_curves(machine, len(block_entropy) - 1)
490
+ )
491
+ h_mu = _entropy_rate(pi, symbol_matrices)
492
+ block_entropy = _block_entropy
493
+ length = len(block_entropy) - 1
494
+ return float(block_entropy[-1] - length * h_mu)
495
+
496
+
497
+ def _estimated_excess_entropy(machine: EpsilonMachine, estimate: np.ndarray, *, use_exact: bool) -> float:
498
+ if use_exact:
499
+ try:
500
+ return float(machine.excess_entropy())
501
+ except Exception:
502
+ pass
503
+ return float(estimate[-1]) if estimate.size else 0.0
504
+
505
+
506
+ def _entropy_rate_estimates(entropies: np.ndarray) -> np.ndarray:
507
+ estimates = np.empty(len(entropies), dtype=float)
508
+ estimates[0] = math.nan
509
+ if len(entropies) > 1:
510
+ estimates[1:] = np.diff(entropies)
511
+ return estimates
512
+
513
+
514
+ def _predictability_gain(block_entropy: np.ndarray, h_mu: float) -> np.ndarray:
515
+ """PG(L) = h_mu(L) - h_mu with h_mu(L) = H(L) - H(L-1) (Bialek et al., 2001).
516
+
517
+ The predictability gain is the excess of the length-``L`` entropy-rate estimate
518
+ over the asymptotic entropy rate, not the second difference of the block entropy.
519
+ """
520
+ gain = np.full(len(block_entropy), math.nan, dtype=float)
521
+ if len(block_entropy) > 1:
522
+ gain[1:] = np.diff(block_entropy) - h_mu
523
+ return gain
524
+
525
+
526
+ def _cm_extension_curves(
527
+ lengths: np.ndarray,
528
+ block_entropy: np.ndarray,
529
+ state_block_entropy: np.ndarray,
530
+ block_state_entropy: np.ndarray,
531
+ *,
532
+ h_mu: float,
533
+ statistical_complexity: float,
534
+ excess_entropy: float,
535
+ entropy_asymptote: np.ndarray,
536
+ crypticity_estimate: np.ndarray,
537
+ ) -> CMExtensionEstimates:
538
+ """Ellison/Mahoney CM-extension curves shared by both block estimators."""
539
+ h_mu_l = h_mu * lengths
540
+ excess_entropy_lower = block_entropy - h_mu_l
541
+ excess_entropy_upper = block_state_entropy - h_mu_l
542
+ excess_entropy_estimate = 0.5 * (excess_entropy_lower + excess_entropy_upper)
543
+ synchronization = block_state_entropy - block_entropy
544
+ reverse_synchronization = state_block_entropy - block_entropy
545
+ transient_information = np.cumsum(entropy_asymptote - block_entropy)
546
+ predictability_gain = _predictability_gain(block_entropy, h_mu)
547
+ oracular_information = statistical_complexity + h_mu_l - state_block_entropy
548
+ gauge_information = statistical_complexity - excess_entropy - crypticity_estimate - oracular_information
549
+ return CMExtensionEstimates(
550
+ excess_entropy_lower=excess_entropy_lower,
551
+ excess_entropy_upper=excess_entropy_upper,
552
+ excess_entropy_estimate=excess_entropy_estimate,
553
+ synchronization_estimate=synchronization,
554
+ reverse_synchronization_estimate=reverse_synchronization,
555
+ transient_information_estimate=transient_information,
556
+ predictability_gain_estimate=predictability_gain,
557
+ oracular_information_estimate=oracular_information,
558
+ gauge_information_estimate=gauge_information,
559
+ )
560
+
561
+
562
+ def _residual_entropy_curve(machine: EpsilonMachine, max_length: int) -> np.ndarray:
563
+ from sofic.generators._word_measures import residual_entropy_from_distribution
564
+
565
+ residual = np.zeros(max_length + 1, dtype=float)
566
+ for length in range(1, max_length + 1):
567
+ residual[length] = residual_entropy_from_distribution(machine.word_probabilities(length))
568
+ return residual
569
+
570
+
571
+ def _entropy(probabilities: Iterable[float]) -> float:
572
+ from sofic.generators.stochastic import shannon_entropy
573
+
574
+ return shannon_entropy(probabilities, normalize=True, atol=_TOL)
575
+
576
+
577
+ def _is_finite_order(order: int | float) -> bool:
578
+ return not isinstance(order, float) or math.isfinite(order)
@@ -0,0 +1,75 @@
1
+ """Information measures for channels presented as (epsilon-)transducers.
2
+
3
+ Following Barnett & Crutchfield (J. Stat. Phys. 161:2 (2015)), a transducer's
4
+ structural quantities are defined relative to a driving input process. Each
5
+ measure here drives the transducer with a supplied input generator, forms the
6
+ joint ``(input, output)`` process via
7
+ :func:`~sofic.automata.transducer_operations.compose_tg`, and reads off the
8
+ quantity -- reusing the directional-flow estimators in
9
+ :mod:`sofic.generators.directional_flow`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import defaultdict
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ import numpy as np
18
+
19
+ if TYPE_CHECKING:
20
+ from sofic.automata.transducers import MealyMachine
21
+ from sofic.generators.base import HiddenMarkovModel
22
+ from sofic.generators.mealy import MealyHMM
23
+
24
+
25
+ def driven_joint_generator(transducer: MealyMachine, input_process: HiddenMarkovModel) -> MealyHMM:
26
+ """Return the joint ``(input, output)`` generator induced by ``input_process``."""
27
+ from sofic.automata.transducer_operations import compose_tg
28
+
29
+ return compose_tg(transducer, input_process, joint=True)
30
+
31
+
32
+ def channel_statistical_complexity(transducer: MealyMachine, input_process: HiddenMarkovModel) -> float:
33
+ """Return the channel statistical complexity ``H[S]`` under ``input_process``.
34
+
35
+ ``S`` is the transducer's causal-state component of the driven joint process,
36
+ weighted by its stationary occupation distribution.
37
+ """
38
+ joint = driven_joint_generator(transducer, input_process)
39
+ idx = joint.reindex()
40
+ if len(idx) == 0:
41
+ return 0.0
42
+ pi = np.asarray(joint.stationary_distribution(), dtype=float)
43
+ mass: dict[Any, float] = defaultdict(float)
44
+ for state, weight in zip(idx.states, pi, strict=True):
45
+ transducer_state = state[1] if isinstance(state, tuple) and len(state) == 2 else state
46
+ mass[transducer_state] += float(weight)
47
+ probs = np.array([value for value in mass.values() if value > 0.0], dtype=float)
48
+ if probs.size == 0:
49
+ return 0.0
50
+ probs = probs / probs.sum()
51
+ return float(-(probs * np.log2(probs)).sum())
52
+
53
+
54
+ def driven_entropy_rate(transducer: MealyMachine, input_process: HiddenMarkovModel) -> float:
55
+ """Return the entropy rate of the output process induced by ``input_process``."""
56
+ from sofic.automata.transducer_operations import transduce_generator
57
+
58
+ output_generator = transduce_generator(transducer, input_process)
59
+ return output_generator.entropy_rate()
60
+
61
+
62
+ def directed_information(transducer: MealyMachine, input_process: HiddenMarkovModel, *, length: int = 1) -> float:
63
+ """Return the directed information ``I(X^n -> Y^n)`` when driven by ``input_process``."""
64
+ from sofic.generators.directional_flow import directed_information as _directed_information
65
+
66
+ joint = driven_joint_generator(transducer, input_process)
67
+ return _directed_information(joint, source="x", target="y", length=length)
68
+
69
+
70
+ def transfer_entropy(transducer: MealyMachine, input_process: HiddenMarkovModel, *, history: int = 1) -> float:
71
+ """Return the input-to-output transfer entropy when driven by ``input_process``."""
72
+ from sofic.generators.directional_flow import transfer_entropy as _transfer_entropy
73
+
74
+ joint = driven_joint_generator(transducer, input_process)
75
+ return _transfer_entropy(joint, source="x", target="y", history=history)