ensemble_size.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. """Accuracy as a function of ensemble size.
  2. For every size 1..M, samples distinct member subsets, scores each as an ensemble
  3. (argmax of the mean member probability), and reports the mean accuracy with a
  4. +/-1 std band across the sampled configurations. Answers "how many models do we
  5. actually need?".
  6. """
  7. import itertools
  8. import math
  9. from typing import Any, Dict, List, Tuple
  10. import numpy as np
  11. from matplotlib.ticker import MaxNLocator
  12. from analysis.plotting import panel_grid, save_figure, save_json, style_axes
  13. from analysis.sources import FAMILY_LABELS, ordered_families
  14. from analysis.context import AnalysisContext, FAMILY_COLORS
  15. from analysis.registry import register
  16. #: Member-subset configurations sampled per ensemble size (all of them when the
  17. #: number of distinct subsets is smaller).
  18. TARGET_CONFIGS = 30
  19. def _sample_subsets(
  20. n_models: int, size: int, target: int, rng: np.random.Generator
  21. ) -> List[List[int]]:
  22. """Distinct member-index subsets of ``size``.
  23. Enumerates exhaustively when ``C(n_models, size) <= target`` (e.g. size 1 or
  24. size M), otherwise draws ``target`` distinct random subsets.
  25. """
  26. if math.comb(n_models, size) <= target:
  27. return [list(combo) for combo in itertools.combinations(range(n_models), size)]
  28. seen: set[Tuple[int, ...]] = set()
  29. subsets: List[List[int]] = []
  30. while len(subsets) < target:
  31. pick = tuple(
  32. sorted(int(i) for i in rng.choice(n_models, size=size, replace=False))
  33. )
  34. if pick not in seen:
  35. seen.add(pick)
  36. subsets.append(list(pick))
  37. return subsets
  38. def _size_curve(
  39. probs: np.ndarray, true_idx: np.ndarray, target: int, rng: np.random.Generator
  40. ) -> Dict[str, Any]:
  41. """Mean/std accuracy over sampled member subsets, for every size 1..M."""
  42. n_models = probs.shape[0]
  43. sizes: List[int] = []
  44. means: List[float] = []
  45. stds: List[float] = []
  46. n_configs: List[int] = []
  47. for size in range(1, n_models + 1):
  48. subsets = _sample_subsets(n_models, size, target, rng)
  49. accuracies = [
  50. float(np.mean(probs[subset].mean(axis=0).argmax(axis=1) == true_idx))
  51. for subset in subsets
  52. ]
  53. acc = np.asarray(accuracies, dtype=float)
  54. sizes.append(size)
  55. means.append(float(acc.mean()))
  56. stds.append(float(acc.std()))
  57. n_configs.append(len(subsets))
  58. return {"sizes": sizes, "mean": means, "std": stds, "n_configs": n_configs}
  59. @register(
  60. "ensemble_size_accuracy",
  61. title="Accuracy vs. number of ensemble members",
  62. )
  63. def ensemble_size_accuracy(ctx: AnalysisContext) -> None:
  64. base_seed = ctx.base_seed()
  65. curves: Dict[str, Dict[str, Any]] = {}
  66. for index, family in enumerate(ctx.families):
  67. selected = ctx.clean_source(family)
  68. if selected is None:
  69. continue
  70. stem, ds = selected
  71. probs = ctx.probs(family, ds)
  72. if probs.shape[0] < 1:
  73. continue
  74. # Distinct, reproducible RNG stream per family for subset sampling.
  75. rng = np.random.default_rng([base_seed, index])
  76. curve = _size_curve(probs, ctx.true_index(ds), TARGET_CONFIGS, rng)
  77. curve["source"] = stem
  78. curves[family] = curve
  79. ctx.log.info(
  80. f"ensemble_size_accuracy: {family} - sizes 1..{probs.shape[0]}, "
  81. f"up to {TARGET_CONFIGS} configs each."
  82. )
  83. if not curves:
  84. ctx.log.error("ensemble_size_accuracy: no usable probabilities.")
  85. return
  86. fig, axes = panel_grid(1, n_cols=1, panel_size=(7.0, 4.3))
  87. ax = axes[0]
  88. for family in ordered_families(curves.keys()):
  89. curve = curves[family]
  90. color = FAMILY_COLORS.get(family, "#666666")
  91. x = np.asarray(curve["sizes"])
  92. mean = np.asarray(curve["mean"])
  93. std = np.asarray(curve["std"])
  94. ax.fill_between(x, mean - std, mean + std, color=color, alpha=0.18, linewidth=0)
  95. ax.plot(
  96. x,
  97. mean,
  98. color=color,
  99. marker="o",
  100. markersize=4,
  101. linewidth=2,
  102. label=FAMILY_LABELS.get(family, family),
  103. )
  104. ax.set_xlabel("Ensemble size (number of models)")
  105. ax.set_ylabel("Accuracy")
  106. ax.set_title("Ensemble accuracy vs. number of models")
  107. ax.xaxis.set_major_locator(MaxNLocator(integer=True))
  108. ax.margins(x=0.02)
  109. style_axes(ax)
  110. ax.legend(frameon=False, loc="lower right")
  111. png = save_figure(fig, ctx.out_dir, "ensemble_size_accuracy")
  112. save_json(
  113. {"target_configs": TARGET_CONFIGS, "families": curves},
  114. ctx.out_dir,
  115. "ensemble_size_accuracy",
  116. )
  117. ctx.log.info(
  118. f"ensemble_size_accuracy: wrote {png.name} ({len(curves)} family/families)."
  119. )