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