bayesian-sparse-gmm 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/PKG-INFO +57 -1
  2. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/README.md +54 -0
  3. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/evaluate.py +146 -55
  4. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/pyproject.toml +3 -0
  5. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/__init__.py +1 -1
  6. bayesian_sparse_gmm-0.2.2/src/bayesian_sparse_gmm/backends/__init__.py +59 -0
  7. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/backends/_cuda.py +22 -21
  8. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/backends/_numba.py +25 -1
  9. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/model.py +1 -1
  10. bayesian_sparse_gmm-0.2.2/tests/test_cuda.py +129 -0
  11. bayesian_sparse_gmm-0.2.2/uv.lock +2210 -0
  12. bayesian_sparse_gmm-0.2.1/src/bayesian_sparse_gmm/backends/__init__.py +0 -52
  13. bayesian_sparse_gmm-0.2.1/tests/test_cuda.py +0 -38
  14. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/.flake8 +0 -0
  15. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/.github/workflows/release.yml +0 -0
  16. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/.gitignore +0 -0
  17. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/LICENSE +0 -0
  18. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/backends/_base.py +0 -0
  19. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/backends/_numpy.py +0 -0
  20. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/config.py +0 -0
  21. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/diagnostics.py +0 -0
  22. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/postprocessing.py +0 -0
  23. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/sampler.py +0 -0
  24. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/state.py +0 -0
  25. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/urn.py +0 -0
  26. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/src/bayesian_sparse_gmm/utils.py +0 -0
  27. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/__init__.py +0 -0
  28. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_backends.py +0 -0
  29. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_diagnostics.py +0 -0
  30. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_identity_cov.py +0 -0
  31. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_laplace.py +0 -0
  32. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_model.py +0 -0
  33. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_numba.py +0 -0
  34. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_phase1.py +0 -0
  35. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_sampler.py +0 -0
  36. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_synthetic.py +0 -0
  37. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_theta_update.py +0 -0
  38. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_urn_weight.py +0 -0
  39. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_variable_k.py +0 -0
  40. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_xi.py +0 -0
  41. {bayesian_sparse_gmm-0.2.1 → bayesian_sparse_gmm-0.2.2}/tests/test_xi_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bayesian-sparse-gmm
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Bayesian Sparse Gaussian Mixture Model implementation in Python
5
5
  Author-email: Nam Nam <nampvh4436@gmail.com>
6
6
  License: MIT
@@ -14,6 +14,8 @@ Requires-Dist: numpy>=1.20.0
14
14
  Requires-Dist: scikit-learn>=1.0.0
15
15
  Requires-Dist: scipy>=1.7.0
16
16
  Requires-Dist: tqdm>=4.60.0
17
+ Provides-Extra: cuda
18
+ Requires-Dist: cupy-cuda12x>=12.0.0; extra == 'cuda'
17
19
  Provides-Extra: dev
18
20
  Requires-Dist: black>=22.0.0; extra == 'dev'
19
21
  Requires-Dist: flake8>=4.0.0; extra == 'dev'
@@ -77,6 +79,60 @@ print(f"Feature inclusion probabilities: {model.feature_probabilities_.round(3)}
77
79
  labels = model.predict(X)
78
80
  ```
79
81
 
82
+ ## GPU / CUDA Acceleration
83
+
84
+ The model supports three backends, selected via the `backend` parameter:
85
+
86
+ | Backend | Value | Description |
87
+ |---------|-------|-------------|
88
+ | NumPy | `"numpy"` | Pure NumPy — always available, no extras needed |
89
+ | Numba CPU | `"numba"` | Parallel CPU via Numba JIT (**default**) |
90
+ | CUDA GPU | `"cuda"` | GPU via CuPy or Numba CUDA |
91
+ | Auto | `"auto"` | Numba CPU if available, else NumPy |
92
+
93
+ ### Install GPU support
94
+
95
+ ```bash
96
+ # CuPy (recommended — more flexible with driver versions)
97
+ pip install "bayesian-sparse-gmm[cuda]"
98
+ # or install directly for your CUDA version, e.g.:
99
+ pip install cupy-cuda12x # CUDA 12.x
100
+ pip install cupy-cuda11x # CUDA 11.x
101
+ ```
102
+
103
+ ### Run with GPU (Python API)
104
+
105
+ ```python
106
+ model = BayesianSparseGMM(
107
+ K_max=10,
108
+ n_iter=500,
109
+ backend="cuda", # GPU via CuPy → Numba CUDA → NumPy fallback
110
+ random_state=42,
111
+ )
112
+ model.fit(X)
113
+ ```
114
+
115
+
116
+ > [!NOTE]
117
+ > **Graceful fallback.** When `backend="cuda"` is requested, the library
118
+ > probes available backends in order: CuPy → Numba CUDA → NumPy. If a GPU
119
+ > backend is unavailable or incompatible, it falls back automatically and
120
+ > emits a `UserWarning` explaining why.
121
+
122
+ > [!WARNING]
123
+ > **Numba CUDA / PTX version mismatch.** Numba bundles its own CUDA
124
+ > toolkit. If the PTX version Numba generates is newer than what the
125
+ > installed NVIDIA driver supports (e.g., `Unsupported .version 8.7;
126
+ > current version is '8.2'`), Numba CUDA will be disabled automatically.
127
+ > Solutions:
128
+ > - **Preferred:** Install CuPy (`pip install cupy-cuda12x`) — it ships
129
+ > its own CUDA runtime and handles driver compatibility independently.
130
+ > - **Alternative:** Update the NVIDIA driver (≥ 545 for CUDA 12.3+).
131
+ > - **Alternative:** Pin Numba to a version matching your driver's CUDA
132
+ > support level.
133
+
134
+ ---
135
+
80
136
  ## Development and Testing
81
137
 
82
138
  Install development dependencies:
@@ -53,6 +53,60 @@ print(f"Feature inclusion probabilities: {model.feature_probabilities_.round(3)}
53
53
  labels = model.predict(X)
54
54
  ```
55
55
 
56
+ ## GPU / CUDA Acceleration
57
+
58
+ The model supports three backends, selected via the `backend` parameter:
59
+
60
+ | Backend | Value | Description |
61
+ |---------|-------|-------------|
62
+ | NumPy | `"numpy"` | Pure NumPy — always available, no extras needed |
63
+ | Numba CPU | `"numba"` | Parallel CPU via Numba JIT (**default**) |
64
+ | CUDA GPU | `"cuda"` | GPU via CuPy or Numba CUDA |
65
+ | Auto | `"auto"` | Numba CPU if available, else NumPy |
66
+
67
+ ### Install GPU support
68
+
69
+ ```bash
70
+ # CuPy (recommended — more flexible with driver versions)
71
+ pip install "bayesian-sparse-gmm[cuda]"
72
+ # or install directly for your CUDA version, e.g.:
73
+ pip install cupy-cuda12x # CUDA 12.x
74
+ pip install cupy-cuda11x # CUDA 11.x
75
+ ```
76
+
77
+ ### Run with GPU (Python API)
78
+
79
+ ```python
80
+ model = BayesianSparseGMM(
81
+ K_max=10,
82
+ n_iter=500,
83
+ backend="cuda", # GPU via CuPy → Numba CUDA → NumPy fallback
84
+ random_state=42,
85
+ )
86
+ model.fit(X)
87
+ ```
88
+
89
+
90
+ > [!NOTE]
91
+ > **Graceful fallback.** When `backend="cuda"` is requested, the library
92
+ > probes available backends in order: CuPy → Numba CUDA → NumPy. If a GPU
93
+ > backend is unavailable or incompatible, it falls back automatically and
94
+ > emits a `UserWarning` explaining why.
95
+
96
+ > [!WARNING]
97
+ > **Numba CUDA / PTX version mismatch.** Numba bundles its own CUDA
98
+ > toolkit. If the PTX version Numba generates is newer than what the
99
+ > installed NVIDIA driver supports (e.g., `Unsupported .version 8.7;
100
+ > current version is '8.2'`), Numba CUDA will be disabled automatically.
101
+ > Solutions:
102
+ > - **Preferred:** Install CuPy (`pip install cupy-cuda12x`) — it ships
103
+ > its own CUDA runtime and handles driver compatibility independently.
104
+ > - **Alternative:** Update the NVIDIA driver (≥ 545 for CUDA 12.3+).
105
+ > - **Alternative:** Pin Numba to a version matching your driver's CUDA
106
+ > support level.
107
+
108
+ ---
109
+
56
110
  ## Development and Testing
57
111
 
58
112
  Install development dependencies:
@@ -1,19 +1,31 @@
1
+ import argparse
1
2
  import os
2
3
  import sys
3
4
  import time
4
- import numpy as np
5
+
5
6
  import matplotlib.pyplot as plt
6
- from sklearn.datasets import fetch_olivetti_faces, fetch_20newsgroups_vectorized, load_digits, load_wine
7
- from sklearn.preprocessing import StandardScaler
8
- from sklearn.decomposition import TruncatedSVD, PCA
7
+ import numpy as np
9
8
  from sklearn.cluster import KMeans
10
- from sklearn.metrics import adjusted_rand_score, adjusted_mutual_info_score, v_measure_score
9
+ from sklearn.datasets import (
10
+ fetch_20newsgroups_vectorized,
11
+ fetch_olivetti_faces,
12
+ load_digits,
13
+ load_wine,
14
+ )
15
+ from sklearn.decomposition import PCA, TruncatedSVD
16
+ from sklearn.metrics import (
17
+ adjusted_mutual_info_score,
18
+ adjusted_rand_score,
19
+ v_measure_score,
20
+ )
11
21
  from sklearn.model_selection import train_test_split
22
+ from sklearn.preprocessing import StandardScaler
12
23
 
24
+ from bayesian_sparse_gmm.diagnostics import effective_sample_size, gelman_rubin
13
25
  from bayesian_sparse_gmm.model import BayesianSparseGMM
14
- from bayesian_sparse_gmm.diagnostics import gelman_rubin, effective_sample_size
15
26
 
16
- def run_olivetti_benchmark():
27
+
28
+ def run_olivetti_benchmark(backend="numba"):
17
29
  """Evaluate on Olivetti Faces (p=4096, K=40)."""
18
30
  print("\n" + "=" * 60)
19
31
  print("OLIVETTI FACES BENCHMARK (n=400, p=4096, K=40)")
@@ -27,7 +39,9 @@ def run_olivetti_benchmark():
27
39
  X_train, X_test, y_train, y_test = train_test_split(
28
40
  X_scaled, y, test_size=0.2, random_state=42, stratify=y
29
41
  )
30
- print(f"Train: {X_train.shape} | Test: {X_test.shape} | Classes: {len(np.unique(y))}")
42
+ print(
43
+ f"Train: {X_train.shape} | Test: {X_test.shape} | Classes: {len(np.unique(y))}"
44
+ )
31
45
 
32
46
  # --- BSGMM ---
33
47
  t0 = time.time()
@@ -43,7 +57,7 @@ def run_olivetti_benchmark():
43
57
  theta=0.5,
44
58
  a_sigma=1.0,
45
59
  b_sigma=1.0,
46
- backend="numba",
60
+ backend=backend,
47
61
  random_state=42,
48
62
  verbose=1,
49
63
  )
@@ -64,8 +78,12 @@ def run_olivetti_benchmark():
64
78
  vm_b = v_measure_score(y_train, gmm.labels_)
65
79
  vm_k = v_measure_score(y_train, kmeans.labels_)
66
80
 
67
- print(f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | V: {vm_b:.4f}")
68
- print(f"KMeans ({kmeans_time:.1f}s) - ARI: {ari_k:.4f} | AMI: {ami_k:.4f} | V: {vm_k:.4f}")
81
+ print(
82
+ f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | V: {vm_b:.4f}"
83
+ )
84
+ print(
85
+ f"KMeans ({kmeans_time:.1f}s) - ARI: {ari_k:.4f} | AMI: {ami_k:.4f} | V: {vm_k:.4f}"
86
+ )
69
87
 
70
88
  # --- Probabilistic fit ---
71
89
  test_ll = gmm.score(X_test)
@@ -109,13 +127,13 @@ def run_olivetti_benchmark():
109
127
  print("Saved './visualize/olivetti_features.png'")
110
128
 
111
129
 
112
- def run_text_benchmark():
130
+ def run_text_benchmark(backend="numba"):
113
131
  """Evaluate on 20 Newsgroups text data (LSA-reduced)."""
114
132
  print("\n" + "=" * 60)
115
133
  print("20 NEWSGROUPS TEXT BENCHMARK (LSA-reduced)")
116
134
  print("=" * 60)
117
135
 
118
- newsgroups = fetch_20newsgroups_vectorized(subset='all')
136
+ newsgroups = fetch_20newsgroups_vectorized(subset="all")
119
137
  X_sparse, y = newsgroups.data, newsgroups.target
120
138
 
121
139
  # LSA dimensionality reduction (preserves Gaussian-like structure)
@@ -138,7 +156,7 @@ def run_text_benchmark():
138
156
  theta=0.5,
139
157
  a_sigma=1.0,
140
158
  b_sigma=1.0,
141
- backend="numba",
159
+ backend=backend,
142
160
  random_state=42,
143
161
  verbose=1,
144
162
  )
@@ -155,11 +173,13 @@ def run_text_benchmark():
155
173
  ami_k = adjusted_mutual_info_score(y, kmeans.labels_)
156
174
 
157
175
  n_sel = len(gmm.selected_features_)
158
- print(f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | Features: {n_sel}/100")
176
+ print(
177
+ f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | Features: {n_sel}/100"
178
+ )
159
179
  print(f"KMeans - ARI: {ari_k:.4f} | AMI: {ami_k:.4f}")
160
180
 
161
181
 
162
- def run_synthetic_sparse_benchmark():
182
+ def run_synthetic_sparse_benchmark(backend="numba"):
163
183
  """Synthetic data: 6 clusters in 60-dim space, only 10/60 features are informative."""
164
184
  print("\n" + "=" * 60)
165
185
  print("SYNTHETIC SPARSE SIGNAL BENCHMARK (n=600, p=60, K=6, signal=10)")
@@ -169,22 +189,35 @@ def run_synthetic_sparse_benchmark():
169
189
  K_true, n_per_k, p_total, p_signal = 6, 100, 60, 10
170
190
  means = np.zeros((K_true, p_total))
171
191
  means[:, :p_signal] = rng_d.normal(0, 3.0, size=(K_true, p_signal))
172
- X_parts = [rng_d.normal(means[k], 1.0, size=(n_per_k, p_total)) for k in range(K_true)]
192
+ X_parts = [
193
+ rng_d.normal(means[k], 1.0, size=(n_per_k, p_total)) for k in range(K_true)
194
+ ]
173
195
  y_parts = [np.full(n_per_k, k) for k in range(K_true)]
174
196
  X_raw = np.vstack(X_parts)
175
197
  y = np.hstack(y_parts)
176
198
  shuf = rng_d.permutation(len(y))
177
199
  X_raw, y = X_raw[shuf], y[shuf]
178
- print(f"Data: {X_raw.shape} | True K={K_true} | Signal={p_signal}/{p_total} features")
200
+ print(
201
+ f"Data: {X_raw.shape} | True K={K_true} | Signal={p_signal}/{p_total} features"
202
+ )
179
203
 
180
204
  X = StandardScaler().fit_transform(X_raw)
181
205
 
182
206
  t0 = time.time()
183
207
  gmm = BayesianSparseGMM(
184
- K_max=10, n_iter=500, burn_in=100, thinning=1,
185
- lambda_0=100.0, lambda_1=1.0, alpha=1.0, theta=0.5,
186
- a_sigma=1.0, b_sigma=1.0,
187
- backend="numba", random_state=42, verbose=1,
208
+ K_max=10,
209
+ n_iter=500,
210
+ burn_in=100,
211
+ thinning=1,
212
+ lambda_0=100.0,
213
+ lambda_1=1.0,
214
+ alpha=1.0,
215
+ theta=0.5,
216
+ a_sigma=1.0,
217
+ b_sigma=1.0,
218
+ backend=backend,
219
+ random_state=42,
220
+ verbose=1,
188
221
  )
189
222
  gmm.fit(X)
190
223
  bsgmm_time = time.time() - t0
@@ -201,9 +234,13 @@ def run_synthetic_sparse_benchmark():
201
234
  true_sig = set(range(p_signal))
202
235
  sel = set(gmm.selected_features_)
203
236
  prec = len(sel & true_sig) / max(n_sel, 1)
204
- rec = len(sel & true_sig) / p_signal
205
- print(f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | Features: {n_sel}/{p_total}")
206
- print(f"KMeans ({km_time:.1f}s) - ARI: {ari_k:.4f} | AMI: {adjusted_mutual_info_score(y, km.labels_):.4f}")
237
+ rec = len(sel & true_sig) / p_signal
238
+ print(
239
+ f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | Features: {n_sel}/{p_total}"
240
+ )
241
+ print(
242
+ f"KMeans ({km_time:.1f}s) - ARI: {ari_k:.4f} | AMI: {adjusted_mutual_info_score(y, km.labels_):.4f}"
243
+ )
207
244
  print(f"Feature Selection - Precision: {prec:.2%} | Recall: {rec:.2%}")
208
245
 
209
246
  X_2d = PCA(n_components=2, random_state=42).fit_transform(X)
@@ -213,22 +250,29 @@ def run_synthetic_sparse_benchmark():
213
250
 
214
251
  for k in range(K_true):
215
252
  m = y == k
216
- axes[0].scatter(X_2d[m, 0], X_2d[m, 1], c=[pal[k]], s=18, alpha=0.75, label=f"C{k}")
253
+ axes[0].scatter(
254
+ X_2d[m, 0], X_2d[m, 1], c=[pal[k]], s=18, alpha=0.75, label=f"C{k}"
255
+ )
217
256
  axes[0].set_title(f"Ground Truth (K={K_true})")
218
- axes[0].set_xlabel("PC1"); axes[0].set_ylabel("PC2")
257
+ axes[0].set_xlabel("PC1")
258
+ axes[0].set_ylabel("PC2")
219
259
  axes[0].legend(fontsize=7, ncol=2)
220
260
 
221
261
  for idx, k in enumerate(np.unique(gmm.labels_)):
222
262
  m = gmm.labels_ == k
223
263
  axes[1].scatter(X_2d[m, 0], X_2d[m, 1], c=[pal[idx % 10]], s=18, alpha=0.75)
224
264
  axes[1].set_title(f"BSGMM (ARI={ari_b:.3f}, K={len(np.unique(gmm.labels_))})")
225
- axes[1].set_xlabel("PC1"); axes[1].set_ylabel("PC2")
265
+ axes[1].set_xlabel("PC1")
266
+ axes[1].set_ylabel("PC2")
226
267
 
227
268
  bar_c = ["#e74c3c" if i in true_sig else "#bdc3c7" for i in range(p_total)]
228
269
  axes[2].bar(range(p_total), gmm.feature_probabilities_, color=bar_c, alpha=0.85)
229
270
  axes[2].axhline(0.5, color="k", ls="--", lw=1, label="threshold")
230
- axes[2].set_xlabel("Feature index"); axes[2].set_ylabel("P(ξ=1|X)")
231
- axes[2].set_title(f"Feature Importance (red=signal | Prec={prec:.0%}, Rec={rec:.0%})")
271
+ axes[2].set_xlabel("Feature index")
272
+ axes[2].set_ylabel("P(ξ=1|X)")
273
+ axes[2].set_title(
274
+ f"Feature Importance (red=signal | Prec={prec:.0%}, Rec={rec:.0%})"
275
+ )
232
276
  axes[2].legend(fontsize=9)
233
277
 
234
278
  plt.tight_layout()
@@ -238,7 +282,7 @@ def run_synthetic_sparse_benchmark():
238
282
  print("Saved './visualize/synthetic_sparse.png'")
239
283
 
240
284
 
241
- def run_digits_benchmark():
285
+ def run_digits_benchmark(backend="numba"):
242
286
  """Sklearn Digits: 1797 samples, 64 features (8x8 images), K=10 digit classes."""
243
287
  print("\n" + "=" * 60)
244
288
  print("SKLEARN DIGITS BENCHMARK (n=1797, p=64, K=10)")
@@ -252,10 +296,19 @@ def run_digits_benchmark():
252
296
 
253
297
  t0 = time.time()
254
298
  gmm = BayesianSparseGMM(
255
- K_max=15, n_iter=600, burn_in=150, thinning=2,
256
- lambda_0=100.0, lambda_1=1.0, alpha=1.0, theta=0.5,
257
- a_sigma=1.0, b_sigma=1.0,
258
- backend="numba", random_state=42, verbose=1,
299
+ K_max=15,
300
+ n_iter=600,
301
+ burn_in=150,
302
+ thinning=2,
303
+ lambda_0=100.0,
304
+ lambda_1=1.0,
305
+ alpha=1.0,
306
+ theta=0.5,
307
+ a_sigma=1.0,
308
+ b_sigma=1.0,
309
+ backend=backend,
310
+ random_state=42,
311
+ verbose=1,
259
312
  )
260
313
  gmm.fit(X)
261
314
  bsgmm_time = time.time() - t0
@@ -270,7 +323,9 @@ def run_digits_benchmark():
270
323
  ami_b = adjusted_mutual_info_score(y, gmm.labels_)
271
324
  ami_k = adjusted_mutual_info_score(y, km.labels_)
272
325
  n_sel = len(gmm.selected_features_)
273
- print(f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | Features: {n_sel}/64")
326
+ print(
327
+ f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | Features: {n_sel}/64"
328
+ )
274
329
  print(f"KMeans ({km_time:.1f}s) - ARI: {ari_k:.4f} | AMI: {ami_k:.4f}")
275
330
 
276
331
  X_2d = PCA(n_components=2, random_state=42).fit_transform(X)
@@ -285,7 +340,8 @@ def run_digits_benchmark():
285
340
  m = y == k
286
341
  ax1.scatter(X_2d[m, 0], X_2d[m, 1], c=[pal[k]], s=10, alpha=0.6, label=str(k))
287
342
  ax1.set_title("Ground Truth Digits")
288
- ax1.set_xlabel("PC1"); ax1.set_ylabel("PC2")
343
+ ax1.set_xlabel("PC1")
344
+ ax1.set_ylabel("PC2")
289
345
  ax1.legend(fontsize=7, ncol=2, markerscale=1.5)
290
346
 
291
347
  # Top-middle: PCA scatter colored by BSGMM clusters
@@ -297,7 +353,8 @@ def run_digits_benchmark():
297
353
  m = gmm.labels_ == k
298
354
  ax2.scatter(X_2d[m, 0], X_2d[m, 1], c=[pal2[idx % 20]], s=10, alpha=0.6)
299
355
  ax2.set_title(f"BSGMM (ARI={ari_b:.3f}, K={n_pred})")
300
- ax2.set_xlabel("PC1"); ax2.set_ylabel("PC2")
356
+ ax2.set_xlabel("PC1")
357
+ ax2.set_ylabel("PC2")
301
358
 
302
359
  # Top-right: Feature importance heatmap (8x8)
303
360
  ax3 = fig.add_subplot(2, 3, 3)
@@ -322,7 +379,7 @@ def run_digits_benchmark():
322
379
  print("Saved './visualize/digits_clusters.png'")
323
380
 
324
381
 
325
- def run_wine_benchmark():
382
+ def run_wine_benchmark(backend="numba"):
326
383
  """Wine dataset: 178 samples, 13 chemical features, K=3 cultivars."""
327
384
  print("\n" + "=" * 60)
328
385
  print("WINE BENCHMARK (n=178, p=13, K=3 cultivars)")
@@ -336,10 +393,19 @@ def run_wine_benchmark():
336
393
 
337
394
  t0 = time.time()
338
395
  gmm = BayesianSparseGMM(
339
- K_max=6, n_iter=500, burn_in=100, thinning=1,
340
- lambda_0=100.0, lambda_1=1.0, alpha=1.0, theta=0.5,
341
- a_sigma=1.0, b_sigma=1.0,
342
- backend="numba", random_state=42, verbose=1,
396
+ K_max=6,
397
+ n_iter=500,
398
+ burn_in=100,
399
+ thinning=1,
400
+ lambda_0=100.0,
401
+ lambda_1=1.0,
402
+ alpha=1.0,
403
+ theta=0.5,
404
+ a_sigma=1.0,
405
+ b_sigma=1.0,
406
+ backend=backend,
407
+ random_state=42,
408
+ verbose=1,
343
409
  )
344
410
  gmm.fit(X)
345
411
  bsgmm_time = time.time() - t0
@@ -354,7 +420,9 @@ def run_wine_benchmark():
354
420
  ami_b = adjusted_mutual_info_score(y, gmm.labels_)
355
421
  ami_k = adjusted_mutual_info_score(y, km.labels_)
356
422
  n_sel = len(gmm.selected_features_)
357
- print(f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | Features: {n_sel}/13")
423
+ print(
424
+ f"\nBSGMM ({bsgmm_time:.1f}s) - ARI: {ari_b:.4f} | AMI: {ami_b:.4f} | Features: {n_sel}/13"
425
+ )
358
426
  print(f"KMeans ({km_time:.1f}s) - ARI: {ari_k:.4f} | AMI: {ami_k:.4f}")
359
427
  print(f"Selected features: {[feat_names[i] for i in gmm.selected_features_]}")
360
428
 
@@ -367,9 +435,12 @@ def run_wine_benchmark():
367
435
  # Panel 1: Ground truth PCA
368
436
  for k in range(3):
369
437
  m = y == k
370
- axes[0].scatter(X_2d[m, 0], X_2d[m, 1], c=[pal[k]], s=40, alpha=0.8, label=f"Cultivar {k+1}")
438
+ axes[0].scatter(
439
+ X_2d[m, 0], X_2d[m, 1], c=[pal[k]], s=40, alpha=0.8, label=f"Cultivar {k+1}"
440
+ )
371
441
  axes[0].set_title(f"Ground Truth (K=3)")
372
- axes[0].set_xlabel("PC1"); axes[0].set_ylabel("PC2")
442
+ axes[0].set_xlabel("PC1")
443
+ axes[0].set_ylabel("PC2")
373
444
  axes[0].legend()
374
445
 
375
446
  # Panel 2: BSGMM predicted
@@ -378,18 +449,29 @@ def run_wine_benchmark():
378
449
  pal2 = plt.cm.Set2(np.linspace(0, 0.7, max(n_pred, 2)))
379
450
  for idx, k in enumerate(unique_labels_w):
380
451
  m = gmm.labels_ == k
381
- axes[1].scatter(X_2d[m, 0], X_2d[m, 1], c=[pal2[idx % len(pal2)]], s=40, alpha=0.8, label=f"C{k}")
452
+ axes[1].scatter(
453
+ X_2d[m, 0],
454
+ X_2d[m, 1],
455
+ c=[pal2[idx % len(pal2)]],
456
+ s=40,
457
+ alpha=0.8,
458
+ label=f"C{k}",
459
+ )
382
460
  axes[1].set_title(f"BSGMM (ARI={ari_b:.3f}, K={n_pred})")
383
- axes[1].set_xlabel("PC1"); axes[1].set_ylabel("PC2")
461
+ axes[1].set_xlabel("PC1")
462
+ axes[1].set_ylabel("PC2")
384
463
  axes[1].legend()
385
464
 
386
465
  # Panel 3: Feature importance bar chart
387
466
  sorted_idx = np.argsort(gmm.feature_probabilities_)[::-1]
388
- bar_c = ["#e74c3c" if i in gmm.selected_features_ else "#bdc3c7" for i in sorted_idx]
467
+ bar_c = [
468
+ "#e74c3c" if i in gmm.selected_features_ else "#bdc3c7" for i in sorted_idx
469
+ ]
389
470
  axes[2].barh(
390
471
  range(len(feat_names)),
391
472
  gmm.feature_probabilities_[sorted_idx],
392
- color=bar_c, alpha=0.85
473
+ color=bar_c,
474
+ alpha=0.85,
393
475
  )
394
476
  axes[2].set_yticks(range(len(feat_names)))
395
477
  axes[2].set_yticklabels([feat_names[i] for i in sorted_idx], fontsize=8)
@@ -405,8 +487,17 @@ def run_wine_benchmark():
405
487
 
406
488
 
407
489
  if __name__ == "__main__":
408
- run_olivetti_benchmark()
409
- run_text_benchmark()
410
- run_synthetic_sparse_benchmark()
411
- run_digits_benchmark()
412
- run_wine_benchmark()
490
+ parser = argparse.ArgumentParser(description="Bayesian Sparse GMM benchmarks")
491
+ parser.add_argument(
492
+ "--backend",
493
+ default="numba",
494
+ choices=["numpy", "numba", "cuda", "auto"],
495
+ help="Compute backend (default: numba)",
496
+ )
497
+ args = parser.parse_args()
498
+
499
+ run_olivetti_benchmark(backend=args.backend)
500
+ run_text_benchmark(backend=args.backend)
501
+ run_synthetic_sparse_benchmark(backend=args.backend)
502
+ run_digits_benchmark(backend=args.backend)
503
+ run_wine_benchmark(backend=args.backend)
@@ -33,6 +33,9 @@ dev = [
33
33
  "flake8>=4.0.0",
34
34
  "matplotlib>=3.5.0",
35
35
  ]
36
+ cuda = [
37
+ "cupy-cuda12x>=12.0.0",
38
+ ]
36
39
 
37
40
  [tool.hatch.version]
38
41
  path = "src/bayesian_sparse_gmm/__init__.py"
@@ -1,5 +1,5 @@
1
1
  from .model import BayesianSparseGMM
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
4
4
 
5
5
  __all__ = ["BayesianSparseGMM"]
@@ -0,0 +1,59 @@
1
+ import warnings
2
+
3
+ from ._base import ComputeBackend
4
+ from ._numpy import NumpyBackend
5
+
6
+ try:
7
+ from ._numba import NumbaBackend
8
+
9
+ NUMBA_AVAILABLE = True
10
+ except ImportError:
11
+ NUMBA_AVAILABLE = False
12
+
13
+ from ._cuda import CUPY_AVAILABLE, CUDABackend
14
+
15
+
16
+ def select_backend(preference: str = "auto") -> ComputeBackend:
17
+ """Select the best available compute backend.
18
+
19
+ Parameters
20
+ ----------
21
+ preference : str
22
+ One of 'numpy', 'numba', 'cuda', or 'auto'.
23
+ - 'cuda': CuPy GPU → Numba CUDA GPU → fallback to NumPy.
24
+ - 'numba': Numba CPU parallel → fallback to NumPy.
25
+ - 'auto': Numba CPU parallel → NumPy.
26
+ - 'numpy': Pure NumPy (always available).
27
+ """
28
+ preference = preference.lower()
29
+
30
+ if preference == "numpy":
31
+ return NumpyBackend()
32
+
33
+ if preference == "cuda":
34
+ if CUPY_AVAILABLE:
35
+ return CUDABackend()
36
+ if NUMBA_AVAILABLE:
37
+ return NumbaBackend(use_cuda=True)
38
+ warnings.warn(
39
+ "CUDA requested but neither CuPy nor Numba CUDA is available. "
40
+ "Falling back to NumPy.",
41
+ stacklevel=2,
42
+ )
43
+ return NumpyBackend()
44
+
45
+ if preference == "numba":
46
+ if NUMBA_AVAILABLE:
47
+ return NumbaBackend(use_cuda=False)
48
+ warnings.warn(
49
+ "Numba requested but not available. Falling back to NumPy.",
50
+ stacklevel=2,
51
+ )
52
+ return NumpyBackend()
53
+
54
+ if preference == "auto":
55
+ if NUMBA_AVAILABLE:
56
+ return NumbaBackend(use_cuda=False)
57
+ return NumpyBackend()
58
+
59
+ raise ValueError(f"Unknown backend: {preference!r}")