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