ソースを参照

Seeding module and NetCDF schedma

Nicholas Schense 2 週間 前
コミット
48a6d8316d
3 ファイル変更538 行追加0 行削除
  1. 31 0
      evaluation/__init__.py
  2. 429 0
      evaluation/schema.py
  3. 78 0
      util/seeding.py

+ 31 - 0
evaluation/__init__.py

@@ -0,0 +1,31 @@
+"""Evaluation output layer.
+
+This package owns the canonical on-disk format for model evaluations. Steps 4-6
+of the pipeline (evaluate normal / Bayesian / noisy) all produce data in the
+single schema defined in ``evaluation.schema`` so that step 7 (analysis) can be
+written as pure functions over the resulting netCDF files.
+"""
+
+from evaluation.schema import (
+    CLASS_NAMES,
+    MODEL_KIND_BAYESIAN,
+    MODEL_KIND_NORMAL,
+    SCHEMA_VERSION,
+    EvaluationAccumulator,
+    build_evaluation_dataset,
+    compute_uncertainty,
+    load_evaluation,
+    save_evaluation,
+)
+
+__all__ = [
+    "CLASS_NAMES",
+    "MODEL_KIND_BAYESIAN",
+    "MODEL_KIND_NORMAL",
+    "SCHEMA_VERSION",
+    "EvaluationAccumulator",
+    "build_evaluation_dataset",
+    "compute_uncertainty",
+    "load_evaluation",
+    "save_evaluation",
+]

+ 429 - 0
evaluation/schema.py

@@ -0,0 +1,429 @@
+"""Canonical netCDF / xarray schema for pipeline evaluations (steps 4-6).
+
+This module is the single source of truth for how model outputs are stored on
+disk. Locking it down means every evaluator (normal, Bayesian, noisy) writes the
+exact same dimensions, variables, coordinates, and dtypes, so the analysis stage
+(step 7) only ever has to understand one format.
+
+--------------------------------------------------------------------------------
+Schema (xarray.Dataset)
+--------------------------------------------------------------------------------
+Dimensions
+    model         ensemble member index (0..M-1)
+    noise_level   Gaussian noise sigma applied to the image; ALWAYS present.
+                  The clean baseline is stored as noise_level == 0.0, so noised
+                  and un-noised evaluations share one file per model family.
+    sample        fixed test-set sample index (from a NON-shuffled loader)
+    class_name    output classes, ordered ["AD", "NL"]
+    mc_pass       (optional) raw Monte-Carlo draw index, Bayesian only
+
+Data variables
+    prob                (model, noise_level, sample, class_name) float32
+                        Mean predictive probability. For Bayesian models this is
+                        the mean over MC passes; for normal models it is the
+                        single softmax output.
+    pred                (model, noise_level, sample) int8
+                        argmax over class_name of ``prob``.
+    predictive_entropy  (model, noise_level, sample) float32
+                        Total/predictive uncertainty: entropy of the mean
+                        predictive distribution (bayesian_torch.predictive_entropy).
+    mutual_information  (model, noise_level, sample) float32
+                        Epistemic/model uncertainty (bayesian_torch.mutual_information).
+                        Identically ~0 for deterministic (K=1) models.
+    correct             (model, noise_level, sample) int8 (0/1)
+                        Whether ``pred`` matches the true class.
+    mc_prob             (model, noise_level, sample, class_name, mc_pass) float32
+                        OPTIONAL raw per-draw probabilities, only if retained.
+
+Coordinates
+    model_kind   (model)   "normal" | "bayesian"
+    image_id     (sample)  ADNI Image Data ID (join key to clinical data)
+    ptid         (sample)  patient id (enables patient-level analysis)
+    true_class   (sample)  ground-truth class label, one of class_name
+    noise_level  (noise_level) float sigma values
+    class_name   (class_name)  ["AD", "NL"]
+
+Attributes
+    schema_version, created (ISO-8601 UTC), seed, git_commit, n_mc,
+    config (TOML string snapshot)
+"""
+
+from __future__ import annotations
+
+import datetime as _dt
+import subprocess
+from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
+
+import numpy as np
+import xarray as xr
+
+# bayesian-torch's own uncertainty utilities, so definitions match the library
+# the Bayesian models were trained with (operate on numpy mc_preds).
+from bayesian_torch.utils.util import mutual_information as _bt_mutual_information
+from bayesian_torch.utils.util import predictive_entropy as _bt_predictive_entropy
+
+SCHEMA_VERSION = "1.0"
+
+CLASS_NAMES: Tuple[str, ...] = ("AD", "NL")
+
+MODEL_KIND_NORMAL = "normal"
+MODEL_KIND_BAYESIAN = "bayesian"
+
+# netCDF engine that supports the full type set (float32, int8, VLEN strings).
+_NETCDF_ENGINE = "netcdf4"
+
+# Encodings applied on write. netCDF has no native bool, so ``correct`` is int8.
+_ENCODINGS: Dict[str, Dict[str, Any]] = {
+    "prob": {"dtype": "float32", "zlib": True, "complevel": 4},
+    "pred": {"dtype": "int8", "zlib": True, "complevel": 4},
+    "predictive_entropy": {"dtype": "float32", "zlib": True, "complevel": 4},
+    "mutual_information": {"dtype": "float32", "zlib": True, "complevel": 4},
+    "correct": {"dtype": "int8", "zlib": True, "complevel": 4},
+    "mc_prob": {"dtype": "float32", "zlib": True, "complevel": 4},
+}
+
+
+# ---------------------------------------------------------------------------
+# Uncertainty computation
+# ---------------------------------------------------------------------------
+def compute_uncertainty(
+    mc_probs: np.ndarray,
+) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+    """Reduce Monte-Carlo predictive probabilities to summary statistics.
+
+    Args:
+        mc_probs: Array of shape ``(K, S, C)`` -- K Monte-Carlo passes, S
+            samples, C classes. For a deterministic model use ``K == 1``.
+
+    Returns:
+        ``(mean_prob, predictive_entropy, mutual_information)`` with shapes
+        ``(S, C)``, ``(S,)`` and ``(S,)`` respectively. ``mutual_information``
+        is ~0 when ``K == 1``.
+    """
+    mc = np.asarray(mc_probs, dtype=np.float64)
+    if mc.ndim != 3:
+        raise ValueError(
+            f"mc_probs must be (K, S, C); got shape {mc.shape} with ndim {mc.ndim}."
+        )
+
+    mean_prob = mc.mean(axis=0)
+    pred_entropy = _bt_predictive_entropy(mc)
+    # mutual_information == predictive_entropy - mean(per-pass entropy). With a
+    # single pass the two entropy terms are identical, so this is exactly 0.
+    mut_info = _bt_mutual_information(mc)
+
+    return (
+        mean_prob.astype(np.float32),
+        np.asarray(pred_entropy, dtype=np.float32),
+        np.asarray(mut_info, dtype=np.float32),
+    )
+
+
+# ---------------------------------------------------------------------------
+# Dataset construction
+# ---------------------------------------------------------------------------
+def build_evaluation_dataset(
+    *,
+    prob: np.ndarray,
+    predictive_entropy: np.ndarray,
+    mutual_information: np.ndarray,
+    model_kinds: Sequence[str],
+    noise_levels: Sequence[float],
+    image_ids: Sequence[int],
+    ptids: Sequence[str],
+    true_classes: Sequence[str],
+    mc_prob: Optional[np.ndarray] = None,
+    attrs: Optional[Mapping[str, Any]] = None,
+) -> xr.Dataset:
+    """Assemble a schema-compliant ``xarray.Dataset``.
+
+    Args:
+        prob: ``(M, L, S, C)`` mean predictive probabilities.
+        predictive_entropy: ``(M, L, S)`` total uncertainty.
+        mutual_information: ``(M, L, S)`` model uncertainty.
+        model_kinds: length-M sequence of "normal"/"bayesian".
+        noise_levels: length-L sequence of sigma values (include 0.0 baseline).
+        image_ids: length-S sequence of ADNI Image Data IDs.
+        ptids: length-S sequence of patient ids.
+        true_classes: length-S ground-truth labels drawn from ``CLASS_NAMES``.
+        mc_prob: optional ``(M, L, S, C, K)`` raw MC draws.
+        attrs: optional extra dataset attributes (merged over the defaults).
+
+    Returns:
+        A validated ``xarray.Dataset`` following the module schema.
+    """
+    prob = np.asarray(prob, dtype=np.float32)
+    predictive_entropy = np.asarray(predictive_entropy, dtype=np.float32)
+    mutual_information = np.asarray(mutual_information, dtype=np.float32)
+
+    m, ell, s, c = _validate_shapes(
+        prob,
+        predictive_entropy,
+        mutual_information,
+        model_kinds,
+        noise_levels,
+        image_ids,
+        ptids,
+        true_classes,
+    )
+
+    # Derived variables: predicted class index and correctness.
+    pred = prob.argmax(axis=-1).astype(np.int8)  # (M, L, S)
+    class_index = {name: i for i, name in enumerate(CLASS_NAMES)}
+    try:
+        true_idx = np.array([class_index[t] for t in true_classes], dtype=np.int8)
+    except KeyError as exc:
+        raise ValueError(
+            f"true_classes contains a label not in CLASS_NAMES {CLASS_NAMES}: {exc}"
+        ) from None
+    # Broadcast true index (S,) against pred (M, L, S).
+    correct = (pred == true_idx[None, None, :]).astype(np.int8)
+
+    data_vars: Dict[str, Any] = {
+        "prob": (("model", "noise_level", "sample", "class_name"), prob),
+        "pred": (("model", "noise_level", "sample"), pred),
+        "predictive_entropy": (
+            ("model", "noise_level", "sample"),
+            predictive_entropy,
+        ),
+        "mutual_information": (
+            ("model", "noise_level", "sample"),
+            mutual_information,
+        ),
+        "correct": (("model", "noise_level", "sample"), correct),
+    }
+
+    if mc_prob is not None:
+        mc_prob = np.asarray(mc_prob, dtype=np.float32)
+        if mc_prob.ndim != 5 or mc_prob.shape[:4] != (m, ell, s, c):
+            raise ValueError(
+                f"mc_prob must be (M, L, S, C, K)=({m}, {ell}, {s}, {c}, K); "
+                f"got {mc_prob.shape}."
+            )
+        data_vars["mc_prob"] = (
+            ("model", "noise_level", "sample", "class_name", "mc_pass"),
+            mc_prob,
+        )
+
+    coords: Dict[str, Any] = {
+        "model": np.arange(m, dtype=np.int32),
+        "model_kind": ("model", np.asarray(list(model_kinds), dtype=object)),
+        "noise_level": np.asarray(list(noise_levels), dtype=np.float32),
+        "sample": np.arange(s, dtype=np.int32),
+        "image_id": ("sample", np.asarray(list(image_ids), dtype=np.int64)),
+        "ptid": ("sample", np.asarray(list(ptids), dtype=object)),
+        "true_class": ("sample", np.asarray(list(true_classes), dtype=object)),
+        "class_name": np.asarray(CLASS_NAMES, dtype=object),
+    }
+
+    ds = xr.Dataset(data_vars=data_vars, coords=coords)
+    ds.attrs.update(_default_attrs())
+    if attrs:
+        ds.attrs.update(dict(attrs))
+    return ds
+
+
+def _validate_shapes(
+    prob: np.ndarray,
+    predictive_entropy: np.ndarray,
+    mutual_information: np.ndarray,
+    model_kinds: Sequence[str],
+    noise_levels: Sequence[float],
+    image_ids: Sequence[int],
+    ptids: Sequence[str],
+    true_classes: Sequence[str],
+) -> Tuple[int, int, int, int]:
+    if prob.ndim != 4:
+        raise ValueError(f"prob must be (M, L, S, C); got shape {prob.shape}.")
+    m, ell, s, c = prob.shape
+
+    if c != len(CLASS_NAMES):
+        raise ValueError(
+            f"prob's class axis has length {c}, expected {len(CLASS_NAMES)} "
+            f"for CLASS_NAMES {CLASS_NAMES}."
+        )
+    for name, arr in (
+        ("predictive_entropy", predictive_entropy),
+        ("mutual_information", mutual_information),
+    ):
+        if arr.shape != (m, ell, s):
+            raise ValueError(
+                f"{name} must be (M, L, S)=({m}, {ell}, {s}); got {arr.shape}."
+            )
+    for name, seq, expected in (
+        ("model_kinds", model_kinds, m),
+        ("noise_levels", noise_levels, ell),
+        ("image_ids", image_ids, s),
+        ("ptids", ptids, s),
+        ("true_classes", true_classes, s),
+    ):
+        if len(seq) != expected:
+            raise ValueError(
+                f"{name} must have length {expected}; got {len(seq)}."
+            )
+    unknown_kinds = set(model_kinds) - {MODEL_KIND_NORMAL, MODEL_KIND_BAYESIAN}
+    if unknown_kinds:
+        raise ValueError(
+            f"model_kinds contains unknown values {unknown_kinds}; "
+            f"expected only {MODEL_KIND_NORMAL!r}/{MODEL_KIND_BAYESIAN!r}."
+        )
+    return m, ell, s, c
+
+
+def _default_attrs() -> Dict[str, Any]:
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "created": _dt.datetime.now(_dt.timezone.utc).isoformat(),
+        "git_commit": _git_commit(),
+    }
+
+
+def _git_commit() -> str:
+    try:
+        out = subprocess.run(
+            ["git", "rev-parse", "HEAD"],
+            capture_output=True,
+            text=True,
+            timeout=5,
+        )
+        return out.stdout.strip() if out.returncode == 0 else "unknown"
+    except Exception:
+        return "unknown"
+
+
+# ---------------------------------------------------------------------------
+# I/O
+# ---------------------------------------------------------------------------
+def save_evaluation(ds: xr.Dataset, path: str) -> None:
+    """Write an evaluation dataset to netCDF using the locked-down encodings."""
+    encoding = {name: _ENCODINGS[name] for name in ds.data_vars if name in _ENCODINGS}
+    ds.to_netcdf(path, engine=_NETCDF_ENGINE, encoding=encoding)
+
+
+def load_evaluation(path: str) -> xr.Dataset:
+    """Load an evaluation dataset previously written by :func:`save_evaluation`."""
+    return xr.open_dataset(path, engine=_NETCDF_ENGINE)
+
+
+# ---------------------------------------------------------------------------
+# Incremental builder
+# ---------------------------------------------------------------------------
+class EvaluationAccumulator:
+    """Collect per-(model, noise_level) results, then finalize to a Dataset.
+
+    This is the intended entry point for the (not-yet-implemented) evaluation
+    tasks. Sample-axis metadata (image ids, ptids, true classes) is fixed by the
+    non-shuffled test loader and supplied once; each ``add`` call contributes one
+    model's Monte-Carlo probabilities at one noise level.
+
+    Example (pseudocode for a future evaluate task)::
+
+        acc = EvaluationAccumulator(image_ids, ptids, true_classes,
+                                    noise_levels=[0.0, 0.05])
+        for m, model in enumerate(models):
+            for sigma in [0.0, 0.05]:
+                mc = run_mc_passes(model, loader, sigma, k=K)  # (K, S, C)
+                acc.add(model_index=m, model_kind="bayesian",
+                        noise_level=sigma, mc_probs=mc)
+        ds = acc.to_dataset(attrs={"seed": seed, "n_mc": K})
+        save_evaluation(ds, out_path)
+    """
+
+    def __init__(
+        self,
+        image_ids: Sequence[int],
+        ptids: Sequence[str],
+        true_classes: Sequence[str],
+        noise_levels: Sequence[float],
+    ) -> None:
+        self._image_ids = list(image_ids)
+        self._ptids = list(ptids)
+        self._true_classes = list(true_classes)
+        self._noise_levels = list(noise_levels)
+        self._s = len(self._image_ids)
+
+        if not (len(self._ptids) == len(self._true_classes) == self._s):
+            raise ValueError(
+                "image_ids, ptids, and true_classes must all have the same length."
+            )
+        self._noise_index = {round(float(n), 12): i for i, n in enumerate(self._noise_levels)}
+
+        # keyed by model index -> dict with kind and per-noise arrays
+        self._models: Dict[int, Dict[str, Any]] = {}
+
+    def add(
+        self,
+        *,
+        model_index: int,
+        model_kind: str,
+        noise_level: float,
+        mc_probs: np.ndarray,
+    ) -> None:
+        """Add one model's MC probabilities ``(K, S, C)`` at one noise level."""
+        key = round(float(noise_level), 12)
+        if key not in self._noise_index:
+            raise ValueError(
+                f"noise_level {noise_level} not in declared levels {self._noise_levels}."
+            )
+        mean_prob, pred_ent, mut_info = compute_uncertainty(mc_probs)
+        if mean_prob.shape[0] != self._s:
+            raise ValueError(
+                f"mc_probs sample axis {mean_prob.shape[0]} != declared S {self._s}."
+            )
+
+        entry = self._models.setdefault(
+            model_index,
+            {
+                "kind": model_kind,
+                "prob": {},
+                "pe": {},
+                "mi": {},
+            },
+        )
+        if entry["kind"] != model_kind:
+            raise ValueError(
+                f"model_index {model_index} already registered as {entry['kind']!r}, "
+                f"got {model_kind!r}."
+            )
+        entry["prob"][key] = mean_prob
+        entry["pe"][key] = pred_ent
+        entry["mi"][key] = mut_info
+
+    def to_dataset(self, attrs: Optional[Mapping[str, Any]] = None) -> xr.Dataset:
+        """Stack all collected results into a schema-compliant Dataset."""
+        if not self._models:
+            raise ValueError("No results were added to the accumulator.")
+
+        model_indices = sorted(self._models)
+        m = len(model_indices)
+        ell = len(self._noise_levels)
+        c = len(CLASS_NAMES)
+
+        prob = np.zeros((m, ell, self._s, c), dtype=np.float32)
+        pe = np.zeros((m, ell, self._s), dtype=np.float32)
+        mi = np.zeros((m, ell, self._s), dtype=np.float32)
+        model_kinds: List[str] = []
+
+        for mi_idx, model_index in enumerate(model_indices):
+            entry = self._models[model_index]
+            model_kinds.append(entry["kind"])
+            for key, li in self._noise_index.items():
+                if key not in entry["prob"]:
+                    raise ValueError(
+                        f"model_index {model_index} is missing noise level {key}."
+                    )
+                prob[mi_idx, li] = entry["prob"][key]
+                pe[mi_idx, li] = entry["pe"][key]
+                mi[mi_idx, li] = entry["mi"][key]
+
+        return build_evaluation_dataset(
+            prob=prob,
+            predictive_entropy=pe,
+            mutual_information=mi,
+            model_kinds=model_kinds,
+            noise_levels=self._noise_levels,
+            image_ids=self._image_ids,
+            ptids=self._ptids,
+            true_classes=self._true_classes,
+            attrs=attrs,
+        )

+ 78 - 0
util/seeding.py

@@ -0,0 +1,78 @@
+"""Centralized seeding helpers for reproducible training and evaluation.
+
+Reproducibility matters here because the whole point of the harness is to
+compare ensemble members and quantify uncertainty. If weight initialization,
+dropout, dataloader shuffling, and BNN sampling are not seeded, "ensemble
+diversity" is accidental and results are not reproducible across runs.
+"""
+
+import os
+import random
+from typing import Optional
+
+import numpy as np
+import torch
+
+# Numpy requires seeds in the uint32 range; torch/python are more permissive,
+# so we clamp everything to this range for consistency.
+_UINT32 = 2**32
+
+
+def seed_everything(seed: int, deterministic: bool = False) -> int:
+    """Seed all relevant RNGs (python, numpy, torch, CUDA).
+
+    Args:
+        seed: The master seed. Clamped into the uint32 range.
+        deterministic: If True, force cuDNN into deterministic mode. This makes
+            convolutions reproducible at some throughput cost. Leave False for
+            training speed; set True when exact reproduction is required.
+
+    Returns:
+        The (clamped) seed actually applied, for logging.
+    """
+    seed = int(seed) % _UINT32
+
+    os.environ["PYTHONHASHSEED"] = str(seed)
+    random.seed(seed)
+    np.random.seed(seed)
+    torch.manual_seed(seed)
+    if torch.cuda.is_available():
+        torch.cuda.manual_seed_all(seed)
+
+    if deterministic:
+        torch.backends.cudnn.deterministic = True
+        torch.backends.cudnn.benchmark = False
+        # Opt into deterministic algorithms where available; warn_only so a
+        # missing deterministic kernel degrades gracefully instead of crashing.
+        torch.use_deterministic_algorithms(True, warn_only=True)
+
+    return seed
+
+
+def derive_seed(base_seed: int, index: int, stream: int = 0) -> int:
+    """Derive a stable, distinct seed for ensemble member ``index``.
+
+    ``stream`` separates otherwise-colliding families (e.g. normal vs. Bayesian
+    ensembles) so that normal-member-0 and bayesian-member-0 do not share an
+    identical RNG stream.
+
+    Args:
+        base_seed: The experiment master seed.
+        index: The ensemble member index (0-based).
+        stream: An offset identifying the RNG family.
+
+    Returns:
+        A seed in the uint32 range, deterministic in all three arguments.
+    """
+    return (int(base_seed) + stream * 100_000 + int(index)) % _UINT32
+
+
+def torch_generator(seed: Optional[int]) -> Optional[torch.Generator]:
+    """Return a seeded ``torch.Generator`` for DataLoader shuffling, or None.
+
+    Giving the training DataLoader its own generator makes the shuffle order a
+    pure function of ``seed`` rather than of global RNG state mutated elsewhere.
+    """
+    if seed is None:
+        return None
+    return torch.Generator().manual_seed(int(seed) % _UINT32)