cascade_risk / scripts /issue11_threshold_sweep.py
Lucasoppem's picture
Sync from GitHub main (part 2)
36f9d47 verified
Raw
History Blame Contribute Delete
5.3 kB
"""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()