coverage.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """Coverage (selective-prediction) curves.
  2. Question: if the most uncertain predictions are refused, how much does quality
  3. improve? A useful uncertainty measure buys accuracy when you discard.
  4. Method (see ai/ANALYSIS_PLAN.md 3.1)
  5. -----------------------------------
  6. Rank samples by uncertainty ascending (most confident first, stable sort), retain
  7. the leading fraction c for each c in the coverage grid, and score accuracy and F1
  8. on the retained subset. Repeated for every family x configuration x applicable
  9. uncertainty measure, at the clean baseline noise level.
  10. The ``single`` configuration evaluates each member on its own and averages the
  11. resulting curves across members (+/-1 std band), so it is a fair "one model"
  12. baseline rather than an arbitrary pick.
  13. A *flat* curve is a real result: it means the measure carries no information
  14. about correctness. A curve rising to the left is what a well-behaved uncertainty
  15. measure looks like.
  16. Outputs: ``coverage_accuracy.png``, ``coverage_f1.png`` (one panel per measure,
  17. one line per family x configuration) and ``coverage.json`` (every curve + AURC).
  18. """
  19. import warnings
  20. from typing import Any, Dict, List
  21. import numpy as np
  22. import analysis.measures as ms
  23. import analysis.metrics as mt
  24. from analysis.context import CONFIG_SINGLE, AnalysisContext, Series
  25. from analysis.plotting import add_legend, panel_grid, save_figure, save_json, style_axes
  26. from analysis.registry import register
  27. def _series_curves(
  28. ctx: AnalysisContext, series: Series, measure: ms.Measure
  29. ) -> Dict[str, Any] | None:
  30. """Coverage curve(s) for one series and measure, aggregated over members."""
  31. selected = ctx.clean_source(series.family)
  32. if selected is None:
  33. return None
  34. stem, ds = selected
  35. probs = ctx.probs(series.family, ds) # (M, S, C)
  36. if probs.shape[0] == 0:
  37. return None
  38. member_mi = (
  39. ctx.member_stat("mutual_information", series.family, ds)
  40. if measure.needs_member_mi
  41. else None
  42. )
  43. true_idx = ctx.true_index(ds)
  44. # ensemble -> one curve from all members; single -> one curve per member.
  45. if series.config == CONFIG_SINGLE:
  46. groups = [
  47. (probs[m : m + 1], None if member_mi is None else member_mi[m : m + 1])
  48. for m in range(probs.shape[0])
  49. ]
  50. else:
  51. groups = [(probs, member_mi)]
  52. curves: List[mt.CoverageCurve] = []
  53. for group_probs, group_mi in groups:
  54. uncertainty = ms.compute(measure, group_probs, member_mi=group_mi)
  55. pred = ms.predicted_class(group_probs)
  56. curves.append(mt.coverage_curve(uncertainty, pred, true_idx))
  57. accuracy = np.array([c.accuracy for c in curves], dtype=float)
  58. f1 = np.array([c.f1 for c in curves], dtype=float)
  59. aurc_values = np.array([c.aurc for c in curves], dtype=float)
  60. # Low-coverage points can be NaN for every member (the min_samples guard);
  61. # averaging an all-NaN column legitimately yields NaN, so silence the notice.
  62. with warnings.catch_warnings():
  63. warnings.simplefilter("ignore", RuntimeWarning)
  64. return {
  65. "source": stem,
  66. "coverage": curves[0].coverage,
  67. "n_retained": curves[0].n_retained,
  68. "n_curves": len(curves),
  69. "accuracy": np.nanmean(accuracy, axis=0).tolist(),
  70. "accuracy_std": np.nanstd(accuracy, axis=0).tolist(),
  71. "f1": np.nanmean(f1, axis=0).tolist(),
  72. "f1_std": np.nanstd(f1, axis=0).tolist(),
  73. "aurc": float(np.nanmean(aurc_values)),
  74. }
  75. def _plot(
  76. ctx: AnalysisContext,
  77. results: Dict[str, Dict[str, Dict[str, Any]]],
  78. metric: str,
  79. std_key: str,
  80. ylabel: str,
  81. stem: str,
  82. ) -> None:
  83. """One panel per measure; one line per series."""
  84. panels = [name for name, per_series in results.items() if per_series]
  85. if not panels:
  86. return
  87. fig, axes = panel_grid(len(panels), n_cols=2)
  88. for ax, measure_name in zip(axes, panels):
  89. for series_key, payload in results[measure_name].items():
  90. series = payload["_series"]
  91. x = np.asarray(payload["coverage"], dtype=float) * 100.0
  92. y = np.asarray(payload[metric], dtype=float)
  93. spread = np.asarray(payload[std_key], dtype=float)
  94. if payload["n_curves"] > 1:
  95. ax.fill_between(
  96. x, y - spread, y + spread,
  97. color=series.color, alpha=0.15, linewidth=0,
  98. )
  99. ax.plot(
  100. x, y,
  101. color=series.color, linestyle=series.linestyle,
  102. linewidth=2, marker="o", markersize=3, label=series.label,
  103. )
  104. ax.set_title(ms.MEASURES[measure_name].label, fontsize=10)
  105. ax.set_xlabel("Coverage: most-confident samples retained (%)")
  106. ax.set_ylabel(ylabel)
  107. style_axes(ax)
  108. fig.suptitle(f"Coverage curves — {ylabel} vs. retained fraction")
  109. add_legend(fig, axes)
  110. png = save_figure(fig, ctx.out_dir, stem)
  111. ctx.log.info(f"coverage: wrote {png.name} ({len(panels)} measure panels).")
  112. @register(
  113. "coverage",
  114. title="Coverage curves: accuracy and F1 vs. retained fraction",
  115. )
  116. def coverage(ctx: AnalysisContext) -> None:
  117. # results[measure][series_key] = curve payload
  118. results: Dict[str, Dict[str, Dict[str, Any]]] = {
  119. name: {} for name in ms.MEASURES
  120. }
  121. for series in ctx.series():
  122. for measure in ms.measures_for(series.family, series.config):
  123. payload = _series_curves(ctx, series, measure)
  124. if payload is None:
  125. continue
  126. payload["_series"] = series
  127. results[measure.name][series.key] = payload
  128. if not any(results.values()):
  129. ctx.log.error("coverage: no usable evaluations.")
  130. return
  131. _plot(ctx, results, "accuracy", "accuracy_std", "Accuracy", "coverage_accuracy")
  132. _plot(ctx, results, "f1", "f1_std", "F1 (AD)", "coverage_f1")
  133. serializable = {
  134. measure: {
  135. key: {k: v for k, v in payload.items() if k != "_series"}
  136. for key, payload in per_series.items()
  137. }
  138. for measure, per_series in results.items()
  139. if per_series
  140. }
  141. save_json(
  142. {
  143. "positive_class": mt.POSITIVE_CLASS,
  144. "grid": list(mt.DEFAULT_COVERAGE_GRID),
  145. "min_samples": mt.MIN_COVERAGE_SAMPLES,
  146. "measures": serializable,
  147. },
  148. ctx.out_dir,
  149. "coverage",
  150. )
  151. for measure, per_series in serializable.items():
  152. for key, payload in per_series.items():
  153. ctx.log.info(
  154. f"coverage: {measure} / {key} — AURC={payload['aurc']:.4f} "
  155. f"(accuracy at full coverage {payload['accuracy'][-1]:.3f})."
  156. )