usearch 0.13.0 → 0.15.0

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.
Files changed (64) hide show
  1. package/.github/workflows/release.yml +62 -7
  2. package/.github/workflows/update_version.sh +6 -1
  3. package/.vscode/launch.json +21 -22
  4. package/.vscode/settings.json +7 -1
  5. package/CITATION.cff +4 -4
  6. package/CMakeLists.txt +1 -1
  7. package/Cargo.toml +1 -1
  8. package/Package.swift +0 -1
  9. package/README.md +100 -27
  10. package/binding.gyp +0 -1
  11. package/build.gradle +1 -1
  12. package/build.rs +3 -3
  13. package/c/lib.cpp +32 -9
  14. package/c/usearch.h +67 -36
  15. package/conanfile.py +1 -1
  16. package/{src → cpp}/bench.cpp +52 -36
  17. package/{src → cpp}/test.cpp +17 -7
  18. package/docs/benchmarks.md +33 -10
  19. package/docs/c/index.rst +11 -0
  20. package/docs/c/reference.rst +7 -0
  21. package/docs/compilation.md +24 -8
  22. package/docs/conf.dox +6 -6
  23. package/docs/conf.py +3 -1
  24. package/docs/cpp/index.rst +11 -0
  25. package/docs/cpp/reference.rst +7 -0
  26. package/docs/index.rst +10 -0
  27. package/docs/java/index.rst +11 -0
  28. package/docs/java/reference.rst +7 -0
  29. package/docs/javascript/index.rst +11 -0
  30. package/docs/javascript/reference.rst +9 -0
  31. package/docs/python/index.rst +11 -0
  32. package/docs/python/reference.rst +35 -0
  33. package/golang/lib.go +1 -1
  34. package/include/usearch/{usearch.hpp → index.hpp} +152 -148
  35. package/include/usearch/index_punned_dense.hpp +633 -0
  36. package/include/usearch/index_punned_helpers.hpp +528 -0
  37. package/java/cloud/unum/usearch/cloud_unum_usearch_Index.cpp +8 -7
  38. package/javascript/lib.cpp +26 -50
  39. package/javascript/usearch.js +117 -2
  40. package/objc/USearchObjective.mm +145 -49
  41. package/objc/include/USearchObjective.h +106 -30
  42. package/package.json +2 -2
  43. package/python/lib.cpp +178 -320
  44. package/python/scripts/bench.ipynb +170 -0
  45. package/python/scripts/bench.py +102 -185
  46. package/python/scripts/index_faiss.py +100 -0
  47. package/python/scripts/test.py +57 -34
  48. package/python/usearch/eval.py +353 -0
  49. package/python/usearch/index.py +138 -117
  50. package/python/usearch/io.py +7 -2
  51. package/python/usearch/join.py +100 -0
  52. package/python/usearch/numba.py +101 -0
  53. package/rust/lib.cpp +27 -14
  54. package/rust/lib.hpp +4 -10
  55. package/rust/lib.rs +50 -17
  56. package/setup.py +12 -2
  57. package/simsimd/include/simsimd/simsimd.h +32 -3
  58. package/swift/Index+Sugar.swift +55 -29
  59. package/swift/Test.swift +1 -1
  60. package/wolfram/lib.cpp +4 -4
  61. package/python/scripts/bench_params.py +0 -161
  62. package/python/usearch/synthetic.py +0 -32
  63. package/src/punned.hpp +0 -887
  64. /package/{src → cpp}/CMakeLists.txt +0 -0
@@ -3,29 +3,41 @@ import pytest
3
3
  import numpy as np
4
4
 
5
5
  from usearch.io import load_matrix, save_matrix
6
- from usearch.index import Index, SetsIndex, MetricKind
6
+ from usearch.eval import recall_members, random_vectors
7
7
 
8
+ from usearch.index import Index, SparseIndex, MetricKind, ScalarKind, Matches
9
+ from usearch.index import (
10
+ DEFAULT_CONNECTIVITY,
11
+ DEFAULT_EXPANSION_ADD,
12
+ DEFAULT_EXPANSION_SEARCH,
13
+ )
8
14
 
9
- dimensions = [13, 99, 100, 256]
10
- batch_sizes = [1, 2, 100]
11
- index_types = ['f32', 'f64', 'f16', 'f8']
12
- numpy_types = [np.float32, np.float64, np.float16, np.byte]
13
- connectivity_options = [3, 13, 50]
15
+
16
+ dimensions = [3, 97, 256]
17
+ batch_sizes = [1, 33]
18
+ index_types = [
19
+ ScalarKind.F32, ScalarKind.F64,
20
+ ScalarKind.F16, ScalarKind.F8,
21
+ ]
22
+ numpy_types = [np.float32, np.float64, np.float16]
23
+
24
+ connectivity_options = [3, 13, 50, DEFAULT_CONNECTIVITY]
14
25
  jit_options = [False]
15
26
  continuous_metrics = [
16
27
  MetricKind.Cos,
17
28
  MetricKind.L2sq,
18
29
  ]
19
30
  hash_metrics = [
20
- MetricKind.BitwiseHamming,
21
- MetricKind.BitwiseTanimoto,
22
- MetricKind.BitwiseSorensen,
31
+ MetricKind.Hamming,
32
+ MetricKind.Tanimoto,
33
+ MetricKind.Sorensen,
23
34
  ]
24
35
 
25
36
 
26
37
  @pytest.mark.parametrize('rows', batch_sizes)
27
38
  @pytest.mark.parametrize('cols', dimensions)
28
39
  def test_serializing_fbin_matrix(rows: int, cols: int):
40
+
29
41
  original = np.random.rand(rows, cols).astype(np.float32)
30
42
  save_matrix(original, 'tmp.fbin')
31
43
  reconstructed = load_matrix('tmp.fbin')
@@ -36,6 +48,7 @@ def test_serializing_fbin_matrix(rows: int, cols: int):
36
48
  @pytest.mark.parametrize('rows', batch_sizes)
37
49
  @pytest.mark.parametrize('cols', dimensions)
38
50
  def test_serializing_ibin_matrix(rows: int, cols: int):
51
+
39
52
  original = np.random.randint(0, rows+1, size=(rows, cols)).astype(np.int32)
40
53
  save_matrix(original, 'tmp.ibin')
41
54
  reconstructed = load_matrix('tmp.ibin')
@@ -59,6 +72,8 @@ def test_index(
59
72
  ndim=ndim,
60
73
  dtype=index_type,
61
74
  connectivity=connectivity,
75
+ expansion_add=DEFAULT_EXPANSION_ADD,
76
+ expansion_search=DEFAULT_EXPANSION_SEARCH,
62
77
  jit=jit,
63
78
  )
64
79
  assert index.ndim == ndim
@@ -66,9 +81,15 @@ def test_index(
66
81
 
67
82
  limit = 0.7 if numpy_type != np.byte else 70
68
83
  vector = np.random.uniform(0, limit, (index.ndim)).astype(numpy_type)
84
+
69
85
  index.add(42, vector)
70
- matches, distances, count = index.search(vector, 10)
71
86
 
87
+ assert 42 in index
88
+ assert 42 in index.labels
89
+ if numpy_type != np.byte:
90
+ assert np.allclose(index[42], vector, atol=0.1)
91
+
92
+ matches, distances, count = index.search(vector, 10)
72
93
  assert len(index) == 1
73
94
  assert len(matches) == count
74
95
  assert len(distances) == count
@@ -95,21 +116,25 @@ def test_index_batch(
95
116
 
96
117
  index = Index(ndim=ndim, metric=metric, dtype=index_type)
97
118
 
98
- limit = 0.7 if numpy_type != np.byte else 70
99
- vectors = np.random.uniform(
100
- 0, limit, (batch_size, index.ndim)).astype(numpy_type)
101
- labels = np.array(range(batch_size), dtype=np.longlong)
119
+ labels = np.arange(batch_size)
120
+ vectors = random_vectors(count=batch_size, ndim=ndim, dtype=numpy_type)
102
121
 
103
122
  index.add(labels, vectors)
104
- matches, distances, count = index.search(vectors, 10)
123
+ assert len(index) == batch_size
124
+ assert np.allclose(index.get_vectors(
125
+ labels).astype(numpy_type), vectors, atol=0.1)
105
126
 
106
- assert matches.shape[0] == distances.shape[0]
107
- assert count.shape[0] == batch_size
127
+ matches: Matches = index.search(vectors, 10)
128
+ assert matches.labels.shape[0] == matches.distances.shape[0]
129
+ assert matches.counts.shape[0] == batch_size
130
+ assert np.all(np.sort(index.labels) == np.sort(labels))
131
+
132
+ assert recall_members(index, exact=True) == 1
108
133
 
109
134
 
110
135
  @pytest.mark.parametrize('ndim', dimensions)
111
136
  @pytest.mark.parametrize('batch_size', batch_sizes)
112
- def test_index_udf(ndim: int, batch_size: int):
137
+ def test_index_numba(ndim: int, batch_size: int):
113
138
 
114
139
  try:
115
140
  from numba import cfunc, types, carray
@@ -118,37 +143,35 @@ def test_index_udf(ndim: int, batch_size: int):
118
143
 
119
144
  # Showcases how to use Numba to JIT-compile similarity measures for USearch.
120
145
  # https://numba.readthedocs.io/en/stable/reference/jit-compilation.html#c-callbacks
121
- signature = types.float32(
122
- types.CPointer(types.float32),
123
- types.CPointer(types.float32),
124
- types.uint64, types.uint64)
146
+ signature = types.float32(types.CPointer(
147
+ types.float32), types.CPointer(types.float32))
125
148
 
126
149
  @cfunc(signature)
127
- def python_dot(a, b, n, m):
128
- a_array = carray(a, n)
129
- b_array = carray(b, n)
150
+ def python_dot(a, b):
151
+ a_array = carray(a, ndim)
152
+ b_array = carray(b, ndim)
130
153
  c = 0.0
131
- for i in range(n):
154
+ for i in range(ndim):
132
155
  c += a_array[i] * b_array[i]
133
- return c
156
+ return 1 - c
134
157
 
135
158
  index = Index(ndim=ndim, metric=python_dot)
136
159
 
137
- vectors = np.random.uniform(
138
- 0, 0.3, (batch_size, index.ndim)).astype(np.float32)
139
- labels = np.array(range(batch_size), dtype=np.longlong)
160
+ labels = np.arange(batch_size)
161
+ vectors = random_vectors(count=batch_size, ndim=ndim)
140
162
 
141
163
  index.add(labels, vectors)
142
164
  matches, distances, count = index.search(vectors, 10)
143
-
144
165
  assert matches.shape[0] == distances.shape[0]
145
166
  assert count.shape[0] == batch_size
146
167
 
168
+ assert recall_members(index, exact=True) == 1
169
+
147
170
 
148
171
  @pytest.mark.parametrize('connectivity', connectivity_options)
149
172
  def test_sets_index(connectivity: int):
150
173
 
151
- index = SetsIndex(connectivity=connectivity)
174
+ index = SparseIndex(connectivity=connectivity)
152
175
  index.add(10, np.array([10, 12, 15], dtype=np.uint32))
153
176
  index.add(11, np.array([11, 12, 15, 16], dtype=np.uint32))
154
177
  results = index.search(np.array([12, 15], dtype=np.uint32), 10)
@@ -159,13 +182,13 @@ def test_sets_index(connectivity: int):
159
182
  @pytest.mark.parametrize('metric', hash_metrics)
160
183
  @pytest.mark.parametrize('connectivity', connectivity_options)
161
184
  @pytest.mark.parametrize('batch_size', batch_sizes)
162
- def test_hash_index(bits: int, metric: MetricKind, connectivity: int, batch_size: int):
185
+ def test_bitwise_index(bits: int, metric: MetricKind, connectivity: int, batch_size: int):
163
186
 
164
187
  index = Index(ndim=bits, metric=metric, connectivity=connectivity)
165
188
 
189
+ labels = np.arange(batch_size)
166
190
  bit_vectors = np.random.randint(2, size=(batch_size, bits))
167
191
  bit_vectors = np.packbits(bit_vectors, axis=1)
168
- labels = np.array(range(batch_size), dtype=np.longlong)
169
192
 
170
193
  index.add(labels, bit_vectors)
171
194
  index.search(bit_vectors, 10)
@@ -0,0 +1,353 @@
1
+ from __future__ import annotations
2
+ from time import time_ns
3
+ from typing import Tuple, Any, Callable, Union, Optional, List
4
+ from dataclasses import dataclass, asdict
5
+ from collections import defaultdict
6
+
7
+ import numpy as np
8
+
9
+ from usearch.io import load_matrix
10
+ from usearch.index import Index, Matches, MetricKind, MetricKindBitwise
11
+
12
+
13
+ def random_vectors(
14
+ count: int,
15
+ metric: MetricKind = MetricKind.IP,
16
+ dtype: np.generic = np.float32,
17
+ ndim: Optional[int] = None,
18
+ index: Optional[Index] = None,
19
+ ) -> np.ndarray:
20
+ """Produces a collection of random vectors normalized for the provided `metric`
21
+ and matching wanted `dtype`, which can both be inferred from an existing `index`.
22
+ """
23
+
24
+ # Infer default parameters from the `index`, if passed
25
+ if index is not None:
26
+ if not isinstance(index, Index):
27
+ raise ValueError('Unsupported `index` type')
28
+
29
+ ndim = index.ndim
30
+ dtype = index.numpy_dtype
31
+ metric = index.metric
32
+
33
+ # Produce data
34
+ if metric in MetricKindBitwise:
35
+ bit_vectors = np.random.randint(2, size=(count, ndim))
36
+ bit_vectors = np.packbits(bit_vectors, axis=1)
37
+ return bit_vectors
38
+
39
+ else:
40
+
41
+ x = np.random.rand(count, ndim)
42
+ if dtype == np.int8:
43
+ x = (x * 100).astype(np.int8)
44
+ else:
45
+ x = x.astype(dtype)
46
+ if metric == MetricKind.IP:
47
+ return x / np.linalg.norm(x, axis=1, keepdims=True)
48
+ return x
49
+
50
+
51
+ def recall_members(index: Index, *args, **kwargs) -> float:
52
+ """Simplest benchmark for a quality of search, which queries every
53
+ existing member of the index, to make sure approximate search finds
54
+ the point itself.
55
+
56
+ :param index: Non-empty pre-constructed index
57
+ :type index: Index
58
+ :return: Value from 0 to 1, for the share of found self-references
59
+ :rtype: float
60
+ """
61
+ if len(index) == 0:
62
+ return 0
63
+
64
+ matches: Matches = index.search(index.vectors, 1, *args, **kwargs)
65
+ return matches.recall_first(index.labels)
66
+
67
+
68
+ def measure_seconds(f: Callable) -> Tuple[float, Any]:
69
+ """Simple function profiling decorator.
70
+
71
+ :param f: Function to be profiled
72
+ :type f: Callable
73
+ :return: Time elapsed in seconds and the result of the execution
74
+ :rtype: Tuple[float, Any]
75
+ """
76
+ a = time_ns()
77
+ result = f()
78
+ b = time_ns()
79
+ c = b - a
80
+ secs = c / (10 ** 9)
81
+ return secs, result
82
+
83
+
84
+ @dataclass
85
+ class Dataset:
86
+
87
+ labels: np.ndarray
88
+ vectors: np.ndarray
89
+ queries: np.ndarray
90
+ neighbors: np.ndarray
91
+
92
+ def crop_neighbors(self, k: int):
93
+ self.neighbors = self.neighbors[:, k]
94
+
95
+ @staticmethod
96
+ def build(
97
+ vectors: Optional[str] = None,
98
+ queries: Optional[str] = None,
99
+ neighbors: Optional[str] = None,
100
+ count: Optional[int] = None,
101
+ ndim: Optional[int] = None,
102
+ k: Optional[int] = None,
103
+ ):
104
+ """Either loads an existing dataset from disk, or generates one on the fly.
105
+
106
+ :param vectors: _description_, defaults to None
107
+ :type vectors: Optional[str], optional
108
+ :param queries: _description_, defaults to None
109
+ :type queries: Optional[str], optional
110
+ :param neighbors: _description_, defaults to None
111
+ :type neighbors: Optional[str], optional
112
+ :param count: _description_, defaults to None
113
+ :type count: Optional[int], optional
114
+ :param ndim: _description_, defaults to None
115
+ :type ndim: Optional[int], optional
116
+ :param k: _description_, defaults to None
117
+ :type k: Optional[int], optional
118
+ """
119
+
120
+ d = Dataset(None, None, None, None)
121
+
122
+ if vectors is not None:
123
+ assert ndim is None
124
+
125
+ d.vectors = load_matrix(vectors)
126
+ ndim = d.vectors.shape[1]
127
+ count = min(
128
+ d.vectors.shape[0], count) if count is not None else d.vectors.shape[0]
129
+ d.vectors = d.vectors[:count, :]
130
+ d.labels = np.arange(count, dtype=np.longlong)
131
+
132
+ if queries is not None:
133
+ d.queries = load_matrix(queries)
134
+ else:
135
+ d.queries = d.vectors
136
+
137
+ if neighbors is not None:
138
+ d.neighbors = load_matrix(neighbors)
139
+ if k is not None:
140
+ d.neighbors = d.neighbors[:, :k]
141
+ else:
142
+ assert k is None, 'Cant ovveride `k`, will retrieve one neighbor'
143
+ d.neighbors = np.reshape(d.labels, (count, 1))
144
+
145
+ else:
146
+
147
+ assert ndim is not None
148
+ assert count is not None
149
+ assert k is None, 'Cant ovveride `k`, will retrieve one neighbor'
150
+
151
+ d.vectors = random_vectors(count=count, ndim=ndim)
152
+ d.queries = d.vectors
153
+ d.labels = np.arange(count, dtype=np.longlong)
154
+ d.neighbors = np.reshape(d.labels, (count, 1))
155
+
156
+ return d
157
+
158
+
159
+ @dataclass
160
+ class TaskResult:
161
+
162
+ add_operations: Optional[int] = None
163
+ add_per_second: Optional[float] = None
164
+
165
+ search_operations: Optional[int] = None
166
+ search_per_second: Optional[float] = None
167
+ recall_at_one: Optional[float] = None
168
+
169
+ def __repr__(self) -> str:
170
+ parts = []
171
+ if self.add_per_second:
172
+ parts.append(f'{self.add_per_second:.2f} add/s')
173
+ if self.search_per_second:
174
+ parts.append(f'{self.search_per_second:.2f} search/s')
175
+ if self.recall_at_one:
176
+ parts.append(f'{self.recall_at_one * 100:.2f}% recall@1')
177
+ return ', '.join(parts)
178
+
179
+ @property
180
+ def add_seconds(self) -> float:
181
+ return self.add_operations / self.add_per_second
182
+
183
+ @property
184
+ def search_seconds(self) -> float:
185
+ return self.search_operations / self.search_per_second
186
+
187
+ def __add__(self, other: TaskResult, /):
188
+ result = TaskResult()
189
+ if self.add_operations and other.add_operations:
190
+ result.add_operations = self.add_operations + other.add_operations
191
+ result.add_per_second = result.add_operations / \
192
+ (self.add_seconds + other.add_seconds)
193
+ else:
194
+ base = self if self.add_operations else other
195
+ result.add_operations = base.add_operations
196
+ result.add_per_second = base.add_per_second
197
+
198
+ if self.search_operations and other.search_operations:
199
+ result.search_operations = self.search_operations + other.search_operations
200
+ result.recall_at_one = (
201
+ self.recall_at_one * self.search_operations + other.recall_at_one *
202
+ other.search_operations) / (self.search_operations + other.search_operations)
203
+ result.search_per_second = result.search_operations / \
204
+ (self.search_seconds + other.search_seconds)
205
+ else:
206
+ base = self if self.search_operations else other
207
+ result.search_operations = base.search_operations
208
+ result.search_per_second = base.search_per_second
209
+ result.recall_at_one = base.recall_at_one
210
+
211
+ return result
212
+
213
+
214
+ @dataclass
215
+ class AddTask:
216
+
217
+ labels: np.ndarray
218
+ vectors: np.ndarray
219
+
220
+ def __call__(self, index: Index) -> TaskResult:
221
+
222
+ batch_size: int = self.vectors.shape[0]
223
+ old_size: int = len(index)
224
+ dt, _ = measure_seconds(
225
+ lambda: index.add(self.labels, self.vectors))
226
+
227
+ assert len(index) == old_size + batch_size
228
+ return TaskResult(
229
+ add_operations=batch_size,
230
+ add_per_second=batch_size/dt,
231
+ )
232
+
233
+ @property
234
+ def ndim(self):
235
+ return self.vectors.shape[1]
236
+
237
+ @property
238
+ def count(self):
239
+ return self.vectors.shape[0]
240
+
241
+ def inplace_shuffle(self):
242
+ """Rorders the `vectors` and `labels`. Often used for robustness benchmarks."""
243
+
244
+ new_order = np.arange(self.count)
245
+ np.random.shuffle(new_order)
246
+ self.labels = self.labels[new_order]
247
+ self.vectors = self.vectors[new_order, :]
248
+
249
+ def slices(self, batch_size: int) -> List[AddTask]:
250
+ """Splits this dataset into smaller chunks."""
251
+
252
+ return [
253
+ AddTask(
254
+ labels=self.labels[start_row:start_row+batch_size],
255
+ vectors=self.vectors[start_row:start_row+batch_size, :],
256
+ ) for start_row in range(0, self.count, batch_size)]
257
+
258
+ def clusters(self, number_of_clusters: int) -> List[AddTask]:
259
+ """Splits this dataset into smaller chunks."""
260
+
261
+ from sklearn.cluster import KMeans
262
+ clustering = KMeans(
263
+ n_clusters=number_of_clusters,
264
+ random_state=0, n_init='auto',
265
+ ).fit(self.vectors)
266
+
267
+ partitioning = defaultdict(list)
268
+ for row, cluster in enumerate(clustering.labels_):
269
+ partitioning[cluster].append(row)
270
+
271
+ return [
272
+ AddTask(
273
+ labels=self.labels[rows],
274
+ vectors=self.vectors[rows, :],
275
+ ) for rows in partitioning.values()]
276
+
277
+
278
+ @dataclass
279
+ class SearchTask:
280
+
281
+ queries: np.ndarray
282
+ neighbors: np.ndarray
283
+
284
+ def __call__(self, index: Index) -> TaskResult:
285
+
286
+ dt, results = measure_seconds(
287
+ lambda: index.search(self.queries, self.neighbors.shape[1]))
288
+
289
+ return TaskResult(
290
+ search_per_second=self.queries.shape[0]/dt,
291
+ recall_at_one=results.recall_first(self.neighbors[:, 0].flatten()),
292
+ )
293
+
294
+ def slices(self, batch_size: int) -> List[SearchTask]:
295
+ """Splits this dataset into smaller chunks."""
296
+
297
+ return [
298
+ SearchTask(
299
+ queries=self.queries[start_row:start_row+batch_size, :],
300
+ neighbors=self.neighbors[start_row:start_row+batch_size, :],
301
+ ) for start_row in range(0, self.queries.shape[0], batch_size)]
302
+
303
+
304
+ @dataclass
305
+ class Evaluation:
306
+
307
+ tasks: List[Union[AddTask, SearchTask]]
308
+ count: int
309
+ ndim: int
310
+
311
+ @staticmethod
312
+ def for_dataset(dataset: Dataset, batch_size: int = 0, clusters: int = 1) -> Evaluation:
313
+ tasks = []
314
+ add = AddTask(
315
+ vectors=dataset.vectors,
316
+ labels=dataset.labels)
317
+ search = SearchTask(
318
+ queries=dataset.queries,
319
+ neighbors=dataset.neighbors)
320
+
321
+ if batch_size:
322
+ tasks.extend(add.slices(batch_size))
323
+ tasks.extend(search.slices(batch_size))
324
+ elif clusters != 1:
325
+ tasks.extend(add.clusters(clusters))
326
+ print(tasks)
327
+ tasks.append(search)
328
+ else:
329
+ tasks.append(add)
330
+ tasks.append(search)
331
+
332
+ return Evaluation(
333
+ tasks=tasks,
334
+ count=add.count,
335
+ ndim=add.ndim,
336
+ )
337
+
338
+ def __call__(self, index: Index, post_clean: bool = True) -> dict:
339
+
340
+ task_result = TaskResult()
341
+
342
+ try:
343
+ for task in self.tasks:
344
+ task_result = task_result + task(index)
345
+ except KeyboardInterrupt:
346
+ pass
347
+
348
+ if post_clean:
349
+ index.clear()
350
+ return {
351
+ **index.specs,
352
+ **asdict(task_result),
353
+ }