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