biplotpy 0.4.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.
biplotpy/distances.py ADDED
@@ -0,0 +1,383 @@
1
+ """Distance-based biplots: proximities, principal coordinates (PCoA),
2
+ metric/non-metric MDS by SMACOF, external logistic biplots and canonical
3
+ analysis of distances.
4
+
5
+ * ``binary_distances`` / ``continuous_distances`` / ``gower_distances`` —
6
+ the coefficients of ``MultBiplotR::BinaryDistances`` and
7
+ ``ContinuousDistances`` (with three defects of the R code corrected, see
8
+ ``FIXES``).
9
+ * ``pcoa`` — ``MultBiplotR::PrincipalCoordinates`` (= ``ade4::dudi.pco``).
10
+ * ``mds`` — symmetric SMACOF of ``smacof::mds`` (ratio, interval, ordinal
11
+ with primary/secondary/tertiary ties).
12
+ * ``ridge_logistic`` / ``external_logistic_biplot`` —
13
+ ``MultBiplotR::RidgeBinaryLogistic`` and ``ExternalBinaryLogisticBiplot``:
14
+ binary variables projected onto a PCoA/MDS configuration by penalized
15
+ logistic regression.
16
+ * ``canonical_distance_analysis`` — ``MultBiplotR::CanonicalDistanceAnalysis``.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ from scipy import stats as sps
23
+
24
+ from .correspondence import OrdinationBiplot
25
+
26
+ BINARY_COEFFICIENTS = ["Kulczynski", "Russell_and_Rao", "Jaccard", "Simple_Matching", "Anderberg",
27
+ "Rogers_and_Tanimoto", "Sorensen_Dice_and_Czekanowski", "Sneath_and_Sokal", "Hamman",
28
+ "Kulczynski2", "Anderberg2", "Ochiai", "S13", "Pearson_phi", "Yule", "Sorensen", "Dice"]
29
+ TRANSFORMATIONS = ["Identity", "1-S", "sqrt(1-S)", "-log(s)", "1/S-1", "sqrt(2(1-S))", "1-(S+1)/2", "1-abs(S)",
30
+ "1/(S+1)"]
31
+
32
+
33
+ def binary_similarity(X, Y=None, coefficient="Jaccard"):
34
+ X = np.asarray(X, float)
35
+ Y = X if Y is None else np.asarray(Y, float)
36
+ a = Y @ X.T; b = Y @ (1 - X).T; c = (1 - Y) @ X.T; d = (1 - Y) @ (1 - X).T
37
+ with np.errstate(divide="ignore", invalid="ignore"):
38
+ S = {"Kulczynski": a / (b + c), "Russell_and_Rao": a / (a + b + c + d), "Jaccard": a / (a + b + c),
39
+ "Simple_Matching": (a + d) / (a + b + c + d), "Anderberg": a / (a + 2 * (b + c)),
40
+ "Rogers_and_Tanimoto": (a + d) / (a + 2 * (b + c) + d),
41
+ "Sorensen_Dice_and_Czekanowski": a / (a + 0.5 * (b + c)),
42
+ "Sneath_and_Sokal": (a + d) / (a + 0.5 * (b + c) + d), "Hamman": (a - (b + c) + d) / (a + b + c + d),
43
+ "Kulczynski2": 0.5 * (a / (a + b) + a / (a + c)),
44
+ "Anderberg2": 0.25 * (a / (a + b) + a / (a + c) + d / (c + d) + d / (b + d)),
45
+ "Ochiai": a / np.sqrt((a + b) * (a + c)), "S13": a * d / np.sqrt((a + b) * (a + c) * (d + b) * (d + c)),
46
+ "Pearson_phi": (a * d - b * c) / np.sqrt((a + b) * (a + c) * (d + b) * (d + c)),
47
+ "Yule": (a * d - b * c) / (a * d + b * c), "Sorensen": 2 * a / (2 * a + b + c),
48
+ "Dice": 2 * a / (2 * a + b + c)}[coefficient]
49
+ return S
50
+
51
+
52
+ def _transform_similarity(S, transformation):
53
+ f = {"Identity": lambda: S, "1-S": lambda: 1 - S, "sqrt(1-S)": lambda: np.sqrt(np.maximum(1 - S, 0)),
54
+ "-log(s)": lambda: -np.log(S), "1/S-1": lambda: 1 / S - 1,
55
+ "sqrt(2(1-S))": lambda: np.sqrt(np.maximum(2 * (1 - S), 0)), "1-(S+1)/2": lambda: 1 - (S + 1) / 2,
56
+ "1-abs(S)": lambda: 1 - np.abs(S), "1/(S+1)": lambda: 1 / (S + 1)}
57
+ with np.errstate(divide="ignore", invalid="ignore"):
58
+ return f[transformation]()
59
+
60
+
61
+ def binary_distances(X, Y=None, coefficient="Jaccard", transformation=None):
62
+ """Dissimilarities between the rows of a 0/1 matrix (``Y`` rows against
63
+ ``X`` rows when given). Default transformation as MultBiplotR:
64
+ sqrt(1-S) for most coefficients, 1-(S+1)/2 for Hamman, phi and Yule,
65
+ 1/(S+1) for Kulczynski."""
66
+ if isinstance(coefficient, int):
67
+ coefficient = BINARY_COEFFICIENTS[coefficient - 1]
68
+ if transformation is None:
69
+ transformation = {"Kulczynski": "1/(S+1)", "Hamman": "1-(S+1)/2", "Pearson_phi": "1-(S+1)/2",
70
+ "Yule": "1-(S+1)/2"}.get(coefficient, "sqrt(1-S)")
71
+ return _transform_similarity(binary_similarity(X, Y, coefficient), transformation)
72
+
73
+
74
+ def continuous_distances(X, Y=None, coef="Pythagorean", r=1.0):
75
+ """Dissimilarities between rows (``MultBiplotR::ContinuousDistances``)."""
76
+ X = np.asarray(X, float)
77
+ Y = X if Y is None else np.asarray(Y, float)
78
+ Dif = Y[:, None, :] - X[None, :, :]
79
+ Sm = Y[:, None, :] + X[None, :, :]
80
+ with np.errstate(divide="ignore", invalid="ignore"):
81
+ if coef == "Pythagorean":
82
+ return np.sqrt((Dif ** 2).sum(2))
83
+ if coef == "Taxonomic":
84
+ return np.sqrt((Dif ** 2 / r ** 2).sum(2))
85
+ if coef == "City":
86
+ return np.abs(Dif).sum(2)
87
+ if coef == "Minkowski":
88
+ return (np.abs(Dif) ** r).sum(2) ** (1 / r)
89
+ if coef == "Divergence":
90
+ return np.sqrt((Dif ** 2 / Sm ** 2).sum(2))
91
+ if coef == "dif_sum":
92
+ return (np.abs(Dif) / np.abs(Sm)).sum(2)
93
+ if coef == "Canberra":
94
+ return (np.abs(Dif) / (np.abs(Y)[:, None, :] + np.abs(X)[None, :, :])).sum(2)
95
+ if coef == "Bray_Curtis":
96
+ return np.abs(Dif).sum(2) / Sm.sum(2)
97
+ if coef == "Soergel":
98
+ return np.abs(Dif).sum(2) / np.maximum(Y[:, None, :], X[None, :, :]).sum(2)
99
+ if coef == "Ware_Hedges":
100
+ return (1 - np.minimum(Y[:, None, :], X[None, :, :]) / np.maximum(Y[:, None, :], X[None, :, :])).sum(2)
101
+ if coef == "Gower":
102
+ rng = np.vstack([X, Y]).max(0) - np.vstack([X, Y]).min(0)
103
+ return (np.abs(Dif) / rng).sum(2)
104
+ raise ValueError(f"unknown coefficient {coef}")
105
+
106
+
107
+ def gower_distances(df: pd.DataFrame):
108
+ """Gower dissimilarity (1 - similarity) for mixed numeric / categorical data."""
109
+ n = len(df)
110
+ S = np.zeros((n, n)); W = np.zeros((n, n))
111
+ for col in df.columns:
112
+ v = df[col]
113
+ ok = v.notna().to_numpy()
114
+ w = np.outer(ok, ok).astype(float)
115
+ if pd.api.types.is_numeric_dtype(v) and v.nunique() > 2:
116
+ x = v.to_numpy(float); rng = np.nanmax(x) - np.nanmin(x)
117
+ s = 1 - np.abs(x[:, None] - x[None, :]) / rng
118
+ else:
119
+ x = v.astype(str).to_numpy()
120
+ s = (x[:, None] == x[None, :]).astype(float)
121
+ S += np.nan_to_num(s) * w; W += w
122
+ return 1 - S / W
123
+
124
+
125
+ # --------------------------------------------------------------------------- PCoA
126
+ def pcoa(D, dim=2, weights=None, row_names=None, sup_distances=None) -> OrdinationBiplot:
127
+ """Principal coordinates of a dissimilarity matrix (Gower, 1966).
128
+
129
+ Weighted version with the correct weighted centring (I - 1 w') on both
130
+ sides. Fit statistics: stress-1 and -2, s-stress, R2 and Spearman's rho
131
+ between the dissimilarities and the configuration distances."""
132
+ D = np.asarray(D, float)
133
+ n = D.shape[0]
134
+ w = np.full(n, 1 / n) if weights is None else np.asarray(weights, float) / np.sum(weights)
135
+ J = np.eye(n) - np.outer(np.ones(n), w)
136
+ b = -0.5 * J @ D ** 2 @ J.T
137
+ Bw = np.sqrt(w)[:, None] * b * np.sqrt(w)[None, :]
138
+ ev, U = np.linalg.eigh(Bw)
139
+ ev, U = ev[::-1], U[:, ::-1]
140
+ pos = ev > 1e-10 * ev[0]
141
+ g = U[:, pos] * np.sqrt(ev[pos]) / np.sqrt(w)[:, None]
142
+ qual = g ** 2 / (g ** 2).sum(1, keepdims=True) * 100
143
+ G = g[:, :dim]
144
+ iu = np.triu_indices(n, 1)
145
+ Dh = D[iu]
146
+ Dc = np.sqrt(((G[:, None] - G[None]) ** 2).sum(2))[iu]
147
+ res = OrdinationBiplot(title="Principal Coordinates Analysis", type="PCoA", dim=dim, eigenvalues=ev,
148
+ inertia=ev / ev.sum() * 100, row_coordinates=G, row_qualities=qual[:, :dim],
149
+ negative_eigenvalues=ev[ev < -1e-10 * ev[0]], centered_products=b,
150
+ stress1=np.sqrt(np.sum((Dc - Dh) ** 2) / np.sum(Dc ** 2)),
151
+ stress2=np.sqrt(np.sum((Dc - Dh) ** 2) / np.sum((Dc - Dc.mean()) ** 2)),
152
+ sstress1=np.sqrt(np.sum((Dc ** 2 - Dh ** 2) ** 2) / np.sum(Dc ** 4)),
153
+ rsq=np.corrcoef(Dh, Dc)[0, 1] ** 2, rho=sps.spearmanr(Dh, Dc)[0],
154
+ row_names=row_names or [f"I{i + 1}" for i in range(n)], col_coordinates=None)
155
+ if sup_distances is not None:
156
+ Ds = np.asarray(sup_distances, float)
157
+ sc = 0.5 * (np.diag(b)[None, :] - Ds ** 2) @ g / ev[pos]
158
+ res.sup_row_coordinates = sc[:, :dim]
159
+ return res
160
+
161
+
162
+ # --------------------------------------------------------------------------- SMACOF (symmetric)
163
+ def _pava(y, w):
164
+ """Weighted isotonic (non-decreasing) regression by pool-adjacent-violators."""
165
+ vals, wts, cnt = [], [], []
166
+ for yi, wi in zip(y, w):
167
+ vals.append(yi); wts.append(wi); cnt.append(1)
168
+ while len(vals) > 1 and vals[-2] > vals[-1]:
169
+ ws = wts[-2] + wts[-1]
170
+ v = (vals[-2] * wts[-2] + vals[-1] * wts[-1]) / ws if ws > 0 else (vals[-2] + vals[-1]) / 2
171
+ c = cnt[-2] + cnt[-1]
172
+ vals[-2:] = [v]; wts[-2:] = [ws]; cnt[-2:] = [c]
173
+ return np.repeat(vals, cnt)
174
+
175
+
176
+ def _torgerson(D, p):
177
+ n = D.shape[0]
178
+ D2 = D ** 2
179
+ B = -0.5 * (D2 - D2.mean(1)[:, None] - D2.mean(0)[None, :] + D2.mean())
180
+ ev, U = np.linalg.eigh(B)
181
+ ev, U = ev[::-1], U[:, ::-1]
182
+ return U[:, :p] * np.sqrt(np.maximum(ev[:p], 0))
183
+
184
+
185
+ def mds(D, dim=2, type="ratio", ties="primary", weights=None, init=None, itmax=1000, eps=1e-6,
186
+ row_names=None) -> OrdinationBiplot:
187
+ """Symmetric SMACOF (``smacof::mds``): metric (ratio, interval) or
188
+ non-metric (ordinal) multidimensional scaling with Torgerson start.
189
+ Returns the configuration, disparities, stress-1 and stress per point."""
190
+ D = np.asarray(D, float)
191
+ n = D.shape[0]
192
+ iu = np.triu_indices(n, 1)
193
+ diss = D.T[np.tril_indices(n, -1)] # column-major lower triangle, as R's dist
194
+ li, lj = np.tril_indices(n, -1)
195
+ order_idx = np.lexsort((li, lj)) # R stores dist by columns
196
+ li, lj, diss = li[order_idx], lj[order_idx], D[li[order_idx], lj[order_idx]]
197
+ m = len(diss)
198
+ W = np.ones((n, n)) if weights is None else np.asarray(weights, float)
199
+ wg = W[li, lj]
200
+ nn = n * (n - 1) / 2
201
+
202
+ def dist(x):
203
+ return np.sqrt(((x[li] - x[lj]) ** 2).sum(1))
204
+
205
+ def normq(v):
206
+ return v * np.sqrt(nn / np.sum(wg * v ** 2))
207
+
208
+ x = _torgerson(D, dim) if init is None else np.asarray(init, float)
209
+ dhat = normq(diss)
210
+ V = -np.zeros((n, n)); V[li, lj] = -wg; V[lj, li] = -wg
211
+ V[np.diag_indices(n)] = -V.sum(1)
212
+ Vinv = np.linalg.inv(V + 1 / n) - 1 / n
213
+ d = dist(x)
214
+ lb = np.sum(wg * d * dhat) / np.sum(wg * d ** 2)
215
+ x, d = lb * x, lb * d
216
+ sold = np.sum(wg * (dhat - d) ** 2) / nn
217
+ # tie structure of the dissimilarities
218
+ iord = np.lexsort((np.arange(m), diss))
219
+ xs = diss[iord]
220
+ starts = np.r_[0, np.where(np.diff(xs) != 0)[0] + 1]
221
+ blocks = np.split(iord, starts[1:])
222
+ xu = xs[starts]
223
+ itel = 1
224
+ while True:
225
+ z = d < 1e-12
226
+ bv = wg * dhat * (~z) / (d + z)
227
+ Bm = np.zeros((n, n)); Bm[li, lj] = -bv; Bm[lj, li] = -bv
228
+ Bm[np.diag_indices(n)] = -Bm.sum(1)
229
+ y = Vinv @ (Bm @ x)
230
+ e = dist(y)
231
+ if type == "ratio":
232
+ new = diss.copy()
233
+ elif type == "interval":
234
+ yb = np.array([np.sum(wg[b] * e[b]) / np.sum(wg[b]) for b in blocks])
235
+ wb = np.array([np.sum(wg[b]) for b in blocks])
236
+ Xb = np.column_stack([np.ones(len(xu)), xu - xu[0]])
237
+ coef = np.linalg.solve(Xb.T @ (wb[:, None] * Xb), Xb.T @ (wb * yb))
238
+ fitb = Xb @ coef
239
+ new = np.empty(m)
240
+ for b, v in zip(blocks, fitb):
241
+ new[b] = v
242
+ elif type == "ordinal" and ties == "primary":
243
+ o = np.lexsort((e, diss))
244
+ new = np.empty(m); new[o] = _pava(e[o], wg[o])
245
+ elif type == "ordinal":
246
+ yb = np.array([np.sum(wg[b] * e[b]) / np.sum(wg[b]) for b in blocks])
247
+ wb = np.array([np.sum(wg[b]) for b in blocks])
248
+ ycon = _pava(yb, wb)
249
+ new = np.empty(m)
250
+ for b, v, ym in zip(blocks, ycon, yb):
251
+ new[b] = v if ties == "secondary" else e[b] + (v - ym)
252
+ else:
253
+ raise ValueError("type must be ratio, interval or ordinal")
254
+ dhat = normq(new)
255
+ snon = np.sum(wg * (dhat - e) ** 2) / nn
256
+ if (sold - snon) < eps or itel == itmax:
257
+ break
258
+ x, d, sold = y, e, snon
259
+ itel += 1
260
+ Dh = np.zeros((n, n)); Dh[li, lj] = dhat; Dh = Dh + Dh.T
261
+ Dc = np.sqrt(((y[:, None] - y[None]) ** 2).sum(2))
262
+ res_mat = W * (Dh - Dc) ** 2
263
+ np.fill_diagonal(res_mat, np.nan)
264
+ spp = np.nanmean(res_mat, 0); spp = spp / spp.sum() * 100
265
+ return OrdinationBiplot(title=f"MDS ({type})", type="MDS", dim=dim, row_coordinates=y, stress=np.sqrt(snon),
266
+ disparities=Dh, config_distances=Dc, spp=spp, niter=itel,
267
+ eigenvalues=np.var(y, axis=0) * n, inertia=np.var(y, axis=0) / np.var(y, axis=0).sum() * 100,
268
+ row_names=row_names or [f"I{i + 1}" for i in range(n)], col_coordinates=None)
269
+
270
+
271
+ # --------------------------------------------------------------------------- logistic regression on a configuration
272
+ def ridge_logistic(y, X, penalization=0.2, intercept=True, tol=1e-5, maxiter=100):
273
+ """Ridge-penalized binary logistic regression by Newton-Raphson
274
+ (``MultBiplotR::RidgeBinaryLogistic``): maximizes loglik - lambda * sum(beta_j^2),
275
+ intercept not penalized. Returns coefficients and fit statistics."""
276
+ y = np.asarray(y, float).ravel()
277
+ X = np.asarray(X, float)
278
+ if X.ndim == 1:
279
+ X = X[:, None]
280
+ n = len(y)
281
+ Xd = np.column_stack([np.ones(n), X]) if intercept else X
282
+
283
+ def fit(Xm):
284
+ m = Xm.shape[1]
285
+ beta = np.zeros(m)
286
+ Im = np.eye(m); Im[0, 0] = 0
287
+ for _ in range(maxiter):
288
+ mu = 1 / (1 + np.exp(-(Xm @ beta)))
289
+ In = Xm.T @ ((mu * (1 - mu))[:, None] * Xm) + 2 * penalization * Im
290
+ U = Xm.T @ (y - mu) - 2 * penalization * Im @ beta
291
+ new = beta + np.linalg.pinv(In) @ U
292
+ err = np.sum(np.abs(new - beta)); beta = new
293
+ if err <= tol:
294
+ break
295
+ return beta
296
+ b0 = fit(np.ones((n, 1)))
297
+ beta = fit(Xd)
298
+ p0 = 1 / (1 + np.exp(-b0[0])); p = 1 / (1 + np.exp(-(Xd @ beta)))
299
+ dev0 = -2 * np.sum(y * np.log(p0) + (1 - y) * np.log(1 - p0))
300
+ dev = -2 * np.sum(y * np.log(p) + (1 - y) * np.log(1 - p))
301
+ dif = dev0 - dev; df = Xd.shape[1] - 1
302
+ cs = 1 - np.exp(-dif / n)
303
+ pred = (p >= 0.5).astype(float)
304
+ return {"beta": beta, "fitted": p, "deviance": dev, "null_deviance": dev0, "dif": dif, "df": df,
305
+ "p": sps.chi2.sf(dif, df), "cox_snell": cs, "nagelkerke": cs / (1 - np.exp(-dev0 / 2) ** (2 / n)),
306
+ "mcfadden": 1 - dev / dev0, "R2": 1 - np.sum((y - p) ** 2) / np.sum(y ** 2),
307
+ "percent_correct": np.mean(pred == y)}
308
+
309
+
310
+ def external_logistic_biplot(coords, Xbin, penalization=0.2, col_names=None, row_names=None) -> OrdinationBiplot:
311
+ """External binary logistic biplot: each binary variable is fitted by a
312
+ ridge logistic regression on a given configuration (PCoA, MDS...), giving
313
+ its direction in the plane (``MultBiplotR::ExternalBinaryLogisticBiplot``)."""
314
+ A = np.asarray(coords, float)
315
+ Xb = np.asarray(Xbin, float)
316
+ p = Xb.shape[1]
317
+ fits = [ridge_logistic(Xb[:, j], A, penalization) for j in range(p)]
318
+ B = np.array([f["beta"] for f in fits])
319
+ dd = np.sqrt(1 + (B[:, 1:] ** 2).sum(1))
320
+ info = pd.DataFrame({"Deviance": [f["dif"] for f in fits], "df": [f["df"] for f in fits],
321
+ "p-value": [f["p"] for f in fits],
322
+ "Bonferroni": np.minimum([f["p"] * p for f in fits], 1),
323
+ "Nagelkerke": [f["nagelkerke"] for f in fits], "R2": [f["R2"] for f in fits],
324
+ "% correct": [f["percent_correct"] * 100 for f in fits]},
325
+ index=col_names or [f"V{j + 1}" for j in range(p)])
326
+ return OrdinationBiplot(title="External Binary Logistic Biplot", type="ExternalLogistic", dim=A.shape[1],
327
+ row_coordinates=A, col_parameters=B, col_coordinates=B[:, 1:], loadings=B[:, 1:] / dd[:, None],
328
+ var_info=info, total_percent=np.mean([f["percent_correct"] for f in fits]) * 100,
329
+ eigenvalues=np.var(A, axis=0), inertia=np.var(A, axis=0) / np.var(A, axis=0).sum() * 100,
330
+ row_names=row_names or [f"I{i + 1}" for i in range(A.shape[0])],
331
+ col_names=list(info.index), data=Xb)
332
+
333
+
334
+ # --------------------------------------------------------------------------- canonical analysis of distances
335
+ def canonical_distance_analysis(D, groups, dim=2, nperm=999, weighted=False, X=None, seed=None) -> OrdinationBiplot:
336
+ """Canonical analysis of distances (Gower & Krzanowski, 1999; port of
337
+ ``MultBiplotR::CanonicalDistanceAnalysis``): pseudo-F (distance-based
338
+ ANOVA) with permutation p-value, PCoA of the group means and projection of
339
+ the individuals (Euclidean distances on ``X``, Gower's add-a-point formula)."""
340
+ D = np.asarray(D, float)
341
+ g = np.asarray([str(v) for v in groups])
342
+ levels = list(groups.cat.categories) if hasattr(groups, "cat") else sorted(set(g))
343
+ levels = [str(v) for v in levels]
344
+ G = np.column_stack([(g == l).astype(float) for l in levels])
345
+ ng = G.sum(0); n = len(g); k = len(levels)
346
+ D2 = 0.5 * D ** 2
347
+
348
+ def stats(Dm):
349
+ Fm = (G.T @ Dm @ G) / np.outer(ng, ng)
350
+ f = np.diag(Fm)
351
+ DE = 0.5 * (2 * Fm - f[:, None] - f[None, :])
352
+ tss = Dm.sum() / n
353
+ bss = ng @ DE @ ng / n
354
+ return Fm, tss, bss
355
+ Fm, tss, bss = stats(D2)
356
+ wss = tss - bss
357
+ Fexp = (bss / (k - 1)) / (wss / (n - k))
358
+ rng = np.random.default_rng(seed)
359
+ perm = np.empty(nperm)
360
+ for i in range(nperm):
361
+ o = rng.permutation(n)
362
+ _, _, b = stats(D2[np.ix_(o, o)])
363
+ perm[i] = (b / (k - 1)) / ((tss - b) / (n - k))
364
+ H = np.eye(k) - (np.outer(np.ones(k), ng) / n if weighted else np.ones((k, k)) / k)
365
+ B = -H @ Fm @ H.T
366
+ ev, U = np.linalg.eigh((B + B.T) / 2)
367
+ ev, U = ev[::-1], U[:, ::-1]
368
+ Y = U * np.sqrt(np.maximum(ev, 0))
369
+ d0 = (Y ** 2).sum(1)
370
+ qual = Y[:, :dim] ** 2 / d0[:, None] * 100
371
+ res = OrdinationBiplot(title="Canonical Analysis of Distances", type="CDA", dim=dim, F=Fexp, TSS=tss, BSS=bss,
372
+ WSS=wss, df=(k - 1, n - k), p_value=float(np.mean(perm > Fexp)), nperm=nperm,
373
+ eigenvalues=ev, inertia=ev / ev.sum() * 100, group_coordinates=Y[:, :dim],
374
+ group_qualities=qual, group_names=levels, groups=g, row_coordinates=None,
375
+ col_coordinates=None)
376
+ if X is not None:
377
+ Xm = np.asarray(X, float)
378
+ means = (G.T @ Xm) / ng[:, None]
379
+ Di = continuous_distances(means, Xm) ** 2
380
+ Yd = Y[:, :dim]
381
+ Yi = -0.5 * np.linalg.solve(Yd.T @ Yd, Yd.T @ H @ (Di - d0[None, :]).T)
382
+ res.row_coordinates = Yi.T
383
+ return res
biplotpy/distatis.py ADDED
@@ -0,0 +1,69 @@
1
+ """DISTATIS (Abdi et al., 2005, 2012): STATIS for a set of distance matrices
2
+ on the same objects (port of ``DistatisR::distatis``)."""
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def _double_center(D):
9
+ n = D.shape[0]
10
+ C = np.eye(n) - 1 / n
11
+ return -0.5 * C @ D @ C
12
+
13
+
14
+ def distatis(cube, norm="MFA", distance=True, double_centering=True, rv=True, nfact=3, design=None) -> dict:
15
+ """``cube``: array n x n x T (or list of T n x n matrices) of distances.
16
+
17
+ Returns the RV (or scalar product) matrix C with its eigen-analysis and
18
+ table factor scores, the weights alpha, the compromise S+ and its factor
19
+ scores, contributions and squared cosines, and the partial factor scores
20
+ of every table."""
21
+ D = np.stack(cube, axis=2) if isinstance(cube, (list, tuple)) else np.asarray(cube, float)
22
+ if distance and not double_centering:
23
+ raise ValueError("Distance matrices must be double centered")
24
+ T = D.shape[2]
25
+ CP = np.stack([_double_center(D[:, :, t] if distance else -D[:, :, t]) for t in range(T)], 2) \
26
+ if double_centering else D.copy()
27
+ for t in range(T):
28
+ Y = CP[:, :, t]
29
+ if norm == "MFA":
30
+ CP[:, :, t] = Y / np.linalg.eigvalsh(Y)[-1]
31
+ elif norm == "SUMPCA":
32
+ CP[:, :, t] = Y / np.trace(Y)
33
+ elif str(norm).upper() == "NUCLEAR":
34
+ ev = np.linalg.eigvalsh(Y)
35
+ CP[:, :, t] = Y / np.sum(np.sqrt(ev[ev > 2 * np.finfo(float).eps]))
36
+ n = CP.shape[0]
37
+ CP2 = CP.reshape(n * n, T, order="F")
38
+ C = CP2.T @ CP2
39
+ if rv:
40
+ nr = np.sqrt((CP2 ** 2).sum(0))
41
+ C = C / np.outer(nr, nr)
42
+ ev, U = np.linalg.eigh(C)
43
+ ev, U = ev[::-1], U[:, ::-1]
44
+ U[:, 0] = np.abs(U[:, 0])
45
+ G = U * np.sqrt(np.abs(ev))
46
+ if design is None:
47
+ alpha = U[:, 0] / U[:, 0].sum()
48
+ else:
49
+ design = np.asarray(design); levs = np.unique(design); alpha = U[:, 0].copy()
50
+ for l in levs:
51
+ m = design == l
52
+ alpha[m] = alpha[m] / (len(levs) * alpha[m].sum())
53
+ S = np.tensordot(CP, alpha, axes=([2], [0]))
54
+ evS, US = np.linalg.eigh(S)
55
+ evS, US = evS[::-1], US[:, ::-1]
56
+ sv = np.sqrt(np.abs(evS))
57
+ F = US * sv
58
+ F2 = F ** 2
59
+ d2 = F2.sum(1)
60
+ with np.errstate(divide="ignore", invalid="ignore"):
61
+ ctr = F2 / np.abs(evS)
62
+ cos2 = F2 / d2[:, None]
63
+ Proj = US / sv
64
+ Proj = Proj[:, :nfact]
65
+ partial = np.stack([CP[:, :, t] @ Proj for t in range(T)], 2)
66
+ return {"C": C, "eig_C": ev, "tau_C": np.round(100 * ev / ev.sum()), "G": G, "alpha": alpha,
67
+ "cubeSP": CP, "Splus": S, "eig_Splus": evS, "tau_Splus": np.round(100 * evS / evS.sum()),
68
+ "F": F[:, :nfact], "ctr": ctr[:, :nfact], "cos2": cos2[:, :nfact], "d2": d2,
69
+ "projection": Proj, "partial_F": partial}
biplotpy/dynamic.py ADDED
@@ -0,0 +1,62 @@
1
+ """Dynamic biplot (Egido & Galindo, 2015).
2
+
3
+ A reference biplot (HJ by default) is computed on a reference matrix (the mean
4
+ of the occasions or one chosen occasion). The data of every occasion are
5
+ transformed with the statistics of the reference and projected as
6
+ supplementary rows (and, optionally, columns), giving the trajectory of each
7
+ individual and variable in the reference plane.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ from .classical import pca_biplot
15
+
16
+
17
+ def dynamic_biplot(X, reference="mean", alpha=2, dim=2, scaling=5, names=None, row_names=None,
18
+ col_names=None) -> dict:
19
+ """``X``: list (or dict) of n x p matrices with the same rows and columns.
20
+
21
+ Returns the reference biplot, the coordinates of the rows and columns at
22
+ every occasion, displacement (first to last occasion) and path length of
23
+ each row, and the quality (%) of the projection of each row at each
24
+ occasion (squared cosine on the reference plane)."""
25
+ if isinstance(X, dict):
26
+ names = names or list(X)
27
+ X = list(X.values())
28
+ if isinstance(X[0], pd.DataFrame):
29
+ row_names = row_names or [str(i) for i in X[0].index]
30
+ col_names = col_names or [str(c) for c in X[0].columns]
31
+ X = [np.asarray(x, float) for x in X]
32
+ T = len(X)
33
+ names = names or [f"t{i + 1}" for i in range(T)]
34
+ ref = np.mean(X, axis=0) if reference == "mean" else X[int(reference)]
35
+ bip = pca_biplot(ref, alpha=alpha, dim=dim, scaling=scaling, row_names=row_names, col_names=col_names)
36
+ mu, sd = ref.mean(0), ref.std(0, ddof=1)
37
+ t = bip.transformation
38
+ if t == "Standardize columns":
39
+ Z = [(x - mu) / sd for x in X]
40
+ elif t == "Column centering":
41
+ Z = [x - mu for x in X]
42
+ elif t == "Raw Data":
43
+ Z = X
44
+ else:
45
+ raise ValueError("dynamic_biplot supports raw data, column centering and standardized columns")
46
+ d = bip.d[:dim]
47
+ U, V = bip.U, bip.V
48
+ if alpha > 1:
49
+ rf, cf = np.ones(dim), np.ones(dim)
50
+ else:
51
+ rf = (1 / d) ** (1 - alpha) * bip.scale_factor
52
+ cf = (1 / d) ** alpha / bip.scale_factor
53
+ rows = [z @ V * rf for z in Z] # supplementary rows
54
+ cols = [z.T @ U * cf for z in Z] # supplementary columns
55
+ qual = np.array([((z @ V) ** 2).sum(1) / np.maximum((z ** 2).sum(1), 1e-300) * 100 for z in Z]).T
56
+ traj = np.stack(rows, axis=1) # n x T x dim
57
+ disp = np.sqrt(((traj[:, -1] - traj[:, 0]) ** 2).sum(1))
58
+ path = np.sqrt((np.diff(traj, axis=1) ** 2).sum(2)).sum(1)
59
+ rn = bip.row_names
60
+ return {"reference": bip, "row_coordinates": dict(zip(names, rows)), "col_coordinates": dict(zip(names, cols)),
61
+ "trajectories": traj, "displacement": pd.Series(disp, index=rn), "path_length": pd.Series(path, index=rn),
62
+ "quality": pd.DataFrame(qual, index=rn, columns=names), "occasions": names}
biplotpy/extras.py ADDED
@@ -0,0 +1,158 @@
1
+ """Bootstrap of the PCA biplot, supplementary variables and calibrated axes."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from .transforms import initial_transform, transform_name
8
+ from .utils import r_pretty
9
+
10
+
11
+ # --------------------------------------------------------------------------- bootstrap
12
+ def pca_bootstrap(X, dim=2, scaling="Standardize columns", B=1000, seed=None, level=0.95) -> dict:
13
+ """Non-parametric bootstrap of the PCA biplot (port of ``PCA.Bootstrap``).
14
+
15
+ Individuals are resampled, the data re-transformed and the SVD recomputed;
16
+ each axis is reflected to agree in sign with the original solution. Returns
17
+ the replicated eigenvalues, explained inertia, loadings, structure
18
+ correlations and qualities of the variables, with percentile and normal
19
+ ("basic", as in MultBiplotR) intervals for the eigenvalues and percentile
20
+ intervals for the rest.
21
+ """
22
+ rng = np.random.default_rng(seed)
23
+ X = np.asarray(X, float)
24
+ n, p = X.shape
25
+ XT = initial_transform(X, transform_name(scaling))["X"]
26
+ U0, d0, V0t = np.linalg.svd(XT, full_matrices=False)
27
+ V0 = V0t.T[:, :dim]
28
+ k = min(n, p)
29
+ eig = np.zeros((B, k)); iner = np.zeros((B, k))
30
+ Vs = np.zeros((B, p, dim)); struct = np.zeros((B, p, dim)); qual = np.zeros((B, p, dim))
31
+ samples = np.zeros((B, n), dtype=int)
32
+ for i in range(B):
33
+ s = rng.integers(n, size=n)
34
+ samples[i] = s
35
+ XB = initial_transform(X[s], transform_name(scaling))["X"]
36
+ U, d, Vt = np.linalg.svd(XB, full_matrices=False)
37
+ V = Vt.T[:, :dim]
38
+ signs = np.sign(np.diag(V0.T @ V)); signs[signs == 0] = 1
39
+ V = V * signs
40
+ U = U[:, :dim] * signs
41
+ eig[i, :len(d)] = d[:k]
42
+ iner[i, :len(d)] = 100 * d[:k] ** 2 / np.sum(d ** 2)
43
+ Vs[i] = V
44
+ A = XB @ V
45
+ struct[i] = np.corrcoef(np.column_stack([XB, A]), rowvar=False)[:p, p:]
46
+ b = V * d[:dim]
47
+ qual[i] = np.cumsum(b ** 2 / (XB ** 2).sum(0)[:, None] * 100, axis=1)
48
+ a = (1 - level) / 2
49
+ ev2 = eig ** 2
50
+ med = ev2.mean(0)
51
+ sdev = ev2.std(0, ddof=1)
52
+ return {"eigenvalues": eig, "inertia": iner, "loadings": Vs, "structure": struct, "qualities": qual,
53
+ "samples": samples, "initial_svd": (U0[:, :dim], d0, V0),
54
+ "mean_eigenvalue": med,
55
+ "ci_percentile_eigenvalue": np.quantile(ev2, [a, 1 - a], axis=0),
56
+ "ci_basic_eigenvalue": np.vstack([med - 1.96 * sdev, med + 1.96 * sdev]),
57
+ "ci_structure": np.quantile(struct, [a, 1 - a], axis=0),
58
+ "ci_qualities": np.quantile(qual, [a, 1 - a], axis=0),
59
+ "ci_inertia": np.quantile(iner, [a, 1 - a], axis=0)}
60
+
61
+
62
+ # --------------------------------------------------------------------------- supplementary
63
+ def add_continuous_variables(bip, X, dims=None, scaling=5, names=None) -> dict:
64
+ """Project continuous supplementary variables on a biplot by regressing each
65
+ (transformed) variable on the row coordinates (port of ``AddContVars2Biplot``).
66
+
67
+ Returns intercepts ``b0``, coordinates, R2 (%) and structure correlations."""
68
+ if isinstance(X, pd.DataFrame):
69
+ names = names or [str(c) for c in X.columns]
70
+ X = X.to_numpy(float)
71
+ X = np.asarray(X, float)
72
+ if X.ndim == 1:
73
+ X = X[:, None]
74
+ n, p = X.shape
75
+ dims = dims or bip.row_coordinates.shape[1]
76
+ Xs = initial_transform(X, transform_name(scaling))["X"]
77
+ A = bip.row_coordinates[:, :dims]
78
+ DE = np.column_stack([np.ones(n), A])
79
+ b = np.linalg.solve(DE.T @ DE, DE.T @ Xs).T
80
+ res = Xs - DE @ b.T
81
+ R2 = (1 - (res ** 2).sum(0) / (Xs ** 2).sum(0)) * 100
82
+ structure = np.corrcoef(np.column_stack([Xs, A]), rowvar=False)[:p, p:]
83
+ names = names or [f"SV{j + 1}" for j in range(p)]
84
+ return {"b0": b[:, 0], "coordinates": b[:, 1:], "R2": R2, "structure": structure, "names": names,
85
+ "scaled_data": Xs}
86
+
87
+
88
+ def project_rows(bip, X_new):
89
+ """Coordinates of supplementary individuals (transformed with the statistics
90
+ of the active data) on a classical biplot."""
91
+ X_new = np.asarray(X_new, float)
92
+ t = bip.transformation
93
+ if t == "Standardize columns":
94
+ Z = (X_new - bip.means) / bip.deviations
95
+ elif t == "Column centering":
96
+ Z = X_new - bip.means
97
+ elif t == "Substract the global mean":
98
+ Z = X_new - bip.gmean
99
+ elif t == "Raw Data":
100
+ Z = X_new
101
+ else:
102
+ raise ValueError(f"Supplementary rows are not defined for '{t}'")
103
+ a = Z @ bip.V
104
+ d = bip.d[:bip.dim]
105
+ if bip.alpha <= 1:
106
+ a = a * (1 / d) ** (1 - bip.alpha) * bip.scale_factor
107
+ return a # HJ-Biplot: principal coordinates X V = U D
108
+
109
+
110
+ # --------------------------------------------------------------------------- calibrated axes
111
+ def biplot_scales(bip, nticks=3, type_scale="Complete", values_scale="Original") -> dict:
112
+ """Ticks (on the transformed scale) and labels (on the original or transformed
113
+ scale) of the calibrated axis of every variable (port of ``GetBiplotScales``
114
+ for raw data, global-mean, column-centred and standardized data)."""
115
+ ticks, labels = [], []
116
+ t = bip.transformation
117
+ for j in range(bip.p):
118
+ mn, mx, mean, sd = bip.minima[j], bip.maxima[j], bip.means[j], bip.deviations[j]
119
+ if t in ("Raw Data", "Substract the global mean"):
120
+ lab = r_pretty([mn, mx], nticks)
121
+ tk = lab - (bip.gmean if t == "Substract the global mean" else 0)
122
+ elif t == "Column centering":
123
+ if type_scale == "Complete":
124
+ if values_scale == "Original":
125
+ lab = r_pretty([mn, mx], nticks); tk = lab - mean
126
+ else:
127
+ tk = r_pretty([mn - mean, mx - mean], nticks); lab = np.round(tk, 2)
128
+ elif type_scale == "StdDev":
129
+ k = np.array([-3, -2, -1, 1, 2, 3])
130
+ tk = np.round(k * sd, 2); lab = np.round(mean + k * sd, 2) if values_scale == "Original" else tk
131
+ else:
132
+ q = np.array([mn, bip.p25[j], bip.medians[j], bip.p75[j], mx])
133
+ tk = q - mean; lab = q if values_scale == "Original" else np.round(tk, 2)
134
+ elif t == "Standardize columns":
135
+ if type_scale == "Complete":
136
+ if values_scale == "Original":
137
+ lab = r_pretty([mn, mx], nticks); tk = (lab - mean) / sd
138
+ else:
139
+ tk = np.array([-3, -2.5, -2, -1.5, -1, -0.5, 0.5, 1, 1.5, 2, 2.5, 3]); lab = np.round(tk, 2)
140
+ elif type_scale == "StdDev":
141
+ tk = np.array([-3., -2, -1, 1, 2, 3])
142
+ lab = np.round(tk * sd + mean, 1) if values_scale == "Original" else np.round(tk, 2)
143
+ else:
144
+ q = np.array([mn, bip.p25[j], bip.medians[j], bip.p75[j], mx])
145
+ tk = (q - mean) / sd; lab = q if values_scale == "Original" else np.round(tk, 2)
146
+ else:
147
+ col = bip.scaled_data[:, j]
148
+ lab = r_pretty([col.min(), col.max()], nticks); tk = lab
149
+ ticks.append(np.asarray(tk, float)); labels.append(np.asarray(lab, float))
150
+ return {"ticks": ticks, "labels": labels}
151
+
152
+
153
+ def calibrated_axis(b, ticks):
154
+ """Positions of the ticks of a calibrated (predictive) axis with direction
155
+ ``b`` (column marker): the point of the axis whose projection predicts the
156
+ value ``t`` is ``t * b / |b|^2``."""
157
+ b = np.asarray(b, float)
158
+ return np.outer(np.asarray(ticks, float), b / (b @ b))