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/logfreq.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Weighted alternating least squares biplot (criss-cross regression) and the
|
|
2
|
+
log-frequency biplot of MultBiplotR (``CrissCross``, ``LogFrequencyBiplot``)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from .classical import ContinuousBiplot
|
|
9
|
+
from .rrng import RRNG
|
|
10
|
+
from .transforms import initial_transform, transform_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _wcor(d1, d2, w):
|
|
14
|
+
s = np.sum(w)
|
|
15
|
+
m1 = np.sum(d1 * w) / s
|
|
16
|
+
m2 = np.sum(d2 * w) / s
|
|
17
|
+
return (np.sum(d1 * d2 * w) / s - m1 * m2) / np.sqrt((np.sum(d1 ** 2 * w) / s - m1 ** 2) *
|
|
18
|
+
(np.sum(d2 ** 2 * w) / s - m2 ** 2))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def criss_cross(X, W=None, dim=2, a0=None, b0=None, maxiter=100, tol=1e-4, addsvd=True, lam=0.0, seed=None):
|
|
22
|
+
"""Weighted low-rank approximation ``X ~ A B'`` by alternating weighted
|
|
23
|
+
least squares (Gabriel & Zamir 1979; ``MultBiplotR::CrissCross``).
|
|
24
|
+
|
|
25
|
+
``W``: non-negative weights (zero = missing). Starting values ``a0``/``b0``
|
|
26
|
+
default to normal random numbers drawn with R's generator after
|
|
27
|
+
``set.seed(seed)`` (``seed=None`` uses numpy). Iterates until the change of
|
|
28
|
+
the relative weighted residual sum of squares is below ``tol``; with
|
|
29
|
+
``addsvd`` the fitted matrix is decomposed by SVD to give the biplot
|
|
30
|
+
markers. Returns row/column markers, weighted goodness of fit of each row
|
|
31
|
+
and column by dimension (%) and the global fit."""
|
|
32
|
+
X = np.array(X, float)
|
|
33
|
+
n, p = X.shape
|
|
34
|
+
W = np.ones((n, p)) if W is None else np.asarray(W, float)
|
|
35
|
+
X[W == 0] = 0
|
|
36
|
+
if a0 is None or b0 is None:
|
|
37
|
+
if seed is None:
|
|
38
|
+
g = np.random.default_rng()
|
|
39
|
+
ra = lambda k: g.standard_normal(k)
|
|
40
|
+
else:
|
|
41
|
+
r = RRNG(seed)
|
|
42
|
+
ra = r.rnorm
|
|
43
|
+
if a0 is None:
|
|
44
|
+
a0 = ra(n * dim).reshape(dim, n).T # matrix(rnorm(n*dim), n, dim): filled by columns
|
|
45
|
+
if b0 is None:
|
|
46
|
+
b0 = ra(p * dim).reshape(dim, p).T
|
|
47
|
+
a0 = np.asarray(a0, float); b0 = np.asarray(b0, float)
|
|
48
|
+
gfit0 = np.sum(((X - a0 @ b0.T) * W) ** 2) / np.sum((X * W) ** 2)
|
|
49
|
+
a, b = a0.copy(), b0.copy()
|
|
50
|
+
err, it = 0.1, 0
|
|
51
|
+
while err > tol and it < maxiter:
|
|
52
|
+
it += 1
|
|
53
|
+
for i in range(n):
|
|
54
|
+
a[i] = np.linalg.pinv(b0.T @ np.diag(W[i] + lam) @ b0) @ b0.T @ np.diag(W[i]) @ X[i]
|
|
55
|
+
for j in range(p):
|
|
56
|
+
b[j] = np.linalg.pinv(a.T @ np.diag(W[:, j] + lam) @ a) @ a.T @ np.diag(W[:, j]) @ X[:, j]
|
|
57
|
+
esp = a @ b.T
|
|
58
|
+
gfit = np.sum(((X - esp) * W) ** 2) / np.sum((X * W) ** 2)
|
|
59
|
+
err = abs(gfit0 - gfit)
|
|
60
|
+
a0, b0, gfit0 = a.copy(), b.copy(), gfit
|
|
61
|
+
esp = a @ b.T
|
|
62
|
+
if addsvd:
|
|
63
|
+
U, d, Vt = np.linalg.svd(esp, full_matrices=False)
|
|
64
|
+
a = U[:, :dim] * np.sqrt(d[:dim])
|
|
65
|
+
b = Vt[:dim].T * np.sqrt(d[:dim])
|
|
66
|
+
rowfit = np.zeros((n, dim)); colfit = np.zeros((p, dim))
|
|
67
|
+
for k in range(dim):
|
|
68
|
+
e = np.outer(a[:, k], b[:, k])
|
|
69
|
+
for i in range(n):
|
|
70
|
+
rowfit[i, k] = _wcor(X[i], e[i], W[i]) ** 2
|
|
71
|
+
for j in range(p):
|
|
72
|
+
colfit[j, k] = _wcor(X[:, j], e[:, j], W[:, j]) ** 2
|
|
73
|
+
return {"A": a, "B": b, "fitted": esp, "row_fit": rowfit * 100, "col_fit": colfit * 100,
|
|
74
|
+
"goodness_of_fit": 1 - gfit0, "iterations": it, "weights": W}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def log_frequency_biplot(N, dim=2, scaling=2, logoffset=1.0, freqoffset=None, seed=None, maxiter=100,
|
|
78
|
+
tol=1e-4, row_names=None, col_names=None) -> ContinuousBiplot:
|
|
79
|
+
"""Log-frequency biplot (``MultBiplotR::LogFrequencyBiplot``): the table of
|
|
80
|
+
frequencies ``N`` is transformed to ``x = log(N + logoffset)``, scaled
|
|
81
|
+
(``scaling``: 1 raw, 2 subtract the global mean, 4 column centering, 5
|
|
82
|
+
standardize columns, 6 row centering, 7 standardize rows) and approximated
|
|
83
|
+
by a weighted ALS biplot (``criss_cross``) with weights ``x + freqoffset``,
|
|
84
|
+
so that larger counts weigh more. ``seed`` fixes R's random start."""
|
|
85
|
+
if isinstance(N, pd.DataFrame):
|
|
86
|
+
row_names = row_names or [str(i) for i in N.index]; col_names = col_names or [str(c) for c in N.columns]
|
|
87
|
+
N = np.asarray(N, float)
|
|
88
|
+
freqoffset = logoffset if freqoffset is None else freqoffset
|
|
89
|
+
x = np.log(N + logoffset)
|
|
90
|
+
Wt = x + freqoffset
|
|
91
|
+
t = transform_name(scaling)
|
|
92
|
+
if t in ("Double centering", "Divide by the column means and center", "Normalized residuals from independence"):
|
|
93
|
+
raise ValueError(f"{t} is not compatible with log-frequencies")
|
|
94
|
+
Xs = initial_transform(x, t)["X"]
|
|
95
|
+
cc = criss_cross(Xs, Wt, dim=dim, seed=seed, maxiter=maxiter, tol=tol)
|
|
96
|
+
n, p = N.shape
|
|
97
|
+
row_names = row_names or [f"I{i + 1}" for i in range(n)]
|
|
98
|
+
col_names = col_names or [f"V{j + 1}" for j in range(p)]
|
|
99
|
+
ev = np.linalg.svd(cc["fitted"], compute_uv=False) ** 2
|
|
100
|
+
bip = ContinuousBiplot(
|
|
101
|
+
title="Log-frequency biplot (weighted ALS)", alpha=0.5, dim=dim, transformation=t, scale_factor=1.0,
|
|
102
|
+
row_coordinates=cc["A"], col_coordinates=cc["B"], row_names=row_names, col_names=col_names,
|
|
103
|
+
row_contributions=cc["row_fit"], col_contributions=cc["col_fit"], eigenvalues=ev,
|
|
104
|
+
inertia=ev / ev.sum() * 100, cum_inertia=np.cumsum(ev / ev.sum() * 100),
|
|
105
|
+
goodness_of_fit=cc["goodness_of_fit"], iterations=cc["iterations"], weights=Wt, data=Xs, log_data=x,
|
|
106
|
+
means=x.mean(0), deviations=x.std(0, ddof=1), gmean=x.mean())
|
|
107
|
+
return bip
|
biplotpy/logistic.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Logistic biplots for binary, ordinal and nominal data.
|
|
2
|
+
|
|
3
|
+
Binary (Vicente-Villardón, Galindo & Blázquez-Zaballos, 2006; Demey et al., 2008):
|
|
4
|
+
logit P(x_ij = 1) = b_j0 + sum_k a_ik b_jk
|
|
5
|
+
Ordinal (Vicente-Villardón & Hernández-Sánchez, 2014), cumulative logits:
|
|
6
|
+
logit P(x_ij <= c) = tau_jc - sum_k a_ik b_jk
|
|
7
|
+
Nominal (Hernández-Sánchez & Vicente-Villardón, 2013), baseline-category logits:
|
|
8
|
+
log P(x_ij = c) / P(x_ij = 1) = b_jc0 + sum_k a_ik b_jck
|
|
9
|
+
|
|
10
|
+
All three are fitted by penalized joint maximum likelihood (ridge penalty
|
|
11
|
+
lambda/2 on the row and slope parameters, which removes the indeterminacy of
|
|
12
|
+
scale between A and B) with L-BFGS and analytic gradients. The solution is
|
|
13
|
+
unique up to an orthogonal rotation of the latent space.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
from scipy import optimize, stats as sps
|
|
20
|
+
from scipy.special import expit, log_expit, logsumexp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _init_rows(X, dim, init):
|
|
24
|
+
if isinstance(init, np.ndarray):
|
|
25
|
+
return np.array(init, float)
|
|
26
|
+
Xc = np.nan_to_num(X - np.nanmean(X, 0))
|
|
27
|
+
sd = Xc.std(0)
|
|
28
|
+
Xc = Xc / np.where(sd > 0, sd, 1)
|
|
29
|
+
U, d, _ = np.linalg.svd(Xc, full_matrices=False)
|
|
30
|
+
return U[:, :dim] * d[:dim] / np.sqrt(X.shape[0])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LogisticBiplot:
|
|
34
|
+
def __init__(self, **kw):
|
|
35
|
+
self.__dict__.update(kw)
|
|
36
|
+
|
|
37
|
+
def plot(self, *args, **kwargs):
|
|
38
|
+
from .plot import plot_logistic
|
|
39
|
+
return plot_logistic(self, *args, **kwargs)
|
|
40
|
+
|
|
41
|
+
def __repr__(self):
|
|
42
|
+
return (f"{self.title}: {self.n} rows, {self.p} variables, dim = {self.dim}, penalization = {self.penalization}\n"
|
|
43
|
+
f"Objective = {self.objective:.4f}; global % correct = {self.global_percent:.2f}\n"
|
|
44
|
+
f"{self.columns.round(3).to_string()}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------- binary
|
|
48
|
+
def _bin_obj(par, X, M, n, p, r, lam):
|
|
49
|
+
A = par[:n * r].reshape(n, r)
|
|
50
|
+
B = par[n * r:].reshape(p, r + 1)
|
|
51
|
+
eta = B[:, 0] + A @ B[:, 1:].T
|
|
52
|
+
H = expit(eta)
|
|
53
|
+
ll = np.where(M, X * log_expit(eta) + (1 - X) * log_expit(-eta), 0.0)
|
|
54
|
+
f = -ll.sum() + lam / 2 * ((A ** 2).sum() + (B[:, 1:] ** 2).sum())
|
|
55
|
+
E = np.where(M, H - np.nan_to_num(X), 0.0)
|
|
56
|
+
gA = E @ B[:, 1:] + lam * A
|
|
57
|
+
gB = np.column_stack([E.sum(0), E.T @ A + lam * B[:, 1:]])
|
|
58
|
+
return f, np.concatenate([gA.ravel(), gB.ravel()])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def binary_logistic_biplot(X, dim=2, penalization=0.01, init="svd", maxiter=5000, tol=1e-10,
|
|
62
|
+
row_names=None, col_names=None) -> LogisticBiplot:
|
|
63
|
+
"""Binary logistic biplot by penalized joint maximum likelihood.
|
|
64
|
+
|
|
65
|
+
Minimizes ``-loglik + penalization/2 (|A|^2 + |B_slopes|^2)``. Missing values
|
|
66
|
+
(NaN) are ignored. Returns row coordinates ``A``, column parameters ``B``
|
|
67
|
+
(intercept and slopes), fitted probabilities, deviance tests against the
|
|
68
|
+
intercept-only model, percentages of correct classification and a pseudo R2.
|
|
69
|
+
"""
|
|
70
|
+
if isinstance(X, pd.DataFrame):
|
|
71
|
+
row_names = row_names or [str(i) for i in X.index]
|
|
72
|
+
col_names = col_names or [str(c) for c in X.columns]
|
|
73
|
+
X = X.to_numpy(float)
|
|
74
|
+
X = np.asarray(X, float)
|
|
75
|
+
n, p = X.shape
|
|
76
|
+
M = np.isfinite(X)
|
|
77
|
+
A0 = _init_rows(X, dim, init)
|
|
78
|
+
pbar = np.clip(np.nanmean(X, 0), 0.01, 0.99)
|
|
79
|
+
B0 = np.column_stack([np.log(pbar / (1 - pbar)), np.zeros((p, dim)) + 0.01])
|
|
80
|
+
par0 = np.concatenate([A0.ravel(), B0.ravel()])
|
|
81
|
+
res = optimize.minimize(_bin_obj, par0, args=(X, M, n, p, dim, penalization), jac=True, method="L-BFGS-B",
|
|
82
|
+
options={"maxiter": maxiter, "ftol": tol, "gtol": 1e-9, "maxcor": 30})
|
|
83
|
+
A = res.x[:n * dim].reshape(n, dim)
|
|
84
|
+
B = res.x[n * dim:].reshape(p, dim + 1)
|
|
85
|
+
# orthogonalize the latent space (rotation that leaves the fit unchanged)
|
|
86
|
+
U, d, Vt = np.linalg.svd(A, full_matrices=False)
|
|
87
|
+
A, B = A @ Vt.T, np.column_stack([B[:, 0], B[:, 1:] @ Vt.T])
|
|
88
|
+
eta = B[:, 0] + A @ B[:, 1:].T
|
|
89
|
+
P = expit(eta)
|
|
90
|
+
Xz = np.nan_to_num(X)
|
|
91
|
+
pred = (P > 0.5).astype(float)
|
|
92
|
+
acc = np.where(M, pred == Xz, np.nan)
|
|
93
|
+
p0 = np.clip(np.nanmean(X, 0), 1e-12, 1 - 1e-12)
|
|
94
|
+
Pc = np.clip(P, 1e-12, 1 - 1e-12)
|
|
95
|
+
d_null = -2 * np.nansum(np.where(M, Xz * np.log(p0) + (1 - Xz) * np.log(1 - p0), 0), 0)
|
|
96
|
+
d_fit = -2 * np.nansum(np.where(M, Xz * np.log(Pc) + (1 - Xz) * np.log(1 - Pc), 0), 0)
|
|
97
|
+
dev = d_null - d_fit
|
|
98
|
+
pvals = sps.chi2.sf(dev, dim)
|
|
99
|
+
r2 = 1 - np.nansum(np.where(M, (Xz - P) ** 2, 0)) / np.nansum(np.where(M, Xz ** 2, 0))
|
|
100
|
+
col_names = col_names or [f"V{j + 1}" for j in range(p)]
|
|
101
|
+
row_names = row_names or [f"I{i + 1}" for i in range(n)]
|
|
102
|
+
cols = pd.DataFrame(np.column_stack([B, dev, pvals, np.nanmean(acc, 0) * 100,
|
|
103
|
+
1 - d_fit / np.where(d_null > 0, d_null, np.nan)]),
|
|
104
|
+
index=col_names, columns=["b0"] + [f"b{k + 1}" for k in range(dim)] +
|
|
105
|
+
["Deviance", "p-value", "% correct", "McFadden R2"])
|
|
106
|
+
return LogisticBiplot(title="Binary Logistic Biplot", n=n, p=p, dim=dim, penalization=penalization,
|
|
107
|
+
A=A, B=B, fitted=P, objective=res.fun, converged=res.success, columns=cols,
|
|
108
|
+
global_percent=np.nanmean(acc) * 100, row_percent=np.nanmean(acc, 1) * 100,
|
|
109
|
+
R2=r2, row_names=row_names, col_names=col_names)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------- ordinal
|
|
113
|
+
def _ord_unpack(par, n, p, r, ncat):
|
|
114
|
+
A = par[:n * r].reshape(n, r)
|
|
115
|
+
o = n * r
|
|
116
|
+
B = par[o:o + p * r].reshape(p, r)
|
|
117
|
+
o += p * r
|
|
118
|
+
taus = []
|
|
119
|
+
for j in range(p):
|
|
120
|
+
k = ncat[j] - 1
|
|
121
|
+
th = par[o:o + k]
|
|
122
|
+
o += k
|
|
123
|
+
taus.append(np.concatenate([[th[0]], th[0] + np.cumsum(np.exp(th[1:]))]))
|
|
124
|
+
return A, B, taus
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _ord_obj(par, X, n, p, r, ncat, lam, fixed_A=None):
|
|
128
|
+
if fixed_A is not None:
|
|
129
|
+
par_full = np.concatenate([fixed_A.ravel(), par])
|
|
130
|
+
else:
|
|
131
|
+
par_full = par
|
|
132
|
+
A, B, taus = _ord_unpack(par_full, n, p, r, ncat)
|
|
133
|
+
f = lam / 2 * ((A ** 2).sum() + (B ** 2).sum()) if fixed_A is None else lam / 2 * (B ** 2).sum()
|
|
134
|
+
gA = lam * A if fixed_A is None else np.zeros_like(A)
|
|
135
|
+
gB = lam * B
|
|
136
|
+
gth = []
|
|
137
|
+
for j in range(p):
|
|
138
|
+
x = X[:, j]
|
|
139
|
+
m = np.isfinite(x)
|
|
140
|
+
c = x[m].astype(int) # categories 1..C
|
|
141
|
+
eta = A[m] @ B[j]
|
|
142
|
+
tau = np.concatenate([[-np.inf], taus[j], [np.inf]])
|
|
143
|
+
up, lo = tau[c] - eta, tau[c - 1] - eta
|
|
144
|
+
Fu, Fl = expit(up), expit(lo)
|
|
145
|
+
prob = np.clip(Fu - Fl, 1e-300, None)
|
|
146
|
+
f -= np.log(prob).sum()
|
|
147
|
+
fu, fl = Fu * (1 - Fu), Fl * (1 - Fl) # densities
|
|
148
|
+
deta = (fu - fl) / prob # d(-log p)/d eta
|
|
149
|
+
gA[m] += np.outer(deta, B[j])
|
|
150
|
+
gB[j] += A[m].T @ deta
|
|
151
|
+
# gradient wrt thresholds tau_k (k = 1..C-1)
|
|
152
|
+
C = ncat[j]
|
|
153
|
+
gt = np.zeros(C - 1)
|
|
154
|
+
for k in range(1, C):
|
|
155
|
+
gt[k - 1] = -np.sum(np.where(c == k, fu, 0) / prob) + np.sum(np.where(c == k + 1, fl, 0) / prob)
|
|
156
|
+
# chain rule to the free parameters (th0, log increments)
|
|
157
|
+
J = np.zeros((C - 1, C - 1))
|
|
158
|
+
J[:, 0] = 1
|
|
159
|
+
th_inc = np.diff(taus[j])
|
|
160
|
+
for k in range(1, C - 1):
|
|
161
|
+
J[k:, k] = th_inc[k - 1]
|
|
162
|
+
gth.append(gt @ J)
|
|
163
|
+
g = np.concatenate([gA.ravel(), gB.ravel()] + gth)
|
|
164
|
+
if fixed_A is not None:
|
|
165
|
+
g = g[n * r:]
|
|
166
|
+
return f, g
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _ord_start(X, p, ncat):
|
|
170
|
+
th = []
|
|
171
|
+
for j in range(p):
|
|
172
|
+
x = X[:, j][np.isfinite(X[:, j])]
|
|
173
|
+
cum = np.array([np.mean(x <= k) for k in range(1, ncat[j])])
|
|
174
|
+
cum = np.clip(cum, 0.02, 0.98)
|
|
175
|
+
t = np.log(cum / (1 - cum))
|
|
176
|
+
t = np.maximum.accumulate(t + np.arange(len(t)) * 1e-3)
|
|
177
|
+
th.append(np.concatenate([[t[0]], np.log(np.maximum(np.diff(t), 1e-3))]))
|
|
178
|
+
return th
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def ordinal_logistic_fit(y, A, penalization=0.0):
|
|
182
|
+
"""Cumulative-logit (proportional odds) regression of one ordinal variable on
|
|
183
|
+
fixed row coordinates ``A``: logit P(y <= c) = tau_c - A b. Equivalent to
|
|
184
|
+
``MASS::polr`` when ``penalization = 0``. Returns thresholds and slopes."""
|
|
185
|
+
y = np.asarray(y, float)
|
|
186
|
+
n, r = A.shape
|
|
187
|
+
C = int(np.nanmax(y))
|
|
188
|
+
X = y[:, None]
|
|
189
|
+
th0 = _ord_start(X, 1, [C])[0]
|
|
190
|
+
par0 = np.concatenate([np.zeros(r), th0])
|
|
191
|
+
res = optimize.minimize(_ord_obj, par0, args=(X, n, 1, r, [C], penalization, A), jac=True, method="BFGS",
|
|
192
|
+
options={"gtol": 1e-10, "maxiter": 10000})
|
|
193
|
+
_, B, taus = _ord_unpack(np.concatenate([A.ravel(), res.x]), n, 1, r, [C])
|
|
194
|
+
return {"thresholds": taus[0], "slopes": B[0], "deviance": 2 * res.fun, "converged": res.success}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def ordinal_logistic_biplot(X, dim=2, penalization=0.01, init="svd", maxiter=10000, row_names=None,
|
|
198
|
+
col_names=None) -> LogisticBiplot:
|
|
199
|
+
"""Ordinal logistic biplot (cumulative logits) by penalized joint ML.
|
|
200
|
+
Categories must be coded 1..C_j for each variable (NaN = missing)."""
|
|
201
|
+
if isinstance(X, pd.DataFrame):
|
|
202
|
+
row_names = row_names or [str(i) for i in X.index]
|
|
203
|
+
col_names = col_names or [str(c) for c in X.columns]
|
|
204
|
+
X = X.to_numpy(float)
|
|
205
|
+
X = np.asarray(X, float)
|
|
206
|
+
n, p = X.shape
|
|
207
|
+
ncat = [int(np.nanmax(X[:, j])) for j in range(p)]
|
|
208
|
+
A0 = _init_rows(X, dim, init)
|
|
209
|
+
par0 = np.concatenate([A0.ravel(), np.full(p * dim, 0.1)] + _ord_start(X, p, ncat))
|
|
210
|
+
res = optimize.minimize(_ord_obj, par0, args=(X, n, p, dim, ncat, penalization), jac=True, method="L-BFGS-B",
|
|
211
|
+
options={"maxiter": maxiter, "ftol": 1e-12, "gtol": 1e-8, "maxcor": 30})
|
|
212
|
+
A, B, taus = _ord_unpack(res.x, n, p, dim, ncat)
|
|
213
|
+
U, d, Vt = np.linalg.svd(A, full_matrices=False)
|
|
214
|
+
A, B = A @ Vt.T, B @ Vt.T
|
|
215
|
+
# fitted category probabilities and classification
|
|
216
|
+
acc = np.full((n, p), np.nan)
|
|
217
|
+
probs = []
|
|
218
|
+
dev_null, dev_fit = np.zeros(p), np.zeros(p)
|
|
219
|
+
for j in range(p):
|
|
220
|
+
tau = np.concatenate([[-np.inf], taus[j], [np.inf]])
|
|
221
|
+
eta = A @ B[j]
|
|
222
|
+
Fc = expit(tau[None, :] - eta[:, None])
|
|
223
|
+
Pj = np.diff(Fc, axis=1)
|
|
224
|
+
probs.append(Pj)
|
|
225
|
+
m = np.isfinite(X[:, j])
|
|
226
|
+
c = X[m, j].astype(int)
|
|
227
|
+
acc[m, j] = (Pj[m].argmax(1) + 1) == c
|
|
228
|
+
freq = np.bincount(c, minlength=ncat[j] + 1)[1:] / m.sum()
|
|
229
|
+
dev_null[j] = -2 * np.sum(np.log(np.clip(freq[c - 1], 1e-300, None)))
|
|
230
|
+
dev_fit[j] = -2 * np.sum(np.log(np.clip(Pj[m, c - 1], 1e-300, None)))
|
|
231
|
+
dev = dev_null - dev_fit
|
|
232
|
+
col_names = col_names or [f"V{j + 1}" for j in range(p)]
|
|
233
|
+
row_names = row_names or [f"I{i + 1}" for i in range(n)]
|
|
234
|
+
cols = pd.DataFrame(np.column_stack([B, dev, sps.chi2.sf(dev, dim), np.nanmean(acc, 0) * 100,
|
|
235
|
+
1 - dev_fit / dev_null]), index=col_names,
|
|
236
|
+
columns=[f"b{k + 1}" for k in range(dim)] + ["Deviance", "p-value", "% correct", "pseudo R2"])
|
|
237
|
+
return LogisticBiplot(title="Ordinal Logistic Biplot", n=n, p=p, dim=dim, penalization=penalization, A=A, B=B,
|
|
238
|
+
thresholds=taus, fitted=probs, objective=res.fun, converged=res.success, columns=cols,
|
|
239
|
+
global_percent=np.nanmean(acc) * 100, row_names=row_names, col_names=col_names)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --------------------------------------------------------------------------- nominal
|
|
243
|
+
def _nom_obj(par, Xi, n, p, r, ncat, lam, fixed_A=None):
|
|
244
|
+
o = 0
|
|
245
|
+
if fixed_A is None:
|
|
246
|
+
A = par[:n * r].reshape(n, r); o = n * r
|
|
247
|
+
else:
|
|
248
|
+
A = fixed_A
|
|
249
|
+
f = lam / 2 * (A ** 2).sum() if fixed_A is None else 0.0
|
|
250
|
+
gA = lam * A if fixed_A is None else None
|
|
251
|
+
gs = []
|
|
252
|
+
for j in range(p):
|
|
253
|
+
C = ncat[j]
|
|
254
|
+
Bj = par[o:o + (C - 1) * (r + 1)].reshape(C - 1, r + 1); o += (C - 1) * (r + 1)
|
|
255
|
+
m = Xi[:, j] >= 0
|
|
256
|
+
eta = np.column_stack([np.zeros(m.sum()), Bj[:, 0] + A[m] @ Bj[:, 1:].T])
|
|
257
|
+
lse = logsumexp(eta, axis=1)
|
|
258
|
+
c = Xi[m, j]
|
|
259
|
+
f -= np.sum(eta[np.arange(len(c)), c] - lse) - lam / 2 * (Bj[:, 1:] ** 2).sum()
|
|
260
|
+
P = np.exp(eta - lse[:, None])
|
|
261
|
+
Y = np.zeros_like(P); Y[np.arange(len(c)), c] = 1
|
|
262
|
+
E = (P - Y)[:, 1:]
|
|
263
|
+
gB = np.column_stack([E.sum(0), E.T @ A[m] + lam * Bj[:, 1:]])
|
|
264
|
+
gs.append(gB.ravel())
|
|
265
|
+
if gA is not None:
|
|
266
|
+
gA[m] += E @ Bj[:, 1:]
|
|
267
|
+
g = np.concatenate(([gA.ravel()] if gA is not None else []) + gs)
|
|
268
|
+
return f, g
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _codes(X):
|
|
272
|
+
cats, Xi = [], np.full(X.shape, -1, dtype=int)
|
|
273
|
+
for j in range(X.shape[1]):
|
|
274
|
+
col = pd.Series(X[:, j])
|
|
275
|
+
levels = sorted(col.dropna().unique().tolist(), key=lambda v: (str(type(v)), v))
|
|
276
|
+
cats.append(levels)
|
|
277
|
+
for k, lev in enumerate(levels):
|
|
278
|
+
Xi[(col == lev).to_numpy(), j] = k
|
|
279
|
+
return Xi, cats
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def nominal_logistic_fit(y, A, penalization=0.0):
|
|
283
|
+
"""Multinomial (baseline-category) logistic regression of one nominal variable
|
|
284
|
+
on fixed row coordinates; equivalent to ``nnet::multinom`` without decay."""
|
|
285
|
+
Xi, cats = _codes(np.asarray(y, dtype=object)[:, None])
|
|
286
|
+
n, r = A.shape
|
|
287
|
+
C = len(cats[0])
|
|
288
|
+
par0 = np.zeros((C - 1) * (r + 1))
|
|
289
|
+
res = optimize.minimize(_nom_obj, par0, args=(Xi, n, 1, r, [C], penalization, A), jac=True, method="BFGS",
|
|
290
|
+
options={"gtol": 1e-10, "maxiter": 20000})
|
|
291
|
+
return {"categories": cats[0], "coef": res.x.reshape(C - 1, r + 1), "deviance": 2 * res.fun,
|
|
292
|
+
"converged": res.success}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def nominal_logistic_biplot(X, dim=2, penalization=0.01, init="indicator", maxiter=10000, row_names=None,
|
|
296
|
+
col_names=None) -> LogisticBiplot:
|
|
297
|
+
"""Nominal logistic biplot by penalized joint ML (first category = baseline).
|
|
298
|
+
Each variable is a column of category labels."""
|
|
299
|
+
if isinstance(X, pd.DataFrame):
|
|
300
|
+
row_names = row_names or [str(i) for i in X.index]
|
|
301
|
+
col_names = col_names or [str(c) for c in X.columns]
|
|
302
|
+
X = X.to_numpy(object)
|
|
303
|
+
X = np.asarray(X, dtype=object)
|
|
304
|
+
n, p = X.shape
|
|
305
|
+
Xi, cats = _codes(X)
|
|
306
|
+
ncat = [len(c) for c in cats]
|
|
307
|
+
if isinstance(init, np.ndarray):
|
|
308
|
+
A0 = init
|
|
309
|
+
else:
|
|
310
|
+
Z = np.column_stack([(Xi[:, j] == k).astype(float) for j in range(p) for k in range(ncat[j])])
|
|
311
|
+
A0 = _init_rows(Z, dim, "svd")
|
|
312
|
+
par0 = np.concatenate([A0.ravel()] + [np.zeros((ncat[j] - 1) * (dim + 1)) for j in range(p)])
|
|
313
|
+
res = optimize.minimize(_nom_obj, par0, args=(Xi, n, p, dim, ncat, penalization), jac=True, method="L-BFGS-B",
|
|
314
|
+
options={"maxiter": maxiter, "ftol": 1e-12, "gtol": 1e-8, "maxcor": 30})
|
|
315
|
+
A = res.x[:n * dim].reshape(n, dim)
|
|
316
|
+
o = n * dim
|
|
317
|
+
Bs = []
|
|
318
|
+
for j in range(p):
|
|
319
|
+
k = (ncat[j] - 1) * (dim + 1)
|
|
320
|
+
Bs.append(res.x[o:o + k].reshape(ncat[j] - 1, dim + 1)); o += k
|
|
321
|
+
U, d, Vt = np.linalg.svd(A, full_matrices=False)
|
|
322
|
+
A = A @ Vt.T
|
|
323
|
+
Bs = [np.column_stack([B[:, 0], B[:, 1:] @ Vt.T]) for B in Bs]
|
|
324
|
+
acc = np.full((n, p), np.nan)
|
|
325
|
+
probs, dev = [], np.zeros(p)
|
|
326
|
+
for j in range(p):
|
|
327
|
+
eta = np.column_stack([np.zeros(n), Bs[j][:, 0] + A @ Bs[j][:, 1:].T])
|
|
328
|
+
P = np.exp(eta - logsumexp(eta, axis=1)[:, None])
|
|
329
|
+
probs.append(P)
|
|
330
|
+
m = Xi[:, j] >= 0
|
|
331
|
+
c = Xi[m, j]
|
|
332
|
+
acc[m, j] = P[m].argmax(1) == c
|
|
333
|
+
freq = np.bincount(c, minlength=ncat[j]) / m.sum()
|
|
334
|
+
dev[j] = -2 * np.sum(np.log(freq[c])) + 2 * np.sum(np.log(np.clip(P[m, c], 1e-300, None)))
|
|
335
|
+
col_names = col_names or [f"V{j + 1}" for j in range(p)]
|
|
336
|
+
row_names = row_names or [f"I{i + 1}" for i in range(n)]
|
|
337
|
+
dfs = [(ncat[j] - 1) * dim for j in range(p)]
|
|
338
|
+
cols = pd.DataFrame({"categories": ncat, "Deviance": dev, "df": dfs, "p-value": sps.chi2.sf(dev, dfs),
|
|
339
|
+
"% correct": np.nanmean(acc, 0) * 100}, index=col_names)
|
|
340
|
+
return LogisticBiplot(title="Nominal Logistic Biplot", n=n, p=p, dim=dim, penalization=penalization, A=A,
|
|
341
|
+
B=Bs, categories=cats, fitted=probs, objective=res.fun, converged=res.success,
|
|
342
|
+
columns=cols, global_percent=np.nanmean(acc) * 100, row_names=row_names,
|
|
343
|
+
col_names=col_names)
|