MachineSex/figures/plot_llm_compose.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

112 lines
5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Composition-decay figure (prereg v3 §8) — written before unblinding.
(A) Composition **surplus** S_t = composed best single parent, per arm over generations, with the
zero line: the vertical claim, and whether it survives inheritance.
(B) Own-skill retention q_t per lineage (math on GSM8K, code on MBPP), dry vs grounded — the
denominators of the prediction.
(C) rho_t, the behavioural correlation between the two lineages: the mechanism, if it rises.
(D) Observed composed accuracy against the framework's forecast Ĉ_t (one free scale, fixed at
generation 0) — H3, the paper's predictive claim, drawn as a line the data can miss.
Reads only committed bundles: one bundle directory, or a campaign directory of ``s*/`` bundles.
Usage: python figures/plot_llm_compose.py [results/llm_compose]
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from _figlib import mean_ci, savefig # noqa: E402
from llm.compose import predicted_composition # noqa: E402
ARMS = [("dry", "#d62728", "dry · blending operator"),
("grounded", "#2ca02c", "grounded (g = 0.10) · blending"),
("dry_cat", "#1f77b4", "dry · union operator (cat)")]
def load_any(results_dir: Path) -> pd.DataFrame:
if (results_dir / "results.parquet").exists():
paths = [results_dir]
else:
paths = sorted(p.parent for p in results_dir.glob("*/results.parquet"))
if not paths:
raise SystemExit(f"no results.parquet under {results_dir}")
return pd.concat([pd.read_parquet(p / "results.parquet") for p in paths], ignore_index=True)
def series(df: pd.DataFrame, arm: str, metric: str) -> pd.DataFrame:
return df[(df.arm == arm) & (df.metric == metric)][["seed", "generation", "value"]]
def main(results_dir: str = "results/llm_compose") -> None:
rd = Path(results_dir)
df = load_any(rd)
arms = [a for a in ARMS if a[0] in set(df.arm.unique())]
n_seeds = df.seed.nunique()
fig, ax = plt.subplots(1, 4, figsize=(21, 4.6))
# (A) surplus
for arm, color, label in arms:
s = series(df, arm, "surplus")
if s.empty:
continue
x, m, h = mean_ci(s, "generation", "value")
ax[0].plot(x, m, "-o", color=color, label=label, lw=2, ms=4)
ax[0].fill_between(x, m - h, m + h, color=color, alpha=0.15, lw=0)
ax[0].axhline(0, color="k", lw=1, ls="--")
ax[0].set_title("(A) composition surplus\ncomposed best single parent", fontsize=10)
ax[0].set_xlabel("generation"); ax[0].set_ylabel("surplus"); ax[0].legend(fontsize=8)
# (B) own-skill retention
for arm, color, _ in arms:
for metric, ls in (("q_math", "-"), ("q_code", ":")):
s = series(df, arm, metric)
if s.empty:
continue
x, m, _h = mean_ci(s, "generation", "value")
ax[1].plot(x, m, ls, color=color, lw=2,
label=f"{arm} · {metric.split('_')[1]}" if arm != "dry_linear" else None)
ax[1].set_title("(B) own-skill retention q_t\nsolid math (GSM8K), dotted code (MBPP)", fontsize=10)
ax[1].set_xlabel("generation"); ax[1].set_ylabel("accuracy"); ax[1].legend(fontsize=8)
# (C) rho
for arm, color, label in arms:
s = series(df, arm, "rho_behav")
if s.empty:
continue
x, m, h = mean_ci(s, "generation", "value")
ax[2].plot(x, m, "-o", color=color, label=label, lw=2, ms=4)
ax[2].fill_between(x, m - h, m + h, color=color, alpha=0.15, lw=0)
ax[2].set_title("(C) lineage correlation ρ_t\n(agreement on a shared probe)", fontsize=10)
ax[2].set_xlabel("generation"); ax[2].set_ylabel("ρ"); ax[2].legend(fontsize=8)
# (D) observed vs predicted, dry arm
for arm, color, label in arms:
obs = series(df, arm, "composed_acc").groupby("generation").value.mean()
qm = series(df, arm, "q_math").groupby("generation").value.mean()
qc = series(df, arm, "q_code").groupby("generation").value.mean()
rho = series(df, arm, "rho_behav").groupby("generation").value.mean()
if obs.empty or len(obs) < 2:
continue
pred = predicted_composition(qm.to_numpy(), qc.to_numpy(), rho.to_numpy(), float(obs.iloc[0]))
ax[3].plot(obs.index, obs.to_numpy(), "-o", color=color, lw=2, ms=4, label=f"{label} observed")
ax[3].plot(obs.index, pred, "--", color=color, lw=1.5, alpha=0.8,
label=f"{label} predicted Ĉ")
ax[3].set_title("(D) H3: observed vs the closed form\nĈ = c₀·q_math·q_code·(1ρ)/(1ρ₀)", fontsize=10)
ax[3].set_xlabel("generation"); ax[3].set_ylabel("composed accuracy"); ax[3].legend(fontsize=7)
fig.suptitle(f"llm_compose — does a composed capability survive inheritance? "
f"({n_seeds} seed{'s' if n_seeds != 1 else ''}, mean ± 95% CI)", y=1.03)
fig.tight_layout()
savefig(fig, rd, "llm_compose")
if __name__ == "__main__":
main(*sys.argv[1:])