analysis.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  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. "split": str(ds.attrs.get("split", "unknown")),
  90. "noise_sigma": float(np.asarray(ds["noise_level"].values, dtype=float)[baseline]),
  91. "n_models": len(models),
  92. "n_samples": int(ds.sizes["sample"]),
  93. "n_mc": int(ds.attrs.get("n_mc", 1)),
  94. "accuracy_mean": float(acc_arr.mean()) if acc_arr.size else 0.0,
  95. "accuracy_std": float(acc_arr.std()) if acc_arr.size else 0.0,
  96. "accuracy_best": float(acc_arr.max()) if acc_arr.size else 0.0,
  97. "models": models,
  98. }
  99. def _source_rank(stem: str, ds: xr.Dataset) -> Tuple[int, int, str]:
  100. """Sort key deciding which evaluation file represents a model family.
  101. Prefers, in order: clean over noisy; then the widest sample coverage --
  102. pooled files (``combined_*`` = test+val, ``oof_*`` = k-fold out-of-fold)
  103. over a single split, and test over val when only single splits exist.
  104. """
  105. split = str(ds.attrs.get("split", ""))
  106. if stem.startswith(("combined_", "oof_")) or "+" in split:
  107. coverage = 0 # pooled: the most evaluation samples
  108. elif split == "test":
  109. coverage = 1
  110. elif split == "val":
  111. coverage = 2
  112. else:
  113. coverage = 3 # unlabeled (pre-split-attr files)
  114. return ("noisy" in stem, coverage, stem)
  115. def _family_sources(
  116. datasets: Dict[str, xr.Dataset]
  117. ) -> Dict[str, Tuple[str, xr.Dataset]]:
  118. """Pick one evaluation dataset per model family.
  119. A family appears in several files (``normal.nc``, ``val_normal.nc``,
  120. ``combined_normal.nc``, ``noisy_normal.nc``) that all describe the same
  121. ensemble; only the best-ranked one is analyzed so a family is never counted
  122. twice. See :func:`_source_rank` for the preference order.
  123. """
  124. chosen: Dict[str, Tuple[str, xr.Dataset]] = {}
  125. for stem in sorted(datasets, key=lambda s: _source_rank(s, datasets[s])):
  126. ds = datasets[stem]
  127. if "model_kind" not in ds.coords:
  128. continue
  129. for kind in {str(k) for k in np.atleast_1d(ds["model_kind"].values)}:
  130. chosen.setdefault(kind, (stem, ds))
  131. return chosen
  132. def _ordered_families(kinds: Any) -> List[str]:
  133. """Sort family kinds into the canonical Normal-then-Bayesian order."""
  134. order = {kind: i for i, kind in enumerate(_FAMILY_ORDER)}
  135. return sorted(kinds, key=lambda k: order.get(k, len(order)))
  136. def _collect_families(datasets: Dict[str, xr.Dataset]) -> List[Dict[str, Any]]:
  137. """One metrics block per model family, in canonical order."""
  138. sources = _family_sources(datasets)
  139. families = [
  140. _family_metrics(kind, source, ds) for kind, (source, ds) in sources.items()
  141. ]
  142. families.sort(key=lambda f: _ordered_families(sources.keys()).index(f["kind"]))
  143. return families
  144. def model_report(
  145. datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
  146. ) -> None:
  147. """Compile a short per-model PDF report (accuracy + uncertainty) via Typst."""
  148. families = _collect_families(datasets)
  149. if not families:
  150. log.error("model_report: no model families found in evaluations; skipping.")
  151. return
  152. meta = next(iter(datasets.values())).attrs
  153. seed = meta.get("seed")
  154. payload = {
  155. "title": "Model Evaluation Report",
  156. "generated": datetime.datetime.now(datetime.timezone.utc).strftime(
  157. "%Y-%m-%d %H:%M UTC"
  158. ),
  159. "work_dir": str(out_dir.parent),
  160. "schema_version": str(meta.get("schema_version", "?")),
  161. "seed": str(seed) if seed is not None else "n/a",
  162. "git_commit": str(meta.get("git_commit", "unknown"))[:10],
  163. "families": families,
  164. }
  165. payload_json = json.dumps(payload)
  166. # The template parses this JSON via `json(bytes(sys.inputs.data))`.
  167. pdf_bytes = typst.compile(
  168. str(_MODEL_REPORT_TEMPLATE),
  169. sys_inputs={"data": payload_json},
  170. format="pdf",
  171. )
  172. (out_dir / "model_report.pdf").write_bytes(pdf_bytes)
  173. # Keep the JSON alongside for debugging / reuse (regeneratable).
  174. (out_dir / "model_report.json").write_text(payload_json)
  175. total_models = sum(f["n_models"] for f in families)
  176. log.info(
  177. f"model_report: wrote model_report.pdf "
  178. f"({len(families)} family/families, {total_models} model(s))."
  179. )
  180. def _baseline_probs(kind: str, ds: xr.Dataset) -> Tuple[np.ndarray, np.ndarray]:
  181. """Return ``(probs (M, S, C), true_idx (S,))`` for ``kind``'s members at the
  182. clean-baseline noise level."""
  183. baseline = _baseline_noise_index(ds)
  184. kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
  185. positions = [i for i, k in enumerate(kinds) if k == kind]
  186. probs = np.asarray(ds["prob"].isel(noise_level=baseline).values, dtype=float)
  187. probs = probs[positions] # (M, S, C)
  188. true_idx = np.array(
  189. [CLASS_NAMES.index(str(t)) for t in np.atleast_1d(ds["true_class"].values)],
  190. dtype=int,
  191. )
  192. return probs, true_idx
  193. def _sample_subsets(
  194. n_models: int, size: int, target: int, rng: np.random.Generator
  195. ) -> List[List[int]]:
  196. """Distinct member-index subsets of ``size``.
  197. Returns every subset when the number of distinct combinations is <= ``target``
  198. (e.g. size == 1, size == M, or size near M); otherwise ``target`` random
  199. distinct subsets.
  200. """
  201. total = math.comb(n_models, size)
  202. if total <= target:
  203. return [list(combo) for combo in itertools.combinations(range(n_models), size)]
  204. seen: set[Tuple[int, ...]] = set()
  205. subsets: List[List[int]] = []
  206. while len(subsets) < target:
  207. pick = tuple(
  208. sorted(int(i) for i in rng.choice(n_models, size=size, replace=False))
  209. )
  210. if pick not in seen:
  211. seen.add(pick)
  212. subsets.append(list(pick))
  213. return subsets
  214. def _ensemble_size_curve(
  215. probs: np.ndarray, true_idx: np.ndarray, target: int, rng: np.random.Generator
  216. ) -> Dict[str, Any]:
  217. """Sampled-configuration accuracy for every ensemble size ``1..M``.
  218. An ensemble prediction is the argmax of the mean of its members' output
  219. probabilities. For each size we average accuracy over sampled member subsets
  220. and report the spread (std) as the uncertainty estimate.
  221. """
  222. n_models = probs.shape[0]
  223. sizes: List[int] = []
  224. means: List[float] = []
  225. stds: List[float] = []
  226. n_configs: List[int] = []
  227. for size in range(1, n_models + 1):
  228. subsets = _sample_subsets(n_models, size, target, rng)
  229. accuracies = []
  230. for subset in subsets:
  231. ensemble_prob = probs[subset].mean(axis=0) # (S, C)
  232. predictions = ensemble_prob.argmax(axis=1)
  233. accuracies.append(float(np.mean(predictions == true_idx)))
  234. acc = np.asarray(accuracies, dtype=float)
  235. sizes.append(size)
  236. means.append(float(acc.mean()))
  237. stds.append(float(acc.std()))
  238. n_configs.append(len(subsets))
  239. return {"sizes": sizes, "mean": means, "std": stds, "n_configs": n_configs}
  240. def ensemble_size_accuracy(
  241. datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
  242. ) -> None:
  243. """Plot ensemble accuracy vs. number of member models, with an uncertainty
  244. band from sampling different member configurations, for each family."""
  245. sources = _family_sources(datasets)
  246. if not sources:
  247. log.error("ensemble_size_accuracy: no model families found; skipping.")
  248. return
  249. base_seed = int(next(iter(datasets.values())).attrs.get("seed", 0) or 0)
  250. curves: Dict[str, Dict[str, Any]] = {}
  251. for fam_index, kind in enumerate(_ordered_families(sources.keys())):
  252. source, ds = sources[kind]
  253. probs, true_idx = _baseline_probs(kind, ds)
  254. if probs.shape[0] < 1:
  255. continue
  256. # Distinct, reproducible RNG stream per family for subset sampling.
  257. rng = np.random.default_rng([base_seed, fam_index])
  258. curve = _ensemble_size_curve(probs, true_idx, _TARGET_CONFIGS, rng)
  259. curve["source"] = source
  260. curves[kind] = curve
  261. log.info(
  262. f"ensemble_size_accuracy: {kind} - sizes 1..{probs.shape[0]}, "
  263. f"up to {_TARGET_CONFIGS} configs each."
  264. )
  265. if not curves:
  266. log.error("ensemble_size_accuracy: no usable probabilities; skipping.")
  267. return
  268. fig, ax = plt.subplots(figsize=(7.0, 4.3))
  269. for kind in _ordered_families(curves.keys()):
  270. curve = curves[kind]
  271. color = _FAMILY_COLORS.get(kind, "#666666")
  272. x = np.asarray(curve["sizes"])
  273. mean = np.asarray(curve["mean"])
  274. std = np.asarray(curve["std"])
  275. ax.fill_between(x, mean - std, mean + std, color=color, alpha=0.18, linewidth=0)
  276. ax.plot(
  277. x, mean, color=color, marker="o", markersize=4, linewidth=2,
  278. label=_FAMILY_LABELS.get(kind, kind),
  279. )
  280. ax.set_xlabel("Ensemble size (number of models)")
  281. ax.set_ylabel("Test accuracy")
  282. ax.set_title("Ensemble accuracy vs. number of models")
  283. ax.xaxis.set_major_locator(MaxNLocator(integer=True))
  284. ax.margins(x=0.02)
  285. ax.grid(True, color="#000000", alpha=0.08, linewidth=0.7)
  286. for spine in ("top", "right"):
  287. ax.spines[spine].set_visible(False)
  288. ax.legend(frameon=False, loc="lower right")
  289. fig.tight_layout()
  290. png_path = out_dir / "ensemble_size_accuracy.png"
  291. fig.savefig(png_path, dpi=150)
  292. fig.savefig(out_dir / "ensemble_size_accuracy.pdf")
  293. plt.close(fig)
  294. # Persist the underlying curve data for reuse / regeneration.
  295. (out_dir / "ensemble_size_accuracy.json").write_text(
  296. json.dumps({"target_configs": _TARGET_CONFIGS, "families": curves})
  297. )
  298. log.info(
  299. f"ensemble_size_accuracy: wrote {png_path.name} "
  300. f"({len(curves)} family/families)."
  301. )
  302. def _full_ensemble_correct(kind: str, ds: xr.Dataset) -> Tuple[np.ndarray, np.ndarray]:
  303. """Per-sample correctness (0/1) of the FULL ensemble at baseline, plus ptids.
  304. The ensemble prediction is the argmax of the mean of ALL members' output
  305. probabilities -- i.e. the rightmost point of the ensemble-size curve.
  306. """
  307. probs, true_idx = _baseline_probs(kind, ds) # (M, S, C), (S,)
  308. ensemble_pred = probs.mean(axis=0).argmax(axis=1) # (S,)
  309. correct = (ensemble_pred == true_idx).astype(float)
  310. ptids = np.asarray(ds["ptid"].values).astype(str)
  311. return correct, ptids
  312. def _bootstrap_accuracy_ci(
  313. correct: np.ndarray,
  314. ptids: np.ndarray,
  315. iters: int,
  316. ci_level: float,
  317. rng: np.random.Generator,
  318. ) -> Dict[str, Any]:
  319. """Patient-clustered percentile bootstrap CI for a mean-accuracy statistic.
  320. Resamples whole PATIENTS with replacement (not individual scans), because
  321. scans from one patient are correlated; a per-scan bootstrap would understate
  322. the interval. Reduces to an ordinary bootstrap when every patient has one scan.
  323. """
  324. point = float(np.mean(correct)) if correct.size else 0.0
  325. _, inverse = np.unique(ptids, return_inverse=True)
  326. n_patients = int(inverse.max()) + 1 if inverse.size else 0
  327. groups = [np.where(inverse == p)[0] for p in range(n_patients)]
  328. boot = np.empty(iters, dtype=float)
  329. for b in range(iters):
  330. chosen = rng.integers(0, n_patients, size=n_patients)
  331. idx = np.concatenate([groups[c] for c in chosen])
  332. boot[b] = correct[idx].mean()
  333. tail = (1.0 - ci_level) / 2.0
  334. return {
  335. "point": point,
  336. "ci_low": float(np.quantile(boot, tail)),
  337. "ci_high": float(np.quantile(boot, 1.0 - tail)),
  338. "n_samples": int(correct.size),
  339. "n_patients": n_patients,
  340. }
  341. def bootstrap_ensemble_ci(
  342. datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
  343. ) -> None:
  344. """Patient-clustered bootstrap CI for each family's full-ensemble test
  345. accuracy; writes a JSON summary and a compact point-range plot."""
  346. sources = _family_sources(datasets)
  347. if not sources:
  348. log.error("bootstrap_ensemble_ci: no model families found; skipping.")
  349. return
  350. base_seed = int(next(iter(datasets.values())).attrs.get("seed", 0) or 0)
  351. results: Dict[str, Dict[str, Any]] = {}
  352. for fam_index, kind in enumerate(_ordered_families(sources.keys())):
  353. source, ds = sources[kind]
  354. correct, ptids = _full_ensemble_correct(kind, ds)
  355. if correct.size == 0:
  356. continue
  357. rng = np.random.default_rng([base_seed, 1000 + fam_index])
  358. ci = _bootstrap_accuracy_ci(correct, ptids, _BOOTSTRAP_ITERS, _CI_LEVEL, rng)
  359. ci["source"] = source
  360. results[kind] = ci
  361. log.info(
  362. f"bootstrap_ensemble_ci: {kind} full-ensemble acc = "
  363. f"{ci['point'] * 100:.1f}% (95% CI "
  364. f"{ci['ci_low'] * 100:.1f}-{ci['ci_high'] * 100:.1f}%, "
  365. f"n={ci['n_samples']} scans / {ci['n_patients']} patients)."
  366. )
  367. if not results:
  368. log.error("bootstrap_ensemble_ci: nothing to compute; skipping.")
  369. return
  370. ordered = _ordered_families(results.keys())
  371. fig, ax = plt.subplots(figsize=(7.0, 1.4 + 0.8 * len(ordered)))
  372. for row, kind in enumerate(ordered):
  373. ci = results[kind]
  374. color = _FAMILY_COLORS.get(kind, "#666666")
  375. ax.plot(
  376. [ci["ci_low"], ci["ci_high"]], [row, row],
  377. color=color, linewidth=2.5, solid_capstyle="round",
  378. )
  379. ax.plot([ci["point"]], [row], "o", color=color, markersize=8)
  380. ax.annotate(
  381. f"{ci['point'] * 100:.1f}% "
  382. f"[{ci['ci_low'] * 100:.1f}, {ci['ci_high'] * 100:.1f}]",
  383. (ci["point"], row), xytext=(0, 10), textcoords="offset points",
  384. ha="center", va="bottom", fontsize=9, color="#222222",
  385. )
  386. ax.set_yticks(range(len(ordered)))
  387. ax.set_yticklabels([_FAMILY_LABELS.get(k, k) for k in ordered])
  388. ax.invert_yaxis() # first family on top
  389. ax.set_xlabel("Full-ensemble test accuracy (95% patient-clustered bootstrap CI)")
  390. ax.set_title("Ensemble accuracy with bootstrap confidence intervals")
  391. ax.margins(x=0.18, y=0.35)
  392. ax.grid(True, axis="x", color="#000000", alpha=0.08, linewidth=0.7)
  393. for spine in ("top", "right", "left"):
  394. ax.spines[spine].set_visible(False)
  395. ax.tick_params(left=False)
  396. fig.tight_layout()
  397. fig.savefig(out_dir / "bootstrap_ci.png", dpi=150)
  398. fig.savefig(out_dir / "bootstrap_ci.pdf")
  399. plt.close(fig)
  400. (out_dir / "bootstrap_ci.json").write_text(
  401. json.dumps(
  402. {"iters": _BOOTSTRAP_ITERS, "ci_level": _CI_LEVEL, "families": results}
  403. )
  404. )
  405. log.info(
  406. f"bootstrap_ensemble_ci: wrote bootstrap_ci.png ({len(results)} family/families)."
  407. )
  408. ANALYSES: Dict[str, AnalysisFn] = {
  409. "model_report": model_report,
  410. "ensemble_size_accuracy": ensemble_size_accuracy,
  411. "bootstrap_ensemble_ci": bootstrap_ensemble_ci,
  412. }
  413. def _load_evaluations(
  414. eval_dir: pl.Path, log: PipelineLogger
  415. ) -> Dict[str, xr.Dataset]:
  416. """Load every ``*.nc`` in ``eval_dir``, keyed by file stem."""
  417. datasets: Dict[str, xr.Dataset] = {}
  418. for nc in sorted(eval_dir.glob("*.nc")):
  419. try:
  420. ds = load_evaluation(str(nc))
  421. except Exception as exc: # noqa: BLE001 - report and skip a bad file
  422. log.error(f"Could not load evaluation '{nc.name}': {exc}")
  423. continue
  424. datasets[nc.stem] = ds
  425. log.info(f"Loaded evaluation '{nc.stem}' with dims {dict(ds.sizes)}.")
  426. return datasets
  427. def run_analysis_task(
  428. track: ProgressTracker,
  429. log: PipelineLogger,
  430. config: Dict[str, Any],
  431. state: Dict[str, Any],
  432. ) -> None:
  433. work_dir = pl.Path(config["work_dir"])
  434. eval_dir = work_dir / EVAL_SUBDIR
  435. out_dir = work_dir / ANALYSIS_SUBDIR
  436. if not eval_dir.is_dir():
  437. log.error(
  438. f"No evaluations directory at {eval_dir}. Run an evaluation scenario "
  439. "first (or point the working directory at one that has evaluations)."
  440. )
  441. return
  442. datasets = _load_evaluations(eval_dir, log)
  443. if not datasets:
  444. log.error(f"No evaluation .nc files found in {eval_dir}; nothing to analyze.")
  445. return
  446. out_dir.mkdir(parents=True, exist_ok=True)
  447. try:
  448. if not ANALYSES:
  449. log.info(
  450. f"[NOT IMPLEMENTED] No analyses registered yet (step 7). "
  451. f"Loaded {len(datasets)} evaluation file(s): "
  452. f"{', '.join(sorted(datasets))}. Register functions in "
  453. f"tasks.analysis.ANALYSES to produce artifacts under {out_dir}/."
  454. )
  455. return
  456. track.update(total=len(ANALYSES), advance=0)
  457. for name, analysis_fn in ANALYSES.items():
  458. log.info(f"Running analysis: {name}")
  459. analysis_fn(datasets, out_dir, log)
  460. track.update(advance=1)
  461. log.info(f"Analysis artifacts written to {out_dir}/.")
  462. finally:
  463. for ds in datasets.values():
  464. ds.close()