canbiplotme 0.3.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.
- canbiplotme/__init__.py +34 -0
- canbiplotme/data.py +304 -0
- canbiplotme/datasets/__init__.py +56 -0
- canbiplotme/datasets/gcb_trade.npz +0 -0
- canbiplotme/datasets/gcb_trade_a21.npz +0 -0
- canbiplotme/datasets/sim_me.npz +0 -0
- canbiplotme/datasets/sim_trade.csv +2521 -0
- canbiplotme/dynamic.py +105 -0
- canbiplotme/inference.py +281 -0
- canbiplotme/meta.py +118 -0
- canbiplotme/plot.py +48 -0
- canbiplotme/regions.py +94 -0
- canbiplotme/reml.py +124 -0
- canbiplotme/rng.py +100 -0
- canbiplotme/simulation.py +77 -0
- canbiplotme/stage1.py +189 -0
- canbiplotme/stage2.py +336 -0
- canbiplotme-0.3.0.dist-info/METADATA +107 -0
- canbiplotme-0.3.0.dist-info/RECORD +22 -0
- canbiplotme-0.3.0.dist-info/WHEEL +5 -0
- canbiplotme-0.3.0.dist-info/licenses/LICENSE +674 -0
- canbiplotme-0.3.0.dist-info/top_level.txt +1 -0
canbiplotme/reml.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""REML / ML estimation of the two-level model (comparator, section 4.8).
|
|
2
|
+
|
|
3
|
+
Python counterpart of ``cbme_reml`` (which uses ``metafor::rma.mv`` in R):
|
|
4
|
+
each identified coefficient is an observation, the sampling covariance is
|
|
5
|
+
block diagonal by individual (``errors='independent'``) or by group with the
|
|
6
|
+
joint covariance (``errors='joint'``), the fixed effects are the means of every
|
|
7
|
+
component in every group and the random effect of the individual has an
|
|
8
|
+
unstructured covariance (the REML estimate of T).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from scipy import optimize, stats as sps
|
|
15
|
+
|
|
16
|
+
from .data import MEData
|
|
17
|
+
from .stage2 import make_cache
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _long(x: MEData, errors: str):
|
|
21
|
+
ok = np.isfinite(x.beta)
|
|
22
|
+
ind, comp = np.where(ok) # individual-major order
|
|
23
|
+
y = x.beta[ind, comp]
|
|
24
|
+
n = len(y)
|
|
25
|
+
Vm = np.zeros((n, n))
|
|
26
|
+
if errors == "independent":
|
|
27
|
+
for i in np.unique(ind):
|
|
28
|
+
r = np.where(ind == i)[0]
|
|
29
|
+
Vm[np.ix_(r, r)] = x.V[i][np.ix_(comp[r], comp[r])]
|
|
30
|
+
else:
|
|
31
|
+
cache = make_cache(x)
|
|
32
|
+
for lev in x.levels:
|
|
33
|
+
r = np.where(x.group[ind] == lev)[0]
|
|
34
|
+
cols = cache["pos"][ind[r]] * x.K + comp[r]
|
|
35
|
+
Vm[np.ix_(r, r)] = cache["Omega"][lev][np.ix_(cols, cols)]
|
|
36
|
+
return y, Vm, ind, comp
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _blocks(x: MEData, ind, errors):
|
|
40
|
+
"""Index sets of independent blocks of the marginal covariance."""
|
|
41
|
+
if errors == "independent":
|
|
42
|
+
return [np.where(ind == i)[0] for i in np.unique(ind)]
|
|
43
|
+
return [np.where(x.group[ind] == lev)[0] for lev in x.levels]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _theta_to_T(th, K):
|
|
47
|
+
Lc = np.zeros((K, K))
|
|
48
|
+
Lc[np.tril_indices(K)] = th
|
|
49
|
+
Lc[np.diag_indices(K)] = np.exp(Lc[np.diag_indices(K)])
|
|
50
|
+
return Lc @ Lc.T
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _T_to_theta(T, K):
|
|
54
|
+
Lc = np.linalg.cholesky(T + 1e-10 * np.eye(K))
|
|
55
|
+
Lc[np.diag_indices(K)] = np.log(np.diag(Lc))
|
|
56
|
+
return Lc[np.tril_indices(K)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _loglik(th, y, X, Vm, ind, comp, blocks, K, reml):
|
|
60
|
+
T = _theta_to_T(th, K)
|
|
61
|
+
n, p = X.shape
|
|
62
|
+
logdet = 0.0
|
|
63
|
+
XtSiX = np.zeros((p, p)); XtSiy = np.zeros(p); ytSiy = 0.0
|
|
64
|
+
SiX_blocks = []
|
|
65
|
+
for r in blocks:
|
|
66
|
+
S = Vm[np.ix_(r, r)] + np.where(ind[r][:, None] == ind[r][None, :], T[np.ix_(comp[r], comp[r])], 0.0)
|
|
67
|
+
try:
|
|
68
|
+
c = np.linalg.cholesky(S)
|
|
69
|
+
except np.linalg.LinAlgError:
|
|
70
|
+
return -np.inf, None, None
|
|
71
|
+
logdet += 2 * np.log(np.diag(c)).sum()
|
|
72
|
+
Xi = np.linalg.solve(c, X[r]); yi = np.linalg.solve(c, y[r])
|
|
73
|
+
XtSiX += Xi.T @ Xi; XtSiy += Xi.T @ yi; ytSiy += yi @ yi
|
|
74
|
+
b = np.linalg.solve(XtSiX, XtSiy)
|
|
75
|
+
q = ytSiy - 2 * b @ XtSiy + b @ XtSiX @ b
|
|
76
|
+
if reml:
|
|
77
|
+
# same constant as metafor: + 0.5 log|X'X|
|
|
78
|
+
ll = -0.5 * ((n - p) * np.log(2 * np.pi) + logdet + np.linalg.slogdet(XtSiX)[1] + q) \
|
|
79
|
+
+ 0.5 * np.linalg.slogdet(X.T @ X)[1]
|
|
80
|
+
else:
|
|
81
|
+
ll = -0.5 * (n * np.log(2 * np.pi) + logdet + q)
|
|
82
|
+
return ll, b, XtSiX
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fit(y, X, Vm, ind, comp, blocks, K, reml, T0):
|
|
86
|
+
f = lambda th: -_loglik(th, y, X, Vm, ind, comp, blocks, K, reml)[0]
|
|
87
|
+
th0 = _T_to_theta(T0, K)
|
|
88
|
+
best = optimize.minimize(f, th0, method="BFGS", options={"gtol": 1e-8, "maxiter": 2000})
|
|
89
|
+
res2 = optimize.minimize(f, best.x, method="Nelder-Mead", options={"xatol": 1e-10, "fatol": 1e-12, "maxiter": 20000})
|
|
90
|
+
if res2.fun < best.fun:
|
|
91
|
+
best = res2
|
|
92
|
+
ll, b, XtSiX = _loglik(best.x, y, X, Vm, ind, comp, blocks, K, reml)
|
|
93
|
+
return {"T": _theta_to_T(best.x, K), "b": b, "vcov": np.linalg.inv(XtSiX), "logLik": ll,
|
|
94
|
+
"converged": bool(best.success or res2.success)}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cbme_reml(x: MEData, errors: str = "independent", T0=None) -> dict:
|
|
98
|
+
"""REML estimate of T and of the group means, and likelihood-ratio test
|
|
99
|
+
(ML) of group-specific versus common means. The size of the test is not
|
|
100
|
+
validated; it is a reference, as in the thesis."""
|
|
101
|
+
if errors == "joint" and not x.has_joint:
|
|
102
|
+
errors = "independent"
|
|
103
|
+
y, Vm, ind, comp = _long(x, errors)
|
|
104
|
+
K = x.K
|
|
105
|
+
blocks = _blocks(x, ind, errors)
|
|
106
|
+
gl = x.group[ind]
|
|
107
|
+
cells = [(g, k) for g in x.levels for k in range(K) if np.any((gl == g) & (comp == k))]
|
|
108
|
+
X1 = np.column_stack([((gl == g) & (comp == k)).astype(float) for g, k in cells])
|
|
109
|
+
X0 = np.column_stack([(comp == k).astype(float) for k in range(K)])
|
|
110
|
+
if T0 is None:
|
|
111
|
+
from .stage2 import cbme
|
|
112
|
+
T0 = cbme(x).T
|
|
113
|
+
reml = _fit(y, X1, Vm, ind, comp, blocks, K, True, T0)
|
|
114
|
+
ml1 = _fit(y, X1, Vm, ind, comp, blocks, K, False, reml["T"])
|
|
115
|
+
ml0 = _fit(y, X0, Vm, ind, comp, blocks, K, False, reml["T"])
|
|
116
|
+
means = pd.DataFrame(np.nan, index=x.levels, columns=x.components)
|
|
117
|
+
for (g, k), v in zip(cells, reml["b"]):
|
|
118
|
+
means.loc[g, x.components[k]] = v
|
|
119
|
+
lrt = 2 * (ml1["logLik"] - ml0["logLik"])
|
|
120
|
+
df = X1.shape[1] - X0.shape[1]
|
|
121
|
+
return {"T": pd.DataFrame(reml["T"], index=x.components, columns=x.components), "means": means,
|
|
122
|
+
"lrt": {"LRT": lrt, "df": df, "p": float(sps.chi2.sf(lrt, df))},
|
|
123
|
+
"logLik_reml": reml["logLik"], "logLik_ml_groups": ml1["logLik"], "logLik_ml_common": ml0["logLik"],
|
|
124
|
+
"converged": reml["converged"] and ml1["converged"] and ml0["converged"]}
|
canbiplotme/rng.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Random numbers identical to those of R.
|
|
2
|
+
|
|
3
|
+
``RRNG(seed)`` reproduces ``set.seed(seed); rnorm(n)`` of R (Mersenne-Twister
|
|
4
|
+
with R's seed scrambling, normal draws by inversion with
|
|
5
|
+
``qnorm`` of Wichura, algorithm AS 241). With it the parametric bootstrap of
|
|
6
|
+
the package gives, seed by seed, the replicates of the R package ``gcbiplot``
|
|
7
|
+
and of the code of the thesis. About 97% of the normal draws are bitwise equal
|
|
8
|
+
to R's and the rest differ by at most a few units in the last place (rounding
|
|
9
|
+
inside ``qnorm``), which does not change any p-value in practice.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
_I2_32M1 = 2.328306437080797e-10 # 1 / (2^32 - 1)
|
|
18
|
+
_BIG = 134217728.0 # 2^27
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _r_seed_state(seed: int) -> np.ndarray:
|
|
22
|
+
s = int(seed) & 0xFFFFFFFF
|
|
23
|
+
for _ in range(50):
|
|
24
|
+
s = (69069 * s + 1) & 0xFFFFFFFF
|
|
25
|
+
st = []
|
|
26
|
+
for _ in range(625):
|
|
27
|
+
s = (69069 * s + 1) & 0xFFFFFFFF
|
|
28
|
+
st.append(s)
|
|
29
|
+
return np.array(st[1:], dtype=np.uint32)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def qnorm_r(p):
|
|
33
|
+
"""R's qnorm(p, 0, 1) (Wichura AS241, nmath/qnorm.c), vectorized."""
|
|
34
|
+
p = np.asarray(p, float); q = p - 0.5; out = np.empty_like(p)
|
|
35
|
+
c = np.abs(q) <= 0.425
|
|
36
|
+
r = 0.180625 - q[c] * q[c]
|
|
37
|
+
out[c] = q[c] * (((((((r * 2509.0809287301226727 + 33430.575583588128105) * r + 67265.770927008700853) * r
|
|
38
|
+
+ 45921.953931549871457) * r + 13731.693765509461125) * r + 1971.5909503065514427) * r + 133.14166789178437745) * r
|
|
39
|
+
+ 3.387132872796366608) / (((((((r * 5226.495278852545925 + 28729.085735721942674) * r + 39307.89580009271061) * r
|
|
40
|
+
+ 21213.794301586595867) * r + 5394.1960214247511077) * r + 687.1870074920579083) * r + 42.313330701600911252) * r + 1.)
|
|
41
|
+
nc = ~c
|
|
42
|
+
pp = np.where(q[nc] < 0, p[nc], 1 - p[nc])
|
|
43
|
+
r = np.sqrt(-np.array([math.log(v) for v in pp]))
|
|
44
|
+
v = np.empty_like(r)
|
|
45
|
+
a = r <= 5.
|
|
46
|
+
ra = r[a] - 1.6
|
|
47
|
+
v[a] = (((((((ra * 7.7454501427834140764e-4 + .0227238449892691845833) * ra + .24178072517745061177) * ra
|
|
48
|
+
+ 1.27045825245236838258) * ra + 3.64784832476320460504) * ra + 5.7694972214606914055) * ra + 4.6303378461565452959) * ra
|
|
49
|
+
+ 1.42343711074968357734) / (((((((ra * 1.05075007164441684324e-9 + 5.475938084995344946e-4) * ra + .0151986665636164571966) * ra
|
|
50
|
+
+ .14810397642748007459) * ra + .68976733498510000455) * ra + 1.6763848301838038494) * ra + 2.05319162663775882187) * ra + 1.)
|
|
51
|
+
b = ~a
|
|
52
|
+
rb = r[b] - 5.
|
|
53
|
+
v[b] = (((((((rb * 2.01033439929228813265e-7 + 2.71155556874348757815e-5) * rb + .0012426609473880784386) * rb
|
|
54
|
+
+ .026532189526576123093) * rb + .29656057182850489123) * rb + 1.7848265399172913358) * rb + 5.4637849111641143699) * rb
|
|
55
|
+
+ 6.6579046435011037772) / (((((((rb * 2.04426310338993978564e-15 + 1.4215117583164458887e-7) * rb + 1.8463183175100546818e-5) * rb
|
|
56
|
+
+ 7.868691311456132591e-4) * rb + .0148753612908506148525) * rb + .13692988092273580531) * rb + .59983220655588793769) * rb + 1.)
|
|
57
|
+
v[q[nc] < 0] = -v[q[nc] < 0]
|
|
58
|
+
out[nc] = v
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RRNG:
|
|
63
|
+
"""Generator compatible with R's default ``RNGkind`` (Mersenne-Twister, Inversion).
|
|
64
|
+
|
|
65
|
+
It exposes ``standard_normal`` and ``integers`` so that it can be passed
|
|
66
|
+
wherever the package accepts a ``numpy.random.Generator``. Draws are taken
|
|
67
|
+
in the order R takes them (``matrix(rnorm(n), ...)`` fills by columns).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, seed: int):
|
|
71
|
+
self.bg = np.random.MT19937()
|
|
72
|
+
self.bg.state = {"bit_generator": "MT19937", "state": {"key": _r_seed_state(seed), "pos": 624}}
|
|
73
|
+
|
|
74
|
+
def unif(self, n: int) -> np.ndarray:
|
|
75
|
+
"""``runif(n)`` of R."""
|
|
76
|
+
v = self.bg.random_raw(n).astype(np.float64) * 2.3283064365386963e-10
|
|
77
|
+
v[v <= 0.0] = 0.5 * _I2_32M1
|
|
78
|
+
v[1.0 - v <= 0.0] = 1.0 - 0.5 * _I2_32M1
|
|
79
|
+
return v
|
|
80
|
+
|
|
81
|
+
def standard_normal(self, size=None) -> np.ndarray:
|
|
82
|
+
"""``rnorm(n)`` of R; ``size`` may be an int or a shape (filled in C order)."""
|
|
83
|
+
n = 1 if size is None else int(np.prod(size))
|
|
84
|
+
u = self.unif(2 * n).reshape(n, 2)
|
|
85
|
+
z = qnorm_r((np.floor(_BIG * u[:, 0]) + u[:, 1]) / _BIG)
|
|
86
|
+
return z[0] if size is None else z.reshape(size)
|
|
87
|
+
|
|
88
|
+
def integers(self, high: int) -> int:
|
|
89
|
+
"""``sample.int(high, 1) - 1`` of R (sample.kind = "Rejection")."""
|
|
90
|
+
bits = int(math.ceil(math.log2(high))) if high > 1 else 0
|
|
91
|
+
while True:
|
|
92
|
+
v = 0.0
|
|
93
|
+
for _ in range(0, bits + 1, 16):
|
|
94
|
+
v1 = int(math.floor(self.unif(1)[0] * 65536))
|
|
95
|
+
v = 65536 * v + v1
|
|
96
|
+
if bits > 0:
|
|
97
|
+
one64 = 1
|
|
98
|
+
v = int(v) & ((one64 << bits) - 1)
|
|
99
|
+
if v < high:
|
|
100
|
+
return int(v)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Size, power and coverage studies by simulation (section 4.6)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from scipy import stats as sps
|
|
7
|
+
|
|
8
|
+
from .inference import _prepare, _rng, _with_core, cbme_test, procrustes_align, refit, simulate_coefs
|
|
9
|
+
from .regions import cbme_regions
|
|
10
|
+
from .stage2 import CBMEFit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cbme_simulate_tests(fit: CBMEFit, nsim=1000, B=199, scale=0.0, generator="model", errors="joint",
|
|
14
|
+
level=0.95, seed=None) -> dict:
|
|
15
|
+
"""Rejection rates of the bootstrap tests in ``nsim`` datasets generated from
|
|
16
|
+
the fitted model, each tested with its own bootstrap of ``B`` replicates.
|
|
17
|
+
``scale=0``: null hypothesis (size); ``scale>0``: power. ``generator`` is
|
|
18
|
+
``'model'`` or ``'t4-replicate'`` (non-normal validation of the thesis)."""
|
|
19
|
+
rng = _rng(seed)
|
|
20
|
+
_prepare(fit)
|
|
21
|
+
alpha = 1 - level
|
|
22
|
+
res = []
|
|
23
|
+
for _ in range(nsim):
|
|
24
|
+
means = "common" if scale == 0 else "group"
|
|
25
|
+
if generator == "model":
|
|
26
|
+
b = simulate_coefs(fit, means, errors, scale=scale, rng=rng)
|
|
27
|
+
else:
|
|
28
|
+
b = simulate_coefs(fit, means, "replicate", "t4", scale=scale, rng=rng)
|
|
29
|
+
core = refit(fit, b)
|
|
30
|
+
if core is None:
|
|
31
|
+
continue
|
|
32
|
+
fs = _with_core(fit, core, beta=b)
|
|
33
|
+
tt = cbme_test(fs, B=B, errors=errors, keep=False, seed=rng)
|
|
34
|
+
res.append({"trace": tt.global_["trace"] < alpha, "lambda1": tt.global_["lambda1"] < alpha,
|
|
35
|
+
"any_pair": bool((tt.pairs["p_holm"] < alpha).any()),
|
|
36
|
+
"mean_pair": float((tt.pairs["p"] < alpha).mean()),
|
|
37
|
+
"n_pairs": int((tt.pairs["p_holm"] < alpha).sum()),
|
|
38
|
+
"composition": tt.composition["holds"] if tt.composition else np.nan})
|
|
39
|
+
res = pd.DataFrame(res)
|
|
40
|
+
ns = len(res)
|
|
41
|
+
mc = alpha + np.array([-1, 1]) * sps.norm.ppf(0.995) * np.sqrt(alpha * (1 - alpha) / ns)
|
|
42
|
+
return {"rates": res.mean(numeric_only=True), "mc_interval_99": mc, "nsim_valid": ns, "scale": scale,
|
|
43
|
+
"generator": generator, "B": B, "results": res}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cbme_coverage(fit: CBMEFit, nsim=500, B_R4=199, types=("R1", "R2", "R3", "R4"), level=0.95, seed=None) -> dict:
|
|
47
|
+
"""Coverage of the regions with data generated with the estimated means,
|
|
48
|
+
heterogeneity and errors; targets: projection of the true means on the
|
|
49
|
+
estimated axes (``conditional``) and population marker aligned by
|
|
50
|
+
Procrustes (``aligned``)."""
|
|
51
|
+
rng = _rng(seed)
|
|
52
|
+
_prepare(fit)
|
|
53
|
+
levs = fit.data.levels
|
|
54
|
+
G = len(levs)
|
|
55
|
+
mbar_true = (fit.mu * fit.n[:, None]).sum(0) / fit.n.sum()
|
|
56
|
+
cond = np.zeros((G, len(types))); alig = np.zeros((G, len(types))); cnt = 0
|
|
57
|
+
for _ in range(nsim):
|
|
58
|
+
b = simulate_coefs(fit, "group", rng=rng)
|
|
59
|
+
core = refit(fit, b)
|
|
60
|
+
if core is None:
|
|
61
|
+
continue
|
|
62
|
+
fs = _with_core(fit, core, beta=b)
|
|
63
|
+
rg = cbme_regions(fs, types=types, level=level, B=B_R4, seed=rng)
|
|
64
|
+
tc = (fit.mu - mbar_true) @ fs.V[:, :2]
|
|
65
|
+
ta = procrustes_align(fit.A, fs.A)
|
|
66
|
+
for ti, t in enumerate(types):
|
|
67
|
+
for g, lev in enumerate(levs):
|
|
68
|
+
r = rg["regions"][t][lev]
|
|
69
|
+
if not np.isfinite(r["q"]):
|
|
70
|
+
continue
|
|
71
|
+
Si = np.linalg.inv(r["S"])
|
|
72
|
+
for target, acc in ((tc, cond), (ta, alig)):
|
|
73
|
+
d = target[g] - fs.A[g]
|
|
74
|
+
acc[g, ti] += float(d @ Si @ d <= r["q"])
|
|
75
|
+
cnt += 1
|
|
76
|
+
return {"conditional": pd.DataFrame(cond / cnt, index=levs, columns=list(types)),
|
|
77
|
+
"aligned": pd.DataFrame(alig / cnt, index=levs, columns=list(types)), "nsim_valid": cnt}
|
canbiplotme/stage1.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""First stage for gravity data: individual PPML coefficients, pairs bootstrap and rule D6.
|
|
2
|
+
|
|
3
|
+
Requires ``pyfixest`` (``pip install canbiplotme[gravity]``).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .data import MEData
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _pf():
|
|
14
|
+
try:
|
|
15
|
+
import pyfixest as pf
|
|
16
|
+
except ImportError as e: # pragma: no cover
|
|
17
|
+
raise ImportError("The first stage needs pyfixest: pip install pyfixest") from e
|
|
18
|
+
return pf
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _prepare(data: pd.DataFrame, origin, destination, frictions):
|
|
22
|
+
"""Explicit origin x friction interactions (more portable than i() syntax)."""
|
|
23
|
+
d = data.copy()
|
|
24
|
+
d[origin] = d[origin].astype(str)
|
|
25
|
+
d[destination] = d[destination].astype(str)
|
|
26
|
+
d["_pair"] = d[origin] + "_" + d[destination]
|
|
27
|
+
return d
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _design(d, origin, frictions, ex):
|
|
31
|
+
cols = {}
|
|
32
|
+
names = []
|
|
33
|
+
for f in frictions:
|
|
34
|
+
for e in ex:
|
|
35
|
+
nm = f"b__{e}__{f}"
|
|
36
|
+
cols[nm] = np.where(d[origin].values == e, d[f].values, 0.0)
|
|
37
|
+
names.append(nm)
|
|
38
|
+
return pd.concat([d.reset_index(drop=True), pd.DataFrame(cols)], axis=1), names
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fml(flow, names, controls, origin, destination, year, extra=()):
|
|
42
|
+
rhs = " + ".join(list(names) + list(controls or []) + list(extra))
|
|
43
|
+
return f"{flow} ~ {rhs} | {origin}^{year} + {destination}^{year}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fit(pf, fml, data, vcov):
|
|
47
|
+
return pf.fepois(fml, data=data, vcov=vcov, iwls_maxiter=100)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract(model, ex, frictions, with_vcov=True):
|
|
51
|
+
coef = model.coef()
|
|
52
|
+
K = len(frictions)
|
|
53
|
+
beta = np.full((len(ex), K), np.nan)
|
|
54
|
+
V = np.full((len(ex), K, K), np.nan)
|
|
55
|
+
vc = None
|
|
56
|
+
if with_vcov:
|
|
57
|
+
vc = pd.DataFrame(model._vcov, index=coef.index, columns=coef.index)
|
|
58
|
+
for j, e in enumerate(ex):
|
|
59
|
+
nm = [f"b__{e}__{f}" for f in frictions]
|
|
60
|
+
pres = [n in coef.index and np.isfinite(coef[n]) for n in nm]
|
|
61
|
+
idx = [k for k in range(K) if pres[k]]
|
|
62
|
+
for k in idx:
|
|
63
|
+
beta[j, k] = coef[nm[k]]
|
|
64
|
+
if with_vcov and idx:
|
|
65
|
+
sel = [nm[k] for k in idx]
|
|
66
|
+
V[j][np.ix_(idx, idx)] = vc.loc[sel, sel].values
|
|
67
|
+
return beta, V
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GravityStage1:
|
|
71
|
+
def __init__(self, beta, V, group, id, spec):
|
|
72
|
+
self.beta, self.V, self.group, self.id, self.spec = beta, V, group, id, spec
|
|
73
|
+
|
|
74
|
+
def __repr__(self):
|
|
75
|
+
return (f"<GravityStage1> {self.beta.shape[0]} individuals in {len(set(self.group))} groups; "
|
|
76
|
+
f"frictions: {', '.join(self.spec['frictions'])}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def gravity_by_individual(data: pd.DataFrame, flow, origin, destination, year, frictions, group,
|
|
80
|
+
controls=None, cluster="pair") -> GravityStage1:
|
|
81
|
+
"""PPML with a coefficient vector of its own for each origin in the compared
|
|
82
|
+
frictions, common controls and origin-year and destination-year fixed
|
|
83
|
+
effects, estimated separately in each group (section 3.2.1)."""
|
|
84
|
+
pf = _pf()
|
|
85
|
+
d0 = _prepare(data, origin, destination, frictions)
|
|
86
|
+
vc = {"pair": {"CRV1": "_pair"}, "destination": {"CRV1": destination},
|
|
87
|
+
"twoway": {"CRV1": f"{origin}+{destination}"}}[cluster]
|
|
88
|
+
spec = dict(flow=flow, origin=origin, destination=destination, year=year, frictions=list(frictions),
|
|
89
|
+
group=group, controls=list(controls or []))
|
|
90
|
+
betas, Vs, grps, ids = [], [], [], []
|
|
91
|
+
for g in sorted(d0[group].dropna().astype(str).unique()):
|
|
92
|
+
d = d0[d0[group].astype(str) == g]
|
|
93
|
+
ex = sorted(d[origin].unique())
|
|
94
|
+
dd, names = _design(d, origin, frictions, ex)
|
|
95
|
+
m = _fit(pf, _fml(flow, names, controls, origin, destination, year), dd, vc)
|
|
96
|
+
b, V = _extract(m, ex, frictions)
|
|
97
|
+
betas.append(b); Vs.append(V); grps += [g] * len(ex); ids += ex
|
|
98
|
+
return GravityStage1(np.vstack(betas), np.concatenate(Vs), np.array(grps, dtype=object), ids, spec)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def refit_on_pairs(s1: GravityStage1, data: pd.DataFrame, g: str, pairs) -> np.ndarray:
|
|
102
|
+
"""Re-estimate group ``g`` on a list of drawn pairs (repeats allowed)."""
|
|
103
|
+
pf = _pf()
|
|
104
|
+
sp = s1.spec
|
|
105
|
+
d = _prepare(data, sp["origin"], sp["destination"], sp["frictions"])
|
|
106
|
+
d = d[d[sp["group"]].astype(str) == g]
|
|
107
|
+
by_pair = {k: v for k, v in d.groupby("_pair").indices.items()}
|
|
108
|
+
rows = np.concatenate([by_pair[p] for p in pairs])
|
|
109
|
+
db = d.iloc[rows]
|
|
110
|
+
ex = [i for i, gg in zip(s1.id, s1.group) if gg == g]
|
|
111
|
+
dd, names = _design(db, sp["origin"], sp["frictions"], ex)
|
|
112
|
+
try:
|
|
113
|
+
m = _fit(pf, _fml(sp["flow"], names, sp["controls"], sp["origin"], sp["destination"], sp["year"]), dd, "iid")
|
|
114
|
+
except Exception:
|
|
115
|
+
return np.full((len(ex), len(sp["frictions"])), np.nan)
|
|
116
|
+
return _extract(m, ex, sp["frictions"], with_vcov=False)[0]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def pairs_bootstrap(s1: GravityStage1, data: pd.DataFrame, R=199, seed=20251025, min_valid=0.8) -> dict:
|
|
120
|
+
"""Pairs bootstrap (section 4.4.3): resamples the origin-destination pairs of
|
|
121
|
+
each group with replacement (each with all its years) and re-estimates the
|
|
122
|
+
model with its fixed effects. Keeps the replicates (R x n_g x K per group)."""
|
|
123
|
+
sp = s1.spec
|
|
124
|
+
d = _prepare(data, sp["origin"], sp["destination"], sp["frictions"])
|
|
125
|
+
K = len(sp["frictions"])
|
|
126
|
+
reps, V, valid = {}, np.full_like(s1.V, np.nan), np.full_like(s1.beta, np.nan)
|
|
127
|
+
groups = list(dict.fromkeys(s1.group))
|
|
128
|
+
for k, g in enumerate(groups):
|
|
129
|
+
pairs = np.array(sorted(d.loc[d[sp["group"]].astype(str) == g, "_pair"].unique()))
|
|
130
|
+
ex_idx = [i for i, gg in enumerate(s1.group) if gg == g]
|
|
131
|
+
arr = np.full((R, len(ex_idx), K), np.nan)
|
|
132
|
+
for r in range(R):
|
|
133
|
+
rng = np.random.default_rng(seed + 10000 * (k + 1) + r + 1)
|
|
134
|
+
arr[r] = refit_on_pairs(s1, data, g, pairs[rng.integers(len(pairs), size=len(pairs))])
|
|
135
|
+
reps[g] = arr
|
|
136
|
+
for j, i in enumerate(ex_idx):
|
|
137
|
+
M = arr[:, j, :]
|
|
138
|
+
valid[i] = np.isfinite(M).mean(0)
|
|
139
|
+
for a in range(K):
|
|
140
|
+
for b in range(K):
|
|
141
|
+
ok = np.isfinite(M[:, a]) & np.isfinite(M[:, b])
|
|
142
|
+
if ok.sum() >= min_valid * R and ok.sum() > 1:
|
|
143
|
+
V[i, a, b] = np.cov(M[ok, a], M[ok, b])[0, 1]
|
|
144
|
+
return {"replicates": reps, "V": V, "valid": valid, "R": R, "seed": seed}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def support_counts(data: pd.DataFrame, origin, destination, binary) -> pd.DataFrame:
|
|
148
|
+
"""Distinct destinations with value one and with value zero of each binary
|
|
149
|
+
friction (a destination whose value changes counts in both)."""
|
|
150
|
+
rows = []
|
|
151
|
+
for o, d in data.groupby(origin):
|
|
152
|
+
r = {"origin": str(o)}
|
|
153
|
+
for b in binary:
|
|
154
|
+
r[f"{b}_1"] = d.loc[d[b] > 0, destination].nunique()
|
|
155
|
+
r[f"{b}_0"] = d.loc[d[b] == 0, destination].nunique()
|
|
156
|
+
rows.append(r)
|
|
157
|
+
return pd.DataFrame(rows)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def identify_d6(beta, V, ids, counts=None, binary=(), se_max=2.0, min_dest=5, valid=None, min_valid=0.8,
|
|
161
|
+
components=None) -> np.ndarray:
|
|
162
|
+
"""Identification rule D6 (section 3.2.2). Returns a boolean (N, K) mask.
|
|
163
|
+
|
|
164
|
+
``valid`` (share of valid replicates) is an optional extra filter that is
|
|
165
|
+
not part of the rule of the thesis; leave it as ``None`` to apply that rule.
|
|
166
|
+
"""
|
|
167
|
+
beta = np.asarray(beta, float)
|
|
168
|
+
se = np.sqrt(np.maximum(np.einsum("ikk->ik", np.asarray(V, float)), 0))
|
|
169
|
+
ok = np.isfinite(beta) & np.isfinite(se) & (se < se_max)
|
|
170
|
+
if valid is not None:
|
|
171
|
+
ok &= np.isfinite(valid) & (np.asarray(valid) >= min_valid)
|
|
172
|
+
if len(binary) and counts is not None:
|
|
173
|
+
cs = counts.set_index("origin").reindex([str(i) for i in ids])
|
|
174
|
+
for b in binary:
|
|
175
|
+
j = components.index(b)
|
|
176
|
+
ok[:, j] &= (cs[f"{b}_1"].values >= min_dest) & (cs[f"{b}_0"].values >= min_dest)
|
|
177
|
+
return ok
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def as_me_data(s1: GravityStage1, boot: dict | None = None, identified=None) -> MEData:
|
|
181
|
+
"""Second-stage data from the first stage (bootstrap covariances and
|
|
182
|
+
replicates when ``boot`` is given)."""
|
|
183
|
+
beta = s1.beta.copy()
|
|
184
|
+
if identified is not None:
|
|
185
|
+
beta[~identified] = np.nan
|
|
186
|
+
if boot is None:
|
|
187
|
+
return MEData(beta, s1.V, s1.group, id=s1.id, components=s1.spec["frictions"])
|
|
188
|
+
return MEData(beta, boot["V"], s1.group, replicates=boot["replicates"], id=s1.id,
|
|
189
|
+
components=s1.spec["frictions"])
|