flash1dkmeans 0.0.1__py3-none-any.whl
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.
- flash1dkmeans/__init__.py +1 -0
- flash1dkmeans/config.py +9 -0
- flash1dkmeans/k_cluster.py +341 -0
- flash1dkmeans/main.py +300 -0
- flash1dkmeans/two_cluster.py +157 -0
- flash1dkmeans/utils.py +37 -0
- flash1dkmeans-0.0.1.dist-info/METADATA +128 -0
- flash1dkmeans-0.0.1.dist-info/RECORD +10 -0
- flash1dkmeans-0.0.1.dist-info/WHEEL +4 -0
- flash1dkmeans-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .main import kmeans_1d
|
flash1dkmeans/config.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""n_cluster.py
|
|
2
|
+
Optimized kmeans for 1D data with n clusters.
|
|
3
|
+
|
|
4
|
+
This module contains an optimized kmeans for 1D data with n clusters.
|
|
5
|
+
|
|
6
|
+
The following is the main function:
|
|
7
|
+
- flash_1d_kmeans_n_cluster
|
|
8
|
+
|
|
9
|
+
Inputs must be sorted in ascending order, no default values are provided, and no error checking is done.
|
|
10
|
+
This is because this module is intended to be used internally.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import numba
|
|
14
|
+
from .utils import query_prefix_sum, query_weights_prefix_sum
|
|
15
|
+
import numpy as np
|
|
16
|
+
from .config import ARRAY_INDEX_DTYPE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#@numba.njit(cache=True)
|
|
20
|
+
def flash_1d_kmeans_k_cluster(
|
|
21
|
+
X,
|
|
22
|
+
n_clusters,
|
|
23
|
+
max_iter,
|
|
24
|
+
is_weighted,
|
|
25
|
+
weights_prefix_sum, weighted_X_prefix_sum,
|
|
26
|
+
weighted_X_squared_prefix_sum,
|
|
27
|
+
n_local_trials, # This value is 2 + int(np.log(n_clusters)) in sklearn
|
|
28
|
+
start_idx,
|
|
29
|
+
stop_idx,
|
|
30
|
+
):
|
|
31
|
+
"""An optimized kmeans for 1D data with n clusters.
|
|
32
|
+
Exploits the fact that the data is 1D to optimize the calculations.
|
|
33
|
+
Time complexity: O(n_clusters * log(n_clusters) * log(len(X)) * n_clusters + max_iter * log(len(X)) * n_clusters)
|
|
34
|
+
= O(k ^ 2 * log(k) * log(n) + max_iter * log(n) * k)
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
X: np.ndarray
|
|
38
|
+
The input data. Should be sorted in ascending order.
|
|
39
|
+
n_clusters: int
|
|
40
|
+
The number of clusters to generate
|
|
41
|
+
max_iter: int
|
|
42
|
+
The maximum number of iterations to run
|
|
43
|
+
is_weighted: bool
|
|
44
|
+
Whether the data is weighted. If True, the weighted versions of the arrays should be provided.
|
|
45
|
+
weights_prefix_sum: np.ndarray
|
|
46
|
+
The prefix sum of the weights. Should be None if the data is unweighted.
|
|
47
|
+
weighted_X_prefix_sum: np.ndarray
|
|
48
|
+
The prefix sum of the weighted X
|
|
49
|
+
weighted_X_squared_prefix_sum: np.ndarray
|
|
50
|
+
The prefix sum of the weighted X squared
|
|
51
|
+
n_local_trials: int
|
|
52
|
+
The number of local trials to run when choosing the next centroid
|
|
53
|
+
start_idx: int
|
|
54
|
+
The start index of the range to consider
|
|
55
|
+
stop_idx: int
|
|
56
|
+
The stop index of the range to consider
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
centroids: np.ndarray
|
|
60
|
+
The centroids of the clusters
|
|
61
|
+
cluster_borders: np.ndarray
|
|
62
|
+
The borders of the clusters
|
|
63
|
+
"""
|
|
64
|
+
if is_weighted:
|
|
65
|
+
assert weights_prefix_sum is not None, "weights_prefix_sum must be provided for weighted data"
|
|
66
|
+
else:
|
|
67
|
+
assert weights_prefix_sum is None, "weights_prefix_sum must not be provided for unweighted data"
|
|
68
|
+
|
|
69
|
+
cluster_borders = np.empty(n_clusters + 1, dtype=ARRAY_INDEX_DTYPE)
|
|
70
|
+
cluster_borders[0] = start_idx
|
|
71
|
+
cluster_borders[-1] = stop_idx
|
|
72
|
+
|
|
73
|
+
centroids = _kmeans_plusplus(
|
|
74
|
+
X, n_clusters, is_weighted,
|
|
75
|
+
weights_prefix_sum, weighted_X_prefix_sum,
|
|
76
|
+
weighted_X_squared_prefix_sum,
|
|
77
|
+
n_local_trials, start_idx, stop_idx,
|
|
78
|
+
)
|
|
79
|
+
sorted_centroids = np.sort(centroids)
|
|
80
|
+
|
|
81
|
+
for _ in range(max_iter):
|
|
82
|
+
new_cluster_borders = _centroids_to_cluster_borders(X, sorted_centroids, start_idx, stop_idx)
|
|
83
|
+
|
|
84
|
+
if np.array_equal(cluster_borders, new_cluster_borders):
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
cluster_borders[:] = new_cluster_borders
|
|
88
|
+
for i in range(n_clusters):
|
|
89
|
+
cluster_start = cluster_borders[i]
|
|
90
|
+
cluster_end = cluster_borders[i + 1]
|
|
91
|
+
|
|
92
|
+
if cluster_end < cluster_start:
|
|
93
|
+
raise ValueError("Cluster end is less than cluster start")
|
|
94
|
+
|
|
95
|
+
if cluster_start == cluster_end:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
cluster_weighted_X_sum = query_prefix_sum(weighted_X_prefix_sum, cluster_start, cluster_end)
|
|
99
|
+
cluster_weight_sum = query_weights_prefix_sum(is_weighted, weights_prefix_sum, cluster_start, cluster_end)
|
|
100
|
+
|
|
101
|
+
if cluster_weight_sum == 0:
|
|
102
|
+
# if the sum of the weights is zero, we set the centroid to the mean of the cluster
|
|
103
|
+
sorted_centroids[i] = X[cluster_start:cluster_end].mean()
|
|
104
|
+
else:
|
|
105
|
+
sorted_centroids[i] = cluster_weighted_X_sum / cluster_weight_sum
|
|
106
|
+
|
|
107
|
+
return sorted_centroids, cluster_borders
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@numba.njit(cache=True)
|
|
111
|
+
def _rand_choice_prefix_sum(arr, prob_prefix_sum, start_idx, stop_idx):
|
|
112
|
+
"""Randomly choose an element from arr according to the probability distribution given by prob_prefix_sum
|
|
113
|
+
Time complexity: O(log(n))
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
arr: np.ndarray
|
|
117
|
+
The array to choose from
|
|
118
|
+
prob_prefix_sum: np.ndarray
|
|
119
|
+
The prefix sum of the probability distribution
|
|
120
|
+
start_idx: int
|
|
121
|
+
The start index of the range to consider
|
|
122
|
+
stop_idx: int
|
|
123
|
+
The stop index of the range to consider
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The chosen element
|
|
127
|
+
"""
|
|
128
|
+
total_prob = query_prefix_sum(prob_prefix_sum, start_idx, stop_idx)
|
|
129
|
+
selector = np.random.random_sample() * total_prob
|
|
130
|
+
|
|
131
|
+
# Because we are using start_idx as the base, but the prefix sum is calculated from 0,
|
|
132
|
+
# we need to adjust the selector if start_idx is not 0.
|
|
133
|
+
adjusted_selector = selector + prob_prefix_sum[start_idx - 1] if start_idx > 0 else selector
|
|
134
|
+
|
|
135
|
+
# Search for the index of the selector in the prefix sum, and add start_idx to get the index in the original array
|
|
136
|
+
idx = np.searchsorted(prob_prefix_sum[start_idx:stop_idx], adjusted_selector) + start_idx
|
|
137
|
+
|
|
138
|
+
return arr[idx]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
#@numba.njit(cache=True)
|
|
142
|
+
def _centroids_to_cluster_borders(X, sorted_centroids, start_idx, stop_idx):
|
|
143
|
+
"""Converts the centroids to cluster borders.
|
|
144
|
+
The cluster borders are where the clusters are divided.
|
|
145
|
+
The centroids must be sorted.
|
|
146
|
+
|
|
147
|
+
Time complexity: O(log(len(X)) * len(centroids))
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
X: np.ndarray
|
|
151
|
+
The input data. Should be sorted in ascending order.
|
|
152
|
+
sorted_centroids: np.ndarray
|
|
153
|
+
The sorted centroids
|
|
154
|
+
start_idx: int
|
|
155
|
+
The start index of the range to consider
|
|
156
|
+
stop_idx: int
|
|
157
|
+
The stop index of the range to consider
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
np.ndarray: The cluster borders
|
|
161
|
+
"""
|
|
162
|
+
midpoints = (sorted_centroids[:-1] + sorted_centroids[1:]) / 2
|
|
163
|
+
cluster_borders = np.empty(len(sorted_centroids) + 1, dtype=ARRAY_INDEX_DTYPE)
|
|
164
|
+
cluster_borders[0] = start_idx
|
|
165
|
+
cluster_borders[-1] = stop_idx
|
|
166
|
+
cluster_borders[1:-1] = np.searchsorted(X[start_idx:stop_idx], midpoints) + start_idx
|
|
167
|
+
return cluster_borders
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#@numba.njit(cache=True)
|
|
171
|
+
def _calculate_inertia(sorted_centroids, centroid_ranges,
|
|
172
|
+
is_weighted, weights_prefix_sum, weighted_X_prefix_sum, weighted_X_squared_prefix_sum,
|
|
173
|
+
stop_idx):
|
|
174
|
+
"""Calculates the inertia of the clusters given the centroids.
|
|
175
|
+
The inertia is the sum of the squared distances of each sample to the closest centroid.
|
|
176
|
+
The calculations are done efficiently using prefix sums.
|
|
177
|
+
|
|
178
|
+
Time complexity: O(len(centroids))
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
sorted_centroids: np.ndarray
|
|
182
|
+
The centroids of the clusters
|
|
183
|
+
centroid_ranges: np.ndarray
|
|
184
|
+
The borders of the clusters
|
|
185
|
+
is_weighted: bool
|
|
186
|
+
Whether the data is weighted. If True, the weighted versions of the arrays should be provided.
|
|
187
|
+
weights_prefix_sum: np.ndarray
|
|
188
|
+
The prefix sum of the weights. Should be None if the data is unweighted.
|
|
189
|
+
weighted_X_prefix_sum: np.ndarray
|
|
190
|
+
The prefix sum of the weighted X
|
|
191
|
+
weighted_X_squared_prefix_sum: np.ndarray
|
|
192
|
+
The prefix sum of the weighted X squared
|
|
193
|
+
stop_idx: int
|
|
194
|
+
The stop index of the range to consider
|
|
195
|
+
"""
|
|
196
|
+
# inertia = sigma_i(w_i * abs(x_i - c)^2) = sigma_i(w_i * (x_i^2 - 2 * x_i * c + c^2))
|
|
197
|
+
# = sigma_i(w_i * x_i^2) - 2 * c * sigma_i(w_i * x_i) + c^2 * sigma_i(w_i)
|
|
198
|
+
# = sigma_i(weighted_X_squared) - 2 * c * sigma_i(weighted_X) + c^2 * sigma_i(weight)
|
|
199
|
+
# Note that the centroid c is the CLOSEST centroid to x_i, so the above calculation must be done for each cluster
|
|
200
|
+
|
|
201
|
+
inertia = 0
|
|
202
|
+
for i in range(len(sorted_centroids)):
|
|
203
|
+
start = centroid_ranges[i]
|
|
204
|
+
end = centroid_ranges[i + 1]
|
|
205
|
+
|
|
206
|
+
if start >= stop_idx:
|
|
207
|
+
break
|
|
208
|
+
if end >= stop_idx:
|
|
209
|
+
end = stop_idx
|
|
210
|
+
|
|
211
|
+
if start == end:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
cluster_weighted_X_squared_sum = query_prefix_sum(weighted_X_squared_prefix_sum, start, end)
|
|
215
|
+
cluster_weighted_X_sum = query_prefix_sum(weighted_X_prefix_sum, start, end)
|
|
216
|
+
cluster_weight_sum = query_weights_prefix_sum(is_weighted, weights_prefix_sum, start, end)
|
|
217
|
+
|
|
218
|
+
inertia += (cluster_weighted_X_squared_sum - 2 * sorted_centroids[i] * cluster_weighted_X_sum +
|
|
219
|
+
sorted_centroids[i] ** 2 * cluster_weight_sum)
|
|
220
|
+
|
|
221
|
+
return inertia
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
#@numba.njit(cache=True)
|
|
225
|
+
def _rand_choice_centroids(X, centroids,
|
|
226
|
+
is_weighted, weights_prefix_sum, weighted_X_prefix_sum, weighted_X_squared_prefix_sum,
|
|
227
|
+
sample_size, start_idx, stop_idx):
|
|
228
|
+
"""Randomly choose sample_size elements from X, weighted by the distance to the closest centroid.
|
|
229
|
+
The weighted logic is implemented efficiently by utilizing the _calculate_inertia function.
|
|
230
|
+
|
|
231
|
+
Time complexity: O(sample_size * log(len(X)) * len(centroids))
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
X: np.ndarray
|
|
235
|
+
The input data. Should be sorted in ascending order.
|
|
236
|
+
centroids: np.ndarray
|
|
237
|
+
The centroids of the clusters
|
|
238
|
+
is_weighted: bool
|
|
239
|
+
Whether the data is weighted. If True, the weighted versions of the arrays should be provided.
|
|
240
|
+
weights_prefix_sum: np.ndarray
|
|
241
|
+
The prefix sum of the weights. Should be None if the data is unweighted.
|
|
242
|
+
weighted_X_prefix_sum: np.ndarray
|
|
243
|
+
The prefix sum of the weighted X
|
|
244
|
+
weighted_X_squared_prefix_sum: np.ndarray
|
|
245
|
+
The prefix sum of the weighted X squared
|
|
246
|
+
sample_size: int
|
|
247
|
+
The number of samples to choose
|
|
248
|
+
start_idx: int
|
|
249
|
+
The start index of the range to consider
|
|
250
|
+
stop_idx: int
|
|
251
|
+
The stop index of the range to consider
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
np.ndarray: The chosen samples
|
|
255
|
+
"""
|
|
256
|
+
sorted_centroids = np.sort(centroids)
|
|
257
|
+
cluster_borders = _centroids_to_cluster_borders(X, sorted_centroids, start_idx, stop_idx)
|
|
258
|
+
total_inertia = _calculate_inertia(sorted_centroids, cluster_borders,
|
|
259
|
+
is_weighted, weights_prefix_sum, weighted_X_prefix_sum,
|
|
260
|
+
weighted_X_squared_prefix_sum, stop_idx)
|
|
261
|
+
selectors = np.random.random_sample(sample_size) * total_inertia
|
|
262
|
+
results = np.empty(sample_size, dtype=centroids.dtype)
|
|
263
|
+
|
|
264
|
+
for i in range(sample_size):
|
|
265
|
+
selector = selectors[i]
|
|
266
|
+
floor = start_idx + 1
|
|
267
|
+
ceiling = stop_idx
|
|
268
|
+
while floor < ceiling:
|
|
269
|
+
stop_idx_cand = (floor + ceiling) // 2
|
|
270
|
+
inertia = _calculate_inertia(sorted_centroids, cluster_borders,
|
|
271
|
+
is_weighted, weights_prefix_sum, weighted_X_prefix_sum,
|
|
272
|
+
weighted_X_squared_prefix_sum, stop_idx_cand)
|
|
273
|
+
if inertia < selector:
|
|
274
|
+
floor = stop_idx_cand + 1
|
|
275
|
+
else:
|
|
276
|
+
ceiling = stop_idx_cand
|
|
277
|
+
results[i] = X[floor - 1]
|
|
278
|
+
|
|
279
|
+
return results
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
#@numba.njit(cache=True)
|
|
283
|
+
def _kmeans_plusplus(X, n_clusters,
|
|
284
|
+
is_weighted, weights_prefix_sum, weighted_X_prefix_sum, weighted_X_squared_prefix_sum,
|
|
285
|
+
n_local_trials, start_idx, stop_idx):
|
|
286
|
+
"""An optimized version of the kmeans++ initialization algorithm for 1D data.
|
|
287
|
+
The algorithm is optimized for 1D data and utilizes prefix sums for efficient calculations.
|
|
288
|
+
|
|
289
|
+
Time complexity: O(n_clusters * log(n_clusters) * log(len(X)) * n_clusters)
|
|
290
|
+
= O(k ^ 2 * log(k) * log(n))
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
X: np.ndarray
|
|
294
|
+
The input data
|
|
295
|
+
n_clusters: int
|
|
296
|
+
The number of clusters to choose
|
|
297
|
+
is_weighted: bool
|
|
298
|
+
Whether the data is weighted. If True, the weighted versions of the arrays should be provided.
|
|
299
|
+
weights_prefix_sum: np.ndarray
|
|
300
|
+
The prefix sum of the weights. Should be None if the data is unweighted.
|
|
301
|
+
weighted_X_prefix_sum: np.ndarray
|
|
302
|
+
The prefix sum of the weighted X
|
|
303
|
+
weighted_X_squared_prefix_sum: np.ndarray
|
|
304
|
+
The prefix sum of the weighted X squared
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
np.ndarray: The chosen centroids
|
|
308
|
+
"""
|
|
309
|
+
centroids = np.empty(n_clusters, dtype=X.dtype)
|
|
310
|
+
|
|
311
|
+
# First centroid is chosen randomly according to sample_weight
|
|
312
|
+
if is_weighted:
|
|
313
|
+
centroids[0] = _rand_choice_prefix_sum(X, weights_prefix_sum, start_idx, stop_idx)
|
|
314
|
+
else:
|
|
315
|
+
centroids[0] = X[np.random.randint(start_idx, stop_idx)]
|
|
316
|
+
|
|
317
|
+
for c_id in range(1, n_clusters):
|
|
318
|
+
# Choose the next centroid randomly according to the weighted distances
|
|
319
|
+
# Sample n_local_trials candidates and choose the best one
|
|
320
|
+
centroid_candidates = _rand_choice_centroids(
|
|
321
|
+
X, centroids[:c_id],
|
|
322
|
+
is_weighted, weights_prefix_sum, weighted_X_prefix_sum,
|
|
323
|
+
weighted_X_squared_prefix_sum, n_local_trials,
|
|
324
|
+
start_idx, stop_idx
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
best_inertia = np.inf
|
|
328
|
+
best_centroid = None
|
|
329
|
+
for i in range(len(centroid_candidates)):
|
|
330
|
+
centroids[c_id] = centroid_candidates[i]
|
|
331
|
+
sorted_centroids = np.sort(centroids[:c_id + 1])
|
|
332
|
+
centroid_ranges = _centroids_to_cluster_borders(X, sorted_centroids, start_idx, stop_idx)
|
|
333
|
+
inertia = _calculate_inertia(sorted_centroids, centroid_ranges,
|
|
334
|
+
is_weighted, weights_prefix_sum, weighted_X_prefix_sum,
|
|
335
|
+
weighted_X_squared_prefix_sum, stop_idx)
|
|
336
|
+
if inertia < best_inertia:
|
|
337
|
+
best_inertia = inertia
|
|
338
|
+
best_centroid = centroid_candidates[i]
|
|
339
|
+
centroids[c_id] = best_centroid
|
|
340
|
+
|
|
341
|
+
return centroids
|
flash1dkmeans/main.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from .k_cluster import flash_1d_kmeans_k_cluster
|
|
2
|
+
from .two_cluster import flash_1d_kmeans_two_cluster
|
|
3
|
+
from .config import LABEL_DTYPE, PREFIX_SUM_DTYPE
|
|
4
|
+
import numpy as np
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def kmeans_1d(
|
|
9
|
+
X,
|
|
10
|
+
n_clusters,
|
|
11
|
+
max_iter=300,
|
|
12
|
+
is_sorted=False,
|
|
13
|
+
sample_weights=None,
|
|
14
|
+
n_local_trials=None,
|
|
15
|
+
return_cluster_borders=False,
|
|
16
|
+
weights_prefix_sum=None,
|
|
17
|
+
weighted_X_prefix_sum=None,
|
|
18
|
+
weighted_X_squared_prefix_sum=None,
|
|
19
|
+
start_idx=None,
|
|
20
|
+
stop_idx=None,
|
|
21
|
+
):
|
|
22
|
+
"""An optimized kmeans for 1D data.
|
|
23
|
+
Utilizes a binary search to find the optimal division points for 2 clusters,
|
|
24
|
+
and an optimized kmeans++ initialization for more than 2 clusters.
|
|
25
|
+
|
|
26
|
+
Exploits the fact that 1D data can be sorted.
|
|
27
|
+
|
|
28
|
+
Time complexity:
|
|
29
|
+
2 clusters:
|
|
30
|
+
O(log(n)) (+ (O(n) for prefix sum calculation if not provided))
|
|
31
|
+
n clusters:
|
|
32
|
+
O(n_clusters * 2 + log(n_clusters) * log(n) + max_iter * log(n) * n_clusters)
|
|
33
|
+
(+ (O(n) for prefix sum calculation if not provided))
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
X: np.ndarray or list
|
|
37
|
+
The input data. Should be sorted in ascending order if is_sorted is False.
|
|
38
|
+
n_clusters: int
|
|
39
|
+
The number of clusters.
|
|
40
|
+
max_iter: int
|
|
41
|
+
The maximum number of iterations. Only relevant for n_clusters > 2.
|
|
42
|
+
is_sorted: bool
|
|
43
|
+
Whether the data is already sorted.
|
|
44
|
+
sample_weights: np.ndarray or list or None
|
|
45
|
+
The sample weights. If None, all samples are weighted equally.
|
|
46
|
+
n_local_trials: int
|
|
47
|
+
The number of local trials for kmeans++ initialization. Only relevant for n_clusters > 2.
|
|
48
|
+
return_cluster_borders: bool
|
|
49
|
+
Whether to return the cluster border indices instead of the labels.
|
|
50
|
+
|
|
51
|
+
In the case of repeatedly calling kmeans on different ranges of the same data, the following arguments
|
|
52
|
+
can be provided to avoid redundant calculations. Make sure that the data is sorted if these are provided.
|
|
53
|
+
|
|
54
|
+
weights_prefix_sum: np.ndarray or list or None
|
|
55
|
+
The prefix sum of the sample weights. Should be None if the data is unweighted.
|
|
56
|
+
weighted_X_prefix_sum: np.ndarray or list or None
|
|
57
|
+
The prefix sum of (the weighted) X.
|
|
58
|
+
weighted_X_squared_prefix_sum: np.ndarray or list or None
|
|
59
|
+
The prefix sum of (weighted) X squared.
|
|
60
|
+
start_idx: int
|
|
61
|
+
The start index of the range to consider.
|
|
62
|
+
stop_idx: int
|
|
63
|
+
The stop index of the range to consider.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
centroids: np.ndarray
|
|
67
|
+
The centroids of the clusters.
|
|
68
|
+
labels: np.ndarray
|
|
69
|
+
The labels of the samples. Only returned if return_cluster_borders is False.
|
|
70
|
+
cluster_borders: np.ndarray
|
|
71
|
+
The borders of the clusters. Only returned if return_cluster_borders is True.
|
|
72
|
+
"""
|
|
73
|
+
# -------------- Input validation --------------
|
|
74
|
+
if weighted_X_prefix_sum is not None:
|
|
75
|
+
assert weighted_X_squared_prefix_sum is not None, \
|
|
76
|
+
"If weighted_X_prefix_sum is provided, weighted_X_squared_prefix_sum should also be provided."
|
|
77
|
+
# Note that weights_prefix_sum may be None if the data is unweighted
|
|
78
|
+
|
|
79
|
+
# Check if user provided both sample_weights and weights_prefix_sum
|
|
80
|
+
if sample_weights is not None and weights_prefix_sum is not None:
|
|
81
|
+
raise ValueError("Both sample_weights and weights_prefix_sum cannot be provided. Please provide only one.")
|
|
82
|
+
|
|
83
|
+
# Check if data is weighted
|
|
84
|
+
if sample_weights is not None or weights_prefix_sum is not None:
|
|
85
|
+
is_weighted = True
|
|
86
|
+
else:
|
|
87
|
+
is_weighted = False
|
|
88
|
+
|
|
89
|
+
# Check that data is sorted if weighted_X_prefix_sum is provided
|
|
90
|
+
if weighted_X_prefix_sum is not None and not is_sorted:
|
|
91
|
+
raise ValueError("The data must be sorted if weighted_X_prefix_sum is provided.")
|
|
92
|
+
|
|
93
|
+
# Check if all lengths are the same
|
|
94
|
+
if sample_weights is not None:
|
|
95
|
+
assert len(X) == len(sample_weights), "X and sample_weights must have the same length"
|
|
96
|
+
if weights_prefix_sum is not None:
|
|
97
|
+
assert len(X) == len(weights_prefix_sum), "X and weights_prefix_sum must have the same length"
|
|
98
|
+
if weighted_X_prefix_sum is not None:
|
|
99
|
+
assert len(X) == len(weighted_X_prefix_sum), "X and weighted_X_prefix_sum must have the same length"
|
|
100
|
+
if weighted_X_squared_prefix_sum is not None:
|
|
101
|
+
assert len(X) == len(weighted_X_squared_prefix_sum), \
|
|
102
|
+
"X and weighted_X_squared_prefix_sum must have the same length"
|
|
103
|
+
|
|
104
|
+
# Check that return_cluster_borders is called with is_sorted=True, warn the user otherwise
|
|
105
|
+
if return_cluster_borders: # User requested cluster borders
|
|
106
|
+
if not is_sorted: # However, the user did not provide sorted data
|
|
107
|
+
logging.warning( # Warn the user that the data will be sorted
|
|
108
|
+
"The returned cluster borders will be indexed in the sorted order of X, not the original order.")
|
|
109
|
+
|
|
110
|
+
# Check if start_idx and stop_idx are given with sorted data
|
|
111
|
+
if not is_sorted:
|
|
112
|
+
assert start_idx is None and stop_idx is None, "start_idx and stop_idx are only valid if the data is sorted."
|
|
113
|
+
|
|
114
|
+
# Check some values
|
|
115
|
+
assert n_clusters >= 2, "n_clusters must be at least 2"
|
|
116
|
+
assert max_iter >= 0, "max_iter must be non-negative"
|
|
117
|
+
assert n_local_trials is None or n_local_trials > 0, "n_local_trials must be positive"
|
|
118
|
+
|
|
119
|
+
# -------------- Convert all arrays to numpy arrays --------------
|
|
120
|
+
X = np.asarray(X)
|
|
121
|
+
|
|
122
|
+
if sample_weights is not None:
|
|
123
|
+
sample_weights = np.asarray(sample_weights)
|
|
124
|
+
|
|
125
|
+
if weights_prefix_sum is not None:
|
|
126
|
+
weights_prefix_sum = np.asarray(weights_prefix_sum)
|
|
127
|
+
|
|
128
|
+
if weighted_X_prefix_sum is not None:
|
|
129
|
+
weighted_X_prefix_sum = np.asarray(weighted_X_prefix_sum)
|
|
130
|
+
|
|
131
|
+
if weighted_X_squared_prefix_sum is not None:
|
|
132
|
+
weighted_X_squared_prefix_sum = np.asarray(weighted_X_squared_prefix_sum)
|
|
133
|
+
|
|
134
|
+
# -------------- Setting up defaults --------------
|
|
135
|
+
|
|
136
|
+
if start_idx is None:
|
|
137
|
+
start_idx = 0
|
|
138
|
+
if stop_idx is None:
|
|
139
|
+
stop_idx = len(X)
|
|
140
|
+
|
|
141
|
+
assert 0 <= start_idx < stop_idx <= len(X), "Invalid start_idx and stop_idx"
|
|
142
|
+
|
|
143
|
+
if n_local_trials is None:
|
|
144
|
+
n_local_trials = 2 + int(np.log(n_clusters))
|
|
145
|
+
|
|
146
|
+
# -------------- Sorting --------------
|
|
147
|
+
|
|
148
|
+
# Sort the data if it is not already sorted
|
|
149
|
+
# Note that we checked that start_idx and stop_idx are not given if the data is not sorted
|
|
150
|
+
if not is_sorted:
|
|
151
|
+
sorted_indices = np.argsort(X)
|
|
152
|
+
sorted_X = X[sorted_indices]
|
|
153
|
+
sorted_sample_weights = None if sample_weights is None else sample_weights[sorted_indices]
|
|
154
|
+
else:
|
|
155
|
+
sorted_indices = None
|
|
156
|
+
sorted_X = X
|
|
157
|
+
sorted_sample_weights = sample_weights
|
|
158
|
+
|
|
159
|
+
# -------------- Main algorithm --------------
|
|
160
|
+
|
|
161
|
+
centroids, cluster_borders = _sorted_kmeans_1d(
|
|
162
|
+
sorted_X,
|
|
163
|
+
n_clusters,
|
|
164
|
+
max_iter,
|
|
165
|
+
is_weighted,
|
|
166
|
+
sorted_sample_weights,
|
|
167
|
+
n_local_trials,
|
|
168
|
+
weights_prefix_sum,
|
|
169
|
+
weighted_X_prefix_sum,
|
|
170
|
+
weighted_X_squared_prefix_sum,
|
|
171
|
+
start_idx,
|
|
172
|
+
stop_idx,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# -------------- Post-processing --------------
|
|
176
|
+
|
|
177
|
+
if return_cluster_borders:
|
|
178
|
+
# We checked that the data is sorted if cluster borders are requested, so no post-processing is needed
|
|
179
|
+
return centroids, cluster_borders
|
|
180
|
+
else:
|
|
181
|
+
# Convert cluster borders to labels
|
|
182
|
+
labels = np.zeros(len(X), dtype=LABEL_DTYPE)
|
|
183
|
+
for i in range(n_clusters):
|
|
184
|
+
labels[cluster_borders[i]:cluster_borders[i + 1]] = i
|
|
185
|
+
|
|
186
|
+
# Unsort the labels if the data was not sorted
|
|
187
|
+
if not is_sorted:
|
|
188
|
+
assert sorted_indices is not None, "sorted_indices should be available here"
|
|
189
|
+
labels = labels[np.argsort(sorted_indices)]
|
|
190
|
+
|
|
191
|
+
return centroids, labels
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _sorted_kmeans_1d(
|
|
195
|
+
sorted_X, # caller should ensure that X is sorted
|
|
196
|
+
n_clusters, # caller should ensure n_clusters >= 2
|
|
197
|
+
max_iter, # caller should ensure max_iter >= 0
|
|
198
|
+
is_weighted,
|
|
199
|
+
sample_weights,
|
|
200
|
+
n_local_trials,
|
|
201
|
+
weights_prefix_sum,
|
|
202
|
+
weighted_X_prefix_sum,
|
|
203
|
+
weighted_X_squared_prefix_sum,
|
|
204
|
+
start_idx, # caller should ensure 0 <= start_idx < stop_idx <= len(X)
|
|
205
|
+
stop_idx, # caller should ensure 0 <= start_idx < stop_idx <= len(X)
|
|
206
|
+
):
|
|
207
|
+
"""The main algorithm for flash_1d_kmeans.
|
|
208
|
+
This function assumes that the input data is sorted, and all input is assumed to be valid.
|
|
209
|
+
|
|
210
|
+
Time complexity:
|
|
211
|
+
2 clusters:
|
|
212
|
+
O(log(n)) (+ (O(n) for prefix sum calculation if not provided))
|
|
213
|
+
n clusters:
|
|
214
|
+
O(n_clusters * 2 + log(n_clusters) * log(n) + max_iter * log(n) * n_clusters)
|
|
215
|
+
(+ (O(n) for prefix sum calculation if not provided))
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
sorted_X: np.ndarray
|
|
219
|
+
The input data. Should be sorted in ascending order.
|
|
220
|
+
n_clusters: int
|
|
221
|
+
The number of clusters.
|
|
222
|
+
max_iter: int
|
|
223
|
+
The maximum number of iterations.
|
|
224
|
+
is_weighted: bool
|
|
225
|
+
Whether the data is weighted.
|
|
226
|
+
sample_weights: np.ndarray or list or None
|
|
227
|
+
The sample weights.
|
|
228
|
+
n_local_trials: int
|
|
229
|
+
The number of local trials for kmeans++ initialization.
|
|
230
|
+
weights_prefix_sum: np.ndarray or list or None
|
|
231
|
+
The prefix sum of the sample weights.
|
|
232
|
+
weighted_X_prefix_sum: np.ndarray or list or None
|
|
233
|
+
The prefix sum of (the weighted) X.
|
|
234
|
+
weighted_X_squared_prefix_sum: np.ndarray or list or None
|
|
235
|
+
The prefix sum of (the squared weighted) X.
|
|
236
|
+
start_idx: int
|
|
237
|
+
The start index of the range to consider.
|
|
238
|
+
stop_idx: int
|
|
239
|
+
The stop index of the range to consider.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
centroids: np.ndarray
|
|
243
|
+
The centroids of the clusters.
|
|
244
|
+
cluster_borders: np.ndarray
|
|
245
|
+
The borders of the clusters.
|
|
246
|
+
"""
|
|
247
|
+
if n_clusters == 2:
|
|
248
|
+
# If weighted_X_prefix_sum is not provided, neither is weights_prefix_sum (checked by the caller)
|
|
249
|
+
# They will be generated from sample_weights if needed
|
|
250
|
+
if weighted_X_prefix_sum is None:
|
|
251
|
+
sorted_X_casted = sorted_X.astype(PREFIX_SUM_DTYPE)
|
|
252
|
+
if is_weighted:
|
|
253
|
+
weights_casted = sample_weights.astype(PREFIX_SUM_DTYPE)
|
|
254
|
+
weights_prefix_sum = np.cumsum(weights_casted)
|
|
255
|
+
|
|
256
|
+
weighted_X_prefix_sum = np.cumsum(sorted_X_casted * weights_casted)
|
|
257
|
+
else:
|
|
258
|
+
weights_prefix_sum = None # weights_prefix_sum is not needed
|
|
259
|
+
weighted_X_prefix_sum = np.cumsum(sorted_X_casted) # no weighting needed
|
|
260
|
+
else:
|
|
261
|
+
pass # weighted_X_prefix_sum and weights_prefix_sum are already provided
|
|
262
|
+
centroids, cluster_borders = flash_1d_kmeans_two_cluster(
|
|
263
|
+
sorted_X,
|
|
264
|
+
is_weighted,
|
|
265
|
+
weighted_X_prefix_sum,
|
|
266
|
+
weights_prefix_sum,
|
|
267
|
+
start_idx,
|
|
268
|
+
stop_idx,
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
# If weighted_X_prefix_sum is not provided, neither is weights_prefix_sum and weighted_X_squared_prefix_sum
|
|
272
|
+
# They will be generated from sample_weights if needed
|
|
273
|
+
if weighted_X_prefix_sum is None:
|
|
274
|
+
sorted_X_casted = sorted_X.astype(PREFIX_SUM_DTYPE)
|
|
275
|
+
if is_weighted:
|
|
276
|
+
weights_casted = sample_weights.astype(PREFIX_SUM_DTYPE)
|
|
277
|
+
weights_prefix_sum = np.cumsum(weights_casted)
|
|
278
|
+
|
|
279
|
+
weighted_X_casted = sorted_X_casted * weights_casted
|
|
280
|
+
weighted_X_prefix_sum = np.cumsum(weighted_X_casted)
|
|
281
|
+
weighted_X_squared_prefix_sum = np.cumsum(weighted_X_casted * sorted_X_casted)
|
|
282
|
+
else:
|
|
283
|
+
weights_prefix_sum = None # weights_prefix_sum is not needed
|
|
284
|
+
weighted_X_prefix_sum = np.cumsum(sorted_X_casted)
|
|
285
|
+
weighted_X_squared_prefix_sum = np.cumsum(sorted_X_casted ** 2)
|
|
286
|
+
|
|
287
|
+
centroids, cluster_borders = flash_1d_kmeans_k_cluster(
|
|
288
|
+
sorted_X,
|
|
289
|
+
n_clusters,
|
|
290
|
+
max_iter,
|
|
291
|
+
is_weighted,
|
|
292
|
+
weights_prefix_sum,
|
|
293
|
+
weighted_X_prefix_sum,
|
|
294
|
+
weighted_X_squared_prefix_sum,
|
|
295
|
+
n_local_trials,
|
|
296
|
+
start_idx,
|
|
297
|
+
stop_idx,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return centroids, cluster_borders
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""two_cluster.py
|
|
2
|
+
Optimized kmeans for 1D data with 2 clusters.
|
|
3
|
+
|
|
4
|
+
This module contains an optimized kmeans for 1D data with 2 clusters.
|
|
5
|
+
|
|
6
|
+
The following is the main function:
|
|
7
|
+
- flash_1d_kmeans_two_cluster
|
|
8
|
+
|
|
9
|
+
Inputs must be sorted in ascending order, no default values are provided, and no error checking is done.
|
|
10
|
+
This is because this module is intended to be used internally.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numba
|
|
15
|
+
from .utils import query_prefix_sum, query_weights_prefix_sum
|
|
16
|
+
from .config import PREFIX_SUM_DTYPE, ARRAY_INDEX_DTYPE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#@numba.njit(cache=True)
|
|
20
|
+
def flash_1d_kmeans_two_cluster(
|
|
21
|
+
X,
|
|
22
|
+
is_weighted,
|
|
23
|
+
weighted_X_prefix_sum,
|
|
24
|
+
sample_weight_prefix_sum,
|
|
25
|
+
start_idx,
|
|
26
|
+
stop_idx,
|
|
27
|
+
):
|
|
28
|
+
"""An optimized kmeans for 1D data with 2 clusters.
|
|
29
|
+
Utilizes a binary search to find the optimal division point.
|
|
30
|
+
Time complexity: O(log(n))
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
X: np.ndarray
|
|
34
|
+
The input data. Should be sorted in ascending order.
|
|
35
|
+
is_weighted: bool
|
|
36
|
+
Whether the data is weighted. If True, the weighted versions of the arrays should be provided.
|
|
37
|
+
weighted_X_prefix_sum: np.ndarray
|
|
38
|
+
The prefix sum of (the weighted) X.
|
|
39
|
+
sample_weight_prefix_sum: np.ndarray
|
|
40
|
+
The prefix sum of the sample weights. Should be None if the data is unweighted.
|
|
41
|
+
start_idx: int
|
|
42
|
+
The start index of the range to consider.
|
|
43
|
+
stop_idx: int
|
|
44
|
+
The stop index of the range to consider.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
centroids: np.ndarray
|
|
48
|
+
The centroids of the two clusters, shape (2,)
|
|
49
|
+
cluster_borders: np.ndarray
|
|
50
|
+
The borders of the two clusters, shape (3,)
|
|
51
|
+
|
|
52
|
+
WARNING: X should be sorted in ascending order before calling this function.
|
|
53
|
+
"""
|
|
54
|
+
if is_weighted:
|
|
55
|
+
assert sample_weight_prefix_sum is not None, "sample_weight_prefix_sum must be provided for weighted data"
|
|
56
|
+
else:
|
|
57
|
+
assert sample_weight_prefix_sum is None, "sample_weight_prefix_sum must not be provided for unweighted data"
|
|
58
|
+
|
|
59
|
+
size = stop_idx - start_idx
|
|
60
|
+
centroids = np.empty(2, dtype=X.dtype)
|
|
61
|
+
cluster_borders = np.empty(3, dtype=ARRAY_INDEX_DTYPE)
|
|
62
|
+
cluster_borders[0] = start_idx
|
|
63
|
+
cluster_borders[2] = stop_idx
|
|
64
|
+
# Remember to set cluster_borders[1] as the division point
|
|
65
|
+
|
|
66
|
+
if size == 1:
|
|
67
|
+
centroids[0], centroids[1] = X[start_idx], X[start_idx]
|
|
68
|
+
cluster_borders[1] = start_idx + 1
|
|
69
|
+
return centroids, cluster_borders
|
|
70
|
+
|
|
71
|
+
if size == 2:
|
|
72
|
+
centroids[0], centroids[1] = X[start_idx], X[start_idx + 1]
|
|
73
|
+
cluster_borders[1] = start_idx + 1
|
|
74
|
+
return centroids, cluster_borders
|
|
75
|
+
|
|
76
|
+
# Now we know that there are at least 3 elements
|
|
77
|
+
|
|
78
|
+
if is_weighted:
|
|
79
|
+
# If the sum of the sample weight in the range is 0, we assume that the data is unweighted
|
|
80
|
+
if query_weights_prefix_sum(True, sample_weight_prefix_sum, start_idx, stop_idx) == 0:
|
|
81
|
+
is_weighted = False
|
|
82
|
+
# We need to recalculate the prefix sum, as previously it would have been all zeros
|
|
83
|
+
weighted_X_prefix_sum = np.cumsum(X, dtype=PREFIX_SUM_DTYPE)
|
|
84
|
+
sample_weight_prefix_sum = None
|
|
85
|
+
else:
|
|
86
|
+
# Check if there is only one nonzero sample weight
|
|
87
|
+
total_weight = query_weights_prefix_sum(True, sample_weight_prefix_sum, start_idx, stop_idx)
|
|
88
|
+
sample_weight_prefix_sum_within_range = sample_weight_prefix_sum[start_idx:stop_idx]
|
|
89
|
+
final_increase_idx = np.searchsorted(sample_weight_prefix_sum_within_range,
|
|
90
|
+
sample_weight_prefix_sum_within_range[-1])
|
|
91
|
+
final_increase_amount = query_weights_prefix_sum(True, sample_weight_prefix_sum,
|
|
92
|
+
start_idx + final_increase_idx,
|
|
93
|
+
start_idx + final_increase_idx + 1)
|
|
94
|
+
if total_weight == final_increase_amount:
|
|
95
|
+
# If there is only one nonzero sample weight, we need to return the corresponding weight as the centroid
|
|
96
|
+
# and set all elements to the left cluster
|
|
97
|
+
nonzero_weight_index = start_idx + final_increase_idx
|
|
98
|
+
centroids[0], centroids[1] = X[nonzero_weight_index], X[nonzero_weight_index]
|
|
99
|
+
cluster_borders[1] = stop_idx
|
|
100
|
+
return centroids, cluster_borders
|
|
101
|
+
|
|
102
|
+
# Now we know that there are at least 3 elements and at least 2 nonzero weights
|
|
103
|
+
|
|
104
|
+
# KMeans with 2 clusters on 1D data is equivalent to finding a division point.
|
|
105
|
+
# The division point can be found by doing a binary search on the prefix sum.
|
|
106
|
+
|
|
107
|
+
# We will do a search for the division point,
|
|
108
|
+
# where we search for the optimum number of elements in the first cluster
|
|
109
|
+
# We don't want empty clusters, so we set the floor and ceiling to start_idx + 1 and stop_idx - 1
|
|
110
|
+
floor = start_idx + 1
|
|
111
|
+
ceiling = stop_idx - 1
|
|
112
|
+
left_centroid = None
|
|
113
|
+
right_centroid = None
|
|
114
|
+
division_point = None
|
|
115
|
+
|
|
116
|
+
while floor + 1 < ceiling:
|
|
117
|
+
division_point = (floor + ceiling) // 2
|
|
118
|
+
# If the left cluster has no weight, we need to move the floor up
|
|
119
|
+
left_weight_sum = query_weights_prefix_sum(is_weighted, sample_weight_prefix_sum, start_idx, division_point)
|
|
120
|
+
if left_weight_sum == 0:
|
|
121
|
+
floor = division_point
|
|
122
|
+
continue
|
|
123
|
+
right_weight_sum = query_weights_prefix_sum(is_weighted, sample_weight_prefix_sum, division_point, stop_idx)
|
|
124
|
+
# If the right cluster has no weight, we need to move the ceiling down
|
|
125
|
+
if right_weight_sum == 0:
|
|
126
|
+
ceiling = division_point
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
left_centroid = query_prefix_sum(weighted_X_prefix_sum, start_idx, division_point) / left_weight_sum
|
|
130
|
+
right_centroid = query_prefix_sum(weighted_X_prefix_sum, division_point, stop_idx) / right_weight_sum
|
|
131
|
+
|
|
132
|
+
new_division_point_value = (left_centroid + right_centroid) / 2
|
|
133
|
+
if X[division_point - 1] <= new_division_point_value:
|
|
134
|
+
if new_division_point_value <= X[division_point]:
|
|
135
|
+
# The new division point matches the previous one, so we can stop
|
|
136
|
+
break
|
|
137
|
+
else:
|
|
138
|
+
floor = division_point
|
|
139
|
+
else:
|
|
140
|
+
ceiling = division_point
|
|
141
|
+
|
|
142
|
+
# initialize variables in case the loop above does not run through
|
|
143
|
+
if left_centroid is None:
|
|
144
|
+
division_point = (floor + ceiling) // 2
|
|
145
|
+
left_centroid = (query_prefix_sum(weighted_X_prefix_sum, start_idx, division_point) /
|
|
146
|
+
query_weights_prefix_sum(is_weighted, sample_weight_prefix_sum, start_idx, division_point))
|
|
147
|
+
if right_centroid is None:
|
|
148
|
+
division_point = (floor + ceiling) // 2
|
|
149
|
+
right_centroid = (query_prefix_sum(weighted_X_prefix_sum, division_point, stop_idx) /
|
|
150
|
+
query_weights_prefix_sum(is_weighted, sample_weight_prefix_sum, division_point, stop_idx))
|
|
151
|
+
|
|
152
|
+
# avoid using lists to allow numba.njit
|
|
153
|
+
centroids[0] = left_centroid
|
|
154
|
+
centroids[1] = right_centroid
|
|
155
|
+
|
|
156
|
+
cluster_borders[1] = division_point
|
|
157
|
+
return centroids, cluster_borders
|
flash1dkmeans/utils.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import numba
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
#@numba.njit(cache=True)
|
|
5
|
+
def query_prefix_sum(arr_prefix_sum, start, stop):
|
|
6
|
+
"""Returns the sum of elements in the range [start, stop) of arr.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
arr_prefix_sum: The prefix sum of the array arr.
|
|
10
|
+
start: The start index of the range.
|
|
11
|
+
stop: The stop index of the range.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
The sum of elements in the range [start, stop) of arr.
|
|
15
|
+
"""
|
|
16
|
+
return arr_prefix_sum[stop - 1] - arr_prefix_sum[start - 1] if start > 0 else arr_prefix_sum[stop - 1]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#@numba.njit(cache=True)
|
|
20
|
+
def query_weights_prefix_sum(is_weighted, sample_weights, start, stop):
|
|
21
|
+
"""Returns the sum of sample_weights in the range [start, stop).
|
|
22
|
+
If is_weighted is False, simply returns stop - start.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
is_weighted: bool
|
|
26
|
+
Whether the data is weighted. Unweighted data is equivalent to weights of all 1.
|
|
27
|
+
sample_weights: np.ndarray
|
|
28
|
+
The sample weights.
|
|
29
|
+
start: int
|
|
30
|
+
The start index of the range.
|
|
31
|
+
stop: int
|
|
32
|
+
The stop index of the range.
|
|
33
|
+
"""
|
|
34
|
+
if is_weighted:
|
|
35
|
+
return query_prefix_sum(sample_weights, start, stop)
|
|
36
|
+
else:
|
|
37
|
+
return stop - start
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: flash1dkmeans
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: An optimized K-means implementation for the one-dimensional case.
|
|
5
|
+
Project-URL: Homepage, https://github.com/SyphonArch/flash1dkmeans
|
|
6
|
+
Project-URL: Issues, https://github.com/SyphonArch/flash1dkmeans/issues
|
|
7
|
+
Author-email: Jake Hyun <jake.hyun@hotmail.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Requires-Dist: numba~=0.59.1
|
|
14
|
+
Requires-Dist: numpy~=1.26.4
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# flash1dkmeans
|
|
18
|
+
An optimized K-means implementation for the one-dimensional case
|
|
19
|
+
|
|
20
|
+
Exploits the fact that one-dimensional data can be sorted.
|
|
21
|
+
|
|
22
|
+
Numba acceleration is used, so callers can further use parallelization with Numba's `@njit(parallel=True)`
|
|
23
|
+
if multiple instances of the algorithm are run in parallel.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
### Two clusters
|
|
28
|
+
|
|
29
|
+
Guaranteed to find the optimal solution for two clusters.
|
|
30
|
+
|
|
31
|
+
### K clusters
|
|
32
|
+
|
|
33
|
+
Uses the K-means++ initialization algorithm to find the initial centroids.
|
|
34
|
+
Then uses the Lloyd's algorithm to find the final centroids, except with optimizations for the one-dimensional case.
|
|
35
|
+
|
|
36
|
+
## Time Complexity
|
|
37
|
+
|
|
38
|
+
- **2 clusters**: O(log(n))
|
|
39
|
+
- (+ (O(n) for prefix sum calculation if not provided))
|
|
40
|
+
- **k clusters**: O(k * 2 + log(k) * log(n) + max_iter * log(n) * k)
|
|
41
|
+
(+ (O(n) for prefix sum calculation if not provided))
|
|
42
|
+
|
|
43
|
+
This is a significant improvement over the standard K-means algorithm, which has a time complexity of O(n * k * max_iter),
|
|
44
|
+
even excluding the time complexity of the K-means++ initialization.
|
|
45
|
+
|
|
46
|
+
## How fast is it?
|
|
47
|
+
|
|
48
|
+
TODO: Comparision with sklearn's KMeans
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
```bash
|
|
53
|
+
pip install flash1dkmeans
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
### Basic usage
|
|
59
|
+
```python
|
|
60
|
+
from flash1dkmeans import kmeans_1d
|
|
61
|
+
|
|
62
|
+
data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
|
|
63
|
+
k = 2
|
|
64
|
+
|
|
65
|
+
centroids, labels = kmeans_1d(data, k)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### More Options
|
|
69
|
+
```python
|
|
70
|
+
from flash1dkmeans import kmeans_1d
|
|
71
|
+
import numpy as np
|
|
72
|
+
|
|
73
|
+
data = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0])
|
|
74
|
+
weights = np.random.random_sample(data.shape)
|
|
75
|
+
k = 3
|
|
76
|
+
|
|
77
|
+
centroids, labels = kmeans_1d(
|
|
78
|
+
data, k,
|
|
79
|
+
sample_weights=weights, # sample weights
|
|
80
|
+
n_local_trials=(2 + int(np.log(k))), # number of local tries
|
|
81
|
+
max_iter=100, # maximum number of iterations
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Even More Options
|
|
86
|
+
```python
|
|
87
|
+
from flash1dkmeans import kmeans_1d
|
|
88
|
+
import numpy as np
|
|
89
|
+
|
|
90
|
+
n, k = 1024, 4
|
|
91
|
+
|
|
92
|
+
# Generate random data
|
|
93
|
+
data = np.random.random_sample(n)
|
|
94
|
+
data = np.sort(data)
|
|
95
|
+
|
|
96
|
+
# Generate random weights
|
|
97
|
+
weights = np.random.random_sample(data.shape)
|
|
98
|
+
|
|
99
|
+
# Calculate prefix sums
|
|
100
|
+
weights_prefix_sum = np.cumsum(weights)
|
|
101
|
+
weighted_X_prefix_sum = np.cumsum(data * weights)
|
|
102
|
+
weighted_X_squared_prefix_sum = np.cumsum(data ** 2 * weights)
|
|
103
|
+
|
|
104
|
+
middle_idx = n // 2
|
|
105
|
+
|
|
106
|
+
# Providing prefix sums reduces redundant calculations
|
|
107
|
+
# This is useful when the algorithm is run multiple times on different segments of the data
|
|
108
|
+
for start_idx, stop_idx in [(0, middle_idx), (middle_idx, n)]:
|
|
109
|
+
centroids, cluster_borders = kmeans_1d(
|
|
110
|
+
data, k, # Note how the sample weights are not provided when the prefix sums are provided
|
|
111
|
+
n_local_trials=(2 + int(np.log(k))), # number of local tries
|
|
112
|
+
max_iter=100, # maximum number of iterations
|
|
113
|
+
return_cluster_borders=True, # return cluster borders instead of labels
|
|
114
|
+
weights_prefix_sum=weights_prefix_sum, # prefix sum of the sample weights, leave empty for unwieghted data
|
|
115
|
+
weighted_X_prefix_sum=weighted_X_prefix_sum, # prefix sum of the weighted data
|
|
116
|
+
weighted_X_squared_prefix_sum=weighted_X_squared_prefix_sum, # prefix sum of the squared weighted data
|
|
117
|
+
start_idx=start_idx, # start index of the data
|
|
118
|
+
stop_idx=stop_idx, # stop index of the data
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Notes
|
|
123
|
+
|
|
124
|
+
This repository has been created to be used in [Any-Precision-LLM](https://github.com/SNU-ARC/any-precision-llm) project,
|
|
125
|
+
where multiple 1D K-means instances are run in parallel for LLM quantization.
|
|
126
|
+
|
|
127
|
+
However the algorithm is general and can be used for any 1D K-means problem.
|
|
128
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
flash1dkmeans/__init__.py,sha256=6tJz85chEMKNU3epnPJLxsxyO0huXrnJqCGU3qRdUlE,28
|
|
2
|
+
flash1dkmeans/config.py,sha256=jCGWED26FWxhDtss3TOcn4Jl0AJ1hAZAMIhMHY6H4kk,189
|
|
3
|
+
flash1dkmeans/k_cluster.py,sha256=WY16GQ2iPZkVL3RPS80y1lzrwGmwdYYS9b_uakTavCc,13940
|
|
4
|
+
flash1dkmeans/main.py,sha256=wcq7P-ASS6PmL_cQNcW57mMbEd1ww4AvD8j33WvQirY,12325
|
|
5
|
+
flash1dkmeans/two_cluster.py,sha256=7yMl0DxjzVcmh2_tSJcNigyceq-MqXel76dRCeY7zg4,7119
|
|
6
|
+
flash1dkmeans/utils.py,sha256=awQlNNN7GRQsG4s2H03WcMObH4aCAH6xT36wGSS0G3o,1179
|
|
7
|
+
flash1dkmeans-0.0.1.dist-info/METADATA,sha256=lN-J-4YS7g2XYWqxf10EhPb8elGJAVW00k9Nm2hSeBQ,4076
|
|
8
|
+
flash1dkmeans-0.0.1.dist-info/WHEEL,sha256=zEMcRr9Kr03x1ozGwg5v9NQBKn3kndp6LSoSlVg-jhU,87
|
|
9
|
+
flash1dkmeans-0.0.1.dist-info/licenses/LICENSE,sha256=4fb3Ics16RwglL3QyYyQvyIkTdTOJ825dCytbUqaDu0,1066
|
|
10
|
+
flash1dkmeans-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Jake Hyun
|
|
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.
|