Source code for pycsamt.ai.inversion.hybrid2d

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

:class:`HybridInverter2D` runs a two-stage workflow:

**Stage 1 — AI initial 2-D section**

A pre-trained
:class:`~pycsamt.ai.inversion.inv2d.EMInverter2D`
maps the EM profile panel directly to a 2-D
resistivity section::

    X_panel --> EMInverter2D --> rho_2d_0

**Stage 2 — Joint physics refinement**

Starting from ``rho_2d_0``, the same joint
physics-informed optimisation used by
:class:`~pycsamt.ai.inversion.pinn2d.PINNInverter2D`
refines all stations simultaneously:

.. math::

    \mathcal{L}(\theta) =
    \mathcal{L}_{\rm data}
    + \lambda_z \mathcal{L}_{\rm vert}
    + \lambda_x \mathcal{L}_{\rm lat}

Because the AI starting model is physically
plausible, Stage 2 converges significantly faster
than a randomly initialised PINN.

Example
-------
>>> from pycsamt.ai.inversion import (
...     EMInverter2D,
...     HybridInverter2D,
... )
>>> ai2d = EMInverter2D.load(  # doctest: +SKIP
...     "checkpoints/unet2d.npz"
... )
>>> inv = HybridInverter2D(  # doctest: +SKIP
...     "edi/profile1/",
...     ai_inverter=ai2d,
...     epochs=150,
...     smoothness_weight=0.005,
... )
>>> inv.fit()  # doctest: +SKIP
HybridInverter2D(n_stations=20, fitted)
>>> section = inv.resistivity_section()  # doctest: +SKIP
>>> s1 = inv.stage1_section()  # 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_2d_joint
from ._sites_bridge import (
    SiteObs2D,
    _interp_to_grid,
    _make_common_grid,
    sites_to_obs_2d,
    sites_to_panel_2d,
)

__all__ = ["HybridInverter2D"]


# ── HybridInverter2D ──


[docs] class HybridInverter2D(BaseHybridInverter): r""" Two-stage hybrid AI + physics 2-D inversion. Parameters ---------- sites : Any Path, ``EDIFile``, ``EDICollection``, ``Site``, ``Sites``, ``APISurvey``, or iterable. ai_inverter : EMInverter2D or str or Path Pre-trained :class:`~pycsamt.ai.inversion.inv2d.EMInverter2D` (fitted) or path to a ``.npz`` checkpoint. n_layers : int or None Layers per station in Stage 2. Defaults to ``ai_inverter.n_depth``. depth_max : float, default 2000.0 Total depth for uniform thickness init when Stage 1 does not provide thicknesses. n_freqs : int, default 32 Frequency-grid size for the panel and the shared optimisation grid. mode : {'te', 'tm', 'both'}, default 'te' Data polarisation used in Stage 2. smoothness_weight : float, default 0.005 Vertical smoothness weight. lateral_weight : float, default 0.003 Lateral smoothness weight. epochs : int, default 150 Adam iterations for Stage 2. lr : float, default 5e-3 Adam learning rate for Stage 2. comp_te : str, default ``'xy'`` comp_tm : str, default ``'yx'`` device : str or None recursive : bool, default True on_dup : str, default ``'replace'`` verbose : int, default 0 """ def __init__( self, sites: Any, ai_inverter: Any | str | Path, *, n_layers: int | None = None, depth_max: float = 2000.0, n_freqs: int = 32, mode: str = "te", smoothness_weight: float = 0.005, lateral_weight: float = 0.003, epochs: int = 150, lr: float = 5e-3, comp_te: str = "xy", comp_tm: str = "yx", device: str | None = None, recursive: bool = True, on_dup: str = "replace", verbose: int = 0, ) -> None: if mode not in ("te", "tm", "both"): raise ValueError( f"mode must be 'te', 'tm', or 'both'; got {mode!r}." ) super().__init__( depth_max=depth_max, device=device, ) self.n_freqs = int(n_freqs) self.mode = mode self.smoothness_weight = float(smoothness_weight) self.lateral_weight = float(lateral_weight) self.epochs = int(epochs) self.lr = float(lr) self.comp_te = comp_te self.comp_tm = comp_tm self.verbose = verbose self._ai_inv = self._load_ai_inverter(ai_inverter) self.n_layers = int( n_layers if n_layers is not None else self._ai_inv.n_depth ) self._obs: list[SiteObs2D] = sites_to_obs_2d( sites, comp_te=comp_te, comp_tm=comp_tm, recursive=recursive, on_dup=on_dup, verbose=verbose, ) self._freqs_grid = _make_common_grid( [o.freq for o in self._obs], n_freqs=n_freqs, ) self._stage1_log_rho: np.ndarray | None = None self._result: dict | None = None # ── fit ──
[docs] def fit( self, *, verbose: bool = True, log_every: int = 50, ) -> HybridInverter2D: """ Run both inversion stages. Parameters ---------- verbose : bool, default True log_every : int, default 50 Returns ------- self """ self._require_backend() dev = self._resolve_device() # Stage 1: AI initial 2-D section if verbose: print("Stage 1: EMInverter2D forward pass ...") init_log_rho = self._run_stage1(verbose=verbose) # Uniform initial thicknesses dz = max(self.depth_max / self.n_layers, 1.0) init_log_thick = np.full( (len(self._obs), self.n_layers - 1), np.log10(dz), ) # Build observed arrays for Stage 2 lr_obs, ph_obs = self._build_obs_arrays() if verbose: S = len(self._obs) print( f"Stage 2: joint physics refinement " f"({S} stations, {self.n_layers} " f"layers, {self.epochs} epochs) ..." ) _every = log_every if verbose else 0 self._result = fit_2d_joint( lr_obs, ph_obs, self._freqs_grid, n_layers=self.n_layers, depth_max=self.depth_max, lam_z=self.smoothness_weight, lam_x=self.lateral_weight, lr=self.lr, epochs=self.epochs, device=dev, log_every=_every, init_log_rho=init_log_rho, init_log_thick=init_log_thick, verbose=verbose, ) self._history = self._result["history"] self._is_fitted = True return self
# ── outputs ──
[docs] def resistivity_section(self, *, as_log10: bool = True) -> np.ndarray: """ Return the Stage-2 2-D resistivity section. Parameters ---------- as_log10 : bool, default True Returns ------- ndarray (n_layers, n_stations) """ self._check_fitted() lr = self._result["log_rho"] # (S, L) section = lr.T # (L, S) if as_log10: return section return 10.0**section
[docs] def thickness_section(self) -> np.ndarray: """ Return Stage-2 layer thicknesses in metres. Returns ------- ndarray (n_layers-1, n_stations) """ self._check_fitted() lt = self._result["log_thick"] # (S, L-1) return (10.0**lt).T # (L-1, S)
[docs] def stage1_section(self, *, as_log10: bool = True) -> np.ndarray: """ Return the Stage-1 AI 2-D section. Parameters ---------- as_log10 : bool, default True Returns ------- ndarray (n_layers, n_stations) """ if self._stage1_log_rho is None: raise RuntimeError("Call fit() to populate Stage-1 results.") s = self._stage1_log_rho.T # (L, S) if as_log10: return s return 10.0**s
[docs] def convergence_curve(self): """ Return Stage-2 Adam loss history. Returns ------- pandas.DataFrame Columns: epoch, loss. """ self._check_fitted() import pandas as pd return pd.DataFrame( { "epoch": range( 1, len(self._result["history"]) + 1, ), "loss": self._result["history"], } )
[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 MT1DForward from pycsamt.forward.synthetic import LayeredModel if stage == 1: if self._stage1_log_rho is None: raise RuntimeError("Call fit() first.") lr_2d = self._stage1_log_rho # (S, L) dz = max(self.depth_max / self.n_layers, 1.0) lt_2d = np.full( (len(self._obs), self.n_layers - 1), np.log10(dz), ) else: lr_2d = self._result["log_rho"] lt_2d = self._result["log_thick"] rows = [] for i, obs in enumerate(self._obs): try: m = LayeredModel( resistivity=np.maximum(10.0 ** lr_2d[i], 1e-3), thickness=np.maximum(10.0 ** lt_2d[i], 1.0), ) resp = MT1DForward(obs.freq).run(m) rp = resp.rho_a pp = resp.phase except Exception: rp = np.full_like(obs.freq, np.nan) pp = np.full_like(obs.freq, np.nan) rho_obs = obs.rho_te if self.mode in ("te", "both") else obs.rho_tm ph_obs = ( obs.phase_te if self.mode in ("te", "both") else obs.phase_tm ) for k in range(len(obs.freq)): rows.append( { "station": obs.name, "stage": stage, "freq": obs.freq[k], "rho_obs": rho_obs[k], "rho_pred": rp[k], "phase_obs": ph_obs[k], "phase_pred": pp[k], } ) return pd.DataFrame(rows)
# ── read-only properties ──
[docs] @property def stations(self) -> list[str]: """Station names in profile 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): from .inv2d import EMInverter2D if isinstance(ai_inverter, EMInverter2D): if not ai_inverter._is_fitted: raise ValueError( "ai_inverter must be a fitted EMInverter2D instance." ) return ai_inverter if isinstance(ai_inverter, (str, Path)): return EMInverter2D.load(Path(ai_inverter)) raise TypeError( "ai_inverter must be a fitted " "EMInverter2D or a path to a checkpoint; " f"got {type(ai_inverter)!r}." ) def _run_stage1(self, *, verbose: bool) -> np.ndarray: """ Apply EMInverter2D to extract an initial 2-D section. Returns ------- init_log_rho : ndarray (S, n_layers) Per-station log10(rho) initialisation. """ ai = self._ai_inv S = len(self._obs) n_ch = ai.n_components n_f = ai.n_freqs n_sta_ai = ai.n_stations # Build the input panel with obs n_stations, # then resize to ai.n_stations if needed. panel, _, _ = sites_to_panel_2d( self._obs, n_freqs=n_f, n_components=n_ch, comp_te=self.comp_te, comp_tm=self.comp_tm, ) # panel: (1, n_ch, n_f, S) if panel.shape[3] != n_sta_ai: # Spatially resample to match AI model panel = _resize_panel_stations(panel, n_sta_ai) # Fill NaN with column median before predict panel = _fill_nan_panel(panel) # rho_2d: (1, n_depth, n_sta_ai) in log10 rho_2d = ai.predict( panel.astype(np.float32), as_log_rho=True, ) rho_2d = rho_2d[0] # (n_depth, n_sta_ai) # Resample back to actual station count if n_sta_ai != S: rho_2d = _resize_section_stations(rho_2d, S) # rho_2d: (n_depth, S) # Clip to n_layers depth levels n_depth = rho_2d.shape[0] take = min(n_depth, self.n_layers) init_lr = np.full((S, self.n_layers), 2.0) init_lr[:, :take] = rho_2d[:take, :].T if take < self.n_layers: # Pad deeper layers with deepest value init_lr[:, take:] = init_lr[:, take - 1 : take] self._stage1_log_rho = init_lr return init_lr def _build_obs_arrays( self, ) -> tuple[np.ndarray, np.ndarray]: S = len(self._obs) F = len(self._freqs_grid) lr_obs = np.full((S, F), np.nan) ph_obs = np.full((S, F), np.nan) for i, o in enumerate(self._obs): if self.mode == "te": rho_src = o.rho_te ph_src = o.phase_te elif self.mode == "tm": rho_src = o.rho_tm ph_src = o.phase_tm else: rho_src = 0.5 * (o.rho_te + o.rho_tm) ph_src = 0.5 * (o.phase_te + o.phase_tm) lr_g, ph_g = _interp_to_grid( o.freq, rho_src, ph_src, self._freqs_grid, ) lr_obs[i] = lr_g ph_obs[i] = ph_g return lr_obs, ph_obs def __repr__(self) -> str: status = "fitted" if self._is_fitted else "unfitted" return ( f"HybridInverter2D(" f"n_stations={self.n_sites}, " f"n_layers={self.n_layers}, " f"{status})" )
# ── panel resize helpers ── def _resize_panel_stations( panel: np.ndarray, n_out: int, ) -> np.ndarray: """ Bilinearly resample the station axis of a panel. Parameters ---------- panel : ndarray (1, C, F, S_in) n_out : int Returns ------- ndarray (1, C, F, n_out) """ from scipy.ndimage import zoom _, C, F, S_in = panel.shape if S_in == n_out: return panel factor = n_out / S_in out = np.empty((1, C, F, n_out), dtype=panel.dtype) for c in range(C): # zoom over (F, S) axis slc = panel[0, c] # (F, S_in) out[0, c] = zoom( slc, (1.0, factor), order=1, mode="nearest", ) return out def _resize_section_stations( section: np.ndarray, n_out: int, ) -> np.ndarray: """ Bilinearly resample the station axis of a section. Parameters ---------- section : ndarray (n_depth, S_in) n_out : int Returns ------- ndarray (n_depth, n_out) """ from scipy.ndimage import zoom n_depth, S_in = section.shape if S_in == n_out: return section factor = n_out / S_in return zoom( section, (1.0, factor), order=1, mode="nearest", ) def _fill_nan_panel( panel: np.ndarray, ) -> np.ndarray: """ Replace NaN in each ``(F,)`` freq column with the channel's finite mean; fall back to 0 if all NaN. Parameters ---------- panel : ndarray (1, C, F, S) Returns ------- ndarray same shape, no NaN. """ out = panel.copy() _, C, F, S = out.shape for c in range(C): for s in range(S): col = out[0, c, :, s] mask = ~np.isfinite(col) if mask.any(): finite = col[~mask] fill = float(finite.mean()) if finite.size > 0 else 0.0 col[mask] = fill return out