Skip to content

Commit 38cd164

Browse files
committed
Drop short good-data spans in Welch PSD with a warning (#13039)
Per maintainer feedback (CarinaFo): rather than shrinking n_overlap to fit good-data spans shorter than n_per_seg, drop them from the estimate and warn, since a single Welch window does not fit them and shrinking the window per-span mixes incompatible estimates. Raise a clear ValueError if every good span is too short. Replaces the earlier noverlap-clamp approach.
1 parent f112781 commit 38cd164

3 files changed

Lines changed: 41 additions & 45 deletions

File tree

doc/changes/dev/14003.bugfix.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Fix :func:`mne.time_frequency.psd_array_welch` (and Welch-method ``compute_psd``) so that good data spans shorter than ``n_overlap`` no longer raise ``noverlap must be less than nperseg``; such spans now reduce ``noverlap`` to fit, by :newcontrib:`Cedric Conday`.
1+
Fix :func:`mne.time_frequency.psd_array_welch` (and Welch-method ``compute_psd``) so that good data spans shorter than ``n_per_seg`` no longer raise ``noverlap must be less than nperseg``; such spans are now dropped from the estimate with a warning, by :newcontrib:`Cedric Conday`.

mne/time_frequency/psd.py

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ..fixes import _reshape_view
1212
from ..parallel import parallel_func
13-
from ..utils import _check_option, _ensure_int, logger, verbose, warn
13+
from ..utils import _check_option, _ensure_int, _pl, logger, verbose, warn
1414
from ..utils.numerics import _mask_to_onsets_offsets
1515

1616

@@ -258,52 +258,37 @@ def psd_array_welch(
258258
# Aligned NaNs across channels → treat as bad annotations.
259259
good_mask = ~nan_mask_full
260260
t_onsets, t_offsets = _mask_to_onsets_offsets(good_mask[0])
261-
x_splits = [x[..., t_ons:t_off] for t_ons, t_off in zip(t_onsets, t_offsets)]
262-
# weights reflect the number of samples used from each span. For spans longer
263-
# than `n_per_seg`, trailing samples may be discarded. For spans shorter than
264-
# `n_per_seg`, the wrapped function (`scipy.signal.spectrogram`) automatically
265-
# reduces `n_per_seg` to match the span length (with a warning).
261+
all_splits = [x[..., t_ons:t_off] for t_ons, t_off in zip(t_onsets, t_offsets)]
262+
# Drop good data spans shorter than n_per_seg: a single Welch window does not
263+
# fit them. (Shrinking the window per-span would mix incompatible estimates,
264+
# and passing them to SciPy as-is raises "noverlap must be less than
265+
# nperseg".) Warn so the user can lower n_per_seg to keep them. See #13039.
266+
x_splits = [span for span in all_splits if span.shape[-1] >= n_per_seg]
267+
n_dropped = len(all_splits) - len(x_splits)
268+
if n_dropped:
269+
warn(
270+
f"{n_dropped} good data span{_pl(n_dropped)} shorter than n_per_seg "
271+
f"({n_per_seg}) {'was' if n_dropped == 1 else 'were'} excluded from "
272+
"the PSD estimate; reduce n_per_seg (or n_fft) to include them."
273+
)
274+
if not x_splits:
275+
raise ValueError(
276+
f"All good data spans are shorter than n_per_seg ({n_per_seg}); no "
277+
"data is left to compute the PSD. Reduce n_per_seg (or n_fft)."
278+
)
279+
# weights reflect the number of samples used from each (kept) span; trailing
280+
# samples beyond the last full window are discarded.
266281
step = n_per_seg - n_overlap
267-
span_lengths = [span.shape[-1] for span in x_splits]
268282
weights = [
269-
w if w < n_per_seg else w - ((w - n_overlap) % step) for w in span_lengths
283+
w - ((w - n_overlap) % step) for w in (s.shape[-1] for s in x_splits)
270284
]
271285
agg_func = partial(np.average, weights=weights)
272286
if n_jobs > 1:
273287
logger.info(
274288
f"Data split into {len(x_splits)} (probably unequal) chunks due to "
275289
'"bad_*" annotations. Parallelization may be sub-optimal.'
276290
)
277-
if (np.array(span_lengths) < n_per_seg).any():
278-
logger.info(
279-
"At least one good data span is shorter than n_per_seg, and will be "
280-
"analyzed with a shorter window than the rest of the file."
281-
)
282-
283-
def func(*args, **kwargs):
284-
# A good data span shorter than n_per_seg makes SciPy reduce nperseg to
285-
# the span length; reduce noverlap to match so it stays < nperseg.
286-
# Otherwise, a span shorter than n_overlap raises "noverlap must be less
287-
# than nperseg". nfft is left unchanged so every span yields the same
288-
# frequency bins (the spans are then combined by weighted average).
289-
# See #13039.
290-
epoch = args[0]
291-
if epoch.shape[-1] < n_per_seg:
292-
span_len = epoch.shape[-1]
293-
kwargs = {
294-
**kwargs,
295-
"nperseg": span_len,
296-
"noverlap": min(n_overlap, max(span_len - 1, 0)),
297-
}
298-
# swallow SciPy warnings caused by short good data spans
299-
with warnings.catch_warnings():
300-
warnings.filterwarnings(
301-
action="ignore",
302-
module="scipy",
303-
category=UserWarning,
304-
message=r"nperseg = \d+ is greater than input length",
305-
)
306-
return _func(*args, **kwargs)
291+
func = _func
307292

308293
else:
309294
# Either no NaNs, or NaNs are not aligned across channels.

mne/time_frequency/tests/test_psd.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,22 +58,33 @@ def test_bad_annot_handling():
5858
np.testing.assert_allclose(got[0], want[0], rtol=1e-15, atol=0)
5959

6060

61-
def test_psd_welch_short_span_with_overlap():
62-
"""Good spans shorter than n_overlap must not crash (gh-13039)."""
61+
def test_psd_welch_short_span_dropped():
62+
"""Good spans shorter than n_per_seg are dropped with a warning (gh-13039)."""
6363
n_fft = 256
6464
n_overlap = n_fft // 2 # 128
6565
n_chan = 2
6666
rng = np.random.default_rng(0)
67-
# A good span (100 samples) shorter than n_overlap, then a bad-annotation
68-
# (aligned NaN), then a long span. Previously raised from SciPy:
69-
# "noverlap must be less than nperseg".
67+
# A short good span (100 samples < n_per_seg), then a bad-annotation
68+
# (aligned NaN), then a long span. The short span cannot hold a single
69+
# Welch window; it is now dropped with a warning rather than raising from
70+
# SciPy ("noverlap must be less than nperseg").
7071
short = rng.standard_normal((n_chan, 100))
7172
long = rng.standard_normal((n_chan, 5 * n_fft))
7273
x = np.concatenate((short, np.full((n_chan, 1), np.nan), long), axis=-1)
73-
psds, freqs = psd_array_welch(x, sfreq=100, n_fft=n_fft, n_overlap=n_overlap)
74+
with pytest.warns(RuntimeWarning, match="shorter than n_per_seg"):
75+
psds, freqs = psd_array_welch(x, sfreq=100, n_fft=n_fft, n_overlap=n_overlap)
7476
assert psds.shape == (n_chan, len(freqs))
7577
assert np.all(np.isfinite(psds))
7678

79+
# If *every* good span is too short, there is nothing left to analyze. Use
80+
# three short spans so the total length still exceeds n_fft (otherwise the
81+
# earlier n_fft > n_times guard fires first).
82+
nan_col = np.full((n_chan, 1), np.nan)
83+
x_all_short = np.concatenate((short, nan_col, short, nan_col, short), axis=-1)
84+
with pytest.raises(ValueError, match="All good data spans are shorter"):
85+
with pytest.warns(RuntimeWarning, match="shorter than n_per_seg"):
86+
psd_array_welch(x_all_short, sfreq=100, n_fft=n_fft, n_overlap=n_overlap)
87+
7788

7889
def _make_psd_data():
7990
"""Make noise data with sinusoids in 2 out of 7 channels."""

0 commit comments

Comments
 (0)