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.
@@ -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('Unsupported `index` type')
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 ** 9)
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 = min(
128
- d.vectors.shape[0], count) if count is not None else d.vectors.shape[0]
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, 'Cant ovveride `k`, will retrieve one neighbor'
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, 'Cant ovveride `k`, will retrieve one neighbor'
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'{self.add_per_second:.2f} add/s')
224
+ parts.append(f"{self.add_per_second:.2f} add/s")
173
225
  if self.search_per_second:
174
- parts.append(f'{self.search_per_second:.2f} search/s')
226
+ parts.append(f"{self.search_per_second:.2f} search/s")
175
227
  if self.recall_at_one:
176
- parts.append(f'{self.recall_at_one * 100:.2f}% recall@1')
177
- return ', '.join(parts)
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
- (self.add_seconds + other.add_seconds)
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 + 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)
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
- ) for start_row in range(0, self.count, batch_size)]
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, n_init='auto',
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
- ) for rows in partitioning.values()]
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
- ) for start_row in range(0, self.queries.shape[0], batch_size)]
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
- dataset: Dataset,
314
- batch_size: int = 0,
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
- vectors=dataset.vectors,
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: