nltools 0.6.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. nltools/__init__.py +55 -0
  2. nltools/algorithms/__init__.py +90 -0
  3. nltools/algorithms/alignment/__init__.py +21 -0
  4. nltools/algorithms/alignment/procrustes.py +565 -0
  5. nltools/algorithms/alignment/srm.py +758 -0
  6. nltools/algorithms/backends.py +1059 -0
  7. nltools/algorithms/corrections.py +177 -0
  8. nltools/algorithms/decoding.py +327 -0
  9. nltools/algorithms/inference/__init__.py +50 -0
  10. nltools/algorithms/inference/bootstrap.py +1386 -0
  11. nltools/algorithms/inference/correlation.py +373 -0
  12. nltools/algorithms/inference/intersubject.py +422 -0
  13. nltools/algorithms/inference/isc.py +1554 -0
  14. nltools/algorithms/inference/matrix.py +602 -0
  15. nltools/algorithms/inference/one_sample.py +288 -0
  16. nltools/algorithms/inference/random.py +122 -0
  17. nltools/algorithms/inference/timeseries.py +347 -0
  18. nltools/algorithms/inference/two_sample.py +212 -0
  19. nltools/algorithms/inference/utils.py +58 -0
  20. nltools/algorithms/inference/validation.py +282 -0
  21. nltools/algorithms/neighborhoods.py +207 -0
  22. nltools/algorithms/outliers.py +308 -0
  23. nltools/algorithms/regression.py +83 -0
  24. nltools/algorithms/signal.py +303 -0
  25. nltools/algorithms/similarity.py +234 -0
  26. nltools/algorithms/validation.py +151 -0
  27. nltools/cross_validation.py +72 -0
  28. nltools/data/__init__.py +30 -0
  29. nltools/data/adjacency/__init__.py +875 -0
  30. nltools/data/adjacency/io.py +111 -0
  31. nltools/data/adjacency/modeling.py +569 -0
  32. nltools/data/adjacency/plotting.py +174 -0
  33. nltools/data/adjacency/state.py +349 -0
  34. nltools/data/adjacency/stats.py +596 -0
  35. nltools/data/adjacency/utils.py +79 -0
  36. nltools/data/atlases/__init__.py +23 -0
  37. nltools/data/atlases/labeling.py +158 -0
  38. nltools/data/atlases/loading.py +76 -0
  39. nltools/data/atlases/registry.py +96 -0
  40. nltools/data/atlases/reporting.py +456 -0
  41. nltools/data/braindata/__init__.py +2170 -0
  42. nltools/data/braindata/analysis.py +1381 -0
  43. nltools/data/braindata/bootstrap.py +398 -0
  44. nltools/data/braindata/io.py +896 -0
  45. nltools/data/braindata/modeling.py +594 -0
  46. nltools/data/braindata/plotting.py +501 -0
  47. nltools/data/braindata/prediction.py +1250 -0
  48. nltools/data/braindata/utils.py +348 -0
  49. nltools/data/braindata/validation.py +197 -0
  50. nltools/data/braindata/viewer.js +266 -0
  51. nltools/data/braindata/viewer.py +770 -0
  52. nltools/data/combine.py +27 -0
  53. nltools/data/designmatrix/__init__.py +1032 -0
  54. nltools/data/designmatrix/append.py +518 -0
  55. nltools/data/designmatrix/diagnostics.py +248 -0
  56. nltools/data/designmatrix/io.py +356 -0
  57. nltools/data/designmatrix/plotting.py +291 -0
  58. nltools/data/designmatrix/regressors.py +463 -0
  59. nltools/data/designmatrix/transforms.py +200 -0
  60. nltools/data/designmatrix/utils.py +350 -0
  61. nltools/data/ownership.py +129 -0
  62. nltools/data/results.py +291 -0
  63. nltools/data/roc/__init__.py +398 -0
  64. nltools/data/simulator/__init__.py +927 -0
  65. nltools/data/simulator/haxby.py +124 -0
  66. nltools/data/validation.py +83 -0
  67. nltools/datasets.py +218 -0
  68. nltools/io/__init__.py +10 -0
  69. nltools/io/events.py +67 -0
  70. nltools/io/h5.py +246 -0
  71. nltools/mask.py +403 -0
  72. nltools/models/__init__.py +11 -0
  73. nltools/models/glm.py +543 -0
  74. nltools/models/results.py +49 -0
  75. nltools/models/ridge.py +1303 -0
  76. nltools/models/validation.py +26 -0
  77. nltools/plotting/__init__.py +32 -0
  78. nltools/plotting/adjacency.py +421 -0
  79. nltools/plotting/brain.py +669 -0
  80. nltools/plotting/decomposition.py +111 -0
  81. nltools/plotting/prediction.py +110 -0
  82. nltools/resources/covariates_example.csv +161 -0
  83. nltools/resources/onsets_example.csv +40 -0
  84. nltools/templates/__init__.py +51 -0
  85. nltools/templates/config.py +144 -0
  86. nltools/templates/fetch.py +260 -0
  87. nltools/templates/matching.py +183 -0
  88. nltools/templates/paths.py +106 -0
  89. nltools/templates/registry.py +25 -0
  90. nltools/utils.py +230 -0
  91. nltools/version.py +13 -0
  92. nltools-0.6.0.dev0.dist-info/METADATA +95 -0
  93. nltools-0.6.0.dev0.dist-info/RECORD +95 -0
  94. nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
  95. nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )