Code
library(matchatr)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).
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.
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.
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.5000Note 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).
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.0000matcha() 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.
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.441885Each estimate recovers its analytical marginal truth:
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.2959The 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.
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.337812Case-control weighting needs q₀ and targets a marginal effect, so a few requests are declined with informative, classed errors:
# 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)`.# 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).# 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.08119688A 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.
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:
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.0768CCW-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:
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.0430With 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.
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.
---
title: "Marginal causal effects from a case-control sample"
code-fold: show
code-tools: true
vignette: >
%\VignetteIndexEntry{Marginal causal effects from a case-control sample}
%\VignetteEngine{quarto::html}
%\VignetteEncoding{UTF-8}
bibliography: references.bib
---
```{r}
#| include: false
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
```
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:
- it is **non-collapsible** — it changes when you add or drop a covariate, even
with no confounding, so the "adjusted OR" is not a marginal effect;
- it is on the **odds-ratio scale** only — there is no risk difference or risk
ratio, and no absolute risk, because a case-control sample fixes the outcome
margin by design and so carries no information about baseline risk.
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**
[@rose2008simple].
```{r}
#| message: false
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`](https://github.com/etverse/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.
```{r}
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)
```
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).
```{r}
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))
```
## 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**.
```{r}
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)
contrast(fit, type = "ratio") # marginal risk ratio
contrast(fit, type = "or") # marginal odds ratio
```
Each estimate recovers its analytical marginal truth:
```{r}
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)
```
## 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.
```{r}
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
)
```
## What is declined
Case-control weighting needs q₀ and targets a marginal effect, so a few requests
are declined with informative, classed errors:
```{r}
#| error: true
# 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"
)
```
```{r}
#| error: true
# The CCW g-formula reports a marginal effect, not a hazard ratio.
contrast(fit, type = "hr")
```
```{r}
#| error: true
# 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")
```
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 [@vanderlaan2006targeted] — 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:
```{r}
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)
```
## 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
[@rose2014doublerobust]. 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:
```{r}
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)
```
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
::: {#refs}
:::