Skip to content

Commit 373ba5e

Browse files
committed
fix
1 parent 7f5d9fb commit 373ba5e

16 files changed

Lines changed: 208 additions & 149 deletions

File tree

.github/workflows/pytest.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
test:
1111
strategy:
1212
matrix:
13-
python-version: ["3.9", "3.13"]
13+
python-version: ["3.10", "3.14"]
1414
runs-on: ubuntu-latest
1515
timeout-minutes: 60
1616
steps:
@@ -27,7 +27,7 @@ jobs:
2727
cache: pip
2828

2929
- name: Install project and dependencies
30-
run: pip install .[annlibs,tests]
30+
run: pip install --group=tests .[annlibs]
3131

3232
- name: Run test suite
3333
run: pytest -v --color=yes

.pre-commit-config.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,20 @@ repos:
1717
- repo: https://github.com/astral-sh/ruff-pre-commit
1818
rev: v0.16.1
1919
hooks:
20-
- id: ruff
20+
- id: ruff-check
2121
args: ["--fix"]
2222
- id: ruff-format
2323
- repo: https://github.com/pre-commit/mirrors-mypy
24-
rev: v1.14.1
24+
rev: v2.3.0
2525
hooks:
2626
- id: mypy
2727
additional_dependencies:
28+
- faiss-cpu
29+
- matplotlib
2830
- pynndescent
31+
- pytest
32+
- scikit-learn
33+
- scipy-stubs
2934
- repo: https://github.com/pre-commit/mirrors-prettier
3035
rev: v4.0.0-alpha.8
3136
hooks:

.readthedocs.yaml

Lines changed: 0 additions & 19 deletions
This file was deleted.

.readthedocs.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# https://docs.readthedocs.io/en/stable/config-file/v2.html
2+
version: 2
3+
build:
4+
os: ubuntu-26.04
5+
tools:
6+
python: "3.14"
7+
jobs:
8+
post_checkout:
9+
# unshallow so version can be derived from tag
10+
- git fetch --unshallow || true
11+
create_environment:
12+
- asdf plugin add uv
13+
- asdf install uv latest
14+
- asdf global uv latest
15+
build:
16+
html:
17+
- uvx hatch run docs:build
18+
- mv docs/_build $READTHEDOCS_OUTPUT

examples/rnn_dbscan_big.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,16 @@
1919
from sklearn_ann.cluster.rnn_dbscan import simple_rnn_dbscan_pipeline
2020

2121
if TYPE_CHECKING:
22+
from collections.abc import Callable
2223
from typing import Any
2324

24-
from sklearn.utils import Bunch
25-
2625

2726
# #############################################################################
2827
# Generate sample data
29-
def fetch_mnist() -> Bunch:
28+
def fetch_mnist() -> tuple[Any, Any]:
3029
print("Downloading mnist_784")
31-
mnist = fetch_openml("mnist_784")
32-
return mnist.data / 255, mnist.target
30+
data, target = fetch_openml("mnist_784", return_X_y=True)
31+
return data / 255, target
3332

3433

3534
memory = Memory("./mnist")
@@ -38,7 +37,7 @@ def fetch_mnist() -> Bunch:
3837

3938

4039
def run_rnn_dbscan(
41-
neighbor_transformer: object, n_neighbors: int, **kwargs: Any
40+
neighbor_transformer: Callable[..., Any], n_neighbors: int, **kwargs: Any
4241
) -> None:
4342
# #############################################################################
4443
# Compute RnnDBSCAN

examples/rnn_dbscan_simple.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
Mostly copypasted from sklearn's DBSCAN example.
99
1010
"""
11+
1112
from __future__ import annotations
1213

1314
import numpy as np
@@ -35,7 +36,7 @@
3536

3637
# Number of clusters in labels, ignoring noise if present.
3738
n_clusters_ = len(set(labels)) - (1 if -1 in labels else 0)
38-
n_noise_ = list(labels).count(-1)
39+
n_noise_ = int((labels == -1).sum())
3940

4041
print(f"""\
4142
Estimated number of clusters: {n_clusters_}

pyproject.toml

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,13 @@ license = "BSD-3-Clause"
99
urls.Source = "https://github.com/scikit-learn-contrib/sklearn-ann"
1010
urls.Documentation = "https://sklearn-ann.readthedocs.io/"
1111
dynamic = ["version", "readme"]
12-
requires-python = ">=3.9"
12+
requires-python = ">=3.10"
1313
dependencies = [
1414
"scikit-learn>=1.6.0",
1515
"scipy>=1.11.1,<2.0.0",
1616
]
1717

1818
[project.optional-dependencies]
19-
tests = [
20-
"pytest>=6.2.1",
21-
"pytest-cov>=2.10.1",
22-
]
23-
docs = [
24-
"sphinx>=7",
25-
"sphinx-gallery>=0.8.2",
26-
"sphinx-book-theme>=1.1.0",
27-
"sphinx-issues>=1.2.0",
28-
"numpydoc>=1.1.0",
29-
"matplotlib>=3.3.3",
30-
"scanpydoc",
31-
]
3219
annoy = [
3320
"annoy>=1.17.0,<2.0.0",
3421
]
@@ -46,6 +33,21 @@ annlibs = [
4633
"sklearn-ann[annoy,faiss,pynndescent,nmslib]",
4734
]
4835

36+
[dependency-groups]
37+
tests = [
38+
"pytest>=6.2.1",
39+
"pytest-cov>=2.10.1",
40+
]
41+
docs = [
42+
"sphinx>=7",
43+
"sphinx-gallery>=0.8.2",
44+
"sphinx-book-theme>=1.1.0",
45+
"sphinx-issues>=1.2.0",
46+
"numpydoc>=1.1.0",
47+
"matplotlib>=3.3.3",
48+
"scanpydoc",
49+
]
50+
4951
[tool.hatch.version]
5052
source = "vcs"
5153

@@ -92,23 +94,33 @@ strict = true
9294

9395
[tool.hatch.envs.docs]
9496
installer = "uv"
95-
features = ["docs", "annlibs"]
97+
features = ["annlibs"]
98+
dependency-groups = ["docs"]
9699
scripts.build = "sphinx-build -M html docs docs/_build"
97100

98101
[tool.hatch.envs.hatch-test]
99102
default-args = []
100-
features = ["tests", "annlibs"]
103+
features = ["annlibs"]
104+
dependency-groups = ["tests"]
101105

102106
[tool.hatch.build.targets.wheel]
103107
packages = ["src/sklearn_ann"]
104108

105109
[tool.mypy]
106-
python_version = "3.11"
110+
python_version = "3.12"
107111
mypy_path = ["src", "tests"]
108112
strict = true
109113
explicit_package_bases = true # pytest doesn’t do __init__.py
110114
no_implicit_optional = true
111115
disallow_untyped_decorators = false # e.g. pytest.mark.parametrize
116+
follow_untyped_imports = true
117+
# follow_untyped_imports makes these readable, but not annotated
118+
untyped_calls_exclude = ["sklearn", "joblib", "pynndescent"]
119+
120+
[[tool.mypy.overrides]]
121+
# pynndescent has no __all__, so its re-exports are implicit
122+
module = ["pynndescent"]
123+
no_implicit_reexport = false
112124

113125
[build-system]
114126
requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"]

src/sklearn_ann/cluster/rnn_dbscan.py

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,35 @@
44
from typing import TYPE_CHECKING, cast
55

66
import numpy as np
7-
from scipy.sparse import csr_matrix
87
from sklearn.base import BaseEstimator, ClusterMixin
98
from sklearn.neighbors import KNeighborsTransformer
10-
from sklearn.utils import Tags
119
from sklearn.utils.validation import validate_data
1210

1311
from ..utils import get_sparse_row
1412

1513
if TYPE_CHECKING:
16-
from collections.abc import Iterator
17-
from typing import Literal, Self
14+
from collections.abc import Callable, Iterator
15+
from typing import Any, Literal, Self
1816

1917
from numpy.typing import NDArray
18+
from scipy.sparse import csr_matrix
2019
from sklearn.pipeline import Pipeline
20+
from sklearn.utils import Tags
2121

2222

2323
UNCLASSIFIED = -2
2424
NOISE = -1
2525

2626

27-
def join(it1, it2):
27+
def join(
28+
it1: Iterator[tuple[int, float]], it2: Iterator[tuple[int, float]]
29+
) -> Iterator[tuple[int, float]]:
2830
cur_it1 = next(it1, None)
2931
cur_it2 = next(it2, None)
3032
while 1:
31-
if cur_it1 is None and cur_it2 is None:
32-
break
33-
elif cur_it1 is None:
33+
if cur_it1 is None:
34+
if cur_it2 is None:
35+
break
3436
yield cur_it2
3537
cur_it2 = next(it2, None)
3638
elif cur_it2 is None:
@@ -66,7 +68,10 @@ def neighborhood(
6668

6769

6870
def rnn_dbscan_inner(
69-
is_core: NDArray[np.bool_], knns: csr_matrix, rev_knns: csr_matrix, labels
71+
is_core: NDArray[np.bool_],
72+
knns: csr_matrix,
73+
rev_knns: csr_matrix,
74+
labels: NDArray[np.int32],
7075
) -> list[float]:
7176
cluster = 0
7277
cur_dens = 0.0
@@ -78,7 +83,7 @@ def rnn_dbscan_inner(
7883
labels[x_idx] = cluster
7984
# TODO: Make this inner bit faster - can just assume
8085
# sorted an keep sorted
81-
seeds = deque()
86+
seeds: deque[int] = deque()
8287
for neighbor_idx, dist in neighborhood(is_core, knns, rev_knns, x_idx):
8388
labels[neighbor_idx] = cluster
8489
if dist > cur_dens:
@@ -166,37 +171,39 @@ def __init__(
166171
self.keep_knns = keep_knns
167172

168173
def fit(self, X: NDArray[np.float64] | csr_matrix, y: None = None) -> Self:
169-
X = cast(csr_matrix, validate_data(self, X, accept_sparse="csr"))
174+
X = validate_data(self, X, accept_sparse="csr")
170175
if self.input_guarantee == "none":
171176
algorithm = KNeighborsTransformer(n_neighbors=self.n_neighbors)
172-
X = algorithm.fit_transform(X)
177+
knns = cast("csr_matrix", algorithm.fit_transform(X))
173178
elif self.input_guarantee == "kneighbors":
174-
pass
179+
knns = cast("csr_matrix", X)
175180
else:
176181
raise ValueError(
177182
"Expected input_guarantee to be one of 'none', 'kneighbors'"
178183
)
179184

180-
XT = cast(csr_matrix, X.transpose().tocsr(copy=True))
185+
rev_knns = knns.transpose().tocsr(copy=True)
181186
if self.keep_knns:
182-
self.knns_ = X
183-
self.rev_knns_ = XT
187+
self.knns_ = knns
188+
self.rev_knns_ = rev_knns
184189

185190
# Initially, all samples are unclassified.
186-
labels = np.full(X.shape[0], UNCLASSIFIED, dtype=np.int32)
191+
labels = np.full(knns.shape[0], UNCLASSIFIED, dtype=np.int32)
187192

188193
# A list of all core samples found. -1 is to account for diagonal.
189-
core_samples = XT.getnnz(1) - 1 >= self.n_neighbors
194+
core_samples = rev_knns.getnnz(1) - 1 >= self.n_neighbors
190195

191-
dens = rnn_dbscan_inner(core_samples, X, XT, labels)
196+
dens = rnn_dbscan_inner(core_samples, knns, rev_knns, labels)
192197

193198
self.core_sample_indices_ = core_samples.nonzero()
194199
self.labels_ = labels
195200
self.dens_ = dens
196201

197202
return self
198203

199-
def fit_predict(self, X, y=None) -> NDArray[np.int32]:
204+
def fit_predict( # type: ignore[override]
205+
self, X: NDArray[np.float64] | csr_matrix, y: None = None
206+
) -> NDArray[np.int32]:
200207
self.fit(X, y=y)
201208
return self.labels_
202209

@@ -205,13 +212,13 @@ def drop_knns(self) -> None:
205212
del self.rev_knns_
206213

207214
def __sklearn_tags__(self) -> Tags:
208-
tags = cast(Tags, super().__sklearn_tags__())
215+
tags = cast("Tags", super().__sklearn_tags__()) # type: ignore[no-untyped-call]
209216
tags.input_tags.sparse = True
210217
return tags
211218

212219

213220
def simple_rnn_dbscan_pipeline(
214-
neighbor_transformer: object,
221+
neighbor_transformer: Callable[..., Any],
215222
n_neighbors: int,
216223
*,
217224
n_jobs: int | None = None,
@@ -236,12 +243,17 @@ class implementing KNeighborsTransformer interface
236243
"""
237244
from sklearn.pipeline import make_pipeline
238245

239-
return make_pipeline(
240-
neighbor_transformer(n_neighbors=n_neighbors, n_jobs=n_jobs, **kwargs),
241-
RnnDBSCAN(
242-
n_neighbors=n_neighbors,
243-
input_guarantee="kneighbors",
244-
n_jobs=n_jobs,
245-
keep_knns=keep_knns,
246+
return cast(
247+
"Pipeline",
248+
make_pipeline(
249+
neighbor_transformer(
250+
n_neighbors=n_neighbors, n_jobs=n_jobs, input_guarantee=input_guarantee
251+
),
252+
RnnDBSCAN(
253+
n_neighbors=n_neighbors,
254+
input_guarantee="kneighbors",
255+
n_jobs=n_jobs,
256+
keep_knns=keep_knns,
257+
),
246258
),
247259
)

0 commit comments

Comments
 (0)