# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
End-to-end 1-D EM inversion workflow.
:class:`EMInverter1D` is the primary user-facing estimator for 1-D
neural-network inversion of MT, CSAMT, and TEM data. It:
1. Loads a :class:`~pycsamt.forward.batch.ForwardDataset` (or a
``.npz`` path to one).
2. Normalises features and targets with z-score normalisation
(log₁₀ thickness transform applied automatically).
3. Instantiates the requested architecture
(``'cnn1d'``, ``'resnet'``, ``'fcn'``).
4. Trains with :class:`~pycsamt.ai.training.trainer.EMTrainer`
(early stopping, LR scheduling, masked MSE loss).
5. Predicts on new data as:
* numpy arrays
* lists of :class:`~pycsamt.z.z.Z` objects
* :class:`~pycsamt.forward.em1d.ForwardResponse` objects
The sklearn-compatible interface (``fit`` / ``predict`` / ``score``)
from :class:`~pycsamt.ai._base.BaseEMNet` is preserved.
Quick start
-----------
>>> import numpy as np
>>> from pycsamt.forward.batch import generate_dataset
>>> from pycsamt.ai.inversion.inv1d import EMInverter1D
# Generate 2 000 training samples
>>> ds = generate_dataset(n_samples=2_000, seed=0, n_layers=5)
# Train
>>> inv = EMInverter1D(arch="resnet", n_layers=5, solver="mt1d")
>>> inv.fit(ds, epochs=30, batch_size=128, verbose=True)
# Predict on a new forward response
>>> from pycsamt.forward import MT1DForward, LayeredModel
>>> m_new = LayeredModel.random(n_layers=5, seed=99)
>>> resp = MT1DForward(np.logspace(-3, 4, 30)).run(m_new)
>>> y_pred = inv.predict_response(resp) # → LayeredModel
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
from .._base import BaseEMNet, EMCheckpoint
__all__ = ["EMInverter1D"]
# ─────────────────────────────────────────────────────────────────────────────
# from_pretrained helper (module-level to avoid class-level redefinition)
# ─────────────────────────────────────────────────────────────────────────────
def _load_pretrained(name: str, cache_dir: str | None = None) -> EMInverter1D:
"""Download (if needed) and load a pre-trained checkpoint."""
from .._zoo import (
download_checkpoint,
get_pretrained_info,
)
get_pretrained_info(name) # validates name early
fpath = download_checkpoint(name, cache_dir=cache_dir)
return EMInverter1D.load(fpath)
# ─────────────────────────────────────────────────────────────────────────────
# EMInverter1D
# ─────────────────────────────────────────────────────────────────────────────
[docs]
class EMInverter1D(BaseEMNet):
"""
1-D EM neural-network inverter.
Supports MT, CSAMT (far-field), and TEM step-off data.
Parameters
----------
arch : {'resnet', 'cnn1d', 'fcn'}
Network architecture. ``'resnet'`` (Liu 2021 style) gives the
best accuracy on typical MT datasets. ``'cnn1d'`` (Puzyrev
style) is faster to train. ``'fcn'`` (Moghadas style) handles
variable input length.
n_layers : int
Number of earth layers to invert for.
solver : {'mt1d', 'csamt1d', 'tem1d'}
EM method this inverter targets (determines default frequency grid
for Z-object input coercion).
device : str or None
Compute device. Auto-detects CUDA/MPS/CPU if ``None``.
log_thickness : bool
Apply log₁₀ to thickness in training targets. Strongly
recommended when thicknesses span > 2 orders of magnitude.
include_phase : bool
Include impedance phase in the input feature vector for MT/CSAMT.
Setting to ``False`` halves the input size.
augment_noise : float
On-the-fly noise level added to training inputs each epoch.
net_kwargs : dict
Extra keyword arguments forwarded to the network constructor
(e.g. ``channels=(64, 128, 256)`` for ResNet).
"""
def __init__(
self,
arch: str = "resnet",
n_layers: int = 5,
solver: str = "mt1d",
*,
device: str | None = None,
log_thickness: bool = True,
include_phase: bool = True,
augment_noise: float = 0.02,
**net_kwargs,
):
super().__init__(
arch=arch, n_layers=n_layers, solver=solver, device=device
)
self.log_thickness = log_thickness
self.include_phase = include_phase
self.augment_noise = augment_noise
self._net_kwargs = net_kwargs
# Set after fit
self._em_dataset = None # EMDataset (holds normalizers)
self._n_features: int | None = None
self._n_out: int = 2 * n_layers - 1
# ─── fit ──────────────────────────────────────────────────────────────
[docs]
def fit(
self,
X,
y=None,
*,
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,
) -> EMInverter1D:
"""
Train the inverter on a :class:`~pycsamt.forward.batch.ForwardDataset`
or a ``.npz`` file path.
Parameters
----------
X : ForwardDataset or str or Path
Training data. If *y* is also given, *X* and *y* are treated
as raw feature / target numpy arrays.
y : ndarray or None
Raw targets (only when X is an ndarray).
epochs : int
Maximum training epochs.
batch_size : int
lr : float
Initial learning rate.
patience : int
Early-stopping patience.
val_frac : float
Fraction of training data used for validation.
grad_clip : float or None
Gradient-norm clipping threshold.
seed : int or None
Random seed for train/val split.
verbose : bool
Print per-epoch summary.
Returns
-------
self
"""
from pycsamt.backends import (
get_backend,
get_backend_instance,
)
from ..training.dataset import EMDataset
# ── Load data ────────────────────────────────────────────────────
ds = self._load_dataset(X, y)
# ── Build EMDataset (normalise) ──────────────────────────────────
em_ds = EMDataset(
ds,
n_layers=self.n_layers,
log_thickness=self.log_thickness,
augment_noise=self.augment_noise,
)
train_ds, val_ds = em_ds.split(val_frac=val_frac, seed=seed)
self._em_dataset = em_ds
self._n_features = em_ds.n_features
self._n_out = em_ds.n_params
# ── Build network ────────────────────────────────────────────────
self._network = self._build_network()
backend_name = get_backend()
self._meta["backend"] = backend_name
# ── Train ────────────────────────────────────────────────────────
if backend_name == "torch":
from ..training.trainer import EMTrainer
device = self._resolve_device()
trainer = EMTrainer(
self._network,
lr=lr,
patience=patience,
batch_size=batch_size,
device=device,
grad_clip=grad_clip,
verbose=verbose,
)
trainer.fit(train_ds, val_ds, epochs=epochs)
self._history = trainer.history
self._meta["best_epoch"] = trainer.best_epoch
self._meta["best_val_loss"] = trainer.best_val_loss
else:
be = get_backend_instance()
self._history = be.train(
self._network,
train_ds.X,
train_ds.y,
val_ds.X,
val_ds.y,
epochs=epochs,
batch_size=batch_size,
lr=lr,
patience=patience,
grad_clip=grad_clip,
verbose=verbose,
)
self._meta["n_features"] = self._n_features
self._meta["n_out"] = self._n_out
self._is_fitted = True
return self
# ─── predict ──────────────────────────────────────────────────────────
[docs]
def predict(
self,
X,
*,
as_log_rho: bool = True,
) -> np.ndarray:
"""
Predict model parameters for new data.
Parameters
----------
X : ndarray (n_samples, n_features) | Z | list of Z | ForwardResponse
Input data.
as_log_rho : bool
If ``True`` (default), the returned resistivity values are
in log₁₀(Ω·m) space matching the training targets.
If ``False``, resistivities are back-transformed to Ω·m.
Returns
-------
y_pred : ndarray, shape (n_samples, n_params)
Parameter vector: ``[log10(ρ), log10(h)]`` (or linear if
``as_log_rho=False``).
"""
self._check_fitted()
backend_name = self._meta.get("backend", "torch")
X_arr = self._coerce_input(
X, include_phase=self.include_phase, log_rho=True
)
X_norm = self._em_dataset.x_norm.transform(X_arr)
if backend_name == "torch":
import torch
device = self._resolve_device()
net = self._network.to(device).eval()
with torch.no_grad():
t = torch.from_numpy(X_norm.astype(np.float32)).to(device)
y_norm = net(t).cpu().numpy()
else:
from pycsamt.backends import get_backend_instance
y_norm = get_backend_instance().predict(self._network, X_norm)
# Inverse-normalise → [log10(ρ), log10(h)] or [log10(ρ), h]
y_raw = self._em_dataset.y_norm.inverse_transform(y_norm)
if not as_log_rho:
# Convert log10(ρ) → ρ for the first n_layers columns
n = self.n_layers
y_raw[:, :n] = 10.0 ** y_raw[:, :n]
if self.log_thickness:
y_raw[:, n:] = 10.0 ** y_raw[:, n:]
return y_raw
[docs]
def predict_models(self, X) -> list:
"""
Predict and return a list of :class:`~pycsamt.forward.synthetic.LayeredModel`.
Resistivity and thickness are back-transformed from log space.
"""
from pycsamt.forward.synthetic import LayeredModel
y_pred = self.predict(X, as_log_rho=True)
models = []
for row in y_pred:
n = self.n_layers
rho = 10.0 ** row[:n]
thick = 10.0 ** row[n:] if self.log_thickness else row[n:]
try:
m = LayeredModel(
resistivity=np.maximum(rho, 1e-3),
thickness=np.maximum(thick, 1.0),
)
except Exception:
m = None
models.append(m)
return models
[docs]
def predict_response(self, response) -> LayeredModel:
"""
Convenience: invert a single
:class:`~pycsamt.forward.em1d.ForwardResponse` and return the
predicted :class:`~pycsamt.forward.synthetic.LayeredModel`.
"""
X = self._coerce_input(
response, include_phase=self.include_phase, log_rho=True
)
models = self.predict_models(X)
return models[0]
# ─── serialisation ────────────────────────────────────────────────────
[docs]
def save(self, path: str | Path) -> None:
"""Save weights + normaliser + hyperparameters to *path*."""
from pycsamt.backends import get_backend_instance
weights = get_backend_instance().get_weights(self._network)
meta = dict(self._meta)
if self._em_dataset is not None:
meta["x_norm"] = self._em_dataset.x_norm.to_dict()
meta["y_norm"] = self._em_dataset.y_norm.to_dict()
meta["n_layers_ds"] = self._em_dataset._n_layers
meta["log_thickness"] = self._em_dataset._log_thickness
ckpt = EMCheckpoint(
params=self._get_params(),
weights=weights,
history=self._history,
meta=meta,
)
ckpt.save(path)
[docs]
@classmethod
def load(cls, path: str | Path) -> EMInverter1D:
"""Load a saved inverter from *path*."""
from pycsamt.backends import (
get_backend_instance,
set_backend,
)
from ..training.dataset import EMDataset, Normalizer
ckpt = EMCheckpoint.load(path)
p = ckpt.params
obj = cls(
arch=p["arch"],
n_layers=p["n_layers"],
solver=p["solver"],
device=p["device"],
log_thickness=p.get("log_thickness", True),
include_phase=p.get("include_phase", True),
)
obj._n_features = ckpt.meta.get("n_features")
obj._n_out = ckpt.meta.get("n_out", 2 * obj.n_layers - 1)
# Restore normalisers
if "x_norm" in ckpt.meta:
obj._em_dataset = EMDataset.__new__(EMDataset)
obj._em_dataset.x_norm = Normalizer.from_dict(ckpt.meta["x_norm"])
obj._em_dataset.y_norm = Normalizer.from_dict(ckpt.meta["y_norm"])
obj._em_dataset._n_layers = ckpt.meta.get("n_layers_ds")
obj._em_dataset._log_thickness = ckpt.meta.get(
"log_thickness", True
)
# Restore backend, rebuild network, and load weights
backend_name = ckpt.meta.get("backend", "torch")
set_backend(backend_name)
obj._network = obj._build_network()
get_backend_instance().set_weights(obj._network, ckpt.weights)
obj._history = ckpt.history
obj._meta = ckpt.meta
obj._is_fitted = bool(ckpt.weights)
return obj
# ─── model zoo ────────────────────────────────────────────────────────
[docs]
@classmethod
def from_pretrained(
cls,
name: str,
*,
cache_dir: str | None = None,
) -> EMInverter1D:
"""
Load a pre-trained model from the pycsamt model zoo.
Pre-trained weights are hosted at
https://github.com/earthai-tech/pycsamt-models
and are downloaded to ``~/.pycsamt/model_zoo/`` on first call.
Parameters
----------
name : str
Model identifier. Call
:func:`~pycsamt.ai._zoo.list_pretrained` to see available
models.
cache_dir : str or None
Override the default download directory.
Returns
-------
EMInverter1D
Raises
------
KeyError
If *name* is not in the model zoo registry.
RuntimeError
If the download fails (weights not yet publicly available).
Examples
--------
>>> from pycsamt.ai.inversion import EMInverter1D
>>> inv = EMInverter1D.from_pretrained("mt1d-resnet-5layer-v1") # doctest: +SKIP
"""
return _load_pretrained(name, cache_dir=cache_dir)
# ─── BaseEMNet hooks ──────────────────────────────────────────────────
def _build_network(self):
"""Instantiate the network via the active backend."""
if self._n_features is None:
raise RuntimeError(
"Cannot build network before n_features is known. "
"Call fit() first (n_features is inferred from training data)."
)
from pycsamt.backends import get_backend_instance
spec = {
"arch": self.arch,
"n_features": self._n_features,
"n_out": self._n_out,
**self._net_kwargs,
}
return get_backend_instance().build(spec)
def _get_params(self) -> dict:
p = super()._get_params()
p["log_thickness"] = self.log_thickness
p["include_phase"] = self.include_phase
p["augment_noise"] = self.augment_noise
return p
# ─── internals ────────────────────────────────────────────────────────
def _load_dataset(self, X, y):
"""Return a ForwardDataset from X (which may be a path, a ds, or arrays)."""
from pycsamt.forward.batch import ForwardDataset
if isinstance(X, ForwardDataset):
return X
if isinstance(X, (str, Path)):
return ForwardDataset.load(str(X))
if isinstance(X, np.ndarray):
if y is None:
raise ValueError(
"When X is a numpy array, y must also be provided."
)
n = len(X)
meta = np.zeros(
n, dtype=[("n_layers", "i4"), ("noise_level", "f4")]
)
meta["n_layers"] = self.n_layers
return ForwardDataset(
X=X.astype(np.float32),
y=y.astype(np.float32),
meta=meta,
solver=self.solver,
)
raise TypeError(
f"X must be a ForwardDataset, path, or ndarray; got {type(X)!r}"
)
def _check_fitted(self):
if not self._is_fitted or self._network is None:
raise RuntimeError("Inverter not fitted. Call fit() first.")