--- title: "causalMed and gfoRmula: A Comparative Overview" output: rmarkdown::html_vignette: toc: true toc_depth: 3 highlight: null vignette: > %\VignetteIndexEntry{causalMed and gfoRmula: A Comparative Overview} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE, fig.width = 7, fig.height = 4 ) has_gfoRmula <- requireNamespace("gfoRmula", quietly = TRUE) ``` ## Overview `gfoRmula` (McGrath et al. 2020) is the established CRAN implementation of the parametric g-formula in R, and `causalMed` uses it as the benchmark for its total-effect function, `gformula()`. This vignette sets out what the two packages share, what `gfoRmula` offers that `causalMed` does not, what `causalMed` adds, and how their estimates compare on the same data. ```{r pkgs} library(causalMed) library(data.table) ``` ## Shared foundation Both packages implement the same algorithm, that of the GFORMULA SAS macro (Logan, Young, Taubman, Hernán and colleagues), of which `gfoRmula` is the R port: fit parametric models to the long-format data, resample baseline records into a Monte Carlo cohort, simulate the time-varying variables forward one time step at a time under each intervention, compute survival risk as $1 - \prod_t (1 - h_t)$ with censoring removed under intervention, and bootstrap subjects for confidence intervals. The GFORMULA macro and `gfoRmula` estimate total effects only. The direct and indirect effects of `causalMed` go beyond both; its interventional mediator permutation follows a separate SAS macro, `mGFORMULA` (Lin et al. 2017). ## What gfoRmula offers that causalMed does not `gfoRmula` is the more mature and complete package for total-effect estimation, with a dedicated publication (McGrath et al. 2020), documentation and vignettes. `causalMed` is at an earlier stage of development. **More covariate types.** | Covariate type | gfoRmula | causalMed | |---------------|:--------:|:---------:| | `"binary"`, `"normal"`, `"categorical"` | ✓ | ✓ | | `"bounded normal"` (model fitted on a rescaled response) | ✓ | — | | `"zero-inflated normal"` | ✓ | — | | `"truncated normal"` | ✓ | — | | `"absorbing"` | ✓ | — | | `"categorical time"` | ✓ | — | Both packages clip simulated normal values to the observed range by default: `spec_model(truncate = TRUE)` in `causalMed` and `sim_trunc = TRUE` in `gfoRmula`, documented there as "whether to truncate simulated covariates to their range in the observed data set". `gfoRmula`'s `"bounded normal"` is a different thing, a covariate type fitted on a response rescaled to `[0, 1]`. For the other types, `causalMed` offers only `var_type = "custom"` with user-written fitting and simulation functions, which leaves the choice and validation of the distribution to the user. **Competing events.** `gfoRmula` models competing events through `compevent_name`; `causalMed` has no competing-event interface. **Intervention functions.** `gfoRmula` interventions are R functions that receive the simulated data and the time index, and can be limited to chosen time points (`int_times`). `causalMed` uses `dyn_int()`, an expression evaluated in the simulated data at each step; the current `time_var` value is in scope (e.g. `dyn_int(as.numeric(A > 0 & time >= 2))`), and history-dependent rules use lagged columns maintained with the recode hooks. ## What causalMed adds: mediation `gfoRmula` does not do mediation analysis. `mediation()` decomposes the effect of a time-varying exposure into direct and indirect effects, as interventional effects (`mediation_type = "I"`; Lin et al. 2017), with one or more mediators, or natural effects (`"N"`; Zheng & van der Laan 2017), estimated by g-computation or targeted minimum loss-based estimation. See `vignette("causalMed-01-overview")` for a quick start and `vignette("causalMed-02-mediation")` for the details. ## Interfaces | Aspect | causalMed | gfoRmula | |--------|-----------|---------| | Models | One `spec_model()` per variable (formula, `var_type`, `mod_type`), in a list | `covnames`, `covtypes`, `covparams`, `ymodel`, `outcome_type` | | Histories | Expressions in `init_recode`, `in_recode`, `out_recode` via `recodes()` | History functions such as `lagged` and `cumavg`, via `histories` and `histvars` | | Interventions | Named list of `NULL` (natural course), static values or `dyn_int()` rules | `intervention.` arguments holding an intervention function and its values | ## Side-by-side estimates For total effects the two packages implement the same estimator, so with the same models and truncation settings their estimates should agree up to Monte Carlo error. We compare them on a binary end-of-follow-up outcome and a survival outcome. ### Data preparation ```{r data-prep} data("nonsurvivaldata", package = "causalMed") dat <- as.data.table(nonsurvivaldata) dat[, time := as.integer(time)] setorder(dat, id, time) # Lag recodes (applied inside the Monte Carlo loop) init_rc <- recodes(lag1_A = 0, lag1_L1 = 0, lag1_L2 = 0) in_rc <- recodes(lag1_A = A, lag1_L1 = L1, lag1_L2 = L2) ``` These models use a simplified `L → A` ordering within each period, not the `A → L` process of `?nonsurvivaldata`. Both packages receive the same specification, so the comparison is unaffected; in applied work the order should match the assumed data-generating process. ### Example 1: Binary end-of-follow-up outcome **causalMed** ```{r cm-binary} models_cm <- list( spec_model(L1 ~ lag1_A + lag1_L1 + V + time, var_type = "normal", mod_type = "covariate"), spec_model(L2 ~ lag1_A + lag1_L2 + V + time, var_type = "binary", mod_type = "covariate"), spec_model(A ~ lag1_A + L1 + L2 + V + time, var_type = "binary", mod_type = "exposure"), spec_model(Y_bin ~ A + L1 + L2, var_type = "binary", mod_type = "outcome") ) fit_cm <- causalMed::gformula( data = dat, id_var = "id", time_var = "time", base_vars = "V", exposure = "A", models = models_cm, intervention = list(natural = NULL, always = 1, never = 0), ref_int = "natural", init_recode = init_rc, in_recode = in_rc, mc_sample = 10000, R = 1, quiet = TRUE, seed = 20250915 ) fit_cm$effect_size ``` **gfoRmula** ```{r gf-binary, eval = has_gfoRmula} suppressPackageStartupMessages(requireNamespace("gfoRmula")) fit_gf <- gfoRmula::gformula( obs_data = copy(dat), id = "id", time_name = "time", time_points = length(unique(dat$time)), covnames = c("L1", "L2", "A"), covtypes = c("normal", "binary", "binary"), basecovs = "V", covparams = list(covmodels = c( L1 ~ lag1_A + lag1_L1 + V + time, L2 ~ lag1_A + lag1_L2 + V + time, A ~ lag1_A + L1 + L2 + V + time )), ymodel = Y_bin ~ A + L1 + L2, outcome_name = "Y_bin", outcome_type = "binary_eof", histories = c(gfoRmula::lagged), histvars = list(c("A", "L1", "L2")), intervention1.A = list(gfoRmula::static, rep(1, length(unique(dat$time)))), intervention2.A = list(gfoRmula::static, rep(0, length(unique(dat$time)))), int_descript = c("Always treat", "Never treat"), nsimul = 10000, seed = 20250915 ) fit_gf$result[, c("k", "g-form mean")] ``` **Side-by-side at final time point** ```{r compare-binary, eval = has_gfoRmula} gf_res <- fit_gf$result[k == max(fit_gf$result$k), c("Interv.", "g-form mean")] gf_res[, Intervention := c("natural", "always", "never")] setnames(gf_res, "g-form mean", "gfoRmula") gf_res[, `:=`(gfoRmula = round(gfoRmula, 4), Interv. = NULL)] cm_res <- fit_cm$effect_size[, .(Intervention, causalMed = round(Est, 4))] merge(cm_res, gf_res, by = "Intervention") ``` ### Example 2: Survival (time-to-event) outcome **causalMed** ```{r cm-survival} data("survivaldata", package = "causalMed") dat_s <- as.data.table(survivaldata) setorder(dat_s, id, time) models_surv <- list( spec_model(L ~ V + lag1_L + time, var_type = "normal", mod_type = "covariate"), spec_model(A ~ V + lag1_A + lag1_L + L + time, var_type = "binary", mod_type = "exposure"), spec_model(Y ~ lag1_A + A + L + lag1_L + time, var_type = "binary", mod_type = "survival") ) fit_cm_s <- causalMed::gformula( data = dat_s, id_var = "id", time_var = "time", base_vars = "V", exposure = "A", models = models_surv, intervention = list(natural = NULL, never = 0, always = 1), ref_int = "natural", init_recode = recodes(lag1_L = 0, lag1_A = 0), in_recode = recodes(lag1_L = L, lag1_A = A), mc_sample = 10000, R = 1, quiet = TRUE, seed = 20250915 ) fit_cm_s$effect_size ``` **gfoRmula** ```{r gf-survival, eval = has_gfoRmula} T_max <- length(unique(dat_s$time)) fit_gf_s <- gfoRmula::gformula( obs_data = copy(dat_s), id = "id", time_name = "time", time_points = T_max, covnames = c("L", "A"), # same order as the causalMed list covtypes = c("normal", "binary"), basecovs = "V", covparams = list(covmodels = c( L ~ V + lag1_L + time, A ~ V + lag1_A + lag1_L + L + time )), ymodel = Y ~ lag1_A + A + L + lag1_L + time, outcome_name = "Y", outcome_type = "survival", histories = c(gfoRmula::lagged), histvars = list(c("A", "L")), intervention1.A = list(gfoRmula::static, rep(0, T_max)), intervention2.A = list(gfoRmula::static, rep(1, T_max)), int_descript = c("Never treat", "Always treat"), nsimul = 10000, seed = 20250915, nsamples = 0 ) fit_gf_s$result[k == max(fit_gf_s$result$k), c("Interv.", "g-form risk")] ``` **Side-by-side at final time point** ```{r compare-survival, eval = has_gfoRmula} gf_s_res <- fit_gf_s$result[k == max(fit_gf_s$result$k), c("Interv.", "g-form risk")] gf_s_res[, Intervention := c("natural", "never", "always")] setnames(gf_s_res, "g-form risk", "gfoRmula") gf_s_res[, `:=`(gfoRmula = round(gfoRmula, 4), Interv. = NULL)] cm_s_res <- fit_cm_s$effect_size[, .(Intervention, causalMed = round(Est, 4))] merge(cm_s_res, gf_s_res, by = "Intervention") ``` ### Example 3: Dynamic intervention The risk under "treat only if L1 > 0". The syntax differs; the estimand is the same. **causalMed**: `dyn_int()` holds the rule as an expression, evaluated in the simulated data at each time step: ```{r dynamic-cm} fit_dyn <- causalMed::gformula( data = dat, id_var = "id", time_var = "time", base_vars = "V", exposure = "A", models = models_cm, intervention = list(natural = NULL, treat_if_L1_pos = dyn_int(as.numeric(L1 > 0))), ref_int = "natural", init_recode = init_rc, in_recode = in_rc, mc_sample = 5000, R = 1, quiet = TRUE, seed = 20250915 ) fit_dyn$effect_size fit_dyn$estimate ``` **gfoRmula**: a user-written intervention function: ```r treat_if_L1_pos <- function(newdf, pool, intvar, intvals, time_name, t) { newdf[, (intvar) := as.integer(L1 > 0)] } gfoRmula::gformula( ..., intervention1.A = list(treat_if_L1_pos), int_descript = "Treat if L1 > 0" ) ``` Both evaluate the rule in the simulated data at each time step. ### Larger Monte Carlo sample Repeating Example 1 with 50,000 rather than 10,000 simulated subjects shrinks the Monte Carlo part of the difference. Both packages fit the same models to the same data, so what remains is Monte Carlo variability. ```{r cross-val, eval = has_gfoRmula} fit_cv_cm <- causalMed::gformula( data = dat, id_var = "id", time_var = "time", base_vars = "V", exposure = "A", models = models_cm, intervention = list(natural = NULL, always = 1), ref_int = "natural", init_recode = init_rc, in_recode = in_rc, mc_sample = 50000, R = 1, quiet = TRUE, seed = 20250915 ) fit_cv_gf <- gfoRmula::gformula( obs_data = copy(dat), id = "id", time_name = "time", time_points = length(unique(dat$time)), covnames = c("L1","L2","A"), covtypes = c("normal","binary","binary"), basecovs = "V", covparams = list(covmodels = c( L1 ~ lag1_A + lag1_L1 + V + time, L2 ~ lag1_A + lag1_L2 + V + time, A ~ lag1_A + L1 + L2 + V + time )), ymodel = Y_bin ~ A + L1 + L2, outcome_name = "Y_bin", outcome_type = "binary_eof", histories = c(gfoRmula::lagged), histvars = list(c("A","L1","L2")), intervention1.A = list(gfoRmula::static, rep(1, length(unique(dat$time)))), int_descript = "Always treat", nsimul = 50000, seed = 20250915 ) cm_vals <- round(fit_cv_cm$effect_size$Est, 5) gf_vals <- round(fit_cv_gf$result[k == max(fit_cv_gf$result$k)][["g-form mean"]], 5) data.frame( Intervention = c("natural", "always"), causalMed = cm_vals, gfoRmula = gf_vals, Difference = round(abs(cm_vals - gf_vals), 5) ) ``` The `Difference` column is what this run produced. The package's test suite runs the same comparison against `gfoRmula` for a binary end-of-follow-up outcome, a survival outcome and a dynamic intervention, and requires agreement within 0.005. That agreement holds for those data-generating processes and model specifications, and does not by itself extend to others. ## References 1. Lin, S. H., Young, J. G., Logan, R., & VanderWeele, T. J. (2017). Mediation analysis for a survival outcome with time-varying exposures, mediators, and confounders. *Statistics in Medicine*, 36(26), 4153–4166. 2. McGrath, S., Lin, V., Zhang, Z., et al. (2020). gfoRmula: An R Package for Estimating the Effects of Sustained Treatment Strategies via the Parametric g-Formula. *Patterns*, 1, 100008. 3. Zheng, W., & van der Laan, M. (2017). Longitudinal mediation analysis with time-varying mediators and exposures, with application to survival outcomes. *Journal of Causal Inference*, 5(2). Package versions used for this comparison: ```{r versions} c(causalMed = as.character(packageVersion("causalMed")), gfoRmula = if (has_gfoRmula) as.character(packageVersion("gfoRmula")) else NA) ```