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