scm2cpp-lasso 0.5.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.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: scm2cpp-lasso
3
+ Version: 0.5.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
+ ```python
36
+ import numpy as np
37
+ from scm2cpp_lasso import CovLasso, cuda_available
38
+
39
+ model = CovLasso(X, y) # forms X'X and X'y
40
+ lambdas = model.lambda_grid(num=100) # from lambda_max down
41
+
42
+ path = model.fit_path(lambdas) # warm-started, sequential
43
+ grid = model.fit_path_batch(lambdas) # every lambda from zero
44
+ print("GPU:", cuda_available())
45
+ ```
46
+
47
+ The objective is scikit-learn's, with `fit_intercept=False`:
48
+
49
+ (1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
50
+
51
+ Penalties are compared against correlations on the features' own
52
+ scale, so the useful range of `lam` depends on the data.
53
+ `lambda_max()` is the smallest penalty that leaves every coefficient
54
+ at zero, and `lambda_grid()` walks down from it -- the construction
55
+ scikit-learn uses. If your columns differ wildly in scale, standardize
56
+ them first; this solver does not do it for you.
57
+
58
+ ## Which method
59
+
60
+ `fit_path` walks a single path, each lambda starting from the previous
61
+ solution -- the descent is exactly resumable, so this costs almost
62
+ nothing per lambda after the first. `fit_path_batch` solves every
63
+ lambda from zero, which is what a cross-validation grid needs, where
64
+ the folds differ and warm starting across lambdas is not on offer; the
65
+ problems are independent, so they go to the GPU together.
66
+
67
+ Over a 400-lambda path at p=200 and 1800 rows, on an RTX 4090 and an
68
+ i9-10900X:
69
+
70
+ | call | time |
71
+ |---------------------------------------|---------|
72
+ | `fit_path` (warm, sequential) | 0.091 s |
73
+ | `fit_path_batch` (GPU) | 0.047 s |
74
+ | `fit_path_batch(force_cpu=True)` | 0.178 s |
75
+
76
+ GPU and CPU agree to 2e-14, and the objectives are within 3e-17 of
77
+ scikit-learn's `lasso_path` on the same grid.
78
+
79
+ ## Elastic net and ridge
80
+
81
+ The same Gram matrix serves two more estimators. `fit_path` and
82
+ `fit_path_batch` take `l1_ratio` (scikit-learn's mixing parameter):
83
+ the L2 share of the penalty enters only the update's denominator, so
84
+ the elastic net runs on the identical machinery, GPU path included,
85
+ and `l1_ratio=1` is bit-for-bit the lasso.
86
+
87
+ `CovRidge` is the closed form: one symmetric eigendecomposition,
88
+ then every alpha costs O(p^2), so thousands of alphas cost what one
89
+ does. Its objective matches scikit-learn's `Ridge` with
90
+ `fit_intercept=False` (which, unlike the lasso, is not scaled by the
91
+ number of rows), and agrees with it to machine precision.
92
+
93
+ ```python
94
+ path = model.fit_path(lambdas, l1_ratio=0.5) # elastic net
95
+ ridge = CovRidge(X, y)
96
+ betas = ridge.fit_path(ridge.alpha_grid()) # a whole ridge path
97
+ ```
98
+
99
+ ## Logistic regression with L1
100
+
101
+ `CovLogistic` solves L1-penalized logistic regression by
102
+ majorization: the logistic Hessian is bounded by X'X/4, so the
103
+ quadratic term is the same Gram matrix, fixed once, and each outer
104
+ round costs one gradient pass before handing the majorizer to the
105
+ same coordinate descent the lasso uses. Objectives agree with
106
+ scikit-learn's `LogisticRegression(penalty="l1",
107
+ fit_intercept=False)` at `C = 1/(n lam)` to 9e-15.
108
+
109
+ ## Group lasso
110
+
111
+ `CovGroupLasso` penalizes whole groups -- `lam * sum_g sqrt(|g|)
112
+ ||b_g||` -- so correlated features enter or leave together. Block
113
+ coordinate descent on the same Gram machinery: each block visit is
114
+ one majorized proximal step (the group's Gram block dominated by its
115
+ top eigenvalue, found once), descending monotonically. With size-one
116
+ groups it reduces exactly to the lasso -- verified against sklearn to
117
+ 9e-16 -- and at convergence the group KKT conditions hold to 2e-12.
118
+
119
+ ## Bootstrap on the GPU
120
+
121
+ `bootstrap` draws pairs-bootstrap resamples and refits them all at
122
+ one lambda. Each resample's Gram matrix is `X' diag(m) X` for its
123
+ multiplicity counts `m` -- one BLAS product -- and because the
124
+ problems are independent, the descents run as one batch: on the GPU,
125
+ one thread per resample, each reading its own Gram matrix.
126
+
127
+ ```python
128
+ betas = model.bootstrap(lam, n_boot=500, seed=0) # (500, p)
129
+ freq = (abs(betas) > 1e-9).mean(axis=0) # selection frequency
130
+ ```
131
+
132
+ Requires constructing the model from `X, y` (a Gram matrix alone
133
+ cannot be resampled by rows). GPU and CPU agree to machine
134
+ precision.
135
+
136
+ ## A design with structure
137
+
138
+ When the design matrix has structure, forming X'X the general way is
139
+ the wrong move. `kernel` exposes the translated functions directly,
140
+ and [`scm2cpp-tfs`](https://pypi.org/project/scm2cpp-tfs/) does exactly
141
+ that for moving-average designs: it builds the Gram matrix from a
142
+ series' prefix sums in O(n p) time, never forming the design. That
143
+ package stands alone -- it carries its own copy of this descent -- so
144
+ neither installs the other.
145
+
146
+ ## Where this comes from
147
+
148
+ The C++ this compiles is committed to `python/scm2cpp-lasso/` in the
149
+ scm2cpp repository, translated from
150
+ `examples/kernel-only/lasso-cov.scm`. That repository also derives the
151
+ covariance-update solver automatically from a naive one by finite
152
+ differencing; this package is the derived kernel, packaged. To refresh
153
+ the committed C++ after changing the Scheme, run `regenerate.sh` --
154
+ that step, and only that step, needs Racket.
155
+
156
+ ## License
157
+
158
+ MIT, the same as scm2cpp.
@@ -0,0 +1,140 @@
1
+ # scm2cpp-lasso
2
+
3
+ *[Japanese (README.ja.md)](README.ja.md)*
4
+
5
+ Lasso by coordinate descent over a Gram matrix, with an optional GPU
6
+ path. The solver is Scheme, translated to C++ by
7
+ [scm2cpp](https://github.com/niitsuma/scm2cpp).
8
+
9
+ ```console
10
+ $ pip install scm2cpp-lasso
11
+ ```
12
+
13
+ Installing needs a C++17 compiler and nothing else. If `nvcc` is on
14
+ the path, the batched GPU solver is built as well; if it is not, the
15
+ package installs and works exactly the same, minus that one method.
16
+
17
+ ```python
18
+ import numpy as np
19
+ from scm2cpp_lasso import CovLasso, cuda_available
20
+
21
+ model = CovLasso(X, y) # forms X'X and X'y
22
+ lambdas = model.lambda_grid(num=100) # from lambda_max down
23
+
24
+ path = model.fit_path(lambdas) # warm-started, sequential
25
+ grid = model.fit_path_batch(lambdas) # every lambda from zero
26
+ print("GPU:", cuda_available())
27
+ ```
28
+
29
+ The objective is scikit-learn's, with `fit_intercept=False`:
30
+
31
+ (1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
32
+
33
+ Penalties are compared against correlations on the features' own
34
+ scale, so the useful range of `lam` depends on the data.
35
+ `lambda_max()` is the smallest penalty that leaves every coefficient
36
+ at zero, and `lambda_grid()` walks down from it -- the construction
37
+ scikit-learn uses. If your columns differ wildly in scale, standardize
38
+ them first; this solver does not do it for you.
39
+
40
+ ## Which method
41
+
42
+ `fit_path` walks a single path, each lambda starting from the previous
43
+ solution -- the descent is exactly resumable, so this costs almost
44
+ nothing per lambda after the first. `fit_path_batch` solves every
45
+ lambda from zero, which is what a cross-validation grid needs, where
46
+ the folds differ and warm starting across lambdas is not on offer; the
47
+ problems are independent, so they go to the GPU together.
48
+
49
+ Over a 400-lambda path at p=200 and 1800 rows, on an RTX 4090 and an
50
+ i9-10900X:
51
+
52
+ | call | time |
53
+ |---------------------------------------|---------|
54
+ | `fit_path` (warm, sequential) | 0.091 s |
55
+ | `fit_path_batch` (GPU) | 0.047 s |
56
+ | `fit_path_batch(force_cpu=True)` | 0.178 s |
57
+
58
+ GPU and CPU agree to 2e-14, and the objectives are within 3e-17 of
59
+ scikit-learn's `lasso_path` on the same grid.
60
+
61
+ ## Elastic net and ridge
62
+
63
+ The same Gram matrix serves two more estimators. `fit_path` and
64
+ `fit_path_batch` take `l1_ratio` (scikit-learn's mixing parameter):
65
+ the L2 share of the penalty enters only the update's denominator, so
66
+ the elastic net runs on the identical machinery, GPU path included,
67
+ and `l1_ratio=1` is bit-for-bit the lasso.
68
+
69
+ `CovRidge` is the closed form: one symmetric eigendecomposition,
70
+ then every alpha costs O(p^2), so thousands of alphas cost what one
71
+ does. Its objective matches scikit-learn's `Ridge` with
72
+ `fit_intercept=False` (which, unlike the lasso, is not scaled by the
73
+ number of rows), and agrees with it to machine precision.
74
+
75
+ ```python
76
+ path = model.fit_path(lambdas, l1_ratio=0.5) # elastic net
77
+ ridge = CovRidge(X, y)
78
+ betas = ridge.fit_path(ridge.alpha_grid()) # a whole ridge path
79
+ ```
80
+
81
+ ## Logistic regression with L1
82
+
83
+ `CovLogistic` solves L1-penalized logistic regression by
84
+ majorization: the logistic Hessian is bounded by X'X/4, so the
85
+ quadratic term is the same Gram matrix, fixed once, and each outer
86
+ round costs one gradient pass before handing the majorizer to the
87
+ same coordinate descent the lasso uses. Objectives agree with
88
+ scikit-learn's `LogisticRegression(penalty="l1",
89
+ fit_intercept=False)` at `C = 1/(n lam)` to 9e-15.
90
+
91
+ ## Group lasso
92
+
93
+ `CovGroupLasso` penalizes whole groups -- `lam * sum_g sqrt(|g|)
94
+ ||b_g||` -- so correlated features enter or leave together. Block
95
+ coordinate descent on the same Gram machinery: each block visit is
96
+ one majorized proximal step (the group's Gram block dominated by its
97
+ top eigenvalue, found once), descending monotonically. With size-one
98
+ groups it reduces exactly to the lasso -- verified against sklearn to
99
+ 9e-16 -- and at convergence the group KKT conditions hold to 2e-12.
100
+
101
+ ## Bootstrap on the GPU
102
+
103
+ `bootstrap` draws pairs-bootstrap resamples and refits them all at
104
+ one lambda. Each resample's Gram matrix is `X' diag(m) X` for its
105
+ multiplicity counts `m` -- one BLAS product -- and because the
106
+ problems are independent, the descents run as one batch: on the GPU,
107
+ one thread per resample, each reading its own Gram matrix.
108
+
109
+ ```python
110
+ betas = model.bootstrap(lam, n_boot=500, seed=0) # (500, p)
111
+ freq = (abs(betas) > 1e-9).mean(axis=0) # selection frequency
112
+ ```
113
+
114
+ Requires constructing the model from `X, y` (a Gram matrix alone
115
+ cannot be resampled by rows). GPU and CPU agree to machine
116
+ precision.
117
+
118
+ ## A design with structure
119
+
120
+ When the design matrix has structure, forming X'X the general way is
121
+ the wrong move. `kernel` exposes the translated functions directly,
122
+ and [`scm2cpp-tfs`](https://pypi.org/project/scm2cpp-tfs/) does exactly
123
+ that for moving-average designs: it builds the Gram matrix from a
124
+ series' prefix sums in O(n p) time, never forming the design. That
125
+ package stands alone -- it carries its own copy of this descent -- so
126
+ neither installs the other.
127
+
128
+ ## Where this comes from
129
+
130
+ The C++ this compiles is committed to `python/scm2cpp-lasso/` in the
131
+ scm2cpp repository, translated from
132
+ `examples/kernel-only/lasso-cov.scm`. That repository also derives the
133
+ covariance-update solver automatically from a naive one by finite
134
+ differencing; this package is the derived kernel, packaged. To refresh
135
+ the committed C++ after changing the Scheme, run `regenerate.sh` --
136
+ that step, and only that step, needs Racket.
137
+
138
+ ## License
139
+
140
+ MIT, the same as scm2cpp.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "numpy"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scm2cpp-lasso"
7
+ version = "0.5.0"
8
+ description = "Lasso by covariance-update coordinate descent, translated from Scheme to C++ by scm2cpp; GPU batch solver included"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Hirotaka Niitsuma"}]
13
+ keywords = ["lasso", "elastic-net", "ridge", "coordinate-descent", "cuda", "compiler"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: C++",
18
+ "Topic :: Scientific/Engineering :: Mathematics",
19
+ "Intended Audience :: Science/Research",
20
+ ]
21
+ dependencies = ["numpy>=1.17"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/niitsuma/scm2cpp"
25
+ Source = "https://github.com/niitsuma/scm2cpp/tree/master/python/scm2cpp-lasso"
26
+
27
+ [tool.setuptools]
28
+ package-dir = {"" = "src"}
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.setuptools.package-data]
34
+ scm2cpp_lasso = ["_generated/*.hpp", "_generated/*.cpp", "*.cu"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,63 @@
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. regenerate.sh is how the committed sources are
6
+ refreshed, and it is the only thing that needs the translator.
7
+
8
+ The CUDA library is optional in the strongest sense: no nvcc, no
9
+ device, or a failed nvcc all leave a working CPU package behind, and
10
+ the batch path notices at import and falls back.
11
+ """
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from setuptools import Extension, setup
19
+ from setuptools.command.build_ext import build_ext
20
+
21
+ HERE = Path(__file__).resolve().parent
22
+ # setuptools wants sources and include paths relative to this file and
23
+ # separated by forward slashes, on every platform
24
+ PKG_REL = "src/scm2cpp_lasso"
25
+ GEN_REL = PKG_REL + "/_generated"
26
+ PKG = HERE / "src" / "scm2cpp_lasso"
27
+ GEN = PKG / "_generated"
28
+
29
+ kernel = Extension(
30
+ "scm2cpp_lasso._lasso_cov",
31
+ sources=[GEN_REL + "/lasso_cov_capi.cpp"],
32
+ include_dirs=[GEN_REL],
33
+ extra_compile_args=(["-O2", "-std=c++17"] if os.name != "nt"
34
+ else ["/O2", "/std:c++17"]),
35
+ )
36
+
37
+
38
+ class BuildWithCuda(build_ext):
39
+ def build_extensions(self):
40
+ # the kernel is C++ compiled through the usual machinery, so
41
+ # wheels and cross-compilation behave as they always do
42
+ super().build_extensions()
43
+ if os.environ.get("SCM2CPP_NO_CUDA"):
44
+ return
45
+ nvcc = shutil.which("nvcc")
46
+ if not nvcc:
47
+ return
48
+ out = Path(self.build_lib) / "scm2cpp_lasso" / "libscm2cpp_batch.so"
49
+ out.parent.mkdir(parents=True, exist_ok=True)
50
+ cmd = [nvcc, "-O2", "-std=c++17", "-shared",
51
+ "-Xcompiler", "-fPIC",
52
+ "-I", str(PKG), "-I", str(GEN),
53
+ str(PKG / "batch_capi.cu"), "-o", str(out),
54
+ "-Wno-deprecated-gpu-targets", "-diag-suppress", "174"]
55
+ try:
56
+ subprocess.run(cmd, check=True)
57
+ print("scm2cpp_lasso: built the CUDA batch path")
58
+ except (subprocess.CalledProcessError, OSError) as exc:
59
+ print(f"scm2cpp_lasso: no CUDA batch path ({exc}); "
60
+ "the CPU solver is unaffected", file=sys.stderr)
61
+
62
+
63
+ setup(ext_modules=[kernel], cmdclass={"build_ext": BuildWithCuda})