# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Out-of-distribution checks against the training realization set.
These checks support the "OOD sensitivity" column of the
Uncertainty row of the validation matrix, and the AI-inversion
plan's requirement that predictions outside training support are
rejected or flagged rather than returned as confident maps. Inputs
are feature vectors, e.g. survey/realization descriptors, shaped
``(n_samples, n_features)``; a common source is per-realization
summary statistics of the geological priors in
:mod:`pycsamt.ai.geology`.
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.spatial.distance import cdist
__all__ = [
"OODReport",
"ood_score",
"flag_out_of_distribution",
]
_METHODS = ("mahalanobis", "knn")
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_2d(name: str, value: Any) -> np.ndarray:
"""Return *value* as a finite float ``(n_samples, n_features)``
array.
"""
array = np.asarray(value, dtype=float)
if array.ndim != 2:
raise ValueError(
f"{name} must be a 2-D (n_samples, n_features) array."
)
if array.shape[0] < 1 or array.shape[1] < 1:
raise ValueError(f"{name} must not be empty.")
if not np.all(np.isfinite(array)):
raise ValueError(f"{name} must be finite.")
return array
def _mahalanobis_scores(x: np.ndarray, reference: np.ndarray) -> np.ndarray:
"""Return the Mahalanobis distance of each row of x from the
reference set's mean and covariance.
"""
n_samples, n_features = reference.shape
if n_samples <= n_features:
raise ValueError(
"reference must contain more samples than features "
f"({n_samples} <= {n_features}) to estimate a full-rank "
"covariance for the mahalanobis method."
)
mean = reference.mean(axis=0)
covariance = np.atleast_2d(np.cov(reference, rowvar=False))
try:
inverse_covariance = np.linalg.inv(covariance)
except np.linalg.LinAlgError as error:
raise ValueError(
"reference covariance is singular; the mahalanobis "
"method requires linearly independent features."
) from error
difference = x - mean
squared = np.einsum(
"ij,jk,ik->i", difference, inverse_covariance, difference
)
return np.sqrt(np.clip(squared, 0.0, None))
def _knn_scores(x: np.ndarray, reference: np.ndarray, k: int) -> np.ndarray:
"""Return each row of x's distance to its k-th nearest neighbour
in reference.
"""
if (
not isinstance(k, int)
or isinstance(k, bool)
or k < 1
or k > reference.shape[0]
):
raise ValueError(f"k must be an integer in [1, {reference.shape[0]}].")
distances = cdist(x, reference)
return np.sort(distances, axis=1)[:, k - 1]
def _self_knn_scores(reference: np.ndarray, k: int) -> np.ndarray:
"""Return each reference row's distance to its k-th nearest
neighbour among the other reference rows, excluding itself.
"""
upper = reference.shape[0] - 1
if not isinstance(k, int) or isinstance(k, bool) or k < 1 or k > upper:
raise ValueError(
f"k must be an integer in [1, {upper}] for "
"self-referential k-NN scoring."
)
distances = cdist(reference, reference)
np.fill_diagonal(distances, np.inf)
return np.sort(distances, axis=1)[:, k - 1]
[docs]
def ood_score(
x: Any,
reference: Any,
*,
method: str = "mahalanobis",
k: int = 5,
) -> np.ndarray:
"""Score how far new inputs fall from the training distribution.
Parameters
----------
x : array-like, shape (n_samples, n_features)
Inputs to score, e.g. new survey feature vectors.
reference : array-like, shape (n_reference, n_features)
Training-set feature vectors defining the support region.
method : {"mahalanobis", "knn"}, default="mahalanobis"
``"mahalanobis"`` measures deviation from the reference
mean/covariance and requires ``n_reference > n_features``.
``"knn"`` measures Euclidean distance to the ``k``-th
nearest reference point and makes no distributional
assumption. If ``x`` shares exact points with ``reference``,
their k-NN distance to those points is zero; use
:func:`flag_out_of_distribution` for a leave-one-out
self-score instead of passing ``reference`` as ``x`` here.
k : int, default=5
Neighbour rank used by ``method="knn"``. Ignored otherwise.
Returns
-------
ndarray, shape (n_samples,)
Higher values indicate inputs farther from the training
support.
Examples
--------
>>> import numpy as np
>>> reference = np.array(
... [
... [0.0, 0.0],
... [1.0, 0.0],
... [0.0, 1.0],
... [-1.0, 0.0],
... [0.0, -1.0],
... [0.5, 0.5],
... ]
... )
>>> x = np.array([[0.0, 0.0], [50.0, 50.0]])
>>> scores = ood_score(x, reference, method="knn", k=2)
>>> scores[0] < scores[1]
True
"""
if method not in _METHODS:
raise ValueError(f"method must be one of {_METHODS}.")
x_arr = _validate_2d("x", x)
ref_arr = _validate_2d("reference", reference)
if x_arr.shape[1] != ref_arr.shape[1]:
raise ValueError(
"x and reference must share the same number of features."
)
if method == "mahalanobis":
return _mahalanobis_scores(x_arr, ref_arr)
return _knn_scores(x_arr, ref_arr, k)
[docs]
@dataclass(frozen=True)
class OODReport:
"""Immutable out-of-distribution screening result.
Parameters
----------
scores : ndarray
Per-sample distance from :func:`ood_score`.
threshold : float
Score above which a sample is flagged.
flagged : ndarray of bool
Whether each sample exceeds ``threshold``, same shape as
``scores``.
method : {"mahalanobis", "knn"}
Distance measure used to compute ``scores``.
quantile : float or None
Quantile of the reference self-scores used to derive
``threshold``, or ``None`` when an explicit ``threshold``
was supplied instead.
n_reference, n_features : int
Size of the reference set used to define support.
Examples
--------
>>> import numpy as np
>>> reference = np.array(
... [
... [0.0, 0.0],
... [1.0, 0.0],
... [0.0, 1.0],
... [-1.0, 0.0],
... [0.0, -1.0],
... [0.5, 0.5],
... ]
... )
>>> x = np.array([[0.0, 0.0], [50.0, 50.0]])
>>> report = flag_out_of_distribution(
... x, reference, method="knn", k=2, quantile=0.5
... )
>>> report.flagged.tolist()
[False, True]
"""
scores: np.ndarray
threshold: float
flagged: np.ndarray
method: str
quantile: float | None
n_reference: int
n_features: int
def __post_init__(self) -> None:
if self.method not in _METHODS:
raise ValueError(f"method must be one of {_METHODS}.")
scores = np.asarray(self.scores, dtype=float)
if scores.ndim != 1 or scores.size == 0:
raise ValueError("scores must be a non-empty 1-D array.")
flagged = np.asarray(self.flagged, dtype=bool)
if flagged.shape != scores.shape:
raise ValueError("flagged must have the same shape as scores.")
threshold = float(self.threshold)
if not np.isfinite(threshold):
raise ValueError("threshold must be finite.")
quantile = None if self.quantile is None else float(self.quantile)
if quantile is not None and not (0.0 < quantile < 1.0):
raise ValueError("quantile must be within (0, 1).")
if self.n_reference < 1 or self.n_features < 1:
raise ValueError("n_reference and n_features must be positive.")
object.__setattr__(self, "scores", _readonly(scores))
object.__setattr__(self, "flagged", _readonly(flagged, bool))
object.__setattr__(self, "threshold", threshold)
object.__setattr__(self, "quantile", quantile)
object.__setattr__(self, "n_reference", int(self.n_reference))
object.__setattr__(self, "n_features", int(self.n_features))
[docs]
@property
def fraction_flagged(self) -> float:
"""Return the fraction of scored samples flagged as OOD.
Examples
--------
>>> import numpy as np
>>> report = OODReport(
... scores=np.array([0.1, 5.0]),
... threshold=1.0,
... flagged=np.array([False, True]),
... method="knn",
... quantile=None,
... n_reference=10,
... n_features=2,
... )
>>> report.fraction_flagged
0.5
"""
return float(np.mean(self.flagged))
[docs]
def flag_out_of_distribution(
x: Any,
reference: Any,
*,
method: str = "mahalanobis",
k: int = 5,
quantile: float = 0.99,
threshold: float | None = None,
) -> OODReport:
"""Score inputs and flag those outside the training support.
When ``threshold`` is not supplied, it is derived as the
requested ``quantile`` of the reference set's own leave-one-out
(``"knn"``) or full-sample (``"mahalanobis"``) self-scores, i.e.
"how unusual is a typical reference point".
Parameters
----------
x : array-like, shape (n_samples, n_features)
Inputs to score.
reference : array-like, shape (n_reference, n_features)
Training-set feature vectors defining the support region.
method : {"mahalanobis", "knn"}, default="mahalanobis"
Distance measure, as in :func:`ood_score`.
k : int, default=5
Neighbour rank used by ``method="knn"``. Ignored otherwise.
quantile : float, default=0.99
Quantile in ``(0, 1)`` of the reference self-scores used to
derive ``threshold``. Ignored when ``threshold`` is given.
threshold : float or None, optional
Explicit score threshold. Overrides ``quantile`` when given.
Returns
-------
OODReport
Scores, threshold, and per-sample OOD flags.
Examples
--------
>>> import numpy as np
>>> reference = np.array(
... [
... [0.0, 0.0],
... [1.0, 0.0],
... [0.0, 1.0],
... [-1.0, 0.0],
... [0.0, -1.0],
... [0.5, 0.5],
... ]
... )
>>> x = np.array([[0.0, 0.0], [50.0, 50.0]])
>>> report = flag_out_of_distribution(x, reference, k=1)
>>> report.method, report.n_reference
('mahalanobis', 6)
"""
if method not in _METHODS:
raise ValueError(f"method must be one of {_METHODS}.")
x_arr = _validate_2d("x", x)
ref_arr = _validate_2d("reference", reference)
if x_arr.shape[1] != ref_arr.shape[1]:
raise ValueError(
"x and reference must share the same number of features."
)
scores = (
_mahalanobis_scores(x_arr, ref_arr)
if method == "mahalanobis"
else _knn_scores(x_arr, ref_arr, k)
)
if threshold is not None:
resolved_threshold = float(threshold)
if not np.isfinite(resolved_threshold):
raise ValueError("threshold must be finite.")
resolved_quantile = None
else:
if not (0.0 < quantile < 1.0):
raise ValueError("quantile must be within (0, 1).")
self_scores = (
_mahalanobis_scores(ref_arr, ref_arr)
if method == "mahalanobis"
else _self_knn_scores(ref_arr, k)
)
resolved_threshold = float(np.quantile(self_scores, quantile))
resolved_quantile = float(quantile)
return OODReport(
scores=scores,
threshold=resolved_threshold,
flagged=scores > resolved_threshold,
method=method,
quantile=resolved_quantile,
n_reference=ref_arr.shape[0],
n_features=ref_arr.shape[1],
)