| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- """Uncertainty vs. accuracy across noise conditions, with a fitted curve.
- Question: is uncertainty a *transferable* predictor of error -- does a given
- uncertainty value imply the same accuracy regardless of the noise condition?
- Method (see ai/ANALYSIS_PLAN.md 3.3)
- -----------------------------------
- For each noise level sigma, split its samples into ``N_BINS`` equal-count
- (quantile) uncertainty bins and plot one point per bin: mean uncertainty in the
- bin (x) against accuracy in the bin (y), coloured by sigma. This gives
- sigma x bins points -- enough to fit -- rather than the single point per sigma a
- naive reading would produce.
- Fits ``acc = a + b * exp(-k * u)`` (bounded, monotone) with a linear fallback,
- and reports R^2 plus Spearman rho. Rho is the robust headline because it makes no
- assumption about the functional form.
- Interpretation
- --------------
- If points from every sigma collapse onto one curve, the measure is calibrated
- *across conditions*: uncertainty alone predicts accuracy. If they separate by
- sigma, the measure is condition-dependent and cannot be used as a standalone
- confidence signal -- an important negative result if it appears.
- Outputs: ``noise_correlation_<measure>.png`` (one panel per family x
- configuration) and ``noise_correlation.json`` (fit parameters, R^2, rho).
- """
- from typing import Any, Dict, List
- import numpy as np
- from matplotlib import cm
- from matplotlib.colors import Normalize
- import analysis.measures as ms
- import analysis.metrics as mt
- from analysis.context import CONFIG_SINGLE, AnalysisContext, Series
- from analysis.sources import noise_axis_label
- from analysis.plotting import panel_grid, save_figure, save_json, style_axes
- from analysis.registry import register
- #: Equal-count uncertainty bins per noise level.
- #
- # 1 = one point per noise level, at that level's mean uncertainty and accuracy.
- # This is the form the earlier codebase used. It cannot show *within*-condition
- # structure, but it does test something that is not guaranteed: accuracy and
- # uncertainty are each only assumed monotone in sigma, so a clean relationship
- # between them across levels is real evidence they are linked, and its shape
- # characterises that link.
- #
- # Raise it (3-5) to also resolve the uncertainty->error relationship *inside*
- # each noise level. Each bin's accuracy is a binomial proportion, so its standard
- # error is sqrt(p(1-p)/n): with ~291 pooled samples the ensemble series gets
- # ~97/bin at 3 bins (SE ~0.04) but only ~29 at 10 (SE ~0.07), which visibly
- # dominated the scatter.
- N_BINS = 1
- #: Minimum samples in a bin for its accuracy to be trustworthy.
- MIN_BIN_SAMPLES = 5
- def _series_points(
- ctx: AnalysisContext, series: Series, measure: ms.Measure
- ) -> Dict[str, Any] | None:
- """Binned (uncertainty, accuracy, sigma) points plus the fit, for one series."""
- selected = ctx.noise_source(series.family)
- if selected is None:
- return None
- stem, ds = selected
- levels = ctx.noise_levels(ds)
- true_idx = ctx.true_index(ds)
- x_values: List[float] = []
- y_values: List[float] = []
- sigma_values: List[float] = []
- for level_index, sigma in enumerate(levels):
- probs = ctx.probs(series.family, ds, level_index)
- member_mi = (
- ctx.member_stat("mutual_information", series.family, ds, level_index)
- if measure.needs_member_mi
- else None
- )
- # `single` pools every (member, sample) observation at this noise level,
- # so it yields the same number of points as `ensemble` while using all
- # members' predictions.
- if series.config == CONFIG_SINGLE:
- uncertainty = np.concatenate(
- [
- ms.compute(
- measure,
- probs[m : m + 1],
- member_mi=None if member_mi is None else member_mi[m : m + 1],
- )
- for m in range(probs.shape[0])
- ]
- )
- correct = np.concatenate(
- [
- ms.predicted_class(probs[m : m + 1]) == true_idx
- for m in range(probs.shape[0])
- ]
- )
- else:
- uncertainty = ms.compute(measure, probs, member_mi=member_mi)
- correct = ms.predicted_class(probs) == true_idx
- for indices in mt.quantile_bins(uncertainty, N_BINS):
- if indices.size < MIN_BIN_SAMPLES:
- continue
- x_values.append(float(np.mean(uncertainty[indices])))
- y_values.append(float(np.mean(correct[indices])))
- sigma_values.append(float(sigma))
- if len(x_values) < 3:
- return None
- fit = mt.fit_accuracy_vs_uncertainty(
- np.asarray(x_values), np.asarray(y_values)
- )
- return {
- "source": stem,
- "noise_axis": noise_axis_label(ds),
- "uncertainty": x_values,
- "accuracy": y_values,
- "noise_level": sigma_values,
- "n_bins": N_BINS,
- "fit": {
- "model": fit.model,
- "params": fit.params,
- "r_squared": fit.r_squared,
- "spearman": fit.spearman,
- },
- "_fit_line": (fit.x_fit, fit.y_fit),
- }
- def _plot_measure(
- ctx: AnalysisContext,
- measure: ms.Measure,
- per_series: Dict[str, Dict[str, Any]],
- ) -> None:
- """One panel per series; points coloured by noise level, with the fit drawn."""
- keys = list(per_series)
- fig, axes = panel_grid(len(keys), n_cols=2, panel_size=(5.0, 3.8))
- all_sigmas = sorted(
- {s for payload in per_series.values() for s in payload["noise_level"]}
- )
- norm = Normalize(vmin=min(all_sigmas), vmax=max(all_sigmas))
- colormap = cm.viridis
- scatter = None
- for ax, key in zip(axes, keys):
- payload = per_series[key]
- series: Series = payload["_series"]
- scatter = ax.scatter(
- payload["uncertainty"],
- payload["accuracy"],
- c=payload["noise_level"],
- cmap=colormap,
- norm=norm,
- s=26,
- edgecolor="white",
- linewidth=0.4,
- zorder=3,
- )
- x_fit, y_fit = payload["_fit_line"]
- if x_fit:
- ax.plot(x_fit, y_fit, color="#444444", linewidth=1.6, zorder=2)
- fit = payload["fit"]
- ax.annotate(
- f"{fit['model']} R²={fit['r_squared']:.3f}\nρ={fit['spearman']:.3f}",
- xy=(0.97, 0.95),
- xycoords="axes fraction",
- ha="right",
- va="top",
- fontsize=8,
- color="#333333",
- )
- ax.set_title(series.label, fontsize=10)
- ax.set_xlabel(f"{measure.label}")
- ax.set_ylabel("Accuracy (per bin)")
- style_axes(ax)
- if scatter is not None:
- label = next(iter(per_series.values()))["noise_axis"]
- fig.colorbar(scatter, ax=axes, label=label, shrink=0.85)
- fig.suptitle(f"Accuracy vs. {measure.label.lower()} across noise levels")
- stem = f"noise_correlation_{measure.name}"
- png = save_figure(fig, ctx.out_dir, stem)
- ctx.log.info(f"noise_correlation: wrote {png.name}.")
- @register(
- "noise_correlation",
- title="Uncertainty vs. accuracy across noise levels (with fit)",
- requires_noise=True,
- )
- def noise_correlation(ctx: AnalysisContext) -> None:
- results: Dict[str, Dict[str, Dict[str, Any]]] = {}
- for series in ctx.series(ctx.noise_families):
- for measure in ms.measures_for(series.family, series.config):
- payload = _series_points(ctx, series, measure)
- if payload is None:
- continue
- payload["_series"] = series
- results.setdefault(measure.name, {})[series.key] = payload
- if not results:
- ctx.log.error("noise_correlation: no noise-sweep evaluations available.")
- return
- for measure_name, per_series in results.items():
- _plot_measure(ctx, ms.MEASURES[measure_name], per_series)
- for key, payload in per_series.items():
- fit = payload["fit"]
- ctx.log.info(
- f"noise_correlation: {measure_name} / {key} — {fit['model']} fit "
- f"R²={fit['r_squared']:.3f}, Spearman ρ={fit['spearman']:.3f}."
- )
- save_json(
- {
- "n_bins": N_BINS,
- "min_bin_samples": MIN_BIN_SAMPLES,
- "measures": {
- measure: {
- key: {
- k: v
- for k, v in payload.items()
- if k not in ("_series", "_fit_line")
- }
- for key, payload in per_series.items()
- }
- for measure, per_series in results.items()
- },
- },
- ctx.out_dir,
- "noise_correlation",
- )
|