Skip to content

Commit 60cc027

Browse files
ehsanxclaude
andcommitted
Add MOVER (Zou 2008) confidence-interval option for RERI
addint() and addintlist() gain `ci_method = c("delta", "mover")`. The default ("delta") keeps the existing symmetric Wald interval for backward compatibility; "mover" uses the Method Of Variance Estimates Recovery interval of Zou (2008) <doi:10.1093/aje/kwn104>, which recovers the variance of RERI from the component risk ratios' confidence limits and generally has better coverage for this bounded, skewed quantity. AP and S continue to use the delta method. The implementation builds the (log RR11, log RR10, log RR01) contrast from the model's coefficients and 3x3 vcov (handling both the joint and interaction parameterisations) and applies Zou's MOVER-R formula with the component correlations. Validated to match the interactionR package exactly on a shared glm fit. Adds a MOVER regression test (85 tests pass). R CMD check stays clean (0 errors, 0 warnings, 1 expected NOTE). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent aa5c666 commit 60cc027

6 files changed

Lines changed: 127 additions & 6 deletions

File tree

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ importFrom(scales,comma)
8383
importFrom(stats,as.formula)
8484
importFrom(stats,coef)
8585
importFrom(stats,confint)
86+
importFrom(stats,cov2cor)
8687
importFrom(stats,fitted)
8788
importFrom(stats,formula)
8889
importFrom(stats,model.frame)

R/addint.R

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
#' S = (RR11 - 1) / ((RR10 - 1) + (RR01 - 1))
2222
#'
2323
#' Confidence intervals for S are calculated on the log scale and then exponentiated.
24+
#' By default the RERI interval uses the symmetric delta-method (Wald) interval;
25+
#' set \code{ci_method = "mover"} for the MOVER interval of Zou (2008)
26+
#' <doi:10.1093/aje/kwn104>, which generally has better coverage.
2427
#'
2528
#' \strong{Scale caveat:} these measures are defined on the risk-ratio scale.
2629
#' When the model supplies odds ratios (logistic) or hazard ratios (Cox), RERI,
@@ -44,13 +47,19 @@
4447
#' @param measures A character vector specifying which measures to calculate.
4548
#' Options: `"RERI"`, `"AP"`, `"S"`, or `"all"`. Default is `"all"`.
4649
#' @param conf.level Confidence level for the interval (default 0.95).
50+
#' @param ci_method Character string giving the confidence-interval method for
51+
#' RERI: `"delta"` (default) for the symmetric delta-method Wald interval, or
52+
#' `"mover"` for the MOVER interval of Zou (2008)
53+
#' <doi:10.1093/aje/kwn104>, which recovers the variance from the component
54+
#' risk ratios' confidence limits and generally has better coverage. The AP and
55+
#' S intervals always use the delta method.
4756
#'
4857
#' @return A list containing named vectors for each requested measure.
4958
#' Each vector includes Estimate, SE (SE for RERI and AP, SE_log for S),
5059
#' LowerCI, UpperCI. Returns `NULL` or a partial list with `NA` values if
5160
#' calculation fails (e.g., missing coefficients, delta method error).
5261
#'
53-
#' @importFrom stats coef vcov qnorm pnorm na.omit
62+
#' @importFrom stats coef vcov qnorm pnorm na.omit cov2cor
5463
#' @importFrom msm deltamethod
5564
#'
5665
#' @export
@@ -89,9 +98,11 @@ addint <- function(model,
8998
type = c("joint", "interaction"),
9099
coef_names,
91100
measures = "all",
92-
conf.level = 0.95) {
101+
conf.level = 0.95,
102+
ci_method = c("delta", "mover")) {
93103

94104
type <- match.arg(type)
105+
ci_method <- match.arg(ci_method)
95106

96107
if (!requireNamespace("msm", quietly = TRUE)) {
97108
stop("Package 'msm' needed for this function to work. Please install it.", call. = FALSE)
@@ -224,6 +235,22 @@ addint <- function(model,
224235
lower_ci <- est_reri - z * se_reri
225236
upper_ci <- est_reri + z * se_reri
226237
}
238+
# Optionally replace the symmetric Wald interval with the MOVER interval
239+
# (Zou 2008), which recovers the variance from the component risk ratios'
240+
# confidence limits and generally has better coverage for RERI.
241+
if (identical(ci_method, "mover")) {
242+
mover <- tryCatch(
243+
.reri_mover_ci(selected_beta, selected_V, type, z, est_reri),
244+
error = function(e) {
245+
warning("MOVER interval failed for RERI; keeping the Wald interval: ",
246+
e$message)
247+
NULL
248+
})
249+
if (!is.null(mover) && all(is.finite(mover))) {
250+
lower_ci <- mover[1]
251+
upper_ci <- mover[2]
252+
}
253+
}
227254
}
228255
results_list$RERI <- c(Estimate = est_reri, SE = se_reri, LowerCI = lower_ci, UpperCI = upper_ci)
229256
names(results_list$RERI) <- c("RERI_Estimate", "RERI_SE", paste0("RERI_CI", floor(conf.level*100), "_low"), paste0("RERI_CI", floor(conf.level*100), "_upp"))
@@ -289,3 +316,45 @@ addint <- function(model,
289316

290317
return(results_list)
291318
}
319+
320+
# Internal: MOVER (Method Of Variance Estimates Recovery) confidence interval for
321+
# RERI = RR11 - RR10 - RR01 + 1, following Zou (2008) <doi:10.1093/aje/kwn104>.
322+
# `selected_beta`/`selected_V` are the three model coefficients and their 3x3
323+
# variance-covariance matrix, in the order implied by `type`. Returns
324+
# c(lower, upper). Not exported.
325+
.reri_mover_ci <- function(selected_beta, selected_V, type, z, est_reri) {
326+
# Contrast matrix mapping the coefficients to (log RR11, log RR10, log RR01).
327+
if (identical(type, "interaction")) {
328+
# selected_beta = (bA, bB, bAB): RR10 = exp(bA), RR01 = exp(bB),
329+
# RR11 = exp(bA + bB + bAB).
330+
C <- rbind(c(1, 1, 1), c(1, 0, 0), c(0, 1, 0))
331+
} else {
332+
# joint: selected_beta = (b10, b01, b11): RR10 = exp(b10), RR01 = exp(b01),
333+
# RR11 = exp(b11).
334+
C <- rbind(c(0, 0, 1), c(1, 0, 0), c(0, 1, 0))
335+
}
336+
337+
log_rr <- as.numeric(C %*% as.numeric(selected_beta))
338+
Sigma <- C %*% selected_V %*% t(C)
339+
se <- sqrt(diag(Sigma))
340+
if (any(!is.finite(se)) || any(se <= 0)) return(NULL)
341+
342+
R <- stats::cov2cor(Sigma)
343+
r12 <- R[1, 2]; r13 <- R[1, 3]; r23 <- R[2, 3] # (RR11,RR10),(RR11,RR01),(RR10,RR01)
344+
345+
theta <- exp(log_rr) # RR11, RR10, RR01
346+
l <- exp(log_rr - z * se)
347+
u <- exp(log_rr + z * se)
348+
349+
# RERI = theta1 - theta2 - theta3 + 1 (a1 = +1, a2 = a3 = -1).
350+
var_lower <- (theta[1] - l[1])^2 + (u[2] - theta[2])^2 + (u[3] - theta[3])^2 -
351+
2 * r12 * (theta[1] - l[1]) * (u[2] - theta[2]) -
352+
2 * r13 * (theta[1] - l[1]) * (u[3] - theta[3]) +
353+
2 * r23 * (u[2] - theta[2]) * (u[3] - theta[3])
354+
var_upper <- (u[1] - theta[1])^2 + (theta[2] - l[2])^2 + (theta[3] - l[3])^2 -
355+
2 * r12 * (u[1] - theta[1]) * (theta[2] - l[2]) -
356+
2 * r13 * (u[1] - theta[1]) * (theta[3] - l[3]) +
357+
2 * r23 * (theta[2] - l[2]) * (theta[3] - l[3])
358+
359+
c(est_reri - sqrt(max(0, var_lower)), est_reri + sqrt(max(0, var_upper)))
360+
}

R/addintlist.R

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
#' CIs in the final table. Default is 3. (Set to NULL for no rounding).
2828
#' @param verbose Logical. If `TRUE`, prints a progress message for each
2929
#' factor-level combination as it is calculated. Defaults to `FALSE`.
30+
#' @param ci_method Confidence-interval method for RERI passed to
31+
#' \code{\link{addint}}: `"delta"` (default) or `"mover"` (Zou 2008).
3032
#'
3133
#' @return A `tibble` (data frame) summarizing the additive interaction results.
3234
#' Columns include:
@@ -96,7 +98,10 @@ addintlist <- function(model,
9698
measures = "all",
9799
conf.level = 0.95,
98100
digits = 3,
99-
verbose = FALSE) {
101+
verbose = FALSE,
102+
ci_method = c("delta", "mover")) {
103+
104+
ci_method <- match.arg(ci_method)
100105

101106
# Check if the core calculation function 'addint' exists
102107
if (!exists("addint") || !is.function(addint)) {
@@ -227,7 +232,8 @@ addintlist <- function(model,
227232
type = "interaction",
228233
coef_names = current_coef_names_list,
229234
measures = measures_to_calc, # Use the validated list
230-
conf.level = conf.level
235+
conf.level = conf.level,
236+
ci_method = ci_method
231237
)
232238

233239
# --- Process results into a flat tibble for this combination ---

man/addint.Rd

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/addintlist.Rd

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-addint.R

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,36 @@ test_that("addint warns and returns NULL when a required coefficient is missing"
6464
expect_null(res)
6565
})
6666

67+
test_that("addint MOVER interval for RERI is valid and asymmetric (Zou 2008)", {
68+
beta <- c(A = 0.4, B = 0.3, `A:B` = 0.5)
69+
V <- matrix(c(0.02, 0.005, 0.004,
70+
0.005, 0.03, 0.006,
71+
0.004, 0.006, 0.05), 3, 3)
72+
dimnames(V) <- list(names(beta), names(beta))
73+
m <- make_mock(beta, V)
74+
cn <- list(exp1_coef = "A", exp2_coef = "B", inter_coef = "A:B")
75+
76+
delta <- addint(m, type = "interaction", coef_names = cn, measures = "RERI",
77+
ci_method = "delta")$RERI
78+
mover <- addint(m, type = "interaction", coef_names = cn, measures = "RERI",
79+
ci_method = "mover")$RERI
80+
81+
# Same point estimate; MOVER interval differs from the symmetric Wald one.
82+
expect_equal(unname(mover[["RERI_Estimate"]]),
83+
unname(delta[["RERI_Estimate"]]), tolerance = 1e-10)
84+
expect_lt(mover[["RERI_CI95_low"]], mover[["RERI_Estimate"]])
85+
expect_gt(mover[["RERI_CI95_upp"]], mover[["RERI_Estimate"]])
86+
87+
# Asymmetric around the estimate (unlike the delta interval).
88+
upper_half <- mover[["RERI_CI95_upp"]] - mover[["RERI_Estimate"]]
89+
lower_half <- mover[["RERI_Estimate"]] - mover[["RERI_CI95_low"]]
90+
expect_false(isTRUE(all.equal(upper_half, lower_half)))
91+
92+
# Pinned values, cross-checked against the interactionR package.
93+
expect_equal(unname(mover[["RERI_CI95_low"]]), 0.3191, tolerance = 1e-3)
94+
expect_equal(unname(mover[["RERI_CI95_upp"]]), 4.4499, tolerance = 1e-3)
95+
})
96+
6797
test_that("addint validates the measures argument", {
6898
beta <- c(A = 0.4, B = 0.3, `A:B` = 0.5)
6999
V <- diag(3)

0 commit comments

Comments
 (0)