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,1386 @@
1
+ """Bootstrap resampling for simple statistics and fitted-model outputs.
2
+
3
+ Resamples observations with replacement `n_samples` times and summarizes the
4
+ resulting distribution with `_BootstrapAccumulator`, a streaming aggregator that
5
+ keeps a running Welford variance plus a bounded per-element tail — enough order
6
+ statistics to reproduce the exact percentile interval without retaining every
7
+ replicate. The CPU engines dispatch in bounded windows and fold each one in
8
+ before opening the next, which is what makes that bound real: `joblib.Parallel`
9
+ queues finished results, so collecting the run would hold every replicate at
10
+ once whatever the accumulator does. The engines cover simple aggregations ('mean', 'median', 'std', ...),
11
+ ridge weights, and ridge predictions, each with a CPU-parallel path (`n_jobs`
12
+ workers) and, for ridge, a batched GPU path. `BrainData.bootstrap` and
13
+ `Adjacency.bootstrap` are the user-facing entry points that pick an engine and
14
+ wrap the arrays as a `BootstrapResult`.
15
+
16
+ Every engine returns the same four arrays — `estimate` (the statistic on the
17
+ unresampled full sample), `standard_error`, `ci_lower`, `ci_upper` — plus
18
+ `samples` when the caller asked to retain the complete distribution. Memory
19
+ budgeting, the output preflight, GPU batch sizing, and CPU worker sizing all
20
+ live in `nltools/algorithms/backends.py`; nothing here does budget arithmetic.
21
+ """
22
+
23
+ import numpy as np
24
+ import warnings
25
+
26
+ from .validation import (
27
+ _validate_bootstrap_method,
28
+ _validate_bootstrap_data,
29
+ _validate_array_shape,
30
+ _validate_array_shape_range,
31
+ _validate_confidence_level,
32
+ _validate_memory_budget,
33
+ _validate_n_samples,
34
+ _validate_shape_compatibility,
35
+ )
36
+ from .random import _generate_bootstrap_indices
37
+ from .utils import _make_progress_bar
38
+ from nltools.utils import _find_stack_level
39
+
40
+
41
+ # Constants for supported methods
42
+ SIMPLE_METHODS = ["mean", "median", "std", "sum", "min", "max"]
43
+ FITTED_METHODS = ["weights", "predict"]
44
+
45
+
46
+ def _advise_on_n_samples(n_samples: int) -> None:
47
+ """Warn once per run when the replicate count is too low for a stable interval.
48
+
49
+ The engines are the single emitter: every facade reaches one of them, and a
50
+ direct engine call still gets advised, so the user sees the sentence exactly
51
+ once however they entered.
52
+
53
+ Args:
54
+ n_samples (int): Number of bootstrap replicates.
55
+ """
56
+ if n_samples < 1000:
57
+ warnings.warn(
58
+ f"n_samples={n_samples} is low. For reliable confidence intervals, "
59
+ f"use n_samples >= 1000. For hypothesis testing, use n_samples >= 5000.",
60
+ UserWarning,
61
+ stacklevel=_find_stack_level(),
62
+ )
63
+
64
+
65
+ def _advise_on_sample_size(data: np.ndarray) -> None:
66
+ """Warn when the sample the bootstrap resamples from is very small.
67
+
68
+ Args:
69
+ data (np.ndarray): The validated 1D or 2D data.
70
+ """
71
+ n_samples = data.shape[0] if data.ndim == 2 else len(data)
72
+ if n_samples < 10:
73
+ warnings.warn(
74
+ f"Only {n_samples} samples available. Bootstrap works best with n >= 30. "
75
+ f"Results may be unreliable with very small sample sizes.",
76
+ UserWarning,
77
+ stacklevel=_find_stack_level(),
78
+ )
79
+
80
+
81
+ class _BootstrapAccumulator:
82
+ """Streaming bootstrap aggregator with a bounded, exact percentile tail.
83
+
84
+ Holds a running Welford mean and variance plus, per output element, the `k`
85
+ smallest and `k` largest replicate values seen so far, where
86
+ `k = ceil((B - 1) * (1 - c) / 2) + 1`. Those are exactly the order
87
+ statistics NumPy's linear-interpolation percentile can reach at either end,
88
+ so the interval matches the one the complete distribution would give while
89
+ storage scales with `(1 - c) * B` instead of `B`.
90
+
91
+ Storage is memory-efficient, not constant: `k` grows with `B`. Retaining the
92
+ complete distribution (`retain_samples=True`) adds the full sample list but
93
+ changes nothing about how the interval is computed.
94
+
95
+ Args:
96
+ shape (tuple[int, ...]): Shape of one replicate's output.
97
+ n_replicates (int): Number of replicates the run will produce, `B`,
98
+ which fixes the retained tail size.
99
+ confidence_level (float): Interval confidence level, `c`. Defaults to
100
+ `0.95`.
101
+ retain_samples (bool): Keep every replicate as well. Defaults to False.
102
+
103
+ Attributes:
104
+ n (int): Replicates folded in so far.
105
+ n_replicates (int): Replicates the run was sized for.
106
+ tail_size (int): Values retained per element at each end.
107
+
108
+ Examples:
109
+ ```python
110
+ accumulator = _BootstrapAccumulator((100,), n_replicates=1000)
111
+ for sample in replicates:
112
+ accumulator.update(sample)
113
+ summary = accumulator.results()
114
+ sorted(summary) # → ['ci_lower', 'ci_upper', 'samples', 'standard_error']
115
+ ```
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ shape: tuple[int, ...],
121
+ *,
122
+ n_replicates: int,
123
+ confidence_level: float = 0.95,
124
+ retain_samples: bool = False,
125
+ ):
126
+ from nltools.algorithms.backends import (
127
+ BOOTSTRAP_TAIL_FLUSH_BLOCK,
128
+ _bootstrap_retained_tail_size,
129
+ )
130
+
131
+ self.shape = tuple(shape)
132
+ self.confidence_level = float(confidence_level)
133
+ self.retain_samples = bool(retain_samples)
134
+ self.n_replicates = int(n_replicates)
135
+ self.tail_size = _bootstrap_retained_tail_size(
136
+ n_replicates, confidence_level=self.confidence_level
137
+ )
138
+ self._flush_block = BOOTSTRAP_TAIL_FLUSH_BLOCK
139
+
140
+ self.n = 0
141
+ self.mean = np.zeros(self.shape, dtype=np.float64)
142
+ self.M2 = np.zeros(self.shape, dtype=np.float64)
143
+
144
+ # Bounded order statistics, unordered within each block until `results`.
145
+ self._low = np.empty((0, *self.shape), dtype=np.float64)
146
+ self._high = np.empty((0, *self.shape), dtype=np.float64)
147
+ self._pending: list[np.ndarray] = []
148
+
149
+ # NaN is dropped by `np.partition`, so a per-element flag reproduces
150
+ # `np.percentile`'s propagation instead of quietly skipping it.
151
+ self._has_nan = np.zeros(self.shape, dtype=bool)
152
+
153
+ # Preallocated rather than appended-and-stacked: `np.stack` on a list of
154
+ # `n_replicates` arrays would briefly hold the distribution twice, which
155
+ # is precisely the peak the preflight is meant to predict.
156
+ self._samples = (
157
+ np.empty((self.n_replicates, *self.shape), dtype=np.float64)
158
+ if self.retain_samples
159
+ else None
160
+ )
161
+
162
+ def update(self, sample: np.ndarray) -> None:
163
+ """Fold one replicate into the running statistics and retained tails.
164
+
165
+ Args:
166
+ sample (np.ndarray): One replicate's output, shaped like `shape`.
167
+
168
+ Raises:
169
+ ValueError: If the sample's shape does not match `shape`, or the
170
+ accumulator has already seen `n_replicates` replicates.
171
+ """
172
+ sample = np.asarray(sample, dtype=np.float64)
173
+ if sample.shape != self.shape:
174
+ raise ValueError(
175
+ f"Sample shape {sample.shape} does not match expected shape {self.shape}"
176
+ )
177
+ if self.n >= self.n_replicates:
178
+ # The retained tail is sized for exactly `n_replicates`; a further
179
+ # replicate would make `results()` read past what it kept.
180
+ raise ValueError(
181
+ f"This accumulator was sized for {self.n_replicates} replicates "
182
+ f"and has already seen that many. Size it with the run's total "
183
+ f"replicate count."
184
+ )
185
+
186
+ self.n += 1
187
+ delta = sample - self.mean
188
+ self.mean += delta / self.n
189
+ self.M2 += delta * (sample - self.mean)
190
+
191
+ self._has_nan |= np.isnan(sample)
192
+ self._pending.append(sample.copy())
193
+ if self._samples is not None:
194
+ self._samples[self.n - 1] = sample
195
+ if len(self._pending) >= self._flush_block:
196
+ self._flush()
197
+
198
+ def _flush(self) -> None:
199
+ """Fold the buffered replicates into the two bounded tails."""
200
+ if not self._pending:
201
+ return
202
+ block = np.stack(self._pending)
203
+ self._pending = []
204
+ self._low = self._keep_smallest(np.concatenate([self._low, block]))
205
+ self._high = self._keep_largest(np.concatenate([self._high, block]))
206
+
207
+ def _keep_smallest(self, values: np.ndarray) -> np.ndarray:
208
+ """Reduce `values` to the `tail_size` smallest entries per element.
209
+
210
+ The slice is copied because a view would keep the whole partition
211
+ output alive, quietly holding `tail_size + flush block` arrays where
212
+ the budget charges `tail_size`.
213
+ """
214
+ if values.shape[0] <= self.tail_size:
215
+ return values
216
+ return np.partition(values, self.tail_size - 1, axis=0)[: self.tail_size].copy()
217
+
218
+ def _keep_largest(self, values: np.ndarray) -> np.ndarray:
219
+ """Reduce `values` to the `tail_size` largest entries per element."""
220
+ if values.shape[0] <= self.tail_size:
221
+ return values
222
+ cut = values.shape[0] - self.tail_size
223
+ return np.partition(values, cut, axis=0)[cut:].copy()
224
+
225
+ @classmethod
226
+ def _empty_like(cls, reference: "_BootstrapAccumulator") -> "_BootstrapAccumulator":
227
+ """An empty accumulator with `reference`'s run shape and retention policy.
228
+
229
+ `merge` needs a target sized for the *whole* run, which the public
230
+ constructor derives from `n_replicates`. Copying the settled values
231
+ across says that directly instead of re-deriving them.
232
+ """
233
+ empty = cls(
234
+ reference.shape,
235
+ n_replicates=reference.n_replicates,
236
+ confidence_level=reference.confidence_level,
237
+ retain_samples=reference.retain_samples,
238
+ )
239
+ empty.tail_size = reference.tail_size
240
+ return empty
241
+
242
+ @staticmethod
243
+ def merge(
244
+ first: "_BootstrapAccumulator", second: "_BootstrapAccumulator"
245
+ ) -> "_BootstrapAccumulator":
246
+ """Combine two accumulators over disjoint replicate blocks.
247
+
248
+ Uses the Chan-Golub-LeVeque parallel variance update and merges the
249
+ bounded tails, so a run split across workers or memory-driven batches
250
+ summarizes to the same numbers as one sequential pass. Retained
251
+ samples are concatenated in `first`-then-`second` order.
252
+
253
+ Args:
254
+ first (_BootstrapAccumulator): Accumulator over the earlier block.
255
+ second (_BootstrapAccumulator): Accumulator over the later block.
256
+ Both must have been sized with the run's total replicate count.
257
+
258
+ Returns:
259
+ _BootstrapAccumulator: A new accumulator holding both blocks.
260
+
261
+ Raises:
262
+ ValueError: If the two accumulators describe different runs.
263
+ """
264
+ if first.shape != second.shape:
265
+ raise ValueError(
266
+ f"Cannot merge accumulators of shape {first.shape} and {second.shape}."
267
+ )
268
+ if first.confidence_level != second.confidence_level:
269
+ raise ValueError(
270
+ "Cannot merge accumulators with different confidence levels."
271
+ )
272
+ if first.tail_size != second.tail_size:
273
+ # Both blocks must retain the tail the *whole* run needs, so size
274
+ # every accumulator with the run's total replicate count.
275
+ raise ValueError(
276
+ f"Cannot merge accumulators retaining {first.tail_size} and "
277
+ f"{second.tail_size} values per tail: both must be sized for "
278
+ f"the whole run's replicate count."
279
+ )
280
+ if first.retain_samples != second.retain_samples:
281
+ # Merging them could only produce a partial distribution, which is
282
+ # worse than refusing: `results()` would report `samples=None` for a
283
+ # run the caller asked to retain.
284
+ raise ValueError(
285
+ "Cannot merge one accumulator that retains every replicate with "
286
+ "one that does not: both blocks of a run must agree on "
287
+ "retain_samples."
288
+ )
289
+
290
+ merged = _BootstrapAccumulator._empty_like(first)
291
+
292
+ first._flush()
293
+ second._flush()
294
+
295
+ total = first.n + second.n
296
+ merged.n = total
297
+ if total:
298
+ delta = second.mean - first.mean
299
+ merged.mean = first.mean + delta * (second.n / total)
300
+ merged.M2 = (
301
+ first.M2 + second.M2 + delta * delta * (first.n * second.n / total)
302
+ )
303
+ merged._has_nan = first._has_nan | second._has_nan
304
+ merged._low = merged._keep_smallest(np.concatenate([first._low, second._low]))
305
+ merged._high = merged._keep_largest(np.concatenate([first._high, second._high]))
306
+ if merged.retain_samples:
307
+ merged._samples = np.concatenate(
308
+ [first._samples[: first.n], second._samples[: second.n]]
309
+ )
310
+ return merged
311
+
312
+ def results(self) -> dict:
313
+ """Summarize the replicates seen so far.
314
+
315
+ Returns:
316
+ dict: `'standard_error'` (elementwise `ddof=1` deviation across
317
+ replicates), `'ci_lower'` and `'ci_upper'` (the central
318
+ percentile interval by linear interpolation), and `'samples'`
319
+ (every replicate, bootstrap axis first, or None).
320
+
321
+ Raises:
322
+ ValueError: If fewer than two replicates have been folded in.
323
+ """
324
+ if self.n < 2:
325
+ raise ValueError(
326
+ f"Need at least 2 bootstrap replicates, got {self.n}. "
327
+ "A standard error cannot be computed from fewer."
328
+ )
329
+ self._flush()
330
+
331
+ standard_error = np.sqrt(self.M2 / (self.n - 1))
332
+ ci_lower, ci_upper = self._interval()
333
+ samples = self._samples[: self.n] if self._samples is not None else None
334
+ return {
335
+ "standard_error": standard_error,
336
+ "ci_lower": ci_lower,
337
+ "ci_upper": ci_upper,
338
+ "samples": samples,
339
+ }
340
+
341
+ def _interval(self) -> tuple[np.ndarray, np.ndarray]:
342
+ """Central percentile interval read off the two retained tails."""
343
+ half_alpha = (1 - self.confidence_level) / 2
344
+ position = (self.n - 1) * half_alpha
345
+
346
+ low_sorted = np.sort(self._low, axis=0)
347
+ high_sorted = np.sort(self._high, axis=0)
348
+ ci_lower = _interpolate_order_statistic(low_sorted, position, offset=0)
349
+ ci_upper = _interpolate_order_statistic(
350
+ high_sorted,
351
+ (self.n - 1) - position,
352
+ offset=self.n - high_sorted.shape[0],
353
+ )
354
+
355
+ # `np.percentile` returns NaN for any element whose distribution holds
356
+ # one, and the spec requires the same propagation here.
357
+ ci_lower = np.where(self._has_nan, np.nan, ci_lower)
358
+ ci_upper = np.where(self._has_nan, np.nan, ci_upper)
359
+ return ci_lower, ci_upper
360
+
361
+
362
+ def _interpolate_order_statistic(
363
+ tail_sorted: np.ndarray, position: float, *, offset: int
364
+ ) -> np.ndarray:
365
+ """Read one linearly interpolated order statistic out of a retained tail.
366
+
367
+ Args:
368
+ tail_sorted (np.ndarray): Retained values, sorted ascending along axis 0.
369
+ position (float): Fractional index into the *complete* sorted
370
+ distribution, as NumPy's linear interpolation defines it.
371
+ offset (int): Complete-distribution index of `tail_sorted[0]`.
372
+
373
+ Returns:
374
+ np.ndarray: The interpolated value per output element.
375
+ """
376
+ lower_index = int(np.floor(position))
377
+ fraction = position - lower_index
378
+ local = lower_index - offset
379
+ lower = tail_sorted[local]
380
+ if fraction == 0:
381
+ return lower.copy()
382
+ return lower + fraction * (tail_sorted[local + 1] - lower)
383
+
384
+
385
+ def _run_replicates(
386
+ compute_one,
387
+ n_samples: int,
388
+ accumulator: _BootstrapAccumulator,
389
+ *,
390
+ n_jobs: int,
391
+ window: int,
392
+ desc: str,
393
+ progress_bar: bool,
394
+ ) -> None:
395
+ """Evaluate every replicate on CPU workers, aggregating one window at a time.
396
+
397
+ Bounding the pipeline is the whole point. `joblib.Parallel` dispatches
398
+ eagerly and queues finished results, so neither `pre_dispatch` nor
399
+ `return_as="generator"` limits how many replicate arrays are alive at once —
400
+ collecting the whole run, the obvious spelling, makes peak memory `O(B)` and
401
+ turns `_bootstrap_memory_preflight` into a budget the run ignores. Instead
402
+ each window of `window` replicates is folded into the accumulator and
403
+ released before the next window is dispatched, so peak memory is the
404
+ accumulator's retained tail plus one window.
405
+
406
+ Windows are consecutive and folded in submission order, so replicate order,
407
+ the reported failure index, and Welford's accumulation order are identical
408
+ to a sequential run — worker count stays numerically invisible.
409
+
410
+ Args:
411
+ compute_one (Callable): `(index) -> np.ndarray` for one replicate.
412
+ n_samples (int): Number of replicates.
413
+ accumulator (_BootstrapAccumulator): Aggregator to fold results into.
414
+ n_jobs (int): Worker count, already planned against the budget.
415
+ window (int): Replicates dispatched before the next aggregation, from
416
+ `backends.bootstrap_replicate_window`.
417
+ desc (str): Progress-bar description.
418
+ progress_bar (bool): Show a progress bar over replicates.
419
+
420
+ Raises:
421
+ RuntimeError: If a replicate fails, naming its index.
422
+ """
423
+ from joblib import Parallel, delayed
424
+
425
+ def _guarded(index):
426
+ try:
427
+ return compute_one(index)
428
+ except Exception as error: # noqa: BLE001 - re-raised with the index
429
+ raise RuntimeError(
430
+ f"bootstrap replicate {index} failed: {error}"
431
+ ) from error
432
+
433
+ pbar = _make_progress_bar(
434
+ progress_bar=progress_bar, total=n_samples, desc=desc, unit="iter"
435
+ )
436
+ # One pool for the whole run; the context manager keeps workers alive
437
+ # across windows so bounding memory does not cost a respawn per window.
438
+ with Parallel(n_jobs=n_jobs) as parallel:
439
+ for start in range(0, n_samples, window):
440
+ stop = min(start + window, n_samples)
441
+ for sample in parallel(
442
+ delayed(_guarded)(index) for index in range(start, stop)
443
+ ):
444
+ accumulator.update(sample)
445
+ # The bar advances on completed replicates, not dispatched ones.
446
+ pbar.update(stop - start)
447
+ pbar.close()
448
+
449
+
450
+ def _summarize(accumulator: _BootstrapAccumulator, estimate: np.ndarray) -> dict:
451
+ """Assemble the engine result from an accumulator and the full-sample estimate.
452
+
453
+ Args:
454
+ accumulator (_BootstrapAccumulator): Aggregator over every replicate.
455
+ estimate (np.ndarray): The statistic on the unresampled full sample.
456
+
457
+ Returns:
458
+ dict: `'estimate'`, `'standard_error'`, `'ci_lower'`, `'ci_upper'`, and
459
+ `'samples'` when the complete distribution was retained.
460
+ """
461
+ summary = accumulator.results()
462
+ result = {
463
+ "estimate": np.asarray(estimate, dtype=np.float64),
464
+ "standard_error": summary["standard_error"],
465
+ "ci_lower": summary["ci_lower"],
466
+ "ci_upper": summary["ci_upper"],
467
+ }
468
+ if summary["samples"] is not None:
469
+ result["samples"] = summary["samples"]
470
+ return result
471
+
472
+
473
+ def _bootstrap_simple_method_worker(
474
+ data: np.ndarray,
475
+ method: str,
476
+ indices: np.ndarray,
477
+ ) -> np.ndarray:
478
+ """Apply one simple aggregation to a resampled copy of the data.
479
+
480
+ The single home of the six basic reductions, used both for the replicates
481
+ and — with the identity index — for the full-sample estimate. `'std'` is
482
+ the population deviation (`ddof=0`), matching `BrainData.std()`.
483
+
484
+ Args:
485
+ data (np.ndarray): Data to bootstrap, shape (n_obs, n_features).
486
+ method (str): Aggregation method, one of 'mean', 'median', 'std', 'sum',
487
+ 'min', or 'max'.
488
+ indices (np.ndarray): Row indices of the replicate, shape (n_obs,).
489
+
490
+ Returns:
491
+ np.ndarray: Aggregated result, shape (n_features,).
492
+
493
+ Raises:
494
+ ValueError: If `method` is not one of the supported aggregations.
495
+ """
496
+ data_boot = data[indices]
497
+
498
+ if method == "mean":
499
+ return np.mean(data_boot, axis=0)
500
+ if method == "median":
501
+ return np.median(data_boot, axis=0)
502
+ if method == "std":
503
+ return np.std(data_boot, axis=0, ddof=0)
504
+ if method == "sum":
505
+ return np.sum(data_boot, axis=0)
506
+ if method == "min":
507
+ return np.min(data_boot, axis=0)
508
+ if method == "max":
509
+ return np.max(data_boot, axis=0)
510
+ raise ValueError(f"Unsupported method: {method}")
511
+
512
+
513
+ def _bootstrap_simple_cpu_parallel(
514
+ data: np.ndarray,
515
+ method: str,
516
+ n_samples: int = 5000,
517
+ *,
518
+ confidence_level: float = 0.95,
519
+ memory_budget_gb: float | None = None,
520
+ return_samples: bool = False,
521
+ n_jobs: int = -1,
522
+ random_state: int | None = None,
523
+ progress_bar: bool = False,
524
+ ) -> dict[str, np.ndarray]:
525
+ """Bootstrap a simple aggregation across CPU workers.
526
+
527
+ Bootstrap indices are pre-generated from `random_state`, replicates run in
528
+ parallel with joblib, and results stream into `_BootstrapAccumulator`.
529
+
530
+ Args:
531
+ data (np.ndarray): Data to bootstrap, shape (n_obs, n_features) or
532
+ (n_obs,).
533
+ method (str): Aggregation method, one of 'mean', 'median', 'std', 'sum',
534
+ 'min', or 'max'.
535
+ n_samples (int): Number of bootstrap replicates. Defaults to 5000.
536
+ confidence_level (float): Interval confidence level. Defaults to 0.95.
537
+ memory_budget_gb (float | None): Working-memory budget in GB governing
538
+ the output preflight and worker planning. None measures the host.
539
+ return_samples (bool): Retain every replicate. Defaults to False.
540
+ n_jobs (int): CPU worker ceiling (-1 = all cores). Defaults to -1.
541
+ random_state (int | None): Random seed for reproducibility.
542
+ progress_bar (bool): Show a progress bar over replicates. Defaults to False.
543
+
544
+ Returns:
545
+ dict[str, np.ndarray]: `'estimate'` (the aggregation on the unresampled
546
+ data), `'standard_error'`, `'ci_lower'`, `'ci_upper'`, `'samples'`
547
+ (only with `return_samples=True`), and `'backend'`.
548
+
549
+ Examples:
550
+ ```python
551
+ data = np.random.randn(100, 50) # 100 observations, 50 features
552
+ result = _bootstrap_simple_cpu_parallel(data, "mean", n_samples=1000)
553
+ result["estimate"].shape # → (50,)
554
+ ```
555
+ """
556
+ from nltools.algorithms.backends import (
557
+ _bootstrap_memory_preflight,
558
+ _bootstrap_n_jobs_cpu,
559
+ _bootstrap_replicate_window,
560
+ _estimate_data_size_mb,
561
+ )
562
+
563
+ _validate_bootstrap_method(method, SIMPLE_METHODS, FITTED_METHODS)
564
+ _validate_n_samples(n_samples)
565
+ _validate_confidence_level(confidence_level)
566
+ _validate_memory_budget(memory_budget_gb)
567
+
568
+ data = np.asarray(data, dtype=np.float64)
569
+ _validate_bootstrap_data(data, method)
570
+
571
+ _advise_on_n_samples(n_samples)
572
+ _advise_on_sample_size(data)
573
+
574
+ single_feature = data.ndim == 1
575
+ if single_feature:
576
+ data = data[:, np.newaxis]
577
+
578
+ n_obs, n_features = data.shape
579
+ output_shape = (1,) if single_feature else (n_features,)
580
+
581
+ workers = _bootstrap_n_jobs_cpu(
582
+ _estimate_data_size_mb(data),
583
+ n_samples,
584
+ memory_budget_gb=memory_budget_gb,
585
+ n_jobs=n_jobs,
586
+ )
587
+ _bootstrap_memory_preflight(
588
+ output_shape,
589
+ n_samples,
590
+ confidence_level=confidence_level,
591
+ return_samples=return_samples,
592
+ n_workers=workers,
593
+ memory_budget_gb=memory_budget_gb,
594
+ )
595
+ window = _bootstrap_replicate_window(n_samples, n_workers=workers)
596
+
597
+ all_indices = _generate_bootstrap_indices(
598
+ n_obs, n_samples, random_state=random_state
599
+ )
600
+ estimate = _bootstrap_simple_method_worker(data, method, np.arange(n_obs))
601
+
602
+ accumulator = _BootstrapAccumulator(
603
+ output_shape,
604
+ n_replicates=n_samples,
605
+ confidence_level=confidence_level,
606
+ retain_samples=return_samples,
607
+ )
608
+
609
+ def _compute_one_bootstrap(index):
610
+ return _bootstrap_simple_method_worker(data, method, all_indices[index])
611
+
612
+ _run_replicates(
613
+ _compute_one_bootstrap,
614
+ n_samples,
615
+ accumulator,
616
+ n_jobs=workers,
617
+ window=window,
618
+ desc="Bootstrap iterations",
619
+ progress_bar=progress_bar,
620
+ )
621
+
622
+ result = _summarize(accumulator, estimate)
623
+ result["backend"] = f"cpu-parallel-{workers}"
624
+ return result
625
+
626
+
627
+ def _as_feature_spaces(X) -> list[np.ndarray]:
628
+ """Normalize training features to a list of 2-D arrays in coefficient order.
629
+
630
+ Ordinary ridge supplies one matrix; banded ridge supplies one matrix per
631
+ fitted feature space, already ordered by `_Ridge.feature_space_names_`.
632
+
633
+ Args:
634
+ X (np.ndarray | Sequence[np.ndarray]): One matrix, or one per space.
635
+
636
+ Returns:
637
+ list[np.ndarray]: The feature spaces as float64 arrays.
638
+ """
639
+ spaces = list(X) if isinstance(X, (list, tuple)) else [X]
640
+ return [np.asarray(space, dtype=np.float64) for space in spaces]
641
+
642
+
643
+ def _stack_feature_spaces(X) -> np.ndarray:
644
+ """Concatenate prediction features into one matrix in coefficient order.
645
+
646
+ Args:
647
+ X (np.ndarray | Sequence[np.ndarray]): One matrix, or one per space.
648
+
649
+ Returns:
650
+ np.ndarray: A `(n_rows, n_features)` float64 matrix.
651
+ """
652
+ spaces = _as_feature_spaces(X)
653
+ return spaces[0] if len(spaces) == 1 else np.concatenate(spaces, axis=1)
654
+
655
+
656
+ def _bootstrap_design(feature_spaces, y, backend=None):
657
+ """Convert the training design onto the backend once for every replicate.
658
+
659
+ Bootstrap replicates differ only in which rows they draw, so the design and
660
+ the response are converted a single time and each replicate resamples rows
661
+ in place. On a GPU that keeps the host-to-device transfer out of the loop;
662
+ on the CPU it keeps the concatenation out of it.
663
+
664
+ Args:
665
+ feature_spaces (Sequence[np.ndarray]): Training features, one matrix per
666
+ fitted space in coefficient order.
667
+ y (np.ndarray): Training targets, shape (n_obs, n_voxels).
668
+ backend (Backend | None): Resolved GPU backend, or None for the CPU.
669
+
670
+ Returns:
671
+ _ResidentDesign: The converted design, ready for repeated refits.
672
+ """
673
+ from nltools.models.ridge import _resident_design
674
+
675
+ return _resident_design(feature_spaces, y, backend)
676
+
677
+
678
+ def _refit_resample(
679
+ design,
680
+ indices: np.ndarray,
681
+ alpha: float | np.ndarray,
682
+ feature_space_weights: np.ndarray | None = None,
683
+ memory_budget_gb: float | None = None,
684
+ ) -> np.ndarray:
685
+ """Refit ridge weights on one bootstrap replicate, hyperparameters fixed.
686
+
687
+ Every ridge-bootstrap replicate — CPU or GPU, ordinary or banded — routes
688
+ through the package's single fixed-hyperparameter refit, so a replicate
689
+ cannot drift from the full-data fit numerically and never reruns model
690
+ selection. `indices` is applied to the design and the response together, so
691
+ every feature space and the response resample with the same rows.
692
+
693
+ Args:
694
+ design (_ResidentDesign): The training design from `_bootstrap_design`.
695
+ indices (np.ndarray): Row indices of the replicate.
696
+ alpha (float | np.ndarray): Scalar or per-target regularization, taken
697
+ from the fitted model and held fixed.
698
+ feature_space_weights (np.ndarray | None): The fitted banded simplex
699
+ weights, shared `(n_spaces,)` or per-target `(n_spaces, n_targets)`.
700
+ None solves the unweighted ordinary system.
701
+ memory_budget_gb (float | None): Budget used to size the refit's
702
+ internal target batch.
703
+
704
+ Returns:
705
+ np.ndarray: Weights, shape (n_features, n_voxels).
706
+ """
707
+ from nltools.models.ridge import _refit_fixed_hyperparameters
708
+
709
+ return _refit_fixed_hyperparameters(
710
+ design,
711
+ None,
712
+ alpha,
713
+ feature_space_weights,
714
+ memory_budget_gb=memory_budget_gb,
715
+ row_indices=indices,
716
+ )
717
+
718
+
719
+ def _bootstrap_ridge_weights_cpu_parallel(
720
+ X,
721
+ y: np.ndarray,
722
+ alpha: float,
723
+ estimate: np.ndarray,
724
+ n_samples: int = 5000,
725
+ *,
726
+ confidence_level: float = 0.95,
727
+ memory_budget_gb: float | None = None,
728
+ feature_space_weights: np.ndarray | None = None,
729
+ return_samples: bool = False,
730
+ n_jobs: int = -1,
731
+ random_state: int | None = None,
732
+ progress_bar: bool = False,
733
+ ) -> dict[str, np.ndarray]:
734
+ """Bootstrap ridge weights across CPU workers.
735
+
736
+ Each replicate calls the shared fixed-hyperparameter refit directly on numpy
737
+ arrays (no `BrainData` serialization), which is 10-100x faster than a naive
738
+ implementation.
739
+
740
+ Args:
741
+ X (np.ndarray | list[np.ndarray]): Feature matrix, shape (n_obs,
742
+ n_features), or one matrix per banded feature space in fitted order.
743
+ y (np.ndarray): Target matrix, shape (n_obs, n_voxels) or (n_obs,).
744
+ alpha (float): Ridge regularization parameter, held fixed.
745
+ estimate (np.ndarray): The fitted full-data coefficients, shape
746
+ (n_features, n_voxels). Only the fitted model knows them, so the
747
+ caller supplies them rather than the engine refitting.
748
+ n_samples (int): Number of bootstrap replicates. Defaults to 5000.
749
+ confidence_level (float): Interval confidence level. Defaults to 0.95.
750
+ memory_budget_gb (float | None): Working-memory budget in GB governing
751
+ the output preflight and worker planning. None measures the host.
752
+ feature_space_weights (np.ndarray | None): Fitted banded simplex
753
+ weights held fixed across replicates. None for ordinary ridge.
754
+ return_samples (bool): Retain every replicate. Defaults to False.
755
+ n_jobs (int): CPU worker ceiling (-1 = all cores). Defaults to -1.
756
+ random_state (int | None): Random seed for reproducibility.
757
+ progress_bar (bool): Show a progress bar over replicates. Defaults to False.
758
+
759
+ Returns:
760
+ dict[str, np.ndarray]: `'estimate'`, `'standard_error'`, `'ci_lower'`,
761
+ `'ci_upper'`, `'samples'` (only with `return_samples=True`), and
762
+ `'backend'`.
763
+
764
+ Examples:
765
+ ```python
766
+ X = np.random.randn(100, 10) # 100 observations, 10 features
767
+ y = np.random.randn(100, 50) # 100 observations, 50 voxels
768
+ coef = _refit_fixed_hyperparameters([X], y, 1.0)
769
+ result = _bootstrap_ridge_weights_cpu_parallel(X, y, 1.0, coef)
770
+ result["estimate"].shape # → (10, 50)
771
+ ```
772
+ """
773
+ from nltools.algorithms.backends import (
774
+ _bootstrap_memory_preflight,
775
+ _bootstrap_n_jobs_cpu,
776
+ _bootstrap_replicate_window,
777
+ _estimate_data_size_mb,
778
+ )
779
+
780
+ spaces = _as_feature_spaces(X)
781
+ y = np.asarray(y, dtype=np.float64)
782
+
783
+ _validate_array_shape_range(y, 1, 2, name="y")
784
+ for space in spaces:
785
+ _validate_array_shape(space, 2, name="X")
786
+ _validate_shape_compatibility(space, y, X_name="X", y_name="y")
787
+ _validate_n_samples(n_samples)
788
+ _validate_confidence_level(confidence_level)
789
+ _validate_memory_budget(memory_budget_gb)
790
+ _advise_on_n_samples(n_samples)
791
+
792
+ if y.ndim == 1:
793
+ y = y[:, np.newaxis]
794
+
795
+ n_obs = spaces[0].shape[0]
796
+ n_features = sum(space.shape[1] for space in spaces)
797
+ output_shape = (n_features, y.shape[1])
798
+
799
+ workers = _bootstrap_n_jobs_cpu(
800
+ _estimate_data_size_mb(y),
801
+ n_samples,
802
+ memory_budget_gb=memory_budget_gb,
803
+ n_jobs=n_jobs,
804
+ )
805
+ _bootstrap_memory_preflight(
806
+ output_shape,
807
+ n_samples,
808
+ confidence_level=confidence_level,
809
+ return_samples=return_samples,
810
+ n_workers=workers,
811
+ memory_budget_gb=memory_budget_gb,
812
+ )
813
+ window = _bootstrap_replicate_window(n_samples, n_workers=workers)
814
+
815
+ all_indices = _generate_bootstrap_indices(
816
+ n_obs, n_samples, random_state=random_state
817
+ )
818
+ accumulator = _BootstrapAccumulator(
819
+ output_shape,
820
+ n_replicates=n_samples,
821
+ confidence_level=confidence_level,
822
+ retain_samples=return_samples,
823
+ )
824
+ design = _bootstrap_design(spaces, y)
825
+
826
+ def _compute_one_bootstrap(index):
827
+ return _refit_resample(
828
+ design,
829
+ all_indices[index],
830
+ alpha,
831
+ feature_space_weights,
832
+ memory_budget_gb=memory_budget_gb,
833
+ )
834
+
835
+ _run_replicates(
836
+ _compute_one_bootstrap,
837
+ n_samples,
838
+ accumulator,
839
+ n_jobs=workers,
840
+ window=window,
841
+ desc="Bootstrap Ridge weights",
842
+ progress_bar=progress_bar,
843
+ )
844
+
845
+ result = _summarize(accumulator, estimate)
846
+ result["backend"] = f"cpu-parallel-{workers}"
847
+ return result
848
+
849
+
850
+ def _bootstrap_ridge_predict_cpu_parallel(
851
+ X,
852
+ y: np.ndarray,
853
+ X_pred: np.ndarray,
854
+ alpha: float,
855
+ estimate: np.ndarray,
856
+ n_samples: int = 5000,
857
+ *,
858
+ confidence_level: float = 0.95,
859
+ memory_budget_gb: float | None = None,
860
+ feature_space_weights: np.ndarray | None = None,
861
+ return_samples: bool = False,
862
+ n_jobs: int = -1,
863
+ random_state: int | None = None,
864
+ progress_bar: bool = False,
865
+ ) -> dict[str, np.ndarray]:
866
+ """Bootstrap ridge predictions across CPU workers.
867
+
868
+ Each replicate refits the ridge model on the resampled training data and
869
+ applies the refitted coefficients to the unchanged `X_pred`.
870
+
871
+ Args:
872
+ X (np.ndarray | list[np.ndarray]): Training feature matrix, shape
873
+ (n_obs, n_features), or one matrix per banded feature space in
874
+ fitted order.
875
+ y (np.ndarray): Training target matrix, shape (n_obs, n_voxels) or
876
+ (n_obs,).
877
+ X_pred (np.ndarray | list[np.ndarray]): Test feature matrix, shape
878
+ (n_test, n_features), or one matrix per banded feature space in
879
+ fitted order.
880
+ alpha (float): Ridge regularization parameter, held fixed.
881
+ estimate (np.ndarray): The fitted full-data model evaluated at `X_pred`,
882
+ shape (n_test, n_voxels).
883
+ n_samples (int): Number of bootstrap replicates. Defaults to 5000.
884
+ confidence_level (float): Interval confidence level. Defaults to 0.95.
885
+ memory_budget_gb (float | None): Working-memory budget in GB governing
886
+ the output preflight and worker planning. None measures the host.
887
+ feature_space_weights (np.ndarray | None): Fitted banded simplex
888
+ weights held fixed across replicates. None for ordinary ridge.
889
+ return_samples (bool): Retain every replicate. Defaults to False.
890
+ n_jobs (int): CPU worker ceiling (-1 = all cores). Defaults to -1.
891
+ random_state (int | None): Random seed for reproducibility.
892
+ progress_bar (bool): Show a progress bar over replicates. Defaults to False.
893
+
894
+ Returns:
895
+ dict[str, np.ndarray]: `'estimate'`, `'standard_error'`, `'ci_lower'`,
896
+ `'ci_upper'`, `'samples'` (only with `return_samples=True`), and
897
+ `'backend'`.
898
+
899
+ Examples:
900
+ ```python
901
+ X = np.random.randn(100, 10) # training features
902
+ y = np.random.randn(100, 50) # training targets (50 voxels)
903
+ X_test = np.random.randn(20, 10) # test features
904
+ coef = _refit_fixed_hyperparameters([X], y, 1.0)
905
+ result = _bootstrap_ridge_predict_cpu_parallel(
906
+ X, y, X_test, 1.0, X_test @ coef
907
+ )
908
+ result["estimate"].shape # → (20, 50)
909
+ ```
910
+ """
911
+ from nltools.algorithms.backends import (
912
+ _bootstrap_memory_preflight,
913
+ _bootstrap_n_jobs_cpu,
914
+ _bootstrap_replicate_window,
915
+ _estimate_data_size_mb,
916
+ )
917
+
918
+ spaces = _as_feature_spaces(X)
919
+ y = np.asarray(y, dtype=np.float64)
920
+ X_pred = _stack_feature_spaces(X_pred)
921
+
922
+ _validate_array_shape_range(y, 1, 2, name="y")
923
+ for space in spaces:
924
+ _validate_array_shape(space, 2, name="X")
925
+ _validate_shape_compatibility(space, y, X_name="X", y_name="y")
926
+ _validate_array_shape(X_pred, 2, name="X_pred")
927
+ n_features = sum(space.shape[1] for space in spaces)
928
+ if n_features != X_pred.shape[1]:
929
+ raise ValueError(
930
+ f"X and X_pred must have same n_features: {n_features} != {X_pred.shape[1]}"
931
+ )
932
+ _validate_n_samples(n_samples)
933
+ _validate_confidence_level(confidence_level)
934
+ _validate_memory_budget(memory_budget_gb)
935
+ _advise_on_n_samples(n_samples)
936
+
937
+ if y.ndim == 1:
938
+ y = y[:, np.newaxis]
939
+
940
+ n_obs = spaces[0].shape[0]
941
+ output_shape = (X_pred.shape[0], y.shape[1])
942
+
943
+ workers = _bootstrap_n_jobs_cpu(
944
+ _estimate_data_size_mb(y),
945
+ n_samples,
946
+ memory_budget_gb=memory_budget_gb,
947
+ n_jobs=n_jobs,
948
+ )
949
+ _bootstrap_memory_preflight(
950
+ output_shape,
951
+ n_samples,
952
+ confidence_level=confidence_level,
953
+ return_samples=return_samples,
954
+ n_workers=workers,
955
+ memory_budget_gb=memory_budget_gb,
956
+ )
957
+ window = _bootstrap_replicate_window(n_samples, n_workers=workers)
958
+
959
+ all_indices = _generate_bootstrap_indices(
960
+ n_obs, n_samples, random_state=random_state
961
+ )
962
+ accumulator = _BootstrapAccumulator(
963
+ output_shape,
964
+ n_replicates=n_samples,
965
+ confidence_level=confidence_level,
966
+ retain_samples=return_samples,
967
+ )
968
+ design = _bootstrap_design(spaces, y)
969
+
970
+ def _compute_one_bootstrap(index):
971
+ weights = _refit_resample(
972
+ design,
973
+ all_indices[index],
974
+ alpha,
975
+ feature_space_weights,
976
+ memory_budget_gb=memory_budget_gb,
977
+ )
978
+ return X_pred @ weights
979
+
980
+ _run_replicates(
981
+ _compute_one_bootstrap,
982
+ n_samples,
983
+ accumulator,
984
+ n_jobs=workers,
985
+ window=window,
986
+ desc="Bootstrap Ridge predictions",
987
+ progress_bar=progress_bar,
988
+ )
989
+
990
+ result = _summarize(accumulator, estimate)
991
+ result["backend"] = f"cpu-parallel-{workers}"
992
+ return result
993
+
994
+
995
+ def _auto_batch_size_ridge(
996
+ n_bootstrap: int,
997
+ n_samples: int,
998
+ n_features: int,
999
+ n_voxels: int,
1000
+ output_shape: tuple[int, ...],
1001
+ max_memory_gb: float | None = None,
1002
+ backend=None,
1003
+ ) -> tuple[int, int]:
1004
+ """Determine the Ridge-bootstrap GPU batch size for a memory budget.
1005
+
1006
+ Forwards to `backends.ridge_bootstrap_batch_size`, which owns the budget
1007
+ measurement, the working-set model, and the batch arithmetic. This wrapper
1008
+ only supplies the solver's working dtype size: MPS solves in float32,
1009
+ every other backend in float64.
1010
+
1011
+ Args:
1012
+ n_bootstrap (int): Total number of bootstrap replicates.
1013
+ n_samples (int): Number of observations in the dataset.
1014
+ n_features (int): Total feature count across all feature spaces.
1015
+ n_voxels (int): Number of voxels/targets.
1016
+ output_shape (tuple[int, ...]): Shape of one retained replicate result.
1017
+ max_memory_gb (float | None): Explicit memory budget in GB. None
1018
+ (default) measures the device via `_device_memory_budget`.
1019
+ backend (Backend | None): Resolved backend the work runs on.
1020
+
1021
+ Returns:
1022
+ tuple[int, int]: `(batch_size, n_batches)`.
1023
+ """
1024
+ from nltools.algorithms.backends import _ridge_bootstrap_batch_size
1025
+
1026
+ device = getattr(backend, "device", None)
1027
+ return _ridge_bootstrap_batch_size(
1028
+ n_bootstrap,
1029
+ n_samples=n_samples,
1030
+ n_features=n_features,
1031
+ n_targets=n_voxels,
1032
+ output_shape=output_shape,
1033
+ device_itemsize=4 if device == "mps" else 8,
1034
+ max_gpu_memory_gb=max_memory_gb,
1035
+ backend=backend,
1036
+ )
1037
+
1038
+
1039
+ def _validate_gpu_backend(backend) -> None:
1040
+ """Raise unless `backend` is a GPU device backend (torch-cuda or torch-mps).
1041
+
1042
+ Guard for the GPU bootstrap engine: CPU backends ('numpy', 'torch-cpu')
1043
+ must be rejected — this engine assumes device compute.
1044
+
1045
+ Args:
1046
+ backend (Backend): Resolved backend instance (only `.name` is inspected).
1047
+
1048
+ Raises:
1049
+ ValueError: If `backend.name` is not 'torch-cuda' or 'torch-mps'.
1050
+ """
1051
+ if backend.name not in ("torch-cuda", "torch-mps"):
1052
+ raise ValueError(
1053
+ f"GPU backend requires 'torch-cuda' or 'torch-mps', got '{backend.name}'"
1054
+ )
1055
+
1056
+
1057
+ def _bootstrap_ridge_gpu_batched(
1058
+ feature_spaces: list[np.ndarray],
1059
+ y: np.ndarray,
1060
+ alpha: float,
1061
+ *,
1062
+ estimate: np.ndarray,
1063
+ compute_sample,
1064
+ output_shape: tuple[int, ...],
1065
+ desc: str,
1066
+ confidence_level: float = 0.95,
1067
+ feature_space_weights: np.ndarray | None = None,
1068
+ n_samples: int = 5000,
1069
+ return_samples: bool = False,
1070
+ backend=None,
1071
+ memory_budget_gb: float | None = None,
1072
+ random_state: int | None = None,
1073
+ progress_bar: bool = False,
1074
+ ) -> dict[str, np.ndarray]:
1075
+ """Shared GPU bootstrap driver for ridge statistics, with automatic batching.
1076
+
1077
+ Owns everything the weights and predict bootstraps have in common — pre-drawn
1078
+ resample indices, batch sizing via `_auto_batch_size_ridge`, the per-replicate
1079
+ refit inside an OOM-safe batch loop, `_BootstrapAccumulator` aggregation on
1080
+ the CPU, the progress bar, and result formatting. The per-replicate statistic
1081
+ is injected via `compute_sample`.
1082
+
1083
+ Each replicate goes through the package's shared fixed-hyperparameter refit
1084
+ (`nltools.models.ridge._refit_fixed_hyperparameters`) under the scoped
1085
+ Himalaya GPU backend, so the GPU path solves the same equation as the CPU
1086
+ path and supports banded models. The batch loop exists for memory control:
1087
+ it bounds how many resamples are in flight before aggregation.
1088
+
1089
+ Args:
1090
+ feature_spaces (list[np.ndarray]): Training features, one matrix per
1091
+ fitted space in coefficient order.
1092
+ y (np.ndarray): Target matrix, shape (n_obs, n_voxels), 2-D.
1093
+ alpha (float): Ridge regularization held fixed across replicates.
1094
+ estimate (np.ndarray): The full-data statistic, shape `output_shape`.
1095
+ compute_sample (Callable): `(coef) -> np.ndarray`, mapping one
1096
+ replicate's coefficients, shape (n_features, n_voxels), to the
1097
+ statistic aggregated on the CPU (the weights themselves, or
1098
+ predictions from them).
1099
+ output_shape (tuple[int, ...]): Shape of each `compute_sample` result.
1100
+ desc (str): Progress-bar description.
1101
+ confidence_level (float): Interval confidence level. Defaults to 0.95.
1102
+ feature_space_weights (np.ndarray | None): Fitted banded simplex
1103
+ weights held fixed across replicates. None for ordinary ridge.
1104
+ n_samples (int): Number of bootstrap replicates. Defaults to 5000.
1105
+ return_samples (bool): Retain every replicate. Defaults to False.
1106
+ backend (Backend | None): Backend instance (must be a GPU torch backend).
1107
+ None auto-selects.
1108
+ memory_budget_gb (float | None): Working-memory budget in GB. None
1109
+ (default) measures the device's available memory.
1110
+ random_state (int | None): Random seed for reproducibility.
1111
+ progress_bar (bool): Show a progress bar over replicates. Defaults to False.
1112
+
1113
+ Returns:
1114
+ dict[str, np.ndarray]: Bootstrap statistics in the same format as the CPU
1115
+ engines, with `'backend'` set to `'gpu-<device>'`.
1116
+
1117
+ Raises:
1118
+ RuntimeError: If a replicate fails, naming its index.
1119
+ """
1120
+ from nltools.algorithms.backends import (
1121
+ _auto_select_backend,
1122
+ _bootstrap_memory_preflight,
1123
+ _compute_oom_safe,
1124
+ _is_oom_error,
1125
+ )
1126
+
1127
+ n_obs = feature_spaces[0].shape[0]
1128
+ n_features = sum(space.shape[1] for space in feature_spaces)
1129
+ n_voxels = y.shape[1]
1130
+
1131
+ if backend is None:
1132
+ backend = _auto_select_backend(n_obs, n_features)
1133
+ _validate_gpu_backend(backend)
1134
+
1135
+ _validate_n_samples(n_samples)
1136
+ _validate_confidence_level(confidence_level)
1137
+ _validate_memory_budget(memory_budget_gb)
1138
+ _advise_on_n_samples(n_samples)
1139
+
1140
+ _bootstrap_memory_preflight(
1141
+ output_shape,
1142
+ n_samples,
1143
+ confidence_level=confidence_level,
1144
+ return_samples=return_samples,
1145
+ memory_budget_gb=memory_budget_gb,
1146
+ backend=backend,
1147
+ )
1148
+
1149
+ all_indices = _generate_bootstrap_indices(
1150
+ n_obs, n_samples, random_state=random_state
1151
+ )
1152
+
1153
+ batch_size, n_batches = _auto_batch_size_ridge(
1154
+ n_samples,
1155
+ n_obs,
1156
+ n_features,
1157
+ n_voxels,
1158
+ output_shape,
1159
+ max_memory_gb=memory_budget_gb,
1160
+ backend=backend,
1161
+ )
1162
+
1163
+ # One host-to-device transfer for the whole run; replicates resample rows
1164
+ # on the device.
1165
+ design = _bootstrap_design(feature_spaces, y, backend)
1166
+
1167
+ def _compute_batch(batch: np.ndarray, replicates: np.ndarray) -> np.ndarray:
1168
+ """GPU ridge statistics for one (sub-)batch of pre-drawn resample indices.
1169
+
1170
+ `replicates` carries each row's global replicate index so a terminal
1171
+ failure names it even after OOM recovery has split the batch. An OOM
1172
+ itself is re-raised untouched, because `_compute_oom_safe` recovers from
1173
+ it by retrying the same rows in smaller pieces.
1174
+ """
1175
+ batch_results = []
1176
+ for replicate, indices in zip(replicates, batch):
1177
+ try:
1178
+ coef = _refit_resample(
1179
+ design,
1180
+ indices,
1181
+ alpha,
1182
+ feature_space_weights,
1183
+ memory_budget_gb=memory_budget_gb,
1184
+ )
1185
+ except Exception as error:
1186
+ if _is_oom_error(error):
1187
+ raise
1188
+ raise RuntimeError(
1189
+ f"bootstrap replicate {int(replicate)} failed: {error}"
1190
+ ) from error
1191
+ batch_results.append(compute_sample(coef))
1192
+ return np.array(batch_results)
1193
+
1194
+ accumulator = _BootstrapAccumulator(
1195
+ output_shape,
1196
+ n_replicates=n_samples,
1197
+ confidence_level=confidence_level,
1198
+ retain_samples=return_samples,
1199
+ )
1200
+
1201
+ pbar = _make_progress_bar(
1202
+ progress_bar=progress_bar,
1203
+ total=n_samples,
1204
+ desc=desc,
1205
+ unit="iter",
1206
+ disable=n_batches == 1,
1207
+ )
1208
+ replicate_ids = np.arange(n_samples)
1209
+
1210
+ for batch_index in range(n_batches):
1211
+ start = batch_index * batch_size
1212
+ end = min(start + batch_size, n_samples)
1213
+
1214
+ # Bootstrap indices for this batch were all pre-drawn (all_indices),
1215
+ # so OOM recovery reuses them exactly; the replicate ids split with them.
1216
+ batch_results = _compute_oom_safe(
1217
+ _compute_batch, all_indices[start:end], replicate_ids[start:end]
1218
+ )
1219
+ for sample in batch_results:
1220
+ accumulator.update(sample)
1221
+
1222
+ pbar.update(end - start)
1223
+
1224
+ pbar.close()
1225
+
1226
+ result = _summarize(accumulator, estimate)
1227
+ result["backend"] = f"gpu-{backend.device}"
1228
+ return result
1229
+
1230
+
1231
+ def _bootstrap_ridge_weights_gpu_batched(
1232
+ X,
1233
+ y: np.ndarray,
1234
+ alpha: float,
1235
+ estimate: np.ndarray,
1236
+ n_samples: int = 5000,
1237
+ *,
1238
+ confidence_level: float = 0.95,
1239
+ feature_space_weights: np.ndarray | None = None,
1240
+ return_samples: bool = False,
1241
+ backend=None,
1242
+ memory_budget_gb: float | None = None,
1243
+ random_state: int | None = None,
1244
+ progress_bar: bool = False,
1245
+ ) -> dict[str, np.ndarray]:
1246
+ """Bootstrap ridge weights on the GPU with automatic batching.
1247
+
1248
+ Thin wrapper over `_bootstrap_ridge_gpu_batched` whose per-replicate
1249
+ statistic is the ridge coefficients themselves.
1250
+
1251
+ Args:
1252
+ X (np.ndarray | list[np.ndarray]): Feature matrix, shape (n_obs,
1253
+ n_features), or one matrix per banded feature space in fitted order.
1254
+ y (np.ndarray): Target matrix, shape (n_obs, n_voxels) or (n_obs,).
1255
+ alpha (float): Ridge regularization parameter, held fixed.
1256
+ estimate (np.ndarray): The fitted full-data coefficients.
1257
+ n_samples (int): Number of bootstrap replicates. Defaults to 5000.
1258
+ confidence_level (float): Interval confidence level. Defaults to 0.95.
1259
+ feature_space_weights (np.ndarray | None): Fitted banded simplex
1260
+ weights held fixed across replicates. None for ordinary ridge.
1261
+ return_samples (bool): Retain every replicate. Defaults to False.
1262
+ backend (Backend | None): Backend instance (must be a GPU torch backend).
1263
+ None auto-selects.
1264
+ memory_budget_gb (float | None): Working-memory budget in GB. None
1265
+ (default) measures the device's available memory.
1266
+ random_state (int | None): Random seed for reproducibility.
1267
+ progress_bar (bool): Show a progress bar over replicates. Defaults to False.
1268
+
1269
+ Returns:
1270
+ dict[str, np.ndarray]: Bootstrap statistics in the same format as the CPU
1271
+ engine.
1272
+ """
1273
+ spaces = _as_feature_spaces(X)
1274
+ y = np.asarray(y, dtype=np.float64)
1275
+
1276
+ _validate_array_shape_range(y, 1, 2, name="y")
1277
+ for space in spaces:
1278
+ _validate_array_shape(space, 2, name="X")
1279
+ _validate_shape_compatibility(space, y, X_name="X", y_name="y")
1280
+
1281
+ if y.ndim == 1:
1282
+ y = y[:, np.newaxis]
1283
+ n_features = sum(space.shape[1] for space in spaces)
1284
+
1285
+ return _bootstrap_ridge_gpu_batched(
1286
+ spaces,
1287
+ y,
1288
+ alpha,
1289
+ estimate=estimate,
1290
+ compute_sample=lambda coef: coef,
1291
+ output_shape=(n_features, y.shape[1]),
1292
+ desc="GPU bootstrap Ridge weights",
1293
+ confidence_level=confidence_level,
1294
+ feature_space_weights=feature_space_weights,
1295
+ n_samples=n_samples,
1296
+ return_samples=return_samples,
1297
+ backend=backend,
1298
+ memory_budget_gb=memory_budget_gb,
1299
+ random_state=random_state,
1300
+ progress_bar=progress_bar,
1301
+ )
1302
+
1303
+
1304
+ def _bootstrap_ridge_predict_gpu_batched(
1305
+ X,
1306
+ y: np.ndarray,
1307
+ X_pred: np.ndarray,
1308
+ alpha: float,
1309
+ estimate: np.ndarray,
1310
+ n_samples: int = 5000,
1311
+ *,
1312
+ confidence_level: float = 0.95,
1313
+ feature_space_weights: np.ndarray | None = None,
1314
+ return_samples: bool = False,
1315
+ backend=None,
1316
+ memory_budget_gb: float | None = None,
1317
+ random_state: int | None = None,
1318
+ progress_bar: bool = False,
1319
+ ) -> dict[str, np.ndarray]:
1320
+ """Bootstrap ridge predictions on the GPU with automatic batching.
1321
+
1322
+ Thin wrapper over `_bootstrap_ridge_gpu_batched` whose per-replicate
1323
+ statistic is `X_pred @ coef`.
1324
+
1325
+ Args:
1326
+ X (np.ndarray | list[np.ndarray]): Training feature matrix, shape
1327
+ (n_obs, n_features), or one matrix per banded feature space in
1328
+ fitted order.
1329
+ y (np.ndarray): Training target matrix, shape (n_obs, n_voxels) or
1330
+ (n_obs,).
1331
+ X_pred (np.ndarray | list[np.ndarray]): Test feature matrix, shape
1332
+ (n_test, n_features), or one matrix per banded feature space
1333
+ in fitted order.
1334
+ alpha (float): Ridge regularization parameter, held fixed.
1335
+ estimate (np.ndarray): The fitted full-data model evaluated at `X_pred`.
1336
+ n_samples (int): Number of bootstrap replicates. Defaults to 5000.
1337
+ confidence_level (float): Interval confidence level. Defaults to 0.95.
1338
+ feature_space_weights (np.ndarray | None): Fitted banded simplex
1339
+ weights held fixed across replicates. None for ordinary ridge.
1340
+ return_samples (bool): Retain every replicate. Defaults to False.
1341
+ backend (Backend | None): Backend instance (must be a GPU torch backend).
1342
+ None auto-selects.
1343
+ memory_budget_gb (float | None): Working-memory budget in GB. None
1344
+ (default) measures the device's available memory.
1345
+ random_state (int | None): Random seed for reproducibility.
1346
+ progress_bar (bool): Show a progress bar over replicates. Defaults to False.
1347
+
1348
+ Returns:
1349
+ dict[str, np.ndarray]: Bootstrap statistics in the same format as the CPU
1350
+ engine.
1351
+ """
1352
+ spaces = _as_feature_spaces(X)
1353
+ y = np.asarray(y, dtype=np.float64)
1354
+ X_pred = _stack_feature_spaces(X_pred)
1355
+
1356
+ _validate_array_shape_range(y, 1, 2, name="y")
1357
+ for space in spaces:
1358
+ _validate_array_shape(space, 2, name="X")
1359
+ _validate_shape_compatibility(space, y, X_name="X", y_name="y")
1360
+ _validate_array_shape(X_pred, 2, name="X_pred")
1361
+ n_features = sum(space.shape[1] for space in spaces)
1362
+ if n_features != X_pred.shape[1]:
1363
+ raise ValueError(
1364
+ f"X and X_pred must have same n_features: {n_features} != {X_pred.shape[1]}"
1365
+ )
1366
+
1367
+ if y.ndim == 1:
1368
+ y = y[:, np.newaxis]
1369
+
1370
+ return _bootstrap_ridge_gpu_batched(
1371
+ spaces,
1372
+ y,
1373
+ alpha,
1374
+ estimate=estimate,
1375
+ compute_sample=lambda coef: X_pred @ coef,
1376
+ output_shape=(X_pred.shape[0], y.shape[1]),
1377
+ desc="GPU bootstrap Ridge predictions",
1378
+ confidence_level=confidence_level,
1379
+ feature_space_weights=feature_space_weights,
1380
+ n_samples=n_samples,
1381
+ return_samples=return_samples,
1382
+ backend=backend,
1383
+ memory_budget_gb=memory_budget_gb,
1384
+ random_state=random_state,
1385
+ progress_bar=progress_bar,
1386
+ )