metrics.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. """Performance metrics and curve fitting.
  2. Definitions follow ai/ANALYSIS_PLAN.md section 3.
  3. Pure numpy/scipy: no plotting, no file I/O, no xarray. This is the part that has
  4. to be numerically correct, so it is kept independently testable.
  5. """
  6. from dataclasses import dataclass, field
  7. from typing import Any, Callable, Dict, List, Sequence, Tuple
  8. import numpy as np
  9. from evaluation import CLASS_NAMES
  10. # numpy>=2 renamed trapz -> trapezoid; support both without a static reference to
  11. # whichever name is missing in the installed version.
  12. _trapezoid: Callable[..., Any] = getattr(np, "trapezoid", None) or getattr(np, "trapz")
  13. #: Positive class for binary F1 (disease detection is the meaningful direction).
  14. POSITIVE_CLASS = "AD"
  15. POSITIVE_INDEX = CLASS_NAMES.index(POSITIVE_CLASS)
  16. #: Default coverage grid: keep the most-confident 5%, 10%, ... 100%.
  17. DEFAULT_COVERAGE_GRID: Tuple[float, ...] = tuple(
  18. round(0.05 * i, 2) for i in range(1, 21)
  19. )
  20. #: A coverage point with fewer retained samples than this is reported as NaN.
  21. MIN_COVERAGE_SAMPLES = 10
  22. # ---------------------------------------------------------------------------
  23. # Classification metrics
  24. # ---------------------------------------------------------------------------
  25. def accuracy(pred: np.ndarray, true: np.ndarray) -> float:
  26. """Fraction of correct predictions; ``nan`` for an empty input."""
  27. pred = np.asarray(pred)
  28. true = np.asarray(true)
  29. if pred.size == 0:
  30. return float("nan")
  31. return float(np.mean(pred == true))
  32. def f1_score(
  33. pred: np.ndarray, true: np.ndarray, positive: int = POSITIVE_INDEX
  34. ) -> float:
  35. """Binary F1 for the ``positive`` class: ``2TP / (2TP + FP + FN)``.
  36. Returns ``nan`` when F1 is genuinely undefined -- no true positives *and* no
  37. predicted positives -- rather than the misleading 0.0 some implementations
  38. report. A subset containing only true negatives is "no evidence", not
  39. "perfectly wrong".
  40. """
  41. pred = np.asarray(pred)
  42. true = np.asarray(true)
  43. if pred.size == 0:
  44. return float("nan")
  45. predicted_positive = pred == positive
  46. actually_positive = true == positive
  47. tp = int(np.sum(predicted_positive & actually_positive))
  48. fp = int(np.sum(predicted_positive & ~actually_positive))
  49. fn = int(np.sum(~predicted_positive & actually_positive))
  50. denominator = 2 * tp + fp + fn
  51. if denominator == 0: # no positives predicted and none present
  52. return float("nan")
  53. return float(2 * tp / denominator)
  54. def macro_f1(pred: np.ndarray, true: np.ndarray) -> float:
  55. """Unweighted mean of the per-class F1 scores (alternative to binary F1)."""
  56. scores = [
  57. f1_score(pred, true, positive=index)
  58. for index in range(len(CLASS_NAMES))
  59. ]
  60. finite = [s for s in scores if np.isfinite(s)]
  61. return float(np.mean(finite)) if finite else float("nan")
  62. # ---------------------------------------------------------------------------
  63. # Coverage / risk-coverage
  64. # ---------------------------------------------------------------------------
  65. @dataclass
  66. class CoverageCurve:
  67. """Accuracy and F1 as a function of retained (most-confident) fraction."""
  68. coverage: List[float] = field(default_factory=list)
  69. accuracy: List[float] = field(default_factory=list)
  70. f1: List[float] = field(default_factory=list)
  71. n_retained: List[int] = field(default_factory=list)
  72. aurc: float = float("nan")
  73. def coverage_curve(
  74. uncertainty: np.ndarray,
  75. pred: np.ndarray,
  76. true: np.ndarray,
  77. grid: Sequence[float] = DEFAULT_COVERAGE_GRID,
  78. min_samples: int = MIN_COVERAGE_SAMPLES,
  79. ) -> CoverageCurve:
  80. """Accuracy/F1 after discarding the most uncertain predictions.
  81. Samples are ranked by ``uncertainty`` ascending (most confident first) with a
  82. stable sort, so ties resolve deterministically. For each fraction in ``grid``
  83. the leading portion is retained and scored.
  84. Args:
  85. uncertainty: Per-sample uncertainty ``(S,)``; higher = less confident.
  86. pred: Predicted class indices ``(S,)``.
  87. true: Ground-truth class indices ``(S,)``.
  88. grid: Retained fractions in (0, 1].
  89. min_samples: Points retaining fewer samples than this report ``nan``.
  90. Returns:
  91. A :class:`CoverageCurve`, including its AURC summary.
  92. """
  93. uncertainty = np.asarray(uncertainty, dtype=float)
  94. pred = np.asarray(pred)
  95. true = np.asarray(true)
  96. n_samples = uncertainty.size
  97. # Ascending uncertainty = most confident first. A stable sort makes ties
  98. # resolve by original sample order, so the curve is reproducible.
  99. order = np.argsort(uncertainty, kind="stable")
  100. curve = CoverageCurve()
  101. for fraction in grid:
  102. n_keep = int(np.clip(round(float(fraction) * n_samples), 0, n_samples))
  103. retained = order[:n_keep]
  104. if n_keep < min_samples:
  105. acc = float("nan")
  106. f1 = float("nan")
  107. else:
  108. acc = accuracy(pred[retained], true[retained])
  109. f1 = f1_score(pred[retained], true[retained])
  110. curve.coverage.append(float(fraction))
  111. curve.accuracy.append(acc)
  112. curve.f1.append(f1)
  113. curve.n_retained.append(n_keep)
  114. curve.aurc = aurc(curve.coverage, curve.accuracy)
  115. return curve
  116. def aurc(coverage: Sequence[float], accuracy_values: Sequence[float]) -> float:
  117. """Area under the risk-coverage curve (risk = 1 - accuracy); lower is better.
  118. Integrated with the trapezoid rule over the finite points and normalised by
  119. the covered range, so curves whose low-coverage end was suppressed by the
  120. ``min_samples`` guard remain comparable.
  121. """
  122. cov = np.asarray(coverage, dtype=float)
  123. acc = np.asarray(accuracy_values, dtype=float)
  124. finite = np.isfinite(acc)
  125. if finite.sum() < 2:
  126. return float("nan")
  127. cov = cov[finite]
  128. acc = acc[finite]
  129. span = float(cov[-1] - cov[0])
  130. if span <= 0:
  131. return float("nan")
  132. return float(_trapezoid(1.0 - acc, cov) / span)
  133. # ---------------------------------------------------------------------------
  134. # Binning + curve fitting (noise correlation)
  135. # ---------------------------------------------------------------------------
  136. def quantile_bins(
  137. uncertainty: np.ndarray, n_bins: int
  138. ) -> List[np.ndarray]:
  139. """Split sample indices into ``n_bins`` equal-count uncertainty bins.
  140. Equal-count (quantile) bins rather than equal-width, so every point in the
  141. scatter carries a comparable number of samples.
  142. """
  143. order = np.argsort(np.asarray(uncertainty, dtype=float), kind="stable")
  144. if order.size == 0 or n_bins < 1:
  145. return []
  146. return [chunk for chunk in np.array_split(order, n_bins) if chunk.size > 0]
  147. @dataclass
  148. class FitResult:
  149. """Outcome of fitting accuracy as a function of uncertainty."""
  150. model: str = "none"
  151. params: Dict[str, float] = field(default_factory=dict)
  152. r_squared: float = float("nan")
  153. spearman: float = float("nan")
  154. #: Points used, for plotting the fitted line.
  155. x_fit: List[float] = field(default_factory=list)
  156. y_fit: List[float] = field(default_factory=list)
  157. def fit_accuracy_vs_uncertainty(
  158. uncertainty: np.ndarray,
  159. accuracy_values: np.ndarray,
  160. model: str = "exponential",
  161. ) -> FitResult:
  162. """Fit accuracy as a decreasing function of uncertainty.
  163. ``exponential``: ``acc = a + b * exp(-k * u)`` -- bounded and monotone, the
  164. expected shape. Falls back to a linear fit if the optimiser fails to
  165. converge. Reports R^2 and Spearman rho; rho is the robust headline because it
  166. does not assume the functional form.
  167. """
  168. from scipy.optimize import curve_fit # local: keeps import cost off startup
  169. from scipy.stats import spearmanr
  170. x = np.asarray(uncertainty, dtype=float)
  171. y = np.asarray(accuracy_values, dtype=float)
  172. finite = np.isfinite(x) & np.isfinite(y)
  173. x, y = x[finite], y[finite]
  174. result = FitResult()
  175. if x.size < 3:
  176. return result # not enough points to say anything
  177. if x.size >= 3 and np.ptp(x) > 0:
  178. rho = float(np.asarray(spearmanr(x, y)).ravel()[0])
  179. result.spearman = rho if np.isfinite(rho) else float("nan")
  180. def _exponential(u: np.ndarray, a: float, b: float, k: float) -> np.ndarray:
  181. return a + b * np.exp(-k * u)
  182. fitted: np.ndarray | None = None
  183. if model == "exponential" and x.size >= 4 and np.ptp(x) > 0:
  184. try:
  185. scale = float(np.mean(x)) or 1.0
  186. guess = (float(np.min(y)), float(np.ptp(y)) or 0.1, 1.0 / scale)
  187. params, _ = curve_fit(_exponential, x, y, p0=guess, maxfev=10000)
  188. fitted = _exponential(x, *params)
  189. result.model = "exponential"
  190. result.params = {
  191. "a": float(params[0]),
  192. "b": float(params[1]),
  193. "k": float(params[2]),
  194. }
  195. except Exception: # noqa: BLE001 - fall back to a linear fit
  196. fitted = None
  197. if fitted is None and np.ptp(x) > 0:
  198. slope, intercept = np.polyfit(x, y, 1)
  199. fitted = slope * x + intercept
  200. result.model = "linear"
  201. result.params = {"slope": float(slope), "intercept": float(intercept)}
  202. if fitted is None:
  203. return result
  204. result.r_squared = r_squared(y, fitted)
  205. # A smooth line for plotting, evaluated on a dense grid over the fitted range.
  206. dense = np.linspace(float(x.min()), float(x.max()), 100)
  207. if result.model == "exponential":
  208. curve = _exponential(dense, *result.params.values())
  209. else:
  210. curve = result.params["slope"] * dense + result.params["intercept"]
  211. result.x_fit = [float(v) for v in dense]
  212. result.y_fit = [float(v) for v in curve]
  213. return result
  214. def r_squared(observed: np.ndarray, predicted: np.ndarray) -> float:
  215. """Coefficient of determination between observed and fitted values."""
  216. obs = np.asarray(observed, dtype=float)
  217. pred = np.asarray(predicted, dtype=float)
  218. ss_tot = float(np.sum((obs - obs.mean()) ** 2))
  219. if ss_tot <= 0:
  220. return float("nan")
  221. ss_res = float(np.sum((obs - pred) ** 2))
  222. return float(1.0 - ss_res / ss_tot)