# 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
# ─── 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})"
)