scm2cpp-lasso 0.5.3.1__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.8.0/PKG-INFO +389 -0
- scm2cpp_lasso-0.8.0/README.md +371 -0
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/pyproject.toml +2 -2
- scm2cpp_lasso-0.8.0/setup.py +151 -0
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/__init__.py +464 -72
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/_loader.py +8 -26
- 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.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/lasso_cov.cpp +0 -2
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/_generated/lasso_cov.hpp +152 -0
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/lasso_cov_capi.cpp +3 -9
- 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.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_libfind.py +29 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso/batch_capi.cu +428 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso.egg-info/PKG-INFO +389 -0
- scm2cpp_lasso-0.8.0/src/scm2cpp_lasso.egg-info/SOURCES.txt +31 -0
- scm2cpp_lasso-0.5.3.1/PKG-INFO +0 -244
- scm2cpp_lasso-0.5.3.1/README.md +0 -226
- scm2cpp_lasso-0.5.3.1/setup.py +0 -63
- scm2cpp_lasso-0.5.3.1/src/scm2cpp_lasso/_generated/lasso_cov.hpp +0 -114
- scm2cpp_lasso-0.5.3.1/src/scm2cpp_lasso/batch_capi.cu +0 -80
- scm2cpp_lasso-0.5.3.1/src/scm2cpp_lasso.egg-info/PKG-INFO +0 -244
- scm2cpp_lasso-0.5.3.1/src/scm2cpp_lasso.egg-info/SOURCES.txt +0 -17
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/setup.cfg +0 -0
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/__init__.py +0 -0
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso/_generated/scm2cpp.hpp +0 -0
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/dependency_links.txt +0 -0
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/requires.txt +0 -0
- {scm2cpp_lasso-0.5.3.1 → scm2cpp_lasso-0.8.0}/src/scm2cpp_lasso.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scm2cpp-lasso
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Lasso by covariance-update coordinate descent, 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,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). 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
|
+
|
|
165
|
+
## Against the other lassos pip can install
|
|
166
|
+
|
|
167
|
+
`bench/lasso-compare.py` in the repository runs the same
|
|
168
|
+
cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
|
|
169
|
+
p=200, n=1800 -- against every lasso a `pip install` produces:
|
|
170
|
+
scikit-learn 1.9.1, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
|
|
171
|
+
Every solver runs at its own default tolerance (ours at the tighter
|
|
172
|
+
1e-8), each gets one untimed warm-up fit, cuML's data is on the device
|
|
173
|
+
before the clock starts, and the last column is each solver's
|
|
174
|
+
objective minus ours at the smallest lambda -- a fast row with a loose
|
|
175
|
+
answer would show as one:
|
|
176
|
+
|
|
177
|
+
| solver | time | objective gap |
|
|
178
|
+
|----------------------------------------|---------|---------------|
|
|
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.)
|
|
188
|
+
|
|
189
|
+
celer and skglm are built for very large sparse designs, where their
|
|
190
|
+
screening rules dominate; at this size they pay their setup per fit.
|
|
191
|
+
cuML parallelises *within* one fit, which wins when a fit is large; at
|
|
192
|
+
this size its launch overhead dominates, while our GPU row
|
|
193
|
+
parallelises *across* lambdas -- one block of CUDA threads per
|
|
194
|
+
penalty, the axis a cross-validation grid actually offers.
|
|
195
|
+
|
|
196
|
+
`CovLassoCV` is scikit-learn's `LassoCV` over this machinery -- same
|
|
197
|
+
grid construction, same contiguous folds, same minimum-mean-MSE choice
|
|
198
|
+
-- with two structural savings. A fold's training Gram is a
|
|
199
|
+
*subtraction* (the Gram is additive over rows, so G - Xf'Xf costs one
|
|
200
|
+
fold's work where rebuilding costs four folds'), and once the Grams
|
|
201
|
+
exist nothing downstream ever touches the n rows again -- which is why
|
|
202
|
+
the gap grows with n. With a GPU the whole grid is one launch: one
|
|
203
|
+
*block* of threads per (fold, alpha) cell, the fold's Gram read in
|
|
204
|
+
place (cv Gram matrices on the device, not cv x 100), thread 0 of the
|
|
205
|
+
block doing the coordinate step and the block spreading the O(p)
|
|
206
|
+
update of the correlations. cv x 100 = 500 cells are too few
|
|
207
|
+
problems to fill a device one thread each, and a block per problem is
|
|
208
|
+
what turns that count into enough parallel work; `bench/cv-grid-designs.py`
|
|
209
|
+
times the alternatives (one thread per cell, warm runs of several
|
|
210
|
+
alphas per thread or block) side by side, and the block per cell wins
|
|
211
|
+
at every size tried. The GPU is the default when a device answers
|
|
212
|
+
(`force_cpu` takes the warm CPU path). Measured at cv=5, 100 alphas,
|
|
213
|
+
one CPU core, sklearn 1.9.1, best of three runs, whole estimator
|
|
214
|
+
including the Grams:
|
|
215
|
+
|
|
216
|
+
| n | p | CPU | CUDA | sklearn `LassoCV` |
|
|
217
|
+
|---------|------|--------|--------|-------------------|
|
|
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
|
|
227
|
+
launch itself takes 9 ms at n=1,800 and 64 ms at n=5,000, p=1000; at
|
|
228
|
+
n=100,000 the CUDA column is nearly all Gram products, which the CPU
|
|
229
|
+
path pays as well. Large n is where the machinery pays either way:
|
|
230
|
+
nothing downstream of the Grams touches the 100,000 rows again. At
|
|
231
|
+
n=5,000, p=1000 sklearn's raw-X descent beats our Gram-heavy CPU path,
|
|
232
|
+
and the GPU makes up the difference three and a half times over. CPU and CUDA
|
|
233
|
+
choose the same alpha everywhere and agree to 1e-14; against sklearn
|
|
234
|
+
the pick is exact at n=5,000 and one grid step apart on a near-tie
|
|
235
|
+
elsewhere (mean-MSE relative gap at most 2.6e-4), and sklearn at
|
|
236
|
+
tol=1e-10 picks our alpha at every size.
|
|
237
|
+
|
|
238
|
+
`CovMultiTaskLasso` and `CovMultiTaskLassoCV` are the multi-task
|
|
239
|
+
family over the same machinery: scikit-learn's `MultiTaskLasso`,
|
|
240
|
+
`MultiTaskElasticNet` (through `l1_ratio`), `MultiTaskLassoCV` and
|
|
241
|
+
`MultiTaskElasticNetCV`. The penalty ties each feature's row of W
|
|
242
|
+
together across tasks, so the coordinate update becomes a block soft
|
|
243
|
+
threshold on the row's L2 norm; C = X'Y - GW is maintained per task
|
|
244
|
+
exactly as the single-task C is, and nothing downstream of the Grams
|
|
245
|
+
touches the n rows -- the kernel is the same translated Scheme
|
|
246
|
+
(`mt-descend`). Folds are independent and both the descent (ctypes)
|
|
247
|
+
and the products (BLAS) release the GIL, so `n_jobs` threads scale the
|
|
248
|
+
CV across folds; the default is sequential, as scikit-learn's
|
|
249
|
+
`n_jobs=None` is, and the benchmark pins BLAS to one thread on both
|
|
250
|
+
sides either way. With a GPU the CV grid is one launch exactly as
|
|
251
|
+
`CovLassoCV`'s is, a block per (fold, alpha) cell with the p x T
|
|
252
|
+
coefficient block in shared memory (`force_cpu` opts out). Same
|
|
253
|
+
protocol as above, 8 tasks; single fits at alpha = 0.1 lambda_max;
|
|
254
|
+
the CV pairs share the 100-alpha grid:
|
|
255
|
+
|
|
256
|
+
| estimator | n | p | ours | ours `n_jobs=5` | ours CUDA | sklearn |
|
|
257
|
+
|-----------------------|---------|-----|--------|-----------------|-----------|---------|
|
|
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
|
|
272
|
+
is slower than sklearn's: the default tol=1e-8 runs more sweeps than
|
|
273
|
+
sklearn's 1e-4 dual-gap stop, and at that size there are no rows to
|
|
274
|
+
save -- and the GPU grid takes the whole thing in a tenth of a second.
|
|
275
|
+
At n=100,000 the Gram route is 46x and 100x ahead, essentially
|
|
276
|
+
unchanged from n=1,800 in absolute time; the CUDA column there is
|
|
277
|
+
the Gram products, the same ones the CPU path forms. Coefficients agree with scikit-learn's to 1e-15 at tight
|
|
278
|
+
tolerance, and the CV pair picks the same alpha with coefficients to
|
|
279
|
+
1e-13.
|
|
280
|
+
|
|
281
|
+
## Which method
|
|
282
|
+
|
|
283
|
+
`fit_path` walks a single path, each lambda starting from the previous
|
|
284
|
+
solution -- the descent is exactly resumable, so this costs almost
|
|
285
|
+
nothing per lambda after the first. `fit_path_batch` solves every
|
|
286
|
+
lambda from zero, which is what a cross-validation grid needs, where
|
|
287
|
+
the folds differ and warm starting across lambdas is not on offer; the
|
|
288
|
+
problems are independent, so they go to the GPU together.
|
|
289
|
+
|
|
290
|
+
Over a 400-lambda path at p=200 and 1800 rows, on an RTX 4090 and an
|
|
291
|
+
i9-10900X:
|
|
292
|
+
|
|
293
|
+
| call | time |
|
|
294
|
+
|---------------------------------------|---------|
|
|
295
|
+
| `fit_path` (warm, sequential) | 0.091 s |
|
|
296
|
+
| `fit_path_batch` (GPU) | 0.047 s |
|
|
297
|
+
| `fit_path_batch(force_cpu=True)` | 0.178 s |
|
|
298
|
+
|
|
299
|
+
GPU and CPU agree to 2e-14, and the objectives are within 3e-17 of
|
|
300
|
+
scikit-learn's `lasso_path` on the same grid.
|
|
301
|
+
|
|
302
|
+
## Elastic net and ridge
|
|
303
|
+
|
|
304
|
+
The same Gram matrix serves two more estimators. `fit_path` and
|
|
305
|
+
`fit_path_batch` take `l1_ratio` (scikit-learn's mixing parameter):
|
|
306
|
+
the L2 share of the penalty enters only the update's denominator, so
|
|
307
|
+
the elastic net runs on the identical machinery, GPU path included,
|
|
308
|
+
and `l1_ratio=1` is bit-for-bit the lasso.
|
|
309
|
+
|
|
310
|
+
`CovRidge` is the closed form: one symmetric eigendecomposition,
|
|
311
|
+
then every alpha costs O(p^2), so thousands of alphas cost what one
|
|
312
|
+
does. Its objective matches scikit-learn's `Ridge` with
|
|
313
|
+
`fit_intercept=False` (which, unlike the lasso, is not scaled by the
|
|
314
|
+
number of rows), and agrees with it to machine precision.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
path = model.fit_path(lambdas, l1_ratio=0.5) # elastic net
|
|
318
|
+
ridge = CovRidge(X, y)
|
|
319
|
+
betas = ridge.fit_path(ridge.alpha_grid()) # a whole ridge path
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Logistic regression with L1
|
|
323
|
+
|
|
324
|
+
`CovLogistic` solves L1-penalized logistic regression by
|
|
325
|
+
majorization: the logistic Hessian is bounded by X'X/4, so the
|
|
326
|
+
quadratic term is the same Gram matrix, fixed once, and each outer
|
|
327
|
+
round costs one gradient pass before handing the majorizer to the
|
|
328
|
+
same coordinate descent the lasso uses. Objectives agree with
|
|
329
|
+
scikit-learn's `LogisticRegression(penalty="l1",
|
|
330
|
+
fit_intercept=False)` at `C = 1/(n lam)` to 9e-15.
|
|
331
|
+
|
|
332
|
+
## Group lasso
|
|
333
|
+
|
|
334
|
+
`CovGroupLasso` penalizes whole groups -- `lam * sum_g sqrt(|g|)
|
|
335
|
+
||b_g||` -- so correlated features enter or leave together. Block
|
|
336
|
+
coordinate descent on the same Gram machinery: each block visit is
|
|
337
|
+
one majorized proximal step (the group's Gram block dominated by its
|
|
338
|
+
top eigenvalue, found once), descending monotonically. With size-one
|
|
339
|
+
groups it reduces exactly to the lasso -- verified against sklearn to
|
|
340
|
+
9e-16 -- and at convergence the group KKT conditions hold to 2e-12.
|
|
341
|
+
|
|
342
|
+
## Bootstrap on the GPU
|
|
343
|
+
|
|
344
|
+
`bootstrap` draws pairs-bootstrap resamples and refits them all at
|
|
345
|
+
one lambda. Each resample's Gram matrix is `X' diag(m) X` for its
|
|
346
|
+
multiplicity counts `m`, written as `Xs' Xs` with `Xs = diag(sqrt m) X`
|
|
347
|
+
so that numpy hands it to the symmetric rank-k product (`dsyrk`,
|
|
348
|
+
half the flops of `X' (diag(m) X)`) -- one BLAS call -- and because
|
|
349
|
+
the problems are independent, the descents run as one batch: on the
|
|
350
|
+
GPU, one block of threads per resample, each reading its own Gram
|
|
351
|
+
matrix. The products are the cost: at n=5000, p=1000 a hundred
|
|
352
|
+
resamples took 18.1 s as general products and 11.2 s as `dsyrk`,
|
|
353
|
+
descents included.
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
betas = model.bootstrap(lam, n_boot=500, seed=0) # (500, p)
|
|
357
|
+
freq = (abs(betas) > 1e-9).mean(axis=0) # selection frequency
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Requires constructing the model from `X, y` (a Gram matrix alone
|
|
361
|
+
cannot be resampled by rows). GPU and CPU agree to machine
|
|
362
|
+
precision.
|
|
363
|
+
|
|
364
|
+
## A design with structure
|
|
365
|
+
|
|
366
|
+
When the design matrix has structure, forming X'X the general way is
|
|
367
|
+
the wrong move. `kernel` exposes the translated functions directly,
|
|
368
|
+
and [`scm2cpp-tfs`](https://pypi.org/project/scm2cpp-tfs/) does exactly
|
|
369
|
+
that for moving-average designs: it builds the Gram matrix from a
|
|
370
|
+
series' prefix sums in O(n p) time, never forming the design. That
|
|
371
|
+
package stands alone -- it carries its own copy of this descent -- so
|
|
372
|
+
neither installs the other.
|
|
373
|
+
|
|
374
|
+
## Where this comes from
|
|
375
|
+
|
|
376
|
+
The C++ this compiles is committed to `python/scm2cpp-lasso/` in the
|
|
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.
|
|
386
|
+
|
|
387
|
+
## License
|
|
388
|
+
|
|
389
|
+
MIT, the same as scm2cpp.
|