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.
@@ -0,0 +1 @@
1
+ from .main import kmeans_1d
@@ -0,0 +1,9 @@
1
+ """config.py
2
+ Contains some configuration variables for the flash1dkmeans package.
3
+ """
4
+
5
+ import numpy as np
6
+
7
+ PREFIX_SUM_DTYPE = np.float64
8
+ ARRAY_INDEX_DTYPE = np.int32
9
+ LABEL_DTYPE = np.int32
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.24.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.