(Adjusted) Cohen's d for contrasts in linear models
@bwiernik is already working on this.
Since Jun 17, 2021.
Assessment
This issue has not been assessed yet.
Description
Some code I wrote a while back for computing SMDs from lm output. Could be useful to report effect sizes in terms of standardized betas and Cohen's ds. Most important bit is the comment at the end.
.cmicalc <- function (df) {
cmi <- ifelse(df <= 1, NA,
exp(lgamma(df/2) - log(sqrt(df/2)) - lgamma((df - 1)/2))
)
return(cmi)
}
smd_stats <-
function(diff = NULL,
se_diff = NULL,
df = NULL,
m1 = NULL, m2 = NULL,
n1 = NULL, n2 = NULL,
sd1 = NULL, sd2 = NULL,
sigma = NULL,
mse = NULL,
t_ncp = NULL,
R2_cov = 0,
q = 0,
method = c("smd_pooledSD", "smd_controlSD", "smd_aveSD"),
unbiased = FALSE,
conf.int = TRUE,
conf.level = 0.95,
conf_method = c("t", "norm"),
sd_method = c("unbiased", "ml"),
std_method = c("unbiased", "ml"),
se_var_method = c("ls", "unbiased"),
...) {
method <- match.arg(method, choices = c("smd_pooledSD", "smd_controlSD", "smd_aveSD"))
# Compute degrees of freedom
if (is.null(df)) {
if (is.null(n1) & is.null(n2)) {
stop("One of these must be provided:\n (1) `df`\n (2) `n1`, `n2`, `q`")
} else {
df <- n1 + n2 - 2 - q
}
}
# TODO: Add Satterwhite degrees of freedom
# Compute standardizer
if (all(is.null(mse),
is.null(sigma),
any(is.null(sd1),
is.null(sd2),
is.null(n1),
is.null(n2)))
) {
stop("One of these must be provided:\n (1) `sigma`\n (2)`mse`\n (3) `sd1`, `sd2`, `n1`, `n2`.")
} else if (is.null(mse)) {
if (!is.null(sigma)) {
mse <- sigma^2
} else {
sd_method <- match.arg(sd_method, choices = c("unbiased", "ml"))
std_method <- match.arg(std_method, choices = c("unbiased", "ml"))
if (method == "smd_pooledSD") {
if (sd_method == "unbiased") {
ssq1 <- (n1 - 1) * sd1^2
ssq2 <- (n2 - 1) * sd2^2
} else {
ssq1 <- n1 * sd1^2
ssq2 <- n2 * sd2^2
}
ssq <- ssq2 + ssq2
mse <-
ifelse(std_method == "unbiased",
ssq / df,
ssq / (n1 + n2)
)
} else if (method == "smd_controlSD") {
if (sd_method == "unbiased") {
ssq <- (n1 - 1) * sd1^2
} else {
ssq <- n1 * sd1^2
}
mse <-
ifelse(std_method == "unbiased",
ssq / df,
ssq / (n1 + n2)
)
} else {
if (sd_method == "unbiased") {
mse <-
ifelse(std_method == "unbiased",
(sd1^2 + sd2^2) / 2,
(sd1^2 * (n1 - 1) / n1 + sd2^2 * (n2 - 1) / n2) / 2
)
} else {
mse <-
ifelse(std_method == "unbiased",
(sd1^2 * n1 / (n1 - 1) + sd2^2 * n2 / (n2 - 1)) / 2,
(sd1^2 + sd2^2) / 2
)
}
}
}
}
# TODO: Implement large-sample vs unbiased sampling error variance
if (is.null(diff)) {
if (is.null(m1) & is.null(m2)) {
stop("One of these must be provided:\n (1) `diff`\n (2) `m1`, `m2`")
} else {
diff <- m2 - m1
}
}
# Adjust standardizer for covariates
A <- (1 - R2_cov)
mse <- mse / A
# Compute d
d <- diff / sqrt(mse)
# Compute variance of d
se_var <-
switch(method,
smd_pooledSD = A * (n1 + n2) / (n1 * n2) + d^2 / (2 * (n1 + n2)),
smd_controlSD = A * (1/n1 + (sd2^2 / sd1^2) / n2 + d^2 / (2 * n1)),
smd_aveSD = A * (d^2 / (8 * mse^2) * (sd1^4 / (n1 - 1) + sd2^4 / (n2 - 1)) + (sd1^2 / mse) / (n1 - 1) + (sd2^2 / mse) / (n2 - 1))
)
if (method != "smd_pooledSD" & R2_cov != 0) {
warning(paste0("Covariate-adjusted 'se_var' is only approximately accurate when `method` is ", method, "."))
}
# Small-sample bias correction
if (unbiased) {
J <- .cmicalc(df)
} else {
J <- 1
}
# Compute confidence interval
if (conf.int) {
conf_method <- match.arg(conf_method, choices = c("t", "norm"))
# TODO: Add alternate ci methods from Bonett 2008
if (method != "smd_pooledSD" & conf_method == "t") {
# TODO: Derive ncp-based CIs for glass and bonnett d
conf_method <- "norm"
message(paste0("`conf_method` == 't' not available when `method` == ", method, ".\n`conf_method` == 'norm' used instead."))
}
if (conf_method == "norm") {
limits <- d + c(-1, 1) * qnorm((1 - conf.level) / 2, lower.tail = FALSE) * sqrt(se_var)
} else {
if (is.null(t_ncp)) {
if (!is.null(se_diff)) {
t_ncp <- diff / se_diff
} else {
# Approximate NCP if neither `t_ncp` nor `se_diff` are given
n_eff <- (n1 * n2)/((n1 + n2) * A)
t_ncp <- d * sqrt(n_eff)
}
}
# TODO: Implement conf.limits.nct natively
limits <- unlist(MBESS::conf.limits.nct(t_ncp, df, conf.level, ...)[c("Lower.Limit", "Upper.Limit")])
if (!is.null(se_diff)) {
limits <- limits * se_diff / sqrt(mse)
} else {
limits <- limits / sqrt(n_eff)
}
}
names(limits) <- c("lower", "upper")
}
# Correct for small-sample bias
d <- d * J
se_var <- se_var * J^2
se <- sqrt(se_var)
if (conf.int) {
limits <- limits * J
out <- list(es = d, df = df, se = se, se_var = se_var, ci.lb = limits['lower'], ci.ub = limits['upper'])
} else {
out <- list(es = d, df = df, se = se, se_var = se_var)
}
out <- lapply(out, unname)
class(out) <- c("es_smd", "es", "list")
attr(out, "es") <- "SMD"
attr(out, "method") <- method
attr(out, "unbiased") <- unbiased
attr(out, "conf.level") <- conf.level
return(out)
}
print.es <- function(x, digits = 3, ...) {
if (attr(x, "es") == "SMD") {
if (attr(x, "method") == "smd_pooledSD") {
if (attr(x, "unbiased") == TRUE) {
cat("Standardized mean difference (Cohen's d)\n")
} else {
cat("Standardized mean difference (Hedges' g)\n")
}
cat("========================================")
} else if (attr(x, "method") == "smd_controlSD") {
if (attr(x, "unbiased") == TRUE) {
cat("Standardized mean difference (Glass' \u0394 [unbiased])\n")
cat("=======================================================")
} else {
cat("Standardized mean difference (Glass' \u0394)\n")
cat("============================================")
}
} else {
if (attr(x, "unbiased") == TRUE) {
cat("Standardized mean difference (Bonett's d [unbiased])\n")
cat("=======================================================")
} else {
cat("Standardized mean difference (Bonett's d)\n")
cat("============================================")
}
}
cat("\n\n")
df <- as.data.frame(x)
print.data.frame(df, digits = digits, row.names = FALSE)
}
invisible(x)
}
mod <- lm(mpg ~ factor(cyl, levels = c(4, 6, 8)) + hp, data = mtcars)
r2_cov <- summary(lm(mpg ~ hp, data = mtcars))$r.squared
print.es(
smd_stats(diff = summary(mod)$coef[2,1],
se_diff = summary(mod)$coef[2,2],
t_ncp = summary(mod)$coef[2,3],
df = mod$df.residual,
sigma = summary(mod)$sigma,
R2_cov = r2_cov,
n1 = table(model.frame(mod)[,2])[1],
n2 = table(model.frame(mod)[,2])[2],
conf.int = TRUE
)
)
# TODO: Add smd.lm() method to automatically extract factors and compute d values.
# Formula to use with lm output
# d_adj <- t * se_b / sigma * sqrt(1 - R2_cov)
- Dominant language
- R
- Stars
- 353
- Forks
- 25
- Avg merge
- 9h 21m
- Merged PRs (30d)
- 1
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from easystats/effectsize
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
easystats/effectsize#709 ·
-
Difficulty 4/5 3-5 days Newbie friendliness 35/100
easystats/effectsize#708 ·
-
Difficulty 4/5 3-5 days Newbie friendliness 20/100
easystats/effectsize#705 · 1 comment ·
-
consistency :apple: :green_apple: docs 📑
Difficulty 3/5 1-2 days Newbie friendliness 35/100
easystats/effectsize#681 · 1 comment ·
-
Significance tests Openenhancement 🔥
Difficulty 5/5 Over a week Newbie friendliness 25/100
easystats/effectsize#638 · 7 comments ·
All issues in easystats/effectsize
Similar issues
-
documentation pkg infrastructure
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
epiverse-trace/epiparameter#511 ·
-
function:write_dwc
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
r-lib/pkgdepends#485 · 3 comments ·
-
Difficulty 1/5 Under an hour Newbie friendliness 92/100
-
beginners blocker
Difficulty 2/5 1-3 hours Newbie friendliness 78/100