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/__init__.py +69 -0
- biplotpy/bootdist.py +174 -0
- biplotpy/canonical.py +198 -0
- biplotpy/cdpca.py +121 -0
- biplotpy/classical.py +159 -0
- biplotpy/correspondence.py +231 -0
- biplotpy/datasets/__init__.py +19 -0
- biplotpy/datasets/iris.csv +151 -0
- biplotpy/datasets/mtcars.csv +33 -0
- biplotpy/distances.py +383 -0
- biplotpy/distatis.py +69 -0
- biplotpy/dynamic.py +62 -0
- biplotpy/extras.py +158 -0
- biplotpy/factor.py +521 -0
- biplotpy/gxe.py +128 -0
- biplotpy/logfreq.py +107 -0
- biplotpy/logistic.py +343 -0
- biplotpy/multitest.py +281 -0
- biplotpy/plot.py +873 -0
- biplotpy/plsbin.py +224 -0
- biplotpy/restricted.py +300 -0
- biplotpy/roptim.py +104 -0
- biplotpy/rrng.py +119 -0
- biplotpy/statis.py +162 -0
- biplotpy/transforms.py +129 -0
- biplotpy/unfolding.py +74 -0
- biplotpy/utils.py +79 -0
- biplotpy-0.4.0.dist-info/METADATA +242 -0
- biplotpy-0.4.0.dist-info/RECORD +32 -0
- biplotpy-0.4.0.dist-info/WHEEL +5 -0
- biplotpy-0.4.0.dist-info/licenses/LICENSE +674 -0
- biplotpy-0.4.0.dist-info/top_level.txt +1 -0
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}
|