Causal Mediation Analysis with causalMed

Introduction

mediation() splits the effect of a time-varying exposure into a direct effect and an indirect effect through one or more time-varying mediators, including when confounders are affected by earlier exposure, the setting the mediational g-formula was developed for (Lin et al. 2017; VanderWeele & Tchetgen Tchetgen 2017). This vignette works through a survival example and reads every row of the output, then covers other exposure regimes, multiple mediators, censoring, bootstrap intervals and natural effects.

Data format, spec_model() and the recodes() hooks are introduced in vignette("causalMed-01-overview").

library(causalMed)
library(data.table)

The two estimands

mediation_type chooses between two definitions of the direct and indirect effects.

Natural effects ("N"; Zheng & van der Laan 2017) decompose the total effect exactly:

\[\underbrace{E[Y_{1,M(1)}] - E[Y_{0,M(0)}]}_{\text{Total effect}} = \underbrace{E[Y_{1,M(0)}] - E[Y_{0,M(0)}]}_{\text{Direct effect}} + \underbrace{E[Y_{1,M(1)}] - E[Y_{1,M(0)}]}_{\text{Indirect effect}}\]

Here the mediator is drawn from its conditional distribution: at each time point it is predicted from the subject’s own history, with the exposure and its first-order lags set to the other level.

Interventional effects ("I", the default; Lin et al. 2017) set the mediator to a random draw \(G_{a^*}\) from its marginal distribution under \(a^*\), drawn independently for each mediator. The direct and indirect effects then sum to the interventional overall effect \(E[Y_{1,G_1}] - E[Y_{0,G_0}]\), which generally differs from the total effect \(E[Y_1] - E[Y_0]\). mediation() reports the total effect separately and adds the difference as a row, TE - (Direct + Indirect), the decomposition residual.

Interventional ("I") Natural ("N")
Mediator drawn from Marginal distribution (permutation) Conditional distribution (own history)
Cross-world assumption needed No Only to read the effects as individual-level
Identified with exposure-affected confounders Yes Yes, as the conditional-draw estimand
Several mediators Yes (Yamamuro et al. 2021) No
Reference Lin et al. (2017) Zheng & van der Laan (2017)

Zheng & van der Laan (2017, Lemma 1) identify the "N" effects under sequential randomization and positivity. Reading them as individual-level natural effects, contrasts of each subject’s own counterfactual mediator, additionally requires a cross-world assumption that is not expected to hold when a mediator-outcome confounder is itself affected by the exposure (Avin, Shpitser & Pearl 2005; VanderWeele & Tchetgen Tchetgen 2017). For that setting VanderWeele & Tchetgen Tchetgen (2017) propose the interventional effects, which is why "I" is the default. With "N", mediation() warns when a covariate model includes the exposure; the check reads formulas only and cannot confirm or rule out such a confounder.

Requirements

Survival data

With a survival outcome, a mod_type = "survival" model estimates the discrete-time hazard among subjects still at risk. The simulation accumulates it into the cumulative incidence \(1 - \prod_t (1 - h_t)\), so every reported quantity is a risk of the event by the end of follow-up. The data must have one row per subject per period at risk, with no rows after the event.

data("survivaldata")
dat <- as.data.table(survivaldata)
head(dat, 8)
#>       id  time          V     A           L     M     Y     C lag1_A lag1_M
#>    <int> <int>      <num> <int>       <num> <num> <int> <int>  <int>  <num>
#> 1:     1     0 -0.9422279     1  0.10013047     0     0     0      0      0
#> 2:     1     1 -0.9422279     0 -0.05257470     0     0     0      1      0
#> 3:     1     2 -0.9422279     0  0.02384068     1     0     0      0      0
#> 4:     1     3 -0.9422279     0  0.14223027     0     0     0      0      1
#> 5:     1     4 -0.9422279     0  0.30567376     0     0     0      0      0
#> 6:     2     0  2.4237402     1  2.16300892     1     0     0      0      0
#> 7:     2     1  2.4237402     1  2.63056349     0     0     0      1      1
#> 8:     2     2  2.4237402     1  2.95539778     1     0     0      1      0
#>         lag1_L
#>          <num>
#> 1:  0.00000000
#> 2:  0.10013047
#> 3: -0.05257470
#> 4:  0.02384068
#> 5:  0.14223027
#> 6:  0.00000000
#> 7:  2.16300892
#> 8:  2.63056349

survivaldata follows 3,000 subjects over five periods (time 0 to 4):

Variable Role
id, time Subject and time index
V Baseline covariate
A Binary exposure
L Continuous confounder
M Binary mediator
Y Event indicator
C Loss to follow-up
lag1_A, lag1_L, lag1_M Previous-period values

Within each period the order is A → L → M → Y (see ?survivaldata).

Single mediator: interventional effects

The models follow that order, with a censoring model for loss to follow-up (see Censoring below) and a hazard model that may include exposure-mediator interactions:

init_s <- recodes(lag1_A = 0, lag1_L = 0, lag1_M = 0)
in_s   <- recodes(lag1_A = A, lag1_L = L, lag1_M = M)

models_surv <- list(
  spec_model(A ~ V + lag1_A + lag1_L + time,
             var_type = "binary", mod_type = "exposure"),
  spec_model(L ~ V + A + lag1_L + time,
             var_type = "normal", mod_type = "covariate"),
  spec_model(M ~ V + A + L + lag1_M + time,
             var_type = "binary", mod_type = "mediator"),
  spec_model(C ~ V + L + time,
             var_type = "binary", mod_type = "censor"),    # loss to follow-up
  spec_model(Y ~ V + A + M + L + A:M + time,
             var_type = "binary", mod_type = "survival")   # discrete-time hazard
)
fit_surv <- mediation(
  data           = dat,
  id_var         = "id",
  time_var       = "time",
  base_vars      = "V",
  exposure       = "A",
  outcome        = "Y",
  models         = models_surv,
  init_recode    = init_s,
  in_recode      = in_s,
  mediation_type = "I",
  mc_sample      = 20000,
  R              = 1,      # set R > 1 for bootstrap CIs (see below)
  quiet          = TRUE,
  seed           = 2026
)

print(fit_surv) shows everything below together, with a legend of the interventions, the analysis setup, and an observed (nonparametric) cumulative-incidence benchmark.

The interventions

fit_surv$effect_size
#>    Intervention       Est
#>          <char>     <num>
#> 1:         nat0 0.4106340
#> 2:         nat1 0.7868999
#> 3:        Phi00 0.4177377
#> 4:        Phi10 0.5903442
#> 5:        Phi11 0.7823181

Each row is the simulated cumulative incidence under one intervention:

The pools hold each simulated subject’s whole mediator trajectory; each subject is given the trajectory of a randomly permuted pool member (see ?mediation).

The decomposition

fit_surv$estimate
#>                      Effect          RD       RR
#>                      <char>       <num>    <num>
#> 1:          Indirect effect  0.19197390 1.325190
#> 2:            Direct effect  0.17260655 1.413194
#> 3:             Total effect  0.37626584 1.916305
#> 4: TE - (Direct + Indirect)  0.01168539       NA
#> 5:     Mediation Proportion 52.65611453       NA

RD is the risk difference and RR the risk ratio (NA for the last two rows). With R > 1 both gain bootstrap standard errors and confidence limits.

Permutation averaging: n_vw

Each pool-drawing intervention (Phi00, Phi10, Phi11) averages n_vw independent permutations of the pool (default 2, as in the SAS mGFORMULA macro). n_vw = 1 is faster, with more Monte Carlo noise. It has no effect under "N", which does not permute.

mediation(..., n_vw = 1)

Other exposure regimes

By default the effects compare always exposed with never exposed, the example Lin et al. (2017, Section 2.2) and Zheng & van der Laan (2017, Section 2.2) give when defining the effects for arbitrary regimes. exposure_regime and reference_regime take any two static regimes as 0/1 vectors, one element per distinct time point (a scalar is recycled). Exposure at times 1 and 2 only, against never exposed:

fit_window <- mediation(
  data             = dat,
  id_var           = "id",
  time_var         = "time",
  base_vars        = "V",
  exposure         = "A",
  outcome          = "Y",
  models           = models_surv,
  init_recode      = init_s,
  in_recode        = in_s,
  mediation_type   = "I",
  exposure_regime  = c(0, 1, 1, 0, 0),   # exposed at t = 1 and 2 only
  reference_regime = 0,                  # never exposed
  mc_sample        = 20000,
  R                = 1,
  quiet            = TRUE,
  seed             = 2026
)
fit_window$estimate
#>                      Effect           RD       RR
#>                      <char>        <num>    <num>
#> 1:          Indirect effect  0.111479530 1.226072
#> 2:            Direct effect  0.075377855 1.180443
#> 3:             Total effect  0.192451782 1.468670
#> 4: TE - (Direct + Indirect)  0.005594397       NA
#> 5:     Mediation Proportion 59.660221446       NA

The rows read as before, with \(a\) now the windowed regime. Two points:

Which pair of regimes answers a given question is the analyst’s judgement; the package checks only that they are 0/1, of the right length, and different. print() also reports how many observed subjects follow each regime:

fit_window$data_summary$regime_support
#>   regime    values n_following prop_following n_complete
#> 1      a 0 1 1 0 0         489          0.163         23
#> 2     a* 0 0 0 0 0         528          0.176        162

n_following counts subjects whose exposure matches the regime at every time they were observed; n_complete counts those among subjects observed at every time point. The estimation does not use these counts: the g-formula produces an estimate whether or not anyone followed the regime.

Multiple mediators: the Yamamuro et al. (2021) simulation

Under "I", several mediators are analysed by listing their models in temporal order. yamamurodata was simulated from the data-generating process of Yamamuro et al. (2021): a treatment A, a confounder L, two sequential mediators M1 and M2, and a survival outcome over three visits, ordered A → L → M1 → M2 → Y. Its true effects are given in ?yamamurodata.

data("yamamurodata")
yam <- as.data.table(yamamurodata)
head(yam, 6)
#>       id  time     V     A        L       M1       M2     Y lag1_A   lag1_L
#>    <int> <int> <int> <int>    <num>    <num>    <num> <int>  <int>    <num>
#> 1:     1     0     0     0 22.99932 154.1412 6560.201     0      0  0.00000
#> 2:     1     1     0     0 22.72770 150.8244 6674.132     0      0 22.99932
#> 3:     1     2     0     1 23.66563 142.4219 6724.582     0      0 22.72770
#> 4:     2     0     1     0 24.95263 188.5737 6593.398     0      0  0.00000
#> 5:     2     1     1     1 24.49130 141.9657 6196.278     0      0 24.95263
#> 6:     2     2     1     1 24.17914 132.8109 5922.934     0      1 24.49130
#>     lag1_M1  lag1_M2   L0base  M10base  M20base
#>       <num>    <num>    <num>    <num>    <num>
#> 1:   0.0000    0.000 22.99932 154.1412 6560.201
#> 2: 154.1412 6560.201 22.99932 154.1412 6560.201
#> 3: 150.8244 6674.132 22.99932 154.1412 6560.201
#> 4:   0.0000    0.000 24.95263 188.5737 6593.398
#> 5: 188.5737 6593.398 24.95263 188.5737 6593.398
#> 6: 141.9657 6196.278 24.95263 188.5737 6593.398

Three details of the specification:

models_yam <- list(
  spec_model(A ~ V + lag1_A + lag1_L + I(lag1_L^2) + lag1_M1 + lag1_M2 +
               I(as.integer(time == 2)),
             var_type = "binary", mod_type = "exposure", subset = time > 0),
  spec_model(L ~ V + A + lag1_L + I(lag1_L^2) + lag1_M1 + lag1_M2 +
               I(as.integer(time == 2)),
             var_type = "normal", mod_type = "covariate", subset = time > 0),
  spec_model(M1 ~ V + A + L + I(L^2) + lag1_L + I(lag1_L^2) + lag1_M1 + lag1_M2 +
               I(as.integer(time == 2)),
             var_type = "normal", mod_type = "mediator", subset = time > 0),
  spec_model(M2 ~ V + A + L + I(L^2) + lag1_L + I(lag1_L^2) + lag1_M1 + lag1_M2 +
               I(as.integer(time == 2)),
             var_type = "normal", mod_type = "mediator", subset = time > 0),
  spec_model(Y ~ V + A + L + I(L^2) + M1 + M2 + M1:M2 +
               I(as.integer(time == 1)) + I(as.integer(time == 2)),
             var_type = "binary", mod_type = "survival")
)

fit_yam <- mediation(
  data           = yam,
  id_var         = "id",
  time_var       = "time",
  base_vars      = c("V", "L0base", "M10base", "M20base"),
  exposure       = "A",
  outcome        = "Y",
  models         = models_yam,
  init_recode    = recodes(L = L0base, M1 = M10base, M2 = M20base,
                           lag1_A = 0, lag1_L = 0, lag1_M1 = 0, lag1_M2 = 0),
  in_recode      = recodes(lag1_A = A, lag1_L = L, lag1_M1 = M1, lag1_M2 = M2),
  mediation_type = "I",
  mc_sample      = 20000,
  R              = 1,
  quiet          = TRUE,
  seed           = 2026
)

fit_yam$effect_size
#>    Intervention        Est
#>          <char>      <num>
#> 1:         nat0 0.11248807
#> 2:         nat1 0.03509880
#> 3:        Phi00 0.11446276
#> 4:        Phi10 0.06851088
#> 5:       Phi1_1 0.04526965
#> 6:        Phi11 0.03573016

With \(N\) mediators there are \(4 + N\) interventions: Phi1_k draws the first \(k\) mediators from the exposed pool and the rest from the unexposed pool. The indirect effect through mediator \(k\) is the sequential contrast \(\Phi_{1,k} - \Phi_{1,k-1}\), labelled Indirect effect (<name>), and the direct effect plus all indirect effects equals Phi11 − Phi00. Because the decomposition is sequential, the order of the mediator models matters.

Comparing with the true values (in percentage points):

truth <- data.table(
  Effect = c("Total effect", "Direct effect", "Indirect effect (M1)",
             "Indirect effect (M2)", "TE - (Direct + Indirect)"),
  True   = c(-6.36, -3.20, -2.29, -0.97, 0.10)   # from ?yamamurodata
)
est <- as.data.table(fit_yam$estimate)[, .(Effect, Estimate = RD * 100)]
cmp <- merge(truth, est, by = "Effect", sort = FALSE)
cmp[, Difference := Estimate - True]
cmp
#>                      Effect  True   Estimate  Difference
#>                      <char> <num>      <num>       <num>
#> 1:             Total effect -6.36 -7.7389265 -1.37892647
#> 2:            Direct effect -3.20 -4.5951879 -1.39518787
#> 3:     Indirect effect (M1) -2.29 -2.3241236 -0.03412365
#> 4:     Indirect effect (M2) -0.97 -0.9539488  0.01605121
#> 5: TE - (Direct + Indirect)  0.10  0.1343338  0.03433385

The true values are large-sample values from the data-generating process. The estimate comes from one dataset of 10,000 subjects and one Monte Carlo run, so the differences combine sampling and Monte Carlo error, neither of which this single run quantifies; a bootstrap (R > 1) does.

Censoring

When follow-up can end before the event, add a censoring indicator with mod_type = "censor". To illustrate censoring that depends on the confounder, we add extra loss to follow-up to survivaldata, ending each subject’s follow-up at the first censoring:

set.seed(11)
dat_c <- copy(dat)
dat_c[, C := rbinom(.N, 1, plogis(-3.5 + 0.8 * L))]
dat_c[, after_cens := cumsum(shift(C, fill = 0)), by = id]
dat_c <- dat_c[after_cens == 0][, after_cens := NULL]
dat_c[C == 1, Y := 0L]   # censored before the event in that period

The censoring model goes after the variables it depends on and before the survival model:

models_cens <- list(
  spec_model(A ~ V + lag1_A + lag1_L + time,
             var_type = "binary", mod_type = "exposure"),
  spec_model(L ~ V + A + lag1_L + time,
             var_type = "normal", mod_type = "covariate"),
  spec_model(M ~ V + A + L + lag1_M + time,
             var_type = "binary", mod_type = "mediator"),
  spec_model(C ~ V + A + L + time,
             var_type = "binary", mod_type = "censor"),     # censoring model
  spec_model(Y ~ V + A + M + L + A:M + time,
             var_type = "binary", mod_type = "survival")
)

fit_cens <- mediation(
  data           = dat_c,
  id_var         = "id",
  time_var       = "time",
  base_vars      = "V",
  exposure       = "A",
  outcome        = "Y",
  models         = models_cens,
  init_recode    = init_s,
  in_recode      = in_s,
  mediation_type = "I",
  mc_sample      = 20000,
  R              = 1,
  quiet          = TRUE,
  seed           = 2026
)

fit_cens$estimate
#>                      Effect          RD       RR
#>                      <char>       <num>    <num>
#> 1:          Indirect effect  0.19200968 1.344445
#> 2:            Direct effect  0.16502821 1.420542
#> 3:             Total effect  0.37218202 1.974708
#> 4: TE - (Direct + Indirect)  0.01514413       NA
#> 5:     Mediation Proportion 53.77851536       NA

Under every intervention the censoring indicator is set to 0, so the reported risks are risks in the absence of censoring, as in the g-formula treatment of right-censoring (Robins 1986; Westreich et al. 2012). With the default estimator = "gcomp" the censoring model does not change these risks: the hazard model is fitted on the rows still at risk, which identifies them when censoring is independent of the event given the modelled history, an assumption the package cannot check. The censoring model is used by the targeted estimator (see below).

Absorbing states with subset

spec_model(subset = ...) fits and simulates a model only on the rows that meet a condition. The classic use is an absorbing state, such as the GvHD exposure of Keil et al. (2014), which can switch on only once: its model is fitted among the not-yet-exposed, and out_recode carries the value forward.

# the exposure can occur only while gvhdm1 == 0 ...
spec_model(gvhd ~ all + cmv + male + age + ...,
           var_type = "binary", mod_type = "exposure",
           subset = gvhdm1 == 0)

# ... and stays at 1 afterwards
out_recode = recodes(gvhd = ifelse(gvhdm1 == 1, 1, gvhd))

Rows excluded by subset keep their current value. The full GvHD analysis (?gvhd) is in vignette("causalMed-03-gformula").

Bootstrap confidence intervals

Set R > 1 for subject-level bootstrap intervals, optionally with a parallel plan:

future::plan(future::multisession)

fit_ci <- mediation(
  data           = dat,
  id_var         = "id",
  time_var       = "time",
  base_vars      = "V",
  exposure       = "A",
  outcome        = "Y",
  models         = models_surv,
  init_recode    = init_s,
  in_recode      = in_s,
  mediation_type = "I",
  mc_sample      = 20000,
  R              = 500,
  seed           = 2026
)

future::plan(future::sequential)

fit_ci$estimate                  # with Sd, percentile and normal limits
fit_ci$boot_estimates$effects    # the per-replicate estimates

Natural effects

mediation_type = "N" gives the natural effects of Zheng & van der Laan (2017), for survival outcomes too. Here the confounder L responds to the current exposure, so mediation() warns: the effects are still identified (their Lemma 1), but not as individual-level natural effects.

fit_nat <- mediation(
  data           = dat,
  id_var         = "id",
  time_var       = "time",
  base_vars      = "V",
  exposure       = "A",
  outcome        = "Y",
  models         = models_surv,
  init_recode    = init_s,
  in_recode      = in_s,
  mediation_type = "N",
  mc_sample      = 20000,
  R              = 1,
  quiet          = TRUE,
  seed           = 2026
)
#> Warning: mediation_type = "N" requested, and covariate model(s) for {L} include
#> the exposure 'A' on the right-hand side, i.e. they are modelled as
#> exposure-affected. The reported effects are those of Zheng & van der Laan
#> (2017), identified under sequential randomization and positivity (their Lemma
#> 1). Reading them as individual-level natural effects, contrasts of each
#> subject's own counterfactual mediator, additionally requires a cross-world
#> independence assumption that is not expected to hold if such a covariate also
#> confounds the mediator-outcome relationship (Avin, Shpitser & Pearl 2005;
#> VanderWeele 2014; VanderWeele & Tchetgen Tchetgen 2017, who propose the
#> randomized interventional analogues, available here as mediation_type = "I").
#> This check reads the model formulas only and cannot verify the causal
#> structure.

The choice between "I" and "N" is substantive. Miles (2023) discusses what a non-zero interventional indirect effect does and does not establish.

Targeted estimation

estimator = "tmle" replaces the simulation with the targeted minimum loss-based estimator of Zheng & van der Laan (2017, Section 4.3), which also handles right-censored survival outcomes. It gives Wald intervals from the efficient influence curve, so R and mc_sample are ignored. It accepts only lag-style recodes and no subset, out_recode or custom models, raising an error otherwise; ?mediation lists the requirements. Its targeted regressions are additive main-effects working models, so the multiple robustness Zheng & van der Laan establish for correctly specified nuisance models is not claimed for it.

Reusing the censored data and models from above:

fit_tmle_s <- mediation(
  data           = dat_c,
  id_var         = "id",
  time_var       = "time",
  base_vars      = "V",
  exposure       = "A",
  outcome        = "Y",
  models         = models_cens,
  init_recode    = init_s,
  in_recode      = in_s,
  mediation_type = "N",
  estimator      = "tmle",
  quiet          = TRUE,
  seed           = 2026
)
#> Warning: mediation_type = "N" requested, and covariate model(s) for {L} include
#> the exposure 'A' on the right-hand side, i.e. they are modelled as
#> exposure-affected. The reported effects are those of Zheng & van der Laan
#> (2017), identified under sequential randomization and positivity (their Lemma
#> 1). Reading them as individual-level natural effects, contrasts of each
#> subject's own counterfactual mediator, additionally requires a cross-world
#> independence assumption that is not expected to hold if such a covariate also
#> confounds the mediator-outcome relationship (Avin, Shpitser & Pearl 2005;
#> VanderWeele 2014; VanderWeele & Tchetgen Tchetgen 2017, who propose the
#> randomized interventional analogues, available here as mediation_type = "I").
#> This check reads the model formulas only and cannot verify the causal
#> structure.

fit_tmle_s$estimate
#>                  Effect         RD       RR         Sd     Sd_RR   norm_lcl
#>                  <char>      <num>    <num>      <num>     <num>      <num>
#> 1:      Indirect effect  0.2525482 1.475027 0.02484973 0.0697680  0.2038436
#> 2:        Direct effect  0.2174757 1.692214 0.03729178 0.1474051  0.1443851
#> 3:         Total effect  0.4700239 2.496061 0.02964941 0.1741515  0.4119121
#> 4: Mediation Proportion 53.7309294       NA 6.07150776        NA 41.8309929
#>      norm_ucl norm_lcl_RR norm_ucl_RR
#>         <num>       <num>       <num>
#> 1:  0.3012528    1.338285    1.611770
#> 2:  0.2905662    1.403305    1.981122
#> 3:  0.5281357    2.154731    2.837392
#> 4: 65.6308660          NA          NA

The subject-level influence-curve values are in fit_tmle_s$tmle_diag$eic, for custom contrasts or diagnostics.

References