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.
Files changed (36) hide show
  1. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/PKG-INFO +119 -36
  2. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/README.md +118 -35
  3. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/pyproject.toml +2 -2
  4. scm2cpp_lasso-0.8.0/setup.py +151 -0
  5. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/__init__.py +122 -4
  6. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/_loader_derived_blas.py +29 -0
  7. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/_loader_derived_cublas.py +29 -0
  8. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/_loader_derived_loops.py +29 -0
  9. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_blas/lasso_design.cpp +5 -0
  10. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_blas/lasso_design.hpp +160 -0
  11. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_blas/lasso_design_capi.cpp +19 -0
  12. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_cublas/lasso_design.cpp +5 -0
  13. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_cublas/lasso_design.hpp +162 -0
  14. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_cublas/lasso_design_capi.cpp +19 -0
  15. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_loops/lasso_design.cpp +5 -0
  16. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_loops/lasso_design.hpp +178 -0
  17. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/derived_loops/lasso_design_capi.cpp +19 -0
  18. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/lasso_cov.hpp +152 -0
  19. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/scm2cpp-blas.hpp +67 -0
  20. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/scm2cpp-cublas.hpp +155 -0
  21. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_libfind.py +18 -0
  22. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/PKG-INFO +119 -36
  23. scm2cpp_lasso-0.8.0/src/scm2cpp_lasso.egg-info/SOURCES.txt +31 -0
  24. scm2cpp_lasso-0.7.0/setup.py +0 -63
  25. scm2cpp_lasso-0.7.0/src/scm2cpp_lasso/_generated/lasso_cov.hpp +0 -93
  26. scm2cpp_lasso-0.7.0/src/scm2cpp_lasso.egg-info/SOURCES.txt +0 -17
  27. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/setup.cfg +0 -0
  28. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/__init__.py +0 -0
  29. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/_loader.py +0 -0
  30. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/lasso_cov.cpp +0 -0
  31. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/lasso_cov_capi.cpp +0 -0
  32. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/scm2cpp.hpp +0 -0
  33. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/batch_capi.cu +0 -0
  34. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/dependency_links.txt +0 -0
  35. {scm2cpp_lasso-0.7.0 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/requires.txt +0 -0
  36. {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.7.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.0, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
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.9 s | 0 |
107
- | scm2cpp-lasso, GPU, one thread/lambda | 0.2 s | 0 |
108
- | sklearn `Lasso.fit` per lambda | 12.5 s | +1.6e-09 |
109
- | celer per lambda | 17.3 s | 0 |
110
- | skglm per lambda | 16.4 s | +2.8e-17 |
111
- | cuML per lambda, GPU | 57.5 s | +9.1e-07 |
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.0, best of three runs, whole estimator
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.36 s | 0.03 s | 0.23 s |
143
- | 5,000 | 1000 | 3.9 s | 0.39 s | 2.7 s |
144
- | 100,000 | 200 | 0.75 s | 0.60 s | 3.9 s |
145
- | 100,000 | 500 | 2.0 s | 1.8 s | 12.6 s |
146
-
147
- (This table was taken on a machine sharing its cores with other jobs,
148
- load 14 on 20 threads, so its absolute times run some 2-3x above the
149
- tables earlier in this README; the ratios are the point.) The grid
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 seven times over. CPU and CUDA
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.15 s | -- | -- | 0.80 s |
182
- | MultiTaskLasso | 100,000 | 500 | 0.53 s | -- | -- | 1.9 s |
183
- | MultiTaskLassoCV | 1,800 | 200 | 1.5 s | 0.33 s | 0.08 s | 0.51 s |
184
- | MultiTaskLassoCV | 100,000 | 200 | 1.6 s | 0.62 s | 1.7 s | 76 s |
185
- | MultiTaskElasticNetCV | 1,800 | 200 | 1.6 s | 0.51 s | 0.10 s | 1.0 s |
186
- | MultiTaskElasticNetCV | 100,000 | 200 | 1.6 s | 0.68 s | 1.9 s | 164 s |
187
-
188
- (`MultiTaskElasticNet` single fits time the same as `MultiTaskLasso`'s.
189
- The CUDA column was timed on the busier day of the `CovLassoCV` table
190
- above, when the sequential CPU rows read 1.5 / 2.0 / 1.6 / 2.1 s and
191
- sklearn's 0.56 / 114 / 0.99 / 241 s.) At n=1,800 our sequential CV
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 48x and 100x ahead, essentially
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, translated from
298
- `examples/kernel-only/lasso-cov.scm`. That repository also derives the
299
- covariance-update solver automatically from a naive one by finite
300
- differencing; this package is the derived kernel, packaged. To refresh
301
- the committed C++ after changing the Scheme, run `regenerate.sh` --
302
- that step, and only that step, needs Racket.
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.0, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
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.9 s | 0 |
89
- | scm2cpp-lasso, GPU, one thread/lambda | 0.2 s | 0 |
90
- | sklearn `Lasso.fit` per lambda | 12.5 s | +1.6e-09 |
91
- | celer per lambda | 17.3 s | 0 |
92
- | skglm per lambda | 16.4 s | +2.8e-17 |
93
- | cuML per lambda, GPU | 57.5 s | +9.1e-07 |
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.0, best of three runs, whole estimator
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.36 s | 0.03 s | 0.23 s |
125
- | 5,000 | 1000 | 3.9 s | 0.39 s | 2.7 s |
126
- | 100,000 | 200 | 0.75 s | 0.60 s | 3.9 s |
127
- | 100,000 | 500 | 2.0 s | 1.8 s | 12.6 s |
128
-
129
- (This table was taken on a machine sharing its cores with other jobs,
130
- load 14 on 20 threads, so its absolute times run some 2-3x above the
131
- tables earlier in this README; the ratios are the point.) The grid
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 seven times over. CPU and CUDA
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.15 s | -- | -- | 0.80 s |
164
- | MultiTaskLasso | 100,000 | 500 | 0.53 s | -- | -- | 1.9 s |
165
- | MultiTaskLassoCV | 1,800 | 200 | 1.5 s | 0.33 s | 0.08 s | 0.51 s |
166
- | MultiTaskLassoCV | 100,000 | 200 | 1.6 s | 0.62 s | 1.7 s | 76 s |
167
- | MultiTaskElasticNetCV | 1,800 | 200 | 1.6 s | 0.51 s | 0.10 s | 1.0 s |
168
- | MultiTaskElasticNetCV | 100,000 | 200 | 1.6 s | 0.68 s | 1.9 s | 164 s |
169
-
170
- (`MultiTaskElasticNet` single fits time the same as `MultiTaskLasso`'s.
171
- The CUDA column was timed on the busier day of the `CovLassoCV` table
172
- above, when the sequential CPU rows read 1.5 / 2.0 / 1.6 / 2.1 s and
173
- sklearn's 0.56 / 114 / 0.99 / 241 s.) At n=1,800 our sequential CV
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 48x and 100x ahead, essentially
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, translated from
280
- `examples/kernel-only/lasso-cov.scm`. That repository also derives the
281
- covariance-update solver automatically from a naive one by finite
282
- differencing; this package is the derived kernel, packaged. To refresh
283
- the committed C++ after changing the Scheme, run `regenerate.sh` --
284
- that step, and only that step, needs Racket.
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.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})