match-stream 0.1.0__py3-none-any.whl
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/__init__.py +45 -0
- match_stream/_cem.py +107 -0
- match_stream/_core/__init__.py +25 -0
- match_stream/_core/balance.py +174 -0
- match_stream/_core/influence.py +222 -0
- match_stream/_core/ipw.py +199 -0
- match_stream/_core/mestimation.py +278 -0
- match_stream/_kdtree.py +194 -0
- match_stream/_kernel.py +570 -0
- match_stream/_match.py +175 -0
- match_stream/_prep.py +297 -0
- match_stream/_spec.py +346 -0
- match_stream/_threads.py +34 -0
- match_stream/api.py +351 -0
- match_stream/balance.py +176 -0
- match_stream/bandwidth.py +74 -0
- match_stream/effects.py +983 -0
- match_stream/inference.py +399 -0
- match_stream/kernels/__init__.py +0 -0
- match_stream/kernels/kernel.py +410 -0
- match_stream/kernels/metric.py +98 -0
- match_stream/kernels/metric_wor.py +227 -0
- match_stream/kernels/nn.py +302 -0
- match_stream/kernels/var.py +50 -0
- match_stream/matches.py +492 -0
- match_stream/profile.py +231 -0
- match_stream/py.typed +0 -0
- match_stream/simulate.py +51 -0
- match_stream-0.1.0.dist-info/METADATA +227 -0
- match_stream-0.1.0.dist-info/RECORD +33 -0
- match_stream-0.1.0.dist-info/WHEEL +5 -0
- match_stream-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- match_stream-0.1.0.dist-info/top_level.txt +1 -0
match_stream/__init__.py
ADDED
|
@@ -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"]
|
match_stream/_cem.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Staging for code bound for ``hdfe_stream.core``.
|
|
2
|
+
|
|
3
|
+
Each module here has the API it will have in core and no matching-specific
|
|
4
|
+
names, so that other packages (a streaming staggered-DiD package, for one)
|
|
5
|
+
can use it once it moves. Until hdfe-stream releases it, match-stream imports
|
|
6
|
+
it from here.
|
|
7
|
+
|
|
8
|
+
Modules:
|
|
9
|
+
|
|
10
|
+
- ``influence``: influence-function variances from streamed per-cluster
|
|
11
|
+
sums (one-way and multi-way); `weighted_mean_difference`.
|
|
12
|
+
- ``mestimation``: a fitted binary logit/probit first stage (scores,
|
|
13
|
+
observed information) and the first-stage correction phi + J H^-1 s.
|
|
14
|
+
- ``ipw``: inverse-probability weights (trimming, Hajek scale) and the
|
|
15
|
+
Hajek IPW difference in means with the estimated-score correction.
|
|
16
|
+
- ``balance``: the weighted two-group balance table.
|
|
17
|
+
|
|
18
|
+
All four are now in hdfe-stream's ``hdfe_stream.core`` (unreleased; same
|
|
19
|
+
code, docstrings aside). When that hdfe-stream is released, switch the
|
|
20
|
+
imports, pin it, and delete them here; until then edit both copies
|
|
21
|
+
together.
|
|
22
|
+
|
|
23
|
+
Not here yet: group tallies for ``core.profile.scan_source`` (they extend a
|
|
24
|
+
function that lives in hdfe-stream and serve only ``profile=True``).
|
|
25
|
+
"""
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""A weighted two-group balance table, out of core. Bound for
|
|
2
|
+
``hdfe_stream.core.balance``.
|
|
3
|
+
|
|
4
|
+
For each term (a numeric column or expression), the weighted means of two
|
|
5
|
+
groups (1 and 0), the standardized mean difference, the variance ratio, and
|
|
6
|
+
the mean and maximum gap between the groups' weighted empirical CDFs. Any
|
|
7
|
+
weighting works: matching weights, inverse-probability weights, entropy
|
|
8
|
+
balancing, or none (weight 1). Everything is streamed sums and one sort per
|
|
9
|
+
term (for the eCDF), so the data are never held in memory.
|
|
10
|
+
|
|
11
|
+
Conventions (MatchIt's, which cobalt shares; match-stream checks them
|
|
12
|
+
against MatchIt 4.8.2):
|
|
13
|
+
|
|
14
|
+
- weighted variance: weights normalized to sum to 1 within the group,
|
|
15
|
+
sum w (x - m)^2 / (1 - sum w^2); for a 0/1 term, m (1 - m);
|
|
16
|
+
- the standardized mean difference is (m1 - m0) / scale, with `scale` the
|
|
17
|
+
term's standard deviation as the caller defines it (MatchIt: the focal
|
|
18
|
+
group's, before weighting; cobalt's default for the ATE: the pooled
|
|
19
|
+
one), else computed here from group 1, group 0 or both pooled (the
|
|
20
|
+
square root of the mean of the two variances); differences below
|
|
21
|
+
sqrt(eps) are reported unscaled, as MatchIt;
|
|
22
|
+
- the eCDF gaps are evaluated at each distinct value of the term among all
|
|
23
|
+
rows given (a row with weight 0 contributes its value but no mass), each
|
|
24
|
+
group's weights summing to 1; for a 0/1 term both equal |m1 - m0|.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import polars as pl
|
|
33
|
+
|
|
34
|
+
_G, _W = "__bal_g", "__bal_w"
|
|
35
|
+
EPS_DIFF = np.sqrt(np.finfo(float).eps)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _prepare(lf, group, weight, terms):
|
|
39
|
+
lf = lf.lazy()
|
|
40
|
+
g = pl.col(group).cast(pl.Boolean) if isinstance(group, str) else group.cast(pl.Boolean)
|
|
41
|
+
if weight is None:
|
|
42
|
+
w = pl.lit(1.0)
|
|
43
|
+
else:
|
|
44
|
+
w = pl.col(weight) if isinstance(weight, str) else weight
|
|
45
|
+
names = [t if isinstance(t, str) else t.meta.output_name() for t in terms]
|
|
46
|
+
exprs = [(pl.col(t) if isinstance(t, str) else t).cast(pl.Float64).alias(f"__bal_x{i}")
|
|
47
|
+
for i, t in enumerate(terms)]
|
|
48
|
+
return lf.select(g.alias(_G), w.cast(pl.Float64).alias(_W), *exprs), names
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def group_moments(lf, n_terms: int) -> dict:
|
|
52
|
+
"""Per group (1, 0) and term i: sum w, sum w^2, sum w x, sum w x^2, and
|
|
53
|
+
whether the term only takes 0 and 1 (over all rows given)."""
|
|
54
|
+
aggs = []
|
|
55
|
+
for arm, cond in (("1", pl.col(_G)), ("0", ~pl.col(_G))):
|
|
56
|
+
w = pl.col(_W).filter(cond)
|
|
57
|
+
aggs += [w.sum().alias(f"sw{arm}"), (w * w).sum().alias(f"sww{arm}")]
|
|
58
|
+
for i in range(n_terms):
|
|
59
|
+
xv = pl.col(f"__bal_x{i}").filter(cond)
|
|
60
|
+
aggs += [(w * xv).sum().alias(f"sx{arm}_{i}"),
|
|
61
|
+
(w * xv * xv).sum().alias(f"sxx{arm}_{i}")]
|
|
62
|
+
for i in range(n_terms):
|
|
63
|
+
aggs.append(pl.col(f"__bal_x{i}").is_in([0.0, 1.0]).all().alias(f"bin_{i}"))
|
|
64
|
+
return lf.select(aggs).collect(engine="streaming").row(0, named=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mean_var(r: dict, arm: str, i: int, binary: bool) -> tuple[float, float]:
|
|
68
|
+
"""The weighted mean and variance of term i in group `arm` ("1"/"0")."""
|
|
69
|
+
sw, sww = r[f"sw{arm}"], r[f"sww{arm}"]
|
|
70
|
+
if not sw:
|
|
71
|
+
return np.nan, np.nan
|
|
72
|
+
m = r[f"sx{arm}_{i}"] / sw
|
|
73
|
+
if binary:
|
|
74
|
+
return m, m * (1 - m)
|
|
75
|
+
denom = 1 - sww / sw**2
|
|
76
|
+
var = (r[f"sxx{arm}_{i}"] / sw - m * m) / denom if denom > 0 else np.nan
|
|
77
|
+
return m, var
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ecdf_gaps(lf, i: int) -> tuple[float, float]:
|
|
81
|
+
"""Mean and maximum |F_1(v) - F_0(v)| over the distinct values v of term i."""
|
|
82
|
+
norm = pl.col(_W) / pl.col(_W).sum().over(_G)
|
|
83
|
+
signed = pl.when(pl.col(_G)).then(norm).otherwise(-norm)
|
|
84
|
+
d = (lf.select(pl.col(f"__bal_x{i}").alias("x"), signed.alias("s"))
|
|
85
|
+
.sort("x")
|
|
86
|
+
.with_columns(pl.col("s").cum_sum().abs().alias("gap"))
|
|
87
|
+
.filter(pl.col("x") != pl.col("x").shift(-1).fill_null(np.inf))
|
|
88
|
+
.select(pl.col("gap").mean().alias("mean"), pl.col("gap").max().alias("max"))
|
|
89
|
+
.collect(engine="streaming"))
|
|
90
|
+
return float(d["mean"][0]), float(d["max"][0])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class GroupSizes:
|
|
95
|
+
"""The rows of groups 1 and 0 (`n1`, `n0`) and their Kish effective
|
|
96
|
+
sample sizes (`ess1`, `ess0`): (sum w)^2 / sum w^2, the number of
|
|
97
|
+
equally weighted rows that would give a mean the same variance; equal
|
|
98
|
+
to the rows without weights, 0 for a group with no weight."""
|
|
99
|
+
|
|
100
|
+
n1: int
|
|
101
|
+
n0: int
|
|
102
|
+
ess1: float
|
|
103
|
+
ess0: float
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def effective_sizes(lf: pl.LazyFrame | pl.DataFrame, group, weight=None) -> GroupSizes:
|
|
107
|
+
"""The rows and Kish effective sample sizes of the two groups in `lf`,
|
|
108
|
+
`group` and `weight` as in `weighted_balance` (a column or expression;
|
|
109
|
+
no weight: 1). One streaming pass."""
|
|
110
|
+
lf, _ = _prepare(lf, group, weight, [])
|
|
111
|
+
aggs = []
|
|
112
|
+
for arm, cond in (("1", pl.col(_G)), ("0", ~pl.col(_G))):
|
|
113
|
+
w = pl.col(_W).filter(cond)
|
|
114
|
+
aggs += [w.len().alias(f"n{arm}"), w.sum().alias(f"sw{arm}"),
|
|
115
|
+
(w * w).sum().alias(f"sww{arm}")]
|
|
116
|
+
r = lf.select(aggs).collect(engine="streaming").row(0, named=True)
|
|
117
|
+
|
|
118
|
+
def ess(arm):
|
|
119
|
+
return r[f"sw{arm}"] ** 2 / r[f"sww{arm}"] if r[f"sww{arm}"] else 0.0
|
|
120
|
+
return GroupSizes(n1=int(r["n1"]), n0=int(r["n0"]), ess1=ess("1"), ess0=ess("0"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def weighted_balance(lf: pl.LazyFrame | pl.DataFrame, group, terms, weight=None, *,
|
|
124
|
+
scale: str | dict = "pooled", ecdf: bool = True) -> pl.DataFrame:
|
|
125
|
+
"""The balance table: one row per term with ``mean1``, ``mean0``,
|
|
126
|
+
``var1``, ``var0``, ``smd``, ``var_ratio`` (var1 / var0; null for a 0/1
|
|
127
|
+
term or two zero variances; infinite for positive var1 and zero var0),
|
|
128
|
+
``ecdf_mean`` and ``ecdf_max`` (|m1 - m0| for a 0/1 term).
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
lf : the rows (a LazyFrame streams)
|
|
133
|
+
group : column or boolean expression, true for group 1
|
|
134
|
+
terms : columns or expressions (aliased) to compare
|
|
135
|
+
weight : column or expression; None weighs every row 1
|
|
136
|
+
scale : "group1", "group0", "pooled", or a dict term -> standard
|
|
137
|
+
deviation (e.g. computed before weighting, as MatchIt)
|
|
138
|
+
ecdf : compute the eCDF gaps (one sort per term)
|
|
139
|
+
"""
|
|
140
|
+
data, names = _prepare(lf, group, weight, list(terms))
|
|
141
|
+
r = group_moments(data, len(names))
|
|
142
|
+
rows = []
|
|
143
|
+
for i, name in enumerate(names):
|
|
144
|
+
binary = bool(r[f"bin_{i}"])
|
|
145
|
+
m1, v1 = mean_var(r, "1", i, binary)
|
|
146
|
+
m0, v0 = mean_var(r, "0", i, binary)
|
|
147
|
+
if isinstance(scale, dict):
|
|
148
|
+
sd = float(scale[name])
|
|
149
|
+
elif scale == "group1":
|
|
150
|
+
sd = np.sqrt(v1)
|
|
151
|
+
elif scale == "group0":
|
|
152
|
+
sd = np.sqrt(v0)
|
|
153
|
+
elif scale == "pooled":
|
|
154
|
+
sd = np.sqrt((v1 + v0) / 2)
|
|
155
|
+
else:
|
|
156
|
+
raise ValueError(f"scale must be 'group1', 'group0', 'pooled' or a dict, "
|
|
157
|
+
f"not {scale!r}")
|
|
158
|
+
diff = m1 - m0
|
|
159
|
+
smd = (
|
|
160
|
+
(np.copysign(np.inf, diff) if sd == 0 else diff / sd)
|
|
161
|
+
if abs(diff) > EPS_DIFF else diff
|
|
162
|
+
)
|
|
163
|
+
if binary:
|
|
164
|
+
ratio, emean, emax = None, abs(diff), abs(diff)
|
|
165
|
+
else:
|
|
166
|
+
ratio = v1 / v0 if v0 != 0 else (np.inf if v1 > 0 else None)
|
|
167
|
+
emean, emax = ecdf_gaps(data, i) if ecdf else (np.nan, np.nan)
|
|
168
|
+
rows.append({"term": name, "mean1": m1, "mean0": m0, "var1": v1, "var0": v0,
|
|
169
|
+
"smd": smd, "var_ratio": ratio, "ecdf_mean": emean, "ecdf_max": emax})
|
|
170
|
+
return pl.DataFrame(rows, schema={"term": pl.String, "mean1": pl.Float64,
|
|
171
|
+
"mean0": pl.Float64, "var1": pl.Float64,
|
|
172
|
+
"var0": pl.Float64, "smd": pl.Float64,
|
|
173
|
+
"var_ratio": pl.Float64, "ecdf_mean": pl.Float64,
|
|
174
|
+
"ecdf_max": pl.Float64})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Influence-function variances from streamed sums, with one-way and
|
|
2
|
+
multi-way clustering. Bound for ``hdfe_stream.core.influence``.
|
|
3
|
+
|
|
4
|
+
An estimator that is (asymptotically) a sum of per-row contributions,
|
|
5
|
+
``theta_hat - theta ~= sum_i phi_i``, has the sandwich variance
|
|
6
|
+
|
|
7
|
+
V = c * sum_g (sum_{i in g} phi_i)(sum_{i in g} phi_i)'
|
|
8
|
+
|
|
9
|
+
over clusters g (each row its own cluster when there is no clustering).
|
|
10
|
+
Only per-cluster sums of phi are needed, so `if_vcov` is one streaming
|
|
11
|
+
``group_by`` per clustering term and holds G x p numbers, never the rows.
|
|
12
|
+
Here phi is the influence function divided by N: it is on the scale of the
|
|
13
|
+
estimate, and V needs no further 1/N^2.
|
|
14
|
+
|
|
15
|
+
Multi-way clustering is Cameron, Gelbach and Miller's (2011)
|
|
16
|
+
inclusion-exclusion: for dimensions A and B, V_A + V_B - V_(A and B), and so
|
|
17
|
+
on for more. The small-sample factor follows hdfe-stream (pyfixest's
|
|
18
|
+
defaults), so that an estimator that is also a least-squares coefficient
|
|
19
|
+
gets the regression's standard error exactly:
|
|
20
|
+
|
|
21
|
+
c = G / (G - 1) * (N - 1) / (N - k)
|
|
22
|
+
|
|
23
|
+
with G the smallest one-way cluster count (G = N without clustering) and k
|
|
24
|
+
the parameters of that regression (`df_k`; None drops the second factor,
|
|
25
|
+
leaving Stata's ``_robust`` convention G / (G - 1)). Every term of a
|
|
26
|
+
multi-way sum takes the same c. A multi-way variance can have a negative
|
|
27
|
+
diagonal; it is returned as is, with a warning, as in hdfe-stream.
|
|
28
|
+
|
|
29
|
+
`weighted_mean_difference` is the first consumer: the difference between
|
|
30
|
+
two groups' weighted means with the weights held fixed (matching weights,
|
|
31
|
+
IPW with a known score, ...).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import itertools
|
|
37
|
+
import warnings
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
import polars as pl
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class IFVariance:
|
|
46
|
+
"""A variance from `if_vcov`: `vcov` (p x p), the rows `n_obs`, the
|
|
47
|
+
one-way cluster counts `n_clusters` (by dimension; empty without
|
|
48
|
+
clustering), the small-sample factor `adj` and the degrees of freedom
|
|
49
|
+
`df` (smallest G minus 1, or N minus 1)."""
|
|
50
|
+
|
|
51
|
+
vcov: np.ndarray
|
|
52
|
+
n_obs: int
|
|
53
|
+
n_clusters: dict
|
|
54
|
+
adj: float
|
|
55
|
+
df: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cluster_dims(cluster) -> list[str]:
|
|
59
|
+
"""``None`` -> [], ``"a"`` -> ["a"], ``"a+b"`` or ``["a", "b"]`` -> ["a", "b"]."""
|
|
60
|
+
if cluster is None:
|
|
61
|
+
return []
|
|
62
|
+
if isinstance(cluster, str):
|
|
63
|
+
dims = [c.strip() for c in cluster.split("+")]
|
|
64
|
+
else:
|
|
65
|
+
dims = [str(c).strip() for c in cluster]
|
|
66
|
+
if not dims or any(not c for c in dims):
|
|
67
|
+
raise ValueError(f"bad cluster specification {cluster!r}")
|
|
68
|
+
if len(set(dims)) != len(dims):
|
|
69
|
+
raise ValueError(f"a cluster dimension is repeated in {cluster!r}")
|
|
70
|
+
return dims
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def if_vcov(lf: pl.LazyFrame | pl.DataFrame, phi: list[str], cluster=None, *,
|
|
74
|
+
df_k: int | None = None) -> IFVariance:
|
|
75
|
+
"""The sandwich variance of an estimator with per-row contributions in
|
|
76
|
+
the columns `phi` of `lf` (see the module docstring), clustered on
|
|
77
|
+
`cluster` (a column, ``"a+b"`` or a list for multi-way; None: by row).
|
|
78
|
+
|
|
79
|
+
Rows with a null in a cluster column are an error (they belong to no
|
|
80
|
+
cluster)."""
|
|
81
|
+
lf = lf.lazy()
|
|
82
|
+
dims = cluster_dims(cluster)
|
|
83
|
+
p = len(phi)
|
|
84
|
+
f = [pl.col(c).cast(pl.Float64) for c in phi]
|
|
85
|
+
if dims:
|
|
86
|
+
nulls = (lf.select([pl.col(d).null_count().alias(d) for d in dims])
|
|
87
|
+
.collect(engine="streaming").row(0, named=True))
|
|
88
|
+
bad = {d: n for d, n in nulls.items() if n}
|
|
89
|
+
if bad:
|
|
90
|
+
raise ValueError(f"cluster columns have missing values: {bad}")
|
|
91
|
+
n = int(lf.select(pl.len()).collect(engine="streaming").item())
|
|
92
|
+
|
|
93
|
+
def meat(by: list[str] | None) -> tuple[np.ndarray, int]:
|
|
94
|
+
if by is None: # each row its own cluster: sum of outer products
|
|
95
|
+
prods = [(f[a] * f[b]).sum().alias(f"{a}_{b}")
|
|
96
|
+
for a in range(p) for b in range(a, p)]
|
|
97
|
+
vals = lf.select(prods).collect(engine="streaming").row(0)
|
|
98
|
+
M = np.zeros((p, p))
|
|
99
|
+
it = iter(vals)
|
|
100
|
+
for a in range(p):
|
|
101
|
+
for b in range(a, p):
|
|
102
|
+
M[a, b] = M[b, a] = next(it)
|
|
103
|
+
return M, n
|
|
104
|
+
s = (lf.group_by(by).agg([x.sum().alias(f"__s{j}") for j, x in enumerate(f)])
|
|
105
|
+
.collect(engine="streaming"))
|
|
106
|
+
A = s.select([f"__s{j}" for j in range(p)]).to_numpy()
|
|
107
|
+
return A.T @ A, s.height
|
|
108
|
+
|
|
109
|
+
V = np.zeros((p, p))
|
|
110
|
+
counts = {}
|
|
111
|
+
if not dims:
|
|
112
|
+
V, G = meat(None)
|
|
113
|
+
else:
|
|
114
|
+
for r in range(1, len(dims) + 1):
|
|
115
|
+
sign = 1.0 if r % 2 else -1.0
|
|
116
|
+
for sub in itertools.combinations(dims, r):
|
|
117
|
+
M, G_sub = meat(list(sub))
|
|
118
|
+
V += sign * M
|
|
119
|
+
if r == 1:
|
|
120
|
+
counts[sub[0]] = G_sub
|
|
121
|
+
G = min(counts.values())
|
|
122
|
+
adj = G / (G - 1) if G > 1 else np.nan
|
|
123
|
+
if df_k is not None:
|
|
124
|
+
adj *= (n - 1) / (n - df_k) if n > df_k else np.nan
|
|
125
|
+
V = adj * (V + V.T) / 2
|
|
126
|
+
if np.any(np.diag(V) < 0):
|
|
127
|
+
warnings.warn("the multi-way clustered variance has negative diagonal entries",
|
|
128
|
+
stacklevel=2)
|
|
129
|
+
return IFVariance(vcov=V, n_obs=n, n_clusters=counts, adj=float(adj), df=G - 1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class WeightedMeanDifference:
|
|
134
|
+
"""`estimate` = mean1 - mean0 (weighted means of the two groups), its
|
|
135
|
+
`variance` (an `IFVariance`), and the group totals of the weights."""
|
|
136
|
+
|
|
137
|
+
estimate: float
|
|
138
|
+
mean1: float
|
|
139
|
+
mean0: float
|
|
140
|
+
weight1: float
|
|
141
|
+
weight0: float
|
|
142
|
+
variance: IFVariance
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def weighted_mean_difference(lf: pl.LazyFrame | pl.DataFrame, y: str, w: str, group: str,
|
|
146
|
+
cluster=None, *, df_k: int | None = 2,
|
|
147
|
+
allow_negative: bool = False) -> WeightedMeanDifference:
|
|
148
|
+
"""The difference of the weighted means of `y` between rows with `group`
|
|
149
|
+
true (1) and false (0), with weights `w` held fixed, and its
|
|
150
|
+
influence-function variance (`if_vcov`, clustered on `cluster`).
|
|
151
|
+
|
|
152
|
+
Each row contributes phi_i = w_i (y_i - mean1) / W1 in group 1 and
|
|
153
|
+
-w_i (y_i - mean0) / W0 in group 0 (W: the group's total weight). This is
|
|
154
|
+
the coefficient on `group` in a weighted least-squares regression of `y`
|
|
155
|
+
on (1, `group`) and, with the default ``df_k=2``, its HC1 / CRV1
|
|
156
|
+
standard error exactly (as hdfe-stream, pyfixest, or R's
|
|
157
|
+
``sandwich::vcovCL``). Rows with zero weight change nothing but N in the
|
|
158
|
+
small-sample factor; leave them out. Negative weights (ridge or
|
|
159
|
+
local-linear matching) are an error unless `allow_negative`; each
|
|
160
|
+
group's total must be positive. Two streaming passes: the group totals,
|
|
161
|
+
then the clustered sums."""
|
|
162
|
+
lf = lf.lazy()
|
|
163
|
+
g = pl.col(group).cast(pl.Boolean)
|
|
164
|
+
W, Y = pl.col(w).cast(pl.Float64), pl.col(y).cast(pl.Float64)
|
|
165
|
+
t = (lf.select((W * g).sum().alias("W1"), (W * ~g).sum().alias("W0"),
|
|
166
|
+
(W * Y * g).sum().alias("S1"), (W * Y * ~g).sum().alias("S0"),
|
|
167
|
+
(W.is_null() | Y.is_null() | pl.col(group).is_null()).sum().alias("nulls"),
|
|
168
|
+
(W < 0).sum().alias("neg"))
|
|
169
|
+
.collect(engine="streaming").row(0, named=True))
|
|
170
|
+
if t["nulls"]:
|
|
171
|
+
raise ValueError(f"{t['nulls']:,} rows have a missing {y!r}, {w!r} or {group!r}")
|
|
172
|
+
if t["neg"] and not allow_negative:
|
|
173
|
+
raise ValueError(f"{t['neg']:,} rows have a negative weight")
|
|
174
|
+
if not (t["W1"] > 0 and t["W0"] > 0):
|
|
175
|
+
raise ValueError("both groups need a positive total weight")
|
|
176
|
+
m1, m0 = t["S1"] / t["W1"], t["S0"] / t["W0"]
|
|
177
|
+
phi = (pl.when(g).then(W * (Y - m1) / t["W1"])
|
|
178
|
+
.otherwise(-W * (Y - m0) / t["W0"]).alias("__phi"))
|
|
179
|
+
var = if_vcov(lf.with_columns(phi), ["__phi"], cluster, df_k=df_k)
|
|
180
|
+
return WeightedMeanDifference(estimate=m1 - m0, mean1=m1, mean0=m0, weight1=t["W1"],
|
|
181
|
+
weight0=t["W0"], variance=var)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def group_mean_phis(lf: pl.LazyFrame | pl.DataFrame, y: str, w: str, group: str,
|
|
185
|
+
wmd: WeightedMeanDifference) -> pl.DataFrame:
|
|
186
|
+
"""The per-row influence contributions behind `weighted_mean_difference`
|
|
187
|
+
(`wmd` is its result for the same columns): ``phi1`` for the mean of group
|
|
188
|
+
1 (w_i (y_i - mean1) / W1 on group-1 rows, else 0) and ``phi0`` for the
|
|
189
|
+
mean of group 0. The difference's contribution is ``phi1 - phi0``."""
|
|
190
|
+
g = pl.col(group).cast(pl.Boolean)
|
|
191
|
+
W, Y = pl.col(w).cast(pl.Float64), pl.col(y).cast(pl.Float64)
|
|
192
|
+
return lf.lazy().select(
|
|
193
|
+
pl.when(g).then(W * (Y - wmd.mean1) / wmd.weight1).otherwise(0.0).alias("phi1"),
|
|
194
|
+
pl.when(~g).then(W * (Y - wmd.mean0) / wmd.weight0).otherwise(0.0).alias("phi0"),
|
|
195
|
+
).collect(engine="streaming")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def joint_if_vcov(parts: list[tuple[pl.DataFrame, dict[int, str]]], p: int, cluster,
|
|
199
|
+
factors: np.ndarray) -> IFVariance:
|
|
200
|
+
"""The joint variance of p estimators that live on different tables.
|
|
201
|
+
|
|
202
|
+
Each part is ``(table, {j: column})``: a table holding the cluster
|
|
203
|
+
columns and, for each estimator j it contributes to, its per-row
|
|
204
|
+
influence contribution in `column`. Estimator j has contributions in one
|
|
205
|
+
part only, so a row of another part adds zero to it; the tables may share
|
|
206
|
+
clusters (the same unit, the same source column value), which is what
|
|
207
|
+
makes the estimators covary. The sums per cluster are those of `if_vcov`
|
|
208
|
+
over the stacked tables, without its small-sample factor; entry (a, b) is
|
|
209
|
+
then scaled by sqrt(factors[a] * factors[b]), where `factors[j]` is the
|
|
210
|
+
factor of estimator j's own fit (`IFVariance.adj`), so that the diagonal
|
|
211
|
+
equals each estimator's own variance exactly. The returned `adj` is 1."""
|
|
212
|
+
dims = cluster_dims(cluster)
|
|
213
|
+
keep = [d for d in dims if d != "__unit"] + (["__unit"] if "__unit" in dims else [])
|
|
214
|
+
frames = []
|
|
215
|
+
for tab, cols in parts:
|
|
216
|
+
frames.append(tab.select(*keep, *(pl.col(c).alias(f"__phi{j}") for j, c in cols.items())))
|
|
217
|
+
stack = pl.concat(frames, how="diagonal").with_columns(
|
|
218
|
+
[pl.col(f"__phi{j}").fill_null(0.0) for j in range(p)])
|
|
219
|
+
raw = if_vcov(stack, [f"__phi{j}" for j in range(p)], dims or None, df_k=None)
|
|
220
|
+
f = np.sqrt(np.asarray(factors, dtype=float))
|
|
221
|
+
return IFVariance(vcov=raw.vcov / raw.adj * np.outer(f, f), n_obs=raw.n_obs,
|
|
222
|
+
n_clusters=raw.n_clusters, adj=1.0, df=raw.df)
|