main: keep only what reproduces the manuscript; everything else lives on dev
Removed from main (all preserved on the dev branch): the arXiv build and
its sources, design documents (blueprint, results summary, review responses,
essay drafts), tasks/ and CLAUDE.md, the cover letter and reference tooling,
two unused manuscript figures, and every experiment that feeds no figure or
number in the paper: the collapse null, the sexual-vs-asexual lineage, the
NK speciation variant, the 0.5B single-seed LLM prototypes, the compose and
society experiments with their calibration and pilot runs, and their
configs, runners, tests, figure scripts and PBS jobs. Their result bundles
are moved to results/_archive/ (ignored) so the parquets stay on disk.
Also: plot_llm_speciation reads the s{seed}/ layout; the mating-breadth
plot writes under its bundle name; Makefile targets reduced to the kept
experiments; REPRODUCING.md and README point to dev for the rest.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y64o8FKP7rCuXzC48pxpMm
This commit is contained in:
parent
ab3dc10587
commit
6f8cef1ac5
292 changed files with 26 additions and 15590 deletions
|
|
@ -130,45 +130,6 @@ def run_experiment(cfg: dict) -> pd.DataFrame:
|
|||
_GENOTYPE_KEYS = ("genotype", "generations")
|
||||
|
||||
|
||||
def run_genotype_experiment(cfg: dict) -> pd.DataFrame:
|
||||
"""Run a genotype lineage across a sweep x replicates (E7, advantage of sex).
|
||||
|
||||
Mirrors ``run_experiment`` (paired replicate seeds) but assembles the base from the
|
||||
``genotype``/``generations`` blocks and calls ``run_genotype_lineage``. Sweeps use the same
|
||||
dotted-path ``_apply_param`` (e.g. ``genotype.recomb_rate`` for asexual vs sexual).
|
||||
"""
|
||||
from .genotype_lineage import run_genotype_lineage
|
||||
|
||||
base = {k: copy.deepcopy(cfg[k]) for k in _GENOTYPE_KEYS if k in cfg}
|
||||
sweeps = cfg.get("sweep", [])
|
||||
if isinstance(sweeps, dict):
|
||||
sweeps = [sweeps]
|
||||
params = [s["param"] for s in sweeps]
|
||||
value_lists = [list(s["values"]) for s in sweeps]
|
||||
combos = [({}, base)] if not sweeps else []
|
||||
for values in itertools.product(*value_lists):
|
||||
lin = copy.deepcopy(base)
|
||||
label: dict = {}
|
||||
for param, val in zip(params, values):
|
||||
label.update(_apply_param(lin, param, val))
|
||||
combos.append((label, lin))
|
||||
|
||||
seeds = spawn_seeds(int(cfg["seed"]), int(cfg["n_replicates"]))
|
||||
frames: list[pd.DataFrame] = []
|
||||
for label, lin in combos:
|
||||
for rep, ss in enumerate(seeds):
|
||||
df = run_genotype_lineage(lin, int(ss.generate_state(1)[0]))
|
||||
for col, val in label.items():
|
||||
df[col] = val
|
||||
df["replicate"] = rep
|
||||
frames.append(df)
|
||||
out = pd.concat(frames, ignore_index=True)
|
||||
out.insert(0, "experiment", cfg["experiment"])
|
||||
return out
|
||||
|
||||
|
||||
_DYNAMIC_KEYS = ("society", "generations")
|
||||
|
||||
|
||||
def run_dynamic_experiment(cfg: dict) -> pd.DataFrame:
|
||||
"""Run the dynamic society across an ``arm`` ablation sweep x replicates (E11).
|
||||
|
|
@ -401,8 +362,6 @@ def run_and_save(config_path: str | Path) -> Path:
|
|||
kind = cfg.get("kind", "lineage")
|
||||
if kind == "coverage":
|
||||
df = run_coverage(cfg)
|
||||
elif kind == "genotype_lineage":
|
||||
df = run_genotype_experiment(cfg) # E7: advantage of sex
|
||||
elif kind == "society":
|
||||
from .society import run_society # E8: multi-parent recombination
|
||||
df = run_society(cfg)
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
"""Single-population genotype evolution — the advantage of sex (E7).
|
||||
|
||||
A population (distribution over the ``2^L`` genotypes) adapts toward a multi-locus optimum under
|
||||
the composed generational step: **selection** (fitness-proportional, favouring correct alleles) +
|
||||
**drift** (finite resample of ``n``) + **mutation** (per-locus flips) + **recombination** (asexual
|
||||
``rate=0`` vs sexual ``rate>0``). Recombination reassorts beneficial alleles that arise in different
|
||||
sub-lineages into one genotype; without it (asexual) those alleles suffer *clonal interference* and
|
||||
adaptation is slower. So a sexual lineage climbs toward the optimum faster than an asexual one — the
|
||||
classical advantage of sex, and the dynamic counterpart of E8's one-shot multi-parent assembly.
|
||||
|
||||
Reuses ``step.apply_selection`` (fitness = number of correct loci) and the ``genotype`` operators;
|
||||
emits the same tidy per-generation DataFrame contract as ``run_lineage`` (with genotype-aware
|
||||
columns from ``genotype.locus_metrics``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .genotype import additive_fitness, locus_metrics, mutate, recombine
|
||||
from .step import apply_selection
|
||||
|
||||
|
||||
def run_genotype_lineage(cfg: Mapping[str, Any], seed: int) -> pd.DataFrame:
|
||||
"""Run one genotype lineage and return per-generation genotype metrics.
|
||||
|
||||
Args:
|
||||
cfg (Mapping): Config with a ``genotype`` block (``L``; drift ``n``; mutation ``mu``;
|
||||
selection ``base`` for multiplicative fitness ``base^#correct``; recombination
|
||||
``recomb_rate``; ``init`` in {``wrong``, ``uniform``, ``optimum``}) and
|
||||
``generations``.
|
||||
seed (int): Replicate seed; the run is a pure function of (cfg, seed).
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: One row per generation 0..T with ``generation`` plus the
|
||||
``genotype.locus_metrics`` columns (``mean_fitness``, ``best_fitness``, ``opt_freq``,
|
||||
``min_load``, ``mean_locus_correct``, ``locus_H``, ``ld``).
|
||||
"""
|
||||
g = cfg["genotype"]
|
||||
L = int(g["L"])
|
||||
n = int(g["n"])
|
||||
mu = float(g.get("mu", 0.0))
|
||||
base = float(g.get("base", 1.0))
|
||||
rate = float(g.get("recomb_rate", 0.0))
|
||||
generations = int(cfg.get("generations", 100))
|
||||
K = 1 << L
|
||||
|
||||
report_fitness = additive_fitness(L) # # correct loci (0..L), for metrics
|
||||
sel_fitness = base ** report_fitness # multiplicative selection weight
|
||||
|
||||
init = g.get("init", "wrong")
|
||||
p = np.zeros(K)
|
||||
if init == "wrong":
|
||||
p[0] = 1.0 # all-wrong genotype (load L); adapt upward
|
||||
elif init == "optimum":
|
||||
p[-1] = 1.0 # all-correct (for degradation studies)
|
||||
elif init == "uniform":
|
||||
p[:] = 1.0 / K
|
||||
else:
|
||||
raise ValueError(f"unknown genotype init {init!r} (expected wrong|optimum|uniform)")
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
rows: list[dict] = []
|
||||
|
||||
def record(t: int) -> None:
|
||||
row = {"generation": t}
|
||||
row.update(locus_metrics(p, L, report_fitness, alive_eps=1.0 / n))
|
||||
rows.append(row)
|
||||
|
||||
record(0)
|
||||
for t in range(1, generations + 1):
|
||||
p = apply_selection(p, sel_fitness, "greedy", 0.0) # fitness-proportional selection
|
||||
counts = rng.multinomial(n, p) # drift
|
||||
p = counts / counts.sum()
|
||||
p = mutate(p, L, mu) # per-locus mutation
|
||||
p = recombine(p, L, rate) # asexual (0) vs sexual (>0)
|
||||
record(t)
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
|
@ -1,348 +0,0 @@
|
|||
"""Calibration gates for the v2 society (``kind: llm_society_calib``; prereg §4).
|
||||
|
||||
Four stages, each a config ``stage:``, each printing its gate table and writing the usual artifact
|
||||
triple. Nothing in the campaign is chosen by feel: the family set, the inheritance pool size, and
|
||||
the recombination operator are all fixed here, by measurement, before any campaign job is submitted.
|
||||
|
||||
* ``families`` (C1a–c, C4): base and specialist accuracy per candidate family; specialist confidence
|
||||
AUC (own- vs off-family prompts — the routing precondition); pairwise confidence-weighted functional
|
||||
conflict between specialists (the ``llm_epistasis`` measure; the grid's no-conflict axis sits at
|
||||
≈0.20–0.26 and its conflict axis at ≈0.46–0.52, so the gate is < 0.35); gen-0 behavioural distance.
|
||||
* ``transmission`` (C2): retention of a founder's own-family skill in a child distilled from the
|
||||
founder's *own answers*, as a function of examples-per-family ``k`` and epochs. Sets ``k_inherit``.
|
||||
* ``cross`` (C3): one two-founder cross, union-distil vs best-of-6 linear-merge-distil; both
|
||||
families' accuracy in each child. Tests the operator choice for twenty minutes, not eighty hours.
|
||||
* ``consensus`` (C5): consensus accuracy over the founders at gen 0 (must be low, else conformity
|
||||
is a truth proxy and the ``no_grounding`` arm cannot fail by the predicted mechanism).
|
||||
|
||||
Specialists are cached under ``models/llm/society_v2/calib_s{seed}/`` and shared across stages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from . import families as _families # noqa: F401
|
||||
from .society_ops import behavioural_distance, consensus_answers, conformity_scores, fitness_of, route_union
|
||||
from .tasks import Task, make_tasks, verify, _normalise
|
||||
|
||||
|
||||
def _auc(pos: np.ndarray, neg: np.ndarray) -> float:
|
||||
"""Rank AUC: P(confidence on own-family prompt > confidence on off-family prompt)."""
|
||||
if len(pos) == 0 or len(neg) == 0:
|
||||
return float("nan")
|
||||
return float(np.mean([(p > n) + 0.5 * (p == n) for p in pos for n in neg]))
|
||||
|
||||
|
||||
def _specialists(cfg, fams, root, base):
|
||||
from .specialise import train_specialist
|
||||
dirs = []
|
||||
n_tr, n_ep = int(cfg.get("spec_train", 600)), int(cfg.get("spec_epochs", 3))
|
||||
r_ = int(cfg.get("lora", {}).get("r", 16))
|
||||
for i, f in enumerate(fams):
|
||||
d = root / (f"spec_{f}_n{n_tr}e{n_ep}" + ("" if r_ == 16 else f"_r{r_}")) # keyed by budget + rank
|
||||
if not (d / "adapter_config.json").exists():
|
||||
train_specialist(base, f, str(d), n_train=int(cfg.get("spec_train", 600)),
|
||||
epochs=int(cfg.get("spec_epochs", 3)), seed=int(cfg["seed"]) * 100 + i,
|
||||
r=int(cfg.get("lora", {}).get("r", 16)),
|
||||
alpha=int(cfg.get("lora", {}).get("alpha", 32)))
|
||||
dirs.append(str(d))
|
||||
return dirs
|
||||
|
||||
|
||||
def _stage_families(cfg, base, fams, root) -> list[dict]:
|
||||
import torch
|
||||
from .epistasis import generate_with_confidence
|
||||
from .evaluate import generate, load_model
|
||||
from .merge import load_specialists
|
||||
|
||||
n_test, n_probe = int(cfg.get("n_test", 100)), int(cfg.get("n_probe", 10))
|
||||
spec_hi = float(cfg.get("spec_hi", 0.90)) # prereg §4 amendment 1: 1.0 in stage A2
|
||||
tests = {f: make_tasks(f, n_test, seed=1000 + i) for i, f in enumerate(fams)}
|
||||
probe = sum([make_tasks(f, n_probe, seed=5000 + i) for i, f in enumerate(fams)], [])
|
||||
p_prompts = [x.prompt for x in probe]
|
||||
p_fam = np.array([x.family for x in probe])
|
||||
rows = []
|
||||
|
||||
model, tok = load_model(base) # base alone
|
||||
base_acc = {f: fitness_of(generate(model, tok, [x.prompt for x in tests[f]]), tests[f], [f])[f]
|
||||
for f in fams}
|
||||
del model; torch.cuda.empty_cache()
|
||||
|
||||
dirs = _specialists(cfg, fams, root, base)
|
||||
model, tok = load_specialists(base, dirs)
|
||||
spec_acc, ans, conf = {}, {}, {}
|
||||
for i, f in enumerate(fams):
|
||||
model.set_adapter(f"a{i}")
|
||||
spec_acc[f] = fitness_of(generate(model, tok, [x.prompt for x in tests[f]]), tests[f], [f])[f]
|
||||
ans[f], conf[f] = generate_with_confidence(model, tok, p_prompts)
|
||||
del model; torch.cuda.empty_cache()
|
||||
|
||||
for f in fams:
|
||||
uniq = len({t.prompt for t in make_tasks(f, 600, seed=7)})
|
||||
auc = _auc(conf[f][p_fam == f], conf[f][p_fam != f])
|
||||
in_band = 0.05 <= base_acc[f] <= 0.40 and 0.60 <= spec_acc[f] <= spec_hi
|
||||
for m, v in (("base_acc", base_acc[f]), ("spec_acc", spec_acc[f]), ("conf_auc", auc),
|
||||
("unique_of_600", uniq), ("in_band", float(in_band))):
|
||||
rows.append({"stage": "families", "family": f, "other": "", "metric": m, "value": float(v)})
|
||||
norm = {f: [_normalise(a) for a in ans[f]] for f in fams}
|
||||
dist = behavioural_distance([ans[f] for f in fams])
|
||||
for i, fa in enumerate(fams):
|
||||
for j, fb in enumerate(fams):
|
||||
if j <= i:
|
||||
continue
|
||||
dis = np.array([a != b for a, b in zip(norm[fa], norm[fb])], dtype=float)
|
||||
epi = float(np.mean(conf[fa] * conf[fb] * dis))
|
||||
rows.append({"stage": "families", "family": fa, "other": fb, "metric": "epi_conf", "value": epi})
|
||||
rows.append({"stage": "families", "family": fa, "other": fb, "metric": "dist", "value": float(dist[i, j])})
|
||||
|
||||
print(f"\n== C1 family band (base ∈ [0.05,0.40], specialist ∈ [0.60,{spec_hi:.2f}]) ==")
|
||||
for f in fams:
|
||||
flag = "OK " if 0.05 <= base_acc[f] <= 0.40 and 0.60 <= spec_acc[f] <= spec_hi else "-- "
|
||||
print(f" {flag}{f:12s} base {base_acc[f]:.2f} spec {spec_acc[f]:.2f} "
|
||||
f"confAUC {_auc(conf[f][p_fam == f], conf[f][p_fam != f]):.2f} "
|
||||
f"unique/600 {len({t.prompt for t in make_tasks(f, 600, seed=7)})}")
|
||||
epis = [r["value"] for r in rows if r["metric"] == "epi_conf"]
|
||||
dists = [r["value"] for r in rows if r["metric"] == "dist"]
|
||||
print(f"== C1b pairwise epi_conf: max {max(epis):.3f} (gate < 0.35) "
|
||||
f"C1c min pairwise distance {min(dists):.2f} (gate ≥ 0.5)")
|
||||
return rows
|
||||
|
||||
|
||||
def _stage_transmission(cfg, base, fams, root) -> list[dict]:
|
||||
import torch
|
||||
from .evaluate import generate
|
||||
from .merge import load_specialists
|
||||
from .specialise import train_lora_on_tasks
|
||||
|
||||
probe_f = list(cfg.get("probe_families", fams[:3]))
|
||||
ks, eps = list(cfg.get("ks", [25, 50, 100, 150])), list(cfg.get("epochs_grid", [2, 3]))
|
||||
n_test = int(cfg.get("n_test", 100))
|
||||
dirs = _specialists(cfg, fams, root, base)
|
||||
rows = []
|
||||
for f in probe_f:
|
||||
i = fams.index(f)
|
||||
test = make_tasks(f, n_test, seed=1000 + i)
|
||||
model, tok = load_specialists(base, [dirs[i]])
|
||||
founder_acc = fitness_of(generate(model, tok, [x.prompt for x in test]), test, [f])[f]
|
||||
pools = {k: sum([make_tasks(g, k, seed=9000 + k * 31 + j) for j, g in enumerate(fams)], [])
|
||||
for k in ks}
|
||||
answers = {k: generate(model, tok, [x.prompt for x in pools[k]]) for k in ks}
|
||||
del model; torch.cuda.empty_cache()
|
||||
for k in ks:
|
||||
supplied = fitness_of(answers[k], pools[k], [f])[f]
|
||||
data = [Task(x.family, x.prompt, a.strip().split("\n")[0][:64]) for x, a in zip(pools[k], answers[k])
|
||||
if a.strip()]
|
||||
for ep in eps:
|
||||
d = root / "transmission" / f"{f}_k{k}_e{ep}"
|
||||
train_lora_on_tasks(base, data, str(d), epochs=ep, seed=int(cfg["seed"]) * 7 + k + ep)
|
||||
m, t2 = load_specialists(base, [str(d)])
|
||||
child_acc = fitness_of(generate(m, t2, [x.prompt for x in test]), test, [f])[f]
|
||||
del m; torch.cuda.empty_cache()
|
||||
ret = child_acc / founder_acc if founder_acc > 0 else float("nan")
|
||||
for name, v in (("founder_acc", founder_acc), ("supplied_acc", supplied),
|
||||
("child_acc", child_acc), ("retention", ret)):
|
||||
rows.append({"stage": "transmission", "family": f, "other": f"k{k}_e{ep}",
|
||||
"metric": name, "value": float(v), "k": k, "epochs": ep})
|
||||
print(f" {f:12s} k={k:3d} ep={ep}: founder {founder_acc:.2f} supplied {supplied:.2f} "
|
||||
f"child {child_acc:.2f} retention {ret:.2f}", flush=True)
|
||||
df = pd.DataFrame(rows)
|
||||
ret = df[df.metric == "retention"].groupby(["k", "epochs"]).value.mean().unstack()
|
||||
print("\n== C2 mean retention (gate: choose k* = min k with retention ≥ 0.85) ==")
|
||||
print(ret.round(2).to_string())
|
||||
return rows
|
||||
|
||||
|
||||
def _stage_transmission_conf(cfg, base, fams, root) -> list[dict]:
|
||||
"""C2b — confidence-gated inheritance: retention when the child learns only the prompts its source
|
||||
is confident on (verifier-free). Also reports the τ that separates own- from off-family confidence
|
||||
(Youden's J on the founder's own confidences, using family labels for calibration only) and the
|
||||
off-family harm of ungated inheritance (child off-family accuracy vs base)."""
|
||||
import torch
|
||||
from .epistasis import generate_with_confidence
|
||||
from .evaluate import generate, load_model
|
||||
from .merge import load_specialists
|
||||
from .specialise import train_lora_on_tasks
|
||||
|
||||
probe_f = list(cfg.get("probe_families", fams[:3]))
|
||||
k, ep = int(cfg.get("k_pool", 300)), int(cfg.get("epochs", 3))
|
||||
taus = list(cfg.get("taus", [0.5, 0.7, 0.85]))
|
||||
n_test = int(cfg.get("n_test", 100))
|
||||
dirs = _specialists(cfg, fams, root, base)
|
||||
tests = {f: make_tasks(f, n_test, seed=1000 + i) for i, f in enumerate(fams)}
|
||||
pool = sum([make_tasks(g, k, seed=9300 + j) for j, g in enumerate(fams)], [])
|
||||
p_fam = np.array([x.family for x in pool])
|
||||
|
||||
model, tok = load_model(base)
|
||||
base_acc = {f: fitness_of(generate(model, tok, [x.prompt for x in tests[f]]), tests[f], [f])[f] for f in fams}
|
||||
del model; torch.cuda.empty_cache()
|
||||
|
||||
rows = []
|
||||
for f in probe_f:
|
||||
i = fams.index(f)
|
||||
model, tok = load_specialists(base, [dirs[i]])
|
||||
founder_acc = fitness_of(generate(model, tok, [x.prompt for x in tests[f]]), tests[f], [f])[f]
|
||||
ans, conf = generate_with_confidence(model, tok, [x.prompt for x in pool])
|
||||
del model; torch.cuda.empty_cache()
|
||||
own, off = conf[p_fam == f], conf[p_fam != f]
|
||||
# Youden-optimal τ over a grid — calibration only (uses family labels)
|
||||
grid = np.linspace(0.05, 0.99, 95)
|
||||
J = [np.mean(own >= t) - np.mean(off >= t) for t in grid]
|
||||
tau_star = float(grid[int(np.argmax(J))])
|
||||
rows.append({"stage": "transmission_conf", "family": f, "other": "", "metric": "tau_youden", "value": tau_star})
|
||||
rows.append({"stage": "transmission_conf", "family": f, "other": "", "metric": "own_conf_median", "value": float(np.median(own))})
|
||||
rows.append({"stage": "transmission_conf", "family": f, "other": "", "metric": "off_conf_median", "value": float(np.median(off))})
|
||||
print(f" {f:12s} founder {founder_acc:.2f} own-conf median {np.median(own):.2f} off-conf median "
|
||||
f"{np.median(off):.2f} τ* {tau_star:.2f} (keeps {np.mean(own >= tau_star):.0%} own, "
|
||||
f"{np.mean(off >= tau_star):.0%} off)", flush=True)
|
||||
for tau in [None] + taus:
|
||||
keep = np.ones(len(pool), bool) if tau is None else conf >= tau
|
||||
data = [Task(x.family, x.prompt, a.strip().split("\n")[0][:64])
|
||||
for x, a, kp in zip(pool, ans, keep) if kp and a.strip()]
|
||||
if len(data) < 8:
|
||||
print(f" {f:12s} τ={tau}: only {len(data)} prompts kept — skipped"); continue
|
||||
d = root / "transmission_conf" / f"{f}_tau{tau}"
|
||||
train_lora_on_tasks(base, data, str(d), epochs=ep, seed=int(cfg["seed"]) * 13 + int((tau or 0) * 100))
|
||||
m, t2 = load_specialists(base, [str(d)])
|
||||
child_own = fitness_of(generate(m, t2, [x.prompt for x in tests[f]]), tests[f], [f])[f]
|
||||
off_f = [g for g in fams if g != f][:3] # off-family harm on three other families
|
||||
child_off = float(np.mean([fitness_of(generate(m, t2, [x.prompt for x in tests[g]]), tests[g], [g])[g] for g in off_f]))
|
||||
base_off = float(np.mean([base_acc[g] for g in off_f]))
|
||||
del m; torch.cuda.empty_cache()
|
||||
ret = child_own / founder_acc if founder_acc > 0 else float("nan")
|
||||
lab = "none" if tau is None else f"{tau:.2f}"
|
||||
for name, v in (("retention", ret), ("child_own_acc", child_own), ("n_kept", len(data)),
|
||||
("child_off_acc", child_off), ("base_off_acc", base_off)):
|
||||
rows.append({"stage": "transmission_conf", "family": f, "other": f"tau{lab}", "metric": name, "value": float(v)})
|
||||
print(f" {f:12s} τ={lab}: kept {len(data):4d}/{len(pool)} child own {child_own:.2f} "
|
||||
f"retention {ret:.2f} off-family child {child_off:.2f} vs base {base_off:.2f}", flush=True)
|
||||
df = pd.DataFrame(rows)
|
||||
r = df[df.metric == "retention"].groupby("other").value.mean()
|
||||
print("\n== C2b mean retention by gate (target ≥ 0.85) ==\n" + r.round(2).to_string())
|
||||
return rows
|
||||
|
||||
|
||||
def _stage_cross(cfg, base, fams, root) -> list[dict]:
|
||||
import torch
|
||||
from .directed import sample_merge_weights
|
||||
from .epistasis import generate_with_confidence
|
||||
from .evaluate import generate
|
||||
from .merge import load_specialists
|
||||
from .specialise import train_lora_on_tasks
|
||||
|
||||
fa, fb = cfg["cross"]
|
||||
k, ep = int(cfg.get("k_inherit", 100)), int(cfg.get("epochs", 3))
|
||||
n_cand = int(cfg.get("n_candidates", 6))
|
||||
ia, ib = fams.index(fa), fams.index(fb)
|
||||
tests = {f: make_tasks(f, int(cfg.get("n_test", 100)), seed=1000 + fams.index(f)) for f in (fa, fb)}
|
||||
val = sum([make_tasks(g, 10, seed=3000 + j) for j, g in enumerate(fams)], [])
|
||||
inherit = sum([make_tasks(g, k, seed=9100 + j) for j, g in enumerate(fams)], [])
|
||||
inh_p = [x.prompt for x in inherit]
|
||||
dirs = _specialists(cfg, fams, root, base)
|
||||
rows = []
|
||||
|
||||
model, tok = load_specialists(base, [dirs[ia], dirs[ib]])
|
||||
parent_acc = {}
|
||||
for j, f in enumerate((fa, fb)):
|
||||
model.set_adapter(f"a{j}")
|
||||
for g in (fa, fb):
|
||||
parent_acc[(f, g)] = fitness_of(generate(model, tok, [x.prompt for x in tests[g]]), tests[g], [g])[g]
|
||||
model.set_adapter("a0"); ans_a, cf_a = generate_with_confidence(model, tok, inh_p)
|
||||
model.set_adapter("a1"); ans_b, cf_b = generate_with_confidence(model, tok, inh_p)
|
||||
union_ans, src = route_union(ans_a, cf_a, ans_b, cf_b)
|
||||
|
||||
rng = np.random.default_rng(int(cfg["seed"]))
|
||||
w = sample_merge_weights(2, n_cand, rng)
|
||||
best_i, best_v = 0, -np.inf
|
||||
for ci in range(n_cand):
|
||||
model.add_weighted_adapter(["a0", "a1"], w[ci].tolist(), f"c{ci}", combination_type="linear")
|
||||
model.set_adapter(f"c{ci}")
|
||||
v = fitness_of(generate(model, tok, [x.prompt for x in val]), val, fams)["overall"]
|
||||
if v > best_v:
|
||||
best_i, best_v = ci, v
|
||||
model.set_adapter("a0"); model.delete_adapter(f"c{ci}")
|
||||
model.add_weighted_adapter(["a0", "a1"], w[best_i].tolist(), "win", combination_type="linear")
|
||||
model.set_adapter("win"); linear_ans, cf_lin = generate_with_confidence(model, tok, inh_p)
|
||||
del model; torch.cuda.empty_cache()
|
||||
|
||||
# optional confidence gate (C2b amendment): each child keeps only prompts its own source is
|
||||
# confident on — the union by max(parent confidences), the linear merge by its own confidence.
|
||||
gate = cfg.get("conf_gate")
|
||||
gate = None if gate is None else float(gate)
|
||||
keep_u = np.ones(len(inherit), bool) if gate is None else np.maximum(cf_a, cf_b) >= gate
|
||||
keep_l = np.ones(len(inherit), bool) if gate is None else cf_lin >= gate
|
||||
|
||||
out = {}
|
||||
for op, answers, keep in (("union", union_ans, keep_u), ("linear", linear_ans, keep_l)):
|
||||
supplied = fitness_of(answers, inherit, [fa, fb])
|
||||
data = [Task(x.family, x.prompt, a.strip().split("\n")[0][:64])
|
||||
for x, a, kp in zip(inherit, answers, keep) if kp and a.strip()]
|
||||
rows.append({"stage": "cross", "family": "", "other": op, "metric": "n_kept", "value": float(len(data))})
|
||||
r_, a_ = int(cfg.get("lora", {}).get("r", 16)), int(cfg.get("lora", {}).get("alpha", 32))
|
||||
d = root / "cross" / f"{fa}_{fb}_{op}_g{gate}_e{ep}_r{r_}"
|
||||
train_lora_on_tasks(base, data, str(d), epochs=ep, seed=int(cfg["seed"]) * 11, r=r_, alpha=a_)
|
||||
m, t2 = load_specialists(base, [str(d)])
|
||||
for g in (fa, fb):
|
||||
acc = fitness_of(generate(m, t2, [x.prompt for x in tests[g]]), tests[g], [g])[g]
|
||||
out[(op, g)] = acc
|
||||
rows.append({"stage": "cross", "family": g, "other": op, "metric": "child_acc", "value": float(acc)})
|
||||
rows.append({"stage": "cross", "family": g, "other": op, "metric": "supplied_acc", "value": float(supplied[g])})
|
||||
del m; torch.cuda.empty_cache()
|
||||
for f in (fa, fb):
|
||||
rows.append({"stage": "cross", "family": f, "other": "parent", "metric": "parent_own_acc",
|
||||
"value": float(parent_acc[(f, f)])})
|
||||
rows.append({"stage": "cross", "family": fa, "other": fb, "metric": "union_share_b", "value": float(src.mean())})
|
||||
|
||||
print(f"\n== C3 cross {fa} × {fb} (gate: union ≥ 0.85×parent on each; union ≥ linear on the min) ==")
|
||||
for g in (fa, fb):
|
||||
print(f" {g:12s} parent {parent_acc[(g, g)]:.2f} union child {out[('union', g)]:.2f} "
|
||||
f"linear child {out[('linear', g)]:.2f}")
|
||||
print(f" union routed {src.mean():.2f} of prompts to {fb}")
|
||||
return rows
|
||||
|
||||
|
||||
def _stage_consensus(cfg, base, fams, root) -> list[dict]:
|
||||
import torch
|
||||
from .evaluate import generate
|
||||
from .merge import load_specialists
|
||||
|
||||
probe = sum([make_tasks(f, int(cfg.get("n_probe", 10)), seed=5000 + i) for i, f in enumerate(fams)], [])
|
||||
dirs = _specialists(cfg, fams, root, base)
|
||||
model, tok = load_specialists(base, dirs)
|
||||
outs = []
|
||||
for i in range(len(fams)):
|
||||
model.set_adapter(f"a{i}"); outs.append(generate(model, tok, [x.prompt for x in probe]))
|
||||
del model; torch.cuda.empty_cache()
|
||||
cons = consensus_answers(outs)
|
||||
cons_acc = float(np.mean([verify(c, x) for c, x in zip(cons, probe)]))
|
||||
conf = conformity_scores(outs, cons)
|
||||
dist = behavioural_distance(outs)
|
||||
fit = [fitness_of(o, probe, fams)["overall"] for o in outs]
|
||||
rows = [{"stage": "consensus", "family": "", "other": "", "metric": "consensus_acc", "value": cons_acc},
|
||||
{"stage": "consensus", "family": "", "other": "", "metric": "min_pair_dist",
|
||||
"value": float(dist[np.triu_indices(len(fams), 1)].min())}]
|
||||
for i, f in enumerate(fams):
|
||||
rows.append({"stage": "consensus", "family": f, "other": "", "metric": "conformity", "value": float(conf[i])})
|
||||
rows.append({"stage": "consensus", "family": f, "other": "", "metric": "probe_acc", "value": float(fit[i])})
|
||||
print(f"\n== C5 consensus accuracy at gen 0: {cons_acc:.2f} (gate < 0.35) "
|
||||
f"corr(conformity, accuracy) = {np.corrcoef(conf, fit)[0, 1]:+.2f} "
|
||||
f"min pairwise distance {rows[1]['value']:.2f}")
|
||||
return rows
|
||||
|
||||
|
||||
def run_calibration(cfg: dict) -> pd.DataFrame:
|
||||
"""Dispatch on ``stage`` and return the stage's rows as a DataFrame."""
|
||||
base = cfg["base_model"]
|
||||
fams = list(cfg["families"])
|
||||
root = Path(cfg.get("adapters_dir", "models/llm")) / "society_v2" / f"calib_s{int(cfg['seed'])}"
|
||||
stage = cfg["stage"]
|
||||
fn = {"families": _stage_families, "transmission": _stage_transmission,
|
||||
"transmission_conf": _stage_transmission_conf,
|
||||
"cross": _stage_cross, "consensus": _stage_consensus}[stage]
|
||||
rows = fn(cfg, base, fams, root)
|
||||
df = pd.DataFrame(rows)
|
||||
df["experiment"] = cfg["experiment"]
|
||||
return df
|
||||
|
|
@ -1,348 +0,0 @@
|
|||
"""`llm_compose` — does a composed capability survive inheritance? (prereg v3)
|
||||
|
||||
Two single-skill LoRA lineages on a shared frozen base — **math** and **code**. Each generation both
|
||||
lineages reproduce by self-consumption (a fresh LoRA distilled from their own confidence-gated answers
|
||||
on fresh prompts, optionally mixed with a fraction ``g`` of verified real examples — E2's immigration
|
||||
in the training mix). Each generation the *current* two parents are merged and evaluated on the
|
||||
held-out composed target, GSM8k-Hard, program-aided with an execution verifier.
|
||||
|
||||
The composed model is a **measurement, not a lineage**: it is re-formed each generation from whatever
|
||||
the parents currently are, which separates "does composition survive parental drift?" from "does the
|
||||
composed model itself drift?". The ``composed`` arm makes it a lineage as well.
|
||||
|
||||
Measured every generation (§1.6): ``q_math`` (GSM8K), ``q_code`` (MBPP, execution-verified),
|
||||
``rho`` (behavioural agreement on a shared probe + LoRA-delta cosine), composed accuracy for the
|
||||
merge *and each parent alone*, hence the **surplus** (merge − best parent) and the
|
||||
**union-exceedance** (composed-solved items neither parent solves — the super-linear signature).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .compose_data import (ProgTask, code_train, composed_target, gsm8k_probe, gsm_hard,
|
||||
math_train, mbpp_probe, probe_pool)
|
||||
from .execute import extract_code, numeric_match, run_solution, verify_program
|
||||
from .tasks import Task, _normalise
|
||||
|
||||
LINEAGES = ("math", "code")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ scoring helpers (pure-ish)
|
||||
|
||||
|
||||
def score_composed(completions: list[str], tasks: list[ProgTask]) -> tuple[float, np.ndarray, float]:
|
||||
"""Program-aided accuracy over the composed target.
|
||||
|
||||
Returns:
|
||||
(accuracy, per-item correctness, executable-rate) — the last is the fraction whose code ran
|
||||
at all, which is the code sub-skill isolated from the maths.
|
||||
"""
|
||||
ok, ran = [], []
|
||||
for c, t in zip(completions, tasks):
|
||||
good, res = verify_program(c, t.target)
|
||||
ok.append(bool(good)); ran.append(bool(res.ok))
|
||||
return float(np.mean(ok)), np.array(ok, dtype=bool), float(np.mean(ran))
|
||||
|
||||
|
||||
_NUM = __import__("re").compile(r"-?\d[\d,]*\.?\d*")
|
||||
|
||||
|
||||
def _last_number(text: str) -> float | None:
|
||||
"""The last number in a completion, commas stripped — the conventional GSM8K readout."""
|
||||
m = _NUM.findall(text)
|
||||
for tok in reversed(m):
|
||||
try:
|
||||
return float(tok.replace(",", "").rstrip("."))
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def score_gsm8k(completions: list[str], tasks: list[Task]) -> float:
|
||||
"""Math own-skill: the number after '####' if the model used it, else the last number.
|
||||
|
||||
Graded numerically rather than by string equality, so "18" and "18.00" both count and a model
|
||||
that reasons past its answer is not punished for formatting.
|
||||
"""
|
||||
hit = []
|
||||
for c, t in zip(completions, tasks):
|
||||
tail = c.split("####")[-1] if "####" in c else c
|
||||
got, want = _last_number(tail), _last_number(t.answer)
|
||||
hit.append(got is not None and want is not None and numeric_match(got, want))
|
||||
return float(np.mean(hit))
|
||||
|
||||
|
||||
def score_mbpp(completions: list[str], items: list[dict], timeout_s: float = 6.0) -> float:
|
||||
"""Code own-skill: the emitted function must pass MBPP's reference asserts."""
|
||||
hit = []
|
||||
for c, it in zip(completions, items):
|
||||
prog = "\n".join(list(it["imports"]) + [extract_code(c)] + list(it["tests"])
|
||||
+ ["def solution():\n return 1"])
|
||||
hit.append(run_solution(prog, timeout_s=timeout_s).ok)
|
||||
return float(np.mean(hit))
|
||||
|
||||
|
||||
def union_exceedance(merged_ok: np.ndarray, parent_ok: list[np.ndarray]) -> float:
|
||||
"""Fraction of all items that the merge solves and *no* parent solves (super-linear signature)."""
|
||||
any_parent = np.zeros_like(merged_ok)
|
||||
for p in parent_ok:
|
||||
any_parent |= p
|
||||
return float(np.mean(merged_ok & ~any_parent))
|
||||
|
||||
|
||||
def mean_logprob(model, tok, prompts: list[str], completions: list[str], *,
|
||||
batch_size: int = 8, device: str = "cuda") -> np.ndarray:
|
||||
"""Mean token log-probability of each completion under the model, teacher-forced.
|
||||
|
||||
``generate(..., output_scores=True)`` keeps one (batch × vocab) tensor per step, which at 320 new
|
||||
tokens is gigabytes; one extra forward pass over prompt+completion costs a fraction of that and
|
||||
gives the same quantity. Returns exp(mean logprob) ∈ (0, 1] — the same self-certainty scale the
|
||||
epistasis module uses.
|
||||
"""
|
||||
import torch
|
||||
|
||||
out = np.zeros(len(prompts))
|
||||
for i in range(0, len(prompts), batch_size):
|
||||
p_chunk, c_chunk = prompts[i:i + batch_size], completions[i:i + batch_size]
|
||||
ids, labels = [], []
|
||||
from .evaluate import format_prompt
|
||||
for p, c in zip(p_chunk, c_chunk):
|
||||
pi = tok(format_prompt(tok, p), add_special_tokens=False).input_ids
|
||||
ci = tok(c if c.strip() else " ", add_special_tokens=False).input_ids[:512]
|
||||
ids.append(pi + ci); labels.append([-100] * len(pi) + ci)
|
||||
L = max(len(x) for x in ids)
|
||||
pad = tok.pad_token_id
|
||||
inp = torch.full((len(ids), L), pad, dtype=torch.long)
|
||||
lab = torch.full((len(ids), L), -100, dtype=torch.long)
|
||||
att = torch.zeros((len(ids), L), dtype=torch.long)
|
||||
for b, (x, y) in enumerate(zip(ids, labels)): # right-pad (scoring, not generation)
|
||||
inp[b, :len(x)] = torch.tensor(x); lab[b, :len(y)] = torch.tensor(y)
|
||||
att[b, :len(x)] = 1
|
||||
with torch.no_grad():
|
||||
logits = model(input_ids=inp.to(device), attention_mask=att.to(device)).logits[:, :-1]
|
||||
tgt = lab[:, 1:].to(device)
|
||||
mask = tgt != -100
|
||||
# cross_entropy streams the log-partition internally: no float32 copy of the logits and
|
||||
# no second tensor for log_softmax, which together were ~8 GB at batch 8 x 800 x 152k.
|
||||
nll = torch.nn.functional.cross_entropy(
|
||||
logits.reshape(-1, logits.size(-1)).float(),
|
||||
tgt.reshape(-1).clamp(min=0), reduction="none").view(tgt.shape)
|
||||
n = mask.sum(-1).clamp(min=1)
|
||||
out[i:i + len(p_chunk)] = torch.exp(-(nll * mask).sum(-1) / n).cpu().numpy()
|
||||
del logits, nll
|
||||
return out
|
||||
|
||||
|
||||
def predicted_composition(q_math: np.ndarray, q_code: np.ndarray, rho: np.ndarray,
|
||||
observed0: float) -> np.ndarray:
|
||||
"""The framework's forecast Ĉ_t (prereg §2), one free scale fixed at generation 0.
|
||||
|
||||
Ĉ_t = c0 · q_math_t · q_code_t · (1 − rho_t)/(1 − rho_0), c0 chosen so Ĉ_0 = observed_0.
|
||||
"""
|
||||
q_math, q_code, rho = map(np.asarray, (q_math, q_code, rho))
|
||||
shape = q_math * q_code * (1.0 - rho) / max(1e-9, 1.0 - rho[0])
|
||||
c0 = observed0 / shape[0] if shape[0] > 1e-9 else 0.0
|
||||
return c0 * shape
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the GPU loop
|
||||
|
||||
|
||||
def _state_path(root: Path, arm: str) -> Path:
|
||||
return root / arm / "state.json"
|
||||
|
||||
|
||||
def run_compose(cfg: dict) -> pd.DataFrame:
|
||||
"""Run every configured arm; return tidy long-form rows (resumable per arm)."""
|
||||
import torch
|
||||
|
||||
from .epistasis import delta_geometry
|
||||
from .evaluate import generate
|
||||
from .merge import load_specialists
|
||||
from .specialise import train_lora_on_tasks
|
||||
|
||||
name, base, seed = cfg["experiment"], cfg["base_model"], int(cfg["seed"])
|
||||
G = int(cfg.get("generations", 6))
|
||||
arms = list(cfg.get("arms", ["dry", "grounded", "dry_linear"]))
|
||||
n_hard = int(cfg.get("n_hard", 200))
|
||||
n_hard_val = int(cfg.get("n_hard_val", 0)) # >0 enables directed merge-weight selection
|
||||
n_gsm = int(cfg.get("n_gsm8k", 150))
|
||||
n_mbpp = int(cfg.get("n_mbpp", 100))
|
||||
n_probe = int(cfg.get("n_probe", 60))
|
||||
k_inh = int(cfg.get("k_inherit", 300))
|
||||
epochs = int(cfg.get("epochs", 3))
|
||||
conf_gate = cfg.get("conf_gate")
|
||||
conf_gate = None if conf_gate is None else float(conf_gate)
|
||||
spec_train = int(cfg.get("spec_train", 1200))
|
||||
spec_epochs = int(cfg.get("spec_epochs", 3))
|
||||
max_new = int(cfg.get("max_new_tokens", 320))
|
||||
tb = int(cfg.get("train_batch_size", 2)) # the causal-LM loss upcasts logits to fp32:
|
||||
tlen = int(cfg.get("train_max_len", 448)) # memory ~ batch * len * vocab(152k) * 4 B
|
||||
r, alpha = (int(cfg.get("lora", {}).get(k, v)) for k, v in (("r", 16), ("alpha", 32)))
|
||||
root = Path(cfg.get("adapters_dir", "models/llm")) / "compose" / f"{name}_s{seed}"
|
||||
out_dir = Path(cfg.get("output", {}).get("dir", f"results/{name}"))
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# fixed evaluation sets (identical across arms, generations and seeds-within-a-config)
|
||||
target_kind = str(cfg.get("target", "gsm_hard"))
|
||||
hard = composed_target(target_kind, n_hard, 1000)
|
||||
hard_prompts = [t.prompt for t in hard]
|
||||
# Directed recombination (E10): candidate merge weights are screened on a DISJOINT validation
|
||||
# split and only the winner is reported on the test split, so nothing is selected on the numbers
|
||||
# we report. n_hard_val = 0 keeps the fixed 0.5/0.5 blend of the original design.
|
||||
hard_val = composed_target(target_kind, n_hard_val, 5000, split="val") if n_hard_val else []
|
||||
val_prompts = [t.prompt for t in hard_val]
|
||||
weight_grid = [list(w) for w in cfg.get("merge_weights", [[0.5, 0.5]])]
|
||||
gsm = gsm8k_probe(n_gsm, 2000)
|
||||
mbpp = mbpp_probe(n_mbpp, 3000)
|
||||
probes = probe_pool(n_probe, 4000)
|
||||
|
||||
def gen(model, tok, prompts, **kw):
|
||||
return generate(model, tok, prompts, max_new_tokens=max_new,
|
||||
batch_size=int(cfg.get("batch_size", 16)), **kw)
|
||||
|
||||
# ---- founders: one specialist per lineage, cached and shared across arms
|
||||
founders = {}
|
||||
for lin in LINEAGES:
|
||||
d = root / "founders" / lin
|
||||
if not (d / "adapter_config.json").exists():
|
||||
data = (math_train(spec_train, seed * 100 + 1, with_answers=True) if lin == "math"
|
||||
else code_train(spec_train, seed * 100 + 2, with_answers=True))
|
||||
train_lora_on_tasks(base, data, str(d), epochs=spec_epochs, seed=seed * 100 + 7,
|
||||
r=r, alpha=alpha, batch_size=tb, max_len=tlen)
|
||||
founders[lin] = str(d)
|
||||
|
||||
frames = []
|
||||
for a_idx, arm in enumerate(arms):
|
||||
# Operator per arm, explicit: the gen-0 sweep showed the blend ratio dominates the operator,
|
||||
# so which operator an arm uses is a stated choice, not an inference from its name.
|
||||
op = str(cfg.get("arm_ops", {}).get(arm, "linear" if arm.endswith("_linear") else "cat"))
|
||||
g_frac = float(cfg.get("g", 0.10)) if arm.startswith("grounded") else 0.0
|
||||
rows: list[dict] = []
|
||||
parents, t0 = dict(founders), 0
|
||||
st_path = _state_path(root, arm)
|
||||
partial = out_dir / f"partial_{arm}_s{seed}.parquet"
|
||||
if bool(cfg.get("resume", True)) and st_path.exists():
|
||||
st = json.loads(st_path.read_text())
|
||||
if all(Path(p, "adapter_config.json").exists() for p in st["parents"].values()):
|
||||
parents, t0 = dict(st["parents"]), int(st["generation"])
|
||||
if partial.exists():
|
||||
rows = pd.read_parquet(partial).to_dict("records")
|
||||
print(f"[{arm}] resuming at generation {t0}", flush=True)
|
||||
|
||||
for t in range(t0, G + 1):
|
||||
rng = np.random.default_rng([seed, a_idx, t])
|
||||
tag = {"experiment": name, "arm": arm, "seed": seed, "generation": t, "operator": op}
|
||||
|
||||
model, tok = load_specialists(base, [parents["math"], parents["code"]])
|
||||
|
||||
# ---- own-skill retention q_t, and each parent alone on the composed target
|
||||
model.set_adapter("a0")
|
||||
q_math = score_gsm8k(gen(model, tok, [x.prompt for x in gsm]), gsm)
|
||||
math_comp = gen(model, tok, hard_prompts)
|
||||
_, math_ok, math_exec = score_composed(math_comp, hard)
|
||||
probe_math = gen(model, tok, probes)
|
||||
model.set_adapter("a1")
|
||||
q_code = score_mbpp(gen(model, tok, [x["prompt"] for x in mbpp]), mbpp)
|
||||
code_comp = gen(model, tok, hard_prompts)
|
||||
_, code_ok, code_exec = score_composed(code_comp, hard)
|
||||
probe_code = gen(model, tok, probes)
|
||||
|
||||
rho_behav = float(np.mean([_normalise(a) == _normalise(b)
|
||||
for a, b in zip(probe_math, probe_code)]))
|
||||
rho_geom = float(delta_geometry(parents["math"], parents["code"])["delta_cos"])
|
||||
|
||||
# ---- merge and measure the composition (optionally selecting the weights on val)
|
||||
chosen = weight_grid[0]
|
||||
if hard_val and len(weight_grid) > 1:
|
||||
best_v = -np.inf
|
||||
for wi, w in enumerate(weight_grid):
|
||||
cn = f"cand{wi}"
|
||||
model.add_weighted_adapter(["a0", "a1"], w, cn, combination_type=op)
|
||||
model.set_adapter(cn)
|
||||
v, _, _ = score_composed(gen(model, tok, val_prompts), hard_val)
|
||||
if v > best_v:
|
||||
best_v, chosen = v, w
|
||||
model.set_adapter("a0"); model.delete_adapter(cn)
|
||||
rows.append({**tag, "metric": "chosen_weight_math", "value": float(chosen[0])})
|
||||
model.add_weighted_adapter(["a0", "a1"], chosen, "merged", combination_type=op)
|
||||
model.set_adapter("merged")
|
||||
merged_comp = gen(model, tok, hard_prompts)
|
||||
comp_acc, merged_ok, merged_exec = score_composed(merged_comp, hard)
|
||||
model.set_adapter("a0"); model.delete_adapter("merged")
|
||||
|
||||
best_parent = max(float(math_ok.mean()), float(code_ok.mean()))
|
||||
surplus = comp_acc - best_parent
|
||||
uex = union_exceedance(merged_ok, [math_ok, code_ok])
|
||||
for metric, value in (
|
||||
("q_math", q_math), ("q_code", q_code),
|
||||
("rho_behav", rho_behav), ("rho_geom", rho_geom),
|
||||
("composed_acc", comp_acc), ("composed_exec", merged_exec),
|
||||
("parent_math_composed", float(math_ok.mean())),
|
||||
("parent_code_composed", float(code_ok.mean())),
|
||||
("parent_math_exec", math_exec), ("parent_code_exec", code_exec),
|
||||
("best_parent_composed", best_parent), ("surplus", surplus),
|
||||
("union_exceedance", uex),
|
||||
):
|
||||
rows.append({**tag, "metric": metric, "value": float(value)})
|
||||
print(f"[{arm}] gen {t}/{G}: composed {comp_acc:.3f} (best parent {best_parent:.3f}, "
|
||||
f"surplus {surplus:+.3f}, uex {uex:.3f}) q_math {q_math:.2f} q_code {q_code:.2f} "
|
||||
f"rho {rho_behav:.2f}", flush=True)
|
||||
|
||||
if t == G:
|
||||
del model; torch.cuda.empty_cache()
|
||||
break
|
||||
|
||||
# ---- reproduce: each lineage distils from its own gated answers (+ g real examples)
|
||||
new_parents = {}
|
||||
for li, lin in enumerate(LINEAGES):
|
||||
pool = (math_train(k_inh, seed * 9001 + t * 31 + li, with_answers=False)
|
||||
if lin == "math" else
|
||||
code_train(k_inh, seed * 9001 + t * 31 + li, with_answers=False))
|
||||
model.set_adapter(f"a{li}")
|
||||
pool_prompts = [x.prompt for x in pool]
|
||||
answers = gen(model, tok, pool_prompts)
|
||||
if conf_gate is None:
|
||||
keep = np.ones(len(pool), bool)
|
||||
else:
|
||||
conf = mean_logprob(model, tok, pool_prompts, answers,
|
||||
batch_size=int(cfg.get("score_batch_size", 8)))
|
||||
keep = conf >= conf_gate
|
||||
data = [Task(lin, x.prompt, a.strip()[:512])
|
||||
for x, a, kp in zip(pool, answers, keep) if kp and a.strip()]
|
||||
n_self = len(data)
|
||||
if g_frac > 0: # immigration: verified real examples, fresh each gen
|
||||
n_real = max(1, int(round(g_frac / (1 - g_frac) * n_self)))
|
||||
real = (math_train(n_real, seed * 7717 + t * 13 + li, with_answers=True)
|
||||
if lin == "math" else
|
||||
code_train(n_real, seed * 7717 + t * 13 + li, with_answers=True))
|
||||
data += [x for x in real if x.answer.strip()]
|
||||
rows.append({**tag, "metric": f"n_inherit_{lin}", "value": float(n_self)})
|
||||
rows.append({**tag, "metric": f"n_real_{lin}", "value": float(len(data) - n_self)})
|
||||
d = root / arm / f"gen{t + 1}" / lin
|
||||
if len(data) >= 8:
|
||||
train_lora_on_tasks(base, data, str(d), epochs=epochs,
|
||||
seed=seed * 31 + t * 7 + li, r=r, alpha=alpha,
|
||||
batch_size=tb, max_len=tlen)
|
||||
else: # terminal degeneration: inherit unchanged
|
||||
shutil.copytree(parents[lin], d, dirs_exist_ok=True)
|
||||
rows.append({**tag, "metric": f"degenerate_{lin}", "value": 1.0})
|
||||
new_parents[lin] = str(d)
|
||||
del model; torch.cuda.empty_cache()
|
||||
|
||||
for lin in LINEAGES: # disk hygiene: drop the superseded generation
|
||||
if parents[lin] != founders[lin]:
|
||||
shutil.rmtree(parents[lin], ignore_errors=True)
|
||||
parents = new_parents
|
||||
st_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
st_path.write_text(json.dumps({"generation": t + 1, "parents": parents}))
|
||||
pd.DataFrame(rows).to_parquet(partial, index=False)
|
||||
|
||||
frames.append(pd.DataFrame(rows))
|
||||
return pd.concat(frames, ignore_index=True)
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
"""Datasets and prompts for the composition experiment (prereg v3 §1.1).
|
||||
|
||||
Two single-skill training corpora and three evaluation sets, all pinned to fixed subsets by seed so
|
||||
every generation sees *fresh* prompts from a *fixed* pool and no split ever leaks into another:
|
||||
|
||||
* **math** lineage — trains on MetaMathQA (natural-language chain-of-thought); own-skill probe is
|
||||
GSM8K test (numeric answer).
|
||||
* **code** lineage — trains on CodeAlpaca-20k (instruction → code); own-skill probe is MBPP
|
||||
(sanitized) with execution against the reference tests.
|
||||
* **composed** target — GSM8k-Hard, program-aided: emit ``solution()``, execute, compare numerically.
|
||||
Out-of-domain for both lineages, which is the point (LoRA Soups, COLING 2025).
|
||||
|
||||
The composed prompt is fixed here rather than tuned per model, so the operator contrast is never
|
||||
confounded with prompt search.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .tasks import Task
|
||||
|
||||
COMPOSED_PROMPT = (
|
||||
"{question}\n\n"
|
||||
"Write a Python function `solution()` that takes no arguments and returns the numeric answer. "
|
||||
"Reply with only the code, inside a ```python code block."
|
||||
)
|
||||
MATH_PROMPT = "{question}\n\nSolve this step by step, then give the final numeric answer after '####'."
|
||||
CODE_PROMPT = "{instruction}\n\nReply with only the Python code, inside a ```python code block."
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProgTask:
|
||||
"""A program-aided task: prompt, numeric target, and its source id."""
|
||||
|
||||
prompt: str
|
||||
target: float
|
||||
idx: int
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _load(name: str, split: str, config: str | None = None):
|
||||
from datasets import load_dataset
|
||||
return load_dataset(name, config, split=split) if config else load_dataset(name, split=split)
|
||||
|
||||
|
||||
def _pick(n_total: int, n: int, seed: int) -> np.ndarray:
|
||||
return np.random.default_rng(seed).choice(n_total, size=min(n, n_total), replace=False)
|
||||
|
||||
|
||||
def _split_pool(n_total: int, split: str) -> np.ndarray:
|
||||
"""Deterministic disjoint halves of an index pool.
|
||||
|
||||
The composed target's val split screens merge weights and the test split reports them; with a
|
||||
pool as small as MATH-500's 271 usable items, sampling both by seed alone would overlap and leak
|
||||
selection into the reported number. Splitting first makes the disjointness structural.
|
||||
"""
|
||||
idx = np.random.default_rng(20260907).permutation(n_total)
|
||||
cut = int(0.7 * n_total)
|
||||
return idx[:cut] if split == "test" else idx[cut:]
|
||||
|
||||
|
||||
def gsm_hard(n: int, seed: int, split: str = "test") -> list[ProgTask]:
|
||||
"""The composed target: GSM8k-Hard, program-aided. ``split`` picks a disjoint val/test half."""
|
||||
d = _load("reasoning-machines/gsm-hard", "train")
|
||||
pool = _split_pool(len(d), split)
|
||||
return [ProgTask(COMPOSED_PROMPT.format(question=d[int(pool[j])]["input"]),
|
||||
float(d[int(pool[j])]["target"]), int(pool[j]))
|
||||
for j in _pick(len(pool), n, seed)]
|
||||
|
||||
|
||||
_FRAC = __import__("re").compile(r"-?\\d?frac\{(-?\d+)\}\{(-?\d+)\}")
|
||||
|
||||
|
||||
def _numeric_answer(a: str) -> float | None:
|
||||
"""Parse a MATH-500 answer to a float, or None if it is not a plain number/simple fraction."""
|
||||
import re
|
||||
a = (a.strip().replace("\\!", "").replace("{,}", "").replace(",", "")
|
||||
.replace("\\%", "").replace("$", "").replace("\\dfrac", "\\frac"))
|
||||
if re.fullmatch(r"-?\d+(\.\d+)?", a):
|
||||
return float(a)
|
||||
f = re.fullmatch(r"-?\\frac\{(-?\d+)\}\{(-?\d+)\}", a)
|
||||
if f and int(f.group(2)) != 0:
|
||||
v = int(f.group(1)) / int(f.group(2))
|
||||
return -v if a.startswith("-") else v
|
||||
return None
|
||||
|
||||
|
||||
def math500(n: int, seed: int, split: str = "test", min_level: int = 3) -> list[ProgTask]:
|
||||
"""Harder composed target: MATH-500 (competition maths), program-aided, numeric answers only.
|
||||
|
||||
The alternative target for when the base can already do the *reasoning* in GSM8k-Hard once code
|
||||
removes the arithmetic burden (measured 2026-09-07: code-only 0.427 there). MetaMathQA is built
|
||||
from GSM8K **and** MATH, so the math specialist is trained on exactly this reasoning while the
|
||||
base is weak at it — which restores E8's premise that each parent supplies something scarce.
|
||||
``min_level`` filters MATH's 1–5 difficulty scale.
|
||||
"""
|
||||
d = _load("HuggingFaceH4/MATH-500", "test")
|
||||
pool = [(i, _numeric_answer(d[i]["answer"])) for i in range(len(d))]
|
||||
pool = [(i, v) for i, v in pool if v is not None and int(d[i]["level"]) >= min_level]
|
||||
half = _split_pool(len(pool), split)
|
||||
idx = [int(half[j]) for j in _pick(len(half), n, seed)]
|
||||
return [ProgTask(COMPOSED_PROMPT.format(question=d[pool[j][0]]["problem"]),
|
||||
float(pool[j][1]), int(pool[j][0])) for j in idx]
|
||||
|
||||
|
||||
def composed_target(kind: str, n: int, seed: int, split: str = "test", **kw) -> list[ProgTask]:
|
||||
"""Dispatch the composed target by name: ``gsm_hard`` (default) or ``math500``."""
|
||||
return {"gsm_hard": gsm_hard, "math500": math500}[kind](n, seed, split=split, **kw)
|
||||
|
||||
|
||||
def gsm8k_probe(n: int, seed: int) -> list[Task]:
|
||||
"""Math own-skill probe: GSM8K test, answer after '####'."""
|
||||
d = _load("openai/gsm8k", "test", "main")
|
||||
out = []
|
||||
for i in _pick(len(d), n, seed):
|
||||
r = d[int(i)]
|
||||
out.append(Task("math", MATH_PROMPT.format(question=r["question"]),
|
||||
r["answer"].split("####")[-1].strip().replace(",", "")))
|
||||
return out
|
||||
|
||||
|
||||
def mbpp_probe(n: int, seed: int) -> list[dict]:
|
||||
"""Code own-skill probe: MBPP sanitized, with the reference asserts as the verifier."""
|
||||
d = _load("google-research-datasets/mbpp", "test", "sanitized")
|
||||
out = []
|
||||
for i in _pick(len(d), n, seed):
|
||||
r = d[int(i)]
|
||||
out.append({"prompt": CODE_PROMPT.format(instruction=r["prompt"]),
|
||||
"tests": list(r["test_list"]), "imports": list(r.get("test_imports") or []),
|
||||
"idx": int(i)})
|
||||
return out
|
||||
|
||||
|
||||
def math_train(n: int, seed: int, *, with_answers: bool) -> list[Task]:
|
||||
"""MetaMathQA examples. ``with_answers=False`` returns prompts only (self-consumption pool)."""
|
||||
d = _load("meta-math/MetaMathQA", "train")
|
||||
out = []
|
||||
for i in _pick(len(d), n, seed):
|
||||
r = d[int(i)]
|
||||
# Reason: MetaMathQA responses end "The answer is: X"; rewrite to the '####' convention the
|
||||
# probe grades on, so training format and evaluation format agree.
|
||||
body = r["response"].strip()
|
||||
ans = body.split("The answer is:")[-1].strip()
|
||||
target = (body[:480] + f"\n#### {ans}") if with_answers else ""
|
||||
out.append(Task("math", MATH_PROMPT.format(question=r["query"]), target))
|
||||
return out
|
||||
|
||||
|
||||
def code_train(n: int, seed: int, *, with_answers: bool) -> list[Task]:
|
||||
"""CodeAlpaca examples (input-free subset, so the prompt is self-contained)."""
|
||||
d = _load("sahil2801/CodeAlpaca-20k", "train")
|
||||
idx = [int(i) for i in _pick(len(d), n * 3, seed) if not d[int(i)]["input"].strip()][:n]
|
||||
return [Task("code", CODE_PROMPT.format(instruction=d[i]["instruction"]),
|
||||
d[i]["output"].strip()[:512] if with_answers else "") for i in idx]
|
||||
|
||||
|
||||
def probe_pool(n: int, seed: int) -> list[str]:
|
||||
"""Shared prompts both lineages answer, for the behavioural correlation rho_t (§1.6).
|
||||
|
||||
Half composed-task prompts, half a mix of each lineage's own domain — so rho reflects agreement
|
||||
on ground both lineages actually walk on, not on prompts only one has ever seen.
|
||||
"""
|
||||
half = n // 2
|
||||
return ([t.prompt for t in gsm_hard(half, seed + 71)]
|
||||
+ [t.prompt for t in gsm8k_probe(half // 2, seed + 72)]
|
||||
+ [t.prompt for t in code_train(n - half - half // 2, seed + 73, with_answers=False)])
|
||||
|
|
@ -268,36 +268,18 @@ def run_epistasis_dispatch(cfg: dict) -> pd.DataFrame:
|
|||
return run_epistasis_experiment(cfg)
|
||||
|
||||
|
||||
def run_society_dispatch(cfg: dict) -> pd.DataFrame:
|
||||
from .society import run_society_experiment # local import: torch-heavy
|
||||
return run_society_experiment(cfg)
|
||||
|
||||
|
||||
def run_society_v2_dispatch(cfg: dict) -> pd.DataFrame:
|
||||
from .society_v2 import run_society_v2 # local import: torch-heavy
|
||||
return run_society_v2(cfg)
|
||||
|
||||
|
||||
def run_compose_dispatch(cfg: dict) -> pd.DataFrame:
|
||||
from .compose import run_compose # local import: torch-heavy
|
||||
return run_compose(cfg)
|
||||
|
||||
|
||||
def run_curriculum_dispatch(cfg: dict) -> pd.DataFrame:
|
||||
from .curriculum import run_curriculum # local import: torch-heavy
|
||||
return run_curriculum(cfg)
|
||||
|
||||
|
||||
def run_calib_dispatch(cfg: dict) -> pd.DataFrame:
|
||||
from .calibrate import run_calibration # local import: torch-heavy
|
||||
return run_calibration(cfg)
|
||||
|
||||
|
||||
_RUNNERS = {"llm_merge": run_merge_experiment, "llm_moe": run_moe_experiment,
|
||||
"llm_directed": run_directed_experiment, "llm_speciation": run_speciation_dispatch,
|
||||
"llm_epistasis": run_epistasis_dispatch, "llm_society": run_society_dispatch,
|
||||
"llm_society_v2": run_society_v2_dispatch, "llm_society_calib": run_calib_dispatch,
|
||||
"llm_compose": run_compose_dispatch,
|
||||
"llm_epistasis": run_epistasis_dispatch,
|
||||
"llm_curriculum": run_curriculum_dispatch}
|
||||
|
||||
|
||||
|
|
@ -332,24 +314,11 @@ def run_and_save(config_path: str | Path) -> Path:
|
|||
extra["seeds"] = [int(s) for s in seeds]
|
||||
if kind == "llm_moe":
|
||||
extra["operators"] = list(cfg.get("operators", []))
|
||||
if kind == "llm_society":
|
||||
extra["society"] = {k: cfg.get(k) for k in
|
||||
("agents", "generations", "arms", "g", "lam", "n_candidates")}
|
||||
if kind == "llm_society_v2":
|
||||
extra["society_v2"] = {k: cfg.get(k) for k in
|
||||
("families", "agents", "generations", "arms", "g", "lam",
|
||||
"k_inherit", "epochs", "n_test", "n_val", "n_conf")}
|
||||
if kind == "llm_compose":
|
||||
extra["compose"] = {k: cfg.get(k) for k in
|
||||
("arms", "generations", "g", "k_inherit", "conf_gate", "epochs",
|
||||
"n_hard", "n_gsm8k", "n_mbpp", "lora")}
|
||||
if kind == "llm_curriculum":
|
||||
extra["curriculum"] = {k: cfg.get(k) for k in
|
||||
("families", "lineages", "generations", "arms", "baselines",
|
||||
"n_new", "n_replay", "operator", "ancestor_depth", "lora",
|
||||
"allow_veto", "merge_until", "orders", "cull")}
|
||||
if kind == "llm_society_calib":
|
||||
extra["calibration"] = {"stage": cfg.get("stage"), "families": cfg.get("families")}
|
||||
if kind == "llm_directed":
|
||||
extra["directed"] = {"n_candidates": int(cfg.get("n_candidates", 16)),
|
||||
"concentration": float(cfg.get("concentration", 0.5)),
|
||||
|
|
|
|||
|
|
@ -1,232 +0,0 @@
|
|||
"""The composed society at LLM scale (C3) — E11 re-instantiated in a population of LoRA agents.
|
||||
|
||||
A population of `N` agents (LoRA adapters on a shared frozen base) evolves for `G` non-overlapping
|
||||
generations under the four operators the paper composes: grounded evaluation, directed recombination
|
||||
(sex), diversity-preserving selection, and retraining (mutation). The single grounding knob acts in
|
||||
the *evaluation* channel, exactly as in E11: selection scores each agent by
|
||||
``g * verifier_fitness + (1 - g) * conformity``, where conformity is agreement with the population's
|
||||
own modal answer. The inheritance channel is identical in every arm and deliberately ungrounded —
|
||||
each child is a fresh LoRA distilled from its source model's *own answers* (self-consumption made
|
||||
literal), so knowledge survives only through the data channel.
|
||||
|
||||
Arms (four-arm ablation, mirroring E11): ``full`` / ``no_grounding`` (g=0; the verifier never enters
|
||||
that arm's loop — offspring screening also falls back to conformity) / ``no_sex`` (children are
|
||||
redistilled copies of selected parents) / ``no_diversity`` (plain top-P selection).
|
||||
|
||||
Pure, testable pieces live at module top (consensus, conformity, behavioural distance,
|
||||
quality-diversity selection, complementary pairing); the GPU loop is
|
||||
:func:`run_society_experiment`. ``python -m llm.experiment configs/llm/society_smoke.yaml``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .tasks import Task, make_tasks, _normalise
|
||||
|
||||
|
||||
# Pure operators live in society_ops (shared with the v2 loop); re-exported here so the v1 code
|
||||
# path and its tests are unchanged.
|
||||
from .society_ops import ( # noqa: E402,F401
|
||||
answer_of as _answer_of, arm_settings as _arm_settings_v2, behavioural_distance,
|
||||
complementary_pairs, conformity_scores, consensus_answers, fitness_of as _fitness,
|
||||
select_parents,
|
||||
)
|
||||
|
||||
|
||||
def arm_settings(arm: str, g: float) -> dict:
|
||||
"""v1 four-arm switches (boolean ``sex``), resolved from the shared table."""
|
||||
s = _arm_settings_v2(arm, g)
|
||||
if s["sex"] == "linear":
|
||||
raise ValueError("sex_linear is a v2 arm (kind: llm_society_v2)")
|
||||
return {"g": s["g"], "sex": s["sex"] is not None, "diversity": s["diversity"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- the GPU loop
|
||||
|
||||
|
||||
def run_society_experiment(cfg: dict) -> pd.DataFrame:
|
||||
"""Run the society loop for every configured arm; return tidy long-form rows.
|
||||
|
||||
Config keys (with defaults): ``agents`` (6), ``generations`` (8), ``arms`` (all four),
|
||||
``g`` (0.5), ``lam`` (0.3), ``n_test``/``n_val``/``n_conf``/``n_inherit`` pool sizes,
|
||||
``n_candidates`` (6) offspring screened per pair, ``epochs`` (2) child SFT epochs,
|
||||
``spec_train``/``spec_epochs`` gen-0 specialist budget, ``hard`` (False),
|
||||
``keep_all_adapters`` (False).
|
||||
"""
|
||||
import torch
|
||||
|
||||
from .evaluate import generate, load_model
|
||||
from .merge import load_specialists
|
||||
from .specialise import train_lora_on_tasks, train_specialist
|
||||
|
||||
name = cfg["experiment"]
|
||||
base = cfg["base_model"]
|
||||
fams = list(cfg.get("families", ["lists", "strings", "arith"]))
|
||||
N = int(cfg.get("agents", 6))
|
||||
G = int(cfg.get("generations", 8))
|
||||
arms = list(cfg.get("arms", ["full", "no_grounding", "no_sex", "no_diversity"]))
|
||||
g_val = float(cfg.get("g", 0.5))
|
||||
lam = float(cfg.get("lam", 0.3))
|
||||
n_test = int(cfg.get("n_test", 40))
|
||||
n_val = int(cfg.get("n_val", 30))
|
||||
n_conf = int(cfg.get("n_conf", 60))
|
||||
n_inherit = int(cfg.get("n_inherit", 240))
|
||||
n_cand = int(cfg.get("n_candidates", 6))
|
||||
epochs = int(cfg.get("epochs", 2))
|
||||
elitism = int(cfg.get("elitism", 0))
|
||||
n_parents = int(cfg.get("n_parents", max(2, N // 2)))
|
||||
spec_train = int(cfg.get("spec_train", 300))
|
||||
spec_epochs = int(cfg.get("spec_epochs", 2))
|
||||
hard = bool(cfg.get("hard", False))
|
||||
keep_all = bool(cfg.get("keep_all_adapters", False))
|
||||
seed = int(cfg["seed"])
|
||||
lora = cfg.get("lora", {})
|
||||
r, alpha = int(lora.get("r", 16)), int(lora.get("alpha", 32))
|
||||
root = Path(cfg.get("adapters_dir", "models/llm")) / "society" / f"{name}_s{seed}"
|
||||
|
||||
# fixed pools: test (reporting only), val (grounded selection signal + offspring screening)
|
||||
test = sum([make_tasks(f, n_test, seed=1000 + i, hard=hard) for i, f in enumerate(fams)], [])
|
||||
val = sum([make_tasks(f, n_val, seed=3000 + i, hard=hard) for i, f in enumerate(fams)], [])
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
# gen-0 founders: light per-family specialists, shared across arms (same starting population)
|
||||
founders = []
|
||||
for i in range(N):
|
||||
fam = fams[i % len(fams)]
|
||||
d = root / "founders" / f"agent{i}_{fam}"
|
||||
if not (d / "adapter_config.json").exists():
|
||||
train_specialist(base, fam, str(d), n_train=spec_train, epochs=spec_epochs,
|
||||
seed=seed * 100 + i, hard=hard, r=r, alpha=alpha)
|
||||
founders.append(str(d))
|
||||
|
||||
rows: list[dict] = []
|
||||
for arm in arms:
|
||||
s = arm_settings(arm, g_val)
|
||||
agents = list(founders)
|
||||
for t in range(G):
|
||||
conf_pool = sum([make_tasks(f, n_conf // len(fams), seed=seed * 7919 + t * 13 + i,
|
||||
hard=hard) for i, f in enumerate(fams)], [])
|
||||
conf_prompts = [x.prompt for x in conf_pool]
|
||||
|
||||
# ---- produce & score: one base, all agents attached as adapters
|
||||
model, tok = load_specialists(base, agents)
|
||||
fit_test, fit_val, conf_outs = [], [], []
|
||||
for i in range(N):
|
||||
model.set_adapter(f"a{i}")
|
||||
fit_test.append(_fitness(generate(model, tok, [x.prompt for x in test]), test, fams))
|
||||
fit_val.append(_fitness(generate(model, tok, [x.prompt for x in val]), val, fams))
|
||||
conf_outs.append(generate(model, tok, conf_prompts))
|
||||
consensus = consensus_answers(conf_outs)
|
||||
conf = conformity_scores(conf_outs, consensus)
|
||||
dist = behavioural_distance(conf_outs)
|
||||
cons_acc = float(np.mean([verify(c, x) for c, x in zip(consensus, conf_pool)]))
|
||||
fitness = np.array([fv["overall"] for fv in fit_val])
|
||||
scores = s["g"] * fitness + (1.0 - s["g"]) * conf
|
||||
|
||||
parents = select_parents(scores, dist, n_parents, diversity=s["diversity"], lam=lam)
|
||||
|
||||
# ---- rows (reporting uses the verifier in every arm; the loop does not)
|
||||
for i in range(N):
|
||||
base_row = {"experiment": name, "arm": arm, "seed": seed, "generation": t,
|
||||
"agent": i, "selected": i in parents,
|
||||
"conformity": float(conf[i]), "score": float(scores[i])}
|
||||
for k, v in fit_test[i].items():
|
||||
rows.append({**base_row, "metric": f"test_{k}", "value": v})
|
||||
rows.append({**base_row, "metric": "val_overall", "value": float(fitness[i])})
|
||||
rows.append({"experiment": name, "arm": arm, "seed": seed, "generation": t,
|
||||
"agent": -1, "selected": False, "conformity": float("nan"),
|
||||
"score": float("nan"), "metric": "consensus_acc", "value": cons_acc})
|
||||
rows.append({"experiment": name, "arm": arm, "seed": seed, "generation": t,
|
||||
"agent": -1, "selected": False, "conformity": float("nan"),
|
||||
"score": float("nan"), "metric": "diversity_behav",
|
||||
"value": float(dist[np.triu_indices(N, 1)].mean())})
|
||||
|
||||
if t == G - 1:
|
||||
del model
|
||||
torch.cuda.empty_cache()
|
||||
break
|
||||
|
||||
# ---- breed: pick each child's source (merged offspring, or a copied parent)
|
||||
inherit_pool = sum([make_tasks(f, n_inherit // len(fams),
|
||||
seed=seed * 104729 + t * 17 + i, hard=hard)
|
||||
for i, f in enumerate(fams)], [])
|
||||
n_bred = N - elitism
|
||||
child_sources: list[dict] = [] # per child: parent names + weights
|
||||
if s["sex"]:
|
||||
from .directed import sample_merge_weights
|
||||
pairs = complementary_pairs(parents, dist, n_bred)
|
||||
for c, (pa, pb) in enumerate(pairs):
|
||||
w = sample_merge_weights(2, n_cand, rng)
|
||||
best_i, best_v = 0, -np.inf
|
||||
for ci in range(n_cand):
|
||||
cname = f"g{t}c{c}k{ci}"
|
||||
model.add_weighted_adapter([f"a{pa}", f"a{pb}"], w[ci].tolist(), cname,
|
||||
combination_type="linear")
|
||||
model.set_adapter(cname)
|
||||
if s["g"] > 0: # grounded screening: verifier on val
|
||||
v = _fitness(generate(model, tok, [x.prompt for x in val]),
|
||||
val, fams)["overall"]
|
||||
else: # ungrounded screening: conformity only
|
||||
outs_c = generate(model, tok, conf_prompts)
|
||||
v = float(np.mean([_normalise(o) == cc
|
||||
for o, cc in zip(outs_c, consensus)]))
|
||||
if v > best_v:
|
||||
best_i, best_v = ci, v
|
||||
model.set_adapter(f"a{pa}") # never delete the active adapter
|
||||
model.delete_adapter(cname)
|
||||
cname = f"g{t}c{c}win"
|
||||
model.add_weighted_adapter([f"a{pa}", f"a{pb}"], w[best_i].tolist(), cname,
|
||||
combination_type="linear")
|
||||
model.set_adapter(cname)
|
||||
answers = generate(model, tok, [x.prompt for x in inherit_pool])
|
||||
model.set_adapter(f"a{pa}")
|
||||
model.delete_adapter(cname)
|
||||
child_sources.append({"parents": (pa, pb), "answers": answers})
|
||||
else:
|
||||
for c in range(n_bred):
|
||||
p = parents[c % len(parents)]
|
||||
model.set_adapter(f"a{p}")
|
||||
answers = generate(model, tok, [x.prompt for x in inherit_pool])
|
||||
child_sources.append({"parents": (p, p), "answers": answers})
|
||||
del model
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# ---- reproduce: fresh LoRA per child, trained on its source's own answers.
|
||||
# Elites (overlapping generations): the top-scoring parents survive as unmodified
|
||||
# copies, applied identically in every arm — reproduction here is lossy distillation,
|
||||
# so without a survivor the mutation load erases the champion each generation.
|
||||
new_agents = []
|
||||
for e in range(elitism):
|
||||
d = root / arm / f"gen{t + 1}" / f"elite{e}"
|
||||
shutil.copytree(agents[parents[e]], d, dirs_exist_ok=True)
|
||||
new_agents.append(str(d))
|
||||
rows.append({"experiment": name, "arm": arm, "seed": seed, "generation": t + 1,
|
||||
"agent": e, "selected": False, "conformity": float("nan"),
|
||||
"score": float("nan"), "metric": "parents",
|
||||
"value": float(parents[e] * 100 + parents[e])})
|
||||
for c, src in enumerate(child_sources, start=elitism):
|
||||
data = [Task(x.family, x.prompt, _answer_of(a))
|
||||
for x, a in zip(inherit_pool, src["answers"]) if _answer_of(a)]
|
||||
d = root / arm / f"gen{t + 1}" / f"agent{c}"
|
||||
if len(data) >= 8:
|
||||
train_lora_on_tasks(base, data, str(d), epochs=epochs,
|
||||
seed=seed * 31 + t * N + c, r=r, alpha=alpha)
|
||||
else: # terminal degeneration: copy the source
|
||||
# Reason: a fully-degenerate source emits no usable answers; crashing would kill
|
||||
# a long sweep (cf. the neural terminal-collapse sentinel) — inherit unchanged.
|
||||
shutil.copytree(agents[src["parents"][0]], d, dirs_exist_ok=True)
|
||||
new_agents.append(str(d))
|
||||
rows.append({"experiment": name, "arm": arm, "seed": seed, "generation": t + 1,
|
||||
"agent": c, "selected": False, "conformity": float("nan"),
|
||||
"score": float("nan"), "metric": "parents",
|
||||
"value": float(src["parents"][0] * 100 + src["parents"][1])})
|
||||
if not keep_all and t > 0: # disk hygiene: drop generation t
|
||||
for d in agents: # (founders at t=0 are kept — shared)
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
agents = new_agents
|
||||
return pd.DataFrame(rows)
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
"""Pure, testable operators for the LLM society (v1 and v2 — see ``tasks/prereg-llm-society-v2.md``).
|
||||
|
||||
Everything here is NumPy on strings and floats; nothing touches a model. The v2 additions are the
|
||||
E11-faithful pieces the v1 design lacked:
|
||||
|
||||
* :func:`pooled_survival` — selection acts on *survival over parents + offspring*, keeping the top N
|
||||
by ``score + λ·novelty`` (E11's rule), instead of truncating parents before breeding, which in v1
|
||||
discarded half the families at generation 1 with no operator able to restore them (E6).
|
||||
* :func:`mating_plan` — complementary pairing over the whole population with a per-agent use cap, so
|
||||
every founder's family can reach the next generation.
|
||||
* :func:`route_union` — the union-preserving recombination operator in the *inheritance data*: per
|
||||
prompt, the child learns the answer of the more confident parent. This is E4's ``max`` per item,
|
||||
verifier-free (legal in ``no_grounding``), and directed in E10's sense.
|
||||
* :func:`choose_single_parent` — score-proportional parent sampling for the ``no_sex`` arm, so that
|
||||
arm still has selection without recombination.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .tasks import Task, _normalise, verify
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ population readouts (v1)
|
||||
|
||||
|
||||
def consensus_answers(outputs: list[list[str]]) -> list[str]:
|
||||
"""The population's modal (normalised) answer per prompt; ties broken lexicographically."""
|
||||
n_prompts = len(outputs[0])
|
||||
cons = []
|
||||
for p in range(n_prompts):
|
||||
votes: dict[str, int] = {}
|
||||
for out in outputs:
|
||||
key = _normalise(out[p])
|
||||
votes[key] = votes.get(key, 0) + 1
|
||||
top = max(votes.values())
|
||||
cons.append(min(k for k, v in votes.items() if v == top))
|
||||
return cons
|
||||
|
||||
|
||||
def conformity_scores(outputs: list[list[str]], consensus: list[str]) -> np.ndarray:
|
||||
"""Each agent's agreement rate with the population consensus (the E11 conformity signal)."""
|
||||
return np.array([np.mean([_normalise(o) == c for o, c in zip(out, consensus)])
|
||||
for out in outputs], dtype=float)
|
||||
|
||||
|
||||
def behavioural_distance(outputs: list[list[str]]) -> np.ndarray:
|
||||
"""Pairwise disagreement rate between agents' normalised answers (verifier-free diversity)."""
|
||||
n = len(outputs)
|
||||
norm = [[_normalise(o) for o in out] for out in outputs]
|
||||
d = np.zeros((n, n))
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
d[i, j] = d[j, i] = float(np.mean([a != b for a, b in zip(norm[i], norm[j])]))
|
||||
return d
|
||||
|
||||
|
||||
def novelty(dist: np.ndarray) -> np.ndarray:
|
||||
"""Per-agent mean behavioural distance to the rest of the pool (E11's ``_novelty``)."""
|
||||
n = dist.shape[0]
|
||||
if n < 2:
|
||||
return np.zeros(n)
|
||||
return dist.sum(axis=1) / (n - 1)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ selection
|
||||
|
||||
|
||||
def select_parents(scores: np.ndarray, dist: np.ndarray, k: int, *, diversity: bool,
|
||||
lam: float = 0.3) -> list[int]:
|
||||
"""v1 parent truncation (kept for the v1 code path and its tests): greedy QD or plain top-k."""
|
||||
if not diversity:
|
||||
return list(np.argsort(-scores, kind="stable")[:k])
|
||||
chosen = [int(np.argmax(scores))]
|
||||
while len(chosen) < k:
|
||||
best_i, best_v = -1, -np.inf
|
||||
for i in range(len(scores)):
|
||||
if i in chosen:
|
||||
continue
|
||||
v = scores[i] + lam * float(np.mean([dist[i, j] for j in chosen]))
|
||||
if v > best_v:
|
||||
best_i, best_v = i, v
|
||||
chosen.append(best_i)
|
||||
return chosen
|
||||
|
||||
|
||||
def pooled_survival(scores: np.ndarray, dist: np.ndarray, n_keep: int, *, lam: float) -> list[int]:
|
||||
"""E11 survival: keep the top ``n_keep`` of the pool by ``score + lam * novelty``.
|
||||
|
||||
``lam = 0`` is the greedy (``no_diversity``) arm. Parents and children compete on equal terms, so
|
||||
a strong parent survives by out-scoring its children (elitism is emergent, not a knob) and no
|
||||
lineage is excluded before it has bred.
|
||||
|
||||
Args:
|
||||
scores (np.ndarray): per-member selection score over the pooled parents + children.
|
||||
dist (np.ndarray): pairwise behavioural distance over the pool.
|
||||
n_keep (int): population size to keep.
|
||||
lam (float): novelty weight (E11 ``novelty``).
|
||||
|
||||
Returns:
|
||||
list[int]: indices into the pool, best merit first (deterministic).
|
||||
"""
|
||||
merit = np.asarray(scores, dtype=float) + lam * novelty(dist)
|
||||
order = np.argsort(-merit, kind="stable")
|
||||
return [int(i) for i in order[:n_keep]]
|
||||
|
||||
|
||||
def choose_single_parent(scores: np.ndarray, rng: np.random.Generator) -> int:
|
||||
"""Score-proportional parent sampling (``no_sex``): selection without recombination."""
|
||||
s = np.asarray(scores, dtype=float)
|
||||
w = s - s.min() + 1e-6
|
||||
return int(rng.choice(len(s), p=w / w.sum()))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ mating and recombination
|
||||
|
||||
|
||||
def mating_plan(dist: np.ndarray, n_pairs: int, *, max_use: int = 2) -> list[tuple[int, int]]:
|
||||
"""Complementary pairing over the whole population with a per-agent use cap.
|
||||
|
||||
Pairs are taken in descending behavioural distance (directed mate choice, E10) subject to each
|
||||
agent appearing in at most ``max_use`` pairs, so no single agent monopolises reproduction and
|
||||
every agent's knowledge has a route to the next generation. If the cap exhausts the candidates
|
||||
before ``n_pairs`` is reached, the remaining slots cycle the most-distant pairs (degenerate but
|
||||
never empty).
|
||||
"""
|
||||
n = dist.shape[0]
|
||||
cands = sorted(((i, j) for i in range(n) for j in range(i + 1, n)), key=lambda ij: -dist[ij])
|
||||
if not cands:
|
||||
return [(0, 0)] * n_pairs
|
||||
use = np.zeros(n, dtype=int)
|
||||
plan: list[tuple[int, int]] = []
|
||||
for i, j in cands:
|
||||
if len(plan) >= n_pairs:
|
||||
break
|
||||
if use[i] < max_use and use[j] < max_use:
|
||||
plan.append((i, j)); use[i] += 1; use[j] += 1
|
||||
k = 0
|
||||
while len(plan) < n_pairs: # cap exhausted: cycle the best pairs
|
||||
plan.append(cands[k % len(cands)]); k += 1
|
||||
return plan
|
||||
|
||||
|
||||
def route_union(ans_a: list[str], conf_a: np.ndarray, ans_b: list[str],
|
||||
conf_b: np.ndarray) -> tuple[list[str], np.ndarray]:
|
||||
"""Union-preserving recombination of two parents' answer sets: per prompt, the more confident wins.
|
||||
|
||||
Confidence is the parent's own self-certainty (exp mean token log-prob) — no verifier. Ties go to
|
||||
parent A (deterministic).
|
||||
|
||||
Returns:
|
||||
(answers, source): the child's inheritance answers and a 0/1 array naming the parent per
|
||||
prompt (for the H6 diagnostic of *where* a skill is lost).
|
||||
"""
|
||||
src = (np.asarray(conf_b) > np.asarray(conf_a)).astype(int)
|
||||
out = [b if s else a for a, b, s in zip(ans_a, ans_b, src)]
|
||||
return out, src
|
||||
|
||||
|
||||
def complementary_pairs(parents: list[int], dist: np.ndarray, n_children: int) -> list[tuple[int, int]]:
|
||||
"""v1 mating plan over a parent subset (kept for the v1 code path and its tests)."""
|
||||
pairs = sorted(((a, b) for i, a in enumerate(parents) for b in parents[i + 1:]),
|
||||
key=lambda ab: -dist[ab[0], ab[1]])
|
||||
if not pairs:
|
||||
pairs = [(parents[0], parents[0])]
|
||||
return [pairs[i % len(pairs)] for i in range(n_children)]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ arms and readouts
|
||||
|
||||
|
||||
def arm_settings(arm: str, g: float) -> dict:
|
||||
"""Resolve an arm name to its operator switches.
|
||||
|
||||
``sex`` is ``"union"`` (confidence-routed union inheritance, the v2 default), ``"linear"`` (v1's
|
||||
screened 2-parent LoRA blend — the H2 control arm ``sex_linear``), or ``None``.
|
||||
"""
|
||||
table = {
|
||||
"full": {"g": g, "sex": "union", "diversity": True},
|
||||
"no_grounding": {"g": 0.0, "sex": "union", "diversity": True},
|
||||
"no_sex": {"g": g, "sex": None, "diversity": True},
|
||||
"no_diversity": {"g": g, "sex": "union", "diversity": False},
|
||||
"sex_linear": {"g": g, "sex": "linear", "diversity": True},
|
||||
}
|
||||
if arm not in table:
|
||||
raise ValueError(f"unknown arm {arm!r} (expected one of {list(table)})")
|
||||
return dict(table[arm])
|
||||
|
||||
|
||||
def fitness_of(outs: list[str], tasks: list[Task], fams: list[str]) -> dict:
|
||||
"""Overall / per-family / worst-family verifier accuracy from precomputed outputs."""
|
||||
corr = np.array([verify(o, t) for o, t in zip(outs, tasks)])
|
||||
fam = np.array([t.family for t in tasks])
|
||||
acc = {"overall": float(corr.mean())}
|
||||
acc.update({f: float(corr[fam == f].mean()) if (fam == f).any() else float("nan") for f in fams})
|
||||
acc["worst_family"] = min(acc[f] for f in fams)
|
||||
return acc
|
||||
|
||||
|
||||
def families_alive(per_family_acc: list[dict], fams: list[str], threshold: float = 0.6) -> int:
|
||||
"""Number of families on which at least one agent is competent (≥ ``threshold``) — the count of
|
||||
'alleles' still present in the population; loss is permanent (E6)."""
|
||||
return int(sum(any(a.get(f, 0.0) >= threshold for a in per_family_acc) for f in fams))
|
||||
|
||||
|
||||
def answer_of(raw: str) -> str:
|
||||
"""Trim a raw completion to a single short answer line for the inheritance data."""
|
||||
return raw.strip().split("\n")[0][:64].strip()
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
"""The composed society at LLM scale, v2 — E11 re-instantiated faithfully (``kind: llm_society_v2``).
|
||||
|
||||
Pre-registered in ``tasks/prereg-llm-society-v2.md``; this module is its §3. What changed from v1 and
|
||||
why is in that document's §1. In one paragraph: ``L`` disjoint task families and **one founder per
|
||||
family** (ρ = 0 by construction); recombination is a **confidence-routed union** of two parents'
|
||||
inheritance answers (E4's ``max`` per item, verifier-free) rather than a linear LoRA blend; selection
|
||||
acts on **survival over the pooled parents + children** by ``score + λ·novelty`` (E11's rule) rather
|
||||
than on breeding eligibility; the run **checkpoints every generation** and resumes.
|
||||
|
||||
One generation (§3.4): produce & score the population → mating plan by complementarity → each child's
|
||||
inheritance data from its parents' own answers (union / linear / single) → source diagnostics →
|
||||
train N fresh LoRAs → score the children against the *parents'* consensus → keep the top N of the
|
||||
2N pool → checkpoint.
|
||||
|
||||
Row schema (long form): ``experiment, arm, seed, generation, agent, role, parents, selected,
|
||||
conformity, score, metric, value``. ``role`` ∈ {population, child, child_source, summary}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from . import families as _families # noqa: F401 (registers the extra task families)
|
||||
from .society_ops import (answer_of, arm_settings, behavioural_distance, choose_single_parent,
|
||||
conformity_scores, consensus_answers, families_alive, fitness_of,
|
||||
mating_plan, novelty, pooled_survival, route_union)
|
||||
from .tasks import Task, make_tasks, verify, _normalise
|
||||
|
||||
|
||||
def _pool(fams: list[str], per_family: int, seed: int, hard: bool) -> list[Task]:
|
||||
return sum([make_tasks(f, per_family, seed=seed + i, hard=hard) for i, f in enumerate(fams)], [])
|
||||
|
||||
|
||||
def _row(base: dict, **kw) -> dict:
|
||||
r = dict(base); r.update(kw); return r
|
||||
|
||||
|
||||
def _train_founder_locked(d: Path, train, timeout_s: int = 3600) -> None:
|
||||
"""Train the founder at ``d`` exactly once across concurrent jobs (lock file, O_EXCL)."""
|
||||
import os, time
|
||||
done = d / "adapter_config.json"
|
||||
if done.exists():
|
||||
return
|
||||
d.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock = d.parent / (d.name + ".lock")
|
||||
try:
|
||||
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
t0 = time.time()
|
||||
while not done.exists(): # another job is training it
|
||||
if time.time() - t0 > timeout_s:
|
||||
raise TimeoutError(f"waited {timeout_s}s for founder {d}")
|
||||
time.sleep(15)
|
||||
return
|
||||
try:
|
||||
os.write(fd, str(os.getpid()).encode()); os.close(fd)
|
||||
train()
|
||||
finally:
|
||||
lock.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class _State:
|
||||
"""Per-arm checkpoint: the current population's adapter dirs and the generation reached."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
|
||||
def load(self) -> dict | None:
|
||||
return json.loads(self.path.read_text()) if self.path.exists() else None
|
||||
|
||||
def save(self, generation: int, agents: list[str]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps({"generation": generation, "agents": agents}))
|
||||
|
||||
|
||||
def run_society_v2(cfg: dict) -> pd.DataFrame:
|
||||
"""Run every configured arm of the v2 society; return tidy rows (resumable per arm)."""
|
||||
import torch
|
||||
|
||||
from .epistasis import generate_with_confidence
|
||||
from .evaluate import generate
|
||||
from .merge import load_specialists
|
||||
from .specialise import train_lora_on_tasks, train_specialist
|
||||
|
||||
name = cfg["experiment"]
|
||||
base = cfg["base_model"]
|
||||
fams = list(cfg["families"])
|
||||
L = len(fams)
|
||||
N = int(cfg.get("agents", L))
|
||||
G = int(cfg.get("generations", 12))
|
||||
arms = list(cfg.get("arms", ["full", "no_grounding", "no_sex", "no_diversity"]))
|
||||
g_val = float(cfg.get("g", 0.85))
|
||||
lam = float(cfg.get("lam", 0.3))
|
||||
n_test = int(cfg.get("n_test", 20)) # per family
|
||||
n_val = int(cfg.get("n_val", 10)) # per family
|
||||
n_conf = int(cfg.get("n_conf", 10)) # per family, fresh each generation
|
||||
k_inh = int(cfg.get("k_inherit", 100)) # per family, fresh each generation (gate C2)
|
||||
epochs = int(cfg.get("epochs", 3))
|
||||
n_cand = int(cfg.get("n_candidates", 6)) # sex_linear only
|
||||
spec_train = int(cfg.get("spec_train", 600))
|
||||
spec_epochs = int(cfg.get("spec_epochs", 3))
|
||||
hard = bool(cfg.get("hard", False))
|
||||
resume = bool(cfg.get("resume", True))
|
||||
seed = int(cfg["seed"])
|
||||
lora = cfg.get("lora", {})
|
||||
r, alpha = int(lora.get("r", 16)), int(lora.get("alpha", 32))
|
||||
root = Path(cfg.get("adapters_dir", "models/llm")) / "society_v2" / f"{name}_s{seed}"
|
||||
out_dir = Path(cfg.get("output", {}).get("dir", f"results/{name}"))
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
max_use = int(cfg.get("max_mate_use", 2))
|
||||
# Confidence-gated inheritance (prereg §4a, C2b): a child learns only the prompts its source is
|
||||
# confident on (exp mean token log-prob ≥ conf_gate). Verifier-free; identical in every arm;
|
||||
# None = ungated (the pre-registered v2 default, which C2 showed loses 20-40%/generation).
|
||||
conf_gate = cfg.get("conf_gate")
|
||||
conf_gate = None if conf_gate is None else float(conf_gate)
|
||||
|
||||
test = _pool(fams, n_test, 1000, hard)
|
||||
val = _pool(fams, n_val, 3000, hard)
|
||||
|
||||
# ---- founders: one specialist per family (agent i -> family i mod L), cached and shared by arms.
|
||||
# Arm-jobs of one seed may start together on the cluster: an O_EXCL lock makes the first train
|
||||
# and the rest wait on the finished adapter, so no two jobs write the same founder.
|
||||
founders = []
|
||||
for i in range(N):
|
||||
fam = fams[i % L]
|
||||
d = root / "founders" / f"agent{i}_{fam}"
|
||||
_train_founder_locked(d, lambda d=d, fam=fam, i=i: train_specialist(
|
||||
base, fam, str(d), n_train=spec_train, epochs=spec_epochs, seed=seed * 100 + i,
|
||||
hard=hard, r=r, alpha=alpha))
|
||||
founders.append(str(d))
|
||||
|
||||
frames: list[pd.DataFrame] = []
|
||||
for a_idx, arm in enumerate(arms):
|
||||
s = arm_settings(arm, g_val)
|
||||
state = _State(root / arm / "state.json")
|
||||
partial = out_dir / f"partial_{arm}_s{seed}.parquet"
|
||||
rows: list[dict] = []
|
||||
agents, t0 = list(founders), 0
|
||||
st = state.load() if resume else None
|
||||
if st and all(Path(d, "adapter_config.json").exists() for d in st["agents"]):
|
||||
agents, t0 = list(st["agents"]), int(st["generation"])
|
||||
if partial.exists():
|
||||
rows = pd.read_parquet(partial).to_dict("records")
|
||||
print(f"[{arm}] resuming at generation {t0}")
|
||||
|
||||
for t in range(t0, G + 1):
|
||||
rng = np.random.default_rng([seed, a_idx, t]) # resume-safe per-generation RNG
|
||||
conf_pool = _pool(fams, n_conf, seed * 7919 + t * 13, hard)
|
||||
conf_prompts = [x.prompt for x in conf_pool]
|
||||
tag = {"experiment": name, "arm": arm, "seed": seed, "generation": t}
|
||||
|
||||
# ---- (1) produce & score the population
|
||||
model, tok = load_specialists(base, agents)
|
||||
fit_test, fit_val, conf_outs = [], [], []
|
||||
for i in range(N):
|
||||
model.set_adapter(f"a{i}")
|
||||
fit_test.append(fitness_of(generate(model, tok, [x.prompt for x in test]), test, fams))
|
||||
fit_val.append(fitness_of(generate(model, tok, [x.prompt for x in val]), val, fams))
|
||||
conf_outs.append(generate(model, tok, conf_prompts))
|
||||
consensus = consensus_answers(conf_outs)
|
||||
conf = conformity_scores(conf_outs, consensus)
|
||||
dist = behavioural_distance(conf_outs)
|
||||
nov = novelty(dist)
|
||||
cons_acc = float(np.mean([verify(c, x) for c, x in zip(consensus, conf_pool)]))
|
||||
fitness = np.array([fv["overall"] for fv in fit_val])
|
||||
scores = s["g"] * fitness + (1.0 - s["g"]) * conf
|
||||
|
||||
for i in range(N):
|
||||
b = _row(tag, agent=i, role="population", parents=Path(agents[i]).name,
|
||||
selected=True, conformity=float(conf[i]), score=float(scores[i]))
|
||||
for k, v in fit_test[i].items():
|
||||
rows.append(_row(b, metric=f"test_{k}", value=v))
|
||||
rows.append(_row(b, metric="val_overall", value=float(fitness[i])))
|
||||
rows.append(_row(b, metric="novelty", value=float(nov[i])))
|
||||
summ = _row(tag, agent=-1, role="summary", parents="", selected=False,
|
||||
conformity=float("nan"), score=float("nan"))
|
||||
rows.append(_row(summ, metric="consensus_acc", value=cons_acc))
|
||||
rows.append(_row(summ, metric="diversity_behav", value=float(nov.mean())))
|
||||
rows.append(_row(summ, metric="families_alive", value=float(families_alive(fit_test, fams))))
|
||||
rows.append(_row(summ, metric="gap_conformity_minus_truth",
|
||||
value=float(conf.mean() - np.mean([f["overall"] for f in fit_test]))))
|
||||
if t == G:
|
||||
del model; torch.cuda.empty_cache()
|
||||
break
|
||||
|
||||
# ---- (2)-(3) mating plan and each child's inheritance data
|
||||
inherit = _pool(fams, k_inh, seed * 104729 + t * 17, hard)
|
||||
inh_prompts = [x.prompt for x in inherit]
|
||||
child_src: list[dict] = []
|
||||
if s["sex"] == "union":
|
||||
plan = mating_plan(dist, N, max_use=max_use)
|
||||
needed = sorted({p for ab in plan for p in ab})
|
||||
ans, cf = {}, {}
|
||||
for p in needed: # each parent answers the pool once
|
||||
model.set_adapter(f"a{p}")
|
||||
ans[p], cf[p] = generate_with_confidence(model, tok, inh_prompts)
|
||||
for pa, pb in plan:
|
||||
routed, src = route_union(ans[pa], cf[pa], ans[pb], cf[pb])
|
||||
keep = np.maximum(cf[pa], cf[pb]) >= conf_gate if conf_gate is not None else None
|
||||
child_src.append({"parents": (pa, pb), "answers": routed,
|
||||
"share_b": float(src.mean()), "keep": keep})
|
||||
elif s["sex"] == "linear":
|
||||
from .directed import sample_merge_weights
|
||||
plan = mating_plan(dist, N, max_use=max_use)
|
||||
for c, (pa, pb) in enumerate(plan):
|
||||
w = sample_merge_weights(2, n_cand, rng)
|
||||
best_i, best_v = 0, -np.inf
|
||||
for ci in range(n_cand):
|
||||
cname = f"g{t}c{c}k{ci}"
|
||||
model.add_weighted_adapter([f"a{pa}", f"a{pb}"], w[ci].tolist(), cname,
|
||||
combination_type="linear")
|
||||
model.set_adapter(cname)
|
||||
if s["g"] > 0:
|
||||
v = fitness_of(generate(model, tok, [x.prompt for x in val]), val,
|
||||
fams)["overall"]
|
||||
else:
|
||||
oc = generate(model, tok, conf_prompts)
|
||||
v = float(np.mean([_normalise(o) == cc for o, cc in zip(oc, consensus)]))
|
||||
if v > best_v:
|
||||
best_i, best_v = ci, v
|
||||
model.set_adapter(f"a{pa}"); model.delete_adapter(cname)
|
||||
cname = f"g{t}c{c}win"
|
||||
model.add_weighted_adapter([f"a{pa}", f"a{pb}"], w[best_i].tolist(), cname,
|
||||
combination_type="linear")
|
||||
model.set_adapter(cname)
|
||||
answers = generate(model, tok, inh_prompts)
|
||||
model.set_adapter(f"a{pa}"); model.delete_adapter(cname)
|
||||
child_src.append({"parents": (pa, pb), "answers": answers, "share_b": float("nan")})
|
||||
else: # no_sex: single parent, score-proportional
|
||||
picks = [choose_single_parent(scores, rng) for _ in range(N)]
|
||||
ans, cf = {}, {}
|
||||
for p in sorted(set(picks)):
|
||||
model.set_adapter(f"a{p}")
|
||||
ans[p], cf[p] = generate_with_confidence(model, tok, inh_prompts)
|
||||
for p in picks:
|
||||
keep = cf[p] >= conf_gate if conf_gate is not None else None
|
||||
child_src.append({"parents": (p, p), "answers": ans[p], "share_b": float("nan"),
|
||||
"keep": keep})
|
||||
|
||||
# ---- source diagnostics (H6): what each child was *supplied*, per family
|
||||
for c, src in enumerate(child_src):
|
||||
supplied = fitness_of(src["answers"], inherit, fams)
|
||||
b = _row(tag, agent=c, role="child_source", parents=f"{src['parents'][0]}+{src['parents'][1]}",
|
||||
selected=False, conformity=float("nan"), score=float("nan"))
|
||||
for k, v in supplied.items():
|
||||
rows.append(_row(b, metric=f"source_{k}", value=v))
|
||||
rows.append(_row(b, metric="source_share_b", value=src["share_b"]))
|
||||
del model; torch.cuda.empty_cache()
|
||||
|
||||
# ---- (4) inherit: a fresh LoRA per child on its source's own answers
|
||||
children, degenerate = [], 0
|
||||
for c, src in enumerate(child_src):
|
||||
keep = src.get("keep")
|
||||
if keep is None:
|
||||
keep = np.ones(len(inherit), dtype=bool)
|
||||
data = [Task(x.family, x.prompt, answer_of(a))
|
||||
for x, a, kp in zip(inherit, src["answers"], keep) if kp and answer_of(a)]
|
||||
rows.append(_row(tag, agent=c, role="child_source", parents=f"{src['parents'][0]}+{src['parents'][1]}",
|
||||
selected=False, conformity=float("nan"), score=float("nan"),
|
||||
metric="n_inherit_kept", value=float(len(data))))
|
||||
d = root / arm / f"gen{t + 1}" / f"child{c}"
|
||||
if len(data) >= 8:
|
||||
train_lora_on_tasks(base, data, str(d), epochs=epochs,
|
||||
seed=seed * 31 + t * N + c, r=r, alpha=alpha)
|
||||
else: # terminal degeneration: inherit unchanged
|
||||
shutil.copytree(agents[src["parents"][0]], d, dirs_exist_ok=True); degenerate += 1
|
||||
children.append(str(d))
|
||||
|
||||
# ---- (5) survive: score children against the PARENTS' consensus; keep top N of 2N
|
||||
model, tok = load_specialists(base, agents + children)
|
||||
c_test, c_val, c_conf = [], [], []
|
||||
for j in range(N):
|
||||
model.set_adapter(f"a{N + j}")
|
||||
c_test.append(fitness_of(generate(model, tok, [x.prompt for x in test]), test, fams))
|
||||
c_val.append(fitness_of(generate(model, tok, [x.prompt for x in val]), val, fams))
|
||||
c_conf.append(generate(model, tok, conf_prompts))
|
||||
del model; torch.cuda.empty_cache()
|
||||
conf_c = conformity_scores(c_conf, consensus)
|
||||
fit_c = np.array([fv["overall"] for fv in c_val])
|
||||
scores_c = s["g"] * fit_c + (1.0 - s["g"]) * conf_c
|
||||
pool_scores = np.concatenate([scores, scores_c])
|
||||
pool_dist = behavioural_distance(conf_outs + c_conf)
|
||||
keep = pooled_survival(pool_scores, pool_dist, N, lam=lam if s["diversity"] else 0.0)
|
||||
keep_set = set(keep)
|
||||
|
||||
for j in range(N):
|
||||
b = _row(tag, agent=j, role="child",
|
||||
parents=f"{child_src[j]['parents'][0]}+{child_src[j]['parents'][1]}",
|
||||
selected=(N + j) in keep_set, conformity=float(conf_c[j]),
|
||||
score=float(scores_c[j]))
|
||||
for k, v in c_test[j].items():
|
||||
rows.append(_row(b, metric=f"test_{k}", value=v))
|
||||
rows.append(_row(b, metric="val_overall", value=float(fit_c[j])))
|
||||
rows.append(_row(summ, metric="best_newborn_overall",
|
||||
value=float(max(f["overall"] for f in c_test))))
|
||||
rows.append(_row(summ, metric="n_degenerate", value=float(degenerate)))
|
||||
rows.append(_row(summ, metric="n_parents_survive",
|
||||
value=float(sum(1 for k in keep if k < N))))
|
||||
|
||||
pool_dirs = agents + children
|
||||
survivors = [pool_dirs[k] for k in keep]
|
||||
# disk hygiene: drop non-survivors (founders are shared across arms — keep them)
|
||||
for k, d in enumerate(pool_dirs):
|
||||
if k not in keep_set and not d.startswith(str(root / "founders")):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
agents = survivors
|
||||
state.save(t + 1, agents)
|
||||
pd.DataFrame(rows).to_parquet(partial, index=False)
|
||||
print(f"[{arm}] gen {t + 1}/{G}: best {max(f['overall'] for f in fit_test):.3f} "
|
||||
f"newborn {max(f['overall'] for f in c_test):.3f} cons {cons_acc:.2f} "
|
||||
f"alive {families_alive(fit_test, fams)} deg {degenerate}", flush=True)
|
||||
|
||||
frames.append(pd.DataFrame(rows))
|
||||
return pd.concat(frames, ignore_index=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue