-
Notifications
You must be signed in to change notification settings - Fork 14
Adding HannoyTransformer #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
3ba8f4a
3110b95
cb12d3d
a5614e3
e9f3d0c
fb9cf4b
b36416b
9ce6bfd
83c3cdc
261b1cc
1519e55
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| # hannoy needs a filesystem path (LMDB-backed) | ||
| import tempfile | ||
| from itertools import count | ||
|
|
||
| import numpy as np | ||
| from hannoy import Database, Metric | ||
| from scipy.sparse import csr_matrix | ||
| from sklearn.base import BaseEstimator, TransformerMixin | ||
| from sklearn.utils import Tags, TargetTags, TransformerTags | ||
| from sklearn.utils.validation import validate_data | ||
|
|
||
| from ..utils import TransformerChecksMixin | ||
|
|
||
| # guards against panic in Rust, i.e. unrecoverable errors | ||
| SUPPORTED_M = frozenset({4, 8, 12, 16, 24, 32}) | ||
|
|
||
| # due to hannoy sharing one global LMDB environement | ||
| # using to avoid colliding | ||
| _index_counter = count() | ||
|
|
||
| class HannoyTransformer(TransformerChecksMixin, TransformerMixin, BaseEstimator): | ||
| # known issue where multiple Database instances silently share the first one's LMDB env | ||
|
|
||
| def __init__( | ||
| self, | ||
| n_neighbors=5, | ||
| *, | ||
| metric=None, | ||
| path=None, | ||
| m=16, | ||
| ef_construction=96, | ||
| ef_search=200, | ||
| ): | ||
| self.n_neighbors = n_neighbors | ||
| self.metric = metric | ||
| # LMDB directory for the index; if None = auto-create a temp dir | ||
| self.path = path | ||
| self.m = m | ||
| self.ef_construction = ef_construction | ||
| self.ef_search = ef_search | ||
|
|
||
| def fit(self, X, y=None): | ||
| X = validate_data(self, X, dtype=np.float32) | ||
| # guard to avoid panic abort | ||
| if self.m not in SUPPORTED_M: | ||
| raise ValueError( | ||
| f"m={self.m!r} is not supported by hannoy; " | ||
| f"choose one of {sorted(SUPPORTED_M)}." | ||
| ) | ||
| self.n_samples_fit_ = X.shape[0] | ||
| metric = Metric.EUCLIDEAN if self.metric is None else self.metric | ||
| path = ( | ||
| self.path if self.path is not None else tempfile.mkdtemp(prefix="hannoy_") | ||
| ) | ||
| self._index_ = next(_index_counter) % 2**16 | ||
|
|
||
| # metric is fixed for the entire database | ||
| self.hannoy_db_ = Database(path, metric) | ||
| with self.hannoy_db_.writer( | ||
| X.shape[1], index=self._index_, m=self.m, ef=self.ef_construction | ||
| ) as writer: | ||
| writer.add_items(list(range(self.n_samples_fit_)), X) | ||
| self.hannoy_reader_ = self.hannoy_db_.reader(index=self._index_) | ||
| return self | ||
|
|
||
| def transform(self, X): | ||
| # verify that fit was called and + that X has the right number of features | ||
| X = self._transform_checks(X, "hannoy_reader_", dtype=np.float32) | ||
| return self._transform(X) | ||
|
|
||
| def fit_transform(self, X, y=None): | ||
| return self.fit(X)._transform(X=None) | ||
|
|
||
| def _transform(self, X): | ||
| # how many points | ||
| n_samples_transform = self.n_samples_fit_ if X is None else X.shape[0] | ||
| n_neighbors = self.n_neighbors + 1 | ||
| # pre allocating indicies for which points are neighbots | ||
| indices = np.empty((n_samples_transform, n_neighbors), dtype=np.int32) | ||
| distances = np.empty((n_samples_transform, n_neighbors), dtype=np.float32) | ||
|
|
||
|
|
||
| if X is None: | ||
| # by_item path | ||
| for i in range(n_samples_transform): | ||
| neighbours = self.hannoy_reader_.by_item( | ||
| i, n=self.n_neighbors, ef_search=self.ef_search | ||
| ) | ||
| indices[i, 0] = i | ||
| distances[i, 0] = 0.0 | ||
| indices[i, 1:] = [j for j, _ in neighbours] | ||
| distances[i, 1:] = [d for _, d in neighbours] | ||
| else: | ||
| # by_vec path | ||
| for i, x in enumerate(X): | ||
| neighbours = self.hannoy_reader_.by_vec( | ||
| x.tolist(), n=n_neighbors, ef_search=self.ef_search | ||
| ) | ||
| indices[i] = [j for j, _ in neighbours] | ||
| distances[i] = [d for _, d in neighbours] | ||
|
|
||
| metric = Metric.EUCLIDEAN if self.metric is None else self.metric | ||
| if metric == Metric.EUCLIDEAN: | ||
| np.sqrt(distances, out=distances) | ||
|
|
||
| indptr = np.arange(0, n_samples_transform * n_neighbors + 1, n_neighbors) | ||
| return csr_matrix( | ||
| (distances.ravel(), indices.ravel(), indptr), | ||
| shape=(n_samples_transform, self.n_samples_fit_), | ||
| ) | ||
|
|
||
| def __sklearn_tags__(self) -> Tags: | ||
| # metadata | ||
| return Tags( | ||
| estimator_type="transformer", | ||
| target_tags=TargetTags(required=False), | ||
| transformer_tags=TransformerTags(preserves_dtype=[np.float32]), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will hannoy only ever output float32?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it seems to be the case, when I was looking through the code. When looking at the hannoy's distance the function returns as -> f32 (src/distance/mod.rs:41), so every metric returns f32 distances. The Python bindings also do the same thing: |
||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import numpy as np | ||
| import pytest | ||
|
|
||
| from sklearn_ann.test_utils import assert_row_close, needs | ||
|
|
||
| try: | ||
| from hannoy import Metric | ||
|
|
||
| from sklearn_ann.kneighbors.hannoy import HannoyTransformer | ||
| except ImportError: | ||
| pass | ||
|
|
||
|
|
||
| @needs.hannoy | ||
| def test_euclidean(random_small, random_small_pdists): | ||
| trans = HannoyTransformer(metric=Metric.EUCLIDEAN) | ||
| mat = trans.fit_transform(random_small) | ||
| euclidean_dist = random_small_pdists["euclidean"] | ||
| assert_row_close(mat, euclidean_dist) | ||
|
|
||
|
|
||
| @needs.hannoy | ||
| def test_cosine(random_small, random_small_pdists): | ||
| trans = HannoyTransformer(metric=Metric.COSINE) | ||
| mat = trans.fit_transform(random_small) | ||
| # hannoy's cosine metric returns (1 - cos_sim) / 2 | ||
| cosine_dist = random_small_pdists["cosine"] / 2 | ||
| assert_row_close(mat, cosine_dist) | ||
|
|
||
|
|
||
| @needs.hannoy | ||
| def test_transform_matches_fit_transform(random_small): | ||
| # fit_transform uses the by_item path; fit().transform() uses by_vec | ||
| # comparing the two paths | ||
| trans = HannoyTransformer(metric=Metric.EUCLIDEAN) | ||
| by_item = trans.fit_transform(random_small) | ||
| by_vec = trans.fit(random_small).transform(random_small) | ||
| by_item.sort_indices() | ||
| by_vec.sort_indices() | ||
| np.testing.assert_array_equal(by_item.indices, by_vec.indices) | ||
| np.testing.assert_allclose(by_item.data, by_vec.data, atol=1e-5) |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oof that looks slow. Are these APIs enough to avoid
forloops? nnethercott/hannoy#127If not, we should file another issue.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From what I understand, it is not yet the case. I am having a bit of a hard time examining Rust code but, it seems like the batch query is still missing, so the loops here are needed for now.
In the issue #127 that I opened up, I mentioned three things:
by_item,batch insert, andbatch query. The first two were resolved and merged from what I am understanding; however, the read side has no batch method. So_transformstill makes one call into Rust per row, which is the slow part you flagged. I will add another comment on this issue #127 to bring up to the attention.