codex/random-walk-endpoint-distributions.py

random-walk-endpoint-distributions.py
"""Exact unit-step endpoint PMFs, using math.comb rather than SciPy.

Tested with Python 3.14 and the root pyproject NumPy/Matplotlib environment.
Run through make _media/random-walk-endpoint-distributions.png; output to CWD.
"""

from math import comb, sqrt
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np


def endpoint_pmf(n, p):
    endpoints = 2 * np.arange(n + 1) - n
    masses = np.array([comb(n, b) * p**b * (1 - p)**(n - b)
                       for b in range(n + 1)])
    mean = n * (2 * p - 1)
    variance = 4 * n * p * (1 - p)
    assert np.isclose(masses.sum(), 1, atol=1e-14, rtol=0)
    assert np.isclose(endpoints @ masses, mean, atol=1e-12, rtol=0)
    assert np.isclose((endpoints - mean)**2 @ masses, variance,
                      atol=1e-12, rtol=0)
    assert np.all((endpoints - n) % 2 == 0)
    return endpoints, masses


def main():
    plt.rcParams.update({"font.size": 11, "axes.titlesize": 13})
    cases = [(20, 0.5, "#2274a5"), (80, 0.5, "#43876d"),
             (80, 0.65, "#d27727")]
    fig, axes = plt.subplots(1, 3, figsize=(12, 5.4), dpi=120,
                             facecolor="white", sharey=True)
    for ax, (n, p, color) in zip(axes, cases):
        endpoints, masses = endpoint_pmf(n, p)
        mean = n * (2 * p - 1)
        variance = 4 * n * p * (1 - p)
        sigma = sqrt(variance)
        ax.vlines(endpoints, 0, masses, color=color, lw=1.3)
        ax.scatter(endpoints, masses, color=color, s=11, zorder=3)
        ax.axvline(mean, color="#182b40", ls="--", lw=1.5)
        ax.plot([mean - sigma, mean + sigma], [0.196, 0.196],
                color="#182b40", lw=2, marker="|", markersize=10)
        ax.text(mean, 0.203, r"Mean $\pm$ one SD", ha="center", fontsize=9)
        ax.text(0.04, 0.74,
                f"Mean = {mean:g}\nVariance = {variance:g}\nSD = {sigma:.3f}",
                transform=ax.transAxes, fontsize=10,
                bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.9})
        ax.set(title=f"n = {n}, p = {p:.2f}", xlabel=r"Endpoint $s$ (even integers)",
               xlim=(-n - 2, n + 2), ylim=(0, 0.228))
        ax.set_xticks(np.linspace(-n, n, 5, dtype=int))
        ax.grid(axis="y", color="#edf0f3")
        ax.set_axisbelow(True)
        ax.spines[["top", "right"]].set_visible(False)
        print(f"n={n}, p={p}: mass={masses.sum():.16g}, "
              f"mean={endpoints @ masses:.12g}, variance={variance:.12g}, "
              f"SD={sigma:.9f}, SD/n={sigma/n:.9f}, spacing/SD={2/sigma:.9f}")
    axes[0].set_ylabel(r"Probability $P(S_n=s)$")
    fig.suptitle("Exact endpoint probabilities: spread, drift, and parity", fontsize=16)
    fig.text(0.5, 0.06,
             "Full support shown: -n, -n+2, ..., n. Odd endpoints have probability zero.\n"
             "Stems show binomial masses, not a continuous density or simulation histogram.",
             ha="center", fontsize=10)
    fig.subplots_adjust(left=0.065, right=0.985, bottom=0.22, top=0.83, wspace=0.16)
    fig.savefig(Path.cwd() / Path(__file__).with_suffix(".png").name,
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()