#!/usr/bin/env python3 """Evaluate MVSA-Single checkpoint with notebook-style variants and ablation.""" from __future__ import annotations import argparse import json from pathlib import Path import numpy as np from transformers import CLIPProcessor, DebertaV2Tokenizer import sys PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from src.mvsa_single_pipeline import ( MVSASingleLoader, apply_bias_temp_neutral, build_classification_outputs, create_dataloaders, evaluate_ablation, evaluate_all_variants, evaluate_raw, gather_logits_labels, load_checkpoint, resolve_device, save_summary_files, summarize_splits, ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Evaluate CLARA MVSA-Single checkpoint") parser.add_argument("--checkpoint", default="outputs/mvsa_single/clara_mvsa_single.pt") parser.add_argument("--data-root", default="data/MVSA-Single") parser.add_argument("--text-dir", default=None, help="Default: /data") parser.add_argument("--label-file", default=None, help="Default: /labelResultAll.txt") parser.add_argument("--output-dir", default="results/mvsa_single") parser.add_argument("--batch-size", type=int, default=None) parser.add_argument("--max-length", type=int, default=None) parser.add_argument("--num-workers", type=int, default=None) parser.add_argument("--train-ratio", type=float, default=None) parser.add_argument("--val-ratio", type=float, default=None) parser.add_argument("--preprocessing-mode", choices=["paper", "strict"], default=None) parser.add_argument("--top2-eps", type=float, default=0.03) parser.add_argument( "--classification-mode", choices=["raw", "bias_temp"], default="raw", help="Prediction source for classification report/confusion matrix", ) parser.add_argument( "--ablation-full-mode", choices=["raw", "bias_temp"], default="raw", help="Metric source for Full row in ablation table", ) parser.add_argument("--device", default="auto", help="auto|cuda|cpu") return parser.parse_args() def main() -> None: args = parse_args() text_dir = args.text_dir or str(Path(args.data_root) / "data") label_file = args.label_file or str(Path(args.data_root) / "labelResultAll.txt") device = resolve_device(args.device) model, cfg, ckpt_meta = load_checkpoint(args.checkpoint, device) cfg["text_dir"] = text_dir cfg["label_file"] = label_file if args.batch_size is not None: cfg["batch_size"] = args.batch_size if args.num_workers is not None: cfg["num_workers"] = args.num_workers if args.max_length is not None: cfg["max_length"] = args.max_length if args.train_ratio is not None: cfg["train_ratio"] = args.train_ratio if args.val_ratio is not None: cfg["val_ratio"] = args.val_ratio if args.preprocessing_mode is not None: cfg["preprocessing_mode"] = args.preprocessing_mode loader = MVSASingleLoader(cfg["text_dir"], cfg["label_file"]) loader.load( preprocessing_mode=str(cfg.get("preprocessing_mode", "paper")), require_unanimous=bool(cfg.get("require_unanimous", True)), require_cross_agree=bool(cfg.get("require_cross_agree", True)), ) train_samples, val_samples, test_samples = loader.split( train_ratio=float(cfg.get("train_ratio", 0.7)), val_ratio=float(cfg.get("val_ratio", 0.15)), seed=int(cfg.get("seed", 42)), paper_811=bool(str(cfg.get("preprocessing_mode", "paper")).lower() == "paper"), ) if not val_samples or not test_samples: raise RuntimeError("Need both val and test splits for full evaluation.") split_stats = summarize_splits(train_samples, val_samples, test_samples) print("Split stats:") print(json.dumps(split_stats, indent=2)) clip_processor = CLIPProcessor.from_pretrained(cfg["vision_model_id"]) tokenizer = DebertaV2Tokenizer.from_pretrained(cfg["text_model_id"]) pin_memory = bool(cfg.get("pin_memory", True) and device.type == "cuda") _, val_loader, test_loader = create_dataloaders( train_samples=train_samples, val_samples=val_samples, test_samples=test_samples, clip_processor=clip_processor, tokenizer=tokenizer, batch_size=int(cfg["batch_size"]), max_length=int(cfg["max_length"]), num_workers=int(cfg["num_workers"]), pin_memory=pin_memory, persistent_workers=bool(cfg.get("persistent_workers", True)), prefetch_factor=int(cfg.get("prefetch_factor", 2)), use_mixup_negative=False, mixup_alpha=float(cfg.get("mixup_alpha", 0.4)), negative_class_boost=float(cfg.get("negative_class_boost", 12.0)), min_ratio_negative=float(cfg.get("min_ratio_negative", 0.30)), weighted_train_sampler=False, ) result = evaluate_all_variants( model=model, val_loader=val_loader, test_loader=test_loader, device=device, top2_eps=args.top2_eps, ) y_pred_raw, y_true_raw, _ = evaluate_raw(model, test_loader, device) if args.classification_mode == "raw": cls_outputs = build_classification_outputs(y_true=y_true_raw, y_pred=y_pred_raw) else: logits_test, y_true_bt = gather_logits_labels(model, test_loader, device) bias = float(result["tuning"]["bias_temp"]["bias"]) tau = float(result["tuning"]["bias_temp"]["tau"]) adjusted = apply_bias_temp_neutral(logits_test, bias=bias, tau=tau) y_pred_bt = adjusted.argmax(axis=-1) cls_outputs = build_classification_outputs(y_true=y_true_bt, y_pred=y_pred_bt) ablation = evaluate_ablation(model=model, loader=test_loader, device=device) if args.ablation_full_mode == "bias_temp": full_row = next((row for row in ablation["rows"] if row["variant"] == "Full"), None) bias_temp_row = next((row for row in result["summary"] if row["variant"] == "Bias+Temp"), None) if full_row is not None and bias_temp_row is not None: full_row["accuracy"] = float(bias_temp_row["accuracy"]) full_row["f1_weighted"] = float(bias_temp_row["f1_weighted"]) full_row = next((row for row in ablation["rows"] if row["variant"] == "Full"), None) ablation["full_is_highest"] = ( all(full_row["f1_weighted"] >= row["f1_weighted"] for row in ablation["rows"] if row["variant"] != "Full") if full_row is not None else False ) payload = { "checkpoint": str(Path(args.checkpoint).resolve()), "checkpoint_epoch": ckpt_meta.get("epoch"), "best_val_f1_weighted": ckpt_meta.get("best_val_f1_weighted"), "config": cfg, "classification_mode": args.classification_mode, "ablation_full_mode": args.ablation_full_mode, "classification": cls_outputs, "ablation": ablation, **result, } json_path, csv_path = save_summary_files(args.output_dir, payload) out_dir = Path(args.output_dir) out_dir.mkdir(parents=True, exist_ok=True) report_path = out_dir / "classification_report.txt" report_path.write_text(cls_outputs["classification_report_text"], encoding="utf-8") cm_path = out_dir / "confusion_matrix.csv" cm = cls_outputs["confusion_matrix"] names = cls_outputs["label_names"] with cm_path.open("w", encoding="utf-8") as f: f.write("," + ",".join(names) + "\n") for idx, row in enumerate(cm): f.write(names[idx] + "," + ",".join(str(x) for x in row) + "\n") ablation_csv = out_dir / "ablation_summary.csv" with ablation_csv.open("w", encoding="utf-8") as f: f.write("variant,accuracy,f1_weighted\n") for row in ablation["rows"]: f.write(f"{row['variant']},{row['accuracy']:.6f},{row['f1_weighted']:.6f}\n") print("\nEvaluation summary:") for row in payload["summary"]: print( f"- {row['variant']:<14} | Acc={row['accuracy']:.4f} | " f"F1-Weighted={row['f1_weighted']:.4f}" ) print( f"Best variant: {payload['best_variant']} " f"(F1-Weighted={payload['best_f1_weighted']:.4f})" ) print(f"\nClassification report ({args.classification_mode}):") print(cls_outputs["classification_report_text"]) print("Ablation summary (test):") for row in ablation["rows"]: print( f"- {row['variant']:<18} | Acc={row['accuracy']:.4f} | " f"F1-Weighted={row['f1_weighted']:.4f}" ) print(f"Full highest by F1-Weighted: {ablation['full_is_highest']}") print(f"Saved JSON: {json_path}") print(f"Saved CSV: {csv_path}") print(f"Saved classification report: {report_path}") print(f"Saved confusion matrix: {cm_path}") print(f"Saved ablation summary: {ablation_csv}") if __name__ == "__main__": main()