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 ADDED
@@ -0,0 +1,69 @@
1
+ """biplotpy: biplot methods of the Salamanca school for Python.
2
+
3
+ Classical biplots (GH, JK, SQRT and HJ-Biplot), canonical/MANOVA biplot with
4
+ confidence circles and MANOVA tests, bootstrap of the PCA biplot,
5
+ supplementary variables and calibrated axes; logistic biplots (binary,
6
+ ordinal, nominal), STATIS-ACT, dual and canonical STATIS, dynamic biplot,
7
+ metric unfolding, GGE and AMMI; DISTATIS and CDPCA; Mardia's test,
8
+ heteroscedastic MANOVA (WTS/MATS with bootstrap) and HE plots (one or several
9
+ factors); constrained logistic biplots (binary and ordinal), external ordinal
10
+ logistic biplot, PLS regression with binary responses (PLSR1Bin, BinaryPLSR),
11
+ log-frequency biplot, bootstrap of principal coordinates and of MDS. Ported from the R package
12
+ MultBiplotR (Vicente-Villardón, Vicente-González and Frutos-Bernal) and
13
+ validated numerically against it.
14
+ """
15
+ from .transforms import initial_transform, TRANSFORMS, FIXES
16
+ from .classical import ContinuousBiplot, pca_biplot, hj_biplot, gh_biplot, jk_biplot, sqrt_biplot
17
+ from .canonical import CanonicalBiplot, canonical_biplot, canonical_variate_analysis
18
+ from .extras import pca_bootstrap, add_continuous_variables, project_rows, biplot_scales, calibrated_axis
19
+ from .utils import r_pretty, matrix_sqrt
20
+ from .datasets import load_iris, load_mtcars
21
+ from .logistic import (LogisticBiplot, binary_logistic_biplot, ordinal_logistic_biplot, ordinal_logistic_fit,
22
+ nominal_logistic_biplot, nominal_logistic_fit)
23
+ from .statis import StatisResult, statis, dual_statis, canonical_statis
24
+ from .dynamic import dynamic_biplot
25
+ from .unfolding import unfolding
26
+ from .gxe import gge_model, ammi
27
+ from .multitest import (mardia, manova_wts, he_matrices, he_ellipses, he_plot, roy_crit, hlt_crit, manova_terms,
28
+ he_plot_terms)
29
+ from .restricted import (constrained_logistic_biplot, constrained_ordinal_logistic_biplot,
30
+ external_ordinal_logistic_biplot, ridge_ordinal_logistic)
31
+ from .plsbin import plsr1_bin, binary_plsr
32
+ from .logfreq import criss_cross, log_frequency_biplot
33
+ from .bootdist import OrdinationBootstrap, bootstrap_pcoa, bootstrap_mds, simple_procrustes
34
+ from .rrng import RRNG
35
+ from .roptim import optim_cg
36
+ from .distatis import distatis
37
+ from .cdpca import cdpca
38
+ from .correspondence import OrdinationBiplot, ca, mca, cca, rda, indicator_matrix
39
+ from .distances import (binary_distances, binary_similarity, continuous_distances, gower_distances, pcoa, mds,
40
+ ridge_logistic, external_logistic_biplot, canonical_distance_analysis)
41
+ from .factor import (rotate, fa_biplot, nipals, nipals_biplot, pls_regression, pls_biplot, coinertia, polychoric,
42
+ polychoric_biplot)
43
+ from .plot import (plot_biplot, plot_canonical, plot_correlation_circle, plot_scree, plot_contributions,
44
+ plot_biplot_3d, plot_bootstrap, plot_logistic, plot_nominal_regions, plot_statis, plot_dynamic,
45
+ plot_unfolding, plot_distatis, plot_cdpca, plot_gge, plot_ammi, plot_ordination, plot_coinertia,
46
+ concentration_ellipse, convex_hull, alpha_bag, plot_bootstrap_ordination, plot_plsbin)
47
+
48
+ __version__ = "0.4.0"
49
+ __all__ = ["initial_transform", "TRANSFORMS", "FIXES", "ContinuousBiplot", "pca_biplot", "hj_biplot",
50
+ "gh_biplot", "jk_biplot", "sqrt_biplot", "CanonicalBiplot", "canonical_biplot",
51
+ "canonical_variate_analysis", "pca_bootstrap", "add_continuous_variables", "project_rows",
52
+ "biplot_scales", "calibrated_axis", "r_pretty", "matrix_sqrt", "load_iris", "load_mtcars",
53
+ "LogisticBiplot", "binary_logistic_biplot", "ordinal_logistic_biplot", "ordinal_logistic_fit",
54
+ "nominal_logistic_biplot", "nominal_logistic_fit", "StatisResult", "statis", "dual_statis",
55
+ "canonical_statis", "dynamic_biplot", "unfolding", "gge_model", "ammi",
56
+ "mardia", "manova_wts", "he_matrices", "he_ellipses", "he_plot", "roy_crit", "hlt_crit",
57
+ "distatis", "cdpca",
58
+ "OrdinationBiplot", "ca", "mca", "cca", "rda", "indicator_matrix", "binary_distances",
59
+ "binary_similarity", "continuous_distances", "gower_distances", "pcoa", "mds", "ridge_logistic",
60
+ "external_logistic_biplot", "canonical_distance_analysis", "rotate", "fa_biplot", "nipals",
61
+ "nipals_biplot", "pls_regression", "pls_biplot", "coinertia", "polychoric", "polychoric_biplot",
62
+ "plot_biplot", "plot_canonical", "plot_correlation_circle", "plot_scree", "plot_contributions",
63
+ "plot_biplot_3d", "plot_bootstrap", "plot_logistic", "plot_nominal_regions", "plot_statis",
64
+ "plot_dynamic", "plot_unfolding", "plot_distatis", "plot_cdpca", "plot_gge", "plot_ammi",
65
+ "plot_ordination", "plot_coinertia", "concentration_ellipse", "convex_hull", "alpha_bag",
66
+ "manova_terms", "he_plot_terms", "constrained_logistic_biplot", "constrained_ordinal_logistic_biplot",
67
+ "external_ordinal_logistic_biplot", "ridge_ordinal_logistic", "plsr1_bin", "binary_plsr", "criss_cross",
68
+ "log_frequency_biplot", "OrdinationBootstrap", "bootstrap_pcoa", "bootstrap_mds", "simple_procrustes",
69
+ "RRNG", "optim_cg", "plot_bootstrap_ordination", "plot_plsbin"]
biplotpy/bootdist.py ADDED
@@ -0,0 +1,174 @@
1
+ """Bootstrap of distance-based ordinations: principal coordinates
2
+ (``MultBiplotR::BootstrapDistance``) and SMACOF multidimensional scaling.
3
+
4
+ Both resample the residuals between the observed dissimilarities and the
5
+ distances fitted by the ordination (with replacement, ``method='sampling'``, or
6
+ by permutation), add them to the fitted distances, refit the ordination and
7
+ align each replicate to the original configuration by Procrustes. The cloud of
8
+ replicates of each individual gives its stability (confidence ellipses or
9
+ convex hulls in ``plot_bootstrap_ordination``)."""
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ from .distances import mds
16
+ from .rrng import RRNG
17
+
18
+
19
+ def _dist_vec(M):
20
+ """Lower triangle of a square matrix in R's ``dist`` order (by columns)."""
21
+ n = M.shape[0]
22
+ return np.concatenate([M[j + 1:, j] for j in range(n - 1)])
23
+
24
+
25
+ def _vec_to_mat(v, n):
26
+ M = np.zeros((n, n))
27
+ k = 0
28
+ for j in range(n - 1):
29
+ M[j + 1:, j] = v[k:k + n - 1 - j]
30
+ k += n - 1 - j
31
+ return M + M.T
32
+
33
+
34
+ def _euclid(X):
35
+ return np.sqrt(((X[:, None, :] - X[None, :, :]) ** 2).sum(2))
36
+
37
+
38
+ def simple_procrustes(X, Y, mode="orthogonal"):
39
+ """Align ``Y`` to ``X`` (rotation/reflection, scale and translation).
40
+
41
+ ``mode='orthogonal'``: least-squares orthogonal Procrustes, rotation
42
+ ``V U'`` from the SVD ``X'JY = U D V'``. ``mode='MultBiplotR'`` uses the
43
+ rotation ``U V'`` of ``MultBiplotR::SimpleProcrustes``, which is the
44
+ transpose of the optimal one (it fixes reflections but turns small
45
+ rotations the wrong way); it is kept only to reproduce R's results."""
46
+ n = X.shape[0]
47
+ J = np.eye(n) - 1.0 / n
48
+ C = X.T @ J @ Y
49
+ U, d, Vt = np.linalg.svd(C)
50
+ T = U @ Vt if mode == "MultBiplotR" else Vt.T @ U.T
51
+ s = np.trace(C @ T) / np.trace(Y.T @ J @ Y)
52
+ t = (X - s * Y @ T).sum(0) / n
53
+ return s * Y @ T + t
54
+
55
+
56
+ def _resample(Er, rng, method):
57
+ m = len(Er)
58
+ if method == "sampling":
59
+ return Er[rng.sample_replace(m, m)]
60
+ return Er[rng.sample_noreplace(m, m)]
61
+
62
+
63
+ class OrdinationBootstrap:
64
+ """Replicates of a bootstrapped ordination."""
65
+
66
+ def __init__(self, **kw):
67
+ self.__dict__.update(kw)
68
+
69
+ def ellipses(self, level=0.95):
70
+ """Centre and covariance of the replicates of each individual (first two dims)."""
71
+ return [(c[:, :2].mean(0), np.cov(c[:, :2], rowvar=False)) for c in self.coordinates]
72
+
73
+ def summary(self) -> pd.DataFrame:
74
+ sd = np.array([c.std(0, ddof=1) for c in self.coordinates])
75
+ return pd.DataFrame(sd, index=self.row_names, columns=[f"sd Dim {k + 1}" for k in range(sd.shape[1])])
76
+
77
+ def plot(self, *args, **kwargs):
78
+ from .plot import plot_bootstrap_ordination
79
+ return plot_bootstrap_ordination(self, *args, **kwargs)
80
+
81
+
82
+ def bootstrap_pcoa(D, nB=200, dim=2, weights=None, method="sampling", procrustes="orthogonal", seed=None,
83
+ row_names=None) -> OrdinationBootstrap:
84
+ """Bootstrap of principal coordinates analysis (``MultBiplotR::BootstrapDistance``).
85
+
86
+ Residuals ``D - D_hat`` (``D_hat``: distances between the principal
87
+ coordinates in ``dim`` dimensions) are resampled, added to ``D_hat`` and the
88
+ PCoA is recomputed. ``seed`` uses R's generator (``set.seed(seed)``), so with
89
+ ``procrustes='MultBiplotR'`` the replicates are those of R. Returns, for each
90
+ replicate, the eigenvalues, the inertia (%), the aligned coordinates and the
91
+ qualities of representation (%) of each individual."""
92
+ if isinstance(D, pd.DataFrame):
93
+ row_names = row_names or [str(i) for i in D.index]
94
+ D = np.asarray(D, float)
95
+ n = D.shape[0]
96
+ if D.shape[1] != n:
97
+ raise ValueError("The distance matrix must be square")
98
+ if np.trace(D) > 0:
99
+ raise ValueError("That is not a distance matrix (no zeros in the diagonal)")
100
+ method = method.lower()
101
+ Wd = np.eye(n) if weights is None else np.diag(np.asarray(weights, float))
102
+ sW = np.sqrt(Wd); sWi = np.linalg.inv(sW)
103
+ J = np.eye(n) - 1.0 / n
104
+
105
+ def pco(Dm):
106
+ B = -0.5 * J @ (Dm ** 2) @ J
107
+ U, d, Vt = np.linalg.svd(sW @ B @ sW)
108
+ return sWi @ U @ np.diag(np.sqrt(d)), d
109
+ PO, d0 = pco(D)
110
+ PO = PO[:, :dim]
111
+ Dhat = _euclid(PO)
112
+ Er = _dist_vec(D - Dhat)
113
+ rng = RRNG(0 if seed is None else seed) if seed is not None else None
114
+ g = np.random.default_rng()
115
+ lam = np.zeros((n, nB)); inert = np.zeros((n, nB))
116
+ coords = np.zeros((n, nB, dim)); qual = np.zeros((n, nB, dim))
117
+ for b in range(nB):
118
+ if rng is not None:
119
+ ErE = _resample(Er, rng, method)
120
+ else:
121
+ ErE = Er[g.integers(0, len(Er), len(Er))] if method == "sampling" else g.permutation(Er)
122
+ DE = _vec_to_mat(ErE, n) + Dhat
123
+ POE, d = pco(DE)
124
+ lam[:, b] = d; inert[:, b] = d / d.sum() * 100
125
+ tot = (POE ** 2).sum(1)
126
+ POE = POE[:, :dim]
127
+ if procrustes:
128
+ POE = simple_procrustes(PO, POE, procrustes)
129
+ coords[:, b] = POE
130
+ qual[:, b] = POE ** 2 / tot[:, None] * 100
131
+ return OrdinationBootstrap(title=f"Bootstrap of PCoA ({method} of residuals)", method=method, nB=nB,
132
+ reference=PO, eigenvalues0=d0, eigenvalues=lam, inertia=inert,
133
+ coordinates=[coords[i] for i in range(n)], qualities=[qual[i] for i in range(n)],
134
+ row_names=row_names or [f"I{i + 1}" for i in range(n)])
135
+
136
+
137
+ def bootstrap_mds(D, nB=200, dim=2, type="ratio", method="sampling", seed=None, itmax=1000, eps=1e-10,
138
+ row_names=None) -> OrdinationBootstrap:
139
+ """Bootstrap of SMACOF multidimensional scaling (``mds``).
140
+
141
+ The residuals between the disparities and the configuration distances of
142
+ the MDS (both on the normalized scale of SMACOF) are resampled and added to
143
+ the configuration distances; negative values are set to 0. Each replicate
144
+ is refitted from the original configuration and aligned to it by
145
+ orthogonal Procrustes (rotation and reflection only). ``seed`` uses R's
146
+ generator. MultBiplotR's ``BootstrapSmacof`` cannot be used as a reference
147
+ (it reads ``Sol$Dhat``, which its ``SMACOF`` does not return); the
148
+ reference is the same scheme run in R on ``smacof::mds``."""
149
+ if isinstance(D, pd.DataFrame):
150
+ row_names = row_names or [str(i) for i in D.index]
151
+ D = np.asarray(D, float)
152
+ n = D.shape[0]
153
+ m0 = mds(D, dim=dim, type=type, itmax=itmax, eps=eps)
154
+ X0 = m0.row_coordinates
155
+ Dh = m0.config_distances
156
+ Er = _dist_vec(m0.disparities - Dh)
157
+ rng = RRNG(seed) if seed is not None else None
158
+ g = np.random.default_rng()
159
+ coords = np.zeros((n, nB, dim)); stress = np.zeros(nB)
160
+ for b in range(nB):
161
+ if rng is not None:
162
+ ErE = _resample(Er, rng, method.lower())
163
+ else:
164
+ ErE = Er[g.integers(0, len(Er), len(Er))] if method.lower() == "sampling" else g.permutation(Er)
165
+ DE = _vec_to_mat(ErE, n) + Dh
166
+ DE[DE < 0] = 0
167
+ mi = mds(DE, dim=dim, type=type, init=X0, itmax=itmax, eps=eps)
168
+ U, d, Vt = np.linalg.svd(mi.row_coordinates.T @ X0)
169
+ coords[:, b] = mi.row_coordinates @ (U @ Vt)
170
+ stress[b] = mi.stress
171
+ return OrdinationBootstrap(title=f"Bootstrap of MDS ({type}, {method} of residuals)", method=method, nB=nB,
172
+ reference=X0, stress0=m0.stress, stress=stress,
173
+ coordinates=[coords[i] for i in range(n)],
174
+ row_names=row_names or [f"I{i + 1}" for i in range(n)])
biplotpy/canonical.py ADDED
@@ -0,0 +1,198 @@
1
+ """Canonical / MANOVA biplot (port of MultBiplotR::CanonicalBiplot and
2
+ Canonical.Variate.Analysis; Gabriel, 1972; Amaro, Vicente-Villardón and
3
+ Galindo-Villardón, 2004).
4
+
5
+ The group means are represented with the within-groups metric. With Z the
6
+ indicator matrix, Xb the group means, S11 = Z'Z, B = Xb' S11 Xb and
7
+ S = X'X - B (within sums of squares and products):
8
+ Y = S11^{1/2} Xb S^{-1/2} = U D V'; variable markers H = S^{1/2} V, canonical
9
+ weights B = S^{-1/2} V, group markers J = Xb B and individuals X B.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from scipy import stats as sps
16
+
17
+ from .transforms import initial_transform, transform_name
18
+ from .utils import matrix_sqrt
19
+
20
+
21
+ class CanonicalBiplot:
22
+ def __init__(self, **kw):
23
+ self.__dict__.update(kw)
24
+
25
+ def _df(self, M, idx):
26
+ return pd.DataFrame(M, index=idx, columns=[f"Dim {d + 1}" for d in range(M.shape[1])])
27
+
28
+ @property
29
+ def groups(self):
30
+ return self._df(self.group_coordinates, self.group_names)
31
+
32
+ @property
33
+ def variables(self):
34
+ return self._df(self.col_coordinates, self.col_names)
35
+
36
+ def radii(self, kind="bonferroni"):
37
+ """Radii of the confidence circles for the group means:
38
+ ``'univariate'`` (t), ``'bonferroni'``, ``'multivariate'`` (Hotelling) or ``'chisq'``."""
39
+ return pd.Series({"univariate": self.univ_rad, "bonferroni": self.bonf_rad,
40
+ "multivariate": self.mult_rad, "chisq": self.chis_rad}[kind], index=self.group_names)
41
+
42
+ def predict(self, Xnew):
43
+ """Classify new (untransformed) observations by linear discriminant analysis
44
+ with priors equal to the group proportions."""
45
+ Z = (np.asarray(Xnew, float) - self._t_center) / self._t_scale
46
+ return self._classify(Z @ self.canonical_weights_full)
47
+
48
+ def summary(self) -> dict:
49
+ r = len(self.eigenvalues)
50
+ return {"eigen": pd.DataFrame({"Eigenvalue": self.eigenvalues, "Explained": self.inertia,
51
+ "Cumulative": self.cum_inertia}, index=[f"Dim {i + 1}" for i in range(r)]),
52
+ "manova": self.manova, "anovas": self.anovas,
53
+ "group_contributions": self._df(self.group_contributions, self.group_names),
54
+ "variable_quality": self._df(self.qlr_vars, self.col_names),
55
+ "structure": self._df(self.structure, self.col_names)}
56
+
57
+ def plot(self, *args, **kwargs):
58
+ from .plot import plot_canonical
59
+ return plot_canonical(self, *args, **kwargs)
60
+
61
+ def __repr__(self):
62
+ s = self.summary()
63
+ return (f"Canonical/MANOVA Biplot; transformation: {self.transformation}\n{s['eigen'].round(4).to_string()}\n\n"
64
+ f"MANOVA:\n{self.manova.round(4).to_string()}\n\nGroup markers:\n{self.groups.round(4).to_string()}")
65
+
66
+
67
+ def _manova(lam, n, g, m):
68
+ """Wilks, Pillai, Hotelling-Lawley and Roy from the eigenvalues of S^-1 B."""
69
+ s = min(m, g - 1)
70
+ dfh, dfe = g - 1, n - g
71
+ wilks = np.prod(1 / (1 + lam))
72
+ t = np.sqrt((dfh ** 2 * m ** 2 - 4) / (m ** 2 + dfh ** 2 - 5)) if (m ** 2 + dfh ** 2 - 5) > 0 else 1.0
73
+ w = dfe + dfh - 0.5 * (m + dfh + 1)
74
+ df1 = m * dfh
75
+ df2 = w * t - 0.5 * (m * dfh - 2)
76
+ Fw = (1 - wilks ** (1 / t)) / wilks ** (1 / t) * df2 / df1
77
+ pillai = np.sum(lam / (1 + lam))
78
+ mm, nn = 0.5 * (abs(m - dfh) - 1), 0.5 * (dfe - m - 1)
79
+ Fp = (2 * nn + s + 1) / (2 * mm + s + 1) * pillai / (s - pillai)
80
+ dfp = (s * (2 * mm + s + 1), s * (2 * nn + s + 1))
81
+ hl = np.sum(lam)
82
+ b = 2 * (s * nn + 1)
83
+ Fh = 2 * (s * nn + 1) * hl / (s ** 2 * (2 * mm + s + 1))
84
+ dfh_ = (s * (2 * mm + s + 1), b)
85
+ roy = lam.max()
86
+ q = max(m, dfh)
87
+ Fr = roy * (dfe - q + dfh) / q
88
+ dfr = (q, dfe - q + dfh)
89
+ tab = pd.DataFrame({
90
+ "Statistic": [wilks, pillai, hl, roy],
91
+ "F": [Fw, Fp, Fh, Fr],
92
+ "df1": [df1, dfp[0], dfh_[0], dfr[0]],
93
+ "df2": [df2, dfp[1], dfh_[1], dfr[1]]}, index=["Wilks", "Pillai", "Hotelling-Lawley", "Roy"])
94
+ tab["p"] = sps.f.sf(tab["F"], tab["df1"], tab["df2"])
95
+ return tab
96
+
97
+
98
+ def canonical_biplot(X, group, sup=None, transform=5, row_names=None, col_names=None) -> CanonicalBiplot:
99
+ """Canonical (MANOVA) biplot of the groups defined by ``group``.
100
+
101
+ ``transform``: initial transformation (5 = standardize columns, as in
102
+ MultBiplotR). ``sup``: supplementary individuals (raw scale).
103
+ """
104
+ if isinstance(X, pd.DataFrame):
105
+ row_names = row_names or [str(i) for i in X.index]
106
+ col_names = col_names or [str(c) for c in X.columns]
107
+ X = X.to_numpy(float)
108
+ X0 = np.array(X, float)
109
+ n, m = X0.shape
110
+ col_names = list(col_names) if col_names is not None else [f"V{j + 1}" for j in range(m)]
111
+ row_names = list(row_names) if row_names is not None else [f"I{i + 1}" for i in range(n)]
112
+ grp = np.asarray(group)
113
+ if hasattr(group, "cat"):
114
+ levels = [str(c) for c in group.cat.categories]
115
+ elif hasattr(group, "categories"):
116
+ levels = [str(c) for c in group.categories]
117
+ else:
118
+ levels = sorted({str(v) for v in grp})
119
+ grp = np.array([str(v) for v in grp])
120
+ tr = initial_transform(X0, transform_name(transform))
121
+ Xs = tr["X"]
122
+ name = transform_name(transform)
123
+ t_center = tr["col_means"] if name in ("Standardize columns", "Column centering") else np.zeros(m)
124
+ t_scale = tr["col_sds"] if name == "Standardize columns" else np.ones(m)
125
+ g = len(levels)
126
+ r = min(g - 1, m)
127
+ Z = np.column_stack([(grp == lev).astype(float) for lev in levels])
128
+ ng = Z.sum(0)
129
+ S11 = np.diag(ng)
130
+ Xb = (Z.T @ Xs) / ng[:, None]
131
+ Bm = Xb.T @ S11 @ Xb
132
+ S = Xs.T @ Xs - Bm
133
+ Y = np.diag(np.sqrt(ng)) @ Xb @ matrix_sqrt(S, inverse=True)
134
+ U, d, Vt = np.linalg.svd(Y, full_matrices=False)
135
+ Vv = Vt.T
136
+ H = matrix_sqrt(S) @ Vv[:, :r]
137
+ Bw = matrix_sqrt(S, inverse=True) @ Vv[:, :r]
138
+ J = Xb @ Bw
139
+ Vi = Xs @ Bw
140
+ VS = None if sup is None else ((np.asarray(sup, float) - t_center) / t_scale) @ Bw
141
+ vprop = d[:r]
142
+ iner = vprop ** 2 / np.sum(vprop ** 2) * 100
143
+ lam = vprop ** 2
144
+ manova = _manova(lam, n, g, m)
145
+ # contributions and qualities
146
+ gcontr = J ** 2 / (J ** 2).sum(1, keepdims=True)
147
+ ccontr = H ** 2 / (H ** 2).sum(1, keepdims=True)
148
+ SCT = (Xs ** 2).sum()
149
+ SCRows, SCCols = (Xs ** 2).sum(1), (Xs ** 2).sum(0)
150
+ expl = np.zeros(r); rowc = np.zeros((n, r)); qlr = np.zeros((m, r))
151
+ for j in range(r):
152
+ res = Xs - Vi[:, :j + 1] @ H[:, :j + 1].T
153
+ expl[j] = 1 - (res ** 2).sum() / SCT
154
+ rowc[:, j] = 1 - (res ** 2).sum(1) / SCRows
155
+ qlr[:, j] = 1 - (res ** 2).sum(0) / SCCols
156
+ sctotal = np.diag(Xs.T @ Xs)
157
+ scdentro = np.diag(S)
158
+ scentre = sctotal - scdentro
159
+ glh, gle = g - 1, n - g
160
+ fs = (scentre / glh) / (scdentro / gle)
161
+ anovas = pd.DataFrame({"Total": sctotal, "Groups": scentre, "Error": scdentro, "F": fs,
162
+ "p-val": sps.f.sf(fs, glh, gle)}, index=col_names)
163
+ base = 1 / np.sqrt(ng) / np.sqrt(n - g)
164
+ univ = sps.t.ppf(1 - 0.025, n - g) * base
165
+ bonf = sps.t.ppf(1 - 0.025 / (g * m), n - g) * base
166
+ mult = np.sqrt(sps.f.ppf(0.95, m, n - g - m + 1) * ((n - g) * m) / (n - g - m + 1)) * base
167
+ chis = np.sqrt(sps.chi2.ppf(0.95, 2)) * base
168
+ structure = np.corrcoef(np.column_stack([Xs, Vi]), rowvar=False)[:m, m:]
169
+ # LDA classification in the full canonical space (within covariance = S/(n-g))
170
+ Bfull = matrix_sqrt(S, inverse=True) @ Vv[:, :r] * np.sqrt(n - g)
171
+ Jf = Xb @ Bfull
172
+ logp = np.log(ng / n)
173
+
174
+ def classify(Zc):
175
+ d2 = ((Zc[:, None, :] - Jf[None, :, :]) ** 2).sum(2)
176
+ return np.array(levels)[np.argmax(-0.5 * d2 + logp, axis=1)]
177
+
178
+ pred = classify(Xs @ Bfull)
179
+ ct = pd.crosstab(pd.Series(grp, name="group"), pd.Series(pred, name="predicted"))
180
+ return CanonicalBiplot(
181
+ title="Canonical/MANOVA Biplot", transformation=name, n=n, p=m, g=g, dim=r, group=grp,
182
+ group_names=levels, col_names=col_names, row_names=row_names, scaled_data=Xs,
183
+ eigenvalues=vprop, inertia=iner, cum_inertia=np.cumsum(iner), wilks_f=manova.loc["Wilks", "F"],
184
+ wilks_p=manova.loc["Wilks", "p"], manova=manova, anovas=anovas,
185
+ group_coordinates=J, col_coordinates=H, canonical_weights=Bw, row_coordinates=Vi,
186
+ sup_coordinates=VS, structure=structure, group_contributions=gcontr, col_contributions=ccontr,
187
+ qlr_vars=qlr, row_contributions=rowc, expl_total=expl,
188
+ univ_rad=univ, bonf_rad=bonf, mult_rad=mult, chis_rad=chis,
189
+ predicted=pred, classification_table=ct,
190
+ percent_correct=float(np.mean(pred == grp)),
191
+ canonical_weights_full=Bfull, _classify=classify, _t_center=t_center, _t_scale=t_scale)
192
+
193
+
194
+ def canonical_variate_analysis(X, group, transform=5, **kw) -> CanonicalBiplot:
195
+ """Canonical variate analysis (same solution, MANOVA and classification)."""
196
+ res = canonical_biplot(X, group, transform=transform, **kw)
197
+ res.title = "Canonical Analysis"
198
+ return res
biplotpy/cdpca.py ADDED
@@ -0,0 +1,121 @@
1
+ """Clustering and Disjoint Principal Component Analysis (CDPCA; Vichi &
2
+ Saporta, 2009; Macedo & Freitas, 2015). Port of ``biplotbootGUI::CDpca``.
3
+
4
+ Simultaneously partitions the I objects into P clusters (membership U) and
5
+ the J variables into Q disjoint groups (membership V), each group defining one
6
+ component (loadings A, non-zero only on its variables), maximizing the
7
+ between-cluster deviance in the reduced space, F = ||U Ybar||^2."""
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+
13
+ def _rand_mat(rng, d1, d2):
14
+ U = np.zeros((d1, d2))
15
+ U[:d2, :d2] = np.eye(d2)
16
+ for j in range(d2, d1):
17
+ U[j, rng.integers(d2)] = 1
18
+ return U
19
+
20
+
21
+ def _lead(Xg, idx, I):
22
+ """Loading vector of a group of variables (first eigenvector, sign with positive sum)."""
23
+ if len(idx) == 1:
24
+ return np.ones(1)
25
+ if I >= len(idx):
26
+ w, v = np.linalg.eigh(Xg[:, idx].T @ Xg[:, idx]); a = v[:, -1]
27
+ else:
28
+ w, v = np.linalg.eigh(Xg[:, idx] @ Xg[:, idx].T)
29
+ a = Xg[:, idx].T @ v[:, -1] / np.sqrt(w[-1])
30
+ return a if a.sum() >= 0 else -a
31
+
32
+
33
+ def _loadings(Xg, V, J, Q, I):
34
+ A = np.zeros((J, Q))
35
+ for j in range(Q):
36
+ idx = np.where(V[:, j] == 1)[0]
37
+ if len(idx):
38
+ A[idx, j] = _lead(Xg, idx, I)
39
+ return A
40
+
41
+
42
+ def _one_run(Xs, P, Q, U, V, tol, maxit):
43
+ I, J = Xs.shape
44
+ Xbar = (U.T @ Xs) / U.sum(0)[:, None]
45
+ Xg = U @ Xbar
46
+ A = _loadings(Xg, V, J, Q, I)
47
+ A0 = A.copy()
48
+ Ybar = Xbar @ A
49
+ F0 = np.sum((U @ Ybar) ** 2)
50
+ Fmax = F0
51
+ it = 0
52
+ while True:
53
+ it += 1
54
+ Y = Xs @ A
55
+ U = np.zeros((I, P))
56
+ U[np.arange(I), ((Y[:, None, :] - Ybar[None, :, :]) ** 2).sum(2).argmin(1)] = 1
57
+ su = U.sum(0)
58
+ while (su == 0).any(): # empty cluster: move half of the largest
59
+ imax, imin = su.argmax(), su.argmin()
60
+ sel = np.where(U[:, imax] == 1)[0][:int(su[imax]) // 2]
61
+ U[sel, imin] = 1; U[sel, imax] = 0
62
+ su = U.sum(0)
63
+ Xbar = (U.T @ Xs) / U.sum(0)[:, None]
64
+ Ybar = Xbar @ A
65
+ Xg = U @ Xbar
66
+ for j in range(J):
67
+ posmax = int(np.where(V[j] == 1)[0][0])
68
+ for g in range(Q):
69
+ V[j] = np.eye(Q)[g]
70
+ if g != posmax:
71
+ for gg in (g, posmax):
72
+ idx = np.where(V[:, gg] == 1)[0]
73
+ A[:, gg] = 0
74
+ if len(idx):
75
+ A[idx, gg] = _lead(Xg, idx, I)
76
+ Ybar = Xbar @ A
77
+ F = np.sum((U @ Ybar) ** 2)
78
+ if F > Fmax:
79
+ Fmax, posmax, A0 = F, g, A.copy()
80
+ else:
81
+ A = A0.copy()
82
+ V[j] = np.eye(Q)[posmax]
83
+ Y = Xs @ A
84
+ Ybar = Xbar @ A
85
+ F = np.sum((U @ Ybar) ** 2)
86
+ conv = F - F0
87
+ if conv > tol:
88
+ F0, A0 = F, A.copy()
89
+ else:
90
+ break
91
+ if it == maxit:
92
+ break
93
+ return {"U": U, "V": V, "A": A, "F": F, "iter": it, "Xbar": Xbar}
94
+
95
+
96
+ def cdpca(X, P: int, Q: int, nstart: int = 20, tol: float = 1e-5, maxit: int = 100, seed=None, init=None) -> dict:
97
+ """CDPCA with ``nstart`` random starts (``init`` = (U0, V0) for a single
98
+ given start). Data are standardized with variance divided by I, as in the
99
+ original. Components are ordered by explained variance."""
100
+ X = np.asarray(X, float)
101
+ I, J = X.shape
102
+ Xs = (X - X.mean(0)) / X.std(0, ddof=1) * np.sqrt(I / (I - 1))
103
+ rng = np.random.default_rng(seed)
104
+ best = None
105
+ starts = [init] if init is not None else [None] * nstart
106
+ for st in starts:
107
+ U0, V0 = (np.array(st[0], float), np.array(st[1], float)) if st is not None else \
108
+ (_rand_mat(rng, I, P), _rand_mat(rng, J, Q))
109
+ r = _one_run(Xs, P, Q, U0, V0, tol, maxit)
110
+ if best is None or r["F"] > best["F"]:
111
+ best = r
112
+ U, V, A = best["U"], best["V"], best["A"]
113
+ Y = Xs @ A
114
+ Ybar = best["Xbar"] @ A
115
+ var = np.var(Y, axis=0, ddof=1) * 100 / J
116
+ o = np.argsort(-var, kind="stable")
117
+ return {"U": U, "cluster": U.argmax(1) + 1, "V": V[:, o], "A": A[:, o], "Y": Y[:, o], "Ybar": Ybar[:, o],
118
+ "F": best["F"], "iter": best["iter"], "var_percent": var[o],
119
+ "bcdev_total": best["F"] / (I * J) * 100,
120
+ "bcdev": np.sum((U @ Ybar) ** 2) / np.sum(Y ** 2) * 100,
121
+ "Enorm": np.linalg.norm(Xs - U @ Ybar @ A.T) / I}