ensemble / bench /run_generalization.py
thefinalboss's picture
v0.3: structural query encoding (patterns + slots) for generalization. Expert.from_qa_pairs now accepts patterns=[...]; questions matching a template are encoded as bind(pattern_hv, slot_hv). On a capitals holdout benchmark, char-level returns empty 100% of the time on unseen slots; structural returns a well-formed answer 100% of the time (+100 pts graceful degradation). Honest: format-generalized guesses by analogy, not factual correctness (no embeddings). 91 tests passing.
27038ac verified
Raw
History Blame Contribute Delete
4.73 kB
"""Benchmark: generalization to unseen slots — char-level vs structural.
Measures the ONE thing the structural encoder is for: does the expert answer
*something well-formed* for a question whose slot was NEVER in training?
Train on capitals of {france, germany, italy, japan, egypt}.
Hold out: {spain, portugal, greece, brazil, norway, india, mexico, kenya}.
Metric: % of holdout questions returning a non-empty answer (the format-
generalization guarantee), char-level vs structural.
Honest framing: this does NOT measure factual correctness (without semantic
embeddings, the system can't know spain's capital is madrid). It measures
whether the system degrades gracefully (well-formed guess) vs silently
(emptiness) on the unknown — a real, meaningful axis of generalization.
Usage:
python bench/run_generalization.py --D 5000 --out results_gen.json
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from ensemble import Expert, Brain
TRAIN = [
("what is the capital of france", "the capital of france is paris"),
("what is the capital of germany", "the capital of germany is berlin"),
("what is the capital of italy", "the capital of italy is rome"),
("what is the capital of japan", "the capital of japan is tokyo"),
("what is the capital of egypt", "the capital of egypt is cairo"),
]
# Ground-truth answers (for the factual-correctness metric, informational)
HOLDOUT = [
("what is the capital of spain", "madrid"),
("what is the capital of portugal", "lisbon"),
("what is the capital of greece", "athens"),
("what is the capital of brazil", "brasilia"),
("what is the capital of norway", "oslo"),
("what is the capital of india", "new delhi"),
("what is the capital of mexico", "mexico city"),
("what is the capital of kenya", "nairobi"),
]
PATTERN = "what is the capital of {country}"
@dataclass
class GenResult:
mode: str # "char" | "structural"
D: int
n_train: int
n_holdout: int
nonempty_rate: float # % holdout with non-empty answer (format guarantee)
avg_len: float # avg answer length (proxy for format richness)
answers: list[tuple[str, str, str]] # (question, truth, got)
def run_mode(mode: str, D: int, repeat: int) -> GenResult:
pairs = TRAIN * repeat
if mode == "structural":
e = Expert.from_qa_pairs(pairs, domain="geo", D=D, patterns=[PATTERN])
else:
e = Expert.from_qa_pairs(pairs, domain="geo", D=D)
brain = Brain()
brain.add_expert(e)
nonempty = 0
total_len = 0
answers = []
for q, truth in HOLDOUT:
got = brain.query(q, max_new_tokens=25).answer.strip()
if got:
nonempty += 1
total_len += len(got)
answers.append((q, truth, got))
n = len(HOLDOUT)
return GenResult(
mode=mode, D=D, n_train=len(pairs), n_holdout=n,
nonempty_rate=nonempty / n,
avg_len=total_len / max(nonempty, 1),
answers=answers,
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--D", type=int, default=5000)
ap.add_argument("--repeat", type=int, default=4, help="training repeats")
ap.add_argument("--out", default="results_gen.json")
args = ap.parse_args()
results = []
for mode in ("char", "structural"):
print(f"\n=== {mode.upper()} (D={args.D}) ===", flush=True)
r = run_mode(mode, args.D, args.repeat)
results.append(r)
print(f" non-empty answers: {r.nonempty_rate:.1%} "
f"({int(r.nonempty_rate*r.n_holdout)}/{r.n_holdout})")
print(f" avg answer length: {r.avg_len:.1f} chars")
for q, truth, got in r.answers:
tag = "OK " if got else "EMPTY"
print(f" [{tag}] {q.split()[-1]:10} truth={truth:12} got={got!r}")
with open(args.out, "w", encoding="utf-8") as f:
json.dump([asdict(r) for r in results], f, indent=2, ensure_ascii=False)
# summary
char = next(r for r in results if r.mode == "char")
struct = next(r for r in results if r.mode == "structural")
print(f"\n{'='*60}")
print(f"GENERALIZATION (format guarantee on unseen slots)")
print(f" char-level non-empty: {char.nonempty_rate:.1%}")
print(f" structural non-empty: {struct.nonempty_rate:.1%}")
delta = struct.nonempty_rate - char.nonempty_rate
print(f" structural lift: +{delta*100:.0f} points")
print(f"{'='*60}")
print(f"\nwrote {args.out}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())