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