Inattention detection for long psychophysical tasks.
inough is an R package that detects episodes of participant inattention in trial-based experiments (200+ trials). It combines response-pattern heuristics for locating candidate regions with formal Welch t-tests (BH-FDR corrected) for statistical testing, and produces a self-contained HTML report for visual inspection.
From GitHub (recommended):
# install.packages("remotes")
remotes::install_github("pawlenartowicz/inough")From a local source clone:
install.packages("/path/to/inough", repos = NULL, type = "source")
# or, equivalently:
# R CMD INSTALL /path/to/inoughDependencies: lme4, ggplot2, patchwork, rlang, jsonlite
A pre-rendered HTML report (built from the bundled task_example dataset, 20 participants × ~250 trials each) is included at examples/example_report.html. To view rendered directly from GitHub:
The same report can be regenerated locally with:
library(inough)
data(task_example)
signals <- inough_signals(task_example,
correct = correct ~ stim + weight + orient + cue_type + block,
response = response ~ response,
id = "participant")
detected <- inough_detect(signals)
report(detected, file = "example_report.html")library(inough)
# 1. Extract signals — fits a probit GLMM on accuracy,
# computes response bias indicators and LZ complexity
signals <- inough_signals(
df = my_data,
correct = correct ~ Stim + Weight + Block,
response = response ~ answ,
id = "ID"
)
# 2. Detect inattention episodes
detected <- inough_detect(signals)
# 3. Inspect
print(detected)
summary(detected)
# 4. Get flagged trials for downstream filtering
flagged <- flags(detected)
clean_data <- my_data[!interaction(my_data$ID, seq_len(nrow(my_data))) %in%
interaction(flagged$id, flagged$trial_idx), ]
# 5. Generate interactive HTML report
report(detected, file = "inough_report.html")
# 6. Diagnostic plots for specific participants
plot(detected, id = "10001")| Parameter | Default | Description |
|---|---|---|
window_size |
3 | Half-width of rolling window (total = 2w+1 = 7 trials) |
sd_threshold |
2 | SDs above chance for |roll_resp| screening threshold |
window_weight |
"uniform" |
Window weighting: "uniform" or "triangular" |
min_chunk |
6 | Minimum chunk length (trials) to retain after merge |
lz_threshold |
0.2 | LZ complexity below this triggers whole-participant bail-out |
comparison |
"clean" |
t-test reference: "clean" (non-flagged trials) or "rest" |
The screening threshold is computed analytically: sd_threshold × sqrt(sum(w²)) / sum(w) where w are the window weights. For uniform weights this simplifies to sd_threshold / sqrt(2 × window_size + 1).
# Example: explicit control (these are the defaults)
detected <- inough_detect(
signals,
control = inough_control(
window_size = 3,
sd_threshold = 2,
window_weight = "uniform",
min_chunk = 6,
lz_threshold = 0.2,
comparison = "clean"
)
)| Parameter | Default | Description |
|---|---|---|
boundary_mode |
"heuristic" |
"heuristic" (default): end = fixed extension, start = walk back to first matching stereotype trial. "fixed": extend symmetrically (±w for uniform, ±⅔w for triangular). |
spurious |
TRUE |
Enable spurious-start accuracy trimming |
spurious_n |
6 | Number of trials at chunk start to inspect |
spurious_k |
NULL |
Explicit: flag if >= k correct out of spurious_n. When NULL, computed from spurious_n (see below). |
min_left |
7 | Minimum trials remaining after trimming |
When spurious_k is not set, it is computed as ceiling((0.5 + 1.5 × sqrt(0.25/n)) × n) (≈ 1.5 SE above chance for n = spurious_n).
# Example: override the spurious-trimming threshold
detected <- inough_detect(
signals,
heuristics = inough_heuristics(
spurious = TRUE,
spurious_k = 5,
spurious_n = 6,
min_left = 7
)
)The pipeline has three stages: bail-out for grossly disengaged participants, screening to locate candidate inattention regions using response-pattern heuristics, and formal testing with LZ-informed Bayesian local FDR.
Input data (trials x participants)
|
v
┌──────────────────────────────┐
│ Signal extraction │
│ Probit GLMM -> acc resids │
│ Lag-1 autocorrelation │
│ LZ complexity (per person) │
└──────────────────────────────┘
|
v
┌──────────────────────────────┐
│ Bail-out │
│ LZ < threshold -> flag all │
│ Acc <= chance+SE -> flag all │
└──────────────────────────────┘
| (passed)
v
┌──────────────────────────────┐
│ Stage 1: Screening │
│ Rolling |resp_lag1| mean │
│ Threshold -> candidate runs │
│ Merge + min-length filter │
│ Boundary extension │
└──────────────────────────────┘
|
v
┌──────────────────────────────┐
│ Heuristics (optional) │
│ Spurious accuracy trimming │
└──────────────────────────────┘
|
v
┌──────────────────────────────┐
│ Stage 2: Formal testing │
│ Welch t-test per chunk │
│ LZ-informed local FDR │
│ -> flagged episodes │
└──────────────────────────────┘
|
v
┌──────────────────────────────┐
│ HTML report │
│ Per-participant browser │
│ Diagnostic plots & export │
└──────────────────────────────┘
inough_signals() prepares two independent behavioral signals from raw trial data:
- Accuracy residuals — Pearson residuals from a probit GLMM (
lme4::glmer) with task-specific design predictors and per-participant random effects. These residuals capture trial-level deviations from expected accuracy, controlling for stimulus difficulty and learning. - Lag-1 response indicator — +1 if the participant repeated their previous response, -1 if they switched. Summarized per participant as LZ complexity (Lempel-Ziv, entropy-corrected and permutation-normalized), which indexes response stereotypy: low LZ indicates repetitive button-pressing, high LZ indicates random-like responding.
Before running the trial-level pipeline, participants whose data is too degraded for meaningful analysis are flagged wholesale:
- LZ < threshold — response sequence too stereotyped (e.g., pressing the same button repeatedly)
- Accuracy <= chance + SE — performing at or below chance across the session
These participants skip the screening and testing stages; all their trials are flagged with flag_type = "bailout".
Response stereotypy locates where to look. A centered rolling window computes the weighted mean of lag-1 response indicators. Regions where |rolling mean| > sd_threshold × window_SD are candidate inattention zones — the participant is repeating or alternating responses beyond what chance predicts.
Candidates are merged if overlapping, filtered by minimum length, then extended via the configured boundary mode:
- Fixed (default): extend ±
window_size(uniform) or ±⅔window_size(triangular) - Heuristic: end boundary uses fixed extension; start boundary walks backwards contiguously, following resp_lag1 trials that match the chunk's stereotype direction (sign of mean rolling response)
When enabled, inspects the first N trials of each extended chunk. If accuracy is suspiciously high (>= k/n correct), those trials are excluded from the t-test but kept in the chunk boundary for reporting. This prevents "lucky starts" from masking an accuracy drop.
Each candidate chunk gets a one-sided Welch t-test on accuracy residuals (chunk vs comparison set), asking: is accuracy inside this chunk significantly lower than outside?
Local FDR. Each chunk receives a posterior probability of being a true null (no real inattention) via Bayesian local FDR:
lfdr = pi0 * f0(t) / [ pi0 * f0(t) + pi1 * f1(t) ]
- pi0, pi1 — LZ-informed prior.
pi0 = min(1, LZ),pi1 = 1 - pi0. Low LZ (stereotyped) gives a permissive prior; high LZ (random-like) gives a skeptical prior. - f0 — null density: central t-distribution at observed df
- f1 — alternative density: non-central t with noncentrality
ncp = -(acc_clean - 0.5) / SE_theory, encoding the expectation that inattentive responding drops to chance (50%)
A chunk is flagged when lfdr < fdr_alpha. BH-adjusted p-values are also computed for reference.
report() generates a self-contained HTML file with:
- Participant browser with color-coded status (bail-out / flagged / clean)
- Sortable and filterable participant list
- Per-participant diagnostic plots (accuracy residuals, rolling response signal, flagged regions)
- Inline chunk table with all tested chunks (significant highlighted, non-significant dimmed), lfdr, effect size, spurious trimming info
- Summary statistics and distribution plots
- CSV export of all flagged chunks
report() accepts an optional custom_plot argument that adds one extra
points-only panel to the per-participant view (after the four built-in
diagnostic panels). Useful for showing a per-trial covariate alongside
inattention signals — e.g. confidence ratings, PAS, RT, stimulus contrast.
custom_plot is a list with three fields:
data— a data frame with columnsid,trial_idx,value.trial_idxis 1-based within participant and must match the trial order fed toinough_signals()(i.e., the row order in the input data frame, after any filtering applied before callinginough_signals). Missing rows orNAvalues are rendered as gaps.title— string shown as the panel label (left column).description— string shown as the panel blurb (left column).
# Example: PAS (1-4 scale) as a custom panel
pas_df <- data.frame(
id = my_data$ID,
trial_idx = ave(seq_len(nrow(my_data)), my_data$ID, FUN = seq_along),
value = my_data$PAS
)
report(
detected,
file = "report.html",
custom_plot = list(
data = pas_df,
title = "PAS rating",
description = "Perceptual Awareness Scale (1 = no experience, 4 = clear)."
)
)inough_detect() returns an inough_detected object containing:
| Field | Description |
|---|---|
$signals |
The input inough_signals object (trial + participant data) |
$bailout |
Data frame of bailed-out participants (id, reason, lz_value, accuracy) |
$candidates |
Raw candidate regions from screening |
$chunks |
Merged/filtered/extended chunks passed to testing |
$results |
All tested chunks with t_stat, df, p_raw, p_adj, lfdr, effect_size, significant |
$control |
The inough_control parameters used |
$heuristics |
The inough_heuristics parameters used |
$fdr_alpha |
The FDR threshold used |
flags() extracts a tidy data frame of all flagged trials (both bail-out and chunk-based), suitable for dplyr::anti_join() or similar filtering.
GPL (>= 3)