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/plot.py
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
"""Graphics for every biplot in biplotpy (matplotlib).
|
|
2
|
+
|
|
3
|
+
Classical and canonical biplots (arrows or calibrated axes, group ellipses,
|
|
4
|
+
convex hulls, alpha-bags, densities, CVA classification regions,
|
|
5
|
+
correlation circle, contributions, scree plot, 3-D biplot), bootstrap
|
|
6
|
+
regions, logistic biplots (binary, ordinal, nominal prediction regions),
|
|
7
|
+
STATIS family, dynamic biplot, unfolding, DISTATIS, CDPCA, GGE biplot (the
|
|
8
|
+
ten views of ``GGEBiplots::GGEPlot``), AMMI1/AMMI2, correspondence-type
|
|
9
|
+
biplots (CA, MCA, CCA and RDA triplots), PCoA/MDS, external logistic
|
|
10
|
+
biplot, canonical analysis of distances, co-inertia and PLS biplots.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .extras import biplot_scales, calibrated_axis
|
|
17
|
+
from .utils import r_pretty
|
|
18
|
+
|
|
19
|
+
GREY = "0.25"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ax(ax, size=(7, 6.5)):
|
|
23
|
+
import matplotlib.pyplot as plt
|
|
24
|
+
if ax is None:
|
|
25
|
+
_, ax = plt.subplots(figsize=size)
|
|
26
|
+
return ax
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _colors(k):
|
|
30
|
+
import matplotlib.pyplot as plt
|
|
31
|
+
cmap = plt.get_cmap("tab10")
|
|
32
|
+
return [cmap(i % 10) for i in range(k)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _finish(ax, lim, xlabel="", ylabel="", title="", axes0=True):
|
|
36
|
+
if axes0:
|
|
37
|
+
ax.axhline(0, ls=":", color="0.65", lw=0.8); ax.axvline(0, ls=":", color="0.65", lw=0.8)
|
|
38
|
+
if np.isscalar(lim):
|
|
39
|
+
ax.set_xlim(-lim, lim); ax.set_ylim(-lim, lim)
|
|
40
|
+
else:
|
|
41
|
+
ax.set_xlim(lim[0], lim[1]); ax.set_ylim(lim[2], lim[3])
|
|
42
|
+
ax.set_aspect("equal", adjustable="box")
|
|
43
|
+
ax.set_xlabel(xlabel); ax.set_ylabel(ylabel)
|
|
44
|
+
if title:
|
|
45
|
+
ax.set_title(title, fontsize=10)
|
|
46
|
+
return ax
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dimlab(obj, i):
|
|
50
|
+
iner = getattr(obj, "inertia", None)
|
|
51
|
+
if iner is not None and len(np.atleast_1d(iner)) > i and np.isfinite(np.atleast_1d(iner)[i]):
|
|
52
|
+
return f"Dim {i + 1} ({np.atleast_1d(iner)[i]:.1f}%)"
|
|
53
|
+
return f"Dim {i + 1}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _arrows(ax, B, names, color=GREY, scale=1.0, fontsize=8, lw=1.0):
|
|
57
|
+
for k, name in enumerate(names):
|
|
58
|
+
ax.annotate("", xy=scale * B[k], xytext=(0, 0),
|
|
59
|
+
arrowprops=dict(arrowstyle="-|>", color=color, lw=lw, shrinkA=0, shrinkB=0, mutation_scale=9))
|
|
60
|
+
ax.text(*(scale * B[k] * 1.07), str(name), fontsize=fontsize, color=color,
|
|
61
|
+
ha="left" if B[k, 0] >= 0 else "right", va="bottom" if B[k, 1] >= 0 else "top")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- group overlays
|
|
65
|
+
def concentration_ellipse(P, level=0.95, n=100):
|
|
66
|
+
"""Normal-theory concentration ellipse of a 2-D point cloud."""
|
|
67
|
+
P = np.asarray(P, float)
|
|
68
|
+
m = P.mean(0)
|
|
69
|
+
S = np.cov(P, rowvar=False)
|
|
70
|
+
from scipy import stats as sps
|
|
71
|
+
r = np.sqrt(sps.chi2.ppf(level, 2))
|
|
72
|
+
w, U = np.linalg.eigh(S)
|
|
73
|
+
th = np.linspace(0, 2 * np.pi, n)
|
|
74
|
+
return m + (np.column_stack([np.cos(th), np.sin(th)]) * r * np.sqrt(np.maximum(w, 0))) @ U.T
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def convex_hull(P):
|
|
78
|
+
from scipy.spatial import ConvexHull
|
|
79
|
+
P = np.asarray(P, float)
|
|
80
|
+
if len(P) < 3:
|
|
81
|
+
return P
|
|
82
|
+
h = ConvexHull(P)
|
|
83
|
+
return P[np.r_[h.vertices, h.vertices[0]]]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def alpha_bag(P, alpha=0.95):
|
|
87
|
+
"""Approximate alpha-bag (biplotEZ): convex hull of the ``alpha`` share of
|
|
88
|
+
the deepest points, depth measured by convex-hull peeling."""
|
|
89
|
+
from scipy.spatial import ConvexHull
|
|
90
|
+
P = np.asarray(P, float)
|
|
91
|
+
idx = np.arange(len(P))
|
|
92
|
+
target = int(np.ceil(alpha * len(P)))
|
|
93
|
+
while len(idx) > max(target, 3):
|
|
94
|
+
try:
|
|
95
|
+
h = ConvexHull(P[idx])
|
|
96
|
+
except Exception:
|
|
97
|
+
break
|
|
98
|
+
if len(idx) - len(h.vertices) < target:
|
|
99
|
+
# remove only the most outlying hull vertices (farthest from the median)
|
|
100
|
+
med = np.median(P[idx], 0)
|
|
101
|
+
far = h.vertices[np.argsort(-((P[idx][h.vertices] - med) ** 2).sum(1))]
|
|
102
|
+
idx = np.delete(idx, far[:len(idx) - target])
|
|
103
|
+
break
|
|
104
|
+
idx = np.delete(idx, h.vertices)
|
|
105
|
+
return convex_hull(P[idx])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _group_overlay(ax, A, groups, kind, level, colors, levels):
|
|
109
|
+
from scipy.stats import gaussian_kde
|
|
110
|
+
for k, g in enumerate(levels):
|
|
111
|
+
P = A[groups == g]
|
|
112
|
+
if len(P) < 3:
|
|
113
|
+
continue
|
|
114
|
+
if kind == "ellipse":
|
|
115
|
+
E = concentration_ellipse(P, level); ax.plot(E[:, 0], E[:, 1], color=colors[k], lw=1.2)
|
|
116
|
+
elif kind == "hull":
|
|
117
|
+
H = convex_hull(P); ax.fill(H[:, 0], H[:, 1], color=colors[k], alpha=0.12, lw=1, ec=colors[k])
|
|
118
|
+
elif kind == "bag":
|
|
119
|
+
H = alpha_bag(P, level); ax.fill(H[:, 0], H[:, 1], color=colors[k], alpha=0.15, lw=1, ec=colors[k])
|
|
120
|
+
elif kind == "density":
|
|
121
|
+
kde = gaussian_kde(P.T)
|
|
122
|
+
xs = np.linspace(P[:, 0].min() - P[:, 0].std(), P[:, 0].max() + P[:, 0].std(), 80)
|
|
123
|
+
ys = np.linspace(P[:, 1].min() - P[:, 1].std(), P[:, 1].max() + P[:, 1].std(), 80)
|
|
124
|
+
XX, YY = np.meshgrid(xs, ys)
|
|
125
|
+
Z = kde(np.vstack([XX.ravel(), YY.ravel()])).reshape(XX.shape)
|
|
126
|
+
ax.contour(XX, YY, Z, levels=4, colors=[colors[k]], linewidths=0.8, alpha=0.8)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------- classical biplots
|
|
130
|
+
def plot_biplot(bip, dims=(0, 1), ax=None, groups=None, show_rows=True, row_labels=False, arrows=True,
|
|
131
|
+
calibrated=False, nticks=4, type_scale="Complete", values_scale="Original", col_color=GREY,
|
|
132
|
+
group_shape=None, level=0.95, color_by=None, cmap="viridis", min_quality=0.0, sup=True,
|
|
133
|
+
show_cols=True, density=False):
|
|
134
|
+
"""Classical (GH, JK, SQRT, HJ, FA, NIPALS, PLS) biplot.
|
|
135
|
+
|
|
136
|
+
``groups``: colour rows by group; ``group_shape``: 'ellipse'
|
|
137
|
+
(concentration), 'hull', 'bag' (alpha-bag) or 'density' per group;
|
|
138
|
+
``calibrated=True`` draws predictive calibrated axes (biplotEZ /
|
|
139
|
+
MultBiplotR); ``color_by='quality'`` colours the rows by their quality of
|
|
140
|
+
representation on the plane (factoextra cos2); ``min_quality`` hides
|
|
141
|
+
variables below that quality (%); ``density`` draws a 2-D density of all
|
|
142
|
+
rows (MultBiplotR DensityBiplot)."""
|
|
143
|
+
import matplotlib.pyplot as plt
|
|
144
|
+
ax = _ax(ax)
|
|
145
|
+
i, j = dims
|
|
146
|
+
A, B = bip.row_coordinates[:, [i, j]], bip.col_coordinates[:, [i, j]]
|
|
147
|
+
lim = max(np.abs(A).max() if show_rows else 0, np.abs(B).max() if show_cols else 0) * 1.12
|
|
148
|
+
if show_rows:
|
|
149
|
+
if density:
|
|
150
|
+
from scipy.stats import gaussian_kde
|
|
151
|
+
kde = gaussian_kde(A.T)
|
|
152
|
+
xs = np.linspace(-lim, lim, 100); XX, YY = np.meshgrid(xs, xs)
|
|
153
|
+
Z = kde(np.vstack([XX.ravel(), YY.ravel()])).reshape(XX.shape)
|
|
154
|
+
ax.contourf(XX, YY, Z, levels=8, cmap="Greys", alpha=0.5)
|
|
155
|
+
if groups is None:
|
|
156
|
+
if color_by == "quality" and getattr(bip, "row_contributions", None) is not None:
|
|
157
|
+
q = bip.row_contributions[:, [i, j]].sum(1)
|
|
158
|
+
sc = ax.scatter(A[:, 0], A[:, 1], s=16, c=q, cmap=cmap, vmin=0, vmax=100)
|
|
159
|
+
plt.colorbar(sc, ax=ax, shrink=0.7, label="Quality (%)")
|
|
160
|
+
else:
|
|
161
|
+
ax.scatter(A[:, 0], A[:, 1], s=14, color="tab:blue", alpha=0.7)
|
|
162
|
+
else:
|
|
163
|
+
groups = np.asarray(groups)
|
|
164
|
+
levels = list(dict.fromkeys(groups))
|
|
165
|
+
cols = _colors(len(levels))
|
|
166
|
+
for k, g in enumerate(levels):
|
|
167
|
+
m = groups == g
|
|
168
|
+
ax.scatter(A[m, 0], A[m, 1], s=14, color=cols[k], alpha=0.7, label=str(g))
|
|
169
|
+
if group_shape:
|
|
170
|
+
_group_overlay(ax, A, groups, group_shape, level, cols, levels)
|
|
171
|
+
ax.legend(fontsize=8, frameon=False)
|
|
172
|
+
if row_labels:
|
|
173
|
+
for r, name in enumerate(bip.row_names):
|
|
174
|
+
ax.annotate(name, A[r], fontsize=6, alpha=0.7, xytext=(2, 2), textcoords="offset points")
|
|
175
|
+
if sup and getattr(bip, "sup_row_coordinates", None) is not None:
|
|
176
|
+
S = bip.sup_row_coordinates[:, [i, j]]
|
|
177
|
+
ax.scatter(S[:, 0], S[:, 1], marker="^", color="tab:red", s=22, label="supplementary")
|
|
178
|
+
if show_cols:
|
|
179
|
+
keep = np.ones(len(B), bool)
|
|
180
|
+
if min_quality > 0 and getattr(bip, "col_contributions", None) is not None:
|
|
181
|
+
keep = bip.col_contributions[:, [i, j]].sum(1) >= min_quality
|
|
182
|
+
names = np.asarray(bip.col_names)
|
|
183
|
+
if calibrated:
|
|
184
|
+
sc = biplot_scales(bip, nticks, type_scale, values_scale)
|
|
185
|
+
for k in np.where(keep)[0]:
|
|
186
|
+
b = B[k]
|
|
187
|
+
u = b / np.linalg.norm(b)
|
|
188
|
+
ax.plot([-lim * u[0], lim * u[0]], [-lim * u[1], lim * u[1]], color=col_color, lw=0.6)
|
|
189
|
+
pos = calibrated_axis(b, sc["ticks"][k])
|
|
190
|
+
perp = np.array([-u[1], u[0]]) * lim * 0.012
|
|
191
|
+
for (x, y), lab in zip(pos, sc["labels"][k]):
|
|
192
|
+
if abs(x) <= lim and abs(y) <= lim:
|
|
193
|
+
ax.plot([x - perp[0], x + perp[0]], [y - perp[1], y + perp[1]], color=col_color, lw=0.6)
|
|
194
|
+
ax.text(x + 2.5 * perp[0], y + 2.5 * perp[1], f"{lab:g}", fontsize=6, color=col_color)
|
|
195
|
+
ax.text(*(u * lim * 1.02), names[k], fontsize=8, color=col_color)
|
|
196
|
+
elif arrows:
|
|
197
|
+
_arrows(ax, B[keep], names[keep], col_color)
|
|
198
|
+
if getattr(bip, "y_coordinates", None) is not None:
|
|
199
|
+
_arrows(ax, bip.y_coordinates[:, [i, j]], bip.y_names, "tab:red")
|
|
200
|
+
if sup and getattr(bip, "sup_col_coordinates", None) is not None:
|
|
201
|
+
_arrows(ax, bip.sup_col_coordinates[:, [i, j]], [f"S{k + 1}" for k in range(len(bip.sup_col_coordinates))],
|
|
202
|
+
"tab:red")
|
|
203
|
+
return _finish(ax, lim, _dimlab(bip, i), _dimlab(bip, j), bip.title)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def plot_correlation_circle(bip, dims=(0, 1), ax=None, color_by_quality=True):
|
|
207
|
+
"""Correlation circle (factoextra ``fviz_pca_var``): correlations of the
|
|
208
|
+
variables with the row coordinates (structure)."""
|
|
209
|
+
import matplotlib.pyplot as plt
|
|
210
|
+
ax = _ax(ax, (6, 6))
|
|
211
|
+
i, j = dims
|
|
212
|
+
S = np.asarray(bip.structure)[:, [i, j]]
|
|
213
|
+
th = np.linspace(0, 2 * np.pi, 200)
|
|
214
|
+
ax.plot(np.cos(th), np.sin(th), color="0.5", lw=0.8)
|
|
215
|
+
q = (S ** 2).sum(1)
|
|
216
|
+
cm = plt.get_cmap("Blues")
|
|
217
|
+
for k, name in enumerate(bip.col_names):
|
|
218
|
+
c = cm(0.35 + 0.65 * min(q[k], 1)) if color_by_quality else GREY
|
|
219
|
+
ax.annotate("", xy=S[k], xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color=c, mutation_scale=9))
|
|
220
|
+
ax.text(*(S[k] * 1.08), name, fontsize=8, color=c, ha="left" if S[k, 0] >= 0 else "right")
|
|
221
|
+
return _finish(ax, 1.15, _dimlab(bip, i), _dimlab(bip, j), "Correlation circle")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def plot_scree(obj, ax=None, n=None, cumulative=True):
|
|
225
|
+
ax = _ax(ax, (6, 4))
|
|
226
|
+
iner = np.asarray(obj.inertia)
|
|
227
|
+
n = n or min(len(iner), 10)
|
|
228
|
+
x = np.arange(1, n + 1)
|
|
229
|
+
ax.bar(x, iner[:n], color="0.6")
|
|
230
|
+
for xi, v in zip(x, iner[:n]):
|
|
231
|
+
ax.text(xi, v, f"{v:.1f}", ha="center", va="bottom", fontsize=7)
|
|
232
|
+
if cumulative:
|
|
233
|
+
ax.plot(x, np.cumsum(iner)[:n], "o-", color="tab:blue", ms=3, lw=1)
|
|
234
|
+
ax.set_xticks(x); ax.set_xlabel("Dimension"); ax.set_ylabel("Explained inertia (%)")
|
|
235
|
+
ax.set_title("Scree plot", fontsize=10)
|
|
236
|
+
return ax
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def plot_contributions(obj, which="cols", dim=0, ax=None, top=None):
|
|
240
|
+
"""Contribution bars (factoextra ``fviz_contrib``) with the expected
|
|
241
|
+
average contribution as a dashed line."""
|
|
242
|
+
ax = _ax(ax, (7, 4))
|
|
243
|
+
if which == "cols":
|
|
244
|
+
b = np.asarray(obj.col_coordinates)[:, dim]; names = list(obj.col_names)
|
|
245
|
+
else:
|
|
246
|
+
b = np.asarray(obj.row_coordinates)[:, dim]; names = list(obj.row_names)
|
|
247
|
+
c = b ** 2 / np.sum(b ** 2) * 100
|
|
248
|
+
o = np.argsort(-c)[:top or len(c)]
|
|
249
|
+
ax.bar(range(len(o)), c[o], color="tab:blue")
|
|
250
|
+
ax.axhline(100 / len(c), ls="--", color="tab:red", lw=1)
|
|
251
|
+
ax.set_xticks(range(len(o))); ax.set_xticklabels([names[k] for k in o], rotation=60, ha="right", fontsize=7)
|
|
252
|
+
ax.set_ylabel("Contribution (%)"); ax.set_title(f"Contributions to Dim {dim + 1}", fontsize=10)
|
|
253
|
+
return ax
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def plot_biplot_3d(bip, dims=(0, 1, 2), groups=None, ax=None, arrows=True):
|
|
257
|
+
"""3-D biplot (bpca)."""
|
|
258
|
+
import matplotlib.pyplot as plt
|
|
259
|
+
if ax is None:
|
|
260
|
+
fig = plt.figure(figsize=(7, 6)); ax = fig.add_subplot(111, projection="3d")
|
|
261
|
+
A, B = bip.row_coordinates[:, list(dims)], bip.col_coordinates[:, list(dims)]
|
|
262
|
+
if groups is None:
|
|
263
|
+
ax.scatter(*A.T, s=10, color="tab:blue", alpha=0.7)
|
|
264
|
+
else:
|
|
265
|
+
groups = np.asarray(groups); levels = list(dict.fromkeys(groups)); cols = _colors(len(levels))
|
|
266
|
+
for k, g in enumerate(levels):
|
|
267
|
+
ax.scatter(*A[groups == g].T, s=10, color=cols[k], label=str(g))
|
|
268
|
+
ax.legend(fontsize=8)
|
|
269
|
+
if arrows:
|
|
270
|
+
for k, name in enumerate(bip.col_names):
|
|
271
|
+
ax.plot([0, B[k, 0]], [0, B[k, 1]], [0, B[k, 2]], color=GREY)
|
|
272
|
+
ax.text(*(B[k] * 1.08), name, fontsize=8, color=GREY)
|
|
273
|
+
ax.set_xlabel(_dimlab(bip, dims[0])); ax.set_ylabel(_dimlab(bip, dims[1])); ax.set_zlabel(_dimlab(bip, dims[2]))
|
|
274
|
+
ax.set_title(bip.title, fontsize=10)
|
|
275
|
+
return ax
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def plot_bootstrap(bip, boot, dims=(0, 1), ax=None, kind="ellipse", level=0.95):
|
|
279
|
+
"""Bootstrap confidence regions for the variable markers of a PCA biplot
|
|
280
|
+
(``pca_bootstrap``): ellipse or convex hull of the replicates."""
|
|
281
|
+
ax = plot_biplot(bip, dims, ax=ax, show_rows=True, arrows=True)
|
|
282
|
+
L = np.asarray(boot["loadings"]) # B x p x dim, unit eigenvectors (sign-aligned)
|
|
283
|
+
ev = np.asarray(boot["eigenvalues"]) # B x k singular values
|
|
284
|
+
i, j = dims
|
|
285
|
+
alpha = bip.alpha if bip.alpha <= 1 else 1.0
|
|
286
|
+
fac = ev[:, [i, j]] ** (1 - alpha) / (bip.scale_factor if bip.alpha <= 1 else 1.0)
|
|
287
|
+
for k in range(L.shape[1]):
|
|
288
|
+
P = L[:, k, :][:, [i, j]] * fac
|
|
289
|
+
if kind == "ellipse":
|
|
290
|
+
E = concentration_ellipse(P, level); ax.plot(E[:, 0], E[:, 1], color="tab:orange", lw=0.9)
|
|
291
|
+
else:
|
|
292
|
+
H = convex_hull(P); ax.plot(H[:, 0], H[:, 1], color="tab:orange", lw=0.9)
|
|
293
|
+
ax.set_title("Bootstrap biplot", fontsize=10)
|
|
294
|
+
return ax
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# --------------------------------------------------------------------------- canonical biplot
|
|
298
|
+
def plot_canonical(cb, dims=(0, 1), ax=None, radius="bonferroni", show_individuals=True, arrows=True,
|
|
299
|
+
regions=False, calibrated=False, group_shape=None, nticks=4, level=0.95):
|
|
300
|
+
"""Canonical/MANOVA (CVA) biplot: group markers with confidence circles,
|
|
301
|
+
individuals and variables. ``regions=True`` shades the classification
|
|
302
|
+
regions (nearest group mean in the canonical space, biplotEZ);
|
|
303
|
+
``calibrated=True`` draws calibrated axes in the original units;
|
|
304
|
+
``group_shape`` adds ellipses / hulls / alpha-bags of the individuals."""
|
|
305
|
+
ax = _ax(ax)
|
|
306
|
+
i, j = dims
|
|
307
|
+
J, H, V = cb.group_coordinates[:, [i, j]], cb.col_coordinates[:, [i, j]], cb.row_coordinates[:, [i, j]]
|
|
308
|
+
cols = _colors(len(cb.group_names))
|
|
309
|
+
rad = cb.radii(radius).values
|
|
310
|
+
lim = max(np.abs(V).max() if show_individuals else 0, (np.abs(J) + rad[:, None]).max()) * 1.1
|
|
311
|
+
if regions:
|
|
312
|
+
xs = np.linspace(-lim, lim, 300); XX, YY = np.meshgrid(xs, xs)
|
|
313
|
+
G = np.column_stack([XX.ravel(), YY.ravel()])
|
|
314
|
+
k = ((G[:, None, :] - J[None]) ** 2).sum(2).argmin(1).reshape(XX.shape)
|
|
315
|
+
from matplotlib.colors import ListedColormap
|
|
316
|
+
ax.contourf(XX, YY, k, levels=np.arange(-0.5, len(J)), cmap=ListedColormap(cols[:len(J)]), alpha=0.12)
|
|
317
|
+
th = np.linspace(0, 2 * np.pi, 100)
|
|
318
|
+
for k, g in enumerate(cb.group_names):
|
|
319
|
+
c = cols[k]
|
|
320
|
+
m = np.asarray(cb.group) == g
|
|
321
|
+
if show_individuals:
|
|
322
|
+
ax.scatter(V[m, 0], V[m, 1], s=10, color=c, alpha=0.4)
|
|
323
|
+
if group_shape:
|
|
324
|
+
_group_overlay(ax, V, np.asarray(cb.group), group_shape, level, cols, list(cb.group_names))
|
|
325
|
+
ax.plot(J[k, 0] + rad[k] * np.cos(th), J[k, 1] + rad[k] * np.sin(th), color=c, lw=1.4)
|
|
326
|
+
ax.scatter(*J[k], color=c, s=50, zorder=3)
|
|
327
|
+
ax.annotate(g, J[k], textcoords="offset points", xytext=(0, 7), ha="center", color=c, fontweight="bold")
|
|
328
|
+
if calibrated:
|
|
329
|
+
center, scale = np.broadcast_to(cb._t_center, (cb.p,)), np.broadcast_to(cb._t_scale, (cb.p,))
|
|
330
|
+
X0 = cb.scaled_data * scale + center
|
|
331
|
+
for k, name in enumerate(cb.col_names):
|
|
332
|
+
b = H[k]; nb = np.linalg.norm(b)
|
|
333
|
+
if nb == 0:
|
|
334
|
+
continue
|
|
335
|
+
u = b / nb
|
|
336
|
+
ax.plot([-lim * u[0], lim * u[0]], [-lim * u[1], lim * u[1]], color=GREY, lw=0.6)
|
|
337
|
+
vals = r_pretty([X0[:, k].min(), X0[:, k].max()], nticks)
|
|
338
|
+
pos = calibrated_axis(b, (vals - center[k]) / scale[k])
|
|
339
|
+
perp = np.array([-u[1], u[0]]) * lim * 0.012
|
|
340
|
+
for (x, y), lab in zip(pos, vals):
|
|
341
|
+
if abs(x) <= lim and abs(y) <= lim:
|
|
342
|
+
ax.plot([x - perp[0], x + perp[0]], [y - perp[1], y + perp[1]], color=GREY, lw=0.6)
|
|
343
|
+
ax.text(x + 2.5 * perp[0], y + 2.5 * perp[1], f"{lab:g}", fontsize=6, color=GREY)
|
|
344
|
+
ax.text(*(u * lim * 1.02), name, fontsize=8, color=GREY)
|
|
345
|
+
elif arrows:
|
|
346
|
+
esc = 0.8 * lim / np.sqrt((H ** 2).sum(1)).max()
|
|
347
|
+
_arrows(ax, esc * H, cb.col_names)
|
|
348
|
+
return _finish(ax, lim, _dimlab(cb, i), _dimlab(cb, j), cb.title)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --------------------------------------------------------------------------- logistic biplots
|
|
352
|
+
def plot_logistic(lb, dims=(0, 1), ax=None, groups=None, probs=(0.5, 0.75), row_labels=False, variables=None):
|
|
353
|
+
"""Binary logistic biplot: each variable is a line in the direction of its
|
|
354
|
+
slopes, marked at the points predicting the probabilities in ``probs``
|
|
355
|
+
(0.5 and 0.75 by default, as MultBiplotR); ordinal logistic biplot: the
|
|
356
|
+
line is cut at the category boundaries (thresholds)."""
|
|
357
|
+
ax = _ax(ax)
|
|
358
|
+
i, j = dims
|
|
359
|
+
A = lb.A[:, [i, j]]
|
|
360
|
+
lim = np.abs(A).max() * 1.25
|
|
361
|
+
if groups is None:
|
|
362
|
+
ax.scatter(A[:, 0], A[:, 1], s=12, color="tab:blue", alpha=0.6)
|
|
363
|
+
else:
|
|
364
|
+
groups = np.asarray(groups); levels = list(dict.fromkeys(groups)); cols = _colors(len(levels))
|
|
365
|
+
for k, g in enumerate(levels):
|
|
366
|
+
ax.scatter(*A[groups == g].T, s=12, color=cols[k], alpha=0.7, label=str(g))
|
|
367
|
+
ax.legend(fontsize=8, frameon=False)
|
|
368
|
+
if row_labels:
|
|
369
|
+
for r, nm in enumerate(lb.row_names):
|
|
370
|
+
ax.annotate(nm, A[r], fontsize=6, alpha=0.7)
|
|
371
|
+
names = list(lb.col_names)
|
|
372
|
+
sel = range(len(names)) if variables is None else [names.index(v) for v in variables]
|
|
373
|
+
ordinal = ("Ordinal" in lb.title) or ("Polychoric" in lb.title)
|
|
374
|
+
for k in sel:
|
|
375
|
+
if ordinal:
|
|
376
|
+
b = lb.B[k][[i, j]]; nb2 = b @ b
|
|
377
|
+
if nb2 == 0:
|
|
378
|
+
continue
|
|
379
|
+
u = b / np.sqrt(nb2)
|
|
380
|
+
ax.plot([-lim * u[0], lim * u[0]], [-lim * u[1], lim * u[1]], color="0.6", lw=0.6)
|
|
381
|
+
for c, t in enumerate(np.atleast_1d(lb.thresholds[k])):
|
|
382
|
+
x = t * b / nb2
|
|
383
|
+
if np.all(np.abs(x) < lim):
|
|
384
|
+
perp = np.array([-u[1], u[0]]) * lim * 0.015
|
|
385
|
+
ax.plot([x[0] - perp[0], x[0] + perp[0]], [x[1] - perp[1], x[1] + perp[1]], color="tab:red", lw=1.2)
|
|
386
|
+
ax.text(x[0] + 2 * perp[0], x[1] + 2 * perp[1], f"{c + 1}|{c + 2}", fontsize=6, color="tab:red")
|
|
387
|
+
ax.text(*(u * lim * 0.98), names[k], fontsize=8, color=GREY)
|
|
388
|
+
else:
|
|
389
|
+
b0, b = lb.B[k, 0], lb.B[k, 1:][[i, j]]; nb2 = b @ b
|
|
390
|
+
if nb2 == 0:
|
|
391
|
+
continue
|
|
392
|
+
pts = [(np.log(p / (1 - p)) - b0) * b / nb2 for p in probs]
|
|
393
|
+
ax.annotate("", xy=pts[-1], xytext=pts[0],
|
|
394
|
+
arrowprops=dict(arrowstyle="-|>", color=GREY, lw=1, mutation_scale=9))
|
|
395
|
+
ax.scatter(*pts[0], s=12, color=GREY)
|
|
396
|
+
ax.text(*(pts[-1] + 0.05 * b / np.sqrt(nb2)), names[k], fontsize=8, color=GREY)
|
|
397
|
+
return _finish(ax, lim, f"Dim {i + 1}", f"Dim {j + 1}", lb.title)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def plot_nominal_regions(lb, variable, dims=(0, 1), ax=None, grid=250):
|
|
401
|
+
"""Prediction regions of the categories of one variable in a nominal
|
|
402
|
+
logistic biplot (the plane is divided by the most probable category)."""
|
|
403
|
+
from matplotlib.colors import ListedColormap
|
|
404
|
+
ax = _ax(ax, (6, 5.5))
|
|
405
|
+
k = list(lb.col_names).index(variable) if isinstance(variable, str) else int(variable)
|
|
406
|
+
i, j = dims
|
|
407
|
+
A = lb.A[:, [i, j]]
|
|
408
|
+
lim = np.abs(A).max() * 1.15
|
|
409
|
+
xs = np.linspace(-lim, lim, grid); XX, YY = np.meshgrid(xs, xs)
|
|
410
|
+
G = np.zeros((grid * grid, lb.A.shape[1])); G[:, i] = XX.ravel(); G[:, j] = YY.ravel()
|
|
411
|
+
Bk = lb.B[k]
|
|
412
|
+
eta = np.column_stack([np.zeros(len(G)), Bk[:, 0] + G @ Bk[:, 1:].T])
|
|
413
|
+
reg = eta.argmax(1).reshape(XX.shape)
|
|
414
|
+
cats = lb.categories[k]
|
|
415
|
+
cols = _colors(len(cats))
|
|
416
|
+
ax.contourf(XX, YY, reg, levels=np.arange(-0.5, len(cats)), cmap=ListedColormap(cols), alpha=0.18)
|
|
417
|
+
x = np.asarray([str(v) for v in lb.data[:, k]]) if hasattr(lb, "data") else None
|
|
418
|
+
for c, cat in enumerate(cats):
|
|
419
|
+
m = reg == c
|
|
420
|
+
if m.any():
|
|
421
|
+
ax.text(XX[m].mean(), YY[m].mean(), str(cat), color=cols[c], fontsize=11, fontweight="bold",
|
|
422
|
+
ha="center")
|
|
423
|
+
ax.scatter(A[:, 0], A[:, 1], s=8, color="0.3", alpha=0.6)
|
|
424
|
+
return _finish(ax, lim, f"Dim {i + 1}", f"Dim {j + 1}", f"{lb.col_names[k]}: prediction regions")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# --------------------------------------------------------------------------- STATIS family
|
|
428
|
+
def plot_statis(res, kind="compromise", dims=(0, 1), ax=None, trajectories=False, cols=True):
|
|
429
|
+
"""STATIS-ACT graphics: 'interstructure' (tables in the RV space),
|
|
430
|
+
'compromise' (biplot of the compromise; ``trajectories`` joins each row
|
|
431
|
+
across tables) or 'weights'."""
|
|
432
|
+
ax = _ax(ax)
|
|
433
|
+
i, j = dims
|
|
434
|
+
names = list(res.names)
|
|
435
|
+
if kind == "interstructure":
|
|
436
|
+
I = res.interstructure[:, [i, j]]
|
|
437
|
+
I = I * np.sign(I.sum(0) + 1e-300)
|
|
438
|
+
for k, nm in enumerate(names):
|
|
439
|
+
ax.annotate("", xy=I[k], xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color="tab:blue"))
|
|
440
|
+
ax.text(*(I[k] * 1.05), nm, fontsize=8)
|
|
441
|
+
lim = np.abs(I).max() * 1.25
|
|
442
|
+
lim = (min(-0.05, I[:, 0].min() * 1.2), lim, min(-lim * 0.6, I[:, 1].min() * 1.2), max(lim * 0.6, I[:, 1].max() * 1.2))
|
|
443
|
+
return _finish(ax, lim, f"Dim {i + 1} ({res.inertia_inter[i]:.1f}%)", f"Dim {j + 1} ({res.inertia_inter[j]:.1f}%)",
|
|
444
|
+
"Interstructure (RV)")
|
|
445
|
+
if kind == "weights":
|
|
446
|
+
ax.bar(names, res.weights, color="tab:blue"); ax.set_ylabel("Weight in the compromise")
|
|
447
|
+
ax.set_title("STATIS weights", fontsize=10); return ax
|
|
448
|
+
R = res.row_coordinates[:, [i, j]]
|
|
449
|
+
rn = getattr(res, "levels", None) or [f"I{k + 1}" for k in range(len(R))]
|
|
450
|
+
ax.scatter(R[:, 0], R[:, 1], s=26, color="tab:blue", zorder=3)
|
|
451
|
+
for k, nm in enumerate(rn):
|
|
452
|
+
ax.annotate(str(nm), R[k], fontsize=7, xytext=(3, 3), textcoords="offset points")
|
|
453
|
+
lim = np.abs(R).max()
|
|
454
|
+
if trajectories and getattr(res, "trajectories", None) is not None:
|
|
455
|
+
T = np.stack([t[:, [i, j]] for t in res.trajectories], 1)
|
|
456
|
+
tc = _colors(T.shape[1])
|
|
457
|
+
for r in range(T.shape[0]):
|
|
458
|
+
ax.plot(T[r, :, 0], T[r, :, 1], color="0.6", lw=0.7)
|
|
459
|
+
for t in range(T.shape[1]):
|
|
460
|
+
ax.scatter(*T[r, t], s=10, color=tc[t], label=names[t] if r == 0 else None)
|
|
461
|
+
ax.legend(fontsize=7, frameon=False)
|
|
462
|
+
lim = max(lim, np.abs(T).max())
|
|
463
|
+
if cols and getattr(res, "col_coordinates", None) is not None and np.ndim(res.col_coordinates) == 2:
|
|
464
|
+
C = res.col_coordinates[:, [i, j]]
|
|
465
|
+
labels = getattr(res, "col_labels", None) or getattr(res, "var_names", None) or [f"V{k + 1}" for k in range(len(C))]
|
|
466
|
+
sc = 0.9 * lim / np.abs(C).max() if np.abs(C).max() > lim else 1.0
|
|
467
|
+
_arrows(ax, C * sc, labels, fontsize=6)
|
|
468
|
+
return _finish(ax, lim * 1.2, f"Dim {i + 1} ({res.inertia[i]:.1f}%)", f"Dim {j + 1} ({res.inertia[j]:.1f}%)",
|
|
469
|
+
res.title)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def plot_dynamic(res, dims=(0, 1), ax=None, rows=None, show_columns=True):
|
|
473
|
+
"""Dynamic biplot (Egido & Galindo): trajectory of each row across the
|
|
474
|
+
occasions on the reference biplot, with arrows between consecutive
|
|
475
|
+
occasions."""
|
|
476
|
+
ax = _ax(ax)
|
|
477
|
+
i, j = dims
|
|
478
|
+
T = res["trajectories"][:, :, [i, j]]
|
|
479
|
+
rn = list(res["reference"].row_names)
|
|
480
|
+
sel = range(T.shape[0]) if rows is None else [rn.index(r) for r in rows]
|
|
481
|
+
tc = _colors(T.shape[1])
|
|
482
|
+
for r in sel:
|
|
483
|
+
for t in range(T.shape[1] - 1):
|
|
484
|
+
ax.annotate("", xy=T[r, t + 1], xytext=T[r, t],
|
|
485
|
+
arrowprops=dict(arrowstyle="->", color="0.55", lw=0.8, mutation_scale=7))
|
|
486
|
+
for t in range(T.shape[1]):
|
|
487
|
+
ax.scatter(*T[r, t], s=10, color=tc[t], label=res["occasions"][t] if r == list(sel)[0] else None, zorder=3)
|
|
488
|
+
ax.annotate(rn[r], T[r, -1], fontsize=6, xytext=(2, 2), textcoords="offset points")
|
|
489
|
+
lim = np.abs(T[list(sel)]).max() * 1.15
|
|
490
|
+
if show_columns:
|
|
491
|
+
B = res["reference"].col_coordinates[:, [i, j]]
|
|
492
|
+
_arrows(ax, B * (0.9 * lim / np.abs(B).max()), res["reference"].col_names)
|
|
493
|
+
ax.legend(fontsize=7, frameon=False)
|
|
494
|
+
return _finish(ax, lim, _dimlab(res["reference"], i), _dimlab(res["reference"], j), "Dynamic biplot")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def plot_unfolding(res, ax=None, row_names=None, col_names=None):
|
|
498
|
+
"""Joint configuration of an unfolding (individuals as points, objects as
|
|
499
|
+
labelled triangles)."""
|
|
500
|
+
ax = _ax(ax)
|
|
501
|
+
X, Y = res["conf_row"], res["conf_col"]
|
|
502
|
+
ax.scatter(X[:, 0], X[:, 1], s=12, color="tab:blue", alpha=0.7)
|
|
503
|
+
ax.scatter(Y[:, 0], Y[:, 1], s=40, marker="^", color="tab:red")
|
|
504
|
+
for k, nm in enumerate(col_names or [f"O{k + 1}" for k in range(len(Y))]):
|
|
505
|
+
ax.annotate(nm, Y[k], fontsize=8, color="tab:red", xytext=(3, 3), textcoords="offset points")
|
|
506
|
+
if row_names:
|
|
507
|
+
for k, nm in enumerate(row_names):
|
|
508
|
+
ax.annotate(nm, X[k], fontsize=6, alpha=0.7)
|
|
509
|
+
lim = max(np.abs(X).max(), np.abs(Y).max()) * 1.15
|
|
510
|
+
return _finish(ax, lim, "Dim 1", "Dim 2", f"Unfolding (stress-1 = {res['stress']:.3f})")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def plot_distatis(res, kind="compromise", dims=(0, 1), ax=None, partial=True, row_names=None, table_names=None):
|
|
514
|
+
"""DISTATIS: 'compromise' (factor scores with the partial scores of each
|
|
515
|
+
table) or 'rv' (tables on the first eigenvectors of the RV matrix)."""
|
|
516
|
+
ax = _ax(ax)
|
|
517
|
+
i, j = dims
|
|
518
|
+
if kind == "rv":
|
|
519
|
+
G = res["G"][:, [i, j]]
|
|
520
|
+
ax.scatter(G[:, 0], G[:, 1], color="tab:blue")
|
|
521
|
+
for k in range(len(G)):
|
|
522
|
+
ax.annotate((table_names or [f"T{t + 1}" for t in range(len(G))])[k], G[k], fontsize=8)
|
|
523
|
+
lim = (G[:, 0].min() - 0.2, G[:, 0].max() + 0.2, G[:, 1].min() - 0.3, G[:, 1].max() + 0.3)
|
|
524
|
+
return _finish(ax, lim, "Dim 1", "Dim 2", "RV map of the tables")
|
|
525
|
+
F = res["F"][:, [i, j]]
|
|
526
|
+
ax.scatter(F[:, 0], F[:, 1], s=40, color="black", zorder=3)
|
|
527
|
+
rn = row_names or [f"O{k + 1}" for k in range(len(F))]
|
|
528
|
+
for k, nm in enumerate(rn):
|
|
529
|
+
ax.annotate(nm, F[k], fontsize=8, xytext=(3, 3), textcoords="offset points")
|
|
530
|
+
lim = np.abs(F).max()
|
|
531
|
+
if partial:
|
|
532
|
+
P = res["partial_F"][:, [i, j], :]
|
|
533
|
+
cols = _colors(P.shape[2])
|
|
534
|
+
for t in range(P.shape[2]):
|
|
535
|
+
for k in range(len(F)):
|
|
536
|
+
ax.plot([F[k, 0], P[k, 0, t]], [F[k, 1], P[k, 1, t]], color=cols[t], lw=0.7)
|
|
537
|
+
ax.scatter(P[:, 0, t], P[:, 1, t], s=10, color=cols[t],
|
|
538
|
+
label=(table_names or [f"T{q + 1}" for q in range(P.shape[2])])[t])
|
|
539
|
+
ax.legend(fontsize=7, frameon=False)
|
|
540
|
+
lim = max(lim, np.abs(P).max())
|
|
541
|
+
ev = res["eig_Splus"]
|
|
542
|
+
return _finish(ax, lim * 1.15, f"Factor {i + 1} ({ev[i] / ev[ev > 0].sum() * 100:.1f}%)",
|
|
543
|
+
f"Factor {j + 1} ({ev[j] / ev[ev > 0].sum() * 100:.1f}%)", "DISTATIS compromise")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def plot_cdpca(res, ax=None, truth=None):
|
|
547
|
+
"""CDPCA: objects on the disjoint components, coloured by cluster, with
|
|
548
|
+
the cluster centroids (``biplotbootGUI::CDpca`` graphic)."""
|
|
549
|
+
ax = _ax(ax)
|
|
550
|
+
Y, Yb = res["Y"][:, :2], res["Ybar"][:, :2]
|
|
551
|
+
cl = res["cluster"]
|
|
552
|
+
cols = _colors(cl.max())
|
|
553
|
+
for c in range(1, cl.max() + 1):
|
|
554
|
+
ax.scatter(*Y[cl == c].T, s=14, color=cols[c - 1], label=f"Cluster {c}", alpha=0.75)
|
|
555
|
+
ax.scatter(Yb[:, 0], Yb[:, 1], s=90, marker="s", color="black")
|
|
556
|
+
lim = np.abs(Y).max() * 1.15
|
|
557
|
+
ax.legend(fontsize=8, frameon=False)
|
|
558
|
+
return _finish(ax, lim, f"Component 1 ({res['var_percent'][0]:.1f}%)", f"Component 2 ({res['var_percent'][1]:.1f}%)",
|
|
559
|
+
f"CDPCA (between-cluster deviance {res['bcdev']:.1f}%)")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# --------------------------------------------------------------------------- GGE and AMMI
|
|
563
|
+
_GGE_TYPES = {"biplot": 1, "examine_env": 2, "examine_gen": 3, "env_relationship": 4, "compare_gens": 5,
|
|
564
|
+
"which_won_where": 6, "discrimination": 7, "rank_env": 8, "mean_stability": 9, "rank_gen": 10}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _perp_foot(direction, P):
|
|
568
|
+
"""Feet of the perpendiculars from the points P to the line through the
|
|
569
|
+
origin with the given direction."""
|
|
570
|
+
u = np.asarray(direction, float); u = u / np.linalg.norm(u)
|
|
571
|
+
return np.outer(P @ u, u)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def plot_gge(model, type="biplot", dims=(0, 1), ax=None, env=None, gen=None, gen2=None):
|
|
575
|
+
"""GGE biplot views of ``GGEBiplots::GGEPlot``: 'biplot', 'examine_env',
|
|
576
|
+
'examine_gen', 'env_relationship', 'compare_gens', 'which_won_where',
|
|
577
|
+
'discrimination' (discriminativeness vs representativeness),
|
|
578
|
+
'rank_env', 'mean_stability' and 'rank_gen'."""
|
|
579
|
+
t = _GGE_TYPES.get(type, type)
|
|
580
|
+
ax = _ax(ax)
|
|
581
|
+
i, j = dims
|
|
582
|
+
G = model["coordgenotype"][:, [i, j]]; E = model["coordenviroment"][:, [i, j]]
|
|
583
|
+
lg, le = model["labelgen"], model["labelenv"]
|
|
584
|
+
cg, ce = "forestgreen", "blue"
|
|
585
|
+
allp = np.vstack([G, E])
|
|
586
|
+
lim = np.abs(allp).max() * 1.2
|
|
587
|
+
ve = model["varexpl"][[i, j]]
|
|
588
|
+
|
|
589
|
+
def txt(P, labs, col, size=8):
|
|
590
|
+
for k, nm in enumerate(labs):
|
|
591
|
+
ax.text(P[k, 0], P[k, 1], nm, color=col, fontsize=size, ha="center", va="center")
|
|
592
|
+
if t in (1, 6, 7, 8, 10, 4):
|
|
593
|
+
if t != 4:
|
|
594
|
+
txt(G, lg, cg)
|
|
595
|
+
if t == 1:
|
|
596
|
+
for k in range(len(E)):
|
|
597
|
+
ax.plot([0, E[k, 0]], [0, E[k, 1]], color=ce, alpha=0.5, lw=0.8)
|
|
598
|
+
txt(E, le, ce)
|
|
599
|
+
title = "GGE biplot"
|
|
600
|
+
elif t == 2:
|
|
601
|
+
k = le.index(env); e = E[k]
|
|
602
|
+
ax.plot([-lim * e[0] / np.linalg.norm(e), lim * e[0] / np.linalg.norm(e)],
|
|
603
|
+
[-lim * e[1] / np.linalg.norm(e), lim * e[1] / np.linalg.norm(e)], color=ce, lw=0.8)
|
|
604
|
+
ax.annotate("", xy=e, xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color=ce))
|
|
605
|
+
F = _perp_foot(e, G)
|
|
606
|
+
for r in range(len(G)):
|
|
607
|
+
ax.plot([G[r, 0], F[r, 0]], [G[r, 1], F[r, 1]], ls="--", color=cg, lw=0.6)
|
|
608
|
+
txt(G, lg, cg); txt(E[[k]], [env], ce, 10)
|
|
609
|
+
title = f"Examine environment {env}"
|
|
610
|
+
elif t == 3:
|
|
611
|
+
k = lg.index(gen); g = G[k]
|
|
612
|
+
ax.plot([-lim * g[0] / np.linalg.norm(g), lim * g[0] / np.linalg.norm(g)],
|
|
613
|
+
[-lim * g[1] / np.linalg.norm(g), lim * g[1] / np.linalg.norm(g)], color=cg, lw=0.8)
|
|
614
|
+
F = _perp_foot(g, E)
|
|
615
|
+
for r in range(len(E)):
|
|
616
|
+
ax.plot([E[r, 0], F[r, 0]], [E[r, 1], F[r, 1]], ls="--", color=ce, lw=0.6)
|
|
617
|
+
txt(E, le, ce); txt(G[[k]], [gen], cg, 10)
|
|
618
|
+
title = f"Examine genotype {gen}"
|
|
619
|
+
elif t == 4:
|
|
620
|
+
for k in range(len(E)):
|
|
621
|
+
ax.annotate("", xy=E[k], xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color=ce, lw=0.9))
|
|
622
|
+
txt(E * 1.08, le, ce)
|
|
623
|
+
title = "Relationship among environments"
|
|
624
|
+
elif t == 5:
|
|
625
|
+
k1, k2 = lg.index(gen), lg.index(gen2)
|
|
626
|
+
a, b = G[k1], G[k2]
|
|
627
|
+
ax.plot([a[0], b[0]], [a[1], b[1]], color="tab:red")
|
|
628
|
+
d = b - a
|
|
629
|
+
perp = np.array([-d[1], d[0]])
|
|
630
|
+
ax.plot([-lim * perp[0] / np.linalg.norm(perp), lim * perp[0] / np.linalg.norm(perp)],
|
|
631
|
+
[-lim * perp[1] / np.linalg.norm(perp), lim * perp[1] / np.linalg.norm(perp)], color="tab:red", lw=0.8)
|
|
632
|
+
txt(E, le, ce); txt(G, lg, cg); txt(G[[k1, k2]], [gen, gen2], "black", 10)
|
|
633
|
+
title = f"Compare {gen} with {gen2}"
|
|
634
|
+
elif t == 6:
|
|
635
|
+
from scipy.spatial import ConvexHull
|
|
636
|
+
h = ConvexHull(G).vertices
|
|
637
|
+
poly = G[np.r_[h, h[0]]]
|
|
638
|
+
ax.plot(poly[:, 0], poly[:, 1], color="black", lw=1)
|
|
639
|
+
for a, b in zip(poly[:-1], poly[1:]):
|
|
640
|
+
d = b - a
|
|
641
|
+
n_ = np.array([-d[1], d[0]])
|
|
642
|
+
n_ = n_ / np.linalg.norm(n_)
|
|
643
|
+
foot = _perp_foot(n_, a[None])[0]
|
|
644
|
+
if foot @ n_ < 0:
|
|
645
|
+
n_ = -n_
|
|
646
|
+
ax.plot([0, n_[0] * lim * 1.5], [0, n_[1] * lim * 1.5], color="tab:red", lw=0.9)
|
|
647
|
+
txt(E, le, ce)
|
|
648
|
+
for k in h:
|
|
649
|
+
ax.text(G[k, 0], G[k, 1], lg[k], color="black", fontsize=9, fontweight="bold", ha="center", va="center")
|
|
650
|
+
title = "Which won where"
|
|
651
|
+
elif t in (7, 8, 9, 10):
|
|
652
|
+
m = E.mean(0)
|
|
653
|
+
u = m / np.linalg.norm(m)
|
|
654
|
+
ax.plot([-lim * u[0], lim * u[0]], [-lim * u[1], lim * u[1]], color=ce, lw=0.9)
|
|
655
|
+
ax.annotate("", xy=u * np.abs(E).max(), xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color=ce, lw=1.2))
|
|
656
|
+
th = np.linspace(0, 2 * np.pi, 200)
|
|
657
|
+
if t == 7:
|
|
658
|
+
for k in range(len(E)):
|
|
659
|
+
ax.plot([0, E[k, 0]], [0, E[k, 1]], color=ce, alpha=0.5, lw=0.8)
|
|
660
|
+
step = max(np.ptp(E[:, 0]), np.ptp(E[:, 1])) / 10
|
|
661
|
+
for r in range(1, 6):
|
|
662
|
+
ax.plot(r * step * np.cos(th), r * step * np.sin(th), color="tab:red", lw=0.6)
|
|
663
|
+
txt(E, le, ce)
|
|
664
|
+
title = "Discriminativeness vs representativeness"
|
|
665
|
+
elif t == 8:
|
|
666
|
+
mod = np.sqrt((E ** 2).sum(1)).max()
|
|
667
|
+
ideal = np.sign(m[0]) * np.sqrt(mod ** 2 / (1 + m[1] ** 2 / m[0] ** 2)) * np.array([1, m[1] / m[0]])
|
|
668
|
+
step = np.linalg.norm(ideal - m) / 3
|
|
669
|
+
for r in range(1, 9):
|
|
670
|
+
ax.plot(ideal[0] + r * step * np.cos(th), ideal[1] + r * step * np.sin(th), color="tab:red", lw=0.6)
|
|
671
|
+
ax.scatter(*ideal, marker="*", s=80, color="tab:red")
|
|
672
|
+
for k in range(len(E)):
|
|
673
|
+
ax.plot([0, E[k, 0]], [0, E[k, 1]], color=ce, alpha=0.5, lw=0.8)
|
|
674
|
+
txt(E, le, ce)
|
|
675
|
+
title = "Ranking environments"
|
|
676
|
+
elif t == 9:
|
|
677
|
+
F = _perp_foot(m, G)
|
|
678
|
+
perp_line = np.array([-u[1], u[0]])
|
|
679
|
+
ax.plot([-lim * perp_line[0], lim * perp_line[0]], [-lim * perp_line[1], lim * perp_line[1]], color=ce,
|
|
680
|
+
lw=0.6, ls=":")
|
|
681
|
+
for r in range(len(G)):
|
|
682
|
+
ax.plot([G[r, 0], F[r, 0]], [G[r, 1], F[r, 1]], ls="--", color=cg, lw=0.6)
|
|
683
|
+
txt(G, lg, cg); txt(E, le, ce)
|
|
684
|
+
title = "Mean vs stability"
|
|
685
|
+
else:
|
|
686
|
+
F = _perp_foot(m, G)
|
|
687
|
+
proj = F @ u
|
|
688
|
+
ideal = F[np.argmax(proj)] if proj.max() > 0 else u * 0
|
|
689
|
+
step = np.linalg.norm(ideal - m) / 3
|
|
690
|
+
for r in range(1, 11):
|
|
691
|
+
ax.plot(ideal[0] + r * step * np.cos(th), ideal[1] + r * step * np.sin(th), color="0.6", lw=0.6)
|
|
692
|
+
ax.scatter(*ideal, marker="*", s=80, color="tab:red")
|
|
693
|
+
txt(E, le, ce)
|
|
694
|
+
title = "Ranking genotypes"
|
|
695
|
+
return _finish(ax, lim, f"Component {i + 1} {ve[0]:.2f}%", f"Component {j + 1} {ve[1]:.2f}%", title)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def plot_ammi(res, kind="AMMI2", ax=None):
|
|
699
|
+
"""AMMI1 (mean vs IPCA1) or AMMI2 (IPCA1 vs IPCA2) biplot."""
|
|
700
|
+
ax = _ax(ax)
|
|
701
|
+
G, E = res["genotype_scores"].to_numpy(), res["environment_scores"].to_numpy()
|
|
702
|
+
pc = res["analysis"]["percent"].to_numpy()
|
|
703
|
+
if kind == "AMMI1":
|
|
704
|
+
xg, xe = res["genotype_means"].to_numpy(), res["environment_means"].to_numpy()
|
|
705
|
+
ax.scatter(xg, G[:, 0], color="forestgreen", s=14)
|
|
706
|
+
ax.scatter(xe, E[:, 0], color="blue", marker="^", s=24)
|
|
707
|
+
for k, nm in enumerate(res["genotype_scores"].index):
|
|
708
|
+
ax.annotate(nm, (xg[k], G[k, 0]), fontsize=7, color="forestgreen")
|
|
709
|
+
for k, nm in enumerate(res["environment_scores"].index):
|
|
710
|
+
ax.annotate(nm, (xe[k], E[k, 0]), fontsize=8, color="blue")
|
|
711
|
+
ax.axhline(0, ls=":", color="0.6"); ax.axvline(np.mean(xg), ls=":", color="0.6")
|
|
712
|
+
ax.set_xlabel("Mean"); ax.set_ylabel(f"IPCA1 ({pc[0]:.1f}%)"); ax.set_title("AMMI1 biplot", fontsize=10)
|
|
713
|
+
return ax
|
|
714
|
+
ax.scatter(G[:, 0], G[:, 1], color="forestgreen", s=14)
|
|
715
|
+
for k, nm in enumerate(res["genotype_scores"].index):
|
|
716
|
+
ax.annotate(nm, G[k, :2], fontsize=7, color="forestgreen")
|
|
717
|
+
for k, nm in enumerate(res["environment_scores"].index):
|
|
718
|
+
ax.annotate("", xy=E[k, :2], xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color="blue"))
|
|
719
|
+
ax.text(*(E[k, :2] * 1.06), nm, fontsize=8, color="blue")
|
|
720
|
+
lim = max(np.abs(G[:, :2]).max(), np.abs(E[:, :2]).max()) * 1.15
|
|
721
|
+
return _finish(ax, lim, f"IPCA1 ({pc[0]:.1f}%)", f"IPCA2 ({pc[1]:.1f}%)", "AMMI2 biplot")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# --------------------------------------------------------------------------- ordination-type biplots
|
|
725
|
+
def plot_ordination(res, dims=(0, 1), ax=None, groups=None, row_labels=False, col_labels=True, show_rows=True,
|
|
726
|
+
show_cols=True, env_scale=None, sup_vars=None, lc=False):
|
|
727
|
+
"""Biplot of CA, MCA, CCA, RDA (triplot), PCoA, MDS, external logistic
|
|
728
|
+
biplot and canonical analysis of distances."""
|
|
729
|
+
ax = _ax(ax)
|
|
730
|
+
i, j = dims
|
|
731
|
+
t = res.type
|
|
732
|
+
R = getattr(res, "lc_site_scores" if lc else "row_coordinates", None)
|
|
733
|
+
C = getattr(res, "col_coordinates", None)
|
|
734
|
+
pts = []
|
|
735
|
+
if t == "CDA":
|
|
736
|
+
cols = _colors(len(res.group_names))
|
|
737
|
+
Gm = res.group_coordinates[:, [i, j]]
|
|
738
|
+
if res.row_coordinates is not None:
|
|
739
|
+
Rr = res.row_coordinates[:, [i, j]]
|
|
740
|
+
for k, g in enumerate(res.group_names):
|
|
741
|
+
ax.scatter(*Rr[res.groups == g].T, s=10, color=cols[k], alpha=0.4)
|
|
742
|
+
pts.append(Rr)
|
|
743
|
+
for k, g in enumerate(res.group_names):
|
|
744
|
+
ax.scatter(*Gm[k], s=60, color=cols[k], zorder=3)
|
|
745
|
+
ax.annotate(g, Gm[k], fontsize=9, color=cols[k], fontweight="bold", xytext=(0, 7), textcoords="offset points",
|
|
746
|
+
ha="center")
|
|
747
|
+
pts.append(Gm)
|
|
748
|
+
lim = np.abs(np.vstack(pts)).max() * 1.15
|
|
749
|
+
return _finish(ax, lim, _dimlab(res, i), _dimlab(res, j),
|
|
750
|
+
f"Canonical analysis of distances (F = {float(np.squeeze(res.F)):.2f}, p = {res.p_value:.3f})")
|
|
751
|
+
if show_rows and R is not None:
|
|
752
|
+
R = R[:, [i, j]]
|
|
753
|
+
if groups is not None:
|
|
754
|
+
groups = np.asarray(groups); levels = list(dict.fromkeys(groups)); gc = _colors(len(levels))
|
|
755
|
+
for k, g in enumerate(levels):
|
|
756
|
+
ax.scatter(*R[groups == g].T, s=12, color=gc[k], alpha=0.7, label=str(g))
|
|
757
|
+
ax.legend(fontsize=7, frameon=False)
|
|
758
|
+
else:
|
|
759
|
+
ax.scatter(R[:, 0], R[:, 1], s=12 if t not in ("CA",) else 26, color="tab:blue", alpha=0.75)
|
|
760
|
+
if row_labels or t == "CA":
|
|
761
|
+
for k, nm in enumerate(res.row_names):
|
|
762
|
+
ax.annotate(nm, R[k], fontsize=7, color="tab:blue", xytext=(3, 3), textcoords="offset points")
|
|
763
|
+
pts.append(R)
|
|
764
|
+
if show_cols and C is not None:
|
|
765
|
+
Cc = C[:, [i, j]]
|
|
766
|
+
if t == "ExternalLogistic":
|
|
767
|
+
for k, nm in enumerate(res.col_names):
|
|
768
|
+
b0, b = res.col_parameters[k, 0], res.col_parameters[k, 1:][[i, j]]
|
|
769
|
+
nb2 = b @ b
|
|
770
|
+
p5, p75 = (0 - b0) * b / nb2, (np.log(3) - b0) * b / nb2
|
|
771
|
+
ax.annotate("", xy=p75, xytext=p5, arrowprops=dict(arrowstyle="-|>", color=GREY, mutation_scale=9))
|
|
772
|
+
ax.scatter(*p5, s=10, color=GREY)
|
|
773
|
+
ax.text(*p75, nm, fontsize=8, color=GREY)
|
|
774
|
+
pts.append(np.vstack([p5, p75]))
|
|
775
|
+
elif t in ("RDA",):
|
|
776
|
+
_arrows(ax, Cc, res.col_names, "tab:red", fontsize=7, lw=0.8)
|
|
777
|
+
pts.append(Cc)
|
|
778
|
+
else:
|
|
779
|
+
ax.scatter(Cc[:, 0], Cc[:, 1], s=28, marker="^", color="tab:red")
|
|
780
|
+
if col_labels:
|
|
781
|
+
for k, nm in enumerate(res.col_names):
|
|
782
|
+
ax.annotate(nm, Cc[k], fontsize=7, color="tab:red", xytext=(3, -8), textcoords="offset points")
|
|
783
|
+
pts.append(Cc)
|
|
784
|
+
lim = np.abs(np.vstack(pts)).max() * 1.15 if pts else 1
|
|
785
|
+
if getattr(res, "env_coordinates", None) is not None:
|
|
786
|
+
Ev = res.env_coordinates[:, [i, j]]
|
|
787
|
+
sc = env_scale or 0.85 * lim / np.abs(Ev).max()
|
|
788
|
+
_arrows(ax, Ev * sc, res.env_names, "black", lw=1.2)
|
|
789
|
+
if sup_vars is not None:
|
|
790
|
+
Sv = sup_vars["coordinates"][:, [i, j]]
|
|
791
|
+
_arrows(ax, Sv * (0.85 * lim / np.abs(Sv).max()), sup_vars["names"], "black")
|
|
792
|
+
if t in ("PCoA", "MDS"):
|
|
793
|
+
xl, yl = _dimlab(res, i), _dimlab(res, j)
|
|
794
|
+
else:
|
|
795
|
+
xl, yl = _dimlab(res, i), _dimlab(res, j)
|
|
796
|
+
return _finish(ax, lim, xl, yl, res.title)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def plot_coinertia(res, ax=None, row_names=None):
|
|
800
|
+
"""Co-inertia plot: row scores in X (start) and Y (arrow head) on the
|
|
801
|
+
co-inertia axes (normalized), as ``ade4::plot.coinertia``."""
|
|
802
|
+
ax = _ax(ax)
|
|
803
|
+
X = res["lX"] / np.sqrt((res["lX"] ** 2).mean(0))
|
|
804
|
+
Y = res["lY"] / np.sqrt((res["lY"] ** 2).mean(0))
|
|
805
|
+
for k in range(len(X)):
|
|
806
|
+
ax.annotate("", xy=Y[k, :2], xytext=X[k, :2], arrowprops=dict(arrowstyle="-|>", color="0.45", lw=0.7,
|
|
807
|
+
mutation_scale=7))
|
|
808
|
+
ax.scatter(X[:, 0], X[:, 1], s=10, color="tab:blue", label="row in X (arrow points to Y)")
|
|
809
|
+
if row_names:
|
|
810
|
+
for k, nm in enumerate(row_names):
|
|
811
|
+
ax.annotate(nm, X[k, :2], fontsize=6)
|
|
812
|
+
lim = max(np.abs(X[:, :2]).max(), np.abs(Y[:, :2]).max()) * 1.15
|
|
813
|
+
ax.legend(fontsize=8, frameon=False)
|
|
814
|
+
return _finish(ax, lim, f"Axis 1 ({res['inertia'][0]:.1f}%)", f"Axis 2 ({res['inertia'][1]:.1f}%)",
|
|
815
|
+
f"Co-inertia (RV = {res['RV']:.3f})")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# --------------------------------------------------------------------------- phase 5
|
|
819
|
+
def plot_bootstrap_ordination(bt, dims=(0, 1), ax=None, kind="ellipse", level=0.95, show_replicates=True,
|
|
820
|
+
labels=True, title=None):
|
|
821
|
+
"""Bootstrap of a PCoA or an MDS: the replicates of each individual (small
|
|
822
|
+
points), the original configuration (large points) and, per individual, a
|
|
823
|
+
concentration ellipse (``kind='ellipse'``) or the convex hull of its
|
|
824
|
+
replicates (``kind='hull'``)."""
|
|
825
|
+
ax = _ax(ax)
|
|
826
|
+
i, j = dims
|
|
827
|
+
cols = _colors(len(bt.coordinates))
|
|
828
|
+
allp = []
|
|
829
|
+
for k, C in enumerate(bt.coordinates):
|
|
830
|
+
P = C[:, [i, j]]; allp.append(P)
|
|
831
|
+
if show_replicates:
|
|
832
|
+
ax.scatter(*P.T, s=4, color=cols[k], alpha=0.35)
|
|
833
|
+
if kind == "hull":
|
|
834
|
+
H = convex_hull(P)
|
|
835
|
+
if H is not None:
|
|
836
|
+
ax.plot(*np.vstack([H, H[:1]]).T, color=cols[k], lw=0.8)
|
|
837
|
+
else:
|
|
838
|
+
ax.plot(*concentration_ellipse(P, level).T, color=cols[k], lw=0.8)
|
|
839
|
+
ref = bt.reference[:, [i, j]]
|
|
840
|
+
ax.scatter(*ref.T, s=22, color="black", zorder=3)
|
|
841
|
+
if labels:
|
|
842
|
+
for k, nm in enumerate(bt.row_names):
|
|
843
|
+
ax.annotate(nm, ref[k], fontsize=7, xytext=(3, 3), textcoords="offset points")
|
|
844
|
+
lim = np.abs(np.vstack(allp + [ref])).max() * 1.1
|
|
845
|
+
return _finish(ax, lim, f"Dim {i + 1}", f"Dim {j + 1}", title or bt.title)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def plot_plsbin(res, dims=(0, 1), ax=None, row_labels=False, title=None):
|
|
849
|
+
"""Biplot of ``plsr1_bin`` (scores and weights of the predictors, rows
|
|
850
|
+
coloured by the binary response) or of ``binary_plsr`` (X scores, X and Y
|
|
851
|
+
loadings)."""
|
|
852
|
+
ax = _ax(ax)
|
|
853
|
+
i, j = dims
|
|
854
|
+
if "scores" in res:
|
|
855
|
+
T = res["scores"].to_numpy()[:, [i, j]]
|
|
856
|
+
y = np.asarray(res["fit"]["fitted"] > 0.5)
|
|
857
|
+
ax.scatter(*T.T, s=14, c=np.where(y, "tab:red", "tab:blue"), alpha=0.7)
|
|
858
|
+
W = res["weights"].to_numpy()[:, [i, j]]
|
|
859
|
+
sc = np.abs(T).max() / np.abs(W).max() * 0.8
|
|
860
|
+
_arrows(ax, W, res["weights"].index, scale=sc)
|
|
861
|
+
lim = max(np.abs(T).max(), np.abs(W * sc).max()) * 1.2
|
|
862
|
+
return _finish(ax, lim, f"Comp. {i + 1}", f"Comp. {j + 1}", title or "PLSR1 Binary (red: predicted 1)")
|
|
863
|
+
T = res["x_scores"].to_numpy()[:, [i, j]]
|
|
864
|
+
ax.scatter(*T.T, s=14, color="tab:blue", alpha=0.6)
|
|
865
|
+
if row_labels:
|
|
866
|
+
for k, nm in enumerate(res["x_scores"].index):
|
|
867
|
+
ax.annotate(nm, T[k], fontsize=6)
|
|
868
|
+
P = res["x_loadings"].to_numpy()[:, [i, j]]; Q = res["y_loadings"].to_numpy()[:, [i, j]]
|
|
869
|
+
sc = np.abs(T).max() / max(np.abs(P).max(), np.abs(Q).max()) * 0.8
|
|
870
|
+
_arrows(ax, P, res["x_loadings"].index, color=GREY, scale=sc)
|
|
871
|
+
_arrows(ax, Q, res["y_loadings"].index, color="tab:red", scale=sc)
|
|
872
|
+
lim = max(np.abs(T).max(), np.abs(np.vstack([P, Q]) * sc).max()) * 1.2
|
|
873
|
+
return _finish(ax, lim, f"Comp. {i + 1}", f"Comp. {j + 1}", title or "Binary PLS regression (red: responses)")
|