This page was generated from a Jupyter notebook. View on GitHub Binder badge

Event metadata#

Besides its onset and label, an event often comes with more information: the trial it belongs to, the condition, the choice, the reward or the reaction time. In pypillometry, this information is stored as metadata of the events: columns with one value per event. The events can then be selected by their metadata, and the metadata are passed on to intervals and segments.

There are two common sources of metadata:

  1. The trigger labels. Many experiments code the information in the triggers that are sent to the eye tracker, e.g., S_12_3_5 for the stimulus of trial 12 with the symbols 3 and 5. The labels are decomposed into metadata with parse_labels().

  2. A behaviour log. The experiment software writes a table with one row per trial. Events.from_table() creates the events from its time columns and takes the other columns as metadata.

This tutorial shows both cases, how to select events by their metadata and how to use the metadata in an analysis.

[1]:
import pypillometry as pp
from pypillometry import u
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

pp.logging_set_level("WARNING")

d = pp.get_example_data("rlmw_002_short")
d = d | (pp.pupil.blinks_detect(vel_onset=-15)
         | pp.pupil.blinks_merge_close(distance=100 * u.ms)
         | pp.pupil.blinks_interpolate(margin=(50, 150) * u.ms)
         | pp.pupil.lowpass_filter(cutoff=5))

Metadata from trigger labels#

The example data are from a reinforcement-learning task. In each trial, a fixation cross was shown (F_<trial>), then two symbols (S_<trial>_<left symbol>_<right symbol>). The participant chose one of them (C_<trial>_<chosen symbol>_<side>, side 1 is left and 2 is right) and got a reward (R_<trial>_<points>):

[2]:
events = d.events
events[:8]
[2]:
Onset (ms) Label
0 5177.0 F_1
1 5677.0 S_1_1_5
2 6773.0 C_1_5_2
3 7010.0 R_1_15
4 8776.0 F_2
5 9575.0 S_2_6_2
6 10581.0 C_2_6_1
7 10808.0 R_2_20

Total: 8 events

parse_labels() takes a regular expression with named groups, (?P<name>...). Each group becomes a metadata column. For events whose label does not match, the value is missing (NaN for numbers, "" for text). Columns that contain only numbers are converted to numbers:

[3]:
ev = events.parse_labels(r"^[FSCR]_(?P<trial>\d+)")
ev[:8]
[3]:
Onset (ms) Label trial
0 5177.0 F_1 1
1 5677.0 S_1_1_5 1
2 6773.0 C_1_5_2 1
3 7010.0 R_1_15 1
4 8776.0 F_2 2
5 9575.0 S_2_6_2 2
6 10581.0 C_2_6_1 2
7 10808.0 R_2_20 2

Total: 8 events

Each call adds columns, so the different kinds of events can be parsed one after the other:

[4]:
ev = (ev.parse_labels(r"^S_\d+_(?P<left>\d+)_(?P<right>\d+)")
        .parse_labels(r"^C_\d+_(?P<chosen>\d+)_(?P<side>\d+)")
        .parse_labels(r"^R_\d+_(?P<reward>\d+)"))
ev[:8]
[4]:
Onset (ms) Label trial left right chosen side reward
0 5177.0 F_1 1 NaN NaN NaN NaN NaN
1 5677.0 S_1_1_5 1 1.0 5.0 NaN NaN NaN
2 6773.0 C_1_5_2 1 NaN NaN 5.0 2.0 NaN
3 7010.0 R_1_15 1 NaN NaN NaN NaN 15.0
4 8776.0 F_2 2 NaN NaN NaN NaN NaN
5 9575.0 S_2_6_2 2 6.0 2.0 NaN NaN NaN
6 10581.0 C_2_6_1 2 NaN NaN 6.0 1.0 NaN
7 10808.0 R_2_20 2 NaN NaN NaN NaN 20.0

Total: 8 events

Instead of a regular expression, parse_labels() also takes a function. It gets a label and returns a dict with the metadata of this event (or None for no metadata). This is often easier for more complex codes. The following function gives the same result as the four regular expressions above:

[5]:
def parse(label):
    kind, trial, *numbers = label.split("_")
    names = {"F": [], "S": ["left", "right"], "C": ["chosen", "side"], "R": ["reward"]}
    return {"trial": int(trial), **dict(zip(names[kind], map(int, numbers)))}

ev = events.parse_labels(parse)
ev[:8]
[5]:
Onset (ms) Label trial left right chosen side reward
0 5177.0 F_1 1 NaN NaN NaN NaN NaN
1 5677.0 S_1_1_5 1 1.0 5.0 NaN NaN NaN
2 6773.0 C_1_5_2 1 NaN NaN 5.0 2.0 NaN
3 7010.0 R_1_15 1 NaN NaN NaN NaN 15.0
4 8776.0 F_2 2 NaN NaN NaN NaN NaN
5 9575.0 S_2_6_2 2 6.0 2.0 NaN NaN NaN
6 10581.0 C_2_6_1 2 NaN NaN 6.0 1.0 NaN
7 10808.0 R_2_20 2 NaN NaN NaN NaN 20.0

Total: 8 events

The metadata are stored in meta, a dict with one numpy array per column. row() returns all information about one event:

[6]:
print(list(ev.meta))
print(ev.meta["reward"][:8])
ev.row(1)
['trial', 'left', 'right', 'chosen', 'side', 'reward']
[nan nan nan 15. nan nan nan 20.]
[6]:
{'onset': 5677.0,
 'label': 'S_1_1_5',
 'trial': 1,
 'left': 1.0,
 'right': 5.0,
 'chosen': nan,
 'side': nan,
 'reward': nan}

Information from other events of the trial#

Each column is filled only for the events whose label contains the information. To select the stimuli by the choice or the reward that followed, these values are needed at the stimulus events. fill_meta() copies them to all events with the same value in another column, here the trial number:

[7]:
ev = ev.fill_meta(["chosen", "side", "reward"], by="trial")
ev[:8]
[7]:
Onset (ms) Label trial left right chosen side reward
0 5177.0 F_1 1 NaN NaN 5.0 2.0 15.0
1 5677.0 S_1_1_5 1 1.0 5.0 5.0 2.0 15.0
2 6773.0 C_1_5_2 1 NaN NaN 5.0 2.0 15.0
3 7010.0 R_1_15 1 NaN NaN 5.0 2.0 15.0
4 8776.0 F_2 2 NaN NaN 6.0 1.0 20.0
5 9575.0 S_2_6_2 2 6.0 2.0 6.0 1.0 20.0
6 10581.0 C_2_6_1 2 NaN NaN 6.0 1.0 20.0
7 10808.0 R_2_20 2 NaN NaN 6.0 1.0 20.0

Total: 8 events

Times between events can be added with add_latency(). For example, the reaction time is the time from each stimulus to the choice of the same trial. It is stored at the stimulus events (in the units of the events, here ms):

[8]:
ev = ev.add_latency("S_", "C_", "rt", by="trial")
ev.filter("S_")
[8]:
Onset (ms) Label trial left right chosen side reward rt
0 5677.0 S_1_1_5 1 1.0 5.0 5.0 2.0 15.0 1096.0
1 9575.0 S_2_6_2 2 6.0 2.0 6.0 1.0 20.0 1006.0
2 12574.0 S_3_2_1 3 2.0 1.0 2.0 1.0 18.0 1159.0
3 16473.0 S_4_4_1 4 4.0 1.0 1.0 2.0 11.0 1116.0
4 20388.0 S_5_3_5 5 3.0 5.0 5.0 2.0 17.0 768.0
5 23987.0 S_6_4_5 6 4.0 5.0 5.0 2.0 20.0 929.0
6 27002.0 S_7_3_1 7 3.0 1.0 1.0 2.0 6.0 1114.0
7 30601.0 S_8_6_5 8 6.0 5.0 6.0 1.0 18.0 907.0
8 34799.0 S_9_4_6 9 4.0 6.0 6.0 2.0 19.0 901.0
9 38415.0 S_10_2_5 10 2.0 5.0 5.0 2.0 18.0 997.0

Total: 10 events

Without by, each first event is paired with the next second event after it, as in get_intervals(). First events without a second event (e.g., no response) get NaN.

Selecting events by metadata#

filter() takes criteria for metadata columns as keyword arguments, in addition to the selectors for the labels (see the events tutorial). All criteria must be met. A value selects the events where the column is equal to it:

[9]:
ev.filter("S_", side=1)
[9]:
Onset (ms) Label trial left right chosen side reward rt
0 9575.0 S_2_6_2 2 6.0 2.0 6.0 1.0 20.0 1006.0
1 12574.0 S_3_2_1 3 2.0 1.0 2.0 1.0 18.0 1159.0
2 30601.0 S_8_6_5 8 6.0 5.0 6.0 1.0 18.0 907.0

Total: 3 events

A list selects the events where the column has one of the values, and a function gets the whole column and returns True for the events to keep:

[10]:
ev.filter("S_", trial=[1, 2, 3])
[10]:
Onset (ms) Label trial left right chosen side reward rt
0 5677.0 S_1_1_5 1 1.0 5.0 5.0 2.0 15.0 1096.0
1 9575.0 S_2_6_2 2 6.0 2.0 6.0 1.0 20.0 1006.0
2 12574.0 S_3_2_1 3 2.0 1.0 2.0 1.0 18.0 1159.0

Total: 3 events

[11]:
ev.filter("S_", rt=lambda rt: rt < 1000)
[11]:
Onset (ms) Label trial left right chosen side reward rt
0 20388.0 S_5_3_5 5 3.0 5.0 5.0 2.0 17.0 768.0
1 23987.0 S_6_4_5 6 4.0 5.0 5.0 2.0 20.0 929.0
2 30601.0 S_8_6_5 8 6.0 5.0 6.0 1.0 18.0 907.0
3 34799.0 S_9_4_6 9 4.0 6.0 6.0 2.0 19.0 901.0
4 38415.0 S_10_2_5 10 2.0 5.0 5.0 2.0 18.0 997.0

Total: 5 events

A boolean array with one value per event works as well, which is useful for criteria that combine several columns:

[12]:
stim = ev.filter("S_")
stim.filter(stim.meta["chosen"] == stim.meta["left"])   # chose the left symbol
[12]:
Onset (ms) Label trial left right chosen side reward rt
0 9575.0 S_2_6_2 2 6.0 2.0 6.0 1.0 20.0 1006.0
1 12574.0 S_3_2_1 3 2.0 1.0 2.0 1.0 18.0 1159.0
2 30601.0 S_8_6_5 8 6.0 5.0 6.0 1.0 18.0 907.0

Total: 3 events

New columns can also be set directly with set_meta(), for all events or only for selected ones. Here, we mark the trials with a high reward (above the median):

[13]:
high = ev.meta["reward"] > np.nanmedian(ev.meta["reward"])
ev = ev.set_meta("high_reward", high)
ev.filter("R_")
[13]:
Onset (ms) Label trial left right chosen side reward rt high_reward
0 7010.0 R_1_15 1 NaN NaN 5.0 2.0 15.0 NaN False
1 10808.0 R_2_20 2 NaN NaN 6.0 1.0 20.0 NaN True
2 13957.0 R_3_18 3 NaN NaN 2.0 1.0 18.0 NaN False
3 17822.0 R_4_11 4 NaN NaN 1.0 2.0 11.0 NaN False
4 21388.0 R_5_17 5 NaN NaN 5.0 2.0 17.0 NaN False
5 25153.0 R_6_20 6 NaN NaN 5.0 2.0 20.0 NaN True
6 28352.0 R_7_6 7 NaN NaN 1.0 2.0 6.0 NaN False
7 31734.0 R_8_18 8 NaN NaN 6.0 1.0 18.0 NaN False
8 35932.0 R_9_19 9 NaN NaN 6.0 2.0 19.0 NaN True
9 39648.0 R_10_18 10 NaN NaN 5.0 2.0 18.0 NaN False

Total: 10 events

Storing the metadata in the data#

set_events() stores the events with their metadata in the dataset. They are kept when the data are cropped and are saved with write_file():

[14]:
d.set_events(ev)
d.events.filter("R_")
[14]:
Onset (ms) Label trial left right chosen side reward rt high_reward
0 7010.0 R_1_15 1 NaN NaN 5.0 2.0 15.0 NaN False
1 10808.0 R_2_20 2 NaN NaN 6.0 1.0 20.0 NaN True
2 13957.0 R_3_18 3 NaN NaN 2.0 1.0 18.0 NaN False
3 17822.0 R_4_11 4 NaN NaN 1.0 2.0 11.0 NaN False
4 21388.0 R_5_17 5 NaN NaN 5.0 2.0 17.0 NaN False
5 25153.0 R_6_20 6 NaN NaN 5.0 2.0 20.0 NaN True
6 28352.0 R_7_6 7 NaN NaN 1.0 2.0 6.0 NaN False
7 31734.0 R_8_18 8 NaN NaN 6.0 1.0 18.0 NaN False
8 35932.0 R_9_19 9 NaN NaN 6.0 2.0 19.0 NaN True
9 39648.0 R_10_18 10 NaN NaN 5.0 2.0 18.0 NaN False

Total: 10 events

Metadata in intervals and segments#

get_epochs() passes the metadata of the selected events on to the epochs (meta). Together with stat_per_event(), this gives a table with one row per event, ready for statistical analysis:

[15]:
epochs = d.get_epochs("R_", window=(0, 1500) * u.ms)
pupil = d.stat_per_event(intervals=epochs, eyes="left", variables="pupil")

table = epochs.as_pandas()
table["pupil"] = pupil["left_pupil"]
table[["label", "trial", "reward", "high_reward", "pupil"]]
[15]:
label trial reward high_reward pupil
0 R_1_15 1 15.0 False 1204.302750
1 R_2_20 2 20.0 True 1188.649806
2 R_3_18 3 18.0 False 1152.004140
3 R_4_11 4 11.0 False 1183.764497
4 R_5_17 5 17.0 False 1115.428883
5 R_6_20 6 20.0 True 1060.981760
6 R_7_6 7 6.0 False 1096.171788
7 R_8_18 8 18.0 False 1208.443171
8 R_9_19 9 19.0 True 1075.801028
9 R_10_18 10 18.0 False 1070.730637

Metadata from a behaviour log#

When the information is in a behaviour log instead, Events.from_table() takes the columns with metadata as meta. Every event of a row gets the values of that row. Here is a small made-up log with one row per trial (times in seconds):

[16]:
behav = pd.DataFrame({"trial": [1, 2, 3],
                      "cond": ["easy", "hard", "easy"],
                      "stim_on": [12.531, 15.704, 18.877],
                      "rt": [0.412, np.nan, 0.538]})

ev_log = pp.Events.from_table(behav, {"stim": "stim_on",
                                      "response": behav.stim_on + behav.rt},
                              units="sec", meta=["trial", "cond", "rt"])
ev_log
[16]:
Onset (sec) Label trial cond rt row
0 12.531 stim 1 easy 0.412 0
1 12.943 response 1 easy 0.412 0
2 15.704 stim 2 hard NaN 1
3 18.877 stim 3 easy 0.538 2
4 19.415 response 3 easy 0.538 2

Total: 5 events | on another clock: map with sync_clocks()

The column row gives the row of the table that each event came from (use row=None to leave it out). It links the events of the same row even if the table has no trial number.

The events are still on the clock of the experiment computer. They are mapped to the eye-tracker clock with sync_clocks() and added to the data with add_events(), as shown in the tutorial Events from a behaviour log; both keep the metadata. Events that do not have a column (e.g., the triggers that were already in the recording) get missing values in it.

Exporting the metadata#

to_table() returns the events with their metadata as a dict of columns, which can be turned into a pandas.DataFrame (pandas is not needed for the metadata themselves):

[17]:
pd.DataFrame(d.events.to_table()).head()
[17]:
onset label trial left right chosen side reward rt high_reward
0 5177.0 F_1 1 NaN NaN 5.0 2.0 15.0 NaN False
1 5677.0 S_1_1_5 1 1.0 5.0 5.0 2.0 15.0 1096.0 False
2 6773.0 C_1_5_2 1 NaN NaN 5.0 2.0 15.0 NaN False
3 7010.0 R_1_15 1 NaN NaN 5.0 2.0 15.0 NaN False
4 8776.0 F_2 2 NaN NaN 6.0 1.0 20.0 NaN True