codex/law-of-large-numbers-averages.py

law-of-large-numbers-averages.py
"""Generate law-of-large-numbers-averages.png in the caller's working directory.

Tested with Python 3.14.4, numpy 2.3.5, matplotlib 3.10.7.
Seeded Bernoulli paths; exact binomial tail sums, evaluated in floating point.
Run from _media with MPLCONFIGDIR set to a writable temporary directory.
"""

from math import lgamma, log
from pathlib import Path

import matplotlib

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


def binomial_probabilities(n, p):
    counts = np.arange(n + 1)
    log_coefficients = np.fromiter(
        (lgamma(n + 1) - lgamma(int(k) + 1) - lgamma(n - int(k) + 1)
         for k in counts), dtype=float, count=n + 1)
    probabilities = np.exp(log_coefficients + counts * log(p)
                           + (n - counts) * log(1 - p))
    assert np.isclose(probabilities.sum(), 1)
    return counts, probabilities


def main():
    p = 0.2
    tolerance = 0.1
    rng = np.random.default_rng(20261009)
    sizes = np.arange(1, 10001)
    trials = rng.binomial(1, p, size=(4, sizes.size))
    averages = np.cumsum(trials, axis=1) / sizes
    plt.rcParams.update({"font.size": 11, "axes.spines.top": False,
                         "axes.spines.right": False})
    fig, axes = plt.subplots(1, 2, figsize=(10, 4.9), dpi=120,
                             facecolor="white")
    for path, color in zip(averages, ["#2b7bba", "#b24a23", "#23815c", "#805da6"]):
        axes[0].plot(sizes, path, color=color, lw=1.1)
    axes[0].axhline(p, color="#222222", ls="--", lw=1.5, label="True mean p = 0.2")
    axes[0].set(xscale="log", xlim=(1, 10000), ylim=(0, 1),
                xlabel="Number of trials n", ylabel="Running success proportion",
                title="Strong law: four sample paths")
    axes[0].legend(loc="upper right", frameon=False, fontsize=10)
    sample_sizes = np.unique(np.geomspace(10, 1000, 90).astype(int))
    tail_probabilities = []
    for n in sample_sizes:
        counts, probabilities = binomial_probabilities(int(n), p)
        # Exact integer boundary for |K/n - 1/5| >= 1/10.
        outside = np.abs(10 * counts - 2 * n) >= n
        tail_probabilities.append(probabilities[outside].sum())
    axes[1].loglog(sample_sizes, tail_probabilities, color="#2b7bba", lw=2,
                   label="Exact binomial probability")
    bound = np.minimum(1, p * (1 - p) / (sample_sizes * tolerance**2))
    axes[1].loglog(sample_sizes, bound, color="#b24a23", lw=1.7, ls="--",
                   label="Chebyshev upper bound")
    assert np.all(np.asarray(tail_probabilities) <= bound + 1e-12)
    axes[1].set(xlim=(10, 1000), ylim=(1e-14, 1.1),
                xlabel="Number of trials n", ylabel="Probability of error at least 0.1",
                title="Weak law: unlikely fixed-size errors")
    axes[1].legend(loc="lower left", frameon=False, fontsize=10)
    for ax in axes:
        ax.grid(alpha=0.18)
    fig.suptitle("Law of large numbers: Bernoulli trials, p = 0.2",
                 fontsize=15, y=0.98)
    fig.text(0.5, 0.025,
             "Paths illustrate the theorem; the right panel sums exact probabilities, without simulation.",
             ha="center", fontsize=10)
    fig.tight_layout(rect=(0, 0.06, 1, 0.93))
    fig.savefig(Path.cwd() / "law-of-large-numbers-averages.png",
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()