Source code for pycsamt.ai.domain_gap.report

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Quantitative comparison between simulated and field feature distributions.

The M3 acceptance gate requires that "simulated and field feature
distributions are compared quantitatively" rather than judged by eye. This
module computes per-feature summary statistics and a two-sample
Kolmogorov-Smirnov test between a simulated (corrupted synthetic) survey
and a real field survey, on features derived from the shared
:class:`~pycsamt.ai.data.contracts.SurveyData` contract so the comparison
never depends on how either survey was produced.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any

import numpy as np

from ..data.contracts import SurveyData

__all__ = [
    "FeatureComparison",
    "DistributionComparisonReport",
    "compare_feature_distributions",
    "compare_survey_distributions",
]

_DEFAULT_FEATURES = (
    "log_impedance_magnitude",
    "phase_deg",
    "error_to_magnitude_ratio",
)


def _feature_values(survey: SurveyData, feature: str) -> np.ndarray:
    valid = survey.valid
    z = survey.impedance[valid]
    if feature == "log_impedance_magnitude":
        magnitude = np.abs(z)
        return np.log10(magnitude[magnitude > 0])
    if feature == "phase_deg":
        return np.degrees(np.angle(z))
    if feature == "error_to_magnitude_ratio":
        if survey.impedance_error is None:
            return np.empty(0)
        error = survey.impedance_error[valid]
        magnitude = np.abs(z)
        ratio = error[magnitude > 0] / magnitude[magnitude > 0]
        return ratio[np.isfinite(ratio)]
    raise ValueError(
        f"unknown feature {feature!r}; choose from {_DEFAULT_FEATURES} "
        "or supply values directly."
    )


[docs] @dataclass(frozen=True) class FeatureComparison: """Quantitative comparison of one feature between two distributions. Parameters ---------- feature : str Name of the compared feature. simulated_stats, field_stats : mapping ``count``, ``mean``, ``std``, ``median`` of each sample. ks_statistic, ks_pvalue : float Two-sample Kolmogorov-Smirnov statistic and p-value; ``NaN`` when either sample is empty. mean_difference : float ``simulated mean - field mean``. std_ratio : float ``simulated std / field std``; ``NaN`` when the field std is zero. """ feature: str simulated_stats: Mapping[str, float] field_stats: Mapping[str, float] ks_statistic: float ks_pvalue: float mean_difference: float std_ratio: float
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable representation. Returns ------- dict All fields, with statistics mappings converted to plain dicts. Examples -------- >>> import numpy as np >>> comparison = compare_feature_distributions( ... np.array([1.0, 2.0, 3.0]), ... np.array([1.0, 2.0, 3.0]), ... feature="custom", ... ) >>> comparison.to_dict()["feature"] 'custom' """ return { "feature": self.feature, "simulated_stats": dict(self.simulated_stats), "field_stats": dict(self.field_stats), "ks_statistic": self.ks_statistic, "ks_pvalue": self.ks_pvalue, "mean_difference": self.mean_difference, "std_ratio": self.std_ratio, }
[docs] @dataclass(frozen=True) class DistributionComparisonReport: """Collection of :class:`FeatureComparison` results across features. Parameters ---------- comparisons : mapping Feature name to :class:`FeatureComparison`. """ comparisons: Mapping[str, FeatureComparison]
[docs] def worst_feature(self) -> str: """Return the feature name with the largest KS statistic. Returns ------- str Feature name whose simulated/field distributions differ most, ignoring features with a ``NaN`` statistic (empty samples). Raises ------ ValueError If every feature has a ``NaN`` KS statistic. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((2, 4, 1), 100 + 50j) >>> survey = SurveyData( ... z, np.linspace(100, 1, 4), ["A", "B"], ["xy"], np.zeros((2, 2)) ... ) >>> report = compare_survey_distributions(survey, survey) >>> report.worst_feature() in report.comparisons True """ finite = { name: comparison.ks_statistic for name, comparison in self.comparisons.items() if np.isfinite(comparison.ks_statistic) } if not finite: raise ValueError("no feature produced a finite KS statistic.") return max(finite, key=finite.get)
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable representation. Returns ------- dict Mapping of feature name to its comparison dict. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((2, 4, 1), 100 + 50j) >>> survey = SurveyData( ... z, np.linspace(100, 1, 4), ["A", "B"], ["xy"], np.zeros((2, 2)) ... ) >>> report = compare_survey_distributions(survey, survey) >>> sorted(report.to_dict()) ['error_to_magnitude_ratio', 'log_impedance_magnitude', 'phase_deg'] """ return { name: comparison.to_dict() for name, comparison in self.comparisons.items() }
def _summary(values: np.ndarray) -> dict[str, float]: if values.size == 0: return { "count": 0, "mean": float("nan"), "std": float("nan"), "median": float("nan"), } return { "count": int(values.size), "mean": float(np.mean(values)), "std": float(np.std(values)), "median": float(np.median(values)), }
[docs] def compare_feature_distributions( simulated: np.ndarray | Sequence[float], field: np.ndarray | Sequence[float], *, feature: str, ) -> FeatureComparison: """Compare two 1-D samples of the same feature quantitatively. Parameters ---------- simulated, field : array-like Feature values already extracted from each survey (see :func:`compare_survey_distributions` to extract them from :class:`~pycsamt.ai.data.contracts.SurveyData` directly). feature : str Label recorded on the returned :class:`FeatureComparison`. Returns ------- FeatureComparison Summary statistics, mean difference, std ratio, and two-sample KS test between the samples. Examples -------- >>> import numpy as np >>> rng = np.random.default_rng(0) >>> comparison = compare_feature_distributions( ... rng.normal(0, 1, 200), rng.normal(0, 1, 200), feature="demo" ... ) >>> comparison.ks_pvalue > 0.01 True """ simulated = np.asarray(simulated, dtype=float) field = np.asarray(field, dtype=float) simulated = simulated[np.isfinite(simulated)] field = field[np.isfinite(field)] ks_statistic = float("nan") ks_pvalue = float("nan") if simulated.size > 0 and field.size > 0: from scipy import stats result = stats.ks_2samp(simulated, field) ks_statistic = float(result.statistic) ks_pvalue = float(result.pvalue) sim_stats = _summary(simulated) field_stats = _summary(field) mean_difference = sim_stats["mean"] - field_stats["mean"] field_std = field_stats["std"] std_ratio = ( sim_stats["std"] / field_std if np.isfinite(field_std) and field_std != 0.0 else float("nan") ) return FeatureComparison( feature=feature, simulated_stats=sim_stats, field_stats=field_stats, ks_statistic=ks_statistic, ks_pvalue=ks_pvalue, mean_difference=mean_difference, std_ratio=std_ratio, )
[docs] def compare_survey_distributions( simulated: SurveyData, field: SurveyData, *, features: Sequence[str] = _DEFAULT_FEATURES, ) -> DistributionComparisonReport: """Compare simulated and field surveys across several canonical features. Parameters ---------- simulated : SurveyData Simulated (e.g. corrupted synthetic) survey. field : SurveyData Real field survey, ideally sharing the simulated survey's frequency band and component set for a meaningful comparison. features : sequence of str, default features Any of ``"log_impedance_magnitude"``, ``"phase_deg"``, or ``"error_to_magnitude_ratio"``. The error-ratio feature is skipped (empty sample) for a survey without declared errors. Returns ------- DistributionComparisonReport One :class:`FeatureComparison` per requested feature. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((2, 4, 1), 100 + 50j) >>> survey = SurveyData( ... z, np.linspace(100, 1, 4), ["A", "B"], ["xy"], np.zeros((2, 2)) ... ) >>> report = compare_survey_distributions(survey, survey) >>> report.comparisons["phase_deg"].mean_difference 0.0 """ comparisons = {} for feature in features: sim_values = _feature_values(simulated, feature) field_values = _feature_values(field, feature) comparisons[feature] = compare_feature_distributions( sim_values, field_values, feature=feature ) return DistributionComparisonReport(comparisons=comparisons)