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,1032 @@
|
|
|
1
|
+
"""Polars-based design matrix for neuroimaging analysis.
|
|
2
|
+
|
|
3
|
+
`DesignMatrix` wraps a Polars DataFrame with neuroimaging metadata (sampling
|
|
4
|
+
frequency, which columns are HRF-convolved, which are confounds) and offers
|
|
5
|
+
HRF convolution, resampling, polynomial and cosine drift regressors, multi-run
|
|
6
|
+
concatenation, and collinearity diagnostics.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from numbers import Integral
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import polars as pl
|
|
19
|
+
|
|
20
|
+
from nltools.utils import _HORIZONTAL_CONCAT
|
|
21
|
+
|
|
22
|
+
from ..ownership import _copy_frame
|
|
23
|
+
from .utils import (
|
|
24
|
+
_copy_with,
|
|
25
|
+
_df_passthrough,
|
|
26
|
+
_effective_frame,
|
|
27
|
+
_replacement_names,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
import pandas as pd
|
|
32
|
+
from matplotlib.figure import Figure
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_pandas_dataframe(obj) -> bool:
|
|
36
|
+
"""Duck-type check for pandas DataFrame without importing pandas."""
|
|
37
|
+
cls = type(obj)
|
|
38
|
+
module = cls.__module__
|
|
39
|
+
return cls.__name__ == "DataFrame" and (
|
|
40
|
+
module == "pandas" or module.startswith("pandas.")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DesignMatrix:
|
|
45
|
+
"""Represent an experimental design for neuroimaging as a Polars-backed matrix.
|
|
46
|
+
|
|
47
|
+
Wraps a Polars DataFrame (one row per timepoint, one column per regressor)
|
|
48
|
+
together with the metadata a GLM needs: the sampling frequency, which
|
|
49
|
+
columns have been HRF-convolved, and which columns are nuisance/confound
|
|
50
|
+
regressors. Transformations return new instances with that metadata
|
|
51
|
+
preserved; `DesignMatrix` is composed over the DataFrame rather than
|
|
52
|
+
subclassing it. Unknown attributes are forwarded to the underlying
|
|
53
|
+
DataFrame, so the Polars API is available directly (``dm.select(...)``,
|
|
54
|
+
``dm.filter(...)``, ``dm.slice(...)`` return a `DesignMatrix`). Every eager DataFrame result becomes a new
|
|
55
|
+
`DesignMatrix`; Series and builder objects remain native Polars values.
|
|
56
|
+
Metadata is retained only when the operation establishes its validity.
|
|
57
|
+
|
|
58
|
+
`data` accepts a Polars DataFrame (copied), a pandas DataFrame
|
|
59
|
+
(converted), a NumPy array (named via `columns`), a dict of columns,
|
|
60
|
+
another `DesignMatrix` (copied), ``None`` (empty), or a file path.
|
|
61
|
+
A `.tsv`/`.csv` path is read as a BIDS events file when it has `onset`
|
|
62
|
+
and `duration` columns — each `trial_type` becomes an HRF-convolved
|
|
63
|
+
regressor, or a raw boxcar under ``hrf_model=None`` — and as a plain table
|
|
64
|
+
otherwise (typically confounds). A `.h5`/`.hdf5` path written by `write` restores
|
|
65
|
+
the data and the metadata (`sampling_freq`, `convolved`, `confounds`,
|
|
66
|
+
`multi`), so neither `run_length` nor `sampling_freq` is required;
|
|
67
|
+
passing either overrides what the file recorded.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
data (DesignMatrix | pl.DataFrame | pd.DataFrame | np.ndarray | dict | str | Path | None):
|
|
71
|
+
Input data; see above for how each type is interpreted.
|
|
72
|
+
sampling_freq (float | None): Sampling frequency in Hz (1/TR for fMRI
|
|
73
|
+
data). Mutually exclusive with `TR`.
|
|
74
|
+
TR (float | None): Repetition time in seconds, a convenience for
|
|
75
|
+
``sampling_freq = 1/TR``. Mutually exclusive with `sampling_freq`.
|
|
76
|
+
run_length (int | str | None): Number of TRs in the run. Required when
|
|
77
|
+
`data` is a path to a text file. Pass ``'infer'`` for tabular
|
|
78
|
+
(confounds) files to accept whatever row count the file has; not
|
|
79
|
+
valid for events files. Not used for `.h5` inputs, which carry
|
|
80
|
+
their own length.
|
|
81
|
+
columns (list[str] | None): Column names, used with NumPy input.
|
|
82
|
+
convolved (list[str] | None): Names of columns that are already
|
|
83
|
+
HRF-convolved.
|
|
84
|
+
confounds (list[str] | None): Names of nuisance/confound columns
|
|
85
|
+
(intercept, polynomial drift, DCT cosines, motion, …).
|
|
86
|
+
hrf_model (str | None): HRF model used to convolve regressors loaded
|
|
87
|
+
from a BIDS events file — ``'glover'`` (the default),
|
|
88
|
+
``'glover_time'``, ``'glover_dispersion'``, ``'spm'``,
|
|
89
|
+
``'spm_time'``, ``'spm_dispersion'``, or ``None`` to keep raw
|
|
90
|
+
boxcar regressors. A model name hands the events straight to
|
|
91
|
+
nilearn's ``make_first_level_design_matrix``, so the regressors are
|
|
92
|
+
the ones a nilearn `FirstLevelModel` would build from the same
|
|
93
|
+
file. Ignored for every other kind of `data`.
|
|
94
|
+
n_rows (int | None): Number of timepoints for a matrix with no columns
|
|
95
|
+
(Polars cannot represent "n rows, 0 columns"). Rarely needed
|
|
96
|
+
directly; set by `find_spikes` and by `append`.
|
|
97
|
+
|
|
98
|
+
Attributes:
|
|
99
|
+
data (pl.DataFrame): The underlying Polars DataFrame.
|
|
100
|
+
sampling_freq (float | None): Sampling frequency in Hz.
|
|
101
|
+
convolved (list[str]): Names of HRF-convolved columns (read-only;
|
|
102
|
+
managed by `convolve` and `append`).
|
|
103
|
+
confounds (list[str]): Names of nuisance/confound columns (read-only;
|
|
104
|
+
managed by `add_poly`, `add_dct_basis`, `append`, and the
|
|
105
|
+
constructor). Skipped by `convolve` and kept separate per run on
|
|
106
|
+
multi-run vertical `append`.
|
|
107
|
+
multi (bool): True if the matrix was created by a multi-run
|
|
108
|
+
vertical `append`.
|
|
109
|
+
columns (list[str]): Column names.
|
|
110
|
+
shape (tuple[int, int]): ``(n_rows, n_cols)``.
|
|
111
|
+
is_empty (bool): True if the matrix holds no data.
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
```python
|
|
115
|
+
# Create from a NumPy array
|
|
116
|
+
dm = DesignMatrix(np.zeros((100, 2)), sampling_freq=0.5, columns=["a", "b"])
|
|
117
|
+
|
|
118
|
+
# Add a column
|
|
119
|
+
dm["stim"] = [0, 1, 1, 0] * 25
|
|
120
|
+
|
|
121
|
+
# Convolve with the HRF — convolved columns get a `_c0` suffix
|
|
122
|
+
dm_conv = dm.convolve() # 'stim' → 'stim_c0'
|
|
123
|
+
|
|
124
|
+
# Add polynomial drift terms
|
|
125
|
+
dm_conv = dm_conv.add_poly(order=2)
|
|
126
|
+
|
|
127
|
+
# Multi-run concatenation separates drift terms per run
|
|
128
|
+
dm_run1 = DesignMatrix(run1_events, sampling_freq=0.5, run_length=100).add_poly(0)
|
|
129
|
+
dm_run2 = DesignMatrix(run2_events, sampling_freq=0.5, run_length=100).add_poly(0)
|
|
130
|
+
dm_multi = dm_run1.append(dm_run2, axis=0) # → .nl_r0_poly_0, .nl_r1_poly_0
|
|
131
|
+
```
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
_metadata = ["sampling_freq", "convolved", "confounds", "multi"]
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
data: DesignMatrix
|
|
139
|
+
| pl.DataFrame
|
|
140
|
+
| pd.DataFrame
|
|
141
|
+
| np.ndarray
|
|
142
|
+
| dict
|
|
143
|
+
| str
|
|
144
|
+
| Path
|
|
145
|
+
| None = None,
|
|
146
|
+
*,
|
|
147
|
+
sampling_freq: float | None = None,
|
|
148
|
+
TR: float | None = None,
|
|
149
|
+
run_length: int | str | None = None,
|
|
150
|
+
columns: list[str] | None = None,
|
|
151
|
+
convolved: list[str] | None = None,
|
|
152
|
+
confounds: list[str] | None = None,
|
|
153
|
+
hrf_model: str | None = "glover",
|
|
154
|
+
n_rows: int | None = None,
|
|
155
|
+
):
|
|
156
|
+
"""Initialize a DesignMatrix from any supported input type.
|
|
157
|
+
|
|
158
|
+
Passing another `DesignMatrix` returns a copy: `data`, `sampling_freq`,
|
|
159
|
+
`convolved`, `confounds`, and `multi` are carried over, and any explicit
|
|
160
|
+
kwarg overrides the inherited value.
|
|
161
|
+
|
|
162
|
+
When `data` is a path to a BIDS events file, the events go to nilearn's
|
|
163
|
+
`make_first_level_design_matrix` with the named `hrf_model`
|
|
164
|
+
(``'glover'`` by default): output columns are suffixed ``_c0`` and
|
|
165
|
+
`convolved` is populated. Pass ``hrf_model=None`` to load raw boxcar
|
|
166
|
+
regressors instead — useful for FIR designs, PPI flows that build
|
|
167
|
+
interaction terms before convolution, or teaching material that
|
|
168
|
+
introduces convolution as a separate step. Those boxcars are sampled
|
|
169
|
+
onto the TR grid, so convolving them afterwards with `convolve` is not
|
|
170
|
+
the same as letting the constructor convolve the events: onsets that
|
|
171
|
+
fall between TRs have already been quantized.
|
|
172
|
+
"""
|
|
173
|
+
if TR is not None and sampling_freq is not None:
|
|
174
|
+
raise ValueError("Pass exactly one of `TR` or `sampling_freq`, not both.")
|
|
175
|
+
for name, value in (("TR", TR), ("sampling_freq", sampling_freq)):
|
|
176
|
+
if value is not None and (not np.isfinite(value) or value <= 0):
|
|
177
|
+
raise ValueError(f"{name} must be finite and positive.")
|
|
178
|
+
if n_rows is not None and (
|
|
179
|
+
isinstance(n_rows, bool) or not isinstance(n_rows, Integral) or n_rows < 0
|
|
180
|
+
):
|
|
181
|
+
raise ValueError("n_rows must be a nonnegative integer.")
|
|
182
|
+
if TR is not None:
|
|
183
|
+
sampling_freq = 1.0 / TR
|
|
184
|
+
|
|
185
|
+
from .regressors import _KERNELS, _kernel_names
|
|
186
|
+
|
|
187
|
+
if hrf_model is not None and hrf_model not in _KERNELS:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Unknown hrf_model={hrf_model!r}. Accepted HRF model names "
|
|
190
|
+
f"are {_kernel_names()}, or hrf_model=None (boxcar — caller "
|
|
191
|
+
"convolves explicitly with .convolve())."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
self.multi = False
|
|
195
|
+
_is_events = False # set True only by the events-file branch below
|
|
196
|
+
|
|
197
|
+
# Create internal Polars DataFrame based on input type
|
|
198
|
+
if isinstance(data, DesignMatrix):
|
|
199
|
+
# Copy-constructor: inherit data + metadata; explicit kwargs override.
|
|
200
|
+
self.data = _copy_frame(data.data, {id(data): self})
|
|
201
|
+
if sampling_freq is None:
|
|
202
|
+
sampling_freq = data.sampling_freq
|
|
203
|
+
if convolved is None:
|
|
204
|
+
convolved = list(data.convolved)
|
|
205
|
+
if confounds is None:
|
|
206
|
+
confounds = list(data.confounds)
|
|
207
|
+
if n_rows is None:
|
|
208
|
+
n_rows = data._n_rows
|
|
209
|
+
self.multi = data.multi
|
|
210
|
+
self._run_count = data._run_count
|
|
211
|
+
|
|
212
|
+
elif data is None:
|
|
213
|
+
# Empty initialization
|
|
214
|
+
self.data = pl.DataFrame()
|
|
215
|
+
|
|
216
|
+
elif isinstance(data, (str, Path)):
|
|
217
|
+
from nltools.io.h5 import _is_h5_path
|
|
218
|
+
|
|
219
|
+
if _is_h5_path(data):
|
|
220
|
+
# A .h5 is a serialized DesignMatrix rather than a table
|
|
221
|
+
# awaiting interpretation: it carries its own sampling_freq
|
|
222
|
+
# and row count, so neither has to be supplied (and
|
|
223
|
+
# `run_length` has nothing to describe). Explicit kwargs
|
|
224
|
+
# still win over what the file recorded.
|
|
225
|
+
from .io import _read_h5
|
|
226
|
+
|
|
227
|
+
self.data, stored = _read_h5(data)
|
|
228
|
+
if sampling_freq is None:
|
|
229
|
+
sampling_freq = stored.get("sampling_freq")
|
|
230
|
+
if convolved is None:
|
|
231
|
+
convolved = stored.get("convolved")
|
|
232
|
+
if confounds is None:
|
|
233
|
+
confounds = stored.get("confounds")
|
|
234
|
+
if n_rows is None:
|
|
235
|
+
n_rows = stored.get("n_rows")
|
|
236
|
+
self.multi = stored.get("multi", False)
|
|
237
|
+
if "run_count" in stored:
|
|
238
|
+
self._run_count = stored["run_count"]
|
|
239
|
+
else:
|
|
240
|
+
if run_length is None:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"Loading DesignMatrix from a file requires `run_length`."
|
|
243
|
+
)
|
|
244
|
+
if sampling_freq is None:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
"Loading DesignMatrix from a file requires `TR` or `sampling_freq`."
|
|
247
|
+
)
|
|
248
|
+
from .io import _load_from_file
|
|
249
|
+
|
|
250
|
+
# _is_events mirrors to the outer scope so the post-dispatch
|
|
251
|
+
# auto-convolve block (below) can pick it up.
|
|
252
|
+
self.data, _is_events = _load_from_file(
|
|
253
|
+
data,
|
|
254
|
+
run_length=run_length,
|
|
255
|
+
sampling_freq=sampling_freq,
|
|
256
|
+
hrf_model=hrf_model,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
elif isinstance(data, pl.DataFrame):
|
|
260
|
+
self.data = data
|
|
261
|
+
|
|
262
|
+
elif isinstance(data, dict):
|
|
263
|
+
# Dictionary - let Polars handle it, ensure string column names
|
|
264
|
+
names = [str(c) for c in data]
|
|
265
|
+
if len(set(names)) != len(names):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"Column names must be unique after conversion to strings."
|
|
268
|
+
)
|
|
269
|
+
self.data = pl.DataFrame(dict(zip(names, data.values())))
|
|
270
|
+
|
|
271
|
+
elif isinstance(data, np.ndarray):
|
|
272
|
+
if data.ndim not in (1, 2):
|
|
273
|
+
raise ValueError("NumPy input must have one or two dimensions.")
|
|
274
|
+
if data.ndim == 2 and data.shape[1] == 0:
|
|
275
|
+
if n_rows is not None and n_rows != data.shape[0]:
|
|
276
|
+
raise ValueError("n_rows conflicts with array observations.")
|
|
277
|
+
n_rows = data.shape[0]
|
|
278
|
+
data = data.copy()
|
|
279
|
+
# Numpy array - handle column names
|
|
280
|
+
if columns is not None:
|
|
281
|
+
# Use provided column names
|
|
282
|
+
self.data = pl.DataFrame(
|
|
283
|
+
data, schema=[str(c) for c in columns], orient="row"
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
# Auto-generate column names as strings: '0', '1', '2', ...
|
|
287
|
+
n_cols = data.shape[1] if data.ndim > 1 else 1
|
|
288
|
+
auto_columns = [str(i) for i in range(n_cols)]
|
|
289
|
+
self.data = pl.DataFrame(data, schema=auto_columns, orient="row")
|
|
290
|
+
|
|
291
|
+
elif _is_pandas_dataframe(data):
|
|
292
|
+
# pandas DataFrame - convert to Polars, ensure string column names
|
|
293
|
+
self.data = pl.from_pandas(data)
|
|
294
|
+
self.data = self.data.rename({col: str(col) for col in self.data.columns})
|
|
295
|
+
|
|
296
|
+
else:
|
|
297
|
+
raise TypeError(
|
|
298
|
+
f"Unsupported data type: {type(data)}. "
|
|
299
|
+
f"Expected DesignMatrix, Polars/pandas DataFrame, numpy array, "
|
|
300
|
+
f"dict, str/Path, or None."
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if not isinstance(data, DesignMatrix):
|
|
304
|
+
self.data = _copy_frame(self.data)
|
|
305
|
+
for annotation in (convolved, confounds):
|
|
306
|
+
if annotation is not None and any(
|
|
307
|
+
c not in self.data.columns for c in annotation
|
|
308
|
+
):
|
|
309
|
+
raise ValueError("Annotation names must refer to existing columns.")
|
|
310
|
+
|
|
311
|
+
# Initialize metadata (after data dispatch so copy-constructor can
|
|
312
|
+
# populate inherited values). Stored on private attrs so the public
|
|
313
|
+
# ``.convolved`` / ``.confounds`` are read-only properties.
|
|
314
|
+
self.sampling_freq = sampling_freq
|
|
315
|
+
self._convolved = list(convolved) if convolved is not None else []
|
|
316
|
+
self._confounds = list(confounds) if confounds is not None else []
|
|
317
|
+
|
|
318
|
+
# Polars derives height from its columns, so a frame with no columns
|
|
319
|
+
# always reports 0 rows. A design matrix with no regressors still
|
|
320
|
+
# describes a specific number of timepoints (e.g. find_spikes() on a
|
|
321
|
+
# subject with no spikes), and that length is needed for .append() to
|
|
322
|
+
# line it up against other runs. Remember it explicitly — and refuse
|
|
323
|
+
# a value the data contradicts rather than silently ignoring it.
|
|
324
|
+
if n_rows is not None:
|
|
325
|
+
if n_rows < 0:
|
|
326
|
+
raise ValueError(f"n_rows must be non-negative, got {n_rows}.")
|
|
327
|
+
if self.data.width > 0 and n_rows != self.data.height:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"n_rows={n_rows} conflicts with the data's "
|
|
330
|
+
f"{self.data.height} rows. Omit n_rows when the frame has "
|
|
331
|
+
f"columns — it is only needed to give a column-less "
|
|
332
|
+
f"DesignMatrix a length."
|
|
333
|
+
)
|
|
334
|
+
self._n_rows = n_rows if self.data.width == 0 else None
|
|
335
|
+
if "_run_count" not in self.__dict__:
|
|
336
|
+
self._run_count = 1 if self.shape[0] > 0 else 0
|
|
337
|
+
if self.multi:
|
|
338
|
+
# Files predating explicit run counts encode identities in names.
|
|
339
|
+
from .utils import _parse_run_separated
|
|
340
|
+
|
|
341
|
+
runs = [_parse_run_separated(c) for c in self.columns]
|
|
342
|
+
self._run_count = max(
|
|
343
|
+
(run[0] + 1 for run in runs if run is not None),
|
|
344
|
+
default=self._run_count,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# An events file loaded with an `hrf_model` came back already convolved
|
|
348
|
+
# by nilearn (`_load_from_file` → `_events_to_convolved_dm`), suffixed
|
|
349
|
+
# `_c0`. Record that rather than convolving a second time; with
|
|
350
|
+
# ``hrf_model=None`` the frame is raw boxcars and stays unannotated.
|
|
351
|
+
if _is_events and hrf_model is not None:
|
|
352
|
+
self._convolved = list(self.data.columns)
|
|
353
|
+
|
|
354
|
+
# ── Dunders (alphabetical) ──────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
def __array__(self, dtype=None) -> np.ndarray:
|
|
357
|
+
"""Provide the NumPy array interface.
|
|
358
|
+
|
|
359
|
+
This enables ``np.array(design_matrix)`` and ``np.asarray()``.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
dtype (np.dtype | None): Desired data type for the array.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
np.ndarray: 2D array representation.
|
|
366
|
+
"""
|
|
367
|
+
if self.data.width == 0 and self._n_rows is not None:
|
|
368
|
+
return np.empty((self._n_rows, 0), dtype=dtype or np.float64)
|
|
369
|
+
arr = deepcopy(self.data.to_numpy().copy())
|
|
370
|
+
if dtype is not None:
|
|
371
|
+
return arr.astype(dtype)
|
|
372
|
+
return arr
|
|
373
|
+
|
|
374
|
+
def __dir__(self):
|
|
375
|
+
"""Include polars DataFrame attrs for REPL/IDE autocomplete."""
|
|
376
|
+
return sorted(set(super().__dir__()) | set(dir(self.data)))
|
|
377
|
+
|
|
378
|
+
def __eq__(self, other) -> bool:
|
|
379
|
+
"""Check equality with another DesignMatrix.
|
|
380
|
+
|
|
381
|
+
Compares data frames only (ignores metadata like `sampling_freq`,
|
|
382
|
+
`convolved`, `confounds`, and `multi`).
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
other (DesignMatrix): Design matrix to compare with.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
bool: True if the data frames are equal (same shape, column names, and values).
|
|
389
|
+
"""
|
|
390
|
+
if not isinstance(other, DesignMatrix):
|
|
391
|
+
return NotImplemented
|
|
392
|
+
return self.shape == other.shape and self.data.equals(other.data)
|
|
393
|
+
|
|
394
|
+
def __getattr__(self, name: str):
|
|
395
|
+
"""Forward unknown attrs to the underlying polars DataFrame.
|
|
396
|
+
|
|
397
|
+
Eager frame results use operation-aware metadata policies; native
|
|
398
|
+
Series, scalar and builder results retain Polars return types. The ``data`` guard avoids recursion
|
|
399
|
+
during construction before ``data`` is assigned.
|
|
400
|
+
"""
|
|
401
|
+
if name.startswith("_") or "data" not in self.__dict__:
|
|
402
|
+
raise AttributeError(name)
|
|
403
|
+
try:
|
|
404
|
+
return _df_passthrough(self, name)
|
|
405
|
+
except AttributeError:
|
|
406
|
+
raise AttributeError(
|
|
407
|
+
f"'DesignMatrix' object has no attribute {name!r}"
|
|
408
|
+
) from None
|
|
409
|
+
|
|
410
|
+
def __getitem__(self, key: str | list[str]) -> pl.Series | DesignMatrix:
|
|
411
|
+
"""Access one column as a Series or several as a new DesignMatrix.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
key (str | list[str]): A single column name or a list of names.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
pl.Series | DesignMatrix: ``dm['col']`` returns a Polars Series;
|
|
418
|
+
``dm[['col1', 'col2']]`` returns a `DesignMatrix` with metadata preserved.
|
|
419
|
+
"""
|
|
420
|
+
if isinstance(key, str):
|
|
421
|
+
# Single column - return Series
|
|
422
|
+
return _copy_frame(self.data.select(key)).to_series()
|
|
423
|
+
if isinstance(key, list) and all(isinstance(c, str) for c in key):
|
|
424
|
+
# Multiple columns - return DesignMatrix with metadata
|
|
425
|
+
subset_df = self.data.select(key)
|
|
426
|
+
return _copy_with(self, subset_df)
|
|
427
|
+
raise TypeError(f"Column key must be str or list of str, got {type(key)}")
|
|
428
|
+
|
|
429
|
+
def __len__(self) -> int:
|
|
430
|
+
"""Return number of rows."""
|
|
431
|
+
return self.shape[0]
|
|
432
|
+
|
|
433
|
+
def __repr__(self) -> str:
|
|
434
|
+
"""Human-readable metadata summary."""
|
|
435
|
+
lines = [
|
|
436
|
+
f"DesignMatrix(sampling_freq={self.sampling_freq}, shape={self.shape})"
|
|
437
|
+
]
|
|
438
|
+
if self.convolved:
|
|
439
|
+
lines.append(f" convolved ({len(self.convolved)}): {self.convolved}")
|
|
440
|
+
if self.confounds:
|
|
441
|
+
lines.append(f" confounds ({len(self.confounds)}): {self.confounds}")
|
|
442
|
+
return "\n".join(lines)
|
|
443
|
+
|
|
444
|
+
def __setitem__(
|
|
445
|
+
self,
|
|
446
|
+
key: str,
|
|
447
|
+
value: int | float | list | np.ndarray | pl.Series | pl.Expr,
|
|
448
|
+
):
|
|
449
|
+
"""Set or add a column in place.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
key (str): Column name.
|
|
453
|
+
value (int | float | list | np.ndarray | pl.Series | pl.Expr): A
|
|
454
|
+
scalar is broadcast; a list, array, or Series is assigned as-is;
|
|
455
|
+
a Polars expression is evaluated against the current columns.
|
|
456
|
+
|
|
457
|
+
Examples:
|
|
458
|
+
```python
|
|
459
|
+
dm["col"] = 0 # broadcast scalar
|
|
460
|
+
dm["col"] = [1, 2, 3] # array assignment
|
|
461
|
+
dm["col"] = pl.col("a") + pl.col("b") # Polars expression
|
|
462
|
+
```
|
|
463
|
+
"""
|
|
464
|
+
result = self.with_columns(**{key: value})
|
|
465
|
+
self.__dict__.update(result.__dict__)
|
|
466
|
+
|
|
467
|
+
# ── Properties (alphabetical) ───────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def columns(self) -> list[str]:
|
|
471
|
+
"""Column names of the design matrix as a list of strings."""
|
|
472
|
+
return self.data.columns
|
|
473
|
+
|
|
474
|
+
@columns.setter
|
|
475
|
+
def columns(self, new_names: list[str]):
|
|
476
|
+
"""Set column names."""
|
|
477
|
+
str_names = [str(name) for name in new_names]
|
|
478
|
+
if len(str_names) != len(self.columns):
|
|
479
|
+
raise ValueError("Column names must match the number of columns.")
|
|
480
|
+
result = self.rename(dict(zip(self.data.columns, str_names)))
|
|
481
|
+
self.__dict__.update(result.__dict__)
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def confounds(self) -> list[str]:
|
|
485
|
+
"""Names of nuisance/confound columns (read-only).
|
|
486
|
+
|
|
487
|
+
Managed by `convolve`, `append`, `add_poly`, `add_dct_basis`, and the
|
|
488
|
+
``confounds=`` constructor kwarg. Direct assignment raises
|
|
489
|
+
``AttributeError`` — pass via the constructor or use
|
|
490
|
+
``.append(other, axis=1)`` (which auto-tracks confounds when `other`
|
|
491
|
+
is a raw Polars DataFrame).
|
|
492
|
+
"""
|
|
493
|
+
return list(self._confounds)
|
|
494
|
+
|
|
495
|
+
@confounds.setter
|
|
496
|
+
def confounds(self, value):
|
|
497
|
+
raise AttributeError(
|
|
498
|
+
"DesignMatrix.confounds is read-only. Pass `confounds=...` to the "
|
|
499
|
+
"constructor, or use `.append(other_dm, axis=1, as_confounds=True)` "
|
|
500
|
+
"/ `.append(raw_df, axis=1)` (raw frames are auto-marked) to "
|
|
501
|
+
"register confound regressors."
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def convolved(self) -> list[str]:
|
|
506
|
+
"""Names of HRF-convolved columns (read-only).
|
|
507
|
+
|
|
508
|
+
Managed by `convolve` and `append` (which merges across inputs).
|
|
509
|
+
Direct assignment raises ``AttributeError`` — pass via the
|
|
510
|
+
``convolved=`` constructor kwarg if you need to set initial state.
|
|
511
|
+
"""
|
|
512
|
+
return list(self._convolved)
|
|
513
|
+
|
|
514
|
+
@convolved.setter
|
|
515
|
+
def convolved(self, value):
|
|
516
|
+
raise AttributeError(
|
|
517
|
+
"DesignMatrix.convolved is read-only. Pass `convolved=...` to the "
|
|
518
|
+
"constructor, or use `.convolve()` / `.append()` which manage this "
|
|
519
|
+
"metadata automatically."
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
@property
|
|
523
|
+
def is_empty(self) -> bool:
|
|
524
|
+
"""True if the design matrix holds no data."""
|
|
525
|
+
return self.data.is_empty()
|
|
526
|
+
|
|
527
|
+
@property
|
|
528
|
+
def shape(self) -> tuple:
|
|
529
|
+
"""The ``(n_rows, n_cols)`` shape of the matrix.
|
|
530
|
+
|
|
531
|
+
For a matrix with no regressors, ``n_rows`` comes from the height
|
|
532
|
+
recorded at construction (Polars cannot represent "n rows, 0 columns").
|
|
533
|
+
"""
|
|
534
|
+
if self.data.width == 0 and self._n_rows is not None:
|
|
535
|
+
return (self._n_rows, 0)
|
|
536
|
+
return self.data.shape
|
|
537
|
+
|
|
538
|
+
# ── Public methods (alphabetical) ───────────────────────────────────
|
|
539
|
+
|
|
540
|
+
def add_dct_basis(
|
|
541
|
+
self,
|
|
542
|
+
duration: float = 180,
|
|
543
|
+
drop: int = 0,
|
|
544
|
+
*,
|
|
545
|
+
include_constant: bool = True,
|
|
546
|
+
) -> DesignMatrix:
|
|
547
|
+
"""Add discrete cosine transform basis functions for high-pass filtering.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
duration (float): Filter duration in seconds. Default: 180.
|
|
551
|
+
drop (int): Number of low-frequency bases to drop. Default: 0.
|
|
552
|
+
include_constant (bool): If True, also add a constant/intercept
|
|
553
|
+
column named ``.nl_cosine_0`` (analogous to ``.nl_poly_0`` in
|
|
554
|
+
`add_poly`). The underlying DCT basis drops the constant
|
|
555
|
+
per SPM convention; set False to match SPM behavior.
|
|
556
|
+
Default: True.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
DesignMatrix: New DesignMatrix with DCT basis columns appended.
|
|
560
|
+
"""
|
|
561
|
+
from .regressors import _add_dct_basis
|
|
562
|
+
|
|
563
|
+
return _add_dct_basis(
|
|
564
|
+
self, duration=duration, drop=drop, include_constant=include_constant
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def add_poly(self, order: int = 0, include_lower: bool = True) -> DesignMatrix:
|
|
568
|
+
"""Add Legendre polynomial drift terms.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
order (int): Polynomial order (0=intercept, 1=linear, 2=quadratic, ...).
|
|
572
|
+
Default: 0.
|
|
573
|
+
include_lower (bool): If True, include all orders from 0 to order.
|
|
574
|
+
Default: True.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
DesignMatrix: New DesignMatrix with polynomial columns appended.
|
|
578
|
+
"""
|
|
579
|
+
from .regressors import _add_poly
|
|
580
|
+
|
|
581
|
+
return _add_poly(self, order, include_lower)
|
|
582
|
+
|
|
583
|
+
def append(
|
|
584
|
+
self,
|
|
585
|
+
data: DesignMatrix | list[DesignMatrix],
|
|
586
|
+
*,
|
|
587
|
+
axis: int = 0,
|
|
588
|
+
keep_separate: bool = True,
|
|
589
|
+
unique_cols: list[str] | None = None,
|
|
590
|
+
fill_na: int | float | None = 0,
|
|
591
|
+
as_confounds: bool = False,
|
|
592
|
+
progress_bar: bool = False,
|
|
593
|
+
) -> DesignMatrix:
|
|
594
|
+
"""Concatenate design matrices.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
data (DesignMatrix or list of DesignMatrix): Design matrix/matrices to append.
|
|
598
|
+
axis (int): 0 for row-wise (vertical), 1 for column-wise (horizontal).
|
|
599
|
+
Default: 0.
|
|
600
|
+
keep_separate (bool): Whether to separate confound columns across runs
|
|
601
|
+
(only applies when axis=0). Default: True.
|
|
602
|
+
unique_cols (list of str, optional): Additional columns to keep separated
|
|
603
|
+
(supports wildcards).
|
|
604
|
+
fill_na (int, float, or None): Value to fill NaN values during
|
|
605
|
+
vertical concatenation, or None to preserve nulls. Default: 0.
|
|
606
|
+
as_confounds (bool): Only applies when ``axis=1``. If True, mark all
|
|
607
|
+
columns from ``data`` as nuisance/confounds in the result — they
|
|
608
|
+
get skipped by ``.convolve()`` and separated across runs on
|
|
609
|
+
later vertical appends. Default: False.
|
|
610
|
+
progress_bar (bool): Print messages about confound separation. Default: False.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
DesignMatrix: Concatenated design matrix.
|
|
614
|
+
"""
|
|
615
|
+
from .append import _append
|
|
616
|
+
|
|
617
|
+
return _append(
|
|
618
|
+
self,
|
|
619
|
+
data,
|
|
620
|
+
axis=axis,
|
|
621
|
+
keep_separate=keep_separate,
|
|
622
|
+
unique_cols=unique_cols,
|
|
623
|
+
fill_na=fill_na,
|
|
624
|
+
as_confounds=as_confounds,
|
|
625
|
+
progress_bar=progress_bar,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
def clean(
|
|
629
|
+
self,
|
|
630
|
+
*,
|
|
631
|
+
fill_na: int | float | None = 0,
|
|
632
|
+
exclude_confounds: bool = False,
|
|
633
|
+
thresh: float = 0.95,
|
|
634
|
+
progress_bar: bool = False,
|
|
635
|
+
) -> DesignMatrix:
|
|
636
|
+
"""Remove highly correlated columns.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
fill_na (int, float, or None): Fill NaN values before checking correlations (default 0)
|
|
640
|
+
exclude_confounds (bool): Skip confound/nuisance columns from correlation check
|
|
641
|
+
thresh (float): Correlation threshold (drop if abs(r) >= thresh, default 0.95)
|
|
642
|
+
progress_bar (bool): Print dropped column names. Default: False
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
DesignMatrix: Cleaned matrix with highly correlated columns removed
|
|
646
|
+
"""
|
|
647
|
+
from .diagnostics import _clean
|
|
648
|
+
|
|
649
|
+
return _clean(
|
|
650
|
+
self,
|
|
651
|
+
fill_na=fill_na,
|
|
652
|
+
exclude_confounds=exclude_confounds,
|
|
653
|
+
thresh=thresh,
|
|
654
|
+
progress_bar=progress_bar,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
def convolve(
|
|
658
|
+
self,
|
|
659
|
+
kernel: str | np.ndarray = "glover",
|
|
660
|
+
columns: list[str] | None = None,
|
|
661
|
+
) -> DesignMatrix:
|
|
662
|
+
"""Convolve columns with an HRF model or custom kernel.
|
|
663
|
+
|
|
664
|
+
Convolved columns are always renamed to ``<col>_c{i}`` (where ``i`` is
|
|
665
|
+
the kernel index, ``0`` for a single 1-D kernel). The source columns
|
|
666
|
+
are dropped, and ``self.convolved`` lists the post-suffix names so
|
|
667
|
+
downstream metadata stays in sync with the dataframe.
|
|
668
|
+
|
|
669
|
+
A kernel name selects one of nilearn's HRF models: each column goes to
|
|
670
|
+
`nilearn.glm.first_level.compute_regressor` as a condition, convolved
|
|
671
|
+
at an oversampling factor of 50 and resampled onto the frame times.
|
|
672
|
+
That is exactly what `FirstLevelModel` computes, so a column whose
|
|
673
|
+
samples sit on the TR grid gives the regressor nilearn would build from
|
|
674
|
+
the same events; sub-TR timing a column cannot represent is lost before
|
|
675
|
+
convolution, so pass an events table to the constructor for that.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
kernel (str or ndarray): An HRF model name — `'glover'` (default),
|
|
679
|
+
`'glover_time'`, `'glover_dispersion'`, `'spm'`, `'spm_time'`
|
|
680
|
+
or `'spm_dispersion'` — or custom kernel(s) as a 1D array
|
|
681
|
+
(single kernel) or 2D array (samples x kernels).
|
|
682
|
+
columns (list of str, optional): Columns to convolve (default: all non-confound columns).
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
DesignMatrix: New DesignMatrix with convolved columns renamed.
|
|
686
|
+
"""
|
|
687
|
+
from .regressors import _convolve
|
|
688
|
+
|
|
689
|
+
return _convolve(self, kernel, columns)
|
|
690
|
+
|
|
691
|
+
def copy(self) -> DesignMatrix:
|
|
692
|
+
"""Create a deep copy of the DesignMatrix.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
DesignMatrix: Copy of the current DesignMatrix
|
|
696
|
+
"""
|
|
697
|
+
return deepcopy(self)
|
|
698
|
+
|
|
699
|
+
def __copy__(self):
|
|
700
|
+
"""Return an independently owned copy."""
|
|
701
|
+
return deepcopy(self)
|
|
702
|
+
|
|
703
|
+
def __deepcopy__(self, memo):
|
|
704
|
+
"""Copy the retained graph while preserving aliases and cycles."""
|
|
705
|
+
if id(self) in memo:
|
|
706
|
+
return memo[id(self)]
|
|
707
|
+
result = type(self).__new__(type(self))
|
|
708
|
+
memo[id(self)] = result
|
|
709
|
+
result.data = _copy_frame(self.data, memo)
|
|
710
|
+
for key, value in self.__dict__.items():
|
|
711
|
+
if key != "data":
|
|
712
|
+
setattr(result, key, deepcopy(value, memo))
|
|
713
|
+
return result
|
|
714
|
+
|
|
715
|
+
def downsample(self, target: float, method: str = "mean") -> DesignMatrix:
|
|
716
|
+
"""Reduce temporal resolution using Polars-native operations.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
target (float): Target sampling frequency in Hz (must be < current sampling_freq)
|
|
720
|
+
method (str): Aggregation method - 'mean' or 'median' (default: 'mean')
|
|
721
|
+
|
|
722
|
+
Returns:
|
|
723
|
+
DesignMatrix: Downsampled DesignMatrix with updated sampling_freq
|
|
724
|
+
"""
|
|
725
|
+
from .transforms import _downsample
|
|
726
|
+
|
|
727
|
+
return _downsample(self, target, method=method)
|
|
728
|
+
|
|
729
|
+
def drop(self, columns: list[str]) -> DesignMatrix:
|
|
730
|
+
"""Drop specified columns.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
columns (list of str): Column names to remove.
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
DesignMatrix: New DesignMatrix without the specified columns.
|
|
737
|
+
"""
|
|
738
|
+
dropped_df = self.data.drop(columns)
|
|
739
|
+
return _copy_with(self, dropped_df)
|
|
740
|
+
|
|
741
|
+
def fillna(self, value: int | float) -> DesignMatrix:
|
|
742
|
+
"""Fill NaN/null values with specified value.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
value (int or float): Value to replace NaN/null entries with.
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
DesignMatrix: New DesignMatrix with NaN/null values replaced.
|
|
749
|
+
"""
|
|
750
|
+
filled_df = self.data.fill_null(value).fill_nan(value)
|
|
751
|
+
return _copy_with(self, filled_df)
|
|
752
|
+
|
|
753
|
+
def plot( # nosemgrep: kwargs-internal-forwarding # forwards to matplotlib via _plot_designmatrix
|
|
754
|
+
self,
|
|
755
|
+
method: str = "matrix",
|
|
756
|
+
*,
|
|
757
|
+
columns: list[str] | None = None,
|
|
758
|
+
rescale: bool = True,
|
|
759
|
+
metric: str = "pearson",
|
|
760
|
+
ax=None,
|
|
761
|
+
figsize: tuple | None = None,
|
|
762
|
+
title: str | None = None,
|
|
763
|
+
cmap: str | None = None,
|
|
764
|
+
save: str | None = None,
|
|
765
|
+
**kwargs,
|
|
766
|
+
) -> Figure:
|
|
767
|
+
"""Visualize the design matrix.
|
|
768
|
+
|
|
769
|
+
Dispatches over `method` (mirroring `BrainData.plot`):
|
|
770
|
+
|
|
771
|
+
- ``'matrix'`` (default): SPM-style heatmap (rows = TRs, columns = regressors).
|
|
772
|
+
- ``'timeseries'``: overlaid line plot of regressor time courses. Pass
|
|
773
|
+
the same `ax` across calls to overlay multiple DesignMatrices
|
|
774
|
+
(e.g. original vs. convolved).
|
|
775
|
+
- ``'corr'``: labeled correlation heatmap of the columns (reuses
|
|
776
|
+
`corr`; diagonal restored to 1.0 for display).
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
method (str): One of ``'matrix'``, ``'timeseries'``, or ``'corr'``.
|
|
780
|
+
Default: ``'matrix'``.
|
|
781
|
+
columns (list of str, optional): Subset of columns to plot.
|
|
782
|
+
Defaults to all columns.
|
|
783
|
+
rescale (bool): ``'matrix'`` only. Rescale each column by its L2
|
|
784
|
+
norm so columns with different native magnitudes are visually
|
|
785
|
+
comparable (SPM/nilearn convention). Default: True.
|
|
786
|
+
metric (str): ``'corr'`` only. ``'pearson'`` (default) or
|
|
787
|
+
``'spearman'``.
|
|
788
|
+
ax (matplotlib.axes.Axes, optional): Existing axis to draw on; a new
|
|
789
|
+
figure is created if omitted.
|
|
790
|
+
figsize (tuple, optional): Figure size; sensible per-method default
|
|
791
|
+
when omitted.
|
|
792
|
+
title (str, optional): Axis title.
|
|
793
|
+
cmap (str, optional): Colormap (``'matrix'`` / ``'corr'``).
|
|
794
|
+
save (str, optional): Path to save the figure.
|
|
795
|
+
**kwargs (dict): Forwarded to the underlying plotter
|
|
796
|
+
(``seaborn.heatmap`` for ``'matrix'`` / ``'corr'``;
|
|
797
|
+
``matplotlib.axes.Axes.plot`` for ``'timeseries'``).
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
matplotlib.figure.Figure: The figure containing the plot.
|
|
801
|
+
"""
|
|
802
|
+
from .plotting import _plot_designmatrix
|
|
803
|
+
|
|
804
|
+
return _plot_designmatrix(
|
|
805
|
+
self,
|
|
806
|
+
method,
|
|
807
|
+
columns=columns,
|
|
808
|
+
rescale=rescale,
|
|
809
|
+
metric=metric,
|
|
810
|
+
ax=ax,
|
|
811
|
+
figsize=figsize,
|
|
812
|
+
title=title,
|
|
813
|
+
cmap=cmap,
|
|
814
|
+
save=save,
|
|
815
|
+
**kwargs,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
def replace_data(
|
|
819
|
+
self,
|
|
820
|
+
data: np.ndarray,
|
|
821
|
+
column_names: list[str] | None = None,
|
|
822
|
+
) -> DesignMatrix:
|
|
823
|
+
"""Replace data columns while preserving confounds and metadata.
|
|
824
|
+
|
|
825
|
+
Args:
|
|
826
|
+
data (ndarray): New data array (must match number of rows in current DesignMatrix)
|
|
827
|
+
column_names (list of str, optional): Names for new data columns.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
DesignMatrix: New DesignMatrix with replaced data columns, preserved confounds
|
|
831
|
+
|
|
832
|
+
Raises:
|
|
833
|
+
ValueError: If row count doesn't match existing data
|
|
834
|
+
"""
|
|
835
|
+
if data.shape[0] != self.shape[0]:
|
|
836
|
+
raise ValueError(
|
|
837
|
+
f"Row count mismatch: new data has {data.shape[0]} rows, "
|
|
838
|
+
f"but DesignMatrix has {self.shape[0]} rows"
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
if column_names is None:
|
|
842
|
+
n_cols = data.shape[1] if data.ndim > 1 else 1
|
|
843
|
+
column_names = [f"col_{i}" for i in range(n_cols)]
|
|
844
|
+
|
|
845
|
+
if data.ndim == 1:
|
|
846
|
+
data = data.reshape(-1, 1)
|
|
847
|
+
new_data_df = pl.DataFrame(data, schema=column_names, orient="row")
|
|
848
|
+
|
|
849
|
+
confound_df = (
|
|
850
|
+
self.data.select(self.confounds) if self.confounds else pl.DataFrame()
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
if confound_df.shape[1] > 0:
|
|
854
|
+
combined_df = pl.concat([new_data_df, confound_df], how=_HORIZONTAL_CONCAT)
|
|
855
|
+
else:
|
|
856
|
+
combined_df = new_data_df
|
|
857
|
+
|
|
858
|
+
return _copy_with(self, combined_df, operation="replace", replaced=column_names)
|
|
859
|
+
|
|
860
|
+
def standardize(
|
|
861
|
+
self, *, method: str = "center", columns: list[str] | None = None
|
|
862
|
+
) -> DesignMatrix:
|
|
863
|
+
"""Standardize columns by centering them, optionally scaling to unit variance.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
method (str): ``'center'`` subtracts the mean (default);
|
|
867
|
+
``'zscore'`` subtracts the mean and divides by the standard
|
|
868
|
+
deviation.
|
|
869
|
+
columns (list[str] | None): Columns to standardize. If None,
|
|
870
|
+
standardize all non-confound columns.
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
DesignMatrix: New DesignMatrix with standardized columns.
|
|
874
|
+
|
|
875
|
+
Raises:
|
|
876
|
+
ValueError: If `method` is neither ``'center'`` nor ``'zscore'``.
|
|
877
|
+
"""
|
|
878
|
+
from .transforms import _standardize
|
|
879
|
+
|
|
880
|
+
return _standardize(self, method=method, columns=columns)
|
|
881
|
+
|
|
882
|
+
def sum(self, axis: int = 0) -> pl.Series:
|
|
883
|
+
"""Compute the sum along an axis.
|
|
884
|
+
|
|
885
|
+
Args:
|
|
886
|
+
axis (int): 0 to sum down each column, 1 to sum across each row.
|
|
887
|
+
Default: 0.
|
|
888
|
+
|
|
889
|
+
Returns:
|
|
890
|
+
pl.Series: Sums along the specified axis.
|
|
891
|
+
"""
|
|
892
|
+
if axis == 0:
|
|
893
|
+
sums = [self.data[col].sum() for col in self.data.columns]
|
|
894
|
+
return pl.Series(values=sums, name="")
|
|
895
|
+
if axis == 1:
|
|
896
|
+
return self.data.select(pl.sum_horizontal(pl.all())).to_series()
|
|
897
|
+
raise ValueError(f"axis must be 0 or 1, got {axis}")
|
|
898
|
+
|
|
899
|
+
def to_numpy(self) -> np.ndarray:
|
|
900
|
+
"""Convert a DesignMatrix to a NumPy array.
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
np.ndarray: 2D array with shape (n_samples, n_columns)
|
|
904
|
+
"""
|
|
905
|
+
from .io import _to_numpy
|
|
906
|
+
|
|
907
|
+
return _to_numpy(self)
|
|
908
|
+
|
|
909
|
+
def upsample(self, target: float, method: str = "linear") -> DesignMatrix:
|
|
910
|
+
"""Increase temporal resolution to a target frequency.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
target (float): Target sampling frequency in Hz (must be > current sampling_freq)
|
|
914
|
+
method (str): Interpolation method - 'linear' or 'nearest' (default: 'linear')
|
|
915
|
+
|
|
916
|
+
Returns:
|
|
917
|
+
DesignMatrix: Upsampled DesignMatrix with updated sampling_freq
|
|
918
|
+
"""
|
|
919
|
+
from .transforms import _upsample
|
|
920
|
+
|
|
921
|
+
return _upsample(self, target, method)
|
|
922
|
+
|
|
923
|
+
def corr(
|
|
924
|
+
self,
|
|
925
|
+
*,
|
|
926
|
+
metric: str = "pearson",
|
|
927
|
+
columns: list[str] | None = None,
|
|
928
|
+
):
|
|
929
|
+
"""Calculate column correlations as a similarity ``Adjacency``.
|
|
930
|
+
|
|
931
|
+
Args:
|
|
932
|
+
metric (str): ``'pearson'`` (default) or ``'spearman'``.
|
|
933
|
+
columns (list of str, optional): Subset of columns to correlate.
|
|
934
|
+
Defaults to all columns.
|
|
935
|
+
|
|
936
|
+
Returns:
|
|
937
|
+
Adjacency: Similarity matrix whose ``labels`` are the column names.
|
|
938
|
+
The unit diagonal is dropped (self-correlation isn't an edge);
|
|
939
|
+
use ``.plot(method='corr')`` for a heatmap with the diagonal
|
|
940
|
+
restored.
|
|
941
|
+
"""
|
|
942
|
+
from .diagnostics import _corr
|
|
943
|
+
|
|
944
|
+
return _corr(self, metric=metric, columns=columns)
|
|
945
|
+
|
|
946
|
+
def vif(self, exclude_confounds: bool = True) -> np.ndarray | None:
|
|
947
|
+
"""Compute the variance inflation factor for each column.
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
exclude_confounds (bool): Skip confound/nuisance columns. Default: True.
|
|
951
|
+
|
|
952
|
+
Returns:
|
|
953
|
+
np.ndarray: VIF values for each included column. Returns None if the
|
|
954
|
+
correlation matrix is singular.
|
|
955
|
+
"""
|
|
956
|
+
from .diagnostics import _vif
|
|
957
|
+
|
|
958
|
+
return _vif(self, exclude_confounds)
|
|
959
|
+
|
|
960
|
+
def with_columns(self, *exprs, **named_exprs) -> DesignMatrix:
|
|
961
|
+
"""Add or replace columns via Polars expressions.
|
|
962
|
+
|
|
963
|
+
Mirrors ``pl.DataFrame.with_columns``. Named kwargs become named
|
|
964
|
+
columns; positional ``pl.Expr`` arguments are accepted as-is
|
|
965
|
+
(including ``pl.Expr.alias("name")``). Returns a new `DesignMatrix`
|
|
966
|
+
preserving annotations on untouched columns. Replacing a column clears
|
|
967
|
+
its convolution annotation and retains its confound role; new columns
|
|
968
|
+
are untagged.
|
|
969
|
+
|
|
970
|
+
For convenience, named-kwarg values that aren't ``pl.Expr`` /
|
|
971
|
+
``pl.Series`` are coerced: an ``int``/``float`` is broadcast as a
|
|
972
|
+
scalar via ``pl.lit``, and a ``list`` / ``np.ndarray`` is wrapped as a
|
|
973
|
+
``pl.Series``.
|
|
974
|
+
|
|
975
|
+
Args:
|
|
976
|
+
*exprs (pl.Expr): Positional Polars expressions, passed through.
|
|
977
|
+
**named_exprs (pl.Expr | pl.Series | np.ndarray | list | int | float):
|
|
978
|
+
New columns keyed by name.
|
|
979
|
+
|
|
980
|
+
Returns:
|
|
981
|
+
DesignMatrix: New DesignMatrix with the columns added or replaced.
|
|
982
|
+
|
|
983
|
+
Examples:
|
|
984
|
+
```python
|
|
985
|
+
dm = dm.with_columns(motor=pl.sum_horizontal(motor_cols)).drop(motor_cols)
|
|
986
|
+
dm = dm.with_columns(
|
|
987
|
+
vmpfc=seed_signal,
|
|
988
|
+
vmpfc_motor=pl.col("vmpfc") * pl.col("motor_c0"),
|
|
989
|
+
)
|
|
990
|
+
```
|
|
991
|
+
"""
|
|
992
|
+
from .utils import _copy_with
|
|
993
|
+
|
|
994
|
+
coerced = {}
|
|
995
|
+
for name, value in named_exprs.items():
|
|
996
|
+
if isinstance(value, (pl.Expr, pl.Series)):
|
|
997
|
+
coerced[name] = value
|
|
998
|
+
elif isinstance(value, (list, np.ndarray)):
|
|
999
|
+
coerced[name] = pl.Series(name, value)
|
|
1000
|
+
elif isinstance(value, (int, float)):
|
|
1001
|
+
coerced[name] = pl.lit(value).alias(name)
|
|
1002
|
+
else:
|
|
1003
|
+
raise TypeError(
|
|
1004
|
+
f"with_columns: kwarg {name!r} has unsupported type "
|
|
1005
|
+
f"{type(value).__name__}. Pass a polars Expr/Series, "
|
|
1006
|
+
"numpy array, list, or scalar."
|
|
1007
|
+
)
|
|
1008
|
+
frame = _effective_frame(self)
|
|
1009
|
+
replaced = _replacement_names(frame, exprs, coerced)
|
|
1010
|
+
new_data = frame.with_columns(*exprs, **coerced)
|
|
1011
|
+
if self.data.width == 0 and self._n_rows is not None and "" not in replaced:
|
|
1012
|
+
new_data = new_data.drop("")
|
|
1013
|
+
return _copy_with(self, new_data, operation="replace", replaced=replaced)
|
|
1014
|
+
|
|
1015
|
+
def write(self, file_name: str, sep: str | None = None) -> None:
|
|
1016
|
+
"""Write DesignMatrix to file.
|
|
1017
|
+
|
|
1018
|
+
Supports TSV, CSV, and HDF5 formats. Format is auto-detected from the
|
|
1019
|
+
file extension. Text formats carry the data only; ``.h5`` also
|
|
1020
|
+
preserves ``sampling_freq``, ``.convolved``, ``.confounds``, and
|
|
1021
|
+
``.multi``, so ``DesignMatrix(path)`` restores the whole object.
|
|
1022
|
+
|
|
1023
|
+
Args:
|
|
1024
|
+
file_name (str): Output file path with a `.tsv`, `.csv`, `.h5`, or
|
|
1025
|
+
`.hdf5` extension.
|
|
1026
|
+
sep (str | None): Column separator for text files. Defaults to the
|
|
1027
|
+
delimiter the extension implies (comma for `.csv`, tab
|
|
1028
|
+
otherwise); pass a value to override.
|
|
1029
|
+
"""
|
|
1030
|
+
from .io import _write
|
|
1031
|
+
|
|
1032
|
+
return _write(self, file_name, sep)
|