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/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)}
@@ -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