evaluate.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  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. ) -> np.ndarray:
  94. """Run ``n_passes`` forward passes over the test set, returning ``(K, S, C)``.
  95. For a deterministic model use ``n_passes == 1``. Bayesian models sample fresh
  96. weights each pass (their reparameterization layers stay stochastic in eval
  97. mode), producing the Monte-Carlo ensemble the uncertainty measures need.
  98. """
  99. n_samples = len(loader.dataset)
  100. mc = np.zeros((n_passes, n_samples, n_classes), dtype=np.float32)
  101. pass_tracker = parent_tracker.get_sub_tracker(label)
  102. pass_tracker.update(total=n_passes, advance=0)
  103. with torch.no_grad():
  104. for k in range(n_passes):
  105. # Re-seed per pass so the noise realization is identical across MC
  106. # passes (and across models) for a given (sample, noise_level).
  107. noise_gen = (
  108. torch.Generator(device=device).manual_seed(int(noise_seed))
  109. if noise_level > 0.0
  110. else None
  111. )
  112. batch_tracker = pass_tracker.get_sub_tracker("Batches")
  113. batch_tracker.update(total=len(loader), advance=0)
  114. idx = 0
  115. for mri, xls, _targets, _ids in loader:
  116. check_control_events(stop_event, pause_event)
  117. mri = mri.to(device, non_blocking=True)
  118. xls = xls.to(device, non_blocking=True)
  119. if noise_gen is not None:
  120. noise = torch.randn(
  121. mri.shape, generator=noise_gen, device=device, dtype=mri.dtype
  122. )
  123. mri = mri + noise * noise_level
  124. outputs = model((mri, xls))
  125. bs = int(outputs.shape[0])
  126. mc[k, idx : idx + bs] = outputs.detach().cpu().numpy()
  127. idx += bs
  128. batch_tracker.update(advance=1)
  129. pass_tracker.update(advance=1)
  130. return mc
  131. def _evaluate_ensemble(
  132. *,
  133. model_paths: Sequence[pl.Path],
  134. kind: str,
  135. config: Dict[str, Any],
  136. state: Dict[str, Any],
  137. log: PipelineLogger,
  138. track: ProgressTracker,
  139. noise_levels: Sequence[float],
  140. n_passes: int,
  141. out_path: pl.Path,
  142. split: str = SPLIT_TEST,
  143. ) -> None:
  144. """Evaluate one ensemble and write a schema-compliant netCDF to ``out_path``."""
  145. device = config["training"]["device"]
  146. n_classes = int(config["data"]["num_classes"])
  147. base_seed = int(config["data"]["seed"])
  148. stop_event, pause_event = _events(state)
  149. loader = state[_SPLIT_LOADERS[split]]
  150. image_to_ptid: Dict[int, str] = state.get("image_to_ptid", {})
  151. image_ids, ptids, true_classes = _collect_sample_metadata(loader, image_to_ptid)
  152. log.info(
  153. f"Evaluating {len(model_paths)} {kind} model(s) on {len(image_ids)} "
  154. f"{split} samples, {len(noise_levels)} noise level(s), "
  155. f"{n_passes} MC pass(es) each."
  156. )
  157. accumulator = EvaluationAccumulator(
  158. image_ids, ptids, true_classes, noise_levels=list(noise_levels)
  159. )
  160. models_tracker = track.get_sub_tracker(f"{kind.capitalize()} models")
  161. models_tracker.update(total=len(model_paths), advance=0)
  162. for model_index, path in enumerate(model_paths):
  163. check_control_events(stop_event, pause_event)
  164. log.info(f"Loading {kind} model {model_index + 1}/{len(model_paths)}: {path.name}")
  165. model = load_model(path, kind, config, device)
  166. for level_index, noise in enumerate(noise_levels):
  167. noise_seed = derive_seed(base_seed, level_index, stream=_NOISE_SEED_STREAM)
  168. mc_probs = _run_mc_passes(
  169. model=model,
  170. loader=loader,
  171. device=device,
  172. noise_level=float(noise),
  173. n_passes=n_passes,
  174. noise_seed=noise_seed,
  175. n_classes=n_classes,
  176. parent_tracker=models_tracker,
  177. stop_event=stop_event,
  178. pause_event=pause_event,
  179. label=f"model {model_index + 1} | sigma={noise}",
  180. )
  181. accumulator.add(
  182. model_index=model_index,
  183. model_kind=kind,
  184. noise_level=float(noise),
  185. mc_probs=mc_probs,
  186. )
  187. del model
  188. release_torch_memory(device)
  189. models_tracker.update(advance=1)
  190. dataset = accumulator.to_dataset(
  191. attrs={
  192. "seed": base_seed,
  193. "n_mc": n_passes,
  194. "ensemble_kind": kind,
  195. "split": split,
  196. "config": _config_snapshot(config),
  197. }
  198. )
  199. out_path.parent.mkdir(parents=True, exist_ok=True)
  200. save_evaluation(dataset, str(out_path))
  201. log.info(
  202. f"Saved {kind} {split} evaluation to {out_path} "
  203. f"(models={dataset.sizes['model']}, samples={dataset.sizes['sample']}, "
  204. f"noise_levels={dataset.sizes['noise_level']})."
  205. )
  206. def _config_snapshot(config: Dict[str, Any]) -> str:
  207. try:
  208. return toml.dumps(config)
  209. except Exception:
  210. return repr(config)
  211. def _eval_cfg(config: Dict[str, Any]):
  212. eval_cfg = config.get("evaluation", {})
  213. noise_levels = list(eval_cfg.get("noise_levels", _DEFAULT_NOISE_LEVELS))
  214. mc_passes = int(eval_cfg.get("mc_passes", _DEFAULT_MC_PASSES))
  215. return noise_levels, mc_passes
  216. def _out_path(config: Dict[str, Any], name: str) -> pl.Path:
  217. return pl.Path(config["work_dir"]) / _EVAL_SUBDIR / name
  218. def eval_filename(split: str, kind: str, noisy: bool = False) -> str:
  219. """Canonical evaluation filename for a (split, kind, noisy) combination.
  220. The test split keeps its historical un-prefixed names (``normal.nc``,
  221. ``noisy_bayesian.nc``); other splits are prefixed (``val_normal.nc``).
  222. """
  223. prefix = "" if split == SPLIT_TEST else f"{split}_"
  224. return f"{prefix}{'noisy_' if noisy else ''}{kind}.nc"
  225. def _evaluate_clean(
  226. track: ProgressTracker,
  227. log: PipelineLogger,
  228. config: Dict[str, Any],
  229. state: Dict[str, Any],
  230. kind: str,
  231. split: str,
  232. ) -> None:
  233. """Evaluate one ensemble on clean data for ``split`` (steps 4/5)."""
  234. key = "normal_model_paths" if kind == KIND_NORMAL else "bayesian_model_paths"
  235. paths = state.get(key) or []
  236. if not paths:
  237. log.error(f"No {kind} models to evaluate (run training/load_models first).")
  238. return
  239. n_passes = 1 if kind == KIND_NORMAL else _eval_cfg(config)[1]
  240. _evaluate_ensemble(
  241. model_paths=paths,
  242. kind=kind,
  243. config=config,
  244. state=state,
  245. log=log,
  246. track=track,
  247. noise_levels=[0.0],
  248. n_passes=n_passes,
  249. out_path=_out_path(config, eval_filename(split, kind)),
  250. split=split,
  251. )
  252. def _evaluate_noisy(
  253. track: ProgressTracker,
  254. log: PipelineLogger,
  255. config: Dict[str, Any],
  256. state: Dict[str, Any],
  257. split: str,
  258. ) -> None:
  259. """Evaluate both ensembles across the noise sweep for ``split`` (step 6)."""
  260. noise_levels, mc_passes = _eval_cfg(config)
  261. any_models = False
  262. for kind, key, n_passes in (
  263. (KIND_NORMAL, "normal_model_paths", 1),
  264. (KIND_BAYESIAN, "bayesian_model_paths", mc_passes),
  265. ):
  266. paths = state.get(key) or []
  267. if not paths:
  268. log.info(f"No {kind} models found; skipping {kind} noisy evaluation.")
  269. continue
  270. any_models = True
  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=noise_levels,
  279. n_passes=n_passes,
  280. out_path=_out_path(config, eval_filename(split, kind, noisy=True)),
  281. split=split,
  282. )
  283. if not any_models:
  284. log.error("No models to evaluate on noised data.")
  285. # ---------------------------------------------------------------------------
  286. # Pipeline task entry points
  287. # ---------------------------------------------------------------------------
  288. def evaluate_normal_task(track, log, config, state) -> None:
  289. """Step 4: normal ensemble on the clean TEST split."""
  290. _evaluate_clean(track, log, config, state, KIND_NORMAL, SPLIT_TEST)
  291. def evaluate_bayesian_task(track, log, config, state) -> None:
  292. """Step 5: Bayesian ensemble (MC uncertainty) on the clean TEST split."""
  293. _evaluate_clean(track, log, config, state, KIND_BAYESIAN, SPLIT_TEST)
  294. def evaluate_noisy_task(track, log, config, state) -> None:
  295. """Step 6: both ensembles across the noise sweep on the TEST split."""
  296. _evaluate_noisy(track, log, config, state, SPLIT_TEST)
  297. def evaluate_normal_val_task(track, log, config, state) -> None:
  298. """Normal ensemble on the clean VALIDATION split."""
  299. _evaluate_clean(track, log, config, state, KIND_NORMAL, SPLIT_VAL)
  300. def evaluate_bayesian_val_task(track, log, config, state) -> None:
  301. """Bayesian ensemble (MC uncertainty) on the clean VALIDATION split."""
  302. _evaluate_clean(track, log, config, state, KIND_BAYESIAN, SPLIT_VAL)
  303. def evaluate_noisy_val_task(track, log, config, state) -> None:
  304. """Both ensembles across the noise sweep on the VALIDATION split."""
  305. _evaluate_noisy(track, log, config, state, SPLIT_VAL)
  306. def combine_evaluations_task(
  307. track: ProgressTracker,
  308. log: PipelineLogger,
  309. config: Dict[str, Any],
  310. state: Dict[str, Any],
  311. ) -> None:
  312. """Pool per-split evaluations into ``combined_*.nc`` for analysis.
  313. Concatenates the configured splits (default test + val) along the ``sample``
  314. axis. The splits are patient-disjoint by construction, so pooling them yields
  315. a larger evaluation set with no duplicated samples -- which is the point:
  316. more evaluation samples means tighter accuracy/uncertainty estimates.
  317. Analysis prefers these pooled files over the single-split ones.
  318. """
  319. eval_dir = pl.Path(config["work_dir"]) / _EVAL_SUBDIR
  320. splits = list(
  321. config.get("evaluation", {}).get("combine_splits", [SPLIT_TEST, SPLIT_VAL])
  322. )
  323. if len(splits) < 2:
  324. log.info(
  325. f"combine_evaluations: only {splits} configured; nothing to pool."
  326. )
  327. return
  328. written = 0
  329. for kind in (KIND_NORMAL, KIND_BAYESIAN):
  330. for noisy in (False, True):
  331. paths = [
  332. eval_dir / eval_filename(split, kind, noisy=noisy) for split in splits
  333. ]
  334. present = [p for p in paths if p.exists()]
  335. if len(present) < 2:
  336. continue
  337. parts = [load_evaluation(str(p)) for p in present]
  338. try:
  339. # Guard against a mis-specified split: the same patient must not
  340. # appear in two pooled parts (that would double-count them).
  341. seen: set[str] = set()
  342. overlap: set[str] = set()
  343. for part in parts:
  344. part_ptids = {str(p) for p in np.atleast_1d(part["ptid"].values)}
  345. overlap |= seen & part_ptids
  346. seen |= part_ptids
  347. if overlap:
  348. log.error(
  349. f"combine_evaluations: {len(overlap)} patient(s) appear in "
  350. f"more than one split ({sorted(overlap)[:3]}...); refusing "
  351. f"to pool {kind} (noisy={noisy})."
  352. )
  353. continue
  354. combined = concat_evaluations(
  355. parts,
  356. attrs={
  357. "split": "+".join(splits),
  358. "combined_from": ", ".join(p.name for p in present),
  359. "n_sources": len(present),
  360. },
  361. )
  362. out_name = f"combined_{'noisy_' if noisy else ''}{kind}.nc"
  363. save_evaluation(combined, str(eval_dir / out_name))
  364. written += 1
  365. log.info(
  366. f"combine_evaluations: wrote {out_name} "
  367. f"({combined.sizes['sample']} samples from "
  368. f"{', '.join(p.name for p in present)})."
  369. )
  370. finally:
  371. for part in parts:
  372. part.close()
  373. if written == 0:
  374. log.error(
  375. "combine_evaluations: found no split pairs to pool. Run the test and "
  376. "validation evaluation steps first."
  377. )