# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
GCNInverter3D — graph-convolutional 3-D MT spatial inversion.
Exploits inter-station spatial relationships by representing the survey
network as a graph whose adjacency encodes station proximity. Each
node receives features from its neighbours via Kipf & Welling (2017)
spectral graph convolutions before predicting a per-station 1-D
subsurface model, yielding a spatially consistent 3-D resistivity image.
Input / output convention
--------------------------
* **Input** ``X``: ``ndarray (n_samples, n_stations, n_features)`` or
``(n_stations, n_features)`` for a single survey.
Typical features: ``[log10(rho_a_f1), phi_f1, …, log10(rho_a_fK), phi_fK]``
giving ``n_features = 2 × K``.
* **Target** ``y``: ``ndarray (n_samples, n_stations, 2*n_layers-1)`` —
per-station model parameters (log10(ρ) for each layer concatenated with
log10(thickness) for each interface).
* **Adjacency** ``A``: ``ndarray (n_stations, n_stations)`` — symmetric
normalised adjacency :math:`\\tilde{D}^{-1/2}\\tilde{A}\\tilde{D}^{-1/2}`.
Build from station coordinates with
:func:`~pycsamt.ai.nets.gcn.build_adjacency`.
Message-passing rule (Kipf & Welling 2017)
------------------------------------------
.. math::
H^{(l+1)} = \\sigma\\!\\bigl(
\\tilde{D}^{-1/2}\\tilde{A}\\tilde{D}^{-1/2}\\,
H^{(l)} W^{(l)}\\bigr)
where :math:`\\tilde{A} = A + I` (self-loops) and
:math:`\\tilde{D}_{ii} = \\sum_j \\tilde{A}_{ij}`.
References
----------
.. [1] Kipf, T. N. & Welling, M. (2017). Semi-supervised classification
with graph convolutional networks. *ICLR 2017*.
Example
-------
>>> import numpy as np
>>> from pycsamt.ai.nets.gcn import build_adjacency
>>> from pycsamt.ai.inversion.inv3d import GCNInverter3D
>>> rng = np.random.default_rng(0)
>>> coords = rng.uniform(0, 10_000, (30, 2))
>>> A = build_adjacency(coords, radius=3_000)
>>> X = rng.standard_normal((200, 30, 40)).astype("f4")
>>> y = rng.standard_normal((200, 30, 9)).astype("f4")
>>> inv = GCNInverter3D(n_features=40, n_layers=5)
>>> inv.fit(X, y, adjacency=A, epochs=5, verbose=False) # doctest: +SKIP
GCNInverter3D(n_features=40, n_layers=5, ..., n_stations=30, fitted)
"""
from __future__ import annotations
import copy
import warnings
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
__all__ = ["GCNInverter3D"]
[docs]
class GCNInverter3D(BaseEMNet):
"""
Graph-convolutional 3-D MT inversion estimator.
Models the spatial context of a multi-station survey as a graph and
applies spectral GCN message-passing before regressing per-station
1-D subsurface models. The result is a spatially coherent 3-D
resistivity volume without any external graph library dependency.
Parameters
----------
n_features : int, default 40
Per-station input feature dimension. A typical choice is
``2 × n_freqs`` (log10(ρ_a) + phase at each frequency).
n_layers : int, default 5
Number of depth layers per station. Output size per station is
``2 * n_layers - 1`` (n_layers log10(ρ) + (n_layers-1) log10(h)).
hidden : sequence of int, default (256, 128, 64)
Width of each GCN message-passing layer.
dropout : float, default 0.1
Dropout probability applied between GCN layers during training.
device : str or None
Compute device (``'cpu'``, ``'cuda'``, ``'gpu:0'``, …).
``None`` auto-detects.
**net_kwargs
Extra keyword arguments forwarded to
:class:`~pycsamt.ai.nets.gcn.GCNNet`.
"""
def __init__(
self,
n_features: int = 40,
n_layers: int = 5,
hidden: Sequence[int] = (256, 128, 64),
dropout: float = 0.1,
device: str | None = None,
**net_kwargs,
) -> None:
super().__init__(
arch="gcn", n_layers=n_layers, solver="mt3d", device=device
)
self.n_features = int(n_features)
self.n_out = 2 * n_layers - 1
self.hidden = tuple(int(h) for h in hidden)
self.dropout = float(dropout)
self._net_kwargs = net_kwargs
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
self._A_stored: np.ndarray | None = None # adjacency from training
# ── internal helpers ──────────────────────────────────────────────────────
def _build_network(self) -> Any:
from ..nets.gcn import GCNNet
factory = GCNNet(
n_features=self.n_features,
n_out=self.n_out,
hidden=self.hidden,
dropout=self.dropout,
**self._net_kwargs,
)
if self._backend_name == "tensorflow":
return factory.build_tf()
return factory.build()
@staticmethod
def _prepare_adjacency(
adjacency: np.ndarray | None,
coords: np.ndarray | None,
radius: float,
n_stations: int,
) -> np.ndarray:
"""Return a float32 normalised adjacency ``(n_stations, n_stations)``."""
from ..nets.gcn import build_adjacency
if adjacency is not None:
A = np.asarray(adjacency, dtype=np.float32)
if A.ndim != 2 or A.shape[0] != A.shape[1]:
raise ValueError(
f"adjacency must be square (n_stations, n_stations); "
f"got shape {A.shape}."
)
return A
if coords is not None:
c = np.asarray(coords, dtype=np.float64)
if c.ndim != 2 or c.shape[1] != 2:
raise ValueError(
f"coords must be shape (n_stations, 2); got {c.shape}."
)
return build_adjacency(c, radius=radius)
# Identity fallback — no inter-station coupling, with warning
warnings.warn(
"Neither adjacency nor coords supplied; using identity adjacency "
"(no inter-station coupling). Pass coords= or adjacency= for "
"full GCN spatial modelling benefits.",
UserWarning,
stacklevel=3,
)
return np.eye(n_stations, dtype=np.float32)
# ── fit ───────────────────────────────────────────────────────────────────
[docs]
def fit(
self,
X: np.ndarray,
y: np.ndarray | None = None,
adjacency: np.ndarray | None = None,
*,
coords: np.ndarray | None = None,
radius: float = 5_000.0,
epochs: int = 100,
batch_size: int = 16,
lr: float = 1e-3,
patience: int = 15,
val_frac: float = 0.15,
grad_clip: float | None = 1.0,
seed: int | None = None,
verbose: bool = True,
) -> GCNInverter3D:
"""
Train the 3-D GCN inversion network.
Parameters
----------
X : ndarray (n_samples, n_stations, n_features) or
(n_stations, n_features)
Per-station MT feature matrices.
y : ndarray (n_samples, n_stations, 2*n_layers-1) or
(n_stations, 2*n_layers-1)
Target per-station model parameters (log10 scale recommended).
adjacency : ndarray (n_stations, n_stations), optional
Pre-computed normalised adjacency matrix. If ``None``,
*coords* and *radius* must be provided.
coords : ndarray (n_stations, 2), optional
Station (x, y) positions used to build the adjacency when
*adjacency* is not given.
radius : float
Maximum inter-station edge distance in the same units as
*coords*; ignored when *adjacency* is supplied.
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)
if X.ndim == 2: # single survey → add sample dim
X, y = X[np.newaxis], y[np.newaxis]
if X.ndim != 3:
raise ValueError(f"X must be 2-D or 3-D; got {X.ndim}-D.")
_, n_stations, _ = X.shape
A = self._prepare_adjacency(adjacency, coords, radius, n_stations)
self._A_stored = A.copy()
# Global z-score normalisation
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
# Train / validation split
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()
if self._backend_name == "none":
raise ImportError(
"No DL backend found. Install PyTorch or TensorFlow:\n"
" pip install torch\n"
" pip install tensorflow"
)
self._network = self._build_network()
if self._backend_name == "tensorflow":
hist, best_val = self._fit_tensorflow(
Xn[ti],
yn[ti],
Xn[vi],
yn[vi],
A,
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],
A,
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 loops ───────────────────────────────────────────────
def _fit_torch(
self,
Xtr,
ytr,
Xva,
yva,
A,
*,
epochs,
batch_size,
lr,
patience,
grad_clip,
verbose,
):
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
dev = resolve_device(self.device)
self._network = self._network.to(dev)
A_t = torch.from_numpy(A).to(dev) # (n_sta, n_sta) — fixed
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) # (b, n_sta, n_feat/n_out)
b = xb.shape[0]
# Process each survey in the mini-batch; n_sta is typically
# small (< 300) so per-survey iteration has negligible overhead
preds = torch.stack(
[self._network(xb[i], A_t) for i in range(b)], dim=0
) # (b, n_sta, n_out)
loss = mse(preds, 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() * b
ep_loss /= len(Xtr)
self._network.eval()
with torch.no_grad():
val_preds = torch.stack(
[self._network(Xva_t[i], A_t) for i in range(len(Xva_t))],
dim=0,
)
v_loss = mse(val_preds, 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" GCNInverter3D 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,
A,
*,
epochs,
batch_size,
lr,
patience,
verbose,
):
import tensorflow as tf
A_t = tf.constant(A) # (n_sta, n_sta)
def _forward_batch(X_batch, A_c, training):
b = X_batch.shape[0]
return tf.stack(
[
self._network([X_batch[i], A_c], training=training)
for i in range(b)
],
axis=0,
)
def make_dataset(X, y, shuffle=False):
ds = tf.data.Dataset.from_tensor_slices(
(X.astype(np.float32), y.astype(np.float32))
)
if shuffle:
ds = ds.shuffle(len(X), reshuffle_each_iteration=True)
return ds.batch(batch_size)
tr_ds = make_dataset(Xtr, ytr, shuffle=True)
va_ds = make_dataset(Xva, yva)
opt = tf.keras.optimizers.Adam(learning_rate=lr)
mse = tf.keras.losses.MeanSquaredError()
best_val, best_weights, no_improve = np.inf, None, 0
train_losses, val_losses = [], []
for ep in range(1, epochs + 1):
ep_loss, n_batches = 0.0, 0
for xb, yb in tr_ds:
with tf.GradientTape() as tape:
preds = _forward_batch(xb, A_t, training=True)
loss = mse(yb, preds)
grads = tape.gradient(loss, self._network.trainable_variables)
opt.apply_gradients(
zip(grads, self._network.trainable_variables)
)
ep_loss += float(loss)
n_batches += 1
ep_loss /= max(n_batches, 1)
v_loss, n_vb = 0.0, 0
for xb, yb in va_ds:
preds = _forward_batch(xb, A_t, training=False)
v_loss += float(mse(yb, preds))
n_vb += 1
v_loss /= max(n_vb, 1)
train_losses.append(ep_loss)
val_losses.append(v_loss)
if v_loss < best_val - 1e-6:
best_val = v_loss
best_weights = self._network.get_weights()
no_improve = 0
else:
no_improve += 1
if verbose and (ep % max(1, epochs // 10) == 0 or ep == 1):
print(
f" GCNInverter3D (TF) 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_weights is not None:
self._network.set_weights(best_weights)
return {"train_loss": train_losses, "val_loss": val_losses}, best_val
# ── predict ───────────────────────────────────────────────────────────────
[docs]
def predict(
self,
X: np.ndarray,
adjacency: np.ndarray | None = None,
*,
coords: np.ndarray | None = None,
radius: float = 5_000.0,
as_log_rho: bool = True,
) -> np.ndarray:
"""
Predict per-station subsurface models.
Parameters
----------
X : ndarray (n_stations, n_features) or
(n_samples, n_stations, n_features)
adjacency : ndarray (n_stations, n_stations), optional
If ``None``, uses the adjacency stored from :meth:`fit`.
coords : ndarray (n_stations, 2), optional
Build adjacency from coordinates when *adjacency* is absent.
radius : float
Edge radius (used only when *coords* is supplied).
as_log_rho : bool
Return log10(ρ) when ``True``; linear-scale ρ otherwise.
Returns
-------
y_pred : ndarray (n_stations, n_out) or (n_samples, n_stations, n_out)
"""
if not self._is_fitted:
raise RuntimeError("Call fit() before predict().")
X = np.asarray(X, dtype=np.float32)
squeeze = X.ndim == 2
if squeeze:
X = X[np.newaxis]
n_samples, n_stations, _ = X.shape
Xn = (X - self._x_mean) / self._x_std
if adjacency is not None or coords is not None:
A = self._prepare_adjacency(adjacency, coords, radius, n_stations)
elif self._A_stored is not None:
A = self._A_stored
else:
A = np.eye(n_stations, dtype=np.float32)
if self._backend_name == "tensorflow":
import tensorflow as tf
A_t = tf.constant(A)
out = np.stack(
[
self._network([Xn[i], A_t], training=False).numpy()
for i in range(n_samples)
],
axis=0,
)
else:
import torch
dev = next(self._network.parameters()).device
A_t = torch.from_numpy(A).to(dev)
self._network.eval()
with torch.no_grad():
out = np.stack(
[
self._network(torch.from_numpy(Xn[i]).to(dev), A_t)
.cpu()
.numpy()
for i in range(n_samples)
],
axis=0,
)
y_log = out * self._y_std + self._y_mean # de-normalise
if squeeze:
y_log = y_log[0]
return y_log if as_log_rho else 10.0**y_log
[docs]
def predict_with_uncertainty(
self,
X: np.ndarray,
adjacency: np.ndarray | None = None,
*,
coords: np.ndarray | None = None,
radius: float = 5_000.0,
n_mc: int = 30,
) -> tuple[np.ndarray, np.ndarray]:
"""
MC-dropout uncertainty estimate for 3-D predictions.
Runs *n_mc* stochastic forward passes with dropout active and
returns the mean and pointwise standard deviation.
Parameters
----------
X : ndarray (..., n_stations, n_features)
adjacency : ndarray, optional
coords : ndarray, optional
radius : float
n_mc : int
Number of Monte-Carlo dropout samples.
Returns
-------
mean : ndarray — same shape as :meth:`predict` output
std : ndarray — same shape
"""
if not self._is_fitted:
raise RuntimeError(
"Call fit() before predict_with_uncertainty()."
)
X = np.asarray(X, dtype=np.float32)
squeeze = X.ndim == 2
if squeeze:
X = X[np.newaxis]
n_samples, n_stations, _ = X.shape
Xn = (X - self._x_mean) / self._x_std
if adjacency is not None or coords is not None:
A = self._prepare_adjacency(adjacency, coords, radius, n_stations)
elif self._A_stored is not None:
A = self._A_stored
else:
A = np.eye(n_stations, dtype=np.float32)
mc_samples = []
if self._backend_name == "tensorflow":
import tensorflow as tf
A_t = tf.constant(A)
for _ in range(n_mc):
out = np.stack(
[
self._network([Xn[i], A_t], training=True).numpy()
for i in range(n_samples)
],
axis=0,
)
mc_samples.append(out * self._y_std + self._y_mean)
else:
import torch
dev = next(self._network.parameters()).device
A_t = torch.from_numpy(A).to(dev)
self._network.train() # activate dropout for MC passes
with torch.no_grad():
for _ in range(n_mc):
out = np.stack(
[
self._network(
torch.from_numpy(Xn[i]).to(dev), A_t
)
.cpu()
.numpy()
for i in range(n_samples)
],
axis=0,
)
mc_samples.append(out * self._y_std + self._y_mean)
self._network.eval()
stacked = np.stack(
mc_samples, axis=0
) # (n_mc, n_samples, n_sta, n_out)
mean = stacked.mean(axis=0)
std = stacked.std(axis=0)
if squeeze:
mean, std = mean[0], std[0]
return mean, std
# ── serialisation ─────────────────────────────────────────────────────────
def _get_params(self) -> dict[str, Any]:
return {
"n_features": self.n_features,
"n_layers": self.n_layers,
"hidden": list(self.hidden),
"dropout": self.dropout,
"device": self.device,
}
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)
if self._A_stored is not None:
out["_A_stored"] = self._A_stored
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)
for attr in ("_x_mean", "_x_std", "_y_mean", "_y_std"):
if attr in weights:
setattr(self, attr, float(weights.pop(attr)[0]))
if "_A_stored" in weights:
self._A_stored = weights.pop("_A_stored")
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"
n_sta = self._A_stored.shape[0] if self._A_stored is not None else "?"
return (
f"GCNInverter3D(n_features={self.n_features}, "
f"n_layers={self.n_layers}, hidden={self.hidden}, "
f"n_stations={n_sta}, {status})"
)