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
164 lines
7.9 KiB
Python
164 lines
7.9 KiB
Python
"""Pre-registered analysis for the v2 society (tasks/prereg-llm-society-v2.md §5, §8).
|
||
|
||
Prints, for each hypothesis, the per-seed quantities, the paired mean ± 95% CI over seeds, the sign
|
||
count, and PASS / FAIL against the pre-set threshold. Written before unblinding and exercised on the
|
||
smoke bundle; nothing here is chosen after seeing the campaign. Reads only committed bundles (one
|
||
bundle directory, or a campaign directory of ``s{seed}_{arm}/`` bundles).
|
||
|
||
H1 vertical climb full best(G) − B₀ ≥ 0.20 ; best newborn(G) − B₀ ≥ 0.15 ; ≥ 6 families ≥ 0.6
|
||
H3 self-consumption no_grounding best(G) ≤ B₀ + 0.05 ; gap(no_grounding) − gap(full) ≥ 0.30
|
||
H4 sex necessity no_sex best(G) ≤ B₀ + 0.05 in every seed
|
||
H5 diversity AUC(diversity) full > no_diversity ; no_diversity diversity < 0.1 by gen 6
|
||
H6 where skills die ≤ 20% of family losses in `full` were supplied at ≥ 0.6 by the child's source
|
||
(H2 is deferred: the sex_linear arm is not in the first campaign.)
|
||
|
||
Usage: python figures/stats_llm_society.py [results/llm_society_v2]
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
from plot_llm_society import load_any # noqa: E402
|
||
|
||
COMPETENT = 0.6
|
||
|
||
|
||
def ci95(x: np.ndarray) -> tuple[float, float]:
|
||
x = np.asarray(x, dtype=float)
|
||
if len(x) < 2:
|
||
return float(x.mean()), float("nan")
|
||
from scipy import stats
|
||
h = stats.t.ppf(0.975, len(x) - 1) * x.std(ddof=1) / np.sqrt(len(x))
|
||
return float(x.mean()), float(h)
|
||
|
||
|
||
def verdict(ok: bool | None) -> str:
|
||
return "n/a " if ok is None else ("PASS" if ok else "FAIL")
|
||
|
||
|
||
def main(results_dir: str = "results/llm_society_v2") -> None:
|
||
df, fams = load_any(Path(results_dir))
|
||
pop, summ, child = (df[df.role == r] for r in ("population", "summary", "child"))
|
||
src = df[df.role == "child_source"]
|
||
seeds = sorted(df.seed.unique())
|
||
G = int(pop.generation.max())
|
||
arms = set(df.arm.unique())
|
||
print(f"bundle: {results_dir} seeds {seeds} G = {G} L = {len(fams)} arms {sorted(arms)}\n")
|
||
|
||
best = pop[pop.metric == "test_overall"].groupby(["arm", "seed", "generation"]).value.max()
|
||
B0 = {s: float(best.xs(s, level="seed").xs(0, level="generation").mean()) for s in seeds}
|
||
print("B₀ (best founder, gen 0) per seed:", {s: round(v, 3) for s, v in B0.items()})
|
||
|
||
def at_G(arm, metric_frame, metric=None, gen=G, agg="max"):
|
||
out = {}
|
||
for s in seeds:
|
||
f = metric_frame[(metric_frame.arm == arm) & (metric_frame.seed == s) & (metric_frame.generation == gen)]
|
||
if metric is not None:
|
||
f = f[f.metric == metric]
|
||
if not f.empty:
|
||
out[s] = float(f.value.max() if agg == "max" else f.value.mean())
|
||
return out
|
||
|
||
def report(name, per_seed, thr, direction, note=""):
|
||
vals = np.array(list(per_seed.values()))
|
||
if len(vals) == 0:
|
||
print(f" {name:38s} {verdict(None)}"); return None
|
||
m, h = ci95(vals)
|
||
ok_each = (vals >= thr) if direction == ">=" else (vals <= thr)
|
||
ok = bool(ok_each.sum() >= max(3, len(vals)) if len(vals) >= 3 else ok_each.all())
|
||
print(f" {name:38s} {verdict(ok)} mean {m:+.3f} ± {h:.3f} per seed "
|
||
f"{np.round(vals, 3).tolist()} {int(ok_each.sum())}/{len(vals)} meet {direction} {thr} {note}")
|
||
return ok
|
||
|
||
# ---------------- H1
|
||
print("\nH1 — vertical climb (full arm)")
|
||
if "full" in arms:
|
||
gain = {s: at_G("full", pop, "test_overall")[s] - B0[s] for s in seeds if s in at_G("full", pop, "test_overall")}
|
||
nb = at_G("full", summ, "best_newborn_overall", gen=G - 1)
|
||
gain_nb = {s: nb[s] - B0[s] for s in nb}
|
||
# families the best agent is competent on, at G
|
||
comp = {}
|
||
for s in seeds:
|
||
f = pop[(pop.arm == "full") & (pop.seed == s) & (pop.generation == G)]
|
||
if f.empty:
|
||
continue
|
||
ov = f[f.metric == "test_overall"].set_index("agent").value
|
||
a = int(ov.idxmax())
|
||
per = f[(f.agent == a) & f.metric.isin([f"test_{x}" for x in fams])].value
|
||
comp[s] = float((per >= COMPETENT).sum())
|
||
report("best agent − B₀ (≥ 0.20)", gain, 0.20, ">=")
|
||
report("best newborn − B₀ (≥ 0.15)", gain_nb, 0.15, ">=")
|
||
report("families competent in best agent (≥ 6)", comp, 6, ">=")
|
||
else:
|
||
print(" full arm absent")
|
||
|
||
# ---------------- H3
|
||
print("\nH3 — self-consumption (no_grounding)")
|
||
if {"no_grounding", "full"} <= arms:
|
||
ng = at_G("no_grounding", pop, "test_overall")
|
||
report("no_grounding best − B₀ (≤ 0.05)", {s: ng[s] - B0[s] for s in ng}, 0.05, "<=")
|
||
gap_ng = at_G("no_grounding", summ, "gap_conformity_minus_truth", agg="mean")
|
||
gap_f = at_G("full", summ, "gap_conformity_minus_truth", agg="mean")
|
||
report("gap(no_grounding) − gap(full) (≥ 0.30)", {s: gap_ng[s] - gap_f[s] for s in gap_ng if s in gap_f}, 0.30, ">=")
|
||
ca = summ[(summ.arm == "no_grounding") & (summ.metric == "consensus_acc")]
|
||
slope = {s: float(np.polyfit(g.generation, g.value, 1)[0]) for s, g in ca.groupby("seed") if len(g) > 1}
|
||
report("consensus-accuracy slope, no_grounding (≤ 0)", slope, 0.0, "<=", note="(non-increasing)")
|
||
else:
|
||
print(" arms absent")
|
||
|
||
# ---------------- H4
|
||
print("\nH4 — sex necessity (no_sex ceiling)")
|
||
if "no_sex" in arms:
|
||
ns = at_G("no_sex", pop, "test_overall")
|
||
vals = {s: ns[s] - B0[s] for s in ns}
|
||
ok = all(v <= 0.05 for v in vals.values()) if vals else None
|
||
print(f" {'no_sex best − B₀ (≤ 0.05 in EVERY seed)':38s} {verdict(ok)} per seed {np.round(list(vals.values()), 3).tolist()}")
|
||
else:
|
||
print(" no_sex arm absent")
|
||
|
||
# ---------------- H5
|
||
print("\nH5 — diversity (full vs no_diversity)")
|
||
if {"full", "no_diversity"} <= arms:
|
||
div = summ[summ.metric == "diversity_behav"]
|
||
auc = lambda arm, s: float(np.trapezoid(div[(div.arm == arm) & (div.seed == s)].sort_values("generation").value))
|
||
d_auc = {s: auc("full", s) - auc("no_diversity", s) for s in seeds
|
||
if not div[(div.arm == "full") & (div.seed == s)].empty and not div[(div.arm == "no_diversity") & (div.seed == s)].empty}
|
||
report("AUC(diversity) full − no_diversity (> 0)", d_auc, 1e-9, ">=")
|
||
g6 = min(6, G)
|
||
nd6 = at_G("no_diversity", summ, "diversity_behav", gen=g6, agg="mean")
|
||
report(f"no_diversity diversity at gen {g6} (< 0.1)", nd6, 0.1, "<=")
|
||
else:
|
||
print(" arms absent")
|
||
|
||
# ---------------- H6
|
||
print("\nH6 — where skills die (full arm)")
|
||
if "full" in arms and not src.empty:
|
||
losses, supplied_ok = 0, 0
|
||
for s in seeds:
|
||
fpop = pop[(pop.arm == "full") & (pop.seed == s)]
|
||
fsrc = src[(src.arm == "full") & (src.seed == s)]
|
||
for t in range(G):
|
||
alive_t = {f for f in fams if (fpop[(fpop.generation == t) & (fpop.metric == f"test_{f}")].value >= COMPETENT).any()}
|
||
alive_t1 = {f for f in fams if (fpop[(fpop.generation == t + 1) & (fpop.metric == f"test_{f}")].value >= COMPETENT).any()}
|
||
for f in alive_t - alive_t1:
|
||
losses += 1
|
||
sup = fsrc[(fsrc.generation == t) & (fsrc.metric == f"source_{f}")].value
|
||
supplied_ok += int((sup >= COMPETENT).any())
|
||
frac = supplied_ok / losses if losses else float("nan")
|
||
ok = None if not losses else frac <= 0.20
|
||
print(f" {'family losses supplied at ≥0.6 (≤ 20%)':38s} {verdict(ok)} {supplied_ok}/{losses} losses "
|
||
f"({frac:.0%} if any) — skills should die because they arrived diluted, not despite competent supply")
|
||
else:
|
||
print(" no source diagnostics")
|
||
|
||
print("\nH2 (union vs linear blend) — deferred: sex_linear not in the first campaign (prereg §12).")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main(*sys.argv[1:])
|