codex/branching-extinction-fixed-point.py

branching-extinction-fixed-point.py
"""Draw the extinction PGF cobweb and probabilities through generation 20.

Tested with Python 3.14.4, NumPy 2.3.5 and Matplotlib 3.10.7.
Build: make _media/branching-extinction-fixed-point.png
Writes an opaque white same-basename PNG to the working directory.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np


def pgf(s):
    return 0.25 + 0.25 * s + 0.5 * s**2


def extinction_sequence(last=20):
    sequence = [0.0]
    for _ in range(last):
        sequence.append(pgf(sequence[-1]))
    q = np.array(sequence)
    # The factorization F(s)-s = (s-1/2)(s-1)/2 identifies both roots.
    grid = np.linspace(0, 1, 401)
    assert np.allclose(pgf(grid) - grid, 0.5 * (grid - 0.5) * (grid - 1),
                       atol=2e-16, rtol=0)
    assert pgf(0.5) == 0.5 and pgf(1.0) == 1.0
    assert np.all(np.diff(q) > 0) and np.all(q < 0.5)
    assert q[2] == 11 / 32 and q[3] == 809 / 2048
    # e_{n+1}=3e_n/4-e_n^2/2 gives a separate check of the error recurrence.
    errors = 0.5 - q
    assert np.allclose(errors[1:], 0.75 * errors[:-1] - 0.5 * errors[:-1]**2,
                       atol=1e-16, rtol=0)
    assert errors[-1] <= 0.5 * 0.75**last
    assert pgf(0) == 0.25 and pgf(1) == 1
    assert 0.25 + 2 * 0.5 == 1.25
    return q


def main():
    q = extinction_sequence()
    print(f"fixed points: 0.5 and 1; q_6={q[6]:.12f}; "
          f"q_20={q[20]:.12f}; remaining extinction at n=6={0.5-q[6]:.12f}")
    plt.rcParams.update({"font.size": 11})
    fig, (left, right) = plt.subplots(1, 2, figsize=(10, 5.2), dpi=120,
                                    facecolor="white")
    s = np.linspace(0, 1, 401)
    left.plot(s, pgf(s), color="#237a9c", lw=2.3, label=r"$F(s)=1/4+s/4+s^2/2$")
    left.plot(s, s, color="#64748b", ls="--", lw=1.5, label=r"$y=s$")
    x, y = [0.0], [0.0]
    for a, b in zip(q[:9], q[1:10]):
        x.extend([a, b])
        y.extend([b, b])
    left.plot(x, y, color="#c55c28", lw=1.3, label="iteration from zero")
    left.scatter([0.5, 1], [0.5, 1], color="#324254", s=40, zorder=5)
    left.annotate("q = 1/2", (0.5, 0.5), xytext=(0.58, 0.40), fontsize=11,
                  arrowprops={"arrowstyle": "->", "color": "#324254"})
    left.annotate("other fixed point: 1", (1, 1), xytext=(0.43, 0.74), fontsize=10,
                  arrowprops={"arrowstyle": "->", "color": "#324254"})
    left.set(xlabel=r"Input $s$", ylabel="Output", xlim=(-0.02, 1.04),
             ylim=(-0.02, 1.04), title="The cobweb selects the smaller crossing")
    left.set_aspect("equal", adjustable="box")
    left.legend(loc="upper left", fontsize=9, framealpha=1)
    right.plot(np.arange(len(q)), q, "o-", color="#c55c28", markersize=4,
               lw=1.8, label=r"$q_n=P(Z_n=0)$")
    right.axhline(0.5, color="#324254", ls="--", lw=1.5, label="eventual extinction: q = 1/2")
    right.scatter([6], [q[6]], color="#237a9c", s=48, zorder=5)
    right.annotate(f"n = 6: {q[6]:.6f}", (6, q[6]), xytext=(6.8, 0.34),
                   arrowprops={"arrowstyle": "->", "color": "#237a9c"}, fontsize=10)
    right.set(xlabel="Generation n", ylabel="Extinction probability by generation n",
              xlim=(-0.3, 20.5), ylim=(-0.02, 0.56),
              title="Finite-horizon extinction approaches 1/2")
    right.set_xticks([0, 5, 10, 15, 20])
    right.legend(loc="lower right", fontsize=9, framealpha=1)
    for ax in (left, right):
        ax.grid(color="#e6ebf0", lw=0.7)
        ax.set_axisbelow(True)
        ax.spines[["top", "right"]].set_visible(False)
    fig.suptitle("Extinction is the smallest fixed point of the offspring PGF",
                 fontsize=15, y=0.97)
    fig.text(0.5, 0.035,
             r"$q_0=0$, $q_{n+1}=F(q_n)$; starting at 1 would stay at 1 and miss the extinction probability.",
             ha="center", fontsize=11)
    fig.subplots_adjust(left=0.07, right=0.98, top=0.82, bottom=0.18, wspace=0.30)
    fig.savefig(Path.cwd() / Path(__file__).with_suffix(".png").name,
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()