MachineSex/figures/_figlib.py
Giorgio Gilestro 84124de143 Manuscript revision and pending experiment work, snapshot before restructuring
Clarity pass over the main text (36-item audit), Discussion rewrite and cut,
acknowledgements, Souly et al. as ref 62, lettered SI panels, model section
moved under Results; plus the untracked curriculum/society/compose/smol
configs, runners, figures, stats and tests that the SI already cites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y64o8FKP7rCuXzC48pxpMm
2026-09-13 16:54:09 +01:00

82 lines
3.2 KiB
Python

"""Shared helpers for Layer-1 figure scripts.
Figures are a pure function of a committed results bundle (``results/EX/``:
``results.parquet`` + ``resolved_config.yaml``). No simulation is rerun here.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
import yaml
def load_bundle(results_dir: str | Path) -> tuple[pd.DataFrame, dict]:
"""Load a results bundle: (long-form DataFrame, source experiment config)."""
results_dir = Path(results_dir)
df = pd.read_parquet(results_dir / "results.parquet")
resolved = yaml.safe_load((results_dir / "resolved_config.yaml").read_text())
return df, resolved["source_config"]
def load_seed_bundles(results_dir: str | Path) -> tuple[pd.DataFrame, dict]:
"""Load a per-seed bundle layout ``results_dir/s{seed}/results.parquet`` into one frame.
Each sub-bundle gets a ``seed`` column from its directory name (the HPC array-job layout, one
element per seed). A flat single-seed bundle is accepted too, tagged with its manifest seed.
Returns (frame, source config of the first seed).
"""
results_dir = Path(results_dir)
subs = sorted(results_dir.glob("s[0-9]*/results.parquet"))
if not subs:
df, cfg = load_bundle(results_dir)
if "seed" not in df.columns:
df = df.assign(seed=int(cfg.get("seed", 1)))
return df, cfg
frames, cfg0 = [], None
for p in subs:
df, cfg = load_bundle(p.parent)
cfg0 = cfg0 or cfg
frames.append(df.assign(seed=int(p.parent.name[1:])))
return pd.concat(frames, ignore_index=True), cfg0
def mean_ci(df: pd.DataFrame, by: str, value: str, ci: float = 0.95):
"""Return (index, mean, half-width) for a normal-approx CI of ``value`` grouped by ``by``."""
from scipy import stats
g = df.groupby(by)[value]
mean = g.mean()
sem = g.sem()
z = stats.norm.ppf(0.5 + ci / 2.0)
return mean.index.to_numpy(), mean.to_numpy(), (z * sem).to_numpy()
def savefig(fig, results_dir: str | Path, name: str) -> None:
"""Write a figure as both PNG (150 dpi) and PDF next to its results bundle."""
results_dir = Path(results_dir)
fig.savefig(results_dir / f"{name}.png", dpi=150, bbox_inches="tight")
fig.savefig(results_dir / f"{name}.pdf", bbox_inches="tight")
print(f"wrote {results_dir}/{name}.png and .pdf")
def letter_axes(fig, x: float = -0.1, y: float = 1.04, fontsize: float = 13) -> None:
"""Letter every data axes of ``fig`` A, B, C ... in reading order (top row first, left to right).
Twin axes and colourbars share a frame with a lettered axes and are skipped. Call once, after
every axes exists and before saving.
"""
seen: list[tuple[float, float]] = []
axes = []
for ax in fig.axes:
b = ax.get_position()
key = (round(b.x0, 3), round(b.y0, 3))
if key in seen or b.width < 0.05: # twin axes / colourbars
continue
seen.append(key); axes.append(ax)
axes.sort(key=lambda a: (-round(a.get_position().y0, 2), a.get_position().x0))
for i, ax in enumerate(axes):
ax.text(x, y, chr(ord("A") + i), transform=ax.transAxes, fontsize=fontsize, fontweight="bold",
va="bottom", ha="left", clip_on=False)