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.
- package/.github/workflows/release.yml +62 -7
- package/.github/workflows/update_version.sh +6 -1
- package/.vscode/launch.json +21 -22
- package/.vscode/settings.json +7 -1
- package/CITATION.cff +4 -4
- package/CMakeLists.txt +1 -1
- package/Cargo.toml +1 -1
- package/Package.swift +0 -1
- package/README.md +100 -27
- package/binding.gyp +0 -1
- package/build.gradle +1 -1
- package/build.rs +3 -3
- package/c/lib.cpp +32 -9
- package/c/usearch.h +67 -36
- package/conanfile.py +1 -1
- package/{src → cpp}/bench.cpp +52 -36
- package/{src → cpp}/test.cpp +17 -7
- package/docs/benchmarks.md +33 -10
- package/docs/c/index.rst +11 -0
- package/docs/c/reference.rst +7 -0
- package/docs/compilation.md +24 -8
- package/docs/conf.dox +6 -6
- package/docs/conf.py +3 -1
- package/docs/cpp/index.rst +11 -0
- package/docs/cpp/reference.rst +7 -0
- package/docs/index.rst +10 -0
- package/docs/java/index.rst +11 -0
- package/docs/java/reference.rst +7 -0
- package/docs/javascript/index.rst +11 -0
- package/docs/javascript/reference.rst +9 -0
- package/docs/python/index.rst +11 -0
- package/docs/python/reference.rst +35 -0
- package/golang/lib.go +1 -1
- package/include/usearch/{usearch.hpp → index.hpp} +152 -148
- package/include/usearch/index_punned_dense.hpp +633 -0
- package/include/usearch/index_punned_helpers.hpp +528 -0
- package/java/cloud/unum/usearch/cloud_unum_usearch_Index.cpp +8 -7
- package/javascript/lib.cpp +26 -50
- package/javascript/usearch.js +117 -2
- package/objc/USearchObjective.mm +145 -49
- package/objc/include/USearchObjective.h +106 -30
- package/package.json +2 -2
- package/python/lib.cpp +178 -320
- package/python/scripts/bench.ipynb +170 -0
- package/python/scripts/bench.py +102 -185
- package/python/scripts/index_faiss.py +100 -0
- package/python/scripts/test.py +57 -34
- package/python/usearch/eval.py +353 -0
- package/python/usearch/index.py +138 -117
- package/python/usearch/io.py +7 -2
- package/python/usearch/join.py +100 -0
- package/python/usearch/numba.py +101 -0
- package/rust/lib.cpp +27 -14
- package/rust/lib.hpp +4 -10
- package/rust/lib.rs +50 -17
- package/setup.py +12 -2
- package/simsimd/include/simsimd/simsimd.h +32 -3
- package/swift/Index+Sugar.swift +55 -29
- package/swift/Test.swift +1 -1
- package/wolfram/lib.cpp +4 -4
- package/python/scripts/bench_params.py +0 -161
- package/python/usearch/synthetic.py +0 -32
- package/src/punned.hpp +0 -887
- /package/{src → cpp}/CMakeLists.txt +0 -0
package/python/scripts/test.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
21
|
-
MetricKind.
|
|
22
|
-
MetricKind.
|
|
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
|
-
|
|
99
|
-
vectors =
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
assert
|
|
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
|
|
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
|
|
128
|
-
a_array = carray(a,
|
|
129
|
-
b_array = carray(b,
|
|
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(
|
|
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
|
-
|
|
138
|
-
|
|
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 =
|
|
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
|
|
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
|
+
}
|