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