Skip to content

Commit 34809b9

Browse files
committed
ENH: support safe operations on variable-duration Epochs
With trials of differing length the methods divide into three kinds, and guessing which one you are calling is how a wrong answer gets returned quietly. Selecting epochs, selecting channels, dropping and shifting the time origin do not care how long each trial is, so they work as they always did; the per-epoch bounds travel with the epochs they describe. That needs one branch each in GetEpochsMixin._getitem, shift_time and _pick_drop_channels, since those hold the data as one array. _pick_drop_channels replaces the list contents rather than the attribute, which keeps `_data` an ndarray for Raw, Evoked and the rest. Reductions across a shared time axis decline and say what they would need: padding makes the number of contributing epochs a function of time, which no single nave describes. Measuring it is what settled this - on 43 epochs spanning 2.0-3.6 s, average() returns an Evoked that is 44% NaN while nave reports 43 where 3 epochs remain. Per-trial operations with no ragged implementation decline too, rather than running on a padded copy and returning a wrong answer instead of a slow one. plot() is among them for now; the next commit implements it. to_data_frame keeps a warning fallback, since its result is only read.
1 parent 26a1a01 commit 34809b9

4 files changed

Lines changed: 352 additions & 6 deletions

File tree

mne/channels/channels.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,14 @@ def _pick_drop_channels(self, idx, *, verbose=None):
636636
else: # All others (Evoked, Epochs, Raw) have chs axis=-2
637637
axis = -2
638638
if hasattr(self, "_data"): # skip non-preloaded Raw
639-
self._data = self._data.take(idx, axis=axis)
639+
if isinstance(self._data, list):
640+
# variable-duration epochs: one array per epoch, channels are
641+
# regular within each, so the pick applies the same way to all.
642+
# Replacing the contents rather than the attribute keeps `_data`
643+
# an ndarray everywhere else this mixin is used.
644+
self._data[:] = [epoch.take(idx, axis=axis) for epoch in self._data]
645+
else:
646+
self._data = self._data.take(idx, axis=axis)
640647
else:
641648
assert isinstance(self, BaseRaw) and not self.preload
642649

mne/epochs.py

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from collections import Counter
1111
from collections.abc import Callable, Iterable, Iterator
1212
from copy import deepcopy
13-
from functools import partial
13+
from functools import partial, wraps
1414
from inspect import getfullargspec
1515
from pathlib import Path
1616
from typing import TYPE_CHECKING, Literal
@@ -4042,6 +4042,165 @@ def _events_from_annotations(raw, events, event_id, annotations, on_missing):
40424042
return events, event_id, annotations
40434043

40444044

4045+
#: Methods whose result is only looked at. They warn and run on ``as_fixed()``,
4046+
#: which is enough for inspection because the padding is visible to whoever is
4047+
#: looking. Anything numeric is not in this table: see the two below.
4048+
_VARIABLE_FALLBACK = {
4049+
"to_data_frame": "",
4050+
}
4051+
4052+
#: Methods that combine epochs across a shared time axis. Padding them makes the
4053+
#: number of contributing epochs a function of time, which no scalar ``nave`` can
4054+
#: describe, so they ask for a policy instead of inventing one. See :gh:`14206`.
4055+
_VARIABLE_NEEDS_POLICY = {
4056+
"average": "averaging",
4057+
"standard_error": "estimating the standard error",
4058+
"subtract_evoked": "subtracting an evoked response",
4059+
"iter_evoked": "iterating as evoked responses",
4060+
"compute_tfr": "computing a time-frequency representation",
4061+
"compute_psd": "computing a spectrum",
4062+
}
4063+
4064+
#: Methods that are mathematically per-trial and simply have no ragged
4065+
#: implementation yet. Running them on a padded copy would return a wrong answer
4066+
#: rather than a slow one, so they raise until implemented natively.
4067+
_VARIABLE_NOT_IMPLEMENTED = {
4068+
"filter": "filtering",
4069+
"plot": "browsing",
4070+
"apply_function": "applying a function",
4071+
"apply_baseline": "baseline correction",
4072+
"crop": "cropping",
4073+
"decimate": "decimation",
4074+
"resample": "resampling",
4075+
"save": "writing to FIF",
4076+
"export": "exporting",
4077+
# these render an image over one axis and cannot draw the NaN padding that
4078+
# as_fixed() introduces, so the fallback has nothing useful to show
4079+
"plot_image": "plotting as an image",
4080+
"plot_topo_image": "plotting as a topographic image",
4081+
}
4082+
4083+
4084+
def _wrap_variable_fallback(func, name, note):
4085+
"""Warn and fall back to ``as_fixed()`` for variable-duration epochs.
4086+
4087+
Parameters
4088+
----------
4089+
func : callable
4090+
The original method.
4091+
name : str
4092+
Its name, used to look it up on the fixed-duration copy.
4093+
note : str
4094+
Extra sentence appended to the warning, or an empty string.
4095+
4096+
Returns
4097+
-------
4098+
wrapper : callable
4099+
The wrapped method.
4100+
"""
4101+
4102+
@wraps(func)
4103+
def wrapper(self, *args, **kwargs):
4104+
if not getattr(self, "_variable_duration", False):
4105+
return func(self, *args, **kwargs)
4106+
message = (
4107+
f"{name}() needs one time axis, which these variable-duration "
4108+
f"epochs do not have, so it ran on as_fixed(): every epoch padded "
4109+
f"to span {self.tmin.min():g} to {self.tmax.max():g} s."
4110+
)
4111+
if note:
4112+
message += " " + note
4113+
message += " Call as_fixed() yourself to make this explicit."
4114+
warn(message, RuntimeWarning)
4115+
fixed, _ = self.as_fixed()
4116+
return getattr(fixed, name)(*args, **kwargs)
4117+
4118+
return wrapper
4119+
4120+
4121+
def _raise_needs_policy(func, name, what):
4122+
"""Raise for reductions across a time axis the epochs do not share.
4123+
4124+
Parameters
4125+
----------
4126+
func : callable
4127+
The original method.
4128+
name : str
4129+
Its name.
4130+
what : str
4131+
Short description of the operation, used in the message.
4132+
4133+
Returns
4134+
-------
4135+
wrapper : callable
4136+
The wrapped method.
4137+
"""
4138+
4139+
@wraps(func)
4140+
def wrapper(self, *args, **kwargs):
4141+
if not getattr(self, "_variable_duration", False):
4142+
return func(self, *args, **kwargs)
4143+
raise NotImplementedError(
4144+
f"{name}() combines epochs across a shared time axis, and these "
4145+
f"epochs do not share one. {what.capitalize()} them needs an "
4146+
"explicit policy, because the number of contributing epochs varies "
4147+
"across the window and no single nave describes it. Either call "
4148+
"as_fixed(), which pads to the union window and returns that count "
4149+
"alongside the data, or align the epochs first. See "
4150+
"https://github.com/mne-tools/mne-python/issues/14206."
4151+
)
4152+
4153+
return wrapper
4154+
4155+
4156+
def _raise_not_implemented(func, name, what):
4157+
"""Raise for per-trial operations with no ragged implementation yet.
4158+
4159+
Parameters
4160+
----------
4161+
func : callable
4162+
The original method.
4163+
name : str
4164+
Its name.
4165+
what : str
4166+
Short description of the operation, used in the message.
4167+
4168+
Returns
4169+
-------
4170+
wrapper : callable
4171+
The wrapped method.
4172+
"""
4173+
4174+
@wraps(func)
4175+
def wrapper(self, *args, **kwargs):
4176+
if not getattr(self, "_variable_duration", False):
4177+
return func(self, *args, **kwargs)
4178+
raise NotImplementedError(
4179+
f"{name}() is not implemented for variable-duration epochs. "
4180+
f"{what.capitalize()} is per-trial and could work here, but running "
4181+
"it on a padded copy would change the result rather than just slow "
4182+
"it down, so it raises until implemented. See "
4183+
"https://github.com/mne-tools/mne-python/issues/14206."
4184+
)
4185+
4186+
return wrapper
4187+
4188+
4189+
for _name, _note in _VARIABLE_FALLBACK.items():
4190+
_orig = getattr(BaseEpochs, _name, None)
4191+
if _orig is not None:
4192+
setattr(BaseEpochs, _name, _wrap_variable_fallback(_orig, _name, _note))
4193+
for _name, _what in _VARIABLE_NEEDS_POLICY.items():
4194+
_orig = getattr(BaseEpochs, _name, None)
4195+
if _orig is not None:
4196+
setattr(BaseEpochs, _name, _raise_needs_policy(_orig, _name, _what))
4197+
for _name, _what in _VARIABLE_NOT_IMPLEMENTED.items():
4198+
_orig = getattr(BaseEpochs, _name, None)
4199+
if _orig is not None:
4200+
setattr(BaseEpochs, _name, _raise_not_implemented(_orig, _name, _what))
4201+
del _name, _note, _what, _orig
4202+
4203+
40454204
@fill_doc
40464205
class Epochs(BaseEpochs):
40474206
"""Epochs extracted from a Raw instance.

mne/tests/test_epochs_variable_duration.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
from numpy.testing import assert_allclose, assert_array_equal
1010

1111
from mne import EpochsArray, create_info
12+
from mne.epochs import (
13+
_VARIABLE_FALLBACK,
14+
_VARIABLE_NEEDS_POLICY,
15+
_VARIABLE_NOT_IMPLEMENTED,
16+
)
1217

1318
SFREQ = 100.0
1419
CH_NAMES = ["a", "b", "c"]
@@ -204,9 +209,91 @@ def test_as_fixed_on_fixed_epochs_is_a_copy():
204209

205210

206211
# -- dispatch --------------------------------------------------------------
212+
@pytest.mark.parametrize("meth", sorted(_VARIABLE_NEEDS_POLICY))
213+
def test_reductions_ask_for_a_policy(variable, meth):
214+
"""Test that combining epochs across a time axis they lack is refused.
215+
216+
Padding first and reducing afterwards is not a slower answer, it is a
217+
different one: one short epoch turns a whole time point into NaN, and the
218+
scalar ``nave`` keeps reporting the full count.
219+
"""
220+
with pytest.raises(NotImplementedError, match="explicit policy"):
221+
result = getattr(variable, meth)()
222+
list(result) # iter_evoked is a generator
223+
224+
225+
def test_policy_message_names_the_varying_count(variable):
226+
"""Test that the refusal explains itself rather than just declining."""
227+
with pytest.raises(NotImplementedError) as excinfo:
228+
variable.average()
229+
message = str(excinfo.value)
230+
assert "varies across the window" in message
231+
assert "as_fixed" in message
232+
233+
234+
def test_compute_tfr_does_not_silently_pad(variable):
235+
"""Test that the transform is not quietly given padded data.
236+
237+
Padding before a time-frequency transform is the opposite of the order this
238+
work argues for, which is to transform at native duration and warp the
239+
result. Doing it silently inside ``compute_tfr`` would ship the thing being
240+
argued against.
241+
"""
242+
with pytest.raises(NotImplementedError, match="explicit policy"):
243+
variable.compute_tfr("morlet", freqs=np.arange(10.0, 20.0, 2.0), n_cycles=2)
244+
245+
246+
@pytest.mark.parametrize("meth", sorted(_VARIABLE_NOT_IMPLEMENTED))
247+
def test_per_trial_methods_raise_until_implemented(variable, meth):
248+
"""Test that per-trial work refuses rather than running on a padded copy."""
249+
with pytest.raises(NotImplementedError, match="not implemented"):
250+
getattr(variable, meth)()
251+
252+
253+
@pytest.mark.parametrize("meth", sorted(_VARIABLE_FALLBACK))
254+
def test_display_methods_warn_and_fall_back(variable, meth):
255+
"""Test that the remaining inspection method degrades rather than refuses."""
256+
if meth == "to_data_frame":
257+
pytest.importorskip("pandas")
258+
with pytest.warns(RuntimeWarning, match="ran on as_fixed"):
259+
assert getattr(variable, meth)() is not None
207260

208261

209262
# -- operations that stay native -------------------------------------------
263+
def test_pick_keeps_durations(variable):
264+
"""Test that channel selection leaves the time axis alone."""
265+
before = variable.durations.copy()
266+
picked = variable.copy().pick(["a", "c"])
267+
assert picked.ch_names == ["a", "c"]
268+
assert_allclose(picked.durations, before)
269+
for epoch in picked.get_data():
270+
assert epoch.shape[0] == 2
271+
272+
273+
def test_getitem_keeps_per_epoch_bounds(variable):
274+
"""Test that indexing carries the bounds with the epochs."""
275+
subset = variable[[0, 2]]
276+
assert len(subset) == 2
277+
assert_allclose(subset.durations, variable.durations[[0, 2]])
278+
for got, want in zip(subset.get_data(), [variable.get_data()[i] for i in (0, 2)]):
279+
assert_array_equal(got, want)
280+
281+
282+
def test_drop_keeps_per_epoch_bounds(variable):
283+
"""Test that dropping an epoch drops its bounds too."""
284+
kept = variable.copy().drop([1])
285+
assert len(kept) == 3
286+
assert_allclose(kept.durations, variable.durations[[0, 2, 3]])
287+
288+
289+
def test_shift_time_moves_bounds_not_samples(variable):
290+
"""Test that shifting the origin does not resample anything."""
291+
before_lengths = [epoch.shape[1] for epoch in variable.get_data()]
292+
before_durations = variable.durations.copy()
293+
shifted = variable.copy().shift_time(0.1)
294+
assert_allclose(shifted.tmin, variable.tmin + 0.1)
295+
assert_allclose(shifted.durations, before_durations)
296+
assert [epoch.shape[1] for epoch in shifted.get_data()] == before_lengths
210297

211298

212299
# -- the time axis ---------------------------------------------------------
@@ -245,6 +332,30 @@ def test_fixed_epochs_still_have_times():
245332
assert epochs.average().data.shape == (len(CH_NAMES), 71)
246333

247334

335+
def test_nothing_reaches_the_user_as_an_internal_error(variable):
336+
"""Test that no public method leaks a NumPy error about lists."""
337+
import warnings
338+
339+
names = (
340+
sorted(_VARIABLE_FALLBACK)
341+
+ sorted(_VARIABLE_NEEDS_POLICY)
342+
+ sorted(_VARIABLE_NOT_IMPLEMENTED)
343+
)
344+
for name in names:
345+
with warnings.catch_warnings():
346+
warnings.simplefilter("ignore")
347+
try:
348+
getattr(variable.copy(), name)()
349+
except (NotImplementedError, RuntimeError):
350+
pass
351+
except TypeError as exc:
352+
assert "argument" in str(exc), f"{name}: {exc}"
353+
except (AttributeError, IndexError) as exc:
354+
raise AssertionError(f"{name} leaked an internal error: {exc}")
355+
except Exception:
356+
pass
357+
358+
248359
# -- construction from Raw -------------------------------------------------
249360
def _raw(n_seconds=30.0, sfreq=SFREQ):
250361
"""Return a small continuous recording."""
@@ -352,3 +463,41 @@ def test_from_raw_scalar_bounds_still_scalar():
352463
assert not epochs.variable_duration
353464
assert isinstance(epochs.tmin, float)
354465
assert epochs.get_data().shape == (2, len(CH_NAMES), 51)
466+
467+
468+
@pytest.mark.parametrize(
469+
"item", [slice(None, 2), slice(1, None), slice(None, None, 2), slice(None)]
470+
)
471+
def test_getitem_slice_selects_epochs_not_the_slice(variable, item):
472+
"""Test that slicing subsets the epochs rather than wrapping the slice."""
473+
want = np.arange(len(variable))[item]
474+
subset = variable[item]
475+
476+
assert len(subset) == len(want)
477+
# a slice used to survive into the data list as a single nested element
478+
assert all(isinstance(d, np.ndarray) and d.ndim == 2 for d in subset.get_data())
479+
assert_allclose(subset.durations, variable.durations[want])
480+
for got, idx in zip(subset.get_data(), want):
481+
assert_array_equal(got, variable.get_data()[idx])
482+
483+
484+
def test_apply_function_refuses(variable):
485+
"""Test that apply_function refuses instead of indexing a list with a tuple."""
486+
with pytest.raises(NotImplementedError, match="not implemented"):
487+
variable.apply_function(lambda x: x * 2)
488+
489+
490+
def test_pick_does_not_reach_back_into_the_parent(variable):
491+
"""Test that picking replaces one object's epochs and no other's."""
492+
before = [epoch.shape for epoch in variable.get_data()]
493+
494+
# _pick_drop_channels replaces the list contents in place, so anything
495+
# sharing that list would be picked too
496+
variable.copy().pick(["a", "c"])
497+
assert [epoch.shape for epoch in variable.get_data()] == before
498+
499+
subset = variable[:2]
500+
assert subset._data is not variable._data
501+
subset.pick(["a"])
502+
assert [epoch.shape for epoch in variable.get_data()] == before
503+
assert all(epoch.shape[0] == 1 for epoch in subset.get_data())

0 commit comments

Comments
 (0)