--- title: Repeated-measures mixed models output: rmarkdown::html_vignette: toc: yes toc_depth: 2.0 css: albers.css includes: in_header: albers-header.html params: family: red preset: interaction resource_files: - albers.css - albers.js - albers-header.html - fonts vignette: | %\VignetteIndexEntry{Repeated-measures mixed models} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} if (requireNamespace("ragg", quietly = TRUE)) knitr::opts_chunk$set(dev = "ragg_png") if (requireNamespace("albersdown", quietly = TRUE)) { if (requireNamespace("systemfonts", quietly = TRUE)) try(albersdown::albers_register_fonts(), silent = TRUE) if (requireNamespace("ggplot2", quietly = TRUE)) try(ggplot2::theme_set(albersdown::theme_albers(family = params$family, preset = params$preset)), silent = TRUE) } knitr::opts_chunk$set( collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE ) library(fmrigds) set.seed(1) ``` ```{r albers-classes, echo=FALSE, results='asis'} cat(sprintf( paste0( '' ), params$family, params$preset )) ``` Repeated-measures fMRI analyses are often statistically simple but operationally awkward. You have one effect estimate per ROI or voxel, per subject, per visit or condition, and you want a group model that respects the repeated structure without hand-writing a loop over samples. `fmrigds` gives you a narrow, fast path for that case. This vignette shows the supported contract: one grouping factor, Gaussian responses, shared observation layout across samples, and either a random intercept or a random intercept plus one within-subject slope. By the end, you will have one model-level GDS containing fixed-effect estimates and variance-component assays that you can inspect or write out like any other group result. You will also know when to use an unweighted model, when to treat first-level variances as known sampling variances, what `pooled` actually pools, and which claims the available summary statistics can support. ## What does the input look like? The easiest route is a stacked long table with one row per sample, subject, and repeated-measure level. Here we build a small ROI example with three visits: ```{r make-long-table} subjects <- paste0("sub-", sprintf("%02d", 1:6)) visits <- c("pre", "mid", "post") time <- c(-1, 0, 1) rm_df <- expand.grid( sample = c("ROI_1", "ROI_2"), subject = subjects, contrast = visits, KEEP.OUT.ATTRS = FALSE, stringsAsFactors = FALSE ) subject_intercept <- c(-0.25, -0.10, 0.00, 0.10, 0.20, 0.30) names(subject_intercept) <- subjects subject_slope <- c(-0.10, -0.04, 0.00, 0.02, 0.06, 0.12) names(subject_slope) <- subjects sample_shift <- c(ROI_1 = 0.00, ROI_2 = 0.35) sample_slope <- c(ROI_1 = 0.45, ROI_2 = 0.70) sample_slope_scale <- c(ROI_1 = 0.5, ROI_2 = 2.0) rm_df$time <- time[match(rm_df$contrast, visits)] rm_signal <- 0.9 + sample_shift[rm_df$sample] + subject_intercept[rm_df$subject] + (sample_slope[rm_df$sample] + sample_slope_scale[rm_df$sample] * subject_slope[rm_df$subject]) * rm_df$time rm_df$beta <- rm_signal + rnorm(nrow(rm_df), sd = 0.06) knitr::kable(head(rm_df), row.names = FALSE) ``` The `time` column is constant within each visit, so it can be collapsed into contrast-level metadata during ingestion. The two simulated ROIs deliberately have different amounts of subject-to-subject slope variation; that makes the pooled-versus-voxelwise assumption visible later. The response is `beta`. Despite the name, it need not be a raw regression coefficient: it can be any approximately Gaussian subject-level effect estimate with a consistent definition and scale across subjects and visits. Choose the reducer from the information that is actually available: | Available input | Recommended reducer | What the model can claim | |---|---|---| | Effect estimate only | `lmm:ri` or `lmm:ri_slope1` | Models the empirical second-level covariance, without separating first-level sampling error | | Effect estimate plus variance or standard error | Matching `*_knownvar` reducer | Treats the supplied marginal sampling variance as known and estimates additional heterogeneity | | Effect estimate plus *t* or *z* | Derive or supply `var` only when the effect-scale standard error can be recovered | Same interpretation as `*_knownvar`; the test statistic alone is not the variance | | *t* or *z* only | No effect-scale repeated-measures LMM | The effect magnitude and its sampling variance cannot generally be recovered from a standardized statistic alone | A *t* or *z* statistic is therefore not interchangeable with an effect estimate: its scale also depends on uncertainty and, for *t*, degrees of freedom. When an unthresholded *t* map was computed exactly as `beta / se` for the same contrast, the effect-scale variance can be recovered as `(beta / t)^2`; verify that provenance before using it. A *z* map may instead be a signed transformation of a *p* value, in which case `beta / z` is not the first-level standard error. If a trustworthy `se` assay is available, the known-variance reducers derive `var` from it automatically. If the scientific target is evidence combination rather than an effect-scale repeated-measures model, a method such as `combine:stouffer` may be appropriate after accounting for dependence. Repeated observations from the same subject are not independent inputs to an ordinary Stouffer combination. ## What model is fitted? For sample $v$, subject $i$, and repeated-measure level $r$, every random intercept plus slope reducer uses the same conditional mean: \[ y_{vir} = x_{ir}^{\mathsf T}\beta_v + b_{0iv} + t_r b_{1iv} + \epsilon_{vir}. \] Here, $x_{ir}$ is the row of the fixed-effects design built from `formula`, and $t_r$ is the numeric variable named by `options$slope`. The simpler random-intercept reducers drop $b_{1iv}$. The two reducer families differ in their marginal covariance. The unweighted methods assume \[ \begin{aligned} (b_{0iv}, b_{1iv})^{\mathsf T} &\sim N(0,\;\sigma_v^2 \Lambda_v),\\ \epsilon_{vir} &\sim N(0,\;\sigma_v^2). \end{aligned} \] This parameterization separates the sample-specific residual scale — `sigma2` or `vc_resid` — from relative random-effect variances in λ (`lambda_intercept`, `lambda_slope`, and, for a full covariance, `lambda_cov_intercept_slope`). The absolute random-effect variances reported as `vc_intercept` and `vc_slope` are λ multiplied by `sigma2`. The simpler `lmm:ri` reducer drops $b_{1iv}$ and estimates one intercept-to-residual variance ratio, `lambda`. The `*_knownvar` methods instead use the supplied sampling-variance vector $d_v$: \[ V_v = \operatorname{diag}(d_v) + \sigma_{\text{resid},v}^2 I + ZG_vZ^{\mathsf T}. \] Here, $G_v$ contains absolute random-effect variances and $\sigma_{\text{resid},v}^2$ is additional observation-level heterogeneity. The known sampling variances establish an absolute scale, so these reducers report `vc_intercept`, `vc_slope`, and `vc_resid` but not relative `lambda_*` assays. They treat `var` as fixed input rather than estimating a common multiplier for it. `covariance = "diag"` fixes the intercept–slope covariance to zero. `covariance = "full"` estimates that covariance through a Cholesky parameterization and also reports `corr_intercept_slope`. The full model is more flexible, but it spends an additional variance parameter and can be weakly identified when there are few subjects or few repeated-measure levels. Centering the slope variable before ingestion makes the random intercept refer to a meaningful time point and usually makes the intercept–slope covariance easier to interpret. ## What is the fastest end-to-end path? Use `gds(..., contrast_data_cols = "time")` to put the repeated-measure metadata on the contrast axis, then call `reduce()` with one of the restricted LMM reducers. The first example has effect estimates but no sampling variances, so it uses the unweighted method: ```{r quick-win} g_rm <- gds(rm_df, contrast_data_cols = "time") |> reduce( method = "lmm:ri_slope1", formula = ~ time, options = list( slope = "time", covariance = "diag", fit = "REML", theta_mode = "voxelwise" ) ) |> compute() ``` ```{r quick-win-checks, include = FALSE} lmm_summary <- data.frame( sample = sample_labels(g_rm), intercept = as.numeric(assay(g_rm, "coef:(Intercept)")), time = as.numeric(assay(g_rm, "coef:time")), vc_intercept = as.numeric(assay(g_rm, "vc_intercept")), vc_slope = as.numeric(assay(g_rm, "vc_slope")), lambda_slope = as.numeric(assay(g_rm, "lambda_slope")), stringsAsFactors = FALSE ) lmm_summary_display <- transform( lmm_summary, intercept = round(intercept, 3), time = round(time, 3), vc_intercept = round(vc_intercept, 3), vc_slope = round(vc_slope, 3), lambda_slope = round(lambda_slope, 3) ) stopifnot( identical(subjects(g_rm), "meta"), identical(contrasts(g_rm), "model"), all(is.finite(lmm_summary$time)), all(lmm_summary$vc_slope > 0), all(lmm_summary$lambda_slope > 0) ) ``` The result is still a GDS, but the subject and contrast axes have collapsed to the model level. A compact summary is usually easier to read than printing individual assay arrays: ```{r inspect-summary} knitr::kable(lmm_summary_display, row.names = FALSE) ``` Fixed-effect outputs still follow the same naming convention as the regression reducers (`coef:`, `se_coef:`, `t_coef:`, `p_coef:`), while variance components are exposed as ordinary assays such as `vc_intercept`, `vc_slope`, and `lambda_slope`. ## How are the random effects estimated? All four reducers stack the subject-by-visit observations for every sample, build one shared fixed-effects matrix and one shared random-effects design, and integrate the subject-specific random effects out of the Gaussian marginal likelihood. They estimate the covariance of the random effects; they do not return subject-level conditional modes or BLUPs. The unweighted reducers then: 1. optimize the relative covariance parameters λ by profile maximum likelihood (`fit = "ML"`) or restricted maximum likelihood (`fit = "REML"`); 2. conditional on those fitted parameters, solve the generalized least-squares problem for each sample's fixed effects; and 3. estimate a residual scale for each sample, then convert the relative covariance parameters into the absolute `vc_*` components. For a known-variance reducer, `diag(var)` remains fixed while the optimizer estimates the absolute random-effect covariance $G$ and the additional residual variance `vc_resid`. At every trial covariance, the reducer profiles out the fixed effects with generalized least squares. There is no single residual scale that can be factored out of the whole covariance, which is why these methods do not report relative `lambda_*` components. REML is the default because it accounts for the fixed-effects degrees of freedom while estimating variance components. ML is mainly useful when models with different fixed-effects structures must be compared on the same data. For `lmm:ri`, the optimizer explicitly considers the zero random-intercept boundary. With `covariance = "full"`, the slope methods obtain positive-definite covariance matrices from a Cholesky factor. Any of the fitted variance components can be near zero at the numerical boundary, especially with few subjects or little replication. The reported fixed-effect standard errors are plug-in GLS standard errors: they condition on the fitted variance parameters. The reported *t* tests use `n_observations - n_fixed_effects` residual degrees of freedom. They do not use Satterthwaite or Kenward–Roger corrections and do not propagate uncertainty in the estimated variance components. With few subjects or weakly identified random slopes, treat `p_coef:*` as an approximate screening summary rather than small-sample-calibrated inference. The restricted implementation is designed for fast, common-design imaging fits; it is not a drop-in replacement for every inferential feature of `lmer()`. The `p_coef:*` assays are also uncorrected for the number of ROIs or voxels tested. ## How do first-level variance estimates enter the model? The original `lmm:ri` and `lmm:ri_slope1` reducers are unweighted: they require only `beta`. If the input also contains `var` or `se`, it is carried by the source GDS but is not used in their likelihood, estimates, or weights. Their `sigma2` and `vc_*` outputs are estimated entirely from the supplied effects. The following check deliberately adds implausibly heterogeneous first-level variances. The LMM result is unchanged because `var` is outside this reducer's input contract: ```{r first-level-variance-contract} rm_df_with_var <- rm_df rm_df_with_var$var <- rep(c(1e-6, 100), length.out = nrow(rm_df_with_var)) g_var_probe <- gds(rm_df_with_var, contrast_data_cols = "time") |> reduce( method = "lmm:ri_slope1", formula = ~ time, options = list( slope = "time", covariance = "diag", fit = "REML", theta_mode = "voxelwise" ) ) |> compute() variance_probe <- data.frame( output = c("time coefficient", "time standard error", "slope variance ratio"), largest_absolute_change = c( max(abs(assay(g_var_probe, "coef:time") - assay(g_rm, "coef:time"))), max(abs(assay(g_var_probe, "se_coef:time") - assay(g_rm, "se_coef:time"))), max(abs(assay(g_var_probe, "lambda_slope") - assay(g_rm, "lambda_slope"))) ) ) knitr::kable( transform(variance_probe, largest_absolute_change = signif(largest_absolute_change, 3)), row.names = FALSE ) ``` ```{r first-level-variance-contract-check, include = FALSE} stopifnot(all(variance_probe$largest_absolute_change < 1e-10)) ``` For an unweighted fit, this has two distinct implications: - A biased first-level variance estimate does **not** directly create an excessive inverse-variance weight. - Differences in first-level precision are nevertheless unmodeled. Variation due to scan duration, motion, censoring, design efficiency, or first-level noise modeling is absorbed into the LMM residual and is not separated from other within-subject variation. When precision differs materially across observations, the homoscedastic residual assumption can be poor and the nominal fixed-effect standard errors can be misleading. Use `lmm:ri_knownvar` or `lmm:ri_slope1_knownvar` when the input variances should enter the model. For each sample, these reducers fit \[ V = \operatorname{diag}(v) + \sigma_{\text{resid}}^2 I + ZGZ^{\mathsf T}, \] where $v$ is the supplied `var` assay, $G$ is the subject random-effect covariance, and `vc_resid` is additional observation-level heterogeneity not explained by the reported sampling variance. The supplied variances are treated as known: they are not multiplied by a fitted calibration factor. This is the same covariance contract accepted by [`metafor::rma.mv()`](https://wviechtb.github.io/metafor/reference/rma.mv.html) when `V` contains the known sampling variances and random effects represent the subject and observation-level heterogeneity. It is not equivalent to passing `weights = 1 / var` to `lme4::lmer()`: in `lmer()`, the inverse weights are still multiplied by an estimated common residual scale. The next example makes the variance contract part of the data-generating process. Subjects have different first-level standard errors, visits differ slightly in precision, and every observation also contains residual variation with variance 0.0064 that is not included in `var`. ```{r known-variance-fit} rm_df_knownvar <- rm_df subject_se <- c(0.02, 0.03, 0.05, 0.07, 0.09, 0.12) names(subject_se) <- subjects visit_precision_factor <- c(pre = 1.15, mid = 0.85, post = 1.00) rm_df_knownvar$var <- ( subject_se[rm_df_knownvar$subject] * visit_precision_factor[rm_df_knownvar$contrast] )^2 true_extra_residual_var <- 0.0064 set.seed(11) rm_df_knownvar$beta <- rm_signal + rnorm(nrow(rm_df_knownvar), sd = sqrt(rm_df_knownvar$var)) + rnorm(nrow(rm_df_knownvar), sd = sqrt(true_extra_residual_var)) g_knownvar_unweighted <- gds(rm_df_knownvar, contrast_data_cols = "time") |> reduce( method = "lmm:ri_slope1", formula = ~ time, options = list( slope = "time", covariance = "diag", fit = "REML", theta_mode = "voxelwise" ) ) |> compute() g_knownvar <- gds(rm_df_knownvar, contrast_data_cols = "time") |> reduce( method = "lmm:ri_slope1_knownvar", formula = ~ time, options = list( slope = "time", covariance = "diag", fit = "REML" ) ) |> compute() knownvar_compare <- data.frame( sample = sample_labels(g_knownvar), unweighted_time = as.numeric(assay(g_knownvar_unweighted, "coef:time")), knownvar_time = as.numeric(assay(g_knownvar, "coef:time")), unweighted_se = as.numeric(assay(g_knownvar_unweighted, "se_coef:time")), knownvar_se = as.numeric(assay(g_knownvar, "se_coef:time")), mean_sampling_var = as.numeric(assay(g_knownvar, "sampling_var_mean")), fitted_extra_residual_var = as.numeric(assay(g_knownvar, "vc_resid")) ) knitr::kable(knownvar_compare, digits = 4, row.names = FALSE) ``` ```{r known-variance-fit-check, include = FALSE} stopifnot( all(is.finite(as.matrix(knownvar_compare[-1]))), any(abs(knownvar_compare$knownvar_se - knownvar_compare$unweighted_se) > 1e-6), all(knownvar_compare$fitted_extra_residual_var > 0) ) ``` Both fits use exactly the same effect estimates. Their coefficients and standard errors differ because the known-variance fit recognizes that some observations are measured less precisely. `fitted_extra_residual_var` estimates the simulated 0.0064 component; with a data set this small it need not recover that value exactly. A near-zero estimate in real data can mean either that the reported sampling variances explain the remaining observation-level variation or that the component is weakly identified. There are two important limits. First, `var` must contain strictly positive sampling variances on the squared scale of `beta`; if the input supplies a standard error, square it during ingestion or map it to `se` so that `fmrigds` can derive `var`. Second, this version assumes the sampling-error covariance is diagonal. Contrasts estimated from the same first-level GLM can have correlated errors. Marginal variance maps alone cannot recover that covariance, so do not interpret the diagonal model as a full solution for dependent first-level contrasts. Because the variances are treated as known, their accuracy now matters directly. A variance biased downward gives that observation too much precision; `vc_resid` can absorb general excess heterogeneity but cannot repair an observation-specific miscalibration. Inspect the distribution and provenance of `var`, and use sensitivity analyses when first-level noise modeling is doubtful. That distinction matters in fMRI. Under a correctly specified mean model with mean-zero errors unrelated to the regressors, ordinary least squares coefficients need not be biased merely because residuals are positively autocorrelated, but an IID variance calculation generally overstates the effective information and can make standard errors too small. Prewhitening choices can materially change first-level uncertainty and, in some settings, the resulting activation estimates. See [Woolrich et al. (2001)](https://ora.ox.ac.uk/objects/uuid%3Ae26cdb91-02a9-4252-b2f6-cc57a8e25844) and the multi-package evaluation by [Olszowy et al. (2019)](https://www.nature.com/articles/s41467-019-09230-w). Before treating a beta map as a comparable response, record how it was made: the first-level design and contrast, effect scaling, temporal autocorrelation model and prewhitening, censoring or nuisance regression, residual degrees of freedom, and any transformations. If first-level sampling variances need to enter the group model, use a `*_knownvar` method and audit the variance model; the unweighted methods are not measurement-error or inverse-variance meta-analytic LMMs. For the distinction between ordinary and precision-weighted two-stage group analysis, see [Mumford and Nichols (2009)](https://pmc.ncbi.nlm.nih.gov/articles/PMC2719771/) and [Chen et al. (2012)](https://pmc.ncbi.nlm.nih.gov/articles/PMC3404516/). ## Should variance components be pooled or fitted voxelwise? This choice currently applies to the unweighted reducers. The known-variance reducers are voxelwise only: sharing an absolute random-effect covariance across voxels would usually be scientifically indefensible, while sharing a relative covariance requires an explicit cross-voxel scaling rule that is not yet part of the API. The choice is a bias–variance tradeoff, not just a speed switch. In pooled mode, all complete samples contribute to one profile-likelihood estimate of the **relative** covariance λ. Each sample still gets its own residual variance `sigma2`, so its absolute `vc_intercept` and `vc_slope` may differ. In voxelwise mode, both the relative covariance and the residual scale are estimated separately for every sample. | Question | `theta_mode = "pooled"` | `theta_mode = "voxelwise"` | |---|---|---| | What is shared? | Random-effect-to-residual variance ratios, and the intercept–slope correlation for `covariance = "full"` | Nothing beyond the fixed/random design | | Main advantage | More stable covariance shape and much less optimization | Allows genuine spatial or ROI heterogeneity in covariance shape | | Main risk | A common shape can misrepresent regions with different longitudinal variability | Noisy or boundary variance estimates with small samples; spatially speckled component maps | | Effect on fixed effects | Shared covariance determines the within-subject GLS weighting | Sample-specific covariance gives sample-specific GLS weighting | | Sensible starting point | Whole-brain data with few repeats, or when covariance heterogeneity is not the scientific target | A modest number of ROIs, stronger replication, or a planned sensitivity analysis | Pooled mode is not spatial smoothing and does not shrink voxelwise estimates toward a mean; it fits one common covariance shape by summing profile-likelihood contributions across samples. It also does not model spatial correlation among samples. A large set of highly correlated voxels therefore supplies less independent information than its voxel count suggests, and a common λ can be a poor description when tissue classes or regions have genuinely different variance ratios. ```{r pooled-vs-voxelwise} g_pooled <- gds(rm_df, contrast_data_cols = "time") |> reduce( method = "lmm:ri_slope1", formula = ~ time, options = list( slope = "time", covariance = "diag", fit = "REML", theta_mode = "pooled" ) ) |> compute() theta_compare <- data.frame( sample = sample_labels(g_rm), pooled_lambda_slope = as.numeric(assay(g_pooled, "lambda_slope")), voxelwise_lambda_slope = as.numeric(assay(g_rm, "lambda_slope")), pooled_vc_slope = as.numeric(assay(g_pooled, "vc_slope")), voxelwise_vc_slope = as.numeric(assay(g_rm, "vc_slope")), pooled_se_time = as.numeric(assay(g_pooled, "se_coef:time")), voxelwise_se_time = as.numeric(assay(g_rm, "se_coef:time")), stringsAsFactors = FALSE ) knitr::kable( theta_compare, digits = 3, row.names = FALSE ) ``` The pooled λ is identical across rows by construction. The absolute pooled slope variance need not be identical because it is the shared λ multiplied by each ROI's residual variance. Differences between the final two columns show the inferential consequence of the choice: the fitted covariance changes the GLS standard error, not merely a diagnostic variance map. With only six subjects, the voxelwise covariance estimates in this toy example should be read as a sensitivity demonstration, not as trustworthy ROI-specific biology. ```{r pooled-vs-voxelwise-checks, include = FALSE} bench_subjects <- paste0("sub-", sprintf("%02d", 1:10)) bench_visits <- c("pre", "mid", "post") bench_time <- c(-1, 0, 1) bench_samples <- paste0("ROI_", sprintf("%02d", 1:24)) bench_df <- expand.grid( sample = bench_samples, subject = bench_subjects, contrast = bench_visits, KEEP.OUT.ATTRS = FALSE, stringsAsFactors = FALSE ) bench_subject_intercept <- seq(-0.25, 0.25, length.out = length(bench_subjects)) names(bench_subject_intercept) <- bench_subjects bench_subject_slope <- seq(-0.04, 0.04, length.out = length(bench_subjects)) names(bench_subject_slope) <- bench_subjects bench_sample_shift <- seq(0.00, 0.45, length.out = length(bench_samples)) names(bench_sample_shift) <- bench_samples bench_sample_slope <- seq(0.35, 0.85, length.out = length(bench_samples)) names(bench_sample_slope) <- bench_samples bench_df$time <- bench_time[match(bench_df$contrast, bench_visits)] bench_df$beta <- 0.8 + bench_sample_shift[bench_df$sample] + bench_subject_intercept[bench_df$subject] + (bench_sample_slope[bench_df$sample] + bench_subject_slope[bench_df$subject]) * bench_df$time + rnorm(nrow(bench_df), sd = 0.05) bench_pooled_plan <- gds(bench_df, contrast_data_cols = "time") |> reduce( method = "lmm:ri_slope1", formula = ~ time, options = list( slope = "time", covariance = "diag", fit = "REML", theta_mode = "pooled" ) ) bench_voxel_plan <- gds(bench_df, contrast_data_cols = "time") |> reduce( method = "lmm:ri_slope1", formula = ~ time, options = list( slope = "time", covariance = "diag", fit = "REML", theta_mode = "voxelwise" ) ) bench_times <- data.frame( theta_mode = c("pooled", "voxelwise"), elapsed = c( median(replicate(3, unname(system.time(compute(bench_pooled_plan))[["elapsed"]]))), median(replicate(3, unname(system.time(compute(bench_voxel_plan))[["elapsed"]]))) ), stringsAsFactors = FALSE ) stopifnot( length(unique(as.numeric(assay(g_pooled, "lambda_slope")))) == 1L, all(is.finite(as.numeric(assay(g_rm, "lambda_slope")))), bench_times$elapsed[1] < bench_times$elapsed[2] ) ``` ```{r pooled-vs-voxelwise-timing} knitr::kable(transform(bench_times, elapsed = round(elapsed, 3)), row.names = FALSE) ``` Runtime is a practical reason to pool, but not a statistical justification. For a real analysis, start from the covariance assumption you can defend and then inspect sensitivity: compare coefficient and standard-error maps, the distribution of `lambda_*`, and the frequency of near-boundary components. The current `converged` assay distinguishes an available numerical fit from an unavailable one; it is not a rich optimizer-diagnostic code. A large change in scientific conclusions is evidence that the random-effects structure is consequential; it is not evidence that the more favorable mode is correct. ## What if your repeated-measure metadata is already separate? If your `time`, visit order, or other repeated-measure annotations already live outside the long table, attach them explicitly with `with_contrast_data()`. ```{r attach-contrast-data} contrast_df <- data.frame(time = time, row.names = visits) g_attached <- gds(rm_df[c("sample", "subject", "contrast", "beta")]) |> with_contrast_data(contrast_df) |> reduce( method = "lmm:ri_slope1", formula = ~ time, options = list( slope = "time", covariance = "diag", fit = "REML", theta_mode = "pooled" ) ) |> compute() knitr::kable( data.frame( sample = sample_labels(g_attached), time = round(as.numeric(assay(g_attached, "coef:time")), 3), stringsAsFactors = FALSE ), row.names = FALSE ) ``` ```{r attach-contrast-data-checks, include = FALSE} stopifnot( max(abs(as.numeric(assay(g_attached, "coef:time")) - as.numeric(assay(g_pooled, "coef:time")))) < 1e-10, max(abs(as.numeric(assay(g_attached, "lambda_slope")) - as.numeric(assay(g_pooled, "lambda_slope")))) < 1e-10 ) ``` If you only need a random intercept, use `method = "lmm:ri"` instead: ```{r random-intercept} g_ri <- gds(rm_df, contrast_data_cols = "time") |> reduce( method = "lmm:ri", formula = ~ time, options = list(fit = "REML", theta_mode = "pooled") ) |> compute() knitr::kable( data.frame( sample = sample_labels(g_ri), time = round(as.numeric(assay(g_ri, "coef:time")), 3), lambda = round(as.numeric(assay(g_ri, "lambda")), 3), stringsAsFactors = FALSE ), row.names = FALSE ) ``` ## What is supported, and what is intentionally out of scope? The repeated-measures LMM reducers are deliberately narrower than `lmer()`: - one grouping factor only - Gaussian outcomes only - shared observation layout across all samples - random intercept only or random intercept plus one slope, each available as an unweighted method or a `*_knownvar` method - unweighted methods estimate one residual variance per sample and ignore observation-level precision - known-variance methods add `diag(var)` to the marginal covariance and estimate additional residual heterogeneity; they currently assume diagonal sampling covariance and voxelwise variance components - complete observations within a sample; a non-finite subject-by-repeat value makes that sample's model outputs unavailable - plug-in GLS inference with residual degrees of freedom, without Satterthwaite or Kenward–Roger correction - no crossed random effects - no general random-effects formula grammar - no spatial covariance model or spatial regularization of voxelwise variance components That restriction is the point: it lets `fmrigds` reuse one design across many responses and produce a whole-brain or whole-ROI result with one call to `reduce()`. It also defines the boundary of the scientific claim: the reducer models dependence among repeated effect estimates from the same subject. The known-variance variants additionally model reported marginal sampling uncertainty, but none of these reducers models dependence between neighboring samples or sampling-error covariance between contrasts. ## Where do you go next? - See `?reduce` for the reducer contract and options. - See `?with_contrast_data` if your repeated-measure metadata is already separate from the long table. - See `inst/examples/fmrigds_lmm_benchmark.R` for a benchmark harness that compares pooled and voxelwise theta modes. - See `inst/examples/fmrigds_lmm_knownvar_benchmark.R` for a benchmark of the variance-aware random-slope reducer.