Causal Inference II(c) — Modern Balancing (R companion)¶

WeightIt (entropy balancing), CBPS, and Matching::GenMatch¶

R has mature, dedicated packages for each modern balancing method, so this companion leads with them on the LaLonde data (experimental benchmark ~USD 1,794):

  • WeightIt(..., method = "ebal") — entropy balancing (Hainmueller), reproducing the from-scratch Python weights exactly;
  • CBPS — the Covariate Balancing Propensity Score (Imai & Ratkovic);
  • Matching::GenMatch — genetic matching (Diamond & Sekhon), searching for the distance metric that optimizes balance;
  • cobalt — to verify balance.

All three land in the same credible band (~USD 1,100–1,300), with entropy balancing matching the Python estimate to the dollar. (TMLE — the doubly-robust efficient estimator — was built from scratch in the Python notebook; R's tmle/tmle3 packages implement it, though their default target is the population ATE rather than the ATT.)

1. Entropy balancing — WeightIt(method = "ebal")¶

WeightIt with method = "ebal" solves the same maximum-entropy problem as the from-scratch Python code, returning control weights that make the covariate means exactly match the treated group's. cobalt::bal.tab confirms the exact balance, and the weighted regression gives the ATT — identical to the Python estimate of USD 1,273.

In [1]:
suppressMessages({library(WeightIt); library(cobalt); library(CBPS); library(Matching)})
d<-read.csv("lalonde_obs.csv"); cov<-c("age","educ","black","hispan","married","nodegree","re74","re75")
f<-as.formula(paste("treat~",paste(cov,collapse="+")))
eb<-weightit(f, data=d, method="ebal", estimand="ATT")
att_eb<-coef(lm(re78~treat, data=d, weights=eb$weights))["treat"]
cat(sprintf("entropy balancing ATT = %.0f  (Python from-scratch: 1273)\n", att_eb))
cat("\ncobalt balance after entropy balancing (Diff.Adj ~ 0 = exact):\n")
print(bal.tab(eb, un=TRUE)$Balance[,c("Diff.Un","Diff.Adj")])
Warning message:
"package 'WeightIt' was built under R version 4.6.1"
Warning message:
"package 'cobalt' was built under R version 4.6.1"
Warning message:
"package 'CBPS' was built under R version 4.6.1"
Warning message:
"package 'MatchIt' was built under R version 4.6.1"
Warning message:
"package 'Matching' was built under R version 4.6.1"
entropy balancing ATT = 1273  (Python from-scratch: 1273)
cobalt balance after entropy balancing (Diff.Adj ~ 0 = exact):
             Diff.Un      Diff.Adj
age      -0.30944526 -3.112177e-12
educ      0.05496466 -4.529710e-13
black     0.64044604  7.162049e-13
hispan   -0.08273168  4.477668e-14
married  -0.32363132 -2.200184e-13
nodegree  0.11137151  1.291189e-13
re74     -0.72108381 -2.045454e-09
re75     -0.29026291 -5.197762e-10

2. CBPS and genetic matching — CBPS and GenMatch¶

CBPS estimates the propensity score under a moment condition that forces covariate balance, rather than only maximizing the treatment-model likelihood — so a single fit both predicts treatment and balances covariates. Genetic matching takes a different route: it runs a genetic algorithm to search for the Mahalanobis-distance weights that best balance the covariates (here matching on the covariates plus the propensity score, as Diamond & Sekhon recommend). Both give ATTs in the credible band.

In [2]:
cb<-weightit(f, data=d, method="cbps", estimand="ATT")
att_cbps<-coef(lm(re78~treat, data=d, weights=cb$weights))["treat"]
ps<-glm(f, data=d, family=binomial)$fitted
X<-cbind(as.matrix(d[,cov]), ps)
set.seed(1); gm<-GenMatch(Tr=d$treat, X=X, estimand="ATT", pop.size=200, print.level=0)
mm<-Match(Y=d$re78, Tr=d$treat, X=X, Weight.matrix=gm, estimand="ATT")
cat(sprintf("CBPS ATT             = %.0f\n", att_cbps))
cat(sprintf("genetic matching ATT = %.0f (SE %.0f)\n", mm$est, mm$se))
Loading required namespace: rgenoud

CBPS ATT             = 1273
genetic matching ATT = 1089 (SE 1421)

3. All methods against the benchmark¶

Collecting the modern balancers with the experimental truth: entropy balancing and CBPS at ~USD 1,270, genetic matching a bit lower, all a world away from the confounded naive gap and consistent with the IPW/AIPW/TMLE estimates of the earlier notebooks. The spread reflects LaLonde's well-known method-sensitivity — and, as the sensitivity notebook showed, the whole set remains fragile to unmeasured confounding.

In [3]:
naive<-mean(d$re78[d$treat==1])-mean(d$re78[d$treat==0])
res<-c(`naive (CPS)`=naive, `entropy balancing`=att_eb, `CBPS`=att_cbps, `genetic matching`=mm$est)
options(repr.plot.width=8.5, repr.plot.height=4)
bp<-barplot(res, horiz=TRUE, las=1, col=c("#a0aec0","#2f855a","#2b6cb0","#dd6b20"), xlim=c(min(res)-800, 2200),
            xlab="estimated ATT (USD)", main="Modern balancing vs the experimental benchmark")
abline(v=1794, col="#c53030", lwd=2.5, lty=2); abline(v=0, col="black", lwd=.6)
text(res+ifelse(res>=0,120,-120), bp, sprintf("%.0f",res), cex=.85)
legend("bottomright", "experimental truth 1794", col="#c53030", lwd=2.5, lty=2, bty="n")
No description has been provided for this image

4. Summary¶

R's balancing packages reproduced the Python notebook: WeightIt entropy balancing matched the from-scratch estimate exactly (USD 1,273) with exact covariate balance confirmed by cobalt; CBPS (balance-targeting propensity score) agreed; and Matching::GenMatch (genetic matching) landed in the same credible band. Entropy balancing and CBPS are the tools to remember for balance-by-design, replacing the fit-check-refit loop of ordinary propensity matching.

The complete matching-subsection toolkit is now: propensity matching + IPW + AIPW (ex1), sensitivity analysis (ex2), and modern balancing + TMLE (ex3) — with the standing discipline that every one of them assumes unconfoundedness and must be paired with a sensitivity analysis. Cross-links: entropy balancing / CBPS are calibration-weighting relatives of IPW; TMLE is the efficient sibling of the AIPW/DML doubly-robust estimators (subsection 9). This completes subsection 2's depth.