Skip to content

Commit b529105

Browse files
nx10Janhavi Pillai
andcommitted
Add per-regressor regression to longitudinal functional workflow
FunctionalLongOutputs gains regressed_bold and cleaned_bold dicts keyed by regressor strategy. longitudinal_process now accepts regressor_files (raw .1D from cross-sectional) and re-runs apply_regression + apply_regression_bandpass on the warped BOLD. No regressor recomputation -- same matrix, different target space. bold_mask is now mandatory (was Path | None). BIDS export writes per-regressor desc-regressed and desc-preproc BOLD with reg-<strategy> entity. resolve_longitudinal_func resolves the raw regressor files per strategy. CLI gains --regressor with the same choices as cross-sectional (36-parameter, aCompCor). Port of the longitudinal regression logic from #282. Co-authored-by: Janhavi Pillai <janhaviiyengar@gmail.com>
1 parent a41f728 commit b529105

6 files changed

Lines changed: 147 additions & 36 deletions

File tree

src/rbc/bids/longitudinal/functional.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from rbc.bids import Suffix, bids_safe_label
88

99
if TYPE_CHECKING:
10+
from collections.abc import Sequence
1011
from pathlib import Path
1112

1213
import polars as pl
@@ -22,7 +23,8 @@ def resolve_longitudinal_func(
2223
tpl_df: pl.DataFrame,
2324
*,
2425
ses: str,
25-
) -> dict[str, Path | None]:
26+
regressors: Sequence[str] = ("36-parameter",),
27+
) -> dict[str, Path | dict[str, Path]]:
2628
"""Resolve inputs for longitudinal functional processing.
2729
2830
Args:
@@ -31,10 +33,23 @@ def resolve_longitudinal_func(
3133
func_df: DataFrame of functional derivatives.
3234
tpl_df: DataFrame of longitudinal template files.
3335
ses: Session label (used for template xfm lookup).
36+
regressors: Regressor strategy names to resolve raw regressor
37+
files for.
3438
3539
Returns:
36-
Dict with keys matching ``longitudinal_process`` parameters.
40+
Dict with keys matching ``longitudinal_process`` parameters,
41+
including ``regressor_files`` keyed by strategy name.
3742
"""
43+
regressor_files: dict[str, Path] = {}
44+
for reg in regressors:
45+
regressor_files[reg] = func_q.expect(
46+
func_df,
47+
suffix="regressors",
48+
desc=bids_safe_label(reg),
49+
extension=".1D",
50+
without=["space"],
51+
)
52+
3853
return {
3954
"template": tpl_q.expect(tpl_df, suffix="T1w"),
4055
"anat_to_template_xfm": tpl_q.expect(
@@ -54,18 +69,25 @@ def resolve_longitudinal_func(
5469
"bold": func_q.expect(
5570
func_df, suffix=Suffix.BOLD, desc="preproc", without=["space"]
5671
),
57-
"bold_mask": func_q.find(
72+
"bold_mask": func_q.expect(
5873
func_df, suffix=Suffix.MASK, desc="brain", without=["space"]
5974
),
75+
"regressor_files": regressor_files,
6076
}
6177

6278

63-
def export_longitudinal_func(fex: Bids, outputs: FunctionalLongOutputs) -> None:
79+
def export_longitudinal_func(
80+
fex: Bids,
81+
outputs: FunctionalLongOutputs,
82+
*,
83+
regressors: Sequence[str],
84+
) -> None:
6485
"""Export longitudinal functional outputs.
6586
6687
Args:
6788
fex: Bids builder with ``space="longitudinal"`` and identity entities.
6889
outputs: Results from the longitudinal functional workflow.
90+
regressors: Regressor strategy names that were applied.
6991
"""
7092
fex.save(outputs.sbref, suffix=Suffix.SBREF)
7193
fex.save(outputs.bold, suffix=Suffix.BOLD, desc="preproc")
@@ -75,5 +97,18 @@ def export_longitudinal_func(fex: Bids, outputs: FunctionalLongOutputs) -> None:
7597
desc="composite",
7698
extra={"from": "bold", "to": "longitudinal", "mode": "image"},
7799
)
78-
if outputs.bold_mask:
79-
fex.save(outputs.bold_mask, suffix=Suffix.MASK, desc="brain")
100+
fex.save(outputs.bold_mask, suffix=Suffix.MASK, desc="brain")
101+
102+
for reg in regressors:
103+
fex.save(
104+
outputs.regressed_bold[reg],
105+
suffix=Suffix.BOLD,
106+
desc="regressed",
107+
extra={"reg": bids_safe_label(reg)},
108+
)
109+
fex.save(
110+
outputs.cleaned_bold[reg],
111+
suffix=Suffix.BOLD,
112+
desc="preproc",
113+
extra={"reg": bids_safe_label(reg)},
114+
)

src/rbc/cli/longitudinal/functional.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Literal
77

88
from rbc.cli.base import _validate_task
99
from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument
@@ -20,6 +20,7 @@ class FunctionalLongArgs(LongitudinalBaseArgs):
2020
"""Arguments for ``rbc longitudinal functional``."""
2121

2222
task: str | None
23+
regressor: Sequence[Literal["36-parameter", "aCompCor"]]
2324

2425
@classmethod
2526
def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalLongArgs:
@@ -28,6 +29,7 @@ def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalLongArgs:
2829
return cls(
2930
**LongitudinalBaseArgs.validate_namespace(ns).__dict__,
3031
task=ns.task,
32+
regressor=ns.regressor,
3133
)
3234

3335

@@ -41,6 +43,7 @@ def main(args: FunctionalLongArgs) -> int:
4143
session_label=args.session_label,
4244
task=args.task,
4345
),
46+
regressors=args.regressor,
4447
runner_config=RunnerConfig(
4548
runner=args.runner,
4649
verbose=bool(args.verbose),
@@ -61,7 +64,7 @@ def register_command(
6164
parents=parents,
6265
description=(
6366
"Warp preprocessed BOLD derivatives into each subject's "
64-
"longitudinal template space."
67+
"longitudinal template space and re-run nuisance regression."
6568
),
6669
help="Longitudinal functional stage",
6770
usage=(
@@ -75,6 +78,13 @@ def register_command(
7578
default=None,
7679
help="Task label to filter BOLD runs (without 'task-' prefix).",
7780
)
81+
parser.add_argument(
82+
"--regressor",
83+
nargs="+",
84+
choices=["36-parameter", "aCompCor"],
85+
default=["36-parameter"],
86+
help="Space-delimited nuisance regression method(s) to apply.",
87+
)
7888

7989
parser.set_defaults(
8090
func=lambda args: main(FunctionalLongArgs.validate_namespace(args))

src/rbc/orchestration/longitudinal/functional.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
if TYPE_CHECKING:
2121
from collections.abc import Sequence
2222
from pathlib import Path
23+
from typing import Literal
2324

2425
import polars as pl
2526

@@ -34,13 +35,16 @@ def process_func(
3435
pipe_ctx: RunContext,
3536
func_df: pl.DataFrame,
3637
tpl_df: pl.DataFrame,
38+
*,
39+
regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",),
3740
) -> None:
3841
"""Handle functional longitudinal processing for one BOLD run.
3942
4043
Args:
4144
pipe_ctx: RunContext bound to this subject/session.
4245
func_df: Functional derivative DataFrame for this run.
4346
tpl_df: Longitudinal template DataFrame.
47+
regressors: Regressor strategies to apply in longitudinal space.
4448
"""
4549
row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True)
4650
ents = extract_entities(row, ["task", "run"])
@@ -54,17 +58,22 @@ def process_func(
5458
func_df,
5559
tpl_df,
5660
ses=pipe_ctx.ses, # type: ignore[arg-type]
61+
regressors=regressors,
62+
)
63+
func_outputs = functional_longitudinal(
64+
**resolved, # type: ignore[arg-type]
65+
regressor_set=regressors,
5766
)
58-
func_outputs = functional_longitudinal(**resolved) # type: ignore[arg-type]
5967
fex = func_q.derive(space="longitudinal")
60-
export_longitudinal_func(fex, func_outputs)
68+
export_longitudinal_func(fex, func_outputs, regressors=regressors)
6169

6270

6371
def run(
6472
input_dirs: Sequence[Path],
6573
output_dir: Path,
6674
*,
6775
filters: Filters,
76+
regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",),
6877
runner_config: RunnerConfig | None = None,
6978
) -> None:
7079
"""Run longitudinal functional processing for all matching subjects/sessions.
@@ -75,6 +84,7 @@ def run(
7584
and longitudinal templates).
7685
output_dir: Output directory for derivatives.
7786
filters: Participant/session/task filters applied before grouping.
87+
regressors: Regressor strategies to apply in longitudinal space.
7888
runner_config: Execution backend configuration.
7989
"""
8090
config = runner_config or RunnerConfig()
@@ -91,7 +101,12 @@ def run(
91101
input_dirs, output_dir, filters=filters, verbose=verbose
92102
):
93103
for func_df, _ in iter_session_files(session, groupby=FUNC_GROUP_ENTITIES):
94-
process_func(pipe_ctx=pipe_ctx, func_df=func_df, tpl_df=tpl_df)
104+
process_func(
105+
pipe_ctx=pipe_ctx,
106+
func_df=func_df,
107+
tpl_df=tpl_df,
108+
regressors=regressors,
109+
)
95110
pipe_ctx.ensure_dataset_description()
96111

97112
_logger.info("RBC longitudinal functional workflow complete")
Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
"""Longitudinal functional processing workflow.
22
33
Transforms preprocessed functional outputs to a pre-computed longitudinal
4-
template space and returns all output paths as a
4+
template space, then re-runs nuisance regression on the warped BOLD using
5+
raw regressors from the cross-sectional run. Returns all output paths as a
56
:class:`FunctionalLongOutputs` named tuple.
67
"""
78

89
from __future__ import annotations
910

11+
import logging
1012
from typing import TYPE_CHECKING, NamedTuple
1113

14+
from rbc.core.functional import apply_regression, apply_regression_bandpass
1215
from rbc.core.longitudinal.transform import (
1316
compose_transform,
1417
func_transform,
1518
mask_transform,
1619
)
1720

1821
if TYPE_CHECKING:
22+
from collections.abc import Sequence
1923
from pathlib import Path
24+
from typing import Literal
25+
26+
_logger = logging.getLogger("rbc")
2027

2128

2229
class FunctionalLongOutputs(NamedTuple):
@@ -26,14 +33,19 @@ class FunctionalLongOutputs(NamedTuple):
2633
bold_to_long_xfm: BOLD-to-longitudinal-template composite warp.
2734
sbref: Motion reference volume warped to longitudinal template space.
2835
bold: Preprocessed BOLD warped to longitudinal template space.
29-
bold_mask: Brain mask warped to longitudinal template space,
30-
or *None* if no mask was provided.
36+
bold_mask: Brain mask warped to longitudinal template space.
37+
regressed_bold: Per-regressor nuisance-regressed BOLD (no bandpass)
38+
in longitudinal template space, keyed by strategy name.
39+
cleaned_bold: Per-regressor nuisance-regressed + bandpass-filtered
40+
BOLD in longitudinal template space, keyed by strategy name.
3141
"""
3242

3343
bold_to_long_xfm: Path
3444
sbref: Path
3545
bold: Path
36-
bold_mask: Path | None = None
46+
bold_mask: Path
47+
regressed_bold: dict[str, Path]
48+
cleaned_bold: dict[str, Path]
3749

3850

3951
def longitudinal_process(
@@ -43,41 +55,70 @@ def longitudinal_process(
4355
bold_to_anat_itk: Path,
4456
sbref: Path,
4557
bold: Path,
46-
bold_mask: Path | None,
58+
bold_mask: Path,
59+
regressor_files: dict[str, Path],
60+
regressor_set: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",),
4761
) -> FunctionalLongOutputs:
4862
"""Transform preprocessed functional outputs to longitudinal template space.
4963
50-
Assumes a longitudinal template has been generated, the subject-to-template
51-
composite warp is available, and anatomical data has already been processed
52-
to longitudinal template space.
64+
After warping the BOLD timeseries, re-runs nuisance regression using the
65+
raw (unfiltered) regressors produced by the cross-sectional pipeline.
66+
No regressor recomputation is performed: the same regressor matrix is
67+
applied in the new target space.
5368
5469
Args:
5570
template: Longitudinal template image.
5671
anat_to_template_xfm: T1w-to-longitudinal-template composite warp.
5772
bold_to_anat_itk: BOLD-to-T1w affine in ITK format.
5873
sbref: Motion reference (single-band reference) volume.
5974
bold: Preprocessed bold image.
60-
bold_mask: Bold brain mask, if available.
75+
bold_mask: Bold brain mask.
76+
regressor_files: Raw (unfiltered) regressor ``.1D`` files from
77+
the cross-sectional run, keyed by strategy name.
78+
regressor_set: Which regressor strategies to apply. Must be a
79+
subset of the keys in *regressor_files*.
6180
6281
Returns:
63-
:class:`FunctionalLongOutputs` with all non-null inputs transformed to template
64-
space.
82+
:class:`FunctionalLongOutputs` with all inputs transformed to
83+
longitudinal template space and per-regressor regression outputs.
6584
"""
6685
bold_to_tpl_xfm = compose_transform(
6786
ref=template,
6887
bold_to_anat_itk=bold_to_anat_itk,
6988
anat_to_tpl_xfm=anat_to_template_xfm,
7089
)
7190

91+
long_sbref = func_transform(
92+
in_file=sbref, template=template, xfm=bold_to_tpl_xfm, strategy="single"
93+
)
94+
long_bold = func_transform(
95+
in_file=bold, template=template, xfm=bold_to_tpl_xfm, strategy="chunked"
96+
)
97+
long_mask = mask_transform(mask=bold_mask, template=template, xfm=bold_to_tpl_xfm)
98+
99+
regressed: dict[str, Path] = {}
100+
cleaned: dict[str, Path] = {}
101+
for reg in regressor_set:
102+
reg_file = regressor_files[reg]
103+
_logger.info("Longitudinal %s nuisance regression (no bandpass)", reg)
104+
regressed[reg] = apply_regression(
105+
bold_file=long_bold,
106+
brain_mask_file=long_mask,
107+
regressor_file=reg_file,
108+
).regressed_bold
109+
110+
_logger.info("Longitudinal %s nuisance regression + bandpass filtering", reg)
111+
cleaned[reg] = apply_regression_bandpass(
112+
bold_file=long_bold,
113+
brain_mask_file=long_mask,
114+
regressor_file=reg_file,
115+
).regressed_bold
116+
72117
return FunctionalLongOutputs(
73-
sbref=func_transform( # 3D volume
74-
in_file=sbref, template=template, xfm=bold_to_tpl_xfm, strategy="single"
75-
),
76-
bold=func_transform(
77-
in_file=bold, template=template, xfm=bold_to_tpl_xfm, strategy="chunked"
78-
),
79-
bold_mask=mask_transform(mask=bold_mask, template=template, xfm=bold_to_tpl_xfm)
80-
if bold_mask
81-
else None,
82118
bold_to_long_xfm=bold_to_tpl_xfm,
119+
sbref=long_sbref,
120+
bold=long_bold,
121+
bold_mask=long_mask,
122+
regressed_bold=regressed,
123+
cleaned_bold=cleaned,
83124
)

tests/unit/cli/test_longitudinal.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,24 @@ class TestFunctionalLongArgs:
8080
def test_valid_task(self, base_ns: argparse.Namespace) -> None:
8181
"""Alphanumeric task labels pass validation."""
8282
base_ns.task = "rest"
83+
base_ns.regressor = ["36-parameter"]
8384
args = FunctionalLongArgs.validate_namespace(base_ns)
8485
assert args.task == "rest"
8586

8687
def test_invalid_task_rejected(self, base_ns: argparse.Namespace) -> None:
8788
"""Task labels with special characters are rejected."""
8889
base_ns.task = "rest/invalid"
90+
base_ns.regressor = ["36-parameter"]
8991
with pytest.raises(ValueError, match="Task"):
9092
FunctionalLongArgs.validate_namespace(base_ns)
9193

94+
def test_regressor_preserved(self, base_ns: argparse.Namespace) -> None:
95+
"""Regressor choices round-trip through validation."""
96+
base_ns.task = None
97+
base_ns.regressor = ["36-parameter", "aCompCor"]
98+
args = FunctionalLongArgs.validate_namespace(base_ns)
99+
assert list(args.regressor) == ["36-parameter", "aCompCor"]
100+
92101

93102
class TestMetricsLongArgs:
94103
"""Tests for the metrics longitudinal subcommand validator."""

0 commit comments

Comments
 (0)