From 85f622d3864475fefe596b9c9c35252693cbb194 Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Mon, 19 Oct 2026 11:23:07 -0500 Subject: [PATCH 1/8] Filter ordinal response categories named in a list or data-frame `by` (emmeans backend) --- R/get_emmeans.R | 138 +++++++++++++++++++++---------- tests/testthat/test-ordinal.R | 150 ++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 45 deletions(-) diff --git a/R/get_emmeans.R b/R/get_emmeans.R index 12cb0f3ac..744ceca8c 100644 --- a/R/get_emmeans.R +++ b/R/get_emmeans.R @@ -433,16 +433,27 @@ get_emmeans <- function( # response categories with `mode = "prob"`, so we remove the term from `by`, # and filter the formatted rows on the response categories. Returns `args` # with the term removed from `by`, and with `response_filter`: the name of -# the response, and the chosen categories (`NULL` when the term has no values) +# the response, and the chosen categories (`NULL` when the term has no values). +# `by` can also be a list or a data frame with a column for the response, or a +# formula that names the response (which has no values) .extract_ordinal_response_filter <- function(model, args, data) { - if (!is.character(args$by) || !length(args$by)) { + by <- args$by + if (is.null(by) || !length(by)) { return(args) } response <- insight::find_response(model) if (length(response) != 1) { return(args) } - is_response <- .grep_cleaned_by_vars(args$by) == response + if (is.character(by)) { + is_response <- .grep_cleaned_by_vars(by) == response + } else if (is.list(by)) { + is_response <- names(by) == response + } else if (inherits(by, "formula")) { + is_response <- all.vars(by) == response + } else { + return(args) + } if ( !any(is_response) || !response %in% colnames(data) || @@ -450,61 +461,98 @@ get_emmeans <- function( ) { return(args) } - # terms with values, e.g. "Sat='Low'" or "rating=c(1, 3)". Numeric values - # are matched with the names of the response categories as character. The - # categories of all terms are kept, and a term without values adds none - value_terms <- args$by[is_response & grepl("=", args$by, fixed = TRUE)] + values <- NULL - if (length(value_terms)) { - values <- unique(unlist(lapply(value_terms, function(term) { - grid <- tryCatch( - suppressWarnings(insight::get_datagrid(data[response], by = term)), - error = function(e) NULL - ) - term_values <- if (is.null(grid)) NA else as.character(grid[[response]]) - if (!length(term_values) || anyNA(term_values)) { - insight::format_error(paste0( - "Could not read the response categories in `", - term, - "`. Please name the categories in quotes, e.g. `", - response, - " = c('a', 'b')`." - )) - } - term_values - }))) - # the categories of the model, which can differ from the factor levels in - # the data (e.g. `clm()` drops unused levels) - response_levels <- model$y.levels - if (is.null(response_levels)) { - response_levels <- model$lev - } - if (is.null(response_levels)) { - response_levels <- levels(droplevels(as.factor(data[[response]]))) + if (is.character(by)) { + # terms with values, e.g. "Sat='Low'" or "rating=c(1, 3)". Numeric values + # are matched with the names of the response categories as character. The + # categories of all terms are kept, and a term without values adds none + value_terms <- by[is_response & grepl("=", by, fixed = TRUE)] + if (length(value_terms)) { + values <- unique(unlist(lapply(value_terms, function(term) { + grid <- tryCatch( + suppressWarnings(insight::get_datagrid(data[response], by = term)), + error = function(e) NULL + ) + term_values <- if (is.null(grid)) NA else as.character(grid[[response]]) + if (!length(term_values) || anyNA(term_values)) { + insight::format_error(paste0( + "Could not read the response categories in `", + term, + "`. Please name the categories in quotes, e.g. `", + response, + " = c('a', 'b')`." + )) + } + term_values + }))) } - unknown <- setdiff(values, response_levels) - if (length(unknown)) { + by <- by[!is_response] + } else if (is.list(by)) { + # a list or data frame: the values of the response element or column + values <- unique(as.character(unlist(by[[response]], use.names = FALSE))) + if (!length(values) || anyNA(values)) { insight::format_error(paste0( - "The response `", + "Could not read the response categories in `by`. Please name the categories as character values, e.g. `", response, - "` has no ", - if (length(unknown) == 1) "category " else "categories ", - datawizard::text_concatenate(unknown, enclose = "`"), - ". Its categories are ", - datawizard::text_concatenate(response_levels, enclose = "`"), - "." + " = c('a', 'b')`." )) } + if (is.data.frame(by)) { + by <- unique(by[!is_response]) + rownames(by) <- NULL + if (!ncol(by)) { + by <- NULL + } + } else { + by <- by[!is_response] + } + } else { + # a formula names variables only, so the response adds no filter + others <- all.vars(by)[!is_response] + by <- if (length(others)) stats::reformulate(others) else NULL } - args$by <- args$by[!is_response] - if (!length(args$by)) { - args$by <- NULL + + if (!is.null(values)) { + .check_ordinal_response_values(model, data, response, values) + } + if (!length(by)) { + by <- NULL } + args$by <- by args$response_filter <- list(response = response, values = values) args } +# stops when `values` are not categories of the ordinal response +.check_ordinal_response_values <- function(model, data, response, values) { + # the categories of the model, which can differ from the factor levels in + # the data (e.g. `clm()` drops unused levels) + response_levels <- model$y.levels + if (is.null(response_levels)) { + response_levels <- model$lev + } + if (is.null(response_levels)) { + response_levels <- levels(droplevels(as.factor(data[[response]]))) + } + unknown <- setdiff(values, response_levels) + if (length(unknown)) { + insight::format_error(paste0( + "The response `", + response, + "` has no ", + if (length(unknown) == 1) "category " else "categories ", + datawizard::text_concatenate(unknown, enclose = "`"), + ". Its categories are ", + datawizard::text_concatenate(response_levels, enclose = "`"), + "." + )) + } + invisible(NULL) +} + + # the response categories are only in the output for `predict = "prob"`, so # we cannot use the response in `by` for the other modes. `ordinal_mode` is # the mode the user asked for, not the mode that we use internally for slopes diff --git a/tests/testthat/test-ordinal.R b/tests/testthat/test-ordinal.R index 71b5d20af..02e315db2 100644 --- a/tests/testthat/test-ordinal.R +++ b/tests/testthat/test-ordinal.R @@ -1336,3 +1336,153 @@ test_that("estimate_means, backend emmeans, ordinal, several by terms on the res expect_response_filter_rows(out, out_all, "Low") expect_identical(nrow(out), 3L) }) + + +# `by` as list, data frame or formula, with the response ----------------------- + +ordinal_filter_form_models <- function() { + data(housing, package = "MASS", envir = environment()) + list( + polr = MASS::polr(Sat ~ Infl + Type, weights = Freq, data = housing, Hess = TRUE), + clm = ordinal::clm(Sat ~ Infl + Type, weights = Freq, data = housing) + ) +} + + +ordinal_filter_slope_model <- function() { + data(housing, package = "MASS", envir = environment()) + set.seed(1) + housing$x <- stats::rnorm(nrow(housing)) + MASS::polr(Sat ~ x * Infl + Type, data = housing, Hess = TRUE) +} + + +# the rows of `out` must be the rows of `out_all` with the same values in the +# `keys` columns, with equal numeric columns +expect_matching_rows <- function(out, out_all, keys, columns, info = NULL) { + key <- function(d) do.call(paste, c(lapply(d[keys], as.character), sep = "\r")) + expect_gt(nrow(out), 0) + matched <- match(key(out), key(out_all)) + expect_false(anyNA(matched), info = info) + for (column in columns) { + expect_equal( + out[[column]], + out_all[[column]][matched], + tolerance = 1e-10, + info = paste(info, column) + ) + } +} + + +test_that("estimate_means, backend emmeans, ordinal, list and data frame filter on the response", { + skip_if_not_installed("emmeans") + skip_if_not_installed("ordinal") + models <- ordinal_filter_form_models() + for (name in names(models)) { + m <- models[[name]] + out_all <- estimate_means(m, "Infl", backend = "emmeans") + + out <- estimate_means( + m, + list(Infl = c("Low", "High"), Sat = "Low"), + backend = "emmeans" + ) + expect_identical(nrow(out), 2L, info = name) + expect_identical(as.character(out$Response), c("Low", "Low"), info = name) + expect_identical(as.character(out$Infl), c("Low", "High"), info = name) + expect_matching_rows(out, out_all, c("Infl", "Response"), c("Probability", "SE"), info = name) + + out <- estimate_means(m, data.frame(Infl = "Low", Sat = "Low"), backend = "emmeans") + expect_identical(nrow(out), 1L, info = name) + expect_identical(as.character(out$Response), "Low", info = name) + expect_identical(as.character(out$Infl), "Low", info = name) + expect_matching_rows(out, out_all, c("Infl", "Response"), c("Probability", "SE"), info = name) + } +}) + + +test_that("estimate_contrasts and estimate_slopes, backend emmeans, ordinal, list and data frame filter on the response", { + skip_if_not_installed("emmeans") + skip_if_not_installed("ordinal") + models <- ordinal_filter_form_models() + for (name in names(models)) { + m <- models[[name]] + out_all <- estimate_contrasts(m, "Infl", by = "Sat", backend = "emmeans") + by_forms <- list( + list = list(Sat = c("Low", "High")), + data_frame = data.frame(Sat = c("Low", "High")) + ) + for (form in names(by_forms)) { + out <- estimate_contrasts(m, "Infl", by = by_forms[[form]], backend = "emmeans") + expect_response_filter_rows(out, out_all, c("Low", "High"), info = paste(name, form)) + expect_identical(nrow(out), 6L, info = paste(name, form)) + } + } + + m2 <- ordinal_filter_slope_model() + out_character <- estimate_slopes(m2, "x", by = "Sat='Low'", backend = "emmeans") + out <- estimate_slopes(m2, "x", by = list(Sat = "Low"), backend = "emmeans") + expect_identical(nrow(out), 1L) + expect_identical(as.character(out$Response), "Low") + expect_equal(out$Slope, out_character$Slope, tolerance = 1e-10) + expect_equal(out$SE, out_character$SE, tolerance = 1e-10) +}) + + +test_that("estimate_means, backend emmeans, ordinal, footer for by forms with the response", { + skip_if_not_installed("emmeans") + data(housing, package = "MASS") + m <- MASS::polr(Sat ~ Infl + Type, weights = Freq, data = housing, Hess = TRUE) + footer_lines <- function(out) { + grep("^Predictors modulated", insight::trim_ws(utils::capture.output(print(out))), value = TRUE) + } + expect_identical( + footer_lines(estimate_means(m, c("Infl", "Sat"), backend = "emmeans")), + "Predictors modulated: Infl" + ) + by_forms <- list( + formula = ~ Infl + Sat, + list = list(Infl = c("Low", "High"), Sat = "Low"), + data_frame = data.frame(Infl = "Low", Sat = "Low") + ) + for (form in names(by_forms)) { + out <- estimate_means(m, by_forms[[form]], backend = "emmeans") + expect_identical(footer_lines(out), "Predictors modulated: Infl", info = form) + } + # the formula has no values, so all response categories are kept + out <- estimate_means(m, ~ Infl + Sat, backend = "emmeans") + expect_identical(nrow(out), 9L) +}) + + +test_that("estimate_means and estimate_slopes, backend emmeans, ordinal, errors for list and data frame filters", { + skip_if_not_installed("emmeans") + skip_if_not_installed("ordinal") + error_message <- function(expr) { + err <- expect_error(expr) + gsub("\\s+", " ", conditionMessage(err)) + } + by_forms <- function(value) { + list( + list = list(Infl = "Low", Sat = value), + data_frame = data.frame(Infl = "Low", Sat = value) + ) + } + models <- ordinal_filter_form_models() + m2 <- ordinal_filter_slope_model() + calls <- list( + polr = function(by, ...) estimate_means(models$polr, by, backend = "emmeans", ...), + clm = function(by, ...) estimate_means(models$clm, by, backend = "emmeans", ...), + slopes = function(by, ...) estimate_slopes(m2, "x", by = by, backend = "emmeans", ...) + ) + for (call in names(calls)) { + for (form in c("list", "data_frame")) { + info <- paste(call, form) + msg <- error_message(calls[[call]](by_forms("Bogus")[[form]])) + expect_match(msg, "The response `Sat` has no category `Bogus`.", fixed = TRUE, info = info) + msg <- error_message(calls[[call]](by_forms("Low")[[form]], predict = "cum.prob")) + expect_match(msg, "The response `Sat` can only be used in `by`", fixed = TRUE, info = info) + } + } +}) From 2334e257f7c3c2d86763267007794f9278271c34 Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Mon, 19 Oct 2026 11:24:52 -0500 Subject: [PATCH 2/8] Contrast only the ordinal response categories named in `contrast` (emmeans backend) --- R/get_emcontrasts.R | 8 ++ R/get_emmeans.R | 137 ++++++++++++++++++++++++---------- tests/testthat/test-ordinal.R | 79 ++++++++++++++++++++ 3 files changed, 185 insertions(+), 39 deletions(-) diff --git a/R/get_emcontrasts.R b/R/get_emcontrasts.R index 4dc665a17..c511e0f57 100644 --- a/R/get_emcontrasts.R +++ b/R/get_emcontrasts.R @@ -71,6 +71,7 @@ get_emcontrasts <- function( fun_args$mode <- ordinal$mode } .check_ordinal_response_filter(my_args$response_filter, ordinal$mode) + .check_ordinal_response_filter(my_args$contrast_filter, ordinal$mode, "contrast") } # add dots @@ -137,6 +138,13 @@ get_emcontrasts <- function( specs <- .add_emmeans_pseudo_factor(my_args$emmeans_specs, ordinal$pseudo_factor) fun_args <- c(fun_args, list(specs = specs, at = my_args$emmeans_at)) estimated <- suppressMessages(do.call(emmeans::emmeans, fun_args)) + # ordinal models: a `contrast` term on the response with values keeps only + # those response categories, before they are contrasted + estimated <- .filter_ordinal_contrast_grid( + estimated, + ordinal$pseudo_factor, + my_args$contrast_filter + ) # Find by variables emm_by <- specs[!specs %in% my_args$contrast] if (length(emm_by) == 0) { diff --git a/R/get_emmeans.R b/R/get_emmeans.R index 744ceca8c..15cb7cc4a 100644 --- a/R/get_emmeans.R +++ b/R/get_emmeans.R @@ -435,57 +435,91 @@ get_emmeans <- function( # with the term removed from `by`, and with `response_filter`: the name of # the response, and the chosen categories (`NULL` when the term has no values). # `by` can also be a list or a data frame with a column for the response, or a -# formula that names the response (which has no values) +# formula that names the response (which has no values). A `contrast` term on +# the response with values (e.g. `"Sat=c('Low','High')"`) gives +# `contrast_filter`, and the term is replaced by the name of the response .extract_ordinal_response_filter <- function(model, args, data) { - by <- args$by - if (is.null(by) || !length(by)) { - return(args) - } response <- insight::find_response(model) if (length(response) != 1) { return(args) } - if (is.character(by)) { - is_response <- .grep_cleaned_by_vars(by) == response - } else if (is.list(by)) { - is_response <- names(by) == response - } else if (inherits(by, "formula")) { - is_response <- all.vars(by) == response - } else { - return(args) - } + is_response_by <- .is_ordinal_response_term(args$by, response) + is_response_contrast <- .is_ordinal_response_term(args$contrast, response) if ( - !any(is_response) || + (!any(is_response_by) && !any(is_response_contrast)) || !response %in% colnames(data) || !.is_emmeans_ordinal(model) ) { return(args) } + if (any(is_response_by)) { + args <- .extract_ordinal_response_by(model, args, data, response, is_response_by) + } + if (any(is_response_contrast)) { + # only terms with values are a filter, e.g. `"Sat=c('Low','High')"` + contrast <- args$contrast + value_terms <- contrast[is_response_contrast & grepl("=", contrast, fixed = TRUE)] + if (length(value_terms)) { + values <- .read_ordinal_response_terms(value_terms, data, response) + .check_ordinal_response_values(model, data, response, values) + contrast[is_response_contrast] <- response + args$contrast <- unique(contrast) + args$contrast_filter <- list(response = response, values = values) + } + } + args +} + +# which elements of `x` (a `by` or `contrast` argument) name the response +.is_ordinal_response_term <- function(x, response) { + if (is.null(x) || !length(x)) { + logical(0) + } else if (is.character(x)) { + .grep_cleaned_by_vars(x) == response + } else if (is.list(x)) { + names(x) == response + } else if (inherits(x, "formula")) { + all.vars(x) == response + } else { + logical(0) + } +} + + +# reads the response categories of terms such as "Sat='Low'" or +# "rating=c(1, 3)". Numeric values are matched with the names of the response +# categories as character. The categories of all terms are kept +.read_ordinal_response_terms <- function(terms, data, response) { + unique(unlist(lapply(terms, function(term) { + grid <- tryCatch( + suppressWarnings(insight::get_datagrid(data[response], by = term)), + error = function(e) NULL + ) + term_values <- if (is.null(grid)) NA else as.character(grid[[response]]) + if (!length(term_values) || anyNA(term_values)) { + insight::format_error(paste0( + "Could not read the response categories in `", + term, + "`. Please name the categories in quotes, e.g. `", + response, + " = c('a', 'b')`." + )) + } + term_values + }))) +} + + +# removes the response from `by`, and adds `response_filter` to `args` +.extract_ordinal_response_by <- function(model, args, data, response, is_response) { + by <- args$by values <- NULL if (is.character(by)) { - # terms with values, e.g. "Sat='Low'" or "rating=c(1, 3)". Numeric values - # are matched with the names of the response categories as character. The - # categories of all terms are kept, and a term without values adds none + # a term without values adds no categories value_terms <- by[is_response & grepl("=", by, fixed = TRUE)] if (length(value_terms)) { - values <- unique(unlist(lapply(value_terms, function(term) { - grid <- tryCatch( - suppressWarnings(insight::get_datagrid(data[response], by = term)), - error = function(e) NULL - ) - term_values <- if (is.null(grid)) NA else as.character(grid[[response]]) - if (!length(term_values) || anyNA(term_values)) { - insight::format_error(paste0( - "Could not read the response categories in `", - term, - "`. Please name the categories in quotes, e.g. `", - response, - " = c('a', 'b')`." - )) - } - term_values - }))) + values <- .read_ordinal_response_terms(value_terms, data, response) } by <- by[!is_response] } else if (is.list(by)) { @@ -554,16 +588,41 @@ get_emmeans <- function( # the response categories are only in the output for `predict = "prob"`, so -# we cannot use the response in `by` for the other modes. `ordinal_mode` is -# the mode the user asked for, not the mode that we use internally for slopes -.check_ordinal_response_filter <- function(response_filter, ordinal_mode) { +# we cannot use the response in `by` (or `contrast`) for the other modes. +# `ordinal_mode` is the mode the user asked for, not the mode that we use +# internally for slopes +.check_ordinal_response_filter <- function(response_filter, ordinal_mode, argument = "by") { if (!is.null(response_filter) && !identical(ordinal_mode, "prob")) { insight::format_error(paste0( "The response `", response_filter$response, - "` can only be used in `by` with `predict = \"prob\"` (the default), which returns the probability of each response category." + "` can only be used in `", + argument, + "` with `predict = \"prob\"` (the default), which returns the probability of each response category." + )) + } +} + + +# keeps the response categories of `contrast_filter` in the emmeans grid +# `estimated`, so that only these categories are contrasted. `pseudo_factor` +# is the grid column with the response categories +.filter_ordinal_contrast_grid <- function(estimated, pseudo_factor, contrast_filter) { + values <- contrast_filter$values + if (is.null(values) || is.null(pseudo_factor)) { + return(estimated) + } + if (length(values) < 2) { + insight::format_error(paste0( + "Contrasts of the response `", + contrast_filter$response, + "` need at least two response categories. Please name two or more categories in `contrast`, e.g. `", + contrast_filter$response, + " = c('a', 'b')`." )) } + keep <- as.character(estimated@grid[[pseudo_factor]]) %in% values + estimated[which(keep)] } diff --git a/tests/testthat/test-ordinal.R b/tests/testthat/test-ordinal.R index 02e315db2..ae7a740c9 100644 --- a/tests/testthat/test-ordinal.R +++ b/tests/testthat/test-ordinal.R @@ -1486,3 +1486,82 @@ test_that("estimate_means and estimate_slopes, backend emmeans, ordinal, errors } } }) + + +test_that("estimate_contrasts, backend emmeans, ordinal, contrast filter on the response", { + skip_if_not_installed("emmeans") + skip_if_not_installed("ordinal") + models <- ordinal_filter_form_models() + for (name in names(models)) { + m <- models[[name]] + out_all <- estimate_contrasts(m, "Sat", by = "Infl", backend = "emmeans") + + # only the contrast of the two named categories, in each level of `Infl` + out <- estimate_contrasts(m, "Sat=c('Low','High')", by = "Infl", backend = "emmeans") + expect_identical(nrow(out), 3L, info = name) + expect_identical(as.character(out$Level1), rep("High", 3), info = name) + expect_identical(as.character(out$Level2), rep("Low", 3), info = name) + expect_identical(as.character(out$Infl), c("Low", "Medium", "High"), info = name) + expect_matching_rows( + out, + out_all, + c("Level1", "Level2", "Infl"), + c("Difference", "SE"), + info = name + ) + + # comparisons that are not pairwise use only the named categories + out <- estimate_contrasts( + m, + "Sat=c('Low','Medium')", + by = "Infl", + comparison = "poly", + backend = "emmeans" + ) + expect_identical(nrow(out), 3L, info = name) + expect_identical(as.character(out$Level), rep("linear", 3), info = name) + pairwise <- out_all[out_all$Level1 == "Medium" & out_all$Level2 == "Low", ] + expect_equal( + out$Difference, + pairwise$Difference[match(out$Infl, pairwise$Infl)], + tolerance = 1e-10, + info = name + ) + + # one category cannot be contrasted + err <- expect_error(estimate_contrasts(m, "Sat='Low'", by = "Infl", backend = "emmeans")) + expect_match( + gsub("\\s+", " ", conditionMessage(err)), + "Contrasts of the response `Sat` need at least two response categories.", + fixed = TRUE, + info = name + ) + + # values that are no response categories, and modes without categories + err <- expect_error(estimate_contrasts( + m, + "Sat=c('Low','Bogus')", + by = "Infl", + backend = "emmeans" + )) + expect_match( + gsub("\\s+", " ", conditionMessage(err)), + "The response `Sat` has no category `Bogus`.", + fixed = TRUE, + info = name + ) + err <- expect_error(estimate_contrasts( + m, + "Sat=c('Low','High')", + by = "Infl", + predict = "cum.prob", + backend = "emmeans" + )) + expect_match( + gsub("\\s+", " ", conditionMessage(err)), + "The response `Sat` can only be used in `contrast` with `predict = \"prob\"`", + fixed = TRUE, + info = name + ) + } +}) From bc1f491bfb000110465bed3cb33cfc852e5da2c2 Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Mon, 19 Oct 2026 11:25:47 -0500 Subject: [PATCH 3/8] Stop when a `by` filter leaves one slope per response category in ordinal slope contrasts (emmeans backend) --- R/get_emcontrasts.R | 9 ++++++++- R/get_marginalcontrasts.R | 20 +++++++++++++++++--- tests/testthat/test-ordinal.R | 28 ++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/R/get_emcontrasts.R b/R/get_emcontrasts.R index c511e0f57..534bbfaa2 100644 --- a/R/get_emcontrasts.R +++ b/R/get_emcontrasts.R @@ -111,7 +111,14 @@ get_emcontrasts <- function( (is.character(comparison) && !.is_custom_comparison(comparison)) || inherits(comparison, "formula") ) { - .check_slope_contrast_filter(model, my_args$by, slope_filter) + # ordinal models: the slopes are contrasted within each response + # category or threshold + .check_slope_contrast_filter( + model, + my_args$by, + slope_filter, + within_categories = !is.null(ordinal$mode) + ) } # Run emmeans. Filters in `by` go into `at`, so only those slopes are # contrasted diff --git a/R/get_marginalcontrasts.R b/R/get_marginalcontrasts.R index ffb0a3770..236612d85 100644 --- a/R/get_marginalcontrasts.R +++ b/R/get_marginalcontrasts.R @@ -356,15 +356,29 @@ get_marginalcontrasts <- function( } -# stop when the filters in `by` leave fewer than two slopes to compare -.check_slope_contrast_filter <- function(model, by, by_filter, estimate = NULL) { +# stop when the filters in `by` leave fewer than two slopes to compare. +# `within_categories` is `TRUE` when the slopes of ordinal models are +# contrasted within each response category or threshold (emmeans backend), so +# that the slopes to compare are counted from `by` alone +.check_slope_contrast_filter <- function( + model, + by, + by_filter, + estimate = NULL, + within_categories = FALSE +) { if (!length(by_filter) || !length(by)) { return(invisible(NULL)) } # models with response categories have one slope per category, so we # cannot count the slopes from `by` alone model_info <- insight::model_info(model, response = 1, verbose = FALSE) - if (isTRUE(model_info$is_ordinal) || isTRUE(model_info$is_multinomial) || isTRUE(model_info$is_categorical)) { + if ( + !within_categories && + (isTRUE(model_info$is_ordinal) || + isTRUE(model_info$is_multinomial) || + isTRUE(model_info$is_categorical)) + ) { return(invisible(NULL)) } model_data <- insight::get_data(model, verbose = FALSE) diff --git a/tests/testthat/test-ordinal.R b/tests/testthat/test-ordinal.R index ae7a740c9..271e99df4 100644 --- a/tests/testthat/test-ordinal.R +++ b/tests/testthat/test-ordinal.R @@ -1565,3 +1565,31 @@ test_that("estimate_contrasts, backend emmeans, ordinal, contrast filter on the ) } }) + + +test_that("estimate_contrasts, backend emmeans, ordinal, slopes with a by filter that keeps one level", { + skip_if_not_installed("emmeans") + m2 <- ordinal_filter_slope_model() + + # slopes are contrasted within each response category, so one level of + # `Infl` leaves nothing to compare + err <- expect_error(estimate_contrasts(m2, "x", by = "Infl='Low'", backend = "emmeans")) + expect_match( + gsub("\\s+", " ", conditionMessage(err)), + "The filter in `by` leaves fewer than two slopes to compare", + fixed = TRUE + ) + + out_all <- estimate_contrasts(m2, "x", by = "Infl", backend = "emmeans") + out <- estimate_contrasts(m2, "x", by = "Infl=c('Low','High')", backend = "emmeans") + expect_identical(nrow(out), 3L) + expect_identical(as.character(out$Level1), rep("High", 3)) + expect_identical(as.character(out$Level2), rep("Low", 3)) + expect_identical(as.character(out$Response), c("Low", "Medium", "High")) + expect_matching_rows( + out, + out_all, + c("Level1", "Level2", "Response"), + c("Difference", "SE") + ) +}) From 119158003995899e6566bfd20d519fe7391e0e14 Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Mon, 19 Oct 2026 11:29:52 -0500 Subject: [PATCH 4/8] Document ordinal response filters in list, data-frame and `contrast` terms; NEWS --- DESCRIPTION | 2 +- NEWS.md | 19 +++++++++++++++++++ R/estimate_contrasts.R | 6 +++++- R/estimate_means.R | 9 +++++---- man/estimate_contrasts.Rd | 15 ++++++++++----- man/estimate_means.Rd | 9 +++++---- man/estimate_slopes.Rd | 9 +++++---- man/get_emmeans.Rd | 15 ++++++++++----- 8 files changed, 60 insertions(+), 24 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index b1c5da55f..72c0fb47f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Type: Package Package: modelbased Title: Estimation of Model-Based Predictions, Contrasts and Means -Version: 0.18.0.2 +Version: 0.18.0.3 Authors@R: c(person(given = "Dominique", family = "Makowski", diff --git a/NEWS.md b/NEWS.md index cdd4a67c5..be652b871 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,25 @@ ## Bug fixes +* For frequentist ordinal models with `backend = "emmeans"`, + `estimate_means()`, `estimate_contrasts()` and `estimate_slopes()` now also + filter the response categories in a list or data-frame `by`, for example + `by = list(Infl = "Low", Sat = "Low")`, where `Sat` is the response. + Before, they returned all response categories. With a list, a data frame or + a formula such as `by = ~ Infl + Sat`, the printed output no longer lists + the response under "Predictors modulated". + +* `estimate_contrasts()` with `backend = "emmeans"` now contrasts only the + named response categories of frequentist ordinal models, for example + `estimate_contrasts(m, "Sat = c('Low', 'High')", by = "Infl")`. Before, it + ignored the values and contrasted all categories. A single category gives + an error. + +* For contrasts of slopes of ordinal models with `backend = "emmeans"`, a + filter in `by` that leaves one level to compare, for example + `by = "Infl = 'Low'"`, now gives a clear error. Before, + `estimate_contrasts()` returned a table with empty contrasts. + * `estimate_contrasts()` now applies the filter values in `by` to contrasts of slopes with `estimate = "average"` and with `backend = "emmeans"`, for example `estimate_contrasts(m, "wt", by = c("cyl = '4'", "am"))`, where diff --git a/R/estimate_contrasts.R b/R/estimate_contrasts.R index 836c2fbda..50427fecb 100644 --- a/R/estimate_contrasts.R +++ b/R/estimate_contrasts.R @@ -15,7 +15,11 @@ #' For contrasting slopes, the `comparison` will always be `"pairwise"`. It is #' possible to compute pairwise comparisons of two average slopes at the levels #' of a third variable, by also adding that variable to the `contrast` argument, -#' e.g. `contrast = c("num1", "num2", "factor")`. See 'Examples'. +#' e.g. `contrast = c("num1", "num2", "factor")`. See 'Examples'. For +#' frequentist ordinal models and `backend = "emmeans"`, `contrast` can name the +#' response with two or more categories, for example +#' `contrast = "y = c('low', 'high')"`, to contrast only these response +#' categories (with `predict = "prob"`, the default). #' @param p_adjust The p-values adjustment method for frequentist multiple #' comparisons. Can be one of `"none"` (default), `"hochberg"`, `"hommel"`, #' `"bonferroni"`, `"BH"`, `"BY"`, `"fdr"`, `"tukey"`, `"sidak"`, `"sup-t"`, diff --git a/R/estimate_means.R b/R/estimate_means.R index 73010d172..dc126e3d0 100644 --- a/R/estimate_means.R +++ b/R/estimate_means.R @@ -22,10 +22,11 @@ #' `by = NULL`, the grand mean of the response is predicted. For frequentist #' ordinal models (for example from `MASS::polr()`, `ordinal::clm()` or #' `ordinal::clmm()`), `by` can also name the response with values, for example -#' `by = c("x", "y = c('low', 'high')")`. `estimate_means()`, -#' `estimate_contrasts()` and `estimate_slopes()` then keep only the rows of -#' these response categories. This filter works with `backend = "emmeans"` and -#' `predict = "prob"` (the default). +#' `by = c("x", "y = c('low', 'high')")`, or as an element of a list or a +#' column of a data frame, for example `by = list(x = c(1, 2), y = "low")`. +#' `estimate_means()`, `estimate_contrasts()` and `estimate_slopes()` then keep +#' only the rows of these response categories. This filter works with +#' `backend = "emmeans"` and `predict = "prob"` (the default). #' @param predict Is passed to the `type` argument in `emmeans::emmeans()` (when #' `backend = "emmeans"`) or in `marginaleffects::avg_predictions()` (when #' `backend = "marginaleffects"`). Valid options for `predict` are: diff --git a/man/estimate_contrasts.Rd b/man/estimate_contrasts.Rd index 0ed291730..1dbfbfd34 100644 --- a/man/estimate_contrasts.Rd +++ b/man/estimate_contrasts.Rd @@ -78,7 +78,11 @@ it is not possible to "filter" at certain values for two numeric predictors. For contrasting slopes, the \code{comparison} will always be \code{"pairwise"}. It is possible to compute pairwise comparisons of two average slopes at the levels of a third variable, by also adding that variable to the \code{contrast} argument, -e.g. \code{contrast = c("num1", "num2", "factor")}. See 'Examples'.} +e.g. \code{contrast = c("num1", "num2", "factor")}. See 'Examples'. For +frequentist ordinal models and \code{backend = "emmeans"}, \code{contrast} can name the +response with two or more categories, for example +\code{contrast = "y = c('low', 'high')"}, to contrast only these response +categories (with \code{predict = "prob"}, the default).} \item{by}{The (focal) predictor variable(s) at which to evaluate the desired effect / mean / contrasts. Other predictors of the model that are not @@ -95,10 +99,11 @@ which automatically selects the first focal predictor found in the model. If \code{by = NULL}, the grand mean of the response is predicted. For frequentist ordinal models (for example from \code{MASS::polr()}, \code{ordinal::clm()} or \code{ordinal::clmm()}), \code{by} can also name the response with values, for example -\code{by = c("x", "y = c('low', 'high')")}. \code{estimate_means()}, -\code{estimate_contrasts()} and \code{estimate_slopes()} then keep only the rows of -these response categories. This filter works with \code{backend = "emmeans"} and -\code{predict = "prob"} (the default).} +\code{by = c("x", "y = c('low', 'high')")}, or as an element of a list or a +column of a data frame, for example \code{by = list(x = c(1, 2), y = "low")}. +\code{estimate_means()}, \code{estimate_contrasts()} and \code{estimate_slopes()} then keep +only the rows of these response categories. This filter works with +\code{backend = "emmeans"} and \code{predict = "prob"} (the default).} \item{predict}{Is passed to the \code{type} argument in \code{emmeans::emmeans()} (when \code{backend = "emmeans"}) or in \code{marginaleffects::avg_predictions()} (when diff --git a/man/estimate_means.Rd b/man/estimate_means.Rd index 86da04dcb..22315a89a 100644 --- a/man/estimate_means.Rd +++ b/man/estimate_means.Rd @@ -35,10 +35,11 @@ which automatically selects the first focal predictor found in the model. If \code{by = NULL}, the grand mean of the response is predicted. For frequentist ordinal models (for example from \code{MASS::polr()}, \code{ordinal::clm()} or \code{ordinal::clmm()}), \code{by} can also name the response with values, for example -\code{by = c("x", "y = c('low', 'high')")}. \code{estimate_means()}, -\code{estimate_contrasts()} and \code{estimate_slopes()} then keep only the rows of -these response categories. This filter works with \code{backend = "emmeans"} and -\code{predict = "prob"} (the default).} +\code{by = c("x", "y = c('low', 'high')")}, or as an element of a list or a +column of a data frame, for example \code{by = list(x = c(1, 2), y = "low")}. +\code{estimate_means()}, \code{estimate_contrasts()} and \code{estimate_slopes()} then keep +only the rows of these response categories. This filter works with +\code{backend = "emmeans"} and \code{predict = "prob"} (the default).} \item{predict}{Is passed to the \code{type} argument in \code{emmeans::emmeans()} (when \code{backend = "emmeans"}) or in \code{marginaleffects::avg_predictions()} (when diff --git a/man/estimate_slopes.Rd b/man/estimate_slopes.Rd index 18c585d21..695c1e27d 100644 --- a/man/estimate_slopes.Rd +++ b/man/estimate_slopes.Rd @@ -49,10 +49,11 @@ which automatically selects the first focal predictor found in the model. If \code{by = NULL}, the grand mean of the response is predicted. For frequentist ordinal models (for example from \code{MASS::polr()}, \code{ordinal::clm()} or \code{ordinal::clmm()}), \code{by} can also name the response with values, for example -\code{by = c("x", "y = c('low', 'high')")}. \code{estimate_means()}, -\code{estimate_contrasts()} and \code{estimate_slopes()} then keep only the rows of -these response categories. This filter works with \code{backend = "emmeans"} and -\code{predict = "prob"} (the default).} +\code{by = c("x", "y = c('low', 'high')")}, or as an element of a list or a +column of a data frame, for example \code{by = list(x = c(1, 2), y = "low")}. +\code{estimate_means()}, \code{estimate_contrasts()} and \code{estimate_slopes()} then keep +only the rows of these response categories. This filter works with +\code{backend = "emmeans"} and \code{predict = "prob"} (the default).} \item{predict}{Is passed to the \code{type} argument in \code{emmeans::emmeans()} (when \code{backend = "emmeans"}) or in \code{marginaleffects::avg_predictions()} (when diff --git a/man/get_emmeans.Rd b/man/get_emmeans.Rd index 7fe429137..226ff1161 100644 --- a/man/get_emmeans.Rd +++ b/man/get_emmeans.Rd @@ -99,7 +99,11 @@ it is not possible to "filter" at certain values for two numeric predictors. For contrasting slopes, the \code{comparison} will always be \code{"pairwise"}. It is possible to compute pairwise comparisons of two average slopes at the levels of a third variable, by also adding that variable to the \code{contrast} argument, -e.g. \code{contrast = c("num1", "num2", "factor")}. See 'Examples'.} +e.g. \code{contrast = c("num1", "num2", "factor")}. See 'Examples'. For +frequentist ordinal models and \code{backend = "emmeans"}, \code{contrast} can name the +response with two or more categories, for example +\code{contrast = "y = c('low', 'high')"}, to contrast only these response +categories (with \code{predict = "prob"}, the default).} \item{by}{The (focal) predictor variable(s) at which to evaluate the desired effect / mean / contrasts. Other predictors of the model that are not @@ -116,10 +120,11 @@ which automatically selects the first focal predictor found in the model. If \code{by = NULL}, the grand mean of the response is predicted. For frequentist ordinal models (for example from \code{MASS::polr()}, \code{ordinal::clm()} or \code{ordinal::clmm()}), \code{by} can also name the response with values, for example -\code{by = c("x", "y = c('low', 'high')")}. \code{estimate_means()}, -\code{estimate_contrasts()} and \code{estimate_slopes()} then keep only the rows of -these response categories. This filter works with \code{backend = "emmeans"} and -\code{predict = "prob"} (the default).} +\code{by = c("x", "y = c('low', 'high')")}, or as an element of a list or a +column of a data frame, for example \code{by = list(x = c(1, 2), y = "low")}. +\code{estimate_means()}, \code{estimate_contrasts()} and \code{estimate_slopes()} then keep +only the rows of these response categories. This filter works with +\code{backend = "emmeans"} and \code{predict = "prob"} (the default).} \item{predict}{Is passed to the \code{type} argument in \code{emmeans::emmeans()} (when \code{backend = "emmeans"}) or in \code{marginaleffects::avg_predictions()} (when From 92c18fe7c812e2749aaf14a4e817c4d7a8c590aa Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Mon, 19 Oct 2026 11:31:53 -0500 Subject: [PATCH 5/8] Correct NEWS and comments on ordinal slope contrasts --- NEWS.md | 3 ++- R/get_emcontrasts.R | 3 ++- R/get_marginalcontrasts.R | 7 ++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index be652b871..7078559c2 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,7 +6,8 @@ `estimate_means()`, `estimate_contrasts()` and `estimate_slopes()` now also filter the response categories in a list or data-frame `by`, for example `by = list(Infl = "Low", Sat = "Low")`, where `Sat` is the response. - Before, they returned all response categories. With a list, a data frame or + Before, `estimate_means()` and `estimate_contrasts()` returned all response + categories, and `estimate_slopes()` gave an error. With a list, a data frame or a formula such as `by = ~ Infl + Sat`, the printed output no longer lists the response under "Predictors modulated". diff --git a/R/get_emcontrasts.R b/R/get_emcontrasts.R index 534bbfaa2..4640686a9 100644 --- a/R/get_emcontrasts.R +++ b/R/get_emcontrasts.R @@ -112,7 +112,8 @@ get_emcontrasts <- function( inherits(comparison, "formula") ) { # ordinal models: the slopes are contrasted within each response - # category or threshold + # category or threshold (or, for `mean.class`, once per level of `by`), + # so the slopes to compare are counted from `by` .check_slope_contrast_filter( model, my_args$by, diff --git a/R/get_marginalcontrasts.R b/R/get_marginalcontrasts.R index 236612d85..344db8b25 100644 --- a/R/get_marginalcontrasts.R +++ b/R/get_marginalcontrasts.R @@ -357,9 +357,10 @@ get_marginalcontrasts <- function( # stop when the filters in `by` leave fewer than two slopes to compare. -# `within_categories` is `TRUE` when the slopes of ordinal models are -# contrasted within each response category or threshold (emmeans backend), so -# that the slopes to compare are counted from `by` alone +# `within_categories` is `TRUE` for ordinal models with the emmeans backend, +# where slopes are contrasted within each response category or threshold (or +# across `by` alone for `mean.class`), so the slopes to compare are counted +# from `by` alone .check_slope_contrast_filter <- function( model, by, From c346d80ab5d20c9bd255d4ae63c889a2f8602712 Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Mon, 19 Oct 2026 11:40:36 -0500 Subject: [PATCH 6/8] Fix the response filter in `contrast` for clmm models and joint tests; one NEWS entry --- NEWS.md | 43 +++++++++++----------- R/get_emcontrasts.R | 5 +-- R/get_emmeans.R | 67 ++++++++++++++++++++++++----------- R/get_jointtest.R | 16 ++++++++- R/get_marginalcontrasts.R | 18 +++++----- tests/testthat/test-ordinal.R | 58 ++++++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 53 deletions(-) diff --git a/NEWS.md b/NEWS.md index 7078559c2..c5db5e822 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,25 +2,23 @@ ## Bug fixes -* For frequentist ordinal models with `backend = "emmeans"`, - `estimate_means()`, `estimate_contrasts()` and `estimate_slopes()` now also - filter the response categories in a list or data-frame `by`, for example - `by = list(Infl = "Low", Sat = "Low")`, where `Sat` is the response. - Before, `estimate_means()` and `estimate_contrasts()` returned all response - categories, and `estimate_slopes()` gave an error. With a list, a data frame or - a formula such as `by = ~ Infl + Sat`, the printed output no longer lists - the response under "Predictors modulated". - -* `estimate_contrasts()` with `backend = "emmeans"` now contrasts only the - named response categories of frequentist ordinal models, for example - `estimate_contrasts(m, "Sat = c('Low', 'High')", by = "Infl")`. Before, it - ignored the values and contrasted all categories. A single category gives - an error. - -* For contrasts of slopes of ordinal models with `backend = "emmeans"`, a - filter in `by` that leaves one level to compare, for example - `by = "Infl = 'Low'"`, now gives a clear error. Before, - `estimate_contrasts()` returned a table with empty contrasts. +* Filters on the response categories of frequentist ordinal models with + `backend = "emmeans"`, where `Sat` is the response: + * `estimate_means()`, `estimate_contrasts()` and `estimate_slopes()` now + also filter the response categories in a list or data-frame `by`, for + example `by = list(Infl = "Low", Sat = "Low")`. Before, + `estimate_means()` and `estimate_contrasts()` returned all response + categories, and `estimate_slopes()` gave an error. With a list, a data + frame or a formula such as `by = ~ Infl + Sat`, the printed output no + longer lists the response under "Predictors modulated". + * `estimate_contrasts()` now contrasts only the named response categories, + for example `estimate_contrasts(m, "Sat = c('Low', 'High')", by = "Infl")`. + Before, it ignored the values and contrasted all categories. A single + category, or a filter on the response in both `contrast` and `by`, gives + an error. + * For contrasts of slopes, a filter in `by` that leaves one level to + compare, for example `by = "Infl = 'Low'"`, now gives a clear error. + Before, `estimate_contrasts()` returned a table with empty contrasts. * `estimate_contrasts()` now applies the filter values in `by` to contrasts of slopes with `estimate = "average"` and with `backend = "emmeans"`, for @@ -29,9 +27,10 @@ all levels and ignored the filter. With `backend = "emmeans"`, the filter applies only to `by` variables that are all factors or character vectors. If the filter leaves fewer than two slopes to compare, - `estimate_contrasts()` now stops with a clear error. Ordinal and - multinomial models, and a custom hypothesis in `comparison` (such as - `"b1 = b2"`, a function or a matrix), get no such check. + `estimate_contrasts()` now stops with a clear error. Multinomial models, + ordinal models with `backend = "marginaleffects"`, and a custom hypothesis + in `comparison` (such as `"b1 = b2"`, a function or a matrix), get no such + check. * `p_adjust = "tukey"` and `p_adjust = "sidak"` now give the same p-values as *emmeans* in `estimate_contrasts()` and `estimate_slopes()` with diff --git a/R/get_emcontrasts.R b/R/get_emcontrasts.R index 4640686a9..1a693308e 100644 --- a/R/get_emcontrasts.R +++ b/R/get_emcontrasts.R @@ -71,7 +71,7 @@ get_emcontrasts <- function( fun_args$mode <- ordinal$mode } .check_ordinal_response_filter(my_args$response_filter, ordinal$mode) - .check_ordinal_response_filter(my_args$contrast_filter, ordinal$mode, "contrast") + .check_ordinal_response_filter(my_args$response_contrast_filter, ordinal$mode, "contrast") } # add dots @@ -150,8 +150,9 @@ get_emcontrasts <- function( # those response categories, before they are contrasted estimated <- .filter_ordinal_contrast_grid( estimated, + model, ordinal$pseudo_factor, - my_args$contrast_filter + my_args$response_contrast_filter ) # Find by variables emm_by <- specs[!specs %in% my_args$contrast] diff --git a/R/get_emmeans.R b/R/get_emmeans.R index 15cb7cc4a..efa35aefb 100644 --- a/R/get_emmeans.R +++ b/R/get_emmeans.R @@ -428,16 +428,17 @@ get_emmeans <- function( } } -# For ordinal models, a `by` term can name the response, to keep only some -# response categories (e.g. `"Sat='Low'"`). emmeans ignores `at` for the -# response categories with `mode = "prob"`, so we remove the term from `by`, -# and filter the formatted rows on the response categories. Returns `args` -# with the term removed from `by`, and with `response_filter`: the name of -# the response, and the chosen categories (`NULL` when the term has no values). -# `by` can also be a list or a data frame with a column for the response, or a -# formula that names the response (which has no values). A `contrast` term on -# the response with values (e.g. `"Sat=c('Low','High')"`) gives -# `contrast_filter`, and the term is replaced by the name of the response +# For ordinal models, a `by` or `contrast` term can name the response, to keep +# only some response categories (e.g. `"Sat='Low'"`). emmeans ignores `at` for +# the response categories with `mode = "prob"`, so we remove the term from +# `by`, and filter the formatted rows on the response categories. Returns +# `args` with the term removed from `by`, and with `response_filter`: the name +# of the response, and the chosen categories (`NULL` when the term has no +# values). `by` can also be a list or a data frame with a column for the +# response, or a formula that names the response (which has no values). A +# `contrast` term on the response with values (e.g. `"Sat=c('Low','High')"`) +# gives `response_contrast_filter`, and the term is replaced by the name of +# the response .extract_ordinal_response_filter <- function(model, args, data) { response <- insight::find_response(model) if (length(response) != 1) { @@ -456,6 +457,17 @@ get_emmeans <- function( args <- .extract_ordinal_response_by(model, args, data, response, is_response_by) } if (any(is_response_contrast)) { + # when the response categories are contrasted, the output has no column + # with the response categories, so a filter in `by` cannot be applied + if (!is.null(args$response_filter$values)) { + insight::format_error(paste0( + "The response `", + response, + "` cannot be filtered in `by` when it is also in `contrast`. Please name the response categories in `contrast` only, e.g. `contrast = \"", + response, + " = c('a', 'b')\"`." + )) + } # only terms with values are a filter, e.g. `"Sat=c('Low','High')"` contrast <- args$contrast value_terms <- contrast[is_response_contrast & grepl("=", contrast, fixed = TRUE)] @@ -464,7 +476,7 @@ get_emmeans <- function( .check_ordinal_response_values(model, data, response, values) contrast[is_response_contrast] <- response args$contrast <- unique(contrast) - args$contrast_filter <- list(response = response, values = values) + args$response_contrast_filter <- list(response = response, values = values) } } args @@ -604,25 +616,40 @@ get_emmeans <- function( } -# keeps the response categories of `contrast_filter` in the emmeans grid -# `estimated`, so that only these categories are contrasted. `pseudo_factor` -# is the grid column with the response categories -.filter_ordinal_contrast_grid <- function(estimated, pseudo_factor, contrast_filter) { - values <- contrast_filter$values +# keeps the response categories of `response_contrast_filter` in the emmeans +# grid `estimated`, so that only these categories are contrasted. +# `pseudo_factor` is the grid column with the response categories +.filter_ordinal_contrast_grid <- function( + estimated, + model, + pseudo_factor, + response_contrast_filter +) { + values <- response_contrast_filter$values if (is.null(values) || is.null(pseudo_factor)) { return(estimated) } if (length(values) < 2) { insight::format_error(paste0( "Contrasts of the response `", - contrast_filter$response, + response_contrast_filter$response, "` need at least two response categories. Please name two or more categories in `contrast`, e.g. `", - contrast_filter$response, + response_contrast_filter$response, " = c('a', 'b')`." )) } - keep <- as.character(estimated@grid[[pseudo_factor]]) %in% values - estimated[which(keep)] + categories <- as.character(estimated@grid[[pseudo_factor]]) + # for clmm models, emmeans codes the response categories as 1, 2, 3, ... - + # we match the codes with the names from the model (see + # `.clean_pseudo_factor_emmeans_ordinal()`) + if (inherits(model, "clmm")) { + response_levels <- model$y.levels + codes <- as.character(seq_along(response_levels)) + if (all(categories %in% codes)) { + categories <- response_levels[match(categories, codes)] + } + } + estimated[which(categories %in% values)] } diff --git a/R/get_jointtest.R b/R/get_jointtest.R index 3908feb3d..6a37567e4 100644 --- a/R/get_jointtest.R +++ b/R/get_jointtest.R @@ -138,7 +138,21 @@ .get_jointtest.emmGrid <- function(means, my_args, ...) { by_arg <- attributes(means)$misc$by.vars - result <- try(as.data.frame(emmeans::joint_tests(means, by = by_arg)), silent = TRUE) + if (is.null(my_args$response_contrast_filter$values)) { + result <- try(as.data.frame(emmeans::joint_tests(means, by = by_arg)), silent = TRUE) + } else { + # ordinal models with a filter on the contrasted response categories: + # `joint_tests()` fails on the grid with only these categories, so we test + # the contrasts jointly, which gives the same values as `joint_tests()` + result <- try( + { + joint <- as.data.frame(emmeans::test(means, joint = TRUE, by = by_arg)) + joint$note <- NULL + cbind(`model term` = "contrast", joint) + }, + silent = TRUE + ) + } # check if everything was ok if (inherits(result, "try-error")) { diff --git a/R/get_marginalcontrasts.R b/R/get_marginalcontrasts.R index 344db8b25..f5fa89edf 100644 --- a/R/get_marginalcontrasts.R +++ b/R/get_marginalcontrasts.R @@ -372,15 +372,17 @@ get_marginalcontrasts <- function( return(invisible(NULL)) } # models with response categories have one slope per category, so we - # cannot count the slopes from `by` alone - model_info <- insight::model_info(model, response = 1, verbose = FALSE) - if ( - !within_categories && - (isTRUE(model_info$is_ordinal) || + # cannot count the slopes from `by` alone - unless the slopes are contrasted + # within each category (`within_categories`) + if (!within_categories) { + model_info <- insight::model_info(model, response = 1, verbose = FALSE) + if ( + isTRUE(model_info$is_ordinal) || isTRUE(model_info$is_multinomial) || - isTRUE(model_info$is_categorical)) - ) { - return(invisible(NULL)) + isTRUE(model_info$is_categorical) + ) { + return(invisible(NULL)) + } } model_data <- insight::get_data(model, verbose = FALSE) by <- insight::trim_ws(gsub("=.*", "", by)) diff --git a/tests/testthat/test-ordinal.R b/tests/testthat/test-ordinal.R index 271e99df4..eea7e9239 100644 --- a/tests/testthat/test-ordinal.R +++ b/tests/testthat/test-ordinal.R @@ -1564,6 +1564,64 @@ test_that("estimate_contrasts, backend emmeans, ordinal, contrast filter on the info = name ) } + + # the response cannot also be filtered in `by` + err <- expect_error(estimate_contrasts( + models$polr, + "Sat=c('Low','High')", + by = "Sat='Low'", + backend = "emmeans" + )) + expect_match( + gsub("\\s+", " ", conditionMessage(err)), + "The response `Sat` cannot be filtered in `by` when it is also in `contrast`.", + fixed = TRUE + ) +}) + + +test_that("estimate_contrasts, backend emmeans, ordinal, contrast filter on the response of clmm", { + skip_if_not_installed("emmeans") + skip_if_not_installed("ordinal") + data(wine, package = "ordinal") + # emmeans codes the categories of clmm models as 1, 2, 3, ..., so we use + # names that are not the codes + levels(wine$rating) <- letters[1:5] + m <- ordinal::clmm(rating ~ temp + contact + (1 | judge), data = wine) + out_all <- estimate_contrasts(m, "rating", by = "temp", backend = "emmeans") + out <- estimate_contrasts(m, "rating=c('a','c')", by = "temp", backend = "emmeans") + # one contrast of the categories `c` and `a` (codes 3 and 1) per level of `temp` + expected <- out_all[out_all$Level1 == "rating3" & out_all$Level2 == "rating1", ] + expect_identical(nrow(out), 2L) + expect_identical(as.character(out$temp), as.character(expected$temp)) + expect_equal(out$Difference, expected$Difference, tolerance = 1e-10) + expect_equal(out$SE, expected$SE, tolerance = 1e-10) +}) + + +test_that("estimate_contrasts, backend emmeans, ordinal, joint test with a contrast filter on the response", { + skip_if_not_installed("emmeans") + data(housing, package = "MASS") + m <- MASS::polr(Sat ~ Infl + Type, weights = Freq, data = housing, Hess = TRUE) + out <- estimate_contrasts( + m, + "Sat=c('Low','High')", + by = "Infl", + comparison = "joint", + backend = "emmeans" + ) + # the joint test of the contrasts of the two named categories in emmeans + estimated <- emmeans::emmeans(m, specs = c("Infl", "Sat"), mode = "prob") + estimated <- estimated[which(as.character(estimated@grid$Sat) %in% c("Low", "High"))] + expected <- as.data.frame(emmeans::test( + emmeans::contrast(estimated, by = "Infl", method = "pairwise"), + joint = TRUE, + by = "Infl" + )) + expect_identical(nrow(out), 3L) + expect_identical(as.character(out$Infl), as.character(expected$Infl)) + expect_equal(out$Chisq, expected$Chisq, tolerance = 1e-6) + expect_equal(out$p, expected$p.value, tolerance = 1e-6) }) From e61e5b7493936ccaa92b196c88e099deef7a8ed0 Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Mon, 19 Oct 2026 11:43:13 -0500 Subject: [PATCH 7/8] Correct NEWS and a comment on joint tests --- NEWS.md | 10 +++++----- R/get_jointtest.R | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/NEWS.md b/NEWS.md index c5db5e822..3290917d7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -14,8 +14,8 @@ * `estimate_contrasts()` now contrasts only the named response categories, for example `estimate_contrasts(m, "Sat = c('Low', 'High')", by = "Infl")`. Before, it ignored the values and contrasted all categories. A single - category, or a filter on the response in both `contrast` and `by`, gives - an error. + category gives an error, and so does a filter on the response in `by` + when the response is also in `contrast`. * For contrasts of slopes, a filter in `by` that leaves one level to compare, for example `by = "Infl = 'Low'"`, now gives a clear error. Before, `estimate_contrasts()` returned a table with empty contrasts. @@ -28,9 +28,9 @@ applies only to `by` variables that are all factors or character vectors. If the filter leaves fewer than two slopes to compare, `estimate_contrasts()` now stops with a clear error. Multinomial models, - ordinal models with `backend = "marginaleffects"`, and a custom hypothesis - in `comparison` (such as `"b1 = b2"`, a function or a matrix), get no such - check. + Bayesian ordinal models, frequentist ordinal models with + `backend = "marginaleffects"`, and a custom hypothesis in `comparison` + (such as `"b1 = b2"`, a function or a matrix), get no such check. * `p_adjust = "tukey"` and `p_adjust = "sidak"` now give the same p-values as *emmeans* in `estimate_contrasts()` and `estimate_slopes()` with diff --git a/R/get_jointtest.R b/R/get_jointtest.R index 6a37567e4..6d927bd1a 100644 --- a/R/get_jointtest.R +++ b/R/get_jointtest.R @@ -142,8 +142,9 @@ result <- try(as.data.frame(emmeans::joint_tests(means, by = by_arg)), silent = TRUE) } else { # ordinal models with a filter on the contrasted response categories: - # `joint_tests()` fails on the grid with only these categories, so we test - # the contrasts jointly, which gives the same values as `joint_tests()` + # `joint_tests()` fails when only two categories are kept (one contrast + # per `by` group), so we test the contrasts jointly, which gives the same + # values as `joint_tests()` when that works result <- try( { joint <- as.data.frame(emmeans::test(means, joint = TRUE, by = by_arg)) From 818735cdc37887f7ff595ca09d131be7d12d0426 Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Mon, 19 Oct 2026 11:48:59 -0500 Subject: [PATCH 8/8] Run joint_tests() first for filtered ordinal contrasts; align the clmm code rule; docs --- R/estimate_means.R | 2 +- R/get_emmeans.R | 9 +++++---- R/get_jointtest.R | 25 ++++++++++++------------- man/estimate_contrasts.Rd | 2 +- man/estimate_means.Rd | 2 +- man/estimate_slopes.Rd | 2 +- man/get_emmeans.Rd | 2 +- tests/testthat/test-ordinal.R | 10 ++++++++++ 8 files changed, 32 insertions(+), 22 deletions(-) diff --git a/R/estimate_means.R b/R/estimate_means.R index dc126e3d0..9ef4803f9 100644 --- a/R/estimate_means.R +++ b/R/estimate_means.R @@ -54,7 +54,7 @@ #' `Response`). The modes `"cum.prob"`, `"exc.prob"` and `"linear.predictor"` #' return one value per threshold (column `Threshold`). For modes with #' response categories or thresholds, contrasts compare the focal levels -#' within each one. For +#' within each one, unless `contrast` names the response. For #' `estimate_slopes()`, the slopes for `"prob"` and `"mean.class"` are #' computed from the slopes of the cumulative probabilities. #' diff --git a/R/get_emmeans.R b/R/get_emmeans.R index efa35aefb..2d4e8ad4e 100644 --- a/R/get_emmeans.R +++ b/R/get_emmeans.R @@ -640,12 +640,12 @@ get_emmeans <- function( } categories <- as.character(estimated@grid[[pseudo_factor]]) # for clmm models, emmeans codes the response categories as 1, 2, 3, ... - - # we match the codes with the names from the model (see - # `.clean_pseudo_factor_emmeans_ordinal()`) + # we match the codes with the names from the model, with the same rule as + # `.clean_pseudo_factor_emmeans_ordinal()` if (inherits(model, "clmm")) { response_levels <- model$y.levels codes <- as.character(seq_along(response_levels)) - if (all(categories %in% codes)) { + if (setequal(categories, codes) && !anyDuplicated(response_levels)) { categories <- response_levels[match(categories, codes)] } } @@ -668,7 +668,8 @@ get_emmeans <- function( #' @keywords internal .process_emmeans_arguments <- function(model, args, data, ...) { - # ordinal models: a `by` term on the response filters the response categories + # ordinal models: a `by` or `contrast` term on the response filters the + # response categories args <- .extract_ordinal_response_filter(model, args, data) # Create the data_matrix diff --git a/R/get_jointtest.R b/R/get_jointtest.R index 6d927bd1a..fe8dc61b3 100644 --- a/R/get_jointtest.R +++ b/R/get_jointtest.R @@ -138,21 +138,20 @@ .get_jointtest.emmGrid <- function(means, my_args, ...) { by_arg <- attributes(means)$misc$by.vars - if (is.null(my_args$response_contrast_filter$values)) { - result <- try(as.data.frame(emmeans::joint_tests(means, by = by_arg)), silent = TRUE) - } else { + result <- try(as.data.frame(emmeans::joint_tests(means, by = by_arg)), silent = TRUE) + if ( + inherits(result, "try-error") && + !is.null(my_args$response_contrast_filter$values) && + length(my_args$contrast) == 1 + ) { # ordinal models with a filter on the contrasted response categories: # `joint_tests()` fails when only two categories are kept (one contrast - # per `by` group), so we test the contrasts jointly, which gives the same - # values as `joint_tests()` when that works - result <- try( - { - joint <- as.data.frame(emmeans::test(means, joint = TRUE, by = by_arg)) - joint$note <- NULL - cbind(`model term` = "contrast", joint) - }, - silent = TRUE - ) + # per `by` group), so we test that contrast in each `by` group + joint <- try(as.data.frame(emmeans::test(means, joint = TRUE, by = by_arg)), silent = TRUE) + if (!inherits(joint, "try-error") && !anyNA(joint$p.value)) { + joint$note <- NULL + result <- cbind(`model term` = "contrast", joint) + } } # check if everything was ok diff --git a/man/estimate_contrasts.Rd b/man/estimate_contrasts.Rd index 1dbfbfd34..645aad7da 100644 --- a/man/estimate_contrasts.Rd +++ b/man/estimate_contrasts.Rd @@ -132,7 +132,7 @@ is \code{"prob"}, which returns one probability per response category (column \code{Response}). The modes \code{"cum.prob"}, \code{"exc.prob"} and \code{"linear.predictor"} return one value per threshold (column \code{Threshold}). For modes with response categories or thresholds, contrasts compare the focal levels -within each one. For +within each one, unless \code{contrast} names the response. For \code{estimate_slopes()}, the slopes for \code{"prob"} and \code{"mean.class"} are computed from the slopes of the cumulative probabilities. } diff --git a/man/estimate_means.Rd b/man/estimate_means.Rd index 22315a89a..a678815e7 100644 --- a/man/estimate_means.Rd +++ b/man/estimate_means.Rd @@ -68,7 +68,7 @@ is \code{"prob"}, which returns one probability per response category (column \code{Response}). The modes \code{"cum.prob"}, \code{"exc.prob"} and \code{"linear.predictor"} return one value per threshold (column \code{Threshold}). For modes with response categories or thresholds, contrasts compare the focal levels -within each one. For +within each one, unless \code{contrast} names the response. For \code{estimate_slopes()}, the slopes for \code{"prob"} and \code{"mean.class"} are computed from the slopes of the cumulative probabilities. } diff --git a/man/estimate_slopes.Rd b/man/estimate_slopes.Rd index 695c1e27d..981f05215 100644 --- a/man/estimate_slopes.Rd +++ b/man/estimate_slopes.Rd @@ -82,7 +82,7 @@ is \code{"prob"}, which returns one probability per response category (column \code{Response}). The modes \code{"cum.prob"}, \code{"exc.prob"} and \code{"linear.predictor"} return one value per threshold (column \code{Threshold}). For modes with response categories or thresholds, contrasts compare the focal levels -within each one. For +within each one, unless \code{contrast} names the response. For \code{estimate_slopes()}, the slopes for \code{"prob"} and \code{"mean.class"} are computed from the slopes of the cumulative probabilities. } diff --git a/man/get_emmeans.Rd b/man/get_emmeans.Rd index 226ff1161..0d061450b 100644 --- a/man/get_emmeans.Rd +++ b/man/get_emmeans.Rd @@ -153,7 +153,7 @@ is \code{"prob"}, which returns one probability per response category (column \code{Response}). The modes \code{"cum.prob"}, \code{"exc.prob"} and \code{"linear.predictor"} return one value per threshold (column \code{Threshold}). For modes with response categories or thresholds, contrasts compare the focal levels -within each one. For +within each one, unless \code{contrast} names the response. For \code{estimate_slopes()}, the slopes for \code{"prob"} and \code{"mean.class"} are computed from the slopes of the cumulative probabilities. } diff --git a/tests/testthat/test-ordinal.R b/tests/testthat/test-ordinal.R index eea7e9239..88a6e4d45 100644 --- a/tests/testthat/test-ordinal.R +++ b/tests/testthat/test-ordinal.R @@ -1622,6 +1622,16 @@ test_that("estimate_contrasts, backend emmeans, ordinal, joint test with a contr expect_identical(as.character(out$Infl), as.character(expected$Infl)) expect_equal(out$Chisq, expected$Chisq, tolerance = 1e-6) expect_equal(out$p, expected$p.value, tolerance = 1e-6) + + # with another term in `contrast`, the joint test fails as without a filter + err <- expect_error(estimate_contrasts( + m, + c("Infl", "Sat=c('Low','High')"), + by = "Type", + comparison = "joint", + backend = "emmeans" + )) + expect_match(conditionMessage(err), "Could not compute joint test", fixed = TRUE) })