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 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