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