minibatch-kmeans 0.1.1__tar.gz
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.
- minibatch_kmeans-0.1.1/LICENSE +21 -0
- minibatch_kmeans-0.1.1/PKG-INFO +100 -0
- minibatch_kmeans-0.1.1/README.md +66 -0
- minibatch_kmeans-0.1.1/evaluation/__init__.py +3 -0
- minibatch_kmeans-0.1.1/evaluation/__main__.py +4 -0
- minibatch_kmeans-0.1.1/evaluation/data.py +121 -0
- minibatch_kmeans-0.1.1/evaluation/datasets.py +49 -0
- minibatch_kmeans-0.1.1/evaluation/heuristics.py +23 -0
- minibatch_kmeans-0.1.1/evaluation/metrics.py +238 -0
- minibatch_kmeans-0.1.1/evaluation/nvitop_process_memory.py +82 -0
- minibatch_kmeans-0.1.1/evaluation/perf_test.py +329 -0
- minibatch_kmeans-0.1.1/evaluation/presets.py +13 -0
- minibatch_kmeans-0.1.1/evaluation/run.py +337 -0
- minibatch_kmeans-0.1.1/minibatch_kmeans.egg-info/PKG-INFO +100 -0
- minibatch_kmeans-0.1.1/minibatch_kmeans.egg-info/SOURCES.txt +24 -0
- minibatch_kmeans-0.1.1/minibatch_kmeans.egg-info/dependency_links.txt +1 -0
- minibatch_kmeans-0.1.1/minibatch_kmeans.egg-info/requires.txt +13 -0
- minibatch_kmeans-0.1.1/minibatch_kmeans.egg-info/top_level.txt +2 -0
- minibatch_kmeans-0.1.1/pyproject.toml +47 -0
- minibatch_kmeans-0.1.1/setup.cfg +4 -0
- minibatch_kmeans-0.1.1/src/minibatch_kmeans/__init__.py +54 -0
- minibatch_kmeans-0.1.1/src/minibatch_kmeans/_core.py +701 -0
- minibatch_kmeans-0.1.1/src/minibatch_kmeans/_utils.py +33 -0
- minibatch_kmeans-0.1.1/tests/test_benchmark_data.py +67 -0
- minibatch_kmeans-0.1.1/tests/test_metrics.py +149 -0
- minibatch_kmeans-0.1.1/tests/test_minibatch_kmeans_io.py +106 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miroojin Bakshi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: minibatch-kmeans
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Mini-batch k-means clustering implemented in PyTorch
|
|
5
|
+
Author: Miroojin Bakshi
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mb-14/minibatch-kmeans
|
|
8
|
+
Project-URL: Documentation, https://mb-14.github.io/minibatch-kmeans/
|
|
9
|
+
Project-URL: Repository, https://github.com/mb-14/minibatch-kmeans
|
|
10
|
+
Project-URL: Issues, https://github.com/mb-14/minibatch-kmeans/issues
|
|
11
|
+
Keywords: k-means,clustering,pytorch,minibatch,machine-learning
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: scikit-learn>=1.3
|
|
23
|
+
Requires-Dist: torch>=2.0
|
|
24
|
+
Requires-Dist: tqdm>=4.66
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pdoc>=14; extra == "dev"
|
|
27
|
+
Provides-Extra: eval
|
|
28
|
+
Requires-Dist: clustering-benchmarks>=1.1; extra == "eval"
|
|
29
|
+
Requires-Dist: fastkmeans>=0.5.0; extra == "eval"
|
|
30
|
+
Requires-Dist: flash-kmeans>=0.2.0; extra == "eval"
|
|
31
|
+
Requires-Dist: matplotlib>=3.8; extra == "eval"
|
|
32
|
+
Requires-Dist: nvitop>=1.4; extra == "eval"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# minibatch-kmeans
|
|
36
|
+
|
|
37
|
+
This package implements **mini-batch k-means** following [Sculley (WWW 2010)](https://doi.org/10.1145/1772690.1772862) (*Web-Scale K-Means Clustering*). [scikit-learn](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.MiniBatchKMeans.html)'s `MiniBatchKMeans` is based on the same algorithm but is **CPU-only**; this library is for PyTorch pipelines that want native tensors and **GPU acceleration**.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- Python **3.10+** (see `pyproject.toml`)
|
|
42
|
+
- **PyTorch** 2.x (`torch>=2.0`; use [pytorch.org](https://pytorch.org/get-started/locally/) for the wheel that matches your platform / CUDA).
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install minibatch-kmeans
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import torch
|
|
54
|
+
from minibatch_kmeans import MiniBatchKMeans
|
|
55
|
+
|
|
56
|
+
X = torch.randn(5000, 4, dtype=torch.float32)
|
|
57
|
+
km = MiniBatchKMeans(n_clusters=8, dtype=torch.float32)
|
|
58
|
+
km.fit(X, batch_size=256, max_iter=20)
|
|
59
|
+
labels = km.predict(X)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Incremental training
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
km = MiniBatchKMeans(n_clusters=8, dtype=torch.float32)
|
|
66
|
+
for batch in X.split(256):
|
|
67
|
+
km.partial_fit(batch)
|
|
68
|
+
labels = km.predict(X)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Example notebook: [notebooks/partial_fit_demo.ipynb](notebooks/partial_fit_demo.ipynb)
|
|
72
|
+
|
|
73
|
+
## Documentation
|
|
74
|
+
|
|
75
|
+
API reference: **[mb-14.github.io/minibatch-kmeans](https://mb-14.github.io/minibatch-kmeans/)**
|
|
76
|
+
|
|
77
|
+
## Benchmarks & evaluation
|
|
78
|
+
|
|
79
|
+
See **[evaluation/README.md](evaluation/README.md)** for benchmarks and evaluation.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install -e ".[dev]"
|
|
85
|
+
python -m unittest discover -s tests -v
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Generate local API docs (HTML in `pdoc_html/`):
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
bash scripts/gen_api_docs.sh
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Related projects
|
|
95
|
+
|
|
96
|
+
- [AnswerDotAI/fastkmeans](https://github.com/AnswerDotAI/fastkmeans)
|
|
97
|
+
- [svg-project/flash-kmeans](https://github.com/svg-project/flash-kmeans)
|
|
98
|
+
- [Faiss wiki](https://github.com/facebookresearch/faiss/wiki)
|
|
99
|
+
- [rapidsai/cuml](https://github.com/rapidsai/cuml)
|
|
100
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# minibatch-kmeans
|
|
2
|
+
|
|
3
|
+
This package implements **mini-batch k-means** following [Sculley (WWW 2010)](https://doi.org/10.1145/1772690.1772862) (*Web-Scale K-Means Clustering*). [scikit-learn](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.MiniBatchKMeans.html)'s `MiniBatchKMeans` is based on the same algorithm but is **CPU-only**; this library is for PyTorch pipelines that want native tensors and **GPU acceleration**.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python **3.10+** (see `pyproject.toml`)
|
|
8
|
+
- **PyTorch** 2.x (`torch>=2.0`; use [pytorch.org](https://pytorch.org/get-started/locally/) for the wheel that matches your platform / CUDA).
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install minibatch-kmeans
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import torch
|
|
20
|
+
from minibatch_kmeans import MiniBatchKMeans
|
|
21
|
+
|
|
22
|
+
X = torch.randn(5000, 4, dtype=torch.float32)
|
|
23
|
+
km = MiniBatchKMeans(n_clusters=8, dtype=torch.float32)
|
|
24
|
+
km.fit(X, batch_size=256, max_iter=20)
|
|
25
|
+
labels = km.predict(X)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Incremental training
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
km = MiniBatchKMeans(n_clusters=8, dtype=torch.float32)
|
|
32
|
+
for batch in X.split(256):
|
|
33
|
+
km.partial_fit(batch)
|
|
34
|
+
labels = km.predict(X)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Example notebook: [notebooks/partial_fit_demo.ipynb](notebooks/partial_fit_demo.ipynb)
|
|
38
|
+
|
|
39
|
+
## Documentation
|
|
40
|
+
|
|
41
|
+
API reference: **[mb-14.github.io/minibatch-kmeans](https://mb-14.github.io/minibatch-kmeans/)**
|
|
42
|
+
|
|
43
|
+
## Benchmarks & evaluation
|
|
44
|
+
|
|
45
|
+
See **[evaluation/README.md](evaluation/README.md)** for benchmarks and evaluation.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install -e ".[dev]"
|
|
51
|
+
python -m unittest discover -s tests -v
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Generate local API docs (HTML in `pdoc_html/`):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bash scripts/gen_api_docs.sh
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Related projects
|
|
61
|
+
|
|
62
|
+
- [AnswerDotAI/fastkmeans](https://github.com/AnswerDotAI/fastkmeans)
|
|
63
|
+
- [svg-project/flash-kmeans](https://github.com/svg-project/flash-kmeans)
|
|
64
|
+
- [Faiss wiki](https://github.com/facebookresearch/faiss/wiki)
|
|
65
|
+
- [rapidsai/cuml](https://github.com/rapidsai/cuml)
|
|
66
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Synthetic clustering datasets for benchmarking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def train_val_split(
|
|
11
|
+
x: torch.Tensor,
|
|
12
|
+
y: torch.Tensor,
|
|
13
|
+
*,
|
|
14
|
+
train_fraction: float,
|
|
15
|
+
seed: int,
|
|
16
|
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None, torch.Tensor | None]:
|
|
17
|
+
"""
|
|
18
|
+
Random row permutation, then first ``floor(train_fraction * n)`` rows for train.
|
|
19
|
+
|
|
20
|
+
When ``train_fraction >= 1.0``, returns ``(x, y, None, None)`` — fit and optional
|
|
21
|
+
quality metrics use the full matrix (no holdout).
|
|
22
|
+
|
|
23
|
+
Raises
|
|
24
|
+
------
|
|
25
|
+
ValueError
|
|
26
|
+
If ``train_fraction`` is not in ``(0, 1]``, or if the implied train/val sizes are invalid.
|
|
27
|
+
"""
|
|
28
|
+
if train_fraction > 1.0 or train_fraction <= 0.0:
|
|
29
|
+
raise ValueError("train_fraction must be in (0, 1].")
|
|
30
|
+
n = x.shape[0]
|
|
31
|
+
if y.shape[0] != n:
|
|
32
|
+
raise ValueError("x and y must have the same number of rows.")
|
|
33
|
+
if train_fraction >= 1.0:
|
|
34
|
+
return x, y, None, None
|
|
35
|
+
|
|
36
|
+
g = torch.Generator()
|
|
37
|
+
g.manual_seed(seed)
|
|
38
|
+
perm = torch.randperm(n, generator=g)
|
|
39
|
+
n_train = int(math.floor(train_fraction * n))
|
|
40
|
+
n_val = n - n_train
|
|
41
|
+
if n_train < 1 or n_val < 1:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"train_fraction={train_fraction} with n={n} yields n_train={n_train}, "
|
|
44
|
+
f"n_val={n_val}; need both >= 1."
|
|
45
|
+
)
|
|
46
|
+
p = perm.to(device=x.device)
|
|
47
|
+
x_perm = x[p]
|
|
48
|
+
y_perm = y[p]
|
|
49
|
+
x_train = x_perm[:n_train]
|
|
50
|
+
y_train = y_perm[:n_train]
|
|
51
|
+
x_val = x_perm[n_train:]
|
|
52
|
+
y_val = y_perm[n_train:]
|
|
53
|
+
return x_train, y_train, x_val, y_val
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def gaussian_mixture(
|
|
57
|
+
n_samples: int,
|
|
58
|
+
n_clusters: int,
|
|
59
|
+
n_features: int,
|
|
60
|
+
seed: int,
|
|
61
|
+
center_scale: float = 10.0,
|
|
62
|
+
noise_scale: float = 5.0,
|
|
63
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
64
|
+
"""
|
|
65
|
+
Structured clusters: points drawn as cluster_center + Gaussian noise.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
x : torch.Tensor, shape (n_samples, n_features), float32
|
|
70
|
+
y : torch.Tensor, shape (n_samples,), int64 — cluster index per point
|
|
71
|
+
"""
|
|
72
|
+
g = torch.Generator()
|
|
73
|
+
g.manual_seed(seed)
|
|
74
|
+
centers = torch.randn(n_clusters, n_features, generator=g, dtype=torch.float32) * center_scale
|
|
75
|
+
cluster_indices = torch.randint(
|
|
76
|
+
0, n_clusters, (n_samples,), generator=g, dtype=torch.int64
|
|
77
|
+
)
|
|
78
|
+
x = torch.empty((n_samples, n_features), dtype=torch.float32)
|
|
79
|
+
batch = 100_000
|
|
80
|
+
for i in range(0, n_samples, batch):
|
|
81
|
+
end = min(i + batch, n_samples)
|
|
82
|
+
cidx = cluster_indices[i:end]
|
|
83
|
+
noise = torch.randn(end - i, n_features, generator=g, dtype=torch.float32) * noise_scale
|
|
84
|
+
x[i:end] = centers[cidx] + noise
|
|
85
|
+
return x, cluster_indices
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def gaussian_mixture_imbalanced(
|
|
89
|
+
n_samples: int,
|
|
90
|
+
n_clusters: int,
|
|
91
|
+
n_features: int,
|
|
92
|
+
seed: int,
|
|
93
|
+
*,
|
|
94
|
+
center_scale: float = 10.0,
|
|
95
|
+
noise_scale: float = 5.0,
|
|
96
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
97
|
+
"""
|
|
98
|
+
Same generative structure as ``gaussian_mixture``, but cluster assignment is
|
|
99
|
+
sampled from a **skewed** categorical (softmax of ``linspace(0, 2, k)``) so
|
|
100
|
+
high-index clusters have heavier mass than uniform sampling.
|
|
101
|
+
"""
|
|
102
|
+
g = torch.Generator()
|
|
103
|
+
g.manual_seed(seed)
|
|
104
|
+
centers = torch.randn(n_clusters, n_features, generator=g, dtype=torch.float32) * center_scale
|
|
105
|
+
logits = torch.linspace(0.0, 2.0, n_clusters, dtype=torch.float32)
|
|
106
|
+
probs = torch.softmax(logits, dim=0)
|
|
107
|
+
cluster_indices = torch.multinomial(
|
|
108
|
+
probs, n_samples, replacement=True, generator=g
|
|
109
|
+
)
|
|
110
|
+
x = torch.empty((n_samples, n_features), dtype=torch.float32)
|
|
111
|
+
batch = 100_000
|
|
112
|
+
for i in range(0, n_samples, batch):
|
|
113
|
+
end = min(i + batch, n_samples)
|
|
114
|
+
cidx = cluster_indices[i:end]
|
|
115
|
+
noise = torch.randn(end - i, n_features, generator=g, dtype=torch.float32) * noise_scale
|
|
116
|
+
x[i:end] = centers[cidx] + noise
|
|
117
|
+
return x, cluster_indices
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def to_torch(x: torch.Tensor, device: torch.device) -> torch.Tensor:
|
|
121
|
+
return x.to(device=device, dtype=torch.float32)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Load clustering benchmark datasets via ``clustering-benchmarks`` (clustbench)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class LoadedBenchmark:
|
|
14
|
+
"""Torch-ready view of a clustbench dataset."""
|
|
15
|
+
|
|
16
|
+
battery: str
|
|
17
|
+
dataset: str
|
|
18
|
+
x: torch.Tensor # float32 CPU dense ``(n, d)``
|
|
19
|
+
labels: list[np.ndarray] # reference partitions (numpy, int)
|
|
20
|
+
n_clusters: np.ndarray # k per reference
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_clustbench_dataset(
|
|
24
|
+
battery: str,
|
|
25
|
+
dataset: str,
|
|
26
|
+
*,
|
|
27
|
+
path: str,
|
|
28
|
+
) -> LoadedBenchmark:
|
|
29
|
+
"""
|
|
30
|
+
Load benchmark data the same way as upstream ``clustbench.load_dataset``.
|
|
31
|
+
|
|
32
|
+
``path`` is the directory that contains battery folders (``sipu/``, ``fcps/``, …)—
|
|
33
|
+
equivalent to ``data_path`` in
|
|
34
|
+
https://clustering-benchmarks.gagolewski.com/weave/clustbench-usage.html
|
|
35
|
+
"""
|
|
36
|
+
import clustbench
|
|
37
|
+
|
|
38
|
+
b: Any = clustbench.load_dataset(battery, dataset, path=path)
|
|
39
|
+
data = np.asarray(b.data, dtype=np.float64)
|
|
40
|
+
x = torch.from_numpy(data).to(dtype=torch.float32)
|
|
41
|
+
labels = [np.asarray(lab, dtype=np.int64) for lab in b.labels]
|
|
42
|
+
n_clusters = np.asarray(b.n_clusters, dtype=np.int64)
|
|
43
|
+
return LoadedBenchmark(
|
|
44
|
+
battery=battery,
|
|
45
|
+
dataset=dataset,
|
|
46
|
+
x=x,
|
|
47
|
+
labels=labels,
|
|
48
|
+
n_clusters=n_clusters,
|
|
49
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Dataset-scaled iteration counts for evaluation runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# --- Iteration count (inverse to sample size) ----------------------------
|
|
6
|
+
|
|
7
|
+
# At ``n == REFERENCE_N``, unclamped ``raw == BASE_NITER``; result is then clamped to [NITER_MIN, NITER_MAX].
|
|
8
|
+
# ``REFERENCE_N`` is the median ``n`` over :data:`evaluation.presets.BENCHMARK_TASKS` (real suite).
|
|
9
|
+
REFERENCE_N = 100_000
|
|
10
|
+
NITER_MIN = 10
|
|
11
|
+
NITER_MAX = 50
|
|
12
|
+
BASE_NITER = (NITER_MIN + NITER_MAX) // 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scaled_niter(n: int) -> int:
|
|
16
|
+
"""
|
|
17
|
+
Lloyd / minibatch iterations for ``n`` points: ``BASE_NITER * REFERENCE_N / n``, rounded and
|
|
18
|
+
clamped to ``[NITER_MIN, NITER_MAX]``. Larger ``n`` → fewer iterations.
|
|
19
|
+
"""
|
|
20
|
+
if n < 1:
|
|
21
|
+
raise ValueError("n must be >= 1")
|
|
22
|
+
raw = BASE_NITER * REFERENCE_N / n
|
|
23
|
+
return int(max(NITER_MIN, min(NITER_MAX, round(raw))))
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Clustering metrics for benchmark evaluation (WCSS, ARI/NMI, clustbench reference scoring)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch
|
|
9
|
+
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score
|
|
10
|
+
|
|
11
|
+
from minibatch_kmeans._utils import _as_device
|
|
12
|
+
|
|
13
|
+
# Rows of X processed per chunk in WCSS / assignment (avoids O(n*k) and full float64 X).
|
|
14
|
+
_DEFAULT_CHUNK_ROWS = 8192
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _row_chunk(x: torch.Tensor, start: int, end: int) -> torch.Tensor:
|
|
18
|
+
return x[start:end]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def wcss_torch(
|
|
22
|
+
x: torch.Tensor,
|
|
23
|
+
centroids: torch.Tensor,
|
|
24
|
+
*,
|
|
25
|
+
chunk_rows: int = _DEFAULT_CHUNK_ROWS,
|
|
26
|
+
eval_device: torch.device | str | None = None,
|
|
27
|
+
) -> float:
|
|
28
|
+
"""
|
|
29
|
+
Within-cluster sum of squares (full data, current centroids).
|
|
30
|
+
|
|
31
|
+
Assigns each row to nearest centroid in Euclidean distance, then sums squared distances.
|
|
32
|
+
Accumulates in float64 for stability. Processes ``x`` in row chunks so peak memory stays
|
|
33
|
+
O(chunk_rows * max(d, k)) instead of O(n * k).
|
|
34
|
+
|
|
35
|
+
If ``eval_device`` is set and differs from ``x.device``, ``centroids`` are placed on
|
|
36
|
+
``eval_device`` once and each chunk ``x[start:end]`` is moved there before distance work
|
|
37
|
+
(so ``x`` can remain on CPU while evaluation runs on GPU without materializing all of ``x``).
|
|
38
|
+
"""
|
|
39
|
+
if chunk_rows < 1:
|
|
40
|
+
raise ValueError("chunk_rows must be >= 1")
|
|
41
|
+
if eval_device is None:
|
|
42
|
+
dev = x.device
|
|
43
|
+
else:
|
|
44
|
+
dev = _as_device(eval_device)
|
|
45
|
+
|
|
46
|
+
n = x.shape[0]
|
|
47
|
+
|
|
48
|
+
if eval_device is None:
|
|
49
|
+
staged = False
|
|
50
|
+
c = centroids
|
|
51
|
+
else:
|
|
52
|
+
staged = x.device != dev
|
|
53
|
+
c = centroids.to(device=dev, dtype=torch.float32)
|
|
54
|
+
c64 = c.double()
|
|
55
|
+
total = torch.zeros((), dtype=torch.float64, device=dev)
|
|
56
|
+
for start in range(0, n, chunk_rows):
|
|
57
|
+
end = min(start + chunk_rows, n)
|
|
58
|
+
if staged:
|
|
59
|
+
x64 = _row_chunk(x, start, end).to(dev).double()
|
|
60
|
+
else:
|
|
61
|
+
x64 = _row_chunk(x, start, end).double()
|
|
62
|
+
dists = torch.cdist(x64, c64, p=2.0) ** 2
|
|
63
|
+
total = total + dists.min(dim=1).values.sum()
|
|
64
|
+
return float(total.item())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def assign_labels_torch(
|
|
68
|
+
x: torch.Tensor,
|
|
69
|
+
centroids: torch.Tensor,
|
|
70
|
+
*,
|
|
71
|
+
chunk_rows: int = _DEFAULT_CHUNK_ROWS,
|
|
72
|
+
eval_device: torch.device | str | None = None,
|
|
73
|
+
) -> torch.Tensor:
|
|
74
|
+
"""Nearest-centroid labels (same distance definition as ``wcss_torch``), chunked over rows."""
|
|
75
|
+
if chunk_rows < 1:
|
|
76
|
+
raise ValueError("chunk_rows must be >= 1")
|
|
77
|
+
if eval_device is None:
|
|
78
|
+
dev = x.device
|
|
79
|
+
else:
|
|
80
|
+
dev = _as_device(eval_device)
|
|
81
|
+
|
|
82
|
+
n = x.shape[0]
|
|
83
|
+
|
|
84
|
+
if eval_device is None:
|
|
85
|
+
staged = False
|
|
86
|
+
c = centroids
|
|
87
|
+
else:
|
|
88
|
+
staged = x.device != dev
|
|
89
|
+
c = centroids.to(device=dev, dtype=torch.float32)
|
|
90
|
+
c64 = c.double()
|
|
91
|
+
parts = []
|
|
92
|
+
for start in range(0, n, chunk_rows):
|
|
93
|
+
end = min(start + chunk_rows, n)
|
|
94
|
+
if staged:
|
|
95
|
+
x64 = _row_chunk(x, start, end).to(dev).double()
|
|
96
|
+
else:
|
|
97
|
+
x64 = _row_chunk(x, start, end).double()
|
|
98
|
+
dists = torch.cdist(x64, c64, p=2.0) ** 2
|
|
99
|
+
parts.append(dists.argmin(dim=1).to(torch.int64))
|
|
100
|
+
return torch.cat(parts, dim=0)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def wcss_and_assign_labels_torch(
|
|
104
|
+
x: torch.Tensor,
|
|
105
|
+
centroids: torch.Tensor,
|
|
106
|
+
*,
|
|
107
|
+
chunk_rows: int = _DEFAULT_CHUNK_ROWS,
|
|
108
|
+
eval_device: torch.device | str | None = None,
|
|
109
|
+
) -> tuple[float, torch.Tensor]:
|
|
110
|
+
"""
|
|
111
|
+
Same as calling ``wcss_torch`` then ``assign_labels_torch``, but one pass over ``x``
|
|
112
|
+
(one ``cdist`` per chunk instead of two).
|
|
113
|
+
"""
|
|
114
|
+
if chunk_rows < 1:
|
|
115
|
+
raise ValueError("chunk_rows must be >= 1")
|
|
116
|
+
if eval_device is None:
|
|
117
|
+
dev = x.device
|
|
118
|
+
else:
|
|
119
|
+
dev = _as_device(eval_device)
|
|
120
|
+
|
|
121
|
+
n = x.shape[0]
|
|
122
|
+
|
|
123
|
+
if eval_device is None:
|
|
124
|
+
staged = False
|
|
125
|
+
c = centroids
|
|
126
|
+
else:
|
|
127
|
+
staged = x.device != dev
|
|
128
|
+
c = centroids.to(device=dev, dtype=torch.float32)
|
|
129
|
+
c64 = c.double()
|
|
130
|
+
total = torch.zeros((), dtype=torch.float64, device=dev)
|
|
131
|
+
parts = []
|
|
132
|
+
for start in range(0, n, chunk_rows):
|
|
133
|
+
end = min(start + chunk_rows, n)
|
|
134
|
+
if staged:
|
|
135
|
+
x64 = _row_chunk(x, start, end).to(dev).double()
|
|
136
|
+
else:
|
|
137
|
+
x64 = _row_chunk(x, start, end).double()
|
|
138
|
+
dists = torch.cdist(x64, c64, p=2.0) ** 2
|
|
139
|
+
total = total + dists.min(dim=1).values.sum()
|
|
140
|
+
parts.append(dists.argmin(dim=1).to(torch.int64))
|
|
141
|
+
pred = torch.cat(parts, dim=0)
|
|
142
|
+
return float(total.item()), pred
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _labels_to_numpy_int64(y: torch.Tensor) -> np.ndarray:
|
|
146
|
+
return y.detach().cpu().numpy().astype(np.int64, copy=False).ravel()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ari_score(y_true: torch.Tensor, y_pred: torch.Tensor) -> float:
|
|
150
|
+
"""Adjusted Rand index (``sklearn.metrics.adjusted_rand_score``)."""
|
|
151
|
+
if y_true.numel() != y_pred.numel():
|
|
152
|
+
raise ValueError("y_true and y_pred must have the same length.")
|
|
153
|
+
return float(
|
|
154
|
+
adjusted_rand_score(
|
|
155
|
+
_labels_to_numpy_int64(y_true),
|
|
156
|
+
_labels_to_numpy_int64(y_pred),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def nmi_score(y_true: torch.Tensor, y_pred: torch.Tensor) -> float:
|
|
162
|
+
"""Normalized mutual information (``average_method='arithmetic'``)."""
|
|
163
|
+
return float(
|
|
164
|
+
normalized_mutual_info_score(
|
|
165
|
+
_labels_to_numpy_int64(y_true),
|
|
166
|
+
_labels_to_numpy_int64(y_pred),
|
|
167
|
+
average_method="arithmetic",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _labels_for_clustbench_get_score(y_pred: np.ndarray, k: int) -> np.ndarray:
|
|
173
|
+
"""
|
|
174
|
+
``clustbench.get_score`` requires ``results[k]`` with values in ``1..k`` (see clustbench/score.py).
|
|
175
|
+
Our k-means backends use nearest-centroid indices ``0..k-1``.
|
|
176
|
+
"""
|
|
177
|
+
y = np.asarray(y_pred, dtype=np.int64)
|
|
178
|
+
lo, hi = int(np.min(y)), int(np.max(y))
|
|
179
|
+
if lo >= 1 and hi <= k:
|
|
180
|
+
return y
|
|
181
|
+
if lo >= 0 and hi <= k - 1:
|
|
182
|
+
return y + 1
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"Cannot map partition to clustbench 1..k labels: min={lo}, max={hi}, k={k}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _metric_ari_numpy(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
|
189
|
+
return float(adjusted_rand_score(y_true, y_pred))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _metric_nmi_numpy(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
|
193
|
+
return float(
|
|
194
|
+
normalized_mutual_info_score(y_true, y_pred, average_method="arithmetic")
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def clustbench_best_score(
|
|
199
|
+
reference_labels: list[np.ndarray],
|
|
200
|
+
y_pred: np.ndarray,
|
|
201
|
+
k: int,
|
|
202
|
+
*,
|
|
203
|
+
metric: Callable[[np.ndarray, np.ndarray], float] | None = None,
|
|
204
|
+
) -> float:
|
|
205
|
+
"""
|
|
206
|
+
Best similarity to any reference partition, using clustbench's noise handling.
|
|
207
|
+
|
|
208
|
+
``y_pred`` must be a length-``n`` integer partition with ``k`` clusters.
|
|
209
|
+
``results`` is ``{k: y_pred}`` as required by ``clustbench.get_score``.
|
|
210
|
+
"""
|
|
211
|
+
import clustbench
|
|
212
|
+
|
|
213
|
+
if metric is None:
|
|
214
|
+
metric = _metric_ari_numpy
|
|
215
|
+
y_pred_cb = _labels_for_clustbench_get_score(y_pred, k)
|
|
216
|
+
return float(
|
|
217
|
+
clustbench.get_score(
|
|
218
|
+
reference_labels,
|
|
219
|
+
{int(k): y_pred_cb},
|
|
220
|
+
metric=metric,
|
|
221
|
+
compute_max=True,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def clustbench_best_ari_and_nmi(
|
|
227
|
+
reference_labels: list[np.ndarray],
|
|
228
|
+
y_pred: np.ndarray,
|
|
229
|
+
k: int,
|
|
230
|
+
) -> tuple[float, float]:
|
|
231
|
+
"""ARI and NMI each maximized over references (same ``y_pred``; two separate ``get_score`` calls)."""
|
|
232
|
+
ari = clustbench_best_score(
|
|
233
|
+
reference_labels, y_pred, k, metric=_metric_ari_numpy
|
|
234
|
+
)
|
|
235
|
+
nmi = clustbench_best_score(
|
|
236
|
+
reference_labels, y_pred, k, metric=_metric_nmi_numpy
|
|
237
|
+
)
|
|
238
|
+
return ari, nmi
|