The software bridge: reconciling with tmle, AIPW, and DoubleML
Source:vignettes/software-bridge.Rmd
software-bridge.RmdApplied researchers often find that tmle,
AIPW, and DoubleML return different
numbers on the same data and worry the methods disagree. They don’t: the
differences track the nuisance library, fold count, and
propensity truncation, not the estimator label. This article
shows that once those are matched, dr3’s transparent hand-coded
estimators reproduce the established packages — the point of the
“software bridge”.
We put the identical NHEFS estimand through each implementation with one shared Super Learner library (SL.mean, SL.glm, SL.glmnet).
set.seed(1)
nu_full <- dr_nuisance(d$Y, d$A, d$X, "full", SL.library = lib)
#> Loading required namespace: glmnet
nu_cross <- dr_nuisance(d$Y, d$A, d$X, "crossfit", SL.library = lib)
hand_tmle <- dr_tmle(d$Y, d$A, nu_full)$estimate # hand TMLE (full-sample targeting)
hand_dml <- dr_aipw(d$Y, d$A, nu_cross)$estimate # hand DML (cross-fit one-step)
c(hand_TMLE = hand_tmle, hand_DML = hand_dml)
#> hand_TMLE hand_DML
#> 3.340813 3.406938
rows <- list()
add <- function(cmp, hand, pkg)
rows[[length(rows) + 1]] <<-
data.frame(comparison = cmp, dr3 = hand, package = pkg, abs_diff = abs(hand - pkg))
## tmle::tmle -- same Super Learner library, full-data targeting
if (have("tmle")) tryCatch({
set.seed(1)
tm <- tmle::tmle(Y = d$Y, A = d$A, W = as.data.frame(d$X),
Q.SL.library = lib, g.SL.library = lib, family = "gaussian")
add("TMLE (full) vs tmle::tmle", hand_tmle, tm$estimates$ATE$psi)
}, error = function(e) message("tmle: ", conditionMessage(e)))
## AIPW::AIPW -- same library, k-fold cross-fitting (= DML)
if (have("AIPW")) tryCatch({
set.seed(1)
ap <- AIPW::AIPW$new(Y = d$Y, A = d$A, W = as.data.frame(d$X),
Q.SL.library = lib, g.SL.library = lib,
k_split = 5, verbose = FALSE)$fit()$summary()
add("DML (cross-fit) vs AIPW::AIPW", hand_dml, as.numeric(ap$estimates$RD["Estimate"]))
}, error = function(e) message("AIPW: ", conditionMessage(e)))
## DoubleML::DoubleMLIRM -- independent mlr3 ranger learners (a looser check)
if (have("DoubleML") && have("mlr3") && have("mlr3learners")) tryCatch({
suppressMessages({ library(mlr3); library(mlr3learners) })
if (have("lgr")) lgr::get_logger("mlr3")$set_threshold("warn")
df <- data.frame(Y = d$Y, A = d$A, as.data.frame(d$X))
dat <- DoubleML::DoubleMLData$new(df, y_col = "Y", d_cols = "A",
x_cols = setdiff(names(df), c("Y", "A")))
set.seed(1)
irm <- DoubleML::DoubleMLIRM$new(dat,
ml_g = mlr3::lrn("regr.ranger"),
ml_m = mlr3::lrn("classif.ranger", predict_type = "prob"),
n_folds = 5, score = "ATE")
irm$fit()
add("DML (cross-fit) vs DoubleML (ranger)", hand_dml, as.numeric(irm$coef))
}, error = function(e) message("DoubleML: ", conditionMessage(e)))
knitr::kable(do.call(rbind, rows), digits = 3,
col.names = c("comparison", "dr3 (hand)", "package", "abs diff"))| comparison | dr3 (hand) | package | abs diff |
|---|---|---|---|
| TMLE (full) vs tmle::tmle | 3.341 | 3.333 | 0.008 |
| DML (cross-fit) vs AIPW::AIPW | 3.407 | 3.391 | 0.016 |
| DML (cross-fit) vs DoubleML (ranger) | 3.407 | 3.459 | 0.052 |
What the table says
With the same Super Learner library, dr3’s
hand-coded TMLE and DML match tmle::tmle and
AIPW::AIPW to within a small fraction of a kilogram — the
transparent step-by-step code and the established packages are computing
the same thing. DoubleML here uses independent
ranger learners rather than the shared library, so it is a
looser comparison, but it still lands in the same 3.4-3.5 kg
neighbourhood.
The practical lesson: when two packages disagree, the estimator label is rarely the reason. Match the nuisance library, the number of cross-fitting folds, and the propensity-truncation bounds, and the disagreement collapses. That is the short checklist to reconcile any two doubly-robust implementations.