evaluate.py 17 KB

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