usearch 0.18.8 → 0.19.1
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.
- package/Cargo.toml +1 -1
- package/README.md +187 -40
- package/c/lib.cpp +71 -51
- package/c/test.c +182 -0
- package/c/usearch.h +11 -5
- package/conanfile.py +1 -1
- package/cpp/bench.cpp +6 -6
- package/cpp/test.cpp +11 -9
- package/docs/compilation.md +1 -0
- package/include/usearch/index.hpp +290 -54
- package/include/usearch/index_punned_dense.hpp +125 -108
- package/include/usearch/index_punned_helpers.hpp +2 -12
- package/objc/USearchObjective.mm +4 -0
- package/objc/include/USearchObjective.h +5 -0
- package/package.json +2 -2
- package/python/lib.cpp +234 -199
- package/python/scripts/bench.ipynb +13 -2113
- package/python/scripts/bench.py +45 -29
- package/python/scripts/index_faiss.py +19 -19
- package/python/scripts/test.py +320 -61
- package/python/usearch/eval.py +100 -49
- package/python/usearch/index.py +245 -129
- package/python/usearch/io.py +25 -25
- package/python/usearch/join.py +2 -4
- package/python/usearch/numba.py +22 -18
- package/python/usearch/server.py +38 -26
- package/setup.py +56 -59
- package/simsimd/.vscode/settings.json +2 -1
- package/simsimd/CMakeLists.txt +1 -1
- package/simsimd/README.md +14 -2
- package/simsimd/bench.cxx +44 -27
- package/simsimd/include/simsimd/simsimd.h +2 -1
- package/swift/Test.swift +13 -9
- package/simsimd/include/simsimd/simsimd.hpp +0 -443
package/python/usearch/eval.py
CHANGED
|
@@ -24,7 +24,7 @@ def random_vectors(
|
|
|
24
24
|
# Infer default parameters from the `index`, if passed
|
|
25
25
|
if index is not None:
|
|
26
26
|
if not isinstance(index, Index):
|
|
27
|
-
raise ValueError(
|
|
27
|
+
raise ValueError("Unsupported `index` type")
|
|
28
28
|
|
|
29
29
|
ndim = index.ndim
|
|
30
30
|
dtype = index.numpy_dtype
|
|
@@ -37,7 +37,6 @@ def random_vectors(
|
|
|
37
37
|
return bit_vectors
|
|
38
38
|
|
|
39
39
|
else:
|
|
40
|
-
|
|
41
40
|
x = np.random.rand(count, ndim)
|
|
42
41
|
if dtype == np.int8:
|
|
43
42
|
x = (x * 100).astype(np.int8)
|
|
@@ -77,13 +76,65 @@ def measure_seconds(f: Callable) -> Tuple[float, Any]:
|
|
|
77
76
|
result = f()
|
|
78
77
|
b = time_ns()
|
|
79
78
|
c = b - a
|
|
80
|
-
secs = c / (10
|
|
79
|
+
secs = c / (10**9)
|
|
81
80
|
return secs, result
|
|
82
81
|
|
|
83
82
|
|
|
83
|
+
def dcg(relevances: np.ndarray, k: Optional[int] = None) -> np.ndarray:
|
|
84
|
+
"""Calculate DCG (Discounted Cumulative Gain) up to position k.
|
|
85
|
+
|
|
86
|
+
:param relevances: List of true relevance scores (in the order as they are ranked)
|
|
87
|
+
:type relevances: list
|
|
88
|
+
:param k: Position up to which DCG is computed
|
|
89
|
+
:type k: int
|
|
90
|
+
:return: The DCG score at position k
|
|
91
|
+
:rtype: float
|
|
92
|
+
"""
|
|
93
|
+
if k:
|
|
94
|
+
relevances = np.asarray(relevances)[:k]
|
|
95
|
+
|
|
96
|
+
n_relevances = len(relevances)
|
|
97
|
+
if n_relevances == 0:
|
|
98
|
+
return 0.0
|
|
99
|
+
|
|
100
|
+
discounts = np.log2(np.arange(n_relevances) + 2)
|
|
101
|
+
return np.sum(relevances / discounts)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ndcg(relevances: np.ndarray, k: Optional[int] = None) -> np.ndarray:
|
|
105
|
+
"""Calculate NDCG (Normalized Discounted Cumulative Gain) at position k.
|
|
106
|
+
|
|
107
|
+
:param relevances: List of true relevance scores (in the order as they are ranked)
|
|
108
|
+
:type relevances: list
|
|
109
|
+
:param k: Position up to which NDCG is computed
|
|
110
|
+
:type k: int
|
|
111
|
+
:return: The NDCG score at position k
|
|
112
|
+
:rtype: float
|
|
113
|
+
"""
|
|
114
|
+
best_dcg = dcg(sorted(relevances, reverse=True), k)
|
|
115
|
+
if best_dcg == 0:
|
|
116
|
+
return 0.0
|
|
117
|
+
|
|
118
|
+
return dcg(relevances, k) / best_dcg
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def relevance(
|
|
122
|
+
expected: np.ndarray, predicted: np.ndarray, k: Optional[int] = None
|
|
123
|
+
) -> np.ndarray:
|
|
124
|
+
"""Calculate relevance scores. Binary relevance scores
|
|
125
|
+
|
|
126
|
+
:param expected: ground-truth labels
|
|
127
|
+
:type expected: np.ndarray
|
|
128
|
+
:param predicted: predicted labels
|
|
129
|
+
:type predicted: np.ndarray
|
|
130
|
+
"""
|
|
131
|
+
expected = expected[:k]
|
|
132
|
+
predicted = predicted[:k]
|
|
133
|
+
return [1 if i in expected else 0 for i in predicted]
|
|
134
|
+
|
|
135
|
+
|
|
84
136
|
@dataclass
|
|
85
137
|
class Dataset:
|
|
86
|
-
|
|
87
138
|
labels: np.ndarray
|
|
88
139
|
vectors: np.ndarray
|
|
89
140
|
queries: np.ndarray
|
|
@@ -124,8 +175,11 @@ class Dataset:
|
|
|
124
175
|
|
|
125
176
|
d.vectors = load_matrix(vectors)
|
|
126
177
|
ndim = d.vectors.shape[1]
|
|
127
|
-
count =
|
|
128
|
-
d.vectors.shape[0], count)
|
|
178
|
+
count = (
|
|
179
|
+
min(d.vectors.shape[0], count)
|
|
180
|
+
if count is not None
|
|
181
|
+
else d.vectors.shape[0]
|
|
182
|
+
)
|
|
129
183
|
d.vectors = d.vectors[:count, :]
|
|
130
184
|
d.labels = np.arange(count, dtype=Label)
|
|
131
185
|
|
|
@@ -139,14 +193,13 @@ class Dataset:
|
|
|
139
193
|
if k is not None:
|
|
140
194
|
d.neighbors = d.neighbors[:, :k]
|
|
141
195
|
else:
|
|
142
|
-
assert k is None,
|
|
196
|
+
assert k is None, "Cant ovveride `k`, will retrieve one neighbor"
|
|
143
197
|
d.neighbors = np.reshape(d.labels, (count, 1))
|
|
144
198
|
|
|
145
199
|
else:
|
|
146
|
-
|
|
147
200
|
assert ndim is not None
|
|
148
201
|
assert count is not None
|
|
149
|
-
assert k is None,
|
|
202
|
+
assert k is None, "Cant ovveride `k`, will retrieve one neighbor"
|
|
150
203
|
|
|
151
204
|
d.vectors = random_vectors(count=count, ndim=ndim)
|
|
152
205
|
d.queries = d.vectors
|
|
@@ -158,7 +211,6 @@ class Dataset:
|
|
|
158
211
|
|
|
159
212
|
@dataclass
|
|
160
213
|
class TaskResult:
|
|
161
|
-
|
|
162
214
|
add_operations: Optional[int] = None
|
|
163
215
|
add_per_second: Optional[float] = None
|
|
164
216
|
|
|
@@ -169,12 +221,12 @@ class TaskResult:
|
|
|
169
221
|
def __repr__(self) -> str:
|
|
170
222
|
parts = []
|
|
171
223
|
if self.add_per_second:
|
|
172
|
-
parts.append(f
|
|
224
|
+
parts.append(f"{self.add_per_second:.2f} add/s")
|
|
173
225
|
if self.search_per_second:
|
|
174
|
-
parts.append(f
|
|
226
|
+
parts.append(f"{self.search_per_second:.2f} search/s")
|
|
175
227
|
if self.recall_at_one:
|
|
176
|
-
parts.append(f
|
|
177
|
-
return
|
|
228
|
+
parts.append(f"{self.recall_at_one * 100:.2f}% recall@1")
|
|
229
|
+
return ", ".join(parts)
|
|
178
230
|
|
|
179
231
|
@property
|
|
180
232
|
def add_seconds(self) -> float:
|
|
@@ -188,8 +240,9 @@ class TaskResult:
|
|
|
188
240
|
result = TaskResult()
|
|
189
241
|
if self.add_operations and other.add_operations:
|
|
190
242
|
result.add_operations = self.add_operations + other.add_operations
|
|
191
|
-
result.add_per_second = result.add_operations /
|
|
192
|
-
|
|
243
|
+
result.add_per_second = result.add_operations / (
|
|
244
|
+
self.add_seconds + other.add_seconds
|
|
245
|
+
)
|
|
193
246
|
else:
|
|
194
247
|
base = self if self.add_operations else other
|
|
195
248
|
result.add_operations = base.add_operations
|
|
@@ -198,10 +251,12 @@ class TaskResult:
|
|
|
198
251
|
if self.search_operations and other.search_operations:
|
|
199
252
|
result.search_operations = self.search_operations + other.search_operations
|
|
200
253
|
result.recall_at_one = (
|
|
201
|
-
self.recall_at_one * self.search_operations
|
|
202
|
-
other.
|
|
203
|
-
|
|
204
|
-
|
|
254
|
+
self.recall_at_one * self.search_operations
|
|
255
|
+
+ other.recall_at_one * other.search_operations
|
|
256
|
+
) / (self.search_operations + other.search_operations)
|
|
257
|
+
result.search_per_second = result.search_operations / (
|
|
258
|
+
self.search_seconds + other.search_seconds
|
|
259
|
+
)
|
|
205
260
|
else:
|
|
206
261
|
base = self if self.search_operations else other
|
|
207
262
|
result.search_operations = base.search_operations
|
|
@@ -213,21 +268,18 @@ class TaskResult:
|
|
|
213
268
|
|
|
214
269
|
@dataclass
|
|
215
270
|
class AddTask:
|
|
216
|
-
|
|
217
271
|
labels: np.ndarray
|
|
218
272
|
vectors: np.ndarray
|
|
219
273
|
|
|
220
274
|
def __call__(self, index: Index) -> TaskResult:
|
|
221
|
-
|
|
222
275
|
batch_size: int = self.vectors.shape[0]
|
|
223
276
|
old_size: int = len(index)
|
|
224
|
-
dt, _ = measure_seconds(
|
|
225
|
-
lambda: index.add(self.labels, self.vectors))
|
|
277
|
+
dt, _ = measure_seconds(lambda: index.add(self.labels, self.vectors))
|
|
226
278
|
|
|
227
279
|
assert len(index) == old_size + batch_size
|
|
228
280
|
return TaskResult(
|
|
229
281
|
add_operations=batch_size,
|
|
230
|
-
add_per_second=batch_size/dt,
|
|
282
|
+
add_per_second=batch_size / dt,
|
|
231
283
|
)
|
|
232
284
|
|
|
233
285
|
@property
|
|
@@ -251,17 +303,21 @@ class AddTask:
|
|
|
251
303
|
|
|
252
304
|
return [
|
|
253
305
|
AddTask(
|
|
254
|
-
labels=self.labels[start_row:start_row+batch_size],
|
|
255
|
-
vectors=self.vectors[start_row:start_row+batch_size, :],
|
|
256
|
-
)
|
|
306
|
+
labels=self.labels[start_row : start_row + batch_size],
|
|
307
|
+
vectors=self.vectors[start_row : start_row + batch_size, :],
|
|
308
|
+
)
|
|
309
|
+
for start_row in range(0, self.count, batch_size)
|
|
310
|
+
]
|
|
257
311
|
|
|
258
312
|
def clusters(self, number_of_clusters: int) -> List[AddTask]:
|
|
259
313
|
"""Splits this dataset into smaller chunks."""
|
|
260
314
|
|
|
261
315
|
from sklearn.cluster import KMeans
|
|
316
|
+
|
|
262
317
|
clustering = KMeans(
|
|
263
318
|
n_clusters=number_of_clusters,
|
|
264
|
-
random_state=0,
|
|
319
|
+
random_state=0,
|
|
320
|
+
n_init="auto",
|
|
265
321
|
).fit(self.vectors)
|
|
266
322
|
|
|
267
323
|
partitioning = defaultdict(list)
|
|
@@ -272,22 +328,23 @@ class AddTask:
|
|
|
272
328
|
AddTask(
|
|
273
329
|
labels=self.labels[rows],
|
|
274
330
|
vectors=self.vectors[rows, :],
|
|
275
|
-
)
|
|
331
|
+
)
|
|
332
|
+
for rows in partitioning.values()
|
|
333
|
+
]
|
|
276
334
|
|
|
277
335
|
|
|
278
336
|
@dataclass
|
|
279
337
|
class SearchTask:
|
|
280
|
-
|
|
281
338
|
queries: np.ndarray
|
|
282
339
|
neighbors: np.ndarray
|
|
283
340
|
|
|
284
341
|
def __call__(self, index: Index) -> TaskResult:
|
|
285
|
-
|
|
286
342
|
dt, results = measure_seconds(
|
|
287
|
-
lambda: index.search(self.queries, self.neighbors.shape[1])
|
|
343
|
+
lambda: index.search(self.queries, self.neighbors.shape[1])
|
|
344
|
+
)
|
|
288
345
|
|
|
289
346
|
return TaskResult(
|
|
290
|
-
search_per_second=self.queries.shape[0]/dt,
|
|
347
|
+
search_per_second=self.queries.shape[0] / dt,
|
|
291
348
|
recall_at_one=results.recall_first(self.neighbors[:, 0].flatten()),
|
|
292
349
|
)
|
|
293
350
|
|
|
@@ -296,31 +353,26 @@ class SearchTask:
|
|
|
296
353
|
|
|
297
354
|
return [
|
|
298
355
|
SearchTask(
|
|
299
|
-
queries=self.queries[start_row:start_row+batch_size, :],
|
|
300
|
-
neighbors=self.neighbors[start_row:start_row+batch_size, :],
|
|
301
|
-
)
|
|
356
|
+
queries=self.queries[start_row : start_row + batch_size, :],
|
|
357
|
+
neighbors=self.neighbors[start_row : start_row + batch_size, :],
|
|
358
|
+
)
|
|
359
|
+
for start_row in range(0, self.queries.shape[0], batch_size)
|
|
360
|
+
]
|
|
302
361
|
|
|
303
362
|
|
|
304
363
|
@dataclass
|
|
305
364
|
class Evaluation:
|
|
306
|
-
|
|
307
365
|
tasks: List[Union[AddTask, SearchTask]]
|
|
308
366
|
count: int
|
|
309
367
|
ndim: int
|
|
310
368
|
|
|
311
369
|
@staticmethod
|
|
312
370
|
def for_dataset(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
clusters: int = 1) -> Evaluation:
|
|
316
|
-
|
|
371
|
+
dataset: Dataset, batch_size: int = 0, clusters: int = 1
|
|
372
|
+
) -> Evaluation:
|
|
317
373
|
tasks = []
|
|
318
|
-
add = AddTask(
|
|
319
|
-
|
|
320
|
-
labels=dataset.labels)
|
|
321
|
-
search = SearchTask(
|
|
322
|
-
queries=dataset.queries,
|
|
323
|
-
neighbors=dataset.neighbors)
|
|
374
|
+
add = AddTask(vectors=dataset.vectors, labels=dataset.labels)
|
|
375
|
+
search = SearchTask(queries=dataset.queries, neighbors=dataset.neighbors)
|
|
324
376
|
|
|
325
377
|
if batch_size:
|
|
326
378
|
tasks.extend(add.slices(batch_size))
|
|
@@ -340,7 +392,6 @@ class Evaluation:
|
|
|
340
392
|
)
|
|
341
393
|
|
|
342
394
|
def __call__(self, index: Index, post_clean: bool = True) -> dict:
|
|
343
|
-
|
|
344
395
|
task_result = TaskResult()
|
|
345
396
|
|
|
346
397
|
try:
|