The general linear hypothesis behind every semnova test

library(semnova)

Every test the package reports is one general linear hypothesis on the contrast layer, evaluated by one piece of code. That covers each row of an ANOVA table, each custom hypothesis and each contrast plot. This vignette derives that construction and shows where each of its parts comes from. It is background: nothing here is needed to use the package, and vignette("semnova") is the place to start.

The model

A multigroup model has one coefficient matrix per group. Write

  • \(n_{\text{groups}}\) for the number of groups,
  • \(q\) for the number of repeated measures,
  • \(p + 1\) for the number of predictors, the intercept included,
  • \(\mathbf{B}_g \in \mathbb{R}^{(p + 1) \times q}\) for the coefficients of group \(g\).

The \(\mathbf{B}_g\) are stacked into a three-dimensional array of dimension \((p + 1) \times q \times n_{\text{groups}}\). In semnova that array is the contrast (pi) layer’s coefficients, returned by regression_coefficients().

The hypothesis

The hypotheses of interest have the form

\[ \mathbf{H}_0: \mathbf{G} \cdot (\mathbf{L} \cdot \mathbf{B}_g \cdot \mathbf{M}) = \mathbf{K} \]

where each matrix selects one aspect of the design:

Symbol Dimensions Selects
\(\mathbf{L}\) \(h \times (p + 1)\) predictors: an intercept, or a covariate’s effect
\(\mathbf{M}\) \(q \times r\) contrast variables: the columns belonging to a within-subject term
\(\mathbf{G}\) \(d \times n_{\text{groups}}\) groups: the between-subject contrasts
\(\mathbf{K}\) \(d \times h \times r\) the values tested against, almost always zero

A main effect, an interaction and a covariate effect differ only in which of these is non-trivial, which is why one implementation covers them all.

Vectorisation

The hypothesis is stated in matrices but tested as a linear restriction on a parameter vector, so it has to be rewritten in terms of \(\text{vec}(\mathbf{B})\).

One group

For a single group, the standard identity for the vectorisation of a matrix product gives

\[ \text{vec}(\mathbf{L} \cdot \mathbf{B}_g \cdot \mathbf{M}) = (\mathbf{M}^\top \otimes \mathbf{L}) \cdot \text{vec}(\mathbf{B}_g), \]

which maps a vector of length \((p + 1) \cdot q\) to one of length \(h \cdot r\).

All groups

Stacking the groups, the same transformation is applied to each block of \(\text{vec}(\mathbf{B})\):

\[ \text{vec} \begin{bmatrix} \mathbf{L} \mathbf{B}_1 \mathbf{M} \\ \vdots \\ \mathbf{L} \mathbf{B}_{n_{\text{groups}}} \mathbf{M} \end{bmatrix} = \left( \mathbf{I}_{n_{\text{groups}}} \otimes (\mathbf{M}^\top \otimes \mathbf{L}) \right) \cdot \text{vec}(\mathbf{B}), \qquad \mathbf{B} = \begin{bmatrix} \mathbf{B}_1 \\ \vdots \\ \mathbf{B}_{n_{\text{groups}}} \end{bmatrix}. \]

The group contrast

Applying \(\mathbf{G}\) across groups completes the construction:

\[ \mathbf{R} = (\mathbf{G} \otimes \mathbf{I}_{h \cdot r}) \cdot (\mathbf{I}_{n_{\text{groups}}} \otimes (\mathbf{M}^\top \otimes \mathbf{L})) \]

so that the hypothesis becomes a single linear restriction,

\[ \mathbf{H}_0: \mathbf{R} \cdot \text{vec}(\mathbf{B}) = \mathbf{k}, \qquad \mathbf{k} = \text{vec}(\mathbf{K}), \]

with one row of \(\mathbf{R}\) per contrast being tested and one column per coefficient.

Dimensions

Quantity Dimensions
\(\mathbf{B}_g\) \((p + 1) \times q\)
\(\mathbf{L}\) \(h \times (p + 1)\)
\(\mathbf{M}\) \(q \times r\)
\(\mathbf{G}\) \(d \times n_{\text{groups}}\)
\(\text{vec}(\mathbf{B})\) \(n_{\text{groups}} \cdot (p + 1) \cdot q\)
\(\mathbf{R}\) \((d \cdot h \cdot r) \times (n_{\text{groups}} \cdot (p + 1) \cdot q)\)
\(\mathbf{k}\) \(d \cdot h \cdot r\)

Implementation

contrast_matrix() builds \(\mathbf{R}\) in the three steps derived above:

contrast_matrix
#> function (G, L, M, B_array = NULL) 
#> {
#>     if (!is.matrix(G) || !is.matrix(L) || !is.matrix(M)) {
#>         stop("semnova: G, L, and M must all be matrices", call. = FALSE)
#>     }
#>     h <- nrow(L)
#>     r <- ncol(M)
#>     d <- nrow(G)
#>     n_groups <- ncol(G)
#>     Rg <- kronecker(t(M), L)
#>     R_within <- kronecker(diag(n_groups), Rg)
#>     R <- kronecker(G, diag(h * r)) %*% R_within
#>     dim_out <- c(rows = d * h * r, cols = n_groups * ncol(L) * 
#>         nrow(M))
#>     names(dim_out) <- c("rows", "cols")
#>     if (!is.null(B_array)) {
#>         if (dim(B_array)[1] != ncol(L) || dim(B_array)[2] != 
#>             nrow(M) || dim(B_array)[3] != n_groups) {
#>             stop("semnova: B_array dimensions (", paste(dim(B_array), 
#>                 collapse = " x "), ") are not compatible with L/M/G (expected ", 
#>                 ncol(L), " x ", nrow(M), " x ", n_groups, ")", 
#>                 call. = FALSE)
#>         }
#>         return(list(R = R, dim = dim_out, vec_B = as.vector(B_array)))
#>     }
#>     list(R = R, dim = dim_out)
#> }
#> <bytecode: 0x5d5de133c0c8>
#> <environment: namespace:semnova>

Supplying B_array returns \(\text{vec}(\mathbf{B})\) alongside it, so the two halves of the restriction are produced by one call and cannot disagree about their ordering:

set.seed(1)

# 3 predictors, 4 repeated measures, 2 groups
B_array <- array(rnorm(3 * 4 * 2), dim = c(3, 4, 2))

L <- matrix(c(0, 1, 0), nrow = 1)          # one predictor contrast
M <- cbind(c(1, -1, 0, 0), c(0, 0, 1, -1)) # two within-subject contrasts
G <- matrix(c(1, -1), nrow = 1)            # one group contrast

out <- contrast_matrix(G = G, L = L, M = M, B_array = B_array)
out$dim
#> rows cols 
#>    2   24

These are the dimensions tabulated above: \(d \cdot h \cdot r = 2\) rows and \(n_{\text{groups}} \cdot (p + 1) \cdot q = 24\) columns.

The construction can be checked against its own definition. Computing \(\mathbf{G} \cdot (\mathbf{L} \mathbf{B}_g \mathbf{M})\) directly, group by group, must reproduce \(\mathbf{R} \cdot \text{vec}(\mathbf{B})\):

direct <- Reduce(`+`, lapply(1:2, function(g) {
    G[1, g] * as.vector(L %*% B_array[, , g] %*% M)
}))

all.equal(as.vector(out$R %*% out$vec_B), direct)
#> [1] TRUE

This vignette is rendered when the package is built, so that equality is verified on every build rather than asserted here once.

Where each part comes from

Part Supplied by
\(\mathbf{G}\), \(\mathbf{L}\), \(\mathbf{M}\), \(\mathbf{K}\) hypothesis(), or generate_anova_hypotheses() for one hypothesis per model term
\(\mathbf{B}\) regression_coefficients(), whose B_array holds the contrast layer’s coefficients
\(\mathbf{R}\) contrast_matrix()
the test test_hypothesis() and contrast_estimates(), which form the Wald statistic from \(\mathbf{R}\), \(\text{vec}(\mathbf{B})\) and the coefficients’ covariance matrix

The restriction \(\mathbf{R} \cdot \text{vec}(\mathbf{B}) - \mathbf{k}\) is what those functions test, and the same quantity underlies the Wald test reported in the ANOVA table, the per-contrast estimates in summary(), and the intervals drawn by plot().