Source code for pycsamt.ai.processing.denoise

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
EMDenoiser — convolutional autoencoder for MT impedance tensor denoising.

Trains a 1-D convolutional autoencoder (CAE) on clean (or simulated)
MT data.  At inference time the trained network suppresses broadband
Gaussian noise and narrowband artefacts (powerline harmonics, dead-band
contamination) while preserving the smooth spectral shape of the true
signal.

Architecture
------------
The network is a symmetric encoder-decoder:

.. math::

    \\mathbf{z} = f_{\\text{enc}}(\\mathbf{x} + \\boldsymbol{\\varepsilon}),
    \\quad
    \\hat{\\mathbf{x}} = f_{\\text{dec}}(\\mathbf{z})

where :math:`\\boldsymbol{\\varepsilon}` is synthetic training noise and
the network minimises :math:`\\|\\mathbf{x} - \\hat{\\mathbf{x}}\\|_2^2`.

Input tensor shape
------------------
``(n_samples, n_components, n_freqs)``

* ``n_components = 4`` (default) → ``[log|Zxy|, \\phi_{xy}, log|Zyx|, \\phi_{yx}]``
* ``n_components = 8`` → all four impedance tensor components

Working directly with site collections
---------------------------------------
:meth:`EMDenoiser.apply` accepts and returns a site collection
directly -- extracting features, denoising, and writing the
reconstructed impedance back onto each station's own frequency grid --
so the network can be dropped into the same sites-in / sites-out chain
as the rule-based functions in :mod:`pycsamt.emtools.remove_noise`.
"""

from __future__ import annotations

import copy
from typing import Any

import numpy as np

from .._backend_utils import (
    active_backend,
    get_weights,
    resolve_device,
    set_weights,
)
from .._base import BaseEMProcessor

__all__ = ["EMDenoiser", "prepare_z_features"]


# ─────────────────────────────────────────────────────────────────────────────
# Input preparation helper (no torch / TF dependency)
# ─────────────────────────────────────────────────────────────────────────────


[docs] def prepare_z_features( sites: Any, *, n_components: int = 4, log_amp: bool = True, freq_ref: np.ndarray | None = None, ) -> np.ndarray: """ Extract an impedance feature array from a site collection. Parameters ---------- sites : SiteCollection, dict, or list of Z objects MT impedance data in any format accepted by :func:`~pycsamt.emtools._core.ensure_sites`. n_components : {4, 8} 4 uses off-diagonal components only; 8 includes diagonal. log_amp : bool If ``True``, amplitudes are transformed as :math:`\\log_{10}(|Z| + \\varepsilon)` before packing. freq_ref : ndarray or None Common frequency grid to interpolate onto. When ``None``, the grid of the first site is used. Returns ------- X : ndarray, shape ``(n_sites, n_components, n_freqs)`` Feature array ready for :class:`EMDenoiser`. """ try: from pycsamt.emtools._core import ( _get_z_block, _iter_items, ensure_sites, ) except ImportError as exc: raise ImportError( "emtools is required for prepare_z_features" ) from exc S = ensure_sites(sites, recursive=True, on_dup="replace") rows: list[np.ndarray] = [] freq_grid: np.ndarray | None = freq_ref for _i, ed in enumerate(_iter_items(S)): result = _get_z_block(ed, with_errors=False) if len(result) == 3: _, z, fr = result else: _, z, fr, _ = result if z is None: continue if freq_grid is None: freq_grid = fr comps: list[np.ndarray] = [] def _amp(z_comp: np.ndarray) -> np.ndarray: amp = np.abs(z_comp) if log_amp: return np.log10(np.maximum(amp, 1e-24)) return amp def _phase(z_comp: np.ndarray) -> np.ndarray: return np.degrees(np.angle(z_comp)) comps += [_amp(z[:, 0, 1]), _phase(z[:, 0, 1])] # Zxy comps += [_amp(z[:, 1, 0]), _phase(z[:, 1, 0])] # Zyx if n_components == 8: comps += [_amp(z[:, 0, 0]), _phase(z[:, 0, 0])] # Zxx comps += [_amp(z[:, 1, 1]), _phase(z[:, 1, 1])] # Zyy mat = np.stack(comps, axis=0) # (n_comp, n_freqs_site) if freq_grid is not fr: mat_interp = np.full((mat.shape[0], len(freq_grid)), np.nan) for ci in range(mat.shape[0]): mat_interp[ci] = _interp_channel_to_grid( mat[ci], fr, freq_grid ) mat = mat_interp rows.append(mat) if not rows: raise ValueError("No valid Z data found in the provided sites.") return np.stack(rows, axis=0).astype( np.float32 ) # (n_sites, n_comp, n_freqs)
def _interp_channel_to_grid( values: np.ndarray, src_freq: np.ndarray, dst_freq: np.ndarray ) -> np.ndarray: """ Interpolate one feature channel from *src_freq* onto *dst_freq*. Interpolation happens in log10-frequency space. ``numpy.interp`` requires its ``xp`` argument strictly increasing; real EDI/MT frequency arrays are conventionally stored high-to-low (descending), so *src_freq* is sorted ascending here first. Skipping this silently returns *wrong, non-NaN* values with no error or warning -- verified directly: querying a descending grid at its own sample points returns neighbouring values, not the sampled ones (``np.interp([10, 8, 6], [10, 8, 6], [1, 2, 3])`` returns ``[3, 3, 1]``, not ``[1, 2, 3]``). """ log_src = np.log10(np.asarray(src_freq, dtype=float) + 1e-30) log_dst = np.log10(np.asarray(dst_freq, dtype=float) + 1e-30) order = np.argsort(log_src) return np.interp(log_dst, log_src[order], np.asarray(values)[order]) def _reconstruct_z_block( z: np.ndarray, fr: np.ndarray, freq_ref: np.ndarray, feat: np.ndarray, n_components: int, log_amp: bool, ) -> np.ndarray: """ Invert :func:`prepare_z_features`'s packing for one site. Writes the modelled components back onto that site's own frequency grid *fr* and leaves every other component of *z* untouched. Used by :meth:`EMDenoiser.apply`. """ z2 = z.copy() def _interp(row: np.ndarray) -> np.ndarray: return _interp_channel_to_grid(row, freq_ref, fr) def _amp(row: np.ndarray) -> np.ndarray: row = _interp(row) return 10.0**row if log_amp else row log_xy, phi_xy = _amp(feat[0]), _interp(feat[1]) log_yx, phi_yx = _amp(feat[2]), _interp(feat[3]) z2[:, 0, 1] = log_xy * np.exp(1j * np.deg2rad(phi_xy)) z2[:, 1, 0] = log_yx * np.exp(1j * np.deg2rad(phi_yx)) if n_components == 8: log_xx, phi_xx = _amp(feat[4]), _interp(feat[5]) log_yy, phi_yy = _amp(feat[6]), _interp(feat[7]) z2[:, 0, 0] = log_xx * np.exp(1j * np.deg2rad(phi_xx)) z2[:, 1, 1] = log_yy * np.exp(1j * np.deg2rad(phi_yy)) return z2 # ───────────────────────────────────────────────────────────────────────────── # Network builders — PyTorch (channels-first) and TensorFlow (channels-last) # ───────────────────────────────────────────────────────────────────────────── def _build_cae_torch( n_components: int, n_freqs: int, channels: tuple[int, ...], dropout: float, ) -> Any: """1-D CAE using PyTorch Conv1d (channels-first).""" try: import torch.nn as nn except ImportError as exc: raise ImportError("PyTorch is required for EMDenoiser") from exc mid = max(n_freqs // 4, 4) ch = list(channels) class _CAE(nn.Module): def __init__(self) -> None: super().__init__() self.encoder = nn.Sequential( nn.Conv1d(n_components, ch[0], 5, padding=2), nn.BatchNorm1d(ch[0]), nn.LeakyReLU(0.1), nn.Conv1d(ch[0], ch[1], 3, padding=1), nn.BatchNorm1d(ch[1]), nn.LeakyReLU(0.1), nn.AdaptiveAvgPool1d(mid), nn.Conv1d(ch[1], ch[2], 3, padding=1), nn.BatchNorm1d(ch[2]), nn.LeakyReLU(0.1), nn.Dropout(dropout), ) self.decoder = nn.Sequential( nn.Conv1d(ch[2], ch[1], 3, padding=1), nn.BatchNorm1d(ch[1]), nn.LeakyReLU(0.1), nn.Upsample(size=n_freqs), nn.Conv1d(ch[1], ch[0], 5, padding=2), nn.BatchNorm1d(ch[0]), nn.LeakyReLU(0.1), nn.Conv1d(ch[0], n_components, 5, padding=2), ) def forward(self, x): # x: (B, n_comp, n_freqs) return self.decoder(self.encoder(x)) return _CAE() def _build_cae_tf( n_components: int, n_freqs: int, channels: tuple[int, ...], dropout: float, ) -> Any: """ 1-D CAE using Keras Conv1D (channels-last). Input / output convention: ``(batch, n_freqs, n_components)``. Data is transposed by the caller from/to the canonical ``(batch, n_components, n_freqs)`` form. """ try: import tensorflow as tf from tensorflow.keras import Model, layers except ImportError as exc: raise ImportError( "TensorFlow is required for EMDenoiser (TF backend)" ) from exc ch = list(channels) # pool_size ≈ n_freqs//(n_freqs//4) = 4; safe default for common grid sizes pool = max(1, n_freqs // max(n_freqs // 4, 4)) inp = tf.keras.Input(shape=(n_freqs, n_components), name="input") # Encoder x = layers.Conv1D(ch[0], 5, padding="same")(inp) x = layers.BatchNormalization()(x) x = layers.LeakyReLU(0.1)(x) x = layers.Conv1D(ch[1], 3, padding="same")(x) x = layers.BatchNormalization()(x) x = layers.LeakyReLU(0.1)(x) x = layers.AveragePooling1D(pool_size=pool, padding="same")(x) x = layers.Conv1D(ch[2], 3, padding="same")(x) x = layers.BatchNormalization()(x) x = layers.LeakyReLU(0.1)(x) x = layers.SpatialDropout1D(dropout)(x) # Decoder x = layers.Conv1D(ch[1], 3, padding="same")(x) x = layers.BatchNormalization()(x) x = layers.LeakyReLU(0.1)(x) x = layers.UpSampling1D(pool)(x) x = layers.Conv1D(ch[0], 5, padding="same")(x) x = layers.BatchNormalization()(x) x = layers.LeakyReLU(0.1)(x) out = layers.Conv1D(n_components, 5, padding="same", name="output")(x) # Trim or pad to exact n_freqs in case pool arithmetic leaves ±1 off out = layers.Lambda(lambda t, nf=n_freqs: t[:, :nf, :], name="trim")(out) return Model(inp, out, name="cae") # ───────────────────────────────────────────────────────────────────────────── # EMDenoiser # ─────────────────────────────────────────────────────────────────────────────
[docs] class EMDenoiser(BaseEMProcessor): """ Convolutional autoencoder for MT impedance tensor denoising. The network is trained on clean (or synthetic) impedance data by adding controlled noise and learning to reconstruct the original signal. Supports both PyTorch and TensorFlow backends via :mod:`pycsamt.backends`. Parameters ---------- n_freqs : int or None, default None Number of frequency channels in the input. When ``None``, inferred from ``X.shape[2]`` on the first call to :meth:`fit`. n_components : {4, 8}, default 4 Feature channels per frequency: 4 = off-diagonal :math:`Z_{xy}`, :math:`Z_{yx}` amplitudes and phases; 8 = all four tensor components. channels : tuple of int, default (64, 128, 64) Channel widths for the encoder/decoder stages. dropout : float, default 0.1 Dropout probability in the encoder bottleneck. device : str or None ``"cpu"``, ``"cuda"``, ``"mps"``, ``"/GPU:0"``, or ``None`` for auto-detect via the active backend. Notes ----- When neither PyTorch nor TensorFlow is available the denoiser falls back to a per-channel Gaussian smoothing filter (requires scipy). Call :func:`prepare_z_features` to convert a site collection to the ``(n_samples, n_components, n_freqs)`` array expected here, or use :meth:`apply` to go straight from a site collection to a corrected one without handling the array form yourself. Examples -------- >>> import numpy as np >>> X_clean = np.random.randn(200, 4, 32).astype("float32") >>> den = EMDenoiser() >>> den.fit(X_clean, epochs=5, verbose=False) # doctest: +SKIP EMDenoiser(n_freqs=32, n_components=4) >>> X_denoised = den.transform(X_clean) # doctest: +SKIP Sites in, corrected sites out — the same convention used by rule-based functions such as :func:`~pycsamt.emtools.remove_noise.notch_powerline`: >>> clean_sites = den.apply(sites) # doctest: +SKIP """ def __init__( self, n_freqs: int | None = None, n_components: int = 4, *, channels: tuple[int, ...] = (64, 128, 64), dropout: float = 0.1, device: str | None = None, ) -> None: self.n_freqs = None if n_freqs is None else int(n_freqs) self.n_components = int(n_components) self.channels = tuple(channels) self.dropout = float(dropout) self.device = device self._network: Any = None self._use_numpy: bool = False self._is_fitted: bool = False self._backend_name: str | None = None self._x_mean: np.ndarray | None = None self._x_std: np.ndarray | None = None self._history: dict[str, list] = {} # ─── BaseEMProcessor interface ────────────────────────────────────────
[docs] def fit( self, X: np.ndarray, *, noise_level: float = 0.05, epochs: int = 50, batch_size: int = 64, lr: float = 1e-3, val_frac: float = 0.1, seed: int | None = None, verbose: bool = True, ) -> EMDenoiser: """ Train the denoiser on clean impedance data. Parameters ---------- X : ndarray, shape (n_samples, n_components, n_freqs) Clean training data. noise_level : float Standard deviation of additive Gaussian noise relative to the per-channel standard deviation of ``X``. epochs : int Maximum number of training epochs. batch_size : int lr : float Initial Adam learning rate. val_frac : float Fraction of training samples held out for validation. seed : int or None verbose : bool Returns ------- self """ rng = np.random.default_rng(seed) X = np.asarray(X, dtype=np.float32) if X.ndim != 3: raise ValueError( f"X must be 3-D (n_samples, n_components, n_freqs), got shape {X.shape}" ) if self.n_freqs is None: self.n_freqs = X.shape[2] elif X.shape[2] != self.n_freqs: raise ValueError( f"Expected n_freqs={self.n_freqs}, got {X.shape[2]}" ) self._x_mean = X.mean(axis=(0, 2), keepdims=True) self._x_std = X.std(axis=(0, 2), keepdims=True) + 1e-8 Xn = (X - self._x_mean) / self._x_std n = len(Xn) idx = rng.permutation(n) n_val = max(1, int(n * val_frac)) val_idx, trn_idx = idx[:n_val], idx[n_val:] Xtr, Xva = Xn[trn_idx], Xn[val_idx] try: self._backend_name = active_backend() if self._backend_name == "tensorflow": self._fit_tensorflow( Xtr, Xva, noise_level, epochs, batch_size, lr, rng, verbose, ) elif self._backend_name != "none": self._fit_torch( Xtr, Xva, noise_level, epochs, batch_size, lr, rng, verbose, ) else: raise RuntimeError("no backend") self._use_numpy = False except (RuntimeError, ImportError): self._backend_name = "numpy" self._use_numpy = True if verbose: print( " EMDenoiser (scipy Gaussian fallback) — no DL backend found." ) self._is_fitted = True return self
[docs] def transform(self, X: np.ndarray) -> np.ndarray: """ Apply the trained denoiser. Parameters ---------- X : ndarray, shape (n_samples, n_components, n_freqs) Noisy impedance data. Returns ------- X_denoised : ndarray, same shape as input """ if not self._is_fitted: raise RuntimeError("Call fit() before transform().") X = np.asarray(X, dtype=np.float32) Xn = (X - self._x_mean) / self._x_std if self._use_numpy: try: from scipy.ndimage import gaussian_filter1d out = gaussian_filter1d(Xn, sigma=1.5, axis=-1) except ImportError: out = Xn.copy() elif self._backend_name == "tensorflow": Xn_tf = Xn.transpose(0, 2, 1) # → (batch, n_freqs, n_components) out_tf = self._network.predict(Xn_tf, verbose=0).astype(np.float32) out = out_tf.transpose(0, 2, 1) # → (batch, n_components, n_freqs) else: import torch dev = next(self._network.parameters()).device self._network.eval() with torch.no_grad(): t = torch.from_numpy(Xn).to(dev) out = self._network(t).cpu().numpy() return out * self._x_std + self._x_mean
# ─── sites-in / sites-out ──────────────────────────────────────────────
[docs] def apply( self, sites: Any, *, freq_ref: np.ndarray | None = None, log_amp: bool = True, inplace: bool = False, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> Any: """ Denoise a site collection's impedance tensor in place. Extracts features with :func:`prepare_z_features`, denoises them with :meth:`transform`, then writes the reconstructed impedance back onto each site's own frequency grid — the same sites-in / sites-out convention used by rule-based correction functions such as :func:`~pycsamt.emtools.remove_noise.notch_powerline` (mutate ``Z.z``, hand the mutation to a shared copy-or-mutate-in-place helper). Parameters ---------- sites : SiteCollection or compatible freq_ref : ndarray or None Common frequency grid (Hz) the network was trained on. Must match the grid used to prepare the training data — when ``None`` (the default), the first site's own grid is used, matching :func:`prepare_z_features`'s own default. Pass the same explicit array at both training-data preparation time and here whenever *sites* is not the exact collection the model was trained on. log_amp : bool, default ``True`` Must match the ``log_amp`` used to build the training features. inplace : bool, default ``False`` When ``False`` (the default), *sites* is left untouched and a corrected copy is returned. When ``True``, *sites* is modified in place and returned. recursive, on_dup, strict, verbose Passed to :func:`~pycsamt.emtools._core.ensure_sites`. Returns ------- corrected : Sites A site collection with the same stations, with each station's ``Z.z`` replaced by the network's denoised reconstruction. Tensor components not modelled by ``n_components`` — the diagonal :math:`Z_{xx}, Z_{yy}` when ``n_components=4`` — are left untouched. Examples -------- >>> den = EMDenoiser().fit(X_clean, epochs=60) # doctest: +SKIP >>> clean_sites = den.apply(sites) # doctest: +SKIP """ if not self._is_fitted: raise RuntimeError("Call fit() before apply().") try: from pycsamt.emtools._core import ( _apply_each, _get_z_block, _iter_items, ensure_sites, ) except ImportError as exc: raise ImportError( "emtools is required for EMDenoiser.apply()" ) from exc S = ensure_sites( sites, recursive=recursive, on_dup=on_dup, strict=strict, verbose=verbose, ) if freq_ref is None: first_ed = next(_iter_items(S)) _, _, freq_ref = _get_z_block(first_ed, with_errors=False)[:3] X = prepare_z_features( S, n_components=self.n_components, log_amp=log_amp, freq_ref=freq_ref, ) X_den = self.transform(X) site_idx = iter(range(len(X_den))) def _one(Si): i = next(site_idx) ed = next(_iter_items(Si)) Z, z, fr = _get_z_block(ed, with_errors=False)[:3] if z is None: return Si Z.z = _reconstruct_z_block( z, fr, freq_ref, X_den[i], self.n_components, log_amp ) return Si return _apply_each(S, _one, inplace=inplace, verbose=verbose)
# ─── internal training paths ────────────────────────────────────────── def _fit_torch( self, Xtr: np.ndarray, Xva: np.ndarray, noise_level: float, epochs: int, batch_size: int, lr: float, rng: np.random.Generator, verbose: bool, ) -> None: import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset dev = resolve_device(self.device) self._network = _build_cae_torch( self.n_components, self.n_freqs, self.channels, self.dropout ).to(dev) opt = torch.optim.Adam(self._network.parameters(), lr=lr) sched = torch.optim.lr_scheduler.ReduceLROnPlateau( opt, factor=0.5, patience=7, min_lr=1e-6 ) mse = nn.MSELoss() per_ch_std = Xtr.std(axis=(0, 2), keepdims=True) def _make_noisy(batch: np.ndarray) -> torch.Tensor: noise = rng.normal(0.0, noise_level, batch.shape).astype( np.float32 ) noise *= per_ch_std return torch.from_numpy(batch + noise).to(dev) tr_ds = TensorDataset(torch.from_numpy(Xtr)) best_val, best_state = np.inf, None train_losses, val_losses = [], [] for ep in range(1, epochs + 1): self._network.train() ep_loss = 0.0 for (xb,) in DataLoader( tr_ds, batch_size=batch_size, shuffle=True ): xb = xb.to(dev) noisy = _make_noisy(xb.cpu().numpy()) pred = self._network(noisy) loss = mse(pred, xb) opt.zero_grad() loss.backward() opt.step() ep_loss += loss.item() * len(xb) ep_loss /= len(Xtr) self._network.eval() with torch.no_grad(): vb = torch.from_numpy(Xva).to(dev) v_pred = self._network(_make_noisy(Xva)) v_loss = mse(v_pred, vb).item() sched.step(v_loss) train_losses.append(ep_loss) val_losses.append(v_loss) if v_loss < best_val: best_val = v_loss best_state = copy.deepcopy(self._network.state_dict()) if verbose and (ep % max(1, epochs // 10) == 0 or ep == 1): print( f" Epoch {ep:>4d}/{epochs} " f"train={ep_loss:.5f} val={v_loss:.5f}" ) if best_state is not None: self._network.load_state_dict(best_state) self._history = {"train_loss": train_losses, "val_loss": val_losses} def _fit_tensorflow( self, Xtr: np.ndarray, Xva: np.ndarray, noise_level: float, epochs: int, batch_size: int, lr: float, rng: np.random.Generator, verbose: bool, ) -> None: import tensorflow as tf # Keras Conv1D is channels-last: (batch, n_freqs, n_components) Xtr_tf = Xtr.transpose(0, 2, 1) Xva_tf = Xva.transpose(0, 2, 1) per_ch_std = Xtr.std(axis=(0, 2), keepdims=True) # (1, n_comp, 1) def _add_noise(X_chlast: np.ndarray) -> np.ndarray: noise = rng.normal(0.0, noise_level, X_chlast.shape).astype( np.float32 ) # per_ch_std is (1, n_comp, 1); transpose to (1, 1, n_comp) for channels-last noise *= per_ch_std.transpose(0, 2, 1) return X_chlast + noise dev = resolve_device(self.device) with tf.device(dev): self._network = _build_cae_tf( self.n_components, self.n_freqs, self.channels, self.dropout ) self._network.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=lr), loss="mse", ) Xtr_noisy = _add_noise(Xtr_tf) hist = self._network.fit( Xtr_noisy, Xtr_tf, validation_data=(_add_noise(Xva_tf), Xva_tf), epochs=epochs, batch_size=batch_size, callbacks=[ tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=10, restore_best_weights=True, min_delta=1e-6, ), tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=7, min_lr=1e-6, ), ], verbose=1 if verbose else 0, ) self._history = { "train_loss": hist.history["loss"], "val_loss": hist.history["val_loss"], } # ─── serialisation ──────────────────────────────────────────────────── def _get_params(self) -> dict[str, Any]: return { "n_freqs": self.n_freqs, # may still be None if save() called before fit() "n_components": self.n_components, "channels": list(self.channels), "dropout": self.dropout, "device": self.device, } def _get_weights(self) -> dict[str, np.ndarray]: out: dict[str, np.ndarray] = {} if self._x_mean is not None: out["_x_mean"] = self._x_mean out["_x_std"] = self._x_std if self._backend_name is not None: out["_backend"] = np.array(self._backend_name) if self._network is not None: for k, v in get_weights(self._network).items(): out[k] = v return out def _load_weights(self, weights: dict[str, np.ndarray]) -> None: self._x_mean = weights.pop("_x_mean", None) self._x_std = weights.pop("_x_std", None) backend_blob = weights.pop("_backend", None) self._backend_name = ( str(backend_blob) if backend_blob is not None else "torch" ) if self._backend_name == "tensorflow": self._network = _build_cae_tf( self.n_components, self.n_freqs, self.channels, self.dropout ) else: self._network = _build_cae_torch( self.n_components, self.n_freqs, self.channels, self.dropout ) set_weights(self._network, weights) self._is_fitted = True
[docs] @property def history_(self) -> dict[str, list]: """ Training history recorded by the last :meth:`fit` call. Returns ------- history : dict ``{"train_loss": [...], "val_loss": [...]}``, one value per epoch. Empty when the scipy Gaussian fallback was used (no epoch loop) or before :meth:`fit` has been called. Pass directly to :func:`~pycsamt.ai.processing.plot.plot_training_history`. """ return dict(self._history)
def __repr__(self) -> str: status = "fitted" if self._is_fitted else "unfitted" return ( f"EMDenoiser(n_freqs={self.n_freqs}, " f"n_components={self.n_components}, {status})" )