--- title: Building Event Models description: Comprehensive guide to event model specification with HRF bases and parametric modulators. output: rmarkdown::html_vignette: toc: yes toc_depth: 2 params: family: lapis preset: adobe base_size: 13 content_width: 80 vignette: | %\VignetteIndexEntry{Building Event Models} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} css: albers.css resource_files: - albers.css - albers.js includes: in_header: |- --- ```{r setup, include=FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.align = "center", fig.retina = 2, out.width = "100%", fig.width = 7, fig.asp = 0.618, message = FALSE, warning = FALSE ) set.seed(123) old_opts <- options(pillar.sigfig = 7, width = 80) old_theme <- ggplot2::theme_get() suppressPackageStartupMessages({ library(fmridesign) library(fmrihrf) library(dplyr) library(ggplot2) library(tidyr) library(plotly) }) ggplot2::theme_set(ggplot2::theme_minimal(base_size = params$base_size)) ``` ```{r content-width, echo=FALSE, results='asis'} cat(sprintf('', params$content_width)) ``` ## Introduction: Modeling Task Effects An **event model** describes the expected BOLD signal changes related to experimental events (stimuli, conditions, responses, etc.). It forms the core of the task-related component of an fMRI General Linear Model (GLM). `fmridesign` primarily uses the `event_model()` function to create these models. It takes experimental design information (event onsets, conditions, durations) and combines it with Hemodynamic Response Function (HRF) specifications to generate the task regressors. This vignette focuses on the common formula-based interface for `event_model()`. There's also a list-based interface (`event_model.list`) and a more programmatic `create_event_model()` function for advanced use cases. ## Quick HRF Primer The hemodynamic response function (HRF) maps brief neural events to predicted BOLD signal changes via convolution. In practice, `event_model()` builds task regressors by convolving an event train with a chosen HRF or with a set of HRF basis functions. An HRF can be a single “canonical” shape (e.g., SPMG1) or a multi-basis set that allows the response shape to vary (e.g., SPMG2/3, tent/FIR, B‑splines). Multi-basis models produce multiple columns per condition, one for each basis function. Designs can use stick functions (zero duration) or boxcars (non‑zero duration) before convolution—choose this based on your experimental timing. HRF definitions and generators live in the `fmrihrf` package. See `fmrihrf::gen_hrf()` and the built‑in HRFs such as `fmrihrf::HRF_SPMG1`, `fmrihrf::HRF_SPMG2`, `fmrihrf::HRF_SPMG3`, `fmrihrf::HRF_GAMMA`, `fmrihrf::HRF_GAUSSIAN`, and `fmrihrf::HRF_BSPLINE` for details. ### Timing and Sampling The `sampling_frame(TR, blocklens)` defines the time grid for your design (one row per scan, grouped by runs). Regressors are generated on this grid. When events have non‑zero duration, the convolution uses an internal time step to accurately approximate the overlap of events and the HRF; `fmrihrf` handles interpolation/upsampling under the hood so you don’t need to micromanage it for typical use cases. ## A Simple Single-Factor Design (Single Run) Consider a basic design with four stimulus types (*face*, *scene*, *tool*, *object*), each presented 4 times for 2s, separated by a variable ISI (4-7s). The total scan duration is 70 TRs (TR=2s). ```{r setup_simple_design} TR <- 2 cond <- c("face", "scene", "tool", "object") NSTIM <- length(cond) * 4 # Construct the design table set.seed(123) # for reproducibility simple_design <- data.frame( stim = factor(sample(rep(cond, 4))), ISI = sample(10:20, NSTIM, replace = TRUE), # Increased ISI range for better spacing run = rep(1, NSTIM), trial = factor(1:NSTIM) ) # Calculate onsets (cumulative sum of duration (2s) + ISI) simple_design$onset <- cumsum(c(0, simple_design$ISI[-NSTIM] + 2)) # Define the sampling frame (temporal structure of the scan) sframe_single_run <- sampling_frame(blocklens = 140, TR = TR) head(simple_design) ``` Now, we create the `event_model`. The formula `onset ~ hrf(stim)` specifies that: * `onset` is the column in `simple_design` containing event start times. * `hrf(stim)` defines a term where the `stim` factor levels determine the conditions. Each level will be convolved with the default HRF (`HRF_SPMG1`). The `block = ~ run` argument links events to scanning runs (here, just one run). ```{r create_simple_model} emodel_simple <- event_model(onset ~ hrf(stim), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) # Print the model summary print(emodel_simple) ``` ## Visualizing the Event Model We can plot the generated regressors using `plot()` (which uses `ggplot2`) or `plotly()` for an interactive version. ```{r plot_simple_model, fig.alt="Event model regressors for a simple single-factor design plotted over time."} # Static plot (ggplot2) plot(emodel_simple) # Interactive plot via plotly (skipped in non-interactive builds) if (interactive()) { plotly::ggplotly(plot(emodel_simple)) } ``` The plot shows the predicted BOLD timecourse for each condition (`stim` level) convolved with the default HRF. ## Design with Multiple Runs Let's extend the design to two runs. ```{r setup_multi_block} # Construct a design table with two runs design_list <- lapply(1:2, function(run_idx) { df <- data.frame( stim = factor(sample(rep(cond, 4))), ISI = sample(10:20, NSTIM, replace = TRUE), # Increased ISI range for better spacing run = rep(run_idx, NSTIM) ) df$onset <- cumsum(c(0, df$ISI[-NSTIM] + 2)) df }) design_multi_run <- bind_rows(design_list) # Sampling frame for two runs of 140s each sframe_multi_run <- sampling_frame(blocklens = c(140, 140), TR = TR) head(design_multi_run) ``` The `event_model` call remains the same, but now `block = ~ run` correctly maps events to their respective runs based on the `run` column and the `sampling_frame`. ```{r create_multi_block_model, fig.alt="Event model regressors across multiple runs plotted over time."} emodel_multi_run <- event_model(onset ~ hrf(stim), data = design_multi_run, block = ~ run, sampling_frame = sframe_multi_run) print(emodel_multi_run) # Plot without faceting - the plot method will handle multiple blocks plot(emodel_multi_run) ``` ## Two-Factor Design Now consider a design crossing stimulus type (`stim`) with task instruction (`task`: attend vs. ignore). ```{r setup_two_factor} cond1 <- c("face", "scene", "tool", "object") cond2 <- c("attend", "ignore") comb <- expand.grid(stim = cond1, task = cond2) NSTIM_TF <- nrow(comb) * 4 # 8 conditions * 4 reps per run # Design for two runs design_two_factor_list <- lapply(1:2, function(run_idx) { ind <- sample(rep(1:nrow(comb), length.out = NSTIM_TF)) df <- data.frame( stim = factor(comb$stim[ind]), task = factor(comb$task[ind]), ISI = sample(6:15, NSTIM_TF, replace = TRUE), # Increased ISI range for better spacing run = rep(run_idx, NSTIM_TF) ) df$onset <- cumsum(c(0, df$ISI[-NSTIM_TF] + 2)) df }) design_two_factor <- bind_rows(design_two_factor_list) # Sampling frame for two runs, potentially longer sframe_two_factor <- sampling_frame(blocklens = c(200, 200), TR = TR) head(design_two_factor) ``` The formula `onset ~ hrf(stim, task)` automatically creates regressors for the interaction of `stim` and `task`. The term tag will default to `stim_task`. ```{r create_two_factor_model, fig.alt="Interaction term regressors from a two-factor design plotted over time."} emodel_two_factor <- event_model(onset ~ hrf(stim, task), data = design_two_factor, block = ~ run, sampling_frame = sframe_two_factor) print(emodel_two_factor) # Column names will be like stim_task_stim.face_task.attend # Plotting all interaction terms can be busy; consider plotly plot(emodel_two_factor) if (interactive()) { plotly::ggplotly(plot(emodel_two_factor)) } ``` ## Amplitude Modulation (Parametric Regressors) We can model how a continuous variable (like reaction time, RT) modulates the amplitude of the BOLD response. This is done by including the modulating variable within the `hrf()` call. ```{r setup_pmod} # Use the simple single-run design and add a simulated RT column simple_design$RT <- rnorm(nrow(simple_design), mean = 700, sd = 100) # It's often recommended to center parametric modulators simple_design$RT_centered <- scale(simple_design$RT, center = TRUE, scale = FALSE)[,1] head(simple_design) ``` Here, `hrf(stim)` creates the main effect term (tag: `stim`), and `hrf(RT_centered)` creates the parametric modulator term (tag: `RT_centered`). ```{r create_pmod_model, fig.alt="Parametric modulator regressors (and main effects) plotted over time."} emodel_pmod <- event_model(onset ~ hrf(stim) + hrf(RT_centered), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) print(emodel_pmod) # Column names: stim_stim.face, ..., stim_stim.object, RT_centered_RT_centered # Plot the RT parametric modulator term (using its term tag) plot(emodel_pmod, term_name = "RT_centered") if (interactive()) { plotly::ggplotly(plot(emodel_pmod, term_name = "RT_centered")) } ``` ## Interaction Between Factors and Amplitude Modulation We can also model how a parametric modulator interacts with a factor. For example, does RT modulate the response differently for faces vs. scenes? The formula `hrf(stim, RT_centered)` creates separate RT modulators for each level of `stim`. Here, `hrf(stim)` is one term (tag: `stim`). The interaction `hrf(stim, RT_centered)` is a second term (tag: `stim_RT_centered`). ```{r create_pmod_interaction, fig.alt="Interaction of factor levels with parametric modulator plotted over time."} emodel_pmod_int <- event_model(onset ~ hrf(stim) + hrf(stim, RT_centered), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) print(emodel_pmod_int) # Columns: stim_stim.face, ..., stim_RT_centered_stim.face_RT_centered, ... # Plot the interaction term (using its term tag) plot(emodel_pmod_int, term_name = "stim_RT_centered") if (interactive()) { plotly::ggplotly(plot(emodel_pmod_int, term_name = "stim_RT_centered")) } ``` ## Specifying Different HRFs By default, `hrf()` uses the SPM canonical HRF (`HRF_SPMG1`). You can specify a different HRF basis for any term using the `basis` argument within the `hrf()` call in the model formula. Here are the main ways to specify the `basis`: 1. **By Name (String):** Use a recognized name for common HRFs: * `"spmg1"` (default), `"spmg2"`, `"spmg3"` * `"gaussian"`, `"gamma"` * `"bspline"` or `"bs"` (use `nbasis` to set number of splines) * `"tent"` (use `nbasis` to set number of tent functions) 2. **Using Pre-defined `HRF` Objects:** Pass an exported `HRF` object directly. Examples include `HRF_SPMG1`, `HRF_GAUSSIAN`, `HRF_SPMG2`, `HRF_SPMG3`, `HRF_BSPLINE`, `HRF_DAGUERRE`, `HRF_DAGUERRE_BASIS`. 3. **Using Custom Functions `f(t)`:** Provide your own function definition that accepts time `t` as the first argument. 4. **Using `gen_hrf()` Results:** Create a customized HRF using `gen_hrf()`, `hrf_blocked()`, `hrf_lagged()`, etc., and pass the resulting `HRF` object. **Discovering Available Options:** You can easily explore all available HRF types using the `list_available_hrfs()` function: ```{r list_hrfs} # List basic information about available HRFs list_available_hrfs() # Get detailed descriptions list_available_hrfs(details = TRUE) ``` For more details and examples of different HRF types, refer to the documentation for `hrf()` and the HRF-related vignettes in the `fmrihrf` package. Here are a few examples within the `event_model` context: ```{r specify_hrf, fig.alt="Regressors generated using different HRF bases (SPMG, Gaussian, spline/tent)."} # Example 1: Using basis name string "gaussian" # Term tags: "stim", "RT_centered" emodel_diff_hrf <- event_model(onset ~ hrf(stim, basis="spmg1") + hrf(RT_centered, basis="gaussian"), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) print(emodel_diff_hrf) plot(emodel_diff_hrf, term_name = "RT_centered") # Plot the Gaussian RT regressor # Example 2: Using a pre-defined HRF object (SPMG3) # Term tag: "stim" emodel_spmg3 <- event_model(onset ~ hrf(stim, basis=HRF_SPMG3), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) print(emodel_spmg3) # Note nbasis=3 for the hrf # Columns: stim_stim.face_b01, stim_stim.face_b02, ... plot(emodel_spmg3, term_name = "stim") # Plotting shows the 3 basis functions for one condition if (interactive()) { plotly::ggplotly(plot(emodel_spmg3)) # Better for exploring many conditions interactively } # Example 3: Using a custom function (simple linear ramp) # Term tag: "stim" linear_ramp_hrf <- function(t) { ifelse(t > 0 & t < 10, t/10, 0) } emodel_custom_hrf <- event_model(onset ~ hrf(stim, basis=linear_ramp_hrf), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) print(emodel_custom_hrf) plot(emodel_custom_hrf, term_name = "stim") # Example 4: Using gen_hrf() to create a lagged Gaussian # Term tag: "stim" lagged_gauss <- gen_hrf(hrf_gaussian, lag = 2, name = "Lagged Gaussian") emodel_gen_hrf <- event_model(onset ~ hrf(stim, basis = lagged_gauss), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) print(emodel_gen_hrf) plot(emodel_gen_hrf, term_name = "stim") ``` ### Per-Onset HRF Specification with `hrf_fun` For advanced use cases where different events require different HRF shapes, you can use the `hrf_fun` parameter. This accepts a generator function that receives event data and returns a list of HRF objects (one per onset). The generator is called *after* any subsetting, so it only sees the events that will actually be modeled. ```{r per_onset_hrf, fig.alt="Event model using per-onset HRF specification with different HRFs for different conditions."} # Generator function that assigns different HRFs based on condition cond_hrf_gen <- function(event_data) { lapply(seq_len(nrow(event_data)), function(i) { if (event_data$stim[i] == "face") { HRF_SPMG1 } else { HRF_GAMMA } }) } emodel_per_onset <- event_model( onset ~ hrf(stim, hrf_fun = cond_hrf_gen), data = simple_design, block = ~ run, sampling_frame = sframe_single_run ) print(emodel_per_onset) ``` The `hrf_fun` generator receives a data frame containing `onset`, `duration`, `blockid`, and any term variables. This enables use cases like: - **Duration-based HRFs**: Use `boxcar_hrf_gen()` to create boxcar HRFs based on event duration - **Weighted sub-event HRFs**: Use `weighted_hrf_gen()` for events with sub-onset timing - **Formula syntax**: Reference a column containing HRF objects directly with `hrf_fun = ~my_hrf_column` ```{r boxcar_example, eval=exists("hrf_boxcar", envir=asNamespace("fmrihrf"))} # Create a design with varying event durations boxcar_design <- data.frame( onset = c(0, 20, 45, 70), stim = factor(c("A", "B", "A", "B")), duration = c(2, 4, 3, 5), # Varying durations run = 1 ) emodel_boxcar <- event_model( onset ~ hrf(stim, hrf_fun = boxcar_hrf_gen()), data = boxcar_design, durations = boxcar_design$duration, block = ~ run, sampling_frame = sframe_single_run ) print(emodel_boxcar) ``` ### Advanced: Parametric Modulator with FIR HRF and Windowed F-contrast Sometimes a parametric modulator is modeled with a multi‑basis HRF (e.g., FIR) to allow more flexible timing. In that case, the modulator produces multiple columns (one per basis bin). You can form an omnibus F‑test across a window of bins (e.g., around the expected peak) or across all bins. ```{r pm_fir_fcontrast, fig.alt="Design with RT parametric modulator modeled using an FIR basis; demonstration of constructing a windowed F-contrast across FIR bins."} # Toy design with a continuous RT modulator set.seed(42) n <- 60 des_pm <- data.frame( onset = sort(runif(n, 0, 260)), RT = scale(runif(n, 0.4, 0.9))[,1], run = 1 ) sframe_pm <- sampling_frame(blocklens = 140, TR = 2) # Model RT with an FIR basis (10 bins) emod_rt_fir <- event_model( onset ~ hrf(RT, basis = "fir", nbasis = 10), data = des_pm, block = ~ run, sampling_frame = sframe_pm ) # Identify the RT columns in the full design matrix dm <- design_matrix(emod_rt_fir) idx <- term_indices(dm) # maps term tags -> column indices rt_cols <- idx[["RT"]] # columns for the RT FIR term (b01..b10) colnames(dm)[rt_cols][1:5] # Build a windowed F-contrast over FIR bins 3:5 (peak region example) window <- 3:5 win_cols <- rt_cols[window] C_F <- matrix(0, nrow = ncol(dm), ncol = length(win_cols)) for (j in seq_along(win_cols)) C_F[win_cols[j], j] <- 1 colnames(C_F) <- paste0("RT_b", sprintf("%02d", window)) # Validate (treat `dm` as X); this reports an F-type contrast because C has multiple columns validate_contrasts(dm, weights = C_F) # If you instead want a single t-contrast averaging the window, use 1/|window| weights C_avg <- matrix(0, nrow = ncol(dm), ncol = 1) C_avg[win_cols, 1] <- 1/length(win_cols) colnames(C_avg) <- "RT_peak_avg" validate_contrasts(dm, weights = C_avg) ``` This pattern also works for other multi‑basis choices (e.g., SPMG2/3): build a contrast matrix that selects the columns for the modulator’s basis functions and use `validate_contrasts()` to confirm the contrast structure before fitting downstream. ## Trialwise Models for Beta-Series Analysis To model each trial individually (e.g., for beta-series correlation), use `trialwise()` in the formula. This creates a separate regressor for every single event specified by the `onset` variable. ```{r create_trialwise, fig.alt="Trialwise regressors (one per trial) plotted over time with optional mean column."} emodel_trialwise <- event_model(onset ~ trialwise(), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) print(emodel_trialwise) # Term tag: "trialwise" # Columns: trialwise_trial.1, trialwise_trial.2, ... # Plotting trialwise models can be very dense! # It generates one condition per trial. Use label_mode/compactness controls. plot(emodel_trialwise, label_mode = "none") if (interactive()) { plotly::ggplotly(plot(emodel_trialwise, label_mode = "compact")) # Use plotly to explore interactively } ``` ## Covariates (Non-convolved Regressors) Sometimes you want to include scan-by-scan regressors that should not be convolved with an HRF (e.g., motion, physiology). Use `covariate()` within the formula. The covariate matrix must have one row per scan (sum of `blocklens`). Normally, one would add `motion` regressors to the `baseline_model` but we use it as an illustrative example. ```{r covariates_example} # Create covariates aligned to the sampling frame n_scans <- sum(blocklens(sframe_single_run)) motion <- data.frame(mx = rnorm(n_scans), my = rnorm(n_scans)) # Build a model with both convolved events and non-convolved covariates emodel_cov <- event_model(onset ~ hrf(stim) + covariate(mx, my, data = motion, id = "motion"), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) print(emodel_cov) # Inspect columns; motion terms are added as-is head(colnames(design_matrix(emodel_cov))) ``` Note: If the covariate rows do not match the number of scans, construction will error with a helpful message. ## Accessing Model Components Once an `event_model` is created, you can extract its components: ```{r access_components} # List the terms in the model (names are now term tags) terms_list <- terms(emodel_pmod_int) print(names(terms_list)) # Expected: "stim", "stim_RT_centered" # Get all condition names (full column names) conds <- conditions(emodel_pmod_int) # Example: stim_stim.face, stim_stim.scene, ..., # stim_RT_centered_stim.face_RT_centered, ... cat("\nFirst 6 column names:", head(colnames(design_matrix(emodel_pmod_int)), 6), "...\n") # Extract the full design matrix dmat_events <- design_matrix(emodel_pmod_int) cat("\nEvent design matrix dimensions:", dim(dmat_events), "\n") head(dmat_events[, 1:6]) # Contrast weights and F-contrasts can also be extracted (see contrasts vignette) contrast_weights(emodel_pmod_int) Fcontrasts(emodel_pmod_int) ``` ## Visualizing the Design Matrix and Correlations Two functions help visualize the structure of the event design matrix: * `design_map`: Shows the matrix values as a heatmap. * `correlation_map`: Shows the correlation between regressors. ```{r matrix_viz, fig.alt="Heatmap visualization of event-model design matrix columns across scans.", fig.width=12, fig.height=6, echo=FALSE} # Heatmap of the design matrix for the 2-factor model (suppress dense axis labels for readability) design_map(emodel_two_factor, rotate_x_text = FALSE) + labs(title = "Design Matrix Heatmap (Two-Factor Model)") + theme(axis.text.x = element_blank(), axis.text.y = element_blank()) # Correlation map for the same model (hide axis labels to emphasize the matrix) correlation_map(emodel_two_factor, rotate_x_text = FALSE) + labs(title = "Regressor Correlation Map (Two-Factor Model)") + theme(axis.text.x = element_blank(), axis.text.y = element_blank()) ``` Note: Axis labels are hidden above to keep the heatmaps legible with many regressors. When exploring smaller models interactively, set `rotate_x_text = TRUE` (and remove the `theme(...)` line) to show labels. This `event_model` represents the task-related part of the fMRI model. In practice, you will combine it with a `baseline_model` (drift/intercepts/nuisance) to form a full design matrix; downstream GLM fitting happens in analysis packages (e.g., fmrireg). ## Contrasts Quickstart You can attach contrasts directly to `hrf()` terms. Here are two common patterns. ```{r contrasts_quickstart} # Pairwise contrast between two stimulus levels con_face_vs_scene <- pair_contrast(~ stim == "face", ~ stim == "scene", name = "face_vs_scene") emodel_con <- event_model(onset ~ hrf(stim, contrasts = contrast_set(con_face_vs_scene)), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) # List available contrast specs and weights contr_specs <- contrasts(emodel_con) cw <- contrast_weights(emodel_con) names(cw) # One-way contrast (main effect over levels of a factor) con_main_stim <- oneway_contrast(~ stim, name = "main_stim") emodel_con2 <- event_model(onset ~ hrf(stim, contrasts = contrast_set(con_main_stim)), data = simple_design, block = ~ run, sampling_frame = sframe_single_run) fcons <- Fcontrasts(emodel_con2) names(fcons) ``` ## Combining Event and Baseline Designs To build a full design matrix, bind the event regressors with the baseline regressors. ```{r combine_designs} # Baseline model for the same sampling frame bmodel <- baseline_model(basis = "poly", degree = 5, sframe = sframe_two_factor) # Combine columns (order can matter downstream; keep consistent) DM_full <- dplyr::bind_cols(design_matrix(emodel_two_factor), design_matrix(bmodel)) dim(DM_full) colnames(DM_full)[1:8] ``` Downstream GLM fitting occurs in analysis packages (e.g., fmrireg), but fmridesign provides consistent, inspectable design matrices you can feed into those tools. ```{r cleanup, include=FALSE} options(old_opts) ggplot2::theme_set(old_theme) ```