Propensity Score Matching
Finding comparable units in observational data
Causal inference fundamentals establishes the goal: estimate what would have happened to treated units had they not been treated, by borrowing information from untreated units with similar confounders. The most direct way to do this is matching — for each treated unit, find an untreated unit (or several) that looks the same on all observed confounders, and compare their outcomes directly.
The problem is that matching on many covariates simultaneously runs into the curse of dimensionality: with even a handful of continuous confounders, finding an untreated unit that's close on every dimension at once becomes nearly impossible. Propensity score matching (PSM), introduced by Rosenbaum and Rubin in 1983, solves this by collapsing all the confounders into a single number.
The propensity score
The propensity score is the probability of receiving treatment given observed covariates: $e(x) = P(T = 1 \mid X = x)$. It's typically estimated with logistic regression, though any well-calibrated classifier works:
$\text{logit}(e(x)) = \beta_0 + \beta_1 x_1 + \dots + \beta_p x_p$
Rosenbaum and Rubin's key theorem is that if treatment assignment is conditionally ignorable given $X$ (i.e., $X$ contains all the confounders), then it's also conditionally ignorable given the propensity score $e(X)$ alone. In other words, two units with the same propensity score — even if their raw covariate values differ — are, on average, exchangeable for causal purposes. This is the balancing property: conditioning on one scalar does the same job as conditioning on the entire covariate vector.
From scores to estimates: matching, weighting, stratification
Once every unit has a propensity score, there are several ways to use it:
Nearest-neighbor matching: pair each treated unit with the untreated unit(s) whose propensity score is closest, typically within a maximum distance (a 'caliper') to avoid bad matches. Unmatched units are discarded.
Inverse probability of treatment weighting (IPTW): instead of discarding units, reweight every unit by $1/e(x)$ if treated or $1/(1-e(x))$ if untreated. This constructs a pseudo-population where treatment is as good as randomized with respect to $X$, using everyone's data.
Stratification: bucket units into propensity-score quantiles (commonly 5) and estimate the treatment effect within each stratum, then average across strata.
Matching is the most intuitive and interpretable; IPTW uses more of the data but can be unstable if some scores are near 0 or 1 (producing huge weights).
import pandas as pd
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import NearestNeighbors
df = pd.read_csv("observational_data.csv")
X_cols = ["age", "income", "prior_usage"]
# Step 1: estimate propensity scores
ps_model = LogisticRegression()
ps_model.fit(df[X_cols], df["treatment"])
df["propensity"] = ps_model.predict_proba(df[X_cols])[:, 1]
# Step 2: nearest-neighbor matching within a caliper
treated = df[df.treatment == 1]
control = df[df.treatment == 0]
nn = NearestNeighbors(n_neighbors=1).fit(control[["propensity"]])
distances, indices = nn.kneighbors(treated[["propensity"]])
caliper = 0.05
matched_pairs = [
(t_idx, control.index[c_idx])
for t_idx, (dist, c_idx) in zip(treated.index, zip(distances.flatten(), indices.flatten()))
if dist <= caliper
]
matched_treated = df.loc[[p[0] for p in matched_pairs], "outcome"]
matched_control = df.loc[[p[1] for p in matched_pairs], "outcome"]
att = matched_treated.mean() - matched_control.mean()
print(f"Estimated ATT from {len(matched_pairs)} matched pairs: {att:.3f}")Always check covariate balance after matching, not just before
PSM cannot fix unobserved confounding
Matching vs. weighting vs. stratification
| Method | Uses full sample? | Main risk |
|---|---|---|
| Nearest-neighbor matching | No — unmatched units dropped | Discarded data, sensitive to caliper choice |
| IPTW | Yes | Extreme weights when scores near 0 or 1 |
| Stratification | Yes (within strata) | Residual imbalance within coarse strata |
What's next?
Propensity score methods assume every confounder was measured — the conditional-ignorability assumption. When that assumption is implausible, because treatment assignment is driven by something you can't observe, a different strategy is needed: finding a variable that shifts treatment but has no direct effect on the outcome, which is exactly what instrumental-variable methods exploit.
I build these systems professionally.
Whether it's a RAG pipeline, analytics migration, or AI workflow — let's talk.