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