--- title: 'Shared-Basis HRF Matching (SBHM): Efficient Voxel-Specific HRF Estimation' output: rmarkdown::html_vignette: mathjax: default toc: yes toc_depth: 2.0 css: albers.css includes: in_header: albers-header.html params: family: red preset: homage resource_files: - albers.css - albers.js - albers-header.html vignette: | %\VignetteIndexEntry{Shared-Basis HRF Matching (SBHM): Efficient Voxel-Specific HRF Estimation} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup-opts, include = FALSE} if (requireNamespace("ggplot2", quietly = TRUE) && requireNamespace("albersdown", quietly = TRUE)) { ggplot2::theme_set( albersdown::theme_albers(family = params$family, preset = params$preset) ) } knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 8, fig.height = 6, dev = "png", dpi = 150, warning = FALSE, message = FALSE ) # Make vignette render in pkgdown by using a lightweight fast mode fast_mode <- identical(Sys.getenv("IN_PKGDOWN"), "true") || identical(Sys.getenv("CI"), "true") # Always evaluate chunks, but keep computations small in fast mode eval_chunks <- TRUE # Global settings used throughout TR <- 1.0 n_time <- if (fast_mode) 160 else 200 set.seed(123) ``` ```{r albers-classes, echo=FALSE, results='asis'} cat(sprintf( paste0( '' ), params$family, params$preset )) ``` ## The Problem Real fMRI data shows substantial HRF variability across brain regions. A single canonical HRF often fits poorly, but estimating fully unconstrained voxel-wise HRFs is expensive and noisy. SBHM offers a middle path: learn a shared basis from a library of plausible HRFs, then match each voxel to its best library member in a low-dimensional space. The pipeline has four steps: 1. **Build** a shared basis from an HRF library via SVD (`sbhm_build()`) 2. **Prepass** aggregate model fit per voxel (`sbhm_prepass()`) 3. **Match** voxels to library members by cosine similarity (`sbhm_match()`) 4. **Project** trial-wise coefficients to scalar amplitudes (`sbhm_project()`) In practice, `lss_sbhm()` runs all four steps in a single call. ```{r setup, message=FALSE, warning=FALSE} library(fmrihrf) library(fmrilss) # Derived convenience defaults for examples n_voxels_default <- if (fast_mode) 20 else 50 n_trials_default <- if (fast_mode) 6 else 10 ranks_default <- if (fast_mode) 2:6 else 2:10 ``` ## Step 1: Build the Shared Basis We start by defining a grid of gamma HRF parameters spanning physiological variability in peak time and width. ```{r param-grid, eval=eval_chunks} shapes <- if (fast_mode) seq(6, 10, by = 2) else seq(5, 11, by = 1.5) rates <- if (fast_mode) seq(0.8, 1.2, by = 0.2) else seq(0.7, 1.3, by = 0.15) param_grid <- expand.grid(shape = shapes, rate = rates) cat("Library size:", nrow(param_grid), "HRFs\n") ``` Each grid row becomes a gamma HRF. We wrap this in a factory function that `sbhm_build()` can evaluate across the grid. ```{r gamma-fun, eval=eval_chunks} gamma_fun <- function(shape, rate) { f <- function(t) fmrihrf::hrf_gamma(t, shape = shape, rate = rate) fmrihrf::as_hrf(f, name = sprintf("gamma(s=%.2f,r=%.2f)", shape, rate), span = 32) } ``` Now we build the SBHM basis. The SVD decomposes the library into a shared time basis `B`, singular values `S`, and per-HRF coordinates `A`. ```{r build-sbhm, eval=eval_chunks} sframe <- sampling_frame(blocklens = n_time, TR = TR) sbhm <- sbhm_build( library_spec = list(fun = gamma_fun, pgrid = param_grid, span = 32, precision = 0.1, method = "conv"), r = 6, sframe = sframe, baseline = c(0, 0.5), normalize = TRUE, ref = "mean" ) ``` ```{r report-dims, eval=eval_chunks} cat("B (time basis):", dim(sbhm$B), "\n") cat("A (library coords):", dim(sbhm$A), "\n") cat("Singular values:", round(sbhm$S, 2), "\n") ``` ### Visualizing the Basis Each basis function captures a principal mode of HRF variation: the main shape, timing shifts, width differences, and undershoot features. ```{r visualize-basis, fig.width=10, fig.height=6, eval=eval_chunks, fig.alt="Six panels showing SBHM shared basis functions capturing principal modes of HRF variation."} par(mfrow = c(2, 3), mar = c(3, 3, 2, 1)) for (i in 1:ncol(sbhm$B)) { plot(sbhm$tgrid, sbhm$B[, i], type = "l", col = "navy", lwd = 2, main = paste0("Basis ", i, " (s=", round(sbhm$S[i], 2), ")"), xlab = "Time (s)", ylab = "Amplitude") abline(h = 0, col = "gray", lty = 2) } ``` ### Choosing the Rank Aim for 90--95% variance explained. We can sweep ranks to find the elbow. ```{r choose-rank, fig.width=8, fig.height=5, eval=eval_chunks} ranks <- ranks_default ve <- sapply(ranks, function(ri) { sum(sbhm_build(library_spec = list(fun = gamma_fun, pgrid = param_grid, span = 32), r = ri, sframe = sframe, normalize = TRUE)$S^2) }) ``` ```{r plot-rank, fig.width=8, fig.height=5, eval=eval_chunks, fig.alt="Line plot of variance explained vs rank showing elbow around rank 6."} plot(ranks, ve / max(ve) * 100, type = "b", pch = 19, col = "navy", lwd = 2, xlab = "Rank (r)", ylab = "Variance Explained (%)", main = "Choosing SBHM Rank") abline(h = 95, col = "red", lty = 2) grid() ``` ### Library Coverage A sample of rank-r reconstructed HRFs shows the range of shapes the library spans. ```{r library-coverage, fig.width=8, fig.height=5, eval=eval_chunks, fig.alt="Overlaid HRF curves showing the range of shapes spanned by the library reconstruction."} H_hat <- sbhm$B %*% sbhm$A sel <- unique(round(seq(1, ncol(H_hat), length.out = min(ncol(H_hat), 12)))) matplot(sbhm$tgrid, H_hat[, sel, drop = FALSE], type = "l", lty = 1, col = colorRampPalette(c("#6baed6", "#08519c"))(length(sel)), lwd = 1.5, xlab = "Time (s)", ylab = "Amplitude", main = "Sample of Library HRFs (rank-r reconstruction)") abline(h = 0, col = "gray80", lty = 2) ``` ## Step 2: Generate Synthetic Data To demonstrate recovery, we create data where each voxel uses a known HRF from the library. First, set up the experimental design. ```{r design-setup, eval=eval_chunks} n_voxels <- n_voxels_default n_trials <- n_trials_default safe_end <- max(sbhm$tgrid) - 30 onsets <- seq(20, safe_end, length.out = n_trials) ``` Assign each voxel a random library HRF and build per-trial regressors. The fresh `set.seed()` here keeps the HRF assignment reproducible independently of earlier random draws. ```{r assign-hrfs, eval=eval_chunks} set.seed(456) true_hrf_idx <- sample(ncol(sbhm$A), n_voxels, replace = TRUE) design_spec <- list( sframe = sframe, cond = list(onsets = onsets, duration = 0, span = 30) ) hrf_B <- sbhm_hrf(sbhm$B, sbhm$tgrid, sbhm$span) ``` ```{r build-regressors, eval=eval_chunks} regressors_by_trial <- lapply(onsets, function(ot) { rr_t <- regressor(onsets = ot, hrf = hrf_B, duration = 0, span = 30, summate = FALSE) evaluate(rr_t, grid = sbhm$tgrid, precision = 0.1, method = "conv") }) ``` Now generate the signal. Each voxel's response is the sum of per-trial regressors projected through that voxel's HRF coordinates, scaled by a random amplitude, plus noise. ```{r generate-signal, eval=eval_chunks} Y <- matrix(rnorm(n_time * n_voxels, sd = 0.5), n_time, n_voxels) true_amplitudes <- matrix(rnorm(n_trials * n_voxels, mean = 2, sd = 0.5), n_trials, n_voxels) for (v in 1:n_voxels) { alpha_true <- sbhm$A[, true_hrf_idx[v]] for (t in 1:n_trials) Y[, v] <- Y[, v] + true_amplitudes[t, v] * (regressors_by_trial[[t]] %*% alpha_true) } ``` ```{r data-summary, eval=eval_chunks} cat("Y:", dim(Y), "\n") cat("Unique true HRFs:", length(unique(true_hrf_idx)), "\n") ``` ## Step 3: Run the SBHM Pipeline A single call to `lss_sbhm()` runs the entire pipeline. All control lists (`prepass`, `match`, `oasis`, `amplitude`) are optional; pass only what you want to override. See `?lss_sbhm` for full parameter documentation. ```{r run-sbhm, eval=eval_chunks} res_sbhm <- lss_sbhm( Y = Y, sbhm = sbhm, design_spec = design_spec, return = "both" ) ``` ```{r sbhm-dims, eval=eval_chunks} cat("Amplitudes:", dim(res_sbhm$amplitude), "\n") cat("Coefficients:", dim(res_sbhm$coeffs_r), "\n") cat("Matched indices:", length(res_sbhm$matched_idx), "\n") ``` If you use `fmridesign`, prefer `lss_sbhm_design()` for an even simpler interface that mirrors `lss_design()`. See `?lss_sbhm_design`. ## Step 4: Evaluate Recovery ### Matching Accuracy ```{r evaluate-recovery, eval=eval_chunks} accuracy <- mean(res_sbhm$matched_idx == true_hrf_idx) cat("HRF matching accuracy:", round(100 * accuracy, 1), "%\n") cat("Confused voxels:", sum(res_sbhm$matched_idx != true_hrf_idx), "/", n_voxels, "\n") ``` ### Matching Confidence The `margin` (top-1 minus top-2 cosine score) indicates how unambiguous each assignment is. Higher is better. ```{r margin-stats, eval=eval_chunks} cat("Margin -- mean:", round(mean(res_sbhm$margin), 3), " median:", round(median(res_sbhm$margin), 3), "\n") ``` ```{r visualize-margin, fig.width=8, fig.height=5, eval=eval_chunks, fig.alt="Histogram of matching confidence margins with median line."} hist(res_sbhm$margin, breaks = 20, col = "skyblue", border = "white", main = "Matching Confidence (Margin)", xlab = "Margin (top1 - top2 cosine score)") abline(v = median(res_sbhm$margin), col = "red", lwd = 2, lty = 2) grid() ``` In this simulation the median margin is small, which means many voxels sit close to multiple library members. That is exactly the regime where soft blending (see below) can help. ### Amplitude Recovery ```{r compare-amplitudes, fig.width=8, fig.height=5, eval=eval_chunks, fig.alt="Scatter plot of true vs estimated amplitudes showing recovery quality."} cor_amp <- cor(as.vector(res_sbhm$amplitude), as.vector(true_amplitudes)) plot(as.vector(true_amplitudes), as.vector(res_sbhm$amplitude), pch = 19, col = adjustcolor("navy", alpha.f = 0.3), xlab = "True Amplitude", ylab = "Estimated Amplitude", main = paste0("Amplitude Recovery (r = ", round(cor_amp, 3), ")")) abline(0, 1, col = "red", lwd = 2, lty = 2) grid() ``` ```{r recovery-summary, eval=eval_chunks} recovery_summary <- data.frame( HRFMatchingAccuracy = accuracy, AmplitudeCorrelation = cor_amp, MedianMargin = median(res_sbhm$margin) ) recovery_summary ``` ```{r recovery-summary-check, include = FALSE, eval = eval_chunks} stopifnot(all(is.finite(as.matrix(recovery_summary)))) ``` ### Recovered HRF Shapes For the most confidently matched voxels, the recovered and true HRFs should overlap closely. ```{r hrf-prep, eval=eval_chunks} H_hat <- sbhm$B %*% sbhm$A vox_show <- head(order(-res_sbhm$margin), n = min(6, n_voxels)) ``` ```{r matched-vs-true, fig.width=10, fig.height=6, eval=eval_chunks, fig.alt="Six panels comparing true and matched HRF shapes for the most confident voxels."} par(mfrow = c(2, 3), mar = c(3, 3, 2, 1)) for (v in vox_show) { rng <- range(c(H_hat[, true_hrf_idx[v]], H_hat[, res_sbhm$matched_idx[v]])) plot(sbhm$tgrid, H_hat[, true_hrf_idx[v]], type = "l", col = "#2c7fb8", lwd = 2, ylim = rng, main = paste0("Voxel ", v), xlab = "Time (s)", ylab = "HRF") lines(sbhm$tgrid, H_hat[, res_sbhm$matched_idx[v]], col = "#d95f02", lwd = 2, lty = 2) legend("topright", bty = "n", cex = 0.8, legend = c("True", "Matched"), lty = 1:2, lwd = 2, col = c("#2c7fb8", "#d95f02")) } ``` ### Library Manifold PCA of the library coordinates shows which HRFs were present (true) versus selected (matched). ```{r pca-library, fig.width=8, fig.height=5, eval=eval_chunks, fig.alt="PCA scatter of library coordinates showing true and matched HRF positions."} pca <- prcomp(t(sbhm$A), center = TRUE, scale. = TRUE) pc <- pca$x[, 1:2, drop = FALSE] plot(pc, pch = 16, col = "gray70", xlab = "PC1", ylab = "PC2", main = "Library Coordinate Space (PCA)") points(pc[unique(true_hrf_idx), , drop = FALSE], pch = 1, col = "#2c7fb8", lwd = 2) points(pc[unique(res_sbhm$matched_idx), , drop = FALSE], pch = 4, col = "#d95f02", lwd = 2) legend("topright", bty = "n", legend = c("Library", "True", "Matched"), pch = c(16, 1, 4), col = c("gray60", "#2c7fb8", "#d95f02")) ``` ## Soft Assignment for Ambiguous Voxels Hard assignment picks the single best HRF per voxel. When the margin is small, blending the top-K candidates can reduce variance. The built-in approach requires just two extra arguments. ```{r soft-blend, eval=eval_chunks} res_soft <- lss_sbhm( Y = Y, sbhm = sbhm, design_spec = design_spec, match = list(topK = 3, soft_blend = TRUE, blend_margin = median(res_sbhm$margin)), return = "amplitude" ) ``` ```{r soft-compare, eval=eval_chunks} cor_sv <- cor(as.vector(res_soft$amplitude), as.vector(res_sbhm$amplitude)) cat("Soft vs hard amplitude correlation:", round(cor_sv, 3), "\n") ``` Blending only applies to voxels whose margin falls below `blend_margin`; confident voxels keep their hard assignment. For manual control over the blending weights, use `sbhm_prepass()`, `sbhm_match(topK = K)`, and `sbhm_project()` directly. See `?sbhm_match` for details. ## Shape Estimation Strategies By default, SBHM estimates each voxel's HRF shape from an aggregate prepass fit (`alpha_source = "prepass"`). Two alternative strategies are available when the prepass is unreliable: - `"trial_projection"` estimates shape from per-trial projection coefficients in the shared basis, using the leading singular vector across trials. - `"oasis_rank1"` extracts shape from the rank-1 approximation of the trial-wise OASIS betas. Set `rank1_min` to gate voxels with low rank-1 variance fraction back to prepass. ```{r alpha-source, eval=FALSE} res_proj <- lss_sbhm( Y, sbhm, design_spec, match = list(alpha_source = "trial_projection") ) ``` ### Low-Confidence Gating For voxels where matching is uncertain (low margin or weak signal), you can gate them to a fallback shape using `min_margin` and `min_beta_norm`. Gated voxels use the reference coordinate instead of the matched one. ```{r gating-example, eval=FALSE} res_gated <- lss_sbhm( Y, sbhm, design_spec, match = list(min_margin = 0.05, min_beta_norm = 1e-3) ) cat("Voxels gated to fallback:", res_gated$diag$fallback_low_conf_n, "\n") ``` ## Amplitude Policy The final amplitude stage estimates per-trial scalars from the matched HRF. The `amplitude$method` argument selects the estimator: `"lss1"` (default, per-trial 2x2 LSS---robust under overlap), `"global_ls"` (fast ridge-regularized GLM across all trials), or `"oasis_voxel"` (full OASIS per voxel, heaviest but returns SEs). The default `"lss1"` provides the best balance of robustness and accuracy for typical rapid event-related designs. For slow designs with well-separated trials, `"global_ls"` can be faster. Use `cond_gate` to fall back automatically when the design is too collinear for a given method. ```{r amplitude-policy, eval=FALSE} out <- lss_sbhm( Y, sbhm, design_spec, amplitude = list(method = "lss1", ridge_frac = list(x = 0.02, b = 0.02), cond_gate = list(metric = "rho", thr = 0.999, fallback = "global_ls")) ) ``` See `?lss_sbhm` for guidance on choosing by ISI regime (slow, moderate, fast ER). ## Returning Coefficients for Custom Analysis When you need the full r-dimensional trial-wise coefficients instead of scalar amplitudes, request `return = "coefficients"`. ```{r coeffs-demo, eval=eval_chunks} res_c <- lss_sbhm(Y = Y[, 1:5], sbhm = sbhm, design_spec = design_spec, return = "coefficients") cat("Coefficients:", dim(res_c$coeffs_r), " [r x trials x voxels]\n") ``` ## Parameter Quick Reference | Parameter | Default | Recommended | Notes | |-----------|---------|-------------|-------| | `r` (rank) | -- | 6--12 | Aim for 90--95% variance explained | | `topK` | 3 | 1--5 | Use 3--5 with `soft_blend = TRUE` for ambiguous cases | | `soft_blend` | TRUE | TRUE | Blend top-K candidates for uncertain voxels | | `blend_margin` | 0.08 | 0.05--0.15 | Only blend voxels with margin below this | | `alpha_source` | `"prepass"` | `"prepass"` | Also `"trial_projection"` or `"oasis_rank1"` | | `prepass$ridge` | NULL | `list(mode = "fractional", lambda = 0.01)` | Stabilizes noisy/collinear designs | | `match$shrink$tau` | 0 | 0--0.2 | Increase for low SNR | | `match$whiten` | FALSE | FALSE | Set TRUE with `whiten_power = 0.5` for partial whitening | | `match$min_margin` | NULL | 0.05--0.1 | Gate low-confidence voxels to fallback shape | | `prewhiten` | NULL | `list(method = "ar", p = 1L)` | Use for TR < 2s | | `amplitude$method` | `"lss1"` | varies by ISI | `"global_ls"` for slow ER, `"lss1"` otherwise | For full details on every parameter, see `?sbhm_build`, `?sbhm_match`, and `?lss_sbhm`. ## Performance Considerations SBHM's cost scales as O(T*r*N*V) for the LSS step, where N is the number of trials. Compared to unconstrained voxel-wise HRF estimation this is typically 10--50x faster, since r << K*N. For very large datasets (V > 50,000), PCA factorization reduces memory by fitting the prepass on q "meta-voxels" instead of V voxels. ```{r factorized, eval=FALSE} pca_Y <- prcomp(Y, center = TRUE, rank. = 100) res <- lss_sbhm( Y = pca_Y$x, sbhm = sbhm, design_spec = design_spec, prepass = list(data_fac = list(scores = pca_Y$x, loadings = pca_Y$rotation)) ) ``` For ROI-based analyses, simply subset columns of Y before calling `lss_sbhm()`. A benchmark script is available via `system.file("benchmarks", "bench_sbhm.R", package = "fmrilss")`. ## Prewhitening If your TR is short (< 2s) or residuals show autocorrelation, add prewhitening to the final OASIS step. ```{r prewhiten-example, eval=FALSE} res_pw <- lss_sbhm( Y = Y, sbhm = sbhm, design_spec = design_spec, prewhiten = list(method = "ar", p = 1L, pooling = "global", exact_first = "ar1") ) ``` Note: the factorized prepass intentionally skips prewhitening for efficiency. The final OASIS step still applies it when `prewhiten` is provided. ## References - Mumford et al. (2012). "Deconvolving BOLD activation in event-related designs for multivoxel pattern classification analyses." *NeuroImage*. - Lindquist et al. (2009). "Modeling the hemodynamic response function in fMRI." *NeuroImage*. ## Summary SBHM provides efficient, interpretable voxel-specific HRF estimation by learning a shared basis from a plausible library and matching each voxel in a low-dimensional coefficient space. The `lss_sbhm()` function runs the full pipeline in a single call, returning trial-wise amplitudes with per-voxel HRF assignments and confidence scores. **Next steps:** see `?sbhm_build` for library construction, `?lss_sbhm` for the end-to-end pipeline, and the "Voxel-wise HRF" vignette for unconstrained alternatives.