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.
@@ -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)