Skip to content

Commit b1a46cc

Browse files
committed
Keep Welch PSD spans shorter than n_per_seg via a per-span window (mne-tools#13039)
Address @drammock's review on mne-tools#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).
1 parent e9384c4 commit b1a46cc

3 files changed

Lines changed: 56 additions & 37 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_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`.
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`.

mne/time_frequency/psd.py

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -257,37 +257,51 @@ def psd_array_welch(
257257
# Aligned NaNs across channels → treat as bad annotations.
258258
good_mask = ~nan_mask_full
259259
t_onsets, t_offsets = _mask_to_onsets_offsets(good_mask[0])
260-
all_splits = [x[..., t_ons:t_off] for t_ons, t_off in zip(t_onsets, t_offsets)]
261-
# Drop good data spans shorter than n_per_seg: a single Welch window does not
262-
# fit them. (Shrinking the window per-span would mix incompatible estimates,
263-
# and passing them to SciPy as-is raises "noverlap must be less than
264-
# nperseg".) Warn so the user can lower n_per_seg to keep them. See #13039.
265-
x_splits = [span for span in all_splits if span.shape[-1] >= n_per_seg]
266-
n_dropped = len(all_splits) - len(x_splits)
267-
if n_dropped:
268-
warn(
269-
f"{n_dropped} good data span{_pl(n_dropped)} shorter than n_per_seg "
270-
f"({n_per_seg}) {'was' if n_dropped == 1 else 'were'} excluded from "
271-
"the PSD estimate; reduce n_per_seg (or n_fft) to include them."
272-
)
260+
x_splits = [x[..., t_ons:t_off] for t_ons, t_off in zip(t_onsets, t_offsets)]
273261
if not x_splits:
274262
raise ValueError(
275-
f"All good data spans are shorter than n_per_seg ({n_per_seg}); no "
276-
"data is left to compute the PSD. Reduce n_per_seg (or n_fft)."
263+
"No good data spans remain to compute the PSD (all samples are "
264+
"excluded by bad annotations)."
265+
)
266+
# A good data span shorter than n_per_seg cannot hold a full-length Welch
267+
# window. SciPy clamps nperseg down to the span length internally but leaves
268+
# noverlap unchanged, then raises "noverlap must be less than nperseg"
269+
# (#13039). Pre-empt that here: analyze each short span with a window shrunk
270+
# to its own length and noverlap clamped below it. n_fft is left unchanged,
271+
# so every span is zero-padded to the same length and shares one frequency
272+
# grid; short spans simply get coarser spectral resolution. A per-span
273+
# ``partial`` overrides the nperseg/noverlap baked into ``_func``.
274+
funcs = []
275+
weights = []
276+
n_short = 0
277+
for span in x_splits:
278+
span_len = span.shape[-1]
279+
if span_len < n_per_seg:
280+
span_nperseg = span_len
281+
span_noverlap = min(n_overlap, span_nperseg - 1)
282+
n_short += 1
283+
else:
284+
span_nperseg = n_per_seg
285+
span_noverlap = n_overlap
286+
funcs.append(partial(_func, nperseg=span_nperseg, noverlap=span_noverlap))
287+
# weight by the samples covered by whole windows (trailing remainder is
288+
# discarded); a shrunk span contributes a single full-length window.
289+
span_step = max(span_nperseg - span_noverlap, 1)
290+
weights.append(span_len - ((span_len - span_noverlap) % span_step))
291+
if n_short:
292+
warn(
293+
f"{n_short} good data span{_pl(n_short)} shorter than n_per_seg "
294+
f"({n_per_seg}) {'was' if n_short == 1 else 'were'} analyzed with a "
295+
"reduced window; spectral resolution is lower for "
296+
f"{'that span' if n_short == 1 else 'those spans'}. Reduce n_per_seg "
297+
"(or n_fft) to silence this warning."
277298
)
278-
# weights reflect the number of samples used from each (kept) span; trailing
279-
# samples beyond the last full window are discarded.
280-
step = n_per_seg - n_overlap
281-
weights = [
282-
w - ((w - n_overlap) % step) for w in (s.shape[-1] for s in x_splits)
283-
]
284299
agg_func = partial(np.average, weights=weights)
285300
if n_jobs > 1:
286301
logger.info(
287302
f"Data split into {len(x_splits)} (probably unequal) chunks due to "
288303
'"bad_*" annotations. Parallelization may be sub-optimal.'
289304
)
290-
func = _func
291305

292306
else:
293307
# Either no NaNs, or NaNs are not aligned across channels.
@@ -298,10 +312,10 @@ def psd_array_welch(
298312
)
299313
x_splits = [arr for arr in np.array_split(x, n_jobs) if arr.size != 0]
300314
agg_func = np.concatenate
301-
func = _func
315+
funcs = [_func] * len(x_splits)
302316
f_spect = parallel(
303-
my_spect_func(d, func=func, freq_sl=freq_sl, average=average, output=output)
304-
for d in x_splits
317+
my_spect_func(d, func=fn, freq_sl=freq_sl, average=average, output=output)
318+
for d, fn in zip(x_splits, funcs)
305319
)
306320
psds = agg_func(f_spect, axis=0)
307321
shape = dshape + (len(freqs),)

mne/time_frequency/tests/test_psd.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,17 @@ 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_dropped():
62-
"""Good spans shorter than n_per_seg are dropped with a warning (gh-13039)."""
61+
def test_psd_welch_short_span_kept():
62+
"""Good spans shorter than n_per_seg are kept 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)
6767
# 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").
68+
# (aligned NaN), then a long span. The short span cannot hold a full Welch
69+
# window; instead of raising from SciPy ("noverlap must be less than
70+
# nperseg") or being dropped, it is now analyzed with a window shrunk to its
71+
# own length and a warning about reduced resolution.
7172
short = rng.standard_normal((n_chan, 100))
7273
long = rng.standard_normal((n_chan, 5 * n_fft))
7374
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():
7677
assert psds.shape == (n_chan, len(freqs))
7778
assert np.all(np.isfinite(psds))
7879

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).
80+
# Even when *every* good span is shorter than n_per_seg, each is analyzed on
81+
# its own; the estimate is still computed (previously this raised). Use three
82+
# short spans so the total length exceeds n_fft (else the n_fft > n_times
83+
# guard fires first).
8284
nan_col = np.full((n_chan, 1), np.nan)
8385
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)
86+
with pytest.warns(RuntimeWarning, match="shorter than n_per_seg"):
87+
psds, freqs = psd_array_welch(
88+
x_all_short, sfreq=100, n_fft=n_fft, n_overlap=n_overlap
89+
)
90+
assert psds.shape == (n_chan, len(freqs))
91+
assert np.all(np.isfinite(psds))
8792

8893

8994
def _make_psd_data():

0 commit comments

Comments
 (0)