bootstrap_ci.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. """Confidence intervals for full-ensemble accuracy.
  2. Patient-clustered percentile bootstrap: whole PATIENTS are resampled with
  3. replacement, not individual scans, because scans from one patient are correlated
  4. and a per-scan bootstrap would understate the interval. Holds the trained models
  5. fixed, so it captures test-set sampling noise only -- an honest lower bound on
  6. total uncertainty (it excludes training/initialisation variability).
  7. """
  8. from typing import Any, Dict
  9. import numpy as np
  10. from analysis.plotting import panel_grid, save_figure, save_json, style_axes
  11. from analysis.sources import FAMILY_LABELS, ordered_families
  12. from analysis.context import AnalysisContext, FAMILY_COLORS
  13. from analysis.registry import register
  14. BOOTSTRAP_ITERS = 2000
  15. CI_LEVEL = 0.95
  16. def _bootstrap_accuracy_ci(
  17. correct: np.ndarray,
  18. ptids: np.ndarray,
  19. iters: int,
  20. ci_level: float,
  21. rng: np.random.Generator,
  22. ) -> Dict[str, Any]:
  23. """Patient-clustered percentile bootstrap CI for a mean-accuracy statistic.
  24. Reduces to an ordinary bootstrap when every patient has exactly one scan.
  25. """
  26. point = float(np.mean(correct)) if correct.size else 0.0
  27. _, inverse = np.unique(ptids, return_inverse=True)
  28. n_patients = int(inverse.max()) + 1 if inverse.size else 0
  29. groups = [np.where(inverse == p)[0] for p in range(n_patients)]
  30. boot = np.empty(iters, dtype=float)
  31. for b in range(iters):
  32. chosen = rng.integers(0, n_patients, size=n_patients)
  33. boot[b] = correct[np.concatenate([groups[c] for c in chosen])].mean()
  34. tail = (1.0 - ci_level) / 2.0
  35. return {
  36. "point": point,
  37. "ci_low": float(np.quantile(boot, tail)),
  38. "ci_high": float(np.quantile(boot, 1.0 - tail)),
  39. "n_samples": int(correct.size),
  40. "n_patients": n_patients,
  41. }
  42. @register(
  43. "bootstrap_ensemble_ci",
  44. title="Bootstrap confidence intervals for ensemble accuracy",
  45. )
  46. def bootstrap_ensemble_ci(ctx: AnalysisContext) -> None:
  47. base_seed = ctx.base_seed()
  48. results: Dict[str, Dict[str, Any]] = {}
  49. for index, family in enumerate(ctx.families):
  50. selected = ctx.clean_source(family)
  51. if selected is None:
  52. continue
  53. stem, ds = selected
  54. probs = ctx.probs(family, ds)
  55. if probs.shape[0] < 1:
  56. continue
  57. # Full-ensemble prediction: argmax of the mean over ALL members.
  58. correct = (
  59. probs.mean(axis=0).argmax(axis=1) == ctx.true_index(ds)
  60. ).astype(float)
  61. rng = np.random.default_rng([base_seed, 1000 + index])
  62. ci = _bootstrap_accuracy_ci(
  63. correct, ctx.ptids(ds), BOOTSTRAP_ITERS, CI_LEVEL, rng
  64. )
  65. ci["source"] = stem
  66. results[family] = ci
  67. ctx.log.info(
  68. f"bootstrap_ensemble_ci: {family} full-ensemble acc = "
  69. f"{ci['point'] * 100:.1f}% (95% CI {ci['ci_low'] * 100:.1f}-"
  70. f"{ci['ci_high'] * 100:.1f}%, n={ci['n_samples']} scans / "
  71. f"{ci['n_patients']} patients)."
  72. )
  73. if not results:
  74. ctx.log.error("bootstrap_ensemble_ci: nothing to compute.")
  75. return
  76. ordered = ordered_families(results.keys())
  77. fig, axes = panel_grid(
  78. 1, n_cols=1, panel_size=(7.0, 1.4 + 0.8 * len(ordered))
  79. )
  80. ax = axes[0]
  81. for row, family in enumerate(ordered):
  82. ci = results[family]
  83. color = FAMILY_COLORS.get(family, "#666666")
  84. ax.plot(
  85. [ci["ci_low"], ci["ci_high"]],
  86. [row, row],
  87. color=color,
  88. linewidth=2.5,
  89. solid_capstyle="round",
  90. )
  91. ax.plot([ci["point"]], [row], "o", color=color, markersize=8)
  92. ax.annotate(
  93. f"{ci['point'] * 100:.1f}% "
  94. f"[{ci['ci_low'] * 100:.1f}, {ci['ci_high'] * 100:.1f}]",
  95. (ci["point"], row),
  96. xytext=(0, 10),
  97. textcoords="offset points",
  98. ha="center",
  99. va="bottom",
  100. fontsize=9,
  101. color="#222222",
  102. )
  103. ax.set_yticks(range(len(ordered)))
  104. ax.set_yticklabels([FAMILY_LABELS.get(k, k) for k in ordered])
  105. ax.invert_yaxis() # first family on top
  106. ax.set_xlabel("Full-ensemble accuracy (95% patient-clustered bootstrap CI)")
  107. ax.set_title("Ensemble accuracy with bootstrap confidence intervals")
  108. ax.margins(x=0.18, y=0.35)
  109. style_axes(ax, grid_axis="x")
  110. ax.spines["left"].set_visible(False)
  111. ax.tick_params(left=False)
  112. png = save_figure(fig, ctx.out_dir, "bootstrap_ci")
  113. save_json(
  114. {"iters": BOOTSTRAP_ITERS, "ci_level": CI_LEVEL, "families": results},
  115. ctx.out_dir,
  116. "bootstrap_ci",
  117. )
  118. ctx.log.info(
  119. f"bootstrap_ensemble_ci: wrote {png.name} ({len(results)} family/families)."
  120. )