context.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Shared state and data access for analyses.
  2. ``AnalysisContext`` owns the loaded evaluation datasets and answers the questions
  3. every analysis needs -- which file represents a family, which members belong to
  4. it, how to slice a noise level -- so no analysis re-derives that logic and two
  5. analyses can never silently disagree about what "the normal ensemble" means.
  6. The ``Series`` dataclass names the family x configuration combinations that all
  7. analyses iterate, together with their visual encoding.
  8. """
  9. import pathlib as pl
  10. from dataclasses import dataclass
  11. from typing import Any, Dict, List, Optional, Tuple
  12. import numpy as np
  13. import xarray as xr
  14. from analysis.sources import (
  15. FAMILY_LABELS,
  16. baseline_noise_index,
  17. ordered_families,
  18. select_clean_sources,
  19. select_noise_sources,
  20. )
  21. from evaluation import CLASS_NAMES, MODEL_KIND_BAYESIAN, MODEL_KIND_NORMAL
  22. from util.ui_logger import PipelineLogger
  23. #: Ensemble configurations analysed side by side.
  24. CONFIG_ENSEMBLE = "ensemble"
  25. CONFIG_SINGLE = "single"
  26. CONFIG_ORDER = [CONFIG_ENSEMBLE, CONFIG_SINGLE]
  27. CONFIG_LABELS = {
  28. CONFIG_ENSEMBLE: "ensemble",
  29. CONFIG_SINGLE: "single",
  30. }
  31. #: Family -> hue (dataviz categorical slots, CVD-safe in this fixed order).
  32. FAMILY_COLORS = {
  33. MODEL_KIND_NORMAL: "#2a78d6", # blue
  34. MODEL_KIND_BAYESIAN: "#e34948", # red
  35. }
  36. #: Configuration -> line style. Identity is never carried by colour alone.
  37. CONFIG_LINESTYLES = {
  38. CONFIG_ENSEMBLE: "-",
  39. CONFIG_SINGLE: "--",
  40. }
  41. @dataclass(frozen=True)
  42. class Series:
  43. """One family x configuration combination, with its visual encoding."""
  44. family: str
  45. config: str
  46. @property
  47. def key(self) -> str:
  48. return f"{self.family}_{self.config}"
  49. @property
  50. def label(self) -> str:
  51. return f"{FAMILY_LABELS.get(self.family, self.family)} — {CONFIG_LABELS[self.config]}"
  52. @property
  53. def color(self) -> str:
  54. return FAMILY_COLORS.get(self.family, "#666666")
  55. @property
  56. def linestyle(self) -> str:
  57. return CONFIG_LINESTYLES[self.config]
  58. @property
  59. def is_ensemble(self) -> bool:
  60. return self.config == CONFIG_ENSEMBLE
  61. class AnalysisContext:
  62. """Loaded evaluations plus the selection logic analyses share."""
  63. def __init__(
  64. self,
  65. datasets: Dict[str, xr.Dataset],
  66. out_dir: pl.Path,
  67. log: PipelineLogger,
  68. config: Optional[Dict[str, Any]] = None,
  69. ) -> None:
  70. self.datasets = datasets
  71. self.out_dir = out_dir
  72. self.log = log
  73. self.config: Dict[str, Any] = config or {}
  74. self._clean = select_clean_sources(datasets)
  75. self._noise = select_noise_sources(datasets)
  76. # -- source selection --------------------------------------------------
  77. @property
  78. def families(self) -> List[str]:
  79. """Families present in the clean evaluations, in canonical order."""
  80. return ordered_families(self._clean.keys())
  81. @property
  82. def noise_families(self) -> List[str]:
  83. """Families that have a noise sweep available."""
  84. return ordered_families(self._noise.keys())
  85. def clean_source(self, family: str) -> Optional[Tuple[str, xr.Dataset]]:
  86. """Best baseline evaluation for ``family`` as ``(stem, dataset)``."""
  87. return self._clean.get(family)
  88. def noise_source(self, family: str) -> Optional[Tuple[str, xr.Dataset]]:
  89. """Best noise-sweep evaluation for ``family`` as ``(stem, dataset)``."""
  90. return self._noise.get(family)
  91. @property
  92. def has_noise(self) -> bool:
  93. return bool(self._noise)
  94. def series(self, families: Optional[List[str]] = None) -> List[Series]:
  95. """All family x configuration series, in canonical order."""
  96. chosen = families if families is not None else self.families
  97. return [
  98. Series(family=family, config=config)
  99. for family in ordered_families(chosen)
  100. for config in CONFIG_ORDER
  101. ]
  102. # -- data access -------------------------------------------------------
  103. def member_positions(self, family: str, ds: xr.Dataset) -> List[int]:
  104. """Indices along the ``model`` axis belonging to ``family``."""
  105. kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
  106. return [i for i, k in enumerate(kinds) if k == family]
  107. def probs(
  108. self, family: str, ds: xr.Dataset, noise_index: Optional[int] = None
  109. ) -> np.ndarray:
  110. """Member probabilities ``(M, S, C)`` for ``family`` at one noise level.
  111. ``noise_index`` defaults to the clean baseline.
  112. """
  113. if noise_index is None:
  114. noise_index = baseline_noise_index(ds)
  115. values = np.asarray(
  116. ds["prob"].isel(noise_level=noise_index).values, dtype=float
  117. )
  118. return values[self.member_positions(family, ds)]
  119. def member_stat(
  120. self,
  121. name: str,
  122. family: str,
  123. ds: xr.Dataset,
  124. noise_index: Optional[int] = None,
  125. ) -> np.ndarray:
  126. """A stored per-member statistic ``(M, S)``.
  127. ``name`` is a schema variable such as ``predictive_entropy`` or
  128. ``mutual_information``.
  129. """
  130. if noise_index is None:
  131. noise_index = baseline_noise_index(ds)
  132. values = np.asarray(
  133. ds[name].isel(noise_level=noise_index).values, dtype=float
  134. )
  135. return values[self.member_positions(family, ds)]
  136. @staticmethod
  137. def true_index(ds: xr.Dataset) -> np.ndarray:
  138. """Ground-truth class indices ``(S,)`` aligned with ``CLASS_NAMES``."""
  139. return np.array(
  140. [
  141. CLASS_NAMES.index(str(t))
  142. for t in np.atleast_1d(ds["true_class"].values)
  143. ],
  144. dtype=int,
  145. )
  146. @staticmethod
  147. def noise_levels(ds: xr.Dataset) -> np.ndarray:
  148. return np.asarray(ds["noise_level"].values, dtype=float)
  149. @staticmethod
  150. def ptids(ds: xr.Dataset) -> np.ndarray:
  151. return np.asarray(ds["ptid"].values).astype(str)
  152. def base_seed(self) -> int:
  153. """Experiment seed recorded in the evaluations (0 when absent)."""
  154. for ds in self.datasets.values():
  155. seed = ds.attrs.get("seed")
  156. if seed is not None:
  157. return int(seed)
  158. return 0