Code
library(matchatr)
library(survival)A case-cohort study draws a random subcohort from the full cohort at baseline and augments it with every case (regardless of subcohort membership). The key advantage over nested case-control sampling is that the subcohort serves as a control group simultaneously for all endpoints of interest — even endpoints ascertained years apart — because selection happens once, at the start. Controls are reused across failure times, which is what makes the pseudo-likelihood analysis necessary.
matchatr wraps survival::cch(), which implements the Prentice, Self-Prentice, Lin-Ying, and Borgan I/II pseudo-likelihoods with their correct asymptotic variances. The additional absolute-risk layer (IPW Breslow cumulative baseline hazard) is matchatr’s own.
We use survival::nwtco, the standard case-cohort example from the Handbook of Statistical Methods for Case-Control Studies (Borgan et al. 2018). A random subcohort of 668 subjects was drawn from 4 028 children enrolled in the National Wilms Tumour Study. The endpoint is relapse (rel); the exposure of interest is histological type (histol: 1 = unfavourable). The subcohort indicator is in.subcohort.
nwtco2 <- nwtco
nwtco2$subcohort <- as.logical(nwtco2$in.subcohort)
nwtco2$stage <- factor(nwtco2$stage)
cat("Cohort size: ", nrow(nwtco2), "\n")
#> Cohort size: 4028
cat("Subcohort: ", sum(nwtco2$subcohort), " (~",
round(100 * mean(nwtco2$subcohort)), "%)\n", sep = "")
#> Subcohort: 668 (~17%)
cat("Relapses: ", sum(nwtco2$rel), "\n")
#> Relapses: 571matcha() takes the full cohort — it subsets internally to cases + subcohort members before calling cch(), so the denominators for the per-stratum cohort sizes are computed correctly.
The Prentice (1986) pseudo-likelihood augments each risk set with the case if the case falls outside the subcohort. contrast() reports the hazard ratio with the Self-Prentice corrected asymptotic variance (the naive information-matrix SE is not valid here because controls are reused across failure times).
fit <- matcha(
nwtco2,
outcome = "rel",
exposure = "histol",
design = case_cohort(subcohort = "subcohort", time = "edrel",
method = "Prentice"),
confounders = ~ stage + age,
estimator = "cch"
)
fit
#> <matchatr_fit>
#> Design: Case-cohort
#> Estimator: cch (engine: cch)
#> Outcome: rel
#> Exposure: histol
#> Confounders: ~stage + age
#> N: 4028 (cases: 571, controls: 3457)
contrast(fit) # type = "hr" is the default for a case-cohort design
#> <matchatr_result>
#> Estimator: cch (engine: cch)
#> Estimand: hazard ratio
#> Contrast: Hazard ratio
#> CI method: model
#> N: 1239
#>
#> Contrasts:
#> comparison estimate se ci_lower ci_upper
#> <char> <num> <num> <num> <num>
#> 1: histol 4.473017 0.7143638 3.27084 6.117047Unfavourable histology roughly doubles the relapse hazard conditional on stage and age at diagnosis.
Three further methods are supported. "SelfPrentice" uses the subcohort alone at each failure time (slightly more efficient); "LinYing" uses the full subcohort plus all failures (Lin and Ying 1993).
fit_sp <- matcha(nwtco2, "rel", "histol",
design = case_cohort("subcohort", "edrel",
method = "SelfPrentice"),
confounders = ~ stage + age, estimator = "cch")
fit_ly <- matcha(nwtco2, "rel", "histol",
design = case_cohort("subcohort", "edrel",
method = "LinYing"),
confounders = ~ stage + age, estimator = "cch")
rbind(
Prentice = unlist(contrast(fit)$contrasts[1, c("estimate", "ci_lower", "ci_upper")]),
SelfPrentice = unlist(contrast(fit_sp)$contrasts[1, c("estimate", "ci_lower", "ci_upper")]),
LinYing = unlist(contrast(fit_ly)$contrasts[1, c("estimate", "ci_lower", "ci_upper")])
)
#> estimate ci_lower ci_upper
#> Prentice 4.473017 3.270840 6.117047
#> SelfPrentice 4.506659 3.295440 6.163054
#> LinYing 4.298614 3.239693 5.703652All three methods agree closely; differences reflect the distinct variance estimators rather than the point estimates.
When the subcohort is drawn by stratified sampling (e.g. oversampling a rare exposure group), the Borgan et al. (2000) IPW estimators weight each risk-set contribution by the inverse of its stratum-specific subcohort sampling fraction N_s / n_sub_s. Provide the stratification column via stratum =.
fit_b2 <- matcha(
nwtco2,
outcome = "rel",
exposure = "histol",
design = case_cohort(subcohort = "subcohort", time = "edrel",
method = "II.Borgan", stratum = "stage"),
confounders = ~ age,
estimator = "cch"
)
contrast(fit_b2)
#> <matchatr_result>
#> Estimator: cch (engine: cch)
#> Estimand: hazard ratio
#> Contrast: Hazard ratio
#> CI method: model
#> N: 1154
#>
#> Contrasts:
#> comparison estimate se ci_lower ci_upper
#> <char> <num> <num> <num> <num>
#> 1: histol 4.437016 0.6305204 3.358391 5.862065Requesting a Borgan method without supplying stratum is declined:
matcha(nwtco2, "rel", "histol",
design = case_cohort("subcohort", "edrel", method = "I.Borgan"),
confounders = ~ stage + age, estimator = "cch")
#> Error in `fit_cch()` at matchatr/R/dispatch.R:236:3:
#> ! `method = "I.Borgan"` requires a subcohort stratification column.
#> ℹ Supply `stratum = "<column>"` in `case_cohort()` to specify which column defines the subcohort sampling strata.absolute_risk() evaluates the cumulative incidence F_x(t) = 1 − exp(−exp(β̂ᵀ x) Λ̂₀(t)) at specified times, where Λ̂₀(t) is the IPW Breslow estimator. The subcohort-only risk-set denominator at each event time is scaled by the inverse sampling fraction N / n_sub (or per-stratum N_s / n_sub_s for Borgan methods) so the estimate targets the full-cohort baseline hazard. Pointwise confidence intervals use the delta method on the complementary log-log scale (Borgan and Liestøl 1990).
# Two covariate profiles: favourable vs unfavourable histology, median age
nd <- data.frame(
histol = c(0L, 1L),
stage = factor(c(2, 2), levels = 1:4),
age = c(3, 3)
)
ar <- absolute_risk(fit, newdata = nd, times = c(500, 1000, 2000))
tidy(ar)
#> row time estimate ci_lower ci_upper
#> <int> <num> <num> <num> <num>
#> 1: 1 500 0.01838153 0.01290246 0.02615629
#> 2: 1 1000 0.02437886 0.01726042 0.03438128
#> 3: 1 2000 0.02600946 0.01844491 0.03661796
#> 4: 2 500 0.07963593 0.05018924 0.12518352
#> 5: 2 1000 0.10452260 0.06657944 0.16213006
#> 6: 2 2000 0.11119772 0.07099865 0.17195364We can compare the case-cohort absolute risk against the full-cohort Kaplan-Meier / Cox curves. Because the subcohort is only ~17% of the cohort, individual-run sampling variability is visible, but the estimates track the full-cohort curve closely.
cox_full <- coxph(Surv(edrel, rel) ~ histol + stage + age, data = nwtco2)
sf_full <- survfit(cox_full, newdata = nd)
f_full <- summary(sf_full, times = c(500, 1000, 2000))$surv
f_full <- 1 - f_full
cat("Full-cohort F_x(t) [histol=0, histol=1]:\n")
#> Full-cohort F_x(t) [histol=0, histol=1]:
print(round(f_full, 3))
#> 1 2
#> [1,] 0.014 0.067
#> [2,] 0.019 0.089
#> [3,] 0.020 0.095
cat("\nIPW Breslow F_x(t) [histol=0, histol=1]:\n")
#>
#> IPW Breslow F_x(t) [histol=0, histol=1]:
est_wide <- matrix(ar$estimates$estimate, nrow = 2, byrow = FALSE)
print(round(est_wide, 3))
#> [,1] [,2] [,3]
#> [1,] 0.018 0.026 0.105
#> [2,] 0.024 0.080 0.111A case-cohort fit identifies the hazard ratio. Requesting an odds ratio (appropriate for matched case-control data) is declined:
A marginal risk difference / risk ratio from the classical case-cohort analysis would require a source-population prevalence q0 that estimator = "cch" does not supply — but the design-weighted g-computation below gets there a different way.
The hazard ratio above is a conditional effect on the hazard scale. For a marginal causal contrast — the absolute risk, risk difference, risk ratio, or restricted mean survival time (RMST) the whole cohort would show under one exposure versus the other — use estimator = "surv_gcomp". It g-computes on the same design-weighted Cox: it standardizes the model-based absolute risk \(F(t \mid x, W) = 1 - \exp(-\hat\Lambda_0(t)\,e^{\hat\beta^\top x})\) over the subcohort (a random sample of the cohort, weighted by the inverse subcohort sampling fraction) to the treat-all and treat-none marginal risks, then contrasts them. No q0 is needed: a case-cohort samples the cohort directly, so the subcohort already represents the source-population covariate distribution. Variance is a design-preserving bootstrap.
The exposure must be binary, so we code unfavourable histology as a 0/1 indicator:
nwtco2$histol_uh <- as.integer(nwtco2$histol == 2L)
fit_g <- matcha(
nwtco2,
outcome = "rel",
exposure = "histol_uh",
design = case_cohort(subcohort = "subcohort", time = "edrel"),
confounders = ~ stage + age,
estimator = "surv_gcomp"
)
# Marginal risk difference at two relapse-free follow-up times (days).
contrast(fit_g, type = "difference", times = c(1500, 3000), n_boot = 200)
#> <matchatr_result>
#> Estimator: surv_gcomp (engine: surv_gcomp)
#> Estimand: marginal risk difference
#> Contrast: Risk difference
#> CI method: bootstrap
#> N: 668
#>
#> Contrasts:
#> comparison time estimate se ci_lower ci_upper
#> <char> <num> <num> <num> <num> <num>
#> 1: treat-all vs treat-none 1500 0.2782414 0.04199591 0.2068035 0.3698705
#> 2: treat-all vs treat-none 3000 0.2830524 0.04267281 0.2087195 0.3775254Unfavourable histology raises the marginal relapse risk by roughly 28 percentage points — a population-level statement the hazard ratio cannot make directly. The restricted mean survival time difference up to a horizon summarises the gap as expected relapse-free days lost:
contrast(fit_g, type = "rmst", times = 3000, n_boot = 200)
#> <matchatr_result>
#> Estimator: surv_gcomp (engine: surv_gcomp)
#> Estimand: marginal RMST difference
#> Contrast: rmst
#> CI method: bootstrap
#> N: 668
#>
#> Contrasts:
#> comparison time estimate se ci_lower ci_upper
#> <char> <num> <num> <num> <num> <num>
#> 1: treat-all vs treat-none 3000 -746.4646 112.9313 -1014.731 -569.4708The risk ratio (type = "ratio") is available on the same fit.
Legend. ✅ truth-pinned in tests · ⛔ rejected with an informative error.
| Method | Subcohort | Estimand | Variance | Status |
|---|---|---|---|---|
| Prentice | simple | HR | Self-Prentice asymptotic | ✅ |
| SelfPrentice / LinYing | simple | HR | asymptotic | ✅ |
| I.Borgan | stratified | HR | Borgan asymptotic | ✅ |
| II.Borgan | stratified | HR | plug-in asymptotic | ✅ |
| any method | — | absolute risk F_x(t) | IPW Breslow + delta log-log CI | ✅ |
| surv_gcomp | simple / stratified | marginal RD(t) / RR(t) / RMST | design-preserving bootstrap | ✅ |
Borgan I/II, no stratum |
— | — | ⛔ matchatr_bad_design |
|
| cch | — | odds ratio | ⛔ use type = "hr" |
|
| cch | — | RD / RR | ⛔ need q0 (use surv_gcomp) |
|
| cch | — | sandwich / bootstrap CI | ⛔ matchatr_unsupported_variance |
|
| surv_gcomp | — | conditional OR / HR | ⛔ matchatr_unidentified_estimand |
|
| surv_gcomp | — | non-binary exposure | ⛔ matchatr_bad_input |
See FEATURE_COVERAGE_MATRIX.md for the full combination table.
---
title: "Case-cohort hazard ratios and absolute risk"
code-fold: show
code-tools: true
vignette: >
%\VignetteIndexEntry{Case-cohort hazard ratios and absolute risk}
%\VignetteEngine{quarto::html}
%\VignetteEncoding{UTF-8}
bibliography: references.bib
nocite: |
@self1988asymptotic
---
```{r}
#| include: false
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
```
A *case-cohort* study draws a random **subcohort** from the full cohort at
baseline and augments it with every case (regardless of subcohort membership).
The key advantage over nested case-control sampling is that the subcohort serves
as a control group simultaneously for all endpoints of interest — even endpoints
ascertained years apart — because selection happens once, at the start. Controls
are reused across failure times, which is what makes the pseudo-likelihood
analysis necessary.
matchatr wraps `survival::cch()`, which implements the Prentice, Self-Prentice,
Lin-Ying, and Borgan I/II pseudo-likelihoods with their correct asymptotic
variances. The additional absolute-risk layer (IPW Breslow cumulative baseline
hazard) is matchatr's own.
```{r}
#| message: false
library(matchatr)
library(survival)
```
## The Wilms tumour data
We use `survival::nwtco`, the standard case-cohort example from the
*Handbook of Statistical Methods for Case-Control Studies* [@borgan2018handbook].
A random subcohort
of 668 subjects was drawn from 4 028 children enrolled in the National Wilms
Tumour Study. The endpoint is relapse (`rel`); the exposure of interest is
histological type (`histol`: 1 = unfavourable). The subcohort indicator is
`in.subcohort`.
```{r}
nwtco2 <- nwtco
nwtco2$subcohort <- as.logical(nwtco2$in.subcohort)
nwtco2$stage <- factor(nwtco2$stage)
cat("Cohort size: ", nrow(nwtco2), "\n")
cat("Subcohort: ", sum(nwtco2$subcohort), " (~",
round(100 * mean(nwtco2$subcohort)), "%)\n", sep = "")
cat("Relapses: ", sum(nwtco2$rel), "\n")
```
`matcha()` takes the **full cohort** — it subsets internally to
cases + subcohort members before calling `cch()`, so the denominators for the
per-stratum cohort sizes are computed correctly.
## Hazard ratio: Prentice pseudo-likelihood
The @prentice1986casecohort pseudo-likelihood augments each risk set with the case if
the case falls outside the subcohort. `contrast()` reports the hazard ratio with
the Self-Prentice corrected asymptotic variance (the naive information-matrix SE
is not valid here because controls are reused across failure times).
```{r}
fit <- matcha(
nwtco2,
outcome = "rel",
exposure = "histol",
design = case_cohort(subcohort = "subcohort", time = "edrel",
method = "Prentice"),
confounders = ~ stage + age,
estimator = "cch"
)
fit
contrast(fit) # type = "hr" is the default for a case-cohort design
```
Unfavourable histology roughly doubles the relapse hazard conditional on stage
and age at diagnosis.
## Alternative pseudo-likelihoods
Three further methods are supported. `"SelfPrentice"` uses the subcohort alone
at each failure time (slightly more efficient); `"LinYing"` uses the full
subcohort plus all failures [@lin1993cox].
```{r}
fit_sp <- matcha(nwtco2, "rel", "histol",
design = case_cohort("subcohort", "edrel",
method = "SelfPrentice"),
confounders = ~ stage + age, estimator = "cch")
fit_ly <- matcha(nwtco2, "rel", "histol",
design = case_cohort("subcohort", "edrel",
method = "LinYing"),
confounders = ~ stage + age, estimator = "cch")
rbind(
Prentice = unlist(contrast(fit)$contrasts[1, c("estimate", "ci_lower", "ci_upper")]),
SelfPrentice = unlist(contrast(fit_sp)$contrasts[1, c("estimate", "ci_lower", "ci_upper")]),
LinYing = unlist(contrast(fit_ly)$contrasts[1, c("estimate", "ci_lower", "ci_upper")])
)
```
All three methods agree closely; differences reflect the distinct variance
estimators rather than the point estimates.
## Stratified subcohorts: Borgan I/II
When the subcohort is drawn by stratified sampling (e.g. oversampling a rare
exposure group), the @borgan2000exposure IPW estimators weight each risk-set
contribution by the inverse of its stratum-specific subcohort sampling fraction
`N_s / n_sub_s`. Provide the stratification column via `stratum =`.
```{r}
fit_b2 <- matcha(
nwtco2,
outcome = "rel",
exposure = "histol",
design = case_cohort(subcohort = "subcohort", time = "edrel",
method = "II.Borgan", stratum = "stage"),
confounders = ~ age,
estimator = "cch"
)
contrast(fit_b2)
```
Requesting a Borgan method without supplying `stratum` is declined:
```{r}
#| error: true
matcha(nwtco2, "rel", "histol",
design = case_cohort("subcohort", "edrel", method = "I.Borgan"),
confounders = ~ stage + age, estimator = "cch")
```
## Absolute risk: IPW Breslow cumulative baseline hazard
`absolute_risk()` evaluates the cumulative incidence
F_x(t) = 1 − exp(−exp(β̂ᵀ x) Λ̂₀(t)) at specified times, where Λ̂₀(t) is the
IPW Breslow estimator. The subcohort-only risk-set denominator at each event
time is scaled by the inverse sampling fraction `N / n_sub` (or per-stratum
`N_s / n_sub_s` for Borgan methods) so the estimate targets the full-cohort
baseline hazard. Pointwise confidence intervals use the delta method on the
complementary log-log scale [@borgan1990note].
```{r}
# Two covariate profiles: favourable vs unfavourable histology, median age
nd <- data.frame(
histol = c(0L, 1L),
stage = factor(c(2, 2), levels = 1:4),
age = c(3, 3)
)
ar <- absolute_risk(fit, newdata = nd, times = c(500, 1000, 2000))
tidy(ar)
```
We can compare the case-cohort absolute risk against the full-cohort Kaplan-Meier
/ Cox curves. Because the subcohort is only ~17% of the cohort, individual-run
sampling variability is visible, but the estimates track the full-cohort curve
closely.
```{r}
cox_full <- coxph(Surv(edrel, rel) ~ histol + stage + age, data = nwtco2)
sf_full <- survfit(cox_full, newdata = nd)
f_full <- summary(sf_full, times = c(500, 1000, 2000))$surv
f_full <- 1 - f_full
cat("Full-cohort F_x(t) [histol=0, histol=1]:\n")
print(round(f_full, 3))
cat("\nIPW Breslow F_x(t) [histol=0, histol=1]:\n")
est_wide <- matrix(ar$estimates$estimate, nrow = 2, byrow = FALSE)
print(round(est_wide, 3))
```
## One scale per design
A case-cohort fit identifies the **hazard ratio**. Requesting an odds ratio
(appropriate for matched case-control data) is declined:
```{r}
#| error: true
contrast(fit, type = "or")
```
A marginal risk difference / risk ratio from the *classical* case-cohort analysis
would require a source-population prevalence `q0` that `estimator = "cch"` does not
supply — but the design-weighted g-computation below gets there a different way.
## Marginal causal survival
The hazard ratio above is a *conditional* effect on the hazard scale. For a
**marginal** causal contrast — the absolute risk, risk difference, risk ratio, or
restricted mean survival time (RMST) the whole cohort would show under one exposure
versus the other — use `estimator = "surv_gcomp"`. It g-computes on the same
design-weighted Cox: it standardizes the model-based absolute risk
$F(t \mid x, W) = 1 - \exp(-\hat\Lambda_0(t)\,e^{\hat\beta^\top x})$ over the
subcohort (a random sample of the cohort, weighted by the inverse subcohort sampling
fraction) to the treat-all and treat-none marginal risks, then contrasts them. No
`q0` is needed: a case-cohort *samples the cohort directly*, so the subcohort already
represents the source-population covariate distribution. Variance is a
design-preserving bootstrap.
The exposure must be binary, so we code unfavourable histology as a 0/1 indicator:
```{r}
nwtco2$histol_uh <- as.integer(nwtco2$histol == 2L)
fit_g <- matcha(
nwtco2,
outcome = "rel",
exposure = "histol_uh",
design = case_cohort(subcohort = "subcohort", time = "edrel"),
confounders = ~ stage + age,
estimator = "surv_gcomp"
)
# Marginal risk difference at two relapse-free follow-up times (days).
contrast(fit_g, type = "difference", times = c(1500, 3000), n_boot = 200)
```
Unfavourable histology raises the marginal relapse risk by roughly 28 percentage
points — a population-level statement the hazard ratio cannot make directly. The
restricted mean survival time difference up to a horizon summarises the gap as
expected relapse-free days lost:
```{r}
contrast(fit_g, type = "rmst", times = 3000, n_boot = 200)
```
The risk ratio (`type = "ratio"`) is available on the same fit.
## Covered combinations
**Legend.** ✅ truth-pinned in tests · ⛔ rejected with an informative error.
| Method | Subcohort | Estimand | Variance | Status |
|---|---|---|---|---|
| Prentice | simple | HR | Self-Prentice asymptotic | ✅ |
| SelfPrentice / LinYing | simple | HR | asymptotic | ✅ |
| I.Borgan | stratified | HR | Borgan asymptotic | ✅ |
| II.Borgan | stratified | HR | plug-in asymptotic | ✅ |
| any method | — | absolute risk F_x(t) | IPW Breslow + delta log-log CI | ✅ |
| surv_gcomp | simple / stratified | marginal RD(t) / RR(t) / RMST | design-preserving bootstrap | ✅ |
| Borgan I/II, no `stratum` | — | — | ⛔ `matchatr_bad_design` |
| cch | — | odds ratio | ⛔ use `type = "hr"` |
| cch | — | RD / RR | ⛔ need `q0` (use `surv_gcomp`) |
| cch | — | sandwich / bootstrap CI | ⛔ `matchatr_unsupported_variance` |
| surv_gcomp | — | conditional OR / HR | ⛔ `matchatr_unidentified_estimand` |
| surv_gcomp | — | non-binary exposure | ⛔ `matchatr_bad_input` |
See `FEATURE_COVERAGE_MATRIX.md` for the full combination table.
## References
::: {#refs}
:::