Spaces:
Paused
Paused
| """Offline threshold sweep for v0.2 issue #11 — replays greedy matching at | |
| arbitrary cosine thresholds against the .diag.json files written by | |
| ``scripts/05_evaluate.py --dump-match-debug``. No LLM, no embedder. | |
| Usage: | |
| PYTHONPATH=. python scripts/issue11_threshold_sweep.py | |
| PYTHONPATH=. python scripts/issue11_threshold_sweep.py --thresholds 0.30 0.35 0.40 0.45 0.50 | |
| Reads: | |
| data/evaluation/gold/{event_id}.json # gold cache (for pred_count, gold_count) | |
| data/evaluation/gold/{event_id}.diag.json # all same-domain pair cosines | |
| Prints a per-threshold table (macro_P / macro_R / macro_F1 / micro_F1). | |
| Does NOT mutate caches or the codebase — purely analytic. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| from pathlib import Path | |
| GOLD_DIR = Path("data/evaluation/gold") | |
| def replay_greedy(candidates: list[dict], threshold: float) -> int: | |
| """Re-run greedy match selection on a sorted-desc candidate list. | |
| candidates: list of {p_id, g_id, cosine, severity_match, accepted} | |
| Returns the number of accepted matches at the given threshold. | |
| """ | |
| matched_p: set[str] = set() | |
| matched_g: set[str] = set() | |
| matched = 0 | |
| for c in candidates: | |
| if c["cosine"] < threshold: | |
| break # sorted desc | |
| if c["p_id"] in matched_p or c["g_id"] in matched_g: | |
| continue | |
| matched_p.add(c["p_id"]) | |
| matched_g.add(c["g_id"]) | |
| matched += 1 | |
| return matched | |
| def per_event_metrics(diag_path: Path, gold_cache_path: Path, threshold: float) -> dict: | |
| diag = json.loads(diag_path.read_text()) | |
| gold = json.loads(gold_cache_path.read_text()) | |
| matched = replay_greedy(diag["candidates"], threshold) | |
| pred_count = gold["predicted_node_count"] | |
| gold_count = gold["gold_node_count"] | |
| precision = matched / pred_count if pred_count else 0.0 | |
| recall = matched / gold_count if gold_count else 0.0 | |
| f1 = ( | |
| 2 * precision * recall / (precision + recall) | |
| if (precision + recall) else 0.0 | |
| ) | |
| return { | |
| "event_id": diag["event_id"], | |
| "matched": matched, | |
| "pred_count": pred_count, | |
| "gold_count": gold_count, | |
| "precision": precision, | |
| "recall": recall, | |
| "f1": f1, | |
| } | |
| def sweep(thresholds: list[float]) -> None: | |
| diag_files = sorted(GOLD_DIR.glob("*.diag.json")) | |
| if not diag_files: | |
| raise SystemExit( | |
| f"No .diag.json files in {GOLD_DIR}. Run " | |
| "`scripts/05_evaluate.py --force --dump-match-debug` first." | |
| ) | |
| print(f"Found {len(diag_files)} events with .diag.json:") | |
| for d in diag_files: | |
| print(f" {d.stem.replace('.diag', '')}") | |
| print() | |
| print(f"{'threshold':>9} {'macro_P':>8} {'macro_R':>8} {'macro_F1':>9} " | |
| f"{'micro_F1':>9} {'matched/pred/gold':>22}") | |
| print("-" * 80) | |
| rows: list[dict] = [] | |
| for t in thresholds: | |
| per_event = [] | |
| for diag_path in diag_files: | |
| event_id = diag_path.stem.replace(".diag", "") | |
| gold_cache = GOLD_DIR / f"{event_id}.json" | |
| if not gold_cache.exists(): | |
| continue | |
| per_event.append(per_event_metrics(diag_path, gold_cache, t)) | |
| n = len(per_event) | |
| if not n: | |
| continue | |
| macro_p = sum(e["precision"] for e in per_event) / n | |
| macro_r = sum(e["recall"] for e in per_event) / n | |
| macro_f1 = sum(e["f1"] for e in per_event) / n | |
| m = sum(e["matched"] for e in per_event) | |
| p_total = sum(e["pred_count"] for e in per_event) | |
| g_total = sum(e["gold_count"] for e in per_event) | |
| micro_p = m / p_total if p_total else 0.0 | |
| micro_r = m / g_total if g_total else 0.0 | |
| micro_f1 = ( | |
| 2 * micro_p * micro_r / (micro_p + micro_r) | |
| if (micro_p + micro_r) else 0.0 | |
| ) | |
| print( | |
| f"{t:>9.2f} {macro_p:>8.3f} {macro_r:>8.3f} {macro_f1:>9.3f} " | |
| f"{micro_f1:>9.3f} {f'{m}/{p_total}/{g_total}':>22}" | |
| ) | |
| rows.append({ | |
| "threshold": t, "macro_p": macro_p, "macro_r": macro_r, | |
| "macro_f1": macro_f1, "micro_f1": micro_f1, | |
| "per_event": per_event, | |
| }) | |
| # Best threshold by macro_F1, tiebreak by precision | |
| best = max(rows, key=lambda r: (r["macro_f1"], r["macro_p"])) | |
| print() | |
| print(f"Best threshold (by macro_F1, tiebreak macro_P): {best['threshold']:.2f}") | |
| print(f" macro_F1={best['macro_f1']:.4f} macro_P={best['macro_p']:.4f} " | |
| f"macro_R={best['macro_r']:.4f} micro_F1={best['micro_f1']:.4f}") | |
| print() | |
| print("Per-event detail at best threshold:") | |
| for e in best["per_event"]: | |
| print( | |
| f" {e['event_id']:<18} matched={e['matched']:>2}/" | |
| f"pred={e['pred_count']:>2}/gold={e['gold_count']:>2} " | |
| f"P={e['precision']:.3f} R={e['recall']:.3f} F1={e['f1']:.3f}" | |
| ) | |
| def main() -> None: | |
| p = argparse.ArgumentParser(description=__doc__) | |
| p.add_argument( | |
| "--thresholds", type=float, nargs="+", | |
| default=[0.30, 0.35, 0.40, 0.45, 0.50], | |
| help="Cosine thresholds to sweep (default: 0.30 0.35 0.40 0.45 0.50).", | |
| ) | |
| args = p.parse_args() | |
| sweep(args.thresholds) | |
| if __name__ == "__main__": | |
| main() | |