analysis.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. """Step 7: analysis over saved evaluation netCDF files.
  2. Analysis is deliberately decoupled from models and data: it reads ONLY the
  3. evaluation ``.nc`` files in ``<work_dir>/evaluations/`` and writes artifacts to
  4. ``<work_dir>/analysis/``. That makes an analysis rerun cheap and safe -- the only
  5. outputs it can clobber are the easily-regeneratable files under ``analysis/``.
  6. To add an analysis, register a callable in :data:`ANALYSES`. Each receives the
  7. loaded evaluation datasets (keyed by file stem, e.g. ``"normal"``,
  8. ``"noisy_bayesian"``), an output directory, and a logger. Because every analysis
  9. is a pure function of the schema-locked datasets, new plots (coverage,
  10. accuracy-vs-noise, uncertainty-vs-noise, calibration, ...) drop in here without
  11. touching the rest of the pipeline.
  12. """
  13. import datetime
  14. import itertools
  15. import json
  16. import math
  17. import pathlib as pl
  18. from typing import Any, Callable, Dict, List, Tuple
  19. import matplotlib
  20. matplotlib.use("Agg") # headless; analysis runs in a background worker thread
  21. import matplotlib.pyplot as plt # noqa: E402
  22. import numpy as np # noqa: E402
  23. import typst # noqa: E402
  24. import xarray as xr # noqa: E402
  25. from matplotlib.ticker import MaxNLocator # noqa: E402
  26. from evaluation import ( # noqa: E402
  27. CLASS_NAMES,
  28. MODEL_KIND_BAYESIAN,
  29. MODEL_KIND_NORMAL,
  30. load_evaluation,
  31. )
  32. from util.progress import ProgressTracker # noqa: E402
  33. from util.ui_logger import PipelineLogger # noqa: E402
  34. # Categorical colors (dataviz palette slots 1 & 2, CVD-safe in fixed order).
  35. _FAMILY_COLORS = {
  36. MODEL_KIND_NORMAL: "#003f5c", # blue
  37. MODEL_KIND_BAYESIAN: "#ffa600", # orange
  38. }
  39. # Number of random member-subset configurations sampled per ensemble size
  40. # (fewer only when the total number of distinct subsets is smaller).
  41. _TARGET_CONFIGS = 30
  42. # Bootstrap CI settings for full-ensemble accuracy.
  43. _BOOTSTRAP_ITERS = 2000
  44. _CI_LEVEL = 0.95
  45. EVAL_SUBDIR = "evaluations"
  46. ANALYSIS_SUBDIR = "analysis"
  47. # Analysis registry: name -> function(datasets, out_dir, log) -> None.
  48. # Each analysis is a pure function of the schema-locked evaluation datasets.
  49. AnalysisFn = Callable[[Dict[str, xr.Dataset], pl.Path, PipelineLogger], None]
  50. _TEMPLATE_DIR = pl.Path(__file__).parent / "templates"
  51. _MODEL_REPORT_TEMPLATE = _TEMPLATE_DIR / "model_report.typ"
  52. # Display labels + ordering for known model families.
  53. _FAMILY_LABELS = {
  54. MODEL_KIND_NORMAL: "Normal (deterministic)",
  55. MODEL_KIND_BAYESIAN: "Bayesian (MC)",
  56. }
  57. _FAMILY_ORDER = [MODEL_KIND_NORMAL, MODEL_KIND_BAYESIAN]
  58. def _baseline_noise_index(ds: xr.Dataset) -> int:
  59. """Index of the noise level closest to the clean baseline (sigma = 0)."""
  60. levels = np.asarray(ds["noise_level"].values, dtype=float)
  61. return int(np.abs(levels).argmin())
  62. def _family_metrics(kind: str, source: str, ds: xr.Dataset) -> Dict[str, Any]:
  63. """Per-model + ensemble metrics for one model family at the clean baseline."""
  64. baseline = _baseline_noise_index(ds)
  65. kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
  66. positions = [i for i, k in enumerate(kinds) if k == kind]
  67. correct = ds["correct"].isel(noise_level=baseline).values # (model, sample)
  68. entropy = ds["predictive_entropy"].isel(noise_level=baseline).values
  69. mut_info = ds["mutual_information"].isel(noise_level=baseline).values
  70. labels = np.atleast_1d(ds["model"].values)
  71. models: List[Dict[str, Any]] = []
  72. accuracies: List[float] = []
  73. for pos in positions:
  74. acc = float(np.mean(correct[pos]))
  75. accuracies.append(acc)
  76. models.append(
  77. {
  78. "index": int(labels[pos]) + 1, # 1-based for display
  79. "accuracy": acc,
  80. "mean_entropy": float(np.mean(entropy[pos])),
  81. "mean_mi": float(np.mean(mut_info[pos])),
  82. }
  83. )
  84. acc_arr = np.asarray(accuracies, dtype=float)
  85. return {
  86. "name": _FAMILY_LABELS.get(kind, kind),
  87. "kind": kind,
  88. "source": source,
  89. "noise_sigma": float(np.asarray(ds["noise_level"].values, dtype=float)[baseline]),
  90. "n_models": len(models),
  91. "n_samples": int(ds.sizes["sample"]),
  92. "n_mc": int(ds.attrs.get("n_mc", 1)),
  93. "accuracy_mean": float(acc_arr.mean()) if acc_arr.size else 0.0,
  94. "accuracy_std": float(acc_arr.std()) if acc_arr.size else 0.0,
  95. "accuracy_best": float(acc_arr.max()) if acc_arr.size else 0.0,
  96. "models": models,
  97. }
  98. def _family_sources(
  99. datasets: Dict[str, xr.Dataset]
  100. ) -> Dict[str, Tuple[str, xr.Dataset]]:
  101. """Pick one evaluation dataset per model family, preferring clean files.
  102. ``normal.nc`` and ``noisy_normal.nc`` share the same members at sigma=0, so
  103. only the first source seen per family is kept (non-noisy stems sort first) to
  104. avoid analyzing a family twice.
  105. """
  106. chosen: Dict[str, Tuple[str, xr.Dataset]] = {}
  107. for stem in sorted(datasets, key=lambda s: ("noisy" in s, s)):
  108. ds = datasets[stem]
  109. if "model_kind" not in ds.coords:
  110. continue
  111. for kind in {str(k) for k in np.atleast_1d(ds["model_kind"].values)}:
  112. chosen.setdefault(kind, (stem, ds))
  113. return chosen
  114. def _ordered_families(kinds: Any) -> List[str]:
  115. """Sort family kinds into the canonical Normal-then-Bayesian order."""
  116. order = {kind: i for i, kind in enumerate(_FAMILY_ORDER)}
  117. return sorted(kinds, key=lambda k: order.get(k, len(order)))
  118. def _collect_families(datasets: Dict[str, xr.Dataset]) -> List[Dict[str, Any]]:
  119. """One metrics block per model family, in canonical order."""
  120. sources = _family_sources(datasets)
  121. families = [
  122. _family_metrics(kind, source, ds) for kind, (source, ds) in sources.items()
  123. ]
  124. families.sort(key=lambda f: _ordered_families(sources.keys()).index(f["kind"]))
  125. return families
  126. def model_report(
  127. datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
  128. ) -> None:
  129. """Compile a short per-model PDF report (accuracy + uncertainty) via Typst."""
  130. families = _collect_families(datasets)
  131. if not families:
  132. log.error("model_report: no model families found in evaluations; skipping.")
  133. return
  134. meta = next(iter(datasets.values())).attrs
  135. seed = meta.get("seed")
  136. payload = {
  137. "title": "Model Evaluation Report",
  138. "generated": datetime.datetime.now(datetime.timezone.utc).strftime(
  139. "%Y-%m-%d %H:%M UTC"
  140. ),
  141. "work_dir": str(out_dir.parent),
  142. "schema_version": str(meta.get("schema_version", "?")),
  143. "seed": str(seed) if seed is not None else "n/a",
  144. "git_commit": str(meta.get("git_commit", "unknown"))[:10],
  145. "families": families,
  146. }
  147. payload_json = json.dumps(payload)
  148. # The template parses this JSON via `json(bytes(sys.inputs.data))`.
  149. pdf_bytes = typst.compile(
  150. str(_MODEL_REPORT_TEMPLATE),
  151. sys_inputs={"data": payload_json},
  152. format="pdf",
  153. )
  154. (out_dir / "model_report.pdf").write_bytes(pdf_bytes)
  155. # Keep the JSON alongside for debugging / reuse (regeneratable).
  156. (out_dir / "model_report.json").write_text(payload_json)
  157. total_models = sum(f["n_models"] for f in families)
  158. log.info(
  159. f"model_report: wrote model_report.pdf "
  160. f"({len(families)} family/families, {total_models} model(s))."
  161. )
  162. def _baseline_probs(kind: str, ds: xr.Dataset) -> Tuple[np.ndarray, np.ndarray]:
  163. """Return ``(probs (M, S, C), true_idx (S,))`` for ``kind``'s members at the
  164. clean-baseline noise level."""
  165. baseline = _baseline_noise_index(ds)
  166. kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
  167. positions = [i for i, k in enumerate(kinds) if k == kind]
  168. probs = np.asarray(ds["prob"].isel(noise_level=baseline).values, dtype=float)
  169. probs = probs[positions] # (M, S, C)
  170. true_idx = np.array(
  171. [CLASS_NAMES.index(str(t)) for t in np.atleast_1d(ds["true_class"].values)],
  172. dtype=int,
  173. )
  174. return probs, true_idx
  175. def _sample_subsets(
  176. n_models: int, size: int, target: int, rng: np.random.Generator
  177. ) -> List[List[int]]:
  178. """Distinct member-index subsets of ``size``.
  179. Returns every subset when the number of distinct combinations is <= ``target``
  180. (e.g. size == 1, size == M, or size near M); otherwise ``target`` random
  181. distinct subsets.
  182. """
  183. total = math.comb(n_models, size)
  184. if total <= target:
  185. return [list(combo) for combo in itertools.combinations(range(n_models), size)]
  186. seen: set[Tuple[int, ...]] = set()
  187. subsets: List[List[int]] = []
  188. while len(subsets) < target:
  189. pick = tuple(
  190. sorted(int(i) for i in rng.choice(n_models, size=size, replace=False))
  191. )
  192. if pick not in seen:
  193. seen.add(pick)
  194. subsets.append(list(pick))
  195. return subsets
  196. def _ensemble_size_curve(
  197. probs: np.ndarray, true_idx: np.ndarray, target: int, rng: np.random.Generator
  198. ) -> Dict[str, Any]:
  199. """Sampled-configuration accuracy for every ensemble size ``1..M``.
  200. An ensemble prediction is the argmax of the mean of its members' output
  201. probabilities. For each size we average accuracy over sampled member subsets
  202. and report the spread (std) as the uncertainty estimate.
  203. """
  204. n_models = probs.shape[0]
  205. sizes: List[int] = []
  206. means: List[float] = []
  207. stds: List[float] = []
  208. n_configs: List[int] = []
  209. for size in range(1, n_models + 1):
  210. subsets = _sample_subsets(n_models, size, target, rng)
  211. accuracies = []
  212. for subset in subsets:
  213. ensemble_prob = probs[subset].mean(axis=0) # (S, C)
  214. predictions = ensemble_prob.argmax(axis=1)
  215. accuracies.append(float(np.mean(predictions == true_idx)))
  216. acc = np.asarray(accuracies, dtype=float)
  217. sizes.append(size)
  218. means.append(float(acc.mean()))
  219. stds.append(float(acc.std()))
  220. n_configs.append(len(subsets))
  221. return {"sizes": sizes, "mean": means, "std": stds, "n_configs": n_configs}
  222. def ensemble_size_accuracy(
  223. datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
  224. ) -> None:
  225. """Plot ensemble accuracy vs. number of member models, with an uncertainty
  226. band from sampling different member configurations, for each family."""
  227. sources = _family_sources(datasets)
  228. if not sources:
  229. log.error("ensemble_size_accuracy: no model families found; skipping.")
  230. return
  231. base_seed = int(next(iter(datasets.values())).attrs.get("seed", 0) or 0)
  232. curves: Dict[str, Dict[str, Any]] = {}
  233. for fam_index, kind in enumerate(_ordered_families(sources.keys())):
  234. source, ds = sources[kind]
  235. probs, true_idx = _baseline_probs(kind, ds)
  236. if probs.shape[0] < 1:
  237. continue
  238. # Distinct, reproducible RNG stream per family for subset sampling.
  239. rng = np.random.default_rng([base_seed, fam_index])
  240. curve = _ensemble_size_curve(probs, true_idx, _TARGET_CONFIGS, rng)
  241. curve["source"] = source
  242. curves[kind] = curve
  243. log.info(
  244. f"ensemble_size_accuracy: {kind} - sizes 1..{probs.shape[0]}, "
  245. f"up to {_TARGET_CONFIGS} configs each."
  246. )
  247. if not curves:
  248. log.error("ensemble_size_accuracy: no usable probabilities; skipping.")
  249. return
  250. fig, ax = plt.subplots(figsize=(7.0, 4.3))
  251. for kind in _ordered_families(curves.keys()):
  252. curve = curves[kind]
  253. color = _FAMILY_COLORS.get(kind, "#666666")
  254. x = np.asarray(curve["sizes"])
  255. mean = np.asarray(curve["mean"])
  256. std = np.asarray(curve["std"])
  257. ax.fill_between(x, mean - std, mean + std, color=color, alpha=0.18, linewidth=0)
  258. ax.plot(
  259. x, mean, color=color, marker="o", markersize=4, linewidth=2,
  260. label=_FAMILY_LABELS.get(kind, kind),
  261. )
  262. ax.set_xlabel("Ensemble size (number of models)")
  263. ax.set_ylabel("Test accuracy")
  264. ax.set_title("Ensemble accuracy vs. number of models")
  265. ax.xaxis.set_major_locator(MaxNLocator(integer=True))
  266. ax.margins(x=0.02)
  267. ax.grid(True, color="#000000", alpha=0.08, linewidth=0.7)
  268. for spine in ("top", "right"):
  269. ax.spines[spine].set_visible(False)
  270. ax.legend(frameon=False, loc="lower right")
  271. fig.tight_layout()
  272. png_path = out_dir / "ensemble_size_accuracy.png"
  273. fig.savefig(png_path, dpi=150)
  274. fig.savefig(out_dir / "ensemble_size_accuracy.pdf")
  275. plt.close(fig)
  276. # Persist the underlying curve data for reuse / regeneration.
  277. (out_dir / "ensemble_size_accuracy.json").write_text(
  278. json.dumps({"target_configs": _TARGET_CONFIGS, "families": curves})
  279. )
  280. log.info(
  281. f"ensemble_size_accuracy: wrote {png_path.name} "
  282. f"({len(curves)} family/families)."
  283. )
  284. def _full_ensemble_correct(kind: str, ds: xr.Dataset) -> Tuple[np.ndarray, np.ndarray]:
  285. """Per-sample correctness (0/1) of the FULL ensemble at baseline, plus ptids.
  286. The ensemble prediction is the argmax of the mean of ALL members' output
  287. probabilities -- i.e. the rightmost point of the ensemble-size curve.
  288. """
  289. probs, true_idx = _baseline_probs(kind, ds) # (M, S, C), (S,)
  290. ensemble_pred = probs.mean(axis=0).argmax(axis=1) # (S,)
  291. correct = (ensemble_pred == true_idx).astype(float)
  292. ptids = np.asarray(ds["ptid"].values).astype(str)
  293. return correct, ptids
  294. def _bootstrap_accuracy_ci(
  295. correct: np.ndarray,
  296. ptids: np.ndarray,
  297. iters: int,
  298. ci_level: float,
  299. rng: np.random.Generator,
  300. ) -> Dict[str, Any]:
  301. """Patient-clustered percentile bootstrap CI for a mean-accuracy statistic.
  302. Resamples whole PATIENTS with replacement (not individual scans), because
  303. scans from one patient are correlated; a per-scan bootstrap would understate
  304. the interval. Reduces to an ordinary bootstrap when every patient has one scan.
  305. """
  306. point = float(np.mean(correct)) if correct.size else 0.0
  307. _, inverse = np.unique(ptids, return_inverse=True)
  308. n_patients = int(inverse.max()) + 1 if inverse.size else 0
  309. groups = [np.where(inverse == p)[0] for p in range(n_patients)]
  310. boot = np.empty(iters, dtype=float)
  311. for b in range(iters):
  312. chosen = rng.integers(0, n_patients, size=n_patients)
  313. idx = np.concatenate([groups[c] for c in chosen])
  314. boot[b] = correct[idx].mean()
  315. tail = (1.0 - ci_level) / 2.0
  316. return {
  317. "point": point,
  318. "ci_low": float(np.quantile(boot, tail)),
  319. "ci_high": float(np.quantile(boot, 1.0 - tail)),
  320. "n_samples": int(correct.size),
  321. "n_patients": n_patients,
  322. }
  323. def bootstrap_ensemble_ci(
  324. datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
  325. ) -> None:
  326. """Patient-clustered bootstrap CI for each family's full-ensemble test
  327. accuracy; writes a JSON summary and a compact point-range plot."""
  328. sources = _family_sources(datasets)
  329. if not sources:
  330. log.error("bootstrap_ensemble_ci: no model families found; skipping.")
  331. return
  332. base_seed = int(next(iter(datasets.values())).attrs.get("seed", 0) or 0)
  333. results: Dict[str, Dict[str, Any]] = {}
  334. for fam_index, kind in enumerate(_ordered_families(sources.keys())):
  335. source, ds = sources[kind]
  336. correct, ptids = _full_ensemble_correct(kind, ds)
  337. if correct.size == 0:
  338. continue
  339. rng = np.random.default_rng([base_seed, 1000 + fam_index])
  340. ci = _bootstrap_accuracy_ci(correct, ptids, _BOOTSTRAP_ITERS, _CI_LEVEL, rng)
  341. ci["source"] = source
  342. results[kind] = ci
  343. log.info(
  344. f"bootstrap_ensemble_ci: {kind} full-ensemble acc = "
  345. f"{ci['point'] * 100:.1f}% (95% CI "
  346. f"{ci['ci_low'] * 100:.1f}-{ci['ci_high'] * 100:.1f}%, "
  347. f"n={ci['n_samples']} scans / {ci['n_patients']} patients)."
  348. )
  349. if not results:
  350. log.error("bootstrap_ensemble_ci: nothing to compute; skipping.")
  351. return
  352. ordered = _ordered_families(results.keys())
  353. fig, ax = plt.subplots(figsize=(7.0, 1.4 + 0.8 * len(ordered)))
  354. for row, kind in enumerate(ordered):
  355. ci = results[kind]
  356. color = _FAMILY_COLORS.get(kind, "#666666")
  357. ax.plot(
  358. [ci["ci_low"], ci["ci_high"]], [row, row],
  359. color=color, linewidth=2.5, solid_capstyle="round",
  360. )
  361. ax.plot([ci["point"]], [row], "o", color=color, markersize=8)
  362. ax.annotate(
  363. f"{ci['point'] * 100:.1f}% "
  364. f"[{ci['ci_low'] * 100:.1f}, {ci['ci_high'] * 100:.1f}]",
  365. (ci["point"], row), xytext=(0, 10), textcoords="offset points",
  366. ha="center", va="bottom", fontsize=9, color="#222222",
  367. )
  368. ax.set_yticks(range(len(ordered)))
  369. ax.set_yticklabels([_FAMILY_LABELS.get(k, k) for k in ordered])
  370. ax.invert_yaxis() # first family on top
  371. ax.set_xlabel("Full-ensemble test accuracy (95% patient-clustered bootstrap CI)")
  372. ax.set_title("Ensemble accuracy with bootstrap confidence intervals")
  373. ax.margins(x=0.18, y=0.35)
  374. ax.grid(True, axis="x", color="#000000", alpha=0.08, linewidth=0.7)
  375. for spine in ("top", "right", "left"):
  376. ax.spines[spine].set_visible(False)
  377. ax.tick_params(left=False)
  378. fig.tight_layout()
  379. fig.savefig(out_dir / "bootstrap_ci.png", dpi=150)
  380. fig.savefig(out_dir / "bootstrap_ci.pdf")
  381. plt.close(fig)
  382. (out_dir / "bootstrap_ci.json").write_text(
  383. json.dumps(
  384. {"iters": _BOOTSTRAP_ITERS, "ci_level": _CI_LEVEL, "families": results}
  385. )
  386. )
  387. log.info(
  388. f"bootstrap_ensemble_ci: wrote bootstrap_ci.png ({len(results)} family/families)."
  389. )
  390. ANALYSES: Dict[str, AnalysisFn] = {
  391. "model_report": model_report,
  392. "ensemble_size_accuracy": ensemble_size_accuracy,
  393. "bootstrap_ensemble_ci": bootstrap_ensemble_ci,
  394. }
  395. def _load_evaluations(
  396. eval_dir: pl.Path, log: PipelineLogger
  397. ) -> Dict[str, xr.Dataset]:
  398. """Load every ``*.nc`` in ``eval_dir``, keyed by file stem."""
  399. datasets: Dict[str, xr.Dataset] = {}
  400. for nc in sorted(eval_dir.glob("*.nc")):
  401. try:
  402. ds = load_evaluation(str(nc))
  403. except Exception as exc: # noqa: BLE001 - report and skip a bad file
  404. log.error(f"Could not load evaluation '{nc.name}': {exc}")
  405. continue
  406. datasets[nc.stem] = ds
  407. log.info(f"Loaded evaluation '{nc.stem}' with dims {dict(ds.sizes)}.")
  408. return datasets
  409. def run_analysis_task(
  410. track: ProgressTracker,
  411. log: PipelineLogger,
  412. config: Dict[str, Any],
  413. state: Dict[str, Any],
  414. ) -> None:
  415. work_dir = pl.Path(config["work_dir"])
  416. eval_dir = work_dir / EVAL_SUBDIR
  417. out_dir = work_dir / ANALYSIS_SUBDIR
  418. if not eval_dir.is_dir():
  419. log.error(
  420. f"No evaluations directory at {eval_dir}. Run an evaluation scenario "
  421. "first (or point the working directory at one that has evaluations)."
  422. )
  423. return
  424. datasets = _load_evaluations(eval_dir, log)
  425. if not datasets:
  426. log.error(f"No evaluation .nc files found in {eval_dir}; nothing to analyze.")
  427. return
  428. out_dir.mkdir(parents=True, exist_ok=True)
  429. try:
  430. if not ANALYSES:
  431. log.info(
  432. f"[NOT IMPLEMENTED] No analyses registered yet (step 7). "
  433. f"Loaded {len(datasets)} evaluation file(s): "
  434. f"{', '.join(sorted(datasets))}. Register functions in "
  435. f"tasks.analysis.ANALYSES to produce artifacts under {out_dir}/."
  436. )
  437. return
  438. track.update(total=len(ANALYSES), advance=0)
  439. for name, analysis_fn in ANALYSES.items():
  440. log.info(f"Running analysis: {name}")
  441. analysis_fn(datasets, out_dir, log)
  442. track.update(advance=1)
  443. log.info(f"Analysis artifacts written to {out_dir}/.")
  444. finally:
  445. for ds in datasets.values():
  446. ds.close()