Source code for pycsamt.ai.domain_gap.survey_fit

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Fit plausible :class:`~.simulator.CorruptionConfig` ranges from real data.

This module is the bridge between a real field survey (AMT, CSAMT, MT,
or otherwise) and the numpy-only :mod:`pycsamt.ai.domain_gap.simulator`.
It is deliberately split in two:

:func:`survey_data_from_sites`
    Converts EDI/``Sites``/``APISurvey`` input into a canonical
    :class:`~pycsamt.ai.data.contracts.SurveyData`, using only the
    quantities the M1 data contract defines. This path stays numpy-only
    once the bridge itself has run.

:func:`fit_corruption_config`
    Derives plausible noise/error-floor/dropout ranges purely from a
    :class:`~pycsamt.ai.data.contracts.SurveyData`'s own declared errors and
    coverage. No EDI or pandas dependency is required at this stage.

:func:`fit_distortion_priors_from_sites`
    Calls the heavier, pandas-based
    :mod:`pycsamt.emtools.gb`/:mod:`pycsamt.emtools.ss` diagnostics directly
    on real sites to estimate empirical static-shift and galvanic-distortion
    spreads. This is the one part of M3 that genuinely depends on a real
    survey's own QC diagnostics rather than literature defaults.

The frequency grid required by :class:`SurveyData` is *not* silently
interpolated across stations with different sampling: mismatched
frequency grids raise a clear error, per the plan's non-negotiable
principles.
"""

from __future__ import annotations

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

import numpy as np

from ...emtools._core import ensure_sites
from ...emtools.gb import groom_bailey_table
from ...emtools.ss import estimate_ss_ama
from ..data.contracts import SurveyData
from .simulator import CorruptionConfig

__all__ = [
    "survey_data_from_sites",
    "fit_corruption_config",
    "fit_distortion_priors_from_sites",
    "StructuredMissingness",
    "fit_structured_missingness",
]

_COMPONENT_ORDER = ("xx", "xy", "yx", "yy")
_COMP_IJ = {"xx": (0, 0), "xy": (0, 1), "yx": (1, 0), "yy": (1, 1)}
_DEG_THRESHOLD_M = 1e-5  # ~1 m of latitude/longitude spread

_MV_KM_NT_TO_SI = 4.0e-4 * np.pi
"""Convert EDI-native impedance ([mV/km]/[nT]) to SI (V/A = Ohm).

``pycsamt.site.Site.z`` returns the impedance tensor exactly as
stored in the EDI ``Z`` block, in the SEG/EMTF field convention of
millivolts per kilometre per nanotesla -- not SI. This is the same
factor ``Site.rho``'s own ``rho = 0.2 * |Z|**2 / f`` formula encodes
implicitly (``0.2 == _MV_KM_NT_TO_SI ** 2 / (2 * pi * mu0)`` after
substituting into the SI apparent-resistivity formula
``rho = |Z_SI|**2 / (omega * mu0)``), and the same factor
``pycsamt.forward.maxwell.modem3d`` already documents using when it
converts ModEM's own ``[mV/km]/[nT]`` output back to SI.
"""


def _project_local_metres(
    lats: np.ndarray, lons: np.ndarray, *, station_spacing: float
) -> np.ndarray:
    """Project (lat, lon) degrees to local metres; fall back to a grid.

    Mirrors the equirectangular approximation used by
    :func:`pycsamt.ai.inversion._sites_bridge.sites_to_coords_3d`: adequate
    for the short baselines of a single survey line, not a general-purpose
    geodetic projection.
    """
    n = lats.size
    ok = np.isfinite(lats) & np.isfinite(lons)
    lat_range = float(lats[ok].max() - lats[ok].min()) if ok.any() else 0.0
    lon_range = float(lons[ok].max() - lons[ok].min()) if ok.any() else 0.0
    if ok.sum() >= 2 and (
        lat_range > _DEG_THRESHOLD_M or lon_range > _DEG_THRESHOLD_M
    ):
        lat_ref = float(np.nanmean(lats[ok]))
        lon_ref = float(np.nanmean(lons[ok]))
        cos_lat = float(np.cos(np.radians(lat_ref)))
        x = np.where(ok, (lons - lon_ref) * cos_lat * 111320.0, 0.0)
        y = np.where(ok, (lats - lat_ref) * 111320.0, 0.0)
        return np.column_stack([x, y])
    side = int(np.ceil(np.sqrt(n)))
    xs = np.array([(i % side) * station_spacing for i in range(n)])
    ys = np.array([(i // side) * station_spacing for i in range(n)])
    return np.column_stack([xs, ys])


[docs] def survey_data_from_sites( sites: Any, *, crs: str | None = None, freq_rtol: float = 1e-6, station_spacing: float = 500.0, recursive: bool = True, on_dup: str = "replace", verbose: int = 0, metadata: dict[str, Any] | None = None, ) -> SurveyData: """Bridge EDI/``Sites``/``APISurvey`` input to canonical ``SurveyData``. Parameters ---------- sites : Any Anything accepted by :func:`pycsamt.emtools._core.ensure_sites`: a filesystem path/glob/directory, ``EDIFile``/``EDICollection``, ``Site``/``Sites``, ``APISurvey``, or an iterable of these. crs : str, optional Coordinate reference system identifier to record. Station positions are always projected with a local equirectangular approximation (see :func:`pycsamt.ai.inversion._sites_bridge.sites_to_coords_3d`); pass a CRS string only if it genuinely describes that projection. freq_rtol : float, default=1e-6 Relative tolerance used when checking that every station shares the same frequency grid. station_spacing : float, default=500.0 Forwarded to the coordinate bridge as a uniform-grid fallback spacing, used only when no station reports finite coordinates. recursive, on_dup, verbose Forwarded to ``ensure_sites``. metadata : dict, optional Extra provenance recorded on the returned survey. Returns ------- SurveyData Canonical survey with full ``xx, xy, yx, yy`` components, with impedance and its declared error converted from ``Site``'s EDI-native ``[mV/km]/[nT]`` convention to SI (V/A), matching :class:`~pycsamt.ai.data.contracts.SurveyData`'s default :class:`~pycsamt.ai.data.contracts.ImpedanceConvention`. Raises ------ ValueError If no station has usable impedance data, or stations do not share a common frequency grid within ``freq_rtol``. Notes ----- This function performs no frequency interpolation: a survey whose stations were sampled on different frequency grids must be resolved by an explicit, survey-matched frequency selector (an M1 concern) before reaching this bridge. Examples -------- >>> survey = survey_data_from_sites( ... "data/AMT/WILLY_DATA/L18PLT", recursive=False, verbose=0 ... ) # doctest: +SKIP >>> survey.components # doctest: +SKIP ('xx', 'xy', 'yx', 'yy') """ collection = ensure_sites( sites, recursive=recursive, on_dup=on_dup, verbose=verbose ) stations = list(collection) if not stations: raise ValueError("no stations were found in the given sites input.") reference_freq: np.ndarray | None = None reference_name = None kept: list[Any] = [] for site in stations: freq = getattr(site, "freq", None) z = getattr(site, "z", None) if freq is None or z is None: continue freq = np.asarray(freq, dtype=float) if reference_freq is None: reference_freq = freq reference_name = site.name elif freq.shape != reference_freq.shape or not np.allclose( freq, reference_freq, rtol=freq_rtol, atol=0.0 ): raise ValueError( f"station {getattr(site, 'name', '?')!r} does not share the " f"frequency grid of {reference_name!r}; a survey-matched " "frequency selector must resolve this before bridging to " "SurveyData." ) kept.append(site) if reference_freq is None: raise ValueError("no station exposed both freq and z arrays.") n_station = len(kept) n_frequency = reference_freq.size impedance = np.full((n_station, n_frequency, 4), np.nan, dtype=complex) error = np.full((n_station, n_frequency, 4), np.nan, dtype=float) names: list[str] = [] for row, site in enumerate(kept): names.append(str(site.name)) z = np.asarray(site.z) * _MV_KM_NT_TO_SI z_err = getattr(site, "z_err", None) z_err = ( None if z_err is None else np.asarray(z_err, dtype=float) * _MV_KM_NT_TO_SI ) if z.ndim == 3 and z.shape[1:] == (2, 2): for col, name in enumerate(_COMPONENT_ORDER): i, j = _COMP_IJ[name] impedance[row, :, col] = z[:, i, j] if z_err is not None: error[row, :, col] = z_err[:, i, j] elif z.ndim == 2 and z.shape[1] == 4: impedance[row] = z if z_err is not None: error[row] = z_err else: raise ValueError( f"station {site.name!r} has an unsupported z shape {z.shape}." ) lats = np.full(n_station, np.nan) lons = np.full(n_station, np.nan) elevation = np.full(n_station, np.nan) for row, site in enumerate(kept): coords = getattr(site, "coords", None) if coords is None: continue try: if len(coords) >= 2: lats[row] = float(coords[0]) lons[row] = float(coords[1]) if len(coords) >= 3: elevation[row] = float(coords[2]) except (TypeError, ValueError): pass xy = _project_local_metres(lats, lons, station_spacing=station_spacing) coordinates = np.column_stack([xy, elevation]) has_error = np.any(np.isfinite(error)) return SurveyData( impedance=impedance, frequencies_hz=reference_freq, station_names=tuple(names), components=_COMPONENT_ORDER, coordinates_m=coordinates, impedance_error=error if has_error else None, crs=crs, metadata=metadata or {}, )
[docs] def fit_corruption_config( survey: SurveyData, *, severity_scale: float = 1.0, components: Sequence[str] | None = None, noise_sampling: str = "lognormal", ) -> CorruptionConfig: """Derive plausible noise/dropout ranges from a real survey's QC. Only quantities already present in the canonical :class:`~pycsamt.ai.data.contracts.SurveyData` contract are used: the ``impedance_error``-to-``|Z|`` ratio for heteroscedastic noise and error floor, and :meth:`~pycsamt.ai.data.contracts.SurveyData.coverage` for dropout rates. Parameters ---------- survey : SurveyData Real (or realistically corrupted) survey to profile. severity_scale : float, default=1.0 Multiplier applied to every fitted range/rate, letting a caller derive a milder or harsher preset from the same empirical fit. components : sequence of str, optional Impedance components whose error ratio calibrates the noise range and error floor, e.g. ``("xy", "yx")``. Calibrate on the components that will later be compared: diagonal components usually carry much larger relative errors, and including them inflates the fitted range for an off-diagonal comparison. ``None`` uses every component present. Dropout rates always use the full coverage mask. noise_sampling : {"lognormal", "loguniform", "uniform"}, default="lognormal" Stored on the returned configuration. The default treats the fitted range as the interquartile range of a log-normal error distribution, which reproduces the median and spread of declared field errors; ``"uniform"`` restores a flat draw between the quartiles. Returns ------- CorruptionConfig Configuration whose noise range spans the interquartile range of the observed relative error, whose error floor is the fifth percentile of that ratio, and whose dropout rates equal the observed missing fractions. Distortion and outlier parameters are left at zero; see :func:`fit_distortion_priors_from_sites` for those. Raises ------ ValueError If ``survey`` has no declared ``impedance_error`` to profile. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((4, 6, 2), 100 + 50j) >>> err = np.full((4, 6, 2), 3.0) >>> survey = SurveyData( ... z, ... np.linspace(1000, 1, 6), ... ["A", "B", "C", "D"], ... ["xy", "yx"], ... np.zeros((4, 2)), ... impedance_error=err, ... ) >>> config = fit_corruption_config(survey) >>> config.noise_level_range[0] >= 0.0 True """ if severity_scale <= 0.0 or not np.isfinite(severity_scale): raise ValueError("severity_scale must be finite and positive.") if survey.impedance_error is None: raise ValueError( "survey has no impedance_error to profile; supply a survey with " "declared errors, or build a CorruptionConfig from literature " "defaults instead." ) valid = survey.valid if components is not None: missing = [c for c in components if c not in survey.components] if missing: raise ValueError( f"components {missing} are not in survey.components " f"{survey.components}." ) keep = np.zeros(survey.shape, dtype=bool) for c in components: keep[..., survey.components.index(c)] = True valid = valid & keep ratio = np.abs(survey.impedance_error[valid]) / np.maximum( np.abs(survey.impedance[valid]), 1e-24 ) ratio = ratio[np.isfinite(ratio)] if ratio.size == 0: raise ValueError( "no valid observations with finite error ratio were found." ) lo = float(np.percentile(ratio, 25)) * severity_scale hi = float(np.percentile(ratio, 75)) * severity_scale floor = float(np.percentile(ratio, 5)) * severity_scale lo, hi = sorted((lo, hi)) coverage = survey.coverage() station_dropout = float( np.clip((1.0 - coverage.by_station).mean() * severity_scale, 0.0, 1.0) ) frequency_dropout = float( np.clip( (1.0 - coverage.by_frequency).mean() * severity_scale, 0.0, 1.0 ) ) random_dropout = float( np.clip((1.0 - coverage.overall) * severity_scale, 0.0, 1.0) ) return CorruptionConfig( noise_level_range=(lo, hi), error_floor_fraction=floor, station_dropout_rate=station_dropout, frequency_dropout_rate=frequency_dropout, random_dropout_rate=random_dropout, noise_sampling=noise_sampling, )
[docs] def fit_distortion_priors_from_sites( sites: Any, *, recursive: bool = True, on_dup: str = "replace", verbose: int = 0, **kwargs: Any, ) -> dict[str, float]: """Estimate empirical static-shift and distortion spreads from real EDI. This is the one M3 entry point that genuinely depends on the heavier, pandas-based EM diagnostics in :mod:`pycsamt.emtools.gb` and :mod:`pycsamt.emtools.ss`, run directly on real sites (e.g. a WILLY line) rather than on the numpy-only :class:`SurveyData` contract. Parameters ---------- sites : Any Anything accepted by :func:`pycsamt.emtools._core.ensure_sites`. recursive, on_dup, verbose Forwarded to the underlying diagnostics. **kwargs Forwarded to :func:`pycsamt.emtools.gb.groom_bailey_table`. Returns ------- dict ``static_shift_log10_sigma``, ``distortion_gain_log10_sigma``, ``distortion_twist_deg_sigma``, ``distortion_shear_sigma``, and ``distortion_anisotropy_sigma``, each the population standard deviation of the corresponding per-station fitted parameter across stations with a successful fit. A parameter is ``0.0`` when fewer than two stations produced a usable fit. Examples -------- >>> priors = fit_distortion_priors_from_sites( ... "data/AMT/WILLY_DATA/L18PLT", recursive=False, verbose=0 ... ) # doctest: +SKIP >>> sorted(priors) # doctest: +SKIP ['distortion_anisotropy_sigma', 'distortion_gain_log10_sigma', \ 'distortion_shear_sigma', 'distortion_twist_deg_sigma', \ 'static_shift_log10_sigma'] """ zero = { "static_shift_log10_sigma": 0.0, "distortion_gain_log10_sigma": 0.0, "distortion_twist_deg_sigma": 0.0, "distortion_shear_sigma": 0.0, "distortion_anisotropy_sigma": 0.0, } gb_table = groom_bailey_table( sites, recursive=recursive, on_dup=on_dup, verbose=verbose, api=False, **kwargs, ) ok = gb_table[gb_table["status"] == "ok"] if len(gb_table) else gb_table if len(ok) >= 2: zero["distortion_gain_log10_sigma"] = float( np.std(np.log10(ok["gain"].to_numpy())) ) zero["distortion_twist_deg_sigma"] = float( np.std(ok["twist_deg"].to_numpy()) ) zero["distortion_shear_sigma"] = float(np.std(ok["shear"].to_numpy())) zero["distortion_anisotropy_sigma"] = float( np.std(ok["anisotropy"].to_numpy()) ) ss_table = estimate_ss_ama( sites, recursive=recursive, on_dup=on_dup, verbose=verbose, api=False ) if len(ss_table) >= 2: # delta_log10_rho is a shift on rho_a; Z scales as sqrt(rho_a). zero["static_shift_log10_sigma"] = 0.5 * float( np.std(ss_table["delta_log10_rho"].to_numpy()) ) return zero
def _sum_zero_contrast(n: int) -> np.ndarray: """Return the ``(n, n-1)`` sum-to-zero contrast matrix ``C``. ``effect = C @ theta`` satisfies ``sum(effect) == 0`` for every ``theta`` in ``R**(n-1)``, the standard "effect coding" trick for fitting a categorical main effect without an arbitrary reference level. """ if n < 1: raise ValueError("n must be positive.") contrast = np.zeros((n, max(n - 1, 0))) if n > 1: contrast[: n - 1, :] = np.eye(n - 1) contrast[n - 1, :] = -1.0 return contrast
[docs] @dataclass(frozen=True) class StructuredMissingness: """Fitted station+frequency+component logistic missingness model. ``logit(pi_sjc) = alpha + a_s + b_j + q_c``, with the station, frequency, and component effects ``a``, ``b``, ``q`` each constrained to sum to zero for identifiability (an effect relative to its own group's mean, not to an arbitrary reference level). This replaces the three independent scalar dropout rates of :func:`fit_corruption_config`/:func:`~.simulator.apply_dropout` -- which are mathematically forced to be numerically identical whenever each is the mean of the same overall coverage deficit taken along a different axis -- with an explicit model of *where* missingness concentrates. Fit by :func:`fit_structured_missingness`. Parameters ---------- alpha : float Intercept: logit of the fitted overall mean dropout probability. station_effect, frequency_effect, component_effect : tuple of float Per-station, per-frequency, and per-component effects, each summing to zero by construction. station_names, frequencies_hz, components : tuple Identities the effects are indexed by, kept for provenance. The fitted probability field is tied to this specific grid; it is not meant to be reused against a differently shaped survey. l2 : float, default=1.0 L2 penalty weight used during fitting (not applied to ``alpha``). Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.ones((3, 4, 1), dtype=complex) >>> z[0, :2] = np.nan >>> survey = SurveyData( ... z, [4.0, 3.0, 2.0, 1.0], ["A", "B", "C"], ["xy"], ... np.zeros((3, 2)), ... ) >>> model = fit_structured_missingness(survey) >>> model.dropout_probability().shape (3, 4, 1) """ alpha: float station_effect: tuple[float, ...] frequency_effect: tuple[float, ...] component_effect: tuple[float, ...] station_names: tuple[str, ...] frequencies_hz: tuple[float, ...] components: tuple[str, ...] l2: float = 1.0
[docs] def dropout_probability(self) -> np.ndarray: """Return the fitted per-cell Bernoulli dropout probability. Returns ------- ndarray, shape (n_station, n_frequency, n_component) ``sigmoid(alpha + a_s + b_j + q_c)``, ready to pass to :func:`~.simulator.apply_structured_dropout`. Examples -------- >>> model = StructuredMissingness( ... alpha=0.0, station_effect=(0.0,), frequency_effect=(0.0,), ... component_effect=(0.0,), station_names=("A",), ... frequencies_hz=(1.0,), components=("xy",), ... ) >>> model.dropout_probability() array([[[0.5]]]) """ a = np.asarray(self.station_effect, dtype=float)[:, None, None] b = np.asarray(self.frequency_effect, dtype=float)[None, :, None] q = np.asarray(self.component_effect, dtype=float)[None, None, :] z = self.alpha + a + b + q return 1.0 / (1.0 + np.exp(-z))
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable representation. Returns ------- dict All fields as plain Python types, with a schema discriminator. Examples -------- >>> model = StructuredMissingness( ... alpha=0.0, station_effect=(0.0,), frequency_effect=(0.0,), ... component_effect=(0.0,), station_names=("A",), ... frequencies_hz=(1.0,), components=("xy",), ... ) >>> model.to_dict()["schema_version"] 1 """ return { "schema_version": 1, "alpha": self.alpha, "station_effect": list(self.station_effect), "frequency_effect": list(self.frequency_effect), "component_effect": list(self.component_effect), "station_names": list(self.station_names), "frequencies_hz": list(self.frequencies_hz), "components": list(self.components), "l2": self.l2, }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> StructuredMissingness: """Restore a serialized model. Parameters ---------- data : mapping State previously returned by :meth:`to_dict`. Returns ------- StructuredMissingness Reconstructed, validated model. Examples -------- >>> model = StructuredMissingness( ... alpha=0.0, station_effect=(0.0,), frequency_effect=(0.0,), ... component_effect=(0.0,), station_names=("A",), ... frequencies_hz=(1.0,), components=("xy",), ... ) >>> StructuredMissingness.from_dict(model.to_dict()) == model True """ if data.get("schema_version") != 1: raise ValueError( "unsupported StructuredMissingness schema version." ) return cls( alpha=float(data["alpha"]), station_effect=tuple(float(v) for v in data["station_effect"]), frequency_effect=tuple( float(v) for v in data["frequency_effect"] ), component_effect=tuple( float(v) for v in data["component_effect"] ), station_names=tuple(data["station_names"]), frequencies_hz=tuple(float(v) for v in data["frequencies_hz"]), components=tuple(data["components"]), l2=float(data["l2"]), )
[docs] def fit_structured_missingness( survey: SurveyData, *, l2: float = 1.0, max_iter: int = 500, ) -> StructuredMissingness: r"""Fit a station+frequency+component logistic missingness model. Replaces the three scalar dropout rates of :func:`fit_corruption_config` with an explicit model of where a survey's missingness is concentrated. With ``Y_sjc = 1 - survey.valid[s, j, c]`` the observed missingness indicator and ``pi_sjc = sigmoid(alpha + a_s + b_j + q_c)``, the fit solves the penalized Bernoulli maximum-likelihood problem .. math:: \hat\vartheta = \arg\min_\vartheta \Bigl[ -\sum_{sjc}\bigl\{Y_{sjc}\log\pi_{sjc} +(1-Y_{sjc})\log(1-\pi_{sjc})\bigr\} +\lambda\bigl(\lVert a\rVert_2^2+\lVert b\rVert_2^2 +\lVert q\rVert_2^2\bigr)\Bigr], with ``a``, ``b``, ``q`` each parameterized through a sum-to-zero contrast (:func:`_sum_zero_contrast`) so the fit is identified without an arbitrary reference level, optimized by L-BFGS-B using the closed-form gradient of the penalized negative log-likelihood (``d(nll)/dz_sjc = pi_sjc - Y_sjc``, the standard logistic-regression result). Parameters ---------- survey : SurveyData Survey whose ``valid`` mask defines the real missingness pattern to fit. Should carry a genuine, disclosed missing-data mask (e.g. a coverage-aware, snap-or-missing survey), not one already interpolated/filled -- interpolating over every gap removes the very signal this fit depends on. l2 : float, default=1.0 Ridge penalty weight :math:`\lambda` on the station, frequency, and component effects (the intercept is not penalized). max_iter : int, default=500 Maximum L-BFGS-B iterations. Returns ------- StructuredMissingness Fitted model. Its :meth:`~StructuredMissingness.dropout_probability` gives the full ``(n_station, n_frequency, n_component)`` probability field for :func:`~.simulator.apply_structured_dropout`. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.ones((3, 4, 1), dtype=complex) >>> z[0, :2] = np.nan >>> survey = SurveyData( ... z, [4.0, 3.0, 2.0, 1.0], ["A", "B", "C"], ["xy"], ... np.zeros((3, 2)), ... ) >>> model = fit_structured_missingness(survey) >>> abs(sum(model.station_effect)) < 1e-6 True >>> abs(sum(model.frequency_effect)) < 1e-6 True """ from scipy.optimize import minimize if l2 < 0.0 or not np.isfinite(l2): raise ValueError("l2 must be finite and non-negative.") y = (~survey.valid).astype(float) n_station, n_frequency, n_component = survey.shape c_a = _sum_zero_contrast(n_station) c_b = _sum_zero_contrast(n_frequency) c_q = _sum_zero_contrast(n_component) n_a, n_b, n_q = c_a.shape[1], c_b.shape[1], c_q.shape[1] def unpack(theta: np.ndarray) -> tuple[float, np.ndarray, np.ndarray, np.ndarray]: alpha = theta[0] theta_a = theta[1 : 1 + n_a] theta_b = theta[1 + n_a : 1 + n_a + n_b] theta_q = theta[1 + n_a + n_b :] return alpha, theta_a, theta_b, theta_q def neg_log_posterior(theta: np.ndarray) -> tuple[float, np.ndarray]: alpha, theta_a, theta_b, theta_q = unpack(theta) a = c_a @ theta_a b = c_b @ theta_b q = c_q @ theta_q z = alpha + a[:, None, None] + b[None, :, None] + q[None, None, :] pi = 1.0 / (1.0 + np.exp(-z)) eps = 1e-12 nll = -np.sum( y * np.log(pi + eps) + (1.0 - y) * np.log(1.0 - pi + eps) ) penalty = l2 * (np.sum(a**2) + np.sum(b**2) + np.sum(q**2)) loss = nll + penalty g = pi - y d_alpha = np.sum(g) d_theta_a = c_a.T @ g.sum(axis=(1, 2)) + 2.0 * l2 * (c_a.T @ a) d_theta_b = c_b.T @ g.sum(axis=(0, 2)) + 2.0 * l2 * (c_b.T @ b) d_theta_q = c_q.T @ g.sum(axis=(0, 1)) + 2.0 * l2 * (c_q.T @ q) grad = np.concatenate(([d_alpha], d_theta_a, d_theta_b, d_theta_q)) return float(loss), grad theta0 = np.zeros(1 + n_a + n_b + n_q) result = minimize( neg_log_posterior, theta0, jac=True, method="L-BFGS-B", options={"maxiter": max_iter}, ) alpha, theta_a, theta_b, theta_q = unpack(result.x) a = c_a @ theta_a b = c_b @ theta_b q = c_q @ theta_q return StructuredMissingness( alpha=float(alpha), station_effect=tuple(float(v) for v in a), frequency_effect=tuple(float(v) for v in b), component_effect=tuple(float(v) for v in q), station_names=tuple(survey.station_names), frequencies_hz=tuple(float(v) for v in survey.frequencies_hz), components=tuple(survey.components), l2=float(l2), )