hypline 0.2.0__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.
- hypline/__init__.py +5 -0
- hypline/cli.py +133 -0
- hypline/enums.py +23 -0
- hypline/regression.py +433 -0
- hypline/schemas.py +28 -0
- hypline/utils.py +23 -0
- hypline-0.2.0.dist-info/METADATA +54 -0
- hypline-0.2.0.dist-info/RECORD +11 -0
- hypline-0.2.0.dist-info/WHEEL +4 -0
- hypline-0.2.0.dist-info/entry_points.txt +2 -0
- hypline-0.2.0.dist-info/licenses/LICENSE +19 -0
hypline/__init__.py
ADDED
hypline/cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import multiprocessing as mp
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich import print
|
|
6
|
+
from typing_extensions import Annotated
|
|
7
|
+
|
|
8
|
+
from .regression import ConfoundRegression
|
|
9
|
+
from .utils import DillProcess
|
|
10
|
+
|
|
11
|
+
app = typer.Typer()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def clean(
|
|
16
|
+
fmriprep_dir: Annotated[
|
|
17
|
+
str,
|
|
18
|
+
typer.Argument(
|
|
19
|
+
help="Directory containing fMRIPrep outputs",
|
|
20
|
+
show_default=False,
|
|
21
|
+
),
|
|
22
|
+
],
|
|
23
|
+
config_file: Annotated[
|
|
24
|
+
str,
|
|
25
|
+
typer.Argument(
|
|
26
|
+
help="Configuration file",
|
|
27
|
+
show_default=False,
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
model_name: Annotated[
|
|
31
|
+
str,
|
|
32
|
+
typer.Argument(
|
|
33
|
+
help="Confound regression model to run (defined in config)",
|
|
34
|
+
show_default=False,
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
output_dir: Annotated[
|
|
38
|
+
str | None,
|
|
39
|
+
typer.Option(
|
|
40
|
+
help="Directory to store cleaned BOLD data",
|
|
41
|
+
show_default="<fmriprep_dir>_cleaned",
|
|
42
|
+
),
|
|
43
|
+
] = None,
|
|
44
|
+
custom_confounds_dir: Annotated[
|
|
45
|
+
str | None,
|
|
46
|
+
typer.Option(help="Directory containing custom confounds"),
|
|
47
|
+
] = None,
|
|
48
|
+
subject_ids_input: Annotated[
|
|
49
|
+
str,
|
|
50
|
+
typer.Option(
|
|
51
|
+
"--subject-ids",
|
|
52
|
+
help="Target subject IDs (comma-separated, with no spaces)",
|
|
53
|
+
show_default="all",
|
|
54
|
+
),
|
|
55
|
+
] = "*",
|
|
56
|
+
session_name: Annotated[
|
|
57
|
+
str,
|
|
58
|
+
typer.Option(
|
|
59
|
+
help="Target session name",
|
|
60
|
+
show_default="all",
|
|
61
|
+
),
|
|
62
|
+
] = "*",
|
|
63
|
+
task_name: Annotated[
|
|
64
|
+
str,
|
|
65
|
+
typer.Option(
|
|
66
|
+
help="Target task name",
|
|
67
|
+
show_default="all",
|
|
68
|
+
),
|
|
69
|
+
] = "*",
|
|
70
|
+
data_space_name: Annotated[
|
|
71
|
+
str,
|
|
72
|
+
typer.Option(help="Target BOLD data space"),
|
|
73
|
+
] = "MNI152NLin2009cAsym",
|
|
74
|
+
n_processes: Annotated[
|
|
75
|
+
int,
|
|
76
|
+
typer.Option(help="Number of processes to run in parallel"),
|
|
77
|
+
] = 1,
|
|
78
|
+
):
|
|
79
|
+
"""
|
|
80
|
+
Clean BOLD data in the given fMRIPrep outputs.
|
|
81
|
+
"""
|
|
82
|
+
if subject_ids_input == "*":
|
|
83
|
+
subject_ids = [
|
|
84
|
+
path.name[4:] for path in Path(fmriprep_dir).glob("sub-*") if path.is_dir()
|
|
85
|
+
]
|
|
86
|
+
else:
|
|
87
|
+
subject_ids = subject_ids_input.split(",")
|
|
88
|
+
|
|
89
|
+
def clean_bold(subject_ids: list[str]):
|
|
90
|
+
reg = ConfoundRegression(
|
|
91
|
+
config_file=config_file,
|
|
92
|
+
fmriprep_dir=fmriprep_dir,
|
|
93
|
+
output_dir=output_dir,
|
|
94
|
+
custom_confounds_dir=custom_confounds_dir,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
reg.clean_bold(
|
|
98
|
+
model_name=model_name,
|
|
99
|
+
subject_ids=subject_ids,
|
|
100
|
+
session_name=session_name,
|
|
101
|
+
task_name=task_name,
|
|
102
|
+
data_space_name=data_space_name,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if n_processes < 2:
|
|
106
|
+
clean_bold(subject_ids)
|
|
107
|
+
else:
|
|
108
|
+
if mp.current_process().name == "MainProcess":
|
|
109
|
+
max_processes = mp.cpu_count()
|
|
110
|
+
if n_processes > max_processes:
|
|
111
|
+
n_processes = max_processes
|
|
112
|
+
print(
|
|
113
|
+
"[bold yellow]Warning:[/bold yellow] "
|
|
114
|
+
"Requested processes exceed available CPU "
|
|
115
|
+
"cores. Resetting to match available cores."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
processes: list[DillProcess] = []
|
|
119
|
+
for i in range(n_processes):
|
|
120
|
+
p = DillProcess(target=clean_bold, args=(subject_ids[i::n_processes],))
|
|
121
|
+
processes.append(p)
|
|
122
|
+
p.start()
|
|
123
|
+
for p in processes:
|
|
124
|
+
p.join()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.callback()
|
|
128
|
+
def callback():
|
|
129
|
+
"""
|
|
130
|
+
An opinionated framework/toolbox for conducting
|
|
131
|
+
data cleaning and analysis in hyperscanning studies
|
|
132
|
+
involving dyadic conversations.
|
|
133
|
+
"""
|
hypline/enums.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CompCorMethod(Enum):
|
|
5
|
+
ANATOMICAL = "aCompCor"
|
|
6
|
+
TEMPORAL = "tCompCor"
|
|
7
|
+
MEAN = "Mean"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompCorTissue(Enum):
|
|
11
|
+
CSF = "CSF"
|
|
12
|
+
WM = "WM"
|
|
13
|
+
COMBINED = "combined"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SurfaceSpace(Enum):
|
|
17
|
+
FS_AVERAGE_5 = "fsaverage5"
|
|
18
|
+
FS_AVERAGE_6 = "fsaverage6"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VolumeSpace(Enum):
|
|
22
|
+
MNI_152_NLIN_6_ASYM = "MNI152NLin6Asym"
|
|
23
|
+
MNI_152_NLIN_2009_C_ASYM = "MNI152NLin2009cAsym"
|
hypline/regression.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
|
|
8
|
+
import nibabel as nib
|
|
9
|
+
import numpy as np
|
|
10
|
+
import polars as pl
|
|
11
|
+
import yaml
|
|
12
|
+
from natsort import natsorted
|
|
13
|
+
from nibabel.gifti import GiftiDataArray, GiftiImage
|
|
14
|
+
from nibabel.nifti1 import Nifti1Image
|
|
15
|
+
from nilearn import image as nimg
|
|
16
|
+
from nilearn import signal
|
|
17
|
+
from pydantic import TypeAdapter
|
|
18
|
+
from rich import print
|
|
19
|
+
from rich.progress import track
|
|
20
|
+
|
|
21
|
+
from .enums import CompCorMethod, CompCorTissue, SurfaceSpace, VolumeSpace
|
|
22
|
+
from .schemas import CompCorOptions, Config, ConfoundMetadata, ModelSpec
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfoundRegression:
|
|
26
|
+
# Read-only mapping between a data space name and its enum variant
|
|
27
|
+
DATA_SPACES = MappingProxyType(
|
|
28
|
+
{space.value: space for space in list(VolumeSpace) + list(SurfaceSpace)}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
config_file: str,
|
|
34
|
+
fmriprep_dir: str,
|
|
35
|
+
output_dir: str | None = None,
|
|
36
|
+
custom_confounds_dir: str | None = None,
|
|
37
|
+
):
|
|
38
|
+
# Parse and validate config data
|
|
39
|
+
config_filepath = Path(config_file)
|
|
40
|
+
if config_filepath.exists() is False:
|
|
41
|
+
raise FileNotFoundError(f"Path does not exist: {config_file}")
|
|
42
|
+
self._config = Config.model_validate(
|
|
43
|
+
yaml.safe_load(config_filepath.read_text())
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Set the directory path to fMRIPrep data
|
|
47
|
+
self._fmriprep_dir = Path(fmriprep_dir)
|
|
48
|
+
if self._fmriprep_dir.exists() is False:
|
|
49
|
+
raise FileNotFoundError(f"Path does not exist: {fmriprep_dir}")
|
|
50
|
+
|
|
51
|
+
# Set the directory path to store cleaned outputs
|
|
52
|
+
self._output_dir = (
|
|
53
|
+
Path(output_dir)
|
|
54
|
+
if output_dir
|
|
55
|
+
else self._fmriprep_dir.with_name(self._fmriprep_dir.name + "_cleaned")
|
|
56
|
+
)
|
|
57
|
+
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
# Set the directory path to custom confounds
|
|
60
|
+
self._custom_confounds_dir = None
|
|
61
|
+
if custom_confounds_dir:
|
|
62
|
+
self._custom_confounds_dir = Path(custom_confounds_dir)
|
|
63
|
+
if self._custom_confounds_dir.exists() is False:
|
|
64
|
+
raise FileNotFoundError(f"Path does not exist: {custom_confounds_dir}")
|
|
65
|
+
|
|
66
|
+
# Create logging-related attributes
|
|
67
|
+
self._log_dir = self._output_dir / "logs"
|
|
68
|
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self._logger = logging.getLogger(__name__)
|
|
70
|
+
self._logger.setLevel(logging.INFO)
|
|
71
|
+
self._formatter = logging.Formatter(
|
|
72
|
+
fmt="%(asctime)s - %(levelname)s - %(message)s",
|
|
73
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def config(self) -> Config:
|
|
78
|
+
return deepcopy(self._config)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def fmriprep_dir(self) -> Path:
|
|
82
|
+
return deepcopy(self._fmriprep_dir)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def output_dir(self) -> Path:
|
|
86
|
+
return deepcopy(self._output_dir)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def custom_confounds_dir(self) -> Path | None:
|
|
90
|
+
return deepcopy(self._custom_confounds_dir)
|
|
91
|
+
|
|
92
|
+
def clean_bold(
|
|
93
|
+
self,
|
|
94
|
+
model_name: str,
|
|
95
|
+
subject_ids: list[str],
|
|
96
|
+
session_name: str = "*",
|
|
97
|
+
task_name: str = "*",
|
|
98
|
+
data_space_name: str = "MNI152NLin2009cAsym",
|
|
99
|
+
):
|
|
100
|
+
# Mapping between a data space type and the corresponding method
|
|
101
|
+
CLEAN_BOLD = {
|
|
102
|
+
VolumeSpace: self._clean_bold_in_volume_space,
|
|
103
|
+
SurfaceSpace: self._clean_bold_in_surface_space,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
model_spec = self._config.model_specs.get(model_name)
|
|
107
|
+
if model_spec is None:
|
|
108
|
+
raise ValueError(f"Undefined model: {model_name}")
|
|
109
|
+
|
|
110
|
+
data_space = self.DATA_SPACES.get(data_space_name)
|
|
111
|
+
data_space_type = type(data_space)
|
|
112
|
+
if data_space_type not in CLEAN_BOLD:
|
|
113
|
+
raise ValueError(f"Unsupported data space: {data_space_name}")
|
|
114
|
+
|
|
115
|
+
for sub_id in track(subject_ids, description="Processing..."):
|
|
116
|
+
file_handler = logging.FileHandler(self._log_dir / f"sub-{sub_id}.log")
|
|
117
|
+
file_handler.setFormatter(self._formatter)
|
|
118
|
+
self._logger.addHandler(file_handler)
|
|
119
|
+
|
|
120
|
+
bold_pattern = self._compose_glob_pattern_for_bold(
|
|
121
|
+
subject_id=sub_id,
|
|
122
|
+
session_name=session_name,
|
|
123
|
+
task_name=task_name,
|
|
124
|
+
data_space=data_space,
|
|
125
|
+
)
|
|
126
|
+
bold_filepaths = self._fmriprep_dir.glob(bold_pattern)
|
|
127
|
+
|
|
128
|
+
for filepath in bold_filepaths:
|
|
129
|
+
self._logger.info("Cleaning starting: %s", filepath.name)
|
|
130
|
+
try:
|
|
131
|
+
CLEAN_BOLD[data_space_type](filepath, model_spec)
|
|
132
|
+
self._logger.info("Cleaning complete: %s", filepath.name)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
self._logger.error(e)
|
|
135
|
+
print("[red]Processing failed:[/red]", filepath.name)
|
|
136
|
+
|
|
137
|
+
self._logger.removeHandler(file_handler) # Reset for next iteration
|
|
138
|
+
|
|
139
|
+
def _clean_bold_in_volume_space(self, filepath: Path, model_spec: ModelSpec):
|
|
140
|
+
# Read raw BOLD data
|
|
141
|
+
bold = nimg.load_img(filepath) # Shape of (x, y, z, TRs)
|
|
142
|
+
assert isinstance(bold, Nifti1Image)
|
|
143
|
+
|
|
144
|
+
# Extract TR value (assumed constant in a given run)
|
|
145
|
+
p = filepath.parent / (filepath.name.split(".")[0] + ".json")
|
|
146
|
+
with open(p, "r") as f:
|
|
147
|
+
data = json.load(f)
|
|
148
|
+
assert isinstance(data, dict)
|
|
149
|
+
repetition_time = data.get("RepetitionTime") # In seconds
|
|
150
|
+
if repetition_time is None:
|
|
151
|
+
raise ValueError(f"TR metadata is missing: {p.name}")
|
|
152
|
+
TR = float(repetition_time)
|
|
153
|
+
|
|
154
|
+
# Load confounds for the requested model
|
|
155
|
+
confounds_df = self._load_confounds(filepath, model_spec)
|
|
156
|
+
confounds = confounds_df.to_numpy() # Shape of (TRs, confounds)
|
|
157
|
+
if confounds.shape[0] != bold.shape[-1]:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"Unequal number of TRs between BOLD and confounds data: {filepath.name}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Perform confound regression
|
|
163
|
+
cleaned_bold = nimg.clean_img(
|
|
164
|
+
bold,
|
|
165
|
+
confounds=confounds,
|
|
166
|
+
detrend=True,
|
|
167
|
+
t_r=TR,
|
|
168
|
+
ensure_finite=True,
|
|
169
|
+
standardize="zscore_sample",
|
|
170
|
+
standardize_confounds=True,
|
|
171
|
+
)
|
|
172
|
+
assert isinstance(cleaned_bold, Nifti1Image)
|
|
173
|
+
|
|
174
|
+
# Store cleaned BOLD data
|
|
175
|
+
entities = filepath.name.split("_")
|
|
176
|
+
if entities[-2].startswith("desc-"):
|
|
177
|
+
entities[-2] = "desc-clean"
|
|
178
|
+
else:
|
|
179
|
+
entities.insert(-1, "desc-clean")
|
|
180
|
+
new_filename = "_".join(entities)
|
|
181
|
+
intermediate_dir = filepath.relative_to(self._fmriprep_dir).parent
|
|
182
|
+
new_filepath = self._output_dir / intermediate_dir / new_filename
|
|
183
|
+
new_filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
nib.save(cleaned_bold, new_filepath)
|
|
185
|
+
|
|
186
|
+
def _clean_bold_in_surface_space(self, filepath: Path, model_spec: ModelSpec):
|
|
187
|
+
# Read raw BOLD data
|
|
188
|
+
img = nib.load(filepath)
|
|
189
|
+
assert isinstance(img, GiftiImage)
|
|
190
|
+
bold = img.agg_data()
|
|
191
|
+
assert isinstance(bold, np.ndarray)
|
|
192
|
+
bold = bold.T # Shape of (TRs, voxels)
|
|
193
|
+
|
|
194
|
+
# Extract TR value (assumed constant in a given run)
|
|
195
|
+
repetition_time = img.darrays[0].meta.get("TimeStep") # In milliseconds
|
|
196
|
+
if repetition_time is None:
|
|
197
|
+
raise ValueError(f"TR metadata is missing: {filepath.name}")
|
|
198
|
+
TR = float(repetition_time) / 1000 # Convert to seconds
|
|
199
|
+
|
|
200
|
+
# Load confounds for the requested model
|
|
201
|
+
confounds_df = self._load_confounds(filepath, model_spec)
|
|
202
|
+
confounds = confounds_df.to_numpy() # Shape of (TRs, confounds)
|
|
203
|
+
if confounds.shape[0] != bold.shape[0]:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"Unequal number of TRs between BOLD and confounds data: {filepath.name}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Perform confound regression
|
|
209
|
+
cleaned_bold = signal.clean(
|
|
210
|
+
bold,
|
|
211
|
+
confounds=confounds,
|
|
212
|
+
detrend=True,
|
|
213
|
+
t_r=TR,
|
|
214
|
+
ensure_finite=True,
|
|
215
|
+
standardize="zscore_sample",
|
|
216
|
+
standardize_confounds=True,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Store cleaned BOLD data
|
|
220
|
+
new_img = GiftiImage(
|
|
221
|
+
darrays=[
|
|
222
|
+
GiftiDataArray(data=row, intent="NIFTI_INTENT_TIME_SERIES")
|
|
223
|
+
for row in cleaned_bold
|
|
224
|
+
],
|
|
225
|
+
header=img.header,
|
|
226
|
+
extra=img.extra,
|
|
227
|
+
)
|
|
228
|
+
entities = filepath.name.split("_")
|
|
229
|
+
if entities[-2].startswith("desc-"):
|
|
230
|
+
entities[-2] = "desc-clean"
|
|
231
|
+
else:
|
|
232
|
+
entities.insert(-1, "desc-clean")
|
|
233
|
+
new_filename = "_".join(entities)
|
|
234
|
+
intermediate_dir = filepath.relative_to(self._fmriprep_dir).parent
|
|
235
|
+
new_filepath = self._output_dir / intermediate_dir / new_filename
|
|
236
|
+
new_filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
nib.save(new_img, new_filepath)
|
|
238
|
+
|
|
239
|
+
def _load_confounds(
|
|
240
|
+
self, bold_filepath: Path, model_spec: ModelSpec
|
|
241
|
+
) -> pl.DataFrame:
|
|
242
|
+
# Extract file name up to the run number segment
|
|
243
|
+
match = re.search(r"^(.*?run-\d+)", bold_filepath.name)
|
|
244
|
+
if match is None:
|
|
245
|
+
raise ValueError(f"Run number is missing: {bold_filepath.name}")
|
|
246
|
+
identifier = match.group(1) # Includes subject/session/task/run info
|
|
247
|
+
|
|
248
|
+
# Load standard confounds for the requested model
|
|
249
|
+
files = bold_filepath.parent.glob(f"{identifier}*desc-confounds*timeseries.tsv")
|
|
250
|
+
confounds_filepath = next(files, None)
|
|
251
|
+
if confounds_filepath is None:
|
|
252
|
+
raise FileNotFoundError(f"Confounds not found for: {identifier}")
|
|
253
|
+
confounds_df = (
|
|
254
|
+
pl.read_csv(confounds_filepath, separator="\t")
|
|
255
|
+
.fill_nan(None) # For interpolation
|
|
256
|
+
.fill_null(strategy="backward") # Assume missing data in the beginning only
|
|
257
|
+
)
|
|
258
|
+
confounds_meta = TypeAdapter(dict[str, ConfoundMetadata]).validate_json(
|
|
259
|
+
confounds_filepath.with_suffix(".json").read_text()
|
|
260
|
+
)
|
|
261
|
+
confounds_df = self._extract_confounds(confounds_df, confounds_meta, model_spec)
|
|
262
|
+
|
|
263
|
+
# Load custom confounds for the requested model
|
|
264
|
+
if model_spec.custom_confounds:
|
|
265
|
+
files = self._custom_confounds_dir.glob(
|
|
266
|
+
f"**/{identifier}*desc-customConfounds*timeseries.tsv"
|
|
267
|
+
)
|
|
268
|
+
custom_confounds_filepath = next(files, None)
|
|
269
|
+
if custom_confounds_filepath is None:
|
|
270
|
+
raise FileNotFoundError(f"Custom confounds not found for: {identifier}")
|
|
271
|
+
custom_confounds_df = pl.read_csv(
|
|
272
|
+
custom_confounds_filepath,
|
|
273
|
+
separator="\t",
|
|
274
|
+
columns=model_spec.custom_confounds,
|
|
275
|
+
)
|
|
276
|
+
if custom_confounds_df.fill_nan(None).null_count().pipe(sum).item() > 0:
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"Missing / NaN values in custom confounds data: {identifier}"
|
|
279
|
+
)
|
|
280
|
+
if custom_confounds_df.height != confounds_df.height:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"Unequal number of rows (TRs) between standard and custom confounds data: {identifier}"
|
|
283
|
+
)
|
|
284
|
+
confounds_df = pl.concat(
|
|
285
|
+
[confounds_df, custom_confounds_df], how="horizontal"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return confounds_df
|
|
289
|
+
|
|
290
|
+
def _extract_confounds(
|
|
291
|
+
self,
|
|
292
|
+
confounds_df: pl.DataFrame,
|
|
293
|
+
confounds_meta: dict[str, ConfoundMetadata],
|
|
294
|
+
model_spec: ModelSpec,
|
|
295
|
+
) -> pl.DataFrame:
|
|
296
|
+
"""
|
|
297
|
+
Extract confounds (including CompCor ones).
|
|
298
|
+
|
|
299
|
+
Notes
|
|
300
|
+
-----
|
|
301
|
+
Adapted from https://github.com/snastase/narratives/blob/master/code/extract_confounds.py.
|
|
302
|
+
"""
|
|
303
|
+
# Pop out confound groups of variable number
|
|
304
|
+
groups = set(model_spec.confounds).intersection({"cosine", "motion_outlier"})
|
|
305
|
+
|
|
306
|
+
# Grab the requested (non-group) confounds
|
|
307
|
+
confounds = confounds_df[[c for c in model_spec.confounds if c not in groups]]
|
|
308
|
+
|
|
309
|
+
# Grab confound groups if requested
|
|
310
|
+
if groups:
|
|
311
|
+
group_cols = [
|
|
312
|
+
col
|
|
313
|
+
for col in confounds_df.columns
|
|
314
|
+
if any(group in col for group in groups)
|
|
315
|
+
]
|
|
316
|
+
confounds = pl.concat(
|
|
317
|
+
[confounds, confounds_df[group_cols]], how="horizontal"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Grab CompCor confounds if requested
|
|
321
|
+
compcors = [c for c in CompCorMethod if c.value in model_spec.model_fields_set]
|
|
322
|
+
if compcors:
|
|
323
|
+
comps_selected: list[str] = []
|
|
324
|
+
for compcor in compcors:
|
|
325
|
+
for options in getattr(model_spec, compcor.value):
|
|
326
|
+
assert isinstance(options, CompCorOptions)
|
|
327
|
+
comps_selected.extend(
|
|
328
|
+
self._select_comps(
|
|
329
|
+
confounds_meta,
|
|
330
|
+
compcor,
|
|
331
|
+
n_comps=options.n_comps,
|
|
332
|
+
tissue=options.tissue,
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
confounds = pl.concat(
|
|
336
|
+
[confounds, confounds_df[comps_selected]], how="horizontal"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return confounds
|
|
340
|
+
|
|
341
|
+
def _select_comps(
|
|
342
|
+
self,
|
|
343
|
+
confounds_meta: dict[str, ConfoundMetadata],
|
|
344
|
+
method: CompCorMethod,
|
|
345
|
+
n_comps: int | float,
|
|
346
|
+
tissue: CompCorTissue | None,
|
|
347
|
+
) -> list[str]:
|
|
348
|
+
"""
|
|
349
|
+
Select relevant CompCor components.
|
|
350
|
+
|
|
351
|
+
Notes
|
|
352
|
+
-----
|
|
353
|
+
Adapted from https://github.com/snastase/narratives/blob/master/code/extract_confounds.py.
|
|
354
|
+
"""
|
|
355
|
+
# Check that we sensible number of components
|
|
356
|
+
assert n_comps > 0
|
|
357
|
+
|
|
358
|
+
# Ignore tissue if specified for tCompCor
|
|
359
|
+
if method == CompCorMethod.TEMPORAL and tissue:
|
|
360
|
+
self._logger.warning(
|
|
361
|
+
"tCompCor is not restricted to a tissue mask - ignoring tissue specification (%s)",
|
|
362
|
+
tissue.value,
|
|
363
|
+
)
|
|
364
|
+
tissue = None
|
|
365
|
+
|
|
366
|
+
# Get CompCor metadata for relevant method
|
|
367
|
+
compcor_meta = {
|
|
368
|
+
k: v
|
|
369
|
+
for k, v in confounds_meta.items()
|
|
370
|
+
if v.Method == method and v.Retained is True
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# If aCompCor, filter metadata for tissue mask
|
|
374
|
+
if method == CompCorMethod.ANATOMICAL:
|
|
375
|
+
compcor_meta = {k: v for k, v in compcor_meta.items() if v.Mask == tissue}
|
|
376
|
+
|
|
377
|
+
# Make sure metadata components are sorted properly
|
|
378
|
+
comps_sorted = natsorted(compcor_meta)
|
|
379
|
+
for i, comp in enumerate(comps_sorted):
|
|
380
|
+
if comp != comps_sorted[-1]:
|
|
381
|
+
comp_next = comps_sorted[i + 1]
|
|
382
|
+
assert (
|
|
383
|
+
compcor_meta[comp].SingularValue
|
|
384
|
+
> compcor_meta[comp_next].SingularValue
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Either get top n components
|
|
388
|
+
if n_comps >= 1.0:
|
|
389
|
+
n_comps = int(n_comps)
|
|
390
|
+
if len(comps_sorted) >= n_comps:
|
|
391
|
+
comps_selected = comps_sorted[:n_comps]
|
|
392
|
+
else:
|
|
393
|
+
comps_selected = comps_sorted
|
|
394
|
+
self._logger.warning(
|
|
395
|
+
"Only %d %s components available (%d requested)",
|
|
396
|
+
len(comps_sorted),
|
|
397
|
+
method.value,
|
|
398
|
+
n_comps,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Or components necessary to capture n proportion of variance
|
|
402
|
+
else:
|
|
403
|
+
comps_selected = []
|
|
404
|
+
for comp in comps_sorted:
|
|
405
|
+
comps_selected.append(comp)
|
|
406
|
+
if compcor_meta[comp].CumulativeVarianceExplained > n_comps:
|
|
407
|
+
break
|
|
408
|
+
|
|
409
|
+
# Check we didn't end up with degenerate 0 components
|
|
410
|
+
assert len(comps_selected) > 0
|
|
411
|
+
|
|
412
|
+
return comps_selected
|
|
413
|
+
|
|
414
|
+
@staticmethod
|
|
415
|
+
def _compose_glob_pattern_for_bold(
|
|
416
|
+
subject_id: str,
|
|
417
|
+
session_name: str,
|
|
418
|
+
task_name: str,
|
|
419
|
+
data_space: VolumeSpace | SurfaceSpace,
|
|
420
|
+
) -> str:
|
|
421
|
+
SUFFIX_MAP = {VolumeSpace: "bold.nii.gz", SurfaceSpace: "bold.func.gii"}
|
|
422
|
+
|
|
423
|
+
subject = f"sub-{subject_id}"
|
|
424
|
+
session = "" if session_name == "*" else f"ses-{session_name}"
|
|
425
|
+
task = "" if task_name == "*" else f"task-{task_name}"
|
|
426
|
+
space = f"space-{data_space.value}"
|
|
427
|
+
suffix = SUFFIX_MAP[type(data_space)]
|
|
428
|
+
|
|
429
|
+
filepath_pattern = "*".join(
|
|
430
|
+
filter(None, [subject, session, task, space, suffix])
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
return f"{subject}/**/{filepath_pattern}"
|
hypline/schemas.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pydantic import BaseModel, PositiveFloat, PositiveInt
|
|
2
|
+
|
|
3
|
+
from .enums import CompCorMethod, CompCorTissue
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CompCorOptions(BaseModel):
|
|
7
|
+
n_comps: PositiveInt | PositiveFloat = 5
|
|
8
|
+
tissue: CompCorTissue | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfoundMetadata(BaseModel):
|
|
12
|
+
Method: CompCorMethod
|
|
13
|
+
Retained: bool | None = None
|
|
14
|
+
Mask: CompCorTissue | None = None
|
|
15
|
+
SingularValue: float | None = None
|
|
16
|
+
VarianceExplained: float | None = None
|
|
17
|
+
CumulativeVarianceExplained: float | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModelSpec(BaseModel):
|
|
21
|
+
confounds: list[str]
|
|
22
|
+
custom_confounds: list[str] | None = None
|
|
23
|
+
aCompCor: list[CompCorOptions] | None = None
|
|
24
|
+
tCompCor: list[CompCorOptions] | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Config(BaseModel):
|
|
28
|
+
model_specs: dict[str, ModelSpec]
|
hypline/utils.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from multiprocessing import Process
|
|
2
|
+
|
|
3
|
+
import dill
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DillProcess(Process):
|
|
7
|
+
"""
|
|
8
|
+
Extend the `Process` class to support serialization
|
|
9
|
+
of closures and local functions.
|
|
10
|
+
|
|
11
|
+
Notes
|
|
12
|
+
-----
|
|
13
|
+
Adapted from https://stackoverflow.com/a/72776044.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *args, **kwargs):
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
self._target = dill.dumps(self._target)
|
|
19
|
+
|
|
20
|
+
def run(self):
|
|
21
|
+
if self._target:
|
|
22
|
+
self._target = dill.loads(self._target)
|
|
23
|
+
self._target(*self._args, **self._kwargs)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hypline
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Data Cleaning and Analysis Pipeline for Hyperscanning Research
|
|
5
|
+
Project-URL: Repository, https://github.com/princeton-ddss/hypline
|
|
6
|
+
Project-URL: Documentation, https://princeton-ddss.github.io/hypline/
|
|
7
|
+
Author-email: Sangyoon Park <datumvitae@gmail.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: dill>=0.3.9
|
|
11
|
+
Requires-Dist: natsort>=8.4.0
|
|
12
|
+
Requires-Dist: nibabel>=5.3.2
|
|
13
|
+
Requires-Dist: nilearn>=0.11.1
|
|
14
|
+
Requires-Dist: numpy>=2.2.4
|
|
15
|
+
Requires-Dist: polars>=1.26.0
|
|
16
|
+
Requires-Dist: pydantic>=2.10.6
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
18
|
+
Requires-Dist: rich>=14.0.0
|
|
19
|
+
Requires-Dist: typer>=0.15.2
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Hypline
|
|
23
|
+
|
|
24
|
+
Hypline is a Python package that provides a CLI tool for cleaning and analyzing data from hyperscanning studies involving dyadic conversations.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Hypline can be installed using `pip`:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install hypline
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It can also be installed using other package managers such as [`uv`](https://docs.astral.sh/uv/) and [`poetry`](https://python-poetry.org/docs/).
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
Once the package is installed, `hypline` command will be available, like so:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
hypline --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Running the above will display an overview of the tool, including supported subcommands.
|
|
45
|
+
|
|
46
|
+
For instance, `clean` is a subcommand for performing confound regression to clean BOLD outputs from [fMRIPrep](https://fmriprep.org/en/stable/index.html), and its details can be viewed by running:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
hypline clean --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## What Next
|
|
53
|
+
|
|
54
|
+
If you want to learn more about Hypline, please check out the official project [documentation](https://princeton-ddss.github.io/hypline/).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
hypline/__init__.py,sha256=SU_ipGTn_2PkwuEufOyMdpyCU2BmU-cDpQZZeMpzF3E,45
|
|
2
|
+
hypline/cli.py,sha256=_o9ZdubSaGriO1UwaJFQkZAxyof9ki22nYH5aBh_v1Q,3638
|
|
3
|
+
hypline/enums.py,sha256=dznKn542WjYtWloHYg6wOgEaA0oM0AkmPHb0GdBycsQ,424
|
|
4
|
+
hypline/regression.py,sha256=jKFMIoxMcYWTo0xNO3rkskHBnBv0HzUM-XMilDs1CgM,16385
|
|
5
|
+
hypline/schemas.py,sha256=GqBR5ZFkk551dk6Dxf5JO8CMQvNTA6W2aost2lXwRJA,761
|
|
6
|
+
hypline/utils.py,sha256=A1ULLv9AGxwe03JBriQz-_Uf7_iRgcjAUeJPeNKnQx8,547
|
|
7
|
+
hypline-0.2.0.dist-info/METADATA,sha256=f5tYpp_got6hXfbp3MMDsjJqM98bKIiwcY3Is6s7XWY,1681
|
|
8
|
+
hypline-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
hypline-0.2.0.dist-info/entry_points.txt,sha256=6v2pTaC6l1ZUnp3ASMrlgDpRzADy9lUgbbClDIb6e_U,41
|
|
10
|
+
hypline-0.2.0.dist-info/licenses/LICENSE,sha256=tQFZEx9unYshmR4ql0X5AMdM4B4seUu6fsiN0kX6FZw,1079
|
|
11
|
+
hypline-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2025 The Trustees of Princeton University
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|