Lalonde NSW/PSID Demo¶
Classic propensity score matching benchmark: National Supported Work (NSW) demonstration vs PSID population.
Dataset¶
- 185 treated (NSW job training)
- 15,992 controls (PSID-1/PSID-3, synthetic "observational")
- Outcome: 1978 earnings (
re78) - Covariates: age, education, race, marital status, degree, prior earnings (
re74,re75) - Confounding: Treated group disadvantaged on almost all covariates
from causal_toolkit.utils import load_dataset
df = load_dataset("lalonde")
print(df.treatment.value_counts())
# 0 15992
# 1 185
Key Challenge: Selection Bias¶
# Baseline comparison (naive)
treated = df[df.treatment == 1].re78.mean() # ~$6,350
control = df[df.treatment == 0].re78.mean() # ~$7,200
print(f"Naive diff: {treated - control:.0f}") # Negative! -$850
Training program appears harmful due to selection bias.
Matching / Weighting¶
from causal_toolkit import CausalModel
from causal_toolkit.wrappers import DoWhyWrapper
from causal_toolkit.core.base import EstimatorType, IdentificationStrategy
from causal_toolkit.visualization import LovePlot
from causal_toolkit.utils import compute_all_smds
model = CausalModel(
df, "treatment", "re78",
common_causes=["age", "education", "black", "hispanic", "married", "nodegree", "re74", "re75"]
)
# Balance before
before = compute_all_smds(df, "treatment", model.common_causes)
print("Before matching:", {k: v["smd"] for k, v in before.items()})
# age: 0.15, education: 0.11, black: 0.62, re74: 0.28, ...
# Matching
dowhy = DoWhyWrapper(model)
estimate = dowhy.estimate(EstimatorType.PROPENSITY_SCORE_MATCHING, n_neighbors=1)
# Balance after (approximate - DoWhy doesn't expose matched sample easily)
# Would need manual matching for full Love plot
Multiple Estimators Comparison¶
estimates = []
for est in [
EstimatorType.LINEAR_REGRESSION,
EstimatorType.PROPENSITY_SCORE_MATCHING,
EstimatorType.PROPENSITY_SCORE_WEIGHTING,
EstimatorType.DOUBLY_ROBUST,
]:
est_result = dowhy.estimate(est)
estimates.append({
"label": est.value,
"estimate": est_result.value,
"ci_lower": est_result.ci_lower,
"ci_upper": est_result.ci_upper,
})
# Forest plot
from causal_toolkit.visualization import ForestPlot
fig = ForestPlot().plot(estimates, title="Lalonde: ATE by Estimator")
fig.savefig("lalonde_forest.png")
Sensitivity Analysis¶
from causal_toolkit.analysis import SensitivityAnalyzer
sa = SensitivityAnalyzer(model)
sa.cinelli_hazlett(estimate, benchmark_covariate="re74")
sa.rosenbaum_bounds(estimate)
print(sa.summarize())
Expected Results¶
| Estimator | ATE ($) | Notes |
|---|---|---|
| Naive | -850 | Confounded |
| Linear regression | +886 | Model-dependent |
| PS Matching | +1,794 | LaLonde 1986 benchmark |
| PS Weighting | +1,948 | IPTW |
| Doubly robust | +1,821 | Recommended |
True effect (from RCT): ~$1,800
Key Lessons¶
- Naive comparison fails — selection bias masks true effect
- Matching/weighting needed — adjust for confounders
- Doubly robust — best of both worlds (model + propensity)
- Overlap matters — poor overlap for Black participants drives variance
- Sensitivity — results moderately robust to unmeasured confounding