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.
@@ -0,0 +1,34 @@
1
+ """canbiplotme: canonical biplot for grouped estimates measured with error.
2
+
3
+ Python port of the R package ``gcbiplot`` (Gravity Canonical Biplot;
4
+ formerly ``canbiplotME``), Freire-Cadme (2026). The ``gcb*`` names are aliases
5
+ of the ``cbme*`` ones and match the R functions.
6
+ """
7
+ from .data import (MEData, me_data, deviation_matrix, replicate_array, select_components, subset_individuals,
8
+ error_correlation)
9
+ from .stage2 import CBMEFit, cbme
10
+ from .inference import simulate_coefs, cbme_test, boot_pvalue, cbme_influence, procrustes_align, holm, cbme_permutation_test
11
+ from .regions import cbme_regions, choose_region
12
+ from .simulation import cbme_simulate_tests, cbme_coverage
13
+ from .stage1 import (gravity_by_individual, pairs_bootstrap, support_counts, identify_d6, as_me_data,
14
+ refit_on_pairs)
15
+ from .dynamic import gravity_dynamic, dynamic_test, cbme_trajectory
16
+ from .reml import cbme_reml
17
+ from .meta import mvmeta
18
+ from .datasets import load_sim_me, load_sim_trade, load_gcb_trade, load_gcb_trade_a21
19
+ from .rng import RRNG
20
+
21
+ # names of the R package gcbiplot
22
+ gcb_data = me_data
23
+ gcb = cbme
24
+ gcb_test = cbme_test
25
+ gcb_influence = cbme_influence
26
+ gcb_regions = cbme_regions
27
+
28
+ __version__ = "0.3.0"
29
+ __all__ = ["MEData", "me_data", "deviation_matrix", "CBMEFit", "cbme", "simulate_coefs", "cbme_test",
30
+ "cbme_influence", "procrustes_align", "holm", "cbme_permutation_test", "cbme_regions", "choose_region",
31
+ "cbme_simulate_tests", "cbme_coverage", "gravity_by_individual", "pairs_bootstrap",
32
+ "support_counts", "identify_d6", "as_me_data", "refit_on_pairs", "gravity_dynamic",
33
+ "dynamic_test", "cbme_trajectory", "cbme_reml", "mvmeta", "load_sim_me", "load_sim_trade", "load_gcb_trade", "load_gcb_trade_a21", "RRNG", "boot_pvalue", "replicate_array",
34
+ "select_components", "subset_individuals", "error_correlation", "gcb_data", "gcb", "gcb_test", "gcb_influence", "gcb_regions"]
canbiplotme/data.py ADDED
@@ -0,0 +1,304 @@
1
+ """Input data: grouped estimates measured with error (port of ``me_data``)."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+
8
+ class MEData:
9
+ """Grouped estimates measured with error.
10
+
11
+ Parameters
12
+ ----------
13
+ beta : array-like (N, K)
14
+ Estimated coefficients; ``NaN`` marks a component that is not
15
+ identified for that individual (decision D3).
16
+ V : array-like (N, K, K) or list of (K, K), optional
17
+ Measurement-error covariance of each individual. Derived from the
18
+ replicates when omitted.
19
+ group : array-like (N,)
20
+ Group of each individual.
21
+ replicates : dict, optional
22
+ ``{group: array (R, n_g, K)}`` bootstrap replicates of the individuals of
23
+ each group (in the order in which they appear in ``beta``). They give
24
+ the joint error covariance of the group.
25
+ Omega : dict, optional
26
+ ``{group: array (n_g K, n_g K)}`` joint error covariance, ordered
27
+ individual-major. Ignored when ``replicates`` or ``D`` is given.
28
+ D : dict, optional
29
+ ``{group: array (R, n_g K)}`` matrices of replicate deviations (already
30
+ built); alternative to ``replicates``.
31
+ id : sequence of str, optional
32
+ Labels of the individuals.
33
+ components : sequence of str, optional
34
+ Names of the components.
35
+ levels : sequence, optional
36
+ Order of the groups (default: categorical order or sorted unique values).
37
+ error_blocks : array-like (N,), optional
38
+ Blocks of individuals whose errors are correlated, when they are not
39
+ the groups (e.g. one block when the first stage uses fixed effects
40
+ common to all individuals). ``replicates``, ``D`` and ``Omega`` are then
41
+ keyed by block, and pairwise comparisons include the covariance between
42
+ the means of groups of the same block.
43
+ """
44
+
45
+ def __init__(self, beta, V=None, group=None, replicates=None, Omega=None, D=None,
46
+ id=None, components=None, levels=None, error_blocks=None):
47
+ if isinstance(beta, pd.DataFrame):
48
+ if components is None:
49
+ components = list(beta.columns)
50
+ if id is None:
51
+ id = [str(i) for i in beta.index]
52
+ beta = beta.to_numpy()
53
+ beta = np.array(beta, dtype=float)
54
+ if beta.ndim == 1:
55
+ beta = beta[:, None]
56
+ N, K = beta.shape
57
+ self.components = list(components) if components is not None else [f"z{k + 1}" for k in range(K)]
58
+ self.id = list(id) if id is not None else [f"i{i + 1}" for i in range(N)]
59
+ if group is None:
60
+ raise ValueError("`group` is required.")
61
+ if levels is not None:
62
+ levs = [str(v) for v in levels]
63
+ elif isinstance(group, (pd.Categorical, pd.Series)) and isinstance(getattr(group, "dtype", None), pd.CategoricalDtype):
64
+ cat = pd.Categorical(group).remove_unused_categories()
65
+ levs = [str(v) for v in cat.categories]
66
+ else:
67
+ levs = sorted({str(v) for v in np.asarray(group, dtype=object)})
68
+ self.levels = levs
69
+ self.group = np.array([str(v) for v in np.asarray(group, dtype=object)], dtype=object)
70
+ if not set(self.group) <= set(levs):
71
+ raise ValueError("Some groups are not in `levels`.")
72
+ if len(self.group) != N:
73
+ raise ValueError("`group` must have one element per row of `beta`.")
74
+ self.N, self.K, self.G = N, K, len(self.levels)
75
+ self.block = None
76
+ if error_blocks is not None:
77
+ self.block = np.array([str(v) for v in np.asarray(error_blocks, dtype=object)], dtype=object)
78
+ if len(self.block) != N:
79
+ raise ValueError("`error_blocks` must have one element per row of `beta`.")
80
+ for lev in self.levels:
81
+ if len(set(self.block[self.group == lev])) > 1:
82
+ raise ValueError("Each group must lie in a single error block.")
83
+ self.D = None
84
+ self.Omega = None
85
+ if replicates is not None:
86
+ self.D = {}
87
+ for lev in self.block_levels:
88
+ r = np.asarray(replicates[lev], dtype=float)
89
+ idx = np.where(self.block_of == lev)[0]
90
+ if r.ndim != 3 or r.shape[1] != len(idx) or r.shape[2] != K:
91
+ raise ValueError(f"replicates[{lev!r}] must be an array (R, n_g, K).")
92
+ self.D[lev] = deviation_matrix(r, beta[idx])
93
+ elif D is not None:
94
+ self.D = {lev: np.asarray(D[lev], dtype=float) for lev in self.block_levels}
95
+ elif Omega is not None:
96
+ self.Omega = {lev: np.asarray(Omega[lev], dtype=float) for lev in self.block_levels}
97
+ if V is None:
98
+ if self.D is not None:
99
+ V = _V_from_blocks(lambda lev: self.D[lev].T @ self.D[lev] / (self.D[lev].shape[0] - 1), self)
100
+ elif self.Omega is not None:
101
+ V = _V_from_blocks(lambda lev: self.Omega[lev], self)
102
+ else:
103
+ raise ValueError("Provide `V`, `replicates`, `D` or `Omega`.")
104
+ V = np.array(V, dtype=float)
105
+ if V.shape != (N, K, K):
106
+ raise ValueError("`V` must have shape (N, K, K).")
107
+ self.V = V
108
+ vd = np.einsum("ikk->ik", V)
109
+ ok = np.isfinite(beta) & np.isfinite(vd) & (vd > 0)
110
+ beta = beta.copy()
111
+ beta[~ok] = np.nan
112
+ self.beta = beta
113
+ if np.any(pd.Series(self.group).value_counts() < 2):
114
+ import warnings
115
+ warnings.warn("Some groups have fewer than two individuals.")
116
+
117
+ # ------------------------------------------------------------------
118
+ @property
119
+ def has_joint(self) -> bool:
120
+ return self.D is not None or self.Omega is not None
121
+
122
+ @property
123
+ def block_of(self) -> np.ndarray:
124
+ """Error block of each individual (the group unless ``error_blocks`` was given)."""
125
+ return self.group if self.block is None else self.block
126
+
127
+ @property
128
+ def block_levels(self) -> list:
129
+ if self.block is None:
130
+ return list(self.levels)
131
+ return sorted(set(self.block.tolist())) # as R's factor(): sorted levels
132
+
133
+ def block_omega(self, b):
134
+ """Joint error covariance of an error block."""
135
+ if self.D is not None:
136
+ D = self.D[b]
137
+ return D.T @ D / (D.shape[0] - 1)
138
+ return self.Omega[b]
139
+
140
+ def group_omega(self, lev):
141
+ """Joint error covariance of a group (n_g K x n_g K)."""
142
+ if self.block is not None and self.has_joint:
143
+ b = self.block[self.group == lev][0]
144
+ inb = np.where(self.block == b)[0]
145
+ pos = np.searchsorted(inb, np.where(self.group == lev)[0])
146
+ cols = (pos[:, None] * self.K + np.arange(self.K)[None, :]).ravel()
147
+ return self.block_omega(b)[np.ix_(cols, cols)]
148
+ if self.D is not None:
149
+ D = self.D[lev]
150
+ return D.T @ D / (D.shape[0] - 1)
151
+ if self.Omega is not None:
152
+ return self.Omega[lev]
153
+ idx = np.where(self.group == lev)[0]
154
+ K = self.K
155
+ O = np.zeros((len(idx) * K, len(idx) * K))
156
+ for j, i in enumerate(idx):
157
+ v = np.nan_to_num(self.V[i], nan=0.0, posinf=0.0, neginf=0.0)
158
+ O[j * K:(j + 1) * K, j * K:(j + 1) * K] = v
159
+ return O
160
+
161
+ def identified_table(self) -> pd.DataFrame:
162
+ rows = []
163
+ for lev in self.levels:
164
+ b = self.beta[self.group == lev]
165
+ rows.append([b.shape[0]] + list(np.isfinite(b).sum(axis=0)))
166
+ return pd.DataFrame(rows, index=self.levels, columns=["n"] + self.components)
167
+
168
+ def __repr__(self) -> str:
169
+ err = ("joint (bootstrap replicates)" if self.D is not None else
170
+ "joint (Omega)" if self.Omega is not None else "independent")
171
+ return (f"<MEData> {self.N} individuals, {self.K} components, {self.G} groups\n"
172
+ f"Identified components per group:\n{self.identified_table()}\n"
173
+ f"Error correlation within groups: {err}")
174
+
175
+
176
+ def me_data(*args, **kwargs) -> MEData:
177
+ """Functional alias of :class:`MEData` (same name as in R)."""
178
+ return MEData(*args, **kwargs)
179
+
180
+
181
+ def deviation_matrix(r: np.ndarray, beta_g: np.ndarray) -> np.ndarray:
182
+ """Deviation matrix D_g (R x n_g K), individual-major columns.
183
+
184
+ Failed replicates (every coefficient missing) are dropped and R is the
185
+ number of valid replicates, as in the thesis (``omega_regimen``). Missing
186
+ coefficients of a replicate are set to zero after centring and the column
187
+ is rescaled by sqrt(R / R_valid), which keeps its variance and makes
188
+ D'D/(R-1) positive semidefinite.
189
+ """
190
+ r = np.asarray(r, float)
191
+ r = r[np.isfinite(r).reshape(r.shape[0], -1).any(axis=1)]
192
+ R, n, K = r.shape
193
+ D = np.zeros((R, n * K))
194
+ for i in range(n):
195
+ for k in range(K):
196
+ x = r[:, i, k]
197
+ ok = np.isfinite(x)
198
+ if not np.isfinite(beta_g[i, k]) or not ok.any():
199
+ continue
200
+ d = x - x[ok].mean()
201
+ d[~ok] = 0.0
202
+ D[:, i * K + k] = d * np.sqrt(R / ok.sum())
203
+ return D
204
+
205
+
206
+ def replicate_array(reps, id, components, name=lambda i, k: f"{i}_{k}") -> np.ndarray:
207
+ """Arrange replicate coefficient vectors (dicts or pandas Series keyed by
208
+ coefficient name; ``None`` = failed replicate) into an array (R, n_g, K)."""
209
+ reps = [r for r in reps if r is not None]
210
+ out = np.full((len(reps), len(id), len(components)), np.nan)
211
+ for j, r in enumerate(reps):
212
+ for a, i in enumerate(id):
213
+ for b, k in enumerate(components):
214
+ v = r.get(name(i, k), np.nan) if hasattr(r, "get") else np.nan
215
+ out[j, a, b] = np.nan if v is None else float(v)
216
+ return out
217
+
218
+
219
+ def select_components(x: "MEData", components) -> "MEData":
220
+ """Keep some components (with their covariance blocks and replicate
221
+ deviations) and drop individuals left without identified components."""
222
+ import copy
223
+ j = [x.components.index(c) if isinstance(c, str) else int(c) for c in components]
224
+ K = x.K
225
+ out = copy.copy(x)
226
+ out.__dict__ = dict(x.__dict__)
227
+ out.beta = x.beta[:, j]
228
+ out.V = x.V[:, j][:, :, j]
229
+ out.components = [x.components[k] for k in j]
230
+ out.K = len(j)
231
+
232
+ def cols(n):
233
+ return np.array([i * K + k for i in range(n) for k in j], dtype=int)
234
+ if x.D is not None:
235
+ out.D = {lev: D[:, cols(D.shape[1] // K)] for lev, D in x.D.items()}
236
+ if x.Omega is not None:
237
+ out.Omega = {lev: O[np.ix_(cols(O.shape[1] // K), cols(O.shape[1] // K))] for lev, O in x.Omega.items()}
238
+ keep = np.where(np.isfinite(out.beta).any(axis=1))[0]
239
+ if len(keep) < x.N:
240
+ out = subset_individuals(out, keep)
241
+ return out
242
+
243
+
244
+ def subset_individuals(x: "MEData", keep) -> "MEData":
245
+ """Keep some individuals (indices), with the matching replicate columns."""
246
+ import copy
247
+ keep = np.sort(np.asarray(keep, int))
248
+ out = copy.copy(x)
249
+ out.__dict__ = dict(x.__dict__)
250
+ K = x.K
251
+ blk = x.block_of
252
+ def cols(lev):
253
+ idx = np.where(blk == lev)[0]
254
+ pos = [p for p, i in enumerate(idx) if i in set(keep)]
255
+ return np.array([p * K + k for p in pos for k in range(K)], dtype=int)
256
+ if x.D is not None:
257
+ out.D = {lev: x.D[lev][:, cols(lev)] for lev in x.block_levels}
258
+ if x.Omega is not None:
259
+ out.Omega = {lev: x.Omega[lev][np.ix_(cols(lev), cols(lev))] for lev in x.block_levels}
260
+ if x.block is not None:
261
+ out.block = x.block[keep]
262
+ out.beta = x.beta[keep]; out.V = x.V[keep]; out.group = x.group[keep]
263
+ out.id = [x.id[i] for i in keep]; out.N = len(keep)
264
+ out.levels = [l for l in x.levels if np.any(out.group == l)]; out.G = len(out.levels)
265
+ if out.D is not None:
266
+ out.D = {l: out.D[l] for l in out.block_levels}
267
+ if out.Omega is not None:
268
+ out.Omega = {l: out.Omega[l] for l in out.block_levels}
269
+ return out
270
+
271
+
272
+ def _V_from_blocks(omega_of, x: MEData) -> np.ndarray:
273
+ V = np.full((x.N, x.K, x.K), np.nan)
274
+ for lev in x.block_levels:
275
+ Om = omega_of(lev)
276
+ idx = np.where(x.block_of == lev)[0]
277
+ for j, i in enumerate(idx):
278
+ V[i] = Om[j * x.K:(j + 1) * x.K, j * x.K:(j + 1) * x.K]
279
+ return V
280
+
281
+
282
+ def error_correlation(x: MEData) -> pd.DataFrame:
283
+ """Mean correlation of the errors and design effect of the simple mean, by
284
+ group and component (cells with at least two identified individuals)."""
285
+ if not x.has_joint:
286
+ raise ValueError("`x` has no joint error covariance.")
287
+ K = x.K
288
+ rows = []
289
+ for lev in x.levels:
290
+ O = x.group_omega(lev)
291
+ idx = np.where(x.group == lev)[0]
292
+ for k in range(K):
293
+ j = np.where(np.isfinite(x.beta[idx, k]))[0]
294
+ m = len(j)
295
+ if m < 2:
296
+ continue
297
+ r = j * K + k
298
+ Ok = O[np.ix_(r, r)]
299
+ sd = np.sqrt(np.diag(Ok))
300
+ C = Ok / np.outer(sd, sd)
301
+ rows.append({"group": lev, "component": x.components[k], "m": m,
302
+ "rho": float(C[np.triu_indices(m, 1)].mean()),
303
+ "deff": float((Ok.sum() / m ** 2) / (np.mean(np.diag(Ok)) / m))})
304
+ return pd.DataFrame(rows)
@@ -0,0 +1,56 @@
1
+ """Example datasets (simulated)."""
2
+ from importlib import resources
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+
8
+ def load_sim_me():
9
+ """Simulated second-stage data: 46 individuals, 4 components, 4 groups,
10
+ structurally missing components and 99 bootstrap-like replicates per group
11
+ (same data as ``data(sim_me)`` in the R package)."""
12
+ from ..data import MEData
13
+ with resources.files(__package__).joinpath("sim_me.npz").open("rb") as f:
14
+ z = np.load(f, allow_pickle=False)
15
+ levels = sorted(set(z["group"].tolist()))
16
+ D = {g: z[f"D_{g}"] for g in levels}
17
+ return MEData(z["beta"], z["V"], z["group"].tolist(), D=D, id=z["id"].tolist(),
18
+ components=z["components"].tolist())
19
+
20
+
21
+ def load_sim_trade() -> pd.DataFrame:
22
+ """Small simulated gravity panel (18 origins in 3 regimes, 36 destinations, 4 years)."""
23
+ with resources.files(__package__).joinpath("sim_trade.csv").open("r") as f:
24
+ return pd.read_csv(f)
25
+
26
+
27
+ def load_gcb_trade():
28
+ """Second-stage data of the thesis (same as ``data(gcb_trade)`` in R).
29
+
30
+ Gravity coefficients of 115 exporters in six trade regimes (CEPII Gravity
31
+ V202211 and BACI flows, 2010-2020): log distance, common official language,
32
+ colonial link, common religion and WTO trade agreement; bootstrap error
33
+ covariances, rule D6 already applied (non-identified components are
34
+ ``NaN``), and the deviation matrices of the 199 pairs-bootstrap replicates
35
+ of each regime, which give the joint error covariance within regimes.
36
+ """
37
+ from ..data import MEData
38
+ with resources.files(__package__).joinpath("gcb_trade.npz").open("rb") as f:
39
+ z = np.load(f, allow_pickle=False)
40
+ levels = z["levels"].tolist()
41
+ D = {g: z[f"D_{g}"] for g in levels}
42
+ return MEData(z["beta"], z["V"], z["group"].tolist(), D=D, id=z["id"].tolist(),
43
+ components=z["components"].tolist(), levels=levels)
44
+
45
+
46
+ def load_gcb_trade_a21():
47
+ """Analysis 21 of the thesis (same as ``data(gcb_trade_a21)`` in R): first
48
+ stage with importer-year fixed effects common to the 115 exporters, so the
49
+ errors are correlated across regimes (one error block with the deviation
50
+ matrix of the 199 pairs-bootstrap replicates of the whole sample)."""
51
+ from ..data import MEData
52
+ with resources.files(__package__).joinpath("gcb_trade_a21.npz").open("rb") as f:
53
+ z = np.load(f, allow_pickle=False)
54
+ return MEData(z["beta"], z["V"], z["group"].tolist(), D={"all": z["D_all"]}, id=z["id"].tolist(),
55
+ components=z["components"].tolist(), levels=z["levels"].tolist(),
56
+ error_blocks=z["block"].tolist())
Binary file
Binary file
Binary file