nltools 0.6.0.dev0__py3-none-any.whl

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