Preprocessing#

Raw pupil data usually contain blinks, other artifacts and high-frequency noise. This page lists the tools for dealing with them. All methods process all eyes by default (use eyes= to select) and can be combined into a pipeline with | (see Data objects).

Visual checks are an important part of preprocessing. Look at the data before and after each step (see Plotting). To tune parameters, use tweak() (see Interactive tools): it shows the result while you change them.

Data quality#

Before and after preprocessing, quality_report() summarizes the quality of a dataset (missing data, noise, blinks, artifacts, calibration) and labels each measure “good”, “ok”, “poor” or “bad”. See Data quality.

d.quality_report()

Other artifacts#

For gaze data, samples off the screen and samples where the two eyes point to very different places are usually artifacts:

EyeData.gaze.mask_offscreen

Detect and mask gaze coordinates that fall outside the screen boundaries.

EyeData.gaze.mask_divergences

Calculate Euclidean distance between left and right eye coordinates and detect divergences.

mask_offscreen() needs the screen size (see set_experimental_setup() and Gaze data and the experimental setup).

Any stretch of data can be masked or interpolated from an Intervals object, e.g., periods you marked as bad by hand:

EyeData.mask_intervals

Apply Intervals as masks to selected data.

EyeData.interpolate_intervals

Interpolate data within specified intervals.

EyeData.mask_as_intervals

Convert masks to Intervals objects.

EyeData.merge_masks

Merge masks of all variables into a single joint mask.

Filtering and downsampling#

Pupil size changes slowly. A low-pass filter at about 4 Hz removes noise without distorting the signal (Jackson & Sirois, 2009). Downsampling reduces the amount of data, e.g., for modeling.

EyeData.pupil.lowpass_filter

Lowpass-filter pupil signal using a Butterworth-filter.

EyeData.pupil.smooth_window

Apply smoothing of the signal using a moving window.

EyeData.downsample

Simple downsampling scheme using mean within the downsampling window.

The filters only use the usable samples: masked samples (e.g., blinks that were not interpolated, or zero-coded blinks) are bridged linearly for the filter and keep their values, so they do not leak into their neighbours. Interpolating blinks before filtering gives a better bridge than a straight line.

downsample() averages the samples within each new sampling interval (masked samples are left out), so filter before downsampling to a rate close to the frequencies of interest.

A 4 Hz low-pass filter removes the fast noise:

d = pp.get_example_data("rlmw_002_short") | pp.pupil.blinks_detect() | pp.pupil.blinks_interpolate()
filtered = d.pupil.lowpass_filter(cutoff=4, inplace=False)

sel = (d.tx >= 12000) & (d.tx < 17000)
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(d.tx[sel] / 1000, d["left_pupil"][sel], color="0.6", lw=1, label="before")
ax.plot(d.tx[sel] / 1000, filtered["left_pupil"][sel], color="C0", lw=1.5,
        label="lowpass_filter(cutoff=4)")
ax.set(xlabel="time (s)", ylabel="left pupil")
ax.legend(frameon=False, fontsize=8)
fig.tight_layout()
../_images/preprocessing-2.png

Downsampling to 50 Hz keeps the slow changes of the pupil:

d = pp.get_example_data("rlmw_002_short") | pp.pupil.blinks_detect() | pp.pupil.blinks_interpolate()
low = d.downsample(50, inplace=False)          # 500 Hz -> 50 Hz

fig, ax = plt.subplots(figsize=(8, 3))
for obj, style, label in [(d, dict(color="0.6", lw=1), "500 Hz"),
                          (low, dict(color="C1", marker="o", ms=3, lw=1), "downsample(50): 50 Hz")]:
    sel = (obj.tx >= 12000) & (obj.tx < 14000)
    ax.plot(obj.tx[sel] / 1000, obj["left_pupil"][sel], label=label, **style)
ax.set(xlabel="time (s)", ylabel="left pupil")
ax.legend(frameon=False, fontsize=8)
fig.tight_layout()
../_images/preprocessing-3.png

Scaling#

Pupil size is measured in arbitrary units that depend on the eye tracker and the setup. To compare participants, the data are often scaled:

EyeData.scale

Scale the signal: subtract center and divide by scale.

EyeData.unscale

Scale back to original values using either values provided as arguments or the values stored in params["scale"] by scale().

scale() subtracts a center and divides by a scale, both computed from the usable samples of each channel. method chooses them:

d.scale("pupil")                       # z-scores (mean and SD)
d.scale("pupil", method="robust")      # median and MAD: units of the MAD
d.scale("pupil", method="minmax")      # between 0 and 1
d.scale("pupil", method="percent")     # % change from the mean

The robust scaling uses the raw median absolute deviation (not multiplied by 1.4826), so artefacts that were not removed have little influence. The values that were used are stored with the object (d.params["scale"]), and unscale() undoes the scaling. For a percent change relative to a baseline before each event, see seg.pupil.baseline_correct(window, method="percent") (Events, intervals and segments).

All methods keep the shape of the signal and change only its units (the y-axes):

d = pp.get_example_data("rlmw_002_short") | pp.pupil.blinks_detect() | pp.pupil.blinks_interpolate()

fig, axes = plt.subplots(1, 4, figsize=(11, 2.6), sharex=True)
for ax, method in zip(axes, ["z", "robust", "minmax", "percent"]):
    s = d.scale("pupil", method=method, inplace=False)
    ax.plot(s.tx / 1000, s["left_pupil"], lw=0.7)
    ax.set_title(f'method="{method}"', fontsize=10)
    ax.set_xlabel("time (s)")
axes[0].set_ylabel("scaled left pupil")
fig.tight_layout()
../_images/preprocessing-4.png

Combining the eyes#

With binocular data, the two eyes can be combined into one signal that is less noisy and has fewer gaps:

EyeData.pupil.merge_eyes

Merge pupil data from both eyes into a new eye (default "mean").

EyeData.merge_eyes

Merge data from both eyes into a single variable.

merge_eyes() corrects for a (slowly changing) difference in pupil size between the eyes and fills samples missing in one eye from the other (recorded samples come before interpolated ones). The result is stored as mean_pupil (eye "mean"; another name with name=); it keeps the layers that both eyes have in common, e.g., "blink" and "interpolated" where both eyes blinked. merge_eyes() simply averages any variable. See Processing monocular and binocular data.

Where the left eye is masked during a blink, the merged pupil continues from the right eye, shifted by the difference between the eyes, so it has no jump and no gap:

d = pp.get_example_data("rlmw_002_short") | pp.pupil.blinks_detect()
d.pupil.merge_eyes()                     # adds "mean_pupil"

sel = (d.tx >= 9800) & (d.tx < 12200)
t = d.tx[sel] / 1000
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(t, d["left_pupil"][sel], color="C0", lw=1, label="left (masked: blink)")
ax.plot(t, d["right_pupil"][sel], color="C1", lw=1, label="right")
ax.plot(t, d["mean_pupil"][sel], color="k", lw=1.5, ls="--", label="mean_pupil")
ax.set(xlabel="time (s)", ylabel="pupil")
ax.legend(frameon=False, fontsize=8, loc="lower left")
fig.tight_layout()
../_images/preprocessing-5.png

Selecting and cleaning up data#

EyeData.crop

Crop the data to the time range from start to end.

EyeData.remove_intervals

Remove the data in the given periods (e.g., breaks) and join the rest.

EyeData.reset_time

Resets time so that time t0 becomes time zero.

EyeData.drop_data

Drop specified eyes and/or variables from the data.

EyeData.drop_original

Drop original dataset from record (e.g., to save space).

EyeData.fill_time_discontinuities

Find gaps in the time-vector and fill them in.

EyeData.copy

Make and return a deep-copy of the eyedata object.

crop() cuts out a time range, e.g., one block of the experiment: d.crop(3 * u.min, 5 * u.min). remove_intervals() removes periods in the middle, e.g., breaks, and joins the rest: the time vector and the events after a removed period are shifted back, so that there are no gaps. reset_time() makes the time vector start at zero (or at any other time). drop_data() removes eyes or variables, e.g., d.drop_data(variables=["x", "y"]) keeps only the pupil data; masks, blinks, events, the experimental setup and all other data are kept. Like all transforming methods, it works in place unless inplace=False is given (or set as the default, see Settings and logging).

References#