Spaces:
Sleeping
Sleeping
| """Compute final metrics, confusion matrix, threshold sweep and a failure | |
| gallery for the saved CV model. | |
| Outputs: | |
| models/cv_confusion_matrix.json | |
| models/cv_metrics.json (test_overall, per_class, threshold_curve) | |
| docs/screenshots/cv_threshold_curve.png | |
| docs/screenshots/cv_failures.png | |
| Usage: | |
| python -m src.cv.evaluate | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| from sklearn.metrics import classification_report, confusion_matrix | |
| from torch.utils.data import DataLoader | |
| from torchvision.datasets import ImageFolder | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[2])) | |
| from src.config import ( # noqa: E402 | |
| CV_METRICS_PATH, | |
| CV_MODEL_PATH, | |
| MODELS_DIR, | |
| PROCESSED_DIR, | |
| SCREENSHOTS_DIR, | |
| ) | |
| from src.cv.train import build_model, build_transforms # noqa: E402 | |
| CONFUSION_PATH = MODELS_DIR / "cv_confusion_matrix.json" | |
| THRESHOLD_PLOT = SCREENSHOTS_DIR / "cv_threshold_curve.png" | |
| FAILURE_PLOT = SCREENSHOTS_DIR / "cv_failures.png" | |
| THRESHOLDS = [round(0.30 + 0.05 * i, 2) for i in range(14)] # 0.30 .. 0.95 | |
| PRECISION_TARGET = 0.95 | |
| def _apply_temperature(logits: torch.Tensor, temperature: float | None) -> torch.Tensor: | |
| if temperature and temperature > 0: | |
| return logits / temperature | |
| return logits | |
| def threshold_sweep( | |
| confidences: np.ndarray, correct: np.ndarray | |
| ) -> tuple[list[dict], float | None]: | |
| """Precision/recall/coverage of the 'accept (to_guest)' decision per threshold. | |
| precision = accepted_correct / accepted | |
| coverage = accepted / total | |
| recall = accepted_correct / total_correct | |
| """ | |
| total = len(confidences) | |
| total_correct = int(correct.sum()) | |
| curve: list[dict] = [] | |
| recommended: float | None = None | |
| for t in THRESHOLDS: | |
| accepted_mask = confidences >= t | |
| accepted = int(accepted_mask.sum()) | |
| accepted_correct = int(correct[accepted_mask].sum()) | |
| precision = accepted_correct / accepted if accepted else 1.0 | |
| coverage = accepted / total if total else 0.0 | |
| recall = accepted_correct / total_correct if total_correct else 0.0 | |
| curve.append( | |
| { | |
| "threshold": t, | |
| "precision": round(precision, 4), | |
| "recall": round(recall, 4), | |
| "coverage": round(coverage, 4), | |
| "accepted": accepted, | |
| } | |
| ) | |
| if recommended is None and precision >= PRECISION_TARGET and accepted > 0: | |
| recommended = t | |
| return curve, recommended | |
| def plot_threshold_curve(curve: list[dict], recommended: float | None) -> None: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| ts = [c["threshold"] for c in curve] | |
| prec = [c["precision"] for c in curve] | |
| rec = [c["recall"] for c in curve] | |
| cov = [c["coverage"] for c in curve] | |
| plt.figure(figsize=(8, 5)) | |
| plt.plot(ts, prec, marker="o", label="Precision (accepted correct)") | |
| plt.plot(ts, rec, marker="s", label="Recall (of all correct)") | |
| plt.plot(ts, cov, marker="^", label="Coverage (auto-passed)") | |
| plt.axhline(PRECISION_TARGET, color="gray", linestyle="--", linewidth=1, | |
| label=f"Precision target {PRECISION_TARGET}") | |
| if recommended is not None: | |
| plt.axvline(recommended, color="red", linestyle=":", linewidth=1.5, | |
| label=f"Recommended t={recommended}") | |
| plt.xlabel("Top-1 confidence threshold") | |
| plt.ylabel("Score") | |
| plt.title("Pass-decision threshold sweep") | |
| plt.legend(loc="lower left", fontsize=8) | |
| plt.grid(alpha=0.3) | |
| plt.tight_layout() | |
| SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True) | |
| plt.savefig(THRESHOLD_PLOT, dpi=120) | |
| plt.close() | |
| print(f"[cv.evaluate] wrote {THRESHOLD_PLOT}") | |
| def plot_failures(failures: list[dict], classes: list[str]) -> None: | |
| if not failures: | |
| print("[cv.evaluate] no misclassifications - skipping failure gallery") | |
| return | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| from PIL import Image | |
| n = min(8, len(failures)) | |
| cols, rows = 4, 2 | |
| fig, axes = plt.subplots(rows, cols, figsize=(14, 7)) | |
| for ax in axes.flat: | |
| ax.axis("off") | |
| for ax, f in zip(axes.flat, failures[:n]): | |
| img = Image.open(f["path"]).convert("RGB") | |
| ax.imshow(img) | |
| ax.set_title( | |
| f"true: {f['true']}\npred: {f['pred']} ({f['confidence']*100:.0f}%)", | |
| fontsize=9, | |
| ) | |
| fig.suptitle("Most confident misclassifications (test set)", fontsize=12) | |
| plt.tight_layout(rect=(0, 0, 1, 0.96)) | |
| SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True) | |
| plt.savefig(FAILURE_PLOT, dpi=120) | |
| plt.close() | |
| print(f"[cv.evaluate] wrote {FAILURE_PLOT}") | |
| def main() -> None: | |
| if not CV_MODEL_PATH.exists(): | |
| raise FileNotFoundError( | |
| "CV model missing. Train first with 'python -m src.cv.train'." | |
| ) | |
| checkpoint = torch.load(CV_MODEL_PATH, map_location="cpu") | |
| classes = checkpoint["classes"] | |
| temperature = checkpoint.get("temperature") | |
| model = build_model(checkpoint["model_name"], len(classes)) | |
| model.load_state_dict(checkpoint["state_dict"]) | |
| model.eval() | |
| _, eval_tf = build_transforms() | |
| test_ds = ImageFolder(PROCESSED_DIR / "cv" / "test", transform=eval_tf) | |
| samples = test_ds.samples # list of (path, class_idx) | |
| loader = DataLoader(test_ds, batch_size=64, shuffle=False, num_workers=2) | |
| y_true: list[int] = [] | |
| y_pred: list[int] = [] | |
| confidences: list[float] = [] | |
| top5_hits = 0 | |
| for x, y in loader: | |
| logits = _apply_temperature(model(x), temperature) | |
| probs = torch.softmax(logits, dim=1) | |
| conf1, pred1 = probs.topk(1, dim=1) | |
| _, pred5 = logits.topk(min(5, logits.size(1)), dim=1) | |
| y_true.extend(y.tolist()) | |
| y_pred.extend(pred1.squeeze(1).tolist()) | |
| confidences.extend(conf1.squeeze(1).tolist()) | |
| top5_hits += pred5.eq(y.unsqueeze(1)).any(dim=1).sum().item() | |
| y_true_arr = np.array(y_true) | |
| y_pred_arr = np.array(y_pred) | |
| conf_arr = np.array(confidences) | |
| correct = (y_true_arr == y_pred_arr).astype(int) | |
| top1 = float(correct.mean()) | |
| top5 = top5_hits / max(len(y_true), 1) | |
| print(f"[cv.evaluate] test top1={top1:.3f} top5={top5:.3f} n={len(y_true)}" | |
| f" (temperature={temperature})") | |
| cm = confusion_matrix(y_true, y_pred, labels=list(range(len(classes)))) | |
| report = classification_report( | |
| y_true, y_pred, target_names=classes, output_dict=True, zero_division=0 | |
| ) | |
| CONFUSION_PATH.write_text( | |
| json.dumps( | |
| {"classes": classes, "matrix": cm.tolist(), "top1": top1, "top5": top5}, | |
| indent=2, | |
| ) | |
| ) | |
| print(f"[cv.evaluate] wrote {CONFUSION_PATH}") | |
| # Q3 threshold sweep | |
| curve, recommended = threshold_sweep(conf_arr, correct) | |
| print(f"[cv.evaluate] recommended threshold (precision>={PRECISION_TARGET}): " | |
| f"{recommended}") | |
| plot_threshold_curve(curve, recommended) | |
| # Q4 failure gallery: most confident wrong predictions | |
| failures = [ | |
| { | |
| "path": samples[i][0], | |
| "true": classes[y_true[i]], | |
| "pred": classes[y_pred[i]], | |
| "confidence": float(conf_arr[i]), | |
| } | |
| for i in range(len(y_true)) | |
| if not correct[i] | |
| ] | |
| failures.sort(key=lambda f: f["confidence"], reverse=True) | |
| plot_failures(failures, classes) | |
| existing = json.loads(CV_METRICS_PATH.read_text()) if CV_METRICS_PATH.exists() else {} | |
| existing["test_overall"] = {"top1": top1, "top5": top5, "n": len(y_true)} | |
| existing["per_class"] = { | |
| cls: {"precision": v["precision"], "recall": v["recall"], "f1": v["f1-score"]} | |
| for cls, v in report.items() | |
| if cls in classes | |
| } | |
| existing["threshold_curve"] = { | |
| "precision_target": PRECISION_TARGET, | |
| "recommended_threshold": recommended, | |
| "points": curve, | |
| } | |
| existing["top_failures"] = [ | |
| {"true": f["true"], "pred": f["pred"], "confidence": round(f["confidence"], 3)} | |
| for f in failures[:8] | |
| ] | |
| CV_METRICS_PATH.write_text(json.dumps(existing, indent=2)) | |
| print(f"[cv.evaluate] updated {CV_METRICS_PATH}") | |
| if __name__ == "__main__": | |
| main() | |