cohortmatch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cohortmatch-0.1.0/LICENSE +21 -0
- cohortmatch-0.1.0/PKG-INFO +592 -0
- cohortmatch-0.1.0/README.md +556 -0
- cohortmatch-0.1.0/cohortmatch/__init__.py +44 -0
- cohortmatch-0.1.0/cohortmatch/api.py +1438 -0
- cohortmatch-0.1.0/cohortmatch/data/__init__.py +0 -0
- cohortmatch-0.1.0/cohortmatch/data/lalonde.csv +615 -0
- cohortmatch-0.1.0/cohortmatch/datasets.py +25 -0
- cohortmatch-0.1.0/cohortmatch/datatypes.py +180 -0
- cohortmatch-0.1.0/cohortmatch/evalue.py +81 -0
- cohortmatch-0.1.0/cohortmatch/exceptions.py +25 -0
- cohortmatch-0.1.0/cohortmatch/matching/_utils.py +59 -0
- cohortmatch-0.1.0/cohortmatch/matching/covariate_nn.py +187 -0
- cohortmatch-0.1.0/cohortmatch/matching/distances.py +192 -0
- cohortmatch-0.1.0/cohortmatch/matching/fast_greedy.py +291 -0
- cohortmatch-0.1.0/cohortmatch/matching/greedy.py +220 -0
- cohortmatch-0.1.0/cohortmatch/matching/optimal.py +146 -0
- cohortmatch-0.1.0/cohortmatch/matching/strata.py +142 -0
- cohortmatch-0.1.0/cohortmatch/metrics/balance.py +438 -0
- cohortmatch-0.1.0/cohortmatch/metrics/propensity.py +497 -0
- cohortmatch-0.1.0/cohortmatch/metrics/treatment.py +388 -0
- cohortmatch-0.1.0/cohortmatch/metrics/utils.py +70 -0
- cohortmatch-0.1.0/cohortmatch/pipeline.py +622 -0
- cohortmatch-0.1.0/cohortmatch/py.typed +0 -0
- cohortmatch-0.1.0/cohortmatch/risk_set.py +501 -0
- cohortmatch-0.1.0/cohortmatch/supplement.py +564 -0
- cohortmatch-0.1.0/cohortmatch/utils/__init__.py +1 -0
- cohortmatch-0.1.0/cohortmatch/utils/logging.py +79 -0
- cohortmatch-0.1.0/cohortmatch/validation.py +292 -0
- cohortmatch-0.1.0/cohortmatch/visualization.py +395 -0
- cohortmatch-0.1.0/cohortmatch.egg-info/PKG-INFO +592 -0
- cohortmatch-0.1.0/cohortmatch.egg-info/SOURCES.txt +61 -0
- cohortmatch-0.1.0/cohortmatch.egg-info/dependency_links.txt +1 -0
- cohortmatch-0.1.0/cohortmatch.egg-info/requires.txt +15 -0
- cohortmatch-0.1.0/cohortmatch.egg-info/top_level.txt +1 -0
- cohortmatch-0.1.0/pyproject.toml +177 -0
- cohortmatch-0.1.0/setup.cfg +4 -0
- cohortmatch-0.1.0/tests/test_api.py +2009 -0
- cohortmatch-0.1.0/tests/test_balance.py +379 -0
- cohortmatch-0.1.0/tests/test_bugfixes.py +367 -0
- cohortmatch-0.1.0/tests/test_calipers.py +184 -0
- cohortmatch-0.1.0/tests/test_distances.py +230 -0
- cohortmatch-0.1.0/tests/test_docstrings.py +107 -0
- cohortmatch-0.1.0/tests/test_engine_parity.py +75 -0
- cohortmatch-0.1.0/tests/test_evalue.py +77 -0
- cohortmatch-0.1.0/tests/test_fast_greedy.py +376 -0
- cohortmatch-0.1.0/tests/test_greedy.py +514 -0
- cohortmatch-0.1.0/tests/test_index_preservation.py +331 -0
- cohortmatch-0.1.0/tests/test_integration.py +272 -0
- cohortmatch-0.1.0/tests/test_lalonde.py +62 -0
- cohortmatch-0.1.0/tests/test_matcher.py +700 -0
- cohortmatch-0.1.0/tests/test_matcher_participant_ids.py +287 -0
- cohortmatch-0.1.0/tests/test_matching_correctness.py +138 -0
- cohortmatch-0.1.0/tests/test_matchit_scale.py +98 -0
- cohortmatch-0.1.0/tests/test_matchit_validation.py +344 -0
- cohortmatch-0.1.0/tests/test_optimal.py +407 -0
- cohortmatch-0.1.0/tests/test_participant_ids_edge_cases.py +335 -0
- cohortmatch-0.1.0/tests/test_propensity.py +177 -0
- cohortmatch-0.1.0/tests/test_readme.py +55 -0
- cohortmatch-0.1.0/tests/test_reproducibility.py +76 -0
- cohortmatch-0.1.0/tests/test_risk_set.py +251 -0
- cohortmatch-0.1.0/tests/test_treatment.py +303 -0
- cohortmatch-0.1.0/tests/test_utils.py +81 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marc-Andre Schulz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cohortmatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Statistical matching for cohort studies, from small samples to biobank scale
|
|
5
|
+
Author-email: Marc-Andre Schulz <marc.a.schulz@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/maschulz/cohortmatch
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/maschulz/cohortmatch/issues
|
|
9
|
+
Project-URL: Source Code, https://github.com/maschulz/cohortmatch
|
|
10
|
+
Project-URL: Changelog, https://github.com/maschulz/cohortmatch/blob/main/CHANGELOG.md
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.20.0
|
|
23
|
+
Requires-Dist: pandas>=1.3.0
|
|
24
|
+
Requires-Dist: scipy>=1.7.0
|
|
25
|
+
Requires-Dist: scikit-learn>=1.0.0
|
|
26
|
+
Requires-Dist: statsmodels>=0.13.0
|
|
27
|
+
Requires-Dist: tqdm
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=3.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff==0.16.5; extra == "dev"
|
|
32
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
33
|
+
Provides-Extra: viz
|
|
34
|
+
Requires-Dist: matplotlib>=3.5.0; extra == "viz"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# CohortMatch
|
|
38
|
+
|
|
39
|
+
[](https://github.com/maschulz/cohortmatch/actions/workflows/tests.yml)
|
|
40
|
+
[](https://pypi.org/project/cohortmatch/)
|
|
41
|
+
[](VALIDATION.md)
|
|
42
|
+
[](https://github.com/maschulz/cohortmatch/blob/main/pyproject.toml)
|
|
43
|
+
[](https://github.com/maschulz/cohortmatch/blob/main/LICENSE)
|
|
44
|
+
|
|
45
|
+
Statistical matching for cohort studies: nearest-neighbor and optimal matching
|
|
46
|
+
on propensity scores or covariate distances, propensity subclassification,
|
|
47
|
+
coarsened exact matching, and risk-set matching, with calipers, exact
|
|
48
|
+
constraints, balance diagnostics, and treatment effect estimation. Validated
|
|
49
|
+
against R's MatchIt, and handles biobank-scale cohorts (500k rows). I first
|
|
50
|
+
wrote this for in-house use by students in the lab and to support my own research at
|
|
51
|
+
that scale; it grew over time and now seems stable enough to release.
|
|
52
|
+
|
|
53
|
+
**Scope.** cohortmatch constructs and diagnoses matched
|
|
54
|
+
samples at any scale, and estimates the standard effect measures on them
|
|
55
|
+
(risk difference, odds ratio, risk ratio). Everything beyond that (survival
|
|
56
|
+
models, sensitivity analysis, doubly-robust estimators) is a documented
|
|
57
|
+
handoff to statsmodels/lifelines with the matching weights and groups
|
|
58
|
+
attached (see "Effects on the matched sample").
|
|
59
|
+
|
|
60
|
+
## Contents
|
|
61
|
+
|
|
62
|
+
- [Getting started](#getting-started): installation, quick start, which function
|
|
63
|
+
- [What matching estimates](#what-matching-estimates): assumptions, the estimand
|
|
64
|
+
- [Checking and analyzing the match](#checking-and-analyzing-the-match): balance, weights, effects, handoff
|
|
65
|
+
- [Tuning the match](#tuning-the-match): calipers, propensity scores, common support, constraints
|
|
66
|
+
- [Other designs and scale](#other-designs-and-scale): subclassification, CEM, risk-set, large data
|
|
67
|
+
- [Reference](#reference): supplement, validation, parameters, errors, citing
|
|
68
|
+
|
|
69
|
+
## Getting started
|
|
70
|
+
|
|
71
|
+
### Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install "cohortmatch[viz] @ git+https://github.com/maschulz/cohortmatch.git"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Not yet on PyPI. The `viz` extra adds plotting.
|
|
78
|
+
|
|
79
|
+
### Quick start
|
|
80
|
+
|
|
81
|
+
Runnable as-is; `load_lalonde()` ships with the package. Match, then check the
|
|
82
|
+
balance:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from cohortmatch import match
|
|
86
|
+
from cohortmatch.datasets import load_lalonde
|
|
87
|
+
|
|
88
|
+
data = load_lalonde() # 614 units; treatment column "treat", outcome "re78"
|
|
89
|
+
|
|
90
|
+
result = match(data, treatment="treat",
|
|
91
|
+
covariates=["age", "educ", "race", "married", "re74", "re75"])
|
|
92
|
+
|
|
93
|
+
print(result.summary()) # counts, balance, Rubin's rules
|
|
94
|
+
result.matched_data # the matched cohort, original index preserved
|
|
95
|
+
result.balance() # signed SMD per covariate, before and after
|
|
96
|
+
result.pairs # treatment_id, control_id, distance, match_group
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The matched cohort is the output. cohortmatch includes the standard effect
|
|
100
|
+
estimators, or you can take it to statsmodels/lifelines (see "Effects on the
|
|
101
|
+
matched sample"):
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
result.estimate_effects("re78") # weighted effect, cluster-robust SE
|
|
105
|
+
result.supplement("supp.md") # methods and results record for a paper
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Which function do I use?
|
|
109
|
+
|
|
110
|
+
| Design | Function | Estimand | Result shape |
|
|
111
|
+
|---|---|---|---|
|
|
112
|
+
| Nearest / optimal pair matching | `match()` | ATT / ATC | pairs + weights |
|
|
113
|
+
| Propensity subclassification | `subclassify()` | ATT / ATC / **ATE** | strata + weights |
|
|
114
|
+
| Coarsened exact matching | `cem()` | ATT / ATC / **ATE** | strata + weights |
|
|
115
|
+
| Nested case-control (incident disease) | `match_risk_set()` | rate/hazard ratio | matched sets |
|
|
116
|
+
|
|
117
|
+
The **estimand** is which average effect you get: ATT (on the treated units),
|
|
118
|
+
ATC (on the controls), or ATE (on the whole population). A **propensity
|
|
119
|
+
score** is each unit's estimated probability of being treated given its
|
|
120
|
+
covariates; matching units with similar scores makes those covariates
|
|
121
|
+
comparable between the groups.
|
|
122
|
+
|
|
123
|
+
For `match()`, `distance="propensity"` (default) matches on the propensity
|
|
124
|
+
score, the default for confounder control. Use `distance="mahalanobis"`
|
|
125
|
+
to match directly in covariate space (no propensity model; scales via a
|
|
126
|
+
KD-tree). All designs scale to biobank size.
|
|
127
|
+
|
|
128
|
+
By default this estimates propensity scores with logistic regression fit on
|
|
129
|
+
the full sample (deterministic, MatchIt's convention), matches each treated
|
|
130
|
+
unit to its nearest control (ATT), applies no caliper, and computes balance
|
|
131
|
+
statistics.
|
|
132
|
+
|
|
133
|
+
**Data contract:** the treatment column is 0/1; the DataFrame index
|
|
134
|
+
identifies units, must be unique, and has string or integer labels; column
|
|
135
|
+
names are strings; covariates must be complete (no NaN).
|
|
136
|
+
|
|
137
|
+
> **Categoricals:** string covariates are one-hot encoded automatically and
|
|
138
|
+
> appear in balance tables as `var=level` rows. A categorical coded as
|
|
139
|
+
> numbers (e.g. smoking 0/1/2) is otherwise treated as *continuous*. Cast
|
|
140
|
+
> it to `category` dtype first (`df["smoking"] = df["smoking"].astype("category")`).
|
|
141
|
+
|
|
142
|
+
> **Missing data:** cohortmatch does not impute. Handle NaN before matching:
|
|
143
|
+
> complete-case (`df.dropna(subset=covariates)`) or your own imputation. Note
|
|
144
|
+
> that imputing then matching propagates imputation uncertainty into the
|
|
145
|
+
> matched set; multiple imputation with matching inside each imputation is
|
|
146
|
+
> the rigorous route.
|
|
147
|
+
|
|
148
|
+
## What matching estimates
|
|
149
|
+
|
|
150
|
+
### What matching assumes
|
|
151
|
+
|
|
152
|
+
Matching adjusts only for what you match on. The causal reading of any
|
|
153
|
+
effect below requires: (1) no unmeasured confounding, every variable that
|
|
154
|
+
influences both treatment and outcome is in `covariates`; (2) covariates
|
|
155
|
+
measured **before** treatment (matching on post-treatment variables biases
|
|
156
|
+
the estimate, and nothing in the data can reveal this); (3) overlap between
|
|
157
|
+
the groups. Good balance is evidence the *measured* covariates are
|
|
158
|
+
comparable, never evidence for (1) or (2). For sensitivity to unmeasured
|
|
159
|
+
confounding, export `result.pairs` to R's `rbounds`/`sensemakr` (Rosenbaum
|
|
160
|
+
bounds are planned).
|
|
161
|
+
|
|
162
|
+
### The estimand is set by the matching
|
|
163
|
+
|
|
164
|
+
`estimand="att"` (default) anchors matching on the treated units: every treated
|
|
165
|
+
unit is kept if possible, and the result estimates the effect on the treated.
|
|
166
|
+
`estimand="atc"` anchors on the controls. If anchor units cannot be matched
|
|
167
|
+
(caliper, exact constraints, pool exhausted), you get a warning with the count,
|
|
168
|
+
because dropping anchor units changes the population your estimate refers to.
|
|
169
|
+
|
|
170
|
+
There is no silent fallback: with more treated than controls, `estimand="att"`
|
|
171
|
+
still matches from the treated side and warns about the shortfall.
|
|
172
|
+
|
|
173
|
+
## Checking and analyzing the match
|
|
174
|
+
|
|
175
|
+
### Balance
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
result.balance() # signed SMDs and variance ratios, before/after
|
|
179
|
+
result.table1() # group means/SDs with SMDs, the cohort table
|
|
180
|
+
result.rubin_statistics # Rubin's rules: share of covariates with
|
|
181
|
+
# |SMD| < 0.25 and variance ratio in [0.5, 2]
|
|
182
|
+
print(result.summary()) # counts, mean/max |SMD|, Rubin's rules
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A **standardized mean difference (SMD)** is the gap in a covariate's mean
|
|
186
|
+
between the groups measured in standard-deviation units, so it is comparable
|
|
187
|
+
across covariates; |SMD| < 0.1 is the usual target for good balance.
|
|
188
|
+
cohortmatch's SMDs are signed and standardized by the anchor group's SD in the
|
|
189
|
+
original sample, with the same denominator before and after matching, so the
|
|
190
|
+
two numbers are directly comparable (cobalt's convention). Post-matching
|
|
191
|
+
statistics use the matching weights.
|
|
192
|
+
|
|
193
|
+
Notes on encoded categoricals and the default propensity model: one-hot
|
|
194
|
+
dummies enter Euclidean/Mahalanobis distances, where a k-level categorical
|
|
195
|
+
contributes k columns and rare levels get large standardized leverage;
|
|
196
|
+
prefer `exact=` for categoricals you want strictly controlled. The default
|
|
197
|
+
propensity model is L2-regularized logistic regression (scores are shrunk
|
|
198
|
+
relative to an unpenalized GLM); pass your own
|
|
199
|
+
`propensity_model=LogisticRegression(penalty=None)` for MLE scores.
|
|
200
|
+
|
|
201
|
+
With the `viz` extra, the standard diagnostics are one call each:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
result.plot_love_plot() # SMDs before/after, the cobalt-style plot
|
|
205
|
+
result.plot_balance()
|
|
206
|
+
result.plot_propensity() # score overlap before/after
|
|
207
|
+
result.plot_match_distances()
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`match()` does not flag balance quality; `summary()` reports the SMDs and the
|
|
211
|
+
judgment is yours.
|
|
212
|
+
|
|
213
|
+
### Matching weights
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
result.weights # Series indexed by unit; anchors get 1
|
|
217
|
+
result.match_groups # anchor id per unit (None with replacement)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Every unit appears once in `matched_data`; reuse under `replace=True` and
|
|
221
|
+
ratio matching are expressed through the weights, never duplicated rows. Any
|
|
222
|
+
analysis of the matched sample should use them, for example
|
|
223
|
+
`sm.WLS(y, X, weights=result.weights)`.
|
|
224
|
+
|
|
225
|
+
### Treatment effects
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
effects = result.estimate_effects(
|
|
229
|
+
["outcome1", "outcome2"],
|
|
230
|
+
method="mean_difference", # or "regression_adjustment"
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
result.estimate_effects("event", family="logistic") # odds ratio
|
|
236
|
+
result.estimate_effects("event", family="poisson") # risk ratio
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Effects are weighted outcome models with the matching weights: `family=`
|
|
240
|
+
selects a mean/risk difference ("linear", default; for a binary outcome
|
|
241
|
+
this is an absolute difference in probabilities, not a relative effect), an
|
|
242
|
+
odds ratio ("logistic"), or a risk ratio ("poisson"); hazard ratios are a
|
|
243
|
+
five-line recipe (see "Effects on the matched sample"). The `measure`
|
|
244
|
+
column records what the effect is. Standard errors are cluster-robust on match
|
|
245
|
+
groups (matching without replacement) or heteroskedasticity-robust otherwise
|
|
246
|
+
(HC3 for the linear model, HC0 for the GLM); the `se_type` column records
|
|
247
|
+
which, and cohortmatch warns when there are too few match groups for reliable
|
|
248
|
+
cluster-robust inference. All standard errors assume errors independent across
|
|
249
|
+
match groups; spatially or network-correlated outcomes need external
|
|
250
|
+
correction. The estimand is inherited from the matching design; there is no
|
|
251
|
+
way to relabel an ATT matched sample as ATE after the fact.
|
|
252
|
+
|
|
253
|
+
`method="regression_adjustment"` adds the covariates to the outcome model and
|
|
254
|
+
reports the treatment coefficient; that equals the target estimand only if the
|
|
255
|
+
treatment effect does not vary with the covariates. When unsure, use the
|
|
256
|
+
default `mean_difference`, which targets the matched estimand directly.
|
|
257
|
+
|
|
258
|
+
### Effects on the matched sample: the handoff
|
|
259
|
+
|
|
260
|
+
Anything beyond the built-in estimators is a few lines with the weights and
|
|
261
|
+
match groups the result carries:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
# hazard ratio: weighted Cox with robust errors clustered on match groups
|
|
265
|
+
from lifelines import CoxPHFitter
|
|
266
|
+
df = result.matched_data[["follow_up", "event", "treated"]].copy()
|
|
267
|
+
df["w"] = result.weights
|
|
268
|
+
df["g"] = result.match_groups
|
|
269
|
+
CoxPHFitter().fit(df, "follow_up", "event",
|
|
270
|
+
weights_col="w", cluster_col="g", robust=True)
|
|
271
|
+
|
|
272
|
+
# anything statsmodels: weighted design, cluster-robust covariance.
|
|
273
|
+
# Align weights and groups to the matched_data row order first, statsmodels
|
|
274
|
+
# consumes them positionally, so pass numpy arrays in the right order.
|
|
275
|
+
import statsmodels.formula.api as smf
|
|
276
|
+
md = result.matched_data
|
|
277
|
+
fit = smf.wls("outcome ~ treated + age", data=md,
|
|
278
|
+
weights=result.weights.reindex(md.index).to_numpy()).fit(
|
|
279
|
+
cov_type="cluster",
|
|
280
|
+
cov_kwds={"groups": result.match_groups.reindex(md.index).to_numpy()})
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
For sensitivity to unmeasured confounding, cohortmatch includes the E-value
|
|
284
|
+
(VanderWeele & Ding 2017), the minimum confounder strength on the risk-ratio
|
|
285
|
+
scale needed to explain the effect away:
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
from cohortmatch import e_value
|
|
289
|
+
row = result.estimate_effects("event", family="poisson").iloc[0]
|
|
290
|
+
e_value(row["effect"], row["ci_lower"], row["ci_upper"], measure="risk_ratio")
|
|
291
|
+
# {"e_value": ..., "e_value_ci": ...}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Odds and hazard ratios are converted via the standard approximations
|
|
295
|
+
(`rare_outcome=True` uses them directly). For Rosenbaum bounds, export
|
|
296
|
+
`result.pairs` to R's `rbounds`.
|
|
297
|
+
|
|
298
|
+
cohortmatch is silent by default. `cohortmatch.configure_logging()` turns on
|
|
299
|
+
progress output, including progress bars for long matching runs.
|
|
300
|
+
|
|
301
|
+
## Tuning the match
|
|
302
|
+
|
|
303
|
+
### Calipers
|
|
304
|
+
|
|
305
|
+
A **caliper** is the largest distance two units may be apart and still be
|
|
306
|
+
matched; a pair farther apart is left unmatched. No caliper is applied unless
|
|
307
|
+
you ask for one, and on lopsided pools that default can be a bias trap: on the
|
|
308
|
+
classic Lalonde data, 1:1 matching
|
|
309
|
+
without a caliper retains all 185 treated but leaves a maximum |SMD| of
|
|
310
|
+
1.03 and halves the effect estimate, silently. Check `summary()` before
|
|
311
|
+
believing any effect; `caliper="auto"` is the standard remedy.
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
# the standard choice: 0.2 x SD of the logit propensity over the full sample
|
|
315
|
+
# (MatchIt's std.caliper convention; differs from Austin 2011's pooled-within SD)
|
|
316
|
+
match(data, treatment="treated", covariates=covs, caliper="auto")
|
|
317
|
+
|
|
318
|
+
# same rule, different width
|
|
319
|
+
match(data, treatment="treated", covariates=covs, caliper=0.1)
|
|
320
|
+
|
|
321
|
+
# raw units instead of standardized (here: max difference in probability)
|
|
322
|
+
match(data, treatment="treated", covariates=covs, caliper=0.05, std_caliper=False)
|
|
323
|
+
|
|
324
|
+
# Mahalanobis matching within a propensity caliper (Rubin & Thomas)
|
|
325
|
+
match(data, treatment="treated", covariates=covs,
|
|
326
|
+
distance="mahalanobis", caliper="auto")
|
|
327
|
+
|
|
328
|
+
# caliper on the matching distance itself
|
|
329
|
+
match(data, treatment="treated", covariates=covs,
|
|
330
|
+
distance="mahalanobis", caliper=4.0, caliper_metric="mahalanobis")
|
|
331
|
+
|
|
332
|
+
# per-variable calipers, raw units: age within 3 years, BMI within 2
|
|
333
|
+
match(data, treatment="treated", covariates=covs,
|
|
334
|
+
caliper="auto", covariate_calipers={"age": 3.0, "bmi": 2.0})
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Numeric propensity calipers are standardized (multiples of the SD of the logit
|
|
338
|
+
propensity score) unless `std_caliper=False`; Mahalanobis and Euclidean
|
|
339
|
+
calipers are always in raw distance units.
|
|
340
|
+
|
|
341
|
+
### Propensity scores
|
|
342
|
+
|
|
343
|
+
When scores are needed and none are supplied, cohortmatch fits L2-regularized
|
|
344
|
+
logistic regression on the full sample, so the default is deterministic (no
|
|
345
|
+
seed needed). Pass `cv=k` to cross-fit instead, scoring each unit with a model
|
|
346
|
+
that did not see it (useful mainly for flexible `propensity_model`s that can
|
|
347
|
+
overfit). No calibration is applied.
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
# any sklearn classifier; it is cloned, your object is not touched
|
|
351
|
+
from sklearn.ensemble import GradientBoostingClassifier
|
|
352
|
+
match(data, treatment="treated", covariates=covs,
|
|
353
|
+
propensity_model=GradientBoostingClassifier())
|
|
354
|
+
|
|
355
|
+
# cross-fit the scores over 5 folds (set random_state for reproducibility)
|
|
356
|
+
match(data, treatment="treated", covariates=covs, cv=5, random_state=0)
|
|
357
|
+
|
|
358
|
+
# precomputed scores: a column name, Series, or array
|
|
359
|
+
match(data, treatment="treated", covariates=covs, propensity_scores="ps")
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`result.propensity_scores` returns the scores as a Series aligned to your
|
|
363
|
+
data's index; `result.propensity_model` a fitted pipeline usable on raw
|
|
364
|
+
covariates; `result.propensity_metrics` the AUC (cross-validated when `cv` is
|
|
365
|
+
set, in-sample otherwise) and overlap diagnostics.
|
|
366
|
+
|
|
367
|
+
### Common support
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
result = match(data, treatment="treated", covariates=covs, discard="treated")
|
|
371
|
+
result.discarded # ids dropped before matching, with a warning
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Drops units whose propensity score falls outside the other group's range
|
|
375
|
+
before matching ("treated", "control", or "both"). `result.original_data`
|
|
376
|
+
and the pre-matching balance always describe the full input sample.
|
|
377
|
+
|
|
378
|
+
### Other constraints
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
match(data, treatment="treated", covariates=covs,
|
|
382
|
+
method="optimal", # global optimum instead of nearest-neighbor
|
|
383
|
+
distance="mahalanobis",
|
|
384
|
+
ratio=2, # 1:2 matching (two controls per anchor)
|
|
385
|
+
exact="sex", # or a list of columns
|
|
386
|
+
random_state=42)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`replace=True` allows controls to be reused across matches (`"nearest"` only).
|
|
390
|
+
|
|
391
|
+
## Other designs and scale
|
|
392
|
+
|
|
393
|
+
### Stratum designs: subclassify() and cem()
|
|
394
|
+
|
|
395
|
+
Stratum designs are their own entry points: they express the design through
|
|
396
|
+
weights instead of pairs, accept different arguments than pair matching, and
|
|
397
|
+
support `estimand="ate"`:
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
from cohortmatch import subclassify, cem
|
|
401
|
+
|
|
402
|
+
# propensity-score subclassification
|
|
403
|
+
result = subclassify(data, treatment="treated", covariates=covs,
|
|
404
|
+
n_subclasses=6, estimand="ate")
|
|
405
|
+
|
|
406
|
+
# coarsened exact matching: bin, cross, keep cells with both groups
|
|
407
|
+
result = cem(data, treatment="treated", covariates=covs,
|
|
408
|
+
coarsening={"age": 5}, exact="sex")
|
|
409
|
+
|
|
410
|
+
result.strata # stratum per unit
|
|
411
|
+
result.weights # stratum weights: each group reweighted to
|
|
412
|
+
# the target population's stratum distribution
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Balance, `table1()`, and `estimate_effects()` use the weights automatically,
|
|
416
|
+
with HC-robust rather than cluster-robust errors (a handful of strata are too
|
|
417
|
+
few clusters). Subclassification is validated against MatchIt; CEM's default
|
|
418
|
+
binning is Sturges' rule per continuous covariate. Note CEM is a different
|
|
419
|
+
design, not a drop-in sensitivity swap for pair matching: there is no ratio
|
|
420
|
+
or caliper; closeness is expressed through the coarsening.
|
|
421
|
+
|
|
422
|
+
> **Trimmed ATE:** strata (or CEM cells) that contain only one group carry no
|
|
423
|
+
> information and are dropped, with a warning. `estimand="ate"` then estimates
|
|
424
|
+
> the ATE over the *retained overlap population* — the units in mixed strata —
|
|
425
|
+
> not necessarily the whole sample. With sparse cells the two can differ; check
|
|
426
|
+
> the warning and the matched counts.
|
|
427
|
+
|
|
428
|
+
### Risk-set matching (nested case-control)
|
|
429
|
+
|
|
430
|
+
```python
|
|
431
|
+
from cohortmatch import match_risk_set
|
|
432
|
+
|
|
433
|
+
result = match_risk_set(
|
|
434
|
+
cohort, event_time="follow_up_years", event="diagnosed",
|
|
435
|
+
ratio=4, exact="sex", covariate_calipers={"age": 3.0},
|
|
436
|
+
)
|
|
437
|
+
result.sets # set_id, unit_id, case, index_time
|
|
438
|
+
result.balance() # cases vs matched controls (SMDs)
|
|
439
|
+
result.table1() # case/control means and SDs
|
|
440
|
+
result.estimate_odds_ratio(
|
|
441
|
+
"exposure", adjustment_covariates=["smoking"]
|
|
442
|
+
) # conditional logistic; OR estimates the hazard ratio
|
|
443
|
+
result.supplement("ncc_S1.md", exposures="exposure") # paper-ready record
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Controls are drawn from each case's risk set, units still at risk at the
|
|
447
|
+
case's event time (strictly later event times; measure time finely to avoid
|
|
448
|
+
ties), including future cases (incidence-density sampling). Control
|
|
449
|
+
confounders by *restricting* eligibility (`exact`, `covariate_calipers`)
|
|
450
|
+
and sampling at random; that is the design under which the odds ratio
|
|
451
|
+
estimates the hazard ratio. Nearest-neighbor selection (`covariates=`)
|
|
452
|
+
departs from random sampling and can bias the odds ratio toward the null
|
|
453
|
+
(overmatching); a warning says so, and any selection covariates should also
|
|
454
|
+
be adjusted in `estimate_odds_ratio`. Neither MatchIt nor any Python
|
|
455
|
+
package offers this design.
|
|
456
|
+
|
|
457
|
+
### Large datasets
|
|
458
|
+
|
|
459
|
+
`match()` refuses to walk into an out-of-memory crash. With `engine="auto"`
|
|
460
|
+
(default) it computes the dense distance matrix when it fits into
|
|
461
|
+
`memory_limit_gb` (default 4 GB); beyond that it switches to a memory-bounded
|
|
462
|
+
algorithm that draws candidates from a propensity-score window, announced
|
|
463
|
+
with a warning. **The memory-bounded path needs a propensity caliper** to
|
|
464
|
+
define its windows: at biobank scale, plans built only on `exact` and
|
|
465
|
+
`covariate_calipers` will raise with the exact argument to add
|
|
466
|
+
(`caliper="auto"`).
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
# 20k cases against 480k controls: ~2 seconds, <0.5 GB
|
|
470
|
+
result = match(biobank, treatment="case", covariates=covs, caliper="auto")
|
|
471
|
+
|
|
472
|
+
# pin it explicitly (reproducible across data sizes, silences the warning)
|
|
473
|
+
result = match(biobank, treatment="case", covariates=covs,
|
|
474
|
+
caliper="auto", engine="approximate")
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Candidate pools come from binary search over propensity-sorted controls, and
|
|
478
|
+
anchor units match hardest-first. On a 20k x 480k cohort with shared
|
|
479
|
+
propensity scores, cohortmatch and R's MatchIt produce identical matched
|
|
480
|
+
counts, balance, and effect estimates, at a third of the memory (see
|
|
481
|
+
`BENCHMARKS.md`).
|
|
482
|
+
|
|
483
|
+
Covariate distances scale too: Mahalanobis and Euclidean matching use a
|
|
484
|
+
whitened KD-tree (no propensity score, no caliper required) and return the
|
|
485
|
+
*same* pairs as the exact path. A 20k x 480k Mahalanobis match runs in
|
|
486
|
+
~1 s in ~0.4 GB, where R's MatchIt takes ~70 s.
|
|
487
|
+
|
|
488
|
+
`method="optimal"` has no approximate variant; at that scale use
|
|
489
|
+
`method="nearest"`.
|
|
490
|
+
|
|
491
|
+
## Reference
|
|
492
|
+
|
|
493
|
+
### Supplementary material
|
|
494
|
+
|
|
495
|
+
```python
|
|
496
|
+
result.supplement("supplement_S1.md", title="Study S1 matching supplement")
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
One call writes a self-contained Markdown record for a paper's
|
|
500
|
+
supplementary material: the resolved design specification (including the
|
|
501
|
+
numeric caliper actually applied, not just "auto"), software versions and
|
|
502
|
+
seed, the sample flow, the balance table, effect estimates, and a citable
|
|
503
|
+
methods paragraph with references. Plain text, no extra dependencies;
|
|
504
|
+
convert with pandoc if the journal wants PDF or Word.
|
|
505
|
+
|
|
506
|
+
### Validation against MatchIt
|
|
507
|
+
|
|
508
|
+
**[VALIDATION.md](VALIDATION.md)** is a generated report reconciling every
|
|
509
|
+
design and effect estimator against R, row by row. **[BENCHMARKS.md](BENCHMARKS.md)**
|
|
510
|
+
is the generated speed/memory report. Both regenerate from the harness
|
|
511
|
+
(`python validation/report.py`, `python benchmarks/report.py`).
|
|
512
|
+
|
|
513
|
+
The balance conventions and matching designs are validated against R's
|
|
514
|
+
MatchIt/cobalt on the Lalonde data: identical propensity scores go into both
|
|
515
|
+
implementations and the outputs are reconciled, unadjusted SMDs to 1e-6,
|
|
516
|
+
optimal matching by total distance, nearest designs by counts, balance, and
|
|
517
|
+
effect estimates. Runs in CI and locally
|
|
518
|
+
(`pixi run --manifest-path validation/pixi.toml Rscript validation/generate_golden.R`,
|
|
519
|
+
then `pytest tests/test_matchit_validation.py`). The benchmark dataset ships
|
|
520
|
+
with the package:
|
|
521
|
+
|
|
522
|
+
```python
|
|
523
|
+
from cohortmatch.datasets import load_lalonde
|
|
524
|
+
lalonde = load_lalonde()
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### No matches?
|
|
528
|
+
|
|
529
|
+
`match()` raises `NoMatchesError` instead of returning an empty result. Relax
|
|
530
|
+
the caliper, drop exact constraints, or check that the groups overlap.
|
|
531
|
+
|
|
532
|
+
### match() reference
|
|
533
|
+
|
|
534
|
+
| Parameter | Default | Applies to | Meaning |
|
|
535
|
+
|---|---|---|---|
|
|
536
|
+
| `data` | required | all | DataFrame, one row per unit; the index identifies units |
|
|
537
|
+
| `treatment` | required | all | binary treatment column (1/0) |
|
|
538
|
+
| `covariates` | required | all | columns to balance (numeric, no NaN) |
|
|
539
|
+
| `method` | `"nearest"` | all | `"nearest"` or `"optimal"` |
|
|
540
|
+
| `distance` | `"propensity"` | all | `"propensity"`, `"logit"`, `"mahalanobis"`, `"euclidean"` |
|
|
541
|
+
| `estimand` | `"att"` | all | `"att"` or `"atc"`, which group anchors the matching |
|
|
542
|
+
| `caliper` | `None` | all | `None`, `"auto"` (0.2 SD logit-PS), or a number |
|
|
543
|
+
| `caliper_metric` | `"propensity"` | all | metric the caliper applies to |
|
|
544
|
+
| `std_caliper` | `True` | with `caliper` | numeric PS calipers in SD-of-logit-PS units |
|
|
545
|
+
| `covariate_calipers` | `None` | all | per-variable max difference, raw units |
|
|
546
|
+
| `ratio` | `1` | all | controls per anchor unit (integer, 1:k) |
|
|
547
|
+
| `replace` | `False` | nearest | reuse controls across matches |
|
|
548
|
+
| `exact` | `None` | all | column(s) that must match exactly |
|
|
549
|
+
| `propensity_scores` | `None` | all | precomputed scores (column, Series, or array) |
|
|
550
|
+
| `propensity_model` | `None` | all | sklearn classifier to estimate scores |
|
|
551
|
+
| `cv` | `None` | estimated scores | `None` fits on the full sample; an int opts into that many cross-fitting folds |
|
|
552
|
+
| `discard` | `None` | all | common-support discard before matching |
|
|
553
|
+
| `algorithm` | `"auto"` | nearest | `"exact"`, `"approximate"`, or size-dependent |
|
|
554
|
+
| `m_order` | hardest-first | nearest | matching order (`"largest"`, `"smallest"`, `"closest"`, `"random"`, `"data"`) |
|
|
555
|
+
| `covariate_weights` | `None` | euclidean | distance weights |
|
|
556
|
+
| `standardize` | `True` | covariate distances | standardize before distance computation |
|
|
557
|
+
| `random_state` | `None` | all | seed for tie-breaking, `m_order="random"`, cross-fitting |
|
|
558
|
+
| `memory_limit_gb` | `4.0` | `engine="auto"` | dense-matrix budget |
|
|
559
|
+
|
|
560
|
+
`subclassify()` and `cem()` have their own, smaller signatures, see their
|
|
561
|
+
docstrings. Warnings are typed (`IncompleteMatchWarning`,
|
|
562
|
+
`CommonSupportWarning`, `ApproximateMatchWarning`), so they can be filtered
|
|
563
|
+
individually.
|
|
564
|
+
|
|
565
|
+
### Inference caveats
|
|
566
|
+
|
|
567
|
+
The built-in confidence intervals and p-values are for the standard designs
|
|
568
|
+
and come with limits worth knowing:
|
|
569
|
+
|
|
570
|
+
- **They condition on the propensity scores as if known.** When scores are
|
|
571
|
+
estimated (the default), the intervals do not propagate that first-stage
|
|
572
|
+
uncertainty; the true variance can be larger or smaller (Abadie & Imbens
|
|
573
|
+
2016). Supplying externally estimated scores does not change this.
|
|
574
|
+
- **Robust SEs assume independence across match groups.** Cluster-robust
|
|
575
|
+
(without replacement) and HC-robust (replacement, strata) inference is
|
|
576
|
+
unreliable with few clusters — cohortmatch warns below ten — and does not
|
|
577
|
+
cover spatial or network correlation.
|
|
578
|
+
- **`regression_adjustment` returns a conditional coefficient**, which is the
|
|
579
|
+
target estimand only without treatment–covariate interaction.
|
|
580
|
+
|
|
581
|
+
Treat the built-in intervals as a reasonable default, not a substitute for a
|
|
582
|
+
design-specific variance procedure when coverage matters; the handoff above
|
|
583
|
+
exports the weights and groups for external inference.
|
|
584
|
+
|
|
585
|
+
### How to cite
|
|
586
|
+
|
|
587
|
+
If you use cohortmatch in published work, please cite it (see
|
|
588
|
+
`CITATION.cff`):
|
|
589
|
+
|
|
590
|
+
> Schulz, M.-A. (2026). *cohortmatch: statistical matching for cohort
|
|
591
|
+
> studies at scale.* https://github.com/maschulz/cohortmatch
|
|
592
|
+
|