Nothing
## anova.rlmerMod: per-term Wald table (single fit), pairwise Wald
## restriction test (fixed-effects-only nested models), and a
## parametric-bootstrap quasi-deviance test (variance-component
## comparisons, where Wald/score asymptotics are invalid on the PSD-cone
## boundary -- see paper2-boundary-singularity).
## Robust quasi-deviance: 2 sum rho_e(r_i / scale) at the conditional
## residuals of `fit`, evaluated at an externally supplied `scale`.
## With rho_e(z) = z^2 / 2 (the classical case) this reduces to sum
## r_i^2 / scale^2.
##
## For the nested-model discrepancy QD(fit0; sigma) - QD(fit1; sigma)
## both fits MUST use the same scale, otherwise the difference is
## contaminated by sigma_0 != sigma_1 and can flip sign even when the
## larger model genuinely fits better (Cantoni and Ronchetti 2001
## evaluate the quasi-deviance at a common scale for exactly this
## reason). The convention here is to use sigma_hat from fit0 (the
## null) throughout.
.quasi_deviance <- function(fit, scale = .sigma(fit)) {
r_std <- residuals(fit) / scale
2 * sum(fit@rho.e@rho(r_std))
}
## Two rlmer fits differ in their random-effects structure iff their
## spherical-RE vectors have different lengths, their theta vectors
## have different lengths, or the random-only sub-formulas are not
## identical after normalisation.
.differ_in_re <- function(fit0, fit1) {
if (fit0@pp$q != fit1@pp$q) return(TRUE)
if (length(getME(fit0, "theta")) != length(getME(fit1, "theta")))
return(TRUE)
rf0 <- formula(fit0, random.only = TRUE)
rf1 <- formula(fit1, random.only = TRUE)
!isTRUE(all.equal(rf0, rf1))
}
## Are the fixed-effect column-name sets nested? Returns the extra
## column names in fit1 (if fit0 nests in fit1).
.extra_fixed_cols <- function(fit0, fit1) {
nms0 <- colnames(fit0@pp$X)
nms1 <- colnames(fit1@pp$X)
list(extra = setdiff(nms1, nms0),
nested = all(nms0 %in% nms1))
}
## Per-term robust Wald test from a single rlmer fit. ddf = "none"
## (default) gives the chi-square table; ddf = "satterthwaite" gives an
## F-test with a Satterthwaite denominator df per term (default vcov).
.anova_single_wald <- function(fit, vcov_type = "default",
ddf = "none", IF = NULL) {
mm <- model.matrix(fit)
assign <- attr(mm, "assign")
tlabs <- attr(terms(fit), "term.labels")
hasInt <- attr(terms(fit), "intercept") == 1L
V <- as.matrix(vcov(fit, type = vcov_type))
bh <- .fixef(fit)
names(bh) <- colnames(mm)
if (hasInt) {
unique_aids <- c(0L, seq_along(tlabs))
term_names <- c("(Intercept)", tlabs)
} else {
unique_aids <- seq_along(tlabs)
term_names <- tlabs
}
p <- ncol(mm)
Df <- integer(length(unique_aids))
Chisq <- numeric(length(unique_aids))
DenDF <- rep(NA_real_, length(unique_aids))
for (k in seq_along(unique_aids)) {
idx <- which(assign == unique_aids[k])
Df[k] <- length(idx)
Vk <- V[idx, idx, drop = FALSE]
bk <- bh[idx]
Chisq[k] <- as.numeric(crossprod(bk, solve(Vk, bk)))
if (ddf == "satterthwaite") {
L <- matrix(0, length(idx), p)
L[cbind(seq_along(idx), idx)] <- 1
fd <- tryCatch(.satterthwaite_Fdenom(fit, L, IF = IF),
error = function(e) list(df2 = NA_real_))
DenDF[k] <- fd$df2
}
}
if (ddf == "satterthwaite") {
Fval <- Chisq / Df
Pr <- stats::pf(Fval, Df, DenDF, lower.tail = FALSE)
out <- data.frame(NumDF = Df, DenDF = DenDF,
`F value` = Fval, `Pr(>F)` = Pr,
check.names = FALSE)
rownames(out) <- term_names
return(structure(out,
heading = c(
"Robust Wald F-test for fixed effects (rlmer)",
" denominator df: Satterthwaite (IF-based), vcov_type = \"default\""),
class = c("anova", "data.frame")))
}
Pr <- stats::pchisq(Chisq, Df, lower.tail = FALSE)
out <- data.frame(Df = Df, Chisq = Chisq,
`Pr(>Chisq)` = Pr, check.names = FALSE)
rownames(out) <- term_names
structure(out,
heading = c("Robust Wald test for fixed effects (rlmer)",
sprintf(" vcov_type = \"%s\"", vcov_type)),
class = c("anova", "data.frame"))
}
## Robust Wald restriction test for two nested rlmer fits that differ
## only in the fixed-effects design.
.anova_pair_wald <- function(fit0, fit1, vcov_type = "default",
ddf = "none", IF = NULL) {
extras <- .extra_fixed_cols(fit0, fit1)
if (!extras$nested)
stop("Models are not nested: fit0's fixed-effect columns ",
"must be a subset of fit1's.", call. = FALSE)
if (length(extras$extra) == 0L)
stop("Models share the same fixed-effect design; nothing to ",
"test.", call. = FALSE)
V1 <- as.matrix(vcov(fit1, type = vcov_type))
bh1 <- .fixef(fit1)
nms1 <- colnames(fit1@pp$X)
idx <- match(extras$extra, nms1)
Vk <- V1[idx, idx, drop = FALSE]
bk <- bh1[idx]
Chi <- as.numeric(crossprod(bk, solve(Vk, bk)))
Df <- length(idx)
p0 <- fit0@pp$p; p1 <- fit1@pp$p
if (ddf == "satterthwaite") {
L <- matrix(0, length(idx), p1)
L[cbind(seq_along(idx), idx)] <- 1
fd <- tryCatch(.satterthwaite_Fdenom(fit1, L, IF = IF),
error = function(e) list(df2 = NA_real_))
Fval <- Chi / Df
Pr <- stats::pf(Fval, Df, fd$df2, lower.tail = FALSE)
out <- data.frame(npar = c(p0, p1),
NumDF = c(NA_integer_, Df),
DenDF = c(NA_real_, fd$df2),
`F value` = c(NA_real_, Fval),
`Pr(>F)` = c(NA_real_, Pr),
check.names = FALSE)
rownames(out) <- c("fit0", "fit1")
return(structure(out,
heading = c(
"Robust Wald restriction F-test (rlmer)",
sprintf(" H0: %s = 0",
paste(extras$extra, collapse = " = ")),
" denominator df: Satterthwaite (IF-based), vcov_type = \"default\""),
class = c("anova", "data.frame")))
}
Pr <- stats::pchisq(Chi, Df, lower.tail = FALSE)
out <- data.frame(npar = c(p0, p1),
Df = c(NA_integer_, Df),
Chisq = c(NA_real_, Chi),
`Pr(>Chisq)` = c(NA_real_, Pr),
check.names = FALSE)
rownames(out) <- c("fit0", "fit1")
structure(out,
heading = c(
"Robust Wald restriction test (rlmer)",
sprintf(" H0: %s = 0",
paste(extras$extra, collapse = " = ")),
sprintf(" vcov_type = \"%s\"", vcov_type)),
class = c("anova", "data.frame"))
}
## Parametric bootstrap of the quasi-deviance difference D = QD(fit0)
## - QD(fit1) under H_0: fit0 is the data-generating model. Simulates
## from the fitted central LMM (Z U_b epsilon_b + sigma epsilon) at
## fit0's parameter estimates, refits BOTH rlmer models on each
## simulated dataset, and returns the empirical p-value for the
## observed discrepancy.
##
## The bootstrap is required for variance-component tests (Wald/score
## asymptotics are invalid on the PSD-cone boundary; see Self-Liang
## 1987 and the paper2-boundary-singularity discussion). It also runs
## for fixed-effects-only nested comparisons when test = "boot" is
## explicit.
## Smallest robustness weight per random-effects group. The robust fit
## downweights the spherical random effects through rho.b, so a group
## whose random effect is heavily downweighted is one the fit treated as
## a group outlier. getME(., "w_b_vector") is aligned with the spherical
## random effects (b-index order, see wgt.b in helpers.R); each b-index
## is mapped to its "factor: level" through the Gp/cnms/flist layout
## (term i owns b-indices Gp[i]+1 .. Gp[i+1], level-major: the
## length(cnms[[i]]) coordinates of one level are consecutive). Minima
## are aggregated over all b's carrying the same label, so several terms
## sharing a grouping factor contribute to one entry per level. Returns
## a named numeric ("factor: level" -> smallest weight over that level's
## random effects); attr "labeled" is FALSE (and names dropped) if the
## layout cannot be resolved, in which case the raw per-coefficient
## weights are returned so the caller can still screen on the minimum.
.re_min_weight_by_group <- function(fit) {
w <- getME(fit, "w_b_vector")
cnms <- fit@cnms
fl <- fit@flist
asg <- attr(fl, "assign")
Gp <- fit@Gp
unlabeled <- structure(as.numeric(w), labeled = FALSE)
if (is.null(cnms) || is.null(fl) || is.null(asg) ||
length(asg) != length(cnms) ||
length(Gp) != length(cnms) + 1L ||
Gp[length(Gp)] != length(w))
return(unlabeled)
## explicit b-index -> "factor: level" labels, term by term
lab <- character(length(w))
for (i in seq_along(cnms)) {
nc <- length(cnms[[i]])
fi <- asg[i]
levs <- levels(fl[[fi]]); nl <- length(levs)
if (Gp[i + 1L] - Gp[i] != nc * nl)
return(unlabeled)
lab[Gp[i] + seq_len(nc * nl)] <-
paste0(names(fl)[fi], ": ", rep(levs, each = nc))
}
if (any(!nzchar(lab)))
return(unlabeled)
grp <- factor(lab, levels = unique(lab))
res <- vapply(split(as.numeric(w), grp), min, numeric(1))
structure(res, labeled = TRUE)
}
## Auto-guard for the bootstrap variance-component test (C4): the
## parametric bootstrap simulates from the central LMM at fit0's
## estimates, so it is anti-conservative when a group is contaminated and
## biases those estimates. The robust fit's own RE weights detect this
## directly -- a heavily downweighted group is a group outlier -- whereas
## cluster Cook's distance does not (the downweighting that removes the
## bias also removes the influence the distance would measure). Warns,
## naming the offending groups, when any group's minimum RE weight falls
## below `threshold` (default 0.5).
.bootstrap_group_guard <- function(fit0, threshold = 0.5) {
mw <- tryCatch(.re_min_weight_by_group(fit0), error = function(e) NULL)
if (is.null(mw) || !length(mw)) return(invisible(NULL))
flagged <- mw[is.finite(mw) & mw < threshold]
if (!length(flagged)) return(invisible(NULL))
flagged <- flagged[order(flagged)]
if (isTRUE(attr(mw, "labeled"))) {
shown <- utils::head(flagged, 5L)
detail <- paste0(names(shown), " (min w = ",
sprintf("%.2f", shown), ")", collapse = "; ")
if (length(flagged) > length(shown))
detail <- paste0(detail, "; and ",
length(flagged) - length(shown), " more")
lead <- paste0("the robust fit heavily downweights random-effect ",
"group(s): ", detail)
} else {
lead <- sprintf(paste0("the robust fit heavily downweights %d ",
"random effect(s) (smallest weight %.2f)"),
length(flagged), min(flagged))
}
warning("anova(test = \"boot\"): ", lead, ". The parametric-bootstrap ",
"variance-component test simulates from the central model at ",
"the null fit's estimates, so it can be anti-conservative when ",
"groups are contaminated; treat the p-value with caution and ",
"inspect these groups (see ?anova.rlmerMod).", call. = FALSE)
invisible(flagged)
}
## Default cleaning threshold for the experimental robust null (see
## .anova_robust_null_params). A cluster is flagged when its smallest
## random-effect robustness weight falls below this value; the value 0.65
## was tuned by simulation (power preserved, contamination-induced Type-I
## inflation reduced at larger J). Overridable, but not part of the public
## interface.
.anova_robust_null_threshold <- function()
getOption("robustlmm.anova.robust_null_threshold", 0.65)
## EXPERIMENTAL contamination-cleaned null for the bootstrap
## variance-component test. The plain parametric bootstrap simulates from
## fit0's robust estimates, so any bias the contaminated clusters induced
## in (sigma0, theta0, beta0) propagates into a too-narrow bootstrap null
## and the test becomes anti-conservative. This helper de-biases the
## *generating* parameters: it flags the clusters the robust fit heavily
## downweights (smallest per-cluster random-effect weight < threshold,
## reusing .re_min_weight_by_group), refits fit0 with those clusters
## removed, and returns the cleaned generating parameters together with
## the generator's U_b = Lambda(theta) rebuilt at the cleaned theta.
## D_obs is still computed from the original untrimmed fits, so the test
## uses all the data; only the bootstrap null GENERATION is cleaned.
##
## Guard / fallback: if no cluster is flagged, the weight layout cannot
## be resolved, more than 50% of clusters are flagged, or the trimmed
## refit fails, the untrimmed fit0 parameters are returned (cleaned =
## FALSE, with the fallback cause in $reason) so the call degrades to
## the plain parametric bootstrap. A single warning is emitted on the
## >50%/failure fallback.
.anova_robust_null_params <- function(fit0,
threshold = .anova_robust_null_threshold()) {
sig0 <- .sigma(fit0)
th_orig <- getME(fit0, "theta")
beta0 <- .fixef(fit0)
Ub0 <- fit0@pp$U_b
untrimmed <- function(reason)
list(sigma = sig0, theta = th_orig, beta = beta0,
Ub = Ub0, flagged = character(0), n_flagged = 0L,
cleaned = FALSE, reason = reason)
mw <- tryCatch(.re_min_weight_by_group(fit0), error = function(e) NULL)
if (is.null(mw) || !length(mw) || !isTRUE(attr(mw, "labeled")))
return(untrimmed("layout-unresolved"))
flagged_names <- names(mw)[is.finite(mw) & mw < threshold]
n_flagged <- length(flagged_names)
if (n_flagged == 0L) return(untrimmed("none-flagged"))
## Map the labelled "<factor>: <level>" entries back to (factor, level)
## and build a subset that keeps every observation NOT in a flagged
## cluster. The labels are produced by .re_min_weight_by_group as
## paste0(factor, ": ", level).
fl <- fit0@flist
keep <- rep(TRUE, nrow(fit0@frame))
n_clusters <- 0L
for (fi in seq_along(fl)) {
fac <- names(fl)[fi]
levs <- levels(fl[[fi]])
n_clusters <- n_clusters + length(levs)
prefix <- paste0(fac, ": ")
hit <- flagged_names[startsWith(flagged_names, prefix)]
if (!length(hit)) next
bad_levs <- substring(hit, nchar(prefix) + 1L)
keep <- keep & !(as.character(fl[[fi]]) %in% bad_levs)
}
## >50% of clusters flagged -> do not clean.
if (n_clusters == 0L || n_flagged > floor(0.5 * n_clusters)) {
warning("anova(test = \"boot\", null = \"robust\"): ", n_flagged,
" of ", n_clusters, " clusters flagged (> 50%); falling ",
"back to the plain parametric null (no cleaning).",
call. = FALSE)
return(untrimmed("over-half-flagged"))
}
if (!any(keep)) return(untrimmed("no-observations-left"))
## Nonsingular-subsampling check on the trimmed design (Koller and
## Stahel 2017; the same test the RANSAC subsampler uses). Removing the
## flagged (contaminated) clusters must not leave the fixed-effects
## design rank-deficient, drop a fixed-factor contrast level, collapse a
## grouping factor below two levels, or make a random-slope covariate
## constant. Unlike the RANSAC repair we cannot add units back -- the
## excluded clusters are exactly the ones we are deliberately removing --
## so a deficient trim falls back to the plain parametric null. This
## catches near-singular trims (e.g. a contrast column that becomes
## near-constant) that the post-refit parameter-dimension check below
## would miss.
rank_info <- tryCatch(.ransac_design_info(formula(fit0), fit0@frame),
error = function(e) NULL)
if (!is.null(rank_info)) {
chk <- tryCatch(.ransac_check_subsample(which(keep), fit0@frame,
rank_info),
error = function(e) list(ok = TRUE))
if (!isTRUE(chk$ok)) {
warning("anova(test = \"boot\", null = \"robust\"): trimming the ",
"flagged clusters leaves the design rank-deficient ",
"(dropped contrast level, collapsed grouping factor, or ",
"constant random-slope covariate); falling back to the ",
"plain parametric null (no cleaning).", call. = FALSE)
return(untrimmed("rank-deficient"))
}
}
data_keep <- fit0@frame[keep, , drop = FALSE]
for (fi in seq_along(fl)) {
fac <- names(fl)[fi]
if (is.factor(data_keep[[fac]]))
data_keep[[fac]] <- droplevels(data_keep[[fac]])
}
fc <- .boot_refit(fit0, data_keep, th_orig)
if (is.null(fc)) {
warning("anova(test = \"boot\", null = \"robust\"): the trimmed ",
"null refit failed; falling back to the plain parametric ",
"null (no cleaning).", call. = FALSE)
return(untrimmed("refit-failed"))
}
sig_c <- .sigma(fc)
th_c <- getME(fc, "theta")
beta_c <- .fixef(fc)
## The trimmed refit must be parameter-compatible with fit0 (same
## theta / beta dimensions); a level dropped from a fixed-effect
## factor, say, makes the cleaned parameters unusable as generating
## parameters for the full design.
if (length(th_c) != length(th_orig) || anyNA(th_c) ||
length(beta_c) != length(beta0) || anyNA(beta_c)) {
warning("anova(test = \"boot\", null = \"robust\"): the trimmed ",
"null refit is not parameter-compatible with the full ",
"fit; falling back to the plain parametric null (no ",
"cleaning).", call. = FALSE)
return(untrimmed("refit-failed"))
}
## Build the generator's U_b = Lambda(theta) directly at the cleaned
## theta: robustlmm's internal (Cholesky-space) theta maps onto the
## non-zeros of U_b through pp$Lind (exactly what rlmerPredD$setTheta
## does), so this is exact for any number of variance components.
## For a single component it coincides with scaling fit0's U_b by
## theta_clean / theta_orig.
Ub_c <- Ub0
Ub_c@x <- th_c[fit0@pp$Lind]
list(sigma = sig_c, theta = th_c, beta = beta_c, Ub = Ub_c,
flagged = flagged_names, n_flagged = n_flagged, cleaned = TRUE,
reason = "cleaned")
}
## Shared machinery of the parametric-bootstrap paths (test = "boot"
## and test = "score"): simulate one dataset from the null generating
## parameters, y* = X beta0 + Z Lambda(theta0) (sigma0 z_b) + sigma0
## eps. Consumes exactly rnorm(q0) followed by rnorm(n), so extracting
## this helper leaves the callers' RNG streams (and hence seeded
## p-values) bit-identical.
.boot_sim_data <- function(data_obs, yname, Xb0, Z0, Ub0, sig0, n, q0) {
b_sim <- sig0 * as.numeric(Ub0 %*% rnorm(q0))
eps <- rnorm(n, sd = sig0)
d_sim <- data_obs
d_sim[[yname]] <- Xb0 + as.numeric(Z0 %*% b_sim) + eps
d_sim
}
## Quiet bootstrap refit at a warm start; NULL on error.
.boot_refit <- function(fit, d_sim, start)
tryCatch(suppressMessages(suppressWarnings(
update(fit, data = d_sim, start = start))),
error = function(e) NULL)
.anova_pair_boot <- function(fit0, fit1, nsim = 1000L, seed = NULL,
verbose = FALSE, robust.null = FALSE) {
if (!is.null(seed)) set.seed(seed)
sig0_obs <- .sigma(fit0)
D_obs <- .quasi_deviance(fit0, scale = sig0_obs) -
.quasi_deviance(fit1, scale = sig0_obs)
pp0 <- fit0@pp
th0 <- getME(fit0, "theta")
X <- pp0$X
Z0 <- t(pp0$Zt)
n <- pp0$n; q0 <- pp0$q
yname <- as.character(formula(fit0)[[2L]])
data_obs <- fit0@frame
th1 <- getME(fit1, "theta")
## Generating parameters for the bootstrap null. Plain parametric:
## fit0's raw robust estimates. Robust null: a contamination-cleaned
## null fit (D_obs above is unchanged -- the test still uses all data).
rn_info <- NULL
if (isTRUE(robust.null)) {
rn_info <- .anova_robust_null_params(fit0)
sig0 <- rn_info$sigma
beta0 <- rn_info$beta
Ub0 <- rn_info$Ub
} else {
sig0 <- sig0_obs
beta0 <- .fixef(fit0)
Ub0 <- pp0$U_b
}
Xb0 <- as.numeric(X %*% beta0)
D_boot <- rep(NA_real_, nsim)
n_fail <- 0L
t0 <- Sys.time()
for (r in seq_len(nsim)) {
d_sim <- .boot_sim_data(data_obs, yname, Xb0, Z0, Ub0, sig0, n, q0)
f0_s <- .boot_refit(fit0, d_sim, th0)
f1_s <- .boot_refit(fit1, d_sim, th1)
if (is.null(f0_s) || is.null(f1_s)) {
n_fail <- n_fail + 1L; next
}
sig0_s <- .sigma(f0_s)
D_boot[r] <- .quasi_deviance(f0_s, scale = sig0_s) -
.quasi_deviance(f1_s, scale = sig0_s)
if (verbose && r %% 50L == 0L) {
elapsed <- as.numeric(Sys.time() - t0, units = "secs")
message(sprintf(" boot rep %d/%d, elapsed %.1fs",
r, nsim, elapsed))
}
}
D_clean <- D_boot[!is.na(D_boot)]
if (length(D_clean) < 10L)
stop("Bootstrap: only ", length(D_clean), " of ", nsim,
" replicates succeeded; need >= 10 to compute a p-value.",
call. = FALSE)
if (length(D_clean) < 200L)
warning("Bootstrap p-value based on only ", length(D_clean),
" effective replicates; consider raising nsim for a ",
"stable tail estimate.", call. = FALSE)
pval <- mean(D_clean >= D_obs)
list(D_obs = D_obs, D_boot = D_clean,
p.value = pval, n_fail = n_fail, nsim = nsim,
robust.null = rn_info)
}
## ------------------------------------------------------------------
## EXPERIMENTAL robust variance-component SCORE test (test = "score").
##
## One-sided score test for a single added (scalar, uncorrelated)
## variance component, computed from the robust NULL fit only and
## calibrated by a parametric SCORE bootstrap that refits ONLY the
## null model per replicate (~4x cheaper than the deviance bootstrap's
## double refits). Statistic (per cluster j, from the null fit):
##
## V_j = sigma0^2 (I + Z0_j Lambda0 Lambda0' Z0_j')
## rtil_j = V_j^{-1/2} (y_j - X_j beta0) (whitened marginal resid,
## symmetric square root)
## v_j = V_j^{-1/2} z_add_j (whitened tested direction;
## z_add = the added RE term's
## design column)
## s_j = (v_j' psi(rtil_j))^2 - kappa_1 ||v_j||^2,
## psi = fit0's rho.e psi, kappa_1 = rho.e@Epsi2()
## S = sum_j s_j / sqrt(sum_j (s_j - s_bar)^2)
##
## kappa_1 ||v_j||^2 is the exact E_Phi[(v' psi(W))^2], W ~ N(0, I)
## (cross terms vanish by independence + odd psi), so each s_j is
## Fisher-consistency centered; the self-normalisation makes any
## j-constant factor drop out. Reject for S large positive (extra
## variance in the tested direction inflates the v-weighted quadratic
## form). Validated by simulation (robust-score-test.R phase 1 +
## robust-score-test-phase2.R, robustlmm-open-problems): all
## pre-registered gates passed; see ?anova.rlmerMod for the numbers.
##
## NO null cleaning and NO .bootstrap_group_guard call here, by
## design: the per-cluster contributions are psi-bounded, so the
## bootstrap null law is insensitive to the O(eps)-biased generating
## parameters -- in 600+ paired replicates (including the
## uncentered-x honesty cells) the trim65-cleaned generating
## parameters changed at most 1-2 decisions vs the raw ones, so the
## cleaning composition is validated unnecessary and the C4 guard's
## "poisoned null" warning would be misleading noise for this path.
## ------------------------------------------------------------------
## Scope check for test = "score" (fail loudly, matching the package's
## C3 discipline): both fits rlmerMod on the same data, a single
## grouping factor shared by both, identical fixed-effects design, and
## the alternative adds exactly ONE independent scalar random-effect
## column relative to the null: length(theta1) == length(theta0) + 1
## and the added column is a 1-dimensional block, structurally
## uncorrelated with every other random effect (checked on the sparsity
## pattern of fit1's Lambda). Covers both `(1|g)` vs `(1|g) + (0+x|g)`
## and diagonal structures adding one component, e.g. `(1|g)` vs
## `diag(x|g)`.
##
## Returns list(z_add, label) where z_add is the added term's design
## column per observation, extracted from fit1's Z: the added block
## owns one Z column per cluster and each observation loads on its own
## cluster's column only, so the per-observation design value is the
## row sum over the added block's columns.
.anova_score_scope <- function(fit0, fit1) {
.score_stop <- function(...)
stop(..., " -- not supported by test = \"score\"; use ",
"test = \"boot\".", call. = FALSE)
if (length(fit0@flist) != 1L || length(fit1@flist) != 1L)
.score_stop("test = \"score\" requires a single grouping ",
"factor in both models; crossed or nested ",
"grouping factors are out of scope")
if (!identical(names(fit0@flist)[1L], names(fit1@flist)[1L]) ||
!identical(as.character(fit0@flist[[1L]]),
as.character(fit1@flist[[1L]])))
.score_stop("both models must share the same grouping factor ",
"(same variable, same observation order)")
if (nrow(fit0@frame) != nrow(fit1@frame) ||
!isTRUE(all.equal(as.numeric(fit0@resp$y),
as.numeric(fit1@resp$y))))
.score_stop("both models must be fitted to the same data")
if (!identical(colnames(fit0@pp$X), colnames(fit1@pp$X)))
.score_stop("the fixed-effects designs differ; test = ",
"\"score\" tests a single added variance ",
"component only")
th0 <- getME(fit0, "theta")
th1 <- getME(fit1, "theta")
if (length(th1) != length(th0) + 1L)
.score_stop("the alternative must add exactly one variance ",
"parameter (scalar component) relative to the ",
"null; got length(theta) ", length(th0), " vs ",
length(th1), ". Multi-component or ",
"correlated-slope alternatives are out of scope")
cn0 <- fit0@cnms
cn1 <- fit1@cnms
Gp1 <- fit1@Gp
nl <- length(levels(fit1@flist[[1L]]))
gname <- names(fit1@flist)[1L]
## Which of fit1's b-columns belong to the added scalar block?
add_cols <- NULL
add_name <- NULL
if (length(cn1) == length(cn0) + 1L) {
## case (a): the alternative has one extra 1-column term,
## e.g. (1|g) vs (1|g) + (0 + x|g)
for (k in seq_along(cn1)) {
if (length(cn1[[k]]) != 1L) next
if (identical(unname(cn1[-k]), unname(cn0))) {
add_cols <- (Gp1[k] + 1L):Gp1[k + 1L]
add_name <- cn1[[k]]
break
}
}
} else if (length(cn1) == length(cn0)) {
## case (b): one term gains one column under a diagonal
## structure, e.g. (1|g) vs diag(x|g). b-layout within a term
## is level-major: the length(cnms[[k]]) coordinates of one
## level are consecutive.
deleted_pos <- function(small, big) {
if (length(big) != length(small) + 1L) return(NA_integer_)
for (pos in seq_along(big))
if (identical(big[-pos], small)) return(pos)
NA_integer_
}
differs <- which(!vapply(seq_along(cn1), function(k)
identical(cn1[[k]], cn0[[k]]), logical(1)))
if (length(differs) == 1L) {
k <- differs
pos <- deleted_pos(cn0[[k]], cn1[[k]])
nc1 <- length(cn1[[k]])
if (!is.na(pos) && Gp1[k + 1L] - Gp1[k] == nc1 * nl) {
add_cols <- Gp1[k] + (seq_len(nl) - 1L) * nc1 + pos
add_name <- cn1[[k]][pos]
}
}
}
if (is.null(add_cols))
.score_stop("could not identify a single added scalar ",
"random-effect column in the alternative model ",
"(models must be nested with exactly one added ",
"independent component)")
## The added block must be structurally uncorrelated with every
## other random effect: no structural nonzero of fit1's Lambda
## pattern may couple the added b-coordinates to any other
## b-coordinate (a correlated-slope alternative would).
Ub1 <- methods::as(methods::as(fit1@pp$U_b, "generalMatrix"),
"TsparseMatrix")
in_add_i <- (Ub1@i + 1L) %in% add_cols
in_add_j <- (Ub1@j + 1L) %in% add_cols
if (any(in_add_i != in_add_j))
.score_stop("the added random-effect column is correlated ",
"with other random effects in the alternative ",
"model; only an independent (uncorrelated) added ",
"component is in scope")
Z1 <- t(fit1@pp$Zt)
z_add <- Matrix::rowSums(Z1[, add_cols, drop = FALSE])
list(z_add = as.numeric(z_add),
label = paste0("(", add_name, " | ", gname, ")"))
}
## The score statistic S and the per-cluster contributions s_j, from
## the null fit and the added term's design column. The per-cluster
## marginal covariance is built from the null fit's own estimates:
## with M = Z Lambda(theta0) (= Z %*% pp$U_b), V_j / sigma0^2 =
## I + M_j M_j' restricted to cluster j's rows (exact for a single
## grouping factor, where V is block-diagonal by cluster). The
## symmetric inverse square root is computed by a per-cluster
## eigendecomposition (cluster sizes are small); in the balanced
## exchangeable special case this reproduces the analytic root used in
## the validation study to machine precision.
.anova_score_stat <- function(fit0, z_add) {
sig <- .sigma(fit0)
beta <- .fixef(fit0)
r <- as.numeric(fit0@resp$y - fit0@resp$offset -
fit0@pp$X %*% beta)
psi <- fit0@rho.e@psi
kap1 <- fit0@rho.e@Epsi2()
M <- Matrix::crossprod(fit0@pp$Zt, fit0@pp$U_b)
idx <- split(seq_along(r), fit0@flist[[1L]])
s_j <- vapply(idx, function(ii) {
Mi <- as.matrix(M[ii, , drop = FALSE])
Mi <- Mi[, colSums(abs(Mi)) > 0, drop = FALSE]
Vi <- diag(length(ii)) + tcrossprod(Mi) # V_j / sigma^2
ee <- eigen(Vi, symmetric = TRUE)
## symmetric V_j^{-1/2} (eigenvalues >= 1 by construction)
W <- ee$vectors %*% (t(ee$vectors) / sqrt(ee$values)) / sig
rt <- as.numeric(W %*% r[ii])
v <- as.numeric(W %*% z_add[ii])
tj <- sum(v * psi(rt))
tj^2 - kap1 * sum(v^2)
}, numeric(1))
den <- sqrt(sum((s_j - mean(s_j))^2))
S <- if (den > 0) sum(s_j) / den else NA_real_
list(S = S, s_j = s_j)
}
## Parametric SCORE bootstrap: simulate nsim datasets from fit0's
## estimates with the same generation machinery as .anova_pair_boot,
## refit ONLY the null model per replicate, and recompute S*. p-value
## = (1 + #{S* >= S_obs}) / (n_effective + 1), one-sided.
.anova_pair_score <- function(fit0, fit1, nsim = 199L, seed = NULL,
verbose = FALSE) {
scope <- .anova_score_scope(fit0, fit1)
if (!is.null(seed)) set.seed(seed)
obs <- .anova_score_stat(fit0, scope$z_add)
if (!is.finite(obs$S))
stop("test = \"score\": the observed statistic is degenerate ",
"(zero variance of the per-cluster contributions).",
call. = FALSE)
pp0 <- fit0@pp
th0 <- getME(fit0, "theta")
X <- pp0$X
Z0 <- t(pp0$Zt)
Ub0 <- pp0$U_b
n <- pp0$n; q0 <- pp0$q
sig0 <- .sigma(fit0)
beta0 <- .fixef(fit0)
yname <- as.character(formula(fit0)[[2L]])
data_obs <- fit0@frame
Xb0 <- as.numeric(X %*% beta0)
S_boot <- rep(NA_real_, nsim)
t0 <- Sys.time()
for (r in seq_len(nsim)) {
d_sim <- .boot_sim_data(data_obs, yname, Xb0, Z0, Ub0, sig0, n, q0)
f0_s <- .boot_refit(fit0, d_sim, th0)
if (is.null(f0_s)) next
S_boot[r] <- tryCatch(.anova_score_stat(f0_s, scope$z_add)$S,
error = function(e) NA_real_)
if (verbose && r %% 50L == 0L) {
elapsed <- as.numeric(Sys.time() - t0, units = "secs")
message(sprintf(" score boot rep %d/%d, elapsed %.1fs",
r, nsim, elapsed))
}
}
S_clean <- S_boot[!is.na(S_boot)]
n_fail <- nsim - length(S_clean)
if (length(S_clean) < 10L)
stop("Score bootstrap: only ", length(S_clean), " of ", nsim,
" replicates succeeded; need >= 10 to compute a p-value.",
call. = FALSE)
if (length(S_clean) < 100L)
warning("Score-bootstrap p-value based on only ",
length(S_clean), " effective replicates; consider ",
"raising nsim for a stable tail estimate.",
call. = FALSE)
pval <- (1 + sum(S_clean >= obs$S)) / (length(S_clean) + 1)
list(S_obs = obs$S, s_j = obs$s_j, S_boot = S_clean,
p.value = pval, n_fail = n_fail, nsim = nsim,
label = scope$label)
}
##' Analysis of variance for an rlmer fit.
##'
##' Three modes.
##' \describe{
##' \item{Single fit \code{anova(fit)}}{returns a per-term robust Wald
##' chi-square table from \code{vcov(fit, type = vcov_type)}. Each
##' term is tested marginally as
##' \eqn{T_t = \hat\beta_t^\top \hat V_t^{-1} \hat\beta_t \sim
##' \chi^2_{k_t}} under \eqn{H_0: \beta_t = 0}.}
##' \item{Nested fits differing only in fixed effects (default
##' \code{test = "Wald"})}{robust Wald restriction test on the
##' extra coefficients. The same \eqn{V} used by the single-fit
##' table is used here; \code{vcov_type = "sandwich"} carries through.}
##' \item{Nested fits differing in random-effects structure}{the
##' Wald and score asymptotics are invalid (boundary problem; see
##' Self-Liang 1987 and Koller 2026 paper 2). \code{test = "Wald"}
##' warns and switches to the parametric bootstrap automatically;
##' \code{test = "boot"} is the default valid path. The bootstrap
##' simulates \code{nsim} datasets from the fitted central LMM at
##' \code{fit0}'s estimates, refits both rlmer models per
##' replicate, and uses the quasi-deviance difference
##' \eqn{D = 2 \sum [\rho_e(r_i^{(0)}/\hat\sigma_0) -
##' \rho_e(r_i^{(1)}/\hat\sigma_1)]} as the discrepancy
##' statistic (Heritier and Ronchetti 1994; Cantoni and Ronchetti
##' 2001; Heritier, Cantoni, Copt and Victoria-Feser 2009 sec. 5).
##' For the special case of a \emph{single added variance
##' component} the experimental \code{test = "score"} offers a
##' contamination-robust alternative (see Details).}
##' }
##'
##' Only pairwise comparison is implemented; chains \code{anova(fit0,
##' fit1, fit2, ...)} are not yet supported.
##'
##' \strong{Small-J caveat for the sandwich.} \code{vcov_type =
##' "sandwich"} is markedly anti-conservative at small \eqn{J}: in
##' a simulation study the pairwise Wald Type-I rate reached
##' 0.15-0.20 (vs. nominal 0.05) at \eqn{J = 8} and was still elevated
##' at \eqn{J = 18}, returning to nominal only by \eqn{J \gtrsim 50}.
##' \code{vcov_sandwich} emits a warning for \eqn{J < 20}; prefer
##' \code{vcov_type = "default"} for hypothesis tests at small
##' \eqn{J}.
##'
##' \strong{Subject-contamination caveat for the bootstrap
##' (experimental).} The parametric-bootstrap quasi-deviance path
##' (\code{test = "boot"}) is experimental --- it is currently the only
##' exposed variance-component path. It calibrates correctly under clean
##' Gaussian and heavy-tailed errors but is markedly anti-conservative
##' when a subset of subjects is contaminated. In a simulation study with 10\% of subjects
##' shifted by 5 standard errors (\code{shift_subj}), Type-I climbed
##' to 0.13-0.24 (2.5-5x nominal) across \eqn{J \in \{8, 18, 50\}}.
##' The mechanism: the bootstrap simulates from the fitted central
##' LMM at \code{fit0}'s estimates, so any bias \code{fit0}'s
##' \eqn{\hat\sigma_0, \hat\theta_0} picked up from the contaminated
##' subjects propagates into a too-narrow bootstrap null. As an automatic
##' guard, \code{anova(test = "boot")} now inspects the null fit's own
##' random-effects robustness weights and \strong{warns} when a group is
##' heavily downweighted (smallest weight below \eqn{0.5}) --- a direct
##' signal that the fit treated that group as an outlier, so the bootstrap
##' null may be poisoned. (The robust fit absorbs a contaminated group
##' into a downweighted random effect, which is why
##' \code{\link[=cooks.distance.rlmerMod]{cooks.distance}} --- an
##' \emph{influence} measure --- does \emph{not} reliably flag it here:
##' the downweighting that removes the bias also removes the influence.)
##' When the warning fires, treat the p-value with caution.
##'
##' \strong{Experimental robust null (\code{null = "robust"}).} As a
##' mitigation for the subject-contamination anti-conservativeness above,
##' the bootstrap path accepts \code{null = "robust"}: instead of
##' generating the parametric bootstrap from \code{fit0}'s raw estimates,
##' it generates from a \emph{contamination-cleaned} null fit. Clusters the
##' robust fit heavily downweights (smallest per-cluster random-effect
##' weight below a tuned threshold, the same signal the automatic guard
##' uses) are flagged, \code{fit0} is refitted with those clusters removed,
##' and the bootstrap is generated from the resulting de-biased
##' \eqn{(\hat\sigma, \hat\theta, \hat\beta)} (with the generator's
##' \eqn{U_b = \Lambda(\theta)} rebuilt at the full cleaned \eqn{\hat\theta}
##' vector). The observed discrepancy \eqn{D} is still
##' taken from the original untrimmed fits, so the test uses all the data;
##' only the bootstrap null generation is cleaned. If no cluster is
##' flagged, more than half the clusters are flagged, trimming would leave
##' the design rank-deficient (a dropped contrast level, a collapsed
##' grouping factor, or a constant random-slope covariate --- checked by
##' the same nonsingular-subsampling test used by the RANSAC initial
##' estimator), or the trimmed refit fails, the call falls back to the
##' plain parametric null (so on clean data \code{null = "robust"} closely
##' matches \code{null = "parametric"}); the table heading states which
##' case applied. In simulation this reduces the contamination-induced
##' Type-I inflation at larger \eqn{J} while preserving power, at the cost
##' of mild conservatism on clean data. It is \strong{experimental},
##' validated by simulation rather than a finite-sample theorem; the table
##' heading notes when the robust null was applied.
##'
##' \strong{Experimental robust score test (\code{test = "score"}).}
##' For the common special case where the alternative adds exactly
##' \emph{one independent scalar variance component} relative to the
##' null --- e.g. \code{(1|g)} vs \code{(1|g) + (0 + x|g)}, or diagonal
##' structures adding one component --- \code{test = "score"} runs a
##' one-sided robust score test computed from the robust \emph{null}
##' fit only and calibrated by a score-only parametric bootstrap that
##' refits just the null model per replicate (roughly 4x cheaper than
##' \code{test = "boot"}'s double refits; hence the smaller default
##' \code{nsim = 199}). Per cluster \eqn{j}, the whitened marginal
##' residuals \eqn{\tilde r_j = V_j^{-1/2}(y_j - X_j \hat\beta_0)} and
##' the whitened tested direction \eqn{v_j = V_j^{-1/2} z_j} (with
##' \eqn{V_j} the null fit's marginal covariance and \eqn{z_j} the
##' added term's design column) give the bounded contribution
##' \eqn{s_j = (v_j^\top \psi(\tilde r_j))^2 - \kappa_1 \|v_j\|^2}
##' (\eqn{\psi} the fit's \code{rho.e} psi-function, \eqn{\kappa_1 =
##' E[\psi(Z)^2]}); the statistic is the self-normalised cluster sum
##' \eqn{S = \sum_j s_j / \sqrt{\sum_j (s_j - \bar s)^2}}, and the
##' one-sided p-value is \eqn{(1 + \#\{S^* \ge S\})/(n_{\mathrm{eff}} +
##' 1)}. Because each \eqn{s_j} is psi-bounded, whole-cluster
##' contamination shifts the statistic and its bootstrap reference law
##' together instead of inflating the test: in simulation (Gaussian
##' balanced designs, one scalar tested component; not a theorem)
##' contaminated-null Type-I was 0.035 vs 0.115 for \code{test =
##' "boot"} with 10\% of clusters shifted at \eqn{J = 50}, clean-null
##' Type-I 0.045, and power 0.920 vs 0.900 --- with no null cleaning
##' needed (raw vs cleaned generating parameters changed at most 1-2
##' decisions in 600+ paired replicates, so \code{null = "robust"} is
##' ignored and no downweighted-group warning is issued for this
##' path). Across an adversarial sweep the pattern held: \eqn{J = 18}
##' clean/contaminated 0.055/0.015, uncentered-\eqn{x} designs
##' clean/contaminated 0.045/0.075, 5\% single-observation outliers
##' 0.060. \emph{Scope}: both fits on the same data with identical
##' fixed effects and a single shared grouping factor, and the
##' alternative adds exactly one uncorrelated scalar component
##' (\code{length(theta)} differs by 1); anything else ---
##' multi-component or correlated-slope alternatives, crossed or
##' nested factors --- stops with an error, use \code{test = "boot"}
##' there. \emph{Caveat}: contamination aligned with the tested
##' direction is indistinguishable from the alternative for any test
##' with power; inspect the attached per-cluster contributions
##' (\code{attr(., "boot")$s_j}; the largest values identify the
##' clusters driving the statistic) and cluster diagnostics
##' (\code{cooks.distance(fit, groups = )},
##' \code{\link[=hatvalues.rlmerMod]{hatvalues}}) when a rejection is
##' suspect. \strong{Experimental}: simulation-validated, not proven;
##' the deviance bootstrap \code{test = "boot"} remains the default
##' variance-component path.
##'
##' @param object An \code{rlmerMod} object.
##' @param ... A second \code{rlmerMod} object for pairwise comparison.
##' @param test One of \code{"Wald"} (default; closed form for nested
##' fixed-effects-only tests), \code{"boot"} (parametric bootstrap
##' quasi-deviance; the default valid path for variance-component
##' tests), or \code{"score"} (experimental one-sided robust score
##' test for a single added variance component, calibrated by a
##' score-only parametric bootstrap; see Details for its scope and
##' validation record).
##' @param null Bootstrap null generation for \code{test = "boot"}.
##' \code{"parametric"} (default) generates from \code{fit0}'s robust
##' estimates (the exact current behaviour). \code{"robust"} is an
##' experimental contamination-robust null that generates from a cleaned
##' null fit (see Details); it only affects the variance-component
##' bootstrap path and is ignored for the Wald paths.
##' @param vcov_type Forwarded to \code{\link[=vcov.rlmerMod]{vcov}}
##' for the Wald paths; ignored for the bootstrap.
##' @param ddf Denominator degrees of freedom for the Wald paths.
##' \code{"none"} (default) reports the chi-square table as before;
##' \code{"satterthwaite"} reports an F-test with a Satterthwaite
##' denominator df (the multivariate \code{lmerTest::contestMD}
##' generalisation, built on the robust IF-based covariance of the
##' variance parameters). It requires \code{vcov_type =
##' "default"} and a single grouping factor; otherwise it warns and
##' falls back to the chi-square table.
##' @param nsim Bootstrap replicates when \code{test = "boot"} or
##' \code{test = "score"}; default 1000 for \code{"boot"} and 199
##' for \code{"score"} (whose null-only refits are ~4x cheaper).
##' @param seed Optional RNG seed for reproducibility of the bootstrap.
##' @param verbose Bootstrap progress messages.
##' @return An \code{"anova"} data.frame; the bootstrap path attaches
##' \code{attr(., "boot") = list(D_boot, n_fail, nsim, D_obs)}. The
##' score path attaches \code{attr(., "boot") = list(S_boot, n_fail,
##' nsim, S_obs, s_j)} with \code{s_j} the named per-cluster
##' contributions to the observed statistic.
##' @references Heritier S, Ronchetti E (1994). \emph{Robust
##' bounded-influence tests in general parametric models}. JASA
##' 89(427): 897--904.
##' @references Cantoni E, Ronchetti E (2001). \emph{Robust inference
##' for generalized linear models}. JASA 96(455): 1022--1030.
##' @references Heritier S, Cantoni E, Copt S, Victoria-Feser MP
##' (2009). \emph{Robust Methods in Biostatistics}. Wiley.
##' @seealso \code{\link[=vcov.rlmerMod]{vcov}},
##' \code{\link[=confint.rlmerMod]{confint}}
##' @importFrom stats anova pchisq pf
##' @method anova rlmerMod
##' @export
anova.rlmerMod <- function(object, ..., test = c("Wald", "boot", "score"),
null = c("parametric", "robust"),
vcov_type = c("default", "sandwich"),
ddf = c("none", "satterthwaite"),
nsim = 1000L, seed = NULL,
verbose = FALSE) {
stopifnot(is(object, "rlmerMod"))
vcov_type <- match.arg(vcov_type)
test_arg <- match.arg(test)
null <- match.arg(null)
ddf <- match.arg(ddf)
## Score refits only the null model per replicate (~4x cheaper),
## so its validated default is nsim = 199, not the boot's 1000.
if (test_arg == "score" && missing(nsim))
nsim <- 199L
if (ddf == "satterthwaite" && vcov_type != "default") {
warning("ddf = \"satterthwaite\" is only available with ",
"vcov_type = \"default\"; reverting to the chi-square ",
"Wald table.", call. = FALSE)
ddf <- "none"
}
## Compute the (cached) influence function once for the fit that
## carries the tested coefficients; fall back to the chi-square
## table if it is unavailable (unsupported design, etc.).
.satt_IF <- function(fit) {
IF <- tryCatch(implicitIF_full(fit), error = function(e) NULL)
if (is.null(IF))
warning("Satterthwaite df unavailable for this fit; ",
"reverting to the chi-square Wald table.",
call. = FALSE)
IF
}
dots <- list(...)
if (length(dots) == 0L) {
IF <- NULL
if (ddf == "satterthwaite") {
IF <- .satt_IF(object)
if (is.null(IF)) ddf <- "none"
}
return(.anova_single_wald(object, vcov_type = vcov_type,
ddf = ddf, IF = IF))
}
if (length(dots) > 1L)
stop("anova.rlmerMod currently supports pairwise comparison ",
"only (anova(fit0, fit1)).", call. = FALSE)
other <- dots[[1L]]
if (!is(other, "rlmerMod"))
stop("Both arguments must be rlmerMod objects.", call. = FALSE)
## Canonicalise so fit0 = smaller, fit1 = larger.
n_par <- function(f) f@pp$p + length(getME(f, "theta")) + 1L
if (n_par(other) < n_par(object)) {
fit0 <- other; fit1 <- object
} else {
fit0 <- object; fit1 <- other
}
is_vc <- .differ_in_re(fit0, fit1)
if (is_vc && test_arg == "Wald") {
warning("Models differ in random-effects structure; the Wald ",
"test on the variance-component boundary is invalid. ",
"Switching to test = \"boot\" (parametric bootstrap).",
call. = FALSE)
test_arg <- "boot"
}
if (test_arg == "score") {
if (!is_vc)
stop("test = \"score\" tests a single added variance ",
"component; the models do not differ in their ",
"random-effects structure. Use test = \"Wald\" for ",
"fixed-effects comparisons.", call. = FALSE)
## No .bootstrap_group_guard call here, deliberately: the score
## statistic's per-cluster contributions are psi-bounded, so
## its bootstrap null law is insensitive to the O(eps)-biased
## generating parameters (validated: cleaned vs raw generating
## parameters changed at most 1-2 decisions in 600+ paired
## replicates, including the uncentered-x cells). The guard's
## "poisoned null" warning is a deviance-bootstrap concern and
## would be misleading noise for this path.
sc <- .anova_pair_score(fit0, fit1, nsim = nsim, seed = seed,
verbose = verbose)
out <- data.frame(npar = c(n_par(fit0), n_par(fit1)),
Df = c(NA_integer_, 1L),
Score = c(NA_real_, sc$S_obs),
`Pr(>=Score)` = c(NA_real_, sc$p.value),
check.names = FALSE)
rownames(out) <- c("fit0", "fit1")
attr(out, "boot") <- list(S_boot = sc$S_boot,
n_fail = sc$n_fail,
nsim = sc$nsim,
S_obs = sc$S_obs,
s_j = sc$s_j)
heading <- c(
"Robust score test for one added variance component (rlmer)",
" EXPERIMENTAL: simulation-validated, not proven; see ?anova.rlmerMod",
sprintf(" tested component: %s, one-sided", sc$label),
sprintf(paste0(" parametric score bootstrap (null refits ",
"only): nsim = %d (effective = %d, fit ",
"failures = %d)"),
sc$nsim, length(sc$S_boot), sc$n_fail),
paste0(" largest per-cluster contributions attr(., ",
"\"boot\")$s_j identify the driving clusters"))
return(structure(out, heading = heading,
class = c("anova", "data.frame")))
}
if (test_arg == "Wald") {
IF <- NULL
if (ddf == "satterthwaite") {
IF <- .satt_IF(fit1)
if (is.null(IF)) ddf <- "none"
}
return(.anova_pair_wald(fit0, fit1, vcov_type = vcov_type,
ddf = ddf, IF = IF))
}
## C4 auto-guard: warn if the robust null fit downweights a group
## (the bootstrap null is built from fit0, so group contamination
## there makes the test anti-conservative).
.bootstrap_group_guard(fit0)
robust_null <- identical(null, "robust")
boot <- .anova_pair_boot(fit0, fit1, nsim = nsim, seed = seed,
verbose = verbose, robust.null = robust_null)
p0 <- fit0@pp$p; p1 <- fit1@pp$p
L0 <- length(getME(fit0, "theta")); L1 <- length(getME(fit1, "theta"))
df_diff <- (p1 - p0) + (L1 - L0)
sig0 <- .sigma(fit0)
out <- data.frame(npar = c(n_par(fit0), n_par(fit1)),
QuasiDev = c(.quasi_deviance(fit0, scale = sig0),
.quasi_deviance(fit1, scale = sig0)),
Df = c(NA_integer_, df_diff),
Diff = c(NA_real_, boot$D_obs),
`Pr(>=Diff)` = c(NA_real_, boot$p.value),
check.names = FALSE)
rownames(out) <- c("fit0", "fit1")
attr(out, "boot") <- list(D_boot = boot$D_boot, n_fail = boot$n_fail,
nsim = nsim, D_obs = boot$D_obs,
robust.null = boot$robust.null)
heading <- c(
"Parametric bootstrap quasi-deviance test (rlmer)",
sprintf(" nsim = %d (effective = %d, fit failures = %d)",
nsim, length(boot$D_boot), boot$n_fail))
if (robust_null) {
rn <- boot$robust.null
cleaned <- !is.null(rn) && isTRUE(rn$cleaned)
reason <- if (is.null(rn)) NULL else rn$reason
heading <- c(heading,
if (cleaned)
sprintf(paste0(" experimental robust null: bootstrap ",
"generated from a contamination-cleaned ",
"fit (%d cluster(s) trimmed)"),
rn$n_flagged)
else if (identical(reason, "layout-unresolved"))
paste0(" experimental robust null: random-effect weight ",
"layout unresolved; robust null not applied ",
"(plain parametric null used)")
else if (identical(reason, "over-half-flagged"))
paste0(" experimental robust null: > 50% of clusters ",
"flagged; robust null not applied (plain ",
"parametric null used)")
else if (identical(reason, "refit-failed") ||
identical(reason, "no-observations-left"))
paste0(" experimental robust null: trimmed refit not ",
"usable; robust null not applied (plain ",
"parametric null used)")
else if (identical(reason, "rank-deficient"))
paste0(" experimental robust null: trimming the flagged ",
"clusters would make the design rank-deficient; ",
"robust null not applied (plain parametric null used)")
else
paste0(" experimental robust null: no cluster flagged; ",
"identical to null = \"parametric\""))
}
structure(out, heading = heading,
class = c("anova", "data.frame"))
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.