File size: 9,823 Bytes
204f4a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# src/dima/ann.py
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple

import numpy as np

from .utils import as_contig_f32, sqdist_ab


# -------------------------
# Public API
# -------------------------
ANNBackend = str  # "auto" | "faiss" | "pynndescent" | "sklearn" | "brute"


class ANNBase:
    """Minimal ANN interface used by DMAP/GPLM."""
    def build(self, X: np.ndarray) -> "ANNBase":
        raise NotImplementedError

    def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
        """

        Returns:

          idx: (B,k) int64

          D2 : (B,k) float32  (squared Euclidean distances)

        """
        raise NotImplementedError


def make_ann(

    backend: ANNBackend = "auto",

    ann_params: Optional[Dict[str, Any]] = None,

    n_jobs: int = -1,

) -> Tuple[ANNBase, str]:
    """

    Create an ANN implementation.

    backend:

      - "auto": prefers faiss, then pynndescent, then sklearn, else brute

      - "faiss": FAISS (if installed)

      - "pynndescent": NNDescent (if installed)

      - "sklearn": sklearn NearestNeighbors (if installed)

      - "brute": exact brute force

    ann_params:

      - for faiss:

          index: "flat" | "hnsw" | "ivf_flat"

          hnsw_M: int (default 32)

          ef_search: int (default 64)

          ef_construction: int (default 200)

          ivf_nlist: int (default 1024)

          ivf_nprobe: int (default 16)

          use_float16: bool (default False; GPU only typically)

      - for pynndescent:

          n_trees: int

          n_iters: int

          metric: str (default "euclidean")

      - for sklearn:

          algorithm: str (default "auto")

          leaf_size: int (default 40)

          metric: str (default "euclidean")

    """
    ann_params = {} if ann_params is None else dict(ann_params)
    b = (backend or "auto").lower()

    if b == "auto":
        for cand in ("faiss", "pynndescent", "sklearn", "brute"):
            ann, used = make_ann(cand, ann_params=ann_params, n_jobs=n_jobs)
            if used != "brute" or cand == "brute":
                return ann, used
        return BruteANN(), "brute"

    if b == "faiss":
        try:
            return FaissANN(ann_params=ann_params), "faiss"
        except Exception as e:
            raise ImportError(
                "FAISS backend requested but faiss is not available or failed to initialize. "
                "Install with: pip install dima[faiss]"
            ) from e

    if b == "pynndescent":
        try:
            return PyNNDescentANN(ann_params=ann_params), "pynndescent"
        except Exception as e:
            raise ImportError(
                "pynndescent backend requested but pynndescent is not available. "
                "Install with: pip install pynndescent"
            ) from e

    if b == "sklearn":
        try:
            return SklearnANN(n_jobs=n_jobs, ann_params=ann_params), "sklearn"
        except Exception as e:
            raise ImportError(
                "sklearn backend requested but scikit-learn is not available. "
                "Install with: pip install scikit-learn"
            ) from e

    if b == "brute":
        return BruteANN(), "brute"

    raise ValueError(f"Unknown ANN backend: {backend!r}")


# -------------------------
# Brute-force (no deps)
# -------------------------
class BruteANN(ANNBase):
    def __init__(self):
        self.X = None

    def build(self, X: np.ndarray) -> "BruteANN":
        self.X = as_contig_f32(X)
        return self

    def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
        if self.X is None:
            raise RuntimeError("BruteANN.search called before build().")
        X = self.X
        Q = as_contig_f32(Q)
        k = int(k)
        if k <= 0:
            raise ValueError("k must be >= 1")
        if k > X.shape[0]:
            k = X.shape[0]

        D2 = sqdist_ab(Q, X)  # (B,N)
        idx = np.argpartition(D2, kth=k - 1, axis=1)[:, :k]
        rows = np.arange(Q.shape[0])[:, None]
        d2 = D2[rows, idx]

        # sort within k
        ordk = np.argsort(d2, axis=1)
        idx = idx[rows, ordk].astype(np.int64)
        d2 = d2[rows, ordk].astype(np.float32)
        return idx, d2


# -------------------------
# FAISS
# -------------------------
class FaissANN(ANNBase):
    def __init__(self, ann_params: Optional[Dict[str, Any]] = None):
        self.ann_params = {} if ann_params is None else dict(ann_params)
        self.index = None
        self.X = None  # keep reference for possible rebuild

        # delayed import
        import faiss  # type: ignore
        self.faiss = faiss

    def _build_index(self, d: int):
        p = self.ann_params
        faiss = self.faiss

        index_kind = str(p.get("index", "flat")).lower()

        if index_kind == "flat":
            index = faiss.IndexFlatL2(d)

        elif index_kind == "hnsw":
            M = int(p.get("hnsw_M", 32))
            index = faiss.IndexHNSWFlat(d, M)
            # optional tuning
            ef_search = int(p.get("ef_search", 64))
            ef_constr = int(p.get("ef_construction", 200))
            index.hnsw.efSearch = ef_search
            index.hnsw.efConstruction = ef_constr

        elif index_kind == "ivf_flat":
            nlist = int(p.get("ivf_nlist", 1024))
            quantizer = faiss.IndexFlatL2(d)
            index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_L2)
            nprobe = int(p.get("ivf_nprobe", 16))
            index.nprobe = nprobe

        else:
            raise ValueError(f"Unknown faiss index kind: {index_kind!r}")

        return index

    def build(self, X: np.ndarray) -> "FaissANN":
        X = as_contig_f32(X)
        self.X = X
        faiss = self.faiss
        d = int(X.shape[1])

        index = self._build_index(d)

        # IVF needs training
        if hasattr(index, "is_trained") and not index.is_trained:
            index.train(X)

        index.add(X)
        self.index = index
        return self

    def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
        if self.index is None:
            raise RuntimeError("FaissANN.search called before build().")
        Q = as_contig_f32(Q)
        k = int(k)
        if k <= 0:
            raise ValueError("k must be >= 1")

        # FAISS returns (distances, indices); for L2 these are squared distances
        D2, I = self.index.search(Q, k)
        return I.astype(np.int64), D2.astype(np.float32)


# -------------------------
# PyNNDescent
# -------------------------
class PyNNDescentANN(ANNBase):
    def __init__(self, ann_params: Optional[Dict[str, Any]] = None):
        self.ann_params = {} if ann_params is None else dict(ann_params)
        self.index = None
        self.X = None

        from pynndescent import NNDescent  # type: ignore
        self.NNDescent = NNDescent

    def build(self, X: np.ndarray) -> "PyNNDescentANN":
        X = as_contig_f32(X)
        self.X = X
        p = self.ann_params

        metric = p.get("metric", "euclidean")
        n_trees = p.get("n_trees", None)
        n_iters = p.get("n_iters", None)

        kwargs: Dict[str, Any] = {"metric": metric}
        if n_trees is not None:
            kwargs["n_trees"] = int(n_trees)
        if n_iters is not None:
            kwargs["n_iters"] = int(n_iters)

        self.index = self.NNDescent(X, **kwargs)
        return self

    def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
        if self.index is None:
            raise RuntimeError("PyNNDescentANN.search called before build().")
        Q = as_contig_f32(Q)
        k = int(k)
        if k <= 0:
            raise ValueError("k must be >= 1")

        # NNDescent returns (indices, distances) with euclidean distances (not squared)
        I, d = self.index.query(Q, k=k)
        D2 = (d.astype(np.float32) ** 2)
        return I.astype(np.int64), D2


# -------------------------
# scikit-learn NearestNeighbors
# -------------------------
class SklearnANN(ANNBase):
    def __init__(self, n_jobs: int = -1, ann_params: Optional[Dict[str, Any]] = None):
        self.ann_params = {} if ann_params is None else dict(ann_params)
        self.n_jobs = int(n_jobs)
        self.nn = None
        self.X = None

        from sklearn.neighbors import NearestNeighbors  # type: ignore
        self.NearestNeighbors = NearestNeighbors

    def build(self, X: np.ndarray) -> "SklearnANN":
        X = as_contig_f32(X)
        self.X = X
        p = self.ann_params

        algorithm = p.get("algorithm", "auto")
        leaf_size = int(p.get("leaf_size", 40))
        metric = p.get("metric", "euclidean")

        self.nn = self.NearestNeighbors(
            n_neighbors=1,  # set later in search
            algorithm=algorithm,
            leaf_size=leaf_size,
            metric=metric,
            n_jobs=self.n_jobs,
        )
        self.nn.fit(X)
        return self

    def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
        if self.nn is None:
            raise RuntimeError("SklearnANN.search called before build().")
        Q = as_contig_f32(Q)
        k = int(k)
        if k <= 0:
            raise ValueError("k must be >= 1")

        self.nn.set_params(n_neighbors=k)
        d, I = self.nn.kneighbors(Q, return_distance=True)
        # sklearn distances are euclidean; square them
        D2 = (d.astype(np.float32) ** 2)
        return I.astype(np.int64), D2