fastjsd 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastjsd/__init__.py +41 -0
- fastjsd/_api.py +394 -0
- fastjsd/_common.py +283 -0
- fastjsd/_numba.py +973 -0
- fastjsd/_numpy.py +236 -0
- fastjsd/_svml.py +402 -0
- fastjsd/reference.py +77 -0
- fastjsd-0.1.0.dist-info/METADATA +277 -0
- fastjsd-0.1.0.dist-info/RECORD +12 -0
- fastjsd-0.1.0.dist-info/WHEEL +5 -0
- fastjsd-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastjsd-0.1.0.dist-info/top_level.txt +1 -0
fastjsd/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""fastjsd -- fast exact pairwise Jensen-Shannon distance.
|
|
2
|
+
|
|
3
|
+
>>> import numpy as np
|
|
4
|
+
>>> from fastjsd import jsd_matrix
|
|
5
|
+
>>> P = np.random.default_rng(0).dirichlet(np.full(32, 0.1), size=500)
|
|
6
|
+
>>> D = jsd_matrix(P) # (500, 500), base 2, distances in [0, 1]
|
|
7
|
+
|
|
8
|
+
Drop-in for ``scipy.spatial.distance.pdist(P, "jensenshannon")`` and
|
|
9
|
+
``cdist(P, Q, "jensenshannon")``, with the same values (base ``e``: pass
|
|
10
|
+
``base=np.e``). The harnesses under ``bench/`` in the source repository
|
|
11
|
+
measure how the two compare on a given machine.
|
|
12
|
+
|
|
13
|
+
Public API
|
|
14
|
+
----------
|
|
15
|
+
jsd_matrix (n, n) self or (n, m) cross distance matrix
|
|
16
|
+
jsd_pdist condensed upper triangle, SciPy ``pdist`` order
|
|
17
|
+
jsd_pairwise row-aligned distances between two stacks
|
|
18
|
+
jsd scalar distance between two distributions
|
|
19
|
+
to_similarity ``1 - d``, valid for base 2
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from ._api import (
|
|
23
|
+
SPARSE_DENSITY_MAX,
|
|
24
|
+
SVML_DENSITY_MAX,
|
|
25
|
+
available_backends,
|
|
26
|
+
jsd,
|
|
27
|
+
jsd_matrix,
|
|
28
|
+
jsd_pairwise,
|
|
29
|
+
jsd_pdist,
|
|
30
|
+
resolve_backend,
|
|
31
|
+
to_similarity,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"jsd", "jsd_matrix", "jsd_pdist", "jsd_pairwise", "to_similarity",
|
|
36
|
+
"available_backends", "resolve_backend", "SPARSE_DENSITY_MAX",
|
|
37
|
+
"SVML_DENSITY_MAX",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
fastjsd/_api.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Public API: pairwise Jensen-Shannon distance / divergence over row-stacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from . import _numba, _numpy, _svml
|
|
8
|
+
from ._common import check_base, density, prepare, xlogx
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"jsd", "jsd_matrix", "jsd_pdist", "jsd_pairwise", "to_similarity",
|
|
12
|
+
"available_backends", "resolve_backend", "SPARSE_DENSITY_MAX",
|
|
13
|
+
"SVML_DENSITY_MAX",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
#: Density at or below which ``auto`` prefers the intersection merge over the
|
|
17
|
+
#: **libm** dense kernel: above it, walking a support intersection costs more
|
|
18
|
+
#: per surviving coordinate than a straight-line pass over ``k``. With the
|
|
19
|
+
#: sparse kernel's two-accumulator complement and empty-intersection mask in
|
|
20
|
+
#: place, the measured turning point is near 0.59 at k=40 and near 0.63 at
|
|
21
|
+
#: k=128. Its location depends on the host as well as on the data, so assign
|
|
22
|
+
#: to this to retune for your own input. ``bench/portable.py --crossover``
|
|
23
|
+
#: reports what this constant costs on a given machine, measured against that
|
|
24
|
+
#: machine's own per-density optimum: near the turning point the two kernels
|
|
25
|
+
#: are within noise of each other, so a bound on the regret travels between
|
|
26
|
+
#: hosts where a located crossover does not. ``bench/distributions.py`` shows
|
|
27
|
+
#: why support structure matters as much as density here.
|
|
28
|
+
SPARSE_DENSITY_MAX = 0.60
|
|
29
|
+
|
|
30
|
+
#: The same turning point when the dense kernel is the SVML one, which is
|
|
31
|
+
#: nearly density-independent: it does the full ``k`` work whatever the input,
|
|
32
|
+
#: so it overtakes the merge much earlier than the libm kernel does. Reusing
|
|
33
|
+
#: ``SPARSE_DENSITY_MAX`` here would keep dispatching to the merge well past
|
|
34
|
+
#: the density at which SVML is faster on thresholded-membership data.
|
|
35
|
+
#:
|
|
36
|
+
#: Measured turning point, n=800, min of 5 interleaved repetitions:
|
|
37
|
+
#:
|
|
38
|
+
#: support structure k=25 k=64 k=128 k=256
|
|
39
|
+
#: uniform random 0.34 0.27 0.27 0.22
|
|
40
|
+
#: correlated (rho=0.9) 0.42 - - -
|
|
41
|
+
#:
|
|
42
|
+
#: 0.22 is the value for the least realistic corner of that table, and the
|
|
43
|
+
#: threshold errs high because the regret is asymmetric: the merge's advantage
|
|
44
|
+
#: grows as density falls, while SVML's is nearly flat, so being under the true
|
|
45
|
+
#: crossover costs more than being over it. At 0.30 the worst regret over the
|
|
46
|
+
#: grid above is 1.26x (k=256, d=0.30, uniform); at 0.22 it is 2.6x on the
|
|
47
|
+
#: 931x25 membership fixture, whose density is 0.237.
|
|
48
|
+
#:
|
|
49
|
+
#: The crossover moves with the *skip rate*, not with density alone: the
|
|
50
|
+
#: empty-intersection mask fires on 58.9 % of pairs on that fixture and on
|
|
51
|
+
#: 15.3 % of uniform-random rows at the same density, so no single scalar is
|
|
52
|
+
#: right everywhere. This one is a bounded-regret compromise, not a constant of
|
|
53
|
+
#: nature, and it needs re-tuning in any change that enables the SVML kernel
|
|
54
|
+
#: for a new configuration.
|
|
55
|
+
SVML_DENSITY_MAX = 0.30
|
|
56
|
+
|
|
57
|
+
_BACKENDS = ("auto", "numpy", "numpy-sparse", "numba", "numba-sparse",
|
|
58
|
+
"numba-svml")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def available_backends() -> tuple[str, ...]:
|
|
62
|
+
"""Backends usable in this interpreter, in the order ``auto`` prefers.
|
|
63
|
+
|
|
64
|
+
``numba-svml`` appears only when Intel's ``libsvml`` is loadable *and* the
|
|
65
|
+
host has AVX-512; ``_svml.why_unavailable()`` says which is missing.
|
|
66
|
+
"""
|
|
67
|
+
if _numba.available():
|
|
68
|
+
base = ("numba-sparse", "numba", "numpy-sparse", "numpy")
|
|
69
|
+
return ("numba-svml",) + base if _svml.available() else base
|
|
70
|
+
return ("numpy-sparse", "numpy")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_backend(backend: str, P: np.ndarray, Q: np.ndarray | None = None) -> str:
|
|
74
|
+
"""Map ``backend`` (possibly ``"auto"``) onto a concrete kernel name."""
|
|
75
|
+
if backend not in _BACKENDS:
|
|
76
|
+
raise ValueError(f"unknown backend {backend!r}; expected one of {_BACKENDS}")
|
|
77
|
+
if backend.startswith("numba") and not _numba.available():
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"backend={backend!r} requires numba (pip install 'fastjsd[numba]')"
|
|
80
|
+
)
|
|
81
|
+
if backend == "numba-svml" and not _svml.available():
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
f"backend='numba-svml' unavailable: {_svml.why_unavailable()}"
|
|
84
|
+
)
|
|
85
|
+
if backend != "auto":
|
|
86
|
+
return backend
|
|
87
|
+
d = density(P) if Q is None else max(density(P), density(Q))
|
|
88
|
+
if not _numba.available():
|
|
89
|
+
return "numpy-sparse" if d <= SPARSE_DENSITY_MAX else "numpy"
|
|
90
|
+
# The dense kernel's speed decides where the merge stops paying, so the
|
|
91
|
+
# threshold has to follow whichever dense kernel is actually available.
|
|
92
|
+
if _svml.available():
|
|
93
|
+
return "numba-sparse" if d <= SVML_DENSITY_MAX else "numba-svml"
|
|
94
|
+
return "numba-sparse" if d <= SPARSE_DENSITY_MAX else "numba"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_WC_KERNELS = ("numba", "numba-sparse")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_well_conditioned(flag, kernel):
|
|
101
|
+
"""Decide the cancellation-free summand, defaulting per kernel.
|
|
102
|
+
|
|
103
|
+
``None`` (the default) enables it wherever it is free. Measured on the
|
|
104
|
+
membership fixtures, over the pairs whose distance is small but nonzero --
|
|
105
|
+
the ill-conditioned band, and the one that occurs in document data:
|
|
106
|
+
|
|
107
|
+
kernel worst err vs 50-digit truth cost
|
|
108
|
+
sparse 1.4e-13 -> 1.7e-18 none
|
|
109
|
+
dense 2.4e-14 -> 3.5e-18 1.6-1.8x
|
|
110
|
+
|
|
111
|
+
Four to five orders of magnitude of accuracy, for nothing, on the sparse
|
|
112
|
+
path -- matched coordinates are rare there, so the extra branch almost never
|
|
113
|
+
fires. On the dense path the same branch fires on every coordinate, which is
|
|
114
|
+
a trade the caller should make rather than inherit. Hence: on by default for
|
|
115
|
+
the merge, off by default for the dense loop, and an explicit
|
|
116
|
+
``True``/``False`` overrides either way.
|
|
117
|
+
|
|
118
|
+
``None`` never raises -- a default that failed on a backend the caller
|
|
119
|
+
picked deliberately would cost more than the accuracy it buys.
|
|
120
|
+
"""
|
|
121
|
+
if flag is None:
|
|
122
|
+
return kernel == "numba-sparse"
|
|
123
|
+
if flag and kernel not in _WC_KERNELS:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"well_conditioned=True is implemented for the 'numba' and "
|
|
126
|
+
f"'numba-sparse' kernels, not {kernel!r}. Pass backend='numba' "
|
|
127
|
+
f"(dense) or 'numba-sparse', or leave well_conditioned unset."
|
|
128
|
+
)
|
|
129
|
+
return bool(flag)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _drop_empty_columns(P, Q):
|
|
133
|
+
"""Columns that are zero in every row cannot affect any pair. Dropping
|
|
134
|
+
them is exact and shrinks the inner loop -- often substantially on
|
|
135
|
+
one-hot-ish or vocabulary-shaped data."""
|
|
136
|
+
keep = P.any(axis=0)
|
|
137
|
+
if Q is not None:
|
|
138
|
+
keep |= Q.any(axis=0)
|
|
139
|
+
if keep.all() or not keep.any():
|
|
140
|
+
return P, Q
|
|
141
|
+
P = np.ascontiguousarray(P[:, keep])
|
|
142
|
+
if Q is not None:
|
|
143
|
+
Q = np.ascontiguousarray(Q[:, keep])
|
|
144
|
+
return P, Q
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _prep_pair(P, Q, dtype, normalize, check):
|
|
148
|
+
P = prepare(P, name="P", dtype=dtype, normalize=normalize, check=check)
|
|
149
|
+
if Q is None:
|
|
150
|
+
return P, None
|
|
151
|
+
Q = prepare(Q, name="Q", dtype=P.dtype, normalize=normalize, check=check)
|
|
152
|
+
if Q.shape[1] != P.shape[1]:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"P and Q must have the same number of columns, got "
|
|
155
|
+
f"{P.shape[1]} and {Q.shape[1]}"
|
|
156
|
+
)
|
|
157
|
+
return P, Q
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def jsd_matrix(
|
|
161
|
+
P,
|
|
162
|
+
Q=None,
|
|
163
|
+
*,
|
|
164
|
+
base: float = 2.0,
|
|
165
|
+
squared: bool = False,
|
|
166
|
+
normalize: bool = True,
|
|
167
|
+
check: bool = True,
|
|
168
|
+
dtype=None,
|
|
169
|
+
backend: str = "auto",
|
|
170
|
+
out: np.ndarray | None = None,
|
|
171
|
+
threads: int | None = None,
|
|
172
|
+
well_conditioned: bool | None = None,
|
|
173
|
+
):
|
|
174
|
+
"""Pairwise Jensen-Shannon distance between the rows of ``P`` (and ``Q``).
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
P : array_like, shape (n, k) or (k,)
|
|
179
|
+
Rows are distributions over ``k`` outcomes. Rows are normalized to
|
|
180
|
+
sum to 1 unless ``normalize=False``.
|
|
181
|
+
Q : array_like, shape (m, k), optional
|
|
182
|
+
When given, the result is the ``(n, m)`` cross matrix. When omitted,
|
|
183
|
+
the symmetric ``(n, n)`` self matrix (zero diagonal) is returned and
|
|
184
|
+
only the upper triangle is computed.
|
|
185
|
+
base : float, default 2.0
|
|
186
|
+
Logarithm base. ``2`` bounds the distance in ``[0, 1]`` (so
|
|
187
|
+
``1 - d`` is a similarity); SciPy's ``jensenshannon`` defaults to
|
|
188
|
+
``e`` instead, which scales every value by ``sqrt(ln 2)``.
|
|
189
|
+
squared : bool, default False
|
|
190
|
+
Return the divergence ``JS`` rather than the distance ``sqrt(JS)``.
|
|
191
|
+
The divergence is not a metric; the distance is.
|
|
192
|
+
normalize : bool, default True
|
|
193
|
+
Divide each row by its sum. With ``False`` the rows are checked
|
|
194
|
+
(unless ``check=False``) and used as given -- the fast identity is
|
|
195
|
+
only valid for normalized rows.
|
|
196
|
+
check : bool, default True
|
|
197
|
+
Validate finiteness, non-negativity and (when ``normalize=False``)
|
|
198
|
+
row sums. Turn off only in a hot loop over already-vetted input.
|
|
199
|
+
dtype : numpy dtype, optional
|
|
200
|
+
Working and output dtype. ``float32`` roughly halves memory and time
|
|
201
|
+
for a worst-case error near ``1e-4`` on the distance; ``float64``
|
|
202
|
+
(the default for integer or float64 input) agrees with SciPy to
|
|
203
|
+
~1e-13.
|
|
204
|
+
backend : str, default "auto"
|
|
205
|
+
One of ``"auto"``, ``"numpy"``, ``"numpy-sparse"``, ``"numba"``,
|
|
206
|
+
``"numba-sparse"``, ``"numba-svml"``. ``auto`` picks the sparse
|
|
207
|
+
Numba kernel below the applicable nonzero fraction
|
|
208
|
+
(``SVML_DENSITY_MAX`` where the SVML kernel is available,
|
|
209
|
+
``SPARSE_DENSITY_MAX`` otherwise), the matching dense kernel above
|
|
210
|
+
it, and the NumPy kernels when Numba is absent.
|
|
211
|
+
out : ndarray, optional
|
|
212
|
+
Preallocated output of the right shape and dtype, C-contiguous.
|
|
213
|
+
threads : int, optional
|
|
214
|
+
Numba thread count for this call (restored afterwards).
|
|
215
|
+
well_conditioned : bool, optional
|
|
216
|
+
Use the cancellation-free summand in the Numba kernels, which is
|
|
217
|
+
more accurate on pairs whose distance is small but nonzero. ``None``
|
|
218
|
+
(the default) enables it where it is free: on for
|
|
219
|
+
``"numba-sparse"``, off for ``"numba"``.
|
|
220
|
+
|
|
221
|
+
Returns
|
|
222
|
+
-------
|
|
223
|
+
ndarray, shape (n, n) or (n, m)
|
|
224
|
+
|
|
225
|
+
Examples
|
|
226
|
+
--------
|
|
227
|
+
>>> import numpy as np
|
|
228
|
+
>>> from fastjsd import jsd_matrix
|
|
229
|
+
>>> P = np.array([[1.0, 0.0], [0.0, 1.0], [0.5, 0.5]])
|
|
230
|
+
>>> np.round(jsd_matrix(P), 6)
|
|
231
|
+
array([[0. , 1. , 0.557923],
|
|
232
|
+
[1. , 0. , 0.557923],
|
|
233
|
+
[0.557923, 0.557923, 0. ]])
|
|
234
|
+
"""
|
|
235
|
+
inv = 1.0 / np.log(check_base(base))
|
|
236
|
+
P, Q = _prep_pair(P, Q, dtype, normalize, check)
|
|
237
|
+
n, m = P.shape[0], (P.shape[0] if Q is None else Q.shape[0])
|
|
238
|
+
if out is not None:
|
|
239
|
+
_check_out(out, (n, m), P.dtype)
|
|
240
|
+
if n == 0 or m == 0:
|
|
241
|
+
return np.empty((n, m), dtype=P.dtype) if out is None else out
|
|
242
|
+
P, Q = _drop_empty_columns(P, Q)
|
|
243
|
+
|
|
244
|
+
kernel = resolve_backend(backend, P, Q)
|
|
245
|
+
wc = _resolve_well_conditioned(well_conditioned, kernel)
|
|
246
|
+
with _threads(threads):
|
|
247
|
+
if kernel == "numpy":
|
|
248
|
+
return _numpy.matrix(P, Q, inv, squared, out)
|
|
249
|
+
if kernel == "numpy-sparse":
|
|
250
|
+
return _numpy.matrix_sparse(P, Q, inv, squared, out)
|
|
251
|
+
if kernel == "numba":
|
|
252
|
+
return _numba.matrix(P, Q, inv, squared, out, wc)
|
|
253
|
+
if kernel == "numba-svml":
|
|
254
|
+
return _svml.matrix(P, Q, inv, squared, out)
|
|
255
|
+
return _numba.matrix_sparse(P, Q, inv, squared, out, wc)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def jsd_pdist(
|
|
259
|
+
P,
|
|
260
|
+
*,
|
|
261
|
+
base: float = 2.0,
|
|
262
|
+
squared: bool = False,
|
|
263
|
+
normalize: bool = True,
|
|
264
|
+
check: bool = True,
|
|
265
|
+
dtype=None,
|
|
266
|
+
backend: str = "auto",
|
|
267
|
+
out: np.ndarray | None = None,
|
|
268
|
+
threads: int | None = None,
|
|
269
|
+
well_conditioned: bool | None = None,
|
|
270
|
+
):
|
|
271
|
+
"""Condensed pairwise distances, in SciPy ``pdist`` order.
|
|
272
|
+
|
|
273
|
+
Same arguments as :func:`jsd_matrix` minus ``Q``. Returns an
|
|
274
|
+
``n(n-1)/2`` vector -- half the memory of the square form, which is the
|
|
275
|
+
difference between fitting and not fitting at large ``n``. Feed it to
|
|
276
|
+
``scipy.spatial.distance.squareform`` if you need the square form.
|
|
277
|
+
|
|
278
|
+
``backend="numpy-sparse"`` has no condensed form -- its column-scatter
|
|
279
|
+
accumulator is inherently square -- so it is served by the tiled NumPy
|
|
280
|
+
kernel here.
|
|
281
|
+
"""
|
|
282
|
+
inv = 1.0 / np.log(check_base(base))
|
|
283
|
+
P = prepare(P, name="P", dtype=dtype, normalize=normalize, check=check)
|
|
284
|
+
n = P.shape[0]
|
|
285
|
+
size = n * (n - 1) // 2
|
|
286
|
+
if out is not None:
|
|
287
|
+
_check_out(out, (size,), P.dtype)
|
|
288
|
+
if n < 2:
|
|
289
|
+
return np.empty(0, dtype=P.dtype) if out is None else out
|
|
290
|
+
P, _ = _drop_empty_columns(P, None)
|
|
291
|
+
|
|
292
|
+
kernel = resolve_backend(backend, P)
|
|
293
|
+
wc = _resolve_well_conditioned(well_conditioned, kernel)
|
|
294
|
+
with _threads(threads):
|
|
295
|
+
if kernel in ("numpy", "numpy-sparse"):
|
|
296
|
+
return _numpy.pdist(P, inv, squared, out)
|
|
297
|
+
if kernel == "numba":
|
|
298
|
+
return _numba.pdist(P, inv, squared, out, wc)
|
|
299
|
+
if kernel == "numba-svml":
|
|
300
|
+
return _svml.pdist(P, inv, squared, out)
|
|
301
|
+
return _numba.pdist_sparse(P, inv, squared, out, wc)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def jsd_pairwise(
|
|
305
|
+
P,
|
|
306
|
+
Q,
|
|
307
|
+
*,
|
|
308
|
+
base: float = 2.0,
|
|
309
|
+
squared: bool = False,
|
|
310
|
+
normalize: bool = True,
|
|
311
|
+
check: bool = True,
|
|
312
|
+
dtype=None,
|
|
313
|
+
):
|
|
314
|
+
"""Row-aligned distances: ``out[i] = JS(P[i], Q[i])``.
|
|
315
|
+
|
|
316
|
+
``P`` and ``Q`` broadcast against each other on the row axis, so a
|
|
317
|
+
single distribution can be compared against a whole stack.
|
|
318
|
+
"""
|
|
319
|
+
inv = 1.0 / np.log(check_base(base))
|
|
320
|
+
P, Q = _prep_pair(P, Q, dtype, normalize, check)
|
|
321
|
+
if P.shape[0] != Q.shape[0]:
|
|
322
|
+
if P.shape[0] == 1:
|
|
323
|
+
P = np.broadcast_to(P, Q.shape)
|
|
324
|
+
elif Q.shape[0] == 1:
|
|
325
|
+
Q = np.broadcast_to(Q, P.shape)
|
|
326
|
+
else:
|
|
327
|
+
raise ValueError(
|
|
328
|
+
f"P and Q must have the same number of rows or one must have "
|
|
329
|
+
f"a single row, got {P.shape[0]} and {Q.shape[0]}"
|
|
330
|
+
)
|
|
331
|
+
M = 0.5 * (P + Q)
|
|
332
|
+
nz = M > 0
|
|
333
|
+
L = np.zeros_like(M)
|
|
334
|
+
np.log(M, out=L, where=nz)
|
|
335
|
+
L *= M
|
|
336
|
+
L *= -2.0
|
|
337
|
+
L += xlogx(P)
|
|
338
|
+
L += xlogx(Q)
|
|
339
|
+
D = L.sum(axis=1)
|
|
340
|
+
D *= 0.5 * inv
|
|
341
|
+
np.clip(D, 0.0, None, out=D)
|
|
342
|
+
return D if squared else np.sqrt(D, out=D)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def jsd(p, q, *, base: float = 2.0, squared: bool = False, **kwargs) -> float:
|
|
346
|
+
"""Jensen-Shannon distance between two single distributions.
|
|
347
|
+
|
|
348
|
+
Note the ``base`` default differs from
|
|
349
|
+
``scipy.spatial.distance.jensenshannon`` (which uses ``e``); pass
|
|
350
|
+
``base=np.e`` for numerically identical results.
|
|
351
|
+
"""
|
|
352
|
+
return float(jsd_pairwise(np.atleast_2d(p), np.atleast_2d(q), base=base,
|
|
353
|
+
squared=squared, **kwargs)[0])
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def to_similarity(D, *, base: float = 2.0):
|
|
357
|
+
"""``1 - D``, the similarity induced by the base-2 distance.
|
|
358
|
+
|
|
359
|
+
Only meaningful for ``base=2``, where the distance is bounded by 1; any
|
|
360
|
+
other base is rejected rather than silently producing negatives.
|
|
361
|
+
"""
|
|
362
|
+
if abs(check_base(base) - 2.0) > 1e-12:
|
|
363
|
+
raise ValueError("to_similarity is only defined for base=2, where the "
|
|
364
|
+
"distance is bounded by 1")
|
|
365
|
+
return 1.0 - np.asarray(D)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
def _check_out(out, shape, dtype):
|
|
371
|
+
if not isinstance(out, np.ndarray):
|
|
372
|
+
raise TypeError("out must be a numpy array")
|
|
373
|
+
if out.shape != shape:
|
|
374
|
+
raise ValueError(f"out has shape {out.shape}, expected {shape}")
|
|
375
|
+
if out.dtype != dtype:
|
|
376
|
+
raise ValueError(f"out has dtype {out.dtype}, expected {dtype}")
|
|
377
|
+
if not out.flags.c_contiguous:
|
|
378
|
+
raise ValueError("out must be C-contiguous")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class _threads:
|
|
382
|
+
"""Scope a Numba thread-count override."""
|
|
383
|
+
|
|
384
|
+
def __init__(self, n):
|
|
385
|
+
self.n = n
|
|
386
|
+
|
|
387
|
+
def __enter__(self):
|
|
388
|
+
if self.n is not None:
|
|
389
|
+
_numba.set_num_threads(self.n)
|
|
390
|
+
|
|
391
|
+
def __exit__(self, *exc):
|
|
392
|
+
if self.n is not None:
|
|
393
|
+
_numba.set_num_threads(None)
|
|
394
|
+
return False
|