scm2cpp-learn 0.18.0__py3-none-macosx_14_0_arm64.whl

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 (71) hide show
  1. scm2cpp_learn/.dylibs/libgcc_s.1.1.dylib +0 -0
  2. scm2cpp_learn/.dylibs/libgfortran.5.dylib +0 -0
  3. scm2cpp_learn/.dylibs/libomp.dylib +0 -0
  4. scm2cpp_learn/.dylibs/libopenblasp-r0.3.34.dylib +0 -0
  5. scm2cpp_learn/.dylibs/libquadmath.0.dylib +0 -0
  6. scm2cpp_learn/__init__.py +3804 -0
  7. scm2cpp_learn/_classifiers.cpython-312-darwin.so +0 -0
  8. scm2cpp_learn/_compat.py +284 -0
  9. scm2cpp_learn/_generated/__init__.py +0 -0
  10. scm2cpp_learn/_generated/_loader.py +41 -0
  11. scm2cpp_learn/_generated/_loader_accel_cublas.py +173 -0
  12. scm2cpp_learn/_generated/_loader_classifiers.py +308 -0
  13. scm2cpp_learn/_generated/_loader_derived_blas.py +29 -0
  14. scm2cpp_learn/_generated/_loader_derived_cublas.py +29 -0
  15. scm2cpp_learn/_generated/_loader_derived_loops.py +29 -0
  16. scm2cpp_learn/_generated/_loader_gpu_cuda.py +313 -0
  17. scm2cpp_learn/_generated/_loader_gramops_blas.py +692 -0
  18. scm2cpp_learn/_generated/_loader_gramops_loops.py +711 -0
  19. scm2cpp_learn/_generated/_loader_nmf_blas.py +29 -0
  20. scm2cpp_learn/_generated/_loader_nmf_cublas.py +29 -0
  21. scm2cpp_learn/_generated/_loader_nmf_loops.py +29 -0
  22. scm2cpp_learn/_generated/accel_cublas/accel.cpp +24 -0
  23. scm2cpp_learn/_generated/accel_cublas/accel.hpp +1178 -0
  24. scm2cpp_learn/_generated/accel_cublas/accel_capi.cpp +76 -0
  25. scm2cpp_learn/_generated/classifiers.cpp +37 -0
  26. scm2cpp_learn/_generated/classifiers.hpp +3745 -0
  27. scm2cpp_learn/_generated/classifiers_capi.cpp +115 -0
  28. scm2cpp_learn/_generated/derived_blas/lasso_design.cpp +5 -0
  29. scm2cpp_learn/_generated/derived_blas/lasso_design.hpp +156 -0
  30. scm2cpp_learn/_generated/derived_blas/lasso_design_capi.cpp +19 -0
  31. scm2cpp_learn/_generated/derived_cublas/lasso_design.cpp +5 -0
  32. scm2cpp_learn/_generated/derived_cublas/lasso_design.hpp +158 -0
  33. scm2cpp_learn/_generated/derived_cublas/lasso_design_capi.cpp +19 -0
  34. scm2cpp_learn/_generated/derived_loops/lasso_design.cpp +5 -0
  35. scm2cpp_learn/_generated/derived_loops/lasso_design.hpp +179 -0
  36. scm2cpp_learn/_generated/derived_loops/lasso_design_capi.cpp +19 -0
  37. scm2cpp_learn/_generated/gpu_cuda/gpu.cpp +37 -0
  38. scm2cpp_learn/_generated/gpu_cuda/gpu.hpp +3846 -0
  39. scm2cpp_learn/_generated/gpu_cuda/gpu_capi.cpp +308 -0
  40. scm2cpp_learn/_generated/gramops_blas/gramops.cpp +82 -0
  41. scm2cpp_learn/_generated/gramops_blas/gramops.hpp +5411 -0
  42. scm2cpp_learn/_generated/gramops_blas/gramops_capi.cpp +244 -0
  43. scm2cpp_learn/_generated/gramops_loops/gramops.cpp +82 -0
  44. scm2cpp_learn/_generated/gramops_loops/gramops.hpp +6395 -0
  45. scm2cpp_learn/_generated/gramops_loops/gramops_capi.cpp +250 -0
  46. scm2cpp_learn/_generated/lasso_cov.cpp +7 -0
  47. scm2cpp_learn/_generated/lasso_cov.hpp +152 -0
  48. scm2cpp_learn/_generated/lasso_cov_capi.cpp +25 -0
  49. scm2cpp_learn/_generated/nmf_blas/nmf_cd.cpp +5 -0
  50. scm2cpp_learn/_generated/nmf_blas/nmf_cd.hpp +132 -0
  51. scm2cpp_learn/_generated/nmf_blas/nmf_cd_capi.cpp +19 -0
  52. scm2cpp_learn/_generated/nmf_cublas/nmf_cd.cpp +5 -0
  53. scm2cpp_learn/_generated/nmf_cublas/nmf_cd.hpp +136 -0
  54. scm2cpp_learn/_generated/nmf_cublas/nmf_cd_capi.cpp +19 -0
  55. scm2cpp_learn/_generated/nmf_loops/nmf_cd.cpp +5 -0
  56. scm2cpp_learn/_generated/nmf_loops/nmf_cd.hpp +167 -0
  57. scm2cpp_learn/_generated/nmf_loops/nmf_cd_capi.cpp +19 -0
  58. scm2cpp_learn/_generated/scm2cpp-blas.hpp +158 -0
  59. scm2cpp_learn/_generated/scm2cpp-cublas.hpp +155 -0
  60. scm2cpp_learn/_generated/scm2cpp-cuda.hpp +285 -0
  61. scm2cpp_learn/_generated/scm2cpp.hpp +1627 -0
  62. scm2cpp_learn/_gramops_blas.cpython-312-darwin.so +0 -0
  63. scm2cpp_learn/_lasso_cov.cpython-312-darwin.so +0 -0
  64. scm2cpp_learn/_lasso_derived_blas.cpython-312-darwin.so +0 -0
  65. scm2cpp_learn/_libfind.py +153 -0
  66. scm2cpp_learn/_nmf_blas.cpython-312-darwin.so +0 -0
  67. scm2cpp_learn/batch_capi.cu +428 -0
  68. scm2cpp_learn-0.18.0.dist-info/METADATA +1171 -0
  69. scm2cpp_learn-0.18.0.dist-info/RECORD +71 -0
  70. scm2cpp_learn-0.18.0.dist-info/WHEEL +6 -0
  71. scm2cpp_learn-0.18.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3804 @@
1
+ """Lasso by coordinate descent over a Gram matrix, from Scheme via C++.
2
+
3
+ The solver is `examples/kernel-only/lasso-cov.scm` in the scm2cpp
4
+ repository, translated to C++ and compiled at install time. It takes
5
+ the covariance route: given X'X and X'y, each coordinate step costs
6
+ O(p) instead of O(n), so a whole regularization path is cheap once the
7
+ Gram matrix exists. The descent is exactly resumable, which is what
8
+ lets a path be walked warm and a batch of lambdas be handed to a GPU.
9
+
10
+ from scm2cpp_learn import CovLasso
11
+
12
+ model = CovLasso(X, y) # forms the Gram matrix
13
+ path = model.fit_path(lambdas) # warm, sequential
14
+ grid = model.fit_path_batch(lambdas) # cold, one thread each
15
+
16
+ `fit_path_batch` uses CUDA when the package was built with nvcc present
17
+ and a device is visible, and falls back to the CPU otherwise; ask
18
+ `cuda_available()` if you need to know which you got.
19
+
20
+ Designs with structure need not form X'X the general way. `kernel`
21
+ exposes the translated functions directly, and `scm2cpp-tfs` uses them
22
+ to build the Gram matrix of a moving-average design in O(n p) time and
23
+ no design matrix at all.
24
+ """
25
+ import ctypes
26
+ import warnings
27
+
28
+ import numpy as np
29
+
30
+ from ._generated import _loader as kernel
31
+ from ._libfind import load_batch_lib, derived_backends, nmf_backends
32
+ from ._compat import (BaseEstimator, ClassifierMixin, RegressorMixin, TransformerMixin,
33
+ DensityMixin, Tagged, fit_xy, fit_x, pred_x, classes_of,
34
+ check_is_fitted)
35
+
36
+ # the derived kernel's variants that were built, each bound to its own
37
+ # library: {'cublas': module, 'blas': module, 'loops': module}
38
+ _DERIVED = {}
39
+ for _v in derived_backends():
40
+ try:
41
+ _DERIVED[_v] = __import__("scm2cpp_learn._generated._loader_derived_" + _v,
42
+ fromlist=["lasso"])
43
+ except ImportError:
44
+ pass
45
+
46
+ __all__ = ["GramLasso", "GramElasticNet", "GramRidge", "GramRidgeCV", "CovLasso", "CovRidge", "CovBayesianRidge", "CovLogistic", "CovGroupLasso",
47
+ "LinearSVM", "KernelSVM", "LinearSVR", "KernelSVR", "L1LogisticRegression",
48
+ "CDNMF", "nmf_backend", "CDGraphicalLasso", "LinearRANSAC",
49
+ "SGDLinearClassifier", "SGDLinearRegressor", "SGDPerceptron", "GramOMP", "GramLars", "GramLassoLars", "GramLassoLarsIC",
50
+ "LBFGSLogisticRegression", "LBFGSHuberRegressor", "LBFGSLogisticRegressionCV",
51
+ "LBFGSPoissonRegressor", "LBFGSGammaRegressor", "LBFGSTweedieRegressor",
52
+ "GramLarsCV", "GramLassoLarsCV", "GramOMPCV",
53
+ "EMGaussianMixture", "CovPCA", "GramKernelPCA", "GramLDA", "GramTruncatedSVD", "ParallelFastICA", "CovARDRegression",
54
+ "L1LinearSVM", "WeiszfeldTheilSen", "f_regression", "f_classif", "SelectKBest",
55
+ "SelectPercentile", "GramRFE",
56
+ "CovMultiTaskLasso", "CovMultiTaskLassoCV", "DerivedLasso",
57
+ "GramLogisticRegression", "GramGroupLasso", "GramMultiTaskLasso",
58
+ "GramMultiTaskElasticNet", "DesignLasso",
59
+ "cuda_available", "derived_backend", "kernel"]
60
+ __version__ = "0.18.0"
61
+
62
+ _BATCH = load_batch_lib()
63
+ _DP = ctypes.POINTER(ctypes.c_double)
64
+
65
+
66
+ def _scale(beta):
67
+ """What `tol` is measured against.
68
+
69
+ An absolute test on the coefficients is a trap when the features
70
+ are large: the Gram diagonal is then large too, every sweep moves
71
+ each coefficient by very little, and the descent declares victory
72
+ a long way from the solution. Measuring the move against the size
73
+ of the coefficients themselves makes the test mean the same thing
74
+ at any scale, and falls back to absolute when they are still near
75
+ zero.
76
+ """
77
+ m = float(np.max(np.abs(beta))) if beta.size else 0.0
78
+ return m if m > 1.0 else 1.0
79
+
80
+
81
+ def cuda_available():
82
+ """True when the GPU batch path was built and a device answers."""
83
+ if _BATCH is None:
84
+ return False
85
+ g, c, b = np.ones(1), np.zeros(1), np.zeros(1)
86
+ lam = np.array([1e9]) # a lambda that zeroes everything
87
+ return 0 == _BATCH.scm2cpp_batch_descend(
88
+ g.ctypes.data_as(_DP), 1, c.ctypes.data_as(_DP),
89
+ b.ctypes.data_as(_DP), lam.ctypes.data_as(_DP), 1.0, 1, 1, 1.0,
90
+ 20, 20, 1e-8)
91
+
92
+
93
+ class CovLasso:
94
+ """Lasso over a design given by its Gram matrix.
95
+
96
+ Either hand it the design and the target, in which case X'X and
97
+ X'y are formed here, or hand it a Gram matrix and correlations
98
+ somebody else built more cheaply.
99
+
100
+ Parameters
101
+ ----------
102
+ X : array (nobs, p), optional
103
+ The design matrix.
104
+ y : array (nobs,), optional
105
+ The target.
106
+ gram : array (p, p) or (p*p,), optional
107
+ X'X, if it is already known.
108
+ corr : array (p,), optional
109
+ X'y, likewise.
110
+ nobs : int, optional
111
+ Rows behind `gram`; required when passing one, since the
112
+ penalty is scaled by it as scikit-learn's alpha is.
113
+
114
+ The objective is the usual one, matching scikit-learn's `Lasso`
115
+ with `fit_intercept=False`:
116
+
117
+ (1 / 2 nobs) ||y - X b||^2 + lam ||b||_1
118
+ """
119
+
120
+ def __init__(self, X=None, y=None, *, gram=None, corr=None, nobs=None):
121
+ if gram is not None:
122
+ if corr is None or nobs is None:
123
+ raise ValueError("gram needs corr and nobs alongside it")
124
+ g = np.ascontiguousarray(gram, dtype=np.float64).ravel()
125
+ c = np.ascontiguousarray(corr, dtype=np.float64)
126
+ p = c.size
127
+ if g.size != p * p:
128
+ raise ValueError(f"gram is {g.size} entries; expected {p*p}")
129
+ self.p, self.nobs, self.g, self.c0 = p, int(nobs), g, c
130
+ else:
131
+ if X is None or y is None:
132
+ raise ValueError("pass X and y, or gram, corr and nobs")
133
+ X = np.ascontiguousarray(X, dtype=np.float64)
134
+ y = np.ascontiguousarray(y, dtype=np.float64)
135
+ if X.ndim != 2 or y.ndim != 1 or X.shape[0] != y.size:
136
+ raise ValueError(
137
+ f"X is {X.shape} and y is {y.shape}; expected (n, p) "
138
+ "and (n,)")
139
+ self.nobs, self.p = X.shape
140
+ self.g = np.ascontiguousarray((X.T @ X).ravel())
141
+ self.c0 = np.ascontiguousarray(X.T @ y)
142
+ self.X, self.y = X, y
143
+
144
+
145
+ # ------------------------- choosing lambdas -------------------------
146
+
147
+ def lambda_max(self, l1_ratio=1.0):
148
+ """The smallest penalty that leaves every coefficient at zero.
149
+
150
+ Above it the solution is exactly zero, so a path that starts
151
+ here starts where the first coefficient is about to enter --
152
+ which is the only scale-free place to start, since the penalty
153
+ is compared against correlations on the features' own scale.
154
+ """
155
+
156
+ return float(np.max(np.abs(self.c0))) / (self.nobs * l1_ratio)
157
+
158
+ def lambda_grid(self, num=100, eps=1e-3, l1_ratio=1.0):
159
+ """A log-spaced path from lambda_max down to eps * lambda_max.
160
+
161
+ The same construction scikit-learn uses, and the same default
162
+ ratio; give the result to fit_path, which expects descending
163
+ lambdas.
164
+ """
165
+ hi = self.lambda_max(l1_ratio)
166
+ return hi * np.logspace(0, np.log10(eps), int(num))
167
+
168
+ # ---------------------------- fitting ----------------------------
169
+
170
+ def fit(self, lam, **kw):
171
+ """Coefficients at one lambda.
172
+
173
+ A single penalty is the one-element special case of the path:
174
+ the Gram matrix was built in the constructor, so this costs one
175
+ descent and nothing per row -- and a warm-started path over a
176
+ whole grid costs barely more, which is why fit_path is the
177
+ primary interface rather than this convenience.
178
+ """
179
+ return self.fit_path([lam], **kw)[0]
180
+
181
+ def fit_path(self, lambdas, tol=1e-8, chunk=20, max_sweeps=100000,
182
+ l1_ratio=1.0):
183
+ """Coefficients per lambda, each warm-started from the last.
184
+
185
+ Returns an array of shape (len(lambdas), p). Give lambdas in
186
+ descending order: that is what makes the warm start pay.
187
+ """
188
+ lambdas = np.asarray(lambdas, dtype=np.float64)
189
+ beta, c = np.zeros(self.p), self.c0.copy()
190
+ prev = np.empty(self.p)
191
+ out = np.empty((lambdas.size, self.p))
192
+ for i, lam in enumerate(lambdas):
193
+ swept = 0
194
+ while swept < max_sweeps:
195
+ prev[:] = beta
196
+ kernel.enet_descend(self.g, c, beta,
197
+ float(lam) * l1_ratio,
198
+ float(lam) * (1.0 - l1_ratio), chunk,
199
+ float(self.nobs), self.p)
200
+ swept += chunk
201
+ if np.max(np.abs(beta - prev)) < tol * _scale(beta):
202
+ break
203
+ out[i] = beta
204
+ return out
205
+
206
+
207
+ def bootstrap(self, lam, n_boot=200, l1_ratio=1.0, seed=None,
208
+ force_cpu=False, **kw):
209
+ """Pairs-bootstrap coefficients at one lambda, (n_boot, p).
210
+
211
+ Each resample draws rows with replacement; its Gram matrix is
212
+ X' diag(m) X with the multiplicity counts m, formed as Xs' Xs
213
+ for Xs = diag(sqrt m) X so that numpy takes the symmetric
214
+ rank-k product (dsyrk, half the flops of a general product)
215
+ -- one BLAS call per resample -- and the descents run as one
216
+ batch: on the GPU one block of threads per resample over its
217
+ own Gram, since the problems are independent. Needs the
218
+ design matrix, so it is unavailable when the model was built
219
+ from a Gram matrix alone.
220
+ """
221
+ if not hasattr(self, "X"):
222
+ raise ValueError("bootstrap needs X; construct from X and y")
223
+ rng = np.random.default_rng(seed)
224
+ B = int(n_boot)
225
+ grams = np.empty((B, self.p * self.p))
226
+ corrs = np.empty((B, self.p))
227
+ for b in range(B):
228
+ m = rng.multinomial(self.nobs,
229
+ np.full(self.nobs, 1.0 / self.nobs))
230
+ Xs = self.X * np.sqrt(m)[:, None]
231
+ grams[b] = (Xs.T @ Xs).ravel()
232
+ corrs[b] = self.X.T @ (m * self.y)
233
+ lams = (np.ascontiguousarray(lam, dtype=np.float64)
234
+ if np.ndim(lam) else np.full(B, float(lam)))
235
+ if not force_cpu:
236
+ beta = _grid_descend(grams, corrs, lams, self.p, 0,
237
+ l1_ratio=l1_ratio, nobs=float(self.nobs),
238
+ **kw)
239
+ if beta is not None:
240
+ return beta
241
+ return _batch_descend_multi(grams, corrs, lams, self.nobs, self.p,
242
+ l1_ratio=l1_ratio, force_cpu=True,
243
+ kernel_fn=kernel.enet_descend, **kw)
244
+
245
+ def fit_path_batch(self, lambdas, tol=1e-8, chunk=20,
246
+ max_sweeps=100000, force_cpu=False, l1_ratio=1.0):
247
+ """Coefficients per lambda, every lambda solved from zero.
248
+
249
+ Independent problems, so they go to the GPU together when one
250
+ is available: one launch, one block of threads per lambda, all
251
+ sharing this Gram matrix. This is the shape of a
252
+ cross-validation grid, where warm starting across lambdas is
253
+ not on offer anyway.
254
+ """
255
+ lambdas = np.ascontiguousarray(lambdas, dtype=np.float64)
256
+ batch = lambdas.size
257
+ if not force_cpu:
258
+ beta = _grid_descend(self.g[None, :], self.c0[None, :], lambdas,
259
+ self.p, 0, tol=tol, chunk=chunk,
260
+ max_sweeps=max_sweeps, l1_ratio=l1_ratio,
261
+ nobs=float(self.nobs))
262
+ if beta is not None:
263
+ return beta
264
+ # a device that refused the work is not a reason to fail
265
+ beta = np.zeros((batch, self.p))
266
+ c = np.tile(self.c0, (batch, 1))
267
+ prev = np.empty(self.p)
268
+ for t in range(batch):
269
+ bt, ct, swept = beta[t], c[t], 0
270
+ while swept < max_sweeps:
271
+ prev[:] = bt
272
+ kernel.enet_descend(self.g, ct, bt,
273
+ float(lambdas[t]) * l1_ratio,
274
+ float(lambdas[t]) * (1.0 - l1_ratio), chunk,
275
+ float(self.nobs), self.p)
276
+ swept += chunk
277
+ if np.max(np.abs(bt - prev)) < tol * _scale(bt):
278
+ break
279
+ return beta
280
+
281
+
282
+ class DerivedLasso:
283
+ """Lasso from the design matrix, by the kernel the translator derived.
284
+
285
+ CovLasso above is the usable one: numpy forms the Gram matrix, a
286
+ hand-written sweep (lasso-cov.scm) runs over it, and cross-
287
+ validation, bootstrap and the GPU grid all feed that sweep their
288
+ own Gram matrices. This class exists to show the translator's
289
+ work instead. Its kernel is examples/kernel-only/lasso-design.scm
290
+ -- the residual-form coordinate descent, the algorithm inside
291
+ scikit-learn's Lasso(precompute=False), written as flat loops over
292
+ X as numpy holds it (n rows of p), the residual and the column
293
+ norms taken inside, no Gram matrix in sight -- translated with
294
+ --derive and --blas:
295
+ the translator raised the loops to the array algebra, replaced the
296
+ residual by the memo c = X'r kept current through a Gram matrix it
297
+ hoisted in front of the penalty path, folded the column norms into
298
+ that Gram's diagonal, folded the products into one dsyrk and two
299
+ dgemv calls, and put the result under (if (< p n) ..)
300
+ with the original sweep in the else branch -- scikit-learn's
301
+ precompute='auto' rule, derived. Nothing in it is written at the Gram level by hand, and numpy
302
+ prepares nothing: X and y go in as they are.
303
+
304
+ Three variants of the same derivation can be built: 'cublas'
305
+ (--derive --cublas: the products on the device, X uploaded once per
306
+ fit_path call), 'blas' (--derive --blas, CBLAS) and 'loops'
307
+ (--derive alone: the Gram a scalar loop nest, much slower on large
308
+ designs). backend=None takes the best one this machine built and
309
+ can run -- cublas only when a device answers -- and
310
+ derived_backend() says which that is; backend='blas' etc. insists.
311
+
312
+ The constructor only checks shapes and keeps X (C-contiguous, as
313
+ numpy makes it) and y; everything else happens inside fit_path.
314
+
315
+ Convergence is the kernel's: a sweep in which no coefficient moved
316
+ by more than tol (absolute) ends the fit at that lambda, max_sweeps
317
+ bounds it; tol=0.0 asks for the exact fixed point. Same objective
318
+ as CovLasso.
319
+ """
320
+
321
+ def __init__(self, X, y, backend=None):
322
+ if backend is None:
323
+ backend = derived_backend()
324
+ if backend is None:
325
+ raise ImportError(
326
+ "scm2cpp_learn: the derived kernel was not built; "
327
+ "reinstall the package (pip install --force-reinstall "
328
+ "scm2cpp-learn)")
329
+ elif backend not in _DERIVED:
330
+ raise ValueError(f"derived kernel backend {backend!r} is not built; "
331
+ f"built: {sorted(_DERIVED)}")
332
+ self.backend = backend
333
+ self._kernel = _DERIVED[backend]
334
+ X = np.ascontiguousarray(X, dtype=np.float64)
335
+ y = np.ascontiguousarray(y, dtype=np.float64)
336
+ if X.ndim != 2 or y.ndim != 1 or X.shape[0] != y.size:
337
+ raise ValueError(
338
+ f"X is {X.shape} and y is {y.shape}; expected (n, p) and (n,)")
339
+ self.nobs, self.p = X.shape
340
+ self.X, self.y = X, y
341
+ self._c0 = None
342
+
343
+ @property
344
+ def c0(self):
345
+ # X'y, needed only to place the penalty grid
346
+ if self._c0 is None:
347
+ self._c0 = self.X.T @ self.y
348
+ return self._c0
349
+
350
+ def lambda_max(self):
351
+ return float(np.max(np.abs(self.c0))) / self.nobs
352
+
353
+ def lambda_grid(self, num=100, eps=1e-3):
354
+ hi = self.lambda_max()
355
+ return hi * np.logspace(0, np.log10(eps), int(num))
356
+
357
+ def fit(self, lam, **kw):
358
+ return self.fit_path([lam], **kw)[0]
359
+
360
+ def fit_path(self, lambdas, tol=1e-8, max_sweeps=100000):
361
+ """Coefficients per lambda, warm-started, shape (len(lambdas), p).
362
+
363
+ One call into the derived kernel: it takes the residual and
364
+ the column norms (folded into the Gram's diagonal when p < n),
365
+ builds the Gram matrix, walks the penalties, and leaves the
366
+ coefficient at each in a row of the result. tol is the largest move of
367
+ one coefficient in a sweep below which that lambda is done. The
368
+ kernel's value, the sweeps run over the whole path, is kept as
369
+ n_iter_.
370
+ """
371
+ lams = np.ascontiguousarray(lambdas, dtype=np.float64).ravel()
372
+ beta = np.zeros(self.p)
373
+ resid = np.empty(self.nobs) # filled by the kernel from y
374
+ xnorm = np.empty(self.p) # the column norms, taken inside
375
+ betas = np.zeros(lams.size * self.p)
376
+ self.n_iter_ = int(self._kernel.lasso_design(
377
+ self.X, self.y, resid, xnorm, beta, lams, betas, int(max_sweeps), float(tol),
378
+ self.nobs, self.p, lams.size))
379
+ return betas.reshape(lams.size, self.p)
380
+
381
+
382
+ class DesignLasso(Tagged, RegressorMixin, BaseEstimator):
383
+ """scikit-learn's ``Lasso`` interface over DerivedLasso: fit(X, y)
384
+ centres the data when ``fit_intercept`` and runs the derived kernel
385
+ at one penalty ``alpha``, same objective as GramLasso. ``tol`` is
386
+ the kernel's (the largest absolute move of one coefficient in a
387
+ sweep), ``max_iter`` its sweep cap and ``backend`` DerivedLasso's.
388
+
389
+ Attributes after fit: coef_, intercept_, n_iter_ (the sweeps the
390
+ kernel ran), n_features_in_.
391
+ """
392
+
393
+ def __init__(self, alpha=1.0, *, fit_intercept=True, tol=1e-8, max_iter=100000,
394
+ backend=None):
395
+ self.alpha, self.fit_intercept, self.tol, self.max_iter = alpha, fit_intercept, tol, max_iter
396
+ self.backend = backend
397
+
398
+ def fit(self, X, y):
399
+ X, y = fit_xy(self, X, y)
400
+ Xc, yc, xm, ym = _centered(X, y, self.fit_intercept)
401
+ m = DerivedLasso(Xc, yc, backend=self.backend)
402
+ self.coef_ = m.fit(float(self.alpha), tol=float(self.tol), max_sweeps=int(self.max_iter))
403
+ self.n_iter_ = m.n_iter_
404
+ self.intercept_ = float(ym - xm @ self.coef_)
405
+ return self
406
+
407
+ def predict(self, X):
408
+ return pred_x(self, X) @ self.coef_ + self.intercept_
409
+
410
+
411
+ def derived_backend():
412
+ """The derived kernel variant DerivedLasso takes by default: 'cublas'
413
+ when built and a device answers, else 'blas', else 'loops'; None
414
+ when none was built."""
415
+ for v in ("cublas", "blas", "loops"):
416
+ if v in _DERIVED and (v != "cublas" or cuda_available()):
417
+ return v
418
+ return None
419
+
420
+
421
+ def _batch_descend_multi(grams, corrs, lam, nobs, p, tol=1e-8, chunk=20,
422
+ max_sweeps=100000, l1_ratio=1.0, force_cpu=False,
423
+ kernel_fn=None):
424
+ """Descend a batch where every problem has its own Gram matrix.
425
+
426
+ grams is (B, p*p) and corrs (B, p); one thread per problem on the
427
+ GPU when it is there (the kernel before 0.7.0, kept for the
428
+ comparison in bench/cv-grid-designs.py), the same chunked-tolerance
429
+ loop on the CPU when it is not -- which is what the bootstrap
430
+ falls back to.
431
+ """
432
+ B = corrs.shape[0]
433
+ import ctypes
434
+ beta = np.zeros((B, p))
435
+ c = np.ascontiguousarray(corrs.copy())
436
+ g = np.ascontiguousarray(grams)
437
+ # lam may be one penalty for every thread or one per thread; the
438
+ # kernel always reads a per-thread array
439
+ lams = (np.ascontiguousarray(lam, dtype=np.float64)
440
+ if np.ndim(lam) else np.full(B, float(lam)))
441
+ if _BATCH is not None and not force_cpu:
442
+ dp = ctypes.POINTER(ctypes.c_double)
443
+ rc = _BATCH.scm2cpp_batch_descend(
444
+ g.ctypes.data_as(dp), int(B), c.ctypes.data_as(dp),
445
+ beta.ctypes.data_as(dp), lams.ctypes.data_as(dp),
446
+ float(l1_ratio), int(B), int(p), float(nobs),
447
+ int(max_sweeps), int(chunk), float(tol))
448
+ if rc == 0:
449
+ return beta
450
+ prev = np.empty(p)
451
+ for b in range(B):
452
+ gb, cb, bb = g[b], c[b], beta[b]
453
+ lam_b = float(lams[b])
454
+ swept = 0
455
+ while swept < max_sweeps:
456
+ prev[:] = bb
457
+ kernel_fn(gb, cb, bb, lam_b * l1_ratio,
458
+ lam_b * (1.0 - l1_ratio), chunk,
459
+ float(nobs), p)
460
+ swept += chunk
461
+ if np.max(np.abs(bb - prev)) < tol * max(
462
+ 1.0, float(np.max(np.abs(bb)))):
463
+ break
464
+ return beta
465
+
466
+
467
+ def _grid_descend(grams, corrs, lams, p, ntask, tol=1e-8, chunk=20,
468
+ max_sweeps=100000, l1_ratio=1.0, nobs=1.0):
469
+ """A batch of cold descents on the GPU, one block per problem.
470
+
471
+ grams is (n_grams, p*p), corrs (n_grams, width) with width p or
472
+ p*ntask, and lams the penalties of the batch in Gram-major order:
473
+ problem i uses Gram i // (batch // n_grams). Every problem starts
474
+ from zero and nothing is replicated: a cross-validation grid of cv
475
+ folds by num penalties moves cv Gram matrices, a fit_path_batch
476
+ one, a bootstrap one per resample. Returns (batch, width), or
477
+ None when the GPU path is not there or declined the shape, in
478
+ which case the caller runs its CPU path.
479
+ """
480
+ fn = getattr(_BATCH, "scm2cpp_cv_descend", None) if _BATCH else None
481
+ if fn is None:
482
+ return None
483
+ n_grams, batch = corrs.shape[0], lams.size
484
+ if batch % n_grams:
485
+ return None
486
+ width = p * (ntask if ntask > 0 else 1)
487
+ g = np.ascontiguousarray(grams, dtype=np.float64)
488
+ c = np.ascontiguousarray(np.repeat(corrs, batch // n_grams, axis=0),
489
+ dtype=np.float64)
490
+ w = np.zeros((batch, width))
491
+ lams = np.ascontiguousarray(lams, dtype=np.float64)
492
+ rc = fn(g.ctypes.data_as(_DP), int(n_grams), c.ctypes.data_as(_DP),
493
+ w.ctypes.data_as(_DP), lams.ctypes.data_as(_DP),
494
+ float(l1_ratio), int(batch), int(p), int(ntask), float(nobs),
495
+ int(max_sweeps), int(chunk), float(tol), 1, 1)
496
+ return w if rc == 0 else None
497
+
498
+
499
+ class CovLassoCV(Tagged, RegressorMixin, BaseEstimator):
500
+ """Cross-validated lasso, scikit-learn's LassoCV semantics.
501
+
502
+ The lambda grid comes from the full data (lambda_max down, the
503
+ sklearn construction), the folds are contiguous splits in order
504
+ (sklearn's KFold default), and validation error is one matrix
505
+ product per fold. alpha_ minimises the mean validation MSE, ties
506
+ going to the larger penalty as in scikit-learn, and coef_ is the
507
+ refit on all rows at alpha_.
508
+
509
+ With a GPU the whole grid -- cv folds by num penalties, each from
510
+ zero -- is one launch with one block of threads per problem, the
511
+ fold's Gram matrix shared by its problems; on the CPU each fold's
512
+ path is walked warm. force_cpu picks the CPU path; force_gpu is
513
+ accepted for compatibility (the GPU is now taken whenever it is
514
+ there and force_cpu is not set).
515
+
516
+ Attributes after fit: alphas_, mse_path_ (num, cv), alpha_, coef_.
517
+ """
518
+
519
+ def __init__(self, cv=5, num=100, eps=1e-3, tol=1e-8, l1_ratio=1.0,
520
+ force_cpu=False, force_gpu=False, n_jobs=1):
521
+ # Folds are independent, the descent is a ctypes call and the
522
+ # products are BLAS calls, both of which release the GIL, so
523
+ # plain threads scale the CPU path across folds. Default 1:
524
+ # sequential, like scikit-learn's n_jobs=None.
525
+ self.cv, self.num, self.eps, self.tol = cv, num, eps, tol
526
+ self.l1_ratio, self.force_cpu, self.force_gpu = l1_ratio, force_cpu, force_gpu
527
+ self.n_jobs = n_jobs
528
+
529
+ def fit(self, X, y):
530
+ X, y = fit_xy(self, X, y)
531
+ n = len(y)
532
+ cv, num = int(self.cv), int(self.num)
533
+ tol, l1_ratio = float(self.tol), float(self.l1_ratio)
534
+ full = CovLasso(X, y)
535
+ self.alphas_ = full.lambda_grid(num=num, eps=float(self.eps),
536
+ l1_ratio=l1_ratio)
537
+ # contiguous folds in row order, sizes as even as they come --
538
+ # sklearn's KFold(shuffle=False)
539
+ bounds = np.linspace(0, n, cv + 1).astype(int)
540
+ self.mse_path_ = np.empty((num, cv))
541
+ G = full.g.reshape(full.p, full.p)
542
+ if cuda_available() and not self.force_cpu:
543
+ done = self._fit_gpu(X, y, full, G, bounds, n)
544
+ if done is not None:
545
+ return done
546
+
547
+ def one_fold(k):
548
+ lo, hi = bounds[k], bounds[k + 1]
549
+ Xf, yf = X[lo:hi], y[lo:hi]
550
+ # The Gram is additive over rows, so a fold's training Gram
551
+ # is a subtraction: G_full - Xf'Xf costs O(n_fold p^2) where
552
+ # rebuilding from the complement costs O((n - n_fold) p^2).
553
+ # Over five folds that is the whole Gram work twice instead
554
+ # of four times, and the Gram is the dominant O(n p^2) step.
555
+ m = CovLasso(gram=(G - Xf.T @ Xf).ravel(),
556
+ corr=full.c0 - Xf.T @ yf,
557
+ nobs=n - (hi - lo))
558
+ # The warm-started path: after the Gram, each lambda resumes
559
+ # where the previous stopped and costs O(p) per moving
560
+ # coordinate. That warm path is what makes searching the
561
+ # alpha grid cheap -- the grid costs barely more than its
562
+ # hardest single alpha.
563
+ path = m.fit_path(self.alphas_, tol=tol, l1_ratio=l1_ratio)
564
+ resid = Xf @ path.T - yf[:, None]
565
+ self.mse_path_[:, k] = np.mean(resid * resid, axis=0)
566
+
567
+ if int(self.n_jobs) > 1:
568
+ from concurrent.futures import ThreadPoolExecutor
569
+ with ThreadPoolExecutor(max_workers=int(self.n_jobs)) as ex:
570
+ list(ex.map(one_fold, range(cv)))
571
+ else:
572
+ for k in range(cv):
573
+ one_fold(k)
574
+ return self._finish(full)
575
+
576
+ def _fit_gpu(self, X, y, full, G, bounds, n):
577
+ # Every fold and every alpha is an independent problem, so all of
578
+ # them go to the device as one batch, one block per problem,
579
+ # the problems of a fold sharing its Gram on the device. Fold
580
+ # sizes may differ by a row; the kernel only ever uses lam
581
+ # through the product lam * nobs, so each problem's nobs is
582
+ # absorbed into its lambda and nobs is passed as 1 -- exact,
583
+ # not approximate. Returns None when the device declined, and
584
+ # the CPU path takes over.
585
+ p_ = full.p
586
+ num, cv = int(self.num), int(self.cv)
587
+ grams = np.empty((cv, p_ * p_))
588
+ corrs = np.empty((cv, p_))
589
+ nobs_k = np.empty(cv)
590
+ for k in range(cv):
591
+ lo, hi = bounds[k], bounds[k + 1]
592
+ Xf, yf = X[lo:hi], y[lo:hi]
593
+ grams[k] = (G - Xf.T @ Xf).ravel()
594
+ corrs[k] = full.c0 - Xf.T @ yf
595
+ nobs_k[k] = n - (hi - lo)
596
+ lams = (np.tile(self.alphas_, cv) * np.repeat(nobs_k, num))
597
+ betas = _grid_descend(grams, corrs, lams, p_, 0, tol=float(self.tol),
598
+ l1_ratio=float(self.l1_ratio))
599
+ if betas is None:
600
+ return None
601
+ betas = betas.reshape(cv, num, p_)
602
+ for k in range(cv):
603
+ lo, hi = bounds[k], bounds[k + 1]
604
+ resid = X[lo:hi] @ betas[k].T - y[lo:hi, None]
605
+ self.mse_path_[:, k] = np.mean(resid * resid, axis=0)
606
+ return self._finish(full)
607
+
608
+ def _finish(self, full):
609
+ mean = self.mse_path_.mean(axis=1)
610
+ # alphas_ descends, so argmin's first hit is the larger penalty
611
+ self.alpha_ = float(self.alphas_[int(np.argmin(mean))])
612
+ self.coef_ = full.fit(self.alpha_, tol=float(self.tol),
613
+ l1_ratio=float(self.l1_ratio))
614
+ self.intercept_ = 0.0 if self.coef_.ndim == 1 else np.zeros(self.coef_.shape[1])
615
+ return self
616
+
617
+ def predict(self, X):
618
+ return pred_x(self, X) @ self.coef_
619
+
620
+
621
+ class CovRidge:
622
+ """Ridge regression over a design given by its Gram matrix.
623
+
624
+ The objective matches scikit-learn's ``Ridge`` with
625
+ ``fit_intercept=False``:
626
+
627
+ ||y - X b||^2 + alpha ||b||^2
628
+
629
+ (note: unlike the lasso objective, scikit-learn does not scale this
630
+ one by the number of rows), so the solution is
631
+ ``b = (X'X + alpha I)^{-1} X'y``.
632
+
633
+ The design matrix enters only through X'X and X'y, so the
634
+ constructor takes either the design or a Gram matrix somebody built
635
+ more cheaply. One symmetric eigendecomposition is done on first
636
+ use, O(p^3); every alpha after that costs O(p^2), which is what
637
+ makes a whole path cheap.
638
+ """
639
+
640
+ def __init__(self, X=None, y=None, *, gram=None, corr=None):
641
+ if gram is not None:
642
+ if corr is None:
643
+ raise ValueError("gram needs corr alongside it")
644
+ g = np.ascontiguousarray(gram, dtype=np.float64).ravel()
645
+ c = np.ascontiguousarray(corr, dtype=np.float64)
646
+ p = c.size
647
+ if g.size != p * p:
648
+ raise ValueError(f"gram is {g.size} entries; expected {p*p}")
649
+ self.p, self.g, self.c0 = p, g, c
650
+ else:
651
+ if X is None or y is None:
652
+ raise ValueError("pass X and y, or gram and corr")
653
+ X = np.ascontiguousarray(X, dtype=np.float64)
654
+ y = np.ascontiguousarray(y, dtype=np.float64)
655
+ if X.ndim != 2 or y.ndim != 1 or X.shape[0] != y.size:
656
+ raise ValueError(
657
+ f"X is {X.shape} and y is {y.shape}; expected (n, p) "
658
+ "and (n,)")
659
+ self.p = X.shape[1]
660
+ self.g = np.ascontiguousarray(_gram(X).ravel())
661
+ self.c0 = np.ascontiguousarray(_gram_xy(X, y))
662
+ self._eig = None
663
+
664
+ def _eigen(self):
665
+ if self._eig is None:
666
+ w, Q = np.linalg.eigh(self.g.reshape(self.p, self.p))
667
+ self._eig = (w, Q, Q.T @ self.c0)
668
+ return self._eig
669
+
670
+ def alpha_grid(self, num=100, eps=1e-6):
671
+ """A log-spaced path from the top eigenvalue of X'X downward.
672
+
673
+ At ``alpha`` around the largest eigenvalue the fit is heavily
674
+ shrunk; by ``eps`` times it the fit is essentially least
675
+ squares. Descending, like the lasso grids.
676
+ """
677
+ w, _, _ = self._eigen()
678
+ hi = float(w.max())
679
+ return hi * np.logspace(0, np.log10(eps), int(num))
680
+
681
+ def fit(self, alpha):
682
+ """Coefficients at one alpha: one solve of X'X + alpha I while no
683
+ path has needed the eigendecomposition (at p = 200 a few
684
+ milliseconds against eigh's tenth of a second), through it after."""
685
+ if self._eig is None and float(alpha) > 0.0:
686
+ b = _ridge_solve(self.g.reshape(self.p, self.p), self.c0, float(alpha))
687
+ if b is not None:
688
+ return b
689
+ w, Q, qc = self._eigen()
690
+ return Q @ (qc / (w + float(alpha)))
691
+
692
+ def fit_path(self, alphas):
693
+ """Coefficients per alpha, shape (len(alphas), p).
694
+
695
+ After the one eigendecomposition this is a single dense
696
+ product, so thousands of alphas cost what one does.
697
+ """
698
+ w, Q, qc = self._eigen()
699
+ alphas = np.asarray(alphas, dtype=np.float64)
700
+ return (Q @ (qc[:, None] / (w[:, None] + alphas[None, :]))).T
701
+
702
+
703
+ def _centered(X, y, fit_intercept):
704
+ # the data the intercept-free solvers see: centred when an intercept
705
+ # is fitted (scikit-learn's _preprocess_data), as they are otherwise
706
+ if not fit_intercept:
707
+ return X, y, np.zeros(X.shape[1]), np.zeros(y.shape[1:]) if y.ndim > 1 else 0.0
708
+ xm, ym = X.mean(axis=0), y.mean(axis=0)
709
+ return np.ascontiguousarray(X - xm), y - ym, xm, ym
710
+
711
+
712
+ def _centered_gram(X, y, fit_intercept):
713
+ """(Xc'Xc, Xc'yc, xm, ym) for the centred data Xc, yc, without
714
+ forming Xc: X'X - n xm xm' and X'y - n xm ym, which saves the copy
715
+ of X (as long as the Gram product itself on 20000 x 200 rows, where
716
+ it took the intercept from free to twice the fit). The correction
717
+ cancels digits when a column's mean is large against its spread,
718
+ about (mean / sd)^2 ulps, so a column with mean over 100 sd (or no
719
+ spread at all) sends the whole fit through the centred copy."""
720
+ n = X.shape[0]
721
+ G, c = _gram(X), _gram_xy(X, y)
722
+ if not fit_intercept:
723
+ return G, c, np.zeros(X.shape[1]), np.zeros(y.shape[1:]) if y.ndim > 1 else 0.0
724
+ xm, ym = X.mean(axis=0), y.mean(axis=0)
725
+ nm2 = n * xm * xm
726
+ if np.all(np.diagonal(G) - nm2 > 1e-4 * nm2):
727
+ G -= np.outer(xm, n * xm)
728
+ c -= np.multiply.outer(n * xm, ym)
729
+ return G, c, xm, ym
730
+ Xc, yc = np.ascontiguousarray(X - xm), y - ym
731
+ return Xc.T @ Xc, Xc.T @ yc, xm, ym
732
+
733
+
734
+ class GramElasticNet(Tagged, RegressorMixin, BaseEstimator):
735
+ """scikit-learn's ``ElasticNet(precompute=True)`` interface over
736
+ CovLasso: fit(X, y) centres the data when ``fit_intercept``, forms
737
+ the Gram matrix once and runs the covariance sweep at ``alpha``,
738
+
739
+ (1 / 2 n) ||y - X b - b0||^2 + alpha l1_ratio ||b||_1
740
+ + (alpha / 2)(1 - l1_ratio) ||b||^2.
741
+
742
+ ``tol`` is CovLasso's (the largest coefficient move in a sweep,
743
+ relative to the coefficients' size) and ``max_iter`` its sweep cap.
744
+ For a whole path or a grid, CovLasso itself (fit_path,
745
+ fit_path_batch) is the faster interface.
746
+
747
+ Attributes after fit: coef_, intercept_, n_features_in_.
748
+ """
749
+
750
+ def __init__(self, alpha=1.0, *, l1_ratio=0.5, fit_intercept=True, tol=1e-8,
751
+ max_iter=100000):
752
+ self.alpha, self.l1_ratio, self.fit_intercept = alpha, l1_ratio, fit_intercept
753
+ self.tol, self.max_iter = tol, max_iter
754
+
755
+ def fit(self, X, y):
756
+ X, y = fit_xy(self, X, y)
757
+ n, p = X.shape
758
+ G, c, xm, ym = _centered_gram(X, y, self.fit_intercept)
759
+ g = np.ascontiguousarray(G.ravel())
760
+ c = np.ascontiguousarray(c)
761
+ lam, r = float(self.alpha), float(self.l1_ratio)
762
+ tol, cap = float(self.tol), int(self.max_iter)
763
+ beta, prev = np.zeros(p), np.empty(p)
764
+ swept = 0
765
+ # CovLasso.fit_path's loop at one lambda, counting the sweeps
766
+ while swept < cap:
767
+ chunk = min(20, cap - swept)
768
+ prev[:] = beta
769
+ kernel.enet_descend(g, c, beta, lam * r, lam * (1.0 - r), chunk, float(n), p)
770
+ swept += chunk
771
+ if np.max(np.abs(beta - prev)) < tol * _scale(beta):
772
+ break
773
+ self.coef_, self.n_iter_ = beta, swept
774
+ self.intercept_ = float(ym - xm @ self.coef_)
775
+ return self
776
+
777
+ def predict(self, X):
778
+ return pred_x(self, X) @ self.coef_ + self.intercept_
779
+
780
+
781
+ class GramLasso(GramElasticNet):
782
+ """scikit-learn's ``Lasso(precompute=True)`` interface over CovLasso:
783
+ GramElasticNet with l1_ratio 1.
784
+
785
+ Attributes after fit: coef_, intercept_, n_features_in_.
786
+ """
787
+
788
+ def __init__(self, alpha=1.0, *, fit_intercept=True, tol=1e-8, max_iter=100000):
789
+ self.alpha, self.fit_intercept, self.tol, self.max_iter = alpha, fit_intercept, tol, max_iter
790
+
791
+ @property
792
+ def l1_ratio(self):
793
+ return 1.0
794
+
795
+
796
+ class GramRidge(Tagged, RegressorMixin, BaseEstimator):
797
+ """scikit-learn's ``Ridge`` interface over the Gram matrix:
798
+ (X'X + alpha I) b = X'y on the centred data by one Cholesky solve
799
+ (least squares when alpha is 0 and X'X singular), y one target or
800
+ several (coef_ then (n_targets, p), as Ridge has it). For many
801
+ alphas, CovRidge's fit_path is the faster interface.
802
+
803
+ Attributes after fit: coef_, intercept_, n_features_in_.
804
+ """
805
+ _multi_output = True
806
+
807
+ def __init__(self, alpha=1.0, *, fit_intercept=True):
808
+ self.alpha, self.fit_intercept = alpha, fit_intercept
809
+
810
+ def fit(self, X, y):
811
+ X, y = fit_xy(self, X, y, multi_output=True)
812
+ p = X.shape[1]
813
+ A, b, xm, ym = _centered_gram(X, y, self.fit_intercept)
814
+ coef = _ridge_solve(A, b, float(self.alpha))
815
+ if coef is None:
816
+ A.flat[::p + 1] += float(self.alpha)
817
+ try:
818
+ coef = np.linalg.solve(A, b)
819
+ except np.linalg.LinAlgError:
820
+ coef = np.linalg.lstsq(A, b, rcond=None)[0]
821
+ self.coef_ = coef.T if coef.ndim > 1 else coef
822
+ self.intercept_ = ym - xm @ coef
823
+ if np.ndim(self.intercept_) == 0:
824
+ self.intercept_ = float(self.intercept_)
825
+ return self
826
+
827
+ def predict(self, X):
828
+ return pred_x(self, X) @ self.coef_.T + self.intercept_
829
+
830
+
831
+ class GramRidgeCV(Tagged, RegressorMixin, BaseEstimator):
832
+ """scikit-learn's ``RidgeCV`` with ``cv=None``: the alpha whose
833
+ leave-one-out mean squared error is least, every alpha from one
834
+ eigenproblem (``examples/kernel-only/ridge-gcv.scm``). With the hat
835
+ matrix H = Z diag(1 / (lam + alpha)) Z' (plus 1/n with an
836
+ intercept), the leave-one-out residual is (y - yhat) / (1 - diag H),
837
+ and diag H and yhat at every alpha are two products. Z comes from
838
+ the eigenvectors of Xc'Xc (``gcv_mode="svd"``, the default when
839
+ n > p, X'X by dsyrk) or of Xc Xc' (``"eigen"``, the default
840
+ otherwise); the eigenproblem is LAPACK's dsyevd where the BLAS
841
+ build is here. Only ``cv=None`` and ``scoring=None`` are taken.
842
+
843
+ Attributes after fit: alpha_, best_score_ (minus the mean squared
844
+ leave-one-out error), coef_, intercept_, cv_results_ (with
845
+ ``store_cv_results``: the squared errors, (n, n_alphas) or
846
+ (n, n_targets, n_alphas)), n_features_in_.
847
+ """
848
+ _multi_output = True
849
+
850
+ def __init__(self, alphas=(0.1, 1.0, 10.0), *, fit_intercept=True, scoring=None,
851
+ cv=None, gcv_mode=None, store_cv_results=False, alpha_per_target=False):
852
+ self.alphas, self.fit_intercept, self.scoring, self.cv = alphas, fit_intercept, scoring, cv
853
+ self.gcv_mode, self.store_cv_results = gcv_mode, store_cv_results
854
+ self.alpha_per_target = alpha_per_target
855
+
856
+ def fit(self, X, y):
857
+ if self.cv is not None or self.scoring is not None:
858
+ raise ValueError("GramRidgeCV takes cv=None and scoring=None only "
859
+ "(leave-one-out by GCV, mean squared error)")
860
+ if self.gcv_mode not in (None, "auto", "svd", "eigen"):
861
+ raise ValueError(f"gcv_mode={self.gcv_mode!r}: expected None, 'auto', 'svd' or 'eigen'")
862
+ alphas = np.ascontiguousarray(np.atleast_1d(np.asarray(self.alphas, dtype=np.float64)))
863
+ if alphas.ndim != 1 or alphas.size == 0 or not np.all(alphas > 0.0):
864
+ raise ValueError(f"alphas={self.alphas!r}: expected positive values")
865
+ X, y = fit_xy(self, X, y, multi_output=True)
866
+ n, p = X.shape
867
+ Y = y.reshape(n, -1)
868
+ t = Y.shape[1]
869
+ k = _gramops_kernels()
870
+ if self.fit_intercept:
871
+ xm, ym = X.mean(axis=0), Y.mean(axis=0)
872
+ else:
873
+ xm, ym = np.zeros(p), np.zeros(t)
874
+ yc = np.ascontiguousarray(Y - ym)
875
+ mode = self.gcv_mode if self.gcv_mode in ("svd", "eigen") else ("svd" if n > p else "eigen")
876
+ if mode == "svd":
877
+ G = _gram(X)
878
+ Xs, xs = X, xm
879
+ nm2 = n * xm * xm
880
+ if self.fit_intercept and not np.all(np.diagonal(G) - nm2 > 1e-4 * nm2):
881
+ # a column whose mean dwarfs its spread: the correction
882
+ # X'X - n xm xm' would cancel its digits (_centered_gram)
883
+ Xs, xs = np.ascontiguousarray(X - xm), np.zeros(p)
884
+ G = _gram(Xs)
885
+ else:
886
+ G -= np.outer(xm, n * xm)
887
+ m = p
888
+ V, lam, Z, R = np.zeros((p, p)), np.zeros(p), np.zeros((n, p)), np.zeros((p, t))
889
+ k.ridge_gcv_primal(Xs, xs, yc, np.ascontiguousarray(G), V, lam, Z, R, n, p, t)
890
+ else:
891
+ Xc = np.ascontiguousarray(X - xm)
892
+ K = _gram(np.ascontiguousarray(Xc.T))
893
+ m = n
894
+ V, lam, Z, R = np.zeros((n, n)), np.zeros(n), np.zeros((n, n)), np.zeros((n, t))
895
+ k.ridge_gcv_dual(K, V, lam, yc, Z, R, n, t)
896
+ na = alphas.size
897
+ mse = np.zeros((na, t))
898
+ loo = np.zeros((n, t, na)) if self.store_cv_results else np.zeros(1)
899
+ k.ridge_gcv_scan(Z, lam, R, yc, alphas, 1.0 / n if self.fit_intercept else 0.0,
900
+ mse, loo, int(bool(self.store_cv_results)), n, m, t, na)
901
+ if self.alpha_per_target and t > 1:
902
+ best = np.argmin(mse, axis=0)
903
+ al = alphas[best]
904
+ self.alpha_ = al
905
+ self.best_score_ = -mse[best, np.arange(t)]
906
+ else:
907
+ best = int(np.argmin(mse.mean(axis=1)))
908
+ al = np.full(t, alphas[best])
909
+ self.alpha_ = float(alphas[best])
910
+ self.best_score_ = float(-mse[best].mean())
911
+ if mode == "svd":
912
+ coef = V @ (R / (lam[:, None] + al[None, :]))
913
+ else:
914
+ # (Xc Xc' + alpha I)^-1 yc by the eigenvectors, then Xc' of it
915
+ qy = V.T @ yc
916
+ coef = Xc.T @ (V @ (qy / (lam[:, None] + al[None, :])))
917
+ if y.ndim == 1:
918
+ self.coef_ = coef[:, 0]
919
+ self.intercept_ = float(ym[0] - xm @ coef[:, 0])
920
+ if self.alpha_per_target:
921
+ self.alpha_ = float(np.ravel(self.alpha_)[0])
922
+ self.best_score_ = float(np.ravel(self.best_score_)[0])
923
+ else:
924
+ self.coef_ = coef.T
925
+ self.intercept_ = ym - xm @ coef
926
+ if self.store_cv_results:
927
+ self.cv_results_ = loo[:, 0, :] if y.ndim == 1 else loo
928
+ return self
929
+
930
+ def predict(self, X):
931
+ return pred_x(self, X) @ self.coef_.T + self.intercept_
932
+
933
+
934
+ class CovBayesianRidge(Tagged, RegressorMixin, BaseEstimator):
935
+ """Bayesian ridge regression, scikit-learn's ``BayesianRidge``,
936
+ with the linear algebra in the eigenbasis of the Gram matrix.
937
+
938
+ The model, the defaults (``max_iter=300``, ``tol=1e-3``, the four
939
+ gamma hyperpriors at 1e-6, ``alpha`` starting at ``1/var(y)``,
940
+ ``lambda`` at 1) and the iteration are scikit-learn's: MacKay's
941
+ updates of the noise precision ``alpha`` and the weight precision
942
+ ``lambda``, the posterior mean recomputed after each, a stop when
943
+ the mean moves by less than ``tol`` in L1. What differs is where
944
+ the work goes. scikit-learn takes the SVD of X once and then, per
945
+ iteration, multiplies by X again for the squared error, O(n p).
946
+ Here, for n > p, X enters once, through X'X and X'y: one symmetric
947
+ eigendecomposition, after which the posterior mean, its squared
948
+ norm and the squared error are sums over the p eigenvalues, O(p) per
949
+ iteration. For n <= p the same is done with the n x n kernel
950
+ matrix X X'. With ``fit_intercept`` the data are centred, as in
951
+ scikit-learn, and the Gram is centred in place.
952
+
953
+ Attributes after fit: coef_, intercept_, alpha_, lambda_, sigma_
954
+ (the posterior covariance of the weights, p x p), n_iter_.
955
+ """
956
+
957
+ def __init__(self, *, max_iter=300, tol=1e-3, alpha_1=1e-6,
958
+ alpha_2=1e-6, lambda_1=1e-6, lambda_2=1e-6,
959
+ alpha_init=None, lambda_init=None, fit_intercept=True):
960
+ self.max_iter, self.tol = max_iter, tol
961
+ self.alpha_1, self.alpha_2 = alpha_1, alpha_2
962
+ self.lambda_1, self.lambda_2 = lambda_1, lambda_2
963
+ self.alpha_init, self.lambda_init = alpha_init, lambda_init
964
+ self.fit_intercept = fit_intercept
965
+
966
+ def fit(self, X, y):
967
+ X, y = fit_xy(self, X, y)
968
+ n, p = X.shape
969
+ max_iter, tol = int(self.max_iter), float(self.tol)
970
+ a1, a2 = float(self.alpha_1), float(self.alpha_2)
971
+ l1, l2 = float(self.lambda_1), float(self.lambda_2)
972
+ y_var = float(y.var())
973
+ if self.fit_intercept:
974
+ xm, ym = X.mean(axis=0), float(y.mean())
975
+ else:
976
+ xm, ym = np.zeros(p), 0.0
977
+ tall = n > p
978
+ if tall:
979
+ # X'X and X'y of the centred data, without centring X
980
+ G = X.T @ X
981
+ c = X.T @ y
982
+ if self.fit_intercept:
983
+ G -= n * np.outer(xm, xm)
984
+ c -= n * ym * xm
985
+ e, V = np.linalg.eigh(G)
986
+ z = V.T @ c
987
+ yy = float(y @ y) - n * ym * ym
988
+ else:
989
+ Xc = X - xm if self.fit_intercept else X
990
+ yc = y - ym
991
+ e, U = np.linalg.eigh(Xc @ Xc.T)
992
+ u = U.T @ yc
993
+ e = np.maximum(e, 0.0)
994
+
995
+ def mean_stats(alpha, lam):
996
+ # the posterior mean at (alpha, lam), its squared norm, and
997
+ # the squared error, without touching X when n > p
998
+ r = lam / alpha
999
+ if tall:
1000
+ w = z / (e + r)
1001
+ coef = V @ w
1002
+ sse = yy - 2.0 * float(w @ z) + float((e * w) @ w)
1003
+ return coef, float(w @ w), max(sse, 0.0)
1004
+ d = u / (e + r)
1005
+ coef = Xc.T @ (U @ d)
1006
+ resid = yc - Xc @ coef
1007
+ return coef, float((e * d) @ d), float(resid @ resid)
1008
+
1009
+ eps = np.finfo(np.float64).eps
1010
+ alpha = 1.0 / (y_var + eps) if self.alpha_init is None else float(self.alpha_init)
1011
+ lam = 1.0 if self.lambda_init is None else float(self.lambda_init)
1012
+ old = None
1013
+ for it in range(max_iter):
1014
+ coef, cc, sse = mean_stats(alpha, lam)
1015
+ gamma = float(np.sum(alpha * e / (lam + alpha * e)))
1016
+ lam = (gamma + 2 * l1) / (cc + 2 * l2)
1017
+ alpha = (n - gamma + 2 * a1) / (sse + 2 * a2)
1018
+ if it != 0 and float(np.sum(np.abs(old - coef))) < tol:
1019
+ break
1020
+ old = coef
1021
+ self.n_iter_ = it + 1
1022
+ self.alpha_, self.lambda_ = alpha, lam
1023
+ self.coef_, _, _ = mean_stats(alpha, lam)
1024
+ self.intercept_ = ym - float(xm @ self.coef_) if self.fit_intercept else 0.0
1025
+ self.X_offset_ = xm
1026
+ # the posterior covariance (lambda I + alpha X'X)^-1
1027
+ if tall:
1028
+ self.sigma_ = (V / (alpha * e + lam)) @ V.T
1029
+ else:
1030
+ # Woodbury through the kernel side: (1/lam)(I - X'U D U'X),
1031
+ # D = alpha / (lam + alpha e)
1032
+ B = Xc.T @ U
1033
+ self.sigma_ = (np.eye(p) - (B * (alpha / (lam + alpha * e))) @ B.T) / lam
1034
+ return self
1035
+
1036
+ def predict(self, X, return_std=False):
1037
+ X = pred_x(self, X)
1038
+ mean = X @ self.coef_ + self.intercept_
1039
+ if not return_std:
1040
+ return mean
1041
+ Xc = X - self.X_offset_
1042
+ var = np.einsum("ij,jk,ik->i", Xc, self.sigma_, Xc) + 1.0 / self.alpha_
1043
+ return mean, np.sqrt(var)
1044
+
1045
+ def _classifier_kernels():
1046
+ # imported on first use: the classifiers are their own library
1047
+ from ._generated import _loader_classifiers
1048
+ return _loader_classifiers
1049
+
1050
+
1051
+ def _binary_labels(y):
1052
+ """Two classes as (classes_, y in {-1, +1}), classes_[1] the +1."""
1053
+ y = np.asarray(y)
1054
+ classes = np.unique(y)
1055
+ if classes.size == 1:
1056
+ raise ValueError("expected two classes, got 1 class")
1057
+ if classes.size != 2:
1058
+ raise ValueError("Only binary classification is supported. The type of the target "
1059
+ f"is multiclass ({classes.size} classes).")
1060
+ return classes, np.where(y == classes[1], 1.0, -1.0)
1061
+
1062
+
1063
+ def _seed(random_state):
1064
+ # the solvers' Park-Miller generator wants a seed in [1, 2^31 - 2]
1065
+ return int(random_state) % 2147483646 + 1
1066
+
1067
+
1068
+ class LinearSVM(Tagged, ClassifierMixin, BaseEstimator):
1069
+ """The L1-loss (hinge) linear SVM by liblinear's own algorithm.
1070
+
1071
+ ``examples/kernel-only/svm-liblinear.scm`` translated plainly:
1072
+ liblinear's solve_l2r_l1l2_svc, dual coordinate descent with a
1073
+ random order in every sweep, shrinking, and the projected-gradient
1074
+ stop -- what scikit-learn's ``LinearSVC(loss="hinge", dual=True,
1075
+ fit_intercept=False)`` runs, on the same problem
1076
+
1077
+ min_w 1/2 ||w||^2 + C sum_i max(0, 1 - y_i w.x_i).
1078
+
1079
+ No intercept (as ``fit_intercept=False``); two classes, the second
1080
+ of ``classes_`` being the positive one. ``tol`` and ``max_iter``
1081
+ are LinearSVC's (the projected-gradient span and the sweep cap).
1082
+
1083
+ Attributes after fit: classes_, coef_, dual_coef_ (the alphas),
1084
+ n_iter_.
1085
+ """
1086
+
1087
+ _binary_only = True
1088
+
1089
+ def __init__(self, C=1.0, tol=1e-4, max_iter=1000, random_state=0):
1090
+ self.C, self.tol, self.max_iter = C, tol, max_iter
1091
+ self.random_state = random_state
1092
+
1093
+ def fit(self, X, y):
1094
+ X, y = fit_xy(self, X, y, classifier=True)
1095
+ n, p = X.shape
1096
+ self.classes_, ys = _binary_labels(y)
1097
+ w = np.zeros(p)
1098
+ alpha = np.zeros(n)
1099
+ xnorm = np.zeros(n)
1100
+ self.n_iter_ = _classifier_kernels().svm_liblinear(
1101
+ X, ys, w, alpha, xnorm, float(self.C), int(self.max_iter), float(self.tol),
1102
+ _seed(self.random_state), n, p)
1103
+ self.coef_, self.dual_coef_ = w, alpha
1104
+ return self
1105
+
1106
+ def decision_function(self, X):
1107
+ return pred_x(self, X) @ self.coef_
1108
+
1109
+ def predict(self, X):
1110
+ z = self.decision_function(X)
1111
+ return self.classes_[(z > 0).astype(int)]
1112
+
1113
+
1114
+ class _KernelMatrix:
1115
+ # the kernel matrix of KernelSVM and KernelSVR, as SVC and SVR take
1116
+ # it: rbf (gamma "scale", "auto" or a number), linear, or precomputed
1117
+ def _k(self, A, B):
1118
+ if self.kernel == "linear":
1119
+ return A @ B.T
1120
+ if self.kernel == "rbf":
1121
+ if A.shape[0] * B.shape[0] < 200_000:
1122
+ # numpy's vectorised exp, while the matrix sits in cache:
1123
+ # 0.14 ms to the loop's 0.31 at 200 x 200
1124
+ sa = (A * A).sum(1)[:, None]
1125
+ sb = (B * B).sum(1)[None, :]
1126
+ return np.exp(-self._gamma * np.maximum(sa + sb - 2.0 * (A @ B.T), 0.0))
1127
+ # the product by BLAS, the rest in place by svm-smo.scm's
1128
+ # rbf-from-products (on every thread of an OpenMP build;
1129
+ # 76 ms to numpy's 96 at 3,000 on one core, 50 to 105 on 20)
1130
+ G = np.ascontiguousarray(A @ B.T)
1131
+ _classifier_kernels().rbf_from_products(
1132
+ G, (A * A).sum(1), (B * B).sum(1), float(self._gamma), A.shape[0], B.shape[0])
1133
+ return G
1134
+ raise ValueError(f"kernel {self.kernel!r}: expected rbf, linear or precomputed")
1135
+
1136
+ def _train_kernel(self, X):
1137
+ if self.kernel == "precomputed":
1138
+ return X
1139
+ p = X.shape[1]
1140
+ if self.gamma == "scale":
1141
+ v = float(X.var())
1142
+ self._gamma = 1.0 / (p * v) if v > 0 else 1.0
1143
+ elif self.gamma == "auto":
1144
+ self._gamma = 1.0 / p
1145
+ else:
1146
+ self._gamma = float(self.gamma)
1147
+ return self._k(X, X)
1148
+
1149
+ def _support_kernel(self, X):
1150
+ X = pred_x(self, X)
1151
+ if self.kernel == "precomputed":
1152
+ return X[:, self.support_]
1153
+ return self._k(X, self.support_vectors_)
1154
+
1155
+
1156
+ class KernelSVM(Tagged, ClassifierMixin, _KernelMatrix, BaseEstimator):
1157
+ """C-SVC by libsvm's own algorithm, over the whole kernel matrix.
1158
+
1159
+ ``examples/kernel-only/svm-smo.scm`` translated plainly: libsvm's
1160
+ Solver::Solve -- SMO with the second-order working-set selection
1161
+ (WSS3), shrinking with gradient reconstruction, and the bias -- the
1162
+ solver scikit-learn's ``SVC`` runs, on the same problem (with the
1163
+ intercept). The kernel matrix is built once in numpy and held
1164
+ whole, n x n doubles (800 MB at n = 10,000): libsvm with every row
1165
+ in its cache. ``kernel`` is "rbf" (``gamma`` "scale", "auto" or a
1166
+ number, as in SVC), "linear", or "precomputed" (X is then the
1167
+ kernel matrix, and decision_function takes the kernel between new
1168
+ points and the training points).
1169
+
1170
+ Attributes after fit: classes_, support_, dual_coef_ (alpha_i y_i
1171
+ over the support), intercept_, n_iter_; support_vectors_ unless
1172
+ precomputed.
1173
+ """
1174
+
1175
+ _binary_only = True
1176
+ _pairwise = True
1177
+
1178
+ def __init__(self, C=1.0, kernel="rbf", gamma="scale", tol=1e-3, max_iter=-1):
1179
+ self.C, self.kernel, self.gamma = C, kernel, gamma
1180
+ self.tol, self.max_iter = tol, max_iter
1181
+
1182
+ def fit(self, X, y):
1183
+ X, y = fit_xy(self, X, y, classifier=True)
1184
+ self.classes_, ys = _binary_labels(y)
1185
+ n = ys.size
1186
+ K = self._train_kernel(X)
1187
+ alpha = np.zeros(n)
1188
+ rho = np.zeros(1)
1189
+ iters = int(self.max_iter) if self.max_iter > 0 else 10_000_000
1190
+ self.n_iter_ = _classifier_kernels().svm_smo(
1191
+ np.ascontiguousarray(K), ys, alpha, rho, float(self.C), iters, float(self.tol), n)
1192
+ self.support_ = np.flatnonzero(alpha > 0)
1193
+ self.dual_coef_ = (alpha * ys)[self.support_]
1194
+ self.intercept_ = -float(rho[0])
1195
+ if self.kernel != "precomputed":
1196
+ self.support_vectors_ = X[self.support_]
1197
+ return self
1198
+
1199
+ def decision_function(self, X):
1200
+ return self._support_kernel(X) @ self.dual_coef_ + self.intercept_
1201
+
1202
+ def predict(self, X):
1203
+ z = self.decision_function(X)
1204
+ return self.classes_[(z > 0).astype(int)]
1205
+
1206
+
1207
+ class LinearSVR(Tagged, RegressorMixin, BaseEstimator):
1208
+ """The linear epsilon-SVR with the L1 loss, by liblinear's own algorithm.
1209
+
1210
+ ``examples/kernel-only/svr-liblinear.scm`` translated plainly:
1211
+ liblinear's solve_l2r_l1l2_svr (dual coordinate descent, random
1212
+ order, shrinking) -- what scikit-learn's
1213
+ ``LinearSVR(loss="epsilon_insensitive", dual=True,
1214
+ fit_intercept=False)`` runs, on the same problem
1215
+
1216
+ min_w 1/2 ||w||^2 + C sum_i max(0, |y_i - w.x_i| - epsilon).
1217
+
1218
+ ``epsilon`` defaults to 0, ``tol`` to 1e-4 and ``max_iter`` to 1000
1219
+ sweeps, as in LinearSVR; no intercept.
1220
+
1221
+ Attributes after fit: coef_, dual_coef_ (the betas), n_iter_.
1222
+ """
1223
+
1224
+ def __init__(self, C=1.0, epsilon=0.0, tol=1e-4, max_iter=1000, random_state=0):
1225
+ self.C, self.epsilon = C, epsilon
1226
+ self.tol, self.max_iter = tol, max_iter
1227
+ self.random_state = random_state
1228
+
1229
+ def fit(self, X, y):
1230
+ X, y = fit_xy(self, X, y)
1231
+ n, p = X.shape
1232
+ w = np.zeros(p)
1233
+ beta = np.zeros(n)
1234
+ xnorm = np.zeros(n)
1235
+ self.n_iter_ = _classifier_kernels().svr_liblinear(
1236
+ X, y, w, beta, xnorm, float(self.C), float(self.epsilon), int(self.max_iter),
1237
+ float(self.tol), _seed(self.random_state), n, p)
1238
+ self.coef_, self.dual_coef_ = w, beta
1239
+ self.intercept_ = 0.0
1240
+ return self
1241
+
1242
+ def predict(self, X):
1243
+ return pred_x(self, X) @ self.coef_
1244
+
1245
+
1246
+ class KernelSVR(Tagged, RegressorMixin, _KernelMatrix, BaseEstimator):
1247
+ """epsilon-SVR by libsvm's own algorithm, over the whole kernel matrix.
1248
+
1249
+ The same translated SMO solver as KernelSVM (``svm-smo.scm``), set
1250
+ up as libsvm's solve_epsilon_svr sets it up: the 2n variables
1251
+ alpha and alpha* with linear terms epsilon -/+ y_i. scikit-learn's
1252
+ ``SVR``, with the intercept; ``epsilon=0.1``, ``tol=1e-3`` and the
1253
+ kernels as there. The kernel matrix is held whole.
1254
+
1255
+ Attributes after fit: support_, dual_coef_ (alpha - alpha* over the
1256
+ support), intercept_, n_iter_; support_vectors_ unless precomputed.
1257
+ """
1258
+
1259
+ _pairwise = True
1260
+
1261
+ def __init__(self, C=1.0, epsilon=0.1, kernel="rbf", gamma="scale", tol=1e-3, max_iter=-1):
1262
+ self.C, self.epsilon, self.kernel, self.gamma = C, epsilon, kernel, gamma
1263
+ self.tol, self.max_iter = tol, max_iter
1264
+
1265
+ def fit(self, X, y):
1266
+ X, y = fit_xy(self, X, y)
1267
+ n = y.size
1268
+ K = self._train_kernel(X)
1269
+ beta = np.zeros(n)
1270
+ rho = np.zeros(1)
1271
+ iters = int(self.max_iter) if self.max_iter > 0 else 10_000_000
1272
+ self.n_iter_ = _classifier_kernels().svr_smo(
1273
+ np.ascontiguousarray(K), y, beta, rho, float(self.C), float(self.epsilon), iters,
1274
+ float(self.tol), n)
1275
+ self.support_ = np.flatnonzero(beta != 0)
1276
+ self.dual_coef_ = beta[self.support_]
1277
+ self.intercept_ = -float(rho[0])
1278
+ if self.kernel != "precomputed":
1279
+ self.support_vectors_ = X[self.support_]
1280
+ return self
1281
+
1282
+ def predict(self, X):
1283
+ return self._support_kernel(X) @ self.dual_coef_ + self.intercept_
1284
+
1285
+
1286
+ def nmf_backend():
1287
+ """The variant CDNMF takes by default: cublas when a device
1288
+ answers, else blas, else loops."""
1289
+ built = nmf_backends()
1290
+ if not built:
1291
+ raise ImportError("scm2cpp_learn: no variant of the derived NMF was built")
1292
+ if "cublas" in built and cuda_available():
1293
+ return "cublas"
1294
+ cpu = [v for v in built if v != "cublas"]
1295
+ return cpu[0] if cpu else built[0]
1296
+
1297
+
1298
+ class CDNMF(Tagged, TransformerMixin, BaseEstimator):
1299
+ """Non-negative matrix factorization by coordinate descent, the
1300
+ translator's derivation end to end.
1301
+
1302
+ ``examples/kernel-only/nmf-cd.scm`` writes scikit-learn's
1303
+ NMF(solver="cd") iteration the plain way, with the residual
1304
+ R = X - W H: each half step sweeps the coordinates of one factor and
1305
+ moves R along the other. ``--derive`` turns that half step into
1306
+ scikit-learn's own form -- the memo X H' - W H H' over the hoisted
1307
+ Gram H H', with R never formed -- and ``--blas`` / ``--cublas`` hand
1308
+ the products to (cu)BLAS. Nothing of the Gram form is written by
1309
+ hand. The iteration, the stopping rule (the projected-gradient
1310
+ violation down to ``tol`` times the first iteration's) and the
1311
+ result are scikit-learn's with Frobenius loss, no regularization and
1312
+ no shuffling.
1313
+
1314
+ ``init`` is "random" (scikit-learn's: |N(0, 1)| scaled by
1315
+ sqrt(mean(X) / k), H drawn before W, from ``random_state``) or
1316
+ "custom" (W and H given to fit_transform). scikit-learn's default
1317
+ "nndsvda" is not offered; pass "custom" with its W and H for an
1318
+ exact comparison. ``backend`` is "cublas", "blas" or "loops", or
1319
+ None for the best built (``nmf_backend()``).
1320
+
1321
+ ``transform`` solves for W with H fixed by the same coordinate
1322
+ descent (scikit-learn's NMF.transform), in numpy.
1323
+
1324
+ Attributes after fit: components_ (H, k x p), n_components_, n_iter_,
1325
+ reconstruction_err_ (||X - W H||_F).
1326
+ """
1327
+ _positive_X = True
1328
+
1329
+ def __init__(self, n_components=None, *, init="random", tol=1e-4, max_iter=200,
1330
+ random_state=None, backend=None):
1331
+ self.n_components = n_components
1332
+ self.init, self.tol, self.max_iter = init, tol, max_iter
1333
+ self.random_state = random_state
1334
+ self.backend = backend
1335
+
1336
+ def _lib(self):
1337
+ import importlib
1338
+ v = self.backend or nmf_backend()
1339
+ return importlib.import_module(f"{__name__}._generated._loader_nmf_{v}")
1340
+
1341
+ def fit_transform(self, X, y=None, W=None, H=None):
1342
+ X = fit_x(self, X)
1343
+ if X.min() < 0:
1344
+ raise ValueError("Negative values in data passed to CDNMF (input X)")
1345
+ n, p = X.shape
1346
+ k = p if self.n_components is None else int(self.n_components)
1347
+ if k < 1:
1348
+ raise ValueError(f"n_components must be at least 1, got {self.n_components!r}")
1349
+ if self.init == "custom":
1350
+ if W is None or H is None:
1351
+ raise ValueError('init="custom" needs W and H')
1352
+ W = np.array(W, dtype=np.float64, copy=True)
1353
+ H = np.array(H, dtype=np.float64)
1354
+ elif self.init == "random":
1355
+ rng = np.random.RandomState(self.random_state)
1356
+ avg = np.sqrt(X.mean() / k)
1357
+ H = np.abs(avg * rng.standard_normal(size=(k, p)))
1358
+ W = np.abs(avg * rng.standard_normal(size=(n, k)))
1359
+ else:
1360
+ raise ValueError(f"init {self.init!r}: expected 'random' or 'custom'")
1361
+ W = np.ascontiguousarray(W)
1362
+ HT = np.ascontiguousarray(H.T)
1363
+ XT = np.ascontiguousarray(X.T)
1364
+ self.n_iter_ = self._lib().nmf(X, XT, W, HT, int(self.max_iter), float(self.tol),
1365
+ n, p, k)
1366
+ self.components_ = np.ascontiguousarray(HT.T)
1367
+ self.n_components_ = k
1368
+ self.reconstruction_err_ = float(np.linalg.norm(X - W @ self.components_))
1369
+ return W
1370
+
1371
+ def fit(self, X, y=None, W=None, H=None):
1372
+ self.fit_transform(X, W=W, H=H)
1373
+ return self
1374
+
1375
+ def transform(self, X):
1376
+ X = pred_x(self, X)
1377
+ if X.min() < 0:
1378
+ raise ValueError("Negative values in data passed to CDNMF (input X)")
1379
+ H = self.components_
1380
+ k = H.shape[0]
1381
+ HHt = H @ H.T
1382
+ XHt = X @ H.T
1383
+ avg = np.sqrt(X.mean() / k) if X.size else 0.0
1384
+ W = np.full((X.shape[0], k), avg)
1385
+ tol = float(self.tol)
1386
+ # the half step of the fit on W alone, H fixed; the stop on the
1387
+ # projected gradient, measured against the first sweep's, as there
1388
+ first = None
1389
+ for _ in range(int(self.max_iter)):
1390
+ viol = 0.0
1391
+ for j in range(k):
1392
+ if HHt[j, j] <= 0.0:
1393
+ continue
1394
+ grad = W @ HHt[:, j] - XHt[:, j]
1395
+ pg = np.where(W[:, j] == 0.0, np.minimum(grad, 0.0), grad)
1396
+ viol += float(np.abs(pg).sum())
1397
+ W[:, j] = np.maximum(W[:, j] - grad / HHt[j, j], 0.0)
1398
+ if first is None:
1399
+ first = viol
1400
+ if first == 0.0 or viol / first <= tol:
1401
+ break
1402
+ return W
1403
+
1404
+
1405
+ class CDGraphicalLasso(Tagged, BaseEstimator):
1406
+ """The graphical lasso, scikit-learn's own algorithm translated.
1407
+
1408
+ ``examples/kernel-only/graphical-lasso.scm`` is scikit-learn's
1409
+ _graphical_lasso in mode "cd" and its inner solver
1410
+ enet_coordinate_descent_gram (the gap-safe rule included), written
1411
+ out whole; ``--blas`` makes its two matrix-vector products dgemv.
1412
+ The same model and stopping rule as ``GraphicalLasso(mode="cd")``:
1413
+ ``alpha``, ``tol``, ``enet_tol``, ``max_iter`` and
1414
+ ``assume_centered`` mean what they mean there. scikit-learn's
1415
+ ``costs_`` (the objective each iteration, a log-determinant) is not
1416
+ computed.
1417
+
1418
+ Attributes after fit: covariance_, precision_, location_, n_iter_.
1419
+ """
1420
+
1421
+ def __init__(self, alpha=0.01, *, tol=1e-4, enet_tol=1e-4, max_iter=100,
1422
+ assume_centered=False):
1423
+ self.alpha, self.tol, self.enet_tol = alpha, tol, enet_tol
1424
+ self.max_iter, self.assume_centered = max_iter, assume_centered
1425
+
1426
+ def fit(self, X, y=None):
1427
+ import importlib
1428
+ from ._libfind import gramops_backend
1429
+ X = fit_x(self, X, ensure_min_samples=2, ensure_min_features=2)
1430
+ n, p = X.shape
1431
+ self.location_ = np.zeros(p) if self.assume_centered else X.mean(axis=0)
1432
+ Xc = X - self.location_
1433
+ emp = np.ascontiguousarray(Xc.T @ Xc / n)
1434
+ lib = importlib.import_module(f"{__name__}._generated._loader_gramops_{gramops_backend()}")
1435
+ self.covariance_ = np.zeros((p, p))
1436
+ self.precision_ = np.zeros((p, p))
1437
+ self.n_iter_ = lib.graphical_lasso(emp, self.covariance_, self.precision_,
1438
+ float(self.alpha), int(self.max_iter),
1439
+ float(self.tol), float(self.enet_tol), p)
1440
+ if not np.isfinite(self.precision_).all():
1441
+ raise FloatingPointError("the system is too ill-conditioned for this solver")
1442
+ return self
1443
+
1444
+
1445
+ class LinearRANSAC(Tagged, RegressorMixin, BaseEstimator):
1446
+ """RANSAC for a linear model, scikit-learn's algorithm translated.
1447
+
1448
+ ``examples/kernel-only/ransac.scm`` is RANSACRegressor.fit around
1449
+ LinearRegression, written out whole: per trial, least squares with
1450
+ an intercept on a subset of ``min_samples`` points, the inliers
1451
+ within ``residual_threshold``, the fit kept by inlier count and then
1452
+ R^2, the trial count lowered by scikit-learn's _dynamic_max_trials;
1453
+ finally least squares on the best inliers. The defaults are
1454
+ RANSACRegressor's (min_samples p + 1, the threshold the MAD of y,
1455
+ max_trials 100, stop_probability 0.99).
1456
+
1457
+ The subsets are drawn here, ``max_trials`` of them, before the
1458
+ trials run. With scikit-learn installed they are drawn exactly as
1459
+ RANSACRegressor draws them (sample_without_replacement on a
1460
+ RandomState of ``random_state``), so the result is scikit-learn's;
1461
+ without it, numpy's choice draws different subsets.
1462
+
1463
+ Attributes after fit: coef_, intercept_, inlier_mask_, n_trials_.
1464
+ """
1465
+
1466
+ def __init__(self, *, min_samples=None, residual_threshold=None, max_trials=100,
1467
+ stop_probability=0.99, random_state=None):
1468
+ self.min_samples, self.residual_threshold = min_samples, residual_threshold
1469
+ self.max_trials, self.stop_probability = max_trials, stop_probability
1470
+ self.random_state = random_state
1471
+
1472
+ def fit(self, X, y):
1473
+ X, y = fit_xy(self, X, y)
1474
+ n, p = X.shape
1475
+ max_trials = int(self.max_trials)
1476
+ ms = p + 1 if self.min_samples is None else int(self.min_samples)
1477
+ thr = (float(np.median(np.abs(y - np.median(y)))) if self.residual_threshold is None
1478
+ else float(self.residual_threshold))
1479
+ rs = np.random.RandomState(self.random_state)
1480
+ try:
1481
+ from sklearn.utils.random import sample_without_replacement
1482
+ draw = lambda: sample_without_replacement(n, ms, random_state=rs)
1483
+ except ImportError:
1484
+ draw = lambda: rs.choice(n, ms, replace=False)
1485
+ if ms > n:
1486
+ raise ValueError(f"min_samples may not be larger than the number of samples: "
1487
+ f"n_samples = {n}")
1488
+ subsets = np.array([draw() for _ in range(max_trials)], dtype=np.float64)
1489
+ coef = np.zeros(p)
1490
+ mask = np.zeros(n)
1491
+ out = np.zeros(1)
1492
+ self.n_trials_ = _classifier_kernels().ransac(
1493
+ X, y, subsets, coef, mask, out, max_trials, ms, thr, float(self.stop_probability),
1494
+ n, p)
1495
+ self.coef_, self.intercept_ = coef, float(out[0])
1496
+ self.inlier_mask_ = mask.astype(bool)
1497
+ return self
1498
+
1499
+ def predict(self, X):
1500
+ return pred_x(self, X) @ self.coef_ + self.intercept_
1501
+
1502
+
1503
+ def _center(X, y, fit_intercept):
1504
+ X = np.ascontiguousarray(X, dtype=np.float64)
1505
+ y = np.ascontiguousarray(y, dtype=np.float64)
1506
+ if fit_intercept:
1507
+ xm, ym = X.mean(axis=0), float(y.mean())
1508
+ return np.ascontiguousarray(X - xm), y - ym, xm, ym
1509
+ return X, y, np.zeros(X.shape[1]), 0.0
1510
+
1511
+
1512
+ _SGD_LOSS = {"hinge": 0, "squared_error": 1, "perceptron": 2, "epsilon_insensitive": 3}
1513
+ _SGD_LR = {"optimal": 0, "invscaling": 1, "constant": 2, "pa1": 3, "pa2": 4}
1514
+
1515
+
1516
+ class _SGDLinear(BaseEstimator):
1517
+ # the shared run; the subclasses' __init__ name the parameters
1518
+ # (scikit-learn reads them off the signature), so this one has none
1519
+ def _set(self, *, loss, penalty, alpha, max_iter, tol, n_iter_no_change, fit_intercept,
1520
+ learning_rate, eta0, power_t, epsilon=0.1):
1521
+ self.loss, self.penalty, self.alpha = loss, penalty, alpha
1522
+ self.max_iter, self.tol = max_iter, tol
1523
+ self.n_iter_no_change, self.fit_intercept = n_iter_no_change, fit_intercept
1524
+ self.learning_rate, self.eta0, self.power_t = learning_rate, eta0, power_t
1525
+ self.epsilon = epsilon
1526
+
1527
+ def _run(self, X, y):
1528
+ loss = getattr(self, "loss", "perceptron")
1529
+ lr = getattr(self, "learning_rate", "constant")
1530
+ penalty = getattr(self, "penalty", None)
1531
+ if loss not in _SGD_LOSS or lr not in _SGD_LR:
1532
+ raise ValueError("loss is one of %s, learning_rate one of %s"
1533
+ % (sorted(_SGD_LOSS), sorted(_SGD_LR)))
1534
+ if penalty not in ("l2", None):
1535
+ raise ValueError("penalty is 'l2' or None")
1536
+ n, p = X.shape
1537
+ w = np.zeros(p)
1538
+ out = np.zeros(1)
1539
+ self.n_iter_ = _classifier_kernels().sgd(
1540
+ X, np.ascontiguousarray(y, dtype=np.float64), w, out,
1541
+ _SGD_LOSS[loss], _SGD_LR[lr],
1542
+ 1 if penalty == "l2" else 0, float(getattr(self, "alpha", 1e-4)),
1543
+ float(self.eta0), float(getattr(self, "power_t", 0.5)),
1544
+ float(getattr(self, "epsilon", 0.1)), int(self.max_iter), float(self.tol),
1545
+ int(self.n_iter_no_change), 1 if self.fit_intercept else 0, n, p)
1546
+ self.coef_, self.intercept_ = w, float(out[0])
1547
+ return self
1548
+
1549
+ def _linear(self, X):
1550
+ return pred_x(self, X) @ self.coef_ + self.intercept_
1551
+
1552
+
1553
+ class SGDLinearClassifier(Tagged, ClassifierMixin, _SGDLinear):
1554
+ """scikit-learn's SGDClassifier by its own _plain_sgd translated
1555
+ (``examples/kernel-only/sgd.scm``), with shuffle=False: the points
1556
+ are visited in order. scikit-learn's shuffling draws from its own
1557
+ xorshift generator, which the kernel does not reproduce, so compare
1558
+ against ``SGDClassifier(shuffle=False)``, which this matches to
1559
+ rounding. loss "hinge" or "perceptron", penalty "l2" or None,
1560
+ learning_rate "optimal", "constant", "invscaling", "pa1" or "pa2"
1561
+ (the passive-aggressive steps, eta0 their C: scikit-learn's
1562
+ replacement for PassiveAggressiveClassifier). Two classes, the
1563
+ second of ``classes_`` positive.
1564
+
1565
+ Attributes after fit: classes_, coef_, intercept_, n_iter_.
1566
+ """
1567
+
1568
+ def __init__(self, *, loss="hinge", penalty="l2", alpha=1e-4, max_iter=1000, tol=1e-3,
1569
+ n_iter_no_change=5, fit_intercept=True, learning_rate="optimal",
1570
+ eta0=0.01, power_t=0.5):
1571
+ self._set(loss=loss, penalty=penalty, alpha=alpha, max_iter=max_iter,
1572
+ tol=tol, n_iter_no_change=n_iter_no_change,
1573
+ fit_intercept=fit_intercept, learning_rate=learning_rate,
1574
+ eta0=eta0, power_t=power_t)
1575
+ del self.epsilon
1576
+
1577
+ _binary_only = True
1578
+
1579
+ def fit(self, X, y):
1580
+ X, y = fit_xy(self, X, y, classifier=True)
1581
+ self.classes_, ys = _binary_labels(y)
1582
+ return self._run(X, ys)
1583
+
1584
+ def decision_function(self, X):
1585
+ return self._linear(X)
1586
+
1587
+ def predict(self, X):
1588
+ z = self.decision_function(X)
1589
+ return self.classes_[(z > 0).astype(int)]
1590
+
1591
+
1592
+ class SGDLinearRegressor(Tagged, RegressorMixin, _SGDLinear):
1593
+ """scikit-learn's SGDRegressor by its own _plain_sgd translated, with
1594
+ shuffle=False (see SGDLinearClassifier). loss "squared_error" or
1595
+ "epsilon_insensitive" (``epsilon``), penalty "l2" or None,
1596
+ learning_rate "invscaling" (the default), "constant", "optimal",
1597
+ "pa1" or "pa2" (scikit-learn's replacement for
1598
+ PassiveAggressiveRegressor).
1599
+
1600
+ Attributes after fit: coef_, intercept_, n_iter_.
1601
+ """
1602
+
1603
+ def __init__(self, *, loss="squared_error", penalty="l2", alpha=1e-4, max_iter=1000,
1604
+ tol=1e-3, n_iter_no_change=5, fit_intercept=True,
1605
+ learning_rate="invscaling", eta0=0.01, power_t=0.25, epsilon=0.1):
1606
+ self._set(loss=loss, penalty=penalty, alpha=alpha, max_iter=max_iter,
1607
+ tol=tol, n_iter_no_change=n_iter_no_change,
1608
+ fit_intercept=fit_intercept, learning_rate=learning_rate,
1609
+ eta0=eta0, power_t=power_t, epsilon=epsilon)
1610
+
1611
+ def fit(self, X, y):
1612
+ X, y = fit_xy(self, X, y)
1613
+ return self._run(X, y)
1614
+
1615
+ def predict(self, X):
1616
+ return self._linear(X)
1617
+
1618
+
1619
+ class SGDPerceptron(SGDLinearClassifier):
1620
+ """scikit-learn's Perceptron: SGDClassifier with the perceptron's
1621
+ hinge (threshold 0), no penalty and the constant step eta0 = 1, by
1622
+ the same translated _plain_sgd, shuffle=False.
1623
+
1624
+ Attributes after fit: classes_, coef_, intercept_, n_iter_.
1625
+ """
1626
+
1627
+ # the loss, penalty and step rule are fixed, not parameters: _run
1628
+ # falls back to the perceptron's when the attributes are absent
1629
+ def __init__(self, *, max_iter=1000, tol=1e-3, n_iter_no_change=5, fit_intercept=True,
1630
+ eta0=1.0):
1631
+ self.max_iter, self.tol, self.n_iter_no_change = max_iter, tol, n_iter_no_change
1632
+ self.fit_intercept, self.eta0 = fit_intercept, eta0
1633
+
1634
+
1635
+ class GramOMP(Tagged, RegressorMixin, BaseEstimator):
1636
+ """Orthogonal matching pursuit over the Gram matrix, scikit-learn's
1637
+ own algorithm translated (``examples/kernel-only/omp.scm``:
1638
+ orthogonal_mp_gram and _gram_omp, X'X and X'y by dsyrk and dgemv).
1639
+ ``OrthogonalMatchingPursuit`` takes this route for n > p
1640
+ (precompute="auto"); here it is taken always. ``n_nonzero_coefs``
1641
+ defaults, as there, to 10% of the features.
1642
+
1643
+ Attributes after fit: coef_, intercept_, n_iter_.
1644
+ """
1645
+
1646
+ def __init__(self, *, n_nonzero_coefs=None, fit_intercept=True):
1647
+ self.n_nonzero_coefs, self.fit_intercept = n_nonzero_coefs, fit_intercept
1648
+
1649
+ def fit(self, X, y):
1650
+ import importlib
1651
+ from ._libfind import gramops_backend
1652
+ X, y = fit_xy(self, X, y)
1653
+ Xc, yc, xm, ym = _center(X, y, self.fit_intercept)
1654
+ n, p = Xc.shape
1655
+ k = max(int(0.1 * p), 1) if self.n_nonzero_coefs is None else int(self.n_nonzero_coefs)
1656
+ lib = importlib.import_module(f"{__name__}._generated._loader_gramops_{gramops_backend()}")
1657
+ self.coef_ = np.zeros(p)
1658
+ self.n_iter_ = lib.omp(Xc, yc, self.coef_, np.zeros(1), 0, k, n, p)
1659
+ self.intercept_ = ym - float(xm @ self.coef_)
1660
+ return self
1661
+
1662
+ def predict(self, X):
1663
+ return pred_x(self, X) @ self.coef_ + self.intercept_
1664
+
1665
+
1666
+ _GRAMOPS = []
1667
+
1668
+
1669
+ def _gramops_kernels():
1670
+ # looked up once: several calls a fit, each a glob before
1671
+ if not _GRAMOPS:
1672
+ import importlib
1673
+ from ._libfind import gramops_backend
1674
+ _GRAMOPS.append(importlib.import_module(
1675
+ f"{__name__}._generated._loader_gramops_{gramops_backend()}"))
1676
+ return _GRAMOPS[0]
1677
+
1678
+
1679
+ class LBFGSLogisticRegression(Tagged, ClassifierMixin, BaseEstimator):
1680
+ """L2-penalised logistic regression, scikit-learn's
1681
+ ``LogisticRegression(solver="lbfgs")`` translated whole
1682
+ (``examples/kernel-only/logistic-lbfgs.scm`` over ``lbfgs.scm``):
1683
+ the mean binomial loss for two classes, the multinomial for more,
1684
+ plus |W|^2 / (2 C n), minimised by scipy's L-BFGS-B as written in
1685
+ the subset (ten pairs, the More-Thuente search, gtol ``tol``, ftol
1686
+ 64 eps). The products X W' and R' X are dgemm where a CBLAS links.
1687
+ On the same data it takes the same iterations as scikit-learn and
1688
+ lands on the same coefficients to rounding.
1689
+
1690
+ Attributes after fit: classes_, coef_, intercept_, n_iter_.
1691
+ """
1692
+
1693
+ def __init__(self, *, C=1.0, tol=1e-4, max_iter=100, fit_intercept=True):
1694
+ self.C, self.tol, self.max_iter, self.fit_intercept = C, tol, max_iter, fit_intercept
1695
+
1696
+ def fit(self, X, y):
1697
+ X, y = fit_xy(self, X, y, classifier=True)
1698
+ self.classes_, yi = classes_of(y)
1699
+ n, p = X.shape
1700
+ nc = len(self.classes_)
1701
+ if nc < 2:
1702
+ raise ValueError("This solver needs samples of at least 2 classes in the data, "
1703
+ f"but the data contains only one class: {self.classes_[0]!r}")
1704
+ multi = 1 if nc > 2 else 0
1705
+ k = nc if multi else 1
1706
+ coef = np.zeros(k * (p + 1))
1707
+ out = np.zeros(2)
1708
+ it = _gpu_or_cpu(n * p, getattr(self, "backend", None)).logistic_lbfgs(
1709
+ X, yi.astype(np.float64), coef, out, float(self.C), int(self.max_iter),
1710
+ float(self.tol), multi, 1 if self.fit_intercept else 0, n, p, k)
1711
+ self.coef_ = coef[:k * p].reshape(k, p).copy()
1712
+ self.intercept_ = coef[k * p:].copy()
1713
+ self.n_iter_ = np.array([min(it, self.max_iter)])
1714
+ return self
1715
+
1716
+ def decision_function(self, X):
1717
+ z = pred_x(self, X) @ self.coef_.T + self.intercept_
1718
+ return z[:, 0] if z.shape[1] == 1 else z
1719
+
1720
+ def predict_proba(self, X):
1721
+ z = self.decision_function(X)
1722
+ if z.ndim == 1:
1723
+ p1 = 1.0 / (1.0 + np.exp(-z))
1724
+ return np.column_stack([1.0 - p1, p1])
1725
+ e = np.exp(z - z.max(axis=1, keepdims=True))
1726
+ return e / e.sum(axis=1, keepdims=True)
1727
+
1728
+ def predict(self, X):
1729
+ z = self.decision_function(X)
1730
+ if z.ndim == 1:
1731
+ return self.classes_[(z > 0).astype(int)]
1732
+ return self.classes_[np.argmax(z, axis=1)]
1733
+
1734
+
1735
+ def _kfold(n, k):
1736
+ """scikit-learn's KFold(k) without shuffling: contiguous test blocks,
1737
+ the first n % k of them one longer."""
1738
+ sizes = np.full(k, n // k)
1739
+ sizes[: n % k] += 1
1740
+ out, start = [], 0
1741
+ for sz in sizes:
1742
+ test = np.arange(start, start + sz)
1743
+ out.append((np.r_[np.arange(0, start), np.arange(start + sz, n)], test))
1744
+ start += sz
1745
+ return out
1746
+
1747
+
1748
+ def _stratified_kfold(y, k):
1749
+ """scikit-learn's StratifiedKFold(k) without shuffling: classes in
1750
+ order of first appearance, each spread over the folds as the sorted
1751
+ labels dealt k ways."""
1752
+ y = np.asarray(y)
1753
+ _, y_idx, y_inv = np.unique(y, return_index=True, return_inverse=True)
1754
+ _, class_perm = np.unique(y_idx, return_inverse=True)
1755
+ y_enc = class_perm[y_inv]
1756
+ nc = len(y_idx)
1757
+ y_order = np.sort(y_enc)
1758
+ alloc = np.asarray([np.bincount(y_order[i::k], minlength=nc) for i in range(k)])
1759
+ test_folds = np.empty(len(y), dtype=int)
1760
+ for c in range(nc):
1761
+ test_folds[y_enc == c] = np.arange(k).repeat(alloc[:, c])
1762
+ return [(np.flatnonzero(test_folds != f), np.flatnonzero(test_folds == f))
1763
+ for f in range(k)]
1764
+
1765
+
1766
+ class _LBFGSGLM(Tagged, RegressorMixin, BaseEstimator):
1767
+ _family = None
1768
+
1769
+ def fit(self, X, y):
1770
+ X, y = fit_xy(self, X, y)
1771
+ n, p = X.shape
1772
+ fam, _ = self._family_power()
1773
+ if fam in (0, 2) and (y < 0).any() or fam == 1 and (y <= 0).any():
1774
+ raise ValueError(f"Some value(s) of y are out of the valid range of the loss "
1775
+ f"{type(self).__name__}")
1776
+ coef = np.zeros(p + 1)
1777
+ out = np.zeros(2)
1778
+ fam, power = self._family_power()
1779
+ it = _gpu_or_cpu(n * p, getattr(self, "backend", None)).glm(X, y, coef, out, fam, float(power), float(self.alpha),
1780
+ int(self.max_iter), float(self.tol),
1781
+ 1 if self.fit_intercept else 0, n, p)
1782
+ self.coef_, self.intercept_ = coef[:p].copy(), float(coef[p])
1783
+ self.n_iter_ = min(it, self.max_iter)
1784
+ return self
1785
+
1786
+ def _linear(self, X):
1787
+ return pred_x(self, X) @ self.coef_ + self.intercept_
1788
+
1789
+ def predict(self, X):
1790
+ z = self._linear(X)
1791
+ return z if self._family_power() == (3, 0.0) else np.exp(z)
1792
+
1793
+
1794
+ class LBFGSPoissonRegressor(_LBFGSGLM):
1795
+ _positive_y = True
1796
+
1797
+ """scikit-learn's ``PoissonRegressor`` (solver "lbfgs") translated
1798
+ whole (``examples/kernel-only/glm.scm`` over ``lbfgs.scm``): the mean
1799
+ half Poisson deviance with the log link plus alpha |w|^2 / 2, from
1800
+ scikit-learn's start. Same iterations and coefficients to rounding.
1801
+
1802
+ Attributes after fit: coef_, intercept_, n_iter_.
1803
+ """
1804
+
1805
+ def __init__(self, *, alpha=1.0, fit_intercept=True, max_iter=100, tol=1e-4):
1806
+ self.alpha, self.fit_intercept, self.max_iter, self.tol = alpha, fit_intercept, max_iter, tol
1807
+
1808
+ def _family_power(self):
1809
+ return 0, 1.0
1810
+
1811
+
1812
+ class LBFGSGammaRegressor(LBFGSPoissonRegressor):
1813
+ """scikit-learn's ``GammaRegressor`` (solver "lbfgs"), as
1814
+ LBFGSPoissonRegressor with the half Gamma deviance.
1815
+
1816
+ Attributes after fit: coef_, intercept_, n_iter_.
1817
+ """
1818
+
1819
+ def _family_power(self):
1820
+ return 1, 2.0
1821
+
1822
+
1823
+ class LBFGSTweedieRegressor(_LBFGSGLM):
1824
+ """scikit-learn's ``TweedieRegressor`` (solver "lbfgs"): ``power`` 0
1825
+ with the identity link (the default), or a power of 1 or more with
1826
+ the log link, as link="auto" chooses.
1827
+
1828
+ Attributes after fit: coef_, intercept_, n_iter_.
1829
+ """
1830
+
1831
+ def __init__(self, *, power=0.0, alpha=1.0, fit_intercept=True, max_iter=100, tol=1e-4):
1832
+ self.power, self.alpha, self.fit_intercept = power, alpha, fit_intercept
1833
+ self.max_iter, self.tol = max_iter, tol
1834
+
1835
+ def _family_power(self):
1836
+ if self.power == 0:
1837
+ return 3, 0.0
1838
+ if self.power < 1:
1839
+ raise ValueError("power is 0 or at least 1 here")
1840
+ return 2, float(self.power)
1841
+
1842
+
1843
+ class LBFGSLogisticRegressionCV(Tagged, ClassifierMixin, BaseEstimator):
1844
+ """scikit-learn's ``LogisticRegressionCV`` with its defaults (the L2
1845
+ penalty, solver "lbfgs", Cs 10 values from 1e-4 to 1e4,
1846
+ StratifiedKFold(5) without shuffling, accuracy, refit): each fold's
1847
+ path over the Cs warm-started from the previous C by the translated
1848
+ LogisticRegression (``logistic-lbfgs.scm``), the C of the best summed
1849
+ score, and the refit from the folds' mean coefficients at that C, as
1850
+ scikit-learn starts it.
1851
+
1852
+ Attributes after fit: classes_, Cs_, C_, coef_, intercept_, scores_,
1853
+ n_iter_.
1854
+ """
1855
+
1856
+ def __init__(self, *, Cs=10, cv=5, tol=1e-4, max_iter=100, fit_intercept=True):
1857
+ self.Cs, self.cv, self.tol, self.max_iter = Cs, cv, tol, max_iter
1858
+ self.fit_intercept = fit_intercept
1859
+
1860
+ def _path(self, X, yi, Cs, coef, multi, k):
1861
+ n, p = X.shape
1862
+ lib = _gpu_or_cpu(n * p, getattr(self, "backend", None))
1863
+ coefs, iters = [], []
1864
+ out = np.zeros(2)
1865
+ for C in Cs:
1866
+ it = lib.logistic_lbfgs(X, yi, coef, out, float(C), int(self.max_iter),
1867
+ float(self.tol), multi, 1 if self.fit_intercept else 0,
1868
+ n, p, k)
1869
+ coefs.append(coef.copy())
1870
+ iters.append(min(it, self.max_iter))
1871
+ return coefs, iters
1872
+
1873
+ def fit(self, X, y):
1874
+ X, y = fit_xy(self, X, y, classifier=True)
1875
+ self.classes_, yi = classes_of(y)
1876
+ nc = len(self.classes_)
1877
+ if nc < 2:
1878
+ raise ValueError("This solver needs samples of at least 2 classes in the data, "
1879
+ f"but the data contains only one class: {self.classes_[0]!r}")
1880
+ multi = 1 if nc > 2 else 0
1881
+ k = nc if multi else 1
1882
+ n, p = X.shape
1883
+ Cs = (np.logspace(-4, 4, int(self.Cs)) if np.isscalar(self.Cs)
1884
+ else np.asarray(self.Cs, float))
1885
+ self.Cs_ = Cs
1886
+ yf = yi.astype(np.float64)
1887
+ folds = _stratified_kfold(y, int(self.cv))
1888
+ paths, scores, iters = [], [], []
1889
+ for train, test in folds:
1890
+ coefs, it = self._path(np.ascontiguousarray(X[train]), yf[train], Cs,
1891
+ np.zeros(k * (p + 1)), multi, k)
1892
+ paths.append(coefs)
1893
+ iters.append(it)
1894
+ sc = []
1895
+ for c in coefs:
1896
+ z = X[test] @ c[:k * p].reshape(k, p).T + c[k * p:]
1897
+ pred = (z[:, 0] > 0).astype(int) if k == 1 else np.argmax(z, axis=1)
1898
+ sc.append(float(np.mean(pred == yi[test])))
1899
+ scores.append(sc)
1900
+ scores = np.asarray(scores)
1901
+ best = int(np.argmax(scores.sum(axis=0)))
1902
+ self.C_ = float(Cs[best])
1903
+ start = np.mean([paths[f][best] for f in range(len(folds))], axis=0)
1904
+ coefs, it = self._path(X, yf, [self.C_], start.copy(), multi, k)
1905
+ c = coefs[0]
1906
+ self.coef_ = c[:k * p].reshape(k, p).copy()
1907
+ self.intercept_ = c[k * p:].copy()
1908
+ self.scores_ = scores
1909
+ self.n_iter_ = np.asarray(iters)
1910
+ return self
1911
+
1912
+ def decision_function(self, X):
1913
+ z = pred_x(self, X) @ self.coef_.T + self.intercept_
1914
+ return z[:, 0] if z.shape[1] == 1 else z
1915
+
1916
+ def predict(self, X):
1917
+ z = self.decision_function(X)
1918
+ if z.ndim == 1:
1919
+ return self.classes_[(z > 0).astype(int)]
1920
+ return self.classes_[np.argmax(z, axis=1)]
1921
+
1922
+
1923
+ class LBFGSHuberRegressor(Tagged, RegressorMixin, BaseEstimator):
1924
+ """The Huber regressor, scikit-learn's ``HuberRegressor`` translated
1925
+ whole (``examples/kernel-only/huber.scm`` over ``lbfgs.scm``): its
1926
+ _huber_loss_and_gradient over the weights, the intercept and the
1927
+ scale, minimised by L-BFGS-B with the scale bounded below by 10 eps
1928
+ (the generalized Cauchy point and the subspace step for that one
1929
+ bound, as L-BFGS-B takes them), scipy's defaults otherwise. X w
1930
+ and X'c are dgemv where a CBLAS links. Same iterations and
1931
+ coefficients as scikit-learn to rounding.
1932
+
1933
+ Attributes after fit: coef_, intercept_, scale_, n_iter_, outliers_.
1934
+ """
1935
+
1936
+ def __init__(self, *, epsilon=1.35, alpha=1e-4, max_iter=100, tol=1e-5,
1937
+ fit_intercept=True):
1938
+ self.epsilon, self.alpha, self.max_iter, self.tol = epsilon, alpha, max_iter, tol
1939
+ self.fit_intercept = fit_intercept
1940
+
1941
+ def fit(self, X, y):
1942
+ X, y = fit_xy(self, X, y)
1943
+ n, p = X.shape
1944
+ coef = np.zeros(p + 2)
1945
+ coef[-1] = 1.0
1946
+ out = np.zeros(2)
1947
+ it = _gpu_or_cpu(n * p, getattr(self, "backend", None)).huber(
1948
+ X, y, coef, out, float(self.epsilon), float(self.alpha), int(self.max_iter),
1949
+ float(self.tol), 1 if self.fit_intercept else 0, n, p)
1950
+ self.coef_ = coef[:p].copy()
1951
+ self.intercept_ = float(coef[p])
1952
+ self.scale_ = float(coef[p + 1])
1953
+ self.n_iter_ = min(it, self.max_iter)
1954
+ self.outliers_ = np.abs(y - X @ self.coef_ - self.intercept_) > self.scale_ * self.epsilon
1955
+ return self
1956
+
1957
+ def predict(self, X):
1958
+ return pred_x(self, X) @ self.coef_ + self.intercept_
1959
+
1960
+
1961
+ class EMGaussianMixture(Tagged, DensityMixin, BaseEstimator):
1962
+ """The Gaussian mixture by EM with full covariances, scikit-learn's
1963
+ ``GaussianMixture(covariance_type="full")`` translated whole
1964
+ (``examples/kernel-only/gmm.scm``): its M step (means and
1965
+ covariances by products, the Cholesky factor, the precision factor
1966
+ L^-T), its E step (the log densities through X U - mu U, a product;
1967
+ log-sum-exp; the lower bound) and its stop on the bound. The start
1968
+ is scikit-learn's: ``init_params="kmeans"`` takes the labels of
1969
+ ``KMeans(n_init=1)`` (scikit-learn's, so it must be installed) drawn
1970
+ from ``RandomState(random_state)``, ``"random"`` uniform draws
1971
+ normalised; with the same ``random_state`` the fit is
1972
+ scikit-learn's to rounding. One initialisation (n_init=1).
1973
+
1974
+ Attributes after fit: weights_, means_, precisions_cholesky_,
1975
+ precisions_, covariances_, converged_, n_iter_, lower_bound_.
1976
+ """
1977
+
1978
+ def __init__(self, n_components=1, *, tol=1e-3, reg_covar=1e-6, max_iter=100,
1979
+ init_params="kmeans", random_state=None):
1980
+ self.n_components, self.tol, self.reg_covar = n_components, tol, reg_covar
1981
+ self.max_iter, self.init_params, self.random_state = max_iter, init_params, random_state
1982
+
1983
+ def fit(self, X, y=None):
1984
+ X = fit_x(self, X, ensure_min_samples=2)
1985
+ n, p = X.shape
1986
+ k = int(self.n_components)
1987
+ if k > n:
1988
+ raise ValueError(f"Expected n_samples >= n_components but got n_components = {k}, "
1989
+ f"n_samples = {n}")
1990
+ rs = np.random.RandomState(self.random_state)
1991
+ if self.init_params == "kmeans":
1992
+ try:
1993
+ from sklearn.cluster import KMeans
1994
+ except ImportError as e:
1995
+ raise ImportError("init_params='kmeans' runs scikit-learn's KMeans; "
1996
+ "install scikit-learn or pass init_params='random'") from e
1997
+ labels = KMeans(n_clusters=k, n_init=1, random_state=rs).fit(X).labels_
1998
+ resp = np.zeros((n, k))
1999
+ resp[np.arange(n), labels] = 1.0
2000
+ elif self.init_params == "random":
2001
+ resp = rs.uniform(size=(n, k))
2002
+ resp /= resp.sum(axis=1)[:, None]
2003
+ else:
2004
+ raise ValueError("init_params is 'kmeans' or 'random'")
2005
+ resp = np.ascontiguousarray(resp)
2006
+ self.weights_ = np.zeros(k)
2007
+ self.means_ = np.zeros((k, p))
2008
+ self.precisions_cholesky_ = np.zeros((k, p, p))
2009
+ labels = np.zeros(n)
2010
+ out = np.zeros(2)
2011
+ it = _gpu_or_cpu(n * p, getattr(self, "backend", None)).gmm(X, resp, self.weights_, self.means_,
2012
+ self.precisions_cholesky_, labels, out,
2013
+ int(self.max_iter), float(self.tol),
2014
+ float(self.reg_covar), n, p, k)
2015
+ self.n_iter_ = it
2016
+ self.lower_bound_ = float(out[0])
2017
+ self.converged_ = bool(out[1] == 1.0)
2018
+ self.precisions_ = np.einsum("kij,klj->kil", self.precisions_cholesky_,
2019
+ self.precisions_cholesky_)
2020
+ self.covariances_ = np.linalg.inv(self.precisions_)
2021
+ return self
2022
+
2023
+ def _weighted_log_prob(self, X):
2024
+ X = pred_x(self, X)
2025
+ n, p = X.shape
2026
+ out = np.empty((n, len(self.weights_)))
2027
+ for c, (mu, u) in enumerate(zip(self.means_, self.precisions_cholesky_)):
2028
+ y = X @ u - mu @ u
2029
+ out[:, c] = (-0.5 * (p * np.log(2 * np.pi) + (y * y).sum(axis=1))
2030
+ + np.log(np.diag(u)).sum() + np.log(self.weights_[c]))
2031
+ return out
2032
+
2033
+ def predict_proba(self, X):
2034
+ w = self._weighted_log_prob(X)
2035
+ w -= w.max(axis=1, keepdims=True)
2036
+ e = np.exp(w)
2037
+ return e / e.sum(axis=1, keepdims=True)
2038
+
2039
+ def predict(self, X):
2040
+ return np.argmax(self._weighted_log_prob(X), axis=1)
2041
+
2042
+ def fit_predict(self, X, y=None):
2043
+ return self.fit(X).predict(X)
2044
+
2045
+ def score_samples(self, X):
2046
+ """The log density of each row under the mixture."""
2047
+ w = self._weighted_log_prob(X)
2048
+ m = w.max(axis=1)
2049
+ return m + np.log(np.exp(w - m[:, None]).sum(axis=1))
2050
+
2051
+ def score(self, X, y=None):
2052
+ """The mean log-likelihood, as GaussianMixture.score."""
2053
+ return float(self.score_samples(X).mean())
2054
+
2055
+
2056
+ class CovPCA(Tagged, TransformerMixin, BaseEstimator):
2057
+ """PCA through the covariance's eigenvectors, scikit-learn's
2058
+ ``PCA(svd_solver="covariance_eigh")`` -- what its "auto" picks for a
2059
+ tall design (p <= 1000, n >= 10 p) -- translated whole
2060
+ (``examples/kernel-only/pca.scm``: X'X by dsyrk where a CBLAS links,
2061
+ the eigenproblem by sym-eigen.scm -- which the BLAS build hands to
2062
+ LAPACK's dsyevd, as numpy's eigh does, and the loop build runs as
2063
+ Householder reduction and QL iteration in the subset -- and
2064
+ svd_flip's signs). The BLAS build is level with scikit-learn on one
2065
+ core at 50,000 x 500 and with sklearnex on twenty; the loop build's
2066
+ eigensolver falls behind LAPACK's from about a hundred features.
2067
+
2068
+ Attributes after fit: components_, explained_variance_,
2069
+ explained_variance_ratio_, singular_values_, mean_, noise_variance_,
2070
+ n_components_.
2071
+ """
2072
+
2073
+ def __init__(self, n_components=None, *, backend=None):
2074
+ self.n_components, self.backend = n_components, backend
2075
+
2076
+ def fit(self, X, y=None):
2077
+ X = fit_x(self, X)
2078
+ n, p = X.shape
2079
+ if self.n_components is not None and not 0 <= int(self.n_components) <= min(n, p):
2080
+ raise ValueError(f"n_components={self.n_components!r} must be between 0 and "
2081
+ f"min(n_samples, n_features)={min(n, p)}")
2082
+ comps = np.zeros((p, p)); ev = np.zeros(p); sv = np.zeros(p); mean = np.zeros(p)
2083
+ # the device forms the covariance faster, but its build keeps
2084
+ # sym-eigen.scm's eigensolver; where the CPU build has LAPACK's
2085
+ # (gramops "blas") that is the faster whole, so the device is
2086
+ # taken by default only without it
2087
+ from ._libfind import gramops_backend
2088
+ dev = self.backend == "cublas" or (self.backend is None and gramops_backend() != "blas")
2089
+ lib = _accel(float(n) * p * p) if dev else None
2090
+ (lib or _gramops_kernels()).pca(X, comps, ev, sv, mean, n, p)
2091
+ k = min(n, p) if self.n_components is None else int(self.n_components)
2092
+ self.n_components_ = k
2093
+ self.mean_ = mean
2094
+ self.components_ = comps[:k].copy()
2095
+ self.explained_variance_ = ev[:k].copy()
2096
+ tot = ev.sum()
2097
+ self.explained_variance_ratio_ = ev[:k] / tot if tot > 0 else np.zeros(k)
2098
+ self.singular_values_ = sv[:k].copy()
2099
+ self.noise_variance_ = float(ev[k:].mean()) if k < min(n, p) else 0.0
2100
+ return self
2101
+
2102
+ def transform(self, X):
2103
+ return (pred_x(self, X) - self.mean_) @ self.components_.T
2104
+
2105
+ def fit_transform(self, X, y=None):
2106
+ return self.fit(X).transform(X)
2107
+
2108
+
2109
+ _KPCA_KERNELS = {"linear": 0, "rbf": 1, "poly": 2, "sigmoid": 3, "cosine": 4}
2110
+
2111
+
2112
+ def _psd_eigenvalues(vals):
2113
+ # scikit-learn's _check_psd_eigenvalues (double precision): refuse
2114
+ # a matrix far from PSD, zero the small negative and positive noise
2115
+ vals = np.array(vals, dtype=np.float64)
2116
+ top = vals.max() if vals.size else 0.0
2117
+ if top < 0:
2118
+ raise ValueError(f"All eigenvalues are negative (maximum is {top:g}). Either the "
2119
+ "matrix is not PSD, or there was an issue while computing the "
2120
+ "eigendecomposition of the matrix.")
2121
+ low = vals.min() if vals.size else 0.0
2122
+ if low < -1e-5 * top and low < -1e-10:
2123
+ raise ValueError(f"There are significant negative eigenvalues ({-low / top:g} of the "
2124
+ "maximum positive). Either the matrix is not PSD, or there was an "
2125
+ "issue while computing the eigendecomposition of the matrix.")
2126
+ vals[vals < 0] = 0.0
2127
+ vals[vals < 1e-12 * top] = 0.0
2128
+ return vals
2129
+
2130
+
2131
+ class GramKernelPCA(Tagged, TransformerMixin, BaseEstimator):
2132
+ """scikit-learn's ``KernelPCA``: the kernel matrix (linear, rbf,
2133
+ poly, sigmoid, cosine or precomputed), centred in feature space, its
2134
+ largest eigenpairs with svd_flip's signs (``examples/kernel-only/
2135
+ kernel-pca.scm``). Three routes to the eigenpairs:
2136
+
2137
+ - the linear kernel with n_components <= p < n: through Xc'Xc
2138
+ (p x p, X'X by dsyrk), O(n p^2) instead of the n x n matrix;
2139
+ - a few pairs of a large matrix (``eigen_solver="auto"``, where
2140
+ scikit-learn takes ARPACK, and ``"subspace"``): subspace iteration
2141
+ whose product is a dgemm, stopped when every residual
2142
+ |K u - lam u| is under ``tol`` (1e-12 for 0) of the largest value,
2143
+ the dense route when it does not converge in ``max_iter`` steps;
2144
+ - otherwise dense: LAPACK's dsyevr over the wanted index range, as
2145
+ scipy's eigh(subset_by_index) that scikit-learn calls.
2146
+
2147
+ No inverse transform. Attributes after fit: eigenvalues_,
2148
+ eigenvectors_, X_fit_, gamma_, n_iter_ (the subspace steps, 1 for a
2149
+ direct solve), n_features_in_.
2150
+ """
2151
+
2152
+ def __init__(self, n_components=None, *, kernel="linear", gamma=None, degree=3, coef0=1,
2153
+ eigen_solver="auto", tol=0, max_iter=None, remove_zero_eig=False,
2154
+ random_state=None):
2155
+ self.n_components, self.kernel, self.gamma = n_components, kernel, gamma
2156
+ self.degree, self.coef0, self.eigen_solver = degree, coef0, eigen_solver
2157
+ self.tol, self.max_iter, self.remove_zero_eig = tol, max_iter, remove_zero_eig
2158
+ self.random_state = random_state
2159
+
2160
+ def _kernel(self, A, B):
2161
+ k = _gramops_kernels()
2162
+ if B is None:
2163
+ K = np.zeros((A.shape[0], A.shape[0]))
2164
+ k.kpca_kernel_self(A, K, self._kind, self.gamma_, float(self.degree),
2165
+ float(self.coef0), A.shape[0], A.shape[1])
2166
+ else:
2167
+ K = np.zeros((A.shape[0], B.shape[0]))
2168
+ k.kpca_kernel(A, B, K, self._kind, self.gamma_, float(self.degree),
2169
+ float(self.coef0), A.shape[0], B.shape[0], A.shape[1])
2170
+ return K
2171
+
2172
+ def fit(self, X, y=None):
2173
+ X = fit_x(self, X)
2174
+ n, p = X.shape
2175
+ if self.kernel != "precomputed" and self.kernel not in _KPCA_KERNELS:
2176
+ raise ValueError(f"kernel={self.kernel!r}: expected one of "
2177
+ f"{sorted(_KPCA_KERNELS) + ['precomputed']}")
2178
+ if self.eigen_solver not in ("auto", "dense", "subspace"):
2179
+ raise ValueError(f"eigen_solver={self.eigen_solver!r}: expected 'auto', 'dense' "
2180
+ "or 'subspace'")
2181
+ if self.n_components is not None and int(self.n_components) < 1:
2182
+ raise ValueError(f"n_components={self.n_components!r} must be at least 1")
2183
+ if self.kernel == "precomputed" and n != p:
2184
+ raise ValueError(f"precomputed kernel is {n} x {p}; expected a square matrix")
2185
+ self._kind = _KPCA_KERNELS.get(self.kernel, -1)
2186
+ self.gamma_ = 1.0 / p if self.gamma is None else float(self.gamma)
2187
+ c = n if self.n_components is None else min(n, int(self.n_components))
2188
+ k = _gramops_kernels()
2189
+ vecs, vals = np.zeros((n, c)), np.zeros(c)
2190
+ m = np.zeros(n + 1)
2191
+ self.n_iter_ = 1
2192
+ if self.kernel == "linear" and c <= p < n:
2193
+ xm = X.mean(axis=0)
2194
+ G = _gram(X)
2195
+ nm2 = n * xm * xm
2196
+ if np.all(np.diagonal(G) - nm2 > 1e-4 * nm2):
2197
+ G -= np.outer(xm, n * xm)
2198
+ Xs, xs = X, xm
2199
+ else:
2200
+ Xs, xs = np.ascontiguousarray(X - xm), np.zeros(p)
2201
+ G = _gram(Xs)
2202
+ k.kpca_linear(Xs, xs, np.ascontiguousarray(G), vecs, vals, n, p, c)
2203
+ # KernelCenterer's statistics of K = X X', for transform
2204
+ m[:n] = X @ xm
2205
+ m[n] = float(xm @ xm)
2206
+ else:
2207
+ K = np.array(X, dtype=np.float64, order="C") if self.kernel == "precomputed" \
2208
+ else self._kernel(X, None)
2209
+ k.kpca_centre(K, m, n)
2210
+ # c + 25 columns: on an rbf kernel whose leading values fall
2211
+ # slowly (0.92 of the first by the fifth), 13 took 235 steps
2212
+ # to 1e-12 and 30 took 15
2213
+ b = min(n, max(2 * c, c + 25))
2214
+ # where scikit-learn's "auto" takes ARPACK; for 50 components
2215
+ # of 3,000 the 100 columns lost to dsyevr (6.2 s to 3.6 s)
2216
+ sub = self.eigen_solver == "subspace" or (
2217
+ self.eigen_solver == "auto" and n > 200 and c < 10)
2218
+ done = False
2219
+ if sub and b < n:
2220
+ rs = self.random_state
2221
+ rng = rs if isinstance(rs, np.random.RandomState) else \
2222
+ np.random.RandomState(0 if rs is None else int(rs))
2223
+ Q = rng.standard_normal((n, b))
2224
+ tol = float(self.tol) if float(self.tol) > 0 else 1e-12
2225
+ it = 1000 if self.max_iter is None else int(self.max_iter)
2226
+ steps = k.kpca_top_subspace(K, Q, vecs, vals, n, b, c, tol, it)
2227
+ done = steps >= 0
2228
+ if done:
2229
+ self.n_iter_ = steps
2230
+ if not done:
2231
+ k.kpca_top_dense(K, vecs, vals, n, c)
2232
+ vals = _psd_eigenvalues(vals)
2233
+ if self.remove_zero_eig or self.n_components is None:
2234
+ keep = vals > 0
2235
+ vecs, vals = vecs[:, keep], vals[keep]
2236
+ self.eigenvalues_ = vals
2237
+ self.eigenvectors_ = np.ascontiguousarray(vecs)
2238
+ self.X_fit_ = X
2239
+ self._m = m
2240
+ return self
2241
+
2242
+ def fit_transform(self, X, y=None):
2243
+ self.fit(X)
2244
+ return self.eigenvectors_ * np.sqrt(self.eigenvalues_)
2245
+
2246
+ def transform(self, X):
2247
+ X = pred_x(self, X)
2248
+ n = self.X_fit_.shape[0]
2249
+ if self.kernel == "precomputed":
2250
+ if X.shape[1] != n:
2251
+ raise ValueError(f"precomputed kernel has {X.shape[1]} columns; expected {n}")
2252
+ K = np.array(X, dtype=np.float64, order="C")
2253
+ else:
2254
+ K = self._kernel(X, self.X_fit_)
2255
+ _gramops_kernels().kpca_centre_new(K, self._m, K.shape[0], n)
2256
+ nz = self.eigenvalues_ > 0
2257
+ scaled = np.zeros_like(self.eigenvectors_)
2258
+ scaled[:, nz] = self.eigenvectors_[:, nz] / np.sqrt(self.eigenvalues_[nz])
2259
+ return K @ scaled
2260
+
2261
+
2262
+ class GramLDA(Tagged, ClassifierMixin, TransformerMixin, BaseEstimator):
2263
+ """scikit-learn's ``LinearDiscriminantAnalysis`` over the Gram matrix
2264
+ (``examples/kernel-only/lda.scm``): X'X by dsyrk and the class means
2265
+ in one pass, then the three solvers' own steps on p x p matrices --
2266
+ ``"svd"`` (the default) takes its first SVD, of the n x p
2267
+ standardised within-class design, as the eigenproblem of
2268
+ D^-1 Sw D^-1 (dsyevd); ``"lsqr"`` solves Sw W = M' by Cholesky;
2269
+ ``"eigen"`` is Sb v = lam Sw v (dsygvd). ``shrinkage`` takes None or a
2270
+ float (not "auto"); no ``covariance_estimator``.
2271
+
2272
+ Attributes after fit: classes_, coef_, intercept_, means_, priors_,
2273
+ covariance_ (lsqr, eigen, or store_covariance), scalings_ and
2274
+ explained_variance_ratio_ (svd, eigen), xbar_ (svd), n_features_in_.
2275
+ """
2276
+
2277
+ def __init__(self, solver="svd", shrinkage=None, priors=None, n_components=None,
2278
+ store_covariance=False, tol=1e-4):
2279
+ self.solver, self.shrinkage, self.priors = solver, shrinkage, priors
2280
+ self.n_components, self.store_covariance, self.tol = n_components, store_covariance, tol
2281
+
2282
+ @staticmethod
2283
+ def _shrunk(C, s):
2284
+ # shrunk_covariance: (1 - s) C + s tr(C) / p I
2285
+ if s is None:
2286
+ return C
2287
+ p = C.shape[0]
2288
+ out = (1.0 - s) * C
2289
+ out.flat[::p + 1] += s * np.trace(C) / p
2290
+ return out
2291
+
2292
+ def fit(self, X, y):
2293
+ if self.solver not in ("svd", "lsqr", "eigen"):
2294
+ raise ValueError(f"solver={self.solver!r}: expected 'svd', 'lsqr' or 'eigen'")
2295
+ s = self.shrinkage
2296
+ if s is not None and (isinstance(s, str) or not 0.0 <= float(s) <= 1.0):
2297
+ raise ValueError(f"shrinkage={s!r}: GramLDA takes None or a float in [0, 1]")
2298
+ if self.solver == "svd" and s is not None:
2299
+ raise NotImplementedError("shrinkage not supported with 'svd' solver.")
2300
+ s = None if s is None else float(s)
2301
+ X, y = fit_xy(self, X, y, classifier=True)
2302
+ n, p = X.shape
2303
+ if n < 2:
2304
+ raise ValueError(f"Found array with {n} sample(s) (shape={X.shape}) while a "
2305
+ "minimum of 2 is required.")
2306
+ self.classes_, yi = classes_of(y)
2307
+ K = len(self.classes_)
2308
+ if n == K:
2309
+ raise ValueError("The number of samples must be more than the number of classes.")
2310
+ if K < 2:
2311
+ raise ValueError("The number of classes has to be greater than one; got 1 class")
2312
+ k = _gramops_kernels()
2313
+ lab = np.ascontiguousarray(yi, dtype=np.int32)
2314
+ G, means, counts = np.zeros((p, p)), np.zeros((K, p)), np.zeros(K)
2315
+ k.lda_moments(X, lab, G, means, counts, n, p, K)
2316
+ if self.priors is None:
2317
+ priors = counts / n
2318
+ else:
2319
+ priors = np.asarray(self.priors, dtype=np.float64)
2320
+ if np.any(priors < 0):
2321
+ raise ValueError("priors must be non-negative")
2322
+ if abs(priors.sum() - 1.0) > 1e-5:
2323
+ warnings.warn("The priors do not sum to 1. Renormalizing", UserWarning)
2324
+ priors = priors / priors.sum()
2325
+ self.priors_, self.means_ = priors, means
2326
+ top = min(K - 1, p)
2327
+ if self.n_components is not None and int(self.n_components) > top:
2328
+ raise ValueError("n_components cannot be larger than min(n_features, n_classes - 1).")
2329
+ self._max_components = top if self.n_components is None else int(self.n_components)
2330
+ xx = np.diagonal(G).copy()
2331
+ k.lda_within(G, means, counts, n, p, K) # G is now Sw, empirical priors
2332
+ if not np.all(np.diagonal(G) > 1e-8 * xx / n):
2333
+ # a column whose class means dwarf its spread: the correction
2334
+ # cancelled its digits, so take the centred rows instead
2335
+ Xc = np.ascontiguousarray(X - means[yi])
2336
+ G = _gram(Xc) / n
2337
+ Sw_emp = G
2338
+
2339
+ def class_cov():
2340
+ # scikit-learn's _class_cov: sum_k prior_k (shrunk) C_k; with
2341
+ # the empirical priors that is the shrunk Sw, else per class
2342
+ if self.priors is None or np.allclose(priors, counts / n):
2343
+ return self._shrunk(Sw_emp, s)
2344
+ C = np.zeros((p, p))
2345
+ for c in range(K):
2346
+ Xk = np.ascontiguousarray(X[yi == c] - means[c])
2347
+ C += priors[c] * self._shrunk(_gram(Xk) / max(Xk.shape[0], 1), s)
2348
+ return C
2349
+
2350
+ if self.solver == "svd":
2351
+ if self.store_covariance:
2352
+ self.covariance_ = class_cov()
2353
+ self.xbar_ = priors @ means
2354
+ std, V, S = np.zeros(p), np.zeros((p, p)), np.zeros(p)
2355
+ k.lda_svd_first(np.ascontiguousarray(Sw_emp), std, V, S, p)
2356
+ rank = int(np.sum(S > self.tol))
2357
+ scal = (V[:, :rank] / std[:, None]) / S[:rank]
2358
+ fac = 1.0 if K == 1 else 1.0 / (K - 1)
2359
+ Z = (np.sqrt(n * priors * fac) * (means - self.xbar_).T).T @ scal
2360
+ _, S2, Vt2 = np.linalg.svd(Z, full_matrices=False)
2361
+ self.explained_variance_ratio_ = (S2 ** 2 / np.sum(S2 ** 2))[:self._max_components] \
2362
+ if self._max_components else np.empty((0,))
2363
+ rank2 = int(np.sum(S2 > self.tol * S2[0]))
2364
+ self.scalings_ = scal @ Vt2.T[:, :rank2]
2365
+ coef = (means - self.xbar_) @ self.scalings_
2366
+ self.intercept_ = -0.5 * np.sum(coef ** 2, axis=1) + np.log(priors)
2367
+ self.coef_ = coef @ self.scalings_.T
2368
+ self.intercept_ -= self.xbar_ @ self.coef_.T
2369
+ else:
2370
+ Sw = class_cov()
2371
+ self.covariance_ = Sw
2372
+ if self.solver == "lsqr":
2373
+ coef = _ridge_solve(Sw, np.ascontiguousarray(means.T), 0.0)
2374
+ if coef is None:
2375
+ coef = np.linalg.lstsq(Sw, means.T, rcond=None)[0]
2376
+ self.coef_ = coef.T
2377
+ self.intercept_ = -0.5 * np.diag(means @ self.coef_.T) + np.log(priors)
2378
+ else:
2379
+ xbar = counts @ means / n
2380
+ St = self._shrunk(Sw_emp + (means - xbar).T @ ((counts / n)[:, None] * (means - xbar)), s)
2381
+ V, ev = np.zeros((p, p)), np.zeros(p)
2382
+ k.lda_eigen(np.ascontiguousarray(St - Sw), np.ascontiguousarray(Sw.copy()), V, ev, p)
2383
+ self.explained_variance_ratio_ = (ev / np.sum(ev))[:self._max_components]
2384
+ self.scalings_ = V
2385
+ self.coef_ = means @ V @ V.T
2386
+ self.intercept_ = -0.5 * np.diag(means @ self.coef_.T) + np.log(priors)
2387
+ if K == 2:
2388
+ self.coef_ = (self.coef_[1, :] - self.coef_[0, :]).reshape(1, -1)
2389
+ self.intercept_ = np.asarray(self.intercept_[1] - self.intercept_[0]).reshape(1)
2390
+ return self
2391
+
2392
+ def decision_function(self, X):
2393
+ check_is_fitted(self)
2394
+ d = pred_x(self, X) @ self.coef_.T + self.intercept_
2395
+ return d.ravel() if d.shape[1] == 1 else d
2396
+
2397
+ def predict(self, X):
2398
+ d = self.decision_function(X)
2399
+ idx = (d > 0).astype(int) if d.ndim == 1 else d.argmax(axis=1)
2400
+ return self.classes_[idx]
2401
+
2402
+ def predict_proba(self, X):
2403
+ d = self.decision_function(X)
2404
+ if d.ndim == 1:
2405
+ p1 = 1.0 / (1.0 + np.exp(-d))
2406
+ return np.stack([1 - p1, p1], axis=1)
2407
+ e = np.exp(d - d.max(axis=1, keepdims=True))
2408
+ return e / e.sum(axis=1, keepdims=True)
2409
+
2410
+ def predict_log_proba(self, X):
2411
+ P = self.predict_proba(X)
2412
+ with np.errstate(divide="ignore"):
2413
+ return np.log(P)
2414
+
2415
+ def transform(self, X):
2416
+ if self.solver == "lsqr":
2417
+ raise NotImplementedError(
2418
+ "transform not implemented for 'lsqr' solver (use 'svd' or 'eigen').")
2419
+ check_is_fitted(self)
2420
+ X = pred_x(self, X)
2421
+ Xn = (X - self.xbar_) @ self.scalings_ if self.solver == "svd" else X @ self.scalings_
2422
+ return Xn[:, :self._max_components]
2423
+
2424
+
2425
+ class GramTruncatedSVD(Tagged, TransformerMixin, BaseEstimator):
2426
+ """scikit-learn's ``TruncatedSVD`` on a dense X, through the Gram
2427
+ matrix (``examples/kernel-only/truncated-svd.scm``): the leading
2428
+ ``n_components`` pairs of X'X (n >= p; X'X by dsyrk) or of X X'
2429
+ (wide) by dsyevr over the wanted index range, the transformed data
2430
+ by one dgemm, svd_flip's signs (u_based_decision=False).
2431
+
2432
+ The result is the exact truncated SVD, what ``algorithm="arpack"``
2433
+ computes; scikit-learn's default randomized solver approximates it
2434
+ (5 power iterations), so against that one the components agree to
2435
+ its approximation error. ``algorithm``, ``n_iter``,
2436
+ ``n_oversamples``, ``power_iteration_normalizer``, ``random_state``
2437
+ and ``tol`` are taken for the interface and do not change the
2438
+ answer.
2439
+
2440
+ Attributes after fit: components_, explained_variance_,
2441
+ explained_variance_ratio_, singular_values_, n_features_in_.
2442
+ """
2443
+
2444
+ def __init__(self, n_components=2, *, algorithm="randomized", n_iter=5, n_oversamples=10,
2445
+ power_iteration_normalizer="auto", random_state=None, tol=0.0):
2446
+ self.n_components, self.algorithm, self.n_iter = n_components, algorithm, n_iter
2447
+ self.n_oversamples = n_oversamples
2448
+ self.power_iteration_normalizer = power_iteration_normalizer
2449
+ self.random_state, self.tol = random_state, tol
2450
+
2451
+ def fit(self, X, y=None):
2452
+ self.fit_transform(X)
2453
+ return self
2454
+
2455
+ def fit_transform(self, X, y=None):
2456
+ if self.algorithm not in ("randomized", "arpack"):
2457
+ raise ValueError(f"algorithm={self.algorithm!r}: expected 'randomized' or 'arpack'")
2458
+ X = fit_x(self, X, ensure_min_features=2)
2459
+ n, p = X.shape
2460
+ c = int(self.n_components)
2461
+ if not 1 <= c <= min(n, p):
2462
+ raise ValueError(f"n_components({c}) must be between 1 and min(n_samples, "
2463
+ f"n_features)={min(n, p)}.")
2464
+ k = _gramops_kernels()
2465
+ V, s, Z = np.zeros((p, c)), np.zeros(c), np.zeros((n, c))
2466
+ (k.tsvd_tall if n >= p else k.tsvd_wide)(X, V, s, Z, n, p, c)
2467
+ self.components_ = np.ascontiguousarray(V.T)
2468
+ self.singular_values_ = s
2469
+ self.explained_variance_ = np.var(Z, axis=0)
2470
+ full = np.var(X, axis=0).sum()
2471
+ self.explained_variance_ratio_ = self.explained_variance_ / full if full > 0 \
2472
+ else np.zeros(c)
2473
+ return Z
2474
+
2475
+ def transform(self, X):
2476
+ check_is_fitted(self)
2477
+ return pred_x(self, X) @ self.components_.T
2478
+
2479
+ def inverse_transform(self, X):
2480
+ check_is_fitted(self)
2481
+ return np.asarray(X) @ self.components_
2482
+
2483
+
2484
+ class ParallelFastICA(Tagged, TransformerMixin, BaseEstimator):
2485
+ """FastICA, scikit-learn's ``FastICA`` at its defaults translated
2486
+ whole (``examples/kernel-only/fastica.scm``: algorithm="parallel",
2487
+ fun="logcosh", whiten="unit-variance", whiten_solver="svd" -- the
2488
+ singular vectors of X' taken from the eigenvectors of X'X, signed as
2489
+ scikit-learn signs them). The start is ``RandomState(random_state)
2490
+ .normal(size=(k, k))``, scikit-learn's draw, unless ``w_init`` is
2491
+ given; with the same start the fit is scikit-learn's to rounding.
2492
+
2493
+ Attributes after fit: components_, mixing_, mean_, whitening_, n_iter_.
2494
+ """
2495
+
2496
+ def __init__(self, n_components=None, *, max_iter=200, tol=1e-4, w_init=None,
2497
+ random_state=None):
2498
+ self.n_components, self.max_iter, self.tol = n_components, max_iter, tol
2499
+ self.w_init, self.random_state = w_init, random_state
2500
+
2501
+ def fit(self, X, y=None):
2502
+ X = fit_x(self, X, ensure_min_samples=2)
2503
+ n, p = X.shape
2504
+ k = min(n, p) if self.n_components is None else min(int(self.n_components), n, p)
2505
+ if self.w_init is None:
2506
+ w0 = np.random.RandomState(self.random_state).normal(size=(k, k))
2507
+ else:
2508
+ w0 = np.asarray(self.w_init, dtype=np.float64)
2509
+ w0 = np.ascontiguousarray(w0)
2510
+ self.components_ = np.zeros((k, p)); self.whitening_ = np.zeros((k, p))
2511
+ self._unmixing = np.zeros((k, k)); self.mean_ = np.zeros(p)
2512
+ self.n_iter_ = _gpu_or_cpu(n * p, getattr(self, "backend", None)).fastica(
2513
+ X, w0, self.components_, self.whitening_, self._unmixing, self.mean_,
2514
+ int(self.max_iter), float(self.tol), n, p, k)
2515
+ self.mixing_ = np.linalg.pinv(self.components_)
2516
+ return self
2517
+
2518
+ def transform(self, X):
2519
+ return (pred_x(self, X) - self.mean_) @ self.components_.T
2520
+
2521
+ def fit_transform(self, X, y=None):
2522
+ return self.fit(X).transform(X)
2523
+
2524
+
2525
+ class CovARDRegression(Tagged, RegressorMixin, BaseEstimator):
2526
+ """Automatic relevance determination, scikit-learn's ``ARDRegression``
2527
+ for n >= p translated whole (``examples/kernel-only/ard.scm``): each
2528
+ iteration sigma = pinvh(diag(lambda) + alpha G) over the kept
2529
+ features (the eigenproblem in the subset, scipy's cutoff), then
2530
+ coef, gamma, lambda, alpha and the pruning at ``threshold_lambda``,
2531
+ stopping on the coefficients' summed change. ``hoist=True`` (the
2532
+ default) takes the kept features' Gram from X'X formed once;
2533
+ ``hoist=False`` forms it from X every iteration, as scikit-learn
2534
+ does. Same iterations and coefficients to rounding either way.
2535
+
2536
+ Attributes after fit: coef_, intercept_, alpha_, lambda_, sigma_,
2537
+ n_iter_.
2538
+ """
2539
+
2540
+ def __init__(self, *, max_iter=300, tol=1e-3, alpha_1=1e-6, alpha_2=1e-6,
2541
+ lambda_1=1e-6, lambda_2=1e-6, threshold_lambda=1e4, fit_intercept=True,
2542
+ hoist=True):
2543
+ self.max_iter, self.tol = max_iter, tol
2544
+ self.alpha_1, self.alpha_2, self.lambda_1, self.lambda_2 = alpha_1, alpha_2, lambda_1, lambda_2
2545
+ self.threshold_lambda, self.fit_intercept, self.hoist = threshold_lambda, fit_intercept, hoist
2546
+
2547
+ def fit(self, X, y):
2548
+ X, y = fit_xy(self, X, y)
2549
+ Xc, yc, xm, ym = _center(X, y, self.fit_intercept)
2550
+ n, p = Xc.shape
2551
+ if n < p:
2552
+ raise ValueError(f"CovARDRegression is the n >= p route (_update_sigma); got "
2553
+ f"n_samples = {n} for {p} features")
2554
+ self.coef_ = np.zeros(p)
2555
+ self.lambda_ = np.zeros(p)
2556
+ sigma = np.zeros(p * p)
2557
+ out = np.zeros(2)
2558
+ self.n_iter_ = _gramops_kernels().ard(
2559
+ Xc, np.ascontiguousarray(yc), self.coef_, sigma, self.lambda_, out,
2560
+ int(self.max_iter), float(self.tol), float(self.alpha_1), float(self.alpha_2),
2561
+ float(self.lambda_1), float(self.lambda_2), float(self.threshold_lambda),
2562
+ 1 if self.hoist else 0, n, p)
2563
+ k = int(out[1])
2564
+ self.alpha_ = float(out[0])
2565
+ self.sigma_ = sigma[:k * k].reshape(k, k).copy()
2566
+ self.intercept_ = ym - float(xm @ self.coef_)
2567
+ return self
2568
+
2569
+ def predict(self, X):
2570
+ return pred_x(self, X) @ self.coef_ + self.intercept_
2571
+
2572
+
2573
+ def _gpu_or_cpu(size, backend=None):
2574
+ """The --cuda translation (kernels and device-resident cuBLAS) when it
2575
+ was built, a device answers, the backend allows it and the problem has
2576
+ at least a million entries; the CPU library otherwise."""
2577
+ import os
2578
+ from ._libfind import gpu_built
2579
+ if (backend in (None, "cuda") and size >= 1e6 and not os.environ.get("SCM2CPP_NO_DEVICE")
2580
+ and gpu_built() and cuda_available()):
2581
+ import importlib
2582
+ return importlib.import_module(f"{__name__}._generated._loader_gpu_cuda")
2583
+ return _gramops_kernels()
2584
+
2585
+
2586
+ def _accel(work):
2587
+ """The --cublas build of the selection and Gram products, when it was
2588
+ built, a device answers, and WORK (flops) is large enough to pay for
2589
+ the transfer; else None."""
2590
+ import os
2591
+ from ._libfind import accel_built
2592
+ if (work < 2e9 or os.environ.get("SCM2CPP_NO_DEVICE") or not accel_built()
2593
+ or not cuda_available()):
2594
+ return None
2595
+ import importlib
2596
+ return importlib.import_module(f"{__name__}._generated._loader_accel_cublas")
2597
+
2598
+
2599
+ # Products under this many multiply-adds go to numpy: no BLAS threads
2600
+ # them, and a ctypes call to the package's library costs 10 us more than
2601
+ # numpy's (a 200 x 10 GramRidge took twice as long through it)
2602
+ _SMALL = 1e6
2603
+
2604
+
2605
+ def _gram(Xc, backend=None):
2606
+ """X'X: gram.scm's gram-xx where the BLAS build is here (one dsyrk on
2607
+ every core, half the flops of numpy's X.T @ X, which is a dgemm), on
2608
+ the device for a large product when one answers, else numpy. With
2609
+ the BLAS build the device is taken by default only from p = 4000:
2610
+ the product reads X once, so its copy to the device is most of the
2611
+ device's time, and twenty cores' dsyrk was faster up to p = 2000
2612
+ (200,000 x 200: 0.044 s against 0.096 s)."""
2613
+ from ._libfind import gramops_backend
2614
+ n, p = Xc.shape
2615
+ if backend is None and float(n) * p * p < _SMALL:
2616
+ return np.ascontiguousarray(Xc.T @ Xc)
2617
+ blas = gramops_backend() == "blas"
2618
+ dev = backend == "cublas" or (backend is None and (not blas or p >= 4000))
2619
+ lib = _accel(float(n) * p * p) if dev else None
2620
+ if lib is None:
2621
+ if not blas:
2622
+ return np.ascontiguousarray(Xc.T @ Xc)
2623
+ lib = _gramops_kernels()
2624
+ G = np.zeros((p, p))
2625
+ lib.gram_xx(np.ascontiguousarray(Xc), G, n, p)
2626
+ return G
2627
+
2628
+
2629
+ def _gram_xy(Xc, y):
2630
+ """X'y (y one target or several) by gram.scm's gram-xy where the BLAS
2631
+ build is here, so that the Gram-route fits run on one BLAS: numpy's
2632
+ OpenBLAS keeps its threads spinning after a call, and the next dsyrk
2633
+ of the other library then shared the cores with them (CovRidge at
2634
+ 200,000 x 200 took 0.14 s for a 0.044 s product)."""
2635
+ from ._libfind import gramops_backend
2636
+ n, p = Xc.shape
2637
+ q = 1 if y.ndim == 1 else y.shape[1]
2638
+ if gramops_backend() != "blas" or float(n) * p * q < _SMALL:
2639
+ return Xc.T @ y
2640
+ c = np.zeros((p, q))
2641
+ _gramops_kernels().gram_xy(np.ascontiguousarray(Xc), np.ascontiguousarray(y), c, n, p, q)
2642
+ return c.ravel() if y.ndim == 1 else c
2643
+
2644
+
2645
+ def _ridge_solve(G, c, alpha):
2646
+ """(G + alpha I) b = c by gram.scm's gram-ridge-solve (LAPACK's dposv in the
2647
+ BLAS build), c one column or several; None when the factor breaks
2648
+ down (alpha 0 on a singular G), for the caller's least squares. In
2649
+ the package's library rather than numpy's for the reason _gram_xy
2650
+ gives: numpy's solve between two dsyrk calls cost the next one half
2651
+ its speed. (Until 0.17 this was rfe.scm's Cholesky in loops, 3.7x
2652
+ numpy's solve at p = 200.)"""
2653
+ p = G.shape[0]
2654
+ cols = np.array(c.reshape(p, -1), dtype=np.float64, order="C")
2655
+ if _gramops_kernels().gram_ridge_solve(np.ascontiguousarray(G, dtype=np.float64), cols,
2656
+ float(alpha), p, cols.shape[1]) != 0:
2657
+ return None
2658
+ return cols.ravel() if c.ndim == 1 else cols
2659
+
2660
+
2661
+ def _cpu_select_lib():
2662
+ return _gramops_kernels()
2663
+
2664
+
2665
+ def f_regression(X, y, *, center=True, backend=None):
2666
+ """scikit-learn's ``f_regression`` translated (``fscore.scm``): F and
2667
+ p-values of each feature against y. ``y`` may also be n x q, the q
2668
+ targets in one call (one product X'Y, on the device when one answers
2669
+ and the product is large) -- what a loop of scikit-learn's calls
2670
+ gives, column by column. Infinite F (perfect correlation) becomes
2671
+ the largest double and NaN F zero with p-value 1, as
2672
+ force_finite=True has it. p-values need scipy (else None)."""
2673
+ X = np.ascontiguousarray(X, dtype=np.float64)
2674
+ Y = np.asarray(y, dtype=np.float64)
2675
+ one = Y.ndim == 1
2676
+ Y = np.ascontiguousarray(Y.reshape(len(Y), -1))
2677
+ n, p = X.shape
2678
+ q = Y.shape[1]
2679
+ lib = _accel(float(n) * p * q) if backend in (None, "cublas") else None
2680
+ if lib is None:
2681
+ lib = _cpu_select_lib()
2682
+ r = np.zeros((p, q))
2683
+ F = np.zeros((p, q))
2684
+ with np.errstate(divide="ignore", invalid="ignore"):
2685
+ lib.f_regression(X, Y, r, F, 1 if center else 0, n, p, q)
2686
+ dof = n - (2 if center else 1)
2687
+ try:
2688
+ from scipy import stats
2689
+ pv = stats.f.sf(F, 1, dof)
2690
+ except ImportError:
2691
+ pv = None
2692
+ inf = np.isinf(F)
2693
+ F[inf] = np.finfo(np.float64).max
2694
+ nan = np.isnan(F)
2695
+ F[nan] = 0.0
2696
+ if pv is not None:
2697
+ pv[nan] = 1.0
2698
+ if one:
2699
+ return F[:, 0], (None if pv is None else pv[:, 0])
2700
+ return F, pv
2701
+
2702
+
2703
+ def f_classif(X, y):
2704
+ """scikit-learn's ``f_classif`` translated (``fscore.scm``: f_oneway's
2705
+ sums of squares, the class sums one product). p-values need scipy."""
2706
+ X = np.ascontiguousarray(X, dtype=np.float64)
2707
+ classes, lab = np.unique(np.asarray(y), return_inverse=True)
2708
+ n, p = X.shape
2709
+ k = len(classes)
2710
+ F = np.zeros(p)
2711
+ with np.errstate(divide="ignore", invalid="ignore"):
2712
+ _cpu_select_lib().f_classif(X, lab.astype(np.int32), F, n, p, k)
2713
+ try:
2714
+ from scipy import special
2715
+ pv = special.fdtrc(k - 1, n - k, F)
2716
+ except ImportError:
2717
+ pv = None
2718
+ return F, pv
2719
+
2720
+
2721
+ def _clean_nans(scores):
2722
+ scores = np.array(scores, dtype=np.float64, copy=True)
2723
+ scores[np.isnan(scores)] = np.finfo(np.float64).min
2724
+ return scores
2725
+
2726
+
2727
+ class _SelectBase(Tagged, TransformerMixin, BaseEstimator):
2728
+ def fit(self, X, y):
2729
+ X, y = fit_xy(self, X, y, y_numeric=False)
2730
+ sf = self.score_func
2731
+ fn = {"f_classif": f_classif, "f_regression": f_regression}.get(sf, sf)
2732
+ self.scores_, self.pvalues_ = fn(X, y)
2733
+ return self
2734
+
2735
+ def transform(self, X):
2736
+ X = pred_x(self, X)
2737
+ return X[:, self.get_support()]
2738
+
2739
+ def fit_transform(self, X, y):
2740
+ return self.fit(X, y).transform(X)
2741
+
2742
+
2743
+ class SelectKBest(_SelectBase):
2744
+ """scikit-learn's ``SelectKBest`` over the translated F statistics
2745
+ (``score_func`` "f_classif", "f_regression", or one of this module's
2746
+ functions): the k best scores by a stable sort, NaN scores last.
2747
+
2748
+ Attributes after fit: scores_, pvalues_.
2749
+ """
2750
+
2751
+ def __init__(self, score_func="f_classif", *, k=10):
2752
+ self.score_func, self.k = score_func, k
2753
+
2754
+ def get_support(self):
2755
+ check_is_fitted(self)
2756
+ if self.k == "all":
2757
+ return np.ones(len(self.scores_), dtype=bool)
2758
+ mask = np.zeros(len(self.scores_), dtype=bool)
2759
+ if self.k == 0:
2760
+ return mask
2761
+ mask[np.argsort(_clean_nans(self.scores_), kind="mergesort")[-self.k:]] = True
2762
+ return mask
2763
+
2764
+
2765
+ class SelectPercentile(_SelectBase):
2766
+ """scikit-learn's ``SelectPercentile`` over the translated F
2767
+ statistics: scores above the (100 - percentile)th percentile, ties
2768
+ at it kept in order up to the percentile's count.
2769
+
2770
+ Attributes after fit: scores_, pvalues_.
2771
+ """
2772
+
2773
+ def __init__(self, score_func="f_classif", *, percentile=10):
2774
+ self.score_func, self.percentile = score_func, percentile
2775
+
2776
+ def get_support(self):
2777
+ check_is_fitted(self)
2778
+ if self.percentile == 100:
2779
+ return np.ones(len(self.scores_), dtype=bool)
2780
+ if self.percentile == 0:
2781
+ return np.zeros(len(self.scores_), dtype=bool)
2782
+ scores = _clean_nans(self.scores_)
2783
+ threshold = np.percentile(scores, 100 - self.percentile)
2784
+ mask = scores > threshold
2785
+ ties = np.where(scores == threshold)[0]
2786
+ if len(ties):
2787
+ max_feats = int(len(scores) * self.percentile / 100)
2788
+ mask[ties[: max_feats - mask.sum()]] = True
2789
+ return mask
2790
+
2791
+
2792
+ class GramRFE(Tagged, RegressorMixin, TransformerMixin, BaseEstimator):
2793
+ """scikit-learn's ``RFE`` around ``Ridge(alpha)`` (``alpha=0``:
2794
+ ``LinearRegression()``) translated whole (``rfe.scm``): the centred
2795
+ Gram formed once (on the device for a large one when one answers),
2796
+ each round's fit from its Cholesky factor with the dropped features
2797
+ taken out by plane rotations, the features ranked by coef^2 in a
2798
+ stable order. scikit-learn refits from X every round; the ranking
2799
+ and the final coefficients are the same to rounding.
2800
+
2801
+ Attributes after fit: support_, ranking_, n_features_, coef_,
2802
+ intercept_.
2803
+ """
2804
+
2805
+ def __init__(self, alpha=1.0, *, n_features_to_select=None, step=1, backend=None):
2806
+ self.alpha, self.n_features_to_select, self.step = alpha, n_features_to_select, step
2807
+ self.backend = backend
2808
+
2809
+ def fit(self, X, y):
2810
+ X, y = fit_xy(self, X, y)
2811
+ n, p = X.shape
2812
+ k = self.n_features_to_select
2813
+ nsel = p // 2 if k is None else (int(k) if isinstance(k, (int, np.integer)) else int(p * k))
2814
+ step = int(max(1, self.step * p)) if 0.0 < self.step < 1.0 else int(self.step)
2815
+ lib = _accel(float(n) * p * p) if self.backend in (None, "cublas") else None
2816
+ if lib is None:
2817
+ lib = _cpu_select_lib()
2818
+ coef = np.zeros(p)
2819
+ support = np.zeros(p, dtype=np.int32)
2820
+ ranking = np.zeros(p, dtype=np.int32)
2821
+ lib.rfe(X, y, coef, support, ranking, float(self.alpha), nsel, step, n, p)
2822
+ self.support_ = support.astype(bool)
2823
+ self.ranking_ = ranking.astype(int)
2824
+ self.n_features_ = int(self.support_.sum())
2825
+ self.coef_ = coef[self.support_].copy()
2826
+ self.intercept_ = float(y.mean() - X.mean(axis=0)[self.support_] @ self.coef_)
2827
+ return self
2828
+
2829
+ def transform(self, X):
2830
+ return pred_x(self, X)[:, self.support_]
2831
+
2832
+ def predict(self, X):
2833
+ return self.transform(X) @ self.coef_ + self.intercept_
2834
+
2835
+ def fit_transform(self, X, y):
2836
+ return self.fit(X, y).transform(X)
2837
+
2838
+
2839
+ class _GramLarsBase(Tagged, RegressorMixin, BaseEstimator):
2840
+ def _fit_lars(self, X, y, method, alpha, max_iter):
2841
+ X, y = fit_xy(self, X, y)
2842
+ Xc, yc, xm, ym = _center(X, y, self.fit_intercept)
2843
+ n, p = Xc.shape
2844
+ G = _gram(Xc)
2845
+ cov = Xc.T @ yc
2846
+ self.coef_ = np.zeros(p)
2847
+ self.n_iter_ = _classifier_kernels().lars(
2848
+ G, cov, self.coef_, np.zeros(1), np.zeros(1), 0, float(n), float(alpha), method,
2849
+ int(max_iter), float(np.finfo(np.float64).eps), p)
2850
+ self.intercept_ = ym - float(xm @ self.coef_)
2851
+ return self
2852
+
2853
+ def predict(self, X):
2854
+ return pred_x(self, X) @ self.coef_ + self.intercept_
2855
+
2856
+
2857
+ class GramLars(_GramLarsBase):
2858
+ """Least angle regression, scikit-learn's _lars_path_solver with a
2859
+ Gram translated (``examples/kernel-only/lars.scm``, method "lar").
2860
+ ``Lars`` takes the Gram route for n > p (precompute="auto"); here it
2861
+ is taken always, the Gram X'X formed in numpy. ``n_nonzero_coefs``
2862
+ defaults to 500, as there.
2863
+
2864
+ Attributes after fit: coef_, intercept_, n_iter_.
2865
+ """
2866
+
2867
+ def __init__(self, *, n_nonzero_coefs=500, fit_intercept=True):
2868
+ self.n_nonzero_coefs, self.fit_intercept = n_nonzero_coefs, fit_intercept
2869
+
2870
+ def fit(self, X, y):
2871
+ return self._fit_lars(X, y, 0, 0.0, int(self.n_nonzero_coefs))
2872
+
2873
+
2874
+ class GramLassoLars(_GramLarsBase):
2875
+ """The lasso by least angle regression, scikit-learn's
2876
+ _lars_path_solver with a Gram translated (method "lasso": variables
2877
+ leave the active set when their coefficient crosses zero, the
2878
+ Cholesky factor downdated by Givens rotations). ``LassoLars`` takes
2879
+ the Gram route for n > p; here it is taken always. ``alpha``
2880
+ defaults to 1.0 and ``max_iter`` to 500, as there.
2881
+
2882
+ Attributes after fit: coef_, intercept_, n_iter_.
2883
+ """
2884
+
2885
+ def __init__(self, alpha=1.0, *, max_iter=500, fit_intercept=True):
2886
+ self.alpha, self.max_iter, self.fit_intercept = alpha, max_iter, fit_intercept
2887
+
2888
+ def fit(self, X, y):
2889
+ return self._fit_lars(X, y, 1, float(self.alpha), int(self.max_iter))
2890
+
2891
+
2892
+ class _GramLarsCVBase(Tagged, RegressorMixin, BaseEstimator):
2893
+ """LarsCV / LassoLarsCV: KFold(cv) without shuffling; on each fold
2894
+ the whole path by the translated LARS over the training fold's Gram
2895
+ (centred on the training means), the test residuals interpolated
2896
+ onto the union of the folds' alphas as scikit-learn interpolates
2897
+ them (the path's coefficients interpolated, the residuals being
2898
+ affine in them, the mean square from the test fold's Gram); the
2899
+ alpha of the least mean square error, refitted on all the data."""
2900
+
2901
+ def __init__(self, *, fit_intercept=True, max_iter=500, cv=5, max_n_alphas=1000):
2902
+ self.fit_intercept, self.max_iter, self.cv = fit_intercept, max_iter, cv
2903
+ self.max_n_alphas = max_n_alphas
2904
+
2905
+ def _lars(self, Xc, yc, alpha_min, rec):
2906
+ n, p = Xc.shape
2907
+ G = _gram(Xc)
2908
+ cov = Xc.T @ yc
2909
+ coef = np.zeros(p)
2910
+ mi = int(self.max_iter)
2911
+ path = np.zeros((mi + 1, p)) if rec else np.zeros(1)
2912
+ alphas = np.zeros(mi + 1) if rec else np.zeros(1)
2913
+ it = _classifier_kernels().lars(G, cov, coef, path, alphas, 1 if rec else 0, float(n),
2914
+ float(alpha_min), self._method, mi,
2915
+ float(np.finfo(np.float64).eps), p)
2916
+ return it, coef, (path[:it + 1] if rec else None), (alphas[:it + 1] if rec else None)
2917
+
2918
+ def fit(self, X, y):
2919
+ X, y = fit_xy(self, X, y)
2920
+ n, p = X.shape
2921
+ if n < int(self.cv):
2922
+ raise ValueError(f"Cannot have number of splits n_splits={self.cv} greater than "
2923
+ f"the number of samples: n_samples={n}.")
2924
+ folds = []
2925
+ for train, test in _kfold(n, int(self.cv)):
2926
+ Xtr, ytr, Xte, yte = X[train], y[train], X[test], y[test]
2927
+ if self.fit_intercept:
2928
+ xm, ym = Xtr.mean(axis=0), ytr.mean()
2929
+ Xtr, Xte, ytr, yte = Xtr - xm, Xte - xm, ytr - ym, yte - ym
2930
+ _, _, path, alphas = self._lars(np.ascontiguousarray(Xtr), ytr, 0.0, True)
2931
+ folds.append((alphas, path, _gram(Xte), Xte.T @ yte, float(yte @ yte), len(test)))
2932
+ all_alphas = np.unique(np.concatenate([f[0] for f in folds]))
2933
+ stride = int(max(1, int(len(all_alphas) / float(self.max_n_alphas))))
2934
+ all_alphas = all_alphas[::stride]
2935
+ mse = np.empty((len(all_alphas), len(folds)))
2936
+ for j, (alphas, path, Gt, cyt, yy, nt) in enumerate(folds):
2937
+ a, c = alphas[::-1], path[::-1]
2938
+ if a[0] != 0:
2939
+ a, c = np.r_[0, a], np.r_[c[:1], c]
2940
+ if a[-1] != all_alphas[-1]:
2941
+ a, c = np.r_[a, all_alphas[-1]], np.r_[c, c[-1:]]
2942
+ ci = np.column_stack([np.interp(all_alphas, a, c[:, q]) for q in range(p)])
2943
+ mse[:, j] = (((ci @ Gt) * ci).sum(axis=1) - 2.0 * ci @ cyt + yy) / nt
2944
+ mask = np.all(np.isfinite(mse), axis=-1)
2945
+ all_alphas, mse = all_alphas[mask], mse[mask]
2946
+ self.alpha_ = float(all_alphas[int(np.argmin(mse.mean(axis=-1)))])
2947
+ self.cv_alphas_, self.mse_path_ = all_alphas, mse
2948
+ Xc, yc, xm, ym = _center(X, y, self.fit_intercept)
2949
+ self.n_iter_, self.coef_, _, _ = self._lars(Xc, yc, self.alpha_, False)
2950
+ self.intercept_ = ym - float(xm @ self.coef_)
2951
+ return self
2952
+
2953
+ def predict(self, X):
2954
+ return pred_x(self, X) @ self.coef_ + self.intercept_
2955
+
2956
+
2957
+ class GramLarsCV(_GramLarsCVBase):
2958
+ """scikit-learn's ``LarsCV`` (method "lar") over the translated LARS;
2959
+ see _GramLarsCVBase. Attributes after fit: alpha_, coef_, intercept_,
2960
+ cv_alphas_, mse_path_, n_iter_."""
2961
+ _method = 0
2962
+
2963
+
2964
+ class GramLassoLarsCV(_GramLarsCVBase):
2965
+ """scikit-learn's ``LassoLarsCV`` (method "lasso") over the translated
2966
+ LARS; see _GramLarsCVBase. Attributes after fit: alpha_, coef_,
2967
+ intercept_, cv_alphas_, mse_path_, n_iter_."""
2968
+ _method = 1
2969
+
2970
+
2971
+ class GramOMPCV(Tagged, RegressorMixin, BaseEstimator):
2972
+ """scikit-learn's ``OrthogonalMatchingPursuitCV``: KFold(cv) without
2973
+ shuffling; on each fold the path of the translated OMP (``omp.scm``,
2974
+ the path recorded) up to ``max_iter`` columns on the centred training
2975
+ fold, the test fold's mean square error after each column, the number
2976
+ of columns that minimises the folds' mean (over the shortest path),
2977
+ refitted as GramOMP. ``max_iter`` defaults, as there, to
2978
+ min(max(10% of p, 5), p).
2979
+
2980
+ Attributes after fit: n_nonzero_coefs_, coef_, intercept_, n_iter_.
2981
+ """
2982
+
2983
+ def __init__(self, *, fit_intercept=True, max_iter=None, cv=5):
2984
+ self.fit_intercept, self.max_iter, self.cv = fit_intercept, max_iter, cv
2985
+
2986
+ def fit(self, X, y):
2987
+ X, y = fit_xy(self, X, y)
2988
+ n, p = X.shape
2989
+ if n < int(self.cv):
2990
+ raise ValueError(f"Cannot have number of splits n_splits={self.cv} greater than "
2991
+ f"the number of samples: n_samples={n}.")
2992
+ kmax = min(max(int(0.1 * p), 5), p) if self.max_iter is None else int(self.max_iter)
2993
+ lib = _gramops_kernels()
2994
+ mses = []
2995
+ for train, test in _kfold(n, int(self.cv)):
2996
+ Xtr, ytr, Xte, yte = X[train], y[train], X[test], y[test]
2997
+ if self.fit_intercept:
2998
+ xm, ym = Xtr.mean(axis=0), ytr.mean()
2999
+ Xtr, Xte, ytr, yte = Xtr - xm, Xte - xm, ytr - ym, yte - ym
3000
+ path = np.zeros((kmax, p))
3001
+ k = lib.omp(np.ascontiguousarray(Xtr), np.ascontiguousarray(ytr), np.zeros(p),
3002
+ path, 1, kmax, len(train), p)
3003
+ res = Xte @ path[:k].T - yte[:, None]
3004
+ mses.append((res ** 2).mean(axis=0))
3005
+ m = min(len(f) for f in mses)
3006
+ best = int(np.argmin(np.mean([f[:m] for f in mses], axis=0))) + 1
3007
+ self.n_nonzero_coefs_ = best
3008
+ Xc, yc, xm, ym = _center(X, y, self.fit_intercept)
3009
+ self.coef_ = np.zeros(p)
3010
+ self.n_iter_ = lib.omp(Xc, yc, self.coef_, np.zeros(1), 0, best, n, p)
3011
+ self.intercept_ = ym - float(xm @ self.coef_)
3012
+ return self
3013
+
3014
+ def predict(self, X):
3015
+ return pred_x(self, X) @ self.coef_ + self.intercept_
3016
+
3017
+
3018
+ class GramLassoLarsIC(Tagged, RegressorMixin, BaseEstimator):
3019
+ """LassoLars with the penalty chosen by AIC or BIC, scikit-learn's
3020
+ ``LassoLarsIC``: the whole "lasso" path by the translated
3021
+ _lars_path_solver (``examples/kernel-only/lars.scm``, the path
3022
+ recorded as lars_path(return_path=True) returns it), then
3023
+ scikit-learn's criterion n log(2 pi s^2) + RSS / s^2 + K df at
3024
+ each knot, s^2 the OLS noise variance RSS_ols / (n - p - 1). The
3025
+ residual sums are taken from the Gram (|y - X c|^2 = y'y - 2 c'X'y
3026
+ + c'X'Xc, the OLS fit by its Cholesky factor) rather than from X
3027
+ at every knot as scikit-learn takes them -- the same numbers to
3028
+ rounding, without the n x knots product. n > p + 1, as there.
3029
+
3030
+ Attributes after fit: alpha_, coef_, intercept_, alphas_, criterion_,
3031
+ noise_variance_, n_iter_.
3032
+ """
3033
+
3034
+ def __init__(self, criterion="aic", *, fit_intercept=True, max_iter=500,
3035
+ noise_variance=None):
3036
+ self.criterion, self.fit_intercept, self.max_iter = criterion, fit_intercept, max_iter
3037
+ self.noise_variance = noise_variance
3038
+
3039
+ def fit(self, X, y):
3040
+ X, y = fit_xy(self, X, y)
3041
+ Xc, yc, xm, ym = _center(X, y, self.fit_intercept)
3042
+ n, p = Xc.shape
3043
+ mi = int(self.max_iter)
3044
+ G0 = _gram(Xc)
3045
+ cov0 = Xc.T @ yc
3046
+ yy = float(yc @ yc)
3047
+ path = np.zeros((mi + 1, p))
3048
+ alphas = np.zeros(mi + 1)
3049
+ coef = np.zeros(p)
3050
+ it = _classifier_kernels().lars(
3051
+ G0.copy(), cov0.copy(), coef, path, alphas, 1, float(n), 0.0, 1, mi,
3052
+ float(np.finfo(np.float64).eps), p)
3053
+ cp, al = path[:it + 1], alphas[:it + 1]
3054
+ rss = yy - 2.0 * (cp @ cov0) + ((cp @ G0) * cp).sum(axis=1)
3055
+ df = (np.abs(cp) > np.finfo(np.float64).eps).sum(axis=1)
3056
+ if self.noise_variance is None:
3057
+ if n <= p + int(self.fit_intercept):
3058
+ raise ValueError(f"n must exceed p (+1 with an intercept) to estimate the "
3059
+ f"noise variance; got n_samples = {n} for {p} features; "
3060
+ "pass noise_variance")
3061
+ L = np.linalg.cholesky(G0)
3062
+ beta = np.linalg.solve(L.T, np.linalg.solve(L, cov0))
3063
+ self.noise_variance_ = (yy - 2.0 * beta @ cov0 + beta @ G0 @ beta) / (
3064
+ n - p - int(self.fit_intercept))
3065
+ else:
3066
+ self.noise_variance_ = float(self.noise_variance)
3067
+ factor = {"aic": 2.0, "bic": np.log(n)}[self.criterion]
3068
+ self.criterion_ = (n * np.log(2 * np.pi * self.noise_variance_)
3069
+ + rss / self.noise_variance_ + factor * df)
3070
+ best = int(np.argmin(self.criterion_))
3071
+ self.alphas_, self.n_iter_ = al, it
3072
+ self.alpha_ = float(al[best])
3073
+ self.coef_ = cp[best].copy()
3074
+ self.intercept_ = ym - float(xm @ self.coef_)
3075
+ return self
3076
+
3077
+ def predict(self, X):
3078
+ return pred_x(self, X) @ self.coef_ + self.intercept_
3079
+
3080
+
3081
+ class WeiszfeldTheilSen(Tagged, RegressorMixin, BaseEstimator):
3082
+ """scikit-learn's ``TheilSenRegressor`` translated whole
3083
+ (``examples/kernel-only/theilsen.scm``): the least squares fit of
3084
+ every subset of n_subsamples rows (p + 1 by default: each an exact
3085
+ solve, the minimum-norm fit for a singular one, as gelss gives it)
3086
+ and the spatial median of the fits by the modified Weiszfeld
3087
+ iteration. The subsets are scikit-learn's: all the combinations
3088
+ when there are at most ``max_subpopulation``, else
3089
+ ``RandomState(random_state).choice`` draws, made here the same way
3090
+ (and on large problems that drawing is most of the time, on both
3091
+ sides).
3092
+
3093
+ Attributes after fit: coef_, intercept_, n_iter_, n_subpopulation_,
3094
+ breakdown_.
3095
+ """
3096
+
3097
+ def __init__(self, *, fit_intercept=True, max_subpopulation=1e4, n_subsamples=None,
3098
+ max_iter=300, tol=1e-3, random_state=None):
3099
+ self.fit_intercept, self.max_subpopulation = fit_intercept, max_subpopulation
3100
+ self.n_subsamples, self.max_iter, self.tol = n_subsamples, max_iter, tol
3101
+ self.random_state = random_state
3102
+
3103
+ def fit(self, X, y):
3104
+ from itertools import combinations
3105
+ from math import comb
3106
+ X, y = fit_xy(self, X, y)
3107
+ n, p = X.shape
3108
+ fi = 1 if self.fit_intercept else 0
3109
+ ns = min(p + fi, n) if self.n_subsamples is None else int(self.n_subsamples)
3110
+ total = max(1, comb(n, ns))
3111
+ self.n_subpopulation_ = int(min(self.max_subpopulation, total))
3112
+ self.breakdown_ = 1 - (0.5 ** (1 / ns) * (n - ns + 1) + ns - 1) / n
3113
+ if total <= self.max_subpopulation:
3114
+ idx = np.array(list(combinations(range(n), ns)), dtype=np.int32)
3115
+ else:
3116
+ rs = np.random.RandomState(self.random_state)
3117
+ idx = np.array([rs.choice(n, size=ns, replace=False)
3118
+ for _ in range(self.n_subpopulation_)], dtype=np.int32)
3119
+ nsub = len(idx)
3120
+ w = np.zeros((nsub, p + fi))
3121
+ c = np.zeros(p + fi)
3122
+ self.n_iter_ = _classifier_kernels().theilsen(X, y, idx, w, c, nsub, ns, fi,
3123
+ int(self.max_iter), float(self.tol), p)
3124
+ self.intercept_ = float(c[0]) if fi else 0.0
3125
+ self.coef_ = c[fi:].copy()
3126
+ return self
3127
+
3128
+ def predict(self, X):
3129
+ return pred_x(self, X) @ self.coef_ + self.intercept_
3130
+
3131
+
3132
+ class L1LinearSVM(Tagged, ClassifierMixin, BaseEstimator):
3133
+ """The L1-regularized L2-loss linear SVM by liblinear's own
3134
+ algorithm: ``examples/kernel-only/svm-l1.scm`` translated plainly,
3135
+ liblinear's solve_l1r_l2_svc (coordinate descent with a Newton step,
3136
+ a line search and shrinking) -- what scikit-learn's
3137
+ ``LinearSVC(penalty="l1", dual=False)`` runs, on
3138
+
3139
+ min_w sum_j |w_j| + C sum_i max(0, 1 - y_i w.x_i)^2,
3140
+
3141
+ the intercept a ones column (``intercept_scaling``) penalised with
3142
+ the rest, as liblinear has it. The coordinate order is random from
3143
+ a different generator than liblinear's, so the fit agrees with
3144
+ scikit-learn's to its tolerance. Two classes.
3145
+
3146
+ Attributes after fit: classes_, coef_, intercept_, n_iter_.
3147
+ """
3148
+
3149
+ def __init__(self, C=1.0, tol=1e-4, max_iter=1000, fit_intercept=True,
3150
+ intercept_scaling=1.0, random_state=0):
3151
+ self.C, self.tol, self.max_iter = C, tol, max_iter
3152
+ self.fit_intercept, self.intercept_scaling = fit_intercept, intercept_scaling
3153
+ self.random_state = random_state
3154
+
3155
+ _binary_only = True
3156
+
3157
+ def fit(self, X, y):
3158
+ X, y = fit_xy(self, X, y, classifier=True)
3159
+ n, p = X.shape
3160
+ self.classes_, ys = _binary_labels(y)
3161
+ scaling = float(self.intercept_scaling)
3162
+ if self.fit_intercept:
3163
+ X = np.hstack([X, np.full((n, 1), scaling)])
3164
+ q = X.shape[1]
3165
+ w = np.zeros(q)
3166
+ # liblinear walks the design by columns
3167
+ self.n_iter_ = _classifier_kernels().svm_l1(
3168
+ np.ascontiguousarray(X.T), ys, w, float(self.C), float(self.tol), int(self.max_iter),
3169
+ _seed(self.random_state), n, q)
3170
+ self.coef_ = w[:p].copy()
3171
+ self.intercept_ = float(w[p] * scaling) if self.fit_intercept else 0.0
3172
+ return self
3173
+
3174
+ def decision_function(self, X):
3175
+ return pred_x(self, X) @ self.coef_ + self.intercept_
3176
+
3177
+ def predict(self, X):
3178
+ z = self.decision_function(X)
3179
+ return self.classes_[(z > 0).astype(int)]
3180
+
3181
+
3182
+ class L1LogisticRegression(Tagged, ClassifierMixin, BaseEstimator):
3183
+ """L1-regularized logistic regression by liblinear's own algorithm.
3184
+
3185
+ ``examples/kernel-only/logistic-l1.scm`` translated plainly:
3186
+ liblinear's solve_l1r_lr (newGLMNET: Newton iterations over a
3187
+ coordinate-descent subproblem with random order and shrinking, and
3188
+ a line search) -- what scikit-learn's
3189
+ ``LogisticRegression(l1_ratio=1, solver="liblinear",
3190
+ fit_intercept=False)`` runs, on the same problem
3191
+
3192
+ min_w sum_j |w_j| + C sum_i log(1 + exp(-y_i w.x_i)).
3193
+
3194
+ ``tol`` and ``max_iter`` are LogisticRegression's (the tolerance is
3195
+ scaled as liblinear scales it; the cap counts Newton iterations).
3196
+ No intercept; two classes. ``CovLogistic`` solves the same problem
3197
+ by majorization over the Gram matrix instead.
3198
+
3199
+ Attributes after fit: classes_, coef_, n_iter_.
3200
+ """
3201
+
3202
+ _binary_only = True
3203
+
3204
+ def __init__(self, C=1.0, tol=1e-4, max_iter=100, random_state=0):
3205
+ self.C, self.tol, self.max_iter = C, tol, max_iter
3206
+ self.random_state = random_state
3207
+
3208
+ def fit(self, X, y):
3209
+ X, y = fit_xy(self, X, y, classifier=True)
3210
+ n, p = X.shape
3211
+ self.classes_, ys = _binary_labels(y)
3212
+ w = np.zeros(p)
3213
+ # liblinear walks the design by columns
3214
+ self.n_iter_ = _classifier_kernels().logistic_l1(
3215
+ np.ascontiguousarray(X.T), ys, w, float(self.C), float(self.tol), int(self.max_iter),
3216
+ _seed(self.random_state), n, p)
3217
+ self.coef_ = w
3218
+ self.intercept_ = 0.0
3219
+ return self
3220
+
3221
+ def decision_function(self, X):
3222
+ return pred_x(self, X) @ self.coef_
3223
+
3224
+ def predict_proba(self, X):
3225
+ p1 = 1.0 / (1.0 + np.exp(-self.decision_function(X)))
3226
+ return np.column_stack([1.0 - p1, p1])
3227
+
3228
+ def predict(self, X):
3229
+ z = self.decision_function(X)
3230
+ return self.classes_[(z > 0).astype(int)]
3231
+
3232
+
3233
+ class CovLogistic:
3234
+ """L1-penalized logistic regression, by majorization over the Gram.
3235
+
3236
+ The objective is
3237
+
3238
+ (1/n) sum_i [log(1 + exp(eta_i)) - y_i eta_i] + lam ||b||_1
3239
+
3240
+ with eta = X b and y in {0,1} -- scikit-learn's ``LogisticRegression``
3241
+ with ``penalty="l1"``, ``fit_intercept=False`` and ``C = 1/(n lam)``.
3242
+
3243
+ The logistic Hessian is bounded by X'X / 4 (Bohning's bound), so
3244
+ instead of reweighting the Gram matrix every round -- which would
3245
+ cost O(n p^2) each time and break the design-free construction --
3246
+ the quadratic term is fixed at G/4 once and only the gradient
3247
+ moves. Each outer round costs one pass for eta and one for
3248
+ X'(y - mu), then hands the majorizer to the same coordinate
3249
+ descent the lasso uses: warm-started, exactly resumable, and with
3250
+ the pleasant identity that the working correlations are exactly
3251
+ X'(y - mu). Majorization descends monotonically to the optimum
3252
+ of this convex problem.
3253
+ """
3254
+
3255
+ def __init__(self, X, y):
3256
+ X = np.ascontiguousarray(X, dtype=np.float64)
3257
+ y = np.ascontiguousarray(y, dtype=np.float64)
3258
+ if X.ndim != 2 or y.ndim != 1 or X.shape[0] != y.size:
3259
+ raise ValueError(
3260
+ f"X is {X.shape} and y is {y.shape}; expected (n, p) "
3261
+ "and (n,)")
3262
+ if not np.all((y == 0.0) | (y == 1.0)):
3263
+ raise ValueError("y must be 0/1")
3264
+ self.X, self.y = X, y
3265
+ self.nobs, self.p = X.shape
3266
+ self.g4 = np.ascontiguousarray((X.T @ X).ravel() / 4.0)
3267
+
3268
+ def _grad_corr(self, beta):
3269
+ mu = 1.0 / (1.0 + np.exp(-(self.X @ beta)))
3270
+ return self.X.T @ (self.y - mu)
3271
+
3272
+ def lambda_max(self):
3273
+ """The smallest penalty keeping every coefficient at zero.
3274
+
3275
+ At beta = 0 the gradient is X'(y - 1/2); the penalty must beat
3276
+ its largest entry, scaled as the objective is.
3277
+ """
3278
+ return float(np.max(np.abs(self.X.T @ (self.y - 0.5)))) / self.nobs
3279
+
3280
+ def lambda_grid(self, num=50, eps=1e-2):
3281
+ """A log-spaced descending path from lambda_max."""
3282
+ return self.lambda_max() * np.logspace(0, np.log10(eps), int(num))
3283
+
3284
+ def fit_path(self, lambdas, tol=1e-7, inner_sweeps=200,
3285
+ max_rounds=500):
3286
+ """Coefficients per lambda, warm-started down the path."""
3287
+ lambdas = np.asarray(lambdas, dtype=np.float64)
3288
+ beta = np.zeros(self.p)
3289
+ out = np.empty((lambdas.size, self.p))
3290
+ for i, lam in enumerate(lambdas):
3291
+ beta = self._fit_one(beta, float(lam), tol, inner_sweeps,
3292
+ max_rounds)
3293
+ out[i] = beta
3294
+ return out
3295
+
3296
+ def fit(self, lam, **kw):
3297
+ """Coefficients at one lambda, from zero."""
3298
+ return self._fit_one(np.zeros(self.p), float(lam),
3299
+ kw.get("tol", 1e-7),
3300
+ kw.get("inner_sweeps", 200),
3301
+ kw.get("max_rounds", 500))
3302
+
3303
+ def _fit_one(self, beta, lam, tol, inner_sweeps, max_rounds):
3304
+ beta = beta.copy()
3305
+ self._rounds = 0
3306
+ for _ in range(max_rounds):
3307
+ self._rounds += 1
3308
+ prev = beta.copy()
3309
+ # the majorizer at beta: quadratic G/4, working
3310
+ # correlations exactly X'(y - mu)
3311
+ c = np.ascontiguousarray(self._grad_corr(beta))
3312
+ kernel.cov_descend(self.g4, c, beta, lam, inner_sweeps,
3313
+ float(self.nobs), self.p)
3314
+ if np.max(np.abs(beta - prev)) < tol * max(
3315
+ 1.0, float(np.max(np.abs(beta)))):
3316
+ break
3317
+ return beta
3318
+
3319
+
3320
+ class GramLogisticRegression(Tagged, ClassifierMixin, BaseEstimator):
3321
+ """scikit-learn's ``LogisticRegression(l1_ratio=1, solver="liblinear")``
3322
+ interface over CovLogistic: the same problem,
3323
+
3324
+ min_w ||w||_1 + C sum_i log(1 + exp(-y_i (w.x_i + b))),
3325
+
3326
+ solved by majorization over the Gram matrix instead of liblinear's
3327
+ Newton steps. As in liblinear the intercept is the weight of a
3328
+ constant column of value ``intercept_scaling`` and is penalized
3329
+ with the rest. ``tol`` is CovLogistic's (the largest coefficient
3330
+ move in a round, relative to their size) and ``max_iter`` caps the
3331
+ rounds. Two classes.
3332
+
3333
+ Attributes after fit: classes_, coef_, intercept_, n_iter_.
3334
+ """
3335
+
3336
+ _binary_only = True
3337
+
3338
+ def __init__(self, C=1.0, *, fit_intercept=True, intercept_scaling=1.0, tol=1e-7,
3339
+ max_iter=500):
3340
+ self.C, self.fit_intercept, self.intercept_scaling = C, fit_intercept, intercept_scaling
3341
+ self.tol, self.max_iter = tol, max_iter
3342
+
3343
+ def fit(self, X, y):
3344
+ X, y = fit_xy(self, X, y, classifier=True)
3345
+ n = X.shape[0]
3346
+ self.classes_, ys = _binary_labels(y)
3347
+ s = float(self.intercept_scaling)
3348
+ if self.fit_intercept:
3349
+ X = np.column_stack([X, np.full(n, s)])
3350
+ m = CovLogistic(X, (ys > 0).astype(np.float64))
3351
+ lam = 1.0 / (n * float(self.C))
3352
+ beta = m._fit_one(np.zeros(m.p), lam, float(self.tol), 200, int(self.max_iter))
3353
+ self.n_iter_ = m._rounds
3354
+ if self.fit_intercept:
3355
+ self.coef_, self.intercept_ = beta[:-1].copy(), float(beta[-1]) * s
3356
+ else:
3357
+ self.coef_, self.intercept_ = beta, 0.0
3358
+ return self
3359
+
3360
+ def decision_function(self, X):
3361
+ return pred_x(self, X) @ self.coef_ + self.intercept_
3362
+
3363
+ def predict_proba(self, X):
3364
+ p1 = 1.0 / (1.0 + np.exp(-self.decision_function(X)))
3365
+ return np.column_stack([1.0 - p1, p1])
3366
+
3367
+ def predict(self, X):
3368
+ z = self.decision_function(X)
3369
+ return self.classes_[(z > 0).astype(int)]
3370
+
3371
+
3372
+ class CovGroupLasso:
3373
+ """Group lasso over a design given by its Gram matrix.
3374
+
3375
+ The objective groups the penalty:
3376
+
3377
+ (1/2n) ||y - X b||^2 + lam * sum_g w_g ||b_g||_2
3378
+
3379
+ with the usual weights w_g = sqrt(|g|), so whole groups enter or
3380
+ leave together. Block coordinate descent runs on the Gram matrix
3381
+ exactly as the lasso's coordinate descent does -- the working
3382
+ correlations c = X'y - G b are maintained across visits -- and
3383
+ each block visit is one majorized proximal step: the group's Gram
3384
+ block is dominated by its top eigenvalue L_g (found once), and the
3385
+ resulting subproblem has the group soft-threshold in closed form.
3386
+ Majorization descends monotonically; a group of size one reduces
3387
+ exactly to the lasso's coordinate update.
3388
+
3389
+ ``groups`` is a list of index arrays, a partition of 0..p-1.
3390
+ """
3391
+
3392
+ def __init__(self, X=None, y=None, *, gram=None, corr=None,
3393
+ nobs=None, groups=None):
3394
+ if gram is not None:
3395
+ if corr is None or nobs is None:
3396
+ raise ValueError("gram needs corr and nobs alongside it")
3397
+ g = np.ascontiguousarray(gram, dtype=np.float64)
3398
+ c = np.ascontiguousarray(corr, dtype=np.float64)
3399
+ self.p = c.size
3400
+ self.G = g.reshape(self.p, self.p)
3401
+ self.c0, self.nobs = c, int(nobs)
3402
+ else:
3403
+ if X is None or y is None:
3404
+ raise ValueError("pass X and y, or gram, corr and nobs")
3405
+ X = np.ascontiguousarray(X, dtype=np.float64)
3406
+ y = np.ascontiguousarray(y, dtype=np.float64)
3407
+ self.nobs, self.p = X.shape
3408
+ self.G = X.T @ X
3409
+ self.c0 = X.T @ y
3410
+ if groups is None:
3411
+ raise ValueError("groups is required")
3412
+ self.groups = [np.asarray(g, dtype=np.intp) for g in groups]
3413
+ seen = np.concatenate(self.groups)
3414
+ if sorted(seen.tolist()) != list(range(self.p)):
3415
+ raise ValueError("groups must partition 0..p-1")
3416
+ self.wg = np.array([np.sqrt(len(g)) for g in self.groups])
3417
+ # the majorizer per group: its Gram block's top eigenvalue
3418
+ self.Lg = np.array(
3419
+ [float(np.linalg.eigvalsh(self.G[np.ix_(g, g)])[-1])
3420
+ for g in self.groups])
3421
+
3422
+ def lambda_max(self):
3423
+ """The smallest penalty at which every group stays zero."""
3424
+ return max(float(np.linalg.norm(self.c0[g])) / (self.nobs * w)
3425
+ for g, w in zip(self.groups, self.wg))
3426
+
3427
+ def lambda_grid(self, num=50, eps=1e-2):
3428
+ """A log-spaced descending path from lambda_max."""
3429
+ return self.lambda_max() * np.logspace(0, np.log10(eps), int(num))
3430
+
3431
+ def fit_path(self, lambdas, tol=1e-8, max_sweeps=10000):
3432
+ """Coefficients per lambda, warm-started down the path."""
3433
+ lambdas = np.asarray(lambdas, dtype=np.float64)
3434
+ beta = np.zeros(self.p)
3435
+ c = self.c0.copy()
3436
+ out = np.empty((lambdas.size, self.p))
3437
+ for i, lam in enumerate(lambdas):
3438
+ beta, c = self._fit_one(beta, c, float(lam), tol, max_sweeps)
3439
+ out[i] = beta
3440
+ return out
3441
+
3442
+ def fit(self, lam, **kw):
3443
+ """Coefficients at one lambda, from zero."""
3444
+ b, _ = self._fit_one(np.zeros(self.p), self.c0.copy(), float(lam),
3445
+ kw.get("tol", 1e-8),
3446
+ kw.get("max_sweeps", 10000))
3447
+ return b
3448
+
3449
+ def _fit_one(self, beta, c, lam, tol, max_sweeps):
3450
+ thr = lam * self.nobs
3451
+ self._sweeps = 0
3452
+ for _ in range(max_sweeps):
3453
+ self._sweeps += 1
3454
+ moved = 0.0
3455
+ for g, w, L in zip(self.groups, self.wg, self.Lg):
3456
+ if L <= 0.0: # all-zero columns: the group stays at zero
3457
+ continue
3458
+ bg = beta[g]
3459
+ z = bg + c[g] / L
3460
+ nz = float(np.linalg.norm(z))
3461
+ scale = max(0.0, 1.0 - thr * w / (L * nz)) if nz > 0 else 0.0
3462
+ nb = scale * z
3463
+ d = nb - bg
3464
+ dmax = float(np.max(np.abs(d))) if d.size else 0.0
3465
+ if dmax > 0.0:
3466
+ c -= self.G[:, g] @ d
3467
+ beta[g] = nb
3468
+ moved = max(moved, dmax)
3469
+ if moved < tol * max(1.0, float(np.max(np.abs(beta)))):
3470
+ break
3471
+ return beta, c
3472
+
3473
+ def objective(self, y_or_none, beta, lam):
3474
+ """The penalized objective, up to the constant ||y||^2/(2n).
3475
+
3476
+ Computable from the Gram alone: (1/2n)(b'Gb - 2 c0'b) plus the
3477
+ penalty; add ||y||^2/(2n) yourself if you want the absolute
3478
+ value.
3479
+ """
3480
+ quad = 0.5 * (beta @ self.G @ beta) - self.c0 @ beta
3481
+ pen = sum(w * float(np.linalg.norm(beta[g]))
3482
+ for g, w in zip(self.groups, self.wg))
3483
+ return quad / self.nobs + lam * pen
3484
+
3485
+
3486
+ class GramGroupLasso(Tagged, RegressorMixin, BaseEstimator):
3487
+ """A ``Lasso``-style interface over CovGroupLasso: fit(X, y) centres
3488
+ the data when ``fit_intercept`` and runs the block descent at
3489
+ ``alpha``,
3490
+
3491
+ (1 / 2 n) ||y - X b - b0||^2 + alpha sum_g sqrt(|g|) ||b_g||_2.
3492
+
3493
+ ``groups`` is a list of index arrays partitioning the columns, or
3494
+ None for one group per column (the lasso). ``tol`` is
3495
+ CovGroupLasso's and ``max_iter`` its sweep cap.
3496
+
3497
+ Attributes after fit: coef_, intercept_, n_iter_, n_features_in_.
3498
+ """
3499
+
3500
+ def __init__(self, alpha=1.0, *, groups=None, fit_intercept=True, tol=1e-8,
3501
+ max_iter=10000):
3502
+ self.alpha, self.groups, self.fit_intercept = alpha, groups, fit_intercept
3503
+ self.tol, self.max_iter = tol, max_iter
3504
+
3505
+ def fit(self, X, y):
3506
+ X, y = fit_xy(self, X, y)
3507
+ n, p = X.shape
3508
+ G, c, xm, ym = _centered_gram(X, y, self.fit_intercept)
3509
+ groups = [[j] for j in range(p)] if self.groups is None else self.groups
3510
+ m = CovGroupLasso(gram=G, corr=c, nobs=n, groups=groups)
3511
+ beta, _ = m._fit_one(np.zeros(p), m.c0.copy(), float(self.alpha), float(self.tol),
3512
+ int(self.max_iter))
3513
+ self.coef_, self.n_iter_ = beta, m._sweeps
3514
+ self.intercept_ = float(ym - xm @ self.coef_)
3515
+ return self
3516
+
3517
+ def predict(self, X):
3518
+ return pred_x(self, X) @ self.coef_ + self.intercept_
3519
+
3520
+
3521
+ class CovMultiTaskLasso:
3522
+ """Multi-task lasso and elastic net over a design's Gram matrix.
3523
+
3524
+ The penalty ties each feature's row of W together across tasks --
3525
+ the row enters or leaves for every task at once -- matching
3526
+ scikit-learn's ``MultiTaskLasso`` / ``MultiTaskElasticNet`` with
3527
+ ``fit_intercept=False``:
3528
+
3529
+ (1/2n)||Y - XW||^2_F + alpha l1_ratio sum_j ||W_j||_2
3530
+ + (alpha/2)(1 - l1_ratio) ||W||^2_F
3531
+
3532
+ The covariance route carries over unchanged: the solver keeps
3533
+ C = X'Y - G W per task, so a row's block update is O(p T) and never
3534
+ touches the n rows. Coefficients are (p, n_tasks) -- the transpose
3535
+ of scikit-learn's ``coef_``.
3536
+ """
3537
+
3538
+ def __init__(self, X=None, Y=None, *, gram=None, corr=None, nobs=None):
3539
+ if gram is not None:
3540
+ if corr is None or nobs is None:
3541
+ raise ValueError("gram needs corr and nobs alongside it")
3542
+ c = np.ascontiguousarray(corr, dtype=np.float64)
3543
+ if c.ndim != 2:
3544
+ raise ValueError("corr must be (p, n_tasks)")
3545
+ self.p, self.n_tasks = c.shape
3546
+ g = np.ascontiguousarray(gram, dtype=np.float64).ravel()
3547
+ if g.size != self.p * self.p:
3548
+ raise ValueError(f"gram is {g.size} entries; expected p*p")
3549
+ self.nobs, self.g, self.c0 = int(nobs), g, c.ravel()
3550
+ else:
3551
+ if X is None or Y is None:
3552
+ raise ValueError("pass X and Y, or gram, corr and nobs")
3553
+ X = np.ascontiguousarray(X, dtype=np.float64)
3554
+ Y = np.ascontiguousarray(Y, dtype=np.float64)
3555
+ if Y.ndim != 2 or X.shape[0] != Y.shape[0]:
3556
+ raise ValueError(
3557
+ f"X is {X.shape} and Y is {Y.shape}; expected (n, p) "
3558
+ "and (n, n_tasks)")
3559
+ self.nobs, self.p = X.shape
3560
+ self.n_tasks = Y.shape[1]
3561
+ self.g = np.ascontiguousarray((X.T @ X).ravel())
3562
+ self.c0 = np.ascontiguousarray((X.T @ Y).ravel())
3563
+ self.X, self.Y = X, Y
3564
+
3565
+ def lambda_max(self, l1_ratio=1.0):
3566
+ """The smallest penalty that zeroes every row of W.
3567
+
3568
+ The single-task |X_j'y| becomes the L2 norm of the row X_j'Y:
3569
+ below it the block soft threshold leaves the row at zero.
3570
+ """
3571
+ C = self.c0.reshape(self.p, self.n_tasks)
3572
+ return (float(np.max(np.linalg.norm(C, axis=1)))
3573
+ / (self.nobs * l1_ratio))
3574
+
3575
+ def lambda_grid(self, num=100, eps=1e-3, l1_ratio=1.0):
3576
+ """Log-spaced from lambda_max down, as the single-task grid is."""
3577
+ hi = self.lambda_max(l1_ratio)
3578
+ return hi * np.logspace(0, np.log10(eps), int(num))
3579
+
3580
+ def fit(self, lam, **kw):
3581
+ """Coefficients (p, n_tasks) at one penalty."""
3582
+ return self.fit_path([lam], **kw)[0]
3583
+
3584
+ def fit_path(self, lambdas, tol=1e-8, chunk=20, max_sweeps=100000,
3585
+ l1_ratio=1.0):
3586
+ """Coefficients per lambda, warm-started; (len(lambdas), p, n_tasks).
3587
+
3588
+ Give lambdas in descending order, as with the single-task path.
3589
+ """
3590
+ lambdas = np.asarray(lambdas, dtype=np.float64)
3591
+ pt = self.p * self.n_tasks
3592
+ W, c = np.zeros(pt), self.c0.copy()
3593
+ prev = np.empty(pt)
3594
+ out = np.empty((lambdas.size, self.p, self.n_tasks))
3595
+ for i, lam in enumerate(lambdas):
3596
+ swept = 0
3597
+ while swept < max_sweeps:
3598
+ prev[:] = W
3599
+ kernel.mt_descend(self.g, c, W,
3600
+ float(lam) * l1_ratio,
3601
+ float(lam) * (1.0 - l1_ratio), chunk,
3602
+ float(self.nobs), self.p, self.n_tasks)
3603
+ swept += chunk
3604
+ if np.max(np.abs(W - prev)) < tol * _scale(W):
3605
+ break
3606
+ out[i] = W.reshape(self.p, self.n_tasks)
3607
+ return out
3608
+
3609
+ def fit_path_batch(self, lambdas, tol=1e-8, chunk=20,
3610
+ max_sweeps=100000, force_cpu=False, l1_ratio=1.0):
3611
+ """Coefficients per lambda, every lambda solved from zero.
3612
+
3613
+ (len(lambdas), p, n_tasks). On the GPU one block per lambda,
3614
+ all sharing this Gram; the CPU fallback is the cold loop.
3615
+ """
3616
+ lambdas = np.ascontiguousarray(lambdas, dtype=np.float64)
3617
+ pt = self.p * self.n_tasks
3618
+ if not force_cpu:
3619
+ W = _grid_descend(self.g[None, :], self.c0[None, :], lambdas,
3620
+ self.p, self.n_tasks, tol=tol, chunk=chunk,
3621
+ max_sweeps=max_sweeps, l1_ratio=l1_ratio,
3622
+ nobs=float(self.nobs))
3623
+ if W is not None:
3624
+ return W.reshape(lambdas.size, self.p, self.n_tasks)
3625
+ out = np.empty((lambdas.size, pt))
3626
+ prev = np.empty(pt)
3627
+ for i, lam in enumerate(lambdas):
3628
+ W, c, swept = np.zeros(pt), self.c0.copy(), 0
3629
+ while swept < max_sweeps:
3630
+ prev[:] = W
3631
+ kernel.mt_descend(self.g, c, W, float(lam) * l1_ratio,
3632
+ float(lam) * (1.0 - l1_ratio), chunk,
3633
+ float(self.nobs), self.p, self.n_tasks)
3634
+ swept += chunk
3635
+ if np.max(np.abs(W - prev)) < tol * _scale(W):
3636
+ break
3637
+ out[i] = W
3638
+ return out.reshape(lambdas.size, self.p, self.n_tasks)
3639
+
3640
+
3641
+ class GramMultiTaskElasticNet(Tagged, RegressorMixin, BaseEstimator):
3642
+ """scikit-learn's ``MultiTaskElasticNet`` interface over
3643
+ CovMultiTaskLasso: fit(X, Y) centres the data when
3644
+ ``fit_intercept``, forms the Gram matrix once and runs the row-block
3645
+ sweep at ``alpha``. Y is (n, n_tasks); coef_ is (n_tasks, p) as
3646
+ scikit-learn has it. ``tol`` is CovMultiTaskLasso's and
3647
+ ``max_iter`` its sweep cap.
3648
+
3649
+ Attributes after fit: coef_, intercept_, n_iter_, n_features_in_.
3650
+ """
3651
+
3652
+ _multi_output = "only"
3653
+
3654
+ def __init__(self, alpha=1.0, *, l1_ratio=0.5, fit_intercept=True, tol=1e-8,
3655
+ max_iter=100000):
3656
+ self.alpha, self.l1_ratio, self.fit_intercept = alpha, l1_ratio, fit_intercept
3657
+ self.tol, self.max_iter = tol, max_iter
3658
+
3659
+ def fit(self, X, y):
3660
+ X, y = fit_xy(self, X, y, multi_output=True)
3661
+ if y.ndim != 2:
3662
+ raise ValueError("For mono-task outputs, use GramElasticNet or GramLasso; "
3663
+ "y must be (n_samples, n_tasks)")
3664
+ n, p = X.shape
3665
+ t = y.shape[1]
3666
+ G, c, xm, ym = _centered_gram(X, y, self.fit_intercept)
3667
+ g = np.ascontiguousarray(G.ravel())
3668
+ c = np.ascontiguousarray(c.ravel())
3669
+ lam, r = float(self.alpha), float(self.l1_ratio)
3670
+ tol, cap = float(self.tol), int(self.max_iter)
3671
+ W, prev = np.zeros(p * t), np.empty(p * t)
3672
+ swept = 0
3673
+ while swept < cap:
3674
+ chunk = min(20, cap - swept)
3675
+ prev[:] = W
3676
+ kernel.mt_descend(g, c, W, lam * r, lam * (1.0 - r), chunk, float(n), p, t)
3677
+ swept += chunk
3678
+ if np.max(np.abs(W - prev)) < tol * _scale(W):
3679
+ break
3680
+ W = W.reshape(p, t)
3681
+ self.coef_, self.n_iter_ = np.ascontiguousarray(W.T), swept
3682
+ self.intercept_ = ym - xm @ W
3683
+ return self
3684
+
3685
+ def predict(self, X):
3686
+ return pred_x(self, X) @ self.coef_.T + self.intercept_
3687
+
3688
+
3689
+ class GramMultiTaskLasso(GramMultiTaskElasticNet):
3690
+ """scikit-learn's ``MultiTaskLasso`` interface over CovMultiTaskLasso:
3691
+ GramMultiTaskElasticNet with l1_ratio 1.
3692
+
3693
+ Attributes after fit: coef_, intercept_, n_iter_, n_features_in_.
3694
+ """
3695
+
3696
+ def __init__(self, alpha=1.0, *, fit_intercept=True, tol=1e-8, max_iter=100000):
3697
+ self.alpha, self.fit_intercept, self.tol, self.max_iter = alpha, fit_intercept, tol, max_iter
3698
+
3699
+ @property
3700
+ def l1_ratio(self):
3701
+ return 1.0
3702
+
3703
+
3704
+ class CovMultiTaskLassoCV(Tagged, RegressorMixin, BaseEstimator):
3705
+ """scikit-learn's ``MultiTaskLassoCV`` (``MultiTaskElasticNetCV``
3706
+ with ``l1_ratio``) over the covariance machinery.
3707
+
3708
+ Same construction as ``CovLassoCV``: the grid from the full data,
3709
+ contiguous folds, each fold's training Gram a subtraction from the
3710
+ full one, the whole path walked warm per fold, alpha_ by minimum
3711
+ mean validation MSE (over samples and tasks) with ties to the
3712
+ larger penalty, refit on all rows. With a GPU the grid is one
3713
+ launch, one block per (fold, penalty) problem, as in CovLassoCV.
3714
+
3715
+ Attributes after fit: alphas_, mse_path_ (num, cv), alpha_, coef_
3716
+ (p, n_tasks).
3717
+ """
3718
+
3719
+ _multi_output = "only"
3720
+
3721
+ def __init__(self, cv=5, num=100, eps=1e-3, tol=1e-8, l1_ratio=1.0,
3722
+ force_cpu=False, force_gpu=False, n_jobs=1):
3723
+ # Same fold-thread parallelism as CovLassoCV; default sequential.
3724
+ self.cv, self.num, self.eps, self.tol = cv, num, eps, tol
3725
+ self.l1_ratio, self.force_cpu, self.force_gpu = l1_ratio, force_cpu, force_gpu
3726
+ self.n_jobs = n_jobs
3727
+
3728
+ def fit(self, X, Y):
3729
+ X, Y = fit_xy(self, X, Y, multi_output=True)
3730
+ if Y.ndim != 2:
3731
+ raise ValueError("For mono-task outputs, use CovLassoCV; Y must be (n, n_tasks)")
3732
+ n = X.shape[0]
3733
+ cv, num = int(self.cv), int(self.num)
3734
+ tol, l1_ratio = float(self.tol), float(self.l1_ratio)
3735
+ full = CovMultiTaskLasso(X, Y)
3736
+ self.alphas_ = full.lambda_grid(num=num, eps=float(self.eps),
3737
+ l1_ratio=l1_ratio)
3738
+ bounds = np.linspace(0, n, cv + 1).astype(int)
3739
+ self.mse_path_ = np.empty((num, cv))
3740
+ G = full.g.reshape(full.p, full.p)
3741
+ C = full.c0.reshape(full.p, full.n_tasks)
3742
+ if cuda_available() and not self.force_cpu:
3743
+ done = self._fit_gpu(X, Y, full, G, C, bounds, n)
3744
+ if done is not None:
3745
+ return done
3746
+
3747
+ def one_fold(k):
3748
+ lo, hi = bounds[k], bounds[k + 1]
3749
+ Xf, Yf = X[lo:hi], Y[lo:hi]
3750
+ m = CovMultiTaskLasso(gram=(G - Xf.T @ Xf).ravel(),
3751
+ corr=C - Xf.T @ Yf,
3752
+ nobs=n - (hi - lo))
3753
+ path = m.fit_path(self.alphas_, tol=tol, l1_ratio=l1_ratio)
3754
+ # all validation residuals in one product: (nf, num, T)
3755
+ resid = np.tensordot(Xf, path, axes=([1], [1])) - Yf[:, None, :]
3756
+ self.mse_path_[:, k] = np.mean(resid * resid, axis=(0, 2))
3757
+
3758
+ if int(self.n_jobs) > 1:
3759
+ from concurrent.futures import ThreadPoolExecutor
3760
+ with ThreadPoolExecutor(max_workers=int(self.n_jobs)) as ex:
3761
+ list(ex.map(one_fold, range(cv)))
3762
+ else:
3763
+ for k in range(cv):
3764
+ one_fold(k)
3765
+ return self._finish(full)
3766
+
3767
+ def _fit_gpu(self, X, Y, full, G, C, bounds, n):
3768
+ # The grid as one launch, the fold's Gram shared by its
3769
+ # problems and nobs absorbed into each problem's lambda as in
3770
+ # CovLassoCV; None when the device declined.
3771
+ p_, T = full.p, full.n_tasks
3772
+ num, cv = int(self.num), int(self.cv)
3773
+ grams = np.empty((cv, p_ * p_))
3774
+ corrs = np.empty((cv, p_ * T))
3775
+ nobs_k = np.empty(cv)
3776
+ for k in range(cv):
3777
+ lo, hi = bounds[k], bounds[k + 1]
3778
+ Xf, Yf = X[lo:hi], Y[lo:hi]
3779
+ grams[k] = (G - Xf.T @ Xf).ravel()
3780
+ corrs[k] = (C - Xf.T @ Yf).ravel()
3781
+ nobs_k[k] = n - (hi - lo)
3782
+ lams = np.tile(self.alphas_, cv) * np.repeat(nobs_k, num)
3783
+ W = _grid_descend(grams, corrs, lams, p_, T, tol=float(self.tol),
3784
+ l1_ratio=float(self.l1_ratio))
3785
+ if W is None:
3786
+ return None
3787
+ W = W.reshape(cv, num, p_, T)
3788
+ for k in range(cv):
3789
+ lo, hi = bounds[k], bounds[k + 1]
3790
+ resid = (np.tensordot(X[lo:hi], W[k], axes=([1], [1]))
3791
+ - Y[lo:hi, None, :])
3792
+ self.mse_path_[:, k] = np.mean(resid * resid, axis=(0, 2))
3793
+ return self._finish(full)
3794
+
3795
+ def _finish(self, full):
3796
+ mean = self.mse_path_.mean(axis=1)
3797
+ self.alpha_ = float(self.alphas_[int(np.argmin(mean))])
3798
+ self.coef_ = full.fit(self.alpha_, tol=float(self.tol),
3799
+ l1_ratio=float(self.l1_ratio))
3800
+ self.intercept_ = 0.0 if self.coef_.ndim == 1 else np.zeros(self.coef_.shape[1])
3801
+ return self
3802
+
3803
+ def predict(self, X):
3804
+ return pred_x(self, X) @ self.coef_