nltools 0.6.0.dev0__py3-none-any.whl

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