Source code for pycsamt.ai.inversion.inv2d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
EMInverter2D — high-level U-Net–based 2-D MT inversion pipeline.

Wraps :class:`~pycsamt.ai.nets.unet.UNet2DNet` with a complete
data-loading → normalisation → training → prediction workflow.

Input / output convention
-------------------------
* **Input** ``X``:
  ``ndarray (n_profiles, n_components, n_freqs, n_stations)`` — each
  entry is one profile of MT apparent-resistivity / phase maps.

* **Target** ``y``:
  ``ndarray (n_profiles, n_depth, n_stations)`` — 2-D log₁₀(ρ) sections.

Both ``n_freqs`` and ``n_depth`` may differ (the U-Net uses bilinear
upsampling); ``n_stations`` must match between ``X`` and ``y``.

Synthetic training data
-----------------------
When PyTorch is available and a 2-D pre-built dataset is not on hand,
use :func:`~pycsamt.forward.batch.generate_dataset` to build
pseudo-2-D profiles by running the 1-D MT forward solver independently
at each virtual station, then stack the per-station feature vectors into
the 2-D panel format expected here.

Example
-------
>>> from pycsamt.ai.inversion import EMInverter2D
>>> inv = EMInverter2D(n_components=4, n_depth=40, n_stations=20, n_freqs=32)
>>> inv.fit(X_train, y_train, epochs=30)              # doctest: +SKIP
EMInverter2D(arch='unet', fitted)
>>> rho_pred = inv.predict(X_test)                    # doctest: +SKIP
"""

from __future__ import annotations

import copy
from typing import Any

import numpy as np

from .._backend_utils import (
    active_backend,
    get_weights,
    resolve_device,
    set_weights,
)
from .._base import BaseEMNet

__all__ = ["EMInverter2D"]


# ─────────────────────────────────────────────────────────────────────────────
# EMInverter2D
# ─────────────────────────────────────────────────────────────────────────────


[docs] class EMInverter2D(BaseEMNet): """ U-Net–based 2-D MT inversion estimator. Parameters ---------- n_components : int, default 4 Number of input channels (EM data components per frequency and station). n_depth : int, default 40 Number of depth cells in the target resistivity section. n_stations : int, default 20 Number of stations along the profile. n_freqs : int, default 32 Number of frequency channels in the input data. arch : str, default ``'unet'`` Network architecture. Only ``'unet'`` is supported in Phase 4. device : str or None Compute device. log_rho_out : bool, default True If ``True``, targets and predictions are in log₁₀(ρ) scale. **net_kwargs Additional keyword arguments forwarded to the architecture factory (e.g. ``channels``, ``dropout``). """ # Base channel widths for each encoder stage (bridge appended adaptively) _BASE_CHANNELS: tuple[int, ...] = (32, 64, 128, 256, 512) def __init__( self, n_components: int = 4, n_depth: int = 40, n_stations: int = 20, n_freqs: int = 32, *, arch: str = "unet", unet_depth: int | None = None, channels: tuple[int, ...] | None = None, dropout: float = 0.2, device: str | None = None, log_rho_out: bool = True, **net_kwargs, ) -> None: super().__init__( arch=arch, n_layers=n_depth, solver="mt2d", device=device ) self.n_components = int(n_components) self.n_depth = int(n_depth) self.n_stations = int(n_stations) self.n_freqs = int(n_freqs) self.log_rho_out = bool(log_rho_out) self.dropout = float(dropout) self._net_kwargs = net_kwargs # Compute adaptive channel spec if not supplied explicitly self._channels = self._resolve_channels(channels, unet_depth) self._x_mean: float | None = None self._x_std: float | None = None self._y_mean: float | None = None self._y_std: float | None = None self._backend_name: str | None = None def _resolve_channels( self, channels: tuple[int, ...] | None, unet_depth: int | None, ) -> tuple[int, ...]: """Return channel tuple safe for current (n_freqs, n_stations).""" import math if channels is not None: return tuple(channels) # Max safe pooling depth: each MaxPool2d(2) halves both H and W. # We need min_dim / 2^depth >= 1, so depth <= log2(min_dim). min_dim = min(self.n_freqs, self.n_stations) max_safe = int(math.floor(math.log2(max(min_dim, 1)))) n_stages = max(1, min(max_safe, 4)) if unet_depth is not None: n_stages = max(1, min(int(unet_depth), max_safe)) # channels has n_stages encoder widths + 1 bridge width base = self._BASE_CHANNELS return base[:n_stages] + (base[min(n_stages, len(base) - 1)],) # ─── BaseEMNet interface ────────────────────────────────────────────── def _build_network(self) -> Any: from pycsamt.backends import get_backend_instance spec = { "arch": "unet2d", "n_in": self.n_components, "n_out": 1, "channels": self._channels, "dropout": self.dropout, **self._net_kwargs, } return get_backend_instance().build(spec)
[docs] def fit( self, X: np.ndarray, y: np.ndarray | None = None, *, epochs: int = 100, batch_size: int = 16, lr: float = 1e-3, patience: int = 15, val_frac: float = 0.1, grad_clip: float | None = 1.0, seed: int | None = None, verbose: bool = True, ) -> EMInverter2D: """ Train the 2-D inversion network. Parameters ---------- X : ndarray (n_profiles, n_components, n_freqs, n_stations) Input data panels. y : ndarray (n_profiles, n_depth, n_stations) Target 2-D log₁₀(ρ) sections. epochs, batch_size, lr, patience, val_frac, grad_clip, seed, verbose Standard training hyper-parameters. Returns ------- self """ X = np.asarray(X, dtype=np.float32) y = np.asarray(y, dtype=np.float32) self._x_mean = float(np.nanmean(X)) self._x_std = float(np.nanstd(X)) + 1e-8 self._y_mean = float(np.nanmean(y)) self._y_std = float(np.nanstd(y)) + 1e-8 Xn = (X - self._x_mean) / self._x_std yn = (y - self._y_mean) / self._y_std rng = np.random.default_rng(seed) n = len(Xn) idx = rng.permutation(n) n_val = max(1, int(n * val_frac)) vi, ti = idx[:n_val], idx[n_val:] self._backend_name = active_backend() self._network = self._build_network() if self._backend_name == "tensorflow": hist, best_val = self._fit_tensorflow( Xn[ti], yn[ti], Xn[vi], yn[vi], epochs=epochs, batch_size=batch_size, lr=lr, patience=patience, verbose=verbose, ) else: hist, best_val = self._fit_torch( Xn[ti], yn[ti], Xn[vi], yn[vi], epochs=epochs, batch_size=batch_size, lr=lr, patience=patience, grad_clip=grad_clip, verbose=verbose, ) self._history = hist self._meta["best_val_loss"] = float(best_val) self._is_fitted = True return self
# ─── internal training paths ────────────────────────────────────────── @staticmethod def _resize(pred, target_hw): """Bilinear-resize *pred* to (H, W) = *target_hw* when shapes differ.""" import torch.nn.functional as F if pred.shape[-2:] == target_hw: return pred return F.interpolate( pred, size=target_hw, mode="bilinear", align_corners=False ) def _fit_torch( self, Xtr, ytr, Xva, yva, *, epochs, batch_size, lr, patience, grad_clip, verbose, ): import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset # channels-first: (n, n_comp, n_freqs, n_sta) — already correct # target: add channel dim → (n, 1, n_depth, n_sta) ytr = ytr[:, np.newaxis, :, :] yva = yva[:, np.newaxis, :, :] target_hw = (ytr.shape[2], ytr.shape[3]) # (n_depth, n_stations) dev = resolve_device(self.device) self._network = self._network.to(dev) opt = torch.optim.Adam(self._network.parameters(), lr=lr) sched = torch.optim.lr_scheduler.ReduceLROnPlateau( opt, factor=0.5, patience=max(5, patience // 3), min_lr=1e-6 ) mse = nn.MSELoss() tr_ds = TensorDataset(torch.from_numpy(Xtr), torch.from_numpy(ytr)) Xva_t = torch.from_numpy(Xva).to(dev) yva_t = torch.from_numpy(yva).to(dev) best_val, best_state, no_improve = np.inf, None, 0 train_losses, val_losses = [], [] for ep in range(1, epochs + 1): self._network.train() ep_loss = 0.0 for xb, yb in DataLoader( tr_ds, batch_size=batch_size, shuffle=True ): xb, yb = xb.to(dev), yb.to(dev) pred = self._resize(self._network(xb), yb.shape[-2:]) loss = mse(pred, yb) opt.zero_grad() loss.backward() if grad_clip: nn.utils.clip_grad_norm_( self._network.parameters(), grad_clip ) opt.step() ep_loss += loss.item() * len(xb) ep_loss /= len(Xtr) self._network.eval() with torch.no_grad(): v_pred = self._resize(self._network(Xva_t), target_hw) v_loss = mse(v_pred, yva_t).item() sched.step(v_loss) train_losses.append(ep_loss) val_losses.append(v_loss) if v_loss < best_val - 1e-6: best_val = v_loss best_state = copy.deepcopy(self._network.state_dict()) no_improve = 0 else: no_improve += 1 if verbose and (ep % max(1, epochs // 10) == 0 or ep == 1): print( f" EMInverter2D ep {ep:>4d}/{epochs} " f"train={ep_loss:.5f} val={v_loss:.5f}" ) if no_improve >= patience: if verbose: print(f" Early stop at epoch {ep} (patience={patience})") break if best_state is not None: self._network.load_state_dict(best_state) return {"train_loss": train_losses, "val_loss": val_losses}, best_val def _fit_tensorflow( self, Xtr, ytr, Xva, yva, *, epochs, batch_size, lr, patience, verbose ): import tensorflow as tf # TF UNet2D expects channels-last: (n, n_freqs, n_sta, n_comp) Xtr_tf = Xtr.transpose(0, 2, 3, 1) # (n, n_freqs, n_sta, n_comp) Xva_tf = Xva.transpose(0, 2, 3, 1) # Resize targets to match UNet output (n_freqs, n_sta) via tf.image.resize target_size = [Xtr.shape[2], Xtr.shape[3]] # [n_freqs, n_sta] ytr_up = tf.image.resize( ytr[:, :, :, np.newaxis], target_size ).numpy() # (n, n_freqs, n_sta, 1) yva_up = tf.image.resize( yva[:, :, :, np.newaxis], target_size ).numpy() dev = resolve_device(self.device) with tf.device(dev): self._network.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=lr), loss="mse", ) hist = self._network.fit( Xtr_tf, ytr_up, validation_data=(Xva_tf, yva_up), epochs=epochs, batch_size=batch_size, callbacks=[ tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=patience, restore_best_weights=True, min_delta=1e-6, ), tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=max(5, patience // 3), min_lr=1e-6, ), ], verbose=1 if verbose else 0, ) best_val = min(hist.history["val_loss"]) return { "train_loss": hist.history["loss"], "val_loss": hist.history["val_loss"], }, best_val
[docs] def predict( self, X: np.ndarray, *, as_log_rho: bool = True, ) -> np.ndarray: """ Predict 2-D resistivity sections. Parameters ---------- X : ndarray (n_profiles, n_components, n_freqs, n_stations) as_log_rho : bool If ``True`` (default) output is log₁₀(ρ); otherwise linear ρ. Returns ------- rho_2d : ndarray (n_profiles, n_depth, n_stations) """ if not self._is_fitted: raise RuntimeError("Call fit() before predict().") X = np.asarray(X, dtype=np.float32) Xn = (X - self._x_mean) / self._x_std target_hw = (self.n_depth, self.n_stations) if self._backend_name == "tensorflow": import tensorflow as tf # channels-last: (n, n_freqs, n_sta, n_comp) → (n, n_depth, n_sta, 1) Xn_tf = Xn.transpose(0, 2, 3, 1) out_tf = self._network.predict( Xn_tf, verbose=0 ) # (n, ?, n_sta, 1) y_norm = out_tf.squeeze(-1) # (n, ?, n_sta) # Resize depth axis if UNet output height != n_depth if y_norm.shape[1] != self.n_depth: y_t = ( tf.image.resize( y_norm[:, :, :, np.newaxis], [self.n_depth, self.n_stations], ) .numpy() .squeeze(-1) ) y_norm = y_t else: import torch dev = next(self._network.parameters()).device self._network.eval() batch_size = 16 outs = [] for i in range(0, len(Xn), batch_size): xb = torch.from_numpy(Xn[i : i + batch_size]).to(dev) with torch.no_grad(): raw = self._network(xb) # (b, 1, n_freqs, n_sta) pred = self._resize(raw, target_hw).squeeze( 1 ) # (b, n_depth, n_sta) outs.append(pred.cpu().numpy()) y_norm = np.concatenate(outs, axis=0) y_log = y_norm * self._y_std + self._y_mean # (n, n_depth, n_sta) if as_log_rho: return y_log return 10.0**y_log
# ─── serialisation ──────────────────────────────────────────────────── def _get_params(self) -> dict[str, Any]: p = { "n_components": self.n_components, "n_depth": self.n_depth, "n_stations": self.n_stations, "n_freqs": self.n_freqs, "arch": self.arch, "channels": list(self._channels), "dropout": self.dropout, "device": self.device, "log_rho_out": self.log_rho_out, } p.update(self._net_kwargs) return p def _get_weights(self) -> dict[str, np.ndarray]: out: dict[str, np.ndarray] = {} if self._network is not None: out.update(get_weights(self._network)) for attr in ("_x_mean", "_x_std", "_y_mean", "_y_std"): val = getattr(self, attr, None) if val is not None: out[attr] = np.array([val]) if self._backend_name: out["_backend"] = np.array(self._backend_name) out["_channels"] = np.array(list(self._channels)) return out def _load_weights(self, weights: dict[str, np.ndarray]) -> None: backend_name = str(weights.pop("_backend", np.array("torch"))) self._backend_name = backend_name from pycsamt.backends import set_backend set_backend(backend_name) if "_channels" in weights: self._channels = tuple( int(c) for c in weights.pop("_channels").tolist() ) for attr in ("_x_mean", "_x_std", "_y_mean", "_y_std"): if attr in weights: setattr(self, attr, float(weights.pop(attr)[0])) self._network = self._build_network() set_weights(self._network, weights) self._is_fitted = True def __repr__(self) -> str: status = "fitted" if self._is_fitted else "unfitted" return f"EMInverter2D(arch={self.arch!r}, {status})"