scanner / scripts /evaluate_head.py
github-actions[bot]
Deploy from GitHub 39b3777315c11d9c8bcd39ad7bf034f2a88a7379 (filtered: code + Dockerfile + README + NOTICES only)
2e175db
Raw
History Blame Contribute Delete
9.24 kB
"""
Evaluate CLIP head checkpoints on cached embedding splits.
This is the Stage 3A checkpoint-comparison script. It loads one candidate head,
optionally a Stage 2 baseline head, and reports overall, per-generator,
per-source, per-model-family, and per-augmentation metrics for any requested
embedding splits.
Usage
-----
python scripts/evaluate_head.py \\
--emb-dir data/embeddings \\
--candidate data/checkpoints/head_v3a.pt \\
--baseline data/checkpoints/head_stage2.pt \\
--split test \\
--split heldout \\
--split test_augmented \\
--report-out data/reports/head_v3a_eval.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import numpy as np
from train_head import SplitEmbeddings, _build_head, _load_split
def _softmax(logits):
import torch
return torch.softmax(logits, dim=-1)
def _rate(num: int, den: int) -> float:
return float(num / den) if den else 0.0
def _threshold_metrics(logits, labels, uncertainty_threshold: float) -> dict[str, Any]:
probs = _softmax(logits)
max_probs, argmax = probs.max(dim=-1)
certain = max_probs >= uncertainty_threshold
authentic = labels == 0
ai = labels == 1
pred_authentic = argmax == 0
pred_ai = argmax == 1
tp = int((certain & pred_ai & ai).sum().item())
tn = int((certain & pred_authentic & authentic).sum().item())
fp = int((certain & pred_ai & authentic).sum().item())
fn = int((certain & pred_authentic & ai).sum().item())
uncertain_authentic = int((~certain & authentic).sum().item())
uncertain_ai = int((~certain & ai).sum().item())
uncertain = uncertain_authentic + uncertain_ai
n = int(len(labels))
argmax_correct = int((argmax == labels).sum().item())
certain_correct = int(((argmax == labels) & certain).sum().item())
certain_n = int(certain.sum().item())
return {
"n": n,
"argmax_accuracy": _rate(argmax_correct, n),
"coverage_accuracy": _rate(certain_correct, certain_n),
"coverage_rate": _rate(certain_n, n),
"uncertainty_rate": _rate(uncertain, n),
"false_positive_rate": _rate(fp, fp + tn + uncertain_authentic),
"false_negative_rate": _rate(fn, fn + tp + uncertain_ai),
"confusion": {
"tp": tp,
"tn": tn,
"fp": fp,
"fn": fn,
"uncertain_authentic": uncertain_authentic,
"uncertain_ai": uncertain_ai,
},
"mean_confidence": float(max_probs.mean().item()) if n else 0.0,
}
def _indices_for(values: np.ndarray, label: str) -> list[int]:
return [i for i, value in enumerate(values) if str(value) == label]
def _group_metrics(
logits,
labels,
values: np.ndarray,
uncertainty_threshold: float,
) -> dict[str, dict[str, Any]]:
import torch
result: dict[str, dict[str, Any]] = {}
group_labels = sorted({str(v) for v in values if str(v)})
for label in group_labels:
idx = _indices_for(values, label)
if not idx:
continue
tensor_idx = torch.as_tensor(idx, dtype=torch.long, device=logits.device)
result[label] = _threshold_metrics(
logits.index_select(0, tensor_idx),
labels.index_select(0, tensor_idx),
uncertainty_threshold,
)
return result
def _generator_values(split: SplitEmbeddings) -> np.ndarray:
values: list[str] = []
labels = split.y.cpu().numpy()
for i, label in enumerate(labels):
if label != 1:
values.append("")
continue
values.append(str(split.generators[i] or split.sources[i]))
return np.asarray(values)
def _augmentation_values(split: SplitEmbeddings) -> np.ndarray:
return np.asarray(
[str(value) if str(value) else "clean" for value in split.augmentations]
)
def _split_report(
split: SplitEmbeddings,
logits,
uncertainty_threshold: float,
) -> dict[str, Any]:
report: dict[str, Any] = {
"overall": _threshold_metrics(logits, split.y, uncertainty_threshold)
}
groups = {
"by_source": split.sources,
"by_generator": _generator_values(split),
"by_model_family": split.model_families,
}
if any(str(value) for value in split.augmentations):
groups["by_augmentation"] = _augmentation_values(split)
for name, values in groups.items():
metrics = _group_metrics(logits, split.y, values, uncertainty_threshold)
if metrics:
report[name] = metrics
return report
def _load_head(checkpoint: Path):
import torch
head = _build_head()
state = torch.load(checkpoint, map_location="cpu")
head.load_state_dict(state)
head.eval()
return head
def _evaluate_checkpoint(
checkpoint: Path,
splits: dict[str, SplitEmbeddings],
uncertainty_threshold: float,
) -> dict[str, Any]:
import torch
head = _load_head(checkpoint)
report: dict[str, Any] = {
"checkpoint": str(checkpoint),
"splits": {},
}
with torch.no_grad():
for name, split in splits.items():
logits = head(split.x)
report["splits"][name] = _split_report(
split,
logits,
uncertainty_threshold,
)
return report
def _comparison(candidate: dict[str, Any], baseline: dict[str, Any] | None) -> dict:
if baseline is None:
return {}
result: dict[str, dict[str, float]] = {}
for split, candidate_report in candidate["splits"].items():
if split not in baseline["splits"]:
continue
candidate_overall = candidate_report["overall"]
baseline_overall = baseline["splits"][split]["overall"]
result[split] = {
"argmax_accuracy_delta": (
candidate_overall["argmax_accuracy"]
- baseline_overall["argmax_accuracy"]
),
"uncertainty_rate_delta": (
candidate_overall["uncertainty_rate"]
- baseline_overall["uncertainty_rate"]
),
"false_positive_rate_delta": (
candidate_overall["false_positive_rate"]
- baseline_overall["false_positive_rate"]
),
"false_negative_rate_delta": (
candidate_overall["false_negative_rate"]
- baseline_overall["false_negative_rate"]
),
}
return result
def _print_summary(report: dict[str, Any]) -> None:
print(f"Candidate: {report['candidate']['checkpoint']}")
if report.get("baseline"):
print(f"Baseline: {report['baseline']['checkpoint']}")
for split, metrics in report["candidate"]["splits"].items():
overall = metrics["overall"]
print(
f" {split}: n={overall['n']} "
f"acc={overall['argmax_accuracy']:.4f} "
f"uncertain={overall['uncertainty_rate']:.4f} "
f"fpr={overall['false_positive_rate']:.4f} "
f"fnr={overall['false_negative_rate']:.4f}"
)
for group_name in ["by_generator", "by_augmentation"]:
if group_name not in metrics:
continue
print(f" {group_name}:")
for label, group_metrics in metrics[group_name].items():
print(
f" {label}: n={group_metrics['n']} "
f"acc={group_metrics['argmax_accuracy']:.4f} "
f"uncertain={group_metrics['uncertainty_rate']:.4f}"
)
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--emb-dir", type=Path, required=True)
parser.add_argument("--candidate", type=Path, required=True)
parser.add_argument("--baseline", type=Path, default=None)
parser.add_argument(
"--split",
action="append",
default=[],
help="Embedding split name to evaluate, without .npz. Defaults to test.",
)
parser.add_argument("--report-out", type=Path, required=True)
parser.add_argument("--uncertainty-threshold", type=float, default=0.6)
args = parser.parse_args()
split_names = args.split or ["test"]
splits = {name: _load_split(args.emb_dir, name) for name in split_names}
candidate = _evaluate_checkpoint(
args.candidate,
splits,
args.uncertainty_threshold,
)
baseline = (
_evaluate_checkpoint(args.baseline, splits, args.uncertainty_threshold)
if args.baseline is not None
else None
)
report = {
"uncertainty_threshold": args.uncertainty_threshold,
"candidate": candidate,
"baseline": baseline,
"comparison": _comparison(candidate, baseline),
}
args.report_out.parent.mkdir(parents=True, exist_ok=True)
with args.report_out.open("w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2, sort_keys=True)
_print_summary(report)
print(f"\nSaved evaluation report to {args.report_out}")
if __name__ == "__main__":
main()