nltools 0.6.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nltools/__init__.py +55 -0
- nltools/algorithms/__init__.py +90 -0
- nltools/algorithms/alignment/__init__.py +21 -0
- nltools/algorithms/alignment/procrustes.py +565 -0
- nltools/algorithms/alignment/srm.py +758 -0
- nltools/algorithms/backends.py +1059 -0
- nltools/algorithms/corrections.py +177 -0
- nltools/algorithms/decoding.py +327 -0
- nltools/algorithms/inference/__init__.py +50 -0
- nltools/algorithms/inference/bootstrap.py +1386 -0
- nltools/algorithms/inference/correlation.py +373 -0
- nltools/algorithms/inference/intersubject.py +422 -0
- nltools/algorithms/inference/isc.py +1554 -0
- nltools/algorithms/inference/matrix.py +602 -0
- nltools/algorithms/inference/one_sample.py +288 -0
- nltools/algorithms/inference/random.py +122 -0
- nltools/algorithms/inference/timeseries.py +347 -0
- nltools/algorithms/inference/two_sample.py +212 -0
- nltools/algorithms/inference/utils.py +58 -0
- nltools/algorithms/inference/validation.py +282 -0
- nltools/algorithms/neighborhoods.py +207 -0
- nltools/algorithms/outliers.py +308 -0
- nltools/algorithms/regression.py +83 -0
- nltools/algorithms/signal.py +303 -0
- nltools/algorithms/similarity.py +234 -0
- nltools/algorithms/validation.py +151 -0
- nltools/cross_validation.py +72 -0
- nltools/data/__init__.py +30 -0
- nltools/data/adjacency/__init__.py +875 -0
- nltools/data/adjacency/io.py +111 -0
- nltools/data/adjacency/modeling.py +569 -0
- nltools/data/adjacency/plotting.py +174 -0
- nltools/data/adjacency/state.py +349 -0
- nltools/data/adjacency/stats.py +596 -0
- nltools/data/adjacency/utils.py +79 -0
- nltools/data/atlases/__init__.py +23 -0
- nltools/data/atlases/labeling.py +158 -0
- nltools/data/atlases/loading.py +76 -0
- nltools/data/atlases/registry.py +96 -0
- nltools/data/atlases/reporting.py +456 -0
- nltools/data/braindata/__init__.py +2170 -0
- nltools/data/braindata/analysis.py +1381 -0
- nltools/data/braindata/bootstrap.py +398 -0
- nltools/data/braindata/io.py +896 -0
- nltools/data/braindata/modeling.py +594 -0
- nltools/data/braindata/plotting.py +501 -0
- nltools/data/braindata/prediction.py +1250 -0
- nltools/data/braindata/utils.py +348 -0
- nltools/data/braindata/validation.py +197 -0
- nltools/data/braindata/viewer.js +266 -0
- nltools/data/braindata/viewer.py +770 -0
- nltools/data/combine.py +27 -0
- nltools/data/designmatrix/__init__.py +1032 -0
- nltools/data/designmatrix/append.py +518 -0
- nltools/data/designmatrix/diagnostics.py +248 -0
- nltools/data/designmatrix/io.py +356 -0
- nltools/data/designmatrix/plotting.py +291 -0
- nltools/data/designmatrix/regressors.py +463 -0
- nltools/data/designmatrix/transforms.py +200 -0
- nltools/data/designmatrix/utils.py +350 -0
- nltools/data/ownership.py +129 -0
- nltools/data/results.py +291 -0
- nltools/data/roc/__init__.py +398 -0
- nltools/data/simulator/__init__.py +927 -0
- nltools/data/simulator/haxby.py +124 -0
- nltools/data/validation.py +83 -0
- nltools/datasets.py +218 -0
- nltools/io/__init__.py +10 -0
- nltools/io/events.py +67 -0
- nltools/io/h5.py +246 -0
- nltools/mask.py +403 -0
- nltools/models/__init__.py +11 -0
- nltools/models/glm.py +543 -0
- nltools/models/results.py +49 -0
- nltools/models/ridge.py +1303 -0
- nltools/models/validation.py +26 -0
- nltools/plotting/__init__.py +32 -0
- nltools/plotting/adjacency.py +421 -0
- nltools/plotting/brain.py +669 -0
- nltools/plotting/decomposition.py +111 -0
- nltools/plotting/prediction.py +110 -0
- nltools/resources/covariates_example.csv +161 -0
- nltools/resources/onsets_example.csv +40 -0
- nltools/templates/__init__.py +51 -0
- nltools/templates/config.py +144 -0
- nltools/templates/fetch.py +260 -0
- nltools/templates/matching.py +183 -0
- nltools/templates/paths.py +106 -0
- nltools/templates/registry.py +25 -0
- nltools/utils.py +230 -0
- nltools/version.py +13 -0
- nltools-0.6.0.dev0.dist-info/METADATA +95 -0
- nltools-0.6.0.dev0.dist-info/RECORD +95 -0
- nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
- nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|