scm2cpp-lasso
Lasso by coordinate descent over a Gram matrix, with an optional GPU path. The solver is Scheme, translated to C++ by scm2cpp.
$ pip install scm2cpp-lasso
Installing needs a C++17 compiler and nothing else. If nvcc is on
the path, the batched GPU solver is built as well; if it is not, the
package installs and works exactly the same, minus that one method.
Three of a hundred candidate columns carry the signal; the solver is asked which, and answers with them and nothing else:
import numpy as np
from scm2cpp_lasso import CovLasso
rng = np.random.default_rng(0)
X = rng.standard_normal((500, 100)) # 500 rows, 100 candidates
beta = np.zeros(100)
beta[[7, 23, 61]] = [2.0, -1.5, 0.8] # only three of them matter
y = X @ beta + 0.1 * rng.standard_normal(500)
model = CovLasso(X, y) # the Gram matrix, built once
path = model.fit_path(model.lambda_grid()) # 100 lambdas, warm-started
fit = path[60]
for j in np.flatnonzero(np.abs(fit) > 1e-6):
print(f" x{j:<3} {fit[j]:+.3f}")
x7 +1.965
x23 -1.474
x61 +0.770
The other 97 are exactly zero -- that is what the penalty is for -- and the coefficients are the planted ones, shrunk toward zero by it.
The rest of the interface:
from scm2cpp_lasso import cuda_available
lambdas = model.lambda_grid(num=100) # from lambda_max down
path = model.fit_path(lambdas) # warm-started, sequential
grid = model.fit_path_batch(lambdas) # every lambda from zero
one = model.fit(lambdas[60]) # a single lambda, if you must
print("GPU:", cuda_available())
fit is the one-element special case of fit_path -- the Gram matrix
was built in the constructor, so a whole warm-started path costs barely
more than one penalty, which is why the path is the primary interface.
The objective is scikit-learn's, with fit_intercept=False:
(1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
Penalties are compared against correlations on the features' own
scale, so the useful range of lam depends on the data.
lambda_max() is the smallest penalty that leaves every coefficient
at zero, and lambda_grid() walks down from it -- the construction
scikit-learn uses. If your columns differ wildly in scale, standardize
them first; this solver does not do it for you.
Against the other lassos pip can install
bench/lasso-compare.py in the repository runs the same
cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
p=200, n=1800 -- against every lasso a pip install produces:
scikit-learn 1.9.0, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
Every solver runs at its own default tolerance (ours at the tighter
1e-8), each gets one untimed warm-up fit, cuML's data is on the device
before the clock starts, and the last column is each solver's
objective minus ours at the smallest lambda -- a fast row with a loose
answer would show as one:
| solver | time | objective gap |
|---|---|---|
| scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.9 s | 0 |
| scm2cpp-lasso, GPU, one thread/lambda | 0.2 s | 0 |
sklearn Lasso.fit per lambda |
12.5 s | +1.6e-09 |
| celer per lambda | 17.3 s | 0 |
| skglm per lambda | 16.4 s | +2.8e-17 |
| cuML per lambda, GPU | 57.5 s | +9.1e-07 |
celer and skglm are built for very large sparse designs, where their screening rules dominate; at this size they pay their setup per fit. cuML parallelises within one fit, which wins when a fit is large; at this size its launch overhead dominates, while our GPU row parallelises across lambdas -- one CUDA thread per penalty, the axis a cross-validation grid actually offers.
CovLassoCV is scikit-learn's LassoCV over this machinery -- same
grid construction, same contiguous folds, same minimum-mean-MSE choice
-- with two structural savings. A fold's training Gram is a
subtraction (the Gram is additive over rows, so G - Xf'Xf costs one
fold's work where rebuilding costs four folds'), and once the Grams
exist nothing downstream ever touches the n rows again -- which is why
the gap grows with n. On the GPU every fold and every alpha can run
as one thread of a single launch; that launch replicates each fold's
Gram per alpha, so it is chosen only while the replication stays under
512 MB, the CPU warm path taking over at large p. Measured at cv=5,
100 alphas, one CPU core:
| n | p | CovLassoCV |
sklearn LassoCV |
|---|---|---|---|
| 1,800 | 200 | 0.23 s | 0.20 s |
| 5,000 | 1000 | 1.1 s | 3.4 s |
| 100,000 | 200 | 0.44 s | 5.7 s |
| 100,000 | 500 | 0.90 s | 8.0 s |
At n=100,000 the chosen alpha is identical and the coefficients agree to 7e-7; at small n a near-tie can leave the picks one grid step apart, which sklearn resolves our way once its tolerance is tightened. GPU and CPU agree to 1e-14 wherever both run.
Which method
fit_path walks a single path, each lambda starting from the previous
solution -- the descent is exactly resumable, so this costs almost
nothing per lambda after the first. fit_path_batch solves every
lambda from zero, which is what a cross-validation grid needs, where
the folds differ and warm starting across lambdas is not on offer; the
problems are independent, so they go to the GPU together.
Over a 400-lambda path at p=200 and 1800 rows, on an RTX 4090 and an i9-10900X:
| call | time |
|---|---|
fit_path (warm, sequential) |
0.091 s |
fit_path_batch (GPU) |
0.047 s |
fit_path_batch(force_cpu=True) |
0.178 s |
GPU and CPU agree to 2e-14, and the objectives are within 3e-17 of
scikit-learn's lasso_path on the same grid.
Elastic net and ridge
The same Gram matrix serves two more estimators. fit_path and
fit_path_batch take l1_ratio (scikit-learn's mixing parameter):
the L2 share of the penalty enters only the update's denominator, so
the elastic net runs on the identical machinery, GPU path included,
and l1_ratio=1 is bit-for-bit the lasso.
CovRidge is the closed form: one symmetric eigendecomposition,
then every alpha costs O(p^2), so thousands of alphas cost what one
does. Its objective matches scikit-learn's Ridge with
fit_intercept=False (which, unlike the lasso, is not scaled by the
number of rows), and agrees with it to machine precision.
path = model.fit_path(lambdas, l1_ratio=0.5) # elastic net
ridge = CovRidge(X, y)
betas = ridge.fit_path(ridge.alpha_grid()) # a whole ridge path
Logistic regression with L1
CovLogistic solves L1-penalized logistic regression by
majorization: the logistic Hessian is bounded by X'X/4, so the
quadratic term is the same Gram matrix, fixed once, and each outer
round costs one gradient pass before handing the majorizer to the
same coordinate descent the lasso uses. Objectives agree with
scikit-learn's LogisticRegression(penalty="l1", fit_intercept=False) at C = 1/(n lam) to 9e-15.
Group lasso
CovGroupLasso penalizes whole groups -- lam * sum_g sqrt(|g|) ||b_g|| -- so correlated features enter or leave together. Block
coordinate descent on the same Gram machinery: each block visit is
one majorized proximal step (the group's Gram block dominated by its
top eigenvalue, found once), descending monotonically. With size-one
groups it reduces exactly to the lasso -- verified against sklearn to
9e-16 -- and at convergence the group KKT conditions hold to 2e-12.
Bootstrap on the GPU
bootstrap draws pairs-bootstrap resamples and refits them all at
one lambda. Each resample's Gram matrix is X' diag(m) X for its
multiplicity counts m -- one BLAS product -- and because the
problems are independent, the descents run as one batch: on the GPU,
one thread per resample, each reading its own Gram matrix.
betas = model.bootstrap(lam, n_boot=500, seed=0) # (500, p)
freq = (abs(betas) > 1e-9).mean(axis=0) # selection frequency
Requires constructing the model from X, y (a Gram matrix alone
cannot be resampled by rows). GPU and CPU agree to machine
precision.
A design with structure
When the design matrix has structure, forming X'X the general way is
the wrong move. kernel exposes the translated functions directly,
and scm2cpp-tfs does exactly
that for moving-average designs: it builds the Gram matrix from a
series' prefix sums in O(n p) time, never forming the design. That
package stands alone -- it carries its own copy of this descent -- so
neither installs the other.
Where this comes from
The C++ this compiles is committed to python/scm2cpp-lasso/ in the
scm2cpp repository, translated from
examples/kernel-only/lasso-cov.scm. That repository also derives the
covariance-update solver automatically from a naive one by finite
differencing; this package is the derived kernel, packaged. To refresh
the committed C++ after changing the Scheme, run regenerate.sh --
that step, and only that step, needs Racket.
License
MIT, the same as scm2cpp.
Metadata
Release files for scm2cpp-lasso 0.5.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| scm2cpp_lasso-0.5.3.1.tar.gz | 34.3 kB | Details |
Release files / scm2cpp_lasso-0.5.3.1.tar.gz
| Download URL | scm2cpp_lasso-0.5.3.1.tar.gz |
|---|---|
| Size | 34.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
81739665feae1383d0c97dbc52b7f29e2cd1765d43fadacfcf6368c393e4e621
|
|
BLAKE2b-256 checksum How to use checksums |
a1854621014ceeca2813186c7ae9f6f675b7d2c5c99f4d345d3ff71dbe6acbed
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|