codex/jackknife-variance-bias.py

jackknife-variance-bias.py
"""Compare jackknife variance and bias calculations; tested with Python 3.14.4.

Dependencies: Matplotlib and NumPy from the root pyproject.toml.
Run through make _media/jackknife-variance-bias.png. The opaque white PNG
is written in the current directory with the script's basename (1200 x 720).
"""

from math import isclose, sqrt
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np


def calculations(data):
    """Recompute each mean and centered divisor-m variance after deletion."""
    n = data.size
    reduced = [np.delete(data, i) for i in range(n)]
    means = np.array([row.mean() for row in reduced])
    variances = np.array([row.var(ddof=0) for row in reduced])
    contributions = (n - 1) / n * (means - means.mean())**2
    empirical = data.var(ddof=0)
    bias = (n - 1) * (variances.mean() - empirical)
    return means, variances, contributions, empirical, bias, empirical - bias


def main():
    data = np.array([1., 2., 3., 4., 10.])
    means, variances, parts, empirical, bias, corrected = calculations(data)
    np.testing.assert_allclose(means, [4.75, 4.5, 4.25, 4, 2.5], atol=0, rtol=0)
    np.testing.assert_allclose(variances, [9.6875, 11.25, 12.1875, 12.5, 1.25], atol=0, rtol=0)
    np.testing.assert_allclose(parts, [0.45, 0.2, 0.05, 0, 1.8], atol=1e-14, rtol=0)
    assert empirical == 10 and variances.mean() == 9.375 and bias == -2.5
    assert corrected == 12.5 and data.var(ddof=1) == 12.5
    assert isclose(parts.sum(), 2.5) and isclose(parts.sum(), data.var(ddof=1) / data.size)
    # Check both algebraic identities on further deterministic data, including n=2.
    for other in (np.array([2., 7.]), np.array([3., 3., 3.]),
                  np.array([-4., 0., 1., 2., 6., 11.])):
        _, _, other_parts, _, _, other_corrected = calculations(other)
        assert isclose(other_corrected, other.var(ddof=1), rel_tol=1e-12, abs_tol=1e-12)
        assert isclose(other_parts.sum(), other.var(ddof=1) / other.size,
                       rel_tol=1e-12, abs_tol=1e-12)

    blue, orange, green = "#176b91", "#b94a15", "#237347"
    plt.rcParams.update({"font.size": 12, "axes.spines.top": False,
                         "axes.spines.right": False})
    fig = plt.figure(figsize=(10, 6), layout="constrained", facecolor="white")
    grid = fig.add_gridspec(2, 2, height_ratios=[2.6, 1.45], width_ratios=[1, 1.15])
    left = fig.add_subplot(grid[:, 0])
    right = fig.add_subplot(grid[0, 1])
    comparison = fig.add_subplot(grid[1, 1])
    fig.suptitle("One set of deletions, two different targets\nData (1, 2, 3, 4, 10)", fontsize=16)

    indices = np.arange(data.size)
    colors = [blue] * 4 + [orange]
    left.bar(indices, parts, color=colors, width=0.65)
    for i, value in enumerate(parts):
        left.text(i, value + 0.035, f"{value:g}", ha="center", va="bottom")
    left.set_xticks(indices, [f"Omit\n{x:g}" for x in data])
    left.set(ylim=(0, 2.55), ylabel="Contribution to variance estimate of the mean")
    left.set_title("Variance of the sample mean", fontsize=13, pad=12)
    left.text(0.5, 0.94, r"$(4/5)(\bar{x}_{(-i)}-4)^2$" + "\nSum = 2.5; SE = √2.5 ≈ 1.581",
              transform=left.transAxes, ha="center", va="top", fontsize=12)
    left.grid(axis="y", color="#e2e2e2", linewidth=0.6)
    left.set_axisbelow(True)

    right.axhline(variances.mean(), color=green, linestyle="--", linewidth=1.4,
                 label="Deletion average = 9.375")
    right.scatter(indices, variances, color=colors, s=55, zorder=3)
    for i, value in enumerate(variances):
        right.annotate(f"{value:g}", (i, value), xytext=(0, 10),
                       textcoords="offset points", ha="center", fontsize=10)
    right.set_xticks(indices, [f"Omit {x:g}" for x in data])
    right.set(xlim=(-0.6, 4.6), ylim=(0, 15.5), ylabel="Divisor-4 variance")
    right.set_title("Bias correction of the empirical variance", fontsize=12, pad=12)
    right.grid(axis="y", color="#e2e2e2", linewidth=0.6)
    right.legend(loc="lower left", framealpha=1, fontsize=10)

    comparison.barh([1, 0], [empirical, corrected], color=[blue, green], height=0.5)
    comparison.set_yticks([1, 0], ["Original", "Corrected"])
    comparison.set(xlim=(0, 15), ylim=(-0.5, 1.7), xlabel="Variance estimate of individual observations")
    for y, value in zip([1, 0], [empirical, corrected]):
        comparison.text(value + 0.25, y, f"{value:g}", va="center", fontsize=11)
    comparison.set_title("Estimated bias = 4(9.375 − 10) = −2.5\n"
                         "Corrected = 10 − (−2.5) = 12.5", fontsize=11, pad=10)
    comparison.grid(axis="x", color="#e2e2e2", linewidth=0.6)
    comparison.set_axisbelow(True)
    comparison.spines["left"].set_visible(False)
    comparison.tick_params(axis="y", length=0)

    output = Path.cwd() / (Path(__file__).stem + ".png")
    fig.savefig(output, dpi=120, facecolor="white", transparent=False)
    plt.close(fig)
    print(f"Saved {output} (1200 x 720)")
    print(f"s²={data.var(ddof=1):g}; Vmean={parts.sum():g}; SE={sqrt(parts.sum()):.12f}")
    print(f"Variance contributions={parts.tolist()}")
    print(f"Deletion empirical variances={variances.tolist()}")
    print(f"T={empirical:g}; average={variances.mean():g}; bias={bias:g}; corrected={corrected:g}")
    print("Additional identity checks passed: n=2, constant data, signed six-point data.")


if __name__ == "__main__":
    main()