Training-Harness (ACO, Multi-Fidelity), Prüfstand-Benchmark, Agenten-README

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-04 12:47:19 +02:00
parent 8d8f6c8e51
commit 8488737303
14 changed files with 772 additions and 267 deletions

View File

@@ -1,40 +1,66 @@
"""make train: Parameter-Optimierung auf Mini-Themen (Baseline → Screening → Koordinaten-Suche).
"""make train: Ameisen-Optimierung (ACO) der Pipeline-Parameter — anytime, multi-fidelity.
Jeder Trial ist ein Subprozess (train_lauf.py) mit CREATOR_PARAMS im ENV — so binden die
Module die überschriebenen Werte beim Import. Metriken sind deterministisch (qa_report
ohne LLM); gegen Judge-/Lauf-Rauschen gilt: Baseline mit Wiederholung liefert die
Rausch-Schwelle, und eine Übernahme braucht einen BESTÄTIGUNGSLAUF (sonst Random Walk).
Prinzip: Pheromon-Gewichte je (Parameter, Stufe) steuern, welche Kandidaten („Ameisen")
als Nächstes getestet werden. Gute Kandidaten verstärken ihre Stufen, Verdunstung hält
die Suche offen — je länger der Trainer läuft, desto gezielter werden die Tests.
Jederzeit stoppbar; der Stand (beste_params.json/report.md) ist immer aktuell.
CLI: python3 train.py [--trials 40] [--stunden 8] [--sitzung NAME]
Ergebnis: storage/train/<sitzung>/{trials.jsonl, report.md, beste_params.json}
Fidelity-Kaskade pro Kandidat:
F0 Fake-E2E (train_f0.py, Sekunden, 0 Tokens): Invarianten + Struktur-Proxy — Filter.
F1 Frozen-Inventar (train_lauf.py --board2, ~58 min): misst Board-2/Guide-Parameter.
F2 Volllauf inkl. Soll-Abgleich: alle N Runden für den Besten + Inventar-Parameter.
CLI: python3 train.py [--stunden 8] [--trials 60] [--ameisen 3] [--seed 0]
[--sitzung NAME] [--f2-intervall 5]
python3 train.py --init (baut das Frozen-Inventar-Vorlage-Topic, einmalig)
Ergebnis: storage/train/<sitzung>/{trials.jsonl, pheromon.json, report.md, beste_params.json}
"""
import argparse
import asyncio
import hashlib
import json
import os
import random
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from config import STORAGE_DIR
from train_params import PARAMS, schritte
from train_params import PARAMS
HAUPT_THEMA = ("train-sort", "benchmarks/sortierverfahren")
VALIDIER_THEMA = ("train-foto", "benchmarks/fotografie")
VORLAGE_TOPIC = "train-vorlage"
BENCHMARK = "benchmarks/pruefstand"
# Score-Gewichte: Qualität + Auswahl dominieren (Entwicklungsphase), Kosten ziehen ab.
W_NOTE, W_AUSWAHL, W_ZEIT, W_TOKEN = 4.0, 4.0, 1.0, 1.0
RHO = 0.2 # Pheromon-Verdunstung je Runde
SPARSITY = 0.5 # Wahrscheinlichkeit, dass eine Ameise einen Parameter auf Default lässt
F0_CALL_FAKTOR = 1.5 # Struktur-Proxy: mehr als 1.5× Baseline-Calls → Kandidat verworfen
# Trainings-Fixa (Speed, kein Suchraum): Abschluss-QA ohne Judges, kurze Nachzügler-Gnade
TRAIN_FIXA = {"ABSCHLUSS_QA_LLM": 0, "CONSENSUS_GRACE": 60}
def stufen(name: str) -> list[float]:
p = PARAMS[name]
out, w = [], p["min"]
while w <= p["max"] + 1e-9:
out.append(round(w, 4))
w += p["step"]
return out
def score(m: dict, basis: dict) -> float:
"""Skalarer Vergleichswert eines Trials. note 010; auswahl aus den MECE-Quoten;
Zeit/Tokens normiert auf die Baseline (1.0 = Baseline-Kosten)."""
q = m.get("quoten") or {}
qa_ = m.get("quoten_artefakte") or {}
auswahl = 10.0 * max(0.0, 1.0 - min(1.0, (
q.get("dubletten_verdacht", 0) + q.get("luecken", 0) + q.get("fremd", 0)
+ qa_.get("sub_dubletten_verdacht", 0) + qa_.get("verwaiste", 0))))
"""Skalarer Vergleichswert. note 010; auswahl aus Soll-Abgleich (F2) oder MECE-Quoten;
Zeit/Tokens normiert auf die Baseline derselben Fidelity."""
if m.get("soll"):
auswahl = 10.0 * m["soll"]["f1"]
else:
q = m.get("quoten") or {}
qa_ = m.get("quoten_artefakte") or {}
auswahl = 10.0 * max(0.0, 1.0 - min(1.0, (
q.get("dubletten_verdacht", 0) + q.get("luecken", 0) + q.get("fremd", 0)
+ qa_.get("sub_dubletten_verdacht", 0) + qa_.get("verwaiste", 0))))
zeit = (m.get("dauer_min") or 0) / max(basis.get("dauer_min") or 1, 0.1)
tok = _tokens(m) / max(_tokens(basis), 1)
return round(W_NOTE * (m.get("note") or 0) + W_AUSWAHL * auswahl
@@ -46,151 +72,269 @@ def _tokens(m: dict) -> int:
return int(t.get("input") or 0) + int(t.get("output") or 0)
class Trainer:
def __init__(self, sitzung: Path, max_trials: int, max_stunden: float, runner=None):
class AmeisenTrainer:
def __init__(self, sitzung: Path, *, max_trials: int, max_stunden: float, ameisen: int = 3,
seed: int = 0, f2_intervall: int = 5, runner=None, runner_f0=None):
self.dir = sitzung
self.dir.mkdir(parents=True, exist_ok=True)
self.cache_pfad = self.dir / "trials.jsonl"
self.cache: dict[str, dict] = {}
if self.cache_pfad.exists(): # Resume: bezahlte Trials nie wiederholen
for line in self.cache_pfad.read_text(encoding="utf-8").splitlines():
e = json.loads(line)
self.cache[e["key"]] = e["metrics"]
self.rng = random.Random(seed)
self.ameisen = ameisen
self.f2_intervall = max(f2_intervall, 1)
self.max_trials = max_trials
self.deadline = time.monotonic() + max_stunden * 3600
self.gezahlt = 0
self.runner = runner or self._subprozess
self.log = []
self.runner = runner or self._subprozess # (params, fidelity) -> metrics|None
self.runner_f0 = runner_f0 or self._subprozess_f0 # (params) -> {"ok","calls",…}|None
self.log: list[str] = []
self.basis: dict[str, dict] = {} # Fidelity → Baseline-Metriken
self.f0_basis: int | None = None
self.best_params: dict = {}
self.best_score: float | None = None
self.rauschen = 0.5
# Pheromon + Trial-Cache (Resume)
self.pheromon: dict[str, dict[str, float]] = {
n: {str(s): 1.0 for s in stufen(n)} for n in PARAMS}
ph = self.dir / "pheromon.json"
if ph.exists():
gespeichert = json.loads(ph.read_text(encoding="utf-8"))
for n, taus in gespeichert.get("pheromon", {}).items():
if n in self.pheromon:
self.pheromon[n].update({k: float(v) for k, v in taus.items()})
self.best_params = gespeichert.get("best_params", {})
self.best_score = gespeichert.get("best_score")
self.cache_pfad = self.dir / "trials.jsonl"
self.cache: dict[str, dict] = {}
if self.cache_pfad.exists():
for line in self.cache_pfad.read_text(encoding="utf-8").splitlines():
e = json.loads(line)
self.cache[e["key"]] = e["metrics"]
# ── Kandidaten ──────────────────────────────────────────────────────────────────
def kandidat(self, fidelity: str) -> dict:
"""Eine Ameise: je Parameter der Fidelity mit SPARSITY auf Default, sonst
Pheromon-gewichtete Stufe. Sparsame Kandidaten → saubere Attribution."""
params = {}
for name, p in PARAMS.items():
if fidelity == "board2" and p["fidelity"] != "board2":
continue
if self.rng.random() < SPARSITY:
continue
st = stufen(name)
taus = [self.pheromon[name][str(s)] for s in st]
wert = self.rng.choices(st, weights=taus)[0]
if wert != p["default"]:
params[name] = wert
return params
# ── Trial-Ausführung ────────────────────────────────────────────────────────────
def _key(self, params: dict, thema: tuple, tag: str = "") -> str:
raw = json.dumps({"p": params, "t": thema[0], "tag": tag}, sort_keys=True)
def _key(self, params: dict, fidelity: str, tag: str = "") -> str:
raw = json.dumps({"p": params, "f": fidelity, "tag": tag}, sort_keys=True)
return hashlib.md5(raw.encode()).hexdigest()[:12]
async def trial(self, params: dict, thema: tuple = HAUPT_THEMA, tag: str = "") -> dict | None:
"""tag unterscheidet bewusste Wiederholungen (Baseline n=2, Bestätigung)."""
key = self._key(params, thema, tag)
async def trial(self, params: dict, fidelity: str, tag: str = "") -> dict | None:
key = self._key(params, fidelity, tag)
if key in self.cache:
return self.cache[key]
if self.gezahlt >= self.max_trials or time.monotonic() > self.deadline:
return None
self.gezahlt += 1
metrics = await self.runner(params, thema)
metrics = await self.runner(params, fidelity, "0")
if metrics is not None:
with open(self.cache_pfad, "a", encoding="utf-8") as f:
f.write(json.dumps({"key": key, "params": params, "thema": thema[0],
f.write(json.dumps({"key": key, "params": params, "fidelity": fidelity,
"tag": tag, "metrics": metrics}, ensure_ascii=False) + "\n")
self.cache[key] = metrics
return metrics
async def _subprozess(self, params: dict, thema: tuple) -> dict | None:
out = self.dir / f"metrics-{self._key(params, thema)}.json"
env = {"CREATOR_PARAMS": json.dumps(params)}
import os
proc = await asyncio.create_subprocess_exec(
sys.executable, "train_lauf.py", thema[0], thema[1], str(out),
env={**os.environ, **env})
async def _subprozess(self, params: dict, fidelity: str, topic_suffix: str = "0") -> dict | None:
out = self.dir / f"metrics-{self._key(params, fidelity)}{topic_suffix}.json"
topic = f"train-t{topic_suffix}"
args = ([topic, VORLAGE_TOPIC, str(out), "--board2"] if fidelity == "board2"
else [topic, BENCHMARK, str(out)])
env = {**os.environ, "CREATOR_PARAMS": json.dumps({**TRAIN_FIXA, **params})}
proc = await asyncio.create_subprocess_exec(sys.executable, "train_lauf.py", *args, env=env)
rc = await proc.wait()
if rc != 0 or not out.exists():
self._log(f"Trial fehlgeschlagen (rc={rc}, params={params})")
self._log(f"Trial fehlgeschlagen (rc={rc}, {fidelity}, params={params})")
return None
return json.loads(out.read_text(encoding="utf-8"))
async def _subprozess_f0(self, params: dict) -> dict | None:
out = self.dir / f"f0-{self._key(params, 'f0')}.json"
env = {**os.environ, "CREATOR_PARAMS": json.dumps(params)}
proc = await asyncio.create_subprocess_exec(sys.executable, "train_f0.py", str(out), env=env)
rc = await proc.wait()
return json.loads(out.read_text(encoding="utf-8")) if rc == 0 and out.exists() else None
def _log(self, msg: str) -> None:
line = f"{datetime.now(timezone.utc).isoformat()[11:19]} {msg}"
print(line, flush=True)
self.log.append(line)
# ── Trainings-Phasen ────────────────────────────────────────────────────────────
# ── Pheromon ────────────────────────────────────────────────────────────────────
def verstaerke(self, params: dict, delta: float) -> None:
for name, wert in params.items():
taus = self.pheromon[name]
key = str(wert)
if key in taus:
taus[key] += delta
def verdunste(self) -> None:
for taus in self.pheromon.values():
for k in taus:
taus[k] = max(0.1, (1 - RHO) * taus[k] + RHO * 1.0) # Drift zurück zu uniform
# ── Hauptschleife ───────────────────────────────────────────────────────────────
async def run(self) -> dict:
# Phase 0: Baseline zweimal → Score-Basis + Rausch-Schwelle
self._log("Baseline (2 Läufe)…")
b1 = await self.trial({}, tag="baseline-1")
b2 = await self.trial({}, tag="baseline-2")
# Baseline F1 ×2 → Score-Basis + Rausch-Schwelle; F0-Basis für den Struktur-Proxy
f0 = await self.runner_f0({})
self.f0_basis = (f0 or {}).get("calls")
b1 = await self.trial({}, "board2", tag="baseline-1")
b2 = await self.trial({}, "board2", tag="baseline-2")
if not b1 or not b2:
self._log("Baseline unvollständig — Abbruch.")
return {}
self.basis = b1
return self.best_params
self.basis["board2"] = b1
s1, s2 = score(b1, b1), score(b2, b1)
self.rauschen = max(abs(s1 - s2), 0.5) # Mindest-Schwelle gegen Glücks-Übernahmen
best_params: dict = {}
best_score = max(s1, s2)
self._log(f"Baseline-Score {s1}/{s2}, Rausch-Schwelle {self.rauschen}")
self.rauschen = max(abs(s1 - s2), 0.5)
if self.best_score is None:
self.best_score = max(s1, s2)
self._log(f"Baseline {s1}/{s2}, Rauschen {self.rauschen}, F0-Basis {self.f0_basis} Calls")
# Phase 1: Screening — je Parameter ±1 Schritt, Effekt vs. Rauschen
effekte: list[tuple[float, str, float]] = [] # (|effekt|, name, bester_wert)
for name in PARAMS:
lo, hi = schritte(name)
for wert in dict.fromkeys((lo, hi)): # lo==hi am Rand nur einmal
if wert == PARAMS[name]["default"]:
continue
m = await self.trial({**best_params, name: wert})
if m is None:
continue
delta = score(m, self.basis) - best_score
self._log(f"Screening {name}={wert}: Δ{delta:+.2f}")
if delta > self.rauschen:
effekte.append((delta, name, wert))
effekte.sort(reverse=True)
self._log(f"Wirksam: {[(n, w) for _, n, w in effekte]}")
# Phase 2: Koordinaten-Suche über ALLE wirksamen Parameter (keine feste Obergrenze),
# Übernahme nur nach Bestätigungslauf
for _, name, start_wert in effekte:
wert = start_wert
p = PARAMS[name]
richtung = p["step"] if wert > p["default"] else -p["step"]
while True:
kandidat = {**best_params, name: wert}
m = await self.trial(kandidat)
if m is None:
runde, stagnation, gezahlt_vorher = 0, 0, self.gezahlt
while (self.gezahlt < self.max_trials and time.monotonic() < self.deadline
and stagnation < 20): # konvergiert: nur noch Cache-Treffer → fertig
if runde > 0:
stagnation = stagnation + 1 if self.gezahlt == gezahlt_vorher else 0
gezahlt_vorher = self.gezahlt
runde += 1
fidelity = "voll" if runde % self.f2_intervall == 0 else "board2"
if fidelity == "voll" and "voll" not in self.basis:
base = await self.trial({}, "voll", tag="baseline-voll")
if base is None:
break
delta = score(m, self.basis) - best_score
if delta <= self.rauschen:
self.basis["voll"] = base
kandidaten = []
for _ in range(self.ameisen * 3): # ziehen bis K einzigartige nicht-leere da sind
k = self.kandidat(fidelity)
if k and k not in kandidaten and k != self.best_params:
kandidaten.append(k)
if len(kandidaten) >= self.ameisen:
break
m2 = await self.trial(kandidat, tag="bestaetigung")
if m2 is None or score(m2, self.basis) - best_score <= self.rauschen:
self._log(f"{name}={wert}: nicht bestätigt — verworfen")
break
best_params, best_score = kandidat, min(score(m, self.basis), score(m2, self.basis))
self._log(f"ÜBERNOMMEN {name}={wert} → Score {best_score}")
naechster = round(wert + richtung, 4)
if not p["min"] <= naechster <= p["max"]:
break
wert = naechster
if not kandidaten:
continue
# F0-Filter: Invarianten + Struktur-Proxy, parallel, kostenlos
f0s = await asyncio.gather(*[self.runner_f0(k) for k in kandidaten])
ueberlebende = []
for k, f in zip(kandidaten, f0s):
if f is None or not f.get("ok") or f.get("invarianten_fehler"):
self._log(f"F0 verwirft {k} (Invarianten)")
elif self.f0_basis and f.get("calls", 0) > self.f0_basis * F0_CALL_FAKTOR:
self._log(f"F0 verwirft {k} (Calls {f['calls']} > {self.f0_basis}×{F0_CALL_FAKTOR})")
else:
ueberlebende.append(k)
if not ueberlebende:
self.verdunste()
continue
# F1/F2 parallel (eigene Topic-Namen)
ergebnisse = await asyncio.gather(*[
self._bewertet(k, fidelity, str(i + 1)) for i, k in enumerate(ueberlebende)])
bewertet = [(k, m, score(m, self.basis[fidelity]))
for k, m in ergebnisse if m is not None]
if not bewertet:
continue
bewertet.sort(key=lambda x: -x[2])
self.verdunste()
top_k, _top_m, top_s = bewertet[0]
self._log(f"Runde {runde} ({fidelity}): top {top_s} {top_k} "
f"(best {self.best_score})")
if top_s > (self.best_score or 0):
self.verstaerke(top_k, delta=1.0)
if top_s > (self.best_score or 0) + self.rauschen:
m2 = await self.trial(top_k, fidelity, tag="bestaetigung")
if m2 is not None and score(m2, self.basis[fidelity]) > self.best_score + self.rauschen:
self.best_params = top_k
self.best_score = min(top_s, score(m2, self.basis[fidelity]))
self._log(f"NEUER BESTER {self.best_params}{self.best_score}")
else:
self._log(f"{top_k}: nicht bestätigt")
self.verstaerke(self.best_params, delta=0.5) # Elite hält die Spur warm
self._speichern()
self._speichern()
return self.best_params
# Validierung auf dem zweiten Thema
if best_params:
v_base = await self.trial({}, thema=VALIDIER_THEMA, tag="val-base")
v_best = await self.trial(best_params, thema=VALIDIER_THEMA, tag="val-best")
if v_base and v_best:
self._log(f"Validierung {VALIDIER_THEMA[0]}: Baseline {score(v_base, v_base)}"
f" → Best {score(v_best, v_base)}")
async def _bewertet(self, params: dict, fidelity: str, suffix: str):
if self.gezahlt >= self.max_trials or time.monotonic() > self.deadline:
return params, None
key = self._key(params, fidelity)
if key in self.cache:
return params, self.cache[key]
self.gezahlt += 1
m = await self.runner(params, fidelity, suffix)
if m is not None:
with open(self.cache_pfad, "a", encoding="utf-8") as f:
f.write(json.dumps({"key": key, "params": params, "fidelity": fidelity,
"tag": "", "metrics": m}, ensure_ascii=False) + "\n")
self.cache[key] = m
return params, m
self._schreibe_report(best_params, best_score)
return best_params
def _schreibe_report(self, best_params: dict, best_score: float) -> None:
def _speichern(self) -> None:
from fsutil import atomic_write_json, atomic_write_text
atomic_write_json(self.dir / "beste_params.json", best_params, indent=1)
report = ["# Trainings-Report", "",
f"Trials bezahlt: {self.gezahlt}/{self.max_trials}",
f"Bester Score: {best_score} (Baseline-Rauschen {self.rauschen})",
f"Beste Parameter: `{json.dumps(best_params, ensure_ascii=False)}`",
"", "Nutzung: `CREATOR_PARAMS=$(cat beste_params.json) make dev` —",
atomic_write_json(self.dir / "pheromon.json",
{"pheromon": self.pheromon, "best_params": self.best_params,
"best_score": self.best_score}, indent=1)
atomic_write_json(self.dir / "beste_params.json", self.best_params, indent=1)
staerkste = sorted(((n, max(t.items(), key=lambda x: x[1]))
for n, t in self.pheromon.items()),
key=lambda x: -x[1][1])[:10]
report = ["# Trainings-Report (Ameisen)", "",
f"Bezahlte Läufe: {self.gezahlt}/{self.max_trials}",
f"Bester Score: {self.best_score} (Rauschband {self.rauschen})",
f"Beste Parameter: `{json.dumps(self.best_params, ensure_ascii=False)}`",
"", "Stärkste Pheromon-Spuren:",
*[f"- {n}={s} (τ={t:.1f})" for n, (s, t) in staerkste],
"", "Nutzung: `CREATOR_PARAMS=$(cat beste_params.json)` —",
"Übernahme nach config.py bleibt eine manuelle Entscheidung.", "", "## Log", ""]
report += [f"- {l}" for l in self.log]
report += [f"- {l}" for l in self.log[-200:]]
atomic_write_text(self.dir / "report.md", "\n".join(report))
print(f"\nReport: {self.dir / 'report.md'}")
async def init_vorlage() -> None:
"""Einmalig: Prüfstand-Volllauf mit Defaults, Ergebnis bleibt als Frozen-Inventar-Vorlage
liegen (Topic train-vorlage). Nach Korpus-/Prompt-Änderungen neu ausführen."""
import agents
import database
from blocks import generate_blocks
from fsutil import atomic_write_json as awj
from paths import source_path
await database.init_db()
agents.on_event = database.add_event
await database.create_topic(VORLAGE_TOPIC)
qp = source_path(VORLAGE_TOPIC)
qp.parent.mkdir(parents=True, exist_ok=True)
awj(qp, {"type": "uni", "location": BENCHMARK, "spec": ""})
await generate_blocks(VORLAGE_TOPIC, provider="minimax", research=True, qa_force=True)
await database.close_db()
print(f"Vorlage {VORLAGE_TOPIC} steht — Training kann starten (make train).")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--trials", type=int, default=40)
ap.add_argument("--stunden", type=float, default=12.0)
ap.add_argument("--sitzung", default=datetime.now(timezone.utc).strftime("%Y%m%d-%H%M"))
ap.add_argument("--init", action="store_true", help="Frozen-Inventar-Vorlage bauen")
ap.add_argument("--trials", type=int, default=60)
ap.add_argument("--stunden", type=float, default=8.0)
ap.add_argument("--ameisen", type=int, default=3)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--f2-intervall", type=int, default=5)
ap.add_argument("--sitzung", default="aco") # fester Default: Resume über Sitzungen hinweg
args = ap.parse_args()
trainer = Trainer(STORAGE_DIR / "train" / args.sitzung, args.trials, args.stunden)
if args.init:
asyncio.run(init_vorlage())
return
trainer = AmeisenTrainer(STORAGE_DIR / "train" / args.sitzung,
max_trials=args.trials, max_stunden=args.stunden,
ameisen=args.ameisen, seed=args.seed,
f2_intervall=args.f2_intervall)
asyncio.run(trainer.run())