Chapter 3 G-methods and Marginal Effects

3.1 Inverse probability weighting

For our illustration of IPW and g-computation, we simulate some simple confounded data.

set.seed(1747)

n <- 1e4
bZ <- 2
bX <- 2
Z <- rnorm(n, 0, 0.5)
X <- rbinom(n, 1, plogis(0.5 + Z*bZ))
Y <- rnorm(n, 10 + X*bX + Z*bZ)

d <- data.frame(Y=Y, X=X, Z=Z)

For IPW, we first fit a logistic regression model of the probability of receiving treatment.

treatment_model <- glm(X ~ Z, 
                       data = d, 
                       family = "binomial")

We then predict for each individual their probability of receiving treatment.

d$pX <- predict(treatment_model, type = "response")

Lastly, we inverse those probabilities and use them as weights in a model – a so-called ‘marginal structural model’ – that regresses Y on X.

d$w <- with(d, ifelse(X==1, 1/pX, 1/(1-pX)))

lm(Y ~ X, data = d, weights = w)

We then compare the treatment (solid lines) and control (dashed lines) groups before (i.e., in the observed sample) and after weighting (i.e., the IPW ‘pseudo-population’).

# IPW with unstabilized weights
library(ggplot2)
library(patchwork)

p1 <- ggplot() + 
  # X = 1 (sample)
  geom_density(data = subset(d, X == 1), 
                 aes(x = pX), size = 1) +  
  # X = 0 (sample)
  geom_density(data = subset(d, X == 0), 
                 aes(x = pX), linetype = "dashed", size = 1) +
  theme_classic() + 
  theme(
    axis.text.y = element_blank(),
    axis.ticks.y = element_blank(),
    axis.title.y = element_blank(),
    axis.line.y = element_blank()) + 
  xlim(c(0,1)) + xlab("Probability of treatment") +
  ggtitle("Before IP weighting")

p2 <- ggplot() + 
  # X = 1 (pseudo-population)
  geom_density(data = subset(d, X == 1), 
                 aes(x = pX, weight = w), size = 1) + 
  # X = 0 (pseudo-population)
  geom_density(data = subset(d, X == 0), 
                 aes(x = pX, weight = w), linetype = "dashed", size = 1) +
  theme_classic() + 
  theme(
  axis.text.y = element_blank(),
  axis.ticks.y = element_blank(),
  axis.title.y = element_blank(),
  axis.line.y = element_blank()) + 
  xlim(c(0,1)) + xlab("Probability of treatment") +
  ggtitle("After IP weighting") 

(p1 + p2)

The above approach showcases IPW with so-called ‘unstabilized’ weights. But stabilizing the IP weights are often recommended. Stabilized weights uses an unconditional model for the treatment probability instead of 1 as the numerator in the IPW formula. Let’s visualize this wit the stabilized weights plotted with a dashed curve. We see that the stabilized weights are much less extreme.

# IPW with stabilized weights
pn <- glm(X ~ 1, 
          data = d, 
          family = "binomial")

d$pnX <- predict(pn, type = "response")
  
d$sw <- with(d, ifelse(X==1, pnX/pX, (1-pnX)/(1-pX)))

p3 <- ggplot() + 
  geom_density(data = d, 
                 aes(x = w), size = 1) + 
  geom_density(data = d, 
                 aes(x = sw), linetype = "dashed", size = 1) +
  theme_classic() + 
    theme_classic() +
    theme(
    axis.text.y = element_blank(),
    axis.ticks.y = element_blank(),
    axis.title.y = element_blank(),
    axis.line.y = element_blank()) + 
  xlab("IP weight") +
  coord_cartesian(xlim = c(0,10)) +
  labs(title = "Unstabilized and stabilized weights")

p3

3.1.1 Bootstrapping

Here’s a basic bootstrapping approach for IPW. We use only 100 bootstrap samples, but in practice we’d often want (many) more.

# Load necessary libraries
library(boot)

# Number of bootstrap samples
n_bootstrap <- 100

# Function to perform the analysis on a bootstrapped sample
bootstrap_analysis <- function(data, indices) {
  
  # Resample the data
  d <- data[indices, ]
  
  # Fit the treatment model using logistic regression
  treatment_model <- glm(X ~ Z, data = d, family = "binomial")
  
  # Calculate predicted probabilities
  d$pX <- predict(treatment_model, type = "response")
  
  # Calculate weights
  d$w <- with(d, ifelse(X == 1, 1 / pX, 1 / (1 - pX)))
  
  # Fit the weighted linear regression model
  weighted_model <- lm(Y ~ X, data = d, weights = w)
  
  # Return the coefficient of X
  return(coef(weighted_model)["X"])
}

# Perform bootstrapping
bootstrap_results <- boot(data = d, 
                          statistic = bootstrap_analysis, 
                          R = n_bootstrap)

# Summarize the bootstrap results
bootstrap_summary <- boot.ci(bootstrap_results, type = "norm")

# Print the results
print(bootstrap_summary)
## BOOTSTRAP CONFIDENCE INTERVAL CALCULATIONS
## Based on 100 bootstrap replicates
## 
## CALL : 
## boot.ci(boot.out = bootstrap_results, type = "norm")
## 
## Intervals : 
## Level      Normal        
## 95%   ( 1.946,  2.057 )  
## Calculations and Intervals on Original Scale

3.1.2 ‘Robust’ standard errors for IPW

library(sandwich)
library(lmtest)

# robust standard errors for coefficients
fit <- lm(Y ~ X, data = d, weights = w)

coeftest(fit, vcov = vcovHC(fit, type = "HC0"))
## 
## t test of coefficients:
## 
##              Estimate Std. Error t value  Pr(>|t|)    
## (Intercept) 10.018844   0.032202 311.123 < 2.2e-16 ***
## X            2.001042   0.039553  50.592 < 2.2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

3.2 G-computation

Here, we show a basic g-computation workflow…

model <- lm(Y ~ X + Z, data = d)

d$EX1 <- predict(model, 
               newdata = transform(d, X = 1))

d$EX0 <- predict(model, 
               newdata = transform(d, X = 0))

with(d, mean(EX1)-mean(EX0))

… And code to produce the table showing both observed and predicted values, some of which are counter-factual.

vars <- c("Y", "X", "Z", "EX1", "EX0")

xtable::xtable(head(d[vars]), digits = c(0,1,0,1,1,1))
## % latex table generated in R 4.4.0 by xtable 1.8-4 package
## % Sat Dec 28 21:56:16 2024
## \begin{table}[ht]
## \centering
## \begin{tabular}{rrrrrr}
##   \hline
##  & Y & X & Z & EX1 & EX0 \\ 
##   \hline
## 1 & 15.3 & 1 & 0.6 & 13.2 & 11.2 \\ 
##   2 & 11.1 & 0 & 0.1 & 12.2 & 10.2 \\ 
##   3 & 10.6 & 1 & -0.1 & 11.7 & 9.7 \\ 
##   4 & 12.9 & 1 & 0.7 & 13.4 & 11.3 \\ 
##   5 & 11.0 & 1 & -0.5 & 10.9 & 8.9 \\ 
##   6 & 9.4 & 0 & 0.5 & 12.9 & 10.9 \\ 
##    \hline
## \end{tabular}
## \end{table}

3.2.1 Bootstrapping

Next, we show a basic bootstrapped g-computation implementation, again using only 100 bootstrap samples to ease the computational burden of the example.

# Number of bootstrap samples
n_bootstrap <- 100

# Define the function to perform the analysis on a bootstrapped sample
bootstrap_analysis <- function(data, indices) {
  
  # Resample the data
  d <- data[indices, ]
  
  # Fit the linear regression model
  model <- lm(Y ~ X + Z, data = d)
  
  # Calculate predicted values for X = 1 and X = 0
  d$EX1 <- predict(model, newdata = transform(d, X = 1))
  d$EX0 <- predict(model, newdata = transform(d, X = 0))
  
  # Compute the difference in means
  ate <- with(d, mean(EX1) - mean(EX0))
  
  return(ate)
}

# Perform bootstrapping
bootstrap_results <- boot(data = d, 
                          statistic = bootstrap_analysis, 
                          R = n_bootstrap)

# Summarize the bootstrap results
bootstrap_summary <- boot.ci(bootstrap_results, type = c("norm"))

# Print the results
print(bootstrap_summary)
## BOOTSTRAP CONFIDENCE INTERVAL CALCULATIONS
## Based on 100 bootstrap replicates
## 
## CALL : 
## boot.ci(boot.out = bootstrap_results, type = c("norm"))
## 
## Intervals : 
## Level      Normal        
## 95%   ( 1.962,  2.050 )  
## Calculations and Intervals on Original Scale

3.2.2 Bayesian g-computation

Lastly, we show a Bayesian g-computation workflow using the R package brms (Bürkner 2017, 2018, 2021), which requires RStan (Stan Development Team 2024), for model fitting and tidybayes for post-processing (Kay 2023).

library(brms)
library(tidybayes)
library(dplyr)
# Fit Bayesian regression
bmodel <- brm(Y ~ X + Z,
              data = d,
              cores = 4, seed = 1,
              file = "fits/bmodel.rds")
# Calculate predicted values for X = 1 and X = 0
bEX1 <- add_epred_draws(object = bmodel,
                        newdata = transform(d, X = 1))

bEX0 <- add_epred_draws(object = bmodel,
                        newdata = transform(d, X = 0))

The key thing to note when working with Bayesian model fits is that we need to calculate our quantity of interest (here, the ATE) within each posterior draw.

# Compute the difference in means
ate <- data.frame(EX1 = bEX1$.epred,
                  EX0 = bEX0$.epred,
                  draw = bEX0$.draw) |>
  # For each posterior draw...
  group_by(draw) |>
  # ... Calculate ATE
  summarise(ate = mean(EX1 - EX0))

We can summarize the posterior ATE by its mean and highest posterior density interval.

mean_hdi(ate$ate)
##          y     ymin     ymax .width .point .interval
## 1 2.009257 1.964355 2.051421   0.95   mean       hdi

An alternative approach – when we have a fitted model, Bayesian or otherwise – is via the versatile and very well documented marginaleffects package (Arel-Bundock, Greifer, and Heiss Forthcoming).

library(marginaleffects)

avg_comparisons(bmodel, variables = list(X = 0:1))
## Error: cannot allocate vector of size 610.4 Mb

3.3 Session info

sessionInfo()
## R version 4.4.0 (2024-04-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 10 x64 (build 19045)
## 
## Matrix products: default
## 
## 
## locale:
## [1] LC_COLLATE=Danish_Denmark.utf8  LC_CTYPE=Danish_Denmark.utf8   
## [3] LC_MONETARY=Danish_Denmark.utf8 LC_NUMERIC=C                   
## [5] LC_TIME=Danish_Denmark.utf8    
## 
## time zone: Europe/Copenhagen
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] lmtest_0.9-40   zoo_1.8-12      sandwich_3.1-0  patchwork_1.2.0
## [5] ggplot2_3.5.1  
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.5         tensorA_0.36.2.1     xfun_0.44           
##  [4] bslib_0.7.0          QuickJSR_1.1.3       inline_0.3.19       
##  [7] lattice_0.22-6       vctrs_0.6.5          tools_4.4.0         
## [10] generics_0.1.3       stats4_4.4.0         parallel_4.4.0      
## [13] tibble_3.2.1         fansi_1.0.6          highr_0.11          
## [16] pkgconfig_2.0.3      brms_2.21.0          Matrix_1.7-0        
## [19] checkmate_2.3.1      distributional_0.4.0 RcppParallel_5.1.7  
## [22] lifecycle_1.0.4      compiler_4.4.0       farver_2.1.2        
## [25] stringr_1.5.1        Brobdingnag_1.2-9    munsell_0.5.1       
## [28] codetools_0.2-20     htmltools_0.5.8.1    sass_0.4.9          
## [31] bayesplot_1.11.1     yaml_2.3.8           pillar_1.9.0        
## [34] jquerylib_0.1.4      cachem_1.1.0         StanHeaders_2.32.9  
## [37] bridgesampling_1.1-2 abind_1.4-5          nlme_3.1-164        
## [40] posterior_1.5.0      rstan_2.32.6         tidyselect_1.2.1    
## [43] digest_0.6.35        mvtnorm_1.2-5        stringi_1.8.4       
## [46] dplyr_1.1.4          bookdown_0.39        labeling_0.4.3      
## [49] fastmap_1.2.0        grid_4.4.0           colorspace_2.1-0    
## [52] cli_3.6.2            magrittr_2.0.3       loo_2.7.0           
## [55] pkgbuild_1.4.4       utf8_1.2.4           withr_3.0.0         
## [58] scales_1.3.0         backports_1.5.0      estimability_1.5.1  
## [61] rmarkdown_2.27       matrixStats_1.3.0    emmeans_1.10.4      
## [64] gridExtra_2.3        coda_0.19-4.1        evaluate_0.23       
## [67] knitr_1.47           rstantools_2.4.0     rlang_1.1.3         
## [70] Rcpp_1.0.12          xtable_1.8-4         glue_1.7.0          
## [73] rstudioapi_0.16.0    jsonlite_1.8.8       R6_2.5.1

References

Arel-Bundock, Vincent, Noah Greifer, and Andrew Heiss. Forthcoming. “How to Interpret Statistical Models Using marginaleffects in R and Python.” Journal of Statistical Software, Forthcoming.
Bürkner, Paul-Christian. 2017. brms: An R Package for Bayesian Multilevel Models Using Stan.” Journal of Statistical Software 80 (1): 1–28. https://doi.org/10.18637/jss.v080.i01.
———. 2018. “Advanced Bayesian Multilevel Modeling with the R Package brms.” The R Journal 10 (1): 395–411. https://doi.org/10.32614/RJ-2018-017.
———. 2021. “Bayesian Item Response Modeling in R with brms and Stan.” Journal of Statistical Software 100 (5): 1–54. https://doi.org/10.18637/jss.v100.i05.
Kay, Matthew. 2023. tidybayes: Tidy Data and Geoms for Bayesian Models. https://doi.org/10.5281/zenodo.1308151.
———. 2024. RStan: The R Interface to Stan.” https://mc-stan.org/.