Source code for pycsamt.ai.validation.calibration

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Uncertainty-calibration diagnostics for predictive intervals.

These diagnostics support the Uncertainty row of the validation
matrix in the AI-inversion plan: "calibration, coverage, sharpness,
OOD sensitivity".  Predictive distributions are assumed Gaussian,
i.e. a cell's predictive interval at nominal level ``p`` is
``mean +/- z(p) * std`` with ``z(p)`` the two-sided normal quantile;
this matches how ``gaussian_nll_loss`` in
:mod:`pycsamt.ai.losses.uncertainty` parameterizes aleatoric
uncertainty.

All functions operate on plain NumPy arrays so the module stays
importable without an optional deep-learning backend.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import numpy as np
from scipy.stats import norm

from ..losses.uncertainty import UncertaintyLossResult, calibration_loss

__all__ = [
    "ReliabilityCurve",
    "reliability_curve",
    "empirical_coverage",
    "predictive_sharpness",
]

_DEFAULT_LEVELS = (0.5, 0.8, 0.9, 0.95, 0.99)


def _readonly(value: Any, dtype: Any | None = None) -> np.ndarray:
    """Return a read-only copy of *value* as an ndarray."""
    array = np.array(value, dtype=dtype, copy=True)
    array.setflags(write=False)
    return array


def _validate_inputs(
    y_true: Any, y_pred_mean: Any, y_pred_std: Any
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Return y_true/y_pred_mean/y_pred_std as float arrays of one
    non-empty shape.
    """
    true = np.asarray(y_true, dtype=float)
    mean = np.asarray(y_pred_mean, dtype=float)
    std = np.asarray(y_pred_std, dtype=float)
    if true.shape != mean.shape or true.shape != std.shape:
        raise ValueError(
            "y_true, y_pred_mean, and y_pred_std must share one shape."
        )
    if true.size == 0:
        raise ValueError(
            "y_true, y_pred_mean, and y_pred_std must not be empty."
        )
    return true, mean, std


def _validate_levels(levels: Any | None) -> np.ndarray:
    """Return validated nominal confidence levels in ``(0, 1)``."""
    array = np.asarray(
        _DEFAULT_LEVELS if levels is None else levels, dtype=float
    )
    if array.ndim != 1 or array.size == 0:
        raise ValueError("levels must be a non-empty 1-D array.")
    if (
        not np.all(np.isfinite(array))
        or np.any(array <= 0.0)
        or np.any(array >= 1.0)
    ):
        raise ValueError("levels must be finite values within (0, 1).")
    return array


def _coverage_mask(
    true: np.ndarray,
    mean: np.ndarray,
    std: np.ndarray,
    valid: Any | None,
) -> np.ndarray:
    """Combine finite/positive-std masking with an optional mask."""
    mask = (
        np.isfinite(true) & np.isfinite(mean) & np.isfinite(std) & (std > 0.0)
    )
    if valid is not None:
        supplied = np.asarray(valid, dtype=bool)
        if supplied.shape != true.shape:
            raise ValueError("valid must have the same shape as y_true.")
        mask &= supplied
    return mask


[docs] @dataclass(frozen=True) class ReliabilityCurve: """Immutable calibration report for Gaussian predictive intervals. Parameters ---------- levels : ndarray Nominal confidence levels in ``(0, 1)``. coverage : ndarray Empirical coverage at each level, same shape as ``levels``. calibration : UncertaintyLossResult Reduced deviation between ``coverage`` and ``levels``, from :func:`~pycsamt.ai.losses.uncertainty.calibration_loss`. sharpness : float Mean predictive standard deviation over included cells. Lower is sharper (more confident); meaningful only alongside good calibration. n_valid : int Number of cells included after masking. shape : tuple of int Shape of the compared ``y_true`` array. Examples -------- >>> import numpy as np >>> true = np.array([0.0, 0.0, 0.0, 0.0, 10.0]) >>> mean = np.zeros(5) >>> std = np.ones(5) >>> curve = reliability_curve(true, mean, std) >>> curve.n_valid 5 """ levels: np.ndarray coverage: np.ndarray calibration: UncertaintyLossResult sharpness: float n_valid: int shape: tuple[int, ...] def __post_init__(self) -> None: levels = np.asarray(self.levels, dtype=float) coverage = np.asarray(self.coverage, dtype=float) if levels.shape != coverage.shape: raise ValueError("levels and coverage must share one shape.") if not isinstance(self.calibration, UncertaintyLossResult): raise TypeError("calibration must be an UncertaintyLossResult.") object.__setattr__(self, "levels", _readonly(levels)) object.__setattr__(self, "coverage", _readonly(coverage)) object.__setattr__(self, "sharpness", float(self.sharpness)) object.__setattr__(self, "n_valid", int(self.n_valid)) object.__setattr__( self, "shape", tuple(int(size) for size in self.shape) )
[docs] def empirical_coverage( y_true: Any, y_pred_mean: Any, y_pred_std: Any, *, levels: Any | None = None, valid: Any | None = None, ) -> tuple[np.ndarray, np.ndarray, int]: """Compute empirical coverage of Gaussian predictive intervals. Parameters ---------- y_true : array-like True values. y_pred_mean, y_pred_std : array-like Predicted mean and positive standard deviation, same shape as ``y_true``. levels : array-like or None, optional Nominal confidence levels in ``(0, 1)``. Defaults to ``(0.5, 0.8, 0.9, 0.95, 0.99)``. valid : array-like of bool or None, optional Explicit cell mask, combined with finite-value masking of all three inputs and with ``y_pred_std > 0``. Returns ------- levels : ndarray The validated nominal levels. coverage : ndarray, same shape as ``levels`` Fraction of included cells whose true value falls inside the ``mean +/- z(level) * std`` interval. n_valid : int Number of cells included after masking. Examples -------- >>> import numpy as np >>> true = np.array([0.0, 0.0, 0.0, 0.0, 10.0]) >>> mean = np.zeros(5) >>> std = np.ones(5) >>> levels, coverage, n_valid = empirical_coverage( ... true, mean, std, levels=[0.5] ... ) >>> coverage.tolist() [0.8] """ true, mean, std = _validate_inputs(y_true, y_pred_mean, y_pred_std) mask = _coverage_mask(true, mean, std, valid) if not np.any(mask): raise ValueError("no valid cell to evaluate.") levels_arr = _validate_levels(levels) residual = np.abs(true[mask] - mean[mask]) / std[mask] coverage = np.array( [ float(np.mean(residual <= norm.ppf(0.5 + level / 2.0))) for level in levels_arr ] ) return levels_arr, coverage, int(np.count_nonzero(mask))
[docs] def predictive_sharpness( y_pred_std: Any, *, valid: Any | None = None ) -> float: """Return the masked mean predictive standard deviation. Sharpness summarizes how confident a model's predictive distribution is, independent of correctness; it is only a meaningful quality signal alongside good calibration. Parameters ---------- y_pred_std : array-like Predicted positive standard deviation. valid : array-like of bool or None, optional Explicit cell mask, combined with finite-value masking and with ``y_pred_std > 0``. Returns ------- float Mean predictive standard deviation over included cells. Examples -------- >>> import numpy as np >>> predictive_sharpness(np.array([1.0, 2.0, 3.0])) 2.0 """ std = np.asarray(y_pred_std, dtype=float) if std.size == 0: raise ValueError("y_pred_std must not be empty.") mask = np.isfinite(std) & (std > 0.0) if valid is not None: supplied = np.asarray(valid, dtype=bool) if supplied.shape != std.shape: raise ValueError("valid must have the same shape as y_pred_std.") mask &= supplied if not np.any(mask): raise ValueError("no valid cell to evaluate.") return float(np.mean(std[mask]))
[docs] def reliability_curve( y_true: Any, y_pred_mean: Any, y_pred_std: Any, *, levels: Any | None = None, valid: Any | None = None, kind: str = "l2", reduction: str = "mean", ) -> ReliabilityCurve: """Build a full calibration report for Gaussian predictive intervals. Parameters ---------- y_true : array-like True values. y_pred_mean, y_pred_std : array-like Predicted mean and positive standard deviation, same shape as ``y_true``. levels : array-like or None, optional Nominal confidence levels in ``(0, 1)``. Defaults to ``(0.5, 0.8, 0.9, 0.95, 0.99)``. valid : array-like of bool or None, optional Explicit cell mask, combined with finite-value masking of all three inputs and with ``y_pred_std > 0``. kind : {"l1", "l2"}, default="l2" Elementwise penalty forwarded to :func:`~pycsamt.ai.losses.uncertainty.calibration_loss`. reduction : {"mean", "sum"}, default="mean" Reduction forwarded to :func:`~pycsamt.ai.losses.uncertainty.calibration_loss`. Returns ------- ReliabilityCurve Combined coverage, calibration penalty, and sharpness. Examples -------- >>> import numpy as np >>> true = np.array([0.0, 0.0, 0.0, 0.0, 10.0]) >>> mean = np.zeros(5) >>> std = np.ones(5) >>> curve = reliability_curve(true, mean, std, levels=[0.5]) >>> curve.coverage.tolist(), curve.sharpness ([0.8], 1.0) """ true, mean, std = _validate_inputs(y_true, y_pred_mean, y_pred_std) mask = _coverage_mask(true, mean, std, valid) if not np.any(mask): raise ValueError("no valid cell to evaluate.") levels_arr, coverage, n_valid = empirical_coverage( true, mean, std, levels=levels, valid=mask ) sharpness = predictive_sharpness(std, valid=mask) calibration = calibration_loss( coverage, levels_arr, kind=kind, reduction=reduction ) return ReliabilityCurve( levels=levels_arr, coverage=coverage, calibration=calibration, sharpness=sharpness, n_valid=n_valid, shape=true.shape, )