Causal Inference II — Potential Outcomes & Matching (R companion)¶
MatchIt, cobalt, WeightIt, and Matching on the LaLonde problem¶
R is the home of the matching literature, and this companion leads with its reference packages to reproduce the Python notebook's from-scratch results on the same LaLonde data:
MatchIt(Ho, Imai, King, Stuart) — the standard interface for propensity-score and Mahalanobis matching;cobalt— the definitive covariate-balance diagnostics and Love plots;WeightIt— inverse-probability and doubly-robust weighting;Matching(Sekhon) — nearest-neighbor matching with the Abadie-Imbens standard errors that a naive bootstrap cannot provide (the caveat flagged in the Python notebook).
The experimental benchmark (ATT ≈ USD 1,794) and the confounded naive estimate (≈ −USD 635) are as in the Python notebook; here the established tools confirm that matching and weighting recover the truth — and, crucially, attach a valid standard error to it.
1. The data and the confounding — cobalt::bal.tab¶
The same two LaLonde samples: the experimental NSW trial (185 treated + 260 randomized controls) that fixes the truth by simple difference in means, and the observational sample (the 185 treated + 429 CPS survey controls) where selection confounds the comparison. cobalt::bal.tab quantifies the imbalance — standardized mean differences far outside ±0.1 on nearly every covariate, and especially on the pre-program earnings re74/re75.
suppressMessages({library(MatchIt); library(cobalt); library(WeightIt); library(Matching)})
exp<-read.csv("lalonde_exp.csv"); obs<-read.csv("lalonde_obs.csv")
cov<-c("age","educ","black","hispan","married","nodegree","re74","re75")
f<-as.formula(paste("treat ~",paste(cov,collapse="+")))
bench<-mean(exp$re78[exp$treat==1])-mean(exp$re78[exp$treat==0])
naive<-mean(obs$re78[obs$treat==1])-mean(obs$re78[obs$treat==0])
cat(sprintf("EXPERIMENTAL benchmark (randomized NSW): ATT = $%.0f <-- the truth\n",bench))
cat(sprintf("OBSERVATIONAL naive difference (CPS controls): $%.0f <-- confounded (bias $%.0f)\n\n",naive,naive-bench))
cat("cobalt::bal.tab — imbalance before adjustment:\n")
print(bal.tab(f, data=obs, estimand="ATT", m.threshold=0.1))
Warning message: "package 'MatchIt' was built under R version 4.6.1"
Warning message: "package 'cobalt' was built under R version 4.6.1"
Warning message: "package 'WeightIt' was built under R version 4.6.1"
Warning message: "package 'Matching' was built under R version 4.6.1"
EXPERIMENTAL benchmark (randomized NSW): ATT = $1794 <-- the truth
OBSERVATIONAL naive difference (CPS controls): $-635 <-- confounded (bias $-2429)
cobalt::bal.tab — imbalance before adjustment:
Balance Measures Type Diff.Un M.Threshold.Un age Contin. -0.3094 Not Balanced, >0.1 educ Contin. 0.0550 Balanced, <0.1 black Binary 0.6404 Not Balanced, >0.1 hispan Binary -0.0827 Balanced, <0.1 married Binary -0.3236 Not Balanced, >0.1 nodegree Binary 0.1114 Not Balanced, >0.1 re74 Contin. -0.7211 Not Balanced, >0.1 re75 Contin. -0.2903 Not Balanced, >0.1 Balance tally for mean differences count Balanced, <0.1 2 Not Balanced, >0.1 6 Variable with the greatest mean difference Variable Diff.Un M.Threshold.Un re74 -0.7211 Not Balanced, >0.1 Sample sizes Control Treated All 429 185
2. Propensity matching and balance — MatchIt + cobalt::love.plot¶
matchit() estimates the propensity score (logistic distance = "glm") and performs 1:1 nearest-neighbor matching with replacement targeting the ATT — the direct analogue of the from-scratch matcher in the Python notebook. cobalt::love.plot then shows the balance transformation: the large pre-matching standardized differences collapse toward zero after matching, confirming the matched control group now resembles the trainees on observed covariates.
options(repr.plot.width=9, repr.plot.height=5)
m<-matchit(f, data=obs, method="nearest", distance="glm", replace=TRUE, estimand="ATT")
print(summary(m)$sum.matched[,1:4])
love.plot(m, binary="std", thresholds=c(m=.1), abs=FALSE,
colors=c("#c53030","#2f855a"), sample.names=c("Before matching","After matching"),
title="cobalt Love plot — MatchIt propensity matching")
Means Treated Means Control Std. Mean Diff. Var. Ratio distance 5.774355e-01 5.764715e-01 0.004376409 0.9921642 age 2.581622e+01 2.410270e+01 0.239484123 0.5565082 educ 1.034595e+01 1.037838e+01 -0.016130320 0.5773139 black 8.432432e-01 8.378378e-01 0.014867526 NA hispan 5.945946e-02 6.486486e-02 -0.022857516 NA married 1.891892e-01 1.297297e-01 0.151814423 NA nodegree 7.081081e-01 7.027027e-01 0.011889606 NA re74 2.095574e+03 2.336463e+03 -0.049295664 1.0363402 re75 1.532055e+03 1.503929e+03 0.008736925 2.1294180
3. The ATT with a valid standard error — Matching::Match¶
The point estimate is only half the answer; matching also needs a correct standard error, and the ordinary bootstrap is invalid for matching estimators (Abadie & Imbens, 2006, 2008). The Matching package implements the correct Abadie-Imbens variance. We match on the estimated propensity score and read off the ATT with its valid SE, alongside the MatchIt matched-data estimate. Both recover the experimental truth of ≈ USD 1,794; the AI standard error also honestly reports that, with only 185 treated units, the confidence interval is wide.
ps<-glm(f, data=obs, family=binomial)$fitted
mm<-Match(Y=obs$re78, Tr=obs$treat, X=ps, estimand="ATT", M=1, replace=TRUE)
att_mi<-coef(lm(re78~treat, data=match.data(m), weights=weights))["treat"]
ci<-c(mm$est-1.96*mm$se, mm$est+1.96*mm$se)
cat(sprintf("Matching::Match ATT = $%.0f (Abadie-Imbens SE = $%.0f; 95%% CI [$%.0f, $%.0f])\n", mm$est, mm$se, ci[1], ci[2]))
cat(sprintf("MatchIt matched ATT = $%.0f\n", att_mi))
cat(sprintf("Experimental truth = $%.0f\n", bench))
cat("\nBoth recover the randomized benchmark; the AI CI is wide (small treated n) but comfortably excludes the naive negative estimate.")
Matching::Match ATT = $1933 (Abadie-Imbens SE = $1091; 95% CI [$-204, $4071])
MatchIt matched ATT = $1992
Experimental truth = $1794
Both recover the randomized benchmark; the AI CI is wide (small treated n) but comfortably excludes the naive negative estimate.
4. Weighting and doubly-robust estimation — WeightIt¶
WeightIt builds inverse-probability weights for the ATT (estimand = "ATT"), reweighting controls to resemble the treated. Weighting the outcome regression by these weights gives the IPW estimate; adding the covariates back into that weighted regression gives a doubly-robust estimate (consistent if either the weight model or the outcome model is right). We collect every estimator against the experimental benchmark — all of them a world away from the naive −USD 635, clustered around the truth.
options(repr.plot.width=8.5, repr.plot.height=4.6)
w<-weightit(f, data=obs, method="glm", estimand="ATT")
att_ipw<-coef(lm(re78~treat, data=obs, weights=w$weights))["treat"]
att_dr <-coef(lm(as.formula(paste("re78~treat+",paste(cov,collapse="+"))), data=obs, weights=w$weights))["treat"]
res<-c("naive (CPS)"=naive,"IPW"=att_ipw,"doubly robust"=att_dr,"Match (AI)"=mm$est,"MatchIt"=att_mi)
cat("ATT estimates:\n"); for(k in names(res)) cat(sprintf(" %-14s $%.0f\n",k,res[k]))
bp<-barplot(res, horiz=TRUE, las=1, col=c("#a0aec0","#dd6b20","#6b46c1","#2b6cb0","#2f855a"),
xlim=c(min(res)-500,max(res)+800), xlab="estimated ATT (USD)",
main="Every method vs the randomized benchmark")
abline(v=bench, col="#c53030", lwd=2.5, lty=2); abline(v=0, col="black", lwd=.7)
text(res+ifelse(res>=0,250,-250), bp, sprintf("$%.0f",res), cex=.8)
legend("bottomright", sprintf("experimental truth $%.0f",bench), col="#c53030", lwd=2.5, lty=2, bty="n", cex=.85)
ATT estimates:
naive (CPS) $-635 IPW.treat $1214 doubly robust.treat $1237 Match (AI) $1933 MatchIt.treat $1992
5. Summary¶
The R reference packages reproduced the Python notebook's story on the LaLonde data: from a confounded naive estimate of −USD 635, MatchIt propensity matching and WeightIt IPW / doubly-robust weighting recovered the randomized benchmark of ≈ USD 1,794, and cobalt::love.plot verified that matching erased the covariate imbalance. The value R adds beyond the from-scratch Python is the Matching package's Abadie-Imbens standard error — the statistically correct uncertainty for a matching estimator, which a naive bootstrap would get wrong, and which honestly shows the estimate is imprecise with only 185 treated units.
The workflow to remember for the whole observational toolkit: estimate the propensity model → check balance (cobalt) → estimate the effect with a valid SE → and remember that balance is testable but unconfoundedness is not. Next, Instrumental Variables confronts exactly the failure this method cannot detect — an unobserved confounder — using AER/ivreg and fixest.