Skip to contents

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