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.
- kerneltests-0.0.1.dist-info/METADATA +116 -0
- kerneltests-0.0.1.dist-info/RECORD +14 -0
- kerneltests-0.0.1.dist-info/WHEEL +5 -0
- kerneltests-0.0.1.dist-info/licenses/LICENSE +10 -0
- kerneltests-0.0.1.dist-info/top_level.txt +1 -0
- kt/__init__.py +5 -0
- kt/_common.py +21 -0
- kt/agginc.py +264 -0
- kt/api.py +74 -0
- kt/backends/__init__.py +15 -0
- kt/backends/jax_mmdagg.py +107 -0
- kt/ksdagg.py +129 -0
- kt/mmdagg.py +177 -0
- kt/mmdfuse.py +141 -0
|
@@ -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,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'")
|
kt/backends/__init__.py
ADDED
|
@@ -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]}
|