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,518 @@
|
|
|
1
|
+
"""Concatenate DesignMatrix objects horizontally or across runs.
|
|
2
|
+
|
|
3
|
+
`append` dispatches to `_append_horizontal` (add columns) or `_append_vertical`
|
|
4
|
+
(stack runs). Vertical appends can keep confound columns separate per run by
|
|
5
|
+
renaming them into the reserved ``.nl_r{run}_`` namespace, so each run gets
|
|
6
|
+
its own intercept and drift terms.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
from nltools.utils import _HORIZONTAL_CONCAT
|
|
17
|
+
|
|
18
|
+
from .utils import (
|
|
19
|
+
RESERVED_PREFIX,
|
|
20
|
+
_copy_with,
|
|
21
|
+
_is_reserved_name,
|
|
22
|
+
_run_separated_name,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from nltools.data.designmatrix import DesignMatrix
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _check_dtype_compatibility(dfs: list[pl.DataFrame]) -> None:
|
|
30
|
+
"""Raise a clear ValueError if shared columns across frames have mismatched dtypes.
|
|
31
|
+
|
|
32
|
+
Polars' native error (``SchemaError: type Float64 is incompatible with
|
|
33
|
+
expected type Int64``) doesn't name the offending column, so we check
|
|
34
|
+
ahead of time and produce an actionable message.
|
|
35
|
+
"""
|
|
36
|
+
if len(dfs) < 2:
|
|
37
|
+
return
|
|
38
|
+
# Accumulate the first dtype (and defining frame index) seen for each column
|
|
39
|
+
# across ALL frames, so a mismatch between two later frames is caught even
|
|
40
|
+
# when the column is absent from dfs[0].
|
|
41
|
+
seen: dict[str, tuple[pl.DataType, int]] = {}
|
|
42
|
+
for idx, df in enumerate(dfs):
|
|
43
|
+
for col, dtype in df.schema.items():
|
|
44
|
+
if col not in seen:
|
|
45
|
+
seen[col] = (dtype, idx)
|
|
46
|
+
continue
|
|
47
|
+
first_dtype, first_idx = seen[col]
|
|
48
|
+
if first_dtype != dtype:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Column {col!r} has mismatched dtype {first_dtype} "
|
|
51
|
+
f"in dm[{first_idx}] vs {dtype} in dm[{idx}]. Cast one side "
|
|
52
|
+
f"with .with_columns(pl.col({col!r}).cast(...)) to align "
|
|
53
|
+
f"dtypes before appending."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _coerce_horizontal_input(x, sampling_freq):
|
|
58
|
+
"""Coerce a horizontal-append input into a DesignMatrix.
|
|
59
|
+
|
|
60
|
+
``append(axis=1)`` accepts DesignMatrix or Polars DataFrame. Raw-frame inputs are wrapped into a DesignMatrix whose new
|
|
61
|
+
columns are tracked as nuisance (``.confounds``) — that way a subsequent
|
|
62
|
+
multi-run vertical append keeps them separated per run, which matches the
|
|
63
|
+
usual use of this path (motion / physio / compcor confounds).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
x (DesignMatrix | pl.DataFrame): Input to coerce.
|
|
67
|
+
sampling_freq (float | None): Base DM's sampling frequency (inherited
|
|
68
|
+
by the wrapped DM).
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
DesignMatrix: The coerced input.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
TypeError: If ``x`` is not a DesignMatrix or supported DataFrame.
|
|
75
|
+
ValueError: If a raw frame's columns intrude on the reserved namespace.
|
|
76
|
+
"""
|
|
77
|
+
from nltools.data.designmatrix import DesignMatrix
|
|
78
|
+
|
|
79
|
+
if isinstance(x, DesignMatrix):
|
|
80
|
+
return x
|
|
81
|
+
if isinstance(x, pl.DataFrame):
|
|
82
|
+
# Columns arriving as a raw frame are user-authored by definition, so
|
|
83
|
+
# the reserved namespace is off limits: letting them in would make a
|
|
84
|
+
# user column indistinguishable from one nltools generated.
|
|
85
|
+
reserved = sorted(c for c in x.columns if _is_reserved_name(c))
|
|
86
|
+
if reserved:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Column names starting with {RESERVED_PREFIX!r} are reserved for "
|
|
89
|
+
f"regressors nltools generates (polynomials, cosine bases, spikes, "
|
|
90
|
+
f"run-separated columns): {reserved}. Rename them before appending."
|
|
91
|
+
)
|
|
92
|
+
# Build once, then re-wrap so we can pass `confounds=` via the
|
|
93
|
+
# constructor (the public attribute is read-only).
|
|
94
|
+
tmp = DesignMatrix(x, sampling_freq=sampling_freq)
|
|
95
|
+
return DesignMatrix(
|
|
96
|
+
tmp.data, sampling_freq=sampling_freq, confounds=list(tmp.columns)
|
|
97
|
+
)
|
|
98
|
+
raise TypeError(
|
|
99
|
+
f"append(axis=1) expects DesignMatrix, polars DataFrame; got {type(x).__name__}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _append(
|
|
104
|
+
dm: DesignMatrix,
|
|
105
|
+
other,
|
|
106
|
+
*,
|
|
107
|
+
axis: int = 0,
|
|
108
|
+
keep_separate: bool = True,
|
|
109
|
+
unique_cols: list[str] | None = None,
|
|
110
|
+
fill_na: int | float | None = 0,
|
|
111
|
+
as_confounds: bool = False,
|
|
112
|
+
progress_bar: bool = False,
|
|
113
|
+
) -> DesignMatrix:
|
|
114
|
+
"""Concatenate design matrices.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
dm (DesignMatrix): The base design matrix.
|
|
118
|
+
other (DesignMatrix | pl.DataFrame | list): Matrix or
|
|
119
|
+
matrices to append. For ``axis=1`` (horizontal), also accepts a
|
|
120
|
+
polars DataFrame (or list thereof); the new columns are
|
|
121
|
+
treated as nuisance regressors (tracked in `confounds` on the
|
|
122
|
+
result). For ``axis=0`` (vertical), all items must be `DesignMatrix`.
|
|
123
|
+
axis (int): 0 for row-wise (vertical), 1 for column-wise (horizontal).
|
|
124
|
+
keep_separate (bool): Whether to separate confound columns across runs
|
|
125
|
+
(only ``axis=0``).
|
|
126
|
+
unique_cols (list[str] | None): Additional columns to keep separated
|
|
127
|
+
(supports ``*`` wildcards).
|
|
128
|
+
fill_na (int, float, or None): Value to fill NaN/null entries introduced
|
|
129
|
+
by the concatenation. Pass ``None`` to preserve nulls. Default: 0.
|
|
130
|
+
as_confounds (bool): Only applies to ``axis=1``. When True, all columns
|
|
131
|
+
contributed by ``other`` are tracked as nuisance regressors in
|
|
132
|
+
the result's ``.confounds`` — so they're skipped by ``.convolve()``
|
|
133
|
+
and kept separate across runs in later vertical appends. Useful
|
|
134
|
+
when ``other`` is a pre-built DesignMatrix of confounds that
|
|
135
|
+
hasn't already marked its columns. Default: False.
|
|
136
|
+
progress_bar (bool): Print messages about confound separation. Default: False.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
DesignMatrix: Concatenated design matrix.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
TypeError: If items to append are not DesignMatrix (or, for ``axis=1``,
|
|
143
|
+
a DesignMatrix / polars DataFrame).
|
|
144
|
+
ValueError: If sampling frequencies do not match, axis is invalid,
|
|
145
|
+
a non-multi base is combined with a multi-run DM, or shared
|
|
146
|
+
columns have mismatched dtypes.
|
|
147
|
+
"""
|
|
148
|
+
from nltools.data.designmatrix import DesignMatrix
|
|
149
|
+
|
|
150
|
+
# Normalize to list
|
|
151
|
+
to_append = [other] if not isinstance(other, list) else list(other)
|
|
152
|
+
|
|
153
|
+
# Horizontal append additionally accepts raw DataFrames — convert them
|
|
154
|
+
# to DesignMatrix first so the rest of the validation and merge path is
|
|
155
|
+
# unchanged.
|
|
156
|
+
if axis == 1:
|
|
157
|
+
to_append = [_coerce_horizontal_input(e, dm.sampling_freq) for e in to_append]
|
|
158
|
+
|
|
159
|
+
# Validate all are DesignMatrix with same sampling_freq
|
|
160
|
+
if not all(isinstance(elem, DesignMatrix) for elem in to_append):
|
|
161
|
+
raise TypeError(
|
|
162
|
+
"All items to append must be DesignMatrix objects "
|
|
163
|
+
"(axis=1 also accepts polars DataFrames)"
|
|
164
|
+
)
|
|
165
|
+
if axis not in (0, 1):
|
|
166
|
+
raise ValueError("axis must be 0 (vertical) or 1 (horizontal)")
|
|
167
|
+
|
|
168
|
+
# Only the fully default empty constructor is an untimed identity.
|
|
169
|
+
def is_identity(matrix):
|
|
170
|
+
return (
|
|
171
|
+
matrix.shape == (0, 0)
|
|
172
|
+
and matrix._n_rows is None
|
|
173
|
+
and matrix.sampling_freq is None
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
to_append = [elem for elem in to_append if not is_identity(elem)]
|
|
177
|
+
if is_identity(dm) and to_append:
|
|
178
|
+
dm, *to_append = to_append
|
|
179
|
+
if not to_append:
|
|
180
|
+
return dm.copy()
|
|
181
|
+
if not all(elem.sampling_freq == dm.sampling_freq for elem in to_append):
|
|
182
|
+
raise ValueError("All Design Matrices must have the same sampling frequency!")
|
|
183
|
+
|
|
184
|
+
if axis == 1:
|
|
185
|
+
return _append_horizontal(dm, to_append, fill_na, as_confounds=as_confounds)
|
|
186
|
+
if axis == 0:
|
|
187
|
+
# Refuse the silent-collision case: a non-multi base with any multi
|
|
188
|
+
# DM in to_append would re-index the base as run 0 and collide with
|
|
189
|
+
# the appended DM's existing 0_* columns.
|
|
190
|
+
if not dm.multi and any(elem.multi for elem in to_append):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"Cannot append a multi-run DesignMatrix to a non-multi base: "
|
|
193
|
+
"the base would be re-indexed as run 0 and collide with the "
|
|
194
|
+
"appended matrix's existing run-prefixed columns. Either start "
|
|
195
|
+
"from the multi-run DM and append the single-run DM to it, or "
|
|
196
|
+
"rebuild the multi-run DM from its constituent single-run DMs "
|
|
197
|
+
"in the desired order."
|
|
198
|
+
)
|
|
199
|
+
return _append_vertical(
|
|
200
|
+
dm,
|
|
201
|
+
to_append,
|
|
202
|
+
keep_separate,
|
|
203
|
+
unique_cols,
|
|
204
|
+
fill_na,
|
|
205
|
+
progress_bar=progress_bar,
|
|
206
|
+
)
|
|
207
|
+
raise ValueError("axis must be 0 (vertical) or 1 (horizontal)")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _append_horizontal(
|
|
211
|
+
dm: DesignMatrix,
|
|
212
|
+
to_append: list[DesignMatrix],
|
|
213
|
+
fill_na: int | float | None,
|
|
214
|
+
as_confounds: bool = False,
|
|
215
|
+
) -> DesignMatrix:
|
|
216
|
+
"""Concatenate matrices horizontally by adding columns.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
dm (DesignMatrix): Base DesignMatrix instance.
|
|
220
|
+
to_append (list[DesignMatrix]): Matrices whose columns to add.
|
|
221
|
+
fill_na (int, float, or None): Value to fill NaN/null entries with.
|
|
222
|
+
Pass ``None`` to preserve nulls.
|
|
223
|
+
as_confounds (bool): If True, mark all columns contributed by
|
|
224
|
+
``to_append`` as nuisance/confounds in the result.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
DesignMatrix: New DesignMatrix with columns from all matrices.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
ValueError: If matrices have different row counts, share column
|
|
231
|
+
names, or an appended column duplicates an existing column's
|
|
232
|
+
values under a different name (a design with straight duplicate
|
|
233
|
+
columns is rank deficient by construction, so the model over it
|
|
234
|
+
is not computable — refuse at assembly time rather than decide
|
|
235
|
+
on the user's behalf which copy to keep).
|
|
236
|
+
"""
|
|
237
|
+
# Check all have same number of rows
|
|
238
|
+
if not all(elem.shape[0] == dm.shape[0] for elem in to_append):
|
|
239
|
+
raise ValueError("All Design Matrices must have the same number of rows!")
|
|
240
|
+
|
|
241
|
+
# Polars refuses duplicate column names on horizontal concat. Detect up
|
|
242
|
+
# front and surface an actionable error instead of the cryptic polars one.
|
|
243
|
+
all_columns = set(dm.columns)
|
|
244
|
+
for elem in to_append:
|
|
245
|
+
dupes = all_columns.intersection(elem.columns)
|
|
246
|
+
if dupes:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Duplicate column names on horizontal append: {sorted(dupes)}. "
|
|
249
|
+
f"Rename the conflicting columns on one side before appending."
|
|
250
|
+
)
|
|
251
|
+
all_columns.update(elem.columns)
|
|
252
|
+
|
|
253
|
+
# Straight duplicate VALUES under different names are just as degenerate
|
|
254
|
+
# as duplicate names: the design becomes rank deficient by construction.
|
|
255
|
+
# Only duplication introduced by this append is checked — the base's
|
|
256
|
+
# pre-existing state is the user's business, not this operation's.
|
|
257
|
+
|
|
258
|
+
# Heights were validated above, so the classic horizontal concat (whichever
|
|
259
|
+
# name the installed polars gives it) never pads.
|
|
260
|
+
dfs_to_stack = [dm.data] + [elem.data for elem in to_append]
|
|
261
|
+
new_df = pl.concat(dfs_to_stack, how=_HORIZONTAL_CONCAT)
|
|
262
|
+
|
|
263
|
+
# Fill NaN if requested
|
|
264
|
+
if fill_na is not None:
|
|
265
|
+
new_df = new_df.fill_null(fill_na).fill_nan(fill_na)
|
|
266
|
+
_check_duplicate_values(new_df, dm.columns)
|
|
267
|
+
|
|
268
|
+
# Merge confounds + convolved metadata across all matrices, dedup in order.
|
|
269
|
+
confound_lists = [dm.confounds, *(e.confounds for e in to_append)]
|
|
270
|
+
if as_confounds:
|
|
271
|
+
# Promote all columns from to_append to nuisance/confounds
|
|
272
|
+
confound_lists.extend(e.columns for e in to_append)
|
|
273
|
+
all_confounds = _merge_ordered(confound_lists)
|
|
274
|
+
all_convolved = _merge_ordered([dm.convolved, *(e.convolved for e in to_append)])
|
|
275
|
+
|
|
276
|
+
return _copy_with(dm, new_df, confounds=all_confounds, convolved=all_convolved)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _check_duplicate_values(frame: pl.DataFrame, base_columns: list[str]) -> None:
|
|
280
|
+
"""Reject new equal numeric columns after filling, preserving exact values."""
|
|
281
|
+
null = object()
|
|
282
|
+
nan = object()
|
|
283
|
+
seen = {}
|
|
284
|
+
for col in frame.columns:
|
|
285
|
+
series = frame[col]
|
|
286
|
+
if not series.dtype.is_numeric() and series.dtype != pl.Null:
|
|
287
|
+
continue
|
|
288
|
+
# Python numeric equality/hash compares integer and float values exactly,
|
|
289
|
+
# including integers beyond Float64 precision; null and NaN stay distinct.
|
|
290
|
+
key = tuple(
|
|
291
|
+
null
|
|
292
|
+
if value is None
|
|
293
|
+
else nan
|
|
294
|
+
if isinstance(value, float) and math.isnan(value)
|
|
295
|
+
else value
|
|
296
|
+
for value in series
|
|
297
|
+
)
|
|
298
|
+
if key in seen and col not in base_columns:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f"Column {col!r} duplicates column {seen[key]!r}: identical values under different names."
|
|
301
|
+
)
|
|
302
|
+
seen.setdefault(key, col)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _stack_frames(frames: list[pl.DataFrame], heights: list[int]) -> pl.DataFrame:
|
|
306
|
+
"""Include recorded rows from column-less inputs in a vertical stack."""
|
|
307
|
+
schema = {}
|
|
308
|
+
for frame in frames:
|
|
309
|
+
schema.update(frame.schema)
|
|
310
|
+
if not schema:
|
|
311
|
+
return pl.DataFrame()
|
|
312
|
+
populated = [
|
|
313
|
+
frame
|
|
314
|
+
if frame.width
|
|
315
|
+
else pl.DataFrame(
|
|
316
|
+
[
|
|
317
|
+
pl.Series(name, [None] * height, dtype=dtype)
|
|
318
|
+
for name, dtype in schema.items()
|
|
319
|
+
]
|
|
320
|
+
)
|
|
321
|
+
for frame, height in zip(frames, heights)
|
|
322
|
+
]
|
|
323
|
+
return pl.concat(populated, how="diagonal")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _merge_ordered(lists: list[list[str]]) -> list[str]:
|
|
327
|
+
"""Concatenate lists, preserving first-seen order, skipping duplicates."""
|
|
328
|
+
seen: set[str] = set()
|
|
329
|
+
out: list[str] = []
|
|
330
|
+
for lst in lists:
|
|
331
|
+
for item in lst:
|
|
332
|
+
if item not in seen:
|
|
333
|
+
seen.add(item)
|
|
334
|
+
out.append(item)
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _append_vertical(
|
|
339
|
+
dm: DesignMatrix,
|
|
340
|
+
to_append: list[DesignMatrix],
|
|
341
|
+
keep_separate: bool,
|
|
342
|
+
unique_cols: list[str] | None,
|
|
343
|
+
fill_na: int | float | None,
|
|
344
|
+
*,
|
|
345
|
+
progress_bar: bool,
|
|
346
|
+
) -> DesignMatrix:
|
|
347
|
+
"""Concatenate matrices vertically with optional confound separation.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
dm (DesignMatrix): Base DesignMatrix instance.
|
|
351
|
+
to_append (list[DesignMatrix]): Matrices to stack below `dm`.
|
|
352
|
+
keep_separate (bool): Whether to separate confound columns across runs.
|
|
353
|
+
unique_cols (list[str] | None): Additional columns to keep separated
|
|
354
|
+
(supports ``*`` wildcards).
|
|
355
|
+
fill_na (int, float, or None): Value to fill NaN/null entries with.
|
|
356
|
+
Pass ``None`` to preserve nulls.
|
|
357
|
+
progress_bar (bool): Print messages about confound separation.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
DesignMatrix: New DesignMatrix with rows from all matrices.
|
|
361
|
+
"""
|
|
362
|
+
all_dms = [dm, *to_append]
|
|
363
|
+
|
|
364
|
+
# Simple case: keep_separate=False - just stack rows
|
|
365
|
+
if not keep_separate:
|
|
366
|
+
dfs_to_stack = [d.data for d in all_dms]
|
|
367
|
+
_check_dtype_compatibility(dfs_to_stack)
|
|
368
|
+
new_df = _stack_frames(dfs_to_stack, [d.shape[0] for d in all_dms])
|
|
369
|
+
|
|
370
|
+
# Fill NaN if requested
|
|
371
|
+
if fill_na is not None:
|
|
372
|
+
new_df = new_df.fill_null(fill_na).fill_nan(fill_na)
|
|
373
|
+
|
|
374
|
+
# Merge confounds + convolved across matrices
|
|
375
|
+
all_confounds = _merge_ordered([d.confounds for d in all_dms])
|
|
376
|
+
all_convolved = _merge_ordered([d.convolved for d in all_dms])
|
|
377
|
+
|
|
378
|
+
return _copy_with(
|
|
379
|
+
dm,
|
|
380
|
+
new_df,
|
|
381
|
+
confounds=all_confounds,
|
|
382
|
+
convolved=all_convolved,
|
|
383
|
+
n_rows=sum(d.shape[0] for d in all_dms),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Complex case: keep_separate=True - separate confound columns across runs
|
|
387
|
+
return _append_vertical_with_separation(
|
|
388
|
+
dm, to_append, unique_cols, fill_na, progress_bar=progress_bar
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _match_column_pattern(columns: list[str], pattern: str) -> list[str]:
|
|
393
|
+
"""Match columns against a pattern with wildcard support.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
columns (list[str]): Column names to search.
|
|
397
|
+
pattern (str): Pattern to match, with ``*`` as a leading or trailing
|
|
398
|
+
wildcard: ``'motion*'`` matches ``motion_x`` and ``motion_y``,
|
|
399
|
+
``'*_motion'`` matches ``x_motion`` and ``y_motion``, and
|
|
400
|
+
``'exact'`` matches only ``exact``.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
list[str]: Column names matching the pattern.
|
|
404
|
+
"""
|
|
405
|
+
if pattern.endswith("*"):
|
|
406
|
+
prefix = pattern[:-1]
|
|
407
|
+
return [c for c in columns if c.startswith(prefix)]
|
|
408
|
+
if pattern.startswith("*"):
|
|
409
|
+
suffix = pattern[1:]
|
|
410
|
+
return [c for c in columns if c.endswith(suffix)]
|
|
411
|
+
return [c for c in columns if c == pattern]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _identify_columns_to_separate(
|
|
415
|
+
dm: DesignMatrix,
|
|
416
|
+
all_dms: list[DesignMatrix],
|
|
417
|
+
unique_cols: list[str] | None,
|
|
418
|
+
) -> set:
|
|
419
|
+
"""Identify columns that need run-specific separation.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
dm (DesignMatrix): The base design matrix (used for context only).
|
|
423
|
+
all_dms (list[DesignMatrix]): All matrices being concatenated.
|
|
424
|
+
unique_cols (list[str] | None): User-specified columns to separate
|
|
425
|
+
(supports ``*`` wildcards).
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
set: Column names that should be separated with run prefixes.
|
|
429
|
+
"""
|
|
430
|
+
cols_to_sep = set()
|
|
431
|
+
|
|
432
|
+
# Add confound columns from non-multi DMs only
|
|
433
|
+
# (Multi-run DMs already have separated confounds)
|
|
434
|
+
for d in all_dms:
|
|
435
|
+
if d.confounds and not d.multi:
|
|
436
|
+
cols_to_sep.update(d.confounds)
|
|
437
|
+
|
|
438
|
+
# Add unique_cols with wildcard matching
|
|
439
|
+
if unique_cols:
|
|
440
|
+
# Collect all column names across all DMs
|
|
441
|
+
all_column_names = set()
|
|
442
|
+
for d in all_dms:
|
|
443
|
+
all_column_names.update(d.columns)
|
|
444
|
+
|
|
445
|
+
# Match each pattern
|
|
446
|
+
for pattern in unique_cols:
|
|
447
|
+
matched = _match_column_pattern(list(all_column_names), pattern)
|
|
448
|
+
cols_to_sep.update(matched)
|
|
449
|
+
|
|
450
|
+
return cols_to_sep
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _append_vertical_with_separation(
|
|
454
|
+
dm: DesignMatrix,
|
|
455
|
+
to_append: list[DesignMatrix],
|
|
456
|
+
unique_cols: list[str] | None,
|
|
457
|
+
fill_na: int | float | None,
|
|
458
|
+
*,
|
|
459
|
+
progress_bar: bool,
|
|
460
|
+
) -> DesignMatrix:
|
|
461
|
+
"""Concatenate vertically with automatic confound separation.
|
|
462
|
+
|
|
463
|
+
Creates run-specific columns (e.g. ``.nl_r0_poly_0``, ``.nl_r1_poly_0``)
|
|
464
|
+
that are active only in their respective runs (sparse representation).
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
dm (DesignMatrix): Base DesignMatrix instance.
|
|
468
|
+
to_append (list[DesignMatrix]): Matrices to stack below `dm`.
|
|
469
|
+
unique_cols (list[str] | None): Additional columns to keep separated
|
|
470
|
+
(supports ``*`` wildcards).
|
|
471
|
+
fill_na (int, float, or None): Value to fill NaN/null entries with.
|
|
472
|
+
Pass ``None`` to preserve nulls.
|
|
473
|
+
progress_bar (bool): Print messages about confound separation.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
DesignMatrix: Concatenated DesignMatrix with run-separated confound columns
|
|
477
|
+
and multi=True.
|
|
478
|
+
"""
|
|
479
|
+
all_dms = [dm, *to_append]
|
|
480
|
+
cols_to_sep = _identify_columns_to_separate(dm, all_dms, unique_cols)
|
|
481
|
+
if progress_bar and cols_to_sep:
|
|
482
|
+
print(f"Separating columns across runs: {sorted(cols_to_sep)}")
|
|
483
|
+
|
|
484
|
+
processed_dfs = []
|
|
485
|
+
all_new_confounds: list[str] = []
|
|
486
|
+
all_new_convolved: list[str] = []
|
|
487
|
+
next_run = 0
|
|
488
|
+
for d in all_dms:
|
|
489
|
+
rename_map = {}
|
|
490
|
+
if d.shape[0] > 0:
|
|
491
|
+
if d.multi:
|
|
492
|
+
# Preassembled inputs already carry their accepted identities.
|
|
493
|
+
next_run = max(next_run, d._run_count)
|
|
494
|
+
else:
|
|
495
|
+
rename_map = {
|
|
496
|
+
col: _run_separated_name(next_run, col)
|
|
497
|
+
for col in d.columns
|
|
498
|
+
if col in cols_to_sep
|
|
499
|
+
}
|
|
500
|
+
next_run += 1
|
|
501
|
+
all_new_confounds.extend(rename_map.get(c, c) for c in d.confounds)
|
|
502
|
+
all_new_convolved.extend(rename_map.get(c, c) for c in d.convolved)
|
|
503
|
+
# Even a zero-row input contributes its schema and dtype obligations.
|
|
504
|
+
processed_dfs.append(d.data.rename(rename_map) if rename_map else d.data)
|
|
505
|
+
|
|
506
|
+
_check_dtype_compatibility(processed_dfs)
|
|
507
|
+
result_df = _stack_frames(processed_dfs, [d.shape[0] for d in all_dms])
|
|
508
|
+
if fill_na is not None:
|
|
509
|
+
result_df = result_df.fill_null(fill_na).fill_nan(fill_na)
|
|
510
|
+
return _copy_with(
|
|
511
|
+
dm,
|
|
512
|
+
result_df,
|
|
513
|
+
confounds=_merge_ordered([all_new_confounds]),
|
|
514
|
+
convolved=_merge_ordered([all_new_convolved]),
|
|
515
|
+
multi=True,
|
|
516
|
+
n_rows=sum(d.shape[0] for d in all_dms),
|
|
517
|
+
run_count=next_run,
|
|
518
|
+
)
|