Source code for pycsamt.ai.losses.response

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Electromagnetic response-consistency losses.

These losses implement the ``L_response`` term 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 and observed complex impedance arrays sharing
the canonical shape ``(station, frequency, component)`` used by
:class:`~pycsamt.forward.maxwell.contracts.ForwardResult` and
:class:`~pycsamt.ai.data.contracts.SurveyData`.  When positive
standard errors are supplied, residuals are normalized by them
before the elementwise penalty is applied, matching the usual
normalized-RMS EM data-misfit convention.

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 TYPE_CHECKING, Any

import numpy as np

from ._common import _reduce

if TYPE_CHECKING:
    from pycsamt.ai.data import SurveyData
    from pycsamt.forward.maxwell import ForwardResult

__all__ = [
    "ResponseLossResult",
    "ResponseLoss",
    "response_residual_loss",
    "response_loss_from_contracts",
]

_KINDS = ("l1", "l2")
_REDUCTIONS = ("mean", "sum")


[docs] @dataclass(frozen=True) class ResponseLossResult: """Immutable scalar result of a response-consistency loss. Parameters ---------- value : float Reduced loss value. ``nan`` when no cell was included and ``reduction="mean"``. kind : {"l1", "l2"} Elementwise penalty applied to each residual magnitude. reduction : {"mean", "sum"} Reduction applied over valid cells. n_valid : int Number of impedance cells included after masking. weight_sum : float Number of included cells; kept for interface parity with other loss results in this package. normalized : bool Whether residuals were divided by a positive standard error before the penalty was applied. Examples -------- >>> import numpy as np >>> pred = np.array([1 + 1j, 2 + 2j]) >>> obs = np.array([1 + 1j, 0 + 0j]) >>> response_residual_loss(pred, obs).value 4.0 """ value: float kind: str reduction: str n_valid: int weight_sum: float normalized: bool 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)) object.__setattr__(self, "normalized", bool(self.normalized))
def _response_residual_loss( predicted: Any, observed: Any, *, errors: Any | None, valid: Any | None, kind: str, reduction: str, ) -> ResponseLossResult: """Compute one masked, optionally error-normalized response loss.""" if kind not in _KINDS: raise ValueError(f"kind must be one of {_KINDS}.") pred = np.asarray(predicted, dtype=complex) obs = np.asarray(observed, dtype=complex) if pred.shape != obs.shape: raise ValueError("predicted and observed must share one shape.") if pred.size == 0: raise ValueError("predicted and observed must not be empty.") mask = np.isfinite(pred.real) & np.isfinite(pred.imag) mask &= np.isfinite(obs.real) & np.isfinite(obs.imag) normalized = errors is not None error_array = None if normalized: error_array = np.asarray(errors, dtype=float) if error_array.shape != pred.shape: raise ValueError( "errors must have the same shape as predicted/observed." ) mask &= np.isfinite(error_array) & (error_array > 0) if valid is not None: supplied = np.asarray(valid, dtype=bool) if supplied.shape != pred.shape: raise ValueError( "valid must have the same shape as predicted/observed." ) mask &= supplied if normalized: safe_errors = np.where(mask, error_array, 1.0) residual = np.where(mask, (pred - obs) / safe_errors, 0.0) else: residual = np.where(mask, pred - obs, 0.0) if kind == "l1": per_cell = np.abs(residual) else: per_cell = np.square(residual.real) + np.square(residual.imag) active_weights = mask.astype(float) value, weight_sum = _reduce(per_cell, active_weights, reduction) return ResponseLossResult( value=value, kind=kind, reduction=reduction, n_valid=int(np.count_nonzero(mask)), weight_sum=weight_sum, normalized=normalized, )
[docs] def response_residual_loss( predicted: Any, observed: Any, *, errors: Any | None = None, valid: Any | None = None, kind: str = "l2", reduction: str = "mean", ) -> ResponseLossResult: """Compare predicted and observed complex impedance responses. Parameters ---------- predicted : array-like of complex Forward-simulated impedance. Any shape is accepted; the canonical layout is ``(station, frequency, component)``. observed : array-like of complex Observed impedance with the same shape. errors : array-like or None, optional Positive absolute standard errors, same shape as ``predicted``. When given, each residual is divided by its error before the elementwise penalty, matching the normalized-RMS EM data-misfit convention. Entries with a non-finite or non-positive error are excluded rather than raising. valid : array-like of bool or None, optional Explicit observation mask, combined with finite-value masking of ``predicted``, ``observed``, and ``errors``. kind : {"l1", "l2"}, default="l2" Elementwise penalty applied to each residual magnitude. reduction : {"mean", "sum"}, default="mean" Reduction applied over included cells. Returns ------- ResponseLossResult Reduced response-consistency penalty, ``L_response``. Examples -------- >>> import numpy as np >>> pred = np.array([1 + 1j, 2 + 2j]) >>> obs = np.array([1 + 1j, 0 + 0j]) >>> response_residual_loss(pred, obs, kind="l2").value 4.0 >>> response_residual_loss( ... pred, obs, errors=np.array([1.0, 2.0]), kind="l2" ... ).value 1.0 """ return _response_residual_loss( predicted, observed, errors=errors, valid=valid, kind=kind, reduction=reduction, )
[docs] def response_loss_from_contracts( forward: ForwardResult, observed: SurveyData, *, kind: str = "l2", reduction: str = "mean", use_errors: bool = True, ) -> ResponseLossResult: """Compute ``L_response`` directly from canonical result/survey. Requires exact station, component, and frequency alignment between ``forward`` and ``observed`` rather than silently interpolating or reordering either axis, per the survey-matching principle in the AI-inversion plan. Parameters ---------- forward : ForwardResult Predicted impedance from a Maxwell backend adapter. observed : SurveyData Observed survey impedance to compare against. kind : {"l1", "l2"}, default="l2" Elementwise penalty applied to each residual magnitude. reduction : {"mean", "sum"}, default="mean" Reduction applied over included cells. use_errors : bool, default=True Normalize residuals by ``observed.impedance_error`` when it is available. Returns ------- ResponseLossResult Reduced response-consistency penalty, ``L_response``. Raises ------ TypeError If ``forward`` or ``observed`` has the wrong type. ValueError If station names, components, or frequencies are not identical and identically ordered on both inputs. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data import SurveyData >>> from pycsamt.forward.maxwell import ForwardResult, SolverDiagnostics >>> z = np.array([[[1 + 1j]]]) >>> observed = SurveyData(z, [10.0], ["S1"], ["zxy"], [[0, 0]]) >>> diagnostics = SolverDiagnostics([[True]], [[1]], [[0.0]], 0.01) >>> forward = ForwardResult( ... "a" * 64, ... [10.0], ... ["S1"], ... ["zxy"], ... z, ... None, ... "demo", ... "1", ... diagnostics, ... ) >>> response_loss_from_contracts(forward, observed).value 0.0 """ from pycsamt.ai.data import SurveyData as _SurveyData from pycsamt.forward.maxwell import ForwardResult as _ForwardResult if not isinstance(forward, _ForwardResult): raise TypeError("forward must be a ForwardResult.") if not isinstance(observed, _SurveyData): raise TypeError("observed must be a SurveyData.") if forward.receiver_names != observed.station_names: raise ValueError( "forward.receiver_names and observed.station_names must " "match exactly; align stations before computing " "L_response." ) if forward.components != observed.components: raise ValueError( "forward.components and observed.components must match " "exactly; align components before computing L_response." ) same_frequencies = forward.frequencies_hz.shape == ( observed.frequencies_hz.shape ) and np.array_equal(forward.frequencies_hz, observed.frequencies_hz) if not same_frequencies: raise ValueError( "forward.frequencies_hz and observed.frequencies_hz must " "match exactly; use a survey-matched frequency selector " "instead of silently interpolating." ) mask = forward.valid & observed.valid errors = observed.impedance_error if use_errors else None return _response_residual_loss( forward.impedance_v_a, observed.impedance, errors=errors, valid=mask, kind=kind, reduction=reduction, )
[docs] @dataclass(frozen=True) class ResponseLoss: """Configurable, callable response-consistency loss. Parameters ---------- kind : {"l1", "l2"}, default="l2" Elementwise penalty applied to each residual magnitude. reduction : {"mean", "sum"}, default="mean" Reduction applied over included cells. Examples -------- >>> import numpy as np >>> loss = ResponseLoss() >>> pred = np.array([1 + 1j, 2 + 2j]) >>> obs = np.array([1 + 1j, 0 + 0j]) >>> loss(pred, obs).value 4.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}.") def __call__( self, predicted: Any, observed: Any, *, errors: Any | None = None, valid: Any | None = None, ) -> ResponseLossResult: """Evaluate the configured loss on one pair of impedances.""" return _response_residual_loss( predicted, observed, errors=errors, valid=valid, kind=self.kind, reduction=self.reduction, )