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/multitest.py ADDED
@@ -0,0 +1,281 @@
1
+ """Multivariate tests that accompany canonical biplots and are not available
2
+ in Python: Mardia's multivariate normality test (``psych::mardia``),
3
+ heteroscedastic MANOVA with the Wald-type and modified ANOVA-type statistics
4
+ and parametric / wild bootstrap (``MANOVA.RM``; Konietschke et al., 2015;
5
+ Friedrich & Pauly, 2018), and the hypothesis and error matrices of HE plots
6
+ (``heplots``; Friendly, 2007)."""
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from scipy import stats as sps
12
+
13
+
14
+ # --------------------------------------------------------------------------- Mardia
15
+ def mardia(X, unbiased: bool = True) -> dict:
16
+ """Mardia's multivariate skewness and kurtosis.
17
+
18
+ ``unbiased=True`` uses the covariance with n - 1 (as ``psych::mardia``);
19
+ ``False`` uses the ML covariance of Mardia (1970). Returns b1p, b2p, the
20
+ skewness statistic (chi-square with p(p+1)(p+2)/6 df), its small-sample
21
+ version, the standardized kurtosis (normal) and their p-values."""
22
+ X = np.asarray(X, float)
23
+ X = X[np.isfinite(X).all(1)]
24
+ n, p = X.shape
25
+ Xc = X - X.mean(0)
26
+ S = np.cov(Xc, rowvar=False, ddof=1 if unbiased else 0)
27
+ D = Xc @ np.linalg.solve(S, Xc.T)
28
+ b1p = np.sum(D ** 3) / n ** 2
29
+ b2p = np.trace(D ** 2) / n
30
+ df = p * (p + 1) * (p + 2) / 6
31
+ k = (p + 1) * (n + 1) * (n + 3) / (n * ((n + 1) * (p + 1) - 6))
32
+ skew, small = n * b1p / 6, n * k * b1p / 6
33
+ kurt = (b2p - p * (p + 2)) * np.sqrt(n / (8 * p * (p + 2)))
34
+ return {"n": n, "p": p, "b1p": b1p, "b2p": b2p, "skew": skew, "small_skew": small, "df": df,
35
+ "p_skew": sps.chi2.sf(skew, df), "p_small": sps.chi2.sf(small, df), "kurtosis": kurt,
36
+ "p_kurt": 2 * sps.norm.sf(abs(kurt)), "d": np.sqrt(np.diag(D))}
37
+
38
+
39
+ # --------------------------------------------------------------------------- MANOVA.RM
40
+ def _wts_mats(means, V, n, H):
41
+ N = n.sum()
42
+ Sn = N * _bdiag(V)
43
+ T = H.T @ np.linalg.pinv(H @ Sn @ H.T) @ H
44
+ wts = N * means @ T @ means
45
+ D = np.diag(np.diag(Sn))
46
+ mats = N * means @ H.T @ np.linalg.pinv(H @ D @ H.T) @ H @ means
47
+ return wts, mats
48
+
49
+
50
+ def _bdiag(blocks):
51
+ p = sum(b.shape[0] for b in blocks)
52
+ M = np.zeros((p, p)); o = 0
53
+ for b in blocks:
54
+ k = b.shape[0]; M[o:o + k, o:o + k] = b; o += k
55
+ return M
56
+
57
+
58
+ def manova_wts(Y, groups, H=None, iter: int = 10000, resampling: str = "paramBS", alpha: float = 0.05,
59
+ seed=None) -> dict:
60
+ """One-way MANOVA without normality or equal covariances (``MANOVA.RM::MANOVA``).
61
+
62
+ ``H`` is the hypothesis matrix acting on the stacked vector of group means
63
+ (default: equality of all group mean vectors, (I_a - J_a/a) kron I_p).
64
+ Returns the Wald-type statistic (WTS) with its asymptotic chi-square
65
+ p-value, the modified ANOVA-type statistic (MATS) and the bootstrap p-values
66
+ of both (``resampling`` = 'paramBS' or 'WildBS')."""
67
+ Y = np.asarray(Y, float)
68
+ g = np.asarray([str(v) for v in groups])
69
+ levels = list(groups.cat.categories) if hasattr(groups, "cat") else sorted(set(g))
70
+ levels = [str(v) for v in levels]
71
+ p = Y.shape[1]; a = len(levels)
72
+ Ys = [Y[g == lev] for lev in levels]
73
+ n = np.array([len(y) for y in Ys])
74
+ if H is None:
75
+ H = np.kron(np.eye(a) - np.ones((a, a)) / a, np.eye(p))
76
+ H = np.asarray(H, float)
77
+ means = np.concatenate([y.mean(0) for y in Ys])
78
+ V = [np.cov(y, rowvar=False) / len(y) for y in Ys]
79
+ wts, mats = _wts_mats(means, V, n, H)
80
+ df = np.linalg.matrix_rank(H)
81
+ rng = seed if isinstance(seed, np.random.Generator) else np.random.default_rng(seed)
82
+ bw, bm = np.empty(iter), np.empty(iter)
83
+ for b in range(iter):
84
+ if resampling == "paramBS":
85
+ Xb = [rng.multivariate_normal(np.zeros(p), len(y) * Vi, size=len(y), method="cholesky")
86
+ for y, Vi in zip(Ys, V)]
87
+ elif resampling == "WildBS":
88
+ Xb = [(2 * rng.integers(0, 2, len(y)) - 1)[:, None] * (y - y.mean(0)) for y in Ys]
89
+ else:
90
+ raise ValueError("resampling must be 'paramBS' or 'WildBS'")
91
+ mb = np.concatenate([x.mean(0) for x in Xb])
92
+ bw[b], bm[b] = _wts_mats(mb, [np.cov(x, rowvar=False) / len(x) for x in Xb], n, H)
93
+ return {"levels": levels, "n": n, "means": means.reshape(a, p), "WTS": wts, "df": df,
94
+ "p_WTS_chisq": sps.chi2.sf(wts, df), "MATS": mats,
95
+ "p_WTS_boot": float(np.mean(bw > wts)), "p_MATS_boot": float(np.mean(bm > mats)),
96
+ "quantiles": {"WTS": float(np.quantile(bw, 1 - alpha)), "MATS": float(np.quantile(bm, 1 - alpha))},
97
+ "resampling": resampling, "iter": iter}
98
+
99
+
100
+ # --------------------------------------------------------------------------- HE plots
101
+ def roy_crit(alpha, p, dfh, dfe):
102
+ """Critical value of Roy's largest root (F upper bound), as ``heplots``."""
103
+ df1 = max(p, dfh); df2 = dfe - df1 + dfh
104
+ return df1 / df2 * sps.f.isf(alpha, df1, df2)
105
+
106
+
107
+ def hlt_crit(alpha, p, dfh, dfe):
108
+ s = min(p, dfh); m = (abs(p - dfh) - 1) / 2; nn = (dfe - p - 1) / 2
109
+ df1 = 2 * m + s + 1; df2 = 2 * (s * nn + 1)
110
+ return s * df1 / df2 * sps.f.isf(alpha, df1, df2)
111
+
112
+
113
+ def he_matrices(Y, groups) -> dict:
114
+ """Hypothesis (H) and error (E) SSP matrices of a one-way MANOVA, their
115
+ degrees of freedom and the four multivariate statistics."""
116
+ Y = np.asarray(Y, float)
117
+ g = np.asarray([str(v) for v in groups])
118
+ levels = list(groups.cat.categories) if hasattr(groups, "cat") else sorted(set(g))
119
+ levels = [str(v) for v in levels]
120
+ gm = Y.mean(0)
121
+ H = np.zeros((Y.shape[1],) * 2); E = np.zeros_like(H)
122
+ means = []
123
+ for lev in levels:
124
+ y = Y[g == lev]; m = y.mean(0); means.append(m)
125
+ H += len(y) * np.outer(m - gm, m - gm)
126
+ E += (y - m).T @ (y - m)
127
+ dfh, dfe = len(levels) - 1, len(Y) - len(levels)
128
+ ev = np.sort(np.real(np.linalg.eigvals(np.linalg.solve(E, H))))[::-1]
129
+ stats = {"Pillai": np.sum(ev / (1 + ev)), "Wilks": np.prod(1 / (1 + ev)),
130
+ "Hotelling-Lawley": np.sum(ev), "Roy": ev[0]}
131
+ return {"H": H, "E": E, "dfh": dfh, "dfe": dfe, "eigenvalues": ev, "stats": stats,
132
+ "means": pd.DataFrame(means, index=levels), "grand_mean": gm}
133
+
134
+
135
+ def he_ellipses(Y, groups, variables=(0, 1), size="evidence", alpha=0.05, level=0.68, segments=60) -> dict:
136
+ """Coordinates of the H and E ellipses of an HE plot (``heplots::heplot``).
137
+
138
+ E is scaled by 1/dfe; H by 1/dfe and, with ``size='evidence'``, also by the
139
+ critical value of Roy's test, so that H projects outside E in some
140
+ direction if and only if Roy's test is significant at ``alpha``."""
141
+ he = he_matrices(Y, groups)
142
+ v = list(variables)
143
+ dfe = he["dfe"]; p = np.asarray(Y).shape[1]
144
+ radius = np.sqrt(2 * sps.f.ppf(level, 2, dfe))
145
+ factor = roy_crit(alpha, p, he["dfh"], dfe) if size in ("evidence", "significance") else 1.0
146
+ th = np.linspace(0, 2 * np.pi, segments + 1)
147
+ circle = radius * np.column_stack([np.cos(th), np.sin(th)])
148
+ c = he["grand_mean"][v]
149
+
150
+ def ell(S):
151
+ ev, U = np.linalg.eigh(S)
152
+ return c + circle @ (U @ np.diag(np.sqrt(np.maximum(ev, 0)))).T
153
+ H = he["H"][np.ix_(v, v)] / dfe / factor
154
+ E = he["E"][np.ix_(v, v)] / dfe
155
+ return {"H": ell(H), "E": ell(E), "H_shape": H, "E_shape": E, "means": he["means"].iloc[:, v],
156
+ "radius": radius, "factor": factor, "roy_significant": he["eigenvalues"][0] > roy_crit(alpha, p, he["dfh"], dfe)}
157
+
158
+
159
+ def he_plot(Y, groups, variables=(0, 1), size="evidence", alpha=0.05, ax=None, labels=None):
160
+ """Draw an HE plot with matplotlib."""
161
+ import matplotlib.pyplot as plt
162
+ r = he_ellipses(Y, groups, variables, size, alpha)
163
+ ax = ax or plt.gca()
164
+ ax.plot(*r["E"].T, color="tab:red", label="Error")
165
+ ax.plot(*r["H"].T, color="tab:blue", label="Groups")
166
+ ax.scatter(*r["means"].to_numpy().T, color="tab:blue", s=12)
167
+ for name, (x, y) in r["means"].iterrows():
168
+ ax.annotate(name, (x, y), fontsize=8)
169
+ if labels:
170
+ ax.set_xlabel(labels[0]); ax.set_ylabel(labels[1])
171
+ ax.legend(frameon=False)
172
+ return ax
173
+
174
+
175
+ # --------------------------------------------------------------------------- multi-factor MANOVA and HE plots
176
+ def _sum_contrast_cols(f, levels):
177
+ """Columns of ``contr.sum`` coding of a factor (k - 1 columns)."""
178
+ k = len(levels)
179
+ C = np.zeros((len(f), k - 1))
180
+ for a, lev in enumerate(levels[:-1]):
181
+ C[:, a] = (f == lev).astype(float)
182
+ C[f == levels[-1]] = -1.0
183
+ return C
184
+
185
+
186
+ def _design(factors: pd.DataFrame, terms):
187
+ """Model matrix (intercept + sum-coded terms) and the columns of each term."""
188
+ n = len(factors)
189
+ cols = {}
190
+ for name in factors.columns:
191
+ f = factors[name].astype(str).to_numpy()
192
+ levels = list(factors[name].cat.categories) if hasattr(factors[name], "cat") else sorted(set(f))
193
+ cols[name] = _sum_contrast_cols(f, [str(v) for v in levels])
194
+ blocks, Xs = {}, [np.ones((n, 1))]
195
+ start = 1
196
+ for t in terms:
197
+ parts = t.split(":")
198
+ M = np.ones((n, 1))
199
+ for p_ in parts:
200
+ M = (M[:, :, None] * cols[p_][:, None, :]).reshape(n, -1)
201
+ Xs.append(M); blocks[t] = list(range(start, start + M.shape[1])); start += M.shape[1]
202
+ return np.column_stack(Xs), blocks
203
+
204
+
205
+ def _sse(Y, X):
206
+ b, *_ = np.linalg.lstsq(X, Y, rcond=None)
207
+ R = Y - X @ b
208
+ return R.T @ R
209
+
210
+
211
+ def manova_terms(Y, factors, terms=None, type="II") -> dict:
212
+ """Multivariate linear model with several factors (and their interactions):
213
+ hypothesis SSP matrix of each term, error SSP matrix and the four
214
+ multivariate statistics, as ``car::Anova(lm(cbind(...) ~ ...), type =)``.
215
+
216
+ ``factors``: DataFrame with one column per factor. ``terms``: list such as
217
+ ``["A", "B", "A:B"]`` (default: all main effects and interactions).
218
+ ``type='II'``: each term adjusted for the terms that do not contain it;
219
+ ``type='III'``: each term adjusted for all the others (sum contrasts)."""
220
+ Y = np.asarray(Y, float)
221
+ factors = pd.DataFrame(factors).copy()
222
+ names = list(factors.columns)
223
+ if terms is None:
224
+ from itertools import combinations
225
+ terms = [":".join(c) for k in range(1, len(names) + 1) for c in combinations(names, k)]
226
+ X, blocks = _design(factors, terms)
227
+ n = len(Y)
228
+ E = _sse(Y, X)
229
+ dfe = n - np.linalg.matrix_rank(X)
230
+ out = {}
231
+ for t in terms:
232
+ tset = set(t.split(":"))
233
+ if type == "III":
234
+ keep = [c for c in range(X.shape[1]) if c not in blocks[t]]
235
+ H = _sse(Y, X[:, keep]) - E
236
+ else:
237
+ contain = [u for u in terms if u != t and tset <= set(u.split(":"))]
238
+ base = [0] + [c for u in terms if u != t and u not in contain for c in blocks[u]]
239
+ H = _sse(Y, X[:, base]) - _sse(Y, X[:, sorted(base + blocks[t])])
240
+ H = (H + H.T) / 2
241
+ ev = np.sort(np.real(np.linalg.eigvals(np.linalg.solve(E, H))))[::-1]
242
+ ev = np.where(np.abs(ev) < 1e-12, 0.0, ev)
243
+ out[t] = {"H": H, "df": len(blocks[t]), "eigenvalues": ev,
244
+ "stats": {"Pillai": np.sum(ev / (1 + ev)), "Wilks": np.prod(1 / (1 + ev)),
245
+ "Hotelling-Lawley": np.sum(ev), "Roy": ev[0]}}
246
+ return {"terms": out, "E": E, "dfe": dfe, "type": type}
247
+
248
+
249
+ def he_plot_terms(Y, factors, terms=None, type="II", variables=(0, 1), alpha=0.05, ax=None, labels=None,
250
+ level=0.68, segments=60):
251
+ """HE plot of a multi-factor MANOVA: the error ellipse and one hypothesis
252
+ ellipse per term, each scaled by the critical value of Roy's test for its
253
+ degrees of freedom (``size = 'evidence'`` of ``heplots::heplot``): a term
254
+ is significant by Roy's test at ``alpha`` exactly when its ellipse goes
255
+ outside the error ellipse in some direction."""
256
+ import matplotlib.pyplot as plt
257
+ m = manova_terms(Y, factors, terms, type)
258
+ Y = np.asarray(Y, float)
259
+ v = list(variables)
260
+ dfe = m["dfe"]; p = Y.shape[1]
261
+ radius = np.sqrt(2 * sps.f.ppf(level, 2, dfe))
262
+ th = np.linspace(0, 2 * np.pi, segments + 1)
263
+ circle = radius * np.column_stack([np.cos(th), np.sin(th)])
264
+ c = Y.mean(0)[v]
265
+
266
+ def ell(S):
267
+ ev, U = np.linalg.eigh(S)
268
+ return c + circle @ (U @ np.diag(np.sqrt(np.maximum(ev, 0)))).T
269
+ ax = ax or plt.gca()
270
+ ax.plot(*ell(m["E"][np.ix_(v, v)] / dfe).T, color="tab:red", label="Error")
271
+ cols = ["tab:blue", "tab:green", "tab:purple", "tab:orange", "tab:brown", "tab:olive", "tab:cyan"]
272
+ for k, (t, r) in enumerate(m["terms"].items()):
273
+ crit = roy_crit(alpha, p, r["df"], dfe)
274
+ sig = r["eigenvalues"][0] > crit
275
+ ax.plot(*ell(r["H"][np.ix_(v, v)] / dfe / crit).T, color=cols[k % len(cols)],
276
+ lw=2 if sig else 1, ls="-" if sig else "--", label=t + (" *" if sig else ""))
277
+ if labels:
278
+ ax.set_xlabel(labels[0]); ax.set_ylabel(labels[1])
279
+ ax.legend(fontsize=8, frameon=False)
280
+ ax.set_title(f"HE plot (type {type})")
281
+ return m