Spaces:
Sleeping
Sleeping
| """ | |
| evaluate.py β Evaluation, quantitative metrics, and error analysis. | |
| Usage: | |
| python src/evaluate.py --model mobilenet_v2 --split test | |
| python src/evaluate.py --model simple_cnn --split val | |
| """ | |
| import argparse | |
| import logging | |
| import sys | |
| from pathlib import Path | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| import seaborn as sns | |
| import torch | |
| import yaml | |
| from sklearn.metrics import ( | |
| accuracy_score, | |
| classification_report, | |
| confusion_matrix, | |
| f1_score, | |
| precision_score, | |
| recall_score, | |
| roc_auc_score, | |
| roc_curve, | |
| ) | |
| from tqdm import tqdm | |
| sys.path.insert(0, str(Path(__file__).parent)) | |
| from dataset import build_dataloaders, IMAGENET_MEAN, IMAGENET_STD, LABEL_NAMES | |
| from model import build_model | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", | |
| datefmt="%Y-%m-%d %H:%M:%S", | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # βββ Inference pass βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def collect_predictions(model, loader, device): | |
| """Run the model on a DataLoader and collect all preds + probs + raw images.""" | |
| model.eval() | |
| all_labels, all_preds, all_probs, all_images = [], [], [], [] | |
| for images, labels in tqdm(loader, desc="Evaluating"): | |
| images = images.to(device) | |
| logits = model(images) | |
| probs = torch.softmax(logits, dim=1) | |
| preds = probs.argmax(dim=1) | |
| all_images.extend(images.cpu()) | |
| all_preds.extend(preds.cpu().numpy()) | |
| all_probs.extend(probs.cpu().numpy()) | |
| all_labels.extend(labels.numpy()) | |
| return ( | |
| np.array(all_labels), | |
| np.array(all_preds), | |
| np.array(all_probs), | |
| all_images, | |
| ) | |
| # βββ Plot helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _denormalize(tensor: torch.Tensor) -> np.ndarray: | |
| """Reverse ImageNet normalisation for visualisation.""" | |
| mean = np.array(IMAGENET_MEAN) | |
| std = np.array(IMAGENET_STD) | |
| img = tensor.permute(1, 2, 0).numpy() | |
| return (img * std + mean).clip(0.0, 1.0) | |
| def plot_confusion_matrix(labels, preds, output_dir: Path, model_name: str) -> None: | |
| cm = confusion_matrix(labels, preds) | |
| fig, ax = plt.subplots(figsize=(6, 5)) | |
| sns.heatmap( | |
| cm, annot=True, fmt="d", cmap="Blues", | |
| xticklabels=list(LABEL_NAMES.values()), | |
| yticklabels=list(LABEL_NAMES.values()), | |
| ax=ax, | |
| ) | |
| ax.set_xlabel("Predicted", fontsize=12) | |
| ax.set_ylabel("True", fontsize=12) | |
| ax.set_title(f"Confusion Matrix β {model_name}", fontsize=13, fontweight="bold") | |
| plt.tight_layout() | |
| out = output_dir / f"confusion_matrix_{model_name}.png" | |
| fig.savefig(out, dpi=150) | |
| plt.close(fig) | |
| logger.info(f"Saved confusion matrix β {out}") | |
| def plot_misclassified( | |
| labels, preds, images, output_dir: Path, model_name: str, n: int = 12 | |
| ) -> None: | |
| wrong_idx = np.where(labels != preds)[0] | |
| if len(wrong_idx) == 0: | |
| logger.info("No misclassified samples β perfect predictions!") | |
| return | |
| wrong_idx = wrong_idx[:n] | |
| cols = 4 | |
| rows = (len(wrong_idx) + cols - 1) // cols | |
| fig, axes = plt.subplots(rows, cols, figsize=(cols * 3, rows * 3.2)) | |
| axes = np.array(axes).flatten() | |
| for i, idx in enumerate(wrong_idx): | |
| img = _denormalize(images[idx]) | |
| axes[i].imshow(img, cmap="gray" if img.std() < 0.05 else None) | |
| true_lbl = LABEL_NAMES[labels[idx]] | |
| pred_lbl = LABEL_NAMES[preds[idx]] | |
| axes[i].set_title(f"True: {true_lbl}\nPred: {pred_lbl}", fontsize=8, color="crimson") | |
| axes[i].axis("off") | |
| for j in range(i + 1, len(axes)): | |
| axes[j].axis("off") | |
| fig.suptitle( | |
| f"Misclassified Samples β {model_name}\n" | |
| f"({len(wrong_idx)} of {len(labels)} shown)", | |
| fontsize=12, fontweight="bold", y=1.01, | |
| ) | |
| plt.tight_layout() | |
| out = output_dir / f"misclassified_{model_name}.png" | |
| fig.savefig(out, dpi=150, bbox_inches="tight") | |
| plt.close(fig) | |
| logger.info(f"Saved misclassified grid β {out}") | |
| def plot_roc_curve(labels, probs, output_dir: Path, model_name: str) -> float: | |
| auc = roc_auc_score(labels, probs[:, 1]) | |
| fpr, tpr, _ = roc_curve(labels, probs[:, 1]) | |
| fig, ax = plt.subplots(figsize=(6, 5)) | |
| ax.plot(fpr, tpr, label=f"AUC = {auc:.3f}", color="#1976D2", lw=2) | |
| ax.fill_between(fpr, tpr, alpha=0.08, color="#1976D2") | |
| ax.plot([0, 1], [0, 1], "k--", lw=1, label="Random (AUC=0.5)") | |
| ax.set_xlabel("False Positive Rate", fontsize=11) | |
| ax.set_ylabel("True Positive Rate", fontsize=11) | |
| ax.set_title(f"ROC Curve β {model_name}", fontsize=13, fontweight="bold") | |
| ax.legend(fontsize=11) | |
| ax.spines[["top", "right"]].set_visible(False) | |
| plt.tight_layout() | |
| out = output_dir / f"roc_curve_{model_name}.png" | |
| fig.savefig(out, dpi=150) | |
| plt.close(fig) | |
| logger.info(f"Saved ROC curve β {out}") | |
| return auc | |
| def plot_training_curves(history: dict, output_dir: Path, model_name: str) -> None: | |
| """Optionally called after training to visualise loss/acc curves.""" | |
| if not history: | |
| return | |
| epochs = range(1, len(history["train_loss"]) + 1) | |
| fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) | |
| ax1.plot(epochs, history["train_loss"], label="Train", color="#E53935") | |
| ax1.plot(epochs, history["val_loss"], label="Val", color="#43A047") | |
| ax1.set_title("Loss"); ax1.set_xlabel("Epoch"); ax1.legend() | |
| ax1.spines[["top", "right"]].set_visible(False) | |
| ax2.plot(epochs, history["train_acc"], label="Train", color="#E53935") | |
| ax2.plot(epochs, history["val_acc"], label="Val", color="#43A047") | |
| ax2.set_title("Accuracy"); ax2.set_xlabel("Epoch"); ax2.legend() | |
| ax2.spines[["top", "right"]].set_visible(False) | |
| fig.suptitle(f"Training Curves β {model_name}", fontsize=13, fontweight="bold") | |
| plt.tight_layout() | |
| out = output_dir / f"training_curves_{model_name}.png" | |
| fig.savefig(out, dpi=150) | |
| plt.close(fig) | |
| logger.info(f"Saved training curves β {out}") | |
| # βββ Main evaluation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def evaluate(cfg: dict, model_name_override: str = None, split: str = "test") -> dict: | |
| """ | |
| Load checkpoint, run evaluation on the chosen split, print metrics, save plots. | |
| Args: | |
| cfg: Config dict. | |
| model_name_override: Override model name from config. | |
| split: 'val' or 'test'. | |
| Returns: | |
| dict of metric values. | |
| """ | |
| if model_name_override: | |
| cfg["model"]["name"] = model_name_override | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| model_name = cfg["model"]["name"] | |
| ckpt_path = Path(cfg["training"]["checkpoint_dir"]) / f"best_{model_name}.pth" | |
| if not ckpt_path.exists(): | |
| raise FileNotFoundError( | |
| f"Checkpoint not found: {ckpt_path}\n" | |
| "Run `python src/train.py --model {model_name}` first." | |
| ) | |
| logger.info(f"Loading checkpoint: {ckpt_path}") | |
| ckpt = torch.load(ckpt_path, map_location=device) | |
| model = build_model(cfg).to(device) | |
| model.load_state_dict(ckpt["model_state"]) | |
| logger.info(f"Loaded epoch={ckpt['epoch']}, best_val_acc={ckpt['val_acc']:.4f}") | |
| loaders = build_dataloaders(cfg) | |
| labels, preds, probs, images = collect_predictions(model, loaders[split], device) | |
| # ββ Metrics ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| acc = accuracy_score(labels, preds) | |
| prec = precision_score(labels, preds, average="binary", zero_division=0) | |
| rec = recall_score(labels, preds, average="binary", zero_division=0) | |
| f1 = f1_score(labels, preds, average="binary", zero_division=0) | |
| auc = roc_auc_score(labels, probs[:, 1]) | |
| sep = "=" * 58 | |
| print(f"\n{sep}") | |
| print(f" Evaluation | model={model_name} | split={split}") | |
| print(sep) | |
| print(f" Accuracy : {acc:.4f}") | |
| print(f" Precision : {prec:.4f} (positive class = PNEUMONIA)") | |
| print(f" Recall : {rec:.4f} (sensitivity)") | |
| print(f" F1-Score : {f1:.4f}") | |
| print(f" AUC-ROC : {auc:.4f}") | |
| print(sep) | |
| print(classification_report(labels, preds, target_names=list(LABEL_NAMES.values()))) | |
| # ββ Plots βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| output_dir = Path(cfg["evaluation"]["output_dir"]) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| plot_confusion_matrix(labels, preds, output_dir, model_name) | |
| plot_misclassified(labels, preds, images, output_dir, model_name) | |
| plot_roc_curve(labels, probs, output_dir, model_name) | |
| logger.info(f"All evaluation outputs saved to: {output_dir}/") | |
| return {"acc": acc, "precision": prec, "recall": rec, "f1": f1, "auc": auc} | |
| # βββ CLI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Evaluate Chest X-Ray classifier") | |
| parser.add_argument("--config", default="configs/config.yaml") | |
| parser.add_argument("--model", choices=["simple_cnn", "mobilenet_v2"]) | |
| parser.add_argument("--split", default="test", choices=["val", "test"]) | |
| args = parser.parse_args() | |
| with open(args.config) as f: | |
| cfg = yaml.safe_load(f) | |
| evaluate(cfg, model_name_override=args.model, split=args.split) | |