Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ jobs:
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
# [fmri] pulls nilearn + nibabel so the NARPS BIDS load path and its
# demo are exercised in CI rather than skipped.
pip install -e ".[dev,fmri]"

- name: Lint
run: ruff check src tests scripts
Expand All @@ -46,3 +48,10 @@ jobs:
# Fails if the loader miswires stimulus keys or the asymmetry features.
- name: DEAP-format recovery check
run: python scripts/run_deap.py --demo

# Same check on the real NARPS BIDS format (synthetic fixture, no
# download). Fails if the loader miswires events, ROI extraction, or
# confounds. Honest claim only: fMRI predicts accept above chance, no
# brain-beats-behaviour assertion (gambles favour the economic baseline).
- name: NARPS-format recovery check
run: python scripts/run_narps.py --demo
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,26 @@ baselines that a claim like it requires. Full citations, with DOIs, in

## Status

Framework and validation harness, working end to end on synthetic data. The
**DEAP loader is built and tested** against the real file format (`io/deap.py`,
[`docs/deap.md`](docs/deap.md)); it needs only the licensed download to run on
real recordings, and DEAP's YouTube stimuli give it a real aggregate outcome via
view counts. The fMRI/MNE/OpenCV loader paths still have not been run against
real recordings; expect to fix things. See [`docs/design.md`](docs/design.md)
§12 for the full list of what is not built.
Framework and validation harness, working end to end on synthetic data. Two
real-dataset loaders are built and tested against their actual file formats:

- **DEAP** (`io/deap.py`, [`docs/deap.md`](docs/deap.md)): EEG + peripheral +
behaviour; needs the licensed download; YouTube stimuli give it a real
aggregate outcome via view counts.
- **NARPS ds001734** (`io/narps.py`, [`docs/narps.md`](docs/narps.md)): the
fMRI mixed-gambles reward task; reuses `FMRILoader`'s NAcc/vmPFC/AIns sphere
extraction; public OpenNeuro download, no licence. NARPS is an
*individual-level* fMRI validation: on gambles the economic baseline dominates
the aggregate arm by construction, so it is the fMRI plumbing check, not a
brain-beats-behaviour demonstration.

The MNE/OpenCV loader paths still have not been run against real recordings;
expect to fix things. See [`docs/design.md`](docs/design.md) §12 for the full
list of what is not built.

```bash
python scripts/run_deap.py --demo # whole DEAP path on a synthetic fixture, no download
python scripts/run_deap.py --demo # whole DEAP path on a synthetic fixture, no download
python scripts/run_narps.py --demo # whole NARPS BIDS path on a synthetic fixture (needs .[fmri])
```

---
Expand Down
7 changes: 6 additions & 1 deletion docs/data_sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,12 @@ institution's ethics approval covers linking lab data to public platform data.
(`io/deap_market.py`), so it can exercise *both* levels, not just the
individual one. Validates the EEG and behaviour paths and the whole ensemble.
2. **NARPS `ds001734`** to validate the fMRI loader and ROI extraction on real
BIDS data with a reward task and a decent sample.
BIDS data with a reward task and a decent sample. **The loader for this is
built** (`io/narps.py`, [`docs/narps.md`](narps.md)); it reuses `FMRILoader`'s
NAcc/vmPFC/AIns sphere extraction and needs only the OpenNeuro download (no
licence). Note NARPS is an *individual-level* validation: on gambles the
economic baseline dominates the aggregate arm by construction, so it is the
fMRI plumbing check, not a brain-beats-behaviour demonstration.
3. **A market-linked stimulus set** (Kiva or Kickstarter items) for a
purpose-built aggregate arm. This is the step that requires collecting your
own scans, and it is the one that produces the fully novel result.
Expand Down
161 changes: 161 additions & 0 deletions docs/narps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# NARPS (ds001734)

How this framework loads the NARPS mixed-gambles dataset, the format details that
matter, and an honest statement of what NARPS can and cannot show.

NARPS (Botvinik-Nezer et al., 2019, *Scientific Data*,
[10.1038/s41597-019-0113-7](https://doi.org/10.1038/s41597-019-0113-7); dataset
[ds001734 on OpenNeuro](https://openneuro.org/datasets/ds001734), DOI
[10.18112/openneuro.ds001734.v1.0.5](https://doi.org/10.18112/openneuro.ds001734.v1.0.5))
is the fMRI counterpart to DEAP in this project: a reward-circuit task with
enough subjects to matter. On each trial a participant sees a 50/50 gamble with a
possible **gain** and a possible **loss** and decides whether to accept it. This
is exactly the anticipatory-affect paradigm the framework's fMRI ROIs come from,
so it is the right dataset to validate the fMRI loader and the NAcc / vmPFC / AIns
sphere extraction on real data.

**Access.** NARPS is public on OpenNeuro; no licence agreement. It is large
(4-D BOLD across ~108 subjects), so the loader downloads nothing and expects a
local BIDS tree, ideally with fMRIPrep-preprocessed derivatives.

---

## Verified format

Confirmed against the dataset's own files on OpenNeuro (2026-08-07), not from
memory. A real events file looks like:

```
onset duration gain loss RT participant_response
4.071 4 14 6 2.388 weakly_accept
11.834 4 34 14 2.289 strongly_accept
27.535 4 10 10 1.457 weakly_reject
36.435 4 12 19 1.973 strongly_reject
```

- **Task**: `MGT` (mixed gambles task).
- **TR**: 1.0 s (`task-MGT_bold.json`, `RepetitionTime: 1`).
- **Trials**: 4 runs per subject, 64 trials per run, 4 s cue per trial.
- **`events.tsv` columns**: `onset`, `duration`, `gain`, `loss`, `RT`,
`participant_response`.
- **Response values**: `strongly_accept`, `weakly_accept`, `weakly_reject`,
`strongly_reject`, and `NoResp` for no-response trials (RT coded 0).
- **`participants.tsv` columns**: `participant_id`, `group`, `gender`, `age`.
- **Groups**: `equalIndifference` (gains 10-40, losses 5-20) and `equalRange`
(gains and losses both 5-20). The two groups saw different gamble matrices;
this is a real design factor, not noise (see below).

---

## How NARPS maps onto the framework

| framework concept | NARPS realisation |
|---|---|
| individual outcome `y_individual` | accept vs reject the gamble |
| stimulus key `stimulus_id` | the gamble, keyed by `(gain, loss)` |
| aggregate outcome `y_aggregate` | population acceptance rate per gamble |
| fMRI block | NAcc / vmPFC / AIns sphere betas per trial |
| behaviour block | gain, loss, expected value, RT |

**Individual choice.** `strongly_accept` and `weakly_accept` collapse to accept
(1); `strongly_reject` and `weakly_reject` to reject (0). `NoResp` trials are
dropped, not imputed: a missing response is missing data, and guessing it would
manufacture labels. The strong/weak distinction is preserved in a separate
confidence field for anyone who wants a 4-level analysis, but the binary accept
is the default outcome.

**Stimulus key.** The gamble is defined by its `(gain, loss)` pair, and those
pairs recur across subjects, which is what makes a stimulus key and an aggregate
outcome possible at all. Keys look like `g14_l06`.

---

## The traps

Fewer than DEAP, but the ones here are sharp.

**1. The behaviour arm is *supposed* to be strong, and that inverts the usual
story.** In the crowdfunding and video paradigms the neural signal beats the
behavioural measure. Gambles are different: acceptance is largely a function of
gain and loss through expected value, so an economic model built from `gain` and
`loss` forecasts aggregate acceptance almost by construction. On NARPS, expect
**behaviour to win the aggregate arm**. That is not a failure of the neural arm;
it is what gambles are. NARPS is primarily an **individual-level** validation
(can NAcc/vmPFC betas predict this person's accept/reject), and the aggregate arm
is a secondary check where the economic baseline is expected to dominate. Do not
report a forced brain-beats-behaviour headline on NARPS; it would be dishonest
about the task.

**2. The two groups saw different gamble matrices.** `equalIndifference` and
`equalRange` do not share the same `(gain, loss)` grid. Pooling them for the
aggregate arm mixes two stimulus spaces, and a gamble present in one group may be
absent in the other. The loader keeps the group label and, by default, warns when
you pool across groups for aggregate forecasting. Analyse within group unless you
have a reason not to.

**3. Slice-timing and the haemodynamic lag are real analytic choices.** The
default trial feature is a peak-window mean of the BOLD 4 to 8 s after cue onset,
which is a crude stand-in for a proper first-level GLM. It is fine for getting the
pipeline running and wrong for a publishable estimate. Replace it with
`nilearn.glm.first_level` before reporting. The loader records
`extraction="peak_window_mean"` in provenance so this is never hidden.

**4. Confounds must be regressed, and this loader does not do it for you.**
Motion, framewise displacement, and the aCompCor components in the fMRIPrep
`*_desc-confounds_timeseries.tsv` are not optional for a reward-ROI analysis:
head motion correlates with task events and with individual differences. The
loader accepts a confounds table and passes it to the masker; if you do not
supply one, it warns.

**5. No-response trials and RT = 0.** `NoResp` rows carry `RT = 0`. Feeding that
zero into an RT feature as if it were a fast response is wrong. The loader drops
`NoResp` trials before building any block.

---

## The theory-specified ROI

On the crowdfunding side the signal lived in NAcc (gain anticipation) and MPFC.
NARPS is built for exactly this circuit: the canonical mixed-gambles result is
that NAcc tracks potential gain and a vmPFC/MPFC region tracks the net expected
value, with anterior insula tracking potential loss. The framework's default
ROIs (`NAcc_L/R`, `MPFC`, `AIns_L/R`) are therefore already the right set, and
the NARPS loader reuses `FMRILoader`'s sphere extraction rather than defining its
own. Confirm the exact sphere coordinates against whatever prior you are
replicating before reporting; sphere placement is a genuine analytic degree of
freedom.

---

## What NARPS validates, and what it does not

**Validates**: that the fMRI loader reads a real BIDS reward task, extracts
trial-wise ROI features, and that those features predict individual accept/reject
above chance with subject-grouped CV. That is milestone 2 in
[data_sources.md](data_sources.md), and it is a real result on its own.

**Does not validate**: the brain-beats-behaviour aggregate claim. Gambles are the
wrong task for that, for the reason in trap 1. NARPS is the fMRI plumbing check
and the individual-decoding check. The aggregate neuroforecasting story needs a
stimulus set with a market outcome that is *not* a deterministic function of the
stimulus parameters, which is what the crowdfunding/video/microlending paradigms
provide and gambles do not.

---

## Usage sketch

```python
from behavioral_decoding.io.narps import load_narps

# Individual-level, one group, from fMRIPrep derivatives.
dataset = load_narps(
root="/path/to/ds001734",
derivatives="/path/to/fmriprep",
group="equalIndifference",
space="MNI152NLin2009cAsym",
)
```

See `scripts/run_narps.py --demo` for the whole path on a synthetic BIDS fixture
that needs no download.
130 changes: 130 additions & 0 deletions scripts/run_narps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Run the pipeline on NARPS (ds001734), or on a synthetic NARPS-format fixture.

With a real NARPS download:

python scripts/run_narps.py --root /path/to/ds001734 \
--derivatives /path/to/fmriprep --group equalIndifference

With no download, to see the whole path on the true BIDS format:

python scripts/run_narps.py --demo

``--demo`` writes a miniature but format-real NARPS BIDS tree (participants.tsv,
per-run events.tsv with the verified columns and response strings, small 4-D
NIfTIs, confounds) with an EV-scaled BOLD bump planted at the NAcc and MPFC
voxels. It then checks that the fMRI ROI features predict accept/reject above
chance out of fold. That is the honest, recoverable claim: consistent with
``docs/narps.md``, no brain-beats-behaviour headline is asserted, because on
gambles the economic baseline dominates the aggregate arm by construction.

Requires nilearn and nibabel (``pip install '.[fmri]'``).
"""

from __future__ import annotations

import argparse
import sys
import tempfile
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
sys.path.insert(0, str(REPO_ROOT / "tests"))

from behavioral_decoding.config import ExperimentConfig # noqa: E402
from behavioral_decoding.evaluation.neuroforecast import ( # noqa: E402
format_forecast_comparison,
)
from behavioral_decoding.io.narps import load_narps # noqa: E402
from behavioral_decoding.pipelines.train import run_experiment # noqa: E402


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", help="NARPS ds001734 BIDS root")
parser.add_argument("--derivatives", help="fMRIPrep derivatives root, if separate")
parser.add_argument("--group", choices=["equalIndifference", "equalRange"])
parser.add_argument("--space", default="MNI152NLin2009cAsym")
parser.add_argument("--no-aggregate", action="store_true",
help="skip the behaviour-dominated aggregate arm")
parser.add_argument("--demo", action="store_true",
help="synthesise a NARPS-format fixture instead of loading a real one")
parser.add_argument("--output-dir", default="results")
args = parser.parse_args()

if not args.demo and not args.root:
parser.error("give --root PATH for a real run, or --demo for the fixture")

tmp = None
if args.demo:
from narps_fixture import write_narps_fixture

tmp = tempfile.mkdtemp(prefix="narps_demo_")
write_narps_fixture(tmp, n_subjects=12, n_runs=2, trials_per_run=16, seed=0)
args.root = tmp
args.group = "equalRange"
print("=" * 78)
print("SYNTHETIC NARPS-FORMAT FIXTURE (simulated; not a finding about brains)")
print("=" * 78)
print("fMRI ROI features should predict accept/reject above chance out of fold.")
print("No brain-beats-behaviour claim is planted; gambles favour the economic")
print("baseline on the aggregate arm. See docs/narps.md.")
print()

dataset = load_narps(
args.root,
derivatives=args.derivatives,
group=args.group,
space=args.space,
with_aggregate=not args.no_aggregate,
)
print(dataset.describe())
print()

cfg = ExperimentConfig(name="narps", output_dir=args.output_dir)
cfg.data.modalities = dataset.modalities
record = run_experiment(dataset, cfg)

if args.demo:
print()
print("=" * 78)
print("GROUND-TRUTH RECOVERY CHECK")
print("=" * 78)
per_mod = record["individual"]["per_modality_pooled"]
fmri_balacc = per_mod["fmri"]["balanced_accuracy"]
perm_p = record["individual"]["permutation_test"]["p_value"]

arms = record.get("aggregate") or {}
if arms:
print(format_forecast_comparison(arms, "regression"))
print()

ok_fmri = fmri_balacc > 0.55
ok_perm = perm_p < 0.05
print(" [{}] fMRI ROI features predict accept above chance "
"(balanced accuracy {:.3f})".format("PASS" if ok_fmri else "FAIL", fmri_balacc))
print(" [{}] individual result survives label permutation "
"(p = {:.4f})".format("PASS" if ok_perm else "FAIL", perm_p))
print()

ok = ok_fmri and ok_perm
if ok:
print("The loader recovers the planted reward signal on the true NARPS BIDS")
print("format: NAcc/MPFC betas predict choice. fMRI plumbing validated. It")
print("says nothing about brains.")
else:
print("The reward signal did not recover; the loader is likely miswiring the")
print("events, the ROI extraction, or the confounds. Fix before real NARPS.")

import shutil

if tmp:
shutil.rmtree(tmp, ignore_errors=True)
return 0 if ok else 1

return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading