dma implements the same causal mediation analysis
framework as crumble (Liu,
Williams, Rudolph & Díaz, 2024), but replaces
mlr3superlearner with engression
(Shen & Meinshausen, 2024) for all outcome regressions. Engression
learns the full conditional distribution \(P(Y
\mid X)\) via the energy score, rather than fitting a point
prediction \(E[Y \mid X]\) via an
ensemble of classical ML learners.
Both packages share the same estimation pipeline:
ife packageThe key difference:
| Component | crumble | dma |
|---|---|---|
| \(\theta\) (outcome regressions) | mlr3superlearner |
engression |
| \(\alpha\) (density ratios) | Custom Riesz NN | Same architecture, same loss |
| Cross-fitting | origami |
origami |
| Inference | ife |
ife |
We use the weight_behavior dataset from the
mma package, which is the primary example in crumble’s
documentation. The causal structure is:
sports): sports
participation (binary: 1 or 2)bmi): body mass index
(continuous)exercises,
overweigh): exercise frequency and overweight statusage,
sex, tvhours): age, sex, TV hoursThe causal question: How much of the effect of sports participation on BMI operates directly vs. through exercise and overweight status?
library(mma)
data(weight_behavior)
weight_behavior <- na.omit(weight_behavior)
dim(weight_behavior)
#> [1] 567 15
head(weight_behavior[, c("sports", "bmi", "exercises", "overweigh", "age", "sex", "tvhours")])
#> sports bmi exercises overweigh age sex tvhours
#> 1 2 18.20665 2 0 12.2 F 4
#> 2 1 22.78401 8 0 12.8 M 4
#> 4 2 25.56754 9 1 12.1 M 0
#> 5 1 15.07408 12 0 12.3 M 2
#> 6 1 22.98338 1 0 11.8 M 4
#> 8 1 19.15658 1 0 12.1 F 0Both packages use modified treatment policies to define interventions. For binary/categorical treatments, we set everyone to the control or treatment level:
Natural direct and indirect effects decompose the ATE into the pathway that operates directly (\(A \to Y\)) and indirectly through the mediators (\(A \to M \to Y\)).
library(crumble)
set.seed(2345)
res_crumble_n <- crumble(
data = weight_behavior,
trt = "sports",
outcome = "bmi",
covar = c("age", "sex", "tvhours"),
mediators = c("exercises", "overweigh"),
d0 = d0,
d1 = d1,
effect = "N",
learners = c("mean", "glm"),
nn_module = sequential_module(),
control = crumble_control(crossfit_folds = 1L, epochs = 20L)
)
print(res_crumble_n)
#> Estimate: 0.017
#> Std. error: 0.192
#> Estimate: 1.009
#> Std. error: 0.237
#> Estimate: 1.026
#> Std. error: 0.311library(dma)
set.seed(2345)
res_dma_n <- dma(
data = weight_behavior,
trt = "sports",
outcome = "bmi",
covar = c("age", "sex", "tvhours"),
mediators = c("exercises", "overweigh"),
d0 = d0,
d1 = d1,
effect = "N",
control = dma_control(
crossfit_folds = 1L,
num_epochs = 100L,
riesz_epochs = 20L,
noise_dim = 5L,
hidden_dim = 100L,
num_layer = 3L
)
)
print(res_dma_n)
#> Estimate: 0.004
#> Std. error: 0.195
#> Estimate: 0.902
#> Std. error: 0.263
#> Estimate: 0.906
#> Std. error: 0.336tidy_crumble_n <- data.frame(
term = names(res_crumble_n$estimates),
crumble_est = sapply(res_crumble_n$estimates, \(x) x@x),
crumble_se = sapply(res_crumble_n$estimates, \(x) x@std_error)
)
tidy_enmed_n <- tidy(res_dma_n)
comparison_n <- merge(tidy_crumble_n, tidy_enmed_n[, c("term", "estimate", "std.error")],
by = "term", suffixes = c("_crumble", "_enmed"))
names(comparison_n)[4:5] <- c("enmed_est", "enmed_se")
print(comparison_n)
#> term crumble_est crumble_se enmed_est enmed_se
#> 1 ate 1.0259345 0.3112327 0.905646144 0.3361126
#> 2 direct 0.0169067 0.1919303 0.003545136 0.1945505
#> 3 indirect 1.0090278 0.2372623 0.902101007 0.2628206Organic effects decompose the ATE into:
Unlike natural effects, organic effects do not require a cross-world counterfactual — they compare observed vs. natural mediator distributions.
set.seed(2345)
res_crumble_o <- crumble(
data = weight_behavior,
trt = "sports",
outcome = "bmi",
covar = c("age", "sex", "tvhours"),
mediators = c("exercises", "overweigh"),
d0 = d0,
d1 = d1,
effect = "O",
learners = c("mean", "glm"),
nn_module = sequential_module(),
control = crumble_control(crossfit_folds = 1L, epochs = 20L)
)
print(res_crumble_o)
#> Estimate: 0.008
#> Std. error: 0.179
#> Estimate: 1.014
#> Std. error: 0.229set.seed(2345)
res_dma_o <- dma(
data = weight_behavior,
trt = "sports",
outcome = "bmi",
covar = c("age", "sex", "tvhours"),
mediators = c("exercises", "overweigh"),
d0 = d0,
d1 = d1,
effect = "O",
control = dma_control(
crossfit_folds = 1L,
num_epochs = 100L,
riesz_epochs = 20L,
noise_dim = 5L,
hidden_dim = 100L,
num_layer = 3L
)
)
print(res_dma_o)
#> Estimate: -0.049
#> Std. error: 0.172
#> Estimate: 0.913
#> Std. error: 0.246tidy_crumble_o <- data.frame(
term = names(res_crumble_o$estimates),
crumble_est = sapply(res_crumble_o$estimates, \(x) x@x),
crumble_se = sapply(res_crumble_o$estimates, \(x) x@std_error)
)
tidy_enmed_o <- tidy(res_dma_o)
comparison_o <- merge(tidy_crumble_o, tidy_enmed_o[, c("term", "estimate", "std.error")],
by = "term", suffixes = c("_crumble", "_enmed"))
names(comparison_o)[4:5] <- c("enmed_est", "enmed_se")
print(comparison_o)
#> term crumble_est crumble_se enmed_est enmed_se
#> 1 ode 0.007655142 0.1793778 -0.04855075 0.1720902
#> 2 oie 1.013607709 0.2286111 0.91343956 0.2462639To validate statistical properties, we run a small Monte Carlo study with a known DGP where true effects are calculable.
DGP: \(W \sim N(0,1)\), \(A \mid W \sim \text{Bernoulli}(\text{logit}^{-1}(0.5W))\), \(M = 0.5A + 0.3W + \varepsilon_M\), \(Y = A + 0.8M + 0.4W + \varepsilon_Y\).
True natural effects: NDE = 1.0, NIE = \(0.8 \times 0.5 = 0.4\), ATE = 1.4.
Since we know the DGP, we can compute the four counterfactual distributions under each combination of treatment assignment and mediator regime. Although this linear DGP shifts only location (not shape), the distributional view confirms the decomposition: \(Y(1,M(0))\) and \(Y(0,M(1))\) isolate the direct and indirect channels.
set.seed(42)
n_oracle <- 10000
W_o <- rnorm(n_oracle)
eps_M <- rnorm(n_oracle, sd = 0.5)
eps_Y <- rnorm(n_oracle, sd = 0.5)
# Potential mediators
M0 <- 0.5 * 0 + 0.3 * W_o + eps_M # M(0)
M1 <- 0.5 * 1 + 0.3 * W_o + eps_M # M(1)
# Four potential outcomes Y(a, M(a'))
Y00 <- 0 + 0.8 * M0 + 0.4 * W_o + eps_Y # Y(0, M(0))
Y11 <- 1 + 0.8 * M1 + 0.4 * W_o + eps_Y # Y(1, M(1))
Y10 <- 1 + 0.8 * M0 + 0.4 * W_o + eps_Y # Y(1, M(0)) — direct effect
Y01 <- 0 + 0.8 * M1 + 0.4 * W_o + eps_Y # Y(0, M(1)) — indirect only
# Plot
df <- rbind(
data.frame(Y = Y00, PO = "Y(0, M(0))"),
data.frame(Y = Y11, PO = "Y(1, M(1))"),
data.frame(Y = Y10, PO = "Y(1, M(0))"),
data.frame(Y = Y01, PO = "Y(0, M(1))")
)
df$PO <- factor(df$PO, levels = c("Y(0, M(0))", "Y(1, M(0))", "Y(0, M(1))", "Y(1, M(1))"))
ggplot(df, aes(x = Y, color = PO)) +
geom_density(linewidth = 0.9) +
scale_color_manual(values = c(
"Y(0, M(0))" = "#377eb8", "Y(1, M(1))" = "#e41a1c",
"Y(1, M(0))" = "#984ea3", "Y(0, M(1))" = "#ff7f00"
)) +
labs(title = "Oracle Potential Outcome Distributions",
subtitle = "Linear DGP: Y = A + 0.8M + 0.4W + eps (NDE = 1.0, NIE = 0.4, ATE = 1.4)",
x = "Outcome (Y)", y = "Density", color = "Potential Outcome") +
theme_minimal() +
theme(legend.position = "bottom")# Mean decomposition
data.frame(
Effect = c("ATE = E[Y(1,M(1))] - E[Y(0,M(0))]",
"NDE = E[Y(1,M(0))] - E[Y(0,M(0))]",
"NIE = E[Y(1,M(1))] - E[Y(1,M(0))]"),
Oracle = round(c(mean(Y11) - mean(Y00), mean(Y10) - mean(Y00), mean(Y11) - mean(Y10)), 3),
True = c(1.4, 1.0, 0.4)
)
#> Effect Oracle True
#> 1 ATE = E[Y(1,M(1))] - E[Y(0,M(0))] 1.4 1.4
#> 2 NDE = E[Y(1,M(0))] - E[Y(0,M(0))] 1.0 1.0
#> 3 NIE = E[Y(1,M(1))] - E[Y(1,M(0))] 0.4 0.4In this linear case, the four densities are parallel shifts — the indirect effect (orange vs blue) shifts by 0.4, and the direct effect (purple vs blue) shifts by 1.0. Non-linear DGPs (see the advantages-of-engression vignette) produce shape changes that only distributional methods can capture.
We fit dma on a single dataset from this DGP and use the
Riesz representer weights to estimate all three mediation counterfactual
distributions. Unlike simple treatment shifting, the weighted approach
correctly handles the cross-world counterfactual \(Y(1, M(0))\) by reweighting the observed
data via the learned density ratios.
# Generate one dataset for fitting
set.seed(2024)
n_fit <- 1000
W_f <- rnorm(n_fit)
A_f <- rbinom(n_fit, 1, plogis(0.5 * W_f))
M_f <- 0.5 * A_f + 0.3 * W_f + rnorm(n_fit, sd = 0.5)
Y_f <- A_f + 0.8 * M_f + 0.4 * W_f + rnorm(n_fit, sd = 0.5)
fit_data <- data.frame(W = W_f, A = A_f, M = M_f, Y = Y_f)
# Fit dma
res_fit <- dma(
data = fit_data, trt = "A", outcome = "Y", mediators = "M", covar = "W",
d0 = function(data, trt) 0, d1 = function(data, trt) 1,
effect = "N",
control = dma_control(crossfit_folds = 2L, num_epochs = 100L,
riesz_epochs = 30L, noise_dim = 5L, hidden_dim = 50L, num_layer = 2L)
)
# Weighted counterfactual densities (all 3 mediation regimes)
plot_counterfactual_density(res_fit, use_weights = TRUE)# Overlay weighted estimates with oracle for all 3 regimes
W_est <- res_fit$alpha_n[["alpha3"]]
regime_map <- c("000" = "Y(0, M(0))", "100" = "Y(1, M(0))", "111" = "Y(1, M(1))")
df_est <- do.call(rbind, lapply(colnames(W_est), function(nm) {
w <- pmax(W_est[, nm], 0)
w <- w / sum(w) * length(w)
data.frame(Y = fit_data$Y, Weight = w, Regime = regime_map[[nm]])
}))
df_oracle <- rbind(
data.frame(Y = Y00, Regime = "Y(0, M(0))"),
data.frame(Y = Y10, Regime = "Y(1, M(0))"),
data.frame(Y = Y11, Regime = "Y(1, M(1))")
)
ggplot() +
geom_density(data = df_oracle, aes(x = Y, color = Regime, linetype = "Oracle"), linewidth = 0.9) +
geom_density(data = df_est, aes(x = Y, color = Regime, weight = Weight, linetype = "Estimated"), linewidth = 0.9) +
scale_color_manual(values = c(
"Y(0, M(0))" = "#377eb8", "Y(1, M(0))" = "#984ea3", "Y(1, M(1))" = "#e41a1c"
)) +
scale_linetype_manual(values = c("Oracle" = "solid", "Estimated" = "dashed")) +
labs(title = "Estimated vs Oracle Counterfactual Distributions",
subtitle = "Solid = oracle (n=10,000), Dashed = weighted via Riesz representers (n=1,000)",
x = "Outcome (Y)", y = "Density", linetype = "Source") +
theme_minimal() +
theme(legend.position = "bottom")# Compare estimated vs oracle means for all 3 regimes
est_means <- sapply(colnames(W_est), function(nm) {
w <- pmax(W_est[, nm], 0)
w <- w / sum(w)
sum(w * fit_data$Y)
})
names(est_means) <- regime_map[names(est_means)]
data.frame(
Regime = names(est_means),
Oracle = round(c(mean(Y00), mean(Y10), mean(Y11)), 3),
Estimated = round(est_means, 3)
)
#> Regime Oracle Estimated
#> Y(1, M(1)) Y(1, M(1)) -0.003 1.126
#> Y(1, M(0)) Y(1, M(0)) 0.997 1.083
#> Y(0, M(0)) Y(0, M(0)) 1.397 0.205# Point estimates from the dma model
tidy(res_fit)
#> term estimate std.error conf.low conf.high
#> 1 direct 1.0354069 0.03187110 0.9729406 1.0978731
#> 2 indirect 0.3733673 0.02148419 0.3312590 0.4154755
#> 3 ate 1.4087742 0.03708329 1.3360922 1.4814561Unlike the no-direct-effect DGP in the distributional-mediation vignette, here all three counterfactual distributions are distinct: \(Y(1, M(0))\) (purple) sits between control and treated, reflecting the direct effect of \(A\) on \(Y\) while holding \(M\) at its control distribution. The NDE shifts from blue to purple; the NIE shifts from purple to red.
set.seed(123)
B <- 5
n <- 500
results <- data.frame()
for (b in 1:B) {
W <- rnorm(n)
A <- rbinom(n, 1, plogis(0.5 * W))
M <- 0.5 * A + 0.3 * W + rnorm(n, sd = 0.5)
Y <- A + 0.8 * M + 0.4 * W + rnorm(n, sd = 0.5)
sim_data <- data.frame(W = W, A = A, M = M, Y = Y)
ctrl <- dma_control(crossfit_folds = 2L, num_epochs = 100L,
riesz_epochs = 30L, noise_dim = 3L, hidden_dim = 50L, num_layer = 2L)
res <- dma(data = sim_data, trt = "A", outcome = "Y", mediators = "M",
covar = "W", d0 = function(data, trt) 0, d1 = function(data, trt) 1,
effect = "N", control = ctrl)
td <- tidy(res)
td$rep <- b
results <- rbind(results, td)
}
# Summarize
true_vals <- c(direct = 1.0, indirect = 0.4, ate = 1.4)
summary_df <- do.call(rbind, lapply(names(true_vals), function(nm) {
sub <- results[results$term == nm, ]
data.frame(
term = nm,
true = true_vals[nm],
mean_est = mean(sub$estimate),
bias = mean(sub$estimate) - true_vals[nm],
emp_se = sd(sub$estimate),
mean_se = mean(sub$std.error),
coverage = mean(sub$conf.low <= true_vals[nm] & sub$conf.high >= true_vals[nm])
)
}))
rownames(summary_df) <- NULL
print(summary_df)
#> term true mean_est bias emp_se mean_se coverage
#> 1 direct 1.0 1.0103527 0.010352696 0.08318893 0.04793290 0.8
#> 2 indirect 0.4 0.3877587 -0.012241284 0.06742012 0.03217372 0.6
#> 3 ate 1.4 1.3981114 -0.001888588 0.05822158 0.05900327 1.0Both crumble and dma implement the same
semiparametric estimation framework for causal mediation. The key
trade-off:
mlr3superlearner, which
ensembles many classical ML learners (GLM, random forest, MARS, etc.) —
flexible but requires many packages and tuning.engression, a single neural
network that learns the full conditional distribution — simpler
dependency structure, potentially better for complex conditional
distributions, but requires torch.For the Riesz representer (\(\alpha\)), both packages use the same approach: a neural network trained with the Riesz loss \(E[\alpha(X)^2 - 2 w \cdot f(\alpha, X')]\).
sessionInfo()
#> R version 4.5.2 (2025-10-31)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.4 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/atlas/libblas.so.3.10.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/atlas/liblapack.so.3.10.3; LAPACK version 3.11.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: America/New_York
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] splines stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] dma_0.1.0 mlr3_1.4.0 future_1.69.0 crumble_0.1.3 mma_10.8-1
#> [6] gplots_3.3.0 car_3.1-5 carData_3.0-6 survival_3.8-6 gbm_2.2.3
#> [11] ggplot2_4.0.2
#>
#> loaded via a namespace (and not attached):
#> [1] tidyselect_1.2.1 ife_0.2.3 dplyr_1.2.0
#> [4] farver_2.1.2 S7_0.2.1 bitops_1.0-9
#> [7] lmtp_1.5.3 fastmap_1.2.0 paradox_1.0.1
#> [10] digest_0.6.39 lifecycle_1.0.5 processx_3.8.6
#> [13] magrittr_2.0.4 compiler_4.5.2 rlang_1.1.7
#> [16] sass_0.4.10 tools_4.5.2 yaml_2.3.12
#> [19] collapse_2.1.6 data.table_1.18.2.1 knitr_1.51
#> [22] labeling_0.4.3 bit_4.6.0 RColorBrewer_1.1-3
#> [25] abind_1.4-8 KernSmooth_2.23-26 withr_3.0.2
#> [28] purrr_1.2.1 mlr3misc_0.20.0 grid_4.5.2
#> [31] caTools_1.18.3 engression_0.1.3 progressr_0.18.0
#> [34] iterators_1.0.14 globals_0.19.1 scales_1.4.0
#> [37] gtools_3.9.5 dichromat_2.0-0.1 cli_3.6.5
#> [40] rmarkdown_2.30 crayon_1.5.3 generics_0.1.4
#> [43] otel_0.2.0 future.apply_1.20.2 cachem_1.1.0
#> [46] nnls_1.6 assertthat_0.2.1 parallel_4.5.2
#> [49] coro_1.1.0 vctrs_0.7.1 glmnet_4.1-10
#> [52] Matrix_1.7-4 jsonlite_2.0.0 callr_3.7.6
#> [55] bit64_4.6.0-1 Formula_1.2-5 listenv_0.10.1
#> [58] mlr3learners_0.14.0 foreach_1.5.2 lgr_0.5.2
#> [61] jquerylib_0.1.4 mlr3superlearner_0.1.2 glue_1.8.0
#> [64] parallelly_1.46.1 codetools_0.2-20 ps_1.9.1
#> [67] shape_1.4.6.1 gtable_0.3.6 palmerpenguins_0.1.1
#> [70] tibble_3.3.1 pillar_1.11.1 htmltools_0.5.9
#> [73] torch_0.16.3 origami_1.0.7 R6_2.6.1
#> [76] evaluate_1.0.5 lattice_0.22-9 backports_1.5.0
#> [79] bslib_0.10.0 Rcpp_1.1.1 uuid_1.2-2
#> [82] checkmate_2.3.4 xfun_0.56 pkgconfig_2.0.3