Mohith202 commited on
Commit
8c5a642
·
1 Parent(s): 33c7212

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 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
- title: Csai
3
- emoji: 🐢
4
- colorFrom: purple
5
- colorTo: pink
6
- sdk: gradio
7
- sdk_version: 6.13.0
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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