Source code for pycsamt.ai.inversion.joint

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
JointInverter — multi-modal / multi-physics joint inversion.

Uses the :class:`~pycsamt.ai.nets.drcnn.DRCNNNet` architecture to fuse
two or more EM datasets (or EM + non-EM methods) into a single
subsurface model prediction.

Supported combinations
----------------------
* MT + TEM — complementary depth sensitivity
* MT + CSAMT — frequency overlap to constrain near-surface
* MT + gravity or seismic — cross-property constraints

Joint training
--------------
All modalities must be observed at the same sites.  Use
:func:`~pycsamt.forward.batch.generate_dataset` with different
``solver`` values and the same :class:`~pycsamt.forward.synthetic.LayeredModel`
to generate correlated synthetic datasets for supervised training.

Example
-------
>>> from pycsamt.ai.inversion import JointInverter
>>> inv = JointInverter(n_features_list=(120, 48), n_layers=5)
>>> inv.fit([X_mt, X_tem], y, epochs=50)              # doctest: +SKIP
JointInverter(modalities=2, fitted)
>>> y_pred = inv.predict([X_mt_test, X_tem_test])     # doctest: +SKIP
"""

from __future__ import annotations

import copy
from collections.abc import Sequence
from typing import Any

import numpy as np

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

__all__ = ["JointInverter"]


[docs] class JointInverter(BaseEMNet): """ Multi-modal joint inversion estimator based on DRCNN. Parameters ---------- n_features_list : sequence of int Feature vector lengths for each modality. E.g. ``(120, 48)`` for two modalities. n_layers : int, default 5 Number of earth layers to invert for. growth_rate : int, default 32 Dense block growth rate. n_dense_layers : int, default 6 Sub-layers per dense block. hidden_dim : int, default 256 Encoded-feature dimension for each modality and fusion stage. dropout : float, default 0.2 device : str or None log_thickness : bool, default True Apply log₁₀ transform to thickness targets before normalisation. **net_kwargs Forwarded to :class:`~pycsamt.ai.nets.drcnn.DRCNNNet`. """ def __init__( self, n_features_list: Sequence[int], n_layers: int = 5, *, growth_rate: int = 32, n_dense_layers: int = 6, hidden_dim: int = 256, dropout: float = 0.2, device: str | None = None, log_thickness: bool = True, **net_kwargs, ) -> None: super().__init__( arch="drcnn", n_layers=n_layers, solver="joint", device=device ) self.n_features_list = tuple(int(n) for n in n_features_list) self.growth_rate = int(growth_rate) self.n_dense_layers = int(n_dense_layers) self.hidden_dim = int(hidden_dim) self.dropout = float(dropout) self.log_thickness = bool(log_thickness) self._net_kwargs = net_kwargs self._n_out = 2 * n_layers - 1 # n_layers rho + (n_layers-1) thick self._x_norms: list[Normalizer | None] = [None] * len(n_features_list) self._y_norm: Normalizer | None = None self._backend_name: str | None = None # ─── BaseEMNet interface ────────────────────────────────────────────── def _build_network(self) -> Any: from pycsamt.backends import get_backend_instance spec = { "arch": "drcnn", "n_features_list": list(self.n_features_list), "n_out": self._n_out, "growth_rate": self.growth_rate, "n_dense_layers": self.n_dense_layers, "hidden_dim": self.hidden_dim, "dropout": self.dropout, **self._net_kwargs, } return get_backend_instance().build(spec)
[docs] def fit( self, X_list: Sequence[np.ndarray], y: np.ndarray, *, epochs: int = 100, batch_size: int = 256, lr: float = 1e-3, patience: int = 20, val_frac: float = 0.1, grad_clip: float | None = 1.0, seed: int | None = None, verbose: bool = True, ) -> JointInverter: """ Train the joint inverter. Parameters ---------- X_list : list of ndarray Feature matrices for each modality, each ``(n_samples, n_features_i)``. Lengths must match. y : ndarray (n_samples, 2*n_layers-1) Target model parameters: first ``n_layers`` columns are log₁₀(ρ) or ρ; last ``n_layers-1`` columns are thicknesses. epochs, batch_size, lr, patience, val_frac, grad_clip, seed, verbose Standard training hyper-parameters. Returns ------- self """ X_arrs = [np.asarray(Xm, dtype=np.float32) for Xm in X_list] y_arr = np.asarray(y, dtype=np.float32) self._x_norms = [] X_normed = [] for Xm in X_arrs: norm = Normalizer().fit(Xm) self._x_norms.append(norm) X_normed.append(norm.transform(Xm).astype(np.float32)) y_proc = y_arr.copy() if self.log_thickness and y_proc.shape[1] > self.n_layers: y_proc[:, self.n_layers :] = np.log10( np.maximum(y_proc[:, self.n_layers :], 1e-3) ) self._y_norm = Normalizer().fit(y_proc) yn = self._y_norm.transform(y_proc).astype(np.float32) rng = np.random.default_rng(seed) n = len(yn) 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( X_normed, yn, ti, vi, epochs=epochs, batch_size=batch_size, lr=lr, patience=patience, verbose=verbose, ) else: dev = resolve_device(self.device) self._network = self._network.to(dev) hist, best_val = self._fit_torch( X_normed, yn, ti, 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 ────────────────────────────────────────── def _fit_torch( self, X_normed, yn, ti, vi, *, epochs, batch_size, lr, patience, grad_clip, verbose, ): import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset dev = next(self._network.parameters()).device 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_tensors = [torch.from_numpy(Xm[ti]) for Xm in X_normed] tr_tensors.append(torch.from_numpy(yn[ti])) tr_ds = TensorDataset(*tr_tensors) va_inputs = [torch.from_numpy(Xm[vi]).to(dev) for Xm in X_normed] yva = torch.from_numpy(yn[vi]).to(dev) best_val, best_state = np.inf, None train_losses, val_losses = [], [] no_improve = 0 for ep in range(1, epochs + 1): self._network.train() ep_loss = 0.0 for *xbs, yb in DataLoader( tr_ds, batch_size=batch_size, shuffle=True ): xbs = [t.to(dev) for t in xbs] yb = yb.to(dev) pred = self._network(*xbs) 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(yb) ep_loss /= len(ti) self._network.eval() with torch.no_grad(): v_loss = mse(self._network(*va_inputs), yva).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" JointInverter 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}") 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, X_normed, yn, ti, vi, *, epochs, batch_size, lr, patience, verbose, ): import tensorflow as tf Xtr_list = [Xm[ti] for Xm in X_normed] Xva_list = [Xm[vi] for Xm in X_normed] yn_tr, yn_va = yn[ti], yn[vi] 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_list, yn_tr, validation_data=(Xva_list, yn_va), 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_list: Sequence[np.ndarray] | np.ndarray, *, as_log_rho: bool = True, ) -> np.ndarray: """ Predict subsurface model parameters. Parameters ---------- X_list : list of ndarray, each (n_samples, n_features_i) as_log_rho : bool If ``True`` (default), resistivity columns are returned in log₁₀(ρ); otherwise linear. Returns ------- y_pred : ndarray (n_samples, 2*n_layers-1) """ if not self._is_fitted: raise RuntimeError("Call fit() before predict().") if isinstance(X_list, np.ndarray): X_list = [X_list] X_normed = [ norm.transform(np.asarray(Xm, dtype=np.float32)) for norm, Xm in zip(self._x_norms, X_list) ] if self._backend_name == "tensorflow": y_norm = self._network.predict(X_normed, verbose=0) else: import torch dev = next(self._network.parameters()).device self._network.eval() n = len(X_normed[0]) outs = [] for i in range(0, n, 256): inputs = [ torch.from_numpy(Xm[i : i + 256]).to(dev) for Xm in X_normed ] with torch.no_grad(): outs.append(self._network(*inputs).cpu().numpy()) y_norm = np.concatenate(outs, axis=0) y_pred = self._y_norm.inverse_transform(y_norm) if self.log_thickness and y_pred.shape[1] > self.n_layers: y_pred[:, self.n_layers :] = 10.0 ** y_pred[:, self.n_layers :] if not as_log_rho: y_pred[:, : self.n_layers] = 10.0 ** y_pred[:, : self.n_layers] return y_pred
# ─── serialisation ──────────────────────────────────────────────────── def _get_params(self) -> dict[str, Any]: return { "n_features_list": list(self.n_features_list), "n_layers": self.n_layers, "growth_rate": self.growth_rate, "n_dense_layers": self.n_dense_layers, "hidden_dim": self.hidden_dim, "dropout": self.dropout, "device": self.device, "log_thickness": self.log_thickness, } 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 i, norm in enumerate(self._x_norms): if norm is not None: d = norm.to_dict() out[f"_xnorm_{i}_mean"] = np.array(d["mean"]) out[f"_xnorm_{i}_std"] = np.array(d["std"]) if self._y_norm is not None: d = self._y_norm.to_dict() out["_ynorm_mean"] = np.array(d["mean"]) out["_ynorm_std"] = np.array(d["std"]) if self._backend_name: out["_backend"] = np.array(self._backend_name) 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) self._x_norms = [] for i in range(len(self.n_features_list)): km, ks = f"_xnorm_{i}_mean", f"_xnorm_{i}_std" if km in weights: norm = Normalizer() norm.mean = weights.pop(km) norm.std = weights.pop(ks) self._x_norms.append(norm) else: self._x_norms.append(None) if "_ynorm_mean" in weights: self._y_norm = Normalizer() self._y_norm.mean = weights.pop("_ynorm_mean") self._y_norm.std = weights.pop("_ynorm_std") self._network = self._build_network() set_weights(self._network, weights) self._is_fitted = True def __repr__(self) -> str: n = len(self.n_features_list) status = "fitted" if self._is_fitted else "unfitted" return f"JointInverter(modalities={n}, {status})"