usearch 0.19.3 → 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.
@@ -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
- tuple_ = self._compiled.search(
405
- vectors,
406
- k,
407
- exact=exact,
408
- threads=threads,
409
- )
410
- return Matches(*tuple_)
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
+ )
@@ -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