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.
- sparsekmeans-0.1/LICENSE +21 -0
- sparsekmeans-0.1/PKG-INFO +22 -0
- sparsekmeans-0.1/README.md +101 -0
- sparsekmeans-0.1/setup.cfg +4 -0
- sparsekmeans-0.1/setup.py +26 -0
- sparsekmeans-0.1/sparsekmeans/__init__.py +1 -0
- sparsekmeans-0.1/sparsekmeans/sparse_kmeans.py +547 -0
- sparsekmeans-0.1/sparsekmeans.egg-info/PKG-INFO +22 -0
- sparsekmeans-0.1/sparsekmeans.egg-info/SOURCES.txt +10 -0
- sparsekmeans-0.1/sparsekmeans.egg-info/dependency_links.txt +1 -0
- sparsekmeans-0.1/sparsekmeans.egg-info/requires.txt +4 -0
- sparsekmeans-0.1/sparsekmeans.egg-info/top_level.txt +1 -0
sparsekmeans-0.1/LICENSE
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sparsekmeans
|