llm_directed: directed sex (breed offspring + select on verifier) — E10 in real weights
Adds the "directed sex" operator (E10) the moe regime-flip pointed to: don't
commit to one a-priori blend — breed a population of recombinant offspring
(specialists merged at Dirichlet-sampled weights), score each on a held-out
validation split with the verifier, and keep the fittest, reported on a fresh
test split. Two breeding objectives: best-overall and best-worst-family.
src/llm/directed.py + kind llm_directed, reusing the cached specialists.
Result — refinements pay off in proportion to how far the uniform soup is from
optimal:
- 0.5B (soup dilutes): directed selection beats soup on the bred objective —
directed_overall 0.69 > soup 0.64; directed_balanced worst-family 0.37 > 0.26.
Riders: single-objective selection trades off the other axis (overall-breed
tanks lists to 0.17); a global blend still trails per-input routing (0.74).
- 7B (Imperial CX3, soup already composes to ceiling on near-saturated families,
strings/arith 1.00): directed ~= soup (0.868 ~ 0.873, marginally below via a
val/test overfit gap) — no fitter offspring to breed.
Through-line across all four LLM runs: "merge, don't average" and its refinements
(routing, directed selection) are weak-base / suboptimal-default phenomena — they
help at 0.5B and are inert at 7B. Honest limitation kept in the writeup: the 7B
families are near-saturated, which caps the headroom; a harder unsaturated
benchmark is the fair next test.
Also folds in the two llm_moe local manifest/config files missed in 8da0dac.
+3 directed unit tests (130 green). Results in results/llm_directed{,_hpc}/
(parquet gitignored).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8da0dac007
commit
e433e48860
22 changed files with 602 additions and 2 deletions
72
src/llm/directed.py
Normal file
72
src/llm/directed.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Directed sex in weight space — recombinant offspring + selection on the verifier (E10 in real LLMs).
|
||||
|
||||
`llm_merge` blends the specialists with *one* fixed rule (uniform soup, or ties); `llm_moe` *selects*
|
||||
one intact specialist per input. Both commit to a single recombination *a priori*. Biology can't
|
||||
preview offspring; an AI can — evaluate many recombinants and keep the fittest. This is E10's
|
||||
"directed sex": generate a **population** of offspring by recombining the parents at *different* mixing
|
||||
weights, score each against the verifier ("reality") on a held-out validation split, and select the
|
||||
best. It unifies the two regimes `llm_moe` exposed — fusion *composes* beyond the parents (so we want
|
||||
blends, not pure selection), but the *right* blend is unknown and base-dependent (so we search it and
|
||||
let grounding choose), instead of betting on uniform averaging.
|
||||
|
||||
This module holds the pure, testable pieces — sampling a diverse population of simplex-ish merge
|
||||
weights, and selecting winners from validation scores. The weight-space recombination + evaluation
|
||||
loop lives in :func:`llm.experiment.run_directed_experiment` (it needs the loaded PEFT model).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def sample_merge_weights(k: int, n: int, rng: np.random.Generator, *, concentration: float = 0.5,
|
||||
scale_lo: float = 1.0, scale_hi: float | None = None) -> np.ndarray:
|
||||
"""Sample ``n`` diverse recombination-weight vectors over ``k`` parents (the offspring genotypes).
|
||||
|
||||
Each row is a Dirichlet draw (direction on the simplex) times a random total magnitude, spanning
|
||||
from soup-like (total ≈ 1, balanced blend) to task-arithmetic-like (total ≈ ``k``, additive).
|
||||
``concentration < 1`` biases toward *sparse* mixes (one or two parents dominant) for real
|
||||
diversity — the point of previewing many offspring rather than one average.
|
||||
|
||||
The first two rows are pinned to the canonical baselines for coverage: uniform **soup**
|
||||
(``1/k`` each) and unit **task-arithmetic** (``1`` each); the remaining ``n-2`` are random.
|
||||
|
||||
Args:
|
||||
k (int): number of parents (specialists).
|
||||
n (int): population size (candidates). Must be ≥ 2.
|
||||
rng (np.random.Generator): explicit RNG (seeded upstream via SeedSequence).
|
||||
concentration (float): Dirichlet concentration; < 1 → sparser, specialist-dominant blends.
|
||||
scale_lo (float): minimum total weight magnitude.
|
||||
scale_hi (float | None): maximum total weight magnitude (defaults to ``k``).
|
||||
|
||||
Returns:
|
||||
np.ndarray: ``(n, k)`` float32 merge-weight vectors.
|
||||
"""
|
||||
if n < 2:
|
||||
raise ValueError("need at least 2 candidates (soup + task_arith baselines)")
|
||||
hi = float(k) if scale_hi is None else float(scale_hi)
|
||||
out = np.empty((n, k), dtype=np.float32)
|
||||
out[0] = np.full(k, 1.0 / k) # uniform soup
|
||||
out[1] = np.ones(k) # task arithmetic
|
||||
for i in range(2, n):
|
||||
direction = rng.dirichlet(np.full(k, concentration))
|
||||
total = rng.uniform(scale_lo, hi)
|
||||
out[i] = direction * total
|
||||
return out
|
||||
|
||||
|
||||
def select_winners(val_overall: np.ndarray, val_worst: np.ndarray) -> dict:
|
||||
"""Pick the offspring that maximise validation *overall* and validation *worst-family* accuracy.
|
||||
|
||||
Two selection objectives = two things directed sex can breed for: raw capability, or balance
|
||||
across skills (the Fisher–Muller generalist). Selection is on validation only; the winners are
|
||||
then reported on a fresh test split (no selection-on-test leakage).
|
||||
|
||||
Args:
|
||||
val_overall (np.ndarray): per-candidate validation overall accuracy.
|
||||
val_worst (np.ndarray): per-candidate validation worst-family accuracy.
|
||||
|
||||
Returns:
|
||||
dict: ``{"overall": idx, "balanced": idx}`` candidate indices.
|
||||
"""
|
||||
return {"overall": int(np.argmax(val_overall)), "balanced": int(np.argmax(val_worst))}
|
||||
|
|
@ -22,6 +22,7 @@ import yaml
|
|||
|
||||
from knowledge.experiment import save_artifacts
|
||||
|
||||
from .directed import sample_merge_weights, select_winners
|
||||
from .evaluate import evaluate, generate, load_model
|
||||
from .merge import load_specialists, make_merge
|
||||
from .moe import build_max_merge, embed_prompts, learned_routes, moe_generate
|
||||
|
|
@ -184,7 +185,74 @@ def run_moe_experiment(cfg: dict) -> pd.DataFrame:
|
|||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
_RUNNERS = {"llm_merge": run_merge_experiment, "llm_moe": run_moe_experiment}
|
||||
def run_directed_experiment(cfg: dict) -> pd.DataFrame:
|
||||
"""Directed sex (E10) in weight space: breed many recombinant offspring, select the fittest.
|
||||
|
||||
Generates a population of weighted merges of the specialists, scores each on a held-out
|
||||
*validation* split with the verifier (grounding = "reality that says no"), and keeps the two
|
||||
winners — best validation *overall* and best validation *worst-family* — reporting them on a
|
||||
fresh *test* split alongside the uniform-soup and best-specialist baselines. The claim (E10): an
|
||||
AI can preview offspring and keep the fittest, so directed selection over recombinants beats both
|
||||
the single a-priori blend (soup) and any parent, at either scale.
|
||||
"""
|
||||
import torch
|
||||
|
||||
name = cfg["experiment"]
|
||||
base = cfg["base_model"]
|
||||
fams = list(cfg.get("families", list(FAMILIES)))
|
||||
n_val = int(cfg.get("n_val", 80))
|
||||
n_cand = int(cfg.get("n_candidates", 16))
|
||||
conc = float(cfg.get("concentration", 0.5))
|
||||
seed = int(cfg["seed"])
|
||||
rows: list[dict] = []
|
||||
|
||||
test = test_of(cfg, fams)
|
||||
val = sum([make_tasks(f, n_val, seed=3000 + i) for i, f in enumerate(fams)], [])
|
||||
|
||||
# base + specialists (parents), scored on test
|
||||
m, tok = load_model(base)
|
||||
rows += _rows(name, "base", "base", evaluate(m, tok, test))
|
||||
del m; torch.cuda.empty_cache()
|
||||
dirs = _load_or_train_specialists(cfg, base, fams, name, rows)
|
||||
k = len(dirs)
|
||||
|
||||
# one base with all specialists; breed a population of weighted-merge offspring
|
||||
model, tok = load_specialists(base, dirs)
|
||||
rng = np.random.default_rng(seed) # Layer-2 statistical reproducibility
|
||||
weights = sample_merge_weights(k, n_cand, rng, concentration=conc)
|
||||
adapters = [f"a{i}" for i in range(k)]
|
||||
|
||||
val_overall = np.empty(n_cand)
|
||||
val_worst = np.empty(n_cand)
|
||||
for i in range(n_cand):
|
||||
cname = f"cand{i}"
|
||||
model.add_weighted_adapter(adapters, weights[i].tolist(), cname, combination_type="linear")
|
||||
model.set_adapter(cname)
|
||||
acc = evaluate(model, tok, val) # selection signal (validation only)
|
||||
val_overall[i] = acc["overall"]
|
||||
val_worst[i] = min(acc[f] for f in fams)
|
||||
|
||||
winners = select_winners(val_overall, val_worst) # {"overall": idx, "balanced": idx}
|
||||
|
||||
def _test_acc(cname: str) -> dict:
|
||||
model.set_adapter(cname)
|
||||
outs = generate(model, tok, [t.prompt for t in test])
|
||||
corr = np.array([verify(o, t) for o, t in zip(outs, test)])
|
||||
famv = np.array([t.family for t in test])
|
||||
acc = {"overall": float(corr.mean())}
|
||||
acc.update({f: float(corr[famv == f].mean()) for f in fams})
|
||||
return acc
|
||||
|
||||
# uniform soup baseline is candidate 0 by construction; report it on test for direct comparison
|
||||
rows += _rows(name, "merge_soup", "merge", _test_acc("cand0"))
|
||||
for label, idx in winners.items():
|
||||
rows += _rows(name, f"directed_{label}", "directed", _test_acc(f"cand{idx}"))
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
_RUNNERS = {"llm_merge": run_merge_experiment, "llm_moe": run_moe_experiment,
|
||||
"llm_directed": run_directed_experiment}
|
||||
|
||||
|
||||
def run_and_save(config_path: str | Path) -> Path:
|
||||
|
|
@ -200,6 +268,10 @@ def run_and_save(config_path: str | Path) -> Path:
|
|||
extra = {"layer": "2", "tier": "llm", "base_model": cfg["base_model"]}
|
||||
if kind == "llm_moe":
|
||||
extra["operators"] = list(cfg.get("operators", []))
|
||||
if kind == "llm_directed":
|
||||
extra["directed"] = {"n_candidates": int(cfg.get("n_candidates", 16)),
|
||||
"concentration": float(cfg.get("concentration", 0.5)),
|
||||
"n_val": int(cfg.get("n_val", 80))}
|
||||
save_artifacts(cfg, df, out_dir, extra_libs=("torch", "transformers", "peft"),
|
||||
extra_manifest=extra, grid=None)
|
||||
return out_dir
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue