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

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
Recording
duration 0.682 min
sampling rate 500 Hz
samples 20465
events 40
time gaps 0
Pupil
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
Gaze
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
Binocular
left/right
pupil correlation 0.914 good
gaze disparity 1.61 deg ok
Blinks
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
Artifacts
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_pctgood <= 10, ok <= 20, poor <= 40, else bad
pupil.dropout_rategood <= 30, ok <= 60, poor <= 150, else bad
pupil.longest_dropoutgood <= 2, ok <= 5, poor <= 30, else bad
pupil.noisegood <= 0.5, ok <= 1, poor <= 2, else bad
pupil.jump_rategood <= 1, ok <= 10, poor <= 100, else bad
gaze.missing_pctgood <= 10, ok <= 20, poor <= 40, else bad
gaze.dropout_rategood <= 30, ok <= 60, poor <= 150, else bad
gaze.longest_dropoutgood <= 2, ok <= 5, poor <= 30, else bad
gaze.offscreen_pctgood <= 5, ok <= 15, poor <= 30, else bad
gaze.precision_deggood <= 0.05, ok <= 0.1, poor <= 0.2, else bad
binocular.pupil_correlationgood >= 0.9, ok >= 0.8, poor >= 0.6, else bad
binocular.gaze_disparity_deggood <= 1, ok <= 2, poor <= 3, else bad
blinks.rategood <= 20, ok <= 35, poor <= 70, else bad
blinks.time_pctgood <= 10, ok <= 20, poor <= 35, else bad
artifacts.pupil_masked_pctgood <= 10, ok <= 20, poor <= 35, else bad
artifacts.gaze_masked_pctgood <= 10, ok <= 20, poor <= 35, else bad
artifacts.interpolated_pctgood <= 10, ok <= 20, poor <= 30, else bad
calibration.mean_errorgood <= 0.5, ok <= 1, poor <= 1.5, else bad
calibration.max_errorgood <= 1, ok <= 1.5, poor <= 2, else bad
The overall label is the worst label of all measures. Hover over a measure for a description.

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}