Skip to contents

Terminology

The overall idea of “marginal effects” is too provide tools to better interpret the results of a model by estimating several quantities at the margins. However, it has been implemented in many different ways by different ways and there is a bunch of quasi-synonyms for the idea of “marginal effects”: statistical effects, marginal effects, marginal means, contrasts, marginal slopes, conditional effects, conditional marginal effects, marginal effects at the mean, and many other similarly-named ideas.

In broom.helpers, we tried to adopt a terminology consistent with the {marginaleffects} package, first released in September 2021, and with Andrew Heiss’ Marginalia blog post published in May 2022.

Adjusted Predictions correspond to the outcome predicted by a fitted model on a specified scale for a given combination of values of the predictor variables, such as their observed values, their means, or factor levels (a.k.a. “reference grid”). When prediction are averaged according to a specific regressor, we will then refer to Marginal Predictions.

Marginal Contrasts are referring to a comparison (e.g. difference) of the outcome for a certain regressor, considering “meaningfully” or “typical” values for the other predictors (at the mean/mode, at custom values, averaged over observed values…). Contrasts could be computed for categorical variables (e.g. difference between two specific levels) or for continuous variables (change in the outcome for a certain change of the regressor).

Marginal Effects / Slopes are defined for continuous variables as a partial derivative (slope) of the regression equation with respect to a regressor of interest. Put differently, the marginal effect is the slope of the prediction function, measured at a specific value of the regressor of interest. In scientific practice, the marginal effects fall in the same toolbox as the marginal contrasts.

Marginal Means are adjusted predictions of a model, averaged across a “reference grid” of categorical predictors. They are similar to marginal predictions, but with subtle differences.

broom.helpers embed several custom tidiers to compute such quantities and to return a tibble compatible with tidy_plus_plus() and all others broom.helpers’s tidy_*() function. Therefore, it is possible to produce nicely formatted tables with gtsummary::tbl_regression() or forest plots with ggstats::ggcoef_model().

Data preparation

Let’s consider the trial dataset from the gtsummary package and build a logistic regression model with two categorical predictors (trt and stage) and two continuous predictor (marker and age). We will include an interaction between trt and marker and polynomial terms for age (i.e. age and age^2).

library(broom.helpers)
library(gtsummary)
library(dplyr)
d <- trial |>
  filter(complete.cases(response, trt, marker, grade, age))

mod <- glm(
  response ~ trt * marker + stage + poly(age, 2),
  data = d,
  family = binomial
)
mod |>
  tbl_regression(
    exponentiate = TRUE,
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic OR1 95% CI1 p-value
Chemotherapy Treatment


    Drug A
    Drug B 1.08 0.40, 2.98 0.9
Marker Level (ng/mL) 1.26 0.73, 2.17 0.4
T Stage


    T1
    T2 0.44 0.17, 1.10 0.084
    T3 0.85 0.33, 2.18 0.7
    T4 0.66 0.26, 1.65 0.4
Age in years


    Age in years 41.7 0.50, 4,791 0.11
    Age in years² 1.04 0.01, 94.1 >0.9
Chemotherapy Treatment * Marker Level (ng/mL)


    Drug B * Marker Level (ng/mL) 1.25 0.59, 2.68 0.6
1 OR = Odds Ratio, CI = Confidence Interval

Marginal Predictions

Marginal Predictions at the Mean

A first approach to better understand / interpret the model consists to predict the value of a regressor, on the model scale, at “typical values” of the other regressors. The estimates are therefore easier to interpret, as they are expressed on the the scale of the outcome (here, for a binary logistic regression, as probabilities). The differences observed between the predictions at different modalities will depend only on the “effect” of that regressor as the others regressors will be fixed at the same “typical values”. However, all packages do not use the same definition of “typical values”.

the {effects}’s approach

The effects package offer an effects::Effect() function to compute marginal predictions at typical values. Although the function is named Effect(), the produced estimates are marginal predictions according to the terminology presented at the beginning of this vignette.

library(effects, quietly = TRUE)
#> lattice theme set by effectsTheme()
#> See ?effectsTheme for details.
e <- Effect("stage", mod)
e
#> 
#>  stage effect
#> stage
#>        T1        T2        T3        T4 
#> 0.3866154 0.2179846 0.3501056 0.2938566
plot(e)

To understand what are the “typical values” used by effects::Effect(), let’s have a look at the model matrix generated by the package and used for predictions.

e$model.matrix
#>   (Intercept) trtDrug B    marker stageT2 stageT3 stageT4 poly(age, 2)1
#> 1           1 0.5202312 0.9191792       0       0       0 -2.228704e-16
#> 2           1 0.5202312 0.9191792       1       0       0 -2.228704e-16
#> 3           1 0.5202312 0.9191792       0       1       0 -2.228704e-16
#> 4           1 0.5202312 0.9191792       0       0       1 -2.228704e-16
#>   poly(age, 2)2 trtDrug B:marker
#> 1   -0.05568232        0.4781857
#> 2   -0.05568232        0.4781857
#> 3   -0.05568232        0.4781857
#> 4   -0.05568232        0.4781857
#> attr(,"assign")
#> [1] 0 1 2 3 3 3 4 4 5
#> attr(,"contrasts")
#> attr(,"contrasts")$trt
#> [1] "contr.treatment"
#> 
#> attr(,"contrasts")$stage
#> [1] "contr.treatment"

The other continuous regressors are set to their observed mean while the other categorical regressors are weighted according to their observed proportions. Somehow, an artificial “averaged” individual is created, of mean age and mean marker level, and being partly receiving Drug A and Drug B. And then, we predict the probability of response if this individual would be in stage T1, T2, T3 or T4.

For a continuous variable, effects::Effect() will consider several values of the regressor (based on the range of observed values) to estimate marginal predictions at these different values.

e2 <- Effect("age", mod)
e2
#> 
#>  age effect
#> age
#>         6        30        40        60        80 
#> 0.1664397 0.2392447 0.2760557 0.3606351 0.4568567
plot(e2)

The effects::allEffects() will build all marginal predictions of all regressors, taking into account eventual interactions within the model.

allEffects(mod)
#>  model: response ~ trt * marker + stage + poly(age, 2)
#> 
#>  stage effect
#> stage
#>        T1        T2        T3        T4 
#> 0.3866154 0.2179846 0.3501056 0.2938566 
#> 
#>  age effect
#> age
#>         6        30        40        60        80 
#> 0.1664397 0.2392447 0.2760557 0.3606351 0.4568567 
#> 
#>  trt*marker effect
#>         marker
#> trt          0.005         1         2         3         4
#>   Drug A 0.2338204 0.2776269 0.3264017 0.3792467 0.4351208
#>   Drug B 0.2479065 0.3408371 0.4484189 0.5610548 0.6677329
plot(allEffects(mod))

It is also possible to generate similar plots with ggeffects::ggeffect(). Please note that ggeffects::ggeffect() will consider, by default, only individual variables from the model and not existing interactions.

mod |>
  ggeffects::ggeffect() |>
  lapply(plot) |>
  patchwork::wrap_plots()

To generate a tibble of these results formatted in a way that it could be use with tidy_plus_plus() and other broom.helpers’s tidy_*() helpers, broom.helpers provides a tidy_all_effects() tieder.

tidy_all_effects(mod)
#>       variable         term  estimate  std.error   conf.low conf.high
#> 1        stage           T1 0.3866154 0.08138561 0.24338626 0.5525749
#> 2        stage           T2 0.2179846 0.06122617 0.12116925 0.3604304
#> 3        stage           T3 0.3501056 0.08749758 0.20225147 0.5337316
#> 4        stage           T4 0.2938566 0.07895328 0.16486445 0.4673012
#> 5  poly(age,2)            6 0.1664397 0.15368239 0.02226625 0.6364570
#> 6  poly(age,2)           30 0.2392447 0.05197357 0.15232129 0.3549982
#> 7  poly(age,2)           40 0.2760557 0.04346297 0.19934983 0.3686854
#> 8  poly(age,2)           60 0.3606351 0.05152656 0.26686307 0.4663957
#> 9  poly(age,2)           80 0.4568567 0.16663622 0.18404077 0.7582667
#> 10  trt:marker Drug A:0.005 0.2338204 0.07478345 0.11867604 0.4088555
#> 11  trt:marker Drug B:0.005 0.2479065 0.06818623 0.13864532 0.4029880
#> 12  trt:marker     Drug A:1 0.2776269 0.05673207 0.18083471 0.4008740
#> 13  trt:marker     Drug B:1 0.3408371 0.05901662 0.23605720 0.4638846
#> 14  trt:marker     Drug A:2 0.3264017 0.08136674 0.19002540 0.5002087
#> 15  trt:marker     Drug B:2 0.4484189 0.09615134 0.27508336 0.6352624
#> 16  trt:marker     Drug A:3 0.3792467 0.13846045 0.16171930 0.6592599
#> 17  trt:marker     Drug B:3 0.5610548 0.15197028 0.27607502 0.8107519
#> 18  trt:marker     Drug A:4 0.4351208 0.20665349 0.12910815 0.8000955
#> 19  trt:marker     Drug B:4 0.6677329 0.19316496 0.26727889 0.9171600

It is therefore very easy to produce a nicely formatted table with gtsummary::tbl_regression() or a forest plot with ggstats::ggcoef_model().

mod |>
  tbl_regression(
    tidy_fun = tidy_all_effects,
    estimate_fun = scales::label_percent(accuracy = .1)
  ) |>
  bold_labels()
Characteristic Marginal Predictions at the Mean 95% CI1
T Stage

    T1 38.7% 24.3%, 55.3%
    T2 21.8% 12.1%, 36.0%
    T3 35.0% 20.2%, 53.4%
    T4 29.4% 16.5%, 46.7%
Chemotherapy Treatment * Marker Level (ng/mL)

    Drug A * 0.005 23.4% 11.9%, 40.9%
    Drug B * 0.005 24.8% 13.9%, 40.3%
    Drug A * 1 27.8% 18.1%, 40.1%
    Drug B * 1 34.1% 23.6%, 46.4%
    Drug A * 2 32.6% 19.0%, 50.0%
    Drug B * 2 44.8% 27.5%, 63.5%
    Drug A * 3 37.9% 16.2%, 65.9%
    Drug B * 3 56.1% 27.6%, 81.1%
    Drug A * 4 43.5% 12.9%, 80.0%
    Drug B * 4 66.8% 26.7%, 91.7%
1 CI = Confidence Interval
ggstats::ggcoef_model(
  mod,
  tidy_fun = tidy_all_effects,
  vline = FALSE
)

the {marginaleffects}’s approach at the Mean

The marginaleffects package allows to compute marginal predictions “at the mean”, i.e. by considering the mean of the other continuous regressors and the mode (i.e. the most frequent observed modality) of categorical regressors. For that, we should call marginaleffects::predictions() with newdata = "mean".

library(marginaleffects)
predictions(
  mod,
  variables = "stage",
  newdata = "mean",
  by = "stage"
)
#> 
#>  stage Estimate Pr(>|z|)   S 2.5 % 97.5 %
#>     T1    0.419  0.39097 1.4 0.254  0.604
#>     T2    0.242  0.00282 8.5 0.131  0.403
#>     T4    0.322  0.06889 3.9 0.176  0.514
#>     T3    0.381  0.24194 2.0 0.215  0.581
#> 
#> Columns: stage, estimate, p.value, s.value, conf.low, conf.high 
#> Type:  invlink(link)

Four “mean individuals” were generated, with just the value of stage being different from one individual to the other, before predicting the probability of response.

For a continuous variable, predictions will be made, by default, at Tukey’s five numbers, i.e. the minimum, the first quartile, the median, the third quartile and the maximum.

predictions(
  mod,
  variables = "age",
  newdata = "mean",
  by = "age"
)
#> 
#>  age Estimate Pr(>|z|)    S  2.5 % 97.5 %
#>    6    0.127  0.10655  3.2 0.0139  0.602
#>   37    0.208  < 0.001 10.2 0.1072  0.365
#>   47    0.242  0.00286  8.5 0.1310  0.403
#>   57    0.280  0.01347  6.2 0.1550  0.451
#>   83    0.395  0.62840  0.7 0.1042  0.786
#> 
#> Columns: age, estimate, p.value, s.value, conf.low, conf.high 
#> Type:  invlink(link)

broom.helpers provides a global tidier tidy_marginal_predictions() to compute the marginal predictions for each variable or combination of variables before stacking them in a unique tibble. You should specify newdata = "mean" to get marginal predictions at the mean. By default, as effects::allEffects(), it will consider all higher order combinations of variables (as identified with model_list_higher_order_variables()).

mod |>
  model_list_higher_order_variables()
#> [1] "stage"      "age"        "trt:marker"
mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_predictions,
    newdata = "mean",
    estimate_fun = scales::label_percent(accuracy = .1),
    label = list(age = "Age in years")
  ) |>
  modify_column_hide("p.value") |>
  bold_labels()
Characteristic Marginal Predictions at the Mean 95% CI1
T Stage

    T1 41.9% 25.4%, 60.4%
    T2 24.2% 13.1%, 40.3%
    T3 38.1% 21.5%, 58.1%
    T4 32.2% 17.6%, 51.4%
Age in years

    6 12.7% 1.4%, 60.2%
    37 20.8% 10.7%, 36.5%
    47 24.2% 13.1%, 40.3%
    57 28.0% 15.5%, 45.1%
    83 39.5% 10.4%, 78.6%
Chemotherapy Treatment * Marker Level (ng/mL)

    Drug A * 0.005 16.3% 6.6%, 35.0%
    Drug A * 0.215 17.0% 7.3%, 34.6%
    Drug A * 0.662 18.5% 8.9%, 34.6%
    Drug A * 1.406 21.3% 10.7%, 37.7%
    Drug A * 3.874 32.4% 8.3%, 71.7%
    Drug B * 0.005 17.4% 7.6%, 35.2%
    Drug B * 0.215 18.8% 8.8%, 35.9%
    Drug B * 0.662 22.1% 11.5%, 38.2%
    Drug B * 1.406 28.4% 15.7%, 46.0%
    Drug B * 3.874 54.8% 18.9%, 86.4%
1 CI = Confidence Interval

Simply specify variables_list = "no_interaction" to compute marginal predictions for each individual variable without considering existing interactions.

mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_predictions,
    variables_list = "no_interaction",
    newdata = "mean",
    estimate_fun = scales::label_percent(accuracy = .1),
    label = list(age = "Age in years")
  ) |>
  modify_column_hide("p.value") |>
  bold_labels()
Characteristic Marginal Predictions at the Mean 95% CI1
Chemotherapy Treatment

    Drug A 19.4% 9.7%, 35.1%
    Drug B 24.2% 13.1%, 40.3%
Marker Level (ng/mL)

    0.005 17.4% 7.6%, 35.2%
    0.215 18.8% 8.8%, 35.9%
    0.662 22.1% 11.5%, 38.2%
    1.406 28.4% 15.6%, 46.0%
    3.874 54.8% 18.9%, 86.4%
T Stage

    T1 41.9% 25.4%, 60.4%
    T2 24.2% 13.1%, 40.3%
    T3 38.1% 21.5%, 58.1%
    T4 32.2% 17.6%, 51.4%
Age in years

    6 12.7% 1.4%, 60.2%
    37 20.8% 10.7%, 36.5%
    47 24.2% 13.1%, 40.3%
    57 28.0% 15.5%, 45.1%
    83 39.5% 10.4%, 78.6%
1 CI = Confidence Interval

broom.helpers also include plot_marginal_predictions() to generate a list of plots to visualize all marginal predictions. Use patchwork::wrap_plots() to combine all plots together.

p <- mod |>
  plot_marginal_predictions(newdata = "mean") |>
  patchwork::wrap_plots() &
  ggplot2::scale_y_continuous(
    labels = scales::label_percent(),
    limits = c(-0.2, 1)
  )
p[[2]] <- p[[2]] + ggplot2::xlab("Age in years")
p + patchwork::plot_annotation(
  title = "Marginal Predictions at the Mean"
)

p <- mod |>
  plot_marginal_predictions(
    "no_interaction",
    newdata = "mean"
  ) |>
  patchwork::wrap_plots() &
  ggplot2::scale_y_continuous(
    labels = scales::label_percent(),
    limits = c(-0.2, 1)
  )
p[[4]] <- p[[4]] + ggplot2::xlab("Age in years")
p + patchwork::plot_annotation(
  title = "Marginal Predictions at the Mean"
)

Alternatively, you can use ggstats::ggcoef_model(), using tidy_args to pass arguments to broom.helpers::tidy_marginal_predictions().

ggstats::ggcoef_model(
  mod,
  tidy_fun = tidy_marginal_predictions,
  tidy_args = list(newdata = "mean", variables_list = "no_interaction"),
  vline = FALSE,
  show_p_values = FALSE,
  signif_stars = FALSE,
  significance = NULL,
  variable_labels = c(age = "Age in years")
)

Average Marginal Predictions

Instead of averaging observed values to generate “typical observations” before predicting the outcome, an alternative consists to predict the outcome on the overall observed values before averaging the results.

More precisely, the purpose is to adopt a counterfactual approach. Let’s take an example. Let’s consider d our observed data used to estimate the model. We can make a copy of this dataset, where all variables would be identical, but considering that all individuals have received Drug A. Similarly, we could generate a dataset where all individuals would have received Drug B.

dA <- d |>
  mutate(trt = "Drug A")
dB <- d |>
  mutate(trt = "Drug B")

We can now predict the outcome for all observations in dA and then compute the average, and similarly with dB.

predict(mod, newdata = dA, type = "response") |> mean()
#> [1] 0.2830866
predict(mod, newdata = dB, type = "response") |> mean()
#> [1] 0.3431492

We, then, obtain Average Marginal Predictions for trt. The same results could be computed with marginaleffects::avg_predictions(). Note that the counterfactual approach corresponds to the default behavior when no value is provided to newdata.

avg_predictions(mod, variables = "trt", by = "trt", type = "response")
#> 
#>     trt Estimate Std. Error    z Pr(>|z|)    S 2.5 % 97.5 %
#>  Drug A    0.283     0.0484 5.85   <0.001 27.6 0.188  0.378
#>  Drug B    0.343     0.0490 7.01   <0.001 38.6 0.247  0.439
#> 
#> Columns: trt, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high 
#> Type:  response

Important: since version 0.10.0 of marginaleffects, we had to add type = "response" to get this result: for glm models, predictions are done on the response scale, before being averaged. If type are not specified, predictions will be made on the link scale, before being averaged and then back transformed on the response scale. Thus, the average prediction may not be exactly identical to the average of predictions.

avg_predictions(mod, variables = "trt", by = "trt")
#> 
#>     trt Estimate Pr(>|z|)    S 2.5 % 97.5 %
#>  Drug A    0.274  < 0.001 13.1 0.187  0.383
#>  Drug B    0.333  0.00279  8.5 0.241  0.441
#> 
#> Columns: trt, estimate, p.value, s.value, conf.low, conf.high 
#> Type:  invlink(link)
b <- binomial()
predict(mod, newdata = dA, type = "link") |>
  mean() |>
  b$linkinv()
#> [1] 0.2743123
predict(mod, newdata = dB, type = "link") |>
  mean() |>
  b$linkinv()
#> [1] 0.3331447

We can use tidy_marginal_predictions() to get average marginal predictions for all variables and plot_marginal_predictions() for a visual representation.

mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_predictions,
    type = "response",
    variables_list = "no_interaction",
    estimate_fun = scales::label_percent(accuracy = .1),
    label = list(age = "Age in years")
  ) |>
  modify_column_hide("p.value") |>
  bold_labels()
Characteristic Average Marginal Predictions 95% CI1
Chemotherapy Treatment

    Drug A 28.3% 18.8%, 37.8%
    Drug B 34.3% 24.7%, 43.9%
Marker Level (ng/mL)

    0.005 25.0% 15.9%, 34.0%
    0.215 26.3% 18.0%, 34.6%
    0.662 29.3% 22.3%, 36.4%
    1.406 34.8% 26.8%, 42.8%
    3.874 54.5% 28.5%, 80.4%
T Stage

    T1 38.9% 24.9%, 52.9%
    T2 22.5% 11.2%, 33.8%
    T3 35.4% 20.0%, 50.8%
    T4 30.0% 16.1%, 43.8%
Age in years

    6 17.4% -12.9%, 47.6%
    37 27.1% 18.8%, 35.4%
    47 30.9% 22.4%, 39.5%
    57 35.1% 26.0%, 44.2%
    83 47.1% 10.0%, 84.2%
1 CI = Confidence Interval
mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_predictions,
    type = "response",
    estimate_fun = scales::label_percent(accuracy = .1),
    label = list(age = "Age in years")
  ) |>
  modify_column_hide("p.value") |>
  bold_labels()
Characteristic Average Marginal Predictions 95% CI1
T Stage

    T1 38.9% 24.9%, 52.9%
    T2 22.5% 11.2%, 33.8%
    T3 35.4% 20.0%, 50.8%
    T4 30.0% 16.1%, 43.8%
Age in years

    6 17.4% -12.9%, 47.6%
    37 27.1% 18.8%, 35.4%
    47 30.9% 22.4%, 39.5%
    57 35.1% 26.0%, 44.2%
    83 47.1% 10.0%, 84.2%
Chemotherapy Treatment * Marker Level (ng/mL)

    Drug A * 0.005 24.2% 10.9%, 37.6%
    Drug A * 0.215 25.1% 13.0%, 37.3%
    Drug A * 0.662 27.0% 16.9%, 37.2%
    Drug A * 1.406 30.5% 19.9%, 41.0%
    Drug A * 3.874 43.1% 5.7%, 80.5%
    Drug B * 0.005 25.6% 13.4%, 37.9%
    Drug B * 0.215 27.4% 16.2%, 38.7%
    Drug B * 0.662 31.5% 21.6%, 41.3%
    Drug B * 1.406 38.8% 26.9%, 50.7%
    Drug B * 3.874 64.9% 29.3%, 100.6%
1 CI = Confidence Interval
p <- plot_marginal_predictions(mod, type = "response") |>
  patchwork::wrap_plots(ncol = 2) &
  ggplot2::scale_y_continuous(
    labels = scales::label_percent(),
    limits = c(-0.2, 1)
  )
p[[2]] <- p[[2]] + ggplot2::xlab("Age in years")
p + patchwork::plot_annotation(
  title = "Average Marginal Predictions"
)

Marginal Means and Marginal Predictions at Marginal Means

The emmeans package adopted, by default, another approach based on marginal means or estimated marginal means (a.k.a. emmeans).

It will consider a grid of predictors with all combinations of the observed modalities of the categorical variables and fixing continuous variables at their means.

Let’s call marginaleffects::predictions() with newdata = "marginalmeans".

pred <- predictions(mod, newdata = "marginalmeans")
pred
#> 
#>  Estimate Pr(>|z|)    S  2.5 % 97.5 %
#>     0.353  0.11462  3.1 0.2042  0.537
#>     0.194  < 0.001 10.8 0.0969  0.351
#>     0.318  0.07405  3.8 0.1678  0.519
#>     0.265  0.01648  5.9 0.1351  0.453
#>     0.419  0.39097  1.4 0.2541  0.604
#>     0.242  0.00282  8.5 0.1308  0.403
#>     0.381  0.24194  2.0 0.2147  0.581
#>     0.322  0.06889  3.9 0.1760  0.514
#> 
#> Columns: rowid, estimate, p.value, s.value, conf.low, conf.high, trt, marker, stage, age, response 
#> Type:  invlink(link)

As we can see, pred contains 8 rows, one for each combination of trt (2 modalities) and stage (4 modalities). age is fixed at its mean (mean(d$age)) as well as marker.

Let’s compute the average predictions for each value of stage.

pred |>
  group_by(stage) |>
  summarise(mean(estimate))
#> # A tibble: 4 × 2
#>   stage `mean(estimate)`
#>   <fct>            <dbl>
#> 1 T1               0.386
#> 2 T2               0.218
#> 3 T3               0.349
#> 4 T4               0.294

We can check that we obtain the same estimates as with emmeans::emmeans().

emmeans::emmeans(mod, "stage", type = "response")
#>  stage  prob     SE  df asymp.LCL asymp.UCL
#>  T1    0.385 0.0813 Inf     0.242     0.551
#>  T2    0.217 0.0611 Inf     0.120     0.359
#>  T3    0.349 0.0874 Inf     0.201     0.532
#>  T4    0.293 0.0788 Inf     0.164     0.466
#> 
#> Results are averaged over the levels of: trt 
#> Confidence level used: 0.95 
#> Intervals are back-transformed from the logit scale

These estimates could be computed, for each categorical variable, with marginaleffects::prediction() using datagrid(grid_type = "balanced")1.

predictions(mod,
  by = "trt",
  newdata = datagrid(grid_type = "balanced")
)
#> 
#>     trt Estimate Pr(>|z|)    S 2.5 % 97.5 %
#>  Drug A    0.278   <0.001 10.1 0.180  0.403
#>  Drug B    0.337   0.0104  6.6 0.233  0.460
#> 
#> Columns: trt, estimate, p.value, s.value, conf.low, conf.high 
#> Type:  invlink(link)
predictions(mod,
  by = "stage",
  newdata = datagrid(grid_type = "balanced")
)
#> 
#>  stage Estimate Pr(>|z|)    S 2.5 % 97.5 %
#>     T1    0.385   0.1734  2.5 0.242  0.551
#>     T2    0.217   <0.001 11.4 0.120  0.359
#>     T3    0.349   0.1047  3.3 0.201  0.532
#>     T4    0.293   0.0205  5.6 0.164  0.466
#> 
#> Columns: stage, estimate, p.value, s.value, conf.low, conf.high 
#> Type:  invlink(link)

Marginal means are defined only for categorical variables. However, we can define marginal predictions at marginal means for both continuous and categorical variables, calling tidy_marginal_predictions() with the option newdata = "marginalmeans". For categorical variables, marginal predictions at marginal means will be equal to marginal means.

mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_predictions,
    newdata = "marginalmeans",
    variables_list = "no_interaction",
    estimate_fun = scales::label_percent(accuracy = .1),
    label = list(age = "Age in years")
  ) |>
  modify_column_hide("p.value") |>
  bold_labels()
Characteristic Marginal Predictions at Marginal Means 95% CI1
Chemotherapy Treatment

    Drug A 27.8% 18.0%, 40.3%
    Drug B 33.7% 23.3%, 46.0%
Marker Level (ng/mL)

    0.005 24.5% 15.3%, 36.8%
    0.215 25.8% 17.0%, 37.2%
    0.662 28.9% 20.6%, 38.8%
    1.406 34.4% 25.2%, 44.8%
    3.874 54.9% 27.9%, 79.3%
T Stage

    T1 38.5% 24.2%, 55.1%
    T2 21.7% 12.0%, 35.9%
    T3 34.9% 20.1%, 53.2%
    T4 29.3% 16.4%, 46.6%
Age in years

    6 16.9% 2.3%, 64.0%
    37 26.8% 19.0%, 36.3%
    47 30.7% 22.5%, 40.4%
    57 35.1% 26.2%, 45.1%
    83 47.6% 16.1%, 81.1%
1 CI = Confidence Interval

Alternative approaches

Marginal Predictions at the Median

They are similar to marginal predictions at the mean, except that continuous variables are fixed at the median of observed values (and categorical variables at their mode). Simply use newdata = "median".

mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_predictions,
    newdata = "median",
    variables_list = "no_interaction",
    estimate_fun = scales::label_percent(accuracy = .1),
    label = list(age = "Age in years")
  ) |>
  modify_column_hide("p.value") |>
  bold_labels()
Characteristic Marginal Predictions 95% CI1
Chemotherapy Treatment

    Drug A 18.5% 8.9%, 34.6%
    Drug B 22.1% 11.5%, 38.3%
Marker Level (ng/mL)

    0.005 17.4% 7.6%, 35.2%
    0.215 18.8% 8.8%, 35.9%
    0.662 22.1% 11.5%, 38.3%
    1.406 28.5% 15.7%, 46.0%
    3.874 54.9% 18.9%, 86.4%
T Stage

    T1 39.1% 23.2%, 57.7%
    T2 22.1% 11.5%, 38.3%
    T3 35.5% 19.3%, 55.7%
    T4 29.8% 16.0%, 48.7%
Age in years

    6 11.5% 1.2%, 57.6%
    37 19.0% 9.4%, 34.6%
    47 22.1% 11.5%, 38.3%
    57 25.7% 13.7%, 42.9%
    83 36.8% 9.3%, 76.6%
1 CI = Confidence Interval

the ggeffects::ggpredict()’s approach

The ggeffects package offers a ggeffects::ggpredict() function which generates marginal predictions at the mean of continuous variables and at the first modality (used as reference) of categorical variables. broom.helpers provides a tidy_ggpredict() tidier.

mod |>
  tbl_regression(
    tidy_fun = tidy_ggpredict,
    estimate_fun = scales::label_percent(accuracy = .1),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
#> Warning: Some of the focal terms are of type `character`. This may lead to
#>   unexpected results. It is recommended to convert these variables to
#>   factors before fitting the model.
#>   The following variables in are of type character: `trt`
Characteristic Marginal Predictions 95% CI1
Chemotherapy Treatment

    Drug A 35.3% 20.4%, 53.7%
    Drug B 41.9% 25.4%, 60.4%
Marker Level (ng/mL)

    0.005 32.3% 16.7%, 53.1%
    0.013 32.3% 16.8%, 53.2%
    0.015 32.4% 16.8%, 53.2%
    0.021 32.4% 16.8%, 53.2%
    0.022 32.4% 16.9%, 53.2%
    0.039 32.6% 17.0%, 53.3%
    0.043 32.6% 17.1%, 53.3%
    0.045 32.7% 17.1%, 53.3%
    0.046 32.7% 17.1%, 53.3%
    0.056 32.8% 17.2%, 53.4%
    0.06 32.8% 17.2%, 53.4%
    0.062 32.8% 17.3%, 53.4%
    0.063 32.8% 17.3%, 53.4%
    0.066 32.9% 17.3%, 53.4%
    0.075 33.0% 17.4%, 53.5%
    0.081 33.0% 17.5%, 53.5%
    0.086 33.1% 17.5%, 53.5%
    0.092 33.1% 17.6%, 53.5%
    0.096 33.2% 17.6%, 53.6%
    0.105 33.3% 17.7%, 53.6%
    0.108 33.3% 17.7%, 53.6%
    0.124 33.5% 17.9%, 53.7%
    0.128 33.5% 17.9%, 53.7%
    0.131 33.5% 18.0%, 53.7%
    0.136 33.6% 18.0%, 53.8%
    0.141 33.6% 18.1%, 53.8%
    0.144 33.7% 18.1%, 53.8%
    0.153 33.7% 18.2%, 53.9%
    0.157 33.8% 18.2%, 53.9%
    0.16 33.8% 18.3%, 53.9%
    0.161 33.8% 18.3%, 53.9%
    0.169 33.9% 18.3%, 54.0%
    0.175 34.0% 18.4%, 54.0%
    0.177 34.0% 18.4%, 54.0%
    0.182 34.0% 18.5%, 54.0%
    0.205 34.3% 18.7%, 54.2%
    0.215 34.4% 18.8%, 54.2%
    0.22 34.4% 18.9%, 54.3%
    0.222 34.5% 18.9%, 54.3%
    0.229 34.5% 19.0%, 54.3%
    0.238 34.6% 19.0%, 54.4%
    0.239 34.6% 19.1%, 54.4%
    0.243 34.7% 19.1%, 54.4%
    0.25 34.7% 19.2%, 54.4%
    0.258 34.8% 19.3%, 54.5%
    0.266 34.9% 19.3%, 54.5%
    0.277 35.0% 19.4%, 54.6%
    0.29 35.1% 19.6%, 54.7%
    0.305 35.3% 19.7%, 54.8%
    0.308 35.3% 19.8%, 54.8%
    0.309 35.3% 19.8%, 54.8%
    0.325 35.5% 19.9%, 54.9%
    0.333 35.6% 20.0%, 55.0%
    0.352 35.8% 20.2%, 55.1%
    0.354 35.8% 20.2%, 55.1%
    0.358 35.9% 20.3%, 55.1%
    0.361 35.9% 20.3%, 55.2%
    0.37 36.0% 20.4%, 55.2%
    0.385 36.1% 20.5%, 55.3%
    0.386 36.1% 20.5%, 55.3%
    0.387 36.2% 20.6%, 55.4%
    0.389 36.2% 20.6%, 55.4%
    0.402 36.3% 20.7%, 55.5%
    0.408 36.4% 20.8%, 55.5%
    0.445 36.8% 21.1%, 55.8%
    0.475 37.1% 21.4%, 56.0%
    0.51 37.5% 21.8%, 56.3%
    0.511 37.5% 21.8%, 56.3%
    0.513 37.5% 21.8%, 56.3%
    0.531 37.7% 22.0%, 56.5%
    0.547 37.8% 22.1%, 56.6%
    0.583 38.2% 22.5%, 56.9%
    0.589 38.3% 22.5%, 57.0%
    0.592 38.3% 22.6%, 57.0%
    0.599 38.4% 22.6%, 57.1%
    0.611 38.5% 22.7%, 57.2%
    0.613 38.5% 22.8%, 57.2%
    0.615 38.6% 22.8%, 57.2%
    0.662 39.1% 23.2%, 57.7%
    0.667 39.1% 23.3%, 57.7%
    0.691 39.4% 23.5%, 57.9%
    0.702 39.5% 23.6%, 58.0%
    0.711 39.6% 23.7%, 58.1%
    0.717 39.7% 23.7%, 58.2%
    0.718 39.7% 23.7%, 58.2%
    0.719 39.7% 23.7%, 58.2%
    0.733 39.8% 23.8%, 58.3%
    0.737 39.9% 23.9%, 58.4%
    0.772 40.3% 24.2%, 58.7%
    0.803 40.6% 24.5%, 59.1%
    0.816 40.7% 24.6%, 59.2%
    0.831 40.9% 24.7%, 59.4%
    0.862 41.2% 25.0%, 59.7%
    0.895 41.6% 25.2%, 60.1%
    0.924 41.9% 25.5%, 60.4%
    0.929 42.0% 25.5%, 60.5%
    0.946 42.2% 25.6%, 60.7%
    0.976 42.5% 25.9%, 61.0%
    0.981 42.6% 25.9%, 61.1%
    1.041 43.2% 26.3%, 61.8%
    1.046 43.3% 26.4%, 61.9%
    1.061 43.4% 26.5%, 62.1%
    1.063 43.5% 26.5%, 62.1%
    1.075 43.6% 26.6%, 62.3%
    1.079 43.6% 26.6%, 62.3%
    1.087 43.7% 26.7%, 62.4%
    1.091 43.8% 26.7%, 62.5%
    1.107 44.0% 26.8%, 62.7%
    1.129 44.2% 27.0%, 63.0%
    1.133 44.3% 27.0%, 63.0%
    1.148 44.4% 27.1%, 63.2%
    1.156 44.5% 27.1%, 63.3%
    1.2 45.0% 27.4%, 63.9%
    1.206 45.1% 27.5%, 64.0%
    1.207 45.1% 27.5%, 64.0%
    1.225 45.3% 27.6%, 64.3%
    1.255 45.6% 27.8%, 64.7%
    1.306 46.2% 28.0%, 65.4%
    1.321 46.4% 28.1%, 65.6%
    1.354 46.7% 28.3%, 66.1%
    1.406 47.3% 28.6%, 66.8%
    1.418 47.5% 28.6%, 67.0%
    1.441 47.7% 28.8%, 67.3%
    1.479 48.1% 28.9%, 67.9%
    1.491 48.3% 29.0%, 68.1%
    1.527 48.7% 29.2%, 68.6%
    1.55 48.9% 29.3%, 68.9%
    1.628 49.8% 29.6%, 70.1%
    1.645 50.0% 29.7%, 70.4%
    1.658 50.2% 29.7%, 70.6%
    1.68 50.4% 29.8%, 70.9%
    1.709 50.7% 29.9%, 71.3%
    1.713 50.8% 29.9%, 71.4%
    1.739 51.1% 30.0%, 71.8%
    1.804 51.8% 30.2%, 72.8%
    1.869 52.5% 30.4%, 73.7%
    1.882 52.7% 30.4%, 73.9%
    1.892 52.8% 30.5%, 74.1%
    1.894 52.8% 30.5%, 74.1%
    1.941 53.4% 30.6%, 74.8%
    1.976 53.8% 30.7%, 75.3%
    1.985 53.9% 30.7%, 75.4%
    2.008 54.1% 30.8%, 75.8%
    2.032 54.4% 30.8%, 76.1%
    2.083 55.0% 30.9%, 76.9%
    2.124 55.4% 31.0%, 77.5%
    2.141 55.6% 31.1%, 77.7%
    2.19 56.2% 31.1%, 78.4%
    2.213 56.4% 31.2%, 78.7%
    2.238 56.7% 31.2%, 79.0%
    2.288 57.2% 31.3%, 79.7%
    2.345 57.9% 31.4%, 80.5%
    2.447 59.0% 31.5%, 81.8%
    2.522 59.8% 31.6%, 82.8%
    2.636 61.0% 31.7%, 84.1%
    2.702 61.8% 31.7%, 84.9%
    2.725 62.0% 31.7%, 85.1%
    2.767 62.4% 31.7%, 85.6%
    3.02 65.1% 31.8%, 88.2%
    3.062 65.5% 31.8%, 88.6%
    3.249 67.4% 31.8%, 90.2%
    3.642 71.2% 31.7%, 92.9%
    3.751 72.2% 31.6%, 93.6%
    3.874 73.3% 31.5%, 94.2%
T Stage

    T1 41.9% 25.4%, 60.4%
    T2 24.2% 13.1%, 40.3%
    T3 38.1% 21.5%, 58.1%
    T4 32.2% 17.6%, 51.4%
Age in years

    6 24.8% 3.4%, 75.6%
    9 25.9% 4.7%, 71.2%
    10 26.2% 5.2%, 69.8%
    17 28.9% 9.5%, 60.9%
    19 29.6% 11.0%, 59.0%
    20 30.0% 11.7%, 58.1%
    21 30.4% 12.5%, 57.3%
    23 31.3% 14.0%, 56.0%
    25 32.1% 15.4%, 55.0%
    26 32.5% 16.1%, 54.6%
    27 32.9% 16.8%, 54.4%
    28 33.3% 17.4%, 54.2%
    30 34.2% 18.6%, 54.1%
    31 34.6% 19.2%, 54.2%
    32 35.1% 19.7%, 54.3%
    34 35.9% 20.7%, 54.7%
    35 36.4% 21.1%, 55.0%
    36 36.8% 21.5%, 55.4%
    37 37.3% 21.9%, 55.7%
    38 37.7% 22.3%, 56.2%
    39 38.2% 22.7%, 56.6%
    40 38.7% 23.0%, 57.1%
    41 39.1% 23.4%, 57.5%
    42 39.6% 23.7%, 58.0%
    43 40.0% 24.0%, 58.5%
    44 40.5% 24.4%, 59.0%
    45 41.0% 24.7%, 59.5%
    46 41.5% 25.1%, 59.9%
    47 41.9% 25.4%, 60.4%
    48 42.4% 25.8%, 60.9%
    49 42.9% 26.2%, 61.4%
    50 43.4% 26.6%, 61.8%
    51 43.8% 26.9%, 62.3%
    52 44.3% 27.3%, 62.8%
    53 44.8% 27.7%, 63.2%
    54 45.3% 28.1%, 63.7%
    55 45.8% 28.5%, 64.1%
    56 46.3% 28.9%, 64.6%
    57 46.8% 29.2%, 65.1%
    58 47.3% 29.6%, 65.6%
    59 47.7% 29.9%, 66.2%
    60 48.2% 30.2%, 66.7%
    61 48.7% 30.5%, 67.3%
    62 49.2% 30.7%, 67.9%
    63 49.7% 30.9%, 68.6%
    64 50.2% 31.1%, 69.3%
    65 50.7% 31.2%, 70.1%
    66 51.2% 31.2%, 70.9%
    67 51.7% 31.2%, 71.7%
    68 52.2% 31.0%, 72.6%
    69 52.7% 30.9%, 73.6%
    70 53.2% 30.6%, 74.6%
    71 53.7% 30.3%, 75.6%
    74 55.2% 28.9%, 78.9%
    75 55.7% 28.3%, 80.0%
    76 56.2% 27.6%, 81.2%
    78 57.2% 26.1%, 83.4%
    83 59.6% 21.6%, 88.8%
1 CI = Confidence Interval
mod |>
  ggeffects::ggpredict() |>
  plot() |>
  patchwork::wrap_plots()
#> Warning: Some of the focal terms are of type `character`. This may lead to
#>   unexpected results. It is recommended to convert these variables to
#>   factors before fitting the model.
#>   The following variables in are of type character: `trt`

Marginal Contrasts

Now that we have a way to estimate marginal predictions, we can easily compute marginal contrasts, i.e. difference between marginal predictions.

Average Marginal Contrasts

Let’s consider first a categorical variable, e.g. stage. Average Marginal Predictions are obtained with marginaleffects::avg_predictions().

pred <- avg_predictions(mod, variables = "stage", by = "stage", type = "response")
pred
#> 
#>  stage Estimate Std. Error    z Pr(>|z|)    S 2.5 % 97.5 %
#>     T1    0.389     0.0715 5.44   <0.001 24.2 0.249  0.529
#>     T2    0.225     0.0577 3.91   <0.001 13.4 0.112  0.338
#>     T4    0.300     0.0706 4.25   <0.001 15.5 0.161  0.438
#>     T3    0.354     0.0786 4.51   <0.001 17.2 0.200  0.508
#> 
#> Columns: stage, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high 
#> Type:  response

The contrast between "T2" and "T1" is simply the difference between the two adjusted predictions:

pred$estimate[2] - pred$estimate[1]
#> [1] -0.1637819

The marginaleffects::avg_comparisons() function allows to compute all differences between adjusted predictions.

comp <- avg_comparisons(mod, variables = "stage")
comp
#> 
#>   Term            Contrast Estimate Std. Error      z Pr(>|z|)   S  2.5 %
#>  stage mean(T2) - mean(T1)  -0.1638     0.0925 -1.770   0.0767 3.7 -0.345
#>  stage mean(T3) - mean(T1)  -0.0351     0.1065 -0.330   0.7417 0.4 -0.244
#>  stage mean(T4) - mean(T1)  -0.0895     0.1004 -0.891   0.3727 1.4 -0.286
#>  97.5 %
#>  0.0175
#>  0.1736
#>  0.1073
#> 
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

Note: in fact, avg_comparisons() has computed the contrasts for each observed values before averaging it. By construction, it is equivalent to the difference of the average marginal predictions.

As the contrast has been averaged over the observed values, we can call them average marginal contrast.

By default, each modality is contrasted with the first one taken as a reference.

avg_comparisons(mod, variables = "stage")
#> 
#>   Term            Contrast Estimate Std. Error      z Pr(>|z|)   S  2.5 %
#>  stage mean(T2) - mean(T1)  -0.1638     0.0925 -1.770   0.0767 3.7 -0.345
#>  stage mean(T3) - mean(T1)  -0.0351     0.1065 -0.330   0.7417 0.4 -0.244
#>  stage mean(T4) - mean(T1)  -0.0895     0.1004 -0.891   0.3727 1.4 -0.286
#>  97.5 %
#>  0.0175
#>  0.1736
#>  0.1073
#> 
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

Other types of contrasts could be specified using the variables argument.

avg_comparisons(mod, variables = list(stage = "sequential"))
#> 
#>   Term            Contrast Estimate Std. Error      z Pr(>|z|)   S   2.5 %
#>  stage mean(T2) - mean(T1)  -0.1638     0.0925 -1.770   0.0767 3.7 -0.3451
#>  stage mean(T3) - mean(T2)   0.1287     0.0972  1.324   0.1856 2.4 -0.0619
#>  stage mean(T4) - mean(T3)  -0.0544     0.1060 -0.513   0.6081 0.7 -0.2622
#>  97.5 %
#>  0.0175
#>  0.3192
#>  0.1535
#> 
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response
avg_comparisons(mod, variables = list(stage = "pairwise"))
#> 
#>   Term            Contrast Estimate Std. Error      z Pr(>|z|)   S   2.5 %
#>  stage mean(T2) - mean(T1)  -0.1638     0.0925 -1.770   0.0767 3.7 -0.3451
#>  stage mean(T3) - mean(T1)  -0.0351     0.1065 -0.330   0.7417 0.4 -0.2437
#>  stage mean(T3) - mean(T2)   0.1287     0.0972  1.324   0.1856 2.4 -0.0619
#>  stage mean(T4) - mean(T1)  -0.0895     0.1004 -0.891   0.3727 1.4 -0.2862
#>  stage mean(T4) - mean(T2)   0.0743     0.0917  0.811   0.4176 1.3 -0.1054
#>  stage mean(T4) - mean(T3)  -0.0544     0.1060 -0.513   0.6081 0.7 -0.2622
#>  97.5 %
#>  0.0175
#>  0.1736
#>  0.3192
#>  0.1073
#>  0.2540
#>  0.1535
#> 
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

Let’s consider a continuous variable:

avg_comparisons(mod, variables = "age")
#> 
#>  Term Contrast Estimate Std. Error    z Pr(>|z|)   S  2.5 %  97.5 %
#>   age mean(+1)  0.00399    0.00239 1.67   0.0954 3.4 -7e-04 0.00868
#> 
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

By default, marginaleffects::avg_comparisons() computes, for each observed value, the effect of increasing age by one unit (comparing adjusted predictions when the regressor is equal to its observed value minus 0.5 and its observed value plus 0.5). It is possible to compute a contrast for another gap, for example the average difference for an increase of 10 years:

avg_comparisons(mod, variables = list(age = 10))
#> 
#>  Term  Contrast Estimate Std. Error    z Pr(>|z|)   S   2.5 % 97.5 %
#>   age mean(+10)   0.0412     0.0288 1.43    0.152 2.7 -0.0152 0.0976
#> 
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

Contrasts for all individual predictors could be easily obtained:

avg_comparisons(mod)
#> 
#>    Term                    Contrast Estimate Std. Error      z Pr(>|z|)   S
#>  age    mean(+1)                     0.00399    0.00239  1.667   0.0954 3.4
#>  marker mean(+1)                     0.07470    0.04150  1.800   0.0719 3.8
#>  stage  mean(T2) - mean(T1)         -0.16378    0.09252 -1.770   0.0767 3.7
#>  stage  mean(T3) - mean(T1)         -0.03509    0.10646 -0.330   0.7417 0.4
#>  stage  mean(T4) - mean(T1)         -0.08947    0.10037 -0.891   0.3727 1.4
#>  trt    mean(Drug B) - mean(Drug A)  0.06006    0.06893  0.871   0.3836 1.4
#>     2.5 %  97.5 %
#>  -0.00070 0.00868
#>  -0.00664 0.15604
#>  -0.34511 0.01755
#>  -0.24374 0.17356
#>  -0.28620 0.10726
#>  -0.07504 0.19517
#> 
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

It should be noted that column names are not consistent with other tidiers used by broom.helpers. Therefore, a comparisons object should not be passed directly to tidy_plus_plus(). Instead, you should use broom.helpers::tidy_avg_comparisons().

tidy_avg_comparisons(mod)
#> # A tibble: 6 × 12
#>   variable term  estimate std.error statistic p.value s.value conf.low conf.high
#>   <chr>    <chr>    <dbl>     <dbl>     <dbl>   <dbl>   <dbl>    <dbl>     <dbl>
#> 1 age      mean…  0.00399   0.00239     1.67   0.0954   3.39  -7.00e-4   0.00868
#> 2 marker   mean…  0.0747    0.0415      1.80   0.0719   3.80  -6.64e-3   0.156  
#> 3 stage    mean… -0.164     0.0925     -1.77   0.0767   3.71  -3.45e-1   0.0175 
#> 4 stage    mean… -0.0351    0.106      -0.330  0.742    0.431 -2.44e-1   0.174  
#> 5 stage    mean… -0.0895    0.100      -0.891  0.373    1.42  -2.86e-1   0.107  
#> 6 trt      mean…  0.0601    0.0689      0.871  0.384    1.38  -7.50e-2   0.195  
#> # ℹ 3 more variables: predicted_lo <dbl>, predicted_hi <dbl>, predicted <dbl>

This custom tidier is compatible with tidy_plus_plus() and the suit of other functions provided by broom.helpers.

mod |>
  tidy_plus_plus(tidy_fun = tidy_avg_comparisons)
#> # A tibble: 6 × 23
#>   term               variable var_label var_class var_type var_nlevels contrasts
#>   <chr>              <chr>    <chr>     <chr>     <chr>          <int> <chr>    
#> 1 mean(+1)           age      age       nmatrix.2 continu…          NA NA       
#> 2 mean(+1)           marker   Marker L… numeric   continu…          NA NA       
#> 3 mean(T2) - mean(T… stage    T Stage   factor    categor…           4 contr.tr…
#> 4 mean(T3) - mean(T… stage    T Stage   factor    categor…           4 contr.tr…
#> 5 mean(T4) - mean(T… stage    T Stage   factor    categor…           4 contr.tr…
#> 6 mean(Drug B) - me… trt      Chemothe… character dichoto…           2 contr.tr…
#> # ℹ 16 more variables: contrasts_type <chr>, reference_row <lgl>, label <chr>,
#> #   n_obs <dbl>, n_event <dbl>, estimate <dbl>, std.error <dbl>,
#> #   statistic <dbl>, p.value <dbl>, s.value <dbl>, conf.low <dbl>,
#> #   conf.high <dbl>, predicted_lo <dbl>, predicted_hi <dbl>, predicted <dbl>,
#> #   label_attr <chr>

A nicely formatted table can therefore be generated with gtsummary::tbl_regression().

mod |>
  tbl_regression(
    tidy_fun = tidy_avg_comparisons,
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Average Marginal Contrasts 95% CI1 p-value
Age in years


    mean(+1) +0.4% -0.07%, +0.87% 0.10
Marker Level (ng/mL)


    mean(+1) +7.5% -0.66%, +15.60% 0.072
T Stage


    mean(T2) - mean(T1) -16.4% -34.51%, +1.75% 0.077
    mean(T3) - mean(T1) -3.5% -24.37%, +17.36% 0.7
    mean(T4) - mean(T1) -8.9% -28.62%, +10.73% 0.4
Chemotherapy Treatment


    mean(Drug B) - mean(Drug A) +6.0% -7.50%, +19.52% 0.4
1 CI = Confidence Interval

Similarly, a forest plot could be produced with ggstats::ggcoef_model().

ggstats::ggcoef_model(
  mod,
  tidy_fun = tidy_avg_comparisons,
  variable_labels = c(age = "Age in years")
) +
  ggplot2::scale_x_continuous(
    labels = scales::label_percent(style_positive = "plus")
  )

Marginal Contrasts at the Mean

Instead of computing contrasts for each observed values before averaging, another approach consist of considering an hypothetical individual whose characteristics correspond to the “average” before predicting results and computing contrasts.

It could be achieved with marginaleffects by using newdata = "mean". In that case, it will consider an individual where continuous predictors are equal to the mean of observed values and where categorical predictors will be set to the mode (i.e. most frequent value) of the observed values.

pred <- predictions(mod, variables = "trt", newdata = "mean")
pred
#> 
#>  marker stage  age    trt Estimate Pr(>|z|)    S  2.5 % 97.5 %
#>   0.919    T2 46.9 Drug A    0.194  < 0.001 10.8 0.0969  0.351
#>   0.919    T2 46.9 Drug B    0.242  0.00282  8.5 0.1308  0.403
#> 
#> Columns: rowid, rowidcf, estimate, p.value, s.value, conf.low, conf.high, response, marker, stage, age, trt 
#> Type:  invlink(link)
pred$estimate[2] - pred$estimate[1]
#> [1] 0.04738154
comparisons(mod, variables = "trt", newdata = "mean")
#> 
#>  Term        Contrast Estimate Std. Error     z Pr(>|z|)   S  2.5 % 97.5 %
#>   trt Drug B - Drug A   0.0474     0.0579 0.819    0.413 1.3 -0.066  0.161
#>     trt marker stage  age
#>  Drug B  0.919    T2 46.9
#> 
#> Columns: rowid, term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted, trt, marker, stage, age, response 
#> Type:  response

The newdata argument can be passed to tidy_avg_comparisons(), tidy_plus_plus or gtsummary::tbl_regression().

mod |>
  tbl_regression(
    tidy_fun = tidy_avg_comparisons,
    newdata = "mean",
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Marginal Contrasts at the Mean 95% CI1 p-value
Age in years


    mean(+1) +0.4% -0.1%, +0.8% 0.11
Marker Level (ng/mL)


    mean(+1) +9.2% -2.6%, +21.0% 0.13
T Stage


    mean(T2) - mean(T1) -17.7% -37.8%, +2.3% 0.083
    mean(T3) - mean(T1) -3.8% -26.2%, +18.7% 0.7
    mean(T4) - mean(T1) -9.6% -30.9%, +11.6% 0.4
Chemotherapy Treatment


    mean(Drug B) - mean(Drug A) +4.7% -6.6%, +16.1% 0.4
1 CI = Confidence Interval

For ggstats::ggcoef_model(), use tidy_args to pass newdata = "mean".

mod |>
  ggstats::ggcoef_model(
    tidy_fun = tidy_avg_comparisons,
    tidy_args = list(newdata = "mean"),
    variable_labels = c(age = "Age in years")
  ) +
  ggplot2::scale_x_continuous(
    labels = scales::label_percent(style_positive = "plus")
  )

Alternative approaches

Other assumptions, such as "marginalmeans" or "median", could be defined using newdata. See the documentation of marginaleffects::comparisons().

mod |>
  tbl_regression(
    tidy_fun = tidy_avg_comparisons,
    newdata = "marginalmeans",
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Marginal Contrasts at Marginal Means 95% CI1 p-value
Age in years


    mean(+1) +0.4% -0.08%, +0.9% 0.10
Marker Level (ng/mL)


    mean(+1) +7.7% -0.97%, +16.3% 0.082
T Stage


    mean(T2) - mean(T1) -16.8% -35.69%, +2.1% 0.082
    mean(T3) - mean(T1) -3.6% -25.25%, +18.0% 0.7
    mean(T4) - mean(T1) -9.2% -29.52%, +11.1% 0.4
Chemotherapy Treatment


    mean(Drug B) - mean(Drug A) +5.9% -8.11%, +19.8% 0.4
1 CI = Confidence Interval
mod |>
  ggstats::ggcoef_model(
    tidy_fun = tidy_avg_comparisons,
    tidy_args = list(newdata = "marginalmeans"),
    variable_labels = c(age = "Age in years")
  ) +
  ggplot2::scale_x_continuous(
    labels = scales::label_percent(style_positive = "plus")
  )

Dealing with interactions

In our model, we defined an interaction between trt and marker. Therefore, we could be interested to compute the contrast of marker for each value of trt.

avg_comparisons(
  mod,
  variables = list(marker = 1),
  newdata = datagrid(
    trt = unique,
    grid_type = "counterfactual"
  ),
  by = "trt"
)
#> 
#>    Term Contrast    trt Estimate Std. Error     z Pr(>|z|)   S   2.5 % 97.5 %
#>  marker mean(+1) Drug A   0.0472     0.0573 0.824   0.4101 1.3 -0.0651  0.159
#>  marker mean(+1) Drug B   0.1004     0.0590 1.703   0.0887 3.5 -0.0152  0.216
#> 
#> Columns: term, contrast, trt, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

Alternatively, it is possible to compute “cross-contrasts” showing what is happening when both marker and trt are changing.

avg_comparisons(
  mod,
  variables = list(marker = 1, trt = "reference"),
  cross = TRUE
)
#> 
#>  Estimate Std. Error    z Pr(>|z|)   S   2.5 % 97.5 % C: marker
#>     0.161     0.0959 1.67   0.0941 3.4 -0.0274  0.348  mean(+1)
#>                       C: trt
#>  mean(Drug B) - mean(Drug A)
#> 
#> Columns: term, contrast_marker, contrast_trt, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

The tidier tidy_marginal_contrasts() allows to compute directly several combinations of variables and to stack all the results in a unique tibble.

mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_contrasts,
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Average Marginal Contrasts 95% CI1 p-value
T Stage


    mean(T2) - mean(T1) -16.4% -34.5%, +1.75% 0.077
    mean(T3) - mean(T1) -3.5% -24.4%, +17.36% 0.7
    mean(T4) - mean(T1) -8.9% -28.6%, +10.73% 0.4
Age in years


    mean(+1) +0.4% -0.1%, +0.87% 0.10
Chemotherapy Treatment * Marker Level (ng/mL)


    Drug A * mean(+1) +4.7% -6.5%, +15.95% 0.4
    Drug B * mean(+1) +10.0% -1.5%, +21.61% 0.089
1 CI = Confidence Interval
ggstats::ggcoef_model(
  mod,
  tidy_fun = tidy_marginal_contrasts,
  variable_labels = c(age = "Age in years")
)

By default, when there is an interaction, contrasts are computed for the last variable of the interaction according to the different values of the first variables (if one of this variable is continuous, using Tukey’s five numbers).

The option variables_list = "cross" could be used to get “cross-contrasts” for interactions.

mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_contrasts,
    variables_list = "cross",
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Average Marginal Contrasts 95% CI1 p-value
T Stage


    mean(T2) - mean(T1) -16.4% -34.5%, +1.75% 0.077
    mean(T3) - mean(T1) -3.5% -24.4%, +17.36% 0.7
    mean(T4) - mean(T1) -8.9% -28.6%, +10.73% 0.4
Age in years


    mean(+1) +0.4% -0.1%, +0.87% 0.10
Chemotherapy Treatment * Marker Level (ng/mL)


    mean(+1) * mean(Drug B) - mean(Drug A) +16.1% -2.7%, +34.84% 0.094
1 CI = Confidence Interval

The option variables_list = "no_interaction" could be used to get the average marginal contrasts for each variable without considering interactions.

mod |>
  tbl_regression(
    tidy_fun = tidy_marginal_contrasts,
    variables_list = "no_interaction",
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Average Marginal Contrasts 95% CI1 p-value
Chemotherapy Treatment


    mean(Drug B) - mean(Drug A) +6.0% -7.50%, +19.52% 0.4
Marker Level (ng/mL)


    mean(+1) +7.5% -0.66%, +15.60% 0.072
T Stage


    mean(T2) - mean(T1) -16.4% -34.51%, +1.75% 0.077
    mean(T3) - mean(T1) -3.5% -24.37%, +17.36% 0.7
    mean(T4) - mean(T1) -8.9% -28.62%, +10.73% 0.4
Age in years


    mean(+1) +0.4% -0.07%, +0.87% 0.10
1 CI = Confidence Interval
ggstats::ggcoef_model(
  mod,
  tidy_fun = tidy_marginal_contrasts,
  tidy_args = list(variables_list = "no_interaction"),
  variable_labels = c(age = "Age in years")
)

As before, to display marginal contrasts at the mean, indicate newdata = "mean". For more information on the way to customize the combination of variables, see the documentation and examples of tidy_marginal_contrasts().

Marginal Effects / Marginal Slopes

Marginal effects are similar to marginal contrasts with a subtle difference. For a continuous regressor, a marginal contrast could be seen as a difference while a marginal effect is a partial derivative. Put differently, the marginal effect of a continuous regressor xx is the slope of the prediction function yy, measured at a specific value of xx, i.e. y/x{\partial y}/{\partial x}.

Marginal effects are expressed according to the scale of the model and represent the expected change on the outcome for an increase of one unit of the regressor.

By definition, marginal effects are not defined for categorical variables, marginal contrasts being reported instead.

Like marginal contrasts, several approaches exist to compute marginal effects. For more details, see the dedicated vignette of the marginaleffects package.

Average Marginal Effects (AME)

A marginal effect will be computed for each observed values before being averaged with marginaleffects::avg_slopes().

avg_slopes(mod)
#> 
#>    Term                    Contrast Estimate Std. Error      z Pr(>|z|)   S
#>  age    mean(dY/dX)                  0.00397    0.00237  1.679   0.0932 3.4
#>  marker mean(dY/dX)                  0.07104    0.03818  1.861   0.0628 4.0
#>  stage  mean(T2) - mean(T1)         -0.16378    0.09252 -1.770   0.0767 3.7
#>  stage  mean(T3) - mean(T1)         -0.03509    0.10646 -0.330   0.7417 0.4
#>  stage  mean(T4) - mean(T1)         -0.08947    0.10037 -0.891   0.3727 1.4
#>  trt    mean(Drug B) - mean(Drug A)  0.06006    0.06893  0.871   0.3836 1.4
#>      2.5 %  97.5 %
#>  -0.000666 0.00861
#>  -0.003781 0.14587
#>  -0.345109 0.01755
#>  -0.243740 0.17356
#>  -0.286204 0.10726
#>  -0.075043 0.19517
#> 
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted 
#> Type:  response

Column names are not consistent with other tidiers used by broom.helpers. Use tidy_avg_slopes() instead.

mod |>
  tbl_regression(
    tidy_fun = tidy_avg_slopes,
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Average Marginal Effects 95% CI1 p-value
Age in years


    mean(dY/dX) +0.4% -0.07%, +0.86% 0.093
Marker Level (ng/mL)


    mean(dY/dX) +7.1% -0.38%, +14.59% 0.063
T Stage


    mean(T2) - mean(T1) -16.4% -34.51%, +1.75% 0.077
    mean(T3) - mean(T1) -3.5% -24.37%, +17.36% 0.7
    mean(T4) - mean(T1) -8.9% -28.62%, +10.73% 0.4
Chemotherapy Treatment


    mean(Drug B) - mean(Drug A) +6.0% -7.50%, +19.52% 0.4
1 CI = Confidence Interval
mod |>
  ggstats::ggcoef_model(
    tidy_fun = tidy_avg_slopes,
    variable_labels = c(age = "Age in years")
  ) +
  ggplot2::scale_x_continuous(
    labels = scales::label_percent(style_positive = "plus")
  )

Please note that for categorical variables, marginal contrasts are returned.

Same results could be obtained with margins::margins() function inspired by Stata’s margins command. As margins::margins() is not compatible with stats::poly(), we will rewrite our model, replacing poly(age, 2) by age + age^2.

mod_alt <- glm(
  response ~ trt * marker + stage + age + age^2,
  data = d,
  family = binomial
)
margins::margins(mod_alt) |> tidy()
#> Warning: tidying a `margins` object was deprecated in broom 1.0.6.
#>  margins was removed from CRAN in April 2024 and its tidiers are thus
#>   untested.
#> ! Please interpret output with caution.
#> This warning is displayed once every 8 hours.
#> Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
#> generated.
#> # A tibble: 6 × 5
#>   term      estimate std.error statistic p.value
#>   <chr>        <dbl>     <dbl>     <dbl>   <dbl>
#> 1 age        0.00397   0.00236     1.68   0.0927
#> 2 marker     0.0710    0.0380      1.87   0.0617
#> 3 stageT2   -0.164     0.0922     -1.78   0.0754
#> 4 stageT3   -0.0351    0.106      -0.330  0.742 
#> 5 stageT4   -0.0895    0.100      -0.891  0.373 
#> 6 trtDrug B  0.0600    0.0689      0.871  0.384

For broom.helpers, gtsummary or ggstats, use tidy_margins().

mod_alt |>
  tbl_regression(
    tidy_fun = tidy_margins,
    estimate_fun = scales::label_percent(style_positive = "plus")
  ) |>
  bold_labels()
Characteristic Average Marginal Effects 95% CI1 p-value
Age +0.4% -0.07%, +0.86% 0.093
Marker Level (ng/mL) +7.1% -0.35%, +14.55% 0.062
T Stage


    T1
    T2 -16.4% -34.46%, +1.68% 0.075
    T3 -3.5% -24.37%, +17.35% 0.7
    T4 -8.9% -28.62%, +10.73% 0.4
Chemotherapy Treatment


    Drug A
    Drug B +6.0% -7.50%, +19.51% 0.4
1 CI = Confidence Interval

Marginal Effects at the Mean (MEM)

For marginal effects at the mean2, simple use newdata = "mean".

mod |>
  tbl_regression(
    tidy_fun = tidy_avg_slopes,
    newdata = "mean",
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Marginal Effects at the Mean 95% CI1 p-value
Age in years


    mean(dY/dX) +0.4% -0.1%, +0.80% 0.11
Marker Level (ng/mL)


    mean(dY/dX) +8.3% -1.6%, +18.17% 0.10
T Stage


    mean(T2) - mean(T1) -17.7% -37.8%, +2.34% 0.083
    mean(T3) - mean(T1) -3.8% -26.2%, +18.67% 0.7
    mean(T4) - mean(T1) -9.6% -30.9%, +11.60% 0.4
Chemotherapy Treatment


    mean(Drug B) - mean(Drug A) +4.7% -6.6%, +16.08% 0.4
1 CI = Confidence Interval
mod |>
  ggstats::ggcoef_model(
    tidy_fun = tidy_avg_slopes,
    tidy_args = list(newdata = "mean"),
    variable_labels = c(age = "Age in years")
  ) +
  ggplot2::scale_x_continuous(
    labels = scales::label_percent(style_positive = "plus")
  )

Marginal Effects at Marginal Means

Simply use newdata = "marginalmeans".

mod |>
  tbl_regression(
    tidy_fun = tidy_avg_slopes,
    newdata = "marginalmeans",
    estimate_fun = scales::label_percent(style_positive = "plus"),
    label = list(age = "Age in years")
  ) |>
  bold_labels()
Characteristic Marginal Effects at Marginal Means 95% CI1 p-value
Age in years


    mean(dY/dX) +0.4% -0.08%, +0.9% 0.10
Marker Level (ng/mL)


    mean(dY/dX) +7.3% -0.63%, +15.2% 0.071
T Stage


    mean(T2) - mean(T1) -16.8% -35.69%, +2.1% 0.082
    mean(T3) - mean(T1) -3.6% -25.25%, +18.0% 0.7
    mean(T4) - mean(T1) -9.2% -29.52%, +11.1% 0.4
Chemotherapy Treatment


    mean(Drug B) - mean(Drug A) +5.9% -8.11%, +19.8% 0.4
1 CI = Confidence Interval
mod |>
  ggstats::ggcoef_model(
    tidy_fun = tidy_avg_slopes,
    tidy_args = list(newdata = "marginalmeans"),
    variable_labels = c(age = "Age in years")
  ) +
  ggplot2::scale_x_continuous(
    labels = scales::label_percent(style_positive = "plus")
  )