From b14059609865de4aaaf316d3a8332ea45e54235a Mon Sep 17 00:00:00 2001 From: Cedric Conday Date: Tue, 30 Jun 2026 02:59:31 +0000 Subject: [PATCH 1/8] Fix psd_array_welch for good spans shorter than n_overlap (#13039) When good data spans (between bad annotations) are shorter than n_per_seg, SciPy reduces nperseg to the span length but leaves noverlap unchanged, so a span shorter than n_overlap raised 'noverlap must be less than nperseg'. Reduce noverlap per-span to stay < nperseg (nfft unchanged so frequency bins match across spans). Adds a regression test. --- doc/changes/dev/13039.bugfix.rst | 1 + mne/time_frequency/psd.py | 14 ++++++++++++++ mne/time_frequency/tests/test_psd.py | 17 +++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 doc/changes/dev/13039.bugfix.rst diff --git a/doc/changes/dev/13039.bugfix.rst b/doc/changes/dev/13039.bugfix.rst new file mode 100644 index 00000000000..efc95fd25da --- /dev/null +++ b/doc/changes/dev/13039.bugfix.rst @@ -0,0 +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`. diff --git a/mne/time_frequency/psd.py b/mne/time_frequency/psd.py index 01d932699a1..9b4612b0d32 100644 --- a/mne/time_frequency/psd.py +++ b/mne/time_frequency/psd.py @@ -281,6 +281,20 @@ def psd_array_welch( ) def func(*args, **kwargs): + # A good data span shorter than n_per_seg makes SciPy reduce nperseg to + # the span length; reduce noverlap to match so it stays < nperseg. + # Otherwise, a span shorter than n_overlap raises "noverlap must be less + # than nperseg". nfft is left unchanged so every span yields the same + # frequency bins (the spans are then combined by weighted average). + # See #13039. + epoch = args[0] + if epoch.shape[-1] < n_per_seg: + span_len = epoch.shape[-1] + kwargs = { + **kwargs, + "nperseg": span_len, + "noverlap": min(n_overlap, max(span_len - 1, 0)), + } # swallow SciPy warnings caused by short good data spans with warnings.catch_warnings(): warnings.filterwarnings( diff --git a/mne/time_frequency/tests/test_psd.py b/mne/time_frequency/tests/test_psd.py index 9718f80b153..330dca8750b 100644 --- a/mne/time_frequency/tests/test_psd.py +++ b/mne/time_frequency/tests/test_psd.py @@ -58,6 +58,23 @@ def test_bad_annot_handling(): np.testing.assert_allclose(got[0], want[0], rtol=1e-15, atol=0) +def test_psd_welch_short_span_with_overlap(): + """Good spans shorter than n_overlap must not crash (gh-13039).""" + n_fft = 256 + n_overlap = n_fft // 2 # 128 + n_chan = 2 + rng = np.random.default_rng(0) + # A good span (100 samples) shorter than n_overlap, then a bad-annotation + # (aligned NaN), then a long span. Previously raised from SciPy: + # "noverlap must be less than nperseg". + short = rng.standard_normal((n_chan, 100)) + long = rng.standard_normal((n_chan, 5 * n_fft)) + x = np.concatenate((short, np.full((n_chan, 1), np.nan), long), axis=-1) + psds, freqs = psd_array_welch(x, sfreq=100, n_fft=n_fft, n_overlap=n_overlap) + assert psds.shape == (n_chan, len(freqs)) + assert np.all(np.isfinite(psds)) + + def _make_psd_data(): """Make noise data with sinusoids in 2 out of 7 channels.""" rng = np.random.default_rng(0) From 89726e43d61260a9f491b98910c81a30229335a4 Mon Sep 17 00:00:00 2001 From: Cedric Conday Date: Tue, 30 Jun 2026 02:59:59 +0000 Subject: [PATCH 2/8] rename changelog fragment to PR number --- doc/changes/dev/{13039.bugfix.rst => 14003.bugfix.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/changes/dev/{13039.bugfix.rst => 14003.bugfix.rst} (100%) diff --git a/doc/changes/dev/13039.bugfix.rst b/doc/changes/dev/14003.bugfix.rst similarity index 100% rename from doc/changes/dev/13039.bugfix.rst rename to doc/changes/dev/14003.bugfix.rst From c0fe44595db7ff1e42c06f7eb6c6c180dd6f98a5 Mon Sep 17 00:00:00 2001 From: Cedric Conday Date: Tue, 30 Jun 2026 10:01:40 +0000 Subject: [PATCH 3/8] 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. --- doc/changes/dev/14003.bugfix.rst | 2 +- mne/time_frequency/psd.py | 61 +++++++++++----------------- mne/time_frequency/tests/test_psd.py | 23 ++++++++--- 3 files changed, 41 insertions(+), 45 deletions(-) diff --git a/doc/changes/dev/14003.bugfix.rst b/doc/changes/dev/14003.bugfix.rst index efc95fd25da..7f418237247 100644 --- a/doc/changes/dev/14003.bugfix.rst +++ b/doc/changes/dev/14003.bugfix.rst @@ -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`. +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`. diff --git a/mne/time_frequency/psd.py b/mne/time_frequency/psd.py index 9b4612b0d32..1f46f9a35bc 100644 --- a/mne/time_frequency/psd.py +++ b/mne/time_frequency/psd.py @@ -10,7 +10,7 @@ from ..fixes import _reshape_view from ..parallel import parallel_func -from ..utils import _check_option, _ensure_int, logger, verbose, warn +from ..utils import _check_option, _ensure_int, _pl, logger, verbose, warn from ..utils.numerics import _mask_to_onsets_offsets @@ -258,15 +258,29 @@ def psd_array_welch( # Aligned NaNs across channels → treat as bad annotations. good_mask = ~nan_mask_full t_onsets, t_offsets = _mask_to_onsets_offsets(good_mask[0]) - x_splits = [x[..., t_ons:t_off] for t_ons, t_off in zip(t_onsets, t_offsets)] - # weights reflect the number of samples used from each span. For spans longer - # than `n_per_seg`, trailing samples may be discarded. For spans shorter than - # `n_per_seg`, the wrapped function (`scipy.signal.spectrogram`) automatically - # reduces `n_per_seg` to match the span length (with a warning). + all_splits = [x[..., t_ons:t_off] for t_ons, t_off in zip(t_onsets, t_offsets)] + # Drop good data spans shorter than n_per_seg: a single Welch window does not + # fit them. (Shrinking the window per-span would mix incompatible estimates, + # and passing them to SciPy as-is raises "noverlap must be less than + # nperseg".) Warn so the user can lower n_per_seg to keep them. See #13039. + x_splits = [span for span in all_splits if span.shape[-1] >= n_per_seg] + n_dropped = len(all_splits) - len(x_splits) + if n_dropped: + warn( + f"{n_dropped} good data span{_pl(n_dropped)} shorter than n_per_seg " + f"({n_per_seg}) {'was' if n_dropped == 1 else 'were'} excluded from " + "the PSD estimate; reduce n_per_seg (or n_fft) to include them." + ) + if not x_splits: + raise ValueError( + f"All good data spans are shorter than n_per_seg ({n_per_seg}); no " + "data is left to compute the PSD. Reduce n_per_seg (or n_fft)." + ) + # weights reflect the number of samples used from each (kept) span; trailing + # samples beyond the last full window are discarded. step = n_per_seg - n_overlap - span_lengths = [span.shape[-1] for span in x_splits] weights = [ - w if w < n_per_seg else w - ((w - n_overlap) % step) for w in span_lengths + w - ((w - n_overlap) % step) for w in (s.shape[-1] for s in x_splits) ] agg_func = partial(np.average, weights=weights) if n_jobs > 1: @@ -274,36 +288,7 @@ def psd_array_welch( f"Data split into {len(x_splits)} (probably unequal) chunks due to " '"bad_*" annotations. Parallelization may be sub-optimal.' ) - if (np.array(span_lengths) < n_per_seg).any(): - logger.info( - "At least one good data span is shorter than n_per_seg, and will be " - "analyzed with a shorter window than the rest of the file." - ) - - def func(*args, **kwargs): - # A good data span shorter than n_per_seg makes SciPy reduce nperseg to - # the span length; reduce noverlap to match so it stays < nperseg. - # Otherwise, a span shorter than n_overlap raises "noverlap must be less - # than nperseg". nfft is left unchanged so every span yields the same - # frequency bins (the spans are then combined by weighted average). - # See #13039. - epoch = args[0] - if epoch.shape[-1] < n_per_seg: - span_len = epoch.shape[-1] - kwargs = { - **kwargs, - "nperseg": span_len, - "noverlap": min(n_overlap, max(span_len - 1, 0)), - } - # swallow SciPy warnings caused by short good data spans - with warnings.catch_warnings(): - warnings.filterwarnings( - action="ignore", - module="scipy", - category=UserWarning, - message=r"nperseg = \d+ is greater than input length", - ) - return _func(*args, **kwargs) + func = _func else: # Either no NaNs, or NaNs are not aligned across channels. diff --git a/mne/time_frequency/tests/test_psd.py b/mne/time_frequency/tests/test_psd.py index 330dca8750b..743e2fd8b0d 100644 --- a/mne/time_frequency/tests/test_psd.py +++ b/mne/time_frequency/tests/test_psd.py @@ -58,22 +58,33 @@ def test_bad_annot_handling(): np.testing.assert_allclose(got[0], want[0], rtol=1e-15, atol=0) -def test_psd_welch_short_span_with_overlap(): - """Good spans shorter than n_overlap must not crash (gh-13039).""" +def test_psd_welch_short_span_dropped(): + """Good spans shorter than n_per_seg are dropped with a warning (gh-13039).""" n_fft = 256 n_overlap = n_fft // 2 # 128 n_chan = 2 rng = np.random.default_rng(0) - # A good span (100 samples) shorter than n_overlap, then a bad-annotation - # (aligned NaN), then a long span. Previously raised from SciPy: - # "noverlap must be less than nperseg". + # A short good span (100 samples < n_per_seg), then a bad-annotation + # (aligned NaN), then a long span. The short span cannot hold a single + # Welch window; it is now dropped with a warning rather than raising from + # SciPy ("noverlap must be less than nperseg"). short = rng.standard_normal((n_chan, 100)) long = rng.standard_normal((n_chan, 5 * n_fft)) x = np.concatenate((short, np.full((n_chan, 1), np.nan), long), axis=-1) - psds, freqs = psd_array_welch(x, sfreq=100, n_fft=n_fft, n_overlap=n_overlap) + with pytest.warns(RuntimeWarning, match="shorter than n_per_seg"): + psds, freqs = psd_array_welch(x, sfreq=100, n_fft=n_fft, n_overlap=n_overlap) assert psds.shape == (n_chan, len(freqs)) assert np.all(np.isfinite(psds)) + # If *every* good span is too short, there is nothing left to analyze. Use + # three short spans so the total length still exceeds n_fft (otherwise the + # earlier n_fft > n_times guard fires first). + nan_col = np.full((n_chan, 1), np.nan) + x_all_short = np.concatenate((short, nan_col, short, nan_col, short), axis=-1) + with pytest.raises(ValueError, match="All good data spans are shorter"): + with pytest.warns(RuntimeWarning, match="shorter than n_per_seg"): + psd_array_welch(x_all_short, sfreq=100, n_fft=n_fft, n_overlap=n_overlap) + def _make_psd_data(): """Make noise data with sinusoids in 2 out of 7 channels.""" From 4cb31c5593b129961c4ccf54b0b7f28656ad8847 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:03:11 +0000 Subject: [PATCH 4/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/time_frequency/psd.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mne/time_frequency/psd.py b/mne/time_frequency/psd.py index 1f46f9a35bc..643233b2c5e 100644 --- a/mne/time_frequency/psd.py +++ b/mne/time_frequency/psd.py @@ -2,7 +2,6 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -import warnings from functools import partial import numpy as np From 7e776c2d9817b4e4297569a7ad02a993f607a1bf Mon Sep 17 00:00:00 2001 From: Cedric Conday <277679649+CedricConday@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:11:47 +0000 Subject: [PATCH 5/8] Keep Welch PSD spans shorter than n_per_seg via a per-span window (#13039) Address @drammock's review on #14003: rather than dropping good-data spans shorter than n_per_seg, analyze each such span with nperseg shrunk to the span length and noverlap clamped below it (a per-span functools.partial overriding the values baked into _func). n_fft is unchanged, so all spans share one frequency grid; short spans just get coarser spectral resolution, which is surfaced via a warning. No data is discarded. This is the option-3 workaround from the PR discussion. The underlying SciPy behavior (clamping nperseg but not noverlap) may still be worth reporting upstream (option 4). --- doc/changes/dev/14003.bugfix.rst | 2 +- mne/time_frequency/psd.py | 64 +++++++++++++++++----------- mne/time_frequency/tests/test_psd.py | 27 +++++++----- 3 files changed, 56 insertions(+), 37 deletions(-) diff --git a/doc/changes/dev/14003.bugfix.rst b/doc/changes/dev/14003.bugfix.rst index 7f418237247..8a0fa1cf79e 100644 --- a/doc/changes/dev/14003.bugfix.rst +++ b/doc/changes/dev/14003.bugfix.rst @@ -1 +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`. +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 analyzed with a window shrunk to the span length (with a warning that their spectral resolution is reduced) instead of being lost, by :newcontrib:`Cedric Conday`. diff --git a/mne/time_frequency/psd.py b/mne/time_frequency/psd.py index 643233b2c5e..56cde99d96d 100644 --- a/mne/time_frequency/psd.py +++ b/mne/time_frequency/psd.py @@ -257,37 +257,51 @@ def psd_array_welch( # Aligned NaNs across channels → treat as bad annotations. good_mask = ~nan_mask_full t_onsets, t_offsets = _mask_to_onsets_offsets(good_mask[0]) - all_splits = [x[..., t_ons:t_off] for t_ons, t_off in zip(t_onsets, t_offsets)] - # Drop good data spans shorter than n_per_seg: a single Welch window does not - # fit them. (Shrinking the window per-span would mix incompatible estimates, - # and passing them to SciPy as-is raises "noverlap must be less than - # nperseg".) Warn so the user can lower n_per_seg to keep them. See #13039. - x_splits = [span for span in all_splits if span.shape[-1] >= n_per_seg] - n_dropped = len(all_splits) - len(x_splits) - if n_dropped: - warn( - f"{n_dropped} good data span{_pl(n_dropped)} shorter than n_per_seg " - f"({n_per_seg}) {'was' if n_dropped == 1 else 'were'} excluded from " - "the PSD estimate; reduce n_per_seg (or n_fft) to include them." - ) + x_splits = [x[..., t_ons:t_off] for t_ons, t_off in zip(t_onsets, t_offsets)] if not x_splits: raise ValueError( - f"All good data spans are shorter than n_per_seg ({n_per_seg}); no " - "data is left to compute the PSD. Reduce n_per_seg (or n_fft)." + "No good data spans remain to compute the PSD (all samples are " + "excluded by bad annotations)." + ) + # A good data span shorter than n_per_seg cannot hold a full-length Welch + # window. SciPy clamps nperseg down to the span length internally but leaves + # noverlap unchanged, then raises "noverlap must be less than nperseg" + # (#13039). Pre-empt that here: analyze each short span with a window shrunk + # to its own length and noverlap clamped below it. n_fft is left unchanged, + # so every span is zero-padded to the same length and shares one frequency + # grid; short spans simply get coarser spectral resolution. A per-span + # ``partial`` overrides the nperseg/noverlap baked into ``_func``. + funcs = [] + weights = [] + n_short = 0 + for span in x_splits: + span_len = span.shape[-1] + if span_len < n_per_seg: + span_nperseg = span_len + span_noverlap = min(n_overlap, span_nperseg - 1) + n_short += 1 + else: + span_nperseg = n_per_seg + span_noverlap = n_overlap + funcs.append(partial(_func, nperseg=span_nperseg, noverlap=span_noverlap)) + # weight by the samples covered by whole windows (trailing remainder is + # discarded); a shrunk span contributes a single full-length window. + span_step = max(span_nperseg - span_noverlap, 1) + weights.append(span_len - ((span_len - span_noverlap) % span_step)) + if n_short: + warn( + f"{n_short} good data span{_pl(n_short)} shorter than n_per_seg " + f"({n_per_seg}) {'was' if n_short == 1 else 'were'} analyzed with a " + "reduced window; spectral resolution is lower for " + f"{'that span' if n_short == 1 else 'those spans'}. Reduce n_per_seg " + "(or n_fft) to silence this warning." ) - # weights reflect the number of samples used from each (kept) span; trailing - # samples beyond the last full window are discarded. - step = n_per_seg - n_overlap - weights = [ - w - ((w - n_overlap) % step) for w in (s.shape[-1] for s in x_splits) - ] agg_func = partial(np.average, weights=weights) if n_jobs > 1: logger.info( f"Data split into {len(x_splits)} (probably unequal) chunks due to " '"bad_*" annotations. Parallelization may be sub-optimal.' ) - func = _func else: # Either no NaNs, or NaNs are not aligned across channels. @@ -298,10 +312,10 @@ def psd_array_welch( ) x_splits = [arr for arr in np.array_split(x, n_jobs) if arr.size != 0] agg_func = np.concatenate - func = _func + funcs = [_func] * len(x_splits) f_spect = parallel( - my_spect_func(d, func=func, freq_sl=freq_sl, average=average, output=output) - for d in x_splits + my_spect_func(d, func=fn, freq_sl=freq_sl, average=average, output=output) + for d, fn in zip(x_splits, funcs) ) psds = agg_func(f_spect, axis=0) shape = dshape + (len(freqs),) diff --git a/mne/time_frequency/tests/test_psd.py b/mne/time_frequency/tests/test_psd.py index 743e2fd8b0d..cd9d054fe25 100644 --- a/mne/time_frequency/tests/test_psd.py +++ b/mne/time_frequency/tests/test_psd.py @@ -58,16 +58,17 @@ def test_bad_annot_handling(): np.testing.assert_allclose(got[0], want[0], rtol=1e-15, atol=0) -def test_psd_welch_short_span_dropped(): - """Good spans shorter than n_per_seg are dropped with a warning (gh-13039).""" +def test_psd_welch_short_span_kept(): + """Good spans shorter than n_per_seg are kept with a warning (gh-13039).""" n_fft = 256 n_overlap = n_fft // 2 # 128 n_chan = 2 rng = np.random.default_rng(0) # A short good span (100 samples < n_per_seg), then a bad-annotation - # (aligned NaN), then a long span. The short span cannot hold a single - # Welch window; it is now dropped with a warning rather than raising from - # SciPy ("noverlap must be less than nperseg"). + # (aligned NaN), then a long span. The short span cannot hold a full Welch + # window; instead of raising from SciPy ("noverlap must be less than + # nperseg") or being dropped, it is now analyzed with a window shrunk to its + # own length and a warning about reduced resolution. short = rng.standard_normal((n_chan, 100)) long = rng.standard_normal((n_chan, 5 * n_fft)) x = np.concatenate((short, np.full((n_chan, 1), np.nan), long), axis=-1) @@ -76,14 +77,18 @@ def test_psd_welch_short_span_dropped(): assert psds.shape == (n_chan, len(freqs)) assert np.all(np.isfinite(psds)) - # If *every* good span is too short, there is nothing left to analyze. Use - # three short spans so the total length still exceeds n_fft (otherwise the - # earlier n_fft > n_times guard fires first). + # Even when *every* good span is shorter than n_per_seg, each is analyzed on + # its own; the estimate is still computed (previously this raised). Use three + # short spans so the total length exceeds n_fft (else the n_fft > n_times + # guard fires first). nan_col = np.full((n_chan, 1), np.nan) x_all_short = np.concatenate((short, nan_col, short, nan_col, short), axis=-1) - with pytest.raises(ValueError, match="All good data spans are shorter"): - with pytest.warns(RuntimeWarning, match="shorter than n_per_seg"): - psd_array_welch(x_all_short, sfreq=100, n_fft=n_fft, n_overlap=n_overlap) + with pytest.warns(RuntimeWarning, match="shorter than n_per_seg"): + psds, freqs = psd_array_welch( + x_all_short, sfreq=100, n_fft=n_fft, n_overlap=n_overlap + ) + assert psds.shape == (n_chan, len(freqs)) + assert np.all(np.isfinite(psds)) def _make_psd_data(): From b28ce8e327ba6abba0be932cae3d42e3dfd93247 Mon Sep 17 00:00:00 2001 From: Cedric Conday <277679649+CedricConday@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:17:01 +0000 Subject: [PATCH 6/8] Fix codespell: Pre-empt -> Preempt in psd.py comment --- mne/time_frequency/psd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/time_frequency/psd.py b/mne/time_frequency/psd.py index 56cde99d96d..73df02a7cbc 100644 --- a/mne/time_frequency/psd.py +++ b/mne/time_frequency/psd.py @@ -266,7 +266,7 @@ def psd_array_welch( # A good data span shorter than n_per_seg cannot hold a full-length Welch # window. SciPy clamps nperseg down to the span length internally but leaves # noverlap unchanged, then raises "noverlap must be less than nperseg" - # (#13039). Pre-empt that here: analyze each short span with a window shrunk + # (#13039). Preempt that here: analyze each short span with a window shrunk # to its own length and noverlap clamped below it. n_fft is left unchanged, # so every span is zero-padded to the same length and shares one frequency # grid; short spans simply get coarser spectral resolution. A per-span From 689c84302c8fa8bc72ab048681d7f3c60c74d15a Mon Sep 17 00:00:00 2001 From: Cedric Conday <277679649+CedricConday@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:06:46 +0000 Subject: [PATCH 7/8] Raise a clear error for a fixed-length window array on a short span A named/tuple window is regenerated by SciPy at the shrunk nperseg, so short good-data spans are handled transparently. An explicit ndarray window has a fixed length and cannot be shortened to match, which previously surfaced as a cryptic SciPy length-mismatch error. Detect this case and raise an actionable ValueError pointing the user at passing a shorter window array or reducing n_per_seg/n_fft. Adds a regression test. --- mne/time_frequency/psd.py | 13 +++++++++++++ mne/time_frequency/tests/test_psd.py | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/mne/time_frequency/psd.py b/mne/time_frequency/psd.py index 73df02a7cbc..7e5f179385a 100644 --- a/mne/time_frequency/psd.py +++ b/mne/time_frequency/psd.py @@ -271,12 +271,25 @@ def psd_array_welch( # so every span is zero-padded to the same length and shares one frequency # grid; short spans simply get coarser spectral resolution. A per-span # ``partial`` overrides the nperseg/noverlap baked into ``_func``. + # A named/tuple window (e.g. "hamming") is regenerated by SciPy at the + # shrunk nperseg, so short spans are handled transparently. An explicit + # ndarray/list window has a fixed length and cannot be shortened to match; + # rather than let SciPy raise a cryptic length-mismatch error, bail clearly. + window_is_array = not isinstance(window, (str, tuple)) funcs = [] weights = [] n_short = 0 for span in x_splits: span_len = span.shape[-1] if span_len < n_per_seg: + if window_is_array: + raise ValueError( + f"A good data span ({span_len} samples) is shorter than " + f"n_per_seg ({n_per_seg}), but `window` is a fixed-length " + "array that cannot be shortened to match it. Pass a shorter " + "`window` array, or reduce n_per_seg (or n_fft) so that every " + "good data span is at least n_per_seg samples long." + ) span_nperseg = span_len span_noverlap = min(n_overlap, span_nperseg - 1) n_short += 1 diff --git a/mne/time_frequency/tests/test_psd.py b/mne/time_frequency/tests/test_psd.py index cd9d054fe25..1200d6be4d3 100644 --- a/mne/time_frequency/tests/test_psd.py +++ b/mne/time_frequency/tests/test_psd.py @@ -91,6 +91,31 @@ def test_psd_welch_short_span_kept(): assert np.all(np.isfinite(psds)) +def test_psd_welch_short_span_array_window_raises(): + """A fixed-length ndarray window can't shrink for a short span (gh-13039).""" + n_fft = 256 + n_overlap = n_fft // 2 + n_chan = 2 + rng = np.random.default_rng(0) + short = rng.standard_normal((n_chan, 100)) + long = rng.standard_normal((n_chan, 5 * n_fft)) + x = np.concatenate((short, np.full((n_chan, 1), np.nan), long), axis=-1) + # Named windows are regenerated by SciPy at the shrunk length, but an explicit + # window array is fixed-length and cannot be shortened to fit the 100-sample + # span, so we raise a clear error instead of a cryptic SciPy length mismatch. + with pytest.raises(ValueError, match="fixed-length array"): + psd_array_welch( + x, sfreq=100, n_fft=n_fft, n_overlap=n_overlap, window=np.hamming(n_fft) + ) + # A full-length array window on all-long spans still works (guard is scoped). + x_ok = np.concatenate((long, np.full((n_chan, 1), np.nan), long), axis=-1) + psds, freqs = psd_array_welch( + x_ok, sfreq=100, n_fft=n_fft, n_overlap=n_overlap, window=np.hamming(n_fft) + ) + assert psds.shape == (n_chan, len(freqs)) + assert np.all(np.isfinite(psds)) + + def _make_psd_data(): """Make noise data with sinusoids in 2 out of 7 channels.""" rng = np.random.default_rng(0) From 4ebb31b65ebc2b992e755e853a148c652f890a26 Mon Sep 17 00:00:00 2001 From: Cedric Conday <277679649+CedricConday@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:12:09 +0000 Subject: [PATCH 8/8] Zero-pad short good-data spans instead of shrinking the window Following @CarinaFo's review on #14003: instead of shrinking nperseg/noverlap per short span, zero-pad each span shorter than n_per_seg up to n_per_seg. This matches the direction SciPy is taking for short input (scipy#25608; scipy PR #25633 zero-pads the csd input up to nperseg), so every span keeps one window length and one frequency grid rather than diverging from upstream. A padded span yields a single full-length segment, weighted by its real (pre-padding) sample count. Because spans are no longer shortened, an explicit fixed-length window array now fits a padded short span too, so the previous fixed-length-window ValueError and its regression test are removed. Updates the warning text and changelog accordingly. --- doc/changes/dev/14003.bugfix.rst | 2 +- mne/time_frequency/psd.py | 70 +++++++++++----------------- mne/time_frequency/tests/test_psd.py | 22 +++++---- 3 files changed, 41 insertions(+), 53 deletions(-) diff --git a/doc/changes/dev/14003.bugfix.rst b/doc/changes/dev/14003.bugfix.rst index 8a0fa1cf79e..763f8e15c95 100644 --- a/doc/changes/dev/14003.bugfix.rst +++ b/doc/changes/dev/14003.bugfix.rst @@ -1 +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 analyzed with a window shrunk to the span length (with a warning that their spectral resolution is reduced) instead of being lost, by :newcontrib:`Cedric Conday`. +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 zero-padded up to ``n_per_seg`` (with a warning) instead of being lost, by :newcontrib:`Cedric Conday`. diff --git a/mne/time_frequency/psd.py b/mne/time_frequency/psd.py index 7e5f179385a..b8e97c9a1b6 100644 --- a/mne/time_frequency/psd.py +++ b/mne/time_frequency/psd.py @@ -266,50 +266,36 @@ def psd_array_welch( # A good data span shorter than n_per_seg cannot hold a full-length Welch # window. SciPy clamps nperseg down to the span length internally but leaves # noverlap unchanged, then raises "noverlap must be less than nperseg" - # (#13039). Preempt that here: analyze each short span with a window shrunk - # to its own length and noverlap clamped below it. n_fft is left unchanged, - # so every span is zero-padded to the same length and shares one frequency - # grid; short spans simply get coarser spectral resolution. A per-span - # ``partial`` overrides the nperseg/noverlap baked into ``_func``. - # A named/tuple window (e.g. "hamming") is regenerated by SciPy at the - # shrunk nperseg, so short spans are handled transparently. An explicit - # ndarray/list window has a fixed length and cannot be shortened to match; - # rather than let SciPy raise a cryptic length-mismatch error, bail clearly. - window_is_array = not isinstance(window, (str, tuple)) - funcs = [] - weights = [] - n_short = 0 - for span in x_splits: - span_len = span.shape[-1] - if span_len < n_per_seg: - if window_is_array: - raise ValueError( - f"A good data span ({span_len} samples) is shorter than " - f"n_per_seg ({n_per_seg}), but `window` is a fixed-length " - "array that cannot be shortened to match it. Pass a shorter " - "`window` array, or reduce n_per_seg (or n_fft) so that every " - "good data span is at least n_per_seg samples long." - ) - span_nperseg = span_len - span_noverlap = min(n_overlap, span_nperseg - 1) - n_short += 1 - else: - span_nperseg = n_per_seg - span_noverlap = n_overlap - funcs.append(partial(_func, nperseg=span_nperseg, noverlap=span_noverlap)) - # weight by the samples covered by whole windows (trailing remainder is - # discarded); a shrunk span contributes a single full-length window. - span_step = max(span_nperseg - span_noverlap, 1) - weights.append(span_len - ((span_len - span_noverlap) % span_step)) + # (#13039). Zero-pad each short span up to n_per_seg instead. This matches + # the direction SciPy itself is taking for short input (scipy#25608; scipy + # PR #25633 zero-pads the csd input up to nperseg rather than shrinking the + # window), so every span keeps the same window length and frequency grid and + # an explicit ``window`` array still fits. A padded span yields a single + # full-length segment; its weight counts only the real (pre-padding) samples + # so the zeros do not inflate its share of the average. + span_lengths = [span.shape[-1] for span in x_splits] + n_short = sum(span_len < n_per_seg for span_len in span_lengths) if n_short: + x_splits = [ + np.pad(span, [(0, 0)] * (span.ndim - 1) + [(0, n_per_seg - span_len)]) + if span_len < n_per_seg + else span + for span, span_len in zip(x_splits, span_lengths) + ] warn( f"{n_short} good data span{_pl(n_short)} shorter than n_per_seg " - f"({n_per_seg}) {'was' if n_short == 1 else 'were'} analyzed with a " - "reduced window; spectral resolution is lower for " - f"{'that span' if n_short == 1 else 'those spans'}. Reduce n_per_seg " - "(or n_fft) to silence this warning." + f"({n_per_seg}) {'was' if n_short == 1 else 'were'} zero-padded up to " + "n_per_seg; the padding biases their spectral estimates. Reduce " + "n_per_seg (or n_fft) to avoid this." ) + step = n_per_seg - n_overlap + # weight each span by the samples covered by whole windows (trailing + # remainder is discarded); a zero-padded short span counts its real length. + weights = [ + w if w < n_per_seg else w - ((w - n_overlap) % step) for w in span_lengths + ] agg_func = partial(np.average, weights=weights) + func = _func if n_jobs > 1: logger.info( f"Data split into {len(x_splits)} (probably unequal) chunks due to " @@ -325,10 +311,10 @@ def psd_array_welch( ) x_splits = [arr for arr in np.array_split(x, n_jobs) if arr.size != 0] agg_func = np.concatenate - funcs = [_func] * len(x_splits) + func = _func f_spect = parallel( - my_spect_func(d, func=fn, freq_sl=freq_sl, average=average, output=output) - for d, fn in zip(x_splits, funcs) + my_spect_func(d, func=func, freq_sl=freq_sl, average=average, output=output) + for d in x_splits ) psds = agg_func(f_spect, axis=0) shape = dshape + (len(freqs),) diff --git a/mne/time_frequency/tests/test_psd.py b/mne/time_frequency/tests/test_psd.py index 1200d6be4d3..86ee7e34ff3 100644 --- a/mne/time_frequency/tests/test_psd.py +++ b/mne/time_frequency/tests/test_psd.py @@ -67,8 +67,8 @@ def test_psd_welch_short_span_kept(): # A short good span (100 samples < n_per_seg), then a bad-annotation # (aligned NaN), then a long span. The short span cannot hold a full Welch # window; instead of raising from SciPy ("noverlap must be less than - # nperseg") or being dropped, it is now analyzed with a window shrunk to its - # own length and a warning about reduced resolution. + # nperseg") or being dropped, it is now zero-padded up to n_per_seg (matching + # SciPy's own direction for short input, scipy#25608) with a warning. short = rng.standard_normal((n_chan, 100)) long = rng.standard_normal((n_chan, 5 * n_fft)) x = np.concatenate((short, np.full((n_chan, 1), np.nan), long), axis=-1) @@ -91,8 +91,8 @@ def test_psd_welch_short_span_kept(): assert np.all(np.isfinite(psds)) -def test_psd_welch_short_span_array_window_raises(): - """A fixed-length ndarray window can't shrink for a short span (gh-13039).""" +def test_psd_welch_short_span_array_window(): + """A fixed-length ndarray window still fits a zero-padded short span (gh-13039).""" n_fft = 256 n_overlap = n_fft // 2 n_chan = 2 @@ -100,14 +100,16 @@ def test_psd_welch_short_span_array_window_raises(): short = rng.standard_normal((n_chan, 100)) long = rng.standard_normal((n_chan, 5 * n_fft)) x = np.concatenate((short, np.full((n_chan, 1), np.nan), long), axis=-1) - # Named windows are regenerated by SciPy at the shrunk length, but an explicit - # window array is fixed-length and cannot be shortened to fit the 100-sample - # span, so we raise a clear error instead of a cryptic SciPy length mismatch. - with pytest.raises(ValueError, match="fixed-length array"): - psd_array_welch( + # Zero-padding the short span up to n_per_seg means an explicit full-length + # window array (which SciPy requires to equal nperseg) now fits it too, so the + # earlier fixed-length-window special case is unnecessary; it just warns. + with pytest.warns(RuntimeWarning, match="shorter than n_per_seg"): + psds, freqs = psd_array_welch( x, sfreq=100, n_fft=n_fft, n_overlap=n_overlap, window=np.hamming(n_fft) ) - # A full-length array window on all-long spans still works (guard is scoped). + assert psds.shape == (n_chan, len(freqs)) + assert np.all(np.isfinite(psds)) + # A full-length array window on all-long spans still works (no padding, no warn). x_ok = np.concatenate((long, np.full((n_chan, 1), np.nan), long), axis=-1) psds, freqs = psd_array_welch( x_ok, sfreq=100, n_fft=n_fft, n_overlap=n_overlap, window=np.hamming(n_fft)