coverage.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  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_measure(
  76. ctx: AnalysisContext,
  77. measure_name: str,
  78. per_series: Dict[str, Dict[str, Any]],
  79. ) -> None:
  80. """One FIGURE per uncertainty measure: accuracy and F1 side by side.
  81. One file per measure keeps each panel to a handful of lines; a single grid of
  82. every measure was too dense to read.
  83. """
  84. label = ms.MEASURES[measure_name].label
  85. fig, axes = panel_grid(2, n_cols=2, panel_size=(5.2, 3.8))
  86. for ax, (metric, std_key, ylabel) in zip(
  87. axes,
  88. [
  89. ("accuracy", "accuracy_std", "Accuracy"),
  90. ("f1", "f1_std", f"F1 ({mt.POSITIVE_CLASS})"),
  91. ],
  92. ):
  93. for payload in per_series.values():
  94. series = payload["_series"]
  95. x = np.asarray(payload["coverage"], dtype=float) * 100.0
  96. y = np.asarray(payload[metric], dtype=float)
  97. spread = np.asarray(payload[std_key], dtype=float)
  98. if payload["n_curves"] > 1:
  99. ax.fill_between(
  100. x, y - spread, y + spread,
  101. color=series.color, alpha=0.15, linewidth=0,
  102. )
  103. ax.plot(
  104. x, y,
  105. color=series.color, linestyle=series.linestyle,
  106. linewidth=2, marker="o", markersize=3, label=series.label,
  107. )
  108. ax.set_title(ylabel, fontsize=10)
  109. ax.set_xlabel("Coverage: most-confident samples retained (%)")
  110. ax.set_ylabel(ylabel)
  111. style_axes(ax)
  112. # Full dataset on the left, progressively more restricted to the right,
  113. # so the curve reads in the direction of "discard more".
  114. ax.invert_xaxis()
  115. fig.suptitle(f"Coverage curves — {label}")
  116. add_legend(fig, axes)
  117. png = save_figure(fig, ctx.out_dir, f"coverage_{measure_name}")
  118. ctx.log.info(f"coverage: wrote {png.name}.")
  119. @register(
  120. "coverage",
  121. title="Coverage curves: accuracy and F1 vs. retained fraction",
  122. )
  123. def coverage(ctx: AnalysisContext) -> None:
  124. # results[measure][series_key] = curve payload
  125. results: Dict[str, Dict[str, Dict[str, Any]]] = {
  126. name: {} for name in ms.MEASURES
  127. }
  128. for series in ctx.series():
  129. for measure in ms.measures_for(series.family, series.config):
  130. payload = _series_curves(ctx, series, measure)
  131. if payload is None:
  132. continue
  133. payload["_series"] = series
  134. results[measure.name][series.key] = payload
  135. if not any(results.values()):
  136. ctx.log.error("coverage: no usable evaluations.")
  137. return
  138. for measure_name, per_series in results.items():
  139. if per_series:
  140. _plot_measure(ctx, measure_name, per_series)
  141. serializable = {
  142. measure: {
  143. key: {k: v for k, v in payload.items() if k != "_series"}
  144. for key, payload in per_series.items()
  145. }
  146. for measure, per_series in results.items()
  147. if per_series
  148. }
  149. save_json(
  150. {
  151. "positive_class": mt.POSITIVE_CLASS,
  152. "grid": list(mt.DEFAULT_COVERAGE_GRID),
  153. "min_samples": mt.MIN_COVERAGE_SAMPLES,
  154. "measures": serializable,
  155. },
  156. ctx.out_dir,
  157. "coverage",
  158. )
  159. for measure, per_series in serializable.items():
  160. for key, payload in per_series.items():
  161. ctx.log.info(
  162. f"coverage: {measure} / {key} — AURC={payload['aurc']:.4f} "
  163. f"(accuracy at full coverage {payload['accuracy'][-1]:.3f})."
  164. )