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.
- match_stream-0.1.0/CHANGELOG.md +72 -0
- match_stream-0.1.0/CITATION.cff +19 -0
- match_stream-0.1.0/LICENSE.md +21 -0
- match_stream-0.1.0/MANIFEST.in +2 -0
- match_stream-0.1.0/PKG-INFO +227 -0
- match_stream-0.1.0/README.md +177 -0
- match_stream-0.1.0/match_stream/__init__.py +45 -0
- match_stream-0.1.0/match_stream/_cem.py +107 -0
- match_stream-0.1.0/match_stream/_core/__init__.py +25 -0
- match_stream-0.1.0/match_stream/_core/balance.py +174 -0
- match_stream-0.1.0/match_stream/_core/influence.py +222 -0
- match_stream-0.1.0/match_stream/_core/ipw.py +199 -0
- match_stream-0.1.0/match_stream/_core/mestimation.py +278 -0
- match_stream-0.1.0/match_stream/_kdtree.py +194 -0
- match_stream-0.1.0/match_stream/_kernel.py +570 -0
- match_stream-0.1.0/match_stream/_match.py +175 -0
- match_stream-0.1.0/match_stream/_prep.py +297 -0
- match_stream-0.1.0/match_stream/_spec.py +346 -0
- match_stream-0.1.0/match_stream/_threads.py +34 -0
- match_stream-0.1.0/match_stream/api.py +351 -0
- match_stream-0.1.0/match_stream/balance.py +176 -0
- match_stream-0.1.0/match_stream/bandwidth.py +74 -0
- match_stream-0.1.0/match_stream/effects.py +983 -0
- match_stream-0.1.0/match_stream/inference.py +399 -0
- match_stream-0.1.0/match_stream/kernels/__init__.py +0 -0
- match_stream-0.1.0/match_stream/kernels/kernel.py +410 -0
- match_stream-0.1.0/match_stream/kernels/metric.py +98 -0
- match_stream-0.1.0/match_stream/kernels/metric_wor.py +227 -0
- match_stream-0.1.0/match_stream/kernels/nn.py +302 -0
- match_stream-0.1.0/match_stream/kernels/var.py +50 -0
- match_stream-0.1.0/match_stream/matches.py +492 -0
- match_stream-0.1.0/match_stream/profile.py +231 -0
- match_stream-0.1.0/match_stream/py.typed +0 -0
- match_stream-0.1.0/match_stream/simulate.py +51 -0
- match_stream-0.1.0/match_stream.egg-info/PKG-INFO +227 -0
- match_stream-0.1.0/match_stream.egg-info/SOURCES.txt +61 -0
- match_stream-0.1.0/match_stream.egg-info/dependency_links.txt +1 -0
- match_stream-0.1.0/match_stream.egg-info/requires.txt +24 -0
- match_stream-0.1.0/match_stream.egg-info/top_level.txt +1 -0
- match_stream-0.1.0/pyproject.toml +94 -0
- match_stream-0.1.0/setup.cfg +4 -0
- match_stream-0.1.0/tests/test_api.py +150 -0
- match_stream-0.1.0/tests/test_balance.py +65 -0
- match_stream-0.1.0/tests/test_discard.py +86 -0
- match_stream-0.1.0/tests/test_effects.py +144 -0
- match_stream-0.1.0/tests/test_effects_if.py +165 -0
- match_stream-0.1.0/tests/test_kdtree.py +176 -0
- match_stream-0.1.0/tests/test_kernel.py +456 -0
- match_stream-0.1.0/tests/test_kmatch_nn.py +108 -0
- match_stream-0.1.0/tests/test_mahvars.py +87 -0
- match_stream-0.1.0/tests/test_profile.py +72 -0
- match_stream-0.1.0/tests/test_ps_correction.py +200 -0
- match_stream-0.1.0/tests/test_psmatch2.py +206 -0
- match_stream-0.1.0/tests/test_reference_nn.py +190 -0
- match_stream-0.1.0/tests/test_reporting.py +77 -0
- match_stream-0.1.0/tests/test_resources.py +122 -0
- match_stream-0.1.0/tests/test_rounds.py +151 -0
- match_stream-0.1.0/tests/test_several_estimands.py +209 -0
- match_stream-0.1.0/tests/test_several_outcomes.py +155 -0
- match_stream-0.1.0/tests/test_teffects.py +164 -0
- match_stream-0.1.0/tests/test_teffects_lone.py +106 -0
- match_stream-0.1.0/tests/test_var_fallback.py +130 -0
- 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,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
|