noise_performance.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  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. # Panels: accuracy, F1, then every measure that any series produced.
  107. measure_panels = [
  108. name
  109. for name in ms.MEASURES
  110. if any(name in p["measures"] for p in responses.values())
  111. ]
  112. panel_keys = ["accuracy", "f1"] + measure_panels
  113. fig, axes = panel_grid(len(panel_keys), n_cols=2)
  114. for ax, key in zip(axes, panel_keys):
  115. is_performance = key in ("accuracy", "f1")
  116. for payload in responses.values():
  117. series: Series = payload["_series"]
  118. x = np.asarray(payload["noise_levels"], dtype=float)
  119. if is_performance:
  120. y = np.asarray(payload[key], dtype=float)
  121. spread = np.asarray(payload[f"{key}_std"], dtype=float)
  122. show_band = series.config == CONFIG_SINGLE
  123. else:
  124. if key not in payload["measures"]:
  125. continue
  126. y = np.asarray(payload["measures"][key]["mean"], dtype=float)
  127. spread = np.asarray(payload["measures"][key]["std"], dtype=float)
  128. show_band = True
  129. if show_band:
  130. ax.fill_between(
  131. x, y - spread, y + spread,
  132. color=series.color, alpha=0.13, linewidth=0,
  133. )
  134. ax.plot(
  135. x, y,
  136. color=series.color, linestyle=series.linestyle,
  137. linewidth=2, marker="o", markersize=4, label=series.label,
  138. )
  139. if key == "accuracy":
  140. ax.set_title("Accuracy", fontsize=10)
  141. ax.set_ylabel("Accuracy")
  142. elif key == "f1":
  143. ax.set_title(f"F1 ({mt.POSITIVE_CLASS})", fontsize=10)
  144. ax.set_ylabel("F1")
  145. else:
  146. ax.set_title(ms.MEASURES[key].label, fontsize=10)
  147. ax.set_ylabel("Uncertainty (nats)")
  148. ax.set_xlabel(next(iter(responses.values()))["noise_axis"])
  149. style_axes(ax)
  150. fig.suptitle("Performance and uncertainty vs. input noise")
  151. add_legend(fig, axes)
  152. png = save_figure(fig, ctx.out_dir, "noise_performance")
  153. save_json(
  154. {
  155. "positive_class": mt.POSITIVE_CLASS,
  156. "series": {
  157. key: {k: v for k, v in payload.items() if k != "_series"}
  158. for key, payload in responses.items()
  159. },
  160. },
  161. ctx.out_dir,
  162. "noise_performance",
  163. )
  164. for key, payload in responses.items():
  165. levels = payload["noise_levels"]
  166. acc = payload["accuracy"]
  167. ctx.log.info(
  168. f"noise_performance: {key} — accuracy {acc[0]:.3f} at σ={levels[0]:g} "
  169. f"→ {acc[-1]:.3f} at σ={levels[-1]:g}."
  170. )
  171. ctx.log.info(f"noise_performance: wrote {png.name} ({len(panel_keys)} panels).")