Pavle-17's picture
InfoShield backend (Docker)
1cf82f0
Raw
History Blame Contribute Delete
5.91 kB
"""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()