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,124 @@
1
+ """A synthetic Haxby-like dataset, built the way `Simulator` builds data.
2
+
3
+ Despite living beside the fetchers in `nltools.datasets`, `load_haxby_example`
4
+ downloads nothing: it injects condition-specific signal into a tiny seeded
5
+ volume. That makes it a simulator, so its implementation sits here.
6
+ """
7
+
8
+ from nltools.data.braindata import BrainData
9
+ from nltools.data.designmatrix import DesignMatrix
10
+ from nltools.io.events import events_to_dm
11
+
12
+
13
+ _HAXBY_CONDITIONS = (
14
+ "face",
15
+ "house",
16
+ "cat",
17
+ "bottle",
18
+ "scissors",
19
+ "shoe",
20
+ "chair",
21
+ "scrambledpix",
22
+ )
23
+
24
+
25
+ def load_haxby_example(n_runs=1, random_state=42):
26
+ """Load a small synthetic Haxby-like dataset, entirely in-memory.
27
+
28
+ Returns paired lists of `BrainData` and `DesignMatrix`, one entry per
29
+ run, generated from a tiny synthetic volume (10 x 10 x 5 = 500 voxels)
30
+ with condition-specific signal injected into disjoint voxel clusters.
31
+ No network I/O, no disk I/O, no nilearn fetcher dependency. Runs in
32
+ well under a second.
33
+
34
+ Intended for tutorials, documentation examples, and tests where
35
+ downloading a real fMRI dataset is impractical. The
36
+ eight conditions match the real Haxby 2001 object-recognition experiment
37
+ (face, house, cat, bottle, scissors, shoe, chair, scrambledpix), arranged
38
+ in a randomized 9-TR block design with TR=2.5s.
39
+
40
+ Args:
41
+ n_runs (int): Number of runs to generate. Default 1.
42
+ random_state (int | None): Seed for reproducible output. Default 42.
43
+
44
+ Returns:
45
+ tuple: `(list[BrainData], list[DesignMatrix])`, each of length `n_runs`.
46
+ The DesignMatrix columns are the eight condition names suffixed
47
+ with `_c0` (HRF-convolved boxcars).
48
+
49
+ Examples:
50
+ ```python
51
+ from nltools.datasets import load_haxby_example
52
+
53
+ brain_data, design_matrices = load_haxby_example()
54
+ data, dm = brain_data[0], design_matrices[0]
55
+ data.shape # → (72, 500)
56
+ "face_c0" in dm.columns # → True
57
+ ```
58
+ """
59
+ import numpy as np
60
+ import pandas as pd
61
+ import nibabel as nib
62
+
63
+ rng = np.random.default_rng(random_state)
64
+
65
+ TR = 2.5
66
+ block_tr = 9 # 22.5s blocks, same order of magnitude as real Haxby
67
+ n_conditions = len(_HAXBY_CONDITIONS)
68
+ n_timepoints = block_tr * n_conditions # 72 TRs per run
69
+ spatial_shape = (10, 10, 5)
70
+ n_voxels = int(np.prod(spatial_shape))
71
+ affine = np.diag([3.0, 3.0, 3.0, 1.0]).astype(np.float32)
72
+ mask_img = nib.Nifti1Image(np.ones(spatial_shape, dtype=np.float32), affine)
73
+
74
+ brain_data_list = []
75
+ design_matrix_list = []
76
+
77
+ for _ in range(n_runs):
78
+ order = list(rng.permutation(_HAXBY_CONDITIONS))
79
+ events_df = pd.DataFrame(
80
+ [
81
+ {
82
+ "onset": i * block_tr * TR,
83
+ "duration": block_tr * TR,
84
+ "trial_type": cond,
85
+ }
86
+ for i, cond in enumerate(order)
87
+ ]
88
+ )
89
+ dm_data = events_to_dm(
90
+ events_df,
91
+ run_length=n_timepoints,
92
+ sampling_freq=1.0 / TR,
93
+ )
94
+ dm = DesignMatrix(dm_data, sampling_freq=1.0 / TR).convolve()
95
+
96
+ # Positive BOLD-like baseline intensity + voxelwise Gaussian noise,
97
+ # plus signal injected into a disjoint voxel cluster per condition
98
+ # so contrasts produce real spatial patterns. The positive baseline
99
+ # is required so percent-signal-change scaling inside GLM fits
100
+ # (which divides by the voxel mean) stays numerically sane.
101
+ baseline = 100.0
102
+ noise_sd = 1.0
103
+ signal_strength = 5.0 # BOLD units → clearly visible pattern in plots
104
+ data_flat = (
105
+ baseline + noise_sd * rng.standard_normal((n_voxels, n_timepoints))
106
+ ).astype(np.float32)
107
+ voxels_per_cond = n_voxels // n_conditions
108
+ voxel_order = rng.permutation(n_voxels)
109
+ for c_idx, cond in enumerate(_HAXBY_CONDITIONS):
110
+ cond_col = f"{cond}_c0"
111
+ if cond_col not in dm.columns:
112
+ continue
113
+ regressor = np.asarray(dm[cond_col], dtype=np.float32)
114
+ cluster = voxel_order[
115
+ c_idx * voxels_per_cond : (c_idx + 1) * voxels_per_cond
116
+ ]
117
+ data_flat[cluster, :] += signal_strength * regressor
118
+
119
+ data_4d = data_flat.reshape((*spatial_shape, n_timepoints))
120
+ img = nib.Nifti1Image(data_4d, affine)
121
+ brain_data_list.append(BrainData(img, mask=mask_img, verbose=0))
122
+ design_matrix_list.append(dm)
123
+
124
+ return brain_data_list, design_matrix_list
@@ -0,0 +1,83 @@
1
+ """Frame validation shared by the data classes.
2
+
3
+ `BrainData.X`/`.Y` and `Adjacency.Y` accept the same range of tabular inputs
4
+ and store the same polars frame, so the ingress check lives here rather than
5
+ inside either class.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import polars as pl
12
+
13
+
14
+ def _validate_frame(frame, data_shape=None, frame_type="DataFrame"):
15
+ """Validate and process an X or Y frame for a data class.
16
+
17
+ Accepts pandas DataFrames for user convenience but always returns a
18
+ polars DataFrame. Internal data-class state is polars-only.
19
+
20
+ Args:
21
+ frame (pl.DataFrame | pd.DataFrame | dict | np.ndarray | str | Path | None):
22
+ Input to validate: ``None``, a path to a CSV, a polars or pandas
23
+ DataFrame, a dict of columns, or a 1D/2D numpy array.
24
+ data_shape (tuple | None): Data shape to validate the row count against.
25
+ frame_type (str): Name of the frame for error messages (e.g. ``"X"``,
26
+ ``"Y"``).
27
+
28
+ Returns:
29
+ pl.DataFrame: Validated frame as polars. Empty ``pl.DataFrame()`` when
30
+ ``frame`` is ``None``.
31
+
32
+ Raises:
33
+ TypeError: If frame is not a supported type.
34
+ ValueError: If frame rows do not match ``data_shape[0]`` or CSV read fails.
35
+ """
36
+ if frame is None:
37
+ return pl.DataFrame()
38
+
39
+ # Unwrap DesignMatrix to its underlying polars DataFrame — DM-specific
40
+ # metadata (sampling_freq, convolved, confounds) isn't preserved on
41
+ # BrainData, but users should be able to hand a DM in directly.
42
+ from nltools.data.designmatrix import DesignMatrix
43
+
44
+ if isinstance(frame, DesignMatrix):
45
+ frame = frame.data
46
+
47
+ if isinstance(frame, pl.DataFrame):
48
+ out = frame
49
+ elif isinstance(frame, (str, Path)):
50
+ try:
51
+ out = pl.read_csv(frame, has_header=False)
52
+ except Exception as e:
53
+ raise ValueError(
54
+ f"Could not read {frame_type} from file '{frame}'. "
55
+ f"Make sure the file exists and is a valid CSV. Error: {e}"
56
+ )
57
+ elif isinstance(frame, dict):
58
+ out = pl.DataFrame(frame)
59
+ elif isinstance(frame, np.ndarray):
60
+ arr = frame if frame.ndim == 2 else frame.reshape(-1, 1)
61
+ out = pl.DataFrame(arr)
62
+ else:
63
+ try:
64
+ import pandas as pd
65
+ except ImportError:
66
+ pd = None
67
+ if pd is not None and isinstance(frame, pd.DataFrame):
68
+ out = pl.DataFrame({str(c): frame[c].to_numpy() for c in frame.columns})
69
+ else:
70
+ raise TypeError(
71
+ f"{frame_type} must be a filepath (str/Path), numpy array, dict, or "
72
+ f"polars/pandas DataFrame. Received {type(frame).__name__}"
73
+ )
74
+
75
+ if not out.is_empty() and data_shape is not None:
76
+ if out.shape[0] != data_shape[0]:
77
+ raise ValueError(
78
+ f"{frame_type} rows ({out.shape[0]}) do not match "
79
+ f"data rows ({data_shape[0]}). Each row in {frame_type} should "
80
+ f"correspond to an image in the data."
81
+ )
82
+
83
+ return out
nltools/datasets.py ADDED
@@ -0,0 +1,218 @@
1
+ """Dataset, resource, and atlas lookups.
2
+
3
+ Functions to fetch example datasets, bundled resources, and parcellations. The
4
+ curated example datasets (`fetch_pain`, `fetch_emotion_ratings`) are hosted on
5
+ the ``nltools/niftis`` Hugging Face dataset and resolve through the same
6
+ `fetch_resource` machinery as the MNI templates and atlases. Arbitrary
7
+ Neurovault collections are available via `fetch_neurovault_collection`, and
8
+ `list_atlases` / `load_atlas` / `label_coords` cover the parcellations.
9
+ """
10
+
11
+ __all__ = [
12
+ "download_nifti",
13
+ "fetch_emotion_ratings",
14
+ "fetch_neurovault_collection",
15
+ "fetch_pain",
16
+ "fetch_resource",
17
+ "get_resource_path",
18
+ "label_coords",
19
+ "list_atlases",
20
+ "list_resources",
21
+ "load_atlas",
22
+ "load_haxby_example",
23
+ ]
24
+
25
+ import io
26
+ from contextlib import nullcontext, redirect_stdout
27
+ from os.path import dirname, join, sep as pathsep
28
+ from pathlib import Path
29
+
30
+ from nltools.data import BrainData
31
+ from nltools.data.atlases import label_coords, list_atlases, load_atlas
32
+ from nltools.data.simulator.haxby import load_haxby_example
33
+ from nltools.templates import fetch_resource, list_resources
34
+
35
+ # Core dependencies
36
+ from nilearn.datasets import fetch_neurovault_ids
37
+
38
+ # Optional dependencies
39
+ try:
40
+ import requests
41
+ except ImportError:
42
+ requests = None
43
+
44
+
45
+ def get_resource_path():
46
+ """Get the path to the nltools resource directory.
47
+
48
+ Returns:
49
+ str: Absolute path to `nltools/resources/`, with a trailing separator.
50
+ """
51
+ return join(dirname(__file__), "resources") + pathsep
52
+
53
+
54
+ # Curated datasets hosted on the ``nltools/niftis`` HF dataset. Each directory
55
+ # holds a ``metadata.csv`` whose ``filename`` column is the image manifest.
56
+ _PAIN_DIR = "datasets/pain"
57
+ _EMOTION_DIR = "datasets/emotion_ratings"
58
+
59
+
60
+ def download_nifti(url, data_dir=None):
61
+ """Download an image from a URL to a nifti file.
62
+
63
+ Args:
64
+ url (str): URL of the image to download
65
+ data_dir (str, optional): Directory to save the file. If None, uses current directory.
66
+
67
+ Returns:
68
+ str: Path to the downloaded file
69
+
70
+ Raises:
71
+ ImportError: If requests is not available
72
+ ValueError: If URL is invalid
73
+ """
74
+ if requests is None:
75
+ raise ImportError("requests package is required for downloading files")
76
+
77
+ if not url:
78
+ raise ValueError("URL cannot be empty")
79
+ if isinstance(url, Path):
80
+ url = str(url)
81
+
82
+ local_filename = url.split("/")[-1]
83
+ if data_dir is not None:
84
+ data_dir = Path(data_dir)
85
+ data_dir.mkdir(parents=True, exist_ok=True)
86
+ local_filename = data_dir / local_filename
87
+
88
+ try:
89
+ with requests.get(url, stream=True, timeout=(10, 60)) as r:
90
+ r.raise_for_status()
91
+
92
+ with open(local_filename, "wb") as f:
93
+ for chunk in r.iter_content(chunk_size=1024):
94
+ if chunk: # filter out keep-alive new chunks
95
+ f.write(chunk)
96
+ except requests.RequestException as e:
97
+ raise ValueError(f"Failed to download {url}: {e}")
98
+
99
+ return str(local_filename)
100
+
101
+
102
+ def fetch_neurovault_collection(collection_id, data_dir=None, verbose=1):
103
+ """Download images and metadata from a Neurovault collection.
104
+
105
+ This function uses the modern nilearn API to download collections from Neurovault.
106
+
107
+ Args:
108
+ collection_id (int): Neurovault collection ID
109
+ data_dir (str, optional): Directory to store downloaded data.
110
+ If None, uses nilearn's default data directory.
111
+ verbose (int, optional): Verbosity level; `0` is silent, including the
112
+ data-directory line nilearn reports whatever it is asked for.
113
+ Default: 1
114
+
115
+ Returns:
116
+ tuple[pl.DataFrame, list[str]]: `(metadata, files)` — the image metadata
117
+ table and the downloaded image paths.
118
+
119
+ Raises:
120
+ ValueError: If collection_id is invalid
121
+ RuntimeError: If download fails
122
+ """
123
+ import polars as pl
124
+
125
+ if not isinstance(collection_id, int) or collection_id <= 0:
126
+ raise ValueError("collection_id must be a positive integer")
127
+
128
+ try:
129
+ # nilearn resolves its data directory with `get_dataset_dir("neurovault",
130
+ # data_dir)` without forwarding `verbose`, so it announces that
131
+ # directory's absolute path however quietly it was asked to work.
132
+ # `verbose=0` has to mean silence: anything that captures a session and
133
+ # publishes it — a notebook, the docs build — would otherwise carry the
134
+ # path of the machine that ran it.
135
+ quiet = redirect_stdout(io.StringIO()) if verbose == 0 else nullcontext()
136
+ with quiet:
137
+ nv_data = fetch_neurovault_ids(
138
+ collection_ids=[collection_id], data_dir=data_dir, verbose=verbose
139
+ )
140
+
141
+ files = nv_data["images"]
142
+ metadata = pl.DataFrame(nv_data["images_meta"])
143
+
144
+ return metadata, files
145
+
146
+ except Exception as e:
147
+ raise RuntimeError(f"Failed to download collection {collection_id}: {e}")
148
+
149
+
150
+ def fetch_pain(verbose=0):
151
+ """Download and load the pain dataset from the nltools HF dataset.
152
+
153
+ Loads the Chang et al. (2015) pain-perception study: 28 subjects x 3
154
+ stimulus-intensity conditions = 84 whole-brain contrast images, with a
155
+ curated metadata table (`SubjectID`, `PainLevel`, `PainIntensity`, `Age`,
156
+ `Sex`, provenance `neurovault_id` / `name`).
157
+
158
+ Data is hosted on the ``nltools/niftis`` Hugging Face dataset and cached
159
+ locally on first use, so this works with no extra setup.
160
+
161
+ Args:
162
+ verbose (int, optional): Verbosity passed to `BrainData` while loading.
163
+ Default: 0
164
+
165
+ Returns:
166
+ BrainData: `BrainData` with the 84 images; `X` holds the metadata table.
167
+
168
+ References:
169
+ Chang, L. J., Gianaros, P. J., Manuck, S. B., Krishnan, A., & Wager, T. D. (2015).
170
+ A sensitive and specific neural signature for picture-induced negative affect.
171
+ PLoS biology, 13(6), e1002180.
172
+ """
173
+ import polars as pl
174
+
175
+ try:
176
+ metadata = pl.read_csv(fetch_resource(f"{_PAIN_DIR}/metadata.csv"))
177
+ files = [fetch_resource(f"{_PAIN_DIR}/{fn}") for fn in metadata["filename"]]
178
+ return BrainData(data=files, X=metadata, verbose=verbose)
179
+
180
+ except Exception as e:
181
+ raise RuntimeError(f"Failed to fetch pain dataset: {e}")
182
+
183
+
184
+ def fetch_emotion_ratings(verbose=0):
185
+ """Download and load the emotion-rating dataset from the nltools HF dataset.
186
+
187
+ Loads the Chang et al. (2015) IAPS emotion-rating study: 679 whole-brain
188
+ contrast images across 150 subjects, each rating images 1-5, with a
189
+ built-in train/test holdout split. `X` carries the full portable Neurovault
190
+ metadata (key columns: `SubjectID`, `Rating`, `Holdout`, `AGE`, `SEX`).
191
+
192
+ Data is hosted on the ``nltools/niftis`` Hugging Face dataset and cached
193
+ locally on first use, so this works with no extra setup.
194
+
195
+ Args:
196
+ verbose (int, optional): Verbosity passed to `BrainData` while loading.
197
+ Default: 0
198
+
199
+ Returns:
200
+ BrainData: `BrainData` with the 679 images; `X` holds the metadata table.
201
+
202
+ References:
203
+ Chang, L. J., Gianaros, P. J., Manuck, S. B., Krishnan, A., & Wager, T. D. (2015).
204
+ A sensitive and specific neural signature for picture-induced negative affect.
205
+ PLoS biology, 13(6), e1002180.
206
+ """
207
+ import polars as pl
208
+
209
+ try:
210
+ metadata = pl.read_csv(fetch_resource(f"{_EMOTION_DIR}/metadata.csv"))
211
+ files = [
212
+ fetch_resource(f"{_EMOTION_DIR}/{fn}")
213
+ for fn in metadata["filename"].to_list()
214
+ ]
215
+ return BrainData(data=files, X=metadata, verbose=verbose)
216
+
217
+ except Exception as e:
218
+ raise RuntimeError(f"Failed to fetch emotion ratings dataset: {e}")
nltools/io/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """nltools I/O utilities.
2
+
3
+ `events_to_dm` turns a BIDS events table into boxcar regressors. HDF5
4
+ serialization for the data classes lives in `nltools.io.h5`; users reach it
5
+ through `BrainData.write`/`Adjacency.write` and the constructors.
6
+ """
7
+
8
+ from .events import events_to_dm
9
+
10
+ __all__ = ["events_to_dm"]
nltools/io/events.py ADDED
@@ -0,0 +1,67 @@
1
+ """Convert BIDS events tables into design-matrix regressors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+ import polars as pl
9
+
10
+ if TYPE_CHECKING:
11
+ import pandas as pd
12
+
13
+
14
+ def events_to_dm(
15
+ events: pl.DataFrame | pd.DataFrame,
16
+ *,
17
+ run_length: int,
18
+ sampling_freq: float,
19
+ ) -> pl.DataFrame:
20
+ """Convert a BIDS events table to boxcar regressors aligned to TRs.
21
+
22
+ Uses `nilearn.glm.first_level.make_first_level_design_matrix` with
23
+ `hrf_model=None` to sample events onto the TR grid without HRF
24
+ convolution — the caller is expected to call `DesignMatrix.convolve()`
25
+ explicitly when convolution is desired. Drops nilearn's auto-added
26
+ `constant` column; users add the intercept via `add_poly(0)`.
27
+
28
+ Args:
29
+ events (pl.DataFrame | pd.DataFrame): Events table with BIDS columns
30
+ `onset`, `duration`, `trial_type` (required); `modulation` is
31
+ passed through if present.
32
+ run_length (int): Number of TRs the run contains.
33
+ sampling_freq (float): Sampling frequency in Hz (= 1/TR).
34
+
35
+ Returns:
36
+ pl.DataFrame: One column per unique `trial_type`, values in
37
+ {0, modulation} indicating where each condition is active.
38
+
39
+ Examples:
40
+ ```python
41
+ import polars as pl
42
+ from nltools.io import events_to_dm
43
+
44
+ events = pl.DataFrame(
45
+ {"onset": [0.0, 10.0], "duration": [5.0, 5.0], "trial_type": ["a", "b"]}
46
+ )
47
+ regressors = events_to_dm(events, run_length=20, sampling_freq=0.5)
48
+ ```
49
+ """
50
+ import pandas as pd
51
+ from nilearn.glm.first_level import make_first_level_design_matrix
52
+
53
+ if isinstance(events, pl.DataFrame):
54
+ events = pd.DataFrame(events.to_dict(as_series=False))
55
+
56
+ tr = 1.0 / sampling_freq
57
+ frame_times = np.arange(run_length) * tr
58
+ dm = make_first_level_design_matrix(
59
+ frame_times,
60
+ events=events,
61
+ hrf_model=None,
62
+ drift_model=None,
63
+ )
64
+ if "constant" in dm.columns:
65
+ dm = dm.drop(columns=["constant"])
66
+ # Avoid pyarrow dep on the pandas → polars hop.
67
+ return pl.DataFrame({str(c): dm[c].to_numpy() for c in dm.columns})