Source code for pycsamt.agents.anomaly_agent

"""
pycsamt.agents.anomaly_agent
=============================

:class:`AnomalyDetectionAgent` — Unsupervised MT data anomaly detection.

Wraps :class:`~pycsamt.ai.processing.anomaly.AnomalyDetector`:

A convolutional autoencoder (CAE) is trained on the clean portions of the
dataset in an unsupervised manner.  Observations whose reconstruction error
exceeds the *threshold_percentile* are flagged as anomalies.

This agent detects anomalous (station, frequency) cells that classical
rule-based QC may miss — e.g. coherent noise from a nearby source, subtle
sensor drift, or 3-D scattering that deviates from the expected frequency
dependence.

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

from __future__ import annotations

import time
from typing import Any

import numpy as np

from ._base import AgentResult, BaseAgent

_SYSTEM_PROMPT = """\
You are an expert in unsupervised anomaly detection for MT/AMT data.
Given an anomaly detection result, write 3-4 sentences that:
1. State how many observations were flagged as anomalous and their distribution.
2. Identify which stations or frequency bands are most affected.
3. Diagnose the likely source (powerline harmonics, near-field, 3-D, instrument).
4. Recommend whether to mask flagged data or apply targeted filtering.
Reply in plain English.
"""


[docs] class AnomalyDetectionAgent(BaseAgent): """Detect anomalous (station, frequency) observations in MT data. Parameters ---------- api_key, model, llm_provider : str threshold_percentile : float Percentile of reconstruction errors used as the flagging threshold (default 95 — top 5 % are anomalies). latent_dim : int CAE latent space dimension (default 32). epochs : int Training epochs (default 50). Input keys ---------- ``sites`` / ``path`` : Sites or str ``output_dir`` : str, optional Output data keys ---------------- ``anomaly_scores`` ndarray — per-(station, freq) reconstruction error ``flags`` ndarray bool — True = anomalous ``flag_table`` pandas DataFrame {station, freq, score, flagged} ``n_flagged`` int ``flagged_stations`` list[str] ``figures`` dict ``figure_paths`` dict """ SYSTEM_PROMPT = _SYSTEM_PROMPT def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", threshold_percentile: float = 95.0, latent_dim: int = 32, epochs: int = 50, ) -> None: super().__init__( "AnomalyDetectionAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="pseudosection", ) self.threshold_percentile = threshold_percentile self.latent_dim = latent_dim self.epochs = epochs
[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.processing.anomaly import ( AnomalyDetector, ) from ..backends import get_backend_instance if get_backend_instance() is None: raise ImportError("No DL backend.") except ImportError as exc: return AgentResult.failed( f"AnomalyDetectionAgent 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, ) from .ai_inversion import _z_to_features 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") freqs = np.logspace(-4, 3, 40) # ── collect features ─────────────────────────────────────────────────── station_names: list[str] = [] feat_list: list[np.ndarray] = [] for i, ed in enumerate(_iter_items(sites)): nm = _name(ed, i) _, z, fr = _get_z_block(ed) if z is None: continue # flat [log10(rho_a_xy), phase_xy] vector, length 2*n_freq feat = _z_to_features(ed, z, fr, freqs) if feat is None: warnings.append(f"{nm}: skipped.") continue station_names.append(nm) feat_list.append(np.asarray(feat, dtype=float).ravel()) if len(feat_list) < 3: return AgentResult.failed( "Need ≥ 3 stations for anomaly detection.", elapsed=time.time() - t0, ) n_sta = len(station_names) # X: (n_obs=n_sta, n_features=2*n_freqs) — flat feature rows X = np.stack(feat_list, axis=0).astype(np.float32) n_feat = X.shape[1] # ── fit AnomalyDetector ──────────────────────────────────────────────── try: detector = AnomalyDetector( n_features=n_feat, latent_dim=self.latent_dim, threshold_percentile=self.threshold_percentile, ) detector.fit( X, epochs=self.epochs, batch_size=max(4, n_sta // 2), verbose=False, ) except Exception as exc: return AgentResult.failed( f"AnomalyDetector.fit failed: {exc}", elapsed=time.time() - t0, ) # ── score + flag ─────────────────────────────────────────────────────── try: scores = detector.transform(X) # (n_sta,) reconstruction errors flags = detector.flag_anomalies(X) # (n_sta,) bool except Exception as exc: return AgentResult.failed( f"AnomalyDetector.transform failed: {exc}", elapsed=time.time() - t0, ) flagged_stations = [nm for nm, f in zip(station_names, flags) if f] n_flagged = int(np.sum(flags)) # ── flag table ───────────────────────────────────────────────────────── try: import pandas as pd flag_table = pd.DataFrame( { "station": station_names, "score": scores, "flagged": flags.astype(bool), } ) except Exception: flag_table = None # ── figures ─────────────────────────────────────────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} try: import matplotlib.pyplot as plt fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4)) # anomaly score bar chart colors = ["#e74c3c" if f else "#2ecc71" for f in flags] ax1.bar( range(n_sta), scores, color=colors, edgecolor="none", alpha=0.8, ) thresh = np.percentile(scores, self.threshold_percentile) ax1.axhline( thresh, color="#e74c3c", lw=1.5, ls="--", label=f"threshold ({self.threshold_percentile:.0f}th pct)", ) ax1.set_xticks(range(n_sta)) ax1.set_xticklabels(station_names, rotation=90, fontsize=6.5) ax1.set_ylabel("Reconstruction error", fontsize=9) ax1.set_title( "Anomaly scores per station", fontsize=9, fontweight="bold" ) ax1.legend(fontsize=8) ax1.tick_params(labelsize=8) # flag map on pseudosection from ..emtools.inspect import pseudosection as _ps try: ax2 = _ps(sites, quantity="rho_xy", ax=ax2) # overlay flagged stations for si, (nm, flag) in enumerate(zip(station_names, flags)): if flag: ax2.axvline(si, color="red", lw=0.8, alpha=0.4) ax2.set_title( "ρa section (red = flagged)", fontsize=9, fontweight="bold", ) except Exception: ax2.text(0.5, 0.5, "pseudosection unavailable", ha="center") fig.suptitle( f"Anomaly detection — {n_flagged}/{n_sta} flagged " f"({100 * n_flagged / max(n_sta, 1):.0f}%)", fontsize=10, fontweight="bold", ) fig.tight_layout() figures["anomaly_map"] = fig p = self._save_figure( fig, output_dir, "anomaly_detection", warnings_list=warnings ) if p: fig_paths["anomaly_map"] = p except Exception as exc: warnings.append(f"Anomaly figure: {exc}") # ── LLM interpretation ──────────────────────────────────────────────── interp: str | None = None if self.api_key: top5 = sorted(zip(station_names, scores), key=lambda x: -x[1])[:5] prompt = ( f"Anomaly detection summary:\n" f" Stations: {n_sta}, flagged: {n_flagged} " f"({100 * n_flagged / max(n_sta, 1):.0f}%)\n" f" Threshold percentile: {self.threshold_percentile:.0f}\n" f" Highest anomaly scores: " f"{[(nm, f'{sc:.3f}') for nm, sc in top5]}\n" f" Flagged: {flagged_stations}\n\n" "Diagnose the anomaly sources and recommend remediation." ) interp = self.query_llm(prompt, max_tokens=200) elapsed = time.time() - t0 return AgentResult( status="success", summary=( f"Anomaly detection: {n_flagged}/{n_sta} stations flagged " f"({100 * n_flagged / max(n_sta, 1):.0f}%). " f"{len(figures)} figures." ), data={ "anomaly_scores": scores, "flags": flags, "flag_table": flag_table, "n_flagged": n_flagged, "flagged_stations": flagged_stations, "figures": figures, "figure_paths": fig_paths, "sites": sites, }, warnings=warnings, llm_interpretation=interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
__all__ = ["AnomalyDetectionAgent"]