Skip to content

Commit 0cffb89

Browse files
committed
ENH: crop variable-duration epochs
Cropping asks for a window in seconds, and that question has an answer for each trial on its own: keep the samples inside it. No epoch has to be padded, stretched or compared with any other, so crop leaves the not-implemented table. The requested window is applied to every epoch independently and clamped to that epoch's own bounds where it reaches past them, which is what the fixed path does against its single interval. Selections for every epoch are computed before anything is written, so a window that misses one epoch fails and leaves the object as it was rather than dropping it. That failure comes from _time_mask seeing an inverted interval once tmax has been clamped back, which is the same route the fixed path takes. Clamping is reported once per bound rather than once per epoch, and only when it happened. A clamped tmax keeps that epoch's last sample even when include_tmax is False, matching the fixed path. Bounds are taken from the samples that survived, never from the requested float, so len(get_times(i)) continues to describe the block. Cropping can also remove the variation: when every epoch ends up on the same axis the blocks are stacked and the object becomes an ordinary Epochs again, which is checked by sample index and length rather than by comparing floats. The reductions then return on their own, since the wrappers ask about _variable_duration when they are called. ExtendedTimeMixin is untouched. It is shared with Raw, Evoked and TFR, and this behaviour belongs to Epochs.
1 parent bed56d5 commit 0cffb89

2 files changed

Lines changed: 290 additions & 1 deletion

File tree

mne/epochs.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
_prepare_read_metadata,
9898
_prepare_write_metadata,
9999
_scale_dataframe_data,
100+
_time_mask,
100101
_validate_type,
101102
check_fname,
102103
check_random_state,
@@ -2729,6 +2730,10 @@ def crop(
27292730
# XXX this could be made to work on non-preloaded data...
27302731
_check_preload(self, "Modifying data of epochs")
27312732

2733+
if self._variable_duration:
2734+
self._crop_variable(tmin, tmax, include_tmax)
2735+
return self
2736+
27322737
super().crop(tmin=tmin, tmax=tmax, include_tmax=include_tmax)
27332738

27342739
# Adjust rejection period
@@ -2746,6 +2751,115 @@ def crop(
27462751
self.reject_tmax = self.tmax
27472752
return self
27482753

2754+
def _crop_variable(self, tmin, tmax, include_tmax):
2755+
"""Crop each epoch on its own time axis.
2756+
2757+
The requested window is a physical interval in seconds, so it is applied
2758+
to every epoch independently and clamped to the epoch's own bounds when
2759+
it reaches past them. Nothing is padded, interpolated or aligned; an
2760+
epoch that the window misses entirely makes the whole call fail, the
2761+
same way it would for that epoch on its own.
2762+
2763+
Parameters
2764+
----------
2765+
tmin : float | None
2766+
Start of the window, or ``None`` for each epoch's own start.
2767+
tmax : float | None
2768+
End of the window, or ``None`` for each epoch's own end.
2769+
include_tmax : bool
2770+
Whether to keep the sample at ``tmax``.
2771+
"""
2772+
for name in ("reject_tmin", "reject_tmax"):
2773+
if getattr(self, name, None) is not None:
2774+
raise NotImplementedError(
2775+
f"{name} is not implemented for variable-duration epochs, "
2776+
"because the window is not guaranteed to exist in every "
2777+
"epoch."
2778+
)
2779+
2780+
sfreq = float(self.info["sfreq"])
2781+
# First pass: work out every selection while changing nothing, so that a
2782+
# window that misses one epoch leaves the object as it was.
2783+
masks = list()
2784+
clamped_tmin = clamped_tmax = False
2785+
for ii in range(len(self.events)):
2786+
times = self.get_times(ii)
2787+
this_tmin, this_tmax = tmin, tmax
2788+
this_include_tmax = include_tmax
2789+
if this_tmin is None:
2790+
this_tmin = times[0]
2791+
elif this_tmin < times[0]:
2792+
clamped_tmin = True
2793+
this_tmin = times[0]
2794+
if this_tmax is None:
2795+
this_tmax = times[-1]
2796+
elif this_tmax > times[-1]:
2797+
clamped_tmax = True
2798+
this_tmax = times[-1]
2799+
# matches the fixed path: a clamped end keeps its last sample
2800+
this_include_tmax = True
2801+
# _time_mask raises when the window is inverted, which is what an
2802+
# entirely out-of-range request collapses to once tmax is clamped
2803+
mask = _time_mask(
2804+
times,
2805+
this_tmin,
2806+
this_tmax,
2807+
sfreq=sfreq,
2808+
include_tmax=this_include_tmax,
2809+
)
2810+
if not mask.any():
2811+
raise ValueError(
2812+
f"tmin ({tmin}) and tmax ({tmax}) would leave epoch {ii} "
2813+
f"with no samples; it spans {times[0]:g} to {times[-1]:g} s."
2814+
)
2815+
masks.append(mask)
2816+
2817+
# One warning per bound however many epochs needed clamping
2818+
if clamped_tmin:
2819+
warn(
2820+
"tmin is not in time interval for every epoch. tmin is set to "
2821+
"each of those epochs' own first sample."
2822+
)
2823+
if clamped_tmax:
2824+
warn(
2825+
"tmax is not in time interval for every epoch. tmax is set to "
2826+
"each of those epochs' own last sample."
2827+
)
2828+
2829+
# Second pass: apply
2830+
ragged = self._data
2831+
assert ragged is not None # variable-duration epochs are always preloaded
2832+
starts = np.empty(len(masks))
2833+
stops = np.empty(len(masks))
2834+
for ii, mask in enumerate(masks):
2835+
kept = self.get_times(ii)[mask]
2836+
ragged[ii] = ragged[ii][..., mask]
2837+
starts[ii], stops[ii] = kept[0], kept[-1]
2838+
self._tmin_per_epoch = starts
2839+
self._tmax_per_epoch = stops
2840+
2841+
# Cropping can remove the variation entirely. Compare the axes by their
2842+
# sample index and length rather than by float equality: every epoch is
2843+
# regularly sampled at one sfreq, so that pair identifies an axis
2844+
# exactly and does not depend on how the bound was rounded.
2845+
first_idx = np.round(starts * sfreq).astype(int)
2846+
lengths = np.array([epoch.shape[-1] for epoch in ragged])
2847+
if (
2848+
len(masks)
2849+
and (first_idx == first_idx[0]).all()
2850+
and (lengths == lengths[0]).all()
2851+
):
2852+
self._data = np.stack(list(ragged))
2853+
self._variable_duration = False
2854+
self._tmin_per_epoch = None # ty: ignore[invalid-assignment]
2855+
self._tmax_per_epoch = None # ty: ignore[invalid-assignment]
2856+
start_idx, stop_idx = first_idx[0], first_idx[0] + lengths[0] - 1
2857+
else:
2858+
start_idx = int(round(starts.min() * sfreq))
2859+
stop_idx = int(round(stops.max() * sfreq))
2860+
self._raw_times = np.arange(start_idx, stop_idx + 1) / sfreq
2861+
self._set_times(self._raw_times)
2862+
27492863
def copy(self) -> Self:
27502864
"""Return copy of Epochs instance.
27512865
@@ -4068,7 +4182,6 @@ def _events_from_annotations(raw, events, event_id, annotations, on_missing):
40684182
"filter": "filtering",
40694183
"apply_function": "applying a function",
40704184
"apply_baseline": "baseline correction",
4071-
"crop": "cropping",
40724185
"decimate": "decimation",
40734186
"resample": "resampling",
40744187
"save": "writing to FIF",

mne/tests/test_epochs_variable_duration.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
# License: BSD-3-Clause
55
# Copyright the MNE-Python contributors.
66

7+
import warnings
8+
79
import numpy as np
810
import pytest
911
from numpy.testing import assert_allclose, assert_array_equal
@@ -508,3 +510,177 @@ def test_pick_does_not_reach_back_into_the_parent(variable):
508510
subset.pick(["a"])
509511
assert [epoch.shape for epoch in variable.get_data()] == before
510512
assert all(epoch.shape[0] == 1 for epoch in subset.get_data())
513+
514+
515+
# -- crop -------------------------------------------------------------------
516+
def _crop_oracle(epochs, idx, **kwargs):
517+
"""Crop epoch ``idx`` as an ordinary one-epoch Epochs, for comparison."""
518+
data = epochs.get_data()[idx]
519+
tmin = np.atleast_1d(epochs.tmin)[idx]
520+
one = EpochsArray(
521+
data[None],
522+
create_info(list(epochs.ch_names), SFREQ, "eeg"),
523+
tmin=float(tmin),
524+
baseline=None,
525+
verbose=False,
526+
)
527+
return one.crop(**kwargs)
528+
529+
530+
def _assert_crop_matches_mne(epochs, **kwargs):
531+
"""Assert every cropped epoch equals ordinary MNE cropping it alone."""
532+
wanted = [_crop_oracle(epochs, ii, **kwargs) for ii in range(len(epochs))]
533+
got = epochs.copy().crop(**kwargs)
534+
data = got.get_data()
535+
for ii, one in enumerate(wanted):
536+
assert_allclose(data[ii], one.get_data()[0])
537+
times = got.get_times(ii) if got.variable_duration else got.times
538+
assert_allclose(times, one.times)
539+
return got
540+
541+
542+
@pytest.mark.parametrize(
543+
"kwargs",
544+
[
545+
dict(tmin=0.0, tmax=0.4), # both bounds
546+
dict(tmin=0.1), # only tmin
547+
dict(tmax=0.45), # only tmax
548+
dict(tmin=-0.05, tmax=0.45, include_tmax=True),
549+
dict(tmin=-0.05, tmax=0.45, include_tmax=False),
550+
],
551+
)
552+
def test_crop_matches_mne_per_epoch(variable, kwargs):
553+
"""Test that cropping equals ordinary MNE applied to each epoch alone."""
554+
_assert_crop_matches_mne(variable, **kwargs)
555+
556+
557+
def test_crop_matches_mne_with_unequal_tmin(kwargs=None):
558+
"""Test parity when both bounds differ between epochs."""
559+
epochs = _make([-0.2, -0.35, 0.0, -0.1], [0.5, 0.9, 0.7, 0.6])
560+
_assert_crop_matches_mne(epochs, tmin=0.05, tmax=0.4)
561+
_assert_crop_matches_mne(epochs, tmax=0.5)
562+
563+
564+
def test_crop_keeps_the_object_ragged(variable):
565+
"""Test that unequal durations survive a crop that does not equalise them."""
566+
cropped = variable.copy().crop(tmin=0.0)
567+
assert cropped.variable_duration
568+
assert len(np.unique(cropped.durations)) > 1
569+
assert isinstance(cropped._data, list)
570+
571+
572+
def test_crop_clamps_each_epoch_and_warns_once(variable):
573+
"""Test that a bound past some epochs clamps per epoch, warning once."""
574+
before = variable.durations.copy()
575+
with pytest.warns(RuntimeWarning, match="tmax is not in time interval") as rec:
576+
cropped = variable.copy().crop(tmax=99.0)
577+
assert len(rec) == 1 # not one per epoch
578+
# each epoch kept everything it had
579+
assert_allclose(cropped.durations, before)
580+
assert cropped.variable_duration
581+
582+
with pytest.warns(RuntimeWarning, match="tmin is not in time interval") as rec:
583+
cropped = variable.copy().crop(tmin=-99.0)
584+
assert len(rec) == 1
585+
assert_allclose(cropped.durations, before)
586+
587+
588+
def test_crop_does_not_warn_when_nothing_is_clamped(variable):
589+
"""Test that a window inside every epoch is silent."""
590+
with warnings.catch_warnings():
591+
warnings.simplefilter("error")
592+
variable.copy().crop(tmin=0.0, tmax=0.4)
593+
594+
595+
def test_crop_clamped_tmax_keeps_the_last_sample(variable):
596+
"""Test that clamping tmax includes that epoch's final sample."""
597+
lengths = [epoch.shape[-1] for epoch in variable.get_data()]
598+
with pytest.warns(RuntimeWarning, match="tmax is not in time interval"):
599+
# include_tmax=False must not drop the endpoint that clamping produced
600+
cropped = variable.copy().crop(tmax=99.0, include_tmax=False)
601+
assert [epoch.shape[-1] for epoch in cropped.get_data()] == lengths
602+
603+
604+
def test_crop_outside_every_sample_fails_cleanly(variable):
605+
"""Test that a window missing an epoch refuses and changes nothing."""
606+
before_data = [epoch.copy() for epoch in variable.get_data()]
607+
before_tmin = np.array(variable.tmin)
608+
before_tmax = np.array(variable.tmax)
609+
with pytest.raises(ValueError, match="must be less than or equal to"):
610+
variable.crop(tmin=5.0)
611+
# the failure left the object exactly as it was
612+
for got, want in zip(variable.get_data(), before_data):
613+
assert_array_equal(got, want)
614+
assert_array_equal(np.array(variable.tmin), before_tmin)
615+
assert_array_equal(np.array(variable.tmax), before_tmax)
616+
assert variable.variable_duration
617+
618+
619+
def test_crop_bounds_come_from_retained_samples(variable):
620+
"""Test that the stored bounds are sample positions, not the request."""
621+
# only tmin, so the differing ends keep the object ragged
622+
cropped = variable.copy().crop(tmin=0.013)
623+
assert cropped.variable_duration
624+
# the request fell between samples and was snapped to one
625+
assert not np.isclose(np.atleast_1d(cropped.tmin)[0], 0.013)
626+
for ii in range(len(cropped)):
627+
times = cropped.get_times(ii)
628+
assert times[0] == pytest.approx(np.atleast_1d(cropped.tmin)[ii])
629+
assert times[-1] == pytest.approx(np.atleast_1d(cropped.tmax)[ii])
630+
# and the axis still describes the block exactly
631+
assert len(times) == cropped.get_data()[ii].shape[-1]
632+
assert_allclose(
633+
cropped.durations, np.atleast_1d(cropped.tmax) - np.atleast_1d(cropped.tmin)
634+
)
635+
636+
637+
def test_crop_that_equalises_axes_returns_fixed_epochs(variable):
638+
"""Test that removing the variation gives an ordinary Epochs back."""
639+
cropped = variable.copy().crop(tmax=0.5)
640+
assert not cropped.variable_duration
641+
assert isinstance(cropped._data, np.ndarray)
642+
assert cropped._tmin_per_epoch is None
643+
assert cropped._tmax_per_epoch is None
644+
assert isinstance(cropped.tmin, float)
645+
assert isinstance(cropped.tmax, float)
646+
# times is answerable again, and agrees with the data
647+
assert len(cropped.times) == cropped.get_data().shape[-1]
648+
# and the reductions come back on their own, without touching the tables
649+
evoked = cropped.average()
650+
assert evoked.nave == len(cropped)
651+
assert_allclose(evoked.times, cropped.times)
652+
653+
654+
def test_crop_keeps_epoch_bookkeeping(variable):
655+
"""Test that events, metadata and drop_log travel unchanged."""
656+
import pandas as pd
657+
658+
pytest.importorskip("pandas")
659+
variable.metadata = pd.DataFrame(dict(kind=list("abcd")))
660+
events = variable.events.copy()
661+
drop_log = variable.drop_log
662+
selection = variable.selection.copy()
663+
664+
cropped = variable.copy().crop(tmin=0.0, tmax=0.4)
665+
assert_array_equal(cropped.events, events)
666+
assert cropped.drop_log == drop_log
667+
assert_array_equal(cropped.selection, selection)
668+
assert list(cropped.metadata["kind"]) == list("abcd")
669+
670+
671+
def test_crop_does_not_fall_back_to_as_fixed(variable):
672+
"""Test that cropping is native, never a padded copy."""
673+
674+
def _boom(*args, **kwargs):
675+
raise AssertionError("crop() fell back to as_fixed()")
676+
677+
variable.as_fixed = _boom
678+
cropped = variable.crop(tmin=0.0)
679+
assert not np.isnan(np.concatenate(cropped.get_data(), axis=-1)).any()
680+
681+
682+
def test_crop_refuses_when_rejection_windows_are_set(variable):
683+
"""Test that a stray rejection window is refused, not compared to an array."""
684+
variable.reject_tmin = 0.0 # the constructor forbids this; be defensive
685+
with pytest.raises(NotImplementedError, match="reject_tmin is not implemented"):
686+
variable.crop(tmin=0.0)

0 commit comments

Comments
 (0)