library(marginaleffects)
mod <- lm(mpg ~ hp + factor(gear), data = mtcars)
20 Equivalence Tests
In many contexts, analysts are less interested in rejecting a null hypothesis, and more interested in testing whether an estimate is “inferior”, “superior”, or “equivalent” to a given threshold or interval. For example, medical researchers may wish to determine if the estimated effect of a new treatment is larger than the effect of prior treatments, or larger than some threshold of “clinical significance.” Alternatively, researchers may wish to support a claim that an estimated parameter is “equivalent to” or “not meaningfully different from” a null hypothesis.
To answer these questions, we can use non-inferiority, non-superiority, or equivalence tests like the two-one-sided test (TOST). This article gives a primer and tutorial on TOST:
Lakens D, Scheel AM, Isager PM. Equivalence Testing for Psychological Research: A Tutorial. Advances in Methods and Practices in Psychological Science. 2018;1(2):259-269. doi:10.1177/2515245918770963
The hypotheses()
function of the marginaleffects
package includes an equivalence
argument which allows users to apply these tests to any of the quantities generated by the package, as well as to arbitrary functions of a model’s parameters. To illustrate, we begin by estimating a simple linear regression model:
import polars as pl
import statsmodels.formula.api as smf
from marginaleffects import *
= pl.read_csv("https://vincentarelbundock.github.io/Rdatasets/csv/datasets/mtcars.csv")
mtcars = mtcars.cast({"gear" : pl.Utf8, "hp" : pl.Float64})
mtcars
= smf.ols("mpg ~ hp + gear", data = mtcars.to_pandas()).fit() mod
The rest of this section considers several quantities estimated by marginaleffects
.
20.1 Predictions
Consider a single prediction, where all predictors are held at their median or mode:
p <- predictions(mod, newdata = "median")
p
#>
#> Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 % hp gear
#> 19.7 1 19.6 <0.001 281.3 17.7 21.6 123 3
#>
#> Type: response
#> Columns: rowid, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, hp, gear, mpg
= predictions(mod, newdata = "median")
p print(p)
#> shape: (1, 7)
#> ┌──────────┬───────────┬──────┬─────────┬─────┬──────┬───────┐
#> │ Estimate ┆ Std.Error ┆ z ┆ P(>|z|) ┆ S ┆ 2.5% ┆ 97.5% │
#> │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
#> │ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str │
#> ╞══════════╪═══════════╪══════╪═════════╪═════╪══════╪═══════╡
#> │ 19.7 ┆ 1 ┆ 19.6 ┆ 0 ┆ inf ┆ 17.7 ┆ 21.6 │
#> └──────────┴───────────┴──────┴─────────┴─────┴──────┴───────┘
#>
#> Columns: rowid, estimate, std_error, statistic, p_value, s_value, conf_low, conf_high, rownames, mpg, cyl, disp, hp, drat, wt, qsec, vs, am, gear, carb
Now we specify an equivalence interval (or “region”) for predictions between 17 and 18:
hypotheses(p, equivalence = c(17, 18))
#>
#> Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 % p (NonSup) p (NonInf) p (Equiv) hp gear
#> 19.7 1 19.6 <0.001 281.3 17.7 21.6 0.951 0.00404 0.951 123 3
#>
#> Type: response
#> Columns: rowid, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, hp, gear, mpg, statistic.noninf, statistic.nonsup, p.value.noninf, p.value.nonsup, p.value.equiv
# Not implemented yet
The results allow us to draw three conclusions:
- The p value for the non-inferiority test is 0.0040. This suggests that we can reject the null hypothesis that the parameter is below 17.
- The p value for the non-superiority test is 0.9508. This suggests that we cannot reject the null hypothesis that the parameter (19.6589) is above 18.
- The p value for the equivalence test is 0.9508. This suggests that we cannot reject the hypothesis that the parameter falls outside the equivalence interval.
20.2 Model coefficients
The hypotheses
function also allows users to conduct equivalence, non-inferiority, and non-superiority tests for model coefficients, and for arbitrary functions of model coefficients.
Our estimate of the coefficient affecting factor(gear)5 in the model is:
coef(mod)[4]
#> factor(gear)5
#> 6.574763
'gear[T.5]']
mod.params[#> np.float64(6.5747628421807685)
We can test if this parameter is likely to fall in the [5,7] interval by:
hypotheses(mod, equivalence = c(5, 7))[4, ]
#>
#> Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 % p (NonSup) p (NonInf) p (Equiv)
#> 6.57 1.64 4 <0.001 14.0 3.36 9.79 0.398 0.169 0.398
#>
#> Term: factor(gear)5
#> Columns: term, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, statistic.noninf, statistic.nonsup, p.value.noninf, p.value.nonsup, p.value.equiv
= pl.DataFrame(hypotheses(mod, equivalence = [5., 7.]))[2,:]
h print(h)
#> shape: (1, 13)
#> ┌───────────┬──────────┬───────────┬───────────┬───┬───────────┬───────────┬───────────┬───────────┐
#> │ term ┆ estimate ┆ std_error ┆ statistic ┆ … ┆ statistic ┆ p_value_n ┆ p_value_n ┆ p_value_e │
#> │ --- ┆ --- ┆ --- ┆ --- ┆ ┆ _nonsup ┆ oninf ┆ onsup ┆ quiv │
#> │ str ┆ f64 ┆ f64 ┆ f64 ┆ ┆ --- ┆ --- ┆ --- ┆ --- │
#> │ ┆ ┆ ┆ ┆ ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
#> ╞═══════════╪══════════╪═══════════╪═══════════╪═══╪═══════════╪═══════════╪═══════════╪═══════════╡
#> │ gear[T.5] ┆ 6.574763 ┆ 1.642684 ┆ 4.002452 ┆ … ┆ -0.258867 ┆ 0.168867 ┆ 0.397869 ┆ 0.397869 │
#> └───────────┴──────────┴───────────┴───────────┴───┴───────────┴───────────┴───────────┴───────────┘
The p value is 0.3979, so we cannot reject the hypothesis that the factor(gear)5 parameter falls outside the [5,7] interval.
20.3 Slopes
The same syntax can be used to conduct tests for all the quantities produced by the marginaleffects
package. For example, imagine that, for substantive or theoretical reasons, an average slope between -0.1 and 0.1 is uninteresting. We can conduct an equivalence test to check if this is the case:
avg_slopes(mod, variables = "hp", equivalence = c(-.1, .1))
#>
#> Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 % p (NonSup) p (NonInf) p (Equiv)
#> -0.0669 0.011 -6.05 <0.001 29.4 -0.0885 -0.0452 <0.001 0.00135 0.00135
#>
#> Term: hp
#> Type: response
#> Comparison: mean(dY/dX)
#> Columns: term, contrast, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted, statistic.noninf, statistic.nonsup, p.value.noninf, p.value.nonsup, p.value.equiv
= pl.DataFrame(avg_slopes(mod, variables = "hp", equivalence = [-.1, .1]))
s print(s)
#> shape: (1, 14)
#> ┌──────┬────────────┬───────────┬───────────┬───┬────────────┬────────────┬────────────┬───────────┐
#> │ term ┆ contrast ┆ estimate ┆ std_error ┆ … ┆ statistic_ ┆ p_value_no ┆ p_value_no ┆ p_value_e │
#> │ --- ┆ --- ┆ --- ┆ --- ┆ ┆ nonsup ┆ ninf ┆ nsup ┆ quiv │
#> │ str ┆ str ┆ f64 ┆ f64 ┆ ┆ --- ┆ --- ┆ --- ┆ --- │
#> │ ┆ ┆ ┆ ┆ ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
#> ╞══════╪════════════╪═══════════╪═══════════╪═══╪════════════╪════════════╪════════════╪═══════════╡
#> │ hp ┆ mean(dY/dX ┆ -0.066854 ┆ 0.010881 ┆ … ┆ -15.334428 ┆ 0.001159 ┆ 2.2511e-53 ┆ 0.001159 │
#> │ ┆ ) ┆ ┆ ┆ ┆ ┆ ┆ ┆ │
#> └──────┴────────────┴───────────┴───────────┴───┴────────────┴────────────┴────────────┴───────────┘
The p value is 0.0013, which suggests that we can reject the hypothesis that the parameter falls outside the region of “substantive equivalence” that we have defined by the interval.
20.4 Difference between comparisons (contrasts)
Consider a model with a multiplicative interaction:
The average contrast for a change of 1 unit in hp
differs based on the value of gear
:
avg_comparisons(int, variables = "hp", by = "gear")
#>
#> gear Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> 3 -0.0522 0.0146 -3.59 <0.001 11.6 -0.0808 -0.0237
#> 4 -0.1792 0.0303 -5.92 <0.001 28.2 -0.2385 -0.1199
#> 5 -0.0583 0.0126 -4.61 <0.001 17.9 -0.0830 -0.0335
#>
#> Term: hp
#> Type: response
#> Comparison: mean(+1)
#> Columns: term, contrast, gear, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, predicted_lo, predicted_hi, predicted
print(avg_comparisons(inter, variables = "hp", by = "gear"))
#> shape: (3, 10)
#> ┌──────┬──────┬──────────┬──────────┬───┬──────────┬──────┬─────────┬─────────┐
#> │ gear ┆ Term ┆ Contrast ┆ Estimate ┆ … ┆ P(>|z|) ┆ S ┆ 2.5% ┆ 97.5% │
#> │ --- ┆ --- ┆ --- ┆ --- ┆ ┆ --- ┆ --- ┆ --- ┆ --- │
#> │ str ┆ str ┆ str ┆ str ┆ ┆ str ┆ str ┆ str ┆ str │
#> ╞══════╪══════╪══════════╪══════════╪═══╪══════════╪══════╪═════════╪═════════╡
#> │ 3 ┆ hp ┆ +1 ┆ -0.0522 ┆ … ┆ 0.000333 ┆ 11.6 ┆ -0.0808 ┆ -0.0237 │
#> │ 4 ┆ hp ┆ +1 ┆ -0.179 ┆ … ┆ 3.16e-09 ┆ 28.2 ┆ -0.238 ┆ -0.12 │
#> │ 5 ┆ hp ┆ +1 ┆ -0.0583 ┆ … ┆ 3.98e-06 ┆ 17.9 ┆ -0.083 ┆ -0.0335 │
#> └──────┴──────┴──────────┴──────────┴───┴──────────┴──────┴─────────┴─────────┘
#>
#> Columns: gear, term, contrast, estimate, std_error, statistic, p_value, s_value, conf_low, conf_high
Are these contrasts different from one another? Let’s look at the pairwise differences between them:
avg_comparisons(int, variables = "hp", by = "gear",
hypothesis = "pairwise")
#>
#> Term Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 %
#> 3 - 4 0.12695 0.0336 3.781 <0.001 12.6 0.0611 0.1928
#> 3 - 5 0.00603 0.0193 0.313 0.754 0.4 -0.0318 0.0438
#> 4 - 5 -0.12092 0.0328 -3.688 <0.001 12.1 -0.1852 -0.0567
#>
#> Type: response
#> Columns: term, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high
print(avg_comparisons(inter, variables = "hp", by = "gear",
= "pairwise"))
hypothesis #> shape: (3, 8)
#> ┌───────────────┬──────────┬───────────┬───────┬──────────┬───────┬─────────┬─────────┐
#> │ Term ┆ Estimate ┆ Std.Error ┆ z ┆ P(>|z|) ┆ S ┆ 2.5% ┆ 97.5% │
#> │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
#> │ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str │
#> ╞═══════════════╪══════════╪═══════════╪═══════╪══════════╪═══════╪═════════╪═════════╡
#> │ Row 1 - Row 2 ┆ 0.127 ┆ 0.0336 ┆ 3.78 ┆ 0.000156 ┆ 12.6 ┆ 0.0611 ┆ 0.193 │
#> │ Row 1 - Row 3 ┆ 0.00603 ┆ 0.0193 ┆ 0.313 ┆ 0.754 ┆ 0.406 ┆ -0.0318 ┆ 0.0438 │
#> │ Row 2 - Row 3 ┆ -0.121 ┆ 0.0328 ┆ -3.69 ┆ 0.000226 ┆ 12.1 ┆ -0.185 ┆ -0.0567 │
#> └───────────────┴──────────┴───────────┴───────┴──────────┴───────┴─────────┴─────────┘
#>
#> Columns: term, estimate, std_error, statistic, p_value, s_value, conf_low, conf_high
We consider that these pairwise comparisons are “equivalent to zero” when they fall in the [-.1, .1] interval:
avg_comparisons(int, variables = "hp", by = "gear",
hypothesis = "pairwise",
equivalence = c(-.1, .1))
#>
#> Term Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 % p (NonSup) p (NonInf) p (Equiv)
#> 3 - 4 0.12695 0.0336 3.781 <0.001 12.6 0.0611 0.1928 0.789 <0.001 0.789
#> 3 - 5 0.00603 0.0193 0.313 0.754 0.4 -0.0318 0.0438 <0.001 <0.001 <0.001
#> 4 - 5 -0.12092 0.0328 -3.688 <0.001 12.1 -0.1852 -0.0567 <0.001 0.738 0.738
#>
#> Type: response
#> Columns: term, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, statistic.noninf, statistic.nonsup, p.value.noninf, p.value.nonsup, p.value.equiv
= pl.DataFrame(
c = "hp", by = "gear",
avg_comparisons(inter, variables = "pairwise",
hypothesis = [-.1, .1])
equivalence
)print(c)
#> shape: (3, 13)
#> ┌───────────┬───────────┬───────────┬───────────┬───┬───────────┬───────────┬───────────┬──────────┐
#> │ term ┆ estimate ┆ std_error ┆ statistic ┆ … ┆ statistic ┆ p_value_n ┆ p_value_n ┆ p_value_ │
#> │ --- ┆ --- ┆ --- ┆ --- ┆ ┆ _nonsup ┆ oninf ┆ onsup ┆ equiv │
#> │ str ┆ f64 ┆ f64 ┆ f64 ┆ ┆ --- ┆ --- ┆ --- ┆ --- │
#> │ ┆ ┆ ┆ ┆ ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
#> ╞═══════════╪═══════════╪═══════════╪═══════════╪═══╪═══════════╪═══════════╪═══════════╪══════════╡
#> │ Row 1 - ┆ 0.126946 ┆ 0.033575 ┆ 3.78103 ┆ … ┆ 0.802581 ┆ 6.9243e-1 ┆ 0.788891 ┆ 0.788891 │
#> │ Row 2 ┆ ┆ ┆ ┆ ┆ ┆ 2 ┆ ┆ │
#> │ Row 1 - ┆ 0.006029 ┆ 0.019277 ┆ 0.312762 ┆ … ┆ -4.874855 ┆ 1.8949e-8 ┆ 5.4444e-7 ┆ 5.4444e- │
#> │ Row 3 ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 7 │
#> │ Row 2 - ┆ -0.120917 ┆ 0.032785 ┆ -3.688224 ┆ … ┆ -6.738428 ┆ 0.738269 ┆ 8.0054e-1 ┆ 0.738269 │
#> │ Row 3 ┆ ┆ ┆ ┆ ┆ ┆ ┆ 2 ┆ │
#> └───────────┴───────────┴───────────┴───────────┴───┴───────────┴───────────┴───────────┴──────────┘
The p (Equiv)
column shows that the difference between the average contrasts when gear
is 3 and gear
is 5 can be said to be equivalent to the specified interval. However, there are good reasons to think that the other two pairwise comparisons may fall outside the interval.
20.5 Marginal means and emmeans
This example shows the equivalence between results produced by the emmeans
package and the predictions()
function:
library(emmeans)
mod <- lm(log(conc) ~ source + factor(percent), data = pigs)
## {emmeans}
emmeans(mod, specs = "source") |>
pairs() |>
test(df = Inf,
null = 0,
delta = log(1.25),
side = "equivalence",
adjust = "none")
#> contrast estimate SE df z.ratio p.value
#> fish - soy -0.273 0.0529 Inf 0.937 0.8257
#> fish - skim -0.402 0.0542 Inf 3.308 0.9995
#> soy - skim -0.130 0.0530 Inf -1.765 0.0388
#>
#> Results are averaged over the levels of: percent
#> Degrees-of-freedom method: user-specified
#> Results are given on the log (not the response) scale.
#> Statistics are tests of equivalence with a threshold of 0.22314
#> P values are left-tailed
## {marginaleffects}
predictions(
mod,
newdata = "balanced",
by = "source",
hypothesis = "pairwise",
equivalence = c(-log(1.25), log(1.25))
)
#>
#> Term Estimate Std. Error z Pr(>|z|) S 2.5 % 97.5 % p (NonSup) p (NonInf) p (Equiv)
#> fish - soy -0.273 0.0529 -5.15 <0.001 21.9 -0.377 -0.1690 <0.001 0.8257 0.8257
#> fish - skim -0.402 0.0542 -7.43 <0.001 43.0 -0.508 -0.2961 <0.001 0.9995 0.9995
#> soy - skim -0.130 0.0530 -2.44 0.0146 6.1 -0.233 -0.0255 <0.001 0.0388 0.0388
#>
#> Type: response
#> Columns: term, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, statistic.noninf, statistic.nonsup, p.value.noninf, p.value.nonsup, p.value.equiv
20.6 t-test
Now we show that the results produced by hypotheses()
are identical to the results produced by the equivalence
package in the case of a simple t-test:
library(equivalence)
set.seed(1024)
## simulate data data
N <- 20
dat <- data.frame(
y = rnorm(N),
x = sample(c(rep(0, N / 2), rep(1, N / 2)), N))
## fit model
mod <- lm(y ~ x, data = dat)
## test with the {equivalence} package
e <- tost(
x = dat$y[dat$x == 0],
y = dat$y[dat$x == 1],
epsilon = 10)
e
#>
#> Welch Two Sample TOST
#>
#> data: dat$y[dat$x == 0] and dat$y[dat$x == 1]
#> df = 17.607
#> sample estimates:
#> mean of x mean of y
#> -0.3788551 -0.2724594
#>
#> Epsilon: 10
#> 95 percent two one-sided confidence interval (TOST interval):
#> -1.058539 0.845747
#> Null hypothesis of statistical difference is: rejected
#> TOST p-value: 4.248528e-13
## test with {marginaleffects} package
h <- hypotheses(mod, equivalence = c(-10, 10), df = e$parameter)[2, ]
h
#>
#> Estimate Std. Error t Pr(>|t|) S 2.5 % 97.5 % p (NonSup) p (NonInf) p (Equiv) Df
#> 0.106 0.548 0.194 0.848 0.2 -1.05 1.26 <0.001 <0.001 <0.001 17.6
#>
#> Term: x
#> Columns: term, estimate, std.error, statistic, p.value, s.value, conf.low, conf.high, df, statistic.noninf, statistic.nonsup, p.value.noninf, p.value.nonsup, p.value.equiv
# identical p values
h$p.value.equiv |> as.vector()
#> [1] 4.248528e-13
e$tost.p.value |> as.vector()
#> [1] 4.248528e-13