codex/mark-recapture-population-likelihood.py

mark-recapture-population-likelihood.py
"""Plot the exact hypergeometric likelihood for M=20, C=25, R=5.

Tested with Python 3.14.4 and Matplotlib 3.10.7 (Debian +dfsg1 build).
Run from _media; write an opaque white PNG with this script's basename to CWD.
The range 40..300 is a plotting window, never an inference cutoff.
"""

from fractions import Fraction
from math import comb
from pathlib import Path

import matplotlib

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


def likelihood(n, m=20, c=25, r=5):
    if n < m + c - r:
        return Fraction(0)
    return Fraction(comb(m, r) * comb(n - m, c - r), comb(n, c))


def main():
    ns = list(range(40, 301))
    exact = [likelihood(n) for n in ns]
    peak = max(exact)
    assert [n for n, value in zip(ns, exact) if value == peak] == [99, 100]
    # Consecutive ratios cross one exactly at N=99 and decrease thereafter.
    # Their numerator minus denominator is 500 - 5*(N+1), so the entire
    # unbounded integer domain has these maxima, not just the plotted range.
    for n in ns[:-1]:
        ratio = Fraction((n - 19) * (n - 24), (n + 1) * (n - 39))
        assert likelihood(n + 1) / likelihood(n) == ratio
    lp = Fraction(20 * 25, 5)
    chapman = Fraction(21 * 26, 6) - 1
    assert (lp, chapman) == (100, 90)
    relative = [float(value / peak) for value in exact]
    plt.rcParams.update({"font.size": 12, "figure.facecolor": "white"})
    fig, ax = plt.subplots(figsize=(9.6, 5.8), dpi=100)
    ax.plot(ns, relative, color="#24669b", linewidth=2.1, label="Exact likelihood / its maximum")
    ax.axvline(float(chapman), color="#b56118", linewidth=1.8, linestyle="--", label="Chapman estimate: 90")
    ax.axvline(float(lp), color="#754a9f", linewidth=1.7, linestyle=":", label="Ratio estimate: 100")
    ax.scatter([99, 100], [1, 1], color="#24669b", s=30, zorder=5)
    ax.annotate("Integer maxima: 99 and 100", xy=(99.5, 1), xytext=(140, 0.91),
                arrowprops={"arrowstyle": "->", "color": "#303030"}, fontsize=11)
    ax.annotate("Positive tail continues\nbeyond the display", xy=(295, relative[-6]),
                xytext=(210, 0.34), arrowprops={"arrowstyle": "->", "color": "#303030"}, fontsize=11)
    ax.set(xlim=(40, 300), ylim=(0, 1.08), xlabel="Candidate population size N (integer, N ≥ 40)",
           ylabel="Relative likelihood  L(N) / max L")
    ax.set_xticks([40, 60, 90, 100, 150, 200, 250, 300])
    ax.grid(alpha=0.2)
    ax.legend(loc="upper right", bbox_to_anchor=(1, 0.79), framealpha=1, fontsize=10)
    ax.set_title("M = 20 marked, C = 25 sampled, R = 5 recaptured", fontsize=14, pad=12)
    fig.suptitle("Population likelihood from a small overlap", y=0.98, fontsize=16, weight="bold")
    fig.text(0.5, 0.035, "Toy data.  N = 40 is the observed minimum; 300 is only a plotting limit.  This is not a posterior.",
             ha="center", fontsize=10.5)
    fig.subplots_adjust(left=0.105, right=0.975, top=0.83, bottom=0.17)
    fig.savefig(Path.cwd() / Path(__file__).with_suffix(".png").name,
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()