
Survival Analysis for Gaze Latency
Source:vignettes/gaze-survival-analysis.Rmd
gaze-survival-analysis.RmdWhy TTFF is a censored-data problem
Time to first fixation, first AOI entry, evidence inspection,
revisit, transition latency, and substantively defined disengagement are
time-to-event outcomes. A valid trial that ends before the target event
occurs still contributes information up to the end of its risk window.
eyeprocess therefore retains that trial as a
right-censored observation rather than dropping it or
assigning a latency of zero.
A second distinction is essential: target not observed during a complete usable trial is not the same as target status unknown because gaze became unusable or the observation window is incomplete. The latter remains a review row with an unknown event indicator; it is not silently counted as ordinary censoring.
The canonical table is one row per participant-trial and contains participant, trial, stimulus, condition, target AOI, time origin, event/censor time, analysis time, event indicator, event definition, valid-sample count, valid-data fraction, and trial duration. Additional governance fields retain censoring reason, join identity, event detector, AOI specification, quality rule, preprocessing specification, source, and software version.
When to use this method
Use survival analysis when the scientific outcome is a genuine event latency and some valid trials end before the event is observed. Typical targets include first disclosure fixation, first source/evidence inspection, first revisit, or first transition into a target AOI.
Do not use this workflow when event status cannot be determined, risk windows are unknown, competing event types require a competing-risks estimand, or the scientific question concerns the complete continuous gaze trajectory rather than an event time. Informative trial termination requires additional sensitivity analysis; ordinary right censoring is not automatically justified by the fact that a recording ended.
Worked example: disclosure inspection
The synthetic generator deliberately returns trial windows and event rows first. Censoring is therefore constructed visibly rather than hidden in a prebuilt survival table.
raw <- simulate_gaze_survival_inputs(
"disclosure",
seed = 20260918,
n_participants = 36,
trials_per_participant = 3
)
head(raw$trials)
#> recording_id participant_id trial_id stimulus_id condition_id
#> 1 R001 P001 P001_T01 S01 control
#> 2 R001 P001 P001_T02 S02 minimal_disclosure
#> 3 R001 P001 P001_T03 S03 detailed_disclosure
#> 4 R002 P002 P002_T01 S01 minimal_disclosure
#> 5 R002 P002 P002_T02 S02 detailed_disclosure
#> 6 R002 P002 P002_T03 S03 control
#> start_time end_time n_valid_samples valid_data_fraction
#> 1 0 4 221 0.9423942
#> 2 0 4 233 0.9775320
#> 3 0 4 239 0.9462968
#> 4 0 4 222 0.9944168
#> 5 0 4 234 0.9563629
#> 6 0 4 238 0.9947536
#> observation_end_reason
#> 1 scheduled_trial_end
#> 2 scheduled_trial_end
#> 3 scheduled_trial_end
#> 4 scheduled_trial_end
#> 5 scheduled_trial_end
#> 6 scheduled_trial_end
head(raw$events)
#> recording_id trial_id start_time aoi_id episode_type
#> 1 R001 P001_T01 0.3500000 body fixation
#> 2 R001 P001_T01 2.5891246 disclosure fixation
#> 3 R001 P001_T02 0.3900000 body fixation
#> 4 R001 P001_T02 0.8711407 disclosure fixation
#> 5 R001 P001_T03 0.4300000 body fixation
#> 6 R001 P001_T03 2.2208362 disclosure fixationCanonical event tables can be joined by
participant_id + trial_id when participant identity is
present, or by recording_id + trial_id when event rows
carry recording identity instead. Trial-only matching is accepted only
when trial IDs are globally unique. Ambiguous identity is an error.
surv_data <- prepare_gaze_survival_data(
raw$trials,
raw$events,
target_aoi = "disclosure",
event_type = "first_fixation",
condition_col = "condition_id",
observation_end_reason_col = "observation_end_reason",
min_valid_fraction = .90,
time_origin = "trial_start",
source_data = "synthetic_disclosure_trial_event_inputs",
preprocessing_specification = "synthetic_truth_no_filtering",
event_detector = "synthetic_truth",
aoi_specification = "fixed synthetic disclosure AOI",
quality_rules = list(minimum_fixation_ms = 80, valid_fraction_min = .90)
)
summarise_gaze_censoring(surv_data, by = "condition")
#> condition n_trials n_analyzable n_observed_events n_censored
#> 1 control 36 36 22 14
#> 2 detailed_disclosure 36 36 36 0
#> 3 minimal_disclosure 36 36 34 2
#> n_review_required censoring_fraction
#> 1 0 0.38888889
#> 2 0 0.00000000
#> 3 0 0.05555556A non-default time origin must be explicit. For example, latency from
stimulus onset should be constructed with
time_origin = "stimulus_onset" and
time_origin_col = "stimulus_onset"; the censoring window is
then measured from that same origin.
Worked variant: evidence verification
The second synthetic scenario models time to first entry into
a source/evidence AOI under repeated standard and
evidence_prompt trials. Some complete usable trials never
inspect the evidence region, so those rows remain right-censored.
verify_raw <- simulate_gaze_survival_inputs(
"verification",
seed = 20260918,
n_participants = 36,
trials_per_participant = 3
)
verification <- prepare_gaze_survival_data(
verify_raw$trials,
verify_raw$events,
target_aoi = "source_evidence",
event_type = "first_aoi_entry",
condition_col = "condition_id",
min_valid_fraction = .90,
time_origin = "trial_start",
event_detector = "synthetic_truth",
aoi_specification = "fixed synthetic source/evidence AOI"
)
summarise_gaze_censoring(verification, by = "condition")
#> condition n_trials n_analyzable n_observed_events n_censored
#> 1 evidence_prompt 54 54 53 1
#> 2 standard 54 54 49 5
#> n_review_required censoring_fraction
#> 1 0 0.01851852
#> 2 0 0.09259259This analysis retains the observed risk time of never-inspected trials rather than reducing the question to a complete-case latency comparison.
Event semantics
event_type supports first fixation, first AOI entry,
first evidence inspection, first revisit, first transition into the
target, and disengagement. Revisit/transition/disengagement are
visit-level concepts: consecutive identical AOI labels
are collapsed before those event times are identified, so two
consecutive fixations within one visit do not create a false
revisit.
Censoring decision guide
| Trial state | event_observed |
analysis_time |
Model eligible? | Interpretation |
|---|---|---|---|---|
| Target event occurs inside a usable window | 1 | Event latency | Yes | Observed event |
| Target never occurs before a complete usable window ends | 0 | Censoring time | Yes | Right censored |
| Window incomplete or event status unknowable | NA | NA | No | Review required |
| Gaze quality fails the declared rule | NA | NA | No | Review/exclusion branch |
| Event occurs after the censoring limit | Invalid | Invalid | No | Data-contract error |
The last three states are never silently recoded as ordinary censoring.
Kaplan–Meier description
plot_gaze_survival_curve(surv_data, group = "condition")
Kaplan–Meier curves for time to first disclosure fixation in the synthetic example.
The survival curve is the probability that the target event has
not yet occurred.
plot_gaze_cumulative_incidence() displays
1 - KM for this single-event setting. It should not be
described as a competing-risks cumulative incidence function when
multiple event types compete.
Complementary survival visualizations
plot_gaze_cumulative_incidence(surv_data, group = "condition")
Single-event 1-KM display for time to first disclosure fixation.
The 1-KM display answers the same single-event question from the
opposite direction: the proportion of trials for which the target event
has already occurred by time t. It is useful for
communication, but it is not a competing-risks
cumulative-incidence estimator.
plot_gaze_hazard(surv_data, group = "condition")
Empirical event/risk increments over time in the synthetic disclosure example.
The hazard view is a descriptive event/risk increment display at observed event times. Treat it as a diagnostic visualization rather than a smoothed continuous-time hazard estimate.
Cox versus AFT models
Estimator choice is explicit. fit_gaze_mixed_cox_model()
requires structure = "cluster_robust" or
"frailty"; fit_gaze_aft_model() requires
distribution = "weibull" or "lognormal". Both
arguments are required so the package never chooses an estimator
silently.
cox_clustered <- fit_gaze_mixed_cox_model(
surv_data,
"condition",
participant_col = "participant_id",
structure = "cluster_robust"
)
weibull <- fit_gaze_aft_model(surv_data, "condition", distribution = "weibull")
lognormal <- fit_gaze_aft_model(surv_data, "condition", distribution = "lognormal")
tidy_gaze_survival_model(cox_clustered)
#> term estimate_log_scale std_error hazard_ratio
#> 1 conditiondetailed_disclosure 1.377500 0.2344998 3.964979
#> 2 conditionminimal_disclosure 1.231458 0.2426759 3.426220
#> conf_low conf_high statistic p_value effect_measure
#> 1 2.504000 6.278378 5.874207 4.248714e-09 hazard_ratio
#> 2 2.129361 5.512916 5.074495 3.885256e-07 hazard_ratio
tidy_gaze_survival_model(weibull)
#> term estimate_log_scale std_error time_ratio
#> 1 (Intercept) 1.3856764 0.07915587 3.9975290
#> 2 conditiondetailed_disclosure -0.5635307 0.10165472 0.5691958
#> 3 conditionminimal_disclosure -0.4914879 0.10340637 0.6117156
#> conf_low conf_high statistic p_value effect_measure
#> 1 3.4230561 4.6684124 17.505669 1.296952e-68 time_ratio
#> 2 0.4663726 0.6946889 -5.543577 2.963544e-08 time_ratio
#> 3 0.4994935 0.7491507 -4.752975 2.004453e-06 time_ratioA Cox coefficient is reported as a hazard ratio: a
relative instantaneous event rate among trials still at risk, not a
ratio of mean TTFF. An AFT coefficient is reported as a time
ratio and describes multiplicative event time under the
specified distribution. Information criteria are not treated as
rank-comparable across ordinary Cox partial likelihood,
coxme penalized frailty likelihood, and AFT full
likelihood.
Cross-language backend contract
R delegates marginal Cox to survival::coxph, Gaussian
participant frailty to coxme::coxme, and Weibull/log-normal
AFT to survival::survreg. Python delegates Cox to
statsmodels.PHReg and parametric AFT to
lifelines. The two packages align the censoring contract,
estimator choice, estimand interpretation, provenance, and semantic
output fields. Exact coefficients are not required to be numerically
identical when backend parameterizations or optimizers differ; each
language is regression-tested against its own specialist backend.
compare_gaze_survival_models() never declares a
preferred estimator. Cox partial-likelihood, mixed-Cox penalized
partial-likelihood, and AFT full-likelihood information criteria are
flagged as non-comparable across those likelihood bases.
compare_gaze_survival_models(cox_clustered, weibull, lognormal)
#> model family backend logLik AIC BIC n
#> 1 model_1 cox_repeated survival::coxph -354.7486 713.4971 718.5407 92
#> 2 model_2 aft_weibull survival::survreg -135.4916 278.9831 289.7117 108
#> 3 model_3 aft_lognormal survival::survreg -135.0641 278.1282 288.8567 108
#> likelihood_basis information_criteria_comparable
#> 1 cox_partial_likelihood FALSE
#> 2 full_likelihood FALSE
#> 3 full_likelihood FALSERepeated participants: clustered versus frailty Cox
structure = "cluster_robust" fits a marginal Cox model
with participant-clustered sandwich uncertainty through
survival::coxph. structure = "frailty" is a
different estimator and delegates to the specialist
coxme::coxme mixed-effects Cox engine with a Gaussian
participant random intercept. The package never silently substitutes one
for the other.
frailty <- fit_gaze_mixed_cox_model(
surv_data,
"condition",
participant_col = "participant_id",
structure = "frailty"
)
tidy_gaze_survival_model(frailty)
#> term estimate_log_scale std_error hazard_ratio
#> 1 conditiondetailed_disclosure 1.536285 0.2993990 4.647293
#> 2 conditionminimal_disclosure 1.476000 0.2983902 4.375410
#> conf_low conf_high statistic p_value effect_measure
#> 1 2.584349 8.356971 5.131229 2.878564e-07 hazard_ratio
#> 2 2.437971 7.852518 4.946545 7.554237e-07 hazard_ratioDiagnostics
check_gaze_proportional_hazards(cox_clustered)
#> term rho chisq p_value alpha ph_flag
#> 1 condition NA 2.636062 0.2676618 0.05 FALSE
#> 2 GLOBAL NA 2.636062 0.2676618 0.05 FALSEcheck_gaze_proportional_hazards() uses
survival::cox.zph() for coxph models. The
helper does not pretend that this is a frailty-specific diagnostic for
coxme; for a frailty analysis, report a corresponding
marginal Cox PH diagnostic separately and make that distinction
explicit.
AFT models require strictly positive analysis times. A target already fixated at time zero should trigger review of the time origin and sampling-resolution rule rather than an automatic numeric offset.
Model-choice quick guide
| Scientific question | Recommended family | Interpretation |
|---|---|---|
| What proportion remains uninspected over time? | Kaplan-Meier | Probability the target event has not yet occurred |
| How does condition alter the instantaneous event rate? | Cox PH | Hazard ratio |
| How does condition multiply event time? | Weibull/log-normal AFT | Time ratio |
| How should repeated trials be handled marginally? | Clustered Cox | Population-average hazard ratio with participant-clustered uncertainty |
| How should latent participant heterogeneity be modelled? |
coxme frailty Cox |
Conditional hazard ratio with participant random effect |
Do not choose between Cox and AFT by whichever produces the smaller p-value. State the estimand first, inspect diagnostics, and treat alternative model families as pre-specified sensitivity analyses where appropriate.
Sensitivity analysis
Rebuild the survival table across defensible AOI geometries, event detectors, trial origins, fixation-duration thresholds, and quality rules. Then fit explicitly named model families across those branches.
expanded <- surv_data
expanded$aoi_specification <- "synthetic expanded disclosure AOI"
sensitivity <- compare_gaze_survival_specifications(
list(primary = surv_data, expanded_aoi_demo = expanded),
"condition",
model_families = c("cox_cluster_robust", "aft_weibull")
)
head(sensitivity)
#> specification model_family term
#> 1 primary cox_repeated conditiondetailed_disclosure
#> 2 primary cox_repeated conditionminimal_disclosure
#> 3 primary aft_weibull (Intercept)
#> 4 primary aft_weibull conditiondetailed_disclosure
#> 5 primary aft_weibull conditionminimal_disclosure
#> 6 expanded_aoi_demo cox_repeated conditiondetailed_disclosure
#> estimate_log_scale std_error hazard_ratio conf_low conf_high statistic
#> 1 1.3775004 0.23449980 3.964979 2.5039996 6.2783776 5.874207
#> 2 1.2314577 0.24267589 3.426220 2.1293605 5.5129161 5.074495
#> 3 1.3856764 0.07915587 NA 3.4230561 4.6684124 17.505669
#> 4 -0.5635307 0.10165472 NA 0.4663726 0.6946889 -5.543577
#> 5 -0.4914879 0.10340637 NA 0.4994935 0.7491507 -4.752975
#> 6 1.3775004 0.23449980 3.964979 2.5039996 6.2783776 5.874207
#> p_value effect_measure n_trials n_observed_events n_censored
#> 1 4.248714e-09 hazard_ratio 108 92 16
#> 2 3.885256e-07 hazard_ratio 108 92 16
#> 3 1.296952e-68 time_ratio 108 92 16
#> 4 2.963544e-08 time_ratio 108 92 16
#> 5 2.004453e-06 time_ratio 108 92 16
#> 6 4.248714e-09 hazard_ratio 108 92 16
#> censoring_fraction event_detector aoi_specification
#> 1 0.1481481 synthetic_truth fixed synthetic disclosure AOI
#> 2 0.1481481 synthetic_truth fixed synthetic disclosure AOI
#> 3 0.1481481 synthetic_truth fixed synthetic disclosure AOI
#> 4 0.1481481 synthetic_truth fixed synthetic disclosure AOI
#> 5 0.1481481 synthetic_truth fixed synthetic disclosure AOI
#> 6 0.1481481 synthetic_truth synthetic expanded disclosure AOI
#> quality_rules
#> 1 min_valid_fraction=0.9;minimum_fixation_ms=80;valid_fraction_min=0.9
#> 2 min_valid_fraction=0.9;minimum_fixation_ms=80;valid_fraction_min=0.9
#> 3 min_valid_fraction=0.9;minimum_fixation_ms=80;valid_fraction_min=0.9
#> 4 min_valid_fraction=0.9;minimum_fixation_ms=80;valid_fraction_min=0.9
#> 5 min_valid_fraction=0.9;minimum_fixation_ms=80;valid_fraction_min=0.9
#> 6 min_valid_fraction=0.9;minimum_fixation_ms=80;valid_fraction_min=0.9
#> preprocessing_specification time_origin time_ratio
#> 1 synthetic_truth_no_filtering trial_start NA
#> 2 synthetic_truth_no_filtering trial_start NA
#> 3 synthetic_truth_no_filtering trial_start 3.9975290
#> 4 synthetic_truth_no_filtering trial_start 0.5691958
#> 5 synthetic_truth_no_filtering trial_start 0.6117156
#> 6 synthetic_truth_no_filtering trial_start NAThe relabelled branch above demonstrates the output contract only. A real AOI sensitivity analysis should reconstruct the event table from the alternative AOI geometry rather than merely change its metadata.
Failure case: unusable gaze is not censoring
bad_trials <- raw$trials
bad_trials$valid_data_fraction[1] <- .20
reviewed <- suppressWarnings(prepare_gaze_survival_data(
bad_trials,
raw$events,
target_aoi = "disclosure",
min_valid_fraction = .90
))
reviewed[1, c("event_observed", "censor_reason", "review_required")]
#> event_observed censor_reason review_required
#> 1 NA unusable_gaze_quality TRUEThat row must be resolved or handled in an explicit sensitivity/exclusion branch before model fitting. The model helpers refuse unresolved review rows rather than silently dropping them.
Reporting guidance
At minimum report participants, trials, observed events, censored trials and percentage, review-required rows, target event, target AOI, time origin, observation-window definition, event detector, fixation-duration rule, quality rule, model family, hazard ratio or time ratio with 95% CI, repeated-participant structure, diagnostics, and sensitivity analyses.
report <- report_gaze_survival_model(cox_clustered)
report[c(
"N_participants", "N_trials", "N_observed_events",
"N_censored_trials", "N_review_required", "censoring_percentage",
"event_type", "target_aoi", "time_origin", "model_family",
"random_or_frailty_structure", "diagnostic_result"
)]
#> $N_participants
#> [1] 36
#>
#> $N_trials
#> [1] 108
#>
#> $N_observed_events
#> [1] 92
#>
#> $N_censored_trials
#> [1] 16
#>
#> $N_review_required
#> [1] 0
#>
#> $censoring_percentage
#> [1] 14.81481
#>
#> $event_type
#> [1] "first_fixation"
#>
#> $target_aoi
#> [1] "disclosure"
#>
#> $time_origin
#> [1] "trial_start"
#>
#> $model_family
#> [1] "cox_repeated"
#>
#> $random_or_frailty_structure
#> [1] "cluster_robust:participant_id"
#>
#> $diagnostic_result
#> [1] "no PH diagnostic flag at alpha=.05"A concise manuscript formulation is:
Gaze latency was analysed as a right-censored time-to-event outcome. Trials in which the target AOI was not inspected before the valid observation window ended were retained as censored observations; unresolved gaze-quality trials were not reclassified as censoring. We fitted a Cox proportional-hazards model with participant-clustered uncertainty and reported hazard ratios with 95% confidence intervals. Proportional-hazards diagnostics and pre-specified AOI/event-definition sensitivity analyses were examined, with a Weibull AFT model reported as an alternative time-ratio estimand.
Adapt the wording to the actual detector, AOI, time origin, quality threshold, and repeated-effects structure.
Copyable manuscript template
Gaze latency was analysed as a right-censored time-to-event outcome. The target event was [EVENT] in [TARGET AOI], measured from [TIME ORIGIN]. Trials remained under observation until [OBSERVATION-WINDOW RULE]. Trials without the event at the end of a complete usable window were retained as right-censored observations; trials with unresolved event status or gaze quality below [QUALITY RULE] were not recoded as censoring. Events were defined using [DETECTOR/FIXATION RULE]. We fitted [MODEL FAMILY] with [REPEATED-PARTICIPANT STRUCTURE] and reported [HAZARD RATIOS/TIME RATIOS] with 95% confidence intervals. Diagnostics indicated [RESULT], and the substantive conclusion was [ROBUST/QUALIFIED/SENSITIVE] across the pre-specified AOI, event-definition, quality-threshold, and estimator sensitivity analyses.
For the results paragraph, report participants, trials, events, censored trials and percentage, review-required rows, the effect estimate with confidence interval, diagnostic result, and whether sensitivity branches changed the conclusion.
API map
Preparation and validation:
prepare_gaze_survival_data(),
validate_gaze_survival_data(),
summarise_gaze_censoring().
Estimation and models: estimate_gaze_survival(),
fit_gaze_cox_model(),
fit_gaze_mixed_cox_model(),
fit_gaze_aft_model(), predict_gaze_survival(),
estimate_gaze_latency_quantiles().
Diagnostics and sensitivity:
check_gaze_proportional_hazards(),
compare_gaze_survival_models(),
compare_gaze_survival_specifications().
Plots and reporting: plot_gaze_survival_curve(),
plot_gaze_cumulative_incidence(),
plot_gaze_hazard(),
plot_gaze_cox_diagnostics(),
tidy_gaze_survival_model(),
report_gaze_survival_model().