match-stream 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. match_stream-0.1.0/CHANGELOG.md +72 -0
  2. match_stream-0.1.0/CITATION.cff +19 -0
  3. match_stream-0.1.0/LICENSE.md +21 -0
  4. match_stream-0.1.0/MANIFEST.in +2 -0
  5. match_stream-0.1.0/PKG-INFO +227 -0
  6. match_stream-0.1.0/README.md +177 -0
  7. match_stream-0.1.0/match_stream/__init__.py +45 -0
  8. match_stream-0.1.0/match_stream/_cem.py +107 -0
  9. match_stream-0.1.0/match_stream/_core/__init__.py +25 -0
  10. match_stream-0.1.0/match_stream/_core/balance.py +174 -0
  11. match_stream-0.1.0/match_stream/_core/influence.py +222 -0
  12. match_stream-0.1.0/match_stream/_core/ipw.py +199 -0
  13. match_stream-0.1.0/match_stream/_core/mestimation.py +278 -0
  14. match_stream-0.1.0/match_stream/_kdtree.py +194 -0
  15. match_stream-0.1.0/match_stream/_kernel.py +570 -0
  16. match_stream-0.1.0/match_stream/_match.py +175 -0
  17. match_stream-0.1.0/match_stream/_prep.py +297 -0
  18. match_stream-0.1.0/match_stream/_spec.py +346 -0
  19. match_stream-0.1.0/match_stream/_threads.py +34 -0
  20. match_stream-0.1.0/match_stream/api.py +351 -0
  21. match_stream-0.1.0/match_stream/balance.py +176 -0
  22. match_stream-0.1.0/match_stream/bandwidth.py +74 -0
  23. match_stream-0.1.0/match_stream/effects.py +983 -0
  24. match_stream-0.1.0/match_stream/inference.py +399 -0
  25. match_stream-0.1.0/match_stream/kernels/__init__.py +0 -0
  26. match_stream-0.1.0/match_stream/kernels/kernel.py +410 -0
  27. match_stream-0.1.0/match_stream/kernels/metric.py +98 -0
  28. match_stream-0.1.0/match_stream/kernels/metric_wor.py +227 -0
  29. match_stream-0.1.0/match_stream/kernels/nn.py +302 -0
  30. match_stream-0.1.0/match_stream/kernels/var.py +50 -0
  31. match_stream-0.1.0/match_stream/matches.py +492 -0
  32. match_stream-0.1.0/match_stream/profile.py +231 -0
  33. match_stream-0.1.0/match_stream/py.typed +0 -0
  34. match_stream-0.1.0/match_stream/simulate.py +51 -0
  35. match_stream-0.1.0/match_stream.egg-info/PKG-INFO +227 -0
  36. match_stream-0.1.0/match_stream.egg-info/SOURCES.txt +61 -0
  37. match_stream-0.1.0/match_stream.egg-info/dependency_links.txt +1 -0
  38. match_stream-0.1.0/match_stream.egg-info/requires.txt +24 -0
  39. match_stream-0.1.0/match_stream.egg-info/top_level.txt +1 -0
  40. match_stream-0.1.0/pyproject.toml +94 -0
  41. match_stream-0.1.0/setup.cfg +4 -0
  42. match_stream-0.1.0/tests/test_api.py +150 -0
  43. match_stream-0.1.0/tests/test_balance.py +65 -0
  44. match_stream-0.1.0/tests/test_discard.py +86 -0
  45. match_stream-0.1.0/tests/test_effects.py +144 -0
  46. match_stream-0.1.0/tests/test_effects_if.py +165 -0
  47. match_stream-0.1.0/tests/test_kdtree.py +176 -0
  48. match_stream-0.1.0/tests/test_kernel.py +456 -0
  49. match_stream-0.1.0/tests/test_kmatch_nn.py +108 -0
  50. match_stream-0.1.0/tests/test_mahvars.py +87 -0
  51. match_stream-0.1.0/tests/test_profile.py +72 -0
  52. match_stream-0.1.0/tests/test_ps_correction.py +200 -0
  53. match_stream-0.1.0/tests/test_psmatch2.py +206 -0
  54. match_stream-0.1.0/tests/test_reference_nn.py +190 -0
  55. match_stream-0.1.0/tests/test_reporting.py +77 -0
  56. match_stream-0.1.0/tests/test_resources.py +122 -0
  57. match_stream-0.1.0/tests/test_rounds.py +151 -0
  58. match_stream-0.1.0/tests/test_several_estimands.py +209 -0
  59. match_stream-0.1.0/tests/test_several_outcomes.py +155 -0
  60. match_stream-0.1.0/tests/test_teffects.py +164 -0
  61. match_stream-0.1.0/tests/test_teffects_lone.py +106 -0
  62. match_stream-0.1.0/tests/test_var_fallback.py +130 -0
  63. match_stream-0.1.0/tests/test_varcalc_metric.py +87 -0
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-06)
4
+
5
+ First release: matching for data too large for memory, with treatment effects
6
+ and the standard errors that go with them.
7
+
8
+ **Three functions, one for each kind of matching.** Each takes only its own
9
+ options and returns its own result, with `matches()`, `weights()`, `counts()`,
10
+ `balance()` and `effect()`.
11
+
12
+ - **`match_nn`: nearest-neighbor matching**, on a propensity score or on the
13
+ covariates themselves.
14
+ - The score is fitted for you (logit or probit, with high-dimensional fixed
15
+ effects absorbed, out of core by hdfe-stream), or it is a column you
16
+ supply. Or match on Mahalanobis, inverse-variance or Euclidean distance.
17
+ - One match or k per treated unit, with or without replacement, ties kept
18
+ or broken, and in any of five orders without replacement.
19
+ - Calipers on the score and on covariates, exact strata, common-support
20
+ trimming, and Mahalanobis distance within a score caliper.
21
+ - The focal units are the treated (the ATT) or the controls (the ATC);
22
+ `effect()` can also match both ways for the ATE.
23
+ - **`match_kernel`: kernel and ridge matching** on the score. Five kernels,
24
+ ridge weights, a default bandwidth from the 1:1 nearest-neighbor distances
25
+ (`PairDistance`) and cross-validated bandwidths (`CrossValidation`), without
26
+ forming pairs.
27
+ - **`match_cem`: coarsened exact matching**, with cells, weights and balance.
28
+
29
+ **Treatment effects with the standard errors the literature uses.**
30
+ `effect()` gives the ATT, ATC and ATE, for one outcome or several in one pass.
31
+
32
+ - Abadie-Imbens standard errors for matching with replacement, with the bias
33
+ adjustment, with heteroskedastic variances (`n_variance_matches=`), and with
34
+ the correction for an estimated propensity score (Abadie and Imbens 2016).
35
+ - Influence-function standard errors, by unit or clustered on the pair, the
36
+ matched unit or your own columns, as Abadie and Spiess (2022).
37
+ - Results follow hdfe-stream's conventions, so they print, tabulate and export
38
+ to pyfixest the same way.
39
+
40
+ **Balance diagnostics.** A table of standardized differences, variance ratios
41
+ and empirical-CDF distances before and after matching, with the sample sizes
42
+ by arm.
43
+
44
+ **Built for large data.**
45
+
46
+ - Only a few narrow columns are held; the rest of the data stays on disk.
47
+ Large data are matched a batch of exact strata at a time
48
+ (`memory="by_stratum"`), with memory bounded by the largest stratum.
49
+ - Matching with replacement and by covariates uses k-d trees, and searches run
50
+ in parallel. On a 16-core machine, 1:1 matching of 100 million units takes
51
+ about one to two minutes (see the benchmarks).
52
+ - `profile=True` estimates the memory and disk a match would need, before
53
+ running it. Threads and memory follow your job's limits (PBS, Slurm,
54
+ containers).
55
+
56
+ **Checked against other software.** On shared reference data the matches and
57
+ weights are the same, unit for unit, as R's MatchIt; the effects and standard
58
+ errors agree with R's Matching and Stata's teffects and kmatch; and the matches
59
+ agree with psmatch2's. `docs/implementations.md` lists where packages differ
60
+ and which option reproduces each.
61
+
62
+ **Documentation and benchmarks.**
63
+
64
+ - A guide to choosing a matching method, runnable examples, a page on how it
65
+ works, a function reference and the conventions compared with other
66
+ packages.
67
+ - Benchmarks against MatchIt and R Matching, pymatchit, fastmatch, bare
68
+ nearest-neighbor searches and Polars, from 10,000 to 100 million units.
69
+ - Memory tests (`pytest -m resource`) check that matching a few narrow columns
70
+ stays under a limit that holding the data would exceed.
71
+
72
+ Requires hdfe-stream 0.9.0 or later.
@@ -0,0 +1,19 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software in your research, please cite it as below."
3
+ type: software
4
+ title: "match-stream"
5
+ abstract: "Out-of-core matching for large data: nearest-neighbor matching with or without replacement, calipers, exact strata, Mahalanobis, kernel, ridge and coarsened exact matching, with Abadie-Imbens inference. Conventions follow R's MatchIt; results are tested against MatchIt and Matching."
6
+ authors:
7
+ - family-names: Tucker
8
+ given-names: Lee
9
+ email: lee.c.tucker@census.gov
10
+ version: 0.1.0
11
+ date-released: 2026-11-06
12
+ license: MIT
13
+ repository-code: "https://github.com/leetucker/match-stream"
14
+ url: "https://github.com/leetucker/match-stream"
15
+ keywords:
16
+ - econometrics
17
+ - matching
18
+ - propensity score
19
+ - treatment effects
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lee Tucker
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,2 @@
1
+ include LICENSE.md README.md CHANGELOG.md CITATION.cff
2
+ include match_stream/py.typed
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: match-stream
3
+ Version: 0.1.0
4
+ Summary: Out-of-core matching: nearest-neighbor (with or without replacement), calipers, exact strata, Mahalanobis, kernel and coarsened exact matching, with Abadie-Imbens inference
5
+ Author: Lee Tucker
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/leetucker/match-stream
8
+ Project-URL: Source, https://github.com/leetucker/match-stream
9
+ Project-URL: Issues, https://github.com/leetucker/match-stream/issues
10
+ Project-URL: Documentation, https://leetucker.github.io/match-stream/
11
+ Project-URL: Changelog, https://github.com/leetucker/match-stream/blob/main/CHANGELOG.md
12
+ Keywords: econometrics,matching,propensity score,nearest neighbor,Mahalanobis,coarsened exact matching,Abadie-Imbens,treatment effects,out-of-core
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
23
+ Classifier: Topic :: Scientific/Engineering
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE.md
28
+ Requires-Dist: hdfe-stream[formula]>=0.9.0
29
+ Requires-Dist: stream-limits>=0.1.1
30
+ Requires-Dist: polars!=1.41.*,>=1.37
31
+ Requires-Dist: numpy>=1.24
32
+ Requires-Dist: numba>=0.59
33
+ Requires-Dist: pyarrow>=14.0
34
+ Requires-Dist: scipy>=1.11
35
+ Requires-Dist: threadpoolctl>=3.1
36
+ Provides-Extra: benchmark
37
+ Requires-Dist: matplotlib>=3.7; extra == "benchmark"
38
+ Requires-Dist: pyarrow>=14; extra == "benchmark"
39
+ Provides-Extra: docs
40
+ Requires-Dist: sphinx>=8.0; extra == "docs"
41
+ Requires-Dist: myst-parser>=4.0; extra == "docs"
42
+ Requires-Dist: furo>=2024.8.6; extra == "docs"
43
+ Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
44
+ Requires-Dist: sphinx-design>=0.6; extra == "docs"
45
+ Provides-Extra: test
46
+ Requires-Dist: pytest>=7.0; extra == "test"
47
+ Requires-Dist: stream-limits[testing]>=0.2.1; extra == "test"
48
+ Requires-Dist: pandas>=2.0; extra == "test"
49
+ Dynamic: license-file
50
+
51
+ # match-stream
52
+
53
+ Matching on data larger than memory. It does nearest-neighbor matching, on
54
+ a propensity score or on the covariates themselves, with or without
55
+ replacement, calipers on the score and on covariates, exact strata, and
56
+ kernel, ridge and coarsened exact matching. It also
57
+ gives balance diagnostics, and treatment effects with Abadie–Imbens or
58
+ clustered standard errors.
59
+
60
+ It reads Parquet in a stream, holds only a few narrow columns while it
61
+ matches, and fits the propensity score out of core, with absorbed fixed
62
+ effects if you want them. Its results are tested against R's MatchIt and
63
+ Matching and Stata's teffects and kmatch (see below).
64
+
65
+ ```python
66
+ from match_stream import match_nn
67
+
68
+ m = match_nn("treat ~ age + tenure + log_wage0", "extract/*.parquet",
69
+ exact=["state", "naics2"], replace=True, caliper=0.2, id="person_id")
70
+ m.balance().summary()
71
+ m.effect("earnings").summary() # the ATT, Abadie-Imbens standard errors
72
+ m.matches().sink_parquet("pairs.parquet")
73
+ ```
74
+
75
+ **Status: version 0.1.0, the first release.** The designs, the inference and
76
+ the documentation are tested against MatchIt, Matching, Stata's teffects, kmatch and
77
+ psmatch2, and the benchmarks are in the documentation (see Performance).
78
+
79
+ ## What it does
80
+
81
+ Three functions: `match_nn` (nearest neighbor, on a score or a multivariate
82
+ distance), `match_kernel` (kernel and ridge) and `match_cem` (coarsened exact
83
+ matching). Each returns the matches and weights; `effect()` and `balance()`
84
+ are methods of the result.
85
+
86
+ **Designs**
87
+ - k:1 nearest neighbor on a propensity score, with or without replacement.
88
+ Without replacement it is greedy in any of five orders (`largest`,
89
+ `smallest`, `closest`, `random`, `data`).
90
+ - Calipers on the score (in standard deviations) and on covariates, and
91
+ exact strata.
92
+ - Mahalanobis, inverse-variance or Euclidean distance on covariates, alone
93
+ or within a score caliper (`mahalanobis_vars=`).
94
+ - Kernel and ridge matching on the score: five kernels, a
95
+ bandwidth rule or cross-validation.
96
+ - Coarsened exact matching.
97
+ - Common support (`discard=`, `reestimate_score=`), ties, a minimum number of
98
+ matches per unit, and one-pass k:1 matching without replacement.
99
+
100
+ **The score**
101
+ - Logit or probit, fitted by hdfe-stream, with absorbed fixed effects
102
+ (`score_model="treat ~ x | firm"`), or a column you supply.
103
+
104
+ **Effects and inference**
105
+ - ATT, ATC and ATE.
106
+ - Abadie–Imbens (2006) standard errors, with or without heteroskedastic
107
+ conditional variances (`n_variance_matches`).
108
+ - The Abadie–Imbens (2011) bias adjustment.
109
+ - The Abadie–Imbens (2016) correction for an estimated score, also with
110
+ absorbed fixed effects.
111
+ - Influence-function standard errors with the matching weights fixed, by unit or clustered one or several ways.
112
+ - Several outcomes in one pass (`effect(["y1", "y2"])`), with their joint
113
+ covariance matrix, for every kind of standard error.
114
+
115
+ **Diagnostics**
116
+ - A balance table (means, standardized differences, variance ratios, eCDF
117
+ gaps) and sample sizes.
118
+
119
+ **Resources**
120
+ - `profile=True` reports the memory, disk and candidate pairs a match would
121
+ need, without matching, and a resource request for a scheduler.
122
+ - `n_threads=` and `max_memory_gb=` respect a job's limits (stream-limits).
123
+ - Large inputs are matched a batch of exact strata at a time
124
+ (`memory="by_stratum"`), with the same matches.
125
+
126
+ **Interface**
127
+ - The matches, weights and subclasses as Polars LazyFrames, ready to write to
128
+ Parquet.
129
+ - Results with hdfe-stream's reporting: `summary()`, `tidy()`, `confint()`,
130
+ `etable`, `summary_json()`.
131
+
132
+ ## Why it is fast
133
+
134
+ - **Windows, not pairs.** Within exact strata and sorted by the score, each
135
+ unit's candidates are a window found by binary search. Nothing forms all
136
+ the pairs.
137
+ - **Cheap skipping.** Matching without replacement skips taken controls
138
+ with path-compressed pointers.
139
+ - **Trees for covariates.** Mahalanobis matching uses k-d trees, and
140
+ candidate lists for matching without replacement and for calipers.
141
+ - **Window sums.** Kernel and ridge weights come from running sums, with no
142
+ pairs formed.
143
+ - **Narrow columns.** Only tens of bytes a unit are held. The score model is
144
+ fitted out of core, and the numba kernels run strata in parallel.
145
+
146
+ ## Validation against other implementations
147
+
148
+ | Implementation | Compared on | Agreement |
149
+ |---|---|---|
150
+ | MatchIt 4.8.2 | nearest neighbor (every order, k:1, calipers), Mahalanobis, `mahalanobis_vars`, common support, CEM, weights, balance | pairs identical, weights and balance to 1e-8 |
151
+ | R Matching 4.10-15 | Abadie–Imbens SEs (`Var.calc`), the bias adjustment | estimates to 1e-10, SEs to 1e-8 |
152
+ | Stata teffects | nnmatch, psmatch with the AI (2016) correction, ipw | to 1e-8 |
153
+ | Stata kmatch 1.1.6 | nearest neighbor, kernel, ridge, bandwidths, influence-function SEs | matches identical, estimates to 1e-12 |
154
+ | MatchIt + sandwich, fixest | clustered influence-function SEs | to 1e-13 |
155
+
156
+ The R and Stata scripts that make these references are in `tests/reference/`.
157
+ `docs/implementations.md` lists the conventions on which these packages
158
+ differ, and which option reproduces each.
159
+
160
+ ## Performance
161
+
162
+ The benchmark suites (`benchmarks/`) compare match-stream with MatchIt and
163
+ Matching (R), pymatchit, fastmatch and Polars' `join_asof` and `group_by`,
164
+ and with bare k-d tree and faiss searches as a floor. They cover
165
+ eight designs and the standard errors, from 10,000 to 100 million units.
166
+ On a 16-vCPU, 32 GB cloud machine, at 10 million units:
167
+
168
+ - match-stream is 6 to 35 times faster than MatchIt where MatchIt finishes,
169
+ with the same matches (estimates agree to about 1e-14). At 100 million
170
+ units MatchIt needs 28 to 30 GB where match-stream needs 8 to 21 GB, and
171
+ in two designs it runs out of memory.
172
+ - With no strata, 1:1 Mahalanobis matching takes 6.1 s, as long as a bare
173
+ SciPy k-d tree search of the same data (6.7 s). MatchIt exceeds 30 minutes.
174
+ - pymatchit and R's Matching are slower still, and mostly do not finish
175
+ at 10 million units or more.
176
+ - Polars' `group_by` (coarsened exact matching) and `join_asof` (1:1 on a
177
+ score) are faster, as they do less.
178
+ - At 100 million units, 1:1 Mahalanobis matching without replacement and
179
+ with calipers needs `memory="by_stratum"` on this machine (187 s, 9.2 GB).
180
+
181
+ The results, figures and caveats are in the
182
+ [benchmarks](https://leetucker.github.io/match-stream/benchmarks/).
183
+
184
+ Small runs, for correctness only, give these results:
185
+
186
+ - MatchIt's estimates agree to about 1e-15 on every design.
187
+ - At 100,000 units, R Matching's Abadie–Imbens standard error (with
188
+ `Var.calc = 1`) took 307 s; match-stream's, the same to 1e-15, took 0.07 s.
189
+
190
+ ## Install
191
+
192
+ ```bash
193
+ pip install match-stream
194
+ ```
195
+
196
+ Python 3.10 or later. It depends on hdfe-stream (with its formula extra),
197
+ stream-limits, Polars, NumPy, numba, SciPy and pyarrow. On Python 3.15,
198
+ pyfixest (which hdfe-stream's formula extra brings in) has no wheels yet: it
199
+ builds from source with a Rust toolchain and
200
+ `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` set; everything else installs from wheels.
201
+
202
+ ## Documentation
203
+
204
+ Build the documentation locally with:
205
+
206
+ ```bash
207
+ pip install -e ".[docs]"
208
+ sphinx-build -W --keep-going -b html docs docs/_build/html
209
+ ```
210
+
211
+ The guide covers:
212
+
213
+ - how it works;
214
+ - the conventions that differ from other matching packages;
215
+ - the resources a match needs;
216
+ - the API reference.
217
+
218
+ `examples/` has runnable scripts, and `benchmarks/` the performance
219
+ comparisons (see `benchmarks/README.md`).
220
+
221
+ A sibling of [hdfe-stream](https://github.com/leetucker/hdfe-stream),
222
+ [logit-stream](https://github.com/leetucker/logit-stream) and
223
+ [dpd-stream](https://github.com/leetucker/dpd-stream).
224
+
225
+ ## License
226
+
227
+ MIT.
@@ -0,0 +1,177 @@
1
+ # match-stream
2
+
3
+ Matching on data larger than memory. It does nearest-neighbor matching, on
4
+ a propensity score or on the covariates themselves, with or without
5
+ replacement, calipers on the score and on covariates, exact strata, and
6
+ kernel, ridge and coarsened exact matching. It also
7
+ gives balance diagnostics, and treatment effects with Abadie–Imbens or
8
+ clustered standard errors.
9
+
10
+ It reads Parquet in a stream, holds only a few narrow columns while it
11
+ matches, and fits the propensity score out of core, with absorbed fixed
12
+ effects if you want them. Its results are tested against R's MatchIt and
13
+ Matching and Stata's teffects and kmatch (see below).
14
+
15
+ ```python
16
+ from match_stream import match_nn
17
+
18
+ m = match_nn("treat ~ age + tenure + log_wage0", "extract/*.parquet",
19
+ exact=["state", "naics2"], replace=True, caliper=0.2, id="person_id")
20
+ m.balance().summary()
21
+ m.effect("earnings").summary() # the ATT, Abadie-Imbens standard errors
22
+ m.matches().sink_parquet("pairs.parquet")
23
+ ```
24
+
25
+ **Status: version 0.1.0, the first release.** The designs, the inference and
26
+ the documentation are tested against MatchIt, Matching, Stata's teffects, kmatch and
27
+ psmatch2, and the benchmarks are in the documentation (see Performance).
28
+
29
+ ## What it does
30
+
31
+ Three functions: `match_nn` (nearest neighbor, on a score or a multivariate
32
+ distance), `match_kernel` (kernel and ridge) and `match_cem` (coarsened exact
33
+ matching). Each returns the matches and weights; `effect()` and `balance()`
34
+ are methods of the result.
35
+
36
+ **Designs**
37
+ - k:1 nearest neighbor on a propensity score, with or without replacement.
38
+ Without replacement it is greedy in any of five orders (`largest`,
39
+ `smallest`, `closest`, `random`, `data`).
40
+ - Calipers on the score (in standard deviations) and on covariates, and
41
+ exact strata.
42
+ - Mahalanobis, inverse-variance or Euclidean distance on covariates, alone
43
+ or within a score caliper (`mahalanobis_vars=`).
44
+ - Kernel and ridge matching on the score: five kernels, a
45
+ bandwidth rule or cross-validation.
46
+ - Coarsened exact matching.
47
+ - Common support (`discard=`, `reestimate_score=`), ties, a minimum number of
48
+ matches per unit, and one-pass k:1 matching without replacement.
49
+
50
+ **The score**
51
+ - Logit or probit, fitted by hdfe-stream, with absorbed fixed effects
52
+ (`score_model="treat ~ x | firm"`), or a column you supply.
53
+
54
+ **Effects and inference**
55
+ - ATT, ATC and ATE.
56
+ - Abadie–Imbens (2006) standard errors, with or without heteroskedastic
57
+ conditional variances (`n_variance_matches`).
58
+ - The Abadie–Imbens (2011) bias adjustment.
59
+ - The Abadie–Imbens (2016) correction for an estimated score, also with
60
+ absorbed fixed effects.
61
+ - Influence-function standard errors with the matching weights fixed, by unit or clustered one or several ways.
62
+ - Several outcomes in one pass (`effect(["y1", "y2"])`), with their joint
63
+ covariance matrix, for every kind of standard error.
64
+
65
+ **Diagnostics**
66
+ - A balance table (means, standardized differences, variance ratios, eCDF
67
+ gaps) and sample sizes.
68
+
69
+ **Resources**
70
+ - `profile=True` reports the memory, disk and candidate pairs a match would
71
+ need, without matching, and a resource request for a scheduler.
72
+ - `n_threads=` and `max_memory_gb=` respect a job's limits (stream-limits).
73
+ - Large inputs are matched a batch of exact strata at a time
74
+ (`memory="by_stratum"`), with the same matches.
75
+
76
+ **Interface**
77
+ - The matches, weights and subclasses as Polars LazyFrames, ready to write to
78
+ Parquet.
79
+ - Results with hdfe-stream's reporting: `summary()`, `tidy()`, `confint()`,
80
+ `etable`, `summary_json()`.
81
+
82
+ ## Why it is fast
83
+
84
+ - **Windows, not pairs.** Within exact strata and sorted by the score, each
85
+ unit's candidates are a window found by binary search. Nothing forms all
86
+ the pairs.
87
+ - **Cheap skipping.** Matching without replacement skips taken controls
88
+ with path-compressed pointers.
89
+ - **Trees for covariates.** Mahalanobis matching uses k-d trees, and
90
+ candidate lists for matching without replacement and for calipers.
91
+ - **Window sums.** Kernel and ridge weights come from running sums, with no
92
+ pairs formed.
93
+ - **Narrow columns.** Only tens of bytes a unit are held. The score model is
94
+ fitted out of core, and the numba kernels run strata in parallel.
95
+
96
+ ## Validation against other implementations
97
+
98
+ | Implementation | Compared on | Agreement |
99
+ |---|---|---|
100
+ | MatchIt 4.8.2 | nearest neighbor (every order, k:1, calipers), Mahalanobis, `mahalanobis_vars`, common support, CEM, weights, balance | pairs identical, weights and balance to 1e-8 |
101
+ | R Matching 4.10-15 | Abadie–Imbens SEs (`Var.calc`), the bias adjustment | estimates to 1e-10, SEs to 1e-8 |
102
+ | Stata teffects | nnmatch, psmatch with the AI (2016) correction, ipw | to 1e-8 |
103
+ | Stata kmatch 1.1.6 | nearest neighbor, kernel, ridge, bandwidths, influence-function SEs | matches identical, estimates to 1e-12 |
104
+ | MatchIt + sandwich, fixest | clustered influence-function SEs | to 1e-13 |
105
+
106
+ The R and Stata scripts that make these references are in `tests/reference/`.
107
+ `docs/implementations.md` lists the conventions on which these packages
108
+ differ, and which option reproduces each.
109
+
110
+ ## Performance
111
+
112
+ The benchmark suites (`benchmarks/`) compare match-stream with MatchIt and
113
+ Matching (R), pymatchit, fastmatch and Polars' `join_asof` and `group_by`,
114
+ and with bare k-d tree and faiss searches as a floor. They cover
115
+ eight designs and the standard errors, from 10,000 to 100 million units.
116
+ On a 16-vCPU, 32 GB cloud machine, at 10 million units:
117
+
118
+ - match-stream is 6 to 35 times faster than MatchIt where MatchIt finishes,
119
+ with the same matches (estimates agree to about 1e-14). At 100 million
120
+ units MatchIt needs 28 to 30 GB where match-stream needs 8 to 21 GB, and
121
+ in two designs it runs out of memory.
122
+ - With no strata, 1:1 Mahalanobis matching takes 6.1 s, as long as a bare
123
+ SciPy k-d tree search of the same data (6.7 s). MatchIt exceeds 30 minutes.
124
+ - pymatchit and R's Matching are slower still, and mostly do not finish
125
+ at 10 million units or more.
126
+ - Polars' `group_by` (coarsened exact matching) and `join_asof` (1:1 on a
127
+ score) are faster, as they do less.
128
+ - At 100 million units, 1:1 Mahalanobis matching without replacement and
129
+ with calipers needs `memory="by_stratum"` on this machine (187 s, 9.2 GB).
130
+
131
+ The results, figures and caveats are in the
132
+ [benchmarks](https://leetucker.github.io/match-stream/benchmarks/).
133
+
134
+ Small runs, for correctness only, give these results:
135
+
136
+ - MatchIt's estimates agree to about 1e-15 on every design.
137
+ - At 100,000 units, R Matching's Abadie–Imbens standard error (with
138
+ `Var.calc = 1`) took 307 s; match-stream's, the same to 1e-15, took 0.07 s.
139
+
140
+ ## Install
141
+
142
+ ```bash
143
+ pip install match-stream
144
+ ```
145
+
146
+ Python 3.10 or later. It depends on hdfe-stream (with its formula extra),
147
+ stream-limits, Polars, NumPy, numba, SciPy and pyarrow. On Python 3.15,
148
+ pyfixest (which hdfe-stream's formula extra brings in) has no wheels yet: it
149
+ builds from source with a Rust toolchain and
150
+ `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` set; everything else installs from wheels.
151
+
152
+ ## Documentation
153
+
154
+ Build the documentation locally with:
155
+
156
+ ```bash
157
+ pip install -e ".[docs]"
158
+ sphinx-build -W --keep-going -b html docs docs/_build/html
159
+ ```
160
+
161
+ The guide covers:
162
+
163
+ - how it works;
164
+ - the conventions that differ from other matching packages;
165
+ - the resources a match needs;
166
+ - the API reference.
167
+
168
+ `examples/` has runnable scripts, and `benchmarks/` the performance
169
+ comparisons (see `benchmarks/README.md`).
170
+
171
+ A sibling of [hdfe-stream](https://github.com/leetucker/hdfe-stream),
172
+ [logit-stream](https://github.com/leetucker/logit-stream) and
173
+ [dpd-stream](https://github.com/leetucker/dpd-stream).
174
+
175
+ ## License
176
+
177
+ MIT.
@@ -0,0 +1,45 @@
1
+ """match-stream: matching on data larger than memory.
2
+
3
+ Nearest-neighbor matching with or without replacement (on a score or a
4
+ multivariate distance), calipers on the score and on covariates, exact
5
+ strata, kernel and ridge matching, coarsened exact matching, balance
6
+ diagnostics, and treatment effects with Abadie-Imbens or influence-function
7
+ standard errors. Conventions follow R's MatchIt. See
8
+ https://leetucker.github.io/match-stream/.
9
+
10
+ Layout
11
+ api.py match_nn(), match_kernel(), match_cem()
12
+ bandwidth.py PairDistance, CrossValidation (kernel bandwidth rules)
13
+ _spec.py argument parsing and checks -> Spec
14
+ _prep.py pass 0: the score model (feglm_stream), the narrow columns
15
+ _match.py nearest neighbor: sort, order, run the kernel, both memory paths
16
+ _kernel.py kernel and ridge matching: bandwidths, weights
17
+ _cem.py coarsened exact matching (streaming group-by)
18
+ kernels/ numba: nn.py (score), metric.py (Mahalanobis etc.),
19
+ kernel.py (window moments), var.py (conditional variances)
20
+ matches.py MatchSet, CEMSet, KernelSet: pairs, weights, counts
21
+ balance.py BalanceTable: MatchIt's summary(m, un = TRUE)
22
+ effects.py effect(): estimates, AI and influence-function variances; MatchResult
23
+ simulate.py simulate_matching()
24
+ _core/ code bound for hdfe_stream.core (influence functions, IPW,
25
+ the first-stage correction, the weighted balance table)
26
+ """
27
+
28
+ from importlib.metadata import PackageNotFoundError
29
+ from importlib.metadata import version as _version
30
+
31
+ from .api import match_cem, match_kernel, match_nn
32
+ from .balance import BalanceTable
33
+ from .bandwidth import CrossValidation, PairDistance
34
+ from .effects import MatchResult
35
+ from .matches import CEMSet, KernelSet, MatchSet
36
+ from .simulate import simulate_matching
37
+
38
+ try:
39
+ __version__ = _version("match-stream")
40
+ except PackageNotFoundError: # pragma: no cover
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = ["BalanceTable", "CEMSet", "CrossValidation", "KernelSet", "MatchResult", "MatchSet",
44
+ "PairDistance", "__version__", "match_cem", "match_kernel", "match_nn",
45
+ "simulate_matching"]
@@ -0,0 +1,107 @@
1
+ """Coarsened exact matching (Iacus, King and Porro), as MatchIt's method="cem".
2
+
3
+ CEM is a group-by: each matching covariate is cut into bins, a cell is a
4
+ combination of bins and exact-matching values, and a cell is kept when it has
5
+ both treated and control units. Two streaming passes over the source (the
6
+ covariates' ranges, then the cells' counts) and a third that writes the
7
+ matched units' weights; nothing the size of the source is held.
8
+
9
+ Cutpoints follow MatchIt for a number of bins: equal-width bins over the
10
+ covariate's range among the units used (R's ``cut(x, breaks = k)``; a value on
11
+ a boundary goes to the upper bin here and to the lower one in R, which matters
12
+ only for values exactly on a boundary, of measure zero for continuous data).
13
+ A list gives the cut points themselves (bins are left-closed).
14
+
15
+ Weights (ATT): a treated unit in a matched cell weighs 1; a control weighs
16
+ (treated in its cell / controls in its cell) x (matched controls / matched
17
+ treated), MatchIt's weights. The ATC swaps the roles.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import polars as pl
23
+ from hdfe_stream.core.utils import as_lazy
24
+
25
+ from ._spec import Spec
26
+
27
+ ROW = "__ms_row"
28
+ CELL = "__ms_cell"
29
+
30
+
31
+ def _bins(x: str, cut, lo: float, hi: float) -> pl.Expr:
32
+ if isinstance(cut, int):
33
+ if cut < 1:
34
+ raise ValueError(f"cutpoints for {x!r} must be a positive number of bins")
35
+ span = hi - lo
36
+ if span <= 0:
37
+ return pl.lit(0, pl.Int32)
38
+ return ((pl.col(x) - lo) / span * cut).floor().clip(0, cut - 1).cast(pl.Int32)
39
+ edges = sorted(float(e) for e in cut)
40
+ return pl.col(x).cut(edges, left_closed=True).to_physical().cast(pl.Int32)
41
+
42
+
43
+ def cem(spec: Spec, data, cutpoints) -> tuple[pl.DataFrame, dict, pl.LazyFrame]:
44
+ """The matched units' weights (row, id, treated, weight, subclass = cell)
45
+ and the counts."""
46
+ covs = spec.covariates
47
+ if not covs and not spec.exact:
48
+ raise ValueError("CEM needs matching covariates in the formula or exact=")
49
+ if cutpoints is None:
50
+ cutpoints = {}
51
+ elif isinstance(cutpoints, int):
52
+ cutpoints = {c: cutpoints for c in covs}
53
+ unknown = set(cutpoints) - set(covs)
54
+ if unknown:
55
+ raise ValueError("cutpoints names columns that are not matching covariates: "
56
+ f"{sorted(unknown)}")
57
+ missing_cut = [c for c in covs if c not in cutpoints]
58
+ if missing_cut:
59
+ # MatchIt's default is Sturges' rule through R's hist(); not reproduced
60
+ # exactly, so ask for the number of bins instead of guessing
61
+ raise ValueError(f"give cutpoints for every covariate (a number of bins or the cut "
62
+ f"points): missing {missing_cut}")
63
+
64
+ lf = as_lazy(data).with_row_index(ROW).with_columns(pl.col(ROW).cast(pl.Int64))
65
+ keep = list(dict.fromkeys([ROW, spec.treatment, *spec.exact, *covs,
66
+ *([spec.id] if spec.id is not None else [])]))
67
+ schema = lf.collect_schema()
68
+ missing = [c for c in keep if c not in schema]
69
+ if missing:
70
+ raise ValueError(f"columns not in the data: {missing}")
71
+ floats = [c for c in keep if schema[c].is_float()]
72
+ ok = pl.all_horizontal([pl.col(c).is_not_null() for c in keep]
73
+ + [pl.col(c).is_not_nan() for c in floats])
74
+ proj = lf.select(keep)
75
+ n_source = proj.select(pl.len()).collect(engine="streaming").item()
76
+ proj = proj.filter(ok).with_columns(pl.col(spec.treatment).cast(pl.Boolean))
77
+ rng = proj.select([pl.col(c).min().alias(f"lo:{c}") for c in covs]
78
+ + [pl.col(c).max().alias(f"hi:{c}") for c in covs]
79
+ + [pl.len().alias("n")]).collect(engine="streaming").row(0, named=True)
80
+
81
+ bins = [_bins(c, cutpoints[c], rng[f"lo:{c}"], rng[f"hi:{c}"]).alias(f"__b:{c}") for c in covs]
82
+ keys = [*spec.exact, *[f"__b:{c}" for c in covs]]
83
+ coarse = proj.with_columns(bins)
84
+ t = pl.col(spec.treatment)
85
+ cells = (coarse.group_by(keys)
86
+ .agg(t.sum().cast(pl.Int64).alias("n_t"), (~t).sum().cast(pl.Int64).alias("n_c"))
87
+ .filter((pl.col("n_t") > 0) & (pl.col("n_c") > 0))
88
+ .collect(engine="streaming")
89
+ .sort(keys).with_row_index(CELL, offset=1)
90
+ .with_columns(pl.col(CELL).cast(pl.Int64)))
91
+ mt, mc = int(cells["n_t"].sum()), int(cells["n_c"].sum())
92
+ focal_t = spec.estimand != "atc"
93
+ focal_n, other_n = ("n_t", "n_c") if focal_t else ("n_c", "n_t")
94
+ m_focal, m_other = (mt, mc) if focal_t else (mc, mt)
95
+ is_focal = t if focal_t else ~t
96
+ w = (pl.when(is_focal).then(1.0)
97
+ .otherwise(pl.col(focal_n) / pl.col(other_n) * (m_other / max(m_focal, 1))))
98
+ out = (coarse.join(cells.lazy(), on=keys, how="inner")
99
+ .select(pl.col(ROW).alias("row"), *([spec.id] if spec.id is not None else []),
100
+ t.alias("treated"), w.alias("weight"), pl.col(CELL).alias("subclass"))
101
+ .collect(engine="streaming").sort("row"))
102
+ n_t = int(proj.select(t.cast(pl.Int64).sum()).collect(engine="streaming").item())
103
+ counts = {"treated": n_t, "control": int(rng["n"]) - n_t, "matched_treated": mt,
104
+ "matched_control": mc, "unmatched_treated": n_t - mt,
105
+ "unmatched_control": int(rng["n"]) - n_t - mc, "cells": cells.height,
106
+ "dropped_missing": n_source - int(rng["n"])}
107
+ return out, counts, proj