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,463 @@
|
|
|
1
|
+
"""Build regressors for a DesignMatrix: HRF convolution and drift terms.
|
|
2
|
+
|
|
3
|
+
`convolve` applies one of nilearn's HRF models or a custom kernel; `add_poly`
|
|
4
|
+
and `add_dct_basis` add Legendre polynomial and discrete-cosine drift
|
|
5
|
+
regressors in the reserved ``.nl_`` namespace. Each function returns a new
|
|
6
|
+
`DesignMatrix` with metadata updated.
|
|
7
|
+
|
|
8
|
+
The HRF path hands the work to `nilearn.glm.first_level.compute_regressor`
|
|
9
|
+
rather than sampling a kernel itself, so a TR-grid column convolved here and a
|
|
10
|
+
nilearn `FirstLevelModel` regressor built from the same events agree exactly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import warnings
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import polars as pl
|
|
20
|
+
from nilearn.glm.first_level import (
|
|
21
|
+
glover_dispersion_derivative,
|
|
22
|
+
glover_time_derivative,
|
|
23
|
+
spm_dispersion_derivative,
|
|
24
|
+
spm_time_derivative,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from nltools.utils import DesignMatrixWarning, _find_stack_level
|
|
28
|
+
|
|
29
|
+
from .utils import (
|
|
30
|
+
_copy_with,
|
|
31
|
+
_get_data_columns,
|
|
32
|
+
_has_run_separated_drift,
|
|
33
|
+
_reserved_name,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from . import DesignMatrix
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# The HRF models `kernel=` accepts, mapped to what nilearn wants for each:
|
|
41
|
+
# a model name its own API understands, or the nilearn function that computes
|
|
42
|
+
# it. Both `compute_regressor` (the `convolve` path) and
|
|
43
|
+
# `make_first_level_design_matrix` (the events-file constructor) take either
|
|
44
|
+
# form, so nltools writes no kernel code and ships no kernel of its own.
|
|
45
|
+
_KERNELS = {
|
|
46
|
+
"glover": "glover",
|
|
47
|
+
"glover_time": glover_time_derivative,
|
|
48
|
+
"glover_dispersion": glover_dispersion_derivative,
|
|
49
|
+
"spm": "spm",
|
|
50
|
+
"spm_time": spm_time_derivative,
|
|
51
|
+
"spm_dispersion": spm_dispersion_derivative,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _kernel_names() -> str:
|
|
56
|
+
"""Return the accepted kernel names, for error messages."""
|
|
57
|
+
return ", ".join(repr(name) for name in _KERNELS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _hrf_regressor(column: np.ndarray, sampling_freq: float, kernel) -> np.ndarray:
|
|
61
|
+
"""Convolve one TR-sampled column with a nilearn HRF model.
|
|
62
|
+
|
|
63
|
+
nilearn's HRF functions are written to be sampled on a finely oversampled
|
|
64
|
+
grid, convolved there, and resampled onto the frame times; that is what
|
|
65
|
+
`compute_regressor` does and what `FirstLevelModel` uses. So the column is
|
|
66
|
+
handed to nilearn as a condition rather than convolved here: each non-zero
|
|
67
|
+
sample becomes one event, onset ``i / sampling_freq``, duration one TR
|
|
68
|
+
(a design-matrix row means the regressor is on for that whole TR), and
|
|
69
|
+
amplitude the sample value. This conversion is the only logic nltools adds.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
column (np.ndarray): Column values, one sample per TR.
|
|
73
|
+
sampling_freq (float): Sampling frequency in Hz (= 1/TR).
|
|
74
|
+
kernel (str | Callable): A value of `_KERNELS` — an nilearn HRF model
|
|
75
|
+
name or the nilearn function that computes it.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
np.ndarray: Convolved regressor sampled at the frame times, same
|
|
79
|
+
length as `column`.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: If the column holds fewer than two timepoints; nilearn
|
|
83
|
+
reads the TR off the spacing of the frame times.
|
|
84
|
+
"""
|
|
85
|
+
from nilearn.glm.first_level import compute_regressor
|
|
86
|
+
|
|
87
|
+
if column.size < 2:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"HRF convolution needs at least two timepoints, got {column.size}. "
|
|
90
|
+
"nilearn reads the repetition time off the spacing between frame "
|
|
91
|
+
"times, which a single-row design does not have."
|
|
92
|
+
)
|
|
93
|
+
tr = 1.0 / sampling_freq
|
|
94
|
+
active = np.flatnonzero(column)
|
|
95
|
+
if active.size == 0:
|
|
96
|
+
return np.zeros(column.size)
|
|
97
|
+
exp_condition = (active * tr, np.full(active.size, tr), column[active])
|
|
98
|
+
regressor, _ = compute_regressor(
|
|
99
|
+
exp_condition,
|
|
100
|
+
kernel,
|
|
101
|
+
np.arange(column.size) * tr,
|
|
102
|
+
oversampling=50,
|
|
103
|
+
)
|
|
104
|
+
return regressor[:, 0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _convolve(
|
|
108
|
+
dm: DesignMatrix,
|
|
109
|
+
kernel: str | np.ndarray = "glover",
|
|
110
|
+
columns: list[str] | None = None,
|
|
111
|
+
) -> DesignMatrix:
|
|
112
|
+
"""Convolve columns with an HRF model or custom kernel.
|
|
113
|
+
|
|
114
|
+
A `kernel` name selects one of nilearn's HRF models: each column is handed
|
|
115
|
+
to `nilearn.glm.first_level.compute_regressor` as a condition, convolved at
|
|
116
|
+
an oversampling factor of 50, and resampled onto the frame times — the same
|
|
117
|
+
computation `FirstLevelModel` runs, so the two agree on identical events.
|
|
118
|
+
A `kernel` array is applied with `numpy.convolve` instead.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
dm (DesignMatrix): DesignMatrix to convolve.
|
|
122
|
+
kernel (str | np.ndarray): An HRF model name — ``'glover'`` (default),
|
|
123
|
+
``'glover_time'``, ``'glover_dispersion'``, ``'spm'``,
|
|
124
|
+
``'spm_time'`` or ``'spm_dispersion'`` — or custom kernel(s) as a
|
|
125
|
+
1D array (single kernel) or 2D array (samples x kernels).
|
|
126
|
+
columns (list[str] | None): Columns to convolve. Default: all
|
|
127
|
+
non-confound columns that are not already convolved.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
DesignMatrix: New DesignMatrix with convolved columns.
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
```python
|
|
134
|
+
# Canonical Glover HRF → produces 'stim_c0'
|
|
135
|
+
dm_conv = convolve(dm)
|
|
136
|
+
|
|
137
|
+
# Glover HRF plus its time derivative, as a second design → 'stim_c0'
|
|
138
|
+
dm_deriv = convolve(dm, kernel="glover_time")
|
|
139
|
+
|
|
140
|
+
# Custom 1-D kernel → produces 'stim_c0'
|
|
141
|
+
kernel = np.array([0.5, 1.0, 0.5])
|
|
142
|
+
dm_conv = convolve(dm, kernel=kernel)
|
|
143
|
+
|
|
144
|
+
# Multiple kernels (FIR model) → produces 'stim_c0', 'stim_c1'
|
|
145
|
+
kernels = np.array([[1.0, 0.5], [0.5, 1.0]]).T # 2 kernels
|
|
146
|
+
dm_conv = convolve(dm, kernel=kernels)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Note:
|
|
150
|
+
Convolved columns are always renamed to ``<col>_c{i}``; the source
|
|
151
|
+
column is dropped. ``dm.convolved`` records the post-suffix names
|
|
152
|
+
(the columns that actually exist in the returned dataframe), so
|
|
153
|
+
downstream metadata propagation through ``.append()`` stays in
|
|
154
|
+
sync with the dataframe.
|
|
155
|
+
"""
|
|
156
|
+
if dm.sampling_freq is None:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"DesignMatrix must have sampling_freq set for convolution. "
|
|
159
|
+
"Specify sampling_freq when creating: DesignMatrix(..., sampling_freq=0.5)"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Determine which columns to convolve
|
|
163
|
+
already_convolved = set(dm.convolved)
|
|
164
|
+
if columns is None:
|
|
165
|
+
# Default: experimental regressors only (drop confounds & polys),
|
|
166
|
+
# idempotent over already-convolved columns — re-convolving would
|
|
167
|
+
# produce ``<col>_c0_c0``, which has no biological meaning.
|
|
168
|
+
columns_to_convolve = [
|
|
169
|
+
c
|
|
170
|
+
for c in _get_data_columns(dm, exclude_confounds=True)
|
|
171
|
+
if c not in already_convolved
|
|
172
|
+
]
|
|
173
|
+
if not columns_to_convolve:
|
|
174
|
+
warnings.warn(
|
|
175
|
+
"All experimental regressors are already convolved; "
|
|
176
|
+
".convolve() is a no-op.",
|
|
177
|
+
DesignMatrixWarning,
|
|
178
|
+
stacklevel=_find_stack_level(),
|
|
179
|
+
)
|
|
180
|
+
return dm
|
|
181
|
+
else:
|
|
182
|
+
# Explicit columns=. Refuse names already in dm.convolved — there is
|
|
183
|
+
# no mathematically sensible "re-convolve" operation. Convolving an
|
|
184
|
+
# HRF-shaped signal with another kernel produces a doubly-blurred
|
|
185
|
+
# thing that doesn't correspond to any neural or hemodynamic process.
|
|
186
|
+
# If the user wants a different kernel, they should rebuild the DM
|
|
187
|
+
# from boxcar regressors and convolve fresh.
|
|
188
|
+
invalid = [c for c in columns if c in already_convolved]
|
|
189
|
+
if invalid:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"Cannot re-convolve already-convolved columns: {invalid}. "
|
|
192
|
+
"Convolving an HRF-shaped signal with another kernel has no "
|
|
193
|
+
"biological meaning. To use a different kernel, drop the "
|
|
194
|
+
"convolved column, re-add the boxcar source, and call "
|
|
195
|
+
".convolve() with the new kernel."
|
|
196
|
+
)
|
|
197
|
+
columns_to_convolve = list(columns)
|
|
198
|
+
|
|
199
|
+
# Decide between a nilearn HRF model and a caller-supplied kernel array
|
|
200
|
+
hrf_model = None
|
|
201
|
+
kernels_2d = None
|
|
202
|
+
if isinstance(kernel, str):
|
|
203
|
+
if kernel not in _KERNELS:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"Unknown kernel {kernel!r}. Accepted HRF model names are "
|
|
206
|
+
f"{_kernel_names()}, or pass a numpy array of your own "
|
|
207
|
+
"kernel(s) — 1D (samples,) or 2D (samples, n_kernels)."
|
|
208
|
+
)
|
|
209
|
+
hrf_model = _KERNELS[kernel]
|
|
210
|
+
elif isinstance(kernel, np.ndarray):
|
|
211
|
+
if len(kernel.shape) > 2:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"A kernel array must be 1D (shape: (samples,)) or 2D (shape: (samples, n_kernels)). "
|
|
214
|
+
f"Got shape: {kernel.shape}. "
|
|
215
|
+
"Tip: Use nilearn.glm.first_level.glover_hrf() to generate HRFs."
|
|
216
|
+
)
|
|
217
|
+
# Normalize to 2-D (samples, n_kernels) so 1-D and 2-D paths share code.
|
|
218
|
+
kernels_2d = kernel.reshape(-1, 1) if kernel.ndim == 1 else kernel
|
|
219
|
+
else:
|
|
220
|
+
raise TypeError(
|
|
221
|
+
f"kernel must be an HRF model name ({_kernel_names()}) or a numpy "
|
|
222
|
+
f"array, got {type(kernel).__name__}."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
n_rows = dm.shape[0]
|
|
226
|
+
|
|
227
|
+
convolved_series: list[pl.Series] = []
|
|
228
|
+
new_convolved: list[str] = []
|
|
229
|
+
for col in columns_to_convolve:
|
|
230
|
+
# NECESSARY: both paths require numpy arrays (no Polars equivalent)
|
|
231
|
+
col_data = dm.data[col].to_numpy()
|
|
232
|
+
if kernels_2d is None:
|
|
233
|
+
results = [_hrf_regressor(col_data, dm.sampling_freq, hrf_model)]
|
|
234
|
+
else:
|
|
235
|
+
results = [
|
|
236
|
+
np.convolve(col_data, kernels_2d[:, k])[:n_rows]
|
|
237
|
+
for k in range(kernels_2d.shape[1])
|
|
238
|
+
]
|
|
239
|
+
for k_idx, result in enumerate(results):
|
|
240
|
+
new_name = f"{col}_c{k_idx}"
|
|
241
|
+
convolved_series.append(pl.Series(new_name, result))
|
|
242
|
+
new_convolved.append(new_name)
|
|
243
|
+
|
|
244
|
+
# Drop source columns and add suffixed variants. Single-kernel and
|
|
245
|
+
# multi-kernel are now uniform: source name never survives, output is
|
|
246
|
+
# always ``<col>_c{i}``.
|
|
247
|
+
new_df = dm.data.drop(columns_to_convolve).with_columns(convolved_series)
|
|
248
|
+
|
|
249
|
+
# Re-convolution of already-convolved columns is refused above, so any
|
|
250
|
+
# entries in ``dm.convolved`` survived in ``new_df`` untouched; just
|
|
251
|
+
# append the freshly convolved names.
|
|
252
|
+
return _copy_with(dm, new_df, convolved=list(dm.convolved) + new_convolved)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _add_poly(
|
|
256
|
+
dm: DesignMatrix,
|
|
257
|
+
order: int = 0,
|
|
258
|
+
include_lower: bool = True,
|
|
259
|
+
) -> DesignMatrix:
|
|
260
|
+
"""Add Legendre polynomial drift terms.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
dm (DesignMatrix): DesignMatrix to add polynomials to.
|
|
264
|
+
order (int): Polynomial order (0=intercept, 1=linear, 2=quadratic, ...).
|
|
265
|
+
Default: 0.
|
|
266
|
+
include_lower (bool): If True, include all orders from 0 to order.
|
|
267
|
+
Default: True.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
DesignMatrix: New DesignMatrix with polynomial columns appended, named
|
|
271
|
+
``.nl_poly_{order}`` in the reserved namespace (see `RESERVED_PREFIX`).
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
ValueError: If order < 0, or if the design already carries run-separated
|
|
275
|
+
drift terms from a previous multi-run append.
|
|
276
|
+
"""
|
|
277
|
+
from scipy.special import legendre
|
|
278
|
+
|
|
279
|
+
if order < 0:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
f"Polynomial order must be >= 0, got {order}. "
|
|
282
|
+
"Common orders: 0 (intercept only), 1 (linear trend), 2 (quadratic), 3 (cubic)."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Adding a global drift term on top of per-run ones is ambiguous.
|
|
286
|
+
if _has_run_separated_drift(dm):
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"This Design Matrix contains run-separated drift terms (polynomial "
|
|
289
|
+
"or cosine) from a previous append operation, which makes adding "
|
|
290
|
+
"global polynomial terms ambiguous. Call .add_poly() on each "
|
|
291
|
+
"single-run Design Matrix before appending them instead."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Determine which polynomials to add
|
|
295
|
+
if include_lower:
|
|
296
|
+
orders_to_add = range(order + 1)
|
|
297
|
+
else:
|
|
298
|
+
orders_to_add = [order]
|
|
299
|
+
|
|
300
|
+
# Detect existing intercept columns (any all-ones confound)
|
|
301
|
+
_has_intercept = False
|
|
302
|
+
if dm.confounds:
|
|
303
|
+
for p in dm.confounds:
|
|
304
|
+
col_vals = dm[p].to_numpy().flatten()
|
|
305
|
+
if np.allclose(col_vals, 1.0):
|
|
306
|
+
_has_intercept = True
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
# Check if we already have these polynomials (idempotent)
|
|
310
|
+
new_poly_cols = {}
|
|
311
|
+
for i in orders_to_add:
|
|
312
|
+
poly_name = _reserved_name(f"poly_{i}")
|
|
313
|
+
if poly_name in dm.confounds:
|
|
314
|
+
warnings.warn(
|
|
315
|
+
f"Design Matrix already has {i}th order polynomial...skipping",
|
|
316
|
+
DesignMatrixWarning,
|
|
317
|
+
stacklevel=_find_stack_level(),
|
|
318
|
+
)
|
|
319
|
+
elif i == 0 and _has_intercept:
|
|
320
|
+
warnings.warn(
|
|
321
|
+
f"Design Matrix already has an intercept column...skipping {poly_name}",
|
|
322
|
+
DesignMatrixWarning,
|
|
323
|
+
stacklevel=_find_stack_level(),
|
|
324
|
+
)
|
|
325
|
+
else:
|
|
326
|
+
# Create normalized Legendre polynomial over [-1, 1]
|
|
327
|
+
norm_order = np.linspace(-1, 1, dm.shape[0])
|
|
328
|
+
poly_values = legendre(i)(norm_order)
|
|
329
|
+
new_poly_cols[poly_name] = poly_values
|
|
330
|
+
|
|
331
|
+
# If no new polynomials to add, return dm unchanged
|
|
332
|
+
if not new_poly_cols:
|
|
333
|
+
return dm
|
|
334
|
+
|
|
335
|
+
# Add new polynomial columns using Polars .with_columns()
|
|
336
|
+
new_df = dm.data.with_columns(
|
|
337
|
+
[pl.Series(name, values) for name, values in new_poly_cols.items()]
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Update confounds metadata
|
|
341
|
+
new_confounds = dm.confounds.copy() if dm.confounds else []
|
|
342
|
+
new_confounds.extend(new_poly_cols.keys())
|
|
343
|
+
|
|
344
|
+
# Return new DesignMatrix with updated data and metadata
|
|
345
|
+
return _copy_with(dm, new_df, confounds=new_confounds)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _add_dct_basis(
|
|
349
|
+
dm: DesignMatrix,
|
|
350
|
+
*,
|
|
351
|
+
duration: float = 180,
|
|
352
|
+
drop: int = 0,
|
|
353
|
+
include_constant: bool = True,
|
|
354
|
+
) -> DesignMatrix:
|
|
355
|
+
"""Add discrete cosine transform basis functions for high-pass filtering.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
dm (DesignMatrix): DesignMatrix to add the DCT basis to.
|
|
359
|
+
duration (float): Filter duration in seconds. Default: 180.
|
|
360
|
+
drop (int): Number of low-frequency bases to drop. Default: 0.
|
|
361
|
+
include_constant (bool): If True, also add a constant/intercept column
|
|
362
|
+
named ``.nl_cosine_0`` (analogous to ``.nl_poly_0`` in `add_poly`).
|
|
363
|
+
The underlying DCT basis drops the constant per SPM convention;
|
|
364
|
+
set False to match SPM behavior. Default: True.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
DesignMatrix: New DesignMatrix with DCT basis columns appended, named
|
|
368
|
+
``.nl_cosine_{i}`` in the reserved namespace (see `RESERVED_PREFIX`).
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
ValueError: If sampling_freq is not set, or if the design already
|
|
372
|
+
carries run-separated drift terms from a previous multi-run append.
|
|
373
|
+
"""
|
|
374
|
+
from nltools.algorithms.signal import make_cosine_basis
|
|
375
|
+
|
|
376
|
+
if dm.sampling_freq is None:
|
|
377
|
+
raise ValueError(
|
|
378
|
+
"DesignMatrix must have sampling_freq set for DCT basis functions. "
|
|
379
|
+
"Specify sampling_freq when creating: DesignMatrix(..., sampling_freq=0.5)"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Adding a global drift term on top of per-run ones is ambiguous.
|
|
383
|
+
if _has_run_separated_drift(dm):
|
|
384
|
+
raise ValueError(
|
|
385
|
+
"This Design Matrix contains run-separated drift terms (polynomial "
|
|
386
|
+
"or cosine) from a previous append operation, which makes adding "
|
|
387
|
+
"global cosine bases ambiguous. Call .add_dct_basis() on each "
|
|
388
|
+
"single-run Design Matrix before appending them instead."
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Create DCT basis matrix using stats function
|
|
392
|
+
basis_mat = make_cosine_basis(
|
|
393
|
+
dm.shape[0], 1.0 / dm.sampling_freq, duration, drop=drop
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Generate column names (.nl_cosine_1, .nl_cosine_2, ...)
|
|
397
|
+
# Note: If drop > 0, numbering starts from drop+1 to reflect original indices
|
|
398
|
+
# e.g., drop=2 -> .nl_cosine_3, .nl_cosine_4, ... (skipped 1 and 2)
|
|
399
|
+
basis_col_names = [
|
|
400
|
+
_reserved_name(f"cosine_{drop + i + 1}") for i in range(basis_mat.shape[1])
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
# Optionally prepend the constant/intercept — mirrors .nl_poly_0 in add_poly.
|
|
404
|
+
# make_cosine_basis drops the constant per SPM; we re-add it here when asked,
|
|
405
|
+
# and skip if an intercept-like confounds column already exists.
|
|
406
|
+
if include_constant:
|
|
407
|
+
constant_name = _reserved_name("cosine_0")
|
|
408
|
+
_has_intercept = False
|
|
409
|
+
if dm.confounds:
|
|
410
|
+
for p in dm.confounds:
|
|
411
|
+
col_vals = dm[p].to_numpy().flatten()
|
|
412
|
+
if np.allclose(col_vals, 1.0):
|
|
413
|
+
_has_intercept = True
|
|
414
|
+
break
|
|
415
|
+
if constant_name in (dm.confounds or []) or _has_intercept:
|
|
416
|
+
warnings.warn(
|
|
417
|
+
f"Design Matrix already has an intercept column...skipping {constant_name}",
|
|
418
|
+
DesignMatrixWarning,
|
|
419
|
+
stacklevel=_find_stack_level(),
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
basis_col_names.insert(0, constant_name)
|
|
423
|
+
basis_mat = np.column_stack([np.ones(dm.shape[0]), basis_mat])
|
|
424
|
+
|
|
425
|
+
# Check which bases we don't already have (idempotent)
|
|
426
|
+
if dm.confounds:
|
|
427
|
+
basis_to_add = [name for name in basis_col_names if name not in dm.confounds]
|
|
428
|
+
else:
|
|
429
|
+
basis_to_add = basis_col_names
|
|
430
|
+
|
|
431
|
+
# If no new bases to add, return dm unchanged
|
|
432
|
+
if not basis_to_add:
|
|
433
|
+
warnings.warn(
|
|
434
|
+
"All basis functions already exist...skipping",
|
|
435
|
+
DesignMatrixWarning,
|
|
436
|
+
stacklevel=_find_stack_level(),
|
|
437
|
+
)
|
|
438
|
+
return dm
|
|
439
|
+
|
|
440
|
+
if len(basis_to_add) < len(basis_col_names):
|
|
441
|
+
warnings.warn(
|
|
442
|
+
"Some basis functions already exist...skipping",
|
|
443
|
+
DesignMatrixWarning,
|
|
444
|
+
stacklevel=_find_stack_level(),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Add new cosine basis columns
|
|
448
|
+
# Only add the columns we don't already have
|
|
449
|
+
new_basis_cols = {}
|
|
450
|
+
for i, name in enumerate(basis_col_names):
|
|
451
|
+
if name in basis_to_add:
|
|
452
|
+
new_basis_cols[name] = basis_mat[:, i]
|
|
453
|
+
|
|
454
|
+
new_df = dm.data.with_columns(
|
|
455
|
+
[pl.Series(name, values) for name, values in new_basis_cols.items()]
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Update confounds metadata
|
|
459
|
+
new_confounds = dm.confounds.copy() if dm.confounds else []
|
|
460
|
+
new_confounds.extend(new_basis_cols.keys())
|
|
461
|
+
|
|
462
|
+
# Return new DesignMatrix with updated data and metadata
|
|
463
|
+
return _copy_with(dm, new_df, confounds=new_confounds)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Standardize and resample a DesignMatrix.
|
|
2
|
+
|
|
3
|
+
`standardize` normalizes columns; `downsample` and `upsample` change the temporal
|
|
4
|
+
resolution. Each returns a new `DesignMatrix` with metadata preserved (and
|
|
5
|
+
`sampling_freq` updated when resampling).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import polars as pl
|
|
14
|
+
|
|
15
|
+
from .utils import _copy_with, _get_data_columns
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from nltools.data.designmatrix import DesignMatrix
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _standardize(
|
|
22
|
+
dm: DesignMatrix,
|
|
23
|
+
*,
|
|
24
|
+
method: str = "center",
|
|
25
|
+
columns: list[str] | None = None,
|
|
26
|
+
) -> DesignMatrix:
|
|
27
|
+
"""Standardize columns by centering them, optionally scaling to unit variance.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
dm (DesignMatrix): DesignMatrix instance to transform.
|
|
31
|
+
method (str): ``'center'`` subtracts the mean (default); ``'zscore'``
|
|
32
|
+
subtracts the mean and divides by the standard deviation.
|
|
33
|
+
columns (list[str] | None): Columns to standardize. If None,
|
|
34
|
+
standardize all non-confound columns.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
DesignMatrix: New DesignMatrix with standardized columns.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If `method` is neither ``'center'`` nor ``'zscore'``.
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
```python
|
|
44
|
+
dm = DesignMatrix(np.random.randn(100, 3))
|
|
45
|
+
dm_c = standardize(dm) # center every non-confound column
|
|
46
|
+
dm_z = standardize(dm, method="zscore") # center and scale
|
|
47
|
+
```
|
|
48
|
+
"""
|
|
49
|
+
if method not in ("center", "zscore"):
|
|
50
|
+
raise ValueError(f"method must be 'center' or 'zscore', got {method!r}")
|
|
51
|
+
|
|
52
|
+
if columns is None:
|
|
53
|
+
columns = _get_data_columns(dm, exclude_confounds=True)
|
|
54
|
+
|
|
55
|
+
def standardized(col: str) -> pl.Expr:
|
|
56
|
+
expr = pl.col(col) - pl.col(col).mean()
|
|
57
|
+
if method == "zscore":
|
|
58
|
+
expr = expr / pl.col(col).std()
|
|
59
|
+
return expr.alias(col)
|
|
60
|
+
|
|
61
|
+
return _copy_with(dm, dm.data.with_columns(standardized(col) for col in columns))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _downsample(dm: DesignMatrix, target: float, method: str = "mean") -> DesignMatrix:
|
|
65
|
+
"""Reduce temporal resolution by aggregating consecutive samples.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
dm (DesignMatrix): DesignMatrix instance to transform.
|
|
69
|
+
target (float): Target sampling frequency in Hz (must be < current
|
|
70
|
+
`sampling_freq`).
|
|
71
|
+
method (str): Aggregation method, ``'mean'`` or ``'median'``.
|
|
72
|
+
Default: ``'mean'``.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
DesignMatrix: Downsampled DesignMatrix with updated `sampling_freq`.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If `sampling_freq` is not set, `target` >= current
|
|
79
|
+
`sampling_freq`, or `method` is invalid.
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
```python
|
|
83
|
+
dm = DesignMatrix({"a": list(range(100))}, sampling_freq=1.0)
|
|
84
|
+
dm_down = downsample(dm, target=0.5) # 1 Hz → 0.5 Hz (100 → 50 samples)
|
|
85
|
+
```
|
|
86
|
+
"""
|
|
87
|
+
if dm.sampling_freq is None:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"DesignMatrix must have sampling_freq set for downsampling. "
|
|
90
|
+
"Specify sampling_freq when creating: DesignMatrix(..., sampling_freq=0.5)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if target >= dm.sampling_freq:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Downsampling target ({target} Hz) must be less than current sampling_freq "
|
|
96
|
+
f"({dm.sampling_freq} Hz). For upsampling, use .upsample() instead."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if method not in ("mean", "median"):
|
|
100
|
+
raise ValueError("method must be 'mean' or 'median'")
|
|
101
|
+
|
|
102
|
+
# Calculate n_samples (number of original samples per downsampled sample)
|
|
103
|
+
# This replicates stats.downsample() logic: n_samples = sampling_freq / target
|
|
104
|
+
n_samples = dm.sampling_freq / target
|
|
105
|
+
|
|
106
|
+
# Assign each row to a group via floor(row / n_samples). For integer ratios
|
|
107
|
+
# this reproduces the old [0,0,1,1,...] grouping exactly; for non-integer
|
|
108
|
+
# ratios it spreads the leftover rows evenly across bins instead of lumping
|
|
109
|
+
# them all into one oversized final group (F083).
|
|
110
|
+
idx = pl.Series(np.floor(np.arange(dm.shape[0]) / n_samples).astype(int))
|
|
111
|
+
|
|
112
|
+
# Add grouping index to dataframe
|
|
113
|
+
df_with_idx = dm.data.with_columns(idx.alias("_group_idx"))
|
|
114
|
+
|
|
115
|
+
# Get all data columns
|
|
116
|
+
data_cols = _get_data_columns(dm, exclude_confounds=False)
|
|
117
|
+
|
|
118
|
+
# Group by index and aggregate
|
|
119
|
+
if method == "mean":
|
|
120
|
+
downsampled_df = (
|
|
121
|
+
df_with_idx.group_by("_group_idx", maintain_order=True)
|
|
122
|
+
.agg([pl.col(col).mean() for col in data_cols])
|
|
123
|
+
.drop("_group_idx")
|
|
124
|
+
)
|
|
125
|
+
else: # median
|
|
126
|
+
downsampled_df = (
|
|
127
|
+
df_with_idx.group_by("_group_idx", maintain_order=True)
|
|
128
|
+
.agg([pl.col(col).median() for col in data_cols])
|
|
129
|
+
.drop("_group_idx")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return _copy_with(dm, downsampled_df, sampling_freq=target)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _upsample(dm: DesignMatrix, target: float, method: str = "linear") -> DesignMatrix:
|
|
136
|
+
"""Increase temporal resolution by interpolating between samples.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
dm (DesignMatrix): DesignMatrix instance to transform.
|
|
140
|
+
target (float): Target sampling frequency in Hz (must be > current
|
|
141
|
+
`sampling_freq`).
|
|
142
|
+
method (str): Interpolation method, ``'linear'`` or ``'nearest'``.
|
|
143
|
+
Default: ``'linear'``.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
DesignMatrix: Upsampled DesignMatrix with updated `sampling_freq`.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If `sampling_freq` is not set, `target` <= current
|
|
150
|
+
`sampling_freq`, or `method` is invalid.
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
```python
|
|
154
|
+
dm = DesignMatrix({"a": list(range(10))}, sampling_freq=1.0)
|
|
155
|
+
dm_up = upsample(dm, target=2.0) # 1 Hz → 2 Hz (10 → 18 samples)
|
|
156
|
+
```
|
|
157
|
+
"""
|
|
158
|
+
from scipy.interpolate import interp1d
|
|
159
|
+
|
|
160
|
+
if dm.sampling_freq is None:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"DesignMatrix must have sampling_freq set for upsampling. "
|
|
163
|
+
"Specify sampling_freq when creating: DesignMatrix(..., sampling_freq=0.5)"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if target <= dm.sampling_freq:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"Upsampling target ({target} Hz) must be greater than current sampling_freq "
|
|
169
|
+
f"({dm.sampling_freq} Hz). For downsampling, use .downsample() instead."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if method not in ("linear", "nearest"):
|
|
173
|
+
raise ValueError("method must be 'linear' or 'nearest'")
|
|
174
|
+
|
|
175
|
+
# Calculate step size (this matches stats.upsample logic)
|
|
176
|
+
# For hz target_type: n_samples = sampling_freq / target
|
|
177
|
+
step_size = dm.sampling_freq / target
|
|
178
|
+
|
|
179
|
+
# Create original and new index arrays (matches stats.upsample)
|
|
180
|
+
orig_indices = np.arange(0, dm.shape[0], 1)
|
|
181
|
+
new_indices = np.arange(0, dm.shape[0] - 1, step_size)
|
|
182
|
+
|
|
183
|
+
# Get all data columns (including confounds - upsample everything)
|
|
184
|
+
data_cols = _get_data_columns(dm, exclude_confounds=False)
|
|
185
|
+
|
|
186
|
+
# Interpolate each column using scipy (matches stats.upsample)
|
|
187
|
+
upsampled_data = {}
|
|
188
|
+
for col in data_cols:
|
|
189
|
+
col_data = dm.data[col].to_numpy()
|
|
190
|
+
|
|
191
|
+
# Create interpolation function
|
|
192
|
+
interpolate = interp1d(orig_indices, col_data, kind=method)
|
|
193
|
+
|
|
194
|
+
# Interpolate to new indices
|
|
195
|
+
upsampled_data[col] = interpolate(new_indices)
|
|
196
|
+
|
|
197
|
+
# Create new Polars DataFrame
|
|
198
|
+
upsampled_df = pl.DataFrame(upsampled_data)
|
|
199
|
+
|
|
200
|
+
return _copy_with(dm, upsampled_df, sampling_freq=target)
|