Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Type: Package
Package: modelbased
Title: Estimation of Model-Based Predictions, Contrasts and Means
Version: 0.16.0.10
Version: 0.16.0.11
Authors@R:
c(person(given = "Dominique",
family = "Makowski",
Expand Down
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

## Changes

* For contrasts of models where predictions vary by response category
(e.g., categorical, ordinal/cumulative or multinomial models, as well as
multivariate response models), `estimate_contrasts()` now returns separate
`Response1` and `Response2` columns instead of merging the response
category label into the `Level1` and `Level2` columns (#646).

* The `iterations` argument can now also be used for Bayesian model in
`estimate_means()`, `estimate_slopes()`, and `estimate_contrasts()`, which
is then passed to the `ndraws` argument and controls how many samples are
Expand Down
107 changes: 105 additions & 2 deletions R/format.R
Original file line number Diff line number Diff line change
Expand Up @@ -512,9 +512,23 @@ format.marginaleffects_contrasts <- function(
}
}

# for models where predictions are made for each level of the response
# (e.g., categorical, ordinal/cumulative or multinomial models, as well
# as multivariate response models), marginaleffects merges the response
# level and the contrasted factor level into the same string (e.g.,
# "setosa 3"). We split those into separate "Response1" and "Response2"
# columns, so contrasted factor levels and response levels are not
# confounded
params <- .split_contrast_response_levels(params, model, x)

# we need to update these variables, because these are the new column
# names for contrasts and focal terms
contrast <- c("Level1", "Level2")
# names for contrasts and focal terms. We use `intersect()` here because
# "Response1"/"Response2" are only present if the contrasted factor and
# response category levels were successfully split above
contrast <- intersect(
c("Level1", "Response1", "Level2", "Response2"),
colnames(params)
)
Comment thread
strengejacke marked this conversation as resolved.

# remove old column
x$Parameter <- NULL
Expand Down Expand Up @@ -570,6 +584,95 @@ format.marginaleffects_contrasts <- function(
# variable names to levels. We first find the intersection of all
# levels from all current contrast terms

# for models where predictions are made separately for each level of the
# response (categorical, ordinal/cumulative, multinomial or multivariate
# models), marginaleffects merges the response level and the contrasted
# factor level into the same string, e.g. "setosa 3". This function splits
# "Level1" and "Level2" into "Response1"/"Level1" and "Response2"/"Level2",
# so response levels and contrasted factor levels are not confounded. If
# splitting isn't possible (e.g., not a response-level model, or the response
# levels cannot be unambiguously detected), `params` is returned unchanged.

.split_contrast_response_levels <- function(params, model, x = NULL) {
if (is.null(model) || !all(c("Level1", "Level2") %in% colnames(params))) {
return(params)
}

# prefer the model info already stored as attribute (consistent with how
# other format-methods retrieve model information), and only compute it
# ourselves as a fallback
model_info <- attr(x, "model_info", exact = TRUE)
if (is.null(model_info)) {
model_info <- .safe(insight::model_info(model, verbose = FALSE))
}
is_multivariate <- isTRUE(.safe(insight::is_multivariate(model)))
is_response_model <- isTRUE(model_info$is_multinomial) ||
isTRUE(model_info$is_categorical) ||
isTRUE(model_info$is_ordinal) ||
isTRUE(model_info$is_cumulative) ||
is_multivariate

if (!is_response_model) {
return(params)
}

# response levels, used to detect and split off the response-category
# label from the contrasted factor level. We keep the original order of
# the response levels (e.g., factor level order), so that "Response1" and
# "Response2" use a meaningful, consistent level order
response <- .safe(insight::get_response(model, verbose = FALSE))
response_levels <- if (is.factor(response)) {
levels(response)
} else {
unique(as.character(response))
}
if (is_multivariate) {
response_levels <- unique(c(response_levels, insight::find_response(model)))
}
if (!length(response_levels)) {
return(params)
}

# sort by length, descending, to avoid partial matches for levels that are
# substrings of other levels (used for pattern matching only)
search_levels <- response_levels[order(nchar(response_levels), decreasing = TRUE)]

split1 <- .split_response_prefix(params$Level1, search_levels)
split2 <- .split_response_prefix(params$Level2, search_levels)

# sanity check - only split if we found a response label for *all* rows, in
# both columns, otherwise keep the original (unsplit) columns
if (all(!is.na(split1$Response)) && all(!is.na(split2$Response))) {
level_order <- unique(c(split1$Level, split2$Level))
response_order <- intersect(response_levels, unique(c(split1$Response, split2$Response)))
params$Level1 <- factor(split1$Level, levels = level_order)
params$Response1 <- factor(split1$Response, levels = response_order)
params$Level2 <- factor(split2$Level, levels = level_order)
params$Response2 <- factor(split2$Response, levels = response_order)
}

params
}


# split off a known "response level" prefix from a vector of strings, e.g.
# "setosa 3" -> Response = "setosa", Level = "3"

.split_response_prefix <- function(x, response_levels) {
x_chr <- as.character(x)
out <- data.frame(Response = NA_character_, Level = x_chr, stringsAsFactors = FALSE)
for (i in response_levels) {
pattern <- paste0("^", i, "[[:space:]]+")
matches <- is.na(out$Response) & grepl(pattern, x_chr)
if (any(matches)) {
out$Response[matches] <- i
out$Level[matches] <- insight::trim_ws(sub(pattern, "", x_chr[matches]))
Comment thread
Copilot marked this conversation as resolved.
Outdated
}
}
out
}


.fix_duplicated_contrastlevels <- function(params, contrast_names) {
multiple_levels <- Reduce(
function(i, j) intersect(i, j),
Expand Down
39 changes: 39 additions & 0 deletions tests/testthat/test-estimate_contrasts.R
Original file line number Diff line number Diff line change
Expand Up @@ -1815,3 +1815,42 @@ test_that("estimate_contrast, p-adjust tukey works for contrasting slopes", {
# here to avoid fragile tests while still ensuring close agreement.
expect_equal(out1$p.value, out2$p, tolerance = 1e-2)
})


test_that("estimate_contrast, categorical/multinomial response models split off Response levels", {
skip_if_not_installed("nnet")

data(iris)
m <- nnet::multinom(Species ~ Sepal.Width, data = iris, trace = FALSE)
out <- estimate_contrasts(m, contrast = "Sepal.Width = c(2, 3)", backend = "marginaleffects")

# Level1/Response1/Level2/Response2 columns, in that order
expect_named(
out,
c(
"Level1", "Response1", "Level2", "Response2", "Difference", "SE",
"CI_low", "CI_high", "t", "df", "p"
)
)
# contrasted levels no longer contain the response category label
expect_true(all(as.character(out$Level1) %in% c("2", "3")))
expect_true(all(as.character(out$Level2) %in% c("2", "3")))
expect_true(all(as.character(out$Response1) %in% levels(iris$Species)))
expect_true(all(as.character(out$Response2) %in% levels(iris$Species)))

# contrasts within the same response category should match the difference
# between the corresponding marginal means for that category
means <- estimate_means(m, by = "Sepal.Width = c(2, 3)", backend = "marginaleffects")
for (resp in levels(iris$Species)) {
row <- out[
as.character(out$Level1) == "3" &
as.character(out$Level2) == "2" &
as.character(out$Response1) == resp &
as.character(out$Response2) == resp,
]
expect_identical(nrow(row), 1L)
mean_3 <- means$Probability[means$Sepal.Width == "3" & means$Response == resp]
mean_2 <- means$Probability[means$Sepal.Width == "2" & means$Response == resp]
expect_equal(row$Difference, mean_3 - mean_2, tolerance = 1e-4)
}
})
Loading