Source code for pycsamt.agents.ensemble_agent

"""
pycsamt.agents.ensemble_agent
==============================

:class:`EnsembleAgent` — Ensemble 1-D inversion with uncertainty quantification.

Wraps :class:`~pycsamt.ai.inversion.ensemble.EnsembleInverter`:

* Trains *N* independent :class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`
  models on a shared synthetic dataset using different random seeds.
* Predicts **mean** resistivity and **uncertainty** (std / confidence intervals)
  for every observed station.
* Optionally **calibrates** the intervals using a held-out conformal set.
* Reports empirical **coverage** (fraction of true values inside the interval)
  as a reliability metric.

The outputs feed directly into the :class:`ReportAgent` and are used as a
rigorous uncertainty-aware alternative to single-model AI inversion.

Requires PyTorch **or** TensorFlow.
"""

from __future__ import annotations

import time
from typing import Any

import numpy as np

from ._base import AgentResult, BaseAgent
from .ai_inversion import _default_thicknesses, _z_to_features

_SYSTEM_PROMPT = """\
You are an expert in Bayesian and ensemble methods for geophysical inversion.
Given an ensemble inversion result with uncertainty quantification, write 4-5
sentences that:
1. Describe the ensemble configuration (N models, architecture, training data).
2. State the prediction quality (mean RMS, uncertainty magnitude).
3. Assess the calibration: are the confidence intervals reliable?
4. Identify depth ranges or stations where uncertainty is largest.
5. Recommend whether the uncertainty is small enough for geological interpretation.
Reply in plain scientific English.
"""


[docs] class EnsembleAgent(BaseAgent): """Ensemble 1-D MT inversion with uncertainty bands. Parameters ---------- api_key, model, llm_provider : str n_estimators : int Number of independent models in the ensemble (default 5). arch : str Network architecture for each estimator (default ``"resnet"``). n_layers : int Number of model layers (default 5). n_train_samples : int Synthetic training samples per estimator (default 2 000). epochs : int Training epochs per estimator (default 30). calibrate : bool Apply conformal calibration using 20 % of training data (default True). Input keys ---------- ``sites`` / ``path`` : Sites or str ``output_dir`` : str, optional Output data keys ---------------- ``ensemble`` EnsembleInverter ``pred_mean`` dict {station: ndarray} — mean log₁₀ ρ ``pred_std`` dict {station: ndarray} — std of log₁₀ ρ ``pred_lo`` dict {station: ndarray} — 5th-percentile bound ``pred_hi`` dict {station: ndarray} — 95th-percentile bound ``coverage`` float — empirical 90 % interval coverage ``rms_global`` float ``figures`` dict ``figure_paths`` dict Examples -------- >>> agent = EnsembleAgent(n_estimators=3, epochs=20) >>> result = agent.execute({"path": "/data/L22PLT", "output_dir": "/out/ens"}) >>> result["coverage"] # should be ≈ 0.90 after calibration 0.88 """ SYSTEM_PROMPT = _SYSTEM_PROMPT def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", n_estimators: int = 5, arch: str = "resnet", n_layers: int = 5, n_train_samples: int = 2_000, epochs: int = 30, calibrate: bool = True, ) -> None: super().__init__( "EnsembleAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="inversion", ) self.n_estimators = n_estimators self.arch = arch self.n_layers = n_layers self.n_train_samples = n_train_samples self.epochs = epochs self.calibrate = calibrate
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: self._last_cost = 0.0 t0 = time.time() warnings: list[str] = [] # ── backend check ────────────────────────────────────────────────────── try: from ..ai.inversion.ensemble import ( EnsembleInverter, ) from ..ai.inversion.inv1d import EMInverter1D from ..backends import get_backend_instance from ..forward.batch import generate_dataset if get_backend_instance() is None: raise ImportError("No DL backend (torch / tensorflow).") except ImportError as exc: return AgentResult.failed( f"EnsembleAgent requires PyTorch or TensorFlow: {exc}", hint="pip install torch or 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) output_dir = input_data.get("output_dir") import os if output_dir: os.makedirs(output_dir, exist_ok=True) freqs = np.logspace(-4, 3, 40) # ── generate synthetic dataset ──────────────────────────────────────── self._log.info( "Generating %d synthetic samples…", self.n_train_samples ) try: dataset = generate_dataset( solver="mt1d", n_samples=self.n_train_samples, freqs=freqs, n_layers=self.n_layers, noise_level=0.03, seed=42, n_jobs=1, verbose=False, ) X_all, y_all = dataset.X, dataset.y # split: 80 % train, 20 % calibration n_cal = max(50, int(len(X_all) * 0.2)) X_train, y_train = X_all[n_cal:], y_all[n_cal:] X_cal, y_cal = X_all[:n_cal], y_all[:n_cal] except Exception as exc: return AgentResult.failed( f"Dataset generation failed: {exc}", elapsed=time.time() - t0, ) # ── build base estimator + ensemble ─────────────────────────────────── self._log.info( "Training ensemble of %d × %s models…", self.n_estimators, self.arch, ) try: base = EMInverter1D( arch=self.arch, n_layers=self.n_layers, solver="mt1d", include_phase=True, ) ens = EnsembleInverter( base_estimator=base, n_estimators=self.n_estimators, ) ens.fit( X_train, y_train, epochs=self.epochs, batch_size=min(256, len(X_train) // 4), patience=max(5, self.epochs // 5), verbose=False, ) except Exception as exc: return AgentResult.failed( f"Ensemble training failed: {exc}", elapsed=time.time() - t0, ) # ── calibrate ───────────────────────────────────────────────────────── if self.calibrate: try: ens.calibrate(X_cal, y_cal, alpha=0.10) self._log.info("Conformal calibration done.") except Exception as exc: warnings.append(f"Calibration failed: {exc}") # ── coverage on held-out set ────────────────────────────────────────── coverage = np.nan try: coverage = float(ens.coverage(X_cal, y_cal)) except Exception as exc: warnings.append(f"Coverage computation: {exc}") # ── predict on observed stations ────────────────────────────────────── pred_mean: dict[str, np.ndarray] = {} pred_std: dict[str, np.ndarray] = {} pred_lo: dict[str, np.ndarray] = {} pred_hi: dict[str, np.ndarray] = {} rms_list: list[float] = [] for i, ed in enumerate(_iter_items(sites)): nm = _name(ed, i) _, z, fr = _get_z_block(ed) if z is None: continue X_obs = _z_to_features(ed, z, fr, freqs) if X_obs is None: warnings.append(f"{nm}: could not build feature vector.") continue try: X_in = X_obs[None, :] mu, sigma = ens.predict_with_uncertainty(X_in) lo_hi = ens.predict_intervals( X_in ) # (lo, hi, mean) or (mean, lo, hi) pred_mean[nm] = mu[0] pred_std[nm] = sigma[0] # unpack intervals — order depends on implementation if len(lo_hi) == 3: lo_arr, hi_arr, _ = lo_hi pred_lo[nm] = lo_arr[0] pred_hi[nm] = hi_arr[0] # compute forward RMS rms = _forward_rms(ed, mu[0], freqs, self.n_layers) if rms is not None: rms_list.append(rms) except Exception as exc: warnings.append(f"Prediction for {nm}: {exc}") rms_global = float(np.mean(rms_list)) if rms_list else np.nan n_pred = len(pred_mean) # ── save ensemble ───────────────────────────────────────────────────── if output_dir and n_pred: try: ens.save(os.path.join(output_dir, "ensemble_inverter.pkl")) except Exception as exc: warnings.append(f"Could not save ensemble: {exc}") # ── figures ─────────────────────────────────────────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} if pred_mean and pred_std: try: fig_unc = _plot_uncertainty_section( pred_mean, pred_std, pred_lo, pred_hi, self.n_layers, freqs, ) if fig_unc is not None: figures["uncertainty_section"] = fig_unc p = self._save_figure( fig_unc, output_dir, "ensemble_uncertainty_section", warnings_list=warnings, ) if p: fig_paths["uncertainty_section"] = p except Exception as exc: warnings.append(f"Uncertainty section figure: {exc}") # ── uncertainty profile for first station ───────────────────────────── if pred_mean: first_station = next(iter(pred_mean)) try: X_first = None for ii, ed in enumerate(_iter_items(sites)): nm = _name(ed, ii) if nm == first_station: _, z, fr = _get_z_block(ed) X_first = _z_to_features(ed, z, fr, freqs) break if X_first is not None: fig_prof = ens.plot_uncertainty_profile( X_first[None, :], sample_idx=0, ) if fig_prof is not None: f = ( fig_prof if hasattr(fig_prof, "savefig") else ( fig_prof.get_figure() if hasattr(fig_prof, "get_figure") else None ) ) if f is not None: figures["uncertainty_profile"] = f p = self._save_figure( f, output_dir, "ensemble_uncertainty_profile", warnings_list=warnings, ) if p: fig_paths["uncertainty_profile"] = p except Exception as exc: warnings.append(f"Uncertainty profile figure: {exc}") # ── LLM interpretation ──────────────────────────────────────────────── interp: str | None = None if self.api_key and n_pred: mean_std = ( float(np.nanmean([v.mean() for v in pred_std.values()])) if pred_std else np.nan ) prompt = ( f"Ensemble inversion summary:\n" f" N estimators: {self.n_estimators}, arch: {self.arch}\n" f" Stations predicted: {n_pred}\n" f" Global RMS: {rms_global:.3f}\n" f" Mean uncertainty (std log₁₀ ρ): {mean_std:.3f}\n" f" Empirical 90% coverage: {coverage:.1%}\n" f" Calibrated: {self.calibrate}\n" f" Warnings: {warnings[:3] if warnings else 'none'}\n\n" "Evaluate the ensemble inversion quality and uncertainty." ) interp = self.query_llm(prompt, max_tokens=250) elapsed = time.time() - t0 cov_str = f"{coverage:.1%}" if not np.isnan(coverage) else "N/A" rms_str = f"{rms_global:.3f}" if not np.isnan(rms_global) else "N/A" return AgentResult( status="success" if n_pred > 0 else "needs_review", summary=( f"Ensemble inversion ({self.n_estimators}× {self.arch}): " f"{n_pred} stations. RMS={rms_str}. " f"90% coverage={cov_str}. {len(figures)} figures." ), data={ "ensemble": ens, "pred_mean": pred_mean, "pred_std": pred_std, "pred_lo": pred_lo, "pred_hi": pred_hi, "coverage": coverage, "rms_global": rms_global, "freqs": freqs, "figures": figures, "figure_paths": fig_paths, }, warnings=warnings, llm_interpretation=interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── private helpers ─────────────────────────────────────────────────────────── def _forward_rms( ed: Any, log_rho: np.ndarray, freqs: np.ndarray, n_layers: int ) -> float | None: """Re-run forward on predicted model and compute log-ρa RMS.""" try: from ..emtools._core import _get_z_block from ..forward import LayeredModel, MT1DForward _, z, fr = _get_z_block(ed) if z is None: return None rhos = 10**log_rho ths = _default_thicknesses(n_layers, freqs) lm = LayeredModel( resistivity=rhos, thickness=ths[: n_layers - 1], ) fwd = MT1DForward(freqs=freqs) resp = fwd.run(lm) rho_fwd = np.asarray(resp.rho_a) rho_xy = rho_fwd[:, 0, 1] if rho_fwd.ndim == 3 else rho_fwd rho_raw = getattr(ed, "rho", None) rho_obs = ( rho_raw[:, 0, 1] if rho_raw is not None else (0.2 / np.where(fr == 0, np.nan, fr)) * np.abs(z[:, 0, 1]) ** 2 ) per = 1.0 / np.where(fr == 0, np.nan, fr) per_fwd = 1.0 / np.where(freqs == 0, np.nan, freqs) mask = np.isfinite(per) & (rho_obs > 0) if mask.sum() < 2: return None interp = np.interp( np.log10(per[mask]), np.log10(per_fwd[np.isfinite(per_fwd)]), np.log10(np.clip(rho_xy[np.isfinite(per_fwd)], 1e-6, None)), ) obs_log = np.log10(np.clip(rho_obs[mask], 1e-6, None)) return float(np.sqrt(np.mean((obs_log - interp) ** 2))) except Exception: return None def _plot_uncertainty_section( pred_mean: dict, pred_std: dict, pred_lo: dict, pred_hi: dict, n_layers: int, freqs: np.ndarray, ) -> Any: """Plot mean ± 2σ uncertainty section for all stations.""" import matplotlib.pyplot as plt from ..api.section import PYCSAMT_SECTION from ..api.station import PYCSAMT_STATION_RENDERING station_names = list(pred_mean.keys()) n_st = len(station_names) if n_st == 0: return None mat_mu = np.full((n_layers, n_st), np.nan) mat_std = np.full((n_layers, n_st), np.nan) for si, nm in enumerate(station_names): n = min(len(pred_mean[nm]), n_layers) mat_mu[:n, si] = pred_mean[nm][:n] if nm in pred_std: mat_std[:n, si] = pred_std[nm][:n] ths = _default_thicknesses(n_layers, freqs) depths = np.concatenate([[0], np.cumsum(ths)]) / 1000.0 # km section = PYCSAMT_SECTION.style_for("inversion") fig, axes = plt.subplots(1, 2, figsize=(14, 5)) ext = (-0.5, n_st - 0.5, depths[-1], depths[0]) vv = mat_mu[np.isfinite(mat_mu)] vmin = float(np.percentile(vv, 5)) if vv.size else 0.0 vmax = float(np.percentile(vv, 95)) if vv.size else 4.0 im0 = axes[0].imshow( mat_mu, aspect="auto", origin="upper", extent=ext, cmap="jet_r", vmin=vmin, vmax=vmax, interpolation="nearest", ) axes[0].set_title( "Ensemble mean $\\log_{10}\\rho$", fontsize=9, fontweight="bold" ) sv = mat_std[np.isfinite(mat_std)] s_vmax = float(np.percentile(sv, 95)) if sv.size else 0.5 im1 = axes[1].imshow( mat_std, aspect="auto", origin="upper", extent=ext, cmap="Oranges", vmin=0.0, vmax=s_vmax, interpolation="nearest", ) axes[1].set_title( "Uncertainty $\\sigma(\\log_{10}\\rho)$", fontsize=9, fontweight="bold", ) for ax, im, lbl in [ (axes[0], im0, "$\\log_{10}\\rho$ (Ω·m)"), (axes[1], im1, "$\\sigma$ (Ω·m)"), ]: 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=lbl) fig.suptitle( "Ensemble inversion — mean and uncertainty", fontsize=11, fontweight="bold", ) fig.tight_layout() return fig __all__ = ["EnsembleAgent"]