Source code for pycsamt.ai.inversion.ensemble

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
EnsembleInverter — deep ensemble for uncertainty-aware EM inversion.

Trains :math:`M` independent copies of a base :class:`EMInverter1D`
estimator with different random seeds.  Epistemic (model) uncertainty
is estimated from the inter-model variance.  Aleatoric uncertainty is
not captured here; use MC-Dropout variants for that.

Uncertainty estimation
----------------------
For a test sample :math:`\\mathbf{x}`, each ensemble member
:math:`f_m` predicts :math:`\\hat{\\mathbf{y}}_m`.  The ensemble
mean and variance are:

.. math::

    \\bar{\\mathbf{y}} &= \\frac{1}{M} \\sum_{m=1}^{M} \\hat{\\mathbf{y}}_m

    \\sigma^2 &= \\frac{1}{M-1} \\sum_{m=1}^{M}
               (\\hat{\\mathbf{y}}_m - \\bar{\\mathbf{y}})^2

The ensemble RMSE (expected calibration) is typically lower than any
single member; :math:`\\sigma` provides a calibrated uncertainty
estimate for downstream probabilistic interpretation.

References
----------
Lakshminarayanan B. et al. (2017) *NeurIPS* — Simple and Scalable
Predictive Uncertainty Estimation using Deep Ensembles.
"""

from __future__ import annotations

import copy
from collections.abc import Sequence
from pathlib import Path
from typing import (
    Any,
)

import numpy as np

__all__ = ["EnsembleInverter"]


# lazy import so calibration module is not pulled in unless used
def _get_calibrators():
    from .calibration import (
        ConformalPredictor,
        PosteriorCalibrator,
    )

    return ConformalPredictor, PosteriorCalibrator


[docs] class EnsembleInverter: """ Deep ensemble of :class:`~pycsamt.ai.inversion.inv1d.EMInverter1D` models. Parameters ---------- base_estimator : EMInverter1D Template estimator (unfitted). Its hyper-parameters are copied for every ensemble member. n_estimators : int, default 5 Number of ensemble members. seeds : list of int or None Per-member random seeds. When ``None``, seeds ``[0, 1, …, n_estimators-1]`` are used. Examples -------- >>> from pycsamt.ai.inversion import EMInverter1D, EnsembleInverter >>> base = EMInverter1D(arch="resnet", n_layers=5) >>> ens = EnsembleInverter(base, n_estimators=5) >>> ens.fit(ds, epochs=50) # doctest: +SKIP EnsembleInverter(n_estimators=5, fitted) >>> mean, std = ens.predict_with_uncertainty(X_test) # doctest: +SKIP """ def __init__( self, base_estimator: Any, n_estimators: int = 5, seeds: Sequence[int] | None = None, ) -> None: self.base_estimator = base_estimator self.n_estimators = int(n_estimators) self.seeds = ( list(seeds) if seeds is not None else list(range(n_estimators)) ) if len(self.seeds) < self.n_estimators: extra = range(len(self.seeds), n_estimators) self.seeds += list(extra) self._members: list[Any] = [] self._is_fitted: bool = False self._conformal: Any | None = None # ConformalPredictor self._posterior_cal: Any | None = None # PosteriorCalibrator # ─── fit ──────────────────────────────────────────────────────────────
[docs] def fit( self, X, y=None, *, epochs: int = 100, batch_size: int = 256, lr: float = 1e-3, patience: int = 20, val_frac: float = 0.1, grad_clip: float | None = 1.0, verbose: bool = True, ) -> EnsembleInverter: """ Train all ensemble members. Parameters ---------- X : ForwardDataset, path, or ndarray Training data (forwarded unchanged to each member's ``fit``). y : ndarray or None epochs, batch_size, lr, patience, val_frac, grad_clip, verbose Forwarded to each member's ``fit`` call; each member receives a unique ``seed``. Returns ------- self """ self._members = [] for i, seed in enumerate(self.seeds[: self.n_estimators]): member = copy.deepcopy(self.base_estimator) if verbose: print( f"\n=== Ensemble member {i + 1}/{self.n_estimators} " f"(seed={seed}) ===" ) member.fit( X, y, epochs=epochs, batch_size=batch_size, lr=lr, patience=patience, val_frac=val_frac, grad_clip=grad_clip, seed=seed, verbose=verbose, ) self._members.append(member) self._is_fitted = True return self
# ─── predict ──────────────────────────────────────────────────────────
[docs] def predict(self, X: np.ndarray, **kwargs) -> np.ndarray: """ Return the mean ensemble prediction. Parameters ---------- X : ndarray (n_samples, n_features) **kwargs : forwarded to each member's ``predict`` Returns ------- y_mean : ndarray (n_samples, n_params) """ preds = self._all_predictions(X, **kwargs) return preds.mean(axis=0)
[docs] def predict_with_uncertainty( self, X: np.ndarray, _use_calibrated: bool = True, **kwargs, ) -> tuple[np.ndarray, np.ndarray]: """ Return (mean, std) ensemble prediction. If :meth:`calibrate` has been called, the returned ``std`` is the *calibrated* standard deviation from the :class:`~pycsamt.ai.inversion.calibration.PosteriorCalibrator` (i.e. the Gaussianization-flow recalibrated sigma). Pass ``_use_calibrated=False`` to retrieve the raw inter-member std. Parameters ---------- X : ndarray (n_samples, n_features) _use_calibrated : bool When ``True`` (default) and a calibrator is attached, return calibrated sigma. Internal flag — not part of the public API. Returns ------- mean : ndarray (n_samples, n_params) std : ndarray (n_samples, n_params) Calibrated or raw inter-member standard deviation. """ preds = self._all_predictions(X, **kwargs) # (M, n, p) mean = preds.mean(axis=0) std = preds.std(axis=0, ddof=min(1, len(preds) - 1)) if _use_calibrated and self._posterior_cal is not None: std = self._posterior_cal.calibrated_std(std) return mean, std
[docs] def predict_quantiles( self, X: np.ndarray, q: Sequence[float] = (0.05, 0.25, 0.5, 0.75, 0.95), **kwargs, ) -> dict[float, np.ndarray]: """ Return per-quantile predictions. Parameters ---------- X : ndarray (n_samples, n_features) q : sequence of float Quantiles in (0, 1). Returns ------- quantiles : dict {q_value: ndarray (n_samples, n_params)} """ preds = self._all_predictions(X, **kwargs) # (M, n, p) return {float(qi): np.quantile(preds, qi, axis=0) for qi in q}
# ─── calibrated uncertainty ───────────────────────────────────────────
[docs] def calibrate( self, X_cal: np.ndarray, y_cal: np.ndarray, *, alpha: float = 0.10, ) -> EnsembleInverter: """ Attach a :class:`~pycsamt.ai.inversion.calibration.ConformalPredictor` and a :class:`~pycsamt.ai.inversion.calibration.PosteriorCalibrator` fitted on a held-out calibration set. After calling this method: * :meth:`predict_intervals` returns conformal prediction bands with guaranteed :math:`\\ge 1-\\alpha` marginal coverage (Vovk et al. 2005). * :meth:`predict_posterior` returns calibrated posterior samples via the Gaussianization normalising flow (Kuleshov et al. 2018). * :meth:`predict_with_uncertainty` returns the calibrated standard deviation instead of the raw inter-member std. Parameters ---------- X_cal : ndarray, shape (n_cal, n_features) Calibration inputs (must not overlap with the training set). y_cal : ndarray, shape (n_cal, n_params) True parameter vectors for the calibration inputs. alpha : float Default significance level for conformal intervals. Returns ------- self """ self._check_fitted() ConformalPredictor, PosteriorCalibrator = _get_calibrators() cp = ConformalPredictor(self, alpha=alpha) cp.calibrate(X_cal, y_cal) self._conformal = cp mean, sigma = self.predict_with_uncertainty( X_cal, _use_calibrated=False ) pc = PosteriorCalibrator() pc.fit(y_cal, mean, sigma) self._posterior_cal = pc return self
[docs] def predict_intervals( self, X: np.ndarray, alpha: float | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Conformal prediction intervals with guaranteed marginal coverage. Requires a prior call to :meth:`calibrate`. Parameters ---------- X : ndarray, shape (n_samples, n_features) alpha : float or None Significance level. Defaults to the value used in :meth:`calibrate`. Returns ------- center : ndarray, shape (n_samples, n_params) lower : ndarray, shape (n_samples, n_params) upper : ndarray, shape (n_samples, n_params) """ if self._conformal is None: raise RuntimeError( "Conformal predictor not attached. Call calibrate() first." ) return self._conformal.predict_intervals(X, alpha=alpha)
[docs] def predict_posterior( self, X: np.ndarray, n_samples: int = 500, rng: np.random.Generator | None = None, ) -> np.ndarray: """ Draw calibrated posterior samples via the Gaussianization normalising flow (Kuleshov et al. 2018). Requires a prior call to :meth:`calibrate`. Parameters ---------- X : ndarray, shape (n_pts, n_features) n_samples : int Number of posterior draws per input point. rng : numpy.random.Generator or None Returns ------- draws : ndarray, shape (n_samples, n_pts, n_params) """ if self._posterior_cal is None: raise RuntimeError( "Posterior calibrator not attached. Call calibrate() first." ) mean, sigma = self.predict_with_uncertainty(X, _use_calibrated=False) return self._posterior_cal.predict_posterior( mean, sigma, n_samples=n_samples, rng=rng )
[docs] def coverage_diagnostics( self, X_test: np.ndarray, y_test: np.ndarray, alphas: Sequence[float] | None = None, ) -> dict[float, float]: """ Per-alpha empirical coverage table (requires prior :meth:`calibrate`). Useful for a reliability diagram: nominal coverage ``1-alpha`` on the x-axis, actual coverage on the y-axis. A perfectly calibrated predictor lies on the diagonal. Parameters ---------- X_test : ndarray y_test : ndarray alphas : sequence of float or None Returns ------- diag : dict {alpha: actual_coverage} """ if self._conformal is None: raise RuntimeError( "Conformal predictor not attached. Call calibrate() first." ) return self._conformal.coverage_diagnostics( X_test, y_test, alphas=alphas )
[docs] def score( self, X: np.ndarray, y: np.ndarray, *, metric: str = "rmse", ) -> float: """ Score the ensemble mean prediction. Parameters ---------- X : ndarray (n_samples, n_features) y : ndarray (n_samples, n_params) metric : {'rmse', 'mae', 'r2', 'relative_rmse'} Returns ------- score : float """ from .._base import _compute_metric y_pred = self.predict(X) return _compute_metric(y, y_pred, metric)
[docs] def coverage( self, X: np.ndarray, y: np.ndarray, *, n_sigma: float = 1.96, ) -> float: """ Fraction of true values within ±n_sigma * std of the ensemble mean. An uncertainty estimate is well-calibrated when ``coverage(1.96) ≈ 0.95``. Parameters ---------- X : ndarray y : ndarray n_sigma : float Returns ------- coverage : float ∈ [0, 1] """ mean, std = self.predict_with_uncertainty(X) within = np.abs(y - mean) <= n_sigma * std mask = np.isfinite(y) & np.isfinite(mean) & np.isfinite(std) if mask.any(): return float(within[mask].mean()) return np.nan
# ─── serialisation ────────────────────────────────────────────────────
[docs] def save(self, path: str | Path) -> None: """ Save all ensemble members. Creates a directory ``<path>/`` containing ``member_00.npz``, ``member_01.npz``, …. Parameters ---------- path : str or Path """ path = Path(path) path.mkdir(parents=True, exist_ok=True) for i, member in enumerate(self._members): member.save(path / f"member_{i:02d}.npz") # Save ensemble metadata np.savez_compressed( path / "_ensemble_meta.npz", n_estimators=np.array([self.n_estimators]), seeds=np.array(self.seeds), )
[docs] @classmethod def load( cls, path: str | Path, base_class: type | None = None, ) -> EnsembleInverter: """ Load a saved ensemble. Parameters ---------- path : str or Path Directory created by :meth:`save`. base_class : type or None Estimator class to use for loading. Defaults to :class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`. Returns ------- EnsembleInverter """ path = Path(path) if base_class is None: from .inv1d import EMInverter1D base_class = EMInverter1D meta = np.load(path / "_ensemble_meta.npz", allow_pickle=True) n = int(meta["n_estimators"][0]) seeds = meta["seeds"].tolist() members = [] for i in range(n): members.append(base_class.load(path / f"member_{i:02d}.npz")) # Reconstruct without fitting dummy = object.__new__(base_class) obj = cls(dummy, n_estimators=n, seeds=seeds) obj._members = members obj._is_fitted = True return obj
# ─── internal ───────────────────────────────────────────────────────── def _check_fitted(self) -> None: if not self._is_fitted or not self._members: raise RuntimeError( "EnsembleInverter is not fitted. Call fit() first." ) def _all_predictions(self, X: np.ndarray, **kwargs) -> np.ndarray: self._check_fitted() preds = np.stack( [m.predict(X, **kwargs) for m in self._members], axis=0 ) # (M, n_samples, n_params) return preds # ─── plotting ─────────────────────────────────────────────────────────
[docs] def plot_uncertainty_profile( self, X: np.ndarray, sample_idx: int = 0, *, y_true: np.ndarray | None = None, n_sigma: float = 1.96, **plot_kwargs, ): """ Plot a 1-D resistivity profile with uncertainty bands. Parameters ---------- X : ndarray (n_samples, n_features) sample_idx : int Which sample to plot. y_true : ndarray (n_params,) or None True model vector for overlay. n_sigma : float Band width in standard deviations. **plot_kwargs Forwarded to :func:`~pycsamt.ai.plot.diagnostics.plot_uncertainty_bands`. Returns ------- fig : Figure """ from ..plot.diagnostics import plot_uncertainty_bands mean, std = self.predict_with_uncertainty(X) m = mean[sample_idx] # (n_params,) s = std[sample_idx] n_layers = self.base_estimator.n_layers rho_m = m[:n_layers] rho_hi = rho_m + n_sigma * s[:n_layers] rho_lo = rho_m - n_sigma * s[:n_layers] # Build depth axis from predicted thicknesses thick = m[n_layers:] depths = np.concatenate([[0.0], np.cumsum(np.maximum(thick, 1.0))]) rho_true_plot = None if y_true is not None: rho_true_plot = np.asarray(y_true)[:n_layers] fig = plot_uncertainty_bands( depths[:n_layers], rho_m, rho_hi, rho_lo, y_true=rho_true_plot, xlabel=r"$\log_{10}(\rho)$ (Ω·m)", ylabel="Depth (m)", title=f"Sample {sample_idx} — ensemble uncertainty", **plot_kwargs, ) return fig
# ─── dunder ─────────────────────────────────────────────────────────── def __len__(self) -> int: return len(self._members) def __getitem__(self, idx: int): return self._members[idx] def __repr__(self) -> str: status = "fitted" if self._is_fitted else "unfitted" return f"EnsembleInverter(n_estimators={self.n_estimators}, {status})"