Marginal causal effects from a case-control sample

Every estimator in the previous articles reports a conditional odds ratio (or a conditional hazard ratio). That is a perfectly good within-stratum effect, but it is not what a population decision usually needs:

A marginal causal effect — the average treatment effect as a risk difference (RD), risk ratio (RR), or marginal odds ratio (mOR) — needs one extra ingredient: the source-population outcome prevalence q₀ = P(Y = 1). With q₀ we can undo the case-oversampling and recover the marginal effect by case-control weighting (Rose and Laan 2008).

Code
library(matchatr)

The idea: reweight the sample back to the population

A case-control study oversamples cases. If we weight each case by q₀ / (sample case fraction) and each control by (1 − q₀) / (sample control fraction), the weighted sample has outcome margin q₀ again — its weighted empirical distribution mimics the source cohort. A cohort estimator applied to the weighted sample (here g-computation: fit a weighted outcome model and standardize over the covariates) then targets the marginal estimand. matchatr builds these weights internally; you only supply q₀ on the design via unmatched_cc(prevalence = q0).

The point estimate and its variance are delegated to the etverse causal engine causatr; matchatr owns the weighting and sampling-design layer.

A cohort with a known marginal effect

We simulate a cohort with a binary exposure x confounded by a continuous w, so the conditional and marginal effects genuinely differ. Because we know the true outcome model, we can compute the marginal truth analytically by the g-formula: average the counterfactual risks under “everyone exposed” and “everyone unexposed” over the cohort.

Code
set.seed(13)
n      <- 1e5
alpha  <- -3
beta_x <- log(2.5)        # the CONDITIONAL log odds ratio for x
gamma  <- 1.0
w  <- rnorm(n)
x  <- rbinom(n, 1, plogis(0.2 * w))                 # x confounded by w
y  <- rbinom(n, 1, plogis(alpha + beta_x * x + gamma * w))

# g-formula truth on the full cohort (true model): mean counterfactual risks.
m1 <- mean(plogis(alpha + beta_x + gamma * w))      # treat-all
m0 <- mean(plogis(alpha + gamma * w))               # treat-none
truth <- c(
  RD  = m1 - m0,
  RR  = m1 / m0,
  mOR = (m1 / (1 - m1)) / (m0 / (1 - m0)),
  conditional_OR = exp(beta_x)
)
round(truth, 4)
#>             RD             RR            mOR conditional_OR 
#>         0.0768         2.1065         2.2959         2.5000

Note the marginal OR (mOR) is not the conditional OR exp(beta_x) = 2.5: non-collapsibility pulls the marginal OR toward the null.

Now draw an unmatched case-control sample — every case plus a 5:1 random sample of controls — and record the source prevalence q₀ = P(Y = 1).

Code
q0 <- mean(y)
cohort   <- data.frame(case = y, x = x, w = w)
cases    <- cohort[cohort$case == 1, ]
controls <- cohort[cohort$case == 0, ]
controls <- controls[sample.int(nrow(controls), 5 * nrow(cases)), ]
cc <- rbind(cases, controls)
rownames(cc) <- NULL
c(q0 = round(q0, 4), n_cases = nrow(cases), n_controls = nrow(controls))
#>         q0    n_cases n_controls 
#>     0.1092 10924.0000 54620.0000

The marginal effect via case-control weighting

matcha() with estimator = "ccw_gformula" and a design carrying q₀ fits the case-control-weighted g-formula. contrast() then reports the marginal effect on the requested scale — the risk difference is the default.

Code
fit <- matcha(
  cc,
  outcome = "case", exposure = "x",
  design = unmatched_cc(prevalence = q0),
  confounders = ~ w,
  estimator = "ccw_gformula"
)

contrast(fit, type = "difference")   # marginal risk difference (default)
#> <matchatr_result>
#>  Estimator:  ccw_gformula  (engine: ccw_gformula)
#>  Estimand:   marginal risk difference
#>  Contrast:   Risk difference
#>  CI method:  sandwich
#>  N:          65544
#> 
#> Contrasts:
#>            comparison   estimate          se   ci_lower   ci_upper
#>                <char>      <num>       <num>      <num>      <num>
#> 1: treated vs control 0.07766085 0.001941464 0.07385565 0.08146605
contrast(fit, type = "ratio")        # marginal risk ratio
#> <matchatr_result>
#>  Estimator:  ccw_gformula  (engine: ccw_gformula)
#>  Estimand:   marginal risk ratio
#>  Contrast:   Risk ratio
#>  CI method:  sandwich
#>  N:          65544
#> 
#> Contrasts:
#>            comparison estimate         se ci_lower ci_upper
#>                <char>    <num>      <num>    <num>    <num>
#> 1: treated vs control 2.143021 0.04327111 2.059867 2.229531
contrast(fit, type = "or")           # marginal odds ratio
#> <matchatr_result>
#>  Estimator:  ccw_gformula  (engine: ccw_gformula)
#>  Estimand:   marginal odds ratio
#>  Contrast:   Odds ratio
#>  CI method:  sandwich
#>  N:          65544
#> 
#> Contrasts:
#>            comparison estimate         se ci_lower ci_upper
#>                <char>    <num>      <num>    <num>    <num>
#> 1: treated vs control 2.337812 0.05195154 2.238174 2.441885

Each estimate recovers its analytical marginal truth:

Code
est <- c(
  RD  = contrast(fit, type = "difference")$contrasts$estimate,
  RR  = contrast(fit, type = "ratio")$contrasts$estimate,
  mOR = contrast(fit, type = "or")$contrasts$estimate
)
round(rbind(estimate = est, truth = truth[c("RD", "RR", "mOR")]), 4)
#>              RD     RR    mOR
#> estimate 0.0777 2.1430 2.3378
#> truth    0.0768 2.1065 2.2959

Marginal is not conditional

The same sample analysed with the plain conditional logistic estimator returns the conditional odds ratio exp(beta_x) = 2.5 — a different number from the marginal OR above. This is the non-collapsibility distinction that motivates case-control weighting: the two estimators answer different questions.

Code
cond <- matcha(
  cc,
  outcome = "case", exposure = "x",
  design = unmatched_cc(),     # no q0: only the conditional OR is identified
  confounders = ~ w,
  estimator = "logistic"
)
c(
  conditional_OR = contrast(cond, type = "or")$contrasts$estimate,
  marginal_OR    = contrast(fit,  type = "or")$contrasts$estimate
)
#> conditional_OR    marginal_OR 
#>       2.553488       2.337812

What is declined

Case-control weighting needs q₀ and targets a marginal effect, so a few requests are declined with informative, classed errors:

Code
# No q0 on the design: the sample cannot be reweighted to the population.
matcha(
  cc, outcome = "case", exposure = "x",
  design = unmatched_cc(), estimator = "ccw_gformula"
)
#> Error in `matcha()`:
#> ! Estimator `ccw_gformula` needs the source-population prevalence q0 to reweight the sample.
#> ℹ Supply it on the design, e.g. `unmatched_cc(prevalence = 0.02)`.
Code
# The CCW g-formula reports a marginal effect, not a hazard ratio.
contrast(fit, type = "hr")
#> Error in `contrast()`:
#> ! A case-control-weighted estimator reports a marginal effect, not `type = "hr"`.
#> ℹ Use `type = "difference"` (risk difference), `"ratio"` (risk ratio), or `"or"` (marginal odds ratio).
Code
# A bootstrap interval must resample within case / control strata and recompute
# the q0 weights each replicate (a later chunk); only the sandwich is wired here.
contrast(fit, type = "difference", ci_method = "bootstrap")
#> <matchatr_result>
#>  Estimator:  ccw_gformula  (engine: ccw_gformula)
#>  Estimand:   marginal risk difference
#>  Contrast:   Risk difference
#>  CI method:  bootstrap
#>  N:          65544
#> 
#> Contrasts:
#>            comparison   estimate          se   ci_lower   ci_upper
#>                <char>      <num>       <num>      <num>      <num>
#> 1: treated vs control 0.07766085 0.001919944 0.07370626 0.08119688

A non-binary exposure is also declined: this estimator reports the binary average treatment effect (treat-all versus treat-none), so a continuous or multi-level exposure is routed to a conditional estimator instead.

Four estimators, one weighting concept

The same q₀ weights drive a whole family of cohort estimators on the weighted sample. estimator = "ccw_ipw" weights by an inverse-propensity model instead of standardizing an outcome model; estimator = "ccw_aipw" combines both into the augmented (doubly-robust) estimator; and estimator = "ccw_tmle" is the targeted maximum likelihood estimator (Laan and Rubin 2006) — also doubly robust, and the one engine matchatr builds itself (the etverse has no targeted-learning code to delegate to). All four report the same marginal effect when their models are correct:

Code
ests <- c("ccw_gformula", "ccw_ipw", "ccw_aipw", "ccw_tmle")
rd <- sapply(ests, function(e) {
  f <- matcha(cc, outcome = "case", exposure = "x",
              design = unmatched_cc(prevalence = q0),
              confounders = ~ w, estimator = e)
  contrast(f, type = "difference")$contrasts$estimate
})
round(rbind(marginal_RD = rd, truth = truth["RD"]), 4)
#>             ccw_gformula ccw_ipw ccw_aipw ccw_tmle
#> marginal_RD       0.0777  0.0778   0.0777   0.0777
#> truth             0.0768  0.0768   0.0768   0.0768

Double robustness of CCW-AIPW

CCW-AIPW’s appeal is that it is consistent if either the outcome model or the propensity model is correct — you get two chances to be right (Rose and Laan 2014). We can see this directly. Build a cohort where the outcome truly depends on w non-linearly but the propensity is linear in w; fitting both working models as ~ w (linear) then misspecifies the outcome model while the propensity model is correct:

Code
set.seed(20)
n <- 1e5; w <- rnorm(n)
a <- rbinom(n, 1, plogis(2 * w))                          # propensity linear in w: CORRECT
lp <- function(av) -2.3 + 0.7 * av + 2 * w - 2.5 * w^2    # outcome non-linear: a ~w model is WRONG
y <- rbinom(n, 1, plogis(lp(a)))
truth_rd <- mean(plogis(lp(1)) - plogis(lp(0)))           # g-formula truth
q0b <- mean(y)
co <- data.frame(case = y, x = a, w = w)
ca <- co[co$case == 1, ]; ct <- co[co$case == 0, ]
ct <- ct[sample.int(nrow(ct), 5 * nrow(ca)), ]
ccb <- rbind(ca, ct); rownames(ccb) <- NULL

rd2 <- sapply(ests, function(e) {
  f <- matcha(ccb, outcome = "case", exposure = "x",
              design = unmatched_cc(prevalence = q0b),
              confounders = ~ w, estimator = e)
  contrast(f, type = "difference")$contrasts$estimate
})
round(rbind(marginal_RD = rd2, truth = truth_rd), 4)
#>             ccw_gformula ccw_ipw ccw_aipw ccw_tmle
#> marginal_RD       0.0793  0.0455   0.0456   0.0394
#> truth             0.0430  0.0430   0.0430   0.0430

With the outcome model misspecified, ccw_gformula is biased, but ccw_ipw (correct propensity), ccw_aipw, and ccw_tmle recover the truth. Swap the misspecification to the propensity model and it is ccw_ipw that fails while ccw_gformula, ccw_aipw, and ccw_tmle stay on target — AIPW and TMLE are robust to either.

Where this sits

The case-control-weighting family now offers g-formula, IPW, the doubly-robust AIPW, and the targeted (TMLE) marginal contrasts for an unmatched case-control study with a known prevalence. Still to come (forthcoming chunks): the estimated-q₀ variance correction, a design-respecting bootstrap, and matched / nested case-control support — all built on the same one weighting concept.

References

Laan, Mark J. van der, and Daniel Rubin. 2006. “Targeted Maximum Likelihood Learning.” The International Journal of Biostatistics 2 (1): Article 11. https://doi.org/10.2202/1557-4679.1043.
Rose, Sherri, and Mark J. van der Laan. 2008. “Simple Optimal Weighting of Cases and Controls in Case-Control Studies.” The International Journal of Biostatistics 4 (1): Article 19. https://doi.org/10.2202/1557-4679.1115.
Rose, Sherri, and Mark J. van der Laan. 2014. “A Double Robust Approach to Causal Effects in Case-Control Studies.” American Journal of Epidemiology 179 (6): 663–69. https://doi.org/10.1093/aje/kwt318.