Skip to content

Commit 9f59b13

Browse files
committed
Use direct NumPy MAD in the windowed correlation loop (#197)
* Use direct numpy MAD in windowed correlation loop * Add changelog entry * Unify MAD computations behind a single _mad helper Replace the mix of scipy.stats.median_abs_deviation calls and inline NumPy MAD implementations in NoisyChannels with one direct-NumPy helper, pyprep.utils._mad, used everywhere a median absolute deviation is needed (find_bad_by_nan_flat, find_bad_by_hfnoise, the find_bad_by_correlation windowed loop, and find_bad_by_PSD's robust_zscore). _mad is numerically identical to scipy's median_abs_deviation with its default scale=1 and nan_policy="propagate", so output is bit-identical; it avoids scipy's per-call input validation and NaN scan, preserving the windowed-loop speedup from the prior commit. The NaN-robust spread in find_bad_by_hfnoise stays inline (it centers on nanmedian, not median) with a comment explaining why. Re-add the _mad unit test (now also asserting scipy equivalence and NaN propagation), drop the now-unused scipy import, and update the changelog.
1 parent 8d2c7b6 commit 9f59b13

4 files changed

Lines changed: 72 additions & 9 deletions

File tree

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Version 0.8.0 (unreleased)
2626

2727
Changelog
2828
~~~~~~~~~
29+
- Unified all median absolute deviation (MAD) computations in :class:`~pyprep.NoisyChannels` behind a single direct-NumPy helper (``pyprep.utils._mad``), replacing the previous mix of :func:`scipy.stats.median_abs_deviation` calls and inline implementations. This keeps the speedup of the windowed loop in :meth:`~pyprep.NoisyChannels.find_bad_by_correlation` while producing bit-identical output, by `Stefan Appelhoff`_ (:gh:`197`)
2930
- :class:`~pyprep.NoisyChannels` now detrends the EEG signal in place during initialization, avoiding two transient full-size copies of the recording; :func:`pyprep.removeTrend.removeTrend` gained a ``copy`` keyword argument (default ``True``, preserving the previous behavior) to support this, by `Stefan Appelhoff`_ (:gh:`196`)
3031
- :meth:`~pyprep.NoisyChannels.find_bad_by_correlation` (and any other method that uses the internally band-pass-filtered signal) is now substantially faster: ``NoisyChannels._get_filtered_data`` filters all channels at once instead of channel-by-channel, with bit-identical output, by `Stefan Appelhoff`_ (:gh:`195`)
3132

pyprep/find_noisy_channels.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77
import numpy as np
88
from mne.utils import check_random_state, logger
99
from scipy import signal
10-
from scipy.stats import median_abs_deviation
1110

1211
from pyprep.ransac import find_bad_by_ransac
1312
from pyprep.removeTrend import removeTrend
14-
from pyprep.utils import _filter_design, _mat_iqr, _mat_quantile
13+
from pyprep.utils import _filter_design, _mad, _mat_iqr, _mat_quantile
1514

1615

1716
class NoisyChannels:
@@ -396,7 +395,7 @@ def find_bad_by_nan_flat(self, flat_threshold=1e-15):
396395
nan_channels = self.ch_names_original[nan_channel_mask]
397396

398397
# Detect channels with flat or extremely weak signals
399-
flat_by_mad = median_abs_deviation(EEGData, axis=1) < flat_threshold
398+
flat_by_mad = _mad(EEGData, axis=1) < flat_threshold
400399
flat_by_stdev = np.std(EEGData, axis=1) < flat_threshold
401400
flat_channel_mask = flat_by_mad | flat_by_stdev
402401
flat_channels = self.ch_names_original[flat_channel_mask]
@@ -485,9 +484,12 @@ def find_bad_by_hfnoise(self, HF_zscore_threshold=5.0):
485484
# < 50 Hz amplitude for each channel and get robust z-scores of values
486485
if self.sample_rate > 100:
487486
noisiness = np.divide(
488-
median_abs_deviation(self.EEGData - self.EEGFiltered, axis=1),
489-
median_abs_deviation(self.EEGFiltered, axis=1),
487+
_mad(self.EEGData - self.EEGFiltered, axis=1),
488+
_mad(self.EEGFiltered, axis=1),
490489
)
490+
# NaN-robust z-score: the center uses nanmedian (noisiness may hold
491+
# NaNs), so the MAD-style spread is computed inline rather than with
492+
# ``_mad``, which centers on the plain median.
491493
noise_median = np.nanmedian(noisiness)
492494
noise_sd = np.median(np.abs(noisiness - noise_median)) * MAD_TO_SD
493495
noise_zscore[self.usable_idx] = (noisiness - noise_median) / noise_sd
@@ -570,7 +572,7 @@ def find_bad_by_correlation(
570572
channel_amplitudes[w, usable] = _mat_iqr(eeg_raw, axis=1) * IQR_TO_SD
571573

572574
# Check for any channel dropouts (flat signal) within the window
573-
eeg_amplitude = median_abs_deviation(eeg_filtered, axis=1)
575+
eeg_amplitude = _mad(eeg_filtered, axis=1)
574576
dropout[w, usable] = eeg_amplitude == 0
575577

576578
# Exclude any dropout chans from further calculations (avoids div-by-zero)
@@ -580,7 +582,7 @@ def find_bad_by_correlation(
580582
eeg_amplitude = eeg_amplitude[eeg_amplitude > 0]
581583

582584
# Get high-frequency noise ratios for the window
583-
high_freq_amplitude = median_abs_deviation(eeg_raw - eeg_filtered, axis=1)
585+
high_freq_amplitude = _mad(eeg_raw - eeg_filtered, axis=1)
584586
noiselevels[w, usable] = high_freq_amplitude / eeg_amplitude
585587

586588
# Get inter-channel correlations for the window
@@ -704,8 +706,7 @@ def find_bad_by_PSD(self, zscore_threshold=3.0, fmin=1.0, fmax=45.0):
704706
def robust_zscore(values):
705707
"""Compute robust z-scores using MAD."""
706708
median = np.median(values)
707-
mad = np.median(np.abs(values - median))
708-
sd = mad * MAD_TO_SD
709+
sd = _mad(values) * MAD_TO_SD
709710
if sd > 0:
710711
return (values - median) / sd
711712
return np.zeros_like(values)

pyprep/utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,39 @@ def _correlate_arrays(a, b, matlab_strict=False):
474474
return np.diag(np.corrcoef(a, b)[:n_chan, n_chan:])
475475

476476

477+
def _mad(x, axis=None):
478+
"""Calculate the median absolute deviation from the median (MAD).
479+
480+
This is a direct NumPy implementation that is numerically identical to
481+
:func:`scipy.stats.median_abs_deviation` with its default ``scale=1`` and
482+
``nan_policy="propagate"`` (i.e. any ``NaN`` along ``axis`` propagates to
483+
the output). It is used in place of the SciPy function because the latter
484+
performs per-call input validation and a full ``NaN`` scan of the input,
485+
which is a measurable overhead when the MAD is computed repeatedly inside
486+
the windowed loop of
487+
:meth:`~pyprep.NoisyChannels.find_bad_by_correlation`.
488+
489+
Parameters
490+
----------
491+
x : np.ndarray
492+
A numeric array to summarize.
493+
axis : int | None
494+
Axis along which the MAD is calculated. If ``None``, the MAD is
495+
calculated over the flattened array. Defaults to ``None``.
496+
497+
Returns
498+
-------
499+
mad : scalar | np.ndarray
500+
If ``axis`` is ``None``, the MAD of the full array as a single value.
501+
Otherwise, an :class:`~numpy.ndarray` with the MAD for each slice along
502+
the specified axis.
503+
504+
"""
505+
x = np.asarray(x)
506+
median = np.median(x, axis=axis, keepdims=True)
507+
return np.median(np.abs(x - median), axis=axis)
508+
509+
477510
def _filter_design(N_order, amp, freq):
478511
"""Create FIR low-pass filter for EEG data using frequency sampling method.
479512

tests/test_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55

66
import numpy as np
77
import pytest
8+
from scipy.stats import median_abs_deviation
89

910
from pyprep.utils import (
1011
_correlate_arrays,
1112
_eeglab_create_highpass,
1213
_get_random_subset,
14+
_mad,
1315
_mat_iqr,
1416
_mat_quantile,
1517
_mat_round,
@@ -153,3 +155,29 @@ def test_eeglab_create_highpass():
153155
expected_val = 0.9961
154156
actual_val = vals[len(vals) // 2]
155157
assert np.isclose(expected_val, actual_val, atol=0.001)
158+
159+
160+
def test_mad():
161+
"""Test the median absolute deviation from the median (MAD) function."""
162+
# Generate test data
163+
tst = np.array([[1, 2, 3, 4, 8], [80, 10, 20, 30, 40], [100, 200, 800, 300, 400]])
164+
expected = np.asarray([1, 10, 100])
165+
166+
# Compare output to expected results
167+
assert all(np.equal(_mad(tst, axis=1), expected))
168+
assert all(np.equal(_mad(tst.T, axis=0), expected))
169+
assert _mad(tst) == 28 # Matches robust.mad from statsmodels
170+
171+
# _mad must stay numerically identical to the scipy function it replaces
172+
# (scale=1, nan_policy="propagate"), which is what keeps its output
173+
# bit-identical while avoiding scipy's per-call overhead.
174+
rng = np.random.default_rng(42)
175+
data = rng.standard_normal((8, 200))
176+
assert np.array_equal(_mad(data, axis=1), median_abs_deviation(data, axis=1))
177+
assert np.array_equal(_mad(data, axis=0), median_abs_deviation(data, axis=0))
178+
assert _mad(data) == median_abs_deviation(data, axis=None)
179+
180+
# A NaN propagates along the reduction axis, matching scipy's default policy
181+
data[0, 0] = np.nan
182+
assert np.isnan(_mad(data, axis=1)[0])
183+
assert not np.isnan(_mad(data, axis=1)[1])

0 commit comments

Comments
 (0)