schema.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. """Canonical netCDF / xarray schema for pipeline evaluations (steps 4-6).
  2. This module is the single source of truth for how model outputs are stored on
  3. disk. Locking it down means every evaluator (normal, Bayesian, noisy) writes the
  4. exact same dimensions, variables, coordinates, and dtypes, so the analysis stage
  5. (step 7) only ever has to understand one format.
  6. --------------------------------------------------------------------------------
  7. Schema (xarray.Dataset)
  8. --------------------------------------------------------------------------------
  9. Dimensions
  10. model ensemble member index (0..M-1)
  11. noise_level Gaussian noise sigma applied to the image; ALWAYS present.
  12. The clean baseline is stored as noise_level == 0.0, so noised
  13. and un-noised evaluations share one file per model family.
  14. sample fixed test-set sample index (from a NON-shuffled loader)
  15. class_name output classes, ordered ["AD", "NL"]
  16. mc_pass (optional) raw Monte-Carlo draw index, Bayesian only
  17. Data variables
  18. prob (model, noise_level, sample, class_name) float32
  19. Mean predictive probability. For Bayesian models this is
  20. the mean over MC passes; for normal models it is the
  21. single softmax output.
  22. pred (model, noise_level, sample) int8
  23. argmax over class_name of ``prob``.
  24. predictive_entropy (model, noise_level, sample) float32
  25. Total/predictive uncertainty: entropy of the mean
  26. predictive distribution (bayesian_torch.predictive_entropy).
  27. mutual_information (model, noise_level, sample) float32
  28. Epistemic/model uncertainty (bayesian_torch.mutual_information).
  29. Identically ~0 for deterministic (K=1) models.
  30. correct (model, noise_level, sample) int8 (0/1)
  31. Whether ``pred`` matches the true class.
  32. mc_prob (model, noise_level, sample, class_name, mc_pass) float32
  33. OPTIONAL raw per-draw probabilities, only if retained.
  34. Coordinates
  35. model_kind (model) "normal" | "bayesian"
  36. image_id (sample) ADNI Image Data ID (join key to clinical data)
  37. ptid (sample) patient id (enables patient-level analysis)
  38. true_class (sample) ground-truth class label, one of class_name
  39. noise_level (noise_level) float sigma values
  40. class_name (class_name) ["AD", "NL"]
  41. Attributes
  42. schema_version, created (ISO-8601 UTC), seed, git_commit, n_mc,
  43. config (TOML string snapshot)
  44. """
  45. from __future__ import annotations
  46. import datetime as _dt
  47. import subprocess
  48. from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
  49. import numpy as np
  50. import xarray as xr
  51. # bayesian-torch's own uncertainty utilities, so definitions match the library
  52. # the Bayesian models were trained with (operate on numpy mc_preds).
  53. from bayesian_torch.utils.util import mutual_information as _bt_mutual_information
  54. from bayesian_torch.utils.util import predictive_entropy as _bt_predictive_entropy
  55. SCHEMA_VERSION = "1.0"
  56. CLASS_NAMES: Tuple[str, ...] = ("AD", "NL")
  57. MODEL_KIND_NORMAL = "normal"
  58. MODEL_KIND_BAYESIAN = "bayesian"
  59. # netCDF engine that supports the full type set (float32, int8, VLEN strings).
  60. _NETCDF_ENGINE = "netcdf4"
  61. # Encodings applied on write. netCDF has no native bool, so ``correct`` is int8.
  62. _ENCODINGS: Dict[str, Dict[str, Any]] = {
  63. "prob": {"dtype": "float32", "zlib": True, "complevel": 4},
  64. "pred": {"dtype": "int8", "zlib": True, "complevel": 4},
  65. "predictive_entropy": {"dtype": "float32", "zlib": True, "complevel": 4},
  66. "mutual_information": {"dtype": "float32", "zlib": True, "complevel": 4},
  67. "correct": {"dtype": "int8", "zlib": True, "complevel": 4},
  68. "mc_prob": {"dtype": "float32", "zlib": True, "complevel": 4},
  69. }
  70. # ---------------------------------------------------------------------------
  71. # Uncertainty computation
  72. # ---------------------------------------------------------------------------
  73. def compute_uncertainty(
  74. mc_probs: np.ndarray,
  75. ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
  76. """Reduce Monte-Carlo predictive probabilities to summary statistics.
  77. Args:
  78. mc_probs: Array of shape ``(K, S, C)`` -- K Monte-Carlo passes, S
  79. samples, C classes. For a deterministic model use ``K == 1``.
  80. Returns:
  81. ``(mean_prob, predictive_entropy, mutual_information)`` with shapes
  82. ``(S, C)``, ``(S,)`` and ``(S,)`` respectively. ``mutual_information``
  83. is ~0 when ``K == 1``.
  84. """
  85. mc = np.asarray(mc_probs, dtype=np.float64)
  86. if mc.ndim != 3:
  87. raise ValueError(
  88. f"mc_probs must be (K, S, C); got shape {mc.shape} with ndim {mc.ndim}."
  89. )
  90. mean_prob = mc.mean(axis=0)
  91. pred_entropy = _bt_predictive_entropy(mc)
  92. # mutual_information == predictive_entropy - mean(per-pass entropy). With a
  93. # single pass the two entropy terms are identical, so this is exactly 0.
  94. mut_info = _bt_mutual_information(mc)
  95. return (
  96. mean_prob.astype(np.float32),
  97. np.asarray(pred_entropy, dtype=np.float32),
  98. np.asarray(mut_info, dtype=np.float32),
  99. )
  100. # ---------------------------------------------------------------------------
  101. # Dataset construction
  102. # ---------------------------------------------------------------------------
  103. def build_evaluation_dataset(
  104. *,
  105. prob: np.ndarray,
  106. predictive_entropy: np.ndarray,
  107. mutual_information: np.ndarray,
  108. model_kinds: Sequence[str],
  109. noise_levels: Sequence[float],
  110. image_ids: Sequence[int],
  111. ptids: Sequence[str],
  112. true_classes: Sequence[str],
  113. mc_prob: Optional[np.ndarray] = None,
  114. attrs: Optional[Mapping[str, Any]] = None,
  115. ) -> xr.Dataset:
  116. """Assemble a schema-compliant ``xarray.Dataset``.
  117. Args:
  118. prob: ``(M, L, S, C)`` mean predictive probabilities.
  119. predictive_entropy: ``(M, L, S)`` total uncertainty.
  120. mutual_information: ``(M, L, S)`` model uncertainty.
  121. model_kinds: length-M sequence of "normal"/"bayesian".
  122. noise_levels: length-L sequence of sigma values (include 0.0 baseline).
  123. image_ids: length-S sequence of ADNI Image Data IDs.
  124. ptids: length-S sequence of patient ids.
  125. true_classes: length-S ground-truth labels drawn from ``CLASS_NAMES``.
  126. mc_prob: optional ``(M, L, S, C, K)`` raw MC draws.
  127. attrs: optional extra dataset attributes (merged over the defaults).
  128. Returns:
  129. A validated ``xarray.Dataset`` following the module schema.
  130. """
  131. prob = np.asarray(prob, dtype=np.float32)
  132. predictive_entropy = np.asarray(predictive_entropy, dtype=np.float32)
  133. mutual_information = np.asarray(mutual_information, dtype=np.float32)
  134. m, ell, s, c = _validate_shapes(
  135. prob,
  136. predictive_entropy,
  137. mutual_information,
  138. model_kinds,
  139. noise_levels,
  140. image_ids,
  141. ptids,
  142. true_classes,
  143. )
  144. # Derived variables: predicted class index and correctness.
  145. pred = prob.argmax(axis=-1).astype(np.int8) # (M, L, S)
  146. class_index = {name: i for i, name in enumerate(CLASS_NAMES)}
  147. try:
  148. true_idx = np.array([class_index[t] for t in true_classes], dtype=np.int8)
  149. except KeyError as exc:
  150. raise ValueError(
  151. f"true_classes contains a label not in CLASS_NAMES {CLASS_NAMES}: {exc}"
  152. ) from None
  153. # Broadcast true index (S,) against pred (M, L, S).
  154. correct = (pred == true_idx[None, None, :]).astype(np.int8)
  155. data_vars: Dict[str, Any] = {
  156. "prob": (("model", "noise_level", "sample", "class_name"), prob),
  157. "pred": (("model", "noise_level", "sample"), pred),
  158. "predictive_entropy": (
  159. ("model", "noise_level", "sample"),
  160. predictive_entropy,
  161. ),
  162. "mutual_information": (
  163. ("model", "noise_level", "sample"),
  164. mutual_information,
  165. ),
  166. "correct": (("model", "noise_level", "sample"), correct),
  167. }
  168. if mc_prob is not None:
  169. mc_prob = np.asarray(mc_prob, dtype=np.float32)
  170. if mc_prob.ndim != 5 or mc_prob.shape[:4] != (m, ell, s, c):
  171. raise ValueError(
  172. f"mc_prob must be (M, L, S, C, K)=({m}, {ell}, {s}, {c}, K); "
  173. f"got {mc_prob.shape}."
  174. )
  175. data_vars["mc_prob"] = (
  176. ("model", "noise_level", "sample", "class_name", "mc_pass"),
  177. mc_prob,
  178. )
  179. coords: Dict[str, Any] = {
  180. "model": np.arange(m, dtype=np.int32),
  181. "model_kind": ("model", np.asarray(list(model_kinds), dtype=object)),
  182. "noise_level": np.asarray(list(noise_levels), dtype=np.float32),
  183. "sample": np.arange(s, dtype=np.int32),
  184. "image_id": ("sample", np.asarray(list(image_ids), dtype=np.int64)),
  185. "ptid": ("sample", np.asarray(list(ptids), dtype=object)),
  186. "true_class": ("sample", np.asarray(list(true_classes), dtype=object)),
  187. "class_name": np.asarray(CLASS_NAMES, dtype=object),
  188. }
  189. ds = xr.Dataset(data_vars=data_vars, coords=coords)
  190. ds.attrs.update(_default_attrs())
  191. if attrs:
  192. ds.attrs.update(dict(attrs))
  193. return ds
  194. def _validate_shapes(
  195. prob: np.ndarray,
  196. predictive_entropy: np.ndarray,
  197. mutual_information: np.ndarray,
  198. model_kinds: Sequence[str],
  199. noise_levels: Sequence[float],
  200. image_ids: Sequence[int],
  201. ptids: Sequence[str],
  202. true_classes: Sequence[str],
  203. ) -> Tuple[int, int, int, int]:
  204. if prob.ndim != 4:
  205. raise ValueError(f"prob must be (M, L, S, C); got shape {prob.shape}.")
  206. m, ell, s, c = prob.shape
  207. if c != len(CLASS_NAMES):
  208. raise ValueError(
  209. f"prob's class axis has length {c}, expected {len(CLASS_NAMES)} "
  210. f"for CLASS_NAMES {CLASS_NAMES}."
  211. )
  212. for name, arr in (
  213. ("predictive_entropy", predictive_entropy),
  214. ("mutual_information", mutual_information),
  215. ):
  216. if arr.shape != (m, ell, s):
  217. raise ValueError(
  218. f"{name} must be (M, L, S)=({m}, {ell}, {s}); got {arr.shape}."
  219. )
  220. for name, seq, expected in (
  221. ("model_kinds", model_kinds, m),
  222. ("noise_levels", noise_levels, ell),
  223. ("image_ids", image_ids, s),
  224. ("ptids", ptids, s),
  225. ("true_classes", true_classes, s),
  226. ):
  227. if len(seq) != expected:
  228. raise ValueError(
  229. f"{name} must have length {expected}; got {len(seq)}."
  230. )
  231. unknown_kinds = set(model_kinds) - {MODEL_KIND_NORMAL, MODEL_KIND_BAYESIAN}
  232. if unknown_kinds:
  233. raise ValueError(
  234. f"model_kinds contains unknown values {unknown_kinds}; "
  235. f"expected only {MODEL_KIND_NORMAL!r}/{MODEL_KIND_BAYESIAN!r}."
  236. )
  237. return m, ell, s, c
  238. def _default_attrs() -> Dict[str, Any]:
  239. return {
  240. "schema_version": SCHEMA_VERSION,
  241. "created": _dt.datetime.now(_dt.timezone.utc).isoformat(),
  242. "git_commit": _git_commit(),
  243. }
  244. def _git_commit() -> str:
  245. try:
  246. out = subprocess.run(
  247. ["git", "rev-parse", "HEAD"],
  248. capture_output=True,
  249. text=True,
  250. timeout=5,
  251. )
  252. return out.stdout.strip() if out.returncode == 0 else "unknown"
  253. except Exception:
  254. return "unknown"
  255. # ---------------------------------------------------------------------------
  256. # I/O
  257. # ---------------------------------------------------------------------------
  258. def save_evaluation(ds: xr.Dataset, path: str) -> None:
  259. """Write an evaluation dataset to netCDF using the locked-down encodings."""
  260. encoding = {name: _ENCODINGS[name] for name in ds.data_vars if name in _ENCODINGS}
  261. ds.to_netcdf(path, engine=_NETCDF_ENGINE, encoding=encoding)
  262. def load_evaluation(path: str) -> xr.Dataset:
  263. """Load an evaluation dataset previously written by :func:`save_evaluation`."""
  264. return xr.open_dataset(path, engine=_NETCDF_ENGINE)
  265. # ---------------------------------------------------------------------------
  266. # Incremental builder
  267. # ---------------------------------------------------------------------------
  268. class EvaluationAccumulator:
  269. """Collect per-(model, noise_level) results, then finalize to a Dataset.
  270. This is the intended entry point for the (not-yet-implemented) evaluation
  271. tasks. Sample-axis metadata (image ids, ptids, true classes) is fixed by the
  272. non-shuffled test loader and supplied once; each ``add`` call contributes one
  273. model's Monte-Carlo probabilities at one noise level.
  274. Example (pseudocode for a future evaluate task)::
  275. acc = EvaluationAccumulator(image_ids, ptids, true_classes,
  276. noise_levels=[0.0, 0.05])
  277. for m, model in enumerate(models):
  278. for sigma in [0.0, 0.05]:
  279. mc = run_mc_passes(model, loader, sigma, k=K) # (K, S, C)
  280. acc.add(model_index=m, model_kind="bayesian",
  281. noise_level=sigma, mc_probs=mc)
  282. ds = acc.to_dataset(attrs={"seed": seed, "n_mc": K})
  283. save_evaluation(ds, out_path)
  284. """
  285. def __init__(
  286. self,
  287. image_ids: Sequence[int],
  288. ptids: Sequence[str],
  289. true_classes: Sequence[str],
  290. noise_levels: Sequence[float],
  291. ) -> None:
  292. self._image_ids = list(image_ids)
  293. self._ptids = list(ptids)
  294. self._true_classes = list(true_classes)
  295. self._noise_levels = list(noise_levels)
  296. self._s = len(self._image_ids)
  297. if not (len(self._ptids) == len(self._true_classes) == self._s):
  298. raise ValueError(
  299. "image_ids, ptids, and true_classes must all have the same length."
  300. )
  301. self._noise_index = {round(float(n), 12): i for i, n in enumerate(self._noise_levels)}
  302. # keyed by model index -> dict with kind and per-noise arrays
  303. self._models: Dict[int, Dict[str, Any]] = {}
  304. def add(
  305. self,
  306. *,
  307. model_index: int,
  308. model_kind: str,
  309. noise_level: float,
  310. mc_probs: np.ndarray,
  311. ) -> None:
  312. """Add one model's MC probabilities ``(K, S, C)`` at one noise level."""
  313. key = round(float(noise_level), 12)
  314. if key not in self._noise_index:
  315. raise ValueError(
  316. f"noise_level {noise_level} not in declared levels {self._noise_levels}."
  317. )
  318. mean_prob, pred_ent, mut_info = compute_uncertainty(mc_probs)
  319. if mean_prob.shape[0] != self._s:
  320. raise ValueError(
  321. f"mc_probs sample axis {mean_prob.shape[0]} != declared S {self._s}."
  322. )
  323. entry = self._models.setdefault(
  324. model_index,
  325. {
  326. "kind": model_kind,
  327. "prob": {},
  328. "pe": {},
  329. "mi": {},
  330. },
  331. )
  332. if entry["kind"] != model_kind:
  333. raise ValueError(
  334. f"model_index {model_index} already registered as {entry['kind']!r}, "
  335. f"got {model_kind!r}."
  336. )
  337. entry["prob"][key] = mean_prob
  338. entry["pe"][key] = pred_ent
  339. entry["mi"][key] = mut_info
  340. def to_dataset(self, attrs: Optional[Mapping[str, Any]] = None) -> xr.Dataset:
  341. """Stack all collected results into a schema-compliant Dataset."""
  342. if not self._models:
  343. raise ValueError("No results were added to the accumulator.")
  344. model_indices = sorted(self._models)
  345. m = len(model_indices)
  346. ell = len(self._noise_levels)
  347. c = len(CLASS_NAMES)
  348. prob = np.zeros((m, ell, self._s, c), dtype=np.float32)
  349. pe = np.zeros((m, ell, self._s), dtype=np.float32)
  350. mi = np.zeros((m, ell, self._s), dtype=np.float32)
  351. model_kinds: List[str] = []
  352. for mi_idx, model_index in enumerate(model_indices):
  353. entry = self._models[model_index]
  354. model_kinds.append(entry["kind"])
  355. for key, li in self._noise_index.items():
  356. if key not in entry["prob"]:
  357. raise ValueError(
  358. f"model_index {model_index} is missing noise level {key}."
  359. )
  360. prob[mi_idx, li] = entry["prob"][key]
  361. pe[mi_idx, li] = entry["pe"][key]
  362. mi[mi_idx, li] = entry["mi"][key]
  363. return build_evaluation_dataset(
  364. prob=prob,
  365. predictive_entropy=pe,
  366. mutual_information=mi,
  367. model_kinds=model_kinds,
  368. noise_levels=self._noise_levels,
  369. image_ids=self._image_ids,
  370. ptids=self._ptids,
  371. true_classes=self._true_classes,
  372. attrs=attrs,
  373. )