codex/linkage-disequilibrium-ideal-recombination-decay.py

linkage-disequilibrium-ideal-recombination-decay.py
"""Draw deterministic recombination decay, not measured population data.

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
import numpy as np


def main():
    generations = np.arange(61)
    fig, axes = plt.subplots(1, 2, figsize=(10.2, 4.6), facecolor="white")
    fig.subplots_adjust(left=0.085, right=0.98, top=0.77, bottom=0.22, wspace=0.27)
    colors = ["#555555", "#0072b2", "#d55e00", "#009e73"]
    for recombination, color in zip([0.0, 0.01, 0.1, 0.5], colors):
        normalized_d = (1-recombination)**generations
        # Recursively update all four frequencies as a separate check of the curves.
        f = np.array([0.36, 0.24, 0.04, 0.36])  # p = 0.60, q = 0.40, D0 = 0.12
        for t in generations:
            d = f[0]*f[3] - f[1]*f[2]
            np.testing.assert_allclose(d, 0.12*normalized_d[t], atol=2e-15)
            np.testing.assert_allclose([f[0]+f[1], f[0]+f[2]], [0.60, 0.40])
            assert np.min(f) >= 0
            f += recombination*d*np.array([-1, 1, 1, -1])
        for ax, power in zip(axes, [1, 2]):
            ax.plot(generations, normalized_d**power, color=color, linewidth=2,
                    marker="o", markersize=3, markevery=5, label=f"r = {recombination:g}")
    for ax in axes:
        ax.set_xlim(0, 60)
        ax.set_ylim(-0.025, 1.04)
        ax.set_xlabel("Generation t", fontsize=12)
        ax.set_yticks(np.linspace(0, 1, 6))
        ax.tick_params(labelsize=10)
        ax.grid(color="#dddddd", linewidth=0.65)
        ax.spines[["top", "right"]].set_visible(False)
        ax.axhline(0.5, color="#999999", linestyle=":", linewidth=1)
    axes[0].set_title(r"Signed association: $D_t/D_0=(1-r)^t$", fontsize=12)
    axes[0].set_ylabel("Fraction of initial D", fontsize=12)
    axes[1].set_title(r"Squared correlation: $r_{\rm LD,t}^2/r_{\rm LD,0}^2=(1-r)^{2t}$", fontsize=12)
    axes[1].set_ylabel("Fraction of initial squared correlation", fontsize=11)
    handles, labels = axes[0].get_legend_handles_labels()
    fig.legend(handles, labels, loc="upper center", bbox_to_anchor=(0.52, 0.9),
               ncol=4, frameon=False, fontsize=11)
    fig.suptitle("Ideal recombination decay at fixed allele frequencies", fontsize=16, y=0.98)
    fig.text(0.5, 0.065, "Random mating; constant r; no selection, mutation, migration or drift\n"
             "D₀ ≠ 0; normalization applies to either sign. Points are model generations.",
             ha="center", fontsize=10.5, color="#444444")
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=110,
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()