# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Objective-function helpers for inversion backends."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from .doc import _inversion_param_docs
__all__ = [
"ErrorModel",
"component_errors",
"component_mask",
"error_model_from_config",
"relative_errors",
"weighted_rms",
]
[docs]
@dataclass
class ErrorModel:
rho_relative: float = 0.05
rho_absolute: float = 1e-12
phase_absolute: float = 3.0
phase_relative: float = 0.0
tdem_relative: float = 0.05
tdem_absolute: float = 1e-30
impedance_relative: float = 0.05
impedance_absolute: float = 1e-12
min_error: float = 1e-12
masks: dict[str, Any] = field(default_factory=dict)
[docs]
def errors(
self,
values: Any,
*,
component: str,
explicit: Any = None,
relative: bool = False,
) -> np.ndarray:
"""Return data errors for one observed component.
Parameters
----------
values : array-like
Observed or predicted values used to scale relative error floors.
component : str
Component name. Common aliases include ``"rho"``, ``"rho_a"``,
``"phase"``, ``"tdem"``, ``"dbdt"``, and ``"impedance"``.
explicit : array-like, optional
Explicit absolute errors. When provided, component floor settings
are bypassed except for the global ``min_error`` lower bound.
relative : bool, default False
If ``True``, return relative errors by dividing absolute errors by
``abs(values)`` with ``min_error`` protection.
Returns
-------
ndarray
Error array with the same shape as ``values``.
Examples
--------
>>> from pycsamt.inversion.objective import ErrorModel
>>> model = ErrorModel(rho_relative=0.1, rho_absolute=1.0)
>>> model.errors([5.0, 100.0], component="rho").tolist()
[1.0, 10.0]
>>> model.errors([45.0], component="phase").tolist()
[3.0]
"""
arr = np.asarray(values, dtype=float)
comp = _canonical_component(component)
if explicit is not None:
err = np.asarray(explicit, dtype=float)
if relative:
err = err / np.maximum(np.abs(arr), self.min_error)
return np.maximum(err, self.min_error)
if comp == "rho":
err = np.maximum(
np.abs(arr) * self.rho_relative, self.rho_absolute
)
elif comp == "phase":
err = np.maximum(
np.abs(arr) * self.phase_relative, self.phase_absolute
)
elif comp == "tdem":
err = np.maximum(
np.abs(arr) * self.tdem_relative, self.tdem_absolute
)
elif comp in {"z_real", "z_imag", "impedance"}:
err = np.maximum(
np.abs(arr) * self.impedance_relative, self.impedance_absolute
)
else:
err = np.maximum(np.abs(arr) * self.rho_relative, self.min_error)
if relative:
err = err / np.maximum(np.abs(arr), self.min_error)
return np.maximum(err, self.min_error)
[docs]
def mask(self, values: Any, *, component: str) -> np.ndarray:
"""Return a boolean inclusion mask for one component.
Parameters
----------
values : array-like
Data array whose shape defines the returned mask shape.
component : str
Component name. Component-specific masks are checked first, then
station-level masks under ``"station"`` or ``"station_mask"``.
Returns
-------
ndarray of bool
Boolean mask broadcast to the shape of ``values``.
Examples
--------
>>> from pycsamt.inversion.objective import ErrorModel
>>> model = ErrorModel(masks={"station": [True, False]})
>>> model.mask([[1.0, 2.0], [3.0, 4.0]], component="rho").tolist()
[[True, True], [False, False]]
"""
arr = np.asarray(values)
comp = _canonical_component(component)
raw = self.masks.get(comp, self.masks.get(component, None))
if raw is None:
raw = self.masks.get(
"station", self.masks.get("station_mask", None)
)
if raw is None:
return np.ones(arr.shape, dtype=bool)
mask = np.asarray(raw, dtype=bool)
if mask.shape == arr.shape:
return mask
if mask.ndim == 1 and arr.ndim == 2 and mask.size == arr.shape[0]:
return np.broadcast_to(mask[:, None], arr.shape)
if mask.ndim == 1 and arr.ndim == 2 and mask.size == arr.shape[1]:
return np.broadcast_to(mask[None, :], arr.shape)
return np.broadcast_to(mask, arr.shape)
ErrorModel.__doc__ = f"""
Component-aware data-error settings for inversion backends.
``ErrorModel`` centralizes the floors used when packing objective functions for
the built-in, SimPEG, pyGIMLi, Occam2D, and ModEM adapters. It supports
component-specific relative/absolute floors and boolean masks used to exclude
stations, frequencies, time gates, or individual samples.
Parameters
----------
rho_relative : float, default 0.05
Relative apparent-resistivity error floor.
rho_absolute : float, default 1e-12
Absolute apparent-resistivity error floor in ohm metres.
phase_absolute : float, default 3.0
Absolute phase error floor in degrees.
phase_relative : float, default 0.0
Optional relative phase error floor.
tdem_relative : float, default 0.05
Relative TDEM decay-value error floor.
tdem_absolute : float, default 1e-30
Absolute TDEM decay-value error floor.
impedance_relative : float, default 0.05
Relative impedance-component error floor.
impedance_absolute : float, default 1e-12
Absolute impedance-component error floor.
min_error : float, default 1e-12
Global lower bound applied to all returned errors.
masks : dict, optional
Component or station masks. Supported keys include component names such as
``"rho"``, ``"phase"``, ``"tdem"``, and station-level keys
``"station"``/``"station_mask"``.
Notes
-----
Configuration helpers read overrides from ``backend_options`` and nested
``backend_options["error_model"]`` dictionaries. Explicit observed-data errors
can still be passed to :meth:`errors` or :func:`component_errors`.
{_inversion_param_docs.errors.error_model_examples}
{_inversion_param_docs.errors.error_model_references}
"""
[docs]
def relative_errors(values, floor: float = 0.05) -> np.ndarray:
"""Return absolute errors from a relative floor.
Parameters
----------
values : array-like
Data values used for relative scaling.
floor : float, default 0.05
Relative floor as a fraction of absolute value.
Returns
-------
ndarray
``max(abs(values) * floor, 1e-12)``.
Examples
--------
>>> from pycsamt.inversion.objective import relative_errors
>>> relative_errors([10.0, 100.0], floor=0.1).tolist()
[1.0, 10.0]
"""
arr = np.asarray(values, dtype=float)
return np.maximum(np.abs(arr) * float(floor), 1e-12)
[docs]
def error_model_from_config(cfg: Any) -> ErrorModel:
"""Build an :class:`ErrorModel` from inversion config options.
Parameters
----------
cfg : object
Inversion config-like object exposing ``error_floor``, ``phase_error``,
and optional ``backend_options``. Overrides may be placed directly in
``backend_options`` or inside ``backend_options["error_model"]``.
Returns
-------
ErrorModel
Component-aware error model.
Examples
--------
>>> from pycsamt.inversion.config import InversionConfig
>>> from pycsamt.inversion.objective import error_model_from_config
>>> cfg = InversionConfig(error_floor=0.1, phase_error=2.0)
>>> model = error_model_from_config(cfg)
>>> model.rho_relative
0.1
>>> model.phase_absolute
2.0
"""
opts = dict(getattr(cfg, "backend_options", {}) or {})
nested = dict(opts.get("error_model", {}) or {})
def pick(name: str, default: Any) -> Any:
return nested.get(name, opts.get(name, default))
return ErrorModel(
rho_relative=float(
pick("rho_relative", getattr(cfg, "error_floor", 0.05))
),
rho_absolute=float(pick("rho_absolute", 1e-12)),
phase_absolute=float(
pick("phase_absolute", getattr(cfg, "phase_error", 3.0))
),
phase_relative=float(pick("phase_relative", 0.0)),
tdem_relative=float(
pick("tdem_relative", getattr(cfg, "error_floor", 0.05))
),
tdem_absolute=float(pick("tdem_absolute", 1e-30)),
impedance_relative=float(
pick("impedance_relative", getattr(cfg, "error_floor", 0.05))
),
impedance_absolute=float(pick("impedance_absolute", 1e-12)),
min_error=float(pick("min_error", 1e-12)),
masks=dict(pick("masks", opts.get("masks", {})) or {}),
)
[docs]
def component_errors(
values: Any,
cfg: Any,
*,
component: str,
explicit: Any = None,
relative: bool = False,
) -> np.ndarray:
"""Return errors for one data component using config settings.
Parameters
----------
values : array-like
Data values used for scaling.
cfg : object
Inversion config-like object accepted by :func:`error_model_from_config`.
component : str
Component name or alias.
explicit : array-like, optional
Explicit data errors.
relative : bool, default False
Return relative errors instead of absolute errors.
Returns
-------
ndarray
Component error array.
Examples
--------
>>> from pycsamt.inversion.config import InversionConfig
>>> from pycsamt.inversion.objective import component_errors
>>> cfg = InversionConfig(error_floor=0.05, phase_error=2.0)
>>> component_errors([100.0, 200.0], cfg, component="rho").tolist()
[5.0, 10.0]
>>> component_errors([45.0], cfg, component="phase").tolist()
[2.0]
"""
return error_model_from_config(cfg).errors(
values,
component=component,
explicit=explicit,
relative=relative,
)
[docs]
def component_mask(values: Any, cfg: Any, *, component: str) -> np.ndarray:
"""Return a boolean mask for one component using config mask settings.
Parameters
----------
values : array-like
Data array whose shape defines the returned mask shape.
cfg : object
Inversion config-like object accepted by :func:`error_model_from_config`.
component : str
Component name or alias.
Returns
-------
ndarray of bool
Boolean inclusion mask.
Examples
--------
>>> import numpy as np
>>> from pycsamt.inversion.config import InversionConfig
>>> from pycsamt.inversion.objective import component_mask
>>> cfg = InversionConfig(backend_options={"masks": {"station": [True, False]}})
>>> component_mask(np.ones((2, 2)), cfg, component="rho").tolist()
[[True, True], [False, False]]
"""
return error_model_from_config(cfg).mask(values, component=component)
[docs]
def weighted_rms(observed, predicted, errors=None) -> float:
"""Return normalized weighted RMS misfit.
Parameters
----------
observed, predicted : array-like
Observed and predicted data arrays. They must be broadcast-compatible
through NumPy conversion.
errors : array-like, optional
Absolute data errors. If omitted, unit errors are used.
Returns
-------
float
Root-mean-square of ``(predicted - observed) / errors`` over finite,
positive-error samples. Returns ``nan`` when no valid samples exist.
Examples
--------
>>> from pycsamt.inversion.objective import weighted_rms
>>> round(weighted_rms([10.0, 20.0], [11.0, 18.0], [1.0, 2.0]), 6)
1.0
References
----------
.. [1] Tarantola, A. (2005). *Inverse Problem Theory and Methods for Model
Parameter Estimation*. SIAM.
"""
obs = np.asarray(observed, dtype=float)
pred = np.asarray(predicted, dtype=float)
if errors is None:
err = np.ones_like(obs, dtype=float)
else:
err = np.asarray(errors, dtype=float)
mask = np.isfinite(obs) & np.isfinite(pred) & np.isfinite(err) & (err > 0)
if not np.any(mask):
return float("nan")
resid = (pred[mask] - obs[mask]) / err[mask]
return float(np.sqrt(np.mean(resid**2)))
def _canonical_component(component: str) -> str:
comp = str(component).lower()
if comp in {"rho_a", "rhoa", "apparent_resistivity", "resistivity"}:
return "rho"
if comp in {"phi", "phase_deg"}:
return "phase"
if comp in {"values", "dbdt", "dbz_dt", "voltage", "tem"}:
return "tdem"
if comp in {"z", "zxy", "zyx", "impedance"}:
return "impedance"
return comp