Source code for pycsamt.ai.inversion.hybrid1d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""
Hybrid AI + physics 1-D MT/CSAMT inversion.

:class:`HybridInverter1D` combines a pre-trained
:class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`
(Option 1, supervised AI) with a physics-informed
refinement step (Option 2 gradient descent).

**Stage 1 — AI initial model**

The supervised inverter maps the observed EM response
directly to a layered Earth model::

    d_obs --> EMInverter1D --> m_0  (fast, per-station)

**Stage 2 — Physics refinement**

Starting from ``m_0``, Adam minimises the same
physics-informed loss as
:class:`~pycsamt.ai.inversion.pinn1d.PINNInverter1D`:

.. math::

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

Because ``m_0`` is physically plausible, Stage 2
converges faster and more reliably than a PINN started
from a naive initialisation.

Example
-------
>>> from pycsamt.ai.inversion import (
...     EMInverter1D,
...     HybridInverter1D,
... )
>>> ai = EMInverter1D.load(  # doctest: +SKIP
...     "checkpoints/mt1d_resnet.npz"
... )
>>> inv = HybridInverter1D(  # doctest: +SKIP
...     "edi/",
...     ai_inverter=ai,
...     max_iter=200,
...     smoothness_weight=0.005,
... )
>>> inv.fit()  # doctest: +SKIP
HybridInverter1D(n_stations=5, fitted)
>>> models = inv.predict()  # doctest: +SKIP
>>> df = inv.convergence_curves()  # doctest: +SKIP
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

import numpy as np

from .._base import BaseHybridInverter
from ._pinn_ops import fit_station
from ._sites_bridge import (
    SiteObs1D,
    obs_to_features_1d,
    sites_to_obs_1d,
)

__all__ = ["HybridInverter1D"]


# ── HybridInverter1D ──


[docs] class HybridInverter1D(BaseHybridInverter): r""" Two-stage hybrid AI + physics 1-D inversion. Parameters ---------- sites : Any Path, ``EDIFile``, ``EDICollection``, ``Site``, ``Sites``, ``APISurvey``, or iterable. ai_inverter : EMInverter1D or str or Path Pre-trained supervised inverter (fitted :class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`) or path to a saved ``.npz`` checkpoint. solver : {'mt1d', 'csamt1d'}, default 'mt1d' EM physics used in the refinement step. max_iter : int, default 200 Adam iterations for the physics refinement. smoothness_weight : float, default 0.005 Regularisation weight on log-resistivity first differences. lr : float, default 5e-3 Adam learning rate for Stage 2. device : str or None Torch device. Auto-detects CUDA/CPU if None. comp : {'xy', 'yx', 'xx', 'yy'}, default 'xy' Impedance component for observed data. n_freqs : int, default 32 Frequency-grid size for EMInverter1D input. recursive : bool, default True on_dup : str, default 'replace' verbose : int, default 0 """ def __init__( self, sites: Any, ai_inverter: Any | str | Path, *, solver: str = "mt1d", max_iter: int = 200, smoothness_weight: float = 0.005, lr: float = 5e-3, device: str | None = None, comp: str = "xy", n_freqs: int = 32, recursive: bool = True, on_dup: str = "replace", verbose: int = 0, ) -> None: if solver not in ("mt1d", "csamt1d"): raise ValueError( "solver must be 'mt1d' or 'csamt1d'; " f"got {solver!r}. TEM not supported." ) super().__init__(device=device) self.solver = solver self.max_iter = int(max_iter) self.smoothness_weight = float(smoothness_weight) self.lr = float(lr) self.comp = comp self.n_freqs = int(n_freqs) self.recursive = recursive self.on_dup = on_dup self.verbose = verbose self._ai_inv = self._load_ai_inverter(ai_inverter) self._obs: list[SiteObs1D] = sites_to_obs_1d( sites, comp=comp, recursive=recursive, on_dup=on_dup, strict=True, verbose=verbose, ) self._stage1: list[dict] = [] self._stage2: list[dict] = [] # ── fit ──
[docs] def fit( self, *, verbose: bool = True, log_every: int = 50, ) -> HybridInverter1D: """ Run both inversion stages. Parameters ---------- verbose : bool, default True Print per-station progress. log_every : int, default 50 Epoch-detail print frequency for Stage 2. Returns ------- self """ from pycsamt.api.view.progress import get_progress_bar self._require_backend() dev = self._resolve_device() # Stage 1: AI initial model init_params = self._run_stage1(verbose=verbose) # Stage 2: physics refinement _every = log_every if verbose else 0 self._stage2 = [] n_layers = self._ai_inv.n_layers with get_progress_bar( total=len(self._obs), desc="Stage 2 refinement", unit="station", verbose=verbose, ) as bar: for obs, params in zip(self._obs, init_params): # Unpack AI output: [log_rho..., log_thick...] init_lr = params[:n_layers] init_lt = params[n_layers:] res = fit_station( obs, n_layers=n_layers, depth_max=0.0, lam=self.smoothness_weight, lr=self.lr, epochs=self.max_iter, device=dev, log_every=_every, init_log_rho=init_lr, init_log_thick=init_lt, ) self._stage2.append(res) bar.set_description(f"Stage 2 refinement: {obs.name}") bar.update(1) self._is_fitted = True return self
# ── predict / diagnostics ──
[docs] def predict(self) -> list: """ Return Stage-2 refined layered models. Returns ------- list of LayeredModel """ self._check_fitted() return self._results_to_models(self._stage2)
[docs] def stage1_models(self) -> list: """ Return Stage-1 (AI-only) layered models. These are the AI starting points before physics refinement. Returns ------- list of LayeredModel """ if not self._stage1: raise RuntimeError("Call fit() to populate stage-1 models.") return self._results_to_models(self._stage1)
[docs] def convergence_curves(self): """ Return Stage-2 Adam loss history. Returns ------- pandas.DataFrame Columns: station, epoch, loss. """ self._check_fitted() import pandas as pd rows = [] for obs, res in zip(self._obs, self._stage2): for ep, val in enumerate(res["history"], start=1): rows.append( { "station": obs.name, "epoch": ep, "loss": val, } ) return pd.DataFrame(rows)
[docs] def residuals(self, stage: int = 2): """ Observed vs predicted data fit. Parameters ---------- stage : {1, 2}, default 2 Which stage's models to evaluate. 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 results = self._stage2 if stage == 2 else self._stage1 Fwd = MT1DForward if self.solver == "mt1d" else CSAMT1DForward rows = [] for obs, res in zip(self._obs, 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, "stage": stage, "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)
# ── 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 _load_ai_inverter(self, ai_inverter): """Load or validate the AI inverter.""" from .inv1d import EMInverter1D if isinstance(ai_inverter, EMInverter1D): if not ai_inverter._is_fitted: raise ValueError( "ai_inverter must be a fitted EMInverter1D instance." ) return ai_inverter if isinstance(ai_inverter, (str, Path)): return EMInverter1D.load(Path(ai_inverter)) raise TypeError( "ai_inverter must be a fitted " "EMInverter1D or a path to a checkpoint; " f"got {type(ai_inverter)!r}." ) def _run_stage1(self, *, verbose: bool) -> list[np.ndarray]: """ Apply EMInverter1D to all stations. Returns a list of parameter arrays with shape ``(2*n_layers-1,)`` per station (same format as :meth:`~EMInverter1D.predict` output rows). """ n_layers = self._ai_inv.n_layers n_features = self._ai_inv._n_features if n_features is None: raise RuntimeError( "ai_inverter._n_features is None. " "Re-fit or reload the checkpoint." ) # Derive n_freqs for the common grid # n_features = 2 * n_freqs (rho block + phase) _n_freqs = max(n_features // 2, 1) if verbose: print( "Stage 1: applying EMInverter1D to " f"{len(self._obs)} station(s) ..." ) # Use obs_to_features_1d to avoid running # ensure_sites on SiteObs1D dataclasses. X, _, _ = obs_to_features_1d(self._obs, n_freqs=_n_freqs) # predict returns (n_stations, 2*n_layers-1) params = self._ai_inv.predict(X, as_log_rho=True) # Store as stage1 results (compatible format) self._stage1 = [] for _i, row in enumerate(params): # row layout: [log_rho * n_layers, # log_thick * (n_layers-1)] log_rho_i = row[:n_layers] log_thick_i = row[n_layers:] self._stage1.append( { "log_rho": log_rho_i, "log_thick": log_thick_i, "history": [], } ) return list(params) def _results_to_models(self, results: list[dict]) -> list: """Convert result dicts to LayeredModel list.""" from pycsamt.forward.synthetic import LayeredModel models = [] for res in 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), ) except Exception: m = None models.append(m) return models def __repr__(self) -> str: status = "fitted" if self._is_fitted else "unfitted" return ( f"HybridInverter1D(" f"n_stations={self.n_sites}, " f"n_layers={self._ai_inv.n_layers}, " f"{status})" )