Chapter 4 Adventures in G-methods

4.1 Doubly robust estimation

For demonstrating a ‘doubly robust’ estimator that combines IPW and g-computation, we use the nhefs data from the causaldata package (Huntington-Klein and Barrett 2024). This data come from the National Health and Nutrition Examination Survey Data I Epidemiologic Follow-up Study.

library(causaldata)
d <- nhefs

We first calculate stabilized IP weights.

treat_mod <- glm(qsmk ~ sex + age,
                 data = d,
                 family = "binomial")

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

pn <- glm(qsmk ~ 1, 
          data = d,
          family = "binomial")

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

We can then plot the sample before and after weighting.

library(ggplot2)
library(patchwork)

p1 <- ggplot() + 
  # X = 1 (sample)
  geom_density(data = subset(d, qsmk == 1), 
                 aes(x = pX), size = 1) +  
  # X = 0 (sample)
  geom_density(data = subset(d, qsmk == 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, qsmk == 1), 
                 aes(x = pX, weight = sw), size = 1) + 
  # X = 0 (pseudo-population)
  geom_density(data = subset(d, qsmk == 0), 
                 aes(x = pX, weight = sw), 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)

We can also make a ‘love plot’ using the cobalt package (Greifer 2024) to inspect whether the IP weights ensures acceptable balance on the level of individual covariates. By setting continuous = "std", we indicate that the function should return the standardized absolute mean difference for any continuous variables (here, age). If we wanted the raw absolute mean difference, we’d set continuous = "raw".

library(cobalt)

love.plot(treat_mod, abs = TRUE,
          sample.names = c("Unweighted", "IP Weighted"), 
          weights = d$sw,
          colors = c("grey60", "black"),
          thresholds = c(m = .1))

bal.tab(treat_mod, abs = TRUE, un = TRUE, thresholds = c(m = .1), weights = d$sw, continuous = "std")$Balance

Finally, we include the stabilized weights in an outcome model, which we in turn use for g-computation.

out_mod <- lm(wt82_71 ~ qsmk + sex + age, data = d, weights = sw)

EX1 <- predict(out_mod, 
               newdata = transform(d, qsmk = 1))

EX0 <- predict(out_mod, 
               newdata = transform(d, qsmk = 0))

mean(EX1)-mean(EX0)
## [1] 3.039286

4.1.1 Bootstrapping

The basic approach to bootstrapping is similar as in the previous chapter. Here, we bootstrap the doubly robust estimator from above. We use only 100 bootstrap samples, but in practice we’d often want more.

library(boot)

# Number of bootstrap samples
n_bootstrap <- 100

bootstrap_analysis <- function(data, indices) {
    
    # Resample the data
    d <- data[indices, ]
  
    # IPW
    treat_mod <- glm(qsmk ~ sex + age,
                     data = d,
                     family = "binomial")

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

    pn <- glm(qsmk ~ 1, 
          data = d,
          family = "binomial")

    d$pnX <- predict(pn, type = "response")
  
    d$sw <- with(d, ifelse(qsmk==1, pnX/pX, (1-pnX)/(1-pX)))
    
    # G-computation with IP weighted outcome model
    out_mod <- lm(wt82_71 ~ qsmk + sex + age, data = d, weights = sw)

    EX1 <- predict(out_mod, 
                   newdata = transform(d, qsmk = 1))
  
    EX0 <- predict(out_mod, 
                   newdata = transform(d, qsmk = 0))

    mean(EX1)-mean(EX0)
  
  # Return the coefficient of X
  return(mean(EX1)-mean(EX0))
}

# 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%   ( 2.092,  3.909 )  
## Calculations and Intervals on Original Scale

4.1.2 More covariates

We can try the same analysis but with a more comprehensive set of covariates.

library(boot)

bootstrap_analysis <- function(data, indices) {
    
    # Resample the data
    d <- data[indices, ]
  
    # IPW
    # see: https://remlapmot.github.io/cibookex-r/ip-weighting-and-marginal-structural-models.html
    treat_mod <- glm(qsmk ~ sex + race + age + I(age ^ 2) + 
                       as.factor(education) + smokeintensity +
                       I(smokeintensity ^ 2) + smokeyrs + I(smokeyrs ^ 2) +
                       as.factor(exercise) + as.factor(active) + wt71 + I(wt71 ^ 2),
                     data = d,
                     family = "binomial")

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

    pn <- glm(qsmk ~ 1, 
          data = d,
          family = "binomial")

    d$pnX <- predict(pn, type = "response")
  
    d$sw <- with(d, ifelse(qsmk==1, pnX/pX, (1-pnX)/(1-pX)))
    
    # G-computation with IP weighted outcome model
    out_mod <- lm(wt82_71 ~ qsmk + sex + race + age + I(age ^ 2) + 
                       as.factor(education) + smokeintensity +
                       I(smokeintensity ^ 2) + smokeyrs + I(smokeyrs ^ 2) +
                       as.factor(exercise) + as.factor(active) + wt71 + I(wt71 ^ 2), 
                  data = d, weights = sw)

    EX1 <- predict(out_mod, 
                   newdata = transform(d, qsmk = 1))
  
    EX0 <- predict(out_mod, 
                   newdata = transform(d, qsmk = 0))

    mean(EX1)-mean(EX0)
  
  # Return the coefficient of X
  return(mean(EX1)-mean(EX0))
}

# 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%   ( 2.620,  4.393 )  
## Calculations and Intervals on Original Scale

The overall inference is the same, although the more comprehensive adjustment set yields a slightly higher point estimate (around 3.5 kg), indicating that quitters gain even more weight than previously estimated.

4.2 Bootstrapped sub-group analysis

bootstrap_analysis <- function(data, indices) {
    
    # Resample the data
    d <- data[indices, ]
  
    # IPW
    pn_sub <- glm(qsmk ~ 1 + sex, data = d, family = "binomial")
    
    d$pnX <- predict(pn_sub, type = "response")
  
    d$sw <- with(d, ifelse(qsmk == 1, pnX / pX, (1 - pnX) / (1 - pX)))
    
    # G-computation with IP weighted outcome model
    out_mod <- glm(wt82_71 ~ qsmk + sex + age + qsmk * sex, data = d, weights = sw)
    
    EX1S1 <- predict(out_mod, newdata = transform(d, qsmk = 1, sex = as.factor(1)))
    EX1S0 <- predict(out_mod, newdata = transform(d, qsmk = 1, sex = as.factor(0)))
    EX0S1 <- predict(out_mod, newdata = transform(d, qsmk = 0, sex = as.factor(1)))
    EX0S0 <- predict(out_mod, newdata = transform(d, qsmk = 0, sex = as.factor(0)))
    
    mean_diff_S1 <- mean(EX1S1) - mean(EX0S1)
    mean_diff_S0 <- mean(EX1S0) - mean(EX0S0)
    
    return(c(mean_diff_S1, mean_diff_S0))
}

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

# Extract and display results
boot.ci(bootstrap_results, type = "norm", index = 1) # For females
## BOOTSTRAP CONFIDENCE INTERVAL CALCULATIONS
## Based on 100 bootstrap replicates
## 
## CALL : 
## boot.ci(boot.out = bootstrap_results, type = "norm", index = 1)
## 
## Intervals : 
## Level      Normal        
## 95%   ( 1.619,  4.043 )  
## Calculations and Intervals on Original Scale
boot.ci(bootstrap_results, type = "norm", index = 2) # For males
## BOOTSTRAP CONFIDENCE INTERVAL CALCULATIONS
## Based on 100 bootstrap replicates
## 
## CALL : 
## boot.ci(boot.out = bootstrap_results, type = "norm", index = 2)
## 
## Intervals : 
## Level      Normal        
## 95%   ( 2.291,  4.585 )  
## Calculations and Intervals on Original Scale

4.3 Complex longitudinal exposure-outcome feedback

In the book, we show a complicated DAG adapted from VanderWeele, Jackson, and Li (2016) of a complex longitudinal exposure-outcome feedback setting. Here, we verify that the adjustment strategy suggested in the book holds true in a simulated setting. To keep things simple, we set all effects to be recovered to 1.

First, we simulate some data consistent with the complex DAG.

# Seed for reproducibility
set.seed(42)

# Define sample size
n <- 1000

# Define variables
C <- rnorm(n)
Z1 <- rnorm(n, C) + rnorm(n)
Z2 <- rnorm(n, Z1) + rnorm(n)
Z3 <- rnorm(n, Z2) + rnorm(n)
X1 <- rnorm(n, C) + rnorm(n)
X2 <- rnorm(n, X1 + Z1) + rnorm(n)
X3 <- rnorm(n, X2 + Z2) + rnorm(n)
Y <- rnorm(n, X1 + X2 + X3 + Z3) + rnorm(n)

Next, we fit a model for each measurement time point, and we see that all three models pick up the simulated effect of (roughly) 1.

# Model for E[Y | X1, X2, Z1] to estimate effect of X1 on Y
model_X1 <- lm(Y ~ X1 + X2 + Z1)
coef(model_X1)[["X1"]]

[1] 0.9999557

# Model for E[Y | X1, X2, X3, Z1] to estimate effect of X2 on Y
model_X2 <- lm(Y ~ X1 + X2 + X3 + Z1 + Z2)
coef(model_X2)[["X2"]]

[1] 0.9896064

# Model for E[Y | X2, X3, Z2] to estimate effect of X3 on Y
model_X3 <- lm(Y ~ X2 + X3 + Z2)
coef(model_X3)[["X3"]]

[1] 1.02467

4.4 Session info

sessionInfo()
## R version 4.4.1 (2024-06-14 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] cobalt_4.5.5     causaldata_0.1.4 sandwich_3.1-1   patchwork_1.3.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.48              
##  [4] bslib_0.7.0             QuickJSR_1.2.2          inline_0.3.19          
##  [7] lattice_0.22-6          vctrs_0.6.5             tools_4.4.1            
## [10] generics_0.1.3          stats4_4.4.1            parallel_4.4.1         
## [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.1          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              crayon_1.5.3           
## [34] pillar_1.9.0            jquerylib_0.1.4         cachem_1.1.0           
## [37] StanHeaders_2.35.0.9000 bridgesampling_1.1-2    abind_1.4-5            
## [40] nlme_3.1-164            posterior_1.5.0.9000    rstan_2.35.0.9000      
## [43] tidyselect_1.2.1        digest_0.6.35           mvtnorm_1.2-5          
## [46] stringi_1.8.4           dplyr_1.1.4             bookdown_0.41          
## [49] labeling_0.4.3          splines_4.4.1           fastmap_1.2.0          
## [52] grid_4.4.1              colorspace_2.1-0        cli_3.6.2              
## [55] magrittr_2.0.3          loo_2.7.0.9000          pkgbuild_1.4.4         
## [58] utf8_1.2.4              withr_3.0.0             scales_1.3.0           
## [61] backports_1.5.0         estimability_1.5.1      rmarkdown_2.27         
## [64] matrixStats_1.3.0       emmeans_1.10.5          gridExtra_2.3          
## [67] chk_0.9.2               zoo_1.8-12              coda_0.19-4.1          
## [70] evaluate_0.24.0         knitr_1.47              mgcv_1.9-1             
## [73] rstantools_2.4.0        rlang_1.1.4             Rcpp_1.0.12            
## [76] xtable_1.8-4            glue_1.7.0              rstudioapi_0.16.0      
## [79] jsonlite_1.8.8          R6_2.5.1

References

Greifer, Noah. 2024. cobalt: Covariate Balance Tables and Plots. https://CRAN.R-project.org/package=cobalt.
Huntington-Klein, Nick, and Malcolm Barrett. 2024. causaldata: Example Data Sets for Causal Inference Textbooks. https://CRAN.R-project.org/package=causaldata.
VanderWeele, Tyler J, John W Jackson, and Shanshan Li. 2016. “Causal Inference and Longitudinal Data: A Case Study of Religion and Mental Health.” Social Psychiatry and Psychiatric Epidemiology 51: 1457–66.