"""Re-run the brutal OOD audit on the v8-distribution-RETRAINED classifiers. Direct apples-to-apples vs scripts/eval_ood_classifiers_brutal.py, which audited the OLD classifiers (trained on Kaggle 4-class only). Difference: classifiers are loaded from real_eval_v8_retrained/ instead of real_eval_current/. Same OOD samples, same metric definitions. """ from __future__ import annotations import sys import time from pathlib import Path import numpy as np import onnxruntime as ort from PIL import Image ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) # Use the same eval helpers as the original brutal audit; we just swap # the classifier ONNX paths. from scripts.eval_ood_cascade import ( SEG_ONNX, MIN_TUMOR_AREA, modality_of, GT, _sess, _preprocess_seg, _preprocess_clf, seg_tta, ) NEW_CLF_ONNX = { 'cnn': ROOT / 'real_eval_v8_retrained' / 'cnn' / 'best_weights.onnx', 'transfer': ROOT / 'real_eval_v8_retrained' / 'transfer' / 'best_weights.onnx', 'vit': ROOT / 'real_eval_v8_retrained' / 'vit' / 'best_weights.onnx', } # CNN is the only one NOT ImageNet-normalised (matches the original # build and what dashboard.py knows about via ckpt['normalize_imagenet']). NORMALIZE_IMAGENET = {'cnn': False, 'transfer': True, 'vit': True} SAMPLES_DIR = ROOT / 'samples' / 'ood' def classify_all(clfs: dict, img: Image.Image) -> dict: out = {} for name, sess in clfs.items(): chw = _preprocess_clf(img, NORMALIZE_IMAGENET[name]) logit = float(sess.run(None, {sess.get_inputs()[0].name: chw[None]})[0].reshape(-1)[0]) out[name] = 1.0 / (1.0 + np.exp(-logit)) return out def main(): for name, p in NEW_CLF_ONNX.items(): if not p.exists(): sys.exit(f'missing {name}: {p}') seg = _sess(SEG_ONNX) clfs = {n: _sess(p) for n, p in NEW_CLF_ONNX.items()} samples = sorted(p for p in SAMPLES_DIR.rglob('*') if p.suffix.lower() in ('.png', '.jpg', '.jpeg') and p.parent.name in GT) print(f'[init] {len(samples)} OOD samples; classifiers from real_eval_v8_retrained/') rows = [] t0 = time.perf_counter() for p in samples: img = Image.open(p) gt = GT[p.parent.name] probs = classify_all(clfs, img) prob_map = seg_tta(seg, _preprocess_seg(img)) rows.append({ 'source': p.parent.name, 'file': p.name, 'gt': gt, 'p_cnn': probs['cnn'], 'p_transfer': probs['transfer'], 'p_vit': probs['vit'], 'v8_pmax': float(prob_map.max()), 'v8_area_020': int((prob_map >= 0.20).sum()), }) print(f'[done] {len(rows)} samples in {time.perf_counter()-t0:.0f}s\n') # =================== per-classifier scorecard ========================= print('='*78) print('PER-CLASSIFIER ACCURACY ON OOD (v8-retrained — fresh weights, no cascade)') print('='*78) OLD_NUMBERS = { # from scripts/eval_ood_classifiers_brutal.py output 'cnn': {'recall': 0.28, 'fpr': 0.58, 'acc': 0.31}, 'transfer': {'recall': 0.36, 'fpr': 0.08, 'acc': 0.50}, 'vit': {'recall': 0.42, 'fpr': 0.08, 'acc': 0.54}, } for clf in ('cnn', 'transfer', 'vit'): pkey = f'p_{clf}' TP = sum(1 for r in rows if r['gt']=='tumor' and r[pkey]>=0.5) FN = sum(1 for r in rows if r['gt']=='tumor' and r[pkey]<0.5) FP = sum(1 for r in rows if r['gt']=='no_tumor' and r[pkey]>=0.5) TN = sum(1 for r in rows if r['gt']=='no_tumor' and r[pkey]<0.5) recall = TP/(TP+FN) if TP+FN else 0 fpr = FP/(FP+TN) if FP+TN else 0 acc = (TP+TN)/len(rows) old = OLD_NUMBERS[clf] d_rec = recall - old['recall'] d_fpr = fpr - old['fpr'] d_acc = acc - old['acc'] print(f'\n {clf.upper():12s} TP={TP:2d} FN={FN:2d} FP={FP:2d} TN={TN:2d}') print(f' NEW (v8-retrained): recall={recall:.0%} FPR={fpr:.0%} accuracy={acc:.0%}') print(f' OLD (Kaggle-only): recall={old["recall"]:.0%} FPR={old["fpr"]:.0%} accuracy={old["acc"]:.0%}') print(f' DELTA: recall {d_rec:+.0%} FPR {d_fpr:+.0%} accuracy {d_acc:+.0%}') # =================== per-source recall =============================== print('\n' + '='*78) print('PER-SOURCE RECALL (GT=tumor) and FPR (GT=no_tumor)') print('='*78) by_src = {} for r in rows: by_src.setdefault(r['source'], []).append(r) print(f'\n{"source":48s} GT n {"cnn":>5s} {"trans":>5s} {"vit":>5s} {"v8seg":>6s}') for src in sorted(by_src): rs = by_src[src] gt = rs[0]['gt'] n = len(rs) cells = [] for clf in ('p_cnn', 'p_transfer', 'p_vit'): hits = sum(1 for r in rs if r[clf] >= 0.5) cells.append(f'{hits/n:.0%}'.rjust(5)) v8_hits = sum(1 for r in rs if r['v8_area_020'] >= MIN_TUMOR_AREA) v8_metric = v8_hits/n kind = 'recall' if gt == 'tumor' else 'FP rate' print(f' {src:46s} {gt[:6]:6s} {n:3d} {cells[0]} {cells[1]} {cells[2]} ' f'{v8_metric:.0%}'.rjust(6) + f' <- {kind}') # =================== consensus breakdown ============================= print('\n' + '='*78) print('CLASSIFIER CONSENSUS ON 36 OOD TUMOR SAMPLES') print('='*78) tum = [r for r in rows if r['gt'] == 'tumor'] n_all_no = sum(1 for r in tum if r['p_cnn']<0.5 and r['p_transfer']<0.5 and r['p_vit']<0.5) n_all_yes = sum(1 for r in tum if r['p_cnn']>=0.5 and r['p_transfer']>=0.5 and r['p_vit']>=0.5) n_split = len(tum) - n_all_no - n_all_yes print(f'\n v8-retrained classifiers:') print(f' ALL 3 say "tumor" (clean detect): {n_all_yes:3d} / {len(tum)} ({n_all_yes/len(tum):.0%})') print(f' SPLIT (some yes, some no): {n_split:3d} / {len(tum)} ({n_split/len(tum):.0%})') print(f' ALL 3 say "no_tumor" (catastrophic): {n_all_no:3d} / {len(tum)} ({n_all_no/len(tum):.0%})') print(f'\n OLD Kaggle-only classifiers (for reference):') print(f' ALL 3 say "tumor": 0 / 36 (0%)') print(f' SPLIT: 26 / 36 (72%)') print(f' ALL 3 say "no_tumor": 10 / 36 (28%)') if __name__ == '__main__': main()