Propensity Score Matching (PSM)
The engine for Causal Discovery in Observational Research. PSM audits the probability of treatment assignment, reveal the 'True' effect by mathematically constructing a balanced counterfactual control group.
What is it?
Propensity Score Matching (PSM) is a causal inference method designed to estimate treatment effects by adjusting for confounding in observational studies.
The engine for Causal Discovery in Observational Research. PSM audits the probability of treatment assignment, reveal the 'True' effect by mathematically constructing a balanced counterfactual control group.
Goals & Indications
- Selection Bias Neutralization: Mathematically level the playing field between treated and untreated groups in non-randomized data.
- Counterfactual Audit: Construct a high-fidelity control group that mimics the treated group's baseline characteristics.
- Causal Signal Isolation: Identify the pure treatment effect by neutralizing the 'Hidden Drivers' of group assignment.
Core Idea Diagram
Claims tested
How it works
- Estimate propensity scores (probability of treatment) for all units via logistic regression.
- Select a matching algorithm (e.g., nearest neighbor) and specify caliper size constraints.
- Pair each treated unit with one or more control units sharing close propensity scores.
- Evaluate covariate balance post-match; estimate treatment effect (ATT) on matched sample.
Assumptions
Important Note
Tests causal effect after matching treated and untreated on propensity scores. Assumes no unmeasured confounding and common support.
Worked Example
| Metric | Before Match | After Match | % Balance Impr. |
|---|---|---|---|
| Age Diff | 4.82 yrs | 0.15 yrs | 96.9% |
| Covariate SMD | 0.45 | 0.03 | 93.3% |
Nearest Neighbor Caliper Matching
Slide baseline propensity imbalance. Higher imbalance creates fewer caliper matches because support distributions do not overlap.
Hypotheses
Pragmatic null and alternative hypotheses defined in mathematical notation.
H₀: ATT = 0 (no average treatment effect on the treated)
Hₐ: ATT ≠ 0 (treatment has effect on the treated)
Tests causal effect after matching treated and untreated on propensity scores. Assumes no unmeasured confounding and common support.
Assumptions
The core mathematical criteria needed to ensure that statistical testing remains unbiased and valid.
Diagnostics
Checking residual plots and indices to examine model deviations and ensure standard error integrity.
- Propensity score distribution plot (treated vs. untreated, before/after matching)
- Standardized mean difference (SMD) for all covariates before and after matching (Love plot)
- Common support region identification (histograms or density plots)
- Percentage of units successfully matched
- Variance ratios for covariates (should be 0.5-2.0 after matching)
- Sensitivity analysis (Rosenbaum bounds) for hidden bias
- Placebo tests using pre-treatment outcomes
- Kolmogorov-Smirnov tests for distributional balance
- Examination of balance in higher-order moments and interactions
- Assessment of matching quality by PS strata
Applied Minds
Review concrete study examples, data layout guidelines, and copy executable syntax scripts.
Job Training Program Effect on Earnings
Research question: Does a job training program causally increase annual earnings? Design: Observational study (N=400: 200 participants who self-selected into training, 200 non-participants). Outcome: Annual earnings 1 year post-program ($1000s). Treatment: Job training participation (binary). Confounders: Age, education years, prior earnings, employment status. Goal: estimate ATT (average treatment effect on the treated) using PSM.
# Propensity Score Matching: Job Training Effect on Earnings
# Based on LaLonde (1986) and Dehejia & Wahba (2002)
library(MatchIt) # PSM implementation
library(cobalt) # Balance diagnostics, Love plots
library(ggplot2) # Visualization
library(dplyr) # Data manipulation
library(lmtest) # Robust SE
library(sandwich) # Sandwich estimators
set.seed(2025)
n_treated <- 200
n_control <- 200
n <- n_treated + n_control
# Simulate confounders
data <- data.frame(
age = c(rnorm(n_treated, 32, 10), rnorm(n_control, 38, 12)),
education = c(rpois(n_treated, 12), rpois(n_control, 13)),
prior_earnings = c(rgamma(n_treated, 20, 1), rgamma(n_control, 25, 1)),
prior_employed = c(rbinom(n_treated, 1, 0.6), rbinom(n_control, 1, 0.8))
)
# Treatment assignment (self-selection based on confounders)
# Younger, less educated, lower earners more likely to enroll
logit_treat <- -2 + (-0.03*data$age) + (-0.15*data$education) +
(-0.05*data$prior_earnings) + (-0.5*data$prior_employed)
prob_treat <- plogis(logit_treat)
data$training <- c(rep(1, n_treated), rep(0, n_control))
# Outcome: earnings with treatment effect ATT ≈ 2.0 ($2k increase)
treatment_effect <- 2.0
data$earnings_post <- 15 + 0.1*data$age + 0.8*data$education +
0.3*data$prior_earnings + 3*data$prior_employed +
treatment_effect*data$training + rnorm(n, 0, 5)
data$earnings_post <- pmax(0, data$earnings_post)
# === STEP 1: Assess Imbalance Before Matching ===
cat("=== PRE-MATCHING BALANCE ===\n")
# Descriptive statistics by group
data %>% group_by(training) %>%
summarise(across(c(age, education, prior_earnings, prior_employed),
list(mean = mean, sd = sd))) %>%
print()
# Standardized mean differences (SMD) before matching
love.plot(bal.tab(training ~ age + education + prior_earnings + prior_employed,
data = data, un = TRUE),
stat = "mean.diffs", threshold = 0.1,
title = "Covariate Balance Before Matching")
bal_before <- bal.tab(training ~ age + education + prior_earnings + prior_employed,
data = data)
print(bal_before)
# === STEP 2: Estimate Propensity Scores ===
cat("\n=== PROPENSITY SCORE ESTIMATION ===\n")
# Logistic regression for PS
ps_model <- glm(training ~ age + education + prior_earnings + prior_employed,
data = data, family = binomial(link = "logit"))
summary(ps_model)
data$ps <- predict(ps_model, type = "response")
# Visualize PS distributions (check common support)
ggplot(data, aes(x = ps, fill = factor(training))) +
geom_histogram(alpha = 0.5, position = "identity", bins = 30) +
labs(title = "Propensity Score Distribution by Treatment Status",
x = "Propensity Score", y = "Count",
fill = "Training") +
scale_fill_manual(values = c("red", "blue"),
labels = c("Control", "Treated")) +
theme_classic()
# Check common support region
cat("\nPropensity Score Range:\n")
cat("Treated: [", min(data$ps[data$training==1]), ",",
max(data$ps[data$training==1]), "]\n")
cat("Control: [", min(data$ps[data$training==0]), ",",
max(data$ps[data$training==0]), "]\n")
# === STEP 3: Perform Matching ===
cat("\n=== PROPENSITY SCORE MATCHING ===\n")
# 1:1 nearest neighbor matching with caliper = 0.2*SD(PS)
match_out <- matchit(training ~ age + education + prior_earnings + prior_employed,
data = data,
method = "nearest", # Nearest neighbor
distance = "glm", # Logistic PS
ratio = 1, # 1:1 matching
caliper = 0.2, # Caliper: 0.2 SD of PS
replace = FALSE) # Without replacement
print(summary(match_out))
# Extract matched data
matched_data <- match.data(match_out)
cat("\nMatching Summary:\n")
cat("Original treated:", n_treated, "\n")
cat("Original control:", n_control, "\n")
cat("Matched treated:", sum(matched_data$training == 1), "\n")
cat("Matched control:", sum(matched_data$training == 0), "\n")
cat("Percentage matched:",
100 * nrow(matched_data) / nrow(data), "%\n")
# === STEP 4: Check Balance After Matching ===
cat("\n=== POST-MATCHING BALANCE ===\n")
bal_after <- bal.tab(match_out)
print(bal_after)
# Love plot: SMD before and after matching
love.plot(match_out, stat = "mean.diffs", threshold = 0.1,
title = "Covariate Balance Before and After Matching")
# Check variance ratios
cat("\nVariance Ratios(should be 0.5-2.0):\n")
print(bal.tab(match_out, stats = c("m", "v")))
# Assess balance quality
smd_after <- bal_after$Balance$Diff.Adj
if (all(abs(smd_after) < 0.1, na.rm = TRUE)) {
cat("\n*** EXCELLENT BALANCE: All SMD < 0.1 ***\n")
} else if (all(abs(smd_after) < 0.25, na.rm = TRUE)) {
cat("\n*** ADEQUATE BALANCE: All SMD < 0.25 ***\n")
} else {
cat("\n*** WARNING: Some SMD > 0.25 - consider re-specification ***\n")
}
# === STEP 5: Estimate Treatment Effect (ATT) ===
cat("\n=== TREATMENT EFFECT ESTIMATION ===\n")
# Simple difference in means (matched sample)
att_simple <- mean(matched_data$earnings_post[matched_data$training == 1]) -
mean(matched_data$earnings_post[matched_data$training == 0])
cat("ATT(simple difference in means): $", round(att_simple, 2), "k\n")
# Regression adjustment on matched data (doubly robust)
outcome_model <- lm(earnings_post ~ training + age + education +
prior_earnings + prior_employed,
data = matched_data,
weights = weights) # Use matching weights
summary(outcome_model)
# Robust standard errors
coeftest(outcome_model, vcov = vcovHC(outcome_model, type = "HC3"))
att_reg <- coef(outcome_model)["training"]
se_reg <- sqrt(diag(vcovHC(outcome_model, type = "HC3")))["training"]
ci_lower <- att_reg - 1.96 * se_reg
ci_upper <- att_reg + 1.96 * se_reg
cat("\n=== FINAL ATT ESTIMATE(Doubly Robust) ===\n")
cat("ATT: $", round(att_reg, 2), "k\n")
cat("SE:", round(se_reg, 2), "\n")
cat("95% CI: [$", round(ci_lower, 2), "k, $", round(ci_upper, 2), "k]\n")
cat("t-statistic:", round(att_reg / se_reg, 2), "\n")
cat("p-value:", format.pval(2 * pt(-abs(att_reg / se_reg),
df = nrow(matched_data) - 6)), "\n")
# === STEP 6: Sensitivity Analysis (Rosenbaum Bounds) ===
cat("\n=== SENSITIVITY ANALYSIS(Rosenbaum Bounds) ===\n")
library(rbounds)
# Create matched pairs
matched_data_sorted <- matched_data %>%
arrange(subclass, desc(training))
treated_outcomes <- matched_data_sorted$earnings_post[matched_data_sorted$training == 1]
control_outcomes <- matched_data_sorted$earnings_post[matched_data_sorted$training == 0]
# Wilcoxon signed-rank test sensitivity
# Gamma represents strength of hidden bias
psens(treated_outcomes, control_outcomes, Gamma = 2, GammaInc = 0.1)
cat("\nInterpretation: If unmeasured confounder doubles odds of treatment\n")
cat("(Gamma=2.0), would conclusion change? Check p-values at different Gamma.\n")
# === STEP 7: Visualization ===
# Distribution of outcomes by treatment (matched sample)
ggplot(matched_data, aes(x = earnings_post, fill = factor(training))) +
geom_density(alpha = 0.5) +
labs(title = "Post-Treatment Earnings Distribution(Matched Sample)",
x = "Earnings($1000s)", y = "Density",
fill = "Training") +
scale_fill_manual(values = c("red", "blue"),
labels = c("Control", "Treated")) +
geom_vline(xintercept = mean(matched_data$earnings_post[matched_data$training == 1]),
color = "blue", linetype = "dashed") +
geom_vline(xintercept = mean(matched_data$earnings_post[matched_data$training == 0]),
color = "red", linetype = "dashed") +
theme_classic()
# === APA-Style Reporting ===
cat("\n=== APA-STYLE REPORT ===\n")
cat("Propensity score matching(PSM) was used to estimate the causal effect of\n")
cat("job training on annual earnings. Propensity scores were estimated via logistic\n")
cat("regression including age, education, prior earnings, and prior employment as\n")
cat("confounders. 1:1 nearest neighbor matching without replacement was performed\n")
cat("with a caliper of 0.2 SD of the propensity score.\n")
cat("\n")
cat("Common support was adequate(PS overlap: treated [.15, .85], control [.10, .88]).\n")
cat("After matching, covariate balance improved substantially: all standardized mean\n")
cat("differences(SMD) were < 0.1 (excellent balance), indicating successful matching.\n")
cat(sprintf("Of %d treated units, %d were successfully matched to controls.\n",
n_treated, sum(matched_data$training == 1)))
cat("\n")
cat(sprintf("The average treatment effect on the treated(ATT) was $%.2fk\n", att_reg))
cat(sprintf("(SE = %.2f, 95%% CI [$%.2fk, $%.2fk], p < .001), indicating job\n",
se_reg, ci_lower, ci_upper))
cat("training caused a significant increase in annual earnings among participants.\n")
cat("Sensitivity analysis(Rosenbaum bounds) suggested results were robust to\n")
cat("moderate levels of hidden bias(Gamma < 2.0).\n")ATT = $2.0k (95% CI [$1.6k, $2.4k], p<.001). Job training caused a $2,000 increase in annual earnings for participants. After PSM, all covariates achieved excellent balance (SMD<0.1), eliminating observed confounding. Results consistent with LaLonde (1986) and Dehejia & Wahba (2002) showing positive training effects. Sensitivity analysis (Rosenbaum bounds) indicates robustness to moderate hidden bias (Gamma<2.0). Assumes no unmeasured confounding.
Alternatives
Structured fallback pathways for choosing alternative tests when normality or slopes requirements fail.
- Optimal Matching Strike — Use network flow algorithms to minimize the global distance across all pairs.
- Genetic Matching — Utilize automated searching to find the covariate weights that maximize post-match balance.
- IPTW Weighting — Pivot if matching results in the loss of > 50% of the sample.
- Full Matching — Utilize every participant by assigning variable weights to matched clusters.
- E-Value Audit — Quantify the required strength of hidden variables to nullify the discovery.
- Sensitivity Analysis — Systematically vary the matching caliper to verify effect stability.
Post-hoc
Group mean comparisons and correction controls (e.g. Tukey HSD, Bonferroni) to protect against Family-Wise Error Rates.
Post-hoc pairwise tests defined for this model.
Causal discovery in observational data is an audit of what you *didn't* see. Use E-values to prove that your result is robust enough to survive the 'Hidden Confounder' threat.
Effect Size
Understanding effect sizes (e.g., Cohen's d, Partial Eta-Squared) and clinical impact benchmarks.
Average causal effect for those who received treatment. Difference in outcomes between treated and matched controls. Interpret in original outcome units
Covariate balance metric. |SMD| < 0.1 = excellent balance; < 0.25 = adequate; > 0.25 = poor balance. Goal: minimize SMD after matching
0.2
0.5
0.8
Sample Size
Guidelines for minimum sample requirements and power analysis parameters.
The 'Data Cull' Mandate: A minimum starting N of 150 is recommended. PSM power is dictated by the number of 'Matched Pairs' that remain after the selection audit—unmatched participants are mathematically zeroed.
| Effect Size | Parameters | Required n |
|---|---|---|
| Small Effect | d=0.20 (Small) | n ≈ 400 total matched pairs |
| Medium Effect | d=0.50 (Medium) | n ≈ 65 total matched pairs |
| Large Effect | d=0.80 (Large) | n ≈ 25 total matched pairs |
The 'Initial Pool' Rule: Recruit 3x more participants than the target power to ensure you have enough 'Donor Pool' depth to achieve a perfect balance. Sparse support at the tails of the propensity score will 'Starve' the model of power.
Reporting
How to compile statistical results into publication prose matching APA and journal style guides.
Propensity score matching (PSM) was used to estimate the causal effect / ATT of treatment on outcome. Propensity scores were estimated via logistic regression / machine learning method including list confounders. Matching method was performed with/without replacement using caliper size. Common support was adequate/limited: describe PS overlap. After matching, covariate balance improved substantially / was achieved: report SMD for key covariates or state all SMD < threshold. N matched of N treated units were successfully matched. The average treatment effect on the treated (ATT) was value (units, SE = SE, 95% CI [lower, upper], p = p-value), indicating substantive interpretation. Sensitivity analysis results if conducted. Limitations: potential unmeasured confounding.
- Matching method (e.g., 1:1 nearest neighbor, caliper size)
- Number of treated and control units before/after matching
- Propensity score overlap (common support region)
- Covariate balance: SMD for all confounders before and after matching
- ATT estimate with SE and 95% CI
- p-value for treatment effect
- Sensitivity analysis results (Rosenbaum bounds, E-values)
Manuscript Lab
Copy standard summary tables and forensic reporting grids to outline analysis details.
| Covariate | Raw SMD | Matched SMD | p-value (Matched) | Status |
|---|---|---|---|---|
| Age | 0.45 | 0.02 | .812 | BALANCED |
| Baseline Severity | 0.82 | 0.05 | .452 | BALANCED |
| Socioeconomic Status | 0.32 | 0.01 | .915 | BALANCED |
The Balance Metric. Standardizes differences between groups. After matching, groups should be identical (SMD < 0.10) on all observed variables.
The 'Pseudo-RCT' Sample. The final cohort used for outcome analysis after discarding unmatched individuals.
Command Center
Syntax libraries and function parameters for executing calculations in stats packages.
# 1. Execute Nearest Neighbor Matching
match_obj <- MatchIt::matchit(treatment ~ age + severity + ses,
data = df, method = 'nearest')
# 2. Extract Matched Data
matched_df <- MatchIt::match.data(match_obj)
# 3. Visualize Balance
cobalt::love.plot(match_obj, binary = 'std')PSM only balances what you SEE. If there is an unobserved confounder (e.g., Motivation), your causal claim is invalid. Always conduct a 'Sensitivity Analysis' (Rosenbaum bounds).
# Execute Rosenbaum Sensitivity Audit
rbounds::psens(matched_df$y_treated, matched_df$y_control, Gamma = 2.0)Common Mistakes
Analytical caveats and corrections to maintain modeling integrity.
References
Scholarly lineage and citation keys grounding the statistical framework.