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/rrng.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Random numbers identical to those of R.
|
|
2
|
+
|
|
3
|
+
``RRNG(seed)`` reproduces ``set.seed(seed); rnorm(n)`` of R (Mersenne-Twister
|
|
4
|
+
with R's seed scrambling, normal draws by inversion with
|
|
5
|
+
``qnorm`` of Wichura, algorithm AS 241). With it the procedures of biplotpy that
|
|
6
|
+
start from random numbers (alternating least squares from a random start,
|
|
7
|
+
bootstrap resampling, random starts of BinaryPLSR) give, seed by seed, the
|
|
8
|
+
same results as their R counterparts. About 97% of the normal draws are bitwise equal
|
|
9
|
+
to R's and the rest differ by at most a few units in the last place (rounding
|
|
10
|
+
inside ``qnorm``), which does not change any p-value in practice.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
_I2_32M1 = 2.328306437080797e-10 # 1 / (2^32 - 1)
|
|
19
|
+
_BIG = 134217728.0 # 2^27
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _r_seed_state(seed: int) -> np.ndarray:
|
|
23
|
+
s = int(seed) & 0xFFFFFFFF
|
|
24
|
+
for _ in range(50):
|
|
25
|
+
s = (69069 * s + 1) & 0xFFFFFFFF
|
|
26
|
+
st = []
|
|
27
|
+
for _ in range(625):
|
|
28
|
+
s = (69069 * s + 1) & 0xFFFFFFFF
|
|
29
|
+
st.append(s)
|
|
30
|
+
return np.array(st[1:], dtype=np.uint32)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def qnorm_r(p):
|
|
34
|
+
"""R's qnorm(p, 0, 1) (Wichura AS241, nmath/qnorm.c), vectorized."""
|
|
35
|
+
p = np.asarray(p, float); q = p - 0.5; out = np.empty_like(p)
|
|
36
|
+
c = np.abs(q) <= 0.425
|
|
37
|
+
r = 0.180625 - q[c] * q[c]
|
|
38
|
+
out[c] = q[c] * (((((((r * 2509.0809287301226727 + 33430.575583588128105) * r + 67265.770927008700853) * r
|
|
39
|
+
+ 45921.953931549871457) * r + 13731.693765509461125) * r + 1971.5909503065514427) * r + 133.14166789178437745) * r
|
|
40
|
+
+ 3.387132872796366608) / (((((((r * 5226.495278852545925 + 28729.085735721942674) * r + 39307.89580009271061) * r
|
|
41
|
+
+ 21213.794301586595867) * r + 5394.1960214247511077) * r + 687.1870074920579083) * r + 42.313330701600911252) * r + 1.)
|
|
42
|
+
nc = ~c
|
|
43
|
+
pp = np.where(q[nc] < 0, p[nc], 1 - p[nc])
|
|
44
|
+
r = np.sqrt(-np.array([math.log(v) for v in pp]))
|
|
45
|
+
v = np.empty_like(r)
|
|
46
|
+
a = r <= 5.
|
|
47
|
+
ra = r[a] - 1.6
|
|
48
|
+
v[a] = (((((((ra * 7.7454501427834140764e-4 + .0227238449892691845833) * ra + .24178072517745061177) * ra
|
|
49
|
+
+ 1.27045825245236838258) * ra + 3.64784832476320460504) * ra + 5.7694972214606914055) * ra + 4.6303378461565452959) * ra
|
|
50
|
+
+ 1.42343711074968357734) / (((((((ra * 1.05075007164441684324e-9 + 5.475938084995344946e-4) * ra + .0151986665636164571966) * ra
|
|
51
|
+
+ .14810397642748007459) * ra + .68976733498510000455) * ra + 1.6763848301838038494) * ra + 2.05319162663775882187) * ra + 1.)
|
|
52
|
+
b = ~a
|
|
53
|
+
rb = r[b] - 5.
|
|
54
|
+
v[b] = (((((((rb * 2.01033439929228813265e-7 + 2.71155556874348757815e-5) * rb + .0012426609473880784386) * rb
|
|
55
|
+
+ .026532189526576123093) * rb + .29656057182850489123) * rb + 1.7848265399172913358) * rb + 5.4637849111641143699) * rb
|
|
56
|
+
+ 6.6579046435011037772) / (((((((rb * 2.04426310338993978564e-15 + 1.4215117583164458887e-7) * rb + 1.8463183175100546818e-5) * rb
|
|
57
|
+
+ 7.868691311456132591e-4) * rb + .0148753612908506148525) * rb + .13692988092273580531) * rb + .59983220655588793769) * rb + 1.)
|
|
58
|
+
v[q[nc] < 0] = -v[q[nc] < 0]
|
|
59
|
+
out[nc] = v
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RRNG:
|
|
64
|
+
"""Generator compatible with R's default ``RNGkind`` (Mersenne-Twister, Inversion).
|
|
65
|
+
|
|
66
|
+
It exposes ``standard_normal`` and ``integers`` so that it can be passed
|
|
67
|
+
wherever the package accepts a ``numpy.random.Generator``. Draws are taken
|
|
68
|
+
in the order R takes them (``matrix(rnorm(n), ...)`` fills by columns).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, seed: int):
|
|
72
|
+
self.bg = np.random.MT19937()
|
|
73
|
+
self.bg.state = {"bit_generator": "MT19937", "state": {"key": _r_seed_state(seed), "pos": 624}}
|
|
74
|
+
|
|
75
|
+
def unif(self, n: int) -> np.ndarray:
|
|
76
|
+
"""``runif(n)`` of R."""
|
|
77
|
+
v = self.bg.random_raw(n).astype(np.float64) * 2.3283064365386963e-10
|
|
78
|
+
v[v <= 0.0] = 0.5 * _I2_32M1
|
|
79
|
+
v[1.0 - v <= 0.0] = 1.0 - 0.5 * _I2_32M1
|
|
80
|
+
return v
|
|
81
|
+
|
|
82
|
+
def standard_normal(self, size=None) -> np.ndarray:
|
|
83
|
+
"""``rnorm(n)`` of R; ``size`` may be an int or a shape (filled in C order)."""
|
|
84
|
+
n = 1 if size is None else int(np.prod(size))
|
|
85
|
+
u = self.unif(2 * n).reshape(n, 2)
|
|
86
|
+
z = qnorm_r((np.floor(_BIG * u[:, 0]) + u[:, 1]) / _BIG)
|
|
87
|
+
return z[0] if size is None else z.reshape(size)
|
|
88
|
+
|
|
89
|
+
def integers(self, high: int) -> int:
|
|
90
|
+
"""``sample.int(high, 1) - 1`` of R (sample.kind = "Rejection")."""
|
|
91
|
+
bits = int(math.ceil(math.log2(high))) if high > 1 else 0
|
|
92
|
+
while True:
|
|
93
|
+
v = 0.0
|
|
94
|
+
for _ in range(0, bits + 1, 16):
|
|
95
|
+
v1 = int(math.floor(self.unif(1)[0] * 65536))
|
|
96
|
+
v = 65536 * v + v1
|
|
97
|
+
v = int(v) & ((1 << bits) - 1) # R masks always (bits = 0 gives 0)
|
|
98
|
+
if v < high:
|
|
99
|
+
return int(v)
|
|
100
|
+
|
|
101
|
+
def rnorm(self, n: int) -> np.ndarray:
|
|
102
|
+
"""``rnorm(n)`` of R."""
|
|
103
|
+
return self.standard_normal(int(n))
|
|
104
|
+
|
|
105
|
+
def sample_replace(self, n: int, size: int) -> np.ndarray:
|
|
106
|
+
"""0-based indices of ``sample.int(n, size, replace = TRUE)``."""
|
|
107
|
+
return np.array([self.integers(n) for _ in range(int(size))], dtype=int)
|
|
108
|
+
|
|
109
|
+
def sample_noreplace(self, n: int, size: int) -> np.ndarray:
|
|
110
|
+
"""0-based indices of ``sample.int(n, size)`` (no hashing, n <= 1e7)."""
|
|
111
|
+
x = list(range(int(n)))
|
|
112
|
+
m = int(n)
|
|
113
|
+
out = []
|
|
114
|
+
for _ in range(int(size)):
|
|
115
|
+
j = self.integers(m)
|
|
116
|
+
out.append(x[j])
|
|
117
|
+
m -= 1
|
|
118
|
+
x[j] = x[m]
|
|
119
|
+
return np.array(out, dtype=int)
|
biplotpy/statis.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""STATIS-ACT family (port of MultBiplotR::StatisBiplot, DualStatisBiplot and
|
|
2
|
+
CanonicalStatisBiplot; Lavit et al., 1994; Vallejo-Arboleda,
|
|
3
|
+
Vicente-Villardón & Galindo-Villardón, 2007).
|
|
4
|
+
|
|
5
|
+
* ``statis``: several tables with the same individuals (rows). Objects
|
|
6
|
+
W_t = X_t X_t'; interstructure from the RV matrix; compromise
|
|
7
|
+
W = sum alpha_t W_t with alpha from the first eigenvector; biplot of the
|
|
8
|
+
compromise with the variables of every table.
|
|
9
|
+
* ``dual_statis``: several tables with the same variables (columns). Objects
|
|
10
|
+
W_t = X_t' X_t (cross-product matrices); compromise of the variables.
|
|
11
|
+
* ``canonical_statis``: several tables with the same individuals and a group
|
|
12
|
+
structure; objects W_t = Xb_t S_t^{-1} Xb_t' (Mahalanobis scalar products
|
|
13
|
+
between group means), giving a compromise canonical biplot of the groups.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from .transforms import initial_transform, transform_name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StatisResult:
|
|
24
|
+
def __init__(self, **kw):
|
|
25
|
+
self.__dict__.update(kw)
|
|
26
|
+
|
|
27
|
+
def plot(self, *args, **kwargs):
|
|
28
|
+
from .plot import plot_statis
|
|
29
|
+
return plot_statis(self, *args, **kwargs)
|
|
30
|
+
|
|
31
|
+
def __repr__(self):
|
|
32
|
+
return (f"{self.title}: {self.n_tables} tables\nRV matrix:\n{self.RV.round(3).to_string()}\n"
|
|
33
|
+
f"Weights: {np.round(self.weights, 4)}\nEigenvalues of the compromise: {np.round(self.eigenvalues[:5], 4)}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _interstructure(Wt, names):
|
|
37
|
+
ng = len(Wt)
|
|
38
|
+
P = np.array([[np.sum(Wt[i] * Wt[j]) for j in range(ng)] for i in range(ng)])
|
|
39
|
+
s = 1 / np.sqrt(np.diag(P))
|
|
40
|
+
RV = P * s[:, None] * s[None, :]
|
|
41
|
+
u, d, _ = np.linalg.svd(RV)
|
|
42
|
+
weights = np.abs(u[:, 0]) / np.abs(u[:, 0]).sum()
|
|
43
|
+
inter = -1 * u * np.sqrt(d)
|
|
44
|
+
return (pd.DataFrame(P, index=names, columns=names), pd.DataFrame(RV, index=names, columns=names),
|
|
45
|
+
d, d / d.sum() * 100, inter, weights)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _compromise(Wt, weights):
|
|
49
|
+
W = sum(w * M for w, M in zip(weights, Wt))
|
|
50
|
+
U, d, _ = np.linalg.svd(W)
|
|
51
|
+
r = int(np.sum(d > 1e-5))
|
|
52
|
+
return W, U, d, r
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def statis(X, transform=5, dim=2, names=None) -> StatisResult:
|
|
56
|
+
"""STATIS-ACT for tables with the same rows (list or dict of n x p_t matrices)."""
|
|
57
|
+
if isinstance(X, dict):
|
|
58
|
+
names = names or list(X)
|
|
59
|
+
X = list(X.values())
|
|
60
|
+
names = names or [f"Occasion_{i + 1}" for i in range(len(X))]
|
|
61
|
+
col_names = [list(x.columns) if isinstance(x, pd.DataFrame) else [f"V{j + 1}" for j in range(np.shape(x)[1])] for x in X]
|
|
62
|
+
X = [initial_transform(np.asarray(x, float), transform_name(transform))["X"] for x in X]
|
|
63
|
+
n = X[0].shape[0]
|
|
64
|
+
if any(x.shape[0] != n for x in X):
|
|
65
|
+
raise ValueError("The number of rows must be the same in all occasions")
|
|
66
|
+
Wt = [x @ x.T for x in X]
|
|
67
|
+
P, RV, eig, iner, inter, w = _interstructure(Wt, names)
|
|
68
|
+
W, U, d, r = _compromise(Wt, w)
|
|
69
|
+
rows = U[:, :dim] * np.sqrt(d[:dim])
|
|
70
|
+
sf = ((U[:, :r] * np.sqrt(d[:r])) ** 2).sum(1)
|
|
71
|
+
row_contr = rows ** 2 / sf[:, None] * 100
|
|
72
|
+
trajin = [Wt[j] @ U[:, :r] * np.sqrt(1 / d[:r]) for j in range(len(X))]
|
|
73
|
+
contvar, cols, col_labels = [], [], []
|
|
74
|
+
for j, x in enumerate(X):
|
|
75
|
+
tv = x.T @ U[:, :r]
|
|
76
|
+
contvar.append(tv ** 2 / (tv ** 2).sum(1, keepdims=True) * 100)
|
|
77
|
+
cols.append(x.T @ U[:, :r] / d[:r])
|
|
78
|
+
col_labels += [f"{c}-{names[j]}" for c in col_names[j]]
|
|
79
|
+
col_coords = np.vstack([c[:, :dim] for c in cols])
|
|
80
|
+
col_contr = np.vstack([c[:, :dim] for c in contvar])
|
|
81
|
+
p = col_coords.shape[0]
|
|
82
|
+
sca = (rows ** 2).sum() / n
|
|
83
|
+
scb = (col_coords ** 2).sum() / p
|
|
84
|
+
scf = np.sqrt(np.sqrt(scb / sca))
|
|
85
|
+
return StatisResult(title="STATIS-ACT", n_tables=len(X), names=names, data=X, objects=Wt, scalar_products=P,
|
|
86
|
+
RV=RV, eig_inter=eig, inertia_inter=iner, interstructure=inter, weights=w, compromise=W,
|
|
87
|
+
eigenvalues=d[:r], inertia=d[:r] / d[:r].sum() * 100,
|
|
88
|
+
row_coordinates=rows * scf, row_contributions=row_contr,
|
|
89
|
+
col_coordinates=col_coords / scf, col_contributions=col_contr, col_labels=col_labels,
|
|
90
|
+
scale_factor=scf, trajectories=[t[:, :dim] * scf for t in trajin])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _common_sd_transform(X, transform):
|
|
94
|
+
if transform_name(transform) == "Standardize columns":
|
|
95
|
+
nr = np.array([x.shape[0] for x in X])
|
|
96
|
+
sds = np.array([np.std(x, axis=0, ddof=1) for x in X])
|
|
97
|
+
common = np.sqrt(((nr - 1)[:, None] * sds ** 2).sum(0) / (nr - 1).sum())
|
|
98
|
+
return [(x - x.mean(0)) / common for x in X], common
|
|
99
|
+
return [initial_transform(x, transform_name(transform))["X"] for x in X], None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def dual_statis(X, transform=5, dim=2, names=None) -> StatisResult:
|
|
103
|
+
"""Dual STATIS for tables with the same variables (columns). Standardization
|
|
104
|
+
uses the pooled within-table standard deviation (as MultBiplotR)."""
|
|
105
|
+
if isinstance(X, dict):
|
|
106
|
+
names = names or list(X)
|
|
107
|
+
X = list(X.values())
|
|
108
|
+
names = names or [f"Occasion_{i + 1}" for i in range(len(X))]
|
|
109
|
+
var_names = list(X[0].columns) if isinstance(X[0], pd.DataFrame) else [f"V{j + 1}" for j in range(np.shape(X[0])[1])]
|
|
110
|
+
X, common = _common_sd_transform([np.asarray(x, float) for x in X], transform)
|
|
111
|
+
Wt = [x.T @ x for x in X]
|
|
112
|
+
P, RV, eig, iner, inter, w = _interstructure(Wt, names)
|
|
113
|
+
W, U, d, r = _compromise(Wt, w)
|
|
114
|
+
cols = U[:, :dim] * np.sqrt(d[:dim])
|
|
115
|
+
sf = ((U[:, :r] * np.sqrt(d[:r])) ** 2).sum(1)
|
|
116
|
+
contr = cols ** 2 / sf[:, None] * 100
|
|
117
|
+
rows = [x @ U[:, :dim] for x in X] # individuals of each table projected on the compromise axes
|
|
118
|
+
return StatisResult(title="Dual STATIS-ACT", n_tables=len(X), names=names, data=X, objects=Wt,
|
|
119
|
+
scalar_products=P, RV=RV, eig_inter=eig, inertia_inter=iner, interstructure=inter, weights=w,
|
|
120
|
+
compromise=W, eigenvalues=d[:r], inertia=d[:r] / d[:r].sum() * 100, col_coordinates=cols,
|
|
121
|
+
col_contributions=contr, var_names=var_names, row_coordinates=rows, common_sd=common)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def canonical_statis(X, groups, transform=5, dim=2, names=None) -> StatisResult:
|
|
125
|
+
"""Canonical STATIS-ACT (Vallejo-Arboleda et al., 2007)."""
|
|
126
|
+
if isinstance(X, dict):
|
|
127
|
+
names = names or list(X)
|
|
128
|
+
X = list(X.values())
|
|
129
|
+
names = names or [f"Occasion_{i + 1}" for i in range(len(X))]
|
|
130
|
+
g = np.asarray([str(v) for v in groups])
|
|
131
|
+
levels = list(groups.cat.categories) if hasattr(groups, "cat") else sorted(set(g))
|
|
132
|
+
levels = [str(v) for v in levels]
|
|
133
|
+
X = [initial_transform(np.asarray(x, float), transform_name(transform))["X"] for x in X]
|
|
134
|
+
Z = np.column_stack([(g == lev).astype(float) for lev in levels])
|
|
135
|
+
ng = Z.sum(0)
|
|
136
|
+
Xbt, Bt, St, Wt = [], [], [], []
|
|
137
|
+
for x in X:
|
|
138
|
+
Xb = (Z.T @ x) / ng[:, None]
|
|
139
|
+
B = Xb.T @ np.diag(ng) @ Xb
|
|
140
|
+
S = x.T @ x - B
|
|
141
|
+
Xbt.append(Xb); Bt.append(B); St.append(S)
|
|
142
|
+
Wt.append(Xb @ np.linalg.solve(S, Xb.T))
|
|
143
|
+
P, RV, eig, iner, inter, w = _interstructure(Wt, names)
|
|
144
|
+
W, U, d, r = _compromise(Wt, w)
|
|
145
|
+
rows = U[:, :dim] * np.sqrt(d[:dim])
|
|
146
|
+
sf = ((U[:, :r] * np.sqrt(d[:r])) ** 2).sum(1)
|
|
147
|
+
contr = rows ** 2 / sf[:, None] * 100
|
|
148
|
+
trajin = [Wt[j] @ U[:, :r] * np.sqrt(1 / d[:r]) for j in range(len(X))]
|
|
149
|
+
cols, ccontr = [], []
|
|
150
|
+
for xb in Xbt:
|
|
151
|
+
tv = xb.T @ U[:, :r]
|
|
152
|
+
ccontr.append((tv ** 2 / (tv ** 2).sum(1, keepdims=True) * 100)[:, :dim])
|
|
153
|
+
cols.append((tv / d[:r])[:, :dim])
|
|
154
|
+
col_coords = np.vstack(cols)
|
|
155
|
+
p = col_coords.shape[0]
|
|
156
|
+
scf = np.sqrt(np.sqrt(((col_coords ** 2).sum() / p) / ((rows ** 2).sum() / len(levels))))
|
|
157
|
+
return StatisResult(title="Canonical STATIS-ACT", n_tables=len(X), names=names, levels=levels, data=X,
|
|
158
|
+
means=Xbt, between=Bt, within=St, objects=Wt, scalar_products=P, RV=RV, eig_inter=eig,
|
|
159
|
+
inertia_inter=iner, interstructure=inter, weights=w, compromise=W, eigenvalues=d[:r],
|
|
160
|
+
inertia=d[:r] / d[:r].sum() * 100, row_coordinates=rows * scf, row_contributions=contr,
|
|
161
|
+
col_coordinates=col_coords / scf, col_contributions=np.vstack(ccontr), scale_factor=scf,
|
|
162
|
+
trajectories=[t[:, :dim] * scf for t in trajin])
|
biplotpy/transforms.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Initial transformations of a data matrix (port of MultBiplotR::InitialTransform).
|
|
2
|
+
|
|
3
|
+
The transformation can be given by name or by its number in MultBiplotR (1-12).
|
|
4
|
+
Two documented defects of the R code are corrected here (see ``FIXES``).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
TRANSFORMS = ["Raw Data", "Substract the global mean", "Double centering", "Column centering",
|
|
11
|
+
"Standardize columns", "Row centering", "Standardize rows",
|
|
12
|
+
"Divide by the column means and center", "Normalized residuals from independence",
|
|
13
|
+
"Divide by the range", "Within groups standardization", "Ranks"]
|
|
14
|
+
|
|
15
|
+
FIXES = {
|
|
16
|
+
"Within groups standardization": "MultBiplotR divides by the within-group variance; biplotpy divides by its square root (standard deviation).",
|
|
17
|
+
"Logit": "MultBiplotR computes the logit into an unused variable, so the data are not transformed; biplotpy applies it.",
|
|
18
|
+
"Supplementary rows": "PCA.Biplot in MultBiplotR projects the untransformed supplementary rows; biplotpy projects the transformed ones.",
|
|
19
|
+
"StatisBiplot transform": "StatisBiplot and CanonicalStatisBiplot pass InitTransform positionally to the wrong argument of TransformIni, so the data are always standardized whatever is requested; biplotpy honours `transform` (the default, 5, gives identical results).",
|
|
20
|
+
"StatisBiplot column names": "StatisBiplot fails when the tables have no column names; biplotpy generates them.",
|
|
21
|
+
"DualStatisBiplot": "DualStatisBiplot is unfinished in MultBiplotR (stops with a dimnames error); biplotpy implements the full method, and its core (pooled SD, RV, weights, compromise) is validated against MultBiplotR's own lines.",
|
|
22
|
+
"CanonicalStatisBiplot within": "CanonicalStatisBiplot stores the group means in $Within; biplotpy stores the within-group SSCP matrices.",
|
|
23
|
+
"BinaryLogBiplotGD": "MultBiplotR's gradient-descent binary logistic biplot divides the log-likelihood by 2 in the objective but not in the gradient; biplotpy minimizes a single consistent penalized objective, validated against R optim on that objective.",
|
|
24
|
+
"NIPALS.Biplot": "MultBiplotR's NIPALS.Biplot multiplies the row scores X V by the singular values again (rows = U D^2), so the biplot does not reproduce the data; biplotpy uses U D (and ade4's algorithm when there are missing values, which MultBiplotR does not handle).",
|
|
25
|
+
"Coinertia": "MultBiplotR's Coinertia replaces Y by X (Y = InitialTransform(X, ...)) and returns no coordinates; biplotpy follows ade4::coinertia.",
|
|
26
|
+
"BinaryDistances": "In MultBiplotR's BinaryDistances, Kulczynski2 is unreachable (the switch has Kulezynski twice), the sqrt(2(1-S)) transformation uses == instead of = (error), and 1/(S+1) computes 1/S + 1; biplotpy implements the stated formulas.",
|
|
27
|
+
"PrincipalCoordinates weights": "MultBiplotR's PrincipalCoordinates centres with (I - 1 w) on both sides, which is not the weighted double centring unless the weights are equal; biplotpy uses (I - 1 w') b (I - w 1'). Equal weights give identical results.",
|
|
28
|
+
"FA.Biplot scores": "MultBiplotR's FA.Biplot calls 'Regression' the Bartlett formula and vice versa, and squares the uniquenesses in Psi^-1; biplotpy uses the standard formulas (validated against psych::factor.scores).",
|
|
29
|
+
"PolyBiplot": "MultBiplotR's PolyBiplot multiplies the loadings and thresholds by sqrt(1 - h^2) instead of dividing (normal-ogive to item parameters) and uses unpenalized scores that diverge for all-extreme response patterns; biplotpy divides, rescales by 1.702 to the logistic metric and uses a standard normal prior on the scores.",
|
|
30
|
+
"PLSR": "MultBiplotR's PLSR normalizes the scores by their squared norm and reports W C' as regression coefficients without (P'W)^-1, which gives wrong coefficients; biplotpy follows pls::plsr (oscorespls) and reproduces its scores, loadings, coefficients and fitted values.",
|
|
31
|
+
"Unfolding stress at perfect fit": "smacof can return NaN stress-1 when the fit is exact (1 - x slightly negative by rounding); biplotpy truncates at 0.",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def transform_name(t):
|
|
36
|
+
return TRANSFORMS[t - 1] if isinstance(t, (int, np.integer)) else t
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sd(a, axis):
|
|
40
|
+
return np.std(a, axis=axis, ddof=1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rank(v):
|
|
44
|
+
from scipy.stats import rankdata
|
|
45
|
+
return rankdata(v, method="average")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def initial_transform(X, transform="Standardize columns", sup_rows=None, sup_cols=None, grouping=None,
|
|
49
|
+
init_transform="None"):
|
|
50
|
+
"""Transform ``X`` (and optional supplementary rows/columns).
|
|
51
|
+
|
|
52
|
+
Returns a dict with ``X``, ``sup_rows``, ``sup_cols`` and the statistics used
|
|
53
|
+
(``col_means``, ``col_sds``, ``gmean``, ``col_ranges``)."""
|
|
54
|
+
X = np.array(X, float)
|
|
55
|
+
n, p = X.shape
|
|
56
|
+
transform = transform_name(transform)
|
|
57
|
+
sr = None if sup_rows is None else np.array(sup_rows, float)
|
|
58
|
+
sc = None if sup_cols is None else np.array(sup_cols, float)
|
|
59
|
+
if init_transform == "Log":
|
|
60
|
+
if np.any(X <= 0):
|
|
61
|
+
raise ValueError("Initial log transformation needs positive values")
|
|
62
|
+
X = np.log(X)
|
|
63
|
+
sr = None if sr is None else np.log(sr)
|
|
64
|
+
sc = None if sc is None else np.log(sc)
|
|
65
|
+
elif init_transform == "Logit":
|
|
66
|
+
X = X + 0.01 * (X == 0) - 0.01 * (X == 1)
|
|
67
|
+
X = np.log(X / (1 - X))
|
|
68
|
+
out = {"transform": transform, "gmean": X.mean(), "col_means": X.mean(0), "col_sds": _sd(X, 0),
|
|
69
|
+
"row_means": X.mean(1), "row_sds": _sd(X, 1) if p > 1 else np.full(n, np.nan), "col_ranges": X.max(0) - X.min(0)}
|
|
70
|
+
nsup = (0 if sr is None else len(sr)) + (0 if sc is None else sc.shape[1])
|
|
71
|
+
if transform in ("Double centering", "Divide by the column means and center",
|
|
72
|
+
"Normalized residuals from independence") and nsup > 0:
|
|
73
|
+
raise ValueError(f"'{transform}' is not compatible with supplementary rows or columns")
|
|
74
|
+
cm, cs = out["col_means"], out["col_sds"]
|
|
75
|
+
if transform == "Raw Data":
|
|
76
|
+
pass
|
|
77
|
+
elif transform == "Substract the global mean":
|
|
78
|
+
X = X - out["gmean"]
|
|
79
|
+
sr = None if sr is None else sr - out["gmean"]
|
|
80
|
+
sc = None if sc is None else sc - out["gmean"]
|
|
81
|
+
elif transform == "Double centering":
|
|
82
|
+
X = X - X.mean(0) - X.mean(1)[:, None] + X.mean()
|
|
83
|
+
elif transform == "Column centering":
|
|
84
|
+
X = X - cm
|
|
85
|
+
sr = None if sr is None else sr - cm
|
|
86
|
+
sc = None if sc is None else sc - sc.mean(0)
|
|
87
|
+
elif transform == "Standardize columns":
|
|
88
|
+
X = (X - cm) / cs
|
|
89
|
+
sr = None if sr is None else (sr - cm) / cs
|
|
90
|
+
sc = None if sc is None else (sc - sc.mean(0)) / _sd(sc, 0)
|
|
91
|
+
elif transform == "Row centering":
|
|
92
|
+
X = X - X.mean(1)[:, None]
|
|
93
|
+
sr = None if sr is None else sr - sr.mean(1)[:, None]
|
|
94
|
+
sc = None if sc is None else sc - sc.mean(1)[:, None]
|
|
95
|
+
elif transform == "Standardize rows":
|
|
96
|
+
rs = out["row_sds"]
|
|
97
|
+
X = (X - X.mean(1)[:, None]) / rs[:, None]
|
|
98
|
+
sr = None if sr is None else (sr - sr.mean(1)[:, None]) / _sd(sr, 1)[:, None]
|
|
99
|
+
sc = None if sc is None else (sc - out["row_means"][:, None]) / rs[:, None]
|
|
100
|
+
elif transform == "Divide by the column means and center":
|
|
101
|
+
X = X / cm
|
|
102
|
+
X = X - X.mean(0)
|
|
103
|
+
elif transform == "Normalized residuals from independence":
|
|
104
|
+
nt = X.sum()
|
|
105
|
+
dr, dc = X.sum(1), X.sum(0)
|
|
106
|
+
esp = np.outer(dr, dc) / nt
|
|
107
|
+
var = np.outer(1 - dr / nt, 1 - dc / nt)
|
|
108
|
+
X = ((X - esp) / np.sqrt(esp)) / np.sqrt(var)
|
|
109
|
+
elif transform == "Divide by the range":
|
|
110
|
+
X = X / out["col_ranges"]
|
|
111
|
+
elif transform == "Within groups standardization":
|
|
112
|
+
if grouping is None:
|
|
113
|
+
raise ValueError("A grouping factor is needed for the within groups standardization")
|
|
114
|
+
g = np.asarray(grouping)
|
|
115
|
+
levs = list(dict.fromkeys(g))
|
|
116
|
+
Xc = X - cm
|
|
117
|
+
wss = np.zeros(p)
|
|
118
|
+
for lev in levs:
|
|
119
|
+
m = g == lev
|
|
120
|
+
wss += ((X[m] - X[m].mean(0)) ** 2).sum(0)
|
|
121
|
+
sdw = np.sqrt(wss / (n - len(levs)))
|
|
122
|
+
out["col_sds"] = sdw
|
|
123
|
+
X = Xc / sdw
|
|
124
|
+
elif transform == "Ranks":
|
|
125
|
+
X = np.column_stack([_rank(X[:, j]) for j in range(p)])
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError(f"Unknown transformation: {transform}")
|
|
128
|
+
out.update(X=X, sup_rows=sr, sup_cols=sc)
|
|
129
|
+
return out
|
biplotpy/unfolding.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Metric (ratio) unfolding by SMACOF for a rectangular dissimilarity matrix
|
|
2
|
+
(rows: individuals, columns: objects), following ``smacof::unfolding``
|
|
3
|
+
(de Leeuw & Mair, 2009) for ``type = "ratio"`` and no constraints."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _dist(x, y):
|
|
10
|
+
return np.sqrt(np.maximum(((x[:, None, :] - y[None, :, :]) ** 2).sum(2), 0))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _pstress(dhat, d, w, wpp, omega=1.0, lam=0.5):
|
|
14
|
+
nrmd2 = np.sum(w * (dhat - d) ** 2)
|
|
15
|
+
mnc1 = nrmd2 / wpp
|
|
16
|
+
wsum = np.sum(w * dhat)
|
|
17
|
+
nrmw2 = np.sum(w * dhat ** 2)
|
|
18
|
+
nrmm2 = wsum ** 2 / wpp
|
|
19
|
+
nrmv2 = nrmw2 - nrmm2
|
|
20
|
+
mnc2 = (nrmv2 + omega * nrmm2) / nrmv2
|
|
21
|
+
return np.sqrt(mnc1 ** lam * mnc2)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def unfolding(delta, ndim=2, weights=None, init=None, itmax=10000, eps=1e-6, relax=True) -> dict:
|
|
25
|
+
"""Returns row and column configurations, distances, stress-1, stress per
|
|
26
|
+
point (%) and number of iterations."""
|
|
27
|
+
diss = np.asarray(delta, float)
|
|
28
|
+
n, m = diss.shape
|
|
29
|
+
w = np.ones((n, m)) if weights is None else np.asarray(weights, float)
|
|
30
|
+
w = np.where(np.isnan(diss), 0.0, w)
|
|
31
|
+
wpp = w.sum()
|
|
32
|
+
dhat = np.nan_to_num(diss)
|
|
33
|
+
e = dhat ** 2
|
|
34
|
+
e = -0.5 * (e - (e.sum(1)[:, None] / m + e.sum(0)[None, :] / n) + e.sum() / (n * m))
|
|
35
|
+
if init is None:
|
|
36
|
+
U, s, Vt = np.linalg.svd(e)
|
|
37
|
+
x = U[:, :ndim]
|
|
38
|
+
y = e.T @ x
|
|
39
|
+
else:
|
|
40
|
+
x, y = np.asarray(init[0], float)[:, :ndim], np.asarray(init[1], float)[:, :ndim]
|
|
41
|
+
dil = np.trace(x.T @ (e @ y)) / np.trace((x.T @ x) @ (y.T @ y))
|
|
42
|
+
x = np.sqrt(dil) / np.sqrt((x ** 2).sum()) * x
|
|
43
|
+
y = np.sqrt(dil) / np.sqrt((y ** 2).sum()) * y
|
|
44
|
+
d = _dist(x, y)
|
|
45
|
+
lold = _pstress(dhat, d, w, wpp)
|
|
46
|
+
wr, wc = w.sum(1), w.sum(0)
|
|
47
|
+
v = np.linalg.inv(np.diag(wc) + 1 / m - w.T @ (w / wr[:, None])) - 1 / m
|
|
48
|
+
itel = 1
|
|
49
|
+
while True:
|
|
50
|
+
d0 = d < eps
|
|
51
|
+
b = w * dhat * (~d0) / (d + d0)
|
|
52
|
+
br, bc = b.sum(1), b.sum(0)
|
|
53
|
+
xraw = br[:, None] * x - b @ y
|
|
54
|
+
yraw = bc[:, None] * y - b.T @ x
|
|
55
|
+
xold, yold = x, y
|
|
56
|
+
y = v @ (yraw + w.T @ (xraw / wr[:, None]))
|
|
57
|
+
x = (xraw + w @ y) / wr[:, None]
|
|
58
|
+
if relax and itel > 100:
|
|
59
|
+
x = 2 * x - xold
|
|
60
|
+
y = 2 * y - yold
|
|
61
|
+
d = _dist(x, y)
|
|
62
|
+
lnew = _pstress(dhat, d, w, wpp)
|
|
63
|
+
if itel == itmax or 2 * (lold - lnew) <= eps * (lold + lnew + 1e-15):
|
|
64
|
+
break
|
|
65
|
+
lold = lnew
|
|
66
|
+
itel += 1
|
|
67
|
+
resmat = (d - dhat) ** 2
|
|
68
|
+
spp_col = (w * resmat).sum(0) / w.sum(0); spp_col = spp_col / spp_col.sum() * 100
|
|
69
|
+
spp_row = (w * resmat).sum(1) / w.sum(1); spp_row = spp_row / spp_row.sum() * 100
|
|
70
|
+
scale = np.sqrt(w.sum() / np.sum(w * d ** 2))
|
|
71
|
+
d, x, y, dhat = scale * d, scale * x, scale * y, scale * dhat
|
|
72
|
+
stress = np.sqrt(max(0.0, 1 - np.sum(w * dhat * d) ** 2 / (np.sum(w * dhat ** 2) * np.sum(w * d ** 2))))
|
|
73
|
+
return {"conf_row": x, "conf_col": y, "confdist": d, "dhat": dhat, "stress": stress, "spp_row": spp_row,
|
|
74
|
+
"spp_col": spp_col, "niter": itel}
|
biplotpy/utils.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Numerical helpers shared by the biplot methods."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
DBL_EPS = np.finfo(float).eps
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def matrix_sqrt(S, tol=math.sqrt(DBL_EPS), inverse=False):
|
|
12
|
+
"""Square root (or inverse square root) of a symmetric PSD matrix via SVD,
|
|
13
|
+
dropping singular values below ``tol`` (as ``matrixsqrt``/``matrixsqrtinv``
|
|
14
|
+
in MultBiplotR)."""
|
|
15
|
+
u, d, vt = np.linalg.svd(np.asarray(S, float))
|
|
16
|
+
nz = d > tol
|
|
17
|
+
f = 1 / np.sqrt(d[nz]) if inverse else np.sqrt(d[nz])
|
|
18
|
+
return u[:, nz] @ np.diag(f) @ vt[nz]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def r_pretty(x, n=5, min_n=None, shrink_sml=0.75, high_u_bias=1.5, u5_bias=None):
|
|
22
|
+
"""Port of R's ``pretty()`` (``R_pretty`` in src/appl/pretty.c, eps.correct = 0)."""
|
|
23
|
+
x = np.asarray(x, float)
|
|
24
|
+
x = x[np.isfinite(x)]
|
|
25
|
+
lo, up = float(x.min()), float(x.max())
|
|
26
|
+
if min_n is None:
|
|
27
|
+
min_n = n // 3
|
|
28
|
+
h = high_u_bias
|
|
29
|
+
h5 = 0.5 + 1.5 * h if u5_bias is None else u5_bias
|
|
30
|
+
ndiv = n
|
|
31
|
+
dx = up - lo
|
|
32
|
+
if dx == 0 and up == 0:
|
|
33
|
+
cell = 1.0
|
|
34
|
+
i_small = True
|
|
35
|
+
else:
|
|
36
|
+
cell = max(abs(lo), abs(up))
|
|
37
|
+
U = 1 + (1 / (1 + h) if h5 >= 1.5 * h + 0.5 else 1.5 / (1 + h5))
|
|
38
|
+
U *= max(1, ndiv) * DBL_EPS
|
|
39
|
+
i_small = dx < cell * U * 3
|
|
40
|
+
if i_small:
|
|
41
|
+
if cell > 10:
|
|
42
|
+
cell = 9 + cell / 10
|
|
43
|
+
cell *= shrink_sml
|
|
44
|
+
if min_n > 1:
|
|
45
|
+
cell /= min_n
|
|
46
|
+
else:
|
|
47
|
+
cell = dx
|
|
48
|
+
if ndiv > 1:
|
|
49
|
+
cell /= ndiv
|
|
50
|
+
if cell < 20 * np.finfo(float).tiny:
|
|
51
|
+
cell = 20 * np.finfo(float).tiny
|
|
52
|
+
base = 10.0 ** math.floor(math.log10(cell))
|
|
53
|
+
unit = base
|
|
54
|
+
ns = 2 * base
|
|
55
|
+
if ns - cell < h * (cell - unit):
|
|
56
|
+
unit = ns
|
|
57
|
+
ns = 5 * base
|
|
58
|
+
if ns - cell < h5 * (cell - unit):
|
|
59
|
+
unit = ns
|
|
60
|
+
ns = 10 * base
|
|
61
|
+
if ns - cell < h * (cell - unit):
|
|
62
|
+
unit = ns
|
|
63
|
+
ns = math.floor(lo / unit + 1e-7)
|
|
64
|
+
nu = math.ceil(up / unit - 1e-7)
|
|
65
|
+
rounding_eps = 1e-10
|
|
66
|
+
while ns * unit > lo + rounding_eps * unit:
|
|
67
|
+
ns -= 1
|
|
68
|
+
while nu * unit < up - rounding_eps * unit:
|
|
69
|
+
nu += 1
|
|
70
|
+
k = int(0.5 + nu - ns)
|
|
71
|
+
if k < min_n:
|
|
72
|
+
k = min_n - k
|
|
73
|
+
if ns >= 0:
|
|
74
|
+
nu += k // 2
|
|
75
|
+
ns -= k // 2 + k % 2
|
|
76
|
+
else:
|
|
77
|
+
ns -= k // 2
|
|
78
|
+
nu += k // 2 + k % 2
|
|
79
|
+
return np.arange(ns, nu + 1) * unit
|