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/factor.py ADDED
@@ -0,0 +1,521 @@
1
+ """Factor-type biplots and two-table biplots.
2
+
3
+ * ``rotate`` — gradient-projection rotations (port of ``GPArotation``
4
+ GPForth / GPFoblq): varimax, quartimax, equamax, parsimax, entropy
5
+ (orthogonal); quartimin and oblimin (oblique).
6
+ * ``fa_biplot`` — ``MultBiplotR::FA.Biplot`` (principal-component
7
+ extraction of the correlation matrix, rotation, factor scores).
8
+ * ``nipals_biplot`` — PCA biplot by NIPALS that accepts missing values
9
+ (``ade4::nipals``), with sparse and truncated variants
10
+ (``MultBiplotR::Sparse.NIPALSPCA`` / ``Truncated.NIPALSPCA``).
11
+ * ``pls_biplot`` — PLS regression (NIPALS, orthogonal scores, as
12
+ ``pls::plsr(method = "oscorespls")``) and its biplot
13
+ (``MultBiplotR::Biplot.PLSR``).
14
+ * ``coinertia`` — co-inertia analysis of two tables (``ade4::coinertia``).
15
+ * ``polychoric`` / ``polychoric_biplot`` — two-step polychoric correlations
16
+ (``psych::polychoric``) and the ordinal biplot built from them
17
+ (``MultBiplotR::PolyBiplot``).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ from scipy import optimize, stats as sps
24
+ from scipy.special import expit
25
+
26
+ from .classical import ContinuousBiplot
27
+ from .transforms import initial_transform, transform_name
28
+
29
+
30
+ # --------------------------------------------------------------------------- rotations
31
+ def _crit(method, L, kappa=0.0, gam=0.0):
32
+ L2 = L ** 2
33
+ if method == "varimax":
34
+ QL = L2 - L2.mean(0)
35
+ return -L * QL, -np.sum(QL ** 2) / 4
36
+ if method == "quartimax":
37
+ return -L ** 3, -np.sum(L2 ** 2) / 4
38
+ if method == "cf":
39
+ p, k = L.shape
40
+ N = np.ones((k, k)) - np.eye(k); M = np.ones((p, p)) - np.eye(p)
41
+ f = (1 - kappa) * np.sum(L2 * (L2 @ N)) / 4 + kappa * np.sum(L2 * (M @ L2)) / 4
42
+ return (1 - kappa) * L * (L2 @ N) + kappa * L * (M @ L2), f
43
+ if method == "entropy":
44
+ lg = np.log(L2 + (L2 == 0))
45
+ return -(L * lg + L), -np.sum(L2 * lg) / 2
46
+ if method in ("oblimin", "quartimin"):
47
+ X = L2 @ (1 - np.eye(L.shape[1]))
48
+ if gam:
49
+ p = L.shape[0]
50
+ X = (np.eye(p) - gam / p) @ X
51
+ return L * X, np.sum(L2 * X) / 4
52
+ raise ValueError(method)
53
+
54
+
55
+ def rotate(A, method="varimax", normalize=False, eps=1e-5, maxit=1000, gamma=0.0):
56
+ """Rotate a loading matrix. Returns ``(loadings, T, Phi)``; Phi is the
57
+ factor correlation matrix (identity for orthogonal rotations)."""
58
+ A = np.asarray(A, float)
59
+ p, k = A.shape
60
+ if method == "none" or k < 2:
61
+ return A, np.eye(k), np.eye(k)
62
+ W = np.sqrt((A ** 2).sum(1))[:, None] if normalize else np.ones((p, 1))
63
+ A = A / W
64
+ kw = {}
65
+ m = method
66
+ if method == "equamax":
67
+ m, kw = "cf", {"kappa": k / (2 * p)}
68
+ elif method == "parsimax":
69
+ m, kw = "cf", {"kappa": (k - 1) / (p + k - 2)}
70
+ elif method == "oblimin":
71
+ kw = {"gam": gamma}
72
+ oblique = m in ("oblimin", "quartimin")
73
+ T = np.eye(k); al = 1.0
74
+ if not oblique:
75
+ L = A @ T
76
+ Gq, f = _crit(m, L, **kw)
77
+ G = A.T @ Gq
78
+ for _ in range(maxit + 1):
79
+ M = T.T @ G
80
+ Gp = G - T @ ((M + M.T) / 2)
81
+ s = np.sqrt(np.sum(Gp ** 2))
82
+ if s < eps:
83
+ break
84
+ al *= 2
85
+ for _ in range(11):
86
+ U, _, Vt = np.linalg.svd(T - al * Gp)
87
+ Tt = U @ Vt
88
+ L = A @ Tt
89
+ Gqt, ft = _crit(m, L, **kw)
90
+ if ft < f - 0.5 * s ** 2 * al:
91
+ break
92
+ al /= 2
93
+ T, f, G = Tt, ft, A.T @ Gqt
94
+ L = A @ T
95
+ Phi = np.eye(k)
96
+ else:
97
+ L = A @ np.linalg.inv(T).T
98
+ Gq, f = _crit(m, L, **kw)
99
+ G = -(L.T @ Gq @ np.linalg.inv(T)).T
100
+ for _ in range(maxit + 1):
101
+ Gp = G - T @ np.diag(np.sum(T * G, 0))
102
+ s = np.sqrt(np.sum(Gp ** 2))
103
+ if s < eps:
104
+ break
105
+ al *= 2
106
+ for _ in range(11):
107
+ X = T - al * Gp
108
+ Tt = X / np.sqrt((X ** 2).sum(0))
109
+ L = A @ np.linalg.inv(Tt).T
110
+ Gqt, ft = _crit(m, L, **kw)
111
+ if f - ft > 0.5 * s ** 2 * al:
112
+ break
113
+ al /= 2
114
+ T, f = Tt, ft
115
+ G = -(L.T @ Gqt @ np.linalg.inv(Tt)).T
116
+ Phi = T.T @ T
117
+ return L * W, T, Phi
118
+
119
+
120
+ # --------------------------------------------------------------------------- FA biplot
121
+ def fa_biplot(X, dim=2, rotation="varimax", normalize=False, scores="regression", row_names=None,
122
+ col_names=None) -> ContinuousBiplot:
123
+ """Factor-analysis biplot with principal-component extraction of the
124
+ correlation matrix (``MultBiplotR::FA.Biplot``, Extraction = "PC").
125
+
126
+ ``scores``: 'regression' (Thurstone, X R^-1 B) or 'bartlett'
127
+ (X Psi^-1 B (B' Psi^-1 B)^-1, Psi = diag(uniquenesses)). MultBiplotR
128
+ swaps the two names and squares the uniquenesses (see ``FIXES``)."""
129
+ if isinstance(X, pd.DataFrame):
130
+ row_names = row_names or [str(i) for i in X.index]; col_names = col_names or [str(c) for c in X.columns]
131
+ X0 = np.asarray(X, float); n, p = X0.shape
132
+ Z = (X0 - X0.mean(0)) / X0.std(0, ddof=1)
133
+ R = np.corrcoef(Z, rowvar=False)
134
+ ev, U = np.linalg.eigh(R); ev, U = ev[::-1], U[:, ::-1]
135
+ B0 = U[:, :dim] * np.sqrt(ev[:dim])
136
+ B, T, Phi = rotate(B0, rotation, normalize)
137
+ h = (B ** 2).sum(1) if rotation not in ("oblimin", "quartimin") else np.diag(B @ Phi @ B.T)
138
+ u = 1 - h
139
+ if scores == "regression":
140
+ A = Z @ np.linalg.solve(R, B @ Phi) if rotation in ("oblimin", "quartimin") else Z @ np.linalg.solve(R, B)
141
+ elif scores == "bartlett":
142
+ Pi = np.diag(1 / u)
143
+ A = Z @ Pi @ B @ np.linalg.inv(B.T @ Pi @ B)
144
+ else:
145
+ raise ValueError("scores must be 'regression' or 'bartlett'")
146
+ inertia = ev[:dim] / p * 100
147
+ row_names = row_names or [f"I{i + 1}" for i in range(n)]
148
+ col_names = col_names or [f"V{j + 1}" for j in range(p)]
149
+ sf = (Z ** 2).sum(1)
150
+ rc = (A * np.sqrt(ev[:dim])) ** 2 / sf[:, None] * 100
151
+ return ContinuousBiplot(title="Factor Analysis Biplot", type="FA", alpha=0, normalization=f"FA ({rotation})",
152
+ dim=dim, transformation="Standardize columns", non_scaled_data=X0, scaled_data=Z,
153
+ means=X0.mean(0), deviations=X0.std(0, ddof=1), minima=X0.min(0), maxima=X0.max(0),
154
+ medians=np.median(X0, 0), gmean=X0.mean(), eigenvalues=ev, inertia=np.r_[inertia, np.zeros(p - dim)],
155
+ cum_inertia=np.cumsum(inertia), row_coordinates=A, col_coordinates=B,
156
+ row_contributions=rc, col_contributions=B ** 2 * 100, communalities=h, uniquenesses=u,
157
+ rotation=rotation, Phi=Phi, R=R, structure=np.corrcoef(np.column_stack([Z, A]), rowvar=False)[:p, p:],
158
+ scale_factor=1.0, row_names=row_names, col_names=col_names, n=n, p=p,
159
+ sup_row_coordinates=None, sup_col_coordinates=None)
160
+
161
+
162
+ # --------------------------------------------------------------------------- NIPALS
163
+ def nipals(X, dim=2, tol=1e-9, maxit=500, standardize=True):
164
+ """NIPALS PCA with missing values (``ade4::nipals``): columns centred and
165
+ scaled (population sd) on the available values; returns scores ``li``,
166
+ normed loadings ``c1``, eigenvalues and the reconstructed (imputed)
167
+ standardized table ``rec``."""
168
+ X = np.asarray(X, float)
169
+ n, p = X.shape
170
+ M = np.isfinite(X)
171
+ mu = np.nanmean(X, 0)
172
+ nr = M.sum(0)
173
+ sd = np.nanstd(X, 0, ddof=1) * np.sqrt((nr - 1) / nr) if standardize else np.ones(p)
174
+ E = (X - mu) / sd
175
+ li = np.zeros((n, dim)); c1 = np.zeros((p, dim)); eig = np.zeros(dim); nb = np.zeros(dim, int)
176
+ for h in range(dim):
177
+ th = E[:, 0].copy()
178
+ ph1 = np.full(p, 1 / np.sqrt(p))
179
+ it = 0
180
+ while True:
181
+ Ez, Mz = np.where(M, E, 0.0), M
182
+ thz = np.where(np.isfinite(th), th, 0.0)
183
+ ph2 = (Ez * thz[:, None]).sum(0) / (Mz * thz[:, None] ** 2).sum(0)
184
+ ph2 = ph2 / np.sqrt(np.sum(ph2 ** 2))
185
+ th = (Ez * ph2[None, :]).sum(1) / (Mz * ph2[None, :] ** 2).sum(1)
186
+ diff = ph2 - ph1; ph1 = ph2; it += 1
187
+ if np.sum(diff ** 2) <= tol or it > maxit:
188
+ break
189
+ E = E - np.outer(th, ph1)
190
+ li[:, h], c1[:, h], nb[h] = th, ph1, it
191
+ eig[h] = np.sum(th ** 2) / (n - 1)
192
+ rec = li @ c1.T
193
+ return {"li": li, "c1": c1, "co": c1 * np.sqrt(eig), "eig": eig, "rec": rec, "imputed": rec * sd + mu,
194
+ "means": mu, "sd": sd, "iterations": nb}
195
+
196
+
197
+ def _nipals_regular(X, dim, tol=1e-6, maxit=1000, mode="regular", lam=0.02):
198
+ n, p = X.shape
199
+ U = np.zeros((n, dim)); V = np.zeros((p, dim)); d = np.zeros(dim)
200
+ E = X.copy()
201
+ for i in range(dim):
202
+ u = E[:, 0].copy()
203
+ for _ in range(maxit):
204
+ uold = u
205
+ v = u @ E / np.sum(u ** 2); v /= np.linalg.norm(v)
206
+ if mode == "sparse":
207
+ v = np.sign(v) * np.maximum(np.abs(v) - lam, 0)
208
+ v /= np.linalg.norm(v)
209
+ elif mode == "truncated":
210
+ v = v * (np.abs(v) > lam); v /= np.linalg.norm(v)
211
+ u = E @ v
212
+ if np.sum((u - uold) ** 2) <= tol:
213
+ break
214
+ d[i] = np.linalg.norm(u)
215
+ E = E - np.outer(u, v)
216
+ U[:, i] = u / d[i]; V[:, i] = v
217
+ return U, d, V
218
+
219
+
220
+ def nipals_biplot(X, dim=2, alpha=1.0, scaling=5, mode="regular", lam=0.02, row_names=None,
221
+ col_names=None) -> ContinuousBiplot:
222
+ """NIPALS biplot (``MultBiplotR::NIPALS.Biplot``). With missing values
223
+ (NaN) the ``ade4::nipals`` algorithm is used on standardized data;
224
+ ``mode`` = 'sparse' (soft thresholding) or 'truncated' (hard
225
+ thresholding) gives sparse loadings for complete data."""
226
+ if isinstance(X, pd.DataFrame):
227
+ row_names = row_names or [str(i) for i in X.index]; col_names = col_names or [str(c) for c in X.columns]
228
+ X0 = np.asarray(X, float); n, p = X0.shape
229
+ row_names = row_names or [f"I{i + 1}" for i in range(n)]
230
+ col_names = col_names or [f"V{j + 1}" for j in range(p)]
231
+ if np.isnan(X0).any():
232
+ r = nipals(X0, dim)
233
+ Xs = r["rec"]
234
+ U = r["li"] / np.sqrt((r["li"] ** 2).sum(0)); d = np.sqrt((r["li"] ** 2).sum(0)); V = r["c1"]
235
+ tname = "Standardize columns (available values)"
236
+ else:
237
+ tname = transform_name(scaling)
238
+ Xs = initial_transform(X0, tname)["X"]
239
+ U, d, V = _nipals_regular(Xs, dim, mode=mode, lam=lam)
240
+ if mode == "truncated":
241
+ U = Xs @ V; d = np.sqrt((U ** 2).sum(0)); U = U / d
242
+ a, b = U * d, V.copy()
243
+ tot = np.nansum(Xs ** 2)
244
+ inertia = d ** 2 / tot * 100
245
+ if alpha <= 1:
246
+ a = U * d ** alpha; b = V * d ** (1 - alpha)
247
+ scf = np.sqrt(np.sqrt(((b ** 2).sum() / p) / ((a ** 2).sum() / n)))
248
+ a, b = a * scf, b / scf
249
+ else:
250
+ b = V * d; scf = 1.0
251
+ return ContinuousBiplot(title={"regular": "NIPALS Biplot", "sparse": "Sparse NIPALS Biplot",
252
+ "truncated": "Truncated NIPALS Biplot"}[mode], type="NIPALS", alpha=alpha,
253
+ normalization="NIPALS", dim=dim, transformation=tname, non_scaled_data=X0, scaled_data=Xs,
254
+ means=np.nanmean(X0, 0), deviations=np.nanstd(X0, 0, ddof=1), minima=np.nanmin(X0, 0),
255
+ maxima=np.nanmax(X0, 0), medians=np.nanmedian(X0, 0), gmean=np.nanmean(X0),
256
+ eigenvalues=d ** 2, inertia=inertia, cum_inertia=np.cumsum(inertia), row_coordinates=a,
257
+ col_coordinates=b, V=V, U=U, d=d, scale_factor=scf, row_names=row_names, col_names=col_names,
258
+ n=n, p=p, row_contributions=None, col_contributions=None, sup_row_coordinates=None,
259
+ sup_col_coordinates=None, missing=np.isnan(X0))
260
+
261
+
262
+ # --------------------------------------------------------------------------- PLS
263
+ def pls_regression(X, Y, ncomp=2, scale=True, tol=1e-12, maxit=500):
264
+ """PLS regression by NIPALS with orthogonal scores (``pls::plsr``,
265
+ method 'oscorespls'): X centred (and scaled by its sd when ``scale``),
266
+ Y centred. Returns scores T, loadings P, Y loadings Q, weights W,
267
+ regression coefficients (for the scaled X) and fitted values."""
268
+ X0 = np.asarray(X, float); Y0 = np.asarray(Y, float)
269
+ if Y0.ndim == 1:
270
+ Y0 = Y0[:, None]
271
+ xm, ym = X0.mean(0), Y0.mean(0)
272
+ xs = X0.std(0, ddof=1) if scale else np.ones(X0.shape[1])
273
+ E = (X0 - xm) / xs; F = Y0 - ym
274
+ n, p = E.shape; q = F.shape[1]
275
+ T = np.zeros((n, ncomp)); P = np.zeros((p, ncomp)); Q = np.zeros((q, ncomp)); W = np.zeros((p, ncomp))
276
+ U = np.zeros((n, ncomp))
277
+ for a in range(ncomp):
278
+ if q == 1:
279
+ w = E.T @ F[:, 0]; w /= np.linalg.norm(w)
280
+ else:
281
+ u = F[:, np.argmax((F ** 2).sum(0))].copy()
282
+ w_old = None
283
+ for _ in range(maxit):
284
+ w = E.T @ u; w /= np.linalg.norm(w)
285
+ t = E @ w
286
+ c = F.T @ t / (t @ t)
287
+ u = F @ c / (c @ c)
288
+ if w_old is not None and np.sum((w - w_old) ** 2) < tol:
289
+ break
290
+ w_old = w
291
+ t = E @ w
292
+ tt = t @ t
293
+ pa = E.T @ t / tt; qa = F.T @ t / tt
294
+ E = E - np.outer(t, pa); F = F - np.outer(t, qa)
295
+ T[:, a], P[:, a], Q[:, a], W[:, a] = t, pa, qa, w
296
+ U[:, a] = (Y0 - ym) @ qa / (qa @ qa)
297
+ R = W @ np.linalg.inv(P.T @ W)
298
+ B = R @ Q.T
299
+ fitted = ym + ((X0 - xm) / xs) @ B
300
+ return {"scores": T, "loadings": P, "y_loadings": Q, "weights": W, "projection": R, "coef": B,
301
+ "fitted": fitted, "y_scores": U, "x_means": xm, "x_sd": xs, "y_means": ym,
302
+ "explained_x": (T ** 2).sum(0) * (P ** 2).sum(0) / np.sum(((X0 - xm) / xs) ** 2) * 100}
303
+
304
+
305
+ def _sup_coords(A, Y):
306
+ """Coordinates of (standardized) supplementary variables regressed on the row coordinates."""
307
+ Y = Y[:, None] if Y.ndim == 1 else Y
308
+ Ys = (Y - Y.mean(0)) / Y.std(0, ddof=1)
309
+ D = np.column_stack([np.ones(len(A)), A])
310
+ return np.linalg.lstsq(D, Ys, rcond=None)[0][1:].T
311
+
312
+
313
+ def pls_biplot(X, Y, ncomp=2, scale=True, row_names=None, col_names=None, y_names=None) -> ContinuousBiplot:
314
+ """PLS-R biplot (``MultBiplotR::Biplot.PLSR``): individuals by their X
315
+ scores, X variables by their loadings (rescaled for display) and the
316
+ responses projected as supplementary variables (regression of the
317
+ standardized responses on the row coordinates, as AddContVars2Biplot)."""
318
+ if isinstance(X, pd.DataFrame):
319
+ row_names = row_names or [str(i) for i in X.index]; col_names = col_names or [str(c) for c in X.columns]
320
+ if isinstance(Y, pd.DataFrame):
321
+ y_names = y_names or [str(c) for c in Y.columns]
322
+ r = pls_regression(X, Y, ncomp, scale)
323
+ X0 = np.asarray(X, float); n, p = X0.shape
324
+ a, b, qy = r["scores"], r["loadings"], r["y_loadings"]
325
+ scf = np.sqrt(np.sqrt(((b ** 2).sum() / p) / ((a ** 2).sum() / n)))
326
+ Z = (X0 - r["x_means"]) / r["x_sd"]
327
+ fit = np.array([np.sum((np.outer(a[:, k], b[:, k])) ** 2) for k in range(ncomp)]) / np.sum(Z ** 2) * 100
328
+ return ContinuousBiplot(title="PLSR Biplot", type="PLSR", alpha=0, normalization="PLS", dim=ncomp,
329
+ transformation="Standardize columns" if scale else "Column centering", non_scaled_data=X0,
330
+ scaled_data=Z, means=X0.mean(0), deviations=X0.std(0, ddof=1), minima=X0.min(0),
331
+ maxima=X0.max(0), medians=np.median(X0, 0), gmean=X0.mean(), eigenvalues=fit / 100,
332
+ inertia=fit, cum_inertia=np.cumsum(fit), row_coordinates=a * scf, col_coordinates=b / scf,
333
+ y_coordinates=_sup_coords(a * scf, np.asarray(Y, float)), y_names=y_names or [f"Y{k + 1}" for k in range(qy.shape[0])],
334
+ pls=r, scale_factor=scf, row_names=row_names or [f"I{i + 1}" for i in range(n)],
335
+ col_names=col_names or [f"V{j + 1}" for j in range(p)], n=n, p=p,
336
+ row_contributions=None, col_contributions=None, sup_row_coordinates=None,
337
+ sup_col_coordinates=None)
338
+
339
+
340
+ # --------------------------------------------------------------------------- co-inertia
341
+ def coinertia(X, Y, scale_x=True, scale_y=True, dim=2) -> dict:
342
+ """Co-inertia analysis of two tables with the same rows (``ade4::coinertia``
343
+ on two ``dudi.pca``). Returns eigenvalues, RV, normed axes of X (``c1``)
344
+ and Y (``l1``), variable coordinates (``co``, ``li``) and row scores in
345
+ each table (``lX``, ``lY``)."""
346
+ def prep(M, sc):
347
+ M = np.asarray(M, float); Mc = M - M.mean(0)
348
+ return Mc / M.std(0) if sc else Mc
349
+ Xt, Yt = prep(X, scale_x), prep(Y, scale_y)
350
+ n = Xt.shape[0]
351
+ C = Yt.T @ Xt / n
352
+ U, d, Vt = np.linalg.svd(C, full_matrices=False)
353
+ rank = int(np.sum(d ** 2 / d[0] ** 2 > 1e-7))
354
+ eig = d[:rank] ** 2
355
+ V = Vt.T
356
+ c1, l1 = V[:, :dim], U[:, :dim]
357
+ eX = np.linalg.eigvalsh(Xt.T @ Xt / n); eY = np.linalg.eigvalsh(Yt.T @ Yt / n)
358
+ RV = eig.sum() / np.sqrt(np.sum(eX ** 2)) / np.sqrt(np.sum(eY ** 2))
359
+ return {"eig": eig, "RV": RV, "c1": c1, "l1": l1, "co": c1 * d[:dim], "li": l1 * d[:dim],
360
+ "lX": Xt @ c1, "lY": Yt @ l1, "inertia": eig / eig.sum() * 100}
361
+
362
+
363
+ # --------------------------------------------------------------------------- polychoric
364
+ _GL = {6: (np.array([0.9324695142031522, 0.6612093864662647, 0.2386191860831970]),
365
+ np.array([0.1713244923791705, 0.3607615730481384, 0.4679139345726904])),
366
+ 12: (np.array([0.9815606342467191, 0.9041172563704750, 0.7699026741943050, 0.5873179542866171,
367
+ 0.3678314989981802, 0.1252334085114692]),
368
+ np.array([0.04717533638651177, 0.1069393259953183, 0.1600783285433464, 0.2031674267230659,
369
+ 0.2334925365383547, 0.2491470458134029])),
370
+ 20: (np.array([0.9931285991850949, 0.9639719272779138, 0.9122344282513259, 0.8391169718222188,
371
+ 0.7463319064601508, 0.6360536807265150, 0.5108670019508271, 0.3737060887154196,
372
+ 0.2277858511416451, 0.07652652113349733]),
373
+ np.array([0.01761400713915212, 0.04060142980038694, 0.06267204833410906, 0.08327674157670475,
374
+ 0.1019301198172404, 0.1181945319615184, 0.1316886384491766, 0.1420961093183821,
375
+ 0.1491729864726037, 0.1527533871307259]))}
376
+
377
+
378
+ def _bvnu(dh, dk, r):
379
+ """Upper bivariate normal probability P(X > dh, Y > dk) with correlation r
380
+ (Genz's BVNU / Drezner-Wesolowsky algorithm, double precision)."""
381
+ if abs(r) < 0.3:
382
+ x, w = _GL[6]
383
+ elif abs(r) < 0.75:
384
+ x, w = _GL[12]
385
+ else:
386
+ x, w = _GL[20]
387
+ h, k = dh, dk
388
+ hk = h * k
389
+ bvn = 0.0
390
+ if abs(r) < 0.925:
391
+ hs = (h * h + k * k) / 2
392
+ asr = np.arcsin(r)
393
+ for sgn in (-1, 1):
394
+ sn = np.sin(asr * (sgn * x + 1) / 2)
395
+ bvn += np.sum(w * np.exp((sn * hk - hs) / (1 - sn * sn)))
396
+ bvn = bvn * asr / (4 * np.pi) + sps.norm.cdf(-h) * sps.norm.cdf(-k)
397
+ else:
398
+ if r < 0:
399
+ k, hk = -k, -hk
400
+ if abs(r) < 1:
401
+ as_ = (1 - r) * (1 + r)
402
+ a = np.sqrt(as_)
403
+ bs = (h - k) ** 2
404
+ c = (4 - hk) / 8
405
+ d = (12 - hk) / 16
406
+ bvn = a * np.exp(-(bs / as_ + hk) / 2) * (1 - c * (bs - as_) * (1 - d * bs / 5) / 3 + c * d * as_ * as_ / 5)
407
+ if hk > -160:
408
+ b = np.sqrt(bs)
409
+ bvn -= np.exp(-hk / 2) * np.sqrt(2 * np.pi) * sps.norm.cdf(-b / a) * b * (1 - c * bs * (1 - d * bs / 5) / 3)
410
+ a = a / 2
411
+ for sgn in (-1, 1):
412
+ xs = (a * (sgn * x + 1)) ** 2
413
+ rs = np.sqrt(1 - xs)
414
+ asr = -(bs / xs + hk) / 2
415
+ ok = asr > -100
416
+ bvn += a * np.sum(w[ok] * np.exp(asr[ok]) * (np.exp(-hk * (1 - rs[ok]) / (2 * (1 + rs[ok]))) / rs[ok]
417
+ - (1 + c * xs[ok] * (1 + d * xs[ok]))))
418
+ bvn = -bvn / (2 * np.pi)
419
+ if r > 0:
420
+ bvn += sps.norm.cdf(-max(h, k))
421
+ else:
422
+ bvn = -bvn
423
+ if k > h:
424
+ bvn += sps.norm.cdf(k) - sps.norm.cdf(h) if h < 0 else sps.norm.cdf(-h) - sps.norm.cdf(-k)
425
+ return bvn
426
+
427
+
428
+ def _bvn_cdf(h, k, r):
429
+ """P(X < h, Y < k) for a standard bivariate normal with correlation r."""
430
+ if h == -np.inf or k == -np.inf:
431
+ return 0.0
432
+ if h == np.inf and k == np.inf:
433
+ return 1.0
434
+ if h == np.inf:
435
+ return sps.norm.cdf(k)
436
+ if k == np.inf:
437
+ return sps.norm.cdf(h)
438
+ return _bvnu(-h, -k, r)
439
+
440
+
441
+ def _cell_probs(t1, t2, r):
442
+ a = np.r_[-np.inf, t1, np.inf]; b = np.r_[-np.inf, t2, np.inf]
443
+ Fc = np.array([[_bvn_cdf(x, y, r) for y in b] for x in a])
444
+ return Fc[1:, 1:] - Fc[:-1, 1:] - Fc[1:, :-1] + Fc[:-1, :-1]
445
+
446
+
447
+ def polychoric(X, correct=0.0):
448
+ """Two-step polychoric correlation matrix (``psych::polychoric``):
449
+ thresholds from the marginal proportions of each variable, then the
450
+ correlation of each pair by maximum likelihood with the thresholds fixed.
451
+ Zero cells may receive ``correct`` (psych applies it only with global = FALSE;
452
+ the default 0 reproduces psych with global thresholds). Categories must be 1..C."""
453
+ X = np.asarray(X, int)
454
+ n, p = X.shape
455
+ cats = [np.unique(X[:, j]) for j in range(p)]
456
+ taus = []
457
+ for j in range(p):
458
+ cnt = np.array([np.sum(X[:, j] == c) for c in cats[j]], float)
459
+ taus.append(sps.norm.ppf(np.cumsum(cnt)[:-1] / n))
460
+ R = np.eye(p)
461
+ for i in range(p):
462
+ for j in range(i + 1, p):
463
+ tab = np.array([[np.sum((X[:, i] == a) & (X[:, j] == b)) for b in cats[j]] for a in cats[i]], float)
464
+ if correct:
465
+ tab[tab == 0] = correct
466
+ f = lambda r: -np.sum(tab * np.log(np.maximum(_cell_probs(taus[i], taus[j], r), 1e-300)))
467
+ R[i, j] = R[j, i] = optimize.minimize_scalar(f, bounds=(-0.999, 0.999), method="bounded",
468
+ options={"xatol": 1e-8}).x
469
+ maxc = max(len(t) for t in taus)
470
+ tau = np.full((p, maxc), np.nan)
471
+ for j, t in enumerate(taus):
472
+ tau[j, :len(t)] = t
473
+ return {"rho": R, "tau": tau}
474
+
475
+
476
+ def polychoric_biplot(X, dim=2, logistic_scale=1.702, penalization=1.0, row_names=None, col_names=None):
477
+ """Ordinal biplot from the factorization of the polychoric correlation
478
+ matrix (``MultBiplotR::PolyBiplot``, fm = 'pca'): loadings of the
479
+ principal components of the polychoric matrix converted to the
480
+ cumulative-logit parameters b_j = c * lambda_j / sqrt(1 - h_j) and
481
+ thresholds tau_jk / sqrt(1 - h_j) (c = 1.702, normal-ogive to logistic),
482
+ then row scores by penalized maximum likelihood with those parameters fixed
483
+ (``penalization`` = 1 is a standard normal prior on the scores, which keeps
484
+ the scores of all-extreme response patterns finite)."""
485
+ if isinstance(X, pd.DataFrame):
486
+ row_names = row_names or [str(i) for i in X.index]; col_names = col_names or [str(c) for c in X.columns]
487
+ Xm = np.asarray(X, int)
488
+ n, p = Xm.shape
489
+ pc = polychoric(Xm)
490
+ R = pc["rho"]
491
+ ev = np.linalg.eigvalsh(R)
492
+ if ev.min() < 0:
493
+ from scipy.linalg import eigh
494
+ w, Vv = eigh(R); w = np.maximum(w, 1e-6); R = Vv @ np.diag(w) @ Vv.T
495
+ dd = np.sqrt(np.diag(R)); R = R / np.outer(dd, dd)
496
+ ev, U = np.linalg.eigh(R); ev, U = ev[::-1], U[:, ::-1]
497
+ L = U[:, :dim] * np.sqrt(ev[:dim])
498
+ h = (L ** 2).sum(1)
499
+ s = np.sqrt(np.maximum(1 - h, 1e-6))
500
+ B = logistic_scale * L / s[:, None]
501
+ thr = logistic_scale * pc["tau"] / s[:, None]
502
+ ncat = [int(np.sum(np.isfinite(thr[j]))) + 1 for j in range(p)]
503
+
504
+ def negll(a, x):
505
+ eta = B @ a
506
+ ll = 0.0
507
+ for j in range(p):
508
+ t = np.r_[-np.inf, thr[j, :ncat[j] - 1], np.inf]
509
+ c = x[j]
510
+ pr = expit(t[c] - eta[j]) - expit(t[c - 1] - eta[j])
511
+ ll += np.log(max(pr, 1e-300))
512
+ return -ll + 0.5 * penalization * a @ a
513
+ A = np.array([optimize.minimize(negll, np.zeros(dim), args=(Xm[i],), method="BFGS").x for i in range(n)])
514
+ from .logistic import LogisticBiplot
515
+ return LogisticBiplot(title="Polychoric (ordinal) Biplot", n=n, p=p, dim=dim, penalization=penalization, A=A, B=B,
516
+ thresholds=[thr[j, :ncat[j] - 1] for j in range(p)], loadings=L, communalities=h,
517
+ polychoric=R, objective=np.nan, converged=True, columns=pd.DataFrame(
518
+ np.column_stack([L, h]), index=col_names or [f"V{j + 1}" for j in range(p)],
519
+ columns=[f"Dim {k + 1}" for k in range(dim)] + ["Communality"]),
520
+ global_percent=np.nan, row_names=row_names or [f"I{i + 1}" for i in range(n)],
521
+ col_names=col_names or [f"V{j + 1}" for j in range(p)], fitted=None)
biplotpy/gxe.py ADDED
@@ -0,0 +1,128 @@
1
+ """Genotype x environment biplots: GGE biplot (Yan et al., 2000; port of
2
+ ``GGEBiplots::GGEModel``) and AMMI (Gauch, 1992; port of ``agricolae::AMMI``)."""
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from scipy import stats as sps
8
+
9
+
10
+ def gge_model(data, centering="tester", scaling="none", svp="column") -> dict:
11
+ """GGE biplot of a genotype (rows) x environment (columns) table of means.
12
+
13
+ ``centering``: none, global, tester (environment-centred, default) or
14
+ double; ``scaling``: none or sd; ``svp``: row, column, dual, symmetrical or
15
+ gge. Coordinates are rescaled as in GGEBiplots for display."""
16
+ if isinstance(data, pd.DataFrame):
17
+ gen, env = [str(i) for i in data.index], [str(c) for c in data.columns]
18
+ D = data.to_numpy(float)
19
+ else:
20
+ D = np.asarray(data, float)
21
+ gen = [f"G{i + 1}" for i in range(D.shape[0])]
22
+ env = [f"E{j + 1}" for j in range(D.shape[1])]
23
+ if np.isnan(D).any():
24
+ raise ValueError("missing data in input")
25
+ if centering in (1, "global"):
26
+ D = D - D.mean()
27
+ elif centering in (2, "tester"):
28
+ D = D - D.mean(0)
29
+ elif centering in (3, "double"):
30
+ D = D + D.mean() - D.mean(0)[None, :] - D.mean(1)[:, None]
31
+ if scaling in (1, "sd"):
32
+ D = D / D.std(0, ddof=1)
33
+ U, s, Vt = np.linalg.svd(D, full_matrices=False)
34
+ V = Vt.T
35
+ n = D.shape[0]
36
+
37
+ def ratio(a, b):
38
+ return max((a[:, 0].max() - a[:, 0].min()) / (b[:, 0].max() - b[:, 0].min()),
39
+ (a[:, 1].max() - a[:, 1].min()) / (b[:, 1].max() - b[:, 1].min()))
40
+ if svp in (0, "gge"):
41
+ G = U * np.sqrt(n - 1)
42
+ E = D.T @ U / np.sqrt(n - 1)
43
+ E = E / ratio(E, G)
44
+ elif svp in (1, "row"):
45
+ G = U * s
46
+ E = V / ratio(V, G)
47
+ elif svp in (2, "column"):
48
+ E = V * s
49
+ G = U / ratio(U, E)
50
+ elif svp in (3, "dual"):
51
+ G, E = U * s, V * s
52
+ elif svp in (4, "symmetrical"):
53
+ G, E = U * np.sqrt(s), V * np.sqrt(s)
54
+ else:
55
+ raise ValueError(f"unknown SVP {svp}")
56
+ vartotal = np.sum(s ** 2)
57
+ return {"coordgenotype": G, "coordenviroment": E, "eigenvalues": s, "vartotal": vartotal,
58
+ "varexpl": s ** 2 / vartotal * 100, "labelgen": gen, "labelenv": env, "data": D,
59
+ "centering": centering, "scaling": scaling, "svp": svp}
60
+
61
+
62
+ def ammi(env, gen, rep, y) -> dict:
63
+ """AMMI analysis from plot data (environment, genotype, replicate within
64
+ environment, response). Returns the ANOVA, the decomposition of the
65
+ interaction in principal components (Gollob degrees of freedom) and the
66
+ biplot scores, as ``agricolae::AMMI`` with replicates."""
67
+ df = pd.DataFrame({"ENV": pd.Categorical(np.asarray(env).astype(str)),
68
+ "GEN": pd.Categorical(np.asarray(gen).astype(str)),
69
+ "REP": pd.Categorical(np.asarray(rep).astype(str)), "Y": np.asarray(y, float)})
70
+ e_lev = sorted(df["ENV"].unique()); g_lev = sorted(df["GEN"].unique())
71
+ nenv, ngen = len(e_lev), len(g_lev)
72
+ nrep = df["REP"].nunique()
73
+ gm = df["Y"].mean()
74
+ # sequential sums of squares for Y ~ ENV + REP%in%ENV + GEN + ENV:GEN (balanced design)
75
+ me = df.groupby("ENV", observed=True)["Y"].mean()
76
+ mg = df.groupby("GEN", observed=True)["Y"].mean()
77
+ mre = df.groupby(["ENV", "REP"], observed=True)["Y"].mean()
78
+ mge = df.groupby(["ENV", "GEN"], observed=True)["Y"].mean()
79
+ N = len(df)
80
+ ss_env = N / nenv * np.sum((me - gm) ** 2)
81
+ ss_rep = np.sum(df.groupby(["ENV", "REP"], observed=True).size() * (mre.to_numpy() - me.reindex(mre.index.get_level_values(0)).to_numpy()) ** 2)
82
+ ss_gen = N / ngen * np.sum((mg - gm) ** 2)
83
+ cell = mge.reset_index()
84
+ cell["int"] = (cell["Y"].to_numpy() - me.reindex(cell["ENV"].astype(str)).to_numpy()
85
+ - mg.reindex(cell["GEN"].astype(str)).to_numpy() + gm)
86
+ ss_ge = nrep * np.sum(cell["int"] ** 2)
87
+ ss_tot = np.sum((df["Y"] - gm) ** 2)
88
+ ss_res = ss_tot - ss_env - ss_rep - ss_gen - ss_ge
89
+ df_env, df_rep, df_gen = nenv - 1, nenv * (nrep - 1), ngen - 1
90
+ df_ge = df_env * df_gen
91
+ df_res = N - 1 - df_env - df_rep - df_gen - df_ge
92
+ mse = ss_res / df_res
93
+ rows = {"ENV": (df_env, ss_env), "REP(ENV)": (df_rep, ss_rep), "GEN": (df_gen, ss_gen),
94
+ "ENV:GEN": (df_ge, ss_ge), "Residuals": (df_res, ss_res)}
95
+ anova = pd.DataFrame(rows, index=["Df", "Sum Sq"]).T
96
+ anova["Mean Sq"] = anova["Sum Sq"] / anova["Df"]
97
+ anova["F value"] = np.nan; anova["Pr(>F)"] = np.nan
98
+ ms = anova["Mean Sq"]
99
+ anova.loc["ENV", "F value"] = ms["ENV"] / ms["REP(ENV)"]
100
+ anova.loc["ENV", "Pr(>F)"] = sps.f.sf(anova.loc["ENV", "F value"], df_env, df_rep)
101
+ for r in ("REP(ENV)", "GEN", "ENV:GEN"):
102
+ anova.loc[r, "F value"] = ms[r] / mse
103
+ anova.loc[r, "Pr(>F)"] = sps.f.sf(anova.loc[r, "F value"], anova.loc[r, "Df"], df_res)
104
+ # interaction matrix (genotypes x environments) and its SVD
105
+ I = cell.pivot(index="GEN", columns="ENV", values="int").loc[g_lev, e_lev].to_numpy()
106
+ U, L, Vt = np.linalg.svd(I, full_matrices=False)
107
+ minimo = min(ngen, nenv)
108
+ L = L[:minimo]
109
+ SS = L ** 2 * nrep
110
+ percent = SS / SS.sum() * 100
111
+ out = []
112
+ for i in range(1, minimo):
113
+ DF = (ngen - 1) + (nenv - 1) - (2 * i - 1)
114
+ if DF <= 0:
115
+ break
116
+ msi = SS[i - 1] / DF
117
+ F = msi / mse
118
+ out.append([percent[i - 1], percent[:i].sum(), DF, SS[i - 1], msi, F, sps.f.sf(F, DF, df_res)])
119
+ ssammi = pd.DataFrame(out, columns=["percent", "acum", "Df", "Sum Sq", "Mean Sq", "F value", "Pr.F"],
120
+ index=[f"PC{i + 1}" for i in range(len(out))])
121
+ LL = np.sqrt(L)
122
+ scores_g = U * LL
123
+ scores_e = Vt.T * LL
124
+ means_g = mge.reset_index().groupby("GEN", observed=True)["Y"].mean().loc[g_lev]
125
+ means_e = me.loc[e_lev]
126
+ return {"anova": anova, "analysis": ssammi, "genotype_scores": pd.DataFrame(scores_g, index=g_lev),
127
+ "environment_scores": pd.DataFrame(scores_e, index=e_lev), "genotype_means": means_g,
128
+ "environment_means": means_e, "interaction": pd.DataFrame(I, index=g_lev, columns=e_lev), "MSE": mse}