Source code for pycsamt.ai.inversion.pinn1d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""
Physics-informed 1-D MT/CSAMT inversion.

:class:`PINNInverter1D` fits a layered Earth model
to observed EM data without labelled training examples.
Model parameters (log-resistivity and log-thickness)
are optimised with Adam by minimising:

.. math::

    \mathcal{L}(\theta) =
    \frac{1}{F}\sum_{f=1}^{F}
    \left[
      \left(
        \log_{10}\rho_a^{\rm pred}(f)
        - \log_{10}\rho_a^{\rm obs}(f)
      \right)^2
      + \left(
          \frac{\phi^{\rm pred}(f)
                - \phi^{\rm obs}(f)}{90}
        \right)^2
    \right]
    + \lambda \sum_{k=1}^{L-1}
      \!\bigl(\rho_k - \rho_{k-1}\bigr)^2

where :math:`\rho_a^{\rm pred}` comes from the Wait
(1954) 1-D MT recursion.  The recursion is
implemented in both PyTorch and TensorFlow; the
active backend is selected automatically via
:mod:`pycsamt.backends`.

Example
-------
>>> from pycsamt.ai.inversion import PINNInverter1D
>>> inv = PINNInverter1D(
...     "edi/",
...     n_layers=10,
...     depth_max=2000.0,
...     smoothness_weight=0.01,
... )  # doctest: +SKIP
>>> inv.fit(epochs=500)  # doctest: +SKIP
PINNInverter1D(n_stations=5, fitted)
>>> models = inv.predict()  # doctest: +SKIP
"""

from __future__ import annotations

from typing import Any

import numpy as np

from .._base import BasePINNInverter
from ._pinn_ops import fit_station
from ._sites_bridge import SiteObs1D, sites_to_obs_1d

__all__ = ["PINNInverter1D"]


# backward-compat re-exports consumed by hybrid1d — torch only
try:
    from ._pinn_ops_torch import (
        _fit_station_torch as _fit_station,
    )
    from ._pinn_ops_torch import (
        _mt1d_torch,
    )
except ImportError:  # TF-only environment
    _mt1d_torch = None  # type: ignore[assignment]
    _fit_station = None  # type: ignore[assignment]


# ── PINNInverter1D ──


[docs] class PINNInverter1D(BasePINNInverter): r""" Physics-informed 1-D EM inversion. Fits a layered Earth model to observed MT/CSAMT apparent resistivity and phase by gradient descent. No labelled training data is required. Parameters ---------- sites : Any Path, ``EDIFile``, ``EDICollection``, ``Site``, ``Sites``, ``APISurvey``, or iterable. Station data are extracted at construction time. solver : {'mt1d', 'csamt1d'} EM physics solver (TEM not supported). n_layers : int, default 10 Number of earth layers including the halfspace. depth_max : float, default 2000.0 Approximate investigation depth in metres, used to set equal initial layer thicknesses. smoothness_weight : float, default 0.01 Regularisation weight :math:`\lambda` on the first-difference of log-resistivity. lr : float, default 1e-2 Adam learning rate. device : str or None Torch device. Auto-detects CUDA/CPU if None. comp : {'xy', 'yx', 'xx', 'yy'}, default 'xy' Impedance tensor component to use. recursive : bool, default True Passed to ``ensure_sites``. on_dup : str, default 'replace' Passed to ``ensure_sites``. verbose : int, default 0 Verbosity level for site loading. """ def __init__( self, sites: Any, *, solver: str = "mt1d", n_layers: int = 10, depth_max: float = 2000.0, smoothness_weight: float = 0.01, lr: float = 1e-2, device: str | None = None, comp: str = "xy", recursive: bool = True, on_dup: str = "replace", verbose: int = 0, ) -> None: if solver not in ("mt1d", "csamt1d"): raise ValueError( f"solver must be 'mt1d' or 'csamt1d'; got {solver!r}." ) super().__init__( n_layers=n_layers, depth_max=depth_max, device=device, ) self.solver = solver self.smoothness_weight = float(smoothness_weight) self.lr = float(lr) self.comp = comp self.recursive = recursive self.on_dup = on_dup self.verbose = verbose self._obs: list[SiteObs1D] = sites_to_obs_1d( sites, comp=comp, recursive=recursive, on_dup=on_dup, strict=True, verbose=verbose, ) self._results: list[dict] = [] # ── fit ──
[docs] def fit( self, epochs: int = 500, *, verbose: bool = True, log_every: int = 100, ) -> PINNInverter1D: """ Run the physics-informed optimisation. Parameters ---------- epochs : int, default 500 Number of Adam iterations per station. verbose : bool, default True Print progress per station. log_every : int, default 100 Print epoch detail every this many steps. Returns ------- self """ from pycsamt.api.view.progress import get_progress_bar self._require_backend() dev = self._resolve_device() _every = log_every if verbose else 0 self._results = [] with get_progress_bar( total=len(self._obs), desc="PINN inversion", unit="station", verbose=verbose, ) as bar: for obs in self._obs: bar.set_description(f"PINN inversion: {obs.name}") res = fit_station( obs, n_layers=self.n_layers, depth_max=self.depth_max, lam=self.smoothness_weight, lr=self.lr, epochs=epochs, device=dev, log_every=_every, ) self._results.append(res) bar.update(1) self._is_fitted = True return self
# ── predict ──
[docs] def predict(self) -> list: """ Return fitted layered models for all stations. Returns ------- models : list of LayeredModel One per station, same order as :attr:`stations`. """ self._check_fitted() from pycsamt.forward.synthetic import LayeredModel models = [] for res in self._results: log_rho = res["log_rho"] log_thick = res["log_thick"] rho = 10.0**log_rho thick = 10.0**log_thick try: m = LayeredModel( resistivity=np.maximum(rho, 1e-3), thickness=np.maximum(thick, 1.0), ) except Exception: m = None models.append(m) return models
# ── diagnostics ──
[docs] def residuals(self): """ Compute observed vs predicted data for all sites. Returns ------- pandas.DataFrame Columns: station, freq, rho_obs, rho_pred, phase_obs, phase_pred. """ self._check_fitted() import pandas as pd from pycsamt.forward.em1d import ( CSAMT1DForward, MT1DForward, ) from pycsamt.forward.synthetic import LayeredModel Fwd = MT1DForward if self.solver == "mt1d" else CSAMT1DForward rows = [] for obs, res in zip(self._obs, self._results): rho = 10.0 ** res["log_rho"] thick = 10.0 ** res["log_thick"] try: m = LayeredModel( resistivity=np.maximum(rho, 1e-3), thickness=np.maximum(thick, 1.0), ) resp = Fwd(obs.freq).run(m) rho_pred = resp.rho_a ph_pred = resp.phase except Exception: rho_pred = np.full_like(obs.rho_obs, np.nan) ph_pred = np.full_like(obs.phase_obs, np.nan) for k in range(len(obs.freq)): rows.append( { "station": obs.name, "freq": obs.freq[k], "rho_obs": obs.rho_obs[k], "rho_pred": rho_pred[k], "phase_obs": obs.phase_obs[k], "phase_pred": ph_pred[k], } ) return pd.DataFrame(rows)
[docs] def loss_curves(self): """ Return the Adam loss history for all stations. Returns ------- pandas.DataFrame Columns: station, epoch, loss. """ self._check_fitted() import pandas as pd rows = [] for obs, res in zip(self._obs, self._results): for ep, val in enumerate(res["history"], start=1): rows.append( { "station": obs.name, "epoch": ep, "loss": val, } ) return pd.DataFrame(rows)
# ── read-only properties ──
[docs] @property def stations(self) -> list[str]: """Station names in order.""" return [o.name for o in self._obs]
[docs] @property def n_sites(self) -> int: """Number of loaded stations.""" return len(self._obs)
# ── internals ── def __repr__(self) -> str: status = "fitted" if self._is_fitted else ("unfitted") return ( f"PINNInverter1D(" f"n_stations={self.n_sites}, " f"n_layers={self.n_layers}, " f"{status})" )