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.

In [1]:
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.

In [2]:
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
No description has been provided for this image

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.

In [3]:
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.

In [4]:
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
No description has been provided for this image

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.