codex/binomial-poisson-limit.py

binomial-poisson-limit.py
"""Generate binomial-poisson-limit.png in the caller's working directory.

Run from _media: MPLCONFIGDIR=/tmp/codex-wiki-mpl python ../binomial-poisson-limit.py
Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Uses exact binomial formula and Poisson recursion, never sampled counts.
"""

from math import comb, exp, isclose
from pathlib import Path

import matplotlib

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


def binomial_masses(n, p):
    return np.array([comb(n, k) * p**k * (1-p)**(n-k)
                     for k in range(n+1)])


def poisson_masses(parameter, maximum):
    masses = np.empty(maximum + 1)
    masses[0] = exp(-parameter)
    for k in range(1, maximum + 1):
        masses[k] = masses[k-1] * parameter / k
    return masses


def main():
    parameter = 3.0
    reference = poisson_masses(parameter, 200)
    counts = np.arange(len(reference))
    assert isclose(float(reference.sum()), 1, abs_tol=1e-14)
    assert isclose(float(np.dot(counts, reference)), parameter, abs_tol=1e-13)
    assert isclose(float(np.dot((counts-parameter)**2, reference)), parameter, abs_tol=1e-13)
    assert isclose(float(reference[3]), 4.5*exp(-3), abs_tol=1e-14)
    visible_counts = np.arange(13)
    plt.rcParams.update({"font.size": 10, "axes.spines.top": False,
                         "axes.spines.right": False})
    fig, axes = plt.subplots(1, 3, figsize=(10, 4.3), dpi=120,
                             facecolor="white", sharex=True, sharey=True)
    for ax, n in zip(axes, [10, 50, 200]):
        p = parameter / n
        masses = binomial_masses(n, p)
        all_counts = np.arange(n+1)
        mean = float(np.dot(all_counts, masses))
        variance = float(np.dot((all_counts-mean)**2, masses))
        assert isclose(float(masses.sum()), 1, abs_tol=3e-14)
        assert isclose(mean, parameter, abs_tol=1e-12)
        assert isclose(variance, parameter*(1-p), abs_tol=1e-12)
        plotted_masses = np.zeros(len(visible_counts))
        plotted_masses[:min(n+1, len(visible_counts))] = masses[:len(visible_counts)]
        ax.bar(visible_counts, plotted_masses, width=0.7, color="#287ba5",
               alpha=0.75, label="Exact binomial mass", zorder=2)
        ax.plot(visible_counts, reference[:len(visible_counts)], "o",
                color="#c65c24", markersize=5.5, markerfacecolor="white",
                markeredgewidth=1.7, label="Poisson(3) mass", zorder=3)
        ax.set(title=f"n = {n},  p = {p:g}", xlabel="Success count k",
               xlim=(-0.7, 12.7), ylim=(0, 0.31), xticks=np.arange(0, 13, 2))
        ax.grid(axis="y", alpha=0.2, zorder=0)
        ax.text(0.95, 0.91, f"Mean = 3\nVariance = {variance:.3f}",
                transform=ax.transAxes, ha="right", va="top", fontsize=10)
        tail = float(masses[13:].sum())
        assert tail < 2e-5
        print(f"n={n}, p={p:g}: PMF sum={masses.sum():.15f}, mean={mean:.12f}, "
              f"variance={variance:.12f}, P(K=3)={masses[3]:.12f}, omitted k>12 mass={tail:.3g}")
    axes[0].set_ylabel("Probability mass at k")
    handles, labels = axes[0].get_legend_handles_labels()
    fig.legend(handles, labels, ncol=2, loc="lower center", frameon=False,
               bbox_to_anchor=(0.5, 0.052))
    fig.suptitle("Rare-event limit: more trials, smaller p, fixed mean np = 3",
                 fontsize=14, y=0.98)
    fig.text(0.5, 0.014, "All panels use the same axes. Counts above 12 have total mass below 0.00002 in every law.",
             ha="center", fontsize=9)
    assert float(reference[13:].sum()) < 2e-5
    fig.tight_layout(rect=(0, 0.13, 1, 0.91))
    destination = Path.cwd() / "binomial-poisson-limit.png"
    fig.savefig(destination, facecolor="white", transparent=False)
    plt.close(fig)
    print(f"Poisson(3): P(N=0)={reference[0]:.12f}, P(N=3)={reference[3]:.12f}, "
          f"P(N>=1)={1-reference[0]:.12f}, omitted k>12 mass={reference[13:].sum():.3g}")
    print(f"Saved {destination}")


if __name__ == "__main__":
    main()