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