|
| 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