scm2cpp-learn 0.18.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.
Files changed (67) hide show
  1. scm2cpp_learn-0.18.0/PKG-INFO +1171 -0
  2. scm2cpp_learn-0.18.0/README.md +1153 -0
  3. scm2cpp_learn-0.18.0/pyproject.toml +34 -0
  4. scm2cpp_learn-0.18.0/setup.cfg +4 -0
  5. scm2cpp_learn-0.18.0/setup.py +356 -0
  6. scm2cpp_learn-0.18.0/src/scm2cpp_learn/__init__.py +3804 -0
  7. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_compat.py +284 -0
  8. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/__init__.py +0 -0
  9. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader.py +41 -0
  10. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_accel_cublas.py +173 -0
  11. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_classifiers.py +308 -0
  12. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_derived_blas.py +29 -0
  13. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_derived_cublas.py +29 -0
  14. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_derived_loops.py +29 -0
  15. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_gpu_cuda.py +313 -0
  16. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_gramops_blas.py +692 -0
  17. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_gramops_loops.py +711 -0
  18. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_nmf_blas.py +29 -0
  19. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_nmf_cublas.py +29 -0
  20. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/_loader_nmf_loops.py +29 -0
  21. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/accel_cublas/accel.cpp +24 -0
  22. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/accel_cublas/accel.hpp +1178 -0
  23. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/accel_cublas/accel_capi.cpp +76 -0
  24. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/classifiers.cpp +37 -0
  25. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/classifiers.hpp +3745 -0
  26. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/classifiers_capi.cpp +115 -0
  27. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_blas/lasso_design.cpp +5 -0
  28. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_blas/lasso_design.hpp +156 -0
  29. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_blas/lasso_design_capi.cpp +19 -0
  30. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_cublas/lasso_design.cpp +5 -0
  31. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_cublas/lasso_design.hpp +158 -0
  32. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_cublas/lasso_design_capi.cpp +19 -0
  33. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_loops/lasso_design.cpp +5 -0
  34. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_loops/lasso_design.hpp +179 -0
  35. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/derived_loops/lasso_design_capi.cpp +19 -0
  36. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gpu_cuda/gpu.cpp +37 -0
  37. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gpu_cuda/gpu.hpp +3846 -0
  38. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gpu_cuda/gpu_capi.cpp +308 -0
  39. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gramops_blas/gramops.cpp +82 -0
  40. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gramops_blas/gramops.hpp +5411 -0
  41. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gramops_blas/gramops_capi.cpp +244 -0
  42. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gramops_loops/gramops.cpp +82 -0
  43. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gramops_loops/gramops.hpp +6395 -0
  44. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/gramops_loops/gramops_capi.cpp +250 -0
  45. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/lasso_cov.cpp +7 -0
  46. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/lasso_cov.hpp +152 -0
  47. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/lasso_cov_capi.cpp +25 -0
  48. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_blas/nmf_cd.cpp +5 -0
  49. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_blas/nmf_cd.hpp +132 -0
  50. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_blas/nmf_cd_capi.cpp +19 -0
  51. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_cublas/nmf_cd.cpp +5 -0
  52. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_cublas/nmf_cd.hpp +136 -0
  53. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_cublas/nmf_cd_capi.cpp +19 -0
  54. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_loops/nmf_cd.cpp +5 -0
  55. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_loops/nmf_cd.hpp +167 -0
  56. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/nmf_loops/nmf_cd_capi.cpp +19 -0
  57. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/scm2cpp-blas.hpp +158 -0
  58. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/scm2cpp-cublas.hpp +155 -0
  59. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/scm2cpp-cuda.hpp +285 -0
  60. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_generated/scm2cpp.hpp +1627 -0
  61. scm2cpp_learn-0.18.0/src/scm2cpp_learn/_libfind.py +153 -0
  62. scm2cpp_learn-0.18.0/src/scm2cpp_learn/batch_capi.cu +428 -0
  63. scm2cpp_learn-0.18.0/src/scm2cpp_learn.egg-info/PKG-INFO +1171 -0
  64. scm2cpp_learn-0.18.0/src/scm2cpp_learn.egg-info/SOURCES.txt +65 -0
  65. scm2cpp_learn-0.18.0/src/scm2cpp_learn.egg-info/dependency_links.txt +1 -0
  66. scm2cpp_learn-0.18.0/src/scm2cpp_learn.egg-info/requires.txt +1 -0
  67. scm2cpp_learn-0.18.0/src/scm2cpp_learn.egg-info/top_level.txt +1 -0
@@ -0,0 +1,1171 @@
1
+ Metadata-Version: 2.4
2
+ Name: scm2cpp-learn
3
+ Version: 0.18.0
4
+ Summary: scikit-learn estimators (lasso, elastic net, ridge, RidgeCV, ARD, SVM, SVR, logistic regression, GLMs, Huber, Theil-Sen, F-score selection, RFE, NMF, Gaussian mixture, FastICA, PCA, KernelPCA, LDA, TruncatedSVD, graphical lasso, RANSAC, SGD, OMP, LARS) translated from Scheme to C++ by scm2cpp; GPU batch solver and CUDA kernels 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-learn
9
+ Keywords: scikit-learn,lasso,elastic-net,ridge,svm,logistic-regression,pca,lda,coordinate-descent,lapack,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-learn
20
+
21
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/niitsuma/scm2cpp/blob/master/notebooks/compare-sklearn.ipynb)
22
+
23
+ *[Japanese (README.ja.md)](README.ja.md)*
24
+
25
+ About fifty scikit-learn estimators -- the lasso and its relatives,
26
+ ridge and RidgeCV, SVMs, GLMs, PCA, KernelPCA, LDA, TruncatedSVD,
27
+ mixtures, feature selection and more -- each written in Scheme and
28
+ translated to C++ by [scm2cpp](https://github.com/niitsuma/scm2cpp),
29
+ with BLAS/LAPACK, OpenMP and an optional GPU path.
30
+
31
+ Up to 0.17.0 this package was published as `scm2cpp-lasso` (imported as
32
+ `scm2cpp_lasso`). That name still installs it: `scm2cpp-lasso` 0.18.0
33
+ depends on this package, and `import scm2cpp_lasso` gives this module,
34
+ with a DeprecationWarning.
35
+
36
+ ```console
37
+ $ pip install scm2cpp-learn
38
+ ```
39
+
40
+ On Linux x86_64, macOS arm64 (macOS 14 or later) and Windows AMD64
41
+ this installs a wheel: compiled, nothing to build (with OpenBLAS
42
+ inside on Linux and macOS; Windows has the loop variants). Elsewhere pip builds the sdist,
43
+ which needs a C++17 compiler and nothing else. The wheels carry no
44
+ GPU code (a wheel cannot know the device); for the GPU builds, install
45
+ from the sdist on a machine with `nvcc` on the path:
46
+
47
+ ```console
48
+ $ pip install --no-binary scm2cpp-learn scm2cpp-learn
49
+ ```
50
+
51
+ Built from the sdist, the products and the eigen- and singular value
52
+ problems go to Intel MKL where pkg-config knows it
53
+ (`mkl-dynamic-lp64-gomp`, GNU OpenMP threading, as Gentoo's
54
+ `sci-libs/mkl` installs it), else to an OpenBLAS with LAPACKE, else to
55
+ the translated loops; `SCM2CPP_NO_MKL=1` skips MKL. The translated
56
+ loops that the dependence test proves independent run on OpenMP where
57
+ the compiler has it (`SCM2CPP_NO_OPENMP=1` builds them serial). Both
58
+ use every core unless told otherwise: `OMP_NUM_THREADS` (and
59
+ `MKL_NUM_THREADS` / `OPENBLAS_NUM_THREADS`) set the threads.
60
+
61
+ The batched GPU solver, the cuBLAS variants and the `--cuda`
62
+ estimators are then built as well; without `nvcc` the package installs
63
+ and works exactly the same, minus those.
64
+
65
+ Three of a hundred candidate columns carry the signal; the solver is
66
+ asked which, and answers with them and nothing else:
67
+
68
+ ```python
69
+ import numpy as np
70
+ from scm2cpp_learn import CovLasso
71
+
72
+ rng = np.random.default_rng(0)
73
+ X = rng.standard_normal((500, 100)) # 500 rows, 100 candidates
74
+ beta = np.zeros(100)
75
+ beta[[7, 23, 61]] = [2.0, -1.5, 0.8] # only three of them matter
76
+ y = X @ beta + 0.1 * rng.standard_normal(500)
77
+
78
+ model = CovLasso(X, y) # the Gram matrix, built once
79
+ path = model.fit_path(model.lambda_grid()) # 100 lambdas, warm-started
80
+
81
+ fit = path[60]
82
+ for j in np.flatnonzero(np.abs(fit) > 1e-6):
83
+ print(f" x{j:<3} {fit[j]:+.3f}")
84
+ ```
85
+
86
+ ```console
87
+ x7 +1.965
88
+ x23 -1.474
89
+ x61 +0.770
90
+ ```
91
+
92
+ The other 97 are exactly zero -- that is what the penalty is for -- and
93
+ the coefficients are the planted ones, shrunk toward zero by it.
94
+
95
+ The rest of the interface:
96
+
97
+ ```python
98
+ from scm2cpp_learn import cuda_available
99
+
100
+ lambdas = model.lambda_grid(num=100) # from lambda_max down
101
+ path = model.fit_path(lambdas) # warm-started, sequential
102
+ grid = model.fit_path_batch(lambdas) # every lambda from zero
103
+ one = model.fit(lambdas[60]) # a single lambda, if you must
104
+ print("GPU:", cuda_available())
105
+ ```
106
+
107
+ `fit` is the one-element special case of `fit_path` -- the Gram matrix
108
+ was built in the constructor, so a whole warm-started path costs barely
109
+ more than one penalty, which is why the path is the primary interface.
110
+
111
+ The objective is scikit-learn's, with `fit_intercept=False`:
112
+
113
+ (1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
114
+
115
+ Penalties are compared against correlations on the features' own
116
+ scale, so the useful range of `lam` depends on the data.
117
+ `lambda_max()` is the smallest penalty that leaves every coefficient
118
+ at zero, and `lambda_grid()` walks down from it -- the construction
119
+ scikit-learn uses. If your columns differ wildly in scale, standardize
120
+ them first; this solver does not do it for you.
121
+
122
+ ## scikit-learn compatibility
123
+
124
+ From 0.14.0 every estimator with a `fit(X, y)` (or `fit(X)`) derives
125
+ from scikit-learn's `BaseEstimator` and the matching mixin, so `clone`,
126
+ `get_params` / `set_params`, `GridSearchCV`, `Pipeline` and
127
+ `cross_val_score` take them, and all 44 pass
128
+ `sklearn.utils.estimator_checks.check_estimator` (`check-sklearn.py`
129
+ runs it; the one expected failure is `LBFGSLogisticRegressionCV`'s
130
+ `n_iter_` of 0 for a C whose warm start is already converged, which
131
+ scikit-learn's own `LogisticRegressionCV` reports on the same data).
132
+ scikit-learn stays optional: without it a numpy-only stand-in gives
133
+ `get_params`, `set_params`, `score` and the same input checks.
134
+
135
+ ```python
136
+ from sklearn.model_selection import GridSearchCV
137
+ from scm2cpp_learn import GramLasso
138
+
139
+ GridSearchCV(GramLasso(), {"alpha": [0.01, 0.1, 1.0]}, cv=5).fit(X, y)
140
+ ```
141
+
142
+ `CovLasso`, `CovRidge`, `DerivedLasso`, `CovLogistic`,
143
+ `CovGroupLasso` and `CovMultiTaskLasso` keep their
144
+ data-in-the-constructor interface, which solves a whole path at once;
145
+ each has a `fit(X, y)` form with the intercept beside it (0.15.0):
146
+
147
+ | data in the constructor | `fit(X, y)` | scikit-learn |
148
+ |---|---|---|
149
+ | `CovLasso` | `GramLasso`, `GramElasticNet` | Lasso, ElasticNet |
150
+ | `CovRidge` | `GramRidge` | Ridge |
151
+ | `DerivedLasso` | `DesignLasso` | Lasso |
152
+ | `CovLogistic` | `GramLogisticRegression` | LogisticRegression(l1_ratio=1, solver="liblinear") |
153
+ | `CovGroupLasso` | `GramGroupLasso` (`groups=None`: the lasso) | (none) |
154
+ | `CovMultiTaskLasso` | `GramMultiTaskLasso`, `GramMultiTaskElasticNet` | MultiTaskLasso, MultiTaskElasticNet |
155
+
156
+ Against scikit-learn on 400 x 30 with an intercept: `DesignLasso` and
157
+ `GramGroupLasso(groups=None)` agree with `Lasso` to 1.5e-10, the
158
+ multi-task pair with `MultiTaskLasso` / `MultiTaskElasticNet` to
159
+ 5e-14, and `GramLogisticRegression` reaches liblinear's objective to
160
+ eight digits with the same predictions.
161
+
162
+ Against the class each one wraps (one alpha, one core), which fits no
163
+ intercept and is unchanged: with `fit_intercept=False` the input checks
164
+ add 15 to 30 us a call (up to +50% on a 0.06 ms fit) and the finiteness
165
+ sum 8 to 14% on 20000 x 200 (none under `assume_finite`); with the
166
+ intercept, the column means add 20 to 30% on 20000 x 200 for the
167
+ Gram-matrix ones, which correct X'X
168
+ for the means rather than copy X to centre it, and about 55% for
169
+ `DesignLasso`, whose kernel needs the centred X itself.
170
+ `GramLogisticRegression` is slower than liblinear by its algorithm, 2.5x
171
+ at 20000 x 200 and more on small problems; `L1LogisticRegression` is
172
+ the fast one for that problem.
173
+
174
+ What it costs is the input checks: X must be a finite 2-D float64
175
+ array with the feature count of the fit. A plain C-ordered float64
176
+ ndarray is checked directly (one sum for finiteness); anything else (a
177
+ list, a DataFrame, another dtype) goes through scikit-learn's
178
+ `validate_data`, which raises the errors scikit-learn raises. The
179
+ checks alone, per call (`bench/sklearn-compat-overhead.py --checks`):
180
+
181
+ | X | fit | predict | predict, 50 rows | validate_data |
182
+ |---|---|---|---|---|
183
+ | 200 x 10 | 13 us | 7 us | 9 us | 173 us |
184
+ | 2000 x 50 | 47 us | 36 us | 9 us | 224 us |
185
+ | 20000 x 200 | 2.7 ms | 3.2 ms | 13 us | 3.2 ms |
186
+
187
+ The finiteness sum is the size-dependent part; under
188
+ `sklearn.set_config(assume_finite=True)` every row is about 3 us.
189
+ Whole calls, before and after (`bench/sklearn-compat-overhead.py`, 30
190
+ estimators, three shapes, the two packages alternating on one core of
191
+ a loaded machine): fits of a millisecond or more are unchanged within
192
+ the run-to-run spread (about 10%); fits of about 0.1 ms take 10 to
193
+ 30% (10 to 40 us) longer; predict on 20000 x 200 takes about twice as
194
+ long (3 ms to 6 ms, the finiteness sum costing as much as the product
195
+ itself), and on small inputs 5 to 10 us longer.
196
+
197
+ ## Estimators at a glance
198
+
199
+ Every estimator, the scikit-learn estimator it reproduces, the Scheme
200
+ kernel under `examples/kernel-only/` it is translated from, the builds
201
+ it runs on, and whether Intel's sklearnex accelerates the scikit-learn
202
+ side. Builds: *loops* is the plain translation, *BLAS* / *cuBLAS* the
203
+ products handed to (cu)BLAS, *--cuda* the translator's GPU kernels
204
+ (`gpu_cuda/`), *GPU batch* the hand-written CUDA batch solver; the
205
+ package picks the fastest one present.
206
+
207
+ | class | scikit-learn | Scheme kernel | builds | sklearnex accelerates |
208
+ |---|---|---|---|---|
209
+ | `GramLasso` | Lasso(precompute=True), fit(X, y) with the intercept | `lasso-cov.scm` | CPU | no |
210
+ | `GramElasticNet` | ElasticNet(precompute=True), fit(X, y) with the intercept | `lasso-cov.scm` | CPU | no |
211
+ | `GramRidge` | Ridge, fit(X, y) with the intercept, one or many targets | `gram.scm` (Cholesky; dposv) | loops / BLAS | Ridge |
212
+ | `GramRidgeCV` | RidgeCV (cv=None), leave-one-out over every alpha from one eigenproblem | `ridge-gcv.scm` | loops / BLAS | no |
213
+ | `GramMultiTaskLasso` | MultiTaskLasso, fit(X, Y) with the intercept | `lasso-cov.scm` | CPU | no |
214
+ | `GramMultiTaskElasticNet` | MultiTaskElasticNet, fit(X, Y) with the intercept | `lasso-cov.scm` | CPU | no |
215
+ | `GramGroupLasso` | (none; skglm GroupLasso), fit(X, y) with the intercept | `lasso-cov.scm` | CPU | - |
216
+ | `GramLogisticRegression` | LogisticRegression(l1_ratio=1, solver='liblinear'), by majorization | `lasso-cov.scm` | CPU | no |
217
+ | `DesignLasso` | Lasso, fit(X, y) with the intercept, from the design | `lasso-design.scm (--derive)` | loops / BLAS / cuBLAS | no |
218
+ | `CovLasso` | Lasso, ElasticNet, lasso_path(precompute=True) | `lasso-cov.scm` | CPU; GPU batch (fit_path_batch, bootstrap) | no |
219
+ | `CovLassoCV` | LassoCV, ElasticNetCV | `lasso-cov.scm` | CPU; GPU batch | no |
220
+ | `DerivedLasso` | lasso_path, from the design | `lasso-design.scm (--derive)` | loops / BLAS / cuBLAS | no |
221
+ | `CovMultiTaskLasso` | MultiTaskLasso, MultiTaskElasticNet | `lasso-cov.scm` | CPU | no |
222
+ | `CovMultiTaskLassoCV` | MultiTaskLassoCV, MultiTaskElasticNetCV | `lasso-cov.scm` | CPU; GPU batch | no |
223
+ | `CovGroupLasso` | (none; skglm GroupLasso) | `lasso-cov.scm` | CPU | - |
224
+ | `CovLogistic` | LogisticRegression(penalty='l1'), by majorization | `lasso-cov.scm` | CPU | no |
225
+ | `CovRidge` | Ridge(fit_intercept=False), a whole alpha path | `lasso-cov.scm` | CPU | Ridge |
226
+ | `CovBayesianRidge` | BayesianRidge | `lasso-cov.scm` | CPU | no |
227
+ | `CovARDRegression` | ARDRegression (n >= p) | `ard.scm` | loops / BLAS | no |
228
+ | `GramLars` | Lars | `lars.scm` | CPU | no |
229
+ | `GramLassoLars` | LassoLars | `lars.scm` | CPU | no |
230
+ | `GramLassoLarsIC` | LassoLarsIC | `lars.scm` | CPU; device Gram | no |
231
+ | `GramLarsCV` | LarsCV | `lars.scm` | CPU | no |
232
+ | `GramLassoLarsCV` | LassoLarsCV | `lars.scm` | CPU; device Gram | no |
233
+ | `GramOMP` | OrthogonalMatchingPursuit | `omp.scm` | loops / BLAS | no |
234
+ | `GramOMPCV` | OrthogonalMatchingPursuitCV | `omp.scm` | loops / BLAS | no |
235
+ | `LBFGSLogisticRegression` | LogisticRegression(solver='lbfgs') | `logistic-lbfgs.scm, lbfgs.scm` | loops / BLAS / --cuda | LogisticRegression |
236
+ | `LBFGSLogisticRegressionCV` | LogisticRegressionCV | `logistic-lbfgs.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
237
+ | `LBFGSHuberRegressor` | HuberRegressor | `huber.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
238
+ | `LBFGSPoissonRegressor` | PoissonRegressor | `glm.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
239
+ | `LBFGSGammaRegressor` | GammaRegressor | `glm.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
240
+ | `LBFGSTweedieRegressor` | TweedieRegressor | `glm.scm, lbfgs.scm` | loops / BLAS / --cuda | no |
241
+ | `LinearSVM` | LinearSVC(loss='hinge') | `svm-liblinear.scm` | CPU | no |
242
+ | `L1LinearSVM` | LinearSVC(penalty='l1', dual=False) | `svm-l1.scm` | CPU | no |
243
+ | `KernelSVM` | SVC | `svm-smo.scm` | CPU | SVC |
244
+ | `LinearSVR` | LinearSVR | `svr-liblinear.scm` | CPU | no |
245
+ | `KernelSVR` | SVR | `svm-smo.scm` | CPU | SVR |
246
+ | `L1LogisticRegression` | LogisticRegression(penalty='l1', solver='liblinear') | `logistic-l1.scm` | CPU | no |
247
+ | `SGDLinearClassifier` | SGDClassifier(shuffle=False) | `sgd.scm` | CPU | no |
248
+ | `SGDLinearRegressor` | SGDRegressor(shuffle=False) | `sgd.scm` | CPU | no |
249
+ | `SGDPerceptron` | Perceptron(shuffle=False) | `sgd.scm` | CPU | no |
250
+ | `LinearRANSAC` | RANSACRegressor (given subsets) | `ransac.scm` | CPU | no |
251
+ | `WeiszfeldTheilSen` | TheilSenRegressor | `theilsen.scm` | CPU | no |
252
+ | `CDNMF` | NMF(solver='cd') | `nmf-cd.scm (--derive)` | loops / BLAS / cuBLAS | no |
253
+ | `CDGraphicalLasso` | GraphicalLasso | `graphical-lasso.scm` | loops / BLAS | no |
254
+ | `EMGaussianMixture` | GaussianMixture(covariance_type='full') | `gmm.scm` | loops / BLAS / --cuda | no |
255
+ | `ParallelFastICA` | FastICA | `fastica.scm` | loops / BLAS / --cuda | no |
256
+ | `CovPCA` | PCA(svd_solver='covariance_eigh') | `pca.scm` | loops / BLAS / cuBLAS | PCA |
257
+ | `GramKernelPCA` | KernelPCA (no inverse_transform) | `kernel-pca.scm` | loops / BLAS | no |
258
+ | `GramLDA` | LinearDiscriminantAnalysis (svd, lsqr, eigen) | `lda.scm` | loops / BLAS | no |
259
+ | `GramTruncatedSVD` | TruncatedSVD on dense X (exact, as arpack) | `truncated-svd.scm` | loops / BLAS | no |
260
+ | `f_regression, f_classif` | f_regression, f_classif | `fscore.scm` | loops / BLAS / cuBLAS | no |
261
+ | `SelectKBest, SelectPercentile` | SelectKBest, SelectPercentile | `fscore.scm` | loops / BLAS / cuBLAS | no |
262
+ | `GramRFE` | RFE(Ridge), RFE(LinearRegression) | `rfe.scm` | loops / BLAS / cuBLAS | inside: Ridge |
263
+
264
+ ## Two kernels: `CovLasso` and `DerivedLasso`
265
+
266
+ Everything above, and the cross-validation, bootstrap and GPU methods
267
+ below, runs on one kernel: numpy forms the Gram matrix, and a
268
+ hand-written coordinate-descent sweep over it
269
+ (`examples/kernel-only/lasso-cov.scm` in the repository, translated to
270
+ C++) does the rest. That is the usable one.
271
+
272
+ `DerivedLasso` is the other kernel, and it is there to show the
273
+ translator's work rather than to be the better estimator. Its source
274
+ is `examples/kernel-only/lasso-design.scm`: the residual-form
275
+ coordinate descent -- the algorithm inside scikit-learn's
276
+ `Lasso(precompute=False)` -- written as flat loops over X exactly as
277
+ numpy holds it (n rows of p, `x[i*p+j]`), the residual copied from y
278
+ and the column norms taken inside, with no Gram matrix anywhere in
279
+ it. The translator's `--derive` turns
280
+ that into the Gram form: it raises the loops to an array algebra,
281
+ notices that the residual is only ever updated by a scaled column and
282
+ read in dot products against columns, replaces it by the memo `c =
283
+ X'r` kept current through a Gram matrix it hoists in front of the
284
+ penalty path, folds the column norms into that Gram's diagonal, folds
285
+ the three products into one `dsyrk` and two `dgemv` calls (`--blas`), and puts the whole thing under
286
+ `(if (< p n) ..)` with the sweep as written in the else branch --
287
+ scikit-learn's `precompute='auto'` rule, derived rather than written.
288
+ The C++ of that derivation, as the translator emitted it, is what
289
+ `DerivedLasso` calls; numpy prepares nothing -- X and y go in as they
290
+ are, no transposed copy, no norms.
291
+
292
+ ```python
293
+ from scm2cpp_learn import DerivedLasso, derived_backend
294
+
295
+ model = DerivedLasso(X, y) # X and y; the kernel builds its own Gram
296
+ path = model.fit_path(model.lambda_grid()) # one call, the whole warm-started path
297
+ print(derived_backend()) # 'cublas', 'blas' or 'loops': what this machine built
298
+ gpu = DerivedLasso(X, y, backend="cublas") # insist on one
299
+ ```
300
+
301
+ A lambda is done when no coefficient moved by more than `tol`
302
+ (absolute, default 1e-8) in a sweep, or at `max_sweeps`; `tol=0.0`
303
+ asks for the exact fixed point. Three translations of the one
304
+ derivation are committed and `pip` builds what the machine allows:
305
+ `--derive --cublas` where the CUDA toolkit is (the three products on
306
+ the device through cuBLAS, X uploaded once per `fit_path` call --
307
+ taken by default when a device answers), `--derive --blas` where
308
+ `cblas.h` and an OpenBLAS are, and `--derive` alone otherwise, the
309
+ same program with its Gram matrix as a scalar loop nest, correct but
310
+ slow on large designs. `check-derived.py` in the package directory runs
311
+ the two kernels and scikit-learn on the same path and prints the
312
+ differences and the times; on AR(1) designs (rho 0.9), 20 penalties
313
+ from lambda_max to 0.01 lambda_max, one core, best of three (load
314
+ about 3), whole calls with the Gram matrix wherever it is built:
315
+
316
+ | n x p | 1,800 x 200 | 5,000 x 1000 | 100,000 x 200 | 100,000 x 500 |
317
+ |-----------------------------------------|-------------|--------------|---------------|---------------|
318
+ | `DerivedLasso`, cuBLAS (upload included) | 0.003 s | 0.024 s | 0.052 s | 0.11 s |
319
+ | `DerivedLasso`, CBLAS | 0.003 s | 0.076 s | 0.12 s | 0.44 s |
320
+ | `CovLasso(X, y).fit_path`, tol 1e-8 | 0.004 s | 0.080 s | 0.11 s | 0.42 s |
321
+ | sklearn `lasso_path(precompute=True)`, tol 1e-4 | 0.005 s | 0.10 s | 0.24 s | 0.76 s |
322
+
323
+ The CBLAS run is the same `dsyrk` and the same sweeps as `CovLasso`'s
324
+ numpy Gram and path, and times the same; the cuBLAS run is the same
325
+ program with the three products on the device, the upload of X
326
+ counted, and is 4.2x, 4.5x and 6.7x ahead of scikit-learn at the
327
+ three larger shapes -- the figures of the repository's equal-work
328
+ table, now reached from the package with nothing in front of the
329
+ kernel: the constructor keeps X and y and the kernel takes the
330
+ residual and the column norms itself (the norms folded into the Gram
331
+ diagonal by the derivation). Threads change the picture less than one might expect: scikit-learn's
332
+ sweeps are single-threaded Cython and only its Gram product scales,
333
+ so with 20 BLAS threads `lasso_path` takes 0.051 / 0.227 / 0.450 s on
334
+ the three larger shapes, against 0.023 / 0.050 / 0.113 s for the
335
+ cuBLAS build (2.2x, 4.5x, 4.0x) and 0.025 / 0.036 / 0.123 s for the
336
+ CBLAS build (2.0x, 6.3x, 3.7x), whose `dsyrk` scales with the
337
+ threads; the GPU figures do not move. Against a reference driven to
338
+ tol=1e-12, `DerivedLasso` lands within 5e-8 on every backend (its
339
+ tolerance is absolute, on one sweep's largest move), `CovLasso`
340
+ within 2e-10 (it tests every 20 sweeps and overshoots), and
341
+ scikit-learn at its default within 1e-3.
342
+
343
+ ### Deriving it yourself
344
+
345
+ Everything `DerivedLasso` runs is reproducible from the Scheme with
346
+ the translator; this is what `regenerate.sh` does for the package,
347
+ spelled out for one kernel (Racket 8 and a C++17 compiler; a CBLAS
348
+ for `--blas`, the CUDA toolkit for `--cublas`):
349
+
350
+ ```console
351
+ $ git clone https://github.com/niitsuma/scm2cpp && cd scm2cpp
352
+ $ raco link --user vendor/rkanren # once per machine
353
+ $ mkdir /tmp/k && cp examples/kernel-only/lasso-design.scm examples/kernel-only/soft-threshold.scm /tmp/k/
354
+ $ racket scm2cpp-file.scm -t scm2c.typ --derive --cublas -M -S /tmp/k/lasso-design.scm
355
+ derive: lasso-design: raise differencing merge matmul guard
356
+ ```
357
+
358
+ `derive:` on stderr names what fired: the loops raised to the array
359
+ algebra, the residual differenced into the memo, the column norms
360
+ merged into the Gram diagonal, the products folded to contractions,
361
+ the `(if (< p n) ..)` guard. The run leaves, beside the source,
362
+ `lasso-design.hpp` (the derived kernel as C++: one `blas_syrk`, two
363
+ `blas_gemv`, the sweep), `lasso-design_capi.cpp` and
364
+ `lasso_design.py` (the `-M` C ABI and its ctypes loader -- what the
365
+ package's `_loader_derived_*.py` are), and `lasso-design.expanded.scm`
366
+ (`-S`: the derived program as Scheme, before C++ -- read this to see
367
+ what the derivation did). `--blas` instead of `--cublas` gives the
368
+ CBLAS variant, neither the loop variant. To call it from numpy
369
+ without the package:
370
+
371
+ ```console
372
+ $ g++ -O2 -std=c++17 -shared -fPIC -I. -I$CUDA_HOME/include -o /tmp/k/liblasso-design.so \
373
+ /tmp/k/lasso-design_capi.cpp -L$CUDA_HOME/lib64 -lcublas -lcudart # -lopenblas for --blas
374
+ $ cd /tmp/k && python3 -c 'import lasso_design' # lasso_design.lasso_design(x, y, resid, xnorm, beta, lams, betas, iters, tol, n, p, nlam)
375
+ ```
376
+
377
+ (`-M` names the Python module after the file with `-` read as `_`,
378
+ and the loader looks for `liblasso-design.so` beside it.)
379
+ `examples/kernel-only/README.md` walks through the same `-M` flow on
380
+ the row-major kernel, and `python/PACKAGING.md` through turning such
381
+ a kernel into a pip package.
382
+
383
+ ## Against the other lassos pip can install
384
+
385
+ `bench/lasso-compare.py` in the repository runs the same
386
+ cross-validation-shaped grid -- 4096 lambdas, each solved from zero,
387
+ p=200, n=1800 -- against every lasso a `pip install` produces:
388
+ scikit-learn 1.9.1, celer 0.7.4, skglm 0.5, and RAPIDS cuML 26.8.
389
+ Every solver runs at its own default tolerance (ours at the tighter
390
+ 1e-8), each gets one untimed warm-up fit, cuML's data is on the device
391
+ before the clock starts, and the last column is each solver's
392
+ objective minus ours at the smallest lambda -- a fast row with a loose
393
+ answer would show as one:
394
+
395
+ | solver | time | objective gap |
396
+ |----------------------------------------|---------|---------------|
397
+ | scm2cpp-learn, 1 CPU core (tol 1e-8) | 0.91 s | 0 |
398
+ | scm2cpp-learn, GPU, one block/lambda | 0.044 s | 0 |
399
+ | sklearn `Lasso.fit` per lambda | 9.6 s | +1.6e-09 |
400
+ | celer per lambda | 13.5 s | 0 |
401
+ | skglm per lambda | 14.5 s | +2.8e-17 |
402
+ | cuML per lambda, GPU | 53.6 s | +9.1e-07 |
403
+
404
+ (One sitting on an otherwise idle machine, best of three runs; cuML
405
+ 26.08 on CUDA 12.9.)
406
+
407
+ celer and skglm are built for very large sparse designs, where their
408
+ screening rules dominate; at this size they pay their setup per fit.
409
+ cuML parallelises *within* one fit, which wins when a fit is large; at
410
+ this size its launch overhead dominates, while our GPU row
411
+ parallelises *across* lambdas -- one block of CUDA threads per
412
+ penalty, the axis a cross-validation grid actually offers.
413
+
414
+ `CovLassoCV` is scikit-learn's `LassoCV` over this machinery -- same
415
+ grid construction, same contiguous folds, same minimum-mean-MSE choice
416
+ -- with two structural savings. A fold's training Gram is a
417
+ *subtraction* (the Gram is additive over rows, so G - Xf'Xf costs one
418
+ fold's work where rebuilding costs four folds'), and once the Grams
419
+ exist nothing downstream ever touches the n rows again -- which is why
420
+ the gap grows with n. With a GPU the whole grid is one launch: one
421
+ *block* of threads per (fold, alpha) cell, the fold's Gram read in
422
+ place (cv Gram matrices on the device, not cv x 100), thread 0 of the
423
+ block doing the coordinate step and the block spreading the O(p)
424
+ update of the correlations. cv x 100 = 500 cells are too few
425
+ problems to fill a device one thread each, and a block per problem is
426
+ what turns that count into enough parallel work; `bench/cv-grid-designs.py`
427
+ times the alternatives (one thread per cell, warm runs of several
428
+ alphas per thread or block) side by side, and the block per cell wins
429
+ at every size tried. The GPU is the default when a device answers
430
+ (`force_cpu` takes the warm CPU path). Measured at cv=5, 100 alphas,
431
+ one CPU core, sklearn 1.9.1, best of three runs, whole estimator
432
+ including the Grams:
433
+
434
+ | n | p | CPU | CUDA | sklearn `LassoCV` |
435
+ |---------|------|--------|--------|-------------------|
436
+ | 1,800 | 200 | 0.21 s | 0.02 s | 0.10 s |
437
+ | 5,000 | 1000 | 1.48 s | 0.25 s | 0.90 s |
438
+ | 100,000 | 200 | 0.49 s | 0.34 s | 1.72 s |
439
+ | 100,000 | 500 | 1.32 s | 1.09 s | 4.82 s |
440
+
441
+ (Taken on an otherwise idle machine, load 2-3 on 20 threads. sklearn's
442
+ `LassoCV` at its default `precompute='auto'` takes the Gram route at
443
+ every size here, n > p, rebuilding each fold's Gram from that fold's
444
+ rows where we subtract it from the whole.) The grid
445
+ launch itself takes 9 ms at n=1,800 and 64 ms at n=5,000, p=1000; at
446
+ n=100,000 the CUDA column is nearly all Gram products, which the CPU
447
+ path pays as well. Large n is where the machinery pays either way:
448
+ nothing downstream of the Grams touches the 100,000 rows again. At
449
+ n=5,000, p=1000 sklearn's raw-X descent beats our Gram-heavy CPU path,
450
+ and the GPU makes up the difference three and a half times over. CPU and CUDA
451
+ choose the same alpha everywhere and agree to 1e-14; against sklearn
452
+ the pick is exact at n=5,000 and one grid step apart on a near-tie
453
+ elsewhere (mean-MSE relative gap at most 2.6e-4), and sklearn at
454
+ tol=1e-10 picks our alpha at every size.
455
+
456
+ The elastic net cross-validates the same way: `CovLassoCV(l1_ratio=0.5)`
457
+ against `ElasticNetCV(l1_ratio=0.5)` on the same folds and alpha grid
458
+ (`bench/lasso-compare.py --cv-only --l1-ratio 0.5`, same conditions,
459
+ load under 1):
460
+
461
+ | n | p | CPU | CUDA | sklearn `ElasticNetCV` |
462
+ |---------|------|--------|--------|------------------------|
463
+ | 1,800 | 200 | 0.35 s | 0.02 s | 0.11 s |
464
+ | 5,000 | 1000 | 1.55 s | 0.26 s | 0.98 s |
465
+ | 100,000 | 200 | 0.52 s | 0.35 s | 1.76 s |
466
+ | 100,000 | 500 | 1.38 s | 1.15 s | 4.97 s |
467
+
468
+ The picture is the lasso's: on the CPU, 3.4-3.6x at 100,000 rows and
469
+ slower than sklearn at the two small shapes; with CUDA, 3.8-5.5x
470
+ everywhere. The chosen alpha is sklearn's at all four sizes, the
471
+ coefficients within 8e-6 (sklearn's default tolerance).
472
+
473
+ `CovMultiTaskLasso` and `CovMultiTaskLassoCV` are the multi-task
474
+ family over the same machinery: scikit-learn's `MultiTaskLasso`,
475
+ `MultiTaskElasticNet` (through `l1_ratio`), `MultiTaskLassoCV` and
476
+ `MultiTaskElasticNetCV`. The penalty ties each feature's row of W
477
+ together across tasks, so the coordinate update becomes a block soft
478
+ threshold on the row's L2 norm; C = X'Y - GW is maintained per task
479
+ exactly as the single-task C is, and nothing downstream of the Grams
480
+ touches the n rows -- the kernel is the same translated Scheme
481
+ (`mt-descend`). Folds are independent and both the descent (ctypes)
482
+ and the products (BLAS) release the GIL, so `n_jobs` threads scale the
483
+ CV across folds; the default is sequential, as scikit-learn's
484
+ `n_jobs=None` is, and the benchmark pins BLAS to one thread on both
485
+ sides either way. With a GPU the CV grid is one launch exactly as
486
+ `CovLassoCV`'s is, a block per (fold, alpha) cell with the p x T
487
+ coefficient block in shared memory (`force_cpu` opts out). Same
488
+ protocol as above, 8 tasks; single fits at alpha = 0.1 lambda_max;
489
+ the CV pairs share the 100-alpha grid:
490
+
491
+ | estimator | n | p | ours | ours `n_jobs=5` | ours CUDA | sklearn |
492
+ |-----------------------|---------|-----|--------|-----------------|-----------|---------|
493
+ | MultiTaskLasso | 100,000 | 200 | 0.14 s | -- | -- | 0.76 s |
494
+ | MultiTaskLasso | 100,000 | 500 | 0.53 s | -- | -- | 1.7 s |
495
+ | MultiTaskLassoCV | 1,800 | 200 | 1.4 s | 0.51 s | 0.07 s | 0.42 s |
496
+ | MultiTaskLassoCV | 100,000 | 200 | 1.6 s | 0.64 s | 1.3 s | 74 s |
497
+ | MultiTaskElasticNetCV | 1,800 | 200 | 1.4 s | 0.49 s | 0.08 s | 0.75 s |
498
+ | MultiTaskElasticNetCV | 100,000 | 200 | 1.6 s | 0.68 s | 1.3 s | 162 s |
499
+
500
+ (`MultiTaskElasticNet` single fits time the same as `MultiTaskLasso`'s;
501
+ every column was taken in one sitting on the idle machine of the
502
+ `CovLassoCV` table above, sklearn 1.9.1. scikit-learn has no Gram
503
+ route for its multi-task estimators -- its path code sets
504
+ `precompute=False` for 2-D targets, "No Gram variant of multi-task
505
+ exists right now" -- so its column is raw-X descent at every n, which
506
+ is why it grows with n where `LassoCV`'s did not.) At n=1,800 our sequential CV
507
+ is slower than sklearn's: the default tol=1e-8 runs more sweeps than
508
+ sklearn's 1e-4 dual-gap stop, and at that size there are no rows to
509
+ save -- and the GPU grid takes the whole thing in a tenth of a second.
510
+ At n=100,000 the Gram route is 46x and 100x ahead, essentially
511
+ unchanged from n=1,800 in absolute time; the CUDA column there is
512
+ the Gram products, the same ones the CPU path forms. Coefficients agree with scikit-learn's to 1e-15 at tight
513
+ tolerance, and the CV pair picks the same alpha with coefficients to
514
+ 1e-13.
515
+
516
+ ## Which method
517
+
518
+ `fit_path` walks a single path, each lambda starting from the previous
519
+ solution -- the descent is exactly resumable, so this costs almost
520
+ nothing per lambda after the first. `fit_path_batch` solves every
521
+ lambda from zero, which is what a cross-validation grid needs, where
522
+ the folds differ and warm starting across lambdas is not on offer; the
523
+ problems are independent, so they go to the GPU together.
524
+
525
+ Over a 400-lambda path at p=200 and 1800 rows, on an RTX 4090 and an
526
+ i9-10900X:
527
+
528
+ | call | time |
529
+ |---------------------------------------|---------|
530
+ | `fit_path` (warm, sequential) | 0.091 s |
531
+ | `fit_path_batch` (GPU) | 0.047 s |
532
+ | `fit_path_batch(force_cpu=True)` | 0.178 s |
533
+
534
+ GPU and CPU agree to 2e-14, and the objectives are within 3e-17 of
535
+ scikit-learn's `lasso_path` on the same grid.
536
+
537
+ ## Elastic net and ridge
538
+
539
+ The same Gram matrix serves two more estimators. `fit_path` and
540
+ `fit_path_batch` take `l1_ratio` (scikit-learn's mixing parameter):
541
+ the L2 share of the penalty enters only the update's denominator, so
542
+ the elastic net runs on the identical machinery, GPU path included,
543
+ and `l1_ratio=1` is bit-for-bit the lasso.
544
+
545
+ `CovRidge` is the closed form: one symmetric eigendecomposition,
546
+ then every alpha costs O(p^2), so thousands of alphas cost what one
547
+ does. Its objective matches scikit-learn's `Ridge` with
548
+ `fit_intercept=False` (which, unlike the lasso, is not scaled by the
549
+ number of rows), and agrees with it to machine precision.
550
+
551
+ ```python
552
+ path = model.fit_path(lambdas, l1_ratio=0.5) # elastic net
553
+ ridge = CovRidge(X, y)
554
+ betas = ridge.fit_path(ridge.alpha_grid()) # a whole ridge path
555
+ ```
556
+
557
+ Measured (`bench/ridge-enet-compare.py` in the repository: AR(1)
558
+ designs, 20 penalties, whole calls with the Gram matrix wherever one
559
+ is built, one core, best of three):
560
+
561
+ | n x p | 1,800 x 200 | 5,000 x 1000 | 100,000 x 200 | 100,000 x 500 |
562
+ |----------------------------------------------|-------------|--------------|---------------|---------------|
563
+ | elastic net, `CovLasso.fit_path(l1_ratio=0.5)`, tol 1e-8 | 0.007 s | 0.092 s | 0.11 s | 0.40 s |
564
+ | elastic net, sklearn `enet_path(precompute=True)`, tol 1e-4 | 0.005 s | 0.099 s | 0.23 s | 0.74 s |
565
+ | ridge, `CovRidge.fit_path(alphas)` | 0.004 s | 0.21 s | 0.10 s | 0.41 s |
566
+ | ridge, sklearn `Ridge(alpha).fit` per alpha | 0.060 s | 2.05 s | 3.37 s | 11.2 s |
567
+ | ridge, sklearn `RidgeCV(alphas)` (its LOO path) | 0.093 s | 3.40 s | 5.38 s | 19.0 s |
568
+
569
+ The elastic net is the same coordinate descent on both sides, so the
570
+ two rows differ by the Gram and the warm path, not the sweeps: level
571
+ at small n, 1.8-2.2x at 100,000 rows, and ours lands within 2e-10 of
572
+ a tight reference where scikit-learn's default tolerance lands within
573
+ 6e-4. The ridge rows compare a whole path against what a user
574
+ writes -- one `Ridge.fit` per alpha, each forming X'X and solving
575
+ afresh, O(np^2 + p^3) every time -- so the gap is the path's: 10-33x,
576
+ agreeing to 1e-14. `RidgeCV` is given for scale (it picks one alpha
577
+ by leave-one-out rather than returning the path). Neither row runs
578
+ on the GPU: the ridge is one eigendecomposition and the elastic net's
579
+ sweeps are the lasso's; `fit_path_batch(l1_ratio=..)` is the GPU
580
+ grid for the elastic net.
581
+
582
+ ## Leave-one-out ridge: `GramRidgeCV`
583
+
584
+ `GramRidgeCV` is scikit-learn's `RidgeCV` with `cv=None`: the alpha
585
+ whose leave-one-out mean squared error is least, without a refit per
586
+ row. With the centred design Xc and H = Xc (Xc'Xc + alpha I)^-1 Xc'
587
+ (plus 1/n for the intercept), the leave-one-out residual is
588
+ (y - yhat) / (1 - diag H). One eigenproblem gives H = Z diag(1 /
589
+ (lam + alpha)) Z' for every alpha: Z = Xc V from Xc'Xc = V diag(lam) V'
590
+ when n > p (X'X by dsyrk), Z = Q diag(sqrt mu) from Xc Xc' = Q diag(mu)
591
+ Q' otherwise. Then diag H and yhat at all the alphas are two products,
592
+ (Z o Z) W and Z (R o W) with W[k, a] = 1 / (lam_k + alpha_a): the
593
+ alphas cost one dgemm, where scikit-learn loops over them in numpy
594
+ (`examples/kernel-only/ridge-gcv.scm`; the eigenproblem is LAPACK's
595
+ dsyevd in the BLAS build).
596
+
597
+ ```python
598
+ from scm2cpp_learn import GramRidgeCV
599
+ m = GramRidgeCV(alphas=np.logspace(-2, 4, 50)).fit(X, y)
600
+ m.alpha_, m.best_score_, m.coef_, m.intercept_
601
+ ```
602
+
603
+ It takes `fit_intercept`, `gcv_mode` (`"svd"` / `"eigen"` choose the
604
+ two Zs), `store_cv_results` and `alpha_per_target`, y one target or
605
+ several; `cv` and `scoring` must stay None. The chosen alpha is
606
+ scikit-learn's, the coefficients agree to 1e-12 and `cv_results_` to
607
+ 1e-7 relative (the wide case near interpolation). Measured
608
+ (`bench/eigen-compare.py`, the MKL build, best of three):
609
+
610
+ | case | scikit-learn `RidgeCV` | `GramRidgeCV` | ratio, one core | ratio, 20 cores |
611
+ |---|---|---|---|---|
612
+ | 200,000 x 200, 3 alphas | 2.04 s | 0.80 s | 2.6x | 3.6x |
613
+ | 200,000 x 200, 50 alphas | 26.6 s | 0.96 s | 27.7x | 29.0x |
614
+ | 20,000 x 500, 50 alphas | 9.64 s | 0.33 s | 28.9x | 29.2x |
615
+ | 2,000 x 5,000, 50 alphas | 3.16 s | 1.47 s | 2.1x | 4.5x |
616
+
617
+ ## Kernel PCA: `GramKernelPCA`
618
+
619
+ `GramKernelPCA` is scikit-learn's `KernelPCA`: the kernel matrix
620
+ (linear, rbf, poly, sigmoid, cosine or precomputed), centred in feature
621
+ space, its largest eigenpairs with svd_flip's signs
622
+ (`examples/kernel-only/kernel-pca.scm`). Where the eigenpairs come from
623
+ is what differs:
624
+
625
+ - fewer than 10 components of more than 200 rows, where scikit-learn
626
+ takes ARPACK (Lanczos, one matrix-vector product at a time): subspace
627
+ iteration on c + 25 columns, so each step is one dgemm, stopped when
628
+ every residual |K u - lam u| is under 1e-12 of the largest value
629
+ (falling back to the dense route if it is not reached);
630
+ - otherwise LAPACK's dsyevr over the wanted index range, which is what
631
+ scipy's `eigh(subset_by_index=..)` runs for scikit-learn;
632
+ - the linear kernel with no more components than features, n > p:
633
+ through Xc'Xc, p x p (the eigenvectors of Xc Xc' are Xc v / sqrt(lam)),
634
+ where scikit-learn forms the n x n kernel.
635
+
636
+ ```python
637
+ from scm2cpp_learn import GramKernelPCA
638
+ Z = GramKernelPCA(5, kernel="rbf", gamma=0.05).fit_transform(X)
639
+ ```
640
+
641
+ Eigenvalues, eigenvectors and transforms agree with scikit-learn's to
642
+ 1e-12 on every kernel; no `inverse_transform`. Measured
643
+ (`bench/eigen-compare.py`, the MKL build, best of three):
644
+
645
+ | case | scikit-learn `KernelPCA` | `GramKernelPCA` | ratio, one core | ratio, 20 cores |
646
+ |---|---|---|---|---|
647
+ | rbf, 3,000 x 20, 5 components | 1.11 s (ARPACK) | 0.35 s (subspace) | 3.2x | 3.0x |
648
+ | rbf, 6,000 x 20, 5 components | 4.63 s (ARPACK) | 1.33 s (subspace) | 3.5x | 4.2x |
649
+ | rbf, 3,000 x 20, 50 components | 3.48 s (dense) | 3.63 s (dense) | 1.0x | 1.2x |
650
+ | linear, 5,000 x 50, 10 components | 19.8 s (ARPACK on 5,000 x 5,000) | 0.002 s (X'X) | 10,000x | 2,100x |
651
+
652
+ The linear row is the algorithm (an n x n problem against a p x p one),
653
+ not the code.
654
+
655
+ ## Linear discriminant analysis: `GramLDA`
656
+
657
+ `GramLDA` is scikit-learn's `LinearDiscriminantAnalysis` with the n x p
658
+ work moved to the Gram matrix (`examples/kernel-only/lda.scm`): one pass
659
+ gives the class means and X'X (dsyrk), and the within-class covariance
660
+ is Sw = (X'X - sum_k n_k mu_k mu_k') / n. Each solver then runs its own
661
+ steps on p x p matrices:
662
+
663
+ - `"svd"` (the default) takes the SVD of the standardised within-class
664
+ design Xc D^-1 / sqrt(n), n x p. Its right singular vectors and values
665
+ are the eigenvectors and square roots of the eigenvalues of
666
+ D^-1 Sw D^-1, so one p x p eigenproblem (dsyevd) replaces it; the
667
+ second, classes x rank SVD and the coefficients follow as in
668
+ scikit-learn;
669
+ - `"lsqr"` solves Sw W = M' by Cholesky;
670
+ - `"eigen"` solves Sb v = lam Sw v (`sym-eigen-gen`, LAPACK's dsygvd).
671
+
672
+ ```python
673
+ from scm2cpp_learn import GramLDA
674
+ clf = GramLDA().fit(X, y) # solver="svd"
675
+ clf.predict(X), clf.transform(X)
676
+ ```
677
+
678
+ `shrinkage` takes None or a float (it is linear in Sw, so the Gram route
679
+ keeps it), not "auto"; custom `priors` form a Gram per class. The
680
+ coefficients, intercepts, probabilities and explained variance agree
681
+ with scikit-learn's to 1e-12, the predictions exactly, and `transform`
682
+ up to the sign of each column. Measured (`bench/eigen-compare.py`, the
683
+ MKL build, best of three):
684
+
685
+ | case | scikit-learn | `GramLDA` | ratio, one core | ratio, 20 cores |
686
+ |---|---|---|---|---|
687
+ | svd (default), 200,000 x 100, 5 classes | 4.80 s | 0.092 s | 52x | 35x |
688
+ | svd, 50,000 x 500, 10 classes | 4.07 s | 0.25 s | 16x | 14x |
689
+ | lsqr, 200,000 x 100, 5 classes | 0.42 s | 0.089 s | 4.7x | 8.9x |
690
+ | eigen, 200,000 x 100, 5 classes | 0.56 s | 0.094 s | 6.0x | 12x |
691
+
692
+ ## Truncated SVD: `GramTruncatedSVD`
693
+
694
+ `GramTruncatedSVD` is scikit-learn's `TruncatedSVD` on a dense X
695
+ (`examples/kernel-only/truncated-svd.scm`): the leading pairs of X'X
696
+ (X'X by dsyrk; X X' when X is wide) by dsyevr over the wanted index
697
+ range, the transformed data by one dgemm, svd_flip's signs. The answer
698
+ is the exact truncated SVD, which `algorithm="arpack"` computes and the
699
+ default `"randomized"` approximates: against arpack the components agree
700
+ to 1e-13, against randomized they differ by its error (up to 1.3 on a
701
+ slowly falling spectrum). Measured (`bench/eigen-compare.py`, the MKL
702
+ build, best of three):
703
+
704
+ | case | scikit-learn | `GramTruncatedSVD` | ratio, one core | ratio, 20 cores |
705
+ |---|---|---|---|---|
706
+ | 200,000 x 100, 10 components, randomized | 1.16 s | 0.22 s | 5.4x | 7.0x |
707
+ | 200,000 x 100, 10 components, arpack | 4.46 s | 0.22 s | 20x | 25x |
708
+ | 50,000 x 500, 20 components, randomized | 1.27 s | 0.39 s | 3.3x | 5.1x |
709
+ | 50,000 x 500, 20 components, arpack | 10.1 s | 0.41 s | 24x | 21x |
710
+ | 5,000 x 2,000, 20 components, randomized | 0.46 s | 0.96 s | 0.5x | 3.5x |
711
+ | 5,000 x 2,000, 20 components, arpack | 5.17 s | 1.01 s | 5.1x | 9.1x |
712
+
713
+ The exact route costs O(n p^2 + p^3); with few components of many
714
+ features (2,000 here) the randomized one does less work, and on one core
715
+ it is faster, at its accuracy. Sparse X is not taken yet.
716
+
717
+ ## Bayesian ridge
718
+
719
+ `CovBayesianRidge` is scikit-learn's `BayesianRidge` -- the same model,
720
+ the same defaults, the same iteration (MacKay's updates of the noise
721
+ precision `alpha_` and the weight precision `lambda_`, stopping when
722
+ the posterior mean moves by less than `tol`) -- with the linear
723
+ algebra moved into the eigenbasis of the Gram matrix. scikit-learn
724
+ takes the SVD of X once and multiplies by X again in every iteration
725
+ for the squared error. Here X enters once, through X'X (X X' when
726
+ n <= p): one eigendecomposition, after which the posterior mean, its
727
+ norm and the squared error are sums over the eigenvalues, O(p) per
728
+ iteration. `sigma_` (the posterior covariance) and
729
+ `predict(return_std=True)` are there as in scikit-learn.
730
+
731
+ ```python
732
+ from scm2cpp_learn import CovBayesianRidge
733
+ m = CovBayesianRidge().fit(X, y) # fit_intercept=True, as sklearn
734
+ mean, std = m.predict(X_new, return_std=True)
735
+ ```
736
+
737
+ Measured (`bench/bayesian-ridge-compare.py` in the repository:
738
+ Gaussian designs, a sparse truth plus noise, whole fits including
739
+ `sigma_`, one core, best of three, sklearn 1.9.1):
740
+
741
+ | n x p | `CovBayesianRidge` | sklearn `BayesianRidge` | ratio | iterations (both) | max coefficient difference |
742
+ |----------------|--------------------|-------------------------|-------|-------------------|----------------------------|
743
+ | 1,800 x 200 | 0.005 s | 0.026 s | 5.2x | 3 | 3.6e-15 |
744
+ | 5,000 x 1000 | 0.257 s | 1.231 s | 4.8x | 4 | 6.7e-15 |
745
+ | 100,000 x 200 | 0.126 s | 3.495 s | 27.8x | 3 | 2.8e-14 |
746
+ | 100,000 x 500 | 0.475 s | 8.909 s | 18.8x | 3 | 1.6e-14 |
747
+ | 500 x 5000 | 0.662 s | 8.689 s | 13.1x | 71 | 1.7e-15 |
748
+
749
+ The iteration counts are identical and the coefficients agree to
750
+ rounding, so the gap is the linear algebra alone: X'X by one BLAS
751
+ product against a full SVD of the tall X, and no pass over X inside
752
+ the loop. At 500 x 5000 scikit-learn takes the full 5000 x 5000 SVD
753
+ for `sigma_`; here `sigma_` comes from the 500 x 500 kernel side by
754
+ the Woodbury identity. This estimator is numpy, not translated code:
755
+ the eigendecomposition is LAPACK's either way.
756
+
757
+ ## Classifiers and SVRs: `LinearSVM`, `KernelSVM`, `LinearSVR`, `KernelSVR`, `L1LogisticRegression`
758
+
759
+ Three classifiers and two regressors, each the algorithm scikit-learn runs, written out
760
+ in the Scheme subset and translated plainly (no derivation; the C++
761
+ is committed under `_generated/classifiers*`):
762
+
763
+ | class | algorithm | scikit-learn counterpart |
764
+ |---|---|---|
765
+ | `LinearSVM` | liblinear's dual coordinate descent for the hinge loss (random order, shrinking) | `LinearSVC(loss="hinge", dual=True, fit_intercept=False)` |
766
+ | `KernelSVM` | libsvm's SMO (WSS3, shrinking, the bias) over the whole kernel matrix | `SVC` (`kernel="rbf"`, `"linear"` or `"precomputed"`) |
767
+ | `L1LogisticRegression` | liblinear's newGLMNET (Newton over coordinate descent, line search) | `LogisticRegression(l1_ratio=1, solver="liblinear", fit_intercept=False)` |
768
+ | `LinearSVR` | liblinear's dual coordinate descent for the epsilon-insensitive loss (random order, shrinking) | `LinearSVR(loss="epsilon_insensitive", dual=True, fit_intercept=False)` |
769
+ | `KernelSVR` | the same SMO as `KernelSVM`, set up as libsvm's epsilon-SVR (2n variables) | `SVR` (`kernel="rbf"`, `"linear"` or `"precomputed"`) |
770
+
771
+ ```python
772
+ from scm2cpp_learn import LinearSVM, KernelSVM, L1LogisticRegression
773
+ LinearSVM(C=1.0).fit(X, y).predict(X_new)
774
+ KernelSVM(C=1.0, gamma="scale").fit(X, y).decision_function(X_new)
775
+ L1LogisticRegression(C=0.1).fit(X, y).predict_proba(X_new)
776
+ ```
777
+
778
+ The classifiers take two classes (the second of `classes_` is the
779
+ positive one), and `tol` / `max_iter` mean what they mean in scikit-learn.
780
+ `KernelSVM` and `KernelSVR` build the kernel matrix in numpy and keep all of it,
781
+ n x n doubles (800 MB at n = 10,000), where `SVC` computes rows on
782
+ demand through a cache; that is its price.
783
+
784
+ Measured with `check-classifiers.py` (in this directory): the same
785
+ problem and stopping rule on both sides, one core, best of three,
786
+ sklearn 1.9.1, load average under 2. Ours includes building the kernel
787
+ matrix (`KernelSVM`) or transposing X into columns
788
+ (`L1LogisticRegression`):
789
+
790
+ | problem | ours | scikit-learn | ratio | objective (ours / sklearn) | same predictions |
791
+ |---|---|---|---|---|---|
792
+ | `LinearSVM`, 5,000 x 200 | 0.379 s | 0.598 s | 1.6x | 1541.06 / 1541.29 | 99.5% |
793
+ | `LinearSVM`, 100,000 x 200 | 23.0 s | 41.7 s | 1.8x | 34503.5 / 34500.7 | 98.9% |
794
+ | `LinearSVM`, 2,000 x 20,000 | 0.975 s | 1.894 s | 1.9x | 0.052484 / 0.052428 | 100% |
795
+ | `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% |
796
+ | `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% |
797
+ | `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% |
798
+ | `L1LogisticRegression`, 5,000 x 200, C=0.1 | 0.064 s | 0.103 s | 1.6x | 61.676698 / 61.676708 | 100% |
799
+ | `L1LogisticRegression`, 20,000 x 500, C=0.05 | 1.20 s | 2.23 s | 1.9x | 106.77913 / 106.77915 | 100% |
800
+ | `L1LogisticRegression`, 100,000 x 200, C=0.05 | 9.06 s | 18.7 s | 2.1x | 349.61154 / 349.61216 | 100% |
801
+
802
+ On 2,000 x 20,000 neither side reaches the optimum at tol 1e-4 (the
803
+ objective at tol 1e-10 is 0.0519588), and where each stops depends on
804
+ the coordinate order: over eight seeds ours stops at 0.05240 to
805
+ 0.05251 (0.9 to 1.1% above the optimum), `LinearSVC`, whose order is
806
+ random unless `random_state` is given, at 0.05242 to 0.05319 (0.9 to
807
+ 2.4%). When `LinearSVC` draws one of its worse orders the gap passes
808
+ the script's 0.5% bound and the row prints `<-- disagrees`; ours is
809
+ then the better solution of the two, so the flag there is not a fault.
810
+
811
+ The regressors, from the same script (C = 1, epsilon 0.1):
812
+
813
+ | problem | ours | scikit-learn | ratio | agreement |
814
+ |---|---|---|---|---|
815
+ | `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 |
816
+ | `LinearSVR`, 20,000 x 500 | 22.1 s | 36.6 s | 1.7x | both at the cap; objective 3741.4 / 3712.2 |
817
+ | `KernelSVR` rbf, 1,000 x 10 | 0.023 s | 0.043 s | 1.9x | iterations 2,172 / 2,177; predictions within 4e-4 |
818
+ | `KernelSVR` rbf, 3,000 x 10 | 0.246 s | 0.332 s | 1.3x | iterations 7,471 / 7,581; within 5e-4 |
819
+ | `KernelSVR` rbf, 6,000 x 10 | 1.18 s | 1.38 s | 1.2x | iterations 16,805 / 16,630; within 5e-4 |
820
+
821
+ `LinearSVR` on these data does not reach its tolerance in 1,000 sweeps on
822
+ either side, and at the cap the random order alone moves the objective
823
+ by about 1% (scikit-learn's own seeds give 776-789 at 5,000 x 200);
824
+ with 20,000 sweeps both land on 728.58. `KernelSVR`'s gain shrinks
825
+ with n here because the 10 features make each kernel row cheap for
826
+ libsvm to compute, while ours reads the rows of a matrix of n^2
827
+ doubles; the predictions agree to within scikit-learn's tolerance.
828
+
829
+ The objectives agree to the stopping tolerance on both sides, not to
830
+ rounding. The linear SVM on tall data stops at the 1,000-sweep cap on
831
+ both sides, so there the two land a hair apart and 1% of the
832
+ predictions differ. The gain is the same in all three: the same
833
+ algorithm, run as dense loops that vectorise, against libsvm's and
834
+ liblinear's sparse rows. The classifiers are compiled at `-O3` with
835
+ `-fstrict-overflow`: Python's build flags carry
836
+ `-fno-strict-overflow`, which keeps the compiler from treating the
837
+ flat index `i*p + j` as an induction variable and makes the linear
838
+ SVM 25% slower.
839
+
840
+ ## NMF: `CDNMF`
841
+
842
+ `CDNMF` is scikit-learn's `NMF(solver="cd")` -- Frobenius loss, no
843
+ regularization, no shuffling -- as the translator derives it.
844
+ `examples/kernel-only/nmf-cd.scm` writes the half step with the
845
+ residual R = X - W H and no Gram matrix. `--derive` memoises R H' over
846
+ the hoisted H H' and distributes the residual through the memo, so the
847
+ compiled half step is scikit-learn's `XHt - W HHt` with R never formed.
848
+ Like `DerivedLasso`, it is built in three variants (`nmf_backend()`
849
+ says which is taken: cublas when a device answers, else blas, else
850
+ loops).
851
+
852
+ ```python
853
+ from scm2cpp_learn import CDNMF
854
+ model = CDNMF(n_components=20, random_state=0) # init="random", as sklearn's
855
+ W = model.fit_transform(X) # H in model.components_
856
+ ```
857
+
858
+ `init="random"` draws the start as scikit-learn does, so the same seed
859
+ gives the same W and H. scikit-learn's default `"nndsvda"` is not
860
+ offered; pass `init="custom"` with its W and H for that start.
861
+ Measured with `check-classifiers.py --only nmf` against
862
+ `NMF(solver="cd", init="random")` from the same seed, tol 1e-4, at most
863
+ 200 iterations, one core:
864
+
865
+ | n x p, k | `CDNMF` cuBLAS | `CDNMF` BLAS | sklearn | iterations | max \|H - H_sklearn\| |
866
+ |---|---|---|---|---|---|
867
+ | 1,000 x 500, 10 | 0.441 s (0.6x) | 0.212 s (1.15x) | 0.244 s | 200 / 200 | 1.3e-13 |
868
+ | 5,000 x 2,000, 20 | 7.01 s (1.6x) | 8.27 s (1.36x) | 11.25 s | 200 / 200 | 4.1e-12 |
869
+ | 20,000 x 1,000, 30 | 16.9 s (1.8x) | 20.6 s (1.46x) | 30.1 s | 200 / 200 | 2.4e-12 |
870
+
871
+ The iterates are scikit-learn's to rounding. The cuBLAS variant pays
872
+ from the middle size on; at 1,000 x 500 its transfers cost more than
873
+ the products save, and `backend="blas"` is the faster choice there.
874
+
875
+ ## Graphical lasso and RANSAC: `CDGraphicalLasso`, `LinearRANSAC`
876
+
877
+ Two more of scikit-learn's algorithms, written out in the Scheme subset
878
+ and translated plainly.
879
+
880
+ - `CDGraphicalLasso` is `GraphicalLasso(mode="cd")`: `_graphical_lasso`
881
+ with `enet_coordinate_descent_gram` inside it, its two matrix-vector
882
+ products as dgemv. `alpha`, `tol`, `enet_tol`, `max_iter` and
883
+ `assume_centered` mean what they mean in scikit-learn. scikit-learn's
884
+ `costs_` (a log-determinant every iteration) is not computed.
885
+ - `LinearRANSAC` is `RANSACRegressor` around `LinearRegression`, with
886
+ the same defaults. With scikit-learn installed, the subsets are drawn
887
+ exactly as it draws them, so `random_state` gives scikit-learn's fit.
888
+
889
+ ```python
890
+ from scm2cpp_learn import CDGraphicalLasso, LinearRANSAC
891
+ prec = CDGraphicalLasso(alpha=0.1).fit(X).precision_
892
+ fit = LinearRANSAC(random_state=0).fit(X, y) # fit.coef_, fit.inlier_mask_
893
+ ```
894
+
895
+ Measured through the package, one core, best of three, against
896
+ scikit-learn on the same data (an AR(1) design with n = 20p for the
897
+ graphical lasso, 100 iterations on both sides; a linear model with
898
+ outliers for RANSAC, max_trials 1000):
899
+
900
+ | problem | ours | scikit-learn | ratio | agreement |
901
+ |---|---|---|---|---|
902
+ | `CDGraphicalLasso`, p = 50 | 0.0069 s | 0.306 s | 44.5x | 100 / 100 iterations, precision to 1.8e-14 |
903
+ | `CDGraphicalLasso`, p = 200 | 0.286 s | 1.62 s | 5.7x | precision to 2.6e-14 |
904
+ | `CDGraphicalLasso`, p = 800 | 33.9 s | 43.8 s | 1.3x | precision to 5.0e-14 |
905
+ | `LinearRANSAC`, 10,000 x 5 | 0.0254 s | 0.0916 s | 3.6x | 98 / 98 trials, same inliers |
906
+ | `LinearRANSAC`, 100,000 x 10 | 0.153 s | 0.241 s | 1.6x | 97 / 97 trials, same inliers |
907
+ | `LinearRANSAC`, 20,000 x 20 | 0.470 s | 1.19 s | 2.5x | 1,000 / 1,000 trials, same inliers |
908
+
909
+ Both gains are mostly scikit-learn's Python between its compiled steps:
910
+ the per-column bookkeeping of the graphical lasso, and the per-trial
911
+ validation and fitting of RANSAC. They shrink as the compiled part
912
+ grows (the graphical lasso at large p).
913
+
914
+ ## SGD, OMP and LARS: `SGDLinearClassifier`, `SGDLinearRegressor`, `GramOMP`, `GramLars`, `GramLassoLars`
915
+
916
+ scikit-learn's own algorithms, translated:
917
+
918
+ - `SGDLinearClassifier` and `SGDLinearRegressor` are
919
+ `SGDClassifier` / `SGDRegressor` at their defaults, run by
920
+ `_plain_sgd` with the points in order. Against
921
+ `shuffle=False` the weights are bit-identical. scikit-learn's
922
+ shuffling generator is not reproduced.
923
+ - `GramOMP` is `OrthogonalMatchingPursuit` over the Gram matrix (its
924
+ route for n > p, taken here always; X'X and X'y by dsyrk and dgemv).
925
+ - `GramLars` and `GramLassoLars` are `Lars` and `LassoLars` over the
926
+ Gram (their route for n > p, taken here always; the Gram formed in
927
+ numpy).
928
+
929
+ ```python
930
+ from scm2cpp_learn import SGDLinearClassifier, GramOMP, GramLassoLars
931
+ SGDLinearClassifier().fit(X, y).predict(X_new)
932
+ GramOMP(n_nonzero_coefs=10).fit(X, y).coef_
933
+ GramLassoLars(alpha=0.01).fit(X, y).coef_
934
+ ```
935
+
936
+ Each gives scikit-learn's coefficients, to the bit for SGD and to 1e-15
937
+ for the rest, with the same number of epochs or steps. The speed is the
938
+ repository README's table: 1.0-3.3x for SGD, 1.1-2.8x for OMP and
939
+ 1.2-5.0x for LARS, largest on small problems, where scikit-learn's
940
+ Python between steps dominates.
941
+
942
+ ## L2 logistic regression and Huber: `LBFGSLogisticRegression`, `LBFGSHuberRegressor`
943
+
944
+ `LogisticRegression(solver="lbfgs")` (the default; two classes
945
+ binomial, more multinomial) and `HuberRegressor`, with scipy's
946
+ L-BFGS-B written out in the subset under them. The products are dgemm
947
+ or dgemv where a CBLAS links.
948
+
949
+ ```python
950
+ from scm2cpp_learn import LBFGSLogisticRegression, LBFGSHuberRegressor
951
+ LBFGSLogisticRegression(C=1.0).fit(X, y).predict_proba(X_new)
952
+ LBFGSHuberRegressor(epsilon=1.35).fit(X, y).coef_
953
+ ```
954
+
955
+ Both take scikit-learn's iterations one for one and give its
956
+ coefficients to rounding (1e-13 at worst). The speed is the repository
957
+ README's table: 1.1-8x for the logistic regression (near 1x on a large
958
+ binary problem, where both sides are two matrix-vector products per
959
+ evaluation) and 2.5-18x for Huber.
960
+
961
+ ## Gaussian mixture, FastICA, PCA: `EMGaussianMixture`, `ParallelFastICA`, `CovPCA`
962
+
963
+ `GaussianMixture(covariance_type="full")`, `FastICA` at its defaults
964
+ and `PCA` by the covariance's eigenvectors (scikit-learn's
965
+ "covariance_eigh", what "auto" picks for a tall design), each
966
+ scikit-learn's algorithm translated, the products dgemm / dsyrk where a
967
+ CBLAS links and the eigenproblem written in the subset.
968
+
969
+ ```python
970
+ from scm2cpp_learn import EMGaussianMixture, ParallelFastICA, CovPCA
971
+ EMGaussianMixture(4, random_state=0).fit(X).predict(X) # KMeans start: scikit-learn's
972
+ ParallelFastICA(random_state=0).fit(X).transform(X)
973
+ CovPCA(n_components=10).fit(X).components_
974
+ ```
975
+
976
+ From the same `random_state` each takes scikit-learn's iterations and
977
+ lands on its parameters to rounding. The speed is the repository
978
+ README's table: 1.6-2.4x for the mixture, 1.1-1.4x for FastICA, and
979
+ for PCA 1.3-4x up to about 100 features, 0.6-0.8x at 200-500, where
980
+ LAPACK's eigensolver wins.
981
+
982
+ ## Perceptron, ARD, LassoLarsIC, L1 linear SVM: `SGDPerceptron`, `CovARDRegression`, `GramLassoLarsIC`, `L1LinearSVM`
983
+
984
+ - `SGDPerceptron` is `Perceptron`. `SGDLinearClassifier` /
985
+ `SGDLinearRegressor` also take `loss="perceptron"` /
986
+ `"epsilon_insensitive"`, `learning_rate="constant"` / `"pa1"` /
987
+ `"pa2"` and `penalty=None`, which covers scikit-learn's replacements
988
+ for the passive-aggressive estimators. All run with shuffle=False.
989
+ - `CovARDRegression` is `ARDRegression` (n >= p). With `hoist=True`,
990
+ the default, the kept features' Gram comes from X'X formed once;
991
+ `hoist=False` forms it every iteration, as scikit-learn does.
992
+ - `GramLassoLarsIC` is `LassoLarsIC`. The whole lasso path comes from
993
+ the translated LARS, and the criterion is evaluated from the Gram.
994
+ - `L1LinearSVM` is `LinearSVC(penalty="l1", dual=False)`, liblinear's
995
+ solve_l1r_l2_svc. It agrees with scikit-learn to the tolerance,
996
+ because the coordinate order comes from another generator.
997
+
998
+ ```python
999
+ from scm2cpp_learn import SGDPerceptron, CovARDRegression, GramLassoLarsIC, L1LinearSVM
1000
+ SGDPerceptron().fit(X, y)
1001
+ CovARDRegression().fit(X, y).coef_
1002
+ GramLassoLarsIC("bic").fit(X, y).alpha_
1003
+ L1LinearSVM(C=1.0).fit(X, y).coef_
1004
+ ```
1005
+
1006
+ Speed, from the repository README's table: SGD 1.2-3x, ARD 1.6-1.9x
1007
+ (3x with the Gram hoisted), LassoLarsIC 8-20x, the L1 SVM 1.1-1.5x.
1008
+
1009
+ ## GLMs, CV and Theil-Sen: `LBFGSPoissonRegressor`, `LBFGSGammaRegressor`, `LBFGSTweedieRegressor`, `LBFGSLogisticRegressionCV`, `GramLarsCV`, `GramLassoLarsCV`, `GramOMPCV`, `WeiszfeldTheilSen`
1010
+
1011
+ - The GLMs are `PoissonRegressor`, `GammaRegressor` and
1012
+ `TweedieRegressor` (solver "lbfgs"), over the same L-BFGS-B as
1013
+ `LBFGSLogisticRegression`.
1014
+ - The CV classes reproduce `LogisticRegressionCV`, `LarsCV`,
1015
+ `LassoLarsCV` and `OrthogonalMatchingPursuitCV`: the same folds (KFold
1016
+ / StratifiedKFold without shuffling), the same choice of C, alpha or
1017
+ number of columns, and the same refit.
1018
+ - `WeiszfeldTheilSen` is `TheilSenRegressor` on scikit-learn's own
1019
+ subsets.
1020
+
1021
+ ```python
1022
+ from scm2cpp_learn import LBFGSPoissonRegressor, LBFGSLogisticRegressionCV, GramLassoLarsCV
1023
+ LBFGSPoissonRegressor(alpha=1e-3).fit(X, counts).predict(X_new)
1024
+ LBFGSLogisticRegressionCV().fit(X, labels).C_
1025
+ GramLassoLarsCV().fit(X, y).alpha_
1026
+ ```
1027
+
1028
+ Speed, from the repository README's table: the GLMs 1.1-6x, the CV
1029
+ estimators 1.6-6.6x, Theil-Sen 1.3-1.8x (its subset drawing dominates).
1030
+
1031
+ ## Feature selection: `f_regression`, `f_classif`, `SelectKBest`, `SelectPercentile`, `GramRFE`
1032
+
1033
+ scikit-learn's univariate F statistics and its selectors over them,
1034
+ and `RFE` around `Ridge` / `LinearRegression`, translated.
1035
+ `f_regression` also takes an n x q array of targets: the q results in
1036
+ one product, on the device when one answers.
1037
+
1038
+ ```python
1039
+ from scm2cpp_learn import SelectKBest, GramRFE, f_regression
1040
+ SelectKBest("f_regression", k=20).fit(X, y).get_support()
1041
+ GramRFE(alpha=1.0, n_features_to_select=10).fit(X, y).ranking_
1042
+ F, p = f_regression(X, Y) # Y: n x q, every target at once
1043
+ ```
1044
+
1045
+ The same scores, ranking and features as scikit-learn. RFE is 80-1,000x
1046
+ faster (one Gram and one Cholesky factor, downdated, instead of a refit
1047
+ per round), and f_regression over many targets 30-50x. The Gram-route
1048
+ estimators (`GramLassoLarsIC`, `GramLarsCV`, `GramLassoLarsCV`,
1049
+ `GramLars`, `GramLassoLars`, `CovPCA`) form a large X'X on the device
1050
+ when one answers. `SCM2CPP_NO_DEVICE=1` keeps everything on the CPU.
1051
+
1052
+ ## Against Intel's sklearnex
1053
+
1054
+ sklearnex (scikit-learn-intelex 2026.2, oneDAL underneath) replaces
1055
+ some scikit-learn estimators with its own. `bench/sklearnex-compare.py`
1056
+ times the ones with a counterpart here, sklearnex's estimators
1057
+ imported directly (not patched in) so that scikit-learn itself is
1058
+ timed in the same process. The ratios are scikit-learn's or
1059
+ sklearnex's time over the package's best (its CPU build, or the device
1060
+ build where one answers). The package was built against Intel MKL
1061
+ (see the build section), its loops under OpenMP; the load average was
1062
+ about 2.3, so the times move by some percent.
1063
+
1064
+ | estimator, n x p | iterations (sk / sklearnex / ours) | vs scikit-learn, one core | vs sklearnex, one core | vs sklearnex, 20 cores |
1065
+ |---|---|---|---|---|
1066
+ | LogisticRegression, 200,000 x 100 | 6 / 12 / 6 | 6.8x (device) | 6.4x (device) | 1.8x (device); on the CPU 0.9x |
1067
+ | Ridge (alpha 1, no intercept), 200,000 x 200 | - | 1.7x | 0.8x | 2.3x |
1068
+ | PCA (10 components), 100,000 x 50 | - | 1.4x | 2.2x | 1.4x |
1069
+ | PCA (10 components), 50,000 x 500 | - | 1.1x | 1.4x | 1.6x (device); on the CPU 1.2x |
1070
+ | SVC (rbf), 5,000 x 20 | 4,858 / 7 / 4,882 | 1.4x | 1.6x | 0.5x |
1071
+ | SVR (rbf), 5,000 x 20 | 12,821 / 10 / 12,824 | 1.4x | 1.4x | 0.4x |
1072
+ | RFE(Ridge), 20 of 200 kept, 20,000 x 200 | - | 138x | 106x | 116x |
1073
+
1074
+ On one core the package is ahead everywhere but Ridge, where both
1075
+ sides are one symmetric rank-k update of the same size and sklearnex
1076
+ is 0.180 s to 0.219 s. With 20 cores both sides use every core: the
1077
+ package's loops run under OpenMP and its products in MKL's threads,
1078
+ so it is ahead on Ridge (the Gram by dsyrk and a Cholesky solve), PCA,
1079
+ the device build of logistic regression and RFE (an algorithm that
1080
+ does less work: the Gram once, the Cholesky factor downdated per drop,
1081
+ where scikit-learn refits), level on logistic regression on the CPU,
1082
+ and behind on the SVMs: SMO takes thousands of short steps, each
1083
+ waiting on the last, where sklearnex's solver takes 7 to 10 long ones. sklearnex is
1084
+ not scikit-learn's arithmetic: its logistic regression takes 12
1085
+ iterations to scikit-learn's 6 and its coefficients differ from
1086
+ scikit-learn's by 6e-3, where the package's agree to 4e-15; its SVMs
1087
+ report a different iteration count. This sklearnex accelerates no
1088
+ lasso, so the lasso tables above stand against scikit-learn alone.
1089
+
1090
+ ## The GPU build
1091
+
1092
+ Where the CUDA toolkit is at install time, `ParallelFastICA`,
1093
+ `EMGaussianMixture`, `LBFGSLogisticRegression`,
1094
+ `LBFGSLogisticRegressionCV`, `LBFGSHuberRegressor` and the `LBFGS*`
1095
+ GLMs also get the same Scheme translated with `--cuda`
1096
+ (`libscm2cpp_gpu.so`). Its loops are CUDA kernels and its products
1097
+ cuBLAS, on arrays that stay on the device. The classes take it when a
1098
+ device answers and the data has a million entries or more, and give
1099
+ the CPU build's iterations and results to the last digits. Measured
1100
+ against scikit-learn: 10-22x for the L-BFGS fits, 6.7x FastICA, 3x the
1101
+ mixture and LogisticRegressionCV (the repository README's table).
1102
+ `SCM2CPP_NO_DEVICE=1` keeps everything on the CPU.
1103
+
1104
+ ## Logistic regression with L1
1105
+
1106
+ `CovLogistic` solves L1-penalized logistic regression by
1107
+ majorization: the logistic Hessian is bounded by X'X/4, so the
1108
+ quadratic term is the same Gram matrix, fixed once, and each outer
1109
+ round costs one gradient pass before handing the majorizer to the
1110
+ same coordinate descent the lasso uses. Objectives agree with
1111
+ scikit-learn's `LogisticRegression(penalty="l1",
1112
+ fit_intercept=False)` at `C = 1/(n lam)` to 9e-15.
1113
+
1114
+ ## Group lasso
1115
+
1116
+ `CovGroupLasso` penalizes whole groups -- `lam * sum_g sqrt(|g|)
1117
+ ||b_g||` -- so correlated features enter or leave together. Block
1118
+ coordinate descent on the same Gram machinery: each block visit is
1119
+ one majorized proximal step (the group's Gram block dominated by its
1120
+ top eigenvalue, found once), descending monotonically. With size-one
1121
+ groups it reduces exactly to the lasso -- verified against sklearn to
1122
+ 9e-16 -- and at convergence the group KKT conditions hold to 2e-12.
1123
+
1124
+ ## Bootstrap on the GPU
1125
+
1126
+ `bootstrap` draws pairs-bootstrap resamples and refits them all at
1127
+ one lambda. Each resample's Gram matrix is `X' diag(m) X` for its
1128
+ multiplicity counts `m`, written as `Xs' Xs` with `Xs = diag(sqrt m) X`
1129
+ so that numpy hands it to the symmetric rank-k product (`dsyrk`,
1130
+ half the flops of `X' (diag(m) X)`) -- one BLAS call -- and because
1131
+ the problems are independent, the descents run as one batch: on the
1132
+ GPU, one block of threads per resample, each reading its own Gram
1133
+ matrix. The products are the cost: at n=5000, p=1000 a hundred
1134
+ resamples took 18.1 s as general products and 11.2 s as `dsyrk`,
1135
+ descents included.
1136
+
1137
+ ```python
1138
+ betas = model.bootstrap(lam, n_boot=500, seed=0) # (500, p)
1139
+ freq = (abs(betas) > 1e-9).mean(axis=0) # selection frequency
1140
+ ```
1141
+
1142
+ Requires constructing the model from `X, y` (a Gram matrix alone
1143
+ cannot be resampled by rows). GPU and CPU agree to machine
1144
+ precision.
1145
+
1146
+ ## A design with structure
1147
+
1148
+ When the design matrix has structure, forming X'X the general way is
1149
+ the wrong move. `kernel` exposes the translated functions directly,
1150
+ and [`scm2cpp-tfs`](https://pypi.org/project/scm2cpp-tfs/) does exactly
1151
+ that for moving-average designs: it builds the Gram matrix from a
1152
+ series' prefix sums in O(n p) time, never forming the design. That
1153
+ package stands alone -- it carries its own copy of this descent -- so
1154
+ neither installs the other.
1155
+
1156
+ ## Where this comes from
1157
+
1158
+ The C++ this compiles is committed to `python/scm2cpp-learn/` in the
1159
+ scm2cpp repository. `CovLasso` and the rest run on the translation of
1160
+ `examples/kernel-only/lasso-cov.scm`, a covariance-update sweep
1161
+ written by hand at the Gram level; `DerivedLasso` runs on the
1162
+ translation of `examples/kernel-only/lasso-design.scm`, the naive
1163
+ residual-form descent over X as numpy holds it, with the translator's `--derive --blas`
1164
+ deriving the same covariance form from it. The two are the same
1165
+ sweep over the same `dsyrk`, and they time the same. To refresh the
1166
+ committed C++ after changing the Scheme, run `regenerate.sh` -- that
1167
+ step, and only that step, needs Racket.
1168
+
1169
+ ## License
1170
+
1171
+ MIT, the same as scm2cpp.