Spaces:
Sleeping
Sleeping
| """Evaluate a gate and find its optimal threshold on a labeled CSV. | |
| Scores every row with one gate, then reports precision / recall / F1, ROC-AUC, | |
| PR-AUC, a threshold sweep, and three recommended operating points | |
| (max-F1, Youden's J, target-precision). See docs/EVALUATION.md for the method. | |
| CSV format: columns `text,label` (label: 1 = hate/manipulation, 0 = not). | |
| Examples: | |
| python backend/scripts/evaluate.py --task hate --data hate.csv --subset 0.1 | |
| python backend/scripts/evaluate.py --task manipulation --data persuasion.csv --subset 0.2 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import random | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(ROOT)) | |
| def load_rows(path: str) -> list[tuple[str, int]]: | |
| rows: list[tuple[str, int]] = [] | |
| with open(path, "r", encoding="utf-8", newline="") as f: | |
| reader = csv.DictReader(f) | |
| for r in reader: | |
| text = (r.get("text") or "").strip() | |
| raw = str(r.get("label") or "").strip() | |
| if not text or raw == "": | |
| continue | |
| try: | |
| label = int(float(raw)) | |
| except ValueError: | |
| continue | |
| rows.append((text, 1 if label >= 1 else 0)) | |
| return rows | |
| def stratified_subset(rows, fraction, seed): | |
| if fraction >= 1.0: | |
| return rows | |
| rng = random.Random(seed) | |
| pos = [r for r in rows if r[1] == 1] | |
| neg = [r for r in rows if r[1] == 0] | |
| rng.shuffle(pos) | |
| rng.shuffle(neg) | |
| keep = pos[: max(1, int(len(pos) * fraction))] + neg[: max(1, int(len(neg) * fraction))] | |
| rng.shuffle(keep) | |
| return keep | |
| def score_rows(rows, task): | |
| scores = [] | |
| if task == "hate": | |
| from classifier import get_classifier | |
| clf = get_classifier() | |
| clf.load() | |
| for i, (text, _) in enumerate(rows, 1): | |
| scores.append(float(clf.classify(text)["confidence"])) | |
| if i % 25 == 0: | |
| print(f" scored {i}/{len(rows)}", file=sys.stderr) | |
| else: # manipulation (local propaganda model -- the gate score) | |
| from manip_classifier import get_manip_classifier | |
| clf = get_manip_classifier() | |
| clf.load() | |
| for i, (text, _) in enumerate(rows, 1): | |
| scores.append(float(clf.classify(text)["confidence"])) | |
| if i % 25 == 0: | |
| print(f" scored {i}/{len(rows)}", file=sys.stderr) | |
| return np.array(scores), np.array([lbl for _, lbl in rows]) | |
| def metrics_at(scores, labels, t): | |
| pred = scores >= t | |
| tp = int(np.sum(pred & (labels == 1))) | |
| fp = int(np.sum(pred & (labels == 0))) | |
| fn = int(np.sum(~pred & (labels == 1))) | |
| tn = int(np.sum(~pred & (labels == 0))) | |
| prec = tp / (tp + fp) if tp + fp else 0.0 | |
| rec = tp / (tp + fn) if tp + fn else 0.0 | |
| f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0 | |
| tpr = rec | |
| fpr = fp / (fp + tn) if fp + tn else 0.0 | |
| return {"t": t, "tp": tp, "fp": fp, "fn": fn, "tn": tn, | |
| "precision": prec, "recall": rec, "f1": f1, "tpr": tpr, "fpr": fpr} | |
| def roc_auc(scores, labels): | |
| pos = scores[labels == 1] | |
| neg = scores[labels == 0] | |
| if len(pos) == 0 or len(neg) == 0: | |
| return float("nan") | |
| # Mann-Whitney U (counts ties as 0.5). | |
| gt = np.sum(pos[:, None] > neg[None, :]) | |
| eq = np.sum(pos[:, None] == neg[None, :]) | |
| return float((gt + 0.5 * eq) / (len(pos) * len(neg))) | |
| def pr_auc(scores, labels): | |
| order = np.argsort(-scores) | |
| y = labels[order] | |
| tp = np.cumsum(y == 1) | |
| fp = np.cumsum(y == 0) | |
| npos = int(np.sum(labels == 1)) | |
| if npos == 0: | |
| return float("nan") | |
| precision = tp / np.maximum(tp + fp, 1) | |
| recall = tp / npos | |
| recall = np.concatenate([[0.0], recall]) | |
| precision = np.concatenate([[1.0], precision]) | |
| return float(np.sum((recall[1:] - recall[:-1]) * precision[1:])) # average precision | |
| def main() -> None: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--task", choices=["hate", "manipulation"], required=True) | |
| ap.add_argument("--data", required=True, help="CSV with columns: text,label") | |
| ap.add_argument("--subset", type=float, default=1.0, help="stratified fraction, e.g. 0.1") | |
| ap.add_argument("--seed", type=int, default=13) | |
| ap.add_argument("--target-precision", type=float, default=0.85) | |
| ap.add_argument("--steps", type=int, default=101) | |
| args = ap.parse_args() | |
| rows = stratified_subset(load_rows(args.data), args.subset, args.seed) | |
| if not rows: | |
| raise SystemExit("No usable rows (need columns text,label).") | |
| print(f"\nTask: {args.task} | rows: {len(rows)} " | |
| f"(pos={sum(l for _, l in rows)}, neg={sum(1 - l for _, l in rows)})") | |
| scores, labels = score_rows(rows, args.task) | |
| sweep = [metrics_at(scores, labels, t) for t in np.linspace(0, 1, args.steps)] | |
| best_f1 = max(sweep, key=lambda m: m["f1"]) | |
| youden = max(sweep, key=lambda m: m["tpr"] - m["fpr"]) | |
| prec_ok = [m for m in sweep if m["precision"] >= args.target_precision and m["recall"] > 0] | |
| target = min(prec_ok, key=lambda m: m["t"]) if prec_ok else best_f1 | |
| print(f"\nROC-AUC: {roc_auc(scores, labels):.3f} PR-AUC (AP): {pr_auc(scores, labels):.3f}") | |
| print("\nOperating points:") | |
| print(f" {'criterion':<22}{'thr':>6}{'P':>8}{'R':>8}{'F1':>8}") | |
| for name, m in [("max-F1", best_f1), ("Youden's J", youden), | |
| (f"precision>={args.target_precision:.2f}", target)]: | |
| print(f" {name:<22}{m['t']:>6.2f}{m['precision']:>8.3f}{m['recall']:>8.3f}{m['f1']:>8.3f}") | |
| r = target | |
| print(f"\nRecommended threshold = {r['t']:.2f} (precision-first). Confusion @ thr:") | |
| print(f" TP={r['tp']} FP={r['fp']} FN={r['fn']} TN={r['tn']}") | |
| print("\nPaste the operating-points table into docs/EVALUATION.md > Key results.\n") | |
| if __name__ == "__main__": | |
| main() | |