noise_performance.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """Performance and uncertainty vs. input noise.
  2. Question: how do accuracy and each uncertainty measure respond as Gaussian input
  3. noise increases? This is the core robustness result.
  4. Method (see ai/ANALYSIS_PLAN.md 3.2)
  5. -----------------------------------
  6. Uses a noise-sweep evaluation (``noisy_*`` / ``combined_noisy_*``). For every
  7. noise level sigma, computes accuracy, F1 and the mean (+/-1 std across samples)
  8. of each applicable uncertainty measure, for every family x configuration. The
  9. ``single`` configuration averages over members.
  10. The scientifically interesting comparison is the *rate* of change: the epistemic
  11. terms (``ensemble_mutual_information``, ``mean_mutual_information``) should rise
  12. faster than the aleatoric term (``mean_predictive_entropy``) as inputs move out
  13. of distribution. If they do not, the uncertainty is not tracking what it should.
  14. Outputs: ``noise_performance.png`` (accuracy and F1 panels, then one panel per
  15. uncertainty measure, all against sigma) and ``noise_performance.json``.
  16. """
  17. from typing import Any, Dict, List, Tuple
  18. import numpy as np
  19. import analysis.measures as ms
  20. import analysis.metrics as mt
  21. from analysis.context import CONFIG_SINGLE, AnalysisContext, Series
  22. from analysis.sources import noise_axis_label
  23. from analysis.plotting import add_legend, panel_grid, save_figure, save_json, style_axes
  24. from analysis.registry import register
  25. def _member_groups(
  26. probs: np.ndarray, member_mi: np.ndarray | None, config: str
  27. ) -> List[Tuple[np.ndarray, np.ndarray | None]]:
  28. """Split into the units scored independently: 1 ensemble, or M singles."""
  29. if config == CONFIG_SINGLE:
  30. return [
  31. (probs[m : m + 1], None if member_mi is None else member_mi[m : m + 1])
  32. for m in range(probs.shape[0])
  33. ]
  34. return [(probs, member_mi)]
  35. def _series_response(
  36. ctx: AnalysisContext, series: Series
  37. ) -> Dict[str, Any] | None:
  38. """Accuracy, F1 and every applicable measure across the noise sweep."""
  39. selected = ctx.noise_source(series.family)
  40. if selected is None:
  41. return None
  42. stem, ds = selected
  43. levels = ctx.noise_levels(ds)
  44. true_idx = ctx.true_index(ds)
  45. applicable = ms.measures_for(series.family, series.config)
  46. accuracy: List[float] = []
  47. accuracy_std: List[float] = []
  48. f1: List[float] = []
  49. f1_std: List[float] = []
  50. measure_mean: Dict[str, List[float]] = {m.name: [] for m in applicable}
  51. measure_std: Dict[str, List[float]] = {m.name: [] for m in applicable}
  52. needs_mi = any(m.needs_member_mi for m in applicable)
  53. for level_index in range(len(levels)):
  54. probs = ctx.probs(series.family, ds, level_index)
  55. member_mi = (
  56. ctx.member_stat("mutual_information", series.family, ds, level_index)
  57. if needs_mi
  58. else None
  59. )
  60. groups = _member_groups(probs, member_mi, series.config)
  61. # Performance: score each unit, then average over units.
  62. unit_acc = [
  63. mt.accuracy(ms.predicted_class(g), true_idx) for g, _ in groups
  64. ]
  65. unit_f1 = [mt.f1_score(ms.predicted_class(g), true_idx) for g, _ in groups]
  66. accuracy.append(float(np.nanmean(unit_acc)))
  67. accuracy_std.append(float(np.nanstd(unit_acc)))
  68. f1.append(float(np.nanmean(unit_f1)))
  69. f1_std.append(float(np.nanstd(unit_f1)))
  70. # Uncertainty: mean over samples for each unit, then averaged over units;
  71. # the band is the spread across samples (pooled over units).
  72. for measure in applicable:
  73. per_sample = np.concatenate(
  74. [ms.compute(measure, g, member_mi=gm) for g, gm in groups]
  75. )
  76. measure_mean[measure.name].append(float(np.mean(per_sample)))
  77. measure_std[measure.name].append(float(np.std(per_sample)))
  78. return {
  79. "source": stem,
  80. "noise_axis": noise_axis_label(ds),
  81. "noise_levels": levels.tolist(),
  82. "accuracy": accuracy,
  83. "accuracy_std": accuracy_std,
  84. "f1": f1,
  85. "f1_std": f1_std,
  86. "measures": {
  87. name: {"mean": measure_mean[name], "std": measure_std[name]}
  88. for name in measure_mean
  89. },
  90. }
  91. @register(
  92. "noise_performance",
  93. title="Accuracy and uncertainty vs. input noise",
  94. requires_noise=True,
  95. )
  96. def noise_performance(ctx: AnalysisContext) -> None:
  97. responses: Dict[str, Dict[str, Any]] = {}
  98. for series in ctx.series(ctx.noise_families):
  99. payload = _series_response(ctx, series)
  100. if payload is not None:
  101. payload["_series"] = series
  102. responses[series.key] = payload
  103. if not responses:
  104. ctx.log.error("noise_performance: no noise-sweep evaluations available.")
  105. return
  106. x_label = next(iter(responses.values()))["noise_axis"]
  107. def _draw(ax, key: str) -> None:
  108. """Plot every series' response for one quantity onto ``ax``."""
  109. is_performance = key in ("accuracy", "f1")
  110. for payload in responses.values():
  111. series: Series = payload["_series"]
  112. x = np.asarray(payload["noise_levels"], dtype=float)
  113. if is_performance:
  114. y = np.asarray(payload[key], dtype=float)
  115. spread = np.asarray(payload[f"{key}_std"], dtype=float)
  116. show_band = series.config == CONFIG_SINGLE
  117. else:
  118. if key not in payload["measures"]:
  119. continue
  120. y = np.asarray(payload["measures"][key]["mean"], dtype=float)
  121. spread = np.asarray(payload["measures"][key]["std"], dtype=float)
  122. show_band = True
  123. if show_band:
  124. ax.fill_between(
  125. x, y - spread, y + spread,
  126. color=series.color, alpha=0.13, linewidth=0,
  127. )
  128. ax.plot(
  129. x, y,
  130. color=series.color, linestyle=series.linestyle,
  131. linewidth=2, marker="o", markersize=4, label=series.label,
  132. )
  133. ax.set_xlabel(x_label)
  134. style_axes(ax)
  135. # One figure for performance, then one per uncertainty measure -- a single
  136. # 7-panel grid was too dense to read.
  137. fig, axes = panel_grid(2, n_cols=2, panel_size=(5.2, 3.8))
  138. for ax, key, title in (
  139. (axes[0], "accuracy", "Accuracy"),
  140. (axes[1], "f1", f"F1 ({mt.POSITIVE_CLASS})"),
  141. ):
  142. _draw(ax, key)
  143. ax.set_title(title, fontsize=10)
  144. ax.set_ylabel(title)
  145. fig.suptitle("Performance vs. input noise")
  146. add_legend(fig, axes)
  147. png = save_figure(fig, ctx.out_dir, "noise_performance")
  148. for name in ms.MEASURES:
  149. if not any(name in p["measures"] for p in responses.values()):
  150. continue
  151. label = ms.MEASURES[name].label
  152. mfig, maxes = panel_grid(1, n_cols=1, panel_size=(6.4, 4.2))
  153. _draw(maxes[0], name)
  154. maxes[0].set_title(f"{label} vs. input noise", fontsize=11)
  155. maxes[0].set_ylabel(label)
  156. add_legend(mfig, maxes)
  157. mpng = save_figure(mfig, ctx.out_dir, f"noise_uncertainty_{name}")
  158. ctx.log.info(f"noise_performance: wrote {mpng.name}.")
  159. save_json(
  160. {
  161. "positive_class": mt.POSITIVE_CLASS,
  162. "series": {
  163. key: {k: v for k, v in payload.items() if k != "_series"}
  164. for key, payload in responses.items()
  165. },
  166. },
  167. ctx.out_dir,
  168. "noise_performance",
  169. )
  170. for key, payload in responses.items():
  171. levels = payload["noise_levels"]
  172. acc = payload["accuracy"]
  173. ctx.log.info(
  174. f"noise_performance: {key} — accuracy {acc[0]:.3f} at σ={levels[0]:g} "
  175. f"→ {acc[-1]:.3f} at σ={levels[-1]:g}."
  176. )
  177. ctx.log.info(f"noise_performance: wrote {png.name}.")