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/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)")