dpd-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.
dpd_stream/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """dpd-stream: Arellano-Bond (difference) and Blundell-Bond (system) GMM
2
+ for dynamic panels, out of core. Names and conventions follow Stata's
3
+ xtabond2 where they overlap.
4
+
5
+ The data are read once (pass 0) into blocks of whole panels sorted by
6
+ time; every later pass reads the blocks, builds each panel's rows and
7
+ instruments in a numba kernel, and accumulates the few matrices the
8
+ estimator needs (j x j and j x K, j the number of instruments). Nothing
9
+ the size of the data is held in memory, and no instrument matrix is
10
+ built.
11
+
12
+ Layout
13
+ api.py dpd_stream()
14
+ _frontend.py formula and instrument parsing; Gmm, Iv
15
+ layout.py the instrument matrix's columns (xtabond2's order)
16
+ store.py pass 0: the source -> panel-sorted blocks
17
+ kernels/ numba: a panel's rows and instruments; the passes
18
+ estimator.py the fit: the passes and the estimates
19
+ _passes.py, _absorb.py, _idtests.py, _nl.py
20
+ parts of the fit (mixins of estimator.DPD): the residual
21
+ and Windmeijer passes; absorbed effects; identification
22
+ tests; Ahn-Schmidt moments
23
+ inference.py the algebra after the passes: generalized inverse,
24
+ Windmeijer, Arellano-Bond, Sargan/Hansen, MMSC
25
+ results.py DPDResult, DPDMulti
26
+ simulate.py simulate_dynamic(), truth_dynamic()
27
+ """
28
+
29
+ from importlib.metadata import PackageNotFoundError
30
+ from importlib.metadata import version as _version
31
+
32
+ from hdfe_stream.core.multcomp import bonferroni, holm
33
+ from hdfe_stream.core.reporting import etable
34
+ from hdfe_stream.core.workspace import cleanup
35
+
36
+ from ._frontend import Gmm, Iv
37
+ from .api import dpd_stream
38
+ from .results import DPDMulti, DPDResult
39
+
40
+ try:
41
+ __version__ = _version("dpd-stream")
42
+ except PackageNotFoundError: # pragma: no cover
43
+ __version__ = "0.1.0"
44
+
45
+ __all__ = [
46
+ "DPDMulti",
47
+ "DPDResult",
48
+ "Gmm",
49
+ "Iv",
50
+ "__version__",
51
+ "bonferroni",
52
+ "cleanup",
53
+ "dpd_stream",
54
+ "etable",
55
+ "holm",
56
+ ]
dpd_stream/_absorb.py ADDED
@@ -0,0 +1,288 @@
1
+ """The fit with absorbed period-by-group effects (`g^year`): the cells'
2
+ moments, their inference (Windmeijer and the Arellano-Bond sums, exactly as
3
+ with the dummies) and the partialling.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+
10
+ from . import kernels as kn
11
+ from ._blocks import _blocks, _chunks
12
+
13
+
14
+ class _Absorb:
15
+ """Methods of `estimator.DPD`."""
16
+
17
+ def _check_absorb(self, terms):
18
+ """The absorbed term must be one period-specific effect: the time
19
+ variable, or g^time with g constant within panels (difference GMM,
20
+ first differences)."""
21
+ tvar = self.panel[1]
22
+ if len(terms) != 1:
23
+ raise NotImplementedError(
24
+ "one absorbed fixed-effect term is available (e.g. '| year' or "
25
+ f"'| industry^year'), got {list(terms)}")
26
+ parts = [p.strip() for p in terms[0].split("^")]
27
+ if tvar not in parts or "[" in terms[0]:
28
+ raise ValueError(
29
+ f"absorbed fixed effect {terms[0]!r}: difference GMM absorbs effects specific "
30
+ f"to a period, '{tvar}' or 'g^{tvar}' with g constant within panels (any "
31
+ "effect constant within a panel is removed by the differencing already)")
32
+ if self.pca:
33
+ raise NotImplementedError("absorbed fixed effects with pca are not available")
34
+ return [p for p in parts if p != tvar]
35
+
36
+ # ------------------------------------------------------------------ passes
37
+
38
+ def _cellscores(self, b_full, delta, Hf):
39
+ """A pass for the absorbed effect's moments g2 = D'e at `b_full` and
40
+ `delta` (`kernels.cells.cellscore_pass`): their covariance in the
41
+ directions Hf (cells x nf), Hf' S22 Hf, with the clusterings and
42
+ factors of the moments' S; and each general cluster's Hf'D_c'e_c, by
43
+ clustering (unscaled)."""
44
+ st = self.store
45
+ nch = self.n_threads
46
+ clus = self.clusters
47
+ nf = Hf.shape[1]
48
+ Hf = np.ascontiguousarray(Hf)
49
+ rowmode = any(c.by_obs for c in clus)
50
+ general = [c for c in clus if c.col >= 0 and not c.nested]
51
+ nested = [c for c in clus if c.col >= 0 and c.nested]
52
+ off = 0
53
+ for c in general:
54
+ c.offset = off
55
+ off += c.n_codes
56
+ AccH = np.zeros((nch, max(off, 1), nf))
57
+ Nacc = {id(c): np.zeros((c.n_codes, nf)) for c in nested}
58
+ SRH = np.zeros((nch, nf, nf))
59
+ H = np.zeros((nf, nf))
60
+
61
+ def fac(c):
62
+ return c.sign * (c.n / (c.n - 1) if self.small else 1.0)
63
+ for blk, pstart in _blocks(st):
64
+ npan = len(pstart) - 1
65
+ CL = blk.get("cl", np.zeros((len(blk["t"]), 0), np.int64))
66
+ CLacc = np.full((len(blk["t"]), len(general)), -1, np.int64)
67
+ for q, c in enumerate(general):
68
+ v = CL[:, c.col]
69
+ CLacc[:, q] = np.where(v >= 0, v + c.offset, -1)
70
+ Hp = np.zeros((npan, nf))
71
+ kn.cellscore_pass(*self._kargs(blk, _chunks(pstart, nch), pstart), b_full,
72
+ blk["ag"], delta, Hf, bool(clus), rowmode, CLacc, Hp, AccH, SRH)
73
+ if not clus:
74
+ H += Hp.T @ Hp
75
+ for c in clus:
76
+ if c.is_panel:
77
+ H += fac(c) * (Hp.T @ Hp)
78
+ for c in nested:
79
+ codes = CL[pstart[:-1], c.col]
80
+ ok = codes >= 0
81
+ np.add.at(Nacc[id(c)], codes[ok], Hp[ok])
82
+ AccH = AccH.sum(0)
83
+ by_code = {}
84
+ for c in nested:
85
+ A = Nacc[id(c)]
86
+ H += fac(c) * (A.T @ A)
87
+ by_code[id(c)] = A
88
+ for c in general:
89
+ A = AccH[c.offset:c.offset + c.n_codes]
90
+ H += fac(c) * (A.T @ A)
91
+ by_code[id(c)] = A
92
+ if rowmode:
93
+ SRt = SRH.sum(0)
94
+ for c in clus:
95
+ if c.by_obs:
96
+ H += fac(c) * SRt
97
+ return (H + H.T) / 2, by_code
98
+
99
+ def _cells_inference(self, kind, c):
100
+ """Windmeijer's correction and the Arellano-Bond tests with an
101
+ absorbed effect, exactly as with its dummies among the regressors
102
+ and instruments (where concentrating the effects out is an
103
+ approximation): both depend on the effects' estimates, and through
104
+ them on the effects' moments g2 = D'e, whose covariance S22 (cells
105
+ x cells) is never formed, only its products with a few directions
106
+ (`_cellscores`, a pass each).
107
+
108
+ The estimates are linear in the moments. With the coefficients' b^ -
109
+ b = G g~ (g~ = Z~'e), the effects' are delta^ - delta = -xshift (b^ -
110
+ b) + DDinv (g2 - SA g~), SA the effects' moments' covariance with the
111
+ instruments' times the instruments' weight (D'HZ~ A1 one-step, the
112
+ cross moments times A2 two-step). So the functional tmp_b'b +
113
+ tmp_d'delta of the AR tests has the influence (G'tmp_x - SA'rho)'g~
114
+ + rho'g2, with tmp_x = tmp_b - xshift'tmp_d (the concentrated
115
+ derivative) and rho = DDinv tmp_d. Windmeijer's correction is the
116
+ same algebra one level down: the two-step estimates depend on the
117
+ one-step coefficients and effects through the weight matrix,
118
+ D_b (b1 - b) + D_d (delta1 - delta) = Dc (b1 - b) + R (g2 - SA1 g~)
119
+ with Dc the concentrated derivative (`Draw`), D_d the derivative in
120
+ the effects (the functionals' `Od`) and R = D_d DDinv.
121
+
122
+ `kind`: "1iid" (one-step, non-robust), "1rob" (one-step, robust), "2"
123
+ (two-step or iterated, uncorrected) or "2rob" (two-step with
124
+ Windmeijer's correction). Returns (the corrected two-step variance
125
+ or None, each AR test's variance less what `inference.ar_stat`
126
+ computes from the concentrated terms)."""
127
+ ab = self.absorb_info
128
+ bmul, DDinv = ab["bmul"], ab["DDinv"]
129
+ keep = c["keep"]
130
+ k = len(keep)
131
+
132
+ def dinv(M):
133
+ return bmul(DDinv, M)
134
+
135
+ def cols(a, b):
136
+ return np.einsum("im,im->m", a, b)
137
+ nar = 0
138
+ if c["ar"] is not None:
139
+ ar_sum, _, tmp, zhw, td, zhd = c["ar"]
140
+ nar = len(ar_sum)
141
+ tmpx = tmp[:, keep].T # k x m
142
+ zhw, zhd = zhw.T, zhd.T # L x m, cells x m
143
+ if kind == "1iid":
144
+ zhw, zhd = zhw * c["s2ar"], zhd * c["s2ar"]
145
+ rho = dinv(td.T) # cells x m
146
+ SA1 = ab["S21"] @ c["A1raw"]
147
+ SA = SA1 if kind[0] == "1" else c["cross"] @ c["A2"]
148
+ extra = np.zeros(nar)
149
+ if nar:
150
+ # the effects' part of the estimates' covariance with the tests'
151
+ extra -= 2 * cols(rho, zhd - SA @ zhw)
152
+ if kind == "1iid":
153
+ if nar:
154
+ # the effects' variance: sigma^2 times the Schur complement of
155
+ # D'HD in the one-step weight's inverse
156
+ S21r = ab["S21"].T @ rho
157
+ extra += c["sig2"] * (cols(rho, bmul(ab["DHD"], rho))
158
+ - cols(S21r, c["A1raw"] @ S21r))
159
+ return None, extra
160
+ S, Cr = c["S"], c["cross"]
161
+ bS, dS = c["point_S"]
162
+ if kind == "1rob":
163
+ if nar:
164
+ self.log("pass 3: the absorbed effects' moments")
165
+ H, _ = self._cellscores(bS, dS, rho)
166
+ a = c["G1"].T @ tmpx
167
+ lam = a - SA.T @ rho
168
+ extra += (cols(lam, S @ lam) - cols(a, S @ a) + 2 * cols(rho, Cr @ lam)
169
+ + np.diag(H))
170
+ return None, extra
171
+ A2, V2 = c["A2"], c["V2"]
172
+ if kind == "2":
173
+ if nar:
174
+ self.log("pass 4: the absorbed effects' moments")
175
+ H, _ = self._cellscores(bS, dS, rho)
176
+ CAr = Cr.T @ rho
177
+ extra += np.diag(H) - cols(CAr, A2 @ CAr)
178
+ return None, extra
179
+ # Windmeijer: the derivatives in the one-step effects from the
180
+ # functionals P (the two-step coefficients' influence) and, for the
181
+ # AR tests, each test's own influence (lam2, rho)
182
+ G1 = c["G1"]
183
+ P = V2 @ c["ZXk"].T @ A2
184
+ Lf, Rf, beta = P, np.zeros((k, ab["ncell"])), None
185
+ if nar:
186
+ lam2 = P.T @ tmpx - A2 @ (Cr.T @ rho)
187
+ Lf = np.vstack([P, lam2.T])
188
+ Rf = np.vstack([Rf, rho.T])
189
+ if any(cc.col >= 0 for cc in self.clusters):
190
+ self.log("pass 4: the absorbed effects' moments by cluster")
191
+ _, bc = self._cellscores(bS, dS, rho)
192
+ beta = {key: np.hstack([np.zeros((v.shape[0], k)), v]) for key, v in bc.items()}
193
+ self.log("pass 4: Windmeijer correction")
194
+ Draw, Ob, Od = self._windmeijer(c["b1"], c["v"], c["info_S"], delta=c["d1"],
195
+ xshift=c["xs1"], funcs=(Lf, Rf, beta))
196
+ Dc = P @ Draw[:, keep]
197
+ Dd = Od[:k]
198
+ R = dinv(Dd.T).T
199
+ dxs = (c["xs1"] - c["xs2"])[:, keep]
200
+ Hf = [R.T]
201
+ if nar:
202
+ wx1 = Ob[k:, keep].T
203
+ wd = Od[k:].T
204
+ nu = dinv(wd)
205
+ Hf += [rho, nu]
206
+ self.log("pass 5: the absorbed effects' moments")
207
+ H, _ = self._cellscores(bS, dS, np.hstack(Hf))
208
+ # V2 + D V1r D' + D V2 + V2 D', with the effects among the
209
+ # parameters: D V1r D' is the variance of Dc (b1 - b) + R (g2 - SA1
210
+ # g~), and the effects' part of V2 enters D V2 through xshift2
211
+ Lam1 = Dc @ G1 - R @ SA1
212
+ LCR = Lam1 @ Cr.T @ R.T
213
+ Vc = Lam1 @ S @ Lam1.T + LCR + LCR.T + H[:k, :k]
214
+ E = Dc + Dd @ dxs
215
+ V2r = V2 + Vc + E @ V2 + V2 @ E.T
216
+ V2r = (V2r + V2r.T) / 2
217
+ if nar:
218
+ m = nar
219
+ Hrr = np.diag(H[k:k + m, k:k + m])
220
+ Hnn = np.diag(H[k + m:, k + m:])
221
+ Hnr = np.diag(H[k + m:, k:k + m])
222
+ CAr, CAn = Cr.T @ rho, Cr.T @ nu
223
+ cV2c = cols(tmpx, V2 @ tmpx) + Hrr - cols(CAr, A2 @ CAr)
224
+ mu = G1.T @ wx1 - SA1.T @ nu
225
+ wV1w = cols(mu, S @ mu) + 2 * cols(nu, Cr @ mu) + Hnn
226
+ wx2 = wx1 + dxs.T @ wd
227
+ wV2c = cols(wx2, V2 @ tmpx) + Hnr - cols(CAn, A2 @ CAr)
228
+ extra += cV2c + wV1w + 2 * wV2c - cols(tmpx, V2r @ tmpx)
229
+ return V2r, extra
230
+
231
+ def _partial(self, cells, live, ZX, ZY, S1, XX, K):
232
+ """The moments net of an absorbed effect (Frisch-Waugh-Lovell for
233
+ GMM). With D the absorbed dummies of the transformed rows and W the
234
+ weights (the regressors' dummies are W D, the instruments' D):
235
+ instruments Z~ = Z - D Mz with Mz = (D'WD)^- D'WZ, the dummies'
236
+ coefficients concentrated out of the regressors and outcome by
237
+ Mx = (D'WD)^- D'[X y], and Z~'HZ~ = Z'HZ - Z'HD Mz - Mz'D'HZ +
238
+ Mz'D'HD Mz. Concentrating out the effects, which their own
239
+ (IV-style) moments exactly identify, leaves the same estimates of the
240
+ other coefficients as including the dummies. D'WD and D'HD are block
241
+ diagonal, one T x T block per group (diagonal and tridiagonal with
242
+ first differences); with forward orthogonal deviations they are
243
+ singular (the deviations of a full set of dummies sum to zero), and
244
+ their generalized inverse leaves the level effects determined up to
245
+ a constant per group, which the estimates do not depend on."""
246
+ DDx, DHD = cells["DDx"], cells["DHD"]
247
+ ng, T = DDx.shape[0], DDx.shape[1]
248
+ DDinv = np.linalg.pinv(DDx, rcond=1e-12, hermitian=True)
249
+ n_cells = int(np.linalg.matrix_rank(DDx, hermitian=True).sum())
250
+
251
+ def bmul(M, X):
252
+ return np.einsum("gst,gtc->gsc", M, X.reshape(ng, T, -1)).reshape(ng * T, -1)
253
+ CZw, CHZ, CX = cells["CZw"][:, live], cells["CHZ"][:, live], cells["CX"]
254
+ Mz = bmul(DDinv, CZw)
255
+ Mx = bmul(DDinv, CX)
256
+ DHDMz = bmul(DHD, Mz)
257
+ ZXt = ZX - Mz.T @ CX[:, :K]
258
+ ZYt = ZY - Mz.T @ CX[:, K]
259
+ S1t = S1 - CHZ.T @ Mz - Mz.T @ CHZ + Mz.T @ DHDMz
260
+ S1t = (S1t + S1t.T) / 2
261
+ XXt = XX - CX.T @ Mx
262
+ ab = dict(ncell=ng * T, DDinv=DDinv, bmul=bmul, Mz=Mz, Mx=Mx, S21=CHZ - DHDMz,
263
+ DHD=DHD, CZw=CZw, CX=CX, n_cells=n_cells)
264
+ return ab, ZXt, ZYt, S1t, XXt
265
+
266
+ def _cell_effects(self, b_full, keep, A, S21, ZXk, ZY, bk):
267
+ """The effects at b in the GMM with the dummies -- their projection
268
+ of y - x'b, less the part their moments' covariance with the
269
+ others' (S21) assigns them -- and the derivative of the concentrated
270
+ residual with respect to b, as x - xshift."""
271
+ ab = self.absorb_info
272
+ Mx, bmul, DDinv = ab["Mx"], ab["bmul"], ab["DDinv"]
273
+ K = Mx.shape[1] - 1
274
+ q = bmul(DDinv, S21 @ (A @ (ZY - ZXk @ bk)))[:, 0]
275
+ delta = Mx[:, K] - Mx[:, :K] @ b_full - q
276
+ w = bmul(DDinv, S21 @ (A @ ZXk))
277
+ xshift = Mx[:, :K].copy()
278
+ xshift[:, keep] -= w
279
+ return np.ascontiguousarray(delta), np.ascontiguousarray(xshift)
280
+
281
+ def _absorb_args(self, blk, delta, xshift):
282
+ ab = self.absorb_info
283
+ if not ab:
284
+ z = np.zeros((1, 1))
285
+ return np.zeros(0, np.int64), z, np.zeros(1), z
286
+ return blk["ag"], ab["Mz"], delta, xshift
287
+
288
+ # ------------------------------------------------------------------ estimation
dpd_stream/_blocks.py ADDED
@@ -0,0 +1,26 @@
1
+ """Iteration over the stored blocks, and the split of a block's panels into the
2
+ chunks the kernels run in parallel."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import numpy as np
7
+
8
+
9
+ def _chunks(pstart, nch):
10
+ """Chunk boundaries over a block's panels, balanced by rows."""
11
+ npan = len(pstart) - 1
12
+ if npan <= 0:
13
+ return np.zeros(1, np.int64)
14
+ nch = max(1, min(nch, npan))
15
+ targets = np.linspace(0, pstart[-1], nch + 1)
16
+ b = np.searchsorted(pstart, targets[1:-1])
17
+ out = np.unique(np.concatenate([[0], b, [npan]])).astype(np.int64)
18
+ return out
19
+
20
+
21
+ def _blocks(store, fields=("X", "t", "pid", "cl", "w", "ag")):
22
+ for blk in store.blocks.iterate(fields=list(fields)):
23
+ pid = blk["pid"]
24
+ starts = np.flatnonzero(np.diff(pid)) + 1
25
+ pstart = np.concatenate([[0], starts, [len(pid)]]).astype(np.int64)
26
+ yield blk, pstart