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,594 @@
|
|
|
1
|
+
"""Model fitting, contrasts, and statistical tests for `BrainData`.
|
|
2
|
+
|
|
3
|
+
GLM and ridge fitting (with cross-validation), contrast computation, one- and
|
|
4
|
+
two-sample t-tests, and the design-matrix diagnostics `fit` runs before a GLM.
|
|
5
|
+
`BrainData` methods delegate here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import warnings
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from nltools.utils import DesignMatrixWarning, _find_stack_level
|
|
15
|
+
from .utils import _clear_fit_state, _copy_for_fit, _is_default, _result_from_array
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
#: Which estimator each model-specific `BrainData.fit` option belongs to.
|
|
19
|
+
#: `BrainData.fit` exposes both estimators' options on one signature, so an
|
|
20
|
+
#: option supplied for the estimator `model=` did not select is rejected
|
|
21
|
+
#: rather than silently ignored.
|
|
22
|
+
_ESTIMATOR_OPTION_OWNERS = {
|
|
23
|
+
"ridge_alpha": "ridge",
|
|
24
|
+
"ridge_cv": "ridge",
|
|
25
|
+
"ridge_search_iterations": "ridge",
|
|
26
|
+
"ridge_dirichlet_concentration": "ridge",
|
|
27
|
+
"ridge_device": "ridge",
|
|
28
|
+
"ridge_memory_budget_gb": "ridge",
|
|
29
|
+
"ridge_per_target_alpha": "ridge",
|
|
30
|
+
"ridge_prefer_conservative_alpha": "ridge",
|
|
31
|
+
"ridge_progress_bar": "ridge",
|
|
32
|
+
"glm_noise_model": "glm",
|
|
33
|
+
"glm_bins": "glm",
|
|
34
|
+
"glm_n_jobs": "glm",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_unselected_estimator_options(model, supplied):
|
|
39
|
+
"""Reject `fit` options belonging to the estimator `model` did not select.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
model (str): The selected model, `'glm'` or `'ridge'`.
|
|
43
|
+
supplied (Iterable[str]): Names of the model-specific options the
|
|
44
|
+
caller gave a non-default value.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If any supplied name belongs to the unselected estimator.
|
|
48
|
+
"""
|
|
49
|
+
wrong = sorted(
|
|
50
|
+
name for name in supplied if _ESTIMATOR_OPTION_OWNERS.get(name, model) != model
|
|
51
|
+
)
|
|
52
|
+
if wrong:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"model={model!r} does not accept {wrong}: those options belong to "
|
|
55
|
+
f"the unselected estimator."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _warn_if_rank_deficient(X_array):
|
|
60
|
+
"""Warn when a design matrix is rank deficient.
|
|
61
|
+
|
|
62
|
+
A rank-deficient design has no unique least-squares solution. The GLM still
|
|
63
|
+
returns betas — nilearn falls back to a pseudo-inverse — but the effect is
|
|
64
|
+
split arbitrarily across the linearly dependent columns, so any contrast
|
|
65
|
+
touching that subspace is not interpretable. Silence here is dangerous
|
|
66
|
+
because the failure is invisible in the output: the betas come back finite
|
|
67
|
+
and plausible.
|
|
68
|
+
|
|
69
|
+
The warning diagnoses the deficiency (how many columns are dependent, or
|
|
70
|
+
the p > n shape when that is the cause) and offers the fixes: inspect
|
|
71
|
+
with ``DesignMatrix.vif()``, drop redundant columns with
|
|
72
|
+
``DesignMatrix.clean()`` (order-dependent for correlated pairs), or use
|
|
73
|
+
regularization (``fit(model='ridge')``), whose solution is unique and
|
|
74
|
+
order-invariant even when ``X'X`` is singular.
|
|
75
|
+
|
|
76
|
+
We warn rather than raise because over-parameterized designs can still have
|
|
77
|
+
estimable contrasts, and because raising would break pipelines currently
|
|
78
|
+
relying (silently) on the pseudo-inverse.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
X_array (np.ndarray): Design matrix as a 2-D array.
|
|
82
|
+
"""
|
|
83
|
+
if X_array.ndim != 2 or X_array.shape[1] < 2:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
finite = X_array[np.isfinite(X_array).all(axis=1)]
|
|
87
|
+
if finite.shape[0] == 0:
|
|
88
|
+
# Nothing to assess; the fit itself will fail loudly on the NaNs.
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
n_cols = X_array.shape[1]
|
|
92
|
+
rank = int(np.linalg.matrix_rank(finite))
|
|
93
|
+
if rank >= n_cols:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if finite.shape[0] < n_cols:
|
|
97
|
+
# More regressors than (finite) timepoints: deficient by construction,
|
|
98
|
+
# no matter what the columns contain.
|
|
99
|
+
diagnosis = (
|
|
100
|
+
f"the design has more columns ({n_cols}) than usable rows "
|
|
101
|
+
f"({finite.shape[0]}), so it cannot be full rank"
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
diagnosis = f"{n_cols - rank} column(s) are linear combinations of the others"
|
|
105
|
+
warnings.warn(
|
|
106
|
+
f"Design matrix is rank deficient: rank {rank} of {n_cols} columns — "
|
|
107
|
+
f"{diagnosis}. The OLS betas are not uniquely determined, and "
|
|
108
|
+
"contrasts touching the dependent columns are not interpretable: the "
|
|
109
|
+
"fit silently returns one of infinitely many solutions. Possible "
|
|
110
|
+
"fixes: (1) inspect the collinearity with `DesignMatrix.vif()`; "
|
|
111
|
+
"(2) try `DesignMatrix.clean()` to drop redundant columns before "
|
|
112
|
+
"fitting (note: which of a correlated pair survives depends on the "
|
|
113
|
+
"order the design was built in); (3) try regularization — "
|
|
114
|
+
"`fit(model='ridge')` keeps every regressor and has a unique, "
|
|
115
|
+
"order-invariant solution.",
|
|
116
|
+
DesignMatrixWarning,
|
|
117
|
+
stacklevel=_find_stack_level(),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _fit(
|
|
122
|
+
bd,
|
|
123
|
+
model="glm",
|
|
124
|
+
*,
|
|
125
|
+
X=None,
|
|
126
|
+
ridge_alpha=1.0,
|
|
127
|
+
ridge_cv=None,
|
|
128
|
+
ridge_search_iterations=100,
|
|
129
|
+
ridge_dirichlet_concentration=(0.1, 1.0),
|
|
130
|
+
ridge_device="cpu",
|
|
131
|
+
ridge_memory_budget_gb=None,
|
|
132
|
+
ridge_per_target_alpha=True,
|
|
133
|
+
ridge_prefer_conservative_alpha=False,
|
|
134
|
+
ridge_progress_bar=False,
|
|
135
|
+
glm_noise_model="ols",
|
|
136
|
+
glm_bins=100,
|
|
137
|
+
glm_n_jobs=1,
|
|
138
|
+
inplace=True,
|
|
139
|
+
random_state=None,
|
|
140
|
+
):
|
|
141
|
+
"""Fit a model to brain imaging data.
|
|
142
|
+
|
|
143
|
+
`bd.data` is always the response. The estimator and its results are stored
|
|
144
|
+
on the returned `BrainData` for later use with `predict` and, for a GLM,
|
|
145
|
+
`compute_contrasts`.
|
|
146
|
+
|
|
147
|
+
For `model='glm'` the design is diagnosed before estimation, as a warning
|
|
148
|
+
only — nothing is ever dropped, modified, or raised on. A rank-deficient
|
|
149
|
+
design fires `DesignMatrixWarning`, which has its own category so
|
|
150
|
+
it can be silenced surgically with `warnings.filterwarnings`.
|
|
151
|
+
|
|
152
|
+
The facade does not preprocess the response. Compose `scale()` and
|
|
153
|
+
`standardize()` before `fit` when you want them, so the fitted object's
|
|
154
|
+
data, predictions, residuals, and coefficients stay in the response space
|
|
155
|
+
you supplied.
|
|
156
|
+
|
|
157
|
+
Every model-specific option carries a `glm_` or `ridge_` prefix that names
|
|
158
|
+
the estimator it configures; `random_state` keeps its bare name because
|
|
159
|
+
both estimators accept it. A non-default option belonging to the estimator
|
|
160
|
+
`model` did not select raises `ValueError`.
|
|
161
|
+
|
|
162
|
+
**Results stored on the returned `BrainData`:**
|
|
163
|
+
|
|
164
|
+
- `model_` — the fitted `_Ridge` or `_Glm`.
|
|
165
|
+
- GLM: `glm_betas`, `glm_residual`, `glm_predicted`, `glm_r2`.
|
|
166
|
+
- _Ridge: `ridge_weights`, `ridge_fitted_values`, `ridge_r2`.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
bd (BrainData): Data whose `.data` is the regression target.
|
|
170
|
+
model (str): `'glm'` (default) or `'ridge'`.
|
|
171
|
+
X (DesignMatrix | array-like | Mapping): Design matrix for a GLM — a
|
|
172
|
+
precomputed `DesignMatrix` with `n_samples` matching `bd` — or a
|
|
173
|
+
feature matrix for ridge. For banded ridge, a mapping of
|
|
174
|
+
feature-space names to matrices.
|
|
175
|
+
ridge_alpha (float | Sequence[float]): Ridge only. A positive scalar
|
|
176
|
+
fits a fixed alpha and requires `ridge_cv=None`; a sequence selects
|
|
177
|
+
an alpha by cross-validation and requires `ridge_cv`. Default 1.0.
|
|
178
|
+
ridge_cv (int | CV splitter | None): Ridge only. An int is the number
|
|
179
|
+
of unshuffled k-fold splits; an sklearn splitter is used as given.
|
|
180
|
+
Default None.
|
|
181
|
+
ridge_search_iterations (int): Ridge only, banded. Number of sampled
|
|
182
|
+
feature-space weight vectors. Default 100.
|
|
183
|
+
ridge_dirichlet_concentration (float | Sequence[float]): Ridge only,
|
|
184
|
+
banded. Concentration of the Dirichlet distribution the candidate
|
|
185
|
+
weights are drawn from. Default `(0.1, 1.0)`.
|
|
186
|
+
ridge_device (str): Ridge only. `'cpu'` (default) or `'gpu'` (PyTorch
|
|
187
|
+
on CUDA/MPS, or an error when neither is available).
|
|
188
|
+
ridge_memory_budget_gb (float | None): Ridge only. Working-memory
|
|
189
|
+
budget in GB for the solver's internal batching. None (default)
|
|
190
|
+
measures the selected device.
|
|
191
|
+
ridge_per_target_alpha (bool): Ridge only. If True (default), select a
|
|
192
|
+
separate best alpha per voxel; if False, one shared alpha.
|
|
193
|
+
ridge_prefer_conservative_alpha (bool): Ridge only. If True, select the
|
|
194
|
+
largest alpha within one standard deviation of the best score.
|
|
195
|
+
Default False.
|
|
196
|
+
ridge_progress_bar (bool): Ridge only. Display a progress bar over the
|
|
197
|
+
banded search. Default False.
|
|
198
|
+
glm_noise_model (str): GLM only. `'ols'` (default) or `'arN'` for
|
|
199
|
+
Nilearn's autoregressive model of order N (`'ar1'`, `'ar2'`, ...).
|
|
200
|
+
glm_bins (int): GLM only. Nilearn's discretization of the estimated AR
|
|
201
|
+
coefficients. Default 100.
|
|
202
|
+
glm_n_jobs (int): GLM only. CPUs Nilearn uses to fit autoregressive
|
|
203
|
+
groups in parallel. The default OLS fit does not use this path.
|
|
204
|
+
Default 1.
|
|
205
|
+
inplace (bool): If True (default), mutate `bd` and return it. If False,
|
|
206
|
+
fit and return an independent `BrainData` copy while leaving every
|
|
207
|
+
part of `bd` untouched.
|
|
208
|
+
random_state (int | None): Seed shared by both estimators. Default None.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
BrainData: `bd` itself when `inplace=True`; otherwise an independently
|
|
212
|
+
owned fitted copy.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
TypeError: If `model` is unknown, `X` is missing, or `model='glm'` gets
|
|
216
|
+
a design that is not a `DesignMatrix`.
|
|
217
|
+
ValueError: If `X` and `bd` disagree on sample count, or a non-default
|
|
218
|
+
option belongs to the unselected estimator.
|
|
219
|
+
|
|
220
|
+
Examples:
|
|
221
|
+
```python
|
|
222
|
+
# inplace=True (default): results are stored on brain_data
|
|
223
|
+
brain_data.fit(model='ridge', ridge_alpha=1.0, X=features)
|
|
224
|
+
weights = brain_data.ridge_weights
|
|
225
|
+
|
|
226
|
+
# inplace=False: fit a copy; brain_data remains completely unchanged
|
|
227
|
+
fitted = brain_data.fit(model='glm', X=design, inplace=False)
|
|
228
|
+
effect = fitted.compute_contrasts('conditionA - conditionB')
|
|
229
|
+
```
|
|
230
|
+
"""
|
|
231
|
+
from nltools.data.designmatrix import DesignMatrix
|
|
232
|
+
from nltools.models import _Glm, _Ridge
|
|
233
|
+
|
|
234
|
+
if model not in ("glm", "ridge"):
|
|
235
|
+
raise TypeError("supported models are 'glm' (default) and 'ridge'")
|
|
236
|
+
if X is None:
|
|
237
|
+
raise TypeError("X must be provided")
|
|
238
|
+
|
|
239
|
+
_check_unselected_estimator_options(
|
|
240
|
+
model,
|
|
241
|
+
[
|
|
242
|
+
name
|
|
243
|
+
for name, value, default in (
|
|
244
|
+
("ridge_alpha", ridge_alpha, 1.0),
|
|
245
|
+
("ridge_cv", ridge_cv, None),
|
|
246
|
+
("ridge_search_iterations", ridge_search_iterations, 100),
|
|
247
|
+
(
|
|
248
|
+
"ridge_dirichlet_concentration",
|
|
249
|
+
ridge_dirichlet_concentration,
|
|
250
|
+
(0.1, 1.0),
|
|
251
|
+
),
|
|
252
|
+
("ridge_device", ridge_device, "cpu"),
|
|
253
|
+
("ridge_memory_budget_gb", ridge_memory_budget_gb, None),
|
|
254
|
+
("ridge_per_target_alpha", ridge_per_target_alpha, True),
|
|
255
|
+
(
|
|
256
|
+
"ridge_prefer_conservative_alpha",
|
|
257
|
+
ridge_prefer_conservative_alpha,
|
|
258
|
+
False,
|
|
259
|
+
),
|
|
260
|
+
("ridge_progress_bar", ridge_progress_bar, False),
|
|
261
|
+
("glm_noise_model", glm_noise_model, "ols"),
|
|
262
|
+
("glm_bins", glm_bins, 100),
|
|
263
|
+
("glm_n_jobs", glm_n_jobs, 1),
|
|
264
|
+
)
|
|
265
|
+
if not _is_default(value, default)
|
|
266
|
+
],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if model == "glm":
|
|
270
|
+
if not isinstance(X, DesignMatrix):
|
|
271
|
+
raise TypeError(
|
|
272
|
+
f"fit(model='glm') requires a precomputed DesignMatrix for X, "
|
|
273
|
+
f"got {type(X).__name__}. Build one with "
|
|
274
|
+
f"`DesignMatrix(...)` before fitting."
|
|
275
|
+
)
|
|
276
|
+
X_model = X
|
|
277
|
+
X_array = X.to_numpy()
|
|
278
|
+
if X_array.shape[0] != bd.shape[0]:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"X has {X_array.shape[0]} samples, but brain data has "
|
|
281
|
+
f"{bd.shape[0]} samples. number of samples must match."
|
|
282
|
+
)
|
|
283
|
+
_warn_if_rank_deficient(X_array)
|
|
284
|
+
elif isinstance(X, Mapping):
|
|
285
|
+
# Banded ridge: one named feature space per entry.
|
|
286
|
+
X_model = {name: np.asarray(space) for name, space in X.items()}
|
|
287
|
+
for name, space in X_model.items():
|
|
288
|
+
if space.ndim != 2 or space.shape[0] != bd.shape[0]:
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"feature space {name!r} has shape {space.shape}, but brain "
|
|
291
|
+
f"data has {bd.shape[0]} samples. number of samples must match."
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
X_model = np.asarray(X)
|
|
295
|
+
if X_model.ndim != 2 or X_model.shape[0] != bd.shape[0]:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"X has shape {X_model.shape}, but brain data has "
|
|
298
|
+
f"{bd.shape[0]} samples. number of samples must match."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
target = bd if inplace else _copy_for_fit(bd)
|
|
302
|
+
if inplace:
|
|
303
|
+
_clear_fit_state(target)
|
|
304
|
+
|
|
305
|
+
if model == "glm":
|
|
306
|
+
_fit_glm(
|
|
307
|
+
target,
|
|
308
|
+
X_model,
|
|
309
|
+
_Glm(
|
|
310
|
+
noise_model=glm_noise_model,
|
|
311
|
+
bins=glm_bins,
|
|
312
|
+
n_jobs=glm_n_jobs,
|
|
313
|
+
random_state=random_state,
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
return target
|
|
317
|
+
|
|
318
|
+
# Prefix translation is the whole job here: every `ridge_*` facade keyword
|
|
319
|
+
# maps onto the identically-named `_Ridge` constructor argument.
|
|
320
|
+
estimator = _Ridge(
|
|
321
|
+
alpha=ridge_alpha,
|
|
322
|
+
cv=ridge_cv,
|
|
323
|
+
search_iterations=ridge_search_iterations,
|
|
324
|
+
dirichlet_concentration=ridge_dirichlet_concentration,
|
|
325
|
+
device=ridge_device,
|
|
326
|
+
memory_budget_gb=ridge_memory_budget_gb,
|
|
327
|
+
per_target_alpha=ridge_per_target_alpha,
|
|
328
|
+
prefer_conservative_alpha=ridge_prefer_conservative_alpha,
|
|
329
|
+
random_state=random_state,
|
|
330
|
+
progress_bar=ridge_progress_bar,
|
|
331
|
+
)
|
|
332
|
+
_fit_ridge(target, X_model, estimator)
|
|
333
|
+
return target
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _fit_ridge(bd, X, model):
|
|
337
|
+
"""Fit `model` on `X` and attach it and the ridge results the facade owns.
|
|
338
|
+
|
|
339
|
+
Alpha selection and the banded search belong to `_Ridge`; this layer only
|
|
340
|
+
stores the results the facade owns. `model_` is attached only once the fit
|
|
341
|
+
succeeds, so a failed fit never leaves an unfitted estimator on `bd`.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
bd (BrainData): Data whose `.data` is the response.
|
|
345
|
+
X (np.ndarray | Mapping[str, np.ndarray]): Training features.
|
|
346
|
+
model (Ridge): An unfitted estimator.
|
|
347
|
+
|
|
348
|
+
Note:
|
|
349
|
+
Sets `model_`, `ridge_weights`, `ridge_fitted_values`, and `ridge_r2`
|
|
350
|
+
on `bd`.
|
|
351
|
+
"""
|
|
352
|
+
model.fit(X, bd.data)
|
|
353
|
+
bd.model_ = model
|
|
354
|
+
_populate_ridge_attributes(bd, X)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _populate_ridge_attributes(bd, X):
|
|
358
|
+
"""Set ridge_weights / ridge_fitted_values / ridge_r2 from bd.model_."""
|
|
359
|
+
# _Ridge.coef_ is (n_features, n_voxels); no transpose.
|
|
360
|
+
bd.ridge_weights = _result_from_array(
|
|
361
|
+
bd, np.array(bd.model_.coef_, copy=True), rows="clear"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
fitted = bd.model_.predict(X)
|
|
365
|
+
bd.ridge_fitted_values = _result_from_array(
|
|
366
|
+
bd, np.array(fitted, copy=True), rows="preserve"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
r2 = bd.model_.score(X, bd.data) # (n_voxels,)
|
|
370
|
+
bd.ridge_r2 = _result_from_array(
|
|
371
|
+
bd, np.array(r2, copy=True).reshape(1, -1), rows="clear"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _fit_glm(bd, X, model):
|
|
376
|
+
"""Fit `model` on `X` and attach it and the GLM results the facade owns.
|
|
377
|
+
|
|
378
|
+
Numerical fitting, coefficients, predictions, residuals, and R-squared all
|
|
379
|
+
come from `_Glm`; this layer only wraps them as independently owned
|
|
380
|
+
`BrainData` results. `model_` is attached only once the fit succeeds, so a
|
|
381
|
+
failed fit never leaves an unfitted estimator on `bd`.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
bd (BrainData): Data whose `.data` is the response.
|
|
385
|
+
X (DesignMatrix): The training design.
|
|
386
|
+
model (_Glm): An unfitted estimator.
|
|
387
|
+
|
|
388
|
+
Note:
|
|
389
|
+
Sets `model_`, `glm_betas` (one map per design column), `glm_predicted`
|
|
390
|
+
and `glm_residual` (one row per training observation, row metadata
|
|
391
|
+
retained), and `glm_r2` (one fit-quality map). `glm_r2` carries
|
|
392
|
+
Nilearn's whitened variance-ratio semantics: conventional R-squared for
|
|
393
|
+
an OLS fit with an intercept, a pseudo-R-squared in the whitened space
|
|
394
|
+
for an autoregressive one.
|
|
395
|
+
"""
|
|
396
|
+
model.fit(X, bd.data)
|
|
397
|
+
bd.model_ = model
|
|
398
|
+
bd.glm_betas = _result_from_array(
|
|
399
|
+
bd, np.array(model.coef_, copy=True), rows="clear"
|
|
400
|
+
)
|
|
401
|
+
bd.glm_predicted = _result_from_array(
|
|
402
|
+
bd, np.array(model.predicted_, copy=True), rows="preserve"
|
|
403
|
+
)
|
|
404
|
+
bd.glm_residual = _result_from_array(
|
|
405
|
+
bd, np.array(model.residuals_, copy=True), rows="preserve"
|
|
406
|
+
)
|
|
407
|
+
bd.glm_r2 = _result_from_array(
|
|
408
|
+
bd, np.array(model.r2_, copy=True).reshape(1, -1), rows="clear"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _ttest(
|
|
413
|
+
bd,
|
|
414
|
+
*,
|
|
415
|
+
popmean=0.0,
|
|
416
|
+
permutation=False,
|
|
417
|
+
n_permute=5000,
|
|
418
|
+
tail=2,
|
|
419
|
+
return_null=False,
|
|
420
|
+
n_jobs=-1,
|
|
421
|
+
random_state=None,
|
|
422
|
+
progress_bar=False,
|
|
423
|
+
):
|
|
424
|
+
"""Run a one-sample voxelwise t-test across images (axis 0).
|
|
425
|
+
|
|
426
|
+
For a BrainData stack of images (e.g. subject-level contrast maps with
|
|
427
|
+
shape `(n_images, n_voxels)`), test whether the per-voxel mean differs from
|
|
428
|
+
`popmean`. Delegates the statistics to the shared one-sample contract in
|
|
429
|
+
`nltools.algorithms.inference.one_sample`.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
bd (BrainData): Stack of two or more images.
|
|
433
|
+
popmean (float): Population mean to test against. Default 0.0.
|
|
434
|
+
permutation (bool): If True, take p from a sign-flip permutation test on
|
|
435
|
+
`images - popmean` via `one_sample_permutation_test`. The reported
|
|
436
|
+
`t` stays the observed parametric statistic. Default False.
|
|
437
|
+
n_permute (int): Number of permutations, used only when
|
|
438
|
+
`permutation=True`. Default 5000.
|
|
439
|
+
tail (int | str): `2` or `'two'` for two-tailed (default); `1` or `'one'`
|
|
440
|
+
for one-tailed (mean > `popmean`).
|
|
441
|
+
return_null (bool): If True, also return the permutation null. Has no
|
|
442
|
+
effect on the parametric path, which computes no null. Default False.
|
|
443
|
+
n_jobs (int): Number of parallel jobs. Default -1 (all cores).
|
|
444
|
+
random_state (int | None): Random seed for reproducibility.
|
|
445
|
+
progress_bar (bool): If True, show a progress bar. Default False.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
dict: `"mean"`, `"t"`, `"z"` and `"p"` as independent `BrainData` images
|
|
449
|
+
with observation metadata cleared. `"mean"` is the voxelwise mean
|
|
450
|
+
minus `popmean` — the effect relative to the tested null, equal to
|
|
451
|
+
the raw mean only when `popmean=0`. `"t"` is the observed one-sample
|
|
452
|
+
t-statistic on both paths. `"p"` is parametric, or the empirical
|
|
453
|
+
sign-flip p-value when `permutation=True`. `"z"` is the tail-aware
|
|
454
|
+
normal score of `p` (`sign(t) * norm.isf(p/2)` two-tailed), matching
|
|
455
|
+
nilearn's `output_type='z_score'`. With `permutation=True` and
|
|
456
|
+
`return_null=True` the dict also holds `"null_dist"`, an owned
|
|
457
|
+
`(n_permute, n_voxels)` array of centered means in the units of
|
|
458
|
+
`"mean"`. Maps are unthresholded. Apply a cutoff or a
|
|
459
|
+
multiple-comparison correction afterwards.
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
ValueError: If `bd` contains fewer than 2 images.
|
|
463
|
+
|
|
464
|
+
Examples:
|
|
465
|
+
```python
|
|
466
|
+
result = contrast_maps.ttest()
|
|
467
|
+
significant = result["z"].data * (result["p"].data < 0.001)
|
|
468
|
+
|
|
469
|
+
perm = contrast_maps.ttest(
|
|
470
|
+
permutation=True, n_permute=5000, return_null=True, random_state=0
|
|
471
|
+
)
|
|
472
|
+
perm["null_dist"].shape # → (5000, n_voxels)
|
|
473
|
+
```
|
|
474
|
+
"""
|
|
475
|
+
from nltools.algorithms.inference.one_sample import _one_sample_statistics
|
|
476
|
+
|
|
477
|
+
if bd.data.ndim < 2 or bd.data.shape[0] < 2:
|
|
478
|
+
raise ValueError(
|
|
479
|
+
"t-test requires multiple images (got shape[0] < 2). "
|
|
480
|
+
"Stack subject-level maps into a single BrainData first."
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
stats = _one_sample_statistics(
|
|
484
|
+
bd.data,
|
|
485
|
+
popmean=popmean,
|
|
486
|
+
permutation=permutation,
|
|
487
|
+
n_permute=n_permute,
|
|
488
|
+
tail=tail,
|
|
489
|
+
return_null=return_null,
|
|
490
|
+
n_jobs=n_jobs,
|
|
491
|
+
random_state=random_state,
|
|
492
|
+
progress_bar=progress_bar,
|
|
493
|
+
)
|
|
494
|
+
results = {
|
|
495
|
+
key: _result_from_array(bd, stats[key], rows="clear")
|
|
496
|
+
for key in ("mean", "t", "z", "p")
|
|
497
|
+
}
|
|
498
|
+
if "null_dist" in stats:
|
|
499
|
+
results["null_dist"] = stats["null_dist"]
|
|
500
|
+
return results
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _compute_contrasts(bd, contrasts, *, inference=False):
|
|
504
|
+
"""Compute contrasts on a fitted GLM.
|
|
505
|
+
|
|
506
|
+
Pure forwarding: the fitted `_Glm` parses every contrast definition and
|
|
507
|
+
computes every number. This layer wraps each per-voxel array as an
|
|
508
|
+
independently owned `BrainData` map with cleared row metadata, because a
|
|
509
|
+
contrast map's leading axis no longer represents training observations.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
bd (BrainData): Data fitted with `model='glm'`.
|
|
513
|
+
contrasts (str | array-like | Mapping): One contrast definition — a
|
|
514
|
+
string expression over design column names or a flat numeric weight
|
|
515
|
+
vector — or a mapping of names to those definitions.
|
|
516
|
+
inference (bool): If True, return `ContrastResult` records instead of
|
|
517
|
+
bare effect maps. Default False.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
BrainData | ContrastResult | dict: One effect map, or one
|
|
521
|
+
`ContrastResult` of maps when `inference=True`; a dictionary with
|
|
522
|
+
the same keys for a mapping.
|
|
523
|
+
|
|
524
|
+
Raises:
|
|
525
|
+
RuntimeError: If no model has been fitted.
|
|
526
|
+
ValueError: If the fitted model is not a `_Glm`.
|
|
527
|
+
|
|
528
|
+
Examples:
|
|
529
|
+
```python
|
|
530
|
+
data.fit(model="glm", X=design)
|
|
531
|
+
|
|
532
|
+
# Effect map — the input a second-level model consumes
|
|
533
|
+
effect = data.compute_contrasts("conditionA - conditionB")
|
|
534
|
+
|
|
535
|
+
# First-level inference: every statistic in one record
|
|
536
|
+
result = data.compute_contrasts("conditionA - conditionB", inference=True)
|
|
537
|
+
result.statistic.plot(threshold=3.09)
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Note:
|
|
541
|
+
Contrast p-values are one-sided (the nilearn/SPM directional-contrast
|
|
542
|
+
convention): the contrast tests "A > B", so negate it for the other
|
|
543
|
+
direction. This is the documented exception to the library's two-tailed
|
|
544
|
+
default.
|
|
545
|
+
"""
|
|
546
|
+
from nltools.models import _Glm
|
|
547
|
+
|
|
548
|
+
model = getattr(bd, "model_", None)
|
|
549
|
+
if model is None:
|
|
550
|
+
raise RuntimeError(
|
|
551
|
+
"compute_contrasts requires a fitted GLM. Run "
|
|
552
|
+
".fit(model='glm', X=design_matrix) first."
|
|
553
|
+
)
|
|
554
|
+
if not isinstance(model, _Glm):
|
|
555
|
+
raise ValueError(
|
|
556
|
+
f"compute_contrasts requires a fitted _Glm, but this BrainData holds "
|
|
557
|
+
f"a fitted {type(model).__name__}. Refit with model='glm'."
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
computed = model.compute_contrasts(contrasts, inference=inference)
|
|
561
|
+
if isinstance(computed, dict):
|
|
562
|
+
return {name: _contrast_maps(bd, value) for name, value in computed.items()}
|
|
563
|
+
return _contrast_maps(bd, computed)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _contrast_maps(bd, computed):
|
|
567
|
+
"""Wrap one `_Glm` contrast return as independently owned `BrainData` maps.
|
|
568
|
+
|
|
569
|
+
Every per-target statistic becomes its own map; `degrees_of_freedom` stays a
|
|
570
|
+
scalar or an array because it describes the fit, not the voxel axis. The
|
|
571
|
+
payload field names come from `ContrastResult` itself so a field added to
|
|
572
|
+
the record cannot silently go unwrapped here.
|
|
573
|
+
"""
|
|
574
|
+
from nltools.models import ContrastResult
|
|
575
|
+
|
|
576
|
+
if not isinstance(computed, ContrastResult):
|
|
577
|
+
return _result_from_array(bd, np.array(computed, copy=True), rows="clear")
|
|
578
|
+
payload_fields = [
|
|
579
|
+
field.name
|
|
580
|
+
for field in dataclasses.fields(ContrastResult)
|
|
581
|
+
if field.name != "degrees_of_freedom"
|
|
582
|
+
]
|
|
583
|
+
degrees_of_freedom = computed.degrees_of_freedom
|
|
584
|
+
if isinstance(degrees_of_freedom, np.ndarray):
|
|
585
|
+
degrees_of_freedom = degrees_of_freedom.copy()
|
|
586
|
+
return ContrastResult(
|
|
587
|
+
**{
|
|
588
|
+
name: _result_from_array(
|
|
589
|
+
bd, np.array(getattr(computed, name), copy=True), rows="clear"
|
|
590
|
+
)
|
|
591
|
+
for name in payload_fields
|
|
592
|
+
},
|
|
593
|
+
degrees_of_freedom=degrees_of_freedom,
|
|
594
|
+
)
|