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/plsbin.py ADDED
@@ -0,0 +1,224 @@
1
+ """PLS regression with binary responses (MultBiplotR).
2
+
3
+ * ``plsr1_bin``: PLS regression of one binary response on continuous
4
+ predictors (``PLSR1Bin``, algorithm 1 of Bastien, Esposito Vinzi & Tenenhaus
5
+ 2005): each component is built from the slopes of univariate ridge logistic
6
+ regressions and the response is finally regressed on the components.
7
+ * ``binary_plsr``: PLS regression for binary data (``BinaryPLSR``), with
8
+ binary predictors and binary responses, both modelled by logistic biplots
9
+ whose row scores are shared; fitted by alternating conjugate gradients
10
+ (R's ``optim(method = "CG")``) from random starts.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ from scipy.special import expit
17
+
18
+ from .distances import ridge_logistic
19
+ from .roptim import optim_cg
20
+ from .rrng import RRNG
21
+ from .transforms import initial_transform, transform_name
22
+
23
+
24
+ def plsr1_bin(y, X, S=2, scaling=5, penalization=0.0, row_names=None, col_names=None) -> dict:
25
+ """PLS regression of a binary response ``y`` (0/1) on ``X``
26
+ (``MultBiplotR::PLSR1Bin`` with ``Algorithm = 1``).
27
+
28
+ Component ``s``: for each predictor, a ridge logistic regression (penalty
29
+ 0.2, as in R) of ``y`` on the previous components and the predictor gives
30
+ a slope; the normalized slopes are the weights ``w``, the scores are
31
+ ``t = X_(s-1) w`` (deflated ``X``) and the loadings ``p = X't / t't``. The
32
+ response is finally regressed on the components with ``penalization``.
33
+
34
+ Returns scores ``T``, weights ``W``, loadings ``P``, the final logistic fit
35
+ and the structure correlations between the predictors and the scores."""
36
+ if isinstance(X, pd.DataFrame):
37
+ col_names = col_names or [str(c) for c in X.columns]
38
+ row_names = row_names or [str(i) for i in X.index]
39
+ Xo = np.asarray(X, float)
40
+ y = np.asarray(y, float).ravel()
41
+ if not np.all(np.isin(y, (0, 1))):
42
+ raise ValueError("The response must be binary (0 or 1)")
43
+ Xs = initial_transform(Xo, transform_name(scaling))["X"]
44
+ n, J = Xs.shape
45
+ T = np.zeros((n, S)); W = np.zeros((J, S)); P = np.zeros((J, S))
46
+ w = np.zeros(J)
47
+ for j in range(J):
48
+ w[j] = ridge_logistic(y, Xs[:, j], 0.2, intercept=True)["beta"][1]
49
+ w = w / np.sqrt(np.sum(w ** 2))
50
+ t = Xs @ w
51
+ T[:, 0] = t; W[:, 0] = w
52
+ p = Xs.T @ t / np.sum(t ** 2); P[:, 0] = p
53
+ X1 = Xs - np.outer(t, p)
54
+ for s in range(1, S):
55
+ for j in range(J):
56
+ x = np.column_stack([T[:, :s], Xs[:, j]])
57
+ w[j] = ridge_logistic(y, x, 0.2, intercept=True)["beta"][s + 1]
58
+ w = w / np.sqrt(np.sum(w ** 2))
59
+ t = X1 @ w
60
+ T[:, s] = t; W[:, s] = w
61
+ p = Xs.T @ t / np.sum(t ** 2); P[:, s] = p
62
+ X1 = X1 - np.outer(t, p)
63
+ fit = ridge_logistic(y, T, penalization, intercept=True)
64
+ comps = [f"Comp. {k + 1}" for k in range(S)]
65
+ col_names = col_names or [f"X{j + 1}" for j in range(J)]
66
+ row_names = row_names or [f"I{i + 1}" for i in range(n)]
67
+ structure = np.corrcoef(np.column_stack([Xo, T]), rowvar=False)[:J, J:]
68
+ return {"method": "PLSR1 Binary", "scores": pd.DataFrame(T, index=row_names, columns=comps),
69
+ "weights": pd.DataFrame(W, index=col_names, columns=comps),
70
+ "loadings": pd.DataFrame(P, index=col_names, columns=comps),
71
+ "structure": pd.DataFrame(structure, index=col_names, columns=comps), "fit": fit,
72
+ "y_weights": fit["beta"], "scaled_X": Xs}
73
+
74
+
75
+ # --------------------------------------------------------------------------- BinaryPLSR
76
+ def _rsum(v):
77
+ """``sum(v, na.rm = TRUE)`` of R: sequential accumulation in long double,
78
+ in column-major order, skipping NaN (keeps the CG path close to R's)."""
79
+ v = np.asarray(v, float).ravel(order="F")
80
+ v = v[~np.isnan(v)]
81
+ return float(np.cumsum(v.astype(np.longdouble))[-1]) if v.size else 0.0
82
+
83
+
84
+ def _mm(A, B):
85
+ """``A %*% B`` with the summation order of the reference BLAS used by R
86
+ (sequential over the inner index, in double precision)."""
87
+ A = np.atleast_2d(A); B = np.atleast_2d(B)
88
+ C = np.zeros((A.shape[0], B.shape[1]))
89
+ for l in range(A.shape[1]):
90
+ C += A[:, l:l + 1] * B[l:l + 1, :]
91
+ return C
92
+
93
+
94
+ def _sig(z):
95
+ return 1 / (1 + np.exp(-1 * z))
96
+
97
+
98
+ def _cost(H, X):
99
+ with np.errstate(divide="ignore", invalid="ignore"):
100
+ return _rsum(-1 * X * np.log(H) - (1 - X) * np.log(1 - H))
101
+
102
+
103
+ def _J_b0(par, X):
104
+ H = _sig(_mm(np.ones((X.shape[0], 1)), par[None, :]))
105
+ return _cost(H, X) / 2
106
+
107
+
108
+ def _g_b0(par, X):
109
+ H = _sig(_mm(np.ones((X.shape[0], 1)), par[None, :]))
110
+ return _mm((H - X).T, np.ones((X.shape[0], 1))).ravel()
111
+
112
+
113
+ def _J_B(par, X, A, B, lam):
114
+ r = A.shape[1] - 1
115
+ B = B.copy(); B[:, r] = par
116
+ H = _sig(_mm(A, B.T))
117
+ return _cost(H, X) / 2 + lam * _rsum(B[:, 1:] ** 2) / 2
118
+
119
+
120
+ def _g_B(par, X, A, B, lam):
121
+ r = A.shape[1] - 1
122
+ B = B.copy(); B[:, r] = par
123
+ E = _sig(_mm(A, B.T)) - X
124
+ E[np.isnan(X)] = 0
125
+ G = _mm(E.T, A) + lam * np.column_stack([np.zeros(B.shape[0]), B[:, 1:]])
126
+ return G[:, r]
127
+
128
+
129
+ def _J_A(par, X, A, B, lam):
130
+ r = B.shape[1] - 1
131
+ A = A.copy(); A[:, r] = par
132
+ H = _sig(_mm(A, B.T))
133
+ return _cost(H, X) / 2 + lam * _rsum(A ** 2) / 2
134
+
135
+
136
+ def _g_A(par, X, A, B, lam):
137
+ r = B.shape[1] - 1
138
+ A = A.copy(); A[:, r] = par
139
+ E = _sig(_mm(A, B.T)) - X
140
+ E[np.isnan(X)] = 0
141
+ return (_mm(E, B) + lam * A)[:, r]
142
+
143
+
144
+ def _scale(v):
145
+ """R's ``scale()`` of a vector: ``colMeans`` (long double sum / n) and the root
146
+ of the sum of squares (long double sum) over n - 1."""
147
+ v = np.asarray(v, float).ravel()
148
+ n = v.size
149
+ m = float(np.cumsum(v.astype(np.longdouble))[-1] / n)
150
+ c = v - m
151
+ ss = float(np.cumsum((c * c).astype(np.longdouble))[-1])
152
+ return c / np.sqrt(ss / max(1, n - 1))
153
+
154
+
155
+ def binary_plsr(Y, X, S=2, penalization=0.1, tol=5e-5, maxiter=100, seed=0, row_names=None,
156
+ x_names=None, y_names=None) -> dict:
157
+ """PLS regression for binary data (``MultBiplotR::BinaryPLSR``).
158
+
159
+ ``X`` (n x J) and ``Y`` (n x K) are binary. Both are modelled by logistic
160
+ biplots with intercepts ``p0`` and ``q0``; component ``s`` alternates, until
161
+ the scores stabilize, ridge-penalized conjugate-gradient updates of the
162
+ ``X`` loadings, the ``X`` scores, the ``Y`` loadings and the ``Y`` scores,
163
+ the scores being standardized after each update (R's ``optim`` CG, ported in
164
+ ``biplotpy.roptim``). Starting values are normal random numbers drawn
165
+ with R's generator after ``set.seed(seed)``, so a given seed gives R's
166
+ result. Returns scores, loadings, intercepts, fitted probabilities and the
167
+ percentages of correct classification of ``Y``."""
168
+ if isinstance(X, pd.DataFrame):
169
+ x_names = x_names or [str(c) for c in X.columns]; row_names = row_names or [str(i) for i in X.index]
170
+ if isinstance(Y, pd.DataFrame):
171
+ y_names = y_names or [str(c) for c in Y.columns]
172
+ X = np.asarray(X, float); Y = np.asarray(Y, float)
173
+ if Y.ndim == 1:
174
+ Y = Y[:, None]
175
+ if not np.all(np.isin(Y[np.isfinite(Y)], (0, 1))):
176
+ raise ValueError("The response must be binary (0 or 1)")
177
+ I, J = X.shape
178
+ K = Y.shape[1]
179
+ rng = RRNG(seed)
180
+ p0 = optim_cg(_J_b0, _g_b0, rng.rnorm(J), args=(X,))["par"]
181
+ q0 = optim_cg(_J_b0, _g_b0, rng.rnorm(K), args=(Y,))["par"]
182
+ TT = np.ones((I, 1)); U = np.ones((I, 1)); P = p0[:, None]; Q = q0[:, None]
183
+ iters = []
184
+ for k in range(S):
185
+ tt = rng.rnorm(I); TT = np.column_stack([TT, tt])
186
+ u = rng.rnorm(I); U = np.column_stack([U, u])
187
+ parP = rng.rnorm(J); P = np.column_stack([P, parP])
188
+ parQ = rng.rnorm(K); Q = np.column_stack([Q, parQ])
189
+ t = u
190
+ error, it = 1.0, 0
191
+ while error > tol and it < maxiter:
192
+ it += 1
193
+ told = t
194
+ parP = optim_cg(_J_B, _g_B, parP, args=(X, TT, P, penalization))["par"]
195
+ P[:, k + 1] = parP
196
+ t = optim_cg(_J_A, _g_A, t, args=(X, TT, P, penalization))["par"]
197
+ t = _scale(t)
198
+ tt = t
199
+ u = t
200
+ U[:, k + 1] = u
201
+ parQ = optim_cg(_J_B, _g_B, parQ, args=(Y, U, Q, penalization))["par"]
202
+ Q[:, k + 1] = parQ
203
+ u = optim_cg(_J_A, _g_A, u, args=(Y, U, Q, penalization))["par"]
204
+ U[:, k + 1] = _scale(u)
205
+ t = _scale(u)
206
+ TT[:, k + 1] = t
207
+ error = np.sum((told - t) ** 2)
208
+ TT[:, k + 1] = tt
209
+ iters.append(it)
210
+ U, Q, P = U[:, 1:], Q[:, 1:], P[:, 1:]
211
+ lin = TT @ np.column_stack([q0, Q]).T
212
+ E = expit(lin)
213
+ pred = (E > 0.5).astype(float)
214
+ right = Y == pred
215
+ comps = [f"Comp. {k + 1}" for k in range(S)]
216
+ row_names = row_names or [f"I{i + 1}" for i in range(I)]
217
+ x_names = x_names or [f"X{j + 1}" for j in range(J)]
218
+ y_names = y_names or [f"Y{j + 1}" for j in range(K)]
219
+ return {"method": "PLSR for binary data", "x_scores": pd.DataFrame(TT[:, 1:], index=row_names, columns=comps),
220
+ "x_loadings": pd.DataFrame(P, index=x_names, columns=comps),
221
+ "y_scores": pd.DataFrame(U, index=row_names, columns=comps),
222
+ "y_loadings": pd.DataFrame(Q, index=y_names, columns=comps), "x_intercepts": p0, "y_intercepts": q0,
223
+ "linear_terms": lin, "expected": E, "predictions": pred, "percent_correct": right.mean(),
224
+ "percent_correct_cols": right.mean(0), "iterations": iters, "seed": seed}
biplotpy/restricted.py ADDED
@@ -0,0 +1,300 @@
1
+ """Constrained (restricted) logistic biplots and the external ordinal logistic
2
+ biplot of MultBiplotR.
3
+
4
+ * ``constrained_logistic_biplot``: binary responses ``Y`` explained by
5
+ continuous variables ``X`` (``ConstrainedLogisticBiplot``). Each response is
6
+ regressed on ``X`` by ridge logistic regression; the standardized linear
7
+ predictors are summarized by an SVD, whose first components are the row
8
+ coordinates, and each response is then refitted on those coordinates.
9
+ * ``constrained_ordinal_logistic_biplot``: the same with ordinal responses and
10
+ ridge cumulative-logit regressions (``ConstrainedOrdinalLogisticBiplot``).
11
+ * ``external_ordinal_logistic_biplot``: ordinal variables fitted by ridge
12
+ cumulative-logit regressions on a given configuration (PCA, PCoA, MDS...),
13
+ as ``AddOrdVars2Biplot``.
14
+ * ``ridge_ordinal_logistic``: the Newton (Fisher scoring) fit of MultBiplotR
15
+ (``OrdinalLogisticFit``/``RidgeOrdinalLogistic``), with the ridge penalty on
16
+ thresholds and slopes.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ import scipy.stats as sps
23
+ from scipy.special import expit
24
+
25
+ from .distances import ridge_logistic
26
+ from .extras import add_continuous_variables
27
+ from .logistic import LogisticBiplot
28
+ from .transforms import initial_transform, transform_name
29
+
30
+
31
+ # --------------------------------------------------------------------------- helpers
32
+ def _ridge_binary_fit(y, Xd, penalization=0.2, tol=1e-5, maxiter=100):
33
+ """``RidgeBinaryLogisticFit``: Newton-Raphson with ridge penalty (first column,
34
+ the intercept, not penalized). ``Xd`` already contains the intercept."""
35
+ n, m = Xd.shape
36
+ beta = np.zeros(m)
37
+ Im = np.eye(m); Im[0, 0] = 0.0
38
+ err, it = 0.1, 0
39
+ while err > tol and it < maxiter:
40
+ it += 1
41
+ old = beta
42
+ mu = expit(Xd @ old)
43
+ In = Xd.T @ ((mu * (1 - mu))[:, None] * Xd) + 2 * penalization * Im
44
+ U = Xd.T @ (y - mu) - 2 * penalization * Im @ old
45
+ beta = old + np.linalg.pinv(In) @ U
46
+ err = np.sum(np.abs(old - beta))
47
+ return beta
48
+
49
+
50
+ def _codes(y):
51
+ """Ordinal variable -> integer codes 1..C (pandas ordered categorical, or numbers)."""
52
+ if hasattr(y, "cat"):
53
+ return y.cat.codes.to_numpy() + 1, list(y.cat.categories)
54
+ y = np.asarray(y)
55
+ lev = sorted(set(y.tolist()))
56
+ return np.array([lev.index(v) + 1 for v in y]), lev
57
+
58
+
59
+ def ridge_ordinal_logistic(y, x=None, penalization=0.1, tol=1e-4, maxiter=200) -> dict:
60
+ """Ridge cumulative-logit regression, ``logit P(y <= c) = tau_c - x beta``,
61
+ fitted by Fisher scoring as ``MultBiplotR::OrdinalLogisticFit`` (the
62
+ penalty ``penalization * |(tau, beta)|^2`` includes the thresholds).
63
+
64
+ ``y``: integer codes 1..J. Returns thresholds, coefficients, linear
65
+ predictors, fitted category probabilities, log-likelihood, deviance and,
66
+ against the model without covariates, the likelihood-ratio test and the
67
+ pseudo R2 of Cox-Snell, Nagelkerke and McFadden."""
68
+ def fit(xm):
69
+ y_ = np.asarray(y, int)
70
+ n = len(y_)
71
+ p = 0 if xm is None else xm.shape[1]
72
+ J = int(y_.max())
73
+ if J < 2:
74
+ raise ValueError("There is a variable with the same value for all the items.")
75
+ npar = J + p - 1
76
+ Y = np.zeros((n, J))
77
+ for i in range(n):
78
+ if y_[i] > 0:
79
+ Y[i, y_[i] - 1] = 1
80
+ R = np.cumsum(Y, axis=1)
81
+ A = R[:, :J - 1].sum(0) / n
82
+ Beta = np.zeros(p)
83
+ B = np.concatenate([A, Beta])
84
+ err, it = 0.1, 0
85
+ while err > tol and it < maxiter:
86
+ it += 1
87
+ ETA = np.tile(A, (n, 1)) if xm is None else A[None, :] - (xm @ Beta)[:, None]
88
+ PIA = np.column_stack([np.exp(ETA) / (1 + np.exp(ETA)), np.ones(n)])
89
+ PI = np.column_stack([PIA[:, :1], PIA[:, 1:J] - PIA[:, :J - 1]])
90
+ Rho = np.log(PIA[:, :J - 1] / (PIA[:, 1:J] - PIA[:, :J - 1]))
91
+ gRho = np.log(1 + np.exp(Rho))
92
+ U = PIA[:, 1:J] / (PIA[:, :J - 1] * (PIA[:, 1:J] - PIA[:, :J - 1]))
93
+ D = R[:, :J - 1] - R[:, 1:J] * (PIA[:, :J - 1] / PIA[:, 1:J])
94
+ D2 = PIA[:, :J] * (1 - PIA[:, :J])
95
+ Q = np.zeros((n, J - 1, npar))
96
+ GR = np.zeros(npar)
97
+ for k in range(npar):
98
+ P = np.zeros((n, J))
99
+ if k < J - 1:
100
+ P[:, k] = 1.0
101
+ else:
102
+ P[:, :J - 1] = -xm[:, k - (J - 1)][:, None]
103
+ Q[:, :, k] = P[:, :J - 1] * D2[:, :J - 1] - P[:, 1:J] * (PIA[:, :J - 1] / PIA[:, 1:J]) * D2[:, 1:J]
104
+ GR[k] = np.sum(D * U * Q[:, :, k])
105
+ HESS = np.einsum("nj,njs,njk->sk", U, Q, Q) + 2 * np.eye(npar) * penalization
106
+ Bnew = B + np.linalg.solve(HESS, GR - 2 * B * penalization)
107
+ err = np.sum((Bnew - B) ** 2)
108
+ B = Bnew
109
+ A = B[:J - 1]
110
+ Beta = np.zeros(p) if xm is None else B[J - 1:J - 1 + p]
111
+ L = np.sum(R[:, :J - 1] * Rho - R[:, 1:J] * gRho)
112
+ pred = PI.argmax(1) + 1
113
+ return {"thresholds": A, "coefficients": Beta, "eta": ETA, "fitted": PI, "pred": pred,
114
+ "logLik": L, "deviance": -2 * L, "percent_correct": np.mean(y_ == pred), "iter": it,
115
+ "J": J, "nvar": p, "covariances": np.linalg.inv(HESS)}
116
+ x = None if x is None else np.asarray(x, float).reshape(len(y), -1)
117
+ model = fit(x)
118
+ null = fit(None)
119
+ n = len(y)
120
+ dif = null["deviance"] - model["deviance"]
121
+ cs = 1 - np.exp(-dif / n)
122
+ model.update({"null_deviance": null["deviance"], "dif": dif, "df": model["nvar"],
123
+ "p": sps.chi2.sf(dif, model["nvar"]), "cox_snell": cs,
124
+ "nagelkerke": cs / (1 - np.exp(null["deviance"] / -2) ** (2 / n)),
125
+ "mcfadden": 1 - model["deviance"] / null["deviance"]})
126
+ return model
127
+
128
+
129
+ def _ordinal_columns(Ycodes, A, penalization, tol, maxiter, names):
130
+ """Fit each ordinal variable on the configuration ``A`` (``AddOrdVars2Biplot``)."""
131
+ n, p = Ycodes.shape
132
+ dim = A.shape[1]
133
+ ncat = [int(Ycodes[:, j].max()) for j in range(p)]
134
+ coef = np.zeros((p, dim)); thr = np.zeros((p, max(ncat) - 1))
135
+ fitcols = ["logLik", "Deviance", "df", "p-value", "PCC", "CoxSnell", "Macfaden", "Nagelkerke", "NullDeviance"]
136
+ fit = np.zeros((p, 9)); models = []
137
+ for j in range(p):
138
+ m = ridge_ordinal_logistic(Ycodes[:, j], A, penalization, tol, maxiter)
139
+ models.append(m)
140
+ coef[j] = m["coefficients"]; thr[j, :len(m["thresholds"])] = m["thresholds"]
141
+ fit[j] = [m["logLik"], m["deviance"], m["df"], m["p"], m["percent_correct"], m["cox_snell"],
142
+ m["mcfadden"], m["nagelkerke"], m["null_deviance"]]
143
+ d = np.sqrt((coef ** 2).sum(1) + 1)
144
+ loadings = coef / d[:, None]
145
+ nn = n * p
146
+ dev0, dev, df = fit[:, 8].sum(), fit[:, 1].sum(), fit[:, 2].sum()
147
+ dif = dev0 - dev
148
+ cs = 1 - np.exp(-dif / nn)
149
+ ll = fit[:, 0].sum()
150
+ return {"coefficients": pd.DataFrame(coef, index=names, columns=[f"Dim_{k + 1}" for k in range(dim)]),
151
+ "thresholds": pd.DataFrame(thr, index=names, columns=[f"C_{c + 1}" for c in range(thr.shape[1])]),
152
+ "fit": pd.DataFrame(fit, index=names, columns=fitcols), "loadings": loadings,
153
+ "communalities": (loadings ** 2).sum(1), "ncats": ncat, "logLik": ll, "deviance": dev,
154
+ "null_deviance": dev0, "df": df, "dif": dif, "p": sps.chi2.sf(dif, df), "cox_snell": cs,
155
+ "nagelkerke": cs / (1 - np.exp(dev0 / -2) ** (2 / nn)), "mcfadden": 1 - dev / dev0,
156
+ "AIC": -2 * ll + 2 * df, "BIC": -2 * ll + df * np.log(nn), "models": models}
157
+
158
+
159
+ def _ordinal_frame(Y):
160
+ if isinstance(Y, pd.DataFrame):
161
+ names = [str(c) for c in Y.columns]
162
+ cols = [_codes(Y[c]) for c in Y.columns]
163
+ else:
164
+ Y = np.asarray(Y)
165
+ names = [f"V{j + 1}" for j in range(Y.shape[1])]
166
+ cols = [_codes(Y[:, j]) for j in range(Y.shape[1])]
167
+ return np.column_stack([c[0] for c in cols]), [c[1] for c in cols], names
168
+
169
+
170
+ # --------------------------------------------------------------------------- binary
171
+ def constrained_logistic_biplot(Y, X, dim=2, scaling=5, penalization=0.1, tol=1e-5, maxiter=100,
172
+ row_names=None) -> LogisticBiplot:
173
+ """Constrained binary logistic biplot (``MultBiplotR::ConstrainedLogisticBiplot``).
174
+
175
+ The row coordinates are linear combinations of the explanatory variables
176
+ ``X``: each binary response is regressed on ``X`` (ridge logistic
177
+ regression, ``penalization``), the standardized linear predictors are
178
+ decomposed by SVD and the first ``dim`` components (scaled by the singular
179
+ values) are the row coordinates. Each response is then refitted on them
180
+ (ridge penalty 0.2, as in R) and the explanatory variables are projected as
181
+ supplementary continuous variables.
182
+
183
+ Two details differ from MultBiplotR on purpose: the p-values of the
184
+ deviance tests use ``dim`` degrees of freedom (R uses 1), and the
185
+ percentages of correct classification threshold the probability at 0.5 (R
186
+ thresholds the linear predictor at 0.5)."""
187
+ if isinstance(Y, pd.DataFrame):
188
+ col_names = [str(c) for c in Y.columns]; row_names = row_names or [str(i) for i in Y.index]
189
+ else:
190
+ col_names = [f"V{j + 1}" for j in range(np.shape(Y)[1])]
191
+ x_names = [str(c) for c in X.columns] if isinstance(X, pd.DataFrame) else None
192
+ Y = np.asarray(Y, float); Xo = np.asarray(X, float)
193
+ n, p = Y.shape
194
+ q = Xo.shape[1]
195
+ r = min(p, q)
196
+ tname = transform_name(scaling)
197
+ Xs = initial_transform(Xo, tname)["X"]
198
+ Xd = np.column_stack([np.ones(n), Xs])
199
+ Yh = np.zeros((n, p)); Betas = np.zeros((p, q + 1))
200
+ for j in range(p):
201
+ fit = ridge_logistic(Y[:, j], Xd, penalization=penalization, intercept=False, tol=tol, maxiter=maxiter)
202
+ Betas[j] = fit["beta"]; Yh[:, j] = Xd @ fit["beta"]
203
+ Yh = initial_transform(Yh, "Standardize columns")["X"]
204
+ U, d, Vt = np.linalg.svd(Yh, full_matrices=False)
205
+ ev = d[:r] ** 2
206
+ A = U[:, :dim] * d[:dim]
207
+ aa = np.column_stack([np.ones(n), A])
208
+ B = np.vstack([_ridge_binary_fit(Y[:, j], aa, 0.2, tol, maxiter) for j in range(p)])
209
+ esp = aa @ B.T
210
+ P = expit(esp)
211
+ pred = (P > 0.5).astype(float)
212
+ acier = pred == Y
213
+ pres = Y.sum(0); abs_ = n - pres
214
+ sens = ((acier) & (Y == 1)).sum(0) / pres
215
+ spec = ((acier) & (Y == 0)).sum(0) / abs_
216
+ p0 = expit(B[:, 0])
217
+ d1 = -2 * (Y * np.log(p0) + (1 - Y) * np.log(1 - p0)).sum(0)
218
+ d2 = -2 * (Y * np.log(P) + (1 - Y) * np.log(1 - P)).sum(0)
219
+ dev = d1 - d2
220
+ cs = 1 - np.exp(-dev / n)
221
+ dd = np.sqrt(1 + (B[:, 1:] ** 2).sum(1))
222
+ loadings = B[:, 1:] / dd[:, None]
223
+ row_names = row_names or [f"I{i + 1}" for i in range(n)]
224
+ cols = pd.DataFrame(np.column_stack([B, dev, sps.chi2.sf(dev, dim), acier.mean(0) * 100, 1 - d2 / d1]),
225
+ index=col_names, columns=["b0"] + [f"b{k + 1}" for k in range(dim)] +
226
+ ["Deviance", "p-value", "% correct", "McFadden R2"])
227
+ out = LogisticBiplot(
228
+ title="Constrained Binary Logistic Biplot", n=n, p=p, dim=dim, penalization=penalization, A=A, B=B,
229
+ fitted=P, objective=float(d2.sum() / 2), converged=True, columns=cols,
230
+ betas=pd.DataFrame(Betas, index=col_names, columns=["Intercept"] + (x_names or [f"X{k + 1}" for k in range(q)])),
231
+ eigenvalues=ev, inertia=ev / ev.sum() * 100, null_deviances=d1, model_deviances=d2, deviances=dev,
232
+ cox_snell=cs, nagelkerke=cs / (1 - np.exp(d1 / -2) ** (2 / n)), mcfadden=1 - d2 / d1,
233
+ loadings=loadings, communalities=(loadings ** 2).sum(1), sensitivity=sens, specificity=spec,
234
+ global_percent=acier.mean() * 100, row_percent=acier.mean(1) * 100, row_names=row_names, col_names=col_names)
235
+ out.row_coordinates = A
236
+ out.sup_variables = add_continuous_variables(out, Xo, dims=dim, scaling=scaling, names=x_names)
237
+ return out
238
+
239
+
240
+ # --------------------------------------------------------------------------- ordinal
241
+ def constrained_ordinal_logistic_biplot(Y, X, dim=2, scaling=5, penalization=0.1, tol=1e-5, maxiter=100,
242
+ row_names=None) -> LogisticBiplot:
243
+ """Constrained ordinal logistic biplot (``MultBiplotR::ConstrainedOrdinalLogisticBiplot``).
244
+
245
+ ``Y``: ordinal variables (DataFrame of ordered categoricals, or integer
246
+ codes 1..C). Each is regressed on the transformed ``X`` by ridge
247
+ cumulative-logit regression; the standardized linear predictors (first
248
+ threshold) are decomposed by SVD; the first ``dim`` components are the row
249
+ coordinates and each ordinal variable is refitted on them."""
250
+ Yc, cats, names = _ordinal_frame(Y)
251
+ if isinstance(Y, pd.DataFrame):
252
+ row_names = row_names or [str(i) for i in Y.index]
253
+ x_names = [str(c) for c in X.columns] if isinstance(X, pd.DataFrame) else None
254
+ Xs = initial_transform(np.asarray(X, float), transform_name(scaling))["X"]
255
+ n, p = Yc.shape
256
+ r = min(p, Xs.shape[1])
257
+ Yh = np.zeros((n, p)); fits = []
258
+ for j in range(p):
259
+ m = ridge_ordinal_logistic(Yc[:, j], Xs, penalization, tol, maxiter)
260
+ fits.append(m)
261
+ e = m["eta"][:, 0]
262
+ Yh[:, j] = (e - e.mean()) / e.std(ddof=1)
263
+ U, d, Vt = np.linalg.svd(Yh, full_matrices=False)
264
+ ev = d[:r] ** 2
265
+ A = U[:, :dim] * d[:dim]
266
+ cp = _ordinal_columns(Yc, A, penalization, tol, maxiter, names)
267
+ row_names = row_names or [f"I{i + 1}" for i in range(n)]
268
+ cols = pd.concat([cp["coefficients"], cp["fit"][["Deviance", "p-value", "PCC"]]], axis=1)
269
+ out = LogisticBiplot(
270
+ title="Constrained Ordinal Logistic Biplot", n=n, p=p, dim=dim, penalization=penalization, A=A,
271
+ B=cp["coefficients"].to_numpy(), thresholds=[cp["thresholds"].to_numpy()[j, :cp["ncats"][j] - 1] for j in range(p)],
272
+ objective=-cp["logLik"], converged=True, columns=cols, column_parameters=cp, eigenvalues=ev,
273
+ inertia=ev / ev.sum() * 100, categories=cats, regressions=fits,
274
+ global_percent=float(np.mean(cp["fit"]["PCC"]) * 100), row_names=row_names, col_names=names,
275
+ loadings=cp["loadings"], communalities=cp["communalities"], deviance=cp["deviance"],
276
+ null_deviance=cp["null_deviance"], df=cp["df"], pvalue=cp["p"], AIC=cp["AIC"], BIC=cp["BIC"])
277
+ out.row_coordinates = A
278
+ out.sup_variables = add_continuous_variables(out, Xs, dims=dim, scaling=scaling, names=x_names)
279
+ return out
280
+
281
+
282
+ def external_ordinal_logistic_biplot(coords, Y, penalization=0.2, tol=1e-6, maxiter=100,
283
+ row_names=None) -> LogisticBiplot:
284
+ """External ordinal logistic biplot: each ordinal variable is fitted by a
285
+ ridge cumulative-logit regression on a given configuration ``coords``
286
+ (principal components, coordinates of a PCoA or an MDS...), as
287
+ ``MultBiplotR::AddOrdVars2Biplot``. The lines of the variables and the
288
+ category boundaries are drawn with ``plot()``."""
289
+ A = np.asarray(getattr(coords, "row_coordinates", coords), float)
290
+ Yc, cats, names = _ordinal_frame(Y)
291
+ n, p = Yc.shape
292
+ cp = _ordinal_columns(Yc, A, penalization, tol, maxiter, names)
293
+ cols = pd.concat([cp["coefficients"], cp["fit"][["Deviance", "p-value", "PCC"]]], axis=1)
294
+ return LogisticBiplot(
295
+ title="External Ordinal Logistic Biplot", n=n, p=p, dim=A.shape[1], penalization=penalization, A=A,
296
+ B=cp["coefficients"].to_numpy(), thresholds=[cp["thresholds"].to_numpy()[j, :cp["ncats"][j] - 1] for j in range(p)],
297
+ objective=-cp["logLik"], converged=True, columns=cols, column_parameters=cp, categories=cats,
298
+ global_percent=float(np.mean(cp["fit"]["PCC"]) * 100), row_names=row_names or [f"I{i + 1}" for i in range(n)],
299
+ col_names=names, loadings=cp["loadings"], communalities=cp["communalities"], deviance=cp["deviance"],
300
+ null_deviance=cp["null_deviance"], df=cp["df"], pvalue=cp["p"], AIC=cp["AIC"], BIC=cp["BIC"])
biplotpy/roptim.py ADDED
@@ -0,0 +1,104 @@
1
+ """R's ``optim(method = "CG")``: conjugate gradients of Nash (1990), as coded in
2
+ R's ``src/appl/optim.c`` (``cgmin``), so that procedures that MultBiplotR runs
3
+ with ``optim(..., method = "CG")`` follow the same path in Python."""
4
+ from __future__ import annotations
5
+
6
+ import math
7
+
8
+ import numpy as np
9
+
10
+ _STEPREDN = 0.2
11
+ _ACCTOL = 0.0001
12
+ _RELTEST = 10.0
13
+
14
+
15
+ def optim_cg(fn, gr, par, args=(), type: int = 1, maxit: int = 100, abstol: float = -math.inf,
16
+ reltol: float = 1.4901161193847656e-08):
17
+ """Minimize ``fn`` from ``par`` with R's CG method (``type`` 1 Fletcher-Reeves,
18
+ 2 Polak-Ribiere, 3 Beale-Sorenson). Returns a dict like ``optim``: ``par``,
19
+ ``value``, ``counts`` and ``convergence`` (1 when ``maxit`` is reached)."""
20
+ B = [float(v) for v in np.asarray(par, float).ravel()]
21
+ n = len(B)
22
+ f_ = lambda b: float(fn(np.array(b), *args))
23
+ g_ = lambda b: [float(v) for v in np.asarray(gr(np.array(b), *args), float).ravel()]
24
+ if maxit <= 0:
25
+ return {"par": np.array(B), "value": f_(B), "counts": (0, 0), "convergence": 0}
26
+ c = [0.0] * n; t = [0.0] * n; X = [0.0] * n
27
+ setstep = 1.7
28
+ fail = 0
29
+ cyclimit = n
30
+ tol = reltol * n * math.sqrt(reltol)
31
+ f = f_(B)
32
+ if not math.isfinite(f):
33
+ raise ValueError("Function cannot be evaluated at initial parameters")
34
+ Fmin = f
35
+ funcount, gradcount = 1, 0
36
+ steplength = 1.0
37
+ while True:
38
+ t = [0.0] * n; c = [0.0] * n
39
+ cycle = 0
40
+ oldstep = 1.0
41
+ count = 0
42
+ while True:
43
+ cycle += 1
44
+ gradcount += 1
45
+ if gradcount > maxit:
46
+ return {"par": np.array(X), "value": Fmin, "counts": (funcount, gradcount), "convergence": 1}
47
+ g = g_(B)
48
+ G1 = 0.0; G2 = 0.0
49
+ for i in range(n):
50
+ X[i] = B[i]
51
+ if type == 1:
52
+ G1 += g[i] * g[i]; G2 += c[i] * c[i]
53
+ elif type == 2:
54
+ G1 += g[i] * (g[i] - c[i]); G2 += c[i] * c[i]
55
+ else:
56
+ G1 += g[i] * (g[i] - c[i]); G2 += t[i] * (g[i] - c[i])
57
+ c[i] = g[i]
58
+ if G1 > tol:
59
+ G3 = G1 / G2 if G2 > 0.0 else 1.0
60
+ gradproj = 0.0
61
+ for i in range(n):
62
+ t[i] = t[i] * G3 - g[i]
63
+ gradproj += t[i] * g[i]
64
+ steplength = oldstep
65
+ accpoint = False
66
+ while True:
67
+ count = 0
68
+ for i in range(n):
69
+ B[i] = X[i] + steplength * t[i]
70
+ if _RELTEST + X[i] == _RELTEST + B[i]:
71
+ count += 1
72
+ if count < n:
73
+ f = f_(B)
74
+ funcount += 1
75
+ accpoint = math.isfinite(f) and f <= Fmin + gradproj * steplength * _ACCTOL
76
+ if not accpoint:
77
+ steplength *= _STEPREDN
78
+ else:
79
+ Fmin = f
80
+ if count == n or accpoint:
81
+ break
82
+ if count < n:
83
+ newstep = 2 * (f - Fmin - gradproj * steplength)
84
+ if newstep > 0:
85
+ newstep = -(gradproj * steplength * steplength / newstep)
86
+ for i in range(n):
87
+ B[i] = X[i] + newstep * t[i]
88
+ Fmin = f
89
+ f = f_(B)
90
+ funcount += 1
91
+ if f < Fmin:
92
+ Fmin = f
93
+ else:
94
+ for i in range(n):
95
+ B[i] = X[i] + steplength * t[i]
96
+ oldstep = setstep * steplength
97
+ if oldstep > 1.0:
98
+ oldstep = 1.0
99
+ if not ((count != n) and (G1 > tol) and (cycle != cyclimit)):
100
+ break
101
+ if not ((cycle != 1) or ((count != n) and (G1 > tol) and Fmin > abstol)):
102
+ break
103
+ # like R's optim, the result is the last base point X (not the last trial point)
104
+ return {"par": np.array(X), "value": Fmin, "counts": (funcount, gradcount), "convergence": fail}