Skip to content

Commit bed56d5

Browse files
committed
DOC: demonstrate variable-duration epochs
Builds epochs straight from the Sleep Physionet hypnogram durations, so no new dataset is needed and tools/circleci_download.sh already prefetches it. Bouts over five minutes are set aside to keep the padded array small, leaving 130 epochs from 30 to 300 s. Walks through what the object holds, which operations are unaffected by ragged trials, which ones refuse and why, browsing them at their own lengths, and what as_fixed() reports: 130 epochs at t=0 falling to 1 by 300 s, which is the reason average() cannot return an ordinary Evoked. The browsing section picks bouts by taking the first occurrence of each distinct value in `durations`, so five different lengths are guaranteed rather than hoped for; here that is 120, 30, 150, 60 and 90 s. It runs under use_browser_backend("matplotlib"), as several other tutorials already do, because the doc build exports MNE_BROWSER_BACKEND=qt and qt is tried first, and the PyQtGraph backend does not handle ragged epochs yet. The closing section points at the sleep-staging tutorial, where fixed 30 s windows are the right representation, so the two are not read as alternatives.
1 parent 5979f12 commit bed56d5

2 files changed

Lines changed: 305 additions & 1 deletion

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Allow :class:`mne.Epochs` to hold trials of different duration by passing ``tmin`` and/or ``tmax`` as arrays with one entry per event, with :meth:`mne.Epochs.as_fixed` to obtain a fixed-duration copy spanning their union together with the number of epochs contributing at each time point, by `Sina Esmaeili`_.
1+
Allow :class:`mne.Epochs` to hold trials of different duration by passing ``tmin`` and/or ``tmax`` as arrays with one entry per event, with :meth:`mne.Epochs.as_fixed` to obtain a fixed-duration copy spanning their union together with the number of epochs contributing at each time point, and with :meth:`mne.Epochs.plot` browsing them at their own lengths rather than padding, demonstrated in :ref:`tut-variable-duration-epochs`, by `Sina Esmaeili`_.
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
"""
2+
.. _tut-variable-duration-epochs:
3+
4+
Epochs whose trials have different durations
5+
============================================
6+
7+
Most epoching starts from an event and takes the same window around every one of
8+
them, which gives a rectangular ``(n_epochs, n_channels, n_times)`` array and one
9+
time axis shared by every trial. Some experiments do not fit that shape. A gait
10+
cycle, a spoken word, a reaching movement and a sleep stage all last as long as
11+
they last, and the duration is often the thing being studied.
12+
13+
The usual way to handle this is to pick a fixed window and accept the
14+
consequences: a window long enough for the longest trial pads the short ones, and
15+
a window short enough for the shortest one truncates the rest. This tutorial
16+
shows the other option, keeping each trial at the length it actually had, and
17+
what the resulting object can and cannot do.
18+
19+
We use the Sleep Physionet data, where the hypnogram annotations mark sleep stage
20+
bouts and each bout carries its own duration.
21+
"""
22+
23+
# Authors: The MNE-Python contributors.
24+
# License: BSD-3-Clause
25+
# Copyright the MNE-Python contributors.
26+
27+
# %%
28+
29+
import matplotlib.pyplot as plt
30+
import numpy as np
31+
32+
import mne
33+
from mne.datasets.sleep_physionet.age import fetch_data
34+
35+
psg_file, hypnogram_file = fetch_data(subjects=[0], recording=[1])[0]
36+
37+
raw = mne.io.read_raw_edf(
38+
psg_file,
39+
stim_channel=False,
40+
preload=True,
41+
verbose="error", # ignore issues with stored filter settings
42+
)
43+
raw.pick(["EEG Fpz-Cz", "EEG Pz-Oz"])
44+
45+
annotations = mne.read_annotations(hypnogram_file)
46+
raw.set_annotations(annotations, emit_warning=False)
47+
48+
# %%
49+
# The annotations already carry durations
50+
# ---------------------------------------
51+
#
52+
# Each hypnogram entry marks one bout of a sleep stage, and
53+
# :class:`~mne.Annotations` stores its ``duration`` alongside its ``onset``. That
54+
# duration is not a constant.
55+
56+
stages = {
57+
"Sleep stage 1": 1,
58+
"Sleep stage 2": 2,
59+
"Sleep stage 3": 3,
60+
"Sleep stage 4": 3, # stages 3 and 4 are conventionally merged
61+
"Sleep stage R": 4,
62+
}
63+
event_id = {"N1": 1, "N2": 2, "N3/4": 3, "REM": 4}
64+
65+
onset = annotations.onset
66+
duration = annotations.duration
67+
description = np.array(annotations.description)
68+
69+
keep = np.array([desc in stages for desc in description])
70+
# a five minute cap keeps the padded array in the last section small; nothing
71+
# about the container requires it
72+
keep &= duration <= 300.0
73+
74+
onset, duration = onset[keep], duration[keep]
75+
description = description[keep]
76+
77+
print(f"{len(duration)} bouts, {duration.min():.0f} to {duration.max():.0f} s")
78+
print(f"median {np.median(duration):.0f} s")
79+
80+
# %%
81+
# Building epochs that keep those durations
82+
# -----------------------------------------
83+
#
84+
# ``tmin`` and ``tmax`` accept one value per event as well as a single number.
85+
# Here every bout starts at its own onset, so ``tmin`` is zero throughout and
86+
# ``tmax`` is the bout's own length. The last sample is included, which is why
87+
# ``tmax`` is one sample short of the full duration.
88+
89+
sfreq = raw.info["sfreq"]
90+
events = np.column_stack(
91+
[
92+
np.round((onset - raw.first_time) * sfreq).astype(int),
93+
np.zeros(len(onset), int),
94+
np.array([stages[desc] for desc in description]),
95+
]
96+
)
97+
98+
epochs = mne.Epochs(
99+
raw,
100+
events,
101+
event_id,
102+
tmin=np.zeros(len(events)),
103+
tmax=duration - 1.0 / sfreq,
104+
baseline=None,
105+
preload=True,
106+
)
107+
print(epochs)
108+
109+
# %%
110+
# The object reports that its trials are not all the same length, and the
111+
# durations it holds are the ones the annotations described.
112+
113+
print(f"variable_duration: {epochs.variable_duration}")
114+
print(f"durations: {epochs.durations.min():.0f} to {epochs.durations.max():.0f} s")
115+
116+
# %%
117+
# Because bounds that carry no variation collapse back to a single value, this
118+
# only changes behaviour when the durations really do differ. Passing equal
119+
# bounds gives an ordinary fixed-duration ``Epochs``.
120+
121+
n2_events = events[events[:, 2] == event_id["N2"]][:5]
122+
fixed_bounds = mne.Epochs(
123+
raw,
124+
n2_events,
125+
{"N2": event_id["N2"]},
126+
tmin=np.zeros(len(n2_events)),
127+
tmax=np.full(len(n2_events), 29.99),
128+
baseline=None,
129+
preload=True,
130+
verbose=False,
131+
)
132+
print(f"equal bounds -> variable_duration: {fixed_bounds.variable_duration}")
133+
134+
# %%
135+
# Getting the data out
136+
# --------------------
137+
#
138+
# There is no rectangular array to return, so :meth:`~mne.Epochs.get_data` gives
139+
# a list with one ``(n_channels, n_times)`` array per epoch. Nothing is padded
140+
# and nothing is cut: each array holds exactly the samples that the bout covered
141+
# in the continuous recording.
142+
143+
data = epochs.get_data()
144+
print(f"{len(data)} arrays, first four shapes {[d.shape for d in data[:4]]}")
145+
146+
lengths = np.array([d.shape[-1] for d in data])
147+
print(f"total samples held: {lengths.sum()}")
148+
print(f"a rectangular array would hold: {lengths.max() * len(lengths)}")
149+
150+
# %%
151+
# For the same reason there is no single ``times`` attribute. Each epoch has its
152+
# own time axis, which :meth:`~mne.Epochs.get_times` returns.
153+
154+
for idx in (0, 1):
155+
t = epochs.get_times(idx)
156+
print(f"epoch {idx}: {len(t)} samples, {t[0]:.2f} to {t[-1]:.2f} s")
157+
158+
# %%
159+
# Asking for ``epochs.times`` raises rather than inventing an axis. Returning the
160+
# longest epoch's axis would make ``len(epochs.times)`` disagree with the data
161+
# for every other epoch while looking perfectly normal.
162+
163+
try:
164+
epochs.times
165+
except RuntimeError as err:
166+
print(f"RuntimeError: {err}")
167+
168+
# %%
169+
# Operations that do not touch the time axis
170+
# ------------------------------------------
171+
#
172+
# Selecting epochs, selecting channels and dropping epochs all work as usual,
173+
# because none of them care how long each trial is. The per-epoch bounds travel
174+
# with the epochs they belong to.
175+
176+
n2 = epochs["N2"]
177+
print(
178+
f"epochs['N2']: {len(n2)} epochs, "
179+
f"{n2.durations.min():.0f} to {n2.durations.max():.0f} s"
180+
)
181+
182+
first_ten = epochs[:10]
183+
print(f"epochs[:10]: durations {first_ten.durations.round(0)}")
184+
185+
one_channel = epochs.copy().pick(["EEG Pz-Oz"])
186+
print(
187+
f"after pick: {one_channel.ch_names}, durations unchanged: "
188+
f"{np.array_equal(one_channel.durations, epochs.durations)}"
189+
)
190+
191+
# %%
192+
# Browsing them
193+
# -------------
194+
#
195+
# :meth:`~mne.Epochs.plot` shows each bout at the length it really has. The
196+
# browser lays the variable-length blocks end to end and rules a line between
197+
# them, so the vertical boundaries are unevenly spaced: a 30 second bout takes a
198+
# fifth of the width of a 150 second one. Nothing is padded or truncated to make
199+
# the picture rectangular, and :meth:`~mne.Epochs.as_fixed` is not involved.
200+
#
201+
# Pick a handful of bouts with genuinely different lengths, taking the first
202+
# occurrence of each distinct duration rather than trusting the first few epochs
203+
# to differ.
204+
205+
_, first_of_each = np.unique(epochs.durations, return_index=True)
206+
browse_idx = np.sort(first_of_each[:5])
207+
browse_epochs = epochs[browse_idx]
208+
print(f"browsing durations: {browse_epochs.durations.round(0)} s")
209+
210+
# the browser's time axis is the real samples, laid end to end
211+
n_browser_samples = sum(
212+
len(browse_epochs.get_times(ii)) for ii in range(len(browse_epochs))
213+
)
214+
print(f"{n_browser_samples} samples in total, none of them padding")
215+
216+
# %%
217+
# Browsing variable-duration epochs currently needs the Matplotlib backend; the
218+
# PyQtGraph one does not handle ragged epochs yet.
219+
220+
with mne.viz.use_browser_backend("matplotlib"):
221+
browse_epochs.plot(n_epochs=len(browse_epochs), picks="eeg")
222+
223+
# %%
224+
# Operations that need one time axis
225+
# ----------------------------------
226+
#
227+
# Averaging is the clearest case. :class:`~mne.Evoked` holds one array and one
228+
# ``nave``, and there is no honest way to fill either when the trials stop at
229+
# different times. Rather than pad quietly, the reduction refuses and says what
230+
# it would need.
231+
232+
try:
233+
epochs.average()
234+
except NotImplementedError as err:
235+
print(f"NotImplementedError: {err}")
236+
237+
# %%
238+
# Making the padding explicit
239+
# ---------------------------
240+
#
241+
# When a rectangular array is genuinely what you want,
242+
# :meth:`~mne.Epochs.as_fixed` produces one. It returns the padded
243+
# :class:`~mne.EpochsArray` together with the number of epochs contributing at
244+
# each sample, so the cost of the padding is visible rather than implied.
245+
246+
padded, n_contributing = epochs.as_fixed()
247+
print(f"padded shape: {padded.get_data().shape}")
248+
print(
249+
f"contributing: {n_contributing.max()} at the start, "
250+
f"{n_contributing.min()} at the end"
251+
)
252+
253+
held = lengths.sum() * len(epochs.ch_names)
254+
allocated = padded.get_data().size
255+
print(f"padding waste: {100 * (1 - held / allocated):.1f}%")
256+
257+
# %%
258+
# That second return value is the point of the method. Plotted against time it
259+
# shows how quickly the epochs stop contributing, which is exactly the
260+
# information an averaged :class:`~mne.Evoked` cannot carry.
261+
262+
fig, ax = plt.subplots(figsize=(8, 4), layout="constrained")
263+
times = padded.times
264+
ax.fill_between(times, n_contributing, step="post", alpha=0.25)
265+
ax.plot(times, n_contributing, drawstyle="steps-post")
266+
267+
half = len(epochs) / 2
268+
crossing = times[np.argmax(n_contributing < half)]
269+
ax.axhline(half, color="0.4", ls=":", lw=1)
270+
ax.axvline(crossing, color="0.4", ls=":", lw=1)
271+
ax.annotate(
272+
f"half the epochs have ended by {crossing:.0f} s",
273+
xy=(crossing, half),
274+
xytext=(crossing + 20, len(epochs) * 0.7),
275+
arrowprops=dict(arrowstyle="->", color="0.4"),
276+
)
277+
278+
ax.set(
279+
xlabel="Time (s)",
280+
ylabel="Epochs contributing",
281+
title="How many sleep-stage bouts are still running",
282+
xlim=(0, times[-1]),
283+
ylim=(0, len(epochs) * 1.05),
284+
)
285+
286+
# %%
287+
# Reading the figure from left to right: every bout contributes at the start,
288+
# and by the end a single long bout is holding up the whole window. An average
289+
# over this padded array would combine all of them at ``t = 0`` and one of them
290+
# at the right-hand edge, while reporting one ``nave`` for the lot. Keeping the
291+
# count alongside the data is what makes that visible.
292+
#
293+
# When fixed windows are the right choice
294+
# ---------------------------------------
295+
#
296+
# None of this argues against fixed-length epochs. Sleep staging is a good
297+
# example of when they are correct: :ref:`tut-sleep-stage-classif` classifies 30
298+
# second windows, so it passes ``chunk_duration=30.`` to
299+
# :func:`mne.events_from_annotations` and deliberately turns each bout into a
300+
# series of equal windows. That is the right representation when the window is
301+
# the unit of analysis.
302+
#
303+
# Variable-duration epochs are for the other case, when the bout itself is the
304+
# unit and its length is part of what is being measured.

0 commit comments

Comments
 (0)