# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Spatial regularization losses on predicted resistivity grids.
These losses implement the ``L_grad_x``, ``L_grad_z``, and ``L_TV``
terms of the staged inversion objective in the AI-inversion plan::
L = w_m * L_model + lambda_x * L_grad_x + lambda_z * L_grad_z
+ lambda_tv * L_TV + lambda_d * L_response
Inputs are predicted model arrays on a canonical geological grid,
e.g. ``(z, x)`` or ``(z, y, x)`` from
:class:`~pycsamt.ai.geology.GeologyGrid`. Only forward-difference
pairs where both endpoints are finite, user-valid, and non-zero
weight are included.
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 ._common import _reduce, _weight_array
__all__ = [
"SpatialLossResult",
"gradient_smoothness_loss",
"total_variation_loss",
"SpatialLoss",
]
_KINDS = ("l1", "l2")
_REDUCTIONS = ("mean", "sum")
[docs]
@dataclass(frozen=True)
class SpatialLossResult:
"""Immutable scalar result of a spatial regularization loss.
Parameters
----------
value : float
Reduced penalty value. ``nan`` when no difference was
included and ``reduction="mean"``.
kind : {"l1", "l2"}
Elementwise penalty applied to each spatial difference.
label : str
Loss identity, e.g. ``"grad_axis0"`` or ``"tv"``.
reduction : {"mean", "sum"}
Reduction applied over valid, weighted differences.
n_valid : int
Number of differences included after masking.
weight_sum : float
Sum of weights over included differences.
Examples
--------
>>> import numpy as np
>>> grid = np.array([[0.0, 1.0, 3.0], [0.0, 0.0, 0.0]])
>>> result = gradient_smoothness_loss(grid, axis=1)
>>> result.label
'grad_axis1'
"""
value: float
kind: str
label: str
reduction: str
n_valid: int
weight_sum: float
def __post_init__(self) -> None:
if self.kind not in _KINDS:
raise ValueError(f"kind must be one of {_KINDS}.")
if self.reduction not in _REDUCTIONS:
raise ValueError(f"reduction must be one of {_REDUCTIONS}.")
if self.n_valid < 0:
raise ValueError("n_valid must be non-negative.")
object.__setattr__(self, "value", float(self.value))
object.__setattr__(self, "weight_sum", float(self.weight_sum))
object.__setattr__(self, "n_valid", int(self.n_valid))
def _normalize_axis(ndim: int, axis: int) -> int:
"""Return *axis* normalized into ``[0, ndim)`` or raise."""
normalized = axis + ndim if axis < 0 else axis
if not (0 <= normalized < ndim):
raise ValueError(f"axis must be in [-{ndim}, {ndim}).")
return normalized
def _axis_pair(array: np.ndarray, axis: int) -> tuple[np.ndarray, np.ndarray]:
"""Return the front/back views shifted by one cell along *axis*."""
front = [slice(None)] * array.ndim
back = [slice(None)] * array.ndim
front[axis] = slice(1, None)
back[axis] = slice(None, -1)
return array[tuple(front)], array[tuple(back)]
def _spatial_difference_loss(
array: np.ndarray,
*,
axis: int,
kind: str,
valid: Any | None,
weights: Any | None,
reduction: str,
label: str,
) -> SpatialLossResult:
"""Compute one masked, weighted first-difference penalty.
Assumes *axis* is already a valid, normalized array axis.
"""
if kind not in _KINDS:
raise ValueError(f"kind must be one of {_KINDS}.")
if array.shape[axis] < 2:
raise ValueError(
f"axis {axis} must have at least two cells to difference."
)
mask = np.isfinite(array)
if valid is not None:
supplied = np.asarray(valid, dtype=bool)
if supplied.shape != array.shape:
raise ValueError("valid must have the same shape as y_pred.")
mask &= supplied
front_values, back_values = _axis_pair(array, axis)
front_mask, back_mask = _axis_pair(mask, axis)
pair_mask = front_mask & back_mask
difference = np.where(pair_mask, front_values - back_values, 0.0)
per_cell = np.abs(difference) if kind == "l1" else np.square(difference)
weight_array = _weight_array(weights, array.shape, "weights")
if weight_array is None:
active_weights = pair_mask.astype(float)
else:
front_weights, back_weights = _axis_pair(weight_array, axis)
active_weights = np.where(
pair_mask, np.minimum(front_weights, back_weights), 0.0
)
value, weight_sum = _reduce(per_cell, active_weights, reduction)
return SpatialLossResult(
value=value,
kind=kind,
label=label,
reduction=reduction,
n_valid=int(np.count_nonzero(pair_mask)),
weight_sum=weight_sum,
)
[docs]
def gradient_smoothness_loss(
y_pred: Any,
*,
axis: int,
kind: str = "l2",
valid: Any | None = None,
weights: Any | None = None,
reduction: str = "mean",
) -> SpatialLossResult:
"""Penalize first-difference magnitude along one grid axis.
Parameters
----------
y_pred : array-like
Predicted model values on a canonical geological grid.
axis : int
Grid axis along which to difference, e.g. ``0`` for depth
or ``-1`` for the horizontal direction. Negative axes are
supported.
kind : {"l1", "l2"}, default="l2"
Elementwise penalty applied to each difference.
valid : array-like of bool or None, optional
Cell mask applied before differencing. A difference is kept
only if both of its endpoint cells are valid and finite.
weights : array-like or None, optional
Non-negative per-cell weights broadcastable to ``y_pred``.
A difference is weighted by the minimum of its two endpoint
weights.
reduction : {"mean", "sum"}, default="mean"
Reduction applied over included differences.
Returns
-------
SpatialLossResult
Reduced smoothness penalty, e.g. ``L_grad_x`` or ``L_grad_z``.
Examples
--------
>>> import numpy as np
>>> grid = np.array([[0.0, 1.0, 3.0], [0.0, 0.0, 0.0]])
>>> gradient_smoothness_loss(grid, axis=1, kind="l1").value
0.75
"""
array = np.asarray(y_pred, dtype=float)
if array.ndim == 0 or array.size == 0:
raise ValueError("y_pred must be a non-empty array.")
normalized = _normalize_axis(array.ndim, axis)
return _spatial_difference_loss(
array,
axis=normalized,
kind=kind,
valid=valid,
weights=weights,
reduction=reduction,
label=f"grad_axis{normalized}",
)
[docs]
def total_variation_loss(
y_pred: Any,
*,
kind: str = "l1",
valid: Any | None = None,
weights: Any | None = None,
reduction: str = "mean",
) -> SpatialLossResult:
"""Penalize anisotropic total variation over every spatial axis.
Computes :func:`gradient_smoothness_loss` along each axis of
``y_pred`` and combines them, matching the standard anisotropic
total-variation definition (a per-axis sum of directional
gradients, as opposed to an isotropic pointwise gradient norm).
Parameters
----------
y_pred : array-like
Predicted model values on a canonical geological grid.
kind : {"l1", "l2"}, default="l1"
Elementwise penalty applied to each difference.
valid : array-like of bool or None, optional
Cell mask shared by every axis.
weights : array-like or None, optional
Non-negative per-cell weights shared by every axis.
reduction : {"mean", "sum"}, default="mean"
Reduction applied over all included differences from every
axis combined.
Returns
-------
SpatialLossResult
Reduced total-variation penalty, ``L_TV``, labeled ``"tv"``.
Examples
--------
>>> import numpy as np
>>> grid = np.array([[0.0, 1.0], [0.0, 3.0]])
>>> total_variation_loss(grid).value
1.5
"""
array = np.asarray(y_pred, dtype=float)
if array.ndim == 0 or array.size == 0:
raise ValueError("y_pred must be a non-empty array.")
total = 0.0
weight_total = 0.0
n_valid = 0
for axis in range(array.ndim):
axis_result = _spatial_difference_loss(
array,
axis=axis,
kind=kind,
valid=valid,
weights=weights,
reduction="sum",
label=f"grad_axis{axis}",
)
total += axis_result.value
weight_total += axis_result.weight_sum
n_valid += axis_result.n_valid
if reduction == "sum":
value = total
elif reduction == "mean":
value = total / weight_total if weight_total > 0 else float("nan")
else:
raise ValueError(f"reduction must be one of {_REDUCTIONS}.")
return SpatialLossResult(
value=value,
kind=kind,
label="tv",
reduction=reduction,
n_valid=n_valid,
weight_sum=weight_total,
)
[docs]
@dataclass(frozen=True)
class SpatialLoss:
"""Configurable combination of gradient and TV regularizers.
Combines the ``lambda_x * L_grad_x + lambda_z * L_grad_z +
lambda_tv * L_TV`` terms of the staged inversion objective for a
canonical 2-D ``(z, x)`` grid, where depth is axis 0 and the
horizontal direction is axis 1.
Parameters
----------
lambda_x : float, default=1.0
Weight applied to the horizontal-gradient term.
lambda_z : float, default=1.0
Weight applied to the depth-gradient term.
lambda_tv : float, default=0.0
Weight applied to the total-variation term.
kind : {"l1", "l2"}, default="l2"
Elementwise penalty shared by the two gradient terms. The
total-variation term always uses ``"l1"``, matching its
standard definition.
reduction : {"mean", "sum"}, default="mean"
Reduction applied within each enabled term.
Examples
--------
>>> import numpy as np
>>> grid = np.array([[0.0, 1.0], [0.0, 3.0]])
>>> loss = SpatialLoss(
... lambda_x=1.0, lambda_z=0.0, lambda_tv=0.0, kind="l1"
... )
>>> loss(grid)
2.0
"""
lambda_x: float = 1.0
lambda_z: float = 1.0
lambda_tv: float = 0.0
kind: str = "l2"
reduction: str = "mean"
def __post_init__(self) -> None:
if self.kind not in _KINDS:
raise ValueError(f"kind must be one of {_KINDS}.")
if self.reduction not in _REDUCTIONS:
raise ValueError(f"reduction must be one of {_REDUCTIONS}.")
for name in ("lambda_x", "lambda_z", "lambda_tv"):
value = float(getattr(self, name))
if not np.isfinite(value) or value < 0:
raise ValueError(f"{name} must be finite and non-negative.")
object.__setattr__(self, name, value)
def __call__(
self,
y_pred: Any,
*,
valid: Any | None = None,
weights: Any | None = None,
) -> float:
"""Evaluate the weighted sum of the configured spatial terms.
Parameters
----------
y_pred : array-like, shape (n_z, n_x)
Predicted model values on a canonical 2-D grid.
valid : array-like of bool or None, optional
Cell mask shared by every enabled term.
weights : array-like or None, optional
Non-negative per-cell weights shared by every enabled
term.
Returns
-------
float
``lambda_x * L_grad_x + lambda_z * L_grad_z +
lambda_tv * L_TV``.
"""
array = np.asarray(y_pred, dtype=float)
if array.ndim != 2:
raise ValueError("y_pred must be a 2-D (z, x) grid.")
total = 0.0
if self.lambda_z > 0:
total += (
self.lambda_z
* gradient_smoothness_loss(
array,
axis=0,
kind=self.kind,
valid=valid,
weights=weights,
reduction=self.reduction,
).value
)
if self.lambda_x > 0:
total += (
self.lambda_x
* gradient_smoothness_loss(
array,
axis=1,
kind=self.kind,
valid=valid,
weights=weights,
reduction=self.reduction,
).value
)
if self.lambda_tv > 0:
total += (
self.lambda_tv
* total_variation_loss(
array,
valid=valid,
weights=weights,
reduction=self.reduction,
).value
)
return total