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.
@@ -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,3 @@
1
+ """Clustering evaluation (clustbench quality) and throughput harness (``perf_test``)."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,4 @@
1
+ from evaluation.run import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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