Matched designs and sensitivity analysis¶
This guide follows the order of work in Rosenbaum's An Introduction to the Theory of Observational Studies (2025). Build the matched sample without looking at outcomes. Check that it resembles an experiment on the covariates you measured. Analyse the outcomes. Then ask how much bias from covariates you did not measure it would take to change the conclusion.
| Step | Function |
|---|---|
| Build matched sets | sp.two_criteria_match |
| Tighten an existing block design | sp.tighten_blocks |
| Compare balance with a randomized experiment | sp.balance_vs_randomization |
| Test, estimate and bound under hidden bias | sp.weighted_rank |
| Plan: which test, how many blocks | sp.weighted_rank_power |
| Strata of unequal size, or no matching at all | sp.rosenbaum_stratified |
| Two control groups | sp.evidence_factors |
| Explain Gamma to a reader | sp.amplify |
1. Matching on two criteria¶
A match has two jobs that pull in different directions. Pairs should be close
on the covariates that predict the outcome. The two groups as a whole should
have the same distribution of every covariate, including ones that are too
sparse or too numerous to pair on. sp.two_criteria_match takes one distance
for each job.
import statspai as sp
m = sp.two_criteria_match(
df, "treated",
ps=["age", "female", "education", "smoker", "bmi"],
ratio=1,
pair=[
{"type": "near_exact", "on": "female", "penalty": 10000},
{"type": "mahalanobis", "on": ["age", "education", "bmi"]},
],
balance=[
{"type": "integer", "on": "smoker", "penalty": 1000},
{"type": "caliper", "on": "pscore", "width": (-1, 0.03), "penalty": 10},
],
)
print(m.summary())
matched = m.matched # long format, one row per person, column `mset`
A term in pair is paid for each treated person and their own control. A term
in balance is paid in a second assignment of the same controls that is free
to ignore the pairing. It is therefore zero whenever the selected controls
can be lined up with the treated group on that covariate, whoever they are
paired with. A large near_exact or integer penalty in balance is fine
balance when it can be met and near-fine balance when it cannot.
The five term types are mahalanobis (rank based), near_exact, integer,
caliper and quantile. A caliper given as a pair such as (-1, 0.03) is
directional. It tolerates controls whose score is above the treated person's
and penalises the other direction, which helps when the treated group sits in
the upper tail of the propensity score.
Put a covariate in pair when it predicts the outcome and you want it equal
within sets. Put it in balance when you only need the groups to agree on it.
Put it in both when exact pairing is not always possible, so that the
mismatches that remain at least cancel.
subset_cost lets the match leave a treated person out at a price. Use it
when a few treated people have no acceptable control, and report how many
were dropped.
2. Is the match as good as an experiment?¶
For each covariate the table gives a two-sample p-value in the matched sample, and the share of 1000 simulated randomized experiments on the same people that were better balanced. A good match beats most of them. The p-values are a yardstick. They are not hypothesis tests, since nobody believes the matched sample was randomized.
3. Outcome analysis and sensitivity¶
With one treated person and J - 1 controls in each of I matched sets:
res = sp.weighted_rank(
"outcome", data=matched, treat="treated", block="mset",
gamma=[1, 1.5, 2, 3], phi="u868", estimates=True,
)
print(res.summary())
res.gamma_critical # the Gamma at which p reaches alpha
gamma=1 is a randomization test. gamma=2 allows two people in the same
set to differ by a factor of two in their odds of treatment because of
something unmeasured, and reports the largest p-value such a bias could
produce. gamma_critical is the number to report. The study is insensitive
to biases smaller than it.
Choosing the weights¶
phi decides how much a matched set counts as a function of how spread out
its outcomes are.
phi |
What it is | When |
|---|---|---|
"wilcoxon" |
every set counts the same | efficient with no bias, the most sensitive to bias |
"quade" |
weight proportional to the rank of the range | a mild improvement |
"u868", "u878" |
little weight on the quiet sets | a sound default |
"u888", "mixed" |
almost all weight on the most dispersed sets | large samples, effects that are not small |
In the book's alcohol and HDL cholesterol example the Wilcoxon analysis is
overturned by a bias of 3.5 and the u878 analysis only by a bias of 6.1,
on the same 406 matched sets.
Do not try them all and report the best. Either choose before looking, or
pass a list. phi=["u868", "u878"] refers the larger of the two deviates to
their joint distribution, which costs very little because the two statistics
are highly correlated.
conditional=True uses only the largest and smallest outcome of each set. It
discards sets in which the treated person is neither, and in exchange can be
insensitive to much larger biases.
sp.weighted_rank_power estimates from the data in hand how these choices
would perform in a study of a given size. Use it to plan the next study, or
on a planning sample that the final analysis will not reuse.
Explaining Gamma¶
Gamma = 2 describes an unmeasured covariate that doubles the odds of
treatment and also determines the outcome. Few covariates are like that.
sp.amplify(2, 3) returns 5. The same analysis therefore covers a covariate
that triples the odds of treatment and multiplies the odds of a higher
outcome by five.
4. Other designs¶
Sets of unequal size, or coarse strata. sp.rosenbaum_stratified takes
any number of treated people and controls in each stratum. With
strata=None it is the sensitivity analysis for an unmatched two-group
comparison.
Matched pairs. sp.rosenbaum_bounds (Wilcoxon, sign, permutational t)
and sp.noether_test (a sign test on the pairs with the largest
differences).
Two control groups. If treated people can be matched to controls of two
different kinds, the comparison of treated with the first control and the
comparison of the second control with the other two are nearly independent
tests of the same hypothesis, open to different biases.
sp.evidence_factors runs both and combines them.
Sets of several sizes. Analyse each size with sp.weighted_rank and
combine the p-values with sp.truncated_product.
A block design that needs one more covariate. sp.tighten_blocks keeps
the treated person of each block and the controls that balance the new
covariate, without rematching from scratch.
For agents¶
- Build the design first and analyse second.
two_criteria_matchandtighten_blocksdo not accept an outcome. weighted_rankneeds sets of equal size. OnMethodIncompatibilityfall back torosenbaum_stratified.- Report
gamma_criticaltogether with the p-value atgamma=1. A small p-value alone says nothing about unmeasured bias. - A list of
phiis the supported way to compare weights. Looping overphiand keeping the smallest p-value is not.