--- title: Demystifying Random-Effects Formulas output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Demystifying Random-Effects Formulas} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE ) ``` ```{r load-package} library(mixeff) ``` ```{r study-data, include = FALSE} set.seed(11) n_subj <- 30L n_time <- 5L study <- expand.grid(subject = factor(seq_len(n_subj)), time = seq_len(n_time)) subj_dose <- rnorm(n_subj, sd = 1) study$dose <- subj_dose[as.integer(study$subject)] b0 <- rnorm(n_subj, sd = 0.6)[as.integer(study$subject)] b1 <- rnorm(n_subj, sd = 0.2)[as.integer(study$subject)] study$x <- rnorm(nrow(study)) study$z <- rnorm(nrow(study)) study$score <- 2 + 0.3 * study$time + b0 + b1 * study$time + rnorm(nrow(study), sd = 0.4) study$y <- 1 + 0.4 * study$x + 0.2 * study$z + b0 + rnorm(nrow(study), sd = 0.5) study$hit <- rbinom(nrow(study), size = 1, prob = plogis(0.5 + study$x)) # A between-subject covariate is required to demonstrate the # structural-refusal case. between_study <- study between_study$dose <- between_study$dose # A small-N variant to demonstrate the low-information-budget case. low_n <- 4L low_study <- subset(study, as.integer(subject) <= low_n) low_study$subject <- factor(low_study$subject) ``` Random-effects formulas are compact, and compactness hides assumptions. The four characters of `(x | g)` commit the analyst to a specific covariance structure, a specific number of free parameters, and a specific identification claim about what the data can support. Two formulas that differ by a single character — `||` for `|`, say — fit different models. `mixeff` shares the formula language with `lme4`, so the notation you already read transfers directly. What it adds is a way to read the formula *before* the optimizer runs: the named form of every random term, the plain-language scope, the parameter count, and the design facts that support or refuse the formula on this particular data. The goal of this vignette is not to pick a model for you. It is to show what the model you wrote can and cannot express, and to make the difference between nearby spellings legible. We use a small repeated-measures dataset with one row per subject per time point. ```{r study-preview} head(study) ``` ## What does `(1 | subject)` say? A random intercept is the simplest random-effects structure: subjects may differ in average score, but the fixed `time` effect is the same across subjects. It is often the right choice, and it is always a reasonable starting point. `explain_model()` spells out what the model assumes and reports that no random slopes were added. ```{r intercept-only-explanation} intercept_only <- compile_model(score ~ time + (1 | subject), study) explain_model(intercept_only) ``` ```{r intercept-only-checks, include = FALSE} intercept_only_explanation <- explain_model(intercept_only) intercept_only_slopes <- intercept_only_explanation$cards[[1L]]$blocks[[1L]]$slopes stopifnot(length(intercept_only_slopes) == 0L) stopifnot(any(vapply( intercept_only_explanation$diagnostics, function(x) identical(x$code, "scope_note"), logical(1) ))) ``` Each random-effects card has two header lines worth pausing on. `wrote:` is the term as you typed it. `canonical:` is what the parser hands downstream — what the optimizer, the audit, and the inference contract will actually see. They are identical here because `(1 | subject)` is already in canonical form, and identity is a positive signal: nothing was rewritten. The two diverge for more elaborate formulas. `(1 | clinic/site)` is rewritten as `(1 | clinic) + (1 | clinic:site)`; implicit intercepts are made explicit; double-bar shorthand expands to its split-block form. The `canonical:` line is where you read off the model the engine will actually fit. The remaining lines decode the term further. `named form:` restates it as a function call that names the grouping factor, the intercept, the slopes (if any), and the covariance family. `scope:` is a plain-language sentence. `covariance` and `support` report parameter cost and design sufficiency. The `Design notes:` section at the bottom records facts about the data that the fitted formula did not use. Here `time` varies within each subject, so a subject-level `time` slope is structurally possible. That is reported as a note, not a correction: whether to add `(time | subject)` is a scientific question about whether subjects plausibly differ in their `time` slopes, not a mechanical one about what the package can do. ## What changes when you add a random slope? `(1 + time | subject)` requests a two-dimensional random effect for each subject: one baseline coefficient and one `time` coefficient, with a fitted covariance between them. ```{r full-slope-explanation} full <- compile_model(score ~ time + (1 + time | subject), study) explain_model(full) ``` After fitting, `parameterization()` shows the model's *internal* coordinates. A brief detour explains what they are. The random-effects covariance matrix Σ — here a 2×2 matrix describing the variance of the intercept, the variance of the `time` slope, and their covariance — is not optimized directly. Instead, the engine searches over a vector θ that maps into a lower-triangular factor Λ via Σ = σ² Λ Λᵀ. The reasons are practical: θ-space has the minimum number of free parameters, it is better-conditioned than searching over Σ directly, and the implied Σ is positive-semidefinite by construction. This is the same parameterization Bates et al. (2015, *JSS* 67(1)) describe for `lme4`; `MixedModels.jl` and the engine behind `mixeff` inherit it. For a 2×2 random-effects matrix, Λ has three free entries — the three rows you see below. `theta_value` is what the optimizer worked with; `lambda_value` places that same number back into the matrix. They match here because the mapping is the identity for this structure; a mismatch would point to a parameterization bug rather than a modelling fact. ```{r full-parameterization} full_fit <- lmm( score ~ time + (1 + time | subject), study, control = mm_control(verbose = -1) ) parameterization(full_fit)$table[, c("theta_name", "theta_value", "lambda_value")] ``` A practical use of this table is boundary-fit detection: when a θ entry is pinned at zero, the corresponding variance or covariance has reached the edge of its parameter space, and the random-effects covariance is reduced-rank. The headliner vignette walks through that case end-to-end. ## What do split blocks and `||` mean? The formula `(1 | subject) + (0 + time | subject)` uses two separate blocks for the same grouping factor. That fixes the intercept-slope covariance to zero. ```{r split-blocks} split_blocks <- compile_model( score ~ time + (1 | subject) + (0 + time | subject), study ) explain_model(split_blocks) ``` `(1 + time || subject)` is a shorthand for the same split-block model. ```{r double-bar} double_bar <- compile_model(score ~ time + (1 + time || subject), study) explain_model(double_bar) ``` ```{r split-checks, include = FALSE} split_text <- explain_model(split_blocks)$text double_text <- explain_model(double_bar)$text stopifnot(grepl("separate random-effect blocks fix the covariance", split_text, fixed = TRUE)) stopifnot(grepl("double-bar syntax fixes the covariance", double_text, fixed = TRUE)) stopifnot(grepl("covariance_assumption", split_text, fixed = TRUE)) stopifnot(grepl("covariance_assumption", double_text, fixed = TRUE)) ``` One caveat when a **factor** appears inside `||`: mixeff decorrelates *everything* in the block, including the factor's level contrasts — each treatment-coded contrast receives an independent variance and no within-factor covariances are estimated (announced via a `covariance_assumption` diagnostic with reason `double_bar_factor_term`). Implementations differ here — `lme4`'s `||` leaves factor terms intact with a full within-factor covariance block — so when matching an external fit, write the intended expansion explicitly: `(1 | g) + (0 + f | g) + (0 + x | g)` keeps `f`'s level covariances, while `(1 + f + x || g)` does not. ## What are the three kinds of help? The audit surface separates three situations that are often mixed together in ordinary model output. First, a structural impossibility: a requested random slope cannot be estimated if that variable does not vary within the group. Here `dose` is constant within each subject. ```{r structural-impossibility} impossible <- compile_model(score ~ dose + (1 + dose | subject), between_study) explain_model(impossible) ``` Second, low information budget: a requested full covariance can be estimable in principle while still having little grouping-level support. The package reports the parameter count and observed levels. ```{r low-information} low_info <- compile_model(score ~ time + (1 + time | subject), low_study) diagnostics(low_info)$table[, c("code", "severity", "message")] ``` Third, unmodeled-but-possible: a fixed effect varies within group, but the random-effects formula does not include the corresponding slope. This is the quiet design note you saw for `(1 | subject)`. ```{r unmodeled-possible} diagnostics(intercept_only)$table[, c("code", "severity", "message")] ``` ```{r help-checks, include = FALSE} stopifnot("structural_refusal" %in% diagnostics(impossible)$table$code) stopifnot("covariance_too_rich" %in% diagnostics(low_info)$table$code) stopifnot("scope_note" %in% diagnostics(intercept_only)$table$code) ``` ## How do you inspect nearby formulas? `random_options()` is a map of nearby random-effect spellings for one grouping factor. It marks the current spelling and reports parameter costs and support facts. It does not rank the rows. ```{r random-options-map} random_options(intercept_only, group = subject, slope = time) ``` The same information is available as a data frame if you want to build a custom report. ```{r random-options-table} opts <- random_options(intercept_only, group = subject, slope = time) opts$options[, c("formula", "theta_parameters", "design_status", "current")] ``` ## Why is there no recommendation row? `mixeff` treats formula explanations as an audit problem, not a model-selection problem. The package reports the current model, nearby spellings, assumptions, and data-support facts. Choosing among scientifically different random-effects structures remains part of the analysis design. `compare_covariance()` gives another view of the same principle: full, diagonal, and scalar covariance families are displayed side by side, without a preferred row. ```{r compare-covariance} compare_covariance(full) ``` ```{r no-recommendation-check, include = FALSE} opts <- random_options(intercept_only, group = subject, slope = time) cmp <- compare_covariance(full) stopifnot(!"recommended" %in% tolower(names(opts$options))) stopifnot(!"recommended" %in% tolower(names(cmp$table))) ``` ## Where do fitted-model changes appear? After fitting, `changes()` records the journey from the formula you wrote to the model the engine actually solved. Each row carries the stage at which something happened, the term it affected, the status of the change, and three side-by-side renderings: what was *requested*, what was *effective* (after design parsing and identifiability checks), and what was *fitted* (after the optimizer landed). A reduced-rank covariance estimate appears here as a labelled fact, not an instruction to rewrite the formula. The full row carries a lot of text. We show it in two passes so neither pass wraps awkwardly in the rendered vignette. The first pass names the stage of each change and its status: ```{r fitted-changes-status} knitr::kable( changes(full_fit)$table[, c("stage", "term_id", "status", "detail")] ) ``` The second pass shows the requested → effective → fitted arc itself, where the three columns are the substance of the report: ```{r fitted-changes-arc} knitr::kable( changes(full_fit)$table[, c("stage", "requested", "effective", "fitted")] ) ``` For a first end-to-end fit workflow, read `vignette("lmm-basics", package = "mixeff")`.