Source code for pycsamt.ai.training.metrics

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
Evaluation metrics for 1-D EM inversion networks.

All functions accept numpy arrays and ignore NaN entries (corresponding
to padding in variable-depth datasets).

Metrics
-------
``rmse``           Root mean square error on log₁₀(ρ).
``mae``            Mean absolute error.
``r2``             Coefficient of determination R².
``relative_rmse``  RMSE normalised by |y_true|.
``depth_rmse``     RMSE weighted by inverse depth (shallower layers
                   are easier to recover and receive less weight).
``layer_rmse``     Per-layer RMSE vector.
"""

from __future__ import annotations

import numpy as np

__all__ = [
    "rmse",
    "mae",
    "r2",
    "relative_rmse",
    "depth_rmse",
    "layer_rmse",
    "masked_mse_loss",
    "summarise",
]


# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────


def _valid(y_true: np.ndarray, y_pred: np.ndarray):
    """Return (yt, yp) with NaN rows removed (any NaN in a row discards it)."""
    mask = np.isfinite(y_true).all(axis=-1) & np.isfinite(y_pred).all(axis=-1)
    return y_true[mask], y_pred[mask]


# ─────────────────────────────────────────────────────────────────────────────
# Numpy metrics
# ─────────────────────────────────────────────────────────────────────────────


[docs] def rmse(y_true: np.ndarray, y_pred: np.ndarray) -> float: """ Root mean square error, ignoring NaN. Operates element-wise on ``y_true`` and ``y_pred``. Both arrays are assumed to be in log₁₀(Ω·m) or normalised space. """ mask = np.isfinite(y_true) & np.isfinite(y_pred) if mask.sum() == 0: return float("nan") return float(np.sqrt(np.mean((y_true[mask] - y_pred[mask]) ** 2)))
[docs] def mae(y_true: np.ndarray, y_pred: np.ndarray) -> float: """Mean absolute error, ignoring NaN.""" mask = np.isfinite(y_true) & np.isfinite(y_pred) if mask.sum() == 0: return float("nan") return float(np.mean(np.abs(y_true[mask] - y_pred[mask])))
[docs] def r2(y_true: np.ndarray, y_pred: np.ndarray) -> float: """Coefficient of determination R², ignoring NaN.""" mask = np.isfinite(y_true) & np.isfinite(y_pred) if mask.sum() == 0: return float("nan") yt, yp = y_true[mask], y_pred[mask] ss_res = np.sum((yt - yp) ** 2) ss_tot = np.sum((yt - np.mean(yt)) ** 2) return float(1.0 - ss_res / (ss_tot + 1e-12))
[docs] def relative_rmse(y_true: np.ndarray, y_pred: np.ndarray) -> float: """ Normalised RMSE: ``sqrt(mean((y_true - y_pred)² / y_true²))``. Useful when comparing models with very different resistivity ranges. """ mask = ( np.isfinite(y_true) & np.isfinite(y_pred) & (np.abs(y_true) > 1e-12) ) if mask.sum() == 0: return float("nan") yt, yp = y_true[mask], y_pred[mask] return float(np.sqrt(np.mean(((yt - yp) / yt) ** 2)))
[docs] def depth_rmse( y_true: np.ndarray, y_pred: np.ndarray, n_layers: int, *, depth_weight: bool = True, ) -> float: """ RMSE on the resistivity sub-vector only (first ``n_layers`` columns), optionally weighted by layer index so that deeper (harder) layers have less influence on the score. Parameters ---------- y_true, y_pred : ndarray, shape (n_samples, n_params) n_layers : int Number of resistivity values in the parameter vector. depth_weight : bool If True, weight by ``1 / (1 + layer_index)``. """ yt = y_true[:, :n_layers] yp = y_pred[:, :n_layers] mask = np.isfinite(yt) & np.isfinite(yp) if mask.sum() == 0: return float("nan") diff_sq = (yt - yp) ** 2 if depth_weight: w = 1.0 / (1.0 + np.arange(n_layers, dtype=float)) w /= w.sum() diff_sq = diff_sq * w[None, :] return float(np.sqrt(np.nanmean(diff_sq)))
[docs] def layer_rmse(y_true: np.ndarray, y_pred: np.ndarray) -> np.ndarray: """ Per-column RMSE vector. Returns ------- rmse_per_col : ndarray, shape (n_params,) RMSE for each parameter column independently. """ n_cols = y_true.shape[1] if y_true.ndim > 1 else 1 out = np.empty(n_cols) for j in range(n_cols): out[j] = rmse(y_true[:, j], y_pred[:, j]) return out
[docs] def summarise( y_true: np.ndarray, y_pred: np.ndarray, n_layers: int | None = None, ) -> dict: """ Compute all scalar metrics and return as a dict. Returns ------- metrics : dict with keys ``'rmse'``, ``'mae'``, ``'r2'``, ``'relative_rmse'``, ``'depth_rmse'`` (if n_layers given). """ d = { "rmse": rmse(y_true, y_pred), "mae": mae(y_true, y_pred), "r2": r2(y_true, y_pred), "relative_rmse": relative_rmse(y_true, y_pred), } if n_layers is not None: d["depth_rmse"] = depth_rmse(y_true, y_pred, n_layers) return d
# ───────────────────────────────────────────────────────────────────────────── # PyTorch masked loss # ─────────────────────────────────────────────────────────────────────────────
[docs] def masked_mse_loss(pred, target): """ MSE loss that ignores ``NaN`` (padding) entries in *target*. Parameters ---------- pred : torch.Tensor, shape (batch, n_out) target : torch.Tensor, shape (batch, n_out) Returns ------- loss : torch.Tensor (scalar) """ mask = torch_isfinite(target) if not mask.any(): return pred.sum() * 0.0 # differentiable zero diff = (pred[mask] - target[mask]) ** 2 return diff.mean()
def torch_isfinite(t): """Return a bool mask without importing torch at module level.""" try: import torch return torch.isfinite(t) except ImportError: raise ImportError("PyTorch required for masked_mse_loss")