OSCR

Building bridges between brain and behavior: An open-source toolbox for joint modeling with fMRI.

Code ↔ Paper

15 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 15 matches
  1. [1] § Application › Convolving with the HRF ↔ R/MRI.R, lines 158–216 · score 0.79 · hrf_model, convolve_design_matrix, high_pass_model, spm, cosine, timeseries
  2. [2] § Application › Convolving with the HRF ↔ R/MRI.R, lines 158–216 · score 0.76 · convolved design matrix, HRF model, high pass filter, spm, cosine, mri
  3. [3] § Workflow › Design specification ↔ R/MRI.R, lines 380–464 · score 0.74 · cosine basis functions, low frequency drifts, high_pass_model, polynomial, filter
  4. [4] § Cognitive Modeling ↔ R/model_RDM.R, lines 274–358 · score 0.73 · response threshold, evidence accumulation, cognitive models, speeded, drift rate, respond
  5. [5] § Application › Model criticism ↔ R/plot_data.R, lines 1018–1058 · score 0.69 · Defective cumulative distributions, posterior predictive CDF, credible intervals, aggregating
  6. [6] § Workflow › Design specification ↔ R/MRI.R, lines 1131–1252 · score 0.66 · high_pass_model, high pass filtering, poly, design matrix, regressors
  7. [7] § Workflow › Design specification ↔ R/MRI.R, lines 218–269 · score 0.65 · double gamma, hrf_model, Glover, SPM, onset
  8. [8] § Cognitive Modeling ↔ R/model_LBA.R, lines 130–215 · score 0.64 · response threshold, evidence accumulation models, drift rate, respond, latent, stimulus
  9. [9] § Other Considerations › Hypothesis tests for correlations ↔ R/s3_funcs.R, lines 784–810 · score 0.64 · Savage Dickey ratio, Bayes factors, hypothesis, posterior, model
  10. [10] § Application › Model criticism ↔ R/plot_data.R, lines 216–256 · score 0.63 · plot_cdf, defective_factor, post_predict, fun
  11. [11] § Application › Convolving with the HRF ↔ R/MRI.R, lines 567–616 · score 0.63 · design_fmri, design_matrix, MRI_AR1, model
  12. [12] § Other Considerations › Hypothesis tests for correlations ↔ R/bridge_sampling.R, lines 187–276 · score 0.58 · Bridge sampling, Bayes factors, mathematical, selectively, posterior, model
  13. [13] § Application › Convolving with the HRF ↔ R/MRI.R, lines 775–860 · score 0.58 · plot_design_fmri, convolved design matrix, TRs, event
  14. [14] § Application › Prior specification ↔ R/MRI.R, lines 699–770 · score 0.55 · MRI_AR1, standard deviation, rho, transformed, sd, log
  15. [15] § Application › Model estimation ↔ R/design.R, lines 1419–1486 · score 0.54 · sampled_pars, par_names, joint design, grepl, Model

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

R · 1,304 lines · 51 KB · GPL-3.0 · 8 matches

  1. apply_contrasts <- function(events, contrast = NULL, cell_coding = FALSE, remove_intercept = TRUE, levels = NULL) {
  2. factor_name <- events$factor[1]
  3. colnames(events)[colnames(events) == "event_type"] <- factor_name
  4. # If a contrast is provided, use it; otherwise let R default to its default contrasts.
  5. if(!is.null(contrast)){
  6. if(is.matrix(contrast)){
  7. if(!is.null(rownames(contrast))){
  8. events[[factor_name]] <- factor(events[[factor_name]], levels = rownames(contrast))
  9. } else {
  10. ## use levels provided
  11. events[[factor_name]] <- factor(events[[factor_name]], levels = levels)
  12. }
  13. stats::contrasts(events[[factor_name]], how.many = ncol(contrast)) <- contrast
  14. } else {
  15. events[[factor_name]] <- factor(events[[factor_name]], levels = levels)
  16. stats::contrasts(events[[factor_name]]) <- do.call(contrast, list(n = length(unique(events[[factor_name]]))))
  17. }
  18. } else {
  19. events[[factor_name]] <- factor(events[[factor_name]], levels = levels)
  20. # R's default contrasts will be used.
  21. }
  22. if(length(unique(events[[factor_name]])) == 1){
  23. design <- matrix(1, nrow = nrow(events))
  24. colnames(design) <- factor_name
  25. } else if(cell_coding){
  26. design <- model.matrix(as.formula(paste0("~ 0 + ", factor_name)), events)
  27. } else {
  28. design <- model.matrix(as.formula(paste0("~ ", factor_name)), events)
  29. colnames(design)[1] <- paste0(factor_name, "0")
  30. if(remove_intercept) design <- design[, -1, drop = FALSE]
  31. }
  32. events$factor <- NULL
  33. events_design <- cbind(design, events)
  34. long_events <- reshape(events_design,
  35. direction = "long",
  36. varying = colnames(design),
  37. v.names = "modulation",
  38. timevar = "regressor",
  39. times = colnames(design))
  40. rownames(long_events) <- NULL
  41. long_events <- long_events[long_events$modulation != 0, ]
  42. long_events <- long_events[, !(colnames(long_events) %in% c("id", factor_name))]
  43. long_events <- long_events[order(long_events$onset), ]
  44. return(long_events)
  45. }
  46. #' Reshape events data for fMRI analysis
  47. #'
  48. #' This function reshapes event data into a format suitable for fMRI analysis by
  49. #' converting specified event_types into separate event types with appropriate modulation values.
  50. #'
  51. #' @param events A data frame containing event information with required columns 'subjects', 'run', and 'onset'
  52. #' @param event_types A character vector of column names in the events data frame to be treated as event_types
  53. #' @param duration Either a single numeric value (applied to all event_types), a list with named elements
  54. #' corresponding to event_types, or a function that takes the events data frame and returns durations
  55. #' @param modulation Either a list with named elements corresponding to event_types, or a function that takes
  56. #' the events data frame and returns durations
  57. #' @return A data frame with columns 'subjects', 'onset', 'run', 'modulation', 'duration', and 'event_type'
  58. #' @export
  59. #' @examples
  60. #' # Create a simple events data frame
  61. #' events <- data.frame(
  62. #' subjects = rep(1, 10),
  63. #' run = rep(1, 10),
  64. #' onset = seq(0, 90, by = 10),
  65. #' condition = rep(c("A", "B"), 5),
  66. #' rt = runif(10, 0.5, 1.5),
  67. #' accuracy = sample(0:1, 10, replace = TRUE)
  68. #' )
  69. #'
  70. #' # Reshape with default duration
  71. #' reshaped1 <- reshape_events(events, event_types = c("condition", "accuracy"))
  72. #'
  73. #' # Reshape with custom duration for each event_type
  74. #' reshaped2 <- reshape_events(events,
  75. #' event_types = c("condition", "accuracy", "rt"),
  76. #' duration = list(condition = 0.5,
  77. #' accuracy = 0.2,
  78. #' rt = function(x) x$rt))
  79. reshape_events <- function(events, event_types, duration = 0.001, modulation = NULL){
  80. if(!(all(c("onset", "run", "subjects") %in% colnames(events)))){
  81. stop("Expected columns: subjects, duration, onset, run")
  82. }
  83. # First check if only 1 numeric entry is present
  84. if(length(duration) == 1 && !is.list(duration)){
  85. duration <- rep(duration, length(event_types))
  86. duration <- lapply(duration, function(x) return(x)) # and make it into a list
  87. } else if(is.list(duration) & any(names(duration) %in% event_types)){
  88. duration_tmp <- replicate(length(event_types), list(0.001)) # Fill in the default spike function
  89. for(i in 1:length(event_types)){
  90. if(any(names(duration) %in% event_types[i])){
  91. duration_tmp[[i]] <- duration[[event_types[i]]]
  92. }
  93. }
  94. duration <- duration_tmp
  95. } else if(length(duration) != length(event_types)){
  96. stop("Length of duration must be 1 or equal to the number of event_types")
  97. }
  98. if(!is.list(duration)){
  99. duration <- lapply(duration, function(x) return(x))
  100. }
  101. out <- list()
  102. for(i in 1:length(event_types)){
  103. fact <- event_types[i]
  104. tmp <- events[,c('subjects', 'run', 'onset', fact)]
  105. if(is.character(tmp[,fact]) || is.factor(tmp[,fact])){
  106. tmp[,fact] <- paste0(fact, "_", tmp[,fact])
  107. colnames(tmp)[4] <- "event_type"
  108. if(is.null(modulation[[fact]])){
  109. tmp$modulation <- 1
  110. } else{
  111. if(is.function(modulation[[fact]])){
  112. tmp$modulation <- modulation[[fact]](events)
  113. } else{
  114. tmp$modulation <- modulation[[fact]]
  115. }
  116. }
  117. } else{
  118. if(!is.null(modulation[[fact]])){
  119. if(is.function(modulation[[fact]])){
  120. tmp[,4] <- modulation[[fact]](events)
  121. } else{
  122. tmp[,4] <- modulation[[fact]]
  123. }
  124. }
  125. colnames(tmp)[4] <- "modulation"
  126. tmp$event_type <- fact
  127. }
  128. if(is.function(duration[[i]])){
  129. tmp$duration <- duration[[i]](events)
  130. } else{
  131. tmp$duration <- duration[[i]]
  132. }
  133. out[[fact]] <- tmp
  134. }
  135. out <- do.call(rbind, out)
  136. rownames(out) <- NULL
  137. out <- out[order(out$subjects, out$run, out$onset),]
  138. return(out)
  139. }
  140. map_MRI <- function(cur_draws){
  141. cur_draws[grepl("sd", rownames(cur_draws)),,] <- exp(cur_draws[grepl("sd", rownames(cur_draws)),,])
  142. cur_draws[grepl("rho", rownames(cur_draws)),,] <- pnorm(cur_draws[grepl("rho", rownames(cur_draws)),,])
  143. return(cur_draws)
  144. }
  145. #' Convolve Events with HRF to Construct Design Matrices
  146. #'
  147. #' This function convolves events with the HRF to construct design matrices for fMRI analysis.
  148. #'
  149. #' @param timeseries A data frame containing fMRI time series data with columns 'subjects', 'run', 'time', and at least one ROI column
  150. #' @param events A data frame containing event information with required columns `subjects`, `run`, `onset`, `duration`, `event_type`, and `modulation`
  151. #' @param factors A named list mapping factor names to event types
  152. #' @param contrasts A named list of contrast matrices for each factor
  153. #' @param covariates A character vector of event types to include as covariates
  154. #' @param add_constant A boolean specifying whether a 1 should be included to the design matrix post convolution
  155. #' @param hrf_model A character string specifying the HRF model to use ('glover', 'spm', 'glover + derivative', or 'spm + derivative')
  156. #' @param cell_coding A character vector of factor names to use cell coding for
  157. #' @param scale A boolean indicating whether to scale the design matrix.
  158. #' @param high_pass Logical indicating whether to apply high-pass filtering.
  159. #' Alternatively, specifying 'add' adds the regressors to the design matrix
  160. #' @param high_pass_model Character indicating which type of high-pass filtering to apply ('cosine', 'poly')
  161. #' @param cut_off A numeric value specifying the cutoff for the high-pass filter
  162. #'
  163. #' @return A list containing the design matrices
  164. #' @export
  165. #' @examples
  166. #' # Generate a simple example timeseries
  167. #' ts <- data.frame(
  168. #' subjects = rep(1, 100),
  169. #' run = rep(1, 100),
  170. #' time = seq(0, 99),
  171. #' ROI1 = rnorm(100)
  172. #' )
  173. #'
  174. #' # Generate example events
  175. #' events <- data.frame(
  176. #' subjects = rep(1, 4),
  177. #' run = rep(1, 4),
  178. #' onset = c(10, 30, 50, 70),
  179. #' duration = rep(0.5, 4),
  180. #' event_type = c("hard", "easy", "hard", "easy"),
  181. #' modulation = c(1, 1, 1, 1)
  182. #' )
  183. #'
  184. #' # Build design matrices
  185. #' design_matrices <- convolve_design_matrix(
  186. #' timeseries = ts,
  187. #' events = events,
  188. #' factors = list(difficulty = c("hard", "easy")),
  189. #' contrasts = list(difficulty = matrix(c(-1, 1)))
  190. #' )
  191. convolve_design_matrix <- function(timeseries, events, factors = NULL, contrasts = NULL,
  192. covariates = NULL, add_constant = TRUE,
  193. hrf_model = 'glover', cell_coding = NULL,
  194. scale = TRUE, high_pass = TRUE,
  195. high_pass_model = "cosine", cut_off = 1e-12) {
  196. if(!(all(c("onset", "run", "subjects", "modulation", "duration") %in% colnames(events)))){
  197. stop("Expected columns in events: subjects, duration, onset, run, modulation, duration")
  198. }
  199. if(!(all(c("run", "subjects", "time") %in% colnames(timeseries)))){
  200. stop("Expected columns in frame_times: run, subjects, time")
  201. }
  202. if(!setequal(unique(timeseries$subjects), unique(events$subjects))){
  203. stop("please make sure timeseries and events have the same subjects")
  204. }
  205. if(!is.null(cell_coding) && !cell_coding %in% names(factors)) stop("Cell coded factors must have same name as factors argument")
  206. # Define double-gamma hyperparameters
  207. if(grepl("glover", hrf_model)){
  208. undershoot <- 12 # When does the negative time point occur
  209. dispersion <- .9 # Width of positive gamma
  210. u_dispersion <- .9 # Width of negative gamma
  211. ratio <- .48 # Relative size of undershoot compared to overshoot
  212. } else{ # Different settings for SPM models
  213. undershoot <- 16
  214. dispersion <- 1
  215. u_dispersion <- 1
  216. ratio <- 1/6
  217. }
  218. if(!is.data.frame(events)) events <- do.call(rbind, events)
  219. subjects <- unique(timeseries$subjects)
  220. # Holders for filtered design matrix
  221. all_dms <- list()
  222. for(subject in subjects){
  223. ev_sub <- events[events$subjects == subject, ]
  224. ts_sub <- timeseries[timeseries$subjects == subject, ]
  225. runs <- unique(ts_sub$run)
  226. # Define subject-wise new design matrix and timeseries
  227. dms_sub <- vector("list", length = length(runs))
  228. for(run in runs){
  229. ev_run <- ev_sub[ev_sub$run == run, ]
  230. ts_run <- ts_sub[ts_sub$run == run, ]
  231. ev_tmp <- data.frame()
  232. for(fact in names(factors)){
  233. idx <- ev_run$event_type %in% factors[[fact]]
  234. if(!any(idx)) next
  235. ev_run$factor[idx] <- fact
  236. tmp <- ev_run[idx, ]
  237. new_tmp <- apply_contrasts(tmp, contrast = contrasts[[fact]],
  238. cell_coding = fact %in% cell_coding,
  239. levels = factors[[fact]])
  240. rownames(new_tmp) <- NULL
  241. new_tmp <- cbind(event_type = new_tmp$regressor, new_tmp)
  242. ev_tmp <- rbind(ev_tmp, new_tmp)
  243. }
  244. for(cov in covariates){
  245. idx <- ev_run$event_type %in% cov
  246. tmp <- ev_run[idx,c('event_type', 'subjects', 'run', 'onset', 'duration', 'modulation')]
  247. tmp$regressor <- cov
  248. ev_tmp <- rbind(ev_tmp, tmp)
  249. }
  250. ev_tmp <- ev_tmp[order(ev_tmp$onset), ]
  251. if((run == runs[1]) & (subject == subjects[1])){
  252. round_ev <- ev_tmp
  253. round_ev$duration <- round(scale(round_ev$duration))
  254. round_ev$modulation <- round(scale(round_ev$modulation))
  255. unq_idx <- !duplicated(round_ev[, !colnames(round_ev) %in% c("onset", "subjects")])
  256. print(ev_tmp[unq_idx,])
  257. }
  258. # Event_type was only included for printing
  259. ev_tmp <- ev_tmp[,colnames(ev_tmp) != "event_type"]
  260. dm <- construct_design_matrix(ts_run$time,
  261. events = ev_tmp,
  262. has_derivative = grepl("derivative", hrf_model),
  263. time_length = 32, # total hrf duration
  264. min_onset = -24, # Grid computation start time for oversampling
  265. oversampling = 50, # How many timepoints per tr
  266. onset = 0, # accounts for shifts in HRF
  267. delay = 6, # Time to peak of initial bump
  268. undershoot = undershoot,
  269. dispersion = dispersion,
  270. u_dispersion = u_dispersion,
  271. ratio = ratio,
  272. add_intercept = FALSE)
  273. if((run == runs[1]) & (subject == subjects[1]) & isTRUE(high_pass)){
  274. message("Filtering out high_pass noise, make sure you also use high_pass_filter(<timeseries>)")
  275. }
  276. if(!isFALSE(high_pass)){
  277. dm <- high_pass_filter(dm, high_pass_model, frame_times = ts_run$time, add=(high_pass == "add"))
  278. }
  279. if(add_constant) dm$constant <- 1
  280. dms_sub[[as.character(run)]] <- dm
  281. }
  282. dms_sub <- Filter(Negate(is.null), dms_sub)
  283. dm_cols <- unique(unlist(lapply(dms_sub, colnames), use.names = FALSE))
  284. dms_sub <- lapply(dms_sub, function(dm) {
  285. for(col in setdiff(dm_cols, colnames(dm))) dm[[col]] <- 0
  286. dm[, dm_cols, drop = FALSE]
  287. })
  288. dms_sub <- do.call(rbind, dms_sub)
  289. dms_sub[abs(dms_sub) < cut_off] <- 0
  290. rownames(dms_sub) <- NULL
  291. all_dms[[as.character(subject)]] <- dms_sub
  292. }
  293. dm_cols <- unique(unlist(lapply(all_dms, colnames), use.names = FALSE))
  294. all_dms <- lapply(all_dms, function(dm) {
  295. for(col in setdiff(dm_cols, colnames(dm))) dm[[col]] <- 0
  296. dm[, dm_cols, drop = FALSE]
  297. })
  298. if(scale){
  299. full_dm <- do.call(rbind, all_dms)
  300. maxs <- apply(full_dm, 2, max)
  301. all_dms <- lapply(all_dms, function(x){
  302. for(i in 1:ncol(x)){
  303. x[,i] <- x[,i]/maxs[i]
  304. }
  305. return(x)
  306. })
  307. }
  308. return(all_dms)
  309. }
  310. #' Split fMRI Timeseries Data by ROI Columns
  311. #'
  312. #' This function splits a timeseries data frame containing multiple ROI columns into a list
  313. #' of data frames, where each data frame contains the common columns (subjects, run, time)
  314. #' and one ROI column.
  315. #'
  316. #' @param timeseries A data frame containing fMRI timeseries data with required columns
  317. #' 'subjects', 'run', and 'time', plus one or more ROI columns.
  318. #' @param columns A character vector specifying which columns to split by. If NULL (default),
  319. #' all columns except 'subjects', 'run', and 'time' will be used.
  320. #'
  321. #' @return A named list of data frames, where each data frame contains the common columns
  322. #' (subjects, run, time) and one ROI column. The names of the list elements correspond
  323. #' to the ROI column names.
  324. #'
  325. #' @export
  326. #'
  327. #' @examples
  328. #' # Create a simple example timeseries with multiple ROIs
  329. #' set.seed(123)
  330. #' n_frames <- 100
  331. #'
  332. #' # Create a data frame with multiple ROIs
  333. #' timeseries <- data.frame(
  334. #' subjects = rep(1, n_frames),
  335. #' run = rep(1, n_frames),
  336. #' time = seq(0, n_frames-1),
  337. #' ROI1 = rnorm(n_frames),
  338. #' ROI2 = rnorm(n_frames),
  339. #' ROI3 = rnorm(n_frames)
  340. #' )
  341. #'
  342. #' # Split the timeseries by all ROI columns
  343. #' split_data <- split_timeseries(timeseries)
  344. split_timeseries <- function(timeseries, columns = NULL){
  345. if(!(all(c("run", "subjects", "time") %in% colnames(timeseries)))){
  346. stop("Expected columns in timeseries: run, subjects, time")
  347. }
  348. if(is.null(columns)) columns <- colnames(timeseries)[!colnames(timeseries) %in% c('run', 'subjects', 'time')]
  349. out <- list()
  350. for(col in columns){
  351. if(!col %in% colnames(timeseries)) stop("Please ensure selected columns are in timeseries")
  352. out[[col]] <- timeseries[,c('subjects', 'run', 'time', col)]
  353. }
  354. return(out)
  355. }
  356. #' Apply High-Pass Filtering to fMRI Data
  357. #'
  358. #' This function applies high-pass filtering to fMRI data to remove low-frequency noise
  359. #' and drift. It supports two filtering methods: cosine basis functions and polynomial
  360. #' regressors.
  361. #'
  362. #' @param X A data frame or matrix containing the data to be filtered. If it contains
  363. #' columns 'subjects' and 'run', the function will apply filtering separately for
  364. #' each subject-run combination.
  365. #' @param high_pass_model A character string specifying the high-pass filtering method.
  366. #' Options are 'cosine' (default) or 'poly' for polynomial regressors.
  367. #' @param frame_times A numeric vector of time points for each frame. If NULL, the
  368. #' function will attempt to extract this from a 'time' column in X.
  369. #' @param ... Additional arguments passed to the function.
  370. #'
  371. #' @return A data frame or matrix with the same structure as X, but with high-frequency
  372. #' components removed from the data columns.
  373. #'
  374. #' @export
  375. #'
  376. #' @examples
  377. #' # Create a simple example data frame with drift
  378. #' set.seed(123)
  379. #' n_frames <- 100
  380. #' time <- seq(0, 99)
  381. #'
  382. #' # Create a signal with low-frequency drift
  383. #' drift <- 0.1 * time
  384. #' signal <- sin(2 * pi * 0.1 * time) + drift
  385. #' noise <- rnorm(n_frames, 0, 0.5)
  386. #' data <- signal + noise
  387. #'
  388. #' # Create a data frame
  389. #' df <- data.frame(
  390. #' time = time,
  391. #' signal = data
  392. #' )
  393. #'
  394. #' # Apply high-pass filtering using cosine basis functions
  395. #' filtered_df <- high_pass_filter(df, high_pass_model = "cosine")
  396. high_pass_filter <- function(X, high_pass_model = 'cosine', frame_times = NULL, ...){
  397. if(is.null(frame_times)){
  398. if(!'time' %in% colnames(X)){
  399. stop("no column named 'time' for frame_times present, please separately provide")
  400. } else{
  401. frame_times <- X[,'time']
  402. }
  403. }
  404. if('subjects' %in% colnames(X) && is.null(list(...)$recursive)){
  405. out <- list()
  406. k <- 0
  407. message("Make sure you also high_pass_filter your events (set high_pass = TRUE in convolve_design_matrix)")
  408. for(sub in unique(X[,'subjects'])){
  409. tmp <- X[X[,'subjects'] == sub,]
  410. for(run in unique(tmp[,'run'])){
  411. k <- k + 1
  412. tmp_run <- tmp[tmp[,'run'] == run,]
  413. tmp_run <- high_pass_filter(tmp_run, recursive = TRUE)
  414. out[[k]] <- tmp_run
  415. }
  416. }
  417. return(do.call(rbind, out))
  418. }
  419. if(high_pass_model == "cosine"){
  420. nuisance <- cosine_drift(frame_times)
  421. } else if(high_pass_model == "poly"){
  422. nuisance <- poly_drift(frame_times)
  423. } else{
  424. stop("Only poly and cosine are supported as high_pass_model")
  425. }
  426. if(!is.null(list(...)$add)){
  427. if(list(...)$add){
  428. return(cbind(X, nuisance))
  429. }
  430. }
  431. gets_filter <- !colnames(X) %in% c('subjects', 'run', 'time')
  432. for(i in 1:ncol(X)){
  433. if(gets_filter[i]){
  434. fit <- lm(X[,i] ~ nuisance - 1)
  435. X[,i] <- residuals(fit)
  436. }
  437. }
  438. return(X)
  439. }
  440. poly_drift <- function(frame_times, order = 3) {
  441. # Ensure that 'order' is an integer
  442. order <- as.integer(order)
  443. n <- length(frame_times)
  444. # Compute maximum of frame_times (will be used to scale the time vector)
  445. tmax <- max(frame_times)
  446. # Create a matrix where column k corresponds to (frame_times/tmax)^k,
  447. # for k = 0, 1, ..., order; note that the 0th power yields a constant.
  448. pol <- sapply(0:order, function(k) (frame_times / tmax) ^ k)
  449. # 'pol' is now an n x (order+1) matrix
  450. # Orthogonalize the columns using QR decomposition.
  451. # The function qr.Q returns an orthonormal basis for the columns.
  452. pol_orth <- qr.Q(qr(pol))
  453. # Drop the constant
  454. result <- pol_orth[, -1, drop = FALSE]
  455. colnames(result) <- paste0("pol_", 1:ncol(result))
  456. return(result)
  457. }
  458. cosine_drift <- function(frame_times, high_pass = .01) {
  459. n_frames <- length(frame_times)
  460. n_times <- 0:(n_frames - 1)
  461. # Compute the time interval (dt) between frames.
  462. dt <- (frame_times[n_frames] - frame_times[1]) / (n_frames - 1)
  463. # Check if the product high_pass * dt is too high, issuing a warning if so.
  464. if (high_pass * dt >= 0.5) {
  465. warning(sprintf("High-pass filter will span all accessible frequencies and saturate the design matrix. You may want to reduce the high_pass value. The provided value is %.3f Hz", high_pass))
  466. }
  467. # Determine the number of cosine basis functions (excluding the constant).
  468. # This follows: order = min(n_frames - 1, floor(2 * n_frames * high_pass * dt))
  469. order <- min(n_frames - 1, floor(2 * n_frames * high_pass * dt))
  470. # Create a matrix to hold the cosine basis functions and a constant column.
  471. # The result will have (order + 1) columns.
  472. cosine_drift <- matrix(0, nrow = n_frames, ncol = order + 1)
  473. normalizer <- sqrt(2.0 / n_frames)
  474. # Fill the first 'order' columns with the cosine functions.
  475. # For each k = 1, 2, ..., order we compute:
  476. # normalizer * cos( (pi/n_frames) * (n_times + 0.5) * k )
  477. for (k in 1:order) {
  478. cosine_drift[, k] <- normalizer * cos((pi / n_frames) * (n_times + 0.5) * k)
  479. }
  480. cosine_drift <- cosine_drift[,-ncol(cosine_drift)]
  481. # Set the last column to a constant of 1.
  482. colnames(cosine_drift) <- paste0("drift_", 1:ncol(cosine_drift))
  483. return(cosine_drift)
  484. }
  485. #' Create fMRI Design for EMC2 Sampling
  486. #'
  487. #' This function takes the output from convolve_design_matrix and transforms it into a design
  488. #' suitable for sampling with EMC2. It properly configures parameter types, bounds, and transformations
  489. #' for the specified model.
  490. #'
  491. #' @param design_matrix A list of design matrices, the output from convolve_design_matrix
  492. #' @param model A function that returns a model specification, options are MRI or MRI_AR1
  493. #' @param ... Additional arguments passed to the model
  494. #'
  495. #' @return An object of class 'emc.design' suitable for EMC2 sampling
  496. #' @export
  497. #'
  498. #' @examples
  499. #' # Generate a simple example timeseries
  500. #' ts <- data.frame(
  501. #' subjects = rep(1, 100),
  502. #' run = rep(1, 100),
  503. #' time = cumsum(rep(1.38, 100)),
  504. #' ROI1 = rnorm(100)
  505. #' )
  506. #'
  507. #' # Generate example events
  508. #' events <- data.frame(
  509. #' subjects = rep(1, 4),
  510. #' run = rep(1, 4),
  511. #' onset = c(10, 30, 50, 70),
  512. #' duration = rep(0.5, 4),
  513. #' event_type = c("A", "B", "A", "B"),
  514. #' modulation = c(1, 1, 1, 1)
  515. #' )
  516. #'
  517. #' # Create convolved design matrix
  518. #' design_matrix <- convolve_design_matrix(
  519. #' timeseries = ts,
  520. #' events = events,
  521. #' factors = list(condition = c("A", "B")),
  522. #' hrf_model = "glover"
  523. #' )
  524. #'
  525. #' # Create fMRI design for EMC2
  526. #' fmri_design <- design_fmri(design_matrix, model = MRI_AR1)
  527. design_fmri <- function(design_matrix,
  528. model = MRI_AR1, ...) {
  529. dots <- list(...)
  530. betas <- colnames(design_matrix[[1]])
  531. subjects <- names(design_matrix)
  532. model_list <- model()
  533. # Fill in new p_types
  534. p_not_beta <- model_list$p_types[names(model_list$p_types) != "beta"]
  535. model_list$p_types <- c(setNames(rep(model_list$p_types["beta"], length(betas)), betas), p_not_beta)
  536. # Fill in new bound
  537. new_mm <- do.call(cbind, rep(list(model_list$bound$minmax[,"beta"]), length(betas)))
  538. colnames(new_mm) <- betas
  539. model_list$bound$minmax <- cbind(model_list$bound$minmax, new_mm)
  540. model_list$bound$minmax <- model_list$bound$minmax[,colnames(model_list$bound$minmax) != "beta"]
  541. # Fill in new transforms
  542. new_t <- setNames(rep(model_list$transform$func["beta"], length(betas)), betas)
  543. model_list$transform$func <- c(model_list$transform$func, new_t)
  544. model_list$transform$func <- model_list$transform$func[names(model_list$transform$func) != "beta"]
  545. # Make pre transforms
  546. par_names <- c(betas, names(p_not_beta))
  547. model_list$pre_transform$func <- setNames(rep("identity", length(par_names)), par_names)
  548. model <- function() {return(model_list)}
  549. n_pars <- length(par_names)
  550. # Fill up final results
  551. model_list$transform <- fill_transform(dots$transform,model)
  552. model_list$bound <- fill_bound(dots$bound,model)
  553. model_list$pre_transform <- fill_transform(dots$pre_transform, model = model, p_vector = model_list$p_types, is_pre = TRUE)
  554. model <- function(){return(model_list)}
  555. Flist <- vector("list", n_pars)
  556. for(i in 1:n_pars){
  557. Flist[[i]] <- as.formula(paste0(par_names[i], "~1"))
  558. }
  559. design <- list(Flist = Flist, model = model, Ffactors = list(subjects = subjects))
  560. attr(design, "design_matrix") <- lapply(design_matrix, FUN=function(x) {
  561. y <- x[,colnames(x) != 'subjects']
  562. DM_tmp <- data.matrix(y)
  563. rownames(DM_tmp) <- NULL
  564. return(DM_tmp)
  565. })
  566. par_names <- setNames(numeric(length(par_names)), par_names)
  567. attr(design, "p_vector") <- par_names
  568. class(design) <- 'emc.design'
  569. return(design)
  570. }
  571. #' GLM model for fMRI data
  572. #'
  573. #' Creates a model specification for fMRI data using a normal distribution.
  574. #' This model assumes that the observed BOLD signal follows a normal distribution
  575. #' with a mean determined by the design matrix and betas, and a standard deviation
  576. #' parameter for noise.
  577. #'
  578. #' @return A list containing model specification
  579. #'
  580. #' @details
  581. #' The model uses a normal distribution to model fMRI BOLD signals.
  582. #' Beta parameters represent the effect sizes for different conditions,
  583. #' and the sd parameter represents the standard deviation of the noise.
  584. #'
  585. #' The log-likelihood function centers the predicted values by subtracting
  586. #' the mean, which helps with model identifiability.
  587. #'
  588. #' @export
  589. #' @examples
  590. #' # Create a normal MRI model specification
  591. #' model_spec <- MRI()
  592. #'
  593. #' # Access model parameters
  594. #' model_spec$p_types
  595. MRI <- function(){
  596. return(
  597. list(
  598. type="MRI",
  599. c_name = "MRI",
  600. p_types=c("beta" = 0, "sd" = log(1)),
  601. transform=list(func=c(beta = "identity", sd = "exp")),
  602. bound=list(minmax=cbind(beta=c(-Inf,Inf),sd=c(0.001,Inf))),
  603. Ttransform = function(pars, dadm) return(pars),
  604. rfun=function(pars){
  605. # - Each row corresponds to an observation
  606. # - All columns except the last are betas (already multiplied by the design matrix)
  607. # - The last column is sigma (the noise standard deviation)
  608. # Extract sigma and betas
  609. sigma <- pars[, ncol(pars)]
  610. betas <- pars[, -ncol(pars)]
  611. # Compute the predicted mean for each observation as the sum of its betas
  612. y_hat <- rowSums(betas)
  613. # Generate simulated data: for each observation, add noise drawn from a normal distribution
  614. # with mean 0 and standard deviation sigma.
  615. y_sim <- y_hat + rnorm(n = length(y_hat), mean = 0, sd = sigma)
  616. return(y_sim)
  617. },
  618. log_likelihood=function(pars, dadm, model, min_ll=log(1e-10)){
  619. # Here pars already contains the mapped design contributions.
  620. y <- as.matrix(dadm[,!colnames(dadm) %in% c("subjects", 'run', 'time', "trials")])
  621. # grab the right parameters
  622. sigma <- pars[,ncol(pars)]
  623. betas <- pars[,-ncol(pars)]
  624. y_hat <- rowSums(betas)
  625. ll <- sum(pmax(dnorm(as.matrix(y), mean = y_hat, sd = sigma, log = T), min_ll))
  626. return(ll)
  627. }
  628. )
  629. )
  630. }
  631. #' Create an AR(1) GLM model for fMRI data
  632. #'
  633. #' This function creates a model specification for MRI data with an AR(1) error structure.
  634. #' The model includes beta parameters for the design matrix, a rho parameter for the
  635. #' autocorrelation, and a standard deviation parameter for the noise.
  636. #'
  637. #' The AR(1) model accounts for temporal autocorrelation in the data, where each timepoint
  638. #' is correlated with the previous timepoint according to the rho parameter.
  639. #'
  640. #' @return A list containing the model specifications
  641. #'
  642. #' @export
  643. #' @examples
  644. #' # Create an AR(1) GLM model for fMRI data
  645. #' model_spec <- MRI_AR1()
  646. #'
  647. #' # Access model parameters
  648. #' model_spec$p_types
  649. MRI_AR1 <- function(){
  650. return(
  651. list(
  652. type="MRI_AR1",
  653. c_name = "MRI_AR1",
  654. p_types=c("beta" = 0, "rho" = pnorm(0.001), "sd" = log(1)),
  655. transform=list(func=c(beta = "identity", rho = "pnorm", sd = "exp")),
  656. bound=list(minmax=cbind(beta=c(-Inf,Inf),sd=c(0.001,Inf), rho = c(0.0001, 1)),
  657. exception=c(rho=0)),
  658. Ttransform = function(pars, dadm) return(pars),
  659. rfun=function(pars){
  660. n <- nrow(pars)
  661. m <- ncol(pars)
  662. # - betas: columns 1 to (m-2)
  663. # - rho: column (m-1)
  664. # - sigma: column m (stationary standard deviation)
  665. betas <- pars[,1:(m-2), drop = FALSE]
  666. rho <- pars[,m-1]
  667. sigma <- pars[,m]
  668. # Compute the linear predictor (sum of beta contributions) and center it.
  669. y_hat <- rowSums(betas)
  670. # y_hat <- y_hat - mean(y_hat)
  671. # Allocate a vector for simulated data
  672. y_sim <- numeric(n)
  673. # Simulate the first observation
  674. y_sim[1] <- y_hat[1] + rnorm(1, mean = 0, sd = sigma[1])
  675. # Loop through time for the remaining observations
  676. for (t in 2:n) {
  677. # The conditional mean for observation t
  678. cond_mean <- y_hat[t] + rho[t] * (y_sim[t - 1] - y_hat[t - 1])
  679. # The conditional standard deviation for observation t
  680. cond_sd <- sigma[t] * sqrt(1 - rho[t]^2)
  681. # Simulate
  682. y_sim[t] <- cond_mean + rnorm(1, mean = 0, sd = cond_sd)
  683. }
  684. return(y_sim)
  685. },
  686. log_likelihood = function(pars, dadm, model, min_ll = log(1e-10)) {
  687. # Here pars already contains the mapped design contributions.
  688. # Extract observed data (as a vector)
  689. y <- as.vector(as.matrix(dadm[, !colnames(dadm) %in% c("subjects", "run", "time", "trials")]))
  690. n <- length(y)
  691. m <- ncol(pars)
  692. betas <- pars[, 1:(m - 2), drop = FALSE]
  693. rho <- pars[, m - 1]
  694. sigma <- pars[, m]
  695. y_hat <- rowSums(betas)
  696. # y_hat <- y_hat - mean(y_hat)
  697. # Log-likelihood for the first observation
  698. ll <- numeric(n)
  699. ll[1] <- dnorm(y[1], mean = y_hat[1], sd = sigma[1], log = TRUE)
  700. # For observations t = 2:n, compute conditional means
  701. cond_mean <- y_hat[-1] + rho[-1] * (y[-n] - y_hat[-n])
  702. cond_sd <- sigma[-1] * sqrt(1 - rho[-1]^2)
  703. ll[-1] <- dnorm(y[-1], mean = cond_mean, sd = cond_sd, log = TRUE)
  704. ll <- pmax(ll, min_ll)
  705. return(sum(ll))
  706. }
  707. )
  708. )
  709. }
  710. # Plotting functions ------------------------------------------------------
  711. #' Plot fMRI Design Matrix
  712. #'
  713. #' This function creates a visualization of an fMRI design matrix, showing the temporal
  714. #' evolution of regressors over time. It can handle various input formats and provides
  715. #' options to customize the visualization.
  716. #'
  717. #' @param design_matrix A design matrix for fMRI analysis. Can be a data frame, matrix,
  718. #' list of matrices, or an object of class 'emc.design'.
  719. #' @param TRs The number of time points (TRs) to plot. Default is 100.
  720. #' @param events A character vector specifying which regressors to plot. If NULL,
  721. #' all non-nuisance regressors will be plotted.
  722. #' @param remove_nuisance Logical indicating whether to remove nuisance regressors
  723. #' (drift terms, polynomial terms, derivatives) from the plot. Default is TRUE.
  724. #' @param subject The subject number to plot. Only applies for list of design matrices. Default is 1.
  725. #' @param legend_pos Position of the legend. Default is "bottomleft".
  726. #' @param ... Additional graphical parameters passed to matplot and legend.
  727. #'
  728. #' @return A plot showing the design matrix regressors over time.
  729. #'
  730. #' @export
  731. #'
  732. #' @examples
  733. #' # Example time series
  734. #' ts <- data.frame(
  735. #' subjects = rep(1, 100),
  736. #' run = rep(1, 100),
  737. #' time = seq(0, 99),
  738. #' ROI = rnorm(100)
  739. #' )
  740. #' # Create a simple events data frame
  741. #' events <- data.frame(
  742. #' subjects = rep(1, 10),
  743. #' run = rep(1, 10),
  744. #' onset = seq(0, 90, by = 10),
  745. #' condition = rep(c("A", "B"), 5),
  746. #' rt = runif(10, 0.5, 1.5),
  747. #' accuracy = sample(0:1, 10, replace = TRUE)
  748. #' )
  749. #' # Reshape with custom duration for each event_type
  750. #' reshaped <- reshape_events(events,
  751. #' event_types = c("condition", "accuracy", "rt"),
  752. #' duration = list(condition = 0.5,
  753. #' accuracy = 0.2,
  754. #' rt = function(x) x$rt))
  755. #' design_matrices <- convolve_design_matrix(
  756. #' timeseries = ts,
  757. #' events = reshaped,
  758. #' covariates = c('accuracy', 'rt'),
  759. #' factors = list(cond = c("condition_A", "condition_B")),
  760. #' contrasts = list(cond = matrix(c(-1, 1))))
  761. #'
  762. #' # Plot the design matrix
  763. #' plot_design_fmri(design_matrices)
  764. plot_design_fmri <- function(design_matrix, TRs = 100, events = NULL, remove_nuisance = TRUE, subject = 1,
  765. legend_pos = "bottomleft", ...){
  766. if(is(design_matrix,"emc.design")){
  767. design_matrix <- attr(design_matrix, "design_matrix")
  768. }
  769. if(!is.data.frame(design_matrix) && !is.matrix(design_matrix)){
  770. if(is.list(design_matrix)) design_matrix <- design_matrix[[subject]]
  771. }
  772. enames <- colnames(design_matrix)
  773. if(remove_nuisance & is.null(events)){
  774. is_nuisance <- grepl("drift", enames) | grepl("poly", enames) | grepl("derivative", enames) | apply(design_matrix, 2, sd) == 0
  775. design_matrix <- design_matrix[,!is_nuisance, drop = F]
  776. }
  777. enames <- colnames(design_matrix)
  778. if(is.null(events)){
  779. events <- enames
  780. } else{
  781. if(any(!events %in% enames)){
  782. stop("events not in colnames design matrix")
  783. }
  784. }
  785. distinct_colors <- c(
  786. "#E6194B", "#3CB44B", "#0082C8", "#F58231", "#911EB4",
  787. "#46F0F0", "#F032E6", "#D2F53C", "#FABEBE", "#008080",
  788. "#E6BEFF", "#AA6E28", "#FFFAC8", "#800000", "#FFD8B1"
  789. )
  790. dots <- add_defaults(list(...), col = distinct_colors, lwd = 2, lty = 1, main = NULL, xlab = "TRs", ylab = "Amplitude")
  791. TRs <- min(c(TRs, nrow(design_matrix)))
  792. design_matrix <- design_matrix[1:TRs, events, drop = F]
  793. do.call(matplot, c(list(design_matrix, type = "l"), fix_dots_plot(dots)))
  794. do.call(legend, c(list(legend_pos, legend = events, bty = "n"), fix_dots(dots, legend)))
  795. }
  796. # -------------------------------------------------------------------------
  797. # Filter events by event_type and bin/categorize the modulation
  798. # -------------------------------------------------------------------------
  799. prepare_event_groups <- function(events, event_type, n_bins = 4) {
  800. ev_sub <- events[events$event_type == event_type, ]
  801. if (nrow(ev_sub) == 0) {
  802. return(ev_sub) # empty
  803. }
  804. if (!"modulation" %in% names(ev_sub)) {
  805. stop("The 'events' data frame must have a 'modulation' column.")
  806. }
  807. # Round to reduce floating precision issues
  808. ev_sub$modulation <- round(ev_sub$modulation, 6)
  809. mod_vals <- unique(ev_sub$modulation)
  810. n_unique <- length(mod_vals)
  811. if (n_unique > 6) {
  812. # Calculate quartile breakpoints for the 'modulation' data
  813. quartile_breaks <- quantile(ev_sub$modulation, probs = seq(0, 1, length.out = n_bins + 1), na.rm = TRUE)
  814. # Bin the data into quartiles using these breakpoints
  815. ev_sub$mod_group <- cut(ev_sub$modulation, breaks = quartile_breaks, include.lowest = TRUE, labels = 1:n_bins)
  816. attr(ev_sub, "binned") <- TRUE
  817. } else {
  818. # treat as categorical
  819. ev_sub$mod_group <- factor(ev_sub$modulation, levels = sort(mod_vals))
  820. attr(ev_sub, "binned") <- FALSE
  821. }
  822. return(ev_sub)
  823. }
  824. # -----------------------------------------------------------------------------
  825. # Fast FIR utilities -----------------------------------------------------------
  826. # -----------------------------------------------------------------------------
  827. # 1) Build an FIR design matrix for one set of onsets --------------------------
  828. # frame_times : numeric vector of acquisition times (s) **monotone**
  829. # onsets : numeric vector of event onsets (s) (single modulation group)
  830. # pre, post : seconds before/after onset to model (positive numbers)
  831. #
  832. # Returns a dense matrix (length(frame_times) x K) where
  833. # K = floor((pre+post)/dt)+1 (dt = median diff(frame_times))
  834. # Column k corresponds to lag t = -pre + (k-1)*dt.
  835. # ---------------------------------------------------------------------------
  836. # Build an FIR design with an optional externally‑specified TR --------------
  837. # ---------------------------------------------------------------------------
  838. ###############################################################################
  839. ## Fast FIR utilities + plot_fmri (robust version, 3 May 2025) ##
  840. ###############################################################################
  841. # ─────────────────────────────────────────────────────────────────────────────
  842. # ─────────────────────────────────────────────────────────────────────────────
  843. # 1) FIR design – *global lag grid* ensured by `fixed_dt` argument
  844. # ─────────────────────────────────────────────────────────────────────────────
  845. build_fir_design <- function(frame_times, onsets, durations,
  846. pre, post, fixed_dt, weights = NULL) {
  847. if (is.null(weights)) weights <- rep(1, length(onsets))
  848. if (is.null(durations)) durations <- rep(0, length(onsets)) # impulse
  849. stopifnot(length(durations) == length(onsets))
  850. dt <- fixed_dt
  851. lags <- seq(-pre, post, by = dt)
  852. K <- length(lags)
  853. nTR <- length(frame_times)
  854. X <- matrix(0, nrow = nTR, ncol = K,
  855. dimnames = list(NULL, sprintf("lag_%0.3f", lags)))
  856. ## pre‑compute frame centres for fast look‑up
  857. fc <- frame_times
  858. for (j in seq_along(onsets)) {
  859. onset <- onsets[j]
  860. dur <- durations[j]
  861. w <- weights[j]
  862. ## frames whose centre is within the event window
  863. in_evt <- which(fc >= onset & fc < onset + dur)
  864. if (!length(in_evt)) { # shorter than first half‑TR → impulse
  865. in_evt <- which.min(abs(fc - onset))
  866. }
  867. ## update FIR cols for every lag
  868. for (k in seq_len(K)) {
  869. rows <- in_evt + round(lags[k] / dt)
  870. rows <- rows[rows >= 1 & rows <= nTR]
  871. if(length(rows))
  872. X[rows, k] <- X[rows, k] + w
  873. }
  874. }
  875. attr(X, "lag_seconds") <- lags
  876. X
  877. }
  878. # ─────────────────────────────────────────────────────────────────────────────
  879. # 2) AR(1) whitening
  880. # ─────────────────────────────────────────────────────────────────────────────
  881. estimate_rho <- function(y, clip = TRUE) {
  882. if (length(y) < 2L) return(0)
  883. y <- y - mean(y, na.rm = TRUE) # 1 de‑mean
  884. rho <- sum(y[-1] * y[-length(y)]) /
  885. (sum(y[-length(y)]^2) + 1e-12)
  886. if (clip) rho <- max(-0.99, min(0.99, rho)) # 2 safe bounds
  887. rho
  888. }
  889. whiten_series <- function(mat_or_vec, rho) {
  890. if (abs(rho) < 1e-6) return(mat_or_vec)
  891. if (is.vector(mat_or_vec))
  892. return(c(mat_or_vec[1],
  893. mat_or_vec[-1] - rho * mat_or_vec[-length(mat_or_vec)]))
  894. rbind(mat_or_vec[1, , drop = FALSE],
  895. mat_or_vec[-1, , drop = FALSE] -
  896. rho * mat_or_vec[-nrow(mat_or_vec), , drop = FALSE])
  897. }
  898. # ─────────────────────────────────────────────────────────────────────────────
  899. # 4) Quick FIR GLM fit (OLS + AR(1) whitening, baseline correction)
  900. # ─────────────────────────────────────────────────────────────────────────────
  901. fit_fir_glm <- function(y, X, lags_sec, target) {
  902. rho <- estimate_rho(y)
  903. yw <- whiten_series(y, rho)
  904. Xw <- whiten_series(X, rho)
  905. beta <- as.vector(solve(crossprod(Xw), crossprod(Xw, yw)))
  906. beta <- beta[target]
  907. ## Baseline correction (pre‑stimulus lags)
  908. idx_pre <- lags_sec < 0
  909. if(any(idx_pre, na.rm = TRUE))
  910. beta <- beta - mean(beta[idx_pre], na.rm = TRUE)
  911. beta
  912. }
  913. # ─────────────────────────────────────────────────────────────────────────────
  914. # 5) Main extractor – returns long data.frame for plotting
  915. # ─────────────────────────────────────────────────────────────────────────────
  916. # ─────────────────────────────────────────────────────────────────────────────
  917. # 5) Main extractor – FIR for *all* events, returns long data‑frame
  918. # ─────────────────────────────────────────────────────────────────────────────
  919. get_fir_lines <- function(timeseries, events, event_type,
  920. pre = 2, post = 18, n_bins = 4,
  921. high_pass = TRUE, high_pass_model = "cosine") {
  922. ## basic checks
  923. stopifnot(all(c("run", "subjects", "time") %in% names(timeseries)))
  924. ROI_col <- setdiff(names(timeseries),
  925. c("run", "subjects", "time", "postn"))
  926. if(length(ROI_col) != 1L) stop("Exactly one ROI column required")
  927. ## global lag grid
  928. global_dt <- median(diff(sort(unique(timeseries$time))))
  929. global_lags <- seq(-pre, post, by = global_dt)
  930. K <- length(global_lags)
  931. ## TARGET events (binned)
  932. ev_sub <- prepare_event_groups(events, event_type, n_bins)
  933. if(nrow(ev_sub) == 0)
  934. stop("No events found for event_type = ", event_type)
  935. groups <- sort(unique(ev_sub$mod_group))
  936. ## NUISANCE events (everything else)
  937. ev_nuis <- events[events$event_type != event_type, ]
  938. nuis_types <- sort(unique(ev_nuis$event_type)) # might be empty
  939. betas <- setNames(vector("list", length(groups)), groups)
  940. ts_split <- split(timeseries, list(timeseries$subjects,
  941. timeseries$run), drop = TRUE)
  942. for(chunk in ts_split) {
  943. ft <- chunk$time
  944. y <- chunk[[ROI_col]]
  945. sid <- chunk$subjects[1]
  946. rid <- chunk$run[1]
  947. ## 1) build FIR blocks for *each nuisance type* in this run
  948. X_nuis <- NULL
  949. if(length(nuis_types)) {
  950. for(et in nuis_types) {
  951. ev_tmp <- ev_nuis[ev_nuis$event_type == et &
  952. ev_nuis$subjects == sid &
  953. ev_nuis$run == rid, ]
  954. if(!nrow(ev_tmp)) next
  955. if(sd(ev_tmp$modulation) == 0 & sd(ev_tmp$duration) == 0) next
  956. ## centre the modulator so ‘main effect’ and modulation are orthogonal
  957. if (sd(ev_tmp$modulation) > 0) {
  958. ev_tmp$modulation <- ev_tmp$modulation - mean(ev_tmp$modulation)
  959. } else{
  960. ev_tmp$modulation <- ev_tmp$duration - mean(ev_tmp$duration)
  961. }
  962. Xet <- build_fir_design(ft,
  963. ev_tmp$onset,
  964. ev_tmp$duration,
  965. pre, post,
  966. fixed_dt = global_dt,
  967. weights = ev_tmp$modulation)
  968. colnames(Xet) <- paste0(et, "_", colnames(Xet))
  969. X_nuis <- if(is.null(X_nuis)) Xet else cbind(X_nuis, Xet)
  970. }
  971. }
  972. ## 2) loop over modulation groups of the TARGET event
  973. for(g in groups) {
  974. ev_g <- ev_sub[ev_sub$mod_group == g &
  975. ev_sub$subjects == sid &
  976. ev_sub$run == rid, ]
  977. if(!nrow(ev_g)) next
  978. ## NOTE: weights = NULL → un‑scaled FIR for the curve we plot
  979. X_tar <- build_fir_design(ft,
  980. ev_g$onset,
  981. ev_g$duration, # durations for the target
  982. pre, post,
  983. fixed_dt = global_dt,
  984. weights = NULL) # no amplitude scaling
  985. X_full <- if(is.null(X_nuis)) X_tar else cbind(X_tar, X_nuis)
  986. if(!isFALSE(high_pass)){
  987. X_full <- high_pass_filter(X_full, high_pass_model, frame_times = ft, add=(high_pass == "add"))
  988. }
  989. b <- fit_fir_glm(y, X_full, global_lags, target = seq_len(K))
  990. if(all(is.na(b))) next
  991. betas[[g]] <- rbind(betas[[g]],
  992. cbind(t(b), nrow(ev_g)))
  993. }
  994. }
  995. ## 3) weight‑averaged β → long data.frame
  996. out <- lapply(names(betas), function(g) {
  997. mat <- betas[[g]]; if(is.null(mat)) return(NULL)
  998. B <- mat[, 1:K, drop = FALSE]; w <- mat[, K+1L]
  999. if(!is.matrix(B)) { B <- matrix(B, nrow = 1); w <- w[1] }
  1000. beta_avg <- colSums(B * w) / sum(w)
  1001. df <- data.frame(
  1002. mod_group = rep(g, K),
  1003. time = global_lags,
  1004. avg_signal = beta_avg
  1005. )
  1006. if(attr(ev_sub, "binned")) {
  1007. ev_all <- ev_sub[ev_sub$mod_group == g, ]
  1008. df$min_mod <- rep(min(ev_all$modulation), K)
  1009. df$max_mod <- rep(max(ev_all$modulation), K)
  1010. }
  1011. df
  1012. })
  1013. rownames_out <- NULL
  1014. do.call(rbind, out)
  1015. }
  1016. #' Plot fMRI peri-stimulus time courses
  1017. #'
  1018. #' This function plots average BOLD response around specified events for a single ROI
  1019. #' by using FIR based event estimation, all event_types in events are taken into account in the FIR.
  1020. #' Posterior predictives can be overlaid via the `post_predict` argument.
  1021. #'
  1022. #' @param timeseries A data frame with columns 'subjects', 'run', 'time', and one ROI measurement column.
  1023. #' @param events A data frame with columns 'subjects', 'run', 'onset', 'duration', 'event_type', and 'modulation'.
  1024. #' @param event_type Character string specifying which `event_type` in `events` to plot.
  1025. #' @param high_pass Logical indicating whether to apply high-pass filtering.
  1026. #' Alternatively, specifying 'add' adds the regressors to the design matrix in the FIR.
  1027. #' The choice here should be the same as the choice for `convolve_design_matrix`
  1028. #' @param high_pass_model Character indicating which type of high-pass filtering to apply ('cosine', 'poly')
  1029. #' @param post_predict Optional posterior predictive samples data frame (not shown in examples).
  1030. #' @param posterior_args Named list of graphical parameters for posterior predictive lines.
  1031. #' @param legend_pos Position of the legend. Default: "topleft".
  1032. #' @param layout Panel layout matrix for multiple modulation groups. NULL leaves current layout
  1033. #' @param n_cores Number of cores to calculate FIR across subjects with.
  1034. #' @param ... Additional graphical parameters passed to plotting functions (e.g., col, lwd, lty).
  1035. #'
  1036. #' @return NULL. Produces plots as a side-effect.
  1037. #' @export
  1038. #'
  1039. #' @examples
  1040. #' ts <- data.frame(
  1041. #' subjects = rep(1, 100),
  1042. #' run = rep(1, 100),
  1043. #' time = seq(0, 99),
  1044. #' ROI = rnorm(100)
  1045. #' )
  1046. #' events <- data.frame(
  1047. #' subjects = rep(1, 5),
  1048. #' run = rep(1, 5),
  1049. #' onset = c(10, 30, 50, 70, 90),
  1050. #' event_type = rep("A", 5),
  1051. #' modulation = rep(1, 5),
  1052. #' duration = rep(0.5, 5)
  1053. #' )
  1054. #' plot_fmri(ts, events = events, event_type = "A")
  1055. plot_fmri <- function(timeseries, post_predict = NULL, events, event_type,
  1056. high_pass = TRUE, high_pass_model = "cosine",
  1057. posterior_args = list(),
  1058. legend_pos = "topleft", layout = NA,
  1059. n_cores = 1, ...) {
  1060. posterior_args <- add_defaults(posterior_args, col = "darkgreen", lwd = 2)
  1061. plot_args <- add_defaults(list(...), col = "black", lwd = 2,
  1062. xlab = "time (s)", ylab = "BOLD response")
  1063. ROI_col <- setdiff(names(timeseries),
  1064. c("run", "subjects", "time", "postn"))
  1065. ts <- get_fir_lines(timeseries, events, event_type,
  1066. high_pass = high_pass,
  1067. high_pass_model = high_pass_model)
  1068. ## posterior predictive overlay
  1069. if(!is.null(post_predict)) {
  1070. pp_split <- split(post_predict, post_predict$postn)
  1071. pp_lines <- auto_mclapply(pp_split, get_fir_lines, events, event_type,
  1072. high_pass = high_pass,
  1073. high_pass_model = high_pass_model, mc.cores = n_cores)
  1074. pp_df <- do.call(rbind, pp_lines)
  1075. qfun <- function(x) quantile(x, c(.025, .5, .975))
  1076. agg <- aggregate(avg_signal ~ mod_group + time,
  1077. data = pp_df, FUN = qfun)
  1078. res_df <- data.frame(agg[, 1:2], agg$avg_signal)
  1079. names(res_df)[3:5] <- c("p025", "p50", "p975")
  1080. }
  1081. ## layout
  1082. n_plots <- length(unique(ts$mod_group))
  1083. if(n_plots == 1){
  1084. insert <- ""
  1085. } else{
  1086. insert <- "Q"
  1087. }
  1088. if(!is.null(layout)){
  1089. oldpar <- par(no.readonly = TRUE)
  1090. on.exit(par(oldpar))
  1091. }
  1092. if (any(is.na(layout))) {
  1093. par(mfrow = coda_setmfrow(Nchains = 1, Nparms = n_plots, nplots = 1))
  1094. } else {
  1095. par(mfrow = layout)
  1096. }
  1097. ## loop over modulation groups
  1098. for(mod in unique(ts$mod_group)){
  1099. if(!is.null(post_predict)){
  1100. ylim <- c(min(ts$avg_signal, res_df$p025), max(ts$avg_signal, res_df$p975))
  1101. } else{
  1102. ylim <- range(ts$avg_signal)
  1103. }
  1104. idx_ts <- ts$mod_group == mod
  1105. tmp_plot_args <- add_defaults(plot_args, ylim = ylim,
  1106. main = paste0(ROI_col, " - ", event_type, ifelse(n_plots == 1, "", paste0(": ", insert, mod))))
  1107. do.call(plot, c(list(ts$time[idx_ts], ts$avg_signal[idx_ts], type = "l"), fix_dots_plot(tmp_plot_args)))
  1108. abline(h = 0, lty = 2)
  1109. if(!is.null(post_predict)){
  1110. idx_pp <- res_df$mod_group == mod
  1111. do.call(lines, c(list(res_df$time[idx_pp], res_df$p50[idx_pp]), fix_dots_plot(posterior_args)))
  1112. polygon_args <- posterior_args
  1113. polygon_args$col <- adjustcolor(polygon_args$col, alpha.f = .2)
  1114. do.call(polygon, c(list(
  1115. x = c(res_df$time[idx_pp], rev(res_df$time[idx_pp])),
  1116. y = c(res_df$p025[idx_pp], rev(res_df$p975[idx_pp])), border = NA), fix_dots_plot(polygon_args)))
  1117. }
  1118. dots_legend <- plot_args
  1119. dots_legend$col <- c(plot_args$col, posterior_args$col)
  1120. dots_legend$lty <- c(plot_args$lty, posterior_args$lty)
  1121. dots_legend$lwd <- c(plot_args$lwd, posterior_args$lwd)
  1122. if(is.null(post_predict)){
  1123. legend_in <- "data"
  1124. } else{
  1125. legend_in <- c("data", "posterior")
  1126. }
  1127. do.call(legend, c(list(legend_pos, legend = legend_in, bty = "n"), fix_dots(dots_legend, legend)))
  1128. }
  1129. }
  1130. # Utility functions -------------------------------------------------------
  1131. make_mri_sampling_design <- function(design, sampled_p_names){
  1132. out <- list()
  1133. expand <- 1:nrow(design)
  1134. rownames(design) <- NULL
  1135. for(i in 1:ncol(design)){
  1136. out[[i]] <- design[,i, drop = F]
  1137. attr(out[[i]], "expand") <- expand
  1138. }
  1139. names(out) <- colnames(design)
  1140. no_design <- sampled_p_names[!sampled_p_names %in% colnames(design)]
  1141. for (nam in no_design){
  1142. mat <- matrix(1, 1, 1)
  1143. colnames(mat) <- nam
  1144. out[[nam]] <- mat
  1145. attr(out[[nam]], "expand") <- rep(1, nrow(design))
  1146. }
  1147. return(out)
  1148. }
  1149. make_data_wrapper_MRI <- function(parameters, data, design){
  1150. data_list <- list()
  1151. for(i in 1:nrow(parameters)){
  1152. data_tmp <- data[data$subjects == unique(data$subjects)[i],]
  1153. data_tmp$subjects <- factor(data_tmp$subjects)
  1154. attr(data_tmp, "designs") <- design$fMRI_design[[i]]
  1155. data_list[[i]] <- make_data_fMRI(parameters[i,,drop = F], design$model, data_tmp, design)
  1156. }
  1157. return(do.call(rbind, data_list))
  1158. }
  1159. make_data_fMRI <- function(parameters, model, data, design, ...){
  1160. # if(is.null(attr(design, "design_matrix"))){
  1161. # stop("for fMRI simulation the original design needs to be passed to the simulation function")
  1162. # }
  1163. pars <- get_pars_matrix_oo(parameters, data, model())
  1164. data[, !colnames(data) %in% c("subjects", "run", "time", "trials")] <- model()$rfun(pars)
  1165. return(data)
  1166. }
  1167. add_design_fMRI_predict <- function(design, emc){
  1168. design$fMRI_design <- lapply(emc[[1]]$data, function(x) return(attr(x,"designs")))
  1169. return(design)
  1170. }

MRI.R at commit beab948, under GPL-3.0 · at the source

Overview

Authors: Niek Stevenson1, Steven Miletić1,2, Birte U. Forstmann1
ORCID iDs: Niek Stevenson
  1. University of Amsterdam, Amsterdam, The Netherlands
  2. Leiden University, Leiden, The Netherlands
Institutions: University of Amsterdam (Netherlands); Leiden University (Netherlands)
Journal: Imaging neuroscience (Cambridge, Mass.), volume 4, article IMAG.a.1272
Dates: received 8 July 2025; accepted 8 May 2026; published online 15 June 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1162/imag.a.1272 · PMID 42312084 · PMCID PMC13271149 · OpenAlex W7162085102
Open access: diamond, a free copy (OpenAlex)
Status: code verified
Categories: fMRI (modality), methods / tools (subfield)
Methods: Connectivity, Preprocessing, fMRI & imaging, Statistics
Keywords: joint modeling, fMRI, hierarchical Bayes, evidence accumulation models, diffusion decision model
Journal subjects: Software Toolbox
Topic: Functional Brain Connectivity Studies (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 86 references in the paper
Research resources: RRID:SCR_001362, RRID:SCR_001847, RRID:SCR_002438, which is based on Nipype 1.7.0 RRID:SCR_002502, RRID:SCR_002823, distributed with ANTs 2.3.3 RRID:SCR_004757, RRID:SCR_005927, RRID:SCR_008796, RRID:SCR_016216

Abstract

Understanding how neural activity relates to behavior remains a central challenge in cognitive neuroscience. Joint modeling offers a principled method by simultaneously fitting behavioral and fMRI data and estimating the relations between them, accounting for measurement error, inter-individual variability, and shared uncertainty. Here, we present a joint modeling toolbox built in the R package, EMC2, which streamlines the behavioral, neural, and joint model estimation. We apply the toolbox to a perceptual decision-making task performed in an fMRI scanner, demonstrating how to specify behavioral and neural design matrices, set priors, construct brain–behavioral links, and conduct group-level tests and individual-difference analyses. Through the hierarchical modeling framework, joint estimation stabilizes weakly identified parameters and mitigates attenuation bias in brain–behavior correlations. Our toolbox, accompanied by practical code examples, provides an easily accessible introduction for researchers to adopt joint models and to derive richer, more reliable insights into the cognitive and neural mechanisms underlying human behavior.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 15 matches between paragraphs and lines of code.

ampl-psych/EMC2

License: GPL-3.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: beab948d283cfff25139de7f1a2dae11839cfddd, 14 July 2026
Languages: R (77), C++ (20), C/C++ (20), JavaScript (13)
Size: 429 files, 130 scripts
Software Heritage: not archived
Found in: “Data and Code Availability”
Holds: README, license file, environment (DESCRIPTION), tests, continuous integration, documentation, 5 notebooks
Not found: CITATION.cff
Tools: psych (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
132 files

OSF jxhcp

License: none: the authors keep all their rights
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Data and Code Availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
At the source: osf.io/jxhcp/

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 130 scripts, each with its path and the digest of its content;
  • 15 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

Datasets cited

Data and Code Availability

The source code for the EMC2 package can be found at: https://github.com/ampl-psych/EMC2. The accompanying tutorial code can be found at: https://osf.io/jxhcp/. The archival data from Pereira et al. (2020) were retrieved from: https://openneuro.org/datasets/ds002158/versions/1.0.2.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 3 authors, 5 keywords, 2 funders, 86 references, 9 RRIDs.

Cite

This paper

Stevenson, N., Miletić, S., & Forstmann, B. U. (2026). Building bridges between brain and behavior: An open-source toolbox for joint modeling with fMRI. Imaging neuroscience (Cambridge, Mass.), 4, IMAG.a.1272. https://doi.org/10.1162/imag.a.1272

BibTeX

@article{stevenson2026building,
author = {Stevenson, Niek and Miletić, Steven and Forstmann, Birte U.},
title = {{Building bridges between brain and behavior: An open-source toolbox for joint modeling with fMRI}},
journal = {Imaging neuroscience (Cambridge, Mass.)},
year = {2026},
month = jun,
volume = {4},
pages = {IMAG.a.1272},
publisher = {MIT Press},
issn = {2837-6056},
doi = {10.1162/imag.a.1272},
url = {https://doi.org/10.1162/imag.a.1272},
pmid = {42312084},
pmcid = {PMC13271149}
}

RIS

TY - JOUR
AU - Stevenson, Niek
AU - Miletić, Steven
AU - Forstmann, Birte U.
TI - Building bridges between brain and behavior: An open-source toolbox for joint modeling with fMRI
T2 - Imaging neuroscience (Cambridge, Mass.)
J2 - Imaging Neurosci (Camb)
PY - 2026
DA - 2026/06/15
VL - 4
SP - IMAG.a.1272
SN - 2837-6056
PB - MIT Press
DO - 10.1162/imag.a.1272
UR - https://doi.org/10.1162/imag.a.1272
LA - en
ER -

CSL-JSON

{
"id": "10.1162/imag.a.1272",
"type": "article-journal",
"title": "Building bridges between brain and behavior: An open-source toolbox for joint modeling with fMRI",
"container-title": "Imaging neuroscience (Cambridge, Mass.)",
"author": [
{
"family": "Stevenson",
"given": "Niek"
},
{
"family": "Miletić",
"given": "Steven"
},
{
"family": "Forstmann",
"given": "Birte U."
}
],
"container-title-short": "Imaging Neurosci (Camb)",
"volume": "4",
"page": "IMAG.a.1272",
"DOI": "10.1162/imag.a.1272",
"PMID": "42312084",
"PMCID": "PMC13271149",
"ISSN": "2837-6056",
"publisher": "MIT Press",
"URL": "https://doi.org/10.1162/imag.a.1272",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
15
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1523/eneuro.0370-25.2026 [code]
Pretraining for Large-Scale Functional Connectome Fingerprinting Supports Generalization and Transfer Learning in Functional Neuroimaging.
Journal: eNeuro
In common: fMRI, methods / tools, 14 references
[2] doi:10.7554/elife.103956 [code]
Human brain-wide activation of sleep rhythms.
Journal: eLife
In common: 13 references
[3] doi:10.7554/elife.107955
Multiple event segmentation mechanisms in the human brain.
Journal: eLife
In common: 13 references
[4] doi:10.1016/j.celrep.2026.117454 [code]
Model-based and model-free valuation signals in the human brain vary markedly in relation to individual differences in behavioral control.
Journal: Cell reports
In common: 13 references
[5] doi:10.1038/s41597-026-07323-y [code]
A densely sampled fMRI dataset for investigating food valuation.
Journal: Scientific data
In common: fMRI, methods / tools, 12 references
[6] doi:10.1371/journal.pbio.3003755 [code]
Action information is integrated into entorhinal representations of conceptual space and is reflected in eye movements.
Journal: PLoS biology
In common: 12 references
[7] doi:10.1523/jneurosci.2178-25.2026 [code]
Increased Attentive Use Is Linked to More Idiosyncratic Functional Connections.
Journal: The Journal of neuroscience : the official journal of the Society for Neuroscience
In common: fMRI, 11 references
[8] doi:10.1162/imag.a.1209
Naturalistic movie viewing is an effective functional localizer of the fusiform face area in adolescents with and without autism.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: fMRI, 10 references
[9] doi:10.1038/s41597-026-07540-5
A multimodal epilepsy dataset of paired 3-Tesla and 7-Tesla MRI and intracranial EEG.
Journal: Scientific data
In common: methods / tools, 10 references
[10] doi:10.1038/s41467-026-71151-2 [code]
Common and distinct neural correlates of social interaction processing and theory of mind in narratives.
Journal: Nature communications
In common: 10 references

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.