Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/examples.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,11 +40,25 @@ Other literature processes
Tent map (Misiurewicz point)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Read through the two-letter kneading partition, split at the critical point
``c = 1/2`` (James et al. :cite:`James2013`, supplement Figs.~6--8):

* :func:`tent_map_misiurewicz_hmm` — non-unifilar HMM
* :func:`tent_map_misiurewicz_forward`, :func:`tent_map_misiurewicz_reverse`
* :func:`tent_map_misiurewicz_bidirectional` — information anatomy reference
* :func:`tent_map_misiurewicz_information_expected` — expected measure dict

Refining that partition by both order-1 preimages of the critical point,
``L = 1/(2a)`` and ``R = 1 - 1/(2a)``, gives a four-letter alphabet and a
five-state machine for the *same* dynamics. Both partitions are generating, so
both have entropy rate ``log2(a)``; only the anatomy split differs, and the
refined one has ``r_mu = 0``. Derived from the interval Markov chain — the 2013
supplement figures cover only the kneading partition:

* :func:`tent_map_misiurewicz_preimage_forward` — five states over ``{0, 1, 2, 3}``
* :func:`tent_map_misiurewicz_preimage_symbol_matrices` — the ``T^(x)`` matrices
* :func:`tent_map_misiurewicz_preimage_information_expected` — expected measure dict

Sofic-Dyck shifts
~~~~~~~~~~~~~~~~~

Expand DownExpand Up@@ -144,6 +158,9 @@ API
.. autofunction:: tent_map_misiurewicz_bidirectional
.. autofunction:: tent_map_misiurewicz_a
.. autofunction:: tent_map_misiurewicz_information_expected
.. autofunction:: tent_map_misiurewicz_preimage_forward
.. autofunction:: tent_map_misiurewicz_preimage_symbol_matrices
.. autofunction:: tent_map_misiurewicz_preimage_information_expected
.. autofunction:: dyck_shift_order
.. autofunction:: motzkin_shift
.. autofunction:: sofic_dyck_fig1_shift
Expand Down
6 changes: 6 additions & 0 deletions sofic/examples/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,9 @@
tent_map_misiurewicz_forward,
tent_map_misiurewicz_hmm,
tent_map_misiurewicz_information_expected,
tent_map_misiurewicz_preimage_forward,
tent_map_misiurewicz_preimage_information_expected,
tent_map_misiurewicz_preimage_symbol_matrices,
tent_map_misiurewicz_reverse,
)
from sofic.examples.processes import *
Expand DownExpand Up@@ -68,6 +71,9 @@
"tent_map_misiurewicz_forward",
"tent_map_misiurewicz_hmm",
"tent_map_misiurewicz_information_expected",
"tent_map_misiurewicz_preimage_forward",
"tent_map_misiurewicz_preimage_information_expected",
"tent_map_misiurewicz_preimage_symbol_matrices",
"tent_map_misiurewicz_reverse",
]
__all__ += _process_all
Expand Down
102 changes: 101 additions & 1 deletion sofic/examples/epsilon_machines.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,10 @@
- Golden-mean shift (forbid ``11``, Parry max-entropy): standard symbolic
dynamics; see e.g. Ellison et al., arXiv:1107.2168 Fig.~2.
- Tent map (Misiurewicz point): James, Burke & Crutchfield (2013), supplement
to *Chaos Forgets and Remembers*; Figs.~6--8.
to *Chaos Forgets and Remembers*; Figs.~6--8. The four-letter
``preimage`` variants read the same dynamics through a partition refined by
both preimages of the critical point; that presentation is derived from the
interval Markov chain rather than taken from a figure.
"""

from __future__ import annotations
Expand DownExpand Up@@ -465,6 +468,103 @@ def tent_map_misiurewicz_forward(a: Any | None = None) -> EpsilonMachine:
return from_symbol_matrices(states, symbols, matrices)


def tent_map_misiurewicz_preimage_symbol_matrices(
a: Any | None = None,
) -> tuple[tuple[str, ...], tuple[int, ...], dict[int, np.ndarray]]:
"""Symbol matrices for the tent map read through the partition ``{L, c, R}``.

The kneading partition of :func:`tent_map_misiurewicz_fig7_symbol_matrices`
cuts ``[0, 1]`` only at the critical point ``c = 1/2``. Refining it by the
two order-1 preimages of ``c`` -- ``L = 1/(2a)`` and ``R = 1 - 1/(2a)`` --
gives the four-letter generating partition

========== ==================
Symbol Cell
========== ==================
``0`` ``[0, L)``
``1`` ``[L, c)``
``2`` ``[c, R)``
``3`` ``[R, 1]``
========== ==================

under which the map has the five-state ε-machine ``A``--``E``. Reducing by
the parameter's minimal polynomial ``a**3 = 2a + 2`` turns every transition
probability into a quadratic in ``a`` with rational coefficients, so unlike
the kneading presentation none of them carries an ``a``-dependent
denominator.

Derived from the exact interval Markov chain on the forward-orbit closure of
``{c, L, R}``. The tent map, the Misiurewicz parameter and the information
anatomy this presentation is used for are from James, Burke & Crutchfield,
*Chaos Forgets and Remembers* (2013) :cite:`James2013`; that paper's figures
cover only the kneading partition, so this refined presentation has no
published figure to cite.
"""
from sofic.generators.prob import is_symbolic, zeros

if a is None:
a = tent_map_misiurewicz_a()
states = ("A", "B", "C", "D", "E")
symbolic = is_symbolic(a)
one = 1 if symbolic else 1.0
matrices = {symbol: zeros((5, 5), symbolic=symbolic) for symbol in (0, 1, 2, 3)}
# A=0, B=1, C=2, D=3, E=4. Rows sum to one identically in ``a``.
matrices[2][0, 0] = (a**2 - 2) / 2 # A → A on 2
matrices[3][0, 1] = (4 - a**2) / 2 # A → B on 3
matrices[0][1, 3] = (a**2 - 2 * a + 2) / 6 # B → D on 0
matrices[1][1, 0] = (4 + 2 * a - a**2) / 6 # B → A on 1
matrices[2][2, 4] = (2 + a - a**2) / 2 # C → E on 2
matrices[3][2, 1] = (a**2 - a) / 2 # C → B on 3
matrices[1][3, 2] = one # D → C on 1
matrices[2][4, 2] = one # E → C on 2
return states, (0, 1, 2, 3), matrices


def tent_map_misiurewicz_preimage_forward(a: Any | None = None) -> EpsilonMachine:
"""Forward ε-machine of the tent map under the four-letter ``{L, c, R}`` partition.

Companion to :func:`tent_map_misiurewicz_forward`, which reads the same
dynamics through the two-letter kneading partition. Refining by both
preimages of the critical point trades states for letters: five causal
states over a four-letter alphabet instead of four over two. The process
stays strictly sofic (infinite Markov and cryptic order) but its ephemeral
information vanishes -- see
:func:`tent_map_misiurewicz_preimage_information_expected`.
"""
states, symbols, matrices = tent_map_misiurewicz_preimage_symbol_matrices(a)
return from_symbol_matrices(states, symbols, matrices)


def tent_map_misiurewicz_preimage_information_expected(a: Any | None = None) -> dict[str, Any]:
"""Closed-form anatomy of the four-letter ``{L, c, R}`` tent-map partition.

The ephemeral rate is *exactly* zero, so ``b_mu = h_mu = log2(a)``. The
reason is structural rather than numerical: the machine of
:func:`tent_map_misiurewicz_preimage_forward` is unifilar, no two of its
edges share both a source and a target, and every branch leads to a state
with a distinguishable future (``A`` versus ``B``, ``D`` versus ``A``, ``E``
versus ``B``). Knowing the past fixes the current causal state, the future
then fixes the successor, and the two together name the emitted symbol --
leaving nothing for ``r_mu = H[X_0 | past, future]`` to measure.

Contrast :func:`tent_map_misiurewicz_information_expected`, where the
coarser kneading partition of the *same* dynamics splits the same entropy
rate into a nonzero ephemeral part (James, Burke & Crutchfield, 2013
:cite:`James2013`).
"""
from sofic.generators.prob import is_symbolic

if a is None:
a = tent_map_misiurewicz_a()
if is_symbolic(a):
import sympy as sp

h_mu = sp.log(a, 2)
return {"bound_mu": h_mu, "ephemeral_mu": sp.Integer(0), "entropy_rate": h_mu}
h_mu = math.log2(a)
return {"bound_mu": h_mu, "ephemeral_mu": 0.0, "entropy_rate": h_mu}


def tent_map_misiurewicz_hmm(a: Any | None = None) -> MealyHMM:
"""Non-unifilar HMM from supplement Fig.~6 (right).

Expand Down
101 changes: 101 additions & 0 deletions tests/test_information_anatomy.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,8 @@

from __future__ import annotations

import math

import pytest

from sofic.examples import (
Expand All@@ -13,9 +15,12 @@
golden_mean_forward,
golden_mean_reverse,
nemo_process,
tent_map_misiurewicz_a,
tent_map_misiurewicz_bidirectional,
tent_map_misiurewicz_forward,
tent_map_misiurewicz_information_expected,
tent_map_misiurewicz_preimage_forward,
tent_map_misiurewicz_preimage_information_expected,
)
from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine

Expand DownExpand Up@@ -481,6 +486,102 @@ def test_tent_map_misiurewicz_bidirectional_regression():
assert bidir.crypticity() == pytest.approx(bidir.statistical_complexity() - bidir.excess_entropy(), abs=1e-9)


def test_tent_map_preimage_partition_presentation():
"""Refining by both preimages of ``c`` gives five states over four letters."""
forward = tent_map_misiurewicz_preimage_forward()
forward.validate_stochastic()

assert sorted(forward.states()) == ["A", "B", "C", "D", "E"]
assert sorted(forward.observation_alphabet) == [0, 1, 2, 3]
assert forward.is_unifilar()
# Still strictly sofic: r_μ = 0 does not buy finite memory.
assert forward.is_strictly_sofic()

# Reduced by the parameter's minimal polynomial ``a**3 = 2a + 2``, every
# branching probability is a quadratic in ``a``.
a = tent_map_misiurewicz_a()
expected = {
("A", 2, "A"): (a**2 - 2) / 2,
("A", 3, "B"): (4 - a**2) / 2,
("B", 0, "D"): (a**2 - 2 * a + 2) / 6,
("B", 1, "A"): (4 + 2 * a - a**2) / 6,
("C", 2, "E"): (2 + a - a**2) / 2,
("C", 3, "B"): (a**2 - a) / 2,
("D", 1, "C"): 1.0,
("E", 2, "C"): 1.0,
}
edges = {(t.source, t.data["emission"], t.target): float(t.data["prob"]) for t in forward.graph.transitions()}
assert edges.keys() == expected.keys()
for edge, probability in expected.items():
assert edges[edge] == pytest.approx(probability, abs=1e-12)


def test_tent_map_preimage_partition_stationary_distribution():
"""Causal-state weights of the four-letter tent-map presentation."""
forward = tent_map_misiurewicz_preimage_forward()
index = forward.reindex()
pi = forward.stationary_distribution()
weights = {index.state(i): float(pi[i]) for i in range(len(index.states))}

assert weights == pytest.approx(
{
"A": 0.4870384416,
"B": 0.2882345739,
"C": 0.1123634923,
"D": 0.0764691477,
"E": 0.0358943445,
},
abs=1e-9,
)


def test_tent_map_preimage_partition_forbidden_blocks():
"""Nine of the sixteen two-letter blocks are forbidden by the refined partition."""
forward = tent_map_misiurewicz_preimage_forward()
words = forward.word_probabilities(2)
forbidden = {(0, 0), (0, 2), (0, 3), (1, 0), (1, 1), (2, 0), (2, 1), (3, 2), (3, 3)}

for word in forbidden:
assert float(words.get(word, 0.0)) == pytest.approx(0.0, abs=1e-12)
allowed = {word for word, p in words.items() if float(p) > 1e-12}
assert len(allowed) == 7
assert allowed.isdisjoint(forbidden)


def test_tent_map_preimage_partition_is_generating():
"""Both partitions of the same dynamics share the entropy rate ``log2(a)``."""
pytest.importorskip("dit")
a = tent_map_misiurewicz_a()
refined = tent_map_misiurewicz_preimage_forward()

assert refined.entropy_rate() == pytest.approx(math.log2(a), abs=1e-9)
assert refined.entropy_rate() == pytest.approx(tent_map_misiurewicz_forward().entropy_rate(), abs=1e-9)


def test_tent_map_preimage_ephemeral_information_vanishes():
"""The refined partition moves the whole entropy rate into bound information."""
pytest.importorskip("dit")
expected = tent_map_misiurewicz_preimage_information_expected()
refined = tent_map_misiurewicz_preimage_forward()

assert refined.ephemeral_information() == pytest.approx(0.0, abs=1e-9)
assert refined.bound_information() == pytest.approx(expected["bound_mu"], abs=1e-9)
assert refined.entropy_rate() == pytest.approx(expected["entropy_rate"], abs=1e-9)

# The coarser kneading partition of the same dynamics does not.
assert tent_map_misiurewicz_information_expected()["ephemeral_mu"] == pytest.approx(0.648258, abs=1e-4)


def test_tent_map_preimage_statistical_complexity_exceeds_excess_entropy():
"""Regression on C_μ and E; the gap is the machine's crypticity."""
pytest.importorskip("dit")
refined = tent_map_misiurewicz_preimage_forward()

assert refined.statistical_complexity() == pytest.approx(1.833069440568067, abs=1e-9)
assert refined.excess_entropy() == pytest.approx(1.1407151497907773, abs=1e-9)
assert refined.statistical_complexity() > refined.excess_entropy()


def test_tent_forward_matches_generator_path():
pytest.importorskip("dit")
from sofic.examples.epsilon_machines import tent_map_misiurewicz_hmm
Expand Down
31 changes: 31 additions & 0 deletions tests/test_symbolic_hmm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,8 @@
tent_map_misiurewicz_forward,
tent_map_misiurewicz_hmm,
tent_map_misiurewicz_information_expected,
tent_map_misiurewicz_preimage_forward,
tent_map_misiurewicz_preimage_symbol_matrices,
)
from sofic.generators.epsilon_machine import EpsilonMachine
from sofic.generators.prob import (
Expand All@@ -28,6 +30,35 @@
from sofic.generators.words import hmm_word_probability


def test_symbolic_preimage_rows_sum_to_one_identically():
"""The refined partition's rows normalize for a free ``a``, not just at the root."""
a = sp.symbols("a", positive=True)
states, symbols, matrices = tent_map_misiurewicz_preimage_symbol_matrices(a)
for i in range(len(states)):
row = sum(matrices[x][i, j] for x in symbols for j in range(len(states)))
assert sp.simplify(row) == 1


def test_symbolic_preimage_substitution_matches_numeric():
"""Substituting the Misiurewicz root into the symbolic machine recovers the floats."""
a = sp.symbols("a", positive=True)
a_num = tent_map_misiurewicz_a()
symbolic = tent_map_misiurewicz_preimage_forward(a)
numeric = tent_map_misiurewicz_preimage_forward()

def edges(machine, substitute):
# Certain transitions are stored as an exact ``1``, so sympify before substituting.
return {
(t.source, t.data["emission"], t.target): float(
sp.sympify(t.data["prob"]).subs(a, a_num) if substitute else t.data["prob"]
)
for t in machine.graph.transitions()
}

assert edges(symbolic, True) == pytest.approx(edges(numeric, False), abs=1e-12)
assert float(symbolic.entropy_rate().subs(a, a_num)) == pytest.approx(math.log2(a_num), abs=1e-9)


def test_symbolic_edge_probabilities_preserved():
a = sp.symbols("a", positive=True)
hmm = tent_map_misiurewicz_forward(a)
Expand Down
Loading