scm2cpp-lasso 0.5.0.1__tar.gz → 0.5.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/PKG-INFO +44 -1
  2. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/README.md +43 -0
  3. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/pyproject.toml +1 -1
  4. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/__init__.py +133 -4
  5. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso.egg-info/PKG-INFO +44 -1
  6. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/setup.cfg +0 -0
  7. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/setup.py +0 -0
  8. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/_generated/__init__.py +0 -0
  9. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/_generated/_loader.py +0 -0
  10. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/_generated/lasso_cov.cpp +0 -0
  11. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/_generated/lasso_cov.hpp +0 -0
  12. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/_generated/lasso_cov_capi.cpp +0 -0
  13. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/_generated/scm2cpp.hpp +0 -0
  14. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/_libfind.py +0 -0
  15. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso/batch_capi.cu +0 -0
  16. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso.egg-info/SOURCES.txt +0 -0
  17. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso.egg-info/dependency_links.txt +0 -0
  18. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso.egg-info/requires.txt +0 -0
  19. {scm2cpp_lasso-0.5.0.1 → scm2cpp_lasso-0.5.3}/src/scm2cpp_lasso.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scm2cpp-lasso
3
- Version: 0.5.0.1
3
+ Version: 0.5.3
4
4
  Summary: Lasso by covariance-update coordinate descent, translated from Scheme to C++ by scm2cpp; GPU batch solver included
5
5
  Author: Hirotaka Niitsuma
6
6
  License: MIT
@@ -70,9 +70,14 @@ from scm2cpp_lasso import cuda_available
70
70
  lambdas = model.lambda_grid(num=100) # from lambda_max down
71
71
  path = model.fit_path(lambdas) # warm-started, sequential
72
72
  grid = model.fit_path_batch(lambdas) # every lambda from zero
73
+ one = model.fit(lambdas[60]) # a single lambda, if you must
73
74
  print("GPU:", cuda_available())
74
75
  ```
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
+
76
81
  The objective is scikit-learn's, with `fit_intercept=False`:
77
82
 
78
83
  (1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
@@ -84,6 +89,44 @@ at zero, and `lambda_grid()` walks down from it -- the construction
84
89
  scikit-learn uses. If your columns differ wildly in scale, standardize
85
90
  them first; this solver does not do it for you.
86
91
 
92
+ ## Against the other lassos pip can install
93
+
94
+ `bench/lasso-compare.py` in the repository runs the same
95
+ cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
96
+ p=200, n=1800 -- against every lasso a `pip install` produces:
97
+ scikit-learn 1.9.0, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
98
+ Every solver runs at its own default tolerance (ours at the tighter
99
+ 1e-8), each gets one untimed warm-up fit, cuML's data is on the device
100
+ before the clock starts, and the last column is each solver's
101
+ objective minus ours at the smallest lambda -- a fast row with a loose
102
+ answer would show as one:
103
+
104
+ | solver | time | objective gap |
105
+ |----------------------------------------|---------|---------------|
106
+ | scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.9 s | 0 |
107
+ | scm2cpp-lasso, GPU, one thread/lambda | 0.2 s | 0 |
108
+ | sklearn `Lasso.fit` per lambda | 12.5 s | +1.6e-09 |
109
+ | celer per lambda | 17.3 s | 0 |
110
+ | skglm per lambda | 16.4 s | +2.8e-17 |
111
+ | cuML per lambda, GPU | 57.5 s | +9.1e-07 |
112
+
113
+ celer and skglm are built for very large sparse designs, where their
114
+ screening rules dominate; at this size they pay their setup per fit.
115
+ cuML parallelises *within* one fit, which wins when a fit is large; at
116
+ this size its launch overhead dominates, while our GPU row
117
+ parallelises *across* lambdas -- one CUDA thread per penalty, the axis
118
+ a cross-validation grid actually offers.
119
+
120
+ `CovLassoCV` is scikit-learn's `LassoCV` over this machinery: the same
121
+ grid construction, the same contiguous folds, the same
122
+ minimum-mean-MSE choice -- and where the two disagree it is by one
123
+ grid step on a near-tie, which sklearn also resolves our way once its
124
+ tolerance is tightened. A fold's training Gram is a subtraction
125
+ (G - Xf'Xf; the Gram is additive over rows), and on the GPU every fold
126
+ and every alpha is one thread of a single launch. At cv=10 with 1000
127
+ alphas -- ten thousand independent problems -- that launch takes
128
+ 1.2 s against sklearn's 1.7 s, with GPU and CPU agreeing to 1e-14.
129
+
87
130
  ## Which method
88
131
 
89
132
  `fit_path` walks a single path, each lambda starting from the previous
@@ -52,9 +52,14 @@ from scm2cpp_lasso import cuda_available
52
52
  lambdas = model.lambda_grid(num=100) # from lambda_max down
53
53
  path = model.fit_path(lambdas) # warm-started, sequential
54
54
  grid = model.fit_path_batch(lambdas) # every lambda from zero
55
+ one = model.fit(lambdas[60]) # a single lambda, if you must
55
56
  print("GPU:", cuda_available())
56
57
  ```
57
58
 
59
+ `fit` is the one-element special case of `fit_path` -- the Gram matrix
60
+ was built in the constructor, so a whole warm-started path costs barely
61
+ more than one penalty, which is why the path is the primary interface.
62
+
58
63
  The objective is scikit-learn's, with `fit_intercept=False`:
59
64
 
60
65
  (1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
@@ -66,6 +71,44 @@ at zero, and `lambda_grid()` walks down from it -- the construction
66
71
  scikit-learn uses. If your columns differ wildly in scale, standardize
67
72
  them first; this solver does not do it for you.
68
73
 
74
+ ## Against the other lassos pip can install
75
+
76
+ `bench/lasso-compare.py` in the repository runs the same
77
+ cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
78
+ p=200, n=1800 -- against every lasso a `pip install` produces:
79
+ scikit-learn 1.9.0, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
80
+ Every solver runs at its own default tolerance (ours at the tighter
81
+ 1e-8), each gets one untimed warm-up fit, cuML's data is on the device
82
+ before the clock starts, and the last column is each solver's
83
+ objective minus ours at the smallest lambda -- a fast row with a loose
84
+ answer would show as one:
85
+
86
+ | solver | time | objective gap |
87
+ |----------------------------------------|---------|---------------|
88
+ | scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.9 s | 0 |
89
+ | scm2cpp-lasso, GPU, one thread/lambda | 0.2 s | 0 |
90
+ | sklearn `Lasso.fit` per lambda | 12.5 s | +1.6e-09 |
91
+ | celer per lambda | 17.3 s | 0 |
92
+ | skglm per lambda | 16.4 s | +2.8e-17 |
93
+ | cuML per lambda, GPU | 57.5 s | +9.1e-07 |
94
+
95
+ celer and skglm are built for very large sparse designs, where their
96
+ screening rules dominate; at this size they pay their setup per fit.
97
+ cuML parallelises *within* one fit, which wins when a fit is large; at
98
+ this size its launch overhead dominates, while our GPU row
99
+ parallelises *across* lambdas -- one CUDA thread per penalty, the axis
100
+ a cross-validation grid actually offers.
101
+
102
+ `CovLassoCV` is scikit-learn's `LassoCV` over this machinery: the same
103
+ grid construction, the same contiguous folds, the same
104
+ minimum-mean-MSE choice -- and where the two disagree it is by one
105
+ grid step on a near-tie, which sklearn also resolves our way once its
106
+ tolerance is tightened. A fold's training Gram is a subtraction
107
+ (G - Xf'Xf; the Gram is additive over rows), and on the GPU every fold
108
+ and every alpha is one thread of a single launch. At cv=10 with 1000
109
+ alphas -- ten thousand independent problems -- that launch takes
110
+ 1.2 s against sklearn's 1.7 s, with GPU and CPU agreeing to 1e-14.
111
+
69
112
  ## Which method
70
113
 
71
114
  `fit_path` walks a single path, each lambda starting from the previous
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scm2cpp-lasso"
7
- version = "0.5.0.1"
7
+ version = "0.5.3"
8
8
  description = "Lasso by covariance-update coordinate descent, translated from Scheme to C++ by scm2cpp; GPU batch solver included"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -31,7 +31,7 @@ from ._libfind import load_batch_lib
31
31
 
32
32
  __all__ = ["CovLasso", "CovRidge", "CovLogistic", "CovGroupLasso",
33
33
  "cuda_available", "kernel"]
34
- __version__ = "0.5.0.1"
34
+ __version__ = "0.5.3"
35
35
 
36
36
  _BATCH = load_batch_lib()
37
37
  _DP = ctypes.POINTER(ctypes.c_double)
@@ -141,6 +141,17 @@ class CovLasso:
141
141
 
142
142
  # ---------------------------- fitting ----------------------------
143
143
 
144
+ def fit(self, lam, **kw):
145
+ """Coefficients at one lambda.
146
+
147
+ A single penalty is the one-element special case of the path:
148
+ the Gram matrix was built in the constructor, so this costs one
149
+ descent and nothing per row -- and a warm-started path over a
150
+ whole grid costs barely more, which is why fit_path is the
151
+ primary interface rather than this convenience.
152
+ """
153
+ return self.fit_path([lam], **kw)[0]
154
+
144
155
  def fit_path(self, lambdas, tol=1e-8, chunk=20, max_sweeps=100000,
145
156
  l1_ratio=1.0):
146
157
  """Coefficients per lambda, each warm-started from the last.
@@ -246,7 +257,10 @@ def _batch_descend_multi(grams, corrs, lam, nobs, p, tol=1e-8, chunk=20,
246
257
  beta = np.zeros((B, p))
247
258
  c = np.ascontiguousarray(corrs.copy())
248
259
  g = np.ascontiguousarray(grams)
249
- lams = np.full(B, float(lam))
260
+ # lam may be one penalty for every thread or one per thread; the
261
+ # kernel always reads a per-thread array
262
+ lams = (np.ascontiguousarray(lam, dtype=np.float64)
263
+ if np.ndim(lam) else np.full(B, float(lam)))
250
264
  if _BATCH is not None and not force_cpu:
251
265
  dp = ctypes.POINTER(ctypes.c_double)
252
266
  rc = _BATCH.scm2cpp_batch_descend(
@@ -259,11 +273,12 @@ def _batch_descend_multi(grams, corrs, lam, nobs, p, tol=1e-8, chunk=20,
259
273
  prev = np.empty(p)
260
274
  for b in range(B):
261
275
  gb, cb, bb = g[b], c[b], beta[b]
276
+ lam_b = float(lams[b])
262
277
  swept = 0
263
278
  while swept < max_sweeps:
264
279
  prev[:] = bb
265
- kernel_fn(gb, cb, bb, float(lam) * l1_ratio,
266
- float(lam) * (1.0 - l1_ratio), chunk,
280
+ kernel_fn(gb, cb, bb, lam_b * l1_ratio,
281
+ lam_b * (1.0 - l1_ratio), chunk,
267
282
  float(nobs), p)
268
283
  swept += chunk
269
284
  if np.max(np.abs(bb - prev)) < tol * max(
@@ -272,6 +287,120 @@ def _batch_descend_multi(grams, corrs, lam, nobs, p, tol=1e-8, chunk=20,
272
287
  return beta
273
288
 
274
289
 
290
+
291
+ class CovLassoCV:
292
+ """Cross-validated lasso, scikit-learn's LassoCV semantics.
293
+
294
+ The lambda grid comes from the full data (lambda_max down, the
295
+ sklearn construction), the folds are contiguous splits in order
296
+ (sklearn's KFold default), each fold's whole path is solved from
297
+ zero -- which is exactly the workload fit_path_batch exists for,
298
+ one GPU thread per lambda -- and validation error is one matrix
299
+ product per fold. alpha_ minimises the mean validation MSE, ties
300
+ going to the larger penalty as in scikit-learn, and coef_ is the
301
+ refit on all rows at alpha_.
302
+
303
+ Attributes after fit: alphas_, mse_path_ (num, cv), alpha_, coef_.
304
+ """
305
+
306
+ def __init__(self, cv=5, num=100, eps=1e-3, tol=1e-8, l1_ratio=1.0,
307
+ force_cpu=False):
308
+ self.cv = int(cv)
309
+ self.num = int(num)
310
+ self.eps = float(eps)
311
+ self.tol = float(tol)
312
+ self.l1_ratio = float(l1_ratio)
313
+ self.force_cpu = force_cpu
314
+
315
+ def fit(self, X, y):
316
+ X = np.ascontiguousarray(X, dtype=np.float64)
317
+ y = np.ascontiguousarray(y, dtype=np.float64)
318
+ n = len(y)
319
+ full = CovLasso(X, y)
320
+ self.alphas_ = full.lambda_grid(num=self.num, eps=self.eps,
321
+ l1_ratio=self.l1_ratio)
322
+ # contiguous folds in row order, sizes as even as they come --
323
+ # sklearn's KFold(shuffle=False)
324
+ bounds = np.linspace(0, n, self.cv + 1).astype(int)
325
+ self.mse_path_ = np.empty((self.num, self.cv))
326
+ gpu = cuda_available() and not self.force_cpu
327
+ G = full.g.reshape(full.p, full.p)
328
+ if gpu:
329
+ return self._fit_gpu(X, y, full, G, bounds, n)
330
+ for k in range(self.cv):
331
+ lo, hi = bounds[k], bounds[k + 1]
332
+ Xf, yf = X[lo:hi], y[lo:hi]
333
+ # The Gram is additive over rows, so a fold's training Gram
334
+ # is a subtraction: G_full - Xf'Xf costs O(n_fold p^2) where
335
+ # rebuilding from the complement costs O((n - n_fold) p^2).
336
+ # Over five folds that is the whole Gram work twice instead
337
+ # of four times, and the Gram is the dominant O(n p^2) step.
338
+ m = CovLasso(gram=(G - Xf.T @ Xf).ravel(),
339
+ corr=full.c0 - Xf.T @ yf,
340
+ nobs=n - (hi - lo))
341
+ # On the GPU every lambda is its own thread, so the batch is
342
+ # the right shape; on the CPU the warm-started path is: after
343
+ # the Gram, each lambda resumes where the previous stopped
344
+ # and costs O(p) per moving coordinate. That warm path is
345
+ # what makes searching the alpha grid cheap -- the grid
346
+ # costs barely more than its hardest single alpha.
347
+ if gpu:
348
+ path = m.fit_path_batch(self.alphas_, tol=self.tol,
349
+ l1_ratio=self.l1_ratio)
350
+ else:
351
+ path = m.fit_path(self.alphas_, tol=self.tol,
352
+ l1_ratio=self.l1_ratio)
353
+ resid = Xf @ path.T - yf[:, None]
354
+ self.mse_path_[:, k] = np.mean(resid * resid, axis=0)
355
+ mean = self.mse_path_.mean(axis=1)
356
+ # alphas_ descends, so argmin's first hit is the larger penalty
357
+ self.alpha_ = float(self.alphas_[int(np.argmin(mean))])
358
+ self.coef_ = full.fit(self.alpha_, tol=self.tol,
359
+ l1_ratio=self.l1_ratio)
360
+ return self
361
+
362
+ def _fit_gpu(self, X, y, full, G, bounds, n):
363
+ # Every fold and every alpha is an independent problem, so all of
364
+ # them go to the device as one batch: cv * num threads, each with
365
+ # its fold's Gram (the per-thread-Gram path the bootstrap added)
366
+ # and its own penalty. Fold sizes may differ by a row; the kernel
367
+ # only ever uses lam through the product lam * nobs, so each
368
+ # thread's nobs is absorbed into its lambda and nobs is passed
369
+ # as 1 -- exact, not approximate.
370
+ p_ = full.p
371
+ num, cv = self.num, self.cv
372
+ grams = np.empty((cv, p_ * p_))
373
+ corrs = np.empty((cv, p_))
374
+ nobs_k = np.empty(cv)
375
+ for k in range(cv):
376
+ lo, hi = bounds[k], bounds[k + 1]
377
+ Xf, yf = X[lo:hi], y[lo:hi]
378
+ grams[k] = (G - Xf.T @ Xf).ravel()
379
+ corrs[k] = full.c0 - Xf.T @ yf
380
+ nobs_k[k] = n - (hi - lo)
381
+ gr = np.repeat(grams, num, axis=0)
382
+ cr = np.repeat(corrs, num, axis=0)
383
+ lams = (np.tile(self.alphas_, cv) * np.repeat(nobs_k, num))
384
+ betas = _batch_descend_multi(gr, cr, lams, 1.0, p_,
385
+ tol=self.tol,
386
+ l1_ratio=self.l1_ratio,
387
+ force_cpu=self.force_cpu,
388
+ kernel_fn=kernel.enet_descend)
389
+ betas = betas.reshape(cv, num, p_)
390
+ for k in range(cv):
391
+ lo, hi = bounds[k], bounds[k + 1]
392
+ resid = X[lo:hi] @ betas[k].T - y[lo:hi, None]
393
+ self.mse_path_[:, k] = np.mean(resid * resid, axis=0)
394
+ mean = self.mse_path_.mean(axis=1)
395
+ self.alpha_ = float(self.alphas_[int(np.argmin(mean))])
396
+ self.coef_ = full.fit(self.alpha_, tol=self.tol,
397
+ l1_ratio=self.l1_ratio)
398
+ return self
399
+
400
+ def predict(self, X):
401
+ return np.ascontiguousarray(X, dtype=np.float64) @ self.coef_
402
+
403
+
275
404
  class CovRidge:
276
405
  """Ridge regression over a design given by its Gram matrix.
277
406
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scm2cpp-lasso
3
- Version: 0.5.0.1
3
+ Version: 0.5.3
4
4
  Summary: Lasso by covariance-update coordinate descent, translated from Scheme to C++ by scm2cpp; GPU batch solver included
5
5
  Author: Hirotaka Niitsuma
6
6
  License: MIT
@@ -70,9 +70,14 @@ from scm2cpp_lasso import cuda_available
70
70
  lambdas = model.lambda_grid(num=100) # from lambda_max down
71
71
  path = model.fit_path(lambdas) # warm-started, sequential
72
72
  grid = model.fit_path_batch(lambdas) # every lambda from zero
73
+ one = model.fit(lambdas[60]) # a single lambda, if you must
73
74
  print("GPU:", cuda_available())
74
75
  ```
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
+
76
81
  The objective is scikit-learn's, with `fit_intercept=False`:
77
82
 
78
83
  (1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
@@ -84,6 +89,44 @@ at zero, and `lambda_grid()` walks down from it -- the construction
84
89
  scikit-learn uses. If your columns differ wildly in scale, standardize
85
90
  them first; this solver does not do it for you.
86
91
 
92
+ ## Against the other lassos pip can install
93
+
94
+ `bench/lasso-compare.py` in the repository runs the same
95
+ cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
96
+ p=200, n=1800 -- against every lasso a `pip install` produces:
97
+ scikit-learn 1.9.0, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
98
+ Every solver runs at its own default tolerance (ours at the tighter
99
+ 1e-8), each gets one untimed warm-up fit, cuML's data is on the device
100
+ before the clock starts, and the last column is each solver's
101
+ objective minus ours at the smallest lambda -- a fast row with a loose
102
+ answer would show as one:
103
+
104
+ | solver | time | objective gap |
105
+ |----------------------------------------|---------|---------------|
106
+ | scm2cpp-lasso, 1 CPU core (tol 1e-8) | 0.9 s | 0 |
107
+ | scm2cpp-lasso, GPU, one thread/lambda | 0.2 s | 0 |
108
+ | sklearn `Lasso.fit` per lambda | 12.5 s | +1.6e-09 |
109
+ | celer per lambda | 17.3 s | 0 |
110
+ | skglm per lambda | 16.4 s | +2.8e-17 |
111
+ | cuML per lambda, GPU | 57.5 s | +9.1e-07 |
112
+
113
+ celer and skglm are built for very large sparse designs, where their
114
+ screening rules dominate; at this size they pay their setup per fit.
115
+ cuML parallelises *within* one fit, which wins when a fit is large; at
116
+ this size its launch overhead dominates, while our GPU row
117
+ parallelises *across* lambdas -- one CUDA thread per penalty, the axis
118
+ a cross-validation grid actually offers.
119
+
120
+ `CovLassoCV` is scikit-learn's `LassoCV` over this machinery: the same
121
+ grid construction, the same contiguous folds, the same
122
+ minimum-mean-MSE choice -- and where the two disagree it is by one
123
+ grid step on a near-tie, which sklearn also resolves our way once its
124
+ tolerance is tightened. A fold's training Gram is a subtraction
125
+ (G - Xf'Xf; the Gram is additive over rows), and on the GPU every fold
126
+ and every alpha is one thread of a single launch. At cv=10 with 1000
127
+ alphas -- ten thousand independent problems -- that launch takes
128
+ 1.2 s against sklearn's 1.7 s, with GPU and CPU agreeing to 1e-14.
129
+
87
130
  ## Which method
88
131
 
89
132
  `fit_path` walks a single path, each lambda starting from the previous
File without changes
File without changes