Summarizing pupillometric data#
pypillometry summarizes a dataset at several levels:
one dataset: its contents (
print(d),summary()), the quality of the data (quality_report()) and how much of it is masked, and why (layer_stats())several datasets: the same summaries as one table with a row per dataset, e.g., to find participants with poor data
trials: one value per event or trial (
stat_per_event()), together with the events and their metadata
Many of these results are dicts that are displayed as a table: per channel (e.g., layer_stats(): channel -> layer -> statistics) or with one row per trial (a dict of columns). .to_pandas() converts them to a pandas.DataFrame.
[1]:
import pypillometry as pp
from pypillometry import u
import pandas as pd
pp.logging_set_level("WARNING")
One dataset#
We detect and interpolate blinks and mask the gaze outside the screen. Printing the object gives an overview of its contents and the history of operations:
[2]:
d = pp.get_example_data("rlmw_002_short")
d = d | pp.pupil.blinks_detect() | pp.pupil.blinks_interpolate() | pp.gaze.mask_offscreen()
print(d)
EyeData(rlmw_002_short, 1.4MiB):
n : 20465
sampling_rate : 500.0
eyes : ['left', 'right']
data : ['left_x', 'left_y', 'left_pupil', 'right_x', 'right_y', 'right_pupil']
nevents : 40
experimental_setup: {'screen_resolution': (1280, 1024), 'physical_screen_size_mm': (300.0, 200.0), 'eye_to_screen_distance_mm': 600.0, 'camera_position': 'not set'}
calibration : not available
duration_minutes : 0.6822
start_min : 0.0
end_min : 0.6821
params : {}
nblinks : {'left_pupil': 14, 'right_pupil': 13}
blinks : {'left_pupil': {'n': 14, 'mean': 217.1429, 'sd': 236.4701, 'min': 48.0, 'max': 984.0, 'total_duration': 3040.0, 'units': 'ms'}, 'right_pupil': {'n': 13, 'mean': 138.0, 'sd': 84.8564, 'min': 44.0, 'max': 404.0, 'total_duration': 1794.0, 'units': 'ms'}}
glimpse : EyeDataDict(vars=6,n=20465,shape=(20465,)):
left_x (float64): 655.6, 655.7, 655.0, 654.5, 655.0...
left_y (float64): 599.9, 598.9, 597.6, 597.8, 597.8...
left_pupil (float64): 1121.0, 1122.0, 1124.0, 1126.0, 1126.0...
right_x (float64): 773.1, 773.8, 774.8, 776.3, 776.7...
right_y (float64): 594.2, 593.9, 596.5, 597.4, 597.3...
right_pupil (float64): 949.0, 951.0, 956.0, 959.0, 960.0...
History:
*
└ reset_time()
└ pupil.blinks_detect()
└ pupil.blinks_interpolate()
└ gaze.mask_offscreen()
summary() returns the same information as a dict:
[3]:
d.summary()
[3]:
{'name': 'rlmw_002_short',
'n': 20465,
'sampling_rate': 500.0,
'eyes': ['left', 'right'],
'data': ['left_x',
'left_y',
'left_pupil',
'right_x',
'right_y',
'right_pupil'],
'nevents': 40,
'experimental_setup': {'screen_resolution': (1280, 1024),
'physical_screen_size_mm': (300.0, 200.0),
'eye_to_screen_distance_mm': 600.0,
'camera_position': 'not set'},
'calibration': 'not available',
'duration_minutes': 0.6821666666666667,
'start_min': 0.0,
'end_min': 0.6821333333333333,
'params': {},
'nblinks': {'left_pupil': 14, 'right_pupil': 13},
'blinks': {'left_pupil': {'n': 14,
'mean': 217.14285714285714,
'sd': 236.4701301411652,
'min': 48.0,
'max': 984.0,
'total_duration': 3040.0,
'units': 'ms'},
'right_pupil': {'n': 13,
'mean': 138.0,
'sd': 84.85643985352782,
'min': 44.0,
'max': 404.0,
'total_duration': 1794.0,
'units': 'ms'}}}
Data quality#
The quality report rates missing data, noise, blinks, gaze precision, the agreement of the eyes and the calibration as “good”, “ok”, “poor” or “bad” (see Data quality):
[4]:
report = d.quality_report()
report
[4]:
Quality report — rlmw_002_short (EyeData)
ok| duration | 0.682 min |
| sampling rate | 500 Hz |
| samples | 20465 |
| events | 40 |
| time gaps | 0 |
| left | right | |
|---|---|---|
| missing data | 0.0% good | 0.0% good |
| dropouts | 0 /min good | 0 /min good |
| longest dropout | 0 s good | 0 s good |
| noise | 0.094% good | 0.11% good |
| noise (SD) | 1.05 | 1.05 |
| jumps | 0 /min good | 0 /min good |
| left | right | |
|---|---|---|
| missing data | 2.7% good | 1.9% good |
| off-screen | 0.21% good | 0.24% good |
| precision | 0.0202 deg good | 0.02 deg good |
| left/right | |
|---|---|
| pupil correlation | 0.914 good |
| gaze disparity | 1.61 deg ok |
| left | right | |
|---|---|---|
| number | 14 | 13 |
| blink rate | 20.5 /min ok | 19.1 /min good |
| mean duration | 217 ms | 138 ms |
| time in blinks | 7.4% good | 4.4% good |
| long (>500 ms) | 1 | 0 |
| left | right | |
|---|---|---|
| pupil masked | 0.0% good | 0.0% good |
| interpolated | 9.9% good | 6.0% good |
| gaze masked | 2.9% good | 2.1% good |
| masked off-screen | 0.21% | 0.24% |
| other artifacts | 0.0% | 0.0% |
Thresholds
| pupil.missing_pct | good <= 10, ok <= 20, poor <= 40, else bad |
| pupil.dropout_rate | good <= 30, ok <= 60, poor <= 150, else bad |
| pupil.longest_dropout | good <= 2, ok <= 5, poor <= 30, else bad |
| pupil.noise | good <= 0.5, ok <= 1, poor <= 2, else bad |
| pupil.jump_rate | good <= 1, ok <= 10, poor <= 100, else bad |
| gaze.missing_pct | good <= 10, ok <= 20, poor <= 40, else bad |
| gaze.dropout_rate | good <= 30, ok <= 60, poor <= 150, else bad |
| gaze.longest_dropout | good <= 2, ok <= 5, poor <= 30, else bad |
| gaze.offscreen_pct | good <= 5, ok <= 15, poor <= 30, else bad |
| gaze.precision_deg | good <= 0.05, ok <= 0.1, poor <= 0.2, else bad |
| binocular.pupil_correlation | good >= 0.9, ok >= 0.8, poor >= 0.6, else bad |
| binocular.gaze_disparity_deg | good <= 1, ok <= 2, poor <= 3, else bad |
| blinks.rate | good <= 20, ok <= 35, poor <= 70, else bad |
| blinks.time_pct | good <= 10, ok <= 20, poor <= 35, else bad |
| artifacts.pupil_masked_pct | good <= 10, ok <= 20, poor <= 35, else bad |
| artifacts.gaze_masked_pct | good <= 10, ok <= 20, poor <= 35, else bad |
| artifacts.interpolated_pct | good <= 10, ok <= 20, poor <= 30, else bad |
| calibration.mean_error | good <= 0.5, ok <= 1, poor <= 1.5, else bad |
| calibration.max_error | good <= 1, ok <= 1.5, poor <= 2, else bad |
report.issues() lists the measures that are “poor” or “bad” (none here), and report.to_dict(flat=True) gives all values as a flat dict, e.g., for a table of many datasets (see below).
Masked data#
layer_stats() describes each mask layer of each channel: the number of stretches, their durations and the percentage of the samples they cover. The entry masked counts all unusable samples of a channel (see Missing data). It is a dict (channel -> layer -> statistics) that is displayed as a table:
[5]:
stats = d.layer_stats(units="sec")
stats
[5]:
| n | mean | sd | min | max | total_duration | units | percent | ||
|---|---|---|---|---|---|---|---|---|---|
| channel | layer | ||||||||
| left_x | offscreen | 14 | 0.006000 | 0.004140 | 0.002 | 0.016 | 0.084 | sec | 0.205228 |
| masked | 11 | 0.109636 | 0.030149 | 0.058 | 0.146 | 1.206 | sec | 2.946494 | |
| left_y | offscreen | 14 | 0.006000 | 0.004140 | 0.002 | 0.016 | 0.084 | sec | 0.205228 |
| masked | 11 | 0.109636 | 0.030149 | 0.058 | 0.146 | 1.206 | sec | 2.946494 | |
| left_pupil | blink | 14 | 0.217143 | 0.236470 | 0.048 | 0.984 | 3.040 | sec | 7.427315 |
| interpolated | 11 | 0.369455 | 0.298280 | 0.116 | 1.026 | 4.064 | sec | 9.929147 | |
| masked | 0 | NaN | NaN | NaN | NaN | 0.000 | sec | 0.000000 | |
| right_x | offscreen | 15 | 0.006533 | 0.003964 | 0.002 | 0.016 | 0.098 | sec | 0.239433 |
| masked | 10 | 0.087600 | 0.025734 | 0.042 | 0.120 | 0.876 | sec | 2.140239 | |
| right_y | offscreen | 15 | 0.006533 | 0.003964 | 0.002 | 0.016 | 0.098 | sec | 0.239433 |
| masked | 10 | 0.087600 | 0.025734 | 0.042 | 0.120 | 0.876 | sec | 2.140239 | |
| right_pupil | blink | 13 | 0.138000 | 0.084856 | 0.044 | 0.404 | 1.794 | sec | 4.383093 |
| interpolated | 13 | 0.189077 | 0.081553 | 0.096 | 0.444 | 2.458 | sec | 6.005375 | |
| masked | 0 | NaN | NaN | NaN | NaN | 0.000 | sec | 0.000000 |
[6]:
stats["left_pupil"]["blink"]["percent"] # % of the samples in blinks
[6]:
7.4273149279257265
After the interpolation, the pupil channels have no unusable samples left (masked: 0 stretches), because interpolated samples are usable; the gaze is still masked during blinks and off the screen.
blink_stats() is a shortcut for the blink rows. Durations of the recording are given by get_duration() and, if periods are excluded (e.g., breaks), excluded_duration():
[7]:
d.blink_stats()["left_pupil"], d.get_duration("min"), d.excluded_duration("min")
[7]:
({'n': 14,
'mean': 217.14285714285714,
'sd': 236.4701301411652,
'min': 48.0,
'max': 984.0,
'total_duration': 3040.0,
'units': 'ms'},
0.6821666666666667,
0.0)
Several datasets#
In a study, there is one dataset per participant. Here, we use three example recordings and detect blinks in each of them:
[8]:
datasets = [pp.get_example_data("rlmw_002_short"),
pp.EyeData.from_file("../../data/test.pd"),
pp.EyeData.from_file("../../data/example_shortblinks.pd")]
datasets = [d | pp.pupil.blinks_detect() for d in datasets]
A table of their summaries (some of the columns):
[9]:
tab = pd.DataFrame([d.summary() for d in datasets])
tab[["name", "n", "sampling_rate", "duration_minutes", "nevents", "nblinks"]]
[9]:
| name | n | sampling_rate | duration_minutes | nevents | nblinks | |
|---|---|---|---|---|---|---|
| 0 | rlmw_002_short | 20465 | 500.0 | 0.682167 | 40 | {'left_pupil': 14, 'right_pupil': 13} |
| 1 | test_ro_ka_si_hu | 60001 | 500.0 | 2.000033 | 56 | {'left_pupil': 65} |
| 2 | 55910_1_wu | 12501 | 500.0 | 0.416700 | 10 | {'left_pupil': 22} |
The quality reports as a table, one row per dataset, with the overall label and the missing pupil data of the left eye:
[10]:
quality = pd.DataFrame([d.quality_report().to_dict(flat=True) for d in datasets])
quality[["name", "overall", "pupil.left.missing_pct", "pupil.left.missing_pct.label",
"blinks.left.rate", "blinks.left.rate.label"]]
[10]:
| name | overall | pupil.left.missing_pct | pupil.left.missing_pct.label | blinks.left.rate | blinks.left.rate.label | |
|---|---|---|---|---|---|---|
| 0 | rlmw_002_short | ok | 2.741266 | good | 20.522844 | ok |
| 1 | test_ro_ka_si_hu | poor | 0.000000 | good | 32.499458 | ok |
| 2 | 55910_1_wu | bad | 0.000000 | good | 52.795776 | poor |
Datasets that need a closer look can be selected by their labels, and issues() tells what is wrong with them:
[11]:
quality[quality["overall"].isin(["poor", "bad"])][["name", "overall"]]
[11]:
| name | overall | |
|---|---|---|
| 1 | test_ro_ka_si_hu | poor |
| 2 | 55910_1_wu | bad |
[12]:
datasets[2].quality_report().issues()
[12]:
[('pupil',
'left',
QualityMetric(name='jump_rate', title='jumps', value=155.98752099832015, unit='/min', label='bad', description='number of changes larger than 5% of the median pupil size from one sample to the next, per minute (ignoring 50 ms around missing data); indicates artifacts, e.g., during saccades', thresholds=(1, 10, 100))),
('blinks',
'left',
QualityMetric(name='rate', title='blink rate', value=52.795776337892974, unit='/min', label='poor', description='number of detected blinks per minute (median across people about 10 during screen tasks and 13 at rest; varies a lot between people)', thresholds=(20, 35, 70))),
('blinks',
'left',
QualityMetric(name='time_pct', title='time in blinks', value=np.float64(21.926245900327974), unit='%', label='poor', description='percentage of the recording covered by blinks (and the samples around them that were removed)', thresholds=(10, 20, 35))),
('artifacts',
'left',
QualityMetric(name='pupil_masked_pct', title='pupil masked', value=np.float64(21.926245900327974), unit='%', label='poor', description="percentage of masked pupil samples (missing data, blinks and other artifacts; interpolated samples only with the 'strict' mask policy)", thresholds=(10, 20, 35)))]
The layer statistics of all datasets in one table, with a column for the dataset:
[13]:
pd.concat([d.layer_stats(variables="pupil").to_pandas().reset_index().assign(name=d.name)
for d in datasets], ignore_index=True)
[13]:
| channel | layer | n | mean | sd | min | max | total_duration | units | percent | name | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | left_pupil | blink | 14 | 217.142857 | 236.470130 | 48.0 | 984.0 | 3040.0 | ms | 7.427315 | rlmw_002_short |
| 1 | left_pupil | masked | 14 | 217.142857 | 236.470130 | 48.0 | 984.0 | 3040.0 | ms | 7.427315 | rlmw_002_short |
| 2 | right_pupil | blink | 13 | 138.000000 | 84.856440 | 44.0 | 404.0 | 1794.0 | ms | 4.383093 | rlmw_002_short |
| 3 | right_pupil | masked | 13 | 138.000000 | 84.856440 | 44.0 | 404.0 | 1794.0 | ms | 4.383093 | rlmw_002_short |
| 4 | left_pupil | blink | 65 | 225.230769 | 247.912067 | 24.0 | 990.0 | 14640.0 | ms | 12.199797 | test_ro_ka_si_hu |
| 5 | left_pupil | masked | 65 | 225.230769 | 247.912067 | 24.0 | 990.0 | 14640.0 | ms | 12.199797 | test_ro_ka_si_hu |
| 6 | left_pupil | blink | 22 | 249.181818 | 68.717231 | 184.0 | 538.0 | 5482.0 | ms | 21.926246 | 55910_1_wu |
| 7 | left_pupil | masked | 22 | 249.181818 | 68.717231 | 184.0 | 538.0 | 5482.0 | ms | 21.926246 | 55910_1_wu |
Trials#
stat_per_event() computes one value per event, e.g., the mean pupil size in the second after each stimulus. Combined with the events and their metadata (see Event metadata), this gives a table with one row per trial, e.g., for a statistical model:
[14]:
d = datasets[0]
epochs = d.get_epochs("S_", (0, 1000) * u.ms)
means = d.stat_per_event(epochs, variables="pupil")
trials = pp.Table({**epochs.events.to_table(), **means})
trials
[14]:
| onset | label | left_pupil | right_pupil | |
|---|---|---|---|---|
| 0 | 5677.0 | S_1_1_5 | 1002.974000 | 831.318000 |
| 1 | 9575.0 | S_2_6_2 | 1179.907489 | 966.048000 |
| 2 | 12574.0 | S_3_2_1 | 1269.650000 | 1077.462000 |
| 3 | 16473.0 | S_4_4_1 | 1153.294000 | 956.178000 |
| 4 | 20388.0 | S_5_3_5 | 1098.470000 | 912.224000 |
| 5 | 23987.0 | S_6_4_5 | 1096.668000 | 914.322000 |
| 6 | 27002.0 | S_7_3_1 | 987.372000 | 832.730000 |
| 7 | 30601.0 | S_8_6_5 | 1077.352000 | 915.889262 |
| 8 | 34799.0 | S_9_4_6 | 955.512000 | 797.592000 |
| 9 | 38415.0 | S_10_2_5 | 1064.282000 | 888.112000 |
For segments around events (see the ERPD tutorial), summary() gives their shape and the missing data per channel; the measures of gaze in regions of interest per trial (seg.gaze.dwell_time(), seg.gaze.first_entry()) are tables like the one above (see Regions of interest):
[15]:
seg = d.get_segments(d.get_epochs("S_", (-500, 2500) * u.ms), variables="pupil")
seg.summary()
[15]:
{'name': 'S_',
'channels': ['left_pupil', 'right_pupil'],
'n_segments': 10,
'n_timepoints': 1500,
'window': (-500.0, 2498.0),
'percent_masked': {'left_pupil': 9.806666666666667,
'right_pupil': 5.733333333333333},
'sampling_rate': 500.0,
'n_averages': 0,
'n_trials': 10}