claimflow-api / ml_training /evaluate.py
Minifigures's picture
feat: ClaimFlow API demo backend
ceea9e1 verified
Raw
History Blame Contribute Delete
6.02 kB
"""Evaluate the trained imaging models on the held-out test split.
uv run python -m ml_training.evaluate --weights-dir weights/ \
--data-dir ml_training/data --report ml_training/data/eval_report.json
``--data-dir`` is the data root: the imaging set is found at ``<data-dir>/imaging``
(or ``<data-dir>`` itself if it directly contains ``manifest.csv``) and the
authenticity set at ``<data-dir>/authenticity`` (or ``<data-dir>`` with
``manifest_auth.csv``). Reports accuracy, macro-F1, per-class precision/recall, the
confusion matrix (printed as a table), and ECE before/after temperature scaling.
Exits nonzero with a clear message if a weights file is missing.
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import UTC, datetime
from pathlib import Path
import numpy as np
import torch
from ml_training.models import (
ManifestImageDataset,
collect_logits,
confusion_matrix_np,
macro_f1_from_cm,
per_class_precision_recall,
read_manifest,
resolve_device,
)
from ml_training.models.backbone import ARCH, build_model, make_transforms
from ml_training.models.calibration import ece, softmax
_MODEL_SPECS: tuple[tuple[str, str, str, str], ...] = (
# (name, data subdir, manifest filename, label column)
("modality", "imaging", "manifest.csv", "modality"),
("authenticity", "authenticity", "manifest_auth.csv", "label"),
)
def _find_data_dir(data_root: Path, subdir: str, manifest_name: str) -> Path | None:
for candidate in (data_root / subdir, data_root):
if (candidate / manifest_name).exists():
return candidate
return None
def _format_confusion_matrix(cm: np.ndarray, classes: list[str]) -> str:
width = max(10, max(len(c) for c in classes) + 6)
header = " " * width + "".join(f"{'pred:' + c:>{width}}" for c in classes)
lines = [header]
for i, cls in enumerate(classes):
lines.append(f"{'true:' + cls:<{width}}" + "".join(f"{n:>{width}d}" for n in cm[i]))
return "\n".join(lines)
def evaluate_model(
name: str,
weights_path: Path,
config_path: Path,
data_dir: Path,
manifest_name: str,
label_column: str,
device: torch.device,
) -> dict[str, object]:
config = json.loads(config_path.read_text())
classes: list[str] = list(config["classes"])
input_size = int(config["input_size"])
temperature = float(config.get("temperature", 1.0))
model = build_model(len(classes), pretrained=False)
state = torch.load(weights_path, map_location="cpu", weights_only=True)
model.load_state_dict(state)
model.to(device)
rows = [r for r in read_manifest(data_dir, manifest_name, label_column) if r.split == "test"]
if not rows:
raise SystemExit(f"empty test split in {data_dir / manifest_name}")
dataset = ManifestImageDataset(
data_dir, rows, classes, make_transforms(train=False, size=input_size)
)
loader = torch.utils.data.DataLoader(dataset, batch_size=32, shuffle=False)
logits, labels = collect_logits(model, loader, device)
preds = logits.argmax(axis=1)
cm = confusion_matrix_np(labels, preds, len(classes))
precision, recall = per_class_precision_recall(cm)
ece_before = ece(softmax(logits, 1.0), labels)
ece_after = ece(softmax(logits, temperature), labels)
print(f"\n=== {name} (test n={len(labels)}) ===")
print(_format_confusion_matrix(cm, classes))
accuracy = float((preds == labels).mean())
macro_f1 = macro_f1_from_cm(cm)
print(f"accuracy={accuracy:.4f} macro_f1={macro_f1:.4f}")
print(f"ece_before={ece_before:.4f} ece_after={ece_after:.4f} (T={temperature:.3f})")
return {
"weights": str(weights_path),
"classes": classes,
"n_test": int(len(labels)),
"accuracy": accuracy,
"macro_f1": macro_f1,
"per_class": {
cls: {"precision": float(precision[i]), "recall": float(recall[i])}
for i, cls in enumerate(classes)
},
"confusion_matrix": cm.tolist(),
"temperature": temperature,
"ece_before_temperature": ece_before,
"ece_after_temperature": ece_after,
}
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Evaluate trained models on the test split.")
parser.add_argument("--weights-dir", type=Path, required=True)
parser.add_argument("--data-dir", type=Path, required=True)
parser.add_argument(
"--report", type=Path, default=Path("ml_training/data/eval_report.json")
)
parser.add_argument("--device", default="auto")
args = parser.parse_args(argv)
device = resolve_device(args.device)
report: dict[str, object] = {"generated_at_utc": datetime.now(UTC).isoformat()}
missing: list[str] = []
for name, subdir, manifest_name, label_column in _MODEL_SPECS:
weights_path = args.weights_dir / f"{name}_{ARCH}.pt"
config_path = args.weights_dir / f"{name}_config.json"
if not weights_path.exists() or not config_path.exists():
missing.append(f"{name}: expected {weights_path} + {config_path}")
continue
data_dir = _find_data_dir(args.data_dir, subdir, manifest_name)
if data_dir is None:
raise SystemExit(
f"data for {name!r} not found: looked for {manifest_name} under "
f"{args.data_dir / subdir} and {args.data_dir}"
)
report[name] = evaluate_model(
name, weights_path, config_path, data_dir, manifest_name, label_column, device
)
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, indent=2))
print(f"\nreport written: {args.report}")
if missing:
print("ERROR: missing trained weights, run the training CLIs first:", file=sys.stderr)
for line in missing:
print(f" - {line}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()