kerneltests 0.0.1__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.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: kerneltests
3
+ Version: 0.0.1
4
+ Summary: Unified, tested implementations of modern kernel hypothesis tests (MMDAgg, ...)
5
+ Author: Ankith Burki
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy>=1.22
11
+ Requires-Dist: scipy>=1.8
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: pytest-cov; extra == "test"
15
+ Provides-Extra: jax
16
+ Requires-Dist: jax>=0.4; extra == "jax"
17
+ Provides-Extra: docs
18
+ Requires-Dist: mkdocs; extra == "docs"
19
+ Requires-Dist: mkdocs-material; extra == "docs"
20
+ Requires-Dist: mkdocstrings[python]; extra == "docs"
21
+ Dynamic: license-file
22
+
23
+ # kerneltests
24
+
25
+ `kerneltests` (import name `kt`) gives you modern kernel hypothesis tests behind one small API: two-sample testing (**MMDAgg**, **MMDAggInc**, **MMD-FUSE**), goodness-of-fit testing (**KSDAgg**, **KSDAggInc**) and independence testing (**HSICAggInc**). Every port is checked against the original reference implementations by Schrab et al. and gives **bit-exact** agreement for the same seed (MMD-FUSE, whose reference is JAX, agrees to rtol 1e-6 given the same permutations) (identical decisions, statistics, quantiles, p-values and thresholds) when run with `fast=False`. The default `fast=True` path for equal sample sizes is 1.7–2.3× faster than the reference MMDAgg and agrees with it up to floating-point rounding (identical decisions in all tested cases; statistics within ~1e-12 relative). The ports also fix five confirmed bugs in the references (all listed in CHANGELOG.md), including:
26
+
27
+ 1. **MMDAgg p-values.** With `kernel="laplace_gaussian"`, the reference reports Gaussian-kernel p-values that duplicate the Laplace ones, because `mmdagg/np.py` line 352 indexes `p_vals[i]` where it should use `p_vals[idx]`.
28
+ 2. **KSDAgg internal assertion.** The reference can raise an `AssertionError` when its two equivalent rejection rules (`p-value <= threshold` and `KSD > quantile`) disagree because of floating-point rounding.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install git+<repository-url> # or, from a clone:
34
+ pip install -e .
35
+ ```
36
+
37
+ You need Python 3.9 or newer, NumPy 1.22 or newer, and SciPy 1.8 or newer.
38
+
39
+ Optional JAX backend for MMDAgg (`mmdagg(X, Y, backend="jax")`, also accepted by `kt.two_sample`):
40
+
41
+ ```bash
42
+ pip install -e .[jax]
43
+ ```
44
+
45
+ It uses the same random draws as the NumPy path and gives identical decisions; see the *Backends* section of the user guide and `experiments/jax_backend/results.md` for timings.
46
+
47
+ ## Quickstart
48
+
49
+ Two-sample test (are `X` and `Y` drawn from the same distribution?):
50
+
51
+ ```python
52
+ import numpy as np
53
+ import kt
54
+
55
+ rng = np.random.default_rng(0)
56
+ X = rng.standard_normal((200, 2))
57
+ Y = rng.standard_normal((200, 2)) + 0.5
58
+ res = kt.two_sample(X, Y)
59
+ print(res) # TestResult(reject=True, method='mmdagg', alpha=0.05)
60
+ ```
61
+
62
+ Goodness-of-fit test (does `X` come from N(0, I), whose score is `-x`?):
63
+
64
+ ```python
65
+ rng = np.random.default_rng(1)
66
+ X = rng.standard_normal((300, 2)) + 0.3
67
+ res = kt.goodness_of_fit(X, score_X=-X)
68
+ print(res.reject, int(res.details["reject"].sum()), "of", len(res.details["reject"]))
69
+ # True 10 of 10
70
+ ```
71
+
72
+ `res.details` holds diagnostics for each kernel and bandwidth (statistic, quantile, p-value, threshold, reject). See the [documentation](docs/index.md).
73
+
74
+ ## Citing
75
+
76
+ If you use these tests, please cite the original papers:
77
+
78
+ ```bibtex
79
+ @article{schrab2023mmdagg,
80
+ author = {Antonin Schrab and Ilmun Kim and M{\'e}lisande Albert and B{\'e}atrice Laurent and Benjamin Guedj and Arthur Gretton},
81
+ title = {{MMD} Aggregated Two-Sample Test},
82
+ journal = {Journal of Machine Learning Research},
83
+ year = {2023}, volume = {24}, number = {194}, pages = {1--81},
84
+ url = {http://jmlr.org/papers/v24/21-1289.html}
85
+ }
86
+ @inproceedings{schrab2022ksdagg,
87
+ author = {Antonin Schrab and Benjamin Guedj and Arthur Gretton},
88
+ title = {{KSD} Aggregated Goodness-of-fit Test},
89
+ booktitle = {Advances in Neural Information Processing Systems (NeurIPS)},
90
+ year = {2022}
91
+ }
92
+ ```
93
+
94
+ The ports are adapted from the MIT-licensed reference packages [antoninschrab/mmdagg](https://github.com/antoninschrab/mmdagg), [antoninschrab/ksdagg](https://github.com/antoninschrab/ksdagg) and [antoninschrab/mmdfuse](https://github.com/antoninschrab/mmdfuse).
95
+
96
+ ## Testing and verification
97
+
98
+ ```bash
99
+ pip install -e .[test]
100
+ pytest --cov=kt --cov-config=.coveragerc
101
+ ```
102
+
103
+ The unit tests check power against clear alternatives, Type I error control under the null (Monte Carlo, within 3 standard errors), determinism for a fixed seed, input validation, and a regression test for the p-value bug. Parity with the references is checked separately against every MMDAgg kernel option, all four weightings, both resampling schemes, and KSDAgg wild-bootstrap and parametric modes. See [docs/verification.md](docs/verification.md). CI runs on Linux, macOS and Windows with Python 3.9 to 3.13.
104
+
105
+ ## Roadmap
106
+
107
+ - GPU benchmarks for the JAX backend (implemented, untested on GPU)
108
+ - Fast path for the permutation (unequal sample size) MMDAgg calibration
109
+
110
+ ## Contributing
111
+
112
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Every new port has to match its reference implementation exactly.
113
+
114
+ ## License
115
+
116
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ kerneltests-0.0.1.dist-info/licenses/LICENSE,sha256=JPwDKdmCFInu3md_StwL2_VzYZfE4G8kEMj9787yCPc,1173
2
+ kt/__init__.py,sha256=xeWodsAPMjDWyz7YEWFsgjf3FZf9WBN4qzmlld1l900,274
3
+ kt/_common.py,sha256=r1g-vasoIimmJ_OqQJKZ82SxgcgFkBUgkpI6AN2rhvo,982
4
+ kt/agginc.py,sha256=WAkbtuYxsFFLvRxbJFpOmsLgnTF_DsscrjTwZT7nRX8,12362
5
+ kt/api.py,sha256=zM2KdCJV8n0aPp0PQZP5zNk4urekobMd4jPrz-hxf-Q,3151
6
+ kt/ksdagg.py,sha256=lSK3NEEqkxNIr2GhJQKq1Ut52zE8UPm1sS0Y1kiXBx4,5879
7
+ kt/mmdagg.py,sha256=vTC5fdPltARfYqixeiBGykV7Z3SseIC_TN1e6MajRuI,8209
8
+ kt/mmdfuse.py,sha256=YJRRVTlwODSEEAg2-PG9jSL-3T9-D6oj56dcsQHfa_4,6584
9
+ kt/backends/__init__.py,sha256=sYlzOQqwcNijrbNXHw62KGvqZqTLFolggSxgum3MeTc,594
10
+ kt/backends/jax_mmdagg.py,sha256=4vAF5D4OsECnFyXjMPE7-juswISj3iLZVOXWIkUoITQ,3986
11
+ kerneltests-0.0.1.dist-info/METADATA,sha256=TVhBVStvzmyuHxSxizWn7liUR92mYuCtDHdqYH0uo9g,5222
12
+ kerneltests-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ kerneltests-0.0.1.dist-info/top_level.txt,sha256=wqvF_VC4zbcLJ2p-d_hAHzAatbzOdJLHrbk5wUtYGTw,3
14
+ kerneltests-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,10 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ankith Burki
4
+ Portions adapted from https://github.com/antoninschrab/mmdagg, Copyright (c) 2023 Antonin Schrab (MIT).
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ kt
kt/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """kerneltests: unified, tested implementations of modern kernel hypothesis tests."""
2
+ from .api import TestResult, two_sample, goodness_of_fit, independence
3
+
4
+ __version__ = "0.1.0.dev0"
5
+ __all__ = ["TestResult", "two_sample", "goodness_of_fit", "independence", "__version__"]
kt/_common.py ADDED
@@ -0,0 +1,21 @@
1
+ """Shared helpers. Weights follow Schrab et al. (MIT reference code) in the same
2
+ floating-point evaluation order, which keeps results bit-identical to the references."""
3
+ import numpy as np
4
+
5
+ WEIGHTS = ("uniform", "decreasing", "increasing", "centred")
6
+
7
+
8
+ def weights(N: int, weights_type: str) -> np.ndarray:
9
+ if weights_type not in WEIGHTS:
10
+ raise ValueError(f"weights_type must be one of {WEIGHTS}")
11
+ if weights_type == "uniform":
12
+ return np.array([1 / N] * N)
13
+ if weights_type == "decreasing":
14
+ z = sum(1 / k for k in range(1, N + 1))
15
+ return np.array([1 / (k * z) for k in range(1, N + 1)])
16
+ if weights_type == "increasing":
17
+ z = sum(1 / k for k in range(1, N + 1))
18
+ return np.array([1 / ((N + 1 - k) * z) for k in range(1, N + 1)])
19
+ off = 1 if N % 2 == 1 else 0.5
20
+ z = sum(1 / (abs((N + 1) / 2 - k) + off) for k in range(1, N + 1))
21
+ return np.array([1 / ((abs((N + 1) / 2 - k) + off) * z) for k in range(1, N + 1)])
kt/agginc.py ADDED
@@ -0,0 +1,264 @@
1
+ """MMDAggInc, HSICAggInc and KSDAggInc: efficient aggregated kernel tests using
2
+ incomplete U-statistics (Schrab, Kim, Guedj, Gretton; NeurIPS 2022).
3
+
4
+ Re-implementation adapted from the MIT-licensed reference package
5
+ https://github.com/antoninschrab/agginc (Copyright (c) 2023 Antonin Schrab),
6
+ NumPy version ``agginc/np.py``. Same random-number protocol as the reference
7
+ (one ``RandomState(seed).choice([1., -1.], size=(N, B))`` Rademacher draw), so
8
+ results agree exactly for a given seed. Index construction, kernel evaluation
9
+ across bandwidths and the quantile bisection are vectorized; inputs are
10
+ validated with ValueError. Fixed relative to the reference: missing
11
+ ``import warnings`` (NameError on non-default ``number_bandwidths`` for hsic
12
+ or non-default ``hsic_collection_parameters`` for mmd/ksd), and the custom
13
+ HSIC bandwidth check testing ``len(bandwidths[0])`` twice instead of
14
+ ``bandwidths[1]``.
15
+ """
16
+ from __future__ import annotations
17
+ import warnings
18
+ import numpy as np
19
+ from scipy.spatial.distance import cdist, pdist
20
+
21
+ _TYPES = ("mmd", "hsic", "ksd")
22
+ _WEIGHTS = ("uniform", "decreasing", "increasing", "centred")
23
+
24
+
25
+ def _weights(N, kind):
26
+ i = np.arange(1, N + 1)
27
+ if kind == "uniform":
28
+ return np.array([1 / N] * N)
29
+ if kind in ("decreasing", "increasing"):
30
+ h = sum(1 / k for k in range(1, N + 1))
31
+ return 1 / (i * h) if kind == "decreasing" else 1 / ((N + 1 - i) * h)
32
+ if kind == "centred":
33
+ c = np.abs((N + 1) / 2 - i) + (1 if N % 2 == 1 else 0.5)
34
+ return 1 / (c * sum(1 / c))
35
+ raise ValueError(f"weights_type must be one of {_WEIGHTS}")
36
+
37
+
38
+ def _indices(N, R):
39
+ r = np.arange(1, R + 1)
40
+ lens = np.maximum(N - r, 0)
41
+ ii = np.concatenate([np.arange(L) for L in lens]) if lens.sum() else np.zeros(0, int)
42
+ jj = ii + np.repeat(r, lens)
43
+ return ii, jj
44
+
45
+
46
+ def _h_mmd(X, Y, R, bws):
47
+ N = min(X.shape[0], Y.shape[0])
48
+ ii, jj = _indices(N, R)
49
+ sq = [np.linalg.norm(A[ii] - B[jj], axis=1) ** 2 for A, B in ((X, X), (X, Y), (Y, X), (Y, Y))]
50
+ b2 = (np.asarray(bws, dtype=float) ** 2)[:, None]
51
+ k = [np.exp(-s[None, :] / b2) for s in sq]
52
+ return k[0] - k[1] - k[2] + k[3], ii, jj, N
53
+
54
+
55
+ def _h_hsic(X, Y, R, bws):
56
+ N = X.shape[0] // 2
57
+ hx, ii, jj, _ = _h_mmd(X[:N], X[N:], R, bws[0])
58
+ hy = _h_mmd(Y[:N], Y[N:], R, bws[1])[0]
59
+ return (hx[None] * hy[:, None]).reshape(hx.shape[0] * hy.shape[0], -1), ii, jj, N
60
+
61
+
62
+ def _h_ksd(X, S, R, bws, beta=-0.5):
63
+ N, d = X.shape
64
+ ii, jj = _indices(N, R)
65
+ D = X[ii] - X[jj]
66
+ nrm = np.linalg.norm(D, axis=1) ** 2
67
+ si, sj = S[ii], S[jj]
68
+ a = np.einsum("ij,ij->i", si - sj, D, optimize=True)
69
+ c = np.einsum("ij,ij->i", si, sj, optimize=True)
70
+ h = np.zeros((len(bws), len(ii)))
71
+ for r, b in enumerate(bws): # kept as a loop: identical op order to the reference
72
+ bn = b ** 2 + nrm
73
+ h[r] = (c * bn ** beta - a * 2 * beta * bn ** (beta - 1)
74
+ - 2 * d * beta * bn ** (beta - 1) - 4 * beta * (beta - 1) * nrm * bn ** (beta - 2))
75
+ return h, ii, jj, N
76
+
77
+
78
+ _H = {"mmd": _h_mmd, "hsic": _h_hsic, "ksd": _h_ksd}
79
+
80
+
81
+ def _bootstrap(h, ii, jj, N, B, seed, batch_size, memory_percentage=0.8):
82
+ eps = np.random.RandomState(seed).choice([1.0, -1.0], size=(N, B))
83
+ if batch_size is None:
84
+ bs = B
85
+ elif batch_size == "auto":
86
+ # Reference bug: it divides RAM by the bytes of a length-#bandwidths
87
+ # vector, so "auto" is always one batch of size len(ii) x B (OOM for
88
+ # large n). Size by the real per-column cost instead; batching does not
89
+ # change any result.
90
+ # psutil can over-report memory inside containers, so cap at ~256 MB per batch.
91
+ try:
92
+ import psutil
93
+ mem = min(psutil.virtual_memory().available, 2 ** 28 / memory_percentage)
94
+ except Exception: # pragma: no cover
95
+ mem = 2 ** 28
96
+ bs = int(max(1, min(B, memory_percentage * mem / (3 * 8 * max(len(ii), 1)))))
97
+ else:
98
+ bs = batch_size
99
+ out = np.zeros((h.shape[0], B))
100
+ for s in range(0, B, bs):
101
+ e = eps[:, s:s + bs]
102
+ out[:, s:s + bs] = h @ (e[ii] * e[jj])
103
+ return out / len(ii), h @ np.ones(h.shape[1]) / len(ii)
104
+
105
+
106
+ def _median(D):
107
+ return np.median(D[D > 0])
108
+
109
+
110
+ def _check_common(agg_type, X, Y, R, alpha, batch_size, memory_percentage, seed):
111
+ if agg_type not in _TYPES:
112
+ raise ValueError('agg_type should be "mmd" or "hsic" or "ksd".')
113
+ X = np.asarray(X, dtype=float); Y = np.asarray(Y, dtype=float)
114
+ if X.ndim != 2 or Y.ndim != 2:
115
+ raise ValueError("X and Y must be 2-D arrays")
116
+ if agg_type == "mmd" and X.shape[1] != Y.shape[1]:
117
+ raise ValueError("mmd: X and Y must have the same number of columns")
118
+ if agg_type == "hsic" and X.shape[0] != Y.shape[0]:
119
+ raise ValueError("hsic: X and Y must have the same number of rows")
120
+ if agg_type == "ksd" and X.shape != Y.shape:
121
+ raise ValueError("ksd: Y (score of X) must have the same shape as X")
122
+ n = min(X.shape[0], Y.shape[0]) // (2 if agg_type == "hsic" else 1)
123
+ if n < 2:
124
+ raise ValueError("too few samples (need >= 2 effective samples; hsic needs >= 4 rows)")
125
+ if not (isinstance(R, (int, np.integer)) and R >= 1):
126
+ raise ValueError("R must be a positive int")
127
+ if not 0 < alpha < 1:
128
+ raise ValueError("alpha must be in (0, 1)")
129
+ if not (batch_size is None or batch_size == "auto"
130
+ or (isinstance(batch_size, (int, np.integer)) and batch_size > 0)):
131
+ raise ValueError('batch_size must be None, "auto" or a positive int')
132
+ if not 0 < memory_percentage <= 1:
133
+ raise ValueError("memory_percentage must be in (0, 1]")
134
+ return X, Y
135
+
136
+
137
+ def agginc(agg_type, X, Y, R=200, alpha=0.05, weights_type="uniform", number_bandwidths=10,
138
+ hsic_collection_parameters=(2, -2, 2), batch_size="auto", memory_percentage=0.8,
139
+ B1=500, B2=500, B3=50, seed=42, return_details=False, bandwidths=None):
140
+ """Aggregated incomplete-U test. Returns 1 if H0 is rejected, else 0.
141
+
142
+ agg_type "mmd": two-sample (X, Y); "hsic": independence (paired X, Y);
143
+ "ksd": goodness-of-fit (Y is the score of the model at X). With
144
+ return_details=True returns (output, dict of per-test arrays).
145
+ """
146
+ X, Y = _check_common(agg_type, X, Y, R, alpha, batch_size, memory_percentage, seed)
147
+ for name, v in (("B1", B1), ("B2", B2), ("B3", B3)):
148
+ if not (isinstance(v, (int, np.integer)) and v > 0):
149
+ raise ValueError(f"{name} must be a positive int")
150
+ if weights_type not in _WEIGHTS:
151
+ raise ValueError(f"weights_type must be one of {_WEIGHTS}")
152
+ if number_bandwidths != 10 and agg_type == "hsic":
153
+ warnings.warn("number_bandwidths has no effect for hsic; use hsic_collection_parameters or bandwidths.")
154
+ if agg_type != "hsic" and tuple(hsic_collection_parameters) != (2, -2, 2):
155
+ warnings.warn("hsic_collection_parameters has no effect for mmd/ksd; use number_bandwidths or bandwidths.")
156
+
157
+ if bandwidths is not None:
158
+ if agg_type == "hsic":
159
+ if len(bandwidths) != 2 or len(bandwidths[0]) == 0 or len(bandwidths[1]) == 0:
160
+ raise ValueError("hsic bandwidths must be a pair of non-empty sequences")
161
+ bandwidths = [list(bandwidths[0]), list(bandwidths[1])]
162
+ else:
163
+ bandwidths = np.asarray(bandwidths, dtype=float)
164
+ if bandwidths.ndim != 1 or bandwidths.size == 0:
165
+ raise ValueError("bandwidths must be a non-empty 1-D array")
166
+ elif agg_type == "hsic":
167
+ if weights_type != "uniform":
168
+ raise ValueError("hsic requires weights_type='uniform'")
169
+ p = tuple(hsic_collection_parameters)
170
+ if len(p) == 3:
171
+ power, lm, lp = p; lm, lp = (lm, lm), (lp, lp)
172
+ elif len(p) == 5:
173
+ power, lmx, lpx, lmy, lpy = p; lm, lp = (lmx, lmy), (lpx, lpy)
174
+ else:
175
+ raise ValueError("hsic_collection_parameters should have length either 3 or 5.")
176
+ med = (_median(pdist(X[:500], "euclidean")), _median(pdist(Y[:500], "euclidean")))
177
+ bandwidths = [[power ** i * med[j] for i in range(lm[j], lp[j] + 1)] for j in range(2)]
178
+ if not bandwidths[0] or not bandwidths[1]:
179
+ raise ValueError("hsic_collection_parameters give an empty bandwidth collection")
180
+ else:
181
+ if not (isinstance(number_bandwidths, (int, np.integer)) and number_bandwidths > 1):
182
+ raise ValueError("number_bandwidths must be an int > 1")
183
+ if agg_type == "mmd":
184
+ D = cdist(X[:500], Y[:500], "euclidean").reshape(-1)
185
+ if np.min(D) < 1e-1:
186
+ dd = np.sort(D); lmin = np.maximum(dd[int(np.floor(len(dd) * 0.05))], 1e-1)
187
+ else:
188
+ lmin = np.min(D)
189
+ lmin = lmin / 2
190
+ lmax = np.maximum(np.max(D), 3e-1) * 2
191
+ pw = (lmax / lmin) ** (1 / (number_bandwidths - 1))
192
+ bandwidths = np.array([pw ** i * lmin for i in range(number_bandwidths)])
193
+ else:
194
+ D = pdist(X[:500], "euclidean"); D = D[D > 0]
195
+ lmax = np.maximum(np.max(D), 2)
196
+ pw = (lmax / 1) ** (1 / (number_bandwidths - 1))
197
+ bandwidths = np.array([pw ** i * 1 / X.shape[1] for i in range(number_bandwidths)])
198
+
199
+ h, ii, jj, N = _H[agg_type](X, Y, R, bandwidths)
200
+ boot, stat = _bootstrap(h, ii, jj, N, B1 + B2, seed, batch_size, memory_percentage)
201
+ nb = boot.shape[0]
202
+ w = _weights(nb, weights_type)
203
+ b1 = np.column_stack([boot[:, :B1], stat])
204
+ b1s = np.sort(b1); b2 = boot[:, B1:]
205
+ rows = np.arange(nb)
206
+
207
+ def quantiles(u):
208
+ return b1s[rows, np.ceil((B1 + 1) * (1 - u * w)).astype(int) - 1]
209
+
210
+ lo, hi = 0.0, np.min(1 / w)
211
+ for _ in range(B3):
212
+ u = (hi + lo) / 2
213
+ if np.sum(np.max(b2 - quantiles(u)[:, None], 0) > 0) / B2 <= alpha:
214
+ lo = u
215
+ else:
216
+ hi = u
217
+ q = quantiles(lo)
218
+ pv = np.mean(b1 - stat[:, None] >= 0, -1)
219
+ rej = stat > q
220
+ out = int(rej.any())
221
+ if not return_details:
222
+ return out
223
+ if agg_type == "hsic":
224
+ bwx = np.array([bandwidths[0][i] for j in range(len(bandwidths[1])) for i in range(len(bandwidths[0]))])
225
+ bwy = np.array([bandwidths[1][j] for j in range(len(bandwidths[1])) for i in range(len(bandwidths[0]))])
226
+ bwinfo = {"bandwidth_x": bwx, "bandwidth_y": bwy}
227
+ else:
228
+ bwinfo = {"bandwidth": np.asarray(bandwidths)}
229
+ return out, {"kernel": "imq" if agg_type == "ksd" else "gaussian", **bwinfo, "statistic": stat,
230
+ "quantile": q, "p_value": pv, "threshold": lo * w, "reject": rej}
231
+
232
+
233
+ def inc(agg_type, X, Y, R=200, alpha=0.05, batch_size="auto", memory_percentage=0.8, B=500,
234
+ seed=42, return_details=False, bandwidth=None):
235
+ """Single-bandwidth incomplete-U test (median heuristic unless bandwidth given)."""
236
+ X, Y = _check_common(agg_type, X, Y, R, alpha, batch_size, memory_percentage, seed)
237
+ if not (isinstance(B, (int, np.integer)) and B > 0):
238
+ raise ValueError("B must be a positive int")
239
+ if bandwidth is not None:
240
+ if agg_type == "hsic":
241
+ if len(bandwidth) != 2:
242
+ raise ValueError("hsic bandwidth must be a pair (bandwidth_X, bandwidth_Y)")
243
+ bws = (np.array(bandwidth[0]).reshape(1), np.array(bandwidth[1]).reshape(1))
244
+ else:
245
+ bws = np.array(bandwidth).reshape(1)
246
+ elif agg_type == "mmd":
247
+ bws = np.array([_median(cdist(X[:500], Y[:500], "euclidean").reshape(-1))])
248
+ elif agg_type == "hsic":
249
+ bws = (np.array([_median(pdist(X[:500], "euclidean"))]), np.array([_median(pdist(Y[:500], "euclidean"))]))
250
+ else:
251
+ bws = np.array([_median(pdist(X[:500], "euclidean"))])
252
+ h, ii, jj, N = _H[agg_type](X, Y, R, bws)
253
+ boot, stat = _bootstrap(h, ii, jj, N, B, seed, batch_size, memory_percentage)
254
+ stat = stat[0]
255
+ b1 = np.column_stack([boot, stat])
256
+ q = np.sort(b1)[0, int(np.ceil((B + 1) * (1 - alpha))) - 1]
257
+ pv = np.mean(b1 - stat >= 0, -1)[0]
258
+ out = int(stat > q)
259
+ if not return_details:
260
+ return out
261
+ bwinfo = ({"bandwidth_x": bws[0][0], "bandwidth_y": bws[1][0]} if agg_type == "hsic"
262
+ else {"bandwidth": bws[0]})
263
+ return out, {"kernel": "imq" if agg_type == "ksd" else "gaussian", **bwinfo, "statistic": stat,
264
+ "quantile": q, "p_value": pv, "threshold": alpha, "reject": bool(stat > q)}
kt/api.py ADDED
@@ -0,0 +1,74 @@
1
+ """Unified entry points: one function per testing problem, one result type."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Dict
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class TestResult:
11
+ """Outcome of a kernel hypothesis test.
12
+
13
+ reject -- True if the null hypothesis is rejected at level ``alpha``.
14
+ method -- name of the test that was run.
15
+ alpha -- significance level used.
16
+ details -- per-kernel/bandwidth diagnostics (statistics, quantiles, p-values, ...).
17
+ """
18
+ reject: bool
19
+ method: str
20
+ alpha: float
21
+ details: Dict[str, Any] = field(default_factory=dict, repr=False)
22
+
23
+ def __bool__(self) -> bool:
24
+ return self.reject
25
+
26
+
27
+ def _run(fn: Callable, method: str, alpha: float, *args, **kwargs) -> TestResult:
28
+ out, details = fn(*args, alpha=alpha, return_details=True, **kwargs)
29
+ return TestResult(reject=bool(out), method=method, alpha=alpha, details=details)
30
+
31
+
32
+ def two_sample(X, Y, method: str = "mmdagg", alpha: float = 0.05, **kwargs) -> TestResult:
33
+ """Test H0: X and Y are drawn from the same distribution.
34
+
35
+ method: "mmdagg" (Schrab et al., JMLR 2023; quadratic time) or
36
+ "mmdagginc" (Schrab et al., NeurIPS 2022; incomplete U-statistics, faster for large n) or
37
+ "mmdfuse" (Biggs, Schrab, Gretton, NeurIPS 2023; fused soft-max over kernels, permutations).
38
+ """
39
+ if method == "mmdagg":
40
+ from .mmdagg import mmdagg
41
+ return _run(mmdagg, method, alpha, X, Y, **kwargs)
42
+ if method == "mmdagginc":
43
+ from .agginc import agginc
44
+ return _run(lambda *a, **k: agginc("mmd", *a, **k), method, alpha, X, Y, **kwargs)
45
+ if method == "mmdfuse":
46
+ from .mmdfuse import mmdfuse
47
+ return _run(mmdfuse, method, alpha, X, Y, **kwargs)
48
+ raise ValueError(f"unknown two-sample method {method!r}; available: 'mmdagg', 'mmdagginc', 'mmdfuse'")
49
+
50
+
51
+ def independence(X, Y, method: str = "hsicagginc", alpha: float = 0.05, **kwargs) -> TestResult:
52
+ """Test H0: paired samples X[i], Y[i] come from independent variables.
53
+
54
+ method: "hsicagginc" (Schrab et al., NeurIPS 2022).
55
+ """
56
+ if method == "hsicagginc":
57
+ from .agginc import agginc
58
+ return _run(lambda *a, **k: agginc("hsic", *a, **k), method, alpha, X, Y, **kwargs)
59
+ raise ValueError(f"unknown independence method {method!r}; available: 'hsicagginc'")
60
+
61
+
62
+ def goodness_of_fit(X, score_X, method: str = "ksdagg", alpha: float = 0.05, **kwargs) -> TestResult:
63
+ """Test H0: X is drawn from the model whose score function values at X are ``score_X``.
64
+
65
+ method: "ksdagg" (Schrab, Guedj, Gretton, NeurIPS 2022) or "ksdagginc" (incomplete U-statistics).
66
+ """
67
+ if method == "ksdagg":
68
+ from .ksdagg import ksdagg
69
+ X = np.asarray(X, dtype=float)
70
+ return _run(ksdagg, method, alpha, X, score_X, **kwargs)
71
+ if method == "ksdagginc":
72
+ from .agginc import agginc
73
+ return _run(lambda *a, **k: agginc("ksd", *a, **k), method, alpha, X, score_X, **kwargs)
74
+ raise ValueError(f"unknown goodness-of-fit method {method!r}; available: 'ksdagg', 'ksdagginc'")
@@ -0,0 +1,15 @@
1
+ """Optional compute backends. Each backend module is imported lazily."""
2
+
3
+
4
+ def require_jax():
5
+ """Import and return ``jax`` (with float64 enabled) or raise a helpful ImportError."""
6
+ try:
7
+ import jax
8
+ except ImportError as e:
9
+ raise ImportError(
10
+ "backend='jax' requires the optional dependency JAX. Install it with "
11
+ "`pip install kerneltests[jax]` (or `pip install \"jax[cpu]\"`; see "
12
+ "https://jax.readthedocs.io for GPU wheels), or use backend='numpy'."
13
+ ) from e
14
+ jax.config.update("jax_enable_x64", True)
15
+ return jax
@@ -0,0 +1,107 @@
1
+ """JAX backend for MMDAgg: jit-compiled kernel matrices and bootstrap/permutation statistics.
2
+
3
+ The random draws (Rademacher matrix R or permutation matrices V) are generated by the
4
+ NumPy ``RandomState`` protocol in :func:`kt.mmdagg.mmdagg` and passed in, so both
5
+ backends see identical resamples. Distances, kernel matrices (all bandwidths of one
6
+ kernel batched with ``jax.vmap``) and the quadratic forms r^T K r are computed in JAX
7
+ in float64. Runs on whatever device JAX selects (CPU, or GPU with a CUDA jaxlib).
8
+ """
9
+ from __future__ import annotations
10
+ from functools import partial
11
+
12
+ import numpy as np
13
+
14
+ from . import require_jax
15
+
16
+ jax = require_jax()
17
+ import jax.numpy as jnp # noqa: E402
18
+
19
+ _MEM_BUDGET = 1 << 30 # bytes of kernel matrices materialised per vmap batch
20
+
21
+
22
+ def _kernel(d, base):
23
+ if base == "gaussian":
24
+ return jnp.exp(-d ** 2)
25
+ if base == "imq":
26
+ return (1 + d ** 2) ** (-0.5)
27
+ if base == "matern_0.5":
28
+ return jnp.exp(-d)
29
+ if base == "matern_1.5":
30
+ return (1 + np.sqrt(3) * d) * jnp.exp(-np.sqrt(3) * d)
31
+ if base == "matern_2.5":
32
+ return (1 + np.sqrt(5) * d + 5 / 3 * d ** 2) * jnp.exp(-np.sqrt(5) * d)
33
+ if base == "matern_3.5":
34
+ return (1 + np.sqrt(7) * d + 2 * 7 / 5 * d ** 2 + 7 * np.sqrt(7) / 3 / 5 * d ** 3) * jnp.exp(-np.sqrt(7) * d)
35
+ if base == "matern_4.5":
36
+ return (1 + 3 * d + 3 * (6 ** 2) / 28 * d ** 2 + (6 ** 3) / 84 * d ** 3
37
+ + (6 ** 4) / 1680 * d ** 4) * jnp.exp(-3 * d)
38
+ raise ValueError(f"unknown kernel {base!r}")
39
+
40
+
41
+ def _base(kernel):
42
+ if kernel in ("gaussian", "imq"):
43
+ return kernel
44
+ return "matern_0.5" if kernel == "laplace" else kernel[:10]
45
+
46
+
47
+ @partial(jax.jit, static_argnames="metric")
48
+ def _pairwise(A, B, metric):
49
+ diff = A[:, None, :] - B[None, :, :]
50
+ if metric == "cityblock":
51
+ return jnp.sum(jnp.abs(diff), -1)
52
+ return jnp.sqrt(jnp.sum(diff ** 2, -1))
53
+
54
+
55
+ @partial(jax.jit, static_argnames="base")
56
+ def _wild(Pxx, Pyy, Pxy, bws, Rn, base):
57
+ n = Pxx.shape[0]
58
+ off = 1.0 - jnp.eye(n)
59
+
60
+ def one(bw):
61
+ H = (_kernel(Pxx / bw, base) + _kernel(Pyy / bw, base)
62
+ - _kernel(Pxy / bw, base) - _kernel(Pxy.T / bw, base)) * off
63
+ return jnp.sum(Rn * (H @ Rn), 0)
64
+ return jax.vmap(one)(bws)
65
+
66
+
67
+ @partial(jax.jit, static_argnames=("base", "m", "n"))
68
+ def _perm(P, bws, V10, V01, V11, base, m, n):
69
+ off = 1.0 - jnp.eye(P.shape[0])
70
+
71
+ def one(bw):
72
+ K = _kernel(P / bw, base) * off
73
+ return (jnp.sum(V10 * (K @ V10), 0) * (n - m + 1) / (m * n * (m - 1))
74
+ + jnp.sum(V01 * (K @ V01), 0) * (m - n + 1) / (m * n * (n - 1))
75
+ + jnp.sum(V11 * (K @ V11), 0) / (m * n))
76
+ return jax.vmap(one)(bws)
77
+
78
+
79
+ def statistics(X, Y, kbl, wild, draws):
80
+ """Return the (#kernels*#bandwidths, B1+B2+1) matrix of bootstrap/permutation statistics.
81
+
82
+ kbl: list of (kernel name, bandwidths, metric); draws: (Rn,) if wild else (V10, V01, V11).
83
+ """
84
+ m, n = X.shape[0], Y.shape[0]
85
+ Xj, Yj = jnp.asarray(X), jnp.asarray(Y)
86
+ side = n if wild else m + n
87
+ per_bw = (4 if wild else 1) * side * side * 8
88
+ batch = max(1, _MEM_BUDGET // per_bw)
89
+ dev = [jnp.asarray(a) for a in draws]
90
+ cache, rows = {}, []
91
+ for kern, bandwidths, metric in kbl:
92
+ if metric not in cache:
93
+ if wild:
94
+ cache = {metric: (_pairwise(Xj, Xj, metric), _pairwise(Yj, Yj, metric),
95
+ _pairwise(Xj, Yj, metric))}
96
+ else:
97
+ Z = jnp.vstack((Xj, Yj))
98
+ cache = {metric: (_pairwise(Z, Z, metric),)}
99
+ P = cache[metric]
100
+ bws = jnp.asarray(bandwidths, dtype=jnp.float64)
101
+ for s in range(0, len(bandwidths), batch):
102
+ b = bws[s:s + batch]
103
+ if wild:
104
+ rows.append(_wild(*P, b, dev[0], base=_base(kern)))
105
+ else:
106
+ rows.append(_perm(P[0], b, *dev, base=_base(kern), m=m, n=n))
107
+ return np.asarray(jnp.concatenate(rows, 0))
kt/ksdagg.py ADDED
@@ -0,0 +1,129 @@
1
+ """KSDAgg goodness-of-fit test (Schrab, Guedj, Gretton; NeurIPS 2022).
2
+
3
+ Re-implementation adapted from the MIT-licensed reference package
4
+ https://github.com/antoninschrab/ksdagg (Copyright (c) 2023 Antonin Schrab).
5
+ Same random-number protocol and floating-point formulas as the reference
6
+ (``ksdagg/np.py``), so results agree exactly for a given seed; the Stein
7
+ kernel shares its bandwidth-independent terms, and the bisection step and
8
+ quantile lookup are vectorized.
9
+
10
+ Supported: kernel "imq" (the reference's only kernel), approx_type
11
+ "wild bootstrap" and "parametric", all four weights_type values, and a
12
+ user-supplied bandwidth collection. Differences from the reference:
13
+ ValueError validation instead of asserts; ``bandwidths`` may be any 1-D
14
+ array-like (the reference silently ignores non-ndarray inputs); for
15
+ "parametric", B1/B2 must have shape (number_bandwidths, B) -- the shape the
16
+ reference code actually uses, not the transposed shape in its docstring --
17
+ and number_bandwidths is taken from B1.shape[0] when bandwidths is None.
18
+ """
19
+ from __future__ import annotations
20
+ import numpy as np
21
+ from scipy.spatial.distance import pdist
22
+ from ._common import WEIGHTS as _WEIGHTS, weights as _weights
23
+
24
+
25
+
26
+ def _stein_imq(X, S, bandwidths):
27
+ """Yield the zero-diagonal IMQ Stein kernel matrix (beta = 1/2) per bandwidth."""
28
+ beta = 0.5
29
+ p = X.shape[1]
30
+ norms = np.sum(X ** 2, -1)
31
+ dists = -2 * X @ X.T + norms[:, None] + norms[None, :]
32
+ diffs = np.sum(X * S, -1)[:, None] - X @ S.T
33
+ diffs = diffs + diffs.T
34
+ SS = S @ S.T
35
+ for bw in bandwidths:
36
+ g = 1 / bw ** 2
37
+ res = 1 + g * dists
38
+ r1 = res ** (-beta - 1)
39
+ H = (SS * res ** (-beta) + 2 * beta * g * r1 * diffs
40
+ + 2 * (beta * g * r1 * p
41
+ - 2 * beta * (beta + 1) * g ** 2 * dists * res ** (-beta - 2)))
42
+ np.fill_diagonal(H, 0)
43
+ yield H
44
+
45
+
46
+ def ksdagg(X, score_X, alpha=0.05, number_bandwidths=10, weights_type="uniform",
47
+ approx_type="wild bootstrap", kernel="imq", B1=2000, B2=2000, B3=50,
48
+ seed=42, bandwidths=None, return_details=False):
49
+ """Return 1 if KSDAgg rejects H0: X ~ model (whose score at X is score_X), else 0."""
50
+ X = np.asarray(X, dtype=float); S = np.asarray(score_X, dtype=float)
51
+ if X.ndim != 2 or X.shape != S.shape:
52
+ raise ValueError("X and score_X must be 2-D arrays of the same shape")
53
+ m = X.shape[0]
54
+ if m < 2:
55
+ raise ValueError("need at least 2 samples")
56
+ if not 0 < alpha < 1:
57
+ raise ValueError("alpha must be in (0, 1)")
58
+ if kernel != "imq":
59
+ raise ValueError(f"unsupported kernel {kernel!r}; only 'imq'")
60
+ if weights_type not in _WEIGHTS:
61
+ raise ValueError(f"weights_type must be one of {_WEIGHTS}")
62
+ if not (isinstance(B3, int) and B3 > 0):
63
+ raise ValueError("B3 must be a positive int")
64
+ if approx_type == "wild bootstrap":
65
+ for name, v in (("B1", B1), ("B2", B2)):
66
+ if not (isinstance(v, int) and v > 0):
67
+ raise ValueError(f"{name} must be a positive int")
68
+ P1 = P2 = None
69
+ elif approx_type == "parametric":
70
+ P1 = np.asarray(B1, dtype=float); P2 = np.asarray(B2, dtype=float)
71
+ if P1.ndim != 2 or P2.ndim != 2 or P1.shape[0] != P2.shape[0] or min(P1.shape + P2.shape) < 1:
72
+ raise ValueError("parametric B1, B2 must be non-empty 2-D arrays (number_bandwidths, B)")
73
+ B1, B2 = P1.shape[1], P2.shape[1]
74
+ if bandwidths is None:
75
+ number_bandwidths = P1.shape[0]
76
+ else:
77
+ raise ValueError("approx_type must be 'wild bootstrap' or 'parametric'")
78
+
79
+ if bandwidths is not None:
80
+ bandwidths = np.asarray(bandwidths, dtype=float)
81
+ if bandwidths.ndim != 1 or len(bandwidths) < 1 or np.any(bandwidths <= 0):
82
+ raise ValueError("bandwidths must be a non-empty 1-D array of positive values")
83
+ number_bandwidths = len(bandwidths)
84
+ if not (isinstance(number_bandwidths, int) and number_bandwidths > 1) and bandwidths is None:
85
+ raise ValueError("number_bandwidths must be an int > 1")
86
+ if P1 is not None and P1.shape[0] != number_bandwidths:
87
+ raise ValueError("parametric B1/B2 must have one row per bandwidth")
88
+ if bandwidths is None:
89
+ d = pdist(X[:500], "euclidean"); d = d[d > 0]
90
+ lam_max = np.maximum(np.max(d), 2) if d.size else 2.0
91
+ power = (lam_max / 1) ** (1 / (number_bandwidths - 1))
92
+ bandwidths = np.array([power ** i * 1 / X.shape[1] for i in range(number_bandwidths)])
93
+ N = number_bandwidths
94
+ w = _weights(N, weights_type)
95
+
96
+ if P1 is None:
97
+ rs = np.random.RandomState(seed)
98
+ R = rs.choice([1.0, -1.0], size=(B1 + B2 + 1, m)); R[B1] = 1.0
99
+ R = R.T
100
+ M = np.empty((N, B1 + B2 + 1))
101
+ for i, H in enumerate(_stein_imq(X, S, bandwidths)):
102
+ M[i] = np.sum(R * (H @ R), 0) / (m * (m - 1))
103
+ stat = M[:, B1]
104
+ M1 = np.sort(M[:, :B1 + 1]); M2 = M[:, B1 + 1:]
105
+ else:
106
+ r = np.ones(m)
107
+ stat = np.array([r @ H @ r / (m * (m - 1)) for H in _stein_imq(X, S, bandwidths)])
108
+ M1 = np.sort(np.concatenate((P1, stat[:, None]), axis=1)); M2 = P2
109
+
110
+ rows = np.arange(N)
111
+ def quantiles(u):
112
+ return M1[rows, np.ceil((B1 + 1) * (1 - u * w)).astype(int) - 1]
113
+
114
+ u_lo, u_hi = 0, np.min(1 / w)
115
+ for _ in range(B3):
116
+ u = (u_hi + u_lo) / 2
117
+ if np.sum(np.max(M2 - quantiles(u)[:, None], 0) > 0) / B2 <= alpha:
118
+ u_lo = u
119
+ else:
120
+ u_hi = u
121
+ u = u_lo
122
+ q = quantiles(u)
123
+ reject_each = stat > q
124
+ out = int(reject_each.any())
125
+ if not return_details:
126
+ return out
127
+ pvals = np.mean(M1 - stat[:, None] >= 0, -1)
128
+ return out, {"kernel": ["imq"] * N, "bandwidth": np.asarray(bandwidths, float), "ksd": stat,
129
+ "quantile": q, "p_value": pvals, "threshold": u * w, "reject": reject_each}
kt/mmdagg.py ADDED
@@ -0,0 +1,177 @@
1
+ """MMDAgg two-sample test (Schrab, Kim, Albert, Laurent, Guedj, Gretton; JMLR 2023).
2
+
3
+ Re-implementation adapted from the MIT-licensed reference package
4
+ https://github.com/antoninschrab/mmdagg (Copyright (c) 2023 Antonin Schrab).
5
+ Same random-number protocol as the reference, so results agree exactly for a
6
+ given seed; the bisection step and quantile lookup are vectorized, and the
7
+ per-test p-value reporting bug of the reference (p_vals[i] vs p_vals[idx]) is fixed.
8
+ """
9
+ from __future__ import annotations
10
+ import numpy as np
11
+ from scipy.spatial.distance import cdist
12
+ from ._common import WEIGHTS as _WEIGHTS, weights as _weights
13
+
14
+ _L1 = ("laplace",) + tuple(f"matern_{i}.5_l1" for i in range(5))
15
+ _L2 = ("gaussian", "imq") + tuple(f"matern_{i}.5_l2" for i in range(5))
16
+
17
+
18
+ def _bandwidths(distances: np.ndarray, number: int) -> np.ndarray:
19
+ if np.min(distances) < 1e-1:
20
+ d = np.sort(distances)
21
+ lam_min = np.maximum(d[int(np.floor(len(d) * 0.05))], 1e-1)
22
+ else:
23
+ lam_min = np.min(distances)
24
+ lam_min = lam_min / 2
25
+ lam_max = np.maximum(np.max(distances), 3e-1) * 2
26
+ power = (lam_max / lam_min) ** (1 / (number - 1))
27
+ return np.array([power ** i * lam_min for i in range(number)])
28
+
29
+
30
+ def _kernel(pairwise: np.ndarray, kernel: str, bandwidth: float) -> np.ndarray:
31
+ d = pairwise / bandwidth
32
+ if kernel == "gaussian":
33
+ return np.exp(-d ** 2)
34
+ if kernel == "imq":
35
+ return (1 + d ** 2) ** (-0.5)
36
+ base = "matern_0.5" if kernel == "laplace" else kernel[:10]
37
+ if base == "matern_0.5":
38
+ return np.exp(-d)
39
+ if base == "matern_1.5":
40
+ return (1 + np.sqrt(3) * d) * np.exp(-np.sqrt(3) * d)
41
+ if base == "matern_2.5":
42
+ return (1 + np.sqrt(5) * d + 5 / 3 * d ** 2) * np.exp(-np.sqrt(5) * d)
43
+ if base == "matern_3.5":
44
+ return (1 + np.sqrt(7) * d + 2 * 7 / 5 * d ** 2 + 7 * np.sqrt(7) / 3 / 5 * d ** 3) * np.exp(-np.sqrt(7) * d)
45
+ if base == "matern_4.5":
46
+ return (1 + 3 * d + 3 * (6 ** 2) / 28 * d ** 2 + (6 ** 3) / 84 * d ** 3 + (6 ** 4) / 1680 * d ** 4) * np.exp(-3 * d)
47
+ raise ValueError(f"unknown kernel {kernel!r}")
48
+
49
+
50
+ def mmdagg(X, Y, alpha=0.05, kernel="laplace_gaussian", number_bandwidths=10,
51
+ weights_type="uniform", B1=2000, B2=2000, B3=50, seed=42, permutations_same_sample_size=False,
52
+ return_details=False, fast=True, backend="numpy"):
53
+ """Return 1 if MMDAgg rejects H0: P_X = P_Y at level alpha, else 0.
54
+
55
+ fast: for equal sample sizes (wild bootstrap), evaluate r^T H r with the n x n matrix
56
+ H = Kxx + Kyy - Kxy - Kyx instead of the reference's 2n x 2n product (about 4x fewer
57
+ flops; mathematically identical, equal to the reference up to floating-point rounding).
58
+ Set fast=False to reproduce the reference bit-for-bit.
59
+ backend: "numpy" (default) or "jax". The JAX backend (optional dependency,
60
+ ``pip install kerneltests[jax]``) builds all kernel matrices and resampled statistics in
61
+ jit-compiled float64 code on the default JAX device (CPU or GPU). Random draws are the
62
+ same NumPy ones, so results match backend="numpy" up to floating-point rounding.
63
+ ``fast`` is ignored by the JAX backend (it always uses the n x n wild-bootstrap form).
64
+ """
65
+ if backend not in ("numpy", "jax"):
66
+ raise ValueError("backend must be 'numpy' or 'jax'")
67
+ if backend == "jax":
68
+ from .backends.jax_mmdagg import statistics as _jax_statistics
69
+ X = np.asarray(X, dtype=float); Y = np.asarray(Y, dtype=float)
70
+ if X.ndim != 2 or Y.ndim != 2 or X.shape[1] != Y.shape[1]:
71
+ raise ValueError("X and Y must be 2-D arrays with the same number of columns")
72
+ m, n = X.shape[0], Y.shape[0]
73
+ if m < 2 or n < 2:
74
+ raise ValueError("need at least 2 samples in each of X and Y")
75
+ if not 0 < alpha < 1:
76
+ raise ValueError("alpha must be in (0, 1)")
77
+ if not (isinstance(number_bandwidths, int) and number_bandwidths > 1):
78
+ raise ValueError("number_bandwidths must be an int > 1")
79
+ for name, v in (("B1", B1), ("B2", B2), ("B3", B3)):
80
+ if not (isinstance(v, int) and v > 0):
81
+ raise ValueError(f"{name} must be a positive int")
82
+ wild = (m == n) and not permutations_same_sample_size
83
+
84
+ ms = 500
85
+ bw_l1 = _bandwidths(cdist(X[:ms], Y[:ms], "cityblock").ravel(), number_bandwidths)
86
+ bw_l2 = _bandwidths(cdist(X[:ms], Y[:ms], "euclidean").ravel(), number_bandwidths)
87
+ if kernel in _L1:
88
+ kbl = [(kernel, bw_l1, "cityblock")]
89
+ elif kernel in _L2:
90
+ kbl = [(kernel, bw_l2, "euclidean")]
91
+ elif kernel in ("laplace_gaussian", "gaussian_laplace"):
92
+ kbl = [("laplace", bw_l1, "cityblock"), ("gaussian", bw_l2, "euclidean")]
93
+ elif kernel == "all_matern_l1":
94
+ kbl = [(f"matern_{i}.5_l1", bw_l1, "cityblock") for i in range(5)]
95
+ elif kernel == "all_matern_l2":
96
+ kbl = [(f"matern_{i}.5_l2", bw_l2, "euclidean") for i in range(5)]
97
+ elif kernel in ("all_matern_l1_l2", "all"):
98
+ kbl = ([(f"matern_{i}.5_l1", bw_l1, "cityblock") for i in range(5)]
99
+ + [(f"matern_{i}.5_l2", bw_l2, "euclidean") for i in range(5)])
100
+ if kernel == "all":
101
+ kbl += [("gaussian", bw_l2, "euclidean"), ("imq", bw_l2, "euclidean")]
102
+ else:
103
+ raise ValueError(f"unsupported kernel {kernel!r}")
104
+ if weights_type not in _WEIGHTS:
105
+ raise ValueError(f"weights_type must be one of {_WEIGHTS}")
106
+ w = _weights(number_bandwidths, weights_type) / len(kbl)
107
+
108
+ rs = np.random.RandomState(seed)
109
+ Btot = B1 + B2 + 1
110
+ if wild:
111
+ R = rs.choice([-1.0, 1.0], size=(Btot, n)); R[B1] = 1.0
112
+ Rn = R.T.copy()
113
+ R = np.concatenate((R.T, -R.T))
114
+ else:
115
+ idx = rs.rand(Btot, m + n).argsort(axis=1)
116
+ def perm(v):
117
+ V = np.take_along_axis(np.tile(v, (Btot, 1)), idx, axis=1); V[B1] = v; return V.T
118
+ V11 = perm(np.r_[np.ones(m), -np.ones(n)])
119
+ V10 = perm(np.r_[np.ones(m), np.zeros(n)])
120
+ V01 = perm(np.r_[np.zeros(m), -np.ones(n)])
121
+
122
+ N = number_bandwidths * len(kbl)
123
+ if backend == "jax":
124
+ M = _jax_statistics(X, Y, kbl, wild, (Rn,) if wild else (V10, V01, V11))
125
+ bws = np.concatenate([b for _, b, _ in kbl])
126
+ names = [k for k, b, _ in kbl for _ in b]
127
+ kbl_loop = []
128
+ else:
129
+ M = np.empty((N, Btot)); bws = np.empty(N); names = []
130
+ kbl_loop = kbl
131
+ Z = np.vstack((X, Y))
132
+ last = None
133
+ for j, (kern, bandwidths, metric) in enumerate(kbl_loop):
134
+ if metric != last:
135
+ P = cdist(Z, Z, metric); last = metric
136
+ for i, bw in enumerate(bandwidths):
137
+ K = _kernel(P, kern, bw)
138
+ np.fill_diagonal(K, 0)
139
+ if wild:
140
+ np.fill_diagonal(K[:n, n:], 0); np.fill_diagonal(K[n:, :n], 0)
141
+ if fast:
142
+ H = K[:n, :n] + K[n:, n:] - K[:n, n:] - K[n:, :n]
143
+ M[j * number_bandwidths + i] = np.sum(Rn * (H @ Rn), 0)
144
+ else:
145
+ M[j * number_bandwidths + i] = np.sum(R * (K @ R), 0)
146
+ else:
147
+ M[j * number_bandwidths + i] = (
148
+ np.sum(V10 * (K @ V10), 0) * (n - m + 1) / (m * n * (m - 1))
149
+ + np.sum(V01 * (K @ V01), 0) * (m - n + 1) / (m * n * (n - 1))
150
+ + np.sum(V11 * (K @ V11), 0) / (m * n))
151
+ bws[j * number_bandwidths + i] = bw; names.append(kern)
152
+
153
+ stat = M[:, B1]
154
+ M1 = np.sort(M[:, :B1 + 1]); M2 = M[:, B1 + 1:]
155
+ allw = np.tile(w, len(kbl))
156
+ rows = np.arange(N)
157
+
158
+ def quantiles(u):
159
+ cols = np.ceil((B1 + 1) * (1 - u * allw)).astype(int) - 1
160
+ return M1[rows, cols]
161
+
162
+ u_lo, u_hi = 0.0, float(np.min(1 / w))
163
+ for _ in range(B3):
164
+ u = (u_lo + u_hi) / 2
165
+ if np.mean(np.max(M2 - quantiles(u)[:, None], 0) > 0) <= alpha:
166
+ u_lo = u
167
+ else:
168
+ u_hi = u
169
+ u = u_lo
170
+ q = quantiles(u)
171
+ reject_each = stat > q
172
+ out = int(reject_each.any())
173
+ if not return_details:
174
+ return out
175
+ pvals = np.mean(M1 - stat[:, None] >= 0, -1)
176
+ return out, {"kernel": names, "bandwidth": bws, "mmd": stat, "quantile": q,
177
+ "p_value": pvals, "threshold": u * allw, "reject": reject_each}
kt/mmdfuse.py ADDED
@@ -0,0 +1,141 @@
1
+ """MMD-FUSE two-sample test (Biggs, Schrab, Gretton; NeurIPS 2023).
2
+
3
+ NumPy re-implementation adapted from the MIT-licensed JAX reference package
4
+ https://github.com/antoninschrab/mmdfuse (Copyright (c) 2023 Antonin Schrab).
5
+ The statistic, bandwidth collection and permutation calibration follow the
6
+ reference in the same evaluation order. JAX's random-number generator cannot be
7
+ reproduced in NumPy, so the permutations are drawn from ``np.random.RandomState(seed)``;
8
+ given the same permutations (private ``_permutations`` hook) the results agree with
9
+ the reference up to floating-point rounding (see ``check_mmdfuse_vs_reference.py``).
10
+ """
11
+ from __future__ import annotations
12
+ import numpy as np
13
+ from scipy.spatial.distance import cdist
14
+ from scipy.special import logsumexp
15
+
16
+ _L1 = ("laplace",) + tuple(f"matern_{i}.5_l1" for i in range(5))
17
+ _L2 = ("imq", "rq", "gaussian") + tuple(f"matern_{i}.5_l2" for i in range(5))
18
+ KERNELS = _L1 + _L2
19
+
20
+
21
+ def _kernel(pairwise: np.ndarray, kernel: str, bandwidth: float, rq_exponent: float = 0.5) -> np.ndarray:
22
+ d = pairwise / bandwidth
23
+ if kernel == "gaussian":
24
+ return np.exp(-(d ** 2) / 2)
25
+ if kernel == "laplace":
26
+ return np.exp(-d * np.sqrt(2))
27
+ if kernel == "rq":
28
+ return (1 + d ** 2 / (2 * rq_exponent)) ** (-rq_exponent)
29
+ if kernel == "imq":
30
+ return (1 + d ** 2) ** (-0.5)
31
+ base = kernel[:10]
32
+ if base == "matern_0.5":
33
+ return np.exp(-d)
34
+ if base == "matern_1.5":
35
+ return (1 + np.sqrt(3) * d) * np.exp(-np.sqrt(3) * d)
36
+ if base == "matern_2.5":
37
+ return (1 + np.sqrt(5) * d + 5 / 3 * d ** 2) * np.exp(-np.sqrt(5) * d)
38
+ if base == "matern_3.5":
39
+ return (1 + np.sqrt(7) * d + 2 * 7 / 5 * d ** 2 + 7 * np.sqrt(7) / 3 / 5 * d ** 3) * np.exp(-np.sqrt(7) * d)
40
+ return (1 + 3 * d + 3 * (6 ** 2) / 28 * d ** 2 + (6 ** 3) / 84 * d ** 3
41
+ + (6 ** 4) / 1680 * d ** 4) * np.exp(-3 * d) # matern_4.5
42
+
43
+
44
+ def _bandwidths(distances: np.ndarray, number: int) -> np.ndarray:
45
+ median = np.median(distances)
46
+ distances = distances + (distances == 0) * median
47
+ dd = np.sort(distances)
48
+ lam_min = dd[int(np.floor(len(dd) * 0.05))] / 2
49
+ lam_max = dd[int(np.floor(len(dd) * 0.95))] * 2
50
+ return np.linspace(lam_min, lam_max, number)
51
+
52
+
53
+ def mmdfuse(X, Y, alpha=0.05, kernels=("laplace", "gaussian"), lambda_multiplier=1,
54
+ number_bandwidths=10, number_permutations=2000, seed=42, return_details=False,
55
+ _permutations=None):
56
+ """Return 1 if MMD-FUSE rejects H0: P_X = P_Y at level alpha, else 0.
57
+
58
+ MMD-FUSE fuses normalised MMD estimates over ``kernels`` x ``number_bandwidths``
59
+ bandwidths with a soft maximum (log-mean-exp, temperature ``lambda_multiplier``) and
60
+ calibrates the fused statistic with ``number_permutations`` permutations.
61
+
62
+ kernels: a kernel name or tuple of names from ``"laplace"``, ``"gaussian"``, ``"imq"``,
63
+ ``"rq"``, ``"matern_{0..4}.5_l1"``, ``"matern_{0..4}.5_l2"``.
64
+ seed: seeds ``np.random.RandomState`` for the permutations.
65
+ return_details: also return a dict with ``statistic``, ``threshold`` (reject iff
66
+ ``statistic > threshold``), ``p_value``, ``permuted_statistics``, ``kernel``,
67
+ ``bandwidth`` and the normalised single-kernel ``mmd`` values.
68
+ """
69
+ X = np.asarray(X, dtype=float); Y = np.asarray(Y, dtype=float)
70
+ if X.ndim != 2 or Y.ndim != 2 or X.shape[1] != Y.shape[1]:
71
+ raise ValueError("X and Y must be 2-D arrays with the same number of columns")
72
+ if Y.shape[0] > X.shape[0]:
73
+ X, Y = Y, X
74
+ m, n = X.shape[0], Y.shape[0]
75
+ if n < 2:
76
+ raise ValueError("need at least 2 samples in each of X and Y")
77
+ if not 0 < alpha < 1:
78
+ raise ValueError("alpha must be in (0, 1)")
79
+ if not lambda_multiplier > 0:
80
+ raise ValueError("lambda_multiplier must be positive")
81
+ if not (isinstance(number_bandwidths, int) and number_bandwidths > 1):
82
+ raise ValueError("number_bandwidths must be an int > 1")
83
+ if not (isinstance(number_permutations, int) and number_permutations > 0):
84
+ raise ValueError("number_permutations must be a positive int")
85
+ if isinstance(kernels, str):
86
+ kernels = (kernels,)
87
+ kernels = tuple(kernels)
88
+ if not kernels or any(k not in KERNELS for k in kernels):
89
+ raise ValueError(f"kernels must be a non-empty collection from {KERNELS}")
90
+
91
+ B = number_permutations
92
+ if _permutations is None:
93
+ idx = np.random.RandomState(seed).rand(B, m + n).argsort(axis=1)
94
+ else:
95
+ idx = np.asarray(_permutations)
96
+ if idx.shape != (B, m + n):
97
+ raise ValueError("_permutations must have shape (number_permutations, m + n)")
98
+
99
+ def perm(v):
100
+ V = np.empty((B + 1, m + n)); V[:B] = v[idx]; V[B] = v
101
+ return V.T
102
+
103
+ V11 = perm(np.r_[np.ones(m), -np.ones(n)])
104
+ V10 = perm(np.r_[np.ones(m), np.zeros(n)])
105
+ V01 = perm(np.r_[np.zeros(m), -np.ones(n)])
106
+
107
+ # same ordering as the reference: all l1 kernels first, then l2
108
+ groups = [("cityblock", [k for k in kernels if k in _L1]),
109
+ ("euclidean", [k for k in kernels if k in _L2])]
110
+ N = number_bandwidths * len(kernels)
111
+ M = np.empty((N, B + 1)); bws = np.empty(N); names = []
112
+ Z = np.vstack((X, Y))
113
+ row = 0
114
+ for metric, ks in groups:
115
+ if not ks:
116
+ continue
117
+ P = cdist(Z, Z, metric)
118
+ bandwidths = _bandwidths(P[np.triu_indices(P.shape[0])], number_bandwidths)
119
+ for kern in ks:
120
+ for bw in bandwidths:
121
+ K = _kernel(P, kern, bw)
122
+ np.fill_diagonal(K, 0)
123
+ unscaled_std = np.sqrt(np.sum(K ** 2))
124
+ M[row] = ((np.sum(V10 * (K @ V10), 0) * (n - m + 1) * (n - 1) / (m * (m - 1))
125
+ + np.sum(V01 * (K @ V01), 0) * (m - n + 1) / m
126
+ + np.sum(V11 * (K @ V11), 0) * (n - 1) / m)
127
+ / unscaled_std * np.sqrt(n * (n - 1)))
128
+ bws[row] = bw; names.append(kern); row += 1
129
+
130
+ all_stats = logsumexp(lambda_multiplier * M, axis=0, b=1 / N)
131
+ stat = all_stats[-1]
132
+ p_val = np.mean(all_stats >= stat)
133
+ out = int(p_val <= alpha)
134
+ if not return_details:
135
+ return out
136
+ # reject iff p_val <= alpha <=> stat > the ceil((B+1)(1-alpha))-th smallest statistic
137
+ k = int(np.ceil((B + 1) * (1 - alpha))) - 1
138
+ threshold = np.sort(all_stats)[k]
139
+ return out, {"statistic": stat, "threshold": threshold, "p_value": p_val,
140
+ "permuted_statistics": all_stats[:-1], "kernel": names, "bandwidth": bws,
141
+ "mmd": M[:, -1]}