library(tidyverse)
library(lme4)
library(ggplot2)Replication of Jin, Hayward, & Cheung (2024) — Complete Composite Task
The original design for the complete composite task includes four trial-level factors:
Congruency: congruent vs. incongruent
Alignment: aligned vs. misaligned
Cue location: top vs. bottom
Same/Different (correct answer): same vs. different
One composite trial has four independent factors each with two levels — 2 (alignment) x 2 (congruency) x 2 (cue) x 2 (same/different) = 16 unique trial conditions. The task contained 384 total composite trials, created by generating 16 unique condition combinations (2 Congruency × 2 Alignment × 2 Cue × 2 Same/Different), each repeated 24 times, resulting in 24 trials per condition.
Setup
This part loads required packages.
Import Pilot Data
Here I load two pilot participants’ CSV files collected via Prolific.
d1 <- read_csv("data/compositeface_20251129_161517.csv")
d2 <- read_csv("data/compositeface_20251129_162056.csv")
raw <- bind_rows(d1, d2)
dim(raw)[1] 834 36
head(raw)# A tibble: 6 × 36
trial_frame item_width_mm item_height_mm item_width_px px2mm view_dist_mm
<chr> <dbl> <dbl> <dbl> <dbl> <dbl>
1 virtual_chinrest 85.6 54.0 422 4.93 720.
2 test_face NA NA NA NA NA
3 test_face NA NA NA NA NA
4 test_face NA NA NA NA NA
5 test_face NA NA NA NA NA
6 test_face NA NA NA NA NA
# ℹ 30 more variables: rt <dbl>, item_width_deg <dbl>, px2deg <dbl>,
# win_width_deg <dbl>, win_height_deg <dbl>, trial_type <chr>,
# trial_index <dbl>, plugin_version <chr>, time_elapsed <dbl>, Subject <chr>,
# Exp_code <chr>, Exp_name <chr>, CFVersion <chr>, isPavlovia <lgl>,
# Browser <chr>, Prolific_id <chr>, Trial_num <dbl>, Cue <chr>,
# Congruency <chr>, Alignment <chr>, SameDifferent <chr>, StimGroup <chr>,
# StudyFace <chr>, TestFace <chr>, Correct_response <dbl>, MaskFace <chr>, …
Filtering to Experimental Trials
The jsPsych data file contains instruction pages, fixation displays, masks, and other non-response rows. Here, I restrict the dataset to only composite decision trials, defined as “trial_type ==”image-keyboard-response”“. All condition variables are present (Congruent, Alignment, Cue, SameDifferent).
df <- raw %>%
filter(
trial_type == "image-keyboard-response",
!is.na(SameDifferent),
!is.na(Congruency),
!is.na(Alignment),
!is.na(Cue)
)
dim(df)[1] 832 36
head(df)# A tibble: 6 × 36
trial_frame item_width_mm item_height_mm item_width_px px2mm view_dist_mm
<chr> <dbl> <dbl> <dbl> <dbl> <dbl>
1 test_face NA NA NA NA NA
2 test_face NA NA NA NA NA
3 test_face NA NA NA NA NA
4 test_face NA NA NA NA NA
5 test_face NA NA NA NA NA
6 test_face NA NA NA NA NA
# ℹ 30 more variables: rt <dbl>, item_width_deg <dbl>, px2deg <dbl>,
# win_width_deg <dbl>, win_height_deg <dbl>, trial_type <chr>,
# trial_index <dbl>, plugin_version <chr>, time_elapsed <dbl>, Subject <chr>,
# Exp_code <chr>, Exp_name <chr>, CFVersion <chr>, isPavlovia <lgl>,
# Browser <chr>, Prolific_id <chr>, Trial_num <dbl>, Cue <chr>,
# Congruency <chr>, Alignment <chr>, SameDifferent <chr>, StimGroup <chr>,
# StudyFace <chr>, TestFace <chr>, Correct_response <dbl>, MaskFace <chr>, …
Cleaning & Set Up Variables
This part converts categorical variables to factors and ensures RT and Correct are numeric.
df <- df %>%
mutate(
Subject = factor(Subject),
Congruency = factor(Congruency, levels = c("congruent", "incongruent")),
Alignment = factor(Alignment, levels = c("aligned", "misaligned")),
Cue = factor(Cue, levels = c("top", "bot")),
SameDifferent = factor(SameDifferent, levels = c("same", "different")),
Correct = as.numeric(Correct),
RT = as.numeric(RT)
)Reaction Time Exclusions
Following the preregistered cleaning plan, RTs < 200 ms or > 3000 ms are removed.
df <- df %>% filter(RT > 200, RT < 3000)
nrow(df)[1] 810
Accuracy GLMM (Confirmatory Model)
This is the primary preregistered confirmatory model for accuracy. A logistic GLMM predicts correctness from Congruency, Alignment, and their interaction with a random intercept for subject. This tests the classic composite congruency x alignment effect.
acc_model <- glmer(
Correct ~ Congruency * Alignment + (1 | Subject),
data = df,
family = binomial,
control = glmerControl(optimizer = "bobyqa")
)
summary(acc_model)Generalized linear mixed model fit by maximum likelihood (Laplace
Approximation) [glmerMod]
Family: binomial ( logit )
Formula: Correct ~ Congruency * Alignment + (1 | Subject)
Data: df
Control: glmerControl(optimizer = "bobyqa")
AIC BIC logLik -2*log(L) df.resid
1029.2 1052.7 -509.6 1019.2 805
Scaled residuals:
Min 1Q Median 3Q Max
-1.7377 -1.0557 0.5755 0.8098 0.9472
Random effects:
Groups Name Variance Std.Dev.
Subject (Intercept) 2.904e-16 1.704e-08
Number of obs: 810, groups: Subject, 2
Fixed effects:
Estimate Std. Error z value Pr(>|z|)
(Intercept) 1.1051 0.1616 6.841 7.89e-12
Congruencyincongruent -0.9966 0.2142 -4.654 3.26e-06
Alignmentmisaligned -0.1607 0.2256 -0.712 0.476
Congruencyincongruent:Alignmentmisaligned 0.4742 0.3023 1.569 0.117
(Intercept) ***
Congruencyincongruent ***
Alignmentmisaligned
Congruencyincongruent:Alignmentmisaligned
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Correlation of Fixed Effects:
(Intr) Cngrnc Algnmn
Cngrncyncng -0.754
Algnmntmslg -0.716 0.540
Cngrncync:A 0.534 -0.709 -0.746
optimizer (bobyqa) convergence code: 0 (OK)
boundary (singular) fit: see help('isSingular')
Z-scoring RTs within Subject
RTs vary across subjects, so I recompute RT z-scores within each subject. This follows the analysis approach used in the original paper.
df <- df %>%
mutate(
RT = as.numeric(RT),
Subject = factor(Subject)
) %>%
group_by(Subject) %>%
mutate(RT_z = scale(RT)[,1]) %>%
ungroup()RT LMER (Secondary Model)
On correct trials only, I examine RTs using a linear mixed-effects model.
DV = RT_z
Fixed effects = Congruency, Alignment, and their interaction
Random intercept for subject
rt_model <- lmer(
RT_z ~ Congruency * Alignment + (1 | Subject),
data = df %>% filter(Correct == 1)
)
summary(rt_model)Linear mixed model fit by REML ['lmerMod']
Formula: RT_z ~ Congruency * Alignment + (1 | Subject)
Data: df %>% filter(Correct == 1)
REML criterion at convergence: 1421
Scaled residuals:
Min 1Q Median 3Q Max
-1.4109 -0.6830 -0.2596 0.3532 4.7813
Random effects:
Groups Name Variance Std.Dev.
Subject (Intercept) 0.0000 0.000
Residual 0.8538 0.924
Number of obs: 527, groups: Subject, 2
Fixed effects:
Estimate Std. Error t value
(Intercept) -0.28312 0.07446 -3.802
Congruencyincongruent 0.08328 0.11629 0.716
Alignmentmisaligned 0.37282 0.10711 3.481
Congruencyincongruent:Alignmentmisaligned -0.13382 0.16264 -0.823
Correlation of Fixed Effects:
(Intr) Cngrnc Algnmn
Cngrncyncng -0.640
Algnmntmslg -0.695 0.445
Cngrncync:A 0.458 -0.715 -0.659
optimizer (nloptwrap) convergence code: 0 (OK)
boundary (singular) fit: see help('isSingular')
Accuracy Plot
This shows group-level accuracy across the 2x2 Congruency x Alignment conditions. It provides a quick sanity check that the expected pattern (aligned-incongruent condition being the hardest) appears.
acc_summary <- df %>%
group_by(Congruency, Alignment) %>%
summarise(acc = mean(Correct))
ggplot(acc_summary, aes(Congruency, acc, fill = Alignment)) +
geom_col(position = "dodge") +
labs(title = "Pilot Accuracy by Condition",
y = "Accuracy") +
theme_minimal()RT Plot
Similar to above, but for mean reaction times among correct responses.
rt_summary <- df %>%
filter(Correct == 1) %>%
group_by(Congruency, Alignment) %>%
summarise(rt = mean(RT))
ggplot(rt_summary, aes(Congruency, rt, fill = Alignment)) +
geom_col(position = "dodge") +
labs(title = "Pilot RT by Condition",
y = "Reaction Time (ms)") +
theme_minimal()d’ Plot (Sensitivity)
To mirror the original paper’s signal detection analysis, this section computes d’ for each Subject x Congruency x Alignment condition.
Hits = correct “same” responses
FA = incorrect “same” responses
A loglinear correction is used to avoid infinite values.
compute_dprime <- function(hits, fas, n_hit, n_fa) {
# loglinear correction
hit_rate <- (hits + 0.5) / (n_hit + 1)
fa_rate <- (fas + 0.5) / (n_fa + 1)
dprime <- qnorm(hit_rate) - qnorm(fa_rate)
return(dprime)
}
dp <- df %>%
group_by(Subject, Congruency, Alignment) %>%
summarise(
hits = sum(Correct == 1 & SameDifferent == "same"),
fas = sum(Correct == 0 & SameDifferent == "different"),
n_hit = sum(SameDifferent == "same"),
n_fa = sum(SameDifferent == "different"),
dprime = compute_dprime(hits, fas, n_hit, n_fa),
.groups = "drop"
)dp_summary <- dp %>%
group_by(Congruency, Alignment) %>%
summarise(
mean_dp = mean(dprime),
se_dp = sd(dprime) / sqrt(n())
)
ggplot(dp_summary, aes(Congruency, mean_dp, fill = Alignment)) +
geom_col(position = "dodge") +
geom_errorbar(aes(ymin = mean_dp - se_dp,
ymax = mean_dp + se_dp),
width = 0.15,
position = position_dodge(0.9)) +
labs(title = "d′ by Congruency × Alignment",
y = "d′ (Sensitivity)") +
theme_minimal()