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,248 @@
|
|
|
1
|
+
"""Collinearity diagnostics for DesignMatrix: column correlations, VIF, and cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .utils import _copy_with, _get_data_columns, _is_generated_intercept
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from nltools.data import Adjacency
|
|
13
|
+
from nltools.data.designmatrix import DesignMatrix
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _corr(
|
|
17
|
+
dm: DesignMatrix,
|
|
18
|
+
*,
|
|
19
|
+
metric: str = "pearson",
|
|
20
|
+
columns: list[str] | None = None,
|
|
21
|
+
) -> Adjacency:
|
|
22
|
+
"""Correlation between DesignMatrix columns as an Adjacency.
|
|
23
|
+
|
|
24
|
+
Returns the column-by-column correlation matrix wrapped in an nltools
|
|
25
|
+
``Adjacency`` (``matrix_type='similarity'``) so it composes with the rest
|
|
26
|
+
of the similarity-matrix tooling (``.plot()``, MDS, etc.). The Adjacency
|
|
27
|
+
stores only the off-diagonal entries — self-correlation isn't a meaningful
|
|
28
|
+
edge — so the unit diagonal is implicit; ``DesignMatrix.plot(method='corr')``
|
|
29
|
+
restores it for display.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
dm (DesignMatrix): DesignMatrix instance.
|
|
33
|
+
metric (str): ``'pearson'`` (default) or ``'spearman'``. Spearman is
|
|
34
|
+
computed as Pearson on column ranks.
|
|
35
|
+
columns (list[str] | None): Subset of columns to correlate.
|
|
36
|
+
Defaults to all columns.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Adjacency: Similarity matrix whose ``labels`` are the included column
|
|
40
|
+
names.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If ``metric`` is unknown or fewer than 2 columns remain.
|
|
44
|
+
|
|
45
|
+
Note:
|
|
46
|
+
Constant columns (e.g. the ``.nl_poly_0`` intercept) have zero variance and
|
|
47
|
+
yield NaN correlations.
|
|
48
|
+
"""
|
|
49
|
+
from nltools.data import Adjacency
|
|
50
|
+
|
|
51
|
+
valid_metrics = ("pearson", "spearman")
|
|
52
|
+
if metric not in valid_metrics:
|
|
53
|
+
raise ValueError(f"metric must be one of {valid_metrics}, got {metric!r}.")
|
|
54
|
+
|
|
55
|
+
cols = list(columns) if columns is not None else list(dm.columns)
|
|
56
|
+
if len(cols) < 2:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Correlation requires at least 2 columns; got {len(cols)} ({cols})."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
X = dm.data.select(cols).to_numpy().astype(float)
|
|
62
|
+
|
|
63
|
+
if metric == "spearman":
|
|
64
|
+
# Spearman == Pearson on per-column ranks. Ranking column-wise (rather
|
|
65
|
+
# than scipy.stats.spearmanr) keeps a uniform n-by-n result even for
|
|
66
|
+
# the 2-column case, where spearmanr collapses to a scalar.
|
|
67
|
+
from scipy.stats import rankdata
|
|
68
|
+
|
|
69
|
+
X = np.column_stack([rankdata(X[:, i]) for i in range(X.shape[1])])
|
|
70
|
+
|
|
71
|
+
# Constant columns (zero variance) make corrcoef divide by zero -> NaN.
|
|
72
|
+
# That NaN is the intended, documented signal; silence the low-level
|
|
73
|
+
# numpy warning so it isn't surfaced as noise.
|
|
74
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
75
|
+
mat = np.corrcoef(X, rowvar=False)
|
|
76
|
+
return Adjacency(
|
|
77
|
+
np.asarray(mat, dtype=float), matrix_type="similarity", labels=cols
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _vif(dm: DesignMatrix, exclude_confounds: bool = True) -> np.ndarray | None:
|
|
82
|
+
"""Compute the variance inflation factor for each column.
|
|
83
|
+
|
|
84
|
+
Uses diagonal elements of inverted correlation matrix
|
|
85
|
+
(same method as Matlab and R).
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
dm (DesignMatrix): DesignMatrix instance.
|
|
89
|
+
exclude_confounds (bool): Skip nuisance/confound columns. Default: True.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
np.ndarray | None: VIF values for each included column, or None if the
|
|
93
|
+
correlation matrix is singular (perfect collinearity detected).
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ValueError: If the DesignMatrix has only 1 column.
|
|
97
|
+
"""
|
|
98
|
+
import polars.selectors as cs
|
|
99
|
+
|
|
100
|
+
if dm.shape[1] <= 1:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"Can't compute VIF with only 1 column! "
|
|
103
|
+
"VIF measures multicollinearity and requires at least 2 columns. "
|
|
104
|
+
f"Your DesignMatrix has shape {dm.shape}."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Determine which columns to include (using polars selectors for declarative filtering)
|
|
108
|
+
if exclude_confounds and dm.confounds:
|
|
109
|
+
# Use polars selector: "select all columns except confound terms"
|
|
110
|
+
subset_df = dm.data.select(cs.exclude(dm.confounds))
|
|
111
|
+
elif exclude_confounds:
|
|
112
|
+
# No confounds to exclude, use all columns
|
|
113
|
+
subset_df = dm.data
|
|
114
|
+
else:
|
|
115
|
+
# Always exclude generated intercepts even when exclude_confounds=False:
|
|
116
|
+
# an all-ones column has zero variance, so it makes the correlation
|
|
117
|
+
# matrix singular and VIF undefined.
|
|
118
|
+
cols_to_use = [c for c in dm.columns if not _is_generated_intercept(c)]
|
|
119
|
+
subset_df = dm.data.select(cols_to_use)
|
|
120
|
+
|
|
121
|
+
# Edge case: single column has VIF = 1 (no multicollinearity)
|
|
122
|
+
if subset_df.shape[1] == 1:
|
|
123
|
+
return np.array([1.0])
|
|
124
|
+
|
|
125
|
+
# Convert to numpy for correlation matrix and linear algebra
|
|
126
|
+
# NECESSARY: Polars doesn't have correlation matrix or matrix inversion
|
|
127
|
+
data_array = subset_df.to_numpy()
|
|
128
|
+
|
|
129
|
+
# Compute correlation matrix
|
|
130
|
+
corr_matrix = np.corrcoef(data_array, rowvar=False)
|
|
131
|
+
|
|
132
|
+
# Compute VIF = diagonal of inverse correlation matrix
|
|
133
|
+
try:
|
|
134
|
+
inv_corr = np.linalg.inv(corr_matrix)
|
|
135
|
+
return np.diag(inv_corr)
|
|
136
|
+
except np.linalg.LinAlgError:
|
|
137
|
+
# Matrix is singular - perfect collinearity detected
|
|
138
|
+
# Return None and warn user (matches old behavior)
|
|
139
|
+
print(
|
|
140
|
+
"ERROR: Cannot compute VIF! Design Matrix is singular because it has "
|
|
141
|
+
"some perfectly correlated or duplicated columns. Using .clean() may help."
|
|
142
|
+
)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _clean(
|
|
147
|
+
dm: DesignMatrix,
|
|
148
|
+
*,
|
|
149
|
+
fill_na: int | float | None = 0,
|
|
150
|
+
exclude_confounds: bool = False,
|
|
151
|
+
thresh: float = 0.95,
|
|
152
|
+
progress_bar: bool = False,
|
|
153
|
+
) -> DesignMatrix:
|
|
154
|
+
"""Remove highly correlated columns.
|
|
155
|
+
|
|
156
|
+
Removes columns with correlation >= threshold. Keeps first instance
|
|
157
|
+
of correlated pair, drops duplicates.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
dm (DesignMatrix): DesignMatrix instance.
|
|
161
|
+
fill_na (int | float | None): Fill NaN values before checking correlations.
|
|
162
|
+
Default: 0.
|
|
163
|
+
exclude_confounds (bool): Skip nuisance/confound columns from correlation check.
|
|
164
|
+
Default: False.
|
|
165
|
+
thresh (float): Correlation threshold (drop if abs(r) >= thresh).
|
|
166
|
+
Default: 0.95.
|
|
167
|
+
progress_bar (bool): Print dropped column names. Default: False.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
DesignMatrix: Cleaned matrix with highly correlated columns removed
|
|
171
|
+
"""
|
|
172
|
+
# Check for duplicate column names
|
|
173
|
+
if len(dm.columns) != len(set(dm.columns)):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"Duplicate column names detected. Using .clean() with duplicate "
|
|
176
|
+
"columns is not supported as it can produce unexpected results."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Start with a copy
|
|
180
|
+
result = dm
|
|
181
|
+
|
|
182
|
+
# Fill NaN if requested
|
|
183
|
+
if fill_na is not None:
|
|
184
|
+
result = result.fillna(fill_na)
|
|
185
|
+
|
|
186
|
+
# Determine which columns to check for correlation
|
|
187
|
+
if exclude_confounds:
|
|
188
|
+
cols_to_check = _get_data_columns(result, exclude_confounds=True)
|
|
189
|
+
else:
|
|
190
|
+
cols_to_check = list(result.columns)
|
|
191
|
+
|
|
192
|
+
if len(cols_to_check) <= 1:
|
|
193
|
+
if progress_bar:
|
|
194
|
+
print("Only 1 column to check...skipping")
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
# Compute pairwise correlations and identify columns to drop
|
|
198
|
+
keep = []
|
|
199
|
+
remove = []
|
|
200
|
+
|
|
201
|
+
# Convert to numpy for pairwise correlation computation
|
|
202
|
+
# NECESSARY: More efficient than Polars for this operation
|
|
203
|
+
subset_df = result.data.select(cols_to_check)
|
|
204
|
+
data_array = subset_df.to_numpy()
|
|
205
|
+
|
|
206
|
+
# Check each pair of columns
|
|
207
|
+
for i in range(len(cols_to_check)):
|
|
208
|
+
col_i = cols_to_check[i]
|
|
209
|
+
col_i_data = data_array[:, i]
|
|
210
|
+
|
|
211
|
+
for j in range(i + 1, len(cols_to_check)):
|
|
212
|
+
col_j = cols_to_check[j]
|
|
213
|
+
col_j_data = data_array[:, j]
|
|
214
|
+
|
|
215
|
+
# Skip if already marked for removal or keeping
|
|
216
|
+
if col_j in keep or col_j in remove:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Check for constant arrays (avoid correlation warnings)
|
|
220
|
+
if np.var(col_i_data) == 0 or np.var(col_j_data) == 0:
|
|
221
|
+
r = 0.0
|
|
222
|
+
else:
|
|
223
|
+
# Compute correlation
|
|
224
|
+
r = np.abs(np.corrcoef(col_i_data, col_j_data)[0, 1])
|
|
225
|
+
|
|
226
|
+
# Mark for removal if correlation exceeds threshold
|
|
227
|
+
if r >= thresh and col_i not in keep and col_i not in remove:
|
|
228
|
+
if progress_bar:
|
|
229
|
+
print(
|
|
230
|
+
f"{col_i} and {col_j} correlated at {r:.2f} which is >= "
|
|
231
|
+
f"threshold of {thresh}. Dropping {col_j}"
|
|
232
|
+
)
|
|
233
|
+
keep.append(col_i)
|
|
234
|
+
remove.append(col_j)
|
|
235
|
+
|
|
236
|
+
# Drop correlated columns
|
|
237
|
+
if remove:
|
|
238
|
+
# Drop from DataFrame
|
|
239
|
+
new_df = result.data.drop(remove)
|
|
240
|
+
|
|
241
|
+
# Update confounds metadata
|
|
242
|
+
new_confounds = [p for p in result.confounds if p not in remove]
|
|
243
|
+
|
|
244
|
+
# Return cleaned matrix
|
|
245
|
+
return _copy_with(result, new_df, confounds=new_confounds)
|
|
246
|
+
if progress_bar:
|
|
247
|
+
print("Dropping columns not needed...skipping")
|
|
248
|
+
return result
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Read and write DesignMatrix objects.
|
|
2
|
+
|
|
3
|
+
Loads BIDS events and tabular confound files into the frame a `DesignMatrix`
|
|
4
|
+
wraps, exports NumPy arrays, and round-trips through TSV/CSV or HDF5
|
|
5
|
+
(which also preserves the metadata). A private pandas adapter serves nilearn.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from nltools.data.designmatrix import DesignMatrix
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _events_to_convolved_dm(
|
|
23
|
+
events: pl.DataFrame | pd.DataFrame,
|
|
24
|
+
*,
|
|
25
|
+
run_length: int,
|
|
26
|
+
sampling_freq: float,
|
|
27
|
+
hrf_model: str,
|
|
28
|
+
) -> pl.DataFrame:
|
|
29
|
+
"""Convert a BIDS events table straight to HRF-convolved regressors.
|
|
30
|
+
|
|
31
|
+
`make_first_level_design_matrix` convolves the events at nilearn's own
|
|
32
|
+
oversampling and only then samples onto the frame times, so onsets that
|
|
33
|
+
fall between TRs keep their timing. Going through `events_to_dm` first
|
|
34
|
+
would quantize them onto the TR grid before convolution, and the result
|
|
35
|
+
would no longer match a nilearn `FirstLevelModel` on the same events.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
events (pl.DataFrame | pd.DataFrame): Events table with BIDS columns
|
|
39
|
+
`onset`, `duration`, `trial_type` (required); `modulation` is
|
|
40
|
+
passed through if present.
|
|
41
|
+
run_length (int): Number of TRs the run contains.
|
|
42
|
+
sampling_freq (float): Sampling frequency in Hz (= 1/TR).
|
|
43
|
+
hrf_model (str): An HRF model name from `_KERNELS`.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
pl.DataFrame: One column per unique `trial_type`, convolved and named
|
|
47
|
+
`<trial_type>_c0`.
|
|
48
|
+
"""
|
|
49
|
+
import pandas as pd
|
|
50
|
+
from nilearn.glm.first_level import make_first_level_design_matrix
|
|
51
|
+
|
|
52
|
+
from .regressors import _KERNELS
|
|
53
|
+
|
|
54
|
+
if isinstance(events, pl.DataFrame):
|
|
55
|
+
events = pd.DataFrame(events.to_dict(as_series=False))
|
|
56
|
+
|
|
57
|
+
kernel = _KERNELS[hrf_model]
|
|
58
|
+
frame_times = np.arange(run_length) / sampling_freq
|
|
59
|
+
dm = make_first_level_design_matrix(
|
|
60
|
+
frame_times,
|
|
61
|
+
events=events,
|
|
62
|
+
hrf_model=kernel,
|
|
63
|
+
drift_model=None,
|
|
64
|
+
)
|
|
65
|
+
if "constant" in dm.columns:
|
|
66
|
+
dm = dm.drop(columns=["constant"])
|
|
67
|
+
# nilearn suffixes a column with the name of the function that convolved
|
|
68
|
+
# it when the model is a callable; nltools names every convolved column
|
|
69
|
+
# `<col>_c0` regardless of kernel, so strip it back off.
|
|
70
|
+
suffix = "" if isinstance(kernel, str) else f"_{kernel.__name__}"
|
|
71
|
+
return pl.DataFrame(
|
|
72
|
+
{f"{str(c).removesuffix(suffix)}_c0": dm[c].to_numpy() for c in dm.columns}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _separator_for_path(path: str | Path) -> str:
|
|
77
|
+
"""Return the delimiter a text DesignMatrix file uses, from its extension.
|
|
78
|
+
|
|
79
|
+
The single source of truth for both `write` and `_load_from_file`, so a
|
|
80
|
+
file nltools writes is always a file nltools can read back. ``.csv`` means
|
|
81
|
+
comma; every other extension means tab, matching the BIDS convention for
|
|
82
|
+
``.tsv`` and keeping the historical default for ``.txt`` and friends.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
path (str | Path): File path whose extension decides the delimiter.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
str: ``','`` for `.csv`, ``'\\t'`` otherwise.
|
|
89
|
+
"""
|
|
90
|
+
return "," if Path(path).suffix.lower() == ".csv" else "\t"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_delimited(path: Path, sep: str) -> pl.DataFrame:
|
|
94
|
+
"""Read a delimited text file, rejecting a separator its extension belies.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path (Path): File to read.
|
|
98
|
+
sep (str): Delimiter the extension implies.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
pl.DataFrame: The parsed table.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If the file parses as a single column whose name still
|
|
105
|
+
holds the other delimiter — the file's separator does not match
|
|
106
|
+
its extension.
|
|
107
|
+
"""
|
|
108
|
+
raw = pl.read_csv(
|
|
109
|
+
path,
|
|
110
|
+
separator=sep,
|
|
111
|
+
null_values=["n/a", "N/A", "NA", ""],
|
|
112
|
+
infer_schema_length=10_000,
|
|
113
|
+
)
|
|
114
|
+
alternate = "," if sep == "\t" else "\t"
|
|
115
|
+
if raw.width == 1 and alternate in raw.columns[0]:
|
|
116
|
+
shown = {",": "','", "\t": "tab"}
|
|
117
|
+
expected = ".tsv" if alternate == "\t" else ".csv"
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"{path.name} parsed as a single column with the {shown[sep]} "
|
|
120
|
+
f"separator its extension implies, but its header contains "
|
|
121
|
+
f"{shown[alternate]}. The file's separator does not match its "
|
|
122
|
+
f"extension: rename it to {expected}, or rewrite it with "
|
|
123
|
+
f"DesignMatrix.write(name, sep=...) using the delimiter the "
|
|
124
|
+
f"extension implies."
|
|
125
|
+
)
|
|
126
|
+
return raw
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_from_file(
|
|
130
|
+
path: str | Path,
|
|
131
|
+
*,
|
|
132
|
+
run_length: int | str,
|
|
133
|
+
sampling_freq: float,
|
|
134
|
+
hrf_model: str | None = None,
|
|
135
|
+
) -> tuple[pl.DataFrame, bool]:
|
|
136
|
+
"""Read a TSV/CSV into the frame a DesignMatrix wraps.
|
|
137
|
+
|
|
138
|
+
Dispatches on column inspection: when `onset` and `duration` are both
|
|
139
|
+
present the file is a BIDS events table and becomes an experimental design
|
|
140
|
+
— HRF-convolved by nilearn when `hrf_model` names a model, raw boxcars via
|
|
141
|
+
`events_to_dm` when it is `None` — otherwise it is a tabular file
|
|
142
|
+
(confounds / nuisance regressors) read as-is.
|
|
143
|
+
|
|
144
|
+
``run_length='infer'`` is accepted only for the tabular path; events
|
|
145
|
+
files must provide an explicit integer (they have a variable row count
|
|
146
|
+
per run, unlike confounds which are 1 row per TR).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
path (str | Path): Path to a `.tsv` or `.csv` file.
|
|
150
|
+
run_length (int | str): Number of TRs, or ``'infer'`` for tabular inputs.
|
|
151
|
+
sampling_freq (float): Sampling frequency in Hz (= 1/TR).
|
|
152
|
+
hrf_model (str | None): HRF model name to convolve an events table
|
|
153
|
+
with, or ``None`` for raw boxcars. Ignored for tabular files.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
tuple[pl.DataFrame, bool]: `(frame, is_events)` — `is_events` signals to
|
|
157
|
+
the caller that the columns are experimental regressors rather than
|
|
158
|
+
nuisance.
|
|
159
|
+
"""
|
|
160
|
+
from nltools.io.events import events_to_dm
|
|
161
|
+
|
|
162
|
+
p = Path(path)
|
|
163
|
+
raw = _read_delimited(p, _separator_for_path(p))
|
|
164
|
+
|
|
165
|
+
is_events = "onset" in raw.columns and "duration" in raw.columns
|
|
166
|
+
|
|
167
|
+
if is_events:
|
|
168
|
+
if run_length == "infer":
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"run_length='infer' is not valid for BIDS events files "
|
|
171
|
+
"(the row count is the number of events, not the number "
|
|
172
|
+
"of TRs). Pass an explicit integer run_length."
|
|
173
|
+
)
|
|
174
|
+
if hrf_model is None:
|
|
175
|
+
data_df = events_to_dm(
|
|
176
|
+
raw,
|
|
177
|
+
run_length=int(run_length),
|
|
178
|
+
sampling_freq=sampling_freq,
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
data_df = _events_to_convolved_dm(
|
|
182
|
+
raw,
|
|
183
|
+
run_length=int(run_length),
|
|
184
|
+
sampling_freq=sampling_freq,
|
|
185
|
+
hrf_model=hrf_model,
|
|
186
|
+
)
|
|
187
|
+
return data_df, True
|
|
188
|
+
|
|
189
|
+
if run_length != "infer":
|
|
190
|
+
rl = int(run_length)
|
|
191
|
+
if raw.height != rl:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Tabular file {p.name} has {raw.height} rows but "
|
|
194
|
+
f"run_length={rl}. Pass run_length='infer' to accept "
|
|
195
|
+
f"whatever the file contains."
|
|
196
|
+
)
|
|
197
|
+
return raw, False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _to_pandas(dm: DesignMatrix):
|
|
201
|
+
"""Build the pandas table required by nilearn's GLM boundary."""
|
|
202
|
+
import pandas as pd
|
|
203
|
+
|
|
204
|
+
return pd.DataFrame(dm.data.to_dict(as_series=False), index=range(dm.shape[0]))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _to_numpy(dm: DesignMatrix) -> np.ndarray:
|
|
208
|
+
"""Convert a DesignMatrix to a NumPy array.
|
|
209
|
+
|
|
210
|
+
Returns the data columns as a 2D array (rows x columns), preserving the
|
|
211
|
+
DataFrame's column order.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
dm (DesignMatrix): DesignMatrix instance.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
np.ndarray: 2D array with shape ``(n_samples, n_columns)``.
|
|
218
|
+
|
|
219
|
+
Examples:
|
|
220
|
+
```python
|
|
221
|
+
dm = DesignMatrix({"a": [1, 2, 3], "b": [4, 5, 6]}, sampling_freq=1)
|
|
222
|
+
arr = to_numpy(dm)
|
|
223
|
+
arr.shape # → (3, 2)
|
|
224
|
+
```
|
|
225
|
+
"""
|
|
226
|
+
# np.asarray(dm) routes through DesignMatrix.__array__, which knows how to
|
|
227
|
+
# honor the recorded length of a column-less matrix (polars itself would
|
|
228
|
+
# report (0, 0)).
|
|
229
|
+
return np.asarray(dm)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _write(dm: DesignMatrix, file_name: str, sep: str | None = None) -> None:
|
|
233
|
+
"""Write DesignMatrix to file.
|
|
234
|
+
|
|
235
|
+
Supports TSV, CSV, and HDF5 formats. The format is automatically
|
|
236
|
+
determined by file extension.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
dm (DesignMatrix): DesignMatrix instance.
|
|
240
|
+
file_name (str): Output file path with a `.tsv`, `.csv`, `.h5`, or
|
|
241
|
+
`.hdf5` extension.
|
|
242
|
+
sep (str | None): Column separator for text files. Defaults to the
|
|
243
|
+
delimiter the extension implies (comma for `.csv`, tab otherwise),
|
|
244
|
+
so the file reads back correctly; pass a value to override.
|
|
245
|
+
Ignored for HDF5.
|
|
246
|
+
|
|
247
|
+
Examples:
|
|
248
|
+
```python
|
|
249
|
+
dm = DesignMatrix(np.random.randn(100, 3), sampling_freq=1)
|
|
250
|
+
write(dm, "design_matrix.tsv") # tab separated (BIDS compatible)
|
|
251
|
+
write(dm, "design_matrix.csv") # comma separated
|
|
252
|
+
write(dm, "design_matrix.h5") # HDF5, metadata preserved
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Note:
|
|
256
|
+
TSV format is recommended for BIDS compatibility. Text formats carry
|
|
257
|
+
the data only — HDF5 additionally preserves ``sampling_freq``,
|
|
258
|
+
``.convolved``, ``.confounds``, ``.multi``, and the row count of a
|
|
259
|
+
column-less matrix, so ``DesignMatrix(path)`` restores the object.
|
|
260
|
+
"""
|
|
261
|
+
from pathlib import Path
|
|
262
|
+
|
|
263
|
+
from nltools.io.h5 import _is_h5_path
|
|
264
|
+
|
|
265
|
+
if isinstance(file_name, Path):
|
|
266
|
+
file_name = str(file_name)
|
|
267
|
+
|
|
268
|
+
if _is_h5_path(file_name):
|
|
269
|
+
_write_h5(dm, file_name)
|
|
270
|
+
else:
|
|
271
|
+
if dm.shape[1] == 0:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"Text export requires at least one column; use HDF5 to preserve observations."
|
|
274
|
+
)
|
|
275
|
+
# Write as delimited text file. The separator follows the extension by
|
|
276
|
+
# default so `write` and the file constructor cannot disagree.
|
|
277
|
+
dm.data.write_csv(
|
|
278
|
+
file_name, separator=_separator_for_path(file_name) if sep is None else sep
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _write_h5(dm: DesignMatrix, file_name: str) -> None:
|
|
283
|
+
"""Write DesignMatrix to HDF5 file with metadata.
|
|
284
|
+
|
|
285
|
+
The frame is stored as Arrow IPC bytes (via the shared
|
|
286
|
+
`nltools.io.h5` helpers) so every dtype round-trips exactly — an integer
|
|
287
|
+
spike indicator comes back an integer rather than being floated by a
|
|
288
|
+
detour through a homogeneous numpy array.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
dm (DesignMatrix): DesignMatrix instance.
|
|
292
|
+
file_name (str): Output HDF5 file path.
|
|
293
|
+
"""
|
|
294
|
+
import h5py
|
|
295
|
+
|
|
296
|
+
from nltools.io.h5 import _write_polars_frame
|
|
297
|
+
|
|
298
|
+
with h5py.File(file_name, "w") as f:
|
|
299
|
+
_write_polars_frame(f, "data", dm.data, "gzip")
|
|
300
|
+
|
|
301
|
+
meta = f.create_group("metadata")
|
|
302
|
+
if dm.sampling_freq is not None:
|
|
303
|
+
meta.attrs["sampling_freq"] = dm.sampling_freq
|
|
304
|
+
meta.attrs["convolved"] = np.array(
|
|
305
|
+
dm.convolved, dtype=h5py.string_dtype("utf-8")
|
|
306
|
+
)
|
|
307
|
+
meta.attrs["confounds"] = np.array(
|
|
308
|
+
dm.confounds, dtype=h5py.string_dtype("utf-8")
|
|
309
|
+
)
|
|
310
|
+
meta.attrs["multi"] = dm.multi
|
|
311
|
+
meta.attrs["run_count"] = dm._run_count
|
|
312
|
+
# A column-less matrix still describes a specific number of
|
|
313
|
+
# timepoints, and polars cannot carry that in the frame itself.
|
|
314
|
+
if dm._n_rows is not None:
|
|
315
|
+
meta.attrs["n_rows"] = dm._n_rows
|
|
316
|
+
meta.attrs["obj_type"] = "design_matrix"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _read_h5(file_name: str | Path) -> tuple[pl.DataFrame, dict]:
|
|
320
|
+
"""Read a DesignMatrix HDF5 file written by `_write_h5`.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
file_name (str | Path): Path to the HDF5 file.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
tuple[pl.DataFrame, dict]: `(frame, metadata)`, where metadata holds
|
|
327
|
+
``sampling_freq``, ``convolved``, ``confounds``, ``multi``, and
|
|
328
|
+
``n_rows`` — absent keys meaning the file didn't record them.
|
|
329
|
+
"""
|
|
330
|
+
import h5py
|
|
331
|
+
|
|
332
|
+
from nltools.io.h5 import _read_polars_frame
|
|
333
|
+
|
|
334
|
+
def _decode(values) -> list[str]:
|
|
335
|
+
return [v.decode() if isinstance(v, bytes) else str(v) for v in values]
|
|
336
|
+
|
|
337
|
+
with h5py.File(file_name, "r") as f:
|
|
338
|
+
data = _read_polars_frame(f, "data")
|
|
339
|
+
|
|
340
|
+
metadata: dict = {}
|
|
341
|
+
if "metadata" in f:
|
|
342
|
+
attrs = f["metadata"].attrs
|
|
343
|
+
if "sampling_freq" in attrs:
|
|
344
|
+
metadata["sampling_freq"] = float(attrs["sampling_freq"])
|
|
345
|
+
if "convolved" in attrs:
|
|
346
|
+
metadata["convolved"] = _decode(attrs["convolved"])
|
|
347
|
+
if "confounds" in attrs:
|
|
348
|
+
metadata["confounds"] = _decode(attrs["confounds"])
|
|
349
|
+
if "multi" in attrs:
|
|
350
|
+
metadata["multi"] = bool(attrs["multi"])
|
|
351
|
+
if "run_count" in attrs:
|
|
352
|
+
metadata["run_count"] = int(attrs["run_count"])
|
|
353
|
+
if "n_rows" in attrs:
|
|
354
|
+
metadata["n_rows"] = int(attrs["n_rows"])
|
|
355
|
+
|
|
356
|
+
return data, metadata
|