Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# insight (devel)

## New supported models

* Support for models of class `glmmTMB` fitted with the new `ordinal()`
family (*glmmTMB* >= 1.1.15). As for `ordinal::clm()`, the thresholds are
returned as conditional parameters ahead of the slopes, with standard errors
Comment thread
jmgirard marked this conversation as resolved.
and statistics obtained via the delta method (the thresholds are estimated on
an internal, softmax-parameterized scale), while the intercept, which is
fixed to zero by *glmmTMB*, is dropped. `get_predicted()` returns
per-category probabilities for `predict = "expectation"` and the most likely
category for `predict = "classification"`, and `get_variance()` uses the
distribution-specific residual variance of the latent scale, as for
`ordinal::clmm()`.

## Bug fixes

* `find_auxiliary()` no longer returns `"sigma"` for *brms* models that have no
Expand Down
15 changes: 10 additions & 5 deletions R/compute_variances.R
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.compute_variances <- function(

Check warning on line 1 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint-changed-files / lint-changed-files

file=R/compute_variances.R,line=1,col=1,[cyclocomp_linter] Reduce the cyclomatic complexity of this expression from 57 to at most 40. Consider replacing high-complexity sections like loops and branches with helper functions.

Check warning on line 1 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint / lint

file=R/compute_variances.R,line=1,col=1,[cyclocomp_linter] Reduce the cyclomatic complexity of this expression from 57 to at most 40. Consider replacing high-complexity sections like loops and branches with helper functions.
model,
component,
name_fun = "get_variance",
Expand Down Expand Up @@ -257,7 +257,7 @@
family
))
}
return(NA)

Check warning on line 260 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint-changed-files / lint-changed-files

file=R/compute_variances.R,line=260,col=3,[return_linter] Use implicit return behavior; explicit return() is not needed.

Check warning on line 260 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint / lint

file=R/compute_variances.R,line=260,col=3,[return_linter] Use implicit return behavior; explicit return() is not needed.
}


Expand Down Expand Up @@ -372,7 +372,7 @@
faminfo,
model_null = NULL,
revar_null = NULL,
name,

Check warning on line 375 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint-changed-files / lint-changed-files

file=R/compute_variances.R,line=375,col=3,[function_argument_linter] Arguments without defaults should come before arguments with defaults.

Check warning on line 375 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint / lint

file=R/compute_variances.R,line=375,col=3,[function_argument_linter] Arguments without defaults should come before arguments with defaults.
approx_method = "lognormal",
model_component = NULL,
verbose = TRUE
Expand Down Expand Up @@ -432,17 +432,22 @@
),
.badlink(faminfo$link_function, faminfo$family, verbose = verbose)
)
} else if (faminfo$is_binomial) {
# binomial / bernoulli ----
# --------------------------
} else if (faminfo$is_binomial || (faminfo$is_ordinal && inherits(model, "glmmTMB"))) {
# binomial / bernoulli / cumulative link ----
# -------------------------------------------

# we need this to adjust for "cbind()" outcomes
y_factor <- .binomial_response_weight(model)

# for observation level approximation, when we don't want the "fixed"
# residual variance, pi^2/3, but the variance based on the distribution
# of the response
pmean <- .obs_level_variance(model_null, revar_null)
# of the response. Ordinal models have no meaningful pmean (glmmTMB fixes
# the null model's intercept to zero, the thresholds absorb it)
if (faminfo$is_ordinal) {
pmean <- NULL
} else {
pmean <- .obs_level_variance(model_null, revar_null)
}

# sanity check - clmm-models are "binomial" but have no pmean
if (is.null(pmean) && identical(approx_method, "observation_level")) {
Expand Down Expand Up @@ -606,7 +611,7 @@
sig,
model_null = NULL,
revar_null = NULL,
name,

Check warning on line 614 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint-changed-files / lint-changed-files

file=R/compute_variances.R,line=614,col=3,[function_argument_linter] Arguments without defaults should come before arguments with defaults.

Check warning on line 614 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint / lint

file=R/compute_variances.R,line=614,col=3,[function_argument_linter] Arguments without defaults should come before arguments with defaults.
approx_method = "lognormal",
model_component = NULL,
verbose = TRUE
Expand Down Expand Up @@ -1072,7 +1077,7 @@
fe <- fe[seq_along(rs)]
}

all(mapply(function(r, f) all(r %in% f), rs, fe, SIMPLIFY = TRUE))

Check warning on line 1080 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint-changed-files / lint-changed-files

file=R/compute_variances.R,line=1080,col=7,[undesirable_function_linter] Avoid undesirable function "mapply".

Check warning on line 1080 in R/compute_variances.R

View workflow job for this annotation

GitHub Actions / lint / lint

file=R/compute_variances.R,line=1080,col=7,[undesirable_function_linter] Avoid undesirable function "mapply".
}


Expand Down
7 changes: 6 additions & 1 deletion R/find_parameters_mixed.R
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ find_parameters.glmmTMB <- function(
)

# installed
check_if_installed("lme4")
check_if_installed(c("lme4", "glmmTMB"))

# we extract random effects only when really necessary, to save
# computational time. In particular model with large sample and
Expand All @@ -73,6 +73,11 @@ find_parameters.glmmTMB <- function(
))
}

# ordinal family: thresholds instead of the (fixed) intercept
if (.is_glmmtmb_ordinal(x)) {
l$conditional <- names(.glmmtmb_ordinal_conditional(x))
}

.filter_parameters(l, effects = effects, component = component, flatten = flatten)
}

Expand Down
7 changes: 6 additions & 1 deletion R/get_parameters_mixed.R
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
#' get_parameters(m)
#' @export
get_parameters.glmmTMB <- function(x, effects = "fixed", component = "all", ...) {
check_if_installed("lme4")
check_if_installed(c("lme4", "glmmTMB"))

effects <- validate_argument(effects, c("fixed", "random"))
# fmt: skip
Expand All @@ -59,6 +59,11 @@ get_parameters.glmmTMB <- function(x, effects = "fixed", component = "all", ...)
))
}

# ordinal family: thresholds instead of the (fixed) intercept
if (.is_glmmtmb_ordinal(x)) {
l$conditional <- .glmmtmb_ordinal_conditional(x)
}

# ---- fixed effects (conditional model)

fixed <- data.frame(
Expand Down
38 changes: 38 additions & 0 deletions R/get_predicted_mixed.R
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,44 @@ get_predicted.glmmTMB <- function(
verbose = TRUE,
...
) {
# ordinal family: "expectation" returns per-category probabilities (like
# `clm`), not `plogis()` of the linear predictor. As in `.get_predicted_args()`,
# a `type` argument takes precedence over `predict`
dots <- list(...)
if (.is_glmmtmb_ordinal(x)) {
if (is.null(dots$type)) {
requested <- predict[1]
} else {
requested <- dots$type[1]
}
ordinal_types <- c(
"expectation",
"expected",
"response",
"prediction",
"predicted",
"classification",
"probs"
)
if (isTRUE(requested %in% ordinal_types)) {
if (!is.null(iterations) && verbose) {
format_warning(
"Bootstrapped predictions are currently not supported for `glmmTMB` models with `ordinal()` family.",
"Ignoring the `iterations` argument."
)
}
return(.get_predicted_glmmtmb_ordinal(
x,
data = data,
predict = predict,
ci = ci,
include_random = include_random,
verbose = verbose,
dots = dots
))
}
}

# validation checks
if (!is.null(predict) && predict %in% c("prediction", "predicted", "classification")) {
predict <- "expectation"
Expand Down
19 changes: 19 additions & 0 deletions R/get_statistic.R
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,25 @@
choices = c("all", "conditional", "zi", "zero_inflated", "dispersion")
)

# ordinal family: thresholds are not in the summary coefficient table,
# so compute Wald statistics from the (delta-method) covariance matrix
if (.is_glmmtmb_ordinal(x)) {
params <- get_parameters(x, effects = "fixed", component = "conditional")
se <- sqrt(diag(get_varcov(x, component = "conditional", verbose = FALSE)))
# match by name - non-estimated parameters (rank deficiency, `map`) are
# kept by get_parameters() but have no variance
stat <- data.frame(
Parameter = params$Parameter,
Statistic = params$Estimate / unname(se[params$Parameter]),
Component = "conditional",
stringsAsFactors = FALSE,
row.names = NULL
)
stat <- .filter_component(stat, component)
attr(stat, "statistic") <- find_statistic(x)
return(stat)
}

cs <- compact_list(stats::coef(summary(x)))
out <- lapply(names(cs), function(i) {
data.frame(
Expand Down Expand Up @@ -752,7 +771,7 @@
# not sure t is possible, but it is cheap to include it in the regex
# avoid calling default method which would be computationally wasteful, since
# we need summary() here.
junk <- utils::capture.output(cs <- suppressWarnings(stats::coef(summary(x))))

Check warning on line 774 in R/get_statistic.R

View workflow job for this annotation

GitHub Actions / lint-changed-files / lint-changed-files

file=R/get_statistic.R,line=774,col=33,[implicit_assignment_linter] Avoid implicit assignments in function calls. For example, instead of `if (x <- 1L) { ... }`, write `x <- 1L; if (x) { ... }`.
column_index <- grep("^z$|^t$|Chisq", colnames(cs))
out <- data.frame(
Parameter = row.names(cs),
Expand Down
33 changes: 23 additions & 10 deletions R/get_varcov.R
Original file line number Diff line number Diff line change
Expand Up @@ -593,15 +593,28 @@ get_varcov.glmmTMB <- function(
c("conditional", "zero_inflated", "zi", "dispersion", "all", "full")
)

if (is.null(vcov)) {
vc <- switch(
component,
conditional = .safe_vcov(x)[["cond"]],
zi = ,
zero_inflated = .safe_vcov(x)[["zi"]],
dispersion = .safe_vcov(x)[["disp"]],
stats::vcov(x, full = TRUE)
# ordinal family: supplied (robust) covariance matrices would be on the
# internal softmax scale for the thresholds and are not supported
if (.is_glmmtmb_ordinal(x) && !is.null(vcov)) {
format_error(
"The `vcov` argument is not supported for `glmmTMB` models with `ordinal()` family."
)
}

if (is.null(vcov)) {
if (.is_glmmtmb_ordinal(x) && component %in% c("conditional", "all")) {
# thresholds (delta method) and estimated fixed effects
vc <- .glmmtmb_ordinal_varcov(x)
} else {
Comment thread
jmgirard marked this conversation as resolved.
vc <- switch(
component,
conditional = .safe_vcov(x)[["cond"]],
zi = ,
zero_inflated = .safe_vcov(x)[["zi"]],
dispersion = .safe_vcov(x)[["disp"]],
stats::vcov(x, full = TRUE)
)
}
} else {
vc <- .get_varcov_sandwich(
x,
Expand Down Expand Up @@ -1184,9 +1197,9 @@ get_varcov.LORgee <- get_varcov.gee

# helper-functions -----------------------------------------------------

.safe_vcov <- function(x) {
.safe_vcov <- function(x, ...) {
vc <- tryCatch(
suppressWarnings(stats::vcov(x)),
suppressWarnings(stats::vcov(x, ...)),
error = function(e) e
)
if (inherits(vc, "error")) {
Expand Down
187 changes: 187 additions & 0 deletions R/utils_glmmtmb_ordinal.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# glmmTMB ordinal family ------------------------------------------------
# =======================================================================

# glmmTMB's `ordinal()` family stores the K-1 thresholds as family-specific
# parameters ("psi", softmax-parameterized so that they stay ordered) rather
# than as fixed effects, and fixes the intercept to zero via an internal map.
# To match `ordinal::clm()`/`clmm()`, we report the thresholds (on the
# threshold scale) as conditional parameters ahead of the slopes, drop the
# non-estimated intercept, and transform the covariance matrix with the
# delta method. `vcov(x, full = TRUE)` is on the internal psi scale.

.is_glmmtmb_ordinal <- function(x) {
inherits(x, "glmmTMB") &&
identical(.safe(x$modelInfo$family$family), "ordinal")
}


# is the intercept fixed to zero by glmmTMB's internal map, i.e. not
# estimated? This is the default unless the user supplied a beta map
.glmmtmb_fixed_intercept <- function(x) {
cf <- .safe(lme4::fixef(x)$cond)
bmap <- .safe(x$obj$env$map$beta)
icpt <- which(names(cf) == "(Intercept)")
length(icpt) == 1L && !is.null(bmap) && is.na(bmap[icpt])
}


# named vector of thresholds followed by the estimated fixed effects
.glmmtmb_ordinal_conditional <- function(x) {
cf <- lme4::fixef(x)$cond
if (.glmmtmb_fixed_intercept(x)) {
cf <- cf[names(cf) != "(Intercept)"]
}
c(glmmTMB::family_params(x), cf)
}


# covariance matrix of c(thresholds, fixed effects) on the threshold scale.
# theta_j = qlogis(cumsum(softmax(c(psi, 0)))_j) is a joint function of all
# psi elements, so the Jacobian is J[j, m] = s[m] * ((m <= j) - C_j) /
# (C_j * (1 - C_j)), with s = softmax(c(psi, 0)) and C_j = cumsum(s)[j]
.glmmtmb_ordinal_varcov <- function(x) {
check_if_installed(c("lme4", "glmmTMB"))
V <- .safe_vcov(x, full = TRUE)
thresholds <- glmmTMB::family_params(x)
cf <- lme4::fixef(x)$cond

# internal parameter ids ("cond1", "psi1", ...) are carried as names of the
# row labels; fall back to the labels if these are missing
ids <- names(rownames(V))
if (is.null(ids)) {
psi_i <- match(names(thresholds), rownames(V))
cond_i <- match(names(cf), rownames(V))
} else {
psi_i <- which(startsWith(ids, "psi"))
cond_i <- which(startsWith(ids, "cond"))
}
# non-estimated parameters (the mapped intercept) have NA variance
cond_i <- cond_i[!is.na(cond_i)]
cond_i <- cond_i[!is.na(diag(V)[cond_i])]

psi <- unname(x$fit$par[names(x$fit$par) == "psi"])
k <- length(psi)
w <- exp(c(psi, 0) - max(psi, 0))
s <- w / sum(w)
Cj <- cumsum(s)[seq_len(k)]
J_psi <- outer(
seq_len(k),
seq_len(k),
function(j, m) s[m] * ((m <= j) - Cj[j]) / (Cj[j] * (1 - Cj[j]))
)

J <- diag(k + length(cond_i))
J[seq_len(k), seq_len(k)] <- J_psi
idx <- c(psi_i, cond_i)
out <- J %*% V[idx, idx, drop = FALSE] %*% t(J)
nms <- c(names(thresholds), unname(rownames(V)[cond_i]))
dimnames(out) <- list(nms, nms)
out
}


# per-category probabilities (or the most likely category) from
# `predict(type = "probs")`, reshaped to long format like `get_predicted.clm()`
.get_predicted_glmmtmb_ordinal <- function(
x,
data = NULL,
predict = "expectation",
ci = NULL,
include_random = "default",
verbose = TRUE,
dots = list()
) {
# `type` takes precedence over `predict`, as in `.get_predicted_args()`
if (is.null(dots$type)) {
requested <- predict[1]
} else {
requested <- dots$type[1]
}
classification <- identical(requested, "classification")

if (requested %in% c("prediction", "predicted") && verbose) {
format_warning(
"\"prediction\" is currently not supported by the `predict` argument for `glmmTMB` models.",
"Changing to `predict=\"expectation\"`."
)
}
if (classification && !is.null(ci)) {
if (verbose) {
format_warning("Confidence intervals are not available for classification.")
}
ci <- NULL
}

# sanitize input. This also handles the `predict`/`type` alert and the
# aliases; the type passed to `predict()` is always "probs"
my_args <- do.call(
.get_predicted_args,
c(
list(
x,
data = data,
predict = predict,
ci = ci,
include_random = include_random,
verbose = verbose
),
dots
)
)
my_args$predict <- ifelse(classification, "classification", "expectation")

# remaining dot-arguments are forwarded to `predict()`, as for other
# families; those managed here take precedence
predict_args <- list(
x,
newdata = my_args$data,
type = "probs",
re.form = my_args$re.form,
allow.new.levels = my_args$allow_new_levels,
se.fit = !classification
)
dots[c("type", "newdata", "re.form", "allow.new.levels", "se.fit")] <- NULL
rez <- do.call(stats::predict, c(predict_args, dots))
if (classification) {
probs <- rez
} else {
probs <- rez$fit
}
resp_levels <- colnames(probs)

# the response is not a focal predictor of the long-format output
resp <- find_response(x)
if (!is.null(my_args$data) && !is.null(resp)) {
my_args$data <- my_args$data[, setdiff(colnames(my_args$data), resp), drop = FALSE]
}

if (classification) {
out <- factor(
resp_levels[max.col(probs, ties.method = "first")],
levels = resp_levels
)
return(.get_predicted_out(out, my_args = my_args))
}

# predictions matrix to long format (column-major, i.e. by response level)
out <- .get_predicted_out(probs, my_args = my_args)
ci_data <- data.frame(
Row = out$Row,
Response = out$Response,
SE = as.vector(rez$se.fit),
stringsAsFactors = FALSE
)

# intervals on the logit scale via the delta method, as in
# `ordinal:::predict.clm()`, so that they stay within [0, 1]
if (!is.null(ci) && !is.na(ci)) {
crit_val <- stats::qnorm((1 + ci) / 2)
p <- out$Predicted
se_logit <- ci_data$SE / (p * (1 - p))
ci_data$CI_low <- stats::plogis(stats::qlogis(p) - crit_val * se_logit)
ci_data$CI_high <- stats::plogis(stats::qlogis(p) + crit_val * se_logit)
}

attr(out, "ci_data") <- ci_data
out
}
Loading
Loading