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 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))}

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