Source code for pycsamt.forward.noise

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
Noise models for synthetic EM training data.

Noise injection turns clean forward responses into realistic
training samples that improve neural-network generalisation.
Two families are provided:

``GaussianNoise``
    Simple additive relative Gaussian noise, identical at all
    frequencies/times.  Fast and widely used in the literature
    (e.g. Puzyrev 2021 applies 1–2 % relative noise).

``FieldRealisticNoise``
    Frequency-dependent noise model that mimics real-world MT/CSAMT
    data quality:

    * Cultural EM noise peaks at 50/60 Hz power-line frequencies.
    * Dead-band effect (elevated noise at ~1 000–3 000 s period) due to
      reduced natural signal in the MT spectrum.
    * Early-time TEM noise from transmitter turn-off and system
      bandwidth.

``MultiplicativeNoise``
    Log-space additive noise — useful when the dynamic range of the
    data spans several decades (log₁₀ space perturbation).

All noise classes follow the same interface::

    noisy_resp = noise_model.apply(clean_resp)

or the functional helpers ``add_gaussian_noise`` / ``add_noise`` can be
used directly.

References
----------
Puzyrev, V. & Swidinsky, A. (2021). Inversion of 1D frequency- and
time-domain EM data with CNNs. *Computers & Geosciences*, 149, 104681.

Egbert, G.D. (1997). Robust multiple station magnetotelluric data
processing. *Geophysical Journal International*, 130, 475–496.
"""

from __future__ import annotations

from abc import ABC, abstractmethod

import numpy as np

from .em1d import ForwardResponse

__all__ = [
    "GaussianNoise",
    "FieldRealisticNoise",
    "MultiplicativeNoise",
    "add_gaussian_noise",
    "add_noise",
]


# ─────────────────────────────────────────────────────────────────────────────
# Abstract base
# ─────────────────────────────────────────────────────────────────────────────


class _BaseNoiseModel(ABC):
    """Abstract base for all noise models."""

    @abstractmethod
    def apply(
        self,
        response: ForwardResponse,
        *,
        seed: int | None = None,
    ) -> ForwardResponse:
        """
        Return a new :class:`~pycsamt.forward.em1d.ForwardResponse`
        with noise added.

        Parameters
        ----------
        response : ForwardResponse
            Clean forward response.
        seed : int or None
            Random seed for reproducibility.
        """

    def __call__(
        self, response: ForwardResponse, **kwargs
    ) -> ForwardResponse:
        return self.apply(response, **kwargs)


# ─────────────────────────────────────────────────────────────────────────────
# Gaussian noise
# ─────────────────────────────────────────────────────────────────────────────


[docs] class GaussianNoise(_BaseNoiseModel): """ Add relative Gaussian noise to an EM forward response. For MT/CSAMT the noise is applied to log₁₀(ρ_a) and the phase independently. For TEM it is applied to log₁₀(|dBz/dt|). Parameters ---------- level : float Relative noise standard deviation (e.g. 0.05 = 5 %). apply_to : {'rho_phase', 'z', 'both'} Which quantities to perturb. Default ``'rho_phase'``. phase_level : float or None Separate noise level for phase in degrees. If ``None``, *level* is used scaled to the typical 45° phase range. Examples -------- >>> import numpy as np >>> from pycsamt.forward.em1d import MT1DForward >>> from pycsamt.forward.synthetic import LayeredModel >>> from pycsamt.forward.noise import GaussianNoise >>> m = LayeredModel([100, 10, 500], [300, 800]) >>> resp = MT1DForward(np.logspace(-3, 4, 20)).run(m) >>> noisy = GaussianNoise(level=0.05).apply(resp, seed=0) >>> noisy.rho_a.shape (20,) """ def __init__( self, level: float = 0.05, apply_to: str = "rho_phase", phase_level: float | None = None, ): if not 0.0 < level < 1.0: raise ValueError(f"level must be in (0, 1), got {level}") self.level = level self.apply_to = apply_to self.phase_level = ( phase_level if phase_level is not None else level * 45.0 )
[docs] def apply( self, response: ForwardResponse, *, seed: int | None = None, ) -> ForwardResponse: rng = np.random.default_rng(seed) if response.method in ("MT1D", "CSAMT1D"): return self._apply_mt(response, rng) return self._apply_tem(response, rng)
def _apply_mt(self, resp: ForwardResponse, rng) -> ForwardResponse: rho_a = resp.rho_a.copy() phase = resp.phase.copy() if self.apply_to in ("rho_phase", "both", "rho"): # Noise in log₁₀ space then exponentiate log_rho = np.log10(rho_a) log_rho += rng.normal(0.0, self.level, rho_a.shape) rho_a = 10.0**log_rho if self.apply_to in ("rho_phase", "both", "phase"): phase += rng.normal(0.0, self.phase_level, phase.shape) # Recompute Z consistent with perturbed rho_a and phase omega = 2.0 * np.pi * resp.freqs from .em1d import MU0 z = np.sqrt(rho_a * omega * MU0) * np.exp(1j * np.deg2rad(phase)) import copy out = copy.copy(resp) out.rho_a = rho_a out.phase = phase out.z = z return out def _apply_tem(self, resp: ForwardResponse, rng) -> ForwardResponse: db = resp.dBz_dt.copy() log_db = np.log10(np.maximum(np.abs(db), 1e-30)) log_db += rng.normal(0.0, self.level, db.shape) import copy out = copy.copy(resp) out.dBz_dt = np.sign(db) * 10.0**log_db return out
# ───────────────────────────────────────────────────────────────────────────── # Multiplicative log-space noise # ─────────────────────────────────────────────────────────────────────────────
[docs] class MultiplicativeNoise(_BaseNoiseModel): """ Log-space (multiplicative) Gaussian noise. Equivalent to a log-normal perturbation on the data value. Appropriate when the data spans many orders of magnitude. Parameters ---------- sigma_log10 : float Standard deviation of the log₁₀ perturbation. ``sigma_log10=0.05`` corresponds to ≈ ±12 % variation. """ def __init__(self, sigma_log10: float = 0.05): self.sigma_log10 = sigma_log10
[docs] def apply( self, response: ForwardResponse, *, seed: int | None = None, ) -> ForwardResponse: rng = np.random.default_rng(seed) import copy out = copy.copy(response) if response.rho_a is not None: log_rho = np.log10(np.maximum(response.rho_a, 1e-12)) out.rho_a = 10.0 ** ( log_rho + rng.normal(0.0, self.sigma_log10, log_rho.shape) ) if response.dBz_dt is not None: db = response.dBz_dt log_db = np.log10(np.maximum(np.abs(db), 1e-30)) out.dBz_dt = np.sign(db) * 10.0 ** ( log_db + rng.normal(0.0, self.sigma_log10, db.shape) ) return out
# ───────────────────────────────────────────────────────────────────────────── # Field-realistic noise (frequency-dependent) # ─────────────────────────────────────────────────────────────────────────────
[docs] class FieldRealisticNoise(_BaseNoiseModel): """ Frequency-dependent noise model for MT/CSAMT training data. Simulates three dominant noise sources present in field data: 1. **Power-line harmonics** — Gaussian spikes at 50 Hz, 100 Hz, 150 Hz (or 60/120/180 Hz for North American data). 2. **MT dead band** — elevated noise at ~1 000–3 000 s period (≈ 3×10⁻⁴ – 10⁻³ Hz) due to reduced natural source energy. 3. **Background floor** — minimum noise level applied uniformly. Parameters ---------- base_level : float Background relative noise floor. Default 0.02 (2 %). powerline_freq : float Fundamental power-line frequency [Hz]. 50 or 60. powerline_level : float Additional noise at power-line harmonics (relative). dead_band : bool If ``True``, inflate noise in the MT dead band. dead_band_freq_range : (low, high) Frequency range [Hz] of the dead band. Default (3e-4, 1e-3). dead_band_level : float Additional noise level in the dead band. Default 0.15. """ def __init__( self, base_level: float = 0.02, powerline_freq: float = 50.0, powerline_level: float = 0.30, dead_band: bool = True, dead_band_freq_range: tuple = (3e-4, 1e-3), dead_band_level: float = 0.15, ): self.base_level = base_level self.powerline_freq = powerline_freq self.powerline_level = powerline_level self.dead_band = dead_band self.dead_band_freq_range = dead_band_freq_range self.dead_band_level = dead_band_level
[docs] def noise_profile(self, freqs: np.ndarray) -> np.ndarray: """ Return the frequency-dependent noise level array. Parameters ---------- freqs : ndarray Frequencies [Hz]. Returns ------- sigma : ndarray, same shape as *freqs* Relative noise standard deviation at each frequency. """ sigma = np.full(len(freqs), self.base_level) # Power-line harmonics (50, 100, 150 Hz etc.) for k in range(1, 5): f_harm = k * self.powerline_freq mask = np.abs(freqs - f_harm) / f_harm < 0.05 # within 5 % sigma[mask] = np.maximum(sigma[mask], self.powerline_level / k) # MT dead band if self.dead_band: f_lo, f_hi = self.dead_band_freq_range db_mask = (freqs >= f_lo) & (freqs <= f_hi) sigma[db_mask] = np.maximum(sigma[db_mask], self.dead_band_level) return sigma
[docs] def apply( self, response: ForwardResponse, *, seed: int | None = None, ) -> ForwardResponse: if response.freqs is None: raise ValueError( "FieldRealisticNoise requires freqs; use GaussianNoise for TEM." ) rng = np.random.default_rng(seed) sigma = self.noise_profile(response.freqs) import copy out = copy.copy(response) if response.rho_a is not None: log_rho = np.log10(np.maximum(response.rho_a, 1e-12)) log_rho += rng.normal(0.0, 1.0, sigma.shape) * sigma out.rho_a = 10.0**log_rho if response.phase is not None: out.phase = ( response.phase + rng.normal(0.0, 1.0, sigma.shape) * sigma * 45.0 ) if out.rho_a is not None and out.phase is not None: omega = 2.0 * np.pi * response.freqs from .em1d import MU0 out.z = np.sqrt(out.rho_a * omega * MU0) * np.exp( 1j * np.deg2rad(out.phase) ) return out
[docs] def plot_profile(self, freqs: np.ndarray, ax=None): """Visualise the noise level vs frequency. Returns Axes.""" import matplotlib.pyplot as plt if ax is None: _, ax = plt.subplots(figsize=(5, 3)) sigma = self.noise_profile(freqs) ax.semilogx(freqs, sigma * 100.0, color="firebrick") ax.set_xlabel("Frequency (Hz)") ax.set_ylabel("Noise level (%)") ax.set_title("Field-realistic noise profile") ax.grid(True, which="both", alpha=0.3) return ax
# ───────────────────────────────────────────────────────────────────────────── # Functional helpers # ─────────────────────────────────────────────────────────────────────────────
[docs] def add_gaussian_noise( response: ForwardResponse, level: float = 0.05, *, seed: int | None = None, ) -> ForwardResponse: """ Convenience wrapper: add Gaussian noise at a given relative level. Parameters ---------- response : ForwardResponse level : float Relative noise standard deviation. seed : int or None Returns ------- ForwardResponse New response with noise applied. """ return GaussianNoise(level).apply(response, seed=seed)
[docs] def add_noise( response: ForwardResponse, noise_model: _BaseNoiseModel | str = "gaussian", level: float = 0.05, *, seed: int | None = None, **kwargs, ) -> ForwardResponse: """ Apply a named or pre-built noise model to a forward response. Parameters ---------- response : ForwardResponse noise_model : str or noise instance ``'gaussian'``, ``'multiplicative'``, ``'field'``, or an already-instantiated noise object. level : float Base noise level (used when constructing named models). seed : int or None **kwargs Extra keyword arguments forwarded to the noise model constructor. Returns ------- ForwardResponse """ if isinstance(noise_model, str): nm = noise_model.lower() if nm in ("gaussian", "gauss"): model = GaussianNoise(level, **kwargs) elif nm in ("multiplicative", "mult", "log"): model = MultiplicativeNoise(level, **kwargs) elif nm in ("field", "realistic", "field_realistic"): model = FieldRealisticNoise(base_level=level, **kwargs) else: raise ValueError( f"Unknown noise_model {noise_model!r}. " "Use 'gaussian', 'multiplicative', or 'field'." ) else: model = noise_model return model.apply(response, seed=seed)