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,1059 @@
|
|
|
1
|
+
"""Backend abstraction for CPU/GPU operations.
|
|
2
|
+
|
|
3
|
+
Supports NumPy (CPU-only) and PyTorch (CPU/CUDA/MPS) backends for linear algebra
|
|
4
|
+
operations, so algorithms are written once against NumPy semantics and run on a
|
|
5
|
+
GPU when one is available.
|
|
6
|
+
|
|
7
|
+
This module is also the package's single GPU execution layer: memory budgets
|
|
8
|
+
(`_device_memory_budget`), batch sizing (`_auto_batch_size`), out-of-memory
|
|
9
|
+
recovery (`_compute_oom_safe`), and CPU worker sizing (`_auto_n_jobs_cpu`)
|
|
10
|
+
live only here. Algorithms supply per-item working-set estimates and never do
|
|
11
|
+
their own budget math.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import warnings
|
|
15
|
+
from copy import deepcopy
|
|
16
|
+
import numpy as np
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from nltools.utils import _find_stack_level
|
|
20
|
+
|
|
21
|
+
# Track if we've warned about MPS initialization to avoid spam
|
|
22
|
+
_already_warned_mps_init = [False]
|
|
23
|
+
# Track if we've warned about float64 conversion to avoid spam
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _array_module_for(name):
|
|
27
|
+
"""Return the array module a pickled backend name needs, if it is usable here.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
name (str | None): A backend name — `'numpy'`, `'torch-cpu'`,
|
|
31
|
+
`'torch-cuda'`, or `'torch-mps'`.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
module | None: `numpy` or `torch`, or None when the named device is not
|
|
35
|
+
available in this process.
|
|
36
|
+
"""
|
|
37
|
+
if name == "numpy":
|
|
38
|
+
return np
|
|
39
|
+
try:
|
|
40
|
+
import torch
|
|
41
|
+
except ImportError:
|
|
42
|
+
return None
|
|
43
|
+
if name == "torch-cuda":
|
|
44
|
+
return torch if torch.cuda.is_available() else None
|
|
45
|
+
if name == "torch-mps":
|
|
46
|
+
available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
|
|
47
|
+
return torch if available else None
|
|
48
|
+
if name == "torch-cpu":
|
|
49
|
+
return torch
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Backend:
|
|
54
|
+
"""Backend abstraction for numerical operations.
|
|
55
|
+
|
|
56
|
+
Provides a unified interface for NumPy and PyTorch operations, enabling
|
|
57
|
+
transparent GPU acceleration when available.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
backend (str): Backend type. `'numpy'` is CPU-only NumPy; `'torch'` is
|
|
61
|
+
PyTorch with automatic device detection (cuda, then mps, then cpu);
|
|
62
|
+
`'auto'` picks `'torch'` when PyTorch is installed and `'numpy'`
|
|
63
|
+
otherwise. Defaults to `'numpy'`.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
name (str): Backend identifier: `'numpy'`, `'torch-cpu'`, `'torch-cuda'`,
|
|
67
|
+
or `'torch-mps'`.
|
|
68
|
+
device (str): Device type: `'cpu'`, `'cuda'`, or `'mps'`.
|
|
69
|
+
xp (module): Array library module (`numpy` or `torch`).
|
|
70
|
+
is_gpu (bool): True when the device is a GPU (`'cuda'` or `'mps'`).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, backend: str = "numpy"):
|
|
74
|
+
if backend == "numpy":
|
|
75
|
+
self._init_numpy()
|
|
76
|
+
elif backend == "torch":
|
|
77
|
+
self._init_torch()
|
|
78
|
+
elif backend == "auto":
|
|
79
|
+
self._init_auto()
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Unknown backend: {backend}. Use 'numpy', 'torch', or 'auto'"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __deepcopy__(self, memo):
|
|
86
|
+
"""Copy backend state without trying to pickle its array module."""
|
|
87
|
+
copied = type(self).__new__(type(self))
|
|
88
|
+
memo[id(self)] = copied
|
|
89
|
+
for name, value in self.__dict__.items():
|
|
90
|
+
setattr(copied, name, value if name == "xp" else deepcopy(value, memo))
|
|
91
|
+
return copied
|
|
92
|
+
|
|
93
|
+
def __getstate__(self):
|
|
94
|
+
"""Drop the array module, which is a live module object and unpicklable.
|
|
95
|
+
|
|
96
|
+
`backend_` is public fitted state on `_Ridge`, so a fitted model has to
|
|
97
|
+
survive `pickle` — `BrainData.copy()` and any process-based `n_jobs`
|
|
98
|
+
worker carry one across. Everything else on a backend is a plain string
|
|
99
|
+
or a `torch.device`, both of which pickle fine; `xp` is recovered by
|
|
100
|
+
name in `__setstate__`.
|
|
101
|
+
"""
|
|
102
|
+
state = dict(self.__dict__)
|
|
103
|
+
state.pop("xp", None)
|
|
104
|
+
return state
|
|
105
|
+
|
|
106
|
+
def __setstate__(self, state):
|
|
107
|
+
"""Restore the descriptor as pickled, and the array module if usable.
|
|
108
|
+
|
|
109
|
+
`name` and `device` record the device the model was *fitted* on and are
|
|
110
|
+
restored verbatim: unpickling never re-resolves them, because silently
|
|
111
|
+
turning a CUDA-fitted model into an MPS one would break the
|
|
112
|
+
run-or-raise rule. When that device is not available in this process
|
|
113
|
+
`xp` stays unset, so any attempt to compute through this backend raises
|
|
114
|
+
from `__getattr__` instead of running somewhere else.
|
|
115
|
+
"""
|
|
116
|
+
self.__dict__.update(state)
|
|
117
|
+
module = _array_module_for(state.get("name"))
|
|
118
|
+
if module is not None:
|
|
119
|
+
self.xp = module
|
|
120
|
+
|
|
121
|
+
def __getattr__(self, name):
|
|
122
|
+
"""Explain a missing array module rather than raising a bare AttributeError.
|
|
123
|
+
|
|
124
|
+
Only reached when normal lookup fails, which for `xp` means this backend
|
|
125
|
+
was unpickled on a host without the device it was fitted on. Pinned by
|
|
126
|
+
`test_ridge.py::TestSerialization::test_gpu_fitted_model_round_trips_through_pickle`
|
|
127
|
+
and `::test_unpickling_never_switches_device`.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
name (str): The attribute being looked up.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
RuntimeError: If `xp` is missing because the device is unavailable.
|
|
134
|
+
AttributeError: For any other missing attribute.
|
|
135
|
+
"""
|
|
136
|
+
if name == "xp" and "name" in self.__dict__:
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
f"This backend was fitted on device "
|
|
139
|
+
f"{self.__dict__.get('device')!r} ({self.__dict__['name']}), "
|
|
140
|
+
"which is not available in this process. Refit on an available "
|
|
141
|
+
"device, or construct a new backend with device='cpu'."
|
|
142
|
+
)
|
|
143
|
+
raise AttributeError(name)
|
|
144
|
+
|
|
145
|
+
def _init_numpy(self):
|
|
146
|
+
"""Initialize NumPy backend."""
|
|
147
|
+
self.name = "numpy"
|
|
148
|
+
self.device = "cpu"
|
|
149
|
+
self.xp = np
|
|
150
|
+
self._torch_device = None
|
|
151
|
+
|
|
152
|
+
def _init_torch(self):
|
|
153
|
+
"""Initialize PyTorch backend with device detection."""
|
|
154
|
+
try:
|
|
155
|
+
import torch
|
|
156
|
+
except ImportError:
|
|
157
|
+
raise ImportError(
|
|
158
|
+
"PyTorch not installed. Install with: pip install torch\n"
|
|
159
|
+
"Or use backend='numpy' for CPU-only operations."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
self.xp = torch
|
|
163
|
+
|
|
164
|
+
# Detect best available device
|
|
165
|
+
if torch.cuda.is_available():
|
|
166
|
+
self.device = "cuda"
|
|
167
|
+
self._torch_device = torch.device("cuda")
|
|
168
|
+
self.name = "torch-cuda"
|
|
169
|
+
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
170
|
+
self.device = "mps"
|
|
171
|
+
self._torch_device = torch.device("mps")
|
|
172
|
+
self.name = "torch-mps"
|
|
173
|
+
# Warn about MPS precision limitations
|
|
174
|
+
if not _already_warned_mps_init[0]:
|
|
175
|
+
warnings.warn(
|
|
176
|
+
"torch-mps backend uses float32 precision due to MPS framework limitations. "
|
|
177
|
+
"This may result in reduced numerical precision compared to float64 backends. "
|
|
178
|
+
"For high-precision requirements, consider using 'torch' (CPU) or 'numpy' backends.",
|
|
179
|
+
UserWarning,
|
|
180
|
+
stacklevel=_find_stack_level(),
|
|
181
|
+
)
|
|
182
|
+
_already_warned_mps_init[0] = True
|
|
183
|
+
else:
|
|
184
|
+
self.device = "cpu"
|
|
185
|
+
self._torch_device = torch.device("cpu")
|
|
186
|
+
self.name = "torch-cpu"
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def is_gpu(self):
|
|
190
|
+
"""True when the resolved device is a GPU (`'cuda'` or `'mps'`)."""
|
|
191
|
+
return self.device in ("cuda", "mps")
|
|
192
|
+
|
|
193
|
+
def _init_auto(self):
|
|
194
|
+
"""Automatically select best backend."""
|
|
195
|
+
import importlib.util
|
|
196
|
+
|
|
197
|
+
# Check if PyTorch is available without importing it
|
|
198
|
+
if importlib.util.find_spec("torch") is not None:
|
|
199
|
+
# PyTorch available, use it
|
|
200
|
+
self._init_torch()
|
|
201
|
+
else:
|
|
202
|
+
# Fall back to NumPy
|
|
203
|
+
self._init_numpy()
|
|
204
|
+
|
|
205
|
+
def to_numpy(self, arr):
|
|
206
|
+
"""Convert an array back to NumPy.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
arr (np.ndarray | torch.Tensor): Array to convert.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
np.ndarray: The input as a NumPy array.
|
|
213
|
+
"""
|
|
214
|
+
if self.name == "numpy":
|
|
215
|
+
# NumPy backend: identity operation
|
|
216
|
+
return arr
|
|
217
|
+
# PyTorch backend: move to CPU and convert
|
|
218
|
+
import torch
|
|
219
|
+
|
|
220
|
+
if isinstance(arr, torch.Tensor):
|
|
221
|
+
return arr.cpu().numpy()
|
|
222
|
+
return arr
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# Static utilities
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def dtype_to_str(dtype):
|
|
230
|
+
"""Normalize a dtype (numpy, torch, or string) to its string name.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
dtype (str | np.dtype | type | torch.dtype | None): Data type to convert.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
str | None: The dtype name (e.g. `"float32"`, `"float64"`), or None if
|
|
237
|
+
the input was None.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
NotImplementedError: If the input cannot be interpreted as a dtype.
|
|
241
|
+
"""
|
|
242
|
+
if isinstance(dtype, str):
|
|
243
|
+
return dtype
|
|
244
|
+
if dtype is None:
|
|
245
|
+
return None
|
|
246
|
+
# numpy dtype instances (np.dtype('float32')) and type objects (np.float32)
|
|
247
|
+
if hasattr(dtype, "name"):
|
|
248
|
+
return dtype.name
|
|
249
|
+
# torch dtypes: str(torch.float32) == "torch.float32"
|
|
250
|
+
dtype_str = str(dtype)
|
|
251
|
+
if "torch." in dtype_str:
|
|
252
|
+
return dtype_str.split("torch.")[-1]
|
|
253
|
+
# last resort: try numpy conversion
|
|
254
|
+
try:
|
|
255
|
+
return np.dtype(dtype).name
|
|
256
|
+
except (TypeError, ValueError):
|
|
257
|
+
pass
|
|
258
|
+
raise NotImplementedError(f"Cannot convert dtype {dtype} to string")
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
# Array conversion
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def asarray(self, x, dtype=None, device=None):
|
|
265
|
+
"""Convert input to a backend array.
|
|
266
|
+
|
|
267
|
+
Handles numpy arrays, lists, and torch tensors. Places the result on
|
|
268
|
+
the backend's device (or an explicit `device`). On MPS a float64 dtype
|
|
269
|
+
is replaced by float32, which is all the device supports.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
x (array-like | torch.Tensor | list): Input data.
|
|
273
|
+
dtype (str | np.dtype | torch.dtype | None): Desired dtype. If None,
|
|
274
|
+
inferred from the input.
|
|
275
|
+
device (str | torch.device | None): Target device (e.g. `"cpu"`,
|
|
276
|
+
`"cuda"`). Ignored for the numpy backend. If None, uses the
|
|
277
|
+
backend's default device.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
np.ndarray | torch.Tensor: Backend array.
|
|
281
|
+
"""
|
|
282
|
+
if self.name == "numpy":
|
|
283
|
+
if dtype is not None:
|
|
284
|
+
dtype = self.dtype_to_str(dtype)
|
|
285
|
+
try:
|
|
286
|
+
return np.asarray(x, dtype=dtype)
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
# torch tensor on CPU
|
|
290
|
+
try:
|
|
291
|
+
return np.asarray(x.cpu().numpy(), dtype=dtype)
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
return np.asarray(x, dtype=dtype)
|
|
295
|
+
import torch
|
|
296
|
+
|
|
297
|
+
if dtype is None:
|
|
298
|
+
if isinstance(x, torch.Tensor):
|
|
299
|
+
dtype = x.dtype
|
|
300
|
+
elif hasattr(x, "dtype") and hasattr(x.dtype, "name"):
|
|
301
|
+
dtype = x.dtype.name
|
|
302
|
+
if dtype is not None:
|
|
303
|
+
dtype_s = self.dtype_to_str(dtype)
|
|
304
|
+
dtype = getattr(torch, dtype_s)
|
|
305
|
+
if device is None:
|
|
306
|
+
device = self._torch_device
|
|
307
|
+
if isinstance(x, torch.Tensor) and device is None:
|
|
308
|
+
device = x.device
|
|
309
|
+
# MPS doesn't support float64 — enforce float32
|
|
310
|
+
if (
|
|
311
|
+
self.device == "mps"
|
|
312
|
+
and dtype is not None
|
|
313
|
+
and self.dtype_to_str(dtype) == "float64"
|
|
314
|
+
):
|
|
315
|
+
dtype = torch.float32
|
|
316
|
+
try:
|
|
317
|
+
return torch.as_tensor(x, dtype=dtype, device=device)
|
|
318
|
+
except Exception:
|
|
319
|
+
arr = np.asarray(x, dtype=self.dtype_to_str(dtype))
|
|
320
|
+
return torch.as_tensor(arr, dtype=dtype, device=device)
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
# Array creation with shape override
|
|
324
|
+
# ------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------
|
|
327
|
+
# Device transfer
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Compat ops (differ between numpy and torch)
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _resolve_backend(parallel):
|
|
336
|
+
"""Coerce a backend specifier into a `_Backend` instance.
|
|
337
|
+
|
|
338
|
+
Accepts the values callers thread through the algorithms package. An
|
|
339
|
+
existing `_Backend` is returned unchanged, which is the reason to prefer
|
|
340
|
+
this over constructing `_Backend(...)` at each call site: device detection
|
|
341
|
+
and the torch import happen once, upstream.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
parallel (str | Backend | None): Backend specifier. `None` or `"cpu"`
|
|
345
|
+
gives the numpy backend; `"gpu"` requires CUDA or MPS; `"numpy"`,
|
|
346
|
+
`"torch"`, and `"auto"` are passed to `_Backend(...)`; a `_Backend`
|
|
347
|
+
instance is returned as-is.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
_Backend: Resolved backend instance.
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
ValueError: If `parallel` is a string outside the accepted set.
|
|
354
|
+
RuntimeError: If `parallel="gpu"` and no accelerator is available.
|
|
355
|
+
"""
|
|
356
|
+
if isinstance(parallel, _Backend):
|
|
357
|
+
return parallel
|
|
358
|
+
if parallel in (None, "cpu"):
|
|
359
|
+
return _Backend("numpy")
|
|
360
|
+
if parallel == "gpu":
|
|
361
|
+
backend = _Backend("torch")
|
|
362
|
+
if not backend.is_gpu:
|
|
363
|
+
raise RuntimeError(
|
|
364
|
+
"GPU requested explicitly, but no GPU accelerator is available. "
|
|
365
|
+
"Use 'cpu' or 'auto' to allow CPU execution."
|
|
366
|
+
)
|
|
367
|
+
return backend
|
|
368
|
+
if parallel in ("numpy", "torch", "auto"):
|
|
369
|
+
return _Backend(parallel)
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f"parallel must be None, 'cpu', 'gpu', 'numpy', 'torch', 'auto', "
|
|
372
|
+
f"or a Backend instance; got: {parallel!r}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def check_gpu_available() -> tuple[bool, dict[str, Any]]:
|
|
377
|
+
"""Check whether GPU acceleration is available.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
tuple[bool, dict[str, Any]]: `(available, info)`. `available` is True when
|
|
381
|
+
a CUDA or MPS device is usable. `info` has keys `'backend'`
|
|
382
|
+
(`'torch'` or `'numpy'`), `'device'` (`'cpu'`, `'cuda'`, or `'mps'`),
|
|
383
|
+
and `'device_name'` (human-readable device name).
|
|
384
|
+
"""
|
|
385
|
+
try:
|
|
386
|
+
import torch
|
|
387
|
+
|
|
388
|
+
if torch.cuda.is_available():
|
|
389
|
+
return True, {
|
|
390
|
+
"backend": "torch",
|
|
391
|
+
"device": "cuda",
|
|
392
|
+
"device_name": torch.cuda.get_device_name(0),
|
|
393
|
+
}
|
|
394
|
+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
395
|
+
return True, {
|
|
396
|
+
"backend": "torch",
|
|
397
|
+
"device": "mps",
|
|
398
|
+
"device_name": "Apple Metal Performance Shaders",
|
|
399
|
+
}
|
|
400
|
+
return False, {
|
|
401
|
+
"backend": "torch",
|
|
402
|
+
"device": "cpu",
|
|
403
|
+
"device_name": "CPU (PyTorch available)",
|
|
404
|
+
}
|
|
405
|
+
except ImportError:
|
|
406
|
+
return False, {
|
|
407
|
+
"backend": "numpy",
|
|
408
|
+
"device": "cpu",
|
|
409
|
+
"device_name": "CPU (NumPy only)",
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _auto_select_backend(n_samples: int, n_features: int, cv: int = 1) -> _Backend:
|
|
414
|
+
"""Select a backend from the problem size.
|
|
415
|
+
|
|
416
|
+
Small problems stay on NumPy to avoid GPU transfer overhead; large problems
|
|
417
|
+
prefer the GPU when one is available. The effective size is
|
|
418
|
+
`n_samples * n_features * cv`.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
n_samples (int): Number of samples in the dataset.
|
|
422
|
+
n_features (int): Number of features in the dataset.
|
|
423
|
+
cv (int): Number of cross-validation folds, which multiplies the effective
|
|
424
|
+
size. Defaults to 1.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
_Backend: The selected backend.
|
|
428
|
+
|
|
429
|
+
Note:
|
|
430
|
+
Below 10M elements the numpy backend is returned. Above 30M elements the
|
|
431
|
+
torch backend is returned when a GPU is available, as it is for any
|
|
432
|
+
cross-validated problem (`cv > 1`) with a GPU. Everything else falls to
|
|
433
|
+
`_Backend('auto')`.
|
|
434
|
+
"""
|
|
435
|
+
# Compute effective problem size
|
|
436
|
+
problem_size = n_samples * n_features * cv
|
|
437
|
+
|
|
438
|
+
# Thresholds
|
|
439
|
+
SMALL_THRESHOLD = 10_000_000 # 10M elements
|
|
440
|
+
LARGE_THRESHOLD = 30_000_000 # 30M elements
|
|
441
|
+
|
|
442
|
+
# Check GPU availability
|
|
443
|
+
gpu_available, _ = check_gpu_available()
|
|
444
|
+
|
|
445
|
+
# Decision logic
|
|
446
|
+
if problem_size < SMALL_THRESHOLD:
|
|
447
|
+
# Small problem: NumPy is efficient enough
|
|
448
|
+
return _Backend("numpy")
|
|
449
|
+
if problem_size > LARGE_THRESHOLD and gpu_available:
|
|
450
|
+
# Large problem with GPU: Use PyTorch
|
|
451
|
+
return _Backend("torch")
|
|
452
|
+
if cv > 1 and gpu_available:
|
|
453
|
+
# Cross-validation with GPU: Prefer PyTorch
|
|
454
|
+
return _Backend("torch")
|
|
455
|
+
# Default: Try auto-selection (falls back to NumPy if no GPU)
|
|
456
|
+
return _Backend("auto")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ----------------------------------------------------------------------
|
|
460
|
+
# Core memory / batching layer
|
|
461
|
+
#
|
|
462
|
+
# Single source of truth for how nltools sizes device work: measured
|
|
463
|
+
# memory budgets, one batch-size calculator, and reactive OOM recovery.
|
|
464
|
+
# Every GPU/batched code path in the package must budget through these
|
|
465
|
+
# helpers rather than hard-coding constants or rolling its own math.
|
|
466
|
+
# ----------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
_FALLBACK_BUDGET_GB = 4.0
|
|
469
|
+
# Fraction of free CUDA memory a computation may claim.
|
|
470
|
+
_CUDA_HEADROOM = 0.8
|
|
471
|
+
# Fraction of available system RAM for CPU work and MPS (unified memory).
|
|
472
|
+
_SYSTEM_HEADROOM = 0.5
|
|
473
|
+
BATCH_WORKING_SET_CEILING_GB = 8.0
|
|
474
|
+
"""Saturation ceiling for batch sizing, in GB of per-batch working set.
|
|
475
|
+
|
|
476
|
+
Batches beyond this compute no faster (GPU kernels saturate at moderate working
|
|
477
|
+
sets) but add allocation latency and, on unified-memory systems, starve the host:
|
|
478
|
+
a measured ~100 GB budget on a 128 GB GB10 sized ~100 GB ISC batches, 4.2s → 16.1s
|
|
479
|
+
and a wedged machine. The cap applies only to *measured* budgets — an explicit
|
|
480
|
+
`max_gpu_memory_gb` is the documented contract and always wins, uncapped.
|
|
481
|
+
Capacity reasoning (OOM recovery, single-item-too-large errors) still uses the
|
|
482
|
+
true measured budget. See `_device_memory_budget(cap_for_batching=True)`.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _device_memory_budget(
|
|
487
|
+
backend: "_Backend | None" = None,
|
|
488
|
+
max_gpu_memory_gb: float | None = None,
|
|
489
|
+
*,
|
|
490
|
+
cap_for_batching: bool = False,
|
|
491
|
+
) -> float:
|
|
492
|
+
"""Usable memory budget in GB for a backend's device.
|
|
493
|
+
|
|
494
|
+
An explicit `max_gpu_memory_gb` always wins, uncapped. Otherwise the
|
|
495
|
+
budget is measured at call time: free CUDA memory (with headroom) on
|
|
496
|
+
CUDA devices; available system RAM (with headroom) for CPU and MPS,
|
|
497
|
+
which share unified/system memory. When nothing can be measured the
|
|
498
|
+
conservative 4 GB fallback applies.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
backend (Backend | None): Resolved backend whose device the work runs
|
|
502
|
+
on. None is treated as CPU.
|
|
503
|
+
max_gpu_memory_gb (float | None): Explicit budget override in GB. Must
|
|
504
|
+
be positive.
|
|
505
|
+
cap_for_batching (bool): Pass True when the budget sizes batches — a
|
|
506
|
+
*measured* budget is then capped at `BATCH_WORKING_SET_CEILING_GB`,
|
|
507
|
+
because working sets beyond the saturation ceiling add allocation
|
|
508
|
+
cost without throughput gain and starve unified-memory hosts. Never
|
|
509
|
+
applied to an explicit `max_gpu_memory_gb`; capacity queries (the
|
|
510
|
+
default) stay uncapped.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
float: Budget in GB.
|
|
514
|
+
|
|
515
|
+
Raises:
|
|
516
|
+
ValueError: If `max_gpu_memory_gb` is not positive.
|
|
517
|
+
"""
|
|
518
|
+
if max_gpu_memory_gb is not None:
|
|
519
|
+
if max_gpu_memory_gb <= 0:
|
|
520
|
+
raise ValueError(
|
|
521
|
+
f"max_gpu_memory_gb must be positive, got {max_gpu_memory_gb!r}"
|
|
522
|
+
)
|
|
523
|
+
return float(max_gpu_memory_gb)
|
|
524
|
+
measured = None
|
|
525
|
+
if getattr(backend, "device", "cpu") == "cuda":
|
|
526
|
+
try:
|
|
527
|
+
import torch
|
|
528
|
+
|
|
529
|
+
free_bytes, _ = torch.cuda.mem_get_info()
|
|
530
|
+
measured = free_bytes * _CUDA_HEADROOM / 1e9
|
|
531
|
+
except Exception: # pragma: no cover - depends on driver state
|
|
532
|
+
pass
|
|
533
|
+
if measured is None:
|
|
534
|
+
try:
|
|
535
|
+
import psutil
|
|
536
|
+
|
|
537
|
+
measured = psutil.virtual_memory().available * _SYSTEM_HEADROOM / 1e9
|
|
538
|
+
except ImportError: # pragma: no cover - psutil ships with the dev env
|
|
539
|
+
measured = _FALLBACK_BUDGET_GB
|
|
540
|
+
if cap_for_batching:
|
|
541
|
+
return min(measured, BATCH_WORKING_SET_CEILING_GB)
|
|
542
|
+
return measured
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _gb_to_bytes(gb: float) -> int:
|
|
546
|
+
"""Convert a GB budget to bytes — the package's one GB↔bytes conversion."""
|
|
547
|
+
return int(gb * 1e9)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
#: Allowance for the transient buffers Himalaya's fixed-hyperparameter solve
|
|
551
|
+
#: holds beyond the resampled design and response themselves.
|
|
552
|
+
_RIDGE_BOOTSTRAP_SOLVER_OVERHEAD = 3.0
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _auto_batch_size(
|
|
556
|
+
n_items: int,
|
|
557
|
+
bytes_per_item: float,
|
|
558
|
+
*,
|
|
559
|
+
budget_gb: float,
|
|
560
|
+
overhead: float = 1.0,
|
|
561
|
+
) -> tuple[int, int]:
|
|
562
|
+
"""Split `n_items` into batches that fit a memory budget.
|
|
563
|
+
|
|
564
|
+
The one batch calculator for the package. Callers supply only the
|
|
565
|
+
per-item working-set estimate (`bytes_per_item`) and an algorithm's
|
|
566
|
+
allocation `overhead` factor; the clamp/ceil policy lives here.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
n_items (int): Total number of items (permutations, targets, ...).
|
|
570
|
+
bytes_per_item (float): Dominant working-set size of one item in bytes.
|
|
571
|
+
budget_gb (float): Memory budget from `_device_memory_budget`.
|
|
572
|
+
overhead (float): Multiplier for intermediate allocations (e.g. 3.0 when
|
|
573
|
+
the computation holds ~3x the input working set). Defaults to 1.0.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
tuple[int, int]: `(batch_size, n_batches)` with
|
|
577
|
+
`batch_size * n_batches >= n_items`.
|
|
578
|
+
|
|
579
|
+
Raises:
|
|
580
|
+
ValueError: If the inputs are invalid or one item exceeds the budget.
|
|
581
|
+
"""
|
|
582
|
+
if n_items <= 0:
|
|
583
|
+
raise ValueError(f"n_items must be positive, got {n_items}")
|
|
584
|
+
if budget_gb <= 0:
|
|
585
|
+
raise ValueError(f"budget_gb must be positive, got {budget_gb}")
|
|
586
|
+
per_item = bytes_per_item * overhead
|
|
587
|
+
if per_item <= 0:
|
|
588
|
+
batch_size = n_items
|
|
589
|
+
else:
|
|
590
|
+
capacity = int(_gb_to_bytes(budget_gb) / per_item)
|
|
591
|
+
if capacity < 1:
|
|
592
|
+
raise ValueError(
|
|
593
|
+
f"one item requires {per_item / 1e9:.6g} GB, exceeding the "
|
|
594
|
+
f"{budget_gb:.6g} GB memory budget"
|
|
595
|
+
)
|
|
596
|
+
batch_size = min(capacity, n_items)
|
|
597
|
+
n_batches = int(np.ceil(n_items / batch_size))
|
|
598
|
+
return batch_size, n_batches
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _ridge_bootstrap_batch_size(
|
|
602
|
+
n_bootstrap: int,
|
|
603
|
+
*,
|
|
604
|
+
n_samples: int,
|
|
605
|
+
n_features: int,
|
|
606
|
+
n_targets: int,
|
|
607
|
+
output_shape: tuple[int, ...],
|
|
608
|
+
device_itemsize: int = 4,
|
|
609
|
+
max_gpu_memory_gb: float | None = None,
|
|
610
|
+
backend=None,
|
|
611
|
+
) -> tuple[int, int]:
|
|
612
|
+
"""Size a Ridge-bootstrap batch against a memory budget.
|
|
613
|
+
|
|
614
|
+
Models what a batch of replicates actually holds. Each replicate solves on
|
|
615
|
+
its own, so device residency is one replicate's resampled design
|
|
616
|
+
`(n_samples, n_features)` and response `(n_samples, n_targets)` with an
|
|
617
|
+
allowance for the solver's own buffers. What accumulates across a batch is
|
|
618
|
+
the host-side result list: `batch_size` float64 arrays of `output_shape`,
|
|
619
|
+
which for a prediction bootstrap is sized by an arbitrary `X_test` row count
|
|
620
|
+
and can dominate everything else. Both terms are charged per replicate so
|
|
621
|
+
the batch cannot outgrow the budget it was given.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
n_bootstrap (int): Total number of bootstrap replicates.
|
|
625
|
+
n_samples (int): Observations in the training data.
|
|
626
|
+
n_features (int): Total feature count across all feature spaces.
|
|
627
|
+
n_targets (int): Number of targets (voxels).
|
|
628
|
+
output_shape (tuple[int, ...]): Shape of one retained replicate result.
|
|
629
|
+
device_itemsize (int): Bytes per element of the solver's working dtype
|
|
630
|
+
(4 on MPS, 8 elsewhere). Defaults to 4.
|
|
631
|
+
max_gpu_memory_gb (float | None): Explicit budget in GB, or None to
|
|
632
|
+
measure the device.
|
|
633
|
+
backend (Backend | None): Resolved backend, used only to measure the
|
|
634
|
+
budget when `max_gpu_memory_gb` is None.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
tuple[int, int]: `(batch_size, n_batches)`.
|
|
638
|
+
"""
|
|
639
|
+
budget_gb = _device_memory_budget(
|
|
640
|
+
backend, max_gpu_memory_gb=max_gpu_memory_gb, cap_for_batching=True
|
|
641
|
+
)
|
|
642
|
+
resident = (
|
|
643
|
+
(n_samples * n_features + n_samples * n_targets)
|
|
644
|
+
* device_itemsize
|
|
645
|
+
* _RIDGE_BOOTSTRAP_SOLVER_OVERHEAD
|
|
646
|
+
)
|
|
647
|
+
retained = int(np.prod(output_shape)) * 8 # host float64 accumulation
|
|
648
|
+
return _auto_batch_size(
|
|
649
|
+
n_bootstrap, resident + retained, budget_gb=budget_gb, overhead=1.0
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
#: Bytes per retained bootstrap output value. Every completed replicate and
|
|
654
|
+
#: every summary payload is converted to CPU float64 before it is retained.
|
|
655
|
+
_BOOTSTRAP_OUTPUT_ITEMSIZE = 8
|
|
656
|
+
|
|
657
|
+
#: Output-sized arrays a bootstrap run always holds beyond its retained
|
|
658
|
+
#: replicates: the two Welford accumulators (running mean and running sum of
|
|
659
|
+
#: squared deviations) and the four `BootstrapResult` summary payloads.
|
|
660
|
+
_BOOTSTRAP_FIXED_OUTPUT_ARRAYS = 6
|
|
661
|
+
|
|
662
|
+
#: Replicates the streaming accumulator buffers before folding them into its
|
|
663
|
+
#: bounded tails. Batching the partition keeps the per-replicate cost near a
|
|
664
|
+
#: plain comparison, but the buffer and the two temporaries a flush creates are
|
|
665
|
+
#: real output-sized allocations — so the constant lives here, with the budget
|
|
666
|
+
#: that has to charge for it, and the accumulator imports it. It doubles as the
|
|
667
|
+
#: per-worker dispatch window (`_bootstrap_replicate_window`).
|
|
668
|
+
BOOTSTRAP_TAIL_FLUSH_BLOCK = 64
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _bootstrap_replicate_window(n_samples: int, *, n_workers: int = 1) -> int:
|
|
672
|
+
"""Replicates a CPU bootstrap may hold in flight before it must aggregate.
|
|
673
|
+
|
|
674
|
+
`joblib.Parallel` dispatches eagerly and queues finished results, so neither
|
|
675
|
+
`pre_dispatch` nor `return_as="generator"` bounds how many replicate arrays
|
|
676
|
+
are alive at once. The engines therefore dispatch in windows of this size
|
|
677
|
+
and fold each window into the accumulator before opening the next. The
|
|
678
|
+
window scales with the worker count — enough to keep every worker busy —
|
|
679
|
+
and never with `n_samples`, which is what makes peak memory independent of
|
|
680
|
+
the replicate count.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
n_samples (int): Total number of bootstrap replicates.
|
|
684
|
+
n_workers (int): Planned CPU worker count.
|
|
685
|
+
|
|
686
|
+
Returns:
|
|
687
|
+
int: Replicates per dispatch window, at least one.
|
|
688
|
+
|
|
689
|
+
Examples:
|
|
690
|
+
```python
|
|
691
|
+
_bootstrap_replicate_window(5000, n_workers=4) # → 256
|
|
692
|
+
_bootstrap_replicate_window(50, n_workers=4) # → 50
|
|
693
|
+
```
|
|
694
|
+
"""
|
|
695
|
+
per_worker = BOOTSTRAP_TAIL_FLUSH_BLOCK * max(1, int(n_workers))
|
|
696
|
+
return max(1, min(int(n_samples), per_worker))
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _bootstrap_retained_tail_size(n_samples: int, *, confidence_level: float) -> int:
|
|
700
|
+
"""Per-element retained tail size for a streaming percentile interval.
|
|
701
|
+
|
|
702
|
+
The streaming accumulator reproduces the complete-distribution percentile
|
|
703
|
+
interval by keeping this many of the smallest and largest values seen for
|
|
704
|
+
each output element:
|
|
705
|
+
|
|
706
|
+
```text
|
|
707
|
+
k = ceil((B - 1) * (1 - c) / 2) + 1
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
That is exactly the number of order statistics NumPy's linear interpolation
|
|
711
|
+
can reach at either end, so nothing the interval needs is discarded.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
n_samples (int): Number of bootstrap replicates, `B`.
|
|
715
|
+
confidence_level (float): Interval confidence level, `c`, in `(0, 1)`.
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
int: Values retained per element at each end, never more than
|
|
719
|
+
`n_samples`.
|
|
720
|
+
|
|
721
|
+
Examples:
|
|
722
|
+
```python
|
|
723
|
+
_bootstrap_retained_tail_size(1000, confidence_level=0.95) # → 26
|
|
724
|
+
```
|
|
725
|
+
"""
|
|
726
|
+
half_alpha = (1 - confidence_level) / 2
|
|
727
|
+
k = int(np.ceil((n_samples - 1) * half_alpha)) + 1
|
|
728
|
+
return min(k, int(n_samples))
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _bootstrap_output_bytes(
|
|
732
|
+
output_shape: tuple[int, ...],
|
|
733
|
+
n_samples: int,
|
|
734
|
+
*,
|
|
735
|
+
confidence_level: float,
|
|
736
|
+
return_samples: bool,
|
|
737
|
+
n_workers: int = 1,
|
|
738
|
+
) -> int:
|
|
739
|
+
"""Bytes a bootstrap run must hold for its retained output.
|
|
740
|
+
|
|
741
|
+
Charges eight bytes for every output-sized array a run holds at once: the
|
|
742
|
+
two bounded tails, the replicates buffered before the next flush and the
|
|
743
|
+
two temporaries that flush builds, one dispatch window of in-flight
|
|
744
|
+
replicates, every replicate when `return_samples=True`, and the two Welford
|
|
745
|
+
accumulators plus the four summary payloads.
|
|
746
|
+
|
|
747
|
+
Args:
|
|
748
|
+
output_shape (tuple[int, ...]): Shape of one replicate's output.
|
|
749
|
+
n_samples (int): Number of bootstrap replicates.
|
|
750
|
+
confidence_level (float): Interval confidence level, which sets the
|
|
751
|
+
retained tail size.
|
|
752
|
+
return_samples (bool): Whether the complete distribution is retained.
|
|
753
|
+
n_workers (int): Planned CPU worker count, which sets the dispatch
|
|
754
|
+
window. Defaults to 1 (the GPU driver budgets its own batch through
|
|
755
|
+
`_ridge_bootstrap_batch_size` instead).
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
int: Required bytes.
|
|
759
|
+
"""
|
|
760
|
+
output_size = int(np.prod(output_shape)) if output_shape else 1
|
|
761
|
+
tail_size = _bootstrap_retained_tail_size(
|
|
762
|
+
n_samples, confidence_level=confidence_level
|
|
763
|
+
)
|
|
764
|
+
buffered = min(BOOTSTRAP_TAIL_FLUSH_BLOCK, int(n_samples))
|
|
765
|
+
arrays = (
|
|
766
|
+
2 * tail_size # the two bounded tails
|
|
767
|
+
+ buffered # replicates buffered before the next flush
|
|
768
|
+
+ 2 * (tail_size + buffered) # a flush's concatenation and partition
|
|
769
|
+
+ _bootstrap_replicate_window(n_samples, n_workers=n_workers)
|
|
770
|
+
+ (int(n_samples) if return_samples else 0)
|
|
771
|
+
+ _BOOTSTRAP_FIXED_OUTPUT_ARRAYS
|
|
772
|
+
)
|
|
773
|
+
return output_size * arrays * _BOOTSTRAP_OUTPUT_ITEMSIZE
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _bootstrap_memory_preflight(
|
|
777
|
+
output_shape: tuple[int, ...],
|
|
778
|
+
n_samples: int,
|
|
779
|
+
*,
|
|
780
|
+
confidence_level: float,
|
|
781
|
+
return_samples: bool,
|
|
782
|
+
n_workers: int = 1,
|
|
783
|
+
memory_budget_gb: float | None = None,
|
|
784
|
+
backend: "_Backend | None" = None,
|
|
785
|
+
) -> float:
|
|
786
|
+
"""Raise before any resampling if the retained output cannot fit the budget.
|
|
787
|
+
|
|
788
|
+
The package's one bootstrap memory gate. It runs against the measured
|
|
789
|
+
budget when `memory_budget_gb` is None, so a run that would otherwise die
|
|
790
|
+
part-way through fails immediately and says what it needed. It never
|
|
791
|
+
weakens the interval, reduces `n_samples`, or disables `return_samples`.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
output_shape (tuple[int, ...]): Shape of one replicate's output.
|
|
795
|
+
n_samples (int): Number of bootstrap replicates.
|
|
796
|
+
confidence_level (float): Interval confidence level.
|
|
797
|
+
return_samples (bool): Whether the complete distribution is retained.
|
|
798
|
+
n_workers (int): Planned CPU worker count, which sets the dispatch
|
|
799
|
+
window. Defaults to 1.
|
|
800
|
+
memory_budget_gb (float | None): Explicit budget in GB, or None to
|
|
801
|
+
measure the device.
|
|
802
|
+
backend (Backend | None): Resolved backend whose device is measured
|
|
803
|
+
when `memory_budget_gb` is None. None means the CPU.
|
|
804
|
+
|
|
805
|
+
Returns:
|
|
806
|
+
float: The required storage in GB.
|
|
807
|
+
|
|
808
|
+
Raises:
|
|
809
|
+
ValueError: If the required storage exceeds the budget.
|
|
810
|
+
"""
|
|
811
|
+
required_bytes = _bootstrap_output_bytes(
|
|
812
|
+
output_shape,
|
|
813
|
+
n_samples,
|
|
814
|
+
confidence_level=confidence_level,
|
|
815
|
+
return_samples=return_samples,
|
|
816
|
+
n_workers=n_workers,
|
|
817
|
+
)
|
|
818
|
+
required_gb = required_bytes / 1e9
|
|
819
|
+
budget_gb = _device_memory_budget(backend, max_gpu_memory_gb=memory_budget_gb)
|
|
820
|
+
if required_gb > budget_gb:
|
|
821
|
+
tail_size = _bootstrap_retained_tail_size(
|
|
822
|
+
n_samples, confidence_level=confidence_level
|
|
823
|
+
)
|
|
824
|
+
retained = f"{tail_size} values per element at each tail"
|
|
825
|
+
if return_samples:
|
|
826
|
+
retained = (
|
|
827
|
+
f"all {n_samples} replicates (return_samples=True) plus {retained}"
|
|
828
|
+
)
|
|
829
|
+
source = (
|
|
830
|
+
"the explicit memory_budget_gb"
|
|
831
|
+
if memory_budget_gb is not None
|
|
832
|
+
else "the measured device budget"
|
|
833
|
+
)
|
|
834
|
+
raise ValueError(
|
|
835
|
+
f"bootstrap needs {required_gb:.6g} GB to retain output of shape "
|
|
836
|
+
f"{tuple(output_shape)} over {n_samples} replicates — it keeps "
|
|
837
|
+
f"{retained} — which exceeds the {budget_gb:.6g} GB budget "
|
|
838
|
+
f"({source}). Lower n_samples, mask to fewer voxels, turn off "
|
|
839
|
+
f"return_samples, or raise the budget with "
|
|
840
|
+
f"memory_budget_gb=<GB>."
|
|
841
|
+
)
|
|
842
|
+
return required_gb
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _bootstrap_n_jobs_cpu(
|
|
846
|
+
data_size_mb: float,
|
|
847
|
+
n_samples: int,
|
|
848
|
+
*,
|
|
849
|
+
memory_budget_gb: float | None = None,
|
|
850
|
+
n_jobs: int = -1,
|
|
851
|
+
) -> int:
|
|
852
|
+
"""CPU worker count for a bootstrap run, capped by `n_jobs` and by memory.
|
|
853
|
+
|
|
854
|
+
`n_jobs` is the ceiling the caller asked for; this planner may return
|
|
855
|
+
fewer when the per-worker copy of the data would not fit the budget. It
|
|
856
|
+
never returns zero, because the preflight — not the worker planner — is
|
|
857
|
+
where a run that cannot fit is refused.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
data_size_mb (float): Size of the array each worker pickles, in MB.
|
|
861
|
+
n_samples (int): Number of bootstrap replicates.
|
|
862
|
+
memory_budget_gb (float | None): Explicit budget in GB, or None to
|
|
863
|
+
measure available system memory.
|
|
864
|
+
n_jobs (int): Worker ceiling, with joblib's negative convention
|
|
865
|
+
(`-1` = all cores).
|
|
866
|
+
|
|
867
|
+
Returns:
|
|
868
|
+
int: Worker count for `joblib.Parallel(n_jobs=...)`.
|
|
869
|
+
"""
|
|
870
|
+
import multiprocessing
|
|
871
|
+
|
|
872
|
+
cores = multiprocessing.cpu_count()
|
|
873
|
+
ceiling = cores if n_jobs == -1 else n_jobs
|
|
874
|
+
if ceiling < 0:
|
|
875
|
+
ceiling = cores + 1 + ceiling
|
|
876
|
+
ceiling = max(1, int(ceiling))
|
|
877
|
+
try:
|
|
878
|
+
return _auto_n_jobs_cpu(
|
|
879
|
+
data_size_mb,
|
|
880
|
+
n_samples,
|
|
881
|
+
max_memory_gb=memory_budget_gb,
|
|
882
|
+
max_jobs=ceiling,
|
|
883
|
+
)
|
|
884
|
+
except ValueError:
|
|
885
|
+
return 1
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _is_oom_error(exc: BaseException) -> bool:
|
|
889
|
+
"""True if `exc` is a device out-of-memory error (CUDA or MPS)."""
|
|
890
|
+
try:
|
|
891
|
+
import torch
|
|
892
|
+
|
|
893
|
+
if hasattr(torch, "OutOfMemoryError") and isinstance(
|
|
894
|
+
exc, torch.OutOfMemoryError
|
|
895
|
+
):
|
|
896
|
+
return True
|
|
897
|
+
except ImportError:
|
|
898
|
+
pass
|
|
899
|
+
return isinstance(exc, RuntimeError) and "out of memory" in str(exc).lower()
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _empty_device_cache() -> None:
|
|
903
|
+
"""Release cached device memory.
|
|
904
|
+
|
|
905
|
+
No-op without torch or a GPU.
|
|
906
|
+
"""
|
|
907
|
+
try:
|
|
908
|
+
import torch
|
|
909
|
+
except ImportError:
|
|
910
|
+
return
|
|
911
|
+
if torch.cuda.is_available():
|
|
912
|
+
torch.cuda.empty_cache()
|
|
913
|
+
elif (
|
|
914
|
+
hasattr(torch.backends, "mps")
|
|
915
|
+
and torch.backends.mps.is_available()
|
|
916
|
+
and hasattr(torch, "mps")
|
|
917
|
+
):
|
|
918
|
+
torch.mps.empty_cache()
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _compute_oom_safe(fn, *arrays, min_chunk: int = 1):
|
|
922
|
+
"""Run `fn(*arrays)` with reactive out-of-memory recovery.
|
|
923
|
+
|
|
924
|
+
All `arrays` must share their axis-0 length, and `fn` must map them to
|
|
925
|
+
a numpy array whose axis 0 corresponds row-for-row to its inputs. On a
|
|
926
|
+
device OOM the cache is emptied, the arrays are split in half along
|
|
927
|
+
axis 0, and the halves are retried recursively; partial results are
|
|
928
|
+
concatenated along axis 0.
|
|
929
|
+
|
|
930
|
+
Because splitting reuses the *already generated* inputs rather than
|
|
931
|
+
re-drawing them, recovery never changes which permutations a seeded
|
|
932
|
+
result is computed from — RNG-consuming input generation stays outside
|
|
933
|
+
this function. For a row-independent `fn` the recovered output matches
|
|
934
|
+
the unsplit computation to within floating-point reduction order
|
|
935
|
+
(backends may block reductions differently per batch shape; observed
|
|
936
|
+
differences are ~1 float32 ulp).
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
fn (Callable[..., np.ndarray]): Maps the arrays to a numpy result
|
|
940
|
+
(axis-0 aligned).
|
|
941
|
+
*arrays (np.ndarray): Input arrays sharing their axis-0 length.
|
|
942
|
+
min_chunk (int): Chunk size below which an OOM is considered fatal.
|
|
943
|
+
Defaults to 1.
|
|
944
|
+
|
|
945
|
+
Returns:
|
|
946
|
+
np.ndarray: `fn`'s result, possibly assembled from retried chunks.
|
|
947
|
+
|
|
948
|
+
Raises:
|
|
949
|
+
MemoryError: If the device OOMs even at `min_chunk` items.
|
|
950
|
+
"""
|
|
951
|
+
n = len(arrays[0])
|
|
952
|
+
try:
|
|
953
|
+
return fn(*arrays)
|
|
954
|
+
except Exception as exc:
|
|
955
|
+
if not _is_oom_error(exc):
|
|
956
|
+
raise
|
|
957
|
+
_empty_device_cache()
|
|
958
|
+
if n <= min_chunk:
|
|
959
|
+
raise MemoryError(
|
|
960
|
+
f"Device out of memory even for a single item (chunk of {n}). "
|
|
961
|
+
"Reduce the problem size, lower max_gpu_memory_gb elsewhere on "
|
|
962
|
+
"the device, or use device='cpu'."
|
|
963
|
+
) from exc
|
|
964
|
+
mid = n // 2
|
|
965
|
+
left = _compute_oom_safe(fn, *(a[:mid] for a in arrays), min_chunk=min_chunk)
|
|
966
|
+
right = _compute_oom_safe(fn, *(a[mid:] for a in arrays), min_chunk=min_chunk)
|
|
967
|
+
return np.concatenate([left, right], axis=0)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
# ----------------------------------------------------------------------
|
|
971
|
+
# CPU worker sizing (joblib) — same budget source as the device batching
|
|
972
|
+
# ----------------------------------------------------------------------
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _auto_n_jobs_cpu(
|
|
976
|
+
data_size_mb: float,
|
|
977
|
+
n_permute: int,
|
|
978
|
+
max_memory_gb: float | None = None,
|
|
979
|
+
max_jobs: int | None = None,
|
|
980
|
+
) -> int:
|
|
981
|
+
"""Choose how many CPU workers fit in memory for a permutation job.
|
|
982
|
+
|
|
983
|
+
Each joblib worker pickles its copy of the data, which costs roughly 3× the
|
|
984
|
+
array size, so the worker count is the memory budget divided by that
|
|
985
|
+
per-worker cost, capped at `max_jobs`.
|
|
986
|
+
|
|
987
|
+
Args:
|
|
988
|
+
data_size_mb (float): Size of the data array in MB.
|
|
989
|
+
n_permute (int): Number of permutations to compute (adds a small
|
|
990
|
+
per-worker result overhead).
|
|
991
|
+
max_memory_gb (float | None): Explicit memory budget in GB. None
|
|
992
|
+
(default) measures available system RAM with headroom via
|
|
993
|
+
`_device_memory_budget`.
|
|
994
|
+
max_jobs (int | None): Maximum number of workers. None (default) means
|
|
995
|
+
all cores.
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
int: Worker count for `joblib.Parallel(n_jobs=...)`.
|
|
999
|
+
|
|
1000
|
+
Examples:
|
|
1001
|
+
```python
|
|
1002
|
+
_auto_n_jobs_cpu(1.0, 5000, max_memory_gb=8.0) # small data → many workers
|
|
1003
|
+
_auto_n_jobs_cpu(100.0, 5000, max_memory_gb=8.0) # large data → fewer workers
|
|
1004
|
+
```
|
|
1005
|
+
"""
|
|
1006
|
+
import multiprocessing
|
|
1007
|
+
|
|
1008
|
+
# Get system limits
|
|
1009
|
+
if max_jobs is None:
|
|
1010
|
+
max_jobs = multiprocessing.cpu_count()
|
|
1011
|
+
|
|
1012
|
+
available_memory_gb = _device_memory_budget(None, max_gpu_memory_gb=max_memory_gb)
|
|
1013
|
+
available_memory_bytes = _gb_to_bytes(available_memory_gb)
|
|
1014
|
+
|
|
1015
|
+
# Memory per worker: data serialization overhead (3× is conservative for pickle)
|
|
1016
|
+
# Plus small overhead for result arrays (n_permute results per worker)
|
|
1017
|
+
serialization_factor = 3.0
|
|
1018
|
+
memory_per_worker_bytes = (
|
|
1019
|
+
data_size_mb * 1024**2 * serialization_factor + n_permute * 4 * 0.1
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
# How many workers can fit in memory budget?
|
|
1023
|
+
if memory_per_worker_bytes <= 0:
|
|
1024
|
+
return 1
|
|
1025
|
+
|
|
1026
|
+
max_workers_by_memory = int(available_memory_bytes / memory_per_worker_bytes)
|
|
1027
|
+
if max_workers_by_memory < 1:
|
|
1028
|
+
raise ValueError(
|
|
1029
|
+
f"one worker requires approximately {memory_per_worker_bytes / 1e9:.6g} "
|
|
1030
|
+
f"GB, exceeding the {available_memory_gb:.6g} GB memory budget"
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
return max(1, min(max_workers_by_memory, max_jobs))
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _estimate_data_size_mb(data: np.ndarray) -> float:
|
|
1037
|
+
"""Estimate the memory footprint of an array in MB.
|
|
1038
|
+
|
|
1039
|
+
Accounts for the dtype item size plus a fixed numpy object overhead.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
data (np.ndarray): Data array.
|
|
1043
|
+
|
|
1044
|
+
Returns:
|
|
1045
|
+
float: Estimated size in MB (0.0 for an empty array).
|
|
1046
|
+
"""
|
|
1047
|
+
if data.size == 0:
|
|
1048
|
+
return 0.0
|
|
1049
|
+
|
|
1050
|
+
# Base size: elements × bytes per element
|
|
1051
|
+
bytes_per_element = data.dtype.itemsize
|
|
1052
|
+
base_size_bytes = data.size * bytes_per_element
|
|
1053
|
+
|
|
1054
|
+
# Add numpy array overhead (typically ~100 bytes)
|
|
1055
|
+
overhead_bytes = 100
|
|
1056
|
+
|
|
1057
|
+
total_size_mb = (base_size_bytes + overhead_bytes) / 1024**2
|
|
1058
|
+
|
|
1059
|
+
return total_size_mb
|