Fix psd_array_welch for good data spans shorter than n_per_seg (#13039) - #14003
Fix psd_array_welch for good data spans shorter than n_per_seg (#13039)#14003CedricConday wants to merge 10 commits into
Conversation
|
Hello! 👋 Thanks for opening your first pull request here! ❤️ We will try to get back to you soon. 🚴 |
|
Hi Cedric, thank you for your contribution. Could you disclose your AI usage according to our adopted policy. I ran into this same issue in my analysis recently and ended up deciding that dropping short spans was the safer approach, so I would be rather cautious about shrinking noverlap, particularly without warning the user about this behaviour. |
|
Hi Carina, thanks for the careful review. On the AI disclosure — I've added it to the PR description per the policy: I'm an AI engineer and use Claude Code in my workflow; I find, fix, and test, then review and verify everything before opening it under my name. Happy to walk through any part of the reasoning. On the approach — I agree, silently shrinking Happy to sync with @drammock before this is finalized. On your open question — I don't want to guess at why Thanks! |
|
Quick follow-up on the history question — I traced it. In 2015 ( |
|
So if I'm understanding correctly, the problem is:
I lean toward considering this a SciPy bug: the user passed input that ought to work, and then scipy-internal code changed one value, then chokes because that value is no longer OK relative the other passed value. Would love @larsoner's opinion as to whether upstreaming this report makes sense. As for what to do about it on our end (mostly repeating what others have said here), we could:
I lean toward (3) or (4 & 3) |
…e-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). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyuFNWN45FNffpGwsC4Su7
|
Thanks @drammock — your root-cause read is exactly right: I've gone with option 3. Instead of dropping short spans, each span now gets a per-span Two things worth your call:
|
Yeah I think it's worth asking them if they consider it bug-ish and want it fixed.
I don't have a clear sense of what users would prefer here (it might vary). drop-segment-plus-warning is a bit more friendly I guess, but would still be catastrophic if a particular dataset was mostly/all short spans... but I guess in either case the remedy is "pass in a shorter custom window array" so maybe that's fine. |
|
Pushed ae6fcbf handling the array- Array/ndarray window + short span — went with the clear-error route. A named/tuple window is regenerated by SciPy at the shrunk Upstream (option 4) — I'll open a SciPy issue for the clamp- |
|
Opened the upstream SciPy issue: scipy/scipy#25608 — clamp- |
…e-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).
ae6fcbf to
a06d907
Compare
…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.
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.
for more information, see https://pre-commit.ci
…e-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).
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.
…e-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).
a06d907 to
689c843
Compare
|
Nice work, the implementation looks good @CedricConday. I had a look at the scipy issue and it seems the suggested fix from scipy uses a different approach: the original signal is zero-padded up to nperseg (with nperseg left unchanged), rather than shrinking nperseg/window to the signal length as this PR does (The scipy PR isn't reviewed yet, so this could still change.) |
Following @CarinaFo's review on mne-tools#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.
Following @CarinaFo's review on mne-tools#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.
|
To make this choice less abstract, I ran a small simulation comparing the three options. I generated 200 stationary signals with known 10 Hz and 20 Hz activity, split them into long and short good-data spans, and compared each result with the PSD of the uninterrupted signal. In this test:
This does not mean that shrinking is always the best choice. Real short spans may contain a different signal, and that would need a separate test. But the result suggests that zero-padding is not neutral. It may be useful to add a regression test that checks the estimated power of a known signal, rather than only checking that the output is finite. The PR description also still says that short spans are dropped, while the current code zero-pads them. I can share the script and plot if useful. |
|
Hi @viranovskaya, would be really useful for us if you could share the code and plots for the simulations so we can make an informed decision on what is the better approach here. Thank your for your great work. Carina |
zip file attachment didn't succeed; can you post the script and CSV as as a GitHub Gist maybe? Or just the script is enough, if it regenerates the CSV. |
|
Thanks — the ZIP upload failed. I’ve posted the full script as a public Gist here: https://gist.github.com/viranovskaya/f245abb502567c2dbc665b59daad7284 Running |
|
Thanks — I updated the simulation to separate end-padding, centred padding, and centred padding with a valid-window correction. The result changes the earlier interpretation. With 80% of samples in short spans, median alpha error was -21.5% for end-padding, -6.5% for centred padding, and -0.3% after the correction. The corrected centred condition also had lower median log-PSD RMSE than shrinking in this stationary test (0.0434 vs 0.0585), although median beta error was +1.3%. So the bias is not a property of zero-padding alone; placement and normalisation both matter. This is still a stationary synthetic test and does not decide what is best when short spans contain a different physiological state. The updated script and full table are in the same Gist: |
|
Thank you for the detailed simulations, this does change the picture quite a lot. I would still opt for shrinking because it is less complex but then scipy does zero pad, so would be also nice to be consistent. Next up would be to compare our way of zero padding (centered and corrected) with the scipy PR |
|
Thanks — I ran the current SciPy PR implementation directly on the same 200 simulations. The PR right-pads each short input to At 20%, 50%, and 80% short samples, median alpha error was −5.8%, −13.7%, and −21.5%, respectively. Shrinking remained close to zero, while centred padding with the valid-window correction was also close to zero and had lower log-PSD RMSE in this stationary simulation. So the SciPy PR would give consistent behaviour, but the right-padding itself is not neutral here. I’ll add the direct comparison script, table, and plot to the Gist. |
4ebb31b to
ffa1717
Compare
|
Sorry for the silence — picking this back up. First, a correction to my own PR that I should have flagged sooner: the branch currently end-pads, and @viranovskaya's numbers say that is the worst of the three options. My recommendation: go back to shrinking. @CarinaFo, I think the consistency argument dissolves once we know what SciPy's approach actually costs. Consistency is only worth having when the thing we are consistent with is unbiased, and viranovskaya's measurement says right-padding is not: it underestimates alpha power by up to 21.5% in a stationary test. Matching SciPy here would mean adopting a known bias in order to agree with it. There is also a reason the right answer should differ from SciPy's, beyond the numbers. SciPy is handed a single short array and has to decide what that array means; padding it is a defensible reading. We are in a different situation: our short spans are carved out of a longer recording by The failure modes also differ in kind. Shrinking's cost is coarser resolution on short spans — visible, warned about, and interpretable. Padding's cost is a silent underestimate of band power, which is invisible and lands on precisely the quantity most people compute from this function. Given the choice, I would rather be transparently coarse than quietly wrong. And it happens to be the simplest option, which was your instinct anyway — the usual accuracy/complexity trade-off doesn't bite here. The one option I would not dismiss is centred padding with the valid-window correction, which viranovskaya measured at −0.3% alpha and a slightly better log-PSD RMSE than shrinking. If we want maximum accuracy I would take that over end-padding without hesitation. I lean against it only because it is a third behaviour — consistent with neither SciPy nor the simple approach — and it adds both a placement rule and a normalisation correction to maintain, for a band-power gain that is within noise of shrinking in this test. Two follow-ups either way:
@viranovskaya — thank you, genuinely. Running the SciPy PR against the same 200 simulations is what turned this from a preference argument into a decidable one, and it caught a regression in my own branch. Happy to push the change as soon as you and @drammock agree on the direction. AI-assisted, human-reviewed. |
This reverts commit ffa1717.
…s#13039) Asserts the alpha-band power recovered from a signal chopped into good spans shorter than n_per_seg matches the value from the uninterrupted signal. Finiteness alone does not catch a biased estimate: zero-padding each short span up to n_per_seg also yields finite, plausible output while underestimating band power by ~78% on this input, because the padded zeros enter the window energy normalisation. Shrinking the window per span stays within 0.3%. Suggested by @viranovskaya.
|
Pushed the decision: back to shrinking, plus the band-power regression test @viranovskaya asked for.
The new
That gap is the point — both produce finite, plausible-looking spectra, so the old finiteness assertions could not tell them apart. I've also rewritten the PR description, which was still describing the drop behaviour from two implementations ago. @CarinaFo — this lands where you leaned originally. I don't think the SciPy-consistency argument survives the measurement: matching them here means importing a known bias, and our situation differs from theirs anyway, since our short spans are carved out of a longer recording rather than being the whole input. Full AI-assisted, human-reviewed. |

Reference issue
Fixes #13039.
What does this implement/fix?
When
bad_*annotations split the data into good spans, a span shorter thann_per_segpreviously raised from SciPy:Such spans are now analysed with a window shrunk to the span's own length, with
n_overlapclamped below it and a warning about the reduced resolution.n_fftis left unchanged, so every span is zero-padded to the same FFT length and shares one frequency grid — short spans simply get coarser spectral resolution. No data is discarded.An explicit ndarray
windowcannot be shortened to match, so that combination raises a clearValueErrorpointing at the remedy (pass a shorterwindow, or reducen_per_seg/n_fft) rather than letting SciPy raise a length mismatch.Why shrinking rather than zero-padding
An earlier revision of this PR zero-padded short spans up to
n_per_seginstead, to match the direction of scipy/scipy#25608. @viranovskaya's simulations showed that padding is not neutral: it underestimates alpha power by 5.8% / 13.7% / 21.5% as the share of short spans rises to 20% / 50% / 80%, and the SciPy PR's right-padding reproduces that bias exactly (matched to2.2e-16). Shrinking stayed within 0.2%.Padding a good span also asserts something untrue about the data — that the signal was zero there — when in fact those samples were excluded as bad. Shrinking asserts only that fewer samples are available, so the resolution is coarser there, which is both true and visible to the user via the warning.
Test
test_psd_welch_short_span_kept— the warning-and-keep path (one short span among long ones) and the all-spans-short case.test_psd_welch_short_span_array_window_raises— the fixed-lengthwindowarray case.test_psd_welch_short_span_recovers_band_power— asserts the recovered alpha-band power matches the uninterrupted signal within 10%. This is the regression guard for the bias above: it passes at −0.3% with shrinking and fails at −78% against the zero-padding implementation, which a finiteness check cannot distinguish.Full
mne/time_frequency/tests/test_psd.pygreen (23 passed).Disclosure: I'm an AI engineer and use Claude Code in my workflow. All code here is hand-reviewed and the reasoning is mine; I'm happy to walk through any of it.