# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""
pycsamt.agents.hybrid_agent
============================
:class:`HybridInversionAgent` wraps
:class:`~pycsamt.ai.inversion.HybridInverter1D`,
:class:`~pycsamt.ai.inversion.HybridInverter2D`, and
:class:`~pycsamt.ai.inversion.HybridInverter3D`.
Two-stage workflow:
**Stage 1** — pre-trained supervised AI inverter
(EMInverter1D / EMInverter2D / GCNInverter3D) is
applied to produce a physically plausible initial
model in milliseconds.
**Stage 2** — physics-informed gradient descent
(same Wait 1954 loss as PINNInverter) refines the
Stage-1 model. Convergence is faster and more
reliable than starting from a random initialisation.
"""
from __future__ import annotations
import time
from typing import Any
import numpy as np
from ._base import AgentResult, BaseAgent
from .pinn_agent import (
_plot_loss_curves,
_plot_pinn_section,
_rms_from_residuals,
)
_HYBRID_SYSTEM = """\
You are an expert in hybrid AI + physics-informed
inversion for MT/CSAMT geophysics.
Given a hybrid inversion result write 4-5 sentences:
1. Compare Stage-1 (AI) and Stage-2 (physics) RMS.
2. Describe how much the physics step improved the fit.
3. State the recovered resistivity structure.
4. Flag stations where Stage-2 failed to improve on
Stage-1 or where residuals remain high.
5. Recommend whether to retrain the AI component,
run more physics iterations, or proceed to 2-D.
Reply in plain scientific English.
"""
[docs]
class HybridInversionAgent(BaseAgent):
r"""Two-stage AI + physics MT inversion.
Stage 1 applies a pre-trained supervised AI
inverter to obtain a starting model.
Stage 2 refines it with physics-informed Adam
gradient descent.
Parameters
----------
dim : {1, 2, 3}
Dimensionality. Default ``1``.
max_iter : int
Physics refinement iterations (Stage 2).
Default ``200``.
smoothness_weight : float
Vertical regularisation weight.
Default ``0.005``.
lateral_weight : float
Lateral smoothness weight (2-D only).
Default ``0.005``.
graph_weight : float
Graph-Laplacian weight (3-D only).
Default ``0.005``.
radius : float
Edge radius in metres for 3-D graph.
Default ``5000.0``.
lr : float
Adam learning rate for Stage 2.
Default ``5e-3``.
solver : {"mt1d", "csamt1d"}
Physics solver. Default ``"mt1d"``.
comp : str
Impedance component (1-D only).
Default ``"xy"``.
n_freqs : int
Frequency-grid size fed to the 1-D AI
inverter. Default ``32``.
api_key, model, llm_provider :
LLM configuration (optional).
Input keys
----------
``sites`` / ``path`` observed data
``ai_inverter`` fitted AI inverter object
or path to checkpoint
``checkpoint`` alias for ``ai_inverter``
``output_dir`` optional save directory
``dim``, ``max_iter``,
``smoothness_weight``,
``lateral_weight``,
``graph_weight`` : optional overrides
Output data keys
----------------
``inverter`` fitted HybridInverterXD
``section`` ndarray (n_layers, n_stations)
Stage-2 log10-rho section
``stage1_section`` ndarray — Stage-1 section
``models`` list of LayeredModel (1-D)
``stage1_models`` list of LayeredModel (1-D)
``n_stations`` int
``rms_per_station`` dict {station: float}
``rms_global`` float (Stage-2)
``rms_stage1`` float (Stage-1 for comparison)
``convergence_df`` pandas.DataFrame or None
``residuals_df`` pandas.DataFrame or None
``figures`` dict
``figure_paths`` dict
Examples
--------
>>> from pycsamt.ai.inversion import EMInverter1D
>>> ai = EMInverter1D.load("checkpoint.npz")
>>> agent = HybridInversionAgent(
... dim=1, max_iter=100
... )
>>> res = agent.execute({
... "path": "/data/L22PLT",
... "ai_inverter": ai,
... })
>>> res["rms_global"]
0.14
"""
SYSTEM_PROMPT = _HYBRID_SYSTEM
def __init__(
self,
*,
dim: int = 1,
max_iter: int = 200,
smoothness_weight: float = 0.005,
lateral_weight: float = 0.005,
graph_weight: float = 0.005,
radius: float = 5000.0,
lr: float = 5e-3,
solver: str = "mt1d",
comp: str = "xy",
n_freqs: int = 32,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
) -> None:
super().__init__(
"HybridInversionAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="inversion",
)
if dim not in (1, 2, 3):
raise ValueError(f"dim must be 1, 2, or 3; got {dim!r}.")
self.dim = dim
self.max_iter = max_iter
self.smoothness_weight = smoothness_weight
self.lateral_weight = lateral_weight
self.graph_weight = graph_weight
self.radius = radius
self.lr = lr
self.solver = solver
self.comp = comp
self.n_freqs = n_freqs
# ── public entry point ────────────────────────
[docs]
def execute(self, input_data: dict[str, Any]) -> AgentResult:
self._last_cost = 0.0
t0 = time.time()
warns: list[str] = []
try:
from ..backends import get_backend_instance
if get_backend_instance() is None:
raise ImportError("No DL backend available.")
except ImportError as exc:
return AgentResult.failed(
f"Hybrid inversion requires PyTorch or TensorFlow: {exc}",
hint=("pip install torch\npip install tensorflow"),
elapsed=time.time() - t0,
)
dim = int(input_data.get("dim", self.dim))
max_iter = int(input_data.get("max_iter", self.max_iter))
sw = float(
input_data.get(
"smoothness_weight",
self.smoothness_weight,
)
)
lw = float(
input_data.get(
"lateral_weight",
self.lateral_weight,
)
)
gw = float(
input_data.get(
"graph_weight",
self.graph_weight,
)
)
output_dir = input_data.get("output_dir")
ai_inv_raw = input_data.get("ai_inverter") or input_data.get(
"checkpoint"
)
if ai_inv_raw is None:
return AgentResult.failed(
"No 'ai_inverter' or 'checkpoint' "
"in input_data. Provide a fitted "
"AI inverter or a checkpoint path.",
elapsed=time.time() - t0,
)
sites_raw = input_data.get("sites") or input_data.get("path")
if sites_raw is None:
return AgentResult.failed(
"No 'sites' or 'path' in input_data.",
elapsed=time.time() - t0,
)
try:
from ..emtools._core import ensure_sites
sites = ensure_sites(sites_raw, verbose=0)
except Exception as exc:
return AgentResult.failed(
str(exc),
elapsed=time.time() - t0,
)
try:
(
inv,
mat,
s1_mat,
conv_df,
res_df,
s1_res_df,
) = self._run(
dim,
sites,
ai_inv_raw,
max_iter,
sw,
lw,
gw,
warns,
)
except Exception as exc:
return AgentResult.failed(
f"Hybrid fitting failed: {exc}",
hint=(
"Check that the AI inverter "
"is trained for this dim. "
"Reduce max_iter if OOM."
),
elapsed=time.time() - t0,
)
station_names = inv.stations
n_st = inv.n_sites
rms_per, rms_global = _rms_from_residuals(res_df)
_, rms_s1 = _rms_from_residuals(s1_res_df)
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
n_layers = getattr(
inv,
"n_layers",
mat.shape[0] if mat is not None else 10,
)
depth_max = getattr(inv, "depth_max", 2000.0)
if mat is not None and n_st > 0:
ths = np.logspace(
np.log10(max(depth_max / 100, 50)),
np.log10(depth_max),
n_layers - 1,
)
depths_km = np.concatenate([[0.0], np.cumsum(ths)]) / 1000.0
try:
fig_s2 = _plot_pinn_section(
mat,
station_names,
n_layers,
depths_km,
title=(f"Hybrid-{dim}D Stage-2 section"),
)
if fig_s2 is not None:
figures["section"] = fig_s2
p = self._save_figure(
fig_s2,
output_dir,
f"hybrid{dim}d_section",
warnings_list=warns,
)
if p:
fig_paths["section"] = p
except Exception as exc:
warns.append(f"Stage-2 section plot: {exc}")
if s1_mat is not None:
try:
fig_s1 = _plot_pinn_section(
s1_mat,
station_names,
n_layers,
depths_km,
title=(f"Hybrid-{dim}D Stage-1 (AI) section"),
)
if fig_s1 is not None:
figures["stage1_section"] = fig_s1
p = self._save_figure(
fig_s1,
output_dir,
f"hybrid{dim}d_s1_section",
warnings_list=warns,
)
if p:
fig_paths["stage1_section"] = p
except Exception as exc:
warns.append(f"Stage-1 section plot: {exc}")
if conv_df is not None and len(conv_df):
try:
fig_c = _plot_loss_curves(
conv_df,
title=(f"Hybrid-{dim}D Stage-2 convergence"),
)
if fig_c is not None:
figures["convergence"] = fig_c
p = self._save_figure(
fig_c,
output_dir,
f"hybrid{dim}d_convergence",
warnings_list=warns,
)
if p:
fig_paths["convergence"] = p
except Exception as exc:
warns.append(f"Convergence plot: {exc}")
interp: str | None = None
if self.api_key and n_st > 0:
rms_s = f"{rms_global:.3f}" if not np.isnan(rms_global) else "N/A"
rms_s1_s = f"{rms_s1:.3f}" if not np.isnan(rms_s1) else "N/A"
n_hi = sum(1 for v in rms_per.values() if v > 0.5)
prompt = (
f"Hybrid-{dim}D MT inversion:\n"
f" Stations: {n_st}\n"
f" Stage-1 (AI) global RMS: "
f"{rms_s1_s}\n"
f" Stage-2 (physics) global RMS: "
f"{rms_s}\n"
f" High-RMS after Stage-2 "
f"(>0.5): {n_hi}\n"
f" max_iter={max_iter}\n"
"Evaluate both stages and recommend "
"next steps."
)
interp = self.query_llm(prompt, max_tokens=300)
models: list = []
stage1_models: list = []
if dim == 1:
try:
models = inv.predict()
except Exception as exc:
warns.append(f"predict(): {exc}")
try:
stage1_models = inv.stage1_models()
except Exception as exc:
warns.append(f"stage1_models(): {exc}")
elapsed = time.time() - t0
rms_str = (
f"RMS {rms_global:.3f}" if not np.isnan(rms_global) else "RMS N/A"
)
return AgentResult(
status=("success" if n_st > 0 else "needs_review"),
summary=(
f"Hybrid-{dim}D: {n_st} stations. "
f"{rms_str} (Stage-2). "
f"{len(figures)} figure(s)."
),
data={
"inverter": inv,
"section": mat,
"stage1_section": s1_mat,
"models": models,
"stage1_models": stage1_models,
"n_stations": n_st,
"rms_per_station": rms_per,
"rms_global": rms_global,
"rms_stage1": rms_s1,
"convergence_df": conv_df,
"residuals_df": res_df,
"figures": figures,
"figure_paths": fig_paths,
},
warnings=warns,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
# ── private runners ───────────────────────────
def _run(
self,
dim: int,
sites: Any,
ai_inv_raw: Any,
max_iter: int,
sw: float,
lw: float,
gw: float,
warns: list[str],
):
if dim == 1:
return self._run_1d(
sites,
ai_inv_raw,
max_iter,
sw,
warns,
)
if dim == 2:
return self._run_2d(
sites,
ai_inv_raw,
max_iter,
sw,
lw,
warns,
)
return self._run_3d(
sites,
ai_inv_raw,
max_iter,
sw,
gw,
warns,
)
def _run_1d(
self,
sites,
ai_inv_raw,
max_iter,
sw,
warns,
):
from ..ai.inversion.hybrid1d import (
HybridInverter1D,
)
inv = HybridInverter1D(
sites,
ai_inv_raw,
solver=self.solver,
max_iter=max_iter,
smoothness_weight=sw,
lr=self.lr,
comp=self.comp,
n_freqs=self.n_freqs,
verbose=0,
)
inv.fit(verbose=False)
n_layers = inv._ai_inv.n_layers
n_st = inv.n_sites
# Stage-2 section matrix
mat = np.full((n_layers, n_st), np.nan)
for si, res in enumerate(inv._stage2):
lr_arr = res["log_rho"]
n = min(len(lr_arr), n_layers)
mat[:n, si] = lr_arr[:n]
# Stage-1 section matrix
s1_mat = np.full((n_layers, n_st), np.nan)
for si, res in enumerate(inv._stage1):
lr_arr = res["log_rho"]
n = min(len(lr_arr), n_layers)
s1_mat[:n, si] = lr_arr[:n]
try:
conv_df = inv.convergence_curves()
except Exception:
conv_df = None
try:
res_df = inv.residuals(stage=2)
except Exception as exc:
warns.append(f"residuals(2): {exc}")
res_df = None
try:
s1_res_df = inv.residuals(stage=1)
except Exception:
s1_res_df = None
return inv, mat, s1_mat, conv_df, res_df, s1_res_df
def _run_2d(
self,
sites,
ai_inv_raw,
max_iter,
sw,
lw,
warns,
):
from ..ai.inversion.hybrid2d import (
HybridInverter2D,
)
inv = HybridInverter2D(
sites,
ai_inv_raw,
solver=self.solver,
max_iter=max_iter,
smoothness_weight=sw,
lateral_weight=lw,
lr=self.lr,
verbose=0,
)
inv.fit(verbose=False)
try:
mat = inv.resistivity_section(stage=2)
except Exception:
try:
mat = inv.resistivity_section()
except Exception:
mat = None
try:
s1_mat = inv.resistivity_section(stage=1)
except Exception:
s1_mat = None
try:
conv_df = inv.convergence_curve()
except Exception:
conv_df = None
try:
res_df = inv.residuals(stage=2)
except Exception as exc:
warns.append(f"residuals(2): {exc}")
res_df = None
try:
s1_res_df = inv.residuals(stage=1)
except Exception:
s1_res_df = None
return inv, mat, s1_mat, conv_df, res_df, s1_res_df
def _run_3d(
self,
sites,
ai_inv_raw,
max_iter,
sw,
gw,
warns,
):
from ..ai.inversion.hybrid3d import (
HybridInverter3D,
)
inv = HybridInverter3D(
sites,
ai_inv_raw,
max_iter=max_iter,
smoothness_weight=sw,
graph_weight=gw,
radius=self.radius,
lr=self.lr,
verbose=0,
)
inv.fit(verbose=False)
try:
mat = inv.resistivity_volume(stage=2)
except Exception:
try:
mat = inv.resistivity_volume()
except Exception:
mat = None
try:
s1_mat = inv.resistivity_volume(stage=1)
except Exception:
s1_mat = None
try:
conv_df = inv.convergence_curve()
except Exception:
conv_df = None
try:
res_df = inv.residuals(stage=2)
except Exception as exc:
warns.append(f"residuals(2): {exc}")
res_df = None
try:
s1_res_df = inv.residuals(stage=1)
except Exception:
s1_res_df = None
return inv, mat, s1_mat, conv_df, res_df, s1_res_df
__all__ = ["HybridInversionAgent"]