.. currentmodule:: pypillometry Events, intervals and segments ============================== Most experiments send *events* (triggers) to the eye tracker, e.g., when a stimulus appears or a response is given. Analyses then look at the data in *windows* around these events or in the *intervals* between them. :mod:`pypillometry` has a class for each step: .. mermaid:: flowchart LR E["Events
onsets + labels + metadata"] -- "get_epochs(event, window)" --> P["Epochs
onsets + one window"] E -- "get_intervals(start=event1, end=event2)" --> I["Intervals
(start, end) pairs"] P -- "get_segments()" --> S["SegmentedEyeData
channels: segments x time"] S -- "average(by=...), group_segments()" --> G["SegmentedEyeData
channels: averages x time"] P -- "stat_per_event()" --> N["one value
per event"] P -- "to_intervals()" --> I Events ------ :attr:`~EyeData.events` holds the events of a dataset as an :class:`Events` object, a list of onsets and labels: .. code-block:: python events = d.events # onsets in ms events = d.events.to_units("sec") # onsets in seconds events.filter("stim") # labels containing "stim" events.filter("stim", exact=True) # labels equal to "stim" events.filter(lambda lab: lab.endswith("_1")) events.filter((60, 120)) # onsets between 60 and 120 s events[:10] # the first ten events :class:`Events` cannot be changed in place: :meth:`~Events.filter`, indexing and the other methods return new :class:`Events`. You can store changed events in the dataset with :meth:`~EyeData.set_events`, e.g., to remove test trials or to rename labels. Operations that change the time of the data (:meth:`~EyeData.crop`, :meth:`~EyeData.remove_intervals`, :meth:`~EyeData.reset_time`) move the events with it. :meth:`~EyeData.add_events` adds events to the existing ones, and :meth:`Events.onset` returns the onset of the one event with a given label (it raises an error unless exactly one event has it): .. code-block:: python d.add_events(pp.Events([1500, 2500], ["cue", "cue"], units="ms")) t = d.events.onset("Start Block 2", units="sec") Tutorial: :doc:`/notebooks/events`. .. _event-metadata: Event metadata ~~~~~~~~~~~~~~ Each event can have *metadata*, e.g., the trial, the condition, the choice or the reaction time. They are stored in ``events.meta``, a read-only mapping with one numpy array per column (one value per event). Missing values are NaN in numeric columns and ``""`` in text columns. There are two common sources: - **Trigger labels** that code information, e.g., ``S_12_3_5`` for the stimulus of trial 12 with the symbols 3 and 5. :meth:`~Events.parse_labels` decomposes them with a regular expression with named groups (or a function that returns a dict): .. code-block:: python ev = d.events ev = ev.parse_labels(r"^[FSCR]_(?P\d+)") ev = ev.parse_labels(r"^S_\d+_(?P\d+)_(?P\d+)") ev = ev.parse_labels(r"^R_\d+_(?P\d+)") - **A behaviour log** with one row per trial. If the eye tracker's events already identify the trials (e.g., by a trial number from the labels), :meth:`~Events.merge_table` adds the columns of the log to all events of each trial, matched by the values in the columns ``on``: .. code-block:: python log = pd.read_csv("behaviour.csv") # columns trial, cond, rt, ... ev = ev.merge_table(log, on="trial", columns=["cond", "rt"]) If the events are only in the log, :meth:`Events.from_table` creates them and takes the columns with metadata as ``meta`` (see :ref:`below `); each event of a row gets the values of that row. Information that is coded at one event (e.g., the reward at the feedback) is copied to the other events of the same trial with :meth:`~Events.fill_meta`, and :meth:`~Events.add_latency` adds the time between two events (e.g., the reaction time). :meth:`~Events.filter` selects events by their metadata: .. code-block:: python ev = ev.fill_meta("reward", by="trial") ev = ev.add_latency("S_", "C_", "rt", by="trial") ev.filter("S_", reward=20) # equal to a value ev.filter("S_", trial=[1, 2, 3]) # one of the values ev.filter("S_", rt=lambda rt: rt < 1000) # function of the column ev.filter(ev.meta["left"] < ev.meta["right"]) # boolean array :meth:`~EyeData.set_events` stores the metadata in the dataset (e.g., ``d = d.set_events(ev)``; they are saved with it), and :meth:`~EyeData.get_epochs` and :meth:`~EyeData.get_intervals` pass the metadata of the selected events on (``epochs.meta``, ``intervals.event_meta``, also in ``as_pandas()``). :meth:`~Events.to_table` returns the events with their metadata as a dict of columns, e.g., for ``pandas.DataFrame()``. Tutorial: :doc:`/notebooks/event_metadata`. .. _behaviour-log-events: Events from a behaviour log ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Often, not all events are sent to the eye tracker. The times of stimuli and responses are then in a behaviour log of the experiment (e.g., from Psychtoolbox, PsychoPy or Presentation), usually a table with one row per trial, with times in seconds on the clock of the experiment computer: .. list-table:: ``behav`` (a :class:`pandas.DataFrame`) :header-rows: 1 :widths: 15 15 20 20 15 * - block - trial - trialstart - stim_on - rt * - 1 - 1 - 12.031 - 12.531 - 0.412 * - 1 - 2 - 15.204 - 15.704 - NaN * - 1 - 3 - 18.377 - 18.877 - 0.538 * - 2 - 1 - 95.880 - 96.380 - 0.611 * - ... - ... - ... - ... - ... :meth:`Events.from_table` turns the columns of such a table into events. The keys of the dict are the labels, the values are column names or arrays of times; missing times (NaN, e.g., no response) give no event: .. code-block:: python ev = pp.Events.from_table(behav, {"stim_on": "stim_on", "response": behav.stim_on + behav.rt}, units="sec") This gives one event per stimulus and per response, sorted by onset (still on the clock of the behaviour log, so they are marked as ``needs_sync``): .. list-table:: ``ev`` :header-rows: 1 :widths: 20 20 * - onset (sec) - label * - 12.531 - stim_on * - 12.943 - response * - 15.704 - stim_on * - 18.877 - stim_on * - 19.415 - response * - 96.380 - stim_on * - ... - ... The behaviour log and the eye tracker have different clocks. They have different zero points and drift apart slowly (a few ms in a typical session), so a single offset is not exact. :func:`sync_clocks` fits a linear map (offset and drift) from moments that are in both logs, e.g., the block starts, paired by order. It returns a :class:`ClockSync` object that maps times or events to the eye-tracker clock: .. code-block:: python starts = behav.groupby("block").trialstart.min() # in sec sync = pp.sync_clocks(starts, d.events.filter("Start Block"), src_units="sec") d.add_events(sync(ev)) Events from :meth:`Events.from_table` are marked as being on another clock (``ev.needs_sync``); :meth:`~EyeData.add_events`, :meth:`~EyeData.set_events` and :meth:`~EyeData.get_epochs` warn if they get such events, so a forgotten ``sync_clocks()`` does not go unnoticed. Mapping them with a :class:`ClockSync` (or :meth:`Events.shift`) removes the mark. If the log is on the eye-tracker clock already (e.g., the times were taken from eye-tracker messages), use ``Events.from_table(..., needs_sync=False)``. Events outside the time range of the data raise an error. Other columns of the log become :ref:`metadata ` of the events with ``meta``, e.g., ``pp.Events.from_table(behav, ..., units="sec", meta=["block", "trial", "rt"])``. They are kept by ``sync`` and :meth:`~EyeData.add_events`. :func:`sync_clocks` logs a summary of the fit. Check the *residuals* (``sync.residuals``, or ``print(sync)`` for a table): with correct pairs, they show only the timing jitter between the two logs (1–2 ms for times taken at the screen flip, up to about one frame otherwise); if a pair is missing in one of the logs, they are much larger and a warning is given (``tol=`` sets the limit, 5 ms by default). A shift of all pairs by the same number of trials does not show in the residuals if the trials are evenly spaced, so check the pairs directly where possible (e.g., trial numbers in the labels). With a single shared moment, only the offset is fitted. Tutorial: :doc:`/notebooks/importdata_behaviour`. Epochs ------ An :class:`Epochs` object holds some events (an :class:`Events` object in ms) and *one* time window around them, e.g., from 200 ms before to 1000 ms after each stimulus. :meth:`~EyeData.get_epochs` selects the events and the window: .. code-block:: python epochs = d.get_epochs("stim", (-200, 1000) * u.ms) epochs.onsets, epochs.window, epochs.labels, epochs.meta["trial"] epochs.events # the events, as Events epochs.filter(cond="a", rt=lambda rt: rt < 800) # select like Events epochs.keep(epochs.meta["cond"] == "a") # a subset of the epochs epochs.set_meta("block", 1) # change the metadata epochs.to_intervals("sec") # the spans as Intervals Selecting and changing the metadata works as for :class:`Events` (:meth:`~Epochs.filter`, :meth:`~Epochs.set_meta`, :meth:`~Epochs.drop_meta`, :meth:`~Epochs.parse_labels`); the results are new Epochs with the same window. Epochs can be used wherever the methods take intervals (e.g., :meth:`~EyeData.mask_intervals`, :meth:`~EyeData.exclude_intervals`, :meth:`~EyeData.stat_per_event`); they stand for their spans ``(onset + window[0], onset + window[1])`` in ms. Windows that extend beyond the recording are not cut off: the methods that read the data use the part within it (in segments, the rest is masked). Epochs and intervals compared: :meth:`~EyeData.get_epochs` gives the same window around each selected event (here, the stimuli "S"), :meth:`~EyeData.get_intervals` the spans between two events (here, from the fixation cross "F" to the response "R" of each trial), which differ in length: .. plot:: :include-source: False from pypillometry import u import matplotlib.patches as patches d = pp.get_example_data("rlmw_002_short") epochs = d.get_epochs("S_", (-200, 1000) * u.ms) intervals = d.get_intervals(start="F_", end="R_") t0, t1 = 4500, 15000 fig, ax = plt.subplots(figsize=(9, 3)) for on, lab in zip(d.events.onsets, d.events.labels): if t0 <= on <= t1: ax.axvline(on / 1000, color="0.75", lw=0.8, zorder=0) ax.text(on / 1000, 2.55, lab.split("_")[0], ha="center", fontsize=9) for (a, b), on in zip(epochs, epochs.onsets): if a < t1: ax.add_patch(patches.Rectangle((a / 1000, 1.15), (b - a) / 1000, 0.7, color="C0", alpha=0.6)) ax.plot([on / 1000] * 2, [1.15, 1.85], color="C0", lw=2) for a, b in intervals.intervals: if a < t1: ax.add_patch(patches.Rectangle((a / 1000, 0.15), (b - a) / 1000, 0.7, color="C1", alpha=0.6)) ax.set_yticks([0.5, 1.5]) ax.set_yticklabels(['get_intervals(start="F_", end="R_")', 'get_epochs("S_", (-200, 1000) ms)']) ax.set_ylim(0, 2.9) ax.set_xlim(t0 / 1000, t1 / 1000) ax.set_xlabel("time (s); events: F, S, C, R of each trial") for s in ("top", "right", "left"): ax.spines[s].set_visible(False) ax.tick_params(axis="y", length=0) fig.tight_layout() Intervals --------- An :class:`Intervals` object is a list of (start, end) pairs, together with their units and, optionally, one label, onset and metadata row per interval (e.g., of the event it belongs to), which are kept when intervals are converted, filtered, padded, combined or merged. Intervals come from several places: .. list-table:: :header-rows: 1 :widths: 45 55 * - Source - Example * - windows around events - ``d.get_epochs("stim", (-200, 1000) * u.ms).to_intervals()`` * - spans between two events - ``d.get_intervals(start="trial_start", end="response")`` * - detected blinks - ``d.get_blinks("left", "pupil")["left_pupil"]`` * - other detection methods - ``d.gaze.mask_offscreen().get_layer_intervals("offscreen")`` * - masked samples - ``d.mask_as_intervals("left", "pupil")["left_pupil"]`` * - your own - ``pp.Intervals([(0, 100), (200, 300)] * u.ms)`` Intervals can be converted to other units (:meth:`~Intervals.to_units`), filtered (:meth:`~Intervals.filter`), padded (:meth:`~Intervals.pad`), merged when they overlap (:meth:`~Intervals.merge`), combined (``+``, :func:`concat_intervals`), subtracted (``-``), summarized (:meth:`~Intervals.stats`, :meth:`~Intervals.as_pandas`) and plotted (:meth:`~Intervals.plot`). :meth:`~Intervals.complement` returns the periods that are not covered (e.g., ``blocks.complement(d)`` gives the time before, between and after the blocks). Between two events ~~~~~~~~~~~~~~~~~~ :meth:`~EyeData.get_intervals` pairs each `start` event with the next `end` event after it. If several `start` events come before the same `end` event (e.g., a missing "End Block"), the last of them is used. Events without a partner are skipped, and a warning lists them. ``""`` and ``""`` stand for the start and the end of the recording: .. code-block:: python blocks = d.get_intervals(start="Start Block", end="End Block", units="ms") breaks = d.get_intervals(start="End Block", end="Start Block", units="ms") before = d.get_intervals(start="", end="Start Block", units="ms") String selectors match labels that contain them; with ``exact=True``, only labels that are equal to them. Sample indices and time ~~~~~~~~~~~~~~~~~~~~~~~ Intervals are either in time units ("ms", "sec", "min", "h") or in sample indices (``units="samples"``; plain numbers in the window of :meth:`~EyeData.get_epochs` and :meth:`~EyeData.get_intervals` are ms, sample indices need ``units="samples"``). Intervals are end-exclusive: ``(start, end)`` covers the samples with ``start <= t < end``. Intervals in sample indices are only valid for the data object they came from: after :meth:`~EyeData.crop`, :meth:`~EyeData.downsample` or for another recording, the same indices point to other samples (blinks stored in the object are adjusted automatically). Therefore, every conversion between sample indices and time takes the data object and uses its time vector: .. code-block:: python blinks = d.get_blinks("left", "pupil", units="samples")["left_pupil"] blinks.to_units("ms", d) # times of these samples trials.to_units("samples", d) # first sample at or after each time mask = trials.to_mask(d) # one entry per sample of d pp.Intervals.from_mask(mask, d, units="ms") Conversions between time units work without the data object, e.g., ``trials.to_units("sec")``. Times are converted to the first sample at or after them, so recordings that do not start at 0 or have gaps are handled correctly. Many methods of the data objects take intervals as input: .. autosummary:: :nosignatures: EyeData.get_segments EyeData.stat_per_event EyeData.mask_intervals EyeData.interpolate_intervals EyeData.plot.plot_intervals Tutorial: :doc:`/notebooks/intervals`. Segments and event-related pupil dilation ----------------------------------------- :meth:`~EyeData.get_segments` cuts the data in each epoch and returns a :class:`SegmentedEyeData` object: one matrix of segments × time points per channel (by default all channels; choose them by name, or with ``eyes=`` and ``variables=``). Averaged over segments, the pupil channel is the *event-related pupil dilation* (ERPD). As for :class:`EyeData`, the methods for the pupil are in ``seg.pupil`` and those for the gaze in ``seg.gaze``: .. code-block:: python epochs = d.get_epochs("stim", (-200, 1000) * u.ms) erpd = d.get_segments(epochs, "left_pupil", name="stimulus") erpd = erpd.pupil.baseline_correct((-200, 0)) # subtract the pre-stimulus mean erpd.pupil.plot() erpd["left_pupil"] # masked array, time points x segments The segments of a channel form a matrix of time points × trials (top, one row per trial, here for the 10 stimuli of the short example dataset); its mean over the trials is the ERPD (bottom, with the standard error): .. plot:: :include-source: False from pypillometry import u d = (pp.get_example_data("rlmw_002_short") | pp.pupil.blinks_detect() | pp.pupil.blinks_interpolate() | pp.pupil.lowpass_filter(5)) seg = d.get_segments(d.get_epochs("S_", (-500, 2500) * u.ms), "left_pupil") seg = seg.pupil.baseline_correct((-200, 0)) x = seg["left_pupil"] fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7, 5.5), sharex=True, gridspec_kw={"height_ratios": [2, 1]}) lim = np.nanpercentile(np.abs(np.ma.filled(x, np.nan)), 98) im = ax1.imshow(np.ma.filled(x, np.nan).T, aspect="auto", cmap="RdBu_r", vmin=-lim, vmax=lim, extent=[seg.tx[0], seg.tx[-1], seg.n_segments, 0], interpolation="nearest") ax1.axvline(0, color="k", lw=0.8, ls="--") ax1.set_ylabel("trial") ax1.set_title('seg["left_pupil"]: time points x trials', fontsize=10) fig.colorbar(im, ax=ax1, label="change from baseline") seg.pupil.plot(ax=ax2) ax2.set_title("mean over trials: the ERPD", fontsize=10) ax2.set_xlabel("time (ms)") fig.colorbar(im, ax=ax2).ax.set_visible(False) # same width as above fig.tight_layout() All channels share the epochs, so trials are selected once for all of them: :meth:`~SegmentedEyeData.filter` selects by label or metadata of the epochs, :meth:`~SegmentedEyeData.keep` with a boolean array (e.g., to reject trials with too much missing data): .. code-block:: python seg = d.get_segments(epochs) # pupil and gaze of both eyes easy = seg.filter(cond="easy") good = seg.keep(seg["left_pupil"].mask.mean(axis=0) < 0.5) The length and the time axis of the segments come from the window: the time points are ``window[0] + k * 1000/fs`` ms (at 500 Hz: -200, -198, ..., 998 ms). Parts of a window outside the recording are masked. The segments keep their epochs (``erpd.epochs``). :meth:`~EyeData.get_segments` also takes Intervals, but only if they all have the same length (the time axis then starts at 0 at their starts); for intervals between two events, it raises an error. The baseline window of ``seg.pupil.baseline_correct()`` must be within the segments. By default, the baseline is subtracted (the change in the units of the data); ``method="divide"`` gives the ratio to the baseline and ``method="percent"`` the change in % of the baseline, e.g., ``erpd.pupil.baseline_correct((-200, 0), method="percent")``. ``seg.gaze`` analyses the gaze with regions of interest (ROIs, see :doc:`gaze`): :meth:`~segmented.SegmentedGazeNamespace.roi_hits` adds a channel per eye that is 1 where the gaze is in the ROI and 0 outside, so its mean over the segments is the share of trials in which the gaze is in the ROI at each time point (a fixation curve, see :doc:`rois`); :meth:`~segmented.SegmentedGazeNamespace.dwell_time` and :meth:`~segmented.SegmentedGazeNamespace.first_entry` give one value per trial, in a table with the metadata of the epochs: .. code-block:: python target = pp.CircularROI((640, 512), radius=100, name="target") seg = seg.gaze.roi_hits(target) # adds left_target, right_target seg.gaze.plot_roi("target") # share of trials in the ROI import pandas as pd pd.DataFrame(seg.gaze.dwell_time(target, window=(0, 1000))) Averages ~~~~~~~~ :meth:`~SegmentedEyeData.average` averages the trials, all of them or by groups defined by metadata columns of the epochs. The result is a :class:`SegmentedEyeData` object with one column per group, so it has the same methods (plots, baseline correction, selection, ``write_file()``). Its epochs have no time (their onsets are NaN); their metadata are the grouping columns, the columns that were the same in all trials of a group, and the number of trials (``n_trials``): .. code-block:: python by_cond = seg.average(by="cond") # one column per condition by_cond.epochs.meta # {"cond": [...], "n_trials": [...]} by_cond.filter(cond="easy").pupil.plot() An average is masked where all its trials are masked. The share of its trials that were usable at each time point is kept (``valid_share``), and the missing data shown by :meth:`~SegmentedEyeData.plot` are the missing trials. Several participants are combined in two steps: :func:`concat_segments` stacks their segments, with the participant in the metadata column ``"subject"``, and :meth:`~SegmentedEyeData.average` averages them. :func:`group_segments` does both for the usual case of one average per participant: .. code-block:: python segs = {"P01": seg1, "P02": seg2} group = pp.group_segments(segs) # one column per participant group.pupil.plot() # mean and error across participants allseg = pp.concat_segments(segs) # all trials, with "subject" means = allseg.average(by=["subject", "cond"]) # per participant and condition means.filter(cond="easy").pupil.plot() # across participants, easy trials Averaging averages gives each of them the same weight (e.g., each participant). Measures that only make sense for single trials (ROI hits, first entry, see :doc:`rois`) raise an error for averages. For statistical models of trial-by-trial data, it is often easier to work with one number per trial. :meth:`~EyeData.stat_per_event` applies a function (by default the mean) to each interval: .. code-block:: python peak = d.stat_per_event(window=(500, 1500) * u.ms, event_select="stim", variables="pupil", statfct=np.max) Tutorial: :doc:`/notebooks/erpds`.