scm2cpp-lasso 0.13.2__py3-none-win_amd64.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.
Files changed (65) hide show
  1. scm2cpp_lasso/__init__.py +2808 -0
  2. scm2cpp_lasso/_classifiers.cp312-win_amd64.pyd +0 -0
  3. scm2cpp_lasso/_generated/__init__.py +0 -0
  4. scm2cpp_lasso/_generated/_loader.py +41 -0
  5. scm2cpp_lasso/_generated/_loader_accel_cublas.py +140 -0
  6. scm2cpp_lasso/_generated/_loader_classifiers.py +281 -0
  7. scm2cpp_lasso/_generated/_loader_derived_blas.py +29 -0
  8. scm2cpp_lasso/_generated/_loader_derived_cublas.py +29 -0
  9. scm2cpp_lasso/_generated/_loader_derived_loops.py +29 -0
  10. scm2cpp_lasso/_generated/_loader_gpu_cuda.py +294 -0
  11. scm2cpp_lasso/_generated/_loader_gramops_blas.py +496 -0
  12. scm2cpp_lasso/_generated/_loader_gramops_loops.py +496 -0
  13. scm2cpp_lasso/_generated/_loader_nmf_blas.py +29 -0
  14. scm2cpp_lasso/_generated/_loader_nmf_cublas.py +29 -0
  15. scm2cpp_lasso/_generated/_loader_nmf_loops.py +29 -0
  16. scm2cpp_lasso/_generated/accel_cublas/accel.cpp +20 -0
  17. scm2cpp_lasso/_generated/accel_cublas/accel.hpp +1023 -0
  18. scm2cpp_lasso/_generated/accel_cublas/accel_capi.cpp +64 -0
  19. scm2cpp_lasso/_generated/classifiers.cpp +34 -0
  20. scm2cpp_lasso/_generated/classifiers.hpp +2825 -0
  21. scm2cpp_lasso/_generated/classifiers_capi.cpp +106 -0
  22. scm2cpp_lasso/_generated/derived_blas/lasso_design.cpp +5 -0
  23. scm2cpp_lasso/_generated/derived_blas/lasso_design.hpp +153 -0
  24. scm2cpp_lasso/_generated/derived_blas/lasso_design_capi.cpp +19 -0
  25. scm2cpp_lasso/_generated/derived_cublas/lasso_design.cpp +5 -0
  26. scm2cpp_lasso/_generated/derived_cublas/lasso_design.hpp +155 -0
  27. scm2cpp_lasso/_generated/derived_cublas/lasso_design_capi.cpp +19 -0
  28. scm2cpp_lasso/_generated/derived_loops/lasso_design.cpp +5 -0
  29. scm2cpp_lasso/_generated/derived_loops/lasso_design.hpp +176 -0
  30. scm2cpp_lasso/_generated/derived_loops/lasso_design_capi.cpp +19 -0
  31. scm2cpp_lasso/_generated/gpu_cuda/gpu.cpp +35 -0
  32. scm2cpp_lasso/_generated/gpu_cuda/gpu.hpp +3715 -0
  33. scm2cpp_lasso/_generated/gpu_cuda/gpu_capi.cpp +289 -0
  34. scm2cpp_lasso/_generated/gramops_blas/gramops.cpp +57 -0
  35. scm2cpp_lasso/_generated/gramops_blas/gramops.hpp +3033 -0
  36. scm2cpp_lasso/_generated/gramops_blas/gramops_capi.cpp +175 -0
  37. scm2cpp_lasso/_generated/gramops_loops/gramops.cpp +57 -0
  38. scm2cpp_lasso/_generated/gramops_loops/gramops.hpp +3302 -0
  39. scm2cpp_lasso/_generated/gramops_loops/gramops_capi.cpp +175 -0
  40. scm2cpp_lasso/_generated/lasso_cov.cpp +7 -0
  41. scm2cpp_lasso/_generated/lasso_cov.hpp +152 -0
  42. scm2cpp_lasso/_generated/lasso_cov_capi.cpp +25 -0
  43. scm2cpp_lasso/_generated/nmf_blas/nmf_cd.cpp +5 -0
  44. scm2cpp_lasso/_generated/nmf_blas/nmf_cd.hpp +132 -0
  45. scm2cpp_lasso/_generated/nmf_blas/nmf_cd_capi.cpp +19 -0
  46. scm2cpp_lasso/_generated/nmf_cublas/nmf_cd.cpp +5 -0
  47. scm2cpp_lasso/_generated/nmf_cublas/nmf_cd.hpp +136 -0
  48. scm2cpp_lasso/_generated/nmf_cublas/nmf_cd_capi.cpp +19 -0
  49. scm2cpp_lasso/_generated/nmf_loops/nmf_cd.cpp +5 -0
  50. scm2cpp_lasso/_generated/nmf_loops/nmf_cd.hpp +167 -0
  51. scm2cpp_lasso/_generated/nmf_loops/nmf_cd_capi.cpp +19 -0
  52. scm2cpp_lasso/_generated/scm2cpp-blas.hpp +77 -0
  53. scm2cpp_lasso/_generated/scm2cpp-cublas.hpp +155 -0
  54. scm2cpp_lasso/_generated/scm2cpp-cuda.hpp +285 -0
  55. scm2cpp_lasso/_generated/scm2cpp.hpp +1627 -0
  56. scm2cpp_lasso/_gramops_loops.cp312-win_amd64.pyd +0 -0
  57. scm2cpp_lasso/_lasso_cov.cp312-win_amd64.pyd +0 -0
  58. scm2cpp_lasso/_lasso_derived_loops.cp312-win_amd64.pyd +0 -0
  59. scm2cpp_lasso/_libfind.py +149 -0
  60. scm2cpp_lasso/_nmf_loops.cp312-win_amd64.pyd +0 -0
  61. scm2cpp_lasso/batch_capi.cu +428 -0
  62. scm2cpp_lasso-0.13.2.dist-info/METADATA +920 -0
  63. scm2cpp_lasso-0.13.2.dist-info/RECORD +65 -0
  64. scm2cpp_lasso-0.13.2.dist-info/WHEEL +5 -0
  65. scm2cpp_lasso-0.13.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,920 @@
1
+ Metadata-Version: 2.4
2
+ Name: scm2cpp-lasso
3
+ Version: 0.13.2
4
+ Summary: scikit-learn estimators (lasso, elastic net, ridge, ARD, SVM, SVR, logistic regression, GLMs, Huber, Theil-Sen, F-score selection, RFE, NMF, Gaussian mixture, FastICA, PCA, graphical lasso, RANSAC, SGD, OMP, LARS) translated from Scheme to C++ by scm2cpp; GPU batch solver and CUDA kernels included
5
+ Author: Hirotaka Niitsuma
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/niitsuma/scm2cpp
8
+ Project-URL: Source, https://github.com/niitsuma/scm2cpp/tree/master/python/scm2cpp-lasso
9
+ Keywords: lasso,elastic-net,ridge,svm,logistic-regression,coordinate-descent,cuda,compiler
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: C++
13
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
14
+ Classifier: Intended Audience :: Science/Research
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy>=1.17
18
+
19
+ # scm2cpp-lasso
20
+
21
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/niitsuma/scm2cpp/blob/master/notebooks/compare-sklearn.ipynb)
22
+
23
+ *[Japanese (README.ja.md)](README.ja.md)*
24
+
25
+ Lasso by coordinate descent over a Gram matrix, with an optional GPU
26
+ path. The solver is Scheme, translated to C++ by
27
+ [scm2cpp](https://github.com/niitsuma/scm2cpp).
28
+
29
+ ```console
30
+ $ pip install scm2cpp-lasso
31
+ ```
32
+
33
+ On Linux x86_64, macOS arm64 (macOS 14 or later) and Windows AMD64
34
+ this installs a wheel: compiled, nothing to build (with OpenBLAS
35
+ inside on Linux and macOS; Windows has the loop variants). Elsewhere pip builds the sdist,
36
+ which needs a C++17 compiler and nothing else. The wheels carry no
37
+ GPU code (a wheel cannot know the device); for the GPU builds, install
38
+ from the sdist on a machine with `nvcc` on the path:
39
+
40
+ ```console
41
+ $ pip install --no-binary scm2cpp-lasso scm2cpp-lasso
42
+ ```
43
+
44
+ The batched GPU solver, the cuBLAS variants and the `--cuda`
45
+ estimators are then built as well; without `nvcc` the package installs
46
+ and works exactly the same, minus those.
47
+
48
+ Three of a hundred candidate columns carry the signal; the solver is
49
+ asked which, and answers with them and nothing else:
50
+
51
+ ```python
52
+ import numpy as np
53
+ from scm2cpp_lasso import CovLasso
54
+
55
+ rng = np.random.default_rng(0)
56
+ X = rng.standard_normal((500, 100)) # 500 rows, 100 candidates
57
+ beta = np.zeros(100)
58
+ beta[[7, 23, 61]] = [2.0, -1.5, 0.8] # only three of them matter
59
+ y = X @ beta + 0.1 * rng.standard_normal(500)
60
+
61
+ model = CovLasso(X, y) # the Gram matrix, built once
62
+ path = model.fit_path(model.lambda_grid()) # 100 lambdas, warm-started
63
+
64
+ fit = path[60]
65
+ for j in np.flatnonzero(np.abs(fit) > 1e-6):
66
+ print(f" x{j:<3} {fit[j]:+.3f}")
67
+ ```
68
+
69
+ ```console
70
+ x7 +1.965
71
+ x23 -1.474
72
+ x61 +0.770
73
+ ```
74
+
75
+ The other 97 are exactly zero -- that is what the penalty is for -- and
76
+ the coefficients are the planted ones, shrunk toward zero by it.
77
+
78
+ The rest of the interface:
79
+
80
+ ```python
81
+ from scm2cpp_lasso import cuda_available
82
+
83
+ lambdas = model.lambda_grid(num=100) # from lambda_max down
84
+ path = model.fit_path(lambdas) # warm-started, sequential
85
+ grid = model.fit_path_batch(lambdas) # every lambda from zero
86
+ one = model.fit(lambdas[60]) # a single lambda, if you must
87
+ print("GPU:", cuda_available())
88
+ ```
89
+
90
+ `fit` is the one-element special case of `fit_path` -- the Gram matrix
91
+ was built in the constructor, so a whole warm-started path costs barely
92
+ more than one penalty, which is why the path is the primary interface.
93
+
94
+ The objective is scikit-learn's, with `fit_intercept=False`:
95
+
96
+ (1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
97
+
98
+ Penalties are compared against correlations on the features' own
99
+ scale, so the useful range of `lam` depends on the data.
100
+ `lambda_max()` is the smallest penalty that leaves every coefficient
101
+ at zero, and `lambda_grid()` walks down from it -- the construction
102
+ scikit-learn uses. If your columns differ wildly in scale, standardize
103
+ them first; this solver does not do it for you.
104
+
105
+ ## Estimators at a glance
106
+
107
+ Every estimator, the scikit-learn estimator it reproduces, the Scheme
108
+ kernel under `examples/kernel-only/` it is translated from, the builds
109
+ it runs on, and whether Intel's sklearnex accelerates the scikit-learn
110
+ side. Builds: *loops* is the plain translation, *BLAS* / *cuBLAS* the
111
+ products handed to (cu)BLAS, *--cuda* the translator's GPU kernels
112
+ (`gpu_cuda/`), *GPU batch* the hand-written CUDA batch solver; the
113
+ package picks the fastest one present. None of the classes derives
114
+ from scikit-learn's `BaseEstimator` yet, so `clone`, `GridSearchCV`
115
+ and `check_estimator` do not apply to them.
116
+
117
+ | class | scikit-learn | Scheme kernel | builds | sklearnex accelerates |
118
+ |---|---|---|---|---|
119
+ | `CovLasso` | Lasso, ElasticNet, lasso_path(precompute=True) | `lasso-cov.scm` | CPU; GPU batch (fit_path_batch, bootstrap) | no |
120
+ | `CovLassoCV` | LassoCV, ElasticNetCV | `lasso-cov.scm` | CPU; GPU batch | no |
121
+ | `DerivedLasso` | lasso_path, from the design | `lasso-design.scm (--derive)` | loops / BLAS / cuBLAS | no |
122
+ | `CovMultiTaskLasso` | MultiTaskLasso, MultiTaskElasticNet | `lasso-cov.scm` | CPU | no |
123
+ | `CovMultiTaskLassoCV` | MultiTaskLassoCV, MultiTaskElasticNetCV | `lasso-cov.scm` | CPU; GPU batch | no |
124
+ | `CovGroupLasso` | (none; skglm GroupLasso) | `lasso-cov.scm` | CPU | - |
125
+ | `CovLogistic` | LogisticRegression(penalty='l1'), by majorization | `lasso-cov.scm` | CPU | no |
126
+ | `CovRidge` | Ridge(fit_intercept=False), a whole alpha path | `lasso-cov.scm` | CPU | Ridge |
127
+ | `CovBayesianRidge` | BayesianRidge | `lasso-cov.scm` | CPU | no |
128
+ | `CovARDRegression` | ARDRegression (n >= p) | `ard.scm` | loops / BLAS | no |
129
+ | `GramLars` | Lars | `lars.scm` | CPU | no |
130
+ | `GramLassoLars` | LassoLars | `lars.scm` | CPU | no |
131
+ | `GramLassoLarsIC` | LassoLarsIC | `lars.scm` | CPU; device Gram | no |
132
+ | `GramLarsCV` | LarsCV | `lars.scm` | CPU | no |
133
+ | `GramLassoLarsCV` | LassoLarsCV | `lars.scm` | CPU; device Gram | no |
134
+ | `GramOMP` | OrthogonalMatchingPursuit | `omp.scm` | loops / BLAS | no |
135
+ | `GramOMPCV` | OrthogonalMatchingPursuitCV | `omp.scm` | loops / BLAS | no |
136
+ | `LBFGSLogisticRegression` | LogisticRegression(solver='lbfgs') | `logistic-lbfgs.scm, lbfgs.scm` | loops / BLAS / --cuda | LogisticRegression |
137
+ | `LBFGSLogisticRegressionCV` | LogisticRegressionCV | `logistic-lbfgs.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
138
+ | `LBFGSHuberRegressor` | HuberRegressor | `huber.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
139
+ | `LBFGSPoissonRegressor` | PoissonRegressor | `glm.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
140
+ | `LBFGSGammaRegressor` | GammaRegressor | `glm.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
141
+ | `LBFGSTweedieRegressor` | TweedieRegressor | `glm.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
142
+ | `LinearSVM` | LinearSVC(loss='hinge') | `svm-liblinear.scm` | CPU | no |
143
+ | `L1LinearSVM` | LinearSVC(penalty='l1', dual=False) | `svm-l1.scm` | CPU | no |
144
+ | `KernelSVM` | SVC | `svm-smo.scm` | CPU | SVC |
145
+ | `LinearSVR` | LinearSVR | `svr-liblinear.scm` | CPU | no |
146
+ | `KernelSVR` | SVR | `svm-smo.scm` | CPU | SVR |
147
+ | `L1LogisticRegression` | LogisticRegression(penalty='l1', solver='liblinear') | `logistic-l1.scm` | CPU | no |
148
+ | `SGDLinearClassifier` | SGDClassifier(shuffle=False) | `sgd.scm` | CPU | no |
149
+ | `SGDLinearRegressor` | SGDRegressor(shuffle=False) | `sgd.scm` | CPU | no |
150
+ | `SGDPerceptron` | Perceptron(shuffle=False) | `sgd.scm` | CPU | no |
151
+ | `LinearRANSAC` | RANSACRegressor (given subsets) | `ransac.scm` | CPU | no |
152
+ | `WeiszfeldTheilSen` | TheilSenRegressor | `theilsen.scm` | CPU | no |
153
+ | `CDNMF` | NMF(solver='cd') | `nmf-cd.scm (--derive)` | loops / BLAS / cuBLAS | no |
154
+ | `CDGraphicalLasso` | GraphicalLasso | `graphical-lasso.scm` | loops / BLAS | no |
155
+ | `EMGaussianMixture` | GaussianMixture(covariance_type='full') | `gmm.scm` | loops / BLAS / --cuda | no |
156
+ | `ParallelFastICA` | FastICA | `fastica.scm` | loops / BLAS / --cuda | no |
157
+ | `CovPCA` | PCA(svd_solver='covariance_eigh') | `pca.scm` | loops / BLAS / cuBLAS | PCA |
158
+ | `f_regression, f_classif` | f_regression, f_classif | `fscore.scm` | loops / BLAS / cuBLAS | no |
159
+ | `SelectKBest, SelectPercentile` | SelectKBest, SelectPercentile | `fscore.scm` | loops / BLAS / cuBLAS | no |
160
+ | `GramRFE` | RFE(Ridge), RFE(LinearRegression) | `rfe.scm` | loops / BLAS / cuBLAS | inside: Ridge |
161
+
162
+ ## Two kernels: `CovLasso` and `DerivedLasso`
163
+
164
+ Everything above, and the cross-validation, bootstrap and GPU methods
165
+ below, runs on one kernel: numpy forms the Gram matrix, and a
166
+ hand-written coordinate-descent sweep over it
167
+ (`examples/kernel-only/lasso-cov.scm` in the repository, translated to
168
+ C++) does the rest. That is the usable one.
169
+
170
+ `DerivedLasso` is the other kernel, and it is there to show the
171
+ translator's work rather than to be the better estimator. Its source
172
+ is `examples/kernel-only/lasso-design.scm`: the residual-form
173
+ coordinate descent -- the algorithm inside scikit-learn's
174
+ `Lasso(precompute=False)` -- written as flat loops over X exactly as
175
+ numpy holds it (n rows of p, `x[i*p+j]`), the residual copied from y
176
+ and the column norms taken inside, with no Gram matrix anywhere in
177
+ it. The translator's `--derive` turns
178
+ that into the Gram form: it raises the loops to an array algebra,
179
+ notices that the residual is only ever updated by a scaled column and
180
+ read in dot products against columns, replaces it by the memo `c =
181
+ X'r` kept current through a Gram matrix it hoists in front of the
182
+ penalty path, folds the column norms into that Gram's diagonal, folds
183
+ the three products into one `dsyrk` and two `dgemv` calls (`--blas`), and puts the whole thing under
184
+ `(if (< p n) ..)` with the sweep as written in the else branch --
185
+ scikit-learn's `precompute='auto'` rule, derived rather than written.
186
+ The C++ of that derivation, as the translator emitted it, is what
187
+ `DerivedLasso` calls; numpy prepares nothing -- X and y go in as they
188
+ are, no transposed copy, no norms.
189
+
190
+ ```python
191
+ from scm2cpp_lasso import DerivedLasso, derived_backend
192
+
193
+ model = DerivedLasso(X, y) # X and y; the kernel builds its own Gram
194
+ path = model.fit_path(model.lambda_grid()) # one call, the whole warm-started path
195
+ print(derived_backend()) # 'cublas', 'blas' or 'loops': what this machine built
196
+ gpu = DerivedLasso(X, y, backend="cublas") # insist on one
197
+ ```
198
+
199
+ A lambda is done when no coefficient moved by more than `tol`
200
+ (absolute, default 1e-8) in a sweep, or at `max_sweeps`; `tol=0.0`
201
+ asks for the exact fixed point. Three translations of the one
202
+ derivation are committed and `pip` builds what the machine allows:
203
+ `--derive --cublas` where the CUDA toolkit is (the three products on
204
+ the device through cuBLAS, X uploaded once per `fit_path` call --
205
+ taken by default when a device answers), `--derive --blas` where
206
+ `cblas.h` and an OpenBLAS are, and `--derive` alone otherwise, the
207
+ same program with its Gram matrix as a scalar loop nest, correct but
208
+ slow on large designs. `check-derived.py` in the package directory runs
209
+ the two kernels and scikit-learn on the same path and prints the
210
+ differences and the times; on AR(1) designs (rho 0.9), 20 penalties
211
+ from lambda_max to 0.01 lambda_max, one core, best of three (load
212
+ about 3), whole calls with the Gram matrix wherever it is built:
213
+
214
+ | n x p | 1,800 x 200 | 5,000 x 1000 | 100,000 x 200 | 100,000 x 500 |
215
+ |-----------------------------------------|-------------|--------------|---------------|---------------|
216
+ | `DerivedLasso`, cuBLAS (upload included) | 0.003 s | 0.024 s | 0.052 s | 0.11 s |
217
+ | `DerivedLasso`, CBLAS | 0.003 s | 0.076 s | 0.12 s | 0.44 s |
218
+ | `CovLasso(X, y).fit_path`, tol 1e-8 | 0.004 s | 0.080 s | 0.11 s | 0.42 s |
219
+ | sklearn `lasso_path(precompute=True)`, tol 1e-4 | 0.005 s | 0.10 s | 0.24 s | 0.76 s |
220
+
221
+ The CBLAS run is the same `dsyrk` and the same sweeps as `CovLasso`'s
222
+ numpy Gram and path, and times the same; the cuBLAS run is the same
223
+ program with the three products on the device, the upload of X
224
+ counted, and is 4.2x, 4.5x and 6.7x ahead of scikit-learn at the
225
+ three larger shapes -- the figures of the repository's equal-work
226
+ table, now reached from the package with nothing in front of the
227
+ kernel: the constructor keeps X and y and the kernel takes the
228
+ residual and the column norms itself (the norms folded into the Gram
229
+ diagonal by the derivation). Threads change the picture less than one might expect: scikit-learn's
230
+ sweeps are single-threaded Cython and only its Gram product scales,
231
+ so with 20 BLAS threads `lasso_path` takes 0.051 / 0.227 / 0.450 s on
232
+ the three larger shapes, against 0.023 / 0.050 / 0.113 s for the
233
+ cuBLAS build (2.2x, 4.5x, 4.0x) and 0.025 / 0.036 / 0.123 s for the
234
+ CBLAS build (2.0x, 6.3x, 3.7x), whose `dsyrk` scales with the
235
+ threads; the GPU figures do not move. Against a reference driven to
236
+ tol=1e-12, `DerivedLasso` lands within 5e-8 on every backend (its
237
+ tolerance is absolute, on one sweep's largest move), `CovLasso`
238
+ within 2e-10 (it tests every 20 sweeps and overshoots), and
239
+ scikit-learn at its default within 1e-3.
240
+
241
+ ### Deriving it yourself
242
+
243
+ Everything `DerivedLasso` runs is reproducible from the Scheme with
244
+ the translator; this is what `regenerate.sh` does for the package,
245
+ spelled out for one kernel (Racket 8 and a C++17 compiler; a CBLAS
246
+ for `--blas`, the CUDA toolkit for `--cublas`):
247
+
248
+ ```console
249
+ $ git clone https://github.com/niitsuma/scm2cpp && cd scm2cpp
250
+ $ raco link --user vendor/rkanren # once per machine
251
+ $ mkdir /tmp/k && cp examples/kernel-only/lasso-design.scm examples/kernel-only/soft-threshold.scm /tmp/k/
252
+ $ racket scm2cpp-file.scm -t scm2c.typ --derive --cublas -M -S /tmp/k/lasso-design.scm
253
+ derive: lasso-design: raise differencing merge matmul guard
254
+ ```
255
+
256
+ `derive:` on stderr names what fired: the loops raised to the array
257
+ algebra, the residual differenced into the memo, the column norms
258
+ merged into the Gram diagonal, the products folded to contractions,
259
+ the `(if (< p n) ..)` guard. The run leaves, beside the source,
260
+ `lasso-design.hpp` (the derived kernel as C++: one `blas_syrk`, two
261
+ `blas_gemv`, the sweep), `lasso-design_capi.cpp` and
262
+ `lasso_design.py` (the `-M` C ABI and its ctypes loader -- what the
263
+ package's `_loader_derived_*.py` are), and `lasso-design.expanded.scm`
264
+ (`-S`: the derived program as Scheme, before C++ -- read this to see
265
+ what the derivation did). `--blas` instead of `--cublas` gives the
266
+ CBLAS variant, neither the loop variant. To call it from numpy
267
+ without the package:
268
+
269
+ ```console
270
+ $ g++ -O2 -std=c++17 -shared -fPIC -I. -I$CUDA_HOME/include -o /tmp/k/liblasso-design.so \
271
+ /tmp/k/lasso-design_capi.cpp -L$CUDA_HOME/lib64 -lcublas -lcudart # -lopenblas for --blas
272
+ $ cd /tmp/k && python3 -c 'import lasso_design' # lasso_design.lasso_design(x, y, resid, xnorm, beta, lams, betas, iters, tol, n, p, nlam)
273
+ ```
274
+
275
+ (`-M` names the Python module after the file with `-` read as `_`,
276
+ and the loader looks for `liblasso-design.so` beside it.)
277
+ `examples/kernel-only/README.md` walks through the same `-M` flow on
278
+ the row-major kernel, and `python/PACKAGING.md` through turning such
279
+ a kernel into a pip package.
280
+
281
+ ## Against the other lassos pip can install
282
+
283
+ `bench/lasso-compare.py` in the repository runs the same
284
+ cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
285
+ p=200, n=1800 -- against every lasso a `pip install` produces:
286
+ scikit-learn 1.9.1, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
287
+ Every solver runs at its own default tolerance (ours at the tighter
288
+ 1e-8), each gets one untimed warm-up fit, cuML's data is on the device
289
+ before the clock starts, and the last column is each solver's
290
+ objective minus ours at the smallest lambda -- a fast row with a loose
291
+ answer would show as one:
292
+
293
+ | solver | time | objective gap |
294
+ |----------------------------------------|---------|---------------|
295
+ | scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.91 s | 0 |
296
+ | scm2cpp-lasso, GPU, one block/lambda | 0.044 s | 0 |
297
+ | sklearn `Lasso.fit` per lambda | 9.6 s | +1.6e-09 |
298
+ | celer per lambda | 13.5 s | 0 |
299
+ | skglm per lambda | 14.5 s | +2.8e-17 |
300
+ | cuML per lambda, GPU | 53.6 s | +9.1e-07 |
301
+
302
+ (One sitting on an otherwise idle machine, best of three runs; cuML
303
+ 26.08 on CUDA 12.9.)
304
+
305
+ celer and skglm are built for very large sparse designs, where their
306
+ screening rules dominate; at this size they pay their setup per fit.
307
+ cuML parallelises *within* one fit, which wins when a fit is large; at
308
+ this size its launch overhead dominates, while our GPU row
309
+ parallelises *across* lambdas -- one block of CUDA threads per
310
+ penalty, the axis a cross-validation grid actually offers.
311
+
312
+ `CovLassoCV` is scikit-learn's `LassoCV` over this machinery -- same
313
+ grid construction, same contiguous folds, same minimum-mean-MSE choice
314
+ -- with two structural savings. A fold's training Gram is a
315
+ *subtraction* (the Gram is additive over rows, so G - Xf'Xf costs one
316
+ fold's work where rebuilding costs four folds'), and once the Grams
317
+ exist nothing downstream ever touches the n rows again -- which is why
318
+ the gap grows with n. With a GPU the whole grid is one launch: one
319
+ *block* of threads per (fold, alpha) cell, the fold's Gram read in
320
+ place (cv Gram matrices on the device, not cv x 100), thread 0 of the
321
+ block doing the coordinate step and the block spreading the O(p)
322
+ update of the correlations. cv x 100 = 500 cells are too few
323
+ problems to fill a device one thread each, and a block per problem is
324
+ what turns that count into enough parallel work; `bench/cv-grid-designs.py`
325
+ times the alternatives (one thread per cell, warm runs of several
326
+ alphas per thread or block) side by side, and the block per cell wins
327
+ at every size tried. The GPU is the default when a device answers
328
+ (`force_cpu` takes the warm CPU path). Measured at cv=5, 100 alphas,
329
+ one CPU core, sklearn 1.9.1, best of three runs, whole estimator
330
+ including the Grams:
331
+
332
+ | n | p | CPU | CUDA | sklearn `LassoCV` |
333
+ |---------|------|--------|--------|-------------------|
334
+ | 1,800 | 200 | 0.21 s | 0.02 s | 0.10 s |
335
+ | 5,000 | 1000 | 1.48 s | 0.25 s | 0.90 s |
336
+ | 100,000 | 200 | 0.49 s | 0.34 s | 1.72 s |
337
+ | 100,000 | 500 | 1.32 s | 1.09 s | 4.82 s |
338
+
339
+ (Taken on an otherwise idle machine, load 2-3 on 20 threads. sklearn's
340
+ `LassoCV` at its default `precompute='auto'` takes the Gram route at
341
+ every size here, n > p, rebuilding each fold's Gram from that fold's
342
+ rows where we subtract it from the whole.) The grid
343
+ launch itself takes 9 ms at n=1,800 and 64 ms at n=5,000, p=1000; at
344
+ n=100,000 the CUDA column is nearly all Gram products, which the CPU
345
+ path pays as well. Large n is where the machinery pays either way:
346
+ nothing downstream of the Grams touches the 100,000 rows again. At
347
+ n=5,000, p=1000 sklearn's raw-X descent beats our Gram-heavy CPU path,
348
+ and the GPU makes up the difference three and a half times over. CPU and CUDA
349
+ choose the same alpha everywhere and agree to 1e-14; against sklearn
350
+ the pick is exact at n=5,000 and one grid step apart on a near-tie
351
+ elsewhere (mean-MSE relative gap at most 2.6e-4), and sklearn at
352
+ tol=1e-10 picks our alpha at every size.
353
+
354
+ The elastic net cross-validates the same way: `CovLassoCV(l1_ratio=0.5)`
355
+ against `ElasticNetCV(l1_ratio=0.5)` on the same folds and alpha grid
356
+ (`bench/lasso-compare.py --cv-only --l1-ratio 0.5`, same conditions,
357
+ load under 1):
358
+
359
+ | n | p | CPU | CUDA | sklearn `ElasticNetCV` |
360
+ |---------|------|--------|--------|------------------------|
361
+ | 1,800 | 200 | 0.35 s | 0.02 s | 0.11 s |
362
+ | 5,000 | 1000 | 1.55 s | 0.26 s | 0.98 s |
363
+ | 100,000 | 200 | 0.52 s | 0.35 s | 1.76 s |
364
+ | 100,000 | 500 | 1.38 s | 1.15 s | 4.97 s |
365
+
366
+ The picture is the lasso's: on the CPU, 3.4-3.6x at 100,000 rows and
367
+ slower than sklearn at the two small shapes; with CUDA, 3.8-5.5x
368
+ everywhere. The chosen alpha is sklearn's at all four sizes, the
369
+ coefficients within 8e-6 (sklearn's default tolerance).
370
+
371
+ `CovMultiTaskLasso` and `CovMultiTaskLassoCV` are the multi-task
372
+ family over the same machinery: scikit-learn's `MultiTaskLasso`,
373
+ `MultiTaskElasticNet` (through `l1_ratio`), `MultiTaskLassoCV` and
374
+ `MultiTaskElasticNetCV`. The penalty ties each feature's row of W
375
+ together across tasks, so the coordinate update becomes a block soft
376
+ threshold on the row's L2 norm; C = X'Y - GW is maintained per task
377
+ exactly as the single-task C is, and nothing downstream of the Grams
378
+ touches the n rows -- the kernel is the same translated Scheme
379
+ (`mt-descend`). Folds are independent and both the descent (ctypes)
380
+ and the products (BLAS) release the GIL, so `n_jobs` threads scale the
381
+ CV across folds; the default is sequential, as scikit-learn's
382
+ `n_jobs=None` is, and the benchmark pins BLAS to one thread on both
383
+ sides either way. With a GPU the CV grid is one launch exactly as
384
+ `CovLassoCV`'s is, a block per (fold, alpha) cell with the p x T
385
+ coefficient block in shared memory (`force_cpu` opts out). Same
386
+ protocol as above, 8 tasks; single fits at alpha = 0.1 lambda_max;
387
+ the CV pairs share the 100-alpha grid:
388
+
389
+ | estimator | n | p | ours | ours `n_jobs=5` | ours CUDA | sklearn |
390
+ |-----------------------|---------|-----|--------|-----------------|-----------|---------|
391
+ | MultiTaskLasso | 100,000 | 200 | 0.14 s | -- | -- | 0.76 s |
392
+ | MultiTaskLasso | 100,000 | 500 | 0.53 s | -- | -- | 1.7 s |
393
+ | MultiTaskLassoCV | 1,800 | 200 | 1.4 s | 0.51 s | 0.07 s | 0.42 s |
394
+ | MultiTaskLassoCV | 100,000 | 200 | 1.6 s | 0.64 s | 1.3 s | 74 s |
395
+ | MultiTaskElasticNetCV | 1,800 | 200 | 1.4 s | 0.49 s | 0.08 s | 0.75 s |
396
+ | MultiTaskElasticNetCV | 100,000 | 200 | 1.6 s | 0.68 s | 1.3 s | 162 s |
397
+
398
+ (`MultiTaskElasticNet` single fits time the same as `MultiTaskLasso`'s;
399
+ every column was taken in one sitting on the idle machine of the
400
+ `CovLassoCV` table above, sklearn 1.9.1. scikit-learn has no Gram
401
+ route for its multi-task estimators -- its path code sets
402
+ `precompute=False` for 2-D targets, "No Gram variant of multi-task
403
+ exists right now" -- so its column is raw-X descent at every n, which
404
+ is why it grows with n where `LassoCV`'s did not.) At n=1,800 our sequential CV
405
+ is slower than sklearn's: the default tol=1e-8 runs more sweeps than
406
+ sklearn's 1e-4 dual-gap stop, and at that size there are no rows to
407
+ save -- and the GPU grid takes the whole thing in a tenth of a second.
408
+ At n=100,000 the Gram route is 46x and 100x ahead, essentially
409
+ unchanged from n=1,800 in absolute time; the CUDA column there is
410
+ the Gram products, the same ones the CPU path forms. Coefficients agree with scikit-learn's to 1e-15 at tight
411
+ tolerance, and the CV pair picks the same alpha with coefficients to
412
+ 1e-13.
413
+
414
+ ## Which method
415
+
416
+ `fit_path` walks a single path, each lambda starting from the previous
417
+ solution -- the descent is exactly resumable, so this costs almost
418
+ nothing per lambda after the first. `fit_path_batch` solves every
419
+ lambda from zero, which is what a cross-validation grid needs, where
420
+ the folds differ and warm starting across lambdas is not on offer; the
421
+ problems are independent, so they go to the GPU together.
422
+
423
+ Over a 400-lambda path at p=200 and 1800 rows, on an RTX 4090 and an
424
+ i9-10900X:
425
+
426
+ | call | time |
427
+ |---------------------------------------|---------|
428
+ | `fit_path` (warm, sequential) | 0.091 s |
429
+ | `fit_path_batch` (GPU) | 0.047 s |
430
+ | `fit_path_batch(force_cpu=True)` | 0.178 s |
431
+
432
+ GPU and CPU agree to 2e-14, and the objectives are within 3e-17 of
433
+ scikit-learn's `lasso_path` on the same grid.
434
+
435
+ ## Elastic net and ridge
436
+
437
+ The same Gram matrix serves two more estimators. `fit_path` and
438
+ `fit_path_batch` take `l1_ratio` (scikit-learn's mixing parameter):
439
+ the L2 share of the penalty enters only the update's denominator, so
440
+ the elastic net runs on the identical machinery, GPU path included,
441
+ and `l1_ratio=1` is bit-for-bit the lasso.
442
+
443
+ `CovRidge` is the closed form: one symmetric eigendecomposition,
444
+ then every alpha costs O(p^2), so thousands of alphas cost what one
445
+ does. Its objective matches scikit-learn's `Ridge` with
446
+ `fit_intercept=False` (which, unlike the lasso, is not scaled by the
447
+ number of rows), and agrees with it to machine precision.
448
+
449
+ ```python
450
+ path = model.fit_path(lambdas, l1_ratio=0.5) # elastic net
451
+ ridge = CovRidge(X, y)
452
+ betas = ridge.fit_path(ridge.alpha_grid()) # a whole ridge path
453
+ ```
454
+
455
+ Measured (`bench/ridge-enet-compare.py` in the repository: AR(1)
456
+ designs, 20 penalties, whole calls with the Gram matrix wherever one
457
+ is built, one core, best of three):
458
+
459
+ | n x p | 1,800 x 200 | 5,000 x 1000 | 100,000 x 200 | 100,000 x 500 |
460
+ |----------------------------------------------|-------------|--------------|---------------|---------------|
461
+ | elastic net, `CovLasso.fit_path(l1_ratio=0.5)`, tol 1e-8 | 0.007 s | 0.092 s | 0.11 s | 0.40 s |
462
+ | elastic net, sklearn `enet_path(precompute=True)`, tol 1e-4 | 0.005 s | 0.099 s | 0.23 s | 0.74 s |
463
+ | ridge, `CovRidge.fit_path(alphas)` | 0.004 s | 0.21 s | 0.10 s | 0.41 s |
464
+ | ridge, sklearn `Ridge(alpha).fit` per alpha | 0.060 s | 2.05 s | 3.37 s | 11.2 s |
465
+ | ridge, sklearn `RidgeCV(alphas)` (its LOO path) | 0.093 s | 3.40 s | 5.38 s | 19.0 s |
466
+
467
+ The elastic net is the same coordinate descent on both sides, so the
468
+ two rows differ by the Gram and the warm path, not the sweeps: level
469
+ at small n, 1.8-2.2x at 100,000 rows, and ours lands within 2e-10 of
470
+ a tight reference where scikit-learn's default tolerance lands within
471
+ 6e-4. The ridge rows compare a whole path against what a user
472
+ writes -- one `Ridge.fit` per alpha, each forming X'X and solving
473
+ afresh, O(np^2 + p^3) every time -- so the gap is the path's: 10-33x,
474
+ agreeing to 1e-14. `RidgeCV` is given for scale (it picks one alpha
475
+ by leave-one-out rather than returning the path). Neither row runs
476
+ on the GPU: the ridge is one eigendecomposition and the elastic net's
477
+ sweeps are the lasso's; `fit_path_batch(l1_ratio=..)` is the GPU
478
+ grid for the elastic net.
479
+
480
+ ## Bayesian ridge
481
+
482
+ `CovBayesianRidge` is scikit-learn's `BayesianRidge` -- the same model,
483
+ the same defaults, the same iteration (MacKay's updates of the noise
484
+ precision `alpha_` and the weight precision `lambda_`, stopping when
485
+ the posterior mean moves by less than `tol`) -- with the linear
486
+ algebra moved into the eigenbasis of the Gram matrix. scikit-learn
487
+ takes the SVD of X once and multiplies by X again in every iteration
488
+ for the squared error. Here X enters once, through X'X (X X' when
489
+ n <= p): one eigendecomposition, after which the posterior mean, its
490
+ norm and the squared error are sums over the eigenvalues, O(p) per
491
+ iteration. `sigma_` (the posterior covariance) and
492
+ `predict(return_std=True)` are there as in scikit-learn.
493
+
494
+ ```python
495
+ from scm2cpp_lasso import CovBayesianRidge
496
+ m = CovBayesianRidge().fit(X, y) # fit_intercept=True, as sklearn
497
+ mean, std = m.predict(X_new, return_std=True)
498
+ ```
499
+
500
+ Measured (`bench/bayesian-ridge-compare.py` in the repository:
501
+ Gaussian designs, a sparse truth plus noise, whole fits including
502
+ `sigma_`, one core, best of three, sklearn 1.9.1):
503
+
504
+ | n x p | `CovBayesianRidge` | sklearn `BayesianRidge` | ratio | iterations (both) | max coefficient difference |
505
+ |----------------|--------------------|-------------------------|-------|-------------------|----------------------------|
506
+ | 1,800 x 200 | 0.005 s | 0.026 s | 5.2x | 3 | 3.6e-15 |
507
+ | 5,000 x 1000 | 0.257 s | 1.231 s | 4.8x | 4 | 6.7e-15 |
508
+ | 100,000 x 200 | 0.126 s | 3.495 s | 27.8x | 3 | 2.8e-14 |
509
+ | 100,000 x 500 | 0.475 s | 8.909 s | 18.8x | 3 | 1.6e-14 |
510
+ | 500 x 5000 | 0.662 s | 8.689 s | 13.1x | 71 | 1.7e-15 |
511
+
512
+ The iteration counts are identical and the coefficients agree to
513
+ rounding, so the gap is the linear algebra alone: X'X by one BLAS
514
+ product against a full SVD of the tall X, and no pass over X inside
515
+ the loop. At 500 x 5000 scikit-learn takes the full 5000 x 5000 SVD
516
+ for `sigma_`; here `sigma_` comes from the 500 x 500 kernel side by
517
+ the Woodbury identity. This estimator is numpy, not translated code:
518
+ the eigendecomposition is LAPACK's either way.
519
+
520
+ ## Classifiers and SVRs: `LinearSVM`, `KernelSVM`, `LinearSVR`, `KernelSVR`, `L1LogisticRegression`
521
+
522
+ Three classifiers and two regressors, each the algorithm scikit-learn runs, written out
523
+ in the Scheme subset and translated plainly (no derivation; the C++
524
+ is committed under `_generated/classifiers*`):
525
+
526
+ | class | algorithm | scikit-learn counterpart |
527
+ |---|---|---|
528
+ | `LinearSVM` | liblinear's dual coordinate descent for the hinge loss (random order, shrinking) | `LinearSVC(loss="hinge", dual=True, fit_intercept=False)` |
529
+ | `KernelSVM` | libsvm's SMO (WSS3, shrinking, the bias) over the whole kernel matrix | `SVC` (`kernel="rbf"`, `"linear"` or `"precomputed"`) |
530
+ | `L1LogisticRegression` | liblinear's newGLMNET (Newton over coordinate descent, line search) | `LogisticRegression(l1_ratio=1, solver="liblinear", fit_intercept=False)` |
531
+ | `LinearSVR` | liblinear's dual coordinate descent for the epsilon-insensitive loss (random order, shrinking) | `LinearSVR(loss="epsilon_insensitive", dual=True, fit_intercept=False)` |
532
+ | `KernelSVR` | the same SMO as `KernelSVM`, set up as libsvm's epsilon-SVR (2n variables) | `SVR` (`kernel="rbf"`, `"linear"` or `"precomputed"`) |
533
+
534
+ ```python
535
+ from scm2cpp_lasso import LinearSVM, KernelSVM, L1LogisticRegression
536
+ LinearSVM(C=1.0).fit(X, y).predict(X_new)
537
+ KernelSVM(C=1.0, gamma="scale").fit(X, y).decision_function(X_new)
538
+ L1LogisticRegression(C=0.1).fit(X, y).predict_proba(X_new)
539
+ ```
540
+
541
+ The classifiers take two classes (the second of `classes_` is the
542
+ positive one), and `tol` / `max_iter` mean what they mean in scikit-learn.
543
+ `KernelSVM` and `KernelSVR` build the kernel matrix in numpy and keep all of it,
544
+ n x n doubles (800 MB at n = 10,000), where `SVC` computes rows on
545
+ demand through a cache; that is its price.
546
+
547
+ Measured with `check-classifiers.py` (in this directory): the same
548
+ problem and stopping rule on both sides, one core, best of three,
549
+ sklearn 1.9.1, load average under 2. Ours includes building the kernel
550
+ matrix (`KernelSVM`) or transposing X into columns
551
+ (`L1LogisticRegression`):
552
+
553
+ | problem | ours | scikit-learn | ratio | objective (ours / sklearn) | same predictions |
554
+ |---|---|---|---|---|---|
555
+ | `LinearSVM`, 5,000 x 200 | 0.379 s | 0.598 s | 1.6x | 1541.06 / 1541.29 | 99.5% |
556
+ | `LinearSVM`, 100,000 x 200 | 23.0 s | 41.7 s | 1.8x | 34503.5 / 34500.7 | 98.9% |
557
+ | `LinearSVM`, 2,000 x 20,000 | 0.975 s | 1.894 s | 1.9x | 0.052484 / 0.052428 | 100% |
558
+ | `KernelSVM` rbf, 2,000 x 50 | 0.068 s | 0.139 s (precomputed 0.081 s) | 2.1x (1.2x) | -1065.5026 / -1065.5026 | 100% |
559
+ | `KernelSVM` rbf, 5,000 x 50 | 0.501 s | 1.032 s (precomputed 0.588 s) | 2.1x (1.2x) | -2628.1422 / -2628.1422 | 100% |
560
+ | `KernelSVM` rbf, 10,000 x 50 | 2.12 s | 4.15 s (precomputed 2.61 s) | 2.0x (1.2x) | -4994.9982 / -4994.9982 | 100% |
561
+ | `L1LogisticRegression`, 5,000 x 200, C=0.1 | 0.064 s | 0.103 s | 1.6x | 61.676698 / 61.676708 | 100% |
562
+ | `L1LogisticRegression`, 20,000 x 500, C=0.05 | 1.20 s | 2.23 s | 1.9x | 106.77913 / 106.77915 | 100% |
563
+ | `L1LogisticRegression`, 100,000 x 200, C=0.05 | 9.06 s | 18.7 s | 2.1x | 349.61154 / 349.61216 | 100% |
564
+
565
+ The regressors, from the same script (C = 1, epsilon 0.1):
566
+
567
+ | problem | ours | scikit-learn | ratio | agreement |
568
+ |---|---|---|---|---|
569
+ | `LinearSVR`, 5,000 x 200 | 1.32 s | 2.53 s | 1.9x | both at the 1,000-sweep cap; objective 779.99 / 790.72 |
570
+ | `LinearSVR`, 20,000 x 500 | 22.1 s | 36.6 s | 1.7x | both at the cap; objective 3741.4 / 3712.2 |
571
+ | `KernelSVR` rbf, 1,000 x 10 | 0.023 s | 0.043 s | 1.9x | iterations 2,172 / 2,177; predictions within 4e-4 |
572
+ | `KernelSVR` rbf, 3,000 x 10 | 0.246 s | 0.332 s | 1.3x | iterations 7,471 / 7,581; within 5e-4 |
573
+ | `KernelSVR` rbf, 6,000 x 10 | 1.18 s | 1.38 s | 1.2x | iterations 16,805 / 16,630; within 5e-4 |
574
+
575
+ `LinearSVR` on these data does not reach its tolerance in 1,000 sweeps on
576
+ either side, and at the cap the random order alone moves the objective
577
+ by about 1% (scikit-learn's own seeds give 776-789 at 5,000 x 200);
578
+ with 20,000 sweeps both land on 728.58. `KernelSVR`'s gain shrinks
579
+ with n here because the 10 features make each kernel row cheap for
580
+ libsvm to compute, while ours reads the rows of a matrix of n^2
581
+ doubles; the predictions agree to within scikit-learn's tolerance.
582
+
583
+ The objectives agree to the stopping tolerance on both sides, not to
584
+ rounding. The linear SVM on tall data stops at the 1,000-sweep cap on
585
+ both sides, so there the two land a hair apart and 1% of the
586
+ predictions differ. The gain is the same in all three: the same
587
+ algorithm, run as dense loops that vectorise, against libsvm's and
588
+ liblinear's sparse rows. The classifiers are compiled at `-O3` with
589
+ `-fstrict-overflow`: Python's build flags carry
590
+ `-fno-strict-overflow`, which keeps the compiler from treating the
591
+ flat index `i*p + j` as an induction variable and makes the linear
592
+ SVM 25% slower.
593
+
594
+ ## NMF: `CDNMF`
595
+
596
+ `CDNMF` is scikit-learn's `NMF(solver="cd")` -- Frobenius loss, no
597
+ regularization, no shuffling -- as the translator derives it.
598
+ `examples/kernel-only/nmf-cd.scm` writes the half step with the
599
+ residual R = X - W H and no Gram matrix. `--derive` memoises R H' over
600
+ the hoisted H H' and distributes the residual through the memo, so the
601
+ compiled half step is scikit-learn's `XHt - W HHt` with R never formed.
602
+ Like `DerivedLasso`, it is built in three variants (`nmf_backend()`
603
+ says which is taken: cublas when a device answers, else blas, else
604
+ loops).
605
+
606
+ ```python
607
+ from scm2cpp_lasso import CDNMF
608
+ model = CDNMF(n_components=20, random_state=0) # init="random", as sklearn's
609
+ W = model.fit_transform(X) # H in model.components_
610
+ ```
611
+
612
+ `init="random"` draws the start as scikit-learn does, so the same seed
613
+ gives the same W and H. scikit-learn's default `"nndsvda"` is not
614
+ offered; pass `init="custom"` with its W and H for that start.
615
+ Measured with `check-classifiers.py --only nmf` against
616
+ `NMF(solver="cd", init="random")` from the same seed, tol 1e-4, at most
617
+ 200 iterations, one core:
618
+
619
+ | n x p, k | `CDNMF` cuBLAS | `CDNMF` BLAS | sklearn | iterations | max \|H - H_sklearn\| |
620
+ |---|---|---|---|---|---|
621
+ | 1,000 x 500, 10 | 0.441 s (0.6x) | 0.212 s (1.15x) | 0.244 s | 200 / 200 | 1.3e-13 |
622
+ | 5,000 x 2,000, 20 | 7.01 s (1.6x) | 8.27 s (1.36x) | 11.25 s | 200 / 200 | 4.1e-12 |
623
+ | 20,000 x 1,000, 30 | 16.9 s (1.8x) | 20.6 s (1.46x) | 30.1 s | 200 / 200 | 2.4e-12 |
624
+
625
+ The iterates are scikit-learn's to rounding. The cuBLAS variant pays
626
+ from the middle size on; at 1,000 x 500 its transfers cost more than
627
+ the products save, and `backend="blas"` is the faster choice there.
628
+
629
+ ## Graphical lasso and RANSAC: `CDGraphicalLasso`, `LinearRANSAC`
630
+
631
+ Two more of scikit-learn's algorithms, written out in the Scheme subset
632
+ and translated plainly.
633
+
634
+ - `CDGraphicalLasso` is `GraphicalLasso(mode="cd")`: `_graphical_lasso`
635
+ with `enet_coordinate_descent_gram` inside it, its two matrix-vector
636
+ products as dgemv. `alpha`, `tol`, `enet_tol`, `max_iter` and
637
+ `assume_centered` mean what they mean in scikit-learn. scikit-learn's
638
+ `costs_` (a log-determinant every iteration) is not computed.
639
+ - `LinearRANSAC` is `RANSACRegressor` around `LinearRegression`, with
640
+ the same defaults. With scikit-learn installed, the subsets are drawn
641
+ exactly as it draws them, so `random_state` gives scikit-learn's fit.
642
+
643
+ ```python
644
+ from scm2cpp_lasso import CDGraphicalLasso, LinearRANSAC
645
+ prec = CDGraphicalLasso(alpha=0.1).fit(X).precision_
646
+ fit = LinearRANSAC(random_state=0).fit(X, y) # fit.coef_, fit.inlier_mask_
647
+ ```
648
+
649
+ Measured through the package, one core, best of three, against
650
+ scikit-learn on the same data (an AR(1) design with n = 20p for the
651
+ graphical lasso, 100 iterations on both sides; a linear model with
652
+ outliers for RANSAC, max_trials 1000):
653
+
654
+ | problem | ours | scikit-learn | ratio | agreement |
655
+ |---|---|---|---|---|
656
+ | `CDGraphicalLasso`, p = 50 | 0.0069 s | 0.306 s | 44.5x | 100 / 100 iterations, precision to 1.8e-14 |
657
+ | `CDGraphicalLasso`, p = 200 | 0.286 s | 1.62 s | 5.7x | precision to 2.6e-14 |
658
+ | `CDGraphicalLasso`, p = 800 | 33.9 s | 43.8 s | 1.3x | precision to 5.0e-14 |
659
+ | `LinearRANSAC`, 10,000 x 5 | 0.0254 s | 0.0916 s | 3.6x | 98 / 98 trials, same inliers |
660
+ | `LinearRANSAC`, 100,000 x 10 | 0.153 s | 0.241 s | 1.6x | 97 / 97 trials, same inliers |
661
+ | `LinearRANSAC`, 20,000 x 20 | 0.470 s | 1.19 s | 2.5x | 1,000 / 1,000 trials, same inliers |
662
+
663
+ Both gains are mostly scikit-learn's Python between its compiled steps:
664
+ the per-column bookkeeping of the graphical lasso, and the per-trial
665
+ validation and fitting of RANSAC. They shrink as the compiled part
666
+ grows (the graphical lasso at large p).
667
+
668
+ ## SGD, OMP and LARS: `SGDLinearClassifier`, `SGDLinearRegressor`, `GramOMP`, `GramLars`, `GramLassoLars`
669
+
670
+ scikit-learn's own algorithms, translated:
671
+
672
+ - `SGDLinearClassifier` and `SGDLinearRegressor` are
673
+ `SGDClassifier` / `SGDRegressor` at their defaults, run by
674
+ `_plain_sgd` with the points in order. Against
675
+ `shuffle=False` the weights are bit-identical. scikit-learn's
676
+ shuffling generator is not reproduced.
677
+ - `GramOMP` is `OrthogonalMatchingPursuit` over the Gram matrix (its
678
+ route for n > p, taken here always; X'X and X'y by dsyrk and dgemv).
679
+ - `GramLars` and `GramLassoLars` are `Lars` and `LassoLars` over the
680
+ Gram (their route for n > p, taken here always; the Gram formed in
681
+ numpy).
682
+
683
+ ```python
684
+ from scm2cpp_lasso import SGDLinearClassifier, GramOMP, GramLassoLars
685
+ SGDLinearClassifier().fit(X, y).predict(X_new)
686
+ GramOMP(n_nonzero_coefs=10).fit(X, y).coef_
687
+ GramLassoLars(alpha=0.01).fit(X, y).coef_
688
+ ```
689
+
690
+ Each gives scikit-learn's coefficients, to the bit for SGD and to 1e-15
691
+ for the rest, with the same number of epochs or steps. The speed is the
692
+ repository README's table: 1.0-3.3x for SGD, 1.1-2.8x for OMP and
693
+ 1.2-5.0x for LARS, largest on small problems, where scikit-learn's
694
+ Python between steps dominates.
695
+
696
+ ## L2 logistic regression and Huber: `LBFGSLogisticRegression`, `LBFGSHuberRegressor`
697
+
698
+ `LogisticRegression(solver="lbfgs")` (the default; two classes
699
+ binomial, more multinomial) and `HuberRegressor`, with scipy's
700
+ L-BFGS-B written out in the subset under them. The products are dgemm
701
+ or dgemv where a CBLAS links.
702
+
703
+ ```python
704
+ from scm2cpp_lasso import LBFGSLogisticRegression, LBFGSHuberRegressor
705
+ LBFGSLogisticRegression(C=1.0).fit(X, y).predict_proba(X_new)
706
+ LBFGSHuberRegressor(epsilon=1.35).fit(X, y).coef_
707
+ ```
708
+
709
+ Both take scikit-learn's iterations one for one and give its
710
+ coefficients to rounding (1e-13 at worst). The speed is the repository
711
+ README's table: 1.1-8x for the logistic regression (near 1x on a large
712
+ binary problem, where both sides are two matrix-vector products per
713
+ evaluation) and 2.5-18x for Huber.
714
+
715
+ ## Gaussian mixture, FastICA, PCA: `EMGaussianMixture`, `ParallelFastICA`, `CovPCA`
716
+
717
+ `GaussianMixture(covariance_type="full")`, `FastICA` at its defaults
718
+ and `PCA` by the covariance's eigenvectors (scikit-learn's
719
+ "covariance_eigh", what "auto" picks for a tall design), each
720
+ scikit-learn's algorithm translated, the products dgemm / dsyrk where a
721
+ CBLAS links and the eigenproblem written in the subset.
722
+
723
+ ```python
724
+ from scm2cpp_lasso import EMGaussianMixture, ParallelFastICA, CovPCA
725
+ EMGaussianMixture(4, random_state=0).fit(X).predict(X) # KMeans start: scikit-learn's
726
+ ParallelFastICA(random_state=0).fit(X).transform(X)
727
+ CovPCA(n_components=10).fit(X).components_
728
+ ```
729
+
730
+ From the same `random_state` each takes scikit-learn's iterations and
731
+ lands on its parameters to rounding. The speed is the repository
732
+ README's table: 1.6-2.4x for the mixture, 1.1-1.4x for FastICA, and
733
+ for PCA 1.3-4x up to about 100 features, 0.6-0.8x at 200-500, where
734
+ LAPACK's eigensolver wins.
735
+
736
+ ## Perceptron, ARD, LassoLarsIC, L1 linear SVM: `SGDPerceptron`, `CovARDRegression`, `GramLassoLarsIC`, `L1LinearSVM`
737
+
738
+ - `SGDPerceptron` is `Perceptron`. `SGDLinearClassifier` /
739
+ `SGDLinearRegressor` also take `loss="perceptron"` /
740
+ `"epsilon_insensitive"`, `learning_rate="constant"` / `"pa1"` /
741
+ `"pa2"` and `penalty=None`, which covers scikit-learn's replacements
742
+ for the passive-aggressive estimators. All run with shuffle=False.
743
+ - `CovARDRegression` is `ARDRegression` (n >= p). With `hoist=True`,
744
+ the default, the kept features' Gram comes from X'X formed once;
745
+ `hoist=False` forms it every iteration, as scikit-learn does.
746
+ - `GramLassoLarsIC` is `LassoLarsIC`. The whole lasso path comes from
747
+ the translated LARS, and the criterion is evaluated from the Gram.
748
+ - `L1LinearSVM` is `LinearSVC(penalty="l1", dual=False)`, liblinear's
749
+ solve_l1r_l2_svc. It agrees with scikit-learn to the tolerance,
750
+ because the coordinate order comes from another generator.
751
+
752
+ ```python
753
+ from scm2cpp_lasso import SGDPerceptron, CovARDRegression, GramLassoLarsIC, L1LinearSVM
754
+ SGDPerceptron().fit(X, y)
755
+ CovARDRegression().fit(X, y).coef_
756
+ GramLassoLarsIC("bic").fit(X, y).alpha_
757
+ L1LinearSVM(C=1.0).fit(X, y).coef_
758
+ ```
759
+
760
+ Speed, from the repository README's table: SGD 1.2-3x, ARD 1.6-1.9x
761
+ (3x with the Gram hoisted), LassoLarsIC 8-20x, the L1 SVM 1.1-1.5x.
762
+
763
+ ## GLMs, CV and Theil-Sen: `LBFGSPoissonRegressor`, `LBFGSGammaRegressor`, `LBFGSTweedieRegressor`, `LBFGSLogisticRegressionCV`, `GramLarsCV`, `GramLassoLarsCV`, `GramOMPCV`, `WeiszfeldTheilSen`
764
+
765
+ - The GLMs are `PoissonRegressor`, `GammaRegressor` and
766
+ `TweedieRegressor` (solver "lbfgs"), over the same L-BFGS-B as
767
+ `LBFGSLogisticRegression`.
768
+ - The CV classes reproduce `LogisticRegressionCV`, `LarsCV`,
769
+ `LassoLarsCV` and `OrthogonalMatchingPursuitCV`: the same folds (KFold
770
+ / StratifiedKFold without shuffling), the same choice of C, alpha or
771
+ number of columns, and the same refit.
772
+ - `WeiszfeldTheilSen` is `TheilSenRegressor` on scikit-learn's own
773
+ subsets.
774
+
775
+ ```python
776
+ from scm2cpp_lasso import LBFGSPoissonRegressor, LBFGSLogisticRegressionCV, GramLassoLarsCV
777
+ LBFGSPoissonRegressor(alpha=1e-3).fit(X, counts).predict(X_new)
778
+ LBFGSLogisticRegressionCV().fit(X, labels).C_
779
+ GramLassoLarsCV().fit(X, y).alpha_
780
+ ```
781
+
782
+ Speed, from the repository README's table: the GLMs 1.1-6x, the CV
783
+ estimators 1.6-6.6x, Theil-Sen 1.3-1.8x (its subset drawing dominates).
784
+
785
+ ## Feature selection: `f_regression`, `f_classif`, `SelectKBest`, `SelectPercentile`, `GramRFE`
786
+
787
+ scikit-learn's univariate F statistics and its selectors over them,
788
+ and `RFE` around `Ridge` / `LinearRegression`, translated.
789
+ `f_regression` also takes an n x q array of targets: the q results in
790
+ one product, on the device when one answers.
791
+
792
+ ```python
793
+ from scm2cpp_lasso import SelectKBest, GramRFE, f_regression
794
+ SelectKBest("f_regression", k=20).fit(X, y).get_support()
795
+ GramRFE(alpha=1.0, n_features_to_select=10).fit(X, y).ranking_
796
+ F, p = f_regression(X, Y) # Y: n x q, every target at once
797
+ ```
798
+
799
+ The same scores, ranking and features as scikit-learn. RFE is 80-1,000x
800
+ faster (one Gram and one Cholesky factor, downdated, instead of a refit
801
+ per round), and f_regression over many targets 30-50x. The Gram-route
802
+ estimators (`GramLassoLarsIC`, `GramLarsCV`, `GramLassoLarsCV`,
803
+ `GramLars`, `GramLassoLars`, `CovPCA`) form a large X'X on the device
804
+ when one answers. `SCM2CPP_NO_DEVICE=1` keeps everything on the CPU.
805
+
806
+ ## Against Intel's sklearnex
807
+
808
+ sklearnex (scikit-learn-intelex 2026.2, oneDAL underneath) replaces
809
+ some scikit-learn estimators with its own. `bench/sklearnex-compare.py`
810
+ times the ones with a counterpart here, sklearnex's estimators
811
+ imported directly (not patched in) so that scikit-learn itself is
812
+ timed in the same process. The ratios are scikit-learn's or
813
+ sklearnex's time over the package's best (its CPU build, or the device
814
+ build where one answers); the load average was 8 to 11, so the times
815
+ move by some tens of percent.
816
+
817
+ | estimator, n x p | iterations (sk / sklearnex / ours) | vs scikit-learn, one core | vs sklearnex, one core | vs sklearnex, 20 cores |
818
+ |---|---|---|---|---|
819
+ | LogisticRegression, 200,000 x 100 | 6 / 12 / 6 | 10.6x (device) | 8.4x (device) | 4.4x (device); on the CPU 0.5x |
820
+ | Ridge (alpha 1), 200,000 x 200 | - | 2.1x | 1.2x | 0.3x |
821
+ | PCA (10 components), 100,000 x 50 | - | 1.5x | 2.4x | 1.7x |
822
+ | PCA (10 components), 50,000 x 500 | - | 1.1x | 1.4x | 0.5x |
823
+ | SVC (rbf), 5,000 x 20 | 4,858 / 7 / 4,882 | 1.2x | 1.4x | 0.5x |
824
+ | SVR (rbf), 5,000 x 20 | 12,821 / 10 / 12,824 | 1.3x | 1.4x | 0.4x |
825
+ | RFE(Ridge), 20 of 200 kept, 20,000 x 200 | - | 216x | 128x | 78x |
826
+
827
+ On one core the package is ahead everywhere. sklearnex uses every
828
+ core by default, and the package's kernels are single-threaded, so
829
+ with 20 cores sklearnex is faster on Ridge, the larger PCA and the
830
+ SVMs; what stays ahead is the device build (logistic regression) and
831
+ an algorithm that does less work (RFE: the Gram once, the Cholesky
832
+ factor downdated per drop, where scikit-learn refits). sklearnex is
833
+ not scikit-learn's arithmetic: its logistic regression takes 12
834
+ iterations to scikit-learn's 6 and its coefficients differ from
835
+ scikit-learn's by 6e-3, where the package's agree to 4e-15; its SVMs
836
+ report a different iteration count. This sklearnex accelerates no
837
+ lasso, so the lasso tables above stand against scikit-learn alone.
838
+
839
+ ## The GPU build
840
+
841
+ Where the CUDA toolkit is at install time, `ParallelFastICA`,
842
+ `EMGaussianMixture`, `LBFGSLogisticRegression`,
843
+ `LBFGSLogisticRegressionCV`, `LBFGSHuberRegressor` and the `LBFGS*`
844
+ GLMs also get the same Scheme translated with `--cuda`
845
+ (`libscm2cpp_gpu.so`). Its loops are CUDA kernels and its products
846
+ cuBLAS, on arrays that stay on the device. The classes take it when a
847
+ device answers and the data has a million entries or more, and give
848
+ the CPU build's iterations and results to the last digits. Measured
849
+ against scikit-learn: 10-22x for the L-BFGS fits, 6.7x FastICA, 3x the
850
+ mixture and LogisticRegressionCV (the repository README's table).
851
+ `SCM2CPP_NO_DEVICE=1` keeps everything on the CPU.
852
+
853
+ ## Logistic regression with L1
854
+
855
+ `CovLogistic` solves L1-penalized logistic regression by
856
+ majorization: the logistic Hessian is bounded by X'X/4, so the
857
+ quadratic term is the same Gram matrix, fixed once, and each outer
858
+ round costs one gradient pass before handing the majorizer to the
859
+ same coordinate descent the lasso uses. Objectives agree with
860
+ scikit-learn's `LogisticRegression(penalty="l1",
861
+ fit_intercept=False)` at `C = 1/(n lam)` to 9e-15.
862
+
863
+ ## Group lasso
864
+
865
+ `CovGroupLasso` penalizes whole groups -- `lam * sum_g sqrt(|g|)
866
+ ||b_g||` -- so correlated features enter or leave together. Block
867
+ coordinate descent on the same Gram machinery: each block visit is
868
+ one majorized proximal step (the group's Gram block dominated by its
869
+ top eigenvalue, found once), descending monotonically. With size-one
870
+ groups it reduces exactly to the lasso -- verified against sklearn to
871
+ 9e-16 -- and at convergence the group KKT conditions hold to 2e-12.
872
+
873
+ ## Bootstrap on the GPU
874
+
875
+ `bootstrap` draws pairs-bootstrap resamples and refits them all at
876
+ one lambda. Each resample's Gram matrix is `X' diag(m) X` for its
877
+ multiplicity counts `m`, written as `Xs' Xs` with `Xs = diag(sqrt m) X`
878
+ so that numpy hands it to the symmetric rank-k product (`dsyrk`,
879
+ half the flops of `X' (diag(m) X)`) -- one BLAS call -- and because
880
+ the problems are independent, the descents run as one batch: on the
881
+ GPU, one block of threads per resample, each reading its own Gram
882
+ matrix. The products are the cost: at n=5000, p=1000 a hundred
883
+ resamples took 18.1 s as general products and 11.2 s as `dsyrk`,
884
+ descents included.
885
+
886
+ ```python
887
+ betas = model.bootstrap(lam, n_boot=500, seed=0) # (500, p)
888
+ freq = (abs(betas) > 1e-9).mean(axis=0) # selection frequency
889
+ ```
890
+
891
+ Requires constructing the model from `X, y` (a Gram matrix alone
892
+ cannot be resampled by rows). GPU and CPU agree to machine
893
+ precision.
894
+
895
+ ## A design with structure
896
+
897
+ When the design matrix has structure, forming X'X the general way is
898
+ the wrong move. `kernel` exposes the translated functions directly,
899
+ and [`scm2cpp-tfs`](https://pypi.org/project/scm2cpp-tfs/) does exactly
900
+ that for moving-average designs: it builds the Gram matrix from a
901
+ series' prefix sums in O(n p) time, never forming the design. That
902
+ package stands alone -- it carries its own copy of this descent -- so
903
+ neither installs the other.
904
+
905
+ ## Where this comes from
906
+
907
+ The C++ this compiles is committed to `python/scm2cpp-lasso/` in the
908
+ scm2cpp repository. `CovLasso` and the rest run on the translation of
909
+ `examples/kernel-only/lasso-cov.scm`, a covariance-update sweep
910
+ written by hand at the Gram level; `DerivedLasso` runs on the
911
+ translation of `examples/kernel-only/lasso-design.scm`, the naive
912
+ residual-form descent over X as numpy holds it, with the translator's `--derive --blas`
913
+ deriving the same covariance form from it. The two are the same
914
+ sweep over the same `dsyrk`, and they time the same. To refresh the
915
+ committed C++ after changing the Scheme, run `regenerate.sh` -- that
916
+ step, and only that step, needs Racket.
917
+
918
+ ## License
919
+
920
+ MIT, the same as scm2cpp.