nltools 0.6.0.dev0__py3-none-any.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.
- nltools/__init__.py +55 -0
- nltools/algorithms/__init__.py +90 -0
- nltools/algorithms/alignment/__init__.py +21 -0
- nltools/algorithms/alignment/procrustes.py +565 -0
- nltools/algorithms/alignment/srm.py +758 -0
- nltools/algorithms/backends.py +1059 -0
- nltools/algorithms/corrections.py +177 -0
- nltools/algorithms/decoding.py +327 -0
- nltools/algorithms/inference/__init__.py +50 -0
- nltools/algorithms/inference/bootstrap.py +1386 -0
- nltools/algorithms/inference/correlation.py +373 -0
- nltools/algorithms/inference/intersubject.py +422 -0
- nltools/algorithms/inference/isc.py +1554 -0
- nltools/algorithms/inference/matrix.py +602 -0
- nltools/algorithms/inference/one_sample.py +288 -0
- nltools/algorithms/inference/random.py +122 -0
- nltools/algorithms/inference/timeseries.py +347 -0
- nltools/algorithms/inference/two_sample.py +212 -0
- nltools/algorithms/inference/utils.py +58 -0
- nltools/algorithms/inference/validation.py +282 -0
- nltools/algorithms/neighborhoods.py +207 -0
- nltools/algorithms/outliers.py +308 -0
- nltools/algorithms/regression.py +83 -0
- nltools/algorithms/signal.py +303 -0
- nltools/algorithms/similarity.py +234 -0
- nltools/algorithms/validation.py +151 -0
- nltools/cross_validation.py +72 -0
- nltools/data/__init__.py +30 -0
- nltools/data/adjacency/__init__.py +875 -0
- nltools/data/adjacency/io.py +111 -0
- nltools/data/adjacency/modeling.py +569 -0
- nltools/data/adjacency/plotting.py +174 -0
- nltools/data/adjacency/state.py +349 -0
- nltools/data/adjacency/stats.py +596 -0
- nltools/data/adjacency/utils.py +79 -0
- nltools/data/atlases/__init__.py +23 -0
- nltools/data/atlases/labeling.py +158 -0
- nltools/data/atlases/loading.py +76 -0
- nltools/data/atlases/registry.py +96 -0
- nltools/data/atlases/reporting.py +456 -0
- nltools/data/braindata/__init__.py +2170 -0
- nltools/data/braindata/analysis.py +1381 -0
- nltools/data/braindata/bootstrap.py +398 -0
- nltools/data/braindata/io.py +896 -0
- nltools/data/braindata/modeling.py +594 -0
- nltools/data/braindata/plotting.py +501 -0
- nltools/data/braindata/prediction.py +1250 -0
- nltools/data/braindata/utils.py +348 -0
- nltools/data/braindata/validation.py +197 -0
- nltools/data/braindata/viewer.js +266 -0
- nltools/data/braindata/viewer.py +770 -0
- nltools/data/combine.py +27 -0
- nltools/data/designmatrix/__init__.py +1032 -0
- nltools/data/designmatrix/append.py +518 -0
- nltools/data/designmatrix/diagnostics.py +248 -0
- nltools/data/designmatrix/io.py +356 -0
- nltools/data/designmatrix/plotting.py +291 -0
- nltools/data/designmatrix/regressors.py +463 -0
- nltools/data/designmatrix/transforms.py +200 -0
- nltools/data/designmatrix/utils.py +350 -0
- nltools/data/ownership.py +129 -0
- nltools/data/results.py +291 -0
- nltools/data/roc/__init__.py +398 -0
- nltools/data/simulator/__init__.py +927 -0
- nltools/data/simulator/haxby.py +124 -0
- nltools/data/validation.py +83 -0
- nltools/datasets.py +218 -0
- nltools/io/__init__.py +10 -0
- nltools/io/events.py +67 -0
- nltools/io/h5.py +246 -0
- nltools/mask.py +403 -0
- nltools/models/__init__.py +11 -0
- nltools/models/glm.py +543 -0
- nltools/models/results.py +49 -0
- nltools/models/ridge.py +1303 -0
- nltools/models/validation.py +26 -0
- nltools/plotting/__init__.py +32 -0
- nltools/plotting/adjacency.py +421 -0
- nltools/plotting/brain.py +669 -0
- nltools/plotting/decomposition.py +111 -0
- nltools/plotting/prediction.py +110 -0
- nltools/resources/covariates_example.csv +161 -0
- nltools/resources/onsets_example.csv +40 -0
- nltools/templates/__init__.py +51 -0
- nltools/templates/config.py +144 -0
- nltools/templates/fetch.py +260 -0
- nltools/templates/matching.py +183 -0
- nltools/templates/paths.py +106 -0
- nltools/templates/registry.py +25 -0
- nltools/utils.py +230 -0
- nltools/version.py +13 -0
- nltools-0.6.0.dev0.dist-info/METADATA +95 -0
- nltools-0.6.0.dev0.dist-info/RECORD +95 -0
- nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
- nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
nltools/models/ridge.py
ADDED
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
"""Ridge and banded Ridge regression, with the numerics delegated to Himalaya.
|
|
2
|
+
|
|
3
|
+
`_Ridge` owns argument names, validation, named feature-space alignment, device
|
|
4
|
+
and memory policy, and fitted-state normalization. Himalaya owns decomposition,
|
|
5
|
+
the cross-validation loss, alpha selection, the Dirichlet search, and
|
|
6
|
+
coefficient refitting. Nothing here re-implements a solver.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numbers
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from ..algorithms.backends import (
|
|
20
|
+
_auto_batch_size,
|
|
21
|
+
_device_memory_budget,
|
|
22
|
+
_resolve_backend,
|
|
23
|
+
)
|
|
24
|
+
from .validation import _check_is_fitted
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
#: Constructor defaults for the arguments that only the banded random search
|
|
28
|
+
#: consumes. Passing any of them a different value while fitting ordinary Ridge
|
|
29
|
+
#: is an error rather than a silent no-op. `random_state` is deliberately absent:
|
|
30
|
+
#: `BrainData.fit` forwards one unprefixed `random_state` to whichever estimator
|
|
31
|
+
#: it builds (see specs/braindata.md), so rejecting it here would break a
|
|
32
|
+
#: documented, universally accepted facade keyword.
|
|
33
|
+
_BANDED_ONLY_DEFAULTS = {
|
|
34
|
+
"search_iterations": 100,
|
|
35
|
+
"dirichlet_concentration": (0.1, 1.0),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#: nltools `_Backend.device` -> Himalaya backend name.
|
|
39
|
+
_HIMALAYA_BACKEND_FOR_DEVICE = {
|
|
40
|
+
"cpu": "numpy",
|
|
41
|
+
"cuda": "torch_cuda",
|
|
42
|
+
"mps": "torch_mps",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#: Allocation factor covering the intermediates Himalaya holds alongside the
|
|
46
|
+
#: dominant working set of one batched item.
|
|
47
|
+
_WORKING_SET_OVERHEAD = 5.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def _scoped_himalaya_backend(name: str):
|
|
52
|
+
"""Set Himalaya's process-global backend for the block and restore it after.
|
|
53
|
+
|
|
54
|
+
Himalaya stores its backend in a module-level global, so a fit that changed
|
|
55
|
+
it would leak into unrelated code. This restores the previous backend on
|
|
56
|
+
success and on exception alike.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
name (str): Himalaya backend name, e.g. `"numpy"` or `"torch_mps"`.
|
|
60
|
+
|
|
61
|
+
Yields:
|
|
62
|
+
None: The block runs with `name` as the active Himalaya backend.
|
|
63
|
+
"""
|
|
64
|
+
from himalaya.backend import get_backend, set_backend
|
|
65
|
+
|
|
66
|
+
previous = get_backend().name
|
|
67
|
+
set_backend(name, on_error="raise")
|
|
68
|
+
try:
|
|
69
|
+
yield
|
|
70
|
+
finally:
|
|
71
|
+
set_backend(previous, on_error="raise")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _himalaya_backend_name(backend) -> str:
|
|
75
|
+
"""Map a resolved nltools `_Backend` to its Himalaya backend name.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
backend (Backend): Backend returned by `_resolve_backend`.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
str: One of `"numpy"`, `"torch_cuda"`, or `"torch_mps"`.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
RuntimeError: If the backend's device has no Himalaya equivalent.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
return _HIMALAYA_BACKEND_FOR_DEVICE[backend.device]
|
|
88
|
+
except KeyError:
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"no Himalaya backend for device {backend.device!r}; "
|
|
91
|
+
"use device='cpu' or device='gpu'"
|
|
92
|
+
) from None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _batch_sizes(
|
|
96
|
+
backend,
|
|
97
|
+
memory_budget_gb: float | None,
|
|
98
|
+
n_samples: int,
|
|
99
|
+
n_features: int,
|
|
100
|
+
n_targets: int,
|
|
101
|
+
n_alphas: int,
|
|
102
|
+
itemsize: int,
|
|
103
|
+
) -> dict[str, int]:
|
|
104
|
+
"""Size the batches of a whole cross-validated or banded fit.
|
|
105
|
+
|
|
106
|
+
Only the per-item working-set estimates live here; the budget itself and
|
|
107
|
+
the batch arithmetic come from `nltools.algorithms.backends`. The estimates
|
|
108
|
+
follow Himalaya's dominant allocations: decomposition matrices of
|
|
109
|
+
`(n_alphas_batch, n_features, n_samples)`, cross-validated predictions of
|
|
110
|
+
`(n_alphas_batch, n_samples, n_targets_batch)`, and refit weights of
|
|
111
|
+
`(n_features, n_targets_batch_refit)`.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
dict[str, int]: `n_targets_batch`, `n_targets_batch_refit`, and
|
|
115
|
+
`n_alphas_batch`.
|
|
116
|
+
"""
|
|
117
|
+
budget_gb = _device_memory_budget(
|
|
118
|
+
backend, max_gpu_memory_gb=memory_budget_gb, cap_for_batching=True
|
|
119
|
+
)
|
|
120
|
+
n_alphas_batch, _ = _auto_batch_size(
|
|
121
|
+
n_alphas,
|
|
122
|
+
n_features * n_samples * itemsize,
|
|
123
|
+
budget_gb=budget_gb,
|
|
124
|
+
overhead=_WORKING_SET_OVERHEAD,
|
|
125
|
+
)
|
|
126
|
+
n_targets_batch, _ = _auto_batch_size(
|
|
127
|
+
n_targets,
|
|
128
|
+
n_alphas_batch * n_samples * itemsize,
|
|
129
|
+
budget_gb=budget_gb,
|
|
130
|
+
overhead=_WORKING_SET_OVERHEAD,
|
|
131
|
+
)
|
|
132
|
+
n_targets_batch_refit, _ = _auto_batch_size(
|
|
133
|
+
n_targets,
|
|
134
|
+
n_alphas_batch * n_features * itemsize,
|
|
135
|
+
budget_gb=budget_gb,
|
|
136
|
+
overhead=_WORKING_SET_OVERHEAD,
|
|
137
|
+
)
|
|
138
|
+
return {
|
|
139
|
+
"n_targets_batch": n_targets_batch,
|
|
140
|
+
"n_targets_batch_refit": n_targets_batch_refit,
|
|
141
|
+
"n_alphas_batch": n_alphas_batch,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _refit_targets_batch(
|
|
146
|
+
backend,
|
|
147
|
+
memory_budget_gb: float | None,
|
|
148
|
+
n_samples: int,
|
|
149
|
+
n_targets: int,
|
|
150
|
+
itemsize: int,
|
|
151
|
+
*,
|
|
152
|
+
per_target_alpha: bool,
|
|
153
|
+
n_features: int,
|
|
154
|
+
) -> int:
|
|
155
|
+
"""Size the target batch of the fixed-hyperparameter refit.
|
|
156
|
+
|
|
157
|
+
With one shared alpha Himalaya reuses a single shrinkage operator, so a
|
|
158
|
+
target costs only its own columns of `Y` and of the weights. With a
|
|
159
|
+
per-target alpha it instead holds an `(n_targets_batch, n_samples,
|
|
160
|
+
n_samples)` block, which dominates everything else.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
int: Target batch size in `[1, n_targets]`.
|
|
164
|
+
"""
|
|
165
|
+
budget_gb = _device_memory_budget(
|
|
166
|
+
backend, max_gpu_memory_gb=memory_budget_gb, cap_for_batching=True
|
|
167
|
+
)
|
|
168
|
+
if per_target_alpha:
|
|
169
|
+
bytes_per_target = n_samples * n_samples * itemsize
|
|
170
|
+
else:
|
|
171
|
+
bytes_per_target = (n_samples + n_features) * itemsize
|
|
172
|
+
batch, _ = _auto_batch_size(
|
|
173
|
+
n_targets,
|
|
174
|
+
bytes_per_target,
|
|
175
|
+
budget_gb=budget_gb,
|
|
176
|
+
overhead=_WORKING_SET_OVERHEAD,
|
|
177
|
+
)
|
|
178
|
+
return batch
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _prepare_feature_space_weights(candidates, dtype) -> np.ndarray:
|
|
182
|
+
"""Validate candidate feature-space weights and convert them for Himalaya.
|
|
183
|
+
|
|
184
|
+
Candidates must be finite, strictly positive, and sum to one per row before
|
|
185
|
+
conversion. After conversion to `dtype`, weights below that dtype's
|
|
186
|
+
smallest positive normal value are raised to it: that is the smallest
|
|
187
|
+
weight a float32 device can take `sqrt` of and then divide back out without
|
|
188
|
+
destroying the reusable feature buffer. The floor is a numerical boundary
|
|
189
|
+
only — it never makes a zero or negative weight valid.
|
|
190
|
+
|
|
191
|
+
The caller's array is never modified.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
candidates (array-like): Shape `(n_candidates, n_spaces)` weights on the
|
|
195
|
+
simplex.
|
|
196
|
+
dtype (np.dtype | type): Floating dtype the feature matrices use.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
np.ndarray: A new `(n_candidates, n_spaces)` array of `dtype`.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
ValueError: If the candidates are not 2-D, or any weight is
|
|
203
|
+
non-finite, not strictly positive, or a row does not sum to one.
|
|
204
|
+
"""
|
|
205
|
+
values = np.array(candidates, dtype=np.float64, copy=True)
|
|
206
|
+
if values.ndim != 2:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
"feature-space weight candidates must be 2D with shape "
|
|
209
|
+
f"(n_candidates, n_spaces), got {values.ndim}D"
|
|
210
|
+
)
|
|
211
|
+
if not np.all(np.isfinite(values)):
|
|
212
|
+
raise ValueError("feature-space weight candidates must all be finite")
|
|
213
|
+
if not np.all(values > 0):
|
|
214
|
+
raise ValueError(
|
|
215
|
+
"feature-space weight candidates must all be strictly positive"
|
|
216
|
+
)
|
|
217
|
+
row_sums = values.sum(axis=1)
|
|
218
|
+
if not np.allclose(row_sums, 1.0, rtol=0, atol=1e-6):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
"each feature-space weight candidate must sum to one; "
|
|
221
|
+
f"observed sums between {row_sums.min():.6g} and {row_sums.max():.6g}"
|
|
222
|
+
)
|
|
223
|
+
converted = values.astype(dtype, copy=True)
|
|
224
|
+
tiny = np.finfo(converted.dtype).tiny
|
|
225
|
+
converted[converted < tiny] = tiny
|
|
226
|
+
return converted
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _working_dtype(spaces, y, backend) -> np.dtype:
|
|
230
|
+
"""Choose the floating dtype a solve runs in.
|
|
231
|
+
|
|
232
|
+
Himalaya solves in the dtype it is handed, so this is the one rule every
|
|
233
|
+
entry point shares. An integer design would make the shrinkage arithmetic
|
|
234
|
+
truncate to zero and return silently wrong (all-zero) coefficients, and MPS
|
|
235
|
+
is a float32-only device that would otherwise downcast float64 inputs with
|
|
236
|
+
a warning on every call.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
spaces (Sequence[np.ndarray]): Feature matrices.
|
|
240
|
+
y (np.ndarray): Targets.
|
|
241
|
+
backend (Backend | None): Resolved backend, or None for the CPU.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
np.dtype: `float32` on MPS, otherwise the promoted dtype of the inputs
|
|
245
|
+
with a `float32` floor.
|
|
246
|
+
"""
|
|
247
|
+
if backend is not None and getattr(backend, "device", None) == "mps":
|
|
248
|
+
return np.dtype(np.float32)
|
|
249
|
+
dtypes = [np.asarray(space).dtype for space in spaces] + [np.asarray(y).dtype]
|
|
250
|
+
return np.dtype(np.promote_types(np.result_type(*dtypes), np.float32))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True)
|
|
254
|
+
class _ResidentDesign:
|
|
255
|
+
"""A concatenated design and its targets, already on Himalaya's backend.
|
|
256
|
+
|
|
257
|
+
Bootstrap refits solve thousands of replicates against the same training
|
|
258
|
+
data. Converting once and resampling rows in place keeps the host-to-device
|
|
259
|
+
transfer out of the replicate loop; on the CPU it also avoids re-running the
|
|
260
|
+
concatenation per replicate.
|
|
261
|
+
|
|
262
|
+
Attributes:
|
|
263
|
+
design: `(n_samples, n_features)` in `feature_space_names_` order, on
|
|
264
|
+
the backend.
|
|
265
|
+
targets: `(n_samples, n_targets)`, on the backend.
|
|
266
|
+
sizes (tuple[int, ...]): Feature count per space, in the same order.
|
|
267
|
+
dtype (np.dtype): The working dtype both arrays were converted to.
|
|
268
|
+
backend: The resolved nltools `_Backend`, or None for the CPU.
|
|
269
|
+
backend_name (str): Himalaya's name for that backend.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
design: Any
|
|
273
|
+
targets: Any
|
|
274
|
+
sizes: tuple[int, ...]
|
|
275
|
+
dtype: np.dtype
|
|
276
|
+
backend: Any
|
|
277
|
+
backend_name: str
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _resident_design(feature_spaces, y, backend=None) -> _ResidentDesign:
|
|
281
|
+
"""Convert a design and its targets onto the backend once.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
feature_spaces (Sequence[np.ndarray]): One or more `(n_samples,
|
|
285
|
+
n_features_k)` matrices in coefficient order.
|
|
286
|
+
y (np.ndarray): Targets of shape `(n_samples, n_targets)`.
|
|
287
|
+
backend (Backend | None): Resolved backend; None stays on the CPU.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
_ResidentDesign: The converted design, ready for repeated refits.
|
|
291
|
+
"""
|
|
292
|
+
spaces = [np.asarray(space) for space in feature_spaces]
|
|
293
|
+
y = np.asarray(y)
|
|
294
|
+
dtype = _working_dtype(spaces, y, backend)
|
|
295
|
+
backend_name = "numpy" if backend is None else _himalaya_backend_name(backend)
|
|
296
|
+
stacked = np.ascontiguousarray(
|
|
297
|
+
spaces[0] if len(spaces) == 1 else np.concatenate(spaces, axis=1), dtype=dtype
|
|
298
|
+
)
|
|
299
|
+
with _scoped_himalaya_backend(backend_name):
|
|
300
|
+
design, targets = _on_active_backend(
|
|
301
|
+
stacked, np.ascontiguousarray(y, dtype=dtype)
|
|
302
|
+
)
|
|
303
|
+
return _ResidentDesign(
|
|
304
|
+
design=design,
|
|
305
|
+
targets=targets,
|
|
306
|
+
sizes=tuple(space.shape[1] for space in spaces),
|
|
307
|
+
dtype=dtype,
|
|
308
|
+
backend=backend,
|
|
309
|
+
backend_name=backend_name,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _take_rows(array, indices):
|
|
314
|
+
"""Select rows of a backend-resident array with host integer indices.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
array: A NumPy array or a torch tensor on any device.
|
|
318
|
+
indices (np.ndarray | None): Row indices, or None to take every row.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
The selected rows, on the same backend and device as `array`.
|
|
322
|
+
"""
|
|
323
|
+
if indices is None:
|
|
324
|
+
return array
|
|
325
|
+
indices = np.asarray(indices, dtype=np.int64)
|
|
326
|
+
if hasattr(array, "detach"):
|
|
327
|
+
import torch
|
|
328
|
+
|
|
329
|
+
return array[torch.as_tensor(indices, device=array.device)]
|
|
330
|
+
return array[indices]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _take_columns(array, columns):
|
|
334
|
+
"""Select columns of a backend-resident array with host integer indices.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
array: A NumPy array or a torch tensor on any device.
|
|
338
|
+
columns (np.ndarray): Column indices.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
The selected columns, on the same backend and device as `array`.
|
|
342
|
+
"""
|
|
343
|
+
columns = np.asarray(columns, dtype=np.int64)
|
|
344
|
+
if hasattr(array, "detach"):
|
|
345
|
+
import torch
|
|
346
|
+
|
|
347
|
+
return array[:, torch.as_tensor(columns, device=array.device)]
|
|
348
|
+
return np.ascontiguousarray(array[:, columns])
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _weight_groups(feature_space_weights, n_targets):
|
|
352
|
+
"""Group targets that selected the same feature-space weight vector.
|
|
353
|
+
|
|
354
|
+
Sharing one decomposition across such targets is an implementation detail
|
|
355
|
+
that does not change the result.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
feature_space_weights (np.ndarray | None): `(n_spaces,)` shared or
|
|
359
|
+
`(n_spaces, n_targets)` per-target weights, or None.
|
|
360
|
+
n_targets (int): Number of targets.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
list[tuple[np.ndarray | None, np.ndarray]]: `(gamma, columns)` pairs;
|
|
364
|
+
`gamma` is None for the unweighted system.
|
|
365
|
+
"""
|
|
366
|
+
if feature_space_weights is None:
|
|
367
|
+
return [(None, np.arange(n_targets))]
|
|
368
|
+
gammas = np.asarray(feature_space_weights, dtype=np.float64)
|
|
369
|
+
# Every target sharing one weight vector is the common case (shared weights,
|
|
370
|
+
# or a search that converged on the same row). Skip the sort in np.unique.
|
|
371
|
+
if gammas.ndim == 1:
|
|
372
|
+
return [(gammas, np.arange(n_targets))]
|
|
373
|
+
if bool(np.all(gammas == gammas[:, :1])):
|
|
374
|
+
return [(gammas[:, 0], np.arange(n_targets))]
|
|
375
|
+
_, inverse = np.unique(gammas.T, axis=0, return_inverse=True)
|
|
376
|
+
inverse = np.asarray(inverse).ravel()
|
|
377
|
+
return [
|
|
378
|
+
(
|
|
379
|
+
gammas[:, np.flatnonzero(inverse == label)[0]],
|
|
380
|
+
np.flatnonzero(inverse == label),
|
|
381
|
+
)
|
|
382
|
+
for label in np.unique(inverse)
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _refit_fixed_hyperparameters(
|
|
387
|
+
feature_spaces,
|
|
388
|
+
y,
|
|
389
|
+
alpha,
|
|
390
|
+
feature_space_weights=None,
|
|
391
|
+
backend=None,
|
|
392
|
+
memory_budget_gb=None,
|
|
393
|
+
n_targets_batch=None,
|
|
394
|
+
row_indices=None,
|
|
395
|
+
):
|
|
396
|
+
"""Refit Ridge coefficients with the hyperparameters held fixed.
|
|
397
|
+
|
|
398
|
+
The one fixed-hyperparameter solve in the package: the final banded refit,
|
|
399
|
+
ordinary fixed-alpha fitting, and every Ridge bootstrap replicate use it, so
|
|
400
|
+
they cannot drift apart numerically. Feature space `k` is scaled by
|
|
401
|
+
`sqrt(gamma[k])` before the solve and the resulting coefficients are scaled
|
|
402
|
+
back, which is equivalent to the per-space penalty `alpha / gamma[k]`.
|
|
403
|
+
|
|
404
|
+
Targets that selected the same weight vector share one decomposition. The
|
|
405
|
+
grouping is an implementation detail and does not change the result.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
feature_spaces (Sequence[np.ndarray] | _ResidentDesign): One or more
|
|
409
|
+
`(n_samples, n_features_k)` matrices in coefficient order, or a
|
|
410
|
+
design already converted onto the backend by `_resident_design`.
|
|
411
|
+
y (np.ndarray | None): Targets of shape `(n_samples, n_targets)`. None
|
|
412
|
+
when `feature_spaces` is a `_ResidentDesign`, which carries them.
|
|
413
|
+
alpha (float | np.ndarray): Scalar or `(n_targets,)` regularization.
|
|
414
|
+
feature_space_weights (np.ndarray | None): `(n_spaces,)` shared or
|
|
415
|
+
`(n_spaces, n_targets)` weights. None solves the unweighted system.
|
|
416
|
+
backend (Backend | None): Resolved backend; None runs on the CPU.
|
|
417
|
+
Ignored when a `_ResidentDesign` is supplied, which records its own.
|
|
418
|
+
memory_budget_gb (float | None): Budget used to size the target batch
|
|
419
|
+
when `n_targets_batch` is not given.
|
|
420
|
+
n_targets_batch (int | None): Himalaya target batch size. None derives
|
|
421
|
+
one from the memory budget.
|
|
422
|
+
row_indices (np.ndarray | None): Rows to solve on, applied to the design
|
|
423
|
+
and the targets alike. None uses every row. Bootstrap replicates
|
|
424
|
+
pass their resample here so the design is converted only once.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
np.ndarray: Coefficients of shape `(n_features, n_targets)` in the
|
|
428
|
+
original, unscaled feature coordinates, as CPU NumPy.
|
|
429
|
+
"""
|
|
430
|
+
from himalaya.ridge import solve_ridge_svd
|
|
431
|
+
|
|
432
|
+
if isinstance(feature_spaces, _ResidentDesign):
|
|
433
|
+
resident = feature_spaces
|
|
434
|
+
else:
|
|
435
|
+
resident = _resident_design(feature_spaces, y, backend)
|
|
436
|
+
|
|
437
|
+
dtype = resident.dtype
|
|
438
|
+
sizes = resident.sizes
|
|
439
|
+
n_features = sum(sizes)
|
|
440
|
+
n_targets = resident.targets.shape[1]
|
|
441
|
+
n_samples = (
|
|
442
|
+
len(row_indices) if row_indices is not None else resident.design.shape[0]
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
alphas = np.broadcast_to(np.asarray(alpha, dtype=np.float64), (n_targets,))
|
|
446
|
+
coef = np.zeros((n_features, n_targets), dtype=np.float64)
|
|
447
|
+
|
|
448
|
+
with _scoped_himalaya_backend(resident.backend_name):
|
|
449
|
+
stacked = _take_rows(resident.design, row_indices)
|
|
450
|
+
all_targets = _take_rows(resident.targets, row_indices)
|
|
451
|
+
|
|
452
|
+
for gamma, columns in _weight_groups(feature_space_weights, n_targets):
|
|
453
|
+
if gamma is None:
|
|
454
|
+
design = stacked
|
|
455
|
+
scale = None
|
|
456
|
+
else:
|
|
457
|
+
scale = np.concatenate(
|
|
458
|
+
[np.full(size, np.sqrt(g)) for size, g in zip(sizes, gamma)]
|
|
459
|
+
).astype(dtype)
|
|
460
|
+
design = stacked * _on_active_backend(scale)[0]
|
|
461
|
+
# A shared alpha needs one shrinkage vector; a per-target alpha
|
|
462
|
+
# makes Himalaya hold an (n_targets_batch, n_samples, n_samples)
|
|
463
|
+
# block instead, so the two paths get different batch estimates.
|
|
464
|
+
group_alphas = alphas[columns]
|
|
465
|
+
shared_alpha = bool(np.all(group_alphas == group_alphas[0]))
|
|
466
|
+
batch = n_targets_batch
|
|
467
|
+
if batch is None:
|
|
468
|
+
batch = _refit_targets_batch(
|
|
469
|
+
resident.backend,
|
|
470
|
+
memory_budget_gb,
|
|
471
|
+
n_samples,
|
|
472
|
+
len(columns),
|
|
473
|
+
dtype.itemsize,
|
|
474
|
+
per_target_alpha=not shared_alpha,
|
|
475
|
+
n_features=n_features,
|
|
476
|
+
)
|
|
477
|
+
targets = (
|
|
478
|
+
all_targets
|
|
479
|
+
if len(columns) == n_targets
|
|
480
|
+
else _take_columns(all_targets, columns)
|
|
481
|
+
)
|
|
482
|
+
solved = solve_ridge_svd(
|
|
483
|
+
design,
|
|
484
|
+
targets,
|
|
485
|
+
alpha=dtype.type(group_alphas[0])
|
|
486
|
+
if shared_alpha
|
|
487
|
+
else group_alphas.astype(dtype),
|
|
488
|
+
fit_intercept=False,
|
|
489
|
+
n_targets_batch=batch,
|
|
490
|
+
warn=False,
|
|
491
|
+
)
|
|
492
|
+
solved = np.asarray(_to_cpu_numpy(solved), dtype=np.float64)
|
|
493
|
+
if scale is not None:
|
|
494
|
+
solved = solved * scale[:, None]
|
|
495
|
+
coef[:, columns] = solved
|
|
496
|
+
|
|
497
|
+
return coef
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _on_active_backend(*arrays):
|
|
501
|
+
"""Move arrays onto Himalaya's active backend and device.
|
|
502
|
+
|
|
503
|
+
Some Himalaya solvers build scratch arrays with `ones_like(X)`, which
|
|
504
|
+
assumes `X` already lives on the active backend. Converting up front keeps
|
|
505
|
+
the GPU backends usable with NumPy inputs.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
*arrays: NumPy arrays to convert.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
list: The arrays as the active backend's array type.
|
|
512
|
+
"""
|
|
513
|
+
from himalaya.backend import get_backend
|
|
514
|
+
|
|
515
|
+
backend = get_backend()
|
|
516
|
+
return [backend.asarray(array) for array in arrays]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _to_cpu_numpy(array) -> np.ndarray:
|
|
520
|
+
"""Return `array` as CPU NumPy, whatever backend produced it.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
array: A NumPy array or a torch tensor from any device.
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
np.ndarray: A NumPy view or copy on the host.
|
|
527
|
+
"""
|
|
528
|
+
if hasattr(array, "detach"):
|
|
529
|
+
return array.detach().cpu().numpy()
|
|
530
|
+
return np.asarray(array)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _snap_to_grid(values: np.ndarray, grid: np.ndarray) -> np.ndarray:
|
|
534
|
+
"""Round selected alphas back onto the candidate grid they came from.
|
|
535
|
+
|
|
536
|
+
Himalaya returns the selected alpha through a log/exp round trip, which on
|
|
537
|
+
a float32 device drifts by a few ULPs. Matching in log space recovers the
|
|
538
|
+
exact candidate the search chose.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
values (np.ndarray): Recovered alphas, shape `(n_targets,)`.
|
|
542
|
+
grid (np.ndarray): Candidate alphas, shape `(n_alphas,)`.
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
np.ndarray: Values drawn from `grid`, shape `(n_targets,)`.
|
|
546
|
+
"""
|
|
547
|
+
log_grid = np.log(np.asarray(grid, dtype=np.float64))
|
|
548
|
+
log_values = np.log(np.asarray(values, dtype=np.float64))
|
|
549
|
+
nearest = np.argmin(np.abs(log_grid[:, None] - log_values[None, :]), axis=0)
|
|
550
|
+
return np.asarray(grid, dtype=np.float64)[nearest]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class _Ridge:
|
|
554
|
+
"""Ridge regression over one or several named feature spaces.
|
|
555
|
+
|
|
556
|
+
Fits `argmin_b ||X @ b - y||^2 + alpha * ||b||^2` without an intercept.
|
|
557
|
+
Callers own preprocessing: `_Ridge` never centers, scales, standardizes, or
|
|
558
|
+
adds an intercept column. Constructor arguments are validated once, at
|
|
559
|
+
construction, and must not be reassigned afterwards.
|
|
560
|
+
|
|
561
|
+
A two-dimensional `X` fits ordinary Ridge. A mapping from names to
|
|
562
|
+
two-dimensional arrays fits banded Ridge, which searches feature-space
|
|
563
|
+
weights on the simplex jointly with the alphas.
|
|
564
|
+
|
|
565
|
+
Himalaya defines the numerical behavior: the cross-validation loss is
|
|
566
|
+
negative mean squared error, and alpha selection, the Dirichlet search, and
|
|
567
|
+
coefficient refitting all come from its solvers.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
alpha (float | Sequence[float] | np.ndarray): A positive finite scalar
|
|
571
|
+
fits a fixed alpha and requires `cv=None`. A non-empty
|
|
572
|
+
one-dimensional collection of positive finite values selects an
|
|
573
|
+
alpha by cross-validation and requires `cv`. Default: `1.0`.
|
|
574
|
+
cv (int | BaseCrossValidator | None): An integer builds unshuffled
|
|
575
|
+
K-fold splits; a reusable scikit-learn cross-validator is used as
|
|
576
|
+
given. A single-use split generator is invalid because fitting
|
|
577
|
+
traverses the splits more than once. Default: `None`.
|
|
578
|
+
search_iterations (int): Number of sampled feature-space weight vectors
|
|
579
|
+
for banded Ridge. Default: `100`.
|
|
580
|
+
dirichlet_concentration (float | Sequence[float]): Concentration
|
|
581
|
+
parameter(s) of the Dirichlet distribution the candidate weights are
|
|
582
|
+
drawn from. A list is cycled through across candidates.
|
|
583
|
+
Default: `(0.1, 1.0)`.
|
|
584
|
+
device (str): `'cpu'` or `'gpu'`. An explicit `'gpu'` resolves to CUDA
|
|
585
|
+
or MPS or raises; it never falls back to a CPU backend.
|
|
586
|
+
Default: `'cpu'`.
|
|
587
|
+
memory_budget_gb (float | None): Working-memory budget in GB used to
|
|
588
|
+
size Himalaya's internal batches. None measures the device with
|
|
589
|
+
conservative headroom. It is a budget, not a hard process limit.
|
|
590
|
+
Default: `None`.
|
|
591
|
+
per_target_alpha (bool): True selects the best alpha separately per
|
|
592
|
+
target; False averages each candidate's fold scores across targets
|
|
593
|
+
and selects one shared alpha. Default: `True`.
|
|
594
|
+
prefer_conservative_alpha (bool): True selects the largest alpha whose
|
|
595
|
+
mean score beats the best alpha's mean score minus that alpha's
|
|
596
|
+
standard deviation across folds. Invalid with
|
|
597
|
+
`per_target_alpha=False`. Default: `False`.
|
|
598
|
+
random_state (int | None): Seed for the banded random search only; the
|
|
599
|
+
cross-validator controls split randomness. Ordinary Ridge accepts it
|
|
600
|
+
and ignores it — it has no randomness of its own — so that
|
|
601
|
+
`BrainData.fit` can keep forwarding one shared `random_state` to
|
|
602
|
+
whichever estimator it builds. Default: `None`.
|
|
603
|
+
progress_bar (bool): Show a progress bar over the banded search.
|
|
604
|
+
Default: `False`.
|
|
605
|
+
|
|
606
|
+
Attributes:
|
|
607
|
+
coef_ (np.ndarray): `(n_features,)` for one-dimensional `y`, otherwise
|
|
608
|
+
`(n_features, n_targets)`, in concatenated feature-space order.
|
|
609
|
+
alpha_ (float | np.ndarray): Scalar for a fixed or shared alpha,
|
|
610
|
+
otherwise `(n_targets,)`.
|
|
611
|
+
cv_scores_ (float | np.ndarray | None): None for a fixed-alpha fit. For
|
|
612
|
+
ordinary Ridge, the fold-averaged negative-MSE score at the selected
|
|
613
|
+
alpha. For banded Ridge, `(search_iterations,)` or
|
|
614
|
+
`(search_iterations, n_targets)` fold-averaged scores.
|
|
615
|
+
feature_space_weights_ (np.ndarray | None): None for ordinary Ridge.
|
|
616
|
+
Strictly positive weights whose columns sum to one, shaped
|
|
617
|
+
`(n_spaces,)` or `(n_spaces, n_targets)`.
|
|
618
|
+
feature_space_names_ (tuple[str, ...] | None): Fitted mapping keys in
|
|
619
|
+
coefficient order; None for ordinary Ridge.
|
|
620
|
+
feature_space_sizes_ (tuple[int, ...] | None): Feature counts aligned
|
|
621
|
+
with `feature_space_names_`; None for ordinary Ridge.
|
|
622
|
+
backend_ (Backend): The resolved execution backend.
|
|
623
|
+
n_samples_ (int): Fitted sample count.
|
|
624
|
+
n_features_in_ (int): Total fitted feature count across spaces.
|
|
625
|
+
is_fitted_ (bool): True after a successful fit.
|
|
626
|
+
|
|
627
|
+
Examples:
|
|
628
|
+
```python
|
|
629
|
+
import numpy as np
|
|
630
|
+
from nltools.models import Ridge
|
|
631
|
+
|
|
632
|
+
X = np.random.randn(100, 50)
|
|
633
|
+
y = np.random.randn(100)
|
|
634
|
+
|
|
635
|
+
model = _Ridge(alpha=1.0).fit(X, y)
|
|
636
|
+
predictions = model.predict(X)
|
|
637
|
+
|
|
638
|
+
# Banded ridge over two named feature spaces
|
|
639
|
+
spaces = {"motion": np.random.randn(100, 6), "task": np.random.randn(100, 12)}
|
|
640
|
+
banded = _Ridge(alpha=[1.0, 10.0, 100.0], cv=5, search_iterations=20)
|
|
641
|
+
banded.fit(spaces, y)
|
|
642
|
+
print(banded.feature_space_weights_)
|
|
643
|
+
```
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
def __init__(
|
|
647
|
+
self,
|
|
648
|
+
*,
|
|
649
|
+
alpha: float | Sequence[float] | np.ndarray = 1.0,
|
|
650
|
+
cv=None,
|
|
651
|
+
search_iterations: int = 100,
|
|
652
|
+
dirichlet_concentration: float | Sequence[float] = (0.1, 1.0),
|
|
653
|
+
device: str = "cpu",
|
|
654
|
+
memory_budget_gb: float | None = None,
|
|
655
|
+
per_target_alpha: bool = True,
|
|
656
|
+
prefer_conservative_alpha: bool = False,
|
|
657
|
+
random_state: int | None = None,
|
|
658
|
+
progress_bar: bool = False,
|
|
659
|
+
) -> None:
|
|
660
|
+
self.alpha = alpha
|
|
661
|
+
self.cv = cv
|
|
662
|
+
self.search_iterations = search_iterations
|
|
663
|
+
self.dirichlet_concentration = dirichlet_concentration
|
|
664
|
+
self.device = device
|
|
665
|
+
self.memory_budget_gb = memory_budget_gb
|
|
666
|
+
self.per_target_alpha = per_target_alpha
|
|
667
|
+
self.prefer_conservative_alpha = prefer_conservative_alpha
|
|
668
|
+
self.random_state = random_state
|
|
669
|
+
self.progress_bar = progress_bar
|
|
670
|
+
self.is_fitted_ = False
|
|
671
|
+
#: The normalized alpha `fit` solves with; `alpha` is validated once here.
|
|
672
|
+
self._normalized_alpha = self._validate_parameters()
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------- validation
|
|
675
|
+
|
|
676
|
+
def _validate_parameters(self) -> np.ndarray | float:
|
|
677
|
+
"""Check the constructor arguments and return the normalized alpha.
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
float | np.ndarray: The scalar alpha, or the one-dimensional array
|
|
681
|
+
of candidate alphas.
|
|
682
|
+
|
|
683
|
+
Raises:
|
|
684
|
+
ValueError: If any argument or combination of arguments is invalid.
|
|
685
|
+
"""
|
|
686
|
+
if self.device not in ("cpu", "gpu"):
|
|
687
|
+
raise ValueError(
|
|
688
|
+
f"device must be 'cpu' or 'gpu', got {self.device!r}; "
|
|
689
|
+
"there is no 'auto' device for Ridge"
|
|
690
|
+
)
|
|
691
|
+
if self.memory_budget_gb is not None:
|
|
692
|
+
budget = self.memory_budget_gb
|
|
693
|
+
if not isinstance(budget, numbers.Real) or isinstance(budget, bool):
|
|
694
|
+
raise ValueError(
|
|
695
|
+
f"memory_budget_gb must be a positive number or None, "
|
|
696
|
+
f"got {budget!r}"
|
|
697
|
+
)
|
|
698
|
+
if not np.isfinite(budget) or budget <= 0:
|
|
699
|
+
raise ValueError(
|
|
700
|
+
f"memory_budget_gb must be positive and finite, got {budget!r}"
|
|
701
|
+
)
|
|
702
|
+
if self.prefer_conservative_alpha and not self.per_target_alpha:
|
|
703
|
+
raise ValueError(
|
|
704
|
+
"prefer_conservative_alpha=True requires per_target_alpha=True; "
|
|
705
|
+
"the conservative rule needs per-target fold scores"
|
|
706
|
+
)
|
|
707
|
+
if not isinstance(self.search_iterations, numbers.Integral) or isinstance(
|
|
708
|
+
self.search_iterations, bool
|
|
709
|
+
):
|
|
710
|
+
raise ValueError(
|
|
711
|
+
f"search_iterations must be a positive int, "
|
|
712
|
+
f"got {self.search_iterations!r}"
|
|
713
|
+
)
|
|
714
|
+
if self.search_iterations < 1:
|
|
715
|
+
raise ValueError(
|
|
716
|
+
f"search_iterations must be at least 1, got {self.search_iterations}"
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
alpha = self._validate_alpha()
|
|
720
|
+
self._validate_cv()
|
|
721
|
+
|
|
722
|
+
scalar_alpha = np.isscalar(alpha) or np.ndim(alpha) == 0
|
|
723
|
+
if scalar_alpha and self.cv is not None:
|
|
724
|
+
raise ValueError(
|
|
725
|
+
f"scalar alpha={alpha!r} fits a fixed alpha and requires cv=None; "
|
|
726
|
+
f"got cv={self.cv!r}. Pass a sequence of alphas to select one."
|
|
727
|
+
)
|
|
728
|
+
if not scalar_alpha and self.cv is None:
|
|
729
|
+
raise ValueError(
|
|
730
|
+
"a sequence of alphas requires cv to select among them; got cv=None. "
|
|
731
|
+
"Pass a scalar alpha for a fixed-alpha fit."
|
|
732
|
+
)
|
|
733
|
+
return alpha
|
|
734
|
+
|
|
735
|
+
def _validate_alpha(self) -> np.ndarray | float:
|
|
736
|
+
"""Normalize `alpha` into a positive scalar or a 1-D candidate array.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
float | np.ndarray: The validated alpha.
|
|
740
|
+
|
|
741
|
+
Raises:
|
|
742
|
+
ValueError: If `alpha` is `"auto"`, empty, multidimensional, or
|
|
743
|
+
holds non-finite or non-positive values.
|
|
744
|
+
"""
|
|
745
|
+
alpha = self.alpha
|
|
746
|
+
if isinstance(alpha, str):
|
|
747
|
+
raise ValueError(
|
|
748
|
+
f"alpha must be a positive number or a sequence of positive "
|
|
749
|
+
f"numbers, got {alpha!r}; alpha='auto' is not supported — pass "
|
|
750
|
+
"the candidate alphas and a cv"
|
|
751
|
+
)
|
|
752
|
+
if isinstance(alpha, numbers.Real) and not isinstance(alpha, bool):
|
|
753
|
+
if not np.isfinite(alpha) or alpha <= 0:
|
|
754
|
+
raise ValueError(f"alpha must be positive and finite, got {alpha!r}")
|
|
755
|
+
return float(alpha)
|
|
756
|
+
|
|
757
|
+
values = np.asarray(alpha, dtype=np.float64)
|
|
758
|
+
if values.ndim == 0:
|
|
759
|
+
if not np.isfinite(values) or values <= 0:
|
|
760
|
+
raise ValueError(f"alpha must be positive and finite, got {alpha!r}")
|
|
761
|
+
return float(values)
|
|
762
|
+
if values.ndim != 1:
|
|
763
|
+
raise ValueError(
|
|
764
|
+
f"alpha must be a scalar or a 1D collection, got a "
|
|
765
|
+
f"{values.ndim}D array of shape {values.shape}"
|
|
766
|
+
)
|
|
767
|
+
if values.size == 0:
|
|
768
|
+
raise ValueError("alpha must not be an empty collection")
|
|
769
|
+
if not np.all(np.isfinite(values)):
|
|
770
|
+
raise ValueError(f"alpha values must all be finite, got {alpha!r}")
|
|
771
|
+
if not np.all(values > 0):
|
|
772
|
+
raise ValueError(f"alpha values must all be positive, got {alpha!r}")
|
|
773
|
+
return values
|
|
774
|
+
|
|
775
|
+
def _validate_cv(self) -> None:
|
|
776
|
+
"""Check that `cv` is None, an int fold count, or a reusable splitter.
|
|
777
|
+
|
|
778
|
+
Raises:
|
|
779
|
+
ValueError: If `cv` is an integer below 2 or an unsupported type.
|
|
780
|
+
TypeError: If `cv` is a single-use split generator.
|
|
781
|
+
"""
|
|
782
|
+
cv = self.cv
|
|
783
|
+
if cv is None:
|
|
784
|
+
return
|
|
785
|
+
is_splitter = hasattr(cv, "split") and hasattr(cv, "get_n_splits")
|
|
786
|
+
if hasattr(cv, "__next__") and not is_splitter:
|
|
787
|
+
raise TypeError(
|
|
788
|
+
"cv got a single-use generator (e.g. `splitter.split(X)`). Pass "
|
|
789
|
+
"the splitter itself — KFold(5), GroupKFold(8) — because fitting "
|
|
790
|
+
"iterates the splits more than once."
|
|
791
|
+
)
|
|
792
|
+
if is_splitter:
|
|
793
|
+
return
|
|
794
|
+
if isinstance(cv, numbers.Integral) and not isinstance(cv, bool):
|
|
795
|
+
if cv < 2:
|
|
796
|
+
raise ValueError(f"cv must be at least 2 folds, got {cv}")
|
|
797
|
+
return
|
|
798
|
+
raise ValueError(
|
|
799
|
+
f"cv must be None, an int fold count, or a scikit-learn "
|
|
800
|
+
f"cross-validator, got {cv!r}"
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
def _resolved_cv(self):
|
|
804
|
+
"""Return the cross-validator Himalaya should use.
|
|
805
|
+
|
|
806
|
+
Returns:
|
|
807
|
+
BaseCrossValidator | None: An unshuffled `KFold` for an int `cv`,
|
|
808
|
+
the caller's splitter, or None.
|
|
809
|
+
"""
|
|
810
|
+
if self.cv is None:
|
|
811
|
+
return None
|
|
812
|
+
if isinstance(self.cv, numbers.Integral) and not isinstance(self.cv, bool):
|
|
813
|
+
from sklearn.model_selection import KFold
|
|
814
|
+
|
|
815
|
+
return KFold(n_splits=int(self.cv), shuffle=False)
|
|
816
|
+
return self.cv
|
|
817
|
+
|
|
818
|
+
def _check_banded_only_arguments_unused(self) -> None:
|
|
819
|
+
"""Reject banded-only arguments left at non-default values.
|
|
820
|
+
|
|
821
|
+
Raises:
|
|
822
|
+
ValueError: If a banded-only argument was set while fitting
|
|
823
|
+
ordinary Ridge.
|
|
824
|
+
"""
|
|
825
|
+
for name, default in _BANDED_ONLY_DEFAULTS.items():
|
|
826
|
+
value = getattr(self, name)
|
|
827
|
+
if isinstance(default, tuple):
|
|
828
|
+
same = isinstance(value, (tuple, list)) and tuple(value) == default
|
|
829
|
+
else:
|
|
830
|
+
same = value == default
|
|
831
|
+
if not same:
|
|
832
|
+
raise ValueError(
|
|
833
|
+
f"{name}={value!r} only applies to banded Ridge, but X is a "
|
|
834
|
+
"single feature matrix. Pass a mapping of named feature "
|
|
835
|
+
f"spaces, or leave {name} at its default {default!r}."
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
# -------------------------------------------------------------------- inputs
|
|
839
|
+
|
|
840
|
+
@staticmethod
|
|
841
|
+
def _as_feature_spaces(X):
|
|
842
|
+
"""Split `X` into ordered feature spaces and their names.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
X (np.ndarray | Mapping[str, np.ndarray]): A single feature matrix
|
|
846
|
+
or a mapping of named feature spaces.
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
tuple[list[np.ndarray], tuple[str, ...] | None]: The matrices in
|
|
850
|
+
coefficient order and their names, or None for ordinary Ridge.
|
|
851
|
+
|
|
852
|
+
Raises:
|
|
853
|
+
ValueError: If `X` is an empty mapping, has a non-string name, or
|
|
854
|
+
holds anything but equally sampled 2-D numeric arrays.
|
|
855
|
+
"""
|
|
856
|
+
if isinstance(X, Mapping):
|
|
857
|
+
if len(X) == 0:
|
|
858
|
+
raise ValueError(
|
|
859
|
+
"X is an empty mapping; banded Ridge needs at least one "
|
|
860
|
+
"named feature space"
|
|
861
|
+
)
|
|
862
|
+
names = []
|
|
863
|
+
spaces = []
|
|
864
|
+
for name, space in X.items():
|
|
865
|
+
if not isinstance(name, str):
|
|
866
|
+
raise ValueError(
|
|
867
|
+
f"feature-space names must be strings, got {name!r} of "
|
|
868
|
+
f"type {type(name).__name__}"
|
|
869
|
+
)
|
|
870
|
+
array = np.asarray(space)
|
|
871
|
+
if array.ndim != 2:
|
|
872
|
+
raise ValueError(
|
|
873
|
+
f"feature space {name!r} must be a 2D array, got "
|
|
874
|
+
f"{array.ndim}D with shape {array.shape}"
|
|
875
|
+
)
|
|
876
|
+
names.append(name)
|
|
877
|
+
spaces.append(array)
|
|
878
|
+
sample_counts = {name: s.shape[0] for name, s in zip(names, spaces)}
|
|
879
|
+
if len(set(sample_counts.values())) > 1:
|
|
880
|
+
raise ValueError(
|
|
881
|
+
"all banded feature spaces must have the same number of "
|
|
882
|
+
f"samples, got {sample_counts}"
|
|
883
|
+
)
|
|
884
|
+
return spaces, tuple(names)
|
|
885
|
+
|
|
886
|
+
try:
|
|
887
|
+
array = np.asarray(X)
|
|
888
|
+
except (TypeError, ValueError) as error:
|
|
889
|
+
raise ValueError(
|
|
890
|
+
"X must be a 2D feature matrix or a mapping of names to 2D "
|
|
891
|
+
f"feature matrices; could not interpret it as an array ({error})"
|
|
892
|
+
) from None
|
|
893
|
+
if array.dtype == object or array.ndim != 2:
|
|
894
|
+
raise ValueError(
|
|
895
|
+
"X must be a 2D feature matrix or a mapping of names to 2D "
|
|
896
|
+
f"feature matrices, got an array with shape {array.shape} and "
|
|
897
|
+
f"dtype {array.dtype}"
|
|
898
|
+
)
|
|
899
|
+
return [array], None
|
|
900
|
+
|
|
901
|
+
# ----------------------------------------------------------------------- fit
|
|
902
|
+
|
|
903
|
+
def fit(self, X, y) -> _Ridge:
|
|
904
|
+
"""Fit the model.
|
|
905
|
+
|
|
906
|
+
Args:
|
|
907
|
+
X (np.ndarray | Mapping[str, np.ndarray]): A `(n_samples,
|
|
908
|
+
n_features)` matrix for ordinary Ridge, or a non-empty mapping
|
|
909
|
+
of unique names to equally sampled 2-D matrices for banded
|
|
910
|
+
Ridge.
|
|
911
|
+
y (np.ndarray): Targets of shape `(n_samples,)` or `(n_samples,
|
|
912
|
+
n_targets)`.
|
|
913
|
+
|
|
914
|
+
Returns:
|
|
915
|
+
_Ridge: `self`.
|
|
916
|
+
|
|
917
|
+
Raises:
|
|
918
|
+
ValueError: If any input or argument combination is invalid.
|
|
919
|
+
RuntimeError: If `device='gpu'` and no accelerator is available.
|
|
920
|
+
"""
|
|
921
|
+
alpha = self._normalized_alpha
|
|
922
|
+
spaces, names = self._as_feature_spaces(X)
|
|
923
|
+
is_banded = names is not None
|
|
924
|
+
|
|
925
|
+
y = np.asarray(y)
|
|
926
|
+
if y.ndim not in (1, 2):
|
|
927
|
+
raise ValueError(f"y must be 1D or 2D, got {y.ndim}D array")
|
|
928
|
+
n_samples = spaces[0].shape[0]
|
|
929
|
+
if y.shape[0] != n_samples:
|
|
930
|
+
raise ValueError(
|
|
931
|
+
f"X and y have inconsistent sample counts: X has {n_samples}, "
|
|
932
|
+
f"y has {y.shape[0]}"
|
|
933
|
+
)
|
|
934
|
+
y_was_1d = y.ndim == 1
|
|
935
|
+
y_2d = y[:, None] if y_was_1d else y
|
|
936
|
+
if y_2d.shape[1] < 1:
|
|
937
|
+
raise ValueError(
|
|
938
|
+
f"y must have at least one target column, got shape {y.shape}"
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
scalar_alpha = np.ndim(alpha) == 0
|
|
942
|
+
if is_banded:
|
|
943
|
+
if scalar_alpha:
|
|
944
|
+
raise ValueError(
|
|
945
|
+
f"banded Ridge needs a sequence of candidate alphas and an "
|
|
946
|
+
f"explicit cv, got scalar alpha={alpha!r}"
|
|
947
|
+
)
|
|
948
|
+
else:
|
|
949
|
+
self._check_banded_only_arguments_unused()
|
|
950
|
+
|
|
951
|
+
backend = _resolve_backend("cpu" if self.device == "cpu" else "gpu")
|
|
952
|
+
dtype = _working_dtype(spaces, y_2d, backend)
|
|
953
|
+
spaces = [np.ascontiguousarray(space, dtype=dtype) for space in spaces]
|
|
954
|
+
targets = np.ascontiguousarray(y_2d, dtype=dtype)
|
|
955
|
+
|
|
956
|
+
sizes = tuple(space.shape[1] for space in spaces)
|
|
957
|
+
n_features = int(sum(sizes))
|
|
958
|
+
n_targets = targets.shape[1]
|
|
959
|
+
alphas = np.atleast_1d(np.asarray(alpha, dtype=np.float64))
|
|
960
|
+
|
|
961
|
+
self.backend_ = backend
|
|
962
|
+
if scalar_alpha:
|
|
963
|
+
# The fixed-alpha refit sizes its own batch from the same budget; it
|
|
964
|
+
# never runs the cross-validation or alpha loops the others measure.
|
|
965
|
+
self._fit_fixed_alpha(spaces, targets, float(alpha))
|
|
966
|
+
else:
|
|
967
|
+
try:
|
|
968
|
+
batches = _batch_sizes(
|
|
969
|
+
backend,
|
|
970
|
+
self.memory_budget_gb,
|
|
971
|
+
n_samples=n_samples,
|
|
972
|
+
n_features=n_features,
|
|
973
|
+
n_targets=n_targets,
|
|
974
|
+
n_alphas=alphas.size,
|
|
975
|
+
itemsize=dtype.itemsize,
|
|
976
|
+
)
|
|
977
|
+
except ValueError as error:
|
|
978
|
+
raise ValueError(
|
|
979
|
+
f"memory_budget_gb={self.memory_budget_gb!r} is too small for a "
|
|
980
|
+
f"fit with n_samples={n_samples}, n_features={n_features}, "
|
|
981
|
+
f"n_targets={n_targets}, n_alphas={alphas.size} ({error})"
|
|
982
|
+
) from error
|
|
983
|
+
if is_banded:
|
|
984
|
+
self._fit_banded(spaces, targets, alphas, dtype, batches)
|
|
985
|
+
else:
|
|
986
|
+
self._fit_ordinary_cv(spaces, targets, alphas, dtype, batches)
|
|
987
|
+
|
|
988
|
+
self.feature_space_names_ = names
|
|
989
|
+
self.feature_space_sizes_ = sizes if is_banded else None
|
|
990
|
+
self.n_samples_ = int(n_samples)
|
|
991
|
+
self.n_features_in_ = n_features
|
|
992
|
+
if y_was_1d:
|
|
993
|
+
self._squeeze_single_target()
|
|
994
|
+
self.is_fitted_ = True
|
|
995
|
+
return self
|
|
996
|
+
|
|
997
|
+
def _fit_fixed_alpha(self, spaces, targets, alpha) -> None:
|
|
998
|
+
"""Solve a fixed-alpha ordinary Ridge and store the fitted state.
|
|
999
|
+
|
|
1000
|
+
Args:
|
|
1001
|
+
spaces (list[np.ndarray]): One feature matrix.
|
|
1002
|
+
targets (np.ndarray): `(n_samples, n_targets)` targets.
|
|
1003
|
+
alpha (float): The fixed regularization strength.
|
|
1004
|
+
"""
|
|
1005
|
+
self.coef_ = _refit_fixed_hyperparameters(
|
|
1006
|
+
spaces,
|
|
1007
|
+
targets,
|
|
1008
|
+
alpha,
|
|
1009
|
+
backend=self.backend_,
|
|
1010
|
+
memory_budget_gb=self.memory_budget_gb,
|
|
1011
|
+
)
|
|
1012
|
+
self.alpha_ = float(alpha)
|
|
1013
|
+
self.cv_scores_ = None
|
|
1014
|
+
self.feature_space_weights_ = None
|
|
1015
|
+
|
|
1016
|
+
def _fit_ordinary_cv(self, spaces, targets, alphas, dtype, batches) -> None:
|
|
1017
|
+
"""Select an alpha by cross-validation and store the fitted state.
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
spaces (list[np.ndarray]): One feature matrix.
|
|
1021
|
+
targets (np.ndarray): `(n_samples, n_targets)` targets.
|
|
1022
|
+
alphas (np.ndarray): Candidate alphas.
|
|
1023
|
+
dtype (np.dtype): Working dtype.
|
|
1024
|
+
batches (dict[str, int]): Himalaya batch sizes.
|
|
1025
|
+
"""
|
|
1026
|
+
from himalaya.ridge import solve_ridge_cv_svd
|
|
1027
|
+
from himalaya.scoring import l2_neg_loss
|
|
1028
|
+
|
|
1029
|
+
with _scoped_himalaya_backend(_himalaya_backend_name(self.backend_)):
|
|
1030
|
+
design, y_device, alpha_device = _on_active_backend(
|
|
1031
|
+
spaces[0], targets, alphas.astype(dtype)
|
|
1032
|
+
)
|
|
1033
|
+
best_alphas, coefs, cv_scores = solve_ridge_cv_svd(
|
|
1034
|
+
design,
|
|
1035
|
+
y_device,
|
|
1036
|
+
alphas=alpha_device,
|
|
1037
|
+
fit_intercept=False,
|
|
1038
|
+
score_func=l2_neg_loss,
|
|
1039
|
+
cv=self._resolved_cv(),
|
|
1040
|
+
local_alpha=self.per_target_alpha,
|
|
1041
|
+
conservative=self.prefer_conservative_alpha,
|
|
1042
|
+
warn=False,
|
|
1043
|
+
**batches,
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
self.coef_ = np.asarray(_to_cpu_numpy(coefs), dtype=np.float64)
|
|
1047
|
+
selected = _snap_to_grid(_to_cpu_numpy(best_alphas), alphas)
|
|
1048
|
+
self.alpha_ = float(selected[0]) if not self.per_target_alpha else selected
|
|
1049
|
+
self.cv_scores_ = np.asarray(
|
|
1050
|
+
_to_cpu_numpy(cv_scores), dtype=np.float64
|
|
1051
|
+
).reshape(-1)
|
|
1052
|
+
self.feature_space_weights_ = None
|
|
1053
|
+
|
|
1054
|
+
def _fit_banded(self, spaces, targets, alphas, dtype, batches) -> None:
|
|
1055
|
+
"""Run the banded random search and store the fitted state.
|
|
1056
|
+
|
|
1057
|
+
Args:
|
|
1058
|
+
spaces (list[np.ndarray]): Feature matrices in coefficient order.
|
|
1059
|
+
targets (np.ndarray): `(n_samples, n_targets)` targets.
|
|
1060
|
+
alphas (np.ndarray): Candidate alphas.
|
|
1061
|
+
dtype (np.dtype): Working dtype.
|
|
1062
|
+
batches (dict[str, int]): Himalaya batch sizes.
|
|
1063
|
+
"""
|
|
1064
|
+
from himalaya.kernel_ridge import generate_dirichlet_samples
|
|
1065
|
+
from himalaya.ridge import solve_group_ridge_random_search
|
|
1066
|
+
from himalaya.scoring import l2_neg_loss
|
|
1067
|
+
|
|
1068
|
+
# Himalaya's sampler ends with `get_backend().asarray(gammas)`, so the
|
|
1069
|
+
# candidates would otherwise take the dtype and device of whatever
|
|
1070
|
+
# backend happened to be globally active. They are validated and clamped
|
|
1071
|
+
# on the host, so draw them under an explicit numpy scope.
|
|
1072
|
+
with _scoped_himalaya_backend("numpy"):
|
|
1073
|
+
candidates = _to_cpu_numpy(
|
|
1074
|
+
generate_dirichlet_samples(
|
|
1075
|
+
n_samples=self.search_iterations,
|
|
1076
|
+
n_kernels=len(spaces),
|
|
1077
|
+
concentration=self._concentration_for_himalaya(),
|
|
1078
|
+
random_state=self.random_state,
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
candidates = _prepare_feature_space_weights(candidates, dtype)
|
|
1082
|
+
|
|
1083
|
+
with _scoped_himalaya_backend(_himalaya_backend_name(self.backend_)):
|
|
1084
|
+
converted = _on_active_backend(*spaces, targets, alphas.astype(dtype))
|
|
1085
|
+
designs, y_device, alpha_device = (
|
|
1086
|
+
converted[:-2],
|
|
1087
|
+
converted[-2],
|
|
1088
|
+
converted[-1],
|
|
1089
|
+
)
|
|
1090
|
+
deltas, refit_weights, cv_scores = solve_group_ridge_random_search(
|
|
1091
|
+
designs,
|
|
1092
|
+
y_device,
|
|
1093
|
+
n_iter=candidates,
|
|
1094
|
+
alphas=alpha_device,
|
|
1095
|
+
fit_intercept=False,
|
|
1096
|
+
score_func=l2_neg_loss,
|
|
1097
|
+
cv=self._resolved_cv(),
|
|
1098
|
+
return_weights=True,
|
|
1099
|
+
local_alpha=self.per_target_alpha,
|
|
1100
|
+
random_state=self.random_state,
|
|
1101
|
+
progress_bar=self.progress_bar,
|
|
1102
|
+
conservative=self.prefer_conservative_alpha,
|
|
1103
|
+
warn=False,
|
|
1104
|
+
**batches,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
deltas = np.asarray(_to_cpu_numpy(deltas), dtype=np.float64)
|
|
1108
|
+
self.coef_ = np.asarray(_to_cpu_numpy(refit_weights), dtype=np.float64)
|
|
1109
|
+
self.cv_scores_ = np.asarray(_to_cpu_numpy(cv_scores), dtype=np.float64)
|
|
1110
|
+
|
|
1111
|
+
# deltas = log(gamma / alpha) with each gamma column summing to one, so
|
|
1112
|
+
# the simplex weights and the selected alpha both fall out of a
|
|
1113
|
+
# log-sum-exp over the spaces.
|
|
1114
|
+
shifted = deltas - deltas.max(axis=0, keepdims=True)
|
|
1115
|
+
weights = np.exp(shifted)
|
|
1116
|
+
self.feature_space_weights_ = weights / weights.sum(axis=0, keepdims=True)
|
|
1117
|
+
log_total = deltas.max(axis=0) + np.log(np.exp(shifted).sum(axis=0))
|
|
1118
|
+
selected = _snap_to_grid(np.exp(-log_total), alphas)
|
|
1119
|
+
self.alpha_ = selected if self.per_target_alpha else float(selected[0])
|
|
1120
|
+
|
|
1121
|
+
def _concentration_for_himalaya(self):
|
|
1122
|
+
"""Return `dirichlet_concentration` in the form Himalaya's sampler takes.
|
|
1123
|
+
|
|
1124
|
+
Returns:
|
|
1125
|
+
float | list[float]: A scalar, or a list Himalaya cycles through.
|
|
1126
|
+
"""
|
|
1127
|
+
values = np.atleast_1d(
|
|
1128
|
+
np.asarray(self.dirichlet_concentration, dtype=np.float64)
|
|
1129
|
+
)
|
|
1130
|
+
if values.size == 1:
|
|
1131
|
+
return float(values[0])
|
|
1132
|
+
return [float(value) for value in values]
|
|
1133
|
+
|
|
1134
|
+
def _squeeze_single_target(self) -> None:
|
|
1135
|
+
"""Drop the trailing target axis after fitting one-dimensional `y`."""
|
|
1136
|
+
self.coef_ = self.coef_[:, 0]
|
|
1137
|
+
if isinstance(self.alpha_, np.ndarray):
|
|
1138
|
+
self.alpha_ = float(self.alpha_[0])
|
|
1139
|
+
if self.cv_scores_ is not None:
|
|
1140
|
+
self.cv_scores_ = (
|
|
1141
|
+
float(self.cv_scores_[0])
|
|
1142
|
+
if self.cv_scores_.ndim == 1
|
|
1143
|
+
else self.cv_scores_[:, 0]
|
|
1144
|
+
)
|
|
1145
|
+
if self.feature_space_weights_ is not None:
|
|
1146
|
+
self.feature_space_weights_ = self.feature_space_weights_[:, 0]
|
|
1147
|
+
|
|
1148
|
+
# ------------------------------------------------------------------- predict
|
|
1149
|
+
|
|
1150
|
+
def _design_matrix(self, X) -> np.ndarray:
|
|
1151
|
+
"""Align `X` to the fitted feature structure and concatenate it.
|
|
1152
|
+
|
|
1153
|
+
Args:
|
|
1154
|
+
X (np.ndarray | Mapping[str, np.ndarray]): Prediction features in
|
|
1155
|
+
the structure used for fitting.
|
|
1156
|
+
|
|
1157
|
+
Returns:
|
|
1158
|
+
np.ndarray: A `(n_samples, n_features_in_)` matrix in fitted order.
|
|
1159
|
+
|
|
1160
|
+
Raises:
|
|
1161
|
+
ValueError: If the structure, names, or feature counts differ from
|
|
1162
|
+
the fitted model.
|
|
1163
|
+
"""
|
|
1164
|
+
spaces = self._aligned_feature_spaces(X)
|
|
1165
|
+
return spaces[0] if len(spaces) == 1 else np.concatenate(spaces, axis=1)
|
|
1166
|
+
|
|
1167
|
+
def _aligned_feature_spaces(self, X) -> list[np.ndarray]:
|
|
1168
|
+
"""Align `X` to the fitted feature structure, one matrix per space.
|
|
1169
|
+
|
|
1170
|
+
The single place that validates prediction and bootstrap features
|
|
1171
|
+
against the fitted model: `_design_matrix` concatenates the result, and
|
|
1172
|
+
the `BrainData` Ridge bootstrap resamples the spaces separately so a
|
|
1173
|
+
banded refit can rescale each one by its own simplex weight.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
X (np.ndarray | Mapping[str, np.ndarray]): Features in the
|
|
1177
|
+
structure used for fitting. A banded mapping may be in any
|
|
1178
|
+
order; it is aligned to `feature_space_names_`.
|
|
1179
|
+
|
|
1180
|
+
Returns:
|
|
1181
|
+
list[np.ndarray]: One `(n_samples, n_features_k)` matrix per fitted
|
|
1182
|
+
feature space, in coefficient order.
|
|
1183
|
+
|
|
1184
|
+
Raises:
|
|
1185
|
+
ValueError: If the structure, names, feature counts, or sample
|
|
1186
|
+
counts differ from the fitted model.
|
|
1187
|
+
"""
|
|
1188
|
+
names = self.feature_space_names_
|
|
1189
|
+
sizes = self.feature_space_sizes_
|
|
1190
|
+
if names is None or sizes is None:
|
|
1191
|
+
if isinstance(X, Mapping):
|
|
1192
|
+
raise ValueError(
|
|
1193
|
+
"this model was fitted on a single feature matrix, but X is "
|
|
1194
|
+
f"a mapping with names {tuple(X)}"
|
|
1195
|
+
)
|
|
1196
|
+
spaces, _ = self._as_feature_spaces(X)
|
|
1197
|
+
matrix = spaces[0]
|
|
1198
|
+
if matrix.shape[1] != self.n_features_in_:
|
|
1199
|
+
raise ValueError(
|
|
1200
|
+
f"X has {matrix.shape[1]} features, but Ridge was fitted "
|
|
1201
|
+
f"with {self.n_features_in_} features"
|
|
1202
|
+
)
|
|
1203
|
+
return [matrix]
|
|
1204
|
+
|
|
1205
|
+
if not isinstance(X, Mapping):
|
|
1206
|
+
raise ValueError(
|
|
1207
|
+
"this model was fitted on named feature spaces "
|
|
1208
|
+
f"{names}, so X must be a mapping with the "
|
|
1209
|
+
f"same names, got {type(X).__name__}"
|
|
1210
|
+
)
|
|
1211
|
+
missing = [name for name in names if name not in X]
|
|
1212
|
+
extra = [name for name in X if name not in names]
|
|
1213
|
+
if missing or extra:
|
|
1214
|
+
raise ValueError(
|
|
1215
|
+
f"X must contain exactly the fitted feature spaces "
|
|
1216
|
+
f"{names}; missing {tuple(missing)}, "
|
|
1217
|
+
f"unexpected {tuple(extra)}"
|
|
1218
|
+
)
|
|
1219
|
+
ordered = []
|
|
1220
|
+
for name, size in zip(names, sizes):
|
|
1221
|
+
space = np.asarray(X[name])
|
|
1222
|
+
if space.ndim != 2:
|
|
1223
|
+
raise ValueError(
|
|
1224
|
+
f"feature space {name!r} must be a 2D array, got "
|
|
1225
|
+
f"{space.ndim}D with shape {space.shape}"
|
|
1226
|
+
)
|
|
1227
|
+
if space.shape[1] != size:
|
|
1228
|
+
raise ValueError(
|
|
1229
|
+
f"feature space {name!r} has {space.shape[1]} features, but "
|
|
1230
|
+
f"Ridge was fitted with {size}"
|
|
1231
|
+
)
|
|
1232
|
+
ordered.append(space)
|
|
1233
|
+
counts = {name: space.shape[0] for name, space in zip(names, ordered)}
|
|
1234
|
+
if len(set(counts.values())) > 1:
|
|
1235
|
+
raise ValueError(
|
|
1236
|
+
f"all feature spaces must have the same number of samples, got {counts}"
|
|
1237
|
+
)
|
|
1238
|
+
return ordered
|
|
1239
|
+
|
|
1240
|
+
def predict(self, X) -> np.ndarray:
|
|
1241
|
+
"""Predict targets for `X`.
|
|
1242
|
+
|
|
1243
|
+
Args:
|
|
1244
|
+
X (np.ndarray | Mapping[str, np.ndarray]): Features in the
|
|
1245
|
+
structure used for fitting. Banded mappings may be in any
|
|
1246
|
+
order; they are aligned to `feature_space_names_`.
|
|
1247
|
+
|
|
1248
|
+
Returns:
|
|
1249
|
+
np.ndarray: `(n_samples,)` when fitted on one-dimensional `y`,
|
|
1250
|
+
otherwise `(n_samples, n_targets)`.
|
|
1251
|
+
|
|
1252
|
+
Raises:
|
|
1253
|
+
ValueError: If the model is not fitted, or `X` does not match the
|
|
1254
|
+
fitted feature structure.
|
|
1255
|
+
"""
|
|
1256
|
+
_check_is_fitted(self)
|
|
1257
|
+
return self._design_matrix(X) @ self.coef_
|
|
1258
|
+
|
|
1259
|
+
def score(self, X, y) -> float | np.ndarray:
|
|
1260
|
+
"""Return the coefficient of determination for each target.
|
|
1261
|
+
|
|
1262
|
+
Args:
|
|
1263
|
+
X (np.ndarray | Mapping[str, np.ndarray]): Features in the fitted
|
|
1264
|
+
structure.
|
|
1265
|
+
y (np.ndarray): True targets, `(n_samples,)` or `(n_samples,
|
|
1266
|
+
n_targets)`.
|
|
1267
|
+
|
|
1268
|
+
Returns:
|
|
1269
|
+
float | np.ndarray: A `float` for one-dimensional `y`, otherwise an
|
|
1270
|
+
array of shape `(n_targets,)`. A constant target scores zero.
|
|
1271
|
+
|
|
1272
|
+
Raises:
|
|
1273
|
+
ValueError: If the model is not fitted, or the shapes disagree.
|
|
1274
|
+
"""
|
|
1275
|
+
_check_is_fitted(self)
|
|
1276
|
+
y = np.asarray(y, dtype=np.float64)
|
|
1277
|
+
if y.ndim not in (1, 2):
|
|
1278
|
+
raise ValueError(f"y must be 1D or 2D, got {y.ndim}D array")
|
|
1279
|
+
predictions = np.asarray(self.predict(X), dtype=np.float64)
|
|
1280
|
+
if y.shape[0] != predictions.shape[0]:
|
|
1281
|
+
raise ValueError(
|
|
1282
|
+
f"X and y have inconsistent sample counts: X gives "
|
|
1283
|
+
f"{predictions.shape[0]} predictions, y has {y.shape[0]}"
|
|
1284
|
+
)
|
|
1285
|
+
was_1d = y.ndim == 1
|
|
1286
|
+
y_2d = y[:, None] if was_1d else y
|
|
1287
|
+
predicted_2d = predictions[:, None] if predictions.ndim == 1 else predictions
|
|
1288
|
+
if y_2d.shape[1] != predicted_2d.shape[1]:
|
|
1289
|
+
raise ValueError(
|
|
1290
|
+
f"y has {y_2d.shape[1]} targets, but Ridge predicts "
|
|
1291
|
+
f"{predicted_2d.shape[1]}"
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
residual = np.sum((y_2d - predicted_2d) ** 2, axis=0)
|
|
1295
|
+
total = np.sum((y_2d - y_2d.mean(axis=0)) ** 2, axis=0)
|
|
1296
|
+
scores = np.zeros(y_2d.shape[1], dtype=np.float64)
|
|
1297
|
+
varying = total > 0
|
|
1298
|
+
scores[varying] = 1.0 - residual[varying] / total[varying]
|
|
1299
|
+
return float(scores[0]) if was_1d else scores
|
|
1300
|
+
|
|
1301
|
+
def __repr__(self) -> str:
|
|
1302
|
+
"""Return a short constructor-style summary of the model."""
|
|
1303
|
+
return f"_Ridge(alpha={self.alpha!r}, device={self.device!r})"
|