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
nltools/models/glm.py ADDED
@@ -0,0 +1,543 @@
1
+ """General linear model estimator built on Nilearn's `run_glm`.
2
+
3
+ `_Glm` fits one run represented by a `(DesignMatrix, y)` pair. It knows nothing
4
+ about `BrainData`, masks, NIfTI images, events, or multi-run orchestration: the
5
+ caller supplies a precomputed design (including any intercept column) and a
6
+ preprocessed response. Fitting delegates to `nilearn.glm.first_level.run_glm`
7
+ and contrast inference to Nilearn's `Contrast`, so every number this module
8
+ returns is Nilearn's.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import numpy as np
19
+ from nilearn.glm import Contrast, expression_to_contrast_vector
20
+ from nilearn.glm.first_level import run_glm
21
+
22
+ from .results import ContrastResult
23
+ from .validation import _check_is_fitted
24
+
25
+ if TYPE_CHECKING:
26
+ from nltools.data import DesignMatrix
27
+
28
+ # `arN` with N a positive integer, matching Nilearn's autoregressive orders.
29
+ AR_NOISE_MODEL = re.compile(r"^ar[1-9][0-9]*$")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class _GlmFitState:
34
+ """Immutable record of one fitted Nilearn GLM, sufficient to compute contrasts.
35
+
36
+ Nilearn's `RegressionResults` also hold the response, the whitened
37
+ response, the regression model, and the residuals. `_Glm` extracts this
38
+ much and discards those objects, so the retained state stays proportional
39
+ to the design rather than to the data. Its arrays keep the dtypes Nilearn
40
+ produced.
41
+
42
+ Attributes:
43
+ feature_names (tuple[str, ...]): Fitted design column names in
44
+ coefficient order.
45
+ labels (np.ndarray): Nilearn's per-target model label, shape
46
+ `(n_targets,)`. Every target sharing a label was fitted by the same
47
+ regression model.
48
+ coefficients (np.ndarray): Fitted coefficients, shape
49
+ `(n_features, n_targets)`.
50
+ covariances (dict[Any, np.ndarray]): Normalized parameter covariance of
51
+ shape `(n_features, n_features)` for each fitted label.
52
+ dispersion (np.ndarray): Per-target dispersion (the whitened residual
53
+ mean square), shape `(n_targets,)`.
54
+ residual_degrees_of_freedom (float): Residual degrees of freedom of the
55
+ fitted design.
56
+ """
57
+
58
+ feature_names: tuple[str, ...]
59
+ labels: np.ndarray
60
+ coefficients: np.ndarray
61
+ covariances: dict[Any, np.ndarray]
62
+ dispersion: np.ndarray
63
+ residual_degrees_of_freedom: float
64
+
65
+
66
+ def _check_noise_model(noise_model: object) -> None:
67
+ """Raise unless `noise_model` is `'ols'` or `'arN'` with N a positive integer."""
68
+ if not isinstance(noise_model, str):
69
+ raise TypeError(
70
+ f"noise_model must be a string, got {type(noise_model).__name__}."
71
+ )
72
+ if noise_model != "ols" and AR_NOISE_MODEL.match(noise_model) is None:
73
+ raise ValueError(
74
+ "noise_model must be 'ols' or 'arN' with N a positive integer "
75
+ f"(e.g. 'ar1', 'ar2'); got {noise_model!r}."
76
+ )
77
+
78
+
79
+ def _check_bins(bins: object) -> None:
80
+ """Raise unless `bins` is a positive integer."""
81
+ if isinstance(bins, bool) or not isinstance(bins, (int, np.integer)):
82
+ raise TypeError(f"bins must be an integer, got {type(bins).__name__}.")
83
+ if bins < 1:
84
+ raise ValueError(f"bins must be a positive integer, got {bins}.")
85
+
86
+
87
+ def _check_design_matrix(X: object, method: str) -> None:
88
+ """Raise unless `X` is a `DesignMatrix`.
89
+
90
+ Args:
91
+ X (object): The candidate design.
92
+ method (str): Name of the calling method, used in the message.
93
+
94
+ Raises:
95
+ TypeError: If `X` is not a `DesignMatrix`.
96
+ """
97
+ from nltools.data import DesignMatrix
98
+
99
+ if not isinstance(X, DesignMatrix):
100
+ raise TypeError(
101
+ f"_Glm.{method} requires a DesignMatrix, got {type(X).__name__}. Raw "
102
+ "arrays and other DataFrame types cannot preserve the fitted "
103
+ "coefficient-to-regressor relationship."
104
+ )
105
+
106
+
107
+ def _extract_fit_state(
108
+ labels: np.ndarray,
109
+ results: dict,
110
+ feature_names: tuple[str, ...],
111
+ n_targets: int,
112
+ ) -> tuple[_GlmFitState, np.ndarray]:
113
+ """Copy the compact fitted state and R-squared out of Nilearn's results.
114
+
115
+ Args:
116
+ labels (np.ndarray): Per-target model labels from `run_glm`.
117
+ results (dict): Label to `RegressionResults` mapping from `run_glm`.
118
+ feature_names (tuple[str, ...]): Fitted design column names.
119
+ n_targets (int): Number of fitted targets.
120
+
121
+ Returns:
122
+ tuple[_GlmFitState, np.ndarray]: The retained state and the per-target
123
+ `r_square` values copied from Nilearn.
124
+ """
125
+ first = next(iter(results.values()))
126
+ coefficients = np.zeros((len(feature_names), n_targets), dtype=first.theta.dtype)
127
+ dispersion = np.zeros(n_targets, dtype=np.asarray(first.dispersion).dtype)
128
+ covariances = {}
129
+ # Nilearn computes `r_square` as a bare division by the target's own
130
+ # variance, so a constant target — an empty voxel inside a mask, which any
131
+ # real brain mask contains — makes it 0/0 or x/0. The ratio is genuinely
132
+ # undefined there and comes back non-finite; reading it must not raise
133
+ # numpy's RuntimeWarning on the caller's behalf. The values are copied, not
134
+ # recomputed, so nothing else about them changes.
135
+ with np.errstate(divide="ignore", invalid="ignore"):
136
+ r_square = np.zeros(n_targets, dtype=np.asarray(first.r_square).dtype)
137
+ for label, result in results.items():
138
+ target_mask = labels == label
139
+ coefficients[:, target_mask] = result.theta
140
+ dispersion[target_mask] = result.dispersion
141
+ r_square[target_mask] = result.r_square
142
+ covariances[label] = np.array(result.cov, copy=True)
143
+
144
+ state = _GlmFitState(
145
+ feature_names=feature_names,
146
+ labels=np.array(labels, copy=True),
147
+ coefficients=coefficients,
148
+ covariances=covariances,
149
+ dispersion=dispersion,
150
+ residual_degrees_of_freedom=float(first.df_residuals),
151
+ )
152
+ return state, r_square
153
+
154
+
155
+ def _resolve_contrast(contrast: object, feature_names: tuple[str, ...]) -> np.ndarray:
156
+ """Resolve one contrast definition to a validated float64 weight vector.
157
+
158
+ Args:
159
+ contrast (object): A string expression over the fitted column names or
160
+ a real-valued flat vector with one weight per fitted column.
161
+ feature_names (tuple[str, ...]): Fitted design column names.
162
+
163
+ Returns:
164
+ np.ndarray: The resolved weights, shape `(n_features,)`, dtype float64.
165
+
166
+ Raises:
167
+ TypeError: If `contrast` is boolean, complex, or otherwise nonnumeric.
168
+ ValueError: If the expression is invalid or names an unknown column, or
169
+ if the resolved vector is empty, not one-dimensional, the wrong
170
+ length, non-finite, or all zero.
171
+ """
172
+ if isinstance(contrast, str):
173
+ weights = expression_to_contrast_vector(contrast, list(feature_names))
174
+ elif isinstance(contrast, (bool, np.bool_)):
175
+ raise TypeError(
176
+ "A contrast must be a string expression or a numeric vector; got a boolean."
177
+ )
178
+ else:
179
+ weights = np.asarray(contrast)
180
+ if weights.dtype == np.bool_:
181
+ raise TypeError("A contrast must be real-valued; got boolean weights.")
182
+ if not np.issubdtype(weights.dtype, np.number):
183
+ raise TypeError(
184
+ "A contrast must be a string expression or a numeric vector; got "
185
+ f"an array of dtype {weights.dtype}."
186
+ )
187
+ if np.issubdtype(weights.dtype, np.complexfloating):
188
+ raise TypeError("A contrast must be real-valued; got complex weights.")
189
+
190
+ weights = np.asarray(weights, dtype=np.float64)
191
+ if weights.size == 0:
192
+ raise ValueError("A contrast must have at least one weight; got an empty one.")
193
+ if weights.ndim != 1:
194
+ raise ValueError(
195
+ f"A contrast must be one-dimensional; got {weights.ndim} dimensions. "
196
+ "_Glm does not compute F-contrasts."
197
+ )
198
+ if weights.shape[0] != len(feature_names):
199
+ raise ValueError(
200
+ "A contrast must have exactly one weight per fitted design column "
201
+ f"({len(feature_names)}); got {weights.shape[0]}. Available columns "
202
+ f"are: {list(feature_names)}."
203
+ )
204
+ if not np.all(np.isfinite(weights)):
205
+ raise ValueError("A contrast must contain only finite weights.")
206
+ if not np.any(weights):
207
+ raise ValueError("A contrast must have at least one nonzero weight.")
208
+ return weights
209
+
210
+
211
+ def _contrast_effect(state: _GlmFitState, weights: np.ndarray) -> np.ndarray:
212
+ """Return the contrast effect `weights @ coefficients` as a new array."""
213
+ return weights @ state.coefficients
214
+
215
+
216
+ def _contrast_statistics(state: _GlmFitState, weights: np.ndarray) -> dict[str, Any]:
217
+ """Compute one t-contrast and its inferential statistics from `state`.
218
+
219
+ Effect and variance use the same matrix operations as Nilearn's functional
220
+ `compute_contrast`; the statistic, p-value, and z-score come from Nilearn's
221
+ public `Contrast`. Every returned array owns its data.
222
+
223
+ Args:
224
+ state (_GlmFitState): The retained fitted state.
225
+ weights (np.ndarray): A resolved float64 contrast vector.
226
+
227
+ Returns:
228
+ dict[str, Any]: `effect`, `variance`, `standard_error`, `statistic`,
229
+ `z_score`, `p_value`, and `degrees_of_freedom`.
230
+ """
231
+ effect = _contrast_effect(state, weights)
232
+ variance = np.zeros(state.labels.shape[0], dtype=np.float64)
233
+ for label, covariance in state.covariances.items():
234
+ target_mask = state.labels == label
235
+ variance[target_mask] = (weights @ covariance @ weights) * state.dispersion[
236
+ target_mask
237
+ ]
238
+
239
+ contrast = Contrast(
240
+ effect=effect,
241
+ variance=variance,
242
+ dim=1,
243
+ dof=state.residual_degrees_of_freedom,
244
+ stat_type="t",
245
+ )
246
+ return {
247
+ "effect": effect,
248
+ "variance": variance,
249
+ "standard_error": np.sqrt(variance),
250
+ "statistic": np.array(contrast.stat(), copy=True),
251
+ "z_score": np.array(contrast.z_score(), copy=True),
252
+ "p_value": np.array(contrast.p_value(), copy=True),
253
+ "degrees_of_freedom": float(contrast.dof),
254
+ }
255
+
256
+
257
+ class _Glm:
258
+ """General linear model over a precomputed design matrix and a response.
259
+
260
+ Fits ordinary least squares or an autoregressive noise model with Nilearn's
261
+ `run_glm`, then exposes coefficients, predictions, residuals, R-squared, and
262
+ contrasts. The model is `y = X @ beta + error`: `_Glm` never adds or
263
+ estimates an intercept, so include an intercept column in `X` when the model
264
+ needs one. Predictions and residuals are always in observation space, for
265
+ autoregressive fits as well as OLS.
266
+
267
+ Args:
268
+ noise_model (str): `'ols'` for ordinary least squares, or `'arN'` with
269
+ `N` a positive integer for Nilearn's autoregressive model of that
270
+ order (`'ar1'`, `'ar2'`, ...). Default `'ols'`.
271
+ bins (int): Nilearn's discretization of the estimated AR coefficients —
272
+ the maximum number of histogram bins for AR(1), and the maximum
273
+ number of K-means clusters for higher orders. Must be positive.
274
+ Default 100.
275
+ n_jobs (int): Number of CPUs Nilearn uses to fit autoregressive groups
276
+ in parallel, following joblib's convention (`-1` is all cores). The
277
+ default OLS fit does not use this path. Default 1.
278
+ random_state (int | None): Seeds the K-means step for autoregressive
279
+ models of order two or greater. It does not affect OLS or AR(1)
280
+ results. Default None.
281
+
282
+ Attributes:
283
+ coef_ (np.ndarray): Fitted coefficients, shape `(n_features,)` for a
284
+ one-dimensional `y` and `(n_features, n_targets)` otherwise.
285
+ predicted_ (np.ndarray): Training predictions `X @ coef_`, same shape as
286
+ the fitted `y`.
287
+ residuals_ (np.ndarray): Training residuals `y - predicted_`, same shape
288
+ as the fitted `y`.
289
+ r2_ (float | np.ndarray): Nilearn's `RegressionResults.r_square`, copied
290
+ rather than recomputed. It is the variance ratio
291
+ `variance(whitened_design @ coef_) / variance(whitened_y)`. For OLS
292
+ the whitening is the identity, so with an intercept in the design
293
+ this equals conventional R-squared; for autoregressive noise models
294
+ it is a pseudo-R-squared in the whitened space. A constant target
295
+ has zero variance, so its ratio is undefined and comes back
296
+ non-finite rather than raising a warning. A float for a
297
+ one-dimensional `y`, otherwise shape `(n_targets,)`.
298
+ n_samples_ (int): Fitted sample count.
299
+ n_features_in_ (int): Fitted feature count.
300
+ feature_names_in_ (tuple[str, ...]): Fitted design column names in
301
+ coefficient order.
302
+ n_targets_ (int): Fitted target count; one for a one-dimensional `y`.
303
+ is_fitted_ (bool): True after a successful fit.
304
+
305
+ Examples:
306
+ ```python
307
+ import numpy as np
308
+ from nltools.data import DesignMatrix
309
+ from nltools.models import _Glm
310
+
311
+ n_samples = 100
312
+ rng = np.random.default_rng(0)
313
+ design = DesignMatrix(
314
+ {
315
+ "condition_a": rng.normal(size=n_samples),
316
+ "condition_b": rng.normal(size=n_samples),
317
+ "intercept": np.ones(n_samples),
318
+ },
319
+ sampling_freq=0.5,
320
+ )
321
+ y = rng.normal(size=(n_samples, 50))
322
+
323
+ model = _Glm(noise_model="ar1").fit(design, y)
324
+ effects = model.compute_contrasts("condition_a - condition_b")
325
+ result = model.compute_contrasts("condition_a - condition_b", inference=True)
326
+ result.statistic # → t-statistic per target
327
+ ```
328
+ """
329
+
330
+ def __init__(
331
+ self,
332
+ *,
333
+ noise_model: str = "ols",
334
+ bins: int = 100,
335
+ n_jobs: int = 1,
336
+ random_state: int | None = None,
337
+ ) -> None:
338
+ _check_noise_model(noise_model)
339
+ _check_bins(bins)
340
+ self.noise_model = noise_model
341
+ self.bins = bins
342
+ self.n_jobs = n_jobs
343
+ self.random_state = random_state
344
+ self.is_fitted_ = False
345
+
346
+ def fit(self, X: DesignMatrix, y) -> _Glm:
347
+ """Fit the model to one design matrix and response.
348
+
349
+ A one-dimensional `y` is expanded to a single column for Nilearn and the
350
+ target axis is squeezed back out of every fitted attribute and contrast
351
+ result.
352
+
353
+ Args:
354
+ X (DesignMatrix): Design of shape `(n_samples, n_features)`,
355
+ including any intercept column the model needs.
356
+ y (array-like): Response of shape `(n_samples,)` or
357
+ `(n_samples, n_targets)`.
358
+
359
+ Returns:
360
+ _Glm: The fitted model, for method chaining.
361
+
362
+ Raises:
363
+ TypeError: If `X` is not a `DesignMatrix`.
364
+ ValueError: If `y` is not one- or two-dimensional, or its sample
365
+ count does not match `X`.
366
+ """
367
+ _check_design_matrix(X, "fit")
368
+ response = np.asarray(y)
369
+ if response.ndim not in (1, 2):
370
+ raise ValueError(f"y must be 1-D or 2-D, got {response.ndim} dimensions.")
371
+ if response.shape[0] != X.shape[0]:
372
+ raise ValueError(
373
+ "X and y must have the same number of samples: X has "
374
+ f"{X.shape[0]} and y has {response.shape[0]}."
375
+ )
376
+
377
+ squeeze_targets = response.ndim == 1
378
+ if squeeze_targets:
379
+ response = response[:, None]
380
+ design = X.to_numpy()
381
+
382
+ labels, results = run_glm(
383
+ response,
384
+ design,
385
+ noise_model=self.noise_model,
386
+ bins=self.bins,
387
+ n_jobs=self.n_jobs,
388
+ random_state=self.random_state,
389
+ )
390
+ state, r_square = _extract_fit_state(
391
+ labels, results, tuple(X.columns), response.shape[1]
392
+ )
393
+ del labels, results
394
+
395
+ predicted = design @ state.coefficients
396
+ residuals = response - predicted
397
+
398
+ self._fit_state = state
399
+ self._squeeze_targets = squeeze_targets
400
+ # Copy so a caller mutating the public attribute cannot reach the
401
+ # retained state that later contrasts are computed from.
402
+ self.coef_ = (
403
+ state.coefficients[:, 0] if squeeze_targets else state.coefficients
404
+ ).copy()
405
+ self.predicted_ = predicted[:, 0] if squeeze_targets else predicted
406
+ self.residuals_ = residuals[:, 0] if squeeze_targets else residuals
407
+ self.r2_ = float(r_square[0]) if squeeze_targets else r_square
408
+ self.n_samples_ = response.shape[0]
409
+ self.n_features_in_ = design.shape[1]
410
+ self.feature_names_in_ = state.feature_names
411
+ self.n_targets_ = response.shape[1]
412
+ self.is_fitted_ = True
413
+ return self
414
+
415
+ def predict(self, X: DesignMatrix) -> np.ndarray:
416
+ """Apply the fitted coefficients to a design matrix.
417
+
418
+ `X` must carry exactly the fitted column names. They may appear in any
419
+ order; the columns are reordered to `feature_names_in_` before
420
+ multiplying, so the coefficient-to-regressor relationship survives.
421
+
422
+ Args:
423
+ X (DesignMatrix): Design with the fitted column names, in any order.
424
+
425
+ Returns:
426
+ np.ndarray: `X @ coef_`, shape `(n_samples,)` for a model fitted on
427
+ a one-dimensional `y` and `(n_samples, n_targets)` otherwise.
428
+
429
+ Raises:
430
+ TypeError: If `X` is not a `DesignMatrix`.
431
+ ValueError: If the model is not fitted, or `X` has missing or
432
+ additional columns.
433
+ """
434
+ _check_is_fitted(self)
435
+ _check_design_matrix(X, "predict")
436
+
437
+ columns = list(X.columns)
438
+ missing = sorted(set(self.feature_names_in_) - set(columns))
439
+ additional = sorted(set(columns) - set(self.feature_names_in_))
440
+ if missing or additional:
441
+ raise ValueError(
442
+ "X must have exactly the fitted design columns "
443
+ f"{list(self.feature_names_in_)}. Missing: {missing}. "
444
+ f"Additional: {additional}."
445
+ )
446
+
447
+ return X[list(self.feature_names_in_)].to_numpy() @ self.coef_
448
+
449
+ def compute_contrasts(
450
+ self,
451
+ contrasts,
452
+ *,
453
+ inference: bool = False,
454
+ ) -> float | np.ndarray | ContrastResult | dict:
455
+ """Compute one contrast or a named mapping of contrasts on the fitted model.
456
+
457
+ A contrast is a string expression over the fitted design column names —
458
+ `"condition_a - condition_b"`, `"2 * condition_a - condition_b"` — or a
459
+ real-valued vector with one weight per fitted column. Several contrasts
460
+ must be supplied as a mapping of names to those definitions, which
461
+ leaves every flat numeric sequence unambiguously available as one
462
+ contrast vector.
463
+
464
+ The default returns the effect `contrast @ coef_` only, the appropriate
465
+ input to a second-level model. With `inference=True`, the contrast is
466
+ tested against zero and every inferential output is returned together;
467
+ the p-value is Nilearn's one-sided upper-tail value, so negating the
468
+ contrast tests the opposite direction.
469
+
470
+ Args:
471
+ contrasts (str | array-like | Mapping): One contrast definition, or
472
+ a mapping of string names to contrast definitions.
473
+ inference (bool): If True, return `ContrastResult` records instead of
474
+ bare effects. Default False.
475
+
476
+ Returns:
477
+ float | np.ndarray | ContrastResult | dict: One effect, or one
478
+ `ContrastResult` when `inference=True`; a dictionary with the
479
+ same keys for a mapping. Effects and inferential fields are
480
+ floats for a model fitted on a one-dimensional `y`, shape `(1,)`
481
+ for a `(n_samples, 1)` response, and shape `(n_targets,)`
482
+ otherwise.
483
+
484
+ Raises:
485
+ RuntimeError: If the model has not been fitted.
486
+ TypeError: If `inference` is not a bool, a mapping key is not a
487
+ string, or a contrast is boolean, complex, or nonnumeric.
488
+ ValueError: If the mapping is empty, an expression is invalid or
489
+ names an unknown column, or a resolved contrast is empty,
490
+ non-finite, all zero, wrongly sized, or not one-dimensional.
491
+
492
+ Examples:
493
+ ```python
494
+ model.compute_contrasts("condition_a - condition_b")
495
+ model.compute_contrasts([1, -1, 0])
496
+ model.compute_contrasts({"a_vs_b": "condition_a - condition_b"})
497
+ model.compute_contrasts("condition_a", inference=True).p_value
498
+ ```
499
+ """
500
+ if not self.is_fitted_:
501
+ raise RuntimeError(
502
+ "_Glm instance is not fitted yet. Call 'fit' with a DesignMatrix "
503
+ "and a response before computing contrasts."
504
+ )
505
+ if not isinstance(inference, (bool, np.bool_)):
506
+ raise TypeError(f"inference must be a bool, got {type(inference)}.")
507
+
508
+ if isinstance(contrasts, Mapping):
509
+ if not contrasts:
510
+ raise ValueError(
511
+ "contrasts is an empty mapping; supply at least one named contrast."
512
+ )
513
+ for name in contrasts:
514
+ if not isinstance(name, str):
515
+ raise TypeError(
516
+ f"Contrast names must be strings, got {type(name).__name__}."
517
+ )
518
+ return {
519
+ name: self._one_contrast(definition, inference)
520
+ for name, definition in contrasts.items()
521
+ }
522
+ return self._one_contrast(contrasts, inference)
523
+
524
+ def _one_contrast(self, contrast, inference: bool):
525
+ """Resolve and compute one contrast definition."""
526
+ weights = _resolve_contrast(contrast, self.feature_names_in_)
527
+ if not inference:
528
+ return self._payload(_contrast_effect(self._fit_state, weights))
529
+
530
+ values = _contrast_statistics(self._fit_state, weights)
531
+ return ContrastResult(
532
+ effect=self._payload(values["effect"]),
533
+ variance=self._payload(values["variance"]),
534
+ standard_error=self._payload(values["standard_error"]),
535
+ statistic=self._payload(values["statistic"]),
536
+ z_score=self._payload(values["z_score"]),
537
+ p_value=self._payload(values["p_value"]),
538
+ degrees_of_freedom=values["degrees_of_freedom"],
539
+ )
540
+
541
+ def _payload(self, values: np.ndarray) -> float | np.ndarray:
542
+ """Apply the fitted squeeze rule to one per-target array."""
543
+ return float(values[0]) if self._squeeze_targets else values
@@ -0,0 +1,49 @@
1
+ """Structural result records returned by the nltools estimators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Generic, TypeVar
7
+
8
+ import numpy as np
9
+
10
+
11
+ Payload = TypeVar("Payload")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ContrastResult(Generic[Payload]):
16
+ """Frozen record of the inferential outputs of one contrast.
17
+
18
+ The one result type inferential contrast methods return. Its payload is
19
+ whatever the producer works in: `float` or `np.ndarray` for a `_Glm`,
20
+ `BrainData` for the `BrainData` facade.
21
+
22
+ Fields cannot be rebound. Array payloads stay mutable, but each result owns
23
+ its arrays: they never alias the input contrast, a model's retained state,
24
+ or another result.
25
+
26
+ Every statistic describes the directional hypothesis that `effect` is zero,
27
+ so `p_value` is one-sided; negating the contrast tests the other direction.
28
+
29
+ Attributes:
30
+ effect (Payload): The estimated linear combination of coefficients.
31
+ variance (Payload): The estimated variance of `effect`.
32
+ standard_error (Payload): `np.sqrt` of `variance`, with no absolute
33
+ value or clipping, so it may be non-finite.
34
+ statistic (Payload): The signed t-statistic for the null hypothesis
35
+ that `effect` is zero.
36
+ z_score (Payload): The signed normal-score equivalent of the
37
+ directional p-value.
38
+ p_value (Payload): The one-sided upper-tail p-value.
39
+ degrees_of_freedom (float | np.ndarray): The residual degrees of
40
+ freedom used for inference.
41
+ """
42
+
43
+ effect: Payload
44
+ variance: Payload
45
+ standard_error: Payload
46
+ statistic: Payload
47
+ z_score: Payload
48
+ p_value: Payload
49
+ degrees_of_freedom: float | np.ndarray