"""Quality-assurance metrics for denoising."""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from . import _mne
__all__ = [
"peak_attenuation_db",
"suppression_ratio",
"noise_surround_ratio",
"below_noise_distortion_db",
"spectral_distortion",
"overclean_proportion",
"underclean_proportion",
"geometric_mean_psd_ratio",
"variance_removed",
"compute_all_qa_metrics",
"rms_change",
"max_abs_change",
"channel_variance_ratio",
]
if TYPE_CHECKING:
import mne
_EPS = 1e-30 # floor to avoid log(0)
[docs]
def peak_attenuation_db(
freqs: np.ndarray,
psd_before: np.ndarray,
psd_after: np.ndarray,
target_freq: float,
bandwidth: float = 2.0,
) -> np.ndarray:
"""Compute peak attenuation around a target frequency.
The metric is 10 * log10(max(psd_before) / max(psd_after)) within the selected
band; positive values indicate attenuation.
Parameters
----------
freqs : ndarray, shape (n_freqs,)
Frequency vector in Hz.
psd_before, psd_after : ndarray, shape (n_freqs,) or (n_channels, n_freqs)
PSDs before and after cleaning.
target_freq : float
Center frequency in Hz.
bandwidth : float, default=2.0
Half-width of the search band in Hz.
Returns
-------
float or ndarray
Scalar for 1-D PSD input, otherwise one value per channel. Empty bands
return NaN; after-power is floored at 1e-30.
"""
mask = (freqs >= target_freq - bandwidth) & (freqs <= target_freq + bandwidth)
if not mask.any():
return np.nan if psd_before.ndim == 1 else np.full(psd_before.shape[0], np.nan)
if psd_before.ndim == 1:
peak_before = psd_before[mask].max()
peak_after = psd_after[mask].max()
else:
peak_before = psd_before[:, mask].max(axis=1)
peak_after = psd_after[:, mask].max(axis=1)
return 10.0 * np.log10(peak_before / np.maximum(peak_after, _EPS))
[docs]
def suppression_ratio(
freqs: np.ndarray,
psd_before: np.ndarray,
psd_after: np.ndarray,
target_freq: float,
bandwidth: float = 2.0,
) -> float:
"""Compute the dB ratio of mean band power before and after cleaning.
The metric is 10 * log10(mean_before / mean_after); positive values indicate
suppression.
Parameters
----------
freqs : ndarray, shape (n_freqs,)
Frequency vector in Hz.
psd_before, psd_after : ndarray, shape (n_freqs,) or (n_channels, n_freqs)
PSDs before and after cleaning.
target_freq : float
Center frequency in Hz.
bandwidth : float, default=2.0
Half-width of the band in Hz.
Returns
-------
float
Scalar ratio after averaging channels when input is 2-D. Empty bands return
NaN; non-positive after-power returns positive infinity.
"""
mask = (freqs >= target_freq - bandwidth) & (freqs <= target_freq + bandwidth)
if not mask.any():
return np.nan
pb = psd_before.mean(axis=0) if psd_before.ndim == 2 else psd_before
pa = psd_after.mean(axis=0) if psd_after.ndim == 2 else psd_after
pb_mean = pb[mask].mean()
pa_mean = pa[mask].mean()
if pa_mean <= 0:
return np.inf
return 10.0 * np.log10(pb_mean / pa_mean)
[docs]
def noise_surround_ratio(
freqs: np.ndarray,
psd_after: np.ndarray,
target_freq: float,
peak_bw: float = 2.0,
surround_bw: float = 5.0,
) -> np.ndarray:
"""Compute target-band power divided by surrounding power.
Parameters
----------
freqs : ndarray, shape (n_freqs,)
Frequency vector in Hz.
psd_after : ndarray, shape (n_freqs,) or (n_channels, n_freqs)
PSD after cleaning.
target_freq : float
Center frequency in Hz.
peak_bw : float, default=2.0
Half-width of the target band in Hz.
surround_bw : float, default=5.0
Half-width of each surrounding region in Hz.
Returns
-------
float or ndarray
Scalar for 1-D input, otherwise one value per channel. Values near one
indicate a flat target region; larger values indicate a residual peak.
Missing surrounding power uses a 1e-30 denominator.
"""
peak_mask = (freqs >= target_freq - peak_bw) & (freqs <= target_freq + peak_bw)
surr_mask = (
(freqs >= target_freq - surround_bw) & (freqs < target_freq - peak_bw)
) | ((freqs > target_freq + peak_bw) & (freqs <= target_freq + surround_bw))
if psd_after.ndim == 1:
peak_power = psd_after[peak_mask].mean() if peak_mask.any() else 0.0
surr_power = psd_after[surr_mask].mean() if surr_mask.any() else _EPS
return peak_power / max(surr_power, _EPS)
peak_power = (
psd_after[:, peak_mask].mean(axis=1)
if peak_mask.any()
else np.zeros(psd_after.shape[0])
)
surr_power = (
psd_after[:, surr_mask].mean(axis=1)
if surr_mask.any()
else np.full(psd_after.shape[0], _EPS)
)
return peak_power / np.maximum(surr_power, _EPS)
[docs]
def below_noise_distortion_db(
freqs: np.ndarray,
psd_before: np.ndarray,
psd_after: np.ndarray,
exclude_freq: float | None = None,
exclude_bw: float = 5.0,
fmin: float = 1.0,
fmax: float = 45.0,
n_harmonics: int = 0,
) -> np.ndarray:
"""Compute mean absolute log-power distortion outside excluded bands.
The metric is the mean of abs(10 * log10(psd_after / psd_before)) over the
selected frequency mask; lower values indicate less broadband change.
Parameters
----------
freqs : ndarray, shape (n_freqs,)
Frequency vector in Hz.
psd_before, psd_after : ndarray, shape (n_freqs,) or (n_channels, n_freqs)
PSDs before and after cleaning.
exclude_freq : float or None, default=None
Fundamental frequency whose harmonics are excluded.
exclude_bw : float, default=5.0
Half-width of each excluded band in Hz.
fmin, fmax : float, default=1.0, 45.0
Inclusive frequency range in Hz.
n_harmonics : int, default=0
Additional harmonics to exclude.
Returns
-------
float or ndarray
Scalar for 1-D input, otherwise one value per channel. An empty mask returns
zero; before-power is floored at 1e-30.
"""
mask = (freqs >= fmin) & (freqs <= fmax)
if exclude_freq is not None:
for h in range(1, n_harmonics + 2):
hf = exclude_freq * h
mask &= ~((freqs >= hf - exclude_bw) & (freqs <= hf + exclude_bw))
if not mask.any():
return 0.0 if psd_before.ndim == 1 else np.zeros(psd_before.shape[0])
if psd_before.ndim == 1:
ratio = np.log10(psd_after[mask] / np.maximum(psd_before[mask], _EPS))
return float(np.mean(np.abs(ratio)) * 10.0)
ratio = np.log10(psd_after[:, mask] / np.maximum(psd_before[:, mask], _EPS))
return np.mean(np.abs(ratio), axis=1) * 10.0
[docs]
def spectral_distortion(
freqs: np.ndarray,
psd_before: np.ndarray,
psd_after: np.ndarray,
line_freq: float = 50.0,
n_harmonics: int = 3,
bandwidth: float = 2.0,
) -> float:
"""Compute RMS log-power distortion away from line-noise harmonics.
Parameters
----------
freqs : ndarray, shape (n_freqs,)
Frequency vector in Hz.
psd_before, psd_after : ndarray, shape (n_freqs,) or (n_channels, n_freqs)
PSDs before and after cleaning; channels are averaged for 2-D input.
line_freq : float, default=50.0
Fundamental line frequency in Hz.
n_harmonics : int, default=3
Number of harmonics to exclude.
bandwidth : float, default=2.0
Base half-width; the implementation excludes line_freq * k +/- 2 * bandwidth.
Returns
-------
float
RMS distortion in dB over 2--160 Hz. An empty mask returns 0.0.
"""
safe = np.ones(len(freqs), dtype=bool)
for k in range(1, n_harmonics + 1):
target = line_freq * k
safe &= ~((freqs >= target - bandwidth * 2) & (freqs <= target + bandwidth * 2))
# Restrict to a reasonable range for evaluation
safe &= (freqs >= 2) & (freqs <= 160)
if not safe.any():
return 0.0
pb = psd_before.mean(axis=0) if psd_before.ndim == 2 else psd_before
pa = psd_after.mean(axis=0) if psd_after.ndim == 2 else psd_after
ratio = pa[safe] / np.maximum(pb[safe], _EPS)
return np.sqrt(np.mean((10.0 * np.log10(ratio)) ** 2))
[docs]
def overclean_proportion(
freqs: np.ndarray,
psd_before: np.ndarray,
psd_after: np.ndarray,
target_freq: float,
bandwidth: float = 2.0,
threshold_db: float = 3.0,
) -> float:
"""Compute the fraction of channels whose surrounding floor is over-suppressed.
A channel is flagged when surrounding-band attenuation exceeds threshold_db.
Parameters
----------
freqs : ndarray, shape (n_freqs,)
Frequency vector in Hz.
psd_before, psd_after : ndarray, shape (n_freqs,) or (n_channels, n_freqs)
PSDs before and after cleaning.
target_freq : float
Center frequency in Hz.
bandwidth : float, default=2.0
Half-width used to define the target band.
threshold_db : float, default=3.0
Surrounding-floor attenuation threshold in dB.
Returns
-------
float
Indicator for 1-D input or channel fraction in [0, 1] for 2-D input.
No surrounding frequencies returns 0.0.
"""
surr_mask = (
(freqs >= target_freq - bandwidth * 2) & (freqs < target_freq - bandwidth)
) | ((freqs > target_freq + bandwidth) & (freqs <= target_freq + bandwidth * 2))
if not surr_mask.any():
return 0.0
if psd_before.ndim == 1:
floor_before = psd_before[surr_mask].mean()
floor_after = psd_after[surr_mask].mean()
atten_db = 10.0 * np.log10(floor_before / max(floor_after, _EPS))
return float(atten_db > threshold_db)
floor_before = psd_before[:, surr_mask].mean(axis=1)
floor_after = psd_after[:, surr_mask].mean(axis=1)
atten_db = 10.0 * np.log10(floor_before / np.maximum(floor_after, _EPS))
return float((atten_db > threshold_db).mean())
[docs]
def underclean_proportion(
freqs: np.ndarray,
psd_after: np.ndarray,
target_freq: float,
peak_bw: float = 2.0,
surround_bw: float = 5.0,
threshold_ratio: float = 2.0,
) -> float:
"""Compute the fraction of channels with a residual target peak.
A channel is flagged when noise_surround_ratio exceeds threshold_ratio.
Parameters
----------
freqs : ndarray, shape (n_freqs,)
Frequency vector in Hz.
psd_after : ndarray, shape (n_freqs,) or (n_channels, n_freqs)
PSD after cleaning.
target_freq : float
Center frequency in Hz.
peak_bw : float, default=2.0
Half-width of the target band in Hz.
surround_bw : float, default=5.0
Half-width of the surrounding bands in Hz.
threshold_ratio : float, default=2.0
Residual peak ratio threshold.
Returns
-------
float
Indicator for 1-D input or channel fraction in [0, 1] for 2-D input.
Missing peak power yields an unflagged ratio of zero.
"""
nsr = noise_surround_ratio(freqs, psd_after, target_freq, peak_bw, surround_bw)
if np.ndim(nsr) == 0:
return float(nsr > threshold_ratio)
return float((nsr > threshold_ratio).mean())
[docs]
def geometric_mean_psd_ratio(
freqs: np.ndarray,
psd_before: np.ndarray,
psd_after: np.ndarray,
fmin: float = 1.0,
fmax: float = 45.0,
) -> np.ndarray:
"""Compute the geometric mean of psd_after / psd_before over a frequency range.
Parameters
----------
freqs : ndarray, shape (n_freqs,)
Frequency vector in Hz.
psd_before, psd_after : ndarray, shape (n_freqs,) or (n_channels, n_freqs)
PSDs before and after cleaning.
fmin, fmax : float, default=1.0, 45.0
Inclusive frequency bounds in Hz.
Returns
-------
float or ndarray
Scalar for 1-D input, otherwise one value per channel. Values below one
indicate net attenuation; an empty mask returns one. PSDs are floored at
1e-30 before logarithms.
"""
mask = (freqs >= fmin) & (freqs <= fmax)
if not mask.any():
return 1.0 if psd_before.ndim == 1 else np.ones(psd_before.shape[0])
if psd_before.ndim == 1:
ratio = psd_after[mask] / np.maximum(psd_before[mask], _EPS)
return float(np.exp(np.mean(np.log(np.maximum(ratio, _EPS)))))
ratio = psd_after[:, mask] / np.maximum(psd_before[:, mask], _EPS)
return np.exp(np.mean(np.log(np.maximum(ratio, _EPS)), axis=1))
[docs]
def variance_removed(data_before: np.ndarray, data_after: np.ndarray) -> float:
"""Compute percentage of total variance removed.
The definition is 100 * (1 - var(data_after) / var(data_before)).
Parameters
----------
data_before, data_after : ndarray
Data before and after cleaning with matching shapes.
Returns
-------
float
Percentage removed. Positive values indicate reduced variance, negative
values increased variance, and zero input variance returns 0.0.
"""
var_before = np.var(data_before)
if var_before == 0:
return 0.0
return 100.0 * (1.0 - np.var(data_after) / var_before)
def _compute_psd_pair(
raw_before: mne.io.BaseRaw,
raw_after: mne.io.BaseRaw,
fmax: float = 125.0,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return ``(freqs, psd_before, psd_after)`` from two Raw objects."""
psd_b = raw_before.compute_psd(fmax=fmax, verbose=False)
psd_a = raw_after.compute_psd(fmax=fmax, verbose=False)
return psd_b.freqs, psd_b.get_data(), psd_a.get_data()
[docs]
def compute_all_qa_metrics(
raw_before: mne.io.BaseRaw,
raw_after: mne.io.BaseRaw,
line_freq: float = 50.0,
n_harmonics: int = 0,
fmax: float = 125.0,
) -> dict:
"""Compute the package QA summary for two MNE Raw objects.
Parameters
----------
raw_before, raw_after : mne.io.BaseRaw
Recordings before and after cleaning.
line_freq : float, default=50.0
Fundamental line frequency in Hz.
n_harmonics : int, default=0
Number of harmonics above the fundamental.
fmax : float, default=125.0
Maximum PSD frequency in Hz.
Returns
-------
dict
Median summary metrics and per-harmonic attenuation/ratio arrays. PSDs are
computed with the Raw objects' compute_psd method.
"""
_mne.require_mne("MNE QA metrics")
freqs, psd_b, psd_a = _compute_psd_pair(raw_before, raw_after, fmax=fmax)
harmonics = [line_freq * h for h in range(1, n_harmonics + 2)]
# Per-harmonic metrics
per_h_atten: list[float] = []
per_h_r: list[float] = []
for hf in harmonics:
atten = peak_attenuation_db(freqs, psd_b, psd_a, hf)
nsr = noise_surround_ratio(freqs, psd_a, hf)
per_h_atten.append(float(np.nanmedian(atten)))
per_h_r.append(float(np.nanmedian(nsr)))
# Broadband metrics
distort = below_noise_distortion_db(
freqs,
psd_b,
psd_a,
exclude_freq=line_freq,
n_harmonics=n_harmonics,
)
oc = overclean_proportion(freqs, psd_b, psd_a, line_freq)
uc = underclean_proportion(freqs, psd_a, line_freq)
gmr = geometric_mean_psd_ratio(freqs, psd_b, psd_a)
return {
"peak_attenuation_db": per_h_atten[0],
"R_f0": per_h_r[0],
"below_noise_distortion_db": float(np.median(distort)),
"overclean_proportion": float(oc),
"underclean_proportion": float(uc),
"geometric_mean_psd_ratio": float(np.median(gmr)),
"harmonics_hz": harmonics,
"per_harmonic_attenuation_db": per_h_atten,
"per_harmonic_R": per_h_r,
}
[docs]
def rms_change(data_before: np.ndarray, data_after: np.ndarray) -> float:
"""Compute the RMS of data_before - data_after.
Parameters
----------
data_before, data_after : ndarray
Matching data arrays.
Returns
-------
float
RMS in the input data units. Empty input produces NaN.
"""
delta = data_before - data_after
return float(np.sqrt(np.mean(delta**2)))
[docs]
def max_abs_change(data_before: np.ndarray, data_after: np.ndarray) -> float:
"""Compute the largest absolute sample-wise change.
Parameters
----------
data_before, data_after : ndarray
Matching data arrays.
Returns
-------
float
max(abs(data_before - data_after)) in input units. Empty input raises the
NumPy maximum error.
"""
return float(np.max(np.abs(data_before - data_after)))
[docs]
def channel_variance_ratio(
data_before: np.ndarray, data_after: np.ndarray
) -> np.ndarray:
"""Compute after/before variance for each channel.
Parameters
----------
data_before : ndarray, shape (n_channels, n_times) or (n_epochs, n_channels, n_times)
Data before cleaning.
data_after : ndarray
Data after cleaning with the same shape.
Returns
-------
ndarray, shape (n_channels,)
Variance ratio pooled over time, or epochs and time. Zero denominators are
replaced with machine epsilon.
"""
axis = (0, 2) if data_before.ndim == 3 else 1
var_before = np.var(data_before, axis=axis)
var_after = np.var(data_after, axis=axis)
return var_after / np.maximum(var_before, np.finfo(float).eps)