"""
pycsamt.agents.ai_inversion
============================
:class:`AIInversionAgent` — End-to-end AI 1-D MT inversion.
Workflow
--------
1. **Generate synthetic training data** — :func:`~pycsamt.forward.batch.generate_dataset`
builds a ``ForwardDataset`` of (response, model) pairs covering a
realistic resistivity range.
2. **Train** — :class:`~pycsamt.ai.inversion.inv1d.EMInverter1D` is fitted
on the synthetic data using the selected network architecture.
3. **Predict** — the trained inverter predicts a layered resistivity model
for every station in the observed dataset.
4. **Evaluate** — per-station RMS between observed and re-computed forward
response validates the prediction quality.
5. **Visualise** — convergence curve + predicted model sections.
Requires PyTorch **or** TensorFlow (lazy import — no hard dependency at
import time). When neither is available the agent returns a clear error
message with an installation hint.
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
import numpy as np
from ._base import AgentResult, BaseAgent
_SYSTEM_PROMPT = """\
You are an expert in AI-based MT inversion and deep learning for geophysics.
Given an AI inversion result, write 4–5 sentences that:
1. Describe the neural network architecture used and training convergence.
2. State the prediction quality (RMS, layer count, depth range).
3. Identify stations where the AI prediction is most / least reliable.
4. Compare AI results with classical Bostick depth estimates if available.
5. Recommend next steps (fine-tuning, ensemble, switch to 2-D inversion).
Reply in plain scientific English.
"""
# ── default training config ───────────────────────────────────────────────────
_DEFAULT_FREQS = np.logspace(-4, 3, 40) # 40 frequencies 10⁻⁴ – 10³ Hz
_DEFAULT_N_SAMP = 2_000 # fast default; 10 000 for production
_DEFAULT_EPOCHS = 30 # fast default; 100+ for production
[docs]
class AIInversionAgent(BaseAgent):
"""Train an AI inverter on synthetic data then predict on observed sites.
Parameters
----------
api_key, model, llm_provider : str
arch : {"resnet", "cnn1d", "fcn"}
Neural network architecture.
n_layers : int
Number of model layers the inverter will predict (default 5).
n_train_samples : int
Number of synthetic training samples (default 2 000).
epochs : int
Training epochs (default 30). Increase for better models.
freqs : array-like or None
Frequencies used for both training synthesis and observed data
interpolation. Default: 40 log-spaced 10⁻⁴–10³ Hz.
pretrained : str or None
Path to a pre-trained model checkpoint. When set, skips training.
Input keys
----------
``sites`` / ``path`` : Sites or str — observed data
``output_dir`` : str, optional
``arch``, ``epochs``, ``n_train_samples`` : optional overrides
Output data keys
----------------
``inverter`` :class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`
``predictions`` dict {station: ndarray of log₁₀ ρ values}
``best_model`` dict with "resistivity" and "thickness" for first station
``rms_per_station`` dict {station: float}
``rms_global`` float
``train_history`` dict (loss curves)
``figures`` dict
``figure_paths`` dict
Examples
--------
>>> agent = AIInversionAgent(arch="resnet", n_layers=5, epochs=30)
>>> result = agent.execute({
... "path": "/data/L22PLT",
... "output_dir": "/out/ai_inv",
... })
>>> result["rms_global"]
0.24
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
[docs]
@classmethod
def from_pretrained(
cls,
model_name: str,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
cache_dir: str | None = None,
force_download: bool = False,
) -> AIInversionAgent:
"""Return an :class:`AIInversionAgent` pre-loaded with a zoo checkpoint.
Parameters
----------
model_name : str
Registry name — see :func:`~pycsamt.ai._zoo.list_pretrained`.
cache_dir : str or None
Override default cache ``~/.pycsamt/model_zoo/``.
force_download : bool
Re-download even if cached.
Examples
--------
>>> agent = AIInversionAgent.from_pretrained("mt1d-resnet-5layer-v1")
>>> result = agent.execute({"path": "/data/L22PLT"})
"""
from ..ai._zoo import (
download_checkpoint,
get_pretrained_info,
)
info = get_pretrained_info(model_name)
ckpt_path = download_checkpoint(
model_name,
cache_dir=cache_dir,
force=force_download,
verbose=True,
)
return cls(
api_key=api_key,
model=model,
llm_provider=llm_provider,
arch=info.get("arch", "resnet"),
n_layers=int(info.get("n_layers", 5)),
pretrained=str(ckpt_path),
)
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
arch: str = "resnet",
n_layers: int = 5,
n_train_samples: int = _DEFAULT_N_SAMP,
epochs: int = _DEFAULT_EPOCHS,
freqs: Any = None,
pretrained: str | None = None,
) -> None:
super().__init__(
"AIInversionAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="inversion",
)
self.arch = arch
self.n_layers = n_layers
self.n_train_samples = n_train_samples
self.epochs = epochs
self._freqs_cfg = freqs
self.pretrained = pretrained
[docs]
def execute(self, input_data: dict[str, Any]) -> AgentResult:
self._last_cost = 0.0
t0 = time.time()
warnings: list[str] = []
# ── check AI backend ──────────────────────────────────────────────────
try:
from ..ai.inversion.inv1d import EMInverter1D
from ..backends import get_backend_instance
from ..forward.batch import (
ForwardDataset,
generate_dataset,
)
if get_backend_instance() is None:
raise ImportError(
"No DL backend available (torch or tensorflow)."
)
except ImportError as exc:
return AgentResult.failed(
f"AI inversion requires PyTorch or TensorFlow: {exc}",
hint=(
"Install PyTorch: pip install torch\n"
"Install TensorFlow: pip install tensorflow"
),
elapsed=time.time() - t0,
)
from ..emtools._core import (
_get_z_block,
_iter_items,
_name,
ensure_sites,
)
sites_raw = input_data.get("sites") or input_data.get("path")
if sites_raw is None:
return AgentResult.failed(
"No 'sites' or 'path'.", elapsed=time.time() - t0
)
try:
sites = ensure_sites(sites_raw, verbose=0)
except Exception as exc:
return AgentResult.failed(str(exc), elapsed=time.time() - t0)
arch = str(input_data.get("arch", self.arch))
epochs = int(input_data.get("epochs", self.epochs))
n_train = int(input_data.get("n_train_samples", self.n_train_samples))
output_dir = input_data.get("output_dir")
freqs = np.asarray(
input_data.get("freqs") or self._freqs_cfg or _DEFAULT_FREQS,
dtype=float,
)
# ── fast-fail: require usable impedance BEFORE training ───────────────
# Training synthesises data and fits a network (can take minutes)
# without touching the real EDIs. If no station has usable
# impedance, we would train only to predict nothing, ending with
# an empty, confusing result. Check first and fail immediately.
n_usable = 0
for _i, _ed in enumerate(_iter_items(sites)):
_Z, _z, _fr = _get_z_block(_ed)
if _z is None or _fr is None:
continue
if _z_to_features(_Z, _z, _fr, freqs) is not None:
n_usable += 1
if n_usable == 0:
return AgentResult.failed(
"No station has usable impedance data for inversion — "
"every station's Z is missing or non-finite. The data "
"may be empty, or corrupted by a prior in-place "
"correction that produced invalid impedance.",
hint=(
"Re-load the original EDI files (Load EDI), then run "
"the inversion. Avoid applying an in-place correction "
"that yields invalid impedance before inverting."
),
elapsed=time.time() - t0,
)
# ── build inverter ────────────────────────────────────────────────────
inverter = EMInverter1D(
arch=arch,
n_layers=self.n_layers,
solver="mt1d",
include_phase=True,
)
# ── load pre-trained or train ─────────────────────────────────────────
train_history: dict = {}
if self.pretrained and Path(self.pretrained).exists():
try:
inverter = EMInverter1D.load(self.pretrained)
self._log.info(
"Loaded pre-trained model from %s", self.pretrained
)
except Exception as exc:
warnings.append(
f"Could not load pre-trained model: {exc}. Training fresh."
)
self.pretrained = None
if not self.pretrained:
# generate synthetic training set
self._log.info(
"Generating %d synthetic training samples (freqs=%d) …",
n_train,
len(freqs),
)
try:
dataset = generate_dataset(
solver="mt1d",
n_samples=n_train,
freqs=freqs,
n_layers=self.n_layers,
noise_level=0.03,
seed=42,
n_jobs=1,
verbose=False,
)
self._log.info("Training %s for %d epochs …", arch, epochs)
inverter.fit(
dataset.X,
dataset.y,
epochs=epochs,
batch_size=min(256, n_train // 4),
patience=max(5, epochs // 5),
verbose=False,
)
# collect training history
if hasattr(inverter, "history_"):
train_history = dict(inverter.history_)
except Exception as exc:
return AgentResult.failed(
f"AI training failed: {exc}",
hint="Try reducing n_train_samples or epochs.",
elapsed=time.time() - t0,
)
# ── predict on observed stations ──────────────────────────────────────
predictions: dict[str, np.ndarray] = {}
rms_per: dict[str, float] = {}
best_model: dict = {}
for i, ed in enumerate(_iter_items(sites)):
nm = _name(ed, i)
Zobj, z, fr = _get_z_block(ed)
if z is None or fr is None:
continue
try:
# interpolate onto training freqs in the inverter's own
# feature format ([log10(rho_a_xy), phase_xy])
X_obs = _z_to_features(Zobj, z, fr, freqs)
if X_obs is None:
warnings.append(f"{nm}: could not build feature vector.")
continue
# predict_models() correctly splits the network output
# ([log10(rho)(n_layers), log10(h)(n_layers-1)]) into a
# LayeredModel. The raw predict() vector must NOT be
# treated as log-resistivities — it also carries the
# predicted thicknesses.
_models = inverter.predict_models(X_obs[None, :])
model = _models[0] if _models else None
if model is None:
warnings.append(f"{nm}: prediction produced no model.")
continue
rhos = np.asarray(model.resistivity, dtype=float)
ths = np.asarray(model.thickness, dtype=float)
log_rho_pred = np.log10(np.clip(rhos, 1e-12, None))
predictions[nm] = log_rho_pred
# forward RMS: response of the predicted model vs observed
from ..forward import MT1DForward
try:
resp = MT1DForward(freqs=freqs).run(model)
rho_fwd = np.asarray(resp.rho_a)
rho_fwd_xy = (
rho_fwd[:, 0, 1] if rho_fwd.ndim == 3 else rho_fwd
)
per_obs = 1.0 / np.where(fr == 0, np.nan, fr)
per_fwd = 1.0 / np.where(freqs == 0, np.nan, freqs)
rho_obs_xy = getattr(Zobj, "resistivity_xy", None)
if rho_obs_xy is None:
rho_obs_xy = (
0.2 / np.where(fr == 0, np.nan, fr)
) * np.abs(z[:, 0, 1]) ** 2
rho_obs_xy = np.asarray(rho_obs_xy, float).ravel()
# forward curve sorted by ascending period (np.interp
# requires an increasing x grid)
fwd_ok = np.isfinite(per_fwd) & (rho_fwd_xy > 0)
o = np.argsort(per_fwd[fwd_ok])
xp = np.log10(per_fwd[fwd_ok][o])
fp = np.log10(np.clip(rho_fwd_xy[fwd_ok][o], 1e-6, None))
mask = np.isfinite(per_obs) & (rho_obs_xy > 0)
if mask.sum() >= 2 and xp.size >= 2:
interp = np.interp(np.log10(per_obs[mask]), xp, fp)
obs_log = np.log10(
np.clip(rho_obs_xy[mask], 1e-6, None)
)
rms_per[nm] = float(
np.sqrt(np.mean((obs_log - interp) ** 2))
)
except Exception as _rms_exc:
warnings.append(f"{nm}: RMS not computed ({_rms_exc}).")
if not best_model:
best_model = {
"station": nm,
"resistivity": list(rhos),
"thickness": list(ths),
"log_rho": list(log_rho_pred),
}
except Exception as exc:
warnings.append(f"Prediction failed for {nm}: {exc}")
rms_global = (
float(np.nanmean(list(rms_per.values()))) if rms_per else np.nan
)
n_pred = len(predictions)
# ── save model ────────────────────────────────────────────────────────
if output_dir:
import os
os.makedirs(output_dir, exist_ok=True)
try:
model_path = f"{output_dir}/ai_inverter_{arch}.pkl"
inverter.save(model_path)
except Exception as exc:
warnings.append(f"Could not save inverter: {exc}")
# ── figures ───────────────────────────────────────────────────────────
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
# training convergence
if train_history:
try:
from ..ai.plot.convergence import (
plot_convergence,
)
fig_cv = plot_convergence(train_history)
if fig_cv is not None:
f = (
fig_cv
if hasattr(fig_cv, "savefig")
else (
fig_cv.get_figure()
if hasattr(fig_cv, "get_figure")
else None
)
)
if f is not None:
figures["convergence"] = f
p = self._save_figure(
f,
output_dir,
"ai_convergence",
warnings_list=warnings,
)
if p:
fig_paths["convergence"] = p
except Exception as exc:
warnings.append(f"Convergence plot: {exc}")
# predicted model section (station × layer colour-coded by log ρ)
if predictions:
try:
fig_sec = _plot_ai_section(predictions, self.n_layers, freqs)
if fig_sec is not None:
figures["ai_section"] = fig_sec
p = self._save_figure(
fig_sec,
output_dir,
"ai_model_section",
warnings_list=warnings,
)
if p:
fig_paths["ai_section"] = p
except Exception as exc:
warnings.append(f"AI section plot: {exc}")
# ── LLM interpretation ────────────────────────────────────────────────
interp: str | None = None
if self.api_key and n_pred:
n_hi = sum(1 for v in rms_per.values() if v > 0.5)
prompt = (
f"AI inversion summary:\n"
f" Architecture: {arch}, layers: {self.n_layers}\n"
f" Stations predicted: {n_pred}\n"
f" Global RMS: {rms_global:.3f} log₁₀(Ω·m)\n"
f" Stations with high RMS (> 0.5): {n_hi}\n"
f" Best model (station {best_model.get('station', '?')}): "
f" ρ = {[f'{r:.0f}' for r in best_model.get('resistivity', [])]} Ω·m\n\n"
"Evaluate this AI inversion and recommend next steps."
)
interp = self.query_llm(prompt, max_tokens=250)
elapsed = time.time() - t0
rms_str = (
f"RMS {rms_global:.3f}" if not np.isnan(rms_global) else "RMS N/A"
)
if n_pred == 0:
summary = (
f"AI inversion ({arch}, {self.n_layers} layers) trained, "
"but produced 0 predictions — no station yielded a valid "
"model. Check the impedance data and try again."
)
else:
summary = (
f"AI inversion ({arch}, {self.n_layers} layers): "
f"{n_pred} stations predicted. {rms_str}. "
f"{len(figures)} figure(s)."
)
return AgentResult(
status="success" if n_pred > 0 else "needs_review",
summary=summary,
data={
"inverter": inverter,
"predictions": predictions,
"best_model": best_model,
"rms_per_station": rms_per,
"rms_global": rms_global,
"train_history": train_history,
"figures": figures,
"figure_paths": fig_paths,
"freqs": freqs,
},
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
# ── helpers ───────────────────────────────────────────────────────────────────
def _z_to_features(
z_obj: Any,
z: np.ndarray,
fr: np.ndarray,
freqs_target: np.ndarray,
*,
include_phase: bool = True,
) -> np.ndarray | None:
r"""Build the observed feature vector for :meth:`EMInverter1D.predict`.
Must match the layout the inverter was trained on — see
:func:`pycsamt.ai._base._z_list_to_array`, which uses the **xy
apparent resistivity and phase**::
[ log10(rho_a_xy) , phase_xy ] # flat, length 2 * n_freq
The observed station's frequency grid usually differs from the
training grid, so both curves are interpolated (in log-frequency)
onto *freqs_target*. The previous implementation returned ``|Z|``
over both off-diagonals as a ``(n, 4)`` array, which never matched
the trained 80-dim input and made every prediction fail.
Returns ``None`` when fewer than two finite samples are available.
"""
try:
fr = np.asarray(fr, dtype=float)
freqs_target = np.asarray(freqs_target, dtype=float)
# Prefer the same quantities the inverter trained on.
rho_xy = getattr(z_obj, "resistivity_xy", None)
pha_xy = getattr(z_obj, "phase_xy", None)
if rho_xy is None or pha_xy is None:
# Fall back to computing from the impedance tensor.
zxy = z[:, 0, 1]
rho_xy = (0.2 / np.where(fr == 0, np.nan, fr)) * np.abs(zxy) ** 2
pha_xy = np.degrees(np.angle(zxy))
rho_xy = np.asarray(rho_xy, dtype=float).ravel()
pha_xy = np.asarray(pha_xy, dtype=float).ravel()
lf = np.log10(np.where(fr <= 0, np.nan, fr))
mask = (
np.isfinite(lf)
& np.isfinite(rho_xy)
& (rho_xy > 0)
& np.isfinite(pha_xy)
)
if mask.sum() < 2:
return None
order = np.argsort(lf[mask])
lf_obs = lf[mask][order]
rho_obs = np.log10(np.clip(rho_xy[mask], 1e-12, None))[order]
pha_obs = pha_xy[mask][order]
lf_t = np.log10(freqs_target)
rho_i = np.interp(lf_t, lf_obs, rho_obs)
pha_i = np.interp(lf_t, lf_obs, pha_obs)
feat = np.concatenate([rho_i, pha_i]) if include_phase else rho_i
return feat.astype(np.float32)
except Exception:
return None
def _default_thicknesses(n_layers: int, freqs: np.ndarray) -> np.ndarray:
"""Return log-spaced thicknesses spanning Bostick depth range."""
rho_ref = 100.0 # Ω·m reference
per_max = float(1.0 / freqs.min())
d_max = np.sqrt(rho_ref * per_max / (4 * np.pi * 1e-7 * 2 * np.pi))
ths = np.logspace(
np.log10(max(d_max / 100, 50)), np.log10(d_max), n_layers - 1
)
return ths.astype(float)
def _plot_ai_section(
predictions: dict[str, np.ndarray],
n_layers: int,
freqs: np.ndarray,
) -> Any:
"""Plot a station × layer colour-coded predicted resistivity section."""
import matplotlib.pyplot as plt
from ..api.section import PYCSAMT_SECTION
station_names = list(predictions.keys())
n_st = len(station_names)
if n_st == 0:
return None
mat = np.full((n_layers, n_st), np.nan)
for si, nm in enumerate(station_names):
log_rho = predictions[nm]
n = min(len(log_rho), n_layers)
mat[:n, si] = log_rho[:n]
ths = _default_thicknesses(n_layers, freqs)
depths = np.concatenate([[0], np.cumsum(ths)]) / 1000.0 # km
section = PYCSAMT_SECTION.style_for("inversion")
fig_w, fig_h = section.figsize_for(n_stations=n_st, n_y=n_layers)
fig, ax = plt.subplots(figsize=(fig_w, fig_h))
im = ax.imshow(
mat,
aspect="auto",
origin="upper",
extent=(-0.5, n_st - 0.5, depths[-1], depths[0]),
cmap="jet_r",
vmin=np.nanpercentile(mat, 5),
vmax=np.nanpercentile(mat, 95),
interpolation="nearest",
)
from ..api.station import PYCSAMT_STATION_RENDERING
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(n_st, dtype=float),
station_names,
preset="inversion",
xlim=(-0.5, n_st - 0.5),
)
ax.set_ylabel("Depth (km)", fontsize=9)
ax.tick_params(axis="y", labelsize=8)
section.add_colorbar(im, ax, label="$\\log_{10}\\rho$ (Ω·m)")
ax.set_title(
"AI-predicted resistivity section", fontsize=10, fontweight="bold"
)
fig.tight_layout()
return fig
__all__ = ["AIInversionAgent"]