Skip to contents

Importing and visualising the data

Below we import and visualise the cross-temporal decoding generalisation data discussed in Nalborczyk & Bürkner (2025, Appendix A).

library(neurogam)
library(ggplot2)
library(scico)
library(dplyr)

# retrieving some EEG data
data(timegen_data)
head(timegen_data)
#>   train_time test_time       auc participant
#> 1       -0.2      -0.2 0.5639503           1
#> 2       -0.2      -0.2 0.5967508           2
#> 3       -0.2      -0.2 0.4648438           3
#> 4       -0.2      -0.2 0.4935377           4
#> 5       -0.2      -0.2 0.5228509           5
#> 6       -0.2      -0.2 0.4771995           6

# plotting the cross-temporal generalisation matrix
timegen_data |>
    summarise(auc = mean(auc), .by = c(train_time, test_time) ) |>
    ggplot(aes(x = train_time, y = test_time, fill = auc) ) +
    geom_tile() +
    geom_vline(xintercept = 0, linetype = 2, linewidth = 0.5) +
    geom_hline(yintercept = 0, linetype = 2, linewidth = 0.5) +
    geom_abline(slope = 1, intercept = 0, linetype = 2, linewidth = 0.5) +
    scale_x_continuous(expand = c(0, 0) ) +
    scale_y_continuous(expand = c(0, 0) ) +
    coord_fixed() +
    theme_bw() +
    scico::scale_fill_scico(palette = "vik", midpoint = 0.5, name = "AUC") +
    labs(x = "Testing time (s)", y = "Training time (s)")

Fitting the model

We fit a 2D BGAMM (with a varying intercept, but no varying smooth) to identify clusters with above-chance decoding accuracy. Such models are much slower than 1D temporal models, we recommend running less chains and using within-chain parallelisation (as shown below with threads = threading(threads = 4)) We also recommend to start with simple models (e.g., low kvalue, no varying effect) to be able to diagnose potential issues rapidly. For more details on such models, see for instance Pedersen et al. (2019).

library(brms)

# fitting a BGAMM with two temporal dimensions
timegen_gam <- brm(
    formula = auc ~ t2(train_time, test_time, bs = "tp", k = 25) + (1 | participant),
    data = timegen_data,
    warmup = 1000,
    iter = 3000,
    chains = 2,
    cores = 2,
    backend = "cmdstanr",
    threads = threading(threads = 4),
    stan_model_args = list(stanc_options = list("O1") )
    )

Identifying clusters

The previous model is equivalent to the following testing_through_time() call. This model takes around 15 hours on 8 parallel apple M4 cores.

# we compute the smallest effect size of interest (SESOI) as the baseline
# (i.e., before stimulus onset) upper 95% quantile of AUC values
baseline_upper_quantile <- timegen_data %>%
    filter(train_time < 0 & test_time < 0) %>%
    summarise(quantile(x = auc, probs = 0.95) ) %>%
    pull()

# fitting the BGAMM to identify clusters
results <- testing_through_time(
    # cross-temporal generalisation data
    data = timegen_data,
    # participant column
    participant_id = "participant",
    # AUC column
    outcome_id = "auc",
    # testing against chance level
    predictor_id = NA,
    # defining training and testing times
    time_id = c("train_time", "test_time"),
    # basis dimension
    kvalue = 25,
    # only includes a per-participant varying intercept (no varying smooth)
    multilevel = "summary",
    use_se = FALSE,
    varying_smooth = FALSE,
    # defining chance level for AUC values
    # chance_level = 0.5,
    # defining chance level + SESOI (upper AUC values during baseline)
    chance_level = baseline_upper_quantile,
    # threshold on posterior odds
    threshold = 10,
    # testing whether AUC is above chance
    threshold_type = "above",
    # number of warmup iterations
    warmup = 1000,
    # total number of iterations (per chain)
    iter = 3000,
    # number of MCMCs
    chains = 2,
    # number of parallel cores
    cores = 2,
    # using with-chain parallelisation (needs 2x4 parallel cores)
    threads = brms::threading(threads = 4),
    # increasing max_treedepth
    stan_control = list(max_treedepth = 15)
    )

The results can be summarised and visualised using the dedicated print(), summary(), and plot() methods.

# print a summary table ("value" represents the posterior odds)
print(results)
#> 
#> ==== Time-resolved GAM results ================================
#> 
#> Clusters found: 2
#> 
#>      sign id onset_time1 onset_time2 offset_time1 offset_time2 span_time1
#>  positive  1        0.06        0.08         0.78         0.40       0.72
#>  positive  2        0.92        0.10         1.00         0.14       0.08
#>  span_time2 n_points value_min value_max
#>        0.32      317     10.08  4000.000
#>        0.04        9     13.44   332.333
#> 
#> =================================================================

# for available colour palettes, see scico::scico_palette_show()
plot(
    x = results,
    palette = "vik", midpoint = 0.5,
    axes_labels = c("Training time (s)", "Testing time (s)")
    )