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.
Files changed (95) hide show
  1. nltools/__init__.py +55 -0
  2. nltools/algorithms/__init__.py +90 -0
  3. nltools/algorithms/alignment/__init__.py +21 -0
  4. nltools/algorithms/alignment/procrustes.py +565 -0
  5. nltools/algorithms/alignment/srm.py +758 -0
  6. nltools/algorithms/backends.py +1059 -0
  7. nltools/algorithms/corrections.py +177 -0
  8. nltools/algorithms/decoding.py +327 -0
  9. nltools/algorithms/inference/__init__.py +50 -0
  10. nltools/algorithms/inference/bootstrap.py +1386 -0
  11. nltools/algorithms/inference/correlation.py +373 -0
  12. nltools/algorithms/inference/intersubject.py +422 -0
  13. nltools/algorithms/inference/isc.py +1554 -0
  14. nltools/algorithms/inference/matrix.py +602 -0
  15. nltools/algorithms/inference/one_sample.py +288 -0
  16. nltools/algorithms/inference/random.py +122 -0
  17. nltools/algorithms/inference/timeseries.py +347 -0
  18. nltools/algorithms/inference/two_sample.py +212 -0
  19. nltools/algorithms/inference/utils.py +58 -0
  20. nltools/algorithms/inference/validation.py +282 -0
  21. nltools/algorithms/neighborhoods.py +207 -0
  22. nltools/algorithms/outliers.py +308 -0
  23. nltools/algorithms/regression.py +83 -0
  24. nltools/algorithms/signal.py +303 -0
  25. nltools/algorithms/similarity.py +234 -0
  26. nltools/algorithms/validation.py +151 -0
  27. nltools/cross_validation.py +72 -0
  28. nltools/data/__init__.py +30 -0
  29. nltools/data/adjacency/__init__.py +875 -0
  30. nltools/data/adjacency/io.py +111 -0
  31. nltools/data/adjacency/modeling.py +569 -0
  32. nltools/data/adjacency/plotting.py +174 -0
  33. nltools/data/adjacency/state.py +349 -0
  34. nltools/data/adjacency/stats.py +596 -0
  35. nltools/data/adjacency/utils.py +79 -0
  36. nltools/data/atlases/__init__.py +23 -0
  37. nltools/data/atlases/labeling.py +158 -0
  38. nltools/data/atlases/loading.py +76 -0
  39. nltools/data/atlases/registry.py +96 -0
  40. nltools/data/atlases/reporting.py +456 -0
  41. nltools/data/braindata/__init__.py +2170 -0
  42. nltools/data/braindata/analysis.py +1381 -0
  43. nltools/data/braindata/bootstrap.py +398 -0
  44. nltools/data/braindata/io.py +896 -0
  45. nltools/data/braindata/modeling.py +594 -0
  46. nltools/data/braindata/plotting.py +501 -0
  47. nltools/data/braindata/prediction.py +1250 -0
  48. nltools/data/braindata/utils.py +348 -0
  49. nltools/data/braindata/validation.py +197 -0
  50. nltools/data/braindata/viewer.js +266 -0
  51. nltools/data/braindata/viewer.py +770 -0
  52. nltools/data/combine.py +27 -0
  53. nltools/data/designmatrix/__init__.py +1032 -0
  54. nltools/data/designmatrix/append.py +518 -0
  55. nltools/data/designmatrix/diagnostics.py +248 -0
  56. nltools/data/designmatrix/io.py +356 -0
  57. nltools/data/designmatrix/plotting.py +291 -0
  58. nltools/data/designmatrix/regressors.py +463 -0
  59. nltools/data/designmatrix/transforms.py +200 -0
  60. nltools/data/designmatrix/utils.py +350 -0
  61. nltools/data/ownership.py +129 -0
  62. nltools/data/results.py +291 -0
  63. nltools/data/roc/__init__.py +398 -0
  64. nltools/data/simulator/__init__.py +927 -0
  65. nltools/data/simulator/haxby.py +124 -0
  66. nltools/data/validation.py +83 -0
  67. nltools/datasets.py +218 -0
  68. nltools/io/__init__.py +10 -0
  69. nltools/io/events.py +67 -0
  70. nltools/io/h5.py +246 -0
  71. nltools/mask.py +403 -0
  72. nltools/models/__init__.py +11 -0
  73. nltools/models/glm.py +543 -0
  74. nltools/models/results.py +49 -0
  75. nltools/models/ridge.py +1303 -0
  76. nltools/models/validation.py +26 -0
  77. nltools/plotting/__init__.py +32 -0
  78. nltools/plotting/adjacency.py +421 -0
  79. nltools/plotting/brain.py +669 -0
  80. nltools/plotting/decomposition.py +111 -0
  81. nltools/plotting/prediction.py +110 -0
  82. nltools/resources/covariates_example.csv +161 -0
  83. nltools/resources/onsets_example.csv +40 -0
  84. nltools/templates/__init__.py +51 -0
  85. nltools/templates/config.py +144 -0
  86. nltools/templates/fetch.py +260 -0
  87. nltools/templates/matching.py +183 -0
  88. nltools/templates/paths.py +106 -0
  89. nltools/templates/registry.py +25 -0
  90. nltools/utils.py +230 -0
  91. nltools/version.py +13 -0
  92. nltools-0.6.0.dev0.dist-info/METADATA +95 -0
  93. nltools-0.6.0.dev0.dist-info/RECORD +95 -0
  94. nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
  95. nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -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})"