feat(eval): add forge_evaluator, eval_policy, compare_eval for issue #153
- ml/eval/forge_evaluator.py: reusable eval harness wrapping
MultiThreadBatchRunner + PolicyInferenceService; collects win rate,
avg turns, life differential, commander damage, illegal action rate,
policy entropy per game; writes results/eval-{run_id}.json
- scripts/eval_policy.py: CLI to run N Forge eval games with any
checkpoint vs forge-builtin or policy-server opponent; supports
--threads, --clock, --seed, --greedy, --temperature
- scripts/compare_eval.py: loads two result JSONs, computes absolute
win-rate delta + 95% CI, runs Fisher's exact test for significance,
prints formatted comparison table and pass/fail verdict
This commit is contained in:
1 parent
b1c17359f0
commit
bff7b3a81e
3 files changed
+983
No files matched your search
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Commander AI Lab — Compare Eval Results (Issue #153)
|
||||
═════════════════════════════════════════════════════
|
||||
Loads two eval result JSONs produced by eval_policy.py, computes
|
||||
win-rate delta, 95% CI, and Fisher's exact test for significance.
|
||||
|
||||
Usage:
|
||||
python scripts/compare_eval.py \
|
||||
--baseline results/eval-baseline.json \
|
||||
--forge results/eval-forge.json
|
||||
|
||||
# Custom threshold
|
||||
python scripts/compare_eval.py \
|
||||
--baseline results/eval-baseline.json \
|
||||
--forge results/eval-forge.json \
|
||||
--min-delta 0.05 \
|
||||
--alpha 0.05
|
||||
|
||||
Exit codes:
|
||||
0 — Forge model passes (delta >= min-delta AND p < alpha)
|
||||
1 — Forge model fails (delta below threshold OR not significant)
|
||||
2 — Input error (missing files, bad JSON)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Statistics helpers
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def wilson_ci(wins: int, n: int, z: float = 1.96):
|
||||
"""Wilson score interval for a proportion."""
|
||||
if n == 0:
|
||||
return 0.0, 0.0
|
||||
p = wins / n
|
||||
denom = 1 + z ** 2 / n
|
||||
centre = (p + z ** 2 / (2 * n)) / denom
|
||||
margin = (z * math.sqrt(p * (1 - p) / n + z ** 2 / (4 * n ** 2))) / denom
|
||||
return max(0.0, centre - margin), min(1.0, centre + margin)
|
||||
|
||||
|
||||
def fishers_exact(a_wins, a_n, b_wins, b_n):
|
||||
"""
|
||||
Two-tailed Fisher's exact test.
|
||||
Contingency table:
|
||||
[[a_wins, a_n - a_wins],
|
||||
[b_wins, b_n - b_wins]]
|
||||
|
||||
Returns (odds_ratio, p_value).
|
||||
Uses scipy if available, otherwise a pure-Python hypergeometric
|
||||
approximation suitable for n >= 50.
|
||||
"""
|
||||
try:
|
||||
from scipy.stats import fisher_exact
|
||||
table = [
|
||||
[a_wins, a_n - a_wins],
|
||||
[b_wins, b_n - b_wins],
|
||||
]
|
||||
oddsratio, pvalue = fisher_exact(table, alternative="two-sided")
|
||||
return float(oddsratio), float(pvalue)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Pure-Python fallback: hypergeometric tail sum (two-tailed)
|
||||
# Works well when all cell counts >= 5
|
||||
def _log_comb(n, k):
|
||||
if k < 0 or k > n:
|
||||
return -math.inf
|
||||
return (
|
||||
sum(math.log(n - i) for i in range(k))
|
||||
- sum(math.log(i + 1) for i in range(k))
|
||||
)
|
||||
|
||||
N = a_n + b_n
|
||||
K = a_wins + b_wins # total wins
|
||||
n = a_n # first group size
|
||||
k_obs = a_wins
|
||||
|
||||
log_denom = _log_comb(N, K)
|
||||
p_obs = math.exp(_log_comb(n, k_obs) + _log_comb(N - n, K - k_obs) - log_denom)
|
||||
|
||||
p_val = 0.0
|
||||
k_lo = max(0, K - b_n)
|
||||
k_hi = min(K, n)
|
||||
for k in range(k_lo, k_hi + 1):
|
||||
p_k = math.exp(_log_comb(n, k) + _log_comb(N - n, K - k) - log_denom)
|
||||
if p_k <= p_obs + 1e-10:
|
||||
p_val += p_k
|
||||
|
||||
a_loss = a_n - a_wins
|
||||
b_loss = b_n - b_wins
|
||||
or_val = (
|
||||
(a_wins * b_loss / (a_loss * b_wins))
|
||||
if a_loss > 0 and b_wins > 0
|
||||
else float("inf")
|
||||
)
|
||||
return or_val, min(p_val, 1.0)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# JSON loader
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def load_result(path: str) -> dict:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
print(f"ERROR: File not found: {path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(p) as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"ERROR: Invalid JSON in {path}: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Accept both {summary: {...}} and flat {win_rate: ...} shapes
|
||||
if "summary" in data:
|
||||
return data["summary"]
|
||||
return data
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Comparison table printer
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def fmt_pct(v) -> str:
|
||||
try:
|
||||
return f"{float(v):.1%}"
|
||||
except (TypeError, ValueError):
|
||||
return str(v)
|
||||
|
||||
|
||||
def fmt_f(v, dp=2) -> str:
|
||||
try:
|
||||
return f"{float(v):.{dp}f}"
|
||||
except (TypeError, ValueError):
|
||||
return str(v)
|
||||
|
||||
|
||||
def print_comparison(b: dict, f: dict, delta: float, ci_lo: float, ci_hi: float,
|
||||
odds: float, pval: float, min_delta: float, alpha: float):
|
||||
W = 62
|
||||
SEP = "─" * W
|
||||
|
||||
def row(label, bval, fval, delta_str=""):
|
||||
print(f" {label:<28} {bval:>10} {fval:>10} {delta_str:>8}")
|
||||
|
||||
print()
|
||||
print("=" * W)
|
||||
print(" COMMANDER AI LAB — EVAL COMPARISON")
|
||||
print("=" * W)
|
||||
print(f" Baseline : {b.get('checkpoint_path', b.get('run_id', '?'))}")
|
||||
print(f" Forge : {f.get('checkpoint_path', f.get('run_id', '?'))}")
|
||||
print(SEP)
|
||||
print(f" {'Metric':<28} {'Baseline':>10} {'Forge':>10} {'Delta':>8}")
|
||||
print(SEP)
|
||||
|
||||
b_n, f_n = int(b.get("num_games", 0)), int(f.get("num_games", 0))
|
||||
b_w, f_w = int(b.get("wins", 0)), int(f.get("wins", 0))
|
||||
|
||||
row("Games", str(b_n), str(f_n))
|
||||
row("Wins", str(b_w), str(f_w), f"{f_w - b_w:+d}")
|
||||
row("Win rate", fmt_pct(b.get("win_rate")), fmt_pct(f.get("win_rate")),
|
||||
f"{delta:+.1%}")
|
||||
|
||||
b_lo, b_hi = wilson_ci(b_w, b_n)
|
||||
f_lo, f_hi = wilson_ci(f_w, f_n)
|
||||
row(" 95% CI",
|
||||
f"[{fmt_pct(b_lo)}, {fmt_pct(b_hi)}]",
|
||||
f"[{fmt_pct(f_lo)}, {fmt_pct(f_hi)}]",
|
||||
)
|
||||
row(" Δ 95% CI", "", "", f"[{ci_lo:+.1%}, {ci_hi:+.1%}]")
|
||||
print(SEP)
|
||||
row("Avg turns",
|
||||
fmt_f(b.get("avg_turns")),
|
||||
fmt_f(f.get("avg_turns")),
|
||||
fmt_f(float(f.get("avg_turns", 0)) - float(b.get("avg_turns", 0)), 1))
|
||||
row("Avg life Δ",
|
||||
fmt_f(b.get("avg_life_delta")),
|
||||
fmt_f(f.get("avg_life_delta")),
|
||||
fmt_f(float(f.get("avg_life_delta", 0)) - float(b.get("avg_life_delta", 0)), 1))
|
||||
row("Avg cmd dmg",
|
||||
fmt_f(b.get("avg_commander_damage")),
|
||||
fmt_f(f.get("avg_commander_damage")),
|
||||
fmt_f(float(f.get("avg_commander_damage", 0)) - float(b.get("avg_commander_damage", 0)), 1))
|
||||
row("Illegal acts/g",
|
||||
fmt_f(b.get("avg_illegal_actions")),
|
||||
fmt_f(f.get("avg_illegal_actions")),
|
||||
fmt_f(float(f.get("avg_illegal_actions", 0)) - float(b.get("avg_illegal_actions", 0)), 2))
|
||||
row("Avg entropy",
|
||||
fmt_f(b.get("avg_entropy"), 4),
|
||||
fmt_f(f.get("avg_entropy"), 4),
|
||||
fmt_f(float(f.get("avg_entropy", 0)) - float(b.get("avg_entropy", 0)), 4))
|
||||
row("Avg infer ms",
|
||||
fmt_f(b.get("avg_inference_ms"), 1),
|
||||
fmt_f(f.get("avg_inference_ms"), 1),
|
||||
fmt_f(float(f.get("avg_inference_ms", 0)) - float(b.get("avg_inference_ms", 0)), 1))
|
||||
print(SEP)
|
||||
|
||||
# Significance block
|
||||
print(f" Fisher's exact test (two-tailed)")
|
||||
print(f" odds ratio : {odds:.4f}")
|
||||
print(f" p-value : {pval:.6f}")
|
||||
print(f" alpha : {alpha}")
|
||||
print(SEP)
|
||||
|
||||
# Pass / fail verdict
|
||||
delta_pass = delta >= min_delta
|
||||
sig_pass = pval < alpha
|
||||
passed = delta_pass and sig_pass
|
||||
|
||||
if passed:
|
||||
verdict = "PASS ✓ Forge model outperforms baseline"
|
||||
elif not delta_pass and not sig_pass:
|
||||
verdict = f"FAIL ✗ delta ({delta:+.1%}) < {min_delta:.1%} AND not significant (p={pval:.4f})"
|
||||
elif not delta_pass:
|
||||
verdict = f"FAIL ✗ delta ({delta:+.1%}) < threshold ({min_delta:.1%})"
|
||||
else:
|
||||
verdict = f"FAIL ✗ not statistically significant (p={pval:.4f} >= alpha={alpha})"
|
||||
|
||||
print(f" {verdict}")
|
||||
print("=" * W)
|
||||
print()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(
|
||||
description="Compare two eval result JSONs from eval_policy.py.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
p.add_argument("--baseline", required=True, help="Baseline (synthetic) eval JSON")
|
||||
p.add_argument("--forge", required=True, help="Forge-trained eval JSON")
|
||||
p.add_argument("--min-delta", type=float, default=0.05,
|
||||
help="Minimum absolute win-rate improvement to pass")
|
||||
p.add_argument("--alpha", type=float, default=0.05,
|
||||
help="Significance level for Fisher's exact test")
|
||||
p.add_argument("--json-out", default="",
|
||||
help="Optional: write comparison result to this JSON file")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
b = load_result(args.baseline)
|
||||
f = load_result(args.forge)
|
||||
|
||||
b_n, f_n = int(b.get("num_games", 0)), int(f.get("num_games", 0))
|
||||
b_w, f_w = int(b.get("wins", 0)), int(f.get("wins", 0))
|
||||
b_wr = b_w / max(b_n, 1)
|
||||
f_wr = f_w / max(f_n, 1)
|
||||
|
||||
delta = f_wr - b_wr
|
||||
|
||||
# Delta 95% CI via normal approximation
|
||||
se_b = math.sqrt(b_wr * (1 - b_wr) / max(b_n, 1))
|
||||
se_f = math.sqrt(f_wr * (1 - f_wr) / max(f_n, 1))
|
||||
se_delta = math.sqrt(se_b ** 2 + se_f ** 2)
|
||||
ci_lo = delta - 1.96 * se_delta
|
||||
ci_hi = delta + 1.96 * se_delta
|
||||
|
||||
odds, pval = fishers_exact(b_w, b_n, f_w, f_n)
|
||||
|
||||
print_comparison(b, f, delta, ci_lo, ci_hi, odds, pval, args.min_delta, args.alpha)
|
||||
|
||||
if args.json_out:
|
||||
out = {
|
||||
"baseline": args.baseline,
|
||||
"forge": args.forge,
|
||||
"baseline_win_rate": b_wr,
|
||||
"forge_win_rate": f_wr,
|
||||
"delta": delta,
|
||||
"delta_ci95_low": ci_lo,
|
||||
"delta_ci95_high": ci_hi,
|
||||
"odds_ratio": odds,
|
||||
"p_value": pval,
|
||||
"alpha": args.alpha,
|
||||
"min_delta": args.min_delta,
|
||||
"passed": delta >= args.min_delta and pval < args.alpha,
|
||||
}
|
||||
Path(args.json_out).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(args.json_out, "w") as jf:
|
||||
json.dump(out, jf, indent=2)
|
||||
print(f"Comparison JSON written to: {args.json_out}")
|
||||
|
||||
passed = delta >= args.min_delta and pval < args.alpha
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Commander AI Lab — Eval Policy Script (Issue #153)
|
||||
═══════════════════════════════════════════════════
|
||||
Run N Forge evaluation games with a given checkpoint and write results.
|
||||
|
||||
Usage:
|
||||
python scripts/eval_policy.py \
|
||||
--model ml/models/checkpoints/forge-trained/final.pt \
|
||||
--games 200 \
|
||||
--run-id forge-trained \
|
||||
--out results/eval-forge.json
|
||||
|
||||
python scripts/eval_policy.py \
|
||||
--model ml/models/checkpoints/baseline-synthetic/final.pt \
|
||||
--games 200 \
|
||||
--run-id baseline-synthetic \
|
||||
--out results/eval-baseline.json \
|
||||
--threads 4 \
|
||||
--seed 42
|
||||
|
||||
Opts:
|
||||
--model PATH Path to .pt checkpoint [required]
|
||||
--games N Number of eval games [default: 200]
|
||||
--run-id ID Label for this run [default: derived from --model]
|
||||
--out PATH Output JSON path [default: results/eval-{run-id}.json]
|
||||
--forge-jar PATH Forge JAR path [default: auto-detect]
|
||||
--forge-dir DIR Forge working directory [default: forge]
|
||||
--decks D [D ...] Deck .dck files to use (2-4) [default: first 2 found in decks/]
|
||||
--threads N Parallel Forge workers [default: 4]
|
||||
--clock N Per-game clock limit in seconds [default: 120]
|
||||
--seed N RNG seed for reproducibility
|
||||
--greedy Use greedy argmax inference (no sampling)
|
||||
--temperature T Softmax temperature [default: 1.0]
|
||||
--device DEVICE torch device (cpu, cuda, mps) [default: auto]
|
||||
--embeddings DIR Embeddings directory [default: embeddings]
|
||||
--results-dir DIR Results output directory [default: results]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = str(Path(__file__).parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("eval_policy")
|
||||
|
||||
|
||||
def find_decks(deck_dir: str, n: int = 2) -> list:
|
||||
"""Auto-discover .dck files under deck_dir."""
|
||||
p = Path(deck_dir)
|
||||
if not p.exists():
|
||||
return []
|
||||
files = sorted(p.glob("**/*.dck"))[:n]
|
||||
return [str(f) for f in files]
|
||||
|
||||
|
||||
def derive_run_id(model_path: str) -> str:
|
||||
"""Derive a readable run ID from the checkpoint path."""
|
||||
p = Path(model_path)
|
||||
# e.g. ml/models/checkpoints/forge-trained/final.pt -> forge-trained
|
||||
parts = p.parts
|
||||
if len(parts) >= 2:
|
||||
return parts[-2]
|
||||
return p.stem
|
||||
|
||||
|
||||
def auto_forge_jar(search_dirs=None) -> str:
|
||||
"""Find the Forge JAR by scanning common locations."""
|
||||
candidates = [
|
||||
"forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar",
|
||||
"forge/forge-gui-desktop.jar",
|
||||
"forge.jar",
|
||||
]
|
||||
dirs = search_dirs or [".", "forge", "lib"]
|
||||
for d in dirs:
|
||||
for c in candidates:
|
||||
path = os.path.join(d, c)
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
# Also glob
|
||||
for jar in Path(".").rglob("forge-gui-desktop*.jar"):
|
||||
return str(jar)
|
||||
return ""
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Run Forge eval games for a policy checkpoint.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
p.add_argument("--model", required=True, help="Path to .pt checkpoint")
|
||||
p.add_argument("--games", type=int, default=200)
|
||||
p.add_argument("--run-id", default="")
|
||||
p.add_argument("--out", default="")
|
||||
p.add_argument("--forge-jar", default="")
|
||||
p.add_argument("--forge-dir", default="forge")
|
||||
p.add_argument("--decks", nargs="+", default=[])
|
||||
p.add_argument("--threads", type=int, default=4)
|
||||
p.add_argument("--clock", type=int, default=120)
|
||||
p.add_argument("--seed", type=int, default=None)
|
||||
p.add_argument("--greedy", action="store_true")
|
||||
p.add_argument("--temperature", type=float, default=1.0)
|
||||
p.add_argument("--device", default=None)
|
||||
p.add_argument("--embeddings", default="embeddings")
|
||||
p.add_argument("--results-dir", default="results")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
# ── Resolve run ID
|
||||
run_id = args.run_id or derive_run_id(args.model)
|
||||
|
||||
# ── Resolve output path
|
||||
out_path = args.out or os.path.join(args.results_dir, f"eval-{run_id}.json")
|
||||
|
||||
# ── Resolve Forge JAR
|
||||
forge_jar = args.forge_jar or auto_forge_jar()
|
||||
if not forge_jar:
|
||||
logger.warning("Forge JAR not found — synthetic fallback will be used.")
|
||||
|
||||
# ── Resolve decks
|
||||
deck_files = args.decks
|
||||
if not deck_files:
|
||||
deck_files = find_decks("decks", n=2)
|
||||
if not deck_files:
|
||||
logger.warning("No .dck files found — synthetic fallback will be used.")
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Eval run : %s", run_id)
|
||||
logger.info("Checkpoint: %s", args.model)
|
||||
logger.info("Games : %d", args.games)
|
||||
logger.info("Threads : %d", args.threads)
|
||||
logger.info("Forge JAR : %s", forge_jar or "(not found)")
|
||||
logger.info("Decks : %s", deck_files or "(none — synthetic)")
|
||||
logger.info("Output : %s", out_path)
|
||||
logger.info("=" * 60)
|
||||
|
||||
# ── Import and run evaluator
|
||||
from ml.eval.forge_evaluator import EvalConfig, ForgeEvaluator
|
||||
|
||||
cfg = EvalConfig(
|
||||
checkpoint_path=args.model,
|
||||
embeddings_dir=args.embeddings,
|
||||
device=args.device,
|
||||
forge_jar=forge_jar,
|
||||
forge_work_dir=args.forge_dir,
|
||||
deck_files=deck_files,
|
||||
deck_names=[Path(f).stem for f in deck_files],
|
||||
num_games=args.games,
|
||||
num_threads=args.threads,
|
||||
clock_seconds=args.clock,
|
||||
seed=args.seed,
|
||||
run_id=run_id,
|
||||
results_dir=args.results_dir,
|
||||
greedy=args.greedy,
|
||||
temperature=args.temperature,
|
||||
)
|
||||
# Override result path to match --out
|
||||
cfg_out = Path(out_path)
|
||||
cfg.results_dir = str(cfg_out.parent)
|
||||
cfg.run_id = cfg_out.stem.replace("eval-", "", 1) if cfg_out.stem.startswith("eval-") else cfg_out.stem
|
||||
|
||||
evaluator = ForgeEvaluator(cfg)
|
||||
summary = evaluator.run()
|
||||
|
||||
print("\n" + str(summary))
|
||||
print(f"Results written to: {out_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user