evaluate.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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. Determinism
  10. -----------
  11. The test loader is unshuffled, so sample order is stable and aligns with the
  12. netCDF ``sample`` axis. Added Gaussian noise is drawn from a generator seeded by
  13. ``derive_seed(base_seed, noise_level_index, stream=_NOISE_SEED_STREAM)`` and
  14. re-seeded at the start of every MC pass, so:
  15. * the noise perturbation for a given (sample, level) is identical across the K
  16. MC passes (only the sampled BNN weights vary), and
  17. * every model sees the exact same noised inputs at a given level (fair
  18. comparison across the ensemble).
  19. """
  20. import pathlib as pl
  21. from typing import Any, Dict, List, Sequence
  22. import numpy as np
  23. import toml
  24. import torch
  25. from evaluation.schema import (
  26. CLASS_NAMES,
  27. EvaluationAccumulator,
  28. save_evaluation,
  29. )
  30. from model.factory import KIND_BAYESIAN, KIND_NORMAL, load_model
  31. from util.control import check_control_events, release_torch_memory
  32. from util.progress import ProgressTracker
  33. from util.seeding import derive_seed
  34. from util.ui_logger import PipelineLogger
  35. # RNG stream id for input noise (0=normal training, 1=Bayesian training).
  36. _NOISE_SEED_STREAM = 2
  37. # Subdirectory (under work_dir) where evaluation netCDF files are written.
  38. _EVAL_SUBDIR = "evaluations"
  39. # Fallbacks if a config predates the [evaluation] section.
  40. _DEFAULT_NOISE_LEVELS: List[float] = [0.0, 0.02, 0.05, 0.1, 0.2]
  41. _DEFAULT_MC_PASSES = 30
  42. def _events(state: Dict[str, Any]):
  43. events = state.get("events")
  44. stop_event = events.get("stop") if isinstance(events, dict) else None
  45. pause_event = events.get("pause") if isinstance(events, dict) else None
  46. return stop_event, pause_event
  47. def _collect_sample_metadata(loader, image_to_ptid: Dict[int, str]):
  48. """Return ordered ``(image_ids, ptids, true_classes)`` for the test set.
  49. The order matches iteration of the (unshuffled) loader and therefore the
  50. order used when accumulating model outputs.
  51. """
  52. image_ids: List[int] = []
  53. true_classes: List[str] = []
  54. for _mri, _xls, targets, ids in loader:
  55. for i in range(int(targets.shape[0])):
  56. image_ids.append(int(ids[i]))
  57. true_classes.append(CLASS_NAMES[int(targets[i].argmax())])
  58. ptids = [str(image_to_ptid.get(iid, "unknown")) for iid in image_ids]
  59. return image_ids, ptids, true_classes
  60. def _run_mc_passes(
  61. model: torch.nn.Module,
  62. loader,
  63. device: str,
  64. noise_level: float,
  65. n_passes: int,
  66. noise_seed: int,
  67. n_classes: int,
  68. parent_tracker: ProgressTracker,
  69. stop_event,
  70. pause_event,
  71. label: str,
  72. ) -> np.ndarray:
  73. """Run ``n_passes`` forward passes over the test set, returning ``(K, S, C)``.
  74. For a deterministic model use ``n_passes == 1``. Bayesian models sample fresh
  75. weights each pass (their reparameterization layers stay stochastic in eval
  76. mode), producing the Monte-Carlo ensemble the uncertainty measures need.
  77. """
  78. n_samples = len(loader.dataset)
  79. mc = np.zeros((n_passes, n_samples, n_classes), dtype=np.float32)
  80. pass_tracker = parent_tracker.get_sub_tracker(label)
  81. pass_tracker.update(total=n_passes, advance=0)
  82. with torch.no_grad():
  83. for k in range(n_passes):
  84. # Re-seed per pass so the noise realization is identical across MC
  85. # passes (and across models) for a given (sample, noise_level).
  86. noise_gen = (
  87. torch.Generator(device=device).manual_seed(int(noise_seed))
  88. if noise_level > 0.0
  89. else None
  90. )
  91. batch_tracker = pass_tracker.get_sub_tracker("Batches")
  92. batch_tracker.update(total=len(loader), advance=0)
  93. idx = 0
  94. for mri, xls, _targets, _ids in loader:
  95. check_control_events(stop_event, pause_event)
  96. mri = mri.to(device, non_blocking=True)
  97. xls = xls.to(device, non_blocking=True)
  98. if noise_gen is not None:
  99. noise = torch.randn(
  100. mri.shape, generator=noise_gen, device=device, dtype=mri.dtype
  101. )
  102. mri = mri + noise * noise_level
  103. outputs = model((mri, xls))
  104. bs = int(outputs.shape[0])
  105. mc[k, idx : idx + bs] = outputs.detach().cpu().numpy()
  106. idx += bs
  107. batch_tracker.update(advance=1)
  108. pass_tracker.update(advance=1)
  109. return mc
  110. def _evaluate_ensemble(
  111. *,
  112. model_paths: Sequence[pl.Path],
  113. kind: str,
  114. config: Dict[str, Any],
  115. state: Dict[str, Any],
  116. log: PipelineLogger,
  117. track: ProgressTracker,
  118. noise_levels: Sequence[float],
  119. n_passes: int,
  120. out_path: pl.Path,
  121. ) -> None:
  122. """Evaluate one ensemble and write a schema-compliant netCDF to ``out_path``."""
  123. device = config["training"]["device"]
  124. n_classes = int(config["data"]["num_classes"])
  125. base_seed = int(config["data"]["seed"])
  126. stop_event, pause_event = _events(state)
  127. test_loader = state["test_loader"]
  128. image_to_ptid: Dict[int, str] = state.get("image_to_ptid", {})
  129. image_ids, ptids, true_classes = _collect_sample_metadata(test_loader, image_to_ptid)
  130. log.info(
  131. f"Evaluating {len(model_paths)} {kind} model(s) on {len(image_ids)} samples, "
  132. f"{len(noise_levels)} noise level(s), {n_passes} MC pass(es) each."
  133. )
  134. accumulator = EvaluationAccumulator(
  135. image_ids, ptids, true_classes, noise_levels=list(noise_levels)
  136. )
  137. models_tracker = track.get_sub_tracker(f"{kind.capitalize()} models")
  138. models_tracker.update(total=len(model_paths), advance=0)
  139. for model_index, path in enumerate(model_paths):
  140. check_control_events(stop_event, pause_event)
  141. log.info(f"Loading {kind} model {model_index + 1}/{len(model_paths)}: {path.name}")
  142. model = load_model(path, kind, config, device)
  143. for level_index, noise in enumerate(noise_levels):
  144. noise_seed = derive_seed(base_seed, level_index, stream=_NOISE_SEED_STREAM)
  145. mc_probs = _run_mc_passes(
  146. model=model,
  147. loader=test_loader,
  148. device=device,
  149. noise_level=float(noise),
  150. n_passes=n_passes,
  151. noise_seed=noise_seed,
  152. n_classes=n_classes,
  153. parent_tracker=models_tracker,
  154. stop_event=stop_event,
  155. pause_event=pause_event,
  156. label=f"model {model_index + 1} | sigma={noise}",
  157. )
  158. accumulator.add(
  159. model_index=model_index,
  160. model_kind=kind,
  161. noise_level=float(noise),
  162. mc_probs=mc_probs,
  163. )
  164. del model
  165. release_torch_memory(device)
  166. models_tracker.update(advance=1)
  167. dataset = accumulator.to_dataset(
  168. attrs={
  169. "seed": base_seed,
  170. "n_mc": n_passes,
  171. "ensemble_kind": kind,
  172. "config": _config_snapshot(config),
  173. }
  174. )
  175. out_path.parent.mkdir(parents=True, exist_ok=True)
  176. save_evaluation(dataset, str(out_path))
  177. log.info(
  178. f"Saved {kind} evaluation to {out_path} "
  179. f"(models={dataset.sizes['model']}, samples={dataset.sizes['sample']}, "
  180. f"noise_levels={dataset.sizes['noise_level']})."
  181. )
  182. def _config_snapshot(config: Dict[str, Any]) -> str:
  183. try:
  184. return toml.dumps(config)
  185. except Exception:
  186. return repr(config)
  187. def _eval_cfg(config: Dict[str, Any]):
  188. eval_cfg = config.get("evaluation", {})
  189. noise_levels = list(eval_cfg.get("noise_levels", _DEFAULT_NOISE_LEVELS))
  190. mc_passes = int(eval_cfg.get("mc_passes", _DEFAULT_MC_PASSES))
  191. return noise_levels, mc_passes
  192. def _out_path(config: Dict[str, Any], name: str) -> pl.Path:
  193. return pl.Path(config["work_dir"]) / _EVAL_SUBDIR / name
  194. # ---------------------------------------------------------------------------
  195. # Pipeline task entry points
  196. # ---------------------------------------------------------------------------
  197. def evaluate_normal_task(
  198. track: ProgressTracker,
  199. log: PipelineLogger,
  200. config: Dict[str, Any],
  201. state: Dict[str, Any],
  202. ) -> None:
  203. """Step 4: evaluate the normal ensemble on clean data (noise=0.0, K=1)."""
  204. paths = state.get("normal_model_paths") or []
  205. if not paths:
  206. log.error("No normal models to evaluate (run training/load_models first).")
  207. return
  208. _evaluate_ensemble(
  209. model_paths=paths,
  210. kind=KIND_NORMAL,
  211. config=config,
  212. state=state,
  213. log=log,
  214. track=track,
  215. noise_levels=[0.0],
  216. n_passes=1,
  217. out_path=_out_path(config, "normal.nc"),
  218. )
  219. def evaluate_bayesian_task(
  220. track: ProgressTracker,
  221. log: PipelineLogger,
  222. config: Dict[str, Any],
  223. state: Dict[str, Any],
  224. ) -> None:
  225. """Step 5: evaluate the Bayesian ensemble on clean data with MC uncertainty."""
  226. paths = state.get("bayesian_model_paths") or []
  227. if not paths:
  228. log.error("No Bayesian models to evaluate (run training/load_models first).")
  229. return
  230. _, mc_passes = _eval_cfg(config)
  231. _evaluate_ensemble(
  232. model_paths=paths,
  233. kind=KIND_BAYESIAN,
  234. config=config,
  235. state=state,
  236. log=log,
  237. track=track,
  238. noise_levels=[0.0],
  239. n_passes=mc_passes,
  240. out_path=_out_path(config, "bayesian.nc"),
  241. )
  242. def evaluate_noisy_task(
  243. track: ProgressTracker,
  244. log: PipelineLogger,
  245. config: Dict[str, Any],
  246. state: Dict[str, Any],
  247. ) -> None:
  248. """Step 6: evaluate both ensembles across the configured Gaussian noise levels."""
  249. noise_levels, mc_passes = _eval_cfg(config)
  250. normal_paths = state.get("normal_model_paths") or []
  251. bayesian_paths = state.get("bayesian_model_paths") or []
  252. if not normal_paths and not bayesian_paths:
  253. log.error("No models to evaluate on noised data.")
  254. return
  255. if normal_paths:
  256. _evaluate_ensemble(
  257. model_paths=normal_paths,
  258. kind=KIND_NORMAL,
  259. config=config,
  260. state=state,
  261. log=log,
  262. track=track,
  263. noise_levels=noise_levels,
  264. n_passes=1,
  265. out_path=_out_path(config, "noisy_normal.nc"),
  266. )
  267. else:
  268. log.info("No normal models found; skipping normal noisy evaluation.")
  269. if bayesian_paths:
  270. _evaluate_ensemble(
  271. model_paths=bayesian_paths,
  272. kind=KIND_BAYESIAN,
  273. config=config,
  274. state=state,
  275. log=log,
  276. track=track,
  277. noise_levels=noise_levels,
  278. n_passes=mc_passes,
  279. out_path=_out_path(config, "noisy_bayesian.nc"),
  280. )
  281. else:
  282. log.info("No Bayesian models found; skipping Bayesian noisy evaluation.")