extra_info.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. """Extra information step -- figures and facts that are NOT in the evaluations.
  2. The analysis stage reads only the evaluation netCDF files, so anything that needs
  3. the *data itself* (split composition, what a noised image actually looks like)
  4. has to be produced here, while the dataloaders are still in ``state``. Runs after
  5. ``load_data`` and before ``run_analysis``, writing into the same regeneratable
  6. ``analysis/`` directory.
  7. Adding an extra: write a function taking an :class:`ExtraContext` and register it
  8. in :data:`EXTRAS`.
  9. """
  10. import json
  11. import pathlib as pl
  12. from dataclasses import dataclass
  13. from typing import Any, Callable, Dict, List, Sequence
  14. import matplotlib
  15. matplotlib.use("Agg") # headless; runs in a background worker thread
  16. import matplotlib.pyplot as plt # noqa: E402
  17. import numpy as np # noqa: E402
  18. import torch # noqa: E402
  19. from analysis.plotting import data_dir, plots_dir # noqa: E402
  20. from evaluation import CLASS_NAMES # noqa: E402
  21. from util.progress import ProgressTracker # noqa: E402
  22. from util.ui_logger import PipelineLogger # noqa: E402
  23. ANALYSIS_SUBDIR = "analysis"
  24. #: Splits summarised, in pipeline order.
  25. _SPLITS = ("train", "val", "test")
  26. #: Example images shown in the noise-degradation grid.
  27. _N_EXAMPLES = 3
  28. #: Maximum noise columns in that grid (levels are subsampled evenly to fit).
  29. _MAX_NOISE_COLUMNS = 8
  30. @dataclass
  31. class ExtraContext:
  32. """What an extra-info producer gets: pipeline state, config, output dir."""
  33. config: Dict[str, Any]
  34. state: Dict[str, Any]
  35. out_dir: pl.Path
  36. log: PipelineLogger
  37. ExtraFn = Callable[[ExtraContext], None]
  38. def _split_arrays(loader) -> tuple[np.ndarray, np.ndarray]:
  39. """Return ``(image_ids, class_indices)`` for a loader WITHOUT loading images.
  40. Reads straight from the underlying ``ADNIDataset`` through the ``Subset``
  41. indices, so summarising the (large) training split costs nothing.
  42. """
  43. subset = loader.dataset
  44. base = getattr(subset, "dataset", subset)
  45. indices = list(getattr(subset, "indices", range(len(base))))
  46. image_ids = np.array([int(base.filename_ids[i]) for i in indices], dtype=int)
  47. classes = base.expected_classes[indices].argmax(dim=1).cpu().numpy()
  48. return image_ids, classes
  49. def dataset_summary(ctx: ExtraContext) -> None:
  50. """Split sizes, class balance and patient counts for every split."""
  51. image_to_ptid: Dict[int, str] = ctx.state.get("image_to_ptid", {})
  52. summary: Dict[str, Any] = {
  53. "seed": ctx.config["data"].get("seed"),
  54. "data_splits": ctx.config["data"].get("data_splits"),
  55. "class_names": list(CLASS_NAMES),
  56. "splits": {},
  57. }
  58. all_patients: Dict[str, set[str]] = {}
  59. for split in _SPLITS:
  60. loader = ctx.state.get(f"{split}_loader")
  61. if loader is None:
  62. continue
  63. image_ids, classes = _split_arrays(loader)
  64. patients = {image_to_ptid.get(int(i), "unknown") for i in image_ids}
  65. all_patients[split] = patients
  66. summary["splits"][split] = {
  67. "n_scans": int(image_ids.size),
  68. "n_patients": len(patients),
  69. "scans_per_patient": round(image_ids.size / max(len(patients), 1), 2),
  70. "class_counts": {
  71. name: int(np.sum(classes == index))
  72. for index, name in enumerate(CLASS_NAMES)
  73. },
  74. "class_fractions": {
  75. name: round(float(np.mean(classes == index)), 4)
  76. for index, name in enumerate(CLASS_NAMES)
  77. },
  78. }
  79. if not summary["splits"]:
  80. ctx.log.error("dataset_summary: no dataloaders in state; skipping.")
  81. return
  82. # Leakage check: the patient-grouped split must keep splits disjoint.
  83. leaks: Dict[str, int] = {}
  84. names = list(all_patients)
  85. for i, a in enumerate(names):
  86. for b in names[i + 1 :]:
  87. shared = all_patients[a] & all_patients[b] - {"unknown"}
  88. if shared:
  89. leaks[f"{a}&{b}"] = len(shared)
  90. summary["patient_overlap_between_splits"] = leaks
  91. if leaks:
  92. ctx.log.error(f"dataset_summary: PATIENT LEAKAGE between splits: {leaks}")
  93. (data_dir(ctx.out_dir) / "dataset_summary.json").write_text(
  94. json.dumps(summary, indent=2)
  95. )
  96. # Figure: scans per split, stacked by class.
  97. splits = list(summary["splits"])
  98. fig, axes = plt.subplots(1, 2, figsize=(10.0, 3.6), layout="constrained")
  99. bottom = np.zeros(len(splits))
  100. for index, name in enumerate(CLASS_NAMES):
  101. heights = np.array(
  102. [summary["splits"][s]["class_counts"][name] for s in splits], dtype=float
  103. )
  104. axes[0].bar(
  105. splits, heights, bottom=bottom, label=name,
  106. color=["#2a78d6", "#e34948"][index % 2],
  107. )
  108. bottom += heights
  109. axes[0].set_ylabel("Scans")
  110. axes[0].set_title("Split size and class balance", fontsize=10)
  111. axes[0].legend(frameon=False)
  112. axes[1].bar(
  113. splits,
  114. [summary["splits"][s]["n_patients"] for s in splits],
  115. color="#7a7a7a",
  116. )
  117. axes[1].set_ylabel("Patients")
  118. axes[1].set_title("Distinct patients per split", fontsize=10)
  119. for ax in axes:
  120. ax.grid(True, axis="y", color="#000000", alpha=0.08, linewidth=0.7)
  121. for spine in ("top", "right"):
  122. ax.spines[spine].set_visible(False)
  123. fig.savefig(
  124. plots_dir(ctx.out_dir) / "dataset_summary.png", dpi=150, bbox_inches="tight"
  125. )
  126. plt.close(fig)
  127. parts = ", ".join(
  128. f"{s}={summary['splits'][s]['n_scans']} scans/"
  129. f"{summary['splits'][s]['n_patients']} patients"
  130. for s in splits
  131. )
  132. ctx.log.info(f"dataset_summary: {parts}. Wrote dataset_summary.png/.json.")
  133. def _noise_columns(config: Dict[str, Any]) -> List[float]:
  134. """Noise levels to show, evenly subsampled to at most _MAX_NOISE_COLUMNS."""
  135. eval_cfg = config.get("evaluation", {})
  136. levels = sorted(
  137. {float(v) for v in eval_cfg.get("noise_levels", [0.0])}
  138. | {float(v) for v in eval_cfg.get("extra_noise_levels", [])}
  139. )
  140. if len(levels) <= _MAX_NOISE_COLUMNS:
  141. return levels
  142. picks = np.linspace(0, len(levels) - 1, _MAX_NOISE_COLUMNS).round().astype(int)
  143. return [levels[i] for i in sorted(set(picks.tolist()))]
  144. def noise_examples(ctx: ExtraContext) -> None:
  145. """Grid of example images degrading across the configured noise levels.
  146. Applies exactly the same perturbation the evaluation uses (relative to each
  147. image's own intensity std when ``noise_relative``), so the figure shows what
  148. the model actually saw at each sigma.
  149. """
  150. loader = ctx.state.get("test_loader")
  151. if loader is None:
  152. ctx.log.error("noise_examples: no test_loader in state; skipping.")
  153. return
  154. subset = loader.dataset
  155. base = getattr(subset, "dataset", subset)
  156. indices = list(getattr(subset, "indices", range(len(base))))[:_N_EXAMPLES]
  157. if not indices:
  158. ctx.log.error("noise_examples: split is empty; skipping.")
  159. return
  160. levels = _noise_columns(ctx.config)
  161. relative = bool(ctx.config.get("evaluation", {}).get("noise_relative", True))
  162. seed = int(ctx.config["data"].get("seed", 0) or 0)
  163. fig, axes = plt.subplots(
  164. len(indices),
  165. len(levels),
  166. figsize=(1.55 * len(levels), 1.75 * len(indices)),
  167. squeeze=False,
  168. layout="constrained",
  169. )
  170. for row, dataset_index in enumerate(indices):
  171. volume = base.mri_data[dataset_index].float() # (C, D, H, W)
  172. image_id = int(base.filename_ids[dataset_index])
  173. label = CLASS_NAMES[int(base.expected_classes[dataset_index].argmax())]
  174. std = float(volume.std())
  175. # A mid-axial slice, chosen once so every column shows the same anatomy.
  176. slice_index = volume.shape[-1] // 2
  177. generator = torch.Generator().manual_seed(seed + dataset_index)
  178. clean_slice = volume[0, :, :, slice_index]
  179. # Fix the display window on the CLEAN image so added noise visibly
  180. # washes the image out instead of being renormalised away.
  181. vmin, vmax = float(clean_slice.min()), float(clean_slice.max())
  182. for col, sigma in enumerate(levels):
  183. noisy = volume
  184. if sigma > 0:
  185. noise = torch.randn(volume.shape, generator=generator)
  186. noisy = volume + noise * (sigma * (std if relative else 1.0))
  187. ax = axes[row][col]
  188. ax.imshow(
  189. noisy[0, :, :, slice_index].numpy().T,
  190. cmap="gray", vmin=vmin, vmax=vmax, origin="lower",
  191. )
  192. ax.set_xticks([])
  193. ax.set_yticks([])
  194. if row == 0:
  195. ax.set_title(f"σ = {sigma:g}", fontsize=9)
  196. if col == 0:
  197. ax.set_ylabel(f"{image_id}\n({label})", fontsize=8)
  198. unit = "× image std" if relative else "raw voxel units"
  199. fig.suptitle(f"Image degradation with added Gaussian noise (σ in {unit})")
  200. fig.savefig(
  201. plots_dir(ctx.out_dir) / "noise_examples.png", dpi=150, bbox_inches="tight"
  202. )
  203. plt.close(fig)
  204. ctx.log.info(
  205. f"noise_examples: wrote noise_examples.png "
  206. f"({len(indices)} images x {len(levels)} noise levels)."
  207. )
  208. #: Registered extra-info producers, in run order.
  209. EXTRAS: Dict[str, ExtraFn] = {
  210. "dataset_summary": dataset_summary,
  211. "noise_examples": noise_examples,
  212. }
  213. def extra_info_task(
  214. track: ProgressTracker,
  215. log: PipelineLogger,
  216. config: Dict[str, Any],
  217. state: Dict[str, Any],
  218. ) -> None:
  219. """Run every registered extra-info producer."""
  220. out_dir = pl.Path(config["work_dir"]) / ANALYSIS_SUBDIR
  221. out_dir.mkdir(parents=True, exist_ok=True)
  222. ctx = ExtraContext(config=config, state=state, out_dir=out_dir, log=log)
  223. track.update(total=len(EXTRAS), advance=0)
  224. for name, fn in EXTRAS.items():
  225. log.info(f"Extra info: {name}")
  226. try:
  227. fn(ctx)
  228. except Exception as exc: # noqa: BLE001 - one failure must not stop the rest
  229. log.error(f"Extra info '{name}' failed: {exc}")
  230. log.file_logger.error(f"Extra info '{name}' failed", exc_info=True)
  231. track.update(advance=1)
  232. log.info(f"Extra information written to {out_dir}/.")