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/__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}
|