Add core ROI masks, visualization script, and model profiles
Browse files- Introduced core ROI masks (BA47, TP, aSTS, pSTS) for A1 evaluation.
- Added README for ROI masks detailing their purpose and usage.
- Created a new script `run_a1_visualize.py` for visualizing A1 fit outputs.
- Implemented model profiles in JSON format for current and future model configurations.
- Updated requirements.txt with necessary dependencies for the new visualization script.
- .gitignore +13 -0
- README.md +162 -12
- code/a1_pipeline/__init__.py +30 -0
- code/a1_pipeline/alignment.py +348 -0
- code/a1_pipeline/annotations.py +258 -0
- code/a1_pipeline/constants.py +71 -0
- code/a1_pipeline/features.py +651 -0
- code/a1_pipeline/io_utils.py +17 -0
- code/a1_pipeline/manifest.py +331 -0
- code/a1_pipeline/model_config.py +95 -0
- code/a1_pipeline/participant_runs.py +176 -0
- code/a1_pipeline/spatial.py +183 -0
- code/a1_pipeline/splits.py +237 -0
- code/a1_pipeline/targets.py +173 -0
- code/assets/participant_run_info.json +28 -0
- code/assets/roi_masks/AG_TPJ.nii.gz +0 -0
- code/assets/roi_masks/BA44.nii.gz +0 -0
- code/assets/roi_masks/BA45.nii.gz +0 -0
- code/assets/roi_masks/BA47.nii.gz +0 -0
- code/assets/roi_masks/README.md +16 -0
- code/assets/roi_masks/TP.nii.gz +0 -0
- code/assets/roi_masks/aSTS.nii.gz +0 -0
- code/assets/roi_masks/pSTS.nii.gz +0 -0
- code/config/model_profiles.json +36 -0
- code/run_a1_visualize.py +198 -0
- requirements.txt +13 -0
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/derivatives
|
| 2 |
+
data
|
| 3 |
+
*.pdf
|
| 4 |
+
*.pyc
|
| 5 |
+
/visualizations
|
| 6 |
+
/code/outputs
|
| 7 |
+
*png
|
| 8 |
+
*.npz
|
| 9 |
+
check_missing_derivatives.py
|
| 10 |
+
free_space.sh
|
| 11 |
+
/outputs
|
| 12 |
+
*.csv
|
| 13 |
+
llms_brain_lateralization/
|
README.md
CHANGED
|
@@ -1,12 +1,162 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Plan: A1 SI Baseline with Dual Evaluation and Expanded Parcels
|
| 2 |
+
|
| 3 |
+
Implement A1 as a frozen SI baseline with two required evaluation protocols: cross-run generalization and within-run blocked split. Keep both 7 language ROIs and expanded Harvard-Oxford symmetric parcels, and report results separately so speaker-shift robustness and same-condition fit are not conflated.
|
| 4 |
+
|
| 5 |
+
**Steps**
|
| 6 |
+
1. Phase 0: Paper-first gate before coding
|
| 7 |
+
- Verify appendix details for ROI/parcellation and document any unresolved ambiguities.
|
| 8 |
+
- Lock assumptions and scope before implementation starts.
|
| 9 |
+
|
| 10 |
+
2. Phase 1: Data manifest and integrity QC
|
| 11 |
+
- Build clean derivatives manifest with strict checks: folder and filename subject consistency, run whitelist 1-4, sidecar existence, unique subject-run keys.
|
| 12 |
+
- Save reject log and accepted manifest.
|
| 13 |
+
- Validate per-run TR and scan length from BIDS sidecars.
|
| 14 |
+
|
| 15 |
+
3. Phase 2: Spatial harmonization and mask
|
| 16 |
+
- Resample BOLD to one canonical analysis grid.
|
| 17 |
+
- Build analysis mask and enforce left-right symmetry using hemisphere swap and mask intersection.
|
| 18 |
+
- Emit affine and shape and voxel-count QC report.
|
| 19 |
+
|
| 20 |
+
4. Phase 3: Target definition with two parcellation tracks
|
| 21 |
+
- Track A: 7 language ROIs from sphere masks.
|
| 22 |
+
- Track B: Harvard-Oxford cortical maxprob-thr0-2mm with symmetric split for expanded parcel analysis.
|
| 23 |
+
- Resample labels with nearest-neighbor interpolation only.
|
| 24 |
+
- Build left-right parcel pairing map and parcel voxel index map.
|
| 25 |
+
|
| 26 |
+
5. Phase 4: Annotation harmonization
|
| 27 |
+
- Build unified word-event table with run_id, condition, speaker_stream, word, onset_s, offset_s, duration_s, provenance.
|
| 28 |
+
- Apply approved fixed run mapping and mixed-run fallback flags.
|
| 29 |
+
- Validate monotonic and non-negative timing.
|
| 30 |
+
|
| 31 |
+
6. Phase 5: Frozen SI feature extraction
|
| 32 |
+
- Extract per-run, per-layer, per-word hidden-state features for approved models.
|
| 33 |
+
- Cache token-to-word aggregated embeddings and extraction metadata.
|
| 34 |
+
- No model fine-tuning in baseline phase.
|
| 35 |
+
|
| 36 |
+
7. Phase 6: Temporal alignment and normalization
|
| 37 |
+
- Convert word features to TR regressors with HRF convolution.
|
| 38 |
+
- Apply per-run z-score to regressors and BOLD targets.
|
| 39 |
+
- Enforce exact regressor-target TR length match after trimming policy.
|
| 40 |
+
|
| 41 |
+
8. Phase 7: Dual evaluation implementation (both required)
|
| 42 |
+
- Protocol A: cross-run split
|
| 43 |
+
- Leave-one-run-out per subject to test speaker and condition transfer.
|
| 44 |
+
- Nested alpha selection on training-only partitions.
|
| 45 |
+
- Protocol B: within-run blocked split
|
| 46 |
+
- Use contiguous temporal blocks inside each run for train and test, not random word sampling.
|
| 47 |
+
- Add temporal gap buffer between train and test blocks to reduce HRF bleed leakage.
|
| 48 |
+
- Tune alpha using training-only temporal partitions.
|
| 49 |
+
- Compute metrics for both target sets in both protocols: 7 ROIs and expanded parcels.
|
| 50 |
+
- Keep outputs clearly labeled by protocol to avoid mixing interpretations.
|
| 51 |
+
|
| 52 |
+
9. Phase 8: Outputs and reporting
|
| 53 |
+
- Save per-model, per-layer, per-subject, per-run, per-target, per-protocol tables.
|
| 54 |
+
- Save left-right asymmetry summaries and significance tables for parcel and ROI levels.
|
| 55 |
+
- Save protocol comparison report: cross-run versus within-run blocked.
|
| 56 |
+
|
| 57 |
+
10. Phase 9: Optional extension after baseline
|
| 58 |
+
- Add fine-tuning branch as a separate experiment only after frozen baseline is complete.
|
| 59 |
+
- Compare against frozen baseline outputs using matched protocols.
|
| 60 |
+
|
| 61 |
+
**Relevant files**
|
| 62 |
+
- /home/mohith/ds005345/NeurIPS-2024-fmri-predictors-based-on-language-models-of-increasing-complexity-recover-brain-left-lateralization-Paper-Conference.pdf — paper and appendix reference gate.
|
| 63 |
+
- /home/mohith/ds005345/llms_brain_lateralization/README.md — reference pipeline order and 7 ROI context.
|
| 64 |
+
- /home/mohith/ds005345/llms_brain_lateralization/create_roi_masks.py — 7 language ROI coordinates and generation pattern.
|
| 65 |
+
- /home/mohith/ds005345/llms_brain_lateralization/analyze_results.ipynb — expanded Harvard-Oxford symmetric atlas workflow and nearest-neighbor resampling pattern.
|
| 66 |
+
- /home/mohith/ds005345/llms_brain_lateralization/extract_llm_activations.py — hidden-state extraction and token-to-word aggregation pattern.
|
| 67 |
+
- /home/mohith/ds005345/llms_brain_lateralization/fit_individual_subject.py — run-wise HRF regressor build and ridge CV template.
|
| 68 |
+
- /home/mohith/ds005345/llms_brain_lateralization/resample_fmri_data.py — canonical resampling pattern.
|
| 69 |
+
- /home/mohith/ds005345/llms_brain_lateralization/compute_mask.py — symmetric mask computation.
|
| 70 |
+
|
| 71 |
+
**Verification**
|
| 72 |
+
1. Paper gate checks
|
| 73 |
+
- Appendix ROI and parcel assumptions logged and aligned with implementation choices.
|
| 74 |
+
|
| 75 |
+
2. Spatial checks
|
| 76 |
+
- Atlas and BOLD match analysis affine and shape.
|
| 77 |
+
- Label interpolation nearest-neighbor only.
|
| 78 |
+
- Symmetric mask voxel counts and left-right balance logged.
|
| 79 |
+
|
| 80 |
+
3. Feature checks
|
| 81 |
+
- Hidden-state files complete for each run, model, and layer.
|
| 82 |
+
- Token-to-word aggregation diagnostics saved.
|
| 83 |
+
|
| 84 |
+
4. Modeling checks
|
| 85 |
+
- Frozen baseline confirmed with no weight updates.
|
| 86 |
+
- Protocol A and Protocol B each have independent train and validation and test metadata.
|
| 87 |
+
- Within-run blocked split includes temporal buffer and zero overlap after buffering.
|
| 88 |
+
|
| 89 |
+
5. Reporting checks
|
| 90 |
+
- 7 ROI and expanded parcel outputs exist for both protocols.
|
| 91 |
+
- Side-by-side protocol comparison produced for every model.
|
| 92 |
+
|
| 93 |
+
**Decisions**
|
| 94 |
+
- Baseline SI uses frozen inference plus ridge only.
|
| 95 |
+
- Two evaluation protocols are both required in baseline: cross-run and within-run blocked.
|
| 96 |
+
- Random word-level 500/100 split is excluded from primary SI evaluation due temporal leakage risk after HRF convolution.
|
| 97 |
+
- Expanded ROI set uses Harvard-Oxford cortical maxprob-thr0-2mm with symmetric split.
|
| 98 |
+
- Fine-tuning remains a separate post-baseline experiment branch.
|
| 99 |
+
|
| 100 |
+
## Running On HF Jobs
|
| 101 |
+
|
| 102 |
+
For this project, use HF Jobs rather than a Space. The A1 pipeline expects one root containing:
|
| 103 |
+
|
| 104 |
+
- `project/`
|
| 105 |
+
- `data/`
|
| 106 |
+
- `derivatives/`
|
| 107 |
+
|
| 108 |
+
The launcher in `project/hf_jobs.py` reconstructs that layout inside the HF container and then runs `project/code/run_a1_end_to_end.py` unchanged.
|
| 109 |
+
|
| 110 |
+
### Required HF repos
|
| 111 |
+
|
| 112 |
+
Create two HF Dataset repos:
|
| 113 |
+
|
| 114 |
+
- one whose repo root mirrors the contents of local `data/`
|
| 115 |
+
- one whose repo root mirrors the contents of local `derivatives/`
|
| 116 |
+
|
| 117 |
+
Important:
|
| 118 |
+
|
| 119 |
+
- upload the folder contents, not the parent folder name
|
| 120 |
+
- the `data` repo should have files like `participants.tsv` and `sub-01/` at repo root
|
| 121 |
+
- the `derivatives` repo should have `sub-01/`, `sub-02/`, ... at repo root
|
| 122 |
+
|
| 123 |
+
### Preview The Job
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
cd /home/mohith/ds005345
|
| 127 |
+
conda activate csai
|
| 128 |
+
python project/hf_jobs.py \
|
| 129 |
+
--code-url https://huggingface.co/spaces/Mohith202/csai.git \
|
| 130 |
+
--data-repo Mohith202/ds005345-data \
|
| 131 |
+
--derivatives-repo Mohith202/ds005345-derivatives \
|
| 132 |
+
--results-repo Mohith202/ds005345-a1-results \
|
| 133 |
+
--secret-hf-token
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
This prints the generated job script and the `hf jobs run` command without launching it.
|
| 137 |
+
|
| 138 |
+
### Launch The End-To-End Run
|
| 139 |
+
|
| 140 |
+
```bash
|
| 141 |
+
cd /home/mohith/ds005345
|
| 142 |
+
conda activate csai
|
| 143 |
+
python project/hf_jobs.py \
|
| 144 |
+
--code-url https://huggingface.co/spaces/Mohith202/csai.git \
|
| 145 |
+
--data-repo Mohith202/ds005345-data \
|
| 146 |
+
--derivatives-repo Mohith202/ds005345-derivatives \
|
| 147 |
+
--results-repo Mohith202/ds005345-a1-results \
|
| 148 |
+
--results-path hf_jobs/run_001 \
|
| 149 |
+
--flavor a10g-small \
|
| 150 |
+
--model-profile current \
|
| 151 |
+
--protocols B,C \
|
| 152 |
+
--secret-hf-token \
|
| 153 |
+
--launch
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
### Notes
|
| 157 |
+
|
| 158 |
+
- `--secret-hf-token` is recommended when the dataset repos are private.
|
| 159 |
+
- The job keeps the CUDA-enabled PyTorch already present in the container image and installs the rest of `project/requirements.txt` separately.
|
| 160 |
+
- By default bootstrap artifacts go to `outputs/a1_bootstrap_hf` inside the cloned repo.
|
| 161 |
+
- You can forward the normal end-to-end controls such as `--model-profile`, `--model-slug`, `--reuse-caches`, `--skip-bootstrap`, `--skip-fit`, and `--skip-visualize`.
|
| 162 |
+
- You can forward the normal end-to-end controls such as `--model-profile`, `--model-slug`, `--protocols`, `--reuse-caches`, `--skip-bootstrap`, `--skip-fit`, and `--skip-visualize`.
|
code/a1_pipeline/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A1 frozen SI baseline bootstrap package."""
|
| 2 |
+
|
| 3 |
+
from .alignment import build_and_cache_alignment_inputs
|
| 4 |
+
from .annotations import build_harmonized_annotation_tables
|
| 5 |
+
from .constants import DEFAULT_ALLOWED_RUNS, DEFAULT_MODEL_IDS
|
| 6 |
+
from .features import extract_and_cache_run_level_features, slugify_model_id
|
| 7 |
+
from .manifest import build_derivatives_manifest
|
| 8 |
+
from .model_config import load_model_ids_from_config, list_model_profiles
|
| 9 |
+
from .spatial import build_symmetric_analysis_mask
|
| 10 |
+
from .splits import build_cross_run_folds, build_within_run_blocked_splits, summarize_split_counts
|
| 11 |
+
from .targets import CORE_ROI_NAMES, evaluate_core_roi_preservation, load_core_roi_masks
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"build_and_cache_alignment_inputs",
|
| 15 |
+
"build_harmonized_annotation_tables",
|
| 16 |
+
"DEFAULT_ALLOWED_RUNS",
|
| 17 |
+
"DEFAULT_MODEL_IDS",
|
| 18 |
+
"extract_and_cache_run_level_features",
|
| 19 |
+
"slugify_model_id",
|
| 20 |
+
"build_derivatives_manifest",
|
| 21 |
+
"load_model_ids_from_config",
|
| 22 |
+
"list_model_profiles",
|
| 23 |
+
"build_symmetric_analysis_mask",
|
| 24 |
+
"build_cross_run_folds",
|
| 25 |
+
"build_within_run_blocked_splits",
|
| 26 |
+
"summarize_split_counts",
|
| 27 |
+
"CORE_ROI_NAMES",
|
| 28 |
+
"load_core_roi_masks",
|
| 29 |
+
"evaluate_core_roi_preservation",
|
| 30 |
+
]
|
code/a1_pipeline/alignment.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TR-level HRF regressor and z-score alignment utilities."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import nibabel as nib
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _zscore_columns(values: np.ndarray, eps: float = 1e-8) -> np.ndarray:
|
| 15 |
+
mean = np.mean(values, axis=0, keepdims=True)
|
| 16 |
+
std = np.std(values, axis=0, keepdims=True)
|
| 17 |
+
std[std < eps] = 1.0
|
| 18 |
+
return ((values - mean) / std).astype(np.float32)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _trim_time_axis(values: np.ndarray, trim_start_tr: int, trim_end_tr: int) -> np.ndarray:
|
| 22 |
+
if trim_start_tr < 0 or trim_end_tr < 0:
|
| 23 |
+
raise ValueError("trim_start_tr and trim_end_tr must be non-negative")
|
| 24 |
+
|
| 25 |
+
start = int(trim_start_tr)
|
| 26 |
+
stop = values.shape[0] - int(trim_end_tr)
|
| 27 |
+
|
| 28 |
+
if start >= stop:
|
| 29 |
+
raise ValueError(
|
| 30 |
+
"Trimming removed all time points: "
|
| 31 |
+
f"n_tr={values.shape[0]}, trim_start={trim_start_tr}, trim_end={trim_end_tr}"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
return values[start:stop]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _format_tr_for_filename(tr_seconds: float) -> str:
|
| 38 |
+
return f"{tr_seconds:.4f}".replace(".", "p")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _assert_same_grid(img: nib.Nifti1Image, reference_img: nib.Nifti1Image, atol: float = 1e-5) -> None:
|
| 42 |
+
if img.shape[:3] != reference_img.shape:
|
| 43 |
+
raise ValueError(
|
| 44 |
+
"Image shape mismatch against analysis mask grid: "
|
| 45 |
+
f"image={img.shape[:3]}, reference={reference_img.shape}"
|
| 46 |
+
)
|
| 47 |
+
if not np.allclose(img.affine, reference_img.affine, atol=atol):
|
| 48 |
+
raise ValueError("Image affine mismatch against analysis mask grid")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _extract_masked_bold_2d(
|
| 52 |
+
bold_path: Path,
|
| 53 |
+
analysis_mask_bool: np.ndarray,
|
| 54 |
+
analysis_mask_img: nib.Nifti1Image,
|
| 55 |
+
) -> np.ndarray:
|
| 56 |
+
img = nib.load(str(bold_path))
|
| 57 |
+
if len(img.shape) != 4:
|
| 58 |
+
raise ValueError(f"Expected 4D BOLD image, got shape={img.shape} for {bold_path}")
|
| 59 |
+
|
| 60 |
+
_assert_same_grid(img=img, reference_img=analysis_mask_img)
|
| 61 |
+
|
| 62 |
+
data = np.asanyarray(img.dataobj)
|
| 63 |
+
# Convert from (x,y,z,t) to (t,voxels)
|
| 64 |
+
bold_2d = data[analysis_mask_bool, :].T.astype(np.float32)
|
| 65 |
+
return bold_2d
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def compute_hrf_regressor_matrix(
|
| 69 |
+
word_features: np.ndarray,
|
| 70 |
+
onsets_s: np.ndarray,
|
| 71 |
+
offsets_s: np.ndarray,
|
| 72 |
+
tr_seconds: float,
|
| 73 |
+
n_volumes: int,
|
| 74 |
+
hrf_model: str,
|
| 75 |
+
) -> np.ndarray:
|
| 76 |
+
"""Build TR-level HRF-convolved regressors from word-level features."""
|
| 77 |
+
from nilearn.glm.first_level import compute_regressor
|
| 78 |
+
|
| 79 |
+
if word_features.ndim != 2:
|
| 80 |
+
raise ValueError("word_features must be 2D: (n_words, hidden_dim)")
|
| 81 |
+
|
| 82 |
+
if onsets_s.shape[0] != word_features.shape[0] or offsets_s.shape[0] != word_features.shape[0]:
|
| 83 |
+
raise ValueError("onset/offset lengths must match n_words in word_features")
|
| 84 |
+
|
| 85 |
+
durations = offsets_s - onsets_s
|
| 86 |
+
frame_times = np.arange(n_volumes, dtype=np.float64) * float(tr_seconds) + 0.5 * float(tr_seconds)
|
| 87 |
+
|
| 88 |
+
regressors = np.zeros((int(n_volumes), int(word_features.shape[1])), dtype=np.float32)
|
| 89 |
+
|
| 90 |
+
for feature_index in range(word_features.shape[1]):
|
| 91 |
+
amplitudes = word_features[:, feature_index].astype(np.float64)
|
| 92 |
+
exp_condition = np.array([onsets_s, durations, amplitudes], dtype=np.float64)
|
| 93 |
+
signal, _ = compute_regressor(exp_condition, hrf_model, frame_times)
|
| 94 |
+
regressors[:, feature_index] = signal[:, 0].astype(np.float32)
|
| 95 |
+
|
| 96 |
+
return regressors
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass(frozen=True)
|
| 100 |
+
class BoldAlignmentRecord:
|
| 101 |
+
"""Summary for one subject-run BOLD alignment artifact."""
|
| 102 |
+
|
| 103 |
+
subject: str
|
| 104 |
+
run: int
|
| 105 |
+
tr_seconds: float
|
| 106 |
+
n_volumes_raw: int
|
| 107 |
+
n_tr_aligned: int
|
| 108 |
+
n_voxels: int
|
| 109 |
+
trim_start_tr: int
|
| 110 |
+
trim_end_tr: int
|
| 111 |
+
bold_z_path: str
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@dataclass(frozen=True)
|
| 115 |
+
class RegressorAlignmentRecord:
|
| 116 |
+
"""Summary for one model-run-layer regressor artifact."""
|
| 117 |
+
|
| 118 |
+
model_id: str
|
| 119 |
+
model_slug: str
|
| 120 |
+
run: int
|
| 121 |
+
tr_seconds: float
|
| 122 |
+
n_volumes_raw: int
|
| 123 |
+
n_tr_aligned: int
|
| 124 |
+
layer_idx: int
|
| 125 |
+
n_features: int
|
| 126 |
+
trim_start_tr: int
|
| 127 |
+
trim_end_tr: int
|
| 128 |
+
hrf_model: str
|
| 129 |
+
regressor_z_path: str
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def build_and_cache_alignment_inputs(
|
| 133 |
+
manifest_df: pd.DataFrame,
|
| 134 |
+
run_events_df: pd.DataFrame,
|
| 135 |
+
feature_summary_df: pd.DataFrame,
|
| 136 |
+
analysis_mask_path: Path,
|
| 137 |
+
output_dir: Path,
|
| 138 |
+
trim_start_tr: int,
|
| 139 |
+
trim_end_tr: int,
|
| 140 |
+
hrf_model: str,
|
| 141 |
+
overwrite: bool,
|
| 142 |
+
alignment_subjects: list[str] | None = None,
|
| 143 |
+
) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, Any]]:
|
| 144 |
+
"""Cache z-scored BOLD targets and HRF-convolved z-scored regressors."""
|
| 145 |
+
if manifest_df.empty:
|
| 146 |
+
raise ValueError("Manifest is empty; cannot build alignment inputs")
|
| 147 |
+
if run_events_df.empty:
|
| 148 |
+
raise ValueError("run_events_df is empty; cannot build regressors")
|
| 149 |
+
if feature_summary_df.empty:
|
| 150 |
+
raise ValueError("feature_summary_df is empty; feature extraction must run first")
|
| 151 |
+
|
| 152 |
+
required_manifest_columns = {"subject", "run", "tr_seconds", "n_volumes", "derivatives_bold_path"}
|
| 153 |
+
missing_manifest = required_manifest_columns.difference(manifest_df.columns)
|
| 154 |
+
if missing_manifest:
|
| 155 |
+
raise ValueError(f"manifest_df missing required columns: {sorted(missing_manifest)}")
|
| 156 |
+
|
| 157 |
+
required_feature_columns = {"model_id", "model_slug", "run", "features_npz_path"}
|
| 158 |
+
missing_feature = required_feature_columns.difference(feature_summary_df.columns)
|
| 159 |
+
if missing_feature:
|
| 160 |
+
raise ValueError(f"feature_summary_df missing required columns: {sorted(missing_feature)}")
|
| 161 |
+
|
| 162 |
+
analysis_mask_img = nib.load(str(analysis_mask_path))
|
| 163 |
+
analysis_mask_bool = analysis_mask_img.get_fdata() > 0.5
|
| 164 |
+
|
| 165 |
+
manifest_alignment_df = manifest_df
|
| 166 |
+
requested_subjects = [value for value in (alignment_subjects or []) if str(value).strip()]
|
| 167 |
+
if requested_subjects:
|
| 168 |
+
subject_set = {str(value) for value in requested_subjects}
|
| 169 |
+
manifest_alignment_df = manifest_df[manifest_df["subject"].isin(subject_set)].copy()
|
| 170 |
+
if manifest_alignment_df.empty:
|
| 171 |
+
raise ValueError(
|
| 172 |
+
"Alignment subject filter produced no rows. "
|
| 173 |
+
f"Requested subjects={sorted(subject_set)}"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
output_dir = output_dir.resolve()
|
| 177 |
+
bold_output_dir = output_dir / "bold_z"
|
| 178 |
+
regressor_output_dir = output_dir / "regressors"
|
| 179 |
+
bold_output_dir.mkdir(parents=True, exist_ok=True)
|
| 180 |
+
regressor_output_dir.mkdir(parents=True, exist_ok=True)
|
| 181 |
+
|
| 182 |
+
bold_rows: list[BoldAlignmentRecord] = []
|
| 183 |
+
|
| 184 |
+
for row in manifest_alignment_df.itertuples(index=False):
|
| 185 |
+
subject = str(getattr(row, "subject"))
|
| 186 |
+
run = int(getattr(row, "run"))
|
| 187 |
+
tr_seconds = float(getattr(row, "tr_seconds"))
|
| 188 |
+
n_volumes = int(getattr(row, "n_volumes"))
|
| 189 |
+
bold_path = Path(str(getattr(row, "derivatives_bold_path")))
|
| 190 |
+
|
| 191 |
+
bold_z_path = bold_output_dir / f"{subject}_run-{run:02d}_bold_z.npy"
|
| 192 |
+
if bold_z_path.exists() and not overwrite:
|
| 193 |
+
aligned_bold = np.load(bold_z_path)
|
| 194 |
+
else:
|
| 195 |
+
bold_2d = _extract_masked_bold_2d(
|
| 196 |
+
bold_path=bold_path,
|
| 197 |
+
analysis_mask_bool=analysis_mask_bool,
|
| 198 |
+
analysis_mask_img=analysis_mask_img,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
if bold_2d.shape[0] != n_volumes:
|
| 202 |
+
raise ValueError(
|
| 203 |
+
f"BOLD volume mismatch for {subject} run {run}: "
|
| 204 |
+
f"manifest={n_volumes}, loaded={bold_2d.shape[0]}"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
aligned_bold = _trim_time_axis(
|
| 208 |
+
values=bold_2d,
|
| 209 |
+
trim_start_tr=trim_start_tr,
|
| 210 |
+
trim_end_tr=trim_end_tr,
|
| 211 |
+
)
|
| 212 |
+
aligned_bold = _zscore_columns(aligned_bold)
|
| 213 |
+
np.save(bold_z_path, aligned_bold)
|
| 214 |
+
|
| 215 |
+
bold_rows.append(
|
| 216 |
+
BoldAlignmentRecord(
|
| 217 |
+
subject=subject,
|
| 218 |
+
run=run,
|
| 219 |
+
tr_seconds=tr_seconds,
|
| 220 |
+
n_volumes_raw=n_volumes,
|
| 221 |
+
n_tr_aligned=int(aligned_bold.shape[0]),
|
| 222 |
+
n_voxels=int(aligned_bold.shape[1]),
|
| 223 |
+
trim_start_tr=int(trim_start_tr),
|
| 224 |
+
trim_end_tr=int(trim_end_tr),
|
| 225 |
+
bold_z_path=str(bold_z_path),
|
| 226 |
+
)
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
regressor_rows: list[RegressorAlignmentRecord] = []
|
| 230 |
+
|
| 231 |
+
# Compute run configuration keys from manifest; regressors depend on run + TR + n_volumes.
|
| 232 |
+
run_config_map: dict[int, list[tuple[float, int]]] = {}
|
| 233 |
+
for run, group_df in manifest_alignment_df.groupby("run"):
|
| 234 |
+
unique_pairs = sorted(
|
| 235 |
+
{(float(tr), int(n_vol)) for tr, n_vol in zip(group_df["tr_seconds"], group_df["n_volumes"])},
|
| 236 |
+
key=lambda pair: (pair[0], pair[1]),
|
| 237 |
+
)
|
| 238 |
+
run_config_map[int(run)] = unique_pairs
|
| 239 |
+
|
| 240 |
+
for feature_row in feature_summary_df.itertuples(index=False):
|
| 241 |
+
model_id = str(getattr(feature_row, "model_id"))
|
| 242 |
+
model_slug = str(getattr(feature_row, "model_slug"))
|
| 243 |
+
run = int(getattr(feature_row, "run"))
|
| 244 |
+
features_npz_path = Path(str(getattr(feature_row, "features_npz_path")))
|
| 245 |
+
|
| 246 |
+
if run not in run_config_map:
|
| 247 |
+
continue
|
| 248 |
+
|
| 249 |
+
run_events = run_events_df[run_events_df["run"] == run].sort_values("word_index")
|
| 250 |
+
onsets_s = run_events["onset_s"].to_numpy(dtype=np.float64)
|
| 251 |
+
offsets_s = run_events["offset_s"].to_numpy(dtype=np.float64)
|
| 252 |
+
|
| 253 |
+
feature_bundle = np.load(features_npz_path)
|
| 254 |
+
layer_keys = sorted(
|
| 255 |
+
[key for key in feature_bundle.files if key.startswith("layer_")],
|
| 256 |
+
key=lambda value: int(value.split("_")[1]),
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
for tr_seconds, n_volumes in run_config_map[run]:
|
| 260 |
+
tr_tag = _format_tr_for_filename(tr_seconds)
|
| 261 |
+
|
| 262 |
+
model_regressor_dir = regressor_output_dir / model_slug
|
| 263 |
+
model_regressor_dir.mkdir(parents=True, exist_ok=True)
|
| 264 |
+
|
| 265 |
+
for layer_key in layer_keys:
|
| 266 |
+
layer_idx = int(layer_key.split("_")[1])
|
| 267 |
+
layer_features = np.asarray(feature_bundle[layer_key], dtype=np.float32)
|
| 268 |
+
|
| 269 |
+
if layer_features.shape[0] != onsets_s.shape[0]:
|
| 270 |
+
raise ValueError(
|
| 271 |
+
f"Word count mismatch for model={model_id}, run={run}, layer={layer_idx}: "
|
| 272 |
+
f"features={layer_features.shape[0]}, events={onsets_s.shape[0]}"
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
regressor_z_path = (
|
| 276 |
+
model_regressor_dir
|
| 277 |
+
/ f"run-{run:02d}_tr-{tr_tag}_nvol-{n_volumes}_layer-{layer_idx:03d}_regressor_z.npy"
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
if regressor_z_path.exists() and not overwrite:
|
| 281 |
+
regressors_aligned = np.load(regressor_z_path)
|
| 282 |
+
else:
|
| 283 |
+
regressors = compute_hrf_regressor_matrix(
|
| 284 |
+
word_features=layer_features,
|
| 285 |
+
onsets_s=onsets_s,
|
| 286 |
+
offsets_s=offsets_s,
|
| 287 |
+
tr_seconds=tr_seconds,
|
| 288 |
+
n_volumes=n_volumes,
|
| 289 |
+
hrf_model=hrf_model,
|
| 290 |
+
)
|
| 291 |
+
regressors_aligned = _trim_time_axis(
|
| 292 |
+
values=regressors,
|
| 293 |
+
trim_start_tr=trim_start_tr,
|
| 294 |
+
trim_end_tr=trim_end_tr,
|
| 295 |
+
)
|
| 296 |
+
regressors_aligned = _zscore_columns(regressors_aligned)
|
| 297 |
+
np.save(regressor_z_path, regressors_aligned)
|
| 298 |
+
|
| 299 |
+
regressor_rows.append(
|
| 300 |
+
RegressorAlignmentRecord(
|
| 301 |
+
model_id=model_id,
|
| 302 |
+
model_slug=model_slug,
|
| 303 |
+
run=run,
|
| 304 |
+
tr_seconds=tr_seconds,
|
| 305 |
+
n_volumes_raw=n_volumes,
|
| 306 |
+
n_tr_aligned=int(regressors_aligned.shape[0]),
|
| 307 |
+
layer_idx=layer_idx,
|
| 308 |
+
n_features=int(regressors_aligned.shape[1]),
|
| 309 |
+
trim_start_tr=int(trim_start_tr),
|
| 310 |
+
trim_end_tr=int(trim_end_tr),
|
| 311 |
+
hrf_model=hrf_model,
|
| 312 |
+
regressor_z_path=str(regressor_z_path),
|
| 313 |
+
)
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
bold_summary_df = pd.DataFrame([asdict(row) for row in bold_rows])
|
| 317 |
+
if not bold_summary_df.empty:
|
| 318 |
+
bold_summary_df = bold_summary_df.sort_values(["subject", "run"]).reset_index(drop=True)
|
| 319 |
+
|
| 320 |
+
regressor_summary_df = pd.DataFrame([asdict(row) for row in regressor_rows])
|
| 321 |
+
if not regressor_summary_df.empty:
|
| 322 |
+
regressor_summary_df = regressor_summary_df.sort_values(
|
| 323 |
+
["model_slug", "run", "layer_idx", "tr_seconds", "n_volumes_raw"]
|
| 324 |
+
).reset_index(drop=True)
|
| 325 |
+
|
| 326 |
+
alignment_qc: dict[str, Any] = {
|
| 327 |
+
"analysis_mask_path": str(analysis_mask_path),
|
| 328 |
+
"hrf_model": hrf_model,
|
| 329 |
+
"trim_start_tr": int(trim_start_tr),
|
| 330 |
+
"trim_end_tr": int(trim_end_tr),
|
| 331 |
+
"alignment_subjects": sorted({str(value) for value in manifest_alignment_df["subject"].tolist()}),
|
| 332 |
+
"n_bold_rows": int(len(bold_summary_df)),
|
| 333 |
+
"n_regressor_rows": int(len(regressor_summary_df)),
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
if not bold_summary_df.empty:
|
| 337 |
+
alignment_qc["bold_n_tr_min"] = int(bold_summary_df["n_tr_aligned"].min())
|
| 338 |
+
alignment_qc["bold_n_tr_max"] = int(bold_summary_df["n_tr_aligned"].max())
|
| 339 |
+
alignment_qc["bold_n_voxels_min"] = int(bold_summary_df["n_voxels"].min())
|
| 340 |
+
alignment_qc["bold_n_voxels_max"] = int(bold_summary_df["n_voxels"].max())
|
| 341 |
+
|
| 342 |
+
if not regressor_summary_df.empty:
|
| 343 |
+
alignment_qc["regressor_n_tr_min"] = int(regressor_summary_df["n_tr_aligned"].min())
|
| 344 |
+
alignment_qc["regressor_n_tr_max"] = int(regressor_summary_df["n_tr_aligned"].max())
|
| 345 |
+
alignment_qc["regressor_n_features_min"] = int(regressor_summary_df["n_features"].min())
|
| 346 |
+
alignment_qc["regressor_n_features_max"] = int(regressor_summary_df["n_features"].max())
|
| 347 |
+
|
| 348 |
+
return bold_summary_df, regressor_summary_df, alignment_qc
|
code/a1_pipeline/annotations.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Annotation harmonization for A1 baseline event tables."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
from .constants import (
|
| 13 |
+
DEFAULT_MIXED_RUN_WORD_TABLE_FALLBACK_MAP,
|
| 14 |
+
DEFAULT_RUN_WORD_TABLE_MAP,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class AnnotationSourceResolution:
|
| 20 |
+
"""Resolved source table for one run."""
|
| 21 |
+
|
| 22 |
+
run: int
|
| 23 |
+
source_word_table: str
|
| 24 |
+
annotation_used_mixed_fallback: bool
|
| 25 |
+
provenance: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
REQUIRED_WORD_TABLE_COLUMNS: tuple[str, ...] = ("word", "onset", "offset")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _validate_required_columns(df: pd.DataFrame, table_name: str) -> None:
|
| 32 |
+
missing = [column for column in REQUIRED_WORD_TABLE_COLUMNS if column not in df.columns]
|
| 33 |
+
if missing:
|
| 34 |
+
raise ValueError(f"Annotation table {table_name} missing columns: {missing}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _load_and_normalize_word_table(
|
| 38 |
+
annotation_dir: Path,
|
| 39 |
+
table_name: str,
|
| 40 |
+
time_scale_seconds: float,
|
| 41 |
+
) -> pd.DataFrame:
|
| 42 |
+
table_path = annotation_dir / table_name
|
| 43 |
+
if not table_path.exists():
|
| 44 |
+
raise FileNotFoundError(f"Annotation table not found: {table_path}")
|
| 45 |
+
|
| 46 |
+
df = pd.read_csv(table_path)
|
| 47 |
+
_validate_required_columns(df, table_name=table_name)
|
| 48 |
+
|
| 49 |
+
out = df.copy()
|
| 50 |
+
out["word"] = out["word"].astype(str).fillna("").str.strip()
|
| 51 |
+
out = out[out["word"] != ""].reset_index(drop=True)
|
| 52 |
+
|
| 53 |
+
out["onset"] = pd.to_numeric(out["onset"], errors="coerce")
|
| 54 |
+
out["offset"] = pd.to_numeric(out["offset"], errors="coerce")
|
| 55 |
+
|
| 56 |
+
if out["onset"].isna().any() or out["offset"].isna().any():
|
| 57 |
+
raise ValueError(f"Annotation table {table_name} contains non-numeric onset/offset values")
|
| 58 |
+
|
| 59 |
+
out["onset_s"] = out["onset"].astype(float) * float(time_scale_seconds)
|
| 60 |
+
out["offset_s"] = out["offset"].astype(float) * float(time_scale_seconds)
|
| 61 |
+
out["duration_s"] = out["offset_s"] - out["onset_s"]
|
| 62 |
+
out["word_index"] = np.arange(len(out), dtype=np.int64)
|
| 63 |
+
out["source_word_table"] = table_name
|
| 64 |
+
|
| 65 |
+
return out[["word_index", "word", "onset_s", "offset_s", "duration_s", "source_word_table"]]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _validate_timing_table(df: pd.DataFrame, label: str) -> list[str]:
|
| 69 |
+
errors: list[str] = []
|
| 70 |
+
|
| 71 |
+
if (df["onset_s"] < 0).any():
|
| 72 |
+
errors.append(f"{label}: negative onset_s found")
|
| 73 |
+
|
| 74 |
+
if (df["offset_s"] < 0).any():
|
| 75 |
+
errors.append(f"{label}: negative offset_s found")
|
| 76 |
+
|
| 77 |
+
if (df["duration_s"] < 0).any():
|
| 78 |
+
errors.append(f"{label}: negative duration_s found")
|
| 79 |
+
|
| 80 |
+
if (df["offset_s"] < df["onset_s"]).any():
|
| 81 |
+
errors.append(f"{label}: offset_s earlier than onset_s")
|
| 82 |
+
|
| 83 |
+
onset_diff = df["onset_s"].diff().dropna()
|
| 84 |
+
if (onset_diff < 0).any():
|
| 85 |
+
errors.append(f"{label}: onset_s is not monotonic non-decreasing")
|
| 86 |
+
|
| 87 |
+
offset_diff = df["offset_s"].diff().dropna()
|
| 88 |
+
if (offset_diff < 0).any():
|
| 89 |
+
errors.append(f"{label}: offset_s is not monotonic non-decreasing")
|
| 90 |
+
|
| 91 |
+
return errors
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _resolve_run_source(
|
| 95 |
+
run: int,
|
| 96 |
+
enable_mixed_fallback: bool,
|
| 97 |
+
run_word_table_map: dict[int, str | None],
|
| 98 |
+
mixed_fallback_map: dict[int, str],
|
| 99 |
+
) -> AnnotationSourceResolution:
|
| 100 |
+
source_table = run_word_table_map.get(run)
|
| 101 |
+
if source_table is not None:
|
| 102 |
+
return AnnotationSourceResolution(
|
| 103 |
+
run=run,
|
| 104 |
+
source_word_table=source_table,
|
| 105 |
+
annotation_used_mixed_fallback=False,
|
| 106 |
+
provenance=f"fixed_run_source:{source_table}",
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
if enable_mixed_fallback and run in mixed_fallback_map:
|
| 110 |
+
fallback_table = mixed_fallback_map[run]
|
| 111 |
+
return AnnotationSourceResolution(
|
| 112 |
+
run=run,
|
| 113 |
+
source_word_table=fallback_table,
|
| 114 |
+
annotation_used_mixed_fallback=True,
|
| 115 |
+
provenance=f"mixed_run_fallback_source:{fallback_table}",
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
raise ValueError(
|
| 119 |
+
"No annotation source available for run "
|
| 120 |
+
f"{run}. Provide a direct run mapping or enable mixed fallback."
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def build_harmonized_annotation_tables(
|
| 125 |
+
manifest_df: pd.DataFrame,
|
| 126 |
+
annotation_dir: Path,
|
| 127 |
+
time_scale_seconds: float,
|
| 128 |
+
enable_mixed_fallback: bool,
|
| 129 |
+
run_word_table_map: dict[int, str | None] | None = None,
|
| 130 |
+
mixed_fallback_map: dict[int, str] | None = None,
|
| 131 |
+
) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, Any]]:
|
| 132 |
+
"""Build run-level templates and subject-run unified annotation tables."""
|
| 133 |
+
if manifest_df.empty:
|
| 134 |
+
raise ValueError("Manifest is empty; cannot harmonize annotations")
|
| 135 |
+
|
| 136 |
+
required_columns = {
|
| 137 |
+
"subject",
|
| 138 |
+
"run",
|
| 139 |
+
"condition_fixed",
|
| 140 |
+
"condition_effective",
|
| 141 |
+
"speaker_stream",
|
| 142 |
+
"used_mixed_fallback",
|
| 143 |
+
}
|
| 144 |
+
missing = required_columns.difference(manifest_df.columns)
|
| 145 |
+
if missing:
|
| 146 |
+
raise ValueError(f"Manifest missing required columns for annotation harmonization: {sorted(missing)}")
|
| 147 |
+
|
| 148 |
+
annotation_dir = annotation_dir.resolve()
|
| 149 |
+
run_word_table_map = run_word_table_map or DEFAULT_RUN_WORD_TABLE_MAP
|
| 150 |
+
mixed_fallback_map = mixed_fallback_map or DEFAULT_MIXED_RUN_WORD_TABLE_FALLBACK_MAP
|
| 151 |
+
|
| 152 |
+
source_cache: dict[str, pd.DataFrame] = {}
|
| 153 |
+
run_template_rows: list[pd.DataFrame] = []
|
| 154 |
+
source_resolutions: list[AnnotationSourceResolution] = []
|
| 155 |
+
|
| 156 |
+
runs = sorted({int(value) for value in manifest_df["run"].tolist()})
|
| 157 |
+
|
| 158 |
+
for run in runs:
|
| 159 |
+
resolution = _resolve_run_source(
|
| 160 |
+
run=run,
|
| 161 |
+
enable_mixed_fallback=enable_mixed_fallback,
|
| 162 |
+
run_word_table_map=run_word_table_map,
|
| 163 |
+
mixed_fallback_map=mixed_fallback_map,
|
| 164 |
+
)
|
| 165 |
+
source_resolutions.append(resolution)
|
| 166 |
+
|
| 167 |
+
if resolution.source_word_table not in source_cache:
|
| 168 |
+
source_cache[resolution.source_word_table] = _load_and_normalize_word_table(
|
| 169 |
+
annotation_dir=annotation_dir,
|
| 170 |
+
table_name=resolution.source_word_table,
|
| 171 |
+
time_scale_seconds=time_scale_seconds,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
template_df = source_cache[resolution.source_word_table].copy()
|
| 175 |
+
template_df["run"] = int(run)
|
| 176 |
+
template_df["annotation_used_mixed_fallback"] = bool(resolution.annotation_used_mixed_fallback)
|
| 177 |
+
template_df["provenance"] = resolution.provenance
|
| 178 |
+
|
| 179 |
+
run_template_rows.append(template_df)
|
| 180 |
+
|
| 181 |
+
run_events_df = pd.concat(run_template_rows, ignore_index=True)
|
| 182 |
+
|
| 183 |
+
template_errors: list[str] = []
|
| 184 |
+
for run, run_df in run_events_df.groupby("run"):
|
| 185 |
+
template_errors.extend(_validate_timing_table(run_df, label=f"run_template_run{run}"))
|
| 186 |
+
|
| 187 |
+
if template_errors:
|
| 188 |
+
raise ValueError("Annotation template validation failed: " + "; ".join(template_errors))
|
| 189 |
+
|
| 190 |
+
merged_rows: list[pd.DataFrame] = []
|
| 191 |
+
for row in manifest_df.itertuples(index=False):
|
| 192 |
+
subject = str(getattr(row, "subject"))
|
| 193 |
+
run = int(getattr(row, "run"))
|
| 194 |
+
|
| 195 |
+
run_template = run_events_df[run_events_df["run"] == run].copy()
|
| 196 |
+
run_template["subject"] = subject
|
| 197 |
+
run_template["condition_fixed"] = str(getattr(row, "condition_fixed"))
|
| 198 |
+
run_template["condition_effective"] = str(getattr(row, "condition_effective"))
|
| 199 |
+
run_template["speaker_stream"] = str(getattr(row, "speaker_stream"))
|
| 200 |
+
run_template["used_mixed_fallback"] = bool(getattr(row, "used_mixed_fallback"))
|
| 201 |
+
|
| 202 |
+
merged_rows.append(run_template)
|
| 203 |
+
|
| 204 |
+
unified_df = pd.concat(merged_rows, ignore_index=True)
|
| 205 |
+
unified_df = unified_df[
|
| 206 |
+
[
|
| 207 |
+
"subject",
|
| 208 |
+
"run",
|
| 209 |
+
"word_index",
|
| 210 |
+
"word",
|
| 211 |
+
"onset_s",
|
| 212 |
+
"offset_s",
|
| 213 |
+
"duration_s",
|
| 214 |
+
"condition_fixed",
|
| 215 |
+
"condition_effective",
|
| 216 |
+
"speaker_stream",
|
| 217 |
+
"used_mixed_fallback",
|
| 218 |
+
"annotation_used_mixed_fallback",
|
| 219 |
+
"source_word_table",
|
| 220 |
+
"provenance",
|
| 221 |
+
]
|
| 222 |
+
]
|
| 223 |
+
|
| 224 |
+
unified_errors: list[str] = []
|
| 225 |
+
for (subject, run), group_df in unified_df.groupby(["subject", "run"]):
|
| 226 |
+
unified_errors.extend(_validate_timing_table(group_df, label=f"unified_{subject}_run{run}"))
|
| 227 |
+
|
| 228 |
+
if unified_errors:
|
| 229 |
+
raise ValueError("Unified annotation validation failed: " + "; ".join(unified_errors))
|
| 230 |
+
|
| 231 |
+
source_resolution_df = pd.DataFrame([asdict(value) for value in source_resolutions])
|
| 232 |
+
source_resolution_df = source_resolution_df.sort_values(["run"]).reset_index(drop=True)
|
| 233 |
+
|
| 234 |
+
run_word_counts = (
|
| 235 |
+
run_events_df.groupby("run")["word_index"].max().add(1).astype(int).to_dict()
|
| 236 |
+
if not run_events_df.empty
|
| 237 |
+
else {}
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
annotation_qc: dict[str, Any] = {
|
| 241 |
+
"annotation_dir": str(annotation_dir),
|
| 242 |
+
"time_scale_seconds": float(time_scale_seconds),
|
| 243 |
+
"enable_mixed_fallback": bool(enable_mixed_fallback),
|
| 244 |
+
"n_manifest_rows": int(len(manifest_df)),
|
| 245 |
+
"n_run_templates": int(run_events_df["run"].nunique()) if not run_events_df.empty else 0,
|
| 246 |
+
"n_unified_rows": int(len(unified_df)),
|
| 247 |
+
"run_word_counts": {str(run): int(count) for run, count in sorted(run_word_counts.items())},
|
| 248 |
+
"source_resolution": [asdict(value) for value in source_resolutions],
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
if not unified_df.empty:
|
| 252 |
+
annotation_qc["duration_s_min"] = float(unified_df["duration_s"].min())
|
| 253 |
+
annotation_qc["duration_s_max"] = float(unified_df["duration_s"].max())
|
| 254 |
+
|
| 255 |
+
run_events_df = run_events_df.sort_values(["run", "word_index"]).reset_index(drop=True)
|
| 256 |
+
unified_df = unified_df.sort_values(["subject", "run", "word_index"]).reset_index(drop=True)
|
| 257 |
+
|
| 258 |
+
return run_events_df, unified_df, source_resolution_df, annotation_qc
|
code/a1_pipeline/constants.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Constants and shared defaults for A1 SI baseline bootstrap."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
DEFAULT_ALLOWED_RUNS: tuple[int, ...] = (1, 2, 3, 4)
|
| 8 |
+
DEFAULT_TIME_SCALE_SECONDS: float = 0.01
|
| 9 |
+
DEFAULT_HRF_LAG_SECONDS: float = 5.0
|
| 10 |
+
|
| 11 |
+
# Locked baseline models from project scope.
|
| 12 |
+
DEFAULT_MODEL_IDS: tuple[str, ...] = (
|
| 13 |
+
"Qwen/Qwen3-0.6B",
|
| 14 |
+
"meta-llama/Llama-3.2-1B",
|
| 15 |
+
"GSAI-ML/LLaDA-8B-Instruct",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# Run-level word timing tables available in ds005345 annotations.
|
| 19 |
+
DEFAULT_RUN_WORD_TABLE_MAP: dict[int, str | None] = {
|
| 20 |
+
1: "single_female_word_information.csv",
|
| 21 |
+
2: "single_male_word_information.csv",
|
| 22 |
+
3: None,
|
| 23 |
+
4: None,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# Fallback sources for mixed-condition runs when dedicated mixed word timings are absent.
|
| 27 |
+
DEFAULT_MIXED_RUN_WORD_TABLE_FALLBACK_MAP: dict[int, str] = {
|
| 28 |
+
3: "single_female_word_information.csv",
|
| 29 |
+
4: "single_male_word_information.csv",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class RunCondition:
|
| 35 |
+
"""Condition labels attached to each run in the baseline mapping."""
|
| 36 |
+
|
| 37 |
+
condition_fixed: str
|
| 38 |
+
speaker_stream: str
|
| 39 |
+
condition_fallback: str | None = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
DEFAULT_RUN_CONDITION_MAP: dict[int, RunCondition] = {
|
| 43 |
+
1: RunCondition(condition_fixed="single_female", speaker_stream="female"),
|
| 44 |
+
2: RunCondition(condition_fixed="single_male", speaker_stream="male"),
|
| 45 |
+
3: RunCondition(
|
| 46 |
+
condition_fixed="mixed_female",
|
| 47 |
+
speaker_stream="mixed_female",
|
| 48 |
+
condition_fallback="mixed",
|
| 49 |
+
),
|
| 50 |
+
4: RunCondition(
|
| 51 |
+
condition_fixed="mixed_male",
|
| 52 |
+
speaker_stream="mixed_male",
|
| 53 |
+
condition_fallback="mixed",
|
| 54 |
+
),
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def resolve_run_condition(run: int, enable_mixed_fallback: bool) -> tuple[str, str, str, bool]:
|
| 59 |
+
"""Resolve fixed/effective condition labels for one run.
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
(condition_fixed, condition_effective, speaker_stream, used_mixed_fallback)
|
| 63 |
+
"""
|
| 64 |
+
if run not in DEFAULT_RUN_CONDITION_MAP:
|
| 65 |
+
raise KeyError(f"Run {run} is not present in condition map")
|
| 66 |
+
|
| 67 |
+
info = DEFAULT_RUN_CONDITION_MAP[run]
|
| 68 |
+
use_fallback = bool(enable_mixed_fallback and info.condition_fallback is not None)
|
| 69 |
+
condition_effective = info.condition_fallback if use_fallback else info.condition_fixed
|
| 70 |
+
|
| 71 |
+
return info.condition_fixed, condition_effective, info.speaker_stream, use_fallback
|
code/a1_pipeline/features.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen feature extraction wrappers for A1 baseline models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import re
|
| 9 |
+
from typing import Any
|
| 10 |
+
import warnings
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def slugify_model_id(model_id: str) -> str:
|
| 17 |
+
cleaned = re.sub(r"[^a-zA-Z0-9._-]+", "_", model_id.strip())
|
| 18 |
+
return cleaned.strip("_") or "unknown_model"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _is_llada_model_id(model_id: str) -> bool:
|
| 22 |
+
return "llada" in model_id.lower()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _apply_llada_compat_patches(model_id: str, local_files_only: bool) -> None:
|
| 26 |
+
"""Apply compatibility patches for LLaDA remote-code models."""
|
| 27 |
+
try:
|
| 28 |
+
import transformers.modeling_utils as modeling_utils
|
| 29 |
+
|
| 30 |
+
if not hasattr(modeling_utils.PreTrainedModel, "all_tied_weights_keys"):
|
| 31 |
+
modeling_utils.PreTrainedModel.all_tied_weights_keys = {}
|
| 32 |
+
elif not isinstance(modeling_utils.PreTrainedModel.all_tied_weights_keys, dict):
|
| 33 |
+
modeling_utils.PreTrainedModel.all_tied_weights_keys = {}
|
| 34 |
+
except Exception:
|
| 35 |
+
# Best effort; continue with standard load flow.
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from transformers import AutoConfig
|
| 40 |
+
from transformers.dynamic_module_utils import get_class_from_dynamic_module
|
| 41 |
+
|
| 42 |
+
config = AutoConfig.from_pretrained(
|
| 43 |
+
model_id,
|
| 44 |
+
trust_remote_code=True,
|
| 45 |
+
local_files_only=local_files_only,
|
| 46 |
+
)
|
| 47 |
+
auto_map = getattr(config, "auto_map", None) or {}
|
| 48 |
+
class_ref = auto_map.get("AutoModelForCausalLM")
|
| 49 |
+
if not class_ref:
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
model_cls = get_class_from_dynamic_module(
|
| 53 |
+
class_ref,
|
| 54 |
+
model_id,
|
| 55 |
+
local_files_only=local_files_only,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
original_tie = getattr(model_cls, "tie_weights", None)
|
| 59 |
+
if original_tie is None or getattr(original_tie, "_a1_llada_safe_wrapped", False):
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
def _safe_tie_weights(self: Any, *args: Any, **kwargs: Any) -> Any:
|
| 63 |
+
kwargs.pop("missing_keys", None)
|
| 64 |
+
kwargs.pop("recompute_mapping", None)
|
| 65 |
+
try:
|
| 66 |
+
return original_tie(self, *args, **kwargs)
|
| 67 |
+
except TypeError as exc:
|
| 68 |
+
if "unexpected keyword argument" in str(exc):
|
| 69 |
+
return original_tie(self)
|
| 70 |
+
raise
|
| 71 |
+
|
| 72 |
+
_safe_tie_weights._a1_llada_safe_wrapped = True
|
| 73 |
+
setattr(model_cls, "tie_weights", _safe_tie_weights)
|
| 74 |
+
except Exception:
|
| 75 |
+
# Best effort; continue with standard load flow.
|
| 76 |
+
pass
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _normalize_all_tied_weights_keys(model: Any) -> None:
|
| 80 |
+
"""Normalize missing/incompatible all_tied_weights_keys on loaded models."""
|
| 81 |
+
try:
|
| 82 |
+
tied = getattr(model, "all_tied_weights_keys", None)
|
| 83 |
+
if tied is None:
|
| 84 |
+
model.all_tied_weights_keys = {}
|
| 85 |
+
elif callable(tied):
|
| 86 |
+
value = tied()
|
| 87 |
+
model.all_tied_weights_keys = value if isinstance(value, dict) else {}
|
| 88 |
+
elif not isinstance(tied, dict):
|
| 89 |
+
model.all_tied_weights_keys = {}
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _load_causal_lm(
|
| 95 |
+
model_id: str,
|
| 96 |
+
model_dtype: Any,
|
| 97 |
+
local_files_only: bool,
|
| 98 |
+
) -> Any:
|
| 99 |
+
from transformers import AutoModelForCausalLM
|
| 100 |
+
|
| 101 |
+
is_llada = _is_llada_model_id(model_id)
|
| 102 |
+
if is_llada:
|
| 103 |
+
_apply_llada_compat_patches(model_id=model_id, local_files_only=local_files_only)
|
| 104 |
+
|
| 105 |
+
load_kwargs: dict[str, Any] = {
|
| 106 |
+
"output_hidden_states": True,
|
| 107 |
+
"dtype": model_dtype,
|
| 108 |
+
"local_files_only": local_files_only,
|
| 109 |
+
}
|
| 110 |
+
if is_llada:
|
| 111 |
+
load_kwargs["trust_remote_code"] = True
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
model = AutoModelForCausalLM.from_pretrained(model_id, **load_kwargs)
|
| 115 |
+
except AttributeError as exc:
|
| 116 |
+
if not (is_llada and "all_tied_weights_keys" in str(exc)):
|
| 117 |
+
raise
|
| 118 |
+
|
| 119 |
+
_apply_llada_compat_patches(model_id=model_id, local_files_only=local_files_only)
|
| 120 |
+
try:
|
| 121 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 122 |
+
model_id,
|
| 123 |
+
low_cpu_mem_usage=False,
|
| 124 |
+
**load_kwargs,
|
| 125 |
+
)
|
| 126 |
+
except TypeError:
|
| 127 |
+
model = AutoModelForCausalLM.from_pretrained(model_id, **load_kwargs)
|
| 128 |
+
|
| 129 |
+
_normalize_all_tied_weights_keys(model)
|
| 130 |
+
if hasattr(model, "config") and not hasattr(model.config, "use_cache"):
|
| 131 |
+
try:
|
| 132 |
+
model.config.use_cache = False
|
| 133 |
+
except Exception:
|
| 134 |
+
pass
|
| 135 |
+
|
| 136 |
+
return model
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@dataclass(frozen=True)
|
| 140 |
+
class FeatureExtractionRecord:
|
| 141 |
+
"""Summary row for cached run-level feature extraction."""
|
| 142 |
+
|
| 143 |
+
model_id: str
|
| 144 |
+
model_slug: str
|
| 145 |
+
run: int
|
| 146 |
+
n_words: int
|
| 147 |
+
n_layers: int
|
| 148 |
+
hidden_dim: int
|
| 149 |
+
unmatched_words: int
|
| 150 |
+
max_words_per_chunk: int
|
| 151 |
+
dry_run: bool
|
| 152 |
+
device: str
|
| 153 |
+
features_npz_path: str
|
| 154 |
+
metadata_json_path: str
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _parse_model_max_length(model: Any, tokenizer: Any) -> int:
|
| 158 |
+
candidate_values: list[int] = []
|
| 159 |
+
|
| 160 |
+
max_position_embeddings = getattr(getattr(model, "config", None), "max_position_embeddings", None)
|
| 161 |
+
if isinstance(max_position_embeddings, int) and max_position_embeddings > 0:
|
| 162 |
+
candidate_values.append(int(max_position_embeddings))
|
| 163 |
+
|
| 164 |
+
tokenizer_max = getattr(tokenizer, "model_max_length", None)
|
| 165 |
+
if isinstance(tokenizer_max, int) and 0 < tokenizer_max < 100000:
|
| 166 |
+
candidate_values.append(int(tokenizer_max))
|
| 167 |
+
|
| 168 |
+
if candidate_values:
|
| 169 |
+
return int(min(candidate_values))
|
| 170 |
+
|
| 171 |
+
return 4096
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _build_text_and_word_spans(words: list[str]) -> tuple[str, list[tuple[int, int]]]:
|
| 175 |
+
safe_words = [str(word) for word in words]
|
| 176 |
+
|
| 177 |
+
spans: list[tuple[int, int]] = []
|
| 178 |
+
cursor = 0
|
| 179 |
+
chunks: list[str] = []
|
| 180 |
+
|
| 181 |
+
for idx, word in enumerate(safe_words):
|
| 182 |
+
start = cursor
|
| 183 |
+
end = start + len(word)
|
| 184 |
+
spans.append((start, end))
|
| 185 |
+
chunks.append(word)
|
| 186 |
+
|
| 187 |
+
cursor = end
|
| 188 |
+
if idx < len(safe_words) - 1:
|
| 189 |
+
chunks.append(" ")
|
| 190 |
+
cursor += 1
|
| 191 |
+
|
| 192 |
+
return "".join(chunks), spans
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _map_tokens_to_words(
|
| 196 |
+
token_offsets: list[tuple[int, int]],
|
| 197 |
+
word_spans: list[tuple[int, int]],
|
| 198 |
+
) -> tuple[list[list[int]], int]:
|
| 199 |
+
token_to_word: list[list[int]] = [[] for _ in range(len(word_spans))]
|
| 200 |
+
|
| 201 |
+
valid_token_centers: list[tuple[int, float]] = []
|
| 202 |
+
|
| 203 |
+
for token_index, (token_start, token_end) in enumerate(token_offsets):
|
| 204 |
+
if token_end <= token_start:
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
valid_token_centers.append((token_index, (token_start + token_end) * 0.5))
|
| 208 |
+
|
| 209 |
+
for word_index, (word_start, word_end) in enumerate(word_spans):
|
| 210 |
+
overlaps = token_end > word_start and token_start < word_end
|
| 211 |
+
if overlaps:
|
| 212 |
+
token_to_word[word_index].append(token_index)
|
| 213 |
+
break
|
| 214 |
+
|
| 215 |
+
unmatched_words = 0
|
| 216 |
+
if valid_token_centers:
|
| 217 |
+
centers = np.array([center for _, center in valid_token_centers], dtype=np.float64)
|
| 218 |
+
indices = [idx for idx, _ in valid_token_centers]
|
| 219 |
+
|
| 220 |
+
for word_index, word_tokens in enumerate(token_to_word):
|
| 221 |
+
if word_tokens:
|
| 222 |
+
continue
|
| 223 |
+
|
| 224 |
+
unmatched_words += 1
|
| 225 |
+
word_start, word_end = word_spans[word_index]
|
| 226 |
+
word_center = (word_start + word_end) * 0.5
|
| 227 |
+
nearest_idx = int(np.argmin(np.abs(centers - word_center)))
|
| 228 |
+
token_to_word[word_index] = [indices[nearest_idx]]
|
| 229 |
+
else:
|
| 230 |
+
unmatched_words = len(token_to_word)
|
| 231 |
+
|
| 232 |
+
return token_to_word, unmatched_words
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _extract_chunk_features(
|
| 236 |
+
words_chunk: list[str],
|
| 237 |
+
model: Any,
|
| 238 |
+
tokenizer: Any,
|
| 239 |
+
device: str,
|
| 240 |
+
selected_layers: list[int],
|
| 241 |
+
) -> tuple[dict[int, np.ndarray], dict[str, Any]]:
|
| 242 |
+
import torch
|
| 243 |
+
|
| 244 |
+
if not getattr(tokenizer, "is_fast", False):
|
| 245 |
+
raise RuntimeError(
|
| 246 |
+
"Fast tokenizer with offset mapping is required for word-level aggregation."
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
text, word_spans = _build_text_and_word_spans(words_chunk)
|
| 250 |
+
max_length = _parse_model_max_length(model=model, tokenizer=tokenizer)
|
| 251 |
+
|
| 252 |
+
encoded = tokenizer(
|
| 253 |
+
text,
|
| 254 |
+
return_tensors="pt",
|
| 255 |
+
return_offsets_mapping=True,
|
| 256 |
+
truncation=True,
|
| 257 |
+
max_length=max_length,
|
| 258 |
+
add_special_tokens=True,
|
| 259 |
+
return_overflowing_tokens=True,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
input_ids = encoded["input_ids"]
|
| 263 |
+
if input_ids.shape[0] != 1:
|
| 264 |
+
raise RuntimeError(
|
| 265 |
+
"Tokenizer overflow produced multiple windows. "
|
| 266 |
+
"Decrease --max-words-per-chunk."
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
offset_mapping = encoded.pop("offset_mapping")[0].cpu().numpy().tolist()
|
| 270 |
+
|
| 271 |
+
model_inputs: dict[str, Any] = {}
|
| 272 |
+
for key, value in encoded.items():
|
| 273 |
+
if key in {"overflow_to_sample_mapping", "num_truncated_tokens"}:
|
| 274 |
+
continue
|
| 275 |
+
model_inputs[key] = value.to(device)
|
| 276 |
+
|
| 277 |
+
with torch.no_grad():
|
| 278 |
+
try:
|
| 279 |
+
outputs = model(**model_inputs, output_hidden_states=True, use_cache=False)
|
| 280 |
+
except TypeError as exc:
|
| 281 |
+
if "unexpected keyword argument" not in str(exc) or "use_cache" not in str(exc):
|
| 282 |
+
raise
|
| 283 |
+
outputs = model(**model_inputs, output_hidden_states=True)
|
| 284 |
+
|
| 285 |
+
hidden_states = outputs.hidden_states
|
| 286 |
+
if hidden_states is None:
|
| 287 |
+
raise RuntimeError("Model did not return hidden states")
|
| 288 |
+
|
| 289 |
+
token_to_word, unmatched_words = _map_tokens_to_words(
|
| 290 |
+
token_offsets=[(int(start), int(end)) for start, end in offset_mapping],
|
| 291 |
+
word_spans=word_spans,
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
per_layer_features: dict[int, np.ndarray] = {}
|
| 295 |
+
hidden_dim = int(hidden_states[selected_layers[0]].shape[-1])
|
| 296 |
+
|
| 297 |
+
for layer_idx in selected_layers:
|
| 298 |
+
layer_tokens = hidden_states[layer_idx][0].detach().float().cpu().numpy()
|
| 299 |
+
layer_word = np.zeros((len(words_chunk), hidden_dim), dtype=np.float32)
|
| 300 |
+
|
| 301 |
+
for word_index, token_indices in enumerate(token_to_word):
|
| 302 |
+
valid = [idx for idx in token_indices if 0 <= idx < layer_tokens.shape[0]]
|
| 303 |
+
if not valid:
|
| 304 |
+
continue
|
| 305 |
+
layer_word[word_index] = np.mean(layer_tokens[valid], axis=0, dtype=np.float32)
|
| 306 |
+
|
| 307 |
+
per_layer_features[layer_idx] = layer_word
|
| 308 |
+
|
| 309 |
+
diagnostics = {
|
| 310 |
+
"n_words": int(len(words_chunk)),
|
| 311 |
+
"n_tokens": int(len(offset_mapping)),
|
| 312 |
+
"unmatched_words": int(unmatched_words),
|
| 313 |
+
}
|
| 314 |
+
return per_layer_features, diagnostics
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _extract_real_features_for_run(
|
| 318 |
+
words: list[str],
|
| 319 |
+
model: Any,
|
| 320 |
+
tokenizer: Any,
|
| 321 |
+
device: str,
|
| 322 |
+
layer_indices: list[int] | None,
|
| 323 |
+
max_words_per_chunk: int,
|
| 324 |
+
) -> tuple[dict[int, np.ndarray], dict[str, Any]]:
|
| 325 |
+
if max_words_per_chunk <= 0:
|
| 326 |
+
raise ValueError("max_words_per_chunk must be positive")
|
| 327 |
+
|
| 328 |
+
if not words:
|
| 329 |
+
raise ValueError("Cannot extract features from an empty word list")
|
| 330 |
+
|
| 331 |
+
n_all_layers = int(getattr(model.config, "num_hidden_layers", 0)) + 1
|
| 332 |
+
selected_layers = layer_indices if layer_indices is not None else list(range(n_all_layers))
|
| 333 |
+
|
| 334 |
+
for layer_idx in selected_layers:
|
| 335 |
+
if layer_idx < 0 or layer_idx >= n_all_layers:
|
| 336 |
+
raise ValueError(f"Layer index {layer_idx} out of range [0, {n_all_layers - 1}]")
|
| 337 |
+
|
| 338 |
+
chunk_outputs: dict[int, list[np.ndarray]] = {layer_idx: [] for layer_idx in selected_layers}
|
| 339 |
+
total_unmatched_words = 0
|
| 340 |
+
total_tokens = 0
|
| 341 |
+
|
| 342 |
+
start = 0
|
| 343 |
+
while start < len(words):
|
| 344 |
+
stop = min(start + max_words_per_chunk, len(words))
|
| 345 |
+
chunk_words = words[start:stop]
|
| 346 |
+
|
| 347 |
+
per_layer_chunk, chunk_diag = _extract_chunk_features(
|
| 348 |
+
words_chunk=chunk_words,
|
| 349 |
+
model=model,
|
| 350 |
+
tokenizer=tokenizer,
|
| 351 |
+
device=device,
|
| 352 |
+
selected_layers=selected_layers,
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
total_unmatched_words += int(chunk_diag["unmatched_words"])
|
| 356 |
+
total_tokens += int(chunk_diag["n_tokens"])
|
| 357 |
+
|
| 358 |
+
for layer_idx in selected_layers:
|
| 359 |
+
chunk_outputs[layer_idx].append(per_layer_chunk[layer_idx])
|
| 360 |
+
|
| 361 |
+
start = stop
|
| 362 |
+
|
| 363 |
+
outputs: dict[int, np.ndarray] = {
|
| 364 |
+
layer_idx: np.concatenate(chunks, axis=0).astype(np.float32)
|
| 365 |
+
for layer_idx, chunks in chunk_outputs.items()
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
hidden_dim = int(outputs[selected_layers[0]].shape[1])
|
| 369 |
+
diagnostics = {
|
| 370 |
+
"n_words": int(len(words)),
|
| 371 |
+
"n_layers": int(len(selected_layers)),
|
| 372 |
+
"hidden_dim": hidden_dim,
|
| 373 |
+
"unmatched_words": int(total_unmatched_words),
|
| 374 |
+
"n_tokens_total": int(total_tokens),
|
| 375 |
+
"selected_layers": selected_layers,
|
| 376 |
+
}
|
| 377 |
+
return outputs, diagnostics
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def _extract_dry_run_features_for_run(
|
| 381 |
+
words: list[str],
|
| 382 |
+
model_id: str,
|
| 383 |
+
run: int,
|
| 384 |
+
dry_run_n_layers: int,
|
| 385 |
+
dry_run_hidden_dim: int,
|
| 386 |
+
) -> tuple[dict[int, np.ndarray], dict[str, Any]]:
|
| 387 |
+
if dry_run_n_layers <= 0:
|
| 388 |
+
raise ValueError("dry_run_n_layers must be positive")
|
| 389 |
+
if dry_run_hidden_dim <= 0:
|
| 390 |
+
raise ValueError("dry_run_hidden_dim must be positive")
|
| 391 |
+
|
| 392 |
+
n_words = len(words)
|
| 393 |
+
seed = abs(hash((model_id, int(run), n_words))) % (2**32)
|
| 394 |
+
rng = np.random.default_rng(seed)
|
| 395 |
+
|
| 396 |
+
outputs: dict[int, np.ndarray] = {}
|
| 397 |
+
for layer_idx in range(dry_run_n_layers):
|
| 398 |
+
features = rng.standard_normal(size=(n_words, dry_run_hidden_dim)).astype(np.float32)
|
| 399 |
+
outputs[layer_idx] = features
|
| 400 |
+
|
| 401 |
+
diagnostics = {
|
| 402 |
+
"n_words": int(n_words),
|
| 403 |
+
"n_layers": int(dry_run_n_layers),
|
| 404 |
+
"hidden_dim": int(dry_run_hidden_dim),
|
| 405 |
+
"unmatched_words": 0,
|
| 406 |
+
"n_tokens_total": int(n_words),
|
| 407 |
+
"selected_layers": list(range(dry_run_n_layers)),
|
| 408 |
+
}
|
| 409 |
+
return outputs, diagnostics
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def extract_and_cache_run_level_features(
|
| 413 |
+
run_events_df: pd.DataFrame,
|
| 414 |
+
model_ids: list[str],
|
| 415 |
+
output_dir: Path,
|
| 416 |
+
layer_indices: list[int] | None,
|
| 417 |
+
max_words_per_chunk: int,
|
| 418 |
+
dry_run: bool,
|
| 419 |
+
dry_run_n_layers: int,
|
| 420 |
+
dry_run_hidden_dim: int,
|
| 421 |
+
device: str,
|
| 422 |
+
local_files_only: bool,
|
| 423 |
+
overwrite: bool,
|
| 424 |
+
) -> tuple[pd.DataFrame, dict[str, Any]]:
|
| 425 |
+
"""Extract and cache run-level word features for each model."""
|
| 426 |
+
if run_events_df.empty:
|
| 427 |
+
raise ValueError("run_events_df is empty; cannot extract features")
|
| 428 |
+
|
| 429 |
+
required_columns = {"run", "word_index", "word", "onset_s", "offset_s"}
|
| 430 |
+
missing = required_columns.difference(run_events_df.columns)
|
| 431 |
+
if missing:
|
| 432 |
+
raise ValueError(f"run_events_df missing required columns: {sorted(missing)}")
|
| 433 |
+
|
| 434 |
+
output_dir = output_dir.resolve()
|
| 435 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 436 |
+
|
| 437 |
+
runs = sorted({int(run) for run in run_events_df["run"].tolist()})
|
| 438 |
+
|
| 439 |
+
summary_rows: list[FeatureExtractionRecord] = []
|
| 440 |
+
|
| 441 |
+
for model_id in model_ids:
|
| 442 |
+
model_slug = slugify_model_id(model_id)
|
| 443 |
+
model_output_dir = output_dir / model_slug
|
| 444 |
+
model_output_dir.mkdir(parents=True, exist_ok=True)
|
| 445 |
+
|
| 446 |
+
resolved_device = "dry-run"
|
| 447 |
+
model = None
|
| 448 |
+
tokenizer = None
|
| 449 |
+
|
| 450 |
+
if not dry_run:
|
| 451 |
+
import torch
|
| 452 |
+
from transformers import AutoTokenizer
|
| 453 |
+
|
| 454 |
+
is_llada = _is_llada_model_id(model_id)
|
| 455 |
+
|
| 456 |
+
if device == "auto":
|
| 457 |
+
resolved_device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 458 |
+
else:
|
| 459 |
+
resolved_device = device
|
| 460 |
+
|
| 461 |
+
model_dtype = torch.float16 if resolved_device.startswith("cuda") else torch.float32
|
| 462 |
+
|
| 463 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 464 |
+
model_id,
|
| 465 |
+
use_fast=True,
|
| 466 |
+
local_files_only=local_files_only,
|
| 467 |
+
trust_remote_code=is_llada,
|
| 468 |
+
)
|
| 469 |
+
if tokenizer.pad_token is None:
|
| 470 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 471 |
+
|
| 472 |
+
model = _load_causal_lm(
|
| 473 |
+
model_id,
|
| 474 |
+
model_dtype=model_dtype,
|
| 475 |
+
local_files_only=local_files_only,
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
try:
|
| 479 |
+
model.to(resolved_device)
|
| 480 |
+
except torch.OutOfMemoryError as exc:
|
| 481 |
+
if device != "auto" or not resolved_device.startswith("cuda"):
|
| 482 |
+
raise RuntimeError(
|
| 483 |
+
"CUDA out of memory while moving model to device. "
|
| 484 |
+
"Retry with --feature-device cpu or reduce model size."
|
| 485 |
+
) from exc
|
| 486 |
+
|
| 487 |
+
warnings.warn(
|
| 488 |
+
f"CUDA OOM while loading model {model_id}; falling back to CPU.",
|
| 489 |
+
RuntimeWarning,
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
try:
|
| 493 |
+
del model
|
| 494 |
+
torch.cuda.empty_cache()
|
| 495 |
+
except Exception:
|
| 496 |
+
pass
|
| 497 |
+
|
| 498 |
+
resolved_device = "cpu"
|
| 499 |
+
model_dtype = torch.float32
|
| 500 |
+
model = _load_causal_lm(
|
| 501 |
+
model_id,
|
| 502 |
+
model_dtype=model_dtype,
|
| 503 |
+
local_files_only=local_files_only,
|
| 504 |
+
)
|
| 505 |
+
model.to(resolved_device)
|
| 506 |
+
|
| 507 |
+
model.eval()
|
| 508 |
+
|
| 509 |
+
for run in runs:
|
| 510 |
+
run_df = run_events_df[run_events_df["run"] == run].sort_values("word_index")
|
| 511 |
+
words = run_df["word"].astype(str).tolist()
|
| 512 |
+
|
| 513 |
+
npz_path = model_output_dir / f"run-{run:02d}_features.npz"
|
| 514 |
+
metadata_path = model_output_dir / f"run-{run:02d}_metadata.json"
|
| 515 |
+
|
| 516 |
+
if npz_path.exists() and metadata_path.exists() and not overwrite:
|
| 517 |
+
with metadata_path.open("r", encoding="utf-8") as handle:
|
| 518 |
+
metadata = json.load(handle)
|
| 519 |
+
|
| 520 |
+
summary_rows.append(
|
| 521 |
+
FeatureExtractionRecord(
|
| 522 |
+
model_id=model_id,
|
| 523 |
+
model_slug=model_slug,
|
| 524 |
+
run=int(run),
|
| 525 |
+
n_words=int(metadata["n_words"]),
|
| 526 |
+
n_layers=int(metadata["n_layers"]),
|
| 527 |
+
hidden_dim=int(metadata["hidden_dim"]),
|
| 528 |
+
unmatched_words=int(metadata.get("unmatched_words", 0)),
|
| 529 |
+
max_words_per_chunk=int(metadata.get("max_words_per_chunk", max_words_per_chunk)),
|
| 530 |
+
dry_run=bool(metadata.get("dry_run", dry_run)),
|
| 531 |
+
device=str(metadata.get("device", resolved_device)),
|
| 532 |
+
features_npz_path=str(npz_path),
|
| 533 |
+
metadata_json_path=str(metadata_path),
|
| 534 |
+
)
|
| 535 |
+
)
|
| 536 |
+
continue
|
| 537 |
+
|
| 538 |
+
if dry_run:
|
| 539 |
+
feature_map, diagnostics = _extract_dry_run_features_for_run(
|
| 540 |
+
words=words,
|
| 541 |
+
model_id=model_id,
|
| 542 |
+
run=int(run),
|
| 543 |
+
dry_run_n_layers=dry_run_n_layers,
|
| 544 |
+
dry_run_hidden_dim=dry_run_hidden_dim,
|
| 545 |
+
)
|
| 546 |
+
else:
|
| 547 |
+
assert model is not None
|
| 548 |
+
assert tokenizer is not None
|
| 549 |
+
try:
|
| 550 |
+
feature_map, diagnostics = _extract_real_features_for_run(
|
| 551 |
+
words=words,
|
| 552 |
+
model=model,
|
| 553 |
+
tokenizer=tokenizer,
|
| 554 |
+
device=resolved_device,
|
| 555 |
+
layer_indices=layer_indices,
|
| 556 |
+
max_words_per_chunk=max_words_per_chunk,
|
| 557 |
+
)
|
| 558 |
+
except torch.OutOfMemoryError as exc:
|
| 559 |
+
if device != "auto" or not resolved_device.startswith("cuda"):
|
| 560 |
+
raise RuntimeError(
|
| 561 |
+
"CUDA out of memory during feature extraction. "
|
| 562 |
+
"Retry with --feature-device cpu or reduce --max-words-per-chunk."
|
| 563 |
+
) from exc
|
| 564 |
+
|
| 565 |
+
warnings.warn(
|
| 566 |
+
(
|
| 567 |
+
f"CUDA OOM during feature extraction for model {model_id}, run={run}; "
|
| 568 |
+
"falling back to CPU and retrying."
|
| 569 |
+
),
|
| 570 |
+
RuntimeWarning,
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
torch.cuda.empty_cache()
|
| 574 |
+
resolved_device = "cpu"
|
| 575 |
+
model.to(resolved_device)
|
| 576 |
+
|
| 577 |
+
feature_map, diagnostics = _extract_real_features_for_run(
|
| 578 |
+
words=words,
|
| 579 |
+
model=model,
|
| 580 |
+
tokenizer=tokenizer,
|
| 581 |
+
device=resolved_device,
|
| 582 |
+
layer_indices=layer_indices,
|
| 583 |
+
max_words_per_chunk=max_words_per_chunk,
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
np.savez(
|
| 587 |
+
npz_path,
|
| 588 |
+
**{f"layer_{layer_idx}": values for layer_idx, values in feature_map.items()},
|
| 589 |
+
onset_s=run_df["onset_s"].to_numpy(dtype=np.float32),
|
| 590 |
+
offset_s=run_df["offset_s"].to_numpy(dtype=np.float32),
|
| 591 |
+
word_index=run_df["word_index"].to_numpy(dtype=np.int64),
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
metadata = {
|
| 595 |
+
"model_id": model_id,
|
| 596 |
+
"model_slug": model_slug,
|
| 597 |
+
"run": int(run),
|
| 598 |
+
"n_words": int(diagnostics["n_words"]),
|
| 599 |
+
"n_layers": int(diagnostics["n_layers"]),
|
| 600 |
+
"hidden_dim": int(diagnostics["hidden_dim"]),
|
| 601 |
+
"unmatched_words": int(diagnostics["unmatched_words"]),
|
| 602 |
+
"n_tokens_total": int(diagnostics["n_tokens_total"]),
|
| 603 |
+
"selected_layers": [int(value) for value in diagnostics["selected_layers"]],
|
| 604 |
+
"max_words_per_chunk": int(max_words_per_chunk),
|
| 605 |
+
"dry_run": bool(dry_run),
|
| 606 |
+
"device": str(resolved_device),
|
| 607 |
+
"features_npz_path": str(npz_path),
|
| 608 |
+
}
|
| 609 |
+
with metadata_path.open("w", encoding="utf-8") as handle:
|
| 610 |
+
json.dump(metadata, handle, indent=2, sort_keys=True)
|
| 611 |
+
|
| 612 |
+
summary_rows.append(
|
| 613 |
+
FeatureExtractionRecord(
|
| 614 |
+
model_id=model_id,
|
| 615 |
+
model_slug=model_slug,
|
| 616 |
+
run=int(run),
|
| 617 |
+
n_words=int(diagnostics["n_words"]),
|
| 618 |
+
n_layers=int(diagnostics["n_layers"]),
|
| 619 |
+
hidden_dim=int(diagnostics["hidden_dim"]),
|
| 620 |
+
unmatched_words=int(diagnostics["unmatched_words"]),
|
| 621 |
+
max_words_per_chunk=int(max_words_per_chunk),
|
| 622 |
+
dry_run=bool(dry_run),
|
| 623 |
+
device=str(resolved_device),
|
| 624 |
+
features_npz_path=str(npz_path),
|
| 625 |
+
metadata_json_path=str(metadata_path),
|
| 626 |
+
)
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
if model is not None:
|
| 630 |
+
del model
|
| 631 |
+
del tokenizer
|
| 632 |
+
|
| 633 |
+
summary_df = pd.DataFrame([asdict(row) for row in summary_rows])
|
| 634 |
+
if not summary_df.empty:
|
| 635 |
+
summary_df = summary_df.sort_values(["model_slug", "run"]).reset_index(drop=True)
|
| 636 |
+
|
| 637 |
+
feature_qc: dict[str, Any] = {
|
| 638 |
+
"n_models": int(len({row.model_slug for row in summary_rows})),
|
| 639 |
+
"n_model_run_rows": int(len(summary_rows)),
|
| 640 |
+
"dry_run": bool(dry_run),
|
| 641 |
+
"max_words_per_chunk": int(max_words_per_chunk),
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
if not summary_df.empty:
|
| 645 |
+
feature_qc["n_layers_min"] = int(summary_df["n_layers"].min())
|
| 646 |
+
feature_qc["n_layers_max"] = int(summary_df["n_layers"].max())
|
| 647 |
+
feature_qc["hidden_dim_min"] = int(summary_df["hidden_dim"].min())
|
| 648 |
+
feature_qc["hidden_dim_max"] = int(summary_df["hidden_dim"].max())
|
| 649 |
+
feature_qc["unmatched_words_total"] = int(summary_df["unmatched_words"].sum())
|
| 650 |
+
|
| 651 |
+
return summary_df, feature_qc
|
code/a1_pipeline/io_utils.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Simple I/O helpers for A1 bootstrap artifacts."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def ensure_directory(path: Path) -> Path:
|
| 11 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 12 |
+
return path
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def write_json(path: Path, payload: dict[str, Any]) -> None:
|
| 16 |
+
with path.open("w", encoding="utf-8") as handle:
|
| 17 |
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
code/a1_pipeline/manifest.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manifest creation and integrity checks for ds005345 derivatives."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import re
|
| 9 |
+
from typing import Any, Iterable
|
| 10 |
+
|
| 11 |
+
import nibabel as nib
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
from .constants import resolve_run_condition
|
| 15 |
+
|
| 16 |
+
# Only multitalker preprocessed BOLD files are valid candidates for A1.
|
| 17 |
+
MULTITALKER_BOLD_RE = re.compile(
|
| 18 |
+
r"^(sub-\d+)_task-multitalker_run-(\d+)_desc-preproc_bold\.nii\.gz$"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class ManifestRecord:
|
| 24 |
+
"""Accepted subject-run record used by later pipeline phases."""
|
| 25 |
+
|
| 26 |
+
subject: str
|
| 27 |
+
run: int
|
| 28 |
+
condition_fixed: str
|
| 29 |
+
condition_effective: str
|
| 30 |
+
speaker_stream: str
|
| 31 |
+
used_mixed_fallback: bool
|
| 32 |
+
derivatives_bold_path: str
|
| 33 |
+
raw_bold_json_path: str
|
| 34 |
+
tr_seconds: float
|
| 35 |
+
n_volumes: int
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class RejectRecord:
|
| 40 |
+
"""Rejected candidate with explicit reason for auditability."""
|
| 41 |
+
|
| 42 |
+
derivatives_bold_path: str
|
| 43 |
+
folder_subject: str
|
| 44 |
+
filename_subject: str
|
| 45 |
+
run: int | None
|
| 46 |
+
reason: str
|
| 47 |
+
details: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _safe_read_json(path: Path) -> dict[str, Any] | None:
|
| 51 |
+
try:
|
| 52 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 53 |
+
payload = json.load(handle)
|
| 54 |
+
if isinstance(payload, dict):
|
| 55 |
+
return payload
|
| 56 |
+
return None
|
| 57 |
+
except Exception:
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _extract_repetition_time_seconds(payload: dict[str, Any]) -> float | None:
|
| 62 |
+
# BIDS RepetitionTime is in seconds.
|
| 63 |
+
for key in ("RepetitionTime", "RepetitionTimeExcitation"):
|
| 64 |
+
value = payload.get(key)
|
| 65 |
+
if value is None:
|
| 66 |
+
continue
|
| 67 |
+
try:
|
| 68 |
+
tr = float(value)
|
| 69 |
+
if tr > 0:
|
| 70 |
+
return tr
|
| 71 |
+
except (TypeError, ValueError):
|
| 72 |
+
continue
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _read_n_volumes(path: Path, deep_integrity_check: bool) -> int | None:
|
| 77 |
+
try:
|
| 78 |
+
image = nib.load(str(path))
|
| 79 |
+
shape = image.shape
|
| 80 |
+
if len(shape) != 4:
|
| 81 |
+
return None
|
| 82 |
+
n_volumes = int(shape[3])
|
| 83 |
+
|
| 84 |
+
if n_volumes <= 0:
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
if deep_integrity_check:
|
| 88 |
+
# Force-read the last volume to catch truncated gzip streams early.
|
| 89 |
+
import numpy as np
|
| 90 |
+
|
| 91 |
+
_ = np.asanyarray(image.dataobj[..., n_volumes - 1])
|
| 92 |
+
|
| 93 |
+
return n_volumes
|
| 94 |
+
except Exception:
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _iter_candidate_bold_files(derivatives_dir: Path) -> list[Path]:
|
| 99 |
+
paths: list[Path] = []
|
| 100 |
+
for subject_dir in sorted(derivatives_dir.glob("sub-*")):
|
| 101 |
+
func_dir = subject_dir / "func"
|
| 102 |
+
if not func_dir.exists():
|
| 103 |
+
continue
|
| 104 |
+
paths.extend(sorted(func_dir.glob("*_desc-preproc_bold.nii.gz")))
|
| 105 |
+
return paths
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def build_derivatives_manifest(
|
| 109 |
+
derivatives_dir: Path,
|
| 110 |
+
data_dir: Path,
|
| 111 |
+
allowed_runs: Iterable[int],
|
| 112 |
+
enable_mixed_fallback: bool,
|
| 113 |
+
excluded_subjects: Iterable[str] | None = None,
|
| 114 |
+
deep_nifti_integrity_check: bool = False,
|
| 115 |
+
) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, Any]]:
|
| 116 |
+
"""Build accepted/rejected manifests with strict anti-leakage checks.
|
| 117 |
+
|
| 118 |
+
Checks enforced:
|
| 119 |
+
- filename must match multitalker preproc BOLD pattern
|
| 120 |
+
- subject must not be in excluded_subjects
|
| 121 |
+
- folder subject must equal filename subject
|
| 122 |
+
- run must be in allowed run whitelist
|
| 123 |
+
- matching raw BIDS sidecar JSON must exist
|
| 124 |
+
- raw sidecar must include a valid TR
|
| 125 |
+
- NIfTI must be readable with valid 4D volume count
|
| 126 |
+
- each (subject, run) key must be unique
|
| 127 |
+
"""
|
| 128 |
+
allowed_run_set = set(int(run) for run in allowed_runs)
|
| 129 |
+
excluded_subject_set = {str(subject).strip() for subject in (excluded_subjects or []) if str(subject).strip()}
|
| 130 |
+
|
| 131 |
+
accepted_rows: list[ManifestRecord] = []
|
| 132 |
+
rejected_rows: list[RejectRecord] = []
|
| 133 |
+
seen_subject_run_keys: set[tuple[str, int]] = set()
|
| 134 |
+
|
| 135 |
+
candidate_files = _iter_candidate_bold_files(derivatives_dir)
|
| 136 |
+
|
| 137 |
+
for bold_path in candidate_files:
|
| 138 |
+
folder_subject = bold_path.parents[1].name
|
| 139 |
+
filename_subject = ""
|
| 140 |
+
run_number: int | None = None
|
| 141 |
+
|
| 142 |
+
match = MULTITALKER_BOLD_RE.match(bold_path.name)
|
| 143 |
+
if match is None:
|
| 144 |
+
rejected_rows.append(
|
| 145 |
+
RejectRecord(
|
| 146 |
+
derivatives_bold_path=str(bold_path),
|
| 147 |
+
folder_subject=folder_subject,
|
| 148 |
+
filename_subject="",
|
| 149 |
+
run=None,
|
| 150 |
+
reason="filename_pattern_mismatch",
|
| 151 |
+
details="Not a multitalker run preproc BOLD filename",
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
continue
|
| 155 |
+
|
| 156 |
+
filename_subject = match.group(1)
|
| 157 |
+
run_number = int(match.group(2))
|
| 158 |
+
|
| 159 |
+
if folder_subject in excluded_subject_set or filename_subject in excluded_subject_set:
|
| 160 |
+
rejected_rows.append(
|
| 161 |
+
RejectRecord(
|
| 162 |
+
derivatives_bold_path=str(bold_path),
|
| 163 |
+
folder_subject=folder_subject,
|
| 164 |
+
filename_subject=filename_subject,
|
| 165 |
+
run=run_number,
|
| 166 |
+
reason="excluded_subject",
|
| 167 |
+
details=f"Excluded subjects={sorted(excluded_subject_set)}",
|
| 168 |
+
)
|
| 169 |
+
)
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
+
if folder_subject != filename_subject:
|
| 173 |
+
rejected_rows.append(
|
| 174 |
+
RejectRecord(
|
| 175 |
+
derivatives_bold_path=str(bold_path),
|
| 176 |
+
folder_subject=folder_subject,
|
| 177 |
+
filename_subject=filename_subject,
|
| 178 |
+
run=run_number,
|
| 179 |
+
reason="folder_filename_subject_mismatch",
|
| 180 |
+
details="Folder subject does not match filename subject",
|
| 181 |
+
)
|
| 182 |
+
)
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
if run_number not in allowed_run_set:
|
| 186 |
+
rejected_rows.append(
|
| 187 |
+
RejectRecord(
|
| 188 |
+
derivatives_bold_path=str(bold_path),
|
| 189 |
+
folder_subject=folder_subject,
|
| 190 |
+
filename_subject=filename_subject,
|
| 191 |
+
run=run_number,
|
| 192 |
+
reason="run_not_in_whitelist",
|
| 193 |
+
details=f"Allowed runs={sorted(allowed_run_set)}",
|
| 194 |
+
)
|
| 195 |
+
)
|
| 196 |
+
continue
|
| 197 |
+
|
| 198 |
+
subject_run_key = (filename_subject, run_number)
|
| 199 |
+
if subject_run_key in seen_subject_run_keys:
|
| 200 |
+
rejected_rows.append(
|
| 201 |
+
RejectRecord(
|
| 202 |
+
derivatives_bold_path=str(bold_path),
|
| 203 |
+
folder_subject=folder_subject,
|
| 204 |
+
filename_subject=filename_subject,
|
| 205 |
+
run=run_number,
|
| 206 |
+
reason="duplicate_subject_run_key",
|
| 207 |
+
details="Another derivatives file already accepted for this subject/run",
|
| 208 |
+
)
|
| 209 |
+
)
|
| 210 |
+
continue
|
| 211 |
+
|
| 212 |
+
raw_json_path = (
|
| 213 |
+
data_dir / filename_subject / "func" / f"{filename_subject}_task-multitalker_run-{run_number}_bold.json"
|
| 214 |
+
)
|
| 215 |
+
if not raw_json_path.exists():
|
| 216 |
+
rejected_rows.append(
|
| 217 |
+
RejectRecord(
|
| 218 |
+
derivatives_bold_path=str(bold_path),
|
| 219 |
+
folder_subject=folder_subject,
|
| 220 |
+
filename_subject=filename_subject,
|
| 221 |
+
run=run_number,
|
| 222 |
+
reason="missing_raw_sidecar_json",
|
| 223 |
+
details=str(raw_json_path),
|
| 224 |
+
)
|
| 225 |
+
)
|
| 226 |
+
continue
|
| 227 |
+
|
| 228 |
+
raw_json_payload = _safe_read_json(raw_json_path)
|
| 229 |
+
if raw_json_payload is None:
|
| 230 |
+
rejected_rows.append(
|
| 231 |
+
RejectRecord(
|
| 232 |
+
derivatives_bold_path=str(bold_path),
|
| 233 |
+
folder_subject=folder_subject,
|
| 234 |
+
filename_subject=filename_subject,
|
| 235 |
+
run=run_number,
|
| 236 |
+
reason="unreadable_raw_sidecar_json",
|
| 237 |
+
details=str(raw_json_path),
|
| 238 |
+
)
|
| 239 |
+
)
|
| 240 |
+
continue
|
| 241 |
+
|
| 242 |
+
tr_seconds = _extract_repetition_time_seconds(raw_json_payload)
|
| 243 |
+
if tr_seconds is None:
|
| 244 |
+
rejected_rows.append(
|
| 245 |
+
RejectRecord(
|
| 246 |
+
derivatives_bold_path=str(bold_path),
|
| 247 |
+
folder_subject=folder_subject,
|
| 248 |
+
filename_subject=filename_subject,
|
| 249 |
+
run=run_number,
|
| 250 |
+
reason="missing_or_invalid_repetition_time",
|
| 251 |
+
details=str(raw_json_path),
|
| 252 |
+
)
|
| 253 |
+
)
|
| 254 |
+
continue
|
| 255 |
+
|
| 256 |
+
n_volumes = _read_n_volumes(
|
| 257 |
+
path=bold_path,
|
| 258 |
+
deep_integrity_check=bool(deep_nifti_integrity_check),
|
| 259 |
+
)
|
| 260 |
+
if n_volumes is None:
|
| 261 |
+
rejected_rows.append(
|
| 262 |
+
RejectRecord(
|
| 263 |
+
derivatives_bold_path=str(bold_path),
|
| 264 |
+
folder_subject=folder_subject,
|
| 265 |
+
filename_subject=filename_subject,
|
| 266 |
+
run=run_number,
|
| 267 |
+
reason="unreadable_or_invalid_nifti",
|
| 268 |
+
details="Could not read 4D volume count",
|
| 269 |
+
)
|
| 270 |
+
)
|
| 271 |
+
continue
|
| 272 |
+
|
| 273 |
+
condition_fixed, condition_effective, speaker_stream, used_fallback = resolve_run_condition(
|
| 274 |
+
run=run_number,
|
| 275 |
+
enable_mixed_fallback=enable_mixed_fallback,
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
accepted_rows.append(
|
| 279 |
+
ManifestRecord(
|
| 280 |
+
subject=filename_subject,
|
| 281 |
+
run=run_number,
|
| 282 |
+
condition_fixed=condition_fixed,
|
| 283 |
+
condition_effective=condition_effective,
|
| 284 |
+
speaker_stream=speaker_stream,
|
| 285 |
+
used_mixed_fallback=used_fallback,
|
| 286 |
+
derivatives_bold_path=str(bold_path),
|
| 287 |
+
raw_bold_json_path=str(raw_json_path),
|
| 288 |
+
tr_seconds=float(tr_seconds),
|
| 289 |
+
n_volumes=int(n_volumes),
|
| 290 |
+
)
|
| 291 |
+
)
|
| 292 |
+
seen_subject_run_keys.add(subject_run_key)
|
| 293 |
+
|
| 294 |
+
manifest_df = pd.DataFrame([asdict(row) for row in accepted_rows])
|
| 295 |
+
reject_df = pd.DataFrame([asdict(row) for row in rejected_rows])
|
| 296 |
+
|
| 297 |
+
if not manifest_df.empty:
|
| 298 |
+
manifest_df = manifest_df.sort_values(["subject", "run"]).reset_index(drop=True)
|
| 299 |
+
if not reject_df.empty:
|
| 300 |
+
reject_df = reject_df.sort_values(["folder_subject", "reason", "derivatives_bold_path"]).reset_index(
|
| 301 |
+
drop=True
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
tr_values = manifest_df["tr_seconds"].tolist() if not manifest_df.empty else []
|
| 305 |
+
n_volumes_values = manifest_df["n_volumes"].tolist() if not manifest_df.empty else []
|
| 306 |
+
reject_counts = (
|
| 307 |
+
reject_df["reason"].value_counts().sort_index().to_dict() if not reject_df.empty else {}
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
qc_summary: dict[str, Any] = {
|
| 311 |
+
"n_candidates": len(candidate_files),
|
| 312 |
+
"n_accepted": int(len(manifest_df)),
|
| 313 |
+
"n_rejected": int(len(reject_df)),
|
| 314 |
+
"n_unique_subjects": int(manifest_df["subject"].nunique()) if not manifest_df.empty else 0,
|
| 315 |
+
"allowed_runs": sorted(int(run) for run in allowed_run_set),
|
| 316 |
+
"excluded_subjects": sorted(excluded_subject_set),
|
| 317 |
+
"deep_nifti_integrity_check": bool(deep_nifti_integrity_check),
|
| 318 |
+
"mixed_fallback_enabled": bool(enable_mixed_fallback),
|
| 319 |
+
"reject_counts": reject_counts,
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
if tr_values:
|
| 323 |
+
qc_summary["tr_seconds_unique"] = sorted({float(value) for value in tr_values})
|
| 324 |
+
qc_summary["tr_seconds_min"] = float(min(tr_values))
|
| 325 |
+
qc_summary["tr_seconds_max"] = float(max(tr_values))
|
| 326 |
+
|
| 327 |
+
if n_volumes_values:
|
| 328 |
+
qc_summary["n_volumes_min"] = int(min(n_volumes_values))
|
| 329 |
+
qc_summary["n_volumes_max"] = int(max(n_volumes_values))
|
| 330 |
+
|
| 331 |
+
return manifest_df, reject_df, qc_summary
|
code/a1_pipeline/model_config.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model profile configuration utilities for A1 bootstrap."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _unique_preserve_order(values: list[str]) -> list[str]:
|
| 11 |
+
seen: set[str] = set()
|
| 12 |
+
unique: list[str] = []
|
| 13 |
+
|
| 14 |
+
for raw in values:
|
| 15 |
+
value = str(raw).strip()
|
| 16 |
+
if not value or value in seen:
|
| 17 |
+
continue
|
| 18 |
+
seen.add(value)
|
| 19 |
+
unique.append(value)
|
| 20 |
+
|
| 21 |
+
return unique
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def list_model_profiles(config_path: Path) -> tuple[list[str], str | None]:
|
| 25 |
+
"""List available profile names and active profile from JSON config."""
|
| 26 |
+
config_path = config_path.resolve()
|
| 27 |
+
if not config_path.exists():
|
| 28 |
+
raise FileNotFoundError(f"Model config not found: {config_path}")
|
| 29 |
+
|
| 30 |
+
with config_path.open("r", encoding="utf-8") as handle:
|
| 31 |
+
payload = json.load(handle)
|
| 32 |
+
|
| 33 |
+
profiles = payload.get("profiles")
|
| 34 |
+
if not isinstance(profiles, dict) or not profiles:
|
| 35 |
+
raise ValueError(f"Invalid model config (missing non-empty 'profiles'): {config_path}")
|
| 36 |
+
|
| 37 |
+
names = sorted(str(name) for name in profiles.keys())
|
| 38 |
+
active = payload.get("active_profile")
|
| 39 |
+
active_profile = str(active) if active is not None else None
|
| 40 |
+
return names, active_profile
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def load_model_ids_from_config(
|
| 44 |
+
config_path: Path,
|
| 45 |
+
profile: str | None,
|
| 46 |
+
) -> tuple[list[str], dict[str, Any]]:
|
| 47 |
+
"""Load model IDs from a profile inside the model config JSON."""
|
| 48 |
+
config_path = config_path.resolve()
|
| 49 |
+
if not config_path.exists():
|
| 50 |
+
raise FileNotFoundError(f"Model config not found: {config_path}")
|
| 51 |
+
|
| 52 |
+
with config_path.open("r", encoding="utf-8") as handle:
|
| 53 |
+
payload = json.load(handle)
|
| 54 |
+
|
| 55 |
+
profiles = payload.get("profiles")
|
| 56 |
+
if not isinstance(profiles, dict) or not profiles:
|
| 57 |
+
raise ValueError(f"Invalid model config (missing non-empty 'profiles'): {config_path}")
|
| 58 |
+
|
| 59 |
+
profile_name = profile or payload.get("active_profile")
|
| 60 |
+
if profile_name is None:
|
| 61 |
+
profile_name = sorted(profiles.keys())[0]
|
| 62 |
+
|
| 63 |
+
profile_name = str(profile_name)
|
| 64 |
+
if profile_name not in profiles:
|
| 65 |
+
available = ", ".join(sorted(str(key) for key in profiles.keys()))
|
| 66 |
+
raise ValueError(
|
| 67 |
+
f"Requested model profile '{profile_name}' not found in {config_path}. "
|
| 68 |
+
f"Available profiles: {available}"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
profile_payload = profiles[profile_name]
|
| 72 |
+
if not isinstance(profile_payload, dict):
|
| 73 |
+
raise ValueError(
|
| 74 |
+
f"Invalid model profile payload for '{profile_name}' in {config_path}"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
raw_model_ids = profile_payload.get("model_ids")
|
| 78 |
+
if not isinstance(raw_model_ids, list):
|
| 79 |
+
raise ValueError(
|
| 80 |
+
f"Model profile '{profile_name}' must contain a list field 'model_ids'"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
model_ids = _unique_preserve_order([str(value) for value in raw_model_ids])
|
| 84 |
+
if not model_ids:
|
| 85 |
+
raise ValueError(
|
| 86 |
+
f"Model profile '{profile_name}' resolved to an empty model list"
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
selection_info = {
|
| 90 |
+
"source": "model_config",
|
| 91 |
+
"config_path": str(config_path),
|
| 92 |
+
"profile": profile_name,
|
| 93 |
+
"description": str(profile_payload.get("description", "")),
|
| 94 |
+
}
|
| 95 |
+
return model_ids, selection_info
|
code/a1_pipeline/participant_runs.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Participant-specific run-to-stimulus mapping helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
CANONICAL_RUN_LABEL_TO_INDEX: dict[str, int] = {
|
| 11 |
+
"single_f": 1,
|
| 12 |
+
"single_female": 1,
|
| 13 |
+
"single_m": 2,
|
| 14 |
+
"single_male": 2,
|
| 15 |
+
"mixed_f": 3,
|
| 16 |
+
"mixed_female": 3,
|
| 17 |
+
"mixed_m": 4,
|
| 18 |
+
"mixed_male": 4,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
CANONICAL_RUN_INDEX_TO_LABEL: dict[int, str] = {
|
| 23 |
+
1: "single_female",
|
| 24 |
+
2: "single_male",
|
| 25 |
+
3: "mixed_female",
|
| 26 |
+
4: "mixed_male",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def parse_run_key(raw_key: Any) -> int:
|
| 31 |
+
if isinstance(raw_key, int):
|
| 32 |
+
run = int(raw_key)
|
| 33 |
+
else:
|
| 34 |
+
token = str(raw_key).strip().lower()
|
| 35 |
+
if token.startswith("run"):
|
| 36 |
+
token = token[3:]
|
| 37 |
+
token = token.lstrip("_-")
|
| 38 |
+
if not token.isdigit():
|
| 39 |
+
raise ValueError(f"Invalid run key in participant_run_info: {raw_key}")
|
| 40 |
+
run = int(token)
|
| 41 |
+
|
| 42 |
+
if run <= 0:
|
| 43 |
+
raise ValueError(f"Run key must be positive in participant_run_info: {raw_key}")
|
| 44 |
+
|
| 45 |
+
return run
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def normalize_run_label(raw_label: Any) -> str:
|
| 49 |
+
token = str(raw_label).strip().lower().replace("-", "_").replace(" ", "_")
|
| 50 |
+
return "_".join(part for part in token.split("_") if part)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def resolve_canonical_run(raw_value: int | str) -> int:
|
| 54 |
+
if isinstance(raw_value, int):
|
| 55 |
+
canonical_run = int(raw_value)
|
| 56 |
+
else:
|
| 57 |
+
normalized_label = normalize_run_label(raw_value)
|
| 58 |
+
if normalized_label not in CANONICAL_RUN_LABEL_TO_INDEX:
|
| 59 |
+
raise ValueError(
|
| 60 |
+
f"Unsupported canonical run label: {raw_value}. "
|
| 61 |
+
f"Allowed labels: {sorted(CANONICAL_RUN_LABEL_TO_INDEX.keys())}"
|
| 62 |
+
)
|
| 63 |
+
canonical_run = int(CANONICAL_RUN_LABEL_TO_INDEX[normalized_label])
|
| 64 |
+
|
| 65 |
+
if canonical_run not in CANONICAL_RUN_INDEX_TO_LABEL:
|
| 66 |
+
raise ValueError(
|
| 67 |
+
f"Unsupported canonical run index: {canonical_run}. "
|
| 68 |
+
f"Allowed indices: {sorted(CANONICAL_RUN_INDEX_TO_LABEL.keys())}"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
return canonical_run
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def canonical_run_to_label(canonical_run: int) -> str:
|
| 75 |
+
resolved = resolve_canonical_run(int(canonical_run))
|
| 76 |
+
return str(CANONICAL_RUN_INDEX_TO_LABEL[resolved])
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def load_participant_run_map(
|
| 80 |
+
participant_run_info_path: Path,
|
| 81 |
+
subjects: list[str],
|
| 82 |
+
runs: list[int] | None = None,
|
| 83 |
+
) -> dict[str, dict[int, int]]:
|
| 84 |
+
if not participant_run_info_path.exists():
|
| 85 |
+
raise FileNotFoundError(f"participant_run_info.json not found: {participant_run_info_path}")
|
| 86 |
+
|
| 87 |
+
with participant_run_info_path.open("r", encoding="utf-8") as handle:
|
| 88 |
+
payload = json.load(handle)
|
| 89 |
+
|
| 90 |
+
if not isinstance(payload, dict):
|
| 91 |
+
raise ValueError("participant_run_info.json must contain a JSON object at top level")
|
| 92 |
+
|
| 93 |
+
requested_subjects = sorted(set(subjects))
|
| 94 |
+
requested_runs = sorted(set(int(run) for run in runs)) if runs is not None else None
|
| 95 |
+
|
| 96 |
+
missing_subjects = [subject for subject in requested_subjects if subject not in payload]
|
| 97 |
+
if missing_subjects:
|
| 98 |
+
raise KeyError(
|
| 99 |
+
"participant_run_info.json is missing subjects required for fit: "
|
| 100 |
+
f"{missing_subjects}"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
subject_run_map: dict[str, dict[int, int]] = {}
|
| 104 |
+
|
| 105 |
+
for subject in requested_subjects:
|
| 106 |
+
raw_subject_map = payload[subject]
|
| 107 |
+
if not isinstance(raw_subject_map, dict):
|
| 108 |
+
raise ValueError(
|
| 109 |
+
f"participant_run_info[{subject}] must be an object of run->condition labels"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
parsed: dict[int, int] = {}
|
| 113 |
+
for raw_run_key, raw_label in raw_subject_map.items():
|
| 114 |
+
run = parse_run_key(raw_run_key)
|
| 115 |
+
parsed[run] = int(resolve_canonical_run(str(raw_label)))
|
| 116 |
+
|
| 117 |
+
if requested_runs is not None:
|
| 118 |
+
missing_runs = [run for run in requested_runs if run not in parsed]
|
| 119 |
+
if missing_runs:
|
| 120 |
+
raise KeyError(
|
| 121 |
+
"participant_run_info.json has incomplete run mapping for "
|
| 122 |
+
f"subject={subject}. Missing runs={missing_runs}"
|
| 123 |
+
)
|
| 124 |
+
subject_run_map[subject] = {run: int(parsed[run]) for run in requested_runs}
|
| 125 |
+
else:
|
| 126 |
+
subject_run_map[subject] = {int(run): int(value) for run, value in parsed.items()}
|
| 127 |
+
|
| 128 |
+
return subject_run_map
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def resolve_subject_canonical_run(
|
| 132 |
+
participant_run_map: dict[str, dict[int, int]],
|
| 133 |
+
subject: str,
|
| 134 |
+
run: int,
|
| 135 |
+
) -> int:
|
| 136 |
+
if subject not in participant_run_map:
|
| 137 |
+
raise KeyError(f"Missing participant run mapping for subject={subject}")
|
| 138 |
+
|
| 139 |
+
subject_map = participant_run_map[subject]
|
| 140 |
+
if run not in subject_map:
|
| 141 |
+
raise KeyError(f"Missing participant run mapping for subject={subject}, run={run}")
|
| 142 |
+
|
| 143 |
+
return int(subject_map[run])
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def resolve_subject_actual_run(
|
| 147 |
+
participant_run_map: dict[str, dict[int, int]],
|
| 148 |
+
subject: str,
|
| 149 |
+
canonical_run: int | str,
|
| 150 |
+
available_runs: set[int] | None = None,
|
| 151 |
+
) -> int:
|
| 152 |
+
if subject not in participant_run_map:
|
| 153 |
+
raise KeyError(f"Missing participant run mapping for subject={subject}")
|
| 154 |
+
|
| 155 |
+
target_canonical_run = resolve_canonical_run(canonical_run)
|
| 156 |
+
subject_map = participant_run_map[subject]
|
| 157 |
+
matching_runs = sorted(
|
| 158 |
+
run
|
| 159 |
+
for run, canonical_value in subject_map.items()
|
| 160 |
+
if int(canonical_value) == int(target_canonical_run)
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if available_runs is not None:
|
| 164 |
+
matching_runs = [run for run in matching_runs if run in available_runs]
|
| 165 |
+
|
| 166 |
+
if not matching_runs:
|
| 167 |
+
raise KeyError(
|
| 168 |
+
f"No actual run found for subject={subject}, canonical_run={target_canonical_run}"
|
| 169 |
+
)
|
| 170 |
+
if len(matching_runs) > 1:
|
| 171 |
+
raise ValueError(
|
| 172 |
+
"Expected exactly one actual run for canonical stimulus, found multiple for "
|
| 173 |
+
f"subject={subject}, canonical_run={target_canonical_run}: {matching_runs}"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return int(matching_runs[0])
|
code/a1_pipeline/spatial.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Spatial harmonization utilities for A1 baseline bootstrap."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import nibabel as nib
|
| 10 |
+
from nibabel.processing import resample_from_to
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class RunMaskQC:
|
| 17 |
+
"""Per-run QC record used when building the analysis mask."""
|
| 18 |
+
|
| 19 |
+
subject: str
|
| 20 |
+
run: int
|
| 21 |
+
derivatives_bold_path: str
|
| 22 |
+
run_mask_method: str
|
| 23 |
+
needs_resample_to_reference: bool
|
| 24 |
+
run_mask_voxels: int
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _make_reference_3d_from_manifest(manifest_df: pd.DataFrame) -> nib.Nifti1Image:
|
| 28 |
+
if manifest_df.empty:
|
| 29 |
+
raise ValueError("Manifest is empty; cannot create spatial reference")
|
| 30 |
+
|
| 31 |
+
first_path = Path(str(manifest_df.iloc[0]["derivatives_bold_path"]))
|
| 32 |
+
first_img = nib.load(str(first_path))
|
| 33 |
+
if len(first_img.shape) != 4:
|
| 34 |
+
raise ValueError(f"Expected 4D BOLD image but got shape={first_img.shape} for {first_path}")
|
| 35 |
+
|
| 36 |
+
shape_3d = first_img.shape[:3]
|
| 37 |
+
return nib.Nifti1Image(np.zeros(shape_3d, dtype=np.uint8), first_img.affine)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _same_grid(img_a: nib.Nifti1Image, img_b: nib.Nifti1Image, atol: float = 1e-5) -> bool:
|
| 41 |
+
return img_a.shape == img_b.shape and np.allclose(img_a.affine, img_b.affine, atol=atol)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _extract_run_mask_from_bold(
|
| 45 |
+
bold_img: nib.Nifti1Image,
|
| 46 |
+
method: str,
|
| 47 |
+
epsilon: float,
|
| 48 |
+
) -> nib.Nifti1Image:
|
| 49 |
+
shape = bold_img.shape
|
| 50 |
+
if len(shape) != 4:
|
| 51 |
+
raise ValueError(f"Expected 4D BOLD image, got shape={shape}")
|
| 52 |
+
|
| 53 |
+
if method == "first_volume_nonzero":
|
| 54 |
+
volume = np.asanyarray(bold_img.dataobj[..., 0])
|
| 55 |
+
run_mask = np.abs(volume) > epsilon
|
| 56 |
+
elif method == "temporal_any_nonzero":
|
| 57 |
+
run_mask = np.zeros(shape[:3], dtype=bool)
|
| 58 |
+
for time_index in range(shape[3]):
|
| 59 |
+
volume = np.asanyarray(bold_img.dataobj[..., time_index])
|
| 60 |
+
run_mask |= np.abs(volume) > epsilon
|
| 61 |
+
else:
|
| 62 |
+
raise ValueError(f"Unsupported run mask method: {method}")
|
| 63 |
+
|
| 64 |
+
return nib.Nifti1Image(run_mask.astype(np.uint8), bold_img.affine)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _resample_binary_mask_to_reference(
|
| 68 |
+
binary_mask_img: nib.Nifti1Image,
|
| 69 |
+
reference_3d_img: nib.Nifti1Image,
|
| 70 |
+
) -> nib.Nifti1Image:
|
| 71 |
+
resampled = resample_from_to(
|
| 72 |
+
binary_mask_img,
|
| 73 |
+
(reference_3d_img.shape, reference_3d_img.affine),
|
| 74 |
+
order=0,
|
| 75 |
+
)
|
| 76 |
+
binary_data = (resampled.get_fdata() > 0.5).astype(np.uint8)
|
| 77 |
+
return nib.Nifti1Image(binary_data, reference_3d_img.affine)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_symmetric_analysis_mask(
|
| 81 |
+
manifest_df: pd.DataFrame,
|
| 82 |
+
run_mask_method: str = "first_volume_nonzero",
|
| 83 |
+
epsilon: float = 1e-6,
|
| 84 |
+
) -> tuple[nib.Nifti1Image, dict[tuple[str, int], np.ndarray], pd.DataFrame, dict[str, Any]]:
|
| 85 |
+
"""Build analysis mask in a canonical grid with left-right symmetry enforced.
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
analysis_mask_img: symmetric boolean mask image in reference grid
|
| 89 |
+
run_mask_map: (subject, run) -> boolean 3D run mask in reference grid
|
| 90 |
+
run_mask_qc_df: per-run mask stats including whether resampling was needed
|
| 91 |
+
mask_qc: summary dictionary
|
| 92 |
+
"""
|
| 93 |
+
if manifest_df.empty:
|
| 94 |
+
raise ValueError("Manifest is empty; cannot build analysis mask")
|
| 95 |
+
|
| 96 |
+
required_columns = {"subject", "run", "derivatives_bold_path"}
|
| 97 |
+
missing = required_columns.difference(manifest_df.columns)
|
| 98 |
+
if missing:
|
| 99 |
+
raise ValueError(f"Manifest missing required columns: {sorted(missing)}")
|
| 100 |
+
|
| 101 |
+
reference_3d_img = _make_reference_3d_from_manifest(manifest_df)
|
| 102 |
+
reference_affine = reference_3d_img.affine
|
| 103 |
+
|
| 104 |
+
run_mask_map: dict[tuple[str, int], np.ndarray] = {}
|
| 105 |
+
run_qc_rows: list[RunMaskQC] = []
|
| 106 |
+
|
| 107 |
+
intersection_mask: np.ndarray | None = None
|
| 108 |
+
n_resampled = 0
|
| 109 |
+
|
| 110 |
+
for row in manifest_df.itertuples(index=False):
|
| 111 |
+
subject = str(getattr(row, "subject"))
|
| 112 |
+
run = int(getattr(row, "run"))
|
| 113 |
+
bold_path = Path(str(getattr(row, "derivatives_bold_path")))
|
| 114 |
+
|
| 115 |
+
bold_img = nib.load(str(bold_path))
|
| 116 |
+
run_mask_img = _extract_run_mask_from_bold(
|
| 117 |
+
bold_img=bold_img,
|
| 118 |
+
method=run_mask_method,
|
| 119 |
+
epsilon=epsilon,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
needs_resample = not _same_grid(run_mask_img, reference_3d_img)
|
| 123 |
+
if needs_resample:
|
| 124 |
+
n_resampled += 1
|
| 125 |
+
run_mask_img = _resample_binary_mask_to_reference(run_mask_img, reference_3d_img)
|
| 126 |
+
|
| 127 |
+
run_mask_bool = run_mask_img.get_fdata() > 0.5
|
| 128 |
+
run_key = (subject, run)
|
| 129 |
+
run_mask_map[run_key] = run_mask_bool
|
| 130 |
+
|
| 131 |
+
if intersection_mask is None:
|
| 132 |
+
intersection_mask = run_mask_bool.copy()
|
| 133 |
+
else:
|
| 134 |
+
intersection_mask &= run_mask_bool
|
| 135 |
+
|
| 136 |
+
run_qc_rows.append(
|
| 137 |
+
RunMaskQC(
|
| 138 |
+
subject=subject,
|
| 139 |
+
run=run,
|
| 140 |
+
derivatives_bold_path=str(bold_path),
|
| 141 |
+
run_mask_method=run_mask_method,
|
| 142 |
+
needs_resample_to_reference=needs_resample,
|
| 143 |
+
run_mask_voxels=int(run_mask_bool.sum()),
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
if intersection_mask is None:
|
| 148 |
+
raise RuntimeError("Could not compute intersection mask from manifest entries")
|
| 149 |
+
|
| 150 |
+
swapped_mask = np.flip(intersection_mask, axis=0)
|
| 151 |
+
symmetric_mask = intersection_mask & swapped_mask
|
| 152 |
+
|
| 153 |
+
analysis_mask_img = nib.Nifti1Image(symmetric_mask.astype(np.uint8), reference_affine)
|
| 154 |
+
|
| 155 |
+
shape_x = symmetric_mask.shape[0]
|
| 156 |
+
mid_x = shape_x // 2
|
| 157 |
+
left_voxels = int(symmetric_mask[:mid_x, :, :].sum())
|
| 158 |
+
right_voxels = int(symmetric_mask[-mid_x:, :, :].sum()) if mid_x > 0 else 0
|
| 159 |
+
|
| 160 |
+
run_mask_qc_df = pd.DataFrame([asdict(row) for row in run_qc_rows])
|
| 161 |
+
if not run_mask_qc_df.empty:
|
| 162 |
+
run_mask_qc_df = run_mask_qc_df.sort_values(["subject", "run"]).reset_index(drop=True)
|
| 163 |
+
|
| 164 |
+
orientation_codes = "".join(nib.aff2axcodes(reference_affine))
|
| 165 |
+
|
| 166 |
+
mask_qc: dict[str, Any] = {
|
| 167 |
+
"run_mask_method": run_mask_method,
|
| 168 |
+
"n_manifest_rows": int(len(manifest_df)),
|
| 169 |
+
"n_run_masks": int(len(run_mask_map)),
|
| 170 |
+
"n_resampled_run_masks": int(n_resampled),
|
| 171 |
+
"reference_shape": [int(dim) for dim in analysis_mask_img.shape],
|
| 172 |
+
"reference_orientation": orientation_codes,
|
| 173 |
+
"intersection_voxels": int(intersection_mask.sum()),
|
| 174 |
+
"symmetric_mask_voxels": int(symmetric_mask.sum()),
|
| 175 |
+
"left_hemisphere_voxels": left_voxels,
|
| 176 |
+
"right_hemisphere_voxels": right_voxels,
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
if not run_mask_qc_df.empty:
|
| 180 |
+
mask_qc["run_mask_voxels_min"] = int(run_mask_qc_df["run_mask_voxels"].min())
|
| 181 |
+
mask_qc["run_mask_voxels_max"] = int(run_mask_qc_df["run_mask_voxels"].max())
|
| 182 |
+
|
| 183 |
+
return analysis_mask_img, run_mask_map, run_mask_qc_df, mask_qc
|
code/a1_pipeline/splits.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Leakage-safe split builders for A1 baseline evaluation protocols."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass(frozen=True)
|
| 12 |
+
class CrossRunFold:
|
| 13 |
+
"""One leave-one-run-out fold for Protocol A."""
|
| 14 |
+
|
| 15 |
+
protocol: str
|
| 16 |
+
subject: str
|
| 17 |
+
fold_id: str
|
| 18 |
+
test_run: int
|
| 19 |
+
train_runs: str
|
| 20 |
+
n_train_runs: int
|
| 21 |
+
n_test_runs: int
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class WithinRunBlockedSplit:
|
| 26 |
+
"""One blocked temporal split with HRF safety gap for Protocol B."""
|
| 27 |
+
|
| 28 |
+
protocol: str
|
| 29 |
+
subject: str
|
| 30 |
+
run: int
|
| 31 |
+
split_id: str
|
| 32 |
+
n_volumes: int
|
| 33 |
+
test_start_tr: int
|
| 34 |
+
test_end_tr_exclusive: int
|
| 35 |
+
gap_tr: int
|
| 36 |
+
train_left_start_tr: int
|
| 37 |
+
train_left_end_tr_exclusive: int
|
| 38 |
+
train_right_start_tr: int
|
| 39 |
+
train_right_end_tr_exclusive: int
|
| 40 |
+
n_train_volumes: int
|
| 41 |
+
n_test_volumes: int
|
| 42 |
+
n_gap_excluded_volumes: int
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass(frozen=True)
|
| 46 |
+
class WithinRunSkipped:
|
| 47 |
+
"""Skipped run when blocked split constraints cannot be satisfied."""
|
| 48 |
+
|
| 49 |
+
subject: str
|
| 50 |
+
run: int
|
| 51 |
+
n_volumes: int
|
| 52 |
+
reason: str
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def build_cross_run_folds(manifest_df: pd.DataFrame) -> pd.DataFrame:
|
| 56 |
+
"""Build Protocol A folds (leave-one-run-out within subject)."""
|
| 57 |
+
if manifest_df.empty:
|
| 58 |
+
return pd.DataFrame()
|
| 59 |
+
|
| 60 |
+
required_columns = {"subject", "run"}
|
| 61 |
+
missing = required_columns.difference(manifest_df.columns)
|
| 62 |
+
if missing:
|
| 63 |
+
raise ValueError(f"Manifest missing required columns: {sorted(missing)}")
|
| 64 |
+
|
| 65 |
+
rows: list[CrossRunFold] = []
|
| 66 |
+
|
| 67 |
+
for subject, subject_df in manifest_df.groupby("subject"):
|
| 68 |
+
runs = sorted({int(run) for run in subject_df["run"].tolist()})
|
| 69 |
+
if len(runs) < 2:
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
for test_run in runs:
|
| 73 |
+
train_runs = [run for run in runs if run != test_run]
|
| 74 |
+
rows.append(
|
| 75 |
+
CrossRunFold(
|
| 76 |
+
protocol="A_cross_run",
|
| 77 |
+
subject=str(subject),
|
| 78 |
+
fold_id=f"{subject}_test_run{test_run}",
|
| 79 |
+
test_run=int(test_run),
|
| 80 |
+
train_runs=",".join(str(run) for run in train_runs),
|
| 81 |
+
n_train_runs=len(train_runs),
|
| 82 |
+
n_test_runs=1,
|
| 83 |
+
)
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
output_df = pd.DataFrame([asdict(row) for row in rows])
|
| 87 |
+
if not output_df.empty:
|
| 88 |
+
output_df = output_df.sort_values(["subject", "test_run"]).reset_index(drop=True)
|
| 89 |
+
return output_df
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _compute_within_run_blocked_bounds(
|
| 93 |
+
n_volumes: int,
|
| 94 |
+
test_fraction: float,
|
| 95 |
+
gap_tr: int,
|
| 96 |
+
min_train_volumes: int,
|
| 97 |
+
min_test_volumes: int,
|
| 98 |
+
) -> tuple[dict[str, int] | None, str | None]:
|
| 99 |
+
if n_volumes <= 0:
|
| 100 |
+
return None, "non_positive_volume_count"
|
| 101 |
+
|
| 102 |
+
if not (0.0 < test_fraction < 1.0):
|
| 103 |
+
return None, "invalid_test_fraction"
|
| 104 |
+
|
| 105 |
+
if gap_tr < 0:
|
| 106 |
+
return None, "negative_gap"
|
| 107 |
+
|
| 108 |
+
n_test = max(min_test_volumes, int(round(n_volumes * test_fraction)))
|
| 109 |
+
n_test = min(n_test, n_volumes)
|
| 110 |
+
|
| 111 |
+
if n_test >= n_volumes:
|
| 112 |
+
return None, "test_block_covers_entire_run"
|
| 113 |
+
|
| 114 |
+
# Center block so train windows exist on both sides when possible.
|
| 115 |
+
test_start = max(0, (n_volumes - n_test) // 2)
|
| 116 |
+
test_end = min(n_volumes, test_start + n_test)
|
| 117 |
+
|
| 118 |
+
left_train_start = 0
|
| 119 |
+
left_train_end = max(0, test_start - gap_tr)
|
| 120 |
+
|
| 121 |
+
right_train_start = min(n_volumes, test_end + gap_tr)
|
| 122 |
+
right_train_end = n_volumes
|
| 123 |
+
|
| 124 |
+
n_train = (left_train_end - left_train_start) + (right_train_end - right_train_start)
|
| 125 |
+
n_gap = (test_start - left_train_end) + (right_train_start - test_end)
|
| 126 |
+
|
| 127 |
+
if n_train < min_train_volumes:
|
| 128 |
+
return None, "insufficient_train_volumes_after_gap"
|
| 129 |
+
|
| 130 |
+
if n_test < min_test_volumes:
|
| 131 |
+
return None, "insufficient_test_volumes"
|
| 132 |
+
|
| 133 |
+
if left_train_end < left_train_start or right_train_end < right_train_start:
|
| 134 |
+
return None, "invalid_train_segment_bounds"
|
| 135 |
+
|
| 136 |
+
bounds = {
|
| 137 |
+
"test_start_tr": int(test_start),
|
| 138 |
+
"test_end_tr_exclusive": int(test_end),
|
| 139 |
+
"train_left_start_tr": int(left_train_start),
|
| 140 |
+
"train_left_end_tr_exclusive": int(left_train_end),
|
| 141 |
+
"train_right_start_tr": int(right_train_start),
|
| 142 |
+
"train_right_end_tr_exclusive": int(right_train_end),
|
| 143 |
+
"n_train_volumes": int(n_train),
|
| 144 |
+
"n_test_volumes": int(n_test),
|
| 145 |
+
"n_gap_excluded_volumes": int(n_gap),
|
| 146 |
+
}
|
| 147 |
+
return bounds, None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def build_within_run_blocked_splits(
|
| 151 |
+
manifest_df: pd.DataFrame,
|
| 152 |
+
test_fraction: float = 0.2,
|
| 153 |
+
gap_tr: int = 8,
|
| 154 |
+
min_train_volumes: int = 40,
|
| 155 |
+
min_test_volumes: int = 20,
|
| 156 |
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
| 157 |
+
"""Build Protocol B blocked temporal splits for each subject-run."""
|
| 158 |
+
if manifest_df.empty:
|
| 159 |
+
return pd.DataFrame(), pd.DataFrame()
|
| 160 |
+
|
| 161 |
+
required_columns = {"subject", "run", "n_volumes"}
|
| 162 |
+
missing = required_columns.difference(manifest_df.columns)
|
| 163 |
+
if missing:
|
| 164 |
+
raise ValueError(f"Manifest missing required columns: {sorted(missing)}")
|
| 165 |
+
|
| 166 |
+
split_rows: list[WithinRunBlockedSplit] = []
|
| 167 |
+
skipped_rows: list[WithinRunSkipped] = []
|
| 168 |
+
|
| 169 |
+
for row in manifest_df.itertuples(index=False):
|
| 170 |
+
subject = str(getattr(row, "subject"))
|
| 171 |
+
run = int(getattr(row, "run"))
|
| 172 |
+
n_volumes = int(getattr(row, "n_volumes"))
|
| 173 |
+
|
| 174 |
+
bounds, reason = _compute_within_run_blocked_bounds(
|
| 175 |
+
n_volumes=n_volumes,
|
| 176 |
+
test_fraction=test_fraction,
|
| 177 |
+
gap_tr=gap_tr,
|
| 178 |
+
min_train_volumes=min_train_volumes,
|
| 179 |
+
min_test_volumes=min_test_volumes,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
if bounds is None:
|
| 183 |
+
skipped_rows.append(
|
| 184 |
+
WithinRunSkipped(
|
| 185 |
+
subject=subject,
|
| 186 |
+
run=run,
|
| 187 |
+
n_volumes=n_volumes,
|
| 188 |
+
reason=str(reason),
|
| 189 |
+
)
|
| 190 |
+
)
|
| 191 |
+
continue
|
| 192 |
+
|
| 193 |
+
split_rows.append(
|
| 194 |
+
WithinRunBlockedSplit(
|
| 195 |
+
protocol="B_within_run_blocked",
|
| 196 |
+
subject=subject,
|
| 197 |
+
run=run,
|
| 198 |
+
split_id=f"{subject}_run{run}_blocked",
|
| 199 |
+
n_volumes=n_volumes,
|
| 200 |
+
test_start_tr=bounds["test_start_tr"],
|
| 201 |
+
test_end_tr_exclusive=bounds["test_end_tr_exclusive"],
|
| 202 |
+
gap_tr=int(gap_tr),
|
| 203 |
+
train_left_start_tr=bounds["train_left_start_tr"],
|
| 204 |
+
train_left_end_tr_exclusive=bounds["train_left_end_tr_exclusive"],
|
| 205 |
+
train_right_start_tr=bounds["train_right_start_tr"],
|
| 206 |
+
train_right_end_tr_exclusive=bounds["train_right_end_tr_exclusive"],
|
| 207 |
+
n_train_volumes=bounds["n_train_volumes"],
|
| 208 |
+
n_test_volumes=bounds["n_test_volumes"],
|
| 209 |
+
n_gap_excluded_volumes=bounds["n_gap_excluded_volumes"],
|
| 210 |
+
)
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
split_df = pd.DataFrame([asdict(row) for row in split_rows])
|
| 214 |
+
skipped_df = pd.DataFrame([asdict(row) for row in skipped_rows])
|
| 215 |
+
|
| 216 |
+
if not split_df.empty:
|
| 217 |
+
split_df = split_df.sort_values(["subject", "run"]).reset_index(drop=True)
|
| 218 |
+
if not skipped_df.empty:
|
| 219 |
+
skipped_df = skipped_df.sort_values(["subject", "run"]).reset_index(drop=True)
|
| 220 |
+
|
| 221 |
+
return split_df, skipped_df
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def summarize_split_counts(
|
| 225 |
+
manifest_df: pd.DataFrame,
|
| 226 |
+
cross_run_df: pd.DataFrame,
|
| 227 |
+
within_run_df: pd.DataFrame,
|
| 228 |
+
within_run_skipped_df: pd.DataFrame,
|
| 229 |
+
) -> dict[str, Any]:
|
| 230 |
+
"""Return compact split summary for reporting JSON outputs."""
|
| 231 |
+
return {
|
| 232 |
+
"n_manifest_rows": int(len(manifest_df)),
|
| 233 |
+
"n_subjects_manifest": int(manifest_df["subject"].nunique()) if not manifest_df.empty else 0,
|
| 234 |
+
"n_protocol_a_folds": int(len(cross_run_df)),
|
| 235 |
+
"n_protocol_b_splits": int(len(within_run_df)),
|
| 236 |
+
"n_protocol_b_skipped": int(len(within_run_skipped_df)),
|
| 237 |
+
}
|
code/a1_pipeline/targets.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Target-region preparation and preservation QC for core language ROIs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import nibabel as nib
|
| 10 |
+
from nibabel.processing import resample_from_to
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
CORE_ROI_NAMES: tuple[str, ...] = (
|
| 15 |
+
"TP",
|
| 16 |
+
"aSTS",
|
| 17 |
+
"pSTS",
|
| 18 |
+
"BA44",
|
| 19 |
+
"BA45",
|
| 20 |
+
"BA47",
|
| 21 |
+
"AG_TPJ",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass(frozen=True)
|
| 26 |
+
class CoreROICoverageRow:
|
| 27 |
+
"""Coverage diagnostics for one subject-run and one core ROI."""
|
| 28 |
+
|
| 29 |
+
subject: str
|
| 30 |
+
run: int
|
| 31 |
+
roi_name: str
|
| 32 |
+
pre_mask_voxels: int
|
| 33 |
+
post_mask_voxels: int
|
| 34 |
+
retention_ratio: float
|
| 35 |
+
centroid_shift_mm: float | None
|
| 36 |
+
min_voxels_required: int
|
| 37 |
+
passed: bool
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _resample_binary_roi_to_reference(
|
| 41 |
+
roi_img: nib.Nifti1Image,
|
| 42 |
+
reference_img: nib.Nifti1Image,
|
| 43 |
+
) -> np.ndarray:
|
| 44 |
+
resampled = resample_from_to(
|
| 45 |
+
roi_img,
|
| 46 |
+
(reference_img.shape, reference_img.affine),
|
| 47 |
+
order=0,
|
| 48 |
+
)
|
| 49 |
+
return resampled.get_fdata() > 0.5
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _compute_centroid_mm(mask_bool: np.ndarray, affine: np.ndarray) -> np.ndarray | None:
|
| 53 |
+
indices = np.argwhere(mask_bool)
|
| 54 |
+
if indices.size == 0:
|
| 55 |
+
return None
|
| 56 |
+
points_mm = nib.affines.apply_affine(affine, indices)
|
| 57 |
+
return np.mean(points_mm, axis=0)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def load_core_roi_masks(
|
| 61 |
+
roi_mask_dir: Path,
|
| 62 |
+
reference_img: nib.Nifti1Image,
|
| 63 |
+
) -> tuple[dict[str, np.ndarray], dict[str, int]]:
|
| 64 |
+
"""Load and resample all core ROI masks into reference analysis grid."""
|
| 65 |
+
roi_masks: dict[str, np.ndarray] = {}
|
| 66 |
+
roi_voxel_counts: dict[str, int] = {}
|
| 67 |
+
|
| 68 |
+
for roi_name in CORE_ROI_NAMES:
|
| 69 |
+
roi_path = roi_mask_dir / f"{roi_name}.nii.gz"
|
| 70 |
+
if not roi_path.exists():
|
| 71 |
+
raise FileNotFoundError(f"Missing core ROI mask: {roi_path}")
|
| 72 |
+
|
| 73 |
+
roi_img = nib.load(str(roi_path))
|
| 74 |
+
roi_bool = _resample_binary_roi_to_reference(roi_img=roi_img, reference_img=reference_img)
|
| 75 |
+
|
| 76 |
+
roi_masks[roi_name] = roi_bool
|
| 77 |
+
roi_voxel_counts[roi_name] = int(roi_bool.sum())
|
| 78 |
+
|
| 79 |
+
return roi_masks, roi_voxel_counts
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def evaluate_core_roi_preservation(
|
| 83 |
+
manifest_df: pd.DataFrame,
|
| 84 |
+
run_mask_map: dict[tuple[str, int], np.ndarray],
|
| 85 |
+
core_roi_masks: dict[str, np.ndarray],
|
| 86 |
+
reference_affine: np.ndarray,
|
| 87 |
+
min_voxels_required: int,
|
| 88 |
+
) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, Any]]:
|
| 89 |
+
"""Evaluate whether each core ROI is preserved for every subject-run."""
|
| 90 |
+
rows: list[CoreROICoverageRow] = []
|
| 91 |
+
|
| 92 |
+
roi_centroids_pre: dict[str, np.ndarray | None] = {
|
| 93 |
+
roi_name: _compute_centroid_mm(mask_bool=mask_bool, affine=reference_affine)
|
| 94 |
+
for roi_name, mask_bool in core_roi_masks.items()
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
for row in manifest_df.itertuples(index=False):
|
| 98 |
+
subject = str(getattr(row, "subject"))
|
| 99 |
+
run = int(getattr(row, "run"))
|
| 100 |
+
run_key = (subject, run)
|
| 101 |
+
|
| 102 |
+
run_mask = run_mask_map.get(run_key)
|
| 103 |
+
if run_mask is None:
|
| 104 |
+
for roi_name, roi_mask in core_roi_masks.items():
|
| 105 |
+
rows.append(
|
| 106 |
+
CoreROICoverageRow(
|
| 107 |
+
subject=subject,
|
| 108 |
+
run=run,
|
| 109 |
+
roi_name=roi_name,
|
| 110 |
+
pre_mask_voxels=int(roi_mask.sum()),
|
| 111 |
+
post_mask_voxels=0,
|
| 112 |
+
retention_ratio=0.0,
|
| 113 |
+
centroid_shift_mm=None,
|
| 114 |
+
min_voxels_required=min_voxels_required,
|
| 115 |
+
passed=False,
|
| 116 |
+
)
|
| 117 |
+
)
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
for roi_name, roi_mask in core_roi_masks.items():
|
| 121 |
+
pre_count = int(roi_mask.sum())
|
| 122 |
+
post_mask = roi_mask & run_mask
|
| 123 |
+
post_count = int(post_mask.sum())
|
| 124 |
+
|
| 125 |
+
retention_ratio = float(post_count / pre_count) if pre_count > 0 else 0.0
|
| 126 |
+
|
| 127 |
+
centroid_pre = roi_centroids_pre.get(roi_name)
|
| 128 |
+
centroid_post = _compute_centroid_mm(mask_bool=post_mask, affine=reference_affine)
|
| 129 |
+
if centroid_pre is not None and centroid_post is not None:
|
| 130 |
+
centroid_shift = float(np.linalg.norm(centroid_post - centroid_pre))
|
| 131 |
+
else:
|
| 132 |
+
centroid_shift = None
|
| 133 |
+
|
| 134 |
+
passed = bool(pre_count > 0 and post_count >= min_voxels_required)
|
| 135 |
+
|
| 136 |
+
rows.append(
|
| 137 |
+
CoreROICoverageRow(
|
| 138 |
+
subject=subject,
|
| 139 |
+
run=run,
|
| 140 |
+
roi_name=roi_name,
|
| 141 |
+
pre_mask_voxels=pre_count,
|
| 142 |
+
post_mask_voxels=post_count,
|
| 143 |
+
retention_ratio=retention_ratio,
|
| 144 |
+
centroid_shift_mm=centroid_shift,
|
| 145 |
+
min_voxels_required=min_voxels_required,
|
| 146 |
+
passed=passed,
|
| 147 |
+
)
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
coverage_df = pd.DataFrame([asdict(row) for row in rows])
|
| 151 |
+
if not coverage_df.empty:
|
| 152 |
+
coverage_df = coverage_df.sort_values(["subject", "run", "roi_name"]).reset_index(drop=True)
|
| 153 |
+
|
| 154 |
+
failures_df = coverage_df[coverage_df["passed"] == False].copy() if not coverage_df.empty else pd.DataFrame()
|
| 155 |
+
if not failures_df.empty:
|
| 156 |
+
failures_df = failures_df.sort_values(["subject", "run", "roi_name"]).reset_index(drop=True)
|
| 157 |
+
|
| 158 |
+
per_run_failures = 0
|
| 159 |
+
if not failures_df.empty:
|
| 160 |
+
per_run_failures = int(failures_df[["subject", "run"]].drop_duplicates().shape[0])
|
| 161 |
+
|
| 162 |
+
qc_summary: dict[str, Any] = {
|
| 163 |
+
"n_coverage_rows": int(len(coverage_df)),
|
| 164 |
+
"n_fail_rows": int(len(failures_df)),
|
| 165 |
+
"n_subject_run_failures": per_run_failures,
|
| 166 |
+
"min_voxels_required": int(min_voxels_required),
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if not coverage_df.empty:
|
| 170 |
+
qc_summary["retention_ratio_min"] = float(coverage_df["retention_ratio"].min())
|
| 171 |
+
qc_summary["retention_ratio_max"] = float(coverage_df["retention_ratio"].max())
|
| 172 |
+
|
| 173 |
+
return coverage_df, failures_df, qc_summary
|
code/assets/participant_run_info.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"sub-01": {"run1": "single_m", "run2": "mixed_f", "run3": "mixed_m", "run4": "single_f"},
|
| 3 |
+
"sub-02": {"run1": "single_m", "run2": "single_f", "run3": "mixed_f", "run4": "mixed_m"},
|
| 4 |
+
"sub-03": {"run1": "single_f", "run2": "single_m", "run3": "mixed_f", "run4": "mixed_m"},
|
| 5 |
+
"sub-04": {"run1": "mixed_f", "run2": "single_f", "run3": "mixed_m", "run4": "single_m"},
|
| 6 |
+
"sub-05": {"run1": "mixed_m", "run2": "single_m", "run3": "single_f", "run4": "mixed_f"},
|
| 7 |
+
"sub-06": {"run1": "mixed_m", "run2": "single_f", "run3": "mixed_f", "run4": "single_m"},
|
| 8 |
+
"sub-07": {"run1": "single_m", "run2": "mixed_f", "run3": "mixed_m", "run4": "single_f"},
|
| 9 |
+
"sub-08": {"run1": "single_f", "run2": "mixed_f", "run3": "single_m", "run4": "mixed_m"},
|
| 10 |
+
"sub-09": {"run1": "single_m", "run2": "single_f", "run3": "mixed_f", "run4": "mixed_m"},
|
| 11 |
+
"sub-10": {"run1": "single_m", "run2": "single_f", "run3": "mixed_m", "run4": "mixed_f"},
|
| 12 |
+
"sub-11": {"run1": "mixed_m", "run2": "single_m", "run3": "mixed_f", "run4": "single_f"},
|
| 13 |
+
"sub-12": {"run1": "mixed_m", "run2": "single_m", "run3": "mixed_f", "run4": "single_f"},
|
| 14 |
+
"sub-13": {"run1": "mixed_m", "run2": "mixed_f", "run3": "single_m", "run4": "single_f"},
|
| 15 |
+
"sub-14": {"run1": "mixed_f", "run2": "mixed_m", "run3": "single_f", "run4": "single_m"},
|
| 16 |
+
"sub-15": {"run1": "single_m", "run2": "single_f", "run3": "mixed_m", "run4": "mixed_f"},
|
| 17 |
+
"sub-16": {"run1": "single_f", "run2": "single_m", "run3": "mixed_f", "run4": "mixed_m"},
|
| 18 |
+
"sub-17": {"run1": "mixed_f", "run2": "single_f", "run3": "mixed_m", "run4": "single_m"},
|
| 19 |
+
"sub-18": {"run1": "single_f", "run2": "single_m", "run3": "mixed_m", "run4": "mixed_f"},
|
| 20 |
+
"sub-19": {"run1": "mixed_f", "run2": "single_m", "run3": "mixed_m", "run4": "single_f"},
|
| 21 |
+
"sub-20": {"run1": "single_m", "run2": "mixed_f", "run3": "single_f", "run4": "mixed_m"},
|
| 22 |
+
"sub-21": {"run1": "single_f", "run2": "mixed_m", "run3": "mixed_f", "run4": "single_m"},
|
| 23 |
+
"sub-22": {"run1": "mixed_f", "run2": "mixed_m", "run3": "single_f", "run4": "single_m"},
|
| 24 |
+
"sub-23": {"run1": "mixed_f", "run2": "single_f", "run3": "single_m", "run4": "mixed_m"},
|
| 25 |
+
"sub-24": {"run1": "mixed_m", "run2": "single_m", "run3": "single_f", "run4": "mixed_f"},
|
| 26 |
+
"sub-25": {"run1": "mixed_m", "run2": "mixed_f", "run3": "single_f", "run4": "single_m"},
|
| 27 |
+
"sub-26": {"run1": "mixed_f", "run2": "mixed_m", "run3": "single_f", "run4": "single_m"}
|
| 28 |
+
}
|
code/assets/roi_masks/AG_TPJ.nii.gz
ADDED
|
Binary file (2.51 kB). View file
|
|
|
code/assets/roi_masks/BA44.nii.gz
ADDED
|
Binary file (2.51 kB). View file
|
|
|
code/assets/roi_masks/BA45.nii.gz
ADDED
|
Binary file (2.48 kB). View file
|
|
|
code/assets/roi_masks/BA47.nii.gz
ADDED
|
Binary file (2.53 kB). View file
|
|
|
code/assets/roi_masks/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core ROI Mask Assets
|
| 2 |
+
|
| 3 |
+
This folder contains the 7 core ROI masks used by the A1 preservation gate:
|
| 4 |
+
|
| 5 |
+
- TP.nii.gz
|
| 6 |
+
- aSTS.nii.gz
|
| 7 |
+
- pSTS.nii.gz
|
| 8 |
+
- BA44.nii.gz
|
| 9 |
+
- BA45.nii.gz
|
| 10 |
+
- BA47.nii.gz
|
| 11 |
+
- AG_TPJ.nii.gz
|
| 12 |
+
|
| 13 |
+
These masks were copied from the upstream llms_brain_lateralization repository to keep
|
| 14 |
+
runtime dependencies modular and local to the code/ tree.
|
| 15 |
+
|
| 16 |
+
If you need to override these masks, pass --roi-mask-dir to code/run_a1_bootstrap.py.
|
code/assets/roi_masks/TP.nii.gz
ADDED
|
Binary file (2.51 kB). View file
|
|
|
code/assets/roi_masks/aSTS.nii.gz
ADDED
|
Binary file (2.51 kB). View file
|
|
|
code/assets/roi_masks/pSTS.nii.gz
ADDED
|
Binary file (2.51 kB). View file
|
|
|
code/config/model_profiles.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": 1,
|
| 3 |
+
"active_profile": "current",
|
| 4 |
+
"profiles": {
|
| 5 |
+
"current": {
|
| 6 |
+
"description": "Current A1 text model set (Qwen3 0.6B + Llama 3.2 1B + LLaDA 8B Instruct)",
|
| 7 |
+
"model_ids": [
|
| 8 |
+
"Qwen/Qwen3-0.6B",
|
| 9 |
+
"meta-llama/Llama-3.2-1B",
|
| 10 |
+
"GSAI-ML/LLaDA-8B-Instruct"
|
| 11 |
+
]
|
| 12 |
+
},
|
| 13 |
+
"future_scaleup": {
|
| 14 |
+
"description": "Planned scale-up set (Qwen3 4B + Llama 3.2 3B + LLaDA 8B Instruct)",
|
| 15 |
+
"model_ids": [
|
| 16 |
+
"Qwen/Qwen3-4B",
|
| 17 |
+
"meta-llama/Llama-3.2-3B",
|
| 18 |
+
"GSAI-ML/LLaDA-8B-Instruct"
|
| 19 |
+
]
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"families": {
|
| 23 |
+
"qwen3": {
|
| 24 |
+
"current": "Qwen/Qwen3-0.6B",
|
| 25 |
+
"future": "Qwen/Qwen3-4B"
|
| 26 |
+
},
|
| 27 |
+
"llama32_text": {
|
| 28 |
+
"current": "meta-llama/Llama-3.2-1B",
|
| 29 |
+
"future": "meta-llama/Llama-3.2-3B"
|
| 30 |
+
},
|
| 31 |
+
"llada_text": {
|
| 32 |
+
"current": "GSAI-ML/LLaDA-8B-Instruct",
|
| 33 |
+
"future": "GSAI-ML/LLaDA-8B-Instruct"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
code/run_a1_visualize.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Visualize A1 fit outputs for core ROI evaluation."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import matplotlib
|
| 10 |
+
|
| 11 |
+
matplotlib.use("Agg")
|
| 12 |
+
|
| 13 |
+
import matplotlib.pyplot as plt
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import seaborn as sns
|
| 16 |
+
|
| 17 |
+
from a1_pipeline.io_utils import ensure_directory
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
METRIC_ALIAS_MAP: dict[str, str] = {
|
| 21 |
+
"2v2": "mean_2v2_accuracy",
|
| 22 |
+
"2v2_accuracy": "mean_2v2_accuracy",
|
| 23 |
+
"two_v_two_accuracy": "mean_2v2_accuracy",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _resolve_metric_column(metric: str) -> str:
|
| 28 |
+
token = str(metric).strip().lower()
|
| 29 |
+
return METRIC_ALIAS_MAP.get(token, str(metric))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _build_parser() -> argparse.ArgumentParser:
|
| 33 |
+
parser = argparse.ArgumentParser(description="Visualize A1 core ROI fit results")
|
| 34 |
+
parser.add_argument(
|
| 35 |
+
"--fit-output-dir",
|
| 36 |
+
type=str,
|
| 37 |
+
default="/home/mohith/ds005345/outputs/a1_bootstrap/fit_results/Qwen_Qwen3-0.6B",
|
| 38 |
+
help="Directory containing run_a1_fit.py outputs",
|
| 39 |
+
)
|
| 40 |
+
parser.add_argument(
|
| 41 |
+
"--output-dir",
|
| 42 |
+
type=str,
|
| 43 |
+
default=None,
|
| 44 |
+
help="Plot output directory (default: <fit-output-dir>/plots)",
|
| 45 |
+
)
|
| 46 |
+
parser.add_argument(
|
| 47 |
+
"--metric",
|
| 48 |
+
type=str,
|
| 49 |
+
default="mean_corr",
|
| 50 |
+
choices=[
|
| 51 |
+
"mean_corr",
|
| 52 |
+
"mean_r2",
|
| 53 |
+
"mean_2v2_accuracy",
|
| 54 |
+
"2v2",
|
| 55 |
+
"2v2_accuracy",
|
| 56 |
+
"two_v_two_accuracy",
|
| 57 |
+
],
|
| 58 |
+
help="Metric used for plots",
|
| 59 |
+
)
|
| 60 |
+
return parser
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _check_required_files(fit_output_dir: Path) -> tuple[Path, Path]:
|
| 64 |
+
layer_summary_path = fit_output_dir / "core_roi_layer_summary.csv"
|
| 65 |
+
best_summary_path = fit_output_dir / "core_roi_best_layer_summary.csv"
|
| 66 |
+
|
| 67 |
+
missing = [path for path in [layer_summary_path, best_summary_path] if not path.exists()]
|
| 68 |
+
if missing:
|
| 69 |
+
raise FileNotFoundError("Missing fit summary files: " + ", ".join(str(path) for path in missing))
|
| 70 |
+
|
| 71 |
+
return layer_summary_path, best_summary_path
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _plot_protocol_heatmap(
|
| 75 |
+
layer_summary_df: pd.DataFrame,
|
| 76 |
+
protocol: str,
|
| 77 |
+
metric: str,
|
| 78 |
+
output_path: Path,
|
| 79 |
+
) -> None:
|
| 80 |
+
protocol_df = layer_summary_df[layer_summary_df["protocol"] == protocol].copy()
|
| 81 |
+
if protocol_df.empty:
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
pivot_df = protocol_df.pivot(index="roi_name", columns="layer_idx", values=metric)
|
| 85 |
+
pivot_df = pivot_df.sort_index()
|
| 86 |
+
|
| 87 |
+
plt.figure(figsize=(max(8, 0.4 * len(pivot_df.columns)), 4.8))
|
| 88 |
+
sns.heatmap(pivot_df, cmap="viridis", annot=False)
|
| 89 |
+
plt.title(f"{protocol} {metric} by ROI and Layer")
|
| 90 |
+
plt.xlabel("Layer")
|
| 91 |
+
plt.ylabel("Core ROI")
|
| 92 |
+
plt.tight_layout()
|
| 93 |
+
plt.savefig(output_path, dpi=180)
|
| 94 |
+
plt.close()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _plot_best_layer_bar(
|
| 98 |
+
best_df: pd.DataFrame,
|
| 99 |
+
metric: str,
|
| 100 |
+
output_path: Path,
|
| 101 |
+
) -> None:
|
| 102 |
+
if best_df.empty:
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
chart_df = best_df.copy()
|
| 106 |
+
chart_df["label"] = chart_df["roi_name"] + "\nL" + chart_df["layer_idx"].astype(int).astype(str)
|
| 107 |
+
|
| 108 |
+
plt.figure(figsize=(11, 5))
|
| 109 |
+
sns.barplot(data=chart_df, x="label", y=metric, hue="protocol")
|
| 110 |
+
plt.title(f"Best Layer per ROI ({metric})")
|
| 111 |
+
plt.xlabel("ROI and selected layer")
|
| 112 |
+
plt.ylabel(metric)
|
| 113 |
+
plt.xticks(rotation=0)
|
| 114 |
+
plt.tight_layout()
|
| 115 |
+
plt.savefig(output_path, dpi=180)
|
| 116 |
+
plt.close()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _plot_protocol_layer_curve(
|
| 120 |
+
layer_summary_df: pd.DataFrame,
|
| 121 |
+
metric: str,
|
| 122 |
+
output_path: Path,
|
| 123 |
+
) -> None:
|
| 124 |
+
if layer_summary_df.empty:
|
| 125 |
+
return
|
| 126 |
+
|
| 127 |
+
curve_df = (
|
| 128 |
+
layer_summary_df.groupby(["protocol", "layer_idx"], as_index=False)[metric]
|
| 129 |
+
.mean()
|
| 130 |
+
.sort_values(["protocol", "layer_idx"])
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
plt.figure(figsize=(10, 4.8))
|
| 134 |
+
sns.lineplot(data=curve_df, x="layer_idx", y=metric, hue="protocol", marker="o")
|
| 135 |
+
plt.title(f"Average Core ROI {metric} by Layer")
|
| 136 |
+
plt.xlabel("Layer")
|
| 137 |
+
plt.ylabel(metric)
|
| 138 |
+
plt.tight_layout()
|
| 139 |
+
plt.savefig(output_path, dpi=180)
|
| 140 |
+
plt.close()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def main() -> None:
|
| 144 |
+
parser = _build_parser()
|
| 145 |
+
args = parser.parse_args()
|
| 146 |
+
|
| 147 |
+
fit_output_dir = Path(args.fit_output_dir).resolve()
|
| 148 |
+
layer_summary_path, best_summary_path = _check_required_files(fit_output_dir=fit_output_dir)
|
| 149 |
+
|
| 150 |
+
plot_output_dir = Path(args.output_dir).resolve() if args.output_dir else fit_output_dir / "plots"
|
| 151 |
+
ensure_directory(plot_output_dir)
|
| 152 |
+
|
| 153 |
+
layer_summary_df = pd.read_csv(layer_summary_path)
|
| 154 |
+
best_summary_df = pd.read_csv(best_summary_path)
|
| 155 |
+
|
| 156 |
+
metric = _resolve_metric_column(str(args.metric))
|
| 157 |
+
if metric not in layer_summary_df.columns:
|
| 158 |
+
raise ValueError(
|
| 159 |
+
f"Metric column '{metric}' not found in {layer_summary_path}. "
|
| 160 |
+
f"Available columns: {sorted(layer_summary_df.columns.tolist())}"
|
| 161 |
+
)
|
| 162 |
+
if metric not in best_summary_df.columns:
|
| 163 |
+
raise ValueError(
|
| 164 |
+
f"Metric column '{metric}' not found in {best_summary_path}. "
|
| 165 |
+
f"Available columns: {sorted(best_summary_df.columns.tolist())}"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
for protocol in sorted(set(layer_summary_df["protocol"].tolist())):
|
| 169 |
+
heatmap_path = plot_output_dir / f"{protocol}_{metric}_heatmap.png"
|
| 170 |
+
_plot_protocol_heatmap(
|
| 171 |
+
layer_summary_df=layer_summary_df,
|
| 172 |
+
protocol=protocol,
|
| 173 |
+
metric=metric,
|
| 174 |
+
output_path=heatmap_path,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
_plot_best_layer_bar(
|
| 178 |
+
best_df=best_summary_df,
|
| 179 |
+
metric=metric,
|
| 180 |
+
output_path=plot_output_dir / f"best_layer_{metric}_bar.png",
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
_plot_protocol_layer_curve(
|
| 184 |
+
layer_summary_df=layer_summary_df,
|
| 185 |
+
metric=metric,
|
| 186 |
+
output_path=plot_output_dir / f"protocol_layer_curve_{metric}.png",
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
print("=" * 72)
|
| 190 |
+
print("A1 visualization complete")
|
| 191 |
+
print(f"Fit output directory: {fit_output_dir}")
|
| 192 |
+
print(f"Plot output directory: {plot_output_dir}")
|
| 193 |
+
print(f"Metric: {metric}")
|
| 194 |
+
print("=" * 72)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy==2.2.6
|
| 2 |
+
pandas==2.3.3
|
| 3 |
+
scikit-learn==1.7.2
|
| 4 |
+
torch==2.5.1
|
| 5 |
+
torchvision==0.20.1
|
| 6 |
+
torchaudio==2.5.1
|
| 7 |
+
transformers==5.5.3
|
| 8 |
+
matplotlib==3.10.8
|
| 9 |
+
seaborn==0.13.2
|
| 10 |
+
scipy==1.15.3
|
| 11 |
+
nibabel==5.4.2
|
| 12 |
+
tqdm==4.67.3
|
| 13 |
+
requests==2.33.1
|