Source code for pycsamt.ai.validation.recovery

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Synthetic-recovery diagnostics for known-truth geological grids.

These diagnostics implement the M0 baseline metrics named in the
AI-inversion plan: log-resistivity MAE/RMSE, structural similarity,
and recovery broken down by depth.  Inputs are predicted and true
model arrays sharing one canonical geological-grid shape, e.g.
``(z, x)`` or ``(z, y, x)`` from
:class:`~pycsamt.ai.geology.GeologyGrid`.  Recovery diagnostics are
only meaningful when the true model is known, i.e. on synthetic
data; field-survey validation instead relies on
:mod:`~pycsamt.ai.losses.response` residuals.

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.ndimage import uniform_filter

from ..losses.model import model_l1_loss, model_l2_loss

__all__ = [
    "RecoveryReport",
    "recovery_report",
    "structural_similarity",
    "depth_profile_rmse",
    "depth_profile_mae",
]


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_pair(y_pred: Any, y_true: Any) -> tuple[np.ndarray, np.ndarray]:
    """Return y_pred/y_true as float arrays of one 2-D/3-D shape."""
    pred = np.asarray(y_pred, dtype=float)
    true = np.asarray(y_true, dtype=float)
    if pred.shape != true.shape:
        raise ValueError("y_pred and y_true must share one shape.")
    if pred.ndim not in (2, 3):
        raise ValueError("y_pred and y_true must be a 2-D or 3-D grid.")
    if pred.size == 0:
        raise ValueError("y_pred and y_true must not be empty.")
    return pred, true


def _validate_valid(
    valid: Any | None, shape: tuple[int, ...]
) -> np.ndarray | None:
    """Return an optional boolean mask validated against *shape*."""
    if valid is None:
        return None
    array = np.asarray(valid, dtype=bool)
    if array.shape != shape:
        raise ValueError("valid must have the same shape as y_pred.")
    return array


def _depth_profile(
    pred: np.ndarray,
    true: np.ndarray,
    *,
    axis: int,
    mask: np.ndarray,
    kind: str,
) -> np.ndarray:
    """Return per-layer masked RMSE (``kind="l2"``) or MAE along axis."""
    n_layers = pred.shape[axis]
    out = np.full(n_layers, np.nan)
    for layer in range(n_layers):
        index = [slice(None)] * pred.ndim
        index[axis] = layer
        index = tuple(index)
        layer_mask = mask[index]
        if not np.any(layer_mask):
            continue
        difference = pred[index][layer_mask] - true[index][layer_mask]
        if kind == "l1":
            out[layer] = float(np.mean(np.abs(difference)))
        else:
            out[layer] = float(np.sqrt(np.mean(np.square(difference))))
    return out


[docs] @dataclass(frozen=True) class RecoveryReport: """Immutable synthetic-recovery diagnostics for one grid pair. Parameters ---------- rmse, mae : float Global masked root-mean-square and mean-absolute error. r2 : float Coefficient of determination. ``nan`` when the true values are numerically constant, making R² undefined. ssim : float or None Structural similarity index (Wang et al., 2004), or ``None`` when it was not requested or not computable, e.g. the grid is partially masked or smaller than the requested window. depth_rmse, depth_mae : ndarray Per-layer RMSE/MAE along the requested depth axis. n_valid : int Number of cells included after masking. shape : tuple of int Shape of the compared grids. Examples -------- >>> import numpy as np >>> pred = np.array([[1.0, 2.0], [3.0, 4.0]]) >>> true = np.array([[1.0, 2.0], [3.0, 6.0]]) >>> report = recovery_report(pred, true, compute_ssim=False) >>> report.n_valid, report.shape (4, (2, 2)) """ rmse: float mae: float r2: float ssim: float | None depth_rmse: np.ndarray depth_mae: np.ndarray n_valid: int shape: tuple[int, ...] def __post_init__(self) -> None: object.__setattr__(self, "rmse", float(self.rmse)) object.__setattr__(self, "mae", float(self.mae)) object.__setattr__(self, "r2", float(self.r2)) object.__setattr__( self, "ssim", None if self.ssim is None else float(self.ssim) ) object.__setattr__(self, "n_valid", int(self.n_valid)) object.__setattr__( self, "shape", tuple(int(size) for size in self.shape) ) object.__setattr__( self, "depth_rmse", _readonly(self.depth_rmse, float) ) object.__setattr__(self, "depth_mae", _readonly(self.depth_mae, float))
[docs] def structural_similarity( y_pred: Any, y_true: Any, *, window: int = 7, data_range: float | None = None, ) -> float: """Compute the mean structural similarity index (SSIM). Uses the windowed luminance/contrast/structure formulation of Wang et al. (2004) with a uniform (box) window, evaluated on the interior of the grid to avoid boundary-filter artifacts. Parameters ---------- y_pred, y_true : array-like Fully finite 2-D or 3-D grids sharing one shape. SSIM has no defined masking rule, so both must be complete. window : int, default=7 Positive odd window size, no larger than the smallest grid axis. data_range : float or None, optional Dynamic range of the compared values. Defaults to the range spanned by the combined ``y_pred``/``y_true`` values. Returns ------- float Mean SSIM over the interior window positions, at most 1.0 for identical grids. Examples -------- >>> import numpy as np >>> grid = np.arange(64, dtype=float).reshape(8, 8) >>> structural_similarity(grid, grid, window=3) 1.0 """ pred, true = _validate_pair(y_pred, y_true) if not np.all(np.isfinite(pred)) or not np.all(np.isfinite(true)): raise ValueError( "structural_similarity requires fully finite grids; mask " "or exclude cells before calling it." ) if ( not isinstance(window, int) or isinstance(window, bool) or window < 1 or window % 2 == 0 ): raise ValueError("window must be a positive odd integer.") if window > min(pred.shape): raise ValueError( f"window must not exceed the smallest grid axis {min(pred.shape)}." ) span = data_range if span is None: span = float(max(pred.max(), true.max()) - min(pred.min(), true.min())) if not np.isfinite(span) or span <= 0: raise ValueError("data_range must be finite and positive.") mu_x = uniform_filter(pred, size=window) mu_y = uniform_filter(true, size=window) sigma_x2 = uniform_filter(pred * pred, size=window) - mu_x * mu_x sigma_y2 = uniform_filter(true * true, size=window) - mu_y * mu_y sigma_xy = uniform_filter(pred * true, size=window) - mu_x * mu_y c1 = (0.01 * span) ** 2 c2 = (0.03 * span) ** 2 numerator = (2 * mu_x * mu_y + c1) * (2 * sigma_xy + c2) denominator = (mu_x**2 + mu_y**2 + c1) * (sigma_x2 + sigma_y2 + c2) ssim_map = numerator / denominator crop = window // 2 interior = tuple(slice(crop, size - crop) for size in ssim_map.shape) cropped = ssim_map[interior] if cropped.size == 0: raise ValueError("window is too large for this grid shape.") return float(np.mean(cropped))
[docs] def depth_profile_rmse( y_pred: Any, y_true: Any, *, axis: int = 0, valid: Any | None = None, ) -> np.ndarray: """Return per-layer masked RMSE along one grid axis. Parameters ---------- y_pred, y_true : array-like Predicted and true model values sharing one 2-D or 3-D grid shape. axis : int, default=0 Grid axis to break down by, typically depth (``z``). valid : array-like of bool or None, optional Explicit cell mask, combined with finite-value masking of both inputs. Returns ------- ndarray, shape (y_pred.shape[axis],) RMSE for each layer; ``nan`` for a layer with no valid cells. Examples -------- >>> import numpy as np >>> pred = np.array([[1.0, 2.0], [3.0, 4.0]]) >>> true = np.array([[1.0, 2.0], [3.0, 6.0]]) >>> depth_profile_rmse(pred, true).tolist() [0.0, 1.4142135623730951] """ pred, true = _validate_pair(y_pred, y_true) mask = np.isfinite(pred) & np.isfinite(true) supplied = _validate_valid(valid, pred.shape) if supplied is not None: mask &= supplied if axis < 0: axis += pred.ndim if not (0 <= axis < pred.ndim): raise ValueError(f"axis must be in [-{pred.ndim}, {pred.ndim}).") return _depth_profile(pred, true, axis=axis, mask=mask, kind="l2")
[docs] def depth_profile_mae( y_pred: Any, y_true: Any, *, axis: int = 0, valid: Any | None = None, ) -> np.ndarray: """Return per-layer masked MAE along one grid axis. Parameters ---------- y_pred, y_true : array-like Predicted and true model values sharing one 2-D or 3-D grid shape. axis : int, default=0 Grid axis to break down by, typically depth (``z``). valid : array-like of bool or None, optional Explicit cell mask, combined with finite-value masking of both inputs. Returns ------- ndarray, shape (y_pred.shape[axis],) MAE for each layer; ``nan`` for a layer with no valid cells. Examples -------- >>> import numpy as np >>> pred = np.array([[1.0, 2.0], [3.0, 4.0]]) >>> true = np.array([[1.0, 2.0], [3.0, 6.0]]) >>> depth_profile_mae(pred, true).tolist() [0.0, 1.0] """ pred, true = _validate_pair(y_pred, y_true) mask = np.isfinite(pred) & np.isfinite(true) supplied = _validate_valid(valid, pred.shape) if supplied is not None: mask &= supplied if axis < 0: axis += pred.ndim if not (0 <= axis < pred.ndim): raise ValueError(f"axis must be in [-{pred.ndim}, {pred.ndim}).") return _depth_profile(pred, true, axis=axis, mask=mask, kind="l1")
[docs] def recovery_report( y_pred: Any, y_true: Any, *, valid: Any | None = None, depth_axis: int = 0, compute_ssim: bool = True, ssim_window: int = 7, ) -> RecoveryReport: """Summarize synthetic-recovery quality for one grid pair. Parameters ---------- y_pred, y_true : array-like Predicted and true model values sharing one 2-D or 3-D grid shape. valid : array-like of bool or None, optional Explicit cell mask, combined with finite-value masking of both inputs. depth_axis : int, default=0 Grid axis passed to :func:`depth_profile_rmse` and :func:`depth_profile_mae`. compute_ssim : bool, default=True Attempt :func:`structural_similarity`. Skipped (``ssim`` is ``None``) when the grid is partially masked or smaller than ``ssim_window``, since SSIM has no defined masking rule. ssim_window : int, default=7 Window forwarded to :func:`structural_similarity`. Returns ------- RecoveryReport Combined recovery diagnostics. Raises ------ ValueError If shapes mismatch, inputs are empty, or no cell is valid. Examples -------- >>> import numpy as np >>> pred = np.array([[1.0, 2.0], [3.0, 4.0]]) >>> true = np.array([[1.0, 2.0], [3.0, 6.0]]) >>> report = recovery_report(pred, true, compute_ssim=False) >>> round(report.rmse, 6), round(report.mae, 6) (1.0, 0.5) >>> round(report.r2, 6) 0.714286 """ pred, true = _validate_pair(y_pred, y_true) supplied = _validate_valid(valid, pred.shape) mask = np.isfinite(pred) & np.isfinite(true) if supplied is not None: mask &= supplied if not np.any(mask): raise ValueError("no valid cell to compare.") rmse = float(np.sqrt(model_l2_loss(pred, true, valid=mask).value)) mae = float(model_l1_loss(pred, true, valid=mask).value) yt = true[mask] yp = pred[mask] ss_res = float(np.sum(np.square(yt - yp))) ss_tot = float(np.sum(np.square(yt - np.mean(yt)))) r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan") ssim = None if compute_ssim and np.all(mask) and min(pred.shape) >= ssim_window: ssim = structural_similarity(pred, true, window=ssim_window) return RecoveryReport( rmse=rmse, mae=mae, r2=r2, ssim=ssim, depth_rmse=_depth_profile( pred, true, axis=depth_axis, mask=mask, kind="l2" ), depth_mae=_depth_profile( pred, true, axis=depth_axis, mask=mask, kind="l1" ), n_valid=int(np.count_nonzero(mask)), shape=pred.shape, )