measures.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. """Uncertainty measures derived from ensemble member probabilities.
  2. Definitions follow ai/ANALYSIS_PLAN.md section 2.
  3. All measures take member probabilities ``probs`` of shape ``(M, S, C)`` -- M
  4. ensemble members, S samples, C classes -- and return a per-sample uncertainty
  5. ``(S,)`` where **higher means more uncertain**. A ``single``-model configuration
  6. passes ``M = 1``.
  7. Key identity that keeps the two families consistent
  8. ---------------------------------------------------
  9. The stored ``predictive_entropy`` of member *m* equals ``H(probs[m])``, because
  10. ``prob`` is already the MC-mean for a Bayesian member and the plain softmax for a
  11. deterministic one. So the ensemble decomposition
  12. H(mean_m p_m) = total (mean_output_entropy)
  13. mean_m H(p_m) = aleatoric (mean_predictive_entropy)
  14. total - aleatoric = epistemic (ensemble_mutual_information)
  15. holds for BOTH families, computed from ``prob`` alone. ``mean_mutual_information``
  16. is different: it averages each member's *within-member* MC epistemic term, which
  17. is identically zero for deterministic models.
  18. """
  19. from dataclasses import dataclass
  20. from typing import Callable, Dict, List, Optional
  21. import numpy as np
  22. from analysis.context import CONFIG_SINGLE
  23. from evaluation import MODEL_KIND_BAYESIAN
  24. #: Numerical floor for logarithms, matching bayesian_torch's convention.
  25. EPS = 1e-15
  26. # ---------------------------------------------------------------------------
  27. # Primitive
  28. # ---------------------------------------------------------------------------
  29. def entropy(prob: np.ndarray) -> np.ndarray:
  30. """Shannon entropy along the last axis: ``-sum(p * log(p))``.
  31. Args:
  32. prob: Probabilities with classes on the last axis, e.g. ``(..., C)``.
  33. Returns:
  34. Entropy with the class axis removed. Natural log, so a uniform binary
  35. distribution gives ``ln 2 ~= 0.693``.
  36. """
  37. p = np.asarray(prob, dtype=float)
  38. return -np.sum(p * np.log(p + EPS), axis=-1)
  39. def predicted_class(probs: np.ndarray) -> np.ndarray:
  40. """Ensemble prediction ``(S,)``: argmax of the mean member probability."""
  41. return np.asarray(probs, dtype=float).mean(axis=0).argmax(axis=1)
  42. # ---------------------------------------------------------------------------
  43. # Measures -- each returns (S,), higher = more uncertain
  44. # ---------------------------------------------------------------------------
  45. def mean_output_entropy(probs: np.ndarray, **_: object) -> np.ndarray:
  46. """Total uncertainty: entropy of the ensemble mean output, ``H(p_bar)``."""
  47. return entropy(np.asarray(probs, dtype=float).mean(axis=0))
  48. def mean_predictive_entropy(probs: np.ndarray, **_: object) -> np.ndarray:
  49. """Aleatoric: mean of the members' own entropies, ``mean_m H(p_m)``.
  50. Equal to :func:`mean_output_entropy` when ``M == 1`` (hence skipped for the
  51. ``single`` configuration -- see :data:`MEASURES`).
  52. """
  53. return entropy(np.asarray(probs, dtype=float)).mean(axis=0)
  54. def ensemble_std(probs: np.ndarray, **_: object) -> np.ndarray:
  55. """Member disagreement: ``std_m(p_m[y_hat])`` on the ensemble-predicted class.
  56. Measures how much the members disagree about the probability they assign to
  57. the class the ensemble ultimately predicts. Identically 0 for a single model;
  58. the applicability matrix excludes it there.
  59. """
  60. values = np.asarray(probs, dtype=float)
  61. y_hat = predicted_class(values) # (S,)
  62. # Take each member's probability for the ensemble-predicted class -> (M, S).
  63. indices = np.broadcast_to(
  64. y_hat[None, :, None], (values.shape[0], values.shape[1], 1)
  65. )
  66. member_prob = np.take_along_axis(values, indices, axis=2)[:, :, 0]
  67. return member_prob.std(axis=0)
  68. def ensemble_mutual_information(probs: np.ndarray, **_: object) -> np.ndarray:
  69. """Epistemic from disagreement: total minus aleatoric (Jensen-Shannon).
  70. ``H(p_bar) - mean_m H(p_m)`` -- non-negative by Jensen's inequality, and
  71. identically zero when ``M == 1``.
  72. """
  73. return mean_output_entropy(probs) - mean_predictive_entropy(probs)
  74. def mean_mutual_information(
  75. probs: np.ndarray, member_mi: Optional[np.ndarray] = None, **_: object
  76. ) -> np.ndarray:
  77. """Within-member MC epistemic: ``mean_m MI_m`` from the stored statistic.
  78. Args:
  79. probs: Unused; kept for a uniform measure signature.
  80. member_mi: Stored ``mutual_information`` of shape ``(M, S)``.
  81. Bayesian only -- identically zero for deterministic members (K = 1).
  82. """
  83. if member_mi is None:
  84. raise ValueError(
  85. "mean_mutual_information needs the stored per-member "
  86. "`mutual_information` (M, S); pass member_mi=..."
  87. )
  88. return np.asarray(member_mi, dtype=float).mean(axis=0)
  89. # ---------------------------------------------------------------------------
  90. # Registry + applicability
  91. # ---------------------------------------------------------------------------
  92. MeasureFn = Callable[..., np.ndarray]
  93. @dataclass(frozen=True)
  94. class Measure:
  95. """An uncertainty measure and where it is scientifically meaningful."""
  96. name: str
  97. label: str
  98. fn: MeasureFn
  99. #: Needs more than one member (excludes the ``single`` configuration).
  100. requires_ensemble: bool = False
  101. #: Restricted to these families (empty = all).
  102. families: tuple[str, ...] = ()
  103. #: Needs the stored per-member ``mutual_information`` variable.
  104. needs_member_mi: bool = False
  105. #: Redundant with ``mean_output_entropy`` when M == 1.
  106. duplicate_when_single: bool = False
  107. def applies_to(self, family: str, config: str) -> bool:
  108. """Whether this measure is meaningful for a family x configuration."""
  109. if self.families and family not in self.families:
  110. return False
  111. if config == CONFIG_SINGLE and (
  112. self.requires_ensemble or self.duplicate_when_single
  113. ):
  114. return False
  115. return True
  116. #: All uncertainty measures, in presentation order.
  117. MEASURES: Dict[str, Measure] = {
  118. "mean_output_entropy": Measure(
  119. name="mean_output_entropy",
  120. label="Mean-output entropy (total)",
  121. fn=mean_output_entropy,
  122. ),
  123. "mean_predictive_entropy": Measure(
  124. name="mean_predictive_entropy",
  125. label="Mean predictive entropy (aleatoric)",
  126. fn=mean_predictive_entropy,
  127. duplicate_when_single=True,
  128. ),
  129. "ensemble_std": Measure(
  130. name="ensemble_std",
  131. label="Ensemble std. dev. (disagreement)",
  132. fn=ensemble_std,
  133. requires_ensemble=True,
  134. ),
  135. "ensemble_mutual_information": Measure(
  136. name="ensemble_mutual_information",
  137. label="Ensemble mutual information (epistemic)",
  138. fn=ensemble_mutual_information,
  139. requires_ensemble=True,
  140. ),
  141. "mean_mutual_information": Measure(
  142. name="mean_mutual_information",
  143. label="Mean MC mutual information (epistemic)",
  144. fn=mean_mutual_information,
  145. families=(MODEL_KIND_BAYESIAN,),
  146. needs_member_mi=True,
  147. ),
  148. }
  149. def measures_for(family: str, config: str) -> List[Measure]:
  150. """Measures that are meaningful for one family x configuration."""
  151. return [m for m in MEASURES.values() if m.applies_to(family, config)]
  152. def compute(
  153. measure: Measure,
  154. probs: np.ndarray,
  155. member_mi: Optional[np.ndarray] = None,
  156. ) -> np.ndarray:
  157. """Evaluate ``measure`` on member probabilities, supplying what it needs.
  158. Args:
  159. measure: The measure to evaluate.
  160. probs: Member probabilities ``(M, S, C)``.
  161. member_mi: Stored per-member ``mutual_information`` ``(M, S)``; required
  162. only by measures with ``needs_member_mi``.
  163. Returns:
  164. Per-sample uncertainty ``(S,)``, higher = more uncertain.
  165. """
  166. if measure.needs_member_mi:
  167. return measure.fn(probs, member_mi=member_mi)
  168. return measure.fn(probs)