scm2cpp-lasso 0.7.0__tar.gz → 0.8.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.
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/PKG-INFO +119 -36
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/README.md +118 -35
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/pyproject.toml +2 -2
- scm2cpp_lasso-0.8.0/setup.py +151 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/__init__.py +122 -4
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/_loader_derived_blas.py +29 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/_loader_derived_cublas.py +29 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/_loader_derived_loops.py +29 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_blas/lasso_design.cpp +5 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_blas/lasso_design.hpp +160 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_blas/lasso_design_capi.cpp +19 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_cublas/lasso_design.cpp +5 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_cublas/lasso_design.hpp +162 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_cublas/lasso_design_capi.cpp +19 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_loops/lasso_design.cpp +5 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_loops/lasso_design.hpp +178 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_loops/lasso_design_capi.cpp +19 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/lasso_cov.hpp +152 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/scm2cpp-blas.hpp +67 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/scm2cpp-cublas.hpp +155 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_libfind.py +18 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/PKG-INFO +119 -36
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso.egg-info/SOURCES.txt +31 -0
- scm2cpp_lasso-0.7.0/setup.py +0 -63
- scm2cpp_lasso-0.7.0/src/scm2cpp_lasso/_generated/lasso_cov.hpp +0 -93
- scm2cpp_lasso-0.7.0/src/scm2cpp_lasso.egg-info/SOURCES.txt +0 -17
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/setup.cfg +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/__init__.py +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/_loader.py +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/lasso_cov.cpp +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/lasso_cov_capi.cpp +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/scm2cpp.hpp +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/batch_capi.cu +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/dependency_links.txt +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/requires.txt +0 -0
- {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scm2cpp-lasso
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Lasso by covariance-update coordinate descent, translated from Scheme to C++ by scm2cpp; GPU batch solver included
|
|
5
5
|
Author: Hirotaka Niitsuma
|
|
6
6
|
License: MIT
|
|
@@ -89,12 +89,85 @@ at zero, and `lambda_grid()` walks down from it -- the construction
|
|
|
89
89
|
scikit-learn uses. If your columns differ wildly in scale, standardize
|
|
90
90
|
them first; this solver does not do it for you.
|
|
91
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). Against a reference driven to
|
|
160
|
+
tol=1e-12, `DerivedLasso` lands within 5e-8 on every backend (its
|
|
161
|
+
tolerance is absolute, on one sweep's largest move), `CovLasso`
|
|
162
|
+
within 2e-10 (it tests every 20 sweeps and overshoots), and
|
|
163
|
+
scikit-learn at its default within 1e-3.
|
|
164
|
+
|
|
92
165
|
## Against the other lassos pip can install
|
|
93
166
|
|
|
94
167
|
`bench/lasso-compare.py` in the repository runs the same
|
|
95
168
|
cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
|
|
96
169
|
p=200, n=1800 -- against every lasso a `pip install` produces:
|
|
97
|
-
scikit-learn 1.9.
|
|
170
|
+
scikit-learn 1.9.1, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
|
|
98
171
|
Every solver runs at its own default tolerance (ours at the tighter
|
|
99
172
|
1e-8), each gets one untimed warm-up fit, cuML's data is on the device
|
|
100
173
|
before the clock starts, and the last column is each solver's
|
|
@@ -103,12 +176,15 @@ answer would show as one:
|
|
|
103
176
|
|
|
104
177
|
| solver | time | objective gap |
|
|
105
178
|
|----------------------------------------|---------|---------------|
|
|
106
|
-
| scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.
|
|
107
|
-
| scm2cpp-lasso, GPU, one
|
|
108
|
-
| sklearn `Lasso.fit` per lambda |
|
|
109
|
-
| celer per lambda |
|
|
110
|
-
| skglm per lambda |
|
|
111
|
-
| cuML per lambda, GPU |
|
|
179
|
+
| scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.91 s | 0 |
|
|
180
|
+
| scm2cpp-lasso, GPU, one block/lambda | 0.044 s | 0 |
|
|
181
|
+
| sklearn `Lasso.fit` per lambda | 9.6 s | +1.6e-09 |
|
|
182
|
+
| celer per lambda | 13.5 s | 0 |
|
|
183
|
+
| skglm per lambda | 14.5 s | +2.8e-17 |
|
|
184
|
+
| cuML per lambda, GPU | 53.6 s | +9.1e-07 |
|
|
185
|
+
|
|
186
|
+
(One sitting on an otherwise idle machine, best of three runs; cuML
|
|
187
|
+
26.08 on CUDA 12.9.)
|
|
112
188
|
|
|
113
189
|
celer and skglm are built for very large sparse designs, where their
|
|
114
190
|
screening rules dominate; at this size they pay their setup per fit.
|
|
@@ -134,25 +210,26 @@ times the alternatives (one thread per cell, warm runs of several
|
|
|
134
210
|
alphas per thread or block) side by side, and the block per cell wins
|
|
135
211
|
at every size tried. The GPU is the default when a device answers
|
|
136
212
|
(`force_cpu` takes the warm CPU path). Measured at cv=5, 100 alphas,
|
|
137
|
-
one CPU core, sklearn 1.9.
|
|
213
|
+
one CPU core, sklearn 1.9.1, best of three runs, whole estimator
|
|
138
214
|
including the Grams:
|
|
139
215
|
|
|
140
216
|
| n | p | CPU | CUDA | sklearn `LassoCV` |
|
|
141
217
|
|---------|------|--------|--------|-------------------|
|
|
142
|
-
| 1,800 | 200 | 0.
|
|
143
|
-
| 5,000 | 1000 |
|
|
144
|
-
| 100,000 | 200 | 0.
|
|
145
|
-
| 100,000 | 500 |
|
|
146
|
-
|
|
147
|
-
(
|
|
148
|
-
|
|
149
|
-
|
|
218
|
+
| 1,800 | 200 | 0.21 s | 0.02 s | 0.10 s |
|
|
219
|
+
| 5,000 | 1000 | 1.48 s | 0.25 s | 0.90 s |
|
|
220
|
+
| 100,000 | 200 | 0.49 s | 0.34 s | 1.72 s |
|
|
221
|
+
| 100,000 | 500 | 1.32 s | 1.09 s | 4.82 s |
|
|
222
|
+
|
|
223
|
+
(Taken on an otherwise idle machine, load 2-3 on 20 threads. sklearn's
|
|
224
|
+
`LassoCV` at its default `precompute='auto'` takes the Gram route at
|
|
225
|
+
every size here, n > p, rebuilding each fold's Gram from that fold's
|
|
226
|
+
rows where we subtract it from the whole.) The grid
|
|
150
227
|
launch itself takes 9 ms at n=1,800 and 64 ms at n=5,000, p=1000; at
|
|
151
228
|
n=100,000 the CUDA column is nearly all Gram products, which the CPU
|
|
152
229
|
path pays as well. Large n is where the machinery pays either way:
|
|
153
230
|
nothing downstream of the Grams touches the 100,000 rows again. At
|
|
154
231
|
n=5,000, p=1000 sklearn's raw-X descent beats our Gram-heavy CPU path,
|
|
155
|
-
and the GPU makes up the difference
|
|
232
|
+
and the GPU makes up the difference three and a half times over. CPU and CUDA
|
|
156
233
|
choose the same alpha everywhere and agree to 1e-14; against sklearn
|
|
157
234
|
the pick is exact at n=5,000 and one grid step apart on a near-tie
|
|
158
235
|
elsewhere (mean-MSE relative gap at most 2.6e-4), and sklearn at
|
|
@@ -178,21 +255,24 @@ the CV pairs share the 100-alpha grid:
|
|
|
178
255
|
|
|
179
256
|
| estimator | n | p | ours | ours `n_jobs=5` | ours CUDA | sklearn |
|
|
180
257
|
|-----------------------|---------|-----|--------|-----------------|-----------|---------|
|
|
181
|
-
| MultiTaskLasso | 100,000 | 200 | 0.
|
|
182
|
-
| MultiTaskLasso | 100,000 | 500 | 0.53 s | -- | -- | 1.
|
|
183
|
-
| MultiTaskLassoCV | 1,800 | 200 | 1.
|
|
184
|
-
| MultiTaskLassoCV | 100,000 | 200 | 1.6 s | 0.
|
|
185
|
-
| MultiTaskElasticNetCV | 1,800 | 200 | 1.
|
|
186
|
-
| MultiTaskElasticNetCV | 100,000 | 200 | 1.6 s | 0.68 s | 1.
|
|
187
|
-
|
|
188
|
-
(`MultiTaskElasticNet` single fits time the same as `MultiTaskLasso`'s
|
|
189
|
-
|
|
190
|
-
above,
|
|
191
|
-
|
|
258
|
+
| MultiTaskLasso | 100,000 | 200 | 0.14 s | -- | -- | 0.76 s |
|
|
259
|
+
| MultiTaskLasso | 100,000 | 500 | 0.53 s | -- | -- | 1.7 s |
|
|
260
|
+
| MultiTaskLassoCV | 1,800 | 200 | 1.4 s | 0.51 s | 0.07 s | 0.42 s |
|
|
261
|
+
| MultiTaskLassoCV | 100,000 | 200 | 1.6 s | 0.64 s | 1.3 s | 74 s |
|
|
262
|
+
| MultiTaskElasticNetCV | 1,800 | 200 | 1.4 s | 0.49 s | 0.08 s | 0.75 s |
|
|
263
|
+
| MultiTaskElasticNetCV | 100,000 | 200 | 1.6 s | 0.68 s | 1.3 s | 162 s |
|
|
264
|
+
|
|
265
|
+
(`MultiTaskElasticNet` single fits time the same as `MultiTaskLasso`'s;
|
|
266
|
+
every column was taken in one sitting on the idle machine of the
|
|
267
|
+
`CovLassoCV` table above, sklearn 1.9.1. scikit-learn has no Gram
|
|
268
|
+
route for its multi-task estimators -- its path code sets
|
|
269
|
+
`precompute=False` for 2-D targets, "No Gram variant of multi-task
|
|
270
|
+
exists right now" -- so its column is raw-X descent at every n, which
|
|
271
|
+
is why it grows with n where `LassoCV`'s did not.) At n=1,800 our sequential CV
|
|
192
272
|
is slower than sklearn's: the default tol=1e-8 runs more sweeps than
|
|
193
273
|
sklearn's 1e-4 dual-gap stop, and at that size there are no rows to
|
|
194
274
|
save -- and the GPU grid takes the whole thing in a tenth of a second.
|
|
195
|
-
At n=100,000 the Gram route is
|
|
275
|
+
At n=100,000 the Gram route is 46x and 100x ahead, essentially
|
|
196
276
|
unchanged from n=1,800 in absolute time; the CUDA column there is
|
|
197
277
|
the Gram products, the same ones the CPU path forms. Coefficients agree with scikit-learn's to 1e-15 at tight
|
|
198
278
|
tolerance, and the CV pair picks the same alpha with coefficients to
|
|
@@ -294,12 +374,15 @@ neither installs the other.
|
|
|
294
374
|
## Where this comes from
|
|
295
375
|
|
|
296
376
|
The C++ this compiles is committed to `python/scm2cpp-lasso/` in the
|
|
297
|
-
scm2cpp repository
|
|
298
|
-
`examples/kernel-only/lasso-cov.scm
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
377
|
+
scm2cpp repository. `CovLasso` and the rest run on the translation of
|
|
378
|
+
`examples/kernel-only/lasso-cov.scm`, a covariance-update sweep
|
|
379
|
+
written by hand at the Gram level; `DerivedLasso` runs on the
|
|
380
|
+
translation of `examples/kernel-only/lasso-design.scm`, the naive
|
|
381
|
+
residual-form descent over X as numpy holds it, with the translator's `--derive --blas`
|
|
382
|
+
deriving the same covariance form from it. The two are the same
|
|
383
|
+
sweep over the same `dsyrk`, and they time the same. To refresh the
|
|
384
|
+
committed C++ after changing the Scheme, run `regenerate.sh` -- that
|
|
385
|
+
step, and only that step, needs Racket.
|
|
303
386
|
|
|
304
387
|
## License
|
|
305
388
|
|
|
@@ -71,12 +71,85 @@ at zero, and `lambda_grid()` walks down from it -- the construction
|
|
|
71
71
|
scikit-learn uses. If your columns differ wildly in scale, standardize
|
|
72
72
|
them first; this solver does not do it for you.
|
|
73
73
|
|
|
74
|
+
## Two kernels: `CovLasso` and `DerivedLasso`
|
|
75
|
+
|
|
76
|
+
Everything above, and the cross-validation, bootstrap and GPU methods
|
|
77
|
+
below, runs on one kernel: numpy forms the Gram matrix, and a
|
|
78
|
+
hand-written coordinate-descent sweep over it
|
|
79
|
+
(`examples/kernel-only/lasso-cov.scm` in the repository, translated to
|
|
80
|
+
C++) does the rest. That is the usable one.
|
|
81
|
+
|
|
82
|
+
`DerivedLasso` is the other kernel, and it is there to show the
|
|
83
|
+
translator's work rather than to be the better estimator. Its source
|
|
84
|
+
is `examples/kernel-only/lasso-design.scm`: the residual-form
|
|
85
|
+
coordinate descent -- the algorithm inside scikit-learn's
|
|
86
|
+
`Lasso(precompute=False)` -- written as flat loops over X exactly as
|
|
87
|
+
numpy holds it (n rows of p, `x[i*p+j]`), the residual copied from y
|
|
88
|
+
and the column norms taken inside, with no Gram matrix anywhere in
|
|
89
|
+
it. The translator's `--derive` turns
|
|
90
|
+
that into the Gram form: it raises the loops to an array algebra,
|
|
91
|
+
notices that the residual is only ever updated by a scaled column and
|
|
92
|
+
read in dot products against columns, replaces it by the memo `c =
|
|
93
|
+
X'r` kept current through a Gram matrix it hoists in front of the
|
|
94
|
+
penalty path, folds the column norms into that Gram's diagonal, folds
|
|
95
|
+
the three products into one `dsyrk` and two `dgemv` calls (`--blas`), and puts the whole thing under
|
|
96
|
+
`(if (< p n) ..)` with the sweep as written in the else branch --
|
|
97
|
+
scikit-learn's `precompute='auto'` rule, derived rather than written.
|
|
98
|
+
The C++ of that derivation, as the translator emitted it, is what
|
|
99
|
+
`DerivedLasso` calls; numpy prepares nothing -- X and y go in as they
|
|
100
|
+
are, no transposed copy, no norms.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from scm2cpp_lasso import DerivedLasso, derived_backend
|
|
104
|
+
|
|
105
|
+
model = DerivedLasso(X, y) # X and y; the kernel builds its own Gram
|
|
106
|
+
path = model.fit_path(model.lambda_grid()) # one call, the whole warm-started path
|
|
107
|
+
print(derived_backend()) # 'cublas', 'blas' or 'loops': what this machine built
|
|
108
|
+
gpu = DerivedLasso(X, y, backend="cublas") # insist on one
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
A lambda is done when no coefficient moved by more than `tol`
|
|
112
|
+
(absolute, default 1e-8) in a sweep, or at `max_sweeps`; `tol=0.0`
|
|
113
|
+
asks for the exact fixed point. Three translations of the one
|
|
114
|
+
derivation are committed and `pip` builds what the machine allows:
|
|
115
|
+
`--derive --cublas` where the CUDA toolkit is (the three products on
|
|
116
|
+
the device through cuBLAS, X uploaded once per `fit_path` call --
|
|
117
|
+
taken by default when a device answers), `--derive --blas` where
|
|
118
|
+
`cblas.h` and an OpenBLAS are, and `--derive` alone otherwise, the
|
|
119
|
+
same program with its Gram matrix as a scalar loop nest, correct but
|
|
120
|
+
slow on large designs. `check-derived.py` in the package directory runs
|
|
121
|
+
the two kernels and scikit-learn on the same path and prints the
|
|
122
|
+
differences and the times; on AR(1) designs (rho 0.9), 20 penalties
|
|
123
|
+
from lambda_max to 0.01 lambda_max, one core, best of three (load
|
|
124
|
+
about 3), whole calls with the Gram matrix wherever it is built:
|
|
125
|
+
|
|
126
|
+
| n x p | 1,800 x 200 | 5,000 x 1000 | 100,000 x 200 | 100,000 x 500 |
|
|
127
|
+
|-----------------------------------------|-------------|--------------|---------------|---------------|
|
|
128
|
+
| `DerivedLasso`, cuBLAS (upload included) | 0.003 s | 0.024 s | 0.052 s | 0.11 s |
|
|
129
|
+
| `DerivedLasso`, CBLAS | 0.003 s | 0.076 s | 0.12 s | 0.44 s |
|
|
130
|
+
| `CovLasso(X, y).fit_path`, tol 1e-8 | 0.004 s | 0.080 s | 0.11 s | 0.42 s |
|
|
131
|
+
| sklearn `lasso_path(precompute=True)`, tol 1e-4 | 0.005 s | 0.10 s | 0.24 s | 0.76 s |
|
|
132
|
+
|
|
133
|
+
The CBLAS run is the same `dsyrk` and the same sweeps as `CovLasso`'s
|
|
134
|
+
numpy Gram and path, and times the same; the cuBLAS run is the same
|
|
135
|
+
program with the three products on the device, the upload of X
|
|
136
|
+
counted, and is 4.2x, 4.5x and 6.7x ahead of scikit-learn at the
|
|
137
|
+
three larger shapes -- the figures of the repository's equal-work
|
|
138
|
+
table, now reached from the package with nothing in front of the
|
|
139
|
+
kernel: the constructor keeps X and y and the kernel takes the
|
|
140
|
+
residual and the column norms itself (the norms folded into the Gram
|
|
141
|
+
diagonal by the derivation). Against a reference driven to
|
|
142
|
+
tol=1e-12, `DerivedLasso` lands within 5e-8 on every backend (its
|
|
143
|
+
tolerance is absolute, on one sweep's largest move), `CovLasso`
|
|
144
|
+
within 2e-10 (it tests every 20 sweeps and overshoots), and
|
|
145
|
+
scikit-learn at its default within 1e-3.
|
|
146
|
+
|
|
74
147
|
## Against the other lassos pip can install
|
|
75
148
|
|
|
76
149
|
`bench/lasso-compare.py` in the repository runs the same
|
|
77
150
|
cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
|
|
78
151
|
p=200, n=1800 -- against every lasso a `pip install` produces:
|
|
79
|
-
scikit-learn 1.9.
|
|
152
|
+
scikit-learn 1.9.1, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
|
|
80
153
|
Every solver runs at its own default tolerance (ours at the tighter
|
|
81
154
|
1e-8), each gets one untimed warm-up fit, cuML's data is on the device
|
|
82
155
|
before the clock starts, and the last column is each solver's
|
|
@@ -85,12 +158,15 @@ answer would show as one:
|
|
|
85
158
|
|
|
86
159
|
| solver | time | objective gap |
|
|
87
160
|
|----------------------------------------|---------|---------------|
|
|
88
|
-
| scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.
|
|
89
|
-
| scm2cpp-lasso, GPU, one
|
|
90
|
-
| sklearn `Lasso.fit` per lambda |
|
|
91
|
-
| celer per lambda |
|
|
92
|
-
| skglm per lambda |
|
|
93
|
-
| cuML per lambda, GPU |
|
|
161
|
+
| scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.91 s | 0 |
|
|
162
|
+
| scm2cpp-lasso, GPU, one block/lambda | 0.044 s | 0 |
|
|
163
|
+
| sklearn `Lasso.fit` per lambda | 9.6 s | +1.6e-09 |
|
|
164
|
+
| celer per lambda | 13.5 s | 0 |
|
|
165
|
+
| skglm per lambda | 14.5 s | +2.8e-17 |
|
|
166
|
+
| cuML per lambda, GPU | 53.6 s | +9.1e-07 |
|
|
167
|
+
|
|
168
|
+
(One sitting on an otherwise idle machine, best of three runs; cuML
|
|
169
|
+
26.08 on CUDA 12.9.)
|
|
94
170
|
|
|
95
171
|
celer and skglm are built for very large sparse designs, where their
|
|
96
172
|
screening rules dominate; at this size they pay their setup per fit.
|
|
@@ -116,25 +192,26 @@ times the alternatives (one thread per cell, warm runs of several
|
|
|
116
192
|
alphas per thread or block) side by side, and the block per cell wins
|
|
117
193
|
at every size tried. The GPU is the default when a device answers
|
|
118
194
|
(`force_cpu` takes the warm CPU path). Measured at cv=5, 100 alphas,
|
|
119
|
-
one CPU core, sklearn 1.9.
|
|
195
|
+
one CPU core, sklearn 1.9.1, best of three runs, whole estimator
|
|
120
196
|
including the Grams:
|
|
121
197
|
|
|
122
198
|
| n | p | CPU | CUDA | sklearn `LassoCV` |
|
|
123
199
|
|---------|------|--------|--------|-------------------|
|
|
124
|
-
| 1,800 | 200 | 0.
|
|
125
|
-
| 5,000 | 1000 |
|
|
126
|
-
| 100,000 | 200 | 0.
|
|
127
|
-
| 100,000 | 500 |
|
|
128
|
-
|
|
129
|
-
(
|
|
130
|
-
|
|
131
|
-
|
|
200
|
+
| 1,800 | 200 | 0.21 s | 0.02 s | 0.10 s |
|
|
201
|
+
| 5,000 | 1000 | 1.48 s | 0.25 s | 0.90 s |
|
|
202
|
+
| 100,000 | 200 | 0.49 s | 0.34 s | 1.72 s |
|
|
203
|
+
| 100,000 | 500 | 1.32 s | 1.09 s | 4.82 s |
|
|
204
|
+
|
|
205
|
+
(Taken on an otherwise idle machine, load 2-3 on 20 threads. sklearn's
|
|
206
|
+
`LassoCV` at its default `precompute='auto'` takes the Gram route at
|
|
207
|
+
every size here, n > p, rebuilding each fold's Gram from that fold's
|
|
208
|
+
rows where we subtract it from the whole.) The grid
|
|
132
209
|
launch itself takes 9 ms at n=1,800 and 64 ms at n=5,000, p=1000; at
|
|
133
210
|
n=100,000 the CUDA column is nearly all Gram products, which the CPU
|
|
134
211
|
path pays as well. Large n is where the machinery pays either way:
|
|
135
212
|
nothing downstream of the Grams touches the 100,000 rows again. At
|
|
136
213
|
n=5,000, p=1000 sklearn's raw-X descent beats our Gram-heavy CPU path,
|
|
137
|
-
and the GPU makes up the difference
|
|
214
|
+
and the GPU makes up the difference three and a half times over. CPU and CUDA
|
|
138
215
|
choose the same alpha everywhere and agree to 1e-14; against sklearn
|
|
139
216
|
the pick is exact at n=5,000 and one grid step apart on a near-tie
|
|
140
217
|
elsewhere (mean-MSE relative gap at most 2.6e-4), and sklearn at
|
|
@@ -160,21 +237,24 @@ the CV pairs share the 100-alpha grid:
|
|
|
160
237
|
|
|
161
238
|
| estimator | n | p | ours | ours `n_jobs=5` | ours CUDA | sklearn |
|
|
162
239
|
|-----------------------|---------|-----|--------|-----------------|-----------|---------|
|
|
163
|
-
| MultiTaskLasso | 100,000 | 200 | 0.
|
|
164
|
-
| MultiTaskLasso | 100,000 | 500 | 0.53 s | -- | -- | 1.
|
|
165
|
-
| MultiTaskLassoCV | 1,800 | 200 | 1.
|
|
166
|
-
| MultiTaskLassoCV | 100,000 | 200 | 1.6 s | 0.
|
|
167
|
-
| MultiTaskElasticNetCV | 1,800 | 200 | 1.
|
|
168
|
-
| MultiTaskElasticNetCV | 100,000 | 200 | 1.6 s | 0.68 s | 1.
|
|
169
|
-
|
|
170
|
-
(`MultiTaskElasticNet` single fits time the same as `MultiTaskLasso`'s
|
|
171
|
-
|
|
172
|
-
above,
|
|
173
|
-
|
|
240
|
+
| MultiTaskLasso | 100,000 | 200 | 0.14 s | -- | -- | 0.76 s |
|
|
241
|
+
| MultiTaskLasso | 100,000 | 500 | 0.53 s | -- | -- | 1.7 s |
|
|
242
|
+
| MultiTaskLassoCV | 1,800 | 200 | 1.4 s | 0.51 s | 0.07 s | 0.42 s |
|
|
243
|
+
| MultiTaskLassoCV | 100,000 | 200 | 1.6 s | 0.64 s | 1.3 s | 74 s |
|
|
244
|
+
| MultiTaskElasticNetCV | 1,800 | 200 | 1.4 s | 0.49 s | 0.08 s | 0.75 s |
|
|
245
|
+
| MultiTaskElasticNetCV | 100,000 | 200 | 1.6 s | 0.68 s | 1.3 s | 162 s |
|
|
246
|
+
|
|
247
|
+
(`MultiTaskElasticNet` single fits time the same as `MultiTaskLasso`'s;
|
|
248
|
+
every column was taken in one sitting on the idle machine of the
|
|
249
|
+
`CovLassoCV` table above, sklearn 1.9.1. scikit-learn has no Gram
|
|
250
|
+
route for its multi-task estimators -- its path code sets
|
|
251
|
+
`precompute=False` for 2-D targets, "No Gram variant of multi-task
|
|
252
|
+
exists right now" -- so its column is raw-X descent at every n, which
|
|
253
|
+
is why it grows with n where `LassoCV`'s did not.) At n=1,800 our sequential CV
|
|
174
254
|
is slower than sklearn's: the default tol=1e-8 runs more sweeps than
|
|
175
255
|
sklearn's 1e-4 dual-gap stop, and at that size there are no rows to
|
|
176
256
|
save -- and the GPU grid takes the whole thing in a tenth of a second.
|
|
177
|
-
At n=100,000 the Gram route is
|
|
257
|
+
At n=100,000 the Gram route is 46x and 100x ahead, essentially
|
|
178
258
|
unchanged from n=1,800 in absolute time; the CUDA column there is
|
|
179
259
|
the Gram products, the same ones the CPU path forms. Coefficients agree with scikit-learn's to 1e-15 at tight
|
|
180
260
|
tolerance, and the CV pair picks the same alpha with coefficients to
|
|
@@ -276,12 +356,15 @@ neither installs the other.
|
|
|
276
356
|
## Where this comes from
|
|
277
357
|
|
|
278
358
|
The C++ this compiles is committed to `python/scm2cpp-lasso/` in the
|
|
279
|
-
scm2cpp repository
|
|
280
|
-
`examples/kernel-only/lasso-cov.scm
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
359
|
+
scm2cpp repository. `CovLasso` and the rest run on the translation of
|
|
360
|
+
`examples/kernel-only/lasso-cov.scm`, a covariance-update sweep
|
|
361
|
+
written by hand at the Gram level; `DerivedLasso` runs on the
|
|
362
|
+
translation of `examples/kernel-only/lasso-design.scm`, the naive
|
|
363
|
+
residual-form descent over X as numpy holds it, with the translator's `--derive --blas`
|
|
364
|
+
deriving the same covariance form from it. The two are the same
|
|
365
|
+
sweep over the same `dsyrk`, and they time the same. To refresh the
|
|
366
|
+
committed C++ after changing the Scheme, run `regenerate.sh` -- that
|
|
367
|
+
step, and only that step, needs Racket.
|
|
285
368
|
|
|
286
369
|
## License
|
|
287
370
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scm2cpp-lasso"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Lasso by covariance-update coordinate descent, translated from Scheme to C++ by scm2cpp; GPU batch solver included"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -31,4 +31,4 @@ package-dir = {"" = "src"}
|
|
|
31
31
|
where = ["src"]
|
|
32
32
|
|
|
33
33
|
[tool.setuptools.package-data]
|
|
34
|
-
scm2cpp_lasso = ["_generated/*.hpp", "_generated/*.cpp", "*.cu"]
|
|
34
|
+
scm2cpp_lasso = ["_generated/*.hpp", "_generated/*.cpp", "_generated/derived_*/*.hpp", "_generated/derived_*/*.cpp", "*.cu"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Build the translated kernel, and the CUDA path when nvcc is here.
|
|
2
|
+
|
|
3
|
+
The C++ this compiles is committed, not generated at install time, so
|
|
4
|
+
building the package needs a C++17 compiler and nothing else -- no
|
|
5
|
+
Racket, no scm2cpp. A CBLAS (cblas.h plus openblas) at install time
|
|
6
|
+
gives the derived kernel its BLAS variant; without one the loop
|
|
7
|
+
variant is built. regenerate.sh is how the committed sources are
|
|
8
|
+
refreshed, and it is the only thing that needs the translator.
|
|
9
|
+
|
|
10
|
+
The CUDA library is optional in the strongest sense: no nvcc, no
|
|
11
|
+
device, or a failed nvcc all leave a working CPU package behind, and
|
|
12
|
+
the batch path notices at import and falls back.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from setuptools import Extension, setup
|
|
21
|
+
from setuptools.command.build_ext import build_ext
|
|
22
|
+
|
|
23
|
+
HERE = Path(__file__).resolve().parent
|
|
24
|
+
# setuptools wants sources and include paths relative to this file and
|
|
25
|
+
# separated by forward slashes, on every platform
|
|
26
|
+
PKG_REL = "src/scm2cpp_lasso"
|
|
27
|
+
GEN_REL = PKG_REL + "/_generated"
|
|
28
|
+
PKG = HERE / "src" / "scm2cpp_lasso"
|
|
29
|
+
GEN = PKG / "_generated"
|
|
30
|
+
|
|
31
|
+
CXXFLAGS = ["-O2", "-std=c++17"] if os.name != "nt" else ["/O2", "/std:c++17"]
|
|
32
|
+
|
|
33
|
+
kernel = Extension(
|
|
34
|
+
"scm2cpp_lasso._lasso_cov",
|
|
35
|
+
sources=[GEN_REL + "/lasso_cov_capi.cpp"],
|
|
36
|
+
include_dirs=[GEN_REL],
|
|
37
|
+
extra_compile_args=CXXFLAGS,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cblas_flags():
|
|
42
|
+
"""How to compile and link against a CBLAS here, or None.
|
|
43
|
+
|
|
44
|
+
pkg-config for openblas where it answers, else the bare -lopenblas
|
|
45
|
+
if a test program compiles and links against it. The derived
|
|
46
|
+
kernel's BLAS variant needs cblas.h and a library; without them the
|
|
47
|
+
loop variant is built instead (same answers, the Gram as a scalar
|
|
48
|
+
loop nest).
|
|
49
|
+
"""
|
|
50
|
+
if os.name == "nt" or os.environ.get("SCM2CPP_NO_BLAS"):
|
|
51
|
+
return None
|
|
52
|
+
cxx = os.environ.get("CXX", "c++")
|
|
53
|
+
candidates = []
|
|
54
|
+
try:
|
|
55
|
+
pc = subprocess.run(["pkg-config", "--cflags", "--libs", "openblas"],
|
|
56
|
+
capture_output=True, text=True, check=True)
|
|
57
|
+
candidates.append(pc.stdout.split())
|
|
58
|
+
except (subprocess.CalledProcessError, OSError):
|
|
59
|
+
pass
|
|
60
|
+
candidates.append(["-lopenblas"])
|
|
61
|
+
probe = "#include <cblas.h>\nint main(){double a=1,b=0;" \
|
|
62
|
+
"cblas_dgemv(CblasRowMajor,CblasNoTrans,1,1,1.0,&a,1,&a,1,0.0,&b,1);" \
|
|
63
|
+
"return b==1?0:1;}\n"
|
|
64
|
+
for flags in candidates:
|
|
65
|
+
try:
|
|
66
|
+
r = subprocess.run([cxx, "-x", "c++", "-", "-o", os.devnull] + flags,
|
|
67
|
+
input=probe, capture_output=True, text=True)
|
|
68
|
+
except OSError:
|
|
69
|
+
return None
|
|
70
|
+
if r.returncode == 0:
|
|
71
|
+
return flags
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# The derived kernel: lasso-design.scm, the plain residual-form
|
|
76
|
+
# coordinate descent over X as numpy holds it, turned into the Gram form by the translator
|
|
77
|
+
# (--derive) with its products as CBLAS calls (--blas). Which variant
|
|
78
|
+
# is built is visible in the extension's name; _libfind reports it.
|
|
79
|
+
_blas = cblas_flags()
|
|
80
|
+
if _blas is not None:
|
|
81
|
+
derived = Extension(
|
|
82
|
+
"scm2cpp_lasso._lasso_derived_blas",
|
|
83
|
+
sources=[GEN_REL + "/derived_blas/lasso_design_capi.cpp"],
|
|
84
|
+
include_dirs=[GEN_REL + "/derived_blas", GEN_REL],
|
|
85
|
+
extra_compile_args=CXXFLAGS + [f for f in _blas if f.startswith("-I")
|
|
86
|
+
or f.startswith("-D")],
|
|
87
|
+
extra_link_args=[f for f in _blas if not f.startswith("-I")
|
|
88
|
+
and not f.startswith("-D")],
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
derived = Extension(
|
|
92
|
+
"scm2cpp_lasso._lasso_derived_loops",
|
|
93
|
+
sources=[GEN_REL + "/derived_loops/lasso_design_capi.cpp"],
|
|
94
|
+
include_dirs=[GEN_REL + "/derived_loops", GEN_REL],
|
|
95
|
+
extra_compile_args=(["-O3", "-std=c++17"] if os.name != "nt"
|
|
96
|
+
else ["/O2", "/std:c++17"]),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BuildWithCuda(build_ext):
|
|
101
|
+
def build_extensions(self):
|
|
102
|
+
# the kernel is C++ compiled through the usual machinery, so
|
|
103
|
+
# wheels and cross-compilation behave as they always do
|
|
104
|
+
super().build_extensions()
|
|
105
|
+
if os.environ.get("SCM2CPP_NO_CUDA"):
|
|
106
|
+
return
|
|
107
|
+
nvcc = shutil.which("nvcc")
|
|
108
|
+
if not nvcc:
|
|
109
|
+
return
|
|
110
|
+
out = Path(self.build_lib) / "scm2cpp_lasso" / "libscm2cpp_batch.so"
|
|
111
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
cmd = [nvcc, "-O2", "-std=c++17", "-shared",
|
|
113
|
+
"-Xcompiler", "-fPIC",
|
|
114
|
+
"-I", str(PKG), "-I", str(GEN),
|
|
115
|
+
str(PKG / "batch_capi.cu"), "-o", str(out),
|
|
116
|
+
"-Wno-deprecated-gpu-targets", "-diag-suppress", "174"]
|
|
117
|
+
try:
|
|
118
|
+
subprocess.run(cmd, check=True)
|
|
119
|
+
print("scm2cpp_lasso: built the CUDA batch path")
|
|
120
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
121
|
+
print(f"scm2cpp_lasso: no CUDA batch path ({exc}); "
|
|
122
|
+
"the CPU solver is unaffected", file=sys.stderr)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# The cuBLAS variant of the derived kernel: host C++ against the CUDA
|
|
126
|
+
# runtime and cuBLAS, so it builds wherever the toolkit's headers and
|
|
127
|
+
# libraries are, device or not; DerivedLasso uses it only when a device
|
|
128
|
+
# answers. Skipped silently without the toolkit.
|
|
129
|
+
def cuda_home():
|
|
130
|
+
if os.environ.get("SCM2CPP_NO_CUDA"):
|
|
131
|
+
return None
|
|
132
|
+
nvcc = shutil.which("nvcc")
|
|
133
|
+
if not nvcc:
|
|
134
|
+
return None
|
|
135
|
+
home = Path(nvcc).resolve().parent.parent
|
|
136
|
+
return home if (home / "include" / "cublas_v2.h").exists() else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_cuda = cuda_home()
|
|
140
|
+
extensions = [kernel, derived]
|
|
141
|
+
if _cuda is not None:
|
|
142
|
+
extensions.append(Extension(
|
|
143
|
+
"scm2cpp_lasso._lasso_derived_cublas",
|
|
144
|
+
sources=[GEN_REL + "/derived_cublas/lasso_design_capi.cpp"],
|
|
145
|
+
include_dirs=[GEN_REL + "/derived_cublas", GEN_REL, str(_cuda / "include")],
|
|
146
|
+
library_dirs=[str(_cuda / "lib64"), str(_cuda / "lib")],
|
|
147
|
+
libraries=["cublas", "cudart"],
|
|
148
|
+
extra_compile_args=CXXFLAGS,
|
|
149
|
+
))
|
|
150
|
+
|
|
151
|
+
setup(ext_modules=extensions, cmdclass={"build_ext": BuildWithCuda})
|