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.
Files changed (63) hide show
  1. cohortmatch-0.1.0/LICENSE +21 -0
  2. cohortmatch-0.1.0/PKG-INFO +592 -0
  3. cohortmatch-0.1.0/README.md +556 -0
  4. cohortmatch-0.1.0/cohortmatch/__init__.py +44 -0
  5. cohortmatch-0.1.0/cohortmatch/api.py +1438 -0
  6. cohortmatch-0.1.0/cohortmatch/data/__init__.py +0 -0
  7. cohortmatch-0.1.0/cohortmatch/data/lalonde.csv +615 -0
  8. cohortmatch-0.1.0/cohortmatch/datasets.py +25 -0
  9. cohortmatch-0.1.0/cohortmatch/datatypes.py +180 -0
  10. cohortmatch-0.1.0/cohortmatch/evalue.py +81 -0
  11. cohortmatch-0.1.0/cohortmatch/exceptions.py +25 -0
  12. cohortmatch-0.1.0/cohortmatch/matching/_utils.py +59 -0
  13. cohortmatch-0.1.0/cohortmatch/matching/covariate_nn.py +187 -0
  14. cohortmatch-0.1.0/cohortmatch/matching/distances.py +192 -0
  15. cohortmatch-0.1.0/cohortmatch/matching/fast_greedy.py +291 -0
  16. cohortmatch-0.1.0/cohortmatch/matching/greedy.py +220 -0
  17. cohortmatch-0.1.0/cohortmatch/matching/optimal.py +146 -0
  18. cohortmatch-0.1.0/cohortmatch/matching/strata.py +142 -0
  19. cohortmatch-0.1.0/cohortmatch/metrics/balance.py +438 -0
  20. cohortmatch-0.1.0/cohortmatch/metrics/propensity.py +497 -0
  21. cohortmatch-0.1.0/cohortmatch/metrics/treatment.py +388 -0
  22. cohortmatch-0.1.0/cohortmatch/metrics/utils.py +70 -0
  23. cohortmatch-0.1.0/cohortmatch/pipeline.py +622 -0
  24. cohortmatch-0.1.0/cohortmatch/py.typed +0 -0
  25. cohortmatch-0.1.0/cohortmatch/risk_set.py +501 -0
  26. cohortmatch-0.1.0/cohortmatch/supplement.py +564 -0
  27. cohortmatch-0.1.0/cohortmatch/utils/__init__.py +1 -0
  28. cohortmatch-0.1.0/cohortmatch/utils/logging.py +79 -0
  29. cohortmatch-0.1.0/cohortmatch/validation.py +292 -0
  30. cohortmatch-0.1.0/cohortmatch/visualization.py +395 -0
  31. cohortmatch-0.1.0/cohortmatch.egg-info/PKG-INFO +592 -0
  32. cohortmatch-0.1.0/cohortmatch.egg-info/SOURCES.txt +61 -0
  33. cohortmatch-0.1.0/cohortmatch.egg-info/dependency_links.txt +1 -0
  34. cohortmatch-0.1.0/cohortmatch.egg-info/requires.txt +15 -0
  35. cohortmatch-0.1.0/cohortmatch.egg-info/top_level.txt +1 -0
  36. cohortmatch-0.1.0/pyproject.toml +177 -0
  37. cohortmatch-0.1.0/setup.cfg +4 -0
  38. cohortmatch-0.1.0/tests/test_api.py +2009 -0
  39. cohortmatch-0.1.0/tests/test_balance.py +379 -0
  40. cohortmatch-0.1.0/tests/test_bugfixes.py +367 -0
  41. cohortmatch-0.1.0/tests/test_calipers.py +184 -0
  42. cohortmatch-0.1.0/tests/test_distances.py +230 -0
  43. cohortmatch-0.1.0/tests/test_docstrings.py +107 -0
  44. cohortmatch-0.1.0/tests/test_engine_parity.py +75 -0
  45. cohortmatch-0.1.0/tests/test_evalue.py +77 -0
  46. cohortmatch-0.1.0/tests/test_fast_greedy.py +376 -0
  47. cohortmatch-0.1.0/tests/test_greedy.py +514 -0
  48. cohortmatch-0.1.0/tests/test_index_preservation.py +331 -0
  49. cohortmatch-0.1.0/tests/test_integration.py +272 -0
  50. cohortmatch-0.1.0/tests/test_lalonde.py +62 -0
  51. cohortmatch-0.1.0/tests/test_matcher.py +700 -0
  52. cohortmatch-0.1.0/tests/test_matcher_participant_ids.py +287 -0
  53. cohortmatch-0.1.0/tests/test_matching_correctness.py +138 -0
  54. cohortmatch-0.1.0/tests/test_matchit_scale.py +98 -0
  55. cohortmatch-0.1.0/tests/test_matchit_validation.py +344 -0
  56. cohortmatch-0.1.0/tests/test_optimal.py +407 -0
  57. cohortmatch-0.1.0/tests/test_participant_ids_edge_cases.py +335 -0
  58. cohortmatch-0.1.0/tests/test_propensity.py +177 -0
  59. cohortmatch-0.1.0/tests/test_readme.py +55 -0
  60. cohortmatch-0.1.0/tests/test_reproducibility.py +76 -0
  61. cohortmatch-0.1.0/tests/test_risk_set.py +251 -0
  62. cohortmatch-0.1.0/tests/test_treatment.py +303 -0
  63. 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
+ [![tests](https://github.com/maschulz/cohortmatch/actions/workflows/tests.yml/badge.svg)](https://github.com/maschulz/cohortmatch/actions/workflows/tests.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/cohortmatch)](https://pypi.org/project/cohortmatch/)
41
+ [![Validated against MatchIt](https://img.shields.io/badge/validated%20against-MatchIt-success)](VALIDATION.md)
42
+ [![Python](https://img.shields.io/badge/python-3.10%E2%80%933.13-blue)](https://github.com/maschulz/cohortmatch/blob/main/pyproject.toml)
43
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
+