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/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"])