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,177 @@
|
|
|
1
|
+
"""Multiple comparison corrections and thresholding."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fdr(p, q=0.05):
|
|
7
|
+
"""Determine an FDR threshold for an array of p-values.
|
|
8
|
+
|
|
9
|
+
Benjamini-Hochberg procedure at false discovery rate `q` (valid under
|
|
10
|
+
independence or positive dependence). Written by Tal Yarkoni.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
p (np.ndarray): Vector of p-values.
|
|
14
|
+
q (float): False discovery rate level. Defaults to 0.05.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
float: The p-value threshold; `-1` when no p-value survives correction.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
if not isinstance(p, np.ndarray):
|
|
21
|
+
raise ValueError("Make sure vector of p-values is a numpy array")
|
|
22
|
+
if np.any(p < 0) or np.any(p > 1):
|
|
23
|
+
raise ValueError("array contains p-values that are outside the range 0-1")
|
|
24
|
+
|
|
25
|
+
s = np.sort(p)
|
|
26
|
+
nvox = p.shape[0]
|
|
27
|
+
null = np.array(range(1, nvox + 1), dtype="float") * q / nvox
|
|
28
|
+
below = np.where(s <= null)[0]
|
|
29
|
+
return s[max(below)] if len(below) else -1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def holm_bonf(p, alpha=0.05):
|
|
33
|
+
"""Determine a Holm-Bonferroni (step-down) threshold for an array of p-values.
|
|
34
|
+
|
|
35
|
+
The step-down procedure applies progressively less correction to larger
|
|
36
|
+
p-values. It is more conservative than FDR but much more powerful than plain
|
|
37
|
+
Bonferroni correction.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
p (np.ndarray): Vector of p-values.
|
|
41
|
+
alpha (float): Family-wise alpha level. Defaults to 0.05.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
float: The p-value threshold; `-1` when no p-value survives correction.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
if not isinstance(p, np.ndarray):
|
|
48
|
+
raise ValueError("Make sure vector of p-values is a numpy array")
|
|
49
|
+
|
|
50
|
+
s = np.sort(p)
|
|
51
|
+
nvox = p.shape[0]
|
|
52
|
+
null = alpha / (nvox - np.arange(1, nvox + 1) + 1)
|
|
53
|
+
below = np.where(s <= null)[0]
|
|
54
|
+
return s[max(below)] if len(below) else -1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def threshold(stat, p, thr=0.05, return_mask=False):
|
|
58
|
+
"""Threshold a statistic image by the p-values in a separate image.
|
|
59
|
+
|
|
60
|
+
Voxels whose p-value is at or above `thr` are set to zero in a copy of `stat`.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
stat (BrainData): Statistic image (e.g. betas or t-values).
|
|
64
|
+
p (BrainData): P-value image with the same voxels as `stat`.
|
|
65
|
+
thr (float): P-value threshold; voxels with `p < thr` are kept. Defaults to 0.05.
|
|
66
|
+
return_mask (bool): Also return the binary thresholding mask. Defaults to False.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
BrainData | tuple[BrainData, BrainData]: The thresholded image, or the
|
|
70
|
+
tuple `(thresholded, mask)` when `return_mask=True`.
|
|
71
|
+
|
|
72
|
+
Note:
|
|
73
|
+
`BrainData.threshold` and `nilearn.image.threshold_img` threshold an image
|
|
74
|
+
by its own values; this function is the only one that thresholds one
|
|
75
|
+
image by the p-values of another.
|
|
76
|
+
"""
|
|
77
|
+
from nltools.data import BrainData
|
|
78
|
+
from nltools.data.braindata.utils import _result_from_array
|
|
79
|
+
|
|
80
|
+
if not isinstance(stat, BrainData):
|
|
81
|
+
raise ValueError("Make sure stat is a BrainData instance")
|
|
82
|
+
|
|
83
|
+
if not isinstance(p, BrainData):
|
|
84
|
+
raise ValueError("Make sure p is a BrainData instance")
|
|
85
|
+
|
|
86
|
+
# Ensure stat and p have compatible shapes
|
|
87
|
+
if len(stat.data) != len(p.data):
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"stat and p must have the same number of voxels. "
|
|
90
|
+
f"Got {len(stat.data)} and {len(p.data)}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Work with masked data arrays directly
|
|
94
|
+
# Create binary mask: p < thr
|
|
95
|
+
if thr > 0:
|
|
96
|
+
p_mask = (p.data < thr).astype(float)
|
|
97
|
+
else:
|
|
98
|
+
p_mask = np.zeros(len(p.data), dtype=float)
|
|
99
|
+
|
|
100
|
+
# Apply mask to stat data
|
|
101
|
+
if np.sum(p_mask) > 0:
|
|
102
|
+
# Threshold stat: keep only voxels where p < thr
|
|
103
|
+
thresholded_data = stat.data.copy()
|
|
104
|
+
thresholded_data[p_mask == 0] = 0.0
|
|
105
|
+
else:
|
|
106
|
+
# No voxels pass threshold - return zeros
|
|
107
|
+
thresholded_data = np.zeros(len(stat.data), dtype=float)
|
|
108
|
+
|
|
109
|
+
# Create output BrainData with same mask as stat
|
|
110
|
+
out = _result_from_array(stat, thresholded_data, rows="clear")
|
|
111
|
+
|
|
112
|
+
if return_mask:
|
|
113
|
+
# Create mask BrainData with same mask as p
|
|
114
|
+
mask = _result_from_array(p, p_mask, rows="clear")
|
|
115
|
+
return out, mask
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def multi_threshold(t_map, p_map, thresh):
|
|
120
|
+
"""Threshold a statistic image at several p-values and count the passes per voxel.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
t_map (BrainData): Statistic image (e.g. t-values or betas).
|
|
124
|
+
p_map (BrainData): P-value image with the same voxels as `t_map`.
|
|
125
|
+
thresh (list[float]): P-value thresholds to apply.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
BrainData: Cumulative map. Positive values count how many thresholds a
|
|
129
|
+
positive statistic passed; negative values count the same for negative
|
|
130
|
+
statistics.
|
|
131
|
+
|
|
132
|
+
Note:
|
|
133
|
+
Calling `threshold` once per level gives separate images; this returns a
|
|
134
|
+
single map of the threshold hierarchy, which `nilearn.image.threshold_img`
|
|
135
|
+
cannot produce.
|
|
136
|
+
"""
|
|
137
|
+
from nltools.data import BrainData
|
|
138
|
+
|
|
139
|
+
if not isinstance(t_map, BrainData):
|
|
140
|
+
raise ValueError("Make sure t_map is a BrainData instance")
|
|
141
|
+
|
|
142
|
+
if not isinstance(p_map, BrainData):
|
|
143
|
+
raise ValueError("Make sure p_map is a BrainData instance")
|
|
144
|
+
|
|
145
|
+
if not isinstance(thresh, list):
|
|
146
|
+
raise ValueError("Make sure thresh is a list of p-values")
|
|
147
|
+
|
|
148
|
+
# Ensure compatible shapes
|
|
149
|
+
if len(t_map.data) != len(p_map.data):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"t_map and p_map must have the same number of voxels. "
|
|
152
|
+
f"Got {len(t_map.data)} and {len(p_map.data)}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Initialize cumulative maps (working with masked data arrays)
|
|
156
|
+
pos_out = np.zeros(len(t_map.data), dtype=float)
|
|
157
|
+
neg_out = np.zeros(len(t_map.data), dtype=float)
|
|
158
|
+
|
|
159
|
+
# Accumulate threshold contributions for each threshold level
|
|
160
|
+
for thr in thresh:
|
|
161
|
+
# Use threshold() to get thresholded image at this level
|
|
162
|
+
t_thresh = threshold(t_map, p_map, thr=thr)
|
|
163
|
+
|
|
164
|
+
# Count positive and negative contributions at this threshold level
|
|
165
|
+
pos_out += (t_thresh.data > 0).astype(float)
|
|
166
|
+
neg_out += (t_thresh.data < 0).astype(float)
|
|
167
|
+
|
|
168
|
+
# Combine positive and negative cumulative maps
|
|
169
|
+
# Positive values show positive threshold counts, negative show negative counts
|
|
170
|
+
cumulative_data = pos_out - neg_out
|
|
171
|
+
|
|
172
|
+
# Create output BrainData with cumulative map
|
|
173
|
+
from nltools.data.braindata.utils import _result_from_array
|
|
174
|
+
|
|
175
|
+
out = _result_from_array(t_map, cumulative_data, rows="clear")
|
|
176
|
+
|
|
177
|
+
return out
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Coefficient back-projection for MVPA decoding pipelines.
|
|
2
|
+
|
|
3
|
+
A decoding pipeline preprocesses voxels before it fits, so the coefficients it
|
|
4
|
+
learns live on the preprocessed feature axis, not on the voxel axis a brain map
|
|
5
|
+
needs. These functions walk a *fitted* scikit-learn estimator or `Pipeline`
|
|
6
|
+
backwards and return coefficients on the original voxel axis.
|
|
7
|
+
|
|
8
|
+
Everything here is a pure function over fitted scikit-learn objects: no
|
|
9
|
+
`BrainData`, no file I/O, no global state. `nltools.data.braindata.prediction`
|
|
10
|
+
orchestrates; this module does the numerics.
|
|
11
|
+
|
|
12
|
+
Only the preprocessing steps in `SUPPORTED_TRANSFORMERS` are accepted. A
|
|
13
|
+
transformer outside that set is rejected even when it implements
|
|
14
|
+
`inverse_transform`, because inverting a *data* transformation is not the same
|
|
15
|
+
operation as back-projecting a *coefficient* vector: `Normalizer`, for
|
|
16
|
+
instance, rescales each observation rather than each feature, so its
|
|
17
|
+
coefficients have no fixed voxel-space image.
|
|
18
|
+
|
|
19
|
+
Centering is deliberately not undone. It shifts the intercept of the raw-space
|
|
20
|
+
decision function without changing its slope map, so `raw_data @ weight_map`
|
|
21
|
+
need not reproduce the decision function. Use the fitted estimator itself to
|
|
22
|
+
predict.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
from sklearn.decomposition import PCA
|
|
31
|
+
from sklearn.feature_selection import (
|
|
32
|
+
RFE,
|
|
33
|
+
RFECV,
|
|
34
|
+
GenericUnivariateSelect,
|
|
35
|
+
SelectFdr,
|
|
36
|
+
SelectFpr,
|
|
37
|
+
SelectFromModel,
|
|
38
|
+
SelectFwe,
|
|
39
|
+
SelectKBest,
|
|
40
|
+
SelectorMixin,
|
|
41
|
+
SelectPercentile,
|
|
42
|
+
SequentialFeatureSelector,
|
|
43
|
+
VarianceThreshold,
|
|
44
|
+
)
|
|
45
|
+
from sklearn.multiclass import OneVsRestClassifier
|
|
46
|
+
from sklearn.pipeline import Pipeline
|
|
47
|
+
from sklearn.preprocessing import StandardScaler
|
|
48
|
+
|
|
49
|
+
#: Preprocessing steps a decoding pipeline may contain. Each one has a defined
|
|
50
|
+
#: coefficient back-projection: a scaler rescales weights, `PCA` rotates them,
|
|
51
|
+
#: and every selector expands them with exact zeros at the positions it dropped.
|
|
52
|
+
SUPPORTED_TRANSFORMERS: tuple[type, ...] = (
|
|
53
|
+
StandardScaler,
|
|
54
|
+
PCA,
|
|
55
|
+
VarianceThreshold,
|
|
56
|
+
GenericUnivariateSelect,
|
|
57
|
+
SelectPercentile,
|
|
58
|
+
SelectKBest,
|
|
59
|
+
SelectFpr,
|
|
60
|
+
SelectFdr,
|
|
61
|
+
SelectFwe,
|
|
62
|
+
SelectFromModel,
|
|
63
|
+
RFE,
|
|
64
|
+
RFECV,
|
|
65
|
+
SequentialFeatureSelector,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _BackProjectionError(ValueError):
|
|
70
|
+
"""A fitted pipeline's coefficients cannot be projected onto the voxel axis.
|
|
71
|
+
|
|
72
|
+
A `ValueError` subclass, so callers that catch `ValueError` — including the
|
|
73
|
+
public `BrainData.predict` contract — see it as one, while the internal
|
|
74
|
+
runners can still tell it apart from an unrelated fit failure.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_passthrough(step: Any) -> bool:
|
|
79
|
+
"""Whether a pipeline step is a placeholder that transforms nothing."""
|
|
80
|
+
return step is None or (isinstance(step, str) and step == "passthrough")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _split_pipeline(pipeline: Any) -> tuple[list, Any]:
|
|
84
|
+
"""Split a decoding pipeline into its preprocessing steps and final estimator.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
pipeline: A `Pipeline`, or a bare estimator (a pipeline of one step).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
tuple: ``(steps, final_estimator)``. A bare estimator has no steps.
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(pipeline, Pipeline):
|
|
93
|
+
steps = [step for _, step in pipeline.steps]
|
|
94
|
+
return steps[:-1], steps[-1]
|
|
95
|
+
return [], pipeline
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate_decoding_pipeline(pipeline: Any) -> None:
|
|
99
|
+
"""Check a pipeline's structure before anything is fitted.
|
|
100
|
+
|
|
101
|
+
Catches the two failures that are visible without fitting: a preprocessing
|
|
102
|
+
step outside `SUPPORTED_TRANSFORMERS`, and a `OneVsRestClassifier` that is
|
|
103
|
+
not the final step. Whether the final estimator exposes ``coef_`` can only
|
|
104
|
+
be observed after a fit, so `_back_project_weight_maps` checks that.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
pipeline: The estimator or `Pipeline` MVPA is about to fit.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
_BackProjectionError: If a step is unsupported or misplaced.
|
|
111
|
+
"""
|
|
112
|
+
steps, _ = _split_pipeline(pipeline)
|
|
113
|
+
for step in steps:
|
|
114
|
+
if _is_passthrough(step):
|
|
115
|
+
continue
|
|
116
|
+
if isinstance(step, OneVsRestClassifier):
|
|
117
|
+
raise _BackProjectionError(_ovr_not_final_message())
|
|
118
|
+
if not isinstance(step, SUPPORTED_TRANSFORMERS):
|
|
119
|
+
raise _BackProjectionError(_unsupported_step_message(step))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _whitening_scale(explained_variance: np.ndarray) -> np.ndarray:
|
|
123
|
+
"""Return the per-component scale a whitened `PCA` divides its output by.
|
|
124
|
+
|
|
125
|
+
``sqrt(explained_variance_)``, with values below the dtype's epsilon
|
|
126
|
+
replaced by that epsilon so a degenerate component cannot blow the
|
|
127
|
+
back-projected weights up to infinity.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
explained_variance: The fitted ``PCA.explained_variance_`` vector.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
ndarray: The floored component scales, one per component.
|
|
134
|
+
"""
|
|
135
|
+
scale = np.sqrt(np.asarray(explained_variance, dtype=float))
|
|
136
|
+
eps = np.finfo(scale.dtype).eps
|
|
137
|
+
return np.where(scale < eps, eps, scale)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _coefficient_rows(final_estimator: Any) -> np.ndarray:
|
|
141
|
+
"""Return a fitted estimator's coefficients as ``(n_maps, n_final_features)``.
|
|
142
|
+
|
|
143
|
+
One row for a regressor or a binary classifier — the signed map for
|
|
144
|
+
``classes_[1]`` versus ``classes_[0]`` — and one row per class, in
|
|
145
|
+
``classes_`` order, for a multiclass classifier. Rows are never averaged: a
|
|
146
|
+
mean across classes describes no fitted decision boundary.
|
|
147
|
+
|
|
148
|
+
`OneVsRestClassifier` is handled explicitly because it exposes no combined
|
|
149
|
+
``coef_``; its fitted children are read in class order instead.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
final_estimator: The fitted estimator ending the pipeline.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
ndarray: Coefficients, ``(n_maps, n_final_features)``.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
_BackProjectionError: If the estimator exposes no usable ``coef_``.
|
|
159
|
+
"""
|
|
160
|
+
if isinstance(final_estimator, OneVsRestClassifier):
|
|
161
|
+
return _one_vs_rest_rows(final_estimator)
|
|
162
|
+
coef = _coef_of(final_estimator)
|
|
163
|
+
if coef.ndim != 2:
|
|
164
|
+
raise _BackProjectionError(
|
|
165
|
+
f"{type(final_estimator).__name__}.coef_ has shape {coef.shape}; "
|
|
166
|
+
f"a decoding estimator must expose one coefficient row per map."
|
|
167
|
+
)
|
|
168
|
+
return coef
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _back_project_step(weights: np.ndarray, step: Any) -> np.ndarray:
|
|
172
|
+
"""Project coefficients backwards through one fitted preprocessing step.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
weights: Coefficients on the step's *output* feature axis,
|
|
176
|
+
``(n_maps, n_output_features)``.
|
|
177
|
+
step: The fitted transformer, or a passthrough placeholder.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
ndarray: Coefficients on the step's *input* feature axis,
|
|
181
|
+
``(n_maps, n_input_features)``.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
_BackProjectionError: If the step is unsupported, or its fitted output
|
|
185
|
+
width does not match the incoming coefficients.
|
|
186
|
+
"""
|
|
187
|
+
if _is_passthrough(step):
|
|
188
|
+
return weights
|
|
189
|
+
if not isinstance(step, SUPPORTED_TRANSFORMERS):
|
|
190
|
+
# The same whitelist `_validate_decoding_pipeline` applies before
|
|
191
|
+
# fitting, re-checked here so a direct caller cannot skip it.
|
|
192
|
+
raise _BackProjectionError(_unsupported_step_message(step))
|
|
193
|
+
if isinstance(step, StandardScaler):
|
|
194
|
+
_check_width(weights, int(step.n_features_in_), step)
|
|
195
|
+
if step.with_std and step.scale_ is not None:
|
|
196
|
+
return weights / step.scale_
|
|
197
|
+
return weights
|
|
198
|
+
if isinstance(step, PCA):
|
|
199
|
+
components = step.components_
|
|
200
|
+
_check_width(weights, components.shape[0], step)
|
|
201
|
+
if step.whiten:
|
|
202
|
+
weights = weights / _whitening_scale(step.explained_variance_)
|
|
203
|
+
return weights @ components
|
|
204
|
+
if isinstance(step, SelectorMixin):
|
|
205
|
+
support = step.get_support()
|
|
206
|
+
_check_width(weights, int(support.sum()), step)
|
|
207
|
+
expanded = np.zeros((weights.shape[0], support.size), dtype=weights.dtype)
|
|
208
|
+
expanded[:, support] = weights
|
|
209
|
+
return expanded
|
|
210
|
+
# Unreachable today: every whitelisted class is a StandardScaler, a PCA, or
|
|
211
|
+
# a SelectorMixin. It guards a future whitelist entry that forgets a branch.
|
|
212
|
+
raise _BackProjectionError(_unsupported_step_message(step)) # pragma: no cover
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _back_project_weight_maps(fitted_estimator: Any, n_features: int) -> np.ndarray:
|
|
216
|
+
"""Project a fitted pipeline's coefficients onto the original feature axis.
|
|
217
|
+
|
|
218
|
+
Starts from ``(n_maps, n_final_features)`` coefficients and walks the fitted
|
|
219
|
+
preprocessing steps in reverse order, validating each step's widths, until
|
|
220
|
+
the weights sit on the axis the pipeline was fitted from — the whole-brain,
|
|
221
|
+
parcel, or sphere voxel axis, depending on the caller.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
fitted_estimator: A fitted estimator or `Pipeline`.
|
|
225
|
+
n_features: Width of the original feature axis the maps must land on.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
ndarray: ``(n_maps, n_features)`` — one row for regression and binary
|
|
229
|
+
classification, one row per class for multiclass.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
_BackProjectionError: If the final estimator exposes no ``coef_``, a step
|
|
233
|
+
is unsupported or misplaced, or any width does not line up.
|
|
234
|
+
|
|
235
|
+
Examples:
|
|
236
|
+
```python
|
|
237
|
+
from sklearn.pipeline import make_pipeline
|
|
238
|
+
from sklearn.preprocessing import StandardScaler
|
|
239
|
+
from sklearn.svm import LinearSVC
|
|
240
|
+
|
|
241
|
+
pipe = make_pipeline(StandardScaler(), LinearSVC()).fit(X, y)
|
|
242
|
+
maps = _back_project_weight_maps(pipe, X.shape[1])
|
|
243
|
+
# → (1, n_voxels), in raw voxel units
|
|
244
|
+
```
|
|
245
|
+
"""
|
|
246
|
+
steps, final_estimator = _split_pipeline(fitted_estimator)
|
|
247
|
+
for step in steps:
|
|
248
|
+
if isinstance(step, OneVsRestClassifier):
|
|
249
|
+
raise _BackProjectionError(_ovr_not_final_message())
|
|
250
|
+
weights = _coefficient_rows(final_estimator)
|
|
251
|
+
for step in reversed(steps):
|
|
252
|
+
weights = _back_project_step(weights, step)
|
|
253
|
+
if weights.shape[1] != n_features:
|
|
254
|
+
raise _BackProjectionError(
|
|
255
|
+
f"Back-projected coefficients have width {weights.shape[1]}, but "
|
|
256
|
+
f"the original feature axis has width {n_features}. The pipeline's "
|
|
257
|
+
f"fitted steps do not reach back to the voxels it was fitted from."
|
|
258
|
+
)
|
|
259
|
+
return weights
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _coef_of(estimator: Any) -> np.ndarray:
|
|
263
|
+
"""Return an estimator's ``coef_`` as a 2-D array, or raise if it has none."""
|
|
264
|
+
coef = getattr(estimator, "coef_", None)
|
|
265
|
+
if coef is None:
|
|
266
|
+
raise _BackProjectionError(
|
|
267
|
+
f"{type(estimator).__name__} exposes no coef_, so this decoding "
|
|
268
|
+
f"pipeline produces no weight map. Use a linear estimator — "
|
|
269
|
+
f"'linear_svc', 'logistic_regression', "
|
|
270
|
+
f"'linear_discriminant_analysis', 'ridge_classifier', 'ridge', "
|
|
271
|
+
f"'lasso', 'linear_svr', or any sklearn estimator with coef_."
|
|
272
|
+
)
|
|
273
|
+
coef = np.asarray(coef, dtype=float)
|
|
274
|
+
return coef[None, :] if coef.ndim == 1 else coef
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _one_vs_rest_rows(ovr: OneVsRestClassifier) -> np.ndarray:
|
|
278
|
+
"""Stack a fitted `OneVsRestClassifier`'s child coefficient rows in class order."""
|
|
279
|
+
classes = np.asarray(ovr.classes_)
|
|
280
|
+
children = list(ovr.estimators_)
|
|
281
|
+
expected = 1 if len(classes) == 2 else len(classes)
|
|
282
|
+
if len(children) != expected:
|
|
283
|
+
raise _BackProjectionError(
|
|
284
|
+
f"OneVsRestClassifier fitted {len(children)} child estimator(s) for "
|
|
285
|
+
f"{len(classes)} classes; {expected} were expected."
|
|
286
|
+
)
|
|
287
|
+
rows = []
|
|
288
|
+
for child in children:
|
|
289
|
+
coef = _coef_of(child)
|
|
290
|
+
if coef.shape[0] != 1:
|
|
291
|
+
raise _BackProjectionError(
|
|
292
|
+
f"Each OneVsRestClassifier child must expose one coefficient "
|
|
293
|
+
f"row; {type(child).__name__} exposes {coef.shape[0]}."
|
|
294
|
+
)
|
|
295
|
+
rows.append(coef[0])
|
|
296
|
+
return np.vstack(rows)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _check_width(weights: np.ndarray, expected: int, step: Any) -> None:
|
|
300
|
+
"""Require incoming coefficients to match a fitted step's output width."""
|
|
301
|
+
if weights.shape[1] != expected:
|
|
302
|
+
raise _BackProjectionError(
|
|
303
|
+
f"{type(step).__name__} was fitted to produce {expected} features, "
|
|
304
|
+
f"but the coefficients arriving at it have width "
|
|
305
|
+
f"{weights.shape[1]}. The pipeline's fitted steps do not line up."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _ovr_not_final_message() -> str:
|
|
310
|
+
"""Explain why `OneVsRestClassifier` cannot sit inside a pipeline."""
|
|
311
|
+
return (
|
|
312
|
+
"OneVsRestClassifier must be the final pipeline step: it holds one "
|
|
313
|
+
"fitted child per class and exposes no combined coef_, so nothing "
|
|
314
|
+
"downstream of it can be back-projected. Put the shared preprocessing "
|
|
315
|
+
"before it."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _unsupported_step_message(step: Any) -> str:
|
|
320
|
+
"""Explain why a preprocessing step has no coefficient back-projection."""
|
|
321
|
+
supported = ", ".join(cls.__name__ for cls in SUPPORTED_TRANSFORMERS)
|
|
322
|
+
return (
|
|
323
|
+
f"{type(step).__name__} is not a supported decoding preprocessing step, "
|
|
324
|
+
f"even if it implements inverse_transform: inverting a data "
|
|
325
|
+
f"transformation is not the same as back-projecting coefficients. "
|
|
326
|
+
f"Supported steps are {supported}, plus None and 'passthrough'."
|
|
327
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Permutation tests, bootstrap resampling, and intersubject statistics.
|
|
2
|
+
|
|
3
|
+
Every test here runs on plain numpy arrays and returns a dict of results. The
|
|
4
|
+
one-, two-sample, correlation, matrix, and timeseries permutation tests share
|
|
5
|
+
one execution model: permutations run on joblib workers, `n_jobs` sets how
|
|
6
|
+
many, and a given `random_state` gives the same result at every worker count.
|
|
7
|
+
The intersubject statistics (`isc`, `isc_group`, `isfc`, `isps` in
|
|
8
|
+
`nltools.algorithms`) are built on the same engine.
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
```python
|
|
12
|
+
import numpy as np
|
|
13
|
+
from nltools.algorithms.inference import one_sample_permutation_test
|
|
14
|
+
|
|
15
|
+
data = np.random.randn(30) # 30 subjects
|
|
16
|
+
result = one_sample_permutation_test(data, n_permute=5000)
|
|
17
|
+
result["p"] # → two-sided p-value
|
|
18
|
+
|
|
19
|
+
# Voxel-wise test
|
|
20
|
+
data = np.random.randn(30, 50000) # 30 subjects, 50K voxels
|
|
21
|
+
result = one_sample_permutation_test(data, n_permute=10000)
|
|
22
|
+
(result["p"] < 0.05).sum() # → number of significant voxels
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Note:
|
|
26
|
+
These are the functional core. The data classes wrap them —
|
|
27
|
+
`BrainData.ttest`, `BrainData.bootstrap`, `Adjacency.ttest` — and handle
|
|
28
|
+
masking and result reshaping for you.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Engine entry points, re-exported so `nltools.algorithms.inference` is the one
|
|
32
|
+
# import path for the whole family.
|
|
33
|
+
from .one_sample import one_sample_permutation_test # noqa: F401
|
|
34
|
+
from .two_sample import two_sample_permutation_test # noqa: F401
|
|
35
|
+
from .correlation import correlation_permutation_test # noqa: F401
|
|
36
|
+
from .timeseries import ( # noqa: F401
|
|
37
|
+
circle_shift,
|
|
38
|
+
phase_randomize,
|
|
39
|
+
_timeseries_correlation_permutation_test,
|
|
40
|
+
)
|
|
41
|
+
from .matrix import ( # noqa: F401
|
|
42
|
+
matrix_permutation_test,
|
|
43
|
+
distance_correlation,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# NOTE: the intersubject statistics (`isc`, `isc_group`, `isfc`, `isps`) live in
|
|
47
|
+
# `.intersubject` and are exported flat from `nltools.algorithms` —
|
|
48
|
+
# re-exporting the `isc` *function* here would shadow the `.isc` engine
|
|
49
|
+
# *module* on this package.
|
|
50
|
+
from .isc import _isc_permutation_test, _isc_group_permutation_test # noqa: F401
|