-
Notifications
You must be signed in to change notification settings - Fork 50
make all neighbors more robust #769
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
Changes from all commits
2f518f0
0f6798f
2e45b35
ba3e7f5
a6b615d
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 |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import math | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| import cupy as cp | ||
|
|
@@ -17,6 +18,38 @@ | |
| from rapids_singlecell.preprocessing._neighbors import _Metrics | ||
|
|
||
|
|
||
| def _default_overlap_factor(n_clusters: int) -> int: | ||
| """Overlap needed to hold recall as the dataset is split into more clusters.""" | ||
| if n_clusters <= 1: | ||
| return 1 | ||
| return max(2, math.ceil(math.log2(n_clusters))) | ||
|
|
||
|
|
||
| def _all_neighbors_batching(algorithm_kwds: Mapping) -> tuple[int, int]: | ||
| """Resolve ``(n_clusters, overlap_factor)`` for the cuVS all-neighbors build.""" | ||
| n_devices = cp.cuda.runtime.getDeviceCount() | ||
| n_clusters = algorithm_kwds.get("n_clusters") | ||
| overlap_factor = algorithm_kwds.get("overlap_factor") | ||
| if n_clusters is None: | ||
| n_clusters = 1 if n_devices == 1 else n_devices | ||
| while n_clusters > 1 and n_clusters <= ( | ||
| _default_overlap_factor(n_clusters) | ||
| if overlap_factor is None | ||
| else overlap_factor | ||
| ): | ||
| n_clusters += n_devices | ||
| if overlap_factor is None: | ||
| overlap_factor = _default_overlap_factor(n_clusters) | ||
| if n_clusters > 1: | ||
| overlap_factor = min(overlap_factor, n_clusters - 1) | ||
| if n_clusters > 1 and overlap_factor >= n_clusters: | ||
| raise ValueError( | ||
| f"'n_clusters' ({n_clusters}) must be greater than 'overlap_factor' " | ||
| f"({overlap_factor}) when batching the all_neighbors build." | ||
| ) | ||
| return n_clusters, overlap_factor | ||
|
|
||
|
|
||
| def _all_neighbors_knn( | ||
| X: np.ndarray, | ||
| Y: np.ndarray, | ||
|
|
@@ -32,32 +65,40 @@ def _all_neighbors_knn( | |
| "Please update your cuvs installation." | ||
| ) | ||
| algo = algorithm_kwds.get("algo", "nn_descent") | ||
| n_devices = cp.cuda.runtime.getDeviceCount() | ||
| if n_devices == 1: | ||
| n_clusters, overlap_factor = _all_neighbors_batching(algorithm_kwds) | ||
| if n_clusters == 1: | ||
| from cuvs.common import Resources | ||
|
|
||
| res = Resources() | ||
| else: | ||
| from cuvs.common import MultiGpuResources | ||
|
|
||
| res = MultiGpuResources() | ||
| n_clusters = algorithm_kwds.get("n_clusters", n_devices) | ||
| overlap_factor = algorithm_kwds.get("overlap_factor", 1) | ||
| cuvs_metric = "sqeuclidean" if metric == "euclidean" else metric | ||
| if algo == "ivf_pq" or algo == "ivfpq": | ||
| from cuvs.neighbors import ivf_pq | ||
|
|
||
| algo = "ivf_pq" | ||
| if cuvs_metric != "sqeuclidean": | ||
| raise ValueError( | ||
| f"all_neighbors with algo='ivf_pq' only supports 'euclidean' and " | ||
| f"'sqeuclidean' metrics, got {metric!r}. Use algo='nn_descent' instead." | ||
| ) | ||
| n_lists = algorithm_kwds.get("n_lists", _compute_nlist(X.shape[0])) | ||
| ivf_pq_params = ivf_pq.IndexParams(n_lists=n_lists) | ||
| ivf_pq_params = ivf_pq.IndexParams(n_lists=n_lists, metric=cuvs_metric) | ||
| nn_descent_params = None | ||
| elif algo == "nn_descent": | ||
| from cuvs.neighbors import nn_descent | ||
|
|
||
| graph_degree = max(algorithm_kwds.get("graph_degree", 64), k) | ||
| intermediate_graph_degree = algorithm_kwds.get( | ||
| "intermediate_graph_degree", None | ||
| "intermediate_graph_degree", max(128, int(1.5 * graph_degree)) | ||
| ) | ||
| intermediate_graph_degree = max(intermediate_graph_degree, graph_degree) | ||
|
Comment on lines
+93
to
+97
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. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Treat explicit When callers pass Use an explicit As per coding guidelines, use 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| nn_descent_params = nn_descent.IndexParams( | ||
| graph_degree=k, intermediate_graph_degree=intermediate_graph_degree | ||
| graph_degree=graph_degree, | ||
| intermediate_graph_degree=intermediate_graph_degree, | ||
| metric=cuvs_metric, | ||
| ) | ||
| ivf_pq_params = None | ||
| else: | ||
|
|
@@ -66,7 +107,7 @@ def _all_neighbors_knn( | |
| algo=algo, | ||
| overlap_factor=overlap_factor, | ||
| n_clusters=n_clusters, | ||
| metric="sqeuclidean", | ||
| metric=cuvs_metric, | ||
| ivf_pq_params=ivf_pq_params, | ||
| nn_descent_params=nn_descent_params, | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,100 @@ def test_all_neighbors(algo): | |
| _calc_recall(distances, adata.obsp["distances"], tolerance=tolerance) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("n_devices", "expected"), | ||
| [(1, (1, 1)), (2, (4, 2)), (3, (3, 2)), (4, (4, 2)), (8, (8, 3)), (16, (16, 4))], | ||
| ) | ||
| def test_all_neighbors_batching_defaults(monkeypatch, n_devices, expected): | ||
| import cupy as cp | ||
|
|
||
| from rapids_singlecell.preprocessing._neighbors._algorithms._all_neighbors import ( | ||
| _all_neighbors_batching, | ||
| ) | ||
|
|
||
| monkeypatch.setattr(cp.cuda.runtime, "getDeviceCount", lambda: n_devices) | ||
| n_clusters, overlap_factor = _all_neighbors_batching({}) | ||
| assert (n_clusters, overlap_factor) == expected | ||
| assert n_clusters == 1 or overlap_factor < n_clusters | ||
|
|
||
|
|
||
| def test_all_neighbors_batching_overrides(): | ||
| from rapids_singlecell.preprocessing._neighbors._algorithms._all_neighbors import ( | ||
| _all_neighbors_batching, | ||
| ) | ||
|
|
||
| assert _all_neighbors_batching({"n_clusters": 16}) == (16, 4) | ||
| assert _all_neighbors_batching({"n_clusters": 8, "overlap_factor": 2}) == (8, 2) | ||
| # The default overlap is capped at n_clusters - 1, so small explicit cluster | ||
| # counts stay usable instead of tripping the guard below. | ||
| assert _all_neighbors_batching({"n_clusters": 2}) == (2, 1) | ||
| assert _all_neighbors_batching({"n_clusters": 3}) == (3, 2) | ||
| with pytest.raises(ValueError, match="must be greater than"): | ||
| _all_neighbors_batching({"n_clusters": 3, "overlap_factor": 3}) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("n_clusters", [4, 8]) | ||
| def test_all_neighbors_batched(n_clusters): | ||
| """These recall 0.91 and 0.47 with the previous ``overlap_factor=1``.""" | ||
| if parse_version(cuvs.__version__) <= parse_version("25.08"): | ||
| pytest.skip("Skipping All-Neighbors") | ||
| adata = pbmc68k_reduced() | ||
| rsc.pp.neighbors( | ||
| adata, | ||
| n_pcs=50, | ||
| n_neighbors=15, | ||
| algorithm="all_neighbors", | ||
| algorithm_kwds={"n_clusters": n_clusters}, | ||
| ) | ||
| distances = adata.obsp["distances"].copy() | ||
| rsc.pp.neighbors(adata, n_pcs=50, n_neighbors=15, algorithm="brute") | ||
| _calc_recall(distances, adata.obsp["distances"], tolerance=0.95) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("n_clusters", [1, 4]) | ||
| @pytest.mark.parametrize("metric", ["cosine", "sqeuclidean"]) | ||
| def test_all_neighbors_metrics(metric, n_clusters): | ||
| if parse_version(cuvs.__version__) <= parse_version("25.08"): | ||
| pytest.skip("Skipping All-Neighbors") | ||
| adata = pbmc68k_reduced() | ||
| rsc.pp.neighbors( | ||
| adata, | ||
| n_pcs=50, | ||
| n_neighbors=15, | ||
| algorithm="all_neighbors", | ||
| metric=metric, | ||
| algorithm_kwds={"n_clusters": n_clusters}, | ||
| ) | ||
| distances = adata.obsp["distances"].copy() | ||
| rsc.pp.neighbors(adata, n_pcs=50, n_neighbors=15, algorithm="brute", metric=metric) | ||
| _calc_recall(distances, adata.obsp["distances"], tolerance=0.95) | ||
|
Comment on lines
+136
to
+152
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. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Add independent coverage for The new Add an As per coding guidelines, tests must validate numerical correctness against scanpy, squidpy, pertpy, or SciPy references rather than only checking that code runs. 🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||
|
|
||
|
|
||
| @pytest.mark.parametrize("n_clusters", [1, 4]) | ||
| def test_all_neighbors_inner_product(n_clusters): | ||
| """``inner_product`` used to fail in the connectivity step, not the build.""" | ||
| if parse_version(cuvs.__version__) <= parse_version("25.08"): | ||
| pytest.skip("Skipping All-Neighbors") | ||
| adata = pbmc68k_reduced() | ||
| rsc.pp.neighbors( | ||
| adata, | ||
| n_pcs=50, | ||
| n_neighbors=15, | ||
| algorithm="all_neighbors", | ||
| metric="inner_product", | ||
| algorithm_kwds={"n_clusters": n_clusters}, | ||
| ) | ||
| distances = adata.obsp["distances"].copy() | ||
| rsc.pp.neighbors( | ||
| adata, n_pcs=50, n_neighbors=15, algorithm="brute", metric="inner_product" | ||
| ) | ||
| # inner_product is a similarity, so the top-k are the *largest* dot products and | ||
| # a point is not its own nearest neighbor. nn-descent refines a similarity graph | ||
| # less well than a distance one: recall measures 0.933 here, against 0.995 for | ||
| # the metrics in ``test_all_neighbors_metrics``. | ||
| _calc_recall(distances, adata.obsp["distances"], tolerance=0.9) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("algo", ["mg_ivfflat", "mg_ivfpq"]) | ||
| def test_mg_bbknn(algo): | ||
| if parse_version(cuvs.__version__) <= parse_version("25.08"): | ||
|
|
||
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.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Validate batching values before returning them.
n_clusters=0returns(0, 1)because Line 43 only rejects invalid values whenn_clusters > 1.overlap_factor=0also passes for a batched build. These values cannot describe a valid cluster assignment and reachAllNeighborsParams.Require positive integer values for both settings before applying the relative bound. Preserve the single-cluster exception for
(1, 1).🤖 Prompt for AI Agents