sparsekmeans 0.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) 2025 Machine Learning Group @ National Taiwan University
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,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: sparsekmeans
3
+ Version: 0.1
4
+ Summary: A package for efficient K-means clustering on sparse dataset
5
+ Home-page: https://github.com/cjlin1/sparsekmeans
6
+ Author: Chih-Jen Lin, He-Zhe Lin, Khoi Nguyen Pham Dang
7
+ Author-email: cjlin@csie.ntu.edu.tw
8
+ License: MIT
9
+ Requires-Python: >=3.10
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy
12
+ Requires-Dist: python-graphblas
13
+ Requires-Dist: scipy
14
+ Requires-Dist: libsvm-official>=3.36.0
15
+ Dynamic: author
16
+ Dynamic: author-email
17
+ Dynamic: home-page
18
+ Dynamic: license
19
+ Dynamic: license-file
20
+ Dynamic: requires-dist
21
+ Dynamic: requires-python
22
+ Dynamic: summary
@@ -0,0 +1,101 @@
1
+ # sparsekmeans Package
2
+
3
+ The sparsekmeans package provides an efficient implementation of the K-means clustering algorithm optimized for sparse data sets. It is designed to handle high-dimensional and sparse data commonly found in text mining, recommender systems, and bioinformatics. By leveraging appropriate storage format and sparse matrix multiplication operations, our package ensures significant speedup in running time while maintaining consistency for clustering results compared with scikit-learn. Besides, the design of the package allows users to easily extend and customize, making it suitable for research or integrating into large-scale machine learning systems.
4
+
5
+ ## Installation
6
+
7
+ Use the following command to install sparsekmeans with python >= 3.10.
8
+
9
+ ```bash
10
+ pip install sparsekmeans
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ We support two popular K-means algorithms: Lloyd's method and Elkan's method. Use the following steps to cluster a data set:
16
+
17
+ ```
18
+ from sparsekmeans import LloydKmeans, ElkanKmeans
19
+
20
+ kmeans = LloydKmeans(n_clusters=100)
21
+ kmeans = ElkanKmeans(n_clusters=100)
22
+ labels = kmeans.fit(X)
23
+ ```
24
+
25
+ Users can specify the following parameters while creating the kmeans object.
26
+ ```
27
+ n_clusters : int, default=8
28
+ The predefined number of clusters
29
+
30
+ n_threads : int, default=max(1, os.cpu_count() // 2)
31
+ The predefined number of threads to use
32
+
33
+ max_iter : int, default=300
34
+ Maximum number of iterations of the k-means algorithm.
35
+
36
+ tol : float, default=1e-4
37
+ Relative tolerance to declare convergence.
38
+
39
+ random_state : int, RandomState instance or None, default=None
40
+ Determines random number generation for centroid initialization.
41
+ ```
42
+
43
+ To cluster additional data by using previously obtained centroids, use the following way.
44
+
45
+ ```
46
+ test_labels = kmeans.predict(Xtest)
47
+ ```
48
+
49
+ In case users have defined their own centroids and want to use them for clustering data, our package provides a function to do that:
50
+ ```
51
+ from sparsekmeans import kmeans_predict
52
+ test_labels = kmeans_predict(Xtest, user_defined_centroids)
53
+ ```
54
+
55
+ ## Example
56
+ We show an example to cluster a real-world data set.
57
+
58
+ ```python
59
+ from sparsekmeans import LloydKmeans
60
+ from libsvm.svmutil import svm_read_problem
61
+ import io
62
+ import urllib.request
63
+ import bz2
64
+
65
+ with urllib.request.urlopen("https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multiclass/news20_tfidf_train.svm.bz2") as r:
66
+ r = bz2.BZ2File(r)
67
+ r = io.TextIOWrapper(io.BufferedReader(r))
68
+ _, X = svm_read_problem(r, return_scipy=True)
69
+
70
+ kmeans = LloydKmeans(n_clusters=100)
71
+ labels = kmeans.fit(X)
72
+ ```
73
+
74
+ ## Benchmark
75
+
76
+ Running time comparison for **Lloyd's** algorithm
77
+
78
+
79
+ | Data sets | 𝑲 | sklearn | sparsekmeans | Speedup |
80
+ |-------------:|------------:|---------------------:|-----:|-------------:|
81
+ | Wiki-500K | 100 | 24,617.7 | 2,957.3 | 8.32x |
82
+ | | 500 | > $10^5$ | 26,104.9 | > 3.83x |
83
+ | Amazon-670K | 100 | 670.9 | 123.4 | 5.43x |
84
+ | | 500 | 7,170.1 | 776.2 | 9.23x |
85
+ | Url | 100 | 799.9 | 163.5 | 4.89x |
86
+ | | 500 | 5,888.4 | 987.2 | 5.96x |
87
+ | Amazon-3M | 100 | 26,207.5 | 2,359.1 | 11.10x |
88
+ | | 500 | > $10^5$ | 39,346.1 | > 2.54x |
89
+
90
+ Running time comparison for **Elkan's** algorithm
91
+
92
+ | Data sets | 𝑲 | sklearn | sparsekmeans | Speedup |
93
+ |-------------:|------------:|---------------------:|-----:|-------------:|
94
+ | Wiki-500K | 100 | 4,042.7 | 2,382.1 | 1.69x |
95
+ | | 500 | 91,441.8 | 5,061.0 | 18.06x |
96
+ | Amazon-670K | 100 | 248.5 | 141.8 | 1.75x |
97
+ | | 500 | 1,248.3 | 685.1 | 1.82x |
98
+ | Url | 100 | 719.8 | 296.0 | 2.43x |
99
+ | | 500 | 4,687.1 | 1,989.7 | 2.35x |
100
+ | Amazon-3M | 100 | 2,965.2 | 1,743.4 | 1.70x |
101
+ | | 500 | 13,340.0 | 5,517.9 | 2.41x|
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ from setuptools import setup
2
+
3
+ PACKAGE_DIR = ["sparsekmeans"]
4
+ PACKAGE_NAME = "sparsekmeans"
5
+ VERSION = "0.1"
6
+
7
+
8
+ # license parameters
9
+ license_source = ""
10
+ license_file = "LICENSE"
11
+ license_name = "MIT"
12
+
13
+
14
+ setup(
15
+ name=PACKAGE_NAME,
16
+ packages=PACKAGE_DIR,
17
+ version=VERSION,
18
+ python_requires=">=3.10",
19
+ install_requires=["numpy","python-graphblas","scipy","libsvm-official>=3.36.0"],
20
+ description="A package for efficient K-means clustering on sparse dataset",
21
+ long_description_content_type="",
22
+ author="Chih-Jen Lin, He-Zhe Lin, Khoi Nguyen Pham Dang",
23
+ author_email="cjlin@csie.ntu.edu.tw",
24
+ url="https://github.com/cjlin1/sparsekmeans",
25
+ license=license_name,
26
+ )
@@ -0,0 +1 @@
1
+ from .sparse_kmeans import *
@@ -0,0 +1,547 @@
1
+ import os
2
+ import sys
3
+ import numpy as np
4
+ import scipy.sparse as sparse
5
+ import time
6
+ from typing import Union
7
+
8
+ import graphblas as gb
9
+ from graphblas import Matrix, Vector, dtypes
10
+
11
+ import gc
12
+
13
+ default_threads = max(1, os.cpu_count() // 2)
14
+
15
+ def check_random_state(seed: Union[int, None]):
16
+ # Generate a global random number generator (global RNG) - a RandomState instance
17
+ if seed is None or seed is np.random:
18
+ # Return np.random.RandomState() (variable _rand = np.random.RandomState(), the seed is chosen automatically by system)
19
+ return np.random.mtrand._rand
20
+ if isinstance(seed, int):
21
+ # Returns a RandomState object seeded with given integer
22
+ return np.random.RandomState(seed)
23
+
24
+ def squared_row_norms(X: gb.Matrix, n_threads: int=default_threads):
25
+ sq_X = Matrix(dtypes.FP64, X.shape[0], X.shape[1])
26
+ sq_X(nthreads=n_threads) << X.ewise_mult(X, op="times")
27
+
28
+ sq_row_norms = Vector(dtypes.FP64, X.shape[0])
29
+ sq_row_norms(nthreads=n_threads) << sq_X.reduce_rowwise("plus")
30
+
31
+ return sq_row_norms.to_dense(fill_value=0)
32
+
33
+
34
+ def check_centroids_density(centroids: gb.Matrix):
35
+ gamma = 0.1
36
+
37
+ if centroids.ss.format == "fullr":
38
+ is_centroid_dense = True
39
+ centroids_density = centroids.V.new().nvals / (centroids.nrows * centroids.ncols)
40
+ if centroids_density <= gamma:
41
+ is_centroid_dense = False
42
+ centroids(mask=centroids.V, replace=True) << centroids
43
+ else:
44
+ is_centroid_dense = False
45
+ centroids_density = centroids.nvals / (centroids.nrows * centroids.ncols)
46
+ if centroids_density > gamma:
47
+ is_centroid_dense = False
48
+ centroids(mask=~centroids.S) << 0
49
+
50
+ return centroids, is_centroid_dense
51
+
52
+
53
+ def predict_labels(X: gb.Matrix, centroids: gb.Matrix, is_centroid_dense: bool, n_threads: int=default_threads):
54
+ n_samples = X.shape[0]
55
+ n_clusters = centroids.shape[0]
56
+
57
+ # x_squared_norms is not needed
58
+ c_squared_norms = squared_row_norms(centroids, n_threads)
59
+ XCt = Matrix(dtypes.FP64, nrows=n_samples, ncols=n_clusters)
60
+
61
+ # For X * Ct, we use CSR x (fullc or CSC) for the best efficiency.
62
+ # We use row format to store centroids in other places, so must convert them here to column format.
63
+ if is_centroid_dense:
64
+ centroids = centroids.ss.export("fullc")
65
+ centroids = gb.Matrix.ss.import_fullc(**centroids)
66
+
67
+ XCt << 0
68
+ XCt(accum=gb.binary.plus, nthreads=n_threads) << X.mxm(centroids.T)
69
+ XCt = 2 * XCt.to_dense()
70
+
71
+ else:
72
+ centroids = centroids.ss.export("csc")
73
+ centroids = gb.Matrix.ss.import_csc(**centroids)
74
+
75
+ XCt(nthreads=n_threads) << X.mxm(centroids.T)
76
+ XCt = 2 * XCt.to_dense(fill_value=0)
77
+
78
+ # x_squared_norms is not needed
79
+ c_squared_norms = squared_row_norms(centroids, n_threads)
80
+ distances_to_centroids = -XCt + c_squared_norms[np.newaxis, :]
81
+
82
+ labels = np.argmin(distances_to_centroids, axis=1)
83
+
84
+ return labels, distances_to_centroids
85
+
86
+
87
+ def kmeans_predict(X: Union[sparse.csr_matrix, gb.Matrix], centroids: Union[sparse.csr_matrix, gb.Matrix], n_threads: int=default_threads) -> np.array:
88
+ """Predict cluster for each sample in X given centroids
89
+
90
+ Parameters
91
+ ----------
92
+ X : `gb.Matrix (csr format), scipy.sparse.csr_matrix`
93
+ Input dataset for clustering
94
+ centroids : `gb.Matrix (csr format), scipy.sparse.csr_matrix`
95
+ Input centroids
96
+ n_threads : int
97
+ Number of using threads
98
+
99
+ Returns
100
+ -------
101
+ labels: `np.array`
102
+ Array storing the assigned cluster for each sample.
103
+ """
104
+ if not isinstance(X, gb.Matrix):
105
+ X = gb.io.from_scipy_sparse(X)
106
+
107
+ if not isinstance(centroids, gb.Matrix):
108
+ centroids = gb.io.from_scipy_sparse(centroids)
109
+
110
+ centroids, is_centroid_dense = check_centroids_density(centroids)
111
+
112
+ labels, _ = predict_labels(X, centroids, is_centroid_dense, n_threads)
113
+
114
+ return labels
115
+
116
+
117
+ class SparseKmeans:
118
+ def __init__(
119
+ self,
120
+ n_clusters: int = 8,
121
+ n_threads: int = default_threads,
122
+ max_iter: int = 300,
123
+ tol: float = 1e-4,
124
+ random_state: Union[
125
+ np.random.randint, np.random.RandomState, int, None
126
+ ] = None,
127
+ verbose: bool = False
128
+ ):
129
+ """Sparse K-means clustering.
130
+
131
+ Parameters
132
+ ----------
133
+ n_clusters : int, default=8
134
+ The predefined number of clusters
135
+
136
+ n_threads : int, default=max(1, os.cpu_count() // 2)
137
+ The predefined number of threads to use
138
+
139
+ max_iter : int, default=300
140
+ Maximum number of iterations of the k-means algorithm.
141
+
142
+ tol : float, default=1e-4
143
+ Relative tolerance to declare convergence.
144
+
145
+ random_state : int, RandomState instance or None, default=None
146
+ Determines random number generation for centroid initialization.
147
+
148
+ Attributes
149
+ ----------
150
+ centroids : `gb.Matrix`
151
+ Centroids of each cluster derived from training on input matrix X.
152
+
153
+ labels : `np.array`
154
+ Cluster for each sample for input matrix X
155
+
156
+ is_fitted: `bool`
157
+ Boolean flag checking whether the model is fitted on datasets or not
158
+ """
159
+ self.n_clusters = n_clusters
160
+ self.max_iter = max_iter
161
+ self.tol = tol
162
+ self.random_state = check_random_state(random_state)
163
+ self.n_threads = n_threads
164
+ self.verbose = verbose
165
+
166
+ # Flag to indicate if centroids stored in dense format
167
+ self.is_centroid_dense = False
168
+
169
+ self.is_fitted = False
170
+
171
+ def _initialize_centroids(self, X: gb.Matrix):
172
+
173
+ # The kmeans++ initialization method by Arthur and Vassilvitskii, 2007
174
+
175
+ n_samples, n_features = X.shape
176
+ n_clusters = self.n_clusters
177
+ x_squared_norms = squared_row_norms(X, self.n_threads)
178
+
179
+ centroids = Matrix(dtype=dtypes.FP64, nrows=n_clusters, ncols=n_features)
180
+
181
+ # We can simply use self.random_state.choice(n_samples), but instead follow scikit-learn to allow
182
+ # weights for the selection. Also, the two ways may give different results.
183
+ first_centroid_id = self.random_state.choice(n_samples, p=[1 / n_samples] * n_samples)
184
+
185
+ centroids[0, :] << X[first_centroid_id, :]
186
+
187
+ Xc = Vector(dtype=dtypes.FP64, size=n_samples)
188
+ Xc(nthreads=self.n_threads) << X.mxv(X[first_centroid_id, :])
189
+ smallest_sq_dist = (
190
+ x_squared_norms
191
+ - 2 * Xc.to_dense(fill_value=0)
192
+ + x_squared_norms[first_centroid_id]
193
+ )
194
+ # Maintain the potential, sum of each sample's smallest squared distance to existing centroids
195
+ current_potential = smallest_sq_dist.sum()
196
+
197
+ n_local_trials = 2 + int(np.log(n_clusters))
198
+
199
+ for j in range(1, n_clusters):
200
+ # Choose centroids candidates by sampling with probability smallest_sq_dist/current_potential, where
201
+ # current_potential is the sum of smallest_sq_dist
202
+ rand_vals = self.random_state.uniform(size=n_local_trials) * current_potential
203
+
204
+ candidate_ids = np.searchsorted(np.cumsum(smallest_sq_dist), rand_vals)
205
+
206
+ # The product between random values in [0, 1] and current_potential may numerically result in
207
+ # rand_vals > current_potential. Then candidate_ids may be outside the desired range
208
+ candidate_ids = np.minimum(candidate_ids, smallest_sq_dist.size - 1)
209
+
210
+ # Compute squared distances to centroid candidates
211
+ candidates = Matrix(dtype=dtypes.FP64, nrows=n_local_trials, ncols=n_features)
212
+ candidates << X[candidate_ids, :]
213
+ candidates = candidates.ss.export("csc")
214
+ candidates = gb.Matrix.ss.import_csc(**candidates)
215
+
216
+ Xcandidates_t = Matrix(dtype=dtypes.FP64, nrows=n_samples, ncols=n_local_trials)
217
+ Xcandidates_t(nthreads=self.n_threads) << X.mxm(candidates.T) # CSR * CSR
218
+ sq_distance_to_candidates = (
219
+ x_squared_norms[:, np.newaxis]
220
+ - 2 * Xcandidates_t.to_dense(fill_value=0)
221
+ + x_squared_norms[candidate_ids][np.newaxis, :]
222
+ )
223
+ sq_distance_to_candidates = sq_distance_to_candidates.T
224
+
225
+ # Find the smallest squared distance from data to candidtaes
226
+ sq_distance_to_candidates = np.minimum(
227
+ smallest_sq_dist, sq_distance_to_candidates
228
+ )
229
+ candidates_potential = sq_distance_to_candidates.sum(axis=1)
230
+
231
+ # Decide which candidate is the best
232
+ best_candidate = np.argmin(candidates_potential)
233
+ current_potential = candidates_potential[best_candidate]
234
+ smallest_sq_dist = sq_distance_to_candidates[best_candidate]
235
+
236
+ centroids[j, :] << X[candidate_ids[best_candidate], :]
237
+
238
+ return centroids
239
+
240
+ def _single_kmeans(self, X: gb.Matrix):
241
+ # Allowing the kmeans procedure to allocate and initialize internal variables
242
+ self._setup_internal_state(X)
243
+
244
+ # Calculate the mean feature variance
245
+ self.mean_feature_variance = self._cal_feature_variance(X)
246
+ if self.mean_feature_variance <= sys.float_info.min:
247
+ return
248
+
249
+ for iter in range(self.max_iter):
250
+ start_iter = time.time()
251
+
252
+ # Decide to store centroids in dense/sparse format based on density
253
+ self.centroids, self.is_centroid_dense = check_centroids_density(self.centroids)
254
+
255
+ self._assign_cluster(X)
256
+
257
+ if iter > 0:
258
+ if self._converged():
259
+ break
260
+
261
+ self.old_centroids = self.centroids
262
+ self.centroids = self._update_centroids(X)
263
+
264
+ # Allowing the kmeans procedure to update internal variables
265
+ self._update_internal_state()
266
+
267
+ end_iter = time.time()
268
+ if self.verbose:
269
+ print(
270
+ f"Time to conduct iteration: {iter}", end_iter - start_iter, flush=True
271
+ )
272
+
273
+ self.is_fitted = True
274
+
275
+ return
276
+
277
+ def fit(self, X: Union[sparse.csr_matrix, gb.Matrix]):
278
+ """Conducting K-means clustering.
279
+
280
+ Parameters
281
+ ----------
282
+ X : `gb.Matrix, scipy.sparse.csr_matrix` (csr format)
283
+ Input dataset for clustering
284
+
285
+ Returns
286
+ -------.
287
+ """
288
+
289
+ n_samples = X.shape[0]
290
+ print("Total samples: ", n_samples)
291
+
292
+ if not isinstance(X, gb.Matrix):
293
+ X = gb.io.from_scipy_sparse(X)
294
+
295
+ start_init_centroids = time.time()
296
+ self.centroids = self._initialize_centroids(X)
297
+ end_init_centroids = time.time()
298
+
299
+ if self.verbose:
300
+ print("Initialze Centroids time: ", end_init_centroids - start_init_centroids)
301
+
302
+ self._single_kmeans(X)
303
+
304
+ self._cleanup()
305
+
306
+ return self.labels
307
+
308
+ def predict(self, X: gb.Matrix):
309
+ """Predict the closest cluster for each sample in X
310
+
311
+ Parameters
312
+ ----------
313
+ X : `gb.Matrix, scipy.sparse.csr_matrix` (csr format)
314
+ Input dataset for predicting label
315
+
316
+ Returns
317
+ -------
318
+ labels: `np.array`
319
+ Array storing the assigned cluster for each sample.
320
+ """
321
+
322
+ if not self.is_fitted:
323
+ raise AttributeError(
324
+ f"This instance of Kmeans is not trained yet. "
325
+ f"Call 'fit' before using this method."
326
+ )
327
+
328
+ if not isinstance(X, gb.Matrix):
329
+ X = gb.io.from_scipy_sparse(X)
330
+
331
+ labels, _ = predict_labels(X, self.centroids, self.is_centroid_dense, self.n_threads)
332
+
333
+ return labels
334
+
335
+ def _assign_cluster(self, *args, **kargs):
336
+ pass
337
+
338
+ def _update_centroids(self, X: gb.Matrix):
339
+ n_samples, n_features = X.shape
340
+ n_clusters = self.n_clusters
341
+
342
+ cluster_sizes = np.bincount(self.labels, minlength=n_clusters)
343
+
344
+ empty_clusters = np.where(cluster_sizes == 0)[0]
345
+ n_empty = len(empty_clusters)
346
+
347
+ # To handle empty clusters, we follow the setting in scikit-learn
348
+ # We consider points with the largest distances to their assigned clusters, and reassign them to the empty clusters
349
+ # Each empty cluster gets one point
350
+ # We also need to update cluster_sizes
351
+ if n_empty > 0:
352
+ far_samples_idx = np.argpartition(self.sample_centroids_closest_distance, -n_empty)[
353
+ : -n_empty - 1 : -1
354
+ ]
355
+ affected_clusters, counts = np.unique(
356
+ self.labels[far_samples_idx], return_counts=True
357
+ )
358
+ cluster_sizes[affected_clusters] -= counts
359
+ cluster_sizes[empty_clusters] = 1
360
+ self.labels[far_samples_idx] = empty_clusters
361
+
362
+ # Get the weight of each sample in its corresponding cluster
363
+ weights = 1 / cluster_sizes
364
+ weights = weights[self.labels]
365
+
366
+ weighted_matrix = Matrix.from_coo(
367
+ self.labels, np.arange(len(self.labels)), weights, nrows=n_clusters, ncols=n_samples
368
+ )
369
+ centroids = Matrix(dtypes.FP64, nrows=n_clusters, ncols=n_features)
370
+
371
+ if self.is_centroid_dense:
372
+ centroids << 0
373
+ centroids(accum=gb.binary.plus, nthreads=self.n_threads) << weighted_matrix.mxm(X)
374
+ else:
375
+ centroids(nthreads=self.n_threads) << weighted_matrix.mxm(X)
376
+
377
+ return centroids
378
+
379
+ def _setup_internal_state(self, X: gb.Matrix):
380
+ n_samples = X.shape[0]
381
+
382
+ self.labels = np.zeros(n_samples, dtype=np.int16)
383
+
384
+ # Maintain the distance between each sample and the assigned centroid
385
+ # We need the distances in the function update_centroids for handling empty clusters
386
+ self.sample_centroids_closest_distance = np.zeros(n_samples, dtype=np.float64)
387
+
388
+ return
389
+
390
+ def _cal_centroids_shift(self):
391
+ n_clusters, n_features = self.centroids.shape
392
+
393
+ centroids_shift_squared = Matrix(dtypes.FP64, nrows=n_clusters, ncols=n_features)
394
+ centroids_shift_squared(nthreads=self.n_threads) << self.centroids.ewise_union(
395
+ self.old_centroids, op="minus", left_default=0, right_default=0
396
+ )
397
+ centroids_shift_squared = squared_row_norms(centroids_shift_squared, self.n_threads)
398
+
399
+ centroids_shift = np.sqrt(centroids_shift_squared)
400
+
401
+ return centroids_shift
402
+
403
+ def _cal_feature_variance(self, X: gb.Matrix):
404
+ n_samples = X.shape[0]
405
+
406
+ means = X.reduce_columnwise(op="add").to_dense(fill_value=0) / n_samples
407
+ sum_squared = (X ** 2).reduce_columnwise(op="add").to_dense(fill_value=0)
408
+ variances = (sum_squared - n_samples * (means ** 2)) / n_samples
409
+
410
+ return np.mean(variances)
411
+
412
+ def _converged(self):
413
+ pass
414
+
415
+ def _update_internal_state(self, *args, **kargs):
416
+ pass
417
+
418
+ def _cleanup(self, keep=['centroids', 'labels', 'is_fitted', 'is_centroid_dense', 'n_threads', 'verbose', 'random_state', 'n_clusters', 'max_iter', 'tol']):
419
+ for attr in list(self.__dict__.keys()):
420
+ if attr not in keep:
421
+ delattr(self, attr)
422
+
423
+
424
+ class LloydKmeans(SparseKmeans):
425
+
426
+ def __init__(self, **kwargs):
427
+ super().__init__(**kwargs)
428
+
429
+ def _assign_cluster(self, X: gb.Matrix):
430
+ self.labels, distances_to_centroids = predict_labels(X, self.centroids, self.is_centroid_dense, self.n_threads)
431
+ np.min(distances_to_centroids, axis=1, out=self.sample_centroids_closest_distance)
432
+
433
+ # Preventing memory issue with mxm operation
434
+ gc.collect()
435
+
436
+ return
437
+
438
+ def _converged(self):
439
+
440
+ centroids_shift = self._cal_centroids_shift()
441
+ tol = (centroids_shift**2).sum()
442
+
443
+ return tol <= self.mean_feature_variance * self.tol
444
+
445
+
446
+ class ElkanKmeans(SparseKmeans):
447
+ def __init__(self, **kwargs):
448
+ super().__init__(**kwargs)
449
+
450
+ def _assign_cluster(self, X: gb.Matrix):
451
+ n_samples = X.shape[0]
452
+ n_clusters = self.centroids.shape[0]
453
+
454
+ samples_idx = np.arange(n_samples)
455
+
456
+ c_squared_norms = squared_row_norms(self.centroids, self.n_threads)
457
+
458
+ CCt = Matrix(dtypes.FP64, nrows=n_clusters, ncols=n_clusters)
459
+ CCt(nthreads=self.n_threads) << self.centroids.mxm(self.centroids.T)
460
+ CCt = CCt.to_dense(fill_value=0)
461
+
462
+ half_centroid_centroid_distances = c_squared_norms[:, np.newaxis] - 2 * CCt + c_squared_norms[np.newaxis, :]
463
+ np.clip(half_centroid_centroid_distances, 0, None, out=half_centroid_centroid_distances)
464
+ half_centroid_centroid_distances = 0.5 * np.sqrt(half_centroid_centroid_distances)
465
+
466
+ # We may do row-wise products between samples (i.e., X) and centroids[labels,:], but the expansion of
467
+ # centroids is time/memory inefficient. Instead, we use a matrix-matrix product with mask.
468
+ samples_centroids_product = Matrix(dtypes.FP64, nrows=n_samples, ncols=n_clusters)
469
+ samples_centroids_mask = Matrix.from_coo(
470
+ np.arange(n_samples), self.labels, values=1, nrows=n_samples, ncols=n_clusters
471
+ )
472
+
473
+ # Always use dense centroids for calculating samples_centroids_product here and Xcj in the loop
474
+ if self.centroids.ss.format != "fullr":
475
+ self.centroids(~self.centroids.S) << 0
476
+
477
+ samples_centroids_product(mask=samples_centroids_mask.S, nthreads=self.n_threads) << X.mxm(self.centroids.T)
478
+ samples_centroids_product = samples_centroids_product.reduce_rowwise(gb.binary.min).to_dense(fill_value=0)
479
+
480
+ self.sample_centroids_closest_distance = self.x_squared_norms - 2 * samples_centroids_product + c_squared_norms[self.labels]
481
+ np.clip(self.sample_centroids_closest_distance, 0, None, out=self.sample_centroids_closest_distance)
482
+ self.sample_centroids_closest_distance = np.sqrt(self.sample_centroids_closest_distance)
483
+
484
+ for j in range(n_clusters):
485
+
486
+ # Apply two conditions first to get a subset as half_centroid_centroid_distances[j, labels] expensively expand a small array to a larger one
487
+ candidate_idx = samples_idx[(self.labels != j) & (self.sample_centroids_closest_distance > self.lower_bounds[j, :])]
488
+ candidate_idx = candidate_idx[self.sample_centroids_closest_distance[candidate_idx] > half_centroid_centroid_distances[j, self.labels[candidate_idx]]]
489
+
490
+ if len(candidate_idx) == 0:
491
+ continue
492
+
493
+ Xcj = Vector(dtypes.FP64, size=n_samples)
494
+ Xcj_mask = Vector.from_coo(candidate_idx, 1, size=n_samples)
495
+
496
+ # Update lower bounds according to new centroids
497
+ Xcj(mask=Xcj_mask.S, nthreads=self.n_threads) << X.mxv(self.centroids[j, :])
498
+ Xcj = Xcj.to_dense(fill_value=0)
499
+ distance_to_cj = self.x_squared_norms[candidate_idx] - 2 * Xcj[candidate_idx] + c_squared_norms[j]
500
+ np.clip(distance_to_cj, 0, None, out=distance_to_cj)
501
+ distance_to_cj = np.sqrt(distance_to_cj)
502
+ self.lower_bounds[j, candidate_idx] = distance_to_cj
503
+
504
+ reassignment_mask = self.sample_centroids_closest_distance[candidate_idx] > self.lower_bounds[j, candidate_idx]
505
+
506
+ samples_to_reassign_idx = candidate_idx[reassignment_mask]
507
+
508
+ if len(samples_to_reassign_idx) == 0:
509
+ continue
510
+
511
+ self.labels[samples_to_reassign_idx] = j
512
+ update_distances = distance_to_cj[reassignment_mask]
513
+ self.sample_centroids_closest_distance[samples_to_reassign_idx] = update_distances
514
+
515
+ # Preventing memory issue with mxm operation
516
+ gc.collect()
517
+
518
+ return
519
+
520
+ def _setup_internal_state(self, X: gb.Matrix):
521
+ n_samples = X.shape[0]
522
+
523
+ self.labels = np.zeros(n_samples, dtype=np.int16)
524
+
525
+ # Maintain the distance between each sample and the assigned centroid
526
+ # We need the distances in the function update_centroids for handling empty clusters
527
+ self.sample_centroids_closest_distance = np.zeros(n_samples, dtype=np.float64)
528
+
529
+ self.x_squared_norms = squared_row_norms(X, self.n_threads)
530
+
531
+ self.lower_bounds = np.zeros((self.n_clusters, n_samples), dtype=np.float64)
532
+
533
+ self.mean_feature_variance = self._cal_feature_variance(X)
534
+
535
+ return
536
+
537
+ def _update_internal_state(self):
538
+ self.centroids_shift = self._cal_centroids_shift()
539
+ self.lower_bounds -= self.centroids_shift[:, np.newaxis]
540
+ np.maximum(self.lower_bounds, 0, self.lower_bounds)
541
+
542
+ return
543
+
544
+ def _converged(self):
545
+ tol = (self.centroids_shift**2).sum()
546
+
547
+ return tol <= self.mean_feature_variance * self.tol
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: sparsekmeans
3
+ Version: 0.1
4
+ Summary: A package for efficient K-means clustering on sparse dataset
5
+ Home-page: https://github.com/cjlin1/sparsekmeans
6
+ Author: Chih-Jen Lin, He-Zhe Lin, Khoi Nguyen Pham Dang
7
+ Author-email: cjlin@csie.ntu.edu.tw
8
+ License: MIT
9
+ Requires-Python: >=3.10
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy
12
+ Requires-Dist: python-graphblas
13
+ Requires-Dist: scipy
14
+ Requires-Dist: libsvm-official>=3.36.0
15
+ Dynamic: author
16
+ Dynamic: author-email
17
+ Dynamic: home-page
18
+ Dynamic: license
19
+ Dynamic: license-file
20
+ Dynamic: requires-dist
21
+ Dynamic: requires-python
22
+ Dynamic: summary
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ setup.py
4
+ sparsekmeans/__init__.py
5
+ sparsekmeans/sparse_kmeans.py
6
+ sparsekmeans.egg-info/PKG-INFO
7
+ sparsekmeans.egg-info/SOURCES.txt
8
+ sparsekmeans.egg-info/dependency_links.txt
9
+ sparsekmeans.egg-info/requires.txt
10
+ sparsekmeans.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ numpy
2
+ python-graphblas
3
+ scipy
4
+ libsvm-official>=3.36.0
@@ -0,0 +1 @@
1
+ sparsekmeans