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:
Giorgio Gilestro 2026-07-05 18:35:04 +01:00
parent 8da0dac007
commit e433e48860
22 changed files with 602 additions and 2 deletions

72
src/llm/directed.py Normal file
View 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 FisherMuller 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))}

View file

@ -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