"""In-distribution regression test for the view-aware cascade policy. Question: does the view-aware policy (src/research/view_router.py) hurt performance on the dataset_v8 test split — the very distribution v8 was calibrated for? If yes, we should NOT wire it into the dashboard. Sample: stratified random ~100 per source from dataset_v8/test, where masks tell us GT (mask sum > 50px -> tumor, else no_tumor). Compares three policies side-by-side (same as eval_ood_view_aware.py): A) v8-only @ t=0.20 B) current cascade (v8@0.20 + classifier consensus suppression) C) view-aware cascade (view-detect -> per-view threshold + override) Per-source numbers + an aggregate confusion table. A regression alert fires if (C) is worse than (B) on aggregate FP or recall by > 3 pp. """ from __future__ import annotations import random import sys import time from pathlib import Path import numpy as np from PIL import Image ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from scripts.eval_ood_cascade import ( SEG_ONNX, CLF_ONNX, MIN_TUMOR_AREA, _sess, _preprocess_seg, seg_tta, classify_all, consensus, modality_of, ) from src.research.view_router import detect_view, cascade_decision TEST_IMG_DIR = ROOT / 'dataset_v8' / 'test' / 'images' TEST_MASK_DIR = ROOT / 'dataset_v8' / 'test' / 'masks' PER_SOURCE = 100 # stratified sample size per prefix SEED = 1234 def _gt_from_mask(stem: str) -> str: mp = TEST_MASK_DIR / f'{stem}.png' if not mp.exists(): return 'unknown' m = np.asarray(Image.open(mp).convert('L')) return 'tumor' if int((m > 127).sum()) >= MIN_TUMOR_AREA else 'no_tumor' def _source_of(name: str) -> str: if name.startswith('brats_t1c'): return 'brats_t1c' if name.startswith('figshare_glioma'): return 'figshare_glioma' if name.startswith('figshare_meningioma'): return 'figshare_meningioma' if name.startswith('figshare_pituitary'): return 'figshare_pituitary' if name.startswith('lgg_'): return 'lgg' if name.startswith('neg_'): return 'neg_kaggle' return name.split('_', 1)[0] def main(): rng = random.Random(SEED) by_src: dict[str, list[Path]] = {} for p in TEST_IMG_DIR.glob('*.png'): by_src.setdefault(_source_of(p.name), []).append(p) samples: list[Path] = [] for src in sorted(by_src): pool = by_src[src] rng.shuffle(pool) samples.extend(pool[:PER_SOURCE]) print(f'[init] stratified sample: {len(samples)} from ' f'{[(s, min(PER_SOURCE, len(by_src[s]))) for s in sorted(by_src)]}') seg = _sess(SEG_ONNX) clfs = {n: _sess(p) for n, p in CLF_ONNX.items()} rows = [] t0 = time.perf_counter() last_print = t0 for i, p in enumerate(samples): img = Image.open(p) gt = _gt_from_mask(p.stem) modality = modality_of(p.name) view_policy = detect_view(np.asarray(img.convert('RGB')), modality_hint=modality if modality != 'unknown' else None) probs = classify_all(clfs, img) verdict_c, mean_p, band = consensus(probs) prob_map = seg_tta(seg, _preprocess_seg(img)) seg_max = float(prob_map.max()) area_020 = int((prob_map >= 0.20).sum()) area_view = int((prob_map >= view_policy.threshold).sum()) # A) v8-only v8_only = 'TUMOR' if area_020 >= MIN_TUMOR_AREA else 'no_tumor' # B) current cascade if verdict_c == 'no_tumor' and band in ('high', 'moderate'): current = 'no_tumor' elif verdict_c == 'tumor' and band in ('high', 'moderate'): current = 'TUMOR' else: current = 'TUMOR' if area_020 >= MIN_TUMOR_AREA else 'no_tumor' # C) view-aware view_aware, _reason = cascade_decision( seg_max_prob=seg_max, seg_area_at_view_thresh=area_view, classifier_mean_p=mean_p, classifier_band=band, view_policy=view_policy, ) rows.append({ 'source': _source_of(p.name), 'file': p.name, 'gt': gt, 'view': view_policy.view, 'thresh': view_policy.threshold, 'mean_p': mean_p, 'band': band, 'v8_only': v8_only, 'current': current, 'view_aware': view_aware, }) # Progress every 30s if time.perf_counter() - last_print > 30: last_print = time.perf_counter() print(f' [{i+1}/{len(samples)}] elapsed={time.perf_counter()-t0:.0f}s') elapsed = time.perf_counter() - t0 print(f'[done] {len(rows)} samples in {elapsed:.1f}s ({elapsed/len(rows):.2f}/sample)\n') # ---- Per-source per-policy aggregates ---- def _stats(rs, col): gts = [r['gt'] for r in rs] preds = [r[col] for r in rs] TP = sum(1 for g, p in zip(gts, preds) if g == 'tumor' and p == 'TUMOR') FN = sum(1 for g, p in zip(gts, preds) if g == 'tumor' and p == 'no_tumor') FP = sum(1 for g, p in zip(gts, preds) if g == 'no_tumor' and p == 'TUMOR') TN = sum(1 for g, p in zip(gts, preds) if g == 'no_tumor' and p == 'no_tumor') recall = TP / (TP + FN) if (TP + FN) else None fpr = FP / (FP + TN) if (FP + TN) else None return TP, FN, FP, TN, recall, fpr print('=== per-source: recall (sensitivity) / FPR ===') print(f'{"source":22s} n {"v8only":>14s} {"current":>14s} {"view_aware":>14s}') by = {} for r in rows: by.setdefault(r['source'], []).append(r) for src in sorted(by): rs = by[src] cells = [] for col in ('v8_only', 'current', 'view_aware'): TP, FN, FP, TN, re, fpr = _stats(rs, col) re_s = f'{re:.0%}' if re is not None else ' - ' fp_s = f'{fpr:.0%}' if fpr is not None else ' - ' cells.append(f'r={re_s}/f={fp_s}'.rjust(14)) print(f' {src:20s} {len(rs):3d} {cells[0]} {cells[1]} {cells[2]}') # ---- Aggregate print('\n=== ID-aggregate confusion (all sources combined) ===') print(f'{"policy":18s} TP FN FP TN recall FPR accuracy') for col in ('v8_only', 'current', 'view_aware'): TP, FN, FP, TN, re, fpr = _stats(rows, col) acc = (TP + TN) / len(rows) print(f' {col:16s} {TP:4d} {FN:4d} {FP:4d} {TN:4d} ' f'{(re or 0):.1%} {(fpr or 0):.1%} {acc:.1%}') # ---- Regression alert print('\n=== regression check (view_aware vs current) ===') _, _, _, _, re_cur, fpr_cur = _stats(rows, 'current') _, _, _, _, re_va, fpr_va = _stats(rows, 'view_aware') d_re = (re_va or 0) - (re_cur or 0) d_fp = (fpr_va or 0) - (fpr_cur or 0) print(f' d_recall = {d_re:+.1%} (positive = better)') print(f' d_FPR = {d_fp:+.1%} (negative = better)') if d_re < -0.03: print(f' [REGRESSION] recall dropped by {abs(d_re):.1%} (>3pp threshold)') if d_fp > 0.03: print(f' [REGRESSION] FPR rose by {d_fp:.1%} (>3pp threshold)') if d_re >= -0.03 and d_fp <= 0.03: print(f' [OK] no significant regression on ID data') if __name__ == '__main__': main()