usearch 0.20.0 → 0.21.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.
- package/.github/workflows/prerelease.yml +27 -0
- package/Cargo.toml +1 -1
- package/README.md +20 -1
- package/conanfile.py +1 -1
- package/cpp/CMakeLists.txt +6 -2
- package/cpp/bench.cpp +41 -4
- package/docs/benchmarks.md +14 -12
- package/include/usearch/index.hpp +475 -56
- package/include/usearch/index_punned_dense.hpp +68 -10
- package/include/usearch/index_punned_helpers.hpp +28 -3
- package/package.json +1 -1
- package/python/lib.cpp +57 -5
- package/python/scripts/join.py +151 -0
- package/python/scripts/test.py +19 -0
- package/python/usearch/eval.py +11 -3
- package/python/usearch/index.py +119 -10
- package/python/usearch/join.py +0 -98
package/python/usearch/index.py
CHANGED
|
@@ -5,12 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
# Python tooling, linters, and static analyzers. It also embeds JIT
|
|
6
6
|
# into the primary `Index` class, connecting USearch with Numba.
|
|
7
7
|
import os
|
|
8
|
+
import math
|
|
8
9
|
from typing import Optional, Union, NamedTuple, List, Iterable
|
|
9
10
|
|
|
10
11
|
import numpy as np
|
|
11
12
|
from tqdm import tqdm
|
|
12
13
|
|
|
13
|
-
from usearch.compiled import Index as _CompiledIndex, IndexMetadata
|
|
14
|
+
from usearch.compiled import Index as _CompiledIndex, IndexMetadata, IndexStats
|
|
14
15
|
from usearch.compiled import SparseIndex as _CompiledSetsIndex
|
|
15
16
|
|
|
16
17
|
from usearch.compiled import MetricKind, ScalarKind, MetricSignature
|
|
@@ -141,6 +142,13 @@ class Matches(NamedTuple):
|
|
|
141
142
|
best_matches = self.labels if not self.is_multiple else self.labels[:, 0]
|
|
142
143
|
return np.sum(best_matches == expected) / len(expected)
|
|
143
144
|
|
|
145
|
+
def recall(self, expected: np.ndarray) -> float:
|
|
146
|
+
assert len(expected) == self.batch_size
|
|
147
|
+
recall = 0
|
|
148
|
+
for i in range(self.batch_size):
|
|
149
|
+
recall += expected[i] in self.labels[i]
|
|
150
|
+
return recall / len(expected)
|
|
151
|
+
|
|
144
152
|
def __repr__(self) -> str:
|
|
145
153
|
return (
|
|
146
154
|
f"usearch.Matches({self.total_matches})"
|
|
@@ -347,6 +355,10 @@ class Index:
|
|
|
347
355
|
count_labels = len(labels) if isinstance(labels, Iterable) else 1
|
|
348
356
|
assert count_labels == count_vectors
|
|
349
357
|
|
|
358
|
+
# If logging is requested, and batch size is undefined, set it to grow 1% at a time:
|
|
359
|
+
if log and batch_size == 0:
|
|
360
|
+
batch_size = int(math.ceil(count_vectors / 100))
|
|
361
|
+
|
|
350
362
|
# Split into batches and log progress, if needed
|
|
351
363
|
if batch_size:
|
|
352
364
|
labels = [
|
|
@@ -401,13 +413,53 @@ class Index:
|
|
|
401
413
|
:return: Approximate matches for one or more queries
|
|
402
414
|
:rtype: Matches
|
|
403
415
|
"""
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
416
|
+
|
|
417
|
+
assert isinstance(vectors, np.ndarray), "Expects a NumPy array"
|
|
418
|
+
assert vectors.ndim == 1 or vectors.ndim == 2, "Expects a matrix or vector"
|
|
419
|
+
count_vectors = vectors.shape[0] if vectors.ndim == 2 else 1
|
|
420
|
+
|
|
421
|
+
if log and batch_size == 0:
|
|
422
|
+
batch_size = int(math.ceil(count_vectors / 100))
|
|
423
|
+
|
|
424
|
+
if batch_size:
|
|
425
|
+
tasks = [
|
|
426
|
+
vectors[start_row : start_row + batch_size, :]
|
|
427
|
+
for start_row in range(0, count_vectors, batch_size)
|
|
428
|
+
]
|
|
429
|
+
tasks_matches = []
|
|
430
|
+
name = log if isinstance(log, str) else "Search"
|
|
431
|
+
pbar = tqdm(
|
|
432
|
+
tasks,
|
|
433
|
+
desc=name,
|
|
434
|
+
total=count_vectors,
|
|
435
|
+
unit="Vector",
|
|
436
|
+
disable=log is False,
|
|
437
|
+
)
|
|
438
|
+
for vectors in tasks:
|
|
439
|
+
tuple_ = self._compiled.search(
|
|
440
|
+
vectors,
|
|
441
|
+
k,
|
|
442
|
+
exact=exact,
|
|
443
|
+
threads=threads,
|
|
444
|
+
)
|
|
445
|
+
tasks_matches.append(Matches(*tuple_))
|
|
446
|
+
pbar.update(vectors.shape[0])
|
|
447
|
+
|
|
448
|
+
pbar.close()
|
|
449
|
+
return Matches(
|
|
450
|
+
labels=np.vstack([m.labels for m in tasks_matches]),
|
|
451
|
+
distances=np.vstack([m.distances for m in tasks_matches]),
|
|
452
|
+
counts=np.concatenate([m.counts for m in tasks_matches], axis=None),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
else:
|
|
456
|
+
tuple_ = self._compiled.search(
|
|
457
|
+
vectors,
|
|
458
|
+
k,
|
|
459
|
+
exact=exact,
|
|
460
|
+
threads=threads,
|
|
461
|
+
)
|
|
462
|
+
return Matches(*tuple_)
|
|
411
463
|
|
|
412
464
|
@property
|
|
413
465
|
def specs(self) -> dict:
|
|
@@ -442,8 +494,8 @@ class Index:
|
|
|
442
494
|
return self._compiled.ndim
|
|
443
495
|
|
|
444
496
|
@property
|
|
445
|
-
def metric(self) -> MetricKind:
|
|
446
|
-
return self._metric_kind
|
|
497
|
+
def metric(self) -> Union[MetricKind, CompiledMetric]:
|
|
498
|
+
return self._metric_jit if self._metric_jit else self._metric_kind
|
|
447
499
|
|
|
448
500
|
@property
|
|
449
501
|
def dtype(self) -> ScalarKind:
|
|
@@ -501,6 +553,26 @@ class Index:
|
|
|
501
553
|
def close(self):
|
|
502
554
|
self._compiled.close()
|
|
503
555
|
|
|
556
|
+
def copy(self) -> Index:
|
|
557
|
+
result = Index(
|
|
558
|
+
ndim=self.ndim,
|
|
559
|
+
metric=self.metric,
|
|
560
|
+
dtype=self.dtype,
|
|
561
|
+
connectivity=self.connectivity,
|
|
562
|
+
expansion_add=self.expansion_add,
|
|
563
|
+
expansion_search=self.expansion_search,
|
|
564
|
+
path=self.path,
|
|
565
|
+
)
|
|
566
|
+
result._compiled = self._compiled.copy()
|
|
567
|
+
return result
|
|
568
|
+
|
|
569
|
+
def join(self, other: Index, max_proposals: int = 0, exact: bool = False) -> dict:
|
|
570
|
+
return self._compiled.join(
|
|
571
|
+
other=other._compiled,
|
|
572
|
+
max_proposals=max_proposals,
|
|
573
|
+
exact=exact,
|
|
574
|
+
)
|
|
575
|
+
|
|
504
576
|
@property
|
|
505
577
|
def labels(self) -> np.ndarray:
|
|
506
578
|
return self._compiled.labels
|
|
@@ -530,3 +602,40 @@ class Index:
|
|
|
530
602
|
vector = self._compiled.__getitem__(label, get_dtype)
|
|
531
603
|
view_dtype = _to_numpy_dtype(dtype)
|
|
532
604
|
return None if vector is None else vector.view(view_dtype)
|
|
605
|
+
|
|
606
|
+
def remove(self, label: int):
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
@property
|
|
610
|
+
def max_level(self) -> int:
|
|
611
|
+
return self._compiled.max_level
|
|
612
|
+
|
|
613
|
+
@property
|
|
614
|
+
def levels_stats(self) -> IndexStats:
|
|
615
|
+
return self._compiled.levels_stats
|
|
616
|
+
|
|
617
|
+
def level_stats(self, level: int) -> IndexStats:
|
|
618
|
+
return self._compiled.level_stats(level)
|
|
619
|
+
|
|
620
|
+
def __repr__(self) -> str:
|
|
621
|
+
return f"usearch.index.Index({self.dtype} x {self.ndim}, {self.metric}, expansion: {self.expansion_add} & {self.expansion_search}, {len(self)} vectors across {self.max_level+1} levels)"
|
|
622
|
+
|
|
623
|
+
def _repr_pretty_(self) -> str:
|
|
624
|
+
level_stats = [
|
|
625
|
+
f"--- {i}. {self.level_stats(i)} nodes" for i in range(self.max_level)
|
|
626
|
+
]
|
|
627
|
+
return "\n".join(
|
|
628
|
+
[
|
|
629
|
+
"usearch.index.Index",
|
|
630
|
+
"- config" f"-- data type: {self.dtype}",
|
|
631
|
+
f"-- dimensions: {self.ndim}",
|
|
632
|
+
f"-- metric: {self.metric}",
|
|
633
|
+
f"-- expansion on addition:{self.expansion_add} candidates",
|
|
634
|
+
f"-- expansion on search: {self.expansion_search} candidates",
|
|
635
|
+
"- state",
|
|
636
|
+
f"-- size: {self.size} vectors",
|
|
637
|
+
f"-- memory usage: {self.memory_usage} bytes",
|
|
638
|
+
f"-- max level: {self.max_level}",
|
|
639
|
+
*level_stats,
|
|
640
|
+
]
|
|
641
|
+
)
|
package/python/usearch/join.py
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
from collections import deque
|
|
2
|
-
|
|
3
|
-
import numpy as np
|
|
4
|
-
from numba import njit
|
|
5
|
-
|
|
6
|
-
from index import Index, Matches, Label
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class bidict(dict):
|
|
10
|
-
def __init__(self, *args, **kwargs):
|
|
11
|
-
super(bidict, self).__init__(*args, **kwargs)
|
|
12
|
-
self.inverse = {}
|
|
13
|
-
for key, value in self.items():
|
|
14
|
-
self.inverse.setdefault(value, []).append(key)
|
|
15
|
-
|
|
16
|
-
def __setitem__(self, key, value):
|
|
17
|
-
if key in self:
|
|
18
|
-
self.inverse[self[key]].remove(key)
|
|
19
|
-
super(bidict, self).__setitem__(key, value)
|
|
20
|
-
self.inverse.setdefault(value, []).append(key)
|
|
21
|
-
|
|
22
|
-
def __delitem__(self, key):
|
|
23
|
-
self.inverse.setdefault(self[key], []).remove(key)
|
|
24
|
-
if self[key] in self.inverse and not self.inverse[self[key]]:
|
|
25
|
-
del self.inverse[self[key]]
|
|
26
|
-
super(bidict, self).__delitem__(key)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@njit
|
|
30
|
-
def index_in_array(array: np.ndarray, item: Label) -> int:
|
|
31
|
-
for idx, val in np.ndenumerate(array):
|
|
32
|
-
if val == item:
|
|
33
|
-
return idx
|
|
34
|
-
|
|
35
|
-
return len(array)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def semantic_join(a: Index, b: Index, resolution: int = 10) -> bidict:
|
|
39
|
-
"""Performs a Semantic Join, matching one entry from `a` with
|
|
40
|
-
one entry from `b`, converging towards a Stable Marriage.
|
|
41
|
-
Assuming the collections can be different in size, classical solution
|
|
42
|
-
doesn't provide
|
|
43
|
-
|
|
44
|
-
:param resolution: Approximate matches per member to consider, defaults to 10
|
|
45
|
-
:type resolution: int, optional
|
|
46
|
-
:return: Bidirectional mapping from men to women and `.inverse`
|
|
47
|
-
:rtype: bidict
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
men, women = (a, b) if len(a) > len(b) else (b, a)
|
|
51
|
-
matches = bidict()
|
|
52
|
-
|
|
53
|
-
men_vectors = np.vstack(men[i] for i in range(len(men)))
|
|
54
|
-
women_vectors = np.vstack(women[i] for i in range(len(women)))
|
|
55
|
-
|
|
56
|
-
man_count_proposed = np.zeros(len(men))
|
|
57
|
-
man_to_women_preferences: Matches = men.search(women_vectors, resolution)
|
|
58
|
-
woman_to_men_preferences: Matches = women.search(men_vectors, resolution)
|
|
59
|
-
|
|
60
|
-
# A nice optimization is to prioritize free men by the quality of their
|
|
61
|
-
# best remaining variant. TODO: Replace with `heapq`
|
|
62
|
-
free_men = deque(range(len(men)))
|
|
63
|
-
|
|
64
|
-
idle_cycles: int = 0
|
|
65
|
-
hopeless_men_count = len(men) - len(women)
|
|
66
|
-
while len(free_men) > hopeless_men_count:
|
|
67
|
-
# In the worst case scenario we may need to match more candidates for every
|
|
68
|
-
# remaining man. This, however, may drastically increase the runtime.
|
|
69
|
-
if len(free_men) == idle_cycles:
|
|
70
|
-
break
|
|
71
|
-
|
|
72
|
-
man: Label = free_men.popleft()
|
|
73
|
-
count_proposals = man_count_proposed[man]
|
|
74
|
-
if count_proposals == man_to_women_preferences.counts[man]:
|
|
75
|
-
free_men.append(man)
|
|
76
|
-
idle_cycles += 1
|
|
77
|
-
continue
|
|
78
|
-
|
|
79
|
-
woman: Label = man_to_women_preferences.labels[man, count_proposals]
|
|
80
|
-
man_count_proposed[man] += 1
|
|
81
|
-
|
|
82
|
-
if woman not in matches.inverse:
|
|
83
|
-
matches[man] = woman
|
|
84
|
-
|
|
85
|
-
else:
|
|
86
|
-
her_preferences = woman_to_men_preferences.labels[woman, :]
|
|
87
|
-
husband: Label = matches.inverse[woman]
|
|
88
|
-
husband_rank = index_in_array(her_preferences, husband)
|
|
89
|
-
challenger_rank = index_in_array(her_preferences, man)
|
|
90
|
-
if challenger_rank < husband_rank:
|
|
91
|
-
del matches[husband]
|
|
92
|
-
free_men.append(husband)
|
|
93
|
-
matches[man] = woman
|
|
94
|
-
|
|
95
|
-
else:
|
|
96
|
-
free_men.append(man)
|
|
97
|
-
|
|
98
|
-
return matches
|