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/dynamic.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Dynamic extension (section 3.7): yearly group deviations, centred pairs
|
|
2
|
+
bootstrap test and trajectories in the reference canonical plane."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from .inference import holm
|
|
9
|
+
from .stage1 import _design, _fml, _pf, _prepare
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _year_design(d, year, frictions, years):
|
|
13
|
+
cols, names = {}, []
|
|
14
|
+
for t in years[1:]:
|
|
15
|
+
for f in frictions:
|
|
16
|
+
nm = f"d__{t}__{f}"
|
|
17
|
+
cols[nm] = np.where(d[year].values == t, d[f].values, 0.0)
|
|
18
|
+
names.append(nm)
|
|
19
|
+
return pd.concat([d.reset_index(drop=True), pd.DataFrame(cols)], axis=1), names
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _dyn_fit(pf, d, sp, years, vcov):
|
|
23
|
+
ex = sorted(d[sp["origin"]].unique())
|
|
24
|
+
dd, names = _design(d, sp["origin"], sp["frictions"], ex)
|
|
25
|
+
dd, ynames = _year_design(dd, sp["year"], sp["frictions"], years)
|
|
26
|
+
m = pf.fepois(_fml(sp["flow"], names, sp["controls"], sp["origin"], sp["destination"], sp["year"], ynames),
|
|
27
|
+
data=dd, vcov=vcov, iwls_maxiter=100)
|
|
28
|
+
coef = m.coef()
|
|
29
|
+
present = [n for n in ynames if n in coef.index and np.isfinite(coef[n])]
|
|
30
|
+
delta = np.zeros((len(years), len(sp["frictions"])))
|
|
31
|
+
for i, t in enumerate(years[1:], start=1):
|
|
32
|
+
for k, f in enumerate(sp["frictions"]):
|
|
33
|
+
nm = f"d__{t}__{f}"
|
|
34
|
+
delta[i, k] = coef[nm] if nm in present else np.nan
|
|
35
|
+
vc = pd.DataFrame(m._vcov, index=coef.index, columns=coef.index).loc[present, present].values
|
|
36
|
+
return delta, coef[present].values, vc, present
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def gravity_dynamic(data, flow, origin, destination, year, frictions, group, controls=None) -> dict:
|
|
40
|
+
"""beta_{i,t} = beta_i + delta_{g,t}, delta_{g,first year} = 0, by group."""
|
|
41
|
+
pf = _pf()
|
|
42
|
+
d0 = _prepare(data, origin, destination, frictions)
|
|
43
|
+
years = sorted(d0[year].unique())
|
|
44
|
+
sp = dict(flow=flow, origin=origin, destination=destination, year=year, frictions=list(frictions),
|
|
45
|
+
group=group, controls=list(controls or []), years=years)
|
|
46
|
+
out = {}
|
|
47
|
+
for g in sorted(d0[group].dropna().astype(str).unique()):
|
|
48
|
+
d = d0[d0[group].astype(str) == g]
|
|
49
|
+
delta, b, V, names = _dyn_fit(pf, d, sp, years, {"CRV1": "_pair"})
|
|
50
|
+
out[g] = {"delta": pd.DataFrame(delta, index=years, columns=frictions), "coef": b, "V": V, "names": names}
|
|
51
|
+
return {"groups": out, "spec": sp}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _wald(b, V):
|
|
55
|
+
return float(b @ np.linalg.solve(V, b))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def dynamic_test(dyn: dict, data, R=199, seed=20251025) -> pd.DataFrame:
|
|
59
|
+
"""Wald test of delta_{g,t} = 0 calibrated by a centred pairs bootstrap, Holm across groups."""
|
|
60
|
+
pf = _pf()
|
|
61
|
+
sp = dyn["spec"]
|
|
62
|
+
d0 = _prepare(data, sp["origin"], sp["destination"], sp["frictions"])
|
|
63
|
+
rows = []
|
|
64
|
+
for k, (g, e) in enumerate(dyn["groups"].items()):
|
|
65
|
+
W_obs = _wald(e["coef"], e["V"])
|
|
66
|
+
d = d0[d0[sp["group"]].astype(str) == g]
|
|
67
|
+
by_pair = d.groupby("_pair").indices
|
|
68
|
+
pairs = np.array(sorted(by_pair))
|
|
69
|
+
Ws = []
|
|
70
|
+
for r in range(R):
|
|
71
|
+
rng = np.random.default_rng(seed + 10000 * (k + 1) + r + 1)
|
|
72
|
+
s = pairs[rng.integers(len(pairs), size=len(pairs))]
|
|
73
|
+
db = d.iloc[np.concatenate([by_pair[p] for p in s])].copy()
|
|
74
|
+
db["_pair"] = db["_pair"] + "_" + np.repeat(np.arange(len(s)), [len(by_pair[p]) for p in s]).astype(str)
|
|
75
|
+
try:
|
|
76
|
+
_, b, V, names = _dyn_fit(pf, db, sp, sp["years"], {"CRV1": "_pair"})
|
|
77
|
+
except Exception:
|
|
78
|
+
Ws.append(np.nan)
|
|
79
|
+
continue
|
|
80
|
+
common = [n for n in names if n in e["names"]]
|
|
81
|
+
ib = [names.index(n) for n in common]
|
|
82
|
+
ie = [e["names"].index(n) for n in common]
|
|
83
|
+
Ws.append(_wald(b[ib] - e["coef"][ie], V[np.ix_(ib, ib)]))
|
|
84
|
+
Ws = np.array(Ws)
|
|
85
|
+
rows.append({"group": g, "W": W_obs, "df": len(e["coef"]),
|
|
86
|
+
"p": (1 + np.nansum(Ws >= W_obs)) / (1 + np.isfinite(Ws).sum()),
|
|
87
|
+
"R_valid": int(np.isfinite(Ws).sum())})
|
|
88
|
+
out = pd.DataFrame(rows)
|
|
89
|
+
out["p_holm"] = holm(out["p"].values)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cbme_trajectory(fit, dyn) -> dict:
|
|
94
|
+
"""a_g(t) = V2'(mu_g + delta_{g,t} - mean_t delta_{g,t} - mbar) (Definition 3.7.1)."""
|
|
95
|
+
deltas = {g: e["delta"].values for g, e in dyn["groups"].items()} if "groups" in dyn else dyn
|
|
96
|
+
V2 = fit.V[:, :fit.A.shape[1]]
|
|
97
|
+
coords, disp, path = {}, {}, {}
|
|
98
|
+
for gi, g in enumerate(fit.data.levels):
|
|
99
|
+
dlt = np.nan_to_num(np.asarray(deltas[g], float))
|
|
100
|
+
mt = dlt - dlt.mean(0) + fit.mu[gi]
|
|
101
|
+
a = (mt - fit.mbar) @ V2
|
|
102
|
+
coords[g] = a
|
|
103
|
+
disp[g] = float(np.sqrt(((a[-1] - a[0]) ** 2).sum()))
|
|
104
|
+
path[g] = float(np.sqrt((np.diff(a, axis=0) ** 2).sum(1)).sum())
|
|
105
|
+
return {"coords": coords, "displacement": pd.Series(disp), "path_length": pd.Series(path)}
|
canbiplotme/inference.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Parametric bootstrap under the null (G0), pairwise tests and influence."""
|
|
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 .stage2 import CBMEFit, fit_core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _rng(seed):
|
|
12
|
+
from .rng import RRNG
|
|
13
|
+
return seed if isinstance(seed, (np.random.Generator, RRNG)) else np.random.default_rng(seed)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sqrt_psd(S: np.ndarray) -> np.ndarray:
|
|
17
|
+
S = np.asarray(S, float)
|
|
18
|
+
ev, evec = np.linalg.eigh((S + S.T) / 2)
|
|
19
|
+
return evec @ np.diag(np.sqrt(np.maximum(ev, 0.0)))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _prepare(fit: CBMEFit):
|
|
23
|
+
x = fit.data
|
|
24
|
+
if x.D is None and x.Omega is not None and "Omega_sqrt" not in fit.cache:
|
|
25
|
+
fit.cache["Omega_sqrt"] = {lev: sqrt_psd(x.Omega[lev]) for lev in x.block_levels}
|
|
26
|
+
return fit
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def simulate_coefs(fit: CBMEFit, means="common", errors: str = "joint", deviations: str = "normal",
|
|
30
|
+
scale: float = 1.0, rng=None) -> np.ndarray:
|
|
31
|
+
"""Simulate coefficient vectors from the fitted two-level model (Definition 3.6.1).
|
|
32
|
+
|
|
33
|
+
``means='common'`` gives the generator G0 (null hypothesis); ``'group'`` uses
|
|
34
|
+
the estimated group means (deviations from the common mean multiplied by
|
|
35
|
+
``scale``); a (G, K) array gives arbitrary means. ``errors`` is ``'joint'``,
|
|
36
|
+
``'independent'`` (G0-ind) or ``'replicate'`` (one random bootstrap replicate
|
|
37
|
+
per group); ``deviations`` is ``'normal'`` or ``'t4'``.
|
|
38
|
+
"""
|
|
39
|
+
rng = _rng(rng)
|
|
40
|
+
_prepare(fit)
|
|
41
|
+
x = fit.data
|
|
42
|
+
K, N = x.K, x.N
|
|
43
|
+
if not x.has_joint and errors != "independent":
|
|
44
|
+
errors = "independent"
|
|
45
|
+
if errors == "replicate" and x.D is None:
|
|
46
|
+
raise ValueError("errors='replicate' needs bootstrap replicates.")
|
|
47
|
+
gi = np.array([x.levels.index(g) for g in x.group])
|
|
48
|
+
if isinstance(means, np.ndarray):
|
|
49
|
+
M = means[gi]
|
|
50
|
+
elif means == "common":
|
|
51
|
+
M = np.tile(fit.mu0, (N, 1))
|
|
52
|
+
else:
|
|
53
|
+
M = (fit.mu0 + scale * (fit.mu - fit.mu0))[gi]
|
|
54
|
+
# deviations u* = L z with T + 1e-10 I = L L' (Cholesky), drawn individual by
|
|
55
|
+
# individual, then the errors group by group in the order of the levels:
|
|
56
|
+
# the order of the code of the thesis (rev_funciones.R::generar), so that
|
|
57
|
+
# with canbiplotme.rng.RRNG the replicates are those of R.
|
|
58
|
+
Lt = np.linalg.cholesky(fit.T + 1e-10 * np.eye(K))
|
|
59
|
+
if deviations == "normal":
|
|
60
|
+
Z = rng.standard_normal((N, K))
|
|
61
|
+
else:
|
|
62
|
+
Z = rng.standard_t(4, (N, K)) / np.sqrt(2)
|
|
63
|
+
U = Z @ Lt.T
|
|
64
|
+
E = np.zeros((N, K))
|
|
65
|
+
ok = np.isfinite(x.beta)
|
|
66
|
+
blk = x.block_of
|
|
67
|
+
for lev in x.block_levels: # error blocks (the groups by default)
|
|
68
|
+
idx = np.where(blk == lev)[0]
|
|
69
|
+
if not len(idx):
|
|
70
|
+
continue
|
|
71
|
+
if errors == "independent":
|
|
72
|
+
for i in idx:
|
|
73
|
+
k = np.where(ok[i])[0]
|
|
74
|
+
if not len(k):
|
|
75
|
+
continue
|
|
76
|
+
ev, evec = np.linalg.eigh(x.V[i][np.ix_(k, k)])
|
|
77
|
+
ev, evec = ev[::-1], evec[:, ::-1]
|
|
78
|
+
E[i, k] = evec @ (np.sqrt(np.maximum(ev, 0.0)) * rng.standard_normal(len(k)))
|
|
79
|
+
continue
|
|
80
|
+
if not ok[idx].any():
|
|
81
|
+
continue
|
|
82
|
+
if x.D is not None:
|
|
83
|
+
D = x.D[lev]
|
|
84
|
+
R = D.shape[0]
|
|
85
|
+
e = (D[rng.integers(R)] * np.sqrt(R / (R - 1)) if errors == "replicate"
|
|
86
|
+
else D.T @ rng.standard_normal(R) / np.sqrt(R - 1))
|
|
87
|
+
else:
|
|
88
|
+
Sq = fit.cache["Omega_sqrt"][lev]
|
|
89
|
+
e = Sq @ rng.standard_normal(Sq.shape[1])
|
|
90
|
+
E[idx] = e.reshape(len(idx), K)
|
|
91
|
+
B = M + U + E
|
|
92
|
+
B[~np.isfinite(x.beta)] = np.nan
|
|
93
|
+
return B
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def refit(fit: CBMEFit, beta, rows=None, fill_missing: bool = True):
|
|
97
|
+
rows = np.arange(fit.data.N) if rows is None else np.asarray(rows)
|
|
98
|
+
try:
|
|
99
|
+
return fit_core(fit.data, beta, rows, fit.settings, fit.cache, fill_missing)
|
|
100
|
+
except np.linalg.LinAlgError:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _with_core(fit: CBMEFit, core: dict, beta=None) -> CBMEFit:
|
|
105
|
+
import copy
|
|
106
|
+
f = copy.copy(fit)
|
|
107
|
+
f.__dict__ = dict(fit.__dict__)
|
|
108
|
+
f._update(core)
|
|
109
|
+
if beta is not None:
|
|
110
|
+
d = copy.copy(fit.data)
|
|
111
|
+
d.__dict__ = dict(fit.data.__dict__)
|
|
112
|
+
d.beta = beta
|
|
113
|
+
f.data = d
|
|
114
|
+
return f
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestResult:
|
|
118
|
+
def __init__(self, **kw):
|
|
119
|
+
self.__dict__.update(kw)
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> str:
|
|
122
|
+
s = [f"Null-hypothesis parametric bootstrap (generator {'G0' if self.errors == 'joint' else 'G0-ind'}), "
|
|
123
|
+
f"{self.B_valid} valid replicates" + (f" ({self.failed} failed)" if self.failed else ""), "",
|
|
124
|
+
"Equality of group means",
|
|
125
|
+
pd.DataFrame({"observed": [self.observed["trace"], self.observed["lambda1"]],
|
|
126
|
+
"p": [self.global_["trace"], self.global_["lambda1"]]},
|
|
127
|
+
index=["trace", "largest root"]).round(4).to_string(), "",
|
|
128
|
+
"Pairwise comparisons (Holm)", self.pairs.round(4).to_string(index=False), "",
|
|
129
|
+
f"{int(self.pairs['different'].sum())} of {len(self.pairs)} pairs differ"]
|
|
130
|
+
if self.composition is not None:
|
|
131
|
+
c = self.composition
|
|
132
|
+
s.append(f"\nComposition of the first dimension: S = {c['S']:.4f} (95th percentile under H0 "
|
|
133
|
+
f"{c['q_S']:.4f}, p = {c['p_S']:.4f}); S_plane = {c['S_plane']:.4f}; rule holds: {c['holds']}")
|
|
134
|
+
return "\n".join(s)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def holm(p) -> np.ndarray:
|
|
138
|
+
"""Holm adjustment (same as R ``p.adjust(method = 'holm')``)."""
|
|
139
|
+
p = np.asarray(p, float)
|
|
140
|
+
m = len(p)
|
|
141
|
+
o = np.argsort(p, kind="stable")
|
|
142
|
+
adj = np.minimum(1.0, np.maximum.accumulate((m - np.arange(m)) * p[o]))
|
|
143
|
+
out = np.empty(m)
|
|
144
|
+
out[o] = adj
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def boot_pvalue(obs, ref) -> float:
|
|
149
|
+
"""(1 + #{r: t*_r >= t}) / (1 + R), with R the number of finite reference values."""
|
|
150
|
+
ref = np.asarray(ref, float)
|
|
151
|
+
fin = np.isfinite(ref)
|
|
152
|
+
return float((1 + np.sum(ref[fin] >= obs)) / (1 + fin.sum()))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cbme_test(fit: CBMEFit, B: int = 1999, errors: str = "joint", S_min: float = 0.5,
|
|
156
|
+
level: float = 0.95, seed=None, keep: bool = True, r_compatible: bool = True) -> TestResult:
|
|
157
|
+
"""Tests by null-hypothesis parametric bootstrap (sections 3.6.2-3.6.5).
|
|
158
|
+
|
|
159
|
+
Equality of the group means (trace and largest root), every pair of groups
|
|
160
|
+
(Mahalanobis D2 with Holm correction) and the composition of the first
|
|
161
|
+
dimension (statistic S). The second stage is re-estimated in every replicate.
|
|
162
|
+
|
|
163
|
+
With an integer ``seed`` and ``r_compatible=True`` (default), replicate ``b``
|
|
164
|
+
is generated with ``canbiplotme.rng.RRNG(seed + b)``, the generator of R
|
|
165
|
+
after ``set.seed(seed + b)``: the replicates are those of
|
|
166
|
+
``gcbiplot::gcb_test`` and of the code of the thesis. Otherwise a
|
|
167
|
+
``numpy.random.Generator`` is used.
|
|
168
|
+
"""
|
|
169
|
+
from .rng import RRNG
|
|
170
|
+
_prepare(fit)
|
|
171
|
+
if r_compatible and seed is not None and not isinstance(seed, np.random.Generator):
|
|
172
|
+
gen = lambda b: RRNG(int(seed) + b)
|
|
173
|
+
else:
|
|
174
|
+
rng = _rng(seed)
|
|
175
|
+
gen = lambda b: rng
|
|
176
|
+
reps = []
|
|
177
|
+
for b in range(1, B + 1):
|
|
178
|
+
core = refit(fit, simulate_coefs(fit, "common", errors, rng=gen(b)))
|
|
179
|
+
if core is not None:
|
|
180
|
+
reps.append(core)
|
|
181
|
+
Bv = len(reps)
|
|
182
|
+
keys = list(fit.stats.keys())
|
|
183
|
+
S_star = pd.DataFrame([[c["stats"][k] for k in keys] for c in reps], columns=keys)
|
|
184
|
+
D_star = np.array([c["D2"].values for c in reps])
|
|
185
|
+
glob = {"trace": boot_pvalue(fit.stats["trace"], S_star["trace"].values),
|
|
186
|
+
"lambda1": boot_pvalue(fit.stats["lambda1"], S_star["lambda1"].values)}
|
|
187
|
+
p_pair = np.array([boot_pvalue(fit.D2.values[j], D_star[:, j]) for j in range(len(fit.D2))])
|
|
188
|
+
lo, hi = [], []
|
|
189
|
+
for j in range(len(fit.D2)):
|
|
190
|
+
r = D_star[:, j]; n = int(np.isfinite(r).sum()); c = int(np.sum(r[np.isfinite(r)] >= fit.D2.values[j]))
|
|
191
|
+
ci = sps.binomtest(c, n).proportion_ci(confidence_level=0.95, method="exact")
|
|
192
|
+
lo.append((1 + ci.low * n) / (1 + n)); hi.append((1 + ci.high * n) / (1 + n))
|
|
193
|
+
dfk = int(np.isfinite(np.diag(fit.mu_cov[0])).sum()) if hasattr(fit, "mu_cov") else fit.W.shape[0]
|
|
194
|
+
pairs = pd.DataFrame({"pair": fit.D2.index, "D2": fit.D2.values, "p": p_pair, "p_mc_low": lo,
|
|
195
|
+
"p_mc_high": hi, "p_holm": holm(p_pair),
|
|
196
|
+
"p_chisq": sps.chi2.sf(fit.D2.values, dfk)})
|
|
197
|
+
pairs["different"] = pairs["p_holm"] < 1 - level
|
|
198
|
+
comp = None
|
|
199
|
+
if "S" in fit.stats:
|
|
200
|
+
qS = float(np.nanquantile(S_star["S"].values, level))
|
|
201
|
+
S = fit.stats["S"]
|
|
202
|
+
comp = {"S": S, "S_plane": fit.stats["S_plane"], "q_S": qS, "p_S": boot_pvalue(S, S_star["S"].values),
|
|
203
|
+
"holds": bool(S > qS and (S_min is None or np.isnan(S_min) or S >= S_min))}
|
|
204
|
+
return TestResult(global_=glob, pairs=pairs, composition=comp, B=B, B_valid=Bv, failed=B - Bv,
|
|
205
|
+
errors=errors, level=level, observed=fit.stats, seed=seed,
|
|
206
|
+
reference={"stats": S_star, "D2": D_star} if keep else None)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def procrustes_align(X: np.ndarray, Y: np.ndarray) -> np.ndarray:
|
|
210
|
+
"""Rotate X to best match Y (orthogonal Procrustes, no translation or scaling)."""
|
|
211
|
+
u, _, vt = np.linalg.svd(X.T @ Y)
|
|
212
|
+
return X @ (u @ vt)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def jack_rho(fit: CBMEFit, beta: np.ndarray) -> np.ndarray:
|
|
216
|
+
N = beta.shape[0]
|
|
217
|
+
A = fit.A
|
|
218
|
+
out = np.full(N, np.nan)
|
|
219
|
+
allrows = np.arange(N)
|
|
220
|
+
for i in range(N):
|
|
221
|
+
keep = np.delete(allrows, i)
|
|
222
|
+
f = refit(fit, beta[keep], rows=keep)
|
|
223
|
+
if f is None or np.any(f["n"] == 0):
|
|
224
|
+
continue
|
|
225
|
+
out[i] = np.sqrt(np.sum((f["A"] - procrustes_align(A, f["A"])) ** 2)) / np.sqrt(np.sum(A ** 2))
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cbme_influence(fit: CBMEFit, B_ref: int = 200, level: float = 0.95, seed=None) -> dict:
|
|
230
|
+
"""Jackknife influence rho_(i) and reference of rho_max (section 3.6.6)."""
|
|
231
|
+
rng = _rng(seed)
|
|
232
|
+
_prepare(fit)
|
|
233
|
+
rho = jack_rho(fit, fit.data.beta)
|
|
234
|
+
ref = None
|
|
235
|
+
if B_ref > 0:
|
|
236
|
+
ref = []
|
|
237
|
+
for _ in range(B_ref):
|
|
238
|
+
bs = simulate_coefs(fit, "group", rng=rng)
|
|
239
|
+
core = refit(fit, bs)
|
|
240
|
+
if core is None:
|
|
241
|
+
ref.append(np.nan)
|
|
242
|
+
continue
|
|
243
|
+
f2 = _with_core(fit, core)
|
|
244
|
+
ref.append(np.nanmax(jack_rho(f2, bs)))
|
|
245
|
+
ref = np.array(ref)
|
|
246
|
+
rmax = np.nanmax(rho)
|
|
247
|
+
q = float(np.nanquantile(ref, level)) if ref is not None else np.nan
|
|
248
|
+
p = (1 + np.nansum(ref >= rmax)) / (1 + np.isfinite(ref).sum()) if ref is not None else np.nan
|
|
249
|
+
tab = pd.DataFrame({"id": fit.data.id, "group": fit.data.group, "rho": rho}).sort_values("rho", ascending=False)
|
|
250
|
+
return {"rho": tab, "rho_max": rmax, "reference": ref, "q": q, "p": p, "excessive": bool(rmax > q)}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cbme_permutation_test(fit: CBMEFit, B: int = 999, seed=None) -> dict:
|
|
254
|
+
"""Permutation test of the equality of group means (section 3.6.1).
|
|
255
|
+
|
|
256
|
+
Permutes the group labels and re-estimates the second stage. Exact only
|
|
257
|
+
under exchangeability; liberal when the measurement error differs between
|
|
258
|
+
groups. Comparator of :func:`cbme_test`."""
|
|
259
|
+
import copy
|
|
260
|
+
from .stage2 import Settings
|
|
261
|
+
rng = _rng(seed)
|
|
262
|
+
x = fit.data
|
|
263
|
+
st = copy.copy(fit.settings)
|
|
264
|
+
st.T_errors = st.mean_errors = "independent"
|
|
265
|
+
xp = copy.copy(x)
|
|
266
|
+
xp.__dict__ = dict(x.__dict__)
|
|
267
|
+
xp.D = None
|
|
268
|
+
xp.Omega = None
|
|
269
|
+
cache = {"pos": fit.cache["pos"], "Omega": None}
|
|
270
|
+
ref = []
|
|
271
|
+
for _ in range(B):
|
|
272
|
+
xp.group = rng.permutation(x.group)
|
|
273
|
+
try:
|
|
274
|
+
f = fit_core(xp, x.beta, np.arange(x.N), st, cache)
|
|
275
|
+
ref.append((f["stats"]["trace"], f["stats"]["lambda1"]))
|
|
276
|
+
except np.linalg.LinAlgError:
|
|
277
|
+
pass
|
|
278
|
+
ref = np.array(ref)
|
|
279
|
+
obs = (fit.stats["trace"], fit.stats["lambda1"])
|
|
280
|
+
return {"trace": (1 + np.sum(ref[:, 0] >= obs[0] - 1e-12)) / (1 + len(ref)),
|
|
281
|
+
"lambda1": (1 + np.sum(ref[:, 1] >= obs[1] - 1e-12)) / (1 + len(ref))}
|
canbiplotme/meta.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Multivariate random-effects meta-analysis and meta-regression (REML / ML),
|
|
2
|
+
the Python counterpart of ``mixmeta``/``mvmeta`` and of ``metafor::rma.mv``
|
|
3
|
+
with an unstructured between-study covariance.
|
|
4
|
+
|
|
5
|
+
Model for study i with outcome vector y_i (missing outcomes allowed):
|
|
6
|
+
y_i = (I_K kron x_i') beta + b_i + e_i, b_i ~ N(0, Psi), e_i ~ N(0, S_i)
|
|
7
|
+
with S_i known.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from scipy import optimize, stats as sps
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _theta_to_psi(th, K):
|
|
17
|
+
L = np.zeros((K, K)); L[np.tril_indices(K)] = th
|
|
18
|
+
L[np.diag_indices(K)] = np.exp(L[np.diag_indices(K)])
|
|
19
|
+
return L @ L.T
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _psi_to_theta(P, K):
|
|
23
|
+
L = np.linalg.cholesky(P + 1e-8 * np.eye(K))
|
|
24
|
+
L[np.diag_indices(K)] = np.log(np.diag(L))
|
|
25
|
+
return L[np.tril_indices(K)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _prep(y, S, X):
|
|
29
|
+
n, K = y.shape
|
|
30
|
+
rows = []
|
|
31
|
+
for i in range(n):
|
|
32
|
+
ok = np.where(np.isfinite(y[i]))[0]
|
|
33
|
+
if not len(ok):
|
|
34
|
+
continue
|
|
35
|
+
Xi = np.kron(np.eye(K), X[i][None, :])[ok]
|
|
36
|
+
rows.append((ok, y[i, ok], S[i][np.ix_(ok, ok)], Xi))
|
|
37
|
+
return rows
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ll(th, rows, K, p, reml, logdet_xx=0.0):
|
|
41
|
+
Psi = _theta_to_psi(th, K)
|
|
42
|
+
XtVX = np.zeros((p, p)); XtVy = np.zeros(p); ld = 0.0; nobs = 0
|
|
43
|
+
chol = []
|
|
44
|
+
for ok, yi, Si, Xi in rows:
|
|
45
|
+
try:
|
|
46
|
+
c = np.linalg.cholesky(Si + Psi[np.ix_(ok, ok)])
|
|
47
|
+
except np.linalg.LinAlgError:
|
|
48
|
+
return -np.inf, None, None
|
|
49
|
+
ld += 2 * np.log(np.diag(c)).sum(); nobs += len(ok)
|
|
50
|
+
Xc = np.linalg.solve(c, Xi); yc = np.linalg.solve(c, yi)
|
|
51
|
+
XtVX += Xc.T @ Xc; XtVy += Xc.T @ yc
|
|
52
|
+
chol.append((Xc, yc))
|
|
53
|
+
b = np.linalg.solve(XtVX, XtVy)
|
|
54
|
+
q = sum(np.sum((yc - Xc @ b) ** 2) for Xc, yc in chol)
|
|
55
|
+
if reml:
|
|
56
|
+
# + 0.5 log|X'X| as in mixmeta and metafor
|
|
57
|
+
ll = -0.5 * ((nobs - p) * np.log(2 * np.pi) + ld + np.linalg.slogdet(XtVX)[1] + q) + 0.5 * logdet_xx
|
|
58
|
+
else:
|
|
59
|
+
ll = -0.5 * (nobs * np.log(2 * np.pi) + ld + q)
|
|
60
|
+
return ll, b, XtVX
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def mvmeta(y, S, mods=None, method: str = "reml", outcome_names=None, Psi0=None) -> dict:
|
|
64
|
+
"""Fit the multivariate meta-analysis / meta-regression.
|
|
65
|
+
|
|
66
|
+
``y``: n x K estimates (NaN for missing outcomes); ``S``: n x K x K
|
|
67
|
+
within-study covariances (or n x K variances, taken as diagonal);
|
|
68
|
+
``mods``: n x q moderators (an intercept is added). ``method``: 'reml',
|
|
69
|
+
'ml' or 'fixed'. Returns coefficients (q+1 x K), their covariance, Psi,
|
|
70
|
+
log-likelihood, AIC/BIC, Cochran's multivariate Q test of heterogeneity and
|
|
71
|
+
the I^2 statistic."""
|
|
72
|
+
y = np.asarray(y, float)
|
|
73
|
+
n, K = y.shape
|
|
74
|
+
S = np.asarray(S, float)
|
|
75
|
+
if S.ndim == 2:
|
|
76
|
+
S = np.stack([np.diag(s) for s in S])
|
|
77
|
+
X = np.ones((n, 1)) if mods is None else np.column_stack([np.ones(n), np.asarray(mods, float).reshape(n, -1)])
|
|
78
|
+
q1 = X.shape[1]; p = q1 * K
|
|
79
|
+
rows = _prep(y, S, X)
|
|
80
|
+
nobs = sum(len(r[0]) for r in rows)
|
|
81
|
+
# fixed-effects fit and Q statistic
|
|
82
|
+
Xlong = np.vstack([r[3] for r in rows])
|
|
83
|
+
th_fixed = np.zeros((K, K)); th_fixed[np.diag_indices(K)] = -40.0
|
|
84
|
+
th_fixed = th_fixed[np.tril_indices(K)]
|
|
85
|
+
ll_f, b_f, XtVX_f = _ll(th_fixed, rows, K, p, False)
|
|
86
|
+
Q = 0.0
|
|
87
|
+
for ok, yi, Si, Xi in rows:
|
|
88
|
+
r = yi - Xi @ b_f; Q += r @ np.linalg.solve(Si, r)
|
|
89
|
+
dfQ = nobs - p
|
|
90
|
+
if method == "fixed":
|
|
91
|
+
Psi, b, XtVX, ll, conv = np.zeros((K, K)), b_f, XtVX_f, ll_f, True
|
|
92
|
+
else:
|
|
93
|
+
reml = method == "reml"
|
|
94
|
+
if Psi0 is None:
|
|
95
|
+
yc = np.where(np.isfinite(y), y, np.nan)
|
|
96
|
+
v = np.nanvar(yc, axis=0, ddof=1) - np.nanmean(np.stack([np.diag(s) for s in S]), 0)
|
|
97
|
+
Psi0 = np.diag(np.maximum(v, 0.1 * np.nanvar(yc, axis=0, ddof=1)))
|
|
98
|
+
lxx = np.linalg.slogdet(Xlong.T @ Xlong)[1] if reml else 0.0
|
|
99
|
+
f = lambda th: -_ll(th, rows, K, p, reml, lxx)[0]
|
|
100
|
+
th0 = _psi_to_theta(np.asarray(Psi0, float), K)
|
|
101
|
+
r1 = optimize.minimize(f, th0, method="BFGS", options={"gtol": 1e-9, "maxiter": 5000})
|
|
102
|
+
r2 = optimize.minimize(f, r1.x, method="Nelder-Mead", options={"xatol": 1e-10, "fatol": 1e-13, "maxiter": 40000})
|
|
103
|
+
best = r2 if r2.fun < r1.fun else r1
|
|
104
|
+
ll, b, XtVX = _ll(best.x, rows, K, p, reml, lxx)
|
|
105
|
+
Psi, conv = _theta_to_psi(best.x, K), bool(r1.success or r2.success)
|
|
106
|
+
names = outcome_names or [f"y{k + 1}" for k in range(K)]
|
|
107
|
+
terms = ["(Intercept)"] + [f"x{j}" for j in range(1, q1)]
|
|
108
|
+
vc = np.linalg.inv(XtVX)
|
|
109
|
+
coef = pd.DataFrame(b.reshape(K, q1).T, index=terms, columns=names)
|
|
110
|
+
se = pd.DataFrame(np.sqrt(np.diag(vc)).reshape(K, q1).T, index=terms, columns=names)
|
|
111
|
+
o = [k * q1 + j for j in range(q1) for k in range(K)] # term-major, as mixmeta
|
|
112
|
+
lab = [f"{t}.{nm}" for t in terms for nm in names]
|
|
113
|
+
vc = pd.DataFrame(vc[np.ix_(o, o)], index=lab, columns=lab)
|
|
114
|
+
npar = p + (K * (K + 1) // 2 if method != "fixed" else 0)
|
|
115
|
+
return {"coef": coef, "se": se, "vcov": vc, "Psi": pd.DataFrame(Psi, index=names, columns=names),
|
|
116
|
+
"logLik": ll, "AIC": -2 * ll + 2 * npar, "BIC": -2 * ll + np.log(nobs - (p if method == "reml" else 0)) * npar,
|
|
117
|
+
"Q": Q, "df_Q": dfQ, "p_Q": sps.chi2.sf(Q, dfQ), "I2": max(0.0, (Q - dfQ) / Q) * 100,
|
|
118
|
+
"method": method, "nobs": nobs, "converged": conv}
|
canbiplotme/plot.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Plot of the canonical biplot (matplotlib)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def plot_cbme(fit, regions=None, region="R3", individuals=True, arrows=True, colors=None, ax=None,
|
|
8
|
+
arrow_scale=None, labels_components=None):
|
|
9
|
+
"""Group markers, component arrows, individuals and (optionally) one region per group."""
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
if ax is None:
|
|
12
|
+
_, ax = plt.subplots(figsize=(7, 6.5))
|
|
13
|
+
A = fit.A
|
|
14
|
+
levs = fit.data.levels
|
|
15
|
+
if colors is None:
|
|
16
|
+
cmap = plt.get_cmap("tab10")
|
|
17
|
+
colors = {g: cmap(i % 10) for i, g in enumerate(levs)}
|
|
18
|
+
pols = {}
|
|
19
|
+
if regions is not None:
|
|
20
|
+
rr = {g: region for g in levs} if isinstance(region, str) else region
|
|
21
|
+
pols = {g: regions["regions"][rr[g]][g]["polygon"] for g in levs}
|
|
22
|
+
pts = [A] + ([fit.ind] if individuals and len(fit.ind) else []) + [p for p in pols.values() if p is not None]
|
|
23
|
+
lim = np.abs(np.vstack(pts)).max() * 1.05
|
|
24
|
+
if individuals and len(fit.ind):
|
|
25
|
+
gi = [fit.data.group[fit.data.id.index(i)] for i in fit.ind_ids]
|
|
26
|
+
ax.scatter(fit.ind[:, 0], fit.ind[:, 1], s=12, facecolors="none",
|
|
27
|
+
edgecolors=[colors[g] for g in gi], alpha=0.6)
|
|
28
|
+
for g_i, g in enumerate(levs):
|
|
29
|
+
if pols.get(g) is not None:
|
|
30
|
+
ax.fill(pols[g][:, 0], pols[g][:, 1], color=colors[g], alpha=0.12)
|
|
31
|
+
ax.plot(pols[g][:, 0], pols[g][:, 1], color=colors[g], lw=1.5)
|
|
32
|
+
ax.scatter(A[g_i, 0], A[g_i, 1], color=colors[g], s=50, zorder=3)
|
|
33
|
+
ax.annotate(g, (A[g_i, 0], A[g_i, 1]), textcoords="offset points", xytext=(0, 6), ha="center",
|
|
34
|
+
color=colors[g], fontweight="bold", fontsize=9)
|
|
35
|
+
if arrows:
|
|
36
|
+
C = fit.C
|
|
37
|
+
esc = arrow_scale if arrow_scale is not None else 0.8 * lim / np.sqrt((C ** 2).sum(1)).max()
|
|
38
|
+
names = labels_components or fit.data.components
|
|
39
|
+
for k in range(C.shape[0]):
|
|
40
|
+
ax.annotate("", xy=(esc * C[k, 0], esc * C[k, 1]), xytext=(0, 0),
|
|
41
|
+
arrowprops=dict(arrowstyle="->", color="0.3"))
|
|
42
|
+
ax.text(esc * C[k, 0] * 1.12, esc * C[k, 1] * 1.12, names[k], color="0.3", fontsize=8, ha="center")
|
|
43
|
+
ax.axhline(0, ls=":", color="0.6"); ax.axvline(0, ls=":", color="0.6")
|
|
44
|
+
ax.set_xlim(-lim, lim); ax.set_ylim(-lim, lim); ax.set_aspect("equal")
|
|
45
|
+
lam = fit.lambda_
|
|
46
|
+
ax.set_xlabel(f"Dimension 1 ({100 * lam[0] / lam.sum():.0f}%)")
|
|
47
|
+
ax.set_ylabel(f"Dimension 2 ({100 * lam[1] / lam.sum():.0f}%)")
|
|
48
|
+
return ax
|
canbiplotme/regions.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Confidence regions R1-R4 for the group markers (section 3.5)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy import stats as sps
|
|
6
|
+
|
|
7
|
+
from .inference import _rng, procrustes_align, refit
|
|
8
|
+
from .stage2 import CBMEFit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ellipse_polygon(center, S, q, n=100):
|
|
12
|
+
th = np.linspace(0, 2 * np.pi, n)
|
|
13
|
+
ev, evec = np.linalg.eigh(S)
|
|
14
|
+
ev, evec = ev[::-1], evec[:, ::-1]
|
|
15
|
+
P = np.column_stack([np.cos(th), np.sin(th)]) @ (evec @ np.diag(np.sqrt(np.maximum(ev, 0) * q))).T
|
|
16
|
+
return P + np.asarray(center)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def polygon_area(P):
|
|
20
|
+
x, y = P[:, 0], P[:, 1]
|
|
21
|
+
return abs(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y)) / 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cbme_regions(fit: CBMEFit, types=("R1", "R2", "R3", "R4"), level=0.95, B=999, npoints=100, seed=None) -> dict:
|
|
25
|
+
"""Regions R1 (circle), R2 (Krzanowski ellipse), R3 (Hotelling type with
|
|
26
|
+
measurement error) and R4 (bootstrap of individuals aligned by Procrustes).
|
|
27
|
+
|
|
28
|
+
Returns ``{"regions": {type: {group: {"S", "q", "polygon", "area"}}}, "level", "A"}``.
|
|
29
|
+
"""
|
|
30
|
+
if fit.A.shape[1] != 2:
|
|
31
|
+
raise ValueError("Regions are defined for the plane (dim = 2).")
|
|
32
|
+
rng = _rng(seed)
|
|
33
|
+
G, K, N = fit.A.shape[0], fit.W.shape[0], fit.n.sum()
|
|
34
|
+
levs = fit.data.levels
|
|
35
|
+
q2 = sps.chi2.ppf(level, 2)
|
|
36
|
+
out = {}
|
|
37
|
+
if "R1" in types:
|
|
38
|
+
out["R1"] = [{"S": np.eye(2) / fit.n[g], "q": q2} for g in range(G)]
|
|
39
|
+
if "R2" in types:
|
|
40
|
+
kk = G - 1
|
|
41
|
+
s = min(kk, K)
|
|
42
|
+
gam = kk / (N - G)
|
|
43
|
+
th = 1 + fit.lambda_[:s]
|
|
44
|
+
xi = fit.Afull[:, :s]
|
|
45
|
+
R2 = []
|
|
46
|
+
for g in range(G):
|
|
47
|
+
v = []
|
|
48
|
+
for l in range(2):
|
|
49
|
+
ex = sum(th[l] * (th[j] + gam * th[l]) / (gam * (th[j] - th[l]) ** 2) * xi[g, j] ** 2
|
|
50
|
+
for j in range(s) if j != l)
|
|
51
|
+
v.append(1 / fit.n[g] + (0.5 * xi[g, l] ** 2 + ex) / (N - G))
|
|
52
|
+
cc = -th[0] * th[1] * (1 + gam) / ((N - G) * gam * (th[0] - th[1]) ** 2) * xi[g, 0] * xi[g, 1]
|
|
53
|
+
R2.append({"S": np.array([[v[0], cc], [cc, v[1]]]), "q": q2})
|
|
54
|
+
out["R2"] = R2
|
|
55
|
+
if "R3" in types:
|
|
56
|
+
V2 = fit.V[:, :2]
|
|
57
|
+
out["R3"] = [{"S": V2.T @ fit.mu_cov[g] @ V2,
|
|
58
|
+
"q": 2 * (fit.n[g] - 1) / (fit.n[g] - 2) * sps.f.ppf(level, 2, fit.n[g] - 2)
|
|
59
|
+
if fit.n[g] > 2 else np.nan} for g in range(G)]
|
|
60
|
+
if "R4" in types:
|
|
61
|
+
x = fit.data
|
|
62
|
+
reps = []
|
|
63
|
+
for _ in range(B):
|
|
64
|
+
idx = np.concatenate([rng.choice(np.where(x.group == lev)[0], size=np.sum(x.group == lev), replace=True)
|
|
65
|
+
for lev in levs])
|
|
66
|
+
core = refit(fit, x.beta[idx], rows=idx)
|
|
67
|
+
if core is not None:
|
|
68
|
+
reps.append(procrustes_align(core["A"], fit.A))
|
|
69
|
+
reps = np.array(reps)
|
|
70
|
+
out["R4"] = [{"S": np.cov(reps[:, g, :].T), "q": q2} for g in range(G)]
|
|
71
|
+
res = {}
|
|
72
|
+
for t, lst in out.items():
|
|
73
|
+
res[t] = {}
|
|
74
|
+
for g, r in enumerate(lst):
|
|
75
|
+
r["polygon"] = ellipse_polygon(fit.A[g], r["S"], r["q"], npoints) if np.isfinite(r["q"]) else None
|
|
76
|
+
r["area"] = polygon_area(r["polygon"]) if r["polygon"] is not None else np.nan
|
|
77
|
+
res[t][levs[g]] = r
|
|
78
|
+
return {"regions": res, "level": level, "A": fit.A, "levels": levs}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def choose_region(regions: dict, coverage, min_cov: float = 0.90) -> dict:
|
|
82
|
+
"""Rule of section 3.5.5: coverage closest to the nominal level without
|
|
83
|
+
falling below ``min_cov``; otherwise the region with the largest area.
|
|
84
|
+
``coverage`` is a DataFrame (groups x region types)."""
|
|
85
|
+
out = {}
|
|
86
|
+
for g in regions["levels"]:
|
|
87
|
+
row = coverage.loc[g]
|
|
88
|
+
ok = row[row >= min_cov]
|
|
89
|
+
if len(ok):
|
|
90
|
+
out[g] = (ok - regions["level"]).abs().idxmin()
|
|
91
|
+
else:
|
|
92
|
+
areas = {t: regions["regions"][t][g]["area"] for t in regions["regions"]}
|
|
93
|
+
out[g] = max(areas, key=areas.get)
|
|
94
|
+
return out
|