evaluate.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. """Evaluation tasks (pipeline steps 4-6).
  2. All three tasks share one core, :func:`_evaluate_ensemble`, which loads each
  3. ensemble member one at a time, runs Monte-Carlo forward passes over the (fixed,
  4. unshuffled) test set at each requested noise level, and writes the results to a
  5. netCDF file in the locked-down evaluation schema (:mod:`evaluation.schema`).
  6. step 4 evaluate_regular -> normal ensemble, noise=[0.0], K=1
  7. step 5 evaluate_bayesian -> Bayesian ensemble, noise=[0.0], K=mc_passes
  8. step 6 evaluate_noisy -> both ensembles across config noise_levels
  9. Every evaluator is parameterized by dataset *split*, so the same code produces
  10. test-split and validation-split evaluations (``*_val_task`` variants). Both
  11. loaders are unshuffled, so sample order is stable in each. Finally,
  12. ``combine_evaluations_task`` pools the per-split files into ``combined_*.nc``
  13. (test + val by default) -- the splits are patient-disjoint, so pooling simply
  14. enlarges the evaluation set that analysis draws on.
  15. Determinism
  16. -----------
  17. The test loader is unshuffled, so sample order is stable and aligns with the
  18. netCDF ``sample`` axis. Added Gaussian noise is drawn from a generator seeded by
  19. ``derive_seed(base_seed, noise_level_index, stream=_NOISE_SEED_STREAM)`` and
  20. re-seeded at the start of every MC pass, so:
  21. * the noise perturbation for a given (sample, level) is identical across the K
  22. MC passes (only the sampled BNN weights vary), and
  23. * every model sees the exact same noised inputs at a given level (fair
  24. comparison across the ensemble).
  25. """
  26. import pathlib as pl
  27. from typing import Any, Dict, List, Sequence
  28. import numpy as np
  29. import toml
  30. import torch
  31. import xarray as xr
  32. from evaluation.schema import (
  33. CLASS_NAMES,
  34. EvaluationAccumulator,
  35. concat_evaluations,
  36. concat_noise_levels,
  37. load_evaluation,
  38. save_evaluation,
  39. )
  40. from model.factory import KIND_BAYESIAN, KIND_NORMAL, load_model
  41. from util.control import check_control_events, release_torch_memory
  42. from util.progress import ProgressTracker
  43. from util.seeding import derive_seed
  44. from util.ui_logger import PipelineLogger
  45. # RNG stream id for input noise (0=normal training, 1=Bayesian training).
  46. _NOISE_SEED_STREAM = 2
  47. # Subdirectory (under work_dir) where evaluation netCDF files are written.
  48. _EVAL_SUBDIR = "evaluations"
  49. # Evaluable dataset splits -> the ``state`` key holding their DataLoader. Both
  50. # val and test loaders are unshuffled, so their sample order is stable and can be
  51. # aligned with the netCDF ``sample`` axis. (``train`` is available for
  52. # completeness but is NOT part of the default pipeline: its samples were fit on,
  53. # so its accuracy is optimistic and not a generalization estimate.)
  54. SPLIT_TEST = "test"
  55. SPLIT_VAL = "val"
  56. SPLIT_TRAIN = "train"
  57. _SPLIT_LOADERS = {
  58. SPLIT_TEST: "test_loader",
  59. SPLIT_VAL: "val_loader",
  60. SPLIT_TRAIN: "train_loader",
  61. }
  62. # Fallbacks if a config predates the [evaluation] section.
  63. _DEFAULT_NOISE_LEVELS: List[float] = [0.0, 0.02, 0.05, 0.1, 0.2]
  64. _DEFAULT_MC_PASSES = 30
  65. def _events(state: Dict[str, Any]):
  66. events = state.get("events")
  67. stop_event = events.get("stop") if isinstance(events, dict) else None
  68. pause_event = events.get("pause") if isinstance(events, dict) else None
  69. return stop_event, pause_event
  70. def _collect_sample_metadata(loader, image_to_ptid: Dict[int, str]):
  71. """Return ordered ``(image_ids, ptids, true_classes)`` for the test set.
  72. The order matches iteration of the (unshuffled) loader and therefore the
  73. order used when accumulating model outputs.
  74. """
  75. image_ids: List[int] = []
  76. true_classes: List[str] = []
  77. for _mri, _xls, targets, ids in loader:
  78. for i in range(int(targets.shape[0])):
  79. image_ids.append(int(ids[i]))
  80. true_classes.append(CLASS_NAMES[int(targets[i].argmax())])
  81. ptids = [str(image_to_ptid.get(iid, "unknown")) for iid in image_ids]
  82. return image_ids, ptids, true_classes
  83. def _run_mc_passes(
  84. model: torch.nn.Module,
  85. loader,
  86. device: str,
  87. noise_level: float,
  88. n_passes: int,
  89. noise_seed: int,
  90. n_classes: int,
  91. parent_tracker: ProgressTracker,
  92. stop_event,
  93. pause_event,
  94. label: str,
  95. noise_relative: bool = True,
  96. ) -> np.ndarray:
  97. """Run ``n_passes`` forward passes over the test set, returning ``(K, S, C)``.
  98. For a deterministic model use ``n_passes == 1``. Bayesian models sample fresh
  99. weights each pass (their reparameterization layers stay stochastic in eval
  100. mode), producing the Monte-Carlo ensemble the uncertainty measures need.
  101. Args:
  102. noise_relative: Scale the Gaussian noise by each image's own intensity
  103. standard deviation, so ``noise_level`` means "fraction of image std".
  104. Required for this dataset: the PET volumes are unnormalised (std in
  105. the thousands), so an absolute sigma of order 1 is numerically
  106. invisible. Per-image (not per-batch) scaling keeps the corruption
  107. level identical for every sample regardless of its intensity range,
  108. and independent of batch composition.
  109. """
  110. n_samples = len(loader.dataset)
  111. mc = np.zeros((n_passes, n_samples, n_classes), dtype=np.float32)
  112. pass_tracker = parent_tracker.get_sub_tracker(label)
  113. pass_tracker.update(total=n_passes, advance=0)
  114. with torch.no_grad():
  115. for k in range(n_passes):
  116. # Re-seed per pass so the noise realization is identical across MC
  117. # passes (and across models) for a given (sample, noise_level).
  118. noise_gen = (
  119. torch.Generator(device=device).manual_seed(int(noise_seed))
  120. if noise_level > 0.0
  121. else None
  122. )
  123. batch_tracker = pass_tracker.get_sub_tracker("Batches")
  124. batch_tracker.update(total=len(loader), advance=0)
  125. idx = 0
  126. for mri, xls, _targets, _ids in loader:
  127. check_control_events(stop_event, pause_event)
  128. mri = mri.to(device, non_blocking=True)
  129. xls = xls.to(device, non_blocking=True)
  130. if noise_gen is not None:
  131. noise = torch.randn(
  132. mri.shape, generator=noise_gen, device=device, dtype=mri.dtype
  133. )
  134. if noise_relative:
  135. # Per-image std over the voxel dims -> sigma is a fraction
  136. # of that image's own intensity spread.
  137. scale = mri.std(
  138. dim=tuple(range(1, mri.ndim)), keepdim=True
  139. )
  140. else:
  141. scale = 1.0
  142. mri = mri + noise * (noise_level * scale)
  143. outputs = model((mri, xls))
  144. bs = int(outputs.shape[0])
  145. mc[k, idx : idx + bs] = outputs.detach().cpu().numpy()
  146. idx += bs
  147. batch_tracker.update(advance=1)
  148. pass_tracker.update(advance=1)
  149. return mc
  150. def _evaluate_ensemble(
  151. *,
  152. model_paths: Sequence[pl.Path],
  153. kind: str,
  154. config: Dict[str, Any],
  155. state: Dict[str, Any],
  156. log: PipelineLogger,
  157. track: ProgressTracker,
  158. noise_levels: Sequence[float],
  159. n_passes: int,
  160. out_path: pl.Path,
  161. split: str = SPLIT_TEST,
  162. ) -> None:
  163. """Evaluate one ensemble and write a schema-compliant netCDF to ``out_path``."""
  164. device = config["training"]["device"]
  165. n_classes = int(config["data"]["num_classes"])
  166. base_seed = int(config["data"]["seed"])
  167. stop_event, pause_event = _events(state)
  168. noise_relative = bool(
  169. config.get("evaluation", {}).get("noise_relative", True)
  170. )
  171. loader = state[_SPLIT_LOADERS[split]]
  172. image_to_ptid: Dict[int, str] = state.get("image_to_ptid", {})
  173. image_ids, ptids, true_classes = _collect_sample_metadata(loader, image_to_ptid)
  174. log.info(
  175. f"Evaluating {len(model_paths)} {kind} model(s) on {len(image_ids)} "
  176. f"{split} samples, {len(noise_levels)} noise level(s), "
  177. f"{n_passes} MC pass(es) each."
  178. )
  179. accumulator = EvaluationAccumulator(
  180. image_ids, ptids, true_classes, noise_levels=list(noise_levels)
  181. )
  182. models_tracker = track.get_sub_tracker(f"{kind.capitalize()} models")
  183. models_tracker.update(total=len(model_paths), advance=0)
  184. for model_index, path in enumerate(model_paths):
  185. check_control_events(stop_event, pause_event)
  186. log.info(f"Loading {kind} model {model_index + 1}/{len(model_paths)}: {path.name}")
  187. model = load_model(path, kind, config, device)
  188. for level_index, noise in enumerate(noise_levels):
  189. noise_seed = derive_seed(base_seed, level_index, stream=_NOISE_SEED_STREAM)
  190. mc_probs = _run_mc_passes(
  191. model=model,
  192. loader=loader,
  193. device=device,
  194. noise_level=float(noise),
  195. n_passes=n_passes,
  196. noise_seed=noise_seed,
  197. n_classes=n_classes,
  198. parent_tracker=models_tracker,
  199. stop_event=stop_event,
  200. pause_event=pause_event,
  201. label=f"model {model_index + 1} | sigma={noise}",
  202. noise_relative=noise_relative,
  203. )
  204. accumulator.add(
  205. model_index=model_index,
  206. model_kind=kind,
  207. noise_level=float(noise),
  208. mc_probs=mc_probs,
  209. )
  210. del model
  211. release_torch_memory(device)
  212. models_tracker.update(advance=1)
  213. dataset = accumulator.to_dataset(
  214. attrs={
  215. "seed": base_seed,
  216. "n_mc": n_passes,
  217. "ensemble_kind": kind,
  218. "split": split,
  219. # 1 => noise_level is a fraction of each image's intensity std;
  220. # 0 => it is an absolute value in raw voxel units.
  221. "noise_relative": int(noise_relative),
  222. "config": _config_snapshot(config),
  223. }
  224. )
  225. out_path.parent.mkdir(parents=True, exist_ok=True)
  226. save_evaluation(dataset, str(out_path))
  227. log.info(
  228. f"Saved {kind} {split} evaluation to {out_path} "
  229. f"(models={dataset.sizes['model']}, samples={dataset.sizes['sample']}, "
  230. f"noise_levels={dataset.sizes['noise_level']})."
  231. )
  232. def _config_snapshot(config: Dict[str, Any]) -> str:
  233. try:
  234. return toml.dumps(config)
  235. except Exception:
  236. return repr(config)
  237. def _eval_cfg(config: Dict[str, Any]):
  238. eval_cfg = config.get("evaluation", {})
  239. noise_levels = list(eval_cfg.get("noise_levels", _DEFAULT_NOISE_LEVELS))
  240. mc_passes = int(eval_cfg.get("mc_passes", _DEFAULT_MC_PASSES))
  241. return noise_levels, mc_passes
  242. def _out_path(config: Dict[str, Any], name: str) -> pl.Path:
  243. return pl.Path(config["work_dir"]) / _EVAL_SUBDIR / name
  244. def eval_filename(
  245. split: str, kind: str, noisy: bool = False, extra: bool = False
  246. ) -> str:
  247. """Canonical evaluation filename for a (split, kind, noisy, extra) combination.
  248. The test split keeps its historical un-prefixed names (``normal.nc``,
  249. ``noisy_bayesian.nc``); other splits are prefixed (``val_normal.nc``).
  250. ``extra`` marks an *additional* noise sweep computed in a later run
  251. (``extra_noisy_normal.nc``), which is merged onto the base sweep's
  252. ``noise_level`` axis by :func:`combine_evaluations_task`.
  253. """
  254. prefix = "" if split == SPLIT_TEST else f"{split}_"
  255. return f"{prefix}{'extra_' if extra else ''}{'noisy_' if noisy else ''}{kind}.nc"
  256. def _evaluate_clean(
  257. track: ProgressTracker,
  258. log: PipelineLogger,
  259. config: Dict[str, Any],
  260. state: Dict[str, Any],
  261. kind: str,
  262. split: str,
  263. ) -> None:
  264. """Evaluate one ensemble on clean data for ``split`` (steps 4/5)."""
  265. key = "normal_model_paths" if kind == KIND_NORMAL else "bayesian_model_paths"
  266. paths = state.get(key) or []
  267. if not paths:
  268. log.error(f"No {kind} models to evaluate (run training/load_models first).")
  269. return
  270. n_passes = 1 if kind == KIND_NORMAL else _eval_cfg(config)[1]
  271. _evaluate_ensemble(
  272. model_paths=paths,
  273. kind=kind,
  274. config=config,
  275. state=state,
  276. log=log,
  277. track=track,
  278. noise_levels=[0.0],
  279. n_passes=n_passes,
  280. out_path=_out_path(config, eval_filename(split, kind)),
  281. split=split,
  282. )
  283. def _evaluate_noisy(
  284. track: ProgressTracker,
  285. log: PipelineLogger,
  286. config: Dict[str, Any],
  287. state: Dict[str, Any],
  288. split: str,
  289. extra: bool = False,
  290. ) -> None:
  291. """Evaluate both ensembles across a noise sweep for ``split`` (step 6).
  292. With ``extra=True`` the sweep uses ``evaluation.extra_noise_levels`` and is
  293. written to separate ``extra_*`` files, so additional sigmas can be computed
  294. later and merged without recomputing the original sweep.
  295. """
  296. noise_levels, mc_passes = _eval_cfg(config)
  297. if extra:
  298. noise_levels = list(
  299. config.get("evaluation", {}).get("extra_noise_levels", [])
  300. )
  301. if not noise_levels:
  302. log.error(
  303. "No evaluation.extra_noise_levels configured; nothing to add. "
  304. "Set them in config.toml to extend the sweep."
  305. )
  306. return
  307. any_models = False
  308. for kind, key, n_passes in (
  309. (KIND_NORMAL, "normal_model_paths", 1),
  310. (KIND_BAYESIAN, "bayesian_model_paths", mc_passes),
  311. ):
  312. paths = state.get(key) or []
  313. if not paths:
  314. log.info(f"No {kind} models found; skipping {kind} noisy evaluation.")
  315. continue
  316. any_models = True
  317. _evaluate_ensemble(
  318. model_paths=paths,
  319. kind=kind,
  320. config=config,
  321. state=state,
  322. log=log,
  323. track=track,
  324. noise_levels=noise_levels,
  325. n_passes=n_passes,
  326. out_path=_out_path(
  327. config, eval_filename(split, kind, noisy=True, extra=extra)
  328. ),
  329. split=split,
  330. )
  331. if not any_models:
  332. log.error("No models to evaluate on noised data.")
  333. # ---------------------------------------------------------------------------
  334. # Pipeline task entry points
  335. # ---------------------------------------------------------------------------
  336. def evaluate_normal_task(track, log, config, state) -> None:
  337. """Step 4: normal ensemble on the clean TEST split."""
  338. _evaluate_clean(track, log, config, state, KIND_NORMAL, SPLIT_TEST)
  339. def evaluate_bayesian_task(track, log, config, state) -> None:
  340. """Step 5: Bayesian ensemble (MC uncertainty) on the clean TEST split."""
  341. _evaluate_clean(track, log, config, state, KIND_BAYESIAN, SPLIT_TEST)
  342. def evaluate_noisy_task(track, log, config, state) -> None:
  343. """Step 6: both ensembles across the noise sweep on the TEST split."""
  344. _evaluate_noisy(track, log, config, state, SPLIT_TEST)
  345. def evaluate_normal_val_task(track, log, config, state) -> None:
  346. """Normal ensemble on the clean VALIDATION split."""
  347. _evaluate_clean(track, log, config, state, KIND_NORMAL, SPLIT_VAL)
  348. def evaluate_bayesian_val_task(track, log, config, state) -> None:
  349. """Bayesian ensemble (MC uncertainty) on the clean VALIDATION split."""
  350. _evaluate_clean(track, log, config, state, KIND_BAYESIAN, SPLIT_VAL)
  351. def evaluate_noisy_val_task(track, log, config, state) -> None:
  352. """Both ensembles across the noise sweep on the VALIDATION split."""
  353. _evaluate_noisy(track, log, config, state, SPLIT_VAL)
  354. def evaluate_noisy_extra_task(track, log, config, state) -> None:
  355. """ADDITIONAL noise levels on the TEST split (extends an existing sweep)."""
  356. _evaluate_noisy(track, log, config, state, SPLIT_TEST, extra=True)
  357. def evaluate_noisy_extra_val_task(track, log, config, state) -> None:
  358. """ADDITIONAL noise levels on the VALIDATION split."""
  359. _evaluate_noisy(track, log, config, state, SPLIT_VAL, extra=True)
  360. def combine_evaluations_task(
  361. track: ProgressTracker,
  362. log: PipelineLogger,
  363. config: Dict[str, Any],
  364. state: Dict[str, Any],
  365. ) -> None:
  366. """Merge per-split evaluations into ``combined_*.nc`` for analysis.
  367. Two independent merges happen here:
  368. 1. **Noise levels** (``concat_noise_levels``) -- within one split, the base
  369. sweep and any ``extra_*`` sweep are unioned along ``noise_level``. This is
  370. what lets extra sigmas be computed later and folded in without recomputing
  371. the original sweep.
  372. 2. **Splits** (``concat_evaluations``) -- the configured splits (default test
  373. + val) are pooled along ``sample``. They are patient-disjoint by
  374. construction, so pooling enlarges the evaluation set with no duplicated
  375. samples, giving tighter accuracy/uncertainty estimates.
  376. Analysis prefers the resulting ``combined_*`` files over single-split ones.
  377. """
  378. eval_dir = pl.Path(config["work_dir"]) / _EVAL_SUBDIR
  379. splits = list(
  380. config.get("evaluation", {}).get("combine_splits", [SPLIT_TEST, SPLIT_VAL])
  381. )
  382. written = 0
  383. for kind in (KIND_NORMAL, KIND_BAYESIAN):
  384. for noisy in (False, True):
  385. per_split: List[xr.Dataset] = []
  386. sources: List[str] = []
  387. merged_noise = False
  388. try:
  389. for split in splits:
  390. # Stage 1: base + extra sweeps for this split.
  391. candidates = [
  392. eval_dir / eval_filename(split, kind, noisy=noisy),
  393. ]
  394. if noisy:
  395. candidates.append(
  396. eval_dir
  397. / eval_filename(split, kind, noisy=True, extra=True)
  398. )
  399. present = [p for p in candidates if p.exists()]
  400. if not present:
  401. continue
  402. parts = [load_evaluation(str(p)) for p in present]
  403. if len(parts) > 1:
  404. merged = concat_noise_levels(parts)
  405. for part in parts:
  406. part.close()
  407. merged_noise = True
  408. else:
  409. merged = parts[0]
  410. per_split.append(merged)
  411. sources.extend(p.name for p in present)
  412. if not per_split:
  413. continue
  414. # Nothing gained from writing a copy of a single source.
  415. if len(per_split) < 2 and not merged_noise:
  416. continue
  417. # Guard: the same patient must never appear in two pooled splits
  418. # (that would double-count them).
  419. seen: set[str] = set()
  420. overlap: set[str] = set()
  421. for part in per_split:
  422. part_ptids = {str(p) for p in np.atleast_1d(part["ptid"].values)}
  423. overlap |= seen & part_ptids
  424. seen |= part_ptids
  425. if overlap:
  426. log.error(
  427. f"combine_evaluations: {len(overlap)} patient(s) appear in "
  428. f"more than one split ({sorted(overlap)[:3]}...); refusing "
  429. f"to pool {kind} (noisy={noisy})."
  430. )
  431. continue
  432. # Stage 2: pool the splits along `sample`.
  433. combined = (
  434. concat_evaluations(per_split)
  435. if len(per_split) > 1
  436. else per_split[0]
  437. )
  438. combined.attrs.update(
  439. {
  440. "split": "+".join(splits[: len(per_split)]),
  441. "combined_from": ", ".join(sources),
  442. "n_sources": len(sources),
  443. }
  444. )
  445. out_name = f"combined_{'noisy_' if noisy else ''}{kind}.nc"
  446. save_evaluation(combined, str(eval_dir / out_name))
  447. written += 1
  448. log.info(
  449. f"combine_evaluations: wrote {out_name} "
  450. f"({combined.sizes['sample']} samples x "
  451. f"{combined.sizes['noise_level']} noise level(s) from "
  452. f"{', '.join(sources)})."
  453. )
  454. finally:
  455. for part in per_split:
  456. part.close()
  457. if written == 0:
  458. log.error(
  459. "combine_evaluations: nothing to merge. Run the test/validation "
  460. "evaluation steps (and any extra noise steps) first."
  461. )