vignettes/group_analysis.Rmd
group_analysis.Rmd
title: “07. Group Analysis” author: “Bradley R. Buchsbaum” date: “2025-09-24” output: rmarkdown::html_vignette vignette: > % % % —
Overview
This vignette walks through a compact, end‑to‑end example of group
analysis with fmrireg. We first construct a small ROI‑level dataset to
illustrate the basic meta‑regression interface, and then move to a
voxelwise example using tiny synthetic NIfTI files. Along the way we
show how to compare groups with fixed‑ and random‑effects meta‑analysis,
how to obtain exact contrasts either at fit‑time or post‑hoc by saving
covariance, and how to perform group inference from t‑maps alone using
Stouffer, Fisher, or Lancaster combinations. The same interface scales
to full HDF5/NIfTI workflows created by write_results()
and
loaded via group_data(format = "h5"|"nifti")
.
Create a small ROI dataset
We simulate 10 subjects across two groups (A/B) for a single ROI. Group B has an effect that is 1 unit larger than group A. All subjects have the same SE for clarity.
n_per_group <- 5
subjects <- sprintf("s%02d", 1:(2 * n_per_group))
group <- factor(rep(c("A", "B"), each = n_per_group))
# True effects: A = 0.5, B = 1.5 (difference = 1.0)
beta <- ifelse(group == "A", 0.5, 1.5)
se <- rep(0.25, length(beta))
roi_df <- data.frame(
subject = subjects,
roi = "ExampleROI",
beta = beta,
se = se,
group = group,
stringsAsFactors = FALSE
)
# Build group dataset (CSV/ROI format)
gd <- group_data_from_csv(
roi_df,
effect_cols = c(beta = "beta", se = "se"),
subject_col = "subject",
roi_col = "roi",
covariate_cols = c("group")
)
gd
#> Group Data Object
#> Format: csv
#> Subjects: 10
#> Covariates: group
Fit group meta-analysis
We first fit an intercept-only model, then a model including a group term.
# Intercept-only (grand mean across subjects)
fit_fe <- fmri_meta(gd, formula = ~ 1, method = "fe", verbose = FALSE)
# Intercept + group term (difference-coding for group B relative to A)
fit_cov <- fmri_meta(gd, formula = ~ 1 + group, method = "fe", verbose = FALSE)
print(fit_cov)
#> fMRI Meta-Analysis Results
#> ==========================
#>
#> Method: fe
#> Robust: none
#> Formula: ~1 + group
#> Subjects: 10
#> ROIs analyzed: 1
#>
#> Heterogeneity:
#> Mean tau^2: 0
#> Mean I^2: NaN %
summary(fit_cov)
#> fMRI Meta-Analysis Summary
#> ==========================
#>
#> fMRI Meta-Analysis Results
#> ==========================
#>
#> Method: fe
#> Robust: none
#> Formula: ~1 + group
#> Subjects: 10
#> ROIs analyzed: 1
#>
#> Heterogeneity:
#> Mean tau^2: 0
#> Mean I^2: NaN %
#>
#> Coefficients:
#> (Intercept):
#> Mean effect: 0.5
#> Mean SE: 0.1118034
#> Significant:1/1 (100%)
#> groupB:
#> Mean effect: 1
#> Mean SE: 0.1581139
#> Significant:1/1 (100%)
Note: With equal SE per subject, fixed-effects and random-effects
will yield similar point estimates. Random-effects
(method = "pm"
) will estimate between- subject
heterogeneity (tau2
) when present.
fit_pm <- fmri_meta(gd, formula = ~ 1 + group, method = "pm", verbose = FALSE)
summary(fit_pm)
#> fMRI Meta-Analysis Summary
#> ==========================
#>
#> fMRI Meta-Analysis Results
#> ==========================
#>
#> Method: pm
#> Robust: none
#> Formula: ~1 + group
#> Subjects: 10
#> ROIs analyzed: 1
#>
#> Heterogeneity:
#> Mean tau^2: 0
#> Mean I^2: NaN %
#>
#> Coefficients:
#> (Intercept):
#> Mean effect: 0.5
#> Mean SE: 0.1118034
#> Significant:1/1 (100%)
#> groupB:
#> Mean effect: 1
#> Mean SE: 0.1581139
#> Significant:1/1 (100%)
Extract coefficients and a contrast
coef_names <- colnames(fit_cov$coefficients)
coef_names
#> [1] "(Intercept)" "groupB"
# Intercept should be near 0.5, the group coefficient near 1.0
coef_est <- as.numeric(fit_cov$coefficients[1, ])
names(coef_est) <- coef_names
coef_est
#> (Intercept) groupB
#> 0.5 1.0
# Build a simple contrast on the group term (if present)
if (any(grepl("group", coef_names))) {
# Create a named weight vector that picks out the group coefficient
w <- rep(0, length(coef_names)); names(w) <- coef_names
w[grep("group", coef_names)] <- 1
con <- contrast(fit_cov, w)
con
}
#> $estimate
#> [1] 1
#>
#> $se
#> [1] 0.1581139
#>
#> $z
#> [1] 6.324555
#>
#> $p
#> [,1]
#> ExampleROI 2.539629e-10
#>
#> $weights
#> (Intercept) groupB
#> 0 1
#>
#> $name
#> [1] "groupB"
#>
#> $parent
#> fMRI Meta-Analysis Results
#> ==========================
#>
#> Method: fe
#> Robust: none
#> Formula: ~1 + group
#> Subjects: 10
#> ROIs analyzed: 1
#>
#> Heterogeneity:
#> Mean tau^2: 0
#> Mean I^2: NaN %
#>
#> attr(,"class")
#> [1] "fmri_meta_contrast"
A quick visualization
We can visualize the group effects and their 95% CIs for the ROI-level fit.
df_tidy <- tidy(fit_cov, conf.int = TRUE)
df_tidy
#> # A tibble: 2 × 10
#> roi term estimate std.error statistic p.value tau2 I2 conf.low
#> <chr> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 ExampleROI (Interc… 0.5 0.112 4.47 7.74e- 6 0 NA 0.281
#> 2 ExampleROI groupB 1 0.158 6.32 2.54e-10 0 NA 0.690
#> # ℹ 1 more variable: conf.high <dbl>
ggplot(df_tidy, aes(x = term, y = estimate, ymin = conf.low, ymax = conf.high)) +
geom_pointrange() +
geom_hline(yintercept = 0, linetype = 2) +
labs(title = "ROI-level group meta-analysis",
x = "Term", y = "Estimate ± 95% CI") +
theme_minimal()
Notes on voxelwise analysis
For voxelwise analysis, construct group_data
with format
"h5"
or "nifti"
:
# HDF5 (produced via write_results.fmri_lm)
# gd_h5 <- group_data(h5_paths, format = "h5",
# subjects = subject_ids,
# contrast = "ContrastName",
# covariates = data.frame(group = group))
# NIfTI (provide per-subject paths for beta/SE or t)
# gd_nii <- group_data(list(beta = beta_paths, se = se_paths), format = "nifti",
# subjects = subject_ids,
# mask = "group_mask.nii.gz")
# fit <- fmri_meta(gd_h5, formula = ~ 1 + group, method = "pm")
# fit <- fmri_meta(gd_nii, formula = ~ 1, method = "fe")
For multiple comparisons correction that leverages spatial structure,
see spatial_fdr()
and create_3d_blocks()
.
Minimal NIfTI Example (Reproducible)
This chunk creates tiny synthetic NIfTI volumes on disk (in a temp dir) for a voxelwise demonstration. Group B has a higher effect in a small cube.
library(neuroim2)
set.seed(42)
tmpdir <- tempdir()
space <- NeuroSpace(c(8, 8, 8), spacing = c(2, 2, 2))
n_per_group <- 3
ids <- sprintf("sub-%02d", 1:(2 * n_per_group))
grp <- factor(rep(c("A", "B"), each = n_per_group))
# Define a small active cube: x=3:5, y=3:5, z=3:5
active <- array(FALSE, dim = c(8, 8, 8))
active[3:5, 3:5, 3:5] <- TRUE
beta_paths <- character(length(ids))
se_paths <- character(length(ids))
for (i in seq_along(ids)) {
b <- array(0, dim = c(8, 8, 8))
# Baseline effect in active region
b[active] <- if (grp[i] == "A") 0.5 else 1.5
# Small random noise per voxel (optional)
b <- b + array(rnorm(length(b), sd = 0.05), dim = dim(b))
v_beta <- NeuroVol(b, space)
# Constant SE per voxel
s <- array(0.25, dim = c(8, 8, 8))
v_se <- NeuroVol(s, space)
beta_paths[i] <- file.path(tmpdir, sprintf("%s_beta.nii.gz", ids[i]))
se_paths[i] <- file.path(tmpdir, sprintf("%s_se.nii.gz", ids[i]))
write_vol(v_beta, beta_paths[i])
write_vol(v_se, se_paths[i])
}
# Mask covers all voxels
mask_path <- file.path(tmpdir, "mask.nii.gz")
write_vol(NeuroVol(array(1, dim = c(8, 8, 8)), space), mask_path)
# Build group_data and fit voxelwise meta-analysis
gd_nii <- group_data_from_nifti(
beta_paths = beta_paths,
se_paths = se_paths,
subjects = ids,
covariates = data.frame(group = grp),
mask = mask_path
)
fit_nii <- fmri_meta(gd_nii, formula = ~ 1 + group, method = "fe", verbose = FALSE)
fit_nii
#> fMRI Meta-Analysis Results
#> ==========================
#>
#> Method: fe
#> Robust: none
#> Formula: ~1 + group
#> Subjects: 6
#> Voxels analyzed: 512
#>
#> Heterogeneity:
#> Mean tau^2: 0
#> Mean I^2: 0 %
# Get p-values for the group term and count discoveries (uncorrected)
group_col <- grep("group", colnames(fit_nii$coefficients))
pvals <- 2 * pnorm(-abs(fit_nii$coefficients[, group_col] / fit_nii$se[, group_col]))
sum(pvals < 0.05)
#> [1] 27
# Optionally, apply spatial FDR (group term), using simple blocks
sfr <- spatial_fdr(fit_nii, p = colnames(fit_nii$coefficients)[group_col], group = "blocks")
sum(sfr$reject)
#> [1] 75
# Reconstruct an image for the group effect estimate
img_group_est <- coef_image(fit_nii, colnames(fit_nii$coefficients)[group_col], statistic = "estimate")
range(as.array(img_group_est), na.rm = TRUE)
#> [1] -0.09585902 1.08515382
Exact contrasts and stored covariance
You can request exact contrasts at fit-time or store per-voxel covariance for exact post-hoc contrasts.
# Exact post-hoc contrasts by storing packed Var(beta) per voxel
fit_nii_pm <- fmri_meta(
gd_nii, formula = ~ 1 + group, method = "pm",
return_cov = "tri", verbose = FALSE
)
# Exact post-hoc contrast on the group term
con <- contrast(fit_nii_pm, c("(Intercept)" = 0, group = 1))
summary(con)
#> Length Class Mode
#> estimate 512 -none- numeric
#> se 512 -none- numeric
#> z 512 -none- numeric
#> p 512 -none- numeric
#> weights 2 -none- numeric
#> name 1 -none- character
#> parent 17 fmri_meta list
# Exact fit-time contrast without storing covariance
fit_nii_con <- fmri_meta(
gd_nii, formula = ~ 1 + group, method = "pm",
contrasts = matrix(c(0, 1), nrow = 1,
dimnames = list("group", colnames(fit_nii_pm$model$X))),
verbose = FALSE
)
str(fit_nii_con$contrasts)
#> List of 4
#> $ names : chr "group"
#> $ estimate: num [1:512, 1] 2.05e-02 1.79e-02 -3.49e-03 -5.51e-02 3.53e-05 ...
#> $ se : num [1:512, 1] 0.204 0.204 0.204 0.204 0.204 ...
#> $ z : num [1:512, 1] 0.100565 0.0878 -0.017112 -0.269929 0.000173 ...
Two-sample t-test (Welch and OLS) on NIfTI
As an alternative to meta-analysis, we can run two-sample voxelwise t-tests directly on the per-subject beta maps, using either Welch’s unequal-variance test or a standard OLS/Student t-test via a simple design matrix.
# Welch and classic OLS via high-level R wrapper
fit_welch <- fmri_ttest(gd_nii, formula = ~ 1 + group, engine = "welch")
t_welch <- as.numeric(fit_welch$t["group", ])
df_welch <- as.numeric(fit_welch$df["group", ])
p_welch <- 2 * pt(abs(t_welch), df = df_welch, lower.tail = FALSE)
fit_ols <- fmri_ttest(gd_nii, formula = ~ 1 + group, engine = "classic")
# Prefer named row; fallback to 2nd row if rownames are missing
rn_t <- rownames(fit_ols$t)
if (!is.null(rn_t) && any(rn_t == "group")) {
t_ols <- fit_ols$t["group", ]
df_ols <- as.numeric(fit_ols$df["group", ])
} else {
t_ols <- fit_ols$t[2, ]
df_ols <- rep(fit_ols$df[2, 1], length(t_ols))
}
p_ols <- 2 * pt(abs(t_ols), df = df_ols, lower.tail = FALSE)
# Reconstruct quick image for Welch t (using the same mask/space)
timg_welch <- NeuroVol(array(NA_real_, dim = c(8, 8, 8)), space)
mask_img <- if (!is.null(gd_nii$mask_data)) gd_nii$mask_data else neuroim2::read_vol(mask_path)
timg_welch[as.array(mask_img) > 0] <- t_welch
range(as.array(timg_welch), na.rm = TRUE)
#> [1] -81.539233 4.582865
# Count uncorrected significant voxels at alpha=0.05
sum(p_welch < 0.05)
#> [1] 46
sum(p_ols < 0.05)
#> [1] 51
Combining t-statistics only (Stouffer/Fisher/Lancaster)
When only per‑subject t‑statistics and degrees‑of‑freedom are
available, you can still carry out group inference without betas/SEs by
setting combine =
in fmri_meta()
(or in
fmri_ttest(..., engine = "meta")
). Stouffer combines signed
z‑scores and supports equal, inverse‑variance or custom weights; Fisher
combines p‑values with equal weights; and Lancaster provides a weighted
Fisher variant by mapping weights to per‑subject degrees‑of‑freedom.
# Derive per-subject t images from the synthetic beta/SE example
dat_full <- read_nifti_full(gd_nii)
tmat <- dat_full$beta / dat_full$se # S x P
# Write t images to tempdir for illustration
t_paths <- character(length(ids))
for (i in seq_along(ids)) {
img <- NeuroVol(array(NA_real_, dim = c(8, 8, 8)), space)
img[as.array(neuroim2::read_vol(mask_path)) > 0] <- tmat[i, ]
pth <- file.path(tmpdir, sprintf("%s_t.nii.gz", ids[i]))
write_vol(img, pth)
t_paths[i] <- pth
}
gd_t <- group_data_from_nifti(
t_paths = t_paths,
df = 60, # scalar df replicated per subject for demo
subjects = ids,
covariates = data.frame(group = grp),
mask = mask_path
)
# Equal-weight Stouffer
fit_st <- fmri_meta(gd_t, formula = ~ 1, combine = "stouffer", verbose = FALSE)
# Weighted Stouffer using custom subject weights (e.g., sample size)
w_subj <- rep(1, length(ids))
fit_st_w <- fmri_meta(gd_t, formula = ~ 1, combine = "stouffer",
weights = "custom", weights_custom = w_subj,
verbose = FALSE)
# Fisher (equal weights) and Lancaster (weighted Fisher)
fit_fi <- fmri_meta(gd_t, formula = ~ 1, combine = "fisher", verbose = FALSE)
fit_la <- fmri_meta(gd_t, formula = ~ 1, combine = "lancaster",
weights = "custom", weights_custom = w_subj,
verbose = FALSE)
Meta engine via fmri_ttest with weights
The t-test interface supports the meta engine with equal/custom weighting.
fit_tt_meta <- fmri_ttest(gd_nii, formula = ~ 1 + group, engine = "meta",
weights = "equal")
# Meta engine with custom subject weights (e.g., sample sizes or reliability)
w_subj <- rep(1, length(ids))
fit_tt_meta_w <- fmri_ttest(gd_nii, formula = ~ 1 + group, engine = "meta",
weights = "custom", weights_custom = w_subj)
# t-only combine via fmri_ttest delegation (Lancaster, weighted Fisher)
fit_tt_la <- fmri_ttest(gd_t, formula = ~ 1, engine = "meta",
combine = "lancaster", weights = "custom",
weights_custom = w_subj)
ROI t-only example (Stouffer and Lancaster)
You can also combine t-statistics at the ROI level from a tabular CSV. Provide per-subject t and df, then choose a combine method.
roi_t_df <- data.frame(
subject = subjects,
roi = "ExampleROI",
t = rnorm(length(subjects), mean = 2.0, sd = 0.5),
df = 40,
stringsAsFactors = FALSE
)
gd_roi_t <- group_data_from_csv(
roi_t_df,
effect_cols = c(t = "t", df = "df"),
subject_col = "subject",
roi_col = "roi"
)
# Equal-weight Stouffer on ROI t-statistics
fit_roi_st <- fmri_meta(gd_roi_t, formula = ~ 1, combine = "stouffer", verbose = FALSE)
# Lancaster (weighted Fisher) with custom weights (e.g., per-subject reliability)
w_roi <- rep(1, length(subjects))
fit_roi_la <- fmri_meta(gd_roi_t, formula = ~ 1, combine = "lancaster",
weights = "custom", weights_custom = w_roi,
verbose = FALSE)
c(fit_roi_st$method, fit_roi_la$method)
#> [1] "pm" "pm"
A brief recap
Meta‑analysis in fmrireg supports fixed‑effects and several
random‑effects estimators (method = "fe"|"pm"|"dl"|"reml"
),
with optional robust Huber weighting. You can pass subject‑level
covariates for group comparisons and, when working from t‑maps only, set
combine =
to use Stouffer, Fisher, or Lancaster. Exact
contrasts are available either at fit‑time (via contrasts=
)
or post‑hoc by saving packed covariance with
return_cov = "tri"
and then calling
contrast()
. Weighting applies to both meta‑regression and
t‑only combinations (weights = "ivw"|"equal"|"custom"
, with
weights_custom
as a vector of length subjects or an S×P
matrix). The examples above show ROI‑based meta‑regression, voxelwise
fits from NIfTI, and t‑only combinations via both
fmri_meta()
and
fmri_ttest(..., engine = "meta")
.