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.
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.
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.
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")
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.