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,1250 @@
1
+ """BrainData prediction — timeseries (encoding) and MVPA (decoding).
2
+
3
+ Single entry point: `predict`. It resolves exactly one mode, validates every
4
+ argument for that mode, and returns either a new `BrainData` (fitted-model
5
+ prediction) or a frozen `Predict` record (MVPA). Nothing is attached to the
6
+ source object.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ from nltools.algorithms.decoding import (
17
+ _back_project_weight_maps,
18
+ _validate_decoding_pipeline,
19
+ )
20
+ from nltools.data.results import Predict
21
+ from nltools.utils import _maybe_tqdm
22
+
23
+ from .utils import _is_default
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Public entry point
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ #: Decoding-only arguments and their documented defaults. A non-default value
32
+ #: for any of them on a fitted-model call is an invalid combination, not a
33
+ #: silently ignored keyword.
34
+ MVPA_ONLY_DEFAULTS = {
35
+ "estimator": "linear_svc",
36
+ "estimator_kwargs": None,
37
+ "cv": None,
38
+ "groups": None,
39
+ "scoring": None,
40
+ "spatial_scale": "whole_brain",
41
+ "roi_mask": None,
42
+ "radius": 10.0,
43
+ "plot": False,
44
+ "n_jobs": 1,
45
+ "progress_bar": False,
46
+ }
47
+
48
+
49
+ def _predict(
50
+ bd,
51
+ *,
52
+ X=None,
53
+ y=None,
54
+ estimator: Any = "linear_svc",
55
+ estimator_kwargs: dict | None = None,
56
+ cv=None,
57
+ groups=None,
58
+ scoring=None,
59
+ spatial_scale: str = "whole_brain",
60
+ roi_mask=None,
61
+ radius: float = 10.0,
62
+ plot: bool = False,
63
+ n_jobs: int = 1,
64
+ progress_bar: bool = False,
65
+ ):
66
+ """Dispatch BrainData prediction to fitted-model prediction or MVPA decoding.
67
+
68
+ Implements `BrainData.predict`. See that method's docstring for full
69
+ parameter documentation.
70
+ """
71
+ if X is not None and y is not None:
72
+ raise ValueError(
73
+ "Cannot specify both X and y. Use X to predict from a fitted "
74
+ "model or y to decode with MVPA."
75
+ )
76
+
77
+ decoding_arguments = {
78
+ "estimator": estimator,
79
+ "estimator_kwargs": estimator_kwargs,
80
+ "cv": cv,
81
+ "groups": groups,
82
+ "scoring": scoring,
83
+ "spatial_scale": spatial_scale,
84
+ "roi_mask": roi_mask,
85
+ "radius": radius,
86
+ "plot": plot,
87
+ "n_jobs": n_jobs,
88
+ "progress_bar": progress_bar,
89
+ }
90
+
91
+ if X is not None:
92
+ _reject_decoding_arguments(decoding_arguments)
93
+ return _predict_timeseries(bd, X=X)
94
+
95
+ resolved_y = _resolve_stored_y(bd, y)
96
+ if resolved_y is None:
97
+ # No labels to decode: the only remaining mode is prediction from a
98
+ # fitted model, which takes none of the decoding arguments.
99
+ _reject_decoding_arguments(decoding_arguments)
100
+ return _predict_timeseries(bd, X=None)
101
+
102
+ return _predict_mvpa(
103
+ bd,
104
+ y=resolved_y,
105
+ estimator=estimator,
106
+ estimator_kwargs=estimator_kwargs,
107
+ cv=cv,
108
+ groups=_resolve_stored_groups(bd, groups),
109
+ scoring=scoring,
110
+ spatial_scale=spatial_scale,
111
+ roi_mask=roi_mask,
112
+ radius=radius,
113
+ plot=plot,
114
+ n_jobs=n_jobs,
115
+ progress_bar=progress_bar,
116
+ )
117
+
118
+
119
+ def _reject_decoding_arguments(supplied: dict) -> None:
120
+ """Raise when a decoding-only argument is passed on a call that is not decoding."""
121
+ offenders = sorted(
122
+ name
123
+ for name, value in supplied.items()
124
+ if not _is_default(value, MVPA_ONLY_DEFAULTS[name])
125
+ )
126
+ if not offenders:
127
+ return
128
+ names = ", ".join(f"{name}=" for name in offenders)
129
+ verb = "configures" if len(offenders) == 1 else "configure"
130
+ subject = "this argument" if len(offenders) == 1 else "these arguments"
131
+ raise ValueError(
132
+ f"{names} only {verb} MVPA decoding, and this call is not decoding. "
133
+ f"Pass y= — or attach labels to .Y — to decode, or drop {subject}."
134
+ )
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Stored-Y resolution (labels travel with the data)
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def _series_to_numpy(series):
143
+ """Convert a polars Series to numpy, mapping string columns to ``'<U'``.
144
+
145
+ Polars Utf8 columns come back from ``to_numpy()`` as object arrays;
146
+ sklearn then propagates the object dtype into ``classes_`` and
147
+ ``predict()`` outputs, which breaks HDF5 persistence and dtype checks.
148
+ A real unicode dtype keeps label arrays first-class end to end.
149
+ """
150
+ import polars as pl
151
+
152
+ arr = series.to_numpy()
153
+ if arr.dtype == object and series.dtype == pl.String:
154
+ return arr.astype(str)
155
+ return arr
156
+
157
+
158
+ def _resolve_stored_y(bd, y):
159
+ """Resolve ``y`` against the stored ``bd.Y`` frame.
160
+
161
+ Rules:
162
+ - array-like ``y`` passes through as an ndarray;
163
+ - a string picks that column of ``bd.Y``;
164
+ - ``None`` falls back to a single-column ``bd.Y`` (the idiomatic
165
+ labels-travel-with-the-data path). A multi-column ``Y`` is ambiguous
166
+ and asks for ``y='name'``; an empty ``Y`` returns ``None`` so the
167
+ dispatcher can fall through to timeseries prediction. A *fitted*
168
+ encoding model wins over stored labels, so an object carrying both
169
+ predicts its training timeseries.
170
+ """
171
+ stored = bd.Y
172
+
173
+ if isinstance(y, str):
174
+ if stored is None or stored.is_empty():
175
+ raise ValueError(
176
+ f"y={y!r} names a column of .Y, but no Y frame is stored on "
177
+ f"this BrainData. Set brain.Y or pass y as an array."
178
+ )
179
+ if y not in stored.columns:
180
+ raise ValueError(
181
+ f"y={y!r} is not a column of .Y (columns: {stored.columns})."
182
+ )
183
+ return _series_to_numpy(stored[y])
184
+
185
+ if y is not None:
186
+ return np.asarray(y)
187
+
188
+ if stored is None or stored.is_empty():
189
+ return None
190
+
191
+ if getattr(getattr(bd, "model_", None), "is_fitted_", False):
192
+ # Fitted-model prediction wins over attached labels on a no-argument
193
+ # call; returning None lets the dispatcher fall through to it. An
194
+ # unfitted `model_` is not a model to predict from, so decoding the
195
+ # stored labels stays available.
196
+ return None
197
+ if stored.shape[1] != 1:
198
+ raise ValueError(
199
+ f".Y has {stored.shape[1]} columns ({stored.columns}); pass "
200
+ f"y='name' to pick the label column."
201
+ )
202
+ return _series_to_numpy(stored[stored.columns[0]])
203
+
204
+
205
+ def _resolve_stored_groups(bd, groups):
206
+ """Resolve a string ``groups`` spec to that column of ``bd.Y``.
207
+
208
+ The ``Y`` frame is the row-aligned metadata carrier on ``BrainData``, so
209
+ within-subject grouping variables (run, session, block) live there
210
+ alongside the labels. Arrays and ``None`` pass through unchanged.
211
+ """
212
+ if not isinstance(groups, str):
213
+ return groups
214
+ stored = bd.Y
215
+ if stored is None or stored.is_empty():
216
+ raise ValueError(
217
+ f"groups={groups!r} names a column of .Y, but no Y frame is "
218
+ f"stored on this BrainData. Set brain.Y or pass groups as an array."
219
+ )
220
+ if groups not in stored.columns:
221
+ raise ValueError(
222
+ f"groups={groups!r} is not a column of .Y (columns: {stored.columns})."
223
+ )
224
+ return _series_to_numpy(stored[groups])
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Timeseries prediction (encoding model — uses fitted ridge / glm)
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def _predict_timeseries(bd, *, X=None):
233
+ """Predict voxel timeseries from a fitted encoding model.
234
+
235
+ Returns a fresh ``BrainData`` whose ``.data`` is the predicted timeseries.
236
+ Encoding model prediction yields a brain image — the natural container is
237
+ ``BrainData``, so it composes directly with downstream methods (`.plot()`,
238
+ `.standardize()`, etc.). MVPA decoding (``y=`` mode) returns ``Predict``.
239
+
240
+ With no ``X``, the fitted model returns an independent copy of the stored
241
+ training predictions and keeps their row metadata: ``glm_predicted`` for a
242
+ GLM, ``ridge_fitted_values`` for a Ridge. Neither retains the training
243
+ features, so a no-argument call never refits or re-multiplies. With an
244
+ explicit ``X``, structural validation and alignment belong to the
245
+ estimator's own ``predict`` — named design columns for `_Glm`, named feature
246
+ spaces for a banded `_Ridge` — and the result clears the source row metadata.
247
+ """
248
+ from nltools.models import _Glm
249
+
250
+ from .utils import _result_from_array
251
+
252
+ if not hasattr(bd, "model_"):
253
+ raise ValueError(
254
+ "Must call fit() before predict() for timeseries prediction. "
255
+ "Example: brain_data.fit(model='ridge', X=features)"
256
+ )
257
+ if not bd.model_.is_fitted_:
258
+ raise ValueError("Model is not fitted")
259
+
260
+ if X is not None:
261
+ return _result_from_array(bd, bd.model_.predict(X), rows="clear")
262
+
263
+ stored = bd.glm_predicted if isinstance(bd.model_, _Glm) else bd.ridge_fitted_values
264
+ return _result_from_array(bd, np.array(stored.data, copy=True), rows="preserve")
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # MVPA decoding
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ VALID_SPATIAL_SCALES = {"whole_brain", "searchlight", "roi"}
273
+
274
+
275
+ def _predict_mvpa(
276
+ bd,
277
+ *,
278
+ y,
279
+ estimator: Any,
280
+ estimator_kwargs: dict | None,
281
+ cv,
282
+ groups,
283
+ scoring,
284
+ spatial_scale: str,
285
+ roi_mask,
286
+ radius: float,
287
+ plot: bool,
288
+ n_jobs: int,
289
+ progress_bar: bool,
290
+ ) -> Predict:
291
+ """Run cross-validated decoding on a `BrainData` and return a `Predict`.
292
+
293
+ Every argument is validated, and the cross-validation folds are
294
+ materialized and checked, before a single model is fitted.
295
+ """
296
+ from sklearn.base import is_classifier
297
+
298
+ _validate_spatial_scale(spatial_scale, roi_mask=roi_mask, radius=radius)
299
+ y = _validate_target(y, n_rows=bd.shape[0])
300
+ groups = _validate_groups(groups, n_rows=bd.shape[0])
301
+ _validate_scoring(scoring)
302
+
303
+ pipe = _build_pipeline(estimator, y=y, estimator_kwargs=estimator_kwargs)
304
+ _validate_decoding_pipeline(pipe)
305
+ classifier = is_classifier(pipe)
306
+ splits = _resolve_splits(cv, X=bd.data, y=y, groups=groups, classifier=classifier)
307
+ classes = np.unique(y) if classifier else None
308
+ _validate_plot(plot, spatial_scale=spatial_scale, pipe=pipe, classes=classes)
309
+
310
+ X_data = bd.data # (n_samples, n_voxels)
311
+
312
+ if spatial_scale == "whole_brain":
313
+ record, out_of_fold_values = _run_whole_brain(
314
+ bd,
315
+ X_data,
316
+ y,
317
+ pipe,
318
+ splits=splits,
319
+ scoring=scoring,
320
+ classes=classes,
321
+ n_jobs=n_jobs,
322
+ )
323
+ if plot:
324
+ _plot_whole_brain_result(
325
+ record,
326
+ y=y,
327
+ out_of_fold_values=out_of_fold_values,
328
+ classifier=classifier,
329
+ )
330
+ return record
331
+ if spatial_scale == "searchlight":
332
+ return _run_searchlight(
333
+ bd,
334
+ X_data,
335
+ y,
336
+ pipe,
337
+ splits=splits,
338
+ scoring=scoring,
339
+ classes=classes,
340
+ radius=radius,
341
+ n_jobs=n_jobs,
342
+ progress_bar=progress_bar,
343
+ )
344
+ return _run_roi(
345
+ bd,
346
+ X_data,
347
+ y,
348
+ pipe,
349
+ splits=splits,
350
+ scoring=scoring,
351
+ classes=classes,
352
+ roi_mask=roi_mask,
353
+ n_jobs=n_jobs,
354
+ progress_bar=progress_bar,
355
+ )
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Argument validation — everything here runs before the first fit
360
+ # ---------------------------------------------------------------------------
361
+
362
+
363
+ def _validate_spatial_scale(spatial_scale: str, *, roi_mask, radius: float) -> None:
364
+ """Check the spatial scale and the companion arguments it owns."""
365
+ if spatial_scale not in VALID_SPATIAL_SCALES:
366
+ raise ValueError(
367
+ f"Invalid spatial_scale: {spatial_scale!r}. "
368
+ f"Must be one of {sorted(VALID_SPATIAL_SCALES)}"
369
+ )
370
+ if spatial_scale == "roi" and roi_mask is None:
371
+ raise ValueError("roi_mask is required for spatial_scale='roi'")
372
+ if spatial_scale != "roi" and roi_mask is not None:
373
+ raise ValueError(
374
+ f"roi_mask only applies to spatial_scale='roi', not {spatial_scale!r}."
375
+ )
376
+ if spatial_scale != "searchlight" and not _is_default(radius, 10.0):
377
+ raise ValueError(
378
+ f"radius only applies to spatial_scale='searchlight', not "
379
+ f"{spatial_scale!r}."
380
+ )
381
+
382
+
383
+ def _validate_plot(plot: bool, *, spatial_scale: str, pipe, classes) -> None:
384
+ """Check that `plot=True` names a decode whose figures exist, before fitting.
385
+
386
+ Only whole-brain decoding produces the per-observation predictions the
387
+ figures are drawn from: ROI and searchlight results carry score maps, not
388
+ predictions. Multiclass classification has no single ROC or margin figure —
389
+ v0.5.1 printed a line and carried on, which is easy to miss in a notebook,
390
+ so it raises here instead.
391
+ """
392
+ if not plot:
393
+ return
394
+ if spatial_scale != "whole_brain":
395
+ raise ValueError(
396
+ f"plot=True draws the cross-validated prediction figures, which "
397
+ f"only spatial_scale='whole_brain' produces; "
398
+ f"spatial_scale={spatial_scale!r} returns score maps instead. "
399
+ f"Plot those with result.score_map.plot()."
400
+ )
401
+ if classes is None:
402
+ return
403
+ if len(classes) > 2:
404
+ raise ValueError(
405
+ f"plot=True is not supported for multiclass decoding: the ROC and "
406
+ f"margin figures describe one decision boundary, and this target "
407
+ f"has {len(classes)} classes. Drop plot= and read result.scores, "
408
+ f"or plot result.weight_map yourself."
409
+ )
410
+ if not (hasattr(pipe, "decision_function") or hasattr(pipe, "predict_proba")):
411
+ raise ValueError(
412
+ "plot=True needs continuous decision values for the ROC figure, "
413
+ "and this classifier exposes neither decision_function nor "
414
+ "predict_proba. Drop plot=, or pass an estimator that exposes one."
415
+ )
416
+
417
+
418
+ def _validate_target(y, *, n_rows: int) -> np.ndarray:
419
+ """Return `y` as a one-dimensional array with one value per row."""
420
+ y = np.asarray(y)
421
+ if y.ndim != 1:
422
+ raise ValueError(
423
+ f"y must be one-dimensional with one value per row; got shape "
424
+ f"{y.shape}. Multioutput and multilabel targets are not accepted."
425
+ )
426
+ if y.shape[0] != n_rows:
427
+ raise ValueError(
428
+ f"y must have one value per row: got {y.shape[0]} values for {n_rows} rows."
429
+ )
430
+ return y
431
+
432
+
433
+ def _validate_groups(groups, *, n_rows: int):
434
+ """Return `groups` as a one-dimensional array with one value per row."""
435
+ if groups is None:
436
+ return None
437
+ groups = np.asarray(groups)
438
+ if groups.ndim != 1 or groups.shape[0] != n_rows:
439
+ raise ValueError(
440
+ f"groups must have one value per row: got shape {groups.shape} "
441
+ f"for {n_rows} rows."
442
+ )
443
+ return groups
444
+
445
+
446
+ def _validate_scoring(scoring) -> None:
447
+ """Reject the removed `'auto'` value and multimetric scoring mappings."""
448
+ from collections.abc import Mapping
449
+
450
+ if isinstance(scoring, Mapping) or (
451
+ isinstance(scoring, (list, tuple, set)) and not isinstance(scoring, str)
452
+ ):
453
+ raise ValueError(
454
+ "Multimetric scoring is not accepted because Predict.scores holds "
455
+ "one value per cross-validation fold. Pass a single scoring name "
456
+ "or callable, or None to use the estimator's own score method."
457
+ )
458
+ if scoring == "auto":
459
+ raise ValueError(
460
+ "scoring='auto' was removed. Pass None (the default) to use the "
461
+ "estimator's own score method — accuracy for a classifier, R2 for "
462
+ "a regressor — or any scikit-learn scoring name or callable."
463
+ )
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # Estimator resolution and pipeline construction
468
+ # ---------------------------------------------------------------------------
469
+
470
+ #: Built-in shortcuts. Every one is a linear estimator, so its coefficients
471
+ #: project back to the voxel axis.
472
+ ESTIMATOR_SHORTCUTS = (
473
+ "linear_svc",
474
+ "logistic_regression",
475
+ "linear_discriminant_analysis",
476
+ "ridge_classifier",
477
+ "ridge",
478
+ "lasso",
479
+ "linear_svr",
480
+ )
481
+
482
+ #: Penalty grid the two ridge shortcuts search by an inner cross-validation
483
+ #: inside each outer training fold. It spans ten orders of magnitude because the
484
+ #: working penalty scales with the feature count: against a kernel built from a
485
+ #: quarter of a million standardized voxels, scikit-learn's default ``alpha=1``
486
+ #: is effectively no penalty at all and the solve is ill-conditioned.
487
+ RIDGE_ALPHA_GRID = np.logspace(-3, 6, 10)
488
+
489
+ #: Abbreviations that used to name a shortcut. They are ambiguous — 'svm' and
490
+ #: 'svr' say nothing about the kernel, 'ridge' already means the regressor —
491
+ #: so they are rejected with the canonical spelling.
492
+ REJECTED_ABBREVIATIONS = {
493
+ "svm": "linear_svc",
494
+ "logistic": "logistic_regression",
495
+ "lda": "linear_discriminant_analysis",
496
+ "svr": "linear_svr",
497
+ }
498
+
499
+
500
+ def _resolve_estimator(estimator: Any, *, estimator_kwargs: dict | None = None):
501
+ """Resolve a shortcut name to an estimator, or pass an sklearn object through.
502
+
503
+ Each shortcut names a class and the constructor options that make it work at
504
+ whole-brain scale. `estimator_kwargs` is merged over those options, so a
505
+ caller's key overrides a shortcut default instead of colliding with it.
506
+
507
+ Args:
508
+ estimator: A shortcut name or an sklearn estimator/`Pipeline`.
509
+ estimator_kwargs: Options for the shortcut's constructor, or `None`.
510
+
511
+ Returns:
512
+ The constructed shortcut estimator, or `estimator` itself.
513
+
514
+ Raises:
515
+ ValueError: On an unknown or ambiguous shortcut name, or on
516
+ `estimator_kwargs` with a caller-supplied estimator.
517
+ TypeError: On an `estimator` that is neither a shortcut name nor an
518
+ object with `fit`/`predict`.
519
+ """
520
+ from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
521
+ from sklearn.linear_model import (
522
+ Lasso,
523
+ LogisticRegression,
524
+ RidgeClassifierCV,
525
+ RidgeCV,
526
+ )
527
+ from sklearn.svm import LinearSVC, LinearSVR
528
+
529
+ builders = {
530
+ "linear_svc": (LinearSVC, {"dual": "auto", "max_iter": 10000}),
531
+ "logistic_regression": (LogisticRegression, {"max_iter": 1000}),
532
+ "linear_discriminant_analysis": (LinearDiscriminantAnalysis, {}),
533
+ "ridge_classifier": (RidgeClassifierCV, {"alphas": RIDGE_ALPHA_GRID}),
534
+ "ridge": (RidgeCV, {"alphas": RIDGE_ALPHA_GRID}),
535
+ "lasso": (Lasso, {}),
536
+ "linear_svr": (LinearSVR, {"max_iter": 10000}),
537
+ }
538
+
539
+ if isinstance(estimator, str):
540
+ if estimator in REJECTED_ABBREVIATIONS:
541
+ canonical = REJECTED_ABBREVIATIONS[estimator]
542
+ raise ValueError(
543
+ f"estimator={estimator!r} is ambiguous and is not accepted; "
544
+ f"use {canonical!r}."
545
+ )
546
+ if estimator not in builders:
547
+ raise ValueError(
548
+ f"Unknown estimator shortcut: {estimator!r}. Valid shortcuts: "
549
+ f"{list(ESTIMATOR_SHORTCUTS)}, or pass any sklearn estimator."
550
+ )
551
+ cls, defaults = builders[estimator]
552
+ return cls(**{**defaults, **(estimator_kwargs or {})})
553
+
554
+ if estimator_kwargs is not None:
555
+ raise ValueError(
556
+ "estimator_kwargs configures a built-in shortcut's constructor, and "
557
+ "estimator is not a shortcut name. A caller-supplied estimator is "
558
+ "used exactly as given, so construct it with the options you want."
559
+ )
560
+
561
+ if not (hasattr(estimator, "fit") and hasattr(estimator, "predict")):
562
+ raise TypeError(
563
+ f"estimator must be a shortcut name or an object with fit/predict; "
564
+ f"got {type(estimator).__name__}"
565
+ )
566
+ return estimator
567
+
568
+
569
+ def _build_pipeline(
570
+ estimator: Any, *, y: np.ndarray, estimator_kwargs: dict | None = None
571
+ ) -> Any:
572
+ """Build the per-fold pipeline for `estimator`.
573
+
574
+ A built-in shortcut selects a predefined pipeline: `StandardScaler` inside
575
+ each fold, then the linear estimator the shortcut names — for the two ridge
576
+ shortcuts, one that selects its own penalty from `RIDGE_ALPHA_GRID` by an
577
+ inner cross-validation of that fold's training set. A classification
578
+ shortcut on a multiclass target is wrapped in `OneVsRestClassifier`, which
579
+ gives one signed coefficient row per class instead of whatever multiclass
580
+ strategy the estimator happens to default to.
581
+
582
+ A caller-supplied estimator or `Pipeline` is used exactly as given — MVPA
583
+ adds, removes, and reconfigures nothing, and never overrides its multiclass
584
+ strategy. Callers who want one-vs-rest supply a `OneVsRestClassifier`.
585
+
586
+ Args:
587
+ estimator: A shortcut name or an sklearn estimator/`Pipeline`.
588
+ y: The validated target vector, used only to decide whether a
589
+ classification shortcut faces a multiclass problem.
590
+ estimator_kwargs: Constructor options for a shortcut; rejected with a
591
+ caller-supplied estimator, which is used exactly as given.
592
+
593
+ Returns:
594
+ The estimator to clone and fit in every fold.
595
+ """
596
+ from sklearn.base import is_classifier
597
+ from sklearn.multiclass import OneVsRestClassifier
598
+ from sklearn.pipeline import make_pipeline
599
+ from sklearn.preprocessing import StandardScaler
600
+
601
+ resolved = _resolve_estimator(estimator, estimator_kwargs=estimator_kwargs)
602
+ if not isinstance(estimator, str):
603
+ return resolved
604
+ if is_classifier(resolved) and len(np.unique(y)) > 2:
605
+ resolved = OneVsRestClassifier(resolved)
606
+ return make_pipeline(StandardScaler(), resolved)
607
+
608
+
609
+ # ---------------------------------------------------------------------------
610
+ # Cross-validation
611
+ # ---------------------------------------------------------------------------
612
+
613
+
614
+ def _resolve_splits(cv, *, X, y, groups, classifier: bool) -> list:
615
+ """Resolve `cv` into materialized train/test splits and check the partition.
616
+
617
+ Materializing once means every runner — and every parallel worker — sees
618
+ the same folds, and it lets the partition rule be checked before any model
619
+ is fitted.
620
+ """
621
+ splitter = _resolve_splitter(
622
+ cv, classifier=classifier, grouped=groups is not None, n_rows=len(y)
623
+ )
624
+ splits = [
625
+ (_as_indices(train), _as_indices(test))
626
+ for train, test in _iter_split(splitter, X, y, groups)
627
+ ]
628
+ _validate_partition(splits, n_rows=len(y))
629
+ return splits
630
+
631
+
632
+ def _resolve_splitter(cv, *, classifier: bool, grouped: bool, n_rows: int):
633
+ """Turn a `cv` spec into a scikit-learn splitter following sklearn's grammar.
634
+
635
+ `None` and an int both mean that many *stratified*, unshuffled folds. What
636
+ they stratify on, and whether they keep a group whole, depends on the
637
+ model and on whether the caller supplied `groups`:
638
+
639
+ | model | `groups` | splitter |
640
+ | ---------- | -------- | ---------------------------------------------- |
641
+ | classifier | no | `StratifiedKFold(n)` on the class labels |
642
+ | classifier | yes | `StratifiedGroupKFold(n)` on the class labels |
643
+ | regressor | no | `StratifiedKFold(n)` on quantile bins of `y` |
644
+ | regressor | yes | `StratifiedGroupKFold(n)` on quantile bins of `y` |
645
+
646
+ A plain `(Stratified)KFold` accepts `groups` in `split()` but ignores it,
647
+ so before the group-aware rows a subject could straddle the train/test
648
+ boundary while the caller believed otherwise. There is no shuffle and no
649
+ `random_state` on any of these paths: `cv=None` and an int stay
650
+ reproducible across calls. A supplied splitter is used exactly as given,
651
+ and the `'loo'`/`'logo'` string aliases raise.
652
+
653
+ Args:
654
+ cv: `None`, an int fold count, or a scikit-learn splitter.
655
+ classifier: Whether the resolved model is a classifier.
656
+ grouped: Whether the caller supplied `groups`.
657
+ n_rows: The number of observations to be split.
658
+
659
+ Returns:
660
+ A scikit-learn-compatible splitter.
661
+
662
+ Raises:
663
+ ValueError: On a string `cv`, or on a regressor with too few rows to
664
+ fill the quantile bins.
665
+ TypeError: On anything that is not `None`, an int, or a splitter.
666
+ """
667
+ from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold
668
+
669
+ if isinstance(cv, str):
670
+ raise ValueError(
671
+ f"cv={cv!r} is not accepted: the 'loo' and 'logo' aliases were "
672
+ f"removed from predict. Pass the splitter itself — "
673
+ f"LeaveOneOut() or LeaveOneGroupOut() with groups= — an int fold "
674
+ f"count, or None for a deterministic five-fold split."
675
+ )
676
+ if cv is None or (isinstance(cv, int) and not isinstance(cv, bool)):
677
+ n_splits = 5 if cv is None else cv
678
+ kind = StratifiedGroupKFold if grouped else StratifiedKFold
679
+ base = kind(n_splits=n_splits)
680
+ if classifier:
681
+ return base
682
+ # Quantile bins hold two rows per fold at least (`_continuous_strata`).
683
+ # Below that the bins are thinner than the fold count and scikit-learn
684
+ # refuses the split, talking about a "class" the caller never had.
685
+ if n_rows < 2 * n_splits:
686
+ raise ValueError(
687
+ f"cv={cv!r} stratifies a continuous target on quantile bins of "
688
+ f"y, which needs at least two rows per fold: {n_rows} row(s) "
689
+ f"cannot fill {n_splits} folds. Use at most {n_rows // 2} "
690
+ f"folds, or an unstratified splitter — "
691
+ f"cv=KFold(n_splits={n_splits})."
692
+ )
693
+ return _ContinuousStratifiedSplitter(base)
694
+ if not (hasattr(cv, "split") and hasattr(cv, "get_n_splits")):
695
+ raise TypeError(
696
+ f"cv must be None, an int fold count, or a scikit-learn "
697
+ f"cross-validation splitter; got {type(cv).__name__}."
698
+ )
699
+ return cv
700
+
701
+
702
+ def _continuous_strata(y, n_splits: int, max_bins: int = 10) -> np.ndarray:
703
+ """Quantile-bin a continuous target so stratified splitters can balance it.
704
+
705
+ The bin count is capped so every bin holds at least `2 * n_splits` samples
706
+ — enough for each fold to draw from every bin — and never exceeds
707
+ `max_bins`. A target with few distinct values (an ordinal score) is used
708
+ as-is, its own values becoming the labels.
709
+
710
+ Every stratum ends up with at least `n_splits` members: a rare ordinal
711
+ level, or ties sitting on a quantile edge, would otherwise leave one
712
+ thinner than the fold count, and scikit-learn would then warn about a
713
+ "class" a regression caller never had. The bins widen until that holds,
714
+ down to a single stratum if the target is that degenerate. The caller
715
+ guarantees at least `2 * n_splits` rows (`_resolve_splitter`).
716
+
717
+ Args:
718
+ y: The continuous target, one value per sample.
719
+ n_splits: The fold count the strata will be split into.
720
+ max_bins: The hard cap on the number of bins.
721
+
722
+ Returns:
723
+ Integer strata labels, one per sample.
724
+ """
725
+ y = np.asarray(y).ravel()
726
+ n = y.shape[0]
727
+ uniques = np.unique(y)
728
+ n_bins = int(np.clip(n // (2 * n_splits), 2, max_bins))
729
+ if uniques.size <= n_bins:
730
+ labels = np.searchsorted(uniques, y)
731
+ if np.bincount(labels).min() >= n_splits:
732
+ return labels
733
+ while n_bins > 1:
734
+ edges = np.quantile(y, np.linspace(0, 1, n_bins + 1)[1:-1])
735
+ labels = np.digitize(y, edges)
736
+ if np.bincount(labels).min() >= n_splits:
737
+ return labels
738
+ n_bins -= 1
739
+ return np.zeros(n, dtype=np.intp)
740
+
741
+
742
+ class _ContinuousStratifiedSplitter:
743
+ """Adapt a stratified splitter to a continuous `y` via quantile bins.
744
+
745
+ Exposes the scikit-learn splitter protocol (`split` / `get_n_splits`) so
746
+ it slots into every code path that consumes `cv`; the binning happens at
747
+ split time from whatever `y` the caller passes.
748
+
749
+ It is not a `BaseCrossValidator`. Only `_resolve_splits` consumes it, and
750
+ it materializes the folds immediately, so the object itself never reaches
751
+ scikit-learn's `check_cv`. Keep it that way, or make it a subclass.
752
+ """
753
+
754
+ def __init__(self, base):
755
+ self.base = base
756
+
757
+ @property
758
+ def n_splits(self) -> int:
759
+ return self.base.n_splits
760
+
761
+ def get_n_splits(self, X=None, y=None, groups=None) -> int:
762
+ return self.base.get_n_splits(X, y, groups)
763
+
764
+ def split(self, X, y=None, groups=None):
765
+ strata = _continuous_strata(y, self.base.n_splits)
766
+ yield from self.base.split(X, strata, groups=groups)
767
+
768
+ def __repr__(self) -> str:
769
+ return f"ContinuousStratified({self.base!r})"
770
+
771
+
772
+ def _as_indices(fold) -> np.ndarray:
773
+ """Return one fold as an integer index array, accepting a boolean mask."""
774
+ fold = np.asarray(fold)
775
+ if fold.dtype == bool:
776
+ return np.flatnonzero(fold)
777
+ if fold.size == 0:
778
+ return fold.astype(np.intp, copy=False)
779
+ if not np.issubdtype(fold.dtype, np.integer):
780
+ raise ValueError(
781
+ f"Cross-validation splits must be integer indices or a boolean "
782
+ f"mask; got dtype {fold.dtype}."
783
+ )
784
+ return fold
785
+
786
+
787
+ def _validate_partition(splits: list, *, n_rows: int) -> None:
788
+ """Require the test folds to partition the rows: each row in exactly one."""
789
+ if not splits:
790
+ raise ValueError("The cross-validation splitter produced no folds.")
791
+ assignments = np.concatenate([test for _, test in splits])
792
+ counts = np.bincount(assignments, minlength=n_rows)
793
+ repeated = int((counts > 1).sum())
794
+ missing = int((counts == 0).sum())
795
+ if repeated or missing:
796
+ raise ValueError(
797
+ f"Cross-validation test folds must partition the observations, so "
798
+ f"each row appears in exactly one test fold: {repeated} row(s) "
799
+ f"appear in more than one fold and {missing} row(s) appear in "
800
+ f"none. Repeated, overlapping, and incomplete splitters (for "
801
+ f"example ShuffleSplit or RepeatedKFold) are not accepted."
802
+ )
803
+
804
+
805
+ # ---------------------------------------------------------------------------
806
+ # Whole-brain runner
807
+ # ---------------------------------------------------------------------------
808
+
809
+
810
+ def _fit_and_score_fold(X, y, pipe, scoring, train_idx, test_idx):
811
+ """Fit one fold and return its score, test predictions, and decision values.
812
+
813
+ A module-level function so `joblib` can ship it to a worker process
814
+ directly. The scorer is rebuilt inside the worker because a scorer bound to
815
+ an unfitted estimator does not survive the trip any more cheaply than the
816
+ two arguments it is built from.
817
+
818
+ The third element is the fold's continuous decision values, which
819
+ `predict(plot=True)` needs for the ROC and margin figures and which the
820
+ class labels in the second element cannot supply. It is `None` whenever the
821
+ fitted pipeline has no single continuous value per observation — every
822
+ regressor, and any classifier exposing neither `decision_function` nor a
823
+ two-column `predict_proba`.
824
+ """
825
+ from sklearn.base import clone
826
+ from sklearn.metrics import check_scoring
827
+
828
+ fitted = clone(pipe).fit(X[train_idx], y[train_idx])
829
+ scorer = check_scoring(fitted, scoring=scoring)
830
+ score = float(scorer(fitted, X[test_idx], y[test_idx]))
831
+ return (
832
+ score,
833
+ np.asarray(fitted.predict(X[test_idx])),
834
+ _fold_decision_values(fitted, X[test_idx]),
835
+ )
836
+
837
+
838
+ def _fold_decision_values(fitted, X_test):
839
+ """Return one continuous decision value per test row, or None if there is none.
840
+
841
+ `decision_function` is preferred over `predict_proba`: it is the signed
842
+ distance from the boundary that v0.5.1's margin figure drew, and an
843
+ estimator exposing both reports the same ordering either way.
844
+ """
845
+ if hasattr(fitted, "decision_function"):
846
+ values = np.asarray(fitted.decision_function(X_test), dtype=float)
847
+ return values if values.ndim == 1 else None
848
+ if hasattr(fitted, "predict_proba"):
849
+ proba = np.asarray(fitted.predict_proba(X_test), dtype=float)
850
+ return proba[:, 1] if proba.ndim == 2 and proba.shape[1] == 2 else None
851
+ return None
852
+
853
+
854
+ def _run_whole_brain(bd, X, y, pipe, *, splits, scoring, classes, n_jobs):
855
+ """A fit on all data for the map, then cross-validation for the scores.
856
+
857
+ The canonical ``weight_map`` comes from a single fit on the full
858
+ ``(X, y)``: one real estimator rather than an aggregation of K fold models,
859
+ none of which the caller ever sees. That fit runs *first* so a pipeline
860
+ whose coefficients cannot be projected back raises after one fit instead of
861
+ after K + 1. Nothing observable is reordered — the folds are already
862
+ materialized and the all-data fit does not depend on them.
863
+
864
+ The cross-validation loop then produces honest scores and row-aligned
865
+ out-of-fold predictions. ``n_jobs`` parallelizes that loop — folds are the
866
+ outer independent work at this spatial scale — at the cost of one copy of
867
+ the brain per worker.
868
+
869
+ Returns:
870
+ tuple: The `Predict` record, and the row-aligned out-of-fold decision
871
+ values (`None` when the pipeline produces none). The values ride
872
+ alongside the record rather than inside it because `Predict` is a
873
+ plain record whose fields are fixed per spatial scale; only
874
+ `predict(plot=True)` consumes them.
875
+ """
876
+ from joblib import Parallel, delayed
877
+ from sklearn.base import clone
878
+
879
+ n_samples, n_voxels = X.shape
880
+
881
+ estimator = clone(pipe).fit(X, y)
882
+ weight_map_arr = _as_predict_map(_back_project_weight_maps(estimator, n_voxels))
883
+
884
+ if n_jobs == 1:
885
+ fold_results = [
886
+ _fit_and_score_fold(X, y, pipe, scoring, train_idx, test_idx)
887
+ for train_idx, test_idx in splits
888
+ ]
889
+ else:
890
+ fold_results = Parallel(n_jobs=n_jobs)(
891
+ delayed(_fit_and_score_fold)(X, y, pipe, scoring, train_idx, test_idx)
892
+ for train_idx, test_idx in splits
893
+ )
894
+
895
+ fold_scores = [score for score, _, _ in fold_results]
896
+ fold_preds = [preds for _, preds, _ in fold_results]
897
+ fold_values = [values for _, _, values in fold_results]
898
+ fold_test_idx = [test_idx for _, test_idx in splits]
899
+
900
+ fold_idx_array = np.empty(n_samples, dtype=int)
901
+ for fold_idx, test_idx in enumerate(fold_test_idx):
902
+ fold_idx_array[test_idx] = fold_idx
903
+
904
+ # Assemble out-of-fold predictions with a dtype wide enough for every
905
+ # fold — string class labels included (np.result_type widens e.g.
906
+ # '<U4' vs '<U5'; float folds stay float). The folds partition the rows,
907
+ # so every position is written exactly once.
908
+ pred_dtype = (
909
+ np.result_type(*(p.dtype for p in fold_preds))
910
+ if fold_preds
911
+ else np.dtype(float)
912
+ )
913
+ fold_predictions = np.zeros(n_samples, dtype=pred_dtype)
914
+ for test_idx, preds in zip(fold_test_idx, fold_preds):
915
+ fold_predictions[test_idx] = preds
916
+
917
+ out_of_fold_values = None
918
+ if fold_values and all(values is not None for values in fold_values):
919
+ out_of_fold_values = np.empty(n_samples, dtype=float)
920
+ for test_idx, values in zip(fold_test_idx, fold_values):
921
+ out_of_fold_values[test_idx] = values
922
+
923
+ record = Predict(
924
+ spatial_scale="whole_brain",
925
+ scoring=scoring,
926
+ classes=getattr(estimator, "classes_", classes),
927
+ predictions=fold_predictions,
928
+ cv_folds=fold_idx_array,
929
+ scores=np.asarray(fold_scores, dtype=float),
930
+ estimator=estimator,
931
+ weight_map=_to_braindata(bd, weight_map_arr),
932
+ )
933
+ return record, out_of_fold_values
934
+
935
+
936
+ def _plot_whole_brain_result(record, *, y, out_of_fold_values, classifier) -> None:
937
+ """Draw the v0.5.1 `predict` figures from a whole-brain result.
938
+
939
+ Regression gets the predicted-versus-actual scatter titled with the
940
+ cross-validated Pearson *r* — the correlation between the target and the
941
+ out-of-fold predictions, not the R2 that `mean_score` reports. Binary
942
+ classification gets the ROC of the out-of-fold decision values, then the
943
+ margin figure when the estimator scores by distance from the boundary and
944
+ the probability figure when it scores by probability. Both get the weight
945
+ map, as v0.5.1 did.
946
+
947
+ The positive class is ``classes[1]``, matching the sign convention of both
948
+ the decision values and `weight_map`; that is a label comparison rather than
949
+ v0.5.1's ``astype(bool)``, so string class labels work.
950
+ """
951
+ from nltools.data.roc import Roc
952
+ from nltools.plotting.prediction import (
953
+ _plot_class_probability,
954
+ _plot_decision_margin,
955
+ _plot_predicted_versus_actual,
956
+ )
957
+
958
+ y = np.asarray(y)
959
+ if not classifier:
960
+ predictions = np.asarray(record.predictions, dtype=float)
961
+ # A constant target or a constant prediction has no correlation, and
962
+ # np.corrcoef says so with a RuntimeWarning that would fail docs-build.
963
+ # The helper renders its title without an r.
964
+ degenerate = y.std() == 0 or predictions.std() == 0
965
+ r = (
966
+ None
967
+ if degenerate
968
+ else float(np.corrcoef(y.astype(float), predictions)[0, 1])
969
+ )
970
+ _plot_predicted_versus_actual(y, predictions, r=r)
971
+ else:
972
+ if out_of_fold_values is None:
973
+ raise ValueError(
974
+ "plot=True needs one continuous decision value per observation "
975
+ "for the ROC figure, and this estimator produced none — a "
976
+ "multi-column decision_function or predict_proba gives no "
977
+ "single margin. Drop plot=, or pass an estimator whose "
978
+ "decision_function returns one value per row."
979
+ )
980
+ outcome = y == record.classes[1]
981
+ Roc(input_values=out_of_fold_values, binary_outcome=outcome).plot()
982
+ if hasattr(record.estimator, "decision_function"):
983
+ _plot_decision_margin(out_of_fold_values, y)
984
+ else:
985
+ _plot_class_probability(out_of_fold_values, y)
986
+ record.weight_map.plot()
987
+
988
+
989
+ def _to_braindata(bd, arr):
990
+ """Return one result map as a new, independently owned `BrainData`.
991
+
992
+ The leading axis of a coefficient or score map is not the source
993
+ observations, so the shared result policy clears the row metadata while
994
+ copying the mask and masker state. Returns None if `arr` is None, which
995
+ preserves "field not applicable" semantics.
996
+
997
+ `Predict` deep-copies whatever it is handed, because a caller can construct
998
+ one from a `BrainData` they still own. This map is therefore copied twice on
999
+ the runner path; do not add a third copy here to "harden" it.
1000
+ """
1001
+ from .utils import _result_from_array
1002
+
1003
+ if arr is None:
1004
+ return None
1005
+ return _result_from_array(bd, np.asarray(arr), rows="clear")
1006
+
1007
+
1008
+ def _iter_split(cv, X, y, groups):
1009
+ """Iterate `cv.split`, passing `groups` only to splitters that accept it.
1010
+
1011
+ The decision is made once from the signature, so a `TypeError` raised
1012
+ partway through a custom splitter's iteration surfaces to the caller
1013
+ instead of silently restarting the split.
1014
+ """
1015
+ try:
1016
+ accepts_groups = "groups" in inspect.signature(cv.split).parameters
1017
+ except (TypeError, ValueError):
1018
+ accepts_groups = True
1019
+ if accepts_groups:
1020
+ yield from cv.split(X, y, groups=groups)
1021
+ else:
1022
+ yield from cv.split(X, y)
1023
+
1024
+
1025
+ # ---------------------------------------------------------------------------
1026
+ # Weight-map extraction
1027
+ # ---------------------------------------------------------------------------
1028
+
1029
+
1030
+ def _as_predict_map(maps: np.ndarray) -> np.ndarray:
1031
+ """Shape back-projected coefficients the way `Predict.weight_map` requires.
1032
+
1033
+ `nltools.algorithms.decoding` always returns ``(n_maps, n_features)``. The
1034
+ record wants one *unstacked* map for regression and binary classification
1035
+ and the stack for multiclass. `Predict` no longer re-checks that rule, so
1036
+ this is the only place it is enforced; every runner drops the leading axis
1037
+ here and nowhere else, and
1038
+ `test_braindata_prediction.py::TestWeightMapShapes` pins it.
1039
+
1040
+ Args:
1041
+ maps: Back-projected coefficients, ``(n_maps, n_features)``.
1042
+
1043
+ Returns:
1044
+ ndarray: ``(n_features,)`` when there is one map, else ``maps``.
1045
+ """
1046
+ return maps[0] if maps.shape[0] == 1 else maps
1047
+
1048
+
1049
+ # ---------------------------------------------------------------------------
1050
+ # Searchlight runner
1051
+ # ---------------------------------------------------------------------------
1052
+
1053
+
1054
+ def _score_sphere(X, y, pipe, splits, scoring, neighbor_indices) -> float:
1055
+ """Mean CV score for one searchlight sphere (NaN for degenerate/failed)."""
1056
+ from sklearn.base import clone
1057
+ from sklearn.model_selection import cross_val_score
1058
+
1059
+ X_sphere = X[:, neighbor_indices]
1060
+ if X_sphere.shape[1] < 2:
1061
+ return np.nan
1062
+ try:
1063
+ scores = cross_val_score(clone(pipe), X_sphere, y, cv=splits, scoring=scoring)
1064
+ return float(np.mean(scores))
1065
+ except Exception:
1066
+ return np.nan
1067
+
1068
+
1069
+ def _run_searchlight(
1070
+ bd, X, y, pipe, *, splits, scoring, classes, radius, n_jobs, progress_bar
1071
+ ) -> Predict:
1072
+ """Per-voxel-neighborhood CV decoding. Returns a Predict with one score_map.
1073
+
1074
+ Local models fitted on overlapping neighborhoods have no common feature
1075
+ axis, so the result exposes no coefficient map, no fold assignments and no
1076
+ estimator — only the cross-fold mean score at each sphere center.
1077
+ """
1078
+ from joblib import Parallel, delayed
1079
+
1080
+ from nltools.algorithms.neighborhoods import compute_searchlight_neighborhoods
1081
+
1082
+ neighborhoods = compute_searchlight_neighborhoods(bd.mask, radius=radius)
1083
+
1084
+ def decode_sphere(center_idx, neighbor_indices):
1085
+ return _score_sphere(X, y, pipe, splits, scoring, neighbor_indices)
1086
+
1087
+ neighborhood_list = _maybe_tqdm(
1088
+ list(neighborhoods.iter_neighborhoods()),
1089
+ progress_bar=progress_bar,
1090
+ desc="Searchlight",
1091
+ total=neighborhoods.n_voxels,
1092
+ )
1093
+
1094
+ if n_jobs == 1:
1095
+ sphere_scores = [decode_sphere(c, n) for c, n in neighborhood_list]
1096
+ else:
1097
+ sphere_scores = Parallel(n_jobs=n_jobs)(
1098
+ delayed(decode_sphere)(c, n) for c, n in neighborhood_list
1099
+ )
1100
+ return Predict(
1101
+ spatial_scale="searchlight",
1102
+ scoring=scoring,
1103
+ classes=classes,
1104
+ score_map=_to_braindata(bd, np.asarray(sphere_scores, dtype=float)),
1105
+ )
1106
+
1107
+
1108
+ # ---------------------------------------------------------------------------
1109
+ # ROI runner
1110
+ # ---------------------------------------------------------------------------
1111
+
1112
+
1113
+ def _assemble_roi_weights(label_vec, unique_labels, per_roi) -> np.ndarray:
1114
+ """Write each parcel's coefficients into its voxels, NaN everywhere else.
1115
+
1116
+ Args:
1117
+ label_vec: ``(n_voxels,)`` atlas label per in-mask voxel.
1118
+ unique_labels: The scored parcel labels, in score order.
1119
+ per_roi: One summary dict per parcel; ``coef`` is ``None`` for a parcel
1120
+ whose fit failed.
1121
+
1122
+ Returns:
1123
+ ndarray: ``(n_voxels,)`` for one map, ``(n_classes, n_voxels)`` for
1124
+ multiclass. A parcel whose fit failed keeps NaN in its voxels.
1125
+
1126
+ Raises:
1127
+ ValueError: If no parcel produced coefficients at all.
1128
+ """
1129
+ fitted = [r["coef"] for r in per_roi if r["coef"] is not None]
1130
+ if not fitted:
1131
+ raise ValueError(
1132
+ "No atlas parcel could be fitted, so no weight_map exists. Check "
1133
+ "that the atlas overlaps the mask and that every parcel has enough "
1134
+ "voxels and observations for the estimator."
1135
+ )
1136
+ n_maps = fitted[0].shape[0]
1137
+ weights = np.full((n_maps, label_vec.shape[0]), np.nan, dtype=float)
1138
+ for roi_label, summary in zip(unique_labels, per_roi):
1139
+ if summary["coef"] is not None:
1140
+ weights[:, label_vec == roi_label] = summary["coef"]
1141
+ return _as_predict_map(weights)
1142
+
1143
+
1144
+ def _run_roi(
1145
+ bd, X, y, pipe, *, splits, scoring, classes, roi_mask, n_jobs, progress_bar
1146
+ ) -> Predict:
1147
+ """Per-parcel cross-validated decoding with an assembled voxel-space map.
1148
+
1149
+ Returns a Predict with:
1150
+
1151
+ - ``scores`` ``(n_folds, n_rois)`` — fold scores per parcel, in
1152
+ ``roi_labels`` order.
1153
+ - ``roi_labels`` ``(n_rois,)`` — atlas integer ids.
1154
+ - ``score_map`` — every voxel of parcel *i* set to that parcel's mean fold
1155
+ score (NaN outside parcels).
1156
+ - ``weight_map`` — per-parcel ``coef_`` from one all-data fit per parcel,
1157
+ written back into voxel space (NaN outside parcels).
1158
+
1159
+ Assembly relies on each voxel belonging to exactly one parcel (the atlas is
1160
+ a label image, so this is structural). Cross-parcel weight magnitudes live
1161
+ on different feature distributions and are not directly comparable;
1162
+ within-parcel ranking is meaningful. The per-parcel estimators are internal:
1163
+ they are fitted to produce the map and are not exposed on the result.
1164
+
1165
+ A pipeline whose coefficients cannot be projected back onto the parcel
1166
+ voxel axis raises `ValueError`, exactly as it does for whole-brain
1167
+ decoding. A parcel that simply *fails to fit* — too few voxels, one class
1168
+ in a training fold — is different: that parcel's ``scores`` column and its
1169
+ voxels in both maps come back NaN, and the rest of the atlas is still
1170
+ reported. Nothing warns and no field names the failed parcels; the matching
1171
+ NaN column in ``scores`` is how a caller identifies them. If *every* parcel
1172
+ fails, there is no map to assemble and the call raises.
1173
+ """
1174
+ from joblib import Parallel, delayed
1175
+ from sklearn.base import clone
1176
+ from sklearn.metrics import check_scoring
1177
+
1178
+ from nltools.data.braindata.analysis import _resolve_atlas_label_vec
1179
+ from nltools.data.results import _fold_mean
1180
+
1181
+ _, label_vec, unique_labels = _resolve_atlas_label_vec(bd, roi_mask)
1182
+
1183
+ n_folds = len(splits)
1184
+
1185
+ def decode_roi(roi_label):
1186
+ """Cross-validate and refit one atlas parcel, returning its summary.
1187
+
1188
+ The scorer is built here rather than closed over, so nothing scorer-
1189
+ shaped has to survive the worker boundary — the same rule
1190
+ `_fit_and_score_fold` follows for whole-brain folds.
1191
+ """
1192
+ failed = {"fold_scores": np.full(n_folds, np.nan), "coef": None}
1193
+ cols = label_vec == roi_label
1194
+ if not cols.any():
1195
+ return failed
1196
+ X_roi = X[:, cols]
1197
+
1198
+ fold_scores = []
1199
+ try:
1200
+ for train_idx, test_idx in splits:
1201
+ fitted = clone(pipe).fit(X_roi[train_idx], y[train_idx])
1202
+ scorer = check_scoring(fitted, scoring=scoring)
1203
+ fold_scores.append(float(scorer(fitted, X_roi[test_idx], y[test_idx])))
1204
+ estimator = clone(pipe).fit(X_roi, y)
1205
+ except Exception:
1206
+ return failed
1207
+
1208
+ # Outside the `try`: an unprojectable pipeline is a contract error for
1209
+ # the whole call, not a parcel that happened to fail.
1210
+ return {
1211
+ "fold_scores": np.asarray(fold_scores, dtype=float),
1212
+ "coef": _back_project_weight_maps(estimator, int(cols.sum())),
1213
+ }
1214
+
1215
+ iterator = _maybe_tqdm(
1216
+ unique_labels, progress_bar=progress_bar, desc="ROI decoding"
1217
+ )
1218
+
1219
+ if n_jobs == 1:
1220
+ per_roi = [decode_roi(label) for label in iterator]
1221
+ else:
1222
+ per_roi = Parallel(n_jobs=n_jobs)(
1223
+ delayed(decode_roi)(label) for label in iterator
1224
+ )
1225
+
1226
+ # Scores: (n_folds, n_rois)
1227
+ fold_scores_per_roi = np.vstack([r["fold_scores"] for r in per_roi]).T
1228
+ # The same reduction `Predict.mean_score` uses, so the painted map and the
1229
+ # reported summary cannot drift apart.
1230
+ mean_per_roi = _fold_mean(fold_scores_per_roi, axis=0)
1231
+
1232
+ # score_map: every voxel carries the mean fold score of its parcel.
1233
+ score_arr = np.full(label_vec.shape, np.nan, dtype=float)
1234
+ for roi_label, parcel_score in zip(unique_labels, mean_per_roi):
1235
+ score_arr[label_vec == roi_label] = parcel_score
1236
+
1237
+ # weight_map: assemble per-parcel coefficients back into voxel space. Each
1238
+ # voxel belongs to exactly one parcel, so every coefficient has one
1239
+ # destination. A parcel that failed to fit leaves NaN behind.
1240
+ weight_arr = _assemble_roi_weights(label_vec, unique_labels, per_roi)
1241
+
1242
+ return Predict(
1243
+ spatial_scale="roi",
1244
+ scoring=scoring,
1245
+ classes=classes,
1246
+ scores=fold_scores_per_roi,
1247
+ weight_map=_to_braindata(bd, weight_arr),
1248
+ roi_labels=unique_labels.astype(np.int64),
1249
+ score_map=_to_braindata(bd, score_arr),
1250
+ )