Skip to content

Commit 130dcd5

Browse files
authored
Keep by variables whose names insight renames (marginaleffects backend) (#677)
* Keep by variables whose names insight renames (marginaleffects backend) model_parameters() renames columns such as type, n or value, and the format step removes columns such as p, CI or S by name. A by variable with one of these names was dropped, renamed, or mislabeled other columns. Focal columns that hold the data grid values now get temporary names until the renaming is done. * Close the test block that lost its closing brace in #676 The "recipes that do not change" test in test-visualisation_recipe.R had no closing "})", so the file did not parse and devtools::test() stopped there. * Build the focal-name test models from local data only Another test file assigns a global d, which the formula environment could find before the local data. * Narrow the NEWS examples to estimate_means() * Make the brms subsample test stable The test asked every median from a random 200-draw subsample to differ from the full-draw median by more than 0.001, which failed in 6 of 10 local runs. It now tests that the medians differ and that 200 draws are kept. * Say in NEWS which cases the focal-name fix does not cover * Bump version to 0.17.0.11 * Check focal columns against the grid used for predictions With a custom newdata, the datagrid attribute holds the generated grid, not the one passed to marginaleffects, so focal columns were not protected. Temporary column names are now also unique against the existing columns.
1 parent b964ea7 commit 130dcd5

6 files changed

Lines changed: 317 additions & 5 deletions

File tree

‎DESCRIPTION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Type: Package
22
Package: modelbased
33
Title: Estimation of Model-Based Predictions, Contrasts and Means
4-
Version: 0.17.0.10
4+
Version: 0.17.0.11
55
Authors@R:
66
c(person(given = "Dominique",
77
family = "Makowski",

‎NEWS.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@
4545

4646
## Bug fixes
4747

48+
* With `backend = "marginaleffects"`, `estimate_means()`, `estimate_slopes()`
49+
and `estimate_contrasts()` no longer drop or rename a `by` variable with a
50+
name such as `type`, `n` or `value`. They also no longer mislabel other
51+
columns because of such a name. Before, for example, `estimate_means()`
52+
dropped a `by` variable named `type` and renamed `n` to `n_Obs`. It also
53+
labeled a variable named `Median` as `Mean`, and the means as `Coefficient`.
54+
This fix does not yet apply to Bayesian models or to inequality comparisons
55+
(for example `comparison = "inequality"`).
56+
4857
* `estimate_means()` with `backend = "emmeans"` now shows the response level
4958
names for models from `ordinal::clmm()`. Before, the `Response` column showed
5059
`1`, `2`, `3`, and the `Threshold` column showed `1|2`, `2|3`.

‎R/format.R‎

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,16 @@ equivalence_columns <- c(
722722
is_contrast_analysis = FALSE,
723723
...
724724
) {
725+
# focal terms can have names that `model_parameters()` renames or that we
726+
# remove below (e.g. "type", "n" or "p"). We give these columns temporary
727+
# names and restore the original names after all renaming is done.
728+
focal_terms <- attr(x, "focal_terms", exact = TRUE)
729+
protected_focal <- .protected_focal_columns(x, focal_terms)
730+
if (length(protected_focal)) {
731+
colnames(x)[match(protected_focal, colnames(x))] <- names(protected_focal)
732+
focal_terms[match(protected_focal, focal_terms)] <- names(protected_focal)
733+
}
734+
725735
# tidy output - we want to tidy the output, using `model_parameters()` or
726736
# `describe_posterior()` for Bayesian models. We also need to know how the
727737
# coefficient column is named, because we replace that column name with an
@@ -783,9 +793,8 @@ equivalence_columns <- c(
783793
params <- params[c(setdiff(colnames(params), relocate_columns), relocate_columns)]
784794

785795
# relocate focal terms to the beginning
786-
by <- attr(x, "focal_terms", exact = TRUE)
787-
if (!is.null(by) && all(by %in% colnames(params))) {
788-
params <- datawizard::data_reorder(params, by, verbose = FALSE)
796+
if (!is.null(focal_terms) && all(focal_terms %in% colnames(params))) {
797+
params <- datawizard::data_reorder(params, focal_terms, verbose = FALSE)
789798
}
790799

791800
# rename coefficient name and statistics columns
@@ -829,6 +838,12 @@ equivalence_columns <- c(
829838
params <- .safe(datawizard::data_rename(params, "group", "Class"), params)
830839
}
831840

841+
# restore original names of protected focal terms
842+
if (length(protected_focal)) {
843+
restore <- match(names(protected_focal), colnames(params))
844+
colnames(params)[restore[!is.na(restore)]] <- protected_focal[!is.na(restore)]
845+
}
846+
832847
# extract information for potential inequality analysis
833848
by_terms <- attributes(x)$hypothesis_by
834849
comparison_hypothesis <- list(...)$hypothesis
@@ -919,6 +934,54 @@ equivalence_columns <- c(
919934
}
920935

921936

937+
# Returns the focal terms that are columns of the marginaleffects output `x`
938+
# and hold the values of the data grid, named with temporary column names.
939+
# Columns that marginaleffects writes itself (e.g. "hypothesis" or "s.value")
940+
# do not hold the data grid values, so these are not protected. Bayesian
941+
# models are tidied by `describe_posterior()`, which may drop unknown columns,
942+
# so we do not protect focal terms there.
943+
944+
#' @keywords internal
945+
.protected_focal_columns <- function(x, focal_terms) {
946+
if (is.null(focal_terms) || .is_bayesian_marginaleffects(x)) {
947+
return(character(0))
948+
}
949+
# the grid used for predictions is the "newdata" component, which differs
950+
# from the "datagrid" attribute when users provide their own `newdata`
951+
grids <- list(
952+
.safe(marginaleffects::components(x, "newdata")),
953+
attr(x, "datagrid", exact = TRUE)
954+
)
955+
grids <- grids[!vapply(grids, is.null, logical(1))]
956+
if (!length(grids)) {
957+
return(character(0))
958+
}
959+
protected <- vapply(
960+
focal_terms,
961+
function(i) {
962+
i %in% colnames(x) &&
963+
any(vapply(
964+
grids,
965+
function(g) {
966+
i %in% colnames(g) && all(as.character(x[[i]]) %in% as.character(g[[i]]))
967+
},
968+
logical(1)
969+
))
970+
},
971+
logical(1)
972+
)
973+
protected <- unname(focal_terms[protected])
974+
if (!length(protected)) {
975+
return(character(0))
976+
}
977+
# temporary names must not match any existing column
978+
temporary <- make.unique(
979+
c(colnames(x), paste0(".modelbased_focal_", seq_along(protected)))
980+
)[-seq_along(colnames(x))]
981+
stats::setNames(protected, temporary)
982+
}
983+
984+
922985
# This function ensures that the formatted object still has all relevant
923986
# information saved as attributes
924987

‎tests/testthat/test-brms.R‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,16 @@ test_that("estimate_means - brms", {
2323
estim2 <- estimate_means(model, iterations = 200, backend = "marginaleffects")
2424
expect_identical(attributes(estim2)$iterations, 200)
2525

26-
expect_true(all(abs(estim1$Median - estim2$Median) > 0.001))
26+
# a random subsample of 200 draws gives other medians than all draws. The
27+
# differences can be smaller than 0.001, so we only test that they differ.
28+
expect_false(isTRUE(all.equal(estim1$Median, estim2$Median, tolerance = 0)))
29+
estim3 <- estimate_means(
30+
model,
31+
iterations = 200,
32+
keep_iterations = TRUE,
33+
backend = "marginaleffects"
34+
)
35+
expect_length(grep("^iter_", colnames(estim3)), 200)
2736
})
2837

2938
test_that("estimate_relation - brms", {
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
skip_on_cran()
2+
skip_if_not_installed("marginaleffects", minimum_version = "0.29.0")
3+
4+
# Focal variables can have names that insight, parameters or modelbased use
5+
# for their own columns. Each test renames the focal variable and compares the
6+
# output with the output for a neutral name (`safe_var`).
7+
8+
# names that `insight::standardize_names()` changes, or that modelbased drops
9+
# by name
10+
focal_reported_names <- c(
11+
"type", "p", "CI", "S", "s.value", "predicted", "conf.level", "df_residual",
12+
"df_error", "response", "n", "value", "component", "effect", "weight",
13+
"weights", "sumsq", "meansq", "std.dev", "y.level", "hypothesis", "Median",
14+
"Mean", "MAP", "Dxy", "rho", "r", "tau", "Difference", "Estimate", "F",
15+
"Chi2", "chisq", "Chisq", "W"
16+
)
17+
18+
focal_data <- function(name, numeric = FALSE) {
19+
d <- datasets::mtcars
20+
d$am_f <- factor(d$am)
21+
d[[name]] <- if (numeric) d$wt else factor(d$cyl)
22+
d
23+
}
24+
25+
# the formula environment is the calling function, so that the data `d` is
26+
# found there and not in the global environment
27+
focal_formula <- function(rhs, env = parent.frame()) {
28+
stats::as.formula(paste("mpg ~", rhs), env = env)
29+
}
30+
31+
focal_calls <- list(
32+
means = function(name) {
33+
d <- focal_data(name)
34+
m <- stats::lm(focal_formula(paste0("`", name, "` + hp")), data = d)
35+
estimate_means(m, name)
36+
},
37+
means_two = function(name) {
38+
d <- focal_data(name)
39+
m <- stats::lm(focal_formula(paste0("`", name, "` * am_f")), data = d)
40+
estimate_means(m, c("am_f", name))
41+
},
42+
slopes_by = function(name) {
43+
d <- focal_data(name)
44+
m <- stats::lm(focal_formula(paste0("`", name, "` * wt")), data = d)
45+
estimate_slopes(m, "wt", by = name)
46+
},
47+
contrasts_by = function(name) {
48+
d <- focal_data(name)
49+
m <- stats::lm(focal_formula(paste0("`", name, "` * am_f")), data = d)
50+
estimate_contrasts(m, "am_f", by = name)
51+
},
52+
contrasts = function(name) {
53+
d <- focal_data(name)
54+
m <- stats::lm(focal_formula(paste0("`", name, "` + hp")), data = d)
55+
estimate_contrasts(m, name)
56+
}
57+
)
58+
59+
# Returns NULL when `out` equals `ref` with "safe_var" replaced by `name`,
60+
# otherwise a short description of the first difference.
61+
focal_difference <- function(ref, out, name) {
62+
if (inherits(out, "error")) {
63+
return(paste("error:", conditionMessage(out)))
64+
}
65+
swap <- function(x) {
66+
if (is.character(x)) gsub("safe_var", name, x, fixed = TRUE) else x
67+
}
68+
ref_names <- swap(colnames(ref))
69+
if (!identical(ref_names, colnames(out))) {
70+
return(paste0(
71+
"columns [", toString(colnames(out)), "], expected [",
72+
toString(ref_names), "]"
73+
))
74+
}
75+
for (i in seq_along(ref)) {
76+
r <- ref[[i]]
77+
o <- out[[i]]
78+
if (!identical(class(r), class(o))) {
79+
return(paste0("column ", i, " has class ", class(o)[1]))
80+
}
81+
if (is.factor(r)) {
82+
levels(r) <- swap(levels(r))
83+
}
84+
r <- swap(r)
85+
if (!isTRUE(all.equal(r, o, check.attributes = FALSE))) {
86+
return(paste0("column ", i, " has other values"))
87+
}
88+
}
89+
for (a in c("focal_terms", "by", "trend", "contrast")) {
90+
if (!identical(swap(attr(ref, a, exact = TRUE)), attr(out, a, exact = TRUE))) {
91+
return(paste0("attribute ", a, " differs"))
92+
}
93+
}
94+
NULL
95+
}
96+
97+
expect_focal_names_match <- function(call_name, names) {
98+
call <- focal_calls[[call_name]]
99+
ref <- call("safe_var")
100+
for (name in names) {
101+
out <- tryCatch(suppressWarnings(call(name)), error = function(e) e)
102+
diff <- focal_difference(ref, out, name)
103+
expect_null(diff, label = paste0(call_name, "(\"", name, "\"): ", diff))
104+
}
105+
}
106+
107+
108+
test_that("estimate_means() keeps focal variables named like renamed columns", {
109+
names <- setdiff(focal_reported_names, c("Mean", "s.value"))
110+
expect_focal_names_match("means", names)
111+
expect_focal_names_match("means_two", names)
112+
})
113+
114+
115+
test_that("estimate_slopes() keeps by-variables named like renamed columns", {
116+
names <- setdiff(focal_reported_names, c("p", "s.value", "predicted"))
117+
expect_focal_names_match("slopes_by", names)
118+
})
119+
120+
121+
test_that("estimate_contrasts() keeps by-variables named like renamed columns", {
122+
names <- setdiff(
123+
focal_reported_names,
124+
c("p", "s.value", "hypothesis", "Difference")
125+
)
126+
expect_focal_names_match("contrasts_by", names)
127+
})
128+
129+
130+
test_that("focal variable names that worked before still work", {
131+
# names that matched on upstream main b964ea75, computed once from a list
132+
# of 99 candidate names (the reported names and other column, argument and
133+
# output names)
134+
matched_before <- list(
135+
means = c(
136+
"z", "rowidcf", "wts", "predicted_lo", "predicted_hi", "emmean",
137+
"prob", "rate", "lower.CL", "upper.CL", "asymp.LCL", "asymp.UCL",
138+
"Level1", "Level2", "Parameter", "Comparison", "Response", "Threshold",
139+
"Row", "Residuals", "Group", "Level", "Slope", "Coefficient",
140+
"Probability", "Contrast", "Component", "Effects", "Type", "Ratio",
141+
"iter_1", "x", "model", "data", "at", "ci", "trend", "predict",
142+
"comparison", "length", "range", "fun", "i", "out", "means", "grid"
143+
),
144+
means_two = c(
145+
"z", "rowidcf", "wts", "predicted_lo", "predicted_hi", "emmean",
146+
"prob", "rate", "lower.CL", "upper.CL", "asymp.LCL", "asymp.UCL",
147+
"Level1", "Level2", "Parameter", "Comparison", "Response", "Threshold",
148+
"Row", "Residuals", "Group", "Level", "Slope", "Coefficient",
149+
"Probability", "Contrast", "Component", "Effects", "Type", "Ratio",
150+
"iter_1", "x", "model", "data", "at", "ci", "trend", "predict",
151+
"comparison", "length", "range", "fun", "i", "out", "means", "grid"
152+
),
153+
slopes_by = c(
154+
"rowidcf", "wts", "emmean", "prob", "rate", "lower.CL", "upper.CL",
155+
"asymp.LCL", "asymp.UCL", "Level1", "Level2", "Response", "Threshold",
156+
"Row", "Residuals", "Group", "Level", "Probability", "Contrast",
157+
"Component", "Effects", "Type", "Ratio", "iter_1", "x", "model",
158+
"data", "at", "ci", "trend", "predict", "comparison", "length",
159+
"range", "fun", "i", "out", "means", "grid"
160+
),
161+
contrasts_by = c(
162+
"z", "rowidcf", "wts", "predicted_lo", "predicted_hi", "emmean",
163+
"prob", "rate", "lower.CL", "upper.CL", "asymp.LCL", "asymp.UCL",
164+
"Level1", "Level2", "Comparison", "Response", "Threshold", "Row",
165+
"Residuals", "Group", "Level", "Probability", "Contrast", "Component",
166+
"Effects", "Type", "Ratio", "iter_1", "x", "model", "data", "at", "ci",
167+
"trend", "predict", "comparison", "length", "range", "fun", "i", "out",
168+
"means", "grid"
169+
),
170+
contrasts = c(
171+
"z", "F", "Chi2", "chisq", "Chisq", "W", "Median", "Mean", "MAP",
172+
"Dxy", "rho", "r", "tau", "df_residual", "df_error", "std.dev",
173+
"s.value", "component", "effect", "predicted", "response", "y.level",
174+
"conf.level", "n", "type", "weight", "weights", "hypothesis", "value",
175+
"sumsq", "meansq", "Estimate", "rowidcf", "wts", "predicted_lo",
176+
"predicted_hi", "emmean", "prob", "rate", "lower.CL", "upper.CL",
177+
"asymp.LCL", "asymp.UCL", "Predicted", "CI", "Level1", "Level2",
178+
"Comparison", "Response", "Threshold", "Row", "Residuals", "Group",
179+
"Level", "Slope", "Coefficient", "Probability", "Contrast",
180+
"Component", "Effects", "Type", "Ratio", "S", "iter_1", "x", "model",
181+
"data", "at", "ci", "trend", "predict", "comparison", "length",
182+
"range", "fun", "i", "out", "means", "grid"
183+
)
184+
)
185+
for (call_name in names(matched_before)) {
186+
expect_focal_names_match(call_name, matched_before[[call_name]])
187+
}
188+
})
189+
190+
191+
test_that("estimate_means() keeps numeric focal variables for glm", {
192+
names <- setdiff(focal_reported_names, c("Mean", "s.value"))
193+
call <- function(name) {
194+
d <- focal_data(name, numeric = TRUE)
195+
d$vs <- datasets::mtcars$vs
196+
m <- stats::glm(
197+
stats::as.formula(paste0("vs ~ am_f * `", name, "`")),
198+
family = stats::binomial(),
199+
data = d
200+
)
201+
estimate_means(m, c("am_f", name))
202+
}
203+
ref <- suppressWarnings(call("safe_var"))
204+
for (name in names) {
205+
out <- tryCatch(suppressWarnings(call(name)), error = function(e) e)
206+
diff <- focal_difference(ref, out, name)
207+
expect_null(diff, label = paste0("glm(\"", name, "\"): ", diff))
208+
}
209+
})
210+
211+
212+
test_that("estimate_means() keeps focal variables with custom newdata", {
213+
# the values in `newdata` are not part of the generated data grid
214+
names <- setdiff(focal_reported_names, c("Mean", "s.value"))
215+
call <- function(name) {
216+
d <- focal_data(name, numeric = TRUE)
217+
m <- stats::lm(focal_formula(paste0("`", name, "` + hp")), data = d)
218+
newdata <- data.frame(x = c(2.123, 4.567), hp = 150)
219+
colnames(newdata)[1] <- name
220+
estimate_means(m, by = name, newdata = newdata)
221+
}
222+
ref <- call("safe_var")
223+
for (name in names) {
224+
out <- tryCatch(suppressWarnings(call(name)), error = function(e) e)
225+
diff <- focal_difference(ref, out, name)
226+
expect_null(diff, label = paste0("newdata(\"", name, "\"): ", diff))
227+
}
228+
})

‎tests/testthat/test-visualisation_recipe.R‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,9 @@ test_that("visualization_recipe, emmeans, recipes that do not change", {
434434
expect_null(out$color)
435435
.expect_same_print(x)
436436
}
437+
})
438+
439+
437440
test_that("visualization_recipe, informative error when nothing goes on x-axis", {
438441
# intercept-only model
439442
model <- lm(qsec ~ 1, data = mtcars)

0 commit comments

Comments
 (0)