diff-stream 0.1.0.dev0__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.
- diff_stream/__init__.py +52 -0
- diff_stream/_bootstrap_worker.py +48 -0
- diff_stream/_core/__init__.py +5 -0
- diff_stream/_core/contrasts.py +199 -0
- diff_stream/_core/covariate_kernels.py +420 -0
- diff_stream/_core/cs_kernels.py +536 -0
- diff_stream/_core/cs_row_kernels.py +495 -0
- diff_stream/_core/design.py +105 -0
- diff_stream/_core/etwfe_kernels.py +288 -0
- diff_stream/_core/functionals.py +30 -0
- diff_stream/_core/multiplier.py +90 -0
- diff_stream/_core/nuisance.py +255 -0
- diff_stream/_core/partition.py +158 -0
- diff_stream/_core/process.py +61 -0
- diff_stream/_core/resample.py +171 -0
- diff_stream/_core/staging_kernels.py +90 -0
- diff_stream/_core/twostage_kernels.py +342 -0
- diff_stream/_core/weights.py +26 -0
- diff_stream/_core/within.py +81 -0
- diff_stream/_docs.py +100 -0
- diff_stream/_options.py +62 -0
- diff_stream/_present.py +202 -0
- diff_stream/absorbed.py +621 -0
- diff_stream/aggregation.py +225 -0
- diff_stream/api.py +1112 -0
- diff_stream/bacon.py +460 -0
- diff_stream/bacon_controls.py +188 -0
- diff_stream/balance.py +149 -0
- diff_stream/bjs.py +101 -0
- diff_stream/bjs_placebo.py +167 -0
- diff_stream/bootstrap.py +704 -0
- diff_stream/cohort_scores.py +68 -0
- diff_stream/cs.py +197 -0
- diff_stream/cs_unbalanced.py +336 -0
- diff_stream/effective_sample.py +73 -0
- diff_stream/estimators.py +95 -0
- diff_stream/etwfe.py +589 -0
- diff_stream/gardner.py +500 -0
- diff_stream/linear.py +339 -0
- diff_stream/periods.py +72 -0
- diff_stream/pretrend.py +97 -0
- diff_stream/py.typed +0 -0
- diff_stream/reference.py +125 -0
- diff_stream/results.py +521 -0
- diff_stream/simulate.py +80 -0
- diff_stream/stacked.py +405 -0
- diff_stream/store.py +706 -0
- diff_stream/twfe_store.py +181 -0
- diff_stream/twfe_weights.py +552 -0
- diff_stream/twostage_absorbed.py +190 -0
- diff_stream/unbalanced.py +297 -0
- diff_stream-0.1.0.dev0.dist-info/METADATA +192 -0
- diff_stream-0.1.0.dev0.dist-info/RECORD +56 -0
- diff_stream-0.1.0.dev0.dist-info/WHEEL +5 -0
- diff_stream-0.1.0.dev0.dist-info/licenses/LICENSE.md +21 -0
- diff_stream-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Joint covariance of cell scores and cohort-share scores, by cohort.
|
|
2
|
+
|
|
3
|
+
The CS covariance sums, over units i, the outer products of [scores_i, share_i]
|
|
4
|
+
where share_i = anchor_i * (e_{g_i} - p): e_g marks the cells of cohort g and p
|
|
5
|
+
is a constant vector. Two facts make this cheaper than one dense product per
|
|
6
|
+
batch (ClusterScoreAccumulator), with the same sums:
|
|
7
|
+
|
|
8
|
+
- A unit's scores are zero outside the cells its cohort enters (its own cells,
|
|
9
|
+
and those where it is a control), so each cohort's rows add a dense product
|
|
10
|
+
only into those columns. Only never-treated units touch every column.
|
|
11
|
+
- The share terms depend on a unit only through its cohort and anchor, so the
|
|
12
|
+
share and score-share blocks need just the per-cohort sums of anchor**2 and
|
|
13
|
+
of anchor * scores.
|
|
14
|
+
|
|
15
|
+
With many cohorts and periods (hundreds of cells) this removes most of the
|
|
16
|
+
work; with few cells it costs about the same as the dense product.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CohortScoreCovariance:
|
|
23
|
+
"""Accumulate V = sum s s', cross = sum s h', shares = sum h h' over units.
|
|
24
|
+
|
|
25
|
+
``cell_cohorts`` holds each cell's cohort; ``center`` is p (zeros for an
|
|
26
|
+
uncentered indicator share).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, cell_cohorts, center=None):
|
|
30
|
+
self.cell_cohorts = np.asarray(cell_cohorts)
|
|
31
|
+
M = len(self.cell_cohorts)
|
|
32
|
+
self.center = np.zeros(M) if center is None else np.asarray(center, dtype=float)
|
|
33
|
+
self.V = np.zeros((M, M))
|
|
34
|
+
self._weighted = {} # cohort -> sum of anchor * scores (M,)
|
|
35
|
+
self._square = {} # cohort -> sum of anchor**2
|
|
36
|
+
self.n_clusters = 0
|
|
37
|
+
|
|
38
|
+
def add(self, g, scores, anchor=None):
|
|
39
|
+
"""Add one row per unit: cohort labels, cell scores and share anchors."""
|
|
40
|
+
anchor = np.ones(len(g)) if anchor is None else anchor
|
|
41
|
+
order = np.argsort(g, kind="stable")
|
|
42
|
+
labels = g[order]
|
|
43
|
+
starts = np.r_[0, np.flatnonzero(labels[1:] != labels[:-1]) + 1, len(labels)]
|
|
44
|
+
M = self.V.shape[0]
|
|
45
|
+
for a, b in zip(starts[:-1], starts[1:], strict=True):
|
|
46
|
+
rows = order[a:b]
|
|
47
|
+
k = labels[a].item()
|
|
48
|
+
S = scores[rows]
|
|
49
|
+
used = np.flatnonzero(np.any(S != 0, axis=0))
|
|
50
|
+
if len(used) == M:
|
|
51
|
+
self.V += S.T @ S
|
|
52
|
+
elif len(used):
|
|
53
|
+
block = S[:, used]
|
|
54
|
+
self.V[np.ix_(used, used)] += block.T @ block
|
|
55
|
+
w = anchor[rows]
|
|
56
|
+
self._weighted[k] = self._weighted.get(k, 0.0) + w @ S
|
|
57
|
+
self._square[k] = self._square.get(k, 0.0) + float(w @ w)
|
|
58
|
+
self.n_clusters += len(g)
|
|
59
|
+
|
|
60
|
+
def blocks(self):
|
|
61
|
+
"""(V, cross, shares): the blocks of the joint [scores, share] sums."""
|
|
62
|
+
M = len(self.cell_cohorts)
|
|
63
|
+
cross, shares = np.zeros((M, M)), np.zeros((M, M))
|
|
64
|
+
for k, weighted in self._weighted.items():
|
|
65
|
+
h = (self.cell_cohorts == k) - self.center
|
|
66
|
+
cross += np.outer(weighted, h)
|
|
67
|
+
shares += self._square[k] * np.outer(h, h)
|
|
68
|
+
return self.V, cross, shares
|
diff_stream/cs.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""CS comparison populations and earlier-period covariate conventions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ._core import cs_kernels
|
|
8
|
+
from ._core.contrasts import fit_contrasts
|
|
9
|
+
from ._core.nuisance import fit_systems
|
|
10
|
+
from .cohort_scores import CohortScoreCovariance
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Comparison:
|
|
15
|
+
cohort: int
|
|
16
|
+
controls: tuple
|
|
17
|
+
xperiod: int
|
|
18
|
+
cells: list
|
|
19
|
+
targets: np.ndarray
|
|
20
|
+
bases: np.ndarray
|
|
21
|
+
weightperiod: int = 0
|
|
22
|
+
# members[label] marks the cohort and its controls; cohort labels are
|
|
23
|
+
# nonnegative integers below len(members).
|
|
24
|
+
members: np.ndarray = None
|
|
25
|
+
|
|
26
|
+
def weights(self, raw, mask):
|
|
27
|
+
w = raw[3]
|
|
28
|
+
return w[mask, self.weightperiod] if w.ndim == 2 else w[mask]
|
|
29
|
+
|
|
30
|
+
def select(self, raw):
|
|
31
|
+
g, y, x = raw[:3]
|
|
32
|
+
if self.members is None:
|
|
33
|
+
mask = (g == self.cohort) | np.isin(g, self.controls)
|
|
34
|
+
else:
|
|
35
|
+
mask = self.members[g]
|
|
36
|
+
rows = np.flatnonzero(mask)[:, None]
|
|
37
|
+
return (
|
|
38
|
+
mask,
|
|
39
|
+
x[rows[:, 0], self.xperiod, :],
|
|
40
|
+
g[rows[:, 0]] == self.cohort,
|
|
41
|
+
y[rows, self.targets] - y[rows, self.bases],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def __call__(self, raw):
|
|
45
|
+
mask, X, d, Y = self.select(raw)
|
|
46
|
+
return (X, d, Y, self.weights(raw, mask)) if len(raw) == 4 else (X, d, Y)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# The fit makes about ten passes over the staged units. When the decoded batches
|
|
50
|
+
# take at most this share of the memory left they are kept after the first pass.
|
|
51
|
+
RETAIN_SHARE = 0.1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def balanced_bytes(store, width):
|
|
55
|
+
"""Memory of all decoded batches of a balanced panel: outcomes, covariates, weights."""
|
|
56
|
+
per_period = 8 * (1 + width + (0 if store.weights_time_invariant else 1))
|
|
57
|
+
return store.n_units * store.periods * per_period
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RetainedBatches:
|
|
61
|
+
"""Calls to ``make()`` that keep their batches after the first complete pass."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, make, retain):
|
|
64
|
+
self.make, self.retain, self.items = make, retain, None
|
|
65
|
+
|
|
66
|
+
def __call__(self):
|
|
67
|
+
if self.items is not None:
|
|
68
|
+
return iter(self.items)
|
|
69
|
+
return self._first() if self.retain else self.make()
|
|
70
|
+
|
|
71
|
+
def _first(self):
|
|
72
|
+
items = []
|
|
73
|
+
for raw in self.make():
|
|
74
|
+
items.append(raw)
|
|
75
|
+
yield raw
|
|
76
|
+
self.items = items
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fit_cs(
|
|
80
|
+
store,
|
|
81
|
+
groups,
|
|
82
|
+
cells,
|
|
83
|
+
bases,
|
|
84
|
+
controls,
|
|
85
|
+
method,
|
|
86
|
+
trim_level,
|
|
87
|
+
multiplier=None,
|
|
88
|
+
*,
|
|
89
|
+
balance_collector=None,
|
|
90
|
+
):
|
|
91
|
+
"""Fit cached comparison systems, then assemble cross-cell unit covariance."""
|
|
92
|
+
comparisons = {}
|
|
93
|
+
for j, (g, t) in enumerate(cells):
|
|
94
|
+
controls_g = tuple(groups[k] for k in np.flatnonzero(controls[j]))
|
|
95
|
+
tindex = t - store.times[0]
|
|
96
|
+
xb = 0 if store.x_time_invariant else min(tindex, int(bases[j]))
|
|
97
|
+
wp = min(tindex, int(bases[j])) if not store.weights_time_invariant else 0
|
|
98
|
+
key = (g, controls_g, xb, wp)
|
|
99
|
+
if key not in comparisons:
|
|
100
|
+
comparisons[key] = [[], [], []]
|
|
101
|
+
comparisons[key][0].append(j)
|
|
102
|
+
comparisons[key][1].append(tindex)
|
|
103
|
+
comparisons[key][2].append(bases[j])
|
|
104
|
+
size = max(groups) + 1
|
|
105
|
+
|
|
106
|
+
def members(g, c):
|
|
107
|
+
table = np.zeros(size, dtype=bool)
|
|
108
|
+
table[[g, *c]] = True
|
|
109
|
+
return table
|
|
110
|
+
|
|
111
|
+
transforms = [
|
|
112
|
+
Comparison(g, c, xb, ix, np.array(t, dtype=int), np.array(b, dtype=int), wp, members(g, c))
|
|
113
|
+
for (g, c, xb, wp), (ix, t, b) in comparisons.items()
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
names = store.design.names if store.design is not None else ["Intercept"]
|
|
117
|
+
batches = RetainedBatches(
|
|
118
|
+
lambda: store.batches(
|
|
119
|
+
include_x=True, include_weights=True, row_weights=not store.weights_time_invariant
|
|
120
|
+
),
|
|
121
|
+
retain=store.is_balanced
|
|
122
|
+
and balanced_bytes(store, len(names)) <= RETAIN_SHARE * store.budget.headroom_gb() * 1e9,
|
|
123
|
+
)
|
|
124
|
+
M = len(cells)
|
|
125
|
+
fast = (
|
|
126
|
+
cs_kernels.BalancedCS(transforms, M) if cs_kernels.ENABLED and store.is_balanced else None
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
systems, passes = fit_systems(batches, transforms, method, names, fast=fast)
|
|
130
|
+
except ValueError:
|
|
131
|
+
# A nearly collinear design can converge in one summation order and not
|
|
132
|
+
# in another; the array path is the reference.
|
|
133
|
+
if fast is None:
|
|
134
|
+
raise
|
|
135
|
+
systems, passes = fit_systems(batches, transforms, method, names)
|
|
136
|
+
fitted = fit_contrasts(batches, transforms, systems, method, trim_level, fast=fast)
|
|
137
|
+
beta = np.zeros(M)
|
|
138
|
+
for c, f in zip(transforms, fitted, strict=True):
|
|
139
|
+
beta[c.cells] = f.estimate
|
|
140
|
+
cohorts = np.array([g for g, t in cells])
|
|
141
|
+
counts = np.array([store.gw[g] for g, t in cells], dtype=float)
|
|
142
|
+
# Unadjusted sums of [scores, share] outer products: api.py owns the
|
|
143
|
+
# explicit CRV0/CRV1 factor for all covariance blocks.
|
|
144
|
+
accumulator = CohortScoreCovariance(cohorts, counts / store.n_units)
|
|
145
|
+
packed = fast.pack_fitted(fitted, len(names)) if fast is not None else None
|
|
146
|
+
for raw in batches():
|
|
147
|
+
g, _, _, w = raw
|
|
148
|
+
anchor = w[:, 0] if w.ndim == 2 else w
|
|
149
|
+
if fast is not None:
|
|
150
|
+
scores = fast.scores(raw, method, packed, trim_level)
|
|
151
|
+
else:
|
|
152
|
+
scores = np.zeros((len(g), M))
|
|
153
|
+
for c, f in zip(transforms, fitted, strict=True):
|
|
154
|
+
mask, X, d, Y = c.select(raw)
|
|
155
|
+
scores[np.ix_(np.flatnonzero(mask), c.cells)] = f.scores(
|
|
156
|
+
X, d, Y, c.weights(raw, mask)
|
|
157
|
+
)
|
|
158
|
+
accumulator.add(g, scores, anchor)
|
|
159
|
+
if multiplier is not None:
|
|
160
|
+
share = anchor[:, None] * ((g[:, None] == cohorts[None, :]) - counts / store.n_units)
|
|
161
|
+
multiplier.update(scores, share)
|
|
162
|
+
V, cross, share_cov = accumulator.blocks()
|
|
163
|
+
if balance_collector is not None:
|
|
164
|
+
balance_collector.balanced(transforms, fitted, cells, batches)
|
|
165
|
+
metadata = dict(
|
|
166
|
+
est_method=method,
|
|
167
|
+
covariate_names=names,
|
|
168
|
+
covariates_time_invariant=store.x_time_invariant,
|
|
169
|
+
nuisance_systems=len(transforms),
|
|
170
|
+
propensity_models=0 if method == "reg" else len(transforms),
|
|
171
|
+
nuisance_source_passes=passes + 2,
|
|
172
|
+
trim_level=trim_level,
|
|
173
|
+
probability_cap=1 - 1e-6 if method != "reg" else None,
|
|
174
|
+
comparisons=[
|
|
175
|
+
dict(
|
|
176
|
+
cohort=c.cohort,
|
|
177
|
+
controls=list(c.controls),
|
|
178
|
+
covariate_time=int(store.times[c.xperiod]),
|
|
179
|
+
weight_time=int(store.times[c.weightperiod])
|
|
180
|
+
if not store.weights_time_invariant
|
|
181
|
+
else None,
|
|
182
|
+
cells=c.cells,
|
|
183
|
+
**f.diagnostics,
|
|
184
|
+
)
|
|
185
|
+
for c, f in zip(transforms, fitted, strict=True)
|
|
186
|
+
],
|
|
187
|
+
)
|
|
188
|
+
metadata["warnings"] = []
|
|
189
|
+
if any(f.diagnostics["trimmed_controls"] for f in fitted):
|
|
190
|
+
metadata["warnings"].append(
|
|
191
|
+
"Propensity trimming removed control contributions in some comparisons; inspect diagnostics['comparisons']."
|
|
192
|
+
)
|
|
193
|
+
if any(f.diagnostics["capped_probabilities"] for f in fitted):
|
|
194
|
+
metadata["warnings"].append(
|
|
195
|
+
"Some propensity probabilities were capped at 1-1e-6 under the R DRDID convention."
|
|
196
|
+
)
|
|
197
|
+
return beta, counts, V, cross, share_cov, metadata
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""R did-compatible observed-row CS functionals, clustered by original unit.
|
|
2
|
+
|
|
3
|
+
Each date pair uses repeated-cross-section mean functionals on its observed
|
|
4
|
+
rows. Units seen at only one date contribute; scores are folded by unit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from scipy.special import expit
|
|
11
|
+
|
|
12
|
+
from . import cs as cs_module
|
|
13
|
+
from ._core import cs_row_kernels
|
|
14
|
+
from ._core.functionals import NormalizedMeans
|
|
15
|
+
from ._core.nuisance import fit_linear_systems, fit_systems, solve_checked
|
|
16
|
+
from .cohort_scores import CohortScoreCovariance
|
|
17
|
+
from .cs import RetainedBatches, balanced_bytes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RowComparison:
|
|
22
|
+
cohort: int
|
|
23
|
+
controls: tuple
|
|
24
|
+
target: int
|
|
25
|
+
base: int
|
|
26
|
+
|
|
27
|
+
def weights(self, raw, unit, post):
|
|
28
|
+
w = raw[3]
|
|
29
|
+
return w[unit, np.where(post, self.target, self.base)] if w.ndim == 2 else w[unit]
|
|
30
|
+
|
|
31
|
+
def select(self, raw):
|
|
32
|
+
g, y, x = raw[:3]
|
|
33
|
+
mask = (g == self.cohort) | np.isin(g, self.controls)
|
|
34
|
+
dates = np.array([self.base, self.target])
|
|
35
|
+
unit, period = np.nonzero(mask[:, None] & np.isfinite(y[:, dates]))
|
|
36
|
+
ti = dates[period]
|
|
37
|
+
return unit, x[unit, ti], g[unit] == self.cohort, y[unit, ti], period == 1
|
|
38
|
+
|
|
39
|
+
def __call__(self, raw):
|
|
40
|
+
unit, X, d, y, post = self.select(raw)
|
|
41
|
+
return (
|
|
42
|
+
(X, d, y[:, None], self.weights(raw, unit, post))
|
|
43
|
+
if len(raw) == 4
|
|
44
|
+
else (X, d, y[:, None])
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RowFunctional:
|
|
49
|
+
def __init__(self, comparison, method, trim, system, models, width):
|
|
50
|
+
self.c, self.method, self.trim, self.system = comparison, method, trim, system
|
|
51
|
+
# Model order is control pre/post, treated pre/post.
|
|
52
|
+
self.models = models
|
|
53
|
+
if method == "ipw":
|
|
54
|
+
self.signs = np.array([1, -1, -1, 1.0])
|
|
55
|
+
self.value_derivative = np.zeros((4, 0))
|
|
56
|
+
elif method == "reg":
|
|
57
|
+
self.signs = np.array([1, -1, -1.0])
|
|
58
|
+
self.value_derivative = np.array([[0, 0], [0, 0], [-1, 1.0]])
|
|
59
|
+
else:
|
|
60
|
+
self.signs = np.array([1, -1, -1, 1, 1, -1, -1, 1.0])
|
|
61
|
+
self.value_derivative = np.array(
|
|
62
|
+
[
|
|
63
|
+
[0, -1, 0, 0],
|
|
64
|
+
[-1, 0, 0, 0],
|
|
65
|
+
[0, -1, 0, 0],
|
|
66
|
+
[-1, 0, 0, 0],
|
|
67
|
+
[0, -1, 0, 1],
|
|
68
|
+
[0, -1, 0, 1],
|
|
69
|
+
[-1, 0, 1, 0],
|
|
70
|
+
[-1, 0, 1, 0.0],
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
self.means = NormalizedMeans(len(self.signs), width)
|
|
74
|
+
self.H = np.zeros((width, width))
|
|
75
|
+
self.trimmed = 0
|
|
76
|
+
self.capped = 0
|
|
77
|
+
self.stratum_counts = np.zeros(4, dtype=int)
|
|
78
|
+
self.probability_min, self.probability_max = np.inf, -np.inf
|
|
79
|
+
self.control_weight_squares = np.zeros(2)
|
|
80
|
+
self.sample_weight = np.zeros(4)
|
|
81
|
+
self.sample_weight_squares = np.zeros(4)
|
|
82
|
+
|
|
83
|
+
def rows(self, raw):
|
|
84
|
+
unit, X, d, y, post = self.c.select(raw)
|
|
85
|
+
tp, tm = d & post, d & ~post
|
|
86
|
+
cp, cm = ~d & post, ~d & ~post
|
|
87
|
+
p = np.minimum(expit(X @ self.system.beta), 1 - 1e-6) if self.system is not None else None
|
|
88
|
+
odds = (p / (1 - p)) * (p < self.trim) if p is not None else np.ones(len(d))
|
|
89
|
+
if self.method == "ipw":
|
|
90
|
+
weights = np.column_stack([tp, tm, cp * odds, cm * odds])
|
|
91
|
+
values = np.broadcast_to(y[:, None], weights.shape)
|
|
92
|
+
elif self.method == "reg":
|
|
93
|
+
weights = np.column_stack([tp, tm, d]).astype(float)
|
|
94
|
+
values = np.column_stack([y, y, X @ (self.models[1][0] - self.models[0][0])])
|
|
95
|
+
else:
|
|
96
|
+
m = [X @ b for b, _ in self.models]
|
|
97
|
+
r = y - np.where(post, m[1], m[0])
|
|
98
|
+
weights = np.column_stack([tp, tm, cp * odds, cm * odds, d, tp, d, tm])
|
|
99
|
+
values = np.column_stack(
|
|
100
|
+
[r, r, r, r, m[3] - m[1], m[3] - m[1], m[2] - m[0], m[2] - m[0]]
|
|
101
|
+
)
|
|
102
|
+
if len(raw) == 4:
|
|
103
|
+
weights = weights * self.c.weights(raw, unit, post)[:, None]
|
|
104
|
+
return unit, X, d, y, post, p, weights, values
|
|
105
|
+
|
|
106
|
+
def update(self, raw):
|
|
107
|
+
unit, X, d, _, post, p, w, v = self.rows(raw)
|
|
108
|
+
sw = self.c.weights(raw, unit, post) if len(raw) == 4 else np.ones(len(d))
|
|
109
|
+
for j, mask in enumerate((~d & ~post, ~d & post, d & ~post, d & post)):
|
|
110
|
+
self.sample_weight[j] += sw[mask].sum()
|
|
111
|
+
self.sample_weight_squares[j] += sw[mask] @ sw[mask]
|
|
112
|
+
self.means.update(w, v, X)
|
|
113
|
+
self.stratum_counts += [
|
|
114
|
+
np.sum(~d & ~post),
|
|
115
|
+
np.sum(~d & post),
|
|
116
|
+
np.sum(d & ~post),
|
|
117
|
+
np.sum(d & post),
|
|
118
|
+
]
|
|
119
|
+
if p is not None:
|
|
120
|
+
self.H += X.T @ ((sw * p * (1 - p))[:, None] * X)
|
|
121
|
+
self.trimmed += int(((~d) & (p >= self.trim)).sum())
|
|
122
|
+
self.capped += int((expit(X @ self.system.beta) > 1 - 1e-6).sum())
|
|
123
|
+
self.control_weight_squares += (w[:, [3, 2]] ** 2).sum(axis=0)
|
|
124
|
+
if len(p):
|
|
125
|
+
self.probability_min = min(self.probability_min, float(p.min()))
|
|
126
|
+
self.probability_max = max(self.probability_max, float(p.max()))
|
|
127
|
+
|
|
128
|
+
def load(self, acc, j):
|
|
129
|
+
"""Take the sums of the compiled pass (``cs_row_kernels``) for comparison ``j``."""
|
|
130
|
+
n = len(self.signs)
|
|
131
|
+
self.means.weight = acc["weight"][j, :n].copy()
|
|
132
|
+
self.means.value = acc["value"][j, :n].copy()
|
|
133
|
+
self.means.x = acc["xw"][j, :n].copy()
|
|
134
|
+
self.means.xvalue = acc["xv"][j, :n].copy()
|
|
135
|
+
self.stratum_counts = acc["strata"][j].copy()
|
|
136
|
+
self.sample_weight = acc["sw"][j].copy()
|
|
137
|
+
self.sample_weight_squares = acc["sw2"][j].copy()
|
|
138
|
+
if self.system is not None:
|
|
139
|
+
self.H = acc["hess"][j].copy()
|
|
140
|
+
self.trimmed = int(acc["trimmed"][j])
|
|
141
|
+
self.capped = int(acc["capped"][j])
|
|
142
|
+
self.control_weight_squares = acc["cws"][j].copy()
|
|
143
|
+
if acc["pmax"][j] >= acc["pmin"][j]:
|
|
144
|
+
self.probability_min = float(acc["pmin"][j])
|
|
145
|
+
self.probability_max = float(acc["pmax"][j])
|
|
146
|
+
|
|
147
|
+
def finish(self):
|
|
148
|
+
self.means.finish()
|
|
149
|
+
self.estimate = self.means.mean @ self.signs
|
|
150
|
+
J = (self.value_derivative.T * self.signs) @ self.means.mean_x
|
|
151
|
+
self.reg_correction = [
|
|
152
|
+
solve_checked(Q, j, "outcome score correction")
|
|
153
|
+
for (_, Q), j in zip(self.models, J, strict=True)
|
|
154
|
+
]
|
|
155
|
+
if self.system is not None:
|
|
156
|
+
Jps = -self.means.centered_x[2] + self.means.centered_x[3]
|
|
157
|
+
self.ps_correction = solve_checked(self.H, Jps, "propensity score correction")
|
|
158
|
+
|
|
159
|
+
def scores(self, raw):
|
|
160
|
+
unit, X, d, y, post, p, w, v = self.rows(raw)
|
|
161
|
+
phi = self.means.scores(w, v, self.signs)
|
|
162
|
+
sw = self.c.weights(raw, unit, post) if len(raw) == 4 else np.ones(len(d))
|
|
163
|
+
strata = [~d & ~post, ~d & post, d & ~post, d & post]
|
|
164
|
+
for (b, _), correction, mask in zip(
|
|
165
|
+
self.models, self.reg_correction, strata[: len(self.models)], strict=True
|
|
166
|
+
):
|
|
167
|
+
phi += sw * mask * (y - X @ b) * (X @ correction)
|
|
168
|
+
if self.system is not None:
|
|
169
|
+
phi += sw * (d - p) * (X @ self.ps_correction)
|
|
170
|
+
return unit, phi
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def fit_cs_unbalanced(
|
|
174
|
+
store, groups, cells, bases, controls, method, trim, multiplier=None, *, balance_collector=None
|
|
175
|
+
):
|
|
176
|
+
K = len(store.design.names) if store.design is not None else 1
|
|
177
|
+
names = store.design.names if store.design is not None else ["Intercept"]
|
|
178
|
+
comparisons = [
|
|
179
|
+
RowComparison(g, tuple(np.asarray(groups)[c]), t - store.times[0], int(b))
|
|
180
|
+
for (g, t), b, c in zip(cells, bases, controls, strict=True)
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
fast = cs_row_kernels.ENABLED
|
|
184
|
+
retain = (
|
|
185
|
+
fast
|
|
186
|
+
and balanced_bytes(store, K) <= cs_module.RETAIN_SHARE * store.budget.headroom_gb() * 1e9
|
|
187
|
+
)
|
|
188
|
+
batches = RetainedBatches(
|
|
189
|
+
lambda: store.batches(
|
|
190
|
+
include_x=True, include_weights=True, row_weights=not store.weights_time_invariant
|
|
191
|
+
),
|
|
192
|
+
retain=retain,
|
|
193
|
+
)
|
|
194
|
+
size = int(max(np.max(groups), *(c.cohort for c in comparisons))) + 1
|
|
195
|
+
|
|
196
|
+
linear_keys = {}
|
|
197
|
+
model_indices = []
|
|
198
|
+
for c in comparisons:
|
|
199
|
+
keys = [] if method == "ipw" else [(c.controls, c.base), (c.controls, c.target)]
|
|
200
|
+
if method == "dr":
|
|
201
|
+
keys += [((c.cohort,), c.base), ((c.cohort,), c.target)]
|
|
202
|
+
model_indices.append([linear_keys.setdefault(key, len(linear_keys)) for key in keys])
|
|
203
|
+
|
|
204
|
+
def linear_transform(cohorts, ti):
|
|
205
|
+
def select(raw):
|
|
206
|
+
g, y, x, w = raw
|
|
207
|
+
mask = np.isin(g, cohorts) & np.isfinite(y[:, ti])
|
|
208
|
+
return x[mask, ti], y[mask, ti], w[mask, ti] if w.ndim == 2 else w[mask]
|
|
209
|
+
|
|
210
|
+
return select
|
|
211
|
+
|
|
212
|
+
if fast and linear_keys:
|
|
213
|
+
gram, moments = cs_row_kernels.fit_linear_rows(batches, list(linear_keys), size, K)
|
|
214
|
+
models = [
|
|
215
|
+
(solve_checked(Q, q, f"stratified outcome regression (system {j}, {K} columns)"), Q)
|
|
216
|
+
for j, (Q, q) in enumerate(zip(gram, moments, strict=True))
|
|
217
|
+
]
|
|
218
|
+
else:
|
|
219
|
+
models = fit_linear_systems(batches, [linear_transform(*key) for key in linear_keys], K)
|
|
220
|
+
ps_keys, ps_indices, ps_transforms = {}, [], []
|
|
221
|
+
for c in comparisons:
|
|
222
|
+
key = (c.cohort, c.controls, *sorted([c.base, c.target]))
|
|
223
|
+
if key not in ps_keys:
|
|
224
|
+
ps_keys[key] = len(ps_keys)
|
|
225
|
+
ps_transforms.append(c)
|
|
226
|
+
ps_indices.append(ps_keys[key])
|
|
227
|
+
kernels = cs_row_kernels.RowKernels(comparisons, size) if fast else None
|
|
228
|
+
|
|
229
|
+
def propensity_fits(use_kernels):
|
|
230
|
+
return fit_systems(
|
|
231
|
+
batches,
|
|
232
|
+
ps_transforms,
|
|
233
|
+
"ipw",
|
|
234
|
+
names,
|
|
235
|
+
fast=cs_row_kernels.RowKernels(ps_transforms, size) if use_kernels else None,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if method == "reg":
|
|
239
|
+
systems, passes = [], 0
|
|
240
|
+
else:
|
|
241
|
+
try:
|
|
242
|
+
systems, passes = propensity_fits(fast)
|
|
243
|
+
except ValueError:
|
|
244
|
+
# A nearly collinear design can converge in one summation order and
|
|
245
|
+
# not in another; the array path is the reference.
|
|
246
|
+
if not fast:
|
|
247
|
+
raise
|
|
248
|
+
systems, passes = propensity_fits(False)
|
|
249
|
+
fitted = [
|
|
250
|
+
RowFunctional(c, method, trim, systems[ps] if systems else None, [models[k] for k in ix], K)
|
|
251
|
+
for c, ps, ix in zip(comparisons, ps_indices, model_indices, strict=True)
|
|
252
|
+
]
|
|
253
|
+
if kernels is not None:
|
|
254
|
+
params = kernels.parameters(fitted, method, K)
|
|
255
|
+
acc = kernels.empty_moments(K)
|
|
256
|
+
for raw in batches():
|
|
257
|
+
kernels.moments(raw, method, trim, params, acc)
|
|
258
|
+
for j, f in enumerate(fitted):
|
|
259
|
+
f.load(acc, j)
|
|
260
|
+
else:
|
|
261
|
+
for raw in batches():
|
|
262
|
+
for f in fitted:
|
|
263
|
+
f.update(raw)
|
|
264
|
+
for f in fitted:
|
|
265
|
+
f.finish()
|
|
266
|
+
M = len(cells)
|
|
267
|
+
cohorts = np.array([g for g, _ in cells])
|
|
268
|
+
counts = np.array([store.gw[g] for g, _ in cells], float)
|
|
269
|
+
# Unadjusted sums of [scores, share] outer products: api.py owns the
|
|
270
|
+
# explicit CRV0/CRV1 factor for all covariance blocks.
|
|
271
|
+
accumulator = CohortScoreCovariance(cohorts, counts / store.n_units)
|
|
272
|
+
for raw in batches():
|
|
273
|
+
g, y, _, w = raw
|
|
274
|
+
anchor = w.sum(axis=1) / np.isfinite(y).sum(axis=1) if w.ndim == 2 else w
|
|
275
|
+
if kernels is not None:
|
|
276
|
+
scores = kernels.scores(raw, method, trim, params, fitted)
|
|
277
|
+
else:
|
|
278
|
+
scores = np.zeros((len(g), M))
|
|
279
|
+
for j, f in enumerate(fitted):
|
|
280
|
+
unit, phi = f.scores(raw)
|
|
281
|
+
np.add.at(scores[:, j], unit, phi)
|
|
282
|
+
accumulator.add(g, scores, anchor)
|
|
283
|
+
if multiplier is not None:
|
|
284
|
+
share = anchor[:, None] * ((g[:, None] == cohorts) - counts / store.n_units)
|
|
285
|
+
multiplier.update(scores, share)
|
|
286
|
+
V, cross, share_cov = accumulator.blocks()
|
|
287
|
+
if balance_collector is not None:
|
|
288
|
+
balance_collector.unbalanced(comparisons, fitted, batches)
|
|
289
|
+
metadata = dict(
|
|
290
|
+
est_method=method,
|
|
291
|
+
covariate_names=names,
|
|
292
|
+
covariate_convention="observed-date covariates; repeated-cross-section functionals",
|
|
293
|
+
nuisance_systems=len(linear_keys),
|
|
294
|
+
propensity_models=len(systems),
|
|
295
|
+
nuisance_source_passes=passes + 2 + int(method != "ipw"),
|
|
296
|
+
trim_level=trim,
|
|
297
|
+
comparisons=[
|
|
298
|
+
dict(
|
|
299
|
+
cohort=c.cohort,
|
|
300
|
+
time=int(store.times[c.target]),
|
|
301
|
+
base=int(store.times[c.base]),
|
|
302
|
+
trimmed_controls=f.trimmed,
|
|
303
|
+
capped_probabilities=f.capped,
|
|
304
|
+
n_control_pre=int(f.stratum_counts[0]),
|
|
305
|
+
n_control_post=int(f.stratum_counts[1]),
|
|
306
|
+
n_treated_pre=int(f.stratum_counts[2]),
|
|
307
|
+
n_treated_post=int(f.stratum_counts[3]),
|
|
308
|
+
propensity_range=[f.probability_min, f.probability_max]
|
|
309
|
+
if method != "reg"
|
|
310
|
+
else None,
|
|
311
|
+
effective_controls=(f.means.weight[[3, 2]] ** 2 / f.control_weight_squares).tolist()
|
|
312
|
+
if method != "reg"
|
|
313
|
+
else (f.sample_weight[:2] ** 2 / f.sample_weight_squares[:2]).tolist(),
|
|
314
|
+
effective_treated=(f.sample_weight[2:] ** 2 / f.sample_weight_squares[2:]).tolist(),
|
|
315
|
+
logit_iterations=f.system.iterations if method != "reg" else None,
|
|
316
|
+
)
|
|
317
|
+
for c, f in zip(comparisons, fitted, strict=True)
|
|
318
|
+
],
|
|
319
|
+
warnings=[],
|
|
320
|
+
)
|
|
321
|
+
if any(f.trimmed for f in fitted):
|
|
322
|
+
metadata["warnings"].append(
|
|
323
|
+
"Propensity trimming removed control contributions; inspect comparisons."
|
|
324
|
+
)
|
|
325
|
+
if any(f.capped for f in fitted):
|
|
326
|
+
metadata["warnings"].append(
|
|
327
|
+
"Some propensity probabilities were capped at 1-1e-6 under the R DRDID convention."
|
|
328
|
+
)
|
|
329
|
+
return (
|
|
330
|
+
np.array([f.estimate for f in fitted]),
|
|
331
|
+
counts,
|
|
332
|
+
V,
|
|
333
|
+
cross,
|
|
334
|
+
share_cov,
|
|
335
|
+
metadata,
|
|
336
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""CS comparison support, warning policy and explicit coefficient suppression."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from numbers import Real
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LowEffectiveSampleWarning(UserWarning):
|
|
11
|
+
"""A CS comparison relies on a low effective treated/control sample."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def threshold(value, name, *, optional=True):
|
|
15
|
+
if value is None and optional:
|
|
16
|
+
return value
|
|
17
|
+
if (
|
|
18
|
+
isinstance(value, (bool, np.bool_))
|
|
19
|
+
or not isinstance(value, Real)
|
|
20
|
+
or not np.isfinite(value)
|
|
21
|
+
or value < 0
|
|
22
|
+
):
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"{name} must be a finite nonnegative number" + (" or None" if optional else "")
|
|
25
|
+
)
|
|
26
|
+
return float(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def finish_result(result, warn_threshold, suppress_threshold):
|
|
30
|
+
if result.estimator != "cs":
|
|
31
|
+
return result
|
|
32
|
+
rows = []
|
|
33
|
+
cell_index = {cell: j for j, cell in enumerate(result.cells)}
|
|
34
|
+
for comparison in result.diagnostics["comparisons"]:
|
|
35
|
+
indices = comparison.get("cells")
|
|
36
|
+
if indices is None:
|
|
37
|
+
indices = [cell_index[(comparison["cohort"], comparison["time"])]]
|
|
38
|
+
for index in indices:
|
|
39
|
+
for group, key in (("treated", "effective_treated"), ("control", "effective_controls")):
|
|
40
|
+
values = np.atleast_1d(comparison[key])
|
|
41
|
+
labels = [group] if len(values) == 1 else [group + "_pre", group + "_post"]
|
|
42
|
+
for label, value in zip(labels, values, strict=True):
|
|
43
|
+
if not np.isfinite(value) or value <= 0:
|
|
44
|
+
raise ValueError("CS comparison has undefined effective sample size")
|
|
45
|
+
rows.append(
|
|
46
|
+
dict(
|
|
47
|
+
coefficient=result.coefnames[index],
|
|
48
|
+
cell=result.coefnames[index],
|
|
49
|
+
cohort=result.cells[index][0],
|
|
50
|
+
time=result.cells[index][1],
|
|
51
|
+
stratum=label,
|
|
52
|
+
effective_n=float(value),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
result._ess_table = pl.DataFrame(rows)
|
|
56
|
+
result.diagnostics["ess_warn_threshold"] = warn_threshold
|
|
57
|
+
result.diagnostics["ess_suppress_threshold"] = suppress_threshold
|
|
58
|
+
result.diagnostics["ess_definition"] = (
|
|
59
|
+
"Kish weight ESS within each required treated/control comparison stratum; not regression-information or influence-score ESS"
|
|
60
|
+
)
|
|
61
|
+
minima = result._minimum_ess()
|
|
62
|
+
bad = minima < (warn_threshold or 0)
|
|
63
|
+
if bad.any():
|
|
64
|
+
message = (
|
|
65
|
+
f"Low effective sample size: {int(bad.sum())} CS coefficients have a required "
|
|
66
|
+
f"treated/control stratum below {warn_threshold:g} (minimum {minima.min():.3g}). "
|
|
67
|
+
"Inspect effective_sample_size(); suppress(min_effective_sample=...) masks coefficients without changing aggregation weights."
|
|
68
|
+
)
|
|
69
|
+
result.diagnostics["warnings"] = [*result.diagnostics.get("warnings", []), message]
|
|
70
|
+
warnings.warn(message, LowEffectiveSampleWarning, stacklevel=3)
|
|
71
|
+
if suppress_threshold is not None:
|
|
72
|
+
return result.suppress(min_effective_sample=suppress_threshold)
|
|
73
|
+
return result
|