From 4cc0911227dfe16272806e53de552099ccb5d5bb Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 12:28:49 +0200 Subject: [PATCH 1/8] Can't use offsets with `offset =` argument, only with `offset()` term Fixes #667 --- DESCRIPTION | 2 +- NEWS.md | 3 +++ R/checks.R | 2 +- R/get_marginalmeans.R | 9 ++++++++- tests/testthat/test-offset.R | 37 ++++++++++++++++++++++++++++++++++-- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index caef20ad3..3317cfd32 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Type: Package Package: modelbased Title: Estimation of Model-Based Predictions, Contrasts and Means -Version: 0.17.0.6 +Version: 0.17.0.7 Authors@R: c(person(given = "Dominique", family = "Makowski", diff --git a/NEWS.md b/NEWS.md index b55e965a4..613b3eff6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -25,6 +25,9 @@ when some of the indexed coefficients contained the same digit (in this case, `1` in `b1` and `b12`). +* Fixed issues in offset-handling when offset was specified via the `offset` + arguments in the model-call, instead inside the formula. + # modelbased 0.17.0 ## Breaking Changes diff --git a/R/checks.R b/R/checks.R index 4fae07361..b18b94cf5 100644 --- a/R/checks.R +++ b/R/checks.R @@ -175,7 +175,7 @@ msg <- c( msg, paste( - "We also found that the model has a log-transformed offset term.", + "\nWe also found that the model has a log-transformed offset term.", "If you use the `offset` argument, the log-transformation will", "automatically be applied to the provided offset-value. I.e., consider", "using, for instance, `offset = 10` and not `offset = log(10)`." diff --git a/R/get_marginalmeans.R b/R/get_marginalmeans.R index 33246d724..6509c7eed 100644 --- a/R/get_marginalmeans.R +++ b/R/get_marginalmeans.R @@ -506,7 +506,11 @@ get_marginalmeans <- function( # for estimate = "population", we need the offset in `by`, thus, we have to # add it before we call data grid model_offset <- insight::find_offset(model) - needs_offset <- !is.null(dots$offset) && !is.null(model_offset) + needs_offset <- (!is.null(dots$offset) && !is.null(model_offset)) + # check if offset was specified via `offset` argument, and not inside + # formula. This may lead to errors, because the offset term would not included + # in the data grid + call_offset <- !is.null(insight::get_call(model)$offset) if ( needs_offset && estimate == "population" && !any(startsWith(dg_args$by, model_offset)) @@ -521,6 +525,9 @@ get_marginalmeans <- function( # handle offsets for other estimate-options if (needs_offset && estimate != "population") { datagrid[[model_offset]] <- dots$offset + } else if (call_offset && estimate != "population") { + model_data <- insight::get_data(model) + datagrid[[model_offset]] <- mean(model_data[[model_offset]], na.rmn = TRUE) } # restore data types - if we have defined numbers in `by`, like diff --git a/tests/testthat/test-offset.R b/tests/testthat/test-offset.R index 94fc0956a..cf13e31f8 100644 --- a/tests/testthat/test-offset.R +++ b/tests/testthat/test-offset.R @@ -1,7 +1,7 @@ skip_if_not_installed("marginaleffects", minimum_version = "0.29.0") -skip_if_not_installed("MASS") -test_that("verbose", { +test_that("offset works", { + skip_if_not_installed("MASS") set.seed(1) newdata <- data.frame( y = c(602, 38, 616, 256, 21, 723, 245, 176, 89, 1614, 31, 27, 313, 251, 345), @@ -80,7 +80,40 @@ test_that("verbose", { }) +test_that("offset works when not in formula", { + set.seed(1) + newdata <- data.frame( + y = c(602, 38, 616, 256, 21, 723, 245, 176, 89, 1614, 31, 27, 313, 251, 345), + x = as.factor(sample(letters[1:3], 15, replace = TRUE)), + offset_1 = c(72, 50, 31, 30, 16, 25, 75, 16, 78, 40, 68, 25, 71, 52, 17) + ) + moff <- glm(y ~ x, offset = log(offset_1), data = newdata, family = poisson()) + expect_message( + { + out <- estimate_means(moff, "x") + }, + regex = "Model contains" + ) + expect_identical(dim(out), c(3L, 4L)) + expect_equal(out$Mean, c(304.31461, 272.69787, 456.2186), tolerance = 1e-3) + + expect_message( + { + out <- estimate_means(moff, "x", estimate = "average") + }, + regex = "and you average" + ) + expect_equal(out$Mean, c(264.40874, 173.47822, 493.28517), tolerance = 1e-3) + + expect_silent({ + out <- estimate_means(moff, "x", offset = 5) + }) + expect_equal(out$Mean, c(34.26966, 30.70922, 51.37597), tolerance = 1e-3) +}) + + test_that("offset, estimate_relation", { + skip_if_not_installed("MASS") set.seed(1) newdata <- data.frame( y = c(602, 38, 616, 256, 21, 723, 245, 176, 89, 1614, 31, 27, 313, 251, 345), From 4af02d57d50e10e0a18efe6c51e8775b3c7117cc Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 12:34:26 +0200 Subject: [PATCH 2/8] Fix offset handling in model call arguments Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- NEWS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 613b3eff6..8013f2e32 100644 --- a/NEWS.md +++ b/NEWS.md @@ -25,8 +25,7 @@ when some of the indexed coefficients contained the same digit (in this case, `1` in `b1` and `b12`). -* Fixed issues in offset-handling when offset was specified via the `offset` - arguments in the model-call, instead inside the formula. +* Fixed issues in offset handling when the offset was specified via the `offset` argument in the model call instead of inside the formula. # modelbased 0.17.0 From 51a44764bc0d4382061389c58dc758a6b36d5391 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 13:16:05 +0200 Subject: [PATCH 3/8] fixes --- R/checks.R | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/R/checks.R b/R/checks.R index b18b94cf5..3b770f9ec 100644 --- a/R/checks.R +++ b/R/checks.R @@ -141,11 +141,17 @@ estimate, offset = NULL, my_args = NULL, + dots = NULL, verbose = TRUE ) { model_offset <- insight::find_offset(model) - # check if model has an offset at all - if (!is.null(model_offset) && !any(startsWith(my_args$by, model_offset)) && verbose) { + # check if model has an offset at all, and if it was specified in "newdata" + offset_missing1 <- !is.null(dots$newdata) && + (!is.null(model_offset) && !model_offset %in% colnames(dots$newdata)) + offset_missing2 <- (!is.null(model_offset) && + (!is.null(my_args$by) && !any(startsWith(my_args$by, model_offset)))) + + if (offset_missing1 && offset_missing2 && verbose) { msg <- NULL if (is.null(offset)) { # if no offset argument was specified, tell user what this means From 1e80aacd6c889c874c596962f55c7e4ce09c351e Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 13:16:13 +0200 Subject: [PATCH 4/8] fixes --- R/get_marginalmeans.R | 29 +++++++++++++++++++++++------ tests/testthat/test-offset.R | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/R/get_marginalmeans.R b/R/get_marginalmeans.R index 6509c7eed..3c0be8984 100644 --- a/R/get_marginalmeans.R +++ b/R/get_marginalmeans.R @@ -55,10 +55,6 @@ get_marginalmeans <- function( # Guess arguments my_args <- .guess_marginaleffects_arguments(model, by, verbose = verbose, ...) - # inform user about appropriate use of offset-terms - model_offset <- dots$offset - .check_offset(model, estimate, offset = model_offset, my_args, verbose = verbose) - # find default response-type, and get information about back transformation predict_args <- .get_marginaleffects_type_argument( model, @@ -73,6 +69,10 @@ get_marginalmeans <- function( # was "data" argument used? if so, it replaces "newdata" in dots dots <- .check_dots_data(dots, verbose) + # inform user about appropriate use of offset-terms + model_offset <- dots$offset + .check_offset(model, estimate, offset = model_offset, my_args, dots, verbose = verbose) + # Second step: create a data grid ------------------------------------------- # --------------------------------------------------------------------------- @@ -503,13 +503,16 @@ get_marginalmeans <- function( dots[c("by", "factors", "include_random", "verbose")] <- NULL dg_args <- insight::compact_list(c(dg_args, dots)) + # we first need to determine whether we have an offset term at all + # ========================================================================== + # for estimate = "population", we need the offset in `by`, thus, we have to # add it before we call data grid model_offset <- insight::find_offset(model) needs_offset <- (!is.null(dots$offset) && !is.null(model_offset)) # check if offset was specified via `offset` argument, and not inside - # formula. This may lead to errors, because the offset term would not included - # in the data grid + # formula. This may lead to errors, because the offset term would not be + # included in the data grid call_offset <- !is.null(insight::get_call(model)$offset) if ( @@ -530,6 +533,20 @@ get_marginalmeans <- function( datagrid[[model_offset]] <- mean(model_data[[model_offset]], na.rmn = TRUE) } + # check if we have a "newdata" argument, where offset is already specified + # if so, we use this data, else, we add the offset mean to the newdata argument + if ( + !is.null(dots$newdata) && (!model_offset %in% colnames(dots$newdata)) || needs_offset + ) { + if (needs_offset) { + offset_val <- dots$offset + } else { + model_data <- insight::get_data(model) + offset_val <- mean(model_data[[model_offset]], na.rmn = TRUE) + } + dots$newdata[[model_offset]] <- offset_val + } + # restore data types - if we have defined numbers in `by`, like # `by = "predictor = 5"`, and `predictor` was a factor, it is returned as # numeric in the data grid. Fix this here, else marginal effects will fail diff --git a/tests/testthat/test-offset.R b/tests/testthat/test-offset.R index cf13e31f8..3247097e2 100644 --- a/tests/testthat/test-offset.R +++ b/tests/testthat/test-offset.R @@ -112,6 +112,26 @@ test_that("offset works when not in formula", { }) +test_that("offset checks work", { + set.seed(1) + newdata <- data.frame( + y = c(602, 38, 616, 256, 21, 723, 245, 176, 89, 1614, 31, 27, 313, 251, 345), + x = as.factor(sample(letters[1:3], 15, replace = TRUE)), + offset_1 = c(72, 50, 31, 30, 16, 25, 75, 16, 78, 40, 68, 25, 71, 52, 17) + ) + moff <- glm(y ~ x, offset = log(offset_1), data = newdata, family = poisson()) + + expect_silent(estimate_means(moff, "x", data = newdata)) + expect_silent(estimate_means(moff, "x", data = newdata, offset = 1)) + expect_message( + { + out <- estimate_means(moff, "x", data = newdata[1:2]) + }, + regex = "Model contains" + ) +}) + + test_that("offset, estimate_relation", { skip_if_not_installed("MASS") set.seed(1) From 49a6e54469ebf4b873871a293dc5fd2f91578451 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 13:18:29 +0200 Subject: [PATCH 5/8] test grand mean --- tests/testthat/test-offset.R | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/testthat/test-offset.R b/tests/testthat/test-offset.R index 3247097e2..b9b6939bc 100644 --- a/tests/testthat/test-offset.R +++ b/tests/testthat/test-offset.R @@ -129,6 +129,11 @@ test_that("offset checks work", { }, regex = "Model contains" ) + # grand mean works + expect_silent({ + out <- estimate_means(moff, by = NULL) + }) + expect_identical(dim(out), c(1L, 3L)) }) From 68285b5b5a15ff0fabda90b917f7de75ce065f39 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 13:20:10 +0200 Subject: [PATCH 6/8] news --- NEWS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 8013f2e32..227120259 100644 --- a/NEWS.md +++ b/NEWS.md @@ -25,7 +25,11 @@ when some of the indexed coefficients contained the same digit (in this case, `1` in `b1` and `b12`). -* Fixed issues in offset handling when the offset was specified via the `offset` argument in the model call instead of inside the formula. +* Fixed issues in offset handling when the offset was specified via the `offset` + argument in the model call instead of inside the formula. + +* Fixed issues with using offsets and predicting grand mean (i.e. when model + contains offset and `by = NULL`). # modelbased 0.17.0 From d2903cf83c6e0457424f3db2ce3f078630059ff3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 14:43:05 +0200 Subject: [PATCH 7/8] fixes --- R/checks.R | 79 ++++++++++++++++++++++--------------------- R/get_marginalmeans.R | 37 ++++++++++++++------ 2 files changed, 66 insertions(+), 50 deletions(-) diff --git a/R/checks.R b/R/checks.R index 3b770f9ec..182138d51 100644 --- a/R/checks.R +++ b/R/checks.R @@ -144,50 +144,51 @@ dots = NULL, verbose = TRUE ) { + # check if model has an offset at all model_offset <- insight::find_offset(model) - # check if model has an offset at all, and if it was specified in "newdata" - offset_missing1 <- !is.null(dots$newdata) && - (!is.null(model_offset) && !model_offset %in% colnames(dots$newdata)) - offset_missing2 <- (!is.null(model_offset) && - (!is.null(my_args$by) && !any(startsWith(my_args$by, model_offset)))) + if (is.null(model_offset)) { + return(NULL) + } + + # check if offset was specified in "newdata" + offset1 <- !is.null(dots$newdata) && !model_offset %in% colnames(dots$newdata) + offset2 <- !is.null(my_args$by) && !any(startsWith(my_args$by, model_offset)) - if (offset_missing1 && offset_missing2 && verbose) { + if (verbose && is.null(offset) && (offset1 || offset2)) { msg <- NULL - if (is.null(offset)) { - # if no offset argument was specified, tell user what this means - msg <- switch( - estimate, - specific = , - typical = paste( - "Model contains an offset-term, which is set to its mean value.", - "If you want to average predictions over the distribution of the offset", - "(if appropriate), use `estimate = \"average\"` or `estimate = \"population\"`.", - "If you want to fix the offset to a specific value, for instance `1`,", - "use `offset = 1`." - ), - average = , - population = paste( - "Model contains an offset-term and you average predictions over the", - "distribution of that offset. If you want to fix the offset to a", - "specific value, for instance `1`, use `offset = 1`." - ) + # if no offset argument was specified, tell user what this means + msg <- switch( + estimate, + specific = , + typical = paste( + "Model contains an offset-term, which is set to its mean value.", + "If you want to average predictions over the distribution of the offset", + "(if appropriate), use `estimate = \"average\"` or `estimate = \"population\"`.", + "If you want to fix the offset to a specific value, for instance `1`,", + "use `offset = 1`." + ), + average = , + population = paste( + "Model contains an offset-term and you average predictions over the", + "distribution of that offset. If you want to fix the offset to a", + "specific value, for instance `1`, use `offset = 1`." ) - # if offset term is log-transformed, tell user. offset should be fixed then - log_offset <- insight::find_transformation(insight::find_offset( - model, - as_term = TRUE - )) - if (!is.null(log_offset) && startsWith(log_offset, "log")) { - msg <- c( - msg, - paste( - "\nWe also found that the model has a log-transformed offset term.", - "If you use the `offset` argument, the log-transformation will", - "automatically be applied to the provided offset-value. I.e., consider", - "using, for instance, `offset = 10` and not `offset = log(10)`." - ) + ) + # if offset term is log-transformed, tell user. offset should be fixed then + log_offset <- insight::find_transformation(insight::find_offset( + model, + as_term = TRUE + )) + if (!is.null(log_offset) && startsWith(log_offset, "log")) { + msg <- c( + msg, + paste( + "\nWe also found that the model has a log-transformed offset term.", + "If you use the `offset` argument, the log-transformation will", + "automatically be applied to the provided offset-value. I.e., consider", + "using, for instance, `offset = 10` and not `offset = log(10)`." ) - } + ) } if (!is.null(msg)) { insight::format_alert(msg) diff --git a/R/get_marginalmeans.R b/R/get_marginalmeans.R index 3c0be8984..0ac809389 100644 --- a/R/get_marginalmeans.R +++ b/R/get_marginalmeans.R @@ -509,15 +509,20 @@ get_marginalmeans <- function( # for estimate = "population", we need the offset in `by`, thus, we have to # add it before we call data grid model_offset <- insight::find_offset(model) - needs_offset <- (!is.null(dots$offset) && !is.null(model_offset)) - # check if offset was specified via `offset` argument, and not inside - # formula. This may lead to errors, because the offset term would not be - # included in the data grid + + # offset_arg is a flag for the "offset" argument, which is used by users + # to define a specific offset-value + offset_arg <- (!is.null(dots$offset) && !is.null(model_offset)) + + # check if offset was specified via `offset` argument in the model call, and + # not inside formula. We need to handle this differently. call_offset <- !is.null(insight::get_call(model)$offset) - if ( - needs_offset && estimate == "population" && !any(startsWith(dg_args$by, model_offset)) - ) { + # by_miss_offset is a flag that checks whether the offset-term was + # specified in the "by" argument or not + by_miss_offset <- (!is.null(dg_args$by) && !any(startsWith(dg_args$by, model_offset))) + + if (offset_arg && estimate == "population" && by_miss_offset) { dg_args$by <- c(dg_args$by, paste(model_offset, "=", dots$offset)) } @@ -526,24 +531,34 @@ get_marginalmeans <- function( datagrid_info <- attributes(datagrid) # handle offsets for other estimate-options - if (needs_offset && estimate != "population") { + if (offset_arg && estimate != "population") { + # if user provided an offset-argument, we use this value in the datagrid datagrid[[model_offset]] <- dots$offset } else if (call_offset && estimate != "population") { + # else, we take the mean-value of the offset from the data model_data <- insight::get_data(model) datagrid[[model_offset]] <- mean(model_data[[model_offset]], na.rmn = TRUE) } # check if we have a "newdata" argument, where offset is already specified - # if so, we use this data, else, we add the offset mean to the newdata argument + # if so, we use that data and don't need to do anything else. However, if + # a) model has an offset, b) newdata is provided, and c) model-offset is + # not yet included in the newdata, or explicitly specied in the offset-argument, + # we add/overwrite that value. "datagrid" is already handled above if ( - !is.null(dots$newdata) && (!model_offset %in% colnames(dots$newdata)) || needs_offset + !is.null(model_offset) && + !is.null(dots$newdata) && + (!model_offset %in% colnames(dots$newdata) || offset_arg) ) { - if (needs_offset) { + if (offset_arg) { + # use user-specified offset-value offset_val <- dots$offset } else { + # take mean from model data model_data <- insight::get_data(model) offset_val <- mean(model_data[[model_offset]], na.rmn = TRUE) } + # add offset-term to newdata dots$newdata[[model_offset]] <- offset_val } From ff8e8f6c0d5c03bdf7bcc611d52b2be9d2c7921d Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 15:02:03 +0200 Subject: [PATCH 8/8] fix --- R/checks.R | 20 +++++++++++++++----- R/get_marginalmeans.R | 4 +++- tests/testthat/test-offset.R | 6 ++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/R/checks.R b/R/checks.R index 182138d51..bef067453 100644 --- a/R/checks.R +++ b/R/checks.R @@ -146,15 +146,25 @@ ) { # check if model has an offset at all model_offset <- insight::find_offset(model) - if (is.null(model_offset)) { + + # if we have no offset at all, or offset is explicitly specified, we don't + # need a warning here... + if (is.null(model_offset) || !is.null(dots$offset)) { return(NULL) } - # check if offset was specified in "newdata" - offset1 <- !is.null(dots$newdata) && !model_offset %in% colnames(dots$newdata) - offset2 <- !is.null(my_args$by) && !any(startsWith(my_args$by, model_offset)) + # check if a) we have "newdata" and b) offset was specified in "newdata". + # if not, "offset_miss_1" flags that offset is not specified or provided, + # we need a warning + offset_miss_1 <- !is.null(dots$newdata) && !model_offset %in% colnames(dots$newdata) + + # if no newdata provided, offset_miss_1 is FALSE, indicating no problems yet. + # We only need to start the 2nd check if we have no newdata + offset_miss_2 <- is.null(dots$newdata) && + !is.null(my_args$by) && + !any(startsWith(my_args$by, model_offset)) - if (verbose && is.null(offset) && (offset1 || offset2)) { + if (verbose && is.null(offset) && (offset_miss_1 || offset_miss_2)) { msg <- NULL # if no offset argument was specified, tell user what this means msg <- switch( diff --git a/R/get_marginalmeans.R b/R/get_marginalmeans.R index 0ac809389..7cdb26c66 100644 --- a/R/get_marginalmeans.R +++ b/R/get_marginalmeans.R @@ -520,7 +520,9 @@ get_marginalmeans <- function( # by_miss_offset is a flag that checks whether the offset-term was # specified in the "by" argument or not - by_miss_offset <- (!is.null(dg_args$by) && !any(startsWith(dg_args$by, model_offset))) + by_miss_offset <- (!is.null(dg_args$by) && + !is.null(model_offset) && + !any(startsWith(dg_args$by, model_offset))) if (offset_arg && estimate == "population" && by_miss_offset) { dg_args$by <- c(dg_args$by, paste(model_offset, "=", dots$offset)) diff --git a/tests/testthat/test-offset.R b/tests/testthat/test-offset.R index b9b6939bc..2c6135a41 100644 --- a/tests/testthat/test-offset.R +++ b/tests/testthat/test-offset.R @@ -121,8 +121,14 @@ test_that("offset checks work", { ) moff <- glm(y ~ x, offset = log(offset_1), data = newdata, family = poisson()) + # offset term is in newdata, no message expect_silent(estimate_means(moff, "x", data = newdata)) + + # offset term is explicitly specified, no message expect_silent(estimate_means(moff, "x", data = newdata, offset = 1)) + + # offset term is neither in newdata, nor as offset-argument, so tell user + # that average is taken expect_message( { out <- estimate_means(moff, "x", data = newdata[1:2])