kuechenpassagent / src /cv /evaluate.py
lederyou's picture
Upload folder using huggingface_hub
db662ea verified
Raw
History Blame Contribute Delete
8.38 kB
"""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}")
@torch.no_grad()
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()