noise_correlation.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. """Uncertainty vs. accuracy across noise conditions, with a fitted curve.
  2. Question: is uncertainty a *transferable* predictor of error -- does a given
  3. uncertainty value imply the same accuracy regardless of the noise condition?
  4. Method (see ai/ANALYSIS_PLAN.md 3.3)
  5. -----------------------------------
  6. For each noise level sigma, split its samples into ``N_BINS`` equal-count
  7. (quantile) uncertainty bins and plot one point per bin: mean uncertainty in the
  8. bin (x) against accuracy in the bin (y), coloured by sigma. This gives
  9. sigma x bins points -- enough to fit -- rather than the single point per sigma a
  10. naive reading would produce.
  11. Fits ``acc = a + b * exp(-k * u)`` (bounded, monotone) with a linear fallback,
  12. and reports R^2 plus Spearman rho. Rho is the robust headline because it makes no
  13. assumption about the functional form.
  14. Interpretation
  15. --------------
  16. If points from every sigma collapse onto one curve, the measure is calibrated
  17. *across conditions*: uncertainty alone predicts accuracy. If they separate by
  18. sigma, the measure is condition-dependent and cannot be used as a standalone
  19. confidence signal -- an important negative result if it appears.
  20. Outputs: ``noise_correlation_<measure>.png`` (one panel per family x
  21. configuration) and ``noise_correlation.json`` (fit parameters, R^2, rho).
  22. """
  23. from typing import Any, Dict, List
  24. import numpy as np
  25. from matplotlib import cm
  26. from matplotlib.colors import Normalize
  27. import analysis.measures as ms
  28. import analysis.metrics as mt
  29. from analysis.context import CONFIG_SINGLE, AnalysisContext, Series
  30. from analysis.sources import noise_axis_label
  31. from analysis.plotting import panel_grid, save_figure, save_json, style_axes
  32. from analysis.registry import register
  33. #: Equal-count uncertainty bins per noise level.
  34. #
  35. # 1 = one point per noise level, at that level's mean uncertainty and accuracy.
  36. # This is the form the earlier codebase used. It cannot show *within*-condition
  37. # structure, but it does test something that is not guaranteed: accuracy and
  38. # uncertainty are each only assumed monotone in sigma, so a clean relationship
  39. # between them across levels is real evidence they are linked, and its shape
  40. # characterises that link.
  41. #
  42. # Raise it (3-5) to also resolve the uncertainty->error relationship *inside*
  43. # each noise level. Each bin's accuracy is a binomial proportion, so its standard
  44. # error is sqrt(p(1-p)/n): with ~291 pooled samples the ensemble series gets
  45. # ~97/bin at 3 bins (SE ~0.04) but only ~29 at 10 (SE ~0.07), which visibly
  46. # dominated the scatter.
  47. N_BINS = 1
  48. #: Minimum samples in a bin for its accuracy to be trustworthy.
  49. MIN_BIN_SAMPLES = 5
  50. def _series_points(
  51. ctx: AnalysisContext, series: Series, measure: ms.Measure
  52. ) -> Dict[str, Any] | None:
  53. """Binned (uncertainty, accuracy, sigma) points plus the fit, for one series."""
  54. selected = ctx.noise_source(series.family)
  55. if selected is None:
  56. return None
  57. stem, ds = selected
  58. levels = ctx.noise_levels(ds)
  59. true_idx = ctx.true_index(ds)
  60. x_values: List[float] = []
  61. y_values: List[float] = []
  62. sigma_values: List[float] = []
  63. for level_index, sigma in enumerate(levels):
  64. probs = ctx.probs(series.family, ds, level_index)
  65. member_mi = (
  66. ctx.member_stat("mutual_information", series.family, ds, level_index)
  67. if measure.needs_member_mi
  68. else None
  69. )
  70. # `single` pools every (member, sample) observation at this noise level,
  71. # so it yields the same number of points as `ensemble` while using all
  72. # members' predictions.
  73. if series.config == CONFIG_SINGLE:
  74. uncertainty = np.concatenate(
  75. [
  76. ms.compute(
  77. measure,
  78. probs[m : m + 1],
  79. member_mi=None if member_mi is None else member_mi[m : m + 1],
  80. )
  81. for m in range(probs.shape[0])
  82. ]
  83. )
  84. correct = np.concatenate(
  85. [
  86. ms.predicted_class(probs[m : m + 1]) == true_idx
  87. for m in range(probs.shape[0])
  88. ]
  89. )
  90. else:
  91. uncertainty = ms.compute(measure, probs, member_mi=member_mi)
  92. correct = ms.predicted_class(probs) == true_idx
  93. for indices in mt.quantile_bins(uncertainty, N_BINS):
  94. if indices.size < MIN_BIN_SAMPLES:
  95. continue
  96. x_values.append(float(np.mean(uncertainty[indices])))
  97. y_values.append(float(np.mean(correct[indices])))
  98. sigma_values.append(float(sigma))
  99. if len(x_values) < 3:
  100. return None
  101. fit = mt.fit_accuracy_vs_uncertainty(
  102. np.asarray(x_values), np.asarray(y_values)
  103. )
  104. return {
  105. "source": stem,
  106. "noise_axis": noise_axis_label(ds),
  107. "uncertainty": x_values,
  108. "accuracy": y_values,
  109. "noise_level": sigma_values,
  110. "n_bins": N_BINS,
  111. "fit": {
  112. "model": fit.model,
  113. "params": fit.params,
  114. "r_squared": fit.r_squared,
  115. "spearman": fit.spearman,
  116. },
  117. "_fit_line": (fit.x_fit, fit.y_fit),
  118. }
  119. def _plot_measure(
  120. ctx: AnalysisContext,
  121. measure: ms.Measure,
  122. per_series: Dict[str, Dict[str, Any]],
  123. ) -> None:
  124. """One panel per series; points coloured by noise level, with the fit drawn."""
  125. keys = list(per_series)
  126. fig, axes = panel_grid(len(keys), n_cols=2, panel_size=(5.0, 3.8))
  127. all_sigmas = sorted(
  128. {s for payload in per_series.values() for s in payload["noise_level"]}
  129. )
  130. norm = Normalize(vmin=min(all_sigmas), vmax=max(all_sigmas))
  131. colormap = cm.viridis
  132. scatter = None
  133. for ax, key in zip(axes, keys):
  134. payload = per_series[key]
  135. series: Series = payload["_series"]
  136. scatter = ax.scatter(
  137. payload["uncertainty"],
  138. payload["accuracy"],
  139. c=payload["noise_level"],
  140. cmap=colormap,
  141. norm=norm,
  142. s=26,
  143. edgecolor="white",
  144. linewidth=0.4,
  145. zorder=3,
  146. )
  147. x_fit, y_fit = payload["_fit_line"]
  148. if x_fit:
  149. ax.plot(x_fit, y_fit, color="#444444", linewidth=1.6, zorder=2)
  150. fit = payload["fit"]
  151. ax.annotate(
  152. f"{fit['model']} R²={fit['r_squared']:.3f}\nρ={fit['spearman']:.3f}",
  153. xy=(0.97, 0.95),
  154. xycoords="axes fraction",
  155. ha="right",
  156. va="top",
  157. fontsize=8,
  158. color="#333333",
  159. )
  160. ax.set_title(series.label, fontsize=10)
  161. ax.set_xlabel(f"{measure.label}")
  162. ax.set_ylabel("Accuracy (per bin)")
  163. style_axes(ax)
  164. if scatter is not None:
  165. label = next(iter(per_series.values()))["noise_axis"]
  166. fig.colorbar(scatter, ax=axes, label=label, shrink=0.85)
  167. fig.suptitle(f"Accuracy vs. {measure.label.lower()} across noise levels")
  168. stem = f"noise_correlation_{measure.name}"
  169. png = save_figure(fig, ctx.out_dir, stem)
  170. ctx.log.info(f"noise_correlation: wrote {png.name}.")
  171. @register(
  172. "noise_correlation",
  173. title="Uncertainty vs. accuracy across noise levels (with fit)",
  174. requires_noise=True,
  175. )
  176. def noise_correlation(ctx: AnalysisContext) -> None:
  177. results: Dict[str, Dict[str, Dict[str, Any]]] = {}
  178. for series in ctx.series(ctx.noise_families):
  179. for measure in ms.measures_for(series.family, series.config):
  180. payload = _series_points(ctx, series, measure)
  181. if payload is None:
  182. continue
  183. payload["_series"] = series
  184. results.setdefault(measure.name, {})[series.key] = payload
  185. if not results:
  186. ctx.log.error("noise_correlation: no noise-sweep evaluations available.")
  187. return
  188. for measure_name, per_series in results.items():
  189. _plot_measure(ctx, ms.MEASURES[measure_name], per_series)
  190. for key, payload in per_series.items():
  191. fit = payload["fit"]
  192. ctx.log.info(
  193. f"noise_correlation: {measure_name} / {key} — {fit['model']} fit "
  194. f"R²={fit['r_squared']:.3f}, Spearman ρ={fit['spearman']:.3f}."
  195. )
  196. save_json(
  197. {
  198. "n_bins": N_BINS,
  199. "min_bin_samples": MIN_BIN_SAMPLES,
  200. "measures": {
  201. measure: {
  202. key: {
  203. k: v
  204. for k, v in payload.items()
  205. if k not in ("_series", "_fit_line")
  206. }
  207. for key, payload in per_series.items()
  208. }
  209. for measure, per_series in results.items()
  210. },
  211. },
  212. ctx.out_dir,
  213. "noise_correlation",
  214. )