Stimulus-locked averaging
Extract epochs from a continuous signal around each stimulus-onset event and average them to get a grand-average response. The bread-and-butter analysis for any "what does X look like when Y happens?" question — ERPs in EEG, stimulus-locked gaze, event-triggered pose.
What this recipe does
- Identify stimulus-onset times from
EventPoints.csv. - For each onset, extract a window from a continuous stream (e.g.
EyeTracking.csv) startingt_prebefore and endingt_postafter. - Align all epochs on the onset (t = 0).
- Compute the mean (and optionally SEM) across epochs.
Files you need
EventPoints.csv— stimulus-onset events.- A continuous stream — any regular stream, e.g.
EyeTracking.csv,ParticipantTracking.csv,FaceTracking.csv.
Code
- Python (pandas)
- R
import pandas as pd
import numpy as np
events = pd.read_csv("EventPoints.csv")
eye = pd.read_csv("EyeTracking.csv")
# Parameters.
signal_col = "Eye_Center_DirZ" # which column to epoch-average
t_pre_s = 0.2 # window before onset, seconds
t_post_s = 1.0 # window after onset, seconds
dt_s = 0.0111 # expected sample interval (1 / frame rate). 90 Hz ≈ 0.0111 s.
# 1. Stimulus onsets.
stim_onsets = events[events["Event_Description"] == "Stimulus flash"]["MonotonicExecutionTime"].values
# 2. For each onset, slice the eye-tracking window.
epochs = []
for t0 in stim_onsets:
window = eye[
(eye["MonotonicExecutionTime"] >= t0 - t_pre_s)
& (eye["MonotonicExecutionTime"] <= t0 + t_post_s)
].copy()
window["t_rel"] = window["MonotonicExecutionTime"] - t0
epochs.append(window[["t_rel", signal_col]])
# 3. Resample each epoch onto a common time grid.
t_grid = np.arange(-t_pre_s, t_post_s, dt_s)
aligned = []
for epoch in epochs:
# Simple linear interpolation onto the grid.
series = np.interp(t_grid, epoch["t_rel"], epoch[signal_col])
aligned.append(series)
aligned = np.vstack(aligned) # shape: (n_trials, n_samples)
# 4. Grand average and SEM.
mean = aligned.mean(axis=0)
sem = aligned.std(axis=0) / np.sqrt(aligned.shape[0])
print(f"n_trials = {aligned.shape[0]}, n_samples = {aligned.shape[1]}")
# 5. Plot (optional).
# import matplotlib.pyplot as plt
# plt.plot(t_grid, mean)
# plt.fill_between(t_grid, mean - sem, mean + sem, alpha=0.3)
# plt.axvline(0, color='k', linestyle='--')
# plt.xlabel("Time from stimulus onset (s)")
# plt.ylabel(signal_col)
# plt.show()
library(data.table)
events <- fread("EventPoints.csv")
eye <- fread("EyeTracking.csv")
signal_col <- "Eye_Center_DirZ"
t_pre_s <- 0.2
t_post_s <- 1.0
dt_s <- 0.0111
stim_onsets <- events[Event_Description == "Stimulus flash", MonotonicExecutionTime]
t_grid <- seq(-t_pre_s, t_post_s - dt_s, by = dt_s)
epochs <- lapply(stim_onsets, function(t0) {
w <- eye[MonotonicExecutionTime >= t0 - t_pre_s & MonotonicExecutionTime <= t0 + t_post_s]
w[, t_rel := MonotonicExecutionTime - t0]
approx(w$t_rel, w[[signal_col]], xout = t_grid)$y
})
aligned <- do.call(rbind, epochs) # matrix: n_trials × n_samples
mean_v <- colMeans(aligned, na.rm = TRUE)
sem_v <- apply(aligned, 2, sd, na.rm = TRUE) / sqrt(nrow(aligned))
# plot(t_grid, mean_v, type="l"); abline(v=0, lty=2)
Gotchas
- Sample rate matching.
dt_sshould match your continuous stream's actual rate. At 90 Hz use1/90. At 72 Hz use1/72. Getting this wrong distorts the time axis of the average. - Trials with insufficient data. If a stimulus fires near the start or end of the session, its window may be truncated.
np.interppads with the edge value, which biases the average at the boundaries. Either drop truncated trials or mark their boundary samples asNaNand usenanmean. - Jittered sample times. Regular-stream samples aren't always perfectly evenly spaced (dropped frames, hitches). Interpolating onto a regular grid (as above) is the right fix. Don't assume exact sample times.
Eye_Center_DirZmight not be meaningful for your question. Pick a signal that actually changes in response to your stimulus. For gaze-capture analysis, distance from a fixation target in screen-space is often more informative than a single direction axis.- Display offset. If high temporal precision matters, shift
stim_onsetsforward by the display offset (see Timing) so t = 0 corresponds to when the participant actually saw the stimulus, not when the CPU logged it. - Check trial count. A grand average of 3 trials is a curiosity, not an analysis. Print
n_trialsand sanity-check it against your experimental design before drawing conclusions.
Further reading
- Events — stimulus-onset source.
- Regular streams — continuous signals you can epoch.
- Gaze paths — for eye-tracking-specific analyses beyond epoch averaging.