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
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
|