codex/haplotype-joint-frequencies-fixed-marginals.py

haplotype-joint-frequencies-fixed-marginals.py
"""Draw synthetic two-locus haplotype tables with fixed allele frequencies.

Tested with Python 3.14, matplotlib 3.10.7 and numpy 2.3.5.
Run from _media; the matching opaque PNG is written to the working directory.
"""

from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import numpy as np


def main():
    p, q = 0.60, 0.40
    associations = [(-0.12, "Negative association"), (0.0, "Linkage equilibrium"),
                    (0.12, "Positive association")]
    fig, axes = plt.subplots(1, 3, figsize=(10.8, 4.4), facecolor="white")
    fig.subplots_adjust(left=0.025, right=0.995, bottom=0.18, top=0.78, wspace=0.09)
    for ax, (d, label) in zip(axes, associations):
        table = np.array([[p*q+d, p*(1-q)-d],
                          [(1-p)*q-d, (1-p)*(1-q)+d]])
        assert np.all(table >= 0)
        np.testing.assert_allclose(table.sum(axis=1), [p, 1-p])
        np.testing.assert_allclose(table.sum(axis=0), [q, 1-q])
        np.testing.assert_allclose(table.sum(), 1)
        np.testing.assert_allclose(np.linalg.det(table), d, atol=1e-14)
        lower = min(p*q, (1-p)*(1-q))
        upper = min(p*(1-q), (1-p)*q)
        dprime = d / (upper if d > 0 else lower) if d else 0
        r2 = d*d / (p*(1-p)*q*(1-q))
        for row in range(2):
            for col in range(2):
                value = table[row, col]
                ax.add_patch(Rectangle((col, 1-row), 1, 1,
                                       facecolor=plt.cm.Blues(0.12 + 0.65*value/0.5),
                                       edgecolor="white", linewidth=2))
                haplotype = ("A" if row == 0 else "a") + ("B" if col == 0 else "b")
                ax.text(col+0.5, 1-row+0.62, haplotype, ha="center", va="center",
                        fontsize=12, color="#17324d")
                ax.text(col+0.5, 1-row+0.34, f"{value:.2f}", ha="center", va="center",
                        fontsize=16, color="#102331", fontweight="bold")
        for row, marginal in enumerate([p, 1-p]):
            ax.text(2.17, 1-row+0.5, f"{marginal:.2f}", ha="center", va="center", fontsize=12)
        for col, marginal in enumerate([q, 1-q]):
            ax.text(col+0.5, -0.24, f"{marginal:.2f}", ha="center", va="center", fontsize=12)
        ax.text(2.17, 2.10, "Total", ha="center", fontsize=10, color="#555555")
        ax.text(-0.03, -0.25, "Total", ha="right", fontsize=10, color="#555555")
        ax.text(1, -0.63, f"D′ = {dprime:.2f}    r² = {r2:.2f}", ha="center", fontsize=12)
        ax.set_title(f"{label}\nD = {d:.2f}", fontsize=12, pad=14)
        ax.set_xlim(-0.36, 2.5)
        ax.set_ylim(-0.77, 2.18)
        ax.set_aspect("equal")
        ax.axis("off")
    fig.suptitle("Same allele frequencies, different haplotype association", fontsize=16, y=0.97)
    fig.text(0.5, 0.045, "Synthetic frequencies: P(A) = 0.60 and P(B) = 0.40 in every table",
             ha="center", fontsize=12, color="#444444")
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=110,
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()