fast-select 0.1.3__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,491 @@
1
+ from __future__ import annotations
2
+ import math
3
+ import warnings
4
+ import numpy as np
5
+ from numba import cuda, float32, int32, njit, prange, config, get_num_threads, set_num_threads
6
+ from numba.core.errors import NumbaPerformanceWarning
7
+ from sklearn.base import BaseEstimator, TransformerMixin
8
+ from sklearn.utils.validation import check_array, check_is_fitted,check_X_y, validate_data
9
+ warnings.simplefilter("ignore", category=NumbaPerformanceWarning)
10
+
11
+ TPB = 64 # Threads Per Block
12
+ MAx_F_TILE = 1024 # Features loaded per shared-memory tile
13
+
14
+
15
+ @cuda.jit
16
+ def _multisurf_gpu_kernel(x, y, recip_full, feat_idx, n_kept, use_star, is_discrete, scores_out):
17
+ """MultiSURF scoring for an _arbitrary subset_ of features."""
18
+ n_samples = x.shape[0]
19
+ i = cuda.blockIdx.x # focal sample index
20
+ tid = cuda.threadIdx.x
21
+ # Shared scratch
22
+ hits_tile = cuda.shared.array(shape=MAx_F_TILE, dtype=float32)
23
+ miss_tile = cuda.shared.array(shape=MAx_F_TILE, dtype=float32)
24
+ sh_red_f32 = cuda.shared.array(shape=TPB, dtype=float32)
25
+ sh_red_i32 = cuda.shared.array(shape=TPB, dtype=int32)
26
+
27
+ sum_d = 0.0
28
+ sum_d2 = 0.0
29
+ for j in range(tid, n_samples, TPB):
30
+ if j == i:
31
+ continue
32
+ dist = 0.0
33
+ for f0 in range(0, n_kept, MAx_F_TILE):
34
+ tile_len = min(MAx_F_TILE, n_kept - f0)
35
+ for f in range(tile_len):
36
+ full_idx = feat_idx[f0 + f]
37
+ if is_discrete[full_idx]:
38
+ diff = 1.0 if x[i, full_idx] != x[j, full_idx] else 0.0
39
+ else:
40
+ diff = abs(x[i, full_idx] - x[j, full_idx]) * recip_full[full_idx]
41
+ dist += diff
42
+ sum_d += dist
43
+ sum_d2 += dist * dist
44
+ # Reduce for mean
45
+ sh_red_f32[tid] = sum_d
46
+ cuda.syncthreads()
47
+ off = TPB // 2
48
+ while off:
49
+ if tid < off:
50
+ sh_red_f32[tid] += sh_red_f32[tid + off]
51
+ off //= 2
52
+ cuda.syncthreads()
53
+ mu = sh_red_f32[0] / (n_samples - 1)
54
+ # Reduce for variance
55
+ sh_red_f32[tid] = sum_d2
56
+ cuda.syncthreads()
57
+ off = TPB // 2
58
+ while off:
59
+ if tid < off:
60
+ sh_red_f32[tid] += sh_red_f32[tid + off]
61
+ off //= 2
62
+ cuda.syncthreads()
63
+ var = sh_red_f32[0] / (n_samples - 1) - mu * mu
64
+ sigma = math.sqrt(max(var, 0.0))
65
+ thresh = mu - 0.5 * sigma
66
+
67
+ for f0 in range(0, n_kept, MAx_F_TILE):
68
+ tile_len = min(MAx_F_TILE, n_kept - f0)
69
+ if tid < tile_len:
70
+ hits_tile[tid] = 0.0
71
+ miss_tile[tid] = 0.0
72
+ cuda.syncthreads()
73
+ n_hit_local = 0
74
+ n_miss_local = 0
75
+ for j in range(tid, n_samples, TPB):
76
+ if j == i:
77
+ continue
78
+ dist = 0.0
79
+ for f in range(tile_len):
80
+ full_idx = feat_idx[f0 + f]
81
+ if is_discrete[full_idx]: # Use binary differences for discrete features
82
+ diff = 1.0 if x[i, full_idx] != x[j, full_idx] else 0.0
83
+ else: # Use scaled continuous difference for continuous features
84
+ diff = abs(x[i, full_idx] - x[j, full_idx]) * recip_full[full_idx]
85
+ dist += diff
86
+ is_hit = y[i] == y[j]
87
+ if dist < thresh: # This is a NEAR neighbor
88
+ for f in range(tile_len):
89
+ full_idx = feat_idx[f0 + f]
90
+ diff = abs(x[i, full_idx] - x[j, full_idx]) * recip_full[full_idx]
91
+ if is_hit:
92
+ cuda.atomic.add(hits_tile, f, diff)
93
+ else:
94
+ cuda.atomic.add(miss_tile, f, diff)
95
+ if is_hit:
96
+ n_hit_local += 1
97
+ else:
98
+ n_miss_local += 1
99
+ elif use_star and not is_hit: # This is a FAR MISS
100
+ for f in range(tile_len):
101
+ full_idx = feat_idx[f0 + f]
102
+ diff = abs(x[i, full_idx] - x[j, full_idx]) * recip_full[full_idx]
103
+ cuda.atomic.add(miss_tile, f, -diff)
104
+ cuda.syncthreads()
105
+ # Shared reduction of neighbour counts
106
+ sh_red_i32[tid] = n_hit_local
107
+ cuda.syncthreads()
108
+ off = TPB // 2
109
+ while off:
110
+ if tid < off:
111
+ sh_red_i32[tid] += sh_red_i32[tid + off]
112
+ off //= 2
113
+ cuda.syncthreads()
114
+ total_hits = sh_red_i32[0]
115
+ sh_red_i32[tid] = n_miss_local
116
+ cuda.syncthreads()
117
+ off = TPB // 2
118
+ while off:
119
+ if tid < off:
120
+ sh_red_i32[tid] += sh_red_i32[tid + off]
121
+ off //= 2
122
+ cuda.syncthreads()
123
+ total_miss = sh_red_i32[0]
124
+ if tid < tile_len:
125
+ local_idx = f0 + tid
126
+ term = 0.0
127
+ if total_miss > 0:
128
+ term += miss_tile[tid] / total_miss
129
+ if total_hits > 0:
130
+ term -= hits_tile[tid] / total_hits
131
+ cuda.atomic.add(scores_out, local_idx, term)
132
+ cuda.syncthreads()
133
+
134
+
135
+ def _compute_ranges(x: np.ndarray) -> np.ndarray:
136
+ """Helper to compute feature ranges on the CPU."""
137
+ ranges = (x.max(axis=0) - x.min(axis=0)).astype(np.float32)
138
+ return ranges
139
+
140
+
141
+ def _multisurf_gpu_host_caller(
142
+ x_d, y_d, recip_full_d, feat_idx: np.ndarray, use_star: bool, is_discrete: np.ndarray
143
+ ) -> np.ndarray:
144
+ """Host helper function that launches the kernel and returns scores."""
145
+ n_samples, _ = x_d.shape
146
+ n_kept = feat_idx.size
147
+ feat_idx_d = cuda.to_device(feat_idx.astype(np.int64, copy=False))
148
+ scores_d = cuda.device_array(n_kept, dtype=np.float32)
149
+ scores_d[:] = 0.0 # Zero-fill on device
150
+
151
+ _multisurf_gpu_kernel[n_samples, TPB](
152
+ x_d, y_d, recip_full_d, feat_idx_d, n_kept, use_star, is_discrete, scores_d
153
+ )
154
+ cuda.synchronize()
155
+
156
+ return scores_d.copy_to_host() / n_samples
157
+
158
+
159
+ @njit(parallel=True, fastmath=True)
160
+ def _multisurf_cpu_kernel(x, y, recip_full, feat_idx, n_kept, use_star, is_discrete, scores_out):
161
+ """
162
+ Optimized MultiSURF scoring for CPU.
163
+ """
164
+ n_samples = x.shape[0]
165
+
166
+ temp_scores = np.zeros((n_samples, n_kept), dtype=np.float32)
167
+
168
+ for i in prange(n_samples):
169
+ sum_d = 0.0
170
+ sum_d2 = 0.0
171
+ for j in range(n_samples):
172
+ if i == j:
173
+ continue
174
+
175
+ dist = 0.0
176
+ for k in range(n_kept):
177
+ f = feat_idx[k]
178
+ if is_discrete[f]:
179
+ diff = 1.0 if x[i, f] != x[j, f] else 0.0
180
+ else:
181
+ diff = abs(x[i, f] - x[j, f]) * recip_full[f]
182
+ dist += diff
183
+
184
+ sum_d += dist
185
+ sum_d2 += dist * dist
186
+
187
+ mu = sum_d / (n_samples - 1)
188
+ var = max(0.0, (sum_d2 / (n_samples - 1)) - (mu * mu))
189
+ sigma = math.sqrt(var)
190
+ thresh = mu - 0.5 * sigma
191
+
192
+ hit_diffs = np.zeros(n_kept, dtype=np.float32)
193
+ miss_diffs = np.zeros(n_kept, dtype=np.float32)
194
+ n_hits = 0
195
+ n_miss = 0
196
+
197
+ for j in range(n_samples):
198
+ if i == j:
199
+ continue
200
+
201
+ dist = 0.0
202
+ for k in range(n_kept):
203
+ f = feat_idx[k]
204
+ if is_discrete[f]:
205
+ diff = 1.0 if x[i, f] != x[j, f] else 0.0
206
+ else:
207
+ diff = abs(x[i, f] - x[j, f]) * recip_full[f] # Continuous difference
208
+ dist += diff
209
+
210
+ is_hit = y[i] == y[j]
211
+ if dist < thresh: # NEAR neighbor
212
+ if is_hit:
213
+ n_hits += 1
214
+ for k in range(n_kept):
215
+ f = feat_idx[k]
216
+ if is_discrete[f]:
217
+ diff = 1.0 if x[i, f] != x[j, f] else 0.0
218
+ else:
219
+ diff = abs(x[i, f] - x[j, f]) * recip_full[f]
220
+ hit_diffs[k] += diff
221
+ else:
222
+ n_miss += 1
223
+ for k in range(n_kept):
224
+ f = feat_idx[k]
225
+ if is_discrete[f]:
226
+ diff = 1.0 if x[i, f] != x[j, f] else 0.0
227
+ else:
228
+ diff = abs(x[i, f] - x[j, f]) * recip_full[f]
229
+ miss_diffs[k] += diff
230
+ elif use_star and not is_hit: # FAR MISS
231
+ for k in range(n_kept):
232
+ f = feat_idx[k]
233
+ if is_discrete[f]:
234
+ diff = 1.0 if x[i, f] != x[j, f] else 0.0
235
+ else:
236
+ diff = abs(x[i, f] - x[j, f]) * recip_full[f]
237
+ miss_diffs[k] -= diff
238
+
239
+ if n_hits > 0:
240
+ hit_diffs /= n_hits
241
+ if n_miss > 0:
242
+ miss_diffs /= n_miss
243
+
244
+ for k in range(n_kept):
245
+ temp_scores[i, k] = miss_diffs[k] - hit_diffs[k]
246
+ for k in range(n_kept):
247
+ scores_out[k] = temp_scores[:, k].sum()
248
+
249
+
250
+ def _multisurf_cpu_host_caller(x, y, recip_full, feat_idx, use_star, is_discrete, n_jobs):
251
+ """Host caller for the optimized CPU kernel."""
252
+ n_samples = x.shape[0]
253
+ n_kept = feat_idx.size
254
+ scores = np.zeros(n_kept, dtype=np.float32)
255
+ num_threads_to_set = config.NUMBA_NUM_THREADS if n_jobs == -1 else n_jobs
256
+ original_num_threads = get_num_threads()
257
+
258
+ try:
259
+ set_num_threads(num_threads_to_set)
260
+ _multisurf_cpu_kernel(x, y, recip_full, feat_idx, n_kept, use_star, is_discrete, scores)
261
+ finally:
262
+ set_num_threads(original_num_threads)
263
+
264
+ return scores / n_samples
265
+
266
+
267
+ class MultiSURF(TransformerMixin, BaseEstimator):
268
+ """GPU and CPU-accelerated feature selection using the MultiSURF algorithm.
269
+
270
+ This estimator provides a unified API for running MultiSURF on either
271
+ a CPU (using Numba's parallel JIT) or a GPU (using Numba CUDA).
272
+
273
+ Parameters
274
+ ----------
275
+ n_features_to_select : int, default=10
276
+ The number of top features to select.
277
+
278
+ backend : {'auto', 'gpu', 'cpu'}, default='auto'
279
+ The compute backend to use.
280
+ - 'auto': Use 'gpu' if a compatible NVIDIA GPU is detected,
281
+ otherwise fall back to 'cpu'.
282
+ - 'gpu': Force use of the GPU. Raises an error if not available.
283
+ - 'cpu': Force use of the CPU.
284
+
285
+ use_star : bool, default=False
286
+ Whether to run the MultiSURF* adaptation of the algorithm.
287
+ By default, the standard MultiSURF algorithm is used.
288
+
289
+ discrete_limit : int, default=10
290
+ The limit of individual feature values to determine whether or not
291
+ a given feature is discrete or continuous. (Effects distance
292
+ calculation)
293
+
294
+ verbose : bool, default=False
295
+ Controls whether progress updates are printed during the fit.
296
+ Limited benefit currently, will be expanded in future versions.
297
+
298
+ n_jobs : int, default=-1
299
+ Controls the number of threads utilized by Numba while running on the cpu.
300
+ -1 uses all threads avaliable by default. Set to a low number if experiencing
301
+ difficulties and lagging running the script.
302
+
303
+ Attributes
304
+ ----------
305
+ n_features_in_ : int
306
+ The number of features seen during `fit`.
307
+
308
+ feature_importances_ : ndarray of shape (n_features,)
309
+ The calculated importance scores for each feature.
310
+
311
+ effective_backend_ : str
312
+ The backend that was actually used during `fit` ('gpu' or 'cpu').
313
+ """
314
+
315
+ def __init__(
316
+ self,
317
+ n_features_to_select: int | float = 0.2,
318
+ backend: str = "auto",
319
+ use_star: bool = False,
320
+ discrete_limit: int = 10,
321
+ n_jobs: int = -1,
322
+ verbose: bool = False,
323
+ ):
324
+ self.n_features_to_select = n_features_to_select
325
+ self.backend = backend
326
+ self.use_star = use_star
327
+ self.discrete_limit = discrete_limit
328
+ self.n_jobs = n_jobs
329
+ self.verbose = verbose
330
+
331
+ def _validate_parameters(self, n_samples, n_features):
332
+ """Validate all user-provided parameters."""
333
+ # Backend check
334
+ if self.backend not in ["auto", "gpu", "cpu"]:
335
+ raise ValueError("backend must be one of 'auto', 'gpu', or 'cpu'")
336
+
337
+ # Sample count check
338
+ if n_samples < 2:
339
+ raise ValueError(
340
+ f"MultiSURF requires at least 2 samples, but got n_samples = {n_samples}"
341
+ )
342
+
343
+
344
+ # n_features_to_select check (handles both int and float)
345
+ if isinstance(self.n_features_to_select, float):
346
+ if not 0.0 < self.n_features_to_select <= 1.0:
347
+ raise ValueError(
348
+ "If n_features_to_select is a float, it must be in (0, 1]."
349
+ )
350
+ n_select = max(1, int(self.n_features_to_select * n_features))
351
+ elif isinstance(self.n_features_to_select, int):
352
+ if not 0 < self.n_features_to_select <= n_features:
353
+ raise ValueError(
354
+ f"If n_features_to_select is an int ({self.n_features_to_select}), "
355
+ f"it must be > 0 and <= n_features ({n_features})."
356
+ )
357
+ n_select = self.n_features_to_select
358
+ else:
359
+ raise TypeError("n_features_to_select must be an int or a float.")
360
+
361
+ return n_select
362
+
363
+ def fit(self, x: np.ndarray, y: np.ndarray):
364
+ """
365
+ Calculates feature importances using the MultiSURF algorithm on a GPU/CPU.
366
+
367
+ Parameters
368
+ ----------
369
+ x : array-like of shape (n_samples, n_features)
370
+ The training input samples.
371
+ y : array-like of shape (n_samples,)
372
+ The target values (class labels).
373
+
374
+ Returns
375
+ -------
376
+ self : object
377
+ Returns the instance itself.
378
+ """
379
+ x, y = validate_data(
380
+ self, x, y, y_numeric=True, dtype=np.float64, ensure_2d=True,
381
+ )
382
+
383
+ self.n_features_in_ = x.shape[1]
384
+ n_samples = x.shape[0]
385
+
386
+ n_select = self._validate_parameters(n_samples, self.n_features_in_)
387
+
388
+ if self.backend == "auto":
389
+ if cuda.is_available():
390
+ self.effective_backend_ = "gpu"
391
+ else:
392
+ self.effective_backend_ = "cpu"
393
+ elif self.backend == "gpu":
394
+ if not cuda.is_available():
395
+ raise RuntimeError(
396
+ "backend='gpu' was selected, but no compatible "
397
+ "NVIDIA GPU was found or CUDA toolkit is not installed."
398
+ )
399
+ self.effective_backend_ = "gpu"
400
+ else:
401
+ self.effective_backend_ = "cpu"
402
+
403
+
404
+ feature_ranges = _compute_ranges(x)
405
+
406
+ feature_ranges[feature_ranges == 0] = 1
407
+ recip_full = (1.0 / feature_ranges).astype(np.float32)
408
+
409
+ all_feature_indices = np.arange(self.n_features_in_, dtype=np.int64)
410
+
411
+ is_discrete = np.zeros(self.n_features_in_, dtype=bool)
412
+ self.is_discrete_ = is_discrete
413
+ for f in range(self.n_features_in_):
414
+ if np.unique(x[:, f]).size <= self.discrete_limit: # Define discrete_limit
415
+ is_discrete[f] = True
416
+
417
+ if self.effective_backend_ == "gpu":
418
+ x_d = cuda.to_device(x)
419
+ y_d = cuda.to_device(y.astype(np.int32))
420
+ recip_full_d = cuda.to_device(recip_full)
421
+ if self.verbose and self.use_star:
422
+ print("Running MultiSURF* on the GPU now...")
423
+ elif self.verbose:
424
+ print("Running MultiSURF on the GPU now...")
425
+ scores = _multisurf_gpu_host_caller(
426
+ x_d, y_d, recip_full_d, all_feature_indices, self.use_star, is_discrete
427
+ )
428
+ else:
429
+ if self.verbose and self.use_star:
430
+ print("Running MultiSURF* on the CPU now...")
431
+ elif self.verbose:
432
+ print("Running MultiSURF on the CPU now...")
433
+ scores = _multisurf_cpu_host_caller(
434
+ x, y, recip_full, all_feature_indices, self.use_star, is_discrete, self.n_jobs
435
+ )
436
+
437
+ self.feature_importances_ = scores
438
+ self.top_features_ = np.argsort(scores)[::-1][:n_select]
439
+ return self
440
+
441
+ def transform(self, x: np.ndarray) -> np.ndarray:
442
+ """
443
+ Reduces x to the selected features.
444
+
445
+ Parameters
446
+ ----------
447
+ x : array-like of shape (n_samples, n_features)
448
+ The input samples to transform.
449
+
450
+ Returns
451
+ -------
452
+ x_new : ndarray of shape (n_samples, n_features_to_select)
453
+ The input samples with only the selected features.
454
+ """
455
+ check_is_fitted(self)
456
+ x = validate_data(
457
+ self, x,
458
+ reset=False,
459
+ ensure_2d=True,
460
+ dtype=[np.float64, np.float32]
461
+ )
462
+
463
+ if x.shape[1] != self.n_features_in_:
464
+ raise ValueError(
465
+ f"x has {x.shape[1]} features, but {self.__class__.__name__} "
466
+ f"was trained with {self.n_features_in_} features."
467
+ )
468
+
469
+ return x[:, self.top_features_]
470
+
471
+ def fit_transform(self, x: np.ndarray, y: np.ndarray) -> np.ndarray:
472
+ """
473
+ Fit to data, then transform it.
474
+
475
+ A convenience method that fits the model and applies the transformation
476
+ to the same data.
477
+
478
+ Parameters
479
+ ----------
480
+ x : array-like of shape (n_samples, n_features)
481
+ The training input samples.
482
+ y : array-like of shape (n_samples,)
483
+ The target values (class labels).
484
+
485
+ Returns
486
+ -------
487
+ x_new : ndarray of shape (n_samples, n_features_to_select)
488
+ The transformed input samples.
489
+ """
490
+ self.fit(x, y)
491
+ return self.transform(x)