Causal Mediation with Energy Regression: dma vs crumble

# Optional: enable parallel cross-fitting
library(future)
plan(multisession)

Overview

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:

  1. \(\theta\) outcome regressions — nested pseudo-outcome cascade (\(\theta^3 \to \theta^2 \to \theta^1\) for natural/organic effects)
  2. \(\alpha\) density ratios — cascaded Riesz representer estimation via neural networks with the Riesz loss
  3. EIF-based inference — efficient influence function yields point estimates, standard errors, and confidence intervals via the ife package

The 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

Data: Weight & Behavior

We use the weight_behavior dataset from the mma package, which is the primary example in crumble’s documentation. The causal structure is:

The 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       0

Shift Functions

Both packages use modified treatment policies to define interventions. For binary/categorical treatments, we set everyone to the control or treatment level:

d0 <- \(data, trt) factor(rep(1, nrow(data)), levels = c("1", "2"))
d1 <- \(data, trt) factor(rep(2, nrow(data)), levels = c("1", "2"))

Natural Effects

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\)).

crumble

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.311

dma

library(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.336

Comparison

tidy_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.2628206

Organic Effects

Organic effects decompose the ATE into:

Unlike natural effects, organic effects do not require a cross-world counterfactual — they compare observed vs. natural mediator distributions.

crumble

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.229

dma

set.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.246

Comparison

tidy_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.2462639

Simulation Study

To 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.

Oracle Potential Outcome Distributions

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.4

In 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.

Estimated vs Oracle Distributions

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.4814561

Unlike 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.

Monte Carlo Study

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.0

Summary

Both crumble and dma implement the same semiparametric estimation framework for causal mediation. The key trade-off:

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')]\).

Session Info

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