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
diff_stream/balance.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""CS-specific comparison selection around shared weighted balance kernels.
|
|
2
|
+
|
|
3
|
+
Optional one-comparison-at-a-time staging avoids retaining all unit covariates
|
|
4
|
+
or propensity scores on a result. Regression adjustment does not reweight controls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import TemporaryDirectory
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import polars as pl
|
|
12
|
+
import pyarrow.parquet as pq
|
|
13
|
+
from hdfe_stream.core.balance import effective_sizes, weighted_balance
|
|
14
|
+
|
|
15
|
+
from ._core.contrasts import att_parts
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CSBalanceCollector:
|
|
19
|
+
def __init__(self, store, ecdf):
|
|
20
|
+
self.store, self.ecdf = store, ecdf
|
|
21
|
+
names = store.design.names if store.design is not None else []
|
|
22
|
+
self.keep = [j for j, name in enumerate(names) if name != "Intercept"]
|
|
23
|
+
if not self.keep:
|
|
24
|
+
raise ValueError("covariate_balance=True requires covariates in the formula")
|
|
25
|
+
self.names = [names[j] for j in self.keep]
|
|
26
|
+
self.columns = [f"__x{j}" for j in range(len(self.keep))]
|
|
27
|
+
self.tables = []
|
|
28
|
+
self.source_passes = 0
|
|
29
|
+
|
|
30
|
+
def compute(self, rows, adjusted):
|
|
31
|
+
"""Shared kernels consume a lazy one-comparison temporary Parquet file."""
|
|
32
|
+
with TemporaryDirectory(prefix="cs-balance-", dir=self.store.root) as run:
|
|
33
|
+
path = Path(run) / "rows.parquet"
|
|
34
|
+
writer = None
|
|
35
|
+
try:
|
|
36
|
+
for X, d, w, after in rows():
|
|
37
|
+
if not len(d):
|
|
38
|
+
continue
|
|
39
|
+
self.store._guard(32e6 + X.nbytes * 4)
|
|
40
|
+
frame = pl.DataFrame(
|
|
41
|
+
{
|
|
42
|
+
**{
|
|
43
|
+
name: X[:, k]
|
|
44
|
+
for name, k in zip(self.columns, self.keep, strict=True)
|
|
45
|
+
},
|
|
46
|
+
"__d": d,
|
|
47
|
+
"__before": w,
|
|
48
|
+
"__after": after,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
table = frame.to_arrow()
|
|
52
|
+
if writer is None:
|
|
53
|
+
writer = pq.ParquetWriter(path, table.schema)
|
|
54
|
+
writer.write_table(table, row_group_size=self.store.read_rows)
|
|
55
|
+
finally:
|
|
56
|
+
if writer is not None:
|
|
57
|
+
writer.close()
|
|
58
|
+
self.source_passes += 1
|
|
59
|
+
if writer is None:
|
|
60
|
+
raise ValueError("CS balance comparison has no observations")
|
|
61
|
+
lf = pl.scan_parquet(path, low_memory=True)
|
|
62
|
+
before = weighted_balance(lf, "__d", self.columns, "__before", ecdf=self.ecdf)
|
|
63
|
+
scales = {
|
|
64
|
+
row["term"]: float(np.sqrt((row["var1"] + row["var0"]) / 2))
|
|
65
|
+
for row in before.iter_rows(named=True)
|
|
66
|
+
}
|
|
67
|
+
tables = []
|
|
68
|
+
for stage, table, weight in [
|
|
69
|
+
("before", before, "__before"),
|
|
70
|
+
*([("after", None, "__after")] if adjusted else []),
|
|
71
|
+
]:
|
|
72
|
+
if table is None:
|
|
73
|
+
table = weighted_balance(
|
|
74
|
+
lf, "__d", self.columns, weight, scale=scales, ecdf=self.ecdf
|
|
75
|
+
)
|
|
76
|
+
sizes = effective_sizes(lf, "__d", weight)
|
|
77
|
+
tables.append(
|
|
78
|
+
table.with_columns(
|
|
79
|
+
pl.col("term").replace_strict(
|
|
80
|
+
dict(zip(self.columns, self.names, strict=True))
|
|
81
|
+
),
|
|
82
|
+
pl.lit(stage).alias("stage"),
|
|
83
|
+
pl.lit(sizes.n1, dtype=pl.Int64).alias("n_treated"),
|
|
84
|
+
pl.lit(sizes.n0, dtype=pl.Int64).alias("n_controls"),
|
|
85
|
+
pl.lit(sizes.ess1).alias("ess_treated"),
|
|
86
|
+
pl.lit(sizes.ess0).alias("ess_controls"),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return pl.concat(tables)
|
|
90
|
+
|
|
91
|
+
def balanced(self, comparisons, fitted, cells, batches):
|
|
92
|
+
for c, f in zip(comparisons, fitted, strict=True):
|
|
93
|
+
|
|
94
|
+
def rows(c=c, f=f):
|
|
95
|
+
for raw in batches():
|
|
96
|
+
mask, X, d, _ = c.select(raw)
|
|
97
|
+
sw = c.weights(raw, mask)
|
|
98
|
+
wc = (
|
|
99
|
+
att_parts(X, d, f.nuisance.beta, f.control_max)[1]
|
|
100
|
+
if f.method != "reg"
|
|
101
|
+
else np.ones(len(d))
|
|
102
|
+
)
|
|
103
|
+
yield X, d, sw, sw * np.where(d, 1, wc)
|
|
104
|
+
|
|
105
|
+
table = self.compute(rows, f.method != "reg")
|
|
106
|
+
for k, j in enumerate(c.cells):
|
|
107
|
+
self.tables.append(
|
|
108
|
+
table.with_columns(
|
|
109
|
+
pl.lit(c.cohort, dtype=pl.Int64).alias("cohort"),
|
|
110
|
+
pl.lit(cells[j][1], dtype=pl.Int64).alias("time"),
|
|
111
|
+
pl.lit(int(self.store.times[c.bases[k]]), dtype=pl.Int64).alias("base"),
|
|
112
|
+
pl.lit("comparison").alias("stratum"),
|
|
113
|
+
pl.lit(int(self.store.times[c.xperiod]), dtype=pl.Int64).alias(
|
|
114
|
+
"covariate_time"
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def unbalanced(self, comparisons, fitted, batches):
|
|
120
|
+
for c, f in zip(comparisons, fitted, strict=True):
|
|
121
|
+
for use_post, stratum in [(False, "pre"), (True, "post")]:
|
|
122
|
+
|
|
123
|
+
def rows(c=c, f=f, use_post=use_post):
|
|
124
|
+
for raw in batches():
|
|
125
|
+
unit, X, d, _, post = c.select(raw)
|
|
126
|
+
sw = c.weights(raw, unit, post)
|
|
127
|
+
wc = (
|
|
128
|
+
att_parts(X, d, f.system.beta, f.trim)[1]
|
|
129
|
+
if f.method != "reg"
|
|
130
|
+
else np.ones(len(d))
|
|
131
|
+
)
|
|
132
|
+
mask = post == use_post
|
|
133
|
+
yield X[mask], d[mask], sw[mask], (sw * np.where(d, 1, wc))[mask]
|
|
134
|
+
|
|
135
|
+
table = self.compute(rows, f.method != "reg")
|
|
136
|
+
self.tables.append(
|
|
137
|
+
table.with_columns(
|
|
138
|
+
pl.lit(c.cohort, dtype=pl.Int64).alias("cohort"),
|
|
139
|
+
pl.lit(int(self.store.times[c.target]), dtype=pl.Int64).alias("time"),
|
|
140
|
+
pl.lit(int(self.store.times[c.base]), dtype=pl.Int64).alias("base"),
|
|
141
|
+
pl.lit(stratum).alias("stratum"),
|
|
142
|
+
pl.lit(int(self.store.times[c.target if use_post else c.base])).alias(
|
|
143
|
+
"covariate_time"
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def table(self):
|
|
149
|
+
return pl.concat(self.tables).sort("cohort", "time", "stratum", "stage", "term")
|
diff_stream/bjs.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""BJS smart residual averaging and cluster leave-out adjustments.
|
|
2
|
+
|
|
3
|
+
The implementation uses cohort-sized first/second moments. It implements the
|
|
4
|
+
published score algebra independently; no author implementation is vendored.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class BJSInference:
|
|
14
|
+
groups: np.ndarray
|
|
15
|
+
sizes: np.ndarray
|
|
16
|
+
times: np.ndarray
|
|
17
|
+
cells: list
|
|
18
|
+
means: np.ndarray
|
|
19
|
+
Z: np.ndarray
|
|
20
|
+
theta: np.ndarray
|
|
21
|
+
H: np.ndarray
|
|
22
|
+
scatter: np.ndarray
|
|
23
|
+
avgeffectsby: str
|
|
24
|
+
leaveout: bool
|
|
25
|
+
anticipation: int = 0
|
|
26
|
+
|
|
27
|
+
def covariance(self, contrasts, *, allow_unidentified=False):
|
|
28
|
+
"""Covariance for contrasts of the original cell estimates.
|
|
29
|
+
|
|
30
|
+
Averaging is contrast-specific. Coarser residual groups therefore need
|
|
31
|
+
fresh smart averages, rather than a linear transform of the cell vcov.
|
|
32
|
+
"""
|
|
33
|
+
A = np.atleast_2d(contrasts)
|
|
34
|
+
P, M = A.shape
|
|
35
|
+
T, G = len(self.times), len(self.groups)
|
|
36
|
+
weights = np.zeros((G, P, T))
|
|
37
|
+
labels = {}
|
|
38
|
+
cell_groups = []
|
|
39
|
+
for j, (g, t) in enumerate(self.cells):
|
|
40
|
+
k = int(np.searchsorted(self.groups, g))
|
|
41
|
+
weights[k, :, t - self.times[0]] = A[:, j] / self.sizes[k]
|
|
42
|
+
label = {"cohort_time": (g, t), "event_time": t - g, "cohort": g, "all": 0}[
|
|
43
|
+
self.avgeffectsby
|
|
44
|
+
]
|
|
45
|
+
cell_groups.append(labels.setdefault(label, len(labels)))
|
|
46
|
+
R = len(labels)
|
|
47
|
+
residual_means = np.zeros((G, T))
|
|
48
|
+
denom, numer = np.zeros((P, R)), np.zeros((P, R))
|
|
49
|
+
|
|
50
|
+
def group_totals(k):
|
|
51
|
+
totals, effects = np.zeros((P, R)), np.zeros((P, R))
|
|
52
|
+
for j, (g, t) in enumerate(self.cells):
|
|
53
|
+
if g == self.groups[k]:
|
|
54
|
+
ti, r = t - self.times[0], cell_groups[j]
|
|
55
|
+
totals[:, r] += weights[k, :, ti]
|
|
56
|
+
effects[:, r] += weights[k, :, ti] * residual_means[k, ti]
|
|
57
|
+
return totals, effects
|
|
58
|
+
|
|
59
|
+
for k, g in enumerate(self.groups):
|
|
60
|
+
use = self.times < g - self.anticipation if g else np.ones(T, dtype=bool)
|
|
61
|
+
mu = self.means[k] - self.Z @ self.theta
|
|
62
|
+
residual_means[k] = mu - mu[use].mean()
|
|
63
|
+
totals, effects = group_totals(k)
|
|
64
|
+
denom += self.sizes[k] * totals**2
|
|
65
|
+
numer += self.sizes[k] * totals * effects
|
|
66
|
+
averages = np.divide(numer, denom, out=np.zeros_like(numer), where=denom > 0)
|
|
67
|
+
target_derivative = A @ self.H
|
|
68
|
+
V = np.zeros((P, P))
|
|
69
|
+
unidentified = np.zeros(P, dtype=bool)
|
|
70
|
+
for k, g in enumerate(self.groups):
|
|
71
|
+
use = self.times < g - self.anticipation if g else np.ones(T, dtype=bool)
|
|
72
|
+
totals, _ = group_totals(k)
|
|
73
|
+
leverage = np.divide(totals**2, denom, out=np.zeros_like(totals), where=denom > 0)
|
|
74
|
+
raw = weights[k].copy()
|
|
75
|
+
raw[:, use] = -target_derivative @ self.Z[use].T
|
|
76
|
+
center = np.zeros(P)
|
|
77
|
+
for j, (cg, t) in enumerate(self.cells):
|
|
78
|
+
if cg != g:
|
|
79
|
+
continue
|
|
80
|
+
ti, r = t - self.times[0], cell_groups[j]
|
|
81
|
+
w = weights[k, :, ti]
|
|
82
|
+
lev = leverage[:, r]
|
|
83
|
+
if self.leaveout:
|
|
84
|
+
invalid = (w != 0) & (lev >= 1 - 1e-12)
|
|
85
|
+
if np.any(invalid) and not allow_unidentified:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"BJS leave_out requires more than one contributing unit in each residual group; use a coarser residual_averaging"
|
|
88
|
+
)
|
|
89
|
+
unidentified |= invalid
|
|
90
|
+
raw[:, ti] = np.divide(w, 1 - lev, out=np.zeros_like(w), where=lev < 1 - 1e-12)
|
|
91
|
+
center += raw[:, ti] * averages[:, r]
|
|
92
|
+
# Scatter was formed after local elimination of unit intercepts.
|
|
93
|
+
# A PSD root avoids cancellation when residual variation is tiny.
|
|
94
|
+
values, vectors = np.linalg.eigh(self.scatter[k])
|
|
95
|
+
linear = raw @ (vectors * np.sqrt(np.maximum(values, 0)))
|
|
96
|
+
mean_score = raw @ residual_means[k] - center
|
|
97
|
+
V += linear @ linear.T + self.sizes[k] * np.outer(mean_score, mean_score)
|
|
98
|
+
V = (V + V.T) / 2
|
|
99
|
+
V[unidentified, :] = np.nan
|
|
100
|
+
V[:, unidentified] = np.nan
|
|
101
|
+
return V
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Separate clean-sample BJS lead regression with bounded unit elimination.
|
|
2
|
+
|
|
3
|
+
The authors' pre-trend exercise augments the untreated unit/time regression
|
|
4
|
+
with lead indicators. It does not average residuals from the post-effect fit.
|
|
5
|
+
No author code is copied; the regression is implemented via sufficient sums.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from copy import copy
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import polars as pl
|
|
13
|
+
from hdfe_stream.core.results_base import RegressionResult
|
|
14
|
+
|
|
15
|
+
from ._core.nuisance import solve_checked
|
|
16
|
+
from ._present import label_frame, labels_of
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(kw_only=True)
|
|
20
|
+
class BJSPlaceboResult(RegressionResult):
|
|
21
|
+
"""Pooled clean lead coefficients from a separate untreated-sample WLS fit.
|
|
22
|
+
|
|
23
|
+
Standard coefficient/interval/Wald methods are inherited from the family.
|
|
24
|
+
There is no estimated joint covariance with the main treatment effects.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
n_units: int
|
|
28
|
+
_support: pl.DataFrame = field(repr=False)
|
|
29
|
+
_internal: dict | None = field(default=None, repr=False)
|
|
30
|
+
_periods: object = field(default=None, repr=False)
|
|
31
|
+
estimator = "bjs_placebo"
|
|
32
|
+
cells = ()
|
|
33
|
+
_normal_inference = True
|
|
34
|
+
|
|
35
|
+
def support(self):
|
|
36
|
+
"""Observed cohort/date support and original-scale weight totals."""
|
|
37
|
+
return label_frame(self._support, labels_of(self))
|
|
38
|
+
|
|
39
|
+
def pretrend_test(self, *, level="auto", horizons=None):
|
|
40
|
+
"""Joint clean-lead Wald test; horizon selection does not refit the model."""
|
|
41
|
+
from .pretrend import pretrend_test
|
|
42
|
+
|
|
43
|
+
return pretrend_test(self, level=level, horizons=horizons)
|
|
44
|
+
|
|
45
|
+
def with_vcov(self, vcov):
|
|
46
|
+
if vcov not in ("CRV0", "CRV1"):
|
|
47
|
+
raise ValueError("vcov must be 'CRV0' or 'CRV1'")
|
|
48
|
+
factor = self.n_units / (self.n_units - 1)
|
|
49
|
+
ratio = (factor if vcov == "CRV1" else 1) / (factor if self.vcov_type == "CRV1" else 1)
|
|
50
|
+
out = copy(self)
|
|
51
|
+
out.vcov = self.vcov * ratio
|
|
52
|
+
out.vcov_type = vcov
|
|
53
|
+
out.all_vcovs = {vcov: (out.vcov, out.df_t)}
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fit_placebos(store, pretrends, anticipation, *, fml, depvar, panel, vcov):
|
|
58
|
+
"""Eliminate unit intercepts locally, solve time/lead coefficients jointly."""
|
|
59
|
+
if store.design is not None or not store.weights_time_invariant:
|
|
60
|
+
from .linear import fit_placebos as fit_rich_placebos
|
|
61
|
+
|
|
62
|
+
return fit_rich_placebos(
|
|
63
|
+
store, pretrends, anticipation, fml=fml, depvar=depvar, panel=panel, vcov=vcov
|
|
64
|
+
)
|
|
65
|
+
T, L = store.periods, pretrends
|
|
66
|
+
horizons = np.arange(-anticipation - L, -anticipation, dtype=np.int64)
|
|
67
|
+
rows = []
|
|
68
|
+
raw_scale = store.weight_scale / store.weight_normalizer
|
|
69
|
+
support = np.zeros(L, dtype=int)
|
|
70
|
+
for g, counts in sorted(store.actual_cell_counts.items()):
|
|
71
|
+
if not g:
|
|
72
|
+
continue
|
|
73
|
+
for ti, t in enumerate(store.times):
|
|
74
|
+
j = int(t) - g + anticipation + L
|
|
75
|
+
if 0 <= j < L and counts[ti]:
|
|
76
|
+
support[j] += counts[ti]
|
|
77
|
+
rows.append(
|
|
78
|
+
dict(
|
|
79
|
+
aggregate=f"event::{horizons[j]}",
|
|
80
|
+
cohort=int(g),
|
|
81
|
+
time=int(t),
|
|
82
|
+
event_time=int(horizons[j]),
|
|
83
|
+
observed_units=int(counts[ti]),
|
|
84
|
+
weight_total=float(store.actual_cell_weights[g][ti] * raw_scale),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
if np.any(support == 0):
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"BJS requested clean placebo horizons are not observed: {horizons[support == 0].tolist()}; reduce pretrend_leads"
|
|
90
|
+
)
|
|
91
|
+
Z = np.eye(T)[:, 1:]
|
|
92
|
+
P = T - 1 + L
|
|
93
|
+
Q, q = np.zeros((P, P)), np.zeros(P)
|
|
94
|
+
context = "BJS clean placebo design is unidentified (missing earlier clean reference or disconnected/collinear design); reduce pretrend_leads"
|
|
95
|
+
|
|
96
|
+
def batches():
|
|
97
|
+
return store.batches(include_weights=True, actual_cohort=True)
|
|
98
|
+
|
|
99
|
+
def design(g):
|
|
100
|
+
lead = (store.times[:, None] - g == horizons) & (g != 0)
|
|
101
|
+
return np.column_stack([Z, lead])
|
|
102
|
+
|
|
103
|
+
def parts(g, values):
|
|
104
|
+
obs = np.isfinite(values)
|
|
105
|
+
use = obs & ((store.times < g - anticipation) if g else True)
|
|
106
|
+
n = use.sum(axis=1)
|
|
107
|
+
if np.any(n == 0):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"BJS requires at least one untreated observation per unit for clean placebo fitting"
|
|
110
|
+
)
|
|
111
|
+
return use, n, np.where(use, values, 0)
|
|
112
|
+
|
|
113
|
+
n_obs = 0
|
|
114
|
+
for g, y, w in batches():
|
|
115
|
+
for cg in np.unique(g):
|
|
116
|
+
mask = g == cg
|
|
117
|
+
use, n, raw = parts(cg, y[mask])
|
|
118
|
+
X, sw = design(cg), w[mask]
|
|
119
|
+
xm = use @ X / n[:, None]
|
|
120
|
+
centered = np.where(use, raw - raw.sum(axis=1)[:, None] / n[:, None], 0)
|
|
121
|
+
Q += X.T @ ((sw @ use)[:, None] * X) - xm.T @ ((sw * n)[:, None] * xm)
|
|
122
|
+
q += X.T @ (sw @ centered)
|
|
123
|
+
n_obs += int(n.sum())
|
|
124
|
+
coefficients = solve_checked(Q, q, context)
|
|
125
|
+
H = solve_checked(Q, np.eye(P)[:, -L:], context)
|
|
126
|
+
V = np.zeros((L, L))
|
|
127
|
+
for g, y, w in batches():
|
|
128
|
+
scores = np.zeros((len(g), L))
|
|
129
|
+
for cg in np.unique(g):
|
|
130
|
+
mask = g == cg
|
|
131
|
+
use, n, raw = parts(cg, y[mask])
|
|
132
|
+
X = design(cg)
|
|
133
|
+
residual = np.where(use, raw - X @ coefficients, 0)
|
|
134
|
+
residual = np.where(use, residual - residual.sum(axis=1)[:, None] / n[:, None], 0)
|
|
135
|
+
scores[mask] = w[mask, None] * (residual @ X @ H)
|
|
136
|
+
V += scores.T @ scores
|
|
137
|
+
factor = store.n_units / (store.n_units - 1) if vcov == "CRV1" else 1.0
|
|
138
|
+
V *= factor
|
|
139
|
+
return BJSPlaceboResult(
|
|
140
|
+
fml=fml,
|
|
141
|
+
depvar=depvar,
|
|
142
|
+
coefnames=[f"event::{h}" for h in horizons],
|
|
143
|
+
beta=coefficients[-L:],
|
|
144
|
+
vcov=(V + V.T) / 2,
|
|
145
|
+
vcov_type=vcov,
|
|
146
|
+
df_t=store.n_units - 1,
|
|
147
|
+
all_vcovs={vcov: (V, store.n_units - 1)},
|
|
148
|
+
n_obs=n_obs,
|
|
149
|
+
n_clusters={panel[0]: store.n_units},
|
|
150
|
+
n_units=store.n_units,
|
|
151
|
+
weights=None,
|
|
152
|
+
_support=pl.DataFrame(rows),
|
|
153
|
+
diagnostics=dict(
|
|
154
|
+
aggregation="event_time",
|
|
155
|
+
anticipation=anticipation,
|
|
156
|
+
reference_event_time=None,
|
|
157
|
+
fitted_horizons=horizons.tolist(),
|
|
158
|
+
pretrends=L,
|
|
159
|
+
base_period="all other clean observations; no fixed event-time reference",
|
|
160
|
+
inference="separate clean-sample WLS; unit-clustered analytic scores",
|
|
161
|
+
cohort_sizes={str(g): int(n) for g, n in store.actual_gc.items()},
|
|
162
|
+
reference_observations=n_obs - int(support.sum()),
|
|
163
|
+
normalized_future_cohorts="original timing retained for placebo indicators",
|
|
164
|
+
post_effect_covariance="not estimated",
|
|
165
|
+
warnings=[],
|
|
166
|
+
),
|
|
167
|
+
)
|